# 链表、栈和队列 *链表、栈和队列是更复杂数据结构的构建模块。本文件涵盖它们的运行机制,然后构建关键模式:快慢指针、单调栈和基于堆的优先队列,通过逐步增加难度的题目,并在每一步指出常见陷阱。* - 数组提供了快速的随机访问但插入代价高。**链表**提供了快速插入但没有随机访问。**栈**和**队列**将访问限制在一端或两端,而正是这种限制使它们强大:通过限制你能做的事情,它们简化了你需要考虑的事情。 ## 链表 - **单向链表**是一个节点链。每个节点存储一个值和一个指向下一个节点的指针。最后一个节点指向 `null`。 ```python class ListNode: def __init__(self, val=0, next=None): self.val = val self.next = next ``` - **相对于数组的优势**:在已知位置插入或删除是 $O(1)$(只需重新指向指针)。无需移动元素。 - **劣势**:访问元素 $i$ 需要 $O(i)$ 次遍历(无随机访问)。缓存局部性差(节点分散在内存中)。 - **双向链表**增加了一个 `prev` 指针,支持向后遍历。用于 LRU 缓存(常数时间删除任何节点)和浏览器历史(前进/后退)。 | 操作 | 单向 | 双向 | |-----------|--------|--------| | 按索引访问 | $O(n)$ | $O(n)$ | | 在头部插入 | $O(1)$ | $O(1)$ | | 在尾部插入 | $O(n)$ 或 $O(1)$* | $O(1)$ | | 删除给定节点 | $O(n)$** | $O(1)$ | | 搜索 | $O(n)$ | $O(n)$ | *有尾指针时。**需要前驱节点,需要遍历。 - **哨兵节点**(虚拟头/尾节点)简化了边界情况。没有虚拟头节点时,在头部插入或删除头部需要特殊代码。有了虚拟节点,每个真实节点都有前驱。 ```python # 无虚拟节点:头部删除需要特殊处理 def delete_head(head): if not head: return None return head.next # 有虚拟节点:统一逻辑 dummy = ListNode(0) dummy.next = head # 现在每次删除都是:prev.next = prev.next.next ``` - **陷阱**:忘记处理空列表(`head is None`)或单元素列表。始终测试这些边界情况。 --- ## 模式:快慢指针(弗洛伊德算法) - 使用两个以不同速度移动的指针来检测链表的属性。**慢**指针一次移动一步;**快**指针一次移动两步。 ### 简单:环形链表 - **问题**:判断一个链表是否有环。 - **模式**:如果有环,快指针最终会追上慢指针(它们会相遇)。如果没有环,快指针会到达 `null`。 ```python def has_cycle(head): slow = fast = head while fast and fast.next: slow = slow.next fast = fast.next.next if slow == fast: return True return False ``` - **为什么有效**:如果环的长度为 $c$,快指针每步缩小1个节点的差距。它们必在慢指针进入环后的 $c$ 步内相遇。 - **陷阱**:检查 `fast and fast.next`(而不仅仅是 `fast.next`)。如果 `fast` 是 `None`,调用 `fast.next` 会崩溃。 ### 中等:寻找链表的中间节点 - **问题**:返回中间节点。 - **模式**:当快指针到达末尾时,慢指针在中间。 ```python def find_middle(head): slow = fast = head while fast and fast.next: slow = slow.next fast = fast.next.next return slow # slow 在中间(偶数长度时为第二个中间节点) ``` ### 中等:环形链表 II(寻找环的起点) - **问题**:返回环开始的节点。 - **模式**:在快指针和慢指针相遇后,将一个指针重置到头部。两者以速度1移动。它们在环的起点相遇。 ```python def detect_cycle(head): slow = fast = head while fast and fast.next: slow = slow.next fast = fast.next.next if slow == fast: # 将一个指针重置到头部 slow = head while slow != fast: slow = slow.next fast = fast.next return slow return None ``` - **为什么有效**:设从头到环起点的距离为 $a$,从环起点到相遇点的距离为 $b$。慢指针走了 $a + b$。快指针走了 $2(a + b)$。差值为一整圈:$a + b = c$(环长)。所以 $a = c - b$:从头到环起点的距离等于从相遇点到环起点的距离(沿环向前走)。 ### 困难:K个一组反转链表 - **问题**:反转链表中每 $k$ 个连续节点。 ```python def reverse_k_group(head, k): # 检查是否还有 k 个节点 node = head for _ in range(k): if not node: return head node = node.next # 反转 k 个节点 prev, curr = None, head for _ in range(k): nxt = curr.next curr.next = prev prev = curr curr = nxt # 当前 head 现在是反转后的组的尾节点 # 递归处理剩余部分 head.next = reverse_k_group(curr, k) return prev # prev 是这组的新头节点 ``` - **陷阱**:原地反转模式(`prev, curr, nxt`)值得记住。画出来:每一步,你将 `curr.next` 指回 `prev`,然后推进所有三个指针。顺序搞错会破坏链表。 --- ## 栈 - **栈**是 LIFO(后进先出):最近添加的元素最先被移除。想象一堆盘子。 - 操作:`push(x)` 添加到顶部,`pop()` 从顶部移除,`peek()` 查看顶部不移除。全部 $O(1)$。 - 栈是**递归**(调用栈)、**表达式求值**(中缀转后缀)和**撤销操作**(每个操作被入栈,撤销时弹出最后一个)背后的隐式结构。 ### 简单:有效的括号 - **问题**:给定一个由括号 `()[]{}` 组成的字符串,判断它们是否平衡。 - **模式**:将左括号入栈。当看到右括号时,检查栈顶是否匹配。 ```python def is_valid(s): stack = [] matching = {')': '(', ']': '[', '}': '{'} for char in s: if char in matching: if not stack or stack[-1] != matching[char]: return False stack.pop() else: stack.append(char) return len(stack) == 0 ``` - **陷阱**:忘记最后检查 `len(stack) == 0`。字符串 "(((" 中没有不匹配的情况,但因为没有闭合的括号,它是无效的。 --- ## 模式:单调栈 - **单调栈**维护按排序顺序排列的元素(递增或递减)。当新元素会破坏顺序时,你弹出元素直到顺序恢复。 - **何时使用**:问题要求"对每个元素,找到下一个/上一个更大/更小的元素。"栈的总时间复杂度为 $O(n)$,因为每个元素最多被入栈和出栈一次。 ### 中等:每日温度 - **问题**:给定每日温度,对于每一天,找到需要等多少天才会升温。 - **模式**:使用一个索引栈。当当前温度高于栈顶时,弹出并记录距离。 ```python def daily_temperatures(temperatures): n = len(temperatures) result = [0] * n stack = [] # 索引栈,温度递减 for i in range(n): while stack and temperatures[i] > temperatures[stack[-1]]: prev = stack.pop() result[prev] = i - prev stack.append(i) return result ``` - 每个元素被入栈一次,最多出栈一次:总计 $O(n)$。 - **陷阱**:在栈中存储索引(而非值)。你需要索引来计算距离。 ### 困难:柱状图中最大的矩形 - **问题**:给定一个条形高度数组,找出最大矩形的面积。 - **模式**:对于每个条形,找出它可以向左右延伸多远(即,每侧最近的更短条形)。单调递增栈高效地追踪这个信息。 ```python def largest_rectangle(heights): stack = [] # 索引栈,高度递增 max_area = 0 heights.append(0) # 哨兵,用于最后清空栈 for i, h in enumerate(heights): start = i while stack and stack[-1][1] > h: idx, height = stack.pop() max_area = max(max_area, height * (i - idx)) start = idx # 当前条形可以延伸到被弹出条形开始的位置 stack.append((start, h)) heights.pop() # 移除哨兵 return max_area ``` - **陷阱**:`start = idx` 这行很微妙。当我们弹出一个比当前条形更高的条形时,当前条形可以向后延伸至被弹出条形开始的位置(因为中间的所有条形至少和被弹出条形一样高)。缺少这行会得到错误的面积。 - **陷阱**:哨兵 `heights.append(0)` 确保栈中所有剩余的条形被处理。没有它,那些右侧从未遇到更短条形的条形会被遗漏。 --- ## 队列 - **队列**是 FIFO(先进先出):元素从后面添加,从前面移除。想象商店里排队。 - **双端队列**(deque)支持在两端 $O(1)$ 插入和删除。Python 的 `collections.deque` 是标准实现。 - 队列是 **BFS**(广度优先搜索,第14章文件04)、**任务调度**和**消息传递**背后的结构。 ### 简单:用栈实现队列 - **问题**:仅使用两个栈实现一个队列。 - **模式**:使用一个栈进行入队操作,一个栈进行出队操作。当出队栈为空时,将所有元素从入队栈转移到出队栈(反转顺序)。 ```python class MyQueue: def __init__(self): self.push_stack = [] self.pop_stack = [] def push(self, x): self.push_stack.append(x) def pop(self): if not self.pop_stack: while self.push_stack: self.pop_stack.append(self.push_stack.pop()) return self.pop_stack.pop() def peek(self): if not self.pop_stack: while self.push_stack: self.pop_stack.append(self.push_stack.pop()) return self.pop_stack[-1] def empty(self): return not self.push_stack and not self.pop_stack ``` - 每次操作的平摊复杂度 $O(1)$:每个元素最多在两个栈之间移动一次。 --- ## 优先队列和堆 - **优先队列**总是返回最小(或最大)的元素,不论插入顺序。标准实现是**二叉堆**。 - **最小堆**是一棵完全二叉树,其中每个父节点都小于其子节点。最小值总是在根节点。以数组形式存储:节点 $i$ 的子节点在位置 $2i + 1$ 和 $2i + 2$。 | 操作 | 时间 | |-----------|------| | 插入 | $O(\log n)$ | | 获取最小值 | $O(1)$ | | 提取最小值 | $O(\log n)$ | | 从数组构建堆 | $O(n)$ | - Python 的 `heapq` 模块提供了最小堆。对于最大堆,将值取反。 ```python import heapq # 最小堆 h = [] heapq.heappush(h, 5) heapq.heappush(h, 2) heapq.heappush(h, 8) print(heapq.heappop(h)) # 2(最小) # 最大堆技巧:取反 heapq.heappush(h, -10) print(-heapq.heappop(h)) # 10(最大) ``` ### 中等:数组中的第 K 个最大元素 - **问题**:找到第 k 个最大的元素。 - **模式**:维护一个大小为 $k$ 的最小堆。堆的根节点就是第 k 大的元素。如果堆有 $k$ 个元素且新元素大于根节点,则替换根节点。 ```python import heapq def find_kth_largest(nums, k): heap = nums[:k] heapq.heapify(heap) # O(k) for num in nums[k:]: if num > heap[0]: heapq.heapreplace(heap, num) # 弹出最小值,推入 num:O(log k) return heap[0] ``` - $O(n \log k)$ 时间,$O(k)$ 空间。当 $k \ll n$ 时,这比排序($O(n \log n)$)好得多。 - **陷阱**:使用大小为 $n$ 的最大堆并弹出 $k$ 次也可行但较慢:$O(n + k \log n)$。大小为 $k$ 的最小堆是最优方法。 ### 困难:合并 K 个排序链表 - **问题**:合并 $k$ 个已排序链表为一个排序链表。 - **模式**:使用一个包含每个链表头节点的最小堆。弹出最小的节点,将其添加到结果中,并将其下一个节点推入堆中。 ```python import heapq def merge_k_lists(lists): heap = [] for i, lst in enumerate(lists): if lst: heapq.heappush(heap, (lst.val, i, lst)) dummy = ListNode(0) curr = dummy while heap: val, i, node = heapq.heappop(heap) curr.next = node curr = curr.next if node.next: heapq.heappush(heap, (node.next.val, i, node.next)) return dummy.next ``` - $O(n \log k)$,其中 $n$ 是总节点数。堆中最多有 $k$ 个元素。 - **陷阱**:堆元组中的 `i`(索引)是用于打破平局的。没有它,当值相等时 Python 会尝试比较 `ListNode` 对象,这会崩溃因为 `ListNode` 不支持 `<`。索确保了一有效的比较。 --- ## 常见陷阱总结 | 陷阱 | 示例 | 修复 | |---------|---------|-----| | `fast.next` 上的空指针 | 循环检测中使用 `while fast.next` | 检查 `fast and fast.next` | | 未处理空链表 | 反转 `None` | 添加 `if not head` 守卫 | | 栈下溢 | 从空栈弹出 | 检查 `len(stack) > 0` 或 `if stack` | | 忘记哨兵 | 直方图遗漏了最后的条形 | 追加 0 来清空栈 | | 堆中缺少平局打破 | 比较不可比较的对象 | 向堆元组添加索引 | | 遍历时修改链表 | 遍历时删除节点 | 使用 prev/curr 模式或虚拟头节点 | --- ## 课后练习题(NeetCode) ### 链表 - [反转链表](https://neetcode.io/problems/reverse-a-linked-list) — 基础的原地反转 - [合并两个有序链表](https://neetcode.io/problems/merge-two-sorted-linked-lists) — 双指针合并 - [环形链表](https://neetcode.io/problems/linked-list-cycle-detection) — 快慢指针 - [重排链表](https://neetcode.io/problems/reorder-linked-list) — 找中间 + 反转 + 合并 - [删除链表的倒数第 N 个节点](https://neetcode.io/problems/remove-node-from-end-of-linked-list) — 间距为 $n$ 的双指针 - [LRU 缓存](https://neetcode.io/problems/lru-cache) — 哈希表 + 双向链表 ### 栈 - [有效的括号](https://neetcode.io/problems/validate-parentheses) — 括号匹配 - [最小栈](https://neetcode.io/problems/minimum-stack) — 在每层跟踪最小值 - [逆波兰表达式求值](https://neetcode.io/problems/evaluate-reverse-polish-notation) — 基于栈的求值 - [每日温度](https://neetcode.io/problems/daily-temperatures) — 单调递减栈 - [柱状图中最大的矩形](https://neetcode.io/problems/largest-rectangle-in-histogram) — 单调递增栈 - [车队](https://neetcode.io/problems/car-fleet) — 带到达时间的栈 ### 堆 / 优先队列 - [数据流中的第 K 大元素](https://neetcode.io/problems/kth-largest-integer-in-a-stream) — 大小为 $k$ 的最小堆 - [最后一块石头的重量](https://neetcode.io/problems/last-stone-weight) — 最大堆模拟 - [最接近原点的 K 个点](https://neetcode.io/problems/k-closest-points-to-origin) — 按距离排序的最小堆 - [任务调度器](https://neetcode.io/problems/task-scheduler) — 贪心 + 最大堆 + 冷却时间 - [数据流的中位数](https://neetcode.io/problems/find-median-in-a-data-stream) — 双堆(下半部分用最大堆,上半部分用最小堆)