回溯法:N皇后与解数独

好久没写博客了,最近比较忙,在系统学习一些知识,没学完之前不太容易输出高质量文章,等过段时间学完了再整理一下写几篇文章出来。

但中间也在用零碎的时间学学别的,今天写总结一下回溯法。

概念

回溯法作为一种搜索算法,可以找出所有或一部分解的一般性算法,尤其适用于约束满足问题,例如今天要讲的N皇后、解数独等等。
回溯法采用试错的思想,它尝试分步的去解决一个问题。在分步解决问题的过程中,当它通过尝试发现现有的分步答案不能得到有效的正确的解答的时候,它将取消上一步甚至是上几步的计算,再通过其它的可能的分步解答再次尝试寻找问题的答案。回溯法通常用最简单的递归方法来实现,在反复重复上述的步骤后可能出现两种情况:

  • 找到一个可能存在的正确的答案
  • 在尝试了所有可能的分步方法后宣告该问题没有答案

在最坏的情况下,回溯法会导致一次复杂度为指数时间的计算。

回溯法实际上是一种 DFS(深度优先搜索算法)的一种,不同的是,回溯法具备剪枝的能力,下面通过两个例子来具体分析回溯算法。

N 皇后

N 皇后问题是基于八皇后问题的进一步发展,如何能够在 n * n 大小的国际象棋棋盘上摆放 n 个皇后,使得任何一个皇后都无法直接吃掉其他的皇后?为了达到此目的,任两个皇后都不能处于同一条横行、纵行或斜线上。下图为八皇后问题的其中一个解。

下面来分析下这个问题。
棋盘上每个位置包含两种状态:有皇后以及没有皇后。在不考虑约束条件的情况下,列出所有组合,我们将得到一个深度为 N * N 的二叉树。

上图描述了棋盘前两个位置的可能性。
最简单的办法是穷举出所有可能性,然后筛选出符合的解。可以通过 DFS 算法遍历这个二叉树,对于 N * N 的棋盘将有 2 的 N * N 次方种可能,这显然是不可接受的。
但我们可以通过规则来进行剪枝,可以使用的规则如下:

  • 共需要摆放 N 个皇后
  • 每行只能有一个皇后
  • 每列只能有一个皇后
  • 每斜线上只能有一个皇后

通过上述四个条件,我们可以减掉大部分的路径。
现在回到回溯法上来看这道题,回溯法使用试错的思想,分步解决问题,我们可以先假设第一个位置放置皇后,然后根据规则,找到第二个合法的位置再放置第二个皇后,如果找不到合适的位置则表示该路径错误,回溯到上一个位置继续。
回溯法特征之一就是会使用数组或其他数据结构保存遍历信息,从而跳过不合法的路径。
本题使用三个数组分别存储列、左上至右下斜边、右上至坐下斜边的皇后摆放数据。
因为每行只能摆放一个皇后,所以我们按行遍历,尝试在当前行每一个位置放置皇后。然后跳到下一行继续。
下面直接放上代码:

public List<List<String>> solveNQueens(int n) {
    //当前列中是否已有皇后
    boolean[] column = new boolean[n];
    //右上到左下列中是否有皇后
    boolean[] rl = new boolean[2 * n - 1];
    //左上到右下列中是否有皇后
    boolean[] lr = new boolean[2 * n - 1];
    //每行皇后的位置
    int[] board = new int[n];
    List<List<String>> result = new ArrayList<>(n);
    backtrace(n, 0, column, rl, lr, board, result);
    return result;
}

private void backtrace(int n, int row, boolean[] column, boolean[] rl, boolean[] lr, int[] board, List<List<String>> result) {
    if (row >= n) {
        //遍历结束,此时 board 数组中存放的就是解
        List<String> boardList = new ArrayList<>(n);
        for (int i = 0; i < n; i++) {
            StringBuilder rowBuilder = new StringBuilder();
            for (int j = 0; j < n; j++) {
                rowBuilder.append(board[i] == j ? "Q" : ".");
            }
            boardList.add(rowBuilder.toString());
        }
        result.add(boardList);
    }
    //
    for (int columnIndex = 0; columnIndex < n; columnIndex++) {
        if (!column[columnIndex] && !rl[row + columnIndex] && !lr[n - 1 - row + columnIndex]) {
            //无皇后
            column[columnIndex] = rl[row + columnIndex] = lr[n - 1 - row + columnIndex] = true;
            board[row] = columnIndex;
            backtrace(n, row + 1, column, rl, lr, board, result);
            column[columnIndex] = rl[row + columnIndex] = lr[n - 1 - row + columnIndex] = false;
        }
    }
}

