[187].重复的 DNA 序列

  • 题目
  • 函数原型
  • 哈希表
  • 滚动哈希
  • Rabin-Karp 算法



 


题目

题目链接:https://leetcode-cn.com/problems/repeated-dna-sequences/

寻找长度 10 或以上、出现次数 2 次或以上的子串。
 


函数原型

vector<string> findRepeatedDnaSequences(string s) {}

 


哈希表

从头到尾扫描一次,看所有长度为 10 的子串,放入一个哈希表中,一旦发现某个长度为 10 的子串出现 2 次,这个就是返回结果。

class Solution {
    const int L = 10;
public:
    vector<string> findRepeatedDnaSequences(string s) {
        vector<string> ans;
        unordered_map<string, int> cnt;
        int n = s.length();
        for (int i = 0; i <= n - L; ++i) {
            string sub = s.substr(i, L);          // substr 截取长期为 10 的子串
            if (++cnt[sub] == 2) {                // 重复出现
                ans.push_back(sub);               // 返回结果
            }
        }
        return ans;
    }
};

 


滚动哈希

我们发现第一个子串、第二个子串之间,相同的字符有 9 个,只有 1 个字符有区别。

[187].重复的 DNA 序列_i++

[187].重复的 DNA 序列_字符串_02

我们不再整体比较子串,而是看子串的哈希值,从第 1 个子串的哈希值计算出第 2 个子串的哈希值,从第 2 个子串的哈希值计算出第 3 个子串的哈希值。

因为每个字符只有 [187].重复的 DNA 序列_字符串_03

  • [187].重复的 DNA 序列_子串_04
  • [187].重复的 DNA 序列_子串_05
  • [187].重复的 DNA 序列_子串_06
  • [187].重复的 DNA 序列_i++_07

在程序里,[187].重复的 DNA 序列_c++_08

我们只需要保持一个长度为 10 的窗口即可,第 1 个字符删除,再最后位置添加 1 个字符。

[187].重复的 DNA 序列_字符串_09

那这个过程怎么写成计算公式?

  • 添加字符:[187].重复的 DNA 序列_c++_10
  • 删除字符:[187].重复的 DNA 序列_子串_11

如 s[i] = 2:

  • 添加字符:[187].重复的 DNA 序列_c++_12
  • 删除字符:[187].重复的 DNA 序列_子串_13

整个过程以此类推,这种方式叫滚动哈希。

class Solution {
public:
    vector<string> findRepeatedDnaSequences(string s) {
        vector<string> ans;
        unordered_map<long, int> cnt;

        if(s.length() < 10)
            return ans;

        int *map = new int[256];
        map['A'] = 1, map['C'] = 2, map['G'] = 3, map['T'] = 4;
        long hash = 0, ten9 = (long)1e9;

        for(int i=0; i<9; i++)                    // 得到长度为 9 的子串
            hash = hash * 10 + map[s[i]];         // 添加字符

        for(int i=9; i<s.length(); i++){          // 滚动逻辑
            hash = hash * 10 + map[s[i]];         // 获取一个长度为 10 的子串
            if( cnt[hash] >= 1 )                  // 重复出现
                ans.push_back( s.substr(i-9, 10) );
            else
                cnt[hash] ++;

            hash = hash - map[ s[i-9] ] * ten9;          // 减去最高位值,新的 9 位子串
        }

		// 发现案例 AAA···AAA 不能通过,对结果数组去重
		sort(ans.begin(), ans.end());
        // it 是一个迭代器,unique函数将重复出现的元素放到数组末尾
        // 并返回这部分元素第一个出现的位置,最终从该位置开始删除后面所有元素即可
		auto it = unique(ans.begin(), ans.end());
		ans.erase(it, ans.end());
        return ans;
    }
};

 


Rabin-Karp 算法

[187].重复的 DNA 序列_子串_14


滚动哈希,是一个滑动窗口 t + 哈希,窗口 t 固定长度是 10。

[187].重复的 DNA 序列_子串_15


那么,我们就用变量 t.length 代替 10、t.length - 1 代替 9。

[187].重复的 DNA 序列_c++_16


那之前的操作添加、删除字符也可以更改:

[187].重复的 DNA 序列_i++_17

现在添加新字符 t.length 该怎么算呢?

首先 s[i] 不止 4 种可能,默认字符串可能任意字符组成,有 256 种可能。

其次,滑动窗口的长度可能不止 10 个了,所以为了防止整型溢出,我们得求余处理。

  • 添加字符:hash = (hash * 256+s[i]) % MOD

此时,hash 是一个长度为 t.length 的字符串的哈希值。

  • 删除字符:hash = hash - s[i - t.length + 1] * (B ^ (t.length - 1) ) % MOD + MOD

加 MOD 是因为,hash - s[i - t.length + 1] 有可能为负数。

比如时钟里3点向前 8 个小时,3 - 8 = -5,负数,-5 + 12(MOD)= 7,在 12 系统里,-5 就是 7。

但也有一个问题,hash - s[i - t.length + 1] 有可能为正数,后面再加一个 MOD 可能会溢出,所以,最后再一次求模。

  • 删除字符:hash = (hash - s[i - t.length + 1] * (B ^ (t.length - 1) ) % MOD + MOD) % MOD

这里使用滚动哈希求解字符串匹配问题,对应的这个思路的算法,就叫 Rabin-Karp 算法。

class Solution {
public:
    vector<string> Rabin-Karp(string s, string t) {
		if(s.length() < 0)	return s;

		long thash = 0, MOD = (long)1e9 + 7, B = 256;
		for(int i=0; i<t.length(); i++)
			thash = (thash * B + s[i]) % MOD;
		
		long hash = 0, P = 1;
		for(int i=0; i<t.length()-1; i++)
			P = P * B % MOD;

		for(int i=0; i<t.length()-1; i++)
			hash = (hash * B + s[i]) % MOD;
		
		for(int i=t.length()-1; i<s.length(); i++) {
			hash = (hash * B + s[i]) % MOD;
			if( hash == thash && equals(s, i-t.length()+1, t) )	
				return i - t.length() + 1;
			hash = (hash - s[i - t.length() + 1] * P % MOD + MOD) % MOD;
		}
		return -1;
	}

	bool equals(string s, int l, string t) {
		for(int i=0; i<t.length(); i++)
			if(t[i] != s[i+l]) return false;
				return true;
	}
};