qinghuan
← 计算机基础 / 全部笔记

单元 30 / 计算机基础

P0 · 必会主线L1 · 基础

数组、链表、栈与队列:按访问方式选择结构

先看要按位置查找、在两端取放,还是按到达顺序处理,再理解数组、链表、栈和队列各自的代价。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能根据访问方向选择结构,并解释链表定位和队列完成顺序的限制。

学习理由、准备要求与知识点

为什么学它

数组、链表、栈与队列是遍历、调度和搜索算法反复使用的基本结构。

先读这些内容

知识点清单

  • 数组按下标定位
  • 动态扩容与移动
  • 链表节点和后继
  • 定位与局部修改成本
  • 栈后进先出
  • 队列先进先出
  • ArrayDeque 方法与空值
  • 容量和并发规则

运行前先确认Java 17;源码在本机 Microsoft OpenJDK 17.0.20.1 src.zip 核验,在线链接为上游 jdk-17.0.20-ga。深入模块可在掌握基础后回读。

01先弄懂它是什么

选数据结构时,先分清两个问题:数据怎样存,以及先取出哪一项。数组和链表回答怎样存;栈要求最后放入的先取出,队列要求最早放入的先取出。栈和队列都能用数组或链表实现,所以 ArrayDeque 可以承担这两种用途,不需要把它们当成四个互不相干的类别。

数组可以按下标直接找到第 i 项,但在中间插入通常要移动后面的元素。链表把节点一个接一个连起来,找第 i 项通常要一路走过去;已经拿到所需节点或迭代位置时,修改少量连接才比较便宜。双向链表还记录前一个节点,方便反向访问和部分删除操作,代价是每个节点占用更多空间。

Java17 常用 ArrayDeque 表达栈或双端队列:push、pop 在同一端操作,addLast、removeFirst 表达队尾加入、队首移除。它拒绝 null,空时抛异常的方法和返回特殊值的方法需要区分。旧 Stack 类仍存在,但新代码通常可使用 Deque 接口,让操作语义更明确;普通 ArrayDeque 不支持无同步的并发修改。

文中这几个词,先认一认

后进先出
最后放进去的先拿出来,例如撤销最近一次操作。
先进先出
最早放进去且还没取出的先拿出来,例如按到达顺序处理任务。
环形缓冲
数组用到末尾后再从开头利用空位,靠下标循环来复用空间。
链节点
一个保存数据和相邻节点引用的小对象,多个节点相连组成链表。

还有陌生词?到术语小抄里看一个具体例子 ↗

02看看它怎么工作

线性数组队列如果每次从头删除都搬运所有剩余项,会付出线性成本。环形缓冲用 head 和 tail 记录逻辑起止位置,消费后只移动指针;到数组末尾时回到零位置。满与空可能出现相同索引关系,需要额外 size、空槽策略或其它约定区分,不能只比较两个数字就猜状态。

链表插入时可能少搬数据,却要为节点分配空间,并沿着引用找下一个节点。相邻节点也未必放在相邻内存中,因此处理器已经取到的那一小片数据不一定能继续派上用场,这与缓存局部性有关。比较花费时,要把找位置、创建节点和改变连接一起算。若主要是顺序读取、在末尾追加,动态数组通常已经够用;只有确实需要某些链接操作时,再评估链表的优势。

两个栈也能组合出队列。输入栈接收新元素,输出栈负责移除最早元素;输出栈为空时,把输入栈全部倒过去,反转顺序后最早元素位于输出栈顶。关键是只有输出为空才转移,否则新元素会插到旧待处理元素前面,破坏先进先出。

正在加载流程图…

一次整体倒转把后进先出转换为先来先服务;转移时机维持队列顺序。
查看流程图源码(Mermaid)
flowchart LR
 A["新元素"] --> B["输入栈:新元素在顶"]
 B -->|"仅输出栈为空时全部倒转"| C["输出栈:最早元素在顶"]
 C --> D["移除队首"]
 E["仍有输出元素时"] --> F["新元素继续留在输入栈"]
 F --> B

