通讯录整理实战:哈希索引、归一化与并查集去重

发布时间:2026/10/31 10:10:11
通讯录整理实战:哈希索引、归一化与并查集去重 前几天有位朋友把手机里导出的通讯录丢给我五个来源合在一起三千七百多条两台旧手机、一张存储卡、一份协作表格、还有一个群里别人转发过来的名单。打开一看同一个人最多出现了四次姓名写法各不相同号码有的带区号有的不带有的中间塞着空格和短横线还有一个张三公司和张三-公司其实是同一个人。他原本想用表格软件筛一筛手工合并折腾一小时之后放弃了。这件事让我想起数据结构课设里那道被无数人当作入门练手的经典题目——通讯录整理。很多人觉得它简单无非增删改查。真正做过一份脏数据的人会知道这道题的价值根本不在增删改查本身而在于它把顺序表与链表的取舍、哈希索引的构建、字符串归一化、稳定排序、并查集去重、内存与迭代器生命周期这些平时分散在各章的知识点全塞进了一个能用脚跑起来的具体场景里。下面我按自己实际做这件事的顺序把选型理由、核心代码、踩过的坑和实测数据摊开讲正在做课设的同学可以对照着改自己的实现手上真有脏数据的开发者也可以直接拿去用。1. 别急着写结构体先把整理拆成可计算的操作1.1 三个真实场景倒推出的操作画像新手拿到通讯录整理这四个字第一反应通常是定义一个Contact结构体然后开始写add、delete、search。这个顺序其实反了因为你根本不知道这三个函数各自要跑多少次、数据规模多大。我把朋友的实际操作过程录了一遍抽象出三个高频场景。第一个场景是批量导入合并从五个来源读入三千多条记录两两比对判重同一人的多条记录合并成一条字段取并集而不是覆盖。这个场景的特点是一次性、大体量、写操作集中对单次插入的延迟不敏感但对整体的判重效率极其敏感——如果用朴素的两两比对三千条记录要做约四百五十万次比较每次比较还涉及字符串归一化跑完要几十秒。第二个场景是按号码或姓名查找朋友常常记得有个姓李的做装修的或者只有一串尾号。查找的特征是高频、小结果集、条件模糊对判重逻辑完全无关但对索引结构要求很高。第三个场景是分组导出按公司分组、按姓氏首字母分组、按最近联系时间倒序。这是典型的范围与排序需求需要有序遍历能力。把这三个场景摆在一起一个很清晰的结论就出来了没有任何单一数据结构能同时把三件事都做到最优。这也是后面要做混合结构的根本原因。1.2 把模糊需求翻译成复杂度清单我习惯在动手前先列一张这样的表把感觉变成指标。这张表直接决定了后面每一个选型决策也是我认为课设报告里最该写、却最常被忽略的部分。操作调用频次单次数据规模复杂度目标备注导入单条记录约 3700 次1O(1) 均摊写密集允许偶发扩容抖动判重查询约 3700 次1O(1) 期望按归一化号码主键姓名模糊查找数十次/天1O(k)k 为命中数允许退化为 O(n) 前缀扫描全量排序每次导出3700O(n log n)必须稳定删除单条少1O(1)前提是已定位到节点全量遍历每次导出3700O(n)常数要小缓存友好性影响明显注意最后一行的常数要小。三千七百条数据在理论复杂度上根本不算什么随便什么结构都能在毫秒级跑完真正的差别在于常数因子和缓存命中。很多课设之所以把链表写得比数组快是因为测试数据只有五十条量级完全掩盖了真实差异。这一点我在第 2 节会给出实测。指标定下来之后还有一个容易被忽略的维度数据的生命周期。通讯录是长期驻留的数据程序跑起来之后可能被反复查询几千次但只在最开始集中写入一次。这种一次写、多次读的偏斜直接支持了用哈希表建索引、用连续内存做主存储的组合而不是教科书里那种因为要频繁插入删除所以必须用链表的教条结论。2. 存储骨架选型二维数组扛不住但链表也未必是答案2.1 顺序表、单链表、哈希表在同一份数据上的真实表现先讲顺序表。把Contact结构体放进std::vector最大的好处是内存连续遍历时 CPU 的预取器能一路顺下去实测遍历三千七百条只要零点几毫秒。代价是中间插入和删除要搬移后续所有元素。但在通讯录这个场景里插入几乎全部发生在末尾导入阶段删除是低频操作所以搬移成本被彻底摊薄了。很多人一听频繁插入删除要用链表就条件反射其实要看插入的位置分布。再讲单链表。教科书写它是因为插入删除 O(1)但这个 O(1) 有个要命的前提你得先拿到前驱节点。通讯录里最常见的删除是按号码删也就是先查找再删除查找本身还是 O(n)除非你在链表外面再挂一层索引。更麻烦的是链表节点在堆上分散分配遍历时每一步都可能 cache miss实测遍历同样是三千七百条链表比 vector 慢三到四倍这个差距在小数据量下看不出来数据量上万之后会非常明显。哈希表的问题在于它丢掉了顺序。std::unordered_map的遍历顺序是不确定的而且不同实现的桶数量变化会导致顺序变化。你没法在它上面做按公司分组或者按姓氏排序也没法做前缀查询。它能干的事只有一件给定一个精确的键极快地吐出对应的值。平衡树或者跳表可以同时提供 O(log n) 查找和有序遍历是最全能的方案但实现复杂度高而且对三千七百这个量级来说O(log n) 和 O(1) 的绝对差值也就几十纳秒不值当。这里的原则很朴素在数据规模不足以让渐进复杂度成为瓶颈时优先选实现简单、常数小的结构。2.2 我最终落地的组合连续主存储 双哈希索引 惰性删除最后的方案是这样的三层结构我用 C 写的用其他语言思路完全一致struct Contact { uint32_t id; // 稳定唯一标识永不复用 uint8_t state; // 0有效 1已合并 2已删除 std::string name; std::string phone; // 归一化后的主键候选 std::string phone_raw; // 原始串仅用于展示和回溯 std::string org; std::string note; int64_t updated_at; // 合并时取最大用于冲突仲裁 }; std::vectorContact contacts; // 主存储只追加 std::unordered_mapstd::string, std::vectoruint32_t by_phone; // 号码 - id 列表 std::unordered_mapstd::string, std::vectoruint32_t by_name; // 姓名 - id 列表三个关键设计点。第一主存储用vector且只追加不真删删除和合并都只是把state置位这是典型的惰性删除。好处是索引里存的uint32_t下标永远不会失效避免了删除元素导致后续下标全部前移这个经典错误。第二索引的 value 是vectoruint32_t而不是单个整数因为同一个号码可能对应多条待合并记录用列表能兼容一人多号和一号多人两种情况后者在家庭共用号码、公司总机场景里非常常见。第三id和数组下标分离id单调递增不复用这样即使以后改成真正的删除加压缩也不会出现旧引用指向新记录的问题。提示惰性删除的代价是内存永远不释放。如果你的场景是长期运行的服务需要定期做一次压缩compaction把所有state 0的记录搬到新数组重建两张索引。通讯录这种一次性整理场景压缩可以省略。2.3 节点里那几个字节的讲究结构体的字段顺序不是随便排的。uint32_t占 4 字节uint8_t占 1 字节后面三个std::string各占 32 字节常见实现是 32 字节含短字符串优化缓冲。如果按声明顺序排id(4) state(1) 3 字节填充 三个 string(96) int64_t(8)总大小是 112 字节浪费了 3 字节填充。把state挪到int64_t后面能省掉填充但说实话三千七百条记录乘 3 字节也就 11KB完全不值得为此牺牲可读性。我提这一点是因为见过太多课设报告在纠结内存对齐却把真正的瓶颈——每个节点独立new出来的链表——放过去了。优化要打在热点上而不是打在看起来专业的地方。真正值得留意的是短字符串优化。大多数标准库的std::string会把 15 个字符以内的串直接存在栈上的对象体内不额外分配堆内存。中文姓名通常 6 到 9 个字节UTF-8 下一个汉字 3 字节手机号 11 个字符都落在短字符串范围内所以这部分实际上没有堆分配开销拷贝成本也很低。这一点对第 5 节讲的深拷贝问题有直接影响。3. 判重合并规则本身比算法难写十倍3.1 姓名相同不等于同一个人我最初的判重规则蠢得可笑name相同就算重复。结果第一条就炸了——名单里有三个王伟分别是装修师傅、快递员和某公司的销售。反过来同一个人写成王伟王 伟王伟顺丰三种形式按姓名精确匹配反而判不出来。所以判重必须以号码为主键、以姓名为辅证而且要分层次处理强判重归一化后的号码完全相等直接认为是同一人这是唯一可靠的自动规则。弱判重号码不同但姓名归一化后相等且公司或备注有交集标记为疑似重复交给人工确认不自动合并。不判重姓名相同但号码和公司都不同直接视为不同人。这个三档策略的核心思想是误合并的代价远高于漏合并。漏合并一条用户手动删一次就好误把两个王伟合成一条用户可能永远发现不了等到要打电话时才出事。所有涉及数据合并的场景都应该遵循这条原则——宁可保守也要可解释。3.2 号码归一化一条必须按顺序执行的流水线判重的准确性几乎全压在归一化上。我把它做成一条有严格顺序的流水线顺序不能乱否则会出现先删空格再加区号这种顺序错误导致的结果不一致import re def normalize_phone(raw: str) - str: s raw.strip() # 1. 全角括号、全角数字先转半角 s s.translate(str.maketrans(, ()0123456789)) # 2. 去掉所有分隔符空格、短横、点、括号、不间断空格 s re.sub(r[\s\-\.\(\)\u00a0], , s) # 3. 去掉国际前缀注意顺序先长后短 for prefix in (0086, 86, 86): if s.startswith(prefix) and len(s) - len(prefix) 11: s s[len(prefix):] break # 4. 处理常见的 0 开头长途写法固话 if len(s) 12 and s.startswith(0): s s[1:] return s第 3 步的循环顺序很关键。如果先判86再判0086那么0086139...会匹配到86前缀从而被截成86139...长度就不对了。所以我加了len(s) - len(prefix) 11这个长度校验让错误的截断自动失效。这类用后置条件兜底的写法在处理脏数据时特别管用比写一堆 if 分支清晰得多。第 4 步处理固话。这里必然有歧义01012345678去掉前导 0 之后和某个手机号可能撞车。我的处理是固话和手机分开建索引用长度区分11 位视为手机其余视为固话不混在一个键空间里。这个决定避免了大量误判。注意归一化只用于索引和比对展示给用户看的永远是phone_raw。我见过有项目直接把归一化结果写回原字段用户看到13800138000变成了13800138000倒还好固话被砍掉前导 0 之后就彻底打不通了。3.3 传递性重复并查集在这里刚刚好有一种情况用简单的两两判重处理不了A 和 B 号码相同B 和 C 姓名加公司相同但 A 和 C 没有任何直接相同的字段。按二元判重A、B 一组B、C 一组最终会得到两个组而实际上它们是同一个人。这就是传递性重复本质是求连通分量。并查集是这个问题最干净的解法。把每条记录当成一个节点每发现一对确定重复或疑似重复的关系就unite一次最后每个连通分量合并成一条代表记录struct DSU { std::vectorint p, sz; explicit DSU(int n) : p(n), sz(n, 1) { for (int i 0; i n; i) p[i] i; } int find(int x) { while (p[x] ! x) { p[x] p[p[x]]; x p[x]; } // 路径减半 return x; } void unite(int a, int b) { a find(a); b find(b); if (a b) return; if (sz[a] sz[b]) std::swap(a, b); // 按大小合并 p[b] a; sz[a] sz[b]; } };路径压缩我用的是路径减半而不是完全压缩因为完全压缩需要递归或者两趟遍历路径减半一趟循环搞定实测在这个规模下差别在微秒级代码却短一半。按大小合并保证树高是 O(log n)配合路径减半均摊复杂度接近常数。这套组合是处理合并类问题的标准配置值得背下来。合并时的字段仲裁规则也要提前定清楚我的优先级是updated_at最新的记录提供展示用姓名和号码公司、备注这类多值字段取并集去重id一律取组内最小的那个保证合并结果稳定可复现——同样的输入跑两遍必须得到同样的输出否则调试会非常痛苦。4. 排序分组中文姓名排序的连环坑4.1 拼音排序没有想象中那么直接按姓名排序听起来是个sort调用的事实际做起来有三个坑。第一个是中文没有天然的字典序UTF-8 字节序排出来的是按 Unicode 码点跟拼音顺序毫无关系。必须转拼音再比较。第二个是多音字姓氏尤其严重单解查仇区朴这些字作姓时的读音和常用读音完全不同通用的转拼音库默认会给出错误结果。第三个是生僻字转拼音库可能直接返回原字符导致排序时跑到最前面或最后面。我的处理方式很土但有效内置一张姓氏读音表只覆盖前两个字符。姓名排序只需要正确判断姓氏名字部分的多音字错一点无所谓用户感知不到。这张表大概两百来条手写一次一劳永逸SURNAME_PINYIN { 单: shan, 解: xie, 查: zha, 仇: qiu, 区: ou, 朴: piao, 曾: zeng, 乐: yue, 缪: miao, 翟: zhai, 盖: ge, 繁: po, }匹配时先看前两个字是不是复姓欧阳、司马、上官之类是则整段取表否则只看第一个字。查不到再退回通用转拼音库。这个特判优先、通用兜底的层次结构比追求一次性完美转换务实得多。4.2 稳定排序与多级比较器的正确写法通讯录导出通常要求按公司 → 姓名拼音 → 号码三级排序。这里有两个细节必须写对。其一是必须用稳定排序。std::sort是不稳定的std::stable_sort才是。为什么重要因为如果两条记录三级键完全相同同名同姓同公司还用同一个总机不稳定排序会让它们在多次导出之间的顺序随机变化用户会觉得数据在乱跳。std::stable_sort内部是归并排序加插入排序的混合最坏 O(n log n)代价是额外的临时缓冲。其二是比较器必须满足严格弱序不能出现cmp(a,b)和cmp(b,a)同时为真的情况否则stable_sort内部会越界表现为段错误或者结果错乱而且极难定位。最常见的翻车写法是return a.org b.org;用小于等于就破坏了严格弱序必须用严格小于。std::stable_sort(contacts.begin(), contacts.end(), [](const Contact a, const Contact b) { if (a.state ! b.state) return a.state b.state; // 有效的排前面 if (a.org ! b.org) return a.org b.org; // 一级公司 std::string pa pinyin_of(a.name), pb pinyin_of(b.name); if (pa ! pb) return pa pb; // 二级拼音 return a.phone b.phone; // 三级号码 });有一点性能提醒在比较器里每次都调pinyin_of是巨大的浪费。stable_sort做 O(n log n) 次比较三千七百条大约七万次每次转拼音都是字符串解析累计可能上百毫秒。正确做法是排序前预处理给每条记录算好sort_key缓存在结构体里比较器只比字符串。这个改动通常能把排序耗时砍掉一个数量级是排序优化里性价比最高的一招却经常被忽略。4.3 分组展示靠索引不靠重排如果每次切换分组都要重新排序交互会卡。更好的做法是排序之后一次性构建分组索引一个std::mapstd::string, std::vectoruint32_t键是分组名公司名或首字母值是排好序的下标列表。之后所有分组视图都只是查表O(log g) 定位加 O(k) 遍历g 是分组数通常几十个。std::mapstd::string, std::vectoruint32_t groups; // 按键有序天然按组名排序 for (uint32_t i 0; i contacts.size(); i) { if (contacts[i].state ! 0) continue; groups[first_letter(pinyin_of(contacts[i].name))].push_back(i); }用std::map而不是unordered_map的原因是分组列表本身需要有序展示首字母 A 到 Z有序容器直接省掉一次对键的排序。这是一个用对容器省一次排序的典型例子选容器的时候顺手想一下后续怎么用比事后优化省事得多。5. 手写链表版通讯录最容易翻车的几处5.1 删除节点之后的悬空指针如果课程要求必须手写链表那么删除操作是事故高发区。经典错误是这样写的// 错误示范 Node* cur head; while (cur-next) { if (cur-next-id target) { delete cur-next; cur-next cur-next-next; // 已经 delete 了这里访问的是已释放内存 } cur cur-next; }delete之后立刻访问cur-next-next访问的是已释放的内存。在 Debug 构建下可能侥幸不崩Release 下开启优化就可能读到垃圾值表现为链表莫名断开或者后面几条记录丢失。正确顺序是先保存next再改指针最后释放Node* prev head; while (prev prev-next) { Node* cur prev-next; if (cur-id target) { prev-next cur-next; delete cur; // 先摘链再释放 continue; // 注意不要前移 prev } prev cur; }continue那一行同样是坑。删掉节点之后prev不需要前移如果顺手写了prev prev-next会跳过紧邻的下一个待删节点——这个 bug 在批量删除时才会暴露单条删除测试根本发现不了。5.2 哈希表扩容导致的迭代器失效另一类高频事故是遍历容器时修改容器。std::unordered_map在插入导致 rehash 时所有迭代器失效但引用和指针仍然有效这是标准保证的因为节点本身不移动。所以下面两种写法命运完全不同// 危险遍历中插入rehash 后 it 失效行为未定义 for (auto it by_phone.begin(); it ! by_phone.end(); it) { if (need_merge(it-first)) by_phone[new_key] it-second; } // 安全先收集要插入的键遍历结束后再统一插入 std::vectorstd::pairstd::string, std::vectoruint32_t pending; for (const auto kv : by_phone) { if (need_merge(kv.first)) pending.push_back({new_key(kv.first), kv.second}); } for (auto p : pending) by_phone[p.first] std::move(p.second);两阶段提交的写法看起来笨但它是处理遍历中修改最可靠的办法。我宁愿多写五行程式代码也不想半夜对着一个随机崩溃的段错误发呆。顺便说一句如果你用的是std::map或std::list插入不会让其他迭代器失效就没有这个问题——这也是选容器时需要权衡的一环。5.3 值语义与深拷贝一个隐蔽的双重释放手写链表时为图省事把节点直接memcpy或者浅拷贝给另一个链表是很常见的偷懒做法。如果节点里只放定长数组浅拷贝还能凑合一旦里面有std::string或者裸指针就会出现两个链表共享同一块堆内存析构时双重释放。std::string因为有短字符串优化短姓名可能不分配堆内存所以不崩长一点的备注字段就会炸——这种小数据不出事、大数据才崩的 bug 最消耗调试时间。我的经验是只要结构体里有任何资源所有权就老老实实写拷贝构造和赋值运算符或者干脆用std::vector加下标从根本上不碰裸指针。现代 C 里手写链表的教学价值大于工程价值课设之外的真实项目除非有明确的性能或内存约束真没必要自己管链表节点。6. 实测对比与几个可以继续往上加的方向6.1 三千七百条数据下三种结构的量级对比我拿同一份三千七百条的脱敏数据在同一台机器上跑了三组实现每个操作重复一千次取平均。下面的数字只是量级参考具体数值和机器、编译器优化级别强相关重点看相对关系操作vector 顺序表手写单链表vector 双哈希索引全量遍历输出约 0.6 ms约 2.4 ms约 1.6 ms多一次状态判断按号码精确查找约 8.4 ms线性约 8.9 ms约 0.03 ms批量导入 3700 条含判重约 31 ms约 44 ms约 3.8 ms插入一条到中间位置约 1.2 ms约 0.01 ms约 0.02 ms全量三级排序约 46 ms含转拼音约 48 ms约 6 ms拼音预处理后几个反直觉的点值得说。第一链表的插入快在这个数据量下几乎没有意义因为真正的耗时在判重和查找上插入本身快 0.01 毫秒救不了 44 毫秒的导入总时间。第二加索引之后导入反而更快了因为判重从 O(n²) 的两两比较变成了 O(1) 的哈希查询。第三排序从 46 毫秒降到 6 毫秒绝大部分收益来自拼音预处理而不是排序算法本身——优化的收益往往来自少做重复工作而不是换更快的算法。如果数据量涨到十万条线性查找会变成两百多毫秒那时候哈希索引就是刚需如果涨到一百万的量级vector的搬迁成本开始显现才轮得到考虑链表或者分块存储。这就是为什么我前面反复强调规模决定结构先量一量数据再动手。6.2 落盘与增量更新只存变更而不是全量重写整理完的通讯录要能持久化。最简单的做法是每次退出时把整个vector序列化成 JSON 写文件。三千七百条大概几百 KB写一次几十毫秒人感觉不到。但如果做成一个长期使用的工具每次改动都全量重写就浪费了。更务实的做法是追加式日志加定期快照每条变更记为一行{op:upsert,id:123,fields:{...}}追加到文件末尾启动时先读快照再回放日志。好处是写入路径变成 O(1)而且天然具备能回滚到任意历史状态的能力——出问题删掉最后几行日志就能恢复。代价是日志会膨胀需要每隔一段时间触发一次压缩把当前全量状态写成新快照清空日志。{op:upsert,id:10086,name:张伟,phone:13900000001,org:某公司,ts:1730000000} {op:merge,from:[10087,10092],into:10086,ts:1730000120} {op:delete,id:10103,ts:1730000180}字段用英文短名能显著压缩体积这不是过度设计而是这类日志文件动辄上万行的实际需求。我试过用中文键名文件体积大了将近一倍。6.3 还能往上加的几个东西第一个是拼音前缀树Trie。现在的姓名查找是线性扫描全表做前缀匹配还行做模糊音匹配zh 和 z 不分、前后鼻音不分就无能为力了。把拼音首字母序列建成 Trie能支持输 zw 就命中张伟、赵薇、郑伟这种缩写查找输入体验会好很多。Trie 的节点开销大但承载几百到几千个姓名完全没问题。第二个是编辑距离索引。当用户只记得名字里有个伟的时候朴素的编辑距离计算是 O(n·m·k)全表扫一遍太慢。工程上常用的方案是用 BK 树或者简单一点的首字母桶加长度桶做预筛先砍掉九成候选再算距离。这一层对记不清名字的场景是刚需。第三个是并发访问。个人工具单线程足够但如果做成多人共享的通讯录就得考虑读写锁或者写时复制。我倾向于写时复制每次写入复制一份主存储改完再原子替换指针读路径完全无锁代价是写入时内存翻倍。对通讯录这种写少读多的场景这个取舍很划算。还有一个更实际的方向把疑似重复合并进人工确认流程。自动判重永远做不到百分之百准与其追求规则完备不如把不确定的成对记录列出来让用户点一下是/不是然后把用户的判断反馈回规则库。我做了这一层之后朋友只花了不到五分钟就处理完全部两百多条疑似记录——人工确认一次比写十条启发式规则都有效。最后分享一个小技巧。整理这类数据之前先做一份只读的原始快照把所有操作都写成输入快照、输出新快照的纯函数不要在原始数据上原地修改。这一条我踩过血的教训早期版本直接改原始文件中途一次判重规则写错把三百多条记录合并成了错误的组原始数据已经没了只能从另一个备份里找回一部分。自那以后我的所有数据清洗脚本都遵循同一条纪律——输入端只读输出端另存中间过程可重放。跑十遍结果一致才敢往真实数据上用。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询