一次相邻交换与多重集合相等:从哈希表到双指针的数组相似性判定
2026/9/9 22:29:12 网站建设 项目流程

1. 题目原型还原与考点定位

1.1 我看到这个标题时的第一反应

“不稳定or相似”这个题名,一眼看过去甚至有点不像算法题,更像一道语文题。但做过几年面试题的人会马上反应过来,这里大概率在考察两个概念:数组的“相似性”和“能不能通过一次相邻交换互相转换”。春招笔试里经常出现这种“把两个常见考点缝在一道题里”的题目,用来快速筛选出真正懂基础的人。先说结论:这道题的难度放在第二题非常合适,不会让你完全做不出来,但也别想用排序糊弄过去。

由于公开渠道往往只流传题目名称和大致描述,细节会有各种版本,我把这道题按下面这个原型来展开:给定两个长度均为n的整数数组a和b,定义一次“相邻交换”操作为交换数组中两个相邻元素的位置。如果a能通过一次相邻交换变化为b,则称a和b是“Unstable”的;如果a和b中所含元素及出现次数完全一致,但不符合“一次相邻交换”条件,则称它们是“Similar”的;如果连元素构成都不一致,输出“Neither”。这种设定在阿里的机试题里并不少见,既考察多重集合判等,又考察对交换操作局部性的理解。

1.2 输入输出约定

回到最常用的笔试场景,输入一般是多组测试数据。第一行一个整数T表示组数,每组第一行一个整数n表示数组长度,接下来两行分别是数组a和数组b,每个数组n个整数。输出也很简单,每组一行,返回Unstable、Similar或Neither。

这个输入输出格式在阿里、字节、腾讯的机试里都非常常见,所以下面所有代码我都按照这个约定来写。你在本地自测的时候可以直接复制粘贴跑起来,不需要额外改IO。

1.3 看完题目先想清楚的三件事

很多同学一拿到题就开始写排序,这是刻在DNA里的习惯。但在这道题里,排序只能帮你判断“元素出现次数是否一致”,它无法回答“能不能通过一次相邻交换变成对方”这个问题。所以思路要分成两层:

  • 第一层:多重集合是否相等。这是“相似”的底层判断条件。如果连元素组成都不一样,后面都不用谈。
  • 第二层:如果多重集合相等,再看a和b是否只差一次相邻交换。判断依据不是“元素一样的个数”,而是“差异位置的跨度”。

复杂度目标也很明确:O(n)。n在机试里经常是2×10^5级别,排序的O(n log n)虽然也可能过,但在有更优解法的情况下,面试官更想看到你用哈希表一次遍历解决。


2. 核心思路拆解:两类判断的算法设计

2.1 “相似”的判定:用哈希表做多重集合比较

很多人一看到“两个数组元素是否完全一致”,第一反应就是排序。排序确实能判断,但它有两个问题:第一,O(n log n)在数据量大时纯属多余的消耗;第二,排序会改变数组原有顺序,如果你后面还要判断“一次相邻交换”,原数组被排序之后这个信息就没了,你还得先拷贝一份,代码变得啰嗦。

更干净的做法是哈希表。用一个map统计a中每个数字出现的次数,然后遍历b,每遇到一个数字就尝试在map里减掉一次。如果某个数字在map里不存在,或者计数已经减到0,说明b里有a没有的元素,直接返回Neither。这个过程只需要两趟遍历,时间复杂度O(n),空间复杂度O(n)。Java里用HashMap配合getOrDefault,C++里用unordered_map配合count,Python里直接用Counter,实现都很短。

这里有一个实操细节:判断条件一定是“c == 0就返回”,而不是“c < 0”。因为你每遇到一个b中的元素就减一次,如果计数已经为0,说明b里这个元素的出现次数比a多,已经不可能成为相同多重集合了,此时直接返回即可,不需要继续遍历完。

2.2 “不稳定”的判定:一次相邻交换的充要条件

“一次相邻交换”能改变的东西非常有限。交换数组里位置i和i+1的两个元素后,除了这两个位置,其他所有位置都与原来一模一样。所以如果两个数组的差别刚好是“两个相邻位置上的值互换”,那么a就可以通过一次相邻交换变成b。

这个结论反过来也很重要:如果a和b有超过两个位置不同,或者仅有的两个不同位置不相邻,那一次相邻交换绝对做不到。有人说“我交换之后,因为数组移动,后面的元素位置都好着呢”,这是因为你只交换相邻两个位置,根本不会引起整体位移——相邻交换不会让其他元素顺移,这是很多初学者容易搞混的地方。

