题目:难度Hard(请自行点击链接查看)
https://leetcode-cn.com/problems/scramble-string/
V2版本的执行时间已经能达到较为理想的结果了。后面只是为了追求更高执行效率。
一、分析:
由题意:这个“乱序”字符串,是由原字符串任意二叉树分割(每一支至少要有一个字符)成每个叶子只有一个字符,再选择任意多个子节点交换左右子树得到。
这操作很明显属于“分治”,适用递归算法。首先想想暴力递归如何做?直接上代码,如下:
//V1 暴力dfs版本
class Solution {
public:
bool isScramble(string s1, string s2) {
int len = s1.length(); //两个字符串长度相等
if (len <= 1 )return s1 == s2; //仅有一个字符,自然是字符相等则有效
for (int i = 1; i < len; i++) { //注意每一子树至少有一个字符
if (isScramble(s1.substr(0, i), s2.substr(0, i)) //s1前i对s2前i
&& isScramble(s1.substr(i, len), s2.substr(i, len)) //且 s1后len-i对s2后len-i
//下面是该节点两个子树交换的效果,由前对前后对后,变为前后后前。
|| isScramble(s1.substr(0, i), s2.substr(len - i, len)) //或 s1前i对s2后i
&& isScramble(s1.substr(i, len), s2.substr(0, len - i)))//且 s1后len-i对s2前len-i
return true; //当任一分割方案有效则有效,否则继续尝试别的分割。
}
return false; //所有分割都尝试无果
}
};
注意每一种分割方法都有不交换和交换两个子树的两种选择,都要尝试。
笔者做此题时并没有尝试提交此解法,因为此解法字符串长度为N时,需要尝试4(N-1)种分支,时间复杂度是指数阶乘级别的!如果没有想出优解提交暴力算法,能确定题意理解正确也好。
二、树遍历剪枝:(AC解)
如果要在递归的基础上改进时间复杂度,常用的方法无法是剪枝和记忆化递归。
那么先来看看记忆化递归有没有戏。因为我是先往此方向思考的。想想要将字符串分割为两部分,无非就是确定子串起点和终点,那就只要两个坐标轴描述递归离散点!就在准备开写时,突然想起如果父节点子树交换一下,
就会出现前对后、后对前的情况。所以s1和s2的起点和终点可以不一致!必须要用三维空间存储递归结果!
那么这个O(N^3)解法先放一边,后备吧!
那就想想如何剪枝,那就是提前排除一些不可能成立的字符串分割方法。
观察一些样例发现,如果分割后s1,和s2相对应的子串,其字符种类和数量没有完全对应,必然不可能通过打乱得到。
只要在执行函数前,检查相对的两个子串其字符种类和数量是否完全一致,不一致则跳过,即为剪枝。
代码如下:
//V2 dfs剪枝版本 AC!
class Solution {
public:
bool isScramble(string s1, string s2) {
static int A[128]; //用于统计两个字符串的字符组成是否完全相同
int len = s1.length(); //两个字符串长度相等
if (len <= 1)return s1 == s2; //仅有一个字符,自然是字符相等则有效
{ //剪枝判定!
fill(A, A + 128, 0); //初始化数组为全0
for (auto c : s1)A[c]++; //s1累加
for (auto c : s2)A[c]--; //s2扣减
for (int i = 0; i < 128; i++)
if (0 != A[i])return false; //不相同则剪枝
}
for (int i = 1; i < len; i++) { //注意每一子树至少有一个字符
if (isScramble(s1.substr(0, i), s2.substr(0, i)) //s1前i对s2前i
&& isScramble(s1.substr(i, len), s2.substr(i, len)) //且 s1后len-i对s2后len-i
//下面是该节点两个子树交换的效果,由前对前后对后,变为前后后前。
|| isScramble(s1.substr(0, i), s2.substr(len - i, len)) //或 s1前i对s2后i
&& isScramble(s1.substr(i, len), s2.substr(0, len - i)))//且 s1后len-i对s2前len-i
return true; //当任一分割方案有效则有效,否则继续尝试别的分割。
}
return false; //所有分割都尝试无果
}
};
此版本可以成功AC!而且优于80%的执行时间哦!
三、dfs剪枝+记忆化递归+哈希(追求极限)
在二中曾经考虑过记忆化递归的方法,那要不要试试呢?O(N^3)的时空复杂度不是开玩笑的!那这个真的无用武之地了吗?
当然不是,观察二版本的代码,发现剪枝操作需要遍历两个字符串和数组A,虽然比起dfs的时间复杂度这个影响不大。现在就是要追求极限的时候啦!
①判定两个等长字符串的字符种类和个数是否相同(记为 A(s1)==A(s2) ),那就是说与其排列顺序无关。回想我们小学学过的加法交换律,如果两个字符串各个字符的ASCII码加起来结果不相同(记为sum(s1)!=sum(s2)),则必有A(s1)!=A(s2)。
但要注意的是,sum(s1)==sum(s2)并不能推出A(s1)==A(s2),如 sum("ace")==sum("bcd")。之所以这么容易出现sum(s1)==sum(s2)而A(s1)!=A(s2)的情况,是因为字符的ASCII码太紧凑,码距太小!
想想哈希函数吧,把字符的ASCII码映射到“伪随机数”,用数组存起来,如 H[ch]=key,ch为ASCII码,也不要用累加了,用无符号整型存储key,用key做累乘,乘法也有交换律。这样可以大大减少sum(s1)==sum(s2)(后面sum改为hash了)而A(s1)!=A(s2)的情况。
②如果在dfs剪枝里去做字符→key再累乘,那还不如直接统计来的快呢!现在捡起“记忆化递归”,可以预先将s1、s2所有的子串哈希累乘的结果存起来。初始总输入s1,s2是不变的,界定子串只需要起点和终点指针,又因为起点<终点,只需要上三角或下三角,那么s1、s2一个上三角另一个下三角不就成了吗?
根据此指导思想,代码如下:
//V3 dfs剪枝+哈希记忆化存储 AC!
/*
成功
显示详情
执行用时 : 16 ms, 在Scramble String的C++提交中击败了81.09% 的用户
内存消耗 : 9.2 MB, 在Scramble String的C++提交中击败了94.74% 的用户
*/
//由V2.0细节优化得V2.1
typedef unsigned int UINT;
typedef vector<UINT> VINT;
typedef vector<VINT> VVINT;
class Solution {
private:
enum {p1 = 10007,p2 = 1000000007};
static UINT hash[128];
static bool has_init;
void init_hash() {
if(!has_init){
for (UINT c = 0; c < 128; c++){
hash[c]= (p2*c) ^ p1;
}
has_init = true;
}
}
//dp[i][j] = hash(s1+i,s1+j) ,when i<j
//dp[i][j] = hash(s2+j,s2+i) ,when i>j
//dp[i][i] = 0
VVINT dp;
//=isScramble( (s1+b1)[len] ,(s2+b2)[len] )
bool dfs(int b1, int b2, UINT len) {
//if (0 == len)return true;
if (1 == len) {
return dp[b1][b1 + 1] == dp[b2 + 1][b2];
}
for (int m = 1; m < len; m++) {
if (dp[b1][b1 + m] == dp[b2 + m][b2] && dp[b1 + m][b1 + len] == dp[b2 + len][b2 + m])
if (dfs(b1, b2, m) && dfs(b1 + m, b2 + m, len - m))return true;
if (dp[b1][b1 + m] == dp[b2 + len][b2 + len - m] && dp[b1 + m][b1 + len] == dp[b2 + len - m][b2])
if (dfs(b1, b2 + len - m, m) && dfs(b1 + m, b2, len - m))return true;
}
return false;
}
/* //若用此版本的dfs替换,会超时!
bool dfs(int b1, int b2, UINT len) {
//if (0 == len)return true;
if (dp[b1][b1 + len] != dp[b2 + len][b2])return false;
if (1 == len) return true;
for (int m = 1; m < len; m++) {
if ( dfs(b1 , b2 , m)
&& dfs(b1 + m , b2 + m , len - m)
|| dfs(b1 , b2 + len - m , m)
&& dfs(b1 + m , b2 , len - m))
return true;
}
return false;
}
*/
public:
bool isScramble(string s1, string s2) {
int len = s1.length();
//Construct dp[][]
init_hash();
dp.clear();
dp.resize(len+1, VINT(len + 1, 0));
for (int i = 0; i < len; i++) {
UINT key = 1;
for (int j = i; j < len; ) {
key *= hash[s1[j]];
dp[i][++j] = key;
}
}
for (int i = 0; i < len; i++) {
int key = 1;
for (int j = i; j < len; ) {
key *= hash[s2[j]];
dp[++j][i] = key;
}
}
//dfs
return dfs(0, 0, len);
}
};
bool Solution::has_init=false;
UINT Solution::hash[128];
此代码被注释的dfs()将剪枝放在dfs开头,结果超时!因为s1,s2分为四个子串两两对应(s1a对s2a和s1b对s2b),实际上仅当要两对同时有:A[s1a]==A[s2a]且A[s1b]==A[s2b]该才可能有效。所以将两部分判断提前到调用dfs前可以更高效率地剪枝。但其实版本V2就是进入dfs()后才剪枝的,一样AC。说明了什么?说明了此哈希查 A[s1]!=A[s2]有漏!某些 A[s1]!=A[s2]其哈希乘积相等。
四、dfs剪枝+记忆化递归+哈希+质因数分解(笔者的极限)
针对V3版本存在的 A[s1]!=A[s2]漏判,结合离散数学之质因数分解。因为是用字符串hash值相乘判定组成是否相同,如果hash映射的是不同的质数(素数),且不考虑相乘越界的问题,那么不同的质数组成,其乘积必然不同!因为每一个正整数都可以唯一地表达为有限个质数的若干次幂的乘积,就像指纹一样独一无二。(若不了解质因数分解,请查阅相关资料)
这里PS一下,LeetCode测试样例中的字符串都是小写字母构成的。
所以应将字母哈希为不同的质数,为了方便用了较小的质数(但注意不要用2,因为每乘一个2,哈希值的二进制低位就会多一个0,当出现32个该字符,整个哈希值都成0了!)。
还有一个结论,当s1,s2长度≤3时,只要A[s1]==A[s2],isScramble(s1,s2)==true必成立!不信可以试试,只要枚举a,b,c里的字符,要使isScramble(s1,s2)尽可能为false,应该取字符种类越多越好,那么 "abc"的排列只有6种!试试就知道了。当长度为4时,就不成立了。如 s1="abcd" ,s2="cadb"或s2="bdac"。
结合这个结论,只要保证3个以内的字符串有 hash(s1)==hash(s2) <=> A[s1]==A[s2],就可以将递归出口提前!用三位数的质数,3个质数相乘肯定不会越界!
代码如下:
//V4 终极版本(别看很长,注释很多,还有一个程序不用于AC)
typedef unsigned int UINT;
typedef vector<UINT> VINT;
typedef vector<VINT> VVINT;
//hash映射表,必须为>2的不同质数。覆盖26个字母。
static const UINT HASH_TABLE[] = { 163,191,223,241,271,307,337,367,397,431,457,487,521,563,593,617,647,677,719,751,787,823,857,883,929,967 };
class Solution {
private:
//预偏移'a',这样 hash['a']对应HASH_TABLE[0]
const UINT *hash = HASH_TABLE - 'a';
//下面的 unordered_hash()函数意义:当数组各元素及对应数量相同时其hash必相同,否则hash值大概率不相同。
//如hash("abc") == hash("bca");hash("abb") != hash("abc")
//unordered_hash(str) = hash[str[0]]*hash[str[1]]*hash[str[2]]*....*hash[str[max]]
//dp[i][j] = unordered_hash(s1[i~j-1]) ,when i<j
//dp[i][j] = unordered_hash(s2[i~j-1]) ,when i>j
//dp[i][i] = 0 (无意义)。
VVINT dp;
bool dfs(int b1, int b2, UINT len) { //=isScramble( (s1+b1)[len] ,(s2+b2)[len] )
if (dp[b1][b1 + len] != dp[b2 + len][b2])
return false; //当子串字符种类及数量不相同时!剪枝
if (len <= 3) //已经证明 unordered_hash(S),对任意≤3个字母的字符串S,都无碰撞(注意hash("abc")==hash("bca"))。
return true; //3个字母及以下,只要成份相同,必为true。
for (int m = 1; m < len; m++) { //剪枝dfs
int cm = len - m; //len 分为 m个 和cm个,两者轮流一前一后。(为了好看)
if (dfs(b1, b2, m) //s1前 对 s2前
&& dfs(b1 + m, b2 + m, cm) //s1后 对 s2后
|| dfs(b1, b2 + cm, m) //s1前 对 s2后
&& dfs(b1 + m, b2, cm)) //s1后 对 s2前
return true;
}
return false;
}
public:
bool isScramble(string s1, string s2) {
int len = s1.length();
//构造dp[][] 记录的是无序哈希函数值
dp.clear(); dp.resize(len + 1, VINT(len + 1, 0));
for (int i = 0; i < len; i++) {
UINT key = 1;
for (int j = i; j < len; ) {
key *= hash[s1[j]]; //乘法满足交换律(用乘法哈希碰撞率低)
dp[i][++j] = key;
}
}
for (int i = 0; i < len; i++) {
UINT key = 1;
for (int j = i; j < len; ) {
key *= hash[s2[j]]; //仅s1换为s2
dp[++j][i] = key; //以及下标顺序对调
}
}
//剪枝dfs
return dfs(0, 0, len);
}
#ifdef _DEBUGING_
//仅用于证明 unordered_hash(S),对任意≤3个字母的字符串S,都无碰撞(注意hash("abc")==hash("bca"))。
UINT test_hash() { //返回重复出现哈希值的次数,次数为0说明无碰撞。
unordered_set<UINT> SET; UINT t = 0; //计数插入次数
for (UINT c = 'a'; c <= 'z'; c++) {
SET.insert(hash[c]); t++; //单字符
for (UINT d = c; d <= 'z'; d++) { //排除 "ab"和"ba"本来就相同,故升序。
UINT key = hash[c] * hash[d];
SET.insert(key); t++;
for (UINT e = d; e <= 'z'; e++) { //同理升序。
SET.insert(hash[e] * key); t++;
}
}
}
cout << "字符串总数=" << t << " 重复=" << t - SET.size() << endl;
return t - SET.size();
}
#endif // _DEBUGING_
};
V4尾部还加了一个验证哈希碰撞的程序,过AC可以删去,大家可以试试将此程序代入V3版本(注意要先调用init_hash()),看看有多少碰撞。
V4没有使用V3的将剪枝操作放在调用dfs()之前,而是沿用V2版本,因为笔者试过,效果几乎一样,那代码简洁一点也好。
如果还要再压缩时间,基本上只能对STL动手了吧,改为数组。这种改进就没必要了。
至于带剪枝DFS的时间复杂度,非常难计算,需要证明剪枝比例下限和输入规模的关系才能准确得出,或者用模拟数据实验的方法测定。
欢迎大神指教。