在数组类算法题中,双指针是面试高频考点。它通过两个指针协同遍历数组,避免了暴力解法的重复操作,能将时间复杂度从 O (n²) 优化到 O (n),尤其适合处理 “原地修改”“子数组查询” 等问题。
一、快慢指针:处理 “原地修改数组” 问题(以移动零为例)
快慢指针是双指针中最基础的类型,核心是用两个指针遍历数组,快指针负责 “探索”,慢指针负责 “记录有效位置” ,适合解决 “元素移动”“去重” 等需要原地修改数组的问题。
(1)例题分析:移动零(LeetCode 283)
暴力解法的局限:
暴力思路是两次遍历:第一次收集非零元素,第二次补零。虽然时间复杂度是 O (n),但逻辑不够简洁。而快慢指针能一次遍历完成,更高效。
快慢指针解题思路:
-
定义指针角色:
- 快指针(
fast):遍历整个数组,寻找非零元素。 - 慢指针(
slow):记录 “下一个非零元素应该存放的位置”。
- 快指针(
-
遍历过程:
-
快指针从 0 开始遍历数组:
- 若
nums[fast]不是 0,说明是有效元素,将其放到慢指针位置(nums[slow] = nums[fast]),慢指针右移(slow++)。 - 若
nums[fast]是 0,快指针直接右移,不操作慢指针。
- 若
-
遍历结束后,慢指针左侧都是非零元素,右侧(从
slow到数组末尾)全部补 0。
-
var moveZeroes = function(nums) {
if (nums.length === 0) return;
let slow = 0; // 慢指针:记录非零元素的位置
// 快指针遍历数组
for (let fast = 0; fast < nums.length; fast++) {
if (nums[fast] !== 0) { // 遇到非零元素
// 交换快慢指针元素(其实是将非零元素移到slow位置)
[nums[slow], nums[fast]] = [nums[fast], nums[slow]];
slow++; // 慢指针右移,准备接收下一个非零元素
}
}
// 此时slow左侧已全是非零元素,右侧自动保留0(无需额外补0)
};
为什么能工作?
- 快指针遍历所有元素,确保每个非零元素都被 “搬运” 到慢指针位置,维持相对顺序。
- 交换操作实现了 “原地修改”,无需额外空间(空间复杂度 O (1))。
- 时间复杂度 O (n),仅遍历一次数组。
(2)快慢指针解题模板
适用于 “原地修改数组”(如移动元素、去重、筛选元素):
function solve(nums) {
let slow = 0; // 慢指针:记录有效元素的位置
for (let fast = 0; fast < nums.length; fast++) {
// 快指针遍历,遇到符合条件的元素(如非零、非重复)
if (/* 满足条件:如nums[fast] !== 0 */) {
// 操作:交换或直接赋值(根据场景)
[nums[slow], nums[fast]] = [nums[fast], nums[slow]];
slow++; // 慢指针右移
}
}
// 可选:处理剩余位置(如补0、截断数组)
// nums.splice(slow); // 若需删除无效元素
}
核心逻辑:快指针负责 “找有效元素”,慢指针负责 “存有效元素”,通过一次遍历完成筛选 + 移动,避免二次遍历。
二、滑动窗口:处理 “子数组范围查询” 问题(以最小子数组长度为例)
滑动窗口是双指针的另一种形式,核心是用两个指针(左边界 left 和右边界 right)维护一个 “窗口”,通过移动边界调整窗口范围,解决子数组 / 子串的范围查询问题,如 “求和”“最长 / 最短长度” 等。
(1)例题分析:最小子数组长度(LeetCode 209)
滑动窗口解题思路:
-
定义窗口边界:
- 右指针(end):扩张窗口,负责将元素加入窗口,扩大窗口范围。
- 左指针(start):收缩窗口,当窗口内的和≥target 时,右移左指针,缩小窗口范围以寻找更短的有效子数组。
-
窗口操作:
- 初始化
sum=0(窗口内元素和)、start=0(左边界)、minLen=Infinity(最小长度)。 - 右指针 end 从 0 遍历数组,每次将
nums[end]加入 sum。 - 当 sum≥target 时,更新
minLen(取当前窗口长度end-start+1的最小值),然后右移 start,从 sum 中减去nums[start],重复此过程直到 sum<target。 - 遍历结束后,若
minLen仍为 Infinity,返回 0;否则返回minLen。
- 初始化
代码实现:
var minSubArrayLen = function(target, nums) {
let n = nums.length;
let minLen = Infinity;
let start = 0; // 左边界
let sum = 0; // 窗口内元素和
for (let end = 0; end < n; end++) {
sum += nums[end]; // 右指针扩张窗口,加入元素
// 当窗口和≥target时,收缩左边界找最短长度
while (sum >= target) {
// 更新最小长度
minLen = Math.min(minLen, end - start + 1);
// 左指针右移,缩小窗口
sum -= nums[start];
start++;
}
}
return minLen === Infinity ? 0 : minLen;
};
为什么能工作?
- 窗口始终保持 “和≥target” 的最小范围:当 sum≥target 时,收缩左边界能排除冗余元素,找到更短的有效子数组。
- 每个元素仅被 start 和 end 各遍历一次,时间复杂度 O (n)。
- 空间复杂度 O (1),无需额外存储子数组。
(2)滑动窗口解题模板
适用于 “子数组 / 子串的范围查询”(如求和、长度、包含元素等):
function solve(target, nums) {
let start = 0; // 左边界
let sum = 0; // 窗口内的指标(和、计数等)
let result = Infinity; // 存储结果(最小/最大长度等)
for (let end = 0; end < nums.length; end++) {
// 右指针扩张窗口,更新窗口内指标
sum += nums[end];
// 当窗口满足条件(如sum≥target),收缩左边界
while (/* 窗口满足条件:如sum >= target */) {
// 更新结果(如最短长度)
result = Math.min(result, end - start + 1);
// 左指针右移,缩小窗口,更新指标
sum -= nums[start];
start++;
}
}
// 处理结果(如未找到返回0)
return result === Infinity ? 0 : result;
}
核心逻辑:右指针负责 “扩张窗口” 以满足条件,左指针负责 “收缩窗口” 以优化结果(如最短长度),通过一次遍历找到最优解。
三、双指针技巧背后的核心知识点
(1)时间复杂度优化
双指针的核心价值是减少重复遍历:
- 暴力解法往往需要嵌套循环(O (n²)),而双指针通过两个指针同向遍历,将时间复杂度降至 O (n)。
- 例如 “移动零” 中,快慢指针一次遍历完成两次遍历的工作;“最小子数组” 中,滑动窗口用一次遍历替代了所有子数组的枚举。
(2)原地操作的意义
数组题常要求 “原地修改”(如移动零的 “不复制数组”),双指针通过交换 / 覆盖元素实现原地操作,空间复杂度从 O (n) 降至 O (1),这在内存受限的场景(如面试中的算法题)中至关重要。
(3)适用场景总结
| 双指针类型 | 核心作用 | 典型问题 |
|---|---|---|
| 快慢指针 | 原地修改数组(筛选、移动、去重) | 移动零、删除重复元素、移除元素 |
| 滑动窗口 | 子数组 / 子串的范围查询 | 最小子数组长度、最长无重复子串、水果成篮 |
四、面试中的注意事项
-
算法选择:遇到数组题时,先判断是否适合双指针:
- 若需要 “原地修改元素顺序”,优先考虑快慢指针。
- 若需要 “寻找满足条件的子数组 / 子串”,优先考虑滑动窗口。
-
边界条件:注意数组为空、元素全满足 / 全不满足条件的情况(如移动零中 nums=[0],最小子数组中 sum 始终 < target)。
-
代码简洁性:双指针代码往往很短,但逻辑密度高,面试时需清晰解释指针移动的条件(如 “为什么此时要移动左指针?”)。
-
与其他算法的结合:双指针常与贪心思想结合(如滑动窗口中 “收缩左指针以找最优解” 本质是贪心策略),理解这一点能更灵活地应用。
五、总结:双指针是数组题的 “最优解钥匙”
双指针技巧通过两个指针的协同遍历,完美解决了数组问题中 “暴力解法低效”“原地修改”“子数组查询” 等核心痛点。
核心要点:
- 快慢指针:快指针找有效元素,慢指针存有效元素,适合原地修改。
- 滑动窗口:右指针扩窗口,左指针缩窗口,适合子数组范围查询。
- 时间复杂度 O (n)、空间复杂度 O (1),是优化算法的典型代表。