速成课 · No. 23
data structure 不过是一种组织数据的方式,让你最常做的事情变快。选对了,一个操作瞬间完成;选错了,同样的任务慢如蜗牛。Big-O 就是描述这种差别的语言——当数据增长时,一个操作的代价如何增长。
16 个概念 · 每个都有图 · 末尾有速查表
在讲具体的结构之前,先说说它们背后共同的想法。data structure 并不学院派——它往往是决定你的代码是快是慢的那个唯一选择。
地图:六组里的 16 个概念
一张地铁图:先看到所有线路,再一站一站地乘过去。
最上面是三个基本想法:Data Structure、Trade-off 和 Big-O。下面是六组:Big-O 的增长形状、array、hash map 与 set、有顺序的结构(stack 和 queue)、有形状的结构(tree 和 graph),最后是实践中如何在它们之间做选择。
如何阅读本课程
一本护照:每个人的栏目都一样,所以很容易比较。
每个概念都用同样的方式讲:它的英文名称(你在文档和面试中会遇到的术语)、一幅来自日常生活的画面、一幅图和一张卡片:它是什么、怎么运作、一个例子、代价,以及它与其他结构的关系。本课程只有一条规则:没有最好的结构——只有最适合你最常做的那个操作的结构。
Data Structure — 为你最常做的操作组织数据
一个带标签格子的整洁工具箱,对比一袋乱堆的工具——同样的工具,但一个让你瞬间抓到要找的那件,另一个意味着每次都要翻找。
data structure 是一种在内存中安排数据的方式。不同的安排让不同的操作变快:有的擅长按名字查找,有的擅长保持顺序,有的擅长总能抓到最新的那一项。整个关键在于让结构匹配你最常做的事。
同一个任务,根据数据如何存放,可能轻而易举,也可能几乎不可能。在一本排好序的 dictionary 里找一个词只要几秒;在同样的词却被随机乱堆的情况下找它,可能要找一整天。所以「把这个弄快」当中有惊人的一部分,其实是「用对的形状把它存起来」。结构就是那个决定。
- 是什么
- 为特定操作而在内存中安排数据的一种方式。
- 如何运作
- 这种安排事先就决定了哪些操作便宜、哪些操作昂贵。
- 例子
按 ID 查找一个用户:在一种结构里是瞬间的事,在另一种里却要把所有东西都翻一遍。
- 代价
- 选错了,日后靠「巧妙的优化」也补救不了。
- 关联
- 结构用 Big-O 来比较,并通过 Trade-off 来选择。
Trade-off — 每种结构都要放弃一些东西
跑车和搬家货车都是车——你不会问哪个更好,你会问对什么更好。
没有哪种结构在所有事上都最好。一种给你瞬间查找,却没有顺序;另一种保持顺序,却查找很慢;还有一种添加很快,却在内部查找很慢。这就是 trade-off:一个操作上的强,要用另一个操作上的弱来换。天下没有免费的午餐——你的工作是选出适合你用途的那个取舍。
- 是什么
- 一种结构在某个操作上的强,总是要用另一个操作上的弱来换。
- 例子
Hash Map 按 key 瞬间找到,但不保持顺序;Array 保持顺序,但按值查找很慢。
- 何时思考
- 每次你决定数据放在哪里的时候:我常做什么,又很少做什么?
- 关联
- 这种取舍用 Big-O 来衡量——每个操作都有自己的「形状」。
data structure 组织数据,让你的常见操作变快。合适的形状把难任务变简单——而每种结构都用某个操作上的速度,去换另一个操作上的弱。
要比较结构,你需要一种谈论速度、又不依赖具体机器的方式。Big-O 就是这种语言:它衡量当数据增长时工作量如何增长。
Big-O notation — 看增长,而非秒数
问的不是一次配送要多久,而是当路线从十站变到一万站时总时间如何变化——是增长的形状,而非秒表上的读数。
Big-O 记号描述一个操作的代价如何随数据量(记作 n)的增长而增长。它忽略确切的毫秒数和具体的电脑,捕捉的是那个形状:把数据翻倍,工作量是翻倍、变四倍,还是几乎不变?正是这个形状决定了某样东西在规模变大时是否还快,所以 Big-O 是工程师不必真正运行就能比较各种方案的方式。
- 是什么
- 对工作量如何随数据规模 n 增长的一种描述。
- 如何运作
- 舍去确切的秒数和硬件细节,只保留增长的形状。
- 例子
- 「在 array 中查找是 O(n)」:数据多十倍——工作量大约也多十倍。
- 注意
- 它不告诉你多少秒;它告诉你如何随规模变化。
- 关联
- 常见的形状就是 Complexity classes。
Complexity classes — 从极好到糟糕的形状
一件家务随规模变化的四种方式:一种无论堆多大都是一步,一种随堆增长,一种随堆的平方爆炸式增长,还有一种即使堆变大也几乎不增长。
少数几种形状就覆盖了大多数代码:
- O(1) — 常数:无论规模多大,时间都一样(按 index 抓取某一项)。
- O(log n) — 对数:即使数据暴涨也几乎不增长(每一步把搜索范围减半)。
- O(n) — 线性:与数据同步增长(扫描一个 list)。
- O(n²) — 平方:爆炸式增长(把每一项和其他每一项比较)。经典的「测试时没事,上了生产就死」。
只有十项时,任何结构都感觉是瞬间的——差别显不出来。Big-O 关心的是 n 变大时会发生什么。这就是为什么东西在生产环境中会「突然」变慢:糟糕的形状一直都在,只是在数据增长之前被藏住了。
- 是什么
- 对典型增长形状的命名。
- 例子
array 的 index——O(1);平衡树查找——O(log n);扫描一个 list——O(n);循环里套循环——O(n²)。
- 注意
- O(n²) 在测试数据上跑得很好,面对一百万条记录却要卡上好几分钟。
- 规则
- O(1) 和 O(log n) 扛得住规模;O(n) 老实可靠;O(n²) 是警报。
- 关联
- Map before loop 是把 O(n²) 变成 O(n) 的主要办法。
Big-O 衡量工作量如何随数据增长,而不是原始的秒数。O(1) 和 O(log n) 扛得住规模;O(n) 老实可靠;O(n²) 测试时没事,上了生产就死。
最基本的结构是 array,也叫 list:一个有序的元素序列。确切知道它擅长什么、不擅长什么,是理解其他一切结构的基线。
Array — 有序、带编号的序列
一排带编号的储物柜:东西按顺序放着,每个都有一个位置——0 号柜、1 号柜、2 号柜——所以你可以凭编号直接走到任何一个。
array(或 list)按顺序存放元素,每个元素在一个带编号的位置上,这个编号叫 index,从 0 开始。因为元素处在已知的、连续的布局里,按 index 跳到任何位置都是瞬间的——O(1)。array 是「一堆按顺序排好的东西」的默认选择,按位置直接访问就是它的超能力。
它有代价。在末尾添加或删除很便宜。但在中间插入或删除,会把后面的所有元素都挪一格,以保持顺序和编号不乱——O(n)。
- 是什么
- 按顺序排列的元素,每个都有一个从零开始的 index。
- 快
- 按 index 访问——O(1);在末尾追加——便宜。
- 慢
- 按值查找——O(n);在中间插入或删除——O(n)。
- 例子
- 一个按顺序阅读、并在末尾追加的帖子 feed。
- 何时使用
- 你按位置读取,而且主要是追加。
- 关联
它的弱点——按值查找——由 Hash Map 来弥补;Stack 和 Queue 常常建立在它之上。
Linear search — 按值查找
你能瞬间打开 500 号柜——但如果你只知道里面装了什么、不知道在哪个柜子,就只能一个个打开,直到找到为止。
问题在于:array 只有在你知道 index 时才快。要按内容找一项——「这个 email 在列表里吗?」——你得逐个检查。这就是 linear search,O(n):列表一大就慢。这个差距,正是下一种结构要解决的问题。
- 是什么
- 一个个检查元素,直到找到你要的那个。
- 代价
- O(n):数据多十倍——耗时多十倍。
- 例子
- 在一百万个地址的列表上
emails.includes(x)。 - 注意
- 放进另一个循环里就变成 O(n²)——经典的「上了生产就慢」。
- 关联
- 被 Hash Map 或 Set 中 O(1) 的查找所取代。
array 是有序、带 index 的序列:按位置访问是瞬间的,但按值查找或在中间插入是 O(n)。
如果有一种结构配得上做你默认的主力,那就是 hash map。它解决了 array 最大的弱点——按值找东西——而且几乎是瞬间完成。
Hash Map — 键值对
一本电话簿:你按名字找一个人,立刻拿到他的号码。你不用扫遍每一条记录——名字直接带你到答案。
hash map(也叫 dictionary、hash table,或者直接叫 map)把数据存成键值对:你给它一个 key,它给你对应的 value。按 ID 找用户、按名字找设置、按单词找计数——直接用 key。array 要求你知道位置,而 hash map 让你用一个有意义的名字去找。
- 是什么
- 按 key 查找的 key → value 键值对。
- 快
- 按 key 查找、插入和删除——实际上是 O(1)。
- 慢
- 没有顺序;按「值」而不是按 key 去找,又变回了扫描。
- 例子
users[id]、settings["theme"]、单词计数counts[word] += 1。- 何时使用
- 你经常要按某个标识符查找东西。
- 关联
- 它的速度来自 Hash function;去掉 value 就成了 Set。
Hash function — 为什么查找是瞬间的
一个神奇的文件柜:给它一个标签,它直接打开正确的那个抽屉,不用查看其他的——抽屉再多也不会让它变慢。
hash map 的神奇之处在于,按 key 查找实际上是 O(1)——常数时间,无论里面装了多少元素。key 会经过一个 hash function,由它算出 value 存在哪里,所以 map 直接跳过去,而不是去搜索。一百万条还是十条,找到一个都一样快。这就是 hash map 作为主力的原因:「按名字查找」正是程序一直在做的事。
- 是什么
- 一个从 key 算出其 value 在表中位置的函数。
- 如何运作
- 同一个 key 总是得到同一个槽位——所以查找从不需要扫描条目。
- 例子
hash("eve")→ 3 号槽 → Eve 的号码就在那里。- 关联
- Hash Map 和 Set 的核心。
Set — 在或不在,没有 value
门口的宾客名单——你不需要任何细节,只要一个是或否:这个名字在名单上吗?瞬间核对,无需扫描。
近亲是 set:一个只存 key、不存 value 的 hash map,用来快速回答一个问题——「这个东西在吗?」。判断是否存在和避免重复,都是 O(1)。每当你发现自己在扫描一个 list 来问「这个我见过吗?」,set 就能把那个缓慢的 O(n) 检查变成瞬间完成。
- 是什么
- 一组不含 value 的唯一 key。
- 快
- 「在这里吗?」和添加——O(1);重复项会被自动丢弃。
- 例子
seen = set()—— 跳过已发过的 email;去掉列表中的重复项。- 何时使用
- 需要唯一性和快速的成员检查。
- 关联
- 没有 value 的 Hash Map。
hash map 按 key 找 value,实际上是 O(1)——按标识符查找的主力。它的近亲 set 同样瞬间回答「这个在吗?」。
有时,你取东西的顺序和你存什么一样重要。两种结构把两种自然的顺序形式化了——它们之间的差别只是一条直观的规则。
Stack — 后进先出(LIFO)
一摞盘子:你从顶上放,也从顶上拿,所以最后放下的那个盘子,是第一个被拿起的。
stack 遵循 LIFO — last in, first out(后进先出)。你在同一端添加(push)和移除(pop),所以最新的元素最先出来。这与一切嵌套或按相反顺序回退的东西相吻合:撤销历史、后退按钮、程序的 call stack。两种操作都是 O(1)。
- 是什么
- 只在顶端添加和移除。
- 快
push和pop——O(1)。- 例子
- 编辑器里的撤销、浏览器的后退按钮、call stack。
- 何时使用
- 你需要「最新的优先」或「撤销上一步」。
- 关联
- Queue 的镜像:同样的数据,从另一端取出。
Queue — 先进先出(FIFO)
售票窗口前的一队人:按到达的顺序被服务,新来的排到队尾,队首下一个被服务。
queue 遵循 FIFO — first in, first out(先进先出)。你在队尾添加(enqueue),从队首移除(dequeue),所以元素按到达顺序被处理。这是讲求公平和顺序的结构:待处理的任务、等待中的作业、要按顺序处理的请求——正是 queues 课程里消息队列的思想。和 stack 一样,它的操作也是 O(1)。
- 是什么
- 在队尾添加,从队首取出。
- 快
enqueue和dequeue——O(1)。- 例子
- 交给后台 worker 的作业、按顺序处理的请求。
- 何时使用
- 到达的顺序就应该是处理的顺序。
- 关联
- Stack 的镜像;规模大了会长成消息队列。
规则决定行为
同一堆活儿,两扇门:一扇交还最新的,另一扇交还最旧的。仅仅选哪一端这一个选择,就改变了后面的一切。
stack 和 queue 存放的是同一类数据;唯一的差别是从哪一端移除。在两者之间选择,就是选择你的工作以什么顺序回来:相反而嵌套(stack),还是公平而有序(queue)。一个小决定,影响却很大——把顺序弄对,其余的逻辑自然就位。
stack 是后进先出——撤销、嵌套、反转。queue 是先进先出——公平、按序的处理。同样的数据;从哪一端移除就是全部的差别。
最后这两种结构处理的数据不是一条平的线,而是有形状的——分叉成层级,或连成网络。它们是你为关系建模的方式。
Tree — 层级
一棵家谱或一张公司组织架构图:顶上一个,向下分出子节点,每个子节点还能继续分叉——结构向外展开,从不绕回来。
tree 把数据组织成层级:顶上是一个根(root),分出带有子节点的节点,如此向下。它能为一切嵌套的东西建模——文件夹套文件夹、评论和它们的回复、一份分类菜单。分叉的形状反映了现实世界的嵌套,所以只要你的数据是「东西里面套东西」,tree 就是天然的选择。你在软件里遇到的大多数层级,底下都是 tree。
- 是什么
- 一种层级:每个节点都可以有子节点,但没有任何东西绕回来。
- 例子
- 文件夹和文件、一串评论、商店的分类树。
- 何时使用
- 你的数据是「东西里面套东西」。
- 注意
- 把层级硬塞进平面的 list,你就得再手动把它拼回去。
- 关联
- 有序而均匀的 tree 是 Balanced tree;tree 是 Graph 的一种特例。
Balanced tree — O(log n) 的查找
在电话簿里找一个名字:先翻开中间,再翻右半边的中间,一次又一次——每一步都扔掉剩下的一半。
一棵组织良好的平衡(balanced)tree 带来一个强大的性质:你可以通过反复把空间减半,在 O(log n) 内查找它,就像你在 dictionary 里找一个词而不用读每一页。这种对数形状即使数据暴增也几乎不增长——十亿项也只需要大约三十步。数据库就是这样在巨大规模下保持查找迅速的:把有序数据放成 tree 的形式,用折半的方式去查。
- 是什么
- 各侧深度相同的有序 tree。
- 如何运作
- 在每个节点比较,然后进入其中一半——另一半再也不会被看。
- 快
- 查找——O(log n):十亿项 ≈ 30 步。
- 例子
- 数据库索引:以 tree 形式存放的有序 key。
- 关联
- 一种特殊的 Tree;与 Hash Map 不同,它保持顺序。
Graph — 连接的网络
一张由道路连起城市、或由友谊连起人的地图——事物以任意的模式彼此相连,没有顶,也没有底。
graph 是最一般的形状:节点由边(edge)以任意模式相连,包括环。它为网络建模——社交关系、城市之间的道路、页面之间的链接、任务之间的依赖。只要数据本质上是「事物与其他事物相关」,而不是层级或一条线,它就是一个 graph。许多困难而有趣的问题——最短路线、谁和谁相连——本质上都是 graph 问题。
- 是什么
- 节点以及它们之间的边——没有层级,允许有环。
- 例子
- 社交网络里的朋友、城市之间的道路、页面之间的链接、任务依赖。
- 典型问题
- 最短路线;谁和谁相连。
- 何时使用
- 数据是一个网络,而不是一条线,也不是一个层级。
- 关联
- Tree 是只有一个根、没有环的 graph。
tree 为层级建模,平衡时能以折半的方式在 O(log n) 内查找。graph 为连接的网络建模。当数据有形状——嵌套的或相连的——就用它们。
你很少需要从零构建这些——你的语言已经提供了。真正的功夫在于为这份工作选对结构,并且懂得足够的 Big-O,能在坏选择咬你之前就感觉到它。
Dominant operation — 按你最常做的事来选
你按每晚要做的菜来备厨房,而不是按看起来唬人的菜。
实际的决定几乎总是:我最常做什么操作,哪种结构能让那一个变快?一直在按 key 查找?Hash Map。需要东西按到达顺序排列?Queue。主要是按位置读取?Array。让主导操作(dominant operation)来选结构,并接受它在你很少做的事情上会更慢。优化常见情况;结构就是办法。
- 思路
- 由最频繁的操作来选结构,而不是靠习惯。
- 例子
- 每秒成千上万次按 ID 查用户 → Hash Map,即使顺序丢了。
- 代价
- 罕见的操作会变慢——这是一次有意的取舍。
- 关联
- 在实践中运用的 Trade-off。
Map before loop — 先想 map,再写循环
在一张张脸地从人群里找人之前,你会先看有没有宾客名单——名单瞬间回答的,正是搜索慢慢做的事。
一个极其常见、极其有效的做法:当你发现自己在扫描一个 list 来查找或匹配东西——一个 O(n) 的循环,更糟的是循环里再套一个 O(n²) 的循环——就问问自己,hash map 或 set 能不能让它瞬间完成。把嵌套的查找换成 map 查询,是现实中最常见的提速之一。解决办法通常是结构,而不是一个更快的循环。
- 问题
- 对每一项我们都要扫描另一个 list——O(n²),上了生产就慢。
- 思路
- 先建一个 Hash Map 或 Set,然后以 O(1) 在里面查找。
- 例子
不要在遍历订单的循环里写
users.find(u => u.id === order.userId)——先建好byId,再用byId[order.userId]。- 代价
- map 要占一点内存。
- 关联
- 把循环里的 Linear search 变成 O(1)。
- 哪个操作占主导——查找、排序,还是添加/删除?
- 按 key 查找? Hash Map(若只看是否存在,则用 Set)是 O(1)。
- 按位置访问、以追加为主? Array 合适。
- 移除的顺序很重要——最新的优先(Stack),还是最旧的优先(Queue)?
- 嵌套或网络状的数据——Tree 还是 Graph?
- 在大规模下,Big-O 是多少——有没有什么东西暗地里是 O(n²)?
- 你按最常做的操作来选结构,而不是靠习惯。
- 你会用 Hash Map 或 Set,而不是扫描 list 来查找或去重。
- 你能说出热点路径的 Big-O,而且没有隐藏的 O(n²)。
- 移除顺序重要时你用 Stack 或 Queue,数据有形状时你用 Tree 或 Graph。
- 数据增长时一切依然很快,因为你在规模暴露问题之前就选好了形状。
一切如何连在一起
一棵工具的家谱:每一个都补上前一个的弱点。
Big-O 是衡量每种结构的那把尺。Hash Map 弥补 Array 的弱点(按值查找);Set 是没有 value 的 Hash Map。Stack 和 Queue 是附带「只从一端取」这条规则的序列。Tree 加上了层级,平衡的 tree 能在 O(log n) 内查找;Graph 去掉了最后一道限制,允许任意连接。
速查表:全部 16 个概念
基础
- Data Structure数据如何摆放
一种组织数据的方式,让你的常见操作变快。
- Trade-off没有免费的午餐
一个操作上的强,要用另一个操作上的弱来换。
Big-O
- Big-O notation渐近复杂度
工作量如何随数据规模 n 增长。
- Complexity classesO(1), O(log n), O(n), O(n²)
常数、对数、线性、平方。O(n²) 是警报。
Array
- Arraylist
有序、带 index:按位置 O(1),在中间插入 O(n)。
- Linear searchscan
通过逐个检查来按值查找。O(n)。
Hash map
- Hash Map字典, map
Key → value,按 key 查找实际上是 O(1)。没有顺序。
- Hash functionhashing
由 key 算出 value 的位置——所以不用扫描。
- Setmembership
只有 key:「在这里吗?」O(1),没有重复。
顺序
- StackLIFO
后进先出。撤销、后退按钮、call stack。
- QueueFIFO
先进先出。按到达顺序处理。
形状
- Tree层级
根、节点、子节点,没有环。
- Balanced treebalanced search tree
折半查找——O(log n)。数据库索引的工作原理。
- Graph网络
节点和边以任意模式相连。网络、路线、依赖。
如何选择
- Dominant operation常见情况
最频繁的操作决定结构。
- Map before loop用查找取代扫描
在做嵌套扫描之前——先建一个 Hash Map 或 Set。
这些结构你不用从零构建——你只需要选择。让结构匹配你最常做的操作,先想 map 再写循环,让 Big-O 赶在规模之前向你示警。