三種字符串查找算法的Go實現

原文轉載自我的個人網站 zhangmingkai.cn ,歡迎大家訪問。

字符串查找就是給定一段文字,查找所有包含特定單詞的方式,當我們使用網站瀏覽信息的時候,用Ctr+F搜索網頁, 或在linux上使用grep查詢日誌文件中的特殊字符串都是屬於這類模式。所以算是一種比較常見的算法,本文將總結一些字符串查找算法常見的實現方式,以及如何用Go語言實現該算法。

主要的算法分爲三種:

  • 暴力遍歷算法
  • KMP算法
  • BM算法

1. 暴力遍歷算法

暴力遍歷算法,簡單來說就是通過一個字符接一個字符的移動比對。比如下面的實例圖, 原始的字符文本(長度爲N)放在第一行,需要匹配的字符文本(長度爲M)在第二行,不斷位移第二行待匹配的字符串來觀察是否相同,這裏每一次觀察都要進行最多M次的比較。

img

在很多程序語言實現中,都直接利用暴力遍歷來獲取匹配結果。 Go語言中對於AMD64的機器,設置的最長的位置是64字節,也就是如果原始文本和匹配內容低於64字節則直接利用暴力遍歷,對於大於64字節的情況下,使用快速查找算法(另一種遍歷算法),但會隨計算輸出調整算法模式

  • 如果原始文本大於64字節, 匹配文本小於64字節,則調整爲暴力遍歷
  • 如果匹配和原始文本都大於64字節,則調整爲Rabin Karp算法,一種指紋搜索算法(O(1)的空間使用以及線性的搜索時間)。

關於暴力遍歷的具體的Go代碼實現如下, 順序位移匹配,查詢是否完成所有匹配,如果完成則代表匹配成功,否則代表匹配失敗(中途break):

func bruteForceStringMatching(src string, match string) bool {
  m := len(match)
  n := len(src)

  for i := 0; i < n-m; i++ {
    j := 0
    for j = 0; j < m; j++ {
      if src[i+j] != match[j] {
        break
      }
    }
    if j == m {
      return true
    }
  }

  return false
}

func main() {
  println(bruteForceStringMatching("this is a simple test text", "hello"))  // false
  println(bruteForceStringMatching("this is a hello message", "hello"))     // true
}

KMP算法是在1976年由Knuth, Morris和Pratt提出並發表的一種字符查找算法。KMP算法的主要原理是通過計算最長前綴的方式來獲得匹配表對應的跳躍表, 後面的匹配過程中利用前綴匹配來計算需要跳躍的位置。實例如下:

2. KMP算法

暴力遍歷的算法簡單,平均需要遍歷的時間基本上與N * M成正比,但是實際運行中我們可以看到,大部分開頭第一個單詞不匹配就退出了這一輪的檢查,因此實際基本與N成正比。當然如果查詢的原始文本類似AAAAAAAAAAAA, 查詢的是AAAAB, 那麼依舊需要遍歷所有的字符串,查詢時間變爲N * M。

S: ABCFABCDABFABCDABCDABDE
W: ABCDABD

我們按照暴力遍歷的方式開始比較,發現搜索字段W的第四個字符”D”和上面的文本S中的“F”不匹配, 這時候如果按照暴力遍歷,我們需要將W位移一位,但是我們知道前面的三個字符也和”F”不一致,其實可以直接跳過去,開始新的遍歷, 如下面所示:

S: ABCFABCDABFABCDABCDABDE
W:     ABCDABD

第二次匹配過程中發現F和D不一致,但是同時我們發現後面出現了的AB和字符前面的AB可以對齊,因此我們直接位移到前一個AB的位置就可以開始第三次匹配了,

S: ABCFABCDABFABCDABCDABDE
W:         ABCDABD

這就是KMP算法的原理。爲了計算跳躍位置比如上面的搜索詞語ABCDABD,我們需要依次查看每個單詞出現的位置和前綴組合, 下面是通過拆分獲取的組合信息,形成的最長前綴組合數組爲[0,0,0,0,1,2,0]

0 A
0 AB
0 ABC
0 ABCD
1 ABCDA
2 ABCDAB
0 ABCDABD

爲了後面更好的計算我們需要對於該前綴表進行移位操作, 計算出實際需要的Next查詢表,這個表代表的是除了當前字符以外的最長前綴表,因此需要將數組向右移位,第一位用-1進行補齊。完成移位操作後的數組爲[-1, 0,0,0,0,1,2 ]。

實際計算過程中通過查詢跳躍表獲取新的匹配位置進行位移即可

