回溯算法

106 阅读20分钟

理解回溯本质上是一种 穷举。只不过有些穷举可以在举的过程中提前判断结果非法,然后剪枝。

www.bilibili.com/video/BV1mG…

基本思路

  1. dfs(pos, chooseSet), 在pos上枚举此时可以选择的所有内容, 然后继续枚举pos + 1, 直到pos(路径位置) 满足加入结果集的需求.
  2. dfs() 每个位置上可以选择 选or不选

leetcode17 电话号码

纯粹的枚举,可以把整个过程思考成一棵多叉树。

递归的正确性只需要证明边界条件&过程正确,本质上是一个递推式,用数学归纳法证明。

回溯可以视为 循环 + 递归。

dfs():
    # 注意, 子过程的输入可能和当前过程能选择的结果不一致
    # 比如子集问题中, 如果当前可选择的结果为{i}, 那么子过程应该从i+1开始,也就是子过程的输入为{i+1}
    for 当前过程可以选择的结果:
        dfs(子过程的输入)

class Solution {
    private static final String[][] m = new String[][] {
        {}, {}, {"a", "b", "c"}, {"d", "e", "f"}, {"g", "h", "i"},
        {"j", "k", "l"}, {"m", "n", "o"}, {"p", "q", "r", "s"},
        {"t", "u", "v"}, {"w", "x", "y", "z"}
    };

    List<String> res = new ArrayList<>();

    public List<String> letterCombinations(String digits) {
        char[] cs = digits.toCharArray();
        if (cs.length == 0) return new ArrayList<>();
        dfs(0, cs, "");
        return res;
    }

    public void dfs(int cur, char[] cs, String path) {

        if (path.length() == cs.length) {
            res.add(path);
            return;
        }


        if (cur >= cs.length) return;

        // 当前过程可以选择的所有结果
        for (String c : m[cs[cur] - '0']) {
            // 子过程, 子过程可以选择的结果(m[cs[cur+1]])
            dfs(cur + 1, cs, path + c);
        }

    }
}

leetcode 437 路径总和III

回溯 + 前缀和 + 两数之和。

我们看下两个递归的做法:

好难啊,写了一遍还是不会.

关键点

  1. 理解求的是路径,不是子序列
  2. 如何体现"可以从任何结点出发" --> 遍历任何结点(外层递归),然后把结点传入路径dfs里(内层递归)
  3. 内层递归中,每一步都需要 - root.val, 以保证路径的"连续性"
/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode() {}
 *     TreeNode(int val) { this.val = val; }
 *     TreeNode(int val, TreeNode left, TreeNode right) {
 *         this.val = val;
 *         this.left = left;
 *         this.right = right;
 *     }
 * }
 */
class Solution {
    public int pathSum(TreeNode root, int targetSum) {
        // 流程: 1. 先 **固定从root出发**,此时路径上一定包含root,寻找**连续的path**中有多少满足要求的

        // 关键点: 要以每个node为起点都试一次。
        if (root == null) return 0;
        // 路径**以当前结点为头**时的合法路径数目
        int chooseRoot = rootPath(root, targetSum);

        // 路径 **跳过当前结点**时的合法数目,我们知道,跳过当前结点意味着 **要么以左子树根为起点**,**要么以右子树根为起点**
        return chooseRoot + pathSum(root.left, targetSum) + pathSum(root.right, targetSum);
    }

    /*
        难点
        1. 理解为什么每次都要 - root.val : 保证路径连续性
        2. pathSum首次调用rootPath时,如何体现 "可以选择,也可以不选择的"
    */
    public int rootPath(TreeNode root, int targetSum) {
        // 找到从root开始的 **连续路径** 中 有多少满足题意的
        // 注意: 这个递归的含义就是,路径必须要以root为起点,这样可以避免重复计算

        // 1. 没有路径时,当targetSum为0,则有一条合法路径
        if (root == null) {
            return 0; // 当当前结点的val == targetSum时判断,覆盖这个case,不需要额外在root == null时判断
        }

        // 2. 向左右子过程询问结果,如果到当前为止targetSum刚好为node.val,则从root到当前结点有一条合法路径
        return rootPath(root.left, targetSum - root.val) +  // 这里必须 让targetSum去 - root.val。否则路径不连续!
            rootPath(root.right, targetSum - root.val) + 
            (targetSum == root.val ? 1 : 0); 

    }
}

