链表作为线性数据结构中的经典代表,在算法面试和实际开发中占据重要地位。与数组不同,链表通过指针将零散的内存块串联起来,这种非连续存储特性带来了插入/删除的高效性(O(1)时间复杂度),但也牺牲了随机访问能力(O(n)时间复杂度)。今天我们将深入探讨四个典型链表问题:两两交换节点、删除倒数第N个节点、链表相交判断以及环形链表检测。
提示:理解链表问题的关键在于掌握指针操作和边界条件处理,建议在纸上画出节点变化过程。
递归解法体现了"分而治之"的思想,将大问题分解为相同结构的子问题。对于swapPairs函数:
c复制struct ListNode* swapPairs(struct ListNode* head) {
if(!head || !head->next) return head; // 终止条件
struct ListNode* newHead = head->next; // 新头节点
head->next = swapPairs(newHead->next); // 递归处理剩余节点
newHead->next = head; // 完成交换
return newHead;
}
递归深度为n/2,空间复杂度O(n)。虽然代码简洁,但存在栈溢出风险(链表过长时)。实际工程中建议限制递归深度。
迭代版本通过虚拟头节点(dummy node)统一处理边界情况,是更稳健的解决方案:
c复制struct ListNode* swapPairs(struct ListNode* head) {
typedef struct ListNode ListNode;
ListNode dummy; // 不使用malloc避免内存泄漏
dummy.next = head;
ListNode* prev = &dummy;
while(prev->next && prev->next->next) {
ListNode* first = prev->next;
ListNode* second = first->next;
// 三步完成交换
prev->next = second;
first->next = second->next;
second->next = first;
prev = first; // 移动指针
}
return dummy.next;
}
关键点在于维护prev指针,它始终指向待交换节点对的前驱节点。时间复杂度O(n),空间复杂度O(1)。
注意事项:迭代法中指针操作的顺序至关重要,错误的顺序会导致链表断裂。建议先画出交换前后的指针变化图。
原代码通过递归统计节点位置,略显复杂。优化后的递归实现:
c复制int findNodeFromEnd(ListNode* node, int n, ListNode** target) {
if(!node) return 0;
int pos = findNodeFromEnd(node->next, n, target) + 1;
if(pos == n+1) *target = node;
return pos;
}
ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode dummy = {0, head};
ListNode* target = NULL;
findNodeFromEnd(&dummy, n, &target);
if(target && target->next) {
ListNode* toDelete = target->next;
target->next = target->next->next;
free(toDelete); // 实际开发中需释放内存
}
return dummy.next;
}
更高效的双指针法只需一次遍历:
c复制ListNode* removeNthFromEnd(ListNode* head, int n) {
ListNode dummy = {0, head};
ListNode *fast = &dummy, *slow = &dummy;
// fast先走n步
for(int i=0; i<=n; ++i) {
if(!fast) return head; // n超出长度
fast = fast->next;
}
// 同步移动
while(fast) {
fast = fast->next;
slow = slow->next;
}
// 删除节点
ListNode* toDelete = slow->next;
slow->next = slow->next->next;
free(toDelete);
return dummy.next;
}
经验之谈:处理链表删除操作时,使用虚拟头节点可以统一处理删除首节点的特殊情况,避免复杂的条件判断。
原解法先计算长度差,然后对齐起点。优化后的实现:
c复制ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
// 计算长度
int lenA = 0, lenB = 0;
for(ListNode *p=headA; p; p=p->next) lenA++;
for(ListNode *p=headB; p; p=p->next) lenB++;
// 对齐起点
while(lenA > lenB) {
headA = headA->next;
lenA--;
}
while(lenB > lenA) {
headB = headB->next;
lenB--;
}
// 同步前进找交点
while(headA != headB) {
headA = headA->next;
headB = headB->next;
}
return headA;
}
更巧妙的O(1)空间解法:
c复制ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
ListNode *p1 = headA, *p2 = headB;
while(p1 != p2) {
p1 = p1 ? p1->next : headB;
p2 = p2 ? p2->next : headA;
}
return p1;
}
原理:两个指针分别遍历A+B和B+A,路径长度相同,会在交点相遇。
Floyd判圈算法的精妙之处在于数学证明:
c复制ListNode *detectCycle(ListNode *head) {
ListNode *slow = head, *fast = head;
while(fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if(slow == fast) { // 相遇点
ListNode *ptr = head;
while(ptr != slow) {
ptr = ptr->next;
slow = slow->next;
}
return ptr; // 环起点
}
}
return NULL;
}
数学推导:
虽然空间复杂度较高,但更直观的实现:
c复制ListNode *detectCycle(ListNode *head) {
unordered_set<ListNode*> visited;
while(head) {
if(visited.count(head)) return head;
visited.insert(head);
head = head->next;
}
return NULL;
}
在以下场景特别有用:
c复制ListNode dummy = {0, head};
ListNode* curr = &dummy;
// 操作结束后返回dummy.next
| 问题 | 最佳解法 | 时间复杂度 | 空间复杂度 | 适用场景 |
|---|---|---|---|---|
| 两两交换 | 迭代法 | O(n) | O(1) | 通用场景 |
| 删除倒数第N节点 | 双指针 | O(n) | O(1) | 链表较长时 |
| 相交链表 | 浪漫相遇法 | O(m+n) | O(1) | 内存受限 |
| 环形链表 | Floyd算法 | O(n) | O(1) | 必须检测环起点 |
在实际工程中,除了考虑时间复杂度,还需注意:
我个人的经验是,对于链表问题,先画出节点和指针的变化示意图,再动手编码,可以避免80%以上的指针操作错误。特别是在处理环形链表时,没有图示很难理解快慢指针的相遇原理。