如何优化 Knight 的游览算法?

Posted

技术标签:

【中文标题】如何优化 Knight 的游览算法?【英文标题】:How to optimize Knight's tour algorithm? 【发布时间】:2013-10-06 21:20:35 【问题描述】:

我使用 Backtracking 方法在 C++ 中编写了 Knight's tour 算法。 但对于 n > 7(大于 7 x 7 棋盘),它似乎太慢或陷入无限循环。

问题是:这个算法的Time complexity是什么,我该如何优化它?!


骑士之旅问题可以表述如下:

给定一个有 n × n 个方格的棋盘,为骑士找到一条路径,该路径恰好访问每个方格一次。

这是我的代码:

#include <iostream>
#include <iomanip>

using namespace std;

int counter = 1;
class horse 
  public:
    horse(int);
    bool backtrack(int, int);
    void print();
  private:
    int size;
    int arr[8][8];
    void mark(int &);
    void unmark(int &);
    bool unvisited(int &);
;

horse::horse(int s) 
    int i, j;
    size = s;
    for (i = 0; i <= s - 1; i++)
        for (j = 0; j <= s - 1; j++)
            arr[i][j] = 0;


void horse::mark(int &val) 
    val = counter;
    counter++;


void horse::unmark(int &val) 
    val = 0;
    counter--;


void horse::print() 
    cout << "\n - - - - - - - - - - - - - - - - - -\n";
    for (int i = 0; i <= size - 1; i++) 
        cout << "| ";
        for (int j = 0; j <= size - 1; j++)
            cout << setw(2) << setfill ('0') << arr[i][j] << " | ";
        cout << "\n - - - - - - - - - - - - - - - - - -\n";
    


bool horse::backtrack(int x, int y) 
    if (counter > (size * size))
        return true;

    if (unvisited(arr[x][y])) 
        if ((x - 2 >= 0) && (y + 1 <= (size - 1))) 
            mark(arr[x][y]);
            if (backtrack(x - 2, y + 1))
                return true;
            else
                unmark(arr[x][y]);
        
        if ((x - 2 >= 0) && (y - 1 >= 0)) 
            mark(arr[x][y]);
            if (backtrack(x - 2, y - 1))
                return true;
            else
                unmark(arr[x][y]);
        
        if ((x - 1 >= 0) && (y + 2 <= (size - 1))) 
            mark(arr[x][y]);
            if (backtrack(x - 1, y + 2))
                return true;
            else
                unmark(arr[x][y]);
        
        if ((x - 1 >= 0) && (y - 2 >= 0)) 
            mark(arr[x][y]);
            if (backtrack(x - 1, y - 2))
                return true;
            else
                unmark(arr[x][y]);
        
        if ((x + 2 <= (size - 1)) && (y + 1 <= (size - 1))) 
            mark(arr[x][y]);
            if (backtrack(x + 2, y + 1))
                return true;
            else
                unmark(arr[x][y]);
        
        if ((x + 2 <= (size - 1)) && (y - 1 >= 0)) 
            mark(arr[x][y]);
            if (backtrack(x + 2, y - 1))
                return true;
            else
                unmark(arr[x][y]);
        
        if ((x + 1 <= (size - 1)) && (y + 2 <= (size - 1))) 
            mark(arr[x][y]);
            if (backtrack(x + 1, y + 2))
                return true;
            else
                unmark(arr[x][y]);
        
        if ((x + 1 <= (size - 1)) && (y - 2 >= 0)) 
            mark(arr[x][y]);
            if (backtrack(x + 1, y - 2))
                return true;
            else
                unmark(arr[x][y]);
        
    
    return false;


bool horse::unvisited(int &val) 
    if (val == 0)
        return 1;
    else
        return 0;


int main() 
    horse example(7);
    if (example.backtrack(0, 0)) 
        cout << " >>> Successful! <<< " << endl;
        example.print();
     else
        cout << " >>> Not possible! <<< " << endl;

上面例子 (n = 7) 的输出是这样的:

【问题讨论】:

您可能对this article 对构建大型游览感兴趣。 @PeterdeRivaz 谢谢,这很有趣,但无法从中获得足够的帮助,实现听起来太复杂了。 一般图的骑士之旅是 NP-hard,相当于访问图的每个顶点的哈密顿路径问题。然而,对于 8x8 标准棋盘的特殊情况,有已知的线性时间算法。这里描述了一种这样的算法:dl.acm.org/citation.cfm?id=363463。有趣的是,在预期的情况下,贪婪的启发式“我们移动马,以便我们总是前进到马向前移动最少的方格”实际上在实践中表现得非常好:en.wikipedia.org/wiki/Knight's_tour#Warnsdorff.27s_rule 我应该明确指出它不等同于哈密顿路径问题,但哈密顿路径问题减少到骑士的旅行问题,这使得骑士的旅行 NP 困难。 我为一个 8x8 板写了一个骑士巡回赛,它通过蛮力在 P100 上运行,平均 18 个小时的巡回赛,其中最长的将近 33 个小时。你在什么系统上用了多长时间? 【参考方案1】:

由于在每个步骤中您有 8 种可能性进行检查,并且必须对每个单元格(减去最后一个单元格)进行检查,因此该算法的时间复杂度为 O(8^(n^2-1)) = O( 8^(n^2)) 其中 n 是棋盘边缘上的方格数。准确地说,这是最坏情况下的时间复杂度(如果没有找到或者是最后一种可能性,则探索所有可能性所花费的时间)。

至于优化,可以有两种类型的改进:

实施改进

您正在计算 x-2、x-1、x+1、x+2 和相同的 y 至少两倍的时间。 我可以建议这样重写:

int sm1 = size - 1;
int xm2 = x - 2;
int yp1 = y + 1;
if((xm2 >= 0) && (yp1 <= (sm1)))
    mark(arr[x][y]);
    if(backtrack(xm2, yp1))
        return true;
    else
        unmark(arr[x][y]);


int ym1 = y-1;
if((xm2 >= 0) && (ym1 >= 0))
    mark(arr[x][y]);
    if(backtrack(xm2, ym1))
        return true;
    else
        unmark(arr[x][y]);

注意在后续块中也重复使用预先计算的值。 我发现这比我所期待的更有效;这意味着变量分配和调用比再次执行操作更快。 还可以考虑在构造函数中保存sm1 = s - 1;area = s * s;,而不是每次都计算。

但是,这(作为实现改进而不是算法改进)不会改变时间复杂度顺序,而只会将时间除以某个因子。 我的意思是时间复杂度 O(8^(n^2)) = k*8^(n^2) 并且差异将在较低的 k 因子中。

算法改进

我可以这样想:

对于从对角线的单元格开始的每次旅行(如在您的示例中从 (0,0) 开始),您可以认为只有第一个移动位于对角线创建的两个半棋盘之一上。 这是因为 simmetry 或者它存在 2 个 simmetric 解决方案或不存在。 对于这种情况,这给出了 O(4*8^(n^2-2)),但对于非 simmetric 的情况仍然如此。 再次注意 O(4*8^(n^2-2)) = O(8^(n^2)) 如果某些全球情况表明根据当前标记无法解决问题,请尝试尽早中断紧急情况。 例如,马不能跳过两个大容量列或行,因此如果您有两个大容量标记的列(或行)和两侧未标记的单元格,您肯定不会有任何解决方案。考虑到这可以在 O(n) 中检查,如果您保持每列/行更新的标记单元格的数量。然后,如果您在每次标记后检查此内容,则添加 O(n*8^(n^2)) 时间,如果 n counter % 8 == 4 或更好的counter &gt; 2*n &amp;&amp; counter % 8 == 4 找到其他想法巧妙地提前中断搜索,但请记住,具有 8 个选项的回溯算法将始终具有 O(8^(2^n)) 的性质。

再见

【讨论】:

【参考方案2】:

这是我的 2 美分。我从基本的回溯算法开始。正如你提到的,它无限期地等待 n > 7。我实现了warnsdorff rule,它就像魔术一样工作,并且对于直到 n = 31 的大小板在不到一秒的时间内给出结果。对于 n > 31,由于递归深度超过限制,它会给出 *** 错误。我可以找到更好的讨论here,它讨论了警告多夫规则的问题以及可能的进一步优化。

仅供参考,我提供了带有warnsdorff优化的Knight's Tour问题的python实现

