优选算法之双指针:从原理到实战,解决数组与链表
目录
一、双指针算法核心分类
1. 对撞指针:从两端向中间逼近
2. 快慢指针:一快一慢检测循环
二、对撞指针经典例题实战
例题 1:移动零(LeetCode 283)—— 数组区间划分
题目描述
算法思路
代码实现(C++)
复杂度分析
例题 2:复写零(LeetCode 1089)—— 逆序处理避免覆盖
题目描述
算法思路
代码实现(C++)
例题 3:两数之和(LeetCode 167)—— 有序数组目标值查找
题目描述
算法思路
代码实现(C++)
例题 4:有效三角形个数(LeetCode 611)—— 固定最长边优化
题目描述
算法思路
代码实现(C++)
例题 5:三数之和(LeetCode 15)—— 固定一端 + 去重
题目描述
算法思路
代码实现(C++)
三、快慢指针经典例题实战
例题:快乐数(LeetCode 202)—— 循环检测
题目描述
算法思路
代码实现(C++)
原理补充
四、双指针算法总结
1. 核心优势
2. 适用场景速查表
3. 避坑指南
双指针是算法领域中高效且灵活的技巧,核心是通过两个指针在数据结构(如数组、链表)上协同移动,将原本 O (n²) 复杂度的问题优化到 O (n),尤其适用于处理 “区间划分”“目标值查找”“循环检测” 等场景。接下来我们将系统拆解双指针的两种核心形式(对撞指针、快慢指针),结合经典例题详解原理与实现,彻底掌握这一算法利器。
一、双指针算法核心分类
双指针并非单一算法,而是根据指针移动方向和场景,分为对撞指针和快慢指针两类,二者适用场景完全不同,需精准区分。
1. 对撞指针:从两端向中间逼近
- 核心逻辑:两个指针分别从数据结构的左端(left) 和右端(right) 出发,根据条件向中间移动,直到指针相遇或错开,常用于有序数组 / 字符串的区间查找。
- 终止条件:
- 指针相遇(left == right);
- 指针错开(left > right)。
- 适用场景:两数之和、三数之和、有效三角形个数、盛水最多的容器等。
2. 快慢指针:一快一慢检测循环
- 核心逻辑:两个指针从同一端出发,移动速度不同(通常慢指针走 1 步,快指针走 2 步),利用 “速度差” 检测数据结构中的循环特性,或定位特定位置(如链表中点)。
- 核心优势:无需额外空间(如哈希表),即可判断循环,空间复杂度优化至 O (1)。
- 适用场景:快乐数、环形链表检测、链表中点查找等。
二、对撞指针经典例题实战
对撞指针的关键是利用数据的有序性(若数据无序,需先排序),通过移动指针缩小查找范围,减少无效枚举。以下是 5 道必刷例题,覆盖从简单到复杂的应用场景。
例题 1:移动零(LeetCode 283)—— 数组区间划分
题目描述
给定数组 nums
,将所有 0 移动到数组末尾,同时保持非零元素的相对顺序,要求原地操作。
- 示例:输入
[0,1,0,3,12]
,输出[1,3,12,0,0]
。
算法思路
本质是 “数组分两块”:用 cur
指针遍历数组,dest
指针记录 “非零元素区间的末尾”,最终让 [0, dest]
全为非零元素,[dest+1, n-1]
全为 0。
- 初始化
cur = 0
(遍历指针),dest = -1
(非零区间末尾,初始无元素); cur
遍历数组:- 若
nums[cur] != 0
:dest
先右移(扩展非零区间),交换nums[dest]
与nums[cur]
,确保非零元素进入左侧区间; - 若
nums[cur] == 0
:直接跳过,让 0 留在右侧。
- 若
代码实现(C++)
class Solution {
public:void moveZeroes(vector<int>& nums) {// cur:遍历指针,dest:非零元素区间末尾for (int cur = 0, dest = -1; cur < nums.size(); cur++) {if (nums[cur] != 0) { // 遇到非零元素,移入左侧区间dest++;swap(nums[dest], nums[cur]);}}}
};
复杂度分析
- 时间复杂度:O (n),
cur
遍历数组一次; - 空间复杂度:O (1),仅用两个指针,原地操作。
例题 2:复写零(LeetCode 1089)—— 逆序处理避免覆盖
题目描述
给定固定长度的数组 arr
,将每个 0 复写一遍(即 0→0,0),其余元素右移,要求不超过数组长度且原地操作。
- 示例:输入
[1,0,2,3,0,4,5,0]
,输出[1,0,0,2,3,0,0,4]
。
算法思路
若从左向右复写,0 会覆盖未处理的元素,因此需先找最后一个复写的位置,再从右向左复写:
- 第一阶段(找最后位置):用
cur
遍历数组,dest
模拟复写后的位置(遇 0 加 2,非 0 加 1),直到dest
超出数组长度; - 第二阶段(处理边界):若
dest
恰好等于数组长度(说明最后一个元素是 0,复写会超出),需单独将数组最后一位设为 0,再调整cur
和dest
; - 第三阶段(逆序复写):从
cur
向 0 移动,遇 0 则dest
位置和dest-1
位置都设为 0,遇非 0 则dest
位置设为该元素,逐步向左复写。
代码实现(C++)
class Solution {
public:void duplicateZeros(vector<int>& arr) {int n = arr.size();int cur = 0, dest = -1;// 1. 找最后一个复写的元素while (cur < n) {if (arr[cur] == 0) dest += 2;else dest += 1;if (dest >= n - 1) break; // 已覆盖数组末尾,停止cur++;}// 2. 处理边界:dest超出数组长度(最后一个元素是0)if (dest == n) {arr[n - 1] = 0;cur--;dest -= 2;}// 3. 从后向前复写while (cur >= 0) {if (arr[cur] == 0) {arr[dest--] = 0;arr[dest--] = 0;} else {arr[dest--] = arr[cur];}cur--;}}
};
例题 3:两数之和(LeetCode 167)—— 有序数组目标值查找
题目描述
给定升序排列的数组 nums
和目标值 target
,找出两个数使其和为 target
,返回这两个数(或下标)。
- 示例:输入
[2,7,11,15]
,target=9
,输出[2,7]
。
算法思路
利用数组有序性,用对撞指针缩小范围:
- 初始化
left=0
(左端),right=n-1
(右端); - 计算
sum = nums[left] + nums[right]
:- 若
sum == target
:找到结果,返回; - 若
sum < target
:左指针右移(需更大的数); - 若
sum > target
:右指针左移(需更小的数)。
- 若
代码实现(C++)
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) {return {nums[left], nums[right]}; // 返回数值(若需下标则返回left+1, right+1)} else if (sum < target) {left++;} else {right--;}}return {-1}; // 无结果(题目保证有解可省略)}
};
例题 4:有效三角形个数(LeetCode 611)—— 固定最长边优化
题目描述
给定非负整数数组 nums
,返回能组成三角形的三元组个数(需满足:任意两边之和大于第三边,即最小两边之和大于最大边)。
- 示例:输入
[2,2,3,4]
,输出3
(有效组合:(2,3,4)、(2,3,4)、(2,2,3))。
算法思路
先排序数组,固定最长边(从后向前枚举),再用对撞指针找满足条件的最小两边:
- 排序数组(升序);
- 固定最长边
nums[i]
(i
从n-1
向2
移动,因三角形需 3 条边); - 初始化
left=0
,right=i-1
(在[0, i-1]
中找两数之和大于nums[i]
):- 若
nums[left] + nums[right] > nums[i]
:right
与[left, right-1]
中所有元素都能组成三角形,计数right-left
,right
左移; - 若
nums[left] + nums[right] <= nums[i]
:left
右移(需更大的数)。
- 若
代码实现(C++)
class Solution {
public:int triangleNumber(vector<int>& nums) {sort(nums.begin(), nums.end());int n = nums.size();int ret = 0;// 固定最长边 nums[i]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; // 所有[left, right-1]都满足right--;} else {left++;}}}return ret;}
};
例题 5:三数之和(LeetCode 15)—— 固定一端 + 去重
题目描述
给定数组 nums
,找出所有和为 0 且不重复的三元组(i≠j≠k
)。
- 示例:输入
[-1,0,1,2,-1,-4]
,输出[[-1,-1,2],[-1,0,1]]
。
算法思路
排序后固定一个数,再用对撞指针找另外两个数,核心是去重(避免重复三元组):
- 排序数组(升序,便于去重和缩小范围);
- 固定
nums[i]
(若nums[i] > 0
,则三数和必大于 0,直接 break); - 初始化
left=i+1
,right=n-1
,目标值target = -nums[i]
:- 若
nums[left] + nums[right] == target
:记录三元组,再分别移动left
和right
,并跳过重复元素; - 若和小于
target
:left
右移; - 若和大于
target
:right
左移;
- 若
- 固定数
nums[i]
也需去重(跳过与前一个元素相同的值)。
代码实现(C++)
class Solution {
public:vector<vector<int>> threeSum(vector<int>& nums) {vector<vector<int>> res;sort(nums.begin(), nums.end());int n = nums.size();for (int i = 0; i < n; ) {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) {left++;} else if (sum > target) {right--;} else {// 记录结果res.push_back({nums[i], nums[left], nums[right]});left++;right--;// 去重:跳过left的重复元素while (left < right && nums[left] == nums[left - 1]) left++;// 去重:跳过right的重复元素while (left < right && nums[right] == nums[right + 1]) right--;}}// 去重:跳过i的重复元素i++;while (i < n && nums[i] == nums[i - 1]) i++;}return res;}
};
三、快慢指针经典例题实战
快慢指针的核心是利用速度差检测循环,无需额外空间即可判断 “是否进入循环” 及 “循环起点”,典型应用是 “快乐数” 和 “环形链表”。
例题:快乐数(LeetCode 202)—— 循环检测
题目描述
“快乐数” 定义:对于正整数 n
,每次将其替换为各位数字的平方和,重复此过程,若最终变为 1 则是快乐数,否则进入无限循环(非快乐数)。
- 示例:输入
n=19
,输出true
(19→82→68→100→1);输入n=2
,输出false
。
算法思路
问题本质是判断 “平方和过程是否进入循环”:
- 定义
Jisuan
函数:计算一个数的各位平方和; - 初始化
slow = n
(慢指针,每次走 1 步:计算 1 次平方和),fast = Jisuan(n)
(快指针,每次走 2 步:计算 2 次平方和); - 若
slow == fast
:说明进入循环,若循环点是 1 则为快乐数,否则不是。
代码实现(C++)
class Solution {
public:// 计算n的各位平方和int Jisuan(int n) {int sum = 0;while (n > 0) {int digit = n % 10; // 取个位sum += digit * digit;n /= 10; // 去掉个位}return sum;}bool isHappy(int n) {int slow = n;int fast = Jisuan(n); // 快指针先出发一步// 若slow != fast,继续移动while (slow != fast) {slow = Jisuan(slow); // 慢指针走1步fast = Jisuan(Jisuan(fast)); // 快指针走2步}// 循环终止:若相遇点是1则为快乐数return fast == 1;}
};
原理补充
- 为何会进入循环?根据 “鸽巢原理”,平方和的范围始终在
[1, 810]
(最大 999999999 的平方和为 9²×9=729),最多 811 次计算必重复,即进入循环; - 为何快慢指针能相遇?若有循环,快指针最终会追上慢指针(类似环形跑道上的快、慢运动员)。
四、双指针算法总结
1. 核心优势
- 时间优化:将暴力枚举的 O (n²) 降至 O (n) 或 O (n log n)(排序耗时);
- 空间优化:无需额外数据结构(如哈希表),空间复杂度多为 O (1)。
2. 适用场景速查表
指针类型 | 核心场景 | 典型题目 |
---|---|---|
对撞指针 | 有序数组查找、区间划分 | 两数之和、三数之和、有效三角形个数 |
快慢指针 | 循环检测、位置定位 | 快乐数、环形链表、链表中点 |
3. 避坑指南
- 对撞指针:若数据无序,需先排序(如三数之和),否则无法缩小范围;
- 快慢指针:初始化时快指针可先移动一步(如快乐数),避免初始状态
slow == fast
; - 去重处理:涉及 “不重复结果” 的题目(如三数之和),需在指针移动后跳过重复元素,避免冗余。
双指针算法是解决数组、链表问题的 “瑞士军刀”,掌握其核心逻辑后,可轻松应对多数中等难度的算法题。建议结合本文例题反复练习,重点体会 “指针移动的条件” 和 “边界处理”,逐步形成 “见题思指针” 的解题直觉。