跳转到内容

双端队列

双端队列(Double-Ended Queue,简称 deque)是一种特殊的线性数据结构,它允许你在队列的两端(头部和尾部)都能高效地进行添加和删除操作。你可以把它想象成一条双车道的高速公路,车辆可以从入口进入,也可以从出口离开,而且入口和出口都支持双向通行。

代码表现形式

在 Python 中,双端队列由 collections 模块提供:

python
from collections import deque

d = deque()

当你第一次用 Python 的 deque 时,可能只是觉得它用起来和 list 差不多——都能 append,都能 pop。但如果你仔细想想,会发现一个奇怪的现象:deque 的 appendleftpopleft 为什么和 list 的对应操作名称不一样?

这个表面上的差异背后,藏着更深层的原理:list 和 deque 的底层数据结构完全不同。list 是动态数组,deque 是分块双向链表。理解了这个底层差异,你才能真正理解为什么在某些场景下 deque 是更好的选择,以及为什么它能实现 O(1) 的头部操作。

为什么 list 的头部操作是 O(n)

在讨论 deque 之前,我们先来理解 list 为什么在头部操作上效率低下。

假设有一个 list,它的内部结构大致是这样的:

[elem0, elem1, elem2, elem3, elem4, ...]

 起始地址

当你执行 list.insert(0, x) 时,Python 必须把 elem0 到 elem4 整体向后移动一位,然后在位置 0 放入新元素。这个移动操作需要复制每一个元素的指针,对于长度为 n 的列表,这是 O(n) 的操作。

同样的,当你执行 list.pop(0) 时,Python 必须把 elem1 到 elemn-1 整体向前移动一位,这是也是 O(n) 的操作。

这个问题的本质在于:list 使用的是连续内存结构。就像电影院的一排座位,如果有人要坐到最左边所有人的都得挪一挪。但如果你用的是双端队列,就像是一条走廊,两端都可以进入,不需要移动中间的人。

deque 的底层结构

deque(Double-Ended Queue)的全称是双端队列,它在底层使用的是"分块双向链式结构"。

你可以把它想象成这样的一种数据结构:每个块是一个固定大小的数组(Python 中默认是 64 个元素),块和块之间通过双向指针链接:

[block A] <-> [block B] <-> [block C] <-> [block D]

当你在右侧添加元素时,如果当前块还有空间,就直接写入;如果满了,就创建新块并链接到尾部。

当你在左侧添加元素时也是如此:如果当前块头部还有空间,就直接写入;如果满了,就创建新块并链接到头部。

关键在于:无论是头部还是尾部操作,都只需要操作当前块的固定位置,不需要移动其他块里的任何元素。这就是为什么 deque 的两端操作都是 O(1)。

python
from collections import deque

d = deque()

# 两端操作都是 O(1)
d.append(1)      # 右端添加
d.appendleft(0)  # 左端添加
d.pop()          # 右端删除,O(1)
d.popleft()      # 左端删除,O(1)

为什么不用纯链表

既然 deque 这么好,为什么 Python 不直接用传统的双向链表(每个节点单独分配内存)?

答案在于内存效率。传统的双向链表每个节点都需要额外的指针空间,而且这些指针分散在内存各处,导致 CPU 缓存命中率低。

deque 的"分块"设计是一个精妙的折中:每个块是一个小数组(64 元素),块内元素是连续内存保证了缓存友好性,而块间的指针链接又保证了在两端的 O(1) 操作。

从内存局部性角度来看,遍历 deque 的 block 内的元素是缓存友好的,因为它们在内存上是连续的。只有在 block 边界才需要跟随指针跳转到下一个 block。这是性能和内存效率的完美平衡。

maxlen:有界队列

deque 有一个独特的功能:可以设置最大长度。当队列满时,自动丢弃对侧的元素。

python
from collections import deque

window = deque(maxlen=3)

for i in range(5):
    window.append(i)
    print(window)

