双指针算法基础
- 两种核心形式
(1)对撞指针(左右指针)
适用场景:顺序结构(数组、字符串等),需要从两端向中间逼近的问题。
移动规则:一个指针从最左端开始,另一个从最右端开始,逐步向中间移动。
终止条件:
left == right:两个指针指向同一位置
left > right:两个指针错开(循环结束)
典型问题:两数之和 II、验证回文串、盛最多水的容器等。
(2)快慢指针(龟兔赛跑算法)
适用场景:环形链表、数组循环问题、需要区分移动速度的场景。
核心思想:使用两个移动速度不同的指针(慢指针一次走1步,快指针一次走2步)在序列上移动。
典型问题:环形链表检测、寻找链表中点、删除链表倒数第N个节点等。
题目1:移动零(LeetCode 283)
- 题目描述
给定一个数组 nums,编写一个函数将所有 0 移动到数组的末尾,同时保持非零元素的相对顺序,且必须原地修改数组,不复制数组。
示例:输入:nums = [0,1,0,3,12] 输出:[1,3,12,0,0]
- 解题思路(快排分区思想)
核心思想:将数组划分为两个区间
0, dest\]:全部为非零元素 \[dest+1, cur-1\]:全部为零元素
指针定义
cur:遍历指针,从左到右扫描整个数组
dest:记录非零元素序列的最后一个位置,初始为 -1(表示初始无任何非零元素)
3. 算法流程
1. 初始化:cur = 0(遍历起点),dest = -1(非零区间末尾)
2. 遍历数组:cur 从 0 到 nums.size()-1:
若 nums\[cur\] != 0: dest++(非零区间右扩一位); 交换 nums\[dest\] 和 nums\[cur\](将当前非零元素放到非零区间末尾); cur++(继续扫描下一个元素)
若 nums\[cur\] == 0: 直接 cur++(零元素暂时留在原位置,后续会被非零元素覆盖)
3. 遍历结束:\[0, dest\] 为所有非零元素,\[dest+1, nums.size()-1\] 为所有零元素。
4. C++ 代码实现
```cpp
class Solution
{
public:
void moveZeroes(vector
- 解题思路(从后往前双指针)
核心问题:若从前往后复写,0 会被复写两次,导致未处理的元素被覆盖,故选择从后往前复写策略。
核心流程:
-
找到最后一个会被复写的元素:模拟复写过程,确定复写后数组的边界。
-
从后往前复写:从边界开始,将原数组元素复写到目标位置,避免覆盖未处理元素。
-
算法流程
步骤1:找到最后一个"复写"的数
指针定义:
cur:遍历原数组的指针,从左到右移动。dest:模拟复写后的位置指针,记录复写后的末尾位置。
流程:
- 判断cur位置的值:
如果arr[cur] == 0,dest += 2(因为0要复写两次)。
如果arr[cur] != 0,dest += 1(非零元素只移动一次)。
-
判断dest是否到达数组末尾(dest >= n-1),如果是则停止。(n为数组size大小)
-
cur++,继续遍历下一个元素。
步骤1.5:处理边界情况(例如[1,0,2,3,0,4])
如果dest == n(说明最后一个元素是0且复写后越界):
将数组最后一个位置设为0:arr[n-1] = 0。
cur--(回退一步,跳过越界的0)。 dest -= 2(回退两位,回到有效边界)。
步骤2:从后向前完成复写操作
从cur和dest的位置开始,从后往前遍历:
如果arr[cur] == 0:arr[dest--] = 0 arr[dest--] = 0(复写两次) cur--
如果arr[cur] != 0:arr[dest--] = arr[cur--](直接移动非零元素)
直到cur < 0时结束。
- C++ 代码实现
cpp
class Solution
{
public:
void duplicateZeros(vector<int>& arr)
{
// 找到最后一个元素
int cur = 0 , dest = -1;
while(cur < arr.size())
{
if(arr[cur]) dest++;
else dest += 2;
if(dest >= arr.size()-1) break;
cur ++;
}
// 处理边界情况
if(dest == arr.size())
{
arr[arr.size()-1] = 0;
cur --;
dest -=2;
}
// 从后往前完成复写操作
while(cur >= 0)
{
if(arr[cur]) arr[dest--] = arr[cur--];
else
{
arr[dest--] = 0;
arr[dest--] = 0;
cur --;
}
}
}
};
- 复杂度分析
时间复杂度:O(n),遍历数组两次(一次定位边界,一次复写)
空间复杂度:O(1),原地修改,无额外空间开销
关键优势:从后往前复写避免了元素覆盖问题,保证了原数组元素的读取完整性
题目3:快乐数(LeetCode 202)
- 题目描述:判断一个数 n 是否为快乐数。
快乐数定义:
-
对正整数 n,重复执行操作:将其替换为每个位置上数字的平方和。
-
若最终结果为 1,则为快乐数;
-
若陷入无限循环(始终无法到1),则不是快乐数。
示例:
输入:19 → 输出:true(过程:1^2+9^2=82 -> 8^2+2^2=68 -> 6^2+8^2=100 -> 1^2+0+0=1)
输入:2 → 输出:false(陷入循环:2 -> 4 -> 16 -> 37 -> 58 -> 89 -> 145 -> 42 -> 20 -> 4)
- 核心结论
对 n 反复执行平方和操作,结果必然陷入循环(鸽巢原理:取值范围有限,必重复)。
循环只有两种结局:1) 循环终点为 1 → 快乐数;2) 循环终点非 1 → 非快乐数。
因此可用快慢指针(龟兔赛跑算法)检测循环,无需额外空间存储历史值。
快慢指针(Floyd判圈算法)
(1)核心原理
用两个指针遍历序列:
慢指针(slow):每次走 1 步(执行1次平方和操作);
快指针(fast):每次走 2 步(执行2次平方和操作)。
若序列存在环:
快慢指针必然相遇(快指针最终套圈追上慢指针;若相遇点值为 1 → 是快乐数;否则 → 不是快乐数。
(2)适用场景
检测环形结构(链表、数组循环、快乐数循环等);
无需额外空间(O(1) 空间复杂度),时间复杂度 O(\log n)(每次操作位数减少)。
cpp
class Solution
{
public:
int bitSum(int n) // 返回 n 这个数每⼀位上的平⽅和
{
int sum = 0;
while(n)
{
int t = n % 10;
sum += t * t;
n /= 10;
}
return sum;
}
bool isHappy(int n)
{
int slow = n , fast = bitSum(n);
while(slow != fast)
{
slow = bitSum(slow);
fast = bitSum(bitSum(fast));
}
return slow == 1;
}
};
关键注意事项
-
快慢指针初始值:fast = bitSum(n),而非 fast = n,避免初始时 slow == fast 直接退出循环;
-
循环终止条件:slow != fast,相遇即退出,保证效率;
-
平方和计算:必须正确提取每一位,避免漏算/错算(如 100 需计算 1^2+0+0=1);
-
环的判断:快乐数的核心是"是否以1为环的入口",快慢指针相遇时只需判断是否等于1。
题目4: 盛最多水的容器(LeetCode 11)
- 题目描述
给定长度为 n 的整数数组 height,数组中的每个元素代表一条垂直线的高度。找出两条线,使得它们与 x 轴共同构成的容器能容纳最多的水。
核心限制:容器不能倾斜,水的容量由短板决定。
容量公式:V = (j - i) * min(height[i], height[j]),其中 i, j 为两条线的下标。
- 示例

- 算法思路对比
1) 解法一:暴力求解(超时)
思路:枚举所有可能的两条线组合 (i, j) (i < j),计算每一个组合的容量并记录最大值。
代码逻辑:双层 for 循环。 外层循环:i 从 0 遍历到 n-1。内层循环:j 从 i+1 遍历到 n-1。
计算:min(height[i], height[j]) * (j - i)。
复杂度:O(n^2),O(1)。
缺点:时间复杂度极高,对于 n > 10^4 的数据会超时,不推荐。
cpp
class Solution {
public:
int maxArea(vector<int>& height) {
int n = height.size();
int ret = 0;
// 两层循环枚举所有组合
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
// 计算当前容量
int v = min(height[i], height[j]) * (j - i);
ret = max(ret, v);
}
}
return ret;
}
};
2) 解法二:对撞指针/快慢指针(最优)
核心思想:利用双指针从数组两端向中间逼近,通过贪心策略舍弃无效组合,仅保留可能产生最优解的组合。
指针定义:
left:左指针,初始指向数组左端(0)。
right:右指针,初始指向数组右端(n-1)。
ret:记录最大容量。
移动规则:
-
计算当前 left 和 right 构成的容量,更新最大值。
-
舍弃短板:移动指向较矮高度的指针。如果 height[left] < height[right],left++。 否则,right--
-
循环直到 left >= right。
为什么移动短板有效?
容器的高度由短板决定。如果固定短板,移动长板,宽度减小,高度不变或变小,容量必然减小。
移动短板,虽然宽度减小,但新的短板可能变高,容量有机会增大。
cpp
class Solution {
public:
int maxArea(vector<int>& height) {
int left = 0;
int right = height.size() - 1;
int ret = 0;
while (left < right) {
// 计算当前容量
int v = min(height[left], height[right]) * (right - left);
ret = max(ret, v);
// 移动指针:谁矮谁走
if (height[left] < height[right]) {
left++;
} else {
right--;
}
}
return ret;
}
};
|------|---------|-------|---------|
| 算法 | 时间复杂度 | 空间复杂度 | 评价 |
| 暴力求解 | O(n^2) | O(1) | 效率极低,超时 |
| 对撞指针 | O(n) | O(1) | 最优,一次遍历 |
注意点
-
容量计算:必须使用 min(height[left], height[right]),水不会溢出短板。
-
指针移动:必须移动短板。若移动长板,宽度减小且高度受限于短板,容量只会变小,无搜索意义。
-
初始值:left 初始为 0,right 初始为 height.size() - 1,保证初始宽度最大。
题目5:有效三角形的个数LeetCodLL(LeetCode 611)
题目描述
给定非负整数数组 nums,统计其中能组成三角形三条边的三元组个数。
三角形判定定理:任意两边之和大于第三边 → 优化为:较小的两边之和 > 最大边(其余两个不等式自动成立)。
解法一:暴力枚举(会超时)
算法思路
-
先排序:将数组从小到大排序,方便后续判断(只需验证 nums[i] + nums[j] > nums[k],其中 i<j<k,nums[k] 为最大边)。
-
三层循环枚举:遍历所有 i<j<k 的三元组,满足条件则计数+1。
时间复杂度:O(n^3),数据量稍大就会超时,仅作为基础思路参考。
代码(C++)
cpp
class Solution {
public:
int triangleNumber(vector<int>& nums) {
// 1. 排序
sort(nums.begin(), nums.end());
int n = nums.size(), ret = 0;
// 2. 从小到大枚举所有三元组
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
for (int k = j + 1; k < n; k++) {
// 当最小的两个边之和大于第三边时,统计答案
if (nums[i] + nums[j] > nums[k])
ret++;
}
}
}
return ret;
}
};
解法二:排序 + 双指针(对撞指针,最优解)
算法思路
-
先排序:数组从小到大排序,固定最大边 nums[i](从数组末尾向前遍历)。
-
双指针统计:在 [0, i-1] 区间内,用 left 指向区间左端点,right 指向区间右端点:
若 nums[left] + nums[right] > nums[i]:说明 [left, right-1] 所有元素都能和 nums[right] 组成满足条件的二元组,共 right-left 个,计数后 right--。
若 nums[left] + nums[right] ≤ nums[i]:说明 nums[left] 无法和任何元素组成满足条件的二元组,left++。
时间复杂度:排序 O(nlog n) + 双指针遍历 O(n^2),整体 O(n^2),可通过所有测试用例。
代码(C++)
cpp
class Solution {
public:
int triangleNumber(vector<int>& nums) {
// 1. 排序
sort(nums.begin(), nums.end());
// 2. 利用双指针解决问题
int ret = 0, n = nums.size();
for(int i = n - 1; i >= 2; i--) // 先固定最大的数
{
// 利用双指针快速统计符合要求的三元组的个数
int left = 0, right = i - 1;
while(left < right)
{
if(nums[left] + nums[right] > nums[i])
{
ret += right - left;
right--;
}
else
{
left++;
}
}
}
return ret;
}
};
题目6:和为s的两个数字(剑指 Offer 57,LeetCode LCR 179)
题目核心
输入递增排序的数组和目标值 s,找出数组中和为 s 的两个数,输出任意一对即可。
解法一:暴力枚举(会超时)
算法思路:两层 for 循环枚举所有数对,判断和是否等于目标值。
优化:第二层循环从 i+1 开始,避免重复枚举。
时间复杂度:O(n^2),数组长度较大时超时,仅作基础思路。
代码(C++)
cpp
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
int n = nums.size();
for (int i = 0; i < n; i++) { // 第一层循环从前往后列举第一个数
for (int j = i + 1; j < n; j++) { // 第二层循环从 i 位置之后列举第二个数
if (nums[i] + nums[j] == target) // 两个数的和等于目标值,找到结果
return {nums[i], nums[j]};
}
}
return {-1, -1};
}
};
解法二:排序 + 双指针(对撞指针,最优解)
算法思路
利用数组升序的特性,用对撞指针优化时间复杂度:
-
初始化 left=0(数组左端),right=n-1(数组右端)。
-
循环判断两数之和:
和 == 目标值:直接返回这两个数。
和 < 目标值:left++(nums[left] 无法和任何数凑出目标值,舍去)。
和 > 目标值:right--(nums[right] 无法和任何数凑出目标值,舍去)。
时间复杂度:O(n),仅需一次遍历,效率极高。
代码(C++)
cpp
class Solution {
public:
vector<int> twoSum(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
while(left < right)
{
int sum = nums[left] + nums[right];
if(sum > target) right--;
else if(sum < target) left++;
else return {nums[left], nums[right]};
}
// 照顾编译器
return {-1, -1};
}
};
{nums[left], nums[right]} 这是 C++11 及以后支持的列表初始化语法:
用大括号 {} 把两个元素包裹起来,直接构造一个 vector<int> 类型的对象
等价于手动写 vector<int>{nums[left], nums[right]} 或者 vector<int>({nums[left], nums[right]})
因为函数的返回值类型就是 vector<int>,所以可以直接用这种简洁的写法返回
C++ 函数返回 vector<int> 时,支持用初始化列表直接构造返回值,这是 C++11 引入的语法糖,让代码更简洁。如果是旧版本 C++,需要写成如下,效果完全一致,只是写法更繁琐。
cpp
vector<int> res;
res.push_back(nums[left]);
res.push_back(nums[right]);
return res;
题目7:三数之和(LeetCode 15)
- 题目描述
给你一个整数数组 nums ,判断是否存在三元组 [nums[i], nums[j], nums[k]] 满足 i != j、i != k 且 j != k ,同时还满足 nums[i] + nums[j] + nums[k] == 0 。请你返回所有和为 0 且不重复的三元组。
示例1
输入: nums = [-1,0,1,2,-1,-4] 输出:[[-1,-1,2],[-1,0,1]]
解释: nums[0] + nums[1] + nums[2] = (-1) + 0 + 1 = 0 。
nums[1] + nums[2] + nums[4] = 0 + 1 + (-1) = 0 。
nums[0] + nums[3] + nums[4] = (-1) + 2 + (-1) = 0 。
不同的三元组是 [-1,0,1] 和 [-1,-1,2] 。注意,输出的顺序和三元组的顺序并不重要。
示例2:输入: nums = [0,1,1] 输出: [] 解释: 唯一可能的三元组和不为 0 。
示例3:输入: nums = [0,0,0] 输出: [[0,0,0]] **解释:**唯一可能的三元组和为 0 。
- 解法:排序 + 双指针(最优)
算法思路:将 三数之和 降维为 两数之和,利用排序和双指针优化,并处理所有去重逻辑。
-
排序:先对数组排序,为双指针和去重奠定基础。
-
固定数 a:遍历数组,固定第一个数 nums[i](记为 a)。
小优化:如果 nums[i] > 0,因为数组已排序,后面所有数都大于0,不可能和为0,直接 break。
-
双指针找 b + c:在 i 后面的区间 [i+1, n-1] 内,用 left 指向左端点,right 指向右端点,找 nums[left] + nums[right] == -a。
-
核心去重:
去重 a:如果当前数和前一个数相同,跳过,避免重复结果。
去重 b, c:找到满足条件的数对后,跳过 left 和 right 指向的重复元素,防止记录重复三元组。
代码逻辑解析(C++)
cpp
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
sort(nums.begin(), nums.end());
int n = nums.size();
vector<vector<int>> ret;
for (int i = 0; i < n; ) {
// 优化:如果第一个数大于0,不可能组成和为0的三元组
if (nums[i] > 0) break;
int left = i + 1, right = n - 1;
int target = -nums[i];
while (left < right ) {
int sum = nums[left] + nums[right];
if (sum > target) {
right--;
} else if (sum < target) {
left++;
} else {
// 找到符合条件的三元组
ret.push_back({nums[i], nums[left], nums[right]});
// 跳过left和right的重复元素
left++;
right--;
while (left < right && nums[left] == nums[left - 1]) left++;
while (left < right && nums[right] == nums[right + 1]) right--;
}
}
// 跳过i的重复元素
i++;
while (i < n && nums[i] == nums[i - 1]) i++;
}
return ret;
}
};
- 复杂度分析
1) 时间复杂度
排序:sort(nums.begin(), nums.end()) 的时间复杂度为 O(n\log n)。
外层循环:遍历数组中的每个元素 i,时间复杂度为 O(n)。
内层双指针:对于每个 i,left 和 right 最多遍历整个数组一次,时间复杂度为 O(n)。
总时间复杂度:O(nlog n) + O(n) * O(n) = O(n^2)。
2) 空间复杂度
排序的空间开销:C++ 的 sort 函数使用的是快速排序,空间复杂度为 O(log n)(递归栈开销)。
结果存储:ret 存储的三元组数量最多为 O(n^2)(极端情况下),但题目中要求不重复,实际数量远小于这个值。
总空间复杂度:O(log n) + O(k)(k 为结果中三元组的数量),通常认为空间复杂度为 O(log n)(忽略结果存储的空间)。
- 用 set 去重
利用set的元素唯一性特性,将生成的三元组存入set中自动去重,再转成vector返回。
cpp
class Solution {
public:
vector<vector<int>> threeSum(vector<int>& nums) {
sort(nums.begin(), nums.end());
int n = nums.size();
set<vector<int>> s; // 利用set自动去重
for (int i = 0; i < n; i++) {
if (nums[i] > 0) break;
int left = i + 1, right = n - 1;
int target = -nums[i];
while (left < right) {
int sum = nums[left] + nums[right];
if (sum > target) {
right--;
} else if (sum < target) {
left++;
} else {
// 插入set自动去重
s.insert({nums[i], nums[left], nums[right]});
left++;
right--;
}
}
}
// 将set转为vector
return vector<vector<int>>(s.begin(), s.end());
}
};
set 会根据 vector 的字典序来比较元素,自动判断是否重复。如果这个 vector 已经存在于 set 中,insert 操作会自动跳过,从而实现去重。
set 的去重依赖于元素的 < 运算符:
对于 vector<int>,比较规则是字典序:从第一个元素开始比较,直到找到第一个不同的元素。
如果两个 vector 的所有元素都相同,就会被判定为重复,不会被插入。
题目8:四数之和(LeetCode 18)
- 题目核心
给你一个由 n 个整数组成的数组 nums 和一个目标值 target。请你找出并返回所有满足条件且不重复的四元组:四个索引 a, b, c, d 互不相同;nums[a] + nums[b] + nums[c] + nums[d] == target。
示例:输入: nums = [1,0,-1,0,-2,2], target = 0 输出:[[-2,-1,1,2],[-2,0,0,2],[-1,0,0,1]]
输入: nums = [2,2,2,2,2], target = 8 输出:[[2,2,2,2]]
- 解法:排序 + 双指针(嵌套)
算法思路:在三数之和的基础上,再嵌套一层循环,将 四数之和 降维为 三数之和。
-
排序:数组排序。
-
双层循环固定 a, b:
第一层循环固定第一个数 nums[i](记为 a),并去重。
第二层循环在 i+1 之后固定第二个数 nums[j](记为 b),并去重。
-
双指针找 c + d:在 j 后面的区间 [j+1, n-1] 内,用 left 和 right 找 nums[left] + nums[right] == target - nums[i] - nums[j]。
-
三级去重:对 a、b、(c, d) 都进行去重操作。
代码逻辑解析(C++)
cpp
class Solution
{
public:
vector<vector<int>> fourSum(vector<int> &nums, int target)
{
vector<vector<int>> ret;
// 排序:为双指针查找 + 去重做准备
sort(nums.begin(), nums.end());
int n = nums.size();
// 固定第一个数 a
for (int i = 0; i < n;)
{
// 固定第二个数 b
for (int j = i + 1; j < n;)
{
int left = j + 1, right = n - 1;
// 目标:c + d = target - a - b,用 long long 防溢出
long long aim = (long long)target - nums[i] - nums[j];
// 双指针找 c、d
while (left < right)
{
long long sum = nums[left] + nums[right];
if (sum > aim)
right--;
else if (sum < aim)
left++;
else
{
// 找到一组解,存入结果
ret.push_back({nums[i], nums[j], nums[left], nums[right]});
left++;
right--;
// 去重:跳过相同的 left、right
while (left < right && nums[left] == nums[left - 1])
left++;
while (left < right && nums[right] == nums[right + 1])
right--;
}
}
// 去重 j:跳过相同的第二个数
j++;
while (j < n - 1 && nums[j] == nums[j - 1])
j++;
}
// 去重 i:跳过相同的第一个数
i++;
while (i < n - 1 && nums[i] == nums[i - 1])
i++;
}
return ret;
}
};
先排序:双指针 + 去重的基础;固定 2 个数:把四数之和转成两数之和;long long 防溢出:int 相加会爆范围;三处去重:i、j、left/right 都要跳重复
- 复杂度分析
1) 时间复杂度
排序:sort(nums.begin(), nums.end()) 的时间复杂度是 O(n log n)。
三层循环:
外层循环(i):遍历数组,最多执行 n 次。
中层循环(j):在 i 的基础上遍历,最多执行 n 次。
内层双指针(left, right):在 j 的基础上遍历,最多执行 n 次。
三层循环的总时间复杂度为 O(n³)。 总时间复杂度:排序的 O(n log n) 可以忽略,最终为 O(n³)。
2) 空间复杂度
结果存储:ret 存储最终的四元组,最坏情况下的空间复杂度为 O(n²)(当所有元素都能组成四元组时)。
排序的空间开销:标准库的 sort 函数通常使用 O(log n) 的栈空间。
总空间复杂度:主要由结果存储决定,为 O(n²)(不计入结果存储的话,为 O(log n))。
- 用 set 去重
用 set 实现四数之和的去重,核心思路是利用 set 自动去重的特性,把找到的四元组存入 set,最后再转成 vector 返回。
cpp
class Solution {
public:
vector<vector<int>> fourSum(vector<int>& nums, int target) {
vector<vector<int>> ret;
set<vector<int>> s; // 用于去重的set
int n = nums.size();
if (n < 4) return ret;
sort(nums.begin(), nums.end());
for (int i = 0; i < n; i++) {
for (int j = i + 1; j < n; j++) {
int left = j + 1;
int right = n - 1;
long long aim = (long long)target - nums[i] - nums[j];
while (left < right) {
long long sum = nums[left] + nums[right];
if (sum > aim) {
right--;
} else if (sum < aim) {
left++;
} else {
// 将四元组插入set自动去重
s.insert({nums[i], nums[j], nums[left], nums[right]});
left++;
right--;
}
}
}
}
// 将set中的元素转成vector返回
for (auto& vec : s) {
ret.push_back(vec);
}
return ret;
}
};
✅ 优点:逻辑简单,不需要手动写复杂的去重逻辑,不容易出错。
❌ 缺点:set 的插入和查找有一定的时间开销,效率略低于手动去重的双指针法。