1487 字
7 分钟
数组:从底层到实战的 TypeScript 指南

概述#

数组是最常用的数据结构:它把一组元素按顺序放在连续的逻辑位置上,并通过下标进行访问。无论是前端列表渲染、表格数据处理、还是算法题里的区间扫描,数组几乎都是第一层抽象。

理解数组的关键,不只是会调用 pushmapfilter,而是知道:

  • 下标访问为什么快;
  • 插入、删除为什么可能慢;
  • 遍历时如何维护状态;
  • 什么时候应该原地修改,什么时候应该返回新数组。

前置知识

  • 下标访问:数组通过位置定位元素
  • 动态数组:理解扩容、移动和摊还复杂度
  • 遍历模型:读写指针是数组原地处理的基础

问题定义#

数组适合表示“顺序稳定、可通过位置定位”的数据集合。

需求数组是否适合原因
通过下标读取第 k 个元素适合随机访问直接定位
频繁在末尾追加适合动态数组通常摊还 O(1)
频繁在头部插入/删除不适合后续元素需要整体移动
保持有序并二分查找适合下标可直接访问中点
表示链式关系不一定链表或 Map 可能更自然

在 JavaScript / TypeScript 中,数组是动态数组:长度可以变化,元素也可以是任意类型。但在算法分析里,我们仍然按“连续下标 + 顺序存储”的模型理解它。


核心原理:分步图解#

假设数组为:

index: 0 1 2 3
value: 10 20 30 40

访问元素#

读取 arr[2] 时,只需要通过下标定位到第 2 个位置:

index: 0 1 2 3
value: 10 20 [30] 40

所以随机访问是 O(1)。

中间插入#

如果要在下标 1 插入 15,原来的 20、30、40 都要向后移动:

插入前: 10 20 30 40
移动后: 10 _ 20 30 40
插入后: 10 15 20 30 40

移动元素的成本与尾部长度有关,因此中间插入最坏是 O(n)。

双指针原地修改#

数组还常用于“读写指针”模型:一个指针扫描原数组,另一个指针维护写入位置。

read -> 扫描每个元素
write -> 指向下一个有效元素位置

这类技巧能把很多“过滤、去重、压缩”问题从额外 O(n) 空间优化为 O(1) 额外空间。


基本操作与复杂度#

操作示例时间复杂度说明
随机访问arr[i]O(1)通过下标定位
修改元素arr[i] = xO(1)不改变数组结构
末尾追加arr.push(x)摊还 O(1)扩容时会有额外成本
末尾删除arr.pop()O(1)不移动前面的元素
头部插入arr.unshift(x)O(n)需要移动所有元素
中间删除arr.splice(i, 1)O(n)需要移动后续元素
遍历for ...O(n)每个元素访问一次
排序arr.sort()通常 O(n log n)取决于运行时实现

工程中最容易忽略的是 shiftunshift。它们看起来简单,但在大数组上可能比 push / pop 慢很多。


TypeScript 实现#

下面用一个“有序数组去重”的例子,把数组的访问、遍历、原地写入串起来。

1. 原地去重#

function removeDuplicates(nums: number[]): number {
if (nums.length === 0) return 0;
let write = 1;
for (let read = 1; read < nums.length; read++) {
if (nums[read] !== nums[read - 1]) {
nums[write] = nums[read];
write++;
}
}
return write;
}
const nums = [1, 1, 2, 2, 3, 4, 4];
const len = removeDuplicates(nums);
console.log(nums.slice(0, len)); // [1, 2, 3, 4]

这里的关键不变量是:

  • [0, write) 区间始终保存已经确认的唯一元素;
  • read 负责向右扫描候选元素;
  • 当发现新值时,把它写到 write 位置。

2. 合并两个有序数组#

function mergeSorted(left: number[], right: number[]): number[] {
const result: number[] = [];
let i = 0;
let j = 0;
while (i < left.length && j < right.length) {
if (left[i] <= right[j]) {
result.push(left[i]);
i++;
} else {
result.push(right[j]);
j++;
}
}
while (i < left.length) result.push(left[i++]);
while (j < right.length) result.push(right[j++]);
return result;
}

这个实现展示了数组的另一个常见模型:两个下标分别在两个序列上移动,结果数组按顺序追加。


工程优化:原地还是新数组#

数组操作通常有两种风格:

风格示例优点代价
原地修改sortsplice、双指针写入节省空间容易影响调用方持有的引用
返回新数组mapfilterslice更安全、更适合不可变状态需要额外空间

在前端状态管理中,通常倾向返回新数组,因为 React 等框架依赖引用变化判断是否更新:

const nextTodos = todos.filter(todo => !todo.completed);

在算法或性能敏感场景中,原地修改更常见,因为它能减少额外内存:

nums.sort((a, b) => a - b);

选择标准不是“哪个更高级”,而是当前场景更看重可读性、不可变性,还是空间效率。


应用与局限#

典型应用#

  • 列表渲染、分页、排序和过滤;
  • 双指针、滑动窗口、前缀和等数组算法;
  • 矩阵、堆、并查集等结构的底层存储;
  • 批量请求结果、日志记录、表格数据处理。

局限性#

  • 中间插入和删除成本高;
  • 稀疏数组会让行为变得不直观;
  • sort 默认按字符串排序,数字排序必须传比较函数;
  • 大数组频繁复制会带来明显的内存压力。

总结#

graph LR
    A[连续下标] --> B[随机访问]
    B --> C[顺序遍历]
    C --> D[原地修改]
    C --> E[返回新数组]
  • 数组的核心优势是下标访问和顺序遍历。
  • 插入、删除的成本主要来自元素移动。
  • 双指针是数组原地处理问题的基础模型。
  • 工程中要区分原地修改和返回新数组两种风格。
  • 掌握数组,才能更自然地理解二分、滑动窗口、前缀和等算法。
数组:从底层到实战的 TypeScript 指南
https://www.hehonglei.cn/technology/array/
作者
Honglei He
发布于
2026-04-26
许可协议
CC BY-NC-SA 4.0