输出:

deque([0], maxlen=3)
deque([0, 1], maxlen=3)
deque([0, 1, 2], maxlen=3)
deque([1, 2, 3], maxlen=3)
deque([2, 3, 4], maxlen=3)

当队列满后再添加元素,会自动从对侧删除最老的元素。这在实现滑动窗口、保留最近 N 条记录、限制缓冲区大小等场景时非常有用。

这个功能在流式处理和数据流控制中特别有用。比如你要实现一个实时监控系统,只想保留最近 100 条错误日志:

python
recent_errors = deque(maxlen=100)

rotate 的实现原理

deque 的 rotate 方法也是面试常问的考点。

rotate(n) 将队列中的元素向右旋转 n 位。如果 n 是正数,元素向右移动;如果 n 是负数,元素向左移动。

python
d = deque([1, 2, 3, 4, 5])

d.rotate(2)
print(d)  # deque([4, 5, 1, 2, 3])

d.rotate(-1)
print(d)  # deque([5, 1, 2, 3, 4])

rotate 的实现原理并不是真的移动所有元素——它只是在逻辑上调整了头尾指针的位置。这使得 rotate 的时间复杂度是 O(k),其中 k 是旋转的步数,而不是 O(n)。

理解这一点很重要:如果 rotate 需要真的移动所有元素,那它就会是 O(n) 的。但实际上它只是调整指针,所以无论 deque 有多长,rotate 的成本只和旋转的步数有关。

索引访问与中间操作

deque 支持索引访问:d[0] 获取第一个元素,d[-1] 获取最后一个元素。

但要注意,deque 不是连续内存结构,访问中间位置时需要跨 block 查找,时间复杂度是 O(n)。如果你需要大量的随机索引访问,list 仍然是更好的选择。

python
d = deque([1, 2, 3, 4, 5])

print(d[0])   # O(1)
print(d[-1])  # O(1)
print(d[2])   # O(n)

deque 的 insertremove 操作也是在中间位置进行的,时间复杂度是 O(n)。deque 的设计目标不是中间操作,而是两端操作——这是它的核心价值所在。

广度优先搜索中的典型应用

deque 最重要的应用场景之一是广度优先搜索(BFS)。

在 BFS 中,我们需要一个队列来存储待访问的节点。每当我们访问一个节点,就把它所有未访问的邻居加入队列。由于我们总是从队列头部取出节点(dequeue),并且从队列尾部添加新节点(enqueue),这正好对应了 deque 的 popleftappend 操作。

python
from collections import deque

def bfs(graph, start):
    visited = set()
    queue = deque([start])
    visited.add(start)

    while queue:
        node = queue.popleft()
        print(node, end=" ")

        for neighbor in graph[node]:
            if neighbor not in visited:
                visited.add(neighbor)
                queue.append(neighbor)

如果用 list 代替 deque 来实现 BFS,每次 popleft(0) 都是 O(n) 的操作(需要移动所有剩余元素),会导致 BFS 的时间复杂度从 O(V+E) 退化到 O(V²+E)。

单调队列与滑动窗口最大值

deque 另一个重要的应用是单调队列,可以用来解决"滑动窗口最大值"问题。

滑动窗口最大值的朴素解法是遍历窗口内的所有元素找最大值,时间复杂度是 O(nk)。但用单调递减队列可以把时间复杂度降到 O(n)。

核心思想是:维护一个从大到小递减的 deque,deque 的头部永远保存当前窗口的最大值。

python
from collections import deque

def max_sliding_window(nums, k):
    dq = deque()  # 存储索引
    result = []

    for i, num in enumerate(nums):
        # 移除超出窗口范围的元素
        while dq and dq[0] <= i - k:
            dq.popleft()

        # 移除所有比当前元素小的元素(它们不可能是最大值)
        while dq and nums[dq[-1]] < num:
            dq.pop()

        # 添加当前元素
        dq.append(i)

        # 当窗口形成后记录答案
        if i >= k - 1:
            result.append(nums[dq[0]])

    return result

