dfs优化套路

基本优化方法

可行性剪枝

所谓可行性剪枝,顾名思义,就是当当前状态和题意不符,并且由于题目可以推出,往后的所有情况和题意都不符,那么就可以进行剪枝,直接把这种情况及后续的所有情况判负,直接返回。

排除等效冗余

所谓排除等效冗余,就是当几个枝桠具有完全相同的效果的时候,只选择其中一个走就可以了。

最优性剪枝

所谓最优性剪枝,是在我们用搜索方法解决最优化问题的时候的一种常用剪枝。就是当你搜到一半的时候,已经比已经搜到的最优解要不优了,那么这个方案肯定是不行的,即刻停止搜索,进行回溯。

顺序剪枝

普遍来讲,搜索的顺序是不固定的,对一个问题来讲,算法可以进入搜索树的任意的一个子节点。但假如我们要搜索一个最小值,而非要从最大值存在的那个节点开搜,就可能存在搜索到最后才出解。而我们从最小的节点开搜很可能马上就出解。这就是顺序剪枝的一个应用。一般来讲,有单调性存在的搜索问题可以和贪心思想结合,进行顺序剪枝。

记忆化

记忆化搜索其实是搜索的另外一个分支。在这里简单介绍一下记忆化的原理:

就是记录搜索的每一个状态,当重复搜索到相同的状态的时候直接返回。

何时用dfs,何时用动态规划

  1. 首先看取值范围,递归回溯一维数组,100就是深度的极限了(但是只要会剪枝和记忆化也是可以的)
  2. 如果是求走迷宫的【路径】,必然是回溯;如果是走迷宫的【路径的条数】,必然是dp——–(这个竟然屡试不爽!!!!)

栗子

576. 出界的路径数

给你一个大小为 m x n 的网格和一个球。球的起始坐标为 [startRow, startColumn] 。你可以将球移到在四个方向上相邻的单元格内(可以穿过网格边界到达网格之外)。你 最多 可以移动 maxMove 次球。

给你五个整数 m、n、maxMove、startRow 以及 startColumn ,找出并返回可以将球移出边界的路径数量。因为答案可能非常大,返回对 109 + 7 取余 后的结果。

第一个版本的垃圾代码

class Solution {
    // 1表示出轨,0表示没出轨
    int[][] mat;
    int ret;
    int max;
    public int findPaths(int m, int n, int maxMove, int startRow, int startColumn) {
        ret = 0;
        max = maxMove;
        mat = new int[m + 2][n + 2];
        for(int i = 1; i < m + 1; i++){
            mat[i][0] = 1;
            mat[i][n + 1] = 1;
        }
        for(int i = 1; i < n + 1; i++){
            mat[0][i] = 1;
            mat[m + 1][i] = 1;
        }
        dfs(startRow + 1,startColumn + 1,0);
        return ret;
    }
    private void dfs(int row,int col,int step){
        if(row < 0 || row >= mat.length || col < 0 || col >= mat[0].length || step > max)return;
        if(mat[row][col] == 1){
            ret++;
            return;
        }
        dfs(row - 1,col,step + 1);
        dfs(row + 1,col,step + 1);
        dfs(row,col - 1, step + 1);
        dfs(row, col + 1,step + 1);
    }
}

//超时

因为dfs函数返回值为空,导致很难使用cache记忆化,以前写dfs留下了的坏习惯,不喜欢dfs有返回值觉得麻烦。

第二个版本

class Solution {
    // 1表示出轨,0表示没出轨
    int[][] mat;
    // 四个方向
    int[][] dir = new int[][]{{1,0},{-1,0},{0,1},{0,-1}};
    int max;
    // 取余
    int mod = (int)1e9 + 7;
    public int findPaths(int m, int n, int maxMove, int startRow, int startColumn) {
        max = maxMove;
        mat = new int[m + 2][n + 2];
        for(int i = 1; i < m + 1; i++){
            mat[i][0] = 1;
            mat[i][n + 1] = 1;
        }
        for(int i = 1; i < n + 1; i++){
            mat[0][i] = 1;
            mat[m + 1][i] = 1;
        }
        return dfs(startRow + 1,startColumn + 1,0);
    }
    private int dfs(int row,int col,int step){
        if(row < 0 || row >= mat.length || col < 0 || col >= mat[0].length || step > max)return 0;
        if(mat[row][col] == 1){
            return 1;
        }
        int ret = 0;
        for(int[] d : dir){
            ret += dfs(row + d[0],col + d[1],step + 1) % mod;
        }
        return ret;
    }
}
//超时

