在数据结构与算法的世界里,链表(Linked List)往往是我们遇到的第一个“非线性”挑战。不同于数组的连续内存存储,链表依靠指针串联起零散的内存块。这种特性决定了链表操作的核心在于:如何优雅且安全地控制指针。
今天我们通过两道经典的 LeetCode 题目——“相交链表”和“反转链表”,来深入探讨双指针技巧背后的数学原理与状态流转。
第一题:相交链表 (Intersection of Two Linked Lists)
题目背景
给你两个单链表的头节点 headA 和 headB,请你找出并返回两个单链表相交的起始节点。如果两个链表不存在相交节点,返回 null。
底层原理解析:消除“时差”的数学魔法
这道题最直观的解法是用哈希表(HashSet)存储链表 A 的所有节点,然后遍历链表 B 查看是否存在相同节点。但这需要 O(N) 的空间复杂度。如果题目要求 O(1) 空间复杂度,我们就必须利用逻辑上的技巧。
为什么双指针走完 A 再走 B 就能相遇?
假设链表 A 的非公共部分长度为 a,链表 B 的非公共部分长度为 b,它们公共部分的长度为 c。
-
链表 A 的总长度 =
a + c -
链表 B 的总长度 =
b + c
如果是两个人在跑步,一个人跑完 A 还需要跑 B,另一个人跑完 B 还需要跑 A:
-
指针 p 的路径:先走 A,再走 B。总路程 =
(a + c) + b -
指针 q 的路径:先走 B,再走 A。总路程 =
(b + c) + a
根据加法交换律:a + c + b = b + c + a。
这意味着什么? 这意味着,只要两个指针按照这个规则走,它们走过的总路程一定是相等的。
-
如果两个链表相交(
c > 0):它们会在走完a+b步后,同时到达公共部分的起点(也就是剩下的c部分的开始)。 -
如果两个链表不相交(
c = 0):它们会同时走完a+b步,然后同时指向nullptr(也就是链表的尽头)。
这个算法的本质,就是通过拼接路径,消除了两个链表长度不同带来的“时差”,强行让两个指针在终点(或交点)同步。
代码实现
C++代码实现:
class Solution {
public:
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
// 定义两个漫游指针
ListNode* p = headA;
ListNode* q = headB;
// 当 p 和 q 没有相遇时,继续循环
// 如果相交,会在交点相遇;如果不相交,会在 nullptr 相遇(此时 p==q==null)
while(p != q) {
// 走完自己的路,就去走别人的路
// 这种三元运算符的写法非常凝练
p = p ? p->next : headB;
q = q ? q->next : headA;
}
return q;
}
};
时空复杂度分析
-
时间复杂度:O(M + N)。其中 M 和 N 分别是两个链表的长度。最坏情况下,两个指针都需要遍历完两个链表(即走过 M+N 个节点)才能相遇或确认不相交。
-
空间复杂度:O(1)。我们只使用了
p和q两个指针变量,没有申请额外的存储空间。
第二题:反转链表 (Reverse Linked List)
题目背景
给你单链表的头节点 head,请你反转链表,并返回反转后的链表。
底层原理解析:三指针的“状态流转”
反转链表看起来简单,但在写代码时很容易出现“断链”的情况(即丢失了下一个节点的地址)。要解决这个问题,我们需要理解这是一个局部状态不断推进的过程。
在反转的过程中,我们需要维护三个视角(变量):
-
过去 (pre):已经反转好的链表的头部。
-
现在 (cur):当前正在处理的节点,我要把它的指针指向“过去”。
-
未来 (nxt):当前节点的下一个节点。在我改变“现在”的指向前,必须先记下“未来”在哪里,否则链条就断了。
操作核心步骤(循环不变量): 每一次循环,我们实际上是把 cur 这个节点,从“未反转”的队伍里拆下来,拼接到“已反转”的队伍头上去。
-
nxt = cur->next:先拿个小本本记下下一家是谁(保存未来)。 -
cur->next = pre:关键一步,斩断与未来的联系,回首指向过去(反转指向)。 -
pre = cur:更新“过去”的定义,当前的节点变成了新的表头(推进状态)。 -
cur = nxt:脚踏实地,走向原本的下一家(继续处理)。
代码实现
C++代码实现:
class Solution {
public:
ListNode* reverseList(ListNode* head) {
// cur 代表当前待处理节点
// pre 代表已经反转好的链表的头节点(初始化为 null)
ListNode* cur = head;
ListNode* pre = nullptr;
while (cur) {
// 1. 暂存未来:记录下一个节点,防止断链
ListNode* nxt = cur->next;
// 2. 回首过去:修改指针指向
cur->next = pre;
// 3. 推进状态:pre 和 cur 整体向前移动
pre = cur;
cur = nxt;
}
// 循环结束时,cur 指向 null,pre 指向原链表的最后一个节点
// 也就是新链表的头节点
return pre;
}
};
时空复杂度分析
-
时间复杂度:O(N)。其中 N 是链表的长度。我们需要遍历链表中的每一个节点一次,进行指针修改操作。
-
空间复杂度:O(1)。这是迭代法的优势,我们只使用了
cur,pre,nxt三个指针变量。如果使用递归法,虽然代码更短,但会消耗 O(N) 的栈空间。
总结
这两道题目虽然简单,但蕴含了链表操作的两个核心哲学:
-
视角的转换:在“相交链表”中,我们通过拼接路径,将“不同步”的问题转化为了“同步”问题。
-
状态的维护:在“反转链表”中,我们通过
pre、cur、nxt三个指针,严密地维护了链表在断裂与重组过程中的状态完整性。
1万+

被折叠的 条评论
为什么被折叠?



