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

20 阅读10分钟

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
┌─────────┬─────────┬─────────┬─────────┐
│    1234    │
└─────────┴─────────┴─────────┴─────────┘
  arr[0]   arr[1]   arr[2]   arr[3]

访问 arr[2]:
  地址 = 起始地址 + 2 × 8字节 = 0x1010
  → O(1) 直接定位!

类型混杂的数组(哈希表):

┌─────────┐
│  哈希表  │
│  0 → 'haha'  │
│  11       │
│  2 → {name}  │
└─────────┘

访问 arr[2]:
  哈希查找 key = "2"
  → O(1) 哈希查找

💡 V8 引擎的优化策略

  • 当数组元素类型一致时,使用快速模式(连续内存)
  • 当数组元素类型混杂时,退化为字典模式(哈希表)

1.2 数组的增删复杂度

数组扩容机制:

初始状态:
┌─────┬─────┬─────┬─────┐
│  1234  │
└─────┴─────┴─────┴─────┘
容量:4    长度:4

push(5) 时:
  容量已满!→ 申请新内存(通常扩容 1.5~2 倍)
  → 复制原有元素 → 插入新元素

新内存:
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│  12345  │     │     │     │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘
容量: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  │  BA  │
└─────┴─────┴─────┴─────┘

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]:
┌─────┬─────┬─────┬─────┬─────┐
│  12345  │
└─────┴─────┴─────┴─────┴─────┘
           ↑
         删除 3

需要移动后续元素:
┌─────┬─────┬─────┬─────┐
│  1245  │
└─────┴─────┴─────┴─────┘

链表增删(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 < 0a 排在 b 前面(升序)
如果 a - b > 0a 排在 b 后面
如果 a - b = 0:位置不变

示例:比较 210
2 - 10 = -8 < 02 排在 10 前面

📊 核心知识速查表

线性数据结构对比

数据结构特性核心操作时间复杂度应用场景
数组连续内存,索引访问访问、遍历访问 O(1),增删 O(n)频繁访问、小数据
LIFOpush、popO(1)函数调用、撤销
队列FIFOpush、shiftO(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 数据结构的底层原理,欢迎点赞 + 收藏 + 关注!有任何疑问欢迎在评论区交流~ 🎉