JavaScript栈和队列:从“冰柜里的雪糕”到“排队打饭”

0 阅读5分钟

JavaScript栈和队列:从“冰柜里的雪糕”到“排队打饭”

摘要:栈和队列是两种操作受限的线性数据结构。本文从数组出发,用 push/pop 实现 LIFO 的栈,用 push/shift 实现 FIFO 的队列,并对比了链表与数组的增删性能差异。还顺手整理了 JS 数组的 splice、sort 陷阱以及内存模型。

📑 目录

  • 线性数据结构:数组、链表、栈、队列
  • 栈:后进先出,就像冰柜里的雪糕
  • 队列:先进先出,就像排队打饭
  • 灵活增删的数组:splice 与 API 副作用
  • sort 的坑:默认按字符串排序
  • 链表:用对象嵌套模拟节点
  • 数组内存真的连续吗?
  • 一点总结
  • 互动讨论

线性数据结构:数组、链表、栈、队列

数据结构可以分为两类:

  • 线性结构:每个元素只有一个前驱和一个后继。包括数组、链表、栈、队列。
  • 非线性结构:树、图等。

数组和链表是“底层容器”,栈和队列可以看作操作受限的数组——它们只允许在特定位置增删元素。由于 JS 数组提供了 pushpopshift 等 API,用数组实现栈和队列非常方便,开箱即用。


栈:后进先出,就像冰柜里的雪糕

栈(Stack)的特点是 LIFO(Last In First Out)。最后放进去的,最先拿出来。

想象一下冰柜里卖雪糕:老板把新进的雪糕放在最上面,顾客买的时候也从最上面拿。这就是栈。

javascript

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

// 出栈(后进先出)
while (stack.length) {
    const top = stack[stack.length - 1];
    console.log("取出的是", top);
    stack.pop();
}
console.log(stack); // []

输出顺序:巧乐兹 → 冰工厂 → 可爱多 → 东北大板。

栈的关键操作:

  • push:入栈(尾部添加)
  • pop:出栈(尾部删除)
  • peek:查看栈顶元素(stack[stack.length-1]

队列:先进先出,就像排队打饭

队列(Queue)的特点是 FIFO(First In First Out)。先来的先服务。

就像食堂排队打饭,先排队的先打到饭。

javascript

const queue = [];
queue.push('许');
queue.push('叶');
queue.push('戴');

while (queue.length) {
    const front = queue[0];
    console.log(front);
    queue.shift();  // 队头出队
}
console.log(queue); // []

输出顺序:许 → 叶 → 戴。

队列的关键操作:

  • push:入队(尾部添加)
  • shift:出队(头部删除)

灵活增删的数组:splice 与 API 副作用

数组的增删 API(pushpopshiftunshiftsplice)都会直接修改原数组,不是纯函数。

splice 是一个多功能方法:可以同时完成删除和插入。

javascript

const arr = [1, 2];
arr.splice(1, 0, 3);   // 在下标1处,删除0个元素,插入3
console.log(arr);      // [1, 3, 2]

console.log(arr.splice(1, 2)); // 删除从下标1开始的2个元素,返回 [3, 2]
console.log(arr);              // [1]

splice 语法:(start_index, delete_count, ...items_to_add)

  • 返回值:被删除的元素组成的数组。
  • 如果没有删除元素,返回空数组 []

注意:unshift 和 shift 操作会导致数组所有元素在内存中后移或前移,开销较大。


sort 的坑:默认按字符串排序

JS 的 sort() 默认将元素转为字符串,按 UTF-16 码点排序。这会导致数字排序不符合直觉:

javascript

let arr = [10, 5, 2];
arr.sort();
console.log(arr); // [10, 2, 5]  —— 因为 "10" < "2" < "5"

正确做法:传入比较函数。

javascript

arr.sort((a, b) => a - b); // 升序 [2, 5, 10]
arr.sort((a, b) => b - a); // 降序 [10, 5, 2]

链表:用对象嵌套模拟节点

链表是线性结构,但元素在内存中不连续,通过指针(引用)链接。

JS 中可以用对象字面量模拟节点:

javascript

function ListNode(val) {
    this.val = val;
    this.next = null;
}
const node = new ListNode(1);
node.next = new ListNode(2);
console.log(node);
// { val: 1, next: { val: 2, next: null } }

链表必须有 head 指针记录起始位置。访问任意节点必须从头开始遍历。增删元素时,只需要修改相邻节点的 next 指针,复杂度 O(1)(前提是已经找到目标位置)。

数组与链表对比

特性数组链表
内存连续(纯数组)或不连续(非纯数组)不连续,离散
访问O(1) 按索引O(n) 需遍历
插入/删除O(n) 需移动元素O(1) 只需改指针
扩容需要整段转移每次新增节点申请内存

小规模数据用数组,大规模频繁增删用链表。


数组内存真的连续吗?

在 JS 中,如果数组所有元素类型一致(如全是 number),引擎会分配连续内存,这是真正的数组。

但如果元素类型不同(如 [1, '2', {a:3}]),就无法通过索引计算偏移量。此时引擎用哈希表(对象)模拟数组,下标作为 key,值作为 value,内存不连续。这就是为什么 JS 数组可以装不同类型的原因——它放弃了连续内存的严格性。


一点总结

  1. 栈(LIFO) :用 push + pop 实现,典型场景:函数调用栈、撤销操作。
  2. 队列(FIFO) :用 push + shift 实现,典型场景:任务队列、打印队列。
  3. 数组增删 API 都会修改原数组,splice 功能强大但注意参数含义。
  4. sort 默认按字符串排序,数字排序必须传比较函数。
  5. 链表适合频繁增删,但查找慢;数组适合随机访问。
  6. JS 数组未必连续,类型一致时才是真正的数组,否则是哈希表。

理解这些基础数据结构,能帮你写出更高效的代码,也是面试的高频考点。


互动讨论

  1. 如果用数组实现队列,频繁 shift 会导致性能问题,有什么优化方案? (提示:用“循环队列”或两个栈模拟)
  2. splice 返回的是什么?如果只传 start_index 不传 delete_count 会怎样?
  3. 为什么链表必须有 head?如果没有头指针,你能找到链表的第一个节点吗?
  4. JS 中 arr.sort((a,b) => a-b) 的工作原理是什么?  比较函数返回负数、零、正数分别代表什么?
  5. 除了栈和队列,还有哪些“操作受限”的数据结构?  比如双端队列(deque)?

📌 一点心得:数据结构不只是面试题。在开发中理解数据结构的特性,能帮你判断用数组还是链表、用栈还是队列,写出更合适的代码。