如果让我从双指针题目里挑一道最值得反复咀嚼的,“找出第 K 小的数对距离”绝对排得上前三。这道题表面上是统计一堆绝对值的第 K 小,实际上把排序、二分答案、双指针计数三样东西揉在了一起。我第一次做的时候,上来就想去枚举所有数对,结果数据一到大直接内存爆炸。后来看懂它的解法,才意识到这类“第 K 小”的题,核心不是找到答案那一刻,而是怎么快速判断“当前猜的这个距离够不够格”。
简单交代一下题目:给定一个整数数组 nums 和一个整数 k,要求把数组中任意两个不同下标元素的差的绝对值——也就是数对距离——全部拿出来,从小到大排序,返回第 k 小的那个。注意这里说的是“不同下标”,两个下标只要不同就算一个数对,值相不相等无所谓。可能有人会说:所有数对距离有 n*(n-1)/2 个,n=100 时也就 4950 个,暴力枚举排序勉强能跑;可一旦 n 到 10^4、10^5,暴力的时间和空间就彻底失控。所以这道题的价值不只是会做一道题,而是学会一套处理“第 K 小”类问题的通用思路。适合的人群也很明确:知道二分和双指针,但看到“第 K 小”不知道从哪下手的同学,以及想复习“二分答案 + 双指针计数”模板的开发者。
1. 题目理解与暴力解法为什么行不通
1.1 认清数对距离和 K 值的第一重陷阱
先严格定义一下数对。题目里“数对”通常指的是 (i, j) 且 i < j,距离是 abs(nums[i] - nums[j])。为什么要规定 i < j?因为 abs(nums[i] - nums[j]) 和 abs(nums[j] - nums[i]) 是同一个值,如果放任 i != j 枚举,会重复统计一倍,把答案直接搞错。所以在写双重循环时,内层一定要从外层下标 + 1 开始。
第二个容易踩的坑是 K 的起点。大部分题目说“第 K 小”,K 是从 1 开始数的;也就是说排序后第一个元素是第 1 小。但有些题给的是下标形式的 k,要求返回排序后索引为 k 的元素。这两者在二分判定里差一个等于号:从 1 开始的时候,我们找最小的 dis 使得 count(mid) >= k;如果是从 0 开始,判断条件就要变成 count(mid) >= k + 1。我写第一版代码时就是想当然用了 >= k,结果样例过、提交错。所以拿到题目第一步,先确认 K 的定义,最好写一句注释给自己看。
还有一个必须明确的事:数对距离是一个多重集合,重复值要分别占名次。比如 nums = [1, 3, 1],排序后是 [1, 1, 3],所有距离是 0、2、2,那么第 1 小是 0,第 2 小和第 3 小都是 2。不要想着“距离相同的数对只算一个”,那样后面二分计数就没法用了。我们统计 count(mid) 时统计的是所有满足 dist <= mid 的数对个数,重复距离的每一对都算数,这正是“第 K 小”的定义。
1.2 暴力枚举的时间与空间为什么撑不住
暴力解法非常直白:两层循环收集所有距离,排序后取第 K 个。写成代码就是:
n = len(nums) distances = [] for i in range(n): for j in range(i + 1, n): distances.append(abs(nums[i] - nums[j])) distances.sort() return distances[k - 1] # 假设 K 从 1 开始这个解法在 n 小的时候完全正确,甚至可以用它来对拍验证优化版本。但它有两个致命问题。第一是空间:数组里要装 n*(n-1)/2 个数。n = 10000 时大约是 5000 万个整数,用 Python 的话光这个列表就要数百 MB 内存,OJ 大概率直接内存超限;n = 100000 时数对超过 49 亿,基本不可能存下来。第二是时间:先要 O(n^2) 枚举生成所有距离,再排序,总复杂度 O(n^2 log n),n 稍大就超时。
所以暴力解法的定位只有一个:在小数据上做基准验证。我自己调试这类题时,会先写一个暴力函数,随机生成 n <= 50 的数组,用暴力和优化版对拍。对拍通过了,再把暴力代码注释掉或删掉。这是我踩了不少次坑之后养成的习惯,它能帮你把“思路写对了但实现细节错了”这类问题快速暴露出来。
2. 核心思路:排序 + 二分答案 + 双指针计数
2.1 排序为什么不会改变答案集合
第一次听说这种题要先排序的时候,我愣了一下:求的是绝对值的差,排序之后数字位置全变了,结果不会变吗?答案是不会。数组里任意两个元素的差的绝对值,本质上和它们在数组中的顺序无关。排序只是把元素重新排列,但所有可能数对及其差的绝对值完全保留了下来。比如 [2, 1, 3] 的距离集合是 {1, 2, 1}(2和1差1,2和3差1,1和3差2),排序成 [1, 2, 3] 之后距离还是 2-1=1、3-1=2、3-2=1,还是 {1, 2, 1}。
那排序的好处是什么?它让数组具有了单调性:左端点固定时,右端点越靠右,差值越大。这样我们在判断“有多少数对距离小于等于某个值 dis”时,就可以用双指针而不是二重循环。另外,排序后整个数组的最大距离就是 nums[-1] - nums[0],这也是二分的初始上界,不需要再去遍历求一遍。如果数组里有负数,排序也不会影响距离集合,因为排序后任意两个位置仍然满足 nums[j] >= nums[i],差值非负,直接相减就是绝对值。
2.2 把“找第 K 小”变成“猜答案 + 数个数”
如果直接去数组里找第 K 个最小距离,一个是距离并没有显式存在数组里,另一个是枚举所有数对本身已经 O(n^2)。这类问题的通用解法是二分答案。二分答案的核心思想是:不直接回答“第 K 小是多少”,而是先猜一个答案 dis,然后问一个更简单的问题——“小于等于 dis 的数对距离有多少个?”
设 count(mid) 表示距离小于等于 mid 的数对个数。这个 count 函数显然随着 mid 单调不减:mid 变大,能覆盖的数对只会更多不会更少。那么原问题的答案,就是让 count(mid) 首次达到 k 的最小 mid。因为如果 count(mid) >= k,说明第 k 小的距离一定在 mid 左侧或就是 mid,所以收缩右边界;如果 count(mid) < k,说明第 k 小的距离比 mid 大,只能收缩左边界。
举个粗糙的例子帮助理解:假设距离按从小到大是 [0, 2, 2, 3, 5, 8],K = 4,那么答案是 3。我们猜 mid = 4,发现 <= 4 的距离有 4 个,>= K,说明答案不大于 4,于是把上界降到 4;再猜 mid = 2,发现 <= 2 的只有 3 个,< K,于是把下界升到 3;最后收敛到 3。二分答案做的事情就是这个猜的过程,只不过不需要真正构建距离数组。
二分区间也很好确定:最小值是 0(两个相同元素距离为 0,或所有元素相同),最大值是排序后首尾差。于是问题从“找第 K 小数”变成了“对一个单调函数做二分查找”,外面多了一层 O(log W),其中 W 是距离范围。如果 W 不超过 10^9,二分次数大约 30 次,每次 count 做到 O(n),总复杂度就是 O(n log n + n log W),k 完全不参与复杂度,这是它能扛住大数据的根本原因。
2.3 二分模板为什么选择左闭右闭
我习惯用左闭右闭区间,也就是 left = 0,right = nums[-1] - nums[0],循环条件是 while left < right。取 mid = (left + right) // 2。如果 count_pairs(mid) >= k,说明 mid 是可行答案,但可能还有更小的,所以把右边界收缩为 mid;否则 mid 不可行,把左边界设为 mid + 1。这样循环结束时 left 和 right 会收敛到同一个值,这个值就是最小的满足 count >= k 的距离。
有同学会问:为什么不是 right = mid - 1?因为当 count(mid) >= k 时,mid 本身可能就是答案,把它排除掉会漏答案。这么写不会死循环,是因为当 left + 1 == right 时,mid = left,如果 count(left) >= k,right 变成 left,循环结束;如果 count(left) < k,left 变成 left + 1 = right,也结束。等号两边都收敛,非常稳。
3. 双指针计数技巧:右指针为什么不用回退
3.1 一行一行拆解计数代码
我把 count 函数单独拿出来看:
def count_pairs(dis): cnt = 0 r = 0 for i in range(n): while r < n and nums[r] - nums[i] <= dis: r += 1 cnt += r - i - 1 return cnt第一次看这段代码的人最容易问:r 为什么不重置成 i + 1 或者 i?因为如果每次循环都重置 r,那整个 while 又成了 O(n^2),和暴力没啥区别。这里的关键是:i 变大之后,nums[i] 变大,所以 nums[r] - nums[i] 会变小。也就是说,上一轮里能够满足 nums[r] - nums[i] <= dis 的位置,在新的 i 下差值更小,必然仍然满足。既然如此,r 完全没有必要回退,继续从当前位置往右推就行。
有一点要注意的是,r 初始值必须设为 0,而不是直接设为 i + 1。第一次进入循环 i = 0,while 先判断 r = 0 时 nums[0] - nums[0] = 0 <= dis(dis 永远非负),r 会至少先变成 1,然后继续判断第二个元素。这样循环结束后 r - i - 1 统计的才是 i+1 到 r-1 这些位置的数量。如果你把 r 初始化成 i + 1,i 递增的时候 r 也会跟着变,看起来没问题,但实际上是每次从 i+1 重新扫描,复杂度退化,数据一大就超时。
3.2 单调性的严谨说明与计数正确性
更形式化地讲,定义 best(i) 为对于左端点 i,最后一个满足 nums[j] - nums[i] <= dis 的下标 j(取下标区间里的最大合法右端点)。当 i 增加到 i+1 时,对任意满足 nums[j] - nums[i] <= dis 的 j,因为 nums[i+1] >= nums[i],必然有 nums[j] - nums[i+1] <= nums[j] - nums[i] <= dis,所以 j 对新的左端点依然是合法右端点。因此 best(i+1) >= best(i),右边界单调不减。这就是双指针能够共享同一个 r 的数学依据。
至于计数正确性,每次累加的 r - i - 1 实际上是在给所有左端点为 i、右端点落在 [i+1, r-1] 的数对计数。因为 r 是第一个超过 dis 的位置,这些右端点确实全部满足 <= dis;而右端点大于等于 r 的不满足。对每个 i 这样累加一遍,每个 (i, j) 数对只在它被扫描到时记一次,没有重复也没有遗漏。所以 count_pairs 返回的就是距离 <= dis 的数对总数。
我建议你自己拿一个小数组跑一遍这个逻辑,比如排序后 [1, 1, 3, 6, 8],dis = 4。手写过程:i=0 时 r 会从 0 推到下标 3(因为 nums[2]=3, 3-1=2<=4,r 变成 3;nums[3]=6, 6-1=5>4,所以停在下标 3),累加 r-i-1 = 3-0-1=2,对应右端点 1 和 2。i=1 时 nums[1]=1,r 继续推,发现 nums[3]-1=5>4,r 停在下标 3,累加 3-1-1=1,对应右端点 2。i=2 时 nums[2]=3,r 推到下标 4? 实际 nums[3]-3=3<=4,r 变成 4;nums[4]-3=5>4,停,累加 4-2-1=1,对应右端点 3。i=3 时 nums[3]=6,r=4,nums[4]-6=2<=4,r 变成 5,累加 5-3-1=1,对应右端点 4。总计 5 个数对,你可以手动验证这 5 个确实是距离 <= 4 的数对。这个过程跑一遍,比背十遍模板都管用。
4. 完整代码实现与细节讲解
4.1 Python 实现与注释
整套解法可以写成下面的代码:
def smallest_distance_pair(nums, k): nums.sort() n = len(nums) def count_pairs(dis): cnt = 0 r = 0 for i in range(n): while r < n and nums[r] - nums[i] <= dis: r += 1 cnt += r - i - 1 return cnt left, right = 0, nums[-1] - nums[0] while left < right: mid = (left + right) // 2 if count_pairs(mid) >= k: right = mid else: left = mid + 1 return left这段代码里值得注意的地方是:count_pairs 里的 r 定义在函数内部,每次调用都会重置为 0,这没问题,因为二分每次调用 count_pairs 时都要重新计数。但在一次 count_pairs 内部,r 不会回退。另外,Python 中 int 没有位数限制,cnt 可以放心累加,不需要像 C++ 那样处理 64 位问题。如果题目数据范围极大,count_pairs 返回的 cnt 可能非常大,但因为 Python 会自动扩容,所以暂时不用慌。
4.2 C++ 实现与类型选择
C++ 版本的长相基本一致,但有个特别注意点:cnt 要用 long long。数对数量最大是 n*(n-1)/2,当 n = 100000 时大约是 5×10^9,用 int 存会溢出,导致计数变成负数,二分直接崩掉。distance 本身可以用 int,但累计数量必须用 64 位。代码示例:
int smallestDistancePair(vector<int>& nums, int k) { sort(nums.begin(), nums.end()); int n = nums.size(); auto count_pairs = [&](int dis) -> long long { long long cnt = 0; int r = 0; for (int i = 0; i < n; ++i) { while (r < n && nums[r] - nums[i] <= dis) ++r; cnt += r - i - 1; } return cnt; }; int left = 0, right = nums.back() - nums.front(); while (left < right) { int mid = left + (right - left) / 2; if (count_pairs(mid) >= k) { right = mid; } else { left = mid + 1; } } return left; }这里用 left + (right - left) / 2 而不是 (left + right) / 2,主要目的是防 left + right 溢出。虽然当前题的数据范围下 left + right 一般不会溢出,但写成这种形式是个好习惯,尤其当你把这道题的模板用到其他数据范围更大的问题时。
4.3 边界情况一次性测试清楚
我自己写完代码后,会特意检查下面几类用例:
| 用例 | 预期结果 | 说明 |
|---|---|---|
| nums = [1],k 任意 | 无有效数对,通常题目保证 n >= 2 | 如果 n < 2 需要提前处理 |
| nums = [1, 3, 1], k = 1 | 0 | 重复元素贡献距离 0 |
| nums = [1, 1, 1], k = 2 | 0 | 距离集合全是 0 |
| nums = [-5, 0, 10], k = 1 | 5 | 负数参与排序后差值仍为非负 |
| nums = [1, 1, 100], k = 3 | 99 | 距离集合排序后是 0, 99, 99,第 3 小是 99 |
| n 特别大时 | 不超时不溢内存 | 计数用 long long,双指针保持 O(n) |
尤其是负数用例,有些同学会担心排序后 nums[r] - nums[i] 可能是负的,其实排序保证 nums[r] >= nums[i],所以差值一定是非负,直接减没问题。距离为 0 的数对在双指针里也会被正常统计,因为 while 条件是 <= dis,当 dis >= 0 时 nums[r] - nums[i] = 0 必然满足。
5. 常见问题与排查技巧实录
5.1 二分条件写反或者模板选错
这是我最开始犯的错。如果你把 if count_pairs(mid) >= k 写成 <= k,那么二分搜出来的是最后一个 count < k 的位置,方向完全反了。区分标准很简单:我们要找“第一个使得 count >= k 的距离”,所以满足条件时说明答案不在右边,把右边界拉回来;不满足时才把左边界往右推。要是实在记不住,可以打印几组中间结果看单调性。
还有一种情况是把求下界的 left < right 模板和求普通值的 left <= right 模板混着写,很容易死循环。我建议固定用下面的模板:while left < right,mid = left + (right - left) / 2,if (check(mid)) right = mid,else left = mid + 1。这本质上是找最小答案的标准范式,只要 check(mid) 是“mid 是否可行/是否已经满足”,就都能套。如果你更习惯 left <= right,那要注意 right = mid - 1,left = mid + 1,最后返回 left 或 right 都要想清楚。别两种混着用。
5.2 双指针 r 重置导致复杂度退化
我之前给一个朋友 review 代码时,看到有人把 while 写在 for 外层,还加了 r = i 的初始化。这样一来每个 i 都从同一个位置重新扫,外层 n、内层也 n,单次 count 就是 O(n^2)。虽然小数据能出结果,但 n 一超过 5000 就直接超时,而且因为二分外层还有 30 次左右,退化得更厉害。
怎么自查?两个办法。第一,在 count_pairs 里放一个计数器,统计 r 总共右移了多少次;如果每次都接近 n,说明 r 被重置了。第二,算复杂度:正确的 count 里 r 最多从 0 移动到 n,总移动次数 O(n),所以循环内的判断总次数是 i 的 n 次加 r 的 n 次,约 2n。如果你发现某个测试点跑了很久,先怀疑是不是 r 回溯了。
5.3 计数溢出与 K 的类型陷阱
正如前面说的,count 的结果很可能超过 2^31 - 1。在 Python 里因为没有固定位数,这个问题不明显,但在 C++ 里就非常致命。我建议在 C++ 里把 count_pairs 的返回类型和 cnt 都定义为 long long,同时把 k 也转成 long long 再比较。还有 mid 用 int 表示距离,当数组元素范围非常大、接近 int 上限时,mid = (left + right) / 2 可能溢出,写成 mid = left + (right - left) / 2 更安全。
另一种 K 的类型陷阱是题目可能把 k 定义成 unsigned int,或者你读进来时用了 int,结果 k 超过 int 范围。虽然大多数题不会,但你要知道:如果 n 接近 10^5,总对数大约是 5e9,k 本身也可能超过 2^31 - 1。读数据时用 long long 解析,比较时统一转 long long,能省很多麻烦。
5.4 把“第 K 小”误解成“去重后的第 K 个”
这个问题比想象中普遍。有个朋友在讨论时说:nums = [1, 1, 1],距离全是 0,哪来的第 2 小?他的潜意识是把距离当成集合去重了。但题目要的是多重集合里排序后第 K 个元素,所以 0、0、0 中第 2 小仍然是 0。我们的二分策略统计的是 count(mid) 也就是小于等于 mid 的个数,天然就是去重前的数量,所以只要 check 写对,不会出问题。如果你总是纠结去重,那多半是把题意理解偏了。
6. 复杂度分析与同类题目扩展
6.1 完整复杂度推导
排序时间复杂度 O(n log n),空间取决于排序算法,通常 O(1) 或 O(log n) 可以忽略。二分部分,初始区间长度 W = max - min,每次折半,需要 O(log W) 次 check。每次 check 中,for 循环执行 n 次,while 循环里 r 最多从 0 增加到 n,整体 O(n)。因此总时间复杂度是 O(n log n + n log W)。当 n = 10^5、W = 10^9 时,n log n 大约是 1.7e6,n log W 大约是 3e6,整体非常轻松。空间上除排序外只需要几个变量,是 O(1)。
这个复杂度里完全看不到 k,因为二分只通过 k 来比较 count(mid) 是否 >= k,k 的大小不改变扫描过程。这使它和 k 无关,和“第 K 小”的 K 可大可小都没关系。
6.2 类似题目与思维迁移
掌握了“排序 + 二分答案 + 双指针计数”这套框架后,你可以迁移到不少题目。比如求“两数之差绝对值第 K 小”其实就是这道题本身;求“子数组和小于等于某个值的个数”,因为数组前缀和有单调性,也可以用双指针计数;“有序矩阵中第 K 小的元素和”一类题,只要是判断一个阈值是否可行、且计数能用单调性做到 O(n) 或 O(n log n),都能套这个思路。
但套模板前必须确认两个前提:问题的答案是否具有单调性——即阈值变大时,合法候选只会增加;以及能否写出高效的 check 函数。如果这两个条件不满足,二分答案就不适用,比如你要找“恰好等于某个距离”的数对数量,count(mid) 的单调性仍然有,但目标函数变成了查相等值,就不是简单的下界二分了。
我在实际使用中还有一个体会:遇到“第 K 小/大”的问题,优先想一想能不能把枚举答案改成猜答案,猜完之后计数能不能用双指针或前缀和优化。如果把这两个问题想通了,这类题的代码其实都差不太多。最后分享一个小技巧:调试时不要对着整个大数据跑,先写个暴力函数,随机生成 n <= 50 的小数组,两版结果对拍,再故意构造一些全 0、最大值、负数用例,把这几个套路走一遍,基本上一次提交就能过。这套组合拳我用了很久,踩过的坑也全是在这些用例上暴露出来的,希望你也能少走点弯路。