优化了dfs递归的写法

剪枝优化版本

就是可行性剪枝,剪枝技巧就是每次DFS的时候判断如果小球不管怎么移动都无法超出网格,那从这个点开始往后的枝就都可以剪掉了,简单修改下代码即可:

class Solution {
    int[][] mat;
    int[][] dir = new int[][]{{1,0},{-1,0},{0,1},{0,-1}};
    int max;
    int mod = (int)1e9 + 7;
    public int findPaths(int m, int n, int maxMove, int startRow, int startColumn) {
        max = maxMove;
        mat = new int[m + 2][n + 2];
        for(int i = 1; i < m + 1; i++){
            mat[i][0] = 1;
            mat[i][n + 1] = 1;
        }
        for(int i = 1; i < n + 1; i++){
            mat[0][i] = 1;
            mat[m + 1][i] = 1;
        }
        return dfs(startRow + 1,startColumn + 1,0);
    }
    private int dfs(int row,int col,int step){
        if(row < 0 || row >= mat.length || col < 0 || col >= mat[0].length || step > max)return 0;
        if(mat[row][col] == 1)return 1;
        int last = max - step;//剩余的步数
        // 剪枝:如果小球不管怎么移动都无法越出网格,那就剪掉这个枝
        if(row - last >= 1 && row + last < mat.length - 1 && col - last >= 1 && col + last < mat[0].length - 1)return 0;
        int ret = 0;
        for(int[] d : dir){
            ret += dfs(row + d[0],col + d[1],step + 1) % mod;
            ret %= mod;
        }
        return ret;
    }
}
// 剪枝了依然超时

记忆化搜索

增加一个缓存,记录下来从每个位置在给定移动次数的范围内可以越界的次数。

class Solution {
    // 1表示出轨,0表示没出轨
    int[][] mat;
    // 缓存
    int[][][] cache;
    // 4个方向
    int[][] dir = new int[][]{{1,0},{-1,0},{0,1},{0,-1}};
    int max;
    int mod = (int)1e9 + 7;
    public int findPaths(int m, int n, int maxMove, int startRow, int startColumn) {
        max = maxMove;
        mat = new int[m + 2][n + 2];
        for(int i = 1; i < m + 1; i++){
            mat[i][0] = 1;
            mat[i][n + 1] = 1;
        }
        for(int i = 1; i < n + 1; i++){
            mat[0][i] = 1;
            mat[m + 1][i] = 1;
        }
        cache = new int[m + 1][n + 1][maxMove + 1];
        for(int i = 1; i < m + 1; i++){
            for(int j = 1; j < n + 1; j++){
                for(int k = 0; k < maxMove + 1; k++)cache[i][j][k] = -1;
            }
        }
        return dfs(startRow + 1,startColumn + 1,0);
    }
    private int dfs(int row,int col,int step){
        if(row < 0 || row >= mat.length || col < 0 || col >= mat[0].length || step > max)return 0;
        if(mat[row][col] == 1){
            return 1;
        }
        // 缓存中存在
        if(cache[row][col][step] != -1)return cache[row][col][step];
        int last = max - step;
        // 剪枝:如果小球不管怎么移动都无法越出网格,那就剪掉这个枝
        if(row - last >= 1 && row + last < mat.length - 1 && col - last >= 1 && col + last < mat[0].length - 1)return 0;
        int ret = 0;
        for(int[] d : dir){
            ret += dfs(row + d[0],col + d[1],step + 1) % mod;
            ret %= mod;
        }
        // 记录缓存
        cache[row][col][step] = ret;
        return ret;
    }
}
// ak

动态规划

一般来说,能使用记忆化搜索的题目都可以使用动态规划来解:

dp[i][j][k]表示从 [i,j] 位置最多移动 k 次能够把小球移出去的最大路径数量

