Linear List

线性表综述

线性表:n个数据元素的有限序列

  • 元素个数n定义为线性表长度,n=0时称为空表
  • 非空表中每个元素都有确定的位置

顺序存储结构

顺序存储结构/映像:使用一组地址连续的存储单元依次存储数据 元素

  • 具体参见algorithm/data_structure_intro

顺序表

顺序表:顺序存储结构的线性表

1
2
3
4
5
typedef struct{
ElemType *elem;
int length;
int listsize;
}SqList;
  • 以元素在计算机内物理位置相邻表示线性表中数据元素之间 的逻辑关系

  • 每个数据元素存储位置和线性表起始位置,相差和其在线性表中 位序成正比常数,所以顺序表时一种随机存取存储结构

    • 高级程序语言中数组类型也有随机存取特点,因此通常 用数组描述

时间效率

  • 插入/删除:$O(n)$
    • 主要时间耗费在移动元素上
  • 求表长:$O(1)$
  • 取第n个元素:$O(1)$

链式存储结构

链式/非顺序存储结构/映像:用一组任意存储单元存储线性表 中数据元素,还需存储一个指示其直接后继的信息

  • 具体参见algorithm/data_structure_intro

(线性/单)链表

线性链表:n个节点链接而成

1
2
3
4
typedef struct LNode{
ElemType data;
struct LNode * next;
}LNode, *LinkList;
  • 链表中每个节点只包含一个指针域
    • 数据元素之间的逻辑关系由节点中指针指示,即指针为数据 元素之间的逻辑关系映像
  • 整个链表存取必须从头指针开始
  • 单链表中任何两个元素存储位置之间没有固定联系,是非随机 存取存储结构
  • 头指针:指示链表中第一个节点的存储位置
  • 头节点:在单链表第一个节点之前附设的节点,其数据域可以 不存储信息,也可以存储线性表长度、尾指针等附加信息

时间效率

  • 插入、删除:$O(n)$
    • 已知插入、删除元素确切位置的情况下,仅需修改指针, 而不需要移动元素
  • 取第n个元素:$O(n)$
    • 访问时间依赖元素在列表中位置

说明

  • 在很多场合下,链表是线性表的首选结构

    • 链表不需要事先分配任何存储空间,空间利用合理
    • 插入、删除效率高,只需要重连相关指针
  • 但是存在一些实现问题

    • 求线性表长不如顺序存储结构
  • 链表中结点关系使用指针表示,数据元素在线性表中“位序” 概念淡化,被“位置”代替

因此重新定义带头结点的线性链表

1
2
3
4
5
6
7
8
typedef struct LNode{
ElemType data;
struct LNode * next;
}*Link, * Position;
typedef struct {
Link head, tail;
int len;
}

单链表注意事项

  • 头结点/头指针:处理链表非常方便的技巧

    • 头指针指向链表首个结点:便于调整首个节点位置时,仍然 记住链表
    • 头指针不包含链表信息,本质不属于链表:有些情况下方便 统一代码,不需要特殊考虑链表首个节点
  • 对链表进行可能改变链表的遍历操作:一般使用两个标记 结点/指针

    • 头结点/指针lstart:记住链表
    • 遍历标记指针cur:标记处理结点
  • 交换节点:标记指针需要指向当前处理节点的前一个结点

    • 单链表中只有指向下个节点的指针,若标记指针指向当前 节点,则无法方便将链表同当前节点断开、重连
    • 注意待交换两个节点为同一节点的情况:不同于值交换 ,这种情况可能导致链表错误连接成环
  • 无指针、纯引用对象语言(python)中:只能使用节点对象 遍历链表

    • 变量为链表中节点引用:使用类似普通指针,需要注意 别修改引用节点数据
    • 变量为额外节点引用:内存类似普通指针,使用时注意 解析引用次数

静态链表

静态链表:使用数组描述的链表

1
2
3
4
typedef struct{
ElemType data;
int cur;
}component, SLinkList[MAXSIZE];
  • 数组分量表示节点
  • 使用游标cur作为指针域指示节点在链表中的逻辑位置
  • 第0个分量表示头节点,其指针域cur指向链表第一个节点
  • 方便在无指针高级语言中使用链式结构
  • 为确定未使用的数组分量,可以将未被使用的、删除的分量用 游标链成备用边表

Circular Linked List

循环链表:表中最后一个节点指针域指向头节点,整个链表形成环

  • 循环链表和线性链表操作基本一致
    • 仅循环条件不再是指针域为空,而是是否等于头指针

Double Linked List

双向链表:链表中有两个指针域,分别指向直接后继、直接前驱

1
2
3
4
5
typedef struct DuLNode{
ElemType data;
Struct DulNode * prior;
Struct DulNode * next;
}DuLNode, * DuLinkList;
  • 双向链表克服线性链表寻找直接前驱时间$O(n)$的缺点
  • 双向链表大部分操作和线性链表相同,指示有些操作需要同时 修改两个指针
  • 字符串:数组实现的一种数据结构
    • 字符串常见操作不同于其他数组
      • 计算字符串长度
      • 按照字典序确定字符串排序时位置
      • 连接字符串构

图算法

总述

  • 图的遍历算法:如何一次访问到网络中所有节点
  • 最短路线算法:两个城市间最佳路线
  • 有向图拓扑排序:课程、预备课程是否有矛盾
  • All-Pairs Shortest-Paths Problem:完全最短路径问题,找到 每个顶点到其他所有顶点的距离

遍历算法

深度优先查找(DFS)

算法

  • 任意顶点开始访问图顶点,然后标记为已访问

  • 每次迭代时,紧接着处理与当前顶点邻接的未访问顶点, 直到遇到终点,该顶点所有邻接顶点均已访问过

  • 在终点上,算法沿着来路后退一条边,继续从那里访问未 访问顶点

  • 后退到起始点,且起始点也是终点时,算法停止,这样 起始点所在的连通分量的所有顶点均已访问过

  • 若存在未访问顶点,则必须从其中任一顶点开始重复上述

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
count = 0
// 全局变量:访问次序(次数)
DFS(G)
// 对给定图的深度优先查找遍历
// 输入:图G=<V, E>
// 输出:图G顶点按照DFS遍历第一次访问到的先后次序,
// 未访问到标记未0
for each vertex v in V do
if v is marked with 0
dfs(v)
dfs(v)
// 递归访问所有和v相连接的未访问顶点,赋予count值
count = count+1
mark v with count
for each vertex w in V adjecnt to v do
if w is marked with 0
dfs(w)

特点

  • 算法效率非常高效,消耗时间和表示图的数据结构规模成正比

    • 邻接矩阵:遍历时间效率$\in \Theta(|V|^2)$
    • 邻接链表:遍历时间效率$\in \Theta(|V|+|E|)$
  • 可以方便地用栈跟踪深度优先查找

    • 首次访问顶点,将顶点入栈
    • 当顶点成为终点时,将其出栈
    • 运行时就是实际上就是栈,所以深度优先可以直接利用递归 实现
  • Depth-First Search Foreat:参见 algorithm/data_structure/graph

  • DFS产生两种节点排列顺序性质不同,有不同应用

    • 入栈(首次访问顶点)次序
    • 出栈(顶点成为终点)次序

应用

  • 检查图连通性:算法第一次停止后,是否所有顶点已经访问
  • 检查图无环性:DFS是否包含回边
  • 拓扑排序:见键值法
    • DFS节点出栈逆序就是拓扑排序的一个解(图中无回边, 即为有向无环图)
    • DAG中顶点v出栈前,不存在顶点u拥有到v的边,否则存在 回边,图不是DAG

广度优先查找(BFS)

算法

  • 首先访问所有和初始顶点邻接的顶点

  • 然后是离它两条边的所有未访问顶点

  • 以此类推,直到所有与初始顶点在同一连通分类顶点均已访问

  • 若存在未访问顶点,从图其他连通分量任意顶点开始

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
count = 0
// 全局变量:访问次序(次数)
BFS(G)
// 给定图广度优先查找变量
// 输入:图G=<V, E>
// 输出:图G的顶点按照被BFS遍历第一次访问到次序,
// 未访问顶点标记未0
for each vertax v in V do
if v is marked with 0
bfs(v)
bfs(v)
// 访问所有和v相连接的顶点,赋count值
count = count+1
whilte queue is not empty do
for each vertex w in V adjcent to the front vertex do
if w is marked with 0
count = count+1
mark w with count
add w to the queue
remove the front vertex from the queue

特点

  • 算法效率同DFS

    • 邻接矩阵:遍历时间效率$\in \Theta(|V|^2)$
    • 邻接链表:遍历时间效率$\in \Theta(|V|+|E|)$
  • 使用队列可以方便地跟踪广度优先查找操作

    • 从遍历初始顶点开始,标记、入队
    • 每次迭代时,算法查找所有和队头顶点邻接未访问,标记 、入队、将队头顶点出队
  • Breadth-First Search Forest:参见 algorithm/data_struture/graph

  • BFS只产生顶点的一种排序,因为队列时FIFO结构,顶点入队、 出队次序相同

应用

  • 和DFS一样可以检查图的连通性、无环性,但是无法用于比较 复杂的应用

  • 求给定两个顶点间最短路径:从一顶点开始BFS遍历,访问到 另一节点结束(难以证明?)

有向图强连通分量

Kosaraju算法

考虑有向图中强连通分量之间不连通的情况

  • 强连通分量之间没有边

    • 在任意连通分量中任意结点开始深度优先遍历

    • 访问完所有结点需要DFS次数就是强连通分量数量,每轮 DFS访问的点就是强连通分量中的顶点

  • 强连通分量之间只有单向边

    • 将强连通分量视为单个结点,则整个图可以视为一个 靠连通分量间单向边连接的有向无环图

    • 从最底层强连通分量中任选结点开始进行DFS,则此轮DFS 只能访问当前连通分量中结点

    • 逆序依次在各强连通分量中选择结点进行DFS,则每轮DFS只 访问当前连通分量中结点(其下层连通分量已访问)

    • 直至所有结点访问完毕,则得到所有强连通分量,即每轮 进行DFS访问的结点

    • 以下图为例,从图中连通分量B中任意结点开始进行DFS, 则经过两轮DFS即能找所有强连通分量

      strongly_connected_two_components

由以上分析

  • 只需要保证底层强连通分量进行DFS优先搜索

  • 也即在结点搜索优先级中,底层强连通分量中至少有一个结点 在其上层连通分量所有结点之前

  • 可以利用原图的反向的DFS逆后序排列得到满足条件 的结点优先级序列

    strongly_connected_two_components_reversed

    • 若从反向图中最底层强连通分量某结点开始,则只能遍历 自身,反向图中其余连通分量位于其所有结点之前

    • 若从反向图中非最底层强连通分量某结点开始,则能依次 遍历其底层所有强连通分量中结点,且至少该结点位于其余 连通分量所有结点之前

  • 逆后序排列参见algorithm/data_structure/graph

算法

  • 对原图G每条路径求反,得到反向图$G^R$
  • 对反向图$G^R$求解逆后序序列
  • 按照逆序序列优先级,对原图G进行DFS,每棵DFS生成树就是 一个强连通分量

特点

  • 算法效率
    • 时间效率$\in O(|V| + |E|)$
    • 算法需要对图进行两次DFS,速度较Tanjar算法更慢

Tarjan算法

