LeetCode 187. 重复的DNA序列
题目描述

题意分析
找出所有出现至少两次、长度恰好为 10 的连续 DNA 片段。不同出现位置可以重叠,但同一个片段在答案中只返回一次,返回顺序不限。
每个片段只包含
A、C、G、T四种字符,可以把它们压缩成一个整数,用整数记录出现次数。
解法:固定窗口 + 2 位滚动编码
核心思路
[!blue]
将
A、C、G、T分别映射为0、1、2、3,每个字符恰好占两位。长度为 10 的窗口便对应一个 20 位整数,范围是0到2^20-1,可以直接作为计数数组的下标。这是固定长度的四进制表示:每两位都对应唯一的字符,按顺序解码就能恢复整个窗口。因此不同的完整窗口不会得到相同编码,即使开头含有编码为 0 的
A也不影响唯一性。扫描到新字符时,将旧
code左移两位,为新字符空出最低两位,再用按位或放入新编码。最后与MASK = (1 << 20) - 1按位与,只保留低 20 位:最早的字符被移到掩码之外,留下的正好是最近 10 个字符,无需重新扫描整个窗口。下标达到 9 后才形成第一个完整窗口,此时开始更新
count[code]。次数从 1 变为 2 时,把当前长度为 10 的子串加入答案;之后次数继续增加,但不再收录。这样同时保证每个答案确实重复,并且只输出一次。
解题步骤
- 准备大小为
2^20的计数数组、低 20 位掩码,以及初始为 0 的code。- 依次读入每个字符,执行“左移两位、加入新编码、保留低 20 位”的滚动更新。
- 若当前位置
i < 9,只更新编码,不计数;这时得到的还不是长度为 10 的片段。- 从
i = 9开始,将count[code]加一。若它恰好等于 2,收录区间[i-9, i+1)的子串。- 扫描结束后返回答案。字符串不足 10 个字符时不会统计窗口,结果自然为空;恰好 10 个字符时最多出现一次,也不会加入答案。
代码实现
class Solution {
private static final int WINDOW = 10;
private static final int MASK = (1 << (WINDOW * 2)) - 1;
public List<String> findRepeatedDnaSequences(String s) {
List<String> res = new ArrayList<>();
int[] count = new int[1 << (WINDOW * 2)];
int code = 0;
for (int i = 0; i < s.length(); i++) {
// 左移加入新字符后只留低二十位,准确表示最近十个字符。
code = ((code << 2) | encode(s.charAt(i))) & MASK;
// 窗口满十个字符才计数;只在第二次出现时报告一次。
if (i >= WINDOW - 1 && ++count[code] == 2) {
res.add(s.substring(i - WINDOW + 1, i + 1));
}
}
return res;
}
// 四种字符显式映射到零至三,每个字符严格占两位。
private int encode(char nucleotide) {
return switch (nucleotide) {
case 'A' -> 0;
case 'C' -> 1;
case 'G' -> 2;
// 题目保证剩余字符只会是 'T'。
default -> 3;
};
}
}
func findRepeatedDnaSequences(s string) []string {
const window = 10
const mask = 1<<(window*2) - 1
count := make([]int, 1<<(window*2))
res := make([]string, 0)
code := 0
for i := 0; i < len(s); i++ {
// 左移加入新字符后只留低二十位,准确表示最近十个字符。
code = (code<<2 | encodeNucleotide(s[i])) & mask
// 窗口满十个字符才计数;只在第二次出现时报告一次。
if i >= window-1 {
count[code]++
if count[code] == 2 {
res = append(res, s[i-window+1:i+1])
}
}
}
return res
}
// 四种字符显式映射到零至三,每个字符严格占两位。
func encodeNucleotide(nucleotide byte) int {
switch nucleotide {
case 'A':
return 0
case 'C':
return 1
case 'G':
return 2
// 题目保证剩余字符只会是 'T'。
default:
return 3
}
}
复杂度分析
设字符串长度为
n。
- 时间复杂度:$O(n+2^{20})$,包含计数数组的初始化。每个字符只进行常数次位运算与计数,收录的子串长度固定为 10。
- 空间复杂度:$O(2^{20})$,用于计数数组,不计返回结果。窗口长度固定为 10,所以这部分空间不随
n增长。
关键点总结
[!green]
- 四种字符恰好用两位表示,十个字符得到可直接索引的 20 位编码。
- 左移加入新字符,掩码删除最早字符,每一步都准确表示当前窗口。
- 只有完整窗口才参与计数,只在第二次出现时收录答案。
易错点总结
[!yellow]
- 直接用字符减去
A作为编码:四种字母在字母表中并不连续,结果可能超过两位,必须映射到0...3。- 忘记保留低 20 位:旧窗口之外的字符仍会留在编码中,不能准确代表最近 10 个字符,还可能越过计数数组范围。
- 窗口未满就计数:短前缀与前面补了
A的完整窗口可能具有相同整数编码,固定长度的唯一性只适用于完整窗口。- 次数大于等于 2 就收录:第三次及之后出现会重复加入答案,应判断是否恰好变成 2。
- 子串右边界写成
i:Java、Go 截取子串的右端不包含在内,包含当前字符需要写成i+1。
相似题目
| 题目 | 难度 | 关联与区别 |
|---|---|---|
| 438. 找到字符串中所有字母异位词 | 中等 | 同样扫描固定长度窗口,本题签名必须保留字符顺序,频次表无法区分不同DNA序列。 |
| 1044. 最长重复子串 | 困难 | 原题寻找任意长度的最长重复子串,本题长度固定为10,可用固定窗口编码判重。 |
转载与许可
许可
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处!