JS 数据结构实战:从栈队列到链表,一文吃透数组底层原理与线性数据结构

JS 数据结构实战:从栈队列到链表,一文吃透数组底层原理与线性数据结构

🚀 JS 数组的内存一定连续吗?栈和队列只是"操作受限的数组"?链表增删真的比数组快吗? 本文从数组底层内存模型出发,深入栈、队列、链表三大线性数据结构,结合代码实战与复杂度分析,带你彻底搞懂前端数据结构的本质!

📖 前言

数组是 JavaScript 中最常用的数据结构,开箱即用、API 丰富。但很多人对它的底层原理一知半解:

  • ❓ JS 数组的内存一定是连续的吗?
  • ❓ push 操作背后的数组扩容机制是什么?
  • ❓ 栈和队列跟数组有什么关系?
  • ❓ 链表和数组到底谁更适合增删操作?
  • ❓ splice 为什么既能增又能删?

这篇文章将从内存视角剖析 JS 数组的本质,并通过代码实战掌握栈、队列、链表三大线性数据结构。


🧠 知识图谱

perl 复制代码
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 数组的内存不一定连续!

javascript 复制代码
// ✅ 类型一致:V8 引擎会优化为连续内存
const arr1 = [1, 2, 3, 4];
// 底层:连续内存存储,通过偏移量快速访问

// ❌ 类型混杂:底层退化为哈希表
const arr2 = ['haha', 1, { name: '张三' }];
// 底层:非连续内存,通过哈希表模拟数组行为

// 但访问方式一样!
console.log(arr1[2]);  // → 3
console.log(arr2[2]);  // → { name: '张三' }
scss 复制代码
类型一致的数组(连续内存):

内存地址: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 数组的增删复杂度

scss 复制代码
数组扩容机制:

初始状态:
┌─────┬─────┬─────┬─────┐
│  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 什么是栈?

💡 栈是一种"操作受限的数组"------只能在同一端(栈顶)进行插入和删除操作。

生活类比:冰柜里的雪糕

sql 复制代码
冰柜(栈):

        ┌─────────┐
        │  巧乐兹  │  ←── 栈顶(最后放入,最先取出)
        ├─────────┤
        │  冰工厂  │
        ├─────────┤
        │  可爱多  │
        ├─────────┤
        │ 东北大板 │  ←── 栈底(最先放入,最后取出)
        └─────────┘

LIFO = Last In First Out

2.2 栈的核心操作

操作 方法 说明
入栈 push(item) 元素放入栈顶
出栈 pop() 移除并返回栈顶元素
查看栈顶 peek 查看栈顶元素(不移除)
判空 stack.length === 0 检查栈是否为空

2.3 代码实战:雪糕出栈

javascript 复制代码
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);  // → []
scss 复制代码
栈操作过程:

入栈:
[]
↓ push('东北大板')
['东北大板']
↓ push('可爱多')
['东北大板', '可爱多']
↓ push('冰工厂')
['东北大板', '可爱多', '冰工厂']
↓ push('巧乐兹')
['东北大板', '可爱多', '冰工厂', '巧乐兹']

出栈(LIFO):
['东北大板', '可爱多', '冰工厂', '巧乐兹']
↓ pop() → 巧乐兹
['东北大板', '可爱多', '冰工厂']
↓ pop() → 冰工厂
['东北大板', '可爱多']
↓ pop() → 可爱多
['东北大板']
↓ pop() → 东北大板
[]

🚶 三、队列(Queue)--- 先进先出 FIFO

2.1 什么是队列?

💡 队列也是一种"操作受限的数组"------只能在队尾入队,在队头出队。

生活类比:排队买票

css 复制代码
队列:

队尾(入队)              队头(出队)
    ↓                       ↓
┌─────┬─────┬─────┬─────┐
│  D  │  C  │  B  │  A  │
└─────┴─────┴─────┴─────┘

