第 3 篇|数组:一排带编号的储物格(第二章开篇)

3 阅读11分钟

「能按号取物,就别全场翻箱倒柜。」——本篇精神版民谚


本篇大纲

读前先看这一篇讲哪几块,方便你对照目录翻。

1. 数组的由来与定义(是啥)

  • 历史上:早期语言里,数组多用来表示一段连续编号的同类型格子,方便按整数下标直接定位——这是机器里「批量数据」最朴素、最好用的模型之一。
  • 抽象定义线性表的一种,元素排成一队,每个位置有一个从 0(或 1)起的索引;在经典教材模型里,按下标读写O(1),因为不必从第一个元素挨个数。
  • 和「链表」的差别(后文会细讲链表):数组擅长随机访问;中间插入删除往往要搬动后面整段,成本更高。
  • 本篇比喻:一排带编号的储物格——对应下文「形象化」一节,把时间账掰开说。

2. JavaScript 里的数组与 API(怎么用)

JS 的 Array 语法上是数组,引擎底层可能是连续存储,也可能对稀疏、超大数组做优化——写业务时按「语义 + 大概复杂度」记即可,别当 C 指针课来背。

本篇正文接下来讲的,主要是数组最底层、最通用的那类操作:按下标读写头尾增减中间插删要搬后面整段——这远不是数组的全部。真要展开讲 concatsomeslicejoinsortpoppush……每个都配上场景、返回值和坑,单开一场聊个把钟头都不夸张

本节暂时不对数组 API 做「手册式集中串讲」脱离场景硬背方法名,又痛苦又容易忘,还很挫伤继续往下读的热情。罗列 API 没有意义——我们换一条路:正文里先建立代价直觉,再单独给一节 「常见数组操作方法(速查)」案头备忘(增删裁改、拼接、查找、排序、转字符串),和「死记硬背」是两回事。

数组更多的方法、惯用法和小技巧,仍会打散到后面的章节里:做真题解读的时候、讲别的数据结构(队列、哈希、字符串……)的时候,都还会反复和数组打交道。到那时,每个方法都会和它对应的应用场景一起出现,比干啃文档好记得多,相信你印象也会更深。

下面这张表只当「骨架」,帮你建立操作形态 → 复杂度直觉的挂钩——不是本篇正文的全部,更不是要你现在就背熟所有 API:

操作形态典型写法举例心里记一笔
按下标读写arr[i]length一般当 O(1)
只动尾部pushpop通常 均摊 O(1)
动头部或中间shiftunshiftsplice常要挪后面全体,O(n) 量级
出新数组 / 遍历sliceconcatmapfilter至少与长度相关 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. 拼接:小心 pushconcat 不是一回事

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. 遍历:forforEachmapreduce……

这一类方法的共同点:至少要把元素过一遍,时间上是 O(n) 量级(some / every / find 可能提前结束,最好情况更快,但别默认成 O(1))。差别主要在:有没有返回值能不能中途 break会不会生成新数组

方式返回值能中途跳出?一句话
for(下标) break/continue最朴素,要下标、要控制流程时首选
for...of只要元素;要下标用 [i, v] of arr.entries()
forEachundefined不能优雅 break(除非抛错或 return 只跳过当前回调)适合「每个元素做点副作用」;链式不如 map
map新数组(与原数组等长跑满全程一一映射;不改原数组
filter新数组(子集)跑满全程保留满足条件的项
reduce / reduceRight一个累加结果跑满全程(有初始值时空数组也安全)求和、拼对象、分组都常用;初始值该传要传
some / everyboolean短路:有一个真/假就停「有没有」「是不是全真」
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/endmap

下面用纯数据模拟「总长很大、只取出视口内几条」,不依赖任何框架:

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 绑死,只跟「窗口长度」走。


小练习

  1. 为什么 arr.unshift(x) 在大数组上往往比 push(x) 更肉疼?(想「要不要挪后面所有人」。)
  2. 手写循环合并两个已排序数字数组(不用 concat+sort),体会双指针从两头扫

面试一句

「数组核心是随机访问 O(1);头尾动便宜,中间插删要搬 O(n);虚拟列表用下标窗口 + slice,渲染跟视口走不跟总量走。」


下篇预告

第 4 篇|栈:最后放上去的最先拿——后进先出(LIFO)、括号匹配与路径简化;继续 纯 JS