数据结构与算法---动态规划学习之一

数据结构与算法—动态规划学习之一

1. 什么是动态规划?

动态规划是求解**“最优解”**的一种数学方法。通常应用于一类具有如下特征的问题:

  • 虽然是大问题,但是可以拆解为小问题
  • 小问题同样存在更小的问题
  • 有一些**“原子”问题**,即那些不可再分的问题,通常是动态规划中的**“边界条件”**

当你看到一些满足以上要求的题目之后,那么你可以考虑使用动态规划的方法去解这道题。算法题与数学题的区别之一,就是一个公式、解题思路并不一定可以解决所有该类型的题目,更需要做出:
“适当的调整”
这是除了动态规划外,另一个应该想到的重点。

2. 如何求解动态规划

根据该博客的描述,可以采用**“五个步骤”**尝试解题。

  • 分析题目,题目是否要求解**“最优解”**
  • 自顶向下的分析,问题是否可以拆解,子问题是否可以继续拆解…
  • 自下向上的分析,总结递推的规律,找出状态转移方程。
  • 边界问题,下边界、上边界的界定。
  • 通常用dp[Input+1]数组,存储递推结果。其中Input为输入规模。
    我的数学功底较差,上面的描述仅仅根据个人理解,详细的可以参考开头的链接。

3. 例举问题

不如从几个简单的动态规划题目来逐步理解这些抽象的概念吧!

问题1:

青蛙跳台阶问题:一只青蛙一次可以跳上1级台阶,也可以跳上2级台阶。求该青蛙跳上一个n级台阶总共有多少种跳法。

  • 1.根据题意,判断是否有求最优解的意味
    遇到问题了,但从题目是看不出来有“最优解”的感觉,那么是否和动态规划无关呢?继续往下看。
  • 2.自上向下分析问题,找到大问题的子问题
    例如,跳6级台阶,最后一跳,要么是从4级一下跳到6级(高度:2)
    要么是从5级一下跳到6级(高度:1),所以问题可以看作是:
    青蛙跳到了6级,请问有多少种跳法 = 青蛙跳到了5级,请问有多少种跳法? + 青蛙跳到了4级,请问有多少种跳法?
Jump(6) = Jump(5) + Jump(4)
所以问题可以化作:Jump(n) = Jump(n-1) + Jump(n-2)
  • 3.自下向上分析问题,找到问题之间的关联
    那么由问题可以简单的归纳出:
    Jump(0) ->无意义
    Jump(1) = 1 一次跳一级
    Jump(2) = 2 一次跳一级 + 一次跳两级
    根据2可知:
    Jump(3) = Jump(1) + Jump(2)
    则:Jump(3) = 1 + 2 = 3;
    那么问题就可以进行迭代了,我们可以从边界迭代到我们想要的答案。
public class DynamicProgramming {
    public static int Jump(int N){
        //动态规划的数组大小,因为0不用,所以要到N+1大小
        int dp[] = new int[N+1];
        //初始化边界
        dp[0] = -1; //无意义的跳跃
        dp[1] = 1;  //1级台阶只用跳1次
        dp[2] = 2;  //2级台阶可以 1+1 也可 2 一步跳上去
        //根据分析,N=3的时候就可以迭代了
        if(N >= 3){
            for (int i = 3; i <= N; i++) {
                dp[i] = dp[i-1] + dp[i-2];
            }
        }
        //返回对应的目标
        return dp[N];
    }
    public static void main(String[] args) {
        System.out.println("青蛙要跳 6 级,所以一共有:");
        System.out.println(Jump(6) + "种跳法");
    }
}
问题2:

给你一根长度为n的绳子,请把绳子剪成m段 (m和n都是整数,n>=1并且m>1),每段绳子的长度记为k[0],k[1],…,k[m],请问k[0]k[1]…*k[m]可能的最大乘积是多少?

例如,当绳子的长度为8时,我们把它剪成长度分别为2,3,3的三段,此时得到的最大乘积是18.

  • 1.分析题目,是否有最优解的感觉?
    有的,求出剪m次的情况下,最大的乘积
  • 2.自顶向下分析问题
    例如长度为n = 8,m = 2可以剪为{1,7},{2,6},{3,5},{4,4}
    只要直到 Max{[f(1) * f(7)],[f(2) * f(6)],[f(3) * f(5)],[f(4) * f(4)]}就可
    因为f(1)已知, f(1) = 1;
    f(7)可以继续分为{1,6},{2,5},{3,4}
  • 3.自下向上分析问题
    f(1) = 1 不能剪,f(1) = 1;
    f(2) = 2 剪一次,{1,1} f(1) * f(1) = 1; 比自身小
    那么"没必要"为求乘积大小而减掉它
    f(3) = 3,m=1时 {1,2} f(1) * f(2) = 1 * 2 = 2;
    m=2时 {1,1,1} f(1)*f(1)*f(1) = 1;都比自身小
    “没必要”
    f(4) = 4, 最合适剪一次,{2,2} f(2) * f(2) = 4 {2,2}是在{{1,3},{2,3}}中找到
    f(5) = 6, 最合适剪一次,{2,3} f(2) * f(3) = 6 {2,3}是在{{1,4},{2,3}}中找到

    注意,以上都是不同长度下,经过剪刀函数f的作用下,得到的最优解
    看似,f(8)要拆分为{1,7},{2,6},{3,5},{4,4},只剪了一次,其实实质上应该这样理解:
    首先,这种拆分得到的组,最大不会超过8,也就是1-7,经过上面的迭代,我们已经得到了所有1-7的最优的解即:
    f(1) - f(7)
    从中找Max{[f(1) * f(7)],[f(2) * f(6)],[f(3) * f(5)],[f(4) * f(4)]},
    意思是找一组最优的分组策略,例如f(3)*f(5)就是最优的,代表的是 3 * 2 * 3,f(3)没必要剪,f(5)代表之前求出的最优:2 * 3.
    和题目完全吻合。
