LeetCode 97. 交错字符串:动态规划详解

在LeetCode中等难度题目中,「交错字符串」是一道经典的动态规划应用题。它的核心是判断一个字符串是否能由另外两个字符串"交错"组成,看似简单却容易陷入思维误区,今天我们就来一步步拆解这道题,从题目理解到代码实现,把每一个细节讲透。

一、题目核心理解

先明确题目要求:给定三个字符串s1、s2、s3,验证s3是否是由s1和s2交错组成的。这里的「交错」有严格定义,我们用通俗的话翻译一下:

  • 将s1分割成若干个非空子串(比如s1 = a + b + c),s2分割成若干个非空子串(比如s2 = x + y);

  • 分割后两个字符串的子串数量相差不超过1(|n - m| ≤ 1);

  • 把这两组子串交替拼接,要么是「s1子串在前、s2子串在后」(a+x + b+y + c),要么是「s2子串在前、s1子串在后」(x+a + y+b),最终拼接结果等于s3。

举个例子:s1 = "aabcc",s2 = "dbbca",s3 = "aadbbcbcac",就是一个合法的交错组合------s1分割为"aa"+"bc"+"c",s2分割为"dbbc"+"a",交替拼接后得到"aa"+"dbbc"+"bc"+"a"+"c",正好等于s3。

而如果s3的长度不等于s1+s2的长度,那直接可以判定为false,这是最基础的边界条件。

二、解题思路:为什么用动态规划?

拿到这道题,首先会想到暴力解法------枚举s1和s2的所有分割方式,再判断拼接后是否等于s3。但这种方法的时间复杂度极高,因为分割方式的数量是指数级的,对于稍长的字符串完全不适用。

这时候就需要动态规划(DP)来优化。动态规划的核心是「状态定义+状态转移」,我们可以通过定义一个DP数组,记录"用s1的前i个字符和s2的前j个字符,能否组成s3的前i+j个字符",这样就能把大问题拆解成小问题,逐步递推求解。

三、动态规划细节拆解

1. 状态定义

定义dpij:表示s1的前i个字符(s10..i-1)和s2的前j个字符(s20..j-1),能否交错组成s3的前i+j个字符(s30..i+j-1)。

这里要注意下标细节:i和j从0开始,当i=0时,意味着不使用s1的任何字符,只使用s2的前j个字符;当j=0时,意味着不使用s2的任何字符,只使用s1的前i个字符。

2. 初始化

初始化的核心是处理"只使用s1"或"只使用s2"的情况:

  • dp00 = true:s1的前0个字符(空字符串)和s2的前0个字符(空字符串),能组成s3的前0个字符(空字符串),这是基础条件。

  • 当i>0、j=0时:dpi0 = dpi-10 && s1i-1 === s3i-1。也就是说,只有前i-1个字符能组成s3的前i-1个字符,且s1的第i个字符(s1i-1)等于s3的第i个字符(s3i-1),才能满足条件。

  • 当j>0、i=0时:类似上面,dp0j = dp0j-1 && s2j-1 === s3j-1

3. 状态转移方程

对于任意i>0、j>0的情况,dpij的取值有两种可能,只要满足其中一种,就为true:

  1. 最后一个字符来自s1:此时需要满足「s1的前i-1个字符和s2的前j个字符能组成s3的前i+j-1个字符」(即dpi-1j为true),并且s1的第i个字符(s1i-1)等于s3的第i+j个字符(s3i+j-1)。

  2. 最后一个字符来自s2:此时需要满足「s1的前i个字符和s2的前j-1个字符能组成s3的前i+j-1个字符」(即dpij-1为true),并且s2的第j个字符(s2j-1)等于s3的第i+j个字符(s3i+j-1)。

因此,状态转移方程可以写成:

dpij = (dpi-1j && s1i-1 === s3i+j-1) || (dpij-1 && s2j-1 === s3i+j-1)

4. 最终结果

dps1.lengths2.length 就是我们要的答案------表示s1的全部字符和s2的全部字符,能否交错组成s3的全部字符。

四、完整代码及逐行解读

下面是完整的TypeScript代码,结合上面的思路,逐行解读每一步的作用:

