穷举、DFS、记忆化搜索、动态规划之间的关系以“分汤”为例

808. 分汤

有 A 和 B 两种类型的汤。一开始每种类型的汤有 N 毫升。有四种分配操作:

提供 100ml 的汤A 和 0ml 的汤B。
提供 75ml 的汤A 和 25ml 的汤B。
提供 50ml 的汤A 和 50ml 的汤B。
提供 25ml 的汤A 和 75ml 的汤B。
当我们把汤分配给某人之后,汤就没有了。每个回合,我们将从四种概率同为0.25的操作中进行分配选择。如果汤的剩余量不足以完成某次操作,我们将尽可能分配。当两种类型的汤都分配完时,停止操作。

注意不存在先分配100 ml汤B的操作。

需要返回的值: 汤A先分配完的概率 + 汤A和汤B同时分配完的概率 / 2。

示例:
输入: N = 50
输出: 0.625
解释:
如果我们选择前两个操作,A将首先变为空。对于第三个操作,A和B会同时变为空。对于第四个操作,B将首先变为空。
所以A变为空的总概率加上A和B同时变为空的概率的一半是 0.25 *(1 + 1 + 0.5 + 0)= 0.625。

DFS错误做法:

    public   double[] dfs2(int N){
        double stime=0;
        double afirst=0;
        Queue<int[]> qu=new LinkedList<>();
        qu.add(new int[]{N,N,1});
        while(!qu.isEmpty()){
            int[] tem=qu.peek();
            int a=tem[0];
            int b=tem[1];
            qu.poll();
            for(int i=0;i<action.length;i++){
                int la=a-action[i][0]>=0?a-action[i][0]:0;
                int lb=b-action[i][1]>=0?b-action[i][1]:0;
                int[] tt=new int[]{la,lb};
                if((a==0&&action[i][0]>0)||(b==0&&action[i][1]>0)){
                    continue;
                }
                if(a>0){
                    if(b>0){
                        if(la<=0){
                            if(lb<=0){
                                //同时从有到无分完
                                stime=stime+Math.pow(0.25,tem[2]);//全零不入队列

                            }else{
                                //A先分完,B没分完
                                afirst=afirst+Math.pow(0.25,tem[2]);
                            }
                        }
                    }
                }
                if(la>0&&lb>0){
                    //只有还可分,才进入循环
                    System.out.printf(la+"+"+lb);
                    System.out.println("+++++++++++++++++++");
                    qu.add(new int[]{la,lb,tem[2]+1});
                }
                int bdd=0;
            }
        }
        return(new double[]{stime,afirst});
    }

这是错误做法:
这种做法是将当前状态所可以处理出来的四种状态都无脑的添加到队列里,这种做法实际上是一种穷举,没有对转移路径进行考虑,会导致爆内存:
在这里插入图片描述
因为对于一个状态能只能由四个状态转移过来,相当于一种单向图的方式,完全可以以单向的方式先从大到小进行计算。
记忆化的方法:
由于每一个状态只能由四个状态转移过来,因此直接使用DFS对这四个状态进行计数即可,与上一个做法类似,只不过对于那些计算过的状态,由于其可转移过来的路径有限,可以在第一次计算出来的时候进行计算并且做一个记录。

double tmp[5000][5000];

double dp(int a, int b)
{
    double res = 0;

    if (a <= 0) {
        if (b > 0)
            return 1;
        else
            return 0.5;
    } else if (b <= 0) {
        return 0;
    }
    if (tmp[a][b] > 0)
        return tmp[a][b];

    res = (dp(a - 4, b) + dp(a - 3, b - 1) + dp(a - 2, b - 2) + dp(a - 1, b - 3)) / 4;
    tmp[a][b] = res;
    return res;
}

double soupServings(int N){
    if (N >= 4850)
        return 1;

    N = N / 25 + ((N % 25) > 0 ? 1 : 0);
    return dp(N, N);
}

作者:ming-1226
链接:https://leetcode-cn.com/problems/soup-servings/solution/dong-tai-gui-hua-di-gui-by-ming-1226/
来源:力扣(LeetCode)