借助这个性质,判定代码可以写得很简洁。先从左往右找到第一个a[i] != b[i]的位置left,再从右往左找到最后一个a[j] != b[j]的位置right。如果没有不同位置,说明两个数组完全相同,直接算Similar。如果right - left != 1,说明差异跨度大于1,一次相邻交换覆盖不了,也是Similar。如果right - left == 1,就交换一下a[left]和a[right],看是否与b完全相等;相等就返回Unstable,否则返回Similar。

2.3 两个判断如何合并成一次优雅的判断流程

完整流程是:第一步,用哈希表判断多重集合是否相等,不等直接输出Neither;第二步,在多重集合相等的前提下,用双指针找出差异区间;第三步,根据差异区间跨度判断是否Unstable。如果不符合Unstable,仍然要输出Similar,因为“相似”只看元素构成,不看能不能短距离交换出来。

有人可能会问:先判断多重集合相等,再判断一次相邻交换,会不会重复遍历数组?不会。步骤一需要O(n)遍历,步骤二的双指针最坏情况也就再O(n),总体上仍然是O(n)。整个算法的空间开销主要来自哈希表,容器长度最多为n,空间也是O(n)。

2.4 为什么这是面试官想看到的解法

这道题最大的分水岭其实不在“会不会写哈希统计”,而在“能不能意识到一次相邻交换只会影响两个相邻位置”。我见过不少人用“模拟冒泡一次”或者“计算逆序对数”来试图解决“不稳定”的判断,最后要么超时要么逻辑漏洞百出。实际上,这个考点在面试官眼里就是一句话的事:一次交换的局部性。

如果你能在面试时说出“我先用哈希表判断多重集合是否相同;如果相同,再检查差异位置跨度,跨度恰好为1且交换后相等,就说明一次相邻交换可以搞定”,面试官基本就会点头。思路简洁,边界清晰,实现也没有复杂的数据结构,这才是一道开发岗第二题应有的样子。


3. 三种语言完整实现

3.1 Java实现:HashMap + BufferedReader

Java的机试代码一般都要注意输入输出速度。System.out.println在数据量大的时候会慢得离谱,所以建议用StringBuilder把答案收集起来,最后一次性输出。读入用BufferedReader,不要用Scanner,Scanner在处理2×10^5量级时性能差距非常明显。我用getOrDefault来做计数,这也是Java机试里最常用的写法。

import java.io.*; import java.util.*; public class Main { public static void main(String[] args) throws IOException { BufferedReader br = new BufferedReader(new InputStreamReader(System.in)); StringBuilder sb = new StringBuilder(); int T = Integer.parseInt(br.readLine().trim()); while (T-- > 0) { int n = Integer.parseInt(br.readLine().trim()); int[] a = new int[n]; int[] b = new int[n]; StringTokenizer st = new StringTokenizer(br.readLine()); for (int i = 0; i < n; i++) a[i] = Integer.parseInt(st.nextToken()); st = new StringTokenizer(br.readLine()); for (int i = 0; i < n; i++) b[i] = Integer.parseInt(st.nextToken()); sb.append(classify(n, a, b)).append('\n'); } System.out.print(sb.toString()); } static String classify(int n, int[] a, int[] b) { Map<Integer, Integer> cnt = new HashMap<>(); for (int x : a) cnt.put(x, cnt.getOrDefault(x, 0) + 1); for (int x : b) { int c = cnt.getOrDefault(x, 0); if (c == 0) return "Neither"; cnt.put(x, c - 1); } int left = 0, right = n - 1; while (left < n && a[left] == b[left]) left++; while (right >= 0 && a[right] == b[right]) right--; if (left > right) return "Similar"; if (right - left != 1) return "Similar"; int tmp = a[left]; a[left] = a[right]; a[right] = tmp; for (int i = 0; i < n; i++) { if (a[i] != b[i]) return "Similar"; } return "Unstable"; } }

一个容易忽略的细节:这里交换的是a[left]和a[right],因为right-left已经等于1了,所以这就是一次相邻交换。换完之后还要再整体比较一遍a和b,千万不要只看left和right两个位置的值相等就返回Unstable。为什么要整体比较?因为如果前面有多余的差异,或者a和b中某处的重复元素分布方式不同,单独看两个位置是发现不了的。整体比较虽然多花O(n),但能稳稳兜住所有边界。

3.2 C++实现:unordered_map + swap