Tarjan算法:基于图深度优先搜索的算法

  • 为每个结点维护两个标记,通过此标记确定是否存在回路

    • DFN:深度优先搜索中搜索到次序
    • Low:通过回边能访问到的前驱被搜索到的次序
    • 还可以维护一个Flag,判断结点是否仍在DFS栈中
  • 对图进行深度优先搜索

    • 未处理结点入栈,设置其DFNLow被搜索到的次序

    • 对已处理结点,考虑到深度优先的搜索、退栈方式

      • 仍然在栈中,则肯定是栈顶元素前驱,连接边为回边, 存在栈顶节点到该前驱结点的回路

      • 不在栈中,该节点不是祖先结点,连接边为交叉边, 该结点已经在其他连通分量中出栈

    • 使用栈中前驱结点Low/DFN次序更新当前(栈顶)结点, 并递归更新,即使用子节点访问先驱次序更新父节点

  • DFS回溯、退栈,考虑栈中每个结点DFNLow

    • DFN[u] > Low[u]:结点u和其前驱之间有回路, 即其属于同一个强连通分量

    • DFN[u] == Low[u]:结点u和其前驱之间没有通路, 没有更多结点属于其所属强连通分量,以结点u为根子树 是一个强连通分量

      • 则从栈顶元素开始退栈直至结点u退栈,退栈的所有 元素构成强连通分量
  • 每个强连通分量为深度优先搜索树中一个子树

  • $w, (w, v) \in E$:顶点v的直接前驱
  • $k, (v, k) \in E$:顶点v的祖先(即栈中结点)

算法

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
S = initStack()
DFN[MAX_VERTAX], Low[MAX_VERTEX]
index = 0
QList = InitList(Queue())

tarjan(u, E):
// 比较DFS搜索次序、回边到达次序判断强连通分量
// 输入:结点u,边集合E
// 输出:强连通分量队列列表
DFN[u] = Low[u] = ++ index
S.push(u)
for each (u, v) in E:
if (v is not visited):
tarjan(v)
Low[u] = min(Low[u], Low[v])
// 使用v找到的前驱更新u能找到前驱,递归更新
else if (v in S):
// 判断边是否为回边
Low[u] = min(Low[u], DFN[v])
// Low[u] = min(Low[u], Low[v])
// 应该也行
if(DFN[u] == Low[u]):
Q = QList.next()
repeat
v = S.pop()
Q.push(v)
until (u == v)

特点

  • 算法效率
    • 时间效率$\in O(|V|+|E|)$

关节点

类Tarjan算法

  • 类似Tarjan算法为每个节点维护DFNLow两个次序
    • 对非根结点v,存在其直接后继w有Low[w] >= DFN[v] ,则v为关节点
    • 对根节点,有两棵以上子树则为关节点

算法

此算法具体实现和Tarjan算法细节有差异

  • 此算法中不需要使用栈保存访问过顶点中是前驱者
    • 连通无向图DFS只会有回边,已访问点必然是前驱结点
  • 需要对根结点额外判断是否为关节点
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
DFN[MAX_VERTAX], Low[MAX_VERTEX]
index = 0
Q = InitQueue()

FindArticul(G):
// 输入:无向连通图G
// 输出:关节点队列
vroot = G.V.pop()
TarjanArticul(vroot, G)
for(v in G.V if v not visited)
// 根节点有两棵及以上子树
TarjanAricul(vroot, G)
Q.push(vroot)
// 根节点也是关节点
return Q

TarjanArticul(u, G):
// 比较DFS搜索次序、回边到达次序判断关节点
// 输入:结点u,无向连通图G
// 输出:关节点队列
DFN[u] = Low[u] = ++ index
for each (u, v) in G.E:
if (v is not visited):
tarjan(v)
Low[u] = min(Low[u], Low[v])
// 使用v找到的前驱更新u能找到前驱,递归更新
else:
Low[u] = min(Low[u], DFN[v])
// Low[u] = min(Low[u], Low[v])
// 应该也行
for(v connected by u)
if(Low[v] <= DFN[u])
Q.push(u)
return Q

无权路径

路径数量

图中顶点i到顶点j之间长度为k的不同路径数量为$A^k[i, j]$

  • A为图的邻接矩阵
  • 可以使用数学归纳法证明
  • 对无向、有向图均适用

Warshall算法

Warshall算法:生成有向图传递闭包

  • 构造n+1个n阶布尔矩阵$R^{(k)}, k=0,1,\cdots, n$

    • $R^{(k)}_{ij}=1$:顶点i、j直接存在中间顶点编号 不大于k的有效路径

    • $R^{(0)}$:邻接矩阵,顶点直接连接

    • $R^{(k)}, 0<k<n$:路径中间顶点编号最大为k

    • $R^{(n)}$:传递闭包,允许所有类型路径

    • 后继矩阵相对前趋,允许作为路径上顶点增加,可能包含 1数量更多

  • 考虑$R^{(k)}$通过直接前趋$R^{(k-1)}$计算得到

    • $R^{(k-1)}$中已有路径在$R^{(k)}$保留

    • 考虑$R^{(k)}$相较于$R^{(k-1)}$新增$r_{ij}=1$

      • 表示顶点i、j之间存在包含k的路径

      • 若k在路径中出现多次,则将删除回路,得到新路径

      • 则存在ik和kj之间路径满足中间顶点编号小于k,即在 $R^{(k-1)}$中有$r{ik}=1, r{kj}=1$

算法

  • 若元素$r_{ij}$在$R^{(k-1)}$中为1,则在$R^{(k)}$也是1

  • 若元素$r{ij}$在$R^{(k-1)}$中为0,当且仅当存在v使得 $R^{(k-1)}$中$r{iv}=1, r_{vj}=1$

1
2
3
4
5
6
7
8
9
10
11
12
Warshall(A[1..n, 1..n])
// 计算传递闭包的Warshall算法
// 输入:A[1..n, 1..n]包含n个顶点的有向图的邻接矩阵
// 输出:A的传递闭包
R^0 = A
for i = 1 to n do
for i = 1 to n do
for j = 1 to n do
if R^(k-1)[i, j] == 1 or
(R^(k-1)[i, k] == 0 and R^(k-1)[k, j] == 0)
R^k[i, j] = 1
return R^n

算法特点

  • 算法效率

    • 时间效率$\in \Theta(n^3)$
      • 重新构造最内层循环,可以提高对某些输入的处理速度
      • 将矩阵行视为位串,使用或运算也可以加速
    • 空间效率取决于如何处理布尔矩阵
  • 蛮力法:所有点分别作为起点作一次搜索,记录能够访问的顶点

    • 对有向图遍历多次
    • 使用邻接链表表示稀疏图,蛮力法渐进效率好于Warshall算法

最小生成树

Prim算法

Prim算法:求解最小图最小生成树算法

  • 每次添加距离当前树距离最近顶点进树
  • 不断迭代构造最小生成树

算法

  • 从图顶点集V中任选单顶点作为序列中初始子树

  • 对图中顶点维护两个标记:树中最近顶点、相应距离

    • 与树不直接相连顶点置:NULL、$\infty$
    • 每次添加新节点更新两个标记
    • 可使用优先队列维护提高效率
  • 以贪婪的方式扩张当前生成树,添加不在树中的最近顶点

  • 更新顶点和树距离最近的顶点、相应距离

    • 只需考察与新添加顶点直接相连顶点即可
  • 不断迭代直到所有点都在树中

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
Prim(G):
// 构造最小生成树Prim算法
// 输入:加权连通图G=<V, E>
// 输出:E_T, 组成G最小生成树的边集合
V_T = {v_0}
// 使用任意顶点初始化树顶点集合
E_T = NULL
// 初始化生成树边为空集

for i = 1 to |V|
if i connect V_T
connect_V[i] = 0
connect_D[i] = e(0, i)
else
connect_V[i] = NULL
connect_D[i] = \infty
// 初始化节点和树最近节点列表、节点与树距离列表

for i = 1 to |V|-1 do
// 重复n-1次,直到树包含所有顶点
edge = min(connect_D)
// 寻找距离树最近的点
v = vertex(edge)

V_T = V_T union {v}
E_T = E_T union {edge}

connect_V[v] = NULL
connect_D[v] = \infty
更新和v相连的顶点两个标记值
return E_T

算法特点

  • 算法时间效率依赖实现优先队列、存储图数据结构

    • 图权重矩阵、优先队列无序数组$\in \Theta(|V|^2)$
    • 图邻接链表、二叉最小堆$\in O(|E|log|V|)$
    • 图邻接链表、Fibonacci Heap $\in O(|E| + |V|log|V|)$
  • 对树进行扩展时用到的边的集合表示算法生成树

  • 穷举查找构造生成树,生成树数量呈指数增长,且构造生成树 不容易

Kruskal算法

Kruskal算法:把最小生成树看作是具有$|V|-1$条边、且边权重最小 的无环子图,通过对子图不断扩展构造最小生成树

算法

  • 按照权重非递减顺序对图中边排序

  • 从空子图开始扫描有序列表,试图把列表中下条边加到当前子图 中,如果添加边导致回路则跳过

  • 不断添加边直到达到$|V|-1$

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Kruskal(G)
// 构造最小生成树的Kruskal算法
// 输入:G=<V, E>加权连通图
// 输出:E_T,组成G的最小生成树边集
reverse_sort([w(e_i)])
// 按照边权非递减顺序对边集排序
E_T = NULL
ecounter = 0
k = 0
while ecounter < |V|-1 do
k += 1
if E_T union {e_k} 无回路
// 常用并查算法检查`e_k`连接的两个顶点是否在
// 同一棵树(并查集)中
E_T = E_T union {e_k}
ecounter += 1
return E_T

算法特点

  • Kruskal每次迭代都需要检查添加新边是否会导致回路,其实 效率不一定比Prim算法高

  • Kruskal算法中间阶段会生成一系列无环子图(树)

    • 子图不总是联通的
    • 可以看作是对包含给定图所有顶点、部分边的森林所作的 连续动作
    • 初始森林由|V|棵普通树构成,包含单独顶点
    • 最终森林为单棵树,包含图中所有顶点
    • 每次迭代从图的边有序列表中取出下条边,找到包含其端点 的树,若不是同一棵树,则加入边生成一棵更大的树
  • 算法效率

    • 如果检查顶点是否位于同一棵树算法高效,则算法运行时间 取决于排序算法,时间效率$\in O(|E|log|E|)$

Sollin算法

Sollin算法:Prim算法、Kruskal算法的结合,将图每个顶点视为 子树,每次添加多条边合并子树直至得到最小生成树

算法

  • 将图中每个顶点视为一棵树,整个图表示森林$F^{(0)}$
  • 为森林$F$中每棵树选择最小代价边合并两棵树
  • 重复以上,直至所有树合并为一棵树
1
2
3
4
5
6
7
8
9
10
11
12
Sollin(G):
// 无向图最小生成树Sollin算法
// 输入:无向图G
// 输出:最小生成树边集
MST_E = NULL
Forest = G.V
while |MST_E| < |G.V|:
for tree in Forest:
e = find_min(G.E)
tree_b = get_tree(e)
MET_E.add(e)
tree.union(tree_b)

todo

算法特点

  • 算法效率
    • 每轮子树数量减少一半,则最多重复log|V|轮算法终止
    • 时间效率$\in O(|E|log|V|)$

最短路径

Dijkstra算法

Dijkstra算法:求解单起点、权值非负最短路径算法

  • 按照从给定起点到图中各顶点的距离,顺序求出离起始点 最近的顶点、相应最短路径

  • 第i次迭代前,算法已经确定了i-1条连接起点、离起点前i-1近 顶点的最短路径

    • 这些构成了给定图的一棵子树$T_i$
    • 可以在同$T_i$顶点邻接的顶点中找到和起点最接近的顶点 (边权非负)
  • 算法类似于Prim算法,两个对代价评价标准不同

    • Dijkstra算法是各条路径长度:有重复边,考虑整个路径
    • Prim算法是评价各边总和:无重复边,只考虑一条边

