滑动窗口

0 阅读5分钟

3. 无重复字符的最长子串

这道题目是 “无重复字符的最长子串” 。它使用的核心技巧是算法中非常著名的 “滑动窗口 (Sliding Window)”


🏠 生活案例:贪吃蛇的极限拉扯

想象你在玩一个简化版的“贪吃蛇”,蛇在一条长廊(字符串)里往前爬:

  • 目标:蛇身越长越好。

  • 规则:蛇身体里不能有两个相同的字符。

  • 游戏过程

    1. 蛇头 (right) 不断向前伸,吞下新字符。
    2. 如果吞下的新字符和身体里的某个字符撞了(重复了),蛇尾就必须立刻收缩 (left)
    3. 蛇尾要缩到哪里呢?直接缩到那个重复字符的“下一个位置”,把脏东西甩掉,这样蛇身就又变回“无重复”状态了。

💻 代码实现与生活化注释

这是你图片中的代码,我为你加上了“滑动窗口”视角的注释:

JavaScript

/**
 * @param {string} s
 * @return {number}
 */
var lengthOfLongestSubstring = function(s) {
    // left 就像蛇尾,right 就像蛇头
    let left = 0;
    // map 是记忆卡,记录每个字符最后一次出现的“坐标”
    let map = new Map();
    // maxlen 记录蛇曾经达到过的最长长度
    let maxlen = 0;

    for(let right = 0; right < s.length; right++){
        let char = s[right]; // 蛇头正要吞下的新字符

        /**
         * 核心:发现重复!
         * 如果发现这个字符以前吃过,并且它还在现在的蛇身体里 (index >= left)
         */
        if(map.has(char) && map.get(char) >= left) {
            // 蛇尾立刻瞬移到重复字符的右边一位,实现“断尾求生”
            left = map.get(char) + 1;
        }

        // 记账:更新(或记录)这个字符最近出现的坐标
        map.set(char, right);

        /**
         * 测量长度:
         * 现在的蛇身长度就是 (头 - 尾 + 1)
         * 看看是不是刷新了最高纪录
         */
        maxlen = Math.max(maxlen, right - left + 1);
    }

    return maxlen;
};

🔍 为什么要用 map.get(char) >= left

这行代码非常巧妙,它能防止“蛇尾倒退”。

假设字符串是 abba

  1. 吃到第二个 b 时,left 跳到索引 2(第二个 b 的位置)。
  2. 吃到最后的 a 时,虽然 amap 里有记录(索引 0),但索引 0 已经在现在的蛇尾 left 之后了。
  3. 如果不加这个判断,蛇尾会从 2 倒退回 1,这就不符合逻辑了。

复杂度分析:

  • 时间复杂度O(n)O(n)。虽然有 if 判断和 map 操作,但 right 指针只从头走到尾一次。
  • 空间复杂度O(min(m,n))O(min(m, n))。取决于字符集的大小(mm)和字符串长度(nn),主要是 Map 占用的空间。

438. 找到字符串中所有字母异位词

这道题目是 “找到字符串中所有字母异位词” 。它在上一题“滑动窗口”的基础上,增加了一个定长窗口的概念。

所谓的“字母异位词”,生活化地理解就是:两个单词用的字母一模一样,只是排列顺序不同。 比如 abccba


🏠 生活案例:超市查库存

想象你手里有一张购物清单(字符串 p),上面写着:1个苹果、1个香蕉、1个橙子。

你在超市的长货架(字符串 s)上推着一个固定长度的小推车往前走:

  1. 初始状态:你把前三个位置的商品放进车里。
  2. 比对:看看车里的商品种类和数量,是不是跟清单上一模一样?如果是,记录下现在的起点。
  3. 滑动:往前走一步。你必须扔掉最左边那个商品,然后加入最右边新遇到的商品。
  4. 循环:每走一步比对一次,直到逛完整个货架。

💻 代码实现与生活化注释

JavaScript

/**
 * @param {string} s
 * @param {string} p
 * @return {number[]}
 */
var findAnagrams = function(s, p) {
    let sLen = s.length;
    let pLen = p.length;
    if (sLen < pLen) return [];

    // 1. 准备两个“库存表”(长度为26的数组,对应a-z)
    let sCount = new Array(26).fill(0);
    let pCount = new Array(26).fill(0);
    let result = [];

    // 2. 初始化:先把清单 p 的库存数记好,同时把货架前 pLen 个商品放进车里
    for (let j = 0; j < pLen; j++) {
        // charCodeAt(j)-97 是把字母 'a'-'z' 转成数字 0-25
        sCount[s.charCodeAt(j) - 97]++;
        pCount[p.charCodeAt(j) - 97]++;
    }

    // 3. 检查第一个窗口:如果一开始就对上了,起点 0 入队
    if (sCount.toString() === pCount.toString()) {
        result.push(0);
    }

    // 4. 开始推车移动(滑动窗口)
    for (let i = 0; i < sLen - pLen; i++) {
        /**
         * 扔掉旧的:i 是窗口左端
         * 这一步就像商品从推车左边掉出去了
         */
        sCount[s.charCodeAt(i) - 97]--;

        /**
         * 加入新的:i + pLen 是窗口右端新进来的位置
         * 这一步就像右手抓了一个新商品放进车里
         */
        sCount[s.charCodeAt(i + pLen) - 97]++;

        /**
         * 查账:
         * 每次移动后,直接对比两个“库存表”字符串是否一致
         */
        if (sCount.toString() === pCount.toString()) {
            result.push(i + 1); // 记录新的起点
        }
    }

    return result;
};

🔍 关键技巧点拨

1. 为什么用 Array(26) 而不是 Map

虽然 Map 也能做,但在处理纯小写字母时,固定长度为 26 的数组效率更高。它就像 26 个固定的储物格,寻找某个字母的时间是恒定的。

2. toString() 的妙用

在 JavaScript 里,直接比较两个数组 [1,2] === [1,2] 是不成立的(因为引用地址不同)。代码通过 toString() 把数组转成类似 "1,0,1,0..." 的字符串,这样就能快速对比两个库存表的内容是否完全一致了。

3. 时间复杂度

这个算法的时间复杂度是 O(n)O(n) (其中 nn 是字符串 s 的长度)。

虽然我们在循环里做了 toString() 操作,但因为数组长度固定是 26,这个对比的耗时是常数级别的,不会随着字符串变长而爆炸增长。