奉獻幾篇很早前寫給朋友的稿子,后來由於其它原因無法出版就壓了箱底。
今天拿出來曬曬太陽,看官覺得能入眼的話,就看看吧~
尋找包含給定字符集合的最小子串
現代的信息處理中,計算機發揮着極其重要的作用。而信息主要以字符串的形式顯示在我們面前,所以對字符串的處理在程序領域中有很多的研究,我們在程序中也常會用到字符串和它的相關算法。
想想小學的時候,老師布置的詞組造句的作業,我們能否寫個程序自動幫老師去判斷呢?
我們將這個問題抽象出來:給定一個字符串和一個字符集合,判斷字符集合是否都在字符串中出現過;同時再求該字符串的最小子串(子串的長度最小,長度一樣時取字典序最小),使得這個子串同樣包含字符集合中的所有元素,字符均為小寫英文字母。最小子串可能有多個,找到任意一個即可。
例如:
字符串S="abcdefg",字符集合D={'c','f'},那這個最小子串為S'="cdef"。
字符串S="cfcf",字符集合D={'c','f'},那這個最小子串為S'="cf"。
解法一
首先我們先規定下最小子串的判斷標准:首先判斷長度是否最小,其次如果長度一樣,則根據字典序大小進行判斷。需要注意的是,我們使用C++ STL中的std::string類的關系運算符默認是按字典序進行字符串大小比較的。所以我們需要定義minstr函數來比較之前定義的最小子串。minstr函數見代碼清單1。
| string minstr(string obj1, string obj2) { if (obj1.length() == obj2.length()) return min(obj1, obj2); return obj1.length() > obj2.length() ? obj2 : obj1; } |
代碼清單1 最小子串比較函數
因為只有當字符集合D里所有字符都在字符串S中出現過,最小包含字串才有可能存在,所以我們先來實現如何判斷D中字符是否都在S中出現的問題。對於這個問題,我們只需要先遍歷字符串,得到它的字符集合Ds,然后判斷D是否是Ds的子集就可以了。理論上很簡單,但在實際編碼時有個問題,就是如何實現這個字符集合及其相關操作。如果你熟悉STL的話,可以使用std::set模板類及其相關函數,但是這些函數的內部實現都太重太復雜了,我們更傾向於找到輕量級的解決方案。由於我們的集合是針對英文字符而言的,英文字母總共只有26個,所以我們可以直接開大小為26的數組來模擬集合(當然也可以用位來進行,雖然操作會復雜點,但是用位運算來實現集合比較會很簡單)。即使要考慮中文字符,開大小為65536的數組也足夠了(MBCS字符集的中文字符占2字節,2^16=65536)。我們用is_subset函數來判斷之前提到的子集問題,見代碼清單2。
| bool is_subset(bool subSet[26], bool set[26]) { for (int i = 0; i < 26; i ++) { if (subSet[i] && !set[i]) return false; } return true; } |
代碼清單2 子集判斷函數
有了代碼清單2后,我們就可以用兩個for循環來實現求最小完全包含子串的函數(見代碼清單3)。其中最外面的for循環枚舉最小子串長度,第二個for循環枚舉子串起始位置,不停調用is_subset函數去判斷該子串是否滿足條件。當然了,還需要一個for循環對bool數組進行賦值。
| string min_substr(string S, string D) // 字符串S,字符集合D { string ret; bool Sset[26], Dset[26]; // 數組模擬集合 memset(Dset, 0, sizeof(Dset)); for (int i = 0; i < D.length(); i ++) Dset[D[i]-'a'] = true; // 字符集合D初始化 for (int i = D.length(); i <= S.length(); i ++) { for (int j = 0; j <= S.length() - i; j ++) { memset(Sset, 0, sizeof(Sset)); for (int k = 0; k < i; k ++) Sset[S[j+k]-'a'] = true; // 字符集合初始化 if (is_subset(Dset, Sset)) ret = ret.empty() ? S.substr(j, i) : minstr(ret, S.substr(j, i)); // substr(offest,length)是取子串函數 } } return ret; } |
代碼清單3 基於is_subset函數的算法
代碼清單3的時間復雜度很容易計算,設N=Len(S),則時間復雜度為O(N^3)。這是高效的算法嗎?當然不是,讓我們來優化它吧。
在代碼清單3中,因為我們是按照子串的長度來進行枚舉的,所以在處理子串的過程中,存在着相當多的重復計算,比如字符串”abcdefg”,計算長度為3起始位置為0時子串為”abc”,偏移為0和1的兩個字符’a’和’b’,在長度為2起始位置為0的子串”ab”時早已經被計算過,只不過那些狀態在每次計算前被memset函數清空了。這樣,假如我們能利用之前計算得出的結果,就能夠避免這些重復計算,從而提高計算的效率,我們用left 和right指針來標記最小子串的左端和右端,left要始終小於等於right;right指針不停地往右移動,當S[left,right]包含D集合中所有字符時,它就是一個可能的答案。記錄完答案后left就該右移一個字符,因為加入left不移動,之后得到的S[left,right]子串,即使滿足條件,但它的長度肯定大於當前,就不可能是最小子串了。總結下left右移的條件:當S[left]在子串的其它位置出現過或者S[left]不在D集合中時,left就可以往右移動了,因為S[left]是一個多余的值。最后,對所有可能的答案進行minstr函數的比較,就能得出最小子串了。
例如,字符串S="aaba",字符集合D={'a','b'},程序運行流程為:

