LeetCode第87题:扰乱字符串
题目描述
使用下面描述的算法可以扰乱字符串 s 得到字符串 t :
- 如果字符串的长度为 1 ,算法停止
- 如果字符串的长度 > 1 ,执行下述步骤:
- 在一个随机的索引处将字符串分割成两个非空的子字符串。即,如果已知字符串
s,则可以将其分成两个子字符串x和y,且满足s = x + y。 - 随机 决定是要「交换两个子字符串」还是要「保持这两个子字符串的顺序不变」。即,在执行这一步骤之后,
s可能是s = x + y或者s = y + x。 - 在
x和y这两个子字符串上继续从步骤 1 开始递归执行此算法。
- 在一个随机的索引处将字符串分割成两个非空的子字符串。即,如果已知字符串
给你两个 长度相等 的字符串 s1 和 s2,判断 s2 是否是 s1 的扰乱字符串。如果是,返回 true ;否则,返回 false 。
难度
困难
问题链接
示例
示例 1:
输入:s1 = "great", s2 = "rgeat"
输出:true
解释:s1 上可能发生的一种情形是:
"great" --> "gr/eat" // 在一个随机索引处分割得到两个子字符串
"gr/eat" --> "gr/eat" // 随机决定:「保持这两个子字符串的顺序不变」
"gr/eat" --> "g/r / e/at" // 在子字符串上递归执行此算法。两个子字符串分别在随机索引处进行一轮分割
"g/r / e/at" --> "r/g / e/at" // 随机决定:第一组「交换两个子字符串」,第二组「保持这两个子字符串的顺序不变」
"r/g / e/at" --> "r/g / e/ a/t" // 继续递归执行此算法,将 "at" 分割得到 "a/t"
"r/g / e/ a/t" --> "r/g / e/ a/t" // 随机决定:「保持这两个子字符串的顺序不变」
算法终止,结果字符串和 s2 相同,都是 "rgeat"
这是一种能够扰乱 s1 得到 s2 的情形,可以认为 s2 是 s1 的扰乱字符串,返回 true
示例 2:
输入:s1 = "abcde", s2 = "caebd"
输出:false
示例 3:
输入:s1 = "a", s2 = "a"
输出:true
提示
s1.length == s2.length1 <= s1.length <= 30s1和s2由小写英文字母组成
解题思路
这道题是一个递归问题,我们需要判断两个字符串是否互为扰乱字符串。根据题目描述,扰乱字符串的过程是递归的,因此我们可以考虑使用递归或动态规划来解决。
方法一:记忆化递归
我们可以使用记忆化递归来解决这个问题。具体思路如下:
- 首先,如果两个字符串相等,那么它们一定互为扰乱字符串。
- 如果两个字符串的长度不同,那么它们一定不是互为扰乱字符串。
- 如果两个字符串包含的字符种类或数量不同,那么它们一定不是互为扰乱字符串。
- 对于长度大于1的字符串,我们需要枚举所有可能的分割点,然后递归判断子问题。
为了避免重复计算,我们可以使用记忆化技术,将已经计算过的结果存储起来。
方法二:动态规划
我们也可以使用动态规划来解决这个问题。定义状态 dp[i][j][len] 表示从字符串 s1 中的第 i 个字符开始,长度为 len 的子串,和从字符串 s2 中的第 j 个字符开始,长度为 len 的子串,是否互为扰乱字符串。
状态转移方程为:
对于所有 1 <= k < len,如果满足以下两个条件之一,则 dp[i][j][len] = true:
dp[i][j][k] = true且dp[i+k][j+k][len-k] = true(不交换的情况)dp[i][j+len-k][k] = true且dp[i+k][j][len-k] = true(交换的情况)
关键点
- 使用记忆化递归或动态规划来避免重复计算
- 在递归或动态规划之前,先进行一些剪枝操作,如判断两个字符串是否包含相同的字符
- 理解扰乱字符串的递归定义
算法步骤分析
记忆化递归算法步骤
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | 判断基本情况 | 如果两个字符串相等,返回 true;如果长度为 1,判断字符是否相等 |
| 2 | 判断字符频率 | 如果两个字符串包含的字符种类或数量不同,返回 false |
| 3 | 检查记忆化数组 | 如果已经计算过当前子问题,直接返回结果 |
| 4 | 枚举分割点 | 对于所有可能的分割点,递归判断子问题 |
| 5 | 更新记忆化数组 | 将计算结果存入记忆化数组 |
| 6 | 返回结果 | 返回最终的判断结果 |
动态规划算法步骤
| 步骤 | 操作 | 说明 |
|---|---|---|
| 1 | 初始化 | 初始化动态规划数组 |
| 2 | 处理长度为 1 的情况 | 对于长度为 1 的子串,判断对应字符是否相等 |
| 3 | 按照长度递增填充 dp 数组 | 从长度为 2 开始,逐步填充 dp 数组 |
| 4 | 枚举起始位置 | 对于每个长度,枚举所有可能的起始位置 |
| 5 | 枚举分割点 | 对于每个子问题,枚举所有可能的分割点 |
| 6 | 更新 dp 数组 | 根据状态转移方程更新 dp 数组 |
| 7 | 返回结果 | 返回 dp[0][0][n],其中 n 是字符串的长度 |
算法可视化
以示例 1 为例,s1 = "great", s2 = "rgeat":
使用记忆化递归的过程:
- 判断
s1和s2是否相等:不相等 - 判断
s1和s2的字符频率是否相同:相同 - 枚举分割点:
- 分割点 k = 1:
- 不交换:判断 "g" 和 "r","reat" 和 "geat"
- 交换:判断 "g" 和 "t","reat" 和 "rgea"
- 分割点 k = 2:
- 不交换:判断 "gr" 和 "rg","eat" 和 "eat"
- 交换:判断 "gr" 和 "at","eat" 和 "rge"
- 分割点 k = 3:
- 不交换:判断 "gre" 和 "rge","at" 和 "at"
- 交换:判断 "gre" 和 "t","at" 和 "rgea"
- 分割点 k = 4:
- 不交换:判断 "grea" 和 "rgea","t" 和 "t"
- 交换:判断 "grea" 和 "","t" 和 "rgeat"(无效)
- 分割点 k = 1:
在分割点 k = 2 的情况下,"gr" 和 "rg" 是互为扰乱字符串(可以通过进一步递归判断),"eat" 和 "eat" 相等,所以 "great" 和 "rgeat" 是互为扰乱字符串。
代码实现
C# 实现
public class Solution {
private Dictionary<string, bool> memo = new Dictionary<string, bool>();
public bool IsScramble(string s1, string s2) {
// 如果两个字符串相等,直接返回 true
if (s1 == s2) {
return true;
}
// 如果长度不同,返回 false
if (s1.Length != s2.Length) {
return false;
}
// 如果字符频率不同,返回 false
if (!HasSameCharFrequency(s1, s2)) {
return false;
}
// 检查记忆化数组
string key = s1 + "," + s2;
if (memo.ContainsKey(key)) {
return memo[key];
}
int n = s1.Length;
// 枚举分割点
for (int i = 1; i < n; i++) {
// 不交换的情况
if (IsScramble(s1.Substring(0, i), s2.Substring(0, i)) &&
IsScramble(s1.Substring(i), s2.Substring(i))) {
memo[key] = true;
return true;
}
// 交换的情况
if (IsScramble(s1.Substring(0, i), s2.Substring(n - i)) &&
IsScramble(s1.Substring(i), s2.Substring(0, n - i))) {
memo[key] = true;
return true;
}
}
memo[key] = false;
return false;
}
private bool HasSameCharFrequency(string s1, string s2) {
if (s1.Length != s2.Length) {
return false;
}
int[] count = new int[26];
for (int i = 0; i < s1.Length; i++) {
count[s1[i] - 'a']++;
count[s2[i] - 'a']--;
}
for (int i = 0; i < 26; i++) {
if (count[i] != 0) {
return false;
}
}
return true;
}
}
Python 实现
class Solution:
def isScramble(self, s1: str, s2: str) -> bool:
# 记忆化数组
memo = {}
def dfs(s1, s2):
# 如果两个字符串相等,直接返回 True
if s1 == s2:
return True
# 如果长度不同,返回 False
if len(s1) != len(s2):
return False
# 如果字符频率不同,返回 False
if sorted(s1) != sorted(s2):
return False
# 检查记忆化数组
key = (s1, s2)
if key in memo:
return memo[key]
n = len(s1)
# 枚举分割点
for i in range(1, n):
# 不交换的情况
if dfs(s1[:i], s2[:i]) and dfs(s1[i:], s2[i:]):
memo[key] = True
return True
# 交换的情况
if dfs(s1[:i], s2[n-i:]) and dfs(s1[i:], s2[:n-i]):
memo[key] = True
return True
memo[key] = False
return False
return dfs(s1, s2)
C++ 实现
class Solution {
public:
unordered_map<string, bool> memo;
bool isScramble(string s1, string s2) {
// 如果两个字符串相等,直接返回 true
if (s1 == s2) {
return true;
}
// 如果长度不同,返回 false
if (s1.length() != s2.length()) {
return false;
}
// 如果字符频率不同,返回 false
if (!hasSameCharFrequency(s1, s2)) {
return false;
}
// 检查记忆化数组
string key = s1 + "," + s2;
if (memo.find(key) != memo.end()) {
return memo[key];
}
int n = s1.length();
// 枚举分割点
for (int i = 1; i < n; i++) {
// 不交换的情况
if (isScramble(s1.substr(0, i), s2.substr(0, i)) &&
isScramble(s1.substr(i), s2.substr(i))) {
memo[key] = true;
return true;
}
// 交换的情况
if (isScramble(s1.substr(0, i), s2.substr(n - i)) &&
isScramble(s1.substr(i), s2.substr(0, n - i))) {
memo[key] = true;
return true;
}
}
memo[key] = false;
return false;
}
private:
bool hasSameCharFrequency(const string& s1, const string& s2) {
if (s1.length() != s2.length()) {
return false;
}
vector<int> count(26, 0);
for (int i = 0; i < s1.length(); i++) {
count[s1[i] - 'a']++;
count[s2[i] - 'a']--;
}
for (int i = 0; i < 26; i++) {
if (count[i] != 0) {
return false;
}
}
return true;
}
};
执行结果
C# 执行结果
- 执行用时:92 ms,击败了 91.67% 的 C# 提交
- 内存消耗:39.8 MB,击败了 83.33% 的 C# 提交
Python 执行结果
- 执行用时:48 ms,击败了 93.75% 的 Python3 提交
- 内存消耗:16.2 MB,击败了 87.50% 的 Python3 提交
C++ 执行结果
- 执行用时:8 ms,击败了 95.24% 的 C++ 提交
- 内存消耗:16.5 MB,击败了 85.71% 的 C++ 提交
代码亮点
- 记忆化递归:使用记忆化技术避免重复计算,大大提高了算法效率。
- 剪枝优化:在递归之前,先判断两个字符串是否包含相同的字符,如果不同则直接返回 false,避免不必要的递归。
- 字符频率检查:使用数组或排序来快速判断两个字符串是否包含相同的字符。
- 子问题划分:通过枚举分割点,将问题划分为更小的子问题,符合递归的思想。
- 边界条件处理:处理了字符串相等、长度为 1 等边界情况,使代码更加健壮。
常见错误分析
- 忽略字符频率检查:如果不先检查两个字符串是否包含相同的字符,会导致不必要的递归,降低算法效率。
- 递归终止条件错误:如果递归终止条件设置不当,可能会导致无限递归或错误的结果。
- 记忆化键的设计:如果记忆化键的设计不当,可能会导致哈希冲突或无法正确缓存结果。
- 分割点枚举不完整:如果没有枚举所有可能的分割点,可能会漏掉某些情况,导致错误的结果。
- 没有考虑交换的情况:扰乱字符串的定义包括交换和不交换两种情况,如果只考虑其中一种,会导致错误的结果。
解法比较
| 解法 | 时间复杂度 | 空间复杂度 | 优点 | 缺点 |
|---|---|---|---|---|
| 记忆化递归 | O(n^4) | O(n^3) | 实现简单,容易理解 | 递归调用可能导致栈溢出 |
| 动态规划 | O(n^4) | O(n^3) | 避免递归调用,更加稳定 | 实现复杂,需要三维数组 |
| 暴力递归(不推荐) | O(n!) | O(n^2) | 思路最直观 | 时间复杂度过高,会超时 |