1. 单链表基础回顾与面试题概览
单链表作为数据结构中最基础的链式存储结构,在技术面试中出现的频率居高不下。我见过太多候选人因为对单链表的基本操作理解不够深入,在面试中错失良机。让我们先快速回顾单链表的核心特性:
每个节点包含数据域和指针域,指针域存储下一个节点的地址。与数组不同,单链表的节点在内存中不必连续存储,通过指针串联形成逻辑上的线性结构。这种特性带来了插入/删除O(1)时间复杂度的优势,但牺牲了随机访问能力(必须从头遍历)。
面试中常见的单链表题目主要考察以下几个维度:
- 基础操作能力(遍历、插入、删除)
- 边界条件处理(空链表、头尾节点)
- 算法思维(双指针、递归)
- 空间复杂度优化(原地操作)
接下来我将拆解5类高频面试题,包含代码实现、复杂度分析和易错点。这些题目来自我过去三年作为面试官的真实题库,以及LeetCode等平台的热门题目。
2. 单链表基本操作面试题精讲
2.1 链表长度计算与遍历陷阱
计算链表长度看似简单,但隐藏着几个关键细节:
public int getLength(ListNode head) { if (head == null) return 0; // 空链表判断 int count = 0; ListNode current = head; while (current != null) { // 注意不是current.next count++; current = current.next; } return count; }常见错误包括:
- 忽略头节点为null的情况
- 循环条件误用current.next导致少计数一次
- 修改了原链表头节点(应用临时变量current)
时间复杂度O(n),空间复杂度O(1)。这是大多数链表题的基础操作,建议熟练掌握。
2.2 倒数第K个节点查找的双指针技巧
这是经典的快慢指针应用场景:
public ListNode findKthFromEnd(ListNode head, int k) { if (head == null || k <= 0) return null; ListNode fast = head, slow = head; // 快指针先走k步 for (int i = 0; i < k; i++) { if (fast == null) return null; // k超过链表长度 fast = fast.next; } // 双指针同步前进 while (fast != null) { fast = fast.next; slow = slow.next; } return slow; }这个解法只需一次遍历,时间复杂度O(n)。关键点在于:
- 处理k大于链表长度的边界情况
- 快指针移动k步后,慢指针才开始移动
- 当快指针到达末尾时,慢指针正好在倒数第k个位置
3. 链表反转的三种实现方式
3.1 迭代法反转链表
最经典的解法,需要维护三个指针:
public ListNode reverseList(ListNode head) { ListNode prev = null; ListNode curr = head; while (curr != null) { ListNode nextTemp = curr.next; // 保存下一个节点 curr.next = prev; // 反转指针 prev = curr; // 前移prev curr = nextTemp; // 前移curr } return prev; // 新头节点 }这个实现的空间复杂度是O(1),因为只使用了固定数量的额外空间。常见错误包括:
- 丢失节点引用(必须先保存curr.next)
- 反转后未正确返回新头节点(应该是prev不是curr)
3.2 递归法实现反转
递归解法更简洁但更难理解:
public ListNode reverseListRecursive(ListNode head) { if (head == null || head.next == null) { return head; } ListNode p = reverseListRecursive(head.next); head.next.next = head; // 反转指针 head.next = null; // 断开原指针 return p; }递归深度为n,空间复杂度O(n)。关键点在于:
- 基准条件处理空链表或单节点链表
- 递归反转后续链表
- 将当前节点连接到已反转链表的末尾
3.3 头插法反转链表
利用虚拟头节点实现:
public ListNode reverseWithDummy(ListNode head) { ListNode dummy = new ListNode(-1); ListNode curr = head; while (curr != null) { ListNode next = curr.next; curr.next = dummy.next; // 将当前节点插入dummy之后 dummy.next = curr; curr = next; } return dummy.next; }这种方法在需要保持原链表不被破坏的场景特别有用,因为可以随时通过dummy节点访问新链表。
4. 链表排序与合并问题
4.1 合并两个有序链表
经典的归并思路:
public ListNode mergeTwoLists(ListNode l1, ListNode l2) { ListNode dummy = new ListNode(-1); ListNode curr = dummy; while (l1 != null && l2 != null) { if (l1.val <= l2.val) { curr.next = l1; l1 = l1.next; } else { curr.next = l2; l2 = l2.next; } curr = curr.next; } // 连接剩余部分 curr.next = (l1 != null) ? l1 : l2; return dummy.next; }时间复杂度O(m+n),空间复杂度O(1)。注意点:
- 使用dummy节点简化头节点处理
- 最后要处理未遍历完的链表剩余部分
- 保持稳定性(相等时优先选择l1的节点)
4.2 链表排序的归并实现
结合归并排序和链表合并:
public ListNode sortList(ListNode head) { if (head == null || head.next == null) return head; // 使用快慢指针找到中点 ListNode slow = head, fast = head, prev = null; while (fast != null && fast.next != null) { prev = slow; slow = slow.next; fast = fast.next.next; } prev.next = null; // 切断链表 // 递归排序两个子链表 ListNode l1 = sortList(head); ListNode l2 = sortList(slow); // 合并已排序链表 return mergeTwoLists(l1, l2); }时间复杂度O(nlogn),空间复杂度O(logn)(递归栈)。这是链表排序的最优解法,比插入排序更适合长链表。
5. 环形链表检测与入口定位
5.1 快慢指针检测环形链表
public boolean hasCycle(ListNode head) { if (head == null) return false; ListNode slow = head, fast = head; while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; if (slow == fast) return true; } return false; }时间复杂度O(n),空间复杂度O(1)。关键点:
- 快指针每次移动两步,慢指针每次一步
- 相遇说明有环,快指针到达null说明无环
- 初始条件处理空链表情况
5.2 环形链表入口定位
找到相遇点后,数学推导可得:
public ListNode detectCycle(ListNode head) { ListNode meet = getMeetNode(head); if (meet == null) return null; ListNode p1 = head, p2 = meet; while (p1 != p2) { p1 = p1.next; p2 = p2.next; } return p1; } private ListNode getMeetNode(ListNode head) { ListNode slow = head, fast = head; while (fast != null && fast.next != null) { slow = slow.next; fast = fast.next.next; if (slow == fast) return slow; } return null; }这个算法基于一个重要的数学关系:从head到环入口的距离等于从相遇点到环入口的距离。因此,在找到相遇点后,用两个指针分别从head和相遇点出发,相遇点即为环入口。
6. 复杂链表操作与边界处理
6.1 删除倒数第N个节点
结合虚拟头节点和双指针:
public ListNode removeNthFromEnd(ListNode head, int n) { ListNode dummy = new ListNode(0); dummy.next = head; ListNode fast = dummy, slow = dummy; // 快指针先走n+1步 for (int i = 0; i <= n; i++) { if (fast == null) return head; // n超出长度 fast = fast.next; } // 同步移动直到快指针到达末尾 while (fast != null) { fast = fast.next; slow = slow.next; } // 删除slow的下一个节点 slow.next = slow.next.next; return dummy.next; }使用虚拟头节点可以统一处理删除头节点的情况。时间复杂度O(n),空间复杂度O(1)。
6.2 链表相交问题
判断两个链表是否相交并找到交点:
public ListNode getIntersectionNode(ListNode headA, ListNode headB) { if (headA == null || headB == null) return null; ListNode pA = headA, pB = headB; while (pA != pB) { pA = (pA == null) ? headB : pA.next; pB = (pB == null) ? headA : pB.next; } return pA; }这个巧妙的解法让两个指针分别遍历两个链表,最终会在交点相遇或同时到达null。时间复杂度O(m+n),空间复杂度O(1)。
7. 链表操作优化技巧总结
经过这些题目的训练,我总结出链表操作的几个核心技巧:
- 虚拟头节点:处理头节点可能被修改的情况,避免复杂的边界判断
- 快慢指针:解决环检测、中点查找、倒数第k个等问题
- 多指针协同:反转链表等操作需要维护多个指针引用
- 递归思维:某些问题(如反转、合并)用递归实现更简洁
- 画图辅助:复杂操作前先画出节点和指针变化示意图
在面试中,建议先明确问题要求,与面试官确认边界条件(如链表是否可能为空、能否修改原链表等),然后选择合适的方法实现。写完代码后,务必用测试用例验证(空链表、单节点链表、头尾节点等特殊情况)。