Leetcode 算法之回溯算法 —— Java 题解

603 阅读10分钟

回溯算法是一种枚举算法,主要是在搜索过程尝试寻找问题的解,如果发现条件不满足、不匹配,那么就回溯“返回”,尝试别的路径。

许多复杂的、规模大的问题都可以尝试使用回溯法解决。

通常,在搜索过程中,我们使用深度搜索,并在搜索之前对条件标记,搜索结束后的回溯阶段取消标记。

整个搜索过程可以抽象成一棵树,树的分支对应每种搜索路径,例如求解数组 [1,2,3,4] 的全排列,可以抽象为:

      1
    / | \
   2  3  4
  /\  /\  /\
 3  4 2 4 2 3 
 /  / ......
 4  3 ......

一个通用的回溯算法模板如下:

void backTracking(args) {
    if (终止条件) {
        收集结果
    }
    // 处理集合
    for (求解的集合) {
       处理结点,标记结点
       backTracking()
       回溯,将标记结点撤销 
    }
}

257. 二叉树的所有路径 - 简单

给你一个二叉树的根节点 root ,按 任意顺序 ,返回所有从根节点到叶子节点的路径。

叶子节点 是指没有子节点的节点。

示例:

paths-tree.jpg

输入:root = [1,2,3,null,5]

输出:["1->2->5","1->3"]

题解:

需要枚举所有的情况,可以考虑使用回溯法:

使用链表来存储每次遍历的结点,即“路径”。

从根结点出发,如果当前结点不是“叶子结点”,搜索其子节点,并将当前结点加到“路径”中,搜索结束后,将路径中的最后一个结点移除

如果当前结点是叶子结点,将当前结点添加到“路径”中,并将路径添加到结果集中

代码:

class Solution {

    public List<String> binaryTreePaths(TreeNode root) {
        List<String> res = new ArrayList<>();
        Deque<Integer> path = new LinkedList<>();
        dfs(root, path, res);
        return res;
    }

    public void dfs(TreeNode root, Deque<Integer> path, List<String> res) {
        if (root == null) {
            return;
        }
        // 如果是叶子结点,将路径添加到结果集中
        if (root.left == null && root.right == null) {
            StringBuilder sb = new StringBuilder();
            for (Integer node : path) {
                sb.append(node).append("->");
            }
            sb.append(root.val);
            res.add(sb.toString());
        }
        // 将当前结点添加到路径中,遍历下一个结点
        path.addLast(root.val);
        // 遍历所有路径
        dfs(root.left, path, res);
        dfs(root.right, path, res);
        // 路径搜索结束,将结点从路径中移除
        path.removeLast();
    }
}

46. 全排列 - 中等

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

示例:

输入:nums = [1,2,3]

输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]

题解:

对所有情况进行枚举,可以使用回溯法。

遍历数组:

  1. 如果排列结果已经满了,添加到结果集中

  2. 如果当前元素已经使用过,那么遍历下一个元素;如果当前元素没有使用过,添加到排列结果中,并标记元素已经使用过

  3. 搜索下一个元素

  4. 搜索结束,取消元素的标记

代码:

class Solution {
    public List<List<Integer>> permute(int[] nums) {
        // 结果集
        List<List<Integer>> res = new ArrayList<>();
        // 排列结果
        List<Integer> array = new LinkedList<>();
        // 标记元素是否使用过
        boolean[] flag = new boolean[nums.length];
        backTracking(nums, res, array, flag);
        return res;
    }

    private void backTracking(int[] nums, List<List<Integer>> res, List<Integer> array, boolean[] flag) {
        // 排列结果满了,添加到结果集中
        if (array.size() == nums.length) {
            res.add(new ArrayList<>(array));
            return;
        }

        for (int i = 0; i < nums.length; i++) {
            // 如果当前元素没有使用过,添加到排列结果中
            if (!flag[i]) {
                flag[i] = true;
                array.add(nums[i]);
                backTracking(nums, res, array, flag);
                array.remove(array.size() - 1);
                flag[i] = false;
            }
        }
    }
}

47. 全排列 II - 中等

给定一个可包含重复数字的序列 nums按任意顺序 返回所有不重复的全排列。

示例:

输入:nums = [1,1,2]

输出:

[[1,1,2],

[1,2,1],

[2,1,1]]

题解:

使用一个列表存放排列结果,如果列表满了,将排列结果添加到结果集中。

遍历数组,如果当前元素已经标记过,即已经加入到排列结果中,遍历下一个元素;或者,当前元素与之前的元素相等,但是之前的元素没有标记,即之前的元素已经考虑过了,遍历下一个元素。

