上一篇我们学了对象和类,知道了怎么用对象组织数据、用 class 批量创建对象。这篇是整个 JavaScript 教程中最重要的一篇,我们要学习刷算法题时最常用的数据结构。
算法题的本质就是对数据结构进行操作,如果你不熟悉这些数据结构的基本用法,看算法代码就会一头雾水。好消息是,JavaScript 的数据结构 API 非常直观,你不需要记住所有方法,只需要掌握最常用的那几个操作就够了。用多了自然就记住了,忘了随时可以回来查。
数组 Array
数组是最基础也最常用的数据结构,没有之一。刷算法题时,几乎每道题都会用到数组。
创建与初始化
这里要特别注意 new Array(5).fill(0) 这个写法,刷题时创建固定长度的数组(比如 dp 数组、visited 数组)都是这个套路。
常用方法
数组的增删方法很多,但刷题时最常用的就那几个,来过一遍:
重点记住这几个:push/pop 操作尾部,unshift/shift 操作头部,splice 操作任意位置,slice 截取子数组。其中 push 和 pop 用得最多,因为它们的时间复杂度是 O(1)。
splice 和 slice 名字很像,容易搞混。记住:splice 会修改原数组(切开再拼接),slice 不修改原数组(只是切一片出来)。
遍历方式
数组遍历有好几种写法,各有各的用处:
刷题时用得最多的是经典 for 循环和 for...of。map/filter/reduce 在处理数据时很好用,但在性能敏感的算法题中,普通 for 循环效率最高。
建议:需要索引时用 for,不需要索引时用 for...of,其他看心情。
二维数组
二维数组在矩阵、网格、动态规划等场景中非常常用。JavaScript 创建二维数组有个坑,需要特别注意:
这个坑一定要避开:new Array(3).fill(new Array(4).fill(0)) 看起来没问题,但 fill 填充的是同一个数组的引用,三行指向同一个数组对象,改一行三行一起变。用 Array.from 配合箭头函数,每次调用都会创建一个新的数组,就没这个问题了。
字符串 String
基本操作
字符串在算法题中也很常用。JavaScript 的字符串有一个非常重要的特点:不可变(immutable)。一旦创建就不能修改其中的某个字符。
字符串不可变这一点很重要。在需要频繁修改字符的场景(比如翻转字符串),通常的做法是先 split('') 转成数组,操作完再 join('') 转回字符串。
常用方法
刷题中最常用的组合是 split + join。因为字符串不可变,很多时候需要先 split 成数组,处理完再 join 回来。比如翻转字符串就是 s.split('').reverse().join('')。
字符与 ASCII
处理字符和 ASCII 码的转换在算法题中经常遇到,比如统计字母频率、判断字符类型等:
charCodeAt(0) 中的 0 表示取字符串第 0 个字符的编码。因为 JavaScript 中单个字符也是字符串类型,所以要指定索引。
字母频率统计是刷题的经典操作:用一个长度为 26 的数组,charCodeAt(0) - 97 把 'a'~'z' 映射到索引 0~25。上一篇提过模板字符串,这里就不赘述了。
哈希表 Map
这里只讲怎么用,关于哈希表的底层原理,后面的 哈希表核心原理 会详细讲解。
为什么用 Map
JavaScript 有两种东西可以当哈希表用:普通对象 {} 和 Map。刷算法题时推荐用 Map,原因有两个:
- 键可以是任意类型。普通对象的键只能是字符串或 Symbol,而
Map的键可以是数字、对象、数组等任何类型。 - 遍历是按插入顺序的,行为更可预测。
经典用法:频率统计
Map 在算法题中最常见的用法就是统计元素出现的频率:
freq.get(n) || 0 这个技巧很常用:如果 n 不在 map 中,get 返回 undefined,undefined || 0 的结果是 0,于是就从 0 开始计数。这是一个非常地道的 JavaScript 写法。
哈希集合 Set
Set 是一种不允许重复元素的集合。它的操作和 Map 很像,只不过只有"键"没有"值"。
[...new Set(arr)] 这一行做了三件事:1) 用数组创建 Set(自动去重);2) 用扩展运算符 ... 把 Set 展开;3) 用方括号包成新数组。这是 JavaScript 去重的经典写法。
栈(数组模拟)
栈是后进先出(LIFO)的数据结构,JavaScript 没有内置的栈,但直接用数组就能完美模拟。关于栈和队列的底层原理,后面的 队列/栈基本原理 会详细讲解,这里先学会怎么用就行。
栈的操作总结:
- 入栈:
stack.push(x) - 栈顶:
stack[stack.length - 1] - 出栈:
stack.pop() - 判空:
stack.length === 0
这四个操作在括号匹配、单调栈、表达式求值等算法题中用得非常多。来看一个经典的栈应用:
队列(数组模拟)
队列是先进先出(FIFO)的数据结构,在 BFS(广度优先搜索)中必用。
队列的操作总结:
- 入队:
queue.push(x) - 队头:
queue[0] - 出队:
queue.shift() - 判空:
queue.length === 0
关于 shift 的性能问题:shift() 每次出队都要把后面的元素全部往前挪一位,时间复杂度是 O(n)。对于一般的算法题来说够用了,但如果数据量很大(比如几十万级别的 BFS),shift 可能会比较慢。这时候可以用下标来模拟队列:
用 head 指针模拟出队,入队还是用 push,这样入队和"出队"都是 O(1)。代价是前面已经出队的元素不会被回收,会多占一些内存。但对于算法题来说,这点内存完全不是问题。
优先队列
优先队列(堆)是一种特殊的队列,每次出队的不是最先入队的元素,而是优先级最高(或最低)的元素,在 Dijkstra 最短路、任务调度、Top-K 等算法题中非常常用。关于堆的底层原理,后面的 二叉堆核心原理 会详细讲解。
JavaScript 没有内置优先队列,但在 LeetCode 和本站的判题环境中,@datastructures-js/priority-queue 这个库是全局可用的,不需要 require,直接用就行。
记住这两个比较函数:
- 小顶堆(最小值先出):
(a, b) => a - b - 大顶堆(最大值先出):
(a, b) => b - a
这和 sort 的比较函数逻辑一样,很好记。
在很多算法题中,堆里存的不是简单的数字,而是包含多个信息的数组。比如 Dijkstra 算法中存 [距离, 节点编号]:
小结
把这篇的数据结构和核心操作汇总一下:
| 数据结构 | JS 类型 | 核心操作 |
|---|---|---|
| 数组 | [] / Array | push()、pop()、splice()、slice()、length |
| 二维数组 | Array.from | Array.from({length: m}, () => new Array(n).fill(0)) |
| 字符串 | String | split()、join()、includes()、indexOf()、slice() |
| 字符编码 | — | charCodeAt()、String.fromCharCode() |
| 哈希表 | Map | set()、get()、has()、delete()、size |
| 哈希集合 | Set | add()、has()、delete()、size |
| 栈 | [](数组模拟) | push() 入栈、pop() 出栈、arr[arr.length-1] 栈顶 |
| 队列 | [](数组模拟) | push() 入队、shift() 出队、arr[0] 队头 |
| 优先队列 | PriorityQueue | push()、pop()、front()、isEmpty()、size() |
不需要一次记住所有方法,用多了自然就熟了。下一篇会讲一些刷算法题时的常用技巧,比如排序、数学运算、ACM 模式的输入输出等。学完就可以正式开始刷题了。