news 2026/9/24 21:44:17

从“奇怪球”到双指针:排序数组计数的优化实战与细节解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从“奇怪球”到双指针:排序数组计数的优化实战与细节解析

2024年9月21号那场练习赛里,我被一道叫“奇怪球”的题卡了二十多分钟。题面叙述挺玄乎,什么魔法球、魔力值、能量阈值,剥掉包装之后其实就是一道非常典型的双指针计数题。这篇文章我把整个思考和实现过程完整写下来,希望给正在刷双指针、或者准备算法面试的朋友一点参考:题目怎么抽象、双指针为什么能优化、Python怎么落地,以及那些特别容易踩的细节坑。

好,下面直接进正文。

1. 题目长什么样:从玄学故事到数学建模

1.1 原题的故事包装

这道题的原题描述大概是这样的(具体措辞我记不全,但核心意思不变):

实验室里有 n 个奇怪的球排成一排,每个球上写着一个整数,叫做“魔力值”。神奇的是,魔力值可以是负数,也可以非常大,这正是“奇怪”的来源。现在给你一个阈值 k,问:有多少对不同的球,它们魔力值之差的绝对值不超过 k?

数据范围我记得很清楚:n 最大到 2×10^5,魔力值的绝对值最大到 10^9,k 同样可以到 10^9。这个范围基本就是在明示:别想用 O(n²) 的暴力,老老实实优化到 O(n log n) 或者 O(n)。

1.2 抽象成数学模型

剥掉故事之后,这个问题的本质非常简单:

给定长度为 n 的整数数组 a,统计满足 i < j 且 |a[i] - a[j]| <= k 的无序对数量。

这里的“无序对”意思是只算一次,比如 (i, j) 和 (j, i) 是同一对。题面里如果没特别说明顺序,通常都是按无序对处理,读题的时候这个细节要注意,不然最后答案会差一倍。

1.3 为什么暴力解法过不了

第一时间能想到的肯定是双重循环,把所有对都检查一遍:

for i in range(n): for j in range(i+1, n): if abs(a[i] - a[j]) <= k: ans += 1

这个写法思路没错,但 n=2×10^5 的时候,内层循环大约要执行 2×10^10 次,也就是 200 亿次比较。就算 Python 每秒能跑 5000 万次简单操作,也得几百秒才能结束,评测系统限时通常只有 1~2 秒,暴力必挂。

注意:看到“n 到 10^5 甚至 10^6 级别”的计数类题目,第一反应就该是双指针、二分、前缀和或者哈希这类 O(n log n)/O(n) 级别的优化手段。

2. 双指针思路的核心:排序消除绝对值

2.1 关键观察:排序后绝对值会“自动消失”

条件里有绝对值,处理起来比较麻烦。一个非常经典的处理方式就是先排序。

排序之后,数组单调不减,如果 i < j,那么 a[j] >= a[i],于是:

|a[i] - a[j]| = a[j] - a[i]

原始条件 |a[i] - a[j]| <= k 就等价于:

a[j] - a[i] <= k

绝对值符号没了,问题变成:对于每个位置靠前的元素,数一数它后面有多少个元素与它的差值不超过 k。排序不会改变“有多少对满足条件”这个答案,因为差值的大小关系在排序后保持不变,只是我们把所有元素按大小重新排列了一遍而已。

2.2 单调性:双指针能跑的底层原因

假设数组已经排好序,现在定义两个指针:

  • left:当前考察的元素下标
  • right:满足 a[right] - a[left] <= k 的最远下标

当 left 向右移动一位变成 left+1 时,因为 a[left+1] >= a[left],同样一个 right 位置如果之前满足 a[right] - a[left] <= k,那么现在必然也满足:

a[right] - a[left+1] <= a[right] - a[left] <= k

也就是说,left 向右走的时候,right 是不可能向左回退的,它只会继续向右扩展。这种“两个指针都朝同一个方向走,谁也不回头”的性质,就是双指针能保证线性时间复杂度的根本原因。

2.3 计数逻辑:每个 left 贡献多少对

对于当前的 left,我们找到最大的 right 满足 a[right] - a[left] <= k,那么在 left 右侧,从 left+1 到 right 这 (right - left) 个位置,每一个都能和 left 组成一个合法对。累加进答案即可。

