跳转至

# 链表、栈和队列

链表、栈和队列是更复杂数据结构的构建模块。本文件涵盖它们的运行机制，然后构建关键模式：快慢指针、单调栈和基于堆的优先队列，通过逐步增加难度的题目，并在每一步指出常见陷阱。

- 数组提供了快速的随机访问但插入代价高。链表提供了快速插入但没有随机访问。栈和队列将访问限制在一端或两端，而正是这种限制使它们强大：通过限制你能做的事情，它们简化了你需要考虑的事情。

## 链表

- 单向链表是一个节点链。每个节点存储一个值和一个指向下一个节点的指针。最后一个节点指向 `null`。

    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)\)

有尾指针时。*需要前驱节点，需要遍历。

- 哨兵节点（虚拟头/尾节点）简化了边界情况。没有虚拟头节点时，在头部插入或删除头部需要特殊代码。有了虚拟节点，每个真实节点都有前驱。

    # 无虚拟节点：头部删除需要特殊处理
    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`。

    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` 会崩溃。

### 中等：寻找链表的中间节点

- 问题：返回中间节点。

- 模式：当快指针到达末尾时，慢指针在中间。

    def find_middle(head):
        slow = fast = head
        while fast and fast.next:
            slow = slow.next
            fast = fast.next.next
        return slow  # slow 在中间（偶数长度时为第二个中间节点）

### 中等：环形链表 II（寻找环的起点）

- 问题：返回环开始的节点。

- 模式：在快指针和慢指针相遇后，将一个指针重置到头部。两者以速度1移动。它们在环的起点相遇。

    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\) 个连续节点。

    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)\)。

- 栈是递归（调用栈）、表达式求值（中缀转后缀）和撤销操作（每个操作被入栈，撤销时弹出最后一个）背后的隐式结构。

### 简单：有效的括号

- 问题：给定一个由括号 `()[]{}` 组成的字符串，判断它们是否平衡。

- 模式：将左括号入栈。当看到右括号时，检查栈顶是否匹配。

    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)\)，因为每个元素最多被入栈和出栈一次。

### 中等：每日温度

- 问题：给定每日温度，对于每一天，找到需要等多少天才会升温。

- 模式：使用一个索引栈。当当前温度高于栈顶时，弹出并记录距离。

    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)\)。

- 陷阱：在栈中存储索引（而非值）。你需要索引来计算距离。

### 困难：柱状图中最大的矩形

- 问题：给定一个条形高度数组，找出最大矩形的面积。

- 模式：对于每个条形，找出它可以向左右延伸多远（即，每侧最近的更短条形）。单调递增栈高效地追踪这个信息。

    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）、任务调度和消息传递背后的结构。

### 简单：用栈实现队列

- 问题：仅使用两个栈实现一个队列。

- 模式：使用一个栈进行入队操作，一个栈进行出队操作。当出队栈为空时，将所有元素从入队栈转移到出队栈（反转顺序）。

    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` 模块提供了最小堆。对于最大堆，将值取反。

    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\) 个元素且新元素大于根节点，则替换根节点。

    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\) 个已排序链表为一个排序链表。

- 模式：使用一个包含每个链表头节点的最小堆。弹出最小的节点，将其添加到结果中，并将其下一个节点推入堆中。

    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）

### 链表

- 反转链表 — 基础的原地反转
- 合并两个有序链表 — 双指针合并
- 环形链表 — 快慢指针
- 重排链表 — 找中间 + 反转 + 合并
- 删除链表的倒数第 N 个节点 — 间距为 \(n\) 的双指针
- LRU 缓存 — 哈希表 + 双向链表

### 栈

- 有效的括号 — 括号匹配
- 最小栈 — 在每层跟踪最小值
- 逆波兰表达式求值 — 基于栈的求值
- 每日温度 — 单调递减栈
- 柱状图中最大的矩形 — 单调递增栈
- 车队 — 带到达时间的栈

### 堆 / 优先队列

- 数据流中的第 K 大元素 — 大小为 \(k\) 的最小堆
- 最后一块石头的重量 — 最大堆模拟
- 最接近原点的 K 个点 — 按距离排序的最小堆
- 任务调度器 — 贪心 + 最大堆 + 冷却时间
- 数据流的中位数 — 双堆（下半部分用最大堆，上半部分用最小堆）