动态规划的“递归之舞”:破解字符串的深层结构——扰乱字符串
哈喽,各位在算法的深渊中,享受着逻辑与结构之美的“思想家”们,我是前端小L。
我们的DP征程,已经穿越了线性的“一维走廊”和矩阵的“二维棋盘”。今天,我们将进入一个全新的、由递归定义出的“分形”世界。在这里,问题会不断地分裂、重组,形成一个看似无穷无尽的可能性迷宫。
这,就是“扰乱字符串”。它不是一个简单的序列匹配,而是对两个字符串结构同源性的终极拷问。这道题,将是我们从二维DP思维,迈向更高维度的结构化DP的毕业典礼。
力扣 87. 扰乱字符串
https://leetcode.cn/problems/scramble-string/
题目分析:递归的“DNA” 题目的核心,在于那个“扰乱”操作的定义:
-
将一个字符串
s
分割成两个非空子串s1
和s2
。 -
有两种选择:
-
保持原样
s1 + s2
。 -
交换顺序
s2 + s1
。
-
-
对
s1
和s2
递归地进行这个过程。
这个定义,本身就充满了强烈的递归暗示。要判断 s1
是否是 s2
的一个扰乱字符串,我们就要看 s1
能否通过某种分割和交换,使其左右两部分,分别与 s2
相应的两部分,构成“扰乱”关系。
一个重要的“剪枝”前提: 如果 s1
和 s2
互为扰乱,那么它们必然拥有完全相同的字符集和字符数量。换句话说,它们必须互为字母异位词 (Anagram)。这是一个强有力的必要条件,我们可以在递归的每一步,都先检查这个条件,如果连字符都对不上,那后续的结构分析就毫无意义了。
从递归到DP:三维状态的诞生
面对这种递归结构,最直观的解法就是写一个递归函数 isScramble(str1, str2)
。但我们很快会发现,这种朴素递归会因为大量的重复子问题计算而超时。这正是DP登场的信号!
我们需要把递归的参数,转化为DP的状态维度。 isScramble
需要比较两个子串。如何唯一地确定一个子串?需要起始位置和长度。 所以,要比较 s1
的子串和 s2
的子串,我们需要三个信息:s1
的起始位置 i
,s2
的起始位置 j
,以及两个子串共同的长度 len
。
1. DP状态定义 (三维DP的核心): dp[i][j][len]
表示:从 s1
的索引 i
开始、长度为 len
的子串,是否是 s2
从索引 j
开始、长度为 len
的子串的一个扰乱。这是一个布尔值。
2. 状态转移的“分裂与重组”: 这是本题最精妙、也最核心的部分。为了计算 dp[i][j][len]
,我们需要模拟那个“分割”动作。我们可以尝试所有可能的分割点 k
(1 <= k < len
),将长度为 len
的子串,分割成长度为 k
和 len-k
的两部分。
对于每一种分割 k
,我们都有两种“重组”的可能(对应原题的“交换”或“不交换”):
-
可能性A:不交换
-
s1
的左半部分s1[i...i+k-1]
与s2
的左半部分s2[j...j+k-1]
互为扰乱。 -
并且
-
s1
的右半部分s1[i+k...i+len-1]
与s2
的右半部分s2[j+k...j+len-1]
互为扰乱。 -
翻译成DP:
dp[i][j][k] && dp[i+k][j+k][len-k]
-
-
可能性B:交换
-
s1
的左半部分s1[i...i+k-1]
与s2
的右半部分s2[j+len-k...j+len-1]
互为扰乱。 -
并且
-
s1
的右半部分s1[i+k...i+len-1]
与s2
的左半部分s2[j...j+k-1]
互为扰乱。 -
翻译成DP:
dp[i][j+len-k][k] && dp[i+k][j][len-k]
-
只要在所有可能的分割点 k
中,存在任意一种可能性(A或B)为 true
,那么 dp[i][j][len]
就为 true
。
3. Base Cases & 迭代顺序
-
Base Case:
len = 1
。dp[i][j][1]
为true
,当且仅当s1[i] == s2[j]
。 -
迭代顺序: 状态
len
的计算,依赖于比它更小的长度。所以,我们的最外层循环,必须是len
从2
到n
。
代码实现 (三维DP)
class Solution {
public:bool isScramble(string s1, string s2) {int n = s1.length();if (n != s2.length()) return false;// dp[len][i][j]vector<vector<vector<bool>>> dp(n + 1, vector<vector<bool>>(n, vector<bool>(n, false)));// Base Case: len = 1for (int i = 0; i < n; ++i) {for (int j = 0; j < n; ++j) {dp[1][i][j] = (s1[i] == s2[j]);}}// 迭代:len 从 2 到 nfor (int len = 2; len <= n; ++len) {for (int i = 0; i <= n - len; ++i) {for (int j = 0; j <= n - len; ++j) {// 检查 anagram 剪枝 (可选,但能优化)// ...// 枚举分割点 kfor (int k = 1; k < len; ++k) {// 可能性 A: 不交换bool caseA = dp[k][i][j] && dp[len - k][i + k][j + k];// 可能性 B: 交换bool caseB = dp[k][i][j + len - k] && dp[len - k][i + k][j];if (caseA || caseB) {dp[len][i][j] = true;break; // 只要找到一种可能,就可以确定为true}}}}}return dp[n][0][0];}
};
(注:也可以用记忆化搜索(自顶向下DP)来实现,逻辑是完全一样的,有时代码会更直观)
总结:DP与递归的共生关系
今天这道题,是动态规划与递归关系的一次最深刻的诠释。它告诉我们:
任何可以用“分治+最优子结构”思想解决的递归问题,都可以被转化为一个动态规划问题。
递归的参数,定义了DP状态的维度。
递归的分解,定义了DP状态的转移方程。
递归的终点,定义了DP的Base Cases。
我们从线性DP的“一维行军”,到二维DP的“矩阵博弈”,再到今天三维DP的“递归之舞”,我们对动态规划的理解,已经从解决特定模式的问题,上升到了能够为任意具有最优子结构的递归问题,自主建模的高度。
咱们下期见~