注意这里不要写成 right - left + 1,因为 left 自己不能和自己配对。

统计完当前 left 之后,left 继续右移,right 保持在原地(或继续右移),不需要重新扫描,整体就是 O(n) 的。

2.4 复杂度分析

整个算法分两步:

  1. 排序:O(n log n)
  2. 双指针扫描:O(n),因为 left 和 right 最多各移动 n 次

合起来是 O(n log n),空间复杂度 O(1)(如果不考虑排序内部使用的栈空间)。这个复杂度在 n=2×10^5 的规模下完全没问题,Python 大概几十毫秒就能跑完。

3. Python 实现与逐行讲解

3.1 标准写法

先给出最标准的双指针实现:

def count_pairs(a, k): a.sort() n = len(a) ans = 0 right = 0 for left in range(n): if right < left: right = left while right + 1 < n and a[right + 1] - a[left] <= k: right += 1 ans += right - left return ans

这段代码非常短,但里面有三处细节值得掰开揉碎讲清楚。

3.2 三个关键细节

第一个细节是if right < left: right = left。正常情况下 right 不会小于 left,因为同一个元素不能和自己配对,right 至少应该等于 left。但为了绝对安全,在循环开头做一次修正,防止某些边界情况(比如上一轮 right 停在原地而 left 已经越过了它)导致 while 条件里出现索引越界或者漏算。

第二个细节是 while 循环里的right + 1 < n。这个条件和a[right + 1] - a[left] <= k一起控制右指针扩展。注意判断的是“下一个位置能不能扩展”,而不是先移动再判断,这样能避免 right 越界之后还要回退的麻烦。

第三个细节是ans += right - left。每次累加的是当前 left 右侧所有能与 left 配对的位置数量。right 指向的是“合法的最后一个位置”,所以数量是 right - left 而不是 right - left + 1。

3.3 用测试用例验证

我写题的习惯是先在心里跑几个小例子再提交,这里给大家留几个常用的验证用例:

输入k输出说明
[]50空数组
[5]1000单元素,没有对
[1,1,1,1]064个相同值,C(4,2)=6
[3,1,4,1,5]13(1,1),(3,4),(4,5)
[-1,2,3]21负数也正常处理

逐个验证一下第三个例子:[3,1,4,1,5],k=1。排序后是 [1,1,3,4,5]。

  • left=0(值1):right 先走到 1,a[1]-a[0]=0<=1;再试 a[2]-a[0]=2>1,停。ans += 1-0=1。
  • left=1(值1):right 不动,a[2]-a[1]=2>1,停。ans += 1-1=0。
  • left=2(值3):right 已经是1,修正为2。a[3]-a[2]=1<=1,right=3;a[4]-a[2]=2>1,停。ans += 3-2=1。
  • left=3(值4):a[4]-a[3]=1<=1,right=4。ans += 4-3=1。
  • left=4(值5):right=4,a[5] 越界,ans += 0。

最后 ans = 1+0+1+1+0 = 3。每个合法对都不重不漏。

3.4 另一种等价写法:二分查找

双指针之外,每个元素配合二分也能做。对排序后的数组,对于每个 i,用二分找到“最后一个值 <= a[i]+k”的位置 idx,那么 i 后面能配对的元素就是 idx - i 个。Python 可以直接用 bisect_right:

from bisect import bisect_right def count_pairs_bisect(a, k): a.sort() ans = 0 for i, x in enumerate(a): idx = bisect_right(a, x + k) - 1 ans += idx - i return ans

这个写法同样正确,复杂度也是 O(n log n)。但双指针除了少一个 log,更重要的是它培养的“单调性思维”能直接迁移到很多滑窗类问题上,所以我更推荐大家把双指针版本吃透。

4. 双指针的变体:奇怪球还能怎么玩

双指针不是一个死板的模板,根据指针移动方向,可以分成好几种形态。这里借着“奇怪球”的设定,讲两个最常考的变体。

4.1 变形一:两数之和小于等于 k

如果题目改成“统计两数之和 <= k 的对数”,排序之后,双指针的移动方向就变了,不再是同向,而是从两端往中间走。

