动态规划Dynamic Programming的总结

来源:互联网 发布:淘宝加盟诈骗案进展 编辑:程序博客网 时间:2024/05/24 07:26

最近找工作需要些计算机算法,所以把最常见的动态规划方法整理一下,从浅到深的进行一下分析。

一、动态规划的原理

1、动态规划的基本思路

许多问题在实现的过程中都是把原问题分解为子问题进行处理,再把子问题相互结合形成原问题的解,首先最常用的分治算法就是把原问题分解为不相交的子问题,与之相反动态规划是用于把原问题分解为具有重叠子问题的一种方法。所谓的重叠子问题是指如果原问题的子问题分别为s11和s12(表示第一层子问题的问题1和问题2),在求解s11和s12的时候可能其子问题s111和s121是同一个问题,这也就是重叠子问题。在这时如果用分治算法处理,会把s11和s12当成独立的问题处理,求解了s111和s121,但实际上只要求一次就可以在另一个子问题上也应用这个结果。

2、动态规划的使用场景

动态规划通常用来求解最优化问题,希望在许多结果中找最优的那一个(在做题的时候,通常对应关键字:最优、最大、最小、最长和计数问题),而且都是处理离散状态时使用。除了这些,动态规划问题最主要的两个要素就是:最优子结构和重叠子问题。

最优子结构指的是,子问题的最优决策可以导出原问题的最优决策,原问题的最优决策必定包含子问题的最优决策。这一点是可以证明的:假设子问题的解不是其最优解,那么我们就可以在原问题中将这个解替换为最优解,从而得到原问题的一个更优解,这与假设初始解为原问题的最优解相矛盾。

对于重叠子问题,就像在基本思路中说的一样,动态规划对于每个子问题之求解一次。

更多关于最优子结构和重叠子问题在看了后面的例子就会清晰了。

二、例子

在看例子之前,一般的动态规划算法都遵循一种思路:

(1)首先观察如果用暴力的方法那么冗余部分是什么地方。

(2)定义一个最优解的子结构,也就是原问题是如何由子问题实现的(也就是状态转移方程)

(3)自底向上的求解最优解,并存储每一个状态(一般用一维数组,二维数组等)

(4)对于一些问题可能会记录一些额外信息

——————————————————————————————————————————————————————————————————————-————

例1:最简单的例子就是求解斐波那契数列,对于递归的方法我们都很熟悉:F(n) = F(n-1)+F(n-2),首先我们先观察冗余的地方在哪,是我们在求解F(n-1)的时候F(n-2)其实已经求解过了这就是冗余所在,接着定义最优子结构(实际上递归方程就是状态转移方程),接着自底向上求解(从n =2 开始用数组记录每一个F(n),在需要的时候直接在数组中调用就可以)。

伪代码如下:

vector F[n]F[1] = 1;F[2] = 1;for i = 3->n    F[i] = F[i -1] + F[i - 2];return F [n]

例2、假设你是一个专业的窃贼,准备沿着一条街打劫房屋。每个房子都存放着特定金额的钱。你面临的唯一约束条件是:相邻的房子装着相互联系的防盗系统,且当相邻的两个房子同一天被打劫时,该系统会自动报警

给定一个非负整数列表,表示每个房子中存放的钱, 算一算,如果今晚去打劫,你最多可以得到多少钱 在不触动报警装置的情况下。(lintcode.392)

这个问题是一个计数问题。首先分析一下这个问题看看如果用暴力的方法处理冗余在哪,如果遍历所有的可能情况对于每一个房屋都计算了很多次初始情况下的问题。接着定义最优子结构,对于每一个房屋都有两种方案,偷或是不偷,如果要偷就必定从房子的前两个房子偷过来,如果不偷就跳过这个房子。不偷的情况是在如果偷前一个房子所得的总金钱大于偷这个房子的总金钱时,就改变路径跳过当前的房子。状态转移方程为max(stl[i-2]+a[i], stl[i-1]),stl[i]为路过第i个房屋时可以得到的最大金钱,a[i]为第个房子的金钱。接着自底向上求解,保存路径上的最大金钱。

代码为:

