一、实验目的与要求
实验目的:
1. 掌握回溯法算法设计思想。
2. 掌握地图填色问题的回溯法解法。
实验要求:
1. 在blackboard提交电子版实验报告。
2. 详细给出算法思想与实现代码之间的关系解释,不可直接粘贴代码。
3. 作为实验报告附件上传源代码。
4. 在实验课现场运行代码验证。
二、实验内容与方法
1. 对下面这个小规模数据,利用四色填色测试算法的正确性;
2. 对附件中给定的地图数据填涂;
3. 随机产生不同规模的图,分析算法效率与图规模的关系(四色)。
三、实验步骤与过程
1. 算法设计与优化:
准备工作:
首先需要定义节点的存储方式与结构,点类的成员包含:当前颜色、可选颜色数组,第i个颜色可选时此数组对应位置将会置为1、邻接表,用于记录每个节点的相邻节点。补充说明:此处使用邻接表显然是由于邻接矩阵的,对于一个有n个节点的图,邻接矩阵中要想找到某个节点的所有相邻节点,固定需要进行n-1次查找,而邻接表最多需要进行n-1次(当这个节点与其他所有节点互相连接时才需要),一般情况下邻接表的查找次数会比邻接矩阵少得多。
代码:定义节点结构
其次需要对给出的文件进行预处理:给出的文件是.col类型,使用记事本打开后可以看到其中除了节点信息之外还包含部分数据集的相关信息,复制出其中的有效信息,另存为一个同名的txt文件,方便程序进行读取。接下来即可开始算法的设计与编写。
(1)朴素深度优先:
首先选择一个节点作为起点,进入深度优先算法函数。接下来对于正在被遍历的某一个节点:
①如果这个节点是最后一个节点,返回其剩余的可选颜色数作为结果(有几个颜色就有几个结果)。
②首先检查其可以被染上的颜色(由更浅层的递归处理),然后轮流将其染上这些颜色。
③在单次染色中(颜色记为a),需要遍历此结点的所有邻接节点,对于遍历到的每一个相邻节点,如果这个相邻节点显示可以被染上a色,就将其改为不可,并将此相邻节点的可染颜色数自减1。
④将此节点的第一个相邻节点直接作为下一个用于遍历的节点。
⑤递归调用dfs函数,继续搜索,并统计返回值。
⑥搜索完成后,需要进行回溯,还原搜索前的状态:首先将这个点还原为未上色状态,接着将③步骤中所改变状态的所有节点还原。
⑦返回答案数。
算法实现的核心伪代码如下:
dfs(current,count){
if(count==点总数){
return N[current].color_state[0] // 返回最后一个点的可选颜色数
}
for(i = 1 to 颜色总数){
if(此颜色不可选)break;
if(check(current,i)){ //更新周围节点状态,并判断是否出现了无解情况
dfs(下一个节点)
}
回溯
}
返回结果
}
(2)优化方法:
①由于不设置优先级会导致运算极慢,进行了一个简单的优化:寻找下一个遍历的节点时,在当前节点的所有未上色相邻节点中寻找一个剩余可选颜色数最少的节点。而实验数据中的图不一定是连通的,所以需要额外进行一些处理:如果没有符合条件的邻接节点,就取整个图中可选颜色最少的节点作为接下来遍历的节点。
例子:下图中假设可用三种颜色,如果选择可选颜色较多的点4,那么可能会直接用掉第三个颜色导致点3无色可用,进行了无效的遍历;而如果选择了可选颜色较少的点3,点3只有第三个颜色可用,接下来遍历点4时也只有第一个颜色可用,直接找到了正确的解,并节省了遍历的时间。
图:优先选剩余可用颜色最少的点示例
②上一步中剩余可选颜色数最少的节点并不是等价的,可以选择其中度最大的节点,因为度最大的节点约束条件最多,也会给最多的其他点带来约束条件,将其优先上色,可以减少尝试失败而回溯的次数。
③经过实验发现,选择下一个点进行深度优先遍历时可以直接选择整个图中可选颜色最少,度最大的节点作为接下来遍历的节点,而不用考虑此节点是否与上一个节点相邻,且这样做运行时间更短,大约是②中的方法的一半。
②③合例:下图中,假设可用两种颜色,每个点初始状态下都可用两种颜色,如果选择点1作为起点,剩余三个点都只有一个颜色可选,直接得到了一个解。但是如果选择了其他点(例如2)作为起点,遍历过程中又选择了一个除1之外的点(如3),这样如果2,3选择的颜色不同,就会直接导致1无色可选。这种情况增加了遍历次数,因此需要优先选择度较大的节点。
④对第一个节点固定颜色,在此基础上进行实验,将得到的结果乘以允许的颜色总数c,即可直接得到答案。这种方法避免了对第一个点的颜色遍历,可以使运行时间直接降低为原本的\(\frac{1}{c}\)倍。
⑤(最关键步骤)对④中方法可以进一步优化。保持记录当前已经用到的颜色数量,对于遍历到的每一个点,对其进行上色的过程中,如果用到了一个之前从未用到的颜色c,那么在这种情况下,所有之前未用到的颜色数(总颜色数-当前已经用到的颜色数)与这个颜色c是等价的(后面会简要证明),那么在计算完颜色c对应的结果后,直接将结果乘以之前未用到的颜色数,即可退出遍历颜色的循环。
所有未用到的颜色等价证明:假设图已经上色完成,用到了c种颜色,那么对这c种颜色进行任意重排列,均可得到一个新的解。这样通过排列得到的解的个数为c!个。代码中由于递归调用,不能直接进行全排列。假设图已经上色完成,将其中的n个颜色进行轮换(例如颜色1,2,3可以转为3,1,2和2,3,1)可以得到n个不同的解。再由接下来的递归调用进行剩余的排列即可实现全排列。④中的优化在此处就相当于将所有的颜色进行轮换。将这些颜色设置为未用到的颜色,那么只需要找到一种情况下对应的解的数量,乘以未用到的颜色数即可得到最终答案。
⑥优化初始节点的选择,实验中初始节点的选择对结果有非常大的影响,较差的选择与较好的选择的所用时间可能有数量级的差距。在时间允许的情况下,可以多选择几个初始节点进行尝试,以找到最优解。对于难以多次测试的数据,也可以直接选择度最大的节点作为初始节点。
补充说明:关于向前检查的实现,本代码中使用check函数,既完成了当前解是否可行的判断,还更新了接下来的节点的状态,具体实现方式是:当前点染色完毕后,遍历其相邻节点,判断相邻节点是否可染此颜色,如果可,改为不可。最终如果发现有一个相邻节点没有颜色可用,则说明当前染色错误,直接进行下一个染色尝试。
2. 在小数据集上验证算法正确性:
对给出的小规模数据集进行处理:首先为每个区域编号,转化为数据结构图中的的节点,接着对每一个节点,与其相接壤的区域对应的节点就是这个点的邻接节点。
经过处理后的结果可视化如图:
图:处理后的小地图样本可视化
接着为方便程序读取。将其转化为文本形式。
接下来直接使用深度优先算法对其进行遍历,得到的解的数量是480个,验证了算法的正确性。
图:运行结果
3. 在给出的数据集上进行实验:
①结束遍历所用的时间:对于数据集le450_5a,共找到3840个解。不同版本代码的运行时间统计如下图(图中的初始版本是已经进行过优化①和②后的版本,因为如果不进行这两个优化,结果是无法在较短时间内计算出来的):
图:实验结果
其中,初始节点优化中,分别使用所有的节点作为初始节点,测试出以节点61为起点时时间最短,仅需0.01s即可找到所有的3840个解。
剩余两个数据集存在过多的解,无法在短时间内求得,图中给出的是使用剪枝优化算法找到一亿个解所用的时间。
图:数据集le450_15b实验结果
图:数据集le450_25a实验结果
实验中还发现,对于数据集le450_15b,需要使用节点4作为起始节点,否则将运行错误,无法得出结果。即使是度最多的节点32,也无法得到结果。
②找到一个解所用的时间:分别使用上述的算法进行深度优先遍历,但是在找到第一个解之后直接退出程序,结果如图:
图:找到第一个解所需时间
可见,除了初始版本代码在le450_15b数据集中未找到解外,其他各个算法在统一数据集下的时间时相近的,这是因为剪枝理论上并不会对找到单个解的时间有所优化。
4. 随机生成不同规模的数据集进行实验:
①将可用颜色数固定为4,边数设置为节点数的两倍,随机生成所有边,进行深度优先遍历。由于随机生成的数据可能会有极多的解,以下对找到一个解所用的时间和找到一千万组解所用的时间进行讨论。不同节点数下找到一个解所用的时间如图:
图:不同节点数下找到一个解所用时间
将时间单位转为ms,绘图,结果如下:
图:节点数与时间的规律
由图可见,找到一个解的时间与节点的个数呈指数关系。
不同节点数下找到一千万个解的时间:
图:不同节点数下找到一千万个解所用时间
将时间单位转为ms,绘图,结果如下:
图:节点数与时间的规律
②将节点数固定为1000,边数固定为2000,改变可用颜色数进行深度优先遍历。找到一个解所用时间的结果如图:
图:不同可用颜色数下找到一个解的时间
将时间单位转为ms,绘图,结果如图:
图:颜色数与找到第一个解的时间的关系
由图可见,颜色数与找到第一个解的时间呈现弱相关关系。
找到第一千万个解的实验结果如图:
图:不同可用颜色数下找到一千万个解的时间
绘图,结果如图:
图:颜色数与找到一千万个解的时间关系
随着可用颜色数的增加,找到一千万个解所用的时间迅速减少,直到趋近于找到一个解所用的时间。这是因为算法中设计了剪枝(上述优化第⑤点),随着颜色数的增加,每次遍历能找到的解的数量呈指数级增加。到颜色数充足时,只需要一次遍历就可以找到一千万个解,故趋近于找到一个解的时间。
③固定颜色数为4,节点数为1000,改变边数,进行测试,结果如图:
一个解的实验结果:
将时间单位转化为ms,绘图,结果如图:
图:边数与找到一个解的时间关系
千万组解的实验结果:
图:颜色数与找到一千万个解的时间关系
由图可见,二者均呈现弱相关关系。
本实验的代码如下,分为随机地图版本和固定地图版本,为避免测试中需要重复修改代码,此处将它们分开展示:
首先是固定地图的版本,需要在其中补充上自己地图文件的对应位置:
#include <iostream>
#include <random>
#include <chrono>
using namespace std::chrono;
using namespace std;
int color_num; //可用颜色数
int node_num; //点数
int edge_num; //边数
const int max_color_num = 30;//最大可用颜色数(用于定义存储数组)
const int max_node_num = 500;//最大点数
const int max_edge_num = 1000;//单个点的最大边数
// 文件地址,常用名:le450_5a,le450_15b,le450_25a,small map
const char filename[] = "small map.txt";//(需要写上完整文件地址,此处已省略)
class Node {//节点
public:
int color; //颜色
int color_state[max_color_num]; //可选颜色,1为可选,非1为不可选,color_state[0]表示当前可选的颜色数
int list[max_edge_num]; //邻接表,list[0]表示第i个节点相邻节点的数量,list[i]表示第i个相邻节点。
void init(int color_num) {//初始化
color = 0;
for (int i = 1; i <= color_num; i++)
color_state[i] = 1;
color_state[0] = color_num;
}
};
Node N[max_node_num];//存储所有点
system_clock::time_point begintime;//用于计时
double first_ans_time;//用于记录找到第一个解的时间
bool check(int current) { //用于判断当前解是否可行
for (int i = 1; i <= N[current].list[0]; i++) { //遍历当前节点的所有相邻节点
int j = N[current].list[i];
if (N[j].color == 0 && N[j].color_state[N[current].color] == 1) {//N[j]未遍历且可以染当前颜色,就使其不可染当前颜色
N[j].color_state[N[current].color] = -current; //设置为-current是为了方便回溯
N[j].color_state[0]--;
if (!N[j].color_state[0]) {//当前情况导致N[j]无解,不可行
return false;
}
}
}
return true;
}
int get_next_new() {//找到下一个遍历的节点,实验发现可以不在相邻点中选,选可选颜色最少的点,其次选度最大的点
int min_color_num = color_num;//最小的可选颜色数量
int next = 0;
for (int i = 1; i <= node_num; i++) {
if (!N[i].color) { //从未填色的点中找到下一个要着色的点(无需相邻)
if (N[i].color_state[0] == min_color_num) {//有多个可选颜色最少的点
if (N[i].list[0] > N[next].list[0]) { //选择度最大的点
min_color_num = N[i].color_state[0];
next = i;
}
}
else if (N[i].color_state[0] < min_color_num) {//可选颜色最少的点
min_color_num = N[i].color_state[0];
next = i;
}
}
}
return next;
}
//深度优先搜索
long long current_ans = 0;//统计当前解的数量,当解过多时用于退出
int ans_max = 100000000;//超过这个值就退出
int flag = 0;//找到解后置为1,用于统计找到第一个解的时间
long long dfs(int current, int colored_node, int used_color) {
if (colored_node == node_num) { //到达叶子节点,找到着色方案
if (!flag) {
flag = 1;
duration<double> dura = system_clock::now() - begintime;
first_ans_time = dura.count();
/*//如果只想要找到一个解,就将如下这段代码取消注释:找到一个解直接退出
cout << "找到第一个解的时间:" << first_ans_time << "s" << endl;
exit(1);
*/
}
current_ans += N[current].color_state[0];
return N[current].color_state[0];
}
else {
long long ans = 0;//固定已遍历节点,以当前节点为起点的解的个数
for (int i = 1; i <= color_num; i++) {
if (N[current].color_state[i] == 1) {
long long next_ans = 0;//固定当前节点与之前所有节点,剩余的点构成的结果数
N[current].color = i;
if (check(current)) {
if (i > used_color)
next_ans = dfs(get_next_new(), colored_node + 1, used_color + 1);
else
next_ans = dfs(get_next_new(), colored_node + 1, used_color);
}
//回溯,此部分与check函数对应
N[current].color = 0;
for (int j = 1; j <= N[current].list[0]; j++) {
int k = N[current].list[j];
if (N[k].color_state[i] == -current) {
N[k].color_state[0]++;
N[k].color_state[i] = 1;
}
}
//如果用到了新的颜色,则剩余的未使用的颜色和这个颜色的结果是等价的,直接剪枝
if (i > used_color) {
ans += next_ans * (color_num - used_color);
current_ans += next_ans * (color_num - used_color - 1);
break;
}
ans += next_ans;
}
}
if (current_ans > ans_max) {//解过多,提前退出
cout << "提前退出,";
duration<double> dura = system_clock::now() - begintime;
cout << "运行时间:" << dura.count() << "s" << endl;
cout << "共找到:" << current_ans << "个解" << endl;
cout << "找到第一个解的时间:" << first_ans_time << "s" << endl;
exit(1);
}
return ans;
}
}
int get_first() {//找到度最大的节点作为第一个节点
int first = 0;
int max_degree = 0;
for (int i = 1; i <= node_num; i++) {
if (N[i].list[0] > max_degree) {//可选颜色最少的点
max_degree = N[i].list[0];
first = i;
}
}
return first;
}
int main() {
//读取文件并构建邻接表
FILE* fp;
if (fopen_s(&fp, filename, "r")) {
cout << "读取错误\n"; return 0;
}
fscanf_s(fp, "%d%d%d\n", &color_num, &node_num, &edge_num);
for (int i = 0; i <= node_num; i++) {
N[i].init(color_num);
}
char c;
int u, v;
for (int i = 1; i <= edge_num; i++) {
fscanf_s(fp, "%c%d%d\n", &c, 1, &u, &v);
N[u].list[++N[u].list[0]] = v;
N[v].list[++N[v].list[0]] = u;
}
fclose(fp);
//进行遍历并计时
double min_time = INT_MAX, min_first_ans_time = INT_MAX;
int min_begin = 0, min_first_ans_begin = 0;
for (int i = 1; i <= node_num; i++) {//对于地图le450_5a,测试得出节点61或288为起点时时间最短
flag = 0;
current_ans = 0;
begintime = system_clock::now();
long long ans = dfs(i, 1, 0);
duration<double> dura = system_clock::now() - begintime;
cout << "起点:" << i << ",运行时间:" << dura.count() << "s" << endl;
cout << "共找到:" << ans << "个解" << endl;
if (min_time > dura.count()) {
min_begin = i;
min_time = dura.count();
}
if (min_first_ans_time > first_ans_time) {
min_first_ans_begin = i;
min_first_ans_time = first_ans_time;
}
//如果需要探究不同起点的影响,就取消注释break;
break;
}
//cout << "最短时间:" << min_time << ",起点编号:" << min_begin << endl;
//cout << "找到第一个解的最短时间:" << min_first_ans_time << ",起点编号:" << min_first_ans_begin << endl;
return 0;
}
接下来是随机生成地图的版本:
#include <iostream>
#include <random>
#include <chrono>
using namespace std::chrono;
using namespace std;
int color_num; //可用颜色数
int node_num; //点数
int edge_num; //边数
const int max_color_num = 30;//最大可用颜色数(用于定义存储数组)
const int max_node_num = 3000;//最大点数
const int max_edge_num = 5000;//单个点的最大边数
class Node {//节点
public:
int color; //颜色
int color_state[max_color_num]; //可选颜色,1为可选,非1为不可选,color_state[0]表示当前可选的颜色数
int list[max_edge_num]; //邻接表,list[0]表示第i个节点相邻节点的数量,list[i]表示第i个相邻节点。
void init(int color_num) {//初始化
color = 0;
for (int i = 1; i <= color_num; i++)
color_state[i] = 1;
color_state[0] = color_num;
}
};
Node N[max_node_num];//存储所有点
system_clock::time_point begintime;//用于计时
double first_ans_time;//用于记录找到第一个解的时间
bool check(int current) { //用于判断当前解是否可行
for (int i = 1; i <= N[current].list[0]; i++) { //遍历当前节点的所有相邻节点
int j = N[current].list[i];
if (N[j].color == 0 && N[j].color_state[N[current].color] == 1) {//N[j]未遍历且可以染当前颜色,就使其不可染当前颜色
N[j].color_state[N[current].color] = -current; //设置为-current是为了方便回溯
N[j].color_state[0]--;
if (!N[j].color_state[0]) {//当前情况导致N[j]无解,不可行
return false;
}
}
}
return true;
}
int get_next(int current) {//从N的相邻点中找到下一个遍历的节点,选可选颜色最少的点,其次选度最大的点
int min_color_num = color_num;//最小的可选颜色数量
int next = 0;
for (int i = 1; i <= N[current].list[0]; i++) {
if (!N[N[current].list[i]].color) { //从相邻的未填色点中找到可选颜色最少的点作为下一个点
if (N[N[current].list[i]].color_state[0] == min_color_num) {
if (N[N[current].list[i]].list[0] > N[next].list[0]) {//有多个可选颜色最少的点
min_color_num = N[N[current].list[i]].color_state[0]; //选择度最大的点
next = N[current].list[i];
}
}
else if (N[N[current].list[i]].color_state[0] < min_color_num) {//可选颜色最少的点
min_color_num = N[N[current].list[i]].color_state[0];
next = N[current].list[i];
}
}
}
if (next == 0) {//图可能不是联通的,需要额外判断
for (int i = 1; i <= node_num; i++) {
if (!N[i].color) {
if (N[i].color_state[0] == min_color_num) {
if (N[i].list[0] > N[next].list[0]) {
min_color_num = N[i].color_state[0];
next = i;
}
}
else if (N[i].color_state[0] < min_color_num) {
min_color_num = N[i].color_state[0];
next = i;
}
}
}
}
return next;
}
int get_next_new() {//找到下一个遍历的节点,实验发现可以不在相邻点中选,选可选颜色最少的点,其次选度最大的点
int min_color_num = color_num;//最小的可选颜色数量
int next = 0;
for (int i = 1; i <= node_num; i++) {
if (!N[i].color) { //从未填色的点中找到下一个要着色的点(无需相邻)
if (N[i].color_state[0] == min_color_num) {//有多个可选颜色最少的点
if (N[i].list[0] > N[next].list[0]) { //选择度最大的点
min_color_num = N[i].color_state[0];
next = i;
}
}
else if (N[i].color_state[0] < min_color_num) {//可选颜色最少的点
min_color_num = N[i].color_state[0];
next = i;
}
}
}
return next;
}
//深度优先搜索
long long current_ans = 0;//统计当前解的数量,当解过多时用于退出
long long ans_max = 10000000;//超过这个值就退出
int flag = 0;//找到解后置为1,用于统计找到第一个解的时间
long long dfs(int current, int colored_node, int used_color) {
if (colored_node == node_num) { //到达叶子节点,找到着色方案
if (!flag) {
flag = 1;
duration<double> dura = system_clock::now() - begintime;
first_ans_time = dura.count();
/*//如果只想要找到一个解,就将如下这段代码取消注释:找到一个解直接退出
cout << "找到第一个解的时间:" << first_ans_time << "s" << endl;
exit(1);*/
}
current_ans += N[current].color_state[0];
return N[current].color_state[0];
}
else {
long long ans = 0;//固定已遍历节点,以当前节点为起点的解的个数
for (int i = 1; i <= color_num; i++) {
if (N[current].color_state[i] == 1) {
long long next_ans = 0;//固定当前节点与之前所有节点,剩余的点构成的结果数
N[current].color = i;
if (check(current)) {
if (i > used_color)
next_ans = dfs(get_next_new(), colored_node + 1, used_color + 1);
else
next_ans = dfs(get_next_new(), colored_node + 1, used_color);
}
//回溯,此部分与check函数对应
N[current].color = 0;
for (int j = 1; j <= N[current].list[0]; j++) {
int k = N[current].list[j];
if (N[k].color_state[i] == -current) {
N[k].color_state[0]++;
N[k].color_state[i] = 1;
}
}
//如果用到了新的颜色,则剩余的未使用的颜色和这个颜色的结果是等价的,直接剪枝
if (i > used_color) {
ans += next_ans * (color_num - used_color);
current_ans += next_ans * (color_num - used_color - 1);
break;
}
ans += next_ans;
}
}
if (current_ans > ans_max) {//解过多,提前退出
cout << "提前退出,";
duration<double> dura = system_clock::now() - begintime;
cout << "运行时间:" << dura.count() << "s" << endl;
cout << "共找到:" << current_ans << "个解" << endl;
cout << "找到第一个解的时间:" << first_ans_time << "s" << endl;
exit(1);
}
return ans;
}
}
int get_first() {
int first = 0;
int max_degree = 0;
for (int i = 1; i <= node_num; i++) {
if (N[i].list[0] > max_degree) {//可选颜色最少的点
max_degree = N[i].list[0];
first = i;
}
}
return first;
}
int main() {
//指定点数、边数,随机生成图并构建邻接表
color_num = 4;
node_num = 1000;
edge_num = 0;
for (int i = 0; i <= node_num; i++) {
N[i].init(color_num);
}
int u, v;
for (int i = 1; i <= edge_num; i++) {
u = rand() % node_num + 1;
v = rand() % node_num + 1;
N[u].list[++N[u].list[0]] = v;
N[v].list[++N[v].list[0]] = u;
}
//进行遍历并计时
flag = 0;
current_ans = 0;
begintime = system_clock::now();
int first = get_first();
long long ans = dfs(first, 1, 0);
duration<double> dura = system_clock::now() - begintime;
cout << "运行时间:" << dura.count() << "s" << endl;
cout << "共找到:" << ans << "个解" << endl;
return 0;
}
四、实验结论与体会
实验结论:
本实验通过编写代码,使用深度优先搜索解决了地图染色问题,并在此基础上对代码进行了优化:选择下一个节点的优化、选择初始节点的优化、剪枝优化、向前探查优化。最终实现了以0.01s的时间完成了le450_5a地图数据集上的染色。并完成了小地图、三个大地图以及随机地图上的多个实验,探究了各个因素与运算时间的关系。
实验体会:
本实验的难点主要在于代码编写与优化:
深度优先遍历的函数在编写过程中容易出错,具体为:要注意状态的改变以及回溯一一对应,否则下一次搜索中将会出现错误。本实验中单个节点存在多个变量,由于向前探查的check函数改变了相邻节点的状态,回溯时也需将相邻节点的状态改回。
本实验存在多种优化,关键的剪枝可以使代码运行时间大大减少。各个优化带来的结果往往会以相乘的结果作用于结果,所以一个较小的优化也是值得钻研的。
尾注:
本实验是此课程的第三次实验,自本次实验开始的后续实验都具有较高的质量(能力有限,已经尽力做了!),但仍难免会存在不准确不完善之处,可供参考。
如有疑问欢迎讨论,如有好的建议与意见欢迎提出,如有发现错误则恳请指正!