思路很简单:left 指向最小值,right 指向最大值。如果 a[left] + a[right] <= k,说明 a[left] 和 left 右侧任意一个数相加都不超过 k,直接累加 right - left 对,然后 left 右移;否则说明 a[right] 太大了,right 左移。

def count_sum_pairs(a, k): a.sort() left, right = 0, len(a) - 1 ans = 0 while left < right: if a[left] + a[right] <= k: ans += right - left left += 1 else: right -= 1 return ans

这个“相向双指针”是两数之和问题最经典的解法。同样利用了单调性——数组有序之后,固定 left,移动 right 的过程就是不断缩小可行范围的过程。

4.2 变形二:三色球原地排序(荷兰国旗问题)

“奇怪球”如果从魔力值换成颜色,比如红白蓝三种颜色,要求把数组原地排成“红、白、蓝”三段,那就变成了荷兰国旗问题。这个用三个指针 low、mid、high 解决,本质上是双指针思想的再扩展。

def sort_colors(nums): low, mid, high = 0, 0, len(nums) - 1 while mid <= high: if nums[mid] == 0: # 红色,换到前面 nums[low], nums[mid] = nums[mid], nums[low] low += 1 mid += 1 elif nums[mid] == 1: # 白色,已经在中间,直接跳过 mid += 1 else: # 蓝色,换到后面 nums[mid], nums[high] = nums[high], nums[mid] high -= 1

这里有个特别容易误解的地方:为什么 nums[mid] == 0 时交换后 mid 要加 1,而 nums[mid] == 2 时交换后 mid 不加 1?

原因很简单。把 0 换过来时,从 low 位置换过来的元素是已经被检查过的(low 一直走在 mid 前面),它不可能是 2,所以 mid 可以放心前进。但把 2 换过来时,从 high 位置换过来的元素是从来没被检查过的,它可能是 0,也可能是 1,所以 mid 必须停在原地再判断一次。

4.3 双指针家族对比

把常见的双指针形态放一起对比,以后看到题目能更快定位用哪种:

形态典型场景指针移动方式复杂度
同向双指针计数配对、滑窗、最长无重复子串left/right 都只向右O(n)
相向双指针两数之和、回文判断、容器盛水left 右移 / right 左移O(n)
快慢指针链表判环、找中点、数组去重速度不同O(n)
三指针分区荷兰国旗、三色排序low/mid/high 配合O(n)

5. 常见问题与排查技巧

写双指针最怕的不是思路不会,而是代码看起来对了、跑起来就是不对。我把这些年踩过的坑集中整理一下。

5.1 经典错误:right 每次从 left 重新开始

这是我见过最普遍的低级错误。有人写:

for left in range(n): right = left while right + 1 < n and a[right + 1] - a[left] <= k: right += 1 ans += right - left

单独看这个循环内部逻辑没问题,但它每次把 right 重置回 left,导致整体复杂度退化成 O(n²)。当 k 很大时,比如 k 取 10^9,每个 left 都会把 right 扫到数组末尾,n=2×10^5 时就是 4×10^10 次操作,和暴力没区别,评测必超时。

牢记:双指针的精髓是 right 不回退。left 移动之后,right 应该在上一次基础上继续扩展,而不是重新开始。

5.2 忘记排序就直接上双指针

双指针能够工作的前提是数组有序。如果不排序,left 和 right 之间根本不存在单调关系,right 是否该回退完全不可控,答案肯定是错的。

我建议在动手写代码之前,先在注释里写清楚“我准备对数组做什么操作”,如果是双指针计数,排序这一行必须排在所有指针操作之前,没有例外。

5.3 重复元素导致重复计数或漏计数

数组里有大量重复值时,双指针的计数依然正确,前提是你要确保每个 pair 只被统计一次。

在“a[j] - a[i] <= k”这个版本里,我们是固定 left 为较小下标,所有配对都是 left 右侧的元素,所以不会重复。但如果把计数逻辑改成“对每个值统计出现次数然后组合数”,也 OK,只是要注意 C(cnt, 2) 的计算方式,别在 cnt 为 1 时也算出 0 之外的数。

5.4 输入规模大的时候记得优化输入输出

