3. 无重复字符的最长子串
这道题目是 “无重复字符的最长子串” 。它使用的核心技巧是算法中非常著名的 “滑动窗口 (Sliding Window)” 。
🏠 生活案例:贪吃蛇的极限拉扯
想象你在玩一个简化版的“贪吃蛇”,蛇在一条长廊(字符串)里往前爬:
-
目标:蛇身越长越好。
-
规则:蛇身体里不能有两个相同的字符。
-
游戏过程:
- 蛇头 (
right) 不断向前伸,吞下新字符。 - 如果吞下的新字符和身体里的某个字符撞了(重复了),蛇尾就必须立刻收缩 (
left) 。 - 蛇尾要缩到哪里呢?直接缩到那个重复字符的“下一个位置”,把脏东西甩掉,这样蛇身就又变回“无重复”状态了。
- 蛇头 (
💻 代码实现与生活化注释
这是你图片中的代码,我为你加上了“滑动窗口”视角的注释:
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:
- 吃到第二个
b时,left跳到索引2(第二个b的位置)。 - 吃到最后的
a时,虽然a在map里有记录(索引0),但索引0已经在现在的蛇尾left之后了。 - 如果不加这个判断,蛇尾会从
2倒退回1,这就不符合逻辑了。
复杂度分析:
- 时间复杂度:。虽然有
if判断和map操作,但right指针只从头走到尾一次。 - 空间复杂度:。取决于字符集的大小()和字符串长度(),主要是
Map占用的空间。
438. 找到字符串中所有字母异位词
这道题目是 “找到字符串中所有字母异位词” 。它在上一题“滑动窗口”的基础上,增加了一个定长窗口的概念。
所谓的“字母异位词”,生活化地理解就是:两个单词用的字母一模一样,只是排列顺序不同。 比如 abc 和 cba。
🏠 生活案例:超市查库存
想象你手里有一张购物清单(字符串 p),上面写着:1个苹果、1个香蕉、1个橙子。
你在超市的长货架(字符串 s)上推着一个固定长度的小推车往前走:
- 初始状态:你把前三个位置的商品放进车里。
- 比对:看看车里的商品种类和数量,是不是跟清单上一模一样?如果是,记录下现在的起点。
- 滑动:往前走一步。你必须扔掉最左边那个商品,然后加入最右边新遇到的商品。
- 循环:每走一步比对一次,直到逛完整个货架。
💻 代码实现与生活化注释
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. 时间复杂度
这个算法的时间复杂度是 (其中 是字符串 s 的长度)。
虽然我们在循环里做了 toString() 操作,但因为数组长度固定是 26,这个对比的耗时是常数级别的,不会随着字符串变长而爆炸增长。