C++的写法核心和Java完全一样,只是容器变成了unordered_map。机试里记得加上ios::sync_with_stdio(false)和cin.tie(nullptr),否则cin读大数据很容易TLE。还有一个很经典的坑:unordered_map的operator[]在key不存在时会自动插入一个默认值0,这会导致你无法区分“原来就不存在”和“原本计数为0”。所以应该用count()先判断key是否存在,或者直接用find()。

#include <bits/stdc++.h> using namespace std; string classify(int n, vector<int>& a, vector<int>& b) { unordered_map<int, int> cnt; for (int i = 0; i < n; i++) cnt[a[i]]++; for (int i = 0; i < n; i++) { if (cnt.count(b[i]) == 0 || cnt[b[i]] == 0) return "Neither"; cnt[b[i]]--; } int left = 0, right = n - 1; while (left < n && a[left] == b[left]) left++; while (right >= 0 && a[right] == b[right]) right--; if (left > right) return "Similar"; if (right - left != 1) return "Similar"; swap(a[left], a[right]); for (int i = 0; i < n; i++) { if (a[i] != b[i]) return "Similar"; } return "Unstable"; } int main() { ios::sync_with_stdio(false); cin.tie(nullptr); int T; cin >> T; while (T--) { int n; cin >> n; vector<int> a(n), b(n); for (int i = 0; i < n; i++) cin >> a[i]; for (int i = 0; i < n; i++) cin >> b[i]; cout << classify(n, a, b) << '\n'; } return 0; }

C++里swap两个int没有太多要注意的,但如果你后续扩展成自定义类型,记得检查类型是否支持拷贝构造/移动赋值。std::swap在多数OJ环境下都是O(1)级别的开销,放心用。

3.3 Python实现:Counter + 列表比较

Python在机试里最大的优势是简洁,最大的劣势是慢。为了不因为输入输出被卡,建议用sys.stdin.buffer.read()一次性把整个输入读进来,再做切片解析。Counter是collections里现成的多重集合计数器,很适合这道题。

import sys from collections import Counter def classify(n, a, b): cnt = Counter(a) for x in b: if cnt[x] == 0: return "Neither" cnt[x] -= 1 left, right = 0, n - 1 while left < n and a[left] == b[left]: left += 1 while right >= 0 and a[right] == b[right]: right -= 1 if left > right: return "Similar" if right - left != 1: return "Similar" a[left], a[right] = a[right], a[left] if a == b: return "Unstable" return "Similar" def main(): data = list(map(int, sys.stdin.buffer.read().split())) idx = 0 T = data[idx] idx += 1 out = [] for _ in range(T): n = data[idx] idx += 1 a = data[idx:idx + n] idx += n b = data[idx:idx + n] idx += n out.append(classify(n, a, b)) sys.stdout.write("\n".join(out)) if __name__ == "__main__": main()

这里有一个Python特有的点:Counter(a)之后,直接对cnt[x]减一,如果某个key不存在,cnt[x]本来会返回0而不是报错,所以用一个if cnt[x] == 0的判断能同时处理“key不存在”和“计数已用完”两种情况。这个写法比Counter(a) == Counter(b)更省一趟完整的遍历。

3.4 三种实现的核心差异与踩坑对比

同一个思路,三种语言写出来风格完全不同。Java强调容器安全,C++强调边界检查,Python强调代码简洁。下面这几个坑是我在实际写题时都踩过的,列出来给大家提个醒:

  • Java的HashMap在getOrDefault时,如果key不存在会返回默认值0,不会像C++那样隐式插入,这点很安全。但要注意,如果你是先用map.put(x, getOrDefault(x,0)-1)来统计b的抵消,遇到负数不要慌,可以用c<0判断直接返回。
  • C++的unordered_map用operator[]读不存在的key会插入默认值,这会在后面的count()判断里造成干扰。所以要么只用count()判断,要么只用find()拿迭代器。
  • Python的Counter虽然好用,但如果数据量很大,统计完a之后还要遍历b逐个减,这个方法本身还是O(n);不要误以为Counter自带魔法能自动判两个计数完全相等,直接Counter(a) == Counter(b)也可以,但额外生成一个Counter对象,内存开销略大一点。

4. 实操流程:从测试样例到提交

4.1 口诀:先判集合,再看跨度

