Skip to content

链表、栈与队列

链表、栈和队列是更复杂数据结构的构建块。本篇将讲解它们的机制,然后通过逐渐深入的问题,构建关键模式:快慢指针、单调栈以及基于堆的优先队列,并在每一步指出常见陷阱。

  • 数组提供了快速的随机访问,但插入代价高昂。链表提供了快速的插入,但没有随机访问。队列将访问限制在一端或两端,而这种限制正是它们强大的原因:通过限制你能做的事情,它们简化了你需要思考的内容。

链表

  • 单向链表 是一个节点链。每个节点存储一个值和一个指向下一个节点的指针。最后一个节点指向 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)或只有一个元素的链表。务必测试这些边界情况。

模式:快慢指针(Floyd 判圈算法)

  • 使用两个以不同速度移动的指针来检测链表的属性。慢指针一次移动一步;快指针一次移动两步。

简单:环形链表

  • 问题:判断一个链表是否有环。

  • 模式:如果有环,快指针最终会追上慢指针(它们会相遇)。如果没有环,快指针会到达 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)。如果 fastNone,调用 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(先进先出):元素在后端添加,从前端移除。想象商店里排队。

  • 双端队列 支持在两端进行 \(O(1)\) 的插入和删除。Python 的 collections.deque 是标准实现。

  • 队列是 BFS(广度优先搜索)任务调度消息传递 背后的结构。

简单:用栈实现队列

  • 问题:仅使用两个栈来实现队列。

  • 模式:一个栈用于入队,一个栈用于出队。当出队栈为空时,将所有元素从入队栈转移过来(这会逆转顺序)。

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) > 0if stack
忘记哨兵 柱状图遗漏最后几个柱子 追加 0 来清空栈
堆中缺少打破平局的键 比较不可比较的对象 在堆元组中添加索引
遍历时修改链表 在遍历时删除节点 使用 prev/curr 模式或哑头

课后练习(NeetCode)

链表

堆 / 优先队列