双指针技巧:从快慢指针到滑动窗口

294 阅读7分钟

在数组类算法题中,双指针是面试高频考点。它通过两个指针协同遍历数组,避免了暴力解法的重复操作,能将时间复杂度从 O (n²) 优化到 O (n),尤其适合处理 “原地修改”“子数组查询” 等问题。

一、快慢指针:处理 “原地修改数组” 问题(以移动零为例)

快慢指针是双指针中最基础的类型,核心是用两个指针遍历数组,快指针负责 “探索”,慢指针负责 “记录有效位置” ,适合解决 “元素移动”“去重” 等需要原地修改数组的问题。

(1)例题分析:移动零(LeetCode 283)

image.png

暴力解法的局限:

暴力思路是两次遍历:第一次收集非零元素,第二次补零。虽然时间复杂度是 O (n),但逻辑不够简洁。而快慢指针能一次遍历完成,更高效。

快慢指针解题思路:

  1. 定义指针角色

    • 快指针(fast):遍历整个数组,寻找非零元素。
    • 慢指针(slow):记录 “下一个非零元素应该存放的位置”。
  2. 遍历过程

    • 快指针从 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)

image.png

滑动窗口解题思路:

  1. 定义窗口边界

    • 右指针(end):扩张窗口,负责将元素加入窗口,扩大窗口范围。
    • 左指针(start):收缩窗口,当窗口内的和≥target 时,右移左指针,缩小窗口范围以寻找更短的有效子数组。
  2. 窗口操作

    • 初始化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)适用场景总结

双指针类型核心作用典型问题
快慢指针原地修改数组(筛选、移动、去重)移动零、删除重复元素、移除元素
滑动窗口子数组 / 子串的范围查询最小子数组长度、最长无重复子串、水果成篮

四、面试中的注意事项

  1. 算法选择:遇到数组题时,先判断是否适合双指针:

    • 若需要 “原地修改元素顺序”,优先考虑快慢指针。
    • 若需要 “寻找满足条件的子数组 / 子串”,优先考虑滑动窗口。
  2. 边界条件:注意数组为空、元素全满足 / 全不满足条件的情况(如移动零中 nums=[0],最小子数组中 sum 始终 < target)。

  3. 代码简洁性:双指针代码往往很短,但逻辑密度高,面试时需清晰解释指针移动的条件(如 “为什么此时要移动左指针?”)。

  4. 与其他算法的结合:双指针常与贪心思想结合(如滑动窗口中 “收缩左指针以找最优解” 本质是贪心策略),理解这一点能更灵活地应用。

五、总结:双指针是数组题的 “最优解钥匙”

双指针技巧通过两个指针的协同遍历,完美解决了数组问题中 “暴力解法低效”“原地修改”“子数组查询” 等核心痛点。

核心要点:

  • 快慢指针:快指针找有效元素,慢指针存有效元素,适合原地修改。
  • 滑动窗口:右指针扩窗口,左指针缩窗口,适合子数组范围查询。
  • 时间复杂度 O (n)、空间复杂度 O (1),是优化算法的典型代表。