欢迎光临
我们一直在努力

栈和队列:你以为很熟,其实暗藏玄机

我的前言:三道题测测你的"线性数据结构"底子

先别往下翻,看看这三道题你能不能讲清楚:

  • 栈和队列不就是数组套了层皮吗? 既然数组的 push/pop 能当栈用,push/shift 能当队列用,为什么面试还要专门考"用栈实现队列"?
  • JS 的数组是真的数组吗? const arr = ['haha', 1, {a: 1}] —— 三种不同类型塞进同一个数组,它凭什么还能用下标访问?
  • 数组和链表到底怎么选? 有人说"小数据用数组,大数据用链表",这个"大小"的边界在哪?
  • 这三个问题,面试官随便拎出一个都能聊十分钟。本文带你从数组出发,一路打通栈、队列、链表,把线性数据结构这块拼图完整拼上。

    📌 适合人群:有一年 JS 经验,刷过几个算法题但总觉得"栈和队列太简单没啥好学的"的开发者。

    📌 你将收获:栈/队列的底层本质、为什么不能只把它们当数组用、链表的实现与适用场景、JS 数组的"真实身份"、经典算法题的解题思路。


    背景:为什么"栈和队列"值得单独写一篇文章?

    340b81ec26d567d4b0bf86b6ba834584.jpg 我刚学数据结构的时候,看到"栈和队列"这一章是这么想的:

    “不就是数组加两个限制条件吗?push/pop 是栈,push/shift 是队列,有什么好学的?”

    我相信很多人跟我一样。但恰恰是这种心态,导致了两个问题:

    第一,面试官问"用栈实现队列",你第一反应是"我用数组不就完了?"——考官要的是你对两种数据结构出栈顺序的理解,不是让你秀 JS 数组 API。

    第二,实际开发中遇到递归爆栈、BFS 遍历、浏览器历史记录这些场景,你看不到"栈和队列"的影子,自然想不到用它们来优化。

    这篇文章要做的,就是让你从"会用"升级到"真懂"。


    目标:这个模块要解决什么问题?

    一句话概括:在合适的场景,用合适的线性数据结构。

    数据结构核心操作适用场景
    数组 下标访问 频繁读取、少量增删
    后进先出(LIFO) 函数调用栈、撤销操作、括号匹配
    队列 先进先出(FIFO) 任务调度、BFS 遍历、消息队列
    链表 O(1) 增删 频繁插入删除、不确定大小的场景

    关键认知:栈和队列不是"简化版的数组",而是在特定约束下最大化效率的数据结构。约束本身不是限制,是优化方向。


    设计:前端视角看数据结构设计

    线性 vs 非线性

    整个数据结构的世界可以这么分:

    数据结构
    ├── 线性结构
    │ ├── 数组(连续存储 + 下标访问)
    │ ├── 链表(离散存储 + 指针串联)
    │ ├── 栈(LIFO,操作受限的线性表)
    │ └── 队列(FIFO,操作受限的线性表)
    └── 非线性结构
    ├── 树
    └── 图

    栈和队列本质上是"操作受限的数组/链表"——你只能在一端操作,但恰恰是这个限制,让它们在特定场景下比"万能数组"更可靠。

    栈的设计:只能在一头操作

    想象一个冰柜里的雪糕——你只能从最上面拿,也只能从最上面放。这就是栈:

    a9b375857bc7164e7f18f590a7c1ebee.png

    • push:在栈顶添加元素(数组尾部)
    • pop:从栈顶取出元素
    • peek:看一眼栈顶,但不取出(stack[stack.length – 1])

    ┌─────────┐
    栈顶 → │ 巧乐兹 │ ← push/pop 操作入口
    ├─────────┤
    │ 冰工厂 │
    ├─────────┤
    │ 可爱多 │
    ├─────────┤
    栈底 → │ 东北大板 │
    └─────────┘

    队列的设计:一头进,另一头出

    就像食堂排队打饭——新来的人排到队尾,打完饭的人从队头离开:

    • enqueue(入队):在队尾添加(push)
    • dequeue(出队):从队头移除(shift)

    出队 ← [ 张三 | 李四 | 王五 | 赵六 ] ← 入队
    队头 队尾

    链表的设计:离散节点 + 指针串联

    数组在内存中是连续存储的,而链表的节点可以"散落"在任何位置,靠每个节点上的 next 指针串联起来:

    // JS 中链表的节点就是一个对象字面量
    {
    val: 1,
    next: {
    val: 2,
    next: {
    val: 3,
    next: null // 尾节点,next 为空
    }
    }
    }

    head tail
    ↓ ↓
    [val:1|next][val:2|next][val:3|next=null]

    访问链表中的任意节点,必须从头开始逐个遍历。 这是链表最大的"代价"——也是它与数组最本质的区别。


    实现:核心代码和流程

    1. 数组的本质:你以为的"增删改查"

    先看数组增加元素的三种方式:

    const arr = [1, 2];

    // push:尾部追加,时间复杂度 O(1)
    arr.push(3); // arr = [1, 2, 3]

    // unshift:头部插入,时间复杂度 O(n)——所有元素都要往后挪
    arr.unshift(0); // arr = [0, 1, 2, 3]

    // splice:任意位置插入/删除,时间复杂度 O(n)
    arr.splice(1, 0, 3); // 在索引1处插入3 → [1, 3, 2]
    arr.splice(1, 1); // 删除索引1 → [1, 2]

    三个需要记住的点:

  • push 没有性能问题吗?有——当数组容量不够时会触发扩容,底层需要重新申请一段更大的连续内存空间并拷贝数据。链表没有这个问题。
  • unshift 从内存视角看是最"贵"的操作——每个元素都要向后移动一位。
  • splice(start, deleteCount, item1, item2, …) —— 能做删除也能做插入,但它不是纯函数,会修改原数组。
  • 核心结论:假设数组长度是 n,增删操作需要移动的元素数量随 n 线性增长 —— O(n)。

    2. 栈的完整实现

    const stack = []; // 空栈
    stack.push("东北大板");
    stack.push("可爱多");
    stack.push("冰工厂");
    stack.push("巧乐兹");

    // 出栈:后进先出
    while (stack.length) {
    const top = stack[stack.length – 1]; // peek 栈顶
    console.log(`取出来的是`, top);
    stack.pop();
    }
    // 输出顺序:巧乐兹 → 冰工厂 → 可爱多 → 东北大板
    console.log(stack); // []

    这就是 LIFO:最后放进去的巧乐兹第一个被拿出来。

    3. 队列:FIFO 的经典实现

    const queue = [];

    // 入队
    queue.push('a');
    queue.push('b');
    queue.push('c');

    // 出队
    while (queue.length) {
    console.log(queue.shift()); // a → b → c
    }

    shift() 会将数组第一个元素移除并返回,同时所有后续元素前移——这是一个 O(n) 的操作。如果追求 O(1) 的队列出队,可以用链表实现。

    4. 链表的 JS 实现

    function ListNode(val) {
    this.val = val;
    this.next = null;
    }

    const node = new ListNode(1);
    node.next = new ListNode(2);
    // 链表:1 → 2 → null

    链表添加元素的核心:操作 next 指针

    原链表:A → B → C
    在 A 后面插入 X:
    X.next = A.next // X 指向原 B
    A.next = X // A 指向 X
    结果:A → X → B → C

    关键:一定要先让新节点指向后继节点,再断开前驱节点的 next。顺序反了就会"坐过站"——B 节点就找不到了。

    数组 vs 链表,什么时候用哪个?
    维度数组链表
    随机访问 O(1),下标秒查 O(n),从头遍历
    插入/删除 O(n),需要移动元素 O(1),改指针就行
    内存分配 连续分配,可能扩容 分散分配,每次新增申请
    JS 中的"特殊性" 本质是哈希表(见下文) 纯对象串联

    选择原则:

    • 数据规模小、需要频繁遍历/索引访问 → 数组
    • 数据规模大、需要频繁增删 → 链表
    • 数组的遍历优势明显,链表的增删优势明显,没有绝对的好坏

    踩坑:JS 数组的"真实身份"

    这是很多前端开发者踩过的坑:JS 的数组不是传统意义上的"数组"。

    const arr = [1, 2, 3, 4, 5]; // 看起来是标准数组

    const arr2 = ['haha', 1, { a: 1 }]; // 不同类型混在一起
    console.log(arr2[2]); // { a: 1 } —— 依然能用下标访问

    为什么不同类型的元素还能用下标 O(1) 访问?

    因为 V8 引擎中,JS 的数组底层其实是哈希表(HashTable),而不是 C 语言那种连续内存的纯数组。所以:

    • 你可以往数组里塞任何类型
    • 下标访问是通过哈希查找实现的
    • 不连续的数组(稀疏数组)也能正常工作

    const arr = [];
    arr[999] = 1; // 这是合法的,arr.length = 1000
    arr[0] = 2; // 中间全是 empty

    另一个坑:sort() 默认按 ASCII 排序

    let arr = [10, 2, 5];
    arr.sort(); // [10, 2, 5] —— 按 ASCII 排序,"10" < "2"
    arr.sort((a, b) => a – b); // [2, 5, 10] —— 数字升序,正确

    sort() 如果不传比较函数,会把元素转成字符串按 Unicode 码点排序。这是一个经典的前端坑,面试也常考。


    复盘:下次怎么优化

    1. 学习的优化

    如果让我重新学一遍栈和队列,我会这样做:

    • 不要先看定义,先看场景。 先问"什么情况下需要 LIFO?什么情况下需要 FIFO?"带着场景去学数据结构,比背定义高效。
    • 画图。 栈就是竖着的管子,队列就是横着的管道,链表就是散落的珠子串在一根线上。图想明白了,代码自然写出来。
    • 手写,不要复制粘贴。 ListNode 的构造函数、链表插入的指针操作,默写多遍,肌肉记忆就形成了。

    2. 代码的优化

    • 队列尽量用链表实现,避免 shift() 的 O(n) 开销。JS 的 Array.shift() 在数据量大时性能很差。
    • 栈用数组的 push/pop 就够了,因为这两个操作都在数组尾部,时间复杂度都是 O(1),不会触发元素移动。
    • sort() 永远传比较函数,养成肌肉记忆。

    3. 知识体系的优化

    把这四个数据结构的增删改查复杂度做成一张表,时常回顾:

    访问 插入(头/尾/中) 删除(头/尾/中)
    数组 O(1) O(n)/O(1)/O(n) O(n)/O(1)/O(n)
    链表 O(n) O(1)/O(1)/O(1) O(1)/O(1)/O(1)
    栈 — –/O(1)/– –/O(1)/–
    队列 — –/O(1)/– O(1)/–/–

    这张表是面试和实际开发中的"速查手册"。理解了它,选数据结构的决策就变成了下意识反应。


    一张图串联全文

    线性数据结构

    ┌──────────────┼──────────────┐
    ▼ ▼ ▼
    数组 链表 受限结构
    连续存储+下标 离散存储+指针 │
    │ │ ┌─────┴─────┐
    │ │ ▼ ▼
    │ │ 栈(LIFO) 队列(FIFO)
    │ │ push/pop push/shift
    │ │
    JS特殊性: 添加元素本质:
    底层是哈希表 操作next指针
    混合类型ok 别"坐过站"
    sort()要传参


    引用说明:本文代码和笔记来自【我的算法学习仓库】(github.com/xz878787), 笔记参考了《算法图解》和 MDN 相关资料。

    如果你觉得这篇文章对你有帮助,欢迎点赞、收藏、评论三连。有任何问题也欢迎在评论区交流讨论! 。

    赞(0)
    未经允许不得转载:171主机测评 » 栈和队列:你以为很熟,其实暗藏玄机
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址