def isValidMove(grid, x, y): maxL = len(grid)-1 if x maxL or y maxL or grid[x][y] > -1 : return False return True def getValidMoves(grid, x, y, validMoves): return [ (i,j) for i,j in validMoves if isValidMove(grid, x+i, y+j) ] def movesSortedbyNumNextValidMoves(grid, x, y, legalMoves): nextValidMoves = [ (i,j) for i,j in getValidMoves(grid,x,y,legalMoves) ] # find the number of valid moves for each of the possible valid mode from x,y withNumNextValidMoves = [ (len(getValidMoves(grid,x+i,y+j,legalMoves)),i,j) for i,j in nextValidMoves] # sort based on the number so that the one with smallest number of valid moves comes on the top return [ (t[1],t[2]) for t in sorted(withNumNextValidMoves) ] def _solveKnightsTour(grid, x, y, num, legalMoves): if num == pow(len(grid),2): return True for i,j in movesSortedbyNumNextValidMoves(grid,x,y,legalMoves): #For testing the advantage of warnsdorff heuristics, comment the above line and uncomment the below line #for i,j in getValidMoves(grid,x,y,legalMoves): xN,yN = x+i,y+j if isValidMove(grid,xN,yN): grid[xN][yN] = num if _solveKnightsTour(grid, xN, yN, num+1, legalMoves): return True grid[xN][yN] = -2 return False def solveKnightsTour(gridSize, startX=0, startY=0): legalMoves = [(2,1),(2,-1),(-2,1),(-2,-1),(1,2),(1,-2),(-1,2),(-1,-2)] #Initializing the grid grid = [ x[:] for x in [[-1]*gridSize]*gridSize ] grid[startX][startY] = 0 if _solveKnightsTour(grid,startX,startY,1,legalMoves): for row in grid: print ' '.join(str(e) for e in row) else: print 'Could not solve the problem..'

【讨论】:

【参考方案3】:

检查你的算法。在每个递归深度,您检查 8 个可能的移动中的每一个,检查哪些在棋盘上,然后递归处理该位置。哪个数学公式最能描述这种展开?

你有一个固定的棋盘大小,int[8][8],也许你应该让它动态,

class horse

    ...
    int** board; //[s][s];
    ...
;

horse::horse(int s)

    int i, j;
    size = s;
    board = (int**)malloc(sizeof(int*)*size);
    for(i = 0; i < size; i++)
    
        board[i] = (int*)malloc(sizeof(int)*size);
        for(j = 0; j < size; j++)
        
            board[i][j] = 0;
        
    

通过添加一个检查棋盘移动是否合法的函数来稍微改变你的测试,

bool canmove(int mx, int my)

    if( (mx>=0) && (mx<size) && (my>=0) && (my<size) ) return true;
    return false;

注意mark()和unmark()是非常重复的,你真的只需要mark()棋盘,检查所有合法的走法,然后unmark()位置,如果没有backtrack()返回true,

重写函数让一切变得更清晰,

bool horse::backtrack(int x, int y)


    if(counter > (size * size))
        return true;

    if(unvisited(board[x][y]))
    
        mark(board[x][y]);
        if( canmove(x-2,y+1) )
        
            if(backtrack(x-2, y+1)) return true;
        
        if( canmove(x-2,y-1) )
        
            if(backtrack(x-2, y-1)) return true;
        
        if( canmove(x-1,y+2) )
        
            if(backtrack(x-1, y+2)) return true;
        
        if( canmove(x-1,y-2) )
        
            if(backtrack(x-1, y-2)) return true;
        
        if( canmove(x+2,y+1) )
        
            if(backtrack(x+2, y+1)) return true;
        
        if( canmove(x+2,y-1) )
        
            if(backtrack(x+2, y-1)) return true;
        
        if( canmove(x+1,y+2) )
        
            if(backtrack(x+1, y+2)) return true;
        
        if( canmove(x+1,y-2) )
        
            if(backtrack(x+1, y-2)) return true;
        
        unmark(board[x][y]);
    
    return false;

现在,想想递归必须有多深才能访问每个 [x][y]?很深吧? 因此,您可能需要考虑一种更有效的策略。将这两个打印输出添加到板显示应该会显示发生了多少回溯步骤,

