从暴力递归到动态规划小乖,你也在为转移方程而烦恼吗?

Posted 努力努力再努力mlx

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了从暴力递归到动态规划小乖,你也在为转移方程而烦恼吗?相关的知识,希望对你有一定的参考价值。

前引:继上篇我们讲到暴力递归的过程,这一篇blog我们将继续对从暴力递归到动态规划的实现过程,与上篇类似,我们依然采用题目的方式对其转化过程进行论述。

上篇博客:https://blog.csdn.net/m0_65431718/article/details/129604874?spm=1001.2014.3001.5502

一.n皇后问题

八皇后问题是十九世纪著名的数学家高斯于1850年提出的。问题是:在n×n的棋盘上摆放n个皇后,使任意两个皇后都不能处于同一行、同一列或同一斜线上。

我们的解题思路如下:采用暴力递归,既然要求任意两个皇后不能在同一行和同一列和同一斜线,我们依次对这三者进行讨论:①同一行:每一层递归代表一行,我们只要保证在每一层递归中只放置一个皇后即可②同一列:按照题目的要求,我们在访摆放第n层的皇后时,要保证它和前面n-1等皇后都不冲突,这就意味着我们在进行下一层递归的时候仍能有方法访问前面皇后摆放的位置:我们的第一考虑对象当然是数组,但是比较巧妙的是它是一个n*n的棋盘,第一个n代表行,第二个n代表列,我们只需要建立一个长度为n的一维数组,下标代表第几行,下标对应的数组元素代表列,作为参数带入到下一层递归中,斜线也是如此,我们详细展开说说列和斜线的要求:对于列来说,我们要遍历缓存,使前面的缓存和当前列不相等即为不冲突,对于斜线的要求来说,对于一个正方形棋盘,我们其实很容易想到的是直线的斜率为1,也就是说两个元素行的变化如果等于列的变化,我们可以说在同一条斜线上。我们根据思路给出code:

public class NEmpress 
    public static void main(String[] args) 
        //创建date
        int n=4;
        int[]data=new int[n];
        System.out.println(process(data, 0, n));
       

    
    //创建递归过程
    public static int process(int[]data,int i,int n)
        //判断出口条件:共有n个元素,一旦当前行越过了n-1,则说明成功
        if(i==n)
            return 1;
        
        //循环处理每一列
        //如果没结束
        int res=0;
        for(int j=0;j<n;++j)
            //判断当前元素是否有效
            if(isValid(data,i,j))
                data[i]=j;
                //进入下一层递归
               res+= process( data, i+1, n);
            
        
        return res;
    
 private static boolean isValid(int[] data, int i, int j) 
        //在data中检验是否存在
        for(int k=0;k<i;++k)
            //第一个是判断从0到i-1行中列的元素是否相等,第二个是判断是否在同一斜线
            if(data[k]==j||(Math.abs(i-k)==Math.abs(j-data[k])))
                return false;
            
        
        return true;
    

如果不改变问题的实现思路,很难去实现大的效率提升,但是考虑不同的方法仍能在一定程度上提升效率(常数级提升):采用位运算,总体的实现逻辑和之前的暴力递归完全相同,但是就具体细节做出了一定的改动,我们给出递归的核心代码,并改动进行解释说明:

limit:是位数限制,对于行列数为N的棋盘,limit的限制是:对于前N个二进制位数均为1,对于N行列的棋盘而言,前N个二进制位代表棋盘的每一行(第一个二进制位代表第一行,第二个代表第二行........)

①col:对于每次摆放个皇后,就将这个二进制位置变为1,表示这个二进制位不能摆放皇后了

②leftLim:左斜线限制,如果leftLim为1,代表对于当前行来说,这个位置不能摆放皇后了。

③RightLim:右斜线限制,如果RightLim为1,同样对于当前行来说,这个位置不能摆放皇后了。

④limit==col:代表col前N个二进制位都是1,表示N个皇后都已经摆放好了,游戏结束,退出递归

⑤limit&(~(col|leftLim|rightLim)):pos是在每一行中能选择的列

⑥ mostRight=pos&(~pos+1):取出最右边的一位

⑦ pos-=mostRight:将最右边的位置从可选择的位数中去除,使当前行不能放置皇后

