题目描述

✅ 97. 交错字符串

:::fold 历史考题

考察公司:美团
考察时间:2025.5.28

:::

image-20260928201801803

image-20260928201801804

题意分析

判断是否能把 s1、s2 的全部字符交错合并成 s3,要求每个来源字符串内部的字符先后顺序保持不变,不能删除、重复使用或重新排序字符。

可以连续从同一个来源取多个字符,不要求每取一个字符就切换来源;这些连续字符自然组成同一段。因此总长度必须满足 s1.length + s2.length == s3.length。只需判断是否存在一种合并方式,来源当前字符相同时不能随意固定选择一边。

解法:一维前缀动态规划

核心思路

[!blue]

令 D[i][j] 表示 s1 的前 i 个字符和 s2 的前 j 个字符,能否交错组成 s3 的前 i + j 个字符。两个来源已经使用的长度确定后,目标的进度也随之确定,所以不需要再增加一个目标下标维度。

对非空前缀,目标最后一个字符位于 s3[i + j - 1],它只有两种来源。如果来自 s1,必须有 D[i - 1][j] 成立,并且 s1[i - 1] 等于目标字符;如果来自 s2,必须有 D[i][j - 1] 成立,并且 s2[j - 1] 等于目标字符。任一来源成立即可,两者用逻辑或连接。检查前驱状态,保证此前的字符也已经按合法顺序拼好。

边界 D[0][0] = true 表示两个空来源能组成空目标。首行只允许使用 s2,首列只允许使用 s1,因此沿对应来源逐字检查前缀是否完全相等;一旦前缀失配,后面的首行或首列状态也都不再可达。

转移只依赖上一行同列和本行左侧,可以压缩为一维 dp。每行先更新 dp[0],再从左向右更新其他位置:旧 dp[j] 仍是 D[i - 1][j],对应从 s1 追加;已经更新的 dp[j - 1] 是 D[i][j - 1],对应从 s2 追加。先用两个来源条件计算结果,再覆盖当前状态。

当字符同时可以来自两边时,动态规划保留两个前驱的可达性,不必提前做不可撤回的选择。处理完全部前缀后,dp[n] 才表示两个来源都已用完、完整目标可以组成。

解题步骤

  1. 记两条来源串长度为 m、n;若总长度不等于目标长度,直接返回 false。
  2. 创建长度为 n + 1 的布尔数组,令 dp[0] = true,依次初始化只使用 s2 的各个前缀状态。
  3. 外层枚举 s1 前缀长度 i,先更新只使用 s1 的 dp[0]。
  4. 内层让 j 从 1 到 n,取目标字符 s3[i + j - 1],分别判断来自 s1 和 s2 的前驱是否可行,再用逻辑或写回 dp[j]。
  5. 返回 dp[n];任意来源为空时,首行或首列的初始化会自然覆盖这类情况。

代码实现

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);
                // 当前位置保留上一行旧值,左侧已更新为本行,各自对应一个来源。
                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]
            // 当前位置保留上一行旧值,左侧已更新为本行,各自对应一个来源。
            fromS1 := dp[j] && s1[i-1] == target
            fromS2 := dp[j-1] && s2[j-1] == target
            dp[j] = fromS1 || fromS2
        }
    }
    return dp[n]
}

复杂度分析

  • 时间复杂度:$O((m + 1)(n + 1))$,每对前缀长度只计算一次;包含首行、首列和空串边界,两串非空时通常写作 $O(mn)$。
  • 空间复杂度:$O(n + 1)$,保存 s2 方向的一行状态及空前缀边界。

关键点总结

[!green]

  • 两个来源的已用前缀长度,既约束各自顺序,也唯一确定目标进度。
  • 最后一个字符可以来自任意一边,必须同时检查对应前驱和当前字符。
  • 一维数组正序更新,才能分别读取上一行同列与本行左侧的状态。
  • 初始化解决空来源,完整状态 dp[n] 才能确保所有字符都被使用。

易错点总结

[!yellow]

  • 省略长度校验,可能漏掉未使用的字符,也可能在访问目标位置时越界。
  • 将目标下标写成单个来源的进度;两个前缀一共使用 i + j 个字符,末尾下标应为 i + j - 1。
  • 一维数组倒序更新,左侧值仍来自上一行,不再对应从当前 s2 前缀追加字符的转移。
  • 每行不更新 dp[0],会沿用旧的仅使用 s1 的前缀匹配结果。
  • 只比较当前字符,不检查此前前缀是否可达,无法保证完整顺序正确。
  • 两个来源当前字符都匹配时固定优先一边,可能让后面的字符无法衔接;应保留两种可达来源。

相似题目

题目 难度 关联与区别
1143. 最长公共子序列 中等 同样在两个字符串的前缀坐标上转移,本题还要求选取顺序拼成第三个串且用完全部字符。
115. 不同的子序列 困难 同样按前缀消费字符,原题从一个串中选出目标并计数,本题从两个来源交错构成目标。
转载与许可
作者
链接 https://hgnulb.github.io/blog/2026/35539364
许可 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!