“ab”和”ba”兩個可能答案經過比較后,最小子串為”ab”。瞧,這例子中N=4,總共也就運行了7步而已,比起代碼3可省了不少計算,只要left和right指針各自掃描字符串一遍就可以了,這個算法能夠做到時間復雜為O(N)!我們還需要一個更有效方法判斷集合中的字符是否都出現了,我們用int記錄D集合中的不同字符出現的個數,並且用int數組代替原來的bool數組來實現字符計數。詳細算法見代碼清單4。
| string min_substr2(string S, string D) // 字符串S,字符集合D { string ret; int Sset[26], Dset[26]; // int數組模擬集合,還有引用計數 int Ds; // 字符集合D在字符串S中出現的不同個數 memset(Dset, 0, sizeof(Dset)); memset(Sset, 0, sizeof(Sset)); Ds = 0; for (int i = 0; i < D.length(); i ++) Dset[D[i]-'a'] = 1; // 字符集合D初始化 int l = 0; for (int r = 0; r < S.length(); r ++) { if (Dset[S[r]-'a'] == 1 && Sset[S[r]-'a'] == 0) Ds ++; // S中出現新的D集合中字符 Sset[S[r]-'a'] ++; for (; l <= r; l ++) { if (Dset[S[l]-'a'] == 1 && Sset[S[l]-'a'] == 1) { if (Ds == D.length()) { // D集合中字符全部出現 ret = ret.empty() ? S.substr(l, r-l+1) : minstr(ret, S.substr(l, r-l+1)); Sset[S[l++]-'a'] --; // left右移 Ds --; } break; } Sset[S[l]-'a'] --; } } return ret; }
|
代碼清單4 基於left和right掃描的算法
總結
我們從朴素的方法開始摸索,尋找重復計算的數據,分析為何會出現重復,這個過程往往能讓你發現問題的本質,再提出能避免重復計算的方案來降低時間復雜度。這樣逐步優化的好處是因為朴素算法的正確性是不言而喻的,而新算法只是解決重復計算的問題,所以新算法的正確性也很容易被證明。
擴展問題
還不知您發現一個有趣的現象沒,最小子串的第一個字符和最后一個字符在該子串中只會出現一次,即子串除去頭尾后那兩個字符不會再出現,這是為什么呢?這個問題的完整證明就留給讀者您自己來完成吧。
