一、实验目的
(1)掌握回溯法算法设计思想
(2)掌握地图填色问题的回溯法解法。
二、实验背景与要求
背景知识:
为地图或其他由不同区域组成的图形着色时,相邻国家/地区不能使用相同的颜色。 我们可能还想使用尽可能少的不同颜色进行填涂。一些简单的“地图”例如棋盘仅需要两种颜色,但是大多数复杂的地图需要更多颜色。
每张地图包含四个相互连接的国家时,它们至少需要四种颜色。1852年,植物学专业的学生弗朗西斯·古思里于1852年首次提出“四色问题”。他观察到四种颜色似乎足以满足他尝试的任何地图填色问题,但他无法找到适用于所有地图的证明。这个问题被称为四色问题。长期以来,数学家无法证明四种颜色就够了,或者无法找到需要四种以上颜色的地图。直到1976年德国数学家沃尔夫冈·哈肯和肯尼斯·阿佩尔使用计算机证明了四色定理,他们将无数种可能的地图缩减为1936种特殊情况,每种情况都由一台计算机进行了总计超过1000个小时的检查。他们因此工作获得了美国数学学会富尔克森奖。在1990年,哈肯成为伊利诺伊大学高级研究中心的成员,他现在是该大学的名誉教授。
四色定理是第一个使用计算机证明的著名数学定理,此后变得越来越普遍,争议也越来越小 更快的计算机和更高效的算法意味着今天您可以在几个小时内在笔记本电脑上证明四种颜色定理。
问题描述:
将地图转换为平面图,每个地区变成一个节点,相邻地区用边连接,为这个图形的顶点着色,并且两个顶点通过边连接时必须具有不同的颜色。附件是给出的地图数据,请针对三个地图数据尝试分别使用5个(le4505a),15个(le45015b),25个(le450_25a)颜色为地图着色。
实验要求:
2、对附件中给定的地图数据填涂;
3、随机产生不同规模的图,分析算法效率与图规模的关系(四色)。
三、实验步骤与结果
关于地图数据读取
顶点定义如下,记录了该顶点的所选颜色、对于每种颜色该顶点是否可选、可选颜色的数量和相邻点的数量即顶点的度。
注:0代表没颜色,颜色从1开始,COLOR为颜色总数。
- struct Vertex
- {
- int color; //该点所选的颜色
- int state[COLOR + 1]; //颜色状态,1为可选,非1为不可选
- int choice; //可选颜色的数量,即state中1的数量
- int degree; //相邻点的数量,即该点的度
- Vertex() {
- color = 0; //默认该顶点没颜色
- for (int i = 0; i <= COLOR; ++i)
- state[i] = 1; //默认初始化为全部颜色都可选
- choice = COLOR; //默认初始化为全部颜色都可选
- degree = 0; //默认初始化度为0
- }
- };
我通过邻接表来储存每个节点的相邻节点信息,包括当前节点的相邻节点总数(也就是度)和相邻节点具体是哪些(即记录当前节点的所有相邻节点编号)。
int Map[VERTEX + 1][256];
//邻接表,Map[i][0]表示第i个节点相邻节点的数量,Map[i][j]表示第i个节点的第j个相邻节点。
回溯法基础版本
回溯法的基本思想是从问题的初始状态出发,逐步地尝试所有可能的解决方案,当发现当前的解决方案不符合要求时,就回溯到之前的状态,尝试其他可能的解决方案,直到找到符合要求的解决方案或者已经尝试了所有的解决方案。
在地图填色问题中,回溯法具体应用如下:对当前进行填色时,如果颜色合法,则填下一个节点。如果所填颜色非法,则不继续搜索,而是回溯到上一层的节点填别的颜色。
大致描述了我想法的伪代码如下:
- void DFS(current,count){
- if(count==点总数){
- sum+=S[current].剩余可选颜色数 //到达叶子节点,统计结果
- }
- for(i = 1 to 颜色总数){
- if(当前颜色i可选){
- S[current].color=i;
- }
- if(check(current,i)){ //检查当前节点填颜色i是否合法
- DFS(下一个节点)
- //合法则DFS继续搜索后再回溯,不合法就不继续搜索了直接回溯
- }
- 回溯
- }
- }
探索如何剪枝
剪枝策略1:向前探测——check函数的实现
在上面的伪代码中我通过check函数其实做到了一种剪枝策略,那么这个check函数要如何实现呢?
check函数即在填涂当前节点颜色时,对当前节点的相邻待填色节点进行遍历,若此节点填当前颜色i后会导致其相邻待填色节点中有节点无色可填,那么表明当前节点填颜色i是不可行的,是没有前途的。所以我们可以对当前节点填颜色i这种情况不继续向下探索,而是直接回溯,这样就做到了剪枝。
下面是一个示例(可供选择的颜色为红黄蓝绿4种),观察下图可以发现,在当前已填涂三个区域的情况下,将进行填涂第四个区域的操作,该区域剩余两种颜色可供选择。若选择红色,那么对该区域的相邻节点进行遍历未出现可选颜色为0的情况,因此保留;若选择蓝色,那么该区域的相邻节点中就有节点(即圆形区域)会出现可选颜色为0的情况,因此对此进行剪枝。
图1 - 剪枝策略-向前探查法分析例子
根据上面的思路实现的check函数具体代码如下:
- bool check(Vertex* S,int current,int i){
- //传进节点数组、当前节点序号current和其所选的颜色i
- for (int k = 1; k <= Map[current][0]; ++k) { //遍历当前节点的所有相邻节点
- int j = Map[current][k]; //j是遍历到的相邻节点序号
- if (S[j].color == 0 && S[j].state[i] == 1) {
- //若顶点j为待填色节点且可以填颜色i
- S[j].state[i] = -current; //就使得顶点j不能选i这个颜色
- S[j].choice--; //顶点j的可选颜色数量-1
- if (!S[j].choice ) {
- //若顶点j的可选颜色数量-1之后变为了0,返回false
- return false;
- }
- }
- }
- return true; //若不会导致出现相邻顶点无色可选的情况则返回true
- }
剪枝策略2:MRV+DH——选点策略的实现
上面描述DFS函数的大致伪代码的9-12行如下:
if(check(current,i)){ //检查当前节点填颜色i是否合法
DFS(下一个节点)
//合法则DFS继续搜索后再回溯,不合法就不继续搜索了直接回溯
}
我在剪枝策略1中给出了check函数的大致实现,那么如果check函数检查合格,程序就要从未填色的点中找到下一个要着色的点进行着色,所以如何挑选下一个需要着色的顶点就是一个值得思考的问题。在阅读了网上相关资料和老师课件中有关地图填色的内容后,我选择使用MRV最少剩余量选择策略 + DH最大度选择策略,其中优先使用MRH,其次DH。
- MRV(最少剩余量选择):在待填色的节点中寻找可选颜色数最小的节点。
- DH(度最大选择):在可选颜色数最小的节点中选择度最大的作为下一个需要着色的节点。
为什么实现了剪枝?
MRH:优先填涂可选颜色数最少的节点,即先对容易导致失败的节点填涂,那么便可以早些发现是否当前填色方案必然失败,从而可以提前回溯,避开不少的不可行解。
DH:优先对度最多的节点进行填色,假设填色为c,因为度最多的节点约束了最多的其他节点,使得最多的其他节点都不会去探索节点填色为c的情况(因为不合法),使得最多的其他节点的填色为c的分枝被剪掉了,从而实现了剪枝。
选点函数实现的具体代码:
- int getNext(Vertex* S) { //优先MRH,其次DH
- auto Min = COLOR;//最小的可选颜色数量,初始化为最大
- int next = 0; //记录下一个需要着色的节点的序号
- for (int i = 1; i <= VERTEX; ++i) {
- if (!S[i].color ) { //从未填色的点中找到下一个要着色的点
- if (S[i].choice == Min) {
- //在可选颜色数最小的节点中选择度最大的作为下一个需要着色的节点
- if (S[i].degree > S[next].degree) {
- Min = S[i].choice;
- next = i;
- }
- }
- else if (S[i].choice < Min) {//寻找可选颜色数最小的节点
- Min = S[i].choice;
- next = i;
- }
- }
- }
- return next; //返回下一个需要着色的节点的序号
- }
通过小规模数据测试算法正确性
算法优化剪枝至此,我对实验要求给出的小规模数据,利用四色填色测试算法的正确性。
注:以下所有运行时间均是我在自己的计算机上在VS2022的Release模式下跑出的。
- 对于该小地图,首先我对各个区域进行编号如下,并将区域间的邻接关系记录于smallMapExample.txt文档中。
图2 - 对小地图各个区域编号并记录区域间的邻接关系
- 我利用四色填色测试了算法的正确性,从下图可以看到对于这个小地图,程序在<0.5ms内找到了全部480个解,解的个数正确,算法是正确的。
通过上面的对小地图的测试,我们知道该算法是正确的的,那么该算法是否有继续优化改进的空间?
剪枝策略3:我称为“树层去重”
前提:每个节点填色时都按颜色编号从小到大尝试填涂(从1到COLOR)
我在阅读相关资料和自己思考后,得到:对于回溯树形结构的同层节点(也即对应于图的同一个节点填不同颜色),一旦某节点填涂了一个之前节点都没用过的新颜色c,我们假设由该节点填涂该新颜色c不断拓展最后得到的可行解的数量为n,那么该节点填涂其他新颜色(序号>c序号)不断拓展最后得到的可行解的数量也为n,故我们可以利用这个已知剪去大量的分支。
具体操作是一旦同层节点的某节点填涂了一个之前节点都没用过的新颜色,那么我们在不断深搜计算得到由该节点填涂该新颜色不断拓展最后得到的可行解的数量n后,就不再去深搜计算该节点填其他新颜色时的解的数量,因为该节点填其他新颜色时解的数量也为n,既然解的个数是一样的,那么就算一次就足够了。统计解总个数的变量sum += n×新颜色总数。我们对该树形结构的每一层都做这样的剪枝操作,那么可以想象我们将减去该树形结构大部分的分枝。
我将这种剪枝策略称为“树层去重”。
图3 - 用于讲解“树层去重”的图
至此,我对我代码中的所有降低运行时间的方法都讲解完毕,在此做个总结。
- 运用邻接表而不是邻接矩阵存储图的信息,这使得在寻找每个节点的相邻节点时不需要遍历整个邻接矩阵而是只需要遍历该节点的相邻节点数组即可。
- 也即剪枝策略1,向前探测——check函数的实现
- 也即剪枝策略2,MRV+DH——选点策略的实现
- 也即剪枝策略3,“树层去重”的实现
根据实验要求,对附件中给定的地图数据填涂
①得到第一个可行解的时间:
特别的,450点15色我开始并不能得到第一个可行解的时间,在跟老师讲演此实验时老师告诉我可以将第一个要着色的点指定为编号为4的点,我在尝试后确实成功得到了第一个可行解的时间。
450点5色 | 450点15色 | 450点25色 | |
运行时间 | 0.033s | 0.147s | 0.001s |
②尝试得到全部可行解:
1. 对于450点5色图,可见经过重重优化后仅用0.077s就可得到全部的3840个解。
2. 对于450点15色图,我开始发现无法得到其全部解的个数,久久运行不出来。后我令解的数量sum>10亿后就退出来,得到sum>10亿后退出来的运行时间为13.218s:
3. 对于450点25色图,我开始发现无法得到其全部解的个数,久久运行不出来。后我令解的数量sum>10亿后就退出来,得到sum>10亿后退出来的运行时间为7.683s:
根据上面数据可以绘制下面表格:
450点5色 | 450点15色 | 450点25色 | |
运行时间 | 0.077s | 13.218s | 7.683s |
解的个数 | 3840 | 限制sum>10亿就退出 | 限制sum>10亿就退出 |
自行生成地图涂色并分析:
通过随机数生成器,我自行生成了不同的地图。我将程序运行5次而计算得到的运行时间平均值作为记录在表格和图像的运行时间。对于每次比较时,均需要保证其余变量的值不变,解的数量sum>10亿就退出,分析统计运行时间作表格和图像如下:
图规模对运行时间的影响:
注:对于不同的点数,边数均为点数的2倍,颜色总数均为4。
100点200边 | 200点400边 | 300点600边 | 400点800边 | |
运行时间 | 13.049s | 35.63s | 51.339s | 68.2158s |
图4 - 图规模对运行时间的影响
从上面的表格和曲线图可知:随着图规模的增大,算法的运行时间也随之增大。
分析原因:由于回溯树形结构同层节点即对应于图的同一个节点填不同颜色,故我们容易知道随着图规模的增大,点的个数增多,那么回溯树形结构的层数也就增多,整棵树也就更为庞大,故算法的运行时间也随之增大。
图的边数对运行时间的影响:
注:颜色总数均为4。
200点150边 | 200点200边 | 200点300边 | 200点 400边 | |
运行时间 | 48.9694s | 42.1784s | 38.0164s | 32.444s |
图5 - 图的边数对运行时间的影响
从上面的表格和曲线图可知:在图的点数相同的情况下,图的边数越多,算法的运行时间就越小。
分析原因:由于图的边代表两个点之间的邻接关系,故在图的点数相同的情况下,图的边数越多就会导致点的平均度数增多(即点的平均相邻点个数增多),又因为在前面的向前探测剪枝时我们已知某个点填了颜色c后将会将会使其相邻节点都无法填颜色c,即将会把其相邻节点填颜色c的分枝剪掉。现在随着边数的增加,点的平均相邻点个数增多了,那么对于某个点来说,它向前探测剪枝能剪掉的分枝就变多了。而点数不变意味着回溯树形结构的层数不变。在回溯树形结构层数不变的情况下能剪掉的分枝变多了,算法的运行时间自然就变小了。
实验结论和体会
结论:
这个实验让我学习了回溯法的基本思想和具体应用,让我感受到了回溯法的强大和灵活。我认识到了回溯法是是一种选优搜索法,又称为试探法,按选优条件向前搜索,以达到目标。我也知晓了如何用剪枝函数来优化回溯法,使其能够更快地找到问题的解。
体会:
- 一开始直接想到的回溯算法往往效率较低,但我们可以通过仔细分析题目来思考出各种合适的剪枝策略来优化回溯算法,提高其效率。
- 可以用数学逻辑思维或方法对回溯算法做出优化,例如在本题的剪枝策略3 - “树层去重”中,我正是通过数学逻辑思维与方法对于同树层的很多节点进行了剪枝,从而大大缩小了算法的运行时间。
- 除了考虑优化算法本身,选择合适的数据结构也能在一定程度上降低程序的运行时间。例如在本题中我选择使用邻接表而不是邻接矩阵来存储图的信息,这使得我得以更快的找到每个节点的相邻节点。
代码附上:
#include <iostream>
#include <ctime>
using namespace std;
#define COLOR 5 //可选颜色总数
#define VERTEX 450 //点数
#define EDGE 5714 //边数
struct Vertex
{
int color; //该点所选的颜色
int state[COLOR + 1]; //颜色状态,1为可选,非1为不可选
int choice; //可选颜色的数量,即state中1的数量
int degree; //相邻点的数量,即该点的度
Vertex() {
color = 0; //默认该顶点没颜色
for (int i = 0; i <= COLOR; ++i)
state[i] = 1; //默认初始化为全部颜色都可选
choice = COLOR; //默认初始化为全部颜色都可选
degree = 0; //默认初始化度为0
}
};
int Map[VERTEX + 1][256]; //邻接表,Map[i][0]表示第i个节点相邻节点的数量,Map[i][j]表示第i个节点的第j个相邻节点。
long long sum = 0; //记录解的个数
clock_t startTime, endTime;//秒级程序计时
bool check(Vertex* S,int current, int i) { //传进节点数组、当前节点序号current和其所选的颜色i
for (int k = 1; k <= Map[current][0]; ++k) { //遍历当前节点的所有相邻节点
int j = Map[current][k]; //j是遍历到的相邻节点序号
if (S[j].color == 0 && S[j].state[i] == 1) { //若顶点j为待填色节点且可以填颜色i
S[j].state[i] = -current; //就使得顶点j不能选i这个颜色
S[j].choice--; //顶点j的可选颜色数量-1
if (!S[j].choice) { //若顶点j的可选颜色数量-1之后变为了0,返回false
return false;
}
}
}
return true; //若不会导致出现相邻顶点无色可选的情况则返回true
}
int getNext(Vertex* S) {//优先MRH,其次DH
auto Min = COLOR;//最小的可选颜色数量,初始化为最大
int next = 0;
for (int i = 1; i <= VERTEX; ++i) {
if (!S[i].color) { //从未填色的点中找到下一个要着色的点
if (S[i].choice == Min) {
if (S[i].degree > S[next].degree) {
Min = S[i].choice; //在可选颜色数最小的节点中选择度最大的作为下一个需要着色的节点
next = i;
}
}
else if (S[i].choice < Min) {//用于寻找可选颜色数最小的节点
Min = S[i].choice;
next = i;
}
}
}
return next;
}
//核心的回溯函数
int DFS(Vertex* S, int current, int count, int usedColor) { //count是已经着色的点的数量
if (count == VERTEX) { //到达叶子节点,找到一个着色方案
sum += S[current].choice;
return S[current].choice;
}
else {
int s = 0;
for (int i = 1; i <= COLOR; i++) {
if (S[current].state[i] == 1) {
int ss = 0;
S[current].color = i;
auto isNewColor = i > usedColor;
//剪枝
if (check(S, current, i)) {
if (isNewColor)
ss = DFS(S, getNext(S), count + 1, usedColor + 1);
else
ss = DFS(S, getNext(S), count + 1, usedColor);
}
//回溯
S[current].color = 0;
for (int k = 1; k <= Map[current][0]; ++k) {
int j = Map[current][k];
if (S[j].state[i] == -current) {
S[j].choice++;
S[j].state[i] = 1;
}
}
//关键剪枝
if (isNewColor) {
s += ss * (COLOR - usedColor);
sum += ss * (COLOR - usedColor - 1);
break;
}
s += ss;
}
}
if (sum > 1000000000) {
endTime = clock();
printf("sum>10亿后退出,运行时间为:\n");
cout << (double)(endTime - startTime) / CLOCKS_PER_SEC << "s" << endl;
exit(1);
}
else
return s;
}
}
int main() {
Vertex S[VERTEX + 1];
FILE* fp = nullptr;
cout << "输入1代表实验要求中给出的小地图数据,输入2代表450点5色,输入3代表450点15色,输入4代表450点25色,记得要在最上面的宏定义中修改相应的颜色数、点数和边数!" << endl;
int fileNumber;
cin >> fileNumber;
if (fileNumber == 1) {
if ((fp = fopen("C:\\Users\\ASUS\\Desktop\\算法实验三\\地图数据\\smallMapExample.txt", "r")) == nullptr) { //打开文件
printf("未能找到文件!\n");
exit(1);
}
}
else if (fileNumber == 2) {
if ((fp = fopen("C:\\Users\\ASUS\\Desktop\\算法实验三\\地图数据\\le450_5a - 副本.txt", "r")) == nullptr) {
printf("未能找到文件!\n");
exit(1);
}
}
else if (fileNumber == 3) {
if ((fp = fopen("C:\\Users\\ASUS\\Desktop\\算法实验三\\地图数据\\le450_15b - 副本.txt", "r")) == nullptr) {
printf("未能找到文件!\n");
exit(1);
}
}
else {
if ((fp = fopen("C:\\Users\\ASUS\\Desktop\\算法实验三\\地图数据\\le450_25a - 副本.txt", "r")) == nullptr) {
printf("未能找到文件!\n");
exit(1);
}
}
char ch;
int u, v;
for (int i = 1; i <= EDGE; i++) {
fscanf(fp, "%c%d%d\n", &ch, &u, &v);
Map[u][0]++;
Map[u][Map[u][0]] = v;
Map[v][0]++;
Map[v][Map[v][0]] = u;
S[u].degree++;
S[v].degree++;
}
printf("成功读取了地图数据,进行地图填色~\n");
fclose(fp);
startTime = clock();//计时开始
auto ans = DFS(S, 4, 1, 0);
endTime = clock();//计时结束
cout << "运行时间为:" << (double)(endTime - startTime) / CLOCKS_PER_SEC << "s" << endl;
printf("解的个数:%d\n", ans);
return 0;
}