说一个我复习时自己总结的口诀:“先判集合,再看跨度。” 集合就是多重集合,跨度就是差异区间长度。先判集合能帮你快速过滤掉大量“Neither”;再看跨度能区分“Unstable”和“Similar”。这个顺序不能反,因为如果不先做多集判断,直接用双指针找差异,你可能会把两个完全不同但恰好一个位置相同的数组误判成Similar。

4.2 完整自测样例与手算推演

我准备了几个有代表性的样例,你可以拿它们验证代码是否正确,也可以体会left和right的定位过程。

输入:

6 4 1 2 3 4 2 1 3 4 4 1 2 3 4 2 1 4 3 4 1 1 2 2 1 2 1 2 3 1 2 3 1 2 3 3 1 2 3 1 2 4 4 2 1 1 2 1 1 2 2

对应输出:

Unstable Similar Unstable Similar Neither Similar

逐个手算一下:

第一组,a=[1,2,3,4],b=[2,1,3,4]。多集相同。差异区间:left=0,right=1,跨度恰好1,交换a[0]和a[1]后与b相等,所以Unstable。

第二组,a=[1,2,3,4],b=[2,1,4,3]。多集相同。差异区间从0到3,跨度3,一次相邻交换覆盖不了,但元素构成相同,所以Similar。

第三组,a=[1,1,2,2],b=[1,2,1,2]。多集相同。left=1,right=2,跨度恰好1,交换a[1]和a[2]后得到[1,2,1,2],Unstable。

第四组,a和b完全相同。left会从左一直走到n,right会从右一直走到-1,满足left>right,说明不存在差异,输出Similar。这里特意排除了“0次交换也算Unstable”的情况。

第五组,多集都不一致,直接Neither。

第六组,a=[2,1,1,2],b=[1,1,2,2]。多集相同。left=0,right=2,跨度2,不是1,所以是Similar。这个样例专门用来防那种“只比较第一个不同位置的同学”。

4.3 对拍脚本:怎么验证自己写的代码一定对

笔试准备阶段,“对拍”是验证算法正确性的黄金方法。思路很简单:写一个绝对正确但可能很慢的解法,再拿它和你的高效解法在大量随机小数据上对比输出。如果不同,就说明高效解法有问题。

下面这个Python脚本可以生成随机数组,并用暴力枚举所有相邻交换来求答案,跟你写的classify函数对比:

import random from collections import Counter def brute(n, a, b): if Counter(a) != Counter(b): return "Neither" for i in range(n - 1): if a[i] != a[i + 1]: c = a[:] c[i], c[i + 1] = c[i + 1], c[i] if c == b: return "Unstable" return "Similar" def solve_one(n, a, b): cnt = Counter(a) for x in b: if cnt[x] == 0: return "Neither" cnt[x] -= 1 left, right = 0, n - 1 while left < n and a[left] == b[left]: left += 1 while right >= 0 and a[right] == b[right]: right -= 1 if left > right: return "Similar" if right - left != 1: return "Similar" a[left], a[right] = a[right], a[left] if a == b: return "Unstable" return "Similar" for _ in range(10000): n = random.randint(1, 8) a = [random.randint(1, 5) for _ in range(n)] b = [random.randint(1, 5) for _ in range(n)] ans1 = brute(n, a, b) ans2 = solve_one(n, a, b) if ans1 != ans2: print("Mismatch:", a, b, ans1, ans2) break else: print("All passed")

暴力方法虽然慢,但逻辑直观:先判断多集一致,再枚举每一对相邻位置做交换,看看交换后是否等于b。把暴力结果当作标准答案,跑一万组随机小数据,如果高效解法和暴力解法输出全部一致,基本上就能放心提交。

4.4 提交时容易栽的IO细节

同为机试,三种语言的IO习惯差别很大。Java里如果用Scanner读2×10^5个整数,速度慢是一方面,另一个隐患是nextInt前的空行处理容易让人烦躁,我推荐统一用BufferedReader+StringTokenizer。C++的cin在默认情况下和stdio同步,逐个读入效率很低,必须加ios::sync_with_stdio(false)。Python更直接,如果你用input()一行一行读,数据量大时可能超时,一次性read().split()是标准做法。

还有一个容易忽略的小事:输出时不要在循环里反复拼接字符串,Java用StringBuilder,Python把结果收集到list最后join,C++直接用cout << ans << '\n'就很快。这些习惯看着不起眼,但在真实机试里可能直接决定你是AC还是TLE。


5. 常见问题与排查技巧实录

5.1 为什么我判断“不稳定”总错?——差异区间找错了

