数据结构-线性结构-双端队列

4 阅读28分钟

概述

在数据结构的世界中,很少有哪种抽象类型像双端队列(Deque,Double-Ended Queue)这样,以如此“低廉的代价”统一了栈(Stack)的 LIFO 与队列(Queue)的 FIFO 两种核心访问模式,还额外提供了双向操作能力。它不只在教科书里是一般线性表的超集,在工程中更是工作窃取算法的基石、滑动窗口极值的最优解,以及 Java 中栈和队列的首选实现。本文将严格遵循从逻辑到物理、从原理到工程的认知路径,先为你建立起双端队列的完整心智模型,再以 Java 世界中的 ArrayDequeLinkedList 等实现为例证,最终深入缓存行为、并发设计与面试要点,让你对双端队列的掌握越过“会用”,直达“可设计”。

  • ADT 与特性:双端队列是允许在两端进行插入和删除的线性表,头尾操作均为 O(1),兼具栈(LIFO)和队列(FIFO)的能力。
  • 适用场景与反模式:极致适用于工作窃取、滑动窗口、双栈模拟、撤销/重做等场景;但不适用于依赖索引随机访问、键值查找或频繁中间插入/删除的场景。
  • 逻辑与物理实现:逻辑上是两端开放的线性表;物理上,循环数组实现缓存友好、空间紧凑,是工程绝对主流;双向链表实现节点离散,迭代器稳定,适用于特殊并发或稳定迭代需求。
  • 超集地位:只需约束操作端,双端队列可直接降维为高效的栈或队列。
  • 工程首选:在 Java 中,ArrayDeque 是双端队列、栈、队列三位一体的“黄金实现”。

文章组织架构

flowchart TD
    subgraph M1[模块1: 概述与核心特性]
        direction TB
        A1[ADT形式化定义]
        A2[核心特性与适用场景]
        A3[反模式与工业概览]
    end
    subgraph M2[模块2: 逻辑与物理实现]
        direction TB
        B1[循环数组详解]
        B2[双向链表详解]
    end
    subgraph M3[模块3: 核心操作与复杂度]
        C1[操作复杂度推导]
        C2[派生栈与队列]
    end
    subgraph M4[模块4: 缓存行为与性能]
        D1[缓存局部性分析]
        D2[循环数组 vs 链表访问模式]
    end
    subgraph M5[模块5: 并发与工作窃取]
        E1[工作窃取算法]
        E2[并发双端队列]
    end
    subgraph M6[模块6: 工程实现与最佳实践]
        F1[Java实现全景对比]
        F2[黄金实现ArrayDeque]
        F3[避坑清单]
    end
    subgraph M7[模块7: 面试高频专题]
        G1[10道核心面试题]
    end

    M1 --> M2 --> M3 --> M4 --> M5 --> M6 --> M7

架构说明

  • 模块 1 从抽象定义与核心特性出发,让你先建立“双端队列能做什么、不能做什么”的直觉,这是选型的基础。
  • 模块 2 与 3 深入到物理存储,揭示循环数组的指针魔法与双向链表的指针调整,并从复杂度角度严格论证头尾 O(1) 的根源。
  • 模块 4 从底层内存访问模式切入,解释为什么循环数组实现几乎是所有高性能场景的默认选择。
  • 模块 5 将视野扩展到并发环境,展现双端队列如何支撑起 ForkJoinPool 的工作窃取调度,以及无锁并发双端队列的设计挑战。
  • 模块 6 回归工程,以 Java 生态为例,给出可运行的代码范式、性能对比以及避坑指南。
  • 模块 7 将所有知识淬炼为面试中的犀利回答,单独成章,避免干扰正文流畅度。

模块 1:双端队列概述与核心特性

一句话定义:双端队列(Deque)是允许在表的两端进行插入和删除的线性表,它融合了栈和队列的能力,是一种最通用的线性操作受限结构

ADT 形式化定义

双端队列的抽象数据类型以 对称的操作集 为核心,并为每一类操作提供“异常版本”与“特殊值版本”(延续了 Java 队列 API 的设计哲学,但在抽象层面普遍适用):

操作类型异常版本特殊值版本(返回 null/false)语义
头部插入addFirst(E e)offerFirst(E e)若容量受限且已满,前者抛异常,后者返回 false
尾部插入addLast(E e)offerLast(E e)同上
头部移除removeFirst()pollFirst()若空,前者抛异常,后者返回 null
尾部移除removeLast()pollLast()同上
头部检查getFirst()peekFirst()若空,前者抛异常,后者返回 null
尾部检查getLast()peekLast()同上
辅助操作isEmpty()size()-判定空与获取元素数量

