滑动窗口算法延伸 Rabin-Karp 算法
简介
拉宾-卡普算法(英语:Rabin–Karp algorithm)或卡普-拉宾算法(Karp–Rabin algorithm),是一种由理查德·卡普与迈克尔·拉宾于1987年提出的、使用散列函数以在文本中搜寻单个模式串的字符串搜索算法单次匹配。该算法先使用旋转哈希以快速筛出无法与给定串匹配的文本位置,此后对剩余位置能否成功匹配进行检验。此算法可推广到用于在文本搜寻单个模式串的所有匹配或在文本中搜寻多个模式串的匹配。
若要在一段文本中找出单个模式串的一个匹配,此算法具有线性时间的平均复杂度,其运行时间与待匹配文本和模式串的长度成线性关系。虽然平均情况下,此算法表现优异,但最坏情况下,其复杂度为文本长与模式串长的乘积。当在文本中搜寻多个匹配时,其具有线性时间的平均复杂度(与文本串长、模式串长、模式串所有匹配总长三者的和成线性关系)。
导读
输入一个字符串形式的正整数,如何把它转化成数字的形式?
String s = "8264"; int number = 0; for (int i = 0; i < s.length(); i++) { // 将字符转化成数字 number = 10 * number + (s.charAt(i) - '0'); System.out.println(number); } // 打印输出: // 8 // 82 // 826 // 8264
其实你想下,你把一个字符串对象转化成了一个数字,这是什么?这就是你设计的一个哈希算法,生成的数字就可以认为是字符串的哈希值。在滑动窗口中快速计算窗口中元素的哈希值,叫做滑动哈希技巧。
如何在数字的最低位添加数字以及如何删除数字的最高位,用 R
表示数字的进制数,用 L
表示数字的位数,就可以总结出如下公式:
/* 在最低位添加一个数字 */ int number = 8264; // number 的进制 int R = 10; // 想在 number 的最低位添加的数字 int appendVal = 3; // 运算,在最低位添加一位 number = R * number + appendVal; // 此时 number = 82643 /* 在最高位删除一个数字 */ int number = 8264; // number 的进制 int R = 10; // number 最高位的数字 int removeVal = 8; // 此时 number 的位数 int L = 4; // 运算,删除最高位数字 number = number - removeVal * R^(L-1); // 此时 number = 264
187. 重复的DNA序列
DNA序列 由一系列核苷酸组成,缩写为 'A'
, 'C'
, 'G'
和 'T'
.。
- 例如,
"ACGAATTCCG"
是一个 DNA序列 。
在研究 DNA 时,识别 DNA 中的重复序列非常有用。
给定一个表示 DNA序列 的字符串 s
,返回所有在 DNA 分子中出现不止一次的 长度为 10
的序列(子字符串)。你可以按 任意顺序 返回答案。
暴力破解:
class Solution { public List<String> findRepeatedDnaSequences(String s) { int n = s.length(); // 记录出现过的子串 HashSet<String> seen = new HashSet<>(); HashSet<String> res = new HashSet<>(); for(int i = 0; i + 10 <= n; i++){ String subStr = s.substring(i, i + 10); if(seen.contains(subStr)){ // 之前出现过,找到一个重复的 res.add(subStr); } else{ // 之前没出现过,加入集合 seen.add(subStr); } } return new ArrayList<String>(res); } }
这个算法肯定是没问题的,只是时间复杂度略高。假设 s 的长度为 N,目标子串的长度为 L(本题 L = 10),for 循环遍历 s 的 O(N) 个字符,对每个字符都要截取长度为 L 的子字符串,所以这个算法的时间复杂是 O(NL)
优化的关键在于,我们能不能不要真的把子字符串生成出来,而是用一些其他形式的唯一标识来表示滑动窗口中的子字符串,并且还能在窗口滑动的过程中快速更新?
把 AGCT 四种字符等价为 0123 四个数字,那么长度为 L = 10 的一个碱基序列其实就可以等价为一个十位数,这个数字可以唯一标识一个子串。而且窗口移动的过程,其实就是给这个数字的最低位添加数字,并删除最高位数字的过程,回顾之前的讲解,添加和删除数字的运算就是两个公式,可以在 O(1) 的时间完成。
不要在哈希集合中直接存储子串了,而是存储子串对应的十位数。因为一个十位数可以唯一标识一个子串,所以也可以起到识别重复的作用。
这样,我们就避免了直接生成子串存入集合,而是生成一个十位数来表示子串,而且生成这个十位数的时间花费为 O(1),从而降低了匹配算法的时间复杂度。
class Solution { public List<String> findRepeatedDnaSequences(String s) { // 先把字符串转化成四进制的数字数组 int[] nums = new int[s.length()]; for(int i = 0; i < nums.length; i++){ switch(s.charAt(i)){ case 'A': nums[i] = 0; break; case 'G': nums[i] = 1; break; case 'C': nums[i] = 2; break; case 'T': nums[i] = 3; break; } } // 记录重复出现的哈希值 HashSet<Integer> seen = new HashSet<>(); // 记录重复出现的字符串结果 HashSet<String> res = new HashSet<>(); // 数字位数 int L = 10; // 进制 int R = 4; // 存储 R^(L - 1) 的结果 int RL = (int) Math.pow(R, L - 1); // 维护滑动窗口中字符串的哈希值 int windowHash = 0; // 滑动窗口代码框架,时间 O(N) int left = 0, right = 0; while(right < nums.length){ // 扩大窗口,移入字符,并维护窗口哈希值(在最低位添加数字) windowHash = R * windowHash + nums[right]; right++; // 当子串的长度达到要求 if (right - left == L) { // 根据哈希值判断是否曾经出现过相同的子串 if (seen.contains(windowHash)) { // 当前窗口中的子串是重复出现的 res.add(s.substring(left, right)); } else { // 当前窗口中的子串之前没有出现过,记下来 seen.add(windowHash); } // 缩小窗口,移出字符,并维护窗口哈希值(删除最高位数字) windowHash = windowHash - nums[left] * RL; left++; } } // 转化成题目要求的 List 类型 return new LinkedList<>(res); } }
这个算法一般情况下的平均时间复杂度是 O(N)
,极端情况下的时间复杂度会退化成 O(NL)
Rabin-Karp 算法
字符串匹配算法
// 在文本串 txt 中搜索模式串 pat 的起始索引 int search(String txt, String pat) { int N = txt.length(), L = pat.length(); for (int i = 0; i + L <= N; i++) { String subStr = txt.substring(i, i + L); if (subStr.equals(pat)){ // 在 txt 中找到模式串 pat,返回起始索引 return i; } } // txt 中不存在模式串 pat return -1; }
们不要每次都去一个字符一个字符地比较子串和模式串,而是维护一个滑动窗口,运用滑动哈希算法一边滑动一边计算窗口中字符串的哈希值,拿这个哈希值去和模式串的哈希值比较,这样就可以避免截取子串,从而把匹配算法降低为 O(N)
,这就是 Rabin-Karp 指纹字符串查找算法的核心逻辑
// 文本串 String txt; // 模式串 String pat; // 需要寻找的子串长度为模式串 pat 的长度 int L = pat.length(); // 仅处理 ASCII 码字符串,可以理解为 256 进制的数字 int R = 256; // 存储 R^(L - 1) 的结果 int RL = (int) Math.pow(R, L - 1); // 维护滑动窗口中字符串的哈希值 int windowHash = 0; // 计算模式串的哈希值 long patHash = 0; for (int i = 0; i < pat.length(); i++) { patHash = R * patHash + pat.charAt(i); } // 滑动窗口代码框架 int left = 0, right = 0; while (right < txt.length()) { // 扩大窗口,移入字符(在最低位添加数字) windowHash = R * windowHash + txt[right]; right++; // 当子串的长度达到要求 if (right - left == L) { // 根据哈希值判断窗口中的子串是否匹配模式串 pat if (patHash == windowHash) { // 找到模式串 print("找到模式串,起始索引为", left); return left; } // 缩小窗口,移出字符(删除最高位数字) windowHash = windowHash - txt[left] * RL; left++; } } // 没有找到模式串 return -1;
不过呢,这段代码实际运行的时候会有一个严重的问题,那就是整型溢出。
==> 无论一个数字多大,你让它除以 Q,余数一定会落在 [0, Q-1] 的范围内。所以我们可以设置一个 Q,用求模的方式让 windowHash 和 patHash 保持在 [0, Q-1] 之间,就可以有效避免整型溢出。
整型溢出的问题倒是解决了,但新的问题又来了:求模之后的哈希值不能和原始字符串一一对应了,可能出现一对多的情况,即哈希冲突
==> 用拉链法和线性探查法解决了哈希表的哈希冲突。对于 Rabin-Karp 算法来说,当发现 windowHash == patHash 时,使用暴力匹配算法检查一下窗口中的字符串和 pat 是否相同就可以避免哈希冲突了。因为希冲突出现的概率比较小。
Rabin-Karp 字符串查找算法
// Rabin-Karp 指纹字符串查找算法 int rabinKarp(String txt, String pat) { // 位数 int L = pat.length(); // 进制(只考虑 ASCII 编码) int R = 256; // 取一个比较大的素数作为求模的除数 long Q = 1658598167; // R^(L - 1) 的结果 long RL = 1; for (int i = 1; i <= L - 1; i++) { // 计算过程中不断求模,避免溢出 RL = (RL * R) % Q; } // 计算模式串的哈希值,时间 O(L) long patHash = 0; for (int i = 0; i < pat.length(); i++) { patHash = (R * patHash + pat.charAt(i)) % Q; } // 滑动窗口中子字符串的哈希值 long windowHash = 0; // 滑动窗口代码框架,时间 O(N) int left = 0, right = 0; while (right < txt.length()) { // 扩大窗口,移入字符 windowHash = ((R * windowHash) % Q + txt.charAt(right)) % Q; right++; // 当子串的长度达到要求 if (right - left == L) { // 根据哈希值判断是否匹配模式串 if (windowHash == patHash) { // 当前窗口中的子串哈希值等于模式串的哈希值 // 还需进一步确认窗口子串是否真的和模式串相同,避免哈希冲突 if (pat.equals(txt.substring(left, right))) { return left; } } // 缩小窗口,移出字符 windowHash = (windowHash - (txt.charAt(left) * RL) % Q + Q) % Q; // X % Q == (X + Q) % Q 是一个模运算法则 // 因为 windowHash - (txt[left] * RL) % Q 可能是负数 // 所以额外再加一个 Q,保证 windowHash 不会是负数 left++; } } // 没有找到模式串 return -1; }
补充:模运算的两个运算法则
X % Q == (X + Q) % Q (X + Y) % Q == (X % Q + Y % Q) % Q
算法应用
此算法的一个实际应用为内容相似度检验(如论文查重)。在给定原材料与待查重论文的情况下,此算法可以迅速在论文中搜寻原材料中的句子,同时忽略诸如大小写与标点等细节。
–end–