⑧while(pos!=0) 循环当前行中能选择的位置

⑨res+= process2(limit,col|mostRight,(leftLim|mostRight)<<1, (rightLim|mostRight)>>>1):循环下一层

public static int process2(int limit,int col,int leftLim,int rightLim)
        //递归出口
    if(limit==col)
        return 1;
    
    //计算能放的位置:
    int pos= limit&(~(col|leftLim|rightLim));
    int mostRight=0;
   int res=0;
    //检验是否能递归
    while(pos!=0)
        //找最右的位置
         mostRight=pos&(~pos+1);
 
        pos-=mostRight;
      res+=  process2(limit,col|mostRight,(leftLim|mostRight)<<1, (rightLim|mostRight)>>>1);
    
    return res;

我们对代码中的几个点进行解释说明:

三.机器人走路问题:(从暴力递归到动态规划的实践)

问题要求如下:

1.假设有排成一行的n个位置记为1-N,N一定大于等于2
2.开始时机器人在m位置上,m一定是1-N中的一个
3.机器人要在规定的步数到达指定的终点,计算到达指定终点的路线有多少条
4.如果机器人来到1位置只能往右来到2位置
5.如果机器人来到N位置只能往左来到N-1位置
6.如果机器人在其他位置,则机器人可以往右也可以往左

对于暴力递归,实现思路就相对比较简单:对于当前位置而言,如果位置是1,他只能选择2,如果在2-N-1的位置,他可以向左和向右走,如果在N位置,只能往N-1的位置走,不断走,直到剩余步数为0,判断是不是要求的位置,然后返回结果。

我们给出关于暴力递归的代码:

public int walking(int N,int cur,int rest,int P)
        //编写递归出口
        if(rest==0)
            if(cur==P)
                return 1;
            else 
                return 0;
            
        
        //排除特殊情况,在0位置处:只能往后走
        if(cur==1)
            return walking(N, cur+1, rest-1, P);
        
        //在最后一个位置,只能往前走
        if(cur==N)
            return walking(N, cur-1, rest-1, P);
        
        //在中间,可以往前往后走
        return walking(N, cur-1, rest-1, P)+walking(N, cur+1, rest-1, P);


        

为什么说这是从暴力递归到动态规划的实践开始呢?我们对此进行解释:

我们能在暴力递归的基础上修改为动态规划,什么是动态规划呢?是将暴力递归中重复计算的过程转化为缓存,从而降低暴力递归中重复计算的次数,转而从相关缓存中获取,是一种典型的空间换时间的策略,对于动态规划而言,其实最难的部分是写出关于动态规划的转移方程。

对这道题来说,这种动态规划的类型是记忆性搜索:如果这个位置有缓存,就直接返回缓存结果,否则递归。

动态规划的的code如下:

public int walkCache(int N,int cur,int rest ,int [][]dp,int P)
        if(dp[cur][rest]!=-1)
            return dp[cur][rest];
        
        if(rest==0)
            dp[cur][rest]=cur==P?1:0;
            return dp[cur][rest];
        
        if(cur==1)
            dp[cur][rest]=walkCache(N, cur+1, rest-1, dp,P);
            return dp[cur][rest];
        
        if(cur==N)
            dp[cur][rest]=walkCache(N, cur-1, rest-1, dp,P);
            return dp[cur][rest];
        
            return dp[cur][rest]=walkCache(N, cur-1, rest-1, dp,P)+walkCache(N, cur+1, rest-1,dp, P);
        
    

四.零和博弈问题:

问题描述:

思路:对于A而言,作为先手,他一定在纵观全局后选择对自己最有利的计划,而B作为后手,只能在A 选择之后在此基础上选择对自己最友好的计划和策略,换句话说,B选择的只能是A选择剩下的

