一、引言
在一个包含 100 万个有序数字的数组中找出两个数使其和等于 target,暴力枚举需要 5000 亿次比较,而双指针只需要 100 万次——这就是双指针的威力。
双指针是算法面试中最重要的优化技巧之一。它通过巧妙地利用数组的有序性,用两个指针协同移动来替代暴力枚举,将时间复杂度从 O(n²) 降到 O(n)。当它与"排序"结合时,还能解决更复杂的问题——比如三数之和。
本文围绕两个经典题目展开:
-
167. 两数之和 II - 输入有序数组(Easy)—— 对撞双指针的入门模板
-
15. 三数之和(Medium)—— 排序降维 + 双指针 + 去重
二、双指针基础
2.1 什么是双指针?
双指针是一种在数组或链表上使用两个指针协同移动来解决问题的技巧。根据移动方向,分为两大类:
| 类型 | 移动方向 | 典型应用 | 核心思想 | | --- | --- | --- | --- | | 对撞指针(左右指针) | 两端向中间 | 两数之和 II、三数之和、回文串 | 利用有序性,调整指针方向控制结果大小 | | 快慢指针 | 同向移动,一快一慢 | 环形链表、链表中点、删除倒数第N个 | 利用速度差来定位特定位置 |
本文重点讨论对撞指针(左右指针)。
2.2 对撞指针的工作原理
对撞指针的核心思想很简单:
-
初始化
left指向数组开头,right指向数组末尾 -
根据
nums[left] + nums[right]与 target 的比较结果,决定移动哪个指针 -
移动指针后,搜索范围缩小,直到找到解或区间为空
关键前提:数组必须有序(或经过排序后有序)。只有有序数组,才能保证移动指针时结果的单调性。
2.3 双指针分类
三、167. 两数之和 II - 输入有序数组(Easy)
3.1 题目描述
给定一个升序排列的整数数组 numbers 和一个目标值 target,找出两个数使它们的和等于 target,返回这两个数的索引(从 1 开始计数)。
示例 1:
输入: numbers = [2,7,11,15], target = 9
输出: [1,2]
解释: 2 + 7 = 9,索引从 1 开始
示例 2:
输入: numbers = [2,3,4], target = 6
输出: [1,3]
示例 3:
输入: numbers = [-1,0], target = -1
输出: [1,2]
3.2 解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 说明 | | --- | --- | --- | --- | | 暴力枚举 | O(n²) | O(1) | 双重循环,不利用有序性 | | 哈希表 | O(n) | O(n) | 空间换时间,从第1题移植 | | 对撞双指针 | O(n) | O(1) | 利用有序性,最优解 |
思考:为什么哈希表不是最优解?因为数组已经有序了——这是题目给的重要条件。利用有序性,我们可以用两个指针在 O(1) 空间中解决问题,不需要哈希表的 O(n) 空间。
3.3 对撞双指针解法
核心逻辑:
-
初始化
left = 0, right = len(numbers) - 1 -
循环条件
while left < right(不能相等,因为不能使用同一个元素两次) -
计算
sum = numbers[left] + numbers[right]-
如果
sum == target:找到解,返回[left+1, right+1] -
如果
sum < target:left++(需要更大的数,左指针右移) -
如果
sum > target:right--(需要更小的数,右指针左移)
-
为什么可行? 因为数组是升序的,left++ 取更大的数使总和增大,right-- 取更小的数使总和减小。通过这种"双向夹逼",可以在 O(n) 时间内找到解。
3.4 代码实现
Python
class Solution:
def twoSum(self, numbers: List[int], target: int) -> List[int]:
left, right = 0, len(numbers) - 1
while left < right:
curr_sum = numbers[left] + numbers[right]
if curr_sum == target:
return [left + 1, right + 1] # 索引从1开始
elif curr_sum < target:
left += 1 # 需要更大的数
else:
right -= 1 # 需要更小的数
return [-1, -1] # 题目保证有解
Java
class Solution {
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};
}
}
C++
class Solution {
public:
vector<int> twoSum(vector<int>& numbers, int target) {
int left = 0, right = numbers.size() - 1;
while (left < right) {
int sum = numbers[left] + numbers[right];
if (sum == target) {
return {left + 1, right + 1};
} else if (sum < target) {
left++;
} else {
right--;
}
}
return {-1, -1};
}
};
3.5 复杂度分析
| 维度 | 分析 | | --- | --- | | 时间复杂度 | O(n),n 为数组长度。最坏情况下 left 和 right 遍历整个数组 | | 空间复杂度 | O(1),仅使用两个指针变量 |
3.6 易错点
-
索引从1开始:返回
[left+1, right+1]而不是[left, right] -
循环条件 left < right:不能写成
left <= right,因为不能使用同一个元素两次 -
指针移动方向:
sum < target时left++,sum > target时right--,不要搞反
3.7 双指针过程图解
在 numbers = [2, 7, 11, 15] 中查找 target = 9 的过程:
四、15. 三数之和(Medium)
4.1 题目描述
给定一个整数数组 nums,找出所有和为 0 且不重复的三元组 [nums[i], nums[j], nums[k]]。
示例 1:
输入: nums = [-1,0,1,2,-1,-4]
输出: [[-1,-1,2],[-1,0,1]]
示例 2:
输入: nums = [0,1,1]
输出: []
示例 3:
输入: nums = [0,0,0]
输出: [[0,0,0]]
4.2 解法对比
| 解法 | 时间复杂度 | 空间复杂度 | 说明 | | --- | --- | --- | --- | | 暴力三层循环 | O(n³) | O(1) | 不可接受,去重复杂 | | 哈希表 | O(n²) | O(n) | 需处理去重,实现复杂 | | 排序 + 双指针 | O(n²) | O(log n) | 最优解,简洁优雅 |
4.3 核心思想:降维
三数之和的核心思想是降维——把三数之和问题转化为两数之和问题:
三数之和(找 a+b+c=0)
→ 固定 a(外层循环)
→ 在 a 右侧找 b+c = -a(两数之和,用双指针)
这种降维思想可以推广到 k 数之和——四数之和固定两个,k 数之和固定 k-2 个,最后两个用双指针。
4.4 排序 + 双指针解法
算法步骤:
-
排序:
nums.sort(),O(n log n) -
外层循环:固定第一个数
nums[i],i 从 0 到 n-3 -
剪枝:
-
如果
nums[i] > 0,break(后面的数更大,不可能和为 0) -
如果
nums[i] + nums[i+1] + nums[i+2] > 0,break -
如果
nums[i] + nums[n-2] + nums[n-1] < 0,continue(当前数太小)
-
-
去重:
if (i > 0 && nums[i] == nums[i-1]) continue; -
双指针:在
[i+1, n-1]范围内用对撞指针找两数之和等于-nums[i] -
找到解后的去重:
-
while (left < right && nums[left] == nums[left+1]) left++; -
while (left < right && nums[right] == nums[right-1]) right--; -
left++; right--;
-
4.5 代码实现
Python
class Solution:
def threeSum(self, nums: List[int]) -> List[List[int]]:
n = len(nums)
if n < 3:
return []
nums.sort()
result = []
for i in range(n - 2):
if nums[i] > 0:
break
if i > 0 and nums[i] == nums[i - 1]:
continue
left, right = i + 1, n - 1
target = -nums[i]
while left < right:
curr_sum = nums[left] + nums[right]
if curr_sum == target:
result.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 curr_sum < target:
left += 1
else:
right -= 1
return result
Java
class Solution {
public List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
int n = nums.length;
if (n < 3) return result;
Arrays.sort(nums);
for (int i = 0; i < n - 2; i++) {
if (nums[i] > 0) break;
if (i > 0 && nums[i] == nums[i - 1]) continue;
int left = i + 1, right = n - 1;
int target = -nums[i];
while (left < right) {
int sum = nums[left] + nums[right];
if (sum == target) {
result.add(Arrays.asList(nums[i], nums[left], nums[right]));
while (left < right && nums[left] == nums[left + 1]) left++;
while (left < right && nums[right] == nums[right - 1]) right--;
left++;
right--;
} else if (sum < target) {
left++;
} else {
right--;
}
}
}
return result;
}
}
C++
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
vector<vector<int>> result;
int n = nums.size();
if (n < 3) return result;
sort(nums.begin(), nums.end());
for (int i = 0; i < n - 2; i++) {
if (nums[i] > 0) break;
if (i > 0 && nums[i] == nums[i - 1]) continue;
int left = i + 1, right = n - 1;
int target = -nums[i];
while (left < right) {
int sum = nums[left] + nums[right];
if (sum == target) {
result.push_back({nums[i], nums[left], nums[right]});
while (left < right && nums[left] == nums[left + 1]) left++;
while (left < right && nums[right] == nums[right - 1]) right--;
left++;
right--;
} else if (sum < target) {
left++;
} else {
right--;
}
}
}
return result;
}
};
4.6 复杂度分析
| 维度 | 分析 | | --- | --- | | 时间复杂度 | O(n²),排序 O(n log n) + 双指针 O(n²) | | 空间复杂度 | O(log n) ~ O(n),排序所需的额外空间 |
4.7 关键点详解:去重三连
去重是三数之和中最容易出错的地方。去重的时机至关重要——必须在找到解后去重,而不是在找的过程中去重。
错误示范(在找到解之前就去重会导致漏解):
# 错误:在找到解之前就去重
while left < right:
while left < right and nums[left] == nums[left + 1]: left += 1
while left < right and nums[right] == nums[right - 1]: right -= 1
# 这里再找解... 可能已经错过了有效解
正确做法(找到解后,再跳过后续重复元素):
if curr_sum == target:
result.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
去重三连总结:
-
外层去重:
nums[i] == nums[i-1]→ 跳过重复的固定元素 -
内层去重左:找到解后,跳过重复的左指针
-
内层去重右:找到解后,跳过重复的右指针
4.8 三数之和过程图解
在 nums = [-4, -1, -1, 0, 1, 2] 中的查找过程:
五、去重模板总结
5.1 三数之和去重模板
def three_sum(nums):
nums.sort()
n = len(nums)
result = []
for i in range(n - 2):
# 剪枝
if nums[i] > 0: break
# 外层去重
if i > 0 and nums[i] == nums[i - 1]: continue
left, right = i + 1, n - 1
target = -nums[i]
while left < right:
s = nums[left] + nums[right]
if s == target:
result.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 s < target:
left += 1
else:
right -= 1
return result
5.2 去重核心规则
|
去重位置
|
时机
|
规则
|
| --- | --- | --- |
|
外层(固定元素)
|
进入循环时
|
if i > 0 and nums[i] == nums[i-1]: continue
|
|
内层左指针
|
找到解后
|
while nums[left] == nums[left+1]: left++
|
|
内层右指针
|
找到解后
|
while nums[right] == nums[right-1]: right--
|
核心记忆:"找解之前不去重,找到解后再去重"
六、对比总结
| 对比维度 | 167. 两数之和 II | 15. 三数之和 | | --- | --- | --- | | 难度 | ⭐ Easy | ⭐⭐ Medium | | 数组状态 | 已有序 | 需先排序 | | 指针数量 | 2 个(left, right) | 3 个(i, left, right) | | 核心思想 | 对撞双指针 | 排序降维 + 对撞双指针 | | 去重 | 不需要(唯一解) | 需要(三连去重) | | 时间复杂度 | O(n) | O(n²) | | 空间复杂度 | O(1) | O(log n) ~ O(n) | | 面试频率 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
核心感悟:
-
167 题是双指针的基本功——理解"有序数组"条件下如何用两个指针夹逼
-
15 题是双指针的进阶应用——"排序降维"思想,把三数之和转化为两数之和
-
两题共同体现了一个核心模式:排序 + 双指针 = 降维打击
七、家族题
| 题目 | 难度 | 核心思想 | 关键点 | | --- | --- | --- | --- | | 11. 盛最多水的容器 | Medium | 对撞双指针,短板效应 | 面积 = min(h[left], h[right]) * (right-left) | | 16. 最接近的三数之和 | Medium | 三数之和变体,维护最接近值 | 比较 abs(sum-target) 更新 | | 18. 四数之和 | Medium | 固定两个 + 双指针,k 数之和扩展 | 两层去重 | | 125. 验证回文串 | Easy | 字符串双指针,跳过无效字符 | isalnum() 过滤 | | 42. 接雨水 | Hard | 对撞双指针 + 左右最大高度 | 维护 left_max 和 right_max |
家族题难度梯度:
八、结语
双指针的核心价值在于——它利用数组的有序性,将"搜索所有组合"的问题转化为"通过指针移动调整结果"的问题。每次移动指针,都排除了一个搜索方向,本质上是一种"排除法"。
三个关键记忆点:
-
对撞双指针:left 从左边来,right 从右边来,根据结果大小决定移动哪个指针
-
排序降维:三数之和 → 排序固定一个 → 降维到两数之和
-
去重三连:外层去重 + 内层去重(找到解后)
刷题建议:先手写 3 遍 167 题的对撞双指针,再手写 3 遍 15 题的排序 + 双指针 + 去重,然后刷家族题:125 → 11 → 16 → 18 → 42。
下一篇预告:排序算法专题——从冒泡排序到快速排序,全面掌握排序算法的核心思想。