dp[i][j][k] = dp[i-1][j][k-1] + dp[i+1][j][k-1] + dp[i][j-1][k-1] + dp[i][j+1][k-1]

注意边界条件,如果是正方形的四个顶点,有两种方法越界,其他边上的位置只有一种方法越界。

另外,要注意移动次数2的都是从移动次数为1的扩展来的,同理,移动次数3的都是从移动次数为2的扩展来的,所以要注意循环的顺序。

class Solution {
    public int findPaths(int m, int n, int maxMove, int startRow, int startColumn) {
        int[][][] dp = new int[m][n][maxMove + 1];
        int[][] direct = new int[][]{{1,0},{-1,0},{0,1},{0,-1}};
        int mod = (int)1e9 + 7;
        // 移动步数2的都是从移动步数1的转移来的
        // 移动步数3的都是从移动步数2的转移来的
        // 所以,要从移动步数从1开始递增
        for(int k = 1; k <= maxMove; k++){
            for(int i = 0; i < m; i++){
                for(int j = 0; j < n; j++){
                    // 初始化,处理四条边
                    if(i == 0)dp[i][j][k]++;
                    if(i == m - 1)dp[i][j][k]++;
                    if(j == 0)dp[i][j][k]++;
                    if(j == n - 1)dp[i][j][k]++;
                    // 中间的位置,向四个方向延伸
                    for(int[] d : direct){
                        int x = i + d[0],y = j + d[1];
                        if(x >= 0 && x < m && y >= 0 && y < n){
                            dp[i][j][k] = (dp[i][j][k] + dp[x][y][k - 1]) % mod;
                        }
                    }
                }
            }
        }
        return dp[startRow][startColumn][maxMove];
    }
}

但是,仍然没办法跟记忆化搜索相比,因为记忆化搜索我们可以通过剪枝等手段减少循环(递归)的次数,但是动态规划的方法每一轮都要把(m∗n)个格子重新计算一遍。

526. 优美的排列

假设有从 1 到 N 的 N 个整数,如果从这 N 个数字中成功构造出一个数组,使得数组的第 i 位 (1 <= i <= N) 满足如下两个条件中的一个,我们就称这个数组为一个优美的排列。条件:

  • 第 i 位的数字能被 i 整除
  • i 能被第 i 位上的数字整除

现在给定一个整数 N,请问可以构造多少个优美的排列?

暴力版本

class Solution {
    boolean[] used;
    public int countArrangement(int n) {
        used = new boolean[n + 1];
        return dfs(n,1);
    }
    private int dfs(int n , int idx){
        if(idx == n + 1)return 1;
        int ans = 0;
        for(int i = 1; i <= n; i++){
            if(!used[i]){
                if(i % idx == 0 || idx % i == 0){
                    used[i] = true;
                    ans += dfs(n,idx + 1);
                    used[i] = false;
                }
            }
        }
        return ans;
    }
}

状态压缩

我们仔细看题目给的限制条件是:n不会超过15,所以,我们可以只使用一个 int 类型的变量代替used数组,这其实就是状态压缩了。

class Solution {
    public int countArrangement(int n) {
        return dfs(n,1,0);
    }
    private int dfs(int n , int idx,int used){
        if(idx == n + 1)return 1;
        int ans = 0;
        for(int i = 1; i <= n; i++){
            if(((1 << i) & used) == 0){
                if(i % idx == 0 || idx % i == 0){
                    ans += dfs(n,idx + 1,(1 << i) | used);
                }
            }
        }
        return ans;
    }
}

记忆化搜索

有了状态压缩基础就可以把used数组当成一个状态,idx当成一个状态缓存起来避免重复计算

class Solution {
    int[][] cache;
    public int countArrangement(int n) {
        cache = new int[n + 1][1 << n + 1];
        for(int i = 0; i < n + 1; i++)Arrays.fill(cache[i],-1);
        return dfs(n,1,0);
    }
    private int dfs(int n , int idx,int used){
        if(idx == n + 1)return 1;
        if(cache[idx][used] != -1)return cache[idx][used];
        int ans = 0;
        for(int i = 1; i <= n; i++){
            if(((1 << i) & used) == 0){
                if(i % idx == 0 || idx % i == 0){
                    ans += dfs(n,idx + 1,(1 << i) | used);
                }
            }
        }
        cache[idx][used] = ans;
        return ans;
    }
}

