前置知识
数据结构
- 数据之间的逻辑关系
- 集合结构
- 线性结构
- 树形结构
- 图形结构
- 存储:元素+关系
- 内容分类:数据,关系,附加(非必要但出于优化的、单数据结构内唯一的内容, 例如长度、根节点…
- 数据储存方式:顺序,链接
- 关系算法:哈希,索引,树
- 接口
- 构造,销毁
- 增查删改
- 访问,遍历
数据结构主要讨论关系的存储
算法复杂度
- 渐进表示法
- $O$ : 渐进上界
- $\Omega$ : 渐进下界
- $\theta$ : 渐进确界
- $o$ : 渐近非紧上界
线性结构
线性表
定义:除了首尾元素,每个元素都有其前驱与后继。每个元素对应唯一非负整数 index.
顺序表
struct Vec<T> {
T* data;
size_t len, cap;
};
- 倍增空间:
realloc
,至多浪费 $50%$ 空间,换取均摊 $O(0)$ 的内存分配(内存分配很耗时)和 $O(1)$ 的复制。 - 增/删:后继节点全部需要移动, $O(n)$
- 查:index 访问 $O(1)$,值访问 $O(n)$
- 遍历:迭代
index: 0..len
- 优势:局部性好,index 访问快。
- 劣势:插入、删除、值访问很慢,均摊浪费 $33%$ 空间。
- 适用情景:静态,可 index 访问。
链表
struct Node<T> {
T value;
Node<T>* next; // 若空则 this 为尾节点
Node<T>* prev; // (双链表专用)指向前面的节点,若为空则是头节点
};
struct LinkedList<T> {
Node<T>* head; // 可空或伪节点
Node<T>* tail; // (双链表专用)可空或伪节点
size_t len; // 可选,长度
};
- 增删:a->b->c 变成 a->c 并 delete b, $O(1)$
- 遍历:循环
p<-(p.next)
- 查:index 与值都是遍历的 $O(n)$
- 优势:对已知节点增删快
- 劣势:访问很慢,浪费 1 或 2 个指针的空间
栈
栈:LIFO 后进先出
应用:
- 函数调用的模拟
- 递归
- 深度优先搜索
- 配对检查
- 表达式求值(中缀/后缀表达式)
顺序栈
struct Stack<T> {
T* bottom; // 栈底,不可变
T* top; // 栈顶,一般取栈顶位置+1,若等于栈底则栈空
size_t cap; // 容量,用来动态扩容
};
- push: 把值放在
top
位置并top
自增,$O(1)$ - pop: 把
top-1
取出来并top
自减,$O(1)$ - 倍增空间同顺序表,均摊 $O(1)$
链接栈
struct StackNode<T> {
T value;
StackNode<T>* down; // 指向栈更底下的元素,若空则为栈底
};
struct LinkedStack<T>{
StackNode<T>* top; // 指向栈顶,若空则栈空
};
- push: 把值放在新节点里,并把它指向
top
,再把top
设为它,$O(1)$ - pop:返回
top
,并把top
设为top.down
,$O(1)$
队列
队列:FIFO 先进先出
应用:
- 列车车厢重排:进入最大可行队尾的队列
- 模拟排队:先把用户按入队时间排序了,生成队列,再一个个出队
顺序表队列
- enqueue: insert(len) $O(1)$
- dequeue: remove(0) $O(n)$
- peak: visit(0) $O(1)$
循环队列 Circular Queue
优势:内存分配开销少 劣势:内存浪费较多
struct Queue<T>{
T* base; // 底层数组起始位置
size_t cap; // 底层数组容量
T* head; // 队列头
T* tail; // 队列尾,实际指向尾部+1 的位置,等于头则队列空,等于头 -1 则队列满
};
- enqueue:
*(tail++)=value
$O(1)$ - dequeue:
*(head++)
$O(1)$ - peak:
*head
$O(1)$ - 注意倍增和取余
链接队列
优势:内存浪费较少 劣势:内存分配开销大
struct QueueNode<T>{
T value;
QueueNode<T>* follower;
};
struct LinkedQueue<T>{
QueueNode<T> *head;
QueueNode<T> *tail;
};
- enqueue:
tail->next=newnode; tail=newnode;
$O(1)$ - dequeue:
node=head; head=head->next;
$O(1)$ - peak:
*head
$O(1)$
树(数据结构)
定义:有根、除了根每个节点对应唯一父节点的组织结构 要求:无回路,连通 术语
- 根(root), 叶(leaf), 内部(internal)
- 子(child), 父(parent), 兄弟(sibling)
- 祖先(ancestor), 子孙(descendant)
- 度(degree): 子节点个数
- 层次/深度(depth): 从根为 1 开始
- 高(height): 子树最大深度
- 有序:对于单节点子节点之间有序
- 森林:一个不相交的树的集合
二叉树
最多两个子节点、左右节点有序的树
- 满二叉树
- 完全二叉树:堆式树
- 完满二叉树:度为 2 的树
- 数学性质:
- $n$ 层上最多 $2^{n-1}$ 个节点
- $n$ 层总共最多 $2^n-1$ 个节点
- 树枝数 $B=n-1=n_0+n_1+n_2-1=n_1+2n_2$
- 叶子数 $n_0$ 与二度节点 $n_2$ 有关系 $n_0=n_2+1$
- 遍历:
- 前序,中序,后序:深度优先,递归/栈
- 层次:队列
链接储存
struct BinaryTree<T>{
BinaryTree<T> *left, *right; // left and right sub-tree/child, null if no child
T value;
};
堆式储存/顺序储存
// same as vec
struct Heap<T>{
T* data;
size_t len, cap;
}
- 左子:
index*2+1
;右子:index*2+2
- 深度优先遍历:递归/循环+switch
- 广度优先遍历:队列/直接顺序遍历 data(跳过空节点)
哈夫曼树
又称:最优(二叉)树,最小代价树
应用:最佳判定树,哈夫曼编码
树的带权路径长度(WPL):树中所有叶子结点的带权路径长度之和 $\text{WPL}=\sum_{i=1}^nw_il_i$
定义:一棵 WPL 值最小的树,是完满二叉树
构造:在 F 中选取两棵根结点权值最小的树作为左、右子树构造一棵新的二叉树, 且新的二叉树根结点权值为其左、右子树根结点的权值之和;在 F 中删除这两棵树, 同时将新得到的树加入 F 中:权值小的二叉树作为新二叉树的左子树, 权值大的二叉树作为新二叉树的右子树;在权值相等时,深度小的二叉树作为新二叉树的左子树, 深度大的二叉树作为新二叉树的右子树。
哈夫曼编码:一种可变长(VLC)、前缀编码
前缀编码:字符只放在叶结点中、所以每个字符的编码都不可能是其他字符编码的前缀
树和森林
- 储存
- 标准储存:最大度有限,数组储存子
- 孩子链表:最大度任意,链表存子
- 孩子兄弟:最大度任意,二叉树左链为树链,右链为兄弟链
- 双亲表示法:指父
- 森林储存:本质上是建立一个超级根节点,其他根节点为其子
- 遍历
- 只有前序、后序、层次
- 已知树的前序遍历序列和后序遍历序列,可以确定一棵树(因为子节点集无序)
优先级队列/堆(二叉堆)
定义:满足父节点 op
子节点序关系恒成立的完全二叉树
操作:
- 向上调整/过滤:while !(父
op
当前) 交换当前与父, $O(\log n)$ - 向下调整/过滤:while !(当前
op
子x) 交换当前与子x 其中有 子xop
子y, $O(\log n)$ - 建堆:for i in 1..len/2 向下调整 buf[i] $O(n)$
应用: 愚蠢的 多服务台的排队系统(不看了)
Set and Map
没找到太好的 map 的翻译,先用英文了(
尽管数学上应该先定义 set 再定义 map,但在数据结构这里,由于 set 会唯一对应一个储存 位置,事实上把这个位置拓展出 value 的位置就成了 k-v map 了;因此通常先实现 k-v map, 然后把 set 当作 value 为空的 map(对于不支持零长类型的语言除外)。因此下面只讨论 map。
数据结构中,对于大部分数据结构键在 map 内唯一。
线性 map
最烂的一种结构:线性 map,要么 $O(n)$ 查找 $O(1)$ 插入删除(链表), 要么 $O(\log n)$ 查找 $O(n)$ 插入删除(有序数组);更别提都是 $O(n)$ 的无序数组。
对于有序表查找算法我们有
- 二分查找 $O(\log n)$
- 插值查找:当 key 分布较为均匀时,可以 $i_{k+1}=low+(\frac{val[i_k]-val[low]}{val[high]-val[low]}(high-low-1))$
- *分块查找:本质上是双层 B+ 树,因为附带了一个索引。所有操作 $O(\sqrt{n})$
平均查找长度(ASL):查找次数的加权平均
朴素二叉查找树
定义:满足节点值大于左子树最大值,小于右子树最小值;即中序遍历递增
任意操作时间复杂度 $O(H)$,其中 $H$ 为平均高度。
注意树操作时为了方便最好采用指针引用的方式。
问题:单调数据的插入会造成退化成链表,因此需要插入删除时平衡操作
AVL 树
任何节点的两个子树的高度最大差别为 1。
平衡信息:子树高度。
危机节点:任何节点的两个子树的高度最大差别为1
平衡算法:从底向上,先调整再更新高度,*若都不更新直接返回根节点
旋转操作:见 四种平衡性破坏的情况 | Oi Wiki
缺点:维护代价过大
红黑树,AA 树
本质上是 3/4 阶 B 树,参考 B 树。
Splay 树 / 伸展树
主要想法是,把所有耗时操作都留到读的时候。
主要算法:通过双旋转(which 保证树平衡性不会过度退化),把所有访问过的节点 旋转到根。
缺陷:
- 读性能较差
- 由于插入没有做平衡,深度没有保证,容易爆栈
B 树与 B+ 树
不知道为什么教材把他放到 11 章。 真是愚蠢的设计
B 树可以说是组合结构的经典了:它既有数组的连续性,又有链表的灵活性。
定义:
- 平衡的 M 叉查找树,它仍符合中序遍历单调
- 任何节点元素个数 $n$ 有 $\lceil M/2\rceil-1\le n\le M-1$,除了根
个人理解:它像是把二叉搜索树的相邻节点中序地“平铺”到了同一层上。
由于 memory cache 和 disk alignment 的存在,现代化处理器对更大块的数据 处理的效率更高,因此 B 树实测性能比传统搜索树都要高,无论是传统的硬盘上还是 近年来流行的内存上。
典型阶数:12 阶(内存型) / 100 阶(外存型)。
阶数选择:一个磁盘块作为 B 树的一个结点效率最高,即 $Block=(M-1)(\texttt{sizeof(K)+sizeof(V)})+M\texttt{sizeof(I)}$
插入:非满数组直接插入,满数组对半分裂
删除:半满数组直接删除,未半满数组“旋转”,借不到合并
B+ 树:
- KV 只放在最底层,上层只放索引
- 叶之间双链起来(书上说单链那就单链吧)
- 有参数最大记录数 L,元素个数 $n$ 有 $\lceil L/2\rceil\le n\le L$
- 区分内部节点和叶节点
散列表
要求键可哈希。能做到小常数均摊 $O(1)$ 任意操作。
哈希算法
- 取余
- 其他根据键分布特性定制的算法(数字分析,平方取中,折叠)
- 其他通用哈希算法
冲突解决
- 闭散列:在散列表内寻找空闲位置,很
- 线性探查:查 $n+i$
- 二次探查:查 $n+i^2$
- 再散列:有主、附散列函数 $H_1$ $H_2$,则查 $H_1(x)+H_2(x)i$
- 开散列:用链表(实际上也可以用小搜索树例如红黑树)
排序
严格来说这算是算法,不知道为啥放在数据结构里。
-
稳定与非稳定排序
- 稳定排序:等 key 元素排序后,相对次序保持不变
- 不稳定排序:等 key 元素排序后,相对次序可能会变
-
内排序与外排序:
- 内排序是指被排序的数据元素全部存放在计算机的内存之中,并且在内存中 调整数据元素的相对位置。
- 外排序是指在排序的过程中,数据元素主要存放在外存储器中,借助于内存 储器逐步调整数据元素之间的相对位置。
-
比较排序算法
- 输出顺序的确定依赖于不同元素之间的比较
- 每次比较能且只能区分一对元素的大小关系
- 对于输入为 $n$ 的一组元素,其排列顺序的可能性为 $n!$
因此理论上排序算法均摊复杂度不可能小于 $O(\log(n!))=O(n\log(n))$
排序方法 | 平均 | 最坏 | 最好 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
直接插入排序 | $O(n^2)$ | $O(n^2)$ | $O(n)$ | $O(1)$ | 稳定 |
希尔排序 | $<O(n^2)$ | $<O(n^2)$ | $<O(n^2)$ | $O(1)$ | 不稳定 |
直接选择排序 | $O(n^2)$ | $O(n^2)$ | $O(n^2)$ | $O(1)$ | 不稳定 |
堆排序 | $O(n\log n)$ | $O(n\log n)$ | $O(n\log n)$ | $O(1)$ | 不稳定 |
冒泡排序 | $O(n^2)$ | $O(n^2)$ | $O(n)$ | $O(1)$ | 稳定 |
快速排序 | $O(n\log n)$ | $O(n^2)$ | $O(n\log n)$ | $O(\log n)$ | 不稳定 |
归并排序 | $O(n\log n)$ | $O(n\log n)$ | $O(n\log n)$ | $O(n)$ | 稳定 |
基数排序 | $O(mn)$ | $O(mn)$ | $O(mn)$ | $O(m)$ | 稳定 |
插入排序
有主循环,每个循环内认为 0..i
是有序的,把 i
号元素上浮
- 稳定
- 小常数
- $O(n^2)$
- 无额外空间
小优化:二分查找,当比较函数很耗时时有用,否则反而会变慢
希尔排序
按照希尔增量序列分组插入排序。看似是 $O(n^3)$ 实则 $<O(n^2)$
步长序列 | 最坏时间复杂度 |
---|---|
$n/2^{i}$ | $O(n^2)$ |
$2^k-1$ | $O(n^{3/2})$ |
$2^i3^j$ | $O(n\log^2n)$ |
- 不稳定
- $O(n\log^2n)$
- 无额外空间
选择排序
有主循环,每个循环选择最小的放到最前面, $O(n^2)$
- 稳定
- $O(n^2)$
- 无额外空间
堆排序
有主循环,每个循环把堆顶放到最后面,$O(n\log n)$
- 不稳定
- 最坏 $O(n\log n)$
- 无额外空间
冒泡排序
循环i=n-1..-1
,从头到 i,有逆序就交换,这样 i..n
就有序了。
一个循环内称为一次起泡(?)。 $O(n^2)$
小优化:当一次循环没发生交换就结束
特点:
- 稳定性
- $O(n^2)$
- 无额外空间
快速排序
通过选定元素划分,通过不断交换 low
与 high
即可实现;然后递归左半边和右半边。
$O(n\log n)$
小优化:
- 对于小数组采用插入排序
- 选定随机元素或首、尾、中的中位数
特点:
- 不稳定
- 均摊 $O(n\log n)$
- $O(\log n)$ 栈空间
归并排序
不断二分、合并
- 稳定
- 最坏 $O(n\log n)$
- $O(n)$ 额外空间
桶排序
设有 $k$ 个桶,时间复杂度 $O(N\log\frac N k)$,要求数据分布较为均匀且已知上下限。
需要额外空间 $O(N)$
基数排序
是离散化的桶排序。由于桶内值相同因此相较桶排序不需要桶内排序。
分为 LSD 与 MSD;前者适用于位数少的,后者适用于位数多的(要额外栈空间)
本质上是利用了类似哈希表的有限储存列表,将排序问题转换为遍历列表。
定义基数集合大小为 $B$ ,位数为 $k$ ,考虑到每个元素在每一位上都入桶、出桶各一次, 则有时间复杂度为 $O(k(B+N))$。
由于此过程未作任何比较,尤其适用于长字符串这种比较函数很慢、可基数化的数据排序。
需要额外空间 $O(n)$
外部排序
由于传统外排局限性很大:只能顺序读,甚至不能倒序。因此只能用顺序读的归并排序作为基础, 并做一些优化
置换选择
把有限的内存空间分为两部分:前一部分为小根堆,后一部分为不可入堆元素
对于一个选择片段,不断 pop 最小数,并输入数,若输入小于剩余堆根则放入不可入堆区,否则 push 进堆。
多阶段归并
用斐波那契分割
图
定义: $G(V, E)$ ,其中 $V$ 是顶点集, $E$ 是边集。
由边的性质可分为有向/无向图,有边权/无边权图
术语
- 图
- 邻接:由某边连起来的二结点
- 度:无向图中与某一结点关联的边的总数
- 入度:有向图中进入某一结点的边数
- 出度:有向图中离开某一结点的边数
- 子图:若 $V, E$ 均为子集,则为子图
- 路径
- 路径:“一笔画”的边对应的点序列
- 路径长:无权边数和;有权权和
- 简单路径:点序列不重复
- 环:路径节点数不为 1 且首尾节点相同的路径
- 连通
- 连通:有路径存在
- 连通图:任意两点连通的无向图
- 连通分量:极大连通子图数
- 强连通:有向图任意两点连通
- 弱连通图:非强连通,但作为无向图连通
- 强连通分量:极大强连通子图
- 完全
- 完全图:每两个节点之间都有边的无向图称为完全图
- 有向完全图:每两个节点之间都有双向边的有向图称为有向完全图
- 有向无环图(DAG)
- 生成树:连通图的极小连通子图,包含图的所有 $n$ 个结点,但只含图的 $n-1$ 条边
储存
- 邻接矩阵:
A[i][j]
代表从i
到j
有一条A[i][j]
边长的边;边长等于已知INF
时边不连通
无向图的邻接矩阵是对称阵
缺点:内存消耗过大, $\Theta(n^2)$ - 邻接表:每个点存其出边
缺点:不好直接检查边存在
遍历:
- DFS:深度优先,递归/栈
- BFS:队列
欧拉图
定义
- 欧拉回路:通过图中每条边恰好一次的回路
- 欧拉通路:通过图中每条边恰好一次的通路
- 欧拉图:具有欧拉回路的图
- 半欧拉图:具有欧拉通路但不具有欧拉回路的图
判别:
- 无向图是欧拉图当且仅当:
- 非零度顶点是连通的
- 顶点的度数都是偶数
- 无向图是半欧拉图当且仅当:
- 非零度顶点是连通的
- 恰有 2 个奇度顶点
- 有向图是欧拉图当且仅当:
- 非零度顶点是强连通的
- 每个顶点的入度和出度相等
- 有向图是半欧拉图当且仅当:
- 非零度顶点是弱连通的
- 至多一个顶点的出度与入度之差为 1
- 至多一个顶点的入度与出度之差为 1
- 其他顶点的入度和出度相等
算法:未访问优先的 DFS,访问过的边删掉(无向图)
强连通分量
Kosaraju 算法
第一次 DFS,选取任意顶点作为起点,遍历所有未访问过的顶点,并在回溯之前给顶点编号,也就是后序遍历。(先遍历的编号大)
第二次 DFS,对于反向后的图,以标号最大的顶点作为起点开始 DFS。这样遍历到的顶点集合就是一个强连通分量。对于所有未访问过的结点,选取标号最大的,重复上述过程。
评析:主要利用了,强连通图反向后仍强连通的好性质。
拓扑排序
能拓扑排序 等价于 是有向无环图(DAG);因此判断有向无环可以用拓扑排序
算法:
- 从有向图中选取一个没有前驱的顶点,并输出
- 从有向图中删去此顶点以及所有以它为尾的边,即它出发的边
- 重复上述两步,直至图空,或者图不空但找不到无前驱的顶点为止
可以维护一个入度表和零入度队列, $O(|E|+|V|)$
AOE 网
AOE 网是一个零入度结点唯一(源点)、零出度节点唯一(汇点)的、带边权的 DAG
关键路径:AOE 网中从源点到汇点的最长路径的长度
关键活动:关键路径上的活动,最早开始时间和最迟开始时间相等
符号定义
- 对于点 $i$ ,有
- 最早发生时间 $ee(i)=$ 源点到 $i$ 的最长路径
- 最晚发生时间 $le(i)=$ $i$ 到汇点的最长路径
- 边 $v=<j, k>$ ,持续时间是 $L_{jk}$
- 最早开始时间 $e(v)=ee(j)$
- 最晚开始时间 $l(v)=le(k)-L_{jk}$
关键路径算法
- 利用拓扑排序求出 AOE 网的一个拓扑序列
- 从拓扑排序的第一个顶点开始,按拓扑顺序依次计算每个事 件的最早发生时间 $ee(i)$
- 从拓扑排序的最后一个顶点开始,按逆拓扑顺序依次计算每 个事件的最晚发生时间 $le(i)$ (注意边要逆向)
- 从源点开始遍历出边, $ee(j)=e(v)=l(v)=le(k)-L_{jk}$ 的边即为关键路径一条边,DFS 下去。
(这不求最长路径的 Dijkstra 吗)
专题
外储存器
特点:储存量大,有持久性
运作原理:
- 寻道:找到对应柱面 (机械硬盘 ~10ms,固态硬盘 0)
- 定位:找到对应盘面和扇区 (机械硬盘 ~0.2ms,固态硬盘 <0.1ms)
- 读写:以块为单位读写
因此外储存器
- 小于块(通常 4kB)大小的数据事实上读写时间是一样的,因此大尺寸数据读写效率高
- 随机读写需要寻道和定位,很慢;因此随机读写慢,连续读写快
卡特兰数
两种定义:
- $C_n= {n\choose 2n}-{n-1\choose2n}$
- 在 $n×n$ 的网格中,一开始在(0,0)处,每次可以向上走一格或者向右走一格,在任一时刻,向右走的次数不能少于向上走的次数,问有多少种合法路径?(用 $(n,n)$ $(n-1, n+1)$ 映射法)
- 括号匹配:有 $n$ 个左括号, $n$ 个右括号,有多少种长度为 $2n$ 的括号序列使得所有括号合法?
- 与 $1, 2, 3,…, n$ 的入栈序列对应的出栈序列种类数
- 不相交弦问题:一个圆周上有 $2n$ 个点,两两配对并在两点间连一条弦,要求所连的n条弦没有交点,那么一共有多少种配对数?
- $C_n=\sum_{k=0}^{n-1}C_kC_{n-1-k}$
- 把一个 $n$ 层的矩形阶梯分为 $n$ 个矩形的方法数?
- 凸多边形的三角划分:一个凸的 $n+2$ 边形,用 $n-1$ 条直线连接它的两个顶点使之分成n个三角形,每条直线不能相交,问一共有多少种划分方式?
- 给出某二叉树的先序遍历,该遍历可能对应的二叉树的种类数
- $n$ 个结点的构成的二叉树种类数