typescript 复制代码
function isInterleave(s1: string, s2: string, s3: string): boolean {
  // 1. 边界条件:s3长度不等于s1+s2长度,直接返回false
  const l1: number = s1.length;
  const l2: number = s2.length;
  const l3: number = s3.length;
  if (l1 + l2 != l3) {
    return false;
  }

  // 2. 初始化DP数组:dp[i][j]表示s1前i个、s2前j个能否组成s3前i+j个
  const dp: boolean[][] = Array.from({ length: l1 + 1 }, () => new Array(l2 + 1).fill(false))
  dp[0][0] = true; // 空字符组成空字符,基础条件

  // 3. 填充DP数组:双重循环遍历所有i和j的组合
  for (let i = 0; i <= l1; i++) {
    for (let j = 0; j <= l2; j++) {
      const p = i + j - 1; // s3当前对应的下标(前i+j个字符的最后一个下标)
      // 情况1:最后一个字符来自s1(i>0才有可能)
      if (i > 0) {
        dp[i][j] = (dp[i - 1][j] && s1[i - 1] === s3[p]) || dp[i][j];
      }
      // 情况2:最后一个字符来自s2(j>0才有可能)
      if (j > 0) {
        dp[i][j] = (dp[i][j - 1] && s2[j - 1] === s3[p]) || dp[i][j];
      }
    }
  }

  // 4. 返回最终结果:s1全部和s2全部能否组成s3全部
  return dp[l1][l2];
};

五、关键注意点&优化方向

1. 下标细节(最容易踩坑)

一定要注意:s1的第i个字符对应的下标是i-1,s2的第j个字符对应的下标是j-1,s3的前i+j个字符的最后一个下标是i+j-1(即变量p)。很多人会在这里混淆下标,导致代码出错。

2. 空间优化

上面的代码使用了二维DP数组,空间复杂度是O(l1*l2)。但观察状态转移方程可以发现,dpij只依赖于dpi-1j(上一行)和dpij-1(同一行前一列),因此可以优化为一维DP数组,将空间复杂度降低到O(min(l1, l2))。

优化思路:用一个一维数组dpj,每次遍历i时,更新dpj的值,具体可以自行尝试(提示:遍历i时,dpj的更新需要注意顺序,避免覆盖未使用的值)。

3. 特殊测试用例

提交代码前,建议测试以下几个特殊用例,避免边界漏洞:

  • s1、s2均为空:s3也为空 → 返回true;s3非空 → 返回false。

  • 其中一个字符串为空:比如s1为空,判断s2是否等于s3;反之亦然。

  • 字符重复场景:比如s1="aabcc",s2="dbbca",s3="aadbbbaccc" → 返回false(最后一个c的来源不匹配)。

六、总结

「交错字符串」的核心是用动态规划将"分割拼接"的复杂问题,转化为"逐步判断字符匹配"的子问题。关键在于正确定义DP状态,理清状态转移的两种情况,同时注意下标细节。

这道题的DP思路具有通用性,类似"两个字符串拼接成第三个字符串"的问题,都可以尝试用类似的状态定义来解决。掌握了这道题,也能加深对动态规划"递推思想"的理解。

相关推荐
欧雷殿18 小时前
从「吸引子引导工程」看我的「一人公司」实践
前端·人工智能·后端
wordbaby18 小时前
React Native + RNOH:一个 `lazyScreen()` 搞定 48 页面启动懒加载
前端·react native
竹林81818 小时前
用 wagmi v2 踩坑两天,我终于搞懂了多链钱包切换
前端·javascript
z落落18 小时前
C#参数区别
java·算法·c#
吃乔巴的糖18 小时前
Vue 3 打印模板设计器 (print-canvas-designer)
前端·vue.js
名字都不重要何况昵称19 小时前
canvas 分层渲染思路和脏矩形处理
前端·canvas
布列瑟农的星空19 小时前
前端是否需要架构
前端
子云zy19 小时前
JS 对象与包装类:new 做了什么?字符串为什么有 length?
前端·javascript
c2385619 小时前
vector(下)
数据结构·算法
z落落19 小时前
C# 冒泡排序+选择排序 + Array.Sort 自定义排序
数据结构·算法