class Solution {public:    /**     * @param A: An array of non-negative integers.     * return: The maximum amount of money you can rob tonight     */    long long houseRobber(vector<int> A) {        // write your code here        vector<long> s;        int len = A.size();        s.push_back(0);        s.push_back(A[0]);        for(int i = 1; i <len; i++){            long max = ((A[i] + s[i-1]) > s [i])? (A[i] + s[i-1]):s[i];            s.push_back(max);        }        return s[len];    }};


注意:这里的s比A的长度多1,记录最初的金钱为0;在看代码的时候S[i]实际上为路过第i-1个房子的情况。

例3、最经典的01背包问题

给出n个物品的体积A[i]和其价值V[i],将他们装入一个大小为m的背包,最多能装入的总价值有多大?

对于物品体积[2, 3, 5, 7]和对应的价值[1, 5, 2, 4], 假设背包大小为10的话,最大能够装入的价值为9。

(lintcode.125)

分析:这个问题本质上也是一个计数问题,首先我们也按照前面的流程进行分析,对于每一个背包i都只有装和不装两种状态,如果装就要在剩余的背包体积中装尽量多价值的东西,如果不装同样要在这些体积中装尽量多价值的东西,只不过不包含当前这件物品,在这两种结果中找价值最高的那个。这本质上就是最优子问题的形式,其状态转移方程为f(i,y) = max(f(i-1,y),f(i-1,y-ai)+vi) //y > ai,否则后面一项为0因为剩余空间不足以加入a1//, 也就是如果现有的空间足够加入体积ai的情况下就加入比较,如果不足以加入就令这时的价值等于f(i-1,y)。我们要保存前面子问题的结果这样直接查表就可以了,我们就定义一个二维矩阵f,求解 f(n,m)就可以了。这里再解释一下f(i,y)的含义,i为物品序号(也就是前i个物品的数量),y为可用空间,此时背包可以装的最大价值。可以通过这个blog继续理解一下,可能叙述的顺序不一样,但是本质是相同的。

在分析的时候,我们发现,每一个状态i都只利用了i-1的状态,可不可以利用这个来降低空间复杂度呢?其实可以用一个滚动数组实现(在当前状态i只由i-1状态决定时可用滚动数组进行优化)。也就是定义两个指向数组的指针v1和v2,在利用前一状态所在的v1计算完成当前状态存入v2后,另v1指向v2,v2指向v1,继续处理。

class Solution {public:    /**     * @param m: An integer m denotes the size of a backpack     * @param A & V: Given n items with size A[i] and value V[i]     * @return: The maximum value     */    void swap(int*& p1, int*& p2){        int *temp = p1;        p1 = p2;        p2 = temp;    }//    int max(int a, int b){ //       return a > b ? a : b;//    }    int backPackII(int m, vector<int> A, vector<int> V) {        // write your code here        int len = A.size();        if(len == 0 || m == 0) return 0;        int *v1 = new int[ m + 1] ;//v1保存上一状态        int *v2 = new int[ m + 1];//v2指示当前状态        for(int i = 0; i < m+1; i++){            if(A[0] > i)                v1[i] = 0;            else                v1[i] = V[0];        }        for(int i = 1; i <len; i++){//i代表物体的序号            for(int j = 0; j < m + 1; j++){//j代表背包的面积                if( j - A[i] < 0){                    v2[j] = v1[j];                }                else{                    v2[j] = max(v1[j - A[i]] + V[i], v1[j]);//选i或者不选i中,取价值最大的                 }            }            swap(v1,v2);        }        return v1[m];    }};

进一步,其实,还可以用一个数组实现。我们看一下这个状态转移方程,f(i,y) = max(f(i-1,y),f(i-1,y-ai)+vi) ,是以j从小到大的顺序求解的,如果我们把j从大到小排列就可以用一个数组来实现了,因为每一个y都是由前面的数据决定的,只要前面的数据不变,就可以得到正确的值。


再进一步,假设物品的重量为1000 和2000,背包可装3000,其实没有必要用一个3000的向量,这也有优化的潜力。

————————————————————————————————————————————————————————————————————————————

下面两个都是子问题和原问题之间的状态转移方程稍微有点麻烦的问题



例4、矩阵连乘问题(这个的分析思路很重要)

给定一个n个矩阵的序列(矩阵链),由于矩阵乘法满足结合律,所以可以在任意地方加括号都可以,但是虽然结果一样但是计算复杂度不一样,不同的计算顺序所需的元素乘法次数不同,求一个完全括号化的矩阵连乘,使得计算的乘法次数最小。

分析如下,考虑矩阵链{A1,A2,...,An}最优化的括号方案必定在某个位置k(1<= k<=n)划分这个链也就是在Ak和Ak+1之间吧矩阵链分成两个链的乘法,{A1,....Ak}和{Ak+1,....An}。前一个矩阵链的形式与原矩阵链类似,但是后一个形式却不一样,所以我们必须允许子问题可以在两端都可以变化,即{Ai,....,Aj}的形式。这个分析在很多情境下都适用。

原问题的状态转移方程就可以写为m[i,j] = min{m[i, k]+ m[k+1,j]}+Pi-1*Pk*Pj ;(i<=k<j) 其中(i < j),Pi为矩阵i对应的列数。得到了状态方程就可以自底向上求解了。问题的复杂度为O(n3)。

伪代码如下:

MATRIX-CHAIN-ORDER(p){n = p.length()-1;m[1...n,1....n] and s[1,n-1,2...n]//m记录最小乘法数,s记录分割点for i = 1->nm[i,i] = 0;//同一个矩阵不需要乘法;}for i = 2 ->n  //i为右边界for j = 1 - > i - 1 //j为左边界l = i - j + 1for k = i - >j - 1q = m[i, k]+ m[k+1,j]}+Pi-1*Pk*Pj ;if(q < m[i,j])m[i, j] = q;//不断更新得到最小值s[i, j ] = k;return m and s

注意:结果中的矩阵只有i>j的一半是有意义的。


例5、最长公共子序列

这个问题是动态规划最常见的问题,首先要知道什么是子序列:是在该序列中删去若干元素后得到的序列,也就是可以不是连续的。可以参考lintcode77.

与前面问题的区别是,这个问题根据原问题的条件排除了一部分子问题。

首先分析一下问题,如果暴力搜索所有公共子序列明显复杂度太高了。现在我们看一下原问题是怎么由子结构所构成的。假设两个序列X{x1,...xn},Y{y1,..ym}。 对于任意一个状态xi和yj总共有两种可能性:xi = yj和xi!=yj,对于前一种情况:原问题与子问题的关系就是LSC( i , j) = LSC(i+1) + 1也就是公共子序列长度在前面的基础上加1;对于第二种情况有两种,一种是LSC(i, j) = LSC(i, j-1) 一种是LSC(i, j) = LSC(i-1, j) 取两者中的大的就是当前的最长公共子序列长度。

代码如下:

class Solution {public:    /**     * @param A, B: Two strings.     * @return: The length of longest common subsequence of A and B.     */    int longestCommonSubsequence(string A, string B) {        // write your code here        int lenA = A.length();        int lenB = B.length();        if(lenA == 0 || lenB ==0) return 0;        int LSC[lenA + 1][lenB + 1];        for(int i = 0; i < lenA + 1; i++){            LSC[i][0] = 0;        }        for(int i = 0; i < lenB + 1; i++){            LSC[0][i] = 0;        }        for(int i = 1; i < lenA + 1; i++){            for(int j = 1; j < lenB + 1; j++){                if(A[i-1] == B[j-1]){                    LSC[i][j] = LSC[i-1][j-1]+ 1;                }                else                    LSC[i][j] = max(LSC[i -1][j], LSC[i][j-1]);            }        }        return LSC[lenA][lenB];    }};


最后举一个例子作为动态规划的完结。证明能用贪心的时候就不用动态规划了。

例6、Wiggle Subsequence

问题在leetcode376,这里有两种方法一种使用动态规划求解,一种是用贪心算法求解。但是总感觉用动态规划过于麻烦,但是可以不断优化到和贪心算法一样的时间和空间复杂度。

问题可以叙述为,找到一种最长的摆动的子序列长度。例子在题介绍上面有。

分析:这个问题类似于找极点的感觉,但是有时候不变的起始点也可能是摆动子序列的一部分。对于一个原问题m[i],有两种情况一种是m为极点,一种是非极点。对于前一种情况首先判断是极小值还是极大值m[i] = m[i-1]+1;对于第二种情况,首先如果前后是递增或递减 m[i] = m[i-1],如果是后面的值与当前值相等m[i] = m[i-1],就跳过后面的值,直到可以确定是递增或递减。这是贪心的思想。

我的贪心代码如下:

class Solution {public:    int wiggleMaxLength(vector<int>& nums) {        int len = nums.size();        if(len <= 1) return len;        int ret = 0;        int diff = nums[1] - nums[0];//用于记录上一个极值点的状态        ret = diff !=0 ? 2: 1;        for(int i = 2; i < len ; i++){            if( (diff <= 0 && nums[i-1] < nums[i] )|| (diff >= 0 && nums[i-1] > nums[i] )){                ret++;                diff = nums[i] - nums[i-1];            }                       }        return ret;    }};



0 0