该 ADT 的对称性使得双端队列可以无缝降级为栈(只从一端操作)或队列(一端入,另一端出),这也是它成为“一体多用”容器的理论基础。

核心特性清单

特性根源工程影响
两端 O(1) 插入 / 删除维护头尾指针或哨兵节点,任何一端插入无需移动元素兼具栈和队列的高效操作,无需类型转换
兼具 Stack 与 Queue 的能力操作集是两者的超集,支持 LIFO 和 FIFO 两种模式一个 ArrayDeque 即可替代 StackLinkedList(作为队列)
可双向迭代物理结构支持从头到尾、从尾到头的双向遍历正反序遍历同一容器,方便回文检查等对称逻辑
缓存友好(循环数组实现)元素在连续内存中存放,头尾操作集中在数组两端的内存区域遍历和批量操作性能远超链表实现,降低 cache miss
可充当工作窃取的介质本地操作从头部进行(LIFO),远程窃取从尾部进行(FIFO),天然解耦ForkJoinPool 的核心基础,减少线程间竞争

适用场景详解

1. 作为栈或队列的通用容器

在许多应用代码中,一个方法可能同时需要后进先出和先进先出的处理逻辑。使用双端队列作为基础容器,只通过不同的方法组合(push/popoffer/poll)即可显现不同的行为,无需维护两个不同的数据结构,消除了类型转换和逻辑分支。例如,一个深度优先搜索(DFS)与广度优先搜索(BFS)切换的遍历器,只需要改变从双端队列的哪一端取下一个节点。

2. 工作窃取(Work Stealing)

在 ForkJoinPool 等细粒度并行框架中,每个工作线程维护一个双端队列。线程将新任务 从头部推入(push),并以 LIFO 方式 从头部取出(pop),这样刚产生的任务数据还在缓存中,局部性最优。当某个线程变为空闲,它会随机挑选另一个线程的双端队列,并从 尾部窃取(poll) 任务(FIFO)。这种不对称操作既照顾了本地缓存,又减少了窃取者与所有者的竞争——因为所有者在头部操作,窃取者在尾部操作。双端队列是这一算法的唯一完美选择

3. 滑动窗口最大值

给定数组和一个固定大小的窗口,求窗口每次滑动时的最大值。使用单调递减双端队列(存储索引),在 O(n) 时间内即可解决。队列头部始终是当前窗口最大值的索引;尾部在新元素加入时移除所有小于新元素的候选。双端队列支持从头部移除过期索引,从尾部维护单调性,双重操作不可或缺。

4. 回文检查

从字符串两端同时取出字符并进行比较。双端队列提供了 pollFirstpollLast 的对称方法,可以干净地实现“两端逼近”的算法,时间复杂度 O(n),且代码十分自我描述。

5. 浏览器的前进 / 后退(双栈逻辑)

虽然传统上使用两个独立的栈来分别管理后退历史与前进历史,但本质上这就是一个带有当前位置逻辑的 双端操作容器。可以把整个浏览历史视为一个双端队列,前进和后退操作分别从两端暂时移除并重新放入。在实际工程中,为清晰性使用双栈更常见,但理解其双端本质有助于设计更加灵活的撤销/重做系统。

6. 撤销 / 重做管理器(支持分支)

在复杂的编辑器中,不仅要支持线性 undo/redo,还要支持在回退之后插入新操作从而创建新的历史分支。使用双端队列可以维护历史记录,addLast 正常推进,在需要创建分支时修剪另一端。这种模式下,两端操作避免了中间数组的大量搬移。


反模式详解

1. 需要大量按索引随机存取

双端队列的接口通常不提供 get(index) 操作。虽然循环数组实现(如 ArrayDeque)在物理上支持 O(1) 的随机访问,但其抽象层隐藏了这一能力,强行使用反射或无文档方法获取内部数组会破坏封装性,并且导致代码与实现细节耦合。链表实现则完全需要 O(n) 遍历。正确的选择应是 ArrayList

2. 需要键值对查找

双端队列是线性序列容器,存储的是元素本身而非键值对。若需要根据键快速检索值,应使用 HashMapTreeMap。强行用双端队列进行线性查找,效率极低且不符合接口契约。

3. 频繁的中间插入或删除

虽然 LinkedList(作为双端队列的实现之一)支持在迭代器位置 O(1) 插入,但标准 Deque 接口并不暴露此类中间操作。如果业务中大量存在在中间位置增删的需求,应当直接使用 List 接口持有 LinkedList,而非将其降级为 Deque 使用。对于 ArrayDeque,中间插入则意味着 O(n) 的元素移动。