func getPrefixTable(search string) []int {
  ptLength := len(search)
  pt := make([]int, ptLength)
  pt[0] = 0
  len := 0
  i := 1
  for i < ptLength {
    if search[i] == search[len] {
      len++
      pt[i] = len
      i++
    } else {
      if len > 1 {
        len = pt[len-1]
      } else {
        pt[i] = len
        i++
      }
    }
  }
  return pt
}
func shiftPT(pt []int) {
  len := len(pt)
  for i := len - 1; i > 0; i-- {
    pt[i] = pt[i-1]
  }
  pt[0] = -1
}

func searchWithPT(text string, pattern string) bool {
  pt := getPrefixTable(pattern)
  shiftPT(pt)
  fmt.Printf("pt=%v", pt)
  M := len(text)
  N := len(pattern)
  if N > M {
    return false
  }
  // i 追蹤text的位置 , j 追蹤pattern的位置
  i, j := 0, 0
  found := 0
  for i < M {
    if j == N-1 && text[i] == pattern[j] {
      fmt.Printf("found pattern \"%s\" at index %d\n", pattern, i-j)
      found++
      j = pt[j]
    }
    if text[i] == pattern[j] {
      i++
      j++
    } else {
      j = pt[j]
      if j == -1 {
        i++
        j++
      }
    }
  }
  fmt.Printf("find %d pattern in the text \n", found)
  return found > 0
}

KMP算法實現起來沒有前面的暴力算法那麼直接,但是可以保證現行級別的性能,且不需要再查詢中進行回退操作。

3. Boyer & Moore算法

BM算法有R.S.Boyer和J.S.Moore發明,被用在許多的文本編輯類的應用程序。B&M算法在Go源碼的stringFinder結構體中實現,用來高效的實現字符串的查找。B&M算法的原理如下,下面的例子是在基因序列中查找特定的基因組合。比如TATGT組合,算法從右向左開始匹配,第一個匹配的是C和G,我們可以看到C並沒有存在匹配序列中,因此我們可以直接將匹配序列向前移動整個序列長度的範圍, 跳到第二個圖例的位置上。

在這裏插入圖片描述

比如比對發現的不是C和G,而是A和G, 那麼我們就將序列向前移動兩個位置,使得A和匹配位置A對齊。

“boyer moore”的圖片搜索結果"

因此B&M算法需要預先計算一個查詢表,用於查詢跳躍的位置數。這個表格可以通過下面的代碼生成, 對於不存在在匹配表的所有字符,全部設置爲-1, 對於其他存在在匹配表的字符,存放實際的最右端的位置。比如有兩個A,則僅存儲最右側的A, 用於實際移動距離計算使用。

const NumberOfChars = 256

func genBadCharTable(pattern string, size int) []int {
  badCharTable := make([]int, NumberOfChars)
  for i := 0; i < NumberOfChars; i++ {
    badCharTable[i] = -1
  }
  for i := 0; i < size; i++ {
    badCharTable[int(pattern[i])] = i
  }
  return badCharTable
}

利用上面的表格進行實際搜索的代碼如下面所示,skip變量存放跳躍的個數,該個數通過當前比較不一致的時候,查詢跳躍表計算獲得,如果內循環結束,且Skip爲0則代表全部匹配成功,並找到查詢的內容否則則根據實際情況進行跳躍。

func searchWithBM(text string, pattern string) int {
	M := len(pattern)
	N := len(text)
	badCharTable := genBadCharTable(pattern, M)
	skip := 0
	for i := 0; i &lt;= N-M; i += skip {

		for j := M - 1; j >= 0; j-- {
			println(i, j)
			if text[i+j] != pattern[j] {
				skip = j - badCharTable[text[i+j]]
				if skip &lt; 1 {
					skip = 1
				}
				break
			}

		}
		if skip == 0 {
			fmt.Printf("found pattern %s\n", pattern)
			return i
		}
	}
	fmt.Println("no pattern been found")
	return -1
}

實際的算法中還可能結合一個正向匹配的表格一併進行計算,每次移動都進行兩次移動,算出最遠的匹配。但是兩次計算的效率是否更高效,需要實際測試才能知道。

總結

關於三種常見的字符串比較算法,應用場景其實存在比較大的區別,對於暴力遍歷,比較易於理解,且對於較短的字符處理高效直接,不需要額外的數組空間存儲其他的數據,但是對於較大量的數據搜索需要的時間可能相對較長。

KMP算法能夠保證線行級別性能,BM算法也算是一種線行級別性能的比較算法,但是BM算法在一般情況下跳躍的字符會更多一些,執行搜索的效率也會更好,另外KMP和BM都需要額外的內存空間存儲數據,對於較大的比較數據,需要綜合考慮實際的情況進行選擇。

PS: 另外如果需要執行多個模式的同時匹配,可利用Aho-Corasick算法實現比較好的搜索性能

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章