栈队列链表,三个故事就懂了

10 阅读7分钟

前言:线性的世界

数组大家都不陌生——连续存储、下标直达,像一排整齐的储物柜,拿第 3 个格子里的东西,O(1) 一步到位。

但它不是万能的。插入和删除时,元素们就得集体"搬家",时间复杂度飙到 O(n)。

今天我们不聊数组本身,而是从它衍生出的三兄弟:队列链表。它们都是线性数据结构——每个节点有且仅有一个前驱、有且仅有一个后继。理解它们,是后续攻克树和图(非线性结构)的基石。


一、栈(Stack)—— 冰柜里的雪糕

什么是栈?

栈是一种 LIFO(Last In First Out,后进先出)的数据结构。你可以把它当成一个操作受限的数组——只能在尾部(栈顶)进行增删。

想象一个冰柜:

你往冰柜里塞雪糕——东北大板塞进去,西北大板再塞进去,中南大板又塞进去……等到要吃的时候,手只能从最上面拿。最后放进去的,最先被拿出来。 这就是栈。

代码视角

// push 入栈 — pop 出栈 — peek 查看栈顶
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); // []

三个核心操作

操作方法说明
入栈push()元素放入栈顶(数组尾部)
出栈pop()移除并返回栈顶元素
窥顶stack[length - 1]只看不删,瞅一眼栈顶是谁

受限制,反而是种保护——你永远只跟栈顶打交道,逻辑干净,不会误操作栈底元素。许多场景(函数调用栈、括号匹配、撤销/重做)恰恰需要这种"只在一头操作"的约束。


二、队列(Queue)—— 食堂打饭的队伍

什么是队列?

队列是 FIFO(First In First Out,先进先出)。依然是操作受限的数组,但限制的位置不同——只能从队尾入队,从队首出队

食堂排队打饭:先来的人先打到饭走人(出队),后来的人只能在队尾接着排(入队)。插队?不存在的。

代码视角

const queue = []; // 空的队列

queue.push('li');    // 入队(队尾)
queue.push('huang');
queue.push('zhang');

while (queue.length) {
    const top = queue[0];          // 队首元素
    console.log(top, 'zaocan');    // li → huang → zhang
    queue.shift();                 // 出队(队首)
}

console.log(queue); // []

核心操作

操作方法说明
入队push()元素从队尾进入
出队shift()元素从队首离开
窥首queue[0]看队首是谁

栈 vs 队列:一张表搞清

栈(Stack)队列(Queue)
规则LIFO 后进先出FIFO 先进先出
push() 栈顶push() 队尾
pop() 栈顶shift() 队首
比喻冰柜拿雪糕 🍦食堂排队 🍚
典型场景函数调用栈、撤销操作任务调度、消息队列

三、JS 数组——你以为的"数组"未必是数组

在深入链表之前,有一个容易被忽略的真相需要先揭开:

const arr = [1, 2, 3, 4];        // 真正的数组:类型一致 → 连续内存 → 下标 O(1)

const arr1 = ['haha', 1, { a: 1 }]; // "伪数组":类型不同 → 底层用哈希映射 → 不再连续

JS 的数组比较"宽容"——你什么都往里塞,它也不抱怨。但代价是:如果元素类型不一致,JS 底层就不再使用连续内存存储,而是通过哈希表映射。此时它名义上叫"数组",实际上已经失去了数组最核心的优势——连续存储 + 下标快速定位。

这就是为什么算法题里,我们默认数组各项是同一类型——只有这样才能享受真正的 O(1) 随机访问。

同样,关于数组的增删方法,补充几个常用但容易踩坑的:

// splice(start_index, delete_count, ...items_to_add)
const arr = [1, 2];

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

arr.splice(1, 1);    // 从索引 1 处删除 1 个元素
console.log(arr);     // [1, 2]

⚠️ pushunshiftsplicepopshift 都会原地修改原数组,不是纯函数。如果你不希望污染原始数据,记得先浅拷贝一份。

