目录
- [LeetCode 167:两数之和 II|从暴力枚举到左右双指针](#LeetCode 167:两数之和 II|从暴力枚举到左右双指针)
-
- 一、题目要求
- 二、从最直接的思路开始:暴力枚举
- 三、关键思路:利用有序性推导双指针
-
- [1. 总和太大,怎么办?](#1. 总和太大,怎么办?)
- [2. 总和太小,又该怎么办?](#2. 总和太小,又该怎么办?)
- [3. 总结指针的移动规则](#3. 总结指针的移动规则)
- 四、双指针完整代码
- [五、补充思路:固定一个数字 + 二分查找](#五、补充思路:固定一个数字 + 二分查找)
-
- [1. 固定第一个数字](#1. 固定第一个数字)
- [2. 用二分查找寻找 need](#2. 用二分查找寻找 need)
- [3. 完整代码](#3. 完整代码)
- 六、复杂度分析
- 七、总结
LeetCode 167:两数之和 II|从暴力枚举到左右双指针
一、题目要求
给定一个按照非递减顺序排列 的整数数组 numbers,找到两个数字,使它们相加等于 target。
题目保证恰好存在一个解,并且不能重复使用同一个元素。
返回这两个数字的下标,注意:下标从 1 开始计算。
例如:
python
numbers = [2, 7, 11, 15]
target = 9
因为:
text
2 + 7 = 9
所以返回:
python
[1, 2]
二、从最直接的思路开始:暴力枚举
要找两个数字,使它们相加等于 target。
最直接的办法是什么?
先固定一个数字,再依次尝试后面的数字。
例如:
python
numbers = [2, 7, 11, 15]
target = 9
先固定 2,然后尝试 7:
text
2 + 7 = 9
找到答案。
换成代码,就是两层循环:
python
class Solution:
def twoSum(self, numbers: list[int], target: int) -> list[int]:
n = len(numbers)
for i in range(n - 1):
for j in range(i + 1, n):
if numbers[i] + numbers[j] == target:
return [i + 1, j + 1]
这个思路没有问题,但它的效率不够高。
why? 因为, 每固定一个数字,我们都需要逐个尝试后面的数字。
最坏情况下,需要检查:
text
(n - 1) + (n - 2) + ... + 1
也就是:
n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n−1)
因此时间复杂度为 O(n²)。
那么, 问题来了:
有没有办法不检查所有组合,就知道哪些数字不可能是答案?
这时候再去重新看题目,会发现一个很重要的条件:
数组已经按照从小到大的顺序排列。
既然数组有序,我们是不是可以利用数字之间的大小关系,减少不必要的搜索?
三、关键思路:利用有序性推导双指针
既然数组已经排序,那么我们至少知道:
- 越往右,数字越大或相等。
- 越往左,数字越小或相等。
如果两个数字相加不等于 target,我们就可以根据总和是太大还是太小,判断应该往哪个方向寻找。
那么,不妨让两个指针分别从数组两端开始。
text
numbers = [2, 7, 11, 15]
↑ ↑
i j
初始化:
python
i = 0
j = len(numbers) - 1
现在:
text
numbers[i] + numbers[j] = 2 + 15 = 17
而我们的目标是 9。
1. 总和太大,怎么办?
现在:
text
17 > 9
说明需要让总和变小。
既然数组有序,我们可以考虑移动右指针 j,换一个更小的数字。
但为什么不是移动左指针呢?
因为 2 已经是当前范围内最小的数字。
连:
text
2 + 15 = 17 > 9
都已经超过目标值,那么用其他更大的数字与 15 相加,只会得到更大的结果。
也就是说:15 不可能与当前范围内的任何数字组成答案。
所以我们可以放心排除 15:
python
j -= 1
这样就不需要再逐个检查:
text
7 + 15
11 + 15
这些组合了。
继续寻找:
text
numbers = [2, 7, 11, 15]
↑ ↑
i j
2 + 11 = 13
仍然太大,所以继续移动右指针。
text
numbers = [2, 7, 11, 15]
↑ ↑
i j
2 + 7 = 9
找到答案!
2. 总和太小,又该怎么办?
反过来考虑另一种情况:
python
numbers = [1, 2, 4, 6, 8]
target = 10
一开始:
text
numbers = [1, 2, 4, 6, 8]
↑ ↑
i j
1 + 8 = 9
发现:
text
9 < 10
这次需要让总和变大。
那应该移动哪个指针?
因为 8 已经是当前范围内最大的数字。
连:
text
1 + 8 = 9 < 10
都达不到目标值,那么 1 与其他更小的数字相加,也不可能达到 10。
所以我们可以直接排除 1,移动左指针:
python
i += 1
继续寻找:
text
numbers = [1, 2, 4, 6, 8]
↑ ↑
i j
2 + 8 = 10
找到答案。
3. 总结指针的移动规则
现在就可以整理出完整的判断逻辑:
text
current < target
→ 总和太小
→ 排除左端数字
→ i += 1
current > target
→ 总和太大
→ 排除右端数字
→ j -= 1
current == target
→ 找到答案
🚩 注意:这里最重要的不是记住指针往哪边移动,而是理解为什么可以排除某一端的数字。
因为数组有序:
- 当总和太大时,右端数字与当前范围内最小的数字相加都超出了目标,所以右端数字可以被排除。
- 当总和太小时,左端数字与当前范围内最大的数字相加都不够,所以左端数字可以被排除。
每次比较,都能够安全地缩小搜索范围,而不是逐个尝试所有组合。
这就是双指针能够提高效率的关键。
四、双指针完整代码
思路明确以后,代码其实很简单。
python
class Solution:
def twoSum(self, numbers: list[int], target: int) -> list[int]:
n = len(numbers)
i = 0
j = n - 1
while i < j:
current = numbers[i] + numbers[j]
if current < target:
i += 1
elif current > target:
j -= 1
else:
return [i + 1, j + 1]
其中:
python
while i < j:
表示两个指针还没有相遇,可以继续寻找。
而:
python
return [i + 1, j + 1]
是因为题目要求返回从 1 开始计算的下标。
容易踩的坑:双指针不是随便移动两个指针
双指针并不意味着随便设置两个位置,然后根据结果尝试移动。
例如,如果两个指针都从左边开始,就需要额外考虑:当前总和太大时,究竟应该移动谁?
如果没有利用数组的有序性建立明确的移动规则,就可能提前结束搜索,甚至漏掉答案。
所以做这类题时,我觉得更应该先问自己:
移动这个指针以后,我能确定排除了哪些不可能的答案吗?
如果无法解释这一点,就说明指针移动的依据还不够充分。
五、补充思路:固定一个数字 + 二分查找
其实除了左右双指针,这道题还可以用二分查找。
这个思路可以从最开始的暴力枚举自然推导出来。
回顾暴力枚举:
python
for i in range(n - 1):
for j in range(i + 1, n):
if numbers[i] + numbers[j] == target:
return [i + 1, j + 1]
当时我们的想法是:
固定一个数字,再去后面寻找另一个数字。
这个思路本身没有问题。
真正耗时的是:内层循环需要逐个检查所有数字。
既然数组已经有序,那么能不能把内层的遍历换成二分查找?
1. 固定第一个数字
例如:
python
numbers = [2, 7, 11, 15]
target = 9
先固定:
python
i = 0
第一个数字是:
text
numbers[i] = 2
既然:
text
第一个数字 + 第二个数字 = target
那么第二个数字必须等于:
python
need = target - numbers[i]
也就是:
text
need = 9 - 2 = 7
现在问题就变成了:
在
i后面的有序数组中,能不能找到数字7?
这就可以使用二分查找。
2. 用二分查找寻找 need
先设置搜索范围:
python
left = i + 1
right = n - 1
为什么从 i + 1 开始?
因为第一个数字已经固定为 numbers[i],第二个数字不能重复使用同一个下标。
接下来取中间位置:
python
mid = (left + right) // 2
然后比较:
numbers[mid] == need:找到答案。numbers[mid] < need:中间数字太小,去右半边寻找。numbers[mid] > need:中间数字太大,去左半边寻找。
每次比较都能排除一半不可能的搜索范围。
3. 完整代码
python
class Solution:
def twoSum(self, numbers: list[int], target: int) -> list[int]:
n = len(numbers)
for i in range(n - 1):
need = target - numbers[i]
left = i + 1
right = n - 1
while left <= right:
mid = (left + right) // 2
if numbers[mid] == need:
return [i + 1, mid + 1]
elif numbers[mid] < need:
left = mid + 1
else:
right = mid - 1
这样就把原来的内层暴力遍历替换成了二分查找。
不过要注意:
二分查找和双指针虽然都利用了数组有序的特点,但思路并不完全相同。
二分查找是先固定一个数字,再寻找另一个确定的目标值。
双指针则不需要提前固定某个数字,而是根据当前总和的大小,直接排除左边或右边的数字。
六、复杂度分析
| 方法 | 时间复杂度 | 额外空间复杂度 |
|---|---|---|
| 暴力枚举 | O(n²) | O(1) |
| 固定一个数 + 二分查找 | O(n log n) | O(1) |
| 左右双指针 | O(n) | O(1) |
暴力枚举:
两层循环,最坏情况下需要检查 O(n²) 组数字。
二分查找:
外层固定一个数字需要 O(n),每次二分查找需要 O(log n)。
因此整体是 O(n log n)。
左右双指针:
两个指针分别从数组两端向中间移动。
每次循环至少移动一个指针,而且不会回头。
所以总移动次数不超过 n - 1,时间复杂度为 O(n)。
三种方法都只需要固定数量的辅助变量,因此额外空间复杂度均为 O(1)。
七、总结
这道题最值得注意的,其实就是题目给出的数组有序这个条件。
最开始,我们可以通过暴力枚举找到答案,但这样需要检查大量组合。
而利用数组有序的特点,就可以根据当前总和与目标值的大小关系,直接排除不可能的数字。
这也是为什么左右双指针可以把时间复杂度从 O(n²) 降到 O(n)。
至于二分查找,则提供了另一条优化路径:保留固定一个数字的思路,只把内层的逐个查找换成更高效的搜索。
所以. 我觉得这道题值得记住的是:
双指针的关键,不是两个指针从哪里开始,而是每次移动时,能否利用题目条件,确定被跳过的部分不可能包含答案。