leetcode78 子集

特点:

  1. 面向过程: 依次遍历整个数组,每个位置上可以选,也可以不选。这个做法得出的是子序列,也就是可以不连续。
class Solution {
    public List<List<Integer>> subsets(int[] nums) {
        // 每个元素可以选择,也可以不选择
        // 注意,我们要注意437,如果真是"可选可不选",那我们得到的结果 将是一个 子序列,而不是子数组
        dfs(0, nums);
        return res;
    }

    List<Integer> path = new ArrayList<>();
    List<List<Integer>> res = new ArrayList<>();
    public void dfs(int cur, int[] nums) {
        // 对于每个元素,可选可不选 e.g.
        // 1会开辟两个平行世界,有1在的和没1在的
        // 然后这两个平行世界再次开辟两个,对于有1在的,那就是2在和2不在,同理1不在的,则有2在和2不在,以此类推

        if (cur >= nums.length) {
            res.add(new ArrayList<>(path));
            return;
        }



        path.add(nums[cur]);
        // 选当前结点的世界线
        dfs(cur + 1, nums);
        // 选完了回溯状态
        path.remove(path.size() - 1);

        // 不选当前结点的世界线
        dfs(cur + 1, nums);

    }
}
  1. 面向结果: 假设每次递归都会在当前位置选择一个合法值,每次递归可以选择多少值。
class Solution {
    public List<List<Integer>> subsets(int[] nums) {
        dfs(0, nums);
        return ans;
    }

    List<Integer> path = new ArrayList<>();
    List<List<Integer>> ans = new ArrayList<>();

    public void dfs(int cur, int[] nums) {


        // 典型问题: 可选可不选,但不能重复选,也就是每个元素 **至多选一次**
        // cur + 1表示下一次就不选cur了。
        for (int i = cur; i < nums.length; i++) {
            // 当前位置选择i(此时我假设这个坑可以选择的是nums[i])
            path.add(nums[i]);
            dfs(i + 1, nums);
            // 回溯最终状态
            path.remove(path.size() - 1);
        }

        // 到达这里意味着存在一个合法解,加入结果集
        ans.add(new ArrayList<>(path));
    }
    
    // 有一个和上面很像的写法,看看有啥区别
    // 我们发现区别主要是 回溯的范围变大了,实际上这个是求出从cur出发的所有路径
    // 而我们希望的是 "假设第一个位置是1,then枚举所有可能。然后假设第一个位置是n,then xxx"
    
    public void dfs2(int cur, int[] nums) {
        
        path.add(nums[cur]);
    
        for (int i = cur + 1; i < nums.length; i++) {
            dfs2(i, nums);
        }
        
        ans.add(new ArrayList<>(path));
        path.remove(path.size() - 1);
    }
}

leetcode 131 分割回文串

我们把字符看成很多可以切的部分,尝试在任何部分切一刀,此时字符分为两部分

  1. 当前dfs判断首部分是不是回文,如果是则把另一部分交给子过程判断,并把自己加入path
  2. 如果不是回文,直接退出。表示 如果切割当前子串的这个位置会产生非回文子串,则需要跳过这次切割。

组合型回溯(0426)

leetcode77 组合

  1. 可以从后往前递归,此时 "还可以选择的数字个数 就是i,而从前往后就是n-i+1"
  2. 思考一下结果是如何去重的,为什么不需要额外的visited数组
class Solution {
    public List<List<Integer>> combine(int n, int k) {

        // 不重复选择的话,加一个vis数组,进入时标记为true,退出还原为false。

        boolean[] vis = new boolean[n + 1];
        dfs(0, 1, n, k); // 从第0个位置开始, 可选区间为[1, .., n]
        return ans;
    }
    // 枚举第i个位置上可以选择哪些元素,当 i > k时退出
    // 剪枝: 如果发现剩下的元素不够构成k,就退出
    
