回溯算法 八皇后问题

参考
小白带你学--回溯算法
LeetCode--回溯法心得
GitHub 标星 15K,这个牛逼开源项目让算法真的动了起来
搜索&回溯——N皇后(hdu2553)

一、八皇后问题

八皇后问题是一个古老而著名的问题,是回溯算法的典型例题。该问题是十九世纪著名的数学家高斯1850年提出:在8X8格的国际象棋上摆放八个皇后(棋子),使其不能互相攻击,即任意两个皇后都不能处于同一行、同一列或同一斜线上。PS:皇后可以攻击同一行、同一列、左上左下右上右下四个方向的任意单位。

问题简化:下面我们将八皇后问题转化为四皇后问题,并用回溯法来找到它的解

1.尝试先放置第一枚皇后,被涂黑的地方是不能放皇后
image.png
2.第二行的皇后只能放在第三格或第四格,比方我们放第三格,则:
image.png

此时我们也能理解为什么叫皇后问题了,皇后旁边容不下其他皇后。而在同一个房间放下四个皇后确实是个不容易的问题。

3.step3

可以看到再难以放下第三个皇后,此时我们就要用到回溯算法了。我们把第二个皇后更改位置,此时我们能放下第三枚皇后了。


image.png
4.step4

虽然是能放置第三个皇后,但是第四个皇后又无路可走了。返回上层调用(3号皇后),而3号也别无可去,继续回溯上层调用(2号),2号已然无路可去,继续回溯上层(1号),于是1号皇后改变位置如下,继续回溯。


image.png

这就是回溯算法的精髓,虽然没有最终把问题解决,但是可以剧透一波,就是根据这个算法,最终能够把四位皇后放在4x4的棋盘里。也能用同样的方法解决了八皇后问题。

image.png
二、Algorithm Visualizer

一个名为Algorithm Visualizer(GitHub地址 )的直观的算法可视化工具,在里面你可以自由选择自己想学习的算法,每个算法它都清晰描绘了其原理和运作过程。
在演示地址中,找到Backtracking中的N_quenes problem,核心代码如下:

// import visualization libraries {
const { Tracer, Array2DTracer, LogTracer, Layout, VerticalLayout } = require('algorithm-visualizer');
// }

const N = 4; // just change the value of N and the visuals will reflect the configuration!
const board = (function createArray(N) {
  const result = [];
  for (let i = 0; i < N; i++) {
    result[i] = Array(...Array(N)).map(Number.prototype.valueOf, 0);
  }
  return result;
}(N));
const queens = (function qSetup(N) {
  const result = [];
  for (let i = 0; i < N; i++) {
    result[i] = [-1, -1];
  }
  return result;
}(N));

// define tracer variables {
const boardTracer = new Array2DTracer('Board');
const queenTracer = new Array2DTracer('Queen Positions');
const logger = new LogTracer('Progress');
Layout.setRoot(new VerticalLayout([boardTracer, queenTracer, logger]));

boardTracer.set(board);
queenTracer.set(queens);
logger.println(`N Queens: ${N}X${N}matrix, ${N} queens`);
Tracer.delay();
// }

function validState(row, col, currentQueen) {
  for (let q = 0; q < currentQueen; q++) {
    const currentQ = queens[q];
    if (row === currentQ[0] || col === currentQ[1] || (Math.abs(currentQ[0] - row) === Math.abs(currentQ[1] - col))) {
      return false;
    }
  }
  return true;
}

function nQ(currentQueen, currentCol) {
  // logger {
  logger.println(`Starting new iteration of nQueens () with currentQueen = ${currentQueen} & currentCol = ${currentCol}`);
  logger.println('------------------------------------------------------------------');
  // }
  if (currentQueen >= N) {
    // logger {
    logger.println('The recursion has BOTTOMED OUT. All queens have been placed successfully');
    // }
    return true;
  }

  let found = false;
  let row = 0;
  while ((row < N) && (!found)) {
    // visualize {
    boardTracer.select(row, currentCol);
    Tracer.delay();
    logger.println(`Trying queen ${currentQueen} at row ${row} & col ${currentCol}`);
    // }
    
    if (validState(row, currentCol, currentQueen)) {
      queens[currentQueen][0] = row;
      queens[currentQueen][1] = currentCol;

      // visualize {
      queenTracer.patch(currentQueen, 0, row);
      Tracer.delay();
      queenTracer.patch(currentQueen, 1, currentCol);
      Tracer.delay();
      queenTracer.depatch(currentQueen, 0);
      Tracer.delay();
      queenTracer.depatch(currentQueen, 1);
      Tracer.delay();
      // }
      
      found = nQ(currentQueen + 1, currentCol + 1);
    }

    if (!found) {
      // visualize {
      boardTracer.deselect(row, currentCol);
      Tracer.delay();
      logger.println(`row ${row} & col ${currentCol} didn't work out. Going down`);
      // }
    }
    row++;
  }

  return found;
}