算法

  • 对顶点维护两个标记:起点到该顶点最短路径长度d、路径上 前个顶点pre_v

    • 一般使用优先队列维护最短路径长度
    • 对所有顶点维护:$\infty$、NULL标记不在树中、不与树 邻接顶点
    • 仅对生成树中顶点、邻接顶点维护:每次迭代更新列表
  • 根据标记选择邻接顶点中和起始点距离d最小顶点,添加进树

  • 更新顶点标记

    • 因为生成树只新添加一个顶点,只需要考虑与新添加顶点 直接相连、未在树中顶点
    • 比较与起始点距离是否改变
  • 不断迭代直至所有点均在树中

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
Dijkstra(G, s)
// 单起点最短路径Dijkstra算法
// 输入:G=<V, E>非负权重加权连通图,顶点s起始点
// 输出:对V中每个顶点,从s到v的最短路径长度d_v
Initialize(Q)
// 将顶点优先队列初始化为空
for v in V
d_v = \infty
p_v = NULL
Insert(Q, s, d_s)
// 初始化有限队列中顶点优先级
d_s = 0
Decrease(Q, s, d_s)
// 更新s优先级为d_s
V_T = NULL

for i = 0 to |V|-1 do
u* = DeleteMin(Q)
// 删除优先级最小元素
V_T = V_T \union {u*}
for v in V-V_T 中与u*邻接顶点 do
if d_u* + w(u*, u) < d_u
d_u = d_u* + w(u*, u)
p_u = u*
Decrease(Q, u, d_u)

算法特点

  • 算法时间效率同Prim算法

Bellman-Ford算法

Bellman-Ford算法:求解单节点、权值正负无限制最短距离

  • 权值正负无限制意味着贪心策略不再有效
  • 要求路径中不存在负权值回路
  • 对n个顶点图,路径最长为n-1,否则删除回路路径长度不增加

算法

考虑使用动态规划算法

  • 令$dist^{(l)}[u]$表示从起点v到节点u边数不超过l的最短 路径长度

    • 在不允许出现负权值回路的前提下,构造最短路算法过程 最多只需要考虑n-1条边

    • 即$dist^{(n-1)}$是从v到u不限制路径中边数目的最短路径 长度

Floyd算法

Floyd算法:求解完全最短路径问题,有向、无向、加权图均适用 (边距离不为负,否则距离可以任意小)

  • 构造n+1个距离矩阵$D^{(k)}, k=0,1,\cdots,n$

    • $D^{(k)}$中元素$d_{ij}$表示顶点i、j之间由编号小于k的 顶点作为中间顶点的距离

    • $D^{(0)}$:初始权重矩阵

    • $D^{(k)}, 0<i<n$:路径中顶点编号最大为k

    • $D^{(n)}$:目标距离矩阵

    • 后继矩阵相对前趋,允许作为路径上顶点增加,各顶点间 距离可能缩短

  • 考虑$D^{(k)}$通过直接前趋$D^{(k-1)}$计算得到,其中距离 (路径)分为两类

    • $d^{(k)}{ij} = d^{(k-1)}{ij}$:不包含顶点k作为中间 节点的路径

    • $d^{(k)}{ij} = d^{(k-1)}{ik} + d^{(k-1)}{kj} < d^{(k-1)}{ij}$: 包含顶点k作为中间节点的路径

算法

1
2
3
4
5
6
7
8
9
10
Floyd(W[1..n, 1..n])
// 计算完全最短路径的Floyd算法
// 输入:W不包含负距离的距离矩阵
// 输出:包含最短距离的距离矩阵
D^0 = W
for k = 1 to n do
for i = 1 to n do
for j = 1 to n do
D[i, j] = min(D[i, j], D[i, k] + D[k, j])
return D

算法特点

  • 算法效率

    • 时间效率同Warshall算法为立方级
    • 如上伪码的空间效率为平方级(没有创建n+1距离矩阵)
  • Floyd算法类似于Warshall算法

  • Floyd算法利用最优性原理,即最短路径中子路径也是最短

最大流量问题

Augmenting-Path Method

Shortest-Augmented-Path算法