工业界使用现状概览

领域典型应用为何选择双端队列
并发框架ForkJoinPool 的工作队列支持本地 LIFO + 远程 FIFO 窃取,减少争用
流量控制TCP 滑动窗口接收端重组缓冲区需要从头部移除已被确认的数据,从尾部加入新到达的数据
算法编程滑动窗口极值、回文检查头尾删除、插入保证 O(n) 复杂度
UI 系统浏览器的回退/前进历史,编辑器 undo/redo双向移动历史记录
任务调度支持优先级插队的任务消费器普通任务从尾部入队,紧急任务可插入头部优先处理
垃圾回收某些 GC 算法的待处理对象队列支持从两端遍历标记

模块 2:逻辑结构与物理实现

逻辑结构:两端开放的线性表

双端队列在逻辑上是对普通队列的“能力扩展”:普通队列是一种插入受限在一端、删除受限在另一端的数据结构;而双端队列同时打开了两端的插入与删除。逻辑上,它仍是一维的线性序列,但头部和尾部都变成了灵活的指针或索引。这种对操作端的扩展代价极小,在两种主流物理实现(循环数组、双向链表)中,头尾操作的复杂度均保持 O(1)。

物理实现一:循环数组(顺序双端队列)

循环数组是双端队列事实上的工程首选,Java 的 ArrayDeque 便是典型。它使用一个普通数组 Object[] elements,并维护 headtail 两个索引:

  • head:指向当前队首元素的位置。
  • tail:指向下一个可以插入队尾的空位(即队尾元素的后一位)。

在容量为 2 的幂时,取模运算退化为位与(head - 1) & (elements.length - 1),这使得头部插入变成纯粹的定点运算,CPU 友好。

循环数组结构示意图

flowchart LR
    subgraph elements[循环数组 elements 长度 8 装满 7 个元素]
        direction LR
        E5["[5]: F"] --> E6["[6]: G"]
        E6 --> E7["[7]: (空)"]
        E7 --> E0["[0]: A"]
        E0 --> E1["[1]: B"]
        E1 --> E2["[2]: C"]
        E2 --> E3["[3]: D"]
        E3 --> E4["[4]: E"]
        E4 --> E5
    end
    headp(("head = 0")) -.-> E0
    tailp(("tail = 7")) -.-> E7
    subgraph Legend[图例]
        T1[元素存在区域]
        T2[空槽]
    end
    style E5 fill:#c3e6cb,stroke:#28a745
    style E6 fill:#c3e6cb,stroke:#28a745
    style E0 fill:#c3e6cb,stroke:#28a745
    style E1 fill:#c3e6cb,stroke:#28a745
    style E2 fill:#c3e6cb,stroke:#28a745
    style E3 fill:#c3e6cb,stroke:#28a745
    style E4 fill:#c3e6cb,stroke:#28a745
    style E7 fill:#e2e3e5,stroke:#6c757d

图表分层说明

  • 主旨:展示一个长度为 8 的循环数组中存储 7 个元素的逻辑视图,揭示 headtail 环绕的机制。
  • 逐层分解:数组有索引 0~7。head 指向索引 0(元素 A),tail 指向索引 7(空位)。数据从 A 到 G 连续存放,但在物理数组上分成了“尾巴段” [0]:A ~ [4]:E 和“头部段” [5]:F ~ [6]:G,在索引 6 与索引 7 之间发生环绕。
  • 原理映射采用浪费一个槽的策略区分空和满。当 head == tail 时队列为空;当 (tail + 1) & (len - 1) == head 时队列为满(即 tail 的下一个是 head,预留一个空位)。因为始终保留一个空槽,插入时不需要额外标记。
  • 场景关联:这种环绕使头尾插入删除都无需搬移元素。头部插入时 head = (head - 1) & mask,并将元素放入新的 head 位置;尾部插入时放入 tail,然后 tail = (tail + 1) & mask
  • 工程实现对应:Java ArrayDeque 默认容量 16,扩容时容量翻倍,该结构完美支撑了栈和队列的 O(1) 需求。
  • 关键结论循环数组通过指针环绕实现了 O(1) 的两端操作,并通过浪费一个槽实现了轻量的空满判断,是所有高性能双端队列的基础。

物理实现二:双向链表(链式双端队列)

双向链表通过节点对象封装元素,并维护头尾哨兵,实现两端 O(1) 操作。

classDiagram
    class Node {
        E item
        Node prev
        Node next
    }
    class Deque {
        Node first
        Node last
        int size
        addFirst(E e)
        addLast(E e)
        removeFirst()
        removeLast()
    }
    Deque --> Node : 持有 first, last
    Node --> Node : prev, next 双向链接