按顺序看,每一步做了什么

  1. 先确定栈、队列或双端行为。
  2. 再选择数组或链节点存储。
  3. 定义空、满和 null 的处理方式。
  4. 分析定位与修改的总成本。
  5. 用交错入队出队检验顺序不变量。

03对照实现代码

环形尾指针如何完成队尾加入 (新标签页)↗

Microsoft OpenJDK 17.0.20.1;java.base/java/util/ArrayDeque.java · java.util.ArrayDeque#addLast(Object)

以下为原创 Java 17 教学实现,不是原样 JDK 源码;只保留文中明确讨论的机制,省略优化、兼容及内部接口。

核心机制代码 · java
import java.util.NoSuchElementException;
public final class SourceModel {
    private final int[] data;
    private int head, tail, size;
    public SourceModel(int capacity) {
        if (capacity <= 0) throw new IllegalArgumentException("capacity");
        data = new int[capacity];
    }
    public void addLast(int value) {
        if (size == data.length) throw new IllegalStateException("full");
        data[tail] = value;
        tail = tail + 1 == data.length ? 0 : tail + 1; // 回到数组开头。
        size++;
    }
    public int removeFirst() {
        if (size == 0) throw new NoSuchElementException();
        int value = data[head];
        head = head + 1 == data.length ? 0 : head + 1;
        size--;
        return value;
    }
}

真实 ArrayDeque.addLast 首先拒绝 null,把元素写到尾部并移动尾指针;尾指针追上头部时调用增长逻辑。模型是固定容量 int 队列,用额外 size 区分满与空,满时拒绝而不扩容。它只展示环形索引与队列行为,不模拟 JDK 的对象槽位、空槽标记和动态增长。

逻辑顺序与物理下标不必一致。队首可能在数组中间,后面的元素跨越数组末尾再从零继续;因此遍历应依据逻辑数量和回绕规则,不能简单从零扫描到 tail。环形设计复用已消费空间,而不是要求每次删除都把数组内容整体向前搬。

对象队列移除元素时还应清理不再使用的槽位,避免继续持有对象引用而延长其生命周期。模型存 int,没有对象引用保留问题;不能把这一省略推广到 Object 数组实现。存储类型改变后,正确性之外的资源行为也会改变。

JDK 的扩容方式可能随版本调整,调用方更应依赖公开说明。ArrayDeque 不是有界阻塞队列,不能指望它满了就等待消费者。需要限制积压、让生产者等一等时,应选相应并发队列或另加容量控制。名字带 Queue,只说明队列用途,不自动保证等待、线程安全或公平。

代码先调用谁,再调用谁

  1. addLast 拒绝 null
  2. 写入当前尾槽位
  3. 尾索引环形推进
  4. 若追上头部则增长容量并调整布局

04动手写一个例子

只用两个栈实现整数先进先出队列

实现 offer、remove 和 size,保持 FIFO。remove 在空队列抛 NoSuchElementException;允许重复和任意 int 值。两个内部栈均为 ArrayDeque,所有操作按单线程模型执行。

输入 1
offer1,offer2,remove,offer3,remove,remove
输出 1
1,2,3
输入 2
空队列remove
输出 2
抛 NoSuchElementException
输入 3
offer0,offer0
输出 3
size为2

输入栈按到达顺序压入,因此顶部是最新项,底部是最早项。输出栈为空时逐项弹出输入并压入输出,会把整段次序翻转,使最早项位于输出顶部。之后连续移除只需弹输出栈,不必反复搬运。

输出栈尚有元素时,新到元素只能进入输入栈并等待。输出栈中的全部元素都比输入栈中的元素更早到达;若此时提前把输入倒入输出,新元素会盖住旧元素,队列顺序立刻被破坏。交错操作测试比一次性全部入队后全部出队更能发现这个错误。

单次 remove 可能搬运 n 项,因此最坏线性;但每个元素只会被压入输入一次、从输入弹出一次、压入输出一次、最终弹出一次。对任意一串操作,总栈动作与操作数同阶,得到摊还常数成本。这个证明关注元素一生的移动次数,而非假设所有输入随机均匀。