如果不满足上面的条件,那么将当前元素标记,并添加到排列结果中,搜索下一个排列元素。

代码:

class Solution {
    public List<List<Integer>> permuteUnique(int[] nums) {
        List<List<Integer>> res = new ArrayList<>();
        List<Integer> perm = new ArrayList<>();

        boolean[] flag = new boolean[nums.length];
        Arrays.sort(nums);

        backTracking(nums, 0, flag, res, perm);

        return res;
    }

    private void backTracking(int[] nums, int idx,boolean[] flag, List<List<Integer>> res, List<Integer> perm) {
        if (idx >= nums.length) {
            res.add(new ArrayList<>(perm));
            return;
        }

        for (int i = 0; i < nums.length; i++) {
            // 当前元素使用过,不加入排列。
            // 或者当前元素与上一个相等并且上一个元素没有使用过,不加入排列,会导致重复,因为上一个元素已经在之前的路径考虑过了
            if (flag[i] || (i > 0 && nums[i-1] == nums[i] && !flag[i-1])) {
                continue;
            }

            flag[i] = true;
            perm.add(nums[i]);
            backTracking(nums, idx+1, flag, res, perm);
            perm.remove(idx);
            flag[i] = false;
        }
    }

}

77. 组合 - 中等

给定两个整数 nk,返回范围 [1, n] 中所有可能的 k 个数的组合。

你可以按 任何顺序 返回答案。

示例:

输入:n = 4, k = 2

输出: [ [2,4], [3,4], [2,3], [1,2], [1,3], [1,4], ]

题解:

枚举所有组合,使用回溯算法:

  1. 使用一个列表保存组合结果,如果组合结果中元素个数等于 k,将组合结果添加到结果集中
  2. 遍历数组,将当前元素添加到组合结果中,搜索下一个元素的组合结果,搜索结束后,从组合结果中移除当前元素

可以进一步优化,如果剩余的元素个数已经小于 k 了,那么是没有足够的元素个数构成 k 个数的组合的,因此结束搜索节省时间。

代码:

class Solution {

    public List<List<Integer>> combine(int n, int k) {
        // 结果集
        List<List<Integer>> res = new ArrayList<>();
        // 组合结果
        LinkedList<Integer> nums = new LinkedList<>();
        backTracking(n, k, 1, res, nums);
        return res;
    }

    private void backTracking(int n, int k, int i, List<List<Integer>> res, LinkedList<Integer> nums) {
        // 组合结果满了,将组合结果添加到结果集中
        if (nums.size() == k) {
            res.add(new ArrayList<>(nums));
            return;
        }

        for (int j = i; j <= n; j++) {
            // 剪枝操作,如果剩余的数字数量小于 k,那么没必要搜索
            if (nums.size() + (n - j + 1) < k) {
                break;
            }
            nums.addLast(j);
            backTracking(n, k, j + 1, res, nums);
            nums.removeLast();
        }
    }
}

79. 单词搜索 - 中等

给定一个 m x n 二维字符网格 board 和一个字符串单词 word 。如果 word 存在于网格中,返回 true ;否则,返回 false 。

单词必须按照字母顺序,通过相邻的单元格内的字母构成,其中“相邻”单元格是那些水平相邻或垂直相邻的单元格。同一个单元格内的字母不允许被重复使用。

示例:

word2.jpg

输入:board = [["A","B","C","E"],["S","F","C","S"],["A","D","E","E"]], word = "ABCCED"

输出:true

题解:

遍历整个表格,需要枚举每个格子所能组成的单词,使用回溯算法。

具体思路为:

  1. 遍历表格,如果从当前格子出发可以组成单词 word,那么返回 true,否则继续遍历表格;
  2. 判断当前格子可否组成单词 word,过程如下:
  3. 当前格子的字符与当前指针 p 指向 word 的字符相同,那么有可能可以组成单词,标记当前格子搜索过,移动指针 p,搜索其他方向的字符,搜索结束后取消标记
  4. 如果指针 p 能够遍历完 word,那么表格中的字母可以组成单词 word,结束搜索,返回 true
  5. 如果当前格子的字符与指针 p 指向的字符不相同,进行回溯。

代码:

class Solution {
    private static final int[] direction = {-1, 0, 1, 0, -1};