n 到 2×10^5 时,直接用 sys.stdin.read() 一次性读入再 split,比逐行 input() 快得多:

import sys def main(): data = list(map(int, sys.stdin.buffer.read().split())) n, k = data[0], data[1] a = data[2:2+n] print(count_pairs(a, k)) if __name__ == "__main__": main()

这个细节在本地可能感觉不明显,但在线评测数据量大时,能省下不少运行时间。Python 刷题一定要养成用 buffer 读输入的习惯。

5.5 万能调试法:对拍

最后分享一个我刷题时最常用的调试技巧——对拍。写一个 O(n²) 的暴力函数,再写一个双指针的快速函数,随机生成大量小数组,比较两者结果:

import random def brute(a, k): ans = 0 for i in range(len(a)): for j in range(i+1, len(a)): if abs(a[i] - a[j]) <= k: ans += 1 return ans for _ in range(10000): a = [random.randint(-10, 10) for _ in range(random.randint(0, 20))] k = random.randint(0, 15) if count_pairs(a[:], k) != brute(a, k): print("出错:", a, k) break else: print("全部通过")

只要对拍能跑过几千组随机数据,你的实现基本就可以放心提交了。很多隐蔽的边界问题,比如越界、重复计数、排序影响,都逃不过这招。

刷双指针这类题,我个人最大的体会是:别急着写代码,先问自己三个问题。第一,排序会不会改变答案?第二,left 向右走的时候,right 会不会需要回退?第三,每个计数分支会不会重复或者遗漏?把这三个问题想清楚,双指针题目至少能答对八成。这道“奇怪球”虽然包装花哨,但扒开之后就是排序加同向双指针,希望这篇记录能让你少走一点我走过的弯路。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/24 21:43:10

Java贪吃蛇毕业设计:Swing游戏开发与答辩要点全解析

简介&#xff1a;Java毕业设计资源&#xff0c;以贪吃蛇游戏为选题&#xff0c;提供完整源代码与论文文档。面向Java初学者、高校学生及毕业设计开发者&#xff0c;尤其适合需要完成课程设计或毕业设计项目、希望从零理解Java游戏开发流程并快速上手的读者。压缩包共15个文件&a…

作者头像 李华
网站建设 2026/9/24 21:43:07

edsl教程:计算社会科学与市场研究的高效分析工作流

就我这几年帮高校课题组和企业研究团队折腾各类效率工具的经验来看&#xff0c;真正能把“计算社会科学”和“市场研究”这两摊事儿揉到一起的AI产品其实非常少。多数工具要么偏学术、要么偏商业&#xff0c;中间有很大一块空白没人管。所以当我第一次看到edsl的时候&#xff0…

作者头像 李华
网站建设 2026/9/24 21:42:58

GitLab + Arbess + OSS:构建可追溯的 Java 制品流水线

1. 为什么要用 Arbess 把 GitLab 和 OSS 串起来1.1 从“构建靠人盯”到“流水线自动跑”的转变先交代背景。我们团队内部有大量 Java 服务&#xff0c;代码都放在自建的 GitLab 上&#xff0c;但很长一段时间里&#xff0c;构建、打包、传服务器这些环节都靠开发自己手动执行。…

作者头像 李华
网站建设 2026/9/24 21:42:49

Agent Skills实战指南:从函数调用到技能库的设计与实现

"Agent Skills"这一两年在AI工程圈里是实打实的热词&#xff0c;尤其是做LLM应用的朋友&#xff0c;几乎每个技术群里都有人问&#xff1a;Agent到底怎么落地&#xff1f;Skills和Tools到底有什么区别&#xff1f;为什么别人家的Agent能自动拆解任务、自己家的却整天…

作者头像 李华
网站建设 2026/9/24 21:40:40

U2Net轻量化实战:分组卷积压缩至86M,边缘端SOD部署指南

简介&#xff1a;本资源是一套面向计算机视觉初学者与进阶研究者的非特定类别图像分割实践项目&#xff0c;聚焦显著性目标检测&#xff08;SOD&#xff09;在通用图像分割中的落地应用&#xff0c;特别适配轻量化部署需求。项目基于U2Net模型展开深度优化实验&#xff0c;完整…

作者头像 李华