掌握这些基础,你的代码能力会上一个台阶
作为前端开发者,我们每天都在和数组打交道。push、pop、shift、unshift、splice 这些方法用得行云流水。但你是否想过,这些操作背后隐藏着怎样的数据结构原理?
今天,我们就来聊聊线性数据结构中的"三剑客"------栈、队列和链表。理解了它们,你写出的代码会更加优雅高效。
一、栈(Stack):后进先出的"冰柜"
栈就像冰柜里的雪糕------后放进去的先拿出来 。这是一种典型的 LIFO(Last In First Out) 数据结构。
栈的特性
栈可以看成是操作受限的数组 ,一个特殊的数组
- 只能在栈顶(数组尾部)进行操作
- 入栈:
push - 出栈:
pop - 查看栈顶元素:
stack[stack.length - 1](即peek操作)
代码实现
javascript
arduino
const stack = []; // 空栈
// 入栈
stack.push("东北大板");
stack.push("可爱多");
stack.push("冰工厂");
stack.push("巧乐兹");
// 出栈
while(stack.length) {
const top = stack[stack.length - 1];
console.log('取出来的是:', top);
stack.pop();
}
console.log(stack); // []
栈的应用场景
- 函数调用栈
- 浏览器的后退功能
- 括号匹配校验
- 撤销操作(Ctrl+Z)
二、队列(Queue):先进先出的"排队"
队列就像在奶茶店排队------先来的先服务 。这是典型的 FIFO(First In First Out) 数据结构。
队列的特性
队列也是一种操作受限的数组
- 只能在队尾入队
- 只能在队首出队
- 入队:
push - 出队:
shift
代码实现
javascript
arduino
const queue = []; // 空队列
// 入队
queue.push("许");
queue.push("叶");
queue.push("戴");
// 出队
while(queue.length) {
const top = queue[0];
console.log('取出来的是:', top);
queue.shift();
}
console.log(queue); // []
队列的应用场景
- 任务队列(宏任务、微任务)
- 消息队列
- 广度优先搜索(BFS)
三、数组的增删改查:不得不说的复杂度
splice 方法详解
splice 是数组增删改的"瑞士军刀",它的本质是 slice + replace:
javascript
scss
// 语法:splice(start_index, del_count, ...addedItems)
const arr = [1, 2];
console.log(arr.splice(1, 0, 3)); // [] (删除0个,返回空数组)
console.log(arr); // [1, 3, 2]
arr.splice(1, 1); // 删除索引1处的元素
console.log(arr); // [1, 2]
⚠️ 注意 :数组的增删改方法都不是纯函数,它们会直接修改原数组!
线性关系:O(n) 的代价
假设数组的长度是 n,当我们在数组中间进行增加或删除操作时:
- 需要移动的元素数量随着
n的增加而增加 - 呈一个线性关系
- 时间复杂度为 O(n)
这就是数组的"痛点"------插入和删除的效率会随着数据量增长而线性下降。
四、链表(LinkedList):灵活连接的"链条"
为了解决数组增删效率低的问题,链表应运而生。链表通过指针连接各个节点,实现了高效的增删操作。
链表节点的定义
javascript
ini
function ListNode(val) {
this.val = val;
this.next = null;
}
const node = new ListNode(1);
node.next = new ListNode(2);
console.log(node); // {val: 1, next: {val: 2, next: null}}
链表的添加与删除
添加元素 :前驱节点的 next 指向新节点,新节点的 next 指向后继节点。
删除元素 :前驱节点的 next 直接指向后继节点。
本质都是对
next指针的操作!
链表的复杂度分析
| 操作 | 时间复杂度 | 说明 |
|---|---|---|
| 访问 | O(n) ❌ | 需要从头遍历,麻烦 |
| 插入(已知位置) | O(1) ✅ | 只需改变指针指向 |
| 删除(已知位置) | O(1) ✅ | 只需改变指针指向 |
| 查找目标位置 | O(n) | 这是插入/删除的前置成本 |
💡 关键理解 :链表虽然有高效的增删操作(O(1)),但前提是你已经明确了要插入或删除的目标位置 。找到这个位置本身需要 O(n) 的遍历时间。也就是说,链表的总操作是 O(n)(查找)+ O(1)(操作)= O(n) 。
数组 vs 链表 完整对比
| 对比维度 | 数组 | 链表 |
|---|---|---|
| 访问元素 | O(1) ✅ | O(n) ❌ |
| 头部插入 | O(n) ❌ | O(1) ✅ |
| 头部删除 | O(n) ❌ | O(1) ✅ |
| 中间插入 | O(n) | O(n)(查找+指针) |
| 内存结构 | 连续(或哈希) | 离散 |
| 内存申请 | 扩容时一次性申请 | 每次新增都申请 |
什么时候用数组?
- 数据量较小
- 频繁的遍历和索引访问
- 对内存连续性有要求
什么时候用链表?
- 数据量较大
- 频繁的头部的插入和删除操作
- 对内存连续性不敏感
五、JS 数组的"特殊身份"(重要!)
需要注意的是,JS 数组未必是真正的数组!这一点经常被前端开发者忽略。
javascript
ini
// 情况一:每一项类型一致 → 连续内存(真正的数组)
const arr1 = [1, 2, 3, 4, 5];
// 情况二:每一项类型不一致 → 离散内存
const arr2 = ['haha', 1, {a: 1}];
原理解析
- 当数组元素类型一致 时,JS 会分配连续内存,此时它具备传统数组的特征
- 当数组元素类型不一致 时,JS 底层使用哈希分配内存空间,数据存储在离散的位置,通过哈希表映射访问
⚠️ 在后一种情况下,JS 数组不再具有数组的连续内存特征,但仍然可以通过下标访问 ------ 这就是哈希表的功劳!
这也是为什么 JS 数组可以这么"灵活"的原因 ------ 它底层做了很多透明处理。
六、排序小贴士
使用 sort 方法时,一定要传比较函数,否则会按 ASCII 码排序:
javascript
css
let arr = [10, 2, 5];
arr.sort((a, b) => a - b); // [2, 5, 10]
// 不传比较函数的后果:
let arr2 = [10, 2, 5];
arr2.sort(); // [10, 2, 5] ❌ 按字符串排序了!
总结:一张图看懂三种数据结构
text
perl
┌─────────────────────────────────────────────────────────┐
│ 线性数据结构 │
├─────────────┬─────────────┬─────────────────────────────┤
│ 栈 │ 队列 │ 链表 │
├─────────────┼─────────────┼─────────────────────────────┤
│ LIFO │ FIFO │ 离散存储 + 指针连接 │
│ push/pop │ push/shift│ 增删 O(1)* 访问 O(n) │
│ 栈顶操作 │ 两端操作 │ 需要手动实现节点 │
└─────────────┴─────────────┴─────────────────────────────┘
核心要点回顾
- 栈和队列:都是操作受限的数组,通过限制操作方式保证了数据的有序性
- 数组增删:O(n) 的线性关系,数据量越大代价越高
- splice:增删改一体,但会修改原数组(非纯函数)
- 链表:离散存储,增删高效(O(1)),但访问低效(O(n))
- JS 数组:类型一致才是真数组,类型不一致时底层是哈希表
一句话总结
数组用空间换时间(连续内存换快速访问),链表用时间换空间(遍历访问换灵活增删)------ 根据场景选择合适的数据结构,才是聪明的开发者。