双指针技巧破解LeetCode 167:有序数组两数之和最优解
2026/9/8 7:04:45 网站建设 项目流程

1. 写在前面:这道题为什么值得反复刷

LeetCode 167题“两数之和 II - 输入有序数组”几乎是我每次向身边朋友推荐入门题单时都会放在前五的一道题。它的题干极其简单:给定一个已按非递减顺序排列的整数数组,找出两个数使得它们的和等于目标值,返回这两个数在数组中的下标(从1开始计数),并且保证有且仅有一个答案。

这道题让我觉得“值得反复刷”的原因有两个。第一,它是双指针技术最经典的落地场景之一,理解了这道题,后面再做三数之和、四数之和、盛最多水的容器、接雨水这些题目,都会顺畅很多;第二,这道题背后藏着“有序数组”这个条件带来的算法优化空间——同样的两数之和问题,在无序数组里要用哈希表做到O(n),但在有序数组里,双指针可以做到O(1)额外空间,这种“利用数据特性”的思路,恰恰是日常业务开发中最常被忽略的。

网上关于这题的题解非常多,但大部分只贴一段能通过的代码就结束了。我打算把这道题掰开揉碎,从暴力解法为什么不行讲起,再到双指针为什么行、边界条件怎么处理、代码怎么写才能不踩坑,最后再把一题多解的思路和复杂度分析放在一起对比。无论你是刚刷题的新手,还是在准备面试想查漏补缺的开发者,这篇内容应该都能给你一些启发。

提示:题目的目标读者可能以为这题只是“哈希表的简单变种”,但实际上,它考察的是对数组有序性这一隐含条件的利用程度。多花一点时间理解指针移动的依据,比背下代码重要得多。

2. 题目理解与整体设计思路

2.1 先看题目说了什么:从需求到算法约束

题目原话的关键点有三个:数组是有序的(非递减)、只需要找一组答案、下标从1开始。这三个条件每一个都在影响解法设计。

第一个条件“有序”是这题最大的突破口。数组有序意味着什么?意味着我们可以通过比较当前两个指针所指元素的和与目标值的大小关系,来判断指针该往哪个方向移动。这种“根据比较结果缩小搜索范围”的思路,是二分查找、双指针这类算法的共同底层逻辑。

第二个条件“有且仅有一个答案”则让解法可以更激进。很多题目要求返回所有不重复的组合,需要额外处理去重逻辑,但本题不需要,这让双指针写法可以简化到极致。

第三个条件“下标从1开始”是一个典型的“面试陷阱”。LeetCode上大部分数组题都默认从0开始计数,但这题为了贴近现实中的“第几个元素”的表述习惯,强制要求下标加1。很多人在这个细节上吃过亏,代码逻辑全对,结果因为忘了加1而提交失败。

把这三个条件汇总一下,这道题本质上是一个“有序数组中的两数之和”问题,最优解要做到时间复杂度O(n)、空间复杂度O(1)。这组复杂度指标,就是双指针解法能给出的最终答案。

2.2 从暴力到哈希表再到双指针:我为什么推荐最后一种

先说暴力解法。两层循环枚举所有组合,时间复杂度O(n²),在n的规模稍大时完全不可用。这是新手的第一直觉,但不是我们讨论的重点。

再看哈希表解法。遍历数组,每遇到一个元素就检查target减去当前值的差是否已经在哈希表里,如果在,就直接返回。时间复杂度O(n),空间复杂度O(n)。这个解法最大的优点是不依赖数组有序,所以在无序数组场景下它是首选。但回到本题,既然题目给了“有序”这个条件,再用哈希表就有点浪费了。

双指针解法是专门针对有序数组优化的思路。一个指针指向数组头部,一个指针指向尾部,计算两个指针所指元素的和。如果和小于目标值,说明需要更大的数,左指针右移;如果和大于目标值,说明需要更小的数,右指针左移;如果相等,直接返回。整个过程只需要一次遍历,时间复杂度O(n),空间复杂度O(1)。

我之所以推荐双指针,不仅仅因为它的复杂度更优,更因为它体现了算法设计中的一个核心思想——利用数据的固有结构来减少不必要的计算。哈希表是用空间换时间,双指针则是用数据特性换空间,两者没有绝对的好坏,但在这道题的场景下,双指针明显更“优雅”。

2.3 双指针为什么能保证不遗漏正确答案

这里有一个非常关键的问题:左指针向右移动时,会不会漏掉正确答案?右指针向左移动时,会不会错过某个组合?