FIFO = First In First Out
A 最先排队,A 最先买到票

3.2 队列的核心操作

操作 方法 说明
入队 push(item) 元素放入队尾
出队 shift() 移除并返回队头元素
查看队头 queue[0] 查看队头元素(不移除)
判空 queue.length === 0 检查队列是否为空

3.3 代码实战:排队出队

javascript 复制代码
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);  // → []
scss 复制代码
队列操作过程:

入队:
[]
↓ 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 什么是链表?

💡 链表是由节点组成的线性数据结构,节点在内存中不连续分布,通过指针连接。

kotlin 复制代码
链表结构:

内存分布(不连续):

节点 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 链表节点结构

javascript 复制代码
// 链表节点构造函数
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 } }
yaml 复制代码
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) 修改指针(已知前驱)
适用场景 频繁访问、遍历 频繁增删
数据规模 小~中规模 大规模
css 复制代码
数组增删(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 指针

javascript 复制代码
// 遍历链表
let current = head;
while (current !== null) {
    console.log(current.val);
    current = current.next;
}

链表增删的关键:找到前驱节点

css 复制代码
在节点 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 --- 尾部操作

javascript 复制代码
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 --- 头部操作

javascript 复制代码
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 是数组最强大的方法,可以删除 、插入 或替换元素。

javascript 复制代码
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 --- 排序陷阱

javascript 复制代码
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 排序! 数字排序必须传入比较函数。

比较函数原理:

css 复制代码
(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)

💡 学习建议

  1. 理解内存模型:JS 数组不一定是连续内存,类型混杂时退化为哈希表
  2. 掌握复杂度:数组访问快 O(1),增删慢 O(n);链表增删快 O(1),访问慢 O(n)
  3. 选择合适的数据结构:频繁访问用数组,频繁增删用链表
  4. 注意 sort 陷阱 :数字排序必须传入比较函数 (a, b) => a - b
  5. 练习 LeetCode:栈(20、155)、队列(232)、链表(206、21)是面试高频题

📚 推荐阅读

  • MDN - Array
  • MDN - splice
  • LeetCode Hot 100 --- 栈、队列、链表相关题目
  • 《数据结构与算法 JavaScript 描述》--- 链表与数组
  • 《JavaScript 高级程序设计》第 4 版 --- 引用类型与内存

🏷️ 标签 :JavaScript 数据结构 栈 队列 链表 数组 算法 复杂度 前端 面试


如果这篇文章帮你理解了 JS 数据结构的底层原理,欢迎点赞 + 收藏 + 关注!有任何疑问欢迎在评论区交流~ 🎉

相关推荐
子兮曰3 天前
jev-ultrafast 深度解析:7 秒订机票的浏览器 Agent 是如何炼成的
前端·后端·agent
子兮曰3 天前
Jev 爆发一周:7 秒 Agent 背后的 System One 生态与三场争议
前端·后端·ai编程
前端小万3 天前
写公众号赚了 3000 块后,我做了一款叫 "一键成稿" 的软件
前端·微信小程序
爱勇宝3 天前
ZCode 开源 24 小时:一份没有历史的账本,回答不了"有没有偷代码"
前端·后端·chatglm (智谱)
三十而立洋3 天前
Cookie 详解:从产生到安全,一次讲透
前端·javascript
卡布鲁3 天前
把一个 Vite + Vue3 应用塞进 qiankun (React + Umi3) 主站:十个坑的复盘
前端·javascript·react.js
m0_547486663 天前
《数据结构教程》全套 PPT课件2026
数据结构
李少兄3 天前
JavaScript 隐式全局变量解析
javascript
汉堡大王95273 天前
Jev:不是聊天机器人, 而是一个智能 if 语句
前端·人工智能·后端
梦想很大很大3 天前
从运行事实到回归证据:Workrun 的 Telemetry 与 Evaluation 实践
前端·人工智能·后端