拓冰建站拓冰建站
首页 / 资讯中心 / 正文

Java Go Bash Vue 四语言实现数组对称差集对比

说实话数组“找不同”这类题我看得太多了。不管是科班笔试、面试八股还是实际业务里对账、同步、过滤脏数据几乎每个搞开发的人都被它缠过。但有意思的是同样一个问题用 Java、Go、Bash、Vue 四种完全不同的技术栈去做思考路径和写法能差出十万八千里。这篇我就拿“两个数组之间找出独行侠只在一边出现的元素”这个经典场景做一次跨界实操对比。里面有完整代码、复杂度分析、坑点复盘也有我这些年实际踩过的雷。无论你主攻后端、搞运维脚本还是写前端页面这篇都能给你一点“原来还能这么搞”的参考。1. 先把“独行侠”这个问题定义清楚1.1 需求到底是什么别把对称差集和普通差集搞混先别急着写代码需求不定义清楚后面全白干。“两个数组间的独行侠”最常用的数学定义是对称差集元素要么只存在于数组A要么只存在于数组B但绝不能在两个数组里同时出现、并且出现次数要严格只算一次。举个例子A [1, 2, 3, 4, 5]B [3, 4, 5, 6, 7]按照对称差集答案应该是 [1, 2, 6, 7]。注意3、4、5 两边都有不算独行侠。但实际业务里还会出现几个变种变种一只要 A 有但 B 没有即 A - B差集答案 [1, 2]。变种二把重复元素也算进去A [1, 1, 2], B [1, 3]如果严格按“集合”算1 在两边的集合里都存在不算独行如果按“多重集”算两边各去掉一个 1 后A 还多一个 1这个 1 也算是多余元素。变种三元素是对象、结构体而不是基本类型比较方式完全不同。所以写代码之前先确认两个问题第一要不要去重第二比较的是值还是引用。很多人死在“去重”这件事上因为数组本身可能已经包含重复项而“找独行侠”天然要求结果里不能有重复。下面所有代码我都默认先对输入数组去重再找差异。这是最接近真实业务的做法也能避免很多面试官设下的坑。1.2 为什么这个“简单题”值得跨语言 PK有人会说这题太简单了两层 for 循环不就完了对两层循环能解但时间复杂度是 O(n*m)数据量一上来就完蛋。真正有价值的是哈希表解法时间 O(nm)空间 O(n)。而不同的语言对哈希表的表达方式完全不同Java 有现成的 HashSet、HashMap还有 Stream 流式 API写起来特别正统适合面试秀基本功。Go 没有 Set 类型只能用 map但它的 map 性能极强还能用泛型封装成通用工具适合工程化沉淀。Bash 是最容易被忽视的它没有真正的数组集合运算但靠 sort、uniq、comm 管道组合一行命令就能搞定同样的事尤其是在处理大文本文件时速度比任何脚本语言都快。Vue 呢它不是用来做算法题的语言但前端业务里经常遇到两个列表对比、筛选未选中的项、同步勾选状态这类需求用 Set 计算属性是最高效的方案。把这四个放一起对比本质是看同一道题在不同范式下的表达方式。对读者来说最大的价值不是背代码而是理解“每种语言擅长用什么数据结构解决什么问题”。2. Java 方案用 HashSet 写出最正统的解法2.1 传统写法手动创建两个 Set再求对称差Java 里做集合运算最正统的方式就是 HashSet。核心思路分三步把两个数组都丢进 Set 去重借助一个中间集合完成“并集减去交集”的运算最后把结果转回数组。import java.util.*; public class UniqueElementsFinder { public static ListInteger findUniqueElements(int[] arrA, int[] arrB) { SetInteger setA new HashSet(); SetInteger setB new HashSet(); for (int num : arrA) { setA.add(num); } for (int num : arrB) { setB.add(num); } // 对称差集(A - B) ∪ (B - A) SetInteger result new HashSet(setA); result.addAll(setB); // 并集 SetInteger intersection new HashSet(setA); intersection.retainAll(setB); // 交集 result.removeAll(intersection); // 并集去掉交集 return new ArrayList(result); } public static void main(String[] args) { int[] A {1, 2, 3, 4, 5}; int[] B {3, 4, 5, 6, 7}; System.out.println(findUniqueElements(A, B)); // 输出 [1, 2, 6, 7]顺序不保证 } }这里有一个很多人不注意的细节new HashSet(setA)这一步不能省。如果你直接用SetInteger result setA;后面result.removeAll(intersection)会把原始的 setA 也改掉。这是一种常见的变量别名问题特别容易在修改已有代码时踩坑。我在 code review 里见过好几回有人把原始数据集合弄丢了找半天才发现是这里出了问题。复杂度上遍历两个数组是 O(nm)求交并补都是 HashSet 的 O(n) 操作整体 O(nm)空间 O(nm)。对于 Java 面试来说这个解法已经能拿满分了。2.2 Java 8 写法用 Stream 一行流式实现如果你习惯函数式编程Java 8 的 Stream 可以玩出更短的代码。思路是把数组转 Stream过滤出“不在另一个 Set 中”的元素再拼接起来。import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; public class UniqueViaStream { public static ListInteger findUnique(int[] arrA, int[] arrB) { SetInteger setA Arrays.stream(arrA).boxed().collect(Collectors.toSet()); SetInteger setB Arrays.stream(arrB).boxed().collect(Collectors.toSet()); ListInteger onlyInA Arrays.stream(arrA) .boxed() .filter(num - !setB.contains(num)) .distinct() .collect(Collectors.toList()); ListInteger onlyInB Arrays.stream(arrB) .boxed() .filter(num - !setA.contains(num)) .distinct() .collect(Collectors.toList()); return Stream.concat(onlyInA.stream(), onlyInB.stream()) .collect(Collectors.toList()); } }这段代码的思路更直观分别找出 A 独有的和 B 独有的再拼起来。注意这里我加了distinct()因为如果输入数组本身有重复比如 A [1, 1, 2]过滤后会有两个 1加 distinct 才能保证结果唯一。不过说实话Stream 版本在性能上并不会比传统写法快自动装箱拆箱反而有额外开销。它最大的价值是表达清晰、声明式风格适合代码洁癖患者和面试时展示“我更现代”的人。传统写法适合性能敏感场景和复杂逻辑调试Stream 适合逻辑简单、可读性优先的场景。提示如果在 Java 面试里被问到“HashSet 底层原理”核心要答三点底层是 HashMapvalue 固定为一个常量对象依赖 hashCode() 定位桶equals() 解决冲突扩容阈值是 0.75扩容后重新散列。3. Go 方案map 加泛型工程化利器3.1 原生 map 实现直观、可控、无依赖Go 语言没有内置 Set但 map 完全可以承担这个角色。最简写法的核心就是map[int]struct{}用空结构体做 value 是为了零内存开销。别小看这个设计在 Go 社区里已经是通用惯例了。package main import fmt func findUnique(A, B []int) []int { onlyInA : make([]int, 0) onlyInB : make([]int, 0) setB : make(map[int]struct{}, len(B)) for _, v : range B { setB[v] struct{}{} } for _, v : range A { if _, ok : setB[v]; !ok { onlyInA append(onlyInA, v) } } setA : make(map[int]struct{}, len(A)) for _, v : range A { setA[v] struct{}{} } for _, v : range B { if _, ok : setA[v]; !ok { onlyInB append(onlyInB, v) } } return append(onlyInA, onlyInB...) } func main() { A : []int{1, 2, 3, 4, 5} B : []int{3, 4, 5, 6, 7} fmt.Println(findUnique(A, B)) }这段代码有个细节setB : make(map[int]struct{}, len(B))里的 len(B) 是预分配容量能够有效减少 map 扩容次数。大量并发场景下如果数据量是百万级这个预分配能带来肉眼可见的性能提升。输出顺序同样不保证。Go 的 map 遍历本身就是随机的如果你需要稳定有序输出必须用 sort.Ints 对结果排序否则两次运行的结果可能不一致。这一点在实际对接接口时极其重要我试过因为输出顺序不稳定导致下游做 diff 的测试脚本挂了。3.2 泛型封装一套代码兼容所有可比较类型Go 1.18 引入泛型后这类工具函数有了新的写法。以前想同时支持 []int、[]string、[]float64你得写三套几乎一样的代码或者用 interface{} 然后做类型断言又丑又不安全。现在用泛型一次搞定。package main import fmt type Ordered interface { ~int | ~int8 | ~int16 | ~int32 | ~int64 | ~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~float32 | ~float64 | ~string } func FindUnique[T Ordered](A, B []T) []T { setB : make(map[T]struct{}, len(B)) for _, v : range B { setB[v] struct{}{} } onlyInA : make([]T, 0, len(A)) seenA : make(map[T]struct{}, len(A)) for _, v : range A { seenA[v] struct{}{} if _, ok : setB[v]; !ok { onlyInA append(onlyInA, v) } } setA : make(map[T]struct{}, len(A)) for v : range seenA { setA[v] struct{}{} } onlyInB : make([]T, 0, len(B)) seenB : make(map[T]struct{}, len(B)) for _, v : range B { if _, ok : setA[v]; !ok { if _, dup : seenB[v]; !dup { onlyInB append(onlyInB, v) seenB[v] struct{}{} } } } return append(onlyInA, onlyInB...) } func main() { fmt.Println(FindUnique([]int{1, 2, 3}, []int{2, 3, 4})) fmt.Println(FindUnique([]string{a, b}, []string{b, c})) }这个版本我顺手处理了输入数组自身重复的问题只找 A 独有、B 独有时用 seenA、seenB 去重。这样比较稳妥。泛型版适合沉淀到公共工具库中因为你的项目里大概率不止一处需要找差集可能是用户 ID、订单号、SKU 编码一套泛型方法全都能用。这也是 Go 工程化思想的体现不追求一行秀技术追求复用和稳定。注意Go 的 map key 必须是可比较类型slice、map、function 不能直接当 key。如果元素是结构体需要确认所有字段都是可比较的或者用自定义的字符串拼接生成 key。4. Bash 方案被严重低估的一行流4.1 sort uniq comm文本界的瑞士军刀Bash 处理数组差异思路和 Java、Go 完全不同。Bash 的数组本质上是字符串列表最自然的方法就是“文本处理”排序、去重、比较。核心工具是sort、uniq和comm。先看最常用的 comm 方案#!/bin/bash A(1 2 3 4 5) B(3 4 5 6 7) # 先排序去重再交给 comm 比较 # -3 表示不显示“仅在第一个文件里有的行”不对来看清楚 comm -3 (printf %s\n ${A[]} | sort -u) (printf %s\n ${B[]} | sort -u)这里必须把 comm 的参数讲清楚因为它特别容易记混comm -1不显示 file1 独有的行comm -2不显示 file2 独有的行comm -3不显示两个文件都有的行即公共部分所以我们要找“两边各自的独行侠”应该用comm -3这样输出两列第一列是 A 独有第二列是 B 独有。如果只想要一列干净的合并结果再用-3去掉公共部分后把两列拼一起# 直接拿到合并后的独行侠列表 comm -3 (printf %s\n ${A[]} | sort -u) (printf %s\n ${B[]} | sort -u) | tr -d \t注意输出里每一行前面可能带制表符tr -d \t的作用就是删掉它。这里的 ( ... ) 语法叫进程替换是 Bash 的特性不能在 sh 或 dash 下运行。如果你的脚本是#!/bin/sh就得改成先写临时文件再比较。4.2 cat sort uniq -d 的思路用计数方式找“只有一份的”还有一种思路是把两个数组合并排序后统计出现次数出现次数为 1 的就是独行侠。用 uniq 的计数模式配合 grep 筛选也能实现。A(1 2 3 4 5) B(3 4 5 6 7) printf %s\n ${A[]} ${B[]} | sort | uniq -c | awk $1 1 {print $2}这里uniq -c给每行加上出现次数awk $1 1 {print $2}筛选出现次数为 1 的行。运行后直接得到1 2 6 7一行命令四个工具干净利落。这个写法的好处是逻辑极其直观而且天然处理了重复元素——如果一个元素在 A 中出现两次在 B 中出现一次那它的总计数是 3不会被误判为独行侠。不过要提醒一句如果数组里的元素本身就包含制表符或者空格比如hello world和hello用printf %s\n分行打印没有毛病但如果像上面那样用 awk 默认按空白分割hello world会被拆成两列。这种情况建议换用别的分隔符或者改用 Java/Go 来处理。Bash 在复杂数据上确实力不从心但在处理 ID 列表、IP 列表、文件名列表这种“一行一条”的数据时速度非常快而且不占内存。提示sort -u其实等价于sort | uniq。直接用sort -u会更快因为它在排序过程中就去重了。在处理十几万行的大文件时这个性能差异很明显。4.3 Bash 处理大文件的隐藏优势很多人都不知道Bash 管道流的处理方式决定了它几乎不占内存因为它是一条条数据流式处理的。Java 或 Go 需要把整个数组加载进内存Bash 的 sort 虽然会将数据写到临时文件但同一时刻驻留内存的数据量非常小。我实际处理过一个场景两个各含几十万行 IP 白名单的列表要找出两边差异。用 Java 写要编译、要配置堆内存用 Bash 一行命令几秒出结果。所以别瞧不起 Bash在日志分析、数据对账、部署脚本这些场景里它才是真正的效率之王。5. Vue 方案前端列表差集计算属性才是最适合的姿势5.1 用 Set filter 实现前端“独行侠”Vue 里做数组差集基本场景是这样的页面上有两个列表左边是“全部商品”右边是“已选商品”你想实时展示“未选商品”。或者你有两个接口返回的 ID 数组需要在前端合并去重后展示。Vue 本身不提供集合运算的 API但我们可以直接在计算属性里用 JavaScript 的 Set 来实现这是前端最顺手的方式。script setup import { ref, computed } from vue; const listA ref([1, 2, 3, 4, 5]); const listB ref([3, 4, 5, 6, 7]); const uniqueItems computed(() { const setA new Set(listA.value); const setB new Set(listB.value); const onlyInA listA.value.filter(item !setB.has(item)); const onlyInB listB.value.filter(item !setA.has(item)); // 可选合并结果 return [...new Set([...onlyInA, ...onlyInB])]; }); /script template ul li v-foritem in uniqueItems :keyitem{{ item }}/li /ul /template这里有几个细节值得注意第一我在 computed 里用了listA.value而不是直接用listA。因为 listA 是 ref计算属性会追踪.value的依赖。如果你写listA.filter它会报错因为 ref 对象没有 filter 方法。第二new Set([...onlyInA, ...onlyInB])是为了去掉合并时可能重复的元素。虽然理论上 onlyInA 和 onlyInB 不会有交集但保险起见还是套一层 Set。第三filter 方法在这里天然解决了数组初始有重复项的问题吗并没有。如果listA [1, 1, 2]onlyInA里会包含两个 1因为它们都不在 setB 里。所以合并时用...new Set(...)去重这一步不能省。5.2 对象数组和响应式性能问题更常见的业务场景是对象数组比如{id: 1, name: 商品A}。这时不能直接用 Set 存对象因为 Set 判断相等用的是引用两个内容相同但不是同一个引用的对象会被认为不同。正确做法是把对象映射成主键 ID 的集合再做过滤const selectedIds computed(() new Set(listB.value.map(item item.id))); const unselectedItems computed(() listA.value.filter(item !selectedIds.value.has(item.id)) );这里还有前端特有的性能问题。如果 listA 和 listB 都很大几百上千条数据filter 遍历一次是没问题的但如果你的 computed 里做得太重比如每次都要把几万条数据筛选、排序、重新生成新数组那在渲染时就会有卡顿感。Vue 的 computed 虽然有缓存但依赖的 listA、listB 一旦变化就要重新计算所以对于特别重的筛选逻辑可以考虑加watch 手动控制频率或者用shallowRef减少深层响应式代理的开销。我自己踩过一个坑用 reactive 包裹一个特别大的对象数组然后每次接口返回新数据都整体赋值结果页面渲染明显卡住。后来改成 shallowRef 存储数组并手动触发更新性能好了很多。对纯展示型列表不需要对象内部字段的深层响应式shallowRef 是完全够用的。5.3 v-for 的 :key 不能直接用索引最后说一个前端 “找完差异渲染时” 的低级坑:key千万别用数组下标。因为你要渲染的是动态筛选出来的列表数据一变顺序就变如果 key 是 indexVue 的 diff 算法会误判节点复用可能出现“明明数据对了界面却展示旧内容”的诡异 bug。上面的代码我用:keyitem如果 item 是基本类型没问题如果是对象就用item.id。提示Vue 面试里如果被问到“computed 和 watch 怎么选”数组筛选、集合运算这种依赖响应式数据自动更新的场景首选 computed。只有需要异步操作或者深度监听时才考虑 watch。6. 四大方案横向对比什么时候用哪个6.1 性能、代码量、适用场景对照表我整理了一份对比表方便大家直接选型维度JavaGoBashVue核心数据结构HashSet / HashMapmap[T]struct{}sort uniq commSet filter时间复杂度O(nm)O(nm)O(n log n m log m)O(nm)空间复杂度O(nm)O(nm)O(1) 流式临时文件除外O(nm)代码量核心逻辑约 15~25 行约 30~40 行1~2 行命令约 10~15 行处理超大数组中上受 JVM 堆限制最好内存可控最稳基本不占内存较差前端不建议海量结果顺序不保证不保证可排序字典序保持原数组顺序适用场景后端接口、批处理、面试微服务、工具库、高并发日志分析、文本对比、运维前端列表筛选、勾选联动这个表不是说要分个高下而是告诉你不同场景下的最优解。比如你要处理的是 100 万行的文本文件Bash 的 sort comm 几乎无敌如果是微服务里两个服务返回的 ID 列表要做对账Go 最合适如果是在后端接口里返回给前端展示Java 稳扎稳打如果这个差异结果要实时渲染成页面列表那只能在 Vue 里做前端计算。6.2 选型背后的核心判断逻辑我个人的选型原则是这样的第一看数据存在哪。数据在数据库/接口返回里处理后端逻辑直接返回结果那就是 Java/Go 的主场数据在日志文件里Bash 是最快路径数据已经在前端状态管理里就没必要传到后端再算一圈Vue 搞定。第二看数据量级。万级以内四种方案随便选性能差异可以忽略万级到百万级Bash 处理文件类数据最优Java/Go 处理程序内数组最优Vue 就有点吃力了建议后端算好再返回。第三看团队维护成本。如果团队里都是 Java 开发你非要用 Bash 一行流实现后续维护的人可能看不太懂反过来也一样。技术选型不只看性能也要看可维护性。7. 常见坑点汇总这些坑我都替你踩过了7.1 通用问题重复元素、顺序、类型在四种语言里都容易踩的坑有三个。第一个是重复元素。输入数组里如果有重复不做去重直接比较结果里会有重复值。Java 用 HashSet 或者 distinct() 解决Go 用 map 去重或额外 map 记录Bash 用 sort -uVue 用 new Set。这个我在面试中被考过好几次很多人一上来就写双层循环完全没考虑去重直接被扣分。第二个是输出顺序。如果不要求顺序Set/map 输出无序是正常现象但如果你要对接下游接口、做断言比较顺序不稳定就会坑到你。我的建议是Java 用ArrayList保持插入顺序但 Set 结果是哈希序需要稳定就手动排序。Go 用 sort 包排序。Bash 天然输出字典序反而最稳定。Vue 的 filter 保持原数组序。第三个是类型比较。Java 的 int 和 Integer 要用 equals 或 boxed()Go 的结构体比较需要字段可比较Bash 的所有内容都是字符串数字在比较时会有“1”和“01”不等价的问题Vue 的 Set 对 NaN 和 0/-0 有特殊处理需要注意。7.2 语言各自的隐形陷阱Java 方面最经典的坑是 Set 的 retainAll 和 removeAll 会修改原集合使用前必须 copy。另外Arrays.asList生成的 List 是固定长度不支持 add/remove如果你拿它去构造 Set 是安全的但如果直接对它调用 removeAll在某些实现下会抛 UnsupportedOperationException。Go 方面最容易犯的错是 nil map。var setB map[int]struct{}直接赋值会在运行时 panic必须用 make 初始化。另外并发读 map 且同时写 map 会直接崩溃如果这个函数将来会被并发调用记得加锁或改用 sync.Map。Bash 方面最大的坑就是进程替换(...)只能在 bash 下运行不能用在 dash/sh。还有数组里有空元素时${A[]}会保留空字符串但${A[*]}会合并行为不一致。如果你的数组元素是a b这种带空格的直接当字符串输出没问题但一进入管道就分裂了需要用 IFS 控制。Vue 方面响应式代理的坑最深。如果用 reactive 包裹数组Set 本身不是响应式的你直接set.add(x)不会触发视图更新。解决办法是让 Vue 知道你在修改要么把整个数组重新赋值要么用 ref 包裹让 .value 变化触发更新。这应该是 Vue 新手最容易困惑的地方我在实际开发里也被这个问题折磨过。7.3 面试怎么答一个“独行侠”的反问技巧如果你是在准备面试这道题其实很适合用来展示你的工程判断力。面试官问“找出两个数组的不同元素”你可以先反问一句“是只要 A 有 B 没有还是 A 有 B 没有和 B 有 A 没有都算需不需要去重数据量大吗”这三个反问本身就是在向面试官传递一个信息我写代码之前先分析需求边界不是一个无脑输出代码的工具人。然后再给出两层解法先讲最容易想到的双层循环 O(n*m)再讲哈希表优化 O(nm)。如果你面的是高级岗还可以主动提一句 B 树排序的做法只适合内存放不下的场景最终还是要看数据量和业务场景。这一连串下来面试官对你的印象绝对不只是“会写 Set”。8. 最后分享一个工程化经验围绕这个话题我最后想分享一个我实际项目里的体会这类“找不同”的需求往往不会只出现一次。一个系统里可能今天要找用户 ID 差异明天要找订单号差异后天要找 SKU 差异。所以不要每次都在业务代码里写一套 for 循环 Set 的临时逻辑值得抽出一个公共工具方法。Java 里可以放到CollectionUtils或者自定义的SetUtilsGo 里可以放在sliceutils包用泛型实现一个Diff函数Bash 场景直接沉淀成一个.sh脚本Vue 场景就抽成 composable比如useArrayDiff传入两个数组返回差异结果。这样每次遇到类似需求一行调用就能完成而且测试也只需要写一次。我自己的习惯是所有工具方法都要带单测。Java 用 JUnitGo 用原生的 testingBash 用 batsVue 用 Vitest。别觉得小题大做我见过太多“看起来简单”的工具函数因为边界条件没考虑全比如空数组、重复元素、特殊字符上线后炸出各种莫名其妙的 bug。有了单测兜底心里会踏实很多。数组“找不同”这道题从算法角度确实不起眼但它像一个万花筒折射出每种语言的设计哲学和适用边界。希望这篇跨界对比能让你在下次面对“找独行侠”时多一个选择维度少踩一个坑。
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门