不会,原因在于数组的有序性。假设正确答案是下标i和j(i < j)。当我们把左指针l移动到i之前的位置,右指针r停在j或j之后的位置时,如果当前两数之和小于目标值,说明左指针l指向的元素太小了,它不可能与任何位于它右侧的元素组成正确答案吗?不,它有可能和某个右侧元素组成正确答案,但这个右侧元素一定还在右指针的更右边,可我们没有搜索那里,这会不会漏?

不会,因为右指针是从最右端向左移动的。当右指针到达j时,左指针l一定还在i的左侧(如果l还没到i的话)。此时nums[l] + nums[j]一定小于target,所以左指针会继续右移,直到到达i。当左指针到达i后,如果右指针还没到达j,此时nums[i] + nums[r]一定大于target,所以右指针会继续左移,直到到达j。两个指针最终会相遇在i和j的位置。

这个证明过程看起来很绕,但核心就一句话:每次移动指针时,我们都排除了一个指针位置上所有不可能的组合,而这些被排除的组合绝不可能是正确答案。正因为数组有序,我们才能通过一次比较知道应该排除哪一侧,这也是双指针正确性的根基。

3. 核心细节解析与易错点说明

3.1 指针移动的“单调性”:为什么左指针只往右走

我在给同事讲这题时,经常被问到同一个问题:为什么左指针只能向右移动,不能在某些时候向左退回来?为什么右指针只能向左移动?

答案在于:整个搜索过程是一个不断收缩区间的过程。左指针向右移动代表“当前最小的数都太小了,需要更大的数”,右指针向左移动代表“当前最大的数都太大了,需要更小的数”。一旦左指针越过了某个位置,就意味着这个位置上的数和右指针当前位置上的数相加已经小于目标值了。

问题是,左指针越过这个位置之后,右指针也在向左移动(变得更小),被跨过的这个位置的数配合一个更小的右指针值,不就更小于目标值了吗?所以这个被跨过的位置永远不可能再组成正确答案。用数学语言说,就是每次指针移动后,搜索区间都会严格缩小,而且缩掉的区域里不存在正确答案,这就保证了算法的完备性。

这里的关键是“指针移动方向是单调的”。很多双指针题目的进阶变种,比如三数之和里的去重逻辑,就需要额外考虑跳过重复元素;而本题因为保证唯一解,不需要考虑这些,但理解单调性依然是后续题目的基础。

3.2 三个常见的“坑”:下标、相等判断与循环边界

第一个坑是下标从1开始。返回结果前每个下标加1就行,但我在实际写代码时经常见过这种错:循环里还在用0基下标,返回时忘了加。建议在编码的最后一刻统一处理下标偏移,不要中途把指针初始化成1,那样会让数组访问越界。

第二个坑是两数相加的溢出问题。如果数组中存在很大的正数和负数,两个int相加可能溢出。LeetCode这题的数据范围一般是int范围内,但鲁棒性好的代码建议用long类型存储和值,或者用减法比较(比如判断 nums[left] > target - nums[right]),这样能完全避免溢出。

第三个坑是循环条件。很多初学双指针的人会把循环写成 while (left <= right),但如果left和right相等时指向了同一个元素,题目要求“两个不同的数”,所以必须严格使用 left < right。这个细节在面试中经常被追问,答案是“left和right指向同一个位置时,两个数不是两个不同元素,不满足题意,因此left < right更严谨”。

注意:当数组长度极短时(比如只有两个元素),left < right这个条件也能正确运行一次循环。但如果用left <= right,在极端情况下可能错误地把同一个元素算了两次(比如数组只有一个元素且该元素恰好等于target的一半,不过本题保证有两个数的和等于target,因此不会发生,但为了通用性仍然建议left < right)。

3.3 边界情况逐一验证:空数组、单元素、负数混排

为了测试代码的健壮性,我通常会构造几组特殊用例来验证。

第一组是空数组和只有一个元素的数组。这种情况无论如何都不可能有答案,但题目保证有答案,所以不会出现。不过在自己实现时,最好加一层防御判断,返回空数组而不是让指针越界。

第二组是全负数数组。例如数组[-9, -5, -3, -1],target = -8,正确结果是[-9, 1](下标1和4)。双指针在这种情况下依然稳定工作,因为负数也满足非递减排序,指针移动的依据是“和的大小关系”,和正负无关。

第三组是包含0和负数的混合数组。例如[-3, 0, 3, 5],target = 0。此时左指针在-3,右指针在5,和为2大于0,右指针左移到3,-3 + 3 = 0,正确返回。

