算法笔记:动态规划(1)

何时能够使用动态规划

       动态规划(Dynamic Programming, DP)与其说是一种算法,更准确地说是一种解决问题的思维方式,因为其并没有对所有相关问题抽象出一种通用的算法程序,而是要在解题时根据具体的问题运用动态规划的思想进行问题的建模并编码求解。因此在理解动态规划解题之前,首先要了解什么样的问题能够用动态规划的思想解决。

        本质上来说,动态规划是一种更高效的递归算法的实现,所以在学习动态规划之前要对递归算法有比较深刻的理解。

在用朴素递归解决问题时,首先将目标问题分解成多个子问题,而在解决这些子问题时又被分解成更小的子问题……如此重复,直到分解到递归的边界为止。在分解过程中,同一个子问题可能会被多次重复解决。

        动态规划的思想就是,每解决一个子问题,都将该子问题的结果保存起来,则每个子问题只需要被解决一次。那么,哪些递归算法可以用动态规划的思想去实现呢?最简单的方法就是看朴素递归的算法有没有将同一个子问题一次次重复计算(可以画一个简单的递归树,能够很直观地发现,如果同一个子问题在树中不断出现,并且不止一次地出现在非叶节点上,那么就可以判断该子问题被重复计算了)。

        一般来说,动态规划是解决组合对象上优化问题的方法,这些对象的组成具有固定的从左到右的顺序。这类对象包括字符串、根树(rooted trees)、多边形和整数列等。 [1](这段话我也没太看明白,不知道是不是对原文有什么误解。不过既然是在书上看到的,先摘抄下来。)

        如果要给动态规划的适用情况做更严谨的定义,那么具有以下性质的问题能够用动态规划的思想解决:[2]

  • 最优子结构性质。如果问题的最优解所包含的子问题的解也是最优的,我们就称该问题具有最优子结构性质(即满足最优化原理)。最优子结构性质为动态规划算法解决问题提供了重要线索。
  • 无后效性。即子问题的解一旦确定,就不再改变,不受在这之后、包含它的更大的问题的求解决策影响。
  • 子问题重叠性质。子问题重叠性质是指在用递归算法自顶向下对问题进行求解时,每次产生的子问题并不总是新问题,有些子问题会被重复计算多次。动态规划算法正是利用了这种子问题的重叠性质,对每一个子问题只计算一次,然后将其计算结果保存在一个表格中,当再次需要计算已经计算过的子问题时,只是在表格中简单地查看一下结果,从而获得较高的效率。

解题基本思路

  1. 确定状态。前文提到,对于每一个子问题都只计算一次,然后将其计算结果保存在一个表格中;对动态规划进行问题建模的第一步也是最困难的一步就是设计这个表格,实现时一般用一个整数数组dp[](进阶题也可能是一个二维数组)。dp[i]就代表着第i个子问题的状态,难点就在于这个dp[i]到底代表什么。一般情况下,问题问什么,dp[i]就代表什么,即迭代完成后的dp[N]就是问题的最终解,这种情况下dp[i]实际上就是子问题i的最优解。但是也有一些难题,迭代完成后的dp[N]需要进行进一步的计算才是最终问题的解,此时dp[i]就只是能导出子问题i最优解的状态值。因此dp[]数组称为状态数组更为合理。
  2. 确定边界值(初始状态)。由于动态规划解决的问题都是一些递归问题,那么也有递归问题中的边界值的问题。通常情况下是第一个或者最前面的两个子问题的最优解。
  3. 确定状态转移方程。即找出dp[i]与dp[i-1],dp[i-2]…的关系。
  4. 优化:如果dp[i]只与固定的前k项有关,那么对算法的空间复杂度进行优化,即只保存前k个状态,而无需保存完整的dp[]数组。

例题

斐波那契数列

基本的动态规划实现

        斐波那契数列是经典的递归问题,递归式为F(n)=F(n1)+F(n2)F(n) = F(n-1)+F(n-2),计算斐波那契数列的第nn项的朴素递归实现代码如下:

int fib_r(int n) {
    if(n == 0) return 0;
    if(n == 1) return 1;
    
    return fib_r(n-1) + fib_r(n-2);
}

在这种实现下,画出计算F(6)的递归树如下:

f6

可以看出其中F(2)F(2)F(3)F(3)F(4)F(4)等都计算了多次,是符合动态规划应用条件的,因此可以按照动态规划的解题思路:

  1. 确定状态:创建状态数组dp[n+1],其中dp[i]就表示斐波那契数列中的第ii
  2. 边界值:在斐波那契数列的定义中已经直接给出了边界值,dp[0] = 0, dp[1] = 1
  3. 状态转移方程:F(n)=F(n1)+F(n2)F(n) = F(n-1)+F(n-2)

据此就可以得到动态规划版本的实现:

int fib_dp(int n) {
    if(n == 0) return 0;
    if(n == 1) return 1;
    int[] dp = new int[n+1];
    dp[0] = 0;
    dp[1] = 1;
    for(int i = 2;i <= n; i++) {
        dp[i] = dp[i-1] + dp[i-2];
    }
    return dp[n];
}

动态规划版本的实现的时间复杂度为O(N)O(N),空间复杂度也为O(N)O(N)。与朴素递归相比都有所优化。

优化

       在迭代过程中只需要保留前两位便可,算法的空间复杂度进一步优化到O(1)O(1)

int fib_bp_opt(int n) {
    if(n == 0) return 0;
    if(n == 1) return 1;
    int prev = 0, cur = 1, tmp;
    for(int i = 2; i <= n; i++) {
        tmp = cur;
        cur = cur + prev;
        prev = tmp;
    }
    return cur;
}

最大子序和

基本的动态规划实现

       (LeetCode.53)给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。

示例:

输入: [-2,1,-3,4,-1,2,1,-5,4],
输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。

  1. 确定状态:dp[i]代表以第i个数结尾的最大子序列和。
  2. 确定边界值:dp[0] = nums[0]
  3. 状态转移方程:dp[i] = (dp[i-1]>0)?(dp[i-1] + nums[i]):(nums[i])

其中dp数组的含义和状态转移方程都有一些难度,并且dp[n]也不是最终结果,需要在迭代的过程中记录最大值。问题建模完成后,就有如下实现:

int max_subarray(int[] nums) {
    int[] dp = new int[nums.length];
    int max = nums[0];

    dp[0] = nums[0];
    
    for(int i = 1; i < nums.length; i++) {
        dp[i] = (dp[i-1] > 0)?(dp[i-1] + nums[i]):(nums[i]);
        max = Math.max(dp[i], max);
    }
    
    return max;
}

优化

       从状态转移方程可以发现,dp[i]的计算只与dp[i-1]有关,因此可以优化空间复杂度到O(1)O(1)

int max_subarray_opt(int[] nums) {
    // 只用一个pre变量保存dp[i-1]
    int pre = nums[0], max = nums[0];

    for(int i = 1; i < nums.length; i++) {
        pre = (pre > 0)?(pre + nums[i]):(nums[i]);
        max = Math.max(pre, max);
    }

    return max;
}

最终经过优化的算法的时间复杂度为O(N)O(N),空间复杂度为O(1)O(1)

PS: 数据结构课程的1.3节对本题的各种解法有详细讲解。

参考

  1. Skiena S S. The Algorithm Design Manual,(2008)[J]. URl: http://dx.doi.org/10.1007/978-1-84800-070-4.
  2. 维基百科(404警告)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章