深入理解JavaScript线性数据结构:从内存视角探究数组、链表、栈与队列
- [1. 线性与非线性数据结构的宏观分类](#1. 线性与非线性数据结构的宏观分类)
- [2. 数组与链表的底层内存对比](#2. 数组与链表的底层内存对比)
-
- [2.1 数组(Array)的特性](#2.1 数组(Array)的特性)
- [2.2 链表(Linked List)的特性](#2.2 链表(Linked List)的特性)
- [2.3 内存分配机制与适用场景](#2.3 内存分配机制与适用场景)
- [3. JavaScript 数组的特殊底层真相](#3. JavaScript 数组的特殊底层真相)
- [4. 动态数组的灵活增删及其代价](#4. 动态数组的灵活增删及其代价)
-
- [4.1 `push` 与 `unshift` 的内存差异](#4.1
push与unshift的内存差异) - [4.2 通过 `splice` 自由增删](#4.2 通过
splice自由增删) - 代码片深度剖析
- [4.1 `push` 与 `unshift` 的内存差异](#4.1
- [5. 操作受限的线性结构:栈与队列](#5. 操作受限的线性结构:栈与队列)
- [6. 链表的构建与高效增删](#6. 链表的构建与高效增删)
-
- [6.1 手动构建链表节点](#6.1 手动构建链表节点)
- 代码片深度剖析
- [6.2 链表的增删效率分析](#6.2 链表的增删效率分析)
- [7. 附录:JS 数组排序的注意事项](#7. 附录:JS 数组排序的注意事项)
在计算机科学中,数据结构是组织和存储数据的方式,它直接影响到算法的执行效率与内存的使用方式。本文将基于内存分配与底层实现,由浅入深地探讨线性数据结构,重点对比数组与链表,并详细分析基于它们衍生出的操作受限结构------栈与队列。
1. 线性与非线性数据结构的宏观分类
在研究具体的数据结构之前,首先需要建立宏观的分类认知。根据数据元素之间的逻辑关系,数据结构主要分为两大类:
- 线性数据结构(Linear Data Structures) :数据元素之间存在一对一的线性关系。即除了第一个和最后一个元素外,每个元素有且仅有一个前驱节点(Predecessor),有且仅有一个后继节点(Successor)。典型的线性结构包括数组、链表、栈、队列和列表。
- 非线性数据结构(Non-linear Data Structures) :数据元素之间不再是一对一的关系,而是一对多或多对多的关系。典型的非线性结构包括树(Tree) (如二叉树、红黑树)和图(Graph)。
本文的核心将完全聚焦于线性数据结构的底层机理与实现。
2. 数组与链表的底层内存对比
数组与链表是构筑其他复杂数据结构的基石,理解它们最关键的角度是物理内存的分配方式。
2.1 数组(Array)的特性
数组具有"开箱即用"的特性,在物理内存中表现为连续存储。当声明一个数组时,系统会划出一块连续的内存空间。
- 优势: 由于内存连续,数组支持下标随机访问 。通过基地址和索引乘上元素大小,可以在 O ( 1 ) O(1) O(1) 的时间内精确定位到任何一个元素,遍历效率极高。
- 缺点: 插入和删除操作效率较低。同时,数组面临容量限制与扩容开销。
2.2 链表(Linked List)的特性
与数组相反,链表在物理内存中的分布是不连续、离散的。
- 组成: 链表由一系列节点(Node) 组成,每个节点除了包含存储数据的"变量值(Value)",还必须包含一个或多个指向其他节点地址的"指针(Pointer/Next)"。
- 逻辑结构: 链表由头节点(
head)开始,通过指针逐个向后指向,直到尾节点(tail),尾节点的next指针通常指向null。要访问链表中的任何一个元素,都必须从head开始沿着指针链逐个向后访问,因此其访问操作的时间复杂度为 O ( n ) O(n) O(n)。
2.3 内存分配机制与适用场景
- 数组的扩容开销: 数组在初始化时需要申请固定大小的容量(Capacity)。当数据量超出容量时,必须经历"申请双倍新空间 → \rightarrow → 复制原数组元素 → \rightarrow → 释放旧空间"的扩容过程。这种内存申请的开销在平时不会发生,但一旦发生就会带来较大的单次时延。
- 链表的动态申请: 链表每次插入新节点时,都是独立且按需地向系统申请单节点规模的内存。它的内存开销是平均分配到每一次插入操作中的。
- 选型原则: * 当数据规模较小,或者需要频繁进行索引、遍历操作时,优先选择数组 。
- 当数据规模庞大、无法预估,或者需要频繁在任意位置进行增加、删除操作时,优先选择链表。
3. JavaScript 数组的特殊底层真相
在传统的低级语言(如 C/C++)中,数组的定义是绝对严谨的。然而在 JavaScript 中,JS 数组未必是真正的数组。
V8 引擎在实现 JS 数组时,为了兼顾性能与灵活性,采用了两种不同的底层存储策略,下面结合代码片段进行细致讲解:
javascript
// js 数组内存一定连续吗? 不一定
const arr=[1,2,3,4];//js当数组来打理
//每一个元素的类型不一样
//arr[2] 仍然可以通过下标访问 hashTable
const arr2=['haha',1,{a:1}]//不那么数组了
console.log(arr[1],arr[2]);
代码片深度剖析
- 同质数组(Fast Elements): 在第 2 行中,
const arr = [1, 2, 3, 4]里的所有元素类型完全一致(均为数字)。此时 V8 引擎会将其作为"快数组"来打理,在底层分配一段连续的内存空间,表现出传统数组的高效索引特征。 - 异构数组(Slow Elements): 在第 5 行中,
const arr2 = ['haha', 1, {a:1}]包含了字符串、数字和对象。由于每种类型占用的内存大小不一,无法直接通过简单的索引偏移量来定位。此时,该数组就会"退化",底层不再使用连续内存,而是使用**哈希表(Hash Table)**来分配和管理内存空间。 - 结论: 在 JS 中,即便底层转化为了哈希表分配,高级语法上依然允许你通过
arr2[1]这样的下标方式进行访问。JS 数组实际上是对底层多种存储结构的高度封装。
4. 动态数组的灵活增删及其代价
数组的增加与删除操作都不是纯函数(Pure Function),因为它们会直接修改原数组。下面我们深入到内存视角来看其增删逻辑。
4.1 push 与 unshift 的内存差异
push:在数组的尾部追加元素。如果当前分配的内存空间未满,只需直接将数据写入对应位置,复杂度为 O ( 1 ) O(1) O(1);若满了,则触发上述的扩容机制。unshift:在数组的头部插入元素。从内存视角 来看,这要求原数组中的所有元素必须整体向后移动一个位置 ,从而腾出索引为0的空间。其复杂度在任何时候都是 O ( n ) O(n) O(n)。
4.2 通过 splice 自由增删
splice 是一个功能强大的方法,可以看作是 slice(切片)与 replace(替换)的结合。其 MDN 标准签名为:arr.splice(start_index, del_count, ...added)。
javascript
const arr=[1,2];
console.log(arr.splice(1,0,3));
console.log(arr);
arr.splice(1,1);
console.log(arr);
代码片深度剖析
- 非删除式插入: 第 2 行执行
arr.splice(1, 0, 3)。- 参数含义:从索引
1的位置开始,删除0个元素,并在此处插入数字3。 - 内存操作:引擎会将原索引
1及其之后的元素向后移动一位,腾出空间放入3。 - 返回值:
splice返回被删除的元素组成的数组,此处因为删除数量为0,故console.log打印出空数组[]。 - 第 3 行打印修改后的原数组,结果为
[1, 3, 2]。
- 参数含义:从索引
- 元素删除: 第 4 行执行
arr.splice(1, 1)。- 参数含义:从索引
1开始,删除1个元素。 - 内存操作:删除索引
1处的元素(即刚刚插入的3),随后为了保持数组内存连续性,索引1之后的元素(元素2)必须向前移动填补空缺。 - 第 5 行打印最终的原数组,结果恢复为
[1, 2]。
- 参数含义:从索引
- 复杂度总结: 假设数组长度为 n n n,由增加或删除操作导致的需要移动的元素数量随着 n n n 的增大呈现线性增长关系。因此,数组的任意位置增删操作的时间复杂度为 O ( n ) O(n) O(n)。
5. 操作受限的线性结构:栈与队列
在实际工程中,为了保证数据的可控性与安全访问,我们往往会对数组或链表的操作进行限制。其中最著名的便是栈和队列,它们可以被看作是操作受限的数组。
5.1 栈(Stack)------ 后进先出(LIFO)
栈是一种只能在**栈顶(Top)**进行插入和删除操作的线性表。其核心特性为 LIFO(Last In First Out,后进先出)。
可以将栈形象地比作"冰柜里的雪糕":后放进去的雪糕总是先被顾客拿走。栈的核心操作包括 push(入栈)、pop(出栈)以及 peek(查看栈顶元素,即访问 length - 1 位置的元素)。
javascript
//push pop 栈顶元素peek stack[length-1]
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);lock
var foo = 'bar';
代码片深度剖析
- 入栈阶段(第 3-6 行): 依次执行
push。此时元素在数组中的物理排列为:["东北大板", "可爱多", "冰工厂", "巧乐兹"]。其中"巧乐兹"位于数组尾部,即栈顶。 - 条件循环(第 8 行):
while(stack.length)利用了 JavaScript 的隐式类型转换。只要栈内还有元素,stack.length就不为0(为真值),循环将继续执行。 - 模拟 Peek 操作(第 9 行):
const top = stack[stack.length - 1],在不改变数组结构的前提下,通过下标直接读取当前的栈顶元素。 - 出栈操作(第 11 行): 执行
stack.pop(),将尾部元素从数组中移除。 - 输出结果: 循环依次打印:
取出来的是 巧乐兹取出来的是 冰工厂取出来的是 可爱多取出来的是 东北大板
完美体现了"后进先出"的特征。第 13 行打印结果为[],说明栈已被完全清空。
5.2 队列(Queue)------ 先进先出(FIFO)
队列是一种只允许在队尾(Rear)入队 、在队首(Front)出队 的线性表。其核心特性为 FIFO(First In First Out,先进先出)。类似于排队买票。
javascript
const queue=[];//空的队列
queue.push('m');
queue.push('o');
queue.push('s');
while(queue.length){
// 模拟出队
queue.shift();
}
console.log(queue);
代码片深度剖析
- 入队操作(第 2-4 行): 通过
push将'm','o','s'依次送入队尾。 - 出队操作(第 5-8 行): 在
while循环内部,核心操作是第 7 行的queue.shift()。shift方法会移除并返回数组的第一个元素(队首)。 - 运行机理: 第一次循环移除最早进入的
'm',第二次移除'o',第三次移除's'。第 9 行最终打印的结构同样是空数组[]。这种限定"push入队,shift出队"的组合成功构建了一个标准的 FIFO 队列。
6. 链表的构建与高效增删
当我们需要规避数组因扩容带来的内存抖动,或是高频进行增删操作时,链表是最佳的选择。
6.1 手动构建链表节点
在 JavaScript 中,我们没有野指针的概念,通常使用对象字面量 或构造函数来表达链表的节点模型:
javascript
function ListNode(val){
this.val=val;
this.next=null;
}
const node=new ListNode(1);
node.next=new ListNode(2);
console.log(node);
//console.log(node.next);
代码片深度剖析
-
节点构造器(第 1-4 行): 定义了类(构造函数)
ListNode。每个节点被实例化时,传入的参数被赋予this.val,而用于指向下一个节点的指针属性this.next默认初始化为null。 -
显式指针链接(第 5-6 行):
const node = new ListNode(1)建立了头节点,其结构为{ val: 1, next: null }。node.next = new ListNode(2)改变了头节点的next指向,将其指向了新创建的节点{ val: 2, next: null }。
-
嵌套表现: 第 7 行打印
node,在控制台会看到清晰的嵌套对象结构,这完美印证了链表在内存中靠"指针(引用)"拉扯起来的离散特性:json{ "val": 1, "next": { "val": 2, "next": null } }
6.2 链表的增删效率分析
链表在执行增加和删除操作时,本质上只是对 next 指针的操作。
- 增加元素: 只需要找到插入位置的前驱节点(Predecessor) ,让前驱节点的
next指向新节点,新节点的next指向原先的后继节点。 - 删除元素: 同样需要找到前驱节点,直接让前驱节点的
next指向待删除节点的后继节点即可(从而跳过目标节点,使其等待垃圾回收)。 - 时间复杂度说明: 一旦我们明确了 要插入或删除的目标位置,改变指针指向的操作只需要固定的常数步。因此,其指针调整的复杂度为 O ( 1 ) O(1) O(1)。虽然为了"找到"这个位置依然需要 O ( n ) O(n) O(n) 的时间去遍历,幕后相比于数组必须在内存中大面积移动数据的物理开销,链表的增删效率依然要高得多。
7. 附录:JS 数组排序的注意事项
在对一维线性数据进行操作时,排序(Sort)是极为基础的需求。JavaScript 原生的 Array.prototype.sort() 存在一个著名的底层行为需要注意。
javascript
let arr=[10,2,5];
//一定要传函数 否则按ASCII 排序
arr.sort((a,b)=>a-b);
console.log(arr);
代码片深度剖析
- 默认排序缺陷: 如果在第 3 行直接调用
arr.sort()而不传递任何参数,JavaScript 默认会把所有元素转换为字符串 ,并按照 ASCII 码(Unicode 码点) 进行升序排列。在不传参的情况下,数字10会因为字符'1'的码点小于'2'和'5',从而排在最前面,导致排序结果变为错误的[10, 2, 5]。 - 比较函数(Compare Function): 为了实现纯数字的正确排序,必须显式传入一个回调比较函数
(a, b) => a - b。- 若返回值 < 0 < 0 <0,则
a会被排列到b之前; - 若返回值 > 0 > 0 >0,则
b会被排列到a之前。
- 若返回值 < 0 < 0 <0,则
- 结论: 经过第 3 行正确的函数传参后,第 4 行最终输出预期的数值升序数组:
[2, 5, 10]。
本期分享到此结束,我们下期再见👋