所以我们需要设计两个函数,一个是先手函数,选择其中相对自己而言最优的策略,即为选择自己能选的棋中的最大值,而同样需要设计一个后手函数,它的作用是:在后手参与下选择相对较小的选择(只能选择A选剩下的

我们给出code:

package violencerecursion;

/**
 * @author tongchen
 * @create 2023-03-21 16:09
 */
public class GameWin 
    public static void main(String[] args) 
        GameWin gameWin = new GameWin();
        int[]arr=1,100,1;
        System.out.println(gameWin.win(arr));
    
    public int win(int[]arr)
        //零和博弈问题,解题思路:先手的人拿最优的选择,后手的人只能被迫接收最差的结果
        int left=0;
        int right= arr.length-1;
       return Math.max(f(arr, 0, arr.length-1),s(arr, 0, arr.length-1));
    

    private int f(int[] arr, int left, int right) 
        //递归出口
        if(left==right)
            return arr[left];
        
        //选择已知策略中最优的选择
        return Math.max(arr[left]+s(arr,left+1,right),arr[right]+s(arr,left,right-1));

    

    private int s(int[] arr, int left, int right) 
        if(right==left)
            return 0;
        
        //B相当于从第二个棋子先选择(因为第一个棋子肯定被A选走了,B先手选第二个棋子)
        //但是这种情况下B只能选择在A纵观全局后选择最优策略之后被迫选择劣的选择(即最小值)
        return Math.min(f(arr, left+1, right),f(arr, left, right-1));
    

后续会更新关于动态规划的转移方程的编写思路过程,希望大家能持续关注捏~

面试官问我斐波拉契数列,我从暴力递归讲到动态规划 ...


前言

在系统学习动态规划之前,一直搞不懂「动态规划」和「记忆化搜索」之间的区别。

总觉得动态规划只是单纯的难在于对“状态”的抽象定义和“状态转移方程”的推导,并无具体的规律可循。

本文将助你彻底搞懂动态规划。


演变过程

暴力递归 -> 记忆化搜索 -> 动态规划

其实动态规划也就是这样演练过来的。

可以说几乎所有的「动态规划」都可以通过「暴力递归」转换而来,前提是该问题是一个“无后效性”问题。


无后效性

所谓的“无后效性”是指:当某阶段的状态一旦确定,此后的决策过程和最终结果将不受此前的各种状态所影响。可简单理解为当编写好一个递归函数之后,当可变参数确定之后,结果是唯一确定的。

可能你还是对什么是“无后效性”问题感到难以理解。没关系,我们再举一个更具象的例子,这是 ​​LeetCode 62. Unique Paths​​ :给定一个 m x n 的矩阵,从左上角作为起点,到达右下角共有多少条路径(机器人只能往右或者往下进行移动)。

面试官问我斐波拉契数列,我从暴力递归讲到动态规划

这是一道经典的「动态规划」入门题目,也是一个经典的“无后效性”问题。

它的“无后效性”体现在:当给定了某个状态(一个具体的 m x n 的矩阵和某个起点,如 (1,2)),那么从这个点到达右下角的路径数量就是完全确定的。

而与如何到达这个“状态”无关,与机器人是经过点 (0,2) 到达的 (1,2),还是经过 (1,1) 到达的 (1,2) 无关。

这就是所谓的“无后效性”问题。

当我们尝试使用「动态规划」解决问题的时候,首先要关注该问题是否为一个“无后效性”问题。


1:暴力递归

经常我们面对一个问题,即使我们明确知道了它是一个“无后效性”问题,它可以通过「动态规划」来解决。我们还是觉得难以入手。

这时候我的建议是,先写一个「暴力递归」的版本。

还是以刚刚说到的 ​​LeetCode 62. Unique Paths​​ 举例:

class Solution 
public int uniquePaths(int m, int n)
return recursive(m, n, 0, 0);


private int recursive(int m, int n, int i, int j)
if (i == m - 1 || j == n - 1) return 1;
return recursive(m, n, i + 1, j) + recursive(m, n, i, j + 1);

当我还不知道如何使用「动态规划」求解时,我会设计一个递归函数 ​​recursive()​​ 。

函数传入矩阵信息和机器人当前所在的位置,返回在这个矩阵里,从机器人所在的位置出发,到达右下角有多少条路径。

有了这个递归函数之后,那问题其实就是求解 ​​recursive(m, n, 0, 0)​​:求解从 (0,0) 到右下角的路径数量。

接下来,实现这个函数:

  1. Base case: 由于题目明确了机器人只能往下或者往右两个方向走,所以可以定下来递归方法的 base case 是当已经处于矩阵的最后一行或者最后一列,即只一条路可以走。
  2. 其余情况:机器人既可以往右走也可以往下走,所以对于某一个位置来说,到达右下角的路径数量等于它右边位置到达右下角的路径数量 + 它下方位置到达右下角的路径数量。即 ​​recursive(m, n, i + 1, j) + recursive(m, n, i, j + 1)​​,这两个位置都可以通过递归函数进行求解。

其实到这里,我们已经求解了这个问题了。

但这种做法还有个严重的性能问题。


2:记忆化搜索

如果将我们上述的代码提交到 LeetCode,会得到 timeout 的结果。

可见「暴力递归」的解决方案“很慢”。

我们知道所有递归函数的本质都是“压栈”和“弹栈”。

既然这个过程很慢,我们可以通过将递归版本暴力解法的改为非递归的暴力解法,来解决 timeout 的问题吗?

答案是不行,因为导致 timeout 的原因不在于使用“递归”手段所带来的成本。

而在于在计算过程,我们进行了多次的重复计算。

我们尝试展开递归过程第几步来看看:

面试官问我斐波拉契数列,我从暴力递归讲到动态规划

不难发现,在递归展开过程会遇到很多的重复计算。

随着我们整个递归过程的展开,重复计算的次数会呈倍数增长。

这才是「暴力递归」解决方案“慢”的原因。

既然是重复计算导致的 timeout,我们自然会想到将计算结果进行“缓存”的方案:

class Solution 
private int[][] cache;

public int uniquePaths(int m, int n)
cache = new int[m][n];
for (int i = 0; i < m; i++)
int[] ints = new int[n];
Arrays.fill(ints, -1);
cache[i] = ints;

return recursive(m, n, 0, 0);


private int recursive(int m, int n, int i, int j)
if (i == m - 1 || j == n - 1) return 1;
if (cache[i][j] == -1)
if (cache[i + 1][j] == -1)
cache[i + 1][j] = recursive(m, n, i + 1, j);

if (cache[i][j + 1] == -1)
cache[i][j + 1] = recursive(m, n, i, j + 1);

cache[i][j] = cache[i + 1][j] + cache[i][j + 1];

return cache[i][j];

对「暴力递归」过程中的中间结果进行缓存,确保相同的情况只会被计算一次的做法,称为「记忆化搜索」。

做了这样的改进之后,提交 LeetCode 已经能 AC 并得到一个不错的评级了。

我们再细想一下就会发现,其实整个求解过程,对于每个情况(每个点)的访问次数并没有发生改变。

只是从「以前的每次访问都进行求解」改进为「只有第一次访问才真正求解」。

事实上,我们通过查看 ​​recursive()​​ 方法就可以发现:

当我们求解某一个点 (i, j) 的答案时,其实是依赖于 (i, j + 1)(i + 1, j)

也就是每求解一个点的答案,都需要访问两个点的结果。

这种情况是由于我们采用的是“自顶向下”的解决思路所导致的。

我们无法直观确定哪个点的结果会在什么时候被访问,被访问多少次。

所以我们不得不使用一个与矩阵相同大小的数组,将所有中间结果“缓存”起来。

换句话说,「记忆化搜索」解决的是重复计算的问题,并没有解决结果访问时机和访问次数的不确定问题。


2.1:次优解版本的「记忆化搜索」

关于「记忆化搜索」最后再说一下。

网上有不少博客和资料在编写「记忆化搜索」解决方案时,会编写类似如下的代码:

class Solution 
private int[][] cache;

public int uniquePaths(int m, int n)
cache = new int[m][n];
for (int i = 0; i < m; i++)
int[] ints = new int[n];
Arrays.fill(ints, -1);
cache[i] = ints;

return recursive(m, n, 0, 0);


private int recursive(int m, int n, int i, int j)
if (i == m - 1 || j == n - 1) return 1;
if (cache[i][j] == -1)
cache[i][j] = recursive(m, n, i + 1, j) + recursive(m, n, i, j + 1);

return cache[动态规划详解

关于动态规划

从暴力递归到动态规划

动态规划初步

从暴力递归到动态规划,记忆化搜索

强化学习入门系列二:从贝尔曼方程到动态规划