public class DynamicProgramming2 {
    public static int cutting(int m){
        //无意义的长度
        if(m < 0)
            return -1;
        //0不用
        int dp[] = new int[m+1];
        //存储迭代最大值
        int max = 0;
        dp[0] = -1; //无意义的长度
        dp[1] = 1;  //f(1) = 1 不能剪,f(1) = 1;
        dp[2] = 2;  //f(2) = 2 没必要剪
        dp[3] = 3;  //f(3) = 3 没必要剪
        //从4开始迭代
        //迭代公式:f(n) = max{f(1)*f(n-1),f(2)*f(n-2)...,f(n/2)*f(n-n/2)}
        for (int i = 4; i <= m; i++) {
            for (int j = 1; j <= m/2; j++) {
                int val = dp[j] * dp[i-j];
                max = max > val ? max : val;    //最优解要求我们求解最大值,所以这里要求出最大值迭代
            }
            dp[i] = max;    //记录已经求出的结果
        }
        return dp[m];
    }
    public static void main(String[] args) {
        System.out.println(cutting(8));
    }
}
问题3:

你将会获得一系列视频片段,这些片段来自于一项持续时长
为 T 秒的体育赛事。这些片段可能有所重叠,也可能长度不一。
视频片段 clips[i] 都用区间进行表示:开始于 clips[i][0] 并于 clips[i][1] 结束。
我们甚至可以对这些片段自由地再剪辑,例如片段 [0, 7]
可以剪切成 [0, 1] + [1, 3] + [3, 7] 三部分。
我们需要将这些片段进行再剪辑,
并将剪辑后的内容拼接成覆盖整个运动过程的片段([0, T])。
返回所需片段的最小数目,如果无法完成该任务,则返回 -1 。

示例 1:

  • 输入:clips = [[0,2],[4,6],[8,10],[1,9],[1,5],[5,9]], T = 10
  • 输出:3
  • 解释:
    我们选中 [0,2], [8,10], [1,9] 这三个片段。
    现在我们手上有 [0,2] + [2,8] + [8,10],而这些涵盖了整场比赛 [0, 10]。

示例 2:

  • 输入:clips = [[0,1],[1,2]], T = 5
  • 输出:-1
  • 解释:
    我们无法只用 [0,1] 和 [1,2] 覆盖 [0,5] 的整个过程。

示例 3:

  • 输入:clips = [[0,1],[6,8],[0,2],[5,6],[0,4],[0,3],[6,7],[1,3],[4,7],[1,4],[2,5],[2,6],[3,4],[4,5],[5,7],[6,9]], T = 9
  • 输出:3
  • 解释:
    我们选取片段 [0,4], [4,7] 和 [6,9] .

示例 4:

  • 输入:clips = [[0,4],[2,8]], T = 5
  • 输出:2
  • 解释:[0,4],[2,8]覆盖了整个片段

