数组数据结构底层:从灵活到陷阱

数组数据结构底层:从灵活到陷阱

接下来正式让我们从 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.prototypeObject.prototypenull。这说明数组在 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([]) 那个例子尤其给我敲了警钟:看起来对的东西,运行起来可能是错的。

相关推荐
十九画生1 小时前
Ajax 入门:用 XHR 理解前后端异步请求
前端·javascript·后端
yingyima2 小时前
Python re 模块速查:从实战对比中掌握正则表达式
前端
hairenwangmiao2 小时前
B4041 [GESP202409 四级] 区间排序
算法·排序
人道领域2 小时前
【LeetCode刷题日记】47.全排列Ⅱ
java·开发语言·算法·leetcode
漂流瓶jz2 小时前
UVA-1606 两亲性分子 题解答案代码 算法竞赛入门经典第二版
数据结构·算法·向量·aoapc·算法竞赛入门经典·atan2·浮点
Navigator_Z2 小时前
LeetCode //C - 1095. Find in Mountain Array
c语言·算法·leetcode
放下华子我只抽RuiKe52 小时前
FastAPI 全栈后端(三):数据库与 ORM
前端·数据库·react.js·oracle·性能优化·前端框架·fastapi
源图客3 小时前
境外电商 - 龙虾智能体-综合选品推荐报告
开发语言·javascript·ecmascript
不会就选b3 小时前
算法日常・每日刷题--<二分查找>1
算法