前言:线性的世界
数组大家都不陌生------连续存储、下标直达,像一排整齐的储物柜,拿第 3 个格子里的东西,O(1) 一步到位。
但它不是万能的。插入和删除时,元素们就得集体"搬家",时间复杂度飙到 O(n)。
今天我们不聊数组本身,而是从它衍生出的三兄弟:栈 、队列 和链表 。它们都是线性数据结构------每个节点有且仅有一个前驱、有且仅有一个后继。理解它们,是后续攻克树和图(非线性结构)的基石。
一、栈(Stack)------ 冰柜里的雪糕
什么是栈?
栈是一种 LIFO (Last In First Out,后进先出)的数据结构。你可以把它当成一个操作受限的数组 ------只能在尾部(栈顶)进行增删。
想象一个冰柜:
你往冰柜里塞雪糕------东北大板塞进去,西北大板再塞进去,中南大板又塞进去......等到要吃的时候,手只能从最上面拿。最后放进去的,最先被拿出来。 这就是栈。
代码视角
js
// 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,先进先出)。依然是操作受限的数组,但限制的位置不同 ------只能从队尾入队 ,从队首出队。
食堂排队打饭:先来的人先打到饭走人(出队),后来的人只能在队尾接着排(入队)。插队?不存在的。
代码视角
js
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 数组------你以为的"数组"未必是数组
在深入链表之前,有一个容易被忽略的真相需要先揭开:
js
const arr = [1, 2, 3, 4]; // 真正的数组:类型一致 → 连续内存 → 下标 O(1)
const arr1 = ['haha', 1, { a: 1 }]; // "伪数组":类型不同 → 底层用哈希映射 → 不再连续
JS 的数组比较"宽容"------你什么都往里塞,它也不抱怨。但代价是:如果元素类型不一致,JS 底层就不再使用连续内存存储,而是通过哈希表映射。此时它名义上叫"数组",实际上已经失去了数组最核心的优势------连续存储 + 下标快速定位。
这就是为什么算法题里,我们默认数组各项是同一类型------只有这样才能享受真正的 O(1) 随机访问。
同样,关于数组的增删方法,补充几个常用但容易踩坑的:
js
// 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]
⚠️
push、unshift、splice、pop、shift都会原地修改原数组,不是纯函数。如果你不希望污染原始数据,记得先浅拷贝一份。
额外一提:数组的 sort() 方法默认按 ASCII 排序,所以:
js
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 指针串成一条链。
js
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) 只改指针指向 ✨ |
| 扩容开销 | 需要整体搬移 | 每次只申请一个节点 |
| 适用规模 | 小规模、读多写少 | 大规模、频繁增删 |
scss
复杂度总结:
┌──────────────────────────────────────┐
│ 操作 数组 链表 │
│ 随机访问 O(1) O(n) │
│ 头部增删 O(n) O(1) │
│ 尾部增删 O(1)* O(n) / O(1)** │
│ 中间增删 O(n) O(n) 定位+O(1) │
│ │
│ * 不触发扩容时 │
│ ** 维护尾指针时 │
└──────────────────────────────────────┘
五、总结
- 栈 (Stack)= LIFO,只能在一头操作。像一个冰柜,后放的雪糕先拿出来。核心 API:
push/pop/peek。 - 队列 (Queue)= FIFO,队尾入、队首出。像食堂排队打饭,先来先走。核心 API:
push/shift。 - 链表 (Linked List)= 节点用
next指针串起来的离散结构。增删快(O(1) 改指针),访问慢(O(n) 从头找)。 - JS 数组 当元素类型一致时才是真正的连续数组;类型混杂时底层退化为哈希映射,丧失数组特性。
- 选型建议:小规模、偏读取 → 数组;大规模、偏增删 → 链表。栈和队列是加了"使用规则"的特殊数组/链表,按场景选择。
数据结构没有银弹,每种结构都是特定场景下的"最优解"。理解了它们的脾气,写代码时才能"对症下药"。
下一篇预告:从线性到非线性------树的遍历与图的初探。
如果这篇文章对你有帮助,欢迎点赞、收藏、评论三连!有任何疑问欢迎在评论区交流 👏