常用数据结构

上一篇我们学了对象和类,知道了怎么用对象组织数据、用 class 批量创建对象。这篇是整个 JavaScript 教程中最重要的一篇,我们要学习刷算法题时最常用的数据结构。

算法题的本质就是对数据结构进行操作,如果你不熟悉这些数据结构的基本用法,看算法代码就会一头雾水。好消息是,JavaScript 的数据结构 API 非常直观,你不需要记住所有方法,只需要掌握最常用的那几个操作就够了。用多了自然就记住了,忘了随时可以回来查。

数组 Array

数组是最基础也最常用的数据结构,没有之一。刷算法题时,几乎每道题都会用到数组。

创建与初始化

这里要特别注意 new Array(5).fill(0) 这个写法,刷题时创建固定长度的数组(比如 dp 数组、visited 数组)都是这个套路。

常用方法

数组的增删方法很多,但刷题时最常用的就那几个,来过一遍:

重点记住这几个:push/pop 操作尾部,unshift/shift 操作头部,splice 操作任意位置,slice 截取子数组。其中 pushpop 用得最多,因为它们的时间复杂度是 O(1)。

spliceslice 名字很像,容易搞混。记住:splice修改原数组(切开再拼接),slice 不修改原数组(只是切一片出来)。

遍历方式

数组遍历有好几种写法,各有各的用处:

刷题时用得最多的是经典 for 循环和 for...ofmap/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,原因有两个:

  1. 键可以是任意类型。普通对象的键只能是字符串或 Symbol,而 Map 的键可以是数字、对象、数组等任何类型。
  2. 遍历是按插入顺序的,行为更可预测。

经典用法:频率统计

Map 在算法题中最常见的用法就是统计元素出现的频率:

freq.get(n) || 0 这个技巧很常用:如果 n 不在 map 中,get 返回 undefinedundefined || 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 类型核心操作
数组[] / Arraypush()pop()splice()slice()length
二维数组Array.fromArray.from({length: m}, () => new Array(n).fill(0))
字符串Stringsplit()join()includes()indexOf()slice()
字符编码charCodeAt()String.fromCharCode()
哈希表Mapset()get()has()delete()size
哈希集合Setadd()has()delete()size
[](数组模拟)push() 入栈、pop() 出栈、arr[arr.length-1] 栈顶
队列[](数组模拟)push() 入队、shift() 出队、arr[0] 队头
优先队列PriorityQueuepush()pop()front()isEmpty()size()

不需要一次记住所有方法,用多了自然就熟了。下一篇会讲一些刷算法题时的常用技巧,比如排序、数学运算、ACM 模式的输入输出等。学完就可以正式开始刷题了。