这些边界用例本身的逻辑都不复杂,但每一次验证都在加深一个认知:双指针不关心数组里装的是什么,只关心数组是否满足有序性。只要有序,这个解法就成立。

4. 实操过程:完整代码推导与写法示范

4.1 标准双指针的Java实现

下面这版代码是我个人最常用的写法,简洁、没有多余分支,也做了基本的防御性判断。

public int[] twoSum(int[] numbers, int target) { if (numbers == null || numbers.length < 2) { return new int[]{-1, -1}; } int left = 0; int 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}; }

这段代码的逻辑顺序是:先做空值防护,然后初始化指针,进入循环。循环内计算当前和,如果等于目标值就直接返回,小于目标值左指针右移,大于目标值右指针左移。循环结束后如果没有找到(虽然题目保证能找到),就返回一个默认的无效值。

这里我特别说一下防御性判断。虽然题目保证有答案,但你在面试中写代码时,面试官经常会在你写完主逻辑后问一句“如果输入不合法怎么办”。提前写好防御逻辑,既能防止自己后面忘,也能展示你对异常输入的敏感度,是加分项。

4.2 Python版本的极简写法与对比

由于LeetCode刷题时Python的使用率很高,我也提供一个Python版本,顺便从代码风格角度做个对比。

def twoSum(numbers, target): left, right = 0, len(numbers) - 1 while left < right: current_sum = numbers[left] + numbers[right] if current_sum == target: return [left + 1, right + 1] elif current_sum < target: left += 1 else: right -= 1 return [-1, -1]

Python版本和Java版本的核心逻辑完全一致,只是语法层面更简洁。我见过有人用Python写这个题时,把elif current_sum < target简化成三元表达式,但可读性反而下降。刷题时代码的可读性同样重要,因为面试中你需要一边写一边解释思路,逻辑清晰比代码短更有利。

4.3 手把手走一遍完整执行流程

我用一个具体例子来演示执行流程。假设数组是[2, 7, 11, 15],target是9。

初始化时,left = 0,right = 3。第一次循环,2 + 15 = 17,大于9,所以right左移,right变成2。第二次循环,2 + 11 = 13,还是大于9,right继续左移,right变成1。第三次循环,2 + 7 = 9,等于target,返回[1, 2]

再看一个稍微复杂点的例子,数组是[1, 3, 4, 5, 7, 10, 11],target是9。left = 0,right = 6,1 + 11 = 12大于9,right左移到5;1 + 10 = 11大于9,right左移到4;1 + 7 = 8小于9,left右移到1;3 + 7 = 10大于9,right左移到2;3 + 4 = 7小于9,left右移到2;此时left和right都指向下标2,循环结束。等等,这个例子是不是有问题?target = 9,数组中1和8没出现,3和6没出现,4和5的和是9,但4的下标是2,5的下标是3,为什么循环结束时left和right都到了2?

让我重新走一遍:数组是[1, 3, 4, 5, 7, 10, 11],第一次1 + 11 = 12 > 9,right = 5;第二次1 + 10 = 11 > 9,right = 4;第三次1 + 7 = 8 < 9,left = 1;第四次3 + 7 = 10 > 9,right = 3;第五次3 + 5 = 8 < 9,left = 2;第六次4 + 5 = 9,返回[3, 4]。前面是我模拟失误,漏看了4和5这一组。这个例子其实正好展示了双指针在中间段才找到答案的过程,比一上来就找到答案的例子更能说明指针交替移动的节奏。

4.4 代码的细节优化与写法取舍

关于代码写法,还有几个可以探讨的优化点。

第一个是用减法避免溢出。我上文提到过,nums[left] + nums[right]在极端数值下可能溢出,可以把判断条件改成nums[left] > target - nums[right],这样两个数都不超过int范围,差值也不会溢出。但代价是代码可读性稍差。实际刷题时,如果题目明确说明数据范围在int范围内,直接相加问题不大;如果没说明,稳妥起见用减法。

第二个是提前终止条件。有些题解会在numbers[left] * 2 > targetnumbers[right] * 2 < target时提前退出,利用了“如果最小的两个数之和都大于target,那就不可能有答案”这类数学性质。这种优化在特定数据集上能减少循环次数,但时间复杂度仍然是O(n),而且会让代码多出好几个分支。我个人认为,在面试场景下不要写这种优化,因为面试官更想看到清晰的逻辑,而不是花哨的剪枝。