额外一提:数组的 sort() 方法默认按 ASCII 排序,所以:

let arr = [10, 2, 5];
arr.sort();            // ❌ [10, 2, 5] — '10' 的 ASCII < '2' 的 ASCII
arr.sort((a, b) => a - b); // ✅ [2, 5, 10] — 传入比较函数才靠谱

四、链表(Linked List)—— 手拉手的节点链

为什么需要链表?

数组有个硬伤:扩容

arr.length >= capacity 时,JS 引擎需要重新申请一块更大的连续内存,再把原有元素整体拷贝过去。对于小规模数据无所谓,但如果数据规模巨大,这种"全体搬家"的开销就很可观了。

链表的思路则完全不同:每次新增一个元素,才申请一小块内存,用指针把它们串起来。不需要连续内存,也不需要扩容搬迁。

但天下没有免费的午餐——链表在遍历和随机访问上付出了代价。

链表长什么样?

链表中的每个数据单位叫节点(Node),每个节点有两个部分:

  • val:存数据
  • next:指向下一个节点的指针

节点分布在内存的各个角落(离散),靠 next 指针串成一条链。

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

const node = new ListNode(1);
node.next = new ListNode(2);

// 结构示意:
// {
//     val: 1,
//     next: {
//         val: 2,
//         next: null
//     }
// }

console.log(node);

head(头节点)→ node1 → node2 → ... → tail(尾节点,next 为 null)

访问链表中的任何一个元素,都必须从 head 出发,顺着 next 逐个往下找,一直找到目标节点为止。 没有"按下标直达"这种好事。

链表的增删

增删操作的本质,就是对 next 指针的重新指向:

  • 插入:找到前驱节点,把新节点的 next 指向前驱原本的 next,再把前驱的 next 指向新节点。
  • 删除:找到前驱节点,把它的 next 直接跳过目标节点,指向目标节点的 next

🚌 一个小比喻:坐公交车别"坐过站"——如果先让前驱指向新节点,你就丢失了原来后继节点的引用。正确顺序是先让新节点指向后继,再让前驱指向新节点。

数组 vs 链表:终结对比

维度数组(Array)链表(Linked List)
内存连续存储离散存储,指针串联
随机访问O(1) 下标直达 ⚡O(n) 从头遍历 🐢
增删(已知位置)O(n) 需要移动后续元素O(1) 只改指针指向 ✨
扩容开销需要整体搬移每次只申请一个节点
适用规模小规模、读多写少大规模、频繁增删
复杂度总结:
┌──────────────────────────────────────┐
│  操作      数组      链表            │
│  随机访问   O(1)     O(n)            │
│  头部增删   O(n)     O(1)            │
│  尾部增删   O(1)*    O(n) / O(1)**   │
│  中间增删   O(n)     O(n) 定位+O(1)  │
│                                      │
│  * 不触发扩容时                       │
│  ** 维护尾指针时                       │
└──────────────────────────────────────┘

五、总结

  1. (Stack)= LIFO,只能在一头操作。像一个冰柜,后放的雪糕先拿出来。核心 API:push / pop / peek
  2. 队列(Queue)= FIFO,队尾入、队首出。像食堂排队打饭,先来先走。核心 API:push / shift
  3. 链表(Linked List)= 节点用 next 指针串起来的离散结构。增删快(O(1) 改指针),访问慢(O(n) 从头找)。
  4. JS 数组 当元素类型一致时才是真正的连续数组;类型混杂时底层退化为哈希映射,丧失数组特性。
  5. 选型建议:小规模、偏读取 → 数组;大规模、偏增删 → 链表。栈和队列是加了"使用规则"的特殊数组/链表,按场景选择。

数据结构没有银弹,每种结构都是特定场景下的"最优解"。理解了它们的脾气,写代码时才能"对症下药"。


下一篇预告:从线性到非线性——树的遍历与图的初探。


如果这篇文章对你有帮助,欢迎点赞、收藏、评论三连!有任何疑问欢迎在评论区交流 👏