87. 扰乱字符串(Scramble String) LeetCode C++版本
題目:難度Hard(請自行點擊鏈接查看)
https://leetcode-cn.com/problems/scramble-string/
V2版本的執行時間已經能達到較為理想的結果了。后面只是為了追求更高執行效率。
一、分析:
由題意:這個“亂序”字符串,是由原字符串任意二叉樹分割(每一支至少要有一個字符)成每個葉子只有一個字符,再選擇任意多個子節點交換左右子樹得到。
這操作很明顯屬于“分治”,適用遞歸算法。首先想想暴力遞歸如何做?直接上代碼,如下:
注意每一種分割方法都有不交換和交換兩個子樹的兩種選擇,都要嘗試。
筆者做此題時并沒有嘗試提交此解法,因為此解法字符串長度為N時,需要嘗試4(N-1)種分支,時間復雜度是指數階乘級別的!如果沒有想出優解提交暴力算法,能確定題意理解正確也好。
二、樹遍歷剪枝:(AC解)
如果要在遞歸的基礎上改進時間復雜度,常用的方法無法是剪枝和記憶化遞歸。
那么先來看看記憶化遞歸有沒有戲。因為我是先往此方向思考的。想想要將字符串分割為兩部分,無非就是確定子串起點和終點,那就只要兩個坐標軸描述遞歸離散點!就在準備開寫時,突然想起如果父節點子樹交換一下,
就會出現前對后、后對前的情況。所以s1和s2的起點和終點可以不一致!必須要用三維空間存儲遞歸結果!
那么這個O(N^3)解法先放一邊,后備吧!
那就想想如何剪枝,那就是提前排除一些不可能成立的字符串分割方法。
觀察一些樣例發現,如果分割后s1,和s2相對應的子串,其字符種類和數量沒有完全對應,必然不可能通過打亂得到。
只要在執行函數前,檢查相對的兩個子串其字符種類和數量是否完全一致,不一致則跳過,即為剪枝。
代碼如下:
此版本可以成功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.1typedef 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;}}//dfsreturn 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++) { //剪枝dfsint 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換為s2dp[++j][i] = key; //以及下標順序對調}}//剪枝dfsreturn 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的時間復雜度,非常難計算,需要證明剪枝比例下限和輸入規模的關系才能準確得出,或者用模擬數據實驗的方法測定。
歡迎大神指教。
總結
以上是生活随笔為你收集整理的87. 扰乱字符串(Scramble String) LeetCode C++版本的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 87. Scramble String
- 下一篇: NOIP(C++)信息学奥赛课程--持续