基礎知識 - 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
}



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




免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM