LeetCode LCR 096. 交错字符串
题目描述
题意分析
给定三个字符串
s1、s2、s3,判断s3是否由s1和s2交错组成。所谓交错,是指把s1和s2各自切成若干段,再交替拼接起来。关键约束是:s1内部的字符必须保持原有的先后顺序,s2内部同理,两者之间的相对位置则可以任意穿插。换个更好操作的说法:想象两个指针分别指向
s1和s2的开头,每一步从其中一个指针处取走一个字符追加到结果末尾。若存在某种取法能拼出s3,答案就是 true。这个描述把「切段」这种模糊的表述转化成了「逐字符决策」,也就把问题变成了可以逐步推进的形式。约束信号有几条。第一,三个串长度都在 100 以内,$O(mn)$ 甚至 $O(mn)$ 带常数都完全够用,不需要贪心式的精巧构造。第二,
s1和s2可以为空串,空串与任意串交错就是那个串本身。第三,字符可以重复,比如s1和s2开头都是a而s3也以a开头,这时两条路都得走,无法靠「谁匹配就选谁」的贪心决定——这一点直接排除了双指针贪心。边界方面,最容易被忽略的是长度关系:任何一次取字符都会让两个指针的总推进量加一,所以只有
s1.length + s2.length == s3.length时才可能成立。这不是优化,而是必须先做的正确性前提,否则后面所有下标运算都会错位甚至越界。
解法:一维前缀动态规划
核心思路
先看暴力。按上面的「逐字符决策」描述,写一个递归
dfs(i, j):已经用掉s1的前i个和s2的前j个字符,问剩下的能否拼出s3的剩余部分。每一层有两种选择——取s1[i]或取s2[j],于是搜索树是一棵二叉树,深度为m + n,最坏 $O(2^{m+n})$。瓶颈在于大量重复:走「先取 s1 再取 s2」和「先取 s2 再取 s1」,最终落到的状态都是(i+1, j+1),后续问题完全一样却被重算了两遍。这正是记忆化/动态规划的入口:状态只由
(i, j)决定,而(i, j)只有 $(m+1)(n+1)$ 种。更重要的一个观察是——s3中已经被消耗掉的长度必然是i + j,不需要第三维记录s3的进度。这个「三维压二维」的观察是本题的核心,说不出它就等于没理解为什么是二维 DP。于是定义状态:
dp[i][j]表示s1的前i个字符与s2的前j个字符,能否交错组成s3的前i + j个字符。答案是dp[m][n]。转移看
s3[i + j - 1](即当前要填的最后一个字符)是从哪个串取来的,只有两种可能:第一支,来自
s1。含义是:最后一步取走了s1的第i个字符,那么在此之前必须已经用s1的前i - 1个和s2的前j个拼出了s3的前i + j - 1个,并且这个字符要对得上。写成dp[i][j] |= dp[i - 1][j] && s1[i - 1] == s3[i + j - 1]。第二支,来自
s2。对称地,最后一步取走s2的第j个字符,要求dp[i][j] |= dp[i][j - 1] && s2[j - 1] == s3[i + j - 1]。两支是「或」的关系,因为只要有一种取法能成就算成立。边界
dp[0][0] = true,表示两个空前缀能拼出空串。最后做空间压缩。
dp[i][j]只依赖同一行的dp[i][j - 1]和上一行的dp[i - 1][j],所以可以只留一行滚动。按j递增更新时,dp[j]在被赋值前还保存着上一行的值,正好是「来自s1」那一支需要的dp[i - 1][j];而dp[j - 1]已经在本轮被更新过,是「来自s2」那一支需要的dp[i][j - 1]。同一个数组里的新旧值恰好对应两个转移来源,这也是这份代码最需要讲清楚的地方。
解题步骤
- 先判断
s1.length() + s2.length() != s3.length(),不等直接返回 false。原因是每取一个字符两侧总进度加一,长度对不上时根本不存在合法的取法,而且后续s3.charAt(i + j - 1)会越界。- 开一个长度
n + 1的布尔数组代表dp的一行,令dp[0] = true。这是dp[0][0],即两个空前缀能拼出空串。- 初始化第一行:对
j从 1 到n,令dp[j] = dp[j - 1] && s2[j - 1] == s3[j - 1]。这一行代表完全不使用s1,所以s3的前j位必须逐字符等于s2的前j位,一旦断了后面就全是 false。- 外层枚举
i从 1 到m,代表把s1的前i个字符纳入考虑,也就是从上一行推进到当前行。- 每行开头先更新
dp[0] = dp[0] && s1[i - 1] == s3[i - 1]。它对应dp[i][0],即完全不使用s2的情形,只能由dp[i - 1][0]这一支转移而来。这一步必须放在内层循环之前,因为dp[0]是本行dp[1]的依赖。- 内层
j从 1 递增到n,取target = s3[i + j - 1],分别算出fromS1 = dp[j] && s1[i - 1] == target与fromS2 = dp[j - 1] && s2[j - 1] == target,再写回dp[j] = fromS1 || fromS2。递增方向是必须的:dp[j]此刻仍是上一行的旧值,读完立刻覆盖;dp[j - 1]此刻已是本行的新值。- 循环结束后返回
dp[n],它就是dp[m][n]。以
s1 = "ab", s2 = "a", s3 = "aab"走一遍。长度校验:2 + 1 == 3,通过。数组长度为 2,下面用[dp[0], dp[1]]记录每一行结束时的状态。初始化第一行(
i = 0,不用s1):dp[0] = true;j = 1时dp[1] = dp[0] && s2[0]='a' == s3[0]='a',成立,得到 true。此行为[T, T],含义是「只用s2的前 0 或前 1 位能拼出s3的前 0 或前 1 位」。
i = 1(纳入s1的'a'):先更新dp[0] = dp[0](T) && s1[0]='a' == s3[0]='a'→ true。再看j = 1,target = s3[1] = 'a';fromS1 = dp[1](旧值 T,即dp[0][1])&& s1[0]='a' == 'a'→ true;fromS2 = dp[0](新值 T,即dp[1][0])&& s2[0]='a' == 'a'→ true;写回dp[1] = true。此行为[T, T]。
i = 2(纳入s1的'b'):先更新dp[0] = dp[0](T) && s1[1]='b' == s3[1]='a'→ false,因为只用"ab"拼不出"aa"。再看j = 1,target = s3[2] = 'b';fromS1 = dp[1](旧值 T,即dp[1][1])&& s1[1]='b' == 'b'→ true;fromS2 = dp[0](新值 F)→ false;写回dp[1] = true。此行为[F, T]。返回
dp[1] = true。核对一下:取s2的'a'、s1的'a'、s1的'b',正好拼出"aab",答案正确。
代码实现
class Solution {
public boolean isInterleave(String s1, String s2, String s3) {
int m = s1.length();
int n = s2.length();
if (m + n != s3.length()) {
return false;
}
boolean[] dp = new boolean[n + 1];
dp[0] = true;
for (int j = 1; j <= n; j++) {
dp[j] = dp[j - 1] && s2.charAt(j - 1) == s3.charAt(j - 1);
}
for (int i = 1; i <= m; i++) {
dp[0] = dp[0] && s1.charAt(i - 1) == s3.charAt(i - 1);
for (int j = 1; j <= n; j++) {
char target = s3.charAt(i + j - 1);
// 当前字符可以来自 s1,也可以来自 s2,任一来源成立即可。
boolean fromS1 = dp[j] && s1.charAt(i - 1) == target;
boolean fromS2 = dp[j - 1] && s2.charAt(j - 1) == target;
dp[j] = fromS1 || fromS2;
}
}
return dp[n];
}
}
func isInterleave(s1 string, s2 string, s3 string) bool {
m := len(s1)
n := len(s2)
if m+n != len(s3) {
return false
}
dp := make([]bool, n+1)
dp[0] = true
for j := 1; j <= n; j++ {
dp[j] = dp[j-1] && s2[j-1] == s3[j-1]
}
for i := 1; i <= m; i++ {
dp[0] = dp[0] && s1[i-1] == s3[i-1]
for j := 1; j <= n; j++ {
target := s3[i+j-1]
// dp[j] 表示来自 s1,dp[j-1] 表示来自 s2。
fromS1 := dp[j] && s1[i-1] == target
fromS2 := dp[j-1] && s2[j-1] == target
dp[j] = fromS1 || fromS2
}
}
return dp[n]
}
复杂度分析
- 时间复杂度:$O(mn)$。状态数是 $(m+1)(n+1)$ 个
(i, j)组合,每个状态只做两次字符比较和一次布尔运算,是常数代价,因此总量与状态数同阶。- 空间复杂度:$O(n)$。二维表的每一行只依赖上一行和本行左侧,压缩成一维滚动数组后只需
n + 1个布尔值,与m无关;若不压缩则是 $O(mn)$。
关键点总结
- 双序列问题的第一反应就是「两个前缀长度作为状态」。本题的额外亮点是发现第三个串的进度可由
i + j推出,从而把看似三维的问题降到二维——遇到多序列同步推进时都该先找这类可推导的冗余维度。- 转移的分支应该按「最后一步是什么」来枚举,而不是按「下一步怎么走」。倒着想能让每一支直接落到一个更小的已知状态上,转移式自然成立。
- 一维压缩不是无脑改写,遍历方向由依赖方向决定。本题依赖上一行同列和本行左侧,所以必须递增;若依赖本行右侧则必须递减。写之前先问一句「我要读的是新值还是旧值」。
- 长度和不等这类必要条件应当在算法开始前作为前置校验,而不是指望 DP 自己算出 false。它同时承担正确性与防越界两个职责。
- 面试视角:这题的标准考法是先让你写出递归加记忆化,再追问「能不能优化空间」。能把
dp[j]是旧值、dp[j - 1]是新值这件事当场说清楚,是区分「背过代码」和「真懂 DP」的分水岭,建议主动讲。- 面试视角:如果面试官问「能不能贪心,谁匹配就取谁」,要能立刻给出反例——
s1 = "aa"、s2 = "ab"、s3 = "aaba"时前两位都可以从任一串取'a',优先取s1会在第三位卡死,而正确答案是 true。举得出反例比说「贪心不对」有力得多。
易错点总结
- 错误写法:不先判断
s1.length() + s2.length() == s3.length()就进入 DP。用例s1 = "ab", s2 = "a", s3 = "aa"→ 循环中访问s3.charAt(i + j - 1),当i = 2, j = 1时下标为 2 而s3只有 2 个字符,直接抛出越界异常;即使侥幸不越界,结果也可能误判为 true。这是本题最高频的错误。- 错误写法:把
s3的下标写成s3.charAt(j - 1)或s3.charAt(i - 1),忘了两个前缀共同贡献长度。用例s1 = "ab", s2 = "a", s3 = "aab"→ 内层比较的一直是s3的前几位,i = 2, j = 1时该比s3[2]='b'却比了s3[0]='a',返回 false。除去第一行和第一列的特例,正文下标恒为i + j - 1。- 错误写法:一维压缩时内层
j从大到小遍历。用例s1 = "a", s2 = "bc", s3 = "abc"→ 先算j = 2时读到的dp[1]还是上一行的 false,而本行的dp[1]应该是 true,「来自s2」那一支的前驱状态错行,返回 false,正确答案是 true。依赖本行左侧就必须递增。- 错误写法:忘记在每行开头更新
dp[0],让它一直保持初始的 true。用例s1 = "x", s2 = "y", s3 = "yy"→dp[0]恒为 true 等于无条件承认「只用s1的前i位就能拼出s3的前i位」,于是dp[1][1]经由s2[0]='y' == s3[1]='y'被置为 true,返回 true,而正确答案是 false。- 错误写法:把
dp[0]的更新放在内层循环之后。用例任意m, n >= 1→ 本行的dp[1]读dp[0]时拿到的还是上一行的值,「来自s2」这一支的前驱状态错行,结果不可预测。- 错误写法:两支转移用「与」连接,写成
dp[j] = fromS1 && fromS2。用例s1 = "a", s2 = "b", s3 = "ab"→ 要求当前字符同时能由两个串提供,几乎总是 false。交错是「存在一种取法」,天然是或的语义。- 错误写法:只比较字符相等而不检查前驱状态,写成
dp[j] = s1[i-1] == target || s2[j-1] == target。用例s1 = "ab", s2 = "c", s3 = "bac"→ 末位'c'与s2[0]相同就把dp[2][1]置成 true 返回 true,但"bac"里'b'排在'a'前面,违反了s1的内部顺序,正确答案是 false。丢掉「之前也得拼得出来」这个前驱约束,DP 就退化成了字符集比较。- 错误写法:第一行初始化时逐位独立判断,写成
dp[j] = s2[j-1] == s3[j-1],漏掉dp[j - 1] &&。用例s1 = "", s2 = "ab", s3 = "cb"→dp[2]因为末位'b'相同被置为 true,但前缀早已断裂,正确答案是 false。第一行必须是前缀式的连乘。- 错误写法:用双指针贪心,哪个串的当前字符与
s3相同就取哪个。用例s1 = "aa", s2 = "ab", s3 = "aaba"→ 前两位都可以从任一串取'a',若优先取s1则s1提前耗尽,走到s3[2] = 'b'时只剩s2[0] = 'a',返回 false,而正确答案是 true。有分歧就必须搜索或 DP。- 错误写法:认为
s1、s2为空是特殊情况需要单独if处理。用例s1 = "", s2 = "abc", s3 = "abc"→ 单独处理容易与主逻辑不一致而写错。实际上m = 0时外层循环不执行,答案直接由第一行初始化给出,主逻辑本身已经覆盖,多写反而增加出错面。
相似题目
| 题目 | 难度 | 考察点 |
|---|---|---|
| 72. 编辑距离 | 中等 | 同为两串前缀状态,但转移是增删改三支取最小值而非布尔可达性 |
| 583. 两个字符串的删除操作 | 中等 | 只允许删除,答案等价于总长减去两倍最长公共子序列 |
| 712. 两个字符串的最小ASCII删除和 | 中等 | 删除的代价按字符 ASCII 加权,不能再按删除次数计数 |
| 1035. 不相交的线 | 中等 | 几何连线问题伪装的最长公共子序列,难点是识别出模型 |
| 1143. 最长公共子序列 | 中等 | 求最优值而非判定,字符不等时取两侧最大值而不是直接置 false |
| LCR 095. 最长公共子序列 | 中等 | 与 1143 同题,可用来练习一维压缩时暂存左上角值的技巧 |