图 1:本文封面,点明方法「哈希签名分组」。
先说明属性:这是一篇历史题目复盘,题目出自本地历史面试资料(给定一个字典,用户输入一个单词,找出它在字典里的全部兄弟单词——字母组成完全相同、仅顺序不同的词,比如 listen 与 silent),与任何公司的当前招聘无关。
先把题意变成接口:字典是静态的,查询是多次的。如果每查一个词就遍历整个字典、逐词比较「是不是字母重排」,每次查询都是 O(n·L)——n 是词数、L 是词长,字典一大就不可接受。面试官想听到的,是你能不能把「反复查询」的压力转移到「一次预处理」上。
核心转念:互为兄弟的单词,本质是同一个等价类。
listen、silent、enlist 是三个不同的字符串,但它们是同一堆字母的不同排列。判断等价关系不需要两两比较,只需要给每个等价类找一个唯一代表——签名。把每个单词映射成签名,签名相同的就是兄弟。问题从「比较」变成了「分组」。
签名怎么设计?最直观的方案:把单词的字母排序。listen 排序后是 eilnst,silent 排序后也是 eilnst——字母排序是字母多重集的规范化形式,等价类的所有成员必然收敛到同一个签名。
三步流程。
第一步,预处理。遍历字典,每个单词算出签名,以签名为 key 把单词塞进哈希表的桶里。字典有 n 个单词、平均词长 L,这步是 O(n·L log L)——排序主导。
第二步,查询。给定单词算一次签名,O(L log L),再查一次哈希表,平均 O(1),桶里的单词就是全部兄弟。查询代价几乎与字典大小无关。
第三步,输出。桶内单词即答案,记得剔除查询词本身——自己不算自己的兄弟。
实测一遍。
用 Node.js 22.15.0 直接运行(node brother-words.js):
'use strict';
// 兄弟单词:字母组成完全相同、仅顺序不同(如 listen 与 silent)
const dictionary = [
'listen', 'silent', 'enlist',
'abc', 'bca', 'cab',
'hello', 'world', 'dog',
];
// 签名:把字母排序,互为兄弟的单词签名必然相同
const sign = (word) => [...word].sort().join('');
// 第一步:预处理,签名 -> 该签名的全部单词
const buckets = new Map();
for (const word of dictionary) {
const s = sign(word);
if (!buckets.has(s)) buckets.set(s, []);
buckets.get(s).push(word);
}
// 第二步:查询,算一次签名,查一次哈希表
function brothers(word) {
const bucket = buckets.get(sign(word)) ?? [];
return bucket.filter((w) => w !== word);
}
for (const query of ['listen', 'abc', 'hello', 'unknown']) {
const result = brothers(query);
console.log(`${query} 的兄弟单词:${result.length} 个${result.length ? '(' + result.join('、') + ')' : ''}`);
}
// 签名变体:字母计数数组,省去排序
const signByCount = (word) => {
const count = new Array(26).fill(0);
for (const ch of word) count[ch.charCodeAt(0) - 97] += 1;
return count.join('#');
};
console.log('两种签名一致:', sign('listen') === sign('silent'), signByCount('listen') === signByCount('silent'));
实测输出:
listen 的兄弟单词:2 个(silent、enlist)
abc 的兄弟单词:2 个(bca、cab)
hello 的兄弟单词:0 个
unknown 的兄弟单词:0 个
两种签名一致: true true
三个兄弟组全部命中,查询词自身被正确剔除,字典外的词返回空。
追问与变体。
「签名一定要排序吗?」不一定。字母计数数组(26 个槽位统计每个字母出现次数)同样唯一标识一个字母多重集,把排序的 O(L log L) 降到 O(L)——上面代码的最后一行验证了两种签名等价。「字典特别大、内存放不下怎么办?」签名分组的思想不变,预处理搬到磁盘或分布式框架上做,签名当分组的 key。「这个思路还能用在哪?」这是它比题目本身更值钱的部分:把等价类映射到同一个 key 是数据处理的通用手法——语料去重时的规范化签名、近重复检测里的 MinHash,都是同一个思想的亲戚。
图 2:排序签名分组的三步流程与复杂度。
易错点清单。
一,每次查询都全表扫描比较,不预处理——多次查询场景下这是最大的失分点。二,输出里忘了剔除查询词本身,把「兄弟」答成「同组」。三,忽视规范化前提:大小写、前后空白、Unicode 形态(全角半角、重音字符)不一致时,先归一化再算签名,否则 hello 和 Hello 被分成两桶。四,把这套方法套到中文上——中文按「字」排序不是合法签名,汉字重排不构成词语等价类,场景不成立。
回到面试现场,建议的答题节奏:先确认题意与使用场景(字典静态、查询多次),一句话否定逐词比较,然后抛出「等价类找签名」的转念,给出排序签名方案与复杂度,再主动递出计数数组变体和内存放不下时的扩展方向。数据处理题的区分度,一半在解法,一半在你对「预处理换查询」这笔账的自觉。
你在工作里用过「签名分组」这招吗?去重、聚类还是别的场景?欢迎分享。