这种方法就是对第一种方法进行了记录,简洁但是程序递归栈的深度很深,占用资源。
傻动态规划(正向):

    public static double soupServings(int N){
        if(N>9990){
            return 1.0;
        }
        int a=N%25==0?N/25:N/25+1;
        int[][] act=new int[][]{{4,0},{3,1},{2,2},{1,3}};
        double[][] dp=new double[a+1][a+1];
        //堆溢出了,不靠谱,必须想办法,实际上保留四排循环利用即可,不需要向上回溯那么多
        dp[a][a]=1;//啥都不要的概率为1,最大,代表啥都不要
        //那些不好考虑的情况,比如尽可能分配,就可以使用初始条件把他们初始化了,
        //或者,提前考虑清除,在最末端进行考虑,一说什么尽可能分配就是最末端的情况,把整个问题对偶过来
        for(int i=a;i>=0;i--){
            for(int j=a;j>=0;j--){
                //如果有0代表需要从别的地方转移过来,需要对满足条件的情况都进行计算
                if(i==0||j==0){
                    if(i==0&&j==0){
                        //全都等于0
                        //需要对两维都进行统计
                        for(int t=0;t<=3;t++){
                            for(int x=i;x<=i+act[t][0]&&x<=a;x++){
                                for(int y=j;y<=j+act[t][1]&&y<=a;y++){
                                    if((x==0&&x==i)||(y==j&&y==0)){
                                        continue;
                                    }
                                    //比这小的元素都可以减过来,相当于一种概率转移路径
                                    dp[i][j]=dp[i][j]+0.25*dp[x][y];
                                }
                            }
                        }
                    }else if(i==0){
                        //i==0,只需要统计需要的那一位
                        for(int t=0;t<=3;t++){
                            for(int x=i+1;x<=i+act[t][0]&&j+act[t][1]<=a&&x<=a;x++){
                                    //比这小的元素都可以减过来,相当于一种概率转移路径
                                    dp[i][j]=dp[i][j]+0.25*dp[x][j+act[t][1]];

                            }
                        }
                    }else{
                        for(int t=0;t<=3;t++){
                            for(int y=j+1;y<=j+act[t][1]&&i+act[t][0]<=a&&y<=a;y++){
                                    //比这小的元素都可以减过来,相当于一种概率转移路径
                                    dp[i][j]=dp[i][j]+0.25*dp[i+act[t][0]][y];
                            }
                        }
                        //j==0
                    }
                }else{
                    for(int t=0;t<=3;t++){
                        if(i+act[t][0]<=a&&j+act[t][1]<=a){
                        //什么时候需要特殊转移状态,剪完之后有一个为0的时候(不够减才为特殊,减后还有剩余则不可有特殊)
                            dp[i][j] = dp[i][j] + 0.25 * dp[i+act[t][0]][j+act[t][1]];
                    }}
                }
            }
        }
        double afirst=0;
        double stime=dp[0][0];
        for(int i=1;i<=a;i++){
            afirst=afirst+dp[0][i];
        }
        return(afirst+0.5*stime);
    }

这是我自己的做法,这种做法中dp[i][j]的意思可以理解为从初始状态转移到dp[i][j]时的概率:

dp[a][a]=1 //初始状态啥都不选当然为全概率1
dp[i][j] = dp[i][j] + 0.25 * dp[i+act[t][0]][j+act[t][1]]  //对几个可能到达的概率进行单向的收割

这种做法,需要在最终对于dp[0][i]、d[0][0]的概率进行收割,但是缺点在于对于那些特殊的情况,比如有一位为0(分完的情况)需要进行
上文代码的优化思路:

  • 由于操作只有四个,而对于所有的逻辑分支中,都有对于操作的for循环,因此,可以将这些这个共性的操作循环提出来,将其放在最外层。
  • 造成循环复杂的一个原因是因为采取了顺向的方法,对于每一个分支情况都进行了复杂的计算,但是实际上分支的原因也只是对于这些情况进行分类,不同情况之间并没有直接耦合,因此完全可以像官方题解中那样,将判断深入到操作当中而不用完全的将操作分开,但是由于本方法的固有缺陷,客观上需要对“减不尽”的情况进行归并,不容易将这直接分开。
  • 综上所述,可以将四种操作的循环提出来,然后将划分方法也提出来写成单独的函数,进行计算。

逆向动态规划:

