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

什么是动态规划

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

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

  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-1 和 i-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/

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值