图表分层说明

  • 主旨:展示链式双端队列的节点结构与容器关系,firstlast 直接引用首尾节点。
  • 逐层分解Node 是自引用结构,包含数据域 item、前驱 prev 和后继 nextDeque 对象只需持有 firstlast,便可从两端开始操作。
  • 原理映射头部插入:创建新节点,设置 newNode.next = firstfirst.prev = newNode,然后更新 first = newNode。若原队列为空,last 也指向新节点。尾部插入对称。头部删除first = first.nextfirst.prev = null所有操作都是指针的局部修改,无任何循环或取模
  • 场景关联:链式结构在 迭代器稳定性 上具有优势。若持有某个节点的引用,在插入或删除其他元素时,该节点依然有效,循环数组则可能因扩容导致引用失效。
  • 工程实现对应:Java 的 LinkedList 实现了与 ArrayDeque 完全相同的 Deque 接口,但内存布局截然不同。链表每个节点有额外的 prev/next 引用,内存开销大,且遍历时指针追逐导致缓存缺失。
  • 关键结论双向链表双端队列不需要容量管理,迭代器稳定,但空间开销大,缓存不友好,在无特殊要求时是次于循环数组的选择。

模块 3:核心操作与复杂度(含与栈/队列的关系)

操作复杂度对比

操作循环数组(均摊)双向链表说明
addFirst / offerFirstO(1)O(1)数组均摊含扩容,单次最坏 O(n);链表总是 O(1)
addLast / offerLastO(1)O(1)同上
removeFirst / pollFirstO(1)O(1)数组隐含移除后置 null 防止内存泄漏
removeLast / pollLastO(1)O(1)双向链表的尾部移除需要 last.prev 引用
peekFirst / peekLastO(1)O(1)数组直接索引 head/tail;链表直接访问 first/last
contains / remove(Object)O(n)O(n)需要线性扫描
随机访问 get(i)不支持不支持接口不提供,但 ArrayDeque 可反射获取数组

派生关系:双端队列是栈和队列的超集

通过有意识地选择操作集,双端队列可以完全模拟栈与队列,不损失任何性能。

graph TD
    subgraph Model["概念继承关系"]
        Deque["双端队列 Deque"]
        Queue["队列 Queue"]
        Stack["栈 Stack"]
    end
    Deque -->|"仅使用 addLast offerLast 和 removeFirst pollFirst"| Queue
    Deque -->|"仅使用 addLast push 和 removeLast pop"| Stack

图表分层说明

  • 主旨:展示双端队列作为超集,通过约束操作端可派生出纯栈和纯队列。
  • 操作映射:若一个 Deque 实例只被当作 队列 使用,应当始终从尾部插入、头部移除(offer / poll)。当做 使用时,应始终从一端操作,通常习惯用 push(映射为 addFirst)和 pop(映射为 removeFirst),或者对称地只用尾部。
  • 工程价值:这一特性使得 Java 官方文档明确推荐用 ArrayDeque 替代 Stack(遗留类,同步,继承自 Vector)和 LinkedList(作为队列时)。统一使用 ArrayDeque 可实现“一器三用”,减少心智负担,同时获得最佳性能。
  • 关键结论双端队列不仅仅是一种新结构,更是一种对线性操作限制的统一抽象,是设计模式中“多用组合/约束而非继承”思想在数据结构层面的绝佳体现。

循环数组关键操作流程:头部插入与扩容

下面的流程图重点展现头部插入时指针环绕的逻辑,以及容量不足时的扩容重排。

graph TD
    subgraph "addFirst"
        A["head = head减一 与 mask 按位与"] --> B{"elements[head] 是否为空"}
        B -->|"是"| C["放入元素"]
        B -->|"否 说明已满"| D["调用扩容"]
        D --> E["容量翻倍 新数组长度等于旧长度乘以2"]
        E --> F["从旧数组 head 开始复制元素 至新数组索引 0 到 size减一"]
        F --> G["重置 head=0 tail=size"]
        G --> H["再次计算 head = head减一 与 newMask 按位与"]
        H --> C
    end