class Solution {
    public double soupServings(int N) {
        N = N/25 + (N%25 > 0 ? 1 : 0);
        if (N >= 500) return 1.0;

        double[][] memo = new double[N+1][N+1];
        for (int s = 0; s <= 2*N; ++s) {
            for (int i = 0; i <= N; ++i) {
                int j = s-i;
                if (j < 0 || j > N) continue;
                double ans = 0.0;
                if (i == 0) ans = 1.0;
                if (i == 0 && j == 0) ans = 0.5;
                if (i > 0 && j > 0) {
                    ans = 0.25 * (memo[M(i-4)][j] + memo[M(i-3)][M(j-1)] +
                                  memo[M(i-2)][M(j-2)] + memo[M(i-1)][M(j-3)]);
                }
                memo[i][j] = ans;

            }
        }
        return memo[N][N];
    }

    public int M(int x) { return Math.max(0, x); }
}

作者:LeetCode
链接:https://leetcode-cn.com/problems/soup-servings/solution/fen-tang-by-leetcode/

官方解法,这种解法的好处在于逆向思考,将我们需要的情况的概率(A先分完或同时分完置为1或0.5)的初始概率,这种办法的好处在于从根源处进行考虑,通过初始化就对所需情况进行计算。
思考:

  • DFS的方法是类似于一种穷举的方法,这种方法理论上一定能得到正确答案,但是由于计算资源的限制,并不具备可实现性,这种办法实际上是一种图的解决办法,因为所有的问题都可抽象到状态转移,而所有的分立状态都是图中的点。
  • 记忆化搜索实际上是一种将需要记录的状态进行记录以减少搜索次数的方式,但是这种方式本身要求状态是可以被抽象与记录的。
  • 动态规划实质上是一种可以在单向中写完的记忆化,必须具有一维或二维的方向性,也因此减少了空间复杂度。

例1

1654. 到家的最少跳跃次数
题目分析

  • 这一题由于可以向前跳与向后跳,因此,不能使用动态规划的方法(不具备一维性
  • 由于可以反复横跳,因此,直接使用DFS会导致递归栈爆炸,不可以使用DFS的方法
  • 综上所述,本题需要使用记忆化的方法,但需要考虑清楚需要把什么作为记忆化的存根,由于在本题中,每一个位置都有两种选项:向前跳、向后跳。因此,不光要对每一个位置进行记忆化,还需要对是否可以向后跳进行记忆化。
    public static int minimumJumps(int[] forbidden, int a, int b, int x) {
        //使用递归的方法完成
        //如何避免跳蚤跳到太远的位置???,有了隐含条件不能联系倒退两次
        //x+a>b则永远无法回来了,停止
        Queue<int[]> qu=new LinkedList<>();
        Set<String> set=new HashSet<>();
        for(int i=0;i<forbidden.length;i++){
            set.add(String.valueOf(forbidden[i])+"+"+"0");
            set.add(String.valueOf(forbidden[i])+"+"+"1");
        }
        qu.add(new int[]{0,0,0});
        //位置,是否能后跳,步数
        set.add(String.valueOf(0)+"+"+"0");
        set.add(String.valueOf(0)+"+"+"1");

        //第二个位置是0代表可以再往后跳一次
        while(!qu.isEmpty()){
            int[] tem=qu.poll();
            if(tem[0]==x){
                return(tem[2]);
            }
            if(tem[1]==0){
                //向后跳的情况
                if(tem[0]-b>=0&&!set.contains((tem[0] - b) +"+"+"1")){
                    set.add((tem[0] - b) +"+"+"1");
                    qu.add(new int[]{tem[0]-b,1,tem[2]+1});
                }
            }
            if(!set.contains((tem[0] + a) +"+"+"0")&&!(tem[0]+a>6000)){
                set.add((tem[0] + a) +"+"+"0");
                qu.add(new int[]{tem[0]+a,0,tem[2]+1});
                //本次向前跳了,下次可以向后跳了;
            }
        }
        return(-1);
    }

例2

挑战程序设计竞赛p191旅行商问题
这是一道经典的NP-hard问题,一般情况下,无法在O(N^K)的时间复杂度下将其解决,但是本题题解中仍采用了记忆化的方式进行优化:

  • 这种记忆化的方式是非常简陋的,实质上就相当于对于可能的路径情况进行记录:耗费的存储资源为(2^n)
  • 这实际上是一种变相的穷举法,需要将所有已知内容进行全部登记才可以运行,因此最大数据量也仅仅为15.
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值