为什么这样写是对的队列逻辑顺序等于输出栈从顶到底的序列,接上输入栈从底到顶的序列。

时间 offer O(1) 摊还;remove 最坏 O(n)、摊还 O(1);size O(1)。额外空间 O(n),n 为当前元素数量。

每个元素最多经历四次栈操作,整个序列总成本线性;底层 ArrayDeque 自身扩容也按摊还成本计入,不保证每次调用延迟相同。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 39 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
import java.util.ArrayDeque;
import java.util.NoSuchElementException;
public class Main {
    static final class TwoStackQueue {
        private final ArrayDeque<Integer> input = new ArrayDeque<>();
        private final ArrayDeque<Integer> output = new ArrayDeque<>();
        void offer(int value) { input.push(value); }
        int size() { return input.size() + output.size(); }
        int remove() {
            // 只有输出为空才能倒转,避免新元素挡在旧元素之前。
            if (output.isEmpty())
                while (!input.isEmpty()) output.push(input.pop());
            if (output.isEmpty()) throw new NoSuchElementException();
            return output.pop();
        }
    }
    static void require(boolean condition) {
        if (!condition) throw new AssertionError();
    }
    public static void main(String[] args) {
        TwoStackQueue q = new TwoStackQueue();
        q.offer(1); q.offer(2);
        require(q.remove() == 1);
        q.offer(3); // 交错加入时输出栈仍有旧元素。
        require(q.remove() == 2 && q.remove() == 3 && q.size() == 0);
        boolean emptyRejected = false;
        try { q.remove(); } catch (NoSuchElementException e) { emptyRejected = true; }
        require(emptyRejected);
        q.offer(0); q.offer(0);
        require(q.size() == 2 && q.remove() == 0 && q.remove() == 0);
        q.offer(Integer.MIN_VALUE); q.offer(Integer.MAX_VALUE);
        require(q.remove() == Integer.MIN_VALUE && q.remove() == Integer.MAX_VALUE);
        // 较长序列验证每个元素恰好返回一次且顺序一致。
        for (int i = 0; i < 1000; i++) q.offer(i);
        for (int i = 0; i < 1000; i++) require(q.remove() == i);
        require(q.size() == 0);
        System.out.println("PASS 6");
    }
}

这些特殊情况也要试一试

  • 空队列 remove 抛异常。
  • 重复值仍是不同队列项。
  • 全部 int 值合法,不用数值哨兵表示空。
  • 输出尚未耗尽时的新入队保持在输入栈。
进阶选读 · 可选深入:有界生产者消费者队列

场景生产速度可能持续超过消费速度,不能无限增长。

做法明确容量上限、阻塞或拒绝策略、取消和超时;使用成熟并发队列,把元素可见性与复合条件交给经过定义的同步协议。

复杂度入出队成本与实现相关,等待时间取决于负载而非单纯 n。

好处与代价背压保护资源但会把等待或拒绝传回生产者,需要业务明确可接受行为。

05项目里怎么用

设计任务队列时,先问最多能排多少项、每秒能处理多少项。没有容量上限的队列,会把“忙不过来”变成越来越长的等待和越来越多的内存占用。即使平均每秒完成的任务数没变,排在后面的少数任务仍可能等很久,这就是需要关注的尾部延迟。一起观察队列长度、等待时间和被拒绝的任务数,比只看有几个消费线程更有用。

双栈队列是理解摊还分析的教学结构,实际 Java 单线程队列通常直接使用 ArrayDeque 更简洁。需要并发时不能在外面零散加几个 synchronized 就宣称完成,size 检查、等待条件与入出队操作之间也可能有竞态,应依据成熟接口契约选择实现。

链表若把节点直接交给外部,调用者可能把前后连接改坏;删除后的节点再次使用,还可能出现错误连接或重复插入。数组则要检查下标、循环回绕,以及删除后是否仍保留对象引用。无论选哪一种,都要约定谁能修改、哪些关系必须保持正确,不能只看到某一步理论上是 O(1) 就忽略维护工作。