图表分层说明

  • 主旨:呈现 addFirst 操作的原子步骤,包含空间检查与扩容时的数据搬迁。
  • 扩容细化:当检测到数组满(新的 head 与 tail 相遇)时,分配双倍容量新数组。重排关键:将原有元素从原 head 开始,按循环顺序逐一复制到新数组从索引 0 开始的位置,保证它们在物理上不再循环,而是平整存放于 0size-1 之间。之后 head 变为 0,tail 变为 size,完整的空余空间在数组后半段。
  • 性能影响:扩容是 O(n),但均摊到每次插入仍为 O(1)。对于实时系统,扩容峰值可能导致毛刺,可通过预估容量在构造时指定避免。
  • 关键结论循环数组的扩容不仅是容量翻倍,更是一次对全体元素的“线性化重组”,该设计保证了扩容后两端的连续空间,使后续操作恢复 O(1) 均摊。

模块 4:缓存行为与性能分析

内存访问模式对比

现代 CPU 的性能瓶颈往往是内存访问延迟。循环数组与双向链表在双端操作时的缓存行为截然不同。

graph LR
    subgraph Array["循环数组访问模式 - 连续内存"]
        A1(("CPU")) -->|"一次填充缓存行 包含多个相邻元素"| A2["头区域..."]
        A1 --> A3["尾区域..."]
    end
    subgraph Linked["双向链表访问模式 - 离散内存"]
        L1(("CPU")) -->|"每次访问新节点 大概率触发 cache miss"| L2(("节点A"))
        L1 --> L3(("节点X"))
        L2 --> L4(("节点B"))
        L3 --> L5(("节点Y"))
    end
    Array --- Linked

图表分层说明

  • 主旨:对比循环数组的空间局部性与双向链表的指针追逐导致的缓存行为差异。
  • 缓存行为分析:循环数组的元素按索引连续存放。当头部或尾部的某个元素被访问时,相邻几个元素(通常 64 字节缓存行容纳 16 个 int / 8 个引用)会被一并加载到 L1/L2 缓存。后续邻近操作几乎必然命中缓存。双向链表的每个节点独立分配在堆上,地址不连续,相邻节点间通过指针链接。每次沿着指针访问下一个节点,都极有可能访问未缓存的冷内存,导致显著的 stall
  • 双端操作影响:即使在双端队列的两端交替操作(一边 push 一边 pop),循环数组仍在数组的两端(内存区域固定)活动,缓存命中率远高于链表。
  • 关键结论循环数组实现的双端队列,在高吞吐遍历、批量操作下,性能往往数倍于链表实现,这是工程上强力推荐 ArrayDeque 的底层硬件原因。

性能量化(JMH 伪代码思路)

典型微基准测试对比 ArrayDequeLinkedList 在双端随机 push/pop 下的吞吐量:

@Benchmark
public void arrayDequeMixedOps() {
    Deque<Integer> dq = new ArrayDeque<>();
    for (int i = 0; i < SIZE; i++) {
        if (random.nextBoolean()) dq.addFirst(i);
        else dq.addLast(i);
    }
    while (!dq.isEmpty()) {
        if (random.nextBoolean()) dq.removeFirst();
        else dq.removeLast();
    }
}

结果表明 ArrayDeque 的吞吐量通常是 LinkedList2~5 倍,且由于无 GC 节点产生的压力,GC 行为也更优。


模块 5:双端队列的并发实现与工作窃取

工作窃取算法(Work Stealing)详解

ForkJoinPool 是 Java 7 引入的细粒度任务并行框架,其核心调度器建立在每个工作线程拥有独立双端队列之上。

  • 本地操作(Owner):工作线程产生的新任务,以 push 方式放入自己队列的 头部。执行任务时,也以 pop 方式从 头部 取出。这是一种 LIFO 模型,数据局部性和缓存亲和性最好。
  • 远程窃取(Thief):当一个工作线程自己的队列为空,它会随机选取一个“受害者”线程,从其双端队列的 尾部 窃取任务(pollLast)。这是一种 FIFO 方式。
  • 竞争最小化:本地线程只在头部操作,窃取者只在尾部操作。在任务粒度足够细的情况下,双端存在极少冲突;即使冲突,也通常发生在队列两端分离的指针上。
sequenceDiagram
    participant Owner as 工作线程 A(Owner)
    participant Deque as 线程 A 的双端队列
    participant Thief as 空闲线程 B(Thief)

    Owner->>Deque: push( task_new ) 头部插入
    note over Deque: head -> task_new,<br/>tail -> oldest task
    Owner->>Deque: pop() 从头部取出任务执行
    alt 线程 B 空闲
        Thief->>Deque: pollLast() 从尾部窃取一个任务
        Deque-->>Thief: 返回尾部任务 oldest
        note over Thief: 窃取成功,开始执行
    end