    public boolean exist(char[][] board, String word) {
        int m = board.length, n = board[0].length;
        // 标记某个单元格的字符是否已经使用过
        boolean[][] flag = new boolean[m][n];
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < n; j++) {
                if (backTracking(board, word, 0, i, j, flag)) {
                    return true;
                }
            }
        }
        return false;
    }

    private boolean backTracking(char[][] board, String word, int i, int x, int y, boolean[][] flag) {
        // 如果单词已经遍历完,即网格中的字母可以构成 word,返回 结果
        if (i >= word.length()) {
            return true;
        }
        int m = board.length, n = board[0].length;
        if (x < 0 || x >= m || y < 0 || y >= n) {
            return false;
        }
        if (board[x][y] != word.charAt(i) || flag[x][y]) {
            return false;
        }
        // 搜索当前格子的四个方向,如果是 word 中的字符,标记,进行搜索,搜索结束后,取消标记
        flag[x][y] = true;
        boolean found = false;
        for (int j = 0; j < 4; j++) {
            int newX = x + direction[j], newY = y + direction[j + 1];
            found = found || backTracking(board, word, i + 1, newX, newY, flag);
            if (found) {
                return true;
            }
        }
        flag[x][y] = false;

        return false;
    }

}

51. N 皇后 - 困难

按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。

n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。

给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。

每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q' 和 '.' 分别代表了皇后和空位。

示例:

queens-16630554228092.jpg

输入:n = 4 输出:[[".Q..","...Q","Q...","..Q."],["..Q.","Q...","...Q",".Q.."]]

解释:如上图所示,4 皇后问题存在两个不同的解法。

题解:

N 皇后问题,在放置棋子时,需要考虑当前棋盘上某一列上、某一行、斜对角线上是否有棋子,如果有棋子,就不放置。同样是需要枚举出所有可能的情况,因此使用回溯法。

使用哈希思想来快速判断某个位置上是否有棋子,声明三个布尔数组来标记:

  1. col[],标记某一列是否有放置棋子
  2. diagonal1[],标记棋盘左上角指向右下角方向的斜线上是否有放置棋子
  3. diagonal2[],标记棋盘右上角指向左下角方向的斜线上是否有放置棋子

关于对角线上的棋子如何判断:

​ 假设 board 是一个4*4二维数组,那么 board[1][1]board[2][0] 在同一斜线(右上角指向左下角方向)上,同理, board[2][1]board[3][0] 也在同一斜线上,可以看到 1+1 = 2+02+1 = 3+0,因此,右上角指向左下角方向的斜线可以表示为 i+j。同理,另一方向的斜线也可以表示为 i-j

从第一行第一列开始遍历棋盘,如果能够放置棋子,标记列、斜线上已经放置了棋子,随后枚举下一行,枚举结束后取消标记。

如果成功遍历完整个棋盘,那么将棋盘结果添加到结果集中,然后回溯。

代码:

class Solution {
    // col 标记棋盘上某一列是否有放置棋子
    boolean[] col;
    // diagonal1 标记棋盘上对角线 \ 上是否有放置棋子
    boolean[] diagonal1;
    // diagonal2 标记棋盘上对角线 / 上是否有放置棋子
    boolean[] diagonal2;
    // 结果集
    List<List<String>> res;
    // 表示棋盘
    char[][] board;

    public List<List<String>> solveNQueens(int n) {
        // 初始化
        col = new boolean[n];
        diagonal1 = new boolean[3 * n];
        diagonal2 = new boolean[3 * n];
        res = new ArrayList<>();
        board = new char[n][n];

        for (int i = 0; i < n; i++) {
            Arrays.fill(board[i], '.');
        }

        backTracking(board, 0, n);

        return res;
    }

    private void backTracking(char[][] board, int row, int n) {
        // 如果棋盘遍历完毕,那么将结果添加到结果集中
        if (row >= n) {
            List<String> temp = new ArrayList<>();
            for (int i = 0; i < n; i++) {
                temp.add(new String(board[i]));
            }
            res.add(temp);
            return;
        }

        for (int j = 0; j < n; j++) {
            // 检查当前行当前列,是否有棋子,斜对角线上是否有棋子
            // 如果有棋子,遍历下一列,没有棋子,标记并搜索下一行,搜索结束后,取消标记
            // 因为 row-j 有可能出现负数的,所以加上 n 防止下标越界
            if (col[j] || diagonal1[n + (row - j)] || diagonal2[n + (row + j)]) {
                continue;
            }
            col[j] = diagonal1[n + (row - j)] = diagonal2[n + (row + j)] = true;
            board[row][j] = 'Q';
            backTracking(board, row + 1, n);
            board[row][j] = '.';
            col[j] = diagonal1[n + (row - j)] = diagonal2[n + (row + j)] = false;

        }

    }
}

37. 解数独 - 困难

编写一个程序,通过填充空格来解决数独问题。

