CH11图论模型算法和总结(不包括网络流)
写在前面,很多东西看了代码就懂了,关键是如何引入这些东西。是否有一个逻辑思考过程,并不是靠猜。所以需要总结了。
无根树转有根树
给出n个节点的无根树的各条,指定一个根节点,然后把该树转换为有根树,并输出各个节点的父节点编号 ——《算法竞赛入门经典》
问题的思考过程
究其然,无论是无根树还是有根树就在图而言还是同样的图,关键在于怎么去表示有根树,和无根树有什么不同。所以关键就在于一个有父节点一个没有父节点,所以如何表示父节点就是解决问题。抓住这里就有点明朗了。
而有根树的父节点存储方式一般利用一个数组p[],而如何读图,在无权值的情况下书本给出了利用vector可以有效减少内存空间。
书本代码解释
vector<int> G[maxn]; //用vector来代替邻接矩阵减少空间开销
int p[maxn]; //存储节点的父节点,每个结点都有一个编号(知道为图的节点编号很重要)
void read_tree() {
int u, v, n;
scanf("%d", &n);
for(int i = 0; i < n; ++i) {
scanf("%d %d", &u, &v);
G[u].push_back(v); //无向图
G[v].push_back(u); //无向图
}
}
//主程序初始化p[root] = -1
int dfs(int root, int fa) {
int m = G[root].size();
for(int i = 0; i < m; ++i) {
int f = G[root][i]; //找到边的另外一个节点
if(f != fa) dfs(f, p[f] = root); //因为是无向图,所以要加一个判定条件,不然递归会无限下去
}
}
在不看书本给出的代码进行写时,发现自己一旦写dfs()函数总会思考在函数的开头进行一个递归退出的判定。应该要思考的是递归如何退出,退出条件是什么。
表达式树
用二叉树来构建表达式树
书本给出的明显是中缀表达式的
问题思考过程
- 什么样的数据结构
- 每次写二叉树我都是习惯是写一个结构体,然后有一个是左指针然后一个右指针,然后分配内存。
- 可是这样写并不适合竞赛。竞赛还是更适合用数组来写,这样写对我来说最大的好处就是避免了如果对于内存管理不熟悉而产生的错误。再一个就是当用到指针总觉得在建一个工程。心理压力会有点不同。
- 用数组来构建二叉树,需要为二叉树的节点编号。无论是自然而然的题目给出还是认为,编号这一步很重要必不可少。
- 怎么找最后一个运算符
- 无论是加减还是乘除都是左结合(就是如果同等优先级简单理解从左算到右),所以尽量往右找。
- 括号内的运算符必然是最先运算,向右扫描时需要被忽略。
- 因为加减和乘除的优先级不同,所以向右扫描加减和乘除必须要“分离”。假设只用一个变量来储存最后运算符,假设碰到了一个运算符,如果不是括号内的,必然需要加许多判断语句。又要找到许多的判定情况。假设判定条件简单,用一个变量,就是说碰到非括号内的运算符就进行赋值。可是乘除和加减会影响。不能够简单判断条件。所以书本用两个变量来储存是如何想到的,是否有迹可循,这才是最重要的。换句话说就是思考思考思考,然后自然而然的出现了用两个变量的想法。
第三点写的有点长,有点难表达自己的想法。
代码解释
const int maxn = 1000;
int lch[maxn], rch[maxn];
char op[maxn];
int nc = 0;
//调用这个函数时x为表达式第一个元素的下标,y为最后一个元素的后一个位置
int build_tree(char *s, int x, int y) { //y类似于最后一个元素的后一个位置。
int p = 0; //判断运算符是否在括号内
int u, c1 = -1, c2 = -1; //c1储存加减,c2储存乘除
if(y - x == 1) { //如果只剩一个元素
u = ++nc;
lch[u] = rch[u] = 0;
op[u] = s[x];
return u;
}
for(int i = x; i < y; ++i) [
switch(s[i]) {
case '(': p = 1; break; //如果碰到(,就p = 1,防止将括号内的运算符当成最右运算的
case ')': p = 0; break; //)解除
case '+':
case '-':
if(!p) c1 =i; // c1保存加减下标
break;
case '*':
case '/':
if(!p) c2 = i; //c2保持乘除下标
break;
}
}
if(c1 < 0) c1 = c2;
if(c2 < 0) return build_tree(s, x + 1, y - 1); //整个式子被括号围住
//可能会有疑问,没有式子会被括号包围吧,别忘记了子表达式~~~~~
u = ++nc;
lch[u] = build_tree(s, x, c1);
rch[u] = build_tree(s, c1 + 1, y);
op[u] = s[c1];
return u;
}
最小生成树
定义
给定无向图G=(V,E),连接G中所有点,且边集是E的子集的树称为G的生成树,而权值最小的生成树称为最小生成树(MST) --算法竞赛入门经典
kruskal算法
算法本身思想简单,但是如何实现和如何高效实现是个问题
算法过程
- 对于给定所有边进行排序,每次选择最小的边。最小生成树嘛,选边时肯定从权值小的选到权值大的。
- 初始化MST。
- 考察每一条边的两个端点,查看这两个端点是否在同一个连通分量,若在,则退出,若不将这条边加入MST也就是连通分量。
接下来不选权值为3的边,因为如果选了这条边,会形成回路。
所以关键怎么样去判断是否形成了回路
并查集
事实上用DFS和BFS都可以去判断是否连通性,但用这个就有点杀鸡用牛刀了。
因为我们找的是最小生成树,是一棵树,对于除了根节点之外的节点有且只有一个根节点,树天然就是连通图。所以只要考察是否同一棵树,就可以快速得到是否同一个连通分量。这个就是并查集的基础了。在并查集,树的根节点就是这棵树的代表元。所以只要考察树的根节点。
代码解释
const int maxn = 1000;
int U[maxn], V[maxn], P[maxn];
//p就是树的根节点,也可以说是并查集
int W[maxn], R[maxn];
/*为什么一个数组r保存数组编号呢。
*因为需要将边排序,排序之后原本数组下标的就代表边的编号就不一定了。
*而需要边的编号才能够根据边的编号得到边的两节点u, v
*/
bool cmp(int i, int j) {
return W[i] < W[j];
}
int find(int x) {
return P[x] == x ? x, P[x] = find(P[x]);
}
int read_graph(int m) {
}
int kruskal(int n, int m) {
for(int i = 0; i < n; ++i) p[i] = i; //初始化并查集
read_graph(m); //加一个初始化数据
//书本并没有read_graph
// for(int i = 0; i < m; ++i) r[i] = i;
sort(r, r + m, cmp);
//排序后在前面的编号就是权值小的
for(int i = 0; i < m; ++i) {
int e = r[i];
int x = find(U[e]); //寻找代表元,也就是根节点
int y = find(V[e]); //寻找代表元,也就是根节点
if(x != y) {
ans += w[e];
p[x] = y;
}
}
return ans;
}
并查集巧妙利用了树的特性
最短路问题(带权)
书本前面讲过无权和带权DAG(无环有向图)的最短路和最长路如何解决。
一个bfs/dfs,一个动态规划
如果带环,前面介绍的方法就不适用了。
单源最短路
Dijkstra算法(有向图)
理论上无向图可以按照有向图来处理。
算法思想
动态规划的思想特别是松弛操作
松弛操作
就是说两点之间相连,他们可以直接相连,也可以经过k(k < n - 2)个点相连。有可能直接相连权值更大,经过几个点后权值可能会更小
从图可以看出来,从A->C,从A->B,B->C也可以到达C。并且后者权值更小
算法过程
- 假设有一个数组d[],d[i]代表源点到达各点的最短路。除了源点到源点的距离d[s]初始化0,源点到其他点的值d[i]初始化INF。表示一开始源点到达各个点的距离为无穷大。
- 然后经过n次循环,每一次都进行找最小的d[i]值,然后利用d[i]值进行松弛。
还是图比较直观。如下图
假设源点为0点,一开始最短路如下
节点 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
距离d | INF | INF | INF | INF | INF |
->代表该两个有边相连
第一次循环找到d[i]值最小并且未被找过的点,这时候i = 0
因为0->1,0->4,所以要考察
d[1] > d[0] + w[0][1] ?
d[4] > d[0] + w[0][4] ?
动态规划的思想就在这里体现了。
第一次循环结束后最短路距离变成了。
节点 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
距离d | 0 | 1 | inf | inf | 3 |
在第二次循环,找到d[i]值最小且未被找过的点,这时候i = 1
以此类推 1->2, 1->3, 1->4,考察
d[2] > d[1] + w[1][2]?
d[3] > d[1] + w[1][3]?
d[4] > d[1] + w[1][4]?
第二次循环结束后最短路距离变成了
节点 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
距离d | 0 | 1 | 2 | 4 | 2 |
以此类推,在第三次循环
节点 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
距离d | 0 | 1 | 2 | 3 | 2 |
第四次循环
节点 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
距离d | 0 | 1 | 2 | 3 | 2 |
第五次循环
节点 | 0 | 1 | 2 | 3 | 4 |
---|---|---|---|---|---|
距离d | 0 | 1 | 2 | 3 | 2 |
书本代码解释
- 朴素
const int maxn = 1000;
const int inf = ~(1 << 31);
int w[maxn][maxn];
int d[maxn];
bool v[maxn];
void dijkstra_g(int n, int b) {
ini();读入图和初始化数据
d[b] = 0;
for(int i = 0; i < n; ++i) {
int mini = inf, x;
for(int j = 0; j < n; ++j) {
if(!v[j] && d[j] < mini) {
mini = d[x = j];
}
}
v[x] = 1;
for(int j = 0; j < n; ++j) {
//d[j] = min(d[j], d[x] + w[x][j]);
if(d[x] < inf && w[x][j] < inf && d[j] < d[x] + w[x][j]) {
d[j] = d[x] + w[x][j];
}
}
}
}
朴素代码有太多可以优化的地方了。不管是每次都需要循环来找最小d值,还是外层循环的次数。这些都是可以优化的地方。书本给出了优化代码,里面采用了优先队列priority_queue,这个队列默认维护了一个最大堆。很容易得到堆顶值。
优化的代码
struct Edge {
int from, to, dist;
Edge(int from, int to, int dist): from(from), to(to), dist(dist) {}
};
struct HeapNode {
int dist, u;
HeapNode(int dist, int u): dist(dist), u(u) {}
bool operator<(const HeapNode &rhs) { return dist > rhs.dist }
};
struct Dijkstra {
/*
*这个用了邻接表的想法
*每一个节点都保存着与它有关的边的编号
*/
int n, m;
vector<int> G[maxn];
vector<Edge> edges;
bool done[maxn]; //这个数组是否可以不用
int p[maxn]; //最短路的上一条弧
int d[maxn];
void dijkstra(int s) {
priority_queue<HeapNode> Q;
Q.push(HeapNode(0, s));
while(!Q.empty()) {
HeapNode h = Q.top();
Q.pop();
int u = h.u;
/*
*这两条语句是否用不着
*朴素代码需要判定是否被用过的原因是一整个数组都被遍历
*而这里,只是遍历了某个点连接的点,并且肯定是d值最小的
*if(done[u]) continue;
*done[u] = 1;
*/
Q.pop();
for(int i = 0; i < G[u].size(); ++i) {
//找到边编号,通过边编号找到边。
Edge e = edges[G[u][i]];
if(d[e.to] > d[e.from] + e.dist) {
d[e.to] = d[e.from] + e.dist;
Q.push(HeapNode(d[e.to], e.to);
}
}
}
}
}
Bellman-Ford算法
- 在边权可正可负的图中,为什么只要有负回路,意味着最短路不存在。
如果有负圈,像上图B->C->D,那么无论是d[B],d[C]还是d[D],总可以绕多几圈负圈,然后得到更小的值,所以负圈最短路是不存在的。 - 为什么进行n - 1轮松弛
像上图那样,这个是连通无环图求最短路的极限情况。所以至少要松弛n - 1轮。
朴素代码
for(int i = 0; i < n; ++i) d[i] = inf;
d[s] = 0;
for(int i = 0; i < n - 1;++i) {
for(int j = 0; j < m; ++j) {
int x = u[i], y = v[i];
if(d[x] < inf && d[y] > d[x] + w[x][y]) {
d[y] = d[x] + w[x][y];
}
}
}
优化代码
带FIFO队列的优化代码
也就是SPFA算法
bool bellman-ford(int s, int n, int m) {
queue<int> q;
int cnt[maxn], inq[maxn];
memset(cnt, 0, sizeof(cnt));
memset(inq, 0, sizeof(inq));
int d[maxn];
d[s] = 0;
for(int i = 0; i < n; ++i) d[i] = inf;
q.push(s);
inq[s] = true;
while(!q.empty()) {
int u = q.front();
inq[u] = true;
q.pop();
for(int i = 0; i < G[u].size(); ++i) {
Edge e = edges[G[u][i]];
if(d[e.to] > d[u] + e.dist) {
d[e.to] = d[u] + e.dist;
}
if(!inq[e.to]) {
q.push(e.to);
inq[e.to] = true;
if(++cnt[e.to] > n) return false;
}
}
}
}
上面代码有一句需要特别注意
if(++cnt[e.to] > n) return false;
- 上面有讲过,如果存在负回路,肯定不存在最短路。代码会进入死循环。
- 为什么要 > n, 如果如果一个图没有负回路,每一个节点都不可能进入队列超过n次。考虑一张图,存在某个节点a,每个节点都有一条边与它相连。ps:其实具体的证明想不出来,只能直观的认为。并且算导看不了,也不知算导有没有。
- 书本作者还说如何找到另外的负环,加一个节点。在想不到如何做的情况下,网络上也找不到资料~~~~~~
多源最短路
Floyd算法
- 动态规划思想
- 考虑两点之间相连,要不直接相连,要不就是通过k个点相连。
代码
int d[maxn][maxn];
void ini(int n) {
for(int i = 0; i < n; ++i) {
for(int j = 0; j < n; ++j) {
if(i == j) {
d[i][j] = 0;
}
else {
d[i][j] = inf
}
}
}
}
void read_graph(int m) {
for(int i = 0; i < m; ++i) {
int u, v, w;
cin >> u >> v >> w;
d[u][v] = w;
}
}
void floyd(int n) {
for(int k = 0; k < n; ++k) {
for(int i = 0; i < n; ++i) {
for(int j = 0; j < n; ++j) {
if(d[i][k] < inf && d[k][j] < inf) {
d[i][j] = min(d[i][j], d[i][k] + d[k][j]);
}
}
}
}
}
/*
*如果去判断是不是连通,有向图的传递背包
*d[]数组初始化为0或1
*/
void floyd(int n) {
for(int k = 0; k < n; ++k) {
for(int i = 0; i < n; ++i) {
for(int j = 0; j < n; ++j) {
if(d[i][k] < inf && d[k][j] < inf) {
d[i][j] = d[i][j] || (d[i][k] && d[k][j]);
}
}
}
}
}
如果有学过动态规划,应该很好理解。
这个有点像一幅图,每个点依次被点亮。
如果您在浏览的过程中,发现有任何觉得有疑问或者错误的地方欢迎交流,一起进步