图表分层说明

  • 主旨:展示一次工作窃取的完整交互,突出本地操作与窃取操作访问双端队列的不同端
  • 原理映射:由于 push/pop 在头部修改 head 索引,而 pollLast 在尾部修改 tail 索引,在循环数组实现中这两组操作甚至可能完全无锁化(通过 CAS 在不同端进行),从而避免大多数场景下的互斥。
  • 关键结论双端队列的不对称性被 ForkJoinPool 精巧地转化为性能优势,使得工作窃取成为以“无锁/少量锁”实现负载均衡的经典范式。

并发双端队列实现

Java 并发包提供了两颗耀眼的明珠:

  • ConcurrentLinkedDeque:基于 CAS 无锁双向链表 的并发双端队列。要同时保持 prevnext 指针的一致性,实现极其复杂。它借鉴了 ConcurrentLinkedQueue 的设计哲学,通过多步 CAS 和惰性标记删除来避免使用锁。适用于生产者-消费者场景且无阻塞需求的高并发环境。
  • LinkedBlockingDeque:采用 可重入锁 + Condition 实现的双端阻塞队列。两端各自拥有一把锁(或全局锁设计),支持阻塞的 putFirsttakeLast 等方法。适用于需要自动阻塞等待的缓冲通道,例如有界任务管道。

模块 6:工程实现例证与最佳实践

Java Deque 实现全景对比

实现底层结构线程安全阻塞支持内存特点典型场景
ArrayDeque循环数组连续内存,空间开销小通用栈/队列/双端队列黄金选择
LinkedList双向链表每元素多两个指针,开销大需要迭代器稳定或频繁中间插入时考虑
ConcurrentLinkedDeque无锁双向链表是(CAS)节点离散,GC 友好高并发非阻塞双端队列
LinkedBlockingDeque双向链表+锁是(lock)节点离散,容量可选并发阻塞任务缓冲

“黄金实现” ArrayDeque 的正确用法

// 1. 作为栈(替代 Stack)
Deque<String> stack = new ArrayDeque<>();
stack.push("compiler");   // 实质 addFirst
stack.push("parser");
String s = stack.pop();   // 实质 removeFirst -> "parser"

// 2. 作为队列(替代 LinkedList 作为 Queue)
Deque<Integer> queue = new ArrayDeque<>();
queue.offer(1);           // addLast
queue.offer(2);
int head = queue.poll();  // removeFirst -> 1

// 3. 作为双端队列(两端操作)
Deque<Character> deque = new ArrayDeque<>();
deque.addFirst('M');
deque.addLast('N');
char first = deque.pollFirst(); // 'M'
char last  = deque.pollLast();  // 'N'

遍历性能与方式

ArrayDeque 的 for-each 和显式迭代器严格按从 head 到 tail 的顺序遍历,且由于数组连续内存,速度极快。LinkedList 遍历同样顺序正确但慢很多。需要注意:在 ArrayDeque 中,任何结构修改(非迭代器删除)都会立刻抛出 ConcurrentModificationException,这是 fail-fast 机制的保护。

滑动窗口最大值(可运行示例)

public int[] maxSlidingWindow(int[] nums, int k) {
    if (nums == null || k <= 0) return new int[0];
    int n = nums.length;
    int[] result = new int[n - k + 1];
    Deque<Integer> deque = new ArrayDeque<>(); // 存索引,值单调递减
    for (int i = 0; i < n; i++) {
        // 移除窗口左边界外的过期索引
        while (!deque.isEmpty() && deque.peekFirst() < i - k + 1)
            deque.pollFirst();
        // 维护单调递减:从尾部移除所有小于新元素的索引
        while (!deque.isEmpty() && nums[deque.peekLast()] < nums[i])
            deque.pollLast();
        deque.offerLast(i);
        // 窗口形成后记录最大值
        if (i >= k - 1)
            result[i - k + 1] = nums[deque.peekFirst()];
    }
    return result;
}

工作窃取简化模拟

// 每个“线程”一个双端队列,这里只演示基本交互
Deque<String> ownerDeque = new ArrayDeque<>();
ownerDeque.addFirst("Task-urgent");
ownerDeque.addLast("Task-normal");

// 本地 pop(头部)
String localTask = ownerDeque.pollFirst(); // LIFO

// 远程窃取(尾部)
Deque<String> victim = ownerDeque;
String stolenTask = victim.pollLast(); // FIFO

工程避坑清单

陷阱表现原因解决方案
用 add/remove 替代 offer/poll 时处理空遇空队列抛异常导致崩溃混合使用强弱语义统一采用特殊值版本或在调用前检查 isEmpty
遍历时调用结构修改方法ConcurrentModificationException迭代器 fast-fail使用 iterator.remove() 或收集后批量操作
需要按索引取值而使用反射代码脆弱、不可移植破坏了 Deque 抽象重构为 List,或接受遍历 O(n)
用 LinkedList 实现高吞吐队列吞吐量骤降,GC 频繁节点分配与缓存缺失替换为 ArrayDeque
高并发下直接使用 ArrayDeque数据竞争,内部数组破坏无任何同步机制选择 ConcurrentLinkedDequeLinkedBlockingDeque

