数据结构和算法相关整理

数据结构(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
2
3
4
5
6
7
8
9
p = head->next;
pre = nullptr;
while(p != nullptr) {
r = p->next;
p->next = pre;
pre = p;
p = r;
}
head->next = pre;

双链表(Doubly Linked List)既能访问结点的前驱又能访问其后继删除和插入操作方便

单循环链表(Circular Singly Linked List)从表中任意结点出发都能扫描整个链表单循环链表设置尾指针而非头指针使查找终结点的时间为而非以尾结点单循环链表为例插入结点于表尾的操作如下

1
2
3
s->next = rear->next;
rear->next = s;
rear = s; // 更新尾结点

删除头结点的操作如下

1
2
3
q = rear->next->next;
rear->next->next = q->next;
delete q;

若经常性地删除最后一个结点可考虑双循环链表(Circular Doubly Linked List)以节省运算开销

可由一个尾指针唯一确定的链表有单/双循环链表和双向链表

广义表(Generalized List)即允许表中有子表的情况非空广义表可表示为如下形式

通常这些被称为原子(Atom)上式广义表的表头和表尾定义如下

  • 表头
  • 表尾

非空广义表的表尾总是一个广义表广义表使得

空表即其表头表尾均未定义

递归表即

广义表的存储结构如下其中tag区分原子和子表hp指示连接本子表的元素tp连接下一元素无论方式Ⅰ还是方式Ⅱ都不破坏元素间并列的关系

1
2
3
4
5
6
7
8
9
struct Node {
int tag;
union {
char atom;
struct {
Node *hp, *tp;
} ptr;
}
};
1
2
3
4
5
6
7
8
struct Node {
int tag;
union {
char atom;
Node* hp;
}
Node* tp;
};

稀疏矩阵(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
2
3
4
5
class Compare {
bool operator()(Node a, Node b) {
return a.value() > b.value();
}
};

使用自定义比较规则则需要书写如下形式的声明

1
priority_queue<Node, vector<Node>, Compare> q;

是优先队列的实现之一

(Tree)

被定义为一个或多个结点的有穷集合具有层次关系且不存在环路存在一个特殊的称为(Root)的结点且除根节点外其余结点能被划分为棵不相交子树(Subtree)树的集合

一个结点的子树的根被称为这个结点的(Children)结点这个结点被称为孩子的(Parent)结点同一父结点的子结点称为兄弟(Siblings)一个结点的祖先(Ancestor)是指从根到该结点路径上的所有结点

结点的(Degree)即结点拥有的子树个数有序树指结点各子树有序不能互换因此易知度为2的树二叉树概念上并不相同

度为0的结点称为(Leaf)结点或终止(Terminal)结点

可以用二叉链表实现树指针域分别指向该结点的第一个子结点右兄弟

二叉树(Binary Tree)

二叉树遍历的递归写法如下

1
2
3
4
5
6
7
8
void visit(Node* root) {
if(root == nullptr) return;
// 先序(Pre Order)遍历操作
visit(root->lchild);
// 中序(In Order)遍历操作
visit(root->rchild);
// 后序(Post Order)遍历操作
}

先序中序和后序遍历分别对应波兰表示去括号的中缀表示和逆波兰表示任意二叉树的叶子结点前序中序和后序遍历中相对次序保持不变

所有结点的子树斜偏向同一侧的二叉树称为斜二叉树(Skewed Binary Tree)前序和后序相反的二叉树结点数即深度前序和中序相同的为右斜树中序和后序相同的为左斜树

树的层序遍历即广度优先搜索二叉树的顺序存储是按层序存储的

个结点的二叉树共计一棵个结点的二叉树共计个指针域个指向非空结点个为空

若高度为的二叉树没有度为1的结点结点最小值为最大值为(即满二叉树)

考虑分枝数可知又由可得

树与二叉树的对应关系

二叉树与普通树逻辑关系的对应如下表由普通树转换为二叉树根结点的右孩子总为空

二叉树
左子结点 第一个子结点
右子结点 右兄弟

满二叉树(Full Binary Tree)

正好包含个结点的高度为的树

个结点的满二叉树满足(即)

完全二叉树(Complete Binary Tree)

若层序遍历从1标号到其结点对应一棵相同高度的满二叉树从1标号到的那些结点

个结点的完全二叉树高度为叶子数编号最小的叶子序号是

哈夫曼树(Huffman Tree)

即最优二叉树保证带权路径长度(WPL; Weighted Path Length)最短带权路径长度即其中表示第个叶子结点的权值表示第个叶子结点至根节点的路径长度

哈夫曼树的构造步骤如下

  1. 棵二叉树森林
  2. 选两棵权值最小的作为左右子树新二叉树的权值为两子树权值之和
  3. 删除上步骤选中的两棵子树加入新生成的二叉树
  4. 重复步骤23直至只剩一棵树

根据哈夫曼树产生哈夫曼编码的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
void huffmanCode(Node* root, vector<bool> curcode) {
if(root->isLeaf()) {
dic[root->value()] = curcode;
return;
}
vector<bool> lcode = curcode;
vector<bool> rcode = curcode;
lcode.push_back(false);
rcode.push_back(true);
huffmanCode(root->lchild, lcode);
huffmanCode(root->rchild, rcode);
}

哈夫曼树由个分支结点经过次合并而获得没有度为1的结点

线索二叉树(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一般情况下删除的为根结点元素

C++标准库的堆操作

C++标准库支持如下堆操作其中pop_heap()不移除容器中的元素

操作 描述
is_heap(first, last) 判断是否为最大堆
make_heap(first, last) 构造最大堆
push_heap(first, last) last位置的元素添加至
[first, last)的最大堆中
pop_heap(first, last) firstlast的值交换并令
[first, last)变为堆
sort_heap(first, last) 将最大堆变为升序序列范围

B(B-Tree)

B树满足如下性质

  • 每个结点最多个子结点
  • 叶外结点之少个子结点
  • 若根非叶至少有两个子结点
  • 所有叶在同一层
  • 个子结点的父节点有个关键码
  • 关键码个数满足

森林(Forest)

森林被定义为棵不相交的树的集合

个顶点条边的无向图是森林则其必有棵树

由森林向二叉树的转换如下

  1. 将森林中每一棵树转为二叉树
  2. 第一棵树的根视为根结点将下一棵树接至根的右孩子
  3. 重复步骤2

并查集(DSU; Disjoint Set Union)

根结点的值始终为负数其取相反数表示集合元素的个数其他结点的值为与它处于同一集合的结点索引(在unite()操作后)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
int dsu[MAX];
int find(int x) {
if(dsu[x] < 0) return x;
// 路径压缩优化记录根结点再返回以便后续的根结点查询
return dsu[x] = find(dsu[x]);
}
bool equal(int x, int y) {
return find(x) == find(y);
}
int count(int i) {
return -dsu[find(i)];
}
void unite(int x, int y) {
x = find(x);
y = find(y);
if(x != y) { // 若不处于同一集合
dsu[x] += dsu[y]; // 更新集合元素个数
dsu[y] = x; // 绑定于根结点
}
}

图论(Graph Theory)

欧拉使用图解决经典的哥尼斯堡桥问题(Königsberg Bridge Problem)成为图论的创始人

(Graph)

图的顶点(Vertex)有穷非空(Edge)可空

  • 无向图(Undirected Graph)边的顶点对是无序的至少0条边至多条边
  • 有向图(Directed Graph)边是由有向对表示的至少0条边至多条边

顶点的(Degree)等于与它相接的边的条数对于有向图入度(In Degree)为头的所有边的数量出度(Out Degree)为尾的所有边的数量

零图(Null Graph)即边集为空的图

平凡图(Trivial Graph)顶点集大小为1的零图

无向边使用表示有向边使用表示若有无向边边的关系是相邻(Adjacent)的关系是相接(Incident)对于有向边称为(Head)称为(Tail)相邻到(Adjacent to)相邻(Adjacent from)

悬挂点(Pendant Vertex)指只有一条边与之相接的点所对应边则称为悬挂边(Pendant Edge)

子图(Subgraph)指顶点集和边集分别是某一图顶点集的子集和边集的子集的图路径(Path)是一个顶点的序列其中是图中的边简单路径(Simple Path)要求不能是重复的顶点个顶点的图简单路径长度不超过

欧拉指出从任意顶点出发存在一条通过所有的边恰好一次并返回出发地的路径当且仅当所有顶点的度数均为偶数而满足这样条件的路径被称为欧拉路径

(Cycle)是图中只有首末顶点重复的非空路径没有环的图称作无环图(Acyclic Graph)没有环的有向图称为有向无环图(DAG; Directed Acyclic Graph)

存在的路径则这两点连通(Connected)

树是连通无环图

割点(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)

每个顶点都与其余顶点相连的无向图通常记阶完全图为

若存在顶点集的划分使图中任一边端点分别在称为二部图(Bipartite Graph)又称二分图或偶图通常记完全二部图为

最小生成树(MST; Minimum Spanning Tree)

图的生成树的唯一性不能确定个顶点图的生成树有条边

Prim

Prim的伪代码

PRIM
输入
输出最小生成树
中任取顶点作为最小生成树的根加入到中;

在连接内顶点与内顶点的边中选取权值最小的边作为MST的边;
加入;

Prim的复杂度为Prim实现的代码如下

其中graph是图的邻接矩阵形式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
int prim(int (&graph)[N][N]) {
int res = 0;
bool visit[N] = {0};
int lowcost[N] = {0};
visit[0] = true;
for(int i = 1; i <= N - 1; ++i) {
lowcost[i] = graph[0][i];
}
for(int i = 1; i <= N - 1; ++i) {
int min = INF;
int p = -1;
for(int j = 0; j < N; ++j) {
if(!visit[j] && min > lowcost[j]) {
min = lowcost[j];
p = j;
}
}
if(min == INF) return -1;
res += min;
visit[p] = true;
for(int j = 0; j < N; ++j) {
if(!visit[j] && lowcost[j] > graph[p][j]) {
lowcost[j] = graph[p][j];
}
}
}
return res;
}

Kruskal

Kruskal的复杂度为Kruskal实现的代码如下

其中graph是图的邻接表形式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
struct edge {
int u;
int v;
int w;
edge(int u_, int v_, int w_): u(u_), v(v_), w(w_) { }
};

int dsu[MAX];

int find(int x) {
if(dsu[x] < 0) return x;
return dsu[x] = find(dsu[x]);
}

int kruskal(vector<edge> graph) {
memset(dsu, -1, sizeof(dsu));
sort(graph.begin(), graph.end(), [](edge x, edge y) {
return x.w < y.w;
});
int count = 0;
int res = 0;
for(size_t i = 0; i < graph.size(); ++i) {
int u = graph[i].u;
int v = graph[i].v;
int w = graph[i].w;
int t1 = find(u);
int t2 = find(v);
if(t1 != t2) {
res += w;
dsu[t1] = t2;
count++;
}
if(count == N - 1) break;
}
if(count < N - 1) return -1;
return res;
}

全源最短路径(All-Pairs Source Shortest Path)

Floyd-Warshall是全源最短路径的直接算法其复杂度为可以应用于包含负权值的图但不可以判断是否存在负权回路

Floyd-Warshall算法是一种动态规划的算法最佳子结构为从顶点到顶点可经由前个顶点的最短路径状态转移式如下 实现的代码如下

其中graph是图的邻接矩阵形式

1
2
3
4
5
6
7
void floyd_warshall(int (&graph)[N][N]) {
for (int k = 0; k < N; ++k)
for (int i = 0; i < N; ++i)
for (int j = 0; j < N; ++j)
if (i != j && graph[i][j] > graph[i][k] + graph[k][j])
graph[i][j] = graph[i][k] + graph[k][j];
}

单源最短路径(Single Source Shortest Path)

艾兹赫尔·韦伯·戴克斯特拉(Edsger Wybe Dijkstra)

荷兰计算机科学家是荷兰第一位以程序为专业的科学家

Dijkstra

Dijkstra的伪代码

DIJKSTRA
输入起点
输出至其余各顶点的距离
连接的各边 的权值;
加入;

中选出最小的顶点;
加入;
相邻且属于的所有顶点
的权值 的权值;

Dijkstra复杂度为不可以应用于包含负权值的图实现的代码如下

其中graph是图的邻接矩阵形式dis存储从源出发到各结点的最短路径长度path存储在这一最短路径中各节点的上一结点src即源结点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
void dijkstra(int (&graph)[N][N], int (&dis)[N], int(&path)[N], int src) {
bool visit[N] = {0};
for (int i = 0; i < N; ++i) {
dis[i] = graph[src][i];
path[i] = ((graph[src][i] < INF) ? (-1) : (src));
}
for (int i = 0; i < N - 1; ++i) {
int u = -1;
int min = INF;
for (int j = 0; j < N; ++j) {
if (!visit[j] && dis[j] < min) {
min = dis[j];
u = j;
}
}
if(u == -1) break;
visit[u] = true;
for (int j = 0; j < N; ++j) {
if (j == src) continue;
if (dis[j] > dis[u] + graph[u][j]) {
dis[j] = dis[u] + graph[u][j];
path[j] = u;
}
}
}
}

使用优先队列的堆优化可将复杂度降至实现的代码如下

其中graph是图的邻接表形式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
struct node {
int u;
int w;
node(int u_ = 0, int w_ = 0): u(u_), w(w_) { }
bool operator<(const node & other) const {
return w > other.w;
}
};

struct edge {
int v;
int w;
edge(int v_ = 0, int w_ = 0): v(v_), w(w_) { }
};

void dijkstra(vector<edge> (&graph)[N], int (&dis)[N], int(&path)[N], int src) {
bool visit[N] = {0};
for(int i = 0; i < N; ++i) {
dis[i] = INF;
path[i] = -1;
}
for(size_t i = 0; i < graph[src].size(); ++i) {
path[graph[src][i].v] = src;
}
dis[src] = 0;
priority_queue<node> q;
q.push(node(src, 0));
while(!q.empty()) {
node tmp = q.top();
q.pop();
int u = tmp.u;
if(visit[u]) continue;
visit[u] = true;
for(size_t i = 0; i < graph[u].size(); ++i) {
int v = graph[u][i].v;
int w = graph[u][i].w;
if(!visit[v] && dis[v] > dis[u] + w) {
dis[v] = dis[u] + w;
path[v] = u;
q.push(node(v, dis[v]));
}
}
}
}

Bellman-Ford

复杂度为可以应用于包含负权值的图也可以判断是否存在负权回路实现的代码如下

其中graph是图的邻接表形式返回值为true表示没有负环回路false表示存在负环回路其他同前述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
struct edge {
int u;
int v;
int w;
edge(int u_, int v_, int w_): u(u_), v(v_), w(w_) { }
};

bool bellman_ford(vector<edge> graph, int (&dis)[N], int (&path)[N], int src) {
for(int i = 0; i < N; ++i) {
dis[i] = INF;
path[i] = -1;
}
dis[src] = 0;
for(size_t i = 0; i < graph.size(); ++i) {
if(graph[i].u == src) {
path[graph[i].v] = src;
}
}
for(int i = 1; i <= N - 1; ++i) {
bool flag = false;
for(size_t j = 0; j < graph.size(); ++j) {
int u = graph[j].u;
int v = graph[j].v;
int w = graph[j].w;
if(dis[v] > dis[u] + w) {
dis[v] = dis[u] + w;
path[v] = u;
flag = true;
}
}
if(!flag) return true;
}
for(size_t i = 0; i < graph.size(); ++i) {
if(dis[graph[i].u] > dis[graph[i].v] + graph[i].w) {
return false;
}
}
return true;
}

搜索(Searching)

深度优先搜索的实现方式大致如下

1
2
3
4
5
6
7
8
9
10
void dfs(int step) {
if(...) { return; } // 剪枝或判断越界行为
if(...) { ...; return; } // 判断终点处理结果并返回
for(...) {
// 一些操作
// 标记
dfs(step + 1); // 进入下一层
// 还原标记
}
}

深度优先搜索常用剪枝(Pruning)操作即跳过无论何种状态转移都不会存在的解

广度优先搜索的实现方式大致如下

1
2
3
4
5
6
7
8
9
10
11
12
void bfs(void) {
queue<...> q;
q.push(...); // 起始点入队
while(!q.empty()) {
auto p = q.front();
q.pop();
for(...) {
// 一些操作
if(...) q.push(...); // 满足下一步条件则入队
}
}
}

排列(Permutation; Arrangement)

将相异对象或符号根据确定的顺序重排每个顺序都称作一个排列

个相异元素中取出个元素排列数量的计算如下 C++标准库提供了栈的实现std::next_permutation(需要包含头文件<algorithm>)用于将个元素的不同排列生成出来使用前需要保证个元素的序列是升序的常见使用如下

其中std::next_permutation将在所有的排列生成后返回false

1
2
3
4
sort(a, a + n);
do {
...
} while(next_permutation(a, a + n));

贪心(Greedy)

使用计算机模拟一个贪心的人做出决策的过程即遵循某种规则只考虑当前情况来选取最优策略

动态规划(DP; Dynamic Programming)

规划(Programming)在数学上的意思即寻找问题最佳化的一个过程动态规划的关键思想如下把大问题分成小问题得到最佳子结构和状态转移式将计算得到的相同问题的答案记录下来

其他经典的动态规划问题

切棍子问题中不同的棍子长度对应不同的价值现切割长度为个单位的木棍使其价值最大切棍子问题的最佳子结构为在第个切割位置前的棍子的最大价值状态转移式如下 求解的代码如下

1
2
3
4
5
6
for(int i = 1; i <= l; ++i) {
for(int j = 1; j <= i; ++j) {
if(dp[i-j] + value[j] > dp[i])
dp[i] = dp[i-j] + value[j];
}
}

不相邻数字最大和问题的最佳子结构为前个数字对此问题的最优解状态转移式如下注意设置递归出口

带权区间调度问题的最佳子结构为在任务之前所选任务的最高价值表示任务确定执行后之前可选的最近的任务带权区间调度问题的状态转移式如下

部分和问题的最佳子结构为前个数字凑固定值的可能状态转移式如下注意出口的情况

多重部分和问题的状态转移式如下

最大连续元素和(MCS; Maximum Consecutive Sum)

最大连续元素和的解题思想为如果在之前的最大和为负值不如就从开始累加最佳子结构为在元素之前的最大连续元素和状态转移式如下

求解的代码如下

1
2
3
4
5
int max = 0;
for(int i = 1; i <= n; ++i) {
dp[i] = (dp[i-1] < 0) ? (value[i]) : (dp[i-1] + value[i]);
if(dp[i] > max) max = dp[i];
}

分析得的计算后可以舍弃可将压缩为整型变量更精简的代码如下

1
2
3
4
5
6
int max = 0;
for(int i = 1; i <= n; ++i) {
if(dp < 0) dp = 0;
dp += value[i];
if(dp > max) max = dp;
}

最长上升子序列(LIS; Longest Increasing Subsequence)

最佳子结构为之前序列的最长上升子序列数量状态转移式如下

求解的代码如下

1
2
3
4
5
6
7
8
9
10
11
12
int max = 0;
for(int i = 0; i < n; ++i) {
dp[i] = 1; // LIS数量至少为1
}
for(int i = 1; i < n; ++i) {
for(int j = 0; j < i; ++j) {
if(value[j] < value[i] && dp[j] + 1 > dp[i]) dp[i] = dp[j] + 1;
}
if(dp[i] > max) {
max = dp[i];
}
}

最长公共子串(LCS; Longest Common Subsequence)

分别表示两个字符串状态转移式如下

求解的代码如下

1
2
3
4
5
6
7
8
9
10
for(int i = 1; i < n; ++i) {
for(int j = 1; j < m; ++j) {
if(s[i] == t[j]) {
dp[i][j] = dp[i-1][j-1] + 1;
}
else {
dp[i][j] = (dp[i-1][j] > dp[i][j-1]) ? dp[i-1][j] : dp[i][j-1];
}
}
}

0-1背包问题(0-1 Knapsack Problem)

个重量和价值分别为的物品从这些物品中挑选总重量不超过的物品使价值总和最大

求解的代码如下

1
2
3
4
5
6
7
8
9
10
for(int i = 1; i <= n; ++i) {
for(int j = 1; j <= w; ++j) {
if(j < weight[i]) { // 放不下
dp[i][j] = dp[i-1][j];
} else {
dp[i][j] = max(dp[i-1][j], // 不选
value[i] + dp[i-1][j-weight[i]]); // 选
}
}
}

完全背包问题(Unbounded Knapsack Problem)

个重量和价值分别为的物品从这些物品中挑选总重量不超过的物品每种物品可以挑选任意多件使价值总和最大

求解的代码如下

1
2
3
4
5
6
7
8
9
10
for(int i = 1; i <= n; ++i) {
for(int j = 1; j <= v; ++j) {
if(j < volume[i]) { // 放不下
dp[i][j] = dp[i-1][j];
} else {
dp[i][j] = max(dp[i-1][j], // 不选
value[i] + dp[i][j-volume[i]]); // 选
}
}
}

数论(Number Theory)

因数(Divisor)又称约数某个数的因数指能被某个数整除的数通常用表示能整除的因子反之则

素数(Prime Number)又称质数指除了1和它自身不再有其它因数合数(Composite Number)指除了1和它自身还有其它因数1既不是质数也不是合数

简单的素性判断可以通过如下的试除法(Trial Division)实现

1
2
3
4
5
bool isPrime(int n) {
for(int i = 2; i * i < n; ++i)
if(n % i == 0) return false;
return true;
}

素因数分解(Prime Factorization)即任何一个正整数都可写为累积式其中是一个素数这种表示方法是唯一的由素因数分解可得如下定理

  • 因数个数定理(Number of Divisors)因数个数为
  • 因数和定理(Sum of Divisors)因数和为

最大公约数(GCD; Greatest Common Divisor)计算的代码如下

1
2
3
LL gcd(LL a, LL b) {
return (b > 0) ? (gcd(b, a % b)) : (a);
}

最小公倍数(LCM; Lowest Common Multiple)计算的代码如下

1
2
3
LL lcm(LL a, LL b) {
return a / gcd(a, b) * b;
}

阶乘(Factorial)计算的代码如下

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
using namespace std;

int factorial(int n) {
return (n == 0) ? (1) : (n * factorial(n - 1));
}

int main(void) {
cout << factorial(5) << endl;
return 0;
}

斐波那契序列(Fibonacci Sequence)计算的代码如下

1
2
3
4
5
6
7
8
9
10
11
#include <iostream>
using namespace std;

int fibonacci(int n) {
return (n == 1 || n == 2) ? (1) : (fibonacci(n - 1) + fibonacci(n - 2));
}

int main(void) {
cout << fibonacci(6) << endl;
return 0;
}

记斐波那契序列的第项为斐波那契序列具有如下性质

筛法(Sieve Theory)

筛法是素性判断的基本方法

埃拉托斯特尼筛法(Sieve of Eratosthenes)

简称埃氏筛即从2开始将每个素数的各个倍数标记为合数

欧拉筛法(Sieve of Euler)

在埃氏筛的基础上用且仅用最小质因子筛出合数避免重复标记

1
2
3
4
5
6
7
8
9
10
11
12
13
bool isPrime[MAX];
std::vector<int> prime;
void primeSieve(int n) {
for(int i = 2; i <= n; ++i) isPrime[i] = 1;
for(int i = 2; i <= n; ++i) {
if(isPrime[i]) prime.push_back(i);
for(auto j : prime) {
if(i * j > n) break;
isPrime[i*j] = 0; // 埃氏筛
if(!(i % j)) break; // 欧氏筛
}
}
}

莫比乌斯筛计算各整数的莫比乌斯函数的值可以和素数一起筛

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int mu[MAX];
bool vis[MAX];
std::vector<int> prime;
void muSieve(int n) {
mu[1]=1;
for(int i = 2; i <= n; ++i){
if(!vis[i]) mu[i] = -1, prime.push_back(i);
for(auto j : prime) {
if(i * j > n) break;
vis[i*j] = 1;
if(!(i % j)) break;
else mu[i*j] = -mu[i];
}
}
}

数论函数(Number-Theoretic Function)

又称算术函数(Arithmetic Function)指定义域为正整数的函数

积性函数(Multiplicative Function)是对的数论函数常见的积性函数如下

  • 除数函数(Divisor Function)
  • 单位函数(Unit Function)
  • 恒等函数(Identity Function)
  • 欧拉函数(Euler's Totient Function)

完全积性函数(Completely Multiplicative Function)是对任意的数论函数

狄利克雷卷积(Dirichlet Product)

狄利克雷卷积是数论函数间的一种运算数学形式如下

狄利克雷卷积的代码实现如下

1
2
3
4
5
void dirichlet(int n) {
for(int i = 1; i <= n; ++i)
for(int j = 1; j * i <= n; ++j)
h[i*j] += f[i] * g[j];
}

狄利克雷卷积满足交换律结合律和分配律两积性函数的狄利克雷卷积仍是积性函数

莫比乌斯函数(Möbius Function)

莫比乌斯函数的数学形式如下

莫比乌斯函数是积性函数且满足

二进制取幂(Binary Exponentiation)

又称快速幂

1
2
3
4
5
6
7
8
9
10
LL mod = 10007;
LL quickpow(LL a, LL b) {
LL re = 1;
while(b) {
if(b & 1) re = (re * a) % mod;
b >>= 1;
a = (a * a) % mod;
}
return re % mod;
}

循环右移(ROR; Rotate Right)

倒置法实现循环右移的代码如下

1
2
3
4
5
6
7
8
9
10
11
void reverse(int* arr, int begin, int end) {
--end;
while(begin < end) swap(arr, begin++, end--);
}
void ror(int* arr, int n, int k) {
k %= n;
if(k == 0) return;
reverse(arr, 0, n);
reverse(arr, 0, k);
reverse(arr, k, n - k);
}

汉诺塔(Tower of Hanoi)

可将一个搬动个盘子的问题转换为两个搬动个盘子的问题

1
2
3
4
5
6
7
8
enum Tower { A = 'A', B = 'B', C = 'C' };
void Hanoi(int n, Tower x, Tower y, Tower z) {
if(n) {
Hanoi(n - 1, x, z, y);
// 移动塔x顶部的盘子至塔y顶部
Hanoi(n - 1, z, y, x);
}
}

霍纳法则(Horner's Rule)

又称秦九韶算法用最少的乘法来计算多项式在点的取值

若有如下的多项式 由霍纳法则可得如下结果

尺取法(しゃくとり法)

尺取法名称源于日本的尺蠖虫(シャクトリムシ)其行动时身体屈伸如拱桥尺取法又称双指针技巧(Two-Pointer Technique)通过移动左右端点来遍历一个数列从而求解一个最短区间的算法一般而言尺取法的时间复杂度为

例如求正整数序列的最短连续子序列使各元素和大于等于某值区间和小于该值时右端右移以使满足条件区间和大于等于该值时左端右移更新答案

弗洛伊德的乌龟和兔子(Floyd's Tortoise and Hare)

又称快慢指针(Fast & Slow Pointers)快指针(Fast Pointer)即兔子每次移动两步慢指针(Slow Pointer)即乌龟每次移动一步如果链表存在环那么快慢指针一定会相遇

下例在长度为且元素位于区间中的数组内找出存在且仅存在一个的重复元素根据鸽巢原理(Pigeonhole Principle)(即只鸽子被关在个鸽笼中至少有一个笼子有至少两只鸽子)该数一定存在

1
2
3
4
5
6
7
8
9
10
11
12
def find(arr):
tortoise = arr[0]
hare = arr[0]
while True:
tortoise, hare = arr[tortoise], arr[arr[hare]]
if tortoise == hare: break
ptr1 = arr[0]
ptr2 = tortoise
while ptr1 != ptr2:
ptr1 = arr[ptr1]
ptr2 = arr[ptr2]
return ptr1

快慢指针相遇后使一指针指向头另一指针指向相遇点每次移动一步能在环的入口处再相遇此例中环的入口即重复的元素

快速输入输出(Fast I/O)

1
2
3
4
5
6
7
8
9
10
11
12
13
int read() {
int x = 0, sign = 1;
char c = getchar();
while(c > '9' || c < '0') {
if(c == '-') sign = -1;
c = getchar();
}
while(c >= '0' && c <= '9') {
x = x * 10 + c - '0';
c = getchar();
}
return x * sign;
}