数组数据结构底层:从灵活到陷阱
接下来正式让我们从 JavaScript 的基础语法迈向算法数据结构的学习,而起点就是数组。
JS 数组的灵活背后藏着大量细节,那些看起来"理所当然"的写法,运行起来可能是错的。这篇文章记录了我重新理解数组底层机制的过程,覆盖了内存模型、纯函数、遍历性能与引用类型陷阱。
为什么从数组开始
大家听说过的数据结构可能有:数组、链表、栈、队列、树(包括二叉树)等等。其中数组是绝大多数语言内置的、开箱即用的数据结构,也是面试中出现频率最高的基础内容。
关于怎么学,有两个方向:
- 面向 JavaScript:先搞懂 JS 数组和其他语言有什么不同
- 面向面试:LeetCode Hot 100 是必刷的
特别强调了一个误区:不要拿到题就写。不同语言的数组底层实现差别很大,JS 的数组和 Java 的 ArrayList 根本不是一回事。急着做题而不做"语言迁移",很容易把 Java 的思维方式硬套到 JS 上,写出来的是能跑,但不一定是对的 JS 写法。
JavaScript 数组的灵活性从哪来
JS 数组最大的特点是灵活。它不要求每一项类型一致,也不需要提前限制长度。这在其他强类型语言中是不可想象的------因为强类型语言需要通过元素类型来计算每个元素占据的字节数,从而确定偏移量来访问内存地址。
JS 数组的底层访问逻辑依然是:起始地址 + 偏移量 → 对应元素的内存地址。但 JS 作为高级脚本语言,不太关注内存细节,把这些都封装了起来。
数组的创建与基本操作
数组的创建方式有多种:
javascript
const arr1 = ['a','b','c']; // 字面量创建
const arr2 = new Array(); // 空数组
const arr3 = new Array(7); // 长度为7的空数组 [empty x 7]
const arr4 = (new Array(7)).fill(1); // 长度为7,每项初始化为1
这里有一个细节:new Array(7) 创建的是 [empty x 7],这里的 empty 表示数组位置还没有被占据,不属于任何类型。访问 arr[0] 会返回 undefined,但这和 [undefined, undefined, ...] 有本质区别。
关于 ADT(Abstract Data Type,抽象数据类型)的认知:数组的本质是连续的存储空间 + 特定的操作。这些特定操作包括:
javascript
const arr1 = ['a','b','c'];
arr1.push(1); // 尾部添加,返回新长度
arr1.unshift(3); // 头部插入,返回新长度
arr1.pop(); // 移除最后一个元素,返回被移除的元素
arr1.shift(); // 移除第一个元素,返回被移除的元素
arr1.splice(2,0,4);// 在索引2处删除0个元素,插入4
一个重要概念:push、pop、shift、unshift 都会修改原数组,它们不是纯函数。
纯函数是什么
这里引出了一个编程中的重要概念------纯函数:
javascript
// 纯函数
function add(a, b) {
return a + b;
}
let num = 0;
// 非纯函数:依赖外部变量,结果不可控
function add(b) {
// 修改了num的值,所以不是纯函数
num += b;
return num;
}
纯函数的定义是:相同输入必出相同输出,并且不会修改原来的数据,无副作用。这个认知对后续学习 React、Redux 等框架非常重要。
数组的原型链
让我们来聊聊 Array 在 JS 中的本质:
javascript
const arr = new Array();// [] 在 JS 中数组是一种对象,不是数据类型
// 如何知道 Array 是一个对象?
// Array 首字母大写,说明是构造函数,通过 Array.prototype 可以知道 Array 的原型对象是谁
// 如果是 Object ,说明 Array 只是 Object 的一个实例而已
console.log(typeof Array,
Array.prototype,
Array.prototype.__proto__,
// Object.prototype
Array.prototype.__proto__.constructor,
// Object :constructor 获取的是原型对象对应的构造函数
Array.prototype.__proto__.__proto__
// null
// 原型对象,构造函数,对应实例,原型对象的原型对象
);
这段代码的输出揭示了 JS 的原型链结构:Array.prototype → Object.prototype → null。这说明数组在 JS 中本质上是一种特殊的对象,继承自 Object。
遍历方法的权衡
数组的遍历是日常开发最高频的操作之一,数组有多种遍历方式:
javascript
const arr = [6,8,12,15];
// 1. for 计数循环
// 机器化,命令式,可读性不好,但性能最好
// 2. for of
// 语义好,可读性强
// 3. forEach
// 性能不好,因为需要调用函数,进入调用栈,创建执行上下文,开销大
// 优点是可以拿到元素、索引、数组本身
// 缺点:不能中途 break
// 4. map - 返回全新数组,纯函数
arr.map((item,index,self) => {
return item * 2;
});
// 5. filter - 筛选,返回true的元素留下
arr.filter((item) => {
return item % 2 === 0;
});
// 6. every - 每一项都满足才返回true
arr.every((item) => {
return item % 2 !== 0;
});
// 7. some - 有一项满足就返回true
arr.some((item) => {
return item % 2 !== 0;
});
// 8. reduce - 类似于封装了的递归函数
arr.reduce((pre,cur,index) => {
return pre + cur;
},0); // initialValue 初始值
其中:map、filter、every、some 底层都是基于 forEach 二次开发的。forEach 本身不返回值,而这些方法在它基础上加了返回值的能力。
至于性能,结论是------for 循环最快,forEach 家族因为要创建函数调用栈、执行上下文,开销大。但说实话,在大多数业务代码里这点性能差异完全可以忽略。选哪种遍历方式,关键看语义对不对:要转换用 map,要筛选用 filter,单纯遍历用 for of,不要为了性能把代码写得像天书。
二维数组的初始化陷阱
最后讲一个非常隐蔽的坑:二维数组的初始化。
javascript
// 错误写法!
const arr = (new Array(7)).fill([]);
arr[0][0] = 1;
// 此时修改的不是第一个子数组,而是所有子数组都会变!
原因是 fill([]) 中的 [] 是引用类型。fill 方法传入的是入参的引用,而不是复制一份新的数组。所以表面上创建了 7 个数组,实际上这 7 个元素指向的是堆内存中的同一个数组地址。
正确的初始化方式应该是:
javascript
const arr = new Array(7);
const len = arr.length;
for(let i = 0;i<len;i++){
arr[i] = [];
}
或者更简洁的写法:
javascript
const arr = Array.from({length: 7}, () => []);
双重循环的优化
还有一个性能优化点,之前我们提到过 JS 作为解释性语言,本身性能就跟编译性语言有差距,所以能优化的地方就优化一下:双重循环中,应该把内层数组的长度缓存起来,避免重复访问对象属性。
javascript
const outerLen =arr.length;
for(let i = 0;i<outerLen;i++){
for(let j = 0;j<innerLen;j++){
const innerLen = arr[i].length;
console.log(arr[i][j],i,j);
}
}
不过我后面注意到这段示例代码其实有个问题:innerLen 的定义应该放在内层循环之前,否则每次循环都会重新获取。正确的写法应该是:
javascript
const outerLen = arr.length;
for(let i = 0; i < outerLen; i++){
const innerLen = arr[i].length;
for(let j = 0; j < innerLen; j++){
console.log(arr[i][j], i, j);
}
}
我现在怎么理解数组
说实话,数组这部分听完我是有点懵的。偏移量、内存地址、纯函数------这些东西我理解了,不敢说过两天还能不能说明白。
但有一点我现在很确定:数组远不是 [] 和 forEach 那么简单 。纯函数、副作用、遍历性能、引用类型陷阱------这些不是面试八股,是写代码时实实在在会踩的坑。fill([]) 那个例子尤其给我敲了警钟:看起来对的东西,运行起来可能是错的。