    /*
         __            
         ⬆️      
       当前位置, set(可以选择的集合)

       之后的过程

       _选择i位的元素_  __
                     i+1 set1, set2, set3...
    */
    List<Integer> path = new ArrayList<>();
    List<List<Integer>> ans = new ArrayList<>();
    // i: 当前位置
    // j: 从哪里开始选择
    // n: 可以选择范围的上限

    // dfs(i, j, n) 表示第i个位置, 可以选择[j, ..., n] 这些元素
    // 其中j从1开始,[j, ..., n] 选择前闭后闭, 元素个数为 n - j + 1
    public void dfs(int i, int j, int n, int k) {
        
        // 如果此时路径长度为k,则加入结果集

        // k: 预期的路径长度
        // path.size(): 目前的路径长度
        // n - j + 1: 还可以选的数字个数
        // 剪枝: 如果目前的路径长度 + 还可以选择的长度 < 预期的路径长度, 
        // 说明继续递归到底也没法选择k个直接退出

        if ((n - j + 1) + path.size() < k) return;

        if (path.size() == k) {
            ans.add(new ArrayList<>(path));
            return;
        }

        for (int cur = j; cur <= n; cur++) {
            // dfs(i, j, n) -> [dfs(i + 1, x + 1, n) for x in [j, ..., n]]  
            path.add(cur);
            // 思考: 为什么这么写可以保证结果无重复
            dfs(i + 1, cur + 1, n, k);
            path.remove(path.size() - 1);
        }
    
    }
}


leetcode 216 组合总和III

理解剪枝的一个办法是, 看下打开和关闭 剪枝II之后函数打印的path路径...


class Solution {

    // 组合总和系列里,有的是排列形回溯,有的是dp
    // 咋看呢? dp的特点就是,一个子过程要向之前的好几个过程询问数据
    // 最终结果可能要求 "个数", "最大值", "最小值"
    // 而如果需要我们给出结果,那一般就是回溯
    public List<List<Integer>> combinationSum3(int k, int n) {
        this.k = k;
        this.n = n;
        dfs(0, 1, n);
        return ans;
    }
    List<Integer> path = new ArrayList<>();
    List<List<Integer>> ans = new ArrayList<>();
    int k;
    int n;

    // pos: 当前位置
    // [start, ..., n] 当前位置可以选择的元素, 前闭后闭
    public void dfs(int pos, int start, int pathSum) {

        // pathSum随着递归计算, 每个栈帧独有一份
        // 如果pathSum不随着递归计算, 也可以在这里判断的时候计算
        if (pos == k && pathSum == 0) {
            ans.add(new ArrayList<>(path));
            return;
        }

        // 剪枝1: 如果此时pathSum < 0, 没必要继续下去了
        if (pathSum < 0) return;

        // 剪枝2: 如果此时[start, ..., 9] 都加上也没法和当前的pathSum凑成n,直接退出
        int allSum = (start + 9) * (9 - start + 1) / 2;

        // 理解怎么剪枝,就看加不加这句话之后,打印出来的path是啥样
        if (pathSum - allSum > 0) return; 
        
        // 子过程 dfs(pos + 1, i + 1) for i in range(start, n)
        for (int i = start; i <= 9; i++) {
            
            // O(1) 等差数列(1,2,3...) 求和
            // System.out.println(path);

            path.add(i);
            dfs(pos + 1, i + 1, pathSum  - i);
            path.remove(path.size() - 1);
        }
    }
}

选or不选

做的时候小细节

  1. 如果满足条件加入结果后,别忘了return (总是忘)
  2. 和77 排列一样,可以根据 还差的元素进行剪枝
  3. 我们注意到 "枚举位置上的所有可能 then 询问下一个位置" 这样得做法中,有一个参数表示当前位置pos。而选or不选的做法,没有这个pos

class Solution {
    public List<List<Integer>> combinationSum3(int k, int n) {
        this.k = k;
        this.n = n;
        dfs(1, n);
        return ans;
    }