最短增益路径法(first-labeled first-scanned algorithm

  • 对网络中顶点维护两个标记

    • 从源点到被标记顶点能增加流量数
    • 路径中前个顶点名称
      • +:从前向边访问到当前顶点
      • -:从后向边访问到当前顶点
  • 对网络的每条边$(i, j)$,初始化流量为$x_{ij}=0$

  • 从源点开始同时沿着前向边、后向边进行广度优先搜索

    • 先更新前向边
    • 只有有增益空间边(顶点)才能被访问
    • 更新搜索到顶点标记
  • 源点被标记表明得到一条增量路径,沿着标记反向更新边流量

  • 若广度优先搜索无法达到源点,表明不存在流量增益路径,当前 流量值作为最大值返回

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
ShortestAugmentingPath(G)
// 最短增量路径算法
// 输入:流量网络G
// 输出:最大流量x
对网络中每条边,设置x[i, j] = 0
把源点标记为(\infty, -),加入空队列Q中
// 使用队列实现广度优先搜索
while not Empty(Q) do
i = Front(Q)
Dequeue(Q)
for 从i到j的每条边 do
// 遍历从i出发的边,前向边
if j未被标记
r[i, j] = = u[i, j] - x[i, j]
if r[i, j] > 0
l[j] = min{l[i], r[i, j]}
/// 更新从源点到顶点j能增加的流量数
用l[j], i+标记j
Enqueue(Q, j)
for 从j到i的每条边 do
// 遍历到达i的边,后向边
if j未被标记
if x[j, i] > 0
l[j] = min{l[i], x[j, i]}
// 更新源点到顶点j能增加的流量数
用l[j], i-标记j
Enqueue(Q, j)
if 汇点被标记
// 沿着找的增益路径进行增益
j = n
while j != 1
// 反向更新到源点为止
if 顶点j前个节点为i+
// 通过前向边访问到顶点j
x[i, j] = x[i, j] + l[n]
else
x[j, i] -= l[n]
j = i
去除除源点外所有顶点标记
重新初始化化队列Q

算法特点

  • 算法正确性可以(联合)最大流-最小割定理证明

  • 算法时间效率

    • 可以证明最短增益路径算法用到的增益路径数量不超过 $|V||E|/2$
    • 对使用邻接列表表示的网络,用广度优先查找找到一条增益 路径的时间$\int O(|V|+|E|)$
    • 所有算法时间效率$\in O(|V||E|^2)$
  • 迭代算法

Preflow推进算法

预流:满足容量约束,但是不满足流量守恒约束

  • 把过剩流量向汇点处移动,直到网络所有中间顶点都满足流量 守恒约束为止

算法特点

  • 算法时间效率
    • 这类算法中较快者最差效率可以接近$O(|V||E|)$

Dinitz算法

Karzanov算法

Malhotra-Kamar-Maheshweari算法

Goldberg-Tarjan算法

单纯形法

此问题仍然是线性规划问题,可以使用单纯形法等通用解法求解

最大匹配(二分图)

匈牙利算法

算法

  • 对U中每个顶点维护一个标记:与其匹配的对偶顶点

  • 从V中的一个自由顶点v出发,按广度优先搜索找到U中自由 顶点u,寻找增益路径,搜索过程中

    • V中顶点:按照广度优先搜索,得到不在匹配M中的边

      • 搜索到U中自由顶点,则停止得到增益路径
      • 搜索到U中被标记顶点,则连接上已有匹配
    • U中顶点:直接找到其在V中的对偶顶点,得到在M中边

  • 得到一个增益路径,沿着增益路径回溯,奇数边加入匹配

  • 未找到自由顶点时,则无法得到增益路径

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
MaximumBipartiteMatching(G)
// 用类似广度优先算法遍历求二分图的一个最大匹配
// 输入:二分图G=<V, U, E>
// 输出:输入图中一个最大基数匹配
初始边集合M包含某些合法的匹配(例如空集合)
初始队列Q包含V的所有自由顶点(任意序)
while not Empty(Q) do
w = Front(Q)
Dequeue(Q)
if w \in V
for 邻接w的每个顶点u do
// 二分图性质保证u一定在U中
if u是自由顶点
// 增益
M = M \union (w, u)
// 首边进匹配
v = w
while v已经被标记 do
// 从增益路径回溯生成匹配
u = 以v标记的点
M -= (v, u)
// 偶数边出匹配
v = 以u标记的点
M += (v, u)
// 奇数边进匹配
删除所以顶点标记
用V中所有自由顶点重新初始化Q
break
// 增益后,重新搜索
else
// u已经匹配
if (w, u) not \in M and u未标记
用w标记u
Enqueue(Q, u)
else
// w \in U,此时w必然已经匹配
用w标记w的对偶v
// 将已有匹配添加进增益路径中
Enqueue(Q, v)
return M
// 当前匹配已经是最大匹配

算法特点

  • 注意:从自由顶点开始寻求匹配时,无论是否找到增益路径, 路径中中U中节点标记已经更新,匹配仅在得到增益路径才更新

  • 算法时间效率

    • 每次迭代花费时间$\in O(|E|+|V|)$,迭代次数 $\in O(|V|/2 + 1)$
    • 若每个顶点的信息(自由、匹配、对偶)能在常数时间内 得到(如存储在数组中)
    • 则算法时间效率$\in O(|V|(|V| + |E|))$
  • 算法正确性参见图graph_undirected关于增益路径-最大匹配

  • 迭代算法

霍普克罗夫-卡普算法

算法特点

  • 对匈牙利算法的改进,把多次迭代在一个阶段完成,然后用一次 查找把最大数量边添加到匹配中

  • 算法时间效率:$\in O(\sqrt {|V|}(|V| + |E|))$

稳定婚姻问题

婚姻稳定算法

存在自由男士,任选求婚回应之一执行,直至不存在自由男士

  • 求婚:自由男士m向女士w求婚,w为其优先级最大、之前未拒绝 过其女士(可以是已匹配)

  • 回应:若女士w自由则接受男士m求婚,与之配对;女士w不自由 则把m同当前配偶匹配,选择其中优先级较高者

算法

算法特点

  • 算法会在$n^2$次迭代内终止:至多每位男士向所有女士求婚

  • 性别倾向:总是生成man-optimal的稳定匹配,优先满足 男士偏好

    • 在任何稳定婚姻中,总是尽可能把优先级最高的女士分配给 男性
    • 使用女士进行求婚也只会把性别偏见反向,而不能消除
  • 对给定的参与者优先选择集合而言,男士(女士)最优匹配唯一

    • 由性别性别倾向容易证明
    • 所以算法的输出不取决于自由男士(女士)求婚顺序,可以 使用任何数据结构表示参与者集合而不影响结果
  • 算法最终输出匹配M为稳定婚姻匹配证明参见graph

分配问题(二分图)

n个任务分配给n个人执行(一人一个),将任务j分配个人i的成本为 $C_{ijd}$,求最小成本分配方案

  • 类似问题:最大权重匹配问题

蛮力算法

算法

  • 生成整数n的全部排列
  • 根据成本矩阵计算每个分配方案总成本
  • 选择和最小的方案

特点

  • 算法排列次数为$n!$

分支界限法

  • 第i层节点下界可取:$lb = c + \sum_{k=i+1}^n min{c_k}$

    • $c$:当前成本
    • $min{c_k}$:成本矩阵第k行最小值

算法

特点

匈牙利算法

算法

特点

Topological Sorting

拓扑排序:按照次序列出有向图的顶点,使得对图中每条边,其 起始顶点总在结束顶点之前

删点法

算法

  • 在有向图中求出源(没有输出边的顶点),然后把删除其和所有 从它出发的边
  • 不断重复,直到不存在源,如果此时图中还有顶点,则图中存在 环,无解
  • 则删除节点顺序即为拓扑排序可行解
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
TopologicalSort(G):
// 从有向图中不断删除入度为0的点、入栈,判断有向图G是否
// 为DAG,并给出拓扑排序栈
// 输入:有向图G
// 输出:拓扑排序栈T
InitStack(S)
indgree = [v.indegree for v in G]
for v in G:
if v.indgree == 0
S.push(v)
// 存储0入度结点栈
count = 0
// 删除结点计数
while(!S.empty())
v = S.pop()
T.push(v)
count++
for(u connected to v)
if --indgree.u == 0
S.push(u)
if count < |G.V|
// 结点未删除完毕,但无0入度结点
// G中有回路,报错
return ERROR
return T

特点

  • 算法效率
    • 时间效率$\in O(|V|+|E|)$
  • 减常数法

DFS逆后序遍历

图中无环时,由某点出发进行DFS

  • 最先退出DFS的为出度为0的点,即拓扑有序序列中最后顶点

  • 按照DFS退出先后次序得到序列即为逆向拓扑有序序列

    • 使用逆后序方式存储DFS访问顶点,判断是否有环、出栈 次序即为正向拓扑有序序列

应用

  • 判断庞大项目中相互关联任务不矛盾,然后才能合理安排,使得 项目整体完成时间最短(需要CPM、PERT算法支持)

Cirtical Path问题

找出使用AOE网表示的工程的中关键路径

  • 关键路径由关键活动构成
  • 即耗费时间变动对工程整体完成时间有影响的活动

拓扑排序求解

  • 最早、最晚开始时间检查是否为关键活动
  • 建立活动(边)、事件(顶点)发生事时间关系
  • 拓扑排序求解事件发生最早、最晚时间
  • 具体参见algorithm/data_structure/graphdi_specials

算法

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
45
46
TopologicalOrder(G):
// 从有向图中不断删除入度为0的点、入栈,判断有向图G是否
// 为DAG,并给出拓扑排序栈
// 输入:有向图G
// 输出:拓扑排序栈T、顶点事件最早发生事件ve
InitStack(S)
indgree = [v.indegree for v in G]
ve[0..|G.V|] = 0
for v in G:
if v.indgree == 0
S.push(v)
// 存储0入度结点栈
count = 0
// 删除结点计数
while(!S.empty())
v = S.pop()
T.push(v)
count++
for(u connected by v)
ve[u] = max{ve[u], ve[v] + len(v, u)}
// 若有更长路径,更新
if --indgree[u] == 0
S.push(u)
if count < |G.V|
// 结点未删除完毕,但无0入度结点
// G中有回路,报错
return ERROR
return T, ve

CriticalPath(G, T, ve):
// 逆序求顶点事件最晚发生时间,求出关键活动
// 输入:有向无环图G,G拓扑排序
// 输出:关键活动队列Q
vl[0..|G.V|] = ve
Q = InitQueue()
while(!T.empty())
v = T.pop()
for (u connect to v)
vl[u] = min{vl[u], vl[v] - len(u, v)}
for(v in G.V)
for (u connected by v)
ee = ve[v]
el = vl[u] - len(v)
if(el == ee)
Q.push(G.edge(v, u))
return Q
  • 以上算法中在生成拓扑排序栈时同时得到各顶点事件最早发生 时间

  • 可以只获取拓扑排序栈,然后处理其获得顶点事件最早发生时间 ,将两个功能分离,只是处理一遍顶点而已

  • 也可以使用其他算法获得拓扑排序栈

    • DFS遍历甚至可以遍历顶点一遍,同时获得顶点事件最早、 最晚发生时间

特点

  • 算法效率
    • 时间效率$\in O(|V|+|E|)$

哈密顿回路问题

确定给定图中是否在包含一条哈密顿回路

回溯算法

算法

  • 对所有节点维护标记:是否位于当前路径中

  • 选择某节点a作为哈密顿回路起点顶点,即回溯状态空间树根

  • 从根节点开始处理

    • 若节点周围还有未标记节点,选择下个加入路径、标记
    • 若节点周围没有未标记节点,回溯到之前节点重新处理
  • 直到所有节点都被标记,且当前节点和根节点相邻

特点

旅商问题

Traveling Salesman Problem:对相互之间距离已知为正整数的n座 城市,求最短漫游路径,使得在回到出发城市之前,对每个城市只 访问一次

  • 即:对权重为正整数的无向完全图寻找最短哈密顿回路

蛮力算法

算法

  • 生成n-1个中间城市的组合得到所有旅行线路
  • 计算线路长度,求得最短路径

特点

  • 算法排序次数为$(n-1)!/2$

改进

  • 线路成对出现,只是方向相反,可考虑任意两个相邻顶点,只 考虑包含其某个排序的线路

分支界限法

  • 第i层下界可取$lb = \sum{k=i+1}^n d{k1}$

  • 更紧密、也不复杂的下界 $lb = \lceil \frac {\sum{k=i+1}^n (d{k1} + d_{k2})} 2 \rceil$

    • $d{k1}, d{k2}$:城市$i+1$到最近的两个城市距离

    • 最短路径为两个端点共享,至多只能有一个端点能够成为 该边起点

    • 若要求所有哈密顿回路中必须包括某些边,则在考虑相应 边端点城市时,使用必须边(若不是节点最短边)替换其中 次短边

  • 只需要生成某对节点有序的路径:可以消去状态空间树中部分 分支

算法

特点

旅商问题非精确算法

以下均是讨论TSP问题的欧几里得实例,不对称实例等已经证明更难 解决,对精确算法、启发式算法都是如此

贪婪算法

Nearest-Neighbor算法

  • 任意选择城市开始
  • 每次访问和当前城市k最接近的城市,直到访问完所有城市
  • 回到开始城市

Multifregment-Heuristic算法

求给定加权完全图的最小权重边集合,且每个顶点连通度均为2

  • 将边按权重升序排列,将要构造的旅途边集合开始时空集合

  • 不断尝试将排序列表中下条边加入旅途边集合

    • 边加入不会使得某节点连通度大于2
    • 不会产生长度小于n的回路
    • 否则忽略这条边
  • 返回旅途集合

算法特点

对属于欧几里得类型的旅商问题实例(大部分)

  • 此时虽然两个算法的精确性能比无法界定,但是满足 $\frac {f(s_a)} {f(s^{*})} \leqslant \frac 1 2 (\lceil log_2 n \rceil + 1)$

Minimum-Spaning-Tree-Based Algorithm

基于最小生成树的算法

  • 哈密顿回路中去掉一条边就能得到一棵生成树$T_h$
  • 可以先构造一棵最小生成数$T^{*}$,然后在其基础上构造近似 最短路径

Twice-Around-The-Tree算法

  • 对给定实例构造最小生成树$T^{}$(Prim, Kruskal*)
  • 从任意顶点开始,(利用深度优先遍历)绕树散步一周,记录 经过顶点
  • 扫描顶点列表,消去重复出现顶点(走捷径,直接去新城市), 除列表尾部重复起点,得到一条哈密顿回路
  • 可能是考虑到最小生成树能够选出部分最短路径???

特点

对属于欧几里得类型的旅商问题

  • 绕树两周算法是2近似算法:$2f(s^{*}) > f(s_a)$

    • $f(s^{} > w(T_h) \geqslant w(T^{})$:最优哈密顿 回路去掉一条边后长度大于等于最小生成树长度
    • $f(s^{}) < 2w(T^{})$:第二次扫描走捷径距离小于绕树 一周距离
    • 这里限定了特点类型实例,并没有找到对所有旅商问题的 优先近似算法

Christofides算法

同样利用问题与最小生成树的出关系,但更复杂

算法

  • 对给定实例构造最小生成树$T^{*}$
  • 构造包含包含$T^{*}$的欧拉回路
    • 找出最小生成树中所有连通度为奇数的顶点
    • 求出这些顶点的最小权重匹配(匈牙利算法)
    • 将最小权重匹配边加入树中得到多重图欧拉回路
  • 使用走捷径方法将欧拉回路转换为哈密顿回路

特点

对属于欧几里得类型的旅商问题

  • 绕最小生成树一周得到的路径是多重图的一条欧拉回路,其中 多重图为将当前图每条边重复一遍得到

    • 绕树两周算法:直接原始欧拉回路上走捷径
    • Christofides算法:重新构建更短的欧拉回路,在此基础 上走捷径
  • Christofides算法是1.5近似算法

    • 实际应用中,Christofides算法近似解明显好于绕树两周
    • 可以对连通度大于2顶点尝试不同访问次序,即将回路 中邻接顶点分别两两组合,找到访问其的最佳路径

迭代改进算法

Local Search Heuristics:本地查找启发法

  • 这类算法从某个初始旅途(随机或简单近似算法生成)开始

  • 每次迭代把当前旅途一些边用其他边代替,试图得到和当前旅途 稍有差别的旅途

    • 若能得到优于当前旅途的新旅途,则替换当前旅途,继续 寻找
    • 否则,返回当前旅途,停止

2选算法

删除旅途中2条非临边,把两条边端点用另一对边重新连接

  • 此操作称为2改变
  • 为保证重连后得到合法哈密顿回路,重连方法只有一种

3选算法

删除3条非临边后重连

  • 重连方法有3种
  • 事实上可以推广到k选,但是只有3改变被证明有意义

Lin-Kernighan算法

变选算法算法的一种

  • 可以视为在3选操作后进行一系列2选操作

算法特点

  • 迭代改进算法求得的近似解效果质量非常好
  • Lin-Kernighan算法是公认的求解高质量近似解的最佳算法

Held-Karp Bound

Held-Karp下界

  • 将TSP描述为线性规划问题求解(忽略整数约束)得到,计算 速度快
  • 一般和最优旅途长度非常接近,误差不超过1%
  • 可使用其代替最短旅途估计近似算法的精确度

模拟

  • 10000个随机点:坐标、距离取整
  • Comqaq ES40:500MHz的Alpha处理器、2GB内存
启发式算法 超过Held-Karp下界的% 运行时间
最近邻居 24.79 0.28
多片段 16.42 0.20
Christofides 9.81 1.04
2选 4.70 1.41
3选 2.88 1.50
Lin-Kernighan 2.00 2.06

分类

图$G=$:由一些称为顶点的点构成的集合,其中某些顶点由 称为边的线段相连

  • 有限集合V:元素为顶点vertex
  • 有限集合E:元素为顶点二元组,称为边edge/弧arc

边方向

Undigraph/Undirected Graph

  • 无向图:所有边都是无向边的图
  • 无向边:若有序对$(u, v) \in E$,必有$(v, u) \in E$, 即E是对称的,顶点对$(u, v)$等同于$(v, u)$,没有顺序

对无向边$(u, v)$

  • 则顶点u、v相互adjcent
  • 其通过undirected edge$(u, v)$相连接
  • 顶点u、v称为边$(u, v)$的endpoint
  • u、v和该边incident(相依附)

Digraph

  • 有向图:所有边都是有向边的图
  • 有向边:若有序对对$(u, v) \in E$无法得到$(v, u) \in E$, 即两者不等同,有顺序

对有向边$(u, v)$

  • 边$(u, v)$的方向是从顶点u到顶点v
  • u称为tail,v称为head

边权重

Ordered Graph

有序图:各边地位有序

Weighted Graph/Network

加权图/网络:给边赋值的图

  • 值称为weightcost
  • 有大量的现实应用

边数量

  • complete graph:任意两个顶点直接都有的边相连的图
    • 使用$K_{|v|}$表示有|V|个顶点的完全图
  • dense graph:图中所缺的边数量相对较少
  • sparse:图中边相对顶点数量较少

特点

  • 不考虑loop(顶点连接自身边),则含有|V|个顶点无向图 包含边的数量|E|满足: $ 0 \leq |E| \leq \frac {|V|(|V| - 1)} {2} $

  • 对于稀疏图、稠密图可能会影响图的表示方法,影响设计、使用 算法的运行时间

图表示方法(存储结构)

Adjacency Matrix

邻接矩阵:两个数组分别存储数据元素(顶点)信息、数据元素之间 关系(边)的信息

  • n个顶点的图对应n * n的bool矩阵
    • 图中每个顶点使用一行和一列表示
    • i、j节点间有边,则矩阵第i行、j列元素为1,否则为0
  • 无向图邻接矩阵一定是对称的,有向图邻接矩阵不一定
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef enum{DG, DN< UDG, UDN} GraphKind;
typedef struct ArcCell{
VRType adj;
// 顶点关系类型:无权图0、1是否相邻,加权图权值类型
InfoType * info;
// 弧相关信息
}ArcCell, AdjMatrix[MAX_VERTEX_NUM][MAX_VERTEX_NUM];
typedef struct{
VertexType vexs[MAX_VERTEX_NUM];
// 顶点向量
AdjMatrix arcs;
int vexnum, arcnum;
// 图当前顶点弧数
GraphKind kind;
}

Adjacency List

邻接表:n条单链表代替邻接矩阵的n行,对应图G的n个顶点

  • 每个顶点用一个邻接表表示

    • 线性表的header表示对应的顶点
    • 链表中结点表示依附于顶点的边
  • 无向图一条边在邻接链表中对应两条链,有向图对应一条

    • 有向图出度计算简单
    • 计算入度则比较复杂,如果需要频繁计算入度,可以再存储 一个反向邻接表
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
typedef struct ArcNode{
int adjvex;
// 弧指向顶点信息
struct ArcNode *nextarc;
// 指向下条弧指针
InfoType * info;
// 弧相关信息
}ArcNode;
typedef struct VNode{
VertexType data;
// 顶点信息
ArcNode * firstarc;
// 首条依附该顶点的弧指针
}VNode, AdjList[MAX_VERTEX_NUM];
typedef struct{
AdjList vertices;
int vexnum, arcnum;
int kind;
}

Orthogonal List

十字链表:将有向图的邻接表、逆邻接表结合起来

  • 有向图中每条弧、每个顶点对应一个结点
  • 弧结点所在链表为非循环链表,
    • 结点之间相对位置自然形成,不一定按照顶点序号有序
  • 表头结点即顶点结点,顺序存储

orthogonal_list_structure

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
typedef struct ArcBox{
int tailvex, headvex;
// 头、尾顶点链域
struct ArcBox *hlink, *tlink;
// 头相同、尾相同弧链域
InfoType *info;
// 弧相关信息
}ArcBox;
typedef struct VexNode{
VertexType data;
// 顶点信息
ArcBox *firstin, *firstout;
// 顶点第一条出、入弧
}VexNode;
typedef struct{
VexNode xlist[MAX_VERTEX _NUM];
// 表头
int vexnum, arcnum;
}OLGraph;

Adjacency Multilist

邻接多重表:无向图的另一种存储形式

  • 一条边对应唯一结点
    • 所有依附于同一顶点的串联在同一链表中
    • 每个边界点同时链接在两个链表中
    • 避免无向图邻接表中一条边两次出现
  • 类似十字链表,仅无需区分头尾结点

adjacency_multilist_structure

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
typedef struct EBox{
VisitIf mark;
// 访问标记
int ivex, jvex;
// 边依附的两个顶点
struct EBox *ilink, *jlink;
// 依附两个顶点的下条边
InfoType *info;
// 边信息指针
}EBox;
typedef struct VexBox{
VertexType data;
EBox *firstedge;
// 第一条依附该顶点的边
}VexBox;
typedef struct{
VexBox adjmulist[MAX_VERTEX_NUM];
int vexnum, edgenum;
}AMLGraph;

说明

  • 稀疏图:尽管链表指针会占用额外存储器,但是相对于邻接矩阵 占用空间依然较少

  • 稠密图:邻接矩阵占用空间较少

  • 邻接矩阵、邻接链表都可以方便的表示加权图

    • 邻接矩阵元素A[i, j]设置为有限值表示存在的边的权重, 设置为$\infty$表示不存在边,此时矩阵也称 weighted matrixcost matrix
    • 邻接链表在节点中型跨同时包含节点名称和相应边权重
  • 任何可以存储顶点、边的数据结构(如集合)都可以表示图, 只是存储效率低、无法高效操作

概念

路径

有向图

  • directed path:有向图中顶点的一个序列,序列中每对连续 顶点都被边连接,边方向为一个顶点指向下一个顶点

  • directed cycle:有向图中节点序列,起点、终点相同,每个 节点和其直接前趋之间,都有一条从前趋指向后继的边

  • directed acyclic graph:DAG,有向无环图

无向图

  • path:(无向)图G中始于u而止于v的邻接顶点序列,即为顶点u 到顶点v的路径

  • simple path:路径上所有点互不相同

  • cycle:特殊的path

    • 起点、终点都是同一顶点
    • 长度大于0
    • 不包含同一条边两次
  • acyclic:不包含回路的图

长度

  • length:路径中代表顶点序列中的顶点数目减1,等于路径中 包含的边数目
  • distance:顶点间距离,顶点之间最短路径长度

连通性

无向图

  • connected:顶点u、v连通,当且仅当存在u到v的路径

  • connected graph:连通图,对图中的每对顶点$(u, v)$, 都有从u到v的路径

  • connected component:连通分量,给定图的极大连通子图, 即没有可以添加的连通子图用于扩充

    • 非连通图中包含的几个自我连通的部分
  • articulation point:关节点,如果从连通图$G$中删除顶点 $v$、极其邻接边各点之后的的图$G^{‘}$至少有两个连通分量, 则称顶点$v$为关节点

  • biconnected graph:重连通图,没有关节点的连通图

  • biconnected component:重连通分量,连通图的最大重连通 子图,即不是其他重连通分量的真子图

    • 两个重连通分量不可能共享一个以上节点,即图的一条边 不可能同时出现在两个重连通分量中 (否则两个“重连通分量”可以合并)
    • 所以可以说重连通分量是对原图的边的划分

有向图

  • 强连通图:对所有不同顶点u、v,都存在u到v、v到u的路径
  • strongly connected component:强连通分量,有向图的 极大连通子图
源点、汇点
  • soruce:源,没有输入边的顶点
  • sink:汇点,没有输出边顶点

图遍历

遍历图:实质是对每个顶点查找其邻接顶点的过程

  • 具体算法参见algorithm/problem/graph

遍历方式

  • BFS:Breadth First Search:广度优先搜索,类似树的先序 遍历
  • DFS:Depth First Search:深度优先搜索,类似树的按层次 遍历

  • 具体算法参见algorithm/problem/graph

结点处理顺序

  • post-order:后序,先处理子节点,再处理当前结点
  • pre-order:前序,先处理当前结点,再处理子节点
  • 搜索森林中仅有一棵树

    • 前序、后序均满足处理顺序(后序为逆处理顺序)
    • 前序、后序处理仅是处理顺序刚好相反
  • 搜索森林中有多棵树时,将每棵树视为一个结点考虑

    • 每个树内前序、后序顶点处理顺序相反
    • 不同树整体前序、后序处理顺序相反
    • 此时前序不再满足处理顺序,后序仍为逆处理顺序,所以 前序不常用
  • 节点处理记录方式

    • 栈:出栈顺序同记录反向
    • 队列:出队列顺序同记录顺序

总结

  • Pre-Order:正前序,先当前顶点、后子顶点,队列存储

  • Reverse Pre-Order:逆前序,先子顶点、后当前顶点, 栈存储

  • Post-Order:正后序,先当前顶点、后子顶点,队列存储

  • Reversed Post-Order:逆后序,先子顶点、后当前顶点, 栈存储,也称为伪拓扑排序

    • 可以用于得到DAG的拓扑有序序列
    • 也可以用于得到有环有向图的伪拓扑序列
      • 强连通分量整体看作结点,组成DAG
      • 各强连通分量必然可以选出某个顶点,满足在伪拓扑 序列中次序先于DAG中先驱强连通分量所有顶点
    • 用途最广
      • 有向图拓扑排序
      • Kosaraju算法中用到

BFS、DFS森林

广度优先、深度优先生成森林

  • 遍历的初始顶点可以作为森林中树的根
  • 遇到新未访问的顶点,将其附加为直接前趋子女
  • 其实仅有树向边才是符合森林定义(其他边都只是搜索过程 中遇到)
  • DFS森林中边是从左到右逐渐生成
  • BFS森林中边是从上到下逐渐生成

边类型

  • tree edge:树向边,连接父母、子女的边
  • back edge:回边,连接非直接前驱的边
    • 对有向图包括连接父母的边
    • 对无向图不存在连接父母边
  • cross edge:交叉边,连接非前驱、后继的边
  • forward edge:前向边,连接非直接后代的边

边存在性

  • 无向图
    • DFS森林:只可能有回边
    • BFS森林:只可能有交叉边
  • 有向图
    • DFS森林:可以都有
    • BFS森林:只可能有回边、交叉边

Spanning Tree

生成树/极小连通子图:包含图中所有顶点的连通无环子图(树)

  • n个顶点的生成树有且仅有n-1条边,反之不成立

  • 在生成树添加一条边必定构成一个环,因为其实使得其依附的 两个顶点之间生成了第二条路径

Minimum Cost Spanning Tree

最小生成树:图的一棵权重最小的生成树(权重指所有边权重之和)

MST性质
  • 若$N=(V, {E})$是连通网,U是顶点集V的非空子集,若 $(u, v), u \in U, v \in V-U$是一条具有最小权值(代价)边 ,则图中存在最小生成树包含该边
  • 假设网N中最小生成树T不包含边$(u, v)$,将其加入T中

  • 因为T为生成树,所以必存在边 $(u^{‘}, v^{‘}), u^{‘} \in U, v^{‘} \in V-U$,且 $u, u^{‘}$、$v, v^{‘}$之间均有路径连通

  • 则删除$(u^{‘}, v^{‘})$可以消去上述回路,得到新生成树 $T^{‘}$,且代价不大于$T$,矛盾

  • 存在是考虑到存在其他同权值边,若权值严格最小,则所有 最小生成树必然包含
  • 依此性质生成算法参见algorithm/problems/graph

连通性

  • 对图进行遍历是判断图连通性、求解连通分量的好方法

有向图

  • 连通图:从图中任意顶点出发,进行深度、广度优先搜索即可 访问到图中所有顶点

    • 利用DFS生成树可以找出图的重连通分量
  • 非连通图:需要从多个顶点起始进行搜索

    • 每次从新的起始点出发进行搜索过程中得到顶点访问 序列就是各个连通分量的顶点集

无向图

  • 深度优先搜索是求有向图强连通分量的有效方法
  • 具体算法参见algorithm/problem/graph

有向图衍生

Directed Acycline Graph

DAG:有向无环图

  • 描述含有公共子式的表达式的有效工具
    • 可以实现对相同子式的共享,节省存储空间
  • 描述一项工程、系统进行过程的有效工具
    • 拓扑排序:工程能否顺利进行
    • 关键活动:整个工程完成必须的最短时间

Dependence Analysis

依赖性分析:由有向图判断、构造可行任务执行序列

Topological Sort

  • 拓扑有序:由偏序得到的全序
  • 拓扑排序:由偏序定义得到拓扑有序的操作
  • 构造有向图中顶点的拓扑有序序列

    • 得到可行任务执行顺序
  • 判断有向图AOV-网中是否存在环,即是否为DAG

    • 若图中所有顶点都在拓扑有序序列中,则有向图中不存在环
  • 参见algorithm/problems/graph
  • AOV网:activity on vertex network,顶点表示活动,弧 表示活动间优先关系的有向图

关键活动优化

Critical Path

关键路径:路径长度最长的路径

  • 对整个AOE网,开始点到结束点的关键路径长度即为完成工程 的最短时间

  • 对事件$v_i$,从起始点$v_1$到其的关键路径长度即为,以 $v_i$为尾的活动的最早开始时间

  • 关键路径上的所有活动均为关键活动

    • 分析关键路径目的就是找出关键活动
    • 提高关键活动工效缩短工期
  • AOE网:activity on edge network,顶点表示事件,边表示 活动持续事件的带权有向无环图
    • 一般网络中只有一个源点、汇点,表示工程开始、完成
    • 以上假设AOE网中活动可以并行进行

关键活动

  • 关键活动:记$ee{ij}$为活动$a{ij}$最早开始时间、 $el{ij}$为不推迟整个工程完成前提下$a{ij}$最迟开始时间 ,称$ee{ij} = el{ij}$称为关键活动
  • $el{ij} - ee{ij}$表示完成活动$a_{ij}$的时间余量

    • 提前完成非关键活动不能加快工程进度
    • 关键活动耗时一定范围变化影响工程整体完成时间
  • 考虑如下事件和活动发生关系有

    • $ve_i, vl_i$:事件(顶点)i最早、最晚发生的时间
    • $a{ij}$:活动$a{ij}$需要持续时间
  • 对$ve_i, vl_i$,存在如下递推关系

    则依拓扑排序可计算得$ve_i$,逆拓扑排序可计算得$vl_i$

  • 参见algorithm/problems/graph
  • 为求解AOE网中活动$e{ij}, l{ij}$,应该首先求出事件

Flow Network

流量网络

  • 包含一个源点:物质流唯一出发点
  • 包含一个汇点:物质流唯一汇聚点
  • 每条有向边权重为边capacity的正整数$u_{ij}$
    • 事实上,有理数总可以通过变换变为整数,所以只要容量为 有理数就可以
    • 计算机无法真正处理无理数,无理数边权只具有理论意义

流量约束

Capacity Constraits

容量约束:通过边的流量不大于边容量$x{ij} \leqslant u{ij}$

Flow-Conservation Requirement

流量守恒要求:除源点、汇点外其余顶点只能改变流方向,进入、 流出物质总量不变,不能消耗、添加物质

  • 其中$x_{ij}$表示通过边$(i,j)$的流量(传输量)
  • 等式左右为进入、离开顶点i的输入、输出流总和

并且

  • 流的值 = 源点输出流 = 汇点输入流
  • 可通过流量守恒要求推出

最大流

最大流:分配流量网络中边权(实际流量),使得网络在满足流量 守恒、容量束情况下,最大的流的值(边容量都为正整数)

cut

割:$C(X,\bar X)$=所有头在$X$、尾在$\bar X$边集合

  • $X$;包含源点、不包含汇点的顶点V的子集
  • $\bar X$:$X$的补集,包含汇点
  • 删除割中所有边,则不存在从源点的汇点的路径

Augmenting-Path Method

Augmenting-Path

流量增益路径:当前流情况下,可以传输更多流量、从源点到 汇点路径,考虑路径$i \rightarrow j \leftarrow k$中顶点j边

  • forward edge:前向边$(i, j)$,同流量网络中边$(i, j)$ 方向相同

    • 具有正的未使用容量$r{ij} = u{ij} - x_{ij}$
    • 可以把该边流量增加最多$r_{ij}$
  • backward edge:后向边$(j, k)$,同流量网络中边$(k, j)$ 方向相反

    • 具有正流量$x_{kj}$
    • 可以把该边流量减少最多$x_{kj}$

增益路径法

  • 寻找网络中增益路径,设$r$为路径中各前向边未使用容量 $r{ij}$、后向边流量$x{jk}$最小值

  • 每条前向边流量加r、后向边流量减r,可以得到新的可行流量, 且流量值增加r,不断迭代

  • 基于边容量、流量都为正整数假设

    • r也为正整数,每次迭代流量值至少增加1

    • 流量最大值有上界为源点为起点边容量和,则增益路径法 在有限步迭代后会停止

  • 联合最大流-最小割定律可以证明

    • 最终流量一定是最大化的,等于最小割容量
    • 和增量路径选择无关
  • 最后一步迭代中

    • 所有已标记顶点和未标记顶点之间边就构成最小割
    • 这样边流量要么满(标记到未标记)、要么空(反)
  • 增益路径法具体实现参见graph
  • 算法主要问题在于如何寻找增益路径,生成路径的顺序对算法 有巨大影响

Max-Flow Min-Cut Theorem

最大流-最小割定理:网络中最大流量值等于其最小割容量

Theorem1

网络中最大流量值小于任意割容量

  • 设可行流量x值为v,割$C(X, \bar_X)$容量为c

  • 通过该割的流量为:$X$到$\bar_X$的流量之和$\bar_X$到$X$的流量之和的差值(可由流量守恒推导)

  • 由流量非负可得

    即网络上任何可行流值不能超过网络上任意割容量

Theorem2

网络中最大流量等于最小割容量,可以使用增益路径法证明

  • 设$v^{}$为通过路径增益法得到的最终流$x^{}$的值

  • 对增益路径法最终流量情况下,考虑顶点集合$X^{*}$

    • 包含源点
    • 其他顶点可由源点出发,经未使用的容量大于0的前向边、 流量大于0的后向边组成路径到达
  • 则汇点不属于$X^{*}$,否则存在一条流量增益路径,不是流量 增益法最终流情况

  • 考虑割$C(X^{}, \bar_{X^{}})$

    • $X^{}, \bar_{X^{}}$之间任意边$(i,j)$: $x{ij} = u{ij}$,没有未使用容量

    • $\bar{X^{}}, X^{}$之间任意边$(j,i)$:$x{ji}=0$, 没有流量

    • 否则顶点j$\in X^{*}$

  • 则有

    即存在某个割容量等于流量增益法得到最终流量值

代码

基础数据类型

整形值

无额外空交换

  • 异或

    1
    2
    3
    a = a^b
    b = a^b
    a = a^b
  • 加减

    1
    2
    3
    a = a+b
    b = a-b
    a = a-b
  • 当然,对某些语言可以同时交换,如:python

取整

  • 向下取整:mid = (left + right) // 2
  • 向上取整:mid = (left + right + 1) // 2

运算溢出

  • 正数:边界值10x7FFF FFFF0xFFFF FFFF
  • 负数:边界值0x8000 00000xFFFF FFFF
  • 忽略语言特性:如long类型常量不加LL

初值选取

0值未考虑

浮点值

  • 浮点取整:尽量避免混用使用向上取整、向下取整,容易混淆

  • 浮点型相等比较:不应使用==,应该使用相差<某常数

    1
    2
    3
    a, b = 1.11111, 1.11111111
    if abs(a - b) < 0.00001:
    print("equal")

数据结构

线性表

  • 常用初值

    • 数值型:0-1sys.maxsizefloat('inf')
    • 字符串型:""
    • 迭代过程中可能取值:输出列表首个元素
  • 判断条件

    • 是否为初值
    • 是否越界
  • 对比迭代技巧

    • 从左至右、从右至左分别遍历
    • 原始列表、排序后列表分别遍历
  • 边界条件限制

    • 判断语句中:先列出边界条件,利用短路求值简化代码

双指针

  • 迭代操作注意事项

    • 保证退出循环
    • 所有值均被检验
    • 数据处理、移动指针的顺序
  • 相向双指针:两指针均向中间靠拢不断缩小搜索空间

    • 明确切片范围:是否包括端点,并尽量保持不变
    • 据此明确搜索空间范围,则搜索空间为空时结束循环
  • 滑动窗口:两指针同时向一侧移动,检验窗口内切片

    • 分类
      • 固定间隔双指针
      • 不同条件移动双指针
    • 示例
  • 快慢指针:两指针同时迭代、但运动速度不同

    • 示例
      • 判断单链表是否成环

端点利用

  • 两端限位器:在数据端点添加标记数据,便于

    • 指示“指针”已经到达数据端点
    • 简化特殊情况代码
      • 将循环中的判断条件合并
      • 端点标记数据符合一般情况,无需特殊处理
    • 示例
      • 数组末尾添加查找键(查询问题):在顺序查找中可以 将判断数组越界、查找键比较合并
  • 末端点为空闲位置:存储有效值

    • 队列、栈插入元素:末端点处为空闲位置,可直接使用
    • 数组迭代比较:末端点处存储有效值,先比较再更新指针
  • 末端点为非空闲位置

    • 队列、栈:可以直接使用其末尾元素作为上次添加的元素, 简化代码

链表特性

  • 头指针/节点:添加链表头,保证特殊情况链表正常工作

    • 示例
      • 删除只包含一个元素的链表
  • 自设外部指针

    • 使用时注意有时会改变内部节点值

      1
      2
      // 修改链表内节点值
      outer.next.val = 4

HashXXX

  • hash数据结构查询是常数时间,非常合适缓冲记录结果

    • HashSet:常数时间判断元素存在性
    • HashDict:键元素、值元素出现次数,记录次数
  • 特殊、常用键

    • 有重复元素:有序tuple、字符串
    • 无重复元素:frozen set

逻辑

运算符

  • 注意运算符优先级

  • =结合不等号

    • 同时使用<=>=,容易造成死循环、遗漏等
    • 尽量只使用>=号,不再使用<=

递归终止条件

递归终止条件主要有两种设计方案

  • 最后元素:判断元素是否是最后元素,终止递归调用

  • 空(无效)元素:判断元素是空(无效)元素,终止递归调用

    • 需要确保最终能够进入该分支,而不是停止在最后一步

预处理方法

排序

  • 预排序是简化问题的好方法

分治

  • 缩小搜索空间:按特征排序后,根据该特征是否满足条件 即可缩小搜索空间

组合

  • 组合后剔除重复:可重复组合排序后唯一,方便剔除重复元素
  • 组合前保证唯一:对组合候选集“预排序”,保证取用元素位序 不减(可重复)、单增(不可重复)
    • 相当于提前对结果排序,保证得到组合结果唯一
    • “预排序”指候选集中顺序有意义,无需真正排序

特殊测试用例

字符串

  • 空字符串
  • 长度1字符串、长度2字符串
  • 字符相同字符串

普通列表

  • 空列表

    • 若在循环外即已取值,应该提前判断列表是否空
  • 长度1列表、长度2列表

  • 元素相同列表

树、二叉树

  • 空树、只有根元素

    1
    2
    node.val = value;
    # 未考虑空树
  • 只有左子树、右子树

文件边界条件

  • 首个字符
  • 最末字符、倒数第二个字符

代码优化考量

  • 保持程序设计风格:把经常使用的工具性代码编辑成已验证
  • 用规范的格式处理、保存数据
  • 区分代码与数据:与代码无关的数据应该尽可能区分开来,尽量 把数据保存在常量数组中

代码执行速度

  • 输入输出方式过慢:cin等高级输入、输出方式比较慢

程序结构优化

  • 栈溢出:数组等大局部变量保存到栈,少量递归即会发生栈溢出

输入、输出

将输入、输出流重定向到文件中,避免频繁测试时频繁输入

  • 输入放在in.txt文件中
  • 输出到out.txt
  • 输出执行时间

C/C++

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#ifdef SUBMIT
freopen("in.txt", "r", stdin);
// input data
freopen("out.txt", "w", stdout);
// output
long _begin_time = clock();
#endif

// put code here

#ifdef SUBMIT
long _end_time = clock();
printf("time = %ld ms\n", _end_time - begin_time);
#endif

Python

1
2
3
4
5
6
7
8
9
10
import sys
import time
sys.stdin = open("in.txt", "r")
sys.stdout = open("out.txt", "w")
__tstart = time.time()

# code here

__trange = time.time() - __tstart
print("time used: %f" % __trange)

博弈论

总述

约瑟夫斯问题

n个人围成圈编号{0..n-1},从1号开始每次消去第m个人直到最后一个 人,计算最后人编号$J(n)$。

减1法

考虑每次消去1人后剩余人的编号情况

  • 还剩k人时,消去1人后,以下个人编号为0开始,将剩余人重新 编号,得到每个人在剩k-1人时的编号

  • 相较于剩k人时,剩k-1人时每个人编号都减少m,即每个人在剩 k人时编号满足

  • 考虑只剩1人时,其编号为0,则可以递推求解

算法

1
2
3
4
5
6
7
Joseph_1(n, m):
// 减1法求解Joseph问题
// 输入:人数n、消去间隔m
// 输出:最后留下者编号
j_n = 0
for k from 2 to n:
j_n = (j_n + m) % k

特点

  • 算法效率
    • 时间效率$\in O(n)$

减常因子

剩余人数$k >= m$时考虑所有人都报数一次后剩余人的编号变化情况

  • 还剩k人时,报数一圈后消去k//m人,以最后被消去人的下个人 编号为0开始,将剩余人重新编号,得到剩k-k/m人时的编号

  • 相较于剩k人时,剩k-k//m人时每个人编号满足

    • $d = k // m$
    • $s = J_{k - d} - n\%m$
  • $k < m$时,使用减1法计算

    • m很大时,以$k < m$作为调用减1法很容易使得递归超出 最大值
    • 另外$m < k <= d * m$时,每轮也不过消去$d$个人,而 递推式复杂许多、需要递归调用
    • 所以具体代码实现中应该选择较大的$d$值,如5

算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Joseph_factor(n, m):
// 减常因子法求解Joseph问题
// 输入:人数n、消去间隔m
// 输出:最后留下者编号
if n < 5 * m:
j_n = 0
for k from 2 to n
j_n = (j_n + m) % k
return j_n

s = Joseph(n-n/m, m) - k % m
if s < 0:
retrun s + n
else:
return s + s // (m-1)
return j_n

特点

  • 算法效率

    • 时间效率$\in O(log n) + m$
  • 特别的,对$m=2$时

    • $n=2k$为偶数时,$J(2k)=2J(k)-1$
    • $n=2k+1$为奇数时,$J(2k+1)=2J(k)+1$

任意第k个

  • 考虑报数不重置,则第k个被消去的人报数为$k * m - 1$

  • 对报数为$p = k * m + a, 0 \leq a < m$的人

    • 此时已经有k个人被消去,剩余n-k个人

    • 则经过$n - k$个剩余人报数之后,该人才继续报数,则 其下次报数为$q = p + n - k = n + k*(m-1) + a$

  • 若该人报数$p$时未被消去,则$a \neq m-1$,则可以得到 $p = (q - n) // (m-1) * m + (q-n) \% (m-1)$

算法

1
2
3
4
5
6
7
8
Joseph_k(n, m, k):
// 计算Joseph问题中第k个被消去人编号
// 输入:人数n、间隔m、被消去次序k
// 输出:第k个被消去人编号
j_k = k*m - 1
while j_k >= n:
j_k = (j_k-n) // (m-1) * m - (j_k-n)%(m-1)
return j_k

算法特点

  • 算法效率

    • 时间效率$\in O(log n)$
  • 特别的,m=2时对n做一次向左循环移位就是最后者编号

双人游戏

  • 双人游戏中往往涉及两个概念
    • state:状态,当前游戏状态、数据
    • move:走子,游戏中所有可能发生的状态改变
  • 状态、走子彼此之间相互“调用”
    • 状态调用走子转化为下个状态
    • 走子调用状态评价当前状态
1
2
3
4
5
6
7
8
9
10
11
12
13
make_move(state, move):
switch move:
case move_1:
state = move_1(state)
evaluate_state(state)
...other cases...

evaluate_state(state):
switch state:
case state_1:
make_move(state, move_1)
...other cases...
end game

拈游戏

同样局面,每个玩家都有同样可选走法,每种步数有限的走法都能 形成游戏的一个较小实例,最后能移动的玩家就是胜者。

  • 拈游戏(单堆版):只有一堆棋子n个,两个玩家轮流拿走最少 1个,最多m个棋子
  • 拈游戏(多堆版):有I堆棋子,每堆棋子个数分别为 ${n_1,\cdots,n_I}$,可以从任意一堆棋子中拿走任意允许数量 棋子,甚至拿走全部一堆

减可变规模算法

算法

(单堆)从较小的n开始考虑胜负(标准流程)

  • n=0:下个人失败
  • 1<=n<=m:下个人胜利(可以拿走全部)
  • n=m+1:下个人失败(无论拿走几个,对方符合1<=n<=m 胜利条件)
  • 数学归纳法可以证明:n=k(m+1)时为败局,其余为胜局
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 两个函数轮流递归调用
find_good_move(coins):
// 判断当前是否有成功步骤
// 输入:棋子数目
// 输出:成功策略或没有成功策略
for taken=1 to limit do
if(is_bad_position(coins-taken))
// 对手没有成功策略
return taken
return NO_GOOD_MOVE

is_bad_position(coins):
// 判断当前是否是good position
// 输入:棋子数量
// 输出:是否有成功策略
if (coins == 0)
return true
return find_good_move(coins) == NO_GOOD_MOVE
// 没有成功策略

特点

  • 堆为2时,需要对两堆是否相同分别考虑

  • 对更一般的I堆时

    • 对每堆数量的位串计算二进制数位和
    • 结果中包含至少一个1则对下个人为胜局,全为0则为负局
    • 则玩家下步要拿走的棋子数量要使得位串二进制数位和全0 ,则对方陷入负局

    • todo又是二进制???和约瑟夫斯问题一样了

    • 但是这里没有涉及最多能拿几个啊,不一定能够成功拿到 使拈和全为0啊
  • 二进制数位和(拈和):每位求和并忽略进位(奇或)

无向图衍生

Bipartite Graph

二分图:所有顶点可以分为两个不相交集合U、V,两个集合大小不定 相等,但每条边都连接两个集合中各一顶点

  • 可以证明:当且仅当图中不存在奇数长度回路时,为二分图

  • 二分图中顶点可以可以染成两种颜色,使得每条边两头顶点颜色 不同

  • 矩阵存储二分图时,因为U、V内部节点之间无边,大部分场景 只需要$|V| * |U|$的矩阵(对有序、加权适用)

  • 性质

    • 二分图匹配意义:同类型(集合内部)之间没有连接

Augmenting-Path

(二分图)增益路径:对合法匹配M,简单路径两端连接V、U中 的自由顶点,路径中交替出现在$E-M, M$中,称M的增益路径

  • 增益路径长度总为奇数,为M中匹配两倍+1

  • 则路径中奇数边组成新的匹配,比M多一条边

增益路径-最大匹配证明

当且仅当G中不存在M增益路径时,M为G最大匹配

必要性

  • 若M存在增益路径,则可以扩展M得到更多匹配,M不是最大匹配

充分性

  • 若存在M无增益路径,但不为最大匹配,设$M{}$是G中的最大 匹配,则$M{}$中边至少比M中边数量大1,有 $|M^{}| > |M|, |M^{} - M| > |M - M^{*}|$

  • 则两者对称差集为 $M \bigoplus M^{} = (M - M^{}) \cup (M^{*} - M)$

  • 设$G^{‘} \subseteq G$为二者差集中所有边、点,根据匹配 定义,$G^{‘}$中任何单个点和$M, M^{*}$相连接不会超过 一条边

  • 则$G^{‘}$中顶点连通度不大于2,其中连通分量为

    • 偶数长度回路,其中边交替属于 $|M^{} - M|, |M - M^{}|$,在两者中数量相同

      • 若不交替,则说明连续两条边在同个匹配中,有交点, 和匹配定义冲突

      • 若不为偶数长度,同样的首、尾两条边在同一匹配中, 和匹配定义冲突

    • 交替路径(无环)

      • 因为$|M^{} - M| > |M - M^{}|$,所以$G^{*}$中 不可能仅有回路

      • 所以至少存在一条具有交替边路径,起点终点都是 $M^{*} - M$同一条边(如单边路径),M具有增益路径 ,矛盾

  • 最大匹配求解匈牙利算法实现详见graph

Konig Theorem

Konig定理:二分图中最大匹配数|M| = 最小点覆盖数|C|

  • 已经通过匈牙利算法求出最大匹配M

  • 从集合V中每个未被匹配点开始,走一条类似增益路径 的交替路径,标记路径中的所有点

    • 只是因为已经找到了最大匹配,所以路径另外一端不可能 是自由顶点

    • 允许重复走过同一条边

  • 路径中V到U的均为非匹配边、U到V均为匹配边

  • 当然也可以从U的所有未匹配顶点开始

记集合V中中未被访问、U集合中已被访问的已匹配顶点点集为C,则 $顶点数目|C| = 最大匹配数 = 最小点覆盖数$ biparttite_konig

  • 因为每个点都是M中某边端点,且V中已标记同U中未标记、V中 未标记顶点同U中已标记一一对应,为匹配边两端点,所以

  • 在最大匹配情况下,所有边至少有一个端点为已匹配点

    • 对于匹配边,肯定被C中顶点覆盖

    • 若非匹配顶点在V中,则一定在某个路径中被访问,则被U 中某个已访问匹配顶点覆盖

    • 若非匹配顶点在U中,则被V中未访问顶点覆盖的

    • 或者说不存在这样的边:其左端点没有标记、而右端点有 标记

      • 匹配边肯定不是起点,不可能
      • 非匹配边,右端有标记则能直接访问左端,标记左端 (通样广度优先搜索遍历所有邻接有顶点点)
  • 而要覆盖匹配边需要至少$|M|$个点,所以$|M|$是最小覆盖点数

推论 1

二分图中:最小边覆盖|W| = 图中顶点数|V| - 最小点覆盖数|C|

Ordered Bipartite Graph

有序二分图:以同一顶点作为端点的边的有优先级(独立于其他点)

  • 即:V中顶点对U中顶点都有优先级排序,U中顶点对V中顶点也有 优先级排序

Marriage Matching

婚姻匹配问题可以使用特殊的完全有序二分图代表

  • 集合V(男士集合)、U(女士集合)大小相同为n

  • 任意男士、女士都有之间都有边连接,即任意男士、女士都需要 给出所有女士、男士优先级

存储、表示方法

  • 优先列表:各男士、女士按照异性优先级排序列表,共2n个

    • 对实现匹配算法而言效率更高
  • 等级矩阵:男士、女士为分别作为矩阵行、列,矩阵元素$P_ij$ 为男士$m_i$对女士$w_j$优先级排序、女士$w_j$对男士$m_i$ 优先级排序元组

    • 更容易看出集合元素匹配
    • 只需要$n * n$阶方阵

Stable Marriage Matching

对包含n个对的婚姻匹配M

  • Block Pair:受阻对,满足$m \in V, w \in U$,而$(m, w)$ 更倾向于对方,而不是匹配M中对象

  • Stable Marriage Matching:稳定婚姻匹配,不存在受阻对的 婚姻匹配

稳定婚姻存在性

V中存在自由男士时,任选求婚回应之一执行,直至不存在 自由男士

  • 求婚:自由男士m向女士w求婚,w为其优先级最大、之前未拒绝 过其女士(可以是已匹配)

  • 回应:若女士w自由则接受男士m求婚,与之配对;女士w不自由 则把m同当前配偶匹配,选择其中优先级较高者

当不存在自由男士时,得到匹配M就是稳定匹配

  • 若M是不稳定的,设存在受阻对$(m, w)$

  • 因为m按照降序求婚,所以m必然在某次迭代向w求过婚,则w当前 对偶必然比m拥有更高优先级,和受阻对假设矛盾

  • 稳定婚姻问题求解算法参见graph

Weighted Bipartite Graph

加权二分图;每条边都有权重的二分图

Distribution Problem

分配问题可以使用特殊的完全加权二分图代表

  • 集合V(人员集合)、U(任务集合)大小相同为n

  • 任意人员、任务有边相连,人员、任务内部之间无边

存储方法

  • 使用$n * n$阶成本矩阵C存储,其元素$c_{ij}$表示人员$i$ 完成任务$j$的成本

  • 则问题转化为:从成本矩阵中每行分别选择元素,且元素不属于 同一行,使得和最小

  • (最小成本)分配问题算法参见graph

Biconnected Graph

  • articulation point:关节点,删除顶点v、与v相连的各边 之后,将图一个连通分量分割成 两个、两个以上连通分量,则 称顶点v为图的一个关节点

  • biconnected graph:重连通图,没有关节点的连通图

  • 重连通图中任意一对结点之间至少存在两条路径

  • 若在重连通图中至少删除k个顶点才能破坏图的连通性,则称图 的连通度为k

求解关节点

利用DFS可以求出图的关节点,判断图是否是重连通的,对DFS生成树

  • 生成树有两棵及以上子树,则根节点为关节点
    • 因为不同子树之间不存在连通不同子树顶点的边
  • 非叶子顶点v某棵子树中没有指向v前驱的回边,则v为关节点
  • 算法参见algorithm/problems/graph

无向图

点集

点覆盖集

  • Vertex Covering Set:点覆盖(集),顶点子集$S \subseteq V$ ,满足每条边至少有一个顶点在集合中

  • Minimum Vertex Covering Set:最小顶点覆盖集,最少顶点的 点覆盖集

点支配集

  • Vertex Dominating Set:点支配集,顶点子集$D \subseteq V$ ,满足$\forall u \in V-D, \exists v \in D, (u, v) \in E$

    • 即V中顶点要么是D中元素,要么同D中一个顶点相邻
  • Minimum Vertext Dominating Set:最小支配集,顶点数目 最小支配集

  • 极小支配集:真子集都不是支配集的支配集

点独立集

  • Vertext Independent Set:(点)独立集,顶点子集 $I \subseteq V$,满足I中任意两顶点不相邻

  • Maximum Vertext Independent Set:最大独立点集,顶点数 最多的独立点集

  • 极大点独立集:超集都不是独立集的独立集

性质

Thoerem 1

若无向图$G(V, E)$中无孤立顶点,则G的极大点独立集都是G的极小 支配集(反之不成立)

Thoerem 2

一个独立集是极大独立集,当前且仅当其为支配集

Thoerem 3

若无向图$G=(V, E)$中无孤立点,顶点集$C \subseteq V$为G点覆盖 ,当且仅当$V - C$是G的点独立集

边集

边覆盖

  • Edge Covering Set:边覆盖(集),边子集$W \subseteq E$, 满足$\forall v \in V, \exists e \in W$,使得v是e端点

    • 即G中所有点都是便覆盖W的邻接顶点
  • Minimum Edge Covering Set:边数最少的边覆盖集

  • 极小边覆盖集:任意真子集都不是边覆盖集的边覆盖

边独立集

  • Matching/Edge Indepdent Set:匹配(边独立集),边子集 $I \subseteq E$,满足I中所有边没有公共顶点

  • Maximum (Cardinality) Matching:最大(基数)匹配,包含 最多边的匹配

  • 极大匹配:任意超集都不是匹配的匹配

  • Perfect Matching:完美匹配,匹配所有点的最大匹配

  • Mate:对偶,匹配中相互连接的一对顶点

性质

Thoerem 1

M为G一个最大匹配,对G中每个M未覆盖点v,选取一条与v关联边组成 集合N,则边集$W = M \cup N$为G中最小边覆盖

Thoerem 2

若W为G最小边覆盖,其中每存在相邻边就移去其中一条,设移去边集 为N,则边集$M = W - N$为G中一个最大匹配

Thoerem 3

最大匹配、最小边覆盖满足:$|M| + |W|= |V|$

图衍生

Laplacian矩阵

$L=D-A$:Laplacian矩阵,其中

  • $A$:邻接矩阵
  • $D$:度矩阵(对角线为各个顶点度)

性质

  • 若$c$为图中各个节点权重,则$c^T L C$为各个节点与其 邻接节点差值平方和
    • 展开即可证明

边数量

Multigraph

多重图:含有平行边,即顶点之间边数大于1

  • 允许顶点通过边和自己相连

边权

欧几里得类型加权图

欧几里得类型加权图:权重满足欧式几何条件

  • 三角不等式:任意3点$i,j,k, d{ij} \leqslant d{ik}+d{kj}

  • 对称性:任意两个点$i,j, d_{ij}=d{ji}$

连通性

Transitive closure

传递闭包:表示所有节点两两直接连通性的n阶布尔矩阵T

  • $T={t{ij}}$,若节点i到节点j直接存在有效(长度>0)有向 路径,则$t{ij}=1$,否则为0

Hamiltonian Circuit

哈密顿回路:对图每个顶点只穿过一次的回路

  • 可以理解为:n+1个相邻顶点的一个排列,其中首尾顶点相同, 而其余顶点互不相同

    • 因为是回路,可以不失一般性的假定回路开始、结束于相同 顶点,这样不影响回路性质

Eular Circuit

欧拉回路:将给定图每条边都只遍历一次的回路

  • 无向图:当且仅当连通多重图的每个顶点连通度都为偶数时, 才具有欧拉回路

  • 有向图:当且仅当图中所有顶点是否出度、入度相等时,才存在 欧拉回路

  • 在$\O(n^)$内解决问题

State-Space Graph

状态空间图:把问题化简为标准图问题

  • 顶点:表示问题可能状态
  • 边:表示状态之间的可能转换
  • 原问题转换为求初始状态到目标状态顶点路径问题

数组和广义表

综述

  • 数组、广义表可以看作是线性表的扩展:线性表中数据元素本身 也是抽象数据结构

Array

对各维界分别为$b_i$的n维数组

  • 数组中含有$\prod_{i=1}^n b_i$个数据元素,每个元素都受n个 关系约束

    • 每个关系中,元素都有直接后继
    • 就单个关系而言,这个n个关系仍然是线性关系
  • n维数组可看作是线性表的推广,n=1时,数组退化为定长线性表

    • 和线性表一样,所有数据元素必须为同一数据类型
  • 数组一旦被定义,其维数、维界就不再改变

    • 除了初始化、销毁之外,数组只有存储、修改元素值的操作
    • 采用顺序存储结构表示数组是自然的
  • 几乎所有程序设计语言都把数组类型设定为固有类型

顺序存储表示

  • 存储单元是一维结构,数组是多维结构,所有使用连续存储单元 存储数组的数据元素需要约定次序

    • BASIC、PL/1、COBOL、PASCAL、C语言中,以行序作主序
    • FORTRAN中,以列序作主序
  • 数组元素存储地址

    • $cn=L, c{i-1} = b_ic_i$
1
2
3
4
5
6
7
8
9
typedef struct{
Elemtype * base;
int dim;
// 数组维数
int * bounds;
// 数组各维界(各维度长度)
int * constants;
// 数组各维度单位含有的数组元素数量,由`bounds`累乘
}

矩阵压缩

压缩存储:为多个值相同元只分配一个存储空间,对0元不分配空间

特殊矩阵

特殊矩阵:值相同元素、0元素在矩阵的分布有一定规律,将其压缩 至一维数组中,并找到每个非零元在一维数组中的对应关系

  • 对称矩阵:为每对对称元分配一个存储空间
    • 一般以行序为主序存储其下三角(包括对角线)中的元
  • 上/下三角矩阵:类似对称矩阵只存储上/下三角中的元,附加 存储下/三角常数
  • 对角矩阵:同样可以按照行、列、对角线优先压缩存储

稀疏矩阵

稀疏矩阵:稀疏因子$\sigma = \frac t {mn} \leq 0.05$的矩阵

  • 使用三元组(非零元值、其所属的行列位置)表示非零元
三元组顺序表

三元组顺序表/有序双下标法:以顺序结构表示三元组表

1
2
3
4
5
6
7
8
9
typedef struct{
int i, j;
ElemType e;
}Triple;
typedef struct{
Triple data[MAXSIZE+1];
int mu, nu, tu;
// 行、列、非零元数
}TSMatrix;
  • data域中非零元的三元组以行序为主序顺序排列,有利于进行 依行顺序处理的矩阵运算
行逻辑链接的顺序表

行逻辑链接的顺序表:带行链接信息的三元组表

1
2
3
4
5
6
typedef struct{
Triple data[MAXSIZE+1];
int rpos[MAXRC+1];
// 各行首个非零元的位置表
int mu, nu, tu;
}RLSMatrix;
  • 为了便于随机存取任意行非零元,需要知道每行首个去非零元 在三元组表中的位置,即rpos
十字链表

十字链表:采用链式存储结构表示三元组的线性表

1
2
3
4
5
6
7
8
9
10
11
12
typedef struct OLNode{
int i,j;
// 该非零元行、列下标
ElemType e;
struct OLNode, *right, *down;
// 该非零元所在行表、列表的后继链域
}OLNode, *OLink;
typedef struct{
OLink *rhead, *chead;
// 行、列链表表头指针向量基址
int mu, nu, tu;
}CrossList;
  • 同一行非零元通过right域链接成一个线性链表
  • 同一列非零元通过down域链接成一个线性链表
  • 适合矩阵非零元个数、位置在操作过程中变化较大的情况

Lists

广义表/列表:线性表的推广

  • $\alpha_i$:可以时单个元素,也可以是广义表,分别称为LS 的原子、子表
  • head:表头,LS非空时的首个元素$\alpha$
  • tail:表尾,LS除表头外的其余元素组成的表,必然是列表
  • 列表是一个多层次结构:列表的元素可以是子表,子表元素 也可以是子表
  • 列表可以为其他列表所共享
  • 列表可以是一个递归的表:即列表自身作为其本身子表
  • 广义表长度:广义表中元素个数
  • 广义表深度:广义表中最大括弧重数

链式存储结构

广义表中数据元素可以具有不同结构,难以用顺序存储结构表示, 通常采用链式存储结构

头尾链表

  • 数据元素可能是原子、列表,因此需要两种结构的结点
    • 表节点:表示列表,包括:标志域、指示表头的指针域、 指示表尾的指针域
    • 原子节点:表示原子,包括:标志域,值域
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
typedef enum{ATOM, LIST} ElemTag;

typedef struct GLNode{
ElemTag tag;
// 标志域,公用
union{
// 原子节点、表节点联合部分
AtomType atom;
// 值域,原子节点
struct{
struct GLNode *hp, *tp;
// 两个指针域,表节点
}ptr;
};
}*GList;
  • 除空表的表头指针为空外,任何非空列表的表头指针均指向 表节点,且该表节点
    • hp指针域指向列表表头
    • tp指针域指向列表表尾,除非表尾为空,否则指向表节点
  • 容易分清列表中原子、子表所属层次
  • 最高层的表节点个数即为列表长度

扩展线性链表

1
2
3
4
5
6
7
8
9
10
typedef enum {ATOM, LIST} ElemTag;

typedef struct GLNode{
ElemTag tag;
union{
AtomType atom;
struct GLNode *hp;
};
struct GLNode *tp;
}*GList;