数据结构和算法相关整理
数据结构(Data Structure)
数据的基本单位是数据元素,最小单位是数据项。
数据与其组成方式即数据元素间的关系一同构成数据结构。数据结构在逻辑上的分类有集合、线性结构、树结构和图结构等。数据结构的选择取决于问题。
数据与其基本操作和处理方式一同构成抽象数据类型(ADT; Abstract Data Type)。
算法(Algorithm)
算法被定义为若按其执行可完成某一特定任务的有限指令集合。算法一词最早来源于波斯数学家花拉子米(Al-Khwarizmi),在中国则最早见于「周髀算经」。算法的五个特征如下。
- 输入(Input):零个或多个输入;
- 输出(Output):一个或多个输出;
- 确定性(Definiteness):每一步骤对应的指令无二义性;
- 有穷性(Finiteness):有限的步骤,每一步骤在可接受的时间内完成;
- 有效性(Effectiveness):每一步骤都是可执行的。
程序可被定义为由编程语言表达的算法。
数据结构 + 算法 = 程序。 ——Niklaus Wirth
描述算法的方法有自然语言、程序设计语言、流程图和伪代码等。其中伪代码被称为算法语言。
算法验证(Algorithm Validation)的工作是证明算法对所有可能的合理输入都能计算出正确的结果。
调试只能指出错误的存在,而并不能指出其不存在。 ——Dijkstra
性能测量(Performance Measurement)又称分析(Profiling),测量算法计算结果所需的时间和空间。
- 时间复杂度(Time Complexity):算法到运行结束时所需要的计算时间;
- 空间复杂度(Space Complexity):算法到运行结束时所需要的内存。
渐进符号(Asymptotic Notations)指问题规模趋近
程序步(Program Step)是程序中有明确语义的一个片段,其执行时间独立于实例特征(输入、参数等)。
时间复杂度可考虑如下三种情况。
- 最好情况步数(Best-Case Step Count);
- 最坏情况步数(Worst-Case Step Count):最经常分析的情况;
- 平均步数(Average Step Count)。
为了对一个短的时间计时,往往需要重复多次,将总用时除以重复的次数。
阿克曼函数(Ackermann Function)的定义如下。
阿克曼函数增长得特别快。而阿克曼反函数则以非常慢的速度增加,通常使用
主定理(Master Theorem)可快速求得关于递归算法的复杂度。
线性表(Linear List)
线性表即有限序列,可分为如下两类。
- 「顺序表」:即数组,逻辑顺序和存储顺序一致,一般仅存取、修改两种操作(本质上,存取和修改两种操作对应寻址这一种操作);
- 链表(Linked List):逻辑顺序和存储顺序不一定一致。
单链表(Singly Linked List)只能访问结点后继。单链表设置头结点的目的是统一空表和非空表的情况,使得编程方便。单链表逆置的操作如下。
1 | p = head->next; |
双链表(Doubly Linked List)既能访问结点的前驱又能访问其后继,删除和插入操作方便。
单循环链表(Circular Singly Linked List)从表中任意结点出发都能扫描整个链表。单循环链表设置尾指针而非头指针,使查找终结点的时间为
1 | s->next = rear->next; |
删除头结点的操作如下。
1 | q = rear->next->next; |
若经常性地删除最后一个结点,可考虑双循环链表(Circular Doubly Linked List)以节省运算开销。
可由一个尾指针唯一确定的链表有单/双循环链表和双向链表。
广义表(Generalized List)即允许表中有子表的情况。非空广义表可表示为如下形式。
通常,这些
- 表头:
; - 表尾:
。
非空广义表的表尾总是一个广义表。广义表
空表即
递归表即
广义表的存储结构如下。其中:tag
区分原子和子表;hp
指示连接本子表的元素;tp
连接下一元素。无论方式Ⅰ还是方式Ⅱ都不破坏元素间并列的关系。
1 | struct Node { |
1 | struct Node { |
稀疏矩阵(Sparse Matrix)
稀疏矩阵的压缩存储方式有三元组顺序表和十字链表等,但这使必然失去随机存取功能。
稀疏矩阵不属于「特殊矩阵」。所谓「特殊矩阵」指有很多值相同的元素,且它们的分布很有规律。常见的「特殊矩阵」有对角矩阵、三角矩阵、对称矩阵等。
栈(Stack)
栈满足后进先出(LIFO; Last In First Out)规则,所有插入和删除操作都在栈的一端即栈顶(Top)进行。
栈和队列能出现的异常状况如下。
- 上溢(Overflow):缓冲区满但写入;
- 下溢(Underflow):缓冲区空但读出。
C++标准库提供了栈的实现std::stack
(需要包含头文件<stack>
)。注意stack::pop
仅仅移除栈顶数据,而并不会返回之;若要访问栈顶数据需用stack::top
。
卡特兰数(Catalan Number)
假设有足够大的栈,
出栈序列的判断技巧是,在输出序列中任意元素后面不能出现比该元素先入栈,且入栈顺序升序的两个元素。
逆波兰表示法(RPN; Reverse Polish Notation)
又称后缀表示法(Postfix Notation),而常见的数学公式和程序设计语言表达式均为中缀表示法(Infix Notation)。举例而言,中缀表示法的a+b*c-d/e
,可转换为逆波兰表示法的abc*+de/-
。
中缀表示法转换为逆波兰表示法的规则如下。
- 遇操作数则输出;
- 遇括号时,若是
(
则压入堆栈,优先级降至最低;)
则逐个弹出,并弃置(
; - 遇运算符时,若优先级大于栈顶运算符则压入堆栈;优先级小于或等于栈顶运算符则弹出再比较;
- 最后若堆栈中有剩余内容,则依次弹出。
逆波兰表示法的运算也借助堆栈。
队列(Queue)
一般而言,队列满足先进先出(FIFO; First In First Out)的规则,所有插入操作在一端即队尾(Rear)进行,而所有删除操作在另一端即队首(Front)进行。栈和队列的区别在于对插入和删除操作的限定不同。
下例计算队列的长度。
1 | size_t size = (rear - front) % MAX_SIZE; |
入队注意更新尾指针。
1 | rear = (rear + 1) % MAX_SIZE; |
C++标准库提供了队列的实现std::queue
(需要包含头文件<queue>
)。
循环队列(Circular Queue)
循环队列是队列的一种高效实现,其目的是为了克服「假溢出」的状况,循环队列区分空满状态的方法如下。
- 浪费一个数组单元;
- 引入标识变量;
- 引入计数器。
优先队列(Priority Queue)
优先队列是任意支持查找、插入以及删除最值操作的数据结构,不满足先进先出的规则。
C++标准库提供了优先队列的实现std::priority_queue
(需要包含头文件<queue>
)。
下例重新定义std::priority_queue
的比较规则。
1 | class Compare { |
使用自定义比较规则,则需要书写如下形式的声明。
1 | priority_queue<Node, vector<Node>, Compare> q; |
堆是优先队列的实现之一。
树(Tree)
树
一个结点的子树的根被称为这个结点的子(Children)结点,这个结点被称为孩子的父(Parent)结点,同一父结点的子结点称为兄弟(Siblings)。一个结点的祖先(Ancestor)是指从根到该结点路径上的所有结点。
结点的度(Degree)即结点拥有的子树个数。有序树指结点各子树有序,不能互换。因此易知「度为2的树」与「二叉树」概念上并不相同。
度为0的结点称为叶(Leaf)结点或终止(Terminal)结点。
可以用二叉链表实现树,指针域分别指向该结点的「第一个子结点」和「右兄弟」。
二叉树(Binary Tree)
二叉树遍历的递归写法如下。
1 | void visit(Node* root) { |
先序、中序和后序遍历分别对应波兰表示、去括号的中缀表示和逆波兰表示。任意二叉树的叶子结点前序、中序和后序遍历中相对次序保持不变。
所有结点的子树斜偏向同一侧的二叉树称为斜二叉树(Skewed Binary Tree)。前序和后序相反的二叉树结点数即深度;前序和中序相同的为右斜树;中序和后序相同的为左斜树。
树的层序遍历即广度优先搜索,二叉树的顺序存储是按层序存储的。
若高度为
考虑分枝数
满二叉树(Full Binary Tree)
正好包含
完全二叉树(Complete Binary Tree)
若层序遍历从1标号到
哈夫曼树(Huffman Tree)
即最优二叉树,保证带权路径长度(WPL; Weighted Path Length)最短。带权路径长度即
哈夫曼树的构造步骤如下。
棵二叉树森林 ;- 选两棵权值最小的作为左右子树,新二叉树的权值为两子树权值之和。
- 删除上步骤选中的两棵子树,加入新生成的二叉树;
- 重复步骤2、3直至只剩一棵树。
根据哈夫曼树产生哈夫曼编码的代码如下。
1 | void huffmanCode(Node* root, vector<bool> curcode) { |
哈夫曼树由
线索二叉树(Threaded Binary Tree)
二叉树按某种次序遍历得到前驱和后继,其线索化即如下操作:若结点无左子树,则左指针指向其前驱结点;若结点无右子树,则右指针指向其后继结点。
通常设立某种标记,标记为真时结点被线索化,反之则指向子树。
二叉搜索树(Binary Search Tree)
又称「查找二叉树」或「排序二叉树」,递归地满足结点值大于任意左子树结点,小于任意右子树结点。有时,规定二叉搜索树中所有的值不允许出现重复。
二叉搜索树中,最值元素不一定是叶子结点。
任意结点的数值位于两子结点的数值之间的不一定是查找二叉树。
平衡二叉树(AVL Tree; Adelson-Velskii-Landis Tree)
定义平衡因子(BF; Balance Factor)如下。
令
- LR
LL (平衡状态); - RL
RR (平衡状态)。
红黑树(Red Black Tree)
红黑树大致上是平衡的,称「非严格平衡」,具有如下性质。
- 每个结点非红即黑;
- 根结点为黑色;
- 树尾的空项算为黑色;
- 红结点的子结点必是黑色;
- 任一路径含相同数目黑结点。
红黑树中没有一条路径会比其他路径长两倍。
若搜索次数远大于插入、删除的次数,选择AVL树;若搜索、插入和删除的次数差不多,则选择红黑树。
堆(Heap)
一个最大堆(Max Heap)是一棵完全二叉树,满足堆性质(Heap Property)即每个结点的值都至少与其孩子的值一样大。最大堆的定义意味着最大元素就在堆的树根。
在堆中插入一个元素,可以在堆的底部加入该元素,然后比较该元素与其祖先结点,直到它小于或等于其中的一个值,这个过程被称为Shift Up。
删除一个元素,则将要删除的元素位置清空,将堆末元素置入该位置,然后向下调整交换,将子结点中最大的元素交换上来,直至不能调整,这个过程称为Shift Down。一般情况下,删除的为根结点元素。
B树(B-Tree)
- 每个结点最多
个子结点; - 根、叶外结点之少
个子结点; - 若根非叶,至少有两个子结点;
- 所有叶在同一层;
- 含
个子结点的父节点有 个关键码; - 关键码个数
满足 。
森林(Forest)
森林被定义为
若
由森林向二叉树的转换如下。
- 将森林中每一棵树转为二叉树;
- 第一棵树的根视为根结点,将下一棵树接至根的右孩子;
- 重复步骤2。
并查集(DSU; Disjoint Set Union)
根结点的值始终为负数,其取相反数表示集合元素的个数;其他结点的值为与它处于同一集合的结点索引(在unite()
操作后)。
1 | int dsu[MAX]; |
图论(Graph Theory)
欧拉使用图解决经典的哥尼斯堡桥问题(Königsberg Bridge Problem),成为图论的创始人。
图(Graph)
图的顶点(Vertex)集
- 无向图(Undirected Graph):边的顶点对是无序的,至少0条边,至多
条边; - 有向图(Directed Graph):边是由有向对表示的,至少0条边,至多
条边。
顶点的度(Degree)等于与它相接的边的条数。对于有向图,
零图(Null Graph)即边集为空的图。
平凡图(Trivial Graph)顶点集大小为1的零图。
无向边使用
悬挂点(Pendant Vertex)指只有一条边与之相接的点,所对应边则称为悬挂边(Pendant Edge)。
子图(Subgraph)指顶点集和边集分别是某一图顶点集的子集和边集的子集的图。路径(Path)是一个顶点的序列
欧拉指出,从任意顶点出发存在一条通过所有的边恰好一次并返回出发地的路径,当且仅当「所有顶点的度数均为偶数」,而满足这样条件的路径被称为欧拉路径。
环(Cycle)是图中只有首末顶点重复的非空路径。没有环的图称作无环图(Acyclic Graph),没有环的有向图称为有向无环图(DAG; Directed Acyclic Graph)。
存在
树是连通无环图。
割点(Cut Vertex)是去掉后使图不再连通的点。所对应边则称为割边(Cut Edge),又称桥(Bridge)。
无向图的一个连通分支(CC; Connected Component)就是最大的连通子图;有向图的任意一对顶点都存在路径,则此图强连通(Strongly Connected),其连通分支称为强连通分支(SCC; Strongly CC)。
有向图的弱连通(Weakly Connected)指有向边换位无向边后可连通;有向图的单向连通(Singly Connected)指任意一对顶点至少一向可连通。强连通图必然单项连通,单项连通图必然弱连通。
简单图(Simple Graph)
平行边(Multiple Edges)指相接于同一对顶点的边。自环(Loop)指顶点与顶点自身连结的边。简单图指不含平行边和自环的图。
允许平行边的图称为多重图(Multigraph),又译多图,或称伪图(Pseudograph)。一般情况下讨论的都是简单图。
多重图与超图(Hypergraph)不同,超图是指一条边可以连接任意数量的节点,而不只是两个。
完全图(Complete Graph)
每个顶点都与其余顶点相连的无向图。通常记
若存在顶点集的划分
最小生成树(MST; Minimum Spanning Tree)
图的生成树的唯一性不能确定,
Prim
Prim的复杂度为
其中,
graph
是图的邻接矩阵形式。
1 | int prim(int (&graph)[N][N]) { |
Kruskal
Kruskal的复杂度为
其中,
graph
是图的邻接表形式。
1 | struct edge { |
全源最短路径(All-Pairs Source Shortest Path)
Floyd-Warshall是全源最短路径的直接算法,其复杂度为
Floyd-Warshall算法是一种动态规划的算法,最佳子结构为从顶点
其中,
graph
是图的邻接矩阵形式。
1 | void floyd_warshall(int (&graph)[N][N]) { |
单源最短路径(Single Source Shortest Path)
Dijkstra
Dijkstra复杂度为
其中:
graph
是图的邻接矩阵形式;dis
存储从源出发到各结点的最短路径长度;path
存储在这一最短路径中各节点的上一结点;src
即源结点。
1 | void dijkstra(int (&graph)[N][N], int (&dis)[N], int(&path)[N], int src) { |
使用「优先队列的堆优化」可将复杂度降至
其中,
graph
是图的邻接表形式。
1 | struct node { |
Bellman-Ford
复杂度为
其中:
graph
是图的邻接表形式,返回值为true
表示没有负环回路,为false
表示存在负环回路,其他同前述。
1 | struct edge { |
搜索(Searching)
深度优先搜索(DFS; Depth First Search)
深度优先搜索的实现方式大致如下。
1 | void dfs(int step) { |
深度优先搜索常用剪枝(Pruning)操作,即跳过无论何种状态转移都不会存在的解。
广度优先搜索(BFS; Breadth First Search)
广度优先搜索的实现方式大致如下。
1 | void bfs(void) { |
排列(Permutation; Arrangement)
将相异对象或符号根据确定的顺序重排,每个顺序都称作一个排列。
从std::next_permutation
(需要包含头文件<algorithm>
),用于将
其中,
std::next_permutation
将在所有的排列生成后返回false
。
1 | sort(a, a + n); |
贪心(Greedy)
使用计算机模拟一个贪心的人做出决策的过程,即遵循某种规则,只考虑当前情况来选取最优策略。
动态规划(DP; Dynamic Programming)
规划(Programming)在数学上的意思,即寻找问题最佳化的一个过程。动态规划的关键思想如下:把大问题分成小问题,得到最佳子结构和状态转移式;将计算得到的相同问题的答案记录下来。
最大连续元素和(MCS; Maximum Consecutive Sum)
最大连续元素和的解题思想为「如果在
求解的代码如下。
1 | int max = 0; |
分析得
1 | int max = 0; |
最长上升子序列(LIS; Longest Increasing Subsequence)
最佳子结构为
求解的代码如下。
1 | int max = 0; |
最长公共子串(LCS; Longest Common Subsequence)
令
求解的代码如下。
1 | for(int i = 1; i < n; ++i) { |
0-1背包问题(0-1 Knapsack Problem)
有
求解的代码如下。
1 | for(int i = 1; i <= n; ++i) { |
完全背包问题(Unbounded Knapsack Problem)
有
求解的代码如下。
1 | for(int i = 1; i <= n; ++i) { |
数论(Number Theory)
因数(Divisor)又称约数,某个数的因数指能被某个数整除的数。通常用
素数(Prime Number)又称质数,指除了1和它自身,不再有其它因数。合数(Composite Number)指除了1和它自身,还有其它因数。1既不是质数,也不是合数。
简单的素性判断可以通过如下的试除法(Trial Division)实现。
1 | bool isPrime(int n) { |
素因数分解(Prime Factorization)即任何一个正整数
- 因数个数定理(Number of Divisors):因数个数为
; - 因数和定理(Sum of Divisors):因数和为
。
最大公约数(GCD; Greatest Common Divisor)计算的代码如下。
1 | LL gcd(LL a, LL b) { |
最小公倍数(LCM; Lowest Common Multiple)计算的代码如下。
1 | LL lcm(LL a, LL b) { |
阶乘(Factorial)计算的代码如下。
1 |
|
斐波那契序列(Fibonacci Sequence)计算的代码如下。
1 |
|
记斐波那契序列的第
; ; ; ; 。
筛法(Sieve Theory)
筛法是素性判断的基本方法。
埃拉托斯特尼筛法(Sieve of Eratosthenes)
简称埃氏筛,即从2开始,将每个素数的各个倍数标记为合数。
欧拉筛法(Sieve of Euler)
在埃氏筛的基础上,用且仅用最小质因子筛出合数,避免重复标记。
1 | bool isPrime[MAX]; |
「莫比乌斯筛」计算各整数的莫比乌斯函数的值,可以和素数一起筛。
1 | int mu[MAX]; |
数论函数(Number-Theoretic Function)
又称算术函数(Arithmetic Function),指定义域为正整数的函数。
积性函数(Multiplicative Function)是对
- 除数函数(Divisor Function):
; - 单位函数(Unit Function):
; - 恒等函数(Identity Function):
; - 欧拉函数(Euler's Totient Function):
。
完全积性函数(Completely Multiplicative Function)是对任意
狄利克雷卷积(Dirichlet Product)
狄利克雷卷积是数论函数间的一种运算,数学形式如下。
狄利克雷卷积的代码实现如下。
1 | void dirichlet(int n) { |
狄利克雷卷积满足交换律、结合律和分配律。两积性函数的狄利克雷卷积仍是积性函数。
莫比乌斯函数(Möbius Function)
莫比乌斯函数的数学形式如下。
莫比乌斯函数是积性函数,且满足
二进制取幂(Binary Exponentiation)
又称「快速幂」。
1 | LL mod = 10007; |
循环右移(ROR; Rotate Right)
倒置法实现循环右移的代码如下。
1 | void reverse(int* arr, int begin, int end) { |
汉诺塔(Tower of Hanoi)
可将一个搬动
1 | enum Tower { A = 'A', B = 'B', C = 'C' }; |
霍纳法则(Horner's Rule)
又称「秦九韶算法」,用最少的乘法来计算多项式在点
若有如下的多项式。
尺取法(しゃくとり法)
尺取法名称源于日本的尺蠖虫(シャクトリムシ),其行动时身体屈伸如拱桥。尺取法又称双指针技巧(Two-Pointer Technique),通过移动左、右端点来遍历一个数列,从而求解一个最短区间的算法。一般而言,尺取法的时间复杂度为
例如,求正整数序列的最短连续子序列使各元素和大于等于某值。区间和小于该值时,右端右移,以使满足条件;区间和大于等于该值时,左端右移,更新答案。
弗洛伊德的乌龟和兔子(Floyd's Tortoise and Hare)
又称快慢指针(Fast & Slow Pointers)。快指针(Fast Pointer)即兔子,每次移动两步;慢指针(Slow Pointer)即乌龟,每次移动一步。如果链表存在环,那么快慢指针一定会相遇。
下例在长度为
1 | def find(arr): |
快慢指针相遇后,使一指针指向头,另一指针指向相遇点,每次移动一步,能在环的入口处再相遇。此例中,环的入口即重复的元素。
快速输入输出(Fast I/O)
1 | int read() { |