常用算法整理:动态规划上

什么是动态规划

动态规划是应该不能叫 一种算法,而应该叫 一类算法 或者 说是 一种思想。它和 二分查找 这种算法是不同的,二分查找我们可以用代码表示出来,并且解决所有问题的思路几乎都是一样的。而动态规划其实无法用代码表示出来,每一种问题的解决方法可能用代码写出来都不一样,所以只看动态规划的定义是很难理解的,需要拿多个题目联系才能真正理解。

动态规划的特点就是把一个大的问题分解为若干个小问题,并且这些小问题之间,每一个小问题都可通过前面的小问题计算出来。通常这些小问题其实都是一个具体的状态。如果一个问题可用动态规划解决,那么需要满足如下特点:

  1. 该问题可以分解为 dp[0], dp[1], … , dp[n] 个状态,最终的答案是其中一个状态,一般是 dp[n]。(或者dp[n][m] 等更多维度,我们这里只拿一维来说)
  2. 其中 dp[i] 可以通过 dp[0] ~ dp[i-1] 或者 `dp[i+1]~dp[n] 计算出来。

如果只看定义的话,会发现DP一定是可以通过分治法的方式来解决的。那么为什么还需通过DP算法来解决呢,因为很多时候分治法算法会有大量的重复计算,而动态规划由于记录了状态,时间复杂度会有大幅下降。很多时候时间复杂度可以从 O(2^n) 降低到 O(n^2) 。如果分治法加上一个Cache来缓存已经计算过的节点,那么他的时间复杂度和DP就是一样的了,其实 DP = DC + Cache

从上面的两个特点就可以看出来,DP 问题的难点有两个:

  1. 如果定义 dp[i],即如何拆分问题,定义状态
  2. 如何计算 dp[i],即通过之前的状态如何计算下一个状态

除了这两个难点之外,还有一点就是要处理初始值 以及可能的边界溢出问题。

另外,如果题目是要求给出所有解,肯定不用DP。DP 一般是求 最优解或者解法数。

简单的动态规划

最简单的一种动态规划,参见这一题: https://leetcode.com/problems/triangle/

这里我们用一个和题目中给出的 triangle 一样的数组 dp 来存储状态。解决上面说到的两个难点:

  1. 如果拆分问题?其实这里 dp[i][j] 就表示跳转到 triangle[i][j] 这个点所需要的最短路径。
  2. 如果计算 dp[i][j] ?dp[i][j] 其实就是取他的上层的临近节点中的比较小的一个 dp[i][j] = Math.min(last[j-1], last[j])+t[j]

然后再把边界条件处理一下,这个问题就可以解决了。代码如下:

var minimumTotal = function(triangle) {
    if(!triangle.length) return 0;
    var dp = [triangle[0]];
         //注意这里处理的初始值

    for(var i=1;i<triangle.length;i++) {
        var last = dp[i-1];
        var row = [];
        var t = triangle[i];
        for(var j=0;j<t.length;j++) {
            if(j === 0) row.push(last[0]+t[0]);
                 //处理左边界
            else if(j === t.length-1) row.push(last[last.length-1]+t[t.length-1]);
                 //处理右边界
            else row.push(Math.min(last[j-1], last[j])+t[j]);
        }
        dp.push(row);
    }

    var r = dp.pop(), min = r[0];
    for(i=0;i<r.length;i++) {
        if(r[i]<min) min = r[i];
    }
    return min;
};

分析一下时间复杂度,如果用 n 表示层数,那么节点数大约是 O(n^2) ,在这个算法中其实我们就对整个三角进行了一次遍历而已,所以时间复杂度也是 O(n^2)

对比一下DC,这里我们可以很容易写一个 DC 的算法,而且比 DP 还要简单。DC算法每次计算一个节点i,j 的值的时候,都需要进行一个两个递归,分别计算 i-1,j-1i-1,j,所以每次调用都会产生两个新的递归,每一层的计算量都是上一层的两倍,时间复杂度是 O(2^n)

再看一个简单的题:https://leetcode.com/problems/unique-paths/

这里我们用 dp[i][j] 表示移动到i,j 的所有不同路径的个数。代码如下:

var uniquePaths = function(m, n) {
    var dp = [], i, j;
    for(i=0;i<m;i++) {
        var row = [];
        for(j=0;j<n;j++) {
            row.push(0);
        }
        row[0] = 1;
        dp.push(row);
    }

    for(i=0;i<m;i++) {
        for(j=1;j<n;j++) {
            if(i === 0) dp[i][j] = dp[i][j-1];
            else dp[i][j] = dp[i][j-1] + dp[i-1][j];
        }
    }

    return dp[m-1][n-1];
};

中等难度的问题

上面的两个DP问题都是 Easy 难度的问题,说它 Easy 主要是对我们定义的两个难点其实都很容易解决。即很容易定义 dp[i] ,而且也很容易计算出 dp[i] 。下面的几个问题就会稍微有一些难度。

第一题,https://leetcode.com/problems/longest-increasing-subsequence/

这里我们如何定义 dp[i] 就有一些难度了,比如主要可能会有两种想法:

  1. 0~i 个元素的最长子序列
  2. 以 i 元素结尾的最长子序列

如果我们用 第一种定义,那么会发现,无法通过 dp[0] ~ dp[i-1] 来计算出 dp[i] ,因为无法知道前面的最长子序列的结尾值是否比 i 元素小。所以我们只能选择第二个定义。

dp[i] 表示以 i 元素结尾的最长子序列,这样我们就可以计算出 dp[i] 了,只要遍历一遍 dp[0~i-1] 找到其中小于当前元素且最长的子序列,然后长度加上自己就是 dp[i], 具体解法如下:

var lengthOfLIS = function(nums) {
    if(nums.length <= 1) return nums.length;

    var dp = [], i, result=1;

    for(i=0;i<nums.length;i++) {
        dp.push(1);
    }

    for(i=1;i<nums.length;i++) {
        for(var j=0;j<i;j++) {
            if(nums[j] < nums[i]) dp[i] = Math.max(dp[i], 1+dp[j]);
        }
        result = Math.max(dp[i], result);
    }
    return result;
};

这个做法的时间复杂度是 O(n^2) ,因为他是一个双重循环。

第二题,https://leetcode.com/problems/palindrome-partitioning-ii/

这个题目相当难,我们这里定义dp[i]i个元素所需要的最小cut。注意是 前i个 也就是不包括当前的第i个元素。至于为什么定义 前i个 而不是 0~i 个,其实主要是为了处理边界条件比较方便,定义成 0~i个 也是可以解出来的。

定义好 dp[i] 之后,我们就需要一个方法来计算出它的值。很明显的一个思路是:遍历 dp[j=0~i-1],如果 j~i 个元素是回文字符串,那么 dp[i] = dp[j]+1,只要取一个最小值就行了。

这样做的话,会出现这样的代码:

for(i=1;i<=s.length;i++) {
    for(var j=0;j<i;j++) {
        if(isPalindrome(j,i-1)) dp[i] = Math.min(dp[j]+1, dp[i]);
    }
}

这里已经有两层循环了,也就是时间复杂度至少是 O(n^2) ,所以其中的 isPalindrome 函数我们不能再次进行循环,不然就变成 O(n^3) 了,这样肯定会超时。

有一个做法是提前计算出所有的情况,然后保存在 isPalindrome[n][n] 的数组里,这样就需要用到额外的 O(n^2) 的空间。实际在leetcode上这样做的话,会报内存超限错误的。

那么可以宣布,上述思路无法获得一个 O(n^2) 时间复杂度,并且同时空间复杂度是 O(n) 的解法。这个思路是比较容易想到的,而且实现起来不复杂。好像用Java就可以通过,但是用JS是无法通过的。贴一下这个思路的完整代码:

var minCut = function(s) {
    if(s.length <= 1) return 0;
    var isPalindrome = caculatePalindrome(s);
    var dp = [-1];

    for(var i=1;i<=s.length;i++) {
    dp.push(i-1);
    }

    for(i=1;i<=s.length;i++) {
        for(var j=0;j<i;j++) {
            if(isPalindrome[j][i-1]) dp[i] = Math.min(dp[j]+1, dp[i]);
        }
    }

    return dp[s.length];
};

var caculatePalindrome = function(s) {
    var dp = [];

    for(var i=0;i<s.length;i++) {
        var row = [];
        for(var j=0;j<=i;j++) {
            row.push(false);
        }
        dp.push(row);
    }

    for(i=0;i<s.length;i++) {
        for(j=0;j<=i;j++) {
            if(i === j) dp[j][i] = true;
            else if(j === i-1) dp[j][i] = (s[i] === s[j]);
            else dp[j][i] = (s[i] === s[j] && dp[j+1][i-1]);
        }
    }

    return dp;
}

这里我们换一个思路,这个思路非常难想。就是我们其实在第二层循环的同时,就能判断出是否是回文字符串了,不需要额外的一次判断。具体的方式,我们直接看代码来说明:

var minCut = function(s) {
    if(s.length <= 1) return 0;
    var dp = [];

    for(var i=0;i<=s.length;i++) {
        dp.push(i-1);
    }

    for(i=0;i<=s.length;i++) {
        for(var j=0;i-j>=0 && i+j<s.length && s[i-j] === s[i+j]; j++) {
            dp[i+j+1] = Math.min(dp[i+j+1], dp[i-j]+1);
        }
        for(j=0;i-j+1>=0 && i+j<s.length && s[i-j+1] === s[i+j]; j++) {
            dp[i+j+1] = Math.min(dp[i+j+1], dp[i-j+1]+1);
        }
    }
    return dp[s.length];
}

这个解法是参考最高票的那个答案的。在第二层循环的时候,如果是计算dp[i+1] ,那么肯定无法直接判断是不是回文字符串,所以这个方法做了一个很巧妙的设计:计算 dp[i+j+1] 而不是计算 dp[i+1]。这样在j逐步递增的时候我们就可以判断出 i-j ~ i+j 是不是一个回文字符串了。

很容易看出来这个解法的时间复杂度是 O(n^2)

第三题,https://leetcode.com/problems/word-break/

这个题相对简单一些,还是和前一题的思路类似,用 dp[i] 表示 前i个元素是不是能切割成单词,然后这里比上一题简单的地方就是,上一题的回文字符串不好判断,这里是不是在词典中就很容易判断出来了,具体解法如下:

var wordBreak = function(s, wordDict) {
    var maxWordLength = 0;
    wordDict.forEach(function(w){
        maxWordLength = Math.max(w.length, maxWordLength);
    });

    var dp = [true];

    for(var i=1;i<=s.length;i++) dp.push(false);

    for(i=1;i<=s.length;i++) {
        for(var j=1;j<=maxWordLength && j<=i;j++) {
            if(wordDict.has(s.slice(i-j,i)) && dp[i-j]) {
                dp[i] = true;
                break;
            }
        }
    }

    return dp[s.length];
};

注意一个地方,就是第二层循环的时候,j的最大值不是i 而是 maxWordLength,所以时间复杂度是 O(NL) 而不是 O(n^2),其中L就是 maxWordLength。

先介绍上面这几种相对简单的动态规划题,除了 回文字符串比较难,其他的都是 Easy 或者 Medium 级别的,属于需要做到 bug free 的题目。

下次继续讲更复杂的动态规划题,这些题目的特点就是考两个序列间的一些操作。

双序列型:

leetcode 上没有 Longest Common Subsequence

https://leetcode.com/problems/edit-distance/

https://leetcode.com/problems/distinct-subsequences/

https://leetcode.com/problems/interleaving-string/

时间: 2024-11-04 11:21:10

常用算法整理:动态规划上的相关文章

算法整理-动态规划和Two Pointers

一 .   最长子序列和 令dp[i] 为以i结尾的最长子序列和.dp[i] = max(dp[i-1] + nums[i], nums[i]). 同时纪录dp[i]遍历结果的中的最大值.需要三个变量,纪录上一个dp,  当前dp和最大的dp. class Solution { public: int maxSubArray(vector<int>& nums) { int len = nums.size(); if (len == 0) return 0; int pre = num

[转]五大常用算法:分治、动态规划、贪心、回溯和分支界定

Referred from http://blog.csdn.net/yapian8/article/details/28240973 分治算法 一.基本概念 在计算机科学中,分治法是一种很重要的算法.字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并.这个技巧是很多高效算法的基础,如排序算法(快速排序,归并排序),傅立叶变换(快速傅立叶变换)…… 任何一个可以用计算机求

常见数据结构与算法整理总结(上)

数据结构是以某种形式将数据组织在一起的集合,它不仅存储数据,还支持访问和处理数据的操作.算法是为求解一个问题需要遵循的.被清楚指定的简单指令的集合.下面是自己整理的常用数据结构与算法相关内容,如有错误,欢迎指出. 为了便于描述,文中涉及到的代码部分都是用Java语言编写的,其实Java本身对常见的几种数据结构,线性表.栈.队列等都提供了较好的实现,就是我们经常用到的Java集合框架,有需要的可以阅读这篇文章.Java - 集合框架完全解析 一.线性表 1.数组实现 2.链表 二.栈与队列 三.树

五大常用算法----贪心、动态规划、分支限界、分治算法和回溯算法

五大常用算法之一:贪心算法 所谓贪心算法是指,在对问题求解时,总是做出在当前看来是最好的选择.也就是说,不从整体最优上加以考虑,他所做出的仅是在某种意义上的局部最优解. 贪心算法没有固定的算法框架,算法设计的关键是贪心策略的选择.必须注意的是,贪心算法不是对所有问题都能得到整体最优解,选择的贪心策略必须具备无后效性,即某个状态以后的过程不会影响以前的状态,只与当前状态有关. 所以对所采用的贪心策略一定要仔细分析其是否满足无后效性. 五大常用算法之二:动态规划算法 五大常用算法之三:分支限界算法

五大常用算法:分治、动态规划、贪心、回溯和分支界定

苹果的WWDC ,除了发布了os x 10.10 和IOS8 外,还推出了Swift.详细点击这里 代码总体风格有点像Java,也有点像javascript. 下面给出一些代码段(来自苹果官方手册): println("Hello, world") "var myVariable = 42 myVariable = 50 let myConstant = 42" 摘录来自: Apple Inc. "The Swift Programming Languag

五种常用算法之二:动态规划算法

动态规划算法: 基本思想: 动态规划算法通常用于求解具有某种最优性质的问题.在这类问题中,可能会有许多可行解.每一个解都对应于一个值,我们希望找到具有最优值的解.动态规划算法与分治法类似,其基本思想也是将待求解问题分解成若干个子问题,先求解子问题,然后从这些子问题的解得到原问题的解.与分治法不同的是,适合于用动态规划求解的问题,经分解得到子问题往往不是互相独立的.若用分治法来解这类问题,则分解得到的子问题数目太多,有些子问题被重复计算了很多次.如果我们能够保存已解决的子问题的答案,而在需要时再找

无关算法的动态规划

动态规划是一类算法,Leetcode上有39题挂上了DP的标签,还有很多也可以用DP求解:博客园有很多对动态规划的讲解,譬如五大常用算法之二:动态规划算法.不敢再写这个算法,随便写写我的2015总结. 研究生二年级,工科,做的多是数据链路层的处理,主要是信道纠错编码.刚刚连续看了两个退学的帖子,没有经历过也不好评价,但是大学四年真的能学到很多,一个词就是——心性. 这一年,我的研究生涯多是围绕项目展开的:研究生做项目,不同的人会有不同的体会吧.至于我,纠错编码这个东西,基本上所有通信系统都有涉及

【转载】算法设计之五大常用算法设计方法总结

转载自http://blog.csdn.net/zolalad/article/details/11393915 算法设计之五大常用算法设计方法总结 一.[分治法]  在计算机科学中,分治法是一种很重要的算法.字面上的解释是"分而治之",就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题--直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并.这个技巧是很多高效算法的基础,如排序算法(快速排序,归并排序),傅立叶变换(快速傅立叶变换)--等.任

五大常用算法

http://www.cnblogs.com/steven_oyj/archive/2010/05/22/1741370.html 分治算法 一.基本概念 在计算机科学中,分治法是一种很重要的算法.字面上的解释是"分而治之",就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题--直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并.这个技巧是很多高效算法的基础,如排序算法(快速排序,归并排序),傅立叶变换(快速傅立叶变换)-- 任何一个可以用计