注意:你有可能录制超过比赛长度的视频。

  • 分析题目

    本题确实存在最优解的思想。

  • 自顶向下分析问题

    我们从具体的角度来思考这个问题,首先如果输入T = 10,那么我们就需要[0,10]这个区间被全部覆盖。另外一点很重要,本题目的所有输入,决定了题目的输出,也就是不同用于那些通过边界条件递推到目标的问题,它们有着固定的答案,但是这道题的答案取决于我们的输入,我们需要从输入中去寻找最优的解。所以我们假设输入和示例一 一样:clips = [[0,2],[4,6],[8,10],[1,9],[1,5],[5,9]]。
    很简单的就可以想到的是,如果要覆盖整个视频长度,我们需要从0开始,超过10结束。[0,2]与[8,10]就是必选的片段。
    题目隐含了一个条件是:**输入T总是位于一个区间(ai,bj],这样一个左开右闭区间。**本题的10就位于区间[8,10],所以ai = 8,bj = 10,那么当我们选择了这个片段,**就证明要求的[0,10]已经被覆盖了后半部分,也就是[8,10]这部分。那么现在的问题是不是变成了:[0,8]的最优覆盖问题?**也就是从求解F(10)变成了求解F(8)。也就是求解F(ai)显然,问题是可以拆分的。
    继续分析,由上面的叙述可以知道,现在T = 8,根据我们的输入,8位于区间(1,9],也位于区间(5,9],此时面临选择,那么依据是什么呢?
    到这一步,你会发现选择的过程没完没了,因为我们在这里比较了F(1)与F(5)之后,又有可能要继续细分问题往下比…还记得动态规划的思想吗?可以从边界迭代到目标,所以当我们输入了10,我们应该让程序帮我们迭代算出F(1) - F(9),然后再计算F(10)的时候,就可以利用之前的值了。
    透过上面的分析我们可以总结出状态转移方程:
    在这里插入图片描述
    +1的意思是,当该区间满足要求,那么就要在F(ai)的基础上,算上这个区间。

  • 自下向上的分析
    clips = [[0,2],[4,6],[8,10],[1,9],[1,5],[5,9]]
    T = 0,无意义,所以F(0) = 0;
    T = 1,它位于区间[0,2],所以F(1) = min{F(0) + 1} = 1
    T = 2,它位于区间[0,2],[1,9],[1,5],所以F(2) = min{F(0) + 1,F(1)+1,F(1)+1} = 1
    T = 3, …

public class LeetCode31 {
    public int videoStitching(int[][] clips, int T) {
        //无意义的输入
        if(T < 0)
            return -1;
        //动态规划
        int[] dp = new int[T + 1];
        //首先给所有的dp赋予一个极大值,这是用于迭代到最小值的初始化
        Arrays.fill(dp,Integer.MAX_VALUE - 1);
        dp[0] = 0;  //输入0,缺省为0
        //从1开始迭代
        for (int i = 1; i < dp.length; i++) {
            //遍历二维数组,也就是输入的视频片段
            for(int[] clip : clips){
                //如果所给的输入T位于(ai,bi]之间,那么dp[T] = min(dp[ai]) + 1
                if(i > clip[0] && i <= clip[1]){
                    dp[i] = Math.min(dp[i],((dp[clip[0]])+1));
                }
            }
        }
        return dp[T] == (Integer.MAX_VALUE - 1) ? -1 : dp[T];
    }
}
问题4:

在这里插入图片描述

  • 分析题目

首先这道题提供了一个递归函数,通过简单的语法就可以实现。但是对于W(15,15,15)这样的输入,递归的次数将十分的吓人。经过测试,我们发现一个比较小的输入W(5,5,5)都有近1500次的递归调用。那么这道题与动态规划的关系是什么呢?根据前面几道题的洗礼,我们知道了动态规划一般和递推脱不了干系,那么递归一定程度上,是可以转化为递归的。那么,对于某些输入,递归调用次数过多的原因在什么地方呢?**答案是:重复计算。**所以我们需要记忆化搜索,也就是把重复的答案记下来,下一次直接用。这就十分类似动态规划了。

  • 自顶向下分析问题

因为递推公式已经被题目给出,那么就没必要总结规律。我们可以通过条件,确定边界是W(0,0,0) - W(20,20,20),用三维立体的思想来考虑,就是存在一定体积的输入是变化的,超过这个体积,如果是负空间内,则返回值就是1。超过X,Y,Z其中一个坐标轴到达20以上的,就返回W(20,20,20)。
另外,这一题也有不同于其他问题的地方。比如,对于DP数组的初始化,是通过一次一系列计算来得到的。由题可知,对于W(15,15,15),它不仅要算W(14,15,15),还要算W(15,14,15)等等…,但是在计算W(14,15,15)这个函数的时候,就有可能把W(15,14,15)所需要的某些值给算出来。所以,通过一次一系列的递归计算,就可以初始化完成DP数组,那么其他地方对于某些值的重复调用只需要查找数组就可以,相当节省时间。

  • 自下向上分析问题

边界条件的确定已经略做了分析。考虑一些超出范围的处理方式就可以。

  • 注意

这一题给了我们一个新的思路,对于某些非常难遍历的多维数组,似乎可以采用递归函数的方式来初始化。

public class LeetCode32 {
    public static int dpW[][][] = new int[21][21][21];
    public static int w(int a,int b,int c){
        if(a <= 0 || b <= 0 || c <= 0){
            return 1;
        }else if (a > 20 || b > 20 || c > 20){
           return dpW[20][20][20] = w(20,20,20);
        }
        else if (dpW[a][b][c] != 0){
            return dpW[a][b][c];
        }
        else if (a < b & b < c){
            dpW[a][b][c] = w(a,b,c-1) + w(a,b-1,c-1) - w(a,b-1,c);
        }else{
            dpW[a][b][c] = w(a-1,b,c)+w(a-1,b-1,c)+w(a-1,b,c-1)-w(a-1,b-1,c-1);
        }
        return dpW[a][b][c];
    }
    public static void main(String[] args) {
        System.out.println(w(1,1,1));
    }
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值