一、这段代码在解决什么问题
先别急着看代码,我们先搞清楚它要回答的那个问题。假设你手里有两串字符,短的那串叫 s,长的那串叫 t。现在要你回答:s 能不能不改变字符顺序、只是"跳过" t 里的一些字符,从而"拼"出来?
用个生活中的例子:s = "abc",t = "ahbgdc"。我们在 t 里找 a,找到了;接着往后找 b,也找到了;再往后找 c,又找到了。所以答案是"能",s 是 t 的子序列。但如果 s = "axc",在 t 里找到 a 之后往后找 x,翻遍了 t 也找不到 x,答案就是"不能"。
注意"子序列"和"子串"是两回事:子串要求字符在 t 里是连续挨着 的;子序列只要求字符按顺序出现 ,中间可以隔着别的字符。"abc" 是 "ahbgdc" 的子序列,但它并不是 "ahbgdc" 的子串,因为 a 和 b 之间隔了个 h。
二、核心思想:两个"指针"像两只手一样扫过去
代码里的两个变量 i 和 j,就是所谓的"双指针"。你可以把它们想象成两只手,各自指着一个位置:
i这只手,指向短串s的某个字符,代表"我现在要找的下一个字符是谁"。j这只手,指向长串t的某个字符,代表"我现在正在t里检查到哪了"。
整个过程就一句话:用 j 在 t 里一路向右扫,凡是碰到的字符恰好是 s[i] 需要的,就把 i 也往右挪一格,表示"这个字符找到了,继续找下一个"。 扫完整个 t 之后,如果 i 已经走到了 s 的末尾,说明 s 的每个字符都在 t 里按顺序找到了。
为什么这个"碰到就匹配、匹配了就前进"的思路一定对?关键在于一个直觉:对于 s 里的每一个字符,我们当然希望它在 t 里出现得越靠前越好。因为越靠前,留给后面字符的"余量"就越大。这正是一种"贪心"策略------每一步都做当下看起来最有利的选择,最终就能得到全局正确的结果。后面我们会用一个例子验证这个直觉。
三、逐行拆解:这 7 行代码到底在干嘛
javascript
let i = 0, j = 0;
初始化两只手,都从各自字符串的**开头(下标 0)**出发。下标是编程里"第几个字符"的编号方式,从 0 开始数。
javascript
while(i < s.length && j < t.length){
循环继续的条件有两个,必须同时 满足:i 还没走出 s 的末尾,j 也还没走出 t 的末尾。只要有一只手走到了头,循环就该停了------要么 s 已经被全部匹配完(成功),要么 t 被扫完了还没找到(失败)。
javascript
if(s[i] === t[j]){
i++;
}
这是循环里唯一的判断:如果当前 t[j] 这个字符,正好等于我们正在寻找的 s[i],说明"找到了一个",于是 i++,让 i 指向 s 的下一个字符,准备找下一个。
javascript
j++;
}
这一行无论是否匹配都会执行 。j 的手永远向右移动,表示我们在 t 里一个字符一个字符地往下扫。也就是说:i 是"条件性地前进"(只有匹配时才动),j 是"无条件地前进"(每个回合都动)。这正是双指针的精髓------一个跑,一个在需要时跟。
javascript
return i === s.length;
最后,判断 i 是否已经走到了 s 的末尾(s.length 就是 s 的长度)。如果 i 等于 s.length,意味着 s 的每一个字符都被成功匹配过,返回 true;否则说明还差几个字符没找到,返回 false。
四、跑一个例子,看代码怎么一步步动
用 s = "abc"、t = "ahbgdc" 来模拟一下:
| 回合 | i |
j |
s[i] |
t[j] |
结果 |
|---|---|---|---|---|---|
| 初始 | 0 | 0 | a |
a |
相等,i 变 1 |
| 1 | 1 | 1 | b |
h |
不等,i 不动 |
| 2 | 1 | 2 | b |
b |
相等,i 变 2 |
| 3 | 2 | 3 | c |
g |
不等 |
| 4 | 2 | 4 | c |
d |
不等 |
| 5 | 2 | 5 | c |
c |
相等,i 变 3 |
| 结束 | 3 | --- | --- | --- | j 走到头,循环退出 |
循环结束时 i = 3,而 s.length = 3,所以 i === s.length 成立,返回 true。整个过程 j 从 0 一路扫到 5,而 i 只在三个匹配点上各前进一次------完美对应了"找到 a、b、c"这三个动作。
五、为什么"贪心"在这里是对的
前面我们提到,让每个字符都匹配到 t 里"最早出现的位置"是最优的。直观地想:假设 s 里的字符 x 在 t 里出现了两次,一次在第 3 位、一次在第 10 位。如果我们贪心地匹配了第 3 位那个,那么第 4 位往后的字符就可以在第 3 位之后随便找;而如果我们"故意"跳过第 3 位、去匹配第 10 位那个,反而白白缩小了后面字符的搜索空间,没有任何好处。所以越早匹配越安全,贪心策略不会错过任何可能。
这里也有一个重要的边界情况值得一提:如果 s 是空字符串 (s.length === 0),那么它一定是任何字符串的子序列。代码里 i 初始为 0,而空串的 s.length 也是 0,循环条件一开始 i < s.length 就是假的,循环根本不执行,最后 return i === s.length 直接返回 0 === 0,即 true。逻辑自洽,不需要额外特判。
六、效率有多高
衡量算法快慢,通常看它的时间复杂度 ------也就是"最坏情况下要操作多少次"。这段代码里,i 和 j 都只会单调递增、永不回头,两个 while 循环合起来,j 最多扫过整个 t,i 最多扫过整个 s。所以总操作次数大约是 s.length + t.length 这个量级。如果两个字符串长度分别是 n 和 m,时间复杂度就是 O(n + m)。
更重要的是,它只用了两个变量 i 和 j,不管字符串有多长,占用的额外空间都恒定不变 ,空间复杂度是 O(1)。这种"时间线性、空间常数"的解法,正是双指针这类技巧的价值所在------用最朴素、最省内存的方式,高效地解决了一类按顺序匹配的问题。