// logger {
logger.println('Starting execution');
// }
nQ(0, 0);
// logger {
logger.println('DONE');
// }

可以使用右上角的单步调试,观察运行规律。代码分析如下:
1.queens存放最终答案,是一个二维数组,第一维等于皇后个数N,第二维是个坐标点[第几行,第几列],初始化全是默认值-1

2.循环向queens塞入结果时,currentQueen表示当前在操作哪个坐标,对应的是第一维,可以取出来一个坐标值。

3.起始是nQ(0,0),对应的是function nQ(currentQueen, currentCol)。也就是从queens的索引0开始写入,并且currentCol是从0列开始的。nQ的意思就是newQueen,会有日志输出

logger.println(`Starting new iteration of nQueens () with...

4.刚开始运行时,把第一个皇后放在0,0成功后,紧接着就进入它的子分支found = nQ(currentQueen + 1, currentCol + 1);。在这个子分支中,会去查第二个皇后的位置,并且currentCol加1,也就是说,之前的列已经不用检查能不能放了。可以理解为,之前的选择都存在queens里了,后面的选择只是它们的子分支。然后在这一列里,每一行都要做检查,所以每次row=0,然后While循环中判断row

5.什么时候结束?其实每个分支在遇到新的分支后,都会往下走,不满足最终条件的都相当于剪枝了。比如第一个皇后放在0,0时。检查所有子分支,都不符合要求。就会进行row++,也就是把第一个皇后放在1,0位置,继续查子分支。

6.检查条件

function validState(row, col, currentQueen) {
  for (let q = 0; q < currentQueen; q++) {
    const currentQ = queens[q];
    if (row === currentQ[0] || col === currentQ[1] || 
    (Math.abs(currentQ[0] - row) === Math.abs(currentQ[1] - col))) {
      return false;
    }
  }
  return true;
}

检查一个位置能不能放,传入了坐标row,col。然后把queens之前的坐标都拿出来遍历一下,其中斜线不能使用,用了Math.abs很方便,即一个点的左上,右上,左下,右下里,单位长度距离1个单位的,都算符合条件。

7.出口
这个语句其实挺好写的,一般也就2-3行代码,大多数人都能想出来。但我觉得大多数人苦恼的就是不知道该把它放在哪儿,我刚开始也是这样,后面总结了2-3题之后,我发现了一个万能规律,就是把出口语句放在递归函数的第一行。最主要的就是不能把出口语句放在for和while循环语句里面,因为出口语句一定要方便整个函数退出

  if (currentQueen >= N) {
    // logger {
    logger.println('The recursion has BOTTOMED OUT. All queens have been placed successfully');
    // }
    return true;
  }

8.递归函数的参数
这个递归函数的参数的设置也是有很大门道的,设置的好就很容易得到答案,否则弄大半天可能还是没有一点反应。大家一定要记住一点:这个参数是随着每一次的递归操作而发生改变的。而回溯法很关键的一点就是:如果当前操作行不通,如何回溯到上一步操作。大家继续看上面贴的两个递归函数的参数,会发现其参数都是要改变的,既然参数会发生改变,那么我们要如何保存其上一步操作的值呢?大家可以再细细看看上述两个函数的传值操作。

for index in range(nums):
    Flag = conflict(queen_str, index)
    # 如果当前位置的皇后是否与之前所有位置的皇后没有冲突,则执行下述代码
    if Flag is False:
       back(queen_str+str(index))

大家可以看到back(queen_str+str(index))这一步,其传的参数就是queen_str+str(index) 其实想法就是不破坏当前参数的值,直接把当前值加上一个值(大家可以理解为定义了另一个非queen_str当前值的值给传到下一次函数),只要不破坏当前值,函数就能回溯。这一步很关键,大家可以好好品味。

for index in range(nums):
    Flag = conflict(queen_str, index)
    # 如果当前位置的皇后是否与之前所有位置的皇后没有冲突,则执行下述代码
    if Flag is False:
       queen_str = queen_str+str(index)
       back(queen_str )

如果大家还有些疑惑的话,可以再把传值操作改成这样试试,你会发现结果会大相径庭的,这里就是破坏了当前值。

关于参数,我还有一点就是强调:就是结果一定是要有一个全局参数来保存,这个全局参数不会随着每一次的递归操作而随时改变,它只是用来保存每一次递归操作成功时的结果,其它的不关它的事。你仔细看看这两个程序也会发现:它们在一开始就定义了一个List空列表。

9.递归函数的处理过程
这个过程是最关键的了,但是也很少有人能把它说清楚,当然也包括我。我想来想去,总结起来一句话就是:如果当前递归过程的处理参数符合要求,则执行相关赋值或其它操作,然后转入下一次递归,如果下一次递归不能找到出口,则把之前相关赋值或其它操作重置为初始状态。

迷宫问题的处理过程

nums[pos_x, pos_y] = 0
back(next_position, pos_list_copy)
# 如果没有找到出口,则将当前上一个位置0重置为1,回溯
nums[pos_x, pos_y] = 1
三、回溯算法套路详解

回溯算法的框架:

result = []
def backtrack(路径, 选择列表):
    if 满足结束条件:
        result.add(路径)
        return

    for 选择 in 选择列表:
        做选择
        backtrack(路径, 选择列表)
        撤销选择

其核心就是 for 循环里面的递归,在递归调用之前「做选择」,在递归调用之后「撤销选择」,特别简单。

请问一下,递归之后为何要撤销选择?
因为在递归之前已经从选择列表中对当前节点做出了一个选择,改变了当前节点的路径和选择列表。因此如果想要从选择列表中对当前节点做另一个选择,就需要先把前一个选择对路径和选择列表的影响消除掉,让路径和选择列表回到没有做选择的状态,因此需要把前一个节点从路径中消除,并重新添加到选择列表中。

vector> res;

/* 输入棋盘边长 n,返回所有合法的放置 */
vector> solveNQueens(int n) {
    // '.' 表示空,'Q' 表示皇后,初始化空棋盘。
    vector board(n, string(n, '.'));
    backtrack(board, 0);
    return res;
}

// 路径:board 中小于 row 的那些行都已经成功放置了皇后
// 选择列表:第 row 行的所有列都是放置皇后的选择
// 结束条件:row 超过 board 的最后一行
void backtrack(vector& board, int row) {
    // 触发结束条件
    if (row == board.size()) {
        res.push_back(board);
        return;
    }

    int n = board[row].size();
    for (int col = 0; col < n; col++) {
        // 排除不合法选择
        if (!isValid(board, row, col)) 
            continue;
        // 做选择
        board[row][col] = 'Q';
        // 进入下一行决策
        backtrack(board, row + 1);
        // 撤销选择
        board[row][col] = '.';
    }
}

/* 是否可以在 board[row][col] 放置皇后? */
bool isValid(vector& board, int row, int col) {
    int n = board.size();
    // 检查列是否有皇后互相冲突
    for (int i = 0; i < n; i++) {
        if (board[i][col] == 'Q')
            return false;
    }
    // 检查右上方是否有皇后互相冲突
    for (int i = row - 1, j = col + 1; 
            i >= 0 && j < n; i--, j++) {
        if (board[i][j] == 'Q')
            return false;
    }
    // 检查左上方是否有皇后互相冲突
    for (int i = row - 1, j = col - 1;
            i >= 0 && j >= 0; i--, j--) {
        if (board[i][j] == 'Q')
            return false;
    }
    return true;
}
四、参考 剑指OFFER

回溯法可以看成是蛮力法的升级版,它从解决问题每一步的所有可能选项里系统的选择出一个可行的解决方案。回溯法非常适合由多个步骤组成的问题,并且每个步骤都有多个选项。当我们在某一步选择了其中一个选项时,就进入下一步,然后面临新的选项。我们就这么重复选择,直至到达最终的状态。
  用回溯法解决的问题的所有选项可以形象地用树状结构表示。在某一步有n个可能的选项,那么该步骤可以看成是树状结构中的一个节点,每个选项看成树中节点的连接线,经过这些连接线到达某个节点的n个子节点。树的叶节点对应着终结状态。如果在叶节点的状态满足题目的约束条件,那么我们找到了一个可行的解决方案。
  如果在叶节点的状态不满足约束条件,那么只好回溯到它的上一个节点再尝试其他选项。如果上一个节点所有可能的选项都已经试过,并且不能达到满足约束条件的终结状态,则再次回溯到上一个节点。如果所有节点的所有选项都已经尝试过仍然不能到达满足约束条件的终结状态,则该问题无解。

如果面试题要求在二维数组(可能具体表现为迷宫或棋盘)上搜索路径,那么我们可以尝试用回溯法。通常回溯法很适合用递归的代码实现。只有当面试官限定不可以用递归实现的时候,我们再考虑用栈来模拟递归的过程。

你可能感兴趣的:(回溯算法 八皇后问题)