    List<Integer> path = new ArrayList<>();
    List<List<Integer>> ans = new ArrayList<>();
    int k;
    int n;

    // 不同于"枚举每个位置上可选值, 构造给下一个位置的可选值, 询问下一个位置的子过程"
    // 对于选or不选型的回溯,我们不需要pos这个局部变量
    public void dfs(int start, int pathSum) {
        
        // 剪枝1: 如果路径和为负数则退出
        if (pathSum < 0) return;

        // 剪枝2: 如果就算把接下来的路径都选上了,也比目标值小,则退出
        if (pathSum - (start + 9) * (10 - start) / 2 > 0) return;

        // 如果路径长度满足, 路径和也满足, 则加入结果
        if (path.size() == k && pathSum == 0) {
            ans.add(new ArrayList<>(path));
            return; // 别忘了这个return啊~
        }

        // 不选当前位置
        // 还是和77: 排列 一样的剪枝
        // dfs的含义就是接下来的元素可选可不选, 所以dfs实际选择的元素 <= 可选择的元素
        // 如果可选择的元素 <= 需要满足的元素,则退出

        // 10-start aka (9-start) + 1
        if (10 - start > k - path.size()) dfs(start + 1, pathSum);

        // 选当前位置
        path.add(start);
        dfs(start + 1, pathSum - start);
        path.remove(path.size() - 1);
    }
}

22. 生成括号

这道题依旧可以用"枚举位置上是左括号,还是右括号, 然后询问下一个位置对应的过程"来做,需要一个pos变量,取值为[0, 2n]

但是本题最直观的做法还是选or不选(选左括号or右括号)。

思考点: 我们是如何"回溯的"。因为调用过程是

dfs(当前过程) -》 1.dfs(选左括号,并进入下一个过程) -》 dfs(当前过程) -》2.dfs(选右括号,并进入下一个过程)。 我们如何保证1的修改不影响2? 答案是,我们传给1的path其实是当前路径和左括号拼接后的一个副本,每次都保证栈帧都保存一个这样的副本,每个栈帧独自享有,而不是共享。

如果是共享必须要回溯。

class Solution {
    public List<String> generateParenthesis(int n) {
        this.n = n;
        dfs(0, 0, "");
        return ans;
    }
    int n;
    List<String> ans = new ArrayList<>();

    // 这道题用 选or不选就比较好理解 !
    // 对于字符串路径,其实可以直接在局部变量传参的时候构造好..

    // left: 左括号的个数 right: 右括号的个数
    // path: 当前路径
    public void dfs(int left, int right, String path) {
        
        // 剪枝1: 如果左括号个数 大于需求的所有括号个数,则退出
        if (left > n || right > n) return;


        // 剪枝2: 任何时刻,右括号的个数不能比左括号更多
        if (right > left) return;

        // 走到这里,说明不存在右括号比左括号更多的case
        // 比如))) (((
        if (left == right && left == n) {
            ans.add(path);
        }

        // 尝试选左括号
        // tips: 思考这里是如何体现回溯的呢?
        dfs(left+1, right, path+"(");
        // 尝试选右括号
        dfs(left, right + 1, path + ")");

    }
}

0427三道题

22: 生成括号

基于pos(尝试在结果序列的每个位置上枚举所有该位置的可能值)

当前过程需要给子过程准备 子过程可以选择的值,然后把过程转交给子过程即可

理解为什么path作为局部变量可以不用回溯写法,却可以达成回溯的效果

class Solution {

    // 这道题用选or不选比较好理解,我们用枚举pos上的可能做一下
    public List<String> generateParenthesis(int n) {
        this.n = n;
        dfs(0, 0, "");
        return ans;
    }

    List<String> ans = new ArrayList<>();
    int n;

    // 对于String, 为了方便我们可以直接把path存成局部变量
    // 兄弟过程的path彼此互为 基于父过程的一个快照,不互相影响