第三个是关于返回值的约定。题目要求返回长度为2的数组,如果没找到,有人习惯返回null,有人习惯返回空数组。在LeetCode上因为保证有答案,这个分支永远不会执行,但如果你在本地测试或者写工程代码,更推荐返回空数组,因为调用方不必做空指针判断。

5. 一题多解与复杂度分析

5.1 三种解法的复杂度对比表

我把这道题常见的三种解法放在一起做个表,方便一眼看清各自的优劣。

解法时间复杂度空间复杂度是否依赖有序适用场景
暴力枚举O(n²)O(1)仅用于理解题目,无实用价值
哈希表O(n)O(n)无序数组、需要快速查找的场景
双指针O(n)O(1)有序数组,空间敏感场景

从这个表能明显看出,双指针解法在本题的条件下是“时间和空间双优”的解法。但反过来也要看清楚,它有两个限制:一是数组必须有序,二是题目必须保证有唯一解。如果数组无序,想用双指针还得先排序,排序的时间复杂度是O(n log n),反而不如哈希表的O(n)。所以双指针不是万能的,它只是一个“在特定条件下最优”的工具。

5.2 为什么哈希表在这里“不够好”

哈希表解法在不要求返回下标、只要求判断是否存在时非常好用,但本题要求返回下标,而且数组有序,哈希表就显得“杀鸡用牛刀”了。

具体来说,哈希表要额外开一个Map存储每个值和它对应的下标,空间开销是O(n)。在数组很长时,这会带来可观的内存占用。双指针则只需要两个int变量,几乎不占额外空间。在某些对内存极其敏感的嵌入式场景或面试追问“能不能优化空间复杂度”时,双指针是唯一的正解。

另外,从面试官的角度看,如果你在LeetCode 167这道题上直接写哈希表,他会怀疑你是否看到了“有序数组”这个关键条件。题目专门在标题里强调“输入有序数组”,就是想引导你往双指针方向思考。抓住这个暗示,比单纯写对代码更重要。

5.3 从两数之和延伸:三数之和与N数之和的套路

理解了双指针在两数之和中的应用后,可以很自然地向三数之和(LeetCode 15)延伸。三数之和的思路是:先排序,然后固定第一个数,剩下两个数用双指针在剩余区间里搜索。因为要返回所有不重复的三元组,所以需要处理跳过重复元素的问题,这个去重逻辑是很多人的难点。

N数之和的通用套路也是类似的:排序 + 层层固定前N-2个数 + 双指针搜索最后两个数。时间复杂度随着固定层的增加而上升,N数之和的通用复杂度是O(n^(N-1)),空间复杂度O(1)(不考虑排序的栈开销)。理解了167题的双指针,再去刷15、18题会顺畅很多。

5.4 双指针问题的家族图谱

双指针这个概念其实覆盖了很多经典题目,除了两数之和,还有这些常见变种:

  • 快慢指针:用于链表成环检测,例如LeetCode 141环形链表。
  • 左右对撞指针:用于有序数组或字符串,例如LeetCode 125验证回文串、LeetCode 11盛最多水的容器。
  • 滑动窗口:本质也是双指针的一种,只是两个指针的移动方向相同,共同维护一个窗口,例如LeetCode 3无重复字符的最长子串。

理解167题的双指针,相当于给整个双指针家族打了一个地基。后面的题目多半是在这个基础上加一些额外的条件或数据处理逻辑,核心的指针移动思想是一致的。

6. 常见问题与排查技巧实录

6.1 刷题现场最容易犯的5个错误

我把平时在讨论区里看到的高频错误整理了一下,也包含我自己曾经踩过的坑。

第一个错误,忘记下标从1开始。逻辑全对,返回[left, right]而不是[left + 1, right + 1],提交后报错,非常可惜。

第二个错误,循环条件写成left <= right。当两个指针指向同一个元素时,可能把同一个元素用了两次。虽然本题保证有解且不会出现这种极端情况,但这是一个错误的写法习惯。

第三个错误,指针移动方向写反。sum < target时应该让左指针右移以增大和,有人会写成右指针右移,导致死循环或数组越界。

第四个错误,没有处理空数组或单元素数组。在本地测试时传入空数组会抛数组越界异常,虽然LeetCode测试用例不一定覆盖,但自己调试时会很痛苦。

第五个错误,将双指针用在无序数组上。直接把双指针解法套到一个未排序的数组上,结果自然不对,这相当于忽略了题目条件。

注意:排查这类问题时,最有效的手段是构造边界用例。把数组长度设为0、1、2,把target设为数组首尾之和、中间两个数之和等,基本能覆盖大部分逻辑错误。

