算法篇 | 双指针
前言
在前端面试中,算法题是绕不开的一关。而在所有算法技巧中,双指针可以说是性价比最高的一个——它原理简单、适用范围广、面试出现频率极高。
双指针的核心思想就一句话:用两个指针在数据结构上同时移动,通过指针的相对位置和移动策略来减少不必要的遍历。 它能把很多看似需要 O(n²) 暴力枚举的问题,优化到 O(n) 的时间复杂度。
双指针并不是一个单一的技巧,而是一类策略的统称。根据两个指针的移动方式,可以分为三大类:
- 左右指针(对撞指针):两个指针从数组两端向中间靠拢
- 快慢指针:两个指针从同一端出发,但速度不同
- 滑动窗口:本质也是双指针,但因为足够重要,我们在下一章单独讲
本章我们就把前两类——左右指针和快慢指针——彻底讲清楚,并通过经典 LeetCode 题目帮你建立肌肉记忆。
诊断自测
在开始正文之前,先试试下面这几道题,测测你目前对双指针的掌握程度。
Q1:给定一个升序数组和一个目标值,找出数组中两个数使它们的和等于目标值。要求 O(n) 时间复杂度,不能用哈希表。你会怎么做?
点击查看答案
使用左右指针(对撞指针)。左指针从头开始、右指针从尾开始。如果两数之和大于目标值,右指针左移;如果小于目标值,左指针右移;等于则找到答案。因为数组有序,每次移动都能排除一个不可能的候选者,所以是 O(n)。
Q2:如何判断一个链表是否有环?要求 O(1) 空间复杂度。
点击查看答案
使用快慢指针(Floyd 判圈算法)。慢指针每次走一步,快指针每次走两步。如果链表有环,快指针一定会追上慢指针(两者相遇);如果没有环,快指针会先到达 null。空间复杂度 O(1),时间复杂度 O(n)。
Q3:给定一个有序数组,原地删除重复项,使每个元素只出现一次,返回新长度。要求 O(1) 额外空间。
点击查看答案
使用快慢指针。慢指针 slow 指向”已去重部分”的最后一个位置,快指针 fast 遍历整个数组。当 nums[fast] !== nums[slow] 时,把 nums[fast] 复制到 nums[slow + 1] 的位置,然后 slow 右移。最终 slow + 1 就是去重后的数组长度。
一、左右指针(对撞指针)
左右指针是最直观的双指针形态:一个指针从左端开始,一个从右端开始,两者相向而行,直到相遇。
它适用的场景通常有一个共同特征:数据是有序的(或者可以利用某种单调性)。因为有序性保证了指针移动的方向是确定的——如果当前值太大就缩右边界,太小就扩左边界。
1.1 对撞指针模板
function twoPointerTemplate(arr) {
let left = 0;
let right = arr.length - 1;
while (left < right) {
// 根据条件判断移动哪个指针
if (满足某个条件) {
// 收集结果或处理逻辑
} else if (需要扩大) {
left++;
} else {
right--;
}
}
}
关键点:
- 循环条件一般是
left < right(不取等号,因为两个指针指向同一个元素时没有意义) - 每次循环至少移动一个指针,保证不会死循环
- 移动方向由问题的单调性决定
1.2 经典题:两数之和 II(LeetCode 167)
给定一个已排序的数组
numbers和一个目标值target,找出两个数的下标(1-indexed),使它们的和等于target。
这是对撞指针最经典的应用场景。
思路:左指针指向最小值,右指针指向最大值。两者之和如果太大,说明需要减小,那就右指针左移;如果太小,就左指针右移。
function twoSum(numbers, target) {
let left = 0;
let right = numbers.length - 1;
while (left < right) {
const sum = numbers[left] + numbers[right];
if (sum === target) {
return [left + 1, right + 1]; // 题目要求 1-indexed
} else if (sum < target) {
left++;
} else {
right--;
}
}
return [-1, -1]; // 题目保证有解,实际不会走到这里
}
为什么这是对的? 因为数组是有序的,当 sum > target 时,如果不减小右边的值,无论左指针怎么右移,和只会更大。所以右指针左移是安全的——我们不会错过答案。同理,sum < target 时左指针右移也是安全的。
复杂度分析:
- 时间复杂度:O(n),每个元素最多被访问一次
- 空间复杂度:O(1),只用了两个指针变量
1.3 经典题:盛最多水的容器(LeetCode 11)
给定 n 个非负整数
height,每个整数代表一条垂直线段。找出两条线段,使它们与 x 轴构成的容器能容纳最多的水。
这道题乍一看和”两数之和”没什么关系,但它的核心逻辑同样是对撞指针。
关键洞察:容器的水量 = min(height[left], height[right]) * (right - left)。如果我们移动较高的那个指针,宽度减小了,而高度最多不变(取决于较矮的那根),所以水量一定减小。因此,每次移动较矮的那个指针,才有可能找到更大的容量。
function maxArea(height) {
let left = 0;
let right = height.length - 1;
let maxWater = 0;
while (left < right) {
const width = right - left;
const h = Math.min(height[left], height[right]);
maxWater = Math.max(maxWater, width * h);
// 移动较矮的那一侧
if (height[left] < height[right]) {
left++;
} else {
right--;
}
}
return maxWater;
}
复杂度分析:
- 时间复杂度:O(n)
- 空间复杂度:O(1)
面试追问:为什么移动较矮的指针是正确的?
答:假设 height[left] < height[right],当前水量受限于 height[left]。如果移动右指针(较高的一侧),宽度减小 1,而新的高度 min(height[left], height[right-1]) 最多等于 height[left](因为 min 的上界不变),所以水量一定不会增大。因此移动右指针不可能得到更优解,可以安全跳过。
二、快慢指针
快慢指针的特征是:两个指针从同一个位置出发,但移动速度不同。 最经典的用法是一个每次走一步(慢指针),另一个每次走两步(快指针)。
它主要用于两类问题:
- 链表中的环检测(Floyd 判圈算法)
- 数组/链表的原地操作(去重、移除元素等)
2.1 经典题:环形链表(LeetCode 141)
给定一个链表的头节点
head,判断链表中是否有环。
这是快慢指针最经典的应用——Floyd 判圈算法。
直觉:想象两个人在操场上跑步,一个跑得快,一个跑得慢。如果操场是环形的,快的人迟早会追上慢的人(套圈)。如果操场是直线的,快的人会先到终点。
function hasCycle(head) {
let slow = head;
let fast = head;
while (fast !== null && fast.next !== null) {
slow = slow.next; // 慢指针走一步
fast = fast.next.next; // 快指针走两步
if (slow === fast) {
return true; // 相遇了,说明有环
}
}
return false; // fast 到达了末尾,说明无环
}
为什么快指针每次走两步一定能追上慢指针?
假设慢指针进入环后,快指针在环中距离慢指针 k 步。每次循环,快指针比慢指针多走 1 步,所以距离每次缩小 1。经过 k 次循环后,距离变为 0,两者相遇。所以只要有环,一定会在环内相遇。
复杂度分析:
- 时间复杂度:O(n)
- 空间复杂度:O(1)
2.2 进阶:环形链表 II — 找到环的入口(LeetCode 142)
给定一个链表,返回链表开始入环的第一个节点。如果链表无环,返回
null。
这道题在面试中经常作为环形链表的追问出现。
核心推导:设链表头到环入口的距离为 a,环入口到相遇点的距离为 b,相遇点再走回环入口的距离为 c。那么:
- 慢指针走了
a + b步 - 快指针走了
a + b + n(b + c)步(n 为快指针在环中转的圈数) - 因为快指针速度是慢指针的两倍:
2(a + b) = a + b + n(b + c) - 化简得:
a = (n - 1)(b + c) + c
这意味着:从头节点走 a 步到环入口,等价于从相遇点走 c + (n-1) 圈到环入口。所以让一个指针从头节点出发,另一个从相遇点出发,都每次走一步,它们会在环入口相遇。
function detectCycle(head) {
let slow = head;
let fast = head;
// 第一步:判断是否有环,找到相遇点
while (fast !== null && fast.next !== null) {
slow = slow.next;
fast = fast.next.next;
if (slow === fast) {
// 第二步:找环的入口
let ptr = head;
while (ptr !== slow) {
ptr = ptr.next;
slow = slow.next;
}
return ptr;
}
}
return null;
}
复杂度分析:
- 时间复杂度:O(n)
- 空间复杂度:O(1)
2.3 经典题:删除有序数组中的重复项(LeetCode 26)
给定一个升序数组
nums,原地删除重复项,使每个元素只出现一次,返回新长度。
思路:慢指针 slow 维护去重后的数组边界,快指针 fast 遍历原数组。当遇到一个新值时(nums[fast] !== nums[slow]),把它放到慢指针的下一个位置。
function removeDuplicates(nums) {
if (nums.length === 0) return 0;
let slow = 0;
for (let fast = 1; fast < nums.length; fast++) {
if (nums[fast] !== nums[slow]) {
slow++;
nums[slow] = nums[fast];
}
}
return slow + 1; // 长度 = 最后一个位置的下标 + 1
}
复杂度分析:
- 时间复杂度:O(n)
- 空间复杂度:O(1)
这道题的快慢指针和链表环检测的快慢指针有什么不同?
链表环检测中,快指针”快”在每次走两步;而这里的快指针”快”在它遍历所有元素,而慢指针只在需要时才移动。本质上,慢指针维护的是”已处理结果”的边界,快指针负责探索。
三、双指针的通用思考框架
当你遇到一道新题时,如何判断能不能用双指针?这里给一个通用的判断思路:
- 是否涉及”一对”元素的关系?(两数之和、容器两端)→ 考虑左右指针
- 数据是否有序或单调? → 有序性是左右指针的前提
- 是否需要原地操作(O(1) 空间)? → 考虑快慢指针
- 是否涉及链表的环或位置关系? → 考虑快慢指针
- 是否涉及连续子序列? → 考虑滑动窗口(下一章)
记住:双指针的本质是通过维护两个位置信息,避免重复遍历,从而将 O(n²) 优化到 O(n)。
常见误区
误区一:“双指针只能用在有序数组上”
左右指针确实要求某种单调性,但快慢指针不需要。链表环检测、数组去重都不要求全局有序。另外,即使数组无序,有时候可以先排序再用双指针(如三数之和),排序的 O(n log n) 通常是可以接受的。
误区二:“快慢指针只能用在链表上”
快慢指针在数组中同样大量使用。比如删除有序数组的重复项、移除元素(LeetCode 27)、移动零(LeetCode 283)等。只要是需要”原地修改,保持相对顺序”的场景,快慢指针都是首选。
误区三:“左右指针只要 left < right 就行了”
虽然大多数情况下循环条件是 left < right,但有些题目需要 left <= right(比如二分查找中的某些变种)。一定要根据具体问题分析:当 left === right 时,是否还有需要处理的情况。
误区四:“双指针题只需要写出答案,不用分析正确性”
面试中,面试官经常会追问”为什么移动这个指针是安全的”或者”为什么不会错过答案”。如果你只是背了模板但说不清原理,分数会大打折扣。每道双指针题,都要能解释清楚为什么指针的移动方向是对的。
小结
本章我们系统地讲解了双指针技巧的两大类型:
-
左右指针(对撞指针):从两端向中间逼近,利用有序性或单调性确定移动方向。经典场景包括两数之和、盛水容器、回文判断等。
-
快慢指针:从同一端出发但速度不同,用于链表环检测(Floyd 算法)和数组原地操作(去重、移除元素等)。
双指针的核心价值在于:通过维护两个位置信息,将 O(n²) 的暴力搜索优化到 O(n)。 它不是一个复杂的算法,而是一种思维方式——当你看到一道需要在序列中找”一对”元素或做”原地操作”的题时,第一反应就应该想到双指针。
本章思维导图
- 核心思想
- 用两个指针同时遍历,减少不必要的搜索
- 将 O(n²) 优化到 O(n)
- 左右指针(对撞指针)
- 适用场景:有序数组、单调性问题
- 模板:left=0, right=len-1, 相向移动
- 经典题
- 两数之和 II(LeetCode 167)
- 盛最多水的容器(LeetCode 11)
- 三数之和(LeetCode 15)
- 关键:每次移动都要排除一个不可能的候选
- 快慢指针
- 链表场景
- 环检测(LeetCode 141)
- 环入口(LeetCode 142)
- 链表中点(LeetCode 876)
- 数组场景
- 删除有序数组重复项(LeetCode 26)
- 移除元素(LeetCode 27)
- 移动零(LeetCode 283)
- 关键:慢指针维护结果边界,快指针负责探索
- 链表场景
- 判断是否可用双指针
- 涉及一对元素关系 → 左右指针
- 需要原地操作 → 快慢指针
- 涉及链表环 → 快慢指针
- 涉及连续子序列 → 滑动窗口
练习挑战
第一题 ⭐:移动零(LeetCode 283)
给定一个数组 nums,将所有 0 移动到数组末尾,同时保持非零元素的相对顺序。要求原地操作。
// 示例
// 输入: [0, 1, 0, 3, 12]
// 输出: [1, 3, 12, 0, 0]
点击查看答案
function moveZeroes(nums) {
let slow = 0;
// 快指针遍历数组,遇到非零就放到 slow 的位置
for (let fast = 0; fast < nums.length; fast++) {
if (nums[fast] !== 0) {
// 交换 slow 和 fast 位置的元素
[nums[slow], nums[fast]] = [nums[fast], nums[slow]];
slow++;
}
}
return nums;
}
思路:快慢指针。slow 指向下一个非零元素应该放的位置,fast 遍历所有元素。遇到非零元素就和 slow 位置交换,slow 右移。最终 slow 左边全是非零元素,右边全是零。
时间复杂度 O(n),空间复杂度 O(1)。
第二题 ⭐⭐:三数之和(LeetCode 15)
给定一个数组 nums,找出所有和为 0 的三元组,不能包含重复的三元组。
// 示例
// 输入: [-1, 0, 1, 2, -1, -4]
// 输出: [[-1, -1, 2], [-1, 0, 1]]
点击查看答案
function threeSum(nums) {
const result = [];
nums.sort((a, b) => a - b); // 先排序
for (let i = 0; i < nums.length - 2; i++) {
// 跳过重复的第一个数
if (i > 0 && nums[i] === nums[i - 1]) continue;
let left = i + 1;
let right = nums.length - 1;
while (left < right) {
const sum = nums[i] + nums[left] + nums[right];
if (sum === 0) {
result.push([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 < 0) {
left++;
} else {
right--;
}
}
}
return result;
}
思路:先排序,然后固定一个数 nums[i],在其右侧用对撞指针找另外两个数使三数之和为 0。去重的关键是跳过相邻的重复元素。
时间复杂度 O(n²),空间复杂度 O(1)(不计输出)。
第三题 ⭐⭐⭐:接雨水(LeetCode 42)
给定 n 个非负整数表示柱子的高度,计算下雨后能接多少雨水。
// 示例
// 输入: [0, 1, 0, 2, 1, 0, 1, 3, 2, 1, 2, 1]
// 输出: 6
点击查看答案
function trap(height) {
let left = 0;
let right = height.length - 1;
let leftMax = 0;
let rightMax = 0;
let water = 0;
while (left < right) {
if (height[left] < height[right]) {
// 左边较矮,水量由左边最大值决定
if (height[left] >= leftMax) {
leftMax = height[left];
} else {
water += leftMax - height[left];
}
left++;
} else {
// 右边较矮,水量由右边最大值决定
if (height[right] >= rightMax) {
rightMax = height[right];
} else {
water += rightMax - height[right];
}
right--;
}
}
return water;
}
思路:对撞指针 + 维护左右两侧的历史最大值。对于每个位置,它能接的水取决于 min(leftMax, rightMax) - height[i]。当 height[left] < height[right] 时,leftMax 一定小于等于 rightMax(因为 rightMax >= height[right] > height[left]),所以当前位置的水量只取决于 leftMax。
时间复杂度 O(n),空间复杂度 O(1)。
自我检测
- 能说清楚左右指针和快慢指针的适用场景区别
- 能默写对撞指针模板,并解释循环条件为什么是
left < right - 能用对撞指针解决两数之和 II,并解释为什么每次移动指针不会错过答案
- 能解释盛最多水的容器中”移动较矮指针”的贪心策略为什么是正确的
- 能用快慢指针实现链表环检测,并推导出环入口的数学证明
- 能用快慢指针原地删除有序数组的重复项
- 遇到新题时,能快速判断是否适合用双指针,以及该用哪种类型
- 能解释双指针技巧的本质——通过维护两个位置信息将 O(n²) 优化到 O(n)
购买课程解锁全部内容
大厂前端面试通关:71 篇构建完整知识体系
¥89.90