int counter = 1; int stepcount=0;
...
void horse::print()

    cout<< "counter: "<<counter<<endl;
    cout<< "stepcount: "<<stepcount<<endl;
    ...
bool horse::backtrack(int x, int y)

    stepcount++;
    ...

这是 5x5、6x6、7x6 的成本,

./knightstour 5
 >>> Successful! <<< 
counter: 26
stepcount: 253283

./knightstour 6
 >>> Successful! <<< 
counter: 37
stepcount: 126229019

./knightstour 7
 >>> Successful! <<< 
counter: 50
stepcount: 56342

为什么 7 步比 5 步少?想想回溯中移动的顺序——如果你改变顺序,步骤会改变吗?如果您列出了可能的移动 [ 1,2,-1,2,1,-2,-1,-2,2,1,2,1 ,2,1,2,1 ],并以不同的顺序处理它们?我们可以更轻松地重新排列动作,

int moves[ ] =
 -2,+1, -2,-1, -1,+2, -1,-2, +2,+1, +2,-1, +1,+2, +1,-2 ;
...
        for(int mdx=0;mdx<8*2;mdx+=2)
        
        if( canmove(x+moves[mdx],y+moves[mdx+1]) )
        
            if(backtrack(x+moves[mdx], y+moves[mdx+1])) return true;
        
        

将原来的移动顺序改成这个顺序,然后跑 7x7 会得到不同的结果,

 +2,+1, +2,-1, +1,+2, +1,-2, -2,+1, -2,-1, -1,+2, -1,-2 ;


./knightstour 7
 >>> Successful! <<< 
counter: 50
stepcount: -556153603 //sheesh, overflow!

但你最初的问题是,

问题是:这个算法的时间复杂度是多少,我该如何优化它?!

回溯算法大约是 8^(n^2),尽管它可能在 n^2 移动之后找到答案。我会让您将其转换为 O() 复杂度指标。

我认为这会引导你找到答案,而不是告诉你答案。

【讨论】:

使用这种方法的代码肯定更具可读性,但从计算的角度来看,我建议避免使用通用函数 canmove(x,y) 因为它总是检查 4 个条件而不是 2 个,因为它还会检查我来自的方向。 同意。请注意,在重构 canmove() 函数的代码后,移动顺序的循环很清楚。这显示了移动顺序如何影响该算法。然后是关于如何以不同顺序采取行动的后续评论可能会导致更好的算法。因为发帖者需要研究更好的算法。 对。并且需要注意的是,对于不同的起始位置,可能表现最好的搜索顺序是不一样的。 是的,查看 Wikipedia 文章显示了 8x8 解决方案的明确“圆形”模式,并调整算法以使移动方向偏向于跟随边缘(顺时针或逆时针)将收敛到解决方案更快。【参考方案4】:

这是一个新的解决方案:

在该方法中,使用马在棋盘中的下一次移动时的死锁概率预测,将选择一个趋向于死锁概率小于其他的移动,我们在第一步就知道这个死锁每个细胞的概率为零,并且会逐渐改变。棋盘中的马有 2 到 8 步,因此每个格子都有预定的下一步棋值。

选择可用移动较少的单元格是最好的选择,因为除非它被填充,否则它将来趋向于死锁。允许的移动次数与进入僵局之间存在反比关系。 外层的牢房是最高优先级的,对于骑士游的问题,骑士只需要穿越一个牢房,这些值会在以后的游历中逐渐改变。 然后在下一步中将选择具有这些条件的单元格

    其相邻的空单元数比其他的少,或者说被填满的概率更多 选择后,相邻房屋不会死锁

你可以在这里阅读我关于这个问题的完整文章 Knight tour problem article

您可以从这里找到完整的源代码 Full Source in GitHub

希望对你有用

【讨论】:

以上是关于如何优化 Knight 的游览算法?的主要内容,如果未能解决你的问题,请参考以下文章

First Knight UVALive - 4297(优化高斯消元解概率dp)

使用回溯的骑士​​算法

Knight's tour-回溯(无法解决奇怪的棋盘尺寸)

Knight Moves (双向bfs)

LuoguP3930 SAC E#1 - 一道大水题 Knight

POJ 1915--Knight Moves