前几天在刷题群里看到有人讨论一个很基础的链表问题——求两个单链表的差集,也就是A中存在、B中不存在的节点集合。问题看着简单,但真动手写的时候,你会发现它涉及的不只是遍历和判断,还有重复元素怎么算、内存怎么管理、边界条件怎么处理这些实际问题。这篇文章我从头到尾捋一遍,把常见的几种解法、细节坑和实际测试心得都写清楚,给正准备练链表操作的读者一个能直接参考的完整过程。
这个问题本质上考察的是:给定两个无序单链表A和B,返回一个新链表,包含所有在A中出现且不在B中出现的节点值。举一个最简单的例子,A = {1, 2, 3, 4},B = {2, 4, 6},那差集结果就是 {1, 3}。看起来是几行代码的事,但审题时第一个要明确的是“重复元素怎么处理”。A和B各自内部可能都有重复值,如果A中某值出现3次,B中完全没有,差集里是保留这个值一次,还是保留全部3次?如果B中出现了1次,A中出现3次,是全部丢弃,还是只丢弃匹配的那一次?不同题解对这个问题有不同约定,写之前一定得先确认清楚。本文按最常见也是面试场合最合理的约定来处理:按“值是否存在”去重,即B中只要出现过某个值,A中所有等于该值的节点都算在差集之外。这个约定下,输出链表天然是去重的结果。
1. 先搞清楚需求:差集到底算的是什么
1.1 题面拆解与隐含的边界条件
先把问题完全展开。输入是两个单向链表的头指针,也就是A链表的头节点指针和B链表的头节点指针。输出是一个新链表的头节点指针,或者用空指针表示空结果。所谓“差集”,在集合论里写成A - B,指属于A但不属于B的元素。但当这个定义应用到链表这种线性结构上时,有几个东西需要约定清楚:
- 按值比较还是按节点比较:正常业务场景下都是按节点的数据域比较,不会有人去比较两个节点的地址。除非题目明确说“同一对象实例”,否则一律按数据值比较。
- 结果链表的节点顺序:输出结果应当保持节点在原始链表A中出现的先后顺序。这一点很关键,因为链表的顺序本身是数据的一部分,如果乱序输出,在有些场景下就不符合预期。
- 是否允许修改原链表:最干净的做法是新建节点组成结果链表,原链表A和B保持不动。如果允许把A链表的节点直接摘下来重新组织,则能省下内存分配的开销,但代价是破坏了输入数据。工程上一般倾向不修改原结构。
- 输入链表的长度为0:A为空则结果必为空,B为空则结果就是A的全部元素(去重后)。
- A与B完全相同:结果为空链表。
这些条件看着琐碎,却是一份完整实现和一个“能跑就行”的实现之间的差距。我实测过很多次,漏掉空链表判断或者在头部插入节点时出错,是新手最容易栽的两个地方。
1.2 常见解法路线对比
就差集这个操作而言,能想到的解法无外乎三类。第一类是双重遍历,拿A的每个节点去和B的每个节点逐个比。第二类是借助哈希表,先把B的所有值存进哈希集合,再遍历A时查表。第三类是排序归并,先分别给A和B排序,再用类似合并有序链表的双指针思路求差集。
三类方法各有适用场景,我整理了一个表格方便对比:
| 方案 | 时间复杂度 | 额外空间 | 是否修改输入 | 适用场景 |
|---|---|---|---|---|
| 双重遍历 | O(n×m) | O(1) | 否 | 两个链表都很短,且不追求效率 |
| 哈希表辅助 | O(n+m) | O(m) | 否 | 链表较长,m可控,内存足够 |
| 排序归并 | O(nlogn+mlogm) | O(1)~O(n) | 需要排序 | 原链表已有序,或对空间极为敏感 |
双重遍历最大的优点是不需要任何额外存储,思路也直白,但一旦链表长度上万,这O(n×m)的开销会迅速让程序卡到无法接受。哈希表方案把“判断某个值是否在B中”从O(m)降低到O(1),整个算法线性完成,是绝大多数场景下的首选。排序归并虽然看起来耗时更多,但如果A和B本身已经有顺序,这一步就省掉大半,反而成为最优解。
从面试角度讲,能先把这三种思路都说清楚,再根据约束条件挑一种动手写,远比上来就写代码更能体现功底。我自己带过的几个新人,面试挂掉的原因往往不是代码写不出来,而是缺少对方案取舍的清晰表述。
2. 核心细节:节点定义与边界处理
2.1 链表节点的定义选择
实现差集操作,第一步是定义链表节点结构。以c语言为例,典型定义是这样:
struct list_node { int value; struct list_node *next; };数据域用int,在实际业务里可能是任意可比较的类型,比如字符串、结构体,甚至是一个自定义对象的哈希值。使用int便于演示,逻辑上没有任何区别。这里有个小讲究:指针域用next,是单向链表的惯例。有些教材用link,有些用next,我建议统一用next,配合prev可以方便扩展成双向链表,代码可读性也更好。
创建新链表的节点时,需要用malloc分配内存。这一步很多人会漏掉判空,或者忘记用完后free释放。判断是否为空可以用下面这段安全的写法:
struct list_node *node = (struct list_node *)malloc(sizeof(struct list_node)); if (node == NULL) { // 处理内存分配失败 exit(EXIT_FAILURE); } node->value = val; node->next = NULL;如果你用python这类语言,就不存在这个麻烦,直接创建对象节点即可:
class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next写C时我经常看到有人追求“一行创建节点”,把malloc和赋值连在一起写。如果分配失败,后续立即访问node->value就会空指针崩溃。所以实际的工程写法,判空不能省。
2.2 边界条件与重复元素处理
如果A里有重复值,比如A = {2, 2, 3},B = {2, 5},按照约定,结果应当只包含3。也就是说B中出现过的值,A中所有同值节点全部排除。如果你在实现时单纯写“第一个匹配到就跳过”的逻辑,碰到A中连续两个2时,第二个2会被错误地加入结果。这就是重复元素处理的核心。
处理重复元素有两种角度。第一种是从B的角度看,把B中的所有值收集到一个哈希表,这样B的重复不影响判断。第二种是从A的角度看,遍历A时判断当前节点值在结果链表中是否已经出现过,如果出现过就不再重复添加。第二种做法需要额外维护一个集合来记录“已加入结果”的值,也会增加O(n)的空间。
我实际测试下来的建议是:在哈希表方案下,B的去重由哈希集合天然保证,A的重复值则需要配合“已输出集合”来过滤,或者直接借助哈希表记录A中的已访问值。不过如果你明确题目约定“A中元素互异、B中元素互异”,那就不用考虑这些,代码还能再简化一截。
还有一个边界情况需要注意:B可能是空链表或者B中没有任何值跟A匹配。此时结果链表应等于去重后的A。如果A也是空链表,则结果也是空。这些判断最好在函数入口处统一完成,避免后面逻辑处理空指针。
if (head_a == NULL) { return NULL; } if (head_b == NULL) { // 理论上应返回去重后的A,但如果A本身已约定互异,可直接返回A的拷贝 return copy_unique_list(head_a); }2.3 为什么哈希表方案更推荐:时间换空间的权衡
大部分实际场景里,两个链表都是动态增长的,长度从几十到几万都有可能。双重遍历在这种规模下会明显卡顿,而哈希表辅助方案的耗时几乎不会随链表规模恶化。拿一个我自己的实验举例:分别生成5000个节点的A和5000个节点的B,用哈希表方案跑一轮差集,整体耗时不到10毫秒;用双重遍历则要数秒。这个差距在用户端体现出来,就是“界面卡死”和“秒出结果”的区别。
哈希表方案的空间代价是O(m),其中m是B链表的节点数。如果B很大而A很小,空间开销可能不划算,这时可以考虑把角色对调,用A建哈希表再遍历B,甚至遍历A本身,不过逻辑会绕一些。对于常规场景,为B建立哈希集合是性价比最高的选择,这也是很多标准库实现差集时使用的方法。
实现层面,如果使用C语言,需要自己弄一个简单的开地址哈希表或者链地址哈希表,不少人会觉得麻烦。更好的替代方案是用现成的库,比如glib的GHashTable,或者C++的std::unordered_set,Python则是内置的set。我自己写C示例时偏爱实现一个极简定长布尔数组版本,前提是值的范围已知且有限:
#define MAX_VALUE 100000 bool in_b[MAX_VALUE] = {false};如果值的范围不确定,再用动态哈希。实际工程里这里的天花板很低,不用纠结太久。
3. 实操过程:完整实现与测试
3.1 方案一:哈希表过滤(推荐)
先给出完整的Python实现,这段代码清晰、易于复现,也是我平时验证思路的首选语言:
class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next def list_difference(head_a, head_b): # 将B中所有值收集到哈希集合 b_values = set() cur = head_b while cur: b_values.add(cur.val) cur = cur.next # 创建哑节点,方便结果链表头部操作 dummy = ListNode(0) tail = dummy # 遍历A,同时用一个集合记录已输出的值,用于去重 seen = set() cur = head_a while cur: if cur.val not in b_values and cur.val not in seen: tail.next = ListNode(cur.val) tail = tail.next seen.add(cur.val) cur = cur.next return dummy.next这段代码做了什么,我逐步说明。第一步遍历B,把所有值塞进b_values,这是哈希集合,查询时间复杂度O(1)。第二步创建哑节点dummy,它的存在让结果链表的构建不用特殊区分“第一个节点”和“后续节点”。tail始终指向结果链表的最后一个节点。第三步遍历A,判断条件有两个:当前值不存在于B中、且此前尚未输出过。第一个条件排除B中存在的值,第二个条件排除A内部的重复值。命中则创建新节点,挂到tail后面,更新tail,并把值记录到seen集合。
最后返回dummy.next,也就是结果链表的真正头节点。整个过程的时间复杂度是O(n+m),额外空间也是O(n+m),因为b_values和seen各占一份。
C语言的完整实现也不复杂,只是需要自己写一个简单的哈希表。我这里用“带头链表+定长布尔数组”做演示,适合刷题和验证思路:
#include <stdio.h> #include <stdlib.h> #include <stdbool.h> struct list_node { int value; struct list_node *next; }; struct list_node *new_node(int value) { struct list_node *node = (struct list_node *)malloc(sizeof(struct list_node)); if (!node) { fprintf(stderr, "Memory allocation failed\n"); exit(EXIT_FAILURE); } node->value = value; node->next = NULL; return node; } // 这里限定值的范围在0~99999之间,可依据题目调整 #define VALUE_RANGE 100000 struct list_node *list_difference(struct list_node *head_a, struct list_node *head_b) { if (!head_a || !head_b) { return NULL; // 简化处理:任一为空则结果返回空,实际需按约定处理 } bool in_b[VALUE_RANGE] = {false}; struct list_node *cur = head_b; while (cur) { in_b[cur->value] = true; cur = cur->next; } bool seen[VALUE_RANGE] = {false}; struct list_node *dummy = new_node(0); struct list_node *tail = dummy; cur = head_a; while (cur) { if (!in_b[cur->value] && !seen[cur->value]) { tail->next = new_node(cur->value); tail = tail->next; seen[cur->value] = true; } cur = cur->next; } struct list_node *result = dummy->next; free(dummy); // 释放哑节点 return result; }这段C代码里用了两个定长布尔数组,当数值范围未知时,需要换成动态哈希表。我在代码注释里也说明了对空输入的处理,这里简化成返回空,真实业务里要根据题目约定调整。
3.2 方案二:排序后归并去重
如果题目允许先对链表排序,或者输入本身有序,那么可以用归并的思路来求差集。基本步骤是:
- 将A和B分别排序(如果无序),使用归并排序可以保证O(nlogn)的复杂度。
- 同时维护两个指针
pa和pb,分别指向A和B的当前节点。 - 比较
pa->value和pb->value:- 若
pa->value < pb->value,说明A当前值不可能在B中出现(因为B已排序,后续值只会更大),将其加入结果,pa前移; - 若
pa->value > pb->value,说明B当前值小于A当前值,但A当前值仍可能与B后续值相等,因此只前移pb; - 若相等,说明A当前值在B中出现,跳过这个值,然后
pa前移到下一个不重复值。
- 若
- 当
pa或pb任一走到链表末尾时,若pa未走到末尾,则说明A剩余所有值都大于B当前值(或B已为空),直接将其余不重复值全部加入结果。
这个思路比哈希表方案少建一张哈希表,如果输入正好有序,时间上更有优势。写排序归并链表本身是一个独立的大知识点,这里不展开,给出一个关键伪代码块就足够:
pa = head_a pb = head_b while pa and pb: if pa.value < pb.value: 添加 pa.value 到结果 pa = pa.next(跳过所有等于当前值的节点) elif pa.value > pb.value: pb = pb.next(跳过所有等于当前值的节点) else: pa = pa.next(跳过所有等于当前值的节点) pb = pb.next(跳过所有等于当前值的节点) while pa: 添加 pa.value 到结果 pa = pa.next(跳过所有等于当前值的节点)这段逻辑天然去重,同时保持了有序性。需要注意“跳过所有等于当前值的节点”这个动作,在链表上操作时要用内层循环,否则重复元素可能会被重复加入。
3.3 测试样例的选择
写代码之前先构造测试数据,这是我自己的习惯,也是减少调试时间最有效的做法。针对差集操作,我建议至少覆盖以下几组样例:
| 用例场景 | A链表 | B链表 | 期望结果 |
|---|---|---|---|
| 基本差集 | 1->2->3->4 | 2->4->6 | 1->3 |
| A有重复 | 2->2->3->3 | 2->5 | 3 |
| B有重复 | 1->2->3 | 2->2->4 | 1->3 |
| A为空 | 空 | 1->2 | 空 |
| B为空 | 1->2 | 空 | 按约定处理(应为1->2) |
| 完全相同 | 1->2 | 1->2 | 空 |
| 所有值都不同 | 1->2 | 3->4 | 1->2 |
我实际测试时还会额外加一个值域很大的场景,比如A和B各包含几千个随机节点,用来验证复杂度和是否有性能问题。刷题时不一定需要构造这么全,但是工程开发中这些用例能帮你提前发现一半以上的隐藏bug。
4. 常见问题与排查技巧实录
4.1 指针悬挂与越界访问
链表问题中最典型的崩溃就是指针悬挂。常见原因包括访问空指针的next成员、返回局部栈变量的地址、以及删除节点后未将前一个节点的next指向新位置。在差集实现的语境里,最容易踩的坑是在“遍历A并构建新链表”的循环里,错误地复用了cur指针:
while (cur) { if (condition) { cur = cur->next; // 错误:cur被提前移动后,丢掉了链表关系 } }正确的做法是,新建结果链表节点时始终使用独立指针,遍历指针cur只在循环末尾统一移动。我在上面给出的代码中就是先赋值、再移动cur,顺序不能反过来。调试时出现段错误,先检查cur指针是否在循环体内被无意改动,再用调试器打印每次循环的指针值,很快就会看到问题位置。
另一个越界访问高发点是数组哈希表。我在示例里使用了定长bool in_b[VALUE_RANGE],如果某个节点的值是负数或超出了范围,直接索引就会访问非法内存。务必在函数入口处检查值的范围,或者改用动态哈希表。
4.2 内存泄漏隐患
C语言实现的另一大坑是内存泄漏。我在写list_difference时创建了大量新节点,如果调用方只拿到结果头指针,而没有释放整条链表的习惯,每一次操作都会泄漏一批内存。更隐蔽的是,我在示例代码里用了new_node为哑节点分配内存,代码末尾用free(dummy)释放,但如果中途某个分支提前返回(比如入口处检查到空链表直接返回NULL),就会漏掉释放那些已经创建的头节点。
解决思路有三个层面。第一,提供一个释放链表的工具函数,每次用完后立刻释放整条结果链表。第二,统一约定返回值所有权,明确“谁创建谁释放”。第三,如果是在高频循环里跑差集,可以采用节点复用策略,直接摘取A链表中符合条件的节点,而不是新建节点,这样不但省内存,还省时间。不过代价是修改了原始链表,取舍取决于业务约束。
4.3 重复元素的坑与排查速查表
处理重复元素时,最容易忽略的是“输出结果的去重”。我见过不少人实现了排除B中存在的值,却把A内部的重复值原样输出。比如A = {2, 2, 3},B = {5},错误实现会输出{2, 2, 3}。针对这个现象,可以用seen集合来解决,我前面已经写过。用C语言实现时,若值域有限,就用布尔数组;若值域未知,需要哈希表或先对A排序去重。
我整理一个常见问题速查表,方便遇到问题时快速定位:
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 结果多出A中重复值 | 未过滤A内部重复 | 增加已输出值记录集合 |
| 结果为空,但期望有值 | B的哈希表构建时指针移动错误 | 打印B遍历过程检查 |
| 输入包含负值时崩溃 | 定长数组越界 | 改用动态哈希或做值域偏移 |
| B很空但运行很慢 | 选择了双重遍历 | 改用哈希表方案 |
| 返回链表顺序错乱 | 结果构建时头插法 | 用尾插法或哑节点 |
| 内存随调用次数不断增长 | 未释放结果链表 | 提供释放函数并统一约定所有权 |
这份速查表是我在多个项目里踩坑总结出来的。排查时先看现象对应的是哪一类,再结合断点验证,通常几分钟就能定位。
5. 经验总结:两个容易被忽略的细节
把差集这个操作做完之后,我最大的一个体会是:决定代码质量的关键往往不在主流程,而在边角细节。比如哑节点的使用就是一个典型的细节优化。没有哑节点时,构建结果链表需要判断“是不是第一个节点”,代码会多出if分支,也容易出现漏挂next的问题。用上哑节点后,逻辑统一而简洁,代码也就更不容易出错。
另一个容易被忽略的细节是“判断标准到底以值还是以节点为准”。如果你没有提前跟需求方确认,默认按值处理,最后别人要求按“节点全等”比较,代码就得推翻重写。这个确认成本几乎为零,但能避免返工。还有一点,我在第二套方案里提到的“排序后归并”,很多讲解会把它等同于“先排序再双循环”,实际上归并的双指针移动策略直接决定了重复元素能否被正确处理,这是最值得花时间理解的地方。
6. 后续扩展思路
差集做完了,反向的“交集”其实就是镜像问题。把排除条件反过来,保留A和B共有的值,逻辑框架完全一致。另一个扩展是“对称差集”,也就是元素在A或B中出现但不同时在两者中都出现,实现时先求并集再减去交集,或者用两个集合分别记录存在性,遍历A和B时只输出“只出现一次”的值。数据结构上,如果链表改为双向链表,差集逻辑不变,但结果链表的构建可以从头部插入而不影响顺序,这会给尾部频繁插入的场景带来便利。
想进一步练手的话,可以尝试给链表节点加一个frequency字段,统计每个值在原链表里的出现次数,然后实现“多重差集”,也就是按次数做差而不是按存在性做差。这在实际业务中对应库存扣减或资源配额的场景,比单纯布尔判断更有实用价值。至少我个人在做库存系统时遇到过类似的逻辑变体,先想清楚链表上的这套基础操作,迁移过去会顺手很多。