LeetCode第87题:扰乱字符串

60 阅读9分钟

LeetCode第87题:扰乱字符串

题目描述

使用下面描述的算法可以扰乱字符串 s 得到字符串 t

  1. 如果字符串的长度为 1 ,算法停止
  2. 如果字符串的长度 > 1 ,执行下述步骤:
    • 在一个随机的索引处将字符串分割成两个非空的子字符串。即,如果已知字符串 s ,则可以将其分成两个子字符串 xy ,且满足 s = x + y
    • 随机 决定是要「交换两个子字符串」还是要「保持这两个子字符串的顺序不变」。即,在执行这一步骤之后,s 可能是 s = x + y 或者 s = y + x
    • xy 这两个子字符串上继续从步骤 1 开始递归执行此算法。

给你两个 长度相等 的字符串 s1s2,判断 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.length
  • 1 <= s1.length <= 30
  • s1s2 由小写英文字母组成

解题思路

这道题是一个递归问题,我们需要判断两个字符串是否互为扰乱字符串。根据题目描述,扰乱字符串的过程是递归的,因此我们可以考虑使用递归或动态规划来解决。

方法一:记忆化递归

我们可以使用记忆化递归来解决这个问题。具体思路如下:

  1. 首先,如果两个字符串相等,那么它们一定互为扰乱字符串。
  2. 如果两个字符串的长度不同,那么它们一定不是互为扰乱字符串。
  3. 如果两个字符串包含的字符种类或数量不同,那么它们一定不是互为扰乱字符串。
  4. 对于长度大于1的字符串,我们需要枚举所有可能的分割点,然后递归判断子问题。

为了避免重复计算,我们可以使用记忆化技术,将已经计算过的结果存储起来。

方法二:动态规划

我们也可以使用动态规划来解决这个问题。定义状态 dp[i][j][len] 表示从字符串 s1 中的第 i 个字符开始,长度为 len 的子串,和从字符串 s2 中的第 j 个字符开始,长度为 len 的子串,是否互为扰乱字符串。

状态转移方程为:

对于所有 1 <= k < len,如果满足以下两个条件之一,则 dp[i][j][len] = true

  1. dp[i][j][k] = truedp[i+k][j+k][len-k] = true(不交换的情况)
  2. dp[i][j+len-k][k] = truedp[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"

使用记忆化递归的过程:

  1. 判断 s1s2 是否相等:不相等
  2. 判断 s1s2 的字符频率是否相同:相同
  3. 枚举分割点:
    • 分割点 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 = 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++ 提交

代码亮点

  1. 记忆化递归:使用记忆化技术避免重复计算,大大提高了算法效率。
  2. 剪枝优化:在递归之前,先判断两个字符串是否包含相同的字符,如果不同则直接返回 false,避免不必要的递归。
  3. 字符频率检查:使用数组或排序来快速判断两个字符串是否包含相同的字符。
  4. 子问题划分:通过枚举分割点,将问题划分为更小的子问题,符合递归的思想。
  5. 边界条件处理:处理了字符串相等、长度为 1 等边界情况,使代码更加健壮。

常见错误分析

  1. 忽略字符频率检查:如果不先检查两个字符串是否包含相同的字符,会导致不必要的递归,降低算法效率。
  2. 递归终止条件错误:如果递归终止条件设置不当,可能会导致无限递归或错误的结果。
  3. 记忆化键的设计:如果记忆化键的设计不当,可能会导致哈希冲突或无法正确缓存结果。
  4. 分割点枚举不完整:如果没有枚举所有可能的分割点,可能会漏掉某些情况,导致错误的结果。
  5. 没有考虑交换的情况:扰乱字符串的定义包括交换和不交换两种情况,如果只考虑其中一种,会导致错误的结果。

解法比较

解法时间复杂度空间复杂度优点缺点
记忆化递归O(n^4)O(n^3)实现简单,容易理解递归调用可能导致栈溢出
动态规划O(n^4)O(n^3)避免递归调用,更加稳定实现复杂,需要三维数组
暴力递归(不推荐)O(n!)O(n^2)思路最直观时间复杂度过高,会超时

相关题目