模块 7:面试高频专题

以下问题与正文前六模块完全隔离,凝练实用。——问题全部聚焦双端队列原理与设计权衡

1. 什么是双端队列?ADT 包含哪些操作?

标准回答:双端队列是允许在线性表两端进行插入和删除的数据结构,头尾操作复杂度均为 O(1)。它的 ADT 对称地提供了 addFirst/offerFirstaddLast/offerLastremoveFirst/pollFirstremoveLast/pollLastpeekFirst/peekLast 等核心操作,以及 size/isEmpty。异常版本在失败时抛出异常,特殊值版本返回 nullfalse

追问:为什么提供两套返回语义? :为适应不同领域习惯,如容量受限的环境下希望以 false 表示插入失败而不是异常,避免中断控制流。

加分回答:用对称操作集可以统一约束得到栈和队列,体现了“操作受限”抽象的力量,这是数据结构多态性的一种形式。

2. 如何用循环数组实现双端队列?空满如何判定?

标准回答:使用数组 elementsheadtail 索引。头部插入将 head 左移一位(取模),放入元素;尾部插入将元素放入 tailtail 右移。常见空满策略是浪费一个槽head == tail 判定为空;(tail + 1) % capacity == head 判定为满。容量常设为 2 的幂,取模退化为 (index) & (capacity - 1)

追问:如果不想浪费槽呢? :可额外维护一个 size 变量,通过 size == 0size == capacity 判空满,但每次操作需更新 size,并可能引入更复杂的并发问题。

加分回答:在 ForkJoinPool 的 WorkQueue 中,为了极致无锁重优化,甚至利用了数组特定填充和 headtail 的语义差来不浪费槽。

3. 为何 ArrayDeque 比 Stack 和 LinkedList 更适合做栈和队列?

标准回答Stack 是遗留类,继承自 Vector,所有方法都带有 synchronized,性能退化,且鼓励基于位置的操作,不符合栈精准语义。LinkedList 作为队列,每次入队都需创建节点,内存开销和缓存缺失严重。ArrayDeque 无同步,循环数组内存连续,缓存友好,且 push/pop/offer/poll 语义完全覆盖栈和队列。

追问:ArrayDeque 不是线程安全的,有没有并发栈替代? :可使用 ConcurrentLinkedDequepush/popLinkedBlockingDeque

加分回答:官方文档 ArrayDeque 声明:“This class is likely to be faster than Stack when used as a stack, and faster than LinkedList when used as a queue.” 这是 JDK 自身背书。

4. 双端队列与普通队列、栈的本质区别?

标准回答:普通队列只允许一端入、另一端出(FIFO);栈只允许在同一端操作(LIFO)。双端队列同时开放了两端的入出操作,是二者的超集。通过约束只使用 addLast+removeFirst 即队列,addFirst+removeFirst(或都用尾部)即栈。

追问:多支持了哪些操作代价是什么? :几乎没有。循环数组只需多维护一个 tailhead(原本作为队列也需要两者),以及在头尾的简便移动;双向链表本来就是头尾指针完备。

加分回答:代价是 API 的表面积增大,程序员可能误用。因此工程上常封装出 asStack()/asQueue() 视图。

5. 工作窃取为何使用双端队列?本地与远程窃取有何不同?

标准回答:本地线程以 LIFO(从头部)操作自己的队列,局部性好;窃取者以 FIFO(从尾部)窃取,减少与所有者的竞争,因为双方操作在不同的端。这使得整个调度器在多数情况下无需加锁,性能极高。

追问:为什么本地用 LIFO 更好? :刚生成的任务数据较热,仍在缓存中,立即执行效率最高。使用 LIFO 可以把长时间未执行的任务留在尾部,窃取者拿到的正是“冷”的大粒度任务,有利于负载均衡。

加分回答:Doug Lea 在 ForkJoin 论文中量化了这种不对称操作带来的 cache 收益和锁低竞争。

6. 滑动窗口最大值如何在 O(n) 内用双端队列解决?

标准回答:维护一个存储数组索引的单调递减双端队列。遍历数组,对每一元素:(1)从头部移除超出窗口左边界的索引;(2)从尾部移除所有值小于当前元素值的索引;(3)将当前索引加入尾部;(4)当窗口形成,头部索引对应的元素即为当前窗口最大值。每个元素最多入队出队一次,总 O(n)。

