ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

LeetCode 1200 最小绝对差:两次遍历+排序的简洁解法

LeetCode 1200 最小绝对差:两次遍历+排序的简洁解法

每天在刷题群里看大家打卡,问得最多的往往不是难题,反而是这种看起来平平无奇的“简单题”。比如 LeetCode 1200. 最小绝对差,题目短、示例少、标签只有数组和排序,但真正上手写的时候,总有人在“怎么找最小差”和“怎么收集结果”之间绕晕。

这篇文章就用解法一的思路——两次遍历,把这道题从题目本质、推导过程到代码实现完整拆一遍。你如果刚接触刷题,能从这里学到一套“先定位基准,再统一收集”的解题套路;你如果已经刷了几百道,也能看看这种两遍扫描的思路是怎么在真实场景里迁移使用的。

1. 先把题目看透:什么是最小绝对差

1.1 拆解题目给的信息

题目给的是一个整数数组arr,要求返回所有“绝对差”等于数组里最小绝对差的元素对。先别急着写代码,这里有两个关键点需要理解清楚:

  • 元素对(a, b)的绝对差就是|a - b|;
  • “最小绝对差”指的是:遍历完所有可能的元素对之后,能取到的最小|a - b|值;
  • 要返回的不是一个值,而是所有能达到这个最小差的元素对。

举个例子,arr = [4, 2, 1, 3]。任意两两组合里,|1 - 2| = 1,|2 - 3| = 1,|3 - 4| = 1,所以最小绝对差是1,符合要求的元素对是[[1, 2], [2, 3], [3, 4]]。

这里有个很容易忽略的细节:题目要求返回的每个元素对里,左边的数字不大于右边的数字。所以[4, 3]不是合法输出,要写成[3, 4]。这个要求其实给了我们一个暗示——排序之后再处理会方便得多。

1.2 为什么排序后只需要看相邻元素

这是整道题的题眼。直观上,如果数组里两个数的绝对差最小,那它们在排好序的位置上一定挨得很近。严谨一点说,排序后任意两个不相邻的元素a[i]和a[j](假设i < j,且j > i + 1),中间至少隔了一个元素a[i+1]。

由于数组有序,a[i] <= a[i+1] <= a[j],所以:

|a[j] - a[i]| = a[j] - a[i] = (a[j] - a[i+1]) + (a[i+1] - a[i]) > a[i+1] - a[i] = |a[i+1] - a[i]|

这里之所以严格大于,是因为j > i+1时,a[j] - a[i+1]至少为0,而a[i+1] - a[i]又至少为0。如果a[j] - a[i+1] = 0,那说明有重复元素,此时a[i+1]和a[j]的差为0,同样比a[i]和a[j]的差小。所以无论如何,全局最小绝对差一定出现在某个相邻元素对之间。

这个结论的价值在于:我们不需要做 O(n²) 的两两对比,只需要在排序后的数组上检查相邻元素,复杂度从平方级别直接降到了线性级别。

2. 两次遍历的思路是怎么一步步推出来的

2.1 第一次遍历:确定最小绝对差的值

排序完成后,数组变成了一个单调不减的序列。此时相邻元素差的最小值,就是全局最小绝对差。

这个过程的代码极其简单:

min_diff = float('inf') for i in range(1, len(arr)): diff = arr[i] - arr[i - 1] if diff < min_diff: min_diff = diff

为什么用float('inf')初始化?因为我们要在循环里不断“刷新”最小值,初始值必须是一个“大到不可能成为答案”的数。用float('inf')最省事,省去了单独处理第一对元素的麻烦。

2.2 第二次遍历:按基准收集所有答案

第一次遍历得到的结果,是一个数值基准:最小绝对差到底是多少。第二次遍历就简单了,再扫一遍相邻元素对,凡是差值等于这个基准的,就把[arr[i - 1], arr[i]]收进结果列表。

res = [] for i in range(1, len(arr)): if arr[i] - arr[i - 1] == min_diff: res.append([arr[i - 1], arr[i]])

到这里,两次遍历的核心思路就完整了:第一趟确定“什么是最小”,第二趟收集“所有达到最小的情况”。

2.3 一个自然的疑问:为什么不能一趟搞定

很多人第一次写这道题的时候,会尝试用一个循环同时完成“找最小差”和“收集答案”。逻辑上并不是不行,但会出现一个很麻烦的分支:当遍历到某个位置,发现当前相邻差比之前记录的最小差还小时,意味着前面收集的所有元素对都作废了,需要清空列表重新收集。

# 看起来能一趟完成的版本,实际坑很多 min_diff = float('inf') res = [] for i in range(1, len(arr)): diff = arr[i] - arr[i - 1] if diff < min_diff: min_diff = diff res.clear() # 之前的全部作废 res.append([arr[i - 1], arr[i]]) elif diff == min_diff: res.append([arr[i - 1], arr[i]])