数独的解法需 遵循如下规则:

  1. 数字 1-9 在每一行只能出现一次。
  2. 数字 1-9 在每一列只能出现一次。
  3. 数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。(请参考示例图)

数独部分空格内已填入了数字,空白格用 '.' 表示。

示例:

250px-sudoku-by-l2g-20050714svg.png

输入:board = [["5","3",".",".","7",".",".",".","."],

["6",".",".","1","9","5",".",".","."],

[".","9","8",".",".",".",".","6","."],

["8",".",".",".","6",".",".",".","3"],

["4",".",".","8",".","3",".",".","1"],

["7",".",".",".","2",".",".",".","6"],

[".","6",".",".",".",".","2","8","."],

[".",".",".","4","1","9",".",".","5"],

[".",".",".",".","8",".",".","7","9"]]

输出:[["5","3","4","6","7","8","9","1","2"],

["6","7","2","1","9","5","3","4","8"],

["1","9","8","3","4","2","5","6","7"],

["8","5","9","7","6","1","4","2","3"],

["4","2","6","8","5","3","7","9","1"],

["7","1","3","9","2","4","8","5","6"],

["9","6","1","5","3","7","2","8","4"],

["2","8","7","4","1","9","6","3","5"],

["3","4","5","2","8","6","1","7","9"]]

解释:输入的数独如上图所示,唯一有效的解决方案如下所示:

250px-sudoku-by-l2g-20050714_solutionsvg.png

题解:

枚举所有可能出现的情况,使用回溯法。

为了快速判断某一列、某一行、某一宫格内是否出现过某个数字,使用哈希思想,利用布尔数组快速定位某个数字是否出现:

  1. 二维数组 col,标志某一列是否出现过某个数字,例如: col[1][2] 表示数字 1 出现第一列
  2. 二维数组 row,标志某一行是否出现过某个数字,例如: row[1][2] 表示数字 1 出现在第一行
  3. 三维数组 boxes 标志某一宫格内是否出现过某个数字,例如: boxes[0][0][2] 表示数字 1 出现在第一个宫格内。
    1. 我们使用数独表格的下标来定位宫格,board 数组的一维下标 i 和二维下标 j 的范围都为 0-9i/3j/3 的范围都为 0,3,可以对应 9 个宫格

遍历 board 数组,如果是空格,将其坐标缓存,如果是数字,标记到三个哈希数组上。

随后进行枚举:

  1. 如果缓存中已经没有待填空格,说明题目已经解决,否则
  2. 从缓存中取出一个待填空格的坐标
  3. 枚举这个空格所能填入的各种数字,标记,随后枚举下一个空格
  4. 枚举结束后,如果题目已经解决,停止枚举,否则
  5. 取消标记,如果该空格所有数字都不能填入,将空格重新加入缓存,返回上一个空格继续枚举

代码:

class Solution {
	// 哈希标记数组
    private boolean[][] col = new boolean[9][9];
    private boolean[][] row = new boolean[9][9];
    private boolean[][][] boxes = new boolean[3][3][9];
    // 缓存待填空格队列
    private Deque<int[]> queue = new LinkedList<>();

    public void solveSudoku(char[][] board) {
        // 确定需要填入数字的表格,标记已经填入数字的格子
        for (int i = 0; i < 9; i++) {
            for (int j = 0; j < 9; j++) {
                char c = board[i][j];
                if (c == '.') {
                    queue.offer(new int[]{i, j});
                } else {
                    int num = c - '0';
                    col[j][num-1] = row[i][num-1] = boxes[i/3][j/3][num-1] = true;
                }
            }
        }
        //回溯
        backTrack(board);
    }

    private boolean backTrack(char[][] board) {
        // 所有格子都已经填充完毕
        if (queue.isEmpty()) {
            return true;
        }
        // 取到待填入空格的坐标
        int[] pos = queue.pollFirst();
        int x = pos[0], y = pos[1];
        boolean flag = false;
        // 在该空格上,遍历所有可以填入的数字的情况,然后深度搜索,搜索结束后,取消标记
        for (int num = 1; num <= 9; num++) {
            int i = num - 1;
            if (!col[y][i] && !row[x][i] && !boxes[x/3][y/3][i]) {
                col[y][i] = row[x][i] = boxes[x/3][y/3][i] = true;
                board[x][y] = (char) ('0'+num);
                flag = backTrack(board);
                if (flag) {
                    return true;
                }
                board[x][y] = '.';
                col[y][i] = row[x][i] = boxes[x/3][y/3][i] = false;
            }
        }
        // 该空格无法填入数字,回溯,重新加入队列
        queue.addFirst(pos);
        return false;
    }
}