数据结构规定数据怎么放,算法规定在这份数据上怎么算。两者一起决定一段程序能处理多大规模、要花多少时间和内存。
衡量标准是大 O,看的是数据量 n 变大之后的增长趋势,常数和系数先忽略。
| 记法 | 数据量翻倍时 | 典型操作 |
|---|---|---|
| O(1) | 耗时几乎不变 | 数组按下标取值、哈希表查找 |
| O(log n) | 只多一步左右 | 二分查找、平衡树查找 |
| O(n) | 大约翻倍 | 遍历链表、线性扫描 |
| O(n log n) | 略超翻倍 | 归并排序、堆排序 |
| O(n²) | 大约变成 4 倍 | 冒泡排序、两层循环比较 |
线性结构
数组把元素放在连续内存里。知道下标就能直接算出地址,读取是 O(1)。在中间插入或删除要搬动后面的元素,是 O(n)。适合按下标访问、长度变化不多的场景。
链表每个节点只保存值和下一节点的地址,节点在内存里不必相邻。已知位置时,插入和删除只改指针,是 O(1)。查找第 k 个元素必须从头走,是 O(n)。适合频繁插删、很少随机访问的场景。
栈只在同一端放入和取出,后进先出。函数调用、括号匹配、撤销操作都是栈:调用一层就压入一层,返回时弹出。
队列在一端放入、另一端取出,先进先出。任务排队、广度优先搜索用它。双端队列两端都能进出,滑动窗口的最值常用它。
哈希表
哈希表用一个函数把键变成数组下标,平均情况下查找、插入、删除都是 O(1)。两个不同的键算出同一个下标叫做冲突,常用链地址法把冲突的键串成链表,或用开放寻址法找下一个空位。
哈希表不保留键的顺序。需要「有没有」「对应的值是什么」时用它;需要按顺序遍历时用树或排序数组。
树
树从根节点向下分叉,每个节点可以有多个子节点。描述层级、保持有序、做优先选择,大多用树。
二叉树每个节点最多两个孩子。二叉搜索树约定左子树的值都更小,右子树的值都更大。查找时每次丢掉一半,平衡时是 O(log n)。如果插入顺序已经有序,树会退化成一条链,退回 O(n)。红黑树、AVL 树在插入和删除后调整形态,把高度维持在 O(log n)。
堆是一棵完全二叉树,存在数组里:下标 i 的左孩子是 2i+1,右孩子是 2i+2。大顶堆的每个父节点都不小于孩子。取最大值是 O(1),放入或删除后向上、向下调整是 O(log n)。优先队列和定时任务用堆。堆排序先建成堆,再反复取出堆顶,总时间 O(n log n)。
前缀树按字符逐层往下走,一条从根到节点的路径就是一个字符串前缀。查某个词是否出现、做前缀补全时,时间和词的长度成正比,与词典里有多少词关系不大。
图
图由顶点和边组成。边可以有方向,也可以带权重。社交关系、路线、依赖关系都是图。
两种常见存法:
- 邻接矩阵用二维数组,
matrix[u][v]表示 u 到 v 有没有边。判断两点是否相连是 O(1),空间是 O(n²),适合顶点少、边密的图。 - 邻接表每个顶点挂一条自己的边。空间和顶点数加边数成正比,适合边稀疏的图。遍历某个点的邻居更快。
广度优先搜索用队列,先访问离起点近的点,适合无权图的最短路径。深度优先搜索用栈或递归,沿一条路走到头再回头,适合检测环、拓扑里的连通块。
Dijkstra 在边权非负时,用堆每次取出当前最近的点,求单源最短路径。拓扑排序只适用于有向无环图,每次取出入度为 0 的点,用来排任务依赖。
常用算法
遍历是所有算法的底子:线性结构从头走到尾;树用前序、中序、后序;图用广度和深度优先。
二分查找要求序列已经有序。每次取中点,目标更小就去左半,更大就去右半,O(log n)。写的时候中点用 lo + (hi - lo) / 2,避免两个大下标相加溢出。
排序里,冒泡、选择、插入是 O(n²),数据量大时不用。归并排序把数组分成两半分别排好再合并,稳定,O(n log n),需要额外 O(n) 空间。快速排序选一个基准,小的放左边、大的放右边,平均 O(n log n),基准每次都选到最值时会退化成 O(n²)。堆排序是 O(n log n),原地,但不稳定。
递归把问题写成「同样的问题,规模更小」。必须有一个不再往下拆的终止条件。调用深度就是栈的深度,太深会栈溢出。尾递归和「用栈模拟递归」可以把深度换到自己控制的内存里。
动态规划处理「大问题的答案能由更小的同类问题拼出来」,并且子问题会重复计算。先写出状态表示什么,再写出转移公式,最后确定计算顺序。爬楼梯、背包、编辑距离都属于这一类。能画成有向无环图的重叠子问题,一般就能写成动态规划。
贪心每一步都取当前看起来最好的选择,而且可以证明后面不需要反悔。活动安排、霍夫曼编码适用。证明不了「局部最优能推出全局最优」时,就改用动态规划或搜索。
怎么选
先看操作,再选结构:
- 只按下标读、批量扫描:数组
- 头尾频繁插入删除:链表,或语言里基于数组扩容的双端队列
- 后进先出、先进先出:栈、队列
- 按键查找:哈希表
- 既要查找又要有序:平衡二叉搜索树
- 反复取当前最大或最小:堆
- 点和点之间有连接:图,边少用邻接表,边多用邻接矩阵
同一个问题往往有多层做法。例如在无序数组里找一个值是 O(n);先排序再二分是 O(n log n) 的准备加上 O(log n) 的查找;如果之后还要反复查,把数据放进哈希表,准备一次,之后每次 O(1)。算法的差别主要在这笔准备成本和之后每次查询的成本怎么换