这段代码逻辑上正确,但你得时刻记得在“更小值出现”的时候清空列表。一旦题目场景复杂一点,比如还要去重、还要按某种规则排序输出,这种“边扫边清”的写法很容易漏掉某个分支。两次遍历的价值在于把两种职责拆开,第一趟只回答“最小值是多少”,第二趟只回答“哪些满足”,每一步都简单到不会出错。

3. 完整代码实现与边界条件处理

3.1 Python 参考实现

把上面的思路拼起来,加上排序,就是完整解法:

from typing import List class Solution: def minimumAbsDifference(self, arr: List[int]) -> List[List[int]]: arr.sort() # 第一趟:确定最小绝对差 min_diff = float('inf') for i in range(1, len(arr)): diff = arr[i] - arr[i - 1] if diff < min_diff: min_diff = diff # 第二趟:收集所有差等于 min_diff 的相邻对 res = [] for i in range(1, len(arr)): if arr[i] - arr[i - 1] == min_diff: res.append([arr[i - 1], arr[i]]) return res

整个实现大约十行左右。相比在一趟循环里同时做两件事,这种写法好读、好改、好讲给别人听。

3.2 边界条件讨论

题目约束数组长度至少为 2,所以不需要单独处理“空数组”或“只有一个元素”的情况。但有几个边界值得注意:

  • 数组包含重复元素:比如arr = [1, 1, 2, 3],排序后arr[1] - arr[0] = 0,最小绝对差是0,结果里会有[1, 1]。这是对的,题目没有说不能有重复元素。有些同学会下意识认为答案里的两个数字必须不同,从而漏掉这种情况。
  • 最小绝对差很大:比如arr = [-1000000, 1000000],最小差是2000000,结果就是[[-1000000, 1000000]]。用float('inf')作为初始值时完全没问题。
  • 负数参与计算:arr = [-3, -1, 0],排序后相邻差分别是2和1,最小差为1,结果是[[-1, 0]]。只要先排序,负号不会造成任何困扰。

3.3 复杂度分析

  • 时间复杂度:排序是 O(n log n),两次遍历各是 O(n),总复杂度 O(n log n),其中 n 是数组长度。
  • 空间复杂度:如果忽略输出数组res占用的空间,只用了min_diff一个额外变量,是 O(1)。加上输出的话,最坏情况比如arr = [1, 2, 3, 4, 5],所有相邻对都是答案,结果有 n-1 个元素对,空间为 O(n)。

在 LeetCode 的讨论区里,偶尔能看到有人尝试“不排序直接两两比较”的写法,也就是 O(n²) 暴力遍历,在小数组上没问题,但题目数据范围一大必然超时。排序在这里不是可选项,而是整个解法成立的前提。

4. 踩坑记录:我在实际提交中遇到的问题

4.1 第一个坑:忘了排序就去找“相邻”

我第一次写这道题的时候,脑袋一热,直接对原始数组做相邻比较,然后发现示例都过了,一提交就报错。原因很简单:原始数组里“位置相邻”的两个数和“数值相邻”的两个数不是一回事。

比如arr = [2, 1, 3],原始数组里位置相邻的对有(2,1)和(1,3),绝对差分别是1和2,最小差是1,结果是[[1, 2]]。但如果数组是[4, 1, 2, 3],位置相邻的对里差最小的是(1,2),绝对差为1,可实际上(2,3)也是答案。只靠原始位置找会漏掉这种组合。

教训:凡是涉及“绝对差最小”这种数值距离的问题,优先想到排序。排序把数值距离转化成了数组下标距离,后面所有推理都简单了。

4.2 第二个坑:第二趟遍历时重复包含同一个元素

这个问题出现在我帮群友看代码的时候。对方写的是:

if arr[i] - arr[i - 1] == min_diff: res.append([arr[i - 1], arr[i]]) elif arr[i + 1] - arr[i - 1] == min_diff: res.append([arr[i - 1], arr[i + 1]])

他试图在一趟循环里同时检查“相隔1个位置”和“相隔2个位置”的元素对,认为最小绝对差也可能出现在不相邻但间隔很小的元素之间。

这就是前面证明过的误区:排序后,不相邻元素的距离必然不小于中间相邻元素的距离。所以如果arr[i + 1] - arr[i - 1]等于最小差,那arr[i] - arr[i - 1]或arr[i + 1] - arr[i]必然不大于这个值,早就被考虑过了。多加这种判断不仅没意义,还可能把同一个元素对重复加入结果。

4.3 第三个坑:把“最小差”和“最小的数”搞混

还有一次,一个同学问我:“为什么我先把数组最小值找到了,再用每个数跟它做差,结果不对?”

