之前做过几次技术分享,都是拿暴力解法撑场面,后来刷到「两数之和」的有序数组版本才发现,这个看似只有几个字的条件变化,直接把解法从哈希表换成了双指针。很多人在LeetCode 1题上做得滚瓜烂熟,遇到167题就隐隐觉得“不太对,好像哪里变了”,但又说不清到底该用哪种思路。今天这篇就把有序数组版的两数之和从头到尾拆透,内容包括:为什么有序这个前提能改变整个算法选型、双指针背后的单调性原理、完整的代码实现与边界处理,以及从这道题延伸出去的一整套面试考点和实战技巧。无论你是刚开始刷数据结构与算法的小白,还是准备面试需要快速过一遍题型的老手,这篇都值得仔细看一遍。
1. 两数之和有序版到底在考什么
1.1 从无序版到有序版:同一个名字,两种解法
LeetCode上最出名的入门题大概就是第1题「两数之和」:给你一个无序数组和一个目标值,找出两个数使其和等于目标值,返回下标。绝大多数人的第一反应是哈希表,遍历一遍,把“差值”存进map里,O(n)时间O(n)空间结束战斗。
但到了第167题,题目变成了:数组是升序排列的,返回的索引还要求从1开始计数。表面上看只是加了一个“有序”限定条件,可如果依旧无脑套哈希表,虽然能通过,面试官大概率会追问一句:“既然数组已经有序,你为什么不利用这个条件?”
这句话一出来,很多人的思路就卡住了。因为习惯了无序场景下的哈希表解法,潜意识里觉得“能跑就行”,却忽略了有序数组本身就是解题线索。实际上,有序条件真正解锁的是双指针——时间复杂度同样是O(n),但空间复杂度降到了O(1),而且代码简洁到面试时手写几乎不会出语法错误。
数据结构与算法的学习里,“识别条件并选择对应解法”恰恰是比“记住某一段代码”重要得多的能力。两数之和这两个版本放在一起对比,就是训练这种识别能力的最短路径。
1.2 有序数组给的“额外条件”为什么值钱
如果你把数组当成一个已经排好队的序列,“无序”意味着你无法对任意两个元素的大小关系做任何预测,必须靠哈希表记录已经见过的元素;“有序”则意味着你可以根据当前两个数字的和,判断接下来该往哪边走。
这种“根据当前结果调整下一步”的能力,正是双指针类算法的核心。它不需要额外存储空间,不需要预处理数组,也不需要复杂的数学推导,仅仅是利用了有序序列本身的大小关系。
我在实际写代码时的一个体会是:能利用输入数据自带的结构,就绝不用通用方案兜底。通用方案(哈希表)在任何情况下都能跑,但它没有把数据特点转化为算法优势。面试中区分“能解题”和“解得好”的,往往就这一点。
2. 暴力枚举的代价与优化的第一直觉
2.1 暴力解法:数据规模一大就崩的典型
先别急着写双指针,我们从头把思路捋一遍。最粗暴的做法是双重循环,枚举所有数对组合,判断它们的和是否等于target。时间复杂度O(n^2),空间复杂度O(1)。
def two_sum_brute(nums, target): n = len(nums) for i in range(n): for j in range(i + 1, n): if nums[i] + nums[j] == target: return [i + 1, j + 1] return []这段代码在数组长度几百的时候完全没问题,但一旦数据规模到10^4以上,O(n^2)的运算量就会迅速膨胀到亿级别,在限时1秒的编程环境里基本宣告超时。暴力枚举算法本身不是不能用,它最大的价值是作为“基准答案”帮助验证其他解法的正确性——我在调试窗口期经常先跑暴力结果,再用优化解法对比输出,能快速定位逻辑漏洞。
2.2 二分查找:把外层遍历变成“带索引的扫描”
既然数组有序,很多人会自然想到:固定一个数nums[i],剩下的问题就变成“在i+1到n-1范围内查找target - nums[i]”。有序数组上的查找,二分查找是首选。
def two_sum_binary(nums, target): n = len(nums) for i in range(n): need = target - nums[i] lo, hi = i + 1, n - 1 while lo <= hi: mid = (lo + hi) // 2 if nums[mid] == need: return [i + 1, mid + 1] elif nums[mid] < need: lo = mid + 1 else: hi = mid - 1 return []这个解法的时间复杂度是O(n log n),空间复杂度O(1)。它已经比暴力枚举好很多,而且思路非常直观:外层线性扫,内层二分找。很多教科书把它当作“两数之和有序版”的标准答案之一,但如果你愿意再往深处想一步,会发现还有更优的路径——既然内层查找也是靠着有序性,那外层能不能也不线性扫完?
2.3 哈希表:通解存在,但有序条件下它不是最优解
说回哈希表解法。它在无序数组下是王者,代码长这样:
def two_sum_hash(nums, target): seen = {} for i, num in enumerate(nums): need = target - num if need in seen: return [seen[need] + 1, i + 1] seen[num] = i return []时间复杂度O(n),空间复杂度O(n)。不管数组有没有序,它都能给出正确答案,这是它“通解”的一面。但在有序数组场景下,它的缺点是明显的:
- 多用了O(n)的哈希表空间,在嵌入式、内存受限的环境里可能是负担;
- 哈希表的常数因子比数组索引访问大得多,实际运行往往慢于双指针;
- 更关键的是,它完全无视了“有序”这一数据结构特性,等于把题目白送的条件扔在一边。
很多人会问:“反正复杂度都是O(n),哈希表也能AC,为什么面试非要用双指针?”答案很简单:因为面试考的不是“能不能过”,而是“你能不能发现并利用输入数据的结构”。数据结构与算法学习中反复强调的“选择合适的结构”,在两数之和这道题上体现得淋漓尽致。
3. 双指针的核心逻辑:单调性让每次移动都有依据
3.1 指针移动规则:大了收右,小了放左
双指针解法的代码非常短,但背后的原理一点也不简单。先把代码摆出来:
def two_sum_sorted(nums, target): left, right = 0, len(nums) - 1 while left < right: current_sum = nums[left] + nums[right] if current_sum == target: return [left + 1, right + 1] elif current_sum < target: left += 1 else: right -= 1 return []规则就两条:当前和小于target,说明整体太“小”,只能把左指针往右移,让和变大;当前和大于target,说明整体太“大”,只能把右指针往左移,让和变小。
很多初学的人会陷入一个困惑:凭什么我移动左指针之后,不会错过原本左指针和中间某个数组成的正确答案?这就是接下来要解释的单调性。
3.2 为什么移动指针不会漏掉正确答案
我们假设当前指针是left和right,它们的和是s。
情况一:s大于target。这时对于任意一个索引k,只要left < k < right,因为数组升序,一定有nums[left] + nums[k] >= nums[left] + nums[right] = s > target。也就是说,所有以nums[left]为左数的组合,和只会比s更大,没有一个可能等于target。于是nums[left]这个元素可以安全地排除,左指针理应向右移动。但这里我们实际移动的是右指针,因为和已经太大了,继续保留nums[right]只会让组合更大。实际上,s > target时,以nums[right]为右数的组合中,只有nums[left]+nums[right]可能小于等于s,而它已经验证过不等于target了,所以排除nums[right]也是安全的。
情况二:s小于target。同理,对于任意k(left < k < right),nums[k] + nums[right] <= nums[left] + nums[right] = s < target,所有以nums[right]为右数的组合都不可能等于target,于是右指针可以安全地向左移动。
每一轮,两个指针中至少有一个指向的元素会被永久排除。数组长度n,最多n-1轮就能遍历完所有“有可能”的组合。这就是双指针不会漏解的原因:每次移动都是基于已证明的“排除区域”,不是盲目尝试。
这种论证方式和算法设计里常用的“安全剪枝”思路一脉相承。把解空间想象成一个n×n的上三角矩阵,双指针每走一步,就划掉一整行或一整列,最终剩下那条“有可能产生答案”的对角线。暴力枚举是把这个矩阵每个格子都看一遍,双指针则是根据单调性直接告诉你哪些行哪些列不用看。
3.3 双指针不是“两头夹”,它和快慢指针是两码事
很多人一听到双指针,脑子里全是“链表判环”“找链表中点”里的快慢指针,那是另外一个世界。链表里的双指针往往一个走得快一个走得慢,靠速度差来检测环或找中点;而两数之和有序版的双指针是“背向双指针”,一个从最左开始,一个从最右开始,朝中间收缩。
同样叫“双指针”,适用的数据结构和推导逻辑完全不同。链表类的双指针依赖的是“是否存在环/步数关系”,数组类的双指针依赖的是“数组有序性带来的单调性”。面试时如果说不清这一点,面试官很容易判断你是背了模板还是真的理解算法。
4. 完整代码实现与边界处理
4.1 Python和Java实现与说明
Python版本上面已经给过了,这里补一个Java版本,方便不同技术栈的读者对照:
public int[] twoSum(int[] numbers, int target) { int left = 0, right = numbers.length - 1; while (left < right) { int sum = numbers[left] + numbers[right]; if (sum == target) { return new int[]{left + 1, right + 1}; } else if (sum < target) { left++; } else { right--; } } return new int[]{-1, -1}; }说几个实现细节。首先是循环条件while (left < right),注意不是<=。如果left和right相遇,说明指向的是同一个元素,一个数不能用两次,所以必须严格小于。其次是返回值left + 1和right + 1,因为题目要求的是从1开始计数的索引,这一点非常容易写错。
4.2 三种情况的处理逻辑
完整的状态机其实就三种:
- current_sum等于target:命中,直接返回。
- current_sum小于target:左指针右移,增大和。
- current_sum大于target:右指针左移,减小和。
看起来简单,但有一个隐藏问题是:如果数组里存在负数怎么办?答案是不影响,因为升序排列在负数区间同样成立,nums[left] + nums[right]的单调性依然存在。比如[-3, -1, 0, 2, 5],target = -4时,初始左-3右5和为2,大于-4,右指针左移到2,和变成-1,还是大于-4,继续右移到0,和变成-3,仍大于-4,继续右移到-1,和变成-4,命中。整个过程完全成立。
4.3 索引从1开始、重复元素这些坑
这道题最大的“坑”就是索引从1开始。LeetCode 167题的描述里明确写了返回的索引是从1开始的,很多人因为习惯0-based索引,直接在LeetCode上提交返回[left, right],系统判定就错了。我在LeetCode讨论区见过不少类似的初学提问,其实只要认真读一遍题,把返回值加1的事放在心里,这个坑一天能排掉。
另一个容易纠结的点是“重复元素”。比如数组[1, 2, 2, 3],target = 4,按照双指针流程:left=0指向1,right=3指向3,和=4命中,返回[1,4],完全正确。如果target=5,left=0 right=3和4小于5,left移到1指向2,right=3指向3,和=5,返回[2,4],也是正确。双指针不关心值是否重复,因为它找的是“值组合”而不是“下标组合”,重复元素不影响正确性。
但如果题目要求返回所有不重复的下标组合,那又另当别论,需要在找到一组后继续移动指针,并且跳过重复值——这部分在延伸章节再展开。
5. 从两数之和延伸出去:一套双指针打天下
5.1 三数之和:排序+固定一个数+双指针
两数之和有序版学会之后,三数之和几乎是顺水推舟的事。LeetCode 15题要求找出所有和为0的三元组,并且不重复。标准解法就是:先排序,然后固定第一个数nums[i],在它右侧的区间里跑双指针找两数之和等于-nums[i]。
def three_sum(nums): nums.sort() res = [] n = len(nums) for i in range(n - 2): if i > 0 and nums[i] == nums[i - 1]: continue left, right = i + 1, n - 1 while left < right: total = nums[i] + nums[left] + nums[right] if total == 0: res.append([nums[i], nums[left], nums[right]]) while left < right and nums[left] == nums[left + 1]: left += 1 while left < right and nums[right] == nums[right - 1]: right -= 1 left += 1 right -= 1 elif total < 0: left += 1 else: right -= 1 return res这个解法的时间复杂度是O(n^2),去重逻辑是这道题的真正难点。排序让重复元素相邻,固定i时跳过重复值,找到一组答案后跳过重复的left和right值,三处去重缺一不可。两数之和有序版如果没吃透,三数之和的代码看起来就像天书;反过来,吃透了两数之和的指针移动原理,三数之和只是在外层加了一层for循环而已。
5.2 盛最多水的容器与最接近的三数之和
双指针的适用面远不止求和类题目。LeetCode 11题“盛最多水的容器”,给一组高度数组,找两根柱子使其与x轴围成的容器面积最大。面积公式是min(height[left], height[right]) * (right - left)。如果当前左柱高度较低,最优策略是右移左指针,因为无论如何移动右指针,容器高度都不会超过当前较低的左柱,而宽度还在减少,所以只能移动左柱才有可能增大面积。这个推理和两数之和里的单调性论证如出一辙。
LeetCode 16题“最接近的三数之和”,同样需要排序加双指针,只是把“是否等于target”的判断改成“不断更新最小差值”。这类题目练个三五道,你就会发现双指针的核心从来不是“指针怎么动”,而是“当前状态如何排除一部分不可能的答案”。把这一点想明白,几乎所有双指针题的思路都能统一起来。
5.3 双指针的适用边界:什么时候不该用
我也见过不少把双指针用错场景的人。双指针在有序数组或可以排序的场景里格外好用,但一旦数组无序且不允许排序,比如题目要求维护原始下标,那就必须回到哈希表方案。LeetCode 1题就是这种情况——虽然有人会强行排序后用双指针,但那样得到的索引是排序后的索引,已经和原始数据脱节,需要额外记录映射关系,复杂度反而更高。
还有一个常见误区:以为“两个数组有序”就一定能用双指针。比如合并两个有序数组确实可以用双指针,但那是因为两个数组内部各自有序,双指针维护的是“谁更小谁先走”的合并顺序。和两数之和里的“一左一右互相逼近”逻辑不同。判断一个场景能不能用这个套路,最稳妥的方法是像3.2节那样证明指针移动不会漏解,而不是凭感觉套模板。
6. 面试、刷题与工程实践中的真实体验
6.1 面试官想从这道题里看到什么
这道题在算法面试中出现的频率高到离谱,但它的定位不是“难题”,而是“基础题”。面试官把它放在第一题,通常不是为了刁难你,而是想观察三件事:能不能快速想到正确解法、能不能在追问下优化到最优解、能不能清晰讲解自己的思路。
我身边一个朋友去面后端岗位,第一题就是两数之和有序版。他直接给出哈希表解法,面试官点头,然后追问“有没有空间O(1)的解法”。他卡住了,因为没把“有序”这个条件当回事。最后虽然也过了面上,但这一轮的反馈明显不好。反例说明,刷题阶段如果只追求“AC”,不深入理解条件与解法的关系,面试时很容易暴露短板。
所以我的建议是:刷LeetCode 167题时,至少要能一口气说出三种解法——暴力、二分、双指针,并分别给出时间空间复杂度。这比单纯记住双指针代码有价值得多。
6.2 三种解法复杂度对比与候选场景
| 解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|
| 暴力枚举 | O(n^2) | O(1) | 数据规模极小,或作为验证基准 |
| 二分查找 | O(n log n) | O(1) | 有序数组,内存受限但可接受log因子 |
| 哈希表 | O(n) | O(n) | 无序数组,或不允许排序的场景 |
| 双指针 | O(n) | O(1) | 有序数组,面试最优解 |
实际工程里,如果拿到的是一个有序数组并且只需要一组答案,双指针几乎是唯一需要写进代码评审的方案。哈希表并非不能工作,但如果你的服务是百万级QPS的接口,每次请求都new一个HashMap的开销累积起来,会让性能调优的人眉头紧锁。
6.3 我在实际代码评审中见过的错误用法
最后说点我在真实工作里见过的写法问题。有人会把两数之和的双指针逻辑封装成一个通用函数,输入条件却是无序数组,导致结果不稳定。这就是没搞清楚双指针的前提条件——先检查数组是否有序,再用双指针。如果数组无序但可以排序,且题目不要求返回原始下标,也可以先排序再用双指针,但一定要在注释里写清楚排序改变了索引这一事实。
还有人写Java版时用了Arrays.asList和泛型来包返回结果,反而把简单问题复杂化。基础数组操作就足够了。
我在实际刷题和带新人时最常说的一句话是:两数之和有序数组版不只是“一道题”,它是双指针这一大类算法的“最小可运行样例”。把这道题从暴力到双指针的优化路径完整走一遍,比刷很多道同类型题目都有用——因为优化的每一步背后都对应一类可复用的思维模式。
最后再分享一个小技巧:如果你在面试考场上实在想不起来双指针的证明,就记住一句话——“大了收右,小了放左,单调性保证不会漏”。先把代码写出来,再在此基础上解释为什么,大多数面试官都能接受。但如果连这句话也忘了,那就老老实实写哈希表,至少能拿到基础分,千万不要在面试现场卡死在空间O(1)的执念里。做题是为了解决问题,能解决问题的方法都是好方法,只是在有序条件下,双指针恰好是那个简单又漂亮的最优解。