    // pos: 当前位置上, 每个位置都可以选左括号or右括号,我们用left表示左括号的数量
    // 由于pos表示当前位置,而左右括号的数量还是互斥的,因此右括号的个数就是 pos - left个
    // 不需要选or不选方法中单独用一个变量来记录
    public void dfs(int pos, int left, String path) {

        // 剪枝1: 左括号的个数不能比总括号数的一半还大
        if (left > n) return;

        // 剪枝2: 任何时刻, 右括号的个数不能比左括号还大
        if (pos - left > left) return;

        if (pos == n * 2) {
            ans.add(path);
            return;
        }
        

        // 这个过程和选或不选的写法非常像,但注意,含义其实是
        // "尝试在pos位置上选择可选集合 ["(", ")"]后,将舞台交给下一个子过程(在pos+1位置上选择) "

        // 理解为什么把path放在局部变量,就能完成和操作全局变量并回溯获取一样的效果,对理解递归栈很有帮助
        dfs(pos + 1, left + 1, path + "("); // 选择左括号
        dfs(pos + 1, left, path + ")"); // 选择右括号
    }
}

39. 组合总和(I)

理解这道题为什么不能像216一样提前剪枝(可选择的不确定性)。

以及组合总和IV 其实是一个dp问题,当前过程依赖很多子过程的结果。

dp和枚举唯一的区别在于,dp不关注过程,只关注结果。dp问题一般求的是"个数, 最大值, 最小值..."。而回溯则需要求出具体的值。

一旦涉及到所有可能解,那就不得不回溯了。而只需要一个期望值的话,dp就是很好的选择

class Solution {

    // 这道题和组合总和III的区别在于,可以无限选取
    // 这样的话, 我们就不能根据"如果都选了,也没法满足"这个条件来剪枝
    // 以及无法通过"如果要选k个,但只能选d个,d < k剪枝"

    // 换句话说: 接下来能选多少, 是未知的,只能通过枚举选定
    public List<List<Integer>> combinationSum(int[] candidates, int target) {
        dfs(0, candidates, target);
        return ans;
        
    }

    List<Integer> path = new ArrayList<>();
    List<List<Integer>> ans = new ArrayList<>();

    // 同时, 由于是 不定长度的选择, 我们没有"pos"的概念,不需要一个局部变量表示当前选择的位置
    // 每次可以选择 candidates[cur:]
    public void dfs(int cur, int[] candidates, int target) {

        // 剪枝1: 如果target < 0, 直接退出
        if (target < 0) return;

        if (target == 0) {
            ans.add(new ArrayList<>(path));
            return;
        }
        
        // 我们把会被所有过程访问的变量target存储为栈帧局部变量,避免了手动回溯之

        for (int i = cur; i < candidates.length; i++) {
            path.add(candidates[i]);
            // 这里不是i+1, 是因为当前位置选完了可以再选
            dfs(i, candidates, target - candidates[i]);
            path.remove(path.size()-1);
        }
    }
}

79. 单词搜索。

这道题其实是网格图的dfs, 但是需要维护per-path上的状态,兄弟path之间回溯的问题。

整个题就好像"拿着word这张地图", 然后不断的尝试向多个方向走,如果发现路径偏离,直接退出。

我们之前的做法是,枚举出所有的定长的路径,然后和目标串对比。。。

这个就属于"不撞南墙不回头",既然我们有地图,那就一步一判断看看偏离没偏离,而不是走完了再回过头反思

class Solution {

    // 我们之前的一个暴力做法是, 枚举出所有可能,然后和目标串对一下,看看是否匹配
    // 且不说对的过程是O(n)的,我们的思维比较直线: 为什么非得等走完了再对?
    // 明明走的过程中就能看到这一步是不是我们要走的
    // 比如目前走到了"ab", 目标串是"abefg", 下一步可以选择{"a", "c", "e"}, 那肯定往e走,
    // 而不是往a或者c走。我们最后只需要比对下路径长度即可
    public boolean exist(char[][] board, String word) {
        this.word = word;

        for (int i = 0; i < board.length; i++) {
            for (int j = 0; j < board[0].length; j++) {
                if (ans) return ans;
                boolean[][] vis = new boolean[board.length][board[0].length];
                if (("" + board[i][j]).equals(word)) return true;
                dfs(0, i, j, vis, board);
            }
        }
        return ans;
    }

