Set是一种不允许元素重复的集合类型,它和数组、列表最直观的区别就是自动去掉重复项。这种能力看起来简单,但具体实现和性能表现取决于底层数据结构。在讨论交集和并集之前,有必要先弄清Set为什么能去重,以及不同的Set实现在运算时有哪些差异。

Set去重的底层原理与实现差异
以Java中的HashSet为例,它内部实际维护了一个HashMap。当调用add方法时,元素会被当成Map的键,值则固定为一个占位对象。HashMap的键不允许重复,其判断依据是先比较哈希值,再通过equals方法确认是否真正相等。因此,哈希表结构让成员检查、插入和删除的平均时间复杂度都维持在O(1)。Python的set也是基于哈希表实现,只不过内部做了更多优化,比如针对小规模集合直接使用线性表避免哈希开销。
对于自定义对象,如果只重写equals而不重写hashCode,就可能出现两个逻辑相等的对象同时存在于Set中。原因是哈希值不同导致它们被分配到不同桶,跳过了相等判断。下面这段Java代码展示了正确的重写方式:
class User {
private String name;
private int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (!(obj instanceof User)) return false;
User other = (User) obj;
return age == other.age && name.equals(other.name);
}
@Override
public int hashCode() {
return Objects.hash(name, age);
}
}
除了哈希实现,Java还提供TreeSet,它基于红黑树,元素必须实现Comparable接口或在构造时传入Comparator。TreeSet去重依赖比较结果,若compareTo返回0则视为相同元素,因此不强制要求重写hashCode和equals。但它的插入和查找复杂度是O(log n),并且可以按照排序规则遍历元素。选择哪种实现取决于是否需要有序性以及数据量大小。
Go语言没有内置Set,通常使用map[T]struct{}来模拟。虽然功能类似,但交集并集需要手动编写循环。不过这种方式可以更灵活地控制内存占用,因为空结构体不分配额外空间。
主流语言中的交集并集操作
Python的Set运算最为简洁,内置了数学集合符号和方法两种风格。使用运算符&、|、-和^时,可读性更高;使用方法intersection、union、difference和symmetric_difference则更明确。下面的例子覆盖了常用操作:
a = {1, 2, 3, 4}
b = {3, 4, 5, 6}
# 交集
print(a & b) # {3, 4}
print(a.intersection(b)) # {3, 4}
# 并集
print(a | b) # {1, 2, 3, 4, 5, 6}
print(a.union(b)) # {1, 2, 3, 4, 5, 6}
# 差集
print(a - b) # {1, 2}
print(a.difference(b)) # {1, 2}
需要注意的是,Python的运算符和方法都不会修改原集合,而是返回新集合。如果数据量特别大,这个行为会带来额外的内存分配。对于需要原地更新的场景,可以使用intersection_update、difference_update等带update后缀的方法,它们会直接修改调用者。
Java的Set接口没有直接提供返回新集合的交集方法,retainAll和addAll会修改当前集合。因此通常先通过构造器复制一份,再执行运算。示例代码如下:
Set<Integer> setA = new HashSet<>(Arrays.asList(1, 2, 3, 4)); Set<Integer> setB = new HashSet<>(Arrays.asList(3, 4, 5, 6)); // 交集 Set<Integer> intersection = new HashSet<>(setA); intersection.retainAll(setB); // 并集 Set<Integer> union = new HashSet<>(setA); union.addAll(setB); System.out.println(intersection); // [3, 4] System.out.println(union); // [1, 2, 3, 4, 5, 6]
如果经常需要做集合运算,可以考虑使用第三方库,例如Guava提供了Sets.intersection、Sets.union等静态方法,返回不可变的视图,避免了频繁复制。JavaScript的Set在ES6后也支持add、has、delete等操作,但交集并集没有内置方法,需要借助展开运算符和数组方法,例如new Set([...a].filter(x => b.has(x)))。这种写法的效率不如原生实现,但应对前端常规数据量已经足够。
使用Set去重与集合运算时的性能注意点
去重操作本身很快,但很多性能问题来自使用方式。例如在循环中不断调用union或addAll,每次都会创建一个新的Set对象,导致时间复杂度和内存占用成倍增加。如果目标是合并多个集合,更好的做法是先创建一个容量足够的集合,再依次addAll。以Java为例,可以提前估算元素数量设置初始容量,减少哈希表扩容带来的开销。
另一个容易被忽略的问题是可变对象作为Set元素。如果元素在加入Set后其字段被修改,导致hashCode返回值变化,该元素可能无法被正确找到或删除,甚至造成内存泄漏。实践中应尽量使用不可变对象作为键或元素,比如Java中的String、Integer,或者把可变字段声明为final。
当集合规模较大且需要频繁求交集时,优先遍历较小的集合,并在较大集合中做查找。因为哈希查找是O(1),这样做可以将不必要的新对象分配降到最低。对于有序Set如TreeSet,交集可以借助双指针在线性时间内完成,但需要集合已经有序且支持按顺序迭代,实现起来也更复杂。实际项目中应根据数据分布、读多写少还是写多读少来选择合适的Set实现,而不是一律使用默认的HashSet。
综合来看,Set的去重、交集和并集操作在多数语言中都有成熟支持,理解底层机制能帮助我们在写业务代码时避开隐藏的性能坑。对于中小规模数据,优先选择语言内置的哈希Set;当需要有序遍历时再考虑TreeSet或有序集合变体。