追问:为什么用索引而不直接存值? :要用索引判断是否滑出窗口(索引差 >= k)。直接存值无法做到这一点。

加分回答:该算法可以推广到带 key 的滑动窗口,配合 HashMap 扩展为更复杂的数据流统计。

7. ConcurrentLinkedDeque 和 LinkedBlockingDeque 的选择?

标准回答ConcurrentLinkedDeque 是无锁 CAS 实现,适合高并发、非阻塞、尽量不出锁的场景;LinkedBlockingDeque 基于锁和 Condition,可在满/空时让线程阻塞等待,适合生产者-消费者模式下的有界通道。

追问:两者的内存可见性保证? :均遵循 Java 内存模型,对双端队列的操作前后有 happen-before 语义保证。ConcurrentLinkedDeque 通过 volatile 变量和 CAS 实现;LinkedBlockingDeque 通过锁导致的内存屏障实现。

加分回答LinkedBlockingDeque 支持“双端阻塞”,可用于比 BlockingQueue 更灵活的任务管道,例如紧急任务插到头部的阻塞等待。

8. 循环数组双端队列扩容时元素顺序会变吗?

标准回答:不会。扩容时从 head 开始按循环顺序复制到新数组的 0 位置,依次放满,直至 tail 的前一个元素。新数组中 head=0tail=size,所有元素物理连续,逻辑顺序完全保留。

追问:扩容后索引全变了,之前暴露给外部的子序列视图会失效吗? ArrayDeque 不提供子序列视图;若自行通过反射获取内部数组引用,扩容后原数组已不被使用,视图必然失效,这正是不应破坏封装的原因。

加分回答:通过在构造时指定足够容量,可完全消灭扩容毛刺,适合低延迟系统。

9. 非并发下,为何 ArrayDeque 应优先于 LinkedList 作为双端队列?

标准回答:核心原因有三:(1)内存,每个 LinkedList 节点有对象头和两个引用开销,是 ArrayDeque 紧致数组的数倍;(2)缓存,数组连续访问远优于链表指针追逐;(3)GCArrayDeque 只有一个数组对象,LinkedList 有众多节点对象,标记和复制压力更大。三者导致 ArrayDeque 压倒性性能优势。

追问:什么情况仍用 LinkedList? :需要稳定的迭代器引用(例如在迭代过程中插入删除大量元素且不触发 fail-fast),或业务强烈依赖在序列中间添加删除,而不得不用 List 接口的实现时。

加分回答:甚至在遍历时,ArrayDeque 几乎都在缓存行内,适合批量定时任务扫描。

10. 【系统设计】设计一个支持事务的撤销/重做管理器,支持创建新分支

标准回答与设计要点

  • 基础模型:使用一个 Deque<Command> 作为当前活跃历史链。正常执行新命令时,offerLast。撤销操作执行 pollLast 即可取出最后命令并记录到临时栈;重做时从临时栈再压回队列。
  • 支持分支:当撤销到某个状态并执行新命令时,原本的“未来”重做链应该被丢弃,因为产生了新分支。双端队列可以通过 removeAll 或新建一个双端队列分支来实现。具体可以用 Deque<Deque<Command>> 维持多个分支。
  • 双端队列的角色:本场景中双端队列的首选原因是需要从尾部快速获取最后命令(撤销),同时从尾部快速附加新命令(重做或新命令),且可能在将来需要可视化时从头部遍历历史。双栈也能做到,但双端队列为将来可能的“快进到最早历史”等功能留出扩展性。

追问:如果需要限制历史深度怎么办? :每次 addLast 后检查 size,若超过上限则 removeFirst 丢弃最早的历史,保持窗口大小。这正是滑动窗口场景。

加分回答:复杂编辑器如 VSCode 可能使用多会话的树形历史图,但其底层局部依然可用双端队列作为每个分支的线性历史。


延伸阅读

  1. 《算法导论(CLRS)》:第 10.1 节基本数据结构,双端队列的形式化基础。
  2. 《Java 并发编程实战》:第 8 章,线程池与 ForkJoin 框架,深入理解工作窃取调度。
  3. Doug Lea, “A Java Fork/Join Framework”:原始论文,详尽解释双端队列在 ForkJoinPool 中的应用与无锁设计。
  4. Java Platform SE 8 官方文档 - Deque 接口与 ArrayDeque:API 约定与性能说明的第一手资料。
  5. 《滑动窗口双端队列算法详细解析》:经典算法教学资源,理解单调双端队列的极值应用。