回溯法解骑士巡游问题

回溯算法#

蛮力法是对整个解空间树中的所有可能解进行穷举搜索的一种方法,但是只有满足约束条件的解才是可行解。在满足约束条件的前提下,只有满足目标函数的解才是最优解,因此结合目标函数进行搜索就有可能减少搜索空间。回溯法从根结点岀发,按照深度优先策略遍历解空间树,搜索满足约束条件的解。在搜索至树中任一结点时,先判断该结点对应的部分解是否满足约束条件,或者是否超出目标函数的界,也就是判断该结点是否包含问题的(最优)解。如果肯定不包含,则跳过对以该结点为根的子树的搜索,即所谓剪枝( pruning)。否则进入以该结点为根的子树,继续按照深度优先策略搜索。简单来说,回溯算法就是带有剪枝策略的蛮力法,回溯算法的时间复杂度是否能降低,取决于剪枝策略的设计。

骑士巡游问题#

国际象棋为许多令人着迷的娱乐提供了固定的框架,这些框架独立于游戏本身。其中很多都是基于奇异的骑士 “L 型”(L-shaped)移动,一个经典的例子就是骑士巡游(knight's tour)问题。输入 n (1< = n < = 10) 代表棋盘的规模是 n * n 的规模,骑士从某个点出发。骑士按象棋中“马走日”的行走方式,要求骑士走遍所有棋盘的格子(遍历棋盘的所有格子),输出骑士巡游整张棋盘的走法。
例如对于 5×5 的棋盘(用黄颜色表示),骑士位于坐标 (3,3) 的位置(用蓝色标出),则骑士按照“日”字形可走的位置如下所示(用紫色标出)。

DFS 解法#

求解思路#

使用回溯法对问题进行求解,使用 DFS 暴力搜索出骑士从某个点出发的所有路径,所有的路径将构成一个解空间树。剪枝的方式是由于骑士从一个点行走有 8 个方向,若这 8 个方向都会出棋盘,或者走到已经走过的点上,则该点之下的解空间树必不存在可行解。

代码编写#

首先先定义需要的辅助结构,需要定义一个二维数组作为骑士巡游的棋盘。

Copy Highlighter-hljs
#define WIDTH 5 #define HEIGHT 5 int checkerboard[6][6] = {0};

接着需要 2 个一维数组分别存储骑士 8 个方向的走法,nextStepX[i] 和 nextStepY[i] 表示骑士在二维空间的位移。

Copy Highlighter-hljs
int nextStepX[8] = {-1, -1, -2, -2, 1, 1, 2, 2}; int nextStepY[8] = {2, -2, 1, -1, 2, -2, 1, -1};

剪枝的方式是由于骑士从一个点行走有 8 个方向,若这 8 个方向都会出棋盘,或者走到已经走过的点上,则该点之下的解空间树必不存在可行解。剪枝函数如下:

Copy Highlighter-hljs
bool judgePrune(int x, int y) { return ((x >= 1 && x <= WIDTH && y >= 1 && y <= HEIGHT) && checkerboard[x][y] == 0); }

DFS 搜索路径的函数如下:

Copy Highlighter-hljs
void travel(int x, int y, int step) { int next_x; int next_y; checkerboard[x][y] = step; if (step == WIDTH * HEIGHT) { print(checkerboard); return; } for (int i = 0; i < 8; i++) { next_x = x + nextStepX[i]; next_y = y + nextStepY[i]; if(judgePrune(next_x, next_y) == true){ travel(next_x, next_y, step + 1); } } checkerboard[x][y] = 0; return; } int main() { travel(1,1,1); return 0; }

结果分析#

5×5 棋盘的骑士巡游可行解:

6×6 棋盘的骑士巡游可行解:

7×7 棋盘的骑士巡游可行解:

8×8 棋盘的骑士巡游可行解,使用上面的没有跑出来,由此可见直接 DFS 没有过多的剪枝策略,导致了算法的效率很低下。

剪枝策略优化#

注意到上文使用的剪枝策略非常简单,只是把不可能有可行解的情况忽略掉,实践证明这样的策略非常低效,几乎等于是蛮力法。由于骑士巡游问题不需要得到最优解,只需要得到一种可行解即可,因此剪枝策略应该保证尽快地接近可行解。分析上文直接 DFS 的性能,会发现每一步巡游的尝试顺序都是依次把 8 个方向尝试一遍,但是 8 个方向中可能会存在可走的方向过多而导致决策树的解空间过大,导致搜索性能过慢。
例如当前骑士位于 5×5 棋盘上的 (1,1) 坐标处(绿色表示已巡游的位置),并且骑士选择 (2,3) 为落点进行巡游,则下一步骑士可巡游的位置用紫色标注。

如果骑士下一步选择移动到 (2,3)->(4,4) 坐标,则骑士的下一次巡游的选择有 (2,5)、(3,2)、(5,2) 这 3 种情况。

如果骑士下一步选择移动到 (2,3)->(1,5) 坐标,则骑士的下一次巡游的选择仅有 (3,4) 这 1 种情况。可见如果骑士选择下一次巡游的可走方向较少的位置进行移动,则骑士的下一次巡游的解空间树的规模会大幅度减小,就可以更快地逼近可行解。

如果能让骑士尽可能选择下一次可走的方向较少的坐标进行巡游,则呈现的效果就是骑士先绕着棋盘的外围巡游,然后再逐层到棋盘的内层巡游。因为当骑士位于棋盘外围时,由于有棋盘的边界限制,会有大部分的方向直接处于不可走的状态。当骑士巡游到内层时,由于棋盘外层已经被巡游过了,可以认为是棋盘的大小收缩了,即外层变为了棋盘新的边界,使得骑士在内层巡游时仍然可以大量剪枝。

代码编写#

首先除了定义棋盘及其大小,还有两个一维数组表示骑士巡游的方向,方便起见定义结构体 Point 表示骑士的下一个落点。成员 dir 为骑士下一个落点的方向,取值范围 0 ~ 7,可以使用 “y + nextStepX[dir]” 和 “y + nextStepY[dir]” 得到下一个落点的坐标。成员 moves 是一个 vector 容器,存储下一个落点下一次可巡游的方向,如果 “moves.size() == 0” 则表示下一个落点没有可走的方向。
由于后面需要从所有可走的方向中,选择下一次巡游可走方向数最少的方向先走,因此需要对 Point 对 moves.size() 按照升序排序。此处可以重载 “<” 运算符,然后调用泛型算法 sort() 实现排序。

Copy Highlighter-hljs
typedef struct Point { int dir; //巡游方向 vector<int> moves; //下一次巡游可走方向 //重载“<”运算符 inline bool operator < (const Point &x) const { return moves.size() < x.moves.size() ; } } Point;

由于需要选择下一次巡游可走方向数最少的方向先走,因此就需要先获取下一次可走的所有方向,以此定义 getMoves() 函数获取。getMoves() 函数返回一个 vector,其中存储数字 0 ~ 7 表示可走的方向,若方向经 judgePrune() 函数判读可行就加入该容器中。

Copy Highlighter-hljs
vector<int> getMoves(int x, int y) //获得8个方向中可走的方向 { vector<int> next_moves; for (int i = 0; i < 8; i++) { if(judgePrune(x + nextStepX[i], y + nextStepY[i])) { next_moves.push_back(i); } } return next_moves; }

根据上文的剪枝策略,设计出新的搜索函数如下。

Copy Highlighter-hljs
void travel(int x, int y, int step, Point pre) //巡游函数 { vector<Point> next_point; checkerboard[x][y] = step; //标记当前位置已走过 //求出可行解,输出棋盘后回溯 if (step == WIDTH * HEIGHT) { print(); return; } //当前位置无法继续巡游,回溯 if(pre.moves.size() == 0) { return; } //依次获取下一个可行的位置的可走方向数 for (int i = 0; i < pre.moves.size(); i++) { Point a_point; a_point.dir = pre.moves[i]; a_point.moves = getMoves(x + nextStepX[a_point.dir], y + nextStepY[a_point.dir]); next_point.push_back(a_point); } //选择下一次可走方向最少的方向先走 sort(next_point.begin(), next_point.end()); //对 next_point 根据 Point.moves.size() 做升序排序 for (int i = 0; i < next_point.size(); i++) { travel(x + nextStepX[next_point[i].dir], y + nextStepY[next_point[i].dir], step + 1, next_point[i]); } //恢复状态,回溯 checkerboard[x][y] = 0; return; }

最后初始化骑士的第一个落点,并获取其所有可走的方向,即可开始搜索。

Copy Highlighter-hljs
int main() { Point a_point; int x, y; cout << "请输入起始位置的x坐标:"; cin >> x; cout << "请输入起始位置的y坐标:"; cin >> y; a_point.moves = getMoves(x, y); //初始化初始点 travel(x, y, 1, a_point); return 0; }

结果分析#

8×8 棋盘的骑士巡游可行解:

9×9 棋盘的骑士巡游可行解:

15×15 棋盘的骑士巡游可行解:

经测试剪枝策略优化后,算法的性能大幅度提升,在 DFS 策略下无法快速找出 8×8 棋盘的可行解也可以求出,而且对于规模更大的棋盘也可以求解。

posted @   乌漆WhiteMoon  阅读(3962)  评论(12编辑  收藏  举报
编辑推荐:
· 一个奇形怪状的面试题:Bean中的CHM要不要加volatile?
· [.NET]调用本地 Deepseek 模型
· 一个费力不讨好的项目,让我损失了近一半的绩效!
· .NET Core 托管堆内存泄露/CPU异常的常见思路
· PostgreSQL 和 SQL Server 在统计信息维护中的关键差异
阅读排行:
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· 微软正式发布.NET 10 Preview 1:开启下一代开发框架新篇章
· 如何使用 Uni-app 实现视频聊天(源码,支持安卓、iOS)
· C# 集成 DeepSeek 模型实现 AI 私有化(本地部署与 API 调用教程)
点击右上角即可分享
微信分享提示
CONTENTS