题目要的是任意两个元素之间的最小绝对差,不是“和某个全局最小值之间的差”。比如arr = [50, 1, 49],排序后是[1, 49, 50],任意两两之间的最小差是1(来自 49 和 50),但如果你只拿全局最小值1去和其他数做差,得到的是48和49,完全不是答案。

教训:重新读题。最小绝对差是一个“任意点对距离”的概念,不是“到某个参照点的距离”。排序之后相邻元素天然就是最可能的候选者,这个直觉是正确的,但不要把它简化成“找全局最小值的距离”。

5. 两次遍历思路在真实场景里的迁移

5.1 从“最小差”到“时间戳聚类”

刷题的时候很容易把一个解法局限在题目里,实际上两次扫描的模式在工程里非常常见。

举个我实际遇到的场景:某个系统需要统计用户在一段时间内的操作时间戳,找到“间隔最短”的时间点对,用于判断是否存在高频异常操作。数据清洗后是一串时间戳,可能有几万个。思路就是先排序,第一趟扫描找最小间隔,第二趟扫描把所有达到最小间隔的时间点对都找出来。这和 1200 题完全同构。

区别只在于:时间戳还要考虑时区、重复值、精度,但这些都属于数据预处理,核心算法部分就是今天的两次遍历。

5.2 从“收集元素对”到“按基准统一筛选”

更广义地看,两次遍历是一个通用的“两步走”技巧:

  1. 第一趟:扫描全局,确定一个基准值(最小值、最大值、累加和、频率峰值等);
  2. 第二趟:再次扫描,按这个基准筛选符合条件的目标项。

比如“找到数组中出现次数最多的所有元素”,第一趟统计频率并确定最高频次,第二趟收集所有达到这个频次的元素。再比如“找到所有子数组中和最大的子数组”,先跑 Kadane 算法求出最大和,再根据这个最大值反推哪些子数组命中。凡是“先求最优值,再找所有最优解”的问题,都可以套用这个模型。

5.3 为什么这个模式值得刻意练习

多数刷题新手容易陷入“一上来就想最优解”的思维定式,结果在边界分支里绕来绕去。两次遍历模式教会我们一个更朴素的方法论:先分开考虑“找标准”和“按标准筛选”两件事,再决定要不要合并。

实际上,一趟遍历往往能同时完成这两件事,比如今天的替代写法里用res.clear()就是合并版本。但合并的前提是你对状态变化足够敏感,知道什么时候该清空、什么时候该保留。对大多数场景来说,两次遍历的代码可读性更好、正确性更直观、调试成本更低。

6. 这道题还能怎么考:变体与延展

6.1 变体一:要求返回最小绝对差本身,而非元素对

如果题目只要求返回最小绝对差的值,第二轮遍历就可以去掉,退化成一次循环。这个变体对应 LeetCode 上很多“最小差值”类问题,比如求数组中两个数的最小差值。

6.2 变体二:要求按数值大小排序后输出元素对

由于我们的解法本身基于排序后数组的相邻比较,结果列表天然就是按数值升序排列的。如果题目额外要求“按第一个元素升序,第一个元素相同时按第二个元素升序”,当前解法无需额外调整,直接返回即可。

6.3 变体三:数组里有重复元素,且要求去重输出

排序后相同元素会相邻,相邻差为 0 时,重复元素对会被输出。如果需要去重,可以在收集时判断arr[i - 1] != arr[i],或者用集合记录已经加入的对。这个改动同样只需要在第二趟遍历里加一个条件。

曾经有朋友问过我:“如果这道题让你用不排序的方式写,你会怎么处理?” 我的回答是:可以用哈希集合,先遍历一遍把所有数存进去,再对每个数检查x + min_diff是否存在,但这样必须先知道min_diff,而求min_diff本身又需要 O(n²) 的遍历,得不偿失。排序在这里是唯一高效的预处理手段。

7. 我的个人体会:解完这道题后我改变了什么

刷这道题之前,我写数组类题目的习惯是“能一趟就一趟”,总觉得多一次循环是浪费。但在实际写代码和帮别人 review 的过程中,我越来越发现,一趟循环里塞太多职责,往往是 bug 的温床。1200 这道题让我真正开始接纳“两遍扫描”的思维:

第一遍负责建立全局认知,第二遍负责具体行动。

排序之后,第一次循环像是一次侦察,只带回一个数字min_diff;第二次循环才是真正干活,把答案一个个捡起来。这种“先算标准,再按标准执行”的方式,比“边算标准边执行”更接近人在现实中的做事逻辑——你总得先知道目标是什么,才能朝着目标搜集东西。

如果你最近也在刷 LeetCode,建议不要只满足于提交通过。试试把这道题的两次遍历思路讲给身边的人听,讲着讲着你就会发现,那些曾经见都没见过的变体题,其实都在同一个套路的延长线上。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进