1 图论
1.1 vector表示邻接链表
首先我们定义一个结构体,包括邻接结点和边权值,用来表示一条边。
struct Edge {
int nextNode;//下一个结点编号
int cost;//该边的权重
};
我们为每一个结点都建立一个单链表来保存与其相邻的边权值和结点的信息。我们使用vector来模拟这些单链表,利用如下语句为每一个结点都建立一个vector对象(结点数量为N)。
vector<Edge> edge[N];
该语句建立了一个大小为N的数组,而数组中保存的元素即为vector对象,我们用edge[i]的vector来表示为结点i建立的单链表。
为了使用vector我们还需在C++源文件头部添加相应的头文件。
#include <vector>
using namespace std; //声明使用标准命名空间
下面,我们学习如何为这些“单链表”添加和删除信息。
利用
for (int i = 0;i < N;i ++) { //遍历所有结点
edge[i].clear(); //清空其单链表
}
来实现对这些单链表的初始化,即利用vector::clear()操作清空这些单链表。
当我们要向其中添加信息时,调用vector::push_back(Edge)。如下所示:
Edge tmp; //准备一个Edge结构体
tmp.nextNode = 3; //下一结点编号为3
tmp.cost = 38; //该边权值为38
edge[1].push_back(tmp); //将该边加入结点1的单链表中
当我们需要查询某个结点的所有邻接信息时,则对vector进行遍历。
for (int i = 0;i < edge[2].size();i ++) { //对edge[2]进行遍历,即对所有与结点2相邻的边进行遍历,edge[2].size()表示其大小
int nextNode = edge[2][i].nextNode; //读出邻接结点
int cost = edge[2][i].cost; //读出该边权值
}
可见,对使用vector实现的邻接链表的访问非常类似于对二维数组的访问,但是其每行的长度是根据边的数量动态变化的。
当我们需要删除某个单链表中的某些边信息时,我们调用vector::erase。
若我们要删除结点1的单链表中edge[1][i]所对应的边信息时,我们使用如下语句:
edge[1].erase(edge[1].begin() + i,edge[1].begin() + i + 1);
//即vector.erase(vector.begin() + 第一个要删除的元素编号,vector.begin() + 最后一个要删除元素的编号 + 1
读者只要记住如上vector的基本用法,就能使用其来模拟单链表,并能对这些单链表进行清空、添加、删除、遍历等操作。
本节说明图的基本概念,和图的两种保存形式,固不安排例题与练习题,但读者必须牢记vector使用方法,以期能够用vector来模拟单链表(假设你对建立单链表没有其他更好的方法)。
函数原型:
iterator erase (iterator position); //删除指定元素
iterator erase (iterator first, iterator last); //删除指定范围内的元素
1.2 并查集
这种数据结构用来表示集合信息,用以实现如确定某个集合含有哪些元素、判断某两个元素是否存在同一个集合中、求集合中元素的数量等问题。
另外若需要在查找过程中添加路径压缩的优化,我们修改以上两个函数为:
int findRoot(int x) {
if (Tree[x] == -1) return x;
else {
int tmp = findRoot(Tree[x]);
Tree[x] = tmp; //将当前结点的双亲结点设置为查找返回的根结点编号
return tmp;
}
}
P1畅通工程
某省调查城镇交通状况,得到现有城镇道路统计表,表中列出了每条道路直
接连通的城镇。省政府“畅通工程”的目标是使全省任何两个城镇间都可以实现交通(但不一定有直接的道路相连,只要互相间接通过道路可达即可)。问最少还需要建设多少条道路?
输入:
测试输入包含若干测试用例。每个测试用例的第1行给出两个正整数,分别是城镇数目N ( < 1000 )和道路数目M;随后的M行对应M条道路,每行给出一对正整数,分别是该条道路直接连通的两个城镇的编号。为简单起见,城镇从1到N编号。当N为0时,输入结束,该用例不被处理。
输出:
对每个测试用例,在1行里输出最少还需要建设的道路数目。
样例输入:
4 2
1 3
4 3
3 3
1 2
1 3
2 3
5 2
1 2
3 5
999 0
0
样例输出:
1
0
2
998
题面中描述的是一个实际的问题,但该问题可以被抽象成在一个图上查找连通分量(彼此连通的结点集合)的个数,我们只需求得连通分量的个数,就能得到答案(新建一些边将这些连通分量连通)。这个问题可以使用并查集完成,初始时,每个结点都是孤立的连通分量,当读入已经建成的边后,我们将边的两个
顶点所在集合合并,表示这两个集合中的所有结点已经连通。对所有的边重复该操作,最后计算所有的结点被保存在几个集合中,即存在多少棵树就能得知共有多少个连通分量(集合)。
#include<bits/stdc++.h>
using namespace std;
int tree[1000];
int findRoot(int x){
if(tree[x]==-1)
return x;
int temp=findRoot(tree[x]);
tree[x]=temp;
return temp;
}
int main()
{
int m,n;
while(cin>>n>>m){
for(int i=0;i<n;i++)
tree[i]=-1;
int a,b;
for(int i=0;i<m;i++){
cin>>a>>b;
int ra=findRoot(a);
int rb=findRoot(b);
if(ra!=rb){
tree[ra]=rb;
}
}
int ans=0;
for(int i=0;i<n;i++){
if(tree[i]==-1){
ans++;
}
}
cout<<ans-1<<endl;
}
return 0;
}
1.3 最小生成树
在一个无向连通图中,如果存在一个连通子图包含原图中所有的结点和部分边,且这个子图不存在回路,那么我们称这个子图为原图的一棵生成树。在带权图中,所有的生成树中边权的和最小的那棵(或几棵)被称为最小生成树。
Kruskal算法
我们将要介绍的求最小生成树Kruskal算法的算法原理,它按照按如下步骤求解最小生成树:
1.初始时所有结点属于孤立的集合。
2.按照边权递增顺序遍历所有的边,若遍历到的边两个顶点仍分属不同的集合(该边即为连通这两个集合的边中权值最小的那条)则确定该边为最小生成树上的一条边,并将这两个顶点分属的集合合并。
3.遍历完所有边后,原图上所有结点属于同一个集合则被选取的边和原图中所有结点构成最小生成树;否则原图不连通,最小生成树不存在。
如步骤所示,在用Kruskal算法求解最小生成树的过程中涉及到大量的集合操作,我们恰好可以使用上一节中讨论的并查集来实现这些操作。
P1还是畅通工程
某省调查乡村交通状况,得到的统计表中列出了任意两村庄间的距离。省政府“畅通工程的目标是使全省任何两个村庄间都可以实现公路交通(但不一定有直接的公路相连,只要间接通过公路可达即可),并要求铺设的公路总长度为最小。请计算最小的公路总长度。
输入:
测试输入包含若干测试用例。每个测试用例的第1行给出村庄数目N ( < 100 );随后的N(N-1)/2行对应村庄间的距离,每行给出一对正整数,分别是两个村庄的编号,以及此两村庄间的距离。为简单起见,村庄从1到N编号。当N为0时,输入结束,该用例不被处理。
输出:
对每个测试用例,在1行里输出最小的公路总长度。
样例输入:
3
1 2 1
1 3 2
2 3 4
4
1 2 1
1 3 4
1 4 1
2 3 3
2 4 2
3 4 5
0
样例输出:
3
5
在给定的道路中选取一些,使所有的城市直接或间接连通且使道路的总长度最小,该例即为典型的最小生成树问题。我们将城市抽象成图上的结点,将道路抽象成连接点的边,其长度即为边的权值。经过这样的抽象,我们求得该图的最小生成树,其上所有的边权和即为所求。
#include<bits/stdc++.h>
using namespace std;
int tree[101];
struct vlliage{
int s;
int e;
int dis;
bool operator <(const vlliage v)const{
return dis<=v.dis;
}
};
int findRoot(int x){
if(tree[x]==-1){
return x;
}
int temp=findRoot(tree[x]);
tree[x]=temp;
return temp;
}
int main()
{
int n;
while(cin>>n){
if(n==0)
break;
vector<vlliage> dists;
for(int i=1;i<=n;i++){
tree[i]=-1;
}
for(int i=0;i<n*(n-1)/2;i++){
vlliage vlli;
cin>>vlli.s>>vlli.e>>vlli.dis;
dists.push_back(vlli);
}
sort(dists.begin(),dists.end());
int ans=0;
for(int i=0;i<dists.size();i++){
int s=dists[i].s;
int e=dists[i].e;
int dis=dists[i].dis;
int rs=findRoot(s);
int re=findRoot(e);
if(rs!=re){
tree[rs]=re;
ans+=dis;
}
}
cout<<ans<<endl;
}
return 0;
}
1.4 最短路径
1.4.1 Floyd算法
for (int k = 1;k <= n;k ++) {
for (int i = 1;i <= n;i ++) {
for (int j = 1;j <= n;j ++) {
if (ans[i][k] == 无穷 || ans[k][j] == 无穷) continue;
if (ans[i][j] == 无穷 || ans[i][k] + ans[k][j] < ans[i][j])
ans[i][j] = ans[i][k] + ans[k][j];
}
}
}
#include <stdio.h>
int ans[101][101]; //二维数组,其初始值即为该图的邻接矩阵
int main () {
int n , m;
while (scanf ("%d%d",&n,&m) != EOF) {
if (n == 0 && m == 0) break;
for (int i = 1;i <= n;i ++) {
for (int j = 1;j <= n;j ++) {
ans[i][j] = -1; //对邻接矩阵初始化,我们用-1代表无穷
}
ans[i][i] = 0;//自己到自己的路径长度设为0
}
while(m --) {
int a , b , c;
scanf ("%d%d%d",&a,&b,&c);
ans[a][b] = ans[b][a] = c; //对邻接矩阵赋值,由于是无向图,该赋值操作要进行两次
}
for (int k = 1;k <= n;k ++) { //k从1到N循环,依次代表允许经过的中间结点编号小于等于k
for (int i = 1;i <= n;i ++) {
for (int j = 1;j <= n;j ++) { //遍历所有ans[i][j],判断其值保持原值还是将要被更新
if (ans[i][k] == -1 || ans[k][j] == -1) continue; //若两值中有一个值为无穷,则ans[i][j]不能由于经过结点k而被更新,跳过循环,保持原值
if (ans[i][j] == -1 || ans[i][k] + ans[k][j] < ans[i][j])
ans[i][j] = ans[i][k] + ans[k][j]; //当由于经过k可以获得更短的最短路径时,更新该值
}
}
}
printf("%d\n",ans[1][n]); //循环结束后输出答案
}
return 0;
}
我们总结Floyd算法的特点。首先,牢记其时间复杂度为O(N^3),所以在大部分机试题的时间允许范围内,它要求被求解图的大小不大于200个结点,若超过该数字该算法很可能因为效率不够高而被判超时。第二,Floyd算法利用一个二维矩阵来进行相关运算,所以当图使用邻接矩阵表示时更为方便。若原图并非由邻接矩阵给出时我们设法将其转换,注意当两个结点间有多余一条边时,我们选择长度最小的边权值存入邻接矩阵。第三,当Floyd算法完成后,图中所有结点之间的最短路都将被确定。所以,其较适用于要求询问多个结点对之间的最短路径长度问题,即全源最短路问题。了解它的这些特点,将有利于我们在特定问题中决定是否使用Floyd算法。
1.4.2 Dijkstra算法
1.初始化,集合K中加入结点1,结点1到结点1最短距离为0,到其它结点为无穷(或不确定)。
2.遍历与集合K中结点直接相邻的边(U,V,C),其中U属于集合K,V不属于集合K,计算由结点1出发按照已经得到的最短路到达U,再由U经过该边到达V时的路径长度。比较所有与集合K中结点直接相邻的非集合K结点
该路径长度,其中路径长度最小的结点被确定为下一个最短路径确定的结点,其最短路径长度即为这个路径长度,最后将该结点加入集合K。
3.若集合K中已经包含了所有的点,算法结束;否则重复步骤2。
#include <stdio.h>
#include <vector>
using namespace std;
struct E{ //邻接链表中的链表元素结构体
int next; //代表直接相邻的结点
int c; //代表该边的权值(长度)
};
vector<E> edge[101]; //邻接链表
bool mark[101];//标记,当mark[j]为true时表示结点j的最短路径长度已经得到,该结点已经加入集合K
int Dis[101]; //距离向量,当mark[i]为true时,表示已得的最短路径长度;否则,表示所有从结点1出发,经过已知的最短路径达到集合K中的某结点,再经过一条边到达结点i的路径中最短的距离
int main () {
int n, m;
while (scanf ("%d%d",&n,&m) != EOF) {
if (n == 0 && m == 0) break;
for (int i = 1;i <= n;i ++) edge[i].clear(); //初试化邻接链表
while(m --) {
int a , b , c;
scanf ("%d%d%d",&a,&b,&c);
E tmp;
tmp.c = c;
tmp.next = b;
edge[a].push_back(tmp);
tmp.next = a;
edge[b].push_back(tmp); //将邻接信息加入邻接链表,由于原图为无向图,固每条边信息都要添加到其两个顶点的两条单链表中
}
for (int i = 1;i <= n;i ++) { //初始化
Dis[i] = -1; //所有距离为-1,即不可达
mark[i] = false; //所有结点不属于集合K
}
Dis[1] = 0; //得到最近的点为结点1,长度为0
mark[1] = true; //将结点1加入集合K
int newP = 1; //集合K中新加入的点为结点1
for (int i = 1;i < n;i ++) { //循环n-1次,按照最短路径递增的顺序确定其他n-1个点的最短路径长度
for (int j = 0;j < edge[newP].size();j ++) { //遍历与该新加入集合K中的结点直接相邻的边
int t = edge[newP][j].next; //该边的另一个结点
int c = edge[newP][j].c; //该边的长度
if (mark[t] == true) continue; //若另一个结点也属于集合K,则跳过
if (Dis[t] == - 1 || Dis[t] > Dis[newP] + c) //若该结点尚不可达,或者该结点从新加入的结点经过一条边到达时比以往距离更短
Dis[t] = Dis[newP] + c; //更新其距离信息
}
int min = 123123123; //最小值初始化为一个大整数,为找最小值做准备
for (int j = 1;j <= n;j ++) { //遍历所有结点
if (mark[j] == true) continue; //若其属于集合K则跳过
if (Dis[j] == -1) continue; //若该结点仍不可达则跳过
if (Dis[j] < min) { //若该结点经由结点1至集合K中的某点在经过一条边到达时距离小于当前最小值
min = Dis[j]; //更新其为最小值
newP = j; //新加入的点暂定为该点
}
}
mark[newP] = true;//将新加入的点加入集合K,Dis[newP]虽然数值不变,但意义发生变化,由所有经过集合K中的结点再经过一条边到达时的距离中的最小值变为 从结点1到结点newP的最短距离
}