「能按号取物,就别全场翻箱倒柜。」——本篇精神版民谚
本篇大纲
读前先看这一篇讲哪几块,方便你对照目录翻。
1. 数组的由来与定义(是啥)
- 历史上:早期语言里,数组多用来表示一段连续编号的同类型格子,方便按整数下标直接定位——这是机器里「批量数据」最朴素、最好用的模型之一。
- 抽象定义:线性表的一种,元素排成一队,每个位置有一个从 0(或 1)起的索引;在经典教材模型里,按下标读写是 O(1),因为不必从第一个元素挨个数。
- 和「链表」的差别(后文会细讲链表):数组擅长随机访问;中间插入删除往往要搬动后面整段,成本更高。
- 本篇比喻:一排带编号的储物格——对应下文「形象化」一节,把时间账掰开说。
2. JavaScript 里的数组与 API(怎么用)
JS 的 Array 语法上是数组,引擎底层可能是连续存储,也可能对稀疏、超大数组做优化——写业务时按「语义 + 大概复杂度」记即可,别当 C 指针课来背。
本篇正文接下来讲的,主要是数组最底层、最通用的那类操作:按下标读写、头尾增减、中间插删要搬后面整段——这远不是数组的全部。真要展开讲 concat、some、slice、join、sort、pop、push……每个都配上场景、返回值和坑,单开一场聊个把钟头都不夸张。
本节暂时不对数组 API 做「手册式集中串讲」:脱离场景硬背方法名,又痛苦又容易忘,还很挫伤继续往下读的热情。罗列 API 没有意义——我们换一条路:正文里先建立代价直觉,再单独给一节 「常见数组操作方法(速查)」 当案头备忘(增删裁改、拼接、查找、排序、转字符串),和「死记硬背」是两回事。
数组更多的方法、惯用法和小技巧,仍会打散到后面的章节里:做真题解读的时候、讲别的数据结构(队列、哈希、字符串……)的时候,都还会反复和数组打交道。到那时,每个方法都会和它对应的应用场景一起出现,比干啃文档好记得多,相信你印象也会更深。
下面这张表只当「骨架」,帮你建立操作形态 → 复杂度直觉的挂钩——不是本篇正文的全部,更不是要你现在就背熟所有 API:
| 操作形态 | 典型写法举例 | 心里记一笔 |
|---|---|---|
| 按下标读写 | arr[i]、length | 一般当 O(1) |
| 只动尾部 | push、pop | 通常 均摊 O(1) |
| 动头部或中间 | shift、unshift、splice | 常要挪后面全体,O(n) 量级 |
| 出新数组 / 遍历 | slice、concat、map、filter 等 | 至少与长度相关 O(n),注意别在回调里再套一层全表扫描 |
更细的 API 对照,后文随题随场景再补;for / forEach / map / reduce 等遍历集中在下文速查 §8。下面「形象化」一节,就是把这张表的直觉说人话。
3. 数组上常考的算法套路(怎么想)
- 双指针:两端向中间(如回文)、快慢指针(如原地删元素)——本篇写两个完整小例子。
- 滑动窗口、前缀和:常在子数组 / 区间问题上出现——这里只点名,后面章节会展开。
- 排序 + 二分:数组有序后查找、去重思路会换一档——与第 2 篇的复杂度直觉衔接。
- 小练习里的合并两个有序数组:典型双指针线性扫,建议自己动手写一遍。
4. 在前端里数组一般干什么(用在哪)
- 列表数据:接口返回的 JSON 数组、表格行、
select的选项——本质都是「有序下标 + 一批同类项」。 - 虚拟列表 / 窗口渲染:数据全长在数组里,界面只
slice可见下标区间——本篇重点讲这一块,对应「别让渲染跟n全长绑死」。 - 不可变更新:React/Vue 里常见「新数组引用」触发更新——和「复制/切片哪些下标」有关,算法上要想清楚拷贝范围,避免无谓的大数组克隆。
- Key 与顺序:用数组渲染列表时,下标当 key 还是 id 当 key,涉及重排代价与状态错乱——不展开框架 API,只强调:顺序结构变了,要想数据怎么对齐。
形象化:储物格怎么用才划算
- 按索引读/写:
arr[i],直达,记 O(1)。 - 尾部加减:
push/pop,通常很香,均摊 O(1) 量级(偶尔扩容另说)。 - 头部或中间插入、删除:后面所有元素要往后挪或往前填,大致 O(n)——像排队插队,后面的人都要动一步。
- 整段复制、拼接:长度有关,别指望白嫖。
人话:头尾好办事,中间动一刀,全队要挪窝。
JavaScript:对外就当「带 length、一堆方法的对象数组」用;稀疏数组(洞)、超大数组可能触发引擎特殊路径——和算法课里的「理想数组」略有温差,心里有数即可。
常见数组操作方法(速查)
前面说过:脱离场景硬背 API 很痛苦,所以本篇不以「词典」为主轴。但手头有一张按用途分好的清单,写业务、刷题时扫一眼「改不改原数组、返回值是啥」,会省很多冤枉路——就当牛哥喊你热身之后,把常用招摊在桌面上认一遍。
下面按 增、删、裁改、拼接、查找、排序、转字符串 收一收(示例均可直接贴进控制台跑)。
1. 增加元素
| 方法 | 作用 | 返回值 | 原数组 |
|---|---|---|---|
unshift | 在头部插入元素 | 新 length | 会改 |
push | 在尾部插入元素 | 新 length | 会改 |
let arr = [1];
arr.unshift(2); // arr -> [2, 1],返回 2
arr = [1];
arr.push(2); // arr -> [1, 2],返回 2
2. 删除元素
| 方法 | 作用 | 返回值 | 原数组 |
|---|---|---|---|
shift | 删掉第一个元素 | 被删的那个元素(空数组则 undefined) | 会改 |
pop | 删掉最后一个元素 | 被删的那个元素 | 会改 |
let arr = [1, 2, 3];
arr.shift(); // 返回 1,arr -> [2, 3]
arr = [1, 2, 3];
arr.pop(); // 返回 3,arr -> [1, 2]
3. 截取、插入、替换:splice(万金油)
作用:从任意下标起,删除若干项,并可同时插入新元素——删、插、换一套 API 搞定。
返回值:由被删除元素组成的新数组(没删则 [])。
原数组:会改。
let arr = [1, 2, 3, 4];
arr.splice(0, 1); // 从下标 0 删 1 个 → 返回 [1],arr 为 [2, 3, 4]
arr = [1, 2, 3, 4];
arr.splice(1, 2, 9); // 从下标 1 删 2 个(2、3),插入 9 → 返回 [2, 3],arr 为 [1, 9, 4]
arr = [1, 2, 3, 4];
arr.splice(3, 0, 10); // 从下标 3 删 0 个,插入 10 → 返回 [],arr 为 [1, 2, 3, 10, 4]
4. 拼接:小心 push 和 concat 不是一回事
push 一个数组:会把整个数组当成一个元素塞进去(常见踩坑)。
let arr1 = [1, 2];
let arr2 = [3, 4];
arr1.push(arr2);
console.log(arr1); // [1, 2, [3, 4]]
concat:把另一数组里的元素挨个展开接到后面;返回新数组,不改原来的 arr1。
let arr1 = [1, 2];
let arr2 = [3, 4];
let arr3 = arr1.concat(arr2);
console.log(arr3); // [1, 2, 3, 4]
(等价写法还有展开运算:[...arr1, ...arr2],同样是新数组。)
5. 查找下标
| 方法 | 作用 | 返回值 | 原数组 |
|---|---|---|---|
indexOf | 某值第一次出现的下标 | 下标或 -1 | 不改 |
lastIndexOf | 某值最后一次出现的下标 | 下标或 -1 | 不改 |
let arr = [1, 2, 3, 1, 5];
arr.indexOf(1); // 0
arr = [1, 2, 3, 2, 1, 2];
arr.lastIndexOf(1); // 4
只问「有没有」常用
includes;要找对象或条件常用find/findIndex——都是扫一遍的思路,复杂度直觉同第 2 篇。
6. 排序与逆序
| 方法 | 作用 | 返回值 | 原数组 |
|---|---|---|---|
sort | 排序 | 排序后的同一数组引用(就地) | 会改 |
reverse | 前后颠倒 | 逆序后的同一数组引用 | 会改 |
let arr = [5, 7, 3, 9, 1, 6];
arr.sort((a, b) => a - b); // 数字升序;不要依赖默认无参 sort 的数字顺序
console.log(arr); // [1, 3, 5, 6, 7, 9]
arr = [5, 7, 3, 9, 1, 6];
arr.reverse();
console.log(arr); // [6, 1, 9, 3, 7, 5]
重要:sort() 不传比较函数时,按默认规则排(常按字符串比),[10, 2, 1].sort() 很容易不是数学大小序——数字数组务必写 (a, b) => a - b。
7. 转成字符串
| 方法 | 作用 | 返回值 | 原数组 |
|---|---|---|---|
toString | 元素用默认分隔(一般是逗号)拼成字符串 | 字符串 | 不改 |
join | 用指定分隔符拼接 | 字符串 | 不改 |
let arr = [1, 2];
arr.toString(); // "1,2"
arr = [1, 2, 3];
arr.join('.'); // "1.2.3"
arr.join('$'); // "1$2$3"
8. 遍历:for、forEach、map、reduce……
这一类方法的共同点:至少要把元素过一遍,时间上是 O(n) 量级(some / every / find 可能提前结束,最好情况更快,但别默认成 O(1))。差别主要在:有没有返回值、能不能中途 break、会不会生成新数组。
| 方式 | 返回值 | 能中途跳出? | 一句话 |
|---|---|---|---|
for(下标) | 无 | 能 break/continue | 最朴素,要下标、要控制流程时首选 |
for...of | 无 | 能 | 只要元素;要下标用 [i, v] of arr.entries() |
forEach | undefined | 不能优雅 break(除非抛错或 return 只跳过当前回调) | 适合「每个元素做点副作用」;链式不如 map |
map | 新数组(与原数组等长) | 跑满全程 | 一一映射;不改原数组 |
filter | 新数组(子集) | 跑满全程 | 保留满足条件的项 |
reduce / reduceRight | 一个累加结果 | 跑满全程(有初始值时空数组也安全) | 求和、拼对象、分组都常用;初始值该传要传 |
some / every | boolean | 短路:有一个真/假就停 | 「有没有」「是不是全真」 |
find / findIndex | 元素或下标 / 找不到 undefined、-1 | 短路 | 找第一个满足条件的 |
const arr = [1, 2, 3];
// for:可控流程
for (let i = 0; i < arr.length; i++) {
if (arr[i] === 2) break;
}
// forEach:无返回值,专注副作用
arr.forEach((v, i) => console.log(i, v));
// map:得到新数组
const doubled = arr.map((v) => v * 2); // [2, 4, 6]
// filter:子集
const evens = arr.filter((v) => v % 2 === 0); // [2]
// reduce:收成单值(或对象、Map 等)
const sum = arr.reduce((acc, v) => acc + v, 0); // 6
// some / every
[1, 2, 3].some((v) => v > 2); // true
[1, 2, 3].every((v) => v > 0); // true
// find
[{ id: 1 }, { id: 2 }].find((x) => x.id === 2); // { id: 2 }
不建议用 for...in 遍历数组:会碰到可枚举属性、下标顺序等坑,对象遍历再用它。
和复杂度篇对齐:map / filter / forEach 的回调里再嵌一层同一数组的 find / includes,很容易变 O(n²)——写之前心里数一圈。
收个口:这一节把改数组和遍历数组两块工具都摊开了,和上文「先搞懂哪里贵」合在一起用效果最好——后面真题、别的结构里再见到这些方法,都会带着具体场景复习一遍。
小算法 1:双指针——回文(数组版)
回文:正着读反着读一样。用两个指针从两端往中间挤,不等就 false。
function isPalindromeArr(arr) {
let lo = 0;
let hi = arr.length - 1;
while (lo < hi) {
if (arr[lo] !== arr[hi]) return false;
lo++;
hi--;
}
return true;
}
console.log(isPalindromeArr(['a', 'b', 'b', 'a'])); // true
console.log(isPalindromeArr(['a', 'b', 'c'])); // false
时间 O(n),额外空间 O(1)(只两个下标)。若你先 reverse 再比,多开一份,空间就变成 O(n)——同题不同写法,账不一样。
小算法 2:原地移除指定值(双指针)
给定数组,删掉所有等于 val 的元素,原地压缩,返回新长度(经典题型变体)。思路:慢指针指「有效区末尾」,快指针扫全程。
function removeVal(nums, val) {
let k = 0;
for (let i = 0; i < nums.length; i++) {
if (nums[i] !== val) {
nums[k] = nums[i];
k++;
}
}
return k; // 前 k 个为保留部分
}
const a = [3, 2, 2, 3];
const len = removeVal(a, 3);
console.log(len, a.slice(0, len)); // 2, [2, 2]
时间 O(n),O(1) 额外空间(在原数组上写)。
虚拟列表:只取「窗口」那一截
页面里有一万条数据,真渲染一万个 DOM 往往顶不住。思路是:数据仍在数组里,屏幕上只取当前可见下标区间那一小段——典型就是 slice(start, end) 或算出 start/end 再 map。
下面用纯数据模拟「总长很大、只取出视口内几条」,不依赖任何框架:
const TOTAL = 10_000; // 假装有一万条
const data = Array.from({ length: TOTAL }, (_, i) => ({ id: i, title: '项 ' + i }));
const ROW_HEIGHT = 32; // 假设每行高度 px
const VIEW_HEIGHT = 320; // 视口高度
const BUFFER = 2; // 上下多取两行,减少闪动
function getVisibleRows(scrollTop) {
const first = Math.floor(scrollTop / ROW_HEIGHT);
const count = Math.ceil(VIEW_HEIGHT / ROW_HEIGHT) + BUFFER * 2;
const start = Math.max(0, first - BUFFER);
const end = Math.min(TOTAL, start + count);
return {
startIndex: start,
slice: data.slice(start, end), // 只操作这一小段
};
}
const { startIndex, slice } = getVisibleRows(640);
console.log('从第', startIndex, '条开始,只取出', slice.length, '条去画界面');
要点:连续下标 + 区间切片——数组最适合表达「第 i 到第 j 项」这种窗口;滚动本质是改 startIndex,再 slice 一次。算法思维在这里就是:别让渲染成本跟 TOTAL 绑死,只跟「窗口长度」走。
小练习
- 为什么
arr.unshift(x)在大数组上往往比push(x)更肉疼?(想「要不要挪后面所有人」。) - 手写循环合并两个已排序数字数组(不用
concat+sort),体会双指针从两头扫。
面试一句
「数组核心是随机访问 O(1);头尾动便宜,中间插删要搬 O(n);虚拟列表用下标窗口 + slice,渲染跟视口走不跟总量走。」
下篇预告
第 4 篇|栈:最后放上去的最先拿——后进先出(LIFO)、括号匹配与路径简化;继续 纯 JS。