1. 程式人生 > >字串匹配演算法(二)窮舉與自動機

字串匹配演算法(二)窮舉與自動機


Rob Pike, 最偉大的C 語言大師之一, 在《Notes on C Programming》中闡述了一個原則:花哨的演算法比簡單演算法更容易出bug、更難實現,儘量使用簡單的演算法配合簡單的資料結構。而Ken Thompson——Unix 最初版本的設計者和實現者,禪宗偈語般地對Pike 的這一原則作了強調: 拿不準就窮舉(When in doubt , use brute force)。 而對於裝13愛好者來說,更是自豪的稱其使用的是BF演算法。窮舉法用在字串匹配上,簡單的描述就是,檢查文字從0到n-m的每一個位置,看看從這個位置開始是否與模式匹配。這種方法還是有一些優點的,如:不需要預處理過程,需要的額外空間為常數,每一趟比較時可以以任意順序進行。
儘管它的時間複雜度為O(mn),例如在文字"aaaaaaaaaaaaaaaaaaaaaaaaaaa"中尋找"aaaaab"時,就完全體現出來了。但是演算法的期望值卻是2n,這表明該演算法在實際應用中效率不低。C程式碼如下:
  1. void
     BF(char *x, int m, char *y, int n) {
  2. int i, j;
  3. /* Searching */
  4. for (j = 0; j <= n - m; ++j) {
  5. for (i = 0; i < m && x[i] == y[i + j]; ++i);
  6. if (i >= m)
  7.          OUTPUT(j);
  8.    }
  9. }
如果我們注意到C庫函式是彙編優化過的,並通常能提供比C程式碼更高的效能的話,我們可以用memcmp來完成每一趟比較過程,從而達到更好的效能:
  1. #define EOS '/0'
  2. void BF(char *x, int m, char *y, 
    int n) { 
  3. char *yb; 
  4. /* Searching */
  5. for (yb = y; *y != EOS; ++y) 
  6. if (memcmp(x, y, m) == 0) 
  7.       OUTPUT(y - yb);
  8. }
自動機的方法其實和窮舉法有點相似,都是用最簡單直白的方式來做事情。區別在於窮舉法是在計算,而自動機則是查表。儘管自動機的構造過程有一點點難解,要涉及到DFA的理論,但是自動機的比較過程那絕對是簡單到無語。簡單說來,根據模式串,畫好了一張大的表格,表格m+1行σ列,這裡σ表示字母表的大小。表格每一行表示一種狀態,狀態數比模式長度多1。一開始的狀態是0,也就是處在表格的第0行,這一行的每個元素指示了當遇到某字元時就跳轉到另一個狀態。每當跳轉到最終狀態時,表示找到了一個匹配。語言表述起來還是比較囉嗦,看程式碼就知道了:
  1. #define ASIZE 256
  2. int preAut(constchar *x, int m, int* aut) {
  3. int i, state, target, old;
  4. for (state = 0, i = 0; i < m; ++i) {
  5.                 target = i + 1;
  6.                 old = aut[state * ASIZE + x[i]];
  7.                 aut[state * ASIZE + x[i]] = target;
  8.                 memcpy(aut + target * ASIZE, aut + old * ASIZE, ASIZE*sizeof(int));
  9.                 state = target;
  10.         }
  11. return state;
  12. }
  13. void AUT(constchar *x, int m, constchar *y, int n) {
  14. int j, state;
  15. /* Preprocessing */
  16. int *aut = (int*)calloc((m+1)*ASIZE, sizeof(int));
  17. int Terminal = preAut(x, m, aut);
  18. /* Searching */
  19. for (state = 0, j = 0; j < n; ++j) {
  20.                 state = aut[state*ASIZE+y[j]];
  21. if (state == Terminal)
  22.                         OUTPUT(j - m + 1);
  23.         }
  24. }
注:原文的程式碼使用一個有向圖的資料結構,我遵循大師的指引,改用了更簡單一點的陣列
從程式碼上我們很容易看出,自動機的構造需要時間是O(mσ),空間也是O(mσ)(嚴格來說這份程式碼使用了O((m+1)σ)),但是一旦構造完畢,接下來匹配的時間則是O(n)。匹配的過程前面已經說了,太簡單了沒什麼好說的,這裡就解釋一下構造過程吧!我們構造的目標是對應模式長度,構造出同樣多的狀態,用0表示初始狀態,然後第一個字元用狀態1表示,第二個用狀態2表示,依次類推,直到最後一個字元,用m表示,也是最終狀態。一開始,陣列全都置0,,這個時候的自動機遇到任何字元都轉到初始狀態。然後給它看模式的第一個字元,假設這是'a'吧,告訴它,狀態0遇到'a'應該到一個新的狀態——狀態1,所以把第0行的第'a'列修改為1。而這個時候狀態1還是空白的,怎麼辦呢?這時候狀態0就想呀,在我被告知遇到'a'要去狀態1之前,我原本遇到'a'都要去狀態0的,也就是修改之前第'a'列所指的那個狀態,稱為old狀態吧;而現在我遇到'a'卻要去一個新的狀態,既然以前old狀態能處理遇到'a'之後的事情,那麼我讓新的狀態像old狀態一樣就好了。於是狀態0把old狀態拷貝到狀態1。現在輪到狀態1了,給它看第二個字元,它也如法炮製,指向了狀態2,又把old狀態拷貝給了狀態2。於是,狀態機就在這種代代傳承的過程中構造完畢了。雖然理論上自動機是最完美的匹配方式,但是由於預處理的消耗過大,實踐中,主要還是用於正則表示式。結語:窮舉法與自動機各自走了兩個極端,因此都沒能達到綜合性能的最佳,本文之後介紹的演算法,可以看成是在窮舉和自動機兩者之間取捨權衡的結果。

相關推薦

no