JS 数据结构实战:从栈队列到链表,一文吃透数组底层原理与线性数据结构
🚀 JS 数组的内存一定连续吗?栈和队列只是"操作受限的数组"?链表增删真的比数组快吗? 本文从数组底层内存模型出发,深入栈、队列、链表三大线性数据结构,结合代码实战与复杂度分析,带你彻底搞懂前端数据结构的本质!
📖 前言
数组是 JavaScript 中最常用的数据结构,开箱即用、API 丰富。但很多人对它的底层原理一知半解:
- ❓ JS 数组的内存一定是连续的吗?
- ❓
push操作背后的数组扩容机制是什么? - ❓ 栈和队列跟数组有什么关系?
- ❓ 链表和数组到底谁更适合增删操作?
- ❓
splice为什么既能增又能删?
这篇文章将从内存视角剖析 JS 数组的本质,并通过代码实战掌握栈、队列、链表三大线性数据结构。
🧠 知识图谱
JS 线性数据结构全解析
├── 📦 一、JS 数组底层原理
│ ├── 连续内存 vs 非连续内存
│ ├── 类型一致 vs 类型混杂
│ ├── 数组扩容机制
│ └── 增删操作的复杂度
│
├── 🥞 二、栈(Stack)— LIFO
│ ├── 栈的特性与类比
│ ├── push / pop / peek
│ └── 代码实战:雪糕出栈
│
├── 🚶 三、队列(Queue)— FIFO
│ ├── 队列的特性
│ ├── push / shift
│ └── 代码实战:排队出队
│
├── 🔗 四、链表(Linked List)
│ ├── 链表节点结构
│ ├── 链表 vs 数组对比
│ ├── 增删操作 O(1)
│ └── 代码实战:创建链表
│
└── 🛠️ 五、数组增删方法详解
├── push / unshift
├── pop / shift
└── splice — 增删神器
📦 一、JS 数组底层原理
1.1 JS 数组未必是真正的数组
这是很多人不知道的秘密:JS 数组的内存不一定连续!
// ✅ 类型一致:V8 引擎会优化为连续内存
const arr1 = [1, 2, 3, 4];
// 底层:连续内存存储,通过偏移量快速访问
// ❌ 类型混杂:底层退化为哈希表
const arr2 = ['haha', 1, { name: '张三' }];
// 底层:非连续内存,通过哈希表模拟数组行为
// 但访问方式一样!
console.log(arr1[2]); // → 3
console.log(arr2[2]); // → { name: '张三' }
类型一致的数组(连续内存):
内存地址:0x1000 0x1008 0x1010 0x1018
┌─────────┬─────────┬─────────┬─────────┐
│ 1 │ 2 │ 3 │ 4 │
└─────────┴─────────┴─────────┴─────────┘
arr[0] arr[1] arr[2] arr[3]
访问 arr[2]:
地址 = 起始地址 + 2 × 8字节 = 0x1010
→ O(1) 直接定位!
类型混杂的数组(哈希表):
┌─────────┐
│ 哈希表 │
│ 0 → 'haha' │
│ 1 → 1 │
│ 2 → {name} │
└─────────┘
访问 arr[2]:
哈希查找 key = "2"
→ O(1) 哈希查找
💡 V8 引擎的优化策略:
- 当数组元素类型一致时,使用快速模式(连续内存)
- 当数组元素类型混杂时,退化为字典模式(哈希表)
1.2 数组的增删复杂度
数组扩容机制:
初始状态:
┌─────┬─────┬─────┬─────┐
│ 1 │ 2 │ 3 │ 4 │
└─────┴─────┴─────┴─────┘
容量:4 长度:4
push(5) 时:
容量已满!→ 申请新内存(通常扩容 1.5~2 倍)
→ 复制原有元素 → 插入新元素
新内存:
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ 1 │ 2 │ 3 │ 4 │ 5 │ │ │ │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
容量:8 长度:5
| 操作 | 位置 | 复杂度 | 说明 |
|---|---|---|---|
| 访问 | 任意 | O(1) | 通过索引直接定位 |
| push | 尾部 | 均摊 O(1) | 偶尔需要扩容 |
| pop | 尾部 | O(1) | 直接移除 |
| unshift | 头部 | O(n) | 所有元素后移 |
| shift | 头部 | O(n) | 所有元素前移 |
| splice | 中间 | O(n) | 移动后续元素 |
🔥 关键洞察:数组的增删操作需要移动元素,复杂度与数组长度呈线性关系 O(n)。
🥞 二、栈(Stack)— 后进先出 LIFO
2.1 什么是栈?
💡 栈是一种"操作受限的数组"——只能在同一端(栈顶)进行插入和删除操作。
生活类比:冰柜里的雪糕
冰柜(栈):
┌─────────┐
│ 巧乐兹 │ ←── 栈顶(最后放入,最先取出)
├─────────┤
│ 冰工厂 │
├─────────┤
│ 可爱多 │
├─────────┤
│ 东北大板 │ ←── 栈底(最先放入,最后取出)
└─────────┘
LIFO = Last In First Out
2.2 栈的核心操作
| 操作 | 方法 | 说明 |
|---|---|---|
| 入栈 | push(item) | 元素放入栈顶 |
| 出栈 | pop() | 移除并返回栈顶元素 |
| 查看栈顶 | peek | 查看栈顶元素(不移除) |
| 判空 | stack.length === 0 | 检查栈是否为空 |
2.3 代码实战:雪糕出栈
const stack = []; // 空栈
// 入栈:依次放入雪糕
stack.push('东北大板');
stack.push('可爱多');
stack.push('冰工厂');
stack.push('巧乐兹');
console.log(stack);
// → ['东北大板', '可爱多', '冰工厂', '巧乐兹']
// 出栈:后进先出
while (stack.length) {
const top = stack[stack.length - 1]; // peek:查看栈顶
console.log('出栈的元素是', top);
stack.pop(); // 移除栈顶
}
// 输出:
// 出栈的元素是 巧乐兹
// 出栈的元素是 冰工厂
// 出栈的元素是 可爱多
// 出栈的元素是 东北大板
console.log(stack); // → []
栈操作过程:
入栈:
[]
↓ push('东北大板')
['东北大板']
↓ push('可爱多')
['东北大板', '可爱多']
↓ push('冰工厂')
['东北大板', '可爱多', '冰工厂']
↓ push('巧乐兹')
['东北大板', '可爱多', '冰工厂', '巧乐兹']
出栈(LIFO):
['东北大板', '可爱多', '冰工厂', '巧乐兹']
↓ pop() → 巧乐兹
['东北大板', '可爱多', '冰工厂']
↓ pop() → 冰工厂
['东北大板', '可爱多']
↓ pop() → 可爱多
['东北大板']
↓ pop() → 东北大板
[]
🚶 三、队列(Queue)— 先进先出 FIFO
2.1 什么是队列?
💡 队列也是一种"操作受限的数组"——只能在队尾入队,在队头出队。
生活类比:排队买票
队列:
队尾(入队) 队头(出队)
↓ ↓
┌─────┬─────┬─────┬─────┐
│ D │ C │ B │ A │
└─────┴─────┴─────┴─────┘
FIFO = First In First Out
A 最先排队,A 最先买到票
3.2 队列的核心操作
| 操作 | 方法 | 说明 |
|---|---|---|
| 入队 | push(item) | 元素放入队尾 |
| 出队 | shift() | 移除并返回队头元素 |
| 查看队头 | queue[0] | 查看队头元素(不移除) |
| 判空 | queue.length === 0 | 检查队列是否为空 |
3.3 代码实战:排队出队
const queue = []; // 空队列
// 入队:依次排队
queue.push('东北大板');
queue.push('可爱多');
queue.push('冰工厂');
queue.push('巧乐兹');
console.log(queue);
// → ['东北大板', '可爱多', '冰工厂', '巧乐兹']
// 出队:先进先出
while (queue.length) {
const top = queue[0]; // 查看队头
console.log('出队的元素是', top);
queue.shift(); // 移除队头
}
// 输出:
// 出队的元素是 东北大板
// 出队的元素是 可爱多
// 出队的元素是 冰工厂
// 出队的元素是 巧乐兹
console.log(queue); // → []
队列操作过程:
入队:
[]
↓ push('东北大板')
['东北大板']
↓ push('可爱多')
['东北大板', '可爱多']
↓ push('冰工厂')
['东北大板', '可爱多', '冰工厂']
↓ push('巧乐兹')
['东北大板', '可爱多', '冰工厂', '巧乐兹']
出队(FIFO):
['东北大板', '可爱多', '冰工厂', '巧乐兹']
↓ shift() → 东北大板
['可爱多', '冰工厂', '巧乐兹']
↓ shift() → 可爱多
['冰工厂', '巧乐兹']
↓ shift() → 冰工厂
['巧乐兹']
↓ shift() → 巧乐兹
[]
3.4 栈 vs 队列对比
| 特性 | 栈(Stack) | 队列(Queue) |
|---|---|---|
| 原则 | LIFO 后进先出 | FIFO 先进先出 |
| 入 | push(栈顶) | push(队尾) |
| 出 | pop(栈顶) | shift(队头) |
| 查看 | stack[length-1] | queue[0] |
| 类比 | 冰柜里的雪糕 | 排队买票 |
| 应用 | 函数调用栈、撤销操作 | 任务队列、消息队列 |
🔗 四、链表(Linked List)
4.1 什么是链表?
💡 链表是由节点组成的线性数据结构,节点在内存中不连续分布,通过指针连接。
链表结构:
内存分布(不连续):
节点 A 节点 B 节点 C
┌─────┐ ┌─────┐ ┌─────┐
│ val │ │ val │ │ val │
│ 1 │ │ 2 │ │ 3 │
├─────┤ ├─────┤ ├─────┤
│ next│──────→│ next│──────→│ next│──→ null
│ B │ │ C │ │null │
└─────┘ └─────┘ └─────┘
0x1000 0x2000 0x3000
(地址分散)
head ──→ 节点 A ──→ 节点 B ──→ 节点 C ──→ null
4.2 链表节点结构
// 链表节点构造函数
function ListNode(val) {
this.val = val; // 节点值
this.next = null; // 指向下一个节点的指针
}
// 创建链表:1 → 2 → null
const node = new ListNode(1);
node.next = new ListNode(2);
console.log(node);
// → ListNode { val: 1, next: ListNode { val: 2, next: null } }
JS 对象表示链表节点:
{
val: 1,
next: {
val: 2,
next: null
}
}
4.3 链表 vs 数组对比
| 维度 | 数组 | 链表 |
|---|---|---|
| 内存分布 | 连续 | 离散(不连续) |
| 访问元素 | O(1) 索引访问 | O(n) 从头遍历 |
| 头部增删 | O(n) 移动元素 | O(1) 修改指针 |
| 尾部增删 | O(1) / 均摊 O(1) | O(n) 需遍历到最后 |
| 中间增删 | O(n) 移动元素 | O(1) 修改指针(已知前驱) |
| 适用场景 | 频繁访问、遍历 | 频繁增删 |
| 数据规模 | 小~中规模 | 大规模 |
数组增删(O(n)):
删除 arr[2]:
┌─────┬─────┬─────┬─────┬─────┐
│ 1 │ 2 │ 3 │ 4 │ 5 │
└─────┴─────┴─────┴─────┴─────┘
↑
删除 3
需要移动后续元素:
┌─────┬─────┬─────┬─────┐
│ 1 │ 2 │ 4 │ 5 │
└─────┴─────┴─────┴─────┘
链表增删(O(1)):
删除节点 B:
head ──→ A ──→ B ──→ C ──→ null
只需修改 A.next:
head ──→ A ────────→ C ──→ null
↑
B 被跳过,自动回收
💡 链表增删高效的本质:只需要修改指针指向,不需要移动任何元素!
4.4 链表的核心操作
访问链表元素:必须从 head 开始,逐个遍历 next 指针
// 遍历链表
let current = head;
while (current !== null) {
console.log(current.val);
current = current.next;
}
链表增删的关键:找到前驱节点
在节点 B 后插入新节点 X:
修改前:A ──→ B ──→ C
步骤:
1. X.next = B.next (X 指向 C)
2. B.next = X (B 指向 X)
修改后:A ──→ B ──→ X ──→ C
🛠️ 五、数组增删方法详解
5.1 push / pop — 尾部操作
const arr = [1, 2, 3];
arr.push(4); // → 4(返回新长度)
// arr = [1, 2, 3, 4]
arr.pop(); // → 4(返回被删除的元素)
// arr = [1, 2, 3]
5.2 unshift / shift — 头部操作
const arr = [1, 2, 3];
arr.unshift(0); // → 4(返回新长度)
// arr = [0, 1, 2, 3]
// 所有元素后移!O(n)
arr.shift(); // → 0(返回被删除的元素)
// arr = [1, 2, 3]
// 所有元素前移!O(n)
5.3 splice — 增删神器
splice 是数组最强大的方法,可以删除、插入或替换元素。
const arr = [1, 2, 3, 4, 5];
// 语法:arr.splice(start, deleteCount, item1, item2, ...)
// ① 在索引 1 处插入 3(不删除)
console.log(arr.splice(1, 0, 3));
// → [](没有删除元素)
console.log(arr);
// → [1, 3, 2, 3, 4, 5]
// ② 删除索引 1 处的 1 个元素
arr.splice(1, 1);
console.log(arr);
// → [1, 2, 3, 4, 5]
// ③ 从索引 1 开始删除 2 个元素,并插入 'a', 'b'
arr.splice(1, 2, 'a', 'b');
console.log(arr);
// → [1, 'a', 'b', 4, 5]
| 参数 | 说明 |
|---|---|
start | 开始位置索引 |
deleteCount | 删除的元素个数(0 表示不删除) |
item1, item2... | 要插入的元素(可选) |
💡 splice 的本质:
slice + replace,先删除指定位置的元素,再插入新元素。
5.4 sort — 排序陷阱
let arr = [2, 10, 3, 1];
// ❌ 默认按 ASCII 排序
arr.sort();
console.log(arr); // → [1, 10, 2, 3]('10' 的 ASCII 小于 '2')
// ✅ 传入比较函数,按数字排序
arr.sort((a, b) => a - b);
console.log(arr); // → [1, 2, 3, 10]
⚠️
sort默认将元素转为字符串按 ASCII 排序! 数字排序必须传入比较函数。
比较函数原理:
(a, b) => a - b
如果 a - b < 0:a 排在 b 前面(升序)
如果 a - b > 0:a 排在 b 后面
如果 a - b = 0:位置不变
示例:比较 2 和 10
2 - 10 = -8 < 0 → 2 排在 10 前面
📊 核心知识速查表
线性数据结构对比
| 数据结构 | 特性 | 核心操作 | 时间复杂度 | 应用场景 |
|---|---|---|---|---|
| 数组 | 连续内存,索引访问 | 访问、遍历 | 访问 O(1),增删 O(n) | 频繁访问、小数据 |
| 栈 | LIFO | push、pop | O(1) | 函数调用、撤销 |
| 队列 | FIFO | push、shift | O(1) | 任务队列、BFS |
| 链表 | 离散内存,指针连接 | 增删 | 访问 O(n),增删 O(1) | 频繁增删、大数据 |
数组方法分类
| 方法 | 操作位置 | 修改原数组? | 返回值 | 复杂度 |
|---|---|---|---|---|
push | 尾部 | ✅ | 新长度 | 均摊 O(1) |
pop | 尾部 | ✅ | 被删元素 | O(1) |
unshift | 头部 | ✅ | 新长度 | O(n) |
shift | 头部 | ✅ | 被删元素 | O(n) |
splice | 任意 | ✅ | 被删数组 | O(n) |
sort | 整体 | ✅ | 排序后的数组 | O(n log n) |
💡 学习建议
- 理解内存模型:JS 数组不一定是连续内存,类型混杂时退化为哈希表
- 掌握复杂度:数组访问快 O(1),增删慢 O(n);链表增删快 O(1),访问慢 O(n)
- 选择合适的数据结构:频繁访问用数组,频繁增删用链表
- 注意 sort 陷阱:数字排序必须传入比较函数
(a, b) => a - b - 练习 LeetCode:栈(20、155)、队列(232)、链表(206、21)是面试高频题
📚 推荐阅读
- MDN - Array
- MDN - splice
- LeetCode Hot 100 — 栈、队列、链表相关题目
- 《数据结构与算法 JavaScript 描述》— 链表与数组
- 《JavaScript 高级程序设计》第 4 版 — 引用类型与内存
🏷️ 标签:
JavaScript数据结构栈队列链表数组算法复杂度前端面试
如果这篇文章帮你理解了 JS 数据结构的底层原理,欢迎点赞 + 收藏 + 关注!有任何疑问欢迎在评论区交流~ 🎉