这个算法的精妙之处在于:deque 允许我们在两端高效地添加和删除元素,使得我们可以维护一个"单调递减"的队列——队列里的元素是递减的,所以头部永远是当前窗口的最大值。

extendleft 的顺序陷阱

deque 的 extendleft 有一个容易踩的坑:它的行为和我们直觉可能不一样。

当你用 extendleft 添加一个可迭代对象时,这些元素是"反向"添加的——第一个元素会被放到最左边。

python
d = deque([1, 2])
d.extendleft([3, 4, 5])
print(d)  # deque([5, 4, 3, 1, 2])

这是因为 extendleft 实际上是逐个执行 appendleft,每次都把新元素放到最左边。所以最后添加的元素反而在最右边。

理解这一点很重要:在需要按顺序添加多个元素到左侧时,应该先反转可迭代对象,或者使用其他方式。

时间复杂度总结

理解 deque 的性能特征,关键是记住它的设计目标:两端操作高效,中间操作低效。

操作时间复杂度说明
appendO(1)右端添加
appendleftO(1)左端添加
popO(1)右端删除
popleftO(1)左端删除
索引访问两端O(1)d[0], d[-1]
索引访问中间O(n)d[i], i 不是两端
insertO(n)中间插入
removeO(n)中间删除
rotateO(k)k 是旋转步数

关键理解:deque 优化的是"两端",不是"中间"。如果你需要大量在中间位置插入或删除,list 仍然是更好的选择。deque 不是 list 的替代品,而是不同场景下的不同选择。

内存模型与缓存

deque 的分块结构使得它在遍历时有一定的缓存局部性优势——每个 block 内的元素在内存上是连续的,访问起来比纯链表的缓存命中率高。

但如果你的场景是纯粹顺序遍历(从头到尾),list 的表现通常会更好,因为 list 整个是连续内存,而 deque 在 block 边界需要跳转。

所以选择数据结构的标准是:如果主要操作是两端,选 deque;如果主要操作是纯顺序遍历,选 list

常见误区

第一个误区是认为 deque 可以完全替代 list。deque 在中间操作上不如 list 高效,所以如果你需要频繁在列表中间插入或删除,或者需要随机访问,list 仍然是更好的选择。

第二个误区是忘记 extendleft 是反向添加。如果你不理解这个行为,可能会写出 bug。记住:extendleft 是逐个 appendleft,所以后添加的元素会在更右侧。

第三个误区是在 deque 上做大量中间操作。deque 的设计目标是两端操作,不是中间操作。在 deque 的中间位置插入或删除是 O(n) 的,和 list 相比没有优势。

第四个误区是不理解 maxlen 的行为。当 deque 满时,新元素会从对侧挤出老的元素。这个行为在某些场景下很有用,但在另一些场景下可能导致意外的数据丢失。

面试核心回答

面试中关于 deque 最常问的问题是:"deque 和 list 的区别是什么?"

一个完整且深入的回答应该包含以下要点:

deque 使用分块双向链式结构,在两端操作(append、appendleft、pop、popleft)时只移动指针,不需要整体元素搬移,所以是 O(1)。而 list 使用连续内存结构,在头部操作时需要移动所有元素,所以是 O(n)。

deque 的这种结构牺牲了中间位置的随机访问性能——访问中间位置需要跨 block 查找,是 O(n)。而 list 的随机访问是 O(1)。

此外,deque 支持 maxlen 参数,可以限制队列长度,满队时自动挤出老元素,这在实现滑动窗口等功能时非常有用。而 list 没有这个功能。

选择使用哪个数据结构,取决于你的访问模式:如果主要是从两端添加删除,deque 更快;如果主要是随机访问或中间操作,list 更合适。

基于 MIT 许可发布