LeetCode 1200这道"最小绝对差",我是在某个工作日的每日一题里遇到的。第一眼扫过去,感觉是个连入门难度都算不上的送分题,但真动手写的时候才发现,它最值得琢磨的地方不是答案,而是"怎么才能把两遍扫描写得不犯低级错误"。这题的完整名称是 1200. Minimum Absolute Difference,给你一个整数数组 arr,要求返回所有具有最小绝对差的元素对,对子内部升序,整个结果也要按升序排列。很多人的第一反应是暴力两层循环,把所有差值算一遍——这个思路没错,但面对 10^5 级别的数组长度时,O(n^2) 是肯定过不了的。事实上,只要对排序后的数组做两次线性扫描,就能在 O(n log n) 的总复杂度内拿到所有正确答案。这篇文章把这套"两次遍历"解法从头到尾拆给你看,也把我在本地验证、提交、踩坑过程中的经验一起放进来,适合刚接触算法题的同学,也适合想把这题讲清楚的面试者。
1. 最小绝对差的题目本质:排序后的邻居关系
1.1 题干拆解:三个隐藏信息值得注意
先别急着写代码,把题目条件逐条看清楚。LeetCode 1200的输入是一个整数数组 arr,长度在 2 到 10^5 之间,元素取值范围是 -10^6 到 10^6。输出要求很直白:找到所有"最小绝对差"的元素对,并且这些对要按升序返回,对子内部也是 a < b 的形式。
这里有一个容易被忽略的约束:题目明确说明数组中每个元素都是唯一的。这个"元素互不相同"不是废话,它直接决定了差值不可能出现 0,也就是说排序之后任意相邻元素的差值都严格大于 0,这让我们后续的所有推导都干净很多。如果题目没有这个约束,数组中存在相等元素时,最小绝对差就变成 0,答案集合会变成所有相等元素构成的相邻对,代码逻辑其实也能兼容,但表达含义会复杂不少。
第二个隐藏信息是"所有对"。很多人看到最小绝对差,下意识以为答案只有一个或一对,实际上示例 [4, 2, 1, 3] 排序后是 [1, 2, 3, 4],相邻差全是 1,所以答案是三对:[[1,2],[2,3],[3,4]]。这个细节决定了我们收集答案时不能找到一个就 break,而是要完整扫描整个数组。
第三个信息是时间规模。arr.length 最大到 10^5,O(n^2) 的暴力枚举是 10^10 次运算,一定会超时。所以这道题虽然标记为 Easy,实际考察的是你能不能第一时间跳出"枚举所有组合"的思维定式。
1.2 数学性质:为什么答案只可能藏在排序后的相邻元素之间
这是整个解法的核心逻辑,也是面试时最有讲头的地方。对于一个无序数组,任意两个元素的绝对差,如果把它们放在排序后的序列里看,你会发现一个很漂亮的规律。
假设排序后的数组是 a[0], a[1], ..., a[n-1],任意选两个位置 i < j,它们之间的差值可以拆成一连串相邻差之和:
a[j] - a[i] = (a[i+1] - a[i]) + (a[i+2] - a[i+1]) + ... + (a[j] - a[j-1])
因为数组中每个元素唯一,排序后严格递增,所以上面每一项都是正数。那么问题来了:如果 j - i ≥ 2,也就是说这两个元素在排序后不相邻,它们之间的差值会严格大于其中任意一个相邻差。既然存在比它更小的相邻差,这个非相邻对就根本不可能是"最小绝对差"的候选者。
我举一个直观的例子。排序后数组是 [1, 5, 6, 10],看 1 和 10 的差是 9,拆开是 (5-1) + (6-5) + (10-6) = 4 + 1 + 4,每一项都是正数,所以 9 一定大于其中任意一项,比如大于 1。因此 1 和 10 绝对不可能成为最小绝对差对。这个结论可以一句话总结:全局最小差值,一定出现在排序后的某对相邻元素之间。
这个数学性质直接把一个"从 n 个元素里任选两个"的 O(n^2) 问题,降维成了"只看 n-1 个相邻差"的 O(n) 问题。理解了这一点,整个"两次遍历"解法就顺理成章了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 两次遍历解法:先定标准再执行,思路、代码与复杂度账本
2.1 第一遍遍历:扫描全局最小差值
既然最小差值只可能出现在相邻元素之间,那第一遍遍历的目标就非常明确:把排序后的数组从头到尾扫一遍,计算每一个相邻差 arr[i] - arr[i-1],用一个变量 min_diff 记录下来最小值。
这里有个初始化的讲究。我第一次写这题的时候直接用 arr[1] - arr[0] 初始化 min_diff,然后从 i = 2 开始循环,看起来没问题,但后来发现这种写法在数组长度恰好为 2 的时候容易产生边界混乱,而且不够通用。更稳的写法是用一个"无穷大"作为初始值,比如 C++ 里的 INT_MAX,或者 Python 里的 float('inf'),然后循环从 i = 1 开始,逐个比较更新。
用"无穷大"初始化还有一个额外的好处:代码对"数组长度不足 2"这种异常情况天然免疫。虽然题目保证数组长度至少为 2,但作为工程习惯,防御性写法总是好的。
第一遍遍历结束后,min_diff 就是整个数组的最小绝对差。注意,这个时候我们还不知道有哪些元素对能凑出这个差值,所以需要第二遍遍历来收集答案。
2.2 第二遍遍历:按阈值收割所有候选对
第二遍遍历的思路更简单:既然已经知道最小差值是多少,那就再扫一遍相邻元素,凡是相邻差恰好等于 min_diff 的,都把这对 (arr[i-1], arr[i]) 收进结果数组。
为什么非要分成两遍,而不是在第一遍遍历时顺便把答案收集了?这是很多人的疑问。答案是:第一遍遍历进行到中间时,你根本不知道最终的最小差值是多少。如果边算边收集,一旦后面出现更小的差值,前面收集的所有候选对就全作废了。要么你频繁清空结果数组,要么你额外再扫一遍。两遍遍历就是"先定标准,再执行"的典型思路,代码逻辑清晰,不容易出 bug。
我在本地验证过,两遍遍历的时间开销非常小。排序的 O(n log n) 才是主导,两次 O(n) 的线性扫描在 10^5 规模下几乎可以忽略不计。这也是为什么这个方案在 LeetCode 上跑起来很稳。
2.3 复杂度账本:真正的开销在排序
把复杂度账算清楚,面试时就能有理有据。时间复杂度上,排序是 O(n log n),第一遍扫描是 O(n),第二遍扫描是 O(n),加起来就是 O(n log n)。空间复杂度上,如果只算额外辅助空间,C++ 的 sort 是内省排序,递归栈空间一般在 O(log n) 量级;Python 的 sort 底层是 Timsort,最坏情况会占用 O(n) 的辅助空间。结果数组本身最多存 n-1 对答案,所以最坏情况下结果空间是 O(n),但这个通常不计入额外复杂度。
有的人会进一步问:能不能不用排序,直接用哈希表或者桶来做?后面我会单独聊。但在标准解法里,排序是所有思路的基石,因为它用一次 O(n log n) 的排序,换来的是后续逻辑的全场开绿灯。
3. 代码实现与多语言验证:从 Python 到 C++ 的一次实操记录
3.1 Python 实现:短小精悍,但边界要抠
Python 版本是我日常刷题最常用的,代码量最小,但有几个细节需要抠清楚。直接看完整实现:
python复制class Solution:
def minimumAbsDifference(self, arr: List[int]) -> List[List[int]]:
arr.sort()
ans = []
min_diff = float('inf')
for i in range(1, len(arr)):
diff = arr[i] - arr[i - 1]
if diff < min_diff:
min_diff = diff
for i in range(1, len(arr)):
if arr[i] - arr[i - 1] == min_diff:
ans.append([arr[i - 1], arr[i]])
return ans
第一遍遍历用 float('inf') 初始化 min_diff,注意循环从 1 开始,保证 arr[i-1] 合法。第二遍遍历条件用的是等于号,这里不能用小于等于,否则会把大于最小差值的对也收进来。整个写法没有任何花哨技巧,提交之后运行时间大约在 300 毫秒左右(LeetCode 服务器浮动比较正常),表现属于第一梯队。
这个版本最大的好处是"一眼就能看懂"。面试场景里,能写出这种代码,已经足够向面试官证明你理解了排序后相邻性质的核心。
3.2 C++ 实现:注意 INT_MAX 与迭代器细节
C++ 版本原理完全一样,但工程细节多一些。sort 直接用 vector 的迭代器方式,min_diff 初始化用 INT_MAX 而不是随便拍一个 99999,因为元素范围是 -10^6 到 10^6,相邻差最大是 2×10^6,INT_MAX 是 2^31-1,肯定够用。
cpp复制class Solution {
public:
vector<vector<int>> minimumAbsDifference(vector<int>& arr) {
sort(arr.begin(), arr.end());
vector<vector<int>> ans;
int min_diff = INT_MAX;
for (int i = 1; i < arr.size(); i++) {
min_diff = min(min_diff, arr[i] - arr[i - 1]);
}
for (int i = 1; i < arr.size(); i++) {
if (arr[i] - arr[i - 1] == min_diff) {
ans.push_back({arr[i - 1], arr[i]});
}
}
return ans;
}
};
有一点值得提:arr[i] - arr[i-1] 在极端情况下会不会溢出 int?题目给的元素范围是 -10^6 到 10^6,差值的绝对值最大 2×10^6,int 完全装得下。但如果题目是更大范围的数组,我建议用 long long 做差值运算,这是个习惯问题,也是很多人在面试追问中会突然卡壳的地方。
3.3 自测用例清单:别只依赖题目给的示例
我每次写完解法,都会额外跑一组自己的测试用例,不只用题目的示例。这里是我整理的一张自测清单,你可以直接拿去用:
| 输入 | 预期输出 | 覆盖点 |
|---|---|---|
| [4,2,1,3] | [[1,2],[2,3],[3,4]] | 标准场景,多个答案 |
| [1,2] | [[1,2]] | 数组长度恰好为2,边界场景 |
| [1,3,6,10,15] | [[1,3]] | 最小差只出现一次 |
| [-10,-5,0,7] | [[-10,-5],[-5,0]] | 负数与0混合,差值与排序正确 |
| [1,1,1,1] | [[1,1],[1,1],[1,1]] | 元素重复时最小差为0的情况 |
最后一行特意说明一下:题目虽然保证元素互不相同,但我在本地会故意测一下重复元素的场景,因为万一某个版本的题意表述变了,或者你拿这个模板去解决别的问题,重复元素场景就能暴露代码是否健壮。实测下来,上面这份两次遍历代码在重复元素情况下依然能正确输出,因为排序后重复元素会聚在一起,差值 0 肯定会被注意到。
4. 常见坑与易错点:这些低级错误,第一遍写这题的人都踩过
4.1 坑位一:尝试"一遍扫描 + 动态清空答案"导致逻辑崩坏
我在评论区见过不少人的提交,试图在第一遍遍历时就把答案收集了,省掉第二遍扫描。思路是维护一个 min_diff,如果当前差值更小,就清空答案数组重新收集;如果等于当前差值,就追加;如果大于,就跳过。
这个思路本身没问题,后面我也会给正确版本。但实操中,很多人把"清空"写成了"重新赋值但没有清空旧元素",导致结果里残留了差值较大的对。比如数组 [4, 2, 1, 3, 8, 12](排序后 [1,2,3,4,8,12]),第一遍先遇到差值 1,收进 [1,2];再遇到差值 1 收进 [2,3];遇到差值 4 时不处理;然后如果 min_diff 还没被更新,前面收到的最小差对是对的。但如果先遇到差值 4 收进 [8,12],后面遇到差值 1 时忘了清空 [8,12],答案就会错误地出现 [1,2],[2,3],[3,4],[8,12] 混杂的情况。
我当时自己写这个优化版本时也翻过车,原因就是清空逻辑放在条件分支里的顺序不对。如果你想省一次遍历,建议在本地把这种混合差值的用例先跑一遍,确认没问题再提交。
4.2 坑位二:初始化不当导致漏掉第一对答案
另一个经典错误是初始化方式。有人喜欢写成这样:
python复制min_diff = arr[1] - arr[0]
for i in range(2, len(arr)):
...
看起来没什么问题,但如果你在第二遍遍历时也沿用从 i=2 开始的习惯,就会漏掉 arr[0] 和 arr[1] 这对。更隐蔽的是,当数组长度恰好为 2 时,第二遍遍历根本不进入循环,结果直接返回空数组。
我建议的写法是统一从 i = 1 开始,用 float('inf') 或 INT_MAX 作初始值,让第一遍循环完整覆盖所有相邻差。这样第二遍循环也自然覆盖从第一个相邻对到最后一个相邻对,边界不会错位。
4.3 坑位三:对"所有候选"的理解偏差
还有一个很常见的错误,就是在第二遍遍历找到第一个等于 min_diff 的对之后直接 break 返回。这在"最小差只出现一次"的用例里恰好能过,但在示例 [4, 2, 1, 3] 这种"多个答案"的情况下就会少输出。
问题出在对题意的理解上:题目要求"所有"最小绝对差对。所以第二遍必须是完整扫描,不能提前终止。这个坑通常是经历过一次 Wrong Answer 才能记住的。
5. 延伸与实战经验:这题背后的通用套路比题目本身值钱
5.1 优化方案:一次遍历的"清空重建"写法
前面提到一遍扫描也能做,这里把正确版本写出来,供你对比:
python复制class Solution:
def minimumAbsDifference(self, arr: List[int]) -> List[List[int]]:
arr.sort()
ans = []
min_diff = float('inf')
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
这个版本的精髓在于:只有当 diff 严格小于当前最小差时,才清空并重建答案数组;等于时直接追加。有人会担心清空操作会不会导致总复杂度变成 O(n^2),其实不会,因为每个候选对被加入答案数组后,最多被清空一次,之后不会再被加入。所有清空操作的总代价与最终输出规模成正比,整体仍然是 O(n)。
但说句实话,我实际刷题时仍然首选两次遍历版本。原因很简单:一次遍历版本虽然省了常数时间的遍历,但正确性需要更仔细的推理,面试时容易在紧张状态下写错分支顺序。能 AC 且不易出错,才是工程上的第一原则。
5.2 同类题型:排序 + 相邻扫描的通用模板
这道题让我想起一组同类题型,它们都有一个共同点:无序数组里找极值、找差值、找配对,只要先排序,问题复杂度直接降一档。
LeetCode 1502那道"判断能否形成等差数列",就是排序后检查所有相邻差是否相等;LeetCode 976"三角形的最大周长",是排序后从后往前扫描,找第一个满足两边之和大于第三边的位置;LeetCode 628"三个数的最大乘积",排序后对比取最大值和最小值组合的两三种情况。这些题拿到手,第一反应都应该是排序。
我个人的经验是:遇到数组相关的"最近""最小""最大""配对"等问题,先问自己一句——排序之后,这个问题的信息是不是变得更规则了?绝大多数时候答案是肯定的。排序是打破无序混沌最廉价的手段。
5.3 我在实战中的刷题习惯与建议
最后说一点自己的习惯。这类 Easy 题我一般会写两个版本,第一个版本保证正确性直接提交,AC 之后再看题解区有没有更优雅的思路,然后重新用第二种思路写一遍。比如这题,我先提交两次遍历版本,再搞懂一次遍历的"清空重建"写法,最后对比两者代码量、可读性和耗时。
这样做的好处是,简单题也被我练出了"多方案对比"的肌肉记忆。等到做 Medium 题的时候,我不会局限于第一个冒出来的解法,而是会习惯性地想"还有没有更好的方案"。LeetCode 官方题库和讨论区里,很多每日一题都藏着这种多解法的乐趣,1200 就是一个很好的入口。
刷了这些年题,我的感受是,学会在一个 Easy 题里抠出"为什么排序后相邻就是全局最小""为什么两遍扫描比一遍更稳""边界条件怎么处理"这些细节,比盲目多刷十道难题都值。算法的本质是训练思维方式,而最好的思维方式,往往就藏在这些看似平平无奇的小题里。
