基礎知識 - Rabin-Karp 算法

Rabin-Karp 算法(字符串快速查找)算法

 

  Go 語言的 strings 包(strings.go)中用到了 Rabin-Karp 算法。Rabin-Karp 算法是基於這樣的思路:即把字符串看做是字符集長度進制的數,由數值的比較結果得出字符串的比較結果。

  樸素的字符串匹配算法爲何慢?由於它太健忘了,前一次匹配的信息其實有部分能夠應用到後一次匹配中去,而樸素的字符串匹配算法只是簡單的把這個信息扔掉,從頭再來,所以,浪費了時間。好好的利用這些信息,天然能夠提升運行速度。

  因爲完成兩個字符串的比較須要對其中包含的字符進行逐個比較,所需的時間較長,而數值比較則一次就能夠完成,那麼咱們首先把「搜索詞」中各個字符的「碼點值」經過計算,得出一個數值(這個數值必須能夠表示出字符的先後順序,並且能夠隨時去掉某個字符的值,能夠隨時添加一個新字符的值),而後對「源串」中要比較的部分進行計算,也得出一個數值,對這兩個數值進行比較,就能判斷字符串是否匹配。對兩個數值進行比較,速度比簡單的字符串比較快不少。

  好比咱們要在源串 "9876543210520" 中查找 "520",由於這些字符串中只有數字,因此咱們可使用字符集 {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9'} 來表示字符串中的全部元素,而且將各個字符映射到數字 0~9,而後用 M 表示字符集中字符的總個數,這裏是 10,那麼咱們就能夠將搜索詞 "520" 轉化爲下面的數值:

("5"的映射值 * M + "2"的映射值) * M + "0"的映射值 = (5 * 10 + 2) * 10 + 0 = 520

  固然,若是「搜索詞」很長,那麼計算出來的這個數值就會很大,這時咱們能夠選一個較大的素數對其取模,用取模後的值做爲「搜索詞」的值。

  分析一下這個數值:520,它能夠表明字符串 "520",其中:

表明字符 "5" 的部分是「 "5"的映射值 * (M 的 n - 1 次方) = 5 * (10 的 2 次方) = 500」
表明字符 "2" 的部分是「 "2"的映射值 * (M 的 n - 2 次方) = 2 * (10 的 1 次方) = 20」
表明字符 "0" 的部分是「 "0"的映射值 * (M 的 n - 3 次方) = 0 * (10 的 0 次方) = 0」
(n 表明字符串的長度)

  咱們能夠隨時減去其中一個字符的值,也能夠隨時添加一個字符的值。

  「搜索詞」計算好了,那麼接下來計算「源串」,取「源串」的前 n 個字符(n 爲「搜索詞」的長度)"987",按照一樣的方法計算其數值:

("9"的映射值 * M + "8"的映射值) * M + "7"的映射值 = (9 * 10 + 8) * 10 + 7 = 987

  而後將該值與搜索詞的值進行比較便可。

  比較發現 520 與 987 不相等,則說明 "520" 與 "987" 不匹配,則繼續向下尋找,這時候該如何作呢?下一步應該比較 "520" 跟 "876" 了,那麼咱們如何利用前一步的信息呢?首先咱們把 987 減去表明字符 "9" 的部分:

987 - ("9"的映射值 * (M 的 n - 1 次方)) = 987 - (9 * (10 的 2 次方)) = 987 - 900 = 87

  而後再乘以 M(這裏是 10),再加上 "6" 的映射值,不就成了 876 了麼:

87 * M + "6"的映射值 = 87 * 10 + 6 = 876

  固然了,因爲採用了取模操做,當兩個數值相等時,未必是真正的相等,咱們須要進行一次細緻的檢查(再進行一次樸素的字符串比較)。若不匹配,則能夠排除掉。繼續下一步。

  若是咱們要在 ASCII 字符集範圍內查找「搜索詞」,因爲 ASCII 字符集中有 128 個字符,那麼 M 就等於 128,好比咱們要在字符串 "abcdefg" 中查找 "cde",那麼咱們就能夠將搜索詞 "cde" 轉化爲「("c"的碼點 * M + "d"的碼點) * M + "e"的碼點 = (99 * 128 + 100) * 128 + 101 = 1634917」這樣一個數值。

  分析一下這個數值:1634917,它能夠表明字符串 "cde",其中:

表明字符 "c" 的部分是「 "c"的碼點 * (M 的 n - 1 次方) = 99 * (128 的 2 次方) = 1622016」
表明字符 "d" 的部分是「 "d"的碼點 * (M 的 n - 2 次方) = 100 * (128 的 1 次方) = 12800」
表明字符 "e" 的部分是「 "e"的碼點 * (M 的 n - 3 次方) = 101 * (128 的 0 次方) = 101」
(n 表明字符串的長度)

  咱們能夠隨時減去其中一個字符的值,也能夠隨時添加一個字符的值。

  「搜索詞」計算好了,那麼接下來計算「源串」,取「源串」的前 n 個字符(n 爲「搜索詞」的長度)"abc",按照一樣的方法計算其數值:

("a"的碼點 * M + "b"的碼點) * M + "c"的碼點 = (97 * 128 + 98) * 128 + 99 = 1601891

  而後將該值與「搜索詞」的值進行比較便可。

  比較發現 1634917 與 1601891 不相等,則說明 "cde" 與 "abc" 不匹配,則繼續向下尋找,下一步應該比較 "cde" 跟 "bcd" 了,那麼咱們如何利用前一步的信息呢?首先去掉 "abc" 的數值中表明 a 的部分:

(1601891 - "a"的碼點 * (M 的 n - 1 次方)) = (1601891 - 97 * (128 的 2 次方)) = 12643

  而後再將結果乘以 M(這裏是 128),再加上 "d" 的碼點值不就成了 "bcd" 的值了嗎:

12643 * 128 + "d"的碼點 = 1618304 + 100 = 1618404

  這樣就能夠繼續比較 "cde" 和 "bcd" 是否匹配,以此類推。

  若是咱們要在 Unicode 字符集範圍內查找「搜索詞」,因爲 Unicode 字符集中有 1114112 個字符,那麼 M 就等於 1114112,而 Go 語言中使用 16777619 做爲 M 的值,16777619 比 1114112 大(更大的 M 值能夠容納更多的字符,這是能夠的),並且 16777619 是一個素數。這樣就可使用上面的方法計算 Unicode 字符串的數值了。進而能夠對 Unicode 字符串進行比較了。

  其實 M 能夠理解爲進位值,好比 10 進制就是 10,128 進制就是 128,16777619 進制就是 16777619。

  下面是 Go 語言中字符串匹配函數的源碼,使用 Rabin-Karp 算法進行字符串比較:

// primeRK 是用於 Rabin-Karp 算法中的素數,也就是上面說的 M
const primeRK = 16777619

// 返回 Rabin-Karp 算法中「搜索詞」 sep 的「哈希值」及相應的「乘數因子(權值)」
func hashstr(sep string) (uint32, uint32) {
	// 計算 sep 的 hash 值
	hash := uint32(0)
	for i := 0; i < len(sep); i++ {
		hash = hash*primeRK + uint32(sep[i])
	}
	// 計算 sep 最高位 + 1 位的權值 pow(乘數因子)
	// 也就是上面說的 M 的 n 次方
	// 這裏經過遍歷 len(sep) 的二進制位來計算,減小計算次數
	var pow, sq uint32 = 1, primeRK
	for i := len(sep); i > 0; i >>= 1 {
		if i&1 != 0 { // 若是二進制最低位不是 0
			pow *= sq
		}
		sq *= sq
	}
	return hash, pow
}

// Count 計算字符串 sep 在 s 中的非重疊個數
// 若是 sep 爲空字符串,則返回 s 中的字符(非字節)個數 + 1
// 使用 Rabin-Karp 算法實現
func Count(s, sep string) int {
	n := 0
	// 特殊狀況判斷
	switch {
	case len(sep) == 0: // 空字符,返回字符個數 + 1
		return utf8.RuneCountInString(s) + 1
	case len(sep) == 1: // 單個字符,能夠用快速方法
		c := sep[0]
		for i := 0; i < len(s); i++ {
			if s[i] == c {
				n++
			}
		}
		return n
	case len(sep) > len(s):
		return 0
	case len(sep) == len(s):
		if sep == s {
			return 1
		}
		return 0
	}
	// 計算 sep 的 hash 值和乘數因子
	hashsep, pow := hashstr(sep)
	// 計算 s 中要進行比較的字符串的 hash 值
	h := uint32(0)
	for i := 0; i < len(sep); i++ {
		h = h*primeRK + uint32(s[i])
	}
	lastmatch := 0 // 下一次查找的起始位置,用於確保找到的字符串不重疊
	// 找到一個匹配項(進行一次樸素比較)
	if h == hashsep && s[:len(sep)] == sep {
		n++
		lastmatch = len(sep)
	}
	// 滾動 s 的 hash 值並與 sep 的 hash 值進行比較
	for i := len(sep); i < len(s); {
		// 加上下一個字符的 hash 值
		h *= primeRK
		h += uint32(s[i])
		// 去掉第一個字符的 hash 值
		h -= pow * uint32(s[i-len(sep)])
		i++
		// 開始比較
		// lastmatch <= i-len(sep) 確保不重疊
		if h == hashsep && lastmatch <= i-len(sep) && s[i-len(sep):i] == sep {
			n++
			lastmatch = i
		}
	}
	return n
}



  我是初學者,這些學習筆記參考了網絡上的一些資料,因爲參考的內容比較多雜,因此不一一列出了,感謝各位網絡朋友的無私奉獻!



相關文章
相關標籤/搜索