不同的子序列 图解题解
这道题到底在问什么
- 输入
- s="rabbbit", t="rabbit"
- 输出
- 3
最优解:为什么这么做
一句话答案:LeetCode 115 不同的子序列:二维动态规划,dp[i][j] 记 s 前 i 个字符里等于 t 前 j 个字符的方案数。相等时「选它 + 不选它」两条路相加,不等只继承。时间 O(m×n)、空间可滚到 O(n)。
不同的子序列这道题到底在数什么
给字符串 s 和 t,要在 s 里按原先后顺序挑字符、拼出和 t 一样的串,问有多少种不同挑法。子序列(从原串挑若干字符、保持先后、可跳过)不要求连续,但顺序不能乱。题面 s="rabbbit"、t="rabbit" 答案 3,三种挑法只差在中间三个 b 挑哪两个。
把 s 的子序列全列出来为什么行不通
s 每个字符可挑可不挑,长 n 就有 2 的 n 次方个子序列,逐个和 t 比对便是天文数字。而且相同前缀的子序列被反复重拼、同一中间结果本该只算一次。把「s 前若干个字符能凑 t 前若干个字符」记下复用,指数级枚举就压成一张表逐格递推(用已算好的格子推出当前格)。
dp[i][j] 该定成什么,第 0 列为什么全填 1
这题用动态规划(把「s 前若干个字符能凑 t 前若干个字符有几种挑法」逐格存表、直接取不重算)。定义 dp[i][j](i、j 从 0 数起,i 行 j 列)为「s 前 i 个字符里能凑出 t 前 j 个字符的挑法数」。铺边界:第 0 列 dp[i][0] 全是 1——凑空的 t 什么都不挑、唯一一种;第 0 行 dp[0][j](j≥1)全是 0——s 空串凑不出非空 t。
字符相等时,为什么要把两条路相加
填 dp[i][j] 时只盯 s[i-1](s 的第 i 个字符,下标从 0 数起)和 t[j-1]。两者不相等时,s 这个字符配不上 t 要的位、只能弃用,照搬正上方 dp[i-1][j]。
相等时有两条互不重叠的路、方案数相加。一条:用它当 t 的第 j 位,前面得用 s 前 i-1 个凑出 t 前 j-1 位,是左上角 dp[i-1][j-1];另一条:不用它,仍靠 s 前 i-1 个凑完整的 t 前 j 位,是正上方 dp[i-1][j]。「用」和「不用」互不重叠、不会重复计数,所以 dp[i][j]=dp[i-1][j-1]+dp[i-1][j]。
拿 rabbbit 和 rabbit 亲手把表填出来
表是 8 行 7 列(s 长 7、t 长 6 各加边界行列)。第 0 列全 1、余下全 0。方案数靠三个 b 长起来:(前面 r 配 r、a 配 a 各只有 1 种,落到 dp[2][2]=1;dp[2][3]=0)dp[3][3]=dp[2][2]+dp[2][3]=1+0=1,dp[4][3]=dp[3][2]+dp[3][3]=1+1=2,dp[5][3]=dp[4][2]+dp[4][3]=1+2=3。收尾 dp[7][6]=dp[6][5]+dp[6][6]=3+0=3,正是用整个 rabbbit 凑出 rabbit 的总数,对上题面。
第 0 列少填一个 1,整张表为什么一路塌成 0
整张表 (m+1)×(n+1) 格,每格一次比较一次加法,时间 O(m×n);填当前行只用得上正上方一行,把二维表压成一行就地覆盖(滚动数组),空间降到 O(n)。
最易写错的是第 0 列:漏填或错成 0,相等时「用这个字符」那条路接左上角就恒为 0、整张表塌成全 0,答案错成 0。第 0 行相反,s 空凑非空 t 必须是 0,误填 1 会多出挑法。
▶ 动画逐步走查(共 87 步)——想跟着动画一帧帧对照就展开
- 3核心就这一个表:行是 s 的前缀越来越长,列是要凑的 t 的前缀,每格存「到这里为止有几种凑法」。
- 4先铺好第 0 列:不管 s 多长,要凑出「空的 t」永远只有 1 种办法——什么都不挑。第 0 行(s 空但 t 非空)则是 0 种。
- 5当前 s 的 'r' 和要凑的 t 的 'r' 相等,有两条路:① 用它来当 t 的这位 → 看左上角 dp[0][0]=1 种;② 不用它、靠前面的 s 凑 → 看上方 dp[0][1]=0 种。两条路相加。
- 6落子:dp[1][1] = 1 + 0 = 1。相等时「选用」与「不用」两种方案数相加。
- 7当前 s 的 'r' 和要凑的 t 的 'a' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[0][2]=0 种。
- 8落子:dp[1][2] = 0(不等,方案数和上一行一样,没新增凑法)。
- 9当前 s 的 'r' 和要凑的 t 的 'b' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[0][3]=0 种。
- 10落子:dp[1][3] = 0(不等,方案数和上一行一样,没新增凑法)。
- 11当前 s 的 'r' 和要凑的 t 的 'b' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[0][4]=0 种。
- 12落子:dp[1][4] = 0(不等,方案数和上一行一样,没新增凑法)。
- 13当前 s 的 'r' 和要凑的 t 的 'i' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[0][5]=0 种。
- 14落子:dp[1][5] = 0(不等,方案数和上一行一样,没新增凑法)。
- 15当前 s 的 'r' 和要凑的 t 的 't' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[0][6]=0 种。
- 16落子:dp[1][6] = 0(不等,方案数和上一行一样,没新增凑法)。
- 17当前 s 的 'a' 和要凑的 t 的 'r' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[1][1]=1 种。
- 18落子:dp[2][1] = 1(不等,方案数和上一行一样,没新增凑法)。
- 19当前 s 的 'a' 和要凑的 t 的 'a' 相等,有两条路:① 用它来当 t 的这位 → 看左上角 dp[1][1]=1 种;② 不用它、靠前面的 s 凑 → 看上方 dp[1][2]=0 种。两条路相加。
- 20落子:dp[2][2] = 1 + 0 = 1。相等时「选用」与「不用」两种方案数相加。
- 21当前 s 的 'a' 和要凑的 t 的 'b' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[1][3]=0 种。
- 22落子:dp[2][3] = 0(不等,方案数和上一行一样,没新增凑法)。
- 23当前 s 的 'a' 和要凑的 t 的 'b' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[1][4]=0 种。
- 24落子:dp[2][4] = 0(不等,方案数和上一行一样,没新增凑法)。
- 25当前 s 的 'a' 和要凑的 t 的 'i' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[1][5]=0 种。
- 26落子:dp[2][5] = 0(不等,方案数和上一行一样,没新增凑法)。
- 27当前 s 的 'a' 和要凑的 t 的 't' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[1][6]=0 种。
- 28落子:dp[2][6] = 0(不等,方案数和上一行一样,没新增凑法)。
- 29当前 s 的 'b' 和要凑的 t 的 'r' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[2][1]=1 种。
- 30落子:dp[3][1] = 1(不等,方案数和上一行一样,没新增凑法)。
- 31当前 s 的 'b' 和要凑的 t 的 'a' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[2][2]=1 种。
- 32落子:dp[3][2] = 1(不等,方案数和上一行一样,没新增凑法)。
- 33当前 s 的 'b' 和要凑的 t 的 'b' 相等,有两条路:① 用它来当 t 的这位 → 看左上角 dp[2][2]=1 种;② 不用它、靠前面的 s 凑 → 看上方 dp[2][3]=0 种。两条路相加。
- 34落子:dp[3][3] = 1 + 0 = 1。相等时「选用」与「不用」两种方案数相加。
- 35当前 s 的 'b' 和要凑的 t 的 'b' 相等,有两条路:① 用它来当 t 的这位 → 看左上角 dp[2][3]=0 种;② 不用它、靠前面的 s 凑 → 看上方 dp[2][4]=0 种。两条路相加。
- 36落子:dp[3][4] = 0 + 0 = 0。相等时「选用」与「不用」两种方案数相加。
- 37当前 s 的 'b' 和要凑的 t 的 'i' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[2][5]=0 种。
- 38落子:dp[3][5] = 0(不等,方案数和上一行一样,没新增凑法)。
- 39当前 s 的 'b' 和要凑的 t 的 't' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[2][6]=0 种。
- 40落子:dp[3][6] = 0(不等,方案数和上一行一样,没新增凑法)。
- 41当前 s 的 'b' 和要凑的 t 的 'r' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[3][1]=1 种。
- 42落子:dp[4][1] = 1(不等,方案数和上一行一样,没新增凑法)。
- 43当前 s 的 'b' 和要凑的 t 的 'a' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[3][2]=1 种。
- 44落子:dp[4][2] = 1(不等,方案数和上一行一样,没新增凑法)。
- 45当前 s 的 'b' 和要凑的 t 的 'b' 相等,有两条路:① 用它来当 t 的这位 → 看左上角 dp[3][2]=1 种;② 不用它、靠前面的 s 凑 → 看上方 dp[3][3]=1 种。两条路相加。
- 46落子:dp[4][3] = 1 + 1 = 2。相等时「选用」与「不用」两种方案数相加。
- 47当前 s 的 'b' 和要凑的 t 的 'b' 相等,有两条路:① 用它来当 t 的这位 → 看左上角 dp[3][3]=1 种;② 不用它、靠前面的 s 凑 → 看上方 dp[3][4]=0 种。两条路相加。
- 48落子:dp[4][4] = 1 + 0 = 1。相等时「选用」与「不用」两种方案数相加。
- 49当前 s 的 'b' 和要凑的 t 的 'i' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[3][5]=0 种。
- 50落子:dp[4][5] = 0(不等,方案数和上一行一样,没新增凑法)。
- 51当前 s 的 'b' 和要凑的 t 的 't' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[3][6]=0 种。
- 52落子:dp[4][6] = 0(不等,方案数和上一行一样,没新增凑法)。
- 53当前 s 的 'b' 和要凑的 t 的 'r' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[4][1]=1 种。
- 54落子:dp[5][1] = 1(不等,方案数和上一行一样,没新增凑法)。
- 55当前 s 的 'b' 和要凑的 t 的 'a' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[4][2]=1 种。
- 56落子:dp[5][2] = 1(不等,方案数和上一行一样,没新增凑法)。
- 57当前 s 的 'b' 和要凑的 t 的 'b' 相等,有两条路:① 用它来当 t 的这位 → 看左上角 dp[4][2]=1 种;② 不用它、靠前面的 s 凑 → 看上方 dp[4][3]=2 种。两条路相加。
- 58落子:dp[5][3] = 1 + 2 = 3。相等时「选用」与「不用」两种方案数相加。
- 59当前 s 的 'b' 和要凑的 t 的 'b' 相等,有两条路:① 用它来当 t 的这位 → 看左上角 dp[4][3]=2 种;② 不用它、靠前面的 s 凑 → 看上方 dp[4][4]=1 种。两条路相加。
- 60落子:dp[5][4] = 2 + 1 = 3。相等时「选用」与「不用」两种方案数相加。
- 61当前 s 的 'b' 和要凑的 t 的 'i' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[4][5]=0 种。
- 62落子:dp[5][5] = 0(不等,方案数和上一行一样,没新增凑法)。
- 63当前 s 的 'b' 和要凑的 t 的 't' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[4][6]=0 种。
- 64落子:dp[5][6] = 0(不等,方案数和上一行一样,没新增凑法)。
- 65当前 s 的 'i' 和要凑的 t 的 'r' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[5][1]=1 种。
- 66落子:dp[6][1] = 1(不等,方案数和上一行一样,没新增凑法)。
- 67当前 s 的 'i' 和要凑的 t 的 'a' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[5][2]=1 种。
- 68落子:dp[6][2] = 1(不等,方案数和上一行一样,没新增凑法)。
- 69当前 s 的 'i' 和要凑的 t 的 'b' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[5][3]=3 种。
- 70落子:dp[6][3] = 3(不等,方案数和上一行一样,没新增凑法)。
- 71当前 s 的 'i' 和要凑的 t 的 'b' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[5][4]=3 种。
- 72落子:dp[6][4] = 3(不等,方案数和上一行一样,没新增凑法)。
- 73当前 s 的 'i' 和要凑的 t 的 'i' 相等,有两条路:① 用它来当 t 的这位 → 看左上角 dp[5][4]=3 种;② 不用它、靠前面的 s 凑 → 看上方 dp[5][5]=0 种。两条路相加。
- 74落子:dp[6][5] = 3 + 0 = 3。相等时「选用」与「不用」两种方案数相加。
- 75当前 s 的 'i' 和要凑的 t 的 't' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[5][6]=0 种。
- 76落子:dp[6][6] = 0(不等,方案数和上一行一样,没新增凑法)。
- 77当前 s 的 't' 和要凑的 t 的 'r' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[6][1]=1 种。
- 78落子:dp[7][1] = 1(不等,方案数和上一行一样,没新增凑法)。
- 79当前 s 的 't' 和要凑的 t 的 'a' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[6][2]=1 种。
- 80落子:dp[7][2] = 1(不等,方案数和上一行一样,没新增凑法)。
- 81当前 s 的 't' 和要凑的 t 的 'b' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[6][3]=3 种。
- 82落子:dp[7][3] = 3(不等,方案数和上一行一样,没新增凑法)。
- 83当前 s 的 't' 和要凑的 t 的 'b' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[6][4]=3 种。
- 84落子:dp[7][4] = 3(不等,方案数和上一行一样,没新增凑法)。
- 85当前 s 的 't' 和要凑的 t 的 'i' 不相等,这个字符没法用,只能「不用它」:直接照搬上方 dp[6][5]=3 种。
- 86落子:dp[7][5] = 3(不等,方案数和上一行一样,没新增凑法)。
- 87当前 s 的 't' 和要凑的 t 的 't' 相等,有两条路:① 用它来当 t 的这位 → 看左上角 dp[6][5]=3 种;② 不用它、靠前面的 s 凑 → 看上方 dp[6][6]=0 种。两条路相加。
- 88落子:dp[7][6] = 3 + 0 = 3。相等时「选用」与「不用」两种方案数相加。
- 89右下角 dp[7][6] = 3,就是用整个 "rabbbit" 凑出 "rabbit" 的不同方案总数。
⚠️ 容易写错的地方
✗ 错:第 0 列忘了置 1
✓ 对:dp[r][0] 全部 = 1
t 为空串时「全不选」就是唯一一种方案,是递推的种子
✗ 错:相等时只走一条路
✓ 对:相等要把「选用」和「不用」两条路相加
同一个字符可选可不选,两种都是合法的不同方案
✗ 错:把它当成「是否存在子序列」
✓ 对:本题数的是「有几种」不是「有没有」
判断存在用贪心即可,计数必须用 DP 累加
完整代码(Python / C++ / Java)
Python
def numDistinct(s, t):
m, n = len(s), len(t)
dp = [[0]*(n+1) for _ in range(m+1)]
for r in range(m+1):
dp[r][0] = 1
for r in range(1, m+1):
for c in range(1, n+1):
if s[r-1] == t[c-1]:
dp[r][c] = dp[r-1][c-1] + dp[r-1][c]
else:
dp[r][c] = dp[r-1][c]
return dp[m][n]C++
int numDistinct(string s, string t){
int m = s.size(), n = t.size();
vector<vector<unsigned long long>> dp(m+1, vector<unsigned long long>(n+1, 0));
for(int r = 0; r <= m; r++) dp[r][0] = 1;
for(int r = 1; r <= m; r++)
for(int c = 1; c <= n; c++)
if(s[r-1] == t[c-1]) dp[r][c] = dp[r-1][c-1] + dp[r-1][c];
else dp[r][c] = dp[r-1][c];
return (int)dp[m][n];
}Java
int numDistinct(String s, String t){
int m = s.length(), n = t.length();
int[][] dp = new int[m + 1][n + 1];
for (int r = 0; r <= m; r++) dp[r][0] = 1;
for (int r = 1; r <= m; r++)
for (int c = 1; c <= n; c++)
if (s.charAt(r-1) == t.charAt(c-1)) dp[r][c] = dp[r-1][c-1] + dp[r-1][c];
else dp[r][c] = dp[r-1][c];
return dp[m][n];复杂度
时间
O(m·n)
每格 O(1),共 (m+1)×(n+1) 格
空间
O(m·n)
整表;每格只依赖上一行,可滚动到 O(n)
看不够?换成动画再走一遍
上面的推演每一步都对应一帧动画。点开交互动画版,能一步步看着 不同的子序列 的数据怎么变、指针怎么走,还能切 Python / Java / C++ 跟着练。
面试官可能追问
字符相等时,转移为什么是 dp[i-1][j-1]+dp[i-1][j],而不是加上左边的 dp[i][j-1]?+
两条路的共同点是都「往回退一步、少用 s 的当前这个字符」,所以都落在上一行 i-1 上。用它当 t 第 j 位时,t 还得往回退一位,是左上角 dp[i-1][j-1];不用它时,t 仍要凑满 j 位,是正上方 dp[i-1][j]。左边的 dp[i][j-1] 停在同一行、意味着 s 的前 i 个字符没减少,和「这一步到底用不用第 i 个字符」的分类对不上,接进来会重复计数。
这道题和最长公共子序列(LeetCode 1143)都是两串上的二维 dp,差在哪?+
骨架像,填的东西不同。最长公共子序列求的是最长长度,格子里存长度,相等时 dp[i-1][j-1]+1、不等时取左和上的较大者 max(dp[i-1][j], dp[i][j-1]),是求最优。本题求的是方案数,格子里存挑法总数,相等时把左上和正上两条路的方案数相加、不等时只继承正上方,是做计数。一个取 max、一个做加法,这是计数型 dp 和最优型 dp 的分界。
空间为什么能压到 O(n),压的时候要注意什么?+
dp[i][j] 只用到上一行的 dp[i-1][j] 和 dp[i-1][j-1],更早的行再用不上,于是只留一行、逐行覆盖,空间从 O(m×n) 降到 O(n)。但压成一维后内层的列必须从右往左(从大到小)遍历:转移要用上一行的左邻 dp[j-1],若从左往右扫,dp[j-1] 已被本行的新值覆盖,就读错了;从右往左时 dp[j-1] 还是上一行的旧值,正好对应左上角。
想听吴师兄把这道题讲给你听?
文字版和动画都随便看。开通图解算法年卡,可以听吴师兄把 不同的子序列 一步步讲透(全站已上线 905 份讲透,持续扩充), 卡住的地方还有 AI 私教小欧就着动画帮你拆到懂。
把这道题真正拿下
本平台为独立第三方培训机构,与华为技术有限公司无任何关联;课程的服务内容与权益以购买协议为准,学习效果因个人情况而异。「华为 OD」「华为可信」等仅为对岗位与考试方向的客观描述,相关商标归各自权利人所有。