最常见的错误是只找第一个不同位置,然后判断a[left]和b[left+1]是否相等、a[left+1]和b[left]是否相等。这种写法面对简单样例能对,但一旦两个数组的重复元素比较多,就会出问题。举个例子,a=[2,1,1,2],b=[1,1,2,2],第一个不同位置是0,如果你只检查a[0]和a[1]交换后是否等于b,会发现交换后是[1,2,1,2],不等于b,于是你判断Not Unstable。但只检查这一个位置是不够的——两个数组的差异不止位置0,还有位置2,所以它们根本不满足“一次相邻交换”的前提。

正确做法是先找左边第一个不同位置、右边第一个不同位置,得到完整差异区间,再看跨度。不要把“第一个不同位置”当成唯一的判断依据。这个坑我在给朋友review代码时见过至少三次。

5.2 两个数组完全相同,该输出什么?

如果题目定义“最多一次相邻交换”算Unstable,那么完全相同的数组应该输出Unstable,因为0次交换也算“至多一次”。如果题目定义必须“真正执行一次交换且数组发生变化”,那完全相同数组应该输出Similar。我在这篇博文里采用后者,因为更严谨,也更能区分“Unstable”和“Similar”两个概念。

如果你在正式机试里遇到这个题,先看清楚样例。如果样例里两个相同数组输出Unstable,就把left > right这个分支改成返回Unstable,其余逻辑不变。

5.3 哈希计数时能不能在遍历b时直接cnt[b[i]]--?

C++里可以,但前提是你已经确认key存在。如果直接写cnt[b[i]]--,而b[i]在map里不存在,operator[]会先插入一个默认值0再减成-1,最终破坏多集判断的逻辑。Java里map.put(x, map.getOrDefault(x,0)-1)不会隐式插入,但会把不存在的key变成-1,后面的判断逻辑也要跟着改。所以最稳妥的写法是:先取当前计数c,如果c为0返回Neither,否则put成c-1。三种语言我都按这个思路写,逻辑完全一致,不容易把自己绕晕。

5.4 易错点速查表

场景正确输出原因
a和b元素组成不同Neither多集不一致,直接退出
a和b完全相同Similar不需要交换,不算Unstable(按本文定义)
差异区间跨度为2及以上Similar一次相邻交换覆盖不了
差异区间跨度恰好为1但交换后仍不相等Similar说明元素构成相同但排列差异更大
差异区间跨度恰好为1且交换后相等Unstable一次相邻交换正好达成

这张表几乎覆盖了所有边界情况。如果你在调试时发现输出和预期不符,先对着表检查自己落在哪个分支。

5.5 给面试官讲思路时如何一句话点透

如果面试官让你现场讲思路,不用背代码,就讲一句话:一次相邻交换只会影响两个相邻位置,所以我先用哈希表判断两个数组的多重集合是否相等,再用双指针找到左右差异点,只要差异区间长度不为1就一定不是Unstable,为1的话交换验证一次即可。这句话说出来,基本就把题目本质讲透了。面试官要是追问“为什么排序不行”,你就说排序不能保留原顺序、也不是线性复杂度,在这个场景下哈希更合适。


6. 最后再分享一点我的体会

这道题让我印象很深。不是因为它难,而是因为它把“看似简单”和“容易出错”结合得特别好。我见过不少准备得很充分的同学,写“相似”判断时直接排序,写“不稳定”判断时枚举所有相邻交换去模拟,最后代码又长又容易漏边界。其实说到底,笔试考的不是你会不会背某个高级算法,而是你对最基础操作有没有建立起直觉:一次交换的局部性、多重集合相等的判法、双指针怎么定位区间。把这三个点想明白,代码自然就短了。

我自己准备笔试时有个习惯:每道题写完一种语言,一定再用另外两种语言各写一遍。不是为了刷题量,而是为了逼自己把逻辑从“语言写法”中剥离出来,只盯着算法本身。Java、C++、Python的语法差异很容易让人忽略同一种思路在不同环境下的陷阱,比如C++的隐式插入、Java的容器安全、Python的输入输出速度。遇到这种要求多语言实现的题目,正好可以一次性补齐。

另外再分享一个小技巧:准备对拍脚本真的不亏。哪怕只是花十分钟写一个暴力版,把一万组随机数据跑一遍,很多你自己想破脑袋都发现不了的边界问题,机器会直接帮你揪出来。我在写这篇博文时,就是用那个暴力脚本验证了上面所有代码的正确性。提交之前,多花十分钟拍一遍,比交上去WA之后再改要值得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询