动态规划

定义 dp[i][used] 为考虑前 i 个数,且当前选择方案为 used 的所有方案数量

一个显然的初始化条件为dp[0][0] = 1,代表当我们不考虑任何数(i=0)的情况下,一个数都不被选择(used = 0)为一种合法方案。

根据状态定义,位置 i 选了数值 num,通过位运算我们可以直接得出决策位置 i 之前的状态是什么:used&(¬(1<<(num−1))),代表将 used 的二进制表示中的第 num 位置为 0。

一些细节:由于给定的数值范围为 [1,n],但实现上为了方便,我们使用 used 从右往左的第 0 位表示数值 1 选择情况,第 1 位表示数值 2 的选择情况 … 即对选择数值 num 做一个 −1 的偏移。

class Solution {
    public int countArrangement(int n) {
        int mask = 1 << n;
        int[][] dp = new int[n + 1][mask];
        dp[0][0] = 1;
        for(int i = 1; i <= n; i++){
            // 枚举所有的状态
            for(int used = 0; used < mask; used++){
                // 枚举位置 i(最后一位)选的数值是 num
                for(int num = 1; num <= n; num++){
                    // 首先 num 在 used 中必须是 1
                    if(((used >> (num - 1)) & 1) == 1){
                        // 数值 num 和位置 i 之间满足任一整除关系
                        if(i % num == 0 || num % i == 0){
                            // used & (~(1 << (num - 1))) 代表将 used 中数值 num 的位置置零
                            dp[i][used] += dp[i - 1][used & (~(1 << (num - 1)))];
                        }
                    }
                }
            }
        }
        return dp[n][mask - 1];
    }
}

63. 不同路径 II

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为“Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为“Finish”)。

现在考虑网格中有障碍物。那么从左上角到右下角将会有多少条不同的路径?

有了上边两题的基础这题迎刃而解~

class Solution {
    int[][] mat;
    int m,n;
    int[][] dist = new int[][]{{1,0},{0,1}};
    //不记忆化的话会超时,毕竟100²的范围
    int[][] cache;
    public int uniquePathsWithObstacles(int[][] obstacleGrid) {
        mat = obstacleGrid;
        m = mat.length;
        n = mat[0].length;
        cache = new int[m][n];
        for(int i = 0; i < m; i++)Arrays.fill(cache[i],-1);
        return dfs(0,0);
    }
    private int dfs(int x,int y){
        if(x < 0 || x >= m || y < 0 || y >= n || mat[x][y] == 1)return 0;
        if(x == m - 1 && y == n - 1){
            return 1;
        }
        if(cache[x][y] != -1)return cache[x][y];
        int ans = 0;
        for(int[] d : dist){
            ans += dfs(x + d[0],y + d[1]);
        }
        cache[x][y] = ans;
        return ans;
    }
}

动态规划

根据上边的dfs很自然的得到递推公式:dp[i][j] = dp[i][j - 1] + dp[x - 1][j]

class Solution {
    public int uniquePathsWithObstacles(int[][] obstacleGrid) {
        int m = obstacleGrid.length, n = obstacleGrid[0].length;
        int[][] dp = new int[m][n];
        if(obstacleGrid[0][0] == 1)return 0;
        // 初始化
        dp[0][0] = 1;
        for(int i = 1; i < m; i++){
            if(obstacleGrid[i][0] != 1)dp[i][0] = 1;
            else break;
        }
        for(int i = 1; i < n; i++){
            if(obstacleGrid[0][i] != 1)dp[0][i] = 1;
            else break;
        }
        // 遍历
        for(int i = 1; i < m; i++){
            for(int j = 1; j < n; j++){
                if(obstacleGrid[i][j] == 0){
                    dp[i][j] = dp[i][j - 1] + dp[i - 1][j];
                }
            }
        }
        return dp[m - 1][n - 1];
    }
}

   转载规则


《dfs优化套路》 锦泉 采用 知识共享署名 4.0 国际许可协议 进行许可。
  目录