容易踩的坑

  • 把栈队列与数组链表当成同一层次分类。
  • 链表按下标操作时漏算定位成本。
  • 环形队列只比较 head与tail,未定义满空区分。
  • 输出栈未空就转移输入,破坏 FIFO。

几种做法,怎么选

表格有四列,窄屏可左右滑动查看代价和适用条件。

方案优势代价与限制适用条件
动态数组实现布局紧凑、索引与尾部操作方便增长或中间移动可能有尖峰通用顺序容器与双端队列
链式节点实现已知位置时链接修改直接节点开销与定位成本较高需要特定节点操作的结构
有界并发队列提供容量和线程协调契约阻塞、拒绝与调度成本需要处理生产者消费者与任务分发

06检验是否读懂了

先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。

先用自己的话解释

01栈能否用数组实现?

展开参考答案与推导

可以。栈规定后进先出,数组是存储方式,用长度或栈顶下标即可在尾部压入弹出。链表也能实现相同行为,因此抽象接口与具体存储应分开讨论。

02双栈队列为什么摊还常数?

展开参考答案与推导

每个元素只从输入栈转到输出栈一次,整个生命周期只有固定次数压入弹出。某次移除可能集中搬运很多项,但总动作不会随每次操作反复重复全部元素。

03为什么不能用某个 int 值表示空队列?

展开参考答案与推导

所有 int 都可能是合法数据,包括最小值和零。应使用异常、可选结果或独立状态表达空,避免数据与控制标记混淆;接口必须告诉调用者采用哪种方式。

换个情况,还会处理吗

01交错操作输出变成1,3,2,哪里可能错?

展开参考答案与推导

可能在输出栈仍含二时把新输入三倒到输出顶部。只有输出为空才能整体转移,保持输出中的旧元素先于输入中的新元素;用交错序列固定这个不变量。

02队列越积越长,换链表能解决吗?

展开参考答案与推导

不能从根本解决生产超过消费的问题。需要容量、背压、拒绝或扩展消费能力策略;换存储结构最多改变常数和分配行为,不能消除无限积压。

03删除对象后内存仍未释放,数组队列可能漏什么?

展开参考答案与推导

检查被移除槽位是否仍保存对象引用。移动 head 只改变逻辑范围,不自动清空数组内容;应在正确位置置 null,避免容器继续保持对象可达。

写代码试一试

01增加 peek 操作

输入 / 条件
队列[1,2]
输出 / 预期
返回1且size不变

提示 复用按需转移。

展开参考答案与推导

先在输出为空时转移输入,再返回 output.peek 或按约定抛异常。不要弹出元素,测试连续两次 peek 与后续 remove 都看到一,确认只观察不消费。

02实现固定容量拒绝策略

输入 / 条件
容量2,连续offer1,2,3
输出 / 预期
第三次拒绝

提示 检查逻辑总大小。

展开参考答案与推导

在 offer 前依据 size 与容量决定抛异常或返回 false,并明确接口。单线程下检查与写入可直接相邻;并发版本必须保证它们作为一个受控操作完成。

03验证重复值不会去重

输入 / 条件
offer7,offer7,remove,remove
输出 / 预期
7,7

提示 队列保存事件位置而非集合成员。

展开参考答案与推导

加入两次相同值并断言两次都能取到。若使用 Set 代替某个内部结构会丢失重复项,说明数据结构选择必须保持抽象语义,而不只追求某个操作方便。

读完之后,独立完成

课后作业:自己做一遍

本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能根据访问方向选择结构,并解释链表定位和队列完成顺序的限制。

本篇重点加练

