位运算 + 滚动哈希实现轻量文本搜索引擎
本节目标
- 理解位运算在"集合表示 / 去重"中的妙用
- 手写滚动哈希(Rabin-Karp)做子串检索
- 体会"算法实现高级功能"的整合思路
位运算妙用:用一个整数的每一个 bit 表示"某元素是否在集合",适合小规模去重/状态压缩。
// 运行环境:Node.js 14+
// 保存为 dsa-l26.js,执行:node dsa-l26.js
// 用位掩码表示集合:第 i 位=1 表示元素 i 在集合内
function setOps() {
let A = 0, B = 0
for (const i of [0, 2, 4]) A |= (1 << i) // 加入元素
for (const i of [1, 2, 3]) B |= (1 << i)
const union = A | B // 并集
const inter = A & B // 交集
const diff = A & ~B // 差集(在 A 不在 B)
return { union, inter, diff }
}
console.log(setOps()) // { union: 31, inter: 4, diff: 21 }
// 解释:A={0,2,4}=bits 10101=21, B={1,2,3}=bits 01110=14
// union=31(11111), inter=4(00100=元素2), diff=21(10101=元素0,2,4)
// 两个集合相似度(Jaccard):交集大小 / 并集大小
function jaccard(a, b) {
const inter = (a & b).toString(2).split('').filter(c => c === '1').length
const union = (a | b).toString(2).split('').filter(c => c === '1').length
return union ? inter / union : 0
}
console.log(jaccard(21, 14).toFixed(2)) // 0.20(交集1个/并集5个)滚动哈希(Rabin-Karp):把字符串当作"_base 进制数"求模,窗口滑动时 O(1) 更新哈希,高效多模式/子串检索。
// 在文本 txt 中找出所有出现模式 pat 的起始下标
function rabinKarp(txt, pat) {
const base = 131, mod = 1e9 + 7
const m = pat.length, n = txt.length
if (m > n) return []
const pow = (() => { let p = 1; for (let i = 0; i < m - 1; i++) p = (p * base) % mod; return p })()
const hash = s => { let h = 0; for (const ch of s) h = (h * base + ch.charCodeAt(0)) % mod; return h }
const ph = hash(pat)
let th = hash(txt.slice(0, m))
const res = []
if (th === ph && txt.slice(0, m) === pat) res.push(0)
for (let i = m; i < n; i++) {
// 滑出 txt[i-m],滑入 txt[i]
th = (th - txt.charCodeAt(i - m) * pow % mod + mod) % mod
th = (th * base + txt.charCodeAt(i)) % mod
if (th === ph && txt.slice(i - m + 1, i + 1) === pat) res.push(i - m + 1)
}
return res
}
console.log(rabinKarp('abracadabra', 'abra')) // [0, 7]名词解释
- 位运算(Bitwise):直接对整数的二进制位操作:
|或、&与、^异或、~取反、<<左移。用一个数的 bit 表示集合,是"状态压缩"的核心技巧。 - 滚动哈希(Rolling Hash):把字符串视为进制数求模;窗口滑动时"去掉最左字符贡献、加上最右字符贡献",O(1) 更新哈希。Rabin-Karp 借此把子串匹配均摊到 O(n)。
- Jaccard 相似度:两集合相似度 = |交集| / |并集|,取值 [0,1]。常用于文本/推荐去重与近邻判断。
课后练习
- 位掩码
A | (1 << i)为什么能"加入元素 i"?- 答案:
1 << i是第 i 位为 1 的掩码,|或运算把 A 的第 i 位置 1,其余位不变,正好表示"集合中加入 i"。
- 答案:
rabinKarp里为什么哈希相等后还要txt.slice(...) === pat再确认?- 答案:哈希可能因取模发生"哈希冲突"(不同串同余),所以哈希相等只是疑似匹配,必须用真实子串比对一次排除冲突,保证正确。
总结
这一节是整门课的"收官彩蛋"——它把前面零散的算法(位运算、哈希、字符串)缝合成一个能用的小工具,恰好说明"高级功能=基础算法的组合"。位运算最迷人之处是用一个整数的 32 个 bit 就能表示一个集合,状态压缩在棋盘/子集枚举里威力巨大;而滚动哈希则展示了"如何把字符串比较从 O(模式长) 降成 O(1) 均摊",是多模式串检索、 plagiarism 检测、DNA 匹配的基石。我想强调 Rabin-Karp 里那个常被忽略的细节:哈希相等不等于真的相等,必须"再比一次"排除冲突——这是所有哈希类算法的通用纪律。整门 DSA 走到这里,希望你体会到的不是"我又背了俩算法",而是"我能用这些零件搭出真正有用的东西"。算法学习的最终目的,从来不是刷题,而是拥有的这套"把复杂问题拆成标准零件"的思维肌肉。