6.2 一个真实调试案例:死循环是怎么产生的

有一次我帮一个朋友看他的代码,发现他在sum < target时写的是right++,在sum > target时写的是left--,整个指针移动方向和正确方向完全相反。

这种错误会导致什么结果?left从0开始,right从末尾开始。如果left向右移动(正确方向)但不该移动时移动了,就会越过正确答案;如果right也向错误方向移动,两个指针的运动轨迹就是混乱的,极端情况下会无限循环或者数组越界。他的代码在几个小用例上碰巧能通过,因为数组恰好有序且目标值恰好让错误移动回到了正确位置附近,但一旦换了测试数据就直接超时。

排查的方法很简单:在循环里打印left、right和当前sum的值,肉眼观察指针移动是否符合预期。如果发现指针在来回横跳或者一直往一个方向冲,基本就是移动方向写反了。

6.3 本地测试用例设计模板

我在本地刷题时,习惯提前写一组通用测试用例,这样每道题写完代码都可以快速验证。针对167题,我会准备这些用例:

  • 普通场景:[2, 7, 11, 15], target = 9,期望[1, 2]
  • 负数场景:[-9, -5, -3, -1], target = -8,期望[1, 4]
  • 连续相同值场景:[1, 2, 3, 3, 4], target = 6,期望[2, 4](2 + 4)或[3, 4](3 + 3),取决于哪个先被找到。
  • 首尾组合场景:[1, 3, 4, 8], target = 9,期望[1, 4]
  • 只有两个元素的场景:[1, 2], target = 3,期望[1, 2]

把这些用例一次性跑过,基本可以确认代码在常规情况下没问题。再配合随机生成的数组和暴力解法对拍,可以进一步验证正确性。对拍是一个很好的刷题习惯,尤其适合验证这类指针移动类算法。

6.4 面试中的追问与应对思路

这道题在面试里经常会有追问,我收集了几个高频问题,供大家参考。

第一个追问是“如果数组不是有序的怎么办”。正确回答是:用哈希表,时间O(n)、空间O(n)。如果面试官追问能否原地解决,可以先排序再用双指针,时间O(n log n)、空间O(1)。要注意这里的空间复杂度是否包含排序递归栈,需要根据具体排序算法说明。

第二个追问是“如果有多个答案,怎么返回所有组合”。这时要去重,方法是在找到一组答案后,左指针向右跳过重复值、右指针向左跳过重复值,再继续搜索。这个逻辑在LeetCode 15三数之和里会出现。

第三个追问是“如果数组里有重复元素,会不会影响双指针查找”。不会,因为题目只要返回任意一组答案,就算数组里有重复值,也能在指针移动时找到一组有效组合。但如果是去重版本,就需要处理跳过重复值。

第四个追问是“为什么不用二分查找”。严格来说,这道题也可以用二分,比如遍历每个元素,然后在剩余区间里二分查找target与当前值的差,时间复杂度O(n log n),空间O(1)。但双指针O(n)更优,所以双指针是正解。这个追问主要是考察你对不同算法复杂度的敏感度。

7. 我个人的刷题体会

这道题我在不同阶段刷过好几次,每次都有不同的感受。第一次刷的时候,我还在用暴力解法,觉得两层循环也挺好,数据量小的时候压根感觉不到性能差异。后来开始刷中等题、难题,才发现基础算法掌握得牢不牢,直接决定了后面解题的上限。

双指针这个技巧,说难不难,但说简单也不简单。难的地方在于理解“为什么可以移动指针而不遗漏答案”,简单的地方在于一旦理解,代码就是几行的事。我在带新人时经常说,刷题不要只追求AC,要多花几分钟复盘一下:这一题的解法利用了题目的哪个隐含条件?这个条件如果去掉,解法会不会变?如果答案的数量从1个变成多个,代码要加什么逻辑?每多想一个问题,这道题的价值就能多发挥一分。

LeetCode 167是一道很好的“模型题”,它把有序数组、双指针、复杂度优化这几个核心概念压缩在一个非常小的题目里。把这题吃透,再往三数之和、滑动窗口、快慢指针走,会发现很多东西都是相通的。

最后分享一个小技巧:刷题时遇到双指针问题,可以在草稿纸上画一个数组,把左指针和右指针的位置标出来,每移动一次就画一个新状态。画几组用例之后,指针移动的规律会变得非常直观,代码写起来也就不会再出方向写反的低级错误了。这个方法我推荐给很多人,反馈都说比自己闷头想代码要快得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询