    boolean ans;
    String word;
    int[][] directions = { {0, 1}, {0, -1}, {1, 0}, {-1, 0} };
    // pos: 当前构成的子串长度
    // i, j: 当前走到的位置
    public void dfs(int pos, int i, int j, boolean[][] vis, char[][] board) {
        
        // 可以把这个过程理解成 "word"就是我们的"地图"
        // 整个过程就是按照地图一步一步走的,走错了就回来
        if (pos == word.length()) {
            ans = true;
            return;
        }


        if (ans) return;

        // 如果当前位置不是地图指向的,那么退出
        if (board[i][j] != word.charAt(pos)) return;
        // System.out.println("当前走到第" + pos + "个位置上, 正在遍历:" + board[i][j]);




        // 尝试往四个方向走一下
        for (int[] direct : directions) {
            int newI = i + direct[0];
            int newJ = j + direct[1];

            // 注意这里continue和return的区别
            // continue: 不去递归当前方向
            // return: 不仅不递归当前方向,其他方向也不递归了
            if (newI < 0 || newJ < 0 || newI >= vis.length || newJ >= vis[0].length) continue;

            if (vis[i][j]) continue;
            // 尝试往多个方向走一下,如果发现和地图对不上,就直接退出
            vis[i][j] = true;

            // 这里pos可以类比 994 腐烂的橘子中的 "感染时间"
            // 对于类dfs的问题,我们可以为每次迭代分配一个time的概念
            // 递归的精髓在于,我们要确信 子过程按照我们传入的参数能返回给我结果
            // 父过程不应该参与子过程的求解,父过程只需要在发现子过程明显不可能的时候剪枝就行。
            dfs(pos + 1, newI, newJ, vis, board);
            vis[i][j] = false;
        }
    }
}

0428

46. 全排列

这道题用 "每个pos上,枚举当前pos可以选择的所有集合"的思想比较好。

这里pos过程要为pos+1过程构造可选集合,pos过程需要把自己访问过的元素设置为true,避免重复选择。

class Solution {
    public List<List<Integer>> permute(int[] nums) {
        this.k = nums.length;
        boolean[] vis = new boolean[nums.length];
        dfs(0, vis, nums);
        return ans;
    }

    List<Integer> path = new ArrayList<>();
    List<List<Integer>> ans = new ArrayList<>();
    int k;

    // 按照 枚举结果每个位置上可能解的做法
    // pos: 当前是结果的第pos个位置
    // vis&nums: 当前过程给子过程准备的 "可选"集合
    // vis和nums其实和一个hashset起到的作用是一样的,用哪个都行

    // 这个是经典的 "枚举所有位置上所有可能"的做法
    // 其中,第i个位置上需要向第i+1位置上的子过程询问结果
    // 同时第i个位置在进入第i+1个过程前,需要准备好 "第i+1个位置可以选择的集合"
    public void dfs(int pos, boolean[] vis, int[] nums) {
        if (path.size() == k) {
            ans.add(new ArrayList<>(path));
            return;
        }

        for (int i = 0; i < nums.length; i++) {
            // 仅当父亲没访问过,孩子才能访问
            if (!vis[i]) {
                // 为子过程划分新的可选集合
                vis[i] = true;
                path.add(nums[i]);
                dfs(pos + 1, vis, nums);
                path.remove(path.size() - 1);
                vis[i] = false; // 不影响兄弟集合
            }
        }



    }
}

51 N皇后

这里是一个二维的 "每个位置上枚举所有该位置上可选" 的dfs

含义是,当前为pos行,尝试选择第i列,然后构造第pos+1行可以选择的列数组,过程交给pos+1来做。

要理解,如果没有"斜着的皇后不能攻击"这个条件,那就是全排列问题。

也就是求出

第1行可以在{1,2,3,4}列上放皇后,第2行,第3行,第4行同理。