复杂性分析

  • 时间复杂度:O(N!)
    第一个皇后有 N 中摆放方式,第二个皇后必须不能跟第一个在一列以及斜角,所以第二个皇后有 N-1 种可能,以此类推,时间复杂度为 O(N!)。
  • 控件复杂度:O(N)
    需要使用数组保存信息。

解数独

数独游戏就是我们常见的那个解数独的游戏。

  • 数字 1-9 在每一行只能出现一次。
  • 数字 1-9 在每一列只能出现一次。
  • 数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。

思路跟 N 皇后一样,遍历所有空格,逐个将 1-9 数组放置空格内,通过规则判断是否合法,最终找到解。
这里同样需要定义三个数组用于存放遍历数据:每行、每列、每 3x3 宫格内的数据。
另外,如果 Sn 表示 第 n 个 3x3 宫格,那么 Sn = (row / 3) * 3 + column / 3;

public void solveSudoku(char[][] board) {
    List<Character>[] rowRecord = new ArrayList[9];
    List<Character>[] columnRecord = new ArrayList[9];
    List<Character>[] boxRecord = new ArrayList[9];
    //初始化数据
    for (int row = 0; row < 9; row++) {
        for (int column = 0; column < 9; column++) {
            Character c = board[row][column];
            if (rowRecord[row] == null) {
                rowRecord[row] = new ArrayList<>(9);
            }
            if (columnRecord[column] == null) {
                columnRecord[column] = new ArrayList<>(9);
            }
            int boxIndex = (row / 3) * 3 + column / 3;
            if (boxRecord[boxIndex] == null) {
                boxRecord[boxIndex] = new ArrayList<>(9);
            }
            if (c != '.') {
                rowRecord[row].add(c);
                columnRecord[column].add(c);
                boxRecord[boxIndex].add(c);
            }
        }
    }
    backtrack(board, 0, 0, rowRecord, columnRecord, boxRecord);
}

private boolean backtrack(char[][] board,
                          int row, int column,
                          List<Character>[] rowRecord,
                          List<Character>[] columnRecord,
                          List<Character>[] boxRecord) {
    if (row == 9) {
        return true;
    }
    int nextRow = column < 8 ? row : row + 1;
    int nextColumn = column < 8 ? column + 1 : 0;
    int boxIndex = (row / 3) * 3 + column / 3;
    Character currentChar = board[row][column];
    if (currentChar == '.') {
        //逐个尝试把 1-9 放入空格中
        for (int i = 1; i < 10; i++) {
            Character c = (char) (i + 48);
            //判断当前行、列、3x3 矩阵中是否包含改数字
            if (!rowRecord[row].contains(c) &&
                    !columnRecord[column].contains(c) &&
                    !boxRecord[boxIndex].contains(c)) {
                //不包含则把数据放入该空格进行尝试
                board[row][column] = c;
                rowRecord[row].add(c);
                columnRecord[column].add(c);
                boxRecord[boxIndex].add(c);
                //跳转到下一个空格继续
                if (!backtrack(board, nextRow, nextColumn, rowRecord, columnRecord, boxRecord)) {
                    //改路径无法得出解,回溯到上一个状态
                    board[row][column] = currentChar;
                    rowRecord[row].remove(c);
                    columnRecord[column].remove(c);
                    boxRecord[boxIndex].remove(c);
                } else {
                    return true;
                }
            }
        }
    } else {
        return backtrack(board, nextRow, nextColumn, rowRecord, columnRecord, boxRecord);
    }
    return false;
}

复杂性分析

  • 时间复杂度
    本题的输入是固定的九宫格,所以直接计算实际次数。
    第一行有不多于 9 个空格要填数字,由于不能重复,所以共有 9! 种方式,总共有 9 行,所以最多需要(9!)^9 次。

  • 空间复杂度
    我们定义了三个数组,每个数组有 81 个元素,共有 3x81=243 个元素。

上面的代码我放在 Github 上了,里面还有很多其他数据结构与算法相关的代码,需要的可以看看:
https://github.com/0xZhangKe/Algorithms/tree/master/src/com/zhangke/algorithms/leetcode

欢迎关注我的公众号,还有更多干货。

微信扫描二维码或者搜索:zhangke_blog


最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 196,099评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,473评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,229评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,570评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,427评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,335评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,737评论 3 386
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,392评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,693评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,730评论 2 312
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,512评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,349评论 3 314
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,750评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,017评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,290评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,706评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,904评论 2 335