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 数据结构的底层原理,欢迎点赞 + 收藏 + 关注!有任何疑问欢迎在评论区交流~ 🎉

相关推荐
小小199210 分钟前
idea 配置less转化为css
前端·css·less
hhb_61813 分钟前
Less嵌套避坑:优先级冲突实战解析
前端·css·less
快乐的哈士奇16 分钟前
【Next.js实战①】Gmail API 按柜号检索邮件:OAuth 双 Cookie 与搜索 Fallback
开发语言·javascript·ecmascript
云水一下23 分钟前
Vue.js从零到精通系列(五):全局状态管理——Pinia 核心与实践
前端·javascript·vue.js
我不是外星人30 分钟前
浅谈我对 AI 发展的看法
前端·ai编程·claude
kmblack11 小时前
javascript计算年龄
开发语言·javascript·ecmascript
Shan12051 小时前
经典问题——验证栈序列
数据结构·算法
甲维斯1 小时前
测一波Kimi K2.7,消耗一周配额!
前端·人工智能·游戏开发
Dick5071 小时前
ROS2 多机器人通用 Driver 层复盘:BaseRobotDriver 到多平台 Mock 切换实现
前端·javascript·机器人
xiaofeichaichai2 小时前
前端安全 XSS 与 CSRF
前端·安全·xss