追加字符以获得子序列 图解题解
这道题到底在问什么
- 输入
- s = "coaching", t = "coding"
- 输出
- 4
- 输入
- s = "abcde", t = "a"
- 输出
- 0
- 输入
- s = "z", t = "abcde"
- 输出
- 5
最优解:为什么这么做
一句话答案:LeetCode 2486 追加字符以获得子序列:一个指针 j 顺着 t 走、遍历 s 能对上就让 j 前进,扫完 s 后追加数就是 t 没对上的尾巴 |t| 减 j,时间 O(|s|)、空间 O(1)。
追加最少字符让 t 成为 s 的子序列,求几个
给两个只含小写字母的字符串 s 和 t,允许往 s 的末尾接上任意字符,问最少接几个,能让 t 成为 s 的子序列。子序列 = 保持相对顺序、从原串里删掉若干字符剩下的串,中间可以跳、不必挨着。题面例子:s=coaching、t=coding 答案 4;s=abcde、t=a 答案 0;s=z、t=abcde 答案 5。
为什么算两串长度差就交卷会错
容易把答案当成两个串的长度差,或者以为要在 s 里找出 t 这一整段连续子串。两条都不对:追加数只跟 t 里「在 s 中对不上」的字符有关,s 再长、缺 t 要的字符也白搭;而子序列允许中间跳字符,不要求连续。真去枚举 t 塞进 s 的每种嵌法硬试,方案多到指数级、根本扫不完。
顺着 t 走一遍、能对上就前进
真正省力的办法是盯着 t 的进度:拿一个指针 j 记「t 已顺序对上到第几位」,从头遍历 s。每看 s 的一个字符,只有它正好等于当前要找的 t[j] 时,才说明 t 这一位在 s 里落实了,j 挪一格去找下一位;对不上就跳过这个 s 字符,j 原地不动。这是子序列匹配的标准双指针,并贪心让每个 t 字符尽早对上——选最靠前的合法位置,不挡后面字符的机会,匹配到的位数只多不少。
扫完 s 后 j 停在哪,尾巴为什么就是要追加的
s 扫到头时,j 停的数值就是 t 从开头起能被顺序对上的最长前缀长度。前面这 j 位已稳稳嵌在 s 里,剩下 t 从第 j 位往后那段尾巴,在现有 s 里再找不到落点。可 t 的相对顺序不能改,这段尾巴只能原样接到 s 末尾,正好 |t| 减 j 个。所以扫完直接返回 len(t) 减 j。这也说清两条边界:t 本就是 s 的子序列时 j 走到底、答案 0;一个字符都对不上时 j 停在 0、答案就是整个 |t|。
顺着 coaching 和 coding 走一遍
记 n=len(t)=6,匹配指针 j 从 0 出发,逐个扫 s=coaching。s[0]=c 等于 t[0]=c,j 进到 1;s[1]=o 等于 t[1]=o,j 进到 2;s[2]=a 要找 t[2]=d,不等、跳过,j 停 2;后面 c、h、i、n、g 一路都不是 d,j 始终卡在 2。s 扫完时 j 落在 2,返回 6 减 2 得 4,正是往末尾追加 ding 这四个字符。
另两组:s=z、t=abcde,z 不等于 t[0]=a,j 一直是 0,返回 5 减 0 得 5,整个 t 都得追加;反过来 s=abcde、t=a,第一个 a 就对上 t[0],j 变 1,返回 1 减 1 得 0,本来就是子序列,一个都不用加。
一趟线性扫,这几处最容易写岔
时间 O(|s|):s 扫一遍、每个字符一次常数比较,j 最多前进 |t| 次;空间 O(1),只用 j 和一个长度变量。几处手滑得防:匹配不上时若也让 j 前进,等于跳过没对上的字符、答案算小;相等时忘了继续挪 s 的下标,遇到 t 里连续相同字符会拿同一个 s 字符重复顶两位;还有别把答案写成 len(s) 减 len(t),跟 s 的长度无关。
▶ 动画逐步走查(共 22 步)——想跟着动画一帧帧对照就展开
- 3记牢这一句:一个指针 j 顺着 t 走,在 s 里从左到右能对上就前进,对不上就跳过;s 扫完 j 停在哪,答案就是 t 的长度减去 j。下面每一帧都在套这句话。
- 4先把两个串摆好。上面这一排是 s,coaching,一共 8 个字符,我们要在它里面从左到右顺序匹配。右边这一列是目标串 t,coding,共 6 个字符,谁被匹配到就打勾。现在还一个都没开始,匹配进度 j 是 0,高亮的是 t 的第一个字符 c,那是我们最先要在 s 里找的。
- 5指针就位。i 指向 s 的第 0 个字符,也就是 c;j 指向 t 的第 0 个字符,也是 c。接下来的规则很简单,拿 s[i] 和 t[j] 比:相等就说明 t 的这个字符在 s 里对上了,j 前进;不相等就只把 s 往后挪,j 原地不动。
- 6扫到 s 的第 0个字符 c。现在 j 是 0,要找的 t[0] 是 c。把这两个放一起比一比:c 和 c 一样吗?
- 7一样!c 正好是要找的 t 字符,命中。把 s 的这一格标绿,j 前进到 1,下一个要在 s 里找的目标变成 t[1] 等于 o。已经顺次对上 1 个了。
- 8扫到 s 的第 1个字符 o。现在 j 是 1,要找的 t[1] 是 o。把这两个放一起比一比:o 和 o 一样吗?
- 9一样!o 正好是要找的 t 字符,命中。把 s 的这一格标绿,j 前进到 2,下一个要在 s 里找的目标变成 t[2] 等于 d。已经顺次对上 2 个了。
- 10扫到 s 的第 2个字符 a。现在 j 是 2,要找的 t[2] 是 d。把这两个放一起比一比:a 和 d 一样吗?
- 11不一样。a 不是当前要找的 d,这个字符对匹配没用,直接跳过。注意 j 一点不动,还是 2,我们只是把 s 往后挪一格继续看。要找的目标依旧是 t[2] 等于 d。
- 12扫到 s 的第 3个字符 c。现在 j 是 2,要找的 t[2] 是 d。把这两个放一起比一比:c 和 d 一样吗?
- 13不一样。c 不是当前要找的 d,这个字符对匹配没用,直接跳过。注意 j 一点不动,还是 2,我们只是把 s 往后挪一格继续看。要找的目标依旧是 t[2] 等于 d。
- 14扫到 s 的第 4个字符 h。现在 j 是 2,要找的 t[2] 是 d。把这两个放一起比一比:h 和 d 一样吗?
- 15不一样。h 不是当前要找的 d,这个字符对匹配没用,直接跳过。注意 j 一点不动,还是 2,我们只是把 s 往后挪一格继续看。要找的目标依旧是 t[2] 等于 d。
- 16扫到 s 的第 5个字符 i。现在 j 是 2,要找的 t[2] 是 d。把这两个放一起比一比:i 和 d 一样吗?
- 17不一样。i 不是当前要找的 d,这个字符对匹配没用,直接跳过。注意 j 一点不动,还是 2,我们只是把 s 往后挪一格继续看。要找的目标依旧是 t[2] 等于 d。
- 18扫到 s 的第 6个字符 n。现在 j 是 2,要找的 t[2] 是 d。把这两个放一起比一比:n 和 d 一样吗?
- 19不一样。n 不是当前要找的 d,这个字符对匹配没用,直接跳过。注意 j 一点不动,还是 2,我们只是把 s 往后挪一格继续看。要找的目标依旧是 t[2] 等于 d。
- 20扫到 s 的第 7个字符 g。现在 j 是 2,要找的 t[2] 是 d。把这两个放一起比一比:g 和 d 一样吗?
- 21不一样。g 不是当前要找的 d,这个字符对匹配没用,直接跳过。注意 j 一点不动,还是 2,我们只是把 s 往后挪一格继续看。要找的目标依旧是 t[2] 等于 d。
- 22s 的 8 个字符全部扫完了。回头看 j,它停在 2。这说明 t 的前 2 个字符,也就是 co,已经能在 s 里按顺序找到(看那两个绿格)。可是 j 没能走到底,t 后面还剩字符没匹配上。
- 23看看 t 里没打勾的部分:ding,一共 4 个字符。它们在 s 里已经没有机会再顺序补齐了,因为 s 已经扫到头。想让 t 成为子序列,只能把这 ding 原样接到 s 的末尾。
- 24答案出来了。t 的长度是 6,已经顺序匹配上 2 个,还差 6 减 2 等于 4 个。这 4 就是要往 s 末尾追加的最少字符数,追加后 s 变成 coachingding,t 就成了它的子序列。
⚠️ 容易写错的地方
✗ 错:把答案当成 s 和 t 的长度差
✓ 对:答案 = |t| 减去已顺序匹配的 j
要追加的只是 t 里没能在 s 中对上的尾巴,和 s 的长度无关,s 再长匹配不上也没用
✗ 错:匹配不上时也让 j 前进
✓ 对:只有 s[i] 等于 t[j] 才让 j 前进
j 前进代表 t 的这个字符被对上了,不相等还前进会跳过没匹配的字符,答案偏小
✗ 错:相等时忘了同时挪 s 的下标
✓ 对:s 的下标每轮都要往后走
t 里可能有连续相同字符,同一个 s 字符不能重复匹配 t 的两个位置
✗ 错:误以为要在 s 里找 t 作为连续子串
✓ 对:找的是子序列,允许中间跳过 s 的字符
子序列只要求相对顺序一致,中间的字符可以任意跳过,不必挨着
完整代码(Python / C++ / Java)
Python
from __future__ import annotations
from typing import *
from collections import *
from functools import *
from itertools import *
from math import *
from heapq import *
from bisect import *
class Solution:
def appendCharacters(self, s: str, t: str) -> int:
n, j = len(t), 0
for c in s:
if j < n and c == t[j]:
j += 1
return n - jC++
#include <algorithm>
#include <array>
#include <cctype>
#include <climits>
#include <cmath>
#include <deque>
#include <functional>
#include <iostream>
#include <limits>
#include <map>
#include <numeric>
#include <queue>
#include <set>
#include <sstream>
#include <stack>
#include <string>
#include <tuple>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
using namespace std;
class Solution {
public:
int appendCharacters(string s, string t) {
int n = t.length(), j = 0;
for (int i = 0; i < s.size() && j < n; ++i) {
if (s[i] == t[j]) {
++j;
}
}
return n - j;
}
};Java
import java.util.*;
class Solution {
public int appendCharacters(String s, String t) {
int n = t.length(), j = 0;
for (int i = 0; i < s.length() && j < n; ++i) {
if (s.charAt(i) == t.charAt(j)) {
++j;
}
}
return n - j;
}
}复杂度
时间
O(|s|)
只把 s 从头到尾扫一遍,每个字符做一次常数比较;j 最多前进 |t| 次,不会让复杂度升级。整体随 s 的长度线性增长,记 O(|s|);t 没有再扫一遍,只取了它的长度算差值
空间
O(1)
只用了指针 j 和长度 n 这几个变量,不额外开数组或哈希,空间是常数
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 追加字符以获得子序列 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
这题和「判断 t 是不是 s 的子序列」是一回事吗?+
底子是同一套双指针。判断子序列是问 j 能不能一路走到 t 的末尾,走到底就是、没走到就不是;这题更进一步,允许在 s 末尾追加字符,所以就算 j 走不到底也不算失败,把没对上的那段 |t| 减 j 个字符追加上去即可。认出这个母题,一整类题都能套同一个双指针骨架。
贪心让每个字符尽早匹配,凭什么一定最优?+
让 t[j] 在 s 里选最靠前能对上的位置,不会挡住后面字符的匹配机会,反而给后面留下最多可用的 s。可以用交换论证:任取一种合法匹配方案,把它每个匹配位置都替换成更靠前的合法位置,匹配到的位数不会变少。所以尽早匹配得到的 j 是所有方案里最大的,追加数 |t| 减 j 也就最小。
为什么答案跟 s 有多长没关系,只看 t?+
追加的字符全部来自 t 里没能在 s 中对上的那段尾巴,和 s 本身多长无关。s 就算有一百万个字符,只要里面缺 t 要的某个字母,j 到那儿就卡住,后面再多的 s 也补不了这一位。所以代码里 s 只被扫来推进 j,最终答案是 len(t) 减 j,len(s) 从头到尾都没进过这个式子。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 追加字符以获得子序列 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。