前言
在軟件開發行業中實際工程做得久了,大多數人會發現很少有機會接觸到各種算法。正如Reddit上有人評論到,當初進公司的時候通過了n輪算法面試,實際工作卻很可能是不斷的解“null pointer exception”的bug。但是算法作為軟件開發的基礎的重要性確是不容置疑的,由此我最近突然想要練習練習算法題,補充一下工作中接觸不到的知識。在探索過程中發現了LeetCode這個網站,其中Online Judge部分有不少不錯的練習題,遂打算在博客中分享解題經驗。我google了一下目前分享LeetCode的博客,大多全篇代碼或比較簡單的解題報告。對於經過ACM訓練的人可能看到要點就能領悟了,但是對於我這種非ACM人士來說經常看得一頭霧水。所以我希望以一個程序員而不是ACM選手的角度分享解題過程,而不是ACM高手那樣的精煉提示:“用DP就能搞定”,“是NP問題”。最后,所有的問題的解決方案將會用C++代碼實現。
問題
給定一個字符串s,切割s使該切割結果中每一個子串都是一個回文。
返回需要切割次數最少的回文集。
例如,如果有s = "aab",
返回1,因為分割集["aa","b"]可以由s切割一次產生
初始思路
不管用什么方法,一個判斷字符串是不是回文的函數肯定是少不了的,這個比較容易實現。我們可以同時從字符串的頭部和尾部向中間移動,只要字符有不等的情況發生,那么改字符串肯定不是回文。代碼如下:
bool IsPalindrome(const std::string& s, size_t start, size_t end) { bool result = true; while(start < end) { if(s[start] != s[end]) { result = false; break; } ++start; --end; } return result; }
要找出切割次數最少的回文集,那么我們可以找出所有可能的回文集,然后找出切割次數最少的那個。要怎么找出所有的回文集呢?讓我們用較短的aab作為例子人力暴力拆解看看:
a,a,b
aa,b
我們從aab的起點開始,先選長度為1的子串,發現它是回文,這樣問題分解為求a和[ab的所有回文集合]的組合。然后選取長度為2的子串,也是回文,問題分解為aa和[b的所有回文集合]的組合。最后選取長度問3的子串,發現它不是回文。
用代碼模擬一下,大概是這樣:
void FindMinPartition(const std::string& s, size_t start) { size_t pos = start; while(pos < s.size()) { if(IsPalindrome(s, start, pos)) { FindMinPartition(s, pos + 1); } ++pos; } }
可以看到函數里面出現了遞歸,既然是遞歸就要有遞歸結束條件。我們可以看看前面模擬的aa和[b的所有回文集合]的情況,判斷b的所有回文集時發現b也是回文,但是我們不需要再往后找了,因為再往后就超出了字符串的范圍,由此我們可以得到遞歸的結束條件應該是start >= s.size()(別忘了下標是從0開始的,下標為s.size()的時候已經越界了)。
現在怎么遞歸清楚了,但是切割次數還沒統計呢。不難看出每判斷出一個回文,就是一次切割。方便起見,我們可以用一個獨立於改函數的全局或成員變量來保存,每次判斷出回文后對其加1。且慢,如果光是加1這個切割次數就變成了整個過程的切割次數總和,這個數字肯定不對。讓我們在用aab的例子來看看,當我們解決完a和[ab的所有回文集合]的組合的問題后取長度為2的字符串aa時,切割次數應該是1,因為切完a后我們又從頭開始切aa,相當於對a的那次切割已經被取消了。從而可以得出每次遞歸解決問題后切割次數應該減1。
好了,那么現在最后的問題就是算最小切割次數了。每次遞歸結束時,我們都會得到一個回文集及切割的次數,將其與一個專門存放最小次數的變量比較,如果當前切割次數小於該變量就更新之即可。這里要注意的是我們對最后的一個子串也“切了一刀”,所以和最小次數比較及賦值時要減去1。
最后得出方案:
class Solution32_v1 { public: int minCut(const std::string& s) { minCut_ = -1; currentCut_ = 0; FindMinPartition(s, 0); return minCut_; } private: void FindMinPartition(const std::string& s, size_t start) { if(start < s.size()) { size_t pos = start; while(pos < s.size()) { if(IsPalindrome(s, start, pos)) { ++currentCut_; FindMinPartition(s, pos + 1); --currentCut_; } ++pos; } } else { if(currentCut_ - 1 < minCut_ || minCut_ == -1) { minCut_ = currentCut_ - 1; } } } int minCut_; int currentCut_; };
運行“Judge small”,順利通過測試!很好,下面讓我們運行“Judge Large”。什么,竟然提交失敗了,超時!
優化
在本機運行超時的用例:
fifgbeajcacehiicccfecbfhhgfiiecdcjjffbghdidbhbdbfbfjccgbbdcjheccfbhafehieabbdfeigbiaggchaeghaijfbjhi
需要6秒才能返回結果。看來LeetCode不允許這么長的執行時間。要怎么優化呢?
經過觀察,可以發現我們在遞歸的過程中有很多重復運算。以上面的字符串為例,我們會計算[f],[i],[f]與[gbeajcacehiicccfecbfhhgfiiecdcjjffbghdidbhbdbfbfjccgbbdcjheccfbhafehieabbdfeigbiaggchaeghaijfbjhi的所有回文集合]。然后又會計算[fif]與[gbeajcacehiicccfecbfhhgfiiecdcjjffbghdidbhbdbfbfjccgbbdcjheccfbhafehieabbdfeigbiaggchaeghaijfbjhi的所有回文集合]。在有很多遞歸調用的情況下,這種重復會浪費很多時間。所以我們需要想一個辦法重復利用某些計算結果。
首先,要保存已近計算過的分割次數,很自然的想到map。於是我們定義一個std::map<std::string, int>類型的類成員變量來保存某個子串的最小分割次數,該字符串本身作為key。
其次,由於要保存各子串的分割次數,使用一個成員變量不能滿足要求,我們需要通過返回值告訴自己的上層函數,並由上層函數計算出最小分割次數。
int partMin = -1; int partCut = 0; 循環start partCut = FindMinPartition(s, pos + 1); if(partCut < partMin || partMin == -1) { partMin = partCut; } 循環end return partMin;
最后,我們需要得到的是某個子串的分割次數而不是當前的分割次數。需要通過partCut - currentCut_計算出來。因為currentCut_為進一步分割子串前的分割次數,而partCut為分割子串后的分割次數,兩者的差即為該子串貢獻的分割次數。
這樣在每次嘗試遞歸求解之前,我們都可以判斷一下該子串以前有沒有被計算過,如果有,可以直接使用計算結果而避免遞歸。把所有功能結合起來之后:
class Solution132_v2 { public: Solution132_v2() : currentCut_(0) { } int FindMinPartition(const std::string& s, size_t start) { if(start < s.size()) { int pos = start; int partMin = -1; int partCut = 0; while(pos < s.size()) { if(IsPalindrome(s, start, pos)) { std::string rest = s.substr(pos + 1, s.size() - pos - 1); ++currentCut_; if(rest != "") { std::map<std::string, int>::const_iterator iter = infoMap_.find(rest); if(iter != infoMap_.end()) { if(currentCut_ + iter->second < partMin || partMin == -1) { partMin = currentCut_ + iter->second; } ++pos;
--currentCut_; continue; } } partCut = FindMinPartition(s, pos + 1); if(partCut < partMin || partMin == -1) { partMin = partCut; } if(rest != "") { infoMap_[rest] = partCut - currentCut_; } --currentCut_; } ++pos; } return partMin; } else { return currentCut_ - 1; } } private: int currentCut_; std::map<std::string, int> infoMap_; };
現在在本機處理fifgbeajcacehiicccfecbfhhgfiiecdcjjffbghdidbhbdbfbfjccgbbdcjheccfbhafehieabbdfeigbiaggchaeghaijfbjhi只需要1ms了。提交,運行!這回大多數字符串通過了,但是又掛在了
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aabbaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
這樣一個字符串上。
再次優化
在本機再次運行處理這個超長字符串,用時3.6秒。還能怎么優化呢?由於我們要找的是最小分割次數,是不是能終止一些已經沒可能產生最小分割的循環?嘗試在判斷回文成功后加入下列代碼:
int estimate = 0; if(pos == s.size() - 1) { estimate = currentCut_; } else { estimate = currentCut_ + 1; } if(estimate >= partMin && partMin != -1) { break; }
這里注意當當前回文是字符串后半部分時,分割次數是不會增加的(我們在后面返回時會減掉)。而如果不是最后,至少會增加1。如果估計的分割數將會大於等於當前的最小分割數,那么已經沒有必要再找下去了。
再次嘗試本機運行,用時基本沒有變化。這是怎么回事?重新審視我們的分割方法,每次取子串都是從最短的取起,如abba會首先得到[a],[b],[b],[a]的3次分割子集。這就導致了濾條件基本沒用,因為首先放進去的都是分割次數最大的。由於子串越長,分割次數肯定更少,我們應該采用先從最長子串取起的方法。還是拿最簡單的aab做例子。我們先取長度為3的aab,發現不是回文。再取長度為2的aa,發現是回文,進而求解b的回文集。最后去長度為1的a,進而求解ab的回文集。這樣從最長取起的方法就可以有效的利用我們的終止循環條件了。調整后的代碼如下:
class Solution132 { public: Solution132() : currentCut_(0) { } int FindMinPartition(const std::string& s, size_t start) { if(start < s.size()) { int pos = s.size() - 1; int partMin = -1; int partCut = 0; while(pos >= (int)start) { if(IsPalindrome(s, start, pos)) { int estimate = 0; if(pos == s.size() - 1) { estimate = currentCut_; } else { estimate = currentCut_ + 1; } if(estimate >= partMin && partMin != -1) { break; } std::string rest = s.substr(pos + 1, s.size() - pos- 1); ++currentCut_; if(rest != "") { std::map<std::string, int>::const_iterator iter = infoMap_.find(rest); if(iter != infoMap_.end()) { //std::cout << "String: " << rest << "-Count: " << iter->second << std::endl; if(currentCut_ + iter->second < partMin || partMin == -1) { partMin = currentCut_ + iter->second; } --pos; --currentCut_; continue; } } partCut = FindMinPartition(s, pos + 1); if(partCut < partMin || partMin == -1) { partMin = partCut; } if(rest != "") { infoMap_[rest] = partCut - currentCut_; } --currentCut_; } --pos; } return partMin; } else { return currentCut_ - 1; } } private: int currentCut_; std::map<std::string, int> infoMap_; };
這回在本機處理那個超長字符串只要7ms了,應該沒問題了。提交並運行:

終於成功通過 Judge Large!在這次解題過程中我們可以看到通過一步步的優化,實現同樣目的的程序性能可以有數百倍的提高。