有了这个约束后,再放的时候就需要额外判断斜左上角和右上角方向上是否存在皇后了。注意左上角和右上角元素在下标上的特点。

很多时候,数组的下标相比数组元素本身,会给我们带来更多的惊喜。

class Solution {

    // 1. 皇后问题实际上是一个 带边界判断的 全排列
    // 如果没有"斜着的皇后不能在一起" 的条件,单凭"一行一列只能有一个皇后"
    // 我们可以得出每个行每列只有一个皇后,那也就是求全排列
    // 对于4皇后,我们在求[1, 2, 3 ,4] 的全排列
    // 其中我们假设 nums[i] = j 表示 [i+1, j] 上有一个皇后
    // e.g. 一个可能的组合为 [3, 1, 4, 2] 表示第1行的皇后在第3列,以此类推

    // 那么加上了斜着的皇后不能互相攻击,情况就有些复杂了
    public List<List<String>> solveNQueens(int n) {
        boolean[] vis = new boolean[n];
        dfs(0, vis, n);
        return ans;
    }

    // Pair<x, y> 表示第x行第y列上已经放了皇后
    static class Pair {
        public int x;
        public int y;

        public Pair(int x, int y) {
            this.x = x;
            this.y = y;
        }
    }

    List<Pair> path = new ArrayList<>();
    List<List<String>> ans = new ArrayList<>();

    // pos: 第i行的皇后需要放在哪里
    // dfs(pos) 负责给 dfs(pos + 1)构造可选集合,并询问dfs(pos + 1)的返回结果
    // 我们发现,其实还是全排列那一套,vis[i] 表示当前皇后已经占据了第i列,其他皇后首先不能选择第i列
    public void dfs(int pos, boolean[] vis, int n) {
        // System.out.println(path);
        // 如果所有皇后都就位
        // 而且按照我们的递推关系,肯定有所有皇后都不会看对眼干起来
        if (path.size() == n) {
            List<String> grid = new ArrayList<>();
            for (Pair p : path) {
                char[] row = new char[n];
                Arrays.fill(row, '.');
                row[p.y] = 'Q';
                grid.add(new String(row));
            }
            ans.add(grid);
            return;
        }

        for (int i = 0; i < n; i++) {
            // 第pos个皇后尝试入场

            // 1. 尝试在n列都入场一下,如果第n列已经有皇后了,自己不能入场
            if (vis[i] == true) continue;

            // 2. 如果第i列没有皇后,但自己一旦入场,就会被左上角和左下角的皇后攻击到,也只能遗憾离场
            // 这里只需要考虑左上角和右上角,因为我们是从上往下放的
            // 下面如何我们不管,这是递归的核心 我们坚信 "dfs(pos+1)"会返回第pos+1行的结果

            // 为了满足斜着的皇后也能被验证,单独一个vis是不够用了
            // 我们得了解之前的皇后都被放在了哪行哪列
            boolean vaildQueen = true;
            for (Pair p : path) {
                // 获取自己之前的所有皇后的位置
                
                // 左上角有皇后: 当前位置为(pos, i)
                // 左上角的皇后leftTop满足 leftTop.x - leftTop.y = pos i
                // 因为左上角表示的含义是 横坐标-1&&纵坐标-1,因此二者之差不会变化
                if (p.x - p.y == pos - i) {
                    vaildQueen = false;
                    break;
                }

                // 右上角的皇后有
                // rightTop.x + rightTop.y = pos + i
                // 因为右上角 = 横坐标+1,纵坐标-1,所以加和应该不变
                if (p.x + p.y == pos + i) {
                    vaildQueen = false;
                    break;
                }
            }

            if (!vaildQueen) continue;

            // 历经千辛万苦,当前(pos, i)才是合法的位置
            path.add(new Pair(pos, i));
            // 管子过程要结果,并构造子过程的可选集合(标记当前位置被选过)
            vis[i] = true;
            dfs(pos+1, vis, n);
            vis[i] = false;
            path.remove(path.size() - 1);



        }
    }
}