
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_{i1}-a_i) (a_{i2}-a_{i1}) ... (a_j-a_{j-1})。每一项都是正数或零所以这些相邻差值不可能全部都大于δ否则加起来会大于δ至少得有一项小于等于δ。但δ已经是全局最小了小于它的相邻差不存在所以只能是等于它。这说明什么说明就算你最终答案里有非相邻的数对那它们之间的每一段相邻差值也全都等于δ也就是说这些数对实际上可以拆成多个相邻数对那些相邻数对同样满足最小差条件。所以我做这道题的时候一直有个很深的体会排序这个操作本质上是把“全局比较”问题转化成了“局部比较”问题。在一个无序数组里找最小差你得考虑 O(n²) 种组合排完序你只需要盯着 n-1 条相邻边看。这就是为什么排序类题目在 LeetCode 里占比这么高——排序从来不是目的而是把复杂关系变简单的手段。生活里也一样你要在一堆杂乱的数字里找最接近的一对手动也会先排个序再挨个看相邻的就是这个道理。1.3 解法一与解法二两次遍历到一次遍历的演进提到“解法二”自然得先说解法一。最常见的写法是排完序后分两步走第一步先遍历一次相邻元素找到全局最小差值minDiff。第二步再遍历一次相邻元素凡是差值等于minDiff的数对全部收集进结果列表。这个思路非常好理解代码写出来也很直观。但有一个小问题你遍历了两遍数组。第一遍找最小值第二遍收集答案。能不能一遍就把两件事都干了答案是当然可以因为这本质上是一个“动态维护最优解”的过程。你在遍历的过程中手里始终握着一个“当前已知的最小差值”和一个“当前收集到的答案列表”。每走到一个新的相邻数对比较一下差值如果这个差值比当前最小差值更小说明之前的答案全都不作数了需要清空重来如果这个差值正好等于当前最小差值说明又多了一个符合条件的数对追加进去如果这个差值比当前最小差值大那直接跳过不影响任何状态。这就是“一次遍历”解法的核心逻辑。你不需要像解法一那样先跑一遍纯找最小值因为你在遍历的同时就把最小值更新和答案维护一并完成了。而且你仔细品一下会发现这个写法其实比解法一更不容易出错——解法一你得保证第二次遍历用的minDiff是全局最优一旦第一步写错比如初始值设错第二步就全乱套了解法二全程只有一个循环状态是实时维护的逻辑链路更短反而更好 debug。下面我直接用代码把这套逻辑落地顺便讲几个关键细节。2. 一次遍历的完整设计与实现要点2.1 结果集的数据结构该怎么选先确定答案用什么容器装。题目要求返回一个列表里面每个元素又是一个长度为 2 的列表[a, b]并且整体按升序排列。在 Python 里直接用二维列表就行Java 用ListListIntegerJavaScript 用嵌套数组。这里有个值得注意的细节当你发现更小的差值时需要“清空结果集放入新数对”。Python 里我习惯直接写ans [[arr[i-1], arr[i]]]相当于用一个新的列表覆盖旧列表简单粗暴。如果用的是 Javaans.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 ListListInteger minimumAbsDifference(int[] arr) { Arrays.sort(arr); int minDiff Integer.MAX_VALUE; ListListInteger 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 - bJava 的Arrays.sort(arr)对基本类型数组是安全的Python 的list.sort()也是安全的这两个默认行为就是数值升序。只有 JavaScript 比较特殊属于历史遗留问题面试官偶尔也会拿这个考候选人是否了解语言特性。4.2 整数溢出与差值计算的防御性写法Java 里有个隐蔽的溢出问题。如果数组元素是int类型而两个极端值分别是Integer.MIN_VALUE和Integer.MAX_VALUEarr[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. 我的实际体会与最后一个小技巧这道题刷完有一阵子了但每次想起都有一种“麻雀虽小五脏俱全”的感觉。它涉及的排序思维、一次遍历维护最优解、边界处理、语言细节坑几乎涵盖了算法初学者需要掌握的所有基本功而题面本身又简单到不会劝退任何人。我个人觉得它特别适合当作“从会做题到会想题”之间的一个过渡案例值得反复品。最后分享一个我刷题时总结的小习惯拿到一道题先别急着写代码在草稿纸上把“暴力解”写一遍再想“有没有什么信息是暴力解里重复计算的”。这道题的重复计算在于所有非相邻数对的距离都可以由相邻距离拼出来所以非相邻的都是冗余计算。抓住这一点排序就是最自然的解法。想清楚这层逻辑哪怕没刷过这道题你也能推导出正确思路。带着这个习惯去刷下一道题会比盲目刷十道题更有收获。