1. 题目解读与核心思路拆解
1.1 这道题到底在问什么
先来把题意彻底捋清楚。LeetCode 1200 这道题给你一个整数数组arr,让你找出所有“最小绝对差”的数对。所谓最小绝对差,就是数组中任意两个不同元素之间差值绝对值的最小值。找到这个最小值之后,把所有差值正好等于这个最小值的数对都返回,而且每个数对内部要满足a < b,数对之间按升序排列。
举个直观的例子:arr = [4, 2, 1, 3],任意两数的差值最小是 1,所以你要返回[[1,2],[2,3],[3,4]]。再看一个:arr = [1, 3, 6, 10, 15],相邻差值是 2、3、4、5,最小差值是 3,所以答案应该是[[1,4],[6,10]],注意[3,6]和[10,15]的差值都是 4,不是最小值,不能混进去。
这题在 LeetCode 上标记的是“简单”难度,但简单题也有讲究。很多人第一反应是暴力双重循环,把所有数对差值算一遍,找到最小值再收集答案,时间复杂度 O(n²)。数组长度稍微大一点(比如 10 万),就妥妥超时。所以这道题表面考的是找最小差,实际上考的是你能不能看穿排序这个关键操作。标题里写的“解法二:一次遍历”,就是排序基础上最优雅的写法,一遍循环同时搞定“找最小值”和“收集答案”两件事。
1.2 为什么最小绝对差必然出现在相邻元素之间
这是整道题的核心洞察,也是所有高效解法的地基。先想想这个问题:在一组排好序的数字里,任意两个数arr[i]和arr[j](假设i < j)的差值,跟它们之间的相邻差值有什么关系?
举例子最直观。排序后是[1, 3, 6, 10, 15],你看[1, 10]的差值是 9,而这个 9 恰好是1→3(差 2)、3→6(差 3)、6→10(差 4)三段相邻差值加起来的和。换句话说,非相邻元素的差值,等于它俩之间所有相邻差的累加和。既然是累加和,那必然大于等于其中任意一段相邻差值。而最小绝对差是取“所有差值的最小值”,这个最小值一定逃不出相邻差值这个集合。
严谨点说:设数组排序后为a_0 ≤ a_1 ≤ ... ≤ a_{n-1},全局最小绝对差为δ。如果某对非相邻元素(a_i, a_j)的差值等于δ,那么根据上面的累加关系,δ = (a_{i+1}-a_i) + (a_{i+2}-a_{i+1}) + ... + (a_j-a_{j-1})。每一项都是正数或零,所以这些相邻差值不可能全部都大于δ(否则加起来会大于δ),至少得有一项小于等于δ。但δ已经是全局最小了,小于它的相邻差不存在,所以只能是等于它。这说明什么?说明就算你最终答案里有非相邻的数对,那它们之间的每一段相邻差值也全都等于δ,也就是说这些数对实际上可以拆成多个相邻数对,那些相邻数对同样满足最小差条件。
所以我做这道题的时候一直有个很深的体会:排序这个操作,本质上是把“全局比较”问题转化成了“局部比较”问题。在一个无序数组里找最小差,你得考虑 O(n²) 种组合;排完序,你只需要盯着 n-1 条相邻边看。这就是为什么排序类题目在 LeetCode 里占比这么高——排序从来不是目的,而是把复杂关系变简单的手段。生活里也一样,你要在一堆杂乱的数字里找最接近的一对,手动也会先排个序再挨个看相邻的,就是这个道理。
1.3 解法一与解法二:两次遍历到一次遍历的演进
提到“解法二”,自然得先说解法一。最常见的写法是排完序后分两步走:
第一步,先遍历一次相邻元素,找到全局最小差值minDiff。第二步,再遍历一次相邻元素,凡是差值等于minDiff的数对全部收集进结果列表。
这个思路非常好理解,代码写出来也很直观。但有一个小问题:你遍历了两遍数组。第一遍找最小值,第二遍收集答案。能不能一遍就把两件事都干了?
答案是当然可以,因为这本质上是一个“动态维护最优解”的过程。你在遍历的过程中,手里始终握着一个“当前已知的最小差值”和一个“当前收集到的答案列表”。每走到一个新的相邻数对,比较一下差值:
- 如果这个差值比当前最小差值更小,说明之前的答案全都不作数了,需要清空重来;
- 如果这个差值正好等于当前最小差值,说明又多了一个符合条件的数对,追加进去;
- 如果这个差值比当前最小差值大,那直接跳过,不影响任何状态。
这就是“一次遍历”解法的核心逻辑。你不需要像解法一那样先跑一遍纯找最小值,因为你在遍历的同时就把最小值更新和答案维护一并完成了。而且你仔细品一下会发现,这个写法其实比解法一更不容易出错——解法一你得保证第二次遍历用的minDiff是全局最优,一旦第一步写错(比如初始值设错),第二步就全乱套了;解法二全程只有一个循环,状态是实时维护的,逻辑链路更短,反而更好 debug。
下面我直接用代码把这套逻辑落地,顺便讲几个关键细节。
2. 一次遍历的完整设计与实现要点
2.1 结果集的数据结构该怎么选
先确定答案用什么容器装。题目要求返回一个列表,里面每个元素又是一个长度为 2 的列表[a, b],并且整体按升序排列。在 Python 里直接用二维列表就行,Java 用List<List<Integer>>,JavaScript 用嵌套数组。
这里有个值得注意的细节:当你发现更小的差值时,需要“清空结果集,放入新数对”。Python 里我习惯直接写ans = [[arr[i-1], arr[i]]],相当于用一个新的列表覆盖旧列表,简单粗暴。如果用的是 Java,ans.clear()之后再加,或者干脆重新new ArrayList<>()。我个人更推荐重新赋值的方式,因为clear()会保留原来的容量,对内存回收反而没那么友好,但这不是什么大问题,别在这上面纠结。
另外有人会问,结果集需不需要排序?答案是只要你的遍历是从左往右的,收集到的数对天然就是有序的。排序后数组本身从左到右递增,相邻数对(arr[i-1], arr[i])里前者一定小于后者;遍历顺序也是从小到大的,所以收集到的数对按第一个元素排好了序。这一点题目里虽然没重点说,但提交时确实是一个隐性要求,别忽略。
2.2 三种情况的处理逻辑:小于、等于、大于
一次遍历的精髓就是那三行判断,我拆开来讲清楚。
假设当前遍历到的是相邻元素prev = arr[i-1]和cur = arr[i],差值diff = cur - prev。维护两个变量:minDiff当前已知最小差值,ans当前结果列表。
情况一:diff < minDiff。这说明撞见了一个更小的差值,那之前收集的所有数对都白收了。比如你之前觉得最小差是 2,收集了[[1,3]],结果现在遇到一对差值是 1 的,那[[1,3]]就再也不是答案了。正确做法是把minDiff更新成diff,同时把ans重置为只包含当前这一对。注意是重置,不是追加。
情况二:diff == minDiff。很好,又遇到一对和当前最小差值一样的。这时候直接ans.append([prev, cur])即可,minDiff不需要动。
情况三:diff > minDiff。比当前最优解还差,那这一对什么都不算,跳过。到这里可能有人疑惑:跳过之后,后面会不会漏掉更好的?不会。因为数组升序排列,相邻差值没有单调性保证,可能后面又突然出现更小的差值。但这种情况会被情况一捕获,所以不会漏。遍历完整一遍后,ans里存的就是所有最小差数对。
初次接触这个逻辑的同学,最容易犯的错是只处理了情况一和情况三,忘了情况二。我也犯过这个错:想着找到更小的就更新,找不到就算了,结果提交后才发现漏掉了“差值与当前最小相等”的数对。这个等号判断千万不能省,它恰恰是这道题和那种“只求最小值”题目的本质区别——题目要的是所有解,不是单个最优值。
2.3 多语言代码实现与逐行解读
Python 版本:
class Solution: def minimumAbsDifference(self, arr: List[int]) -> List[List[int]]: arr.sort() min_diff = float('inf') ans = [] for i in range(1, len(arr)): diff = arr[i] - arr[i-1] if diff < min_diff: min_diff = diff ans = [[arr[i-1], arr[i]]] elif diff == min_diff: ans.append([arr[i-1], arr[i]]) return ans这里float('inf')表示正无穷大,确保第一个差值一定能触发“更新”逻辑。也可以用一个足够大的数比如10**9来初始化,但从可读性上讲,float('inf')更明确。循环从i = 1开始,保证arr[i-1]合法。
Java 版本:
class Solution { public List<List<Integer>> minimumAbsDifference(int[] arr) { Arrays.sort(arr); int minDiff = Integer.MAX_VALUE; List<List<Integer>> ans = new ArrayList<>(); for (int i = 1; i < arr.length; i++) { int diff = arr[i] - arr[i-1]; if (diff < minDiff) { minDiff = diff; ans.clear(); ans.add(Arrays.asList(arr[i-1], arr[i])); } else if (diff == minDiff) { ans.add(Arrays.asList(arr[i-1], arr[i])); } } return ans; } }JavaScript 版本:
var minimumAbsDifference = function(arr) { arr.sort((a, b) => a - b); let minDiff = Infinity; let ans = []; for (let i = 1; i < arr.length; i++) { const diff = arr[i] - arr[i-1]; if (diff < minDiff) { minDiff = diff; ans = [[arr[i-1], arr[i]]]; } else if (diff === minDiff) { ans.push([arr[i-1], arr[i]]); } } return ans; };看到没有,三个语言的逻辑是一模一样的,只是语法细节不同。特别注意 JavaScript 的sort()默认是字典序排序,排序数字时必须传入(a, b) => a - b这个比较函数,不然[1, 3, 10]会被排成[1, 10, 3],整个答案就完全错了。这个坑我在评论区见过无数次,属于高频踩雷点,后面小节再展开聊。
还有一个可选的优化:用prev变量而不是索引访问。这样代码稍微短一点,也能避免数组越界的担忧。但用索引的话,数组随机访问是 O(1),性能完全一样,看个人习惯了。我自己的偏好是用索引,因为可以直接和题目描述里的下标对应上,排查问题的时候更直观。
2.4 复杂度分析:时间与空间
这个解法的时间复杂度是 O(n log n),关键在排序。n是数组长度,Python 的Timsort、Java 的Dual-Pivot Quicksort、V8 的TimSort,最坏情况下都是 O(n log n)。排序之后那一次遍历是 O(n)。整体复杂度由排序主导,所以 O(n log n)。
空间复杂度要分情况讨论。如果不把结果集算进去,额外空间只有几个变量,是 O(1)。但题目要求的返回值ans在最坏情况下可能收集很多数对。什么情况最坏呢?数组里所有相邻差值都相等且为最小,比如[1, 2, 3, 4, 5],最小差是 1,所有相邻数对都要进答案,一共 n-1 对。这种情况下结果集本身就要占用 O(n) 空间。所以笼统地说,空间复杂度是 O(n),严格点是“除了返回结果外 O(1)” + “结果集最坏 O(n)”。
如果面试官问你能不能优化空间,要分清一个概念:题目要求返回所有数对,所以结果集的空间是省不掉的,除非你换一种表示方式(比如只统计数量,不存数对)。后面第 4 节我会给一个统计最小差数对数量的变式,那个就能把额外空间压到 O(1)。
3. 实操过程与踩坑记录
3.1 从暴力到排序:一道题的三次演进
我最早刷这道题的时候,是在 LeetCode 的“每日一题”里碰到的。看到“最小绝对差”这个字眼,我第一反应就是一个暴力解法:双重循环枚举所有数对,计算绝对差,用一个字典记录“差值 → 数对列表”,最后取最小的那个键。思路最直白,代码也写得快,但提交的时候问题就来了——我随手构造了一个 10 万长度的测试用例,双重循环跑了差不多十秒才出结果,在 LeetCode 上直接超时。
然后我静下来想:是不是有什么规律可以让我不用算所有数对?这时候想到了排序。把数组排好序之后,最小差必然出现在相邻元素之间(前面已经证明过了),那问题就简化成了找相邻元素的最小差值。第一次用两次遍历版本写出来,提交直接通过,那一刻我有点小得意,心想“简单题不过如此”。但刷题多的人应该能感觉到,两次遍历有一个不舒服的点:你第一遍已经把所有相邻差值算过了,第二遍又算一遍,等于白算了一趟。
我后来在题解区看到解法二的思路,第一反应是“原来可以这样”,第二反应是“这其实是个通用的优化套路”。在很多算法题里,你都可以把“先求最优值、再收集解”的两阶段过程,合并成“动态维护最优解和答案”的一趟扫描。这道题就是一个里程碑式的例子——它简单到你能一眼看穿结构,又典型到可以迁移到很多其他问题上。
3.2 我踩过的坑:漏掉等号判断
说出来有点丢人,但这是我真实犯过的错。最初写一次遍历版本的时候,我的判断条件长这样:
if diff < min_diff: min_diff = diff ans = [[arr[i-1], arr[i]]]然后我就没写elif分支了,直接默认只要不触发更新就不用管。测试用例[4,2,1,3]排完序是[1,2,3,4],遍历过程中差值是 1、1、1,第一次触发更新,后面两次差值等于 1 但被跳过了,结果答案里只有[[1,2]]。我一直以为答案不对是排序的问题,排查了半天,最后对照题解才反应过来:diff == min_diff的情况必须特殊处理。
这个坑为什么容易出现?因为大部分“找最小值”的题目,确实只需要一个变量记录最优值就行了,找完了再重新遍历收集解。但如果想着“一遍同时搞定”,就必须额外维护一个答案列表,并且对“等于当前最优”的情况做追加处理。这种思维转换不是天然的,需要刻意训练。我建议所有刷这道题的人,第一次写这个解法的时候,故意少写这个分支,跑一遍测试用例看看输出是什么,再补上,体会会更深刻。
3.3 边界条件:空数组、单元素数组、重复元素
面试高频问题来了:边界情况处理。先说两个极端——如果arr长度小于 2,那别说最小绝对差了,数对都不存在,直接返回空列表即可。我的解法里循环从i = 1开始,长度为 0 或 1 时循环根本进不去,天然返回空列表,所以不用写额外的 if 判断。
再说重复元素的情况。假设arr = [2, 2, 2, 3],排序后还是[2, 2, 2, 3]。相邻差值是 0、0、1,最小差值就是 0,答案应该是[[2,2],[2,2]]。这里注意,题目里的数对是“两个元素”,不是“两个不同值的元素”,所以数组中存在多个相同值的时候,相同值之间可以组成多对。我的解法里相邻的两个 2 差值确实是 0,会正常收集。这一点有的同学会想歪,以为要跳过相同元素,其实完全不需要,0也是合法差值,而且如果最小差是 0,那必然是因为有重复元素,这些重复元素相邻排列,都会被正常收集。
还有一种情况值得留意:数组里所有元素都相同,比如[5, 5, 5]。排序后相邻差值全是 0,最小差值 0,答案就是[[5,5],[5,5]]。遍历会依次收集(0,1)和(1,2)两对,结果正确。这些边界场景,我每次写完代码都会在本地跑一遍,保证万无一失再提交。
3.4 举一反三:把“收集数对”改成“统计数量”
前面提到一个变式,现在展开说说。如果题目改成“求有多少对元素达到最小绝对差”,那空间复杂度就能精简很多。思路还是先排序 + 一次遍历,但不需要维护答案列表,只需要一个计数器。
第一步,先找出最小差值(和原解法一样,但只更新min_diff)。第二步,再跑一遍遍历,统计相邻差值等于最小差值的个数。注意这里还是得两遍,因为第一遍你只知道最小值,不知道答案数量是多少;除非你像解法二那样边遍历边维护一个“当前计数”,遇到更小的差值就重置计数为 1,遇到相等就计数加一。这种情况下连第二遍都省了,只需要一个整数变量,额外空间直接 O(1)。核心逻辑跟原题几乎一模一样,就看你有没有理解“维护答案”和“维护答案数量”这件事在本质上是等价的。
我刷题的时候特别喜欢做这种变式训练,因为一个题目的变式往往比题目本身更能检验你是否真正理解了解法结构。能看出“收集列表”和“统计个数”只是同一个维护逻辑的两种输出形式,说明你离内化这种思维模式已经不远了。
4. 常见问题与排查技巧实录
4.1 排序器的默认行为:无孔不入的坑
这个坑值得单独写一节。JavaScript 的Array.prototype.sort()在不传参时,会把元素先转成字符串,再按字典序排序。所以对数组[1, 10, 2]调用默认sort(),结果是[1, 10, 2]而不是[1, 2, 10]。一旦数组里有两位数以上,排序结果就错了,后续所有相邻差值的计算全部白费。
这个问题的可怕之处在于,它在数据全是个位数的时候是正常的,一旦用例里出现一个两位数,结果立刻错乱。我第一次用 JS 刷题时也在这里栽过跟头,排查了很久才发现是排序的问题。所以不管用什么语言,只要数组元素是数字,务必显式传比较函数:JavaScript 是(a, b) => a - b,Java 的Arrays.sort(arr)对基本类型数组是安全的,Python 的list.sort()也是安全的,这两个默认行为就是数值升序。只有 JavaScript 比较特殊,属于历史遗留问题,面试官偶尔也会拿这个考候选人是否了解语言特性。
4.2 整数溢出与差值计算的防御性写法
Java 里有个隐蔽的溢出问题。如果数组元素是int类型,而两个极端值分别是Integer.MIN_VALUE和Integer.MAX_VALUE,arr[i] - arr[i-1]可能溢出不复存在——因为题目给的是排序后的数组,相邻差值必然是非负的。但如果是无序数组里的任意两数差值,或者你写的是先取绝对值的逻辑,那就要小心了:Math.abs(arr[i] - arr[j])在极端情况下可能因为减法溢出得到错误结果。
在这个具体题目里,因为我们已经排好序,arr[i] >= arr[i-1],所以减法不会下溢出。但养成一个防御性习惯总没错:遇到可能异号相减的场景,改用安全的差值计算方法,比如先把int提升为long再相减。这道题用不上,但同类型的变式题(比如“最大间距”“绝对值最大的数对”)就可能踩中。我在 LeetCode 上见过不少讨论帖,就是因为溢出导致答案死活不对,最后发现是类型问题。这里先给各位提个醒。
4.3 关于输出顺序的隐性要求
题目描述里明确要求数对之间按升序排列。前面分析过,排序数组从左到右遍历收集到的数对天然有序,但这建立在两个前提上:第一,你确实对数组做了升序排序;第二,你遍历的方向是从小到大。如果手滑把数组降序排序了,或者反向遍历,那收集到的数对顺序就会乱。LeetCode 的判题器对这道题会检查顺序,所以不要以为元素对正确就行了,顺序错了照样报错。
另外一个小细节:数对内部的顺序,题目要求a < b。由于我们取的是排序后相邻元素arr[i-1]和arr[i],而数组是升序,所以自动满足arr[i-1] < arr[i]。如果你用逆序遍历,那就得小心要不要交换顺序。为了少给自己找麻烦,老老实实从小到大遍历就是最优解。
4.4 相关题目与套路总结
聊到这儿,我给各位梳理一个“排序 + 相邻关系”的题目套路。LeetCode 里不少题目的核心都有“排序后看相邻”的味道:
- 第 164 题“最大间距”:排序后找相邻元素差值的最大值,也是先排序再遍历。
- 第 217 题“存在重复元素”:排序后看相邻元素有没有相等的。
- 第 628 题“三个数的最大乘积”:排序后穷举靠近两端的组合。
- 第 976 题“三角形的最大周长”:排序后从后往前找满足三角不等式的三元组。
这些题看起来五花八门,但共同点都是:先排序,把无序的全局问题变成有序的局部问题。拿这个题目当切入点,一套训练下来,你对“排序的意义”会有更深的体感。
另外一个值得记的套路是“动态维护答案”的思想。凡是你需要“找最优、收所有解”的题目,都可以想想能不能一趟扫描搞定。核心标志是:最优值越小(或越大),之前的解就越没有意义,需要重置。逮住这个特征,就可以往“一次遍历维护答案”的方向去优化。这个思想可以用在很多贪心题、滑动窗口题上,价值不亚于这道题本身。
5. 我的实际体会与最后一个小技巧
这道题刷完有一阵子了,但每次想起都有一种“麻雀虽小,五脏俱全”的感觉。它涉及的排序思维、一次遍历维护最优解、边界处理、语言细节坑,几乎涵盖了算法初学者需要掌握的所有基本功,而题面本身又简单到不会劝退任何人。我个人觉得它特别适合当作“从会做题到会想题”之间的一个过渡案例,值得反复品。
最后分享一个我刷题时总结的小习惯:拿到一道题,先别急着写代码,在草稿纸上把“暴力解”写一遍,再想“有没有什么信息是暴力解里重复计算的”。这道题的重复计算在于,所有非相邻数对的距离都可以由相邻距离拼出来,所以非相邻的都是冗余计算。抓住这一点,排序就是最自然的解法。想清楚这层逻辑,哪怕没刷过这道题,你也能推导出正确思路。带着这个习惯去刷下一道题,会比盲目刷十道题更有收获。