重点加练定位成本与容量政策,避免只比较插入删除语句而遗漏整体操作。

  • 定位与局部修改成本
  • 容量和并发规则

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01原理问答

    动态数组初始容量 2,连续追加 7 个元素,再在首部插入一项。自定并声明扩容政策,逐步列出定位、搬移与分配成本。

    提交内容
    容量变化图与操作计数。
    怎样算完成
    • 容量和有效长度分开
    • 追加与中间插入成本区分

    练到的知识点数组按下标定位 · 动态扩容与移动

  2. 作业 02代码推演

    单链表 A→B→C→D,分别给定头引用与直接持有 C 节点引用,比较删除或插入某位置需要读取的引用;对首节点与尾节点分别推演。

    提交内容
    节点图和逐步操作表。
    怎样算完成
    • 定位与局部链接操作分开
    • 首尾边界完整

    练到的知识点链表节点和后继 · 定位与局部修改成本

综合应用

  1. 作业 03编程实践

    用 ArrayDeque 分别实现撤销栈与待办队列,输入操作序列包含 push/add、peek、remove 和清空;自行规定空结构和 null 输入政策。

    提交内容
    Java 实现、自写交错/空结构/重复值测试及运行证据。
    怎样算完成
    • 分别规定先进先出与后进先出的操作行为
    • 空状态不与合法数据混淆

    练到的知识点栈后进先出 · 队列先进先出 · ArrayDeque 方法与空值

  2. 作业 04面试场景

    日志队列生产速度长期高于消费速度,开发者提议换 LinkedList。定义容量、拒绝或等待政策以及共享访问约束,说明需采集哪些指标。

    提交内容
    队列合同、负载时间线和指标表。
    怎样算完成
    • 容量问题与容器实现分开
    • 并发责任明确

    练到的知识点容量和并发规则

  3. 作业 05编程实践

    实现容量为 4 的整数环形队列,支持 offer、poll、peek,允许所有 int 值;测试多次绕回、队满、队空和交错操作。

    提交内容
    Java 实现、自写状态序列及参考队列对照测试、运行证据。
    怎样算完成
    • 索引和有效数量一致
    • 失败操作不破坏已有内容
    • 内存容量固定

    练到的知识点数组按下标定位 · 动态扩容与移动 · 队列先进先出 · 容量和并发规则

  4. 作业 06编程实践

    实现容量上限为 5 的最近访问列表,访问已有编号要按合同移动位置,新编号到来可能淘汰;记录查找与修改各自操作量。

    提交内容
    Java 实现、自写满容量/重复/空输入测试与运行证据。
    怎样算完成
    • 定位和改动成本分开记录
    • 容量与顺序合同同时检验

    练到的知识点定位与局部修改成本 · 容量和并发规则

  5. 作业 07设计评审

    编辑器需要按行号删除与持有节点后插入两种操作。比较数组与链表在两种入口的完整成本,不把已持有节点当作所有请求的前提。

    提交内容
    入口条件和成本矩阵。
    怎样算完成
    • 查找前提明确
    • 结构选择与工作负载相符

    练到的知识点定位与局部修改成本

  6. 作业 08面试场景

    任务队列满时可拒绝新任务、丢弃旧任务或让调用者等待。为个人通知与账务任务分别制定政策和可观察反馈。

    提交内容
    两种业务的容量合同。
    怎样算完成
    • 丢弃和等待后果被纳入需求
    • 失败不会被静默当成成功

    练到的知识点容量和并发规则

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    完成并发基础后:工作队列支持取消尚未开始任务。比较数组队列、链表和附加索引方案,定义取消与取出同时发生时的合同。

    提交内容
    操作复杂度表与竞态验收场景。
    怎样算完成
    • 查找取消成本纳入分析
    • 任务身份与执行状态可追踪

    练到的知识点定位与局部修改成本 · 栈后进先出 · 队列先进先出 · 容量和并发规则

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    最近访问列表增加“随时删除任意编号”,同时要求严格内存上限。评估是否需要辅助索引,比较删除定位成本与额外空间。

    提交内容
    数据结构候选、资源账本和验收样本。
    怎样算完成
    • 额外索引维护成本被计算
    • 容量规则覆盖全部内部结构

    练到的知识点定位与局部修改成本 · 容量和并发规则

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