算法之动态规划

零类

一、引入

动态规划一般形式是求最值,是运筹学的一种优化方法,求解决策过程最优化的过程。在背包问题、生产经营问题、资金管理问题、资源分配问题、最短路径问题和复杂系统可靠性问题等中取得了显著的效果。

         虽然动态规划主要用于求解以时间划分阶段的动态过程的优化问题,但是一些与时间无关的静态规划(如线性规划、非线性规划),只要人为地引进时间因素,把它视为多阶段决策过程,也可以用动态规划方法方便地求解。

         多阶段决策问题:有一类活动的过程,可将过程分成若干个互相联系的阶段,在它的每一阶段都需要作出决策,从而使整个过程达到最好的活动效果。因此各个阶段决策的选取不能任意确定,它依赖于当前面临的状态,又影响以后的发展。当各个阶段决策确定后,就组成一个决策序列,因而也就确定了整个过程的一条活动路线.这种把一个问题看作是一个前后关联具有链状结构的多阶段过程就称为多阶段决策过程。在多阶段决策问题中,各个阶段采取的决策,一般来说是与时间有关的,决策依赖于当前状态,又随即引起状态的转移,一个决策序列就是在变化的状态中产生出来的,故有“动态”的含义,称这种解决多阶段决策最优化的过程为动态规划方法

         求解动态规划的核心问题是穷举,因为要求最值,肯定要把所有可行的答案穷举出来,然后在其中找最值。

 

二、关键

要素
1. 重叠子问题

存在重叠子问题,需要备忘录或者DP table来优化穷举过程,避免不必要的计算。

2. 最优子结构

通过子问题的最值得到原问题的最值。子问题需要相互独立。

3. 状态转移方程

写出状态转移方程才能正确地穷举

*个人认为先写出上一个状态到达下一个状态的每种情况,然后再综合写一下写出状态转移方程。

 

一般流程:运用历史记录

暴力的递归->带备忘录的递归->迭代的动态递归

 

思路

明确状态是什么(原问题和子问题中变化的变量)->定义dp数组/函数的含义(描述问题局面的数组)->明确状态之间的关系(以及明确选择的概念,明确base case)

 

优化:

动态规划的题目都可以画图,可以直观的发现怎么优化。

 

 

 

 

 

三、题目

例子

1.凑零钱问题

问题描述:K种面值,c1,c2,…,ck,每种不限量,给定总额amount,问最少需要几枚硬币凑出。其中,子问题相互之间没有制约,相互独立。

 

1)暴力递归

状态:目标金额amount

Dp函数:当前目标金额,至少需要dp(n)个硬币凑出该金额。

选择:选择一个硬币,目标金额减少。

Base case: 目标金额0,返回需要硬币0,目标小于0,无解返回-1.

 

 

 

状态转移方程:

 

 

 

 

 

每个子问题一个for循环:O(k),子问题总数是递归树节点个数:O(n^k),总的为:O(k*n^k)是指数级的。

 

2)带备忘录的递归

其中备忘录减小了子问题数目,消除了冗余,子问题总数:O(n),处理一个子问题的时间:O(k),总的:O(kn)

 

3)dp数组的迭代解法

使用dp table来自底向上消除重叠子问题。

 

附加:

          计算机解决问题的唯一办法是穷举,穷举所有可能性。而算法设计无非就是先思考如何穷举,再追求如何聪明地穷举

          90%的字符串问题都可以用动态规划解决,并且90%是采用二维数组。

 

练习:

 

列表:

0-1背包问题

887.鸡蛋掉落问题  以及鸡蛋掉落问题优化(二分查找与重新定义状态转移)

416.分割等和子集

518.零钱兑换问题

300.最长上升子序列

背包问题

编辑距离

312.戳气球

最长公共子序列

516.最长回文子序列

->子序列问题解题模板

博弈问题

正则表达

KMP字符匹配算法

区间调度问题(贪心算法可以认作DP的一个特例,需要满足更多条件—贪心选择性质)

 

 

 

 0-1 背包问题

 

状态:背包容量,可选择的物品

选择:装进背包或者不装进背包

Dp数组

         Dp[i][w]:对于前i个物品,当前背包容量为w,这种情况下可以装的最大价值是dp[i][w]

 

大体框架:
         int dp[N+1][W+1]

         Dp[0][…] = 0

         Dp[…][0] = 0

 

         For i in [1..N]:

                  For w in [1..W]:

                          Dp[i][w] = max(

把物品 i 装进背包,

                    不把物品 i 装进背包

)

return dp[N][W]

 

dp[i][w] = x表示:对于前i个物品,当前背包的容量为w时,这种情况下可以装下的最大价值是x。

如果你没有把这第i个物品装入背包,那么很显然,最大价值dp[i][w]应该等于dp[i-1][w]。你不装嘛,那就继承之前的结果。

如果你把这第i个物品装入了背包,那么dp[i][w]应该等于dp[i-1][w-wt[i-1]] + val[i-1]。

 

由于i是从 1 开始的,所以对val和wt的取值是i-1。

for i in [1..N]:

    for w in [1..W]:

        dp[i][w] = max(

            dp[i-1][w],

            dp[i-1][w - wt[i-1]] + val[i-1]

        )

return dp[N][W]

 

---------------------------------------------------------------------------------------------------------------------------------

 

0-1背包变体

416分割等和子集

一个集合,分割成和相等的两个子集

状态:集合(背包)容量,可选择的子集(物品)

选择:划分进子集与否(装进背包与否)

 

Dp数组:dp[i][j]=x表示,对于前i个物品,背包容量为j时,若x为true,则表明背包恰好可以被装满,若x为Fasle则说明不能恰好将背包装满

 

转态转移

如果不把nums[i]算入子集,或者说你不把这第i个物品装入背包,那么是否能够恰好装满背包,取决于上一个状态dp[i-1][j],继承之前的结果。

 

如果把nums[i]算入子集,或者说你把这第i个物品装入了背包,那么是否能够恰好装满背包,取决于状态dp[i - 1][j-nums[i-1]]。

 

Bool canPartition(vector<int>& nums){

         Int sum =0;

         For(int num : nums) sum += num;

         If(sum % 2 != 0) return false;   //和为奇数则不可能划分为两个和相等的子集

         Int n = nums.size();

         Sum = sum / 2;

         Vector<vector<bool>>

                  Dp(n+1, vector<bool>(sum+1, false));

         For(int i=0; i<=n; i++)

                  Dp[i][0] = true;

         For(int i=1; i<=n; i++)

                  For(int j=1; j<=sum; j++){

If(j-nums[i-1]<0)//背包容量不足,不能装进第i个物品

                  Dp[i][j] = dp[i-1][j];

}else{

                  Dp[i][j] = dp[i-1][j]|dp[i-1][j-nums[i-1]];

}

}

Return dp[n][sum];

}

 

!!!!!!状态压缩,因为注意到dp[i][j]都是通过上一行的dp[i-1][..]转移过来的,之前的数据不再使用,则进行状态压缩,把二维Dp数组压缩为一维。

bool canPartition(vector<int>& nums) {

    int sum = 0, n = nums.size();

    for (int num : nums) sum += num;

    if (sum % 2 != 0) return false;

    sum = sum / 2;

    vector<bool> dp(sum + 1, false);

    // base case

    dp[0] = true;

 

    for (int i = 0; i < n; i++)

        for (int j = sum; j >= 0; j--)

            if (j - nums[i] >= 0)

                dp[j] = dp[j] || dp[j - nums[i]];

 

    return dp[sum];

}

i每进行一轮迭代,dp[j]其实就相当于dp[i-1][j],所以只需要一维数组就够用了。

其中j应该是从后往前反向遍历,因为每个物品(或者说数字)只能用一次,以免之前的结果影响其他的结果。

时间复杂度 O(n*sum),空间复杂度 O(sum)。

 

---------------------------------------------------------------------------------------------------------------------------------

 

完全背包问题  物品不限量

零钱兑换问题

给定不同面额的硬币和一个总金额,写出函数来计算可以凑成总金额的硬币组合数,假设每一种面额的硬币有无限个。

 

状态有两个,就是「背包的容量」和「可选择的物品」,选择就是「装进背包」或者「不装进背包」。

首先看看刚才找到的「状态」,有两个,也就是说我们需要一个二维dp数组。

dp[i][j]的定义如下:

若只使用前i个物品,当背包容量为j时,有dp[i][j]种方法可以装满背包。

换句话说,翻译回我们题目的意思就是:

若只使用coins中的前i个硬币的面值,若想凑出金额j,有dp[i][j]种凑法。

经过以上的定义,可以得到:

base case 为dp[0][..] = 0, dp[..][0] = 1。因为如果不使用任何硬币面值,就无法凑出任何金额;如果凑出的目标金额为 0,那么“无为而治”就是唯一的一种凑法。

dp[N][amount]是最终想要得到的答案

int dp[N+1][amount+1]

dp[0][..] = 0

dp[..][0] = 1

 

for i in [1..N]:

    for j in [1..amount]:

        把物品 i 装进背包,

        不把物品 i 装进背包

return dp[N][amount]

 

综上就是两种选择,而我们想求的dp[i][j]是「共有多少种凑法」,所以dp[i][j]的值应该是以上两种选择的结果之和:

 

for (int i = 1; i <= n; i++) {

    for (int j = 1; j <= amount; j++) {

        if (j - coins[i-1] >= 0)

            dp[i][j] = dp[i - 1][j]

                     + dp[i][j-coins[i-1]];

return dp[N][W]

 

 

int change(int amount, int[] coins) {

    int n = coins.length;

    int[][] dp = amount int[n + 1][amount + 1];

    // base case

    for (int i = 0; i <= n; i++)

        dp[i][0] = 1;

 

    for (int i = 1; i <= n; i++) {

        for (int j = 1; j <= amount; j++)

            if (j - coins[i-1] >= 0)

                dp[i][j] = dp[i - 1][j]

                         + dp[i][j - coins[i-1]];

            else

                dp[i][j] = dp[i - 1][j];

    }

    return dp[n][amount];

}

 

状态压缩:

int change(int amount, int[] coins) {

    int n = coins.length;

    int[] dp = new int[amount + 1];

    dp[0] = 1; // base case

    for (int i = 0; i < n; i++)

        for (int j = 1; j <= amount; j++)

            if (j - coins[i] >= 0)

                dp[j] = dp[j] + dp[j-coins[i]];

 

    return dp[amount];

}

 

 

---------------------------------------------------------------------------------------------------------------------------------

--------------------------------------------------------------------------------------------------------------------------------

 鸡蛋掉落问题

题目描述:

一栋1到N共N层的楼,给你K个鸡蛋(K至少为1),现在确定这栋楼存在楼层,0<=F<=N,在这层楼扔鸡蛋下去,鸡蛋恰好没摔碎,高于F的楼层都会碎,低于F的楼层都不会碎,问你最坏的情况下,要扔几次,才能确定这个楼层F

 

分析:

最坏情况下,是一层一层试,鸡蛋破碎一定发生在搜索区间穷尽时。至少的情况,就是不考虑鸡蛋个数限制,优化策略查找。

最好的策略是:

二分查找   先在(1+N)/2扔一下,确定在中上还是中下,来在(1,中下一个)/2扔,还是在(中上一个,N)/2扔。

         该策略中,最坏的情况是试到最后一层还没碎,或者鸡蛋一直碎到第一层。这样需要的次数是logN,比刚才一层一层要少。

 

 

DP分析

状态:拥有鸡蛋的数量K和需要测试的楼层数N

状态变化:鸡蛋数目的减少和楼层数范围的减少。

选择:选择哪一层楼扔鸡蛋。

DP数组/函数:二维的DP数组或者拥有两个状态参数的DP函数来表示状态转移。

大体上:

Def dp(K,N):
         int ren

         For 1<=i<=N:

                  Res = min(res, 这次在第i层扔鸡蛋)

         return res

状态转移

如果第i层扔鸡蛋,碎了,则鸡蛋的个数减少一,搜索的楼层应该从[1..N]到[1..i-1]的范围;如果没碎,鸡蛋个数不变,搜索楼层从[1..N]到[i+1..N]的范围。

Res=min(res, max(dp(K-1, i-1),#碎了

dp(K,N-i)#没碎

)+1#在i层扔了一次

)

Base case:

 N=0时,不需要壬鸡蛋

 K=1时,线性扫描所有楼层即N

 

再添加一个备忘录消除重叠子问题:

         Def superEggDrop(K:int, N:int):

                 

                  Memo = dict()

                  Def dp(K,N)->int:

                          #base case

                          If(K==1) : return N

                          If(N==0): return 0

                          #避免重复计算

                          If(K,N) in memo:
                                   return memo[(K,N)]

                          Res = float(‘INF’)

                          #穷举所有可能的选择

                          For I in range(1, N + 1):
                                   res = min(res,

max (dp(K-1, i-1),#碎了

dp(K,N-i)#没碎

)+1#在i层扔了一次

)

)

                          #记入备忘录

                          Memo[(K,N)] = res

                          Return res

         Return dp(K,N)

 

效率:

子问题数量*函数本身复杂度

Dp函数中一个for循环,本身是O(N),子问题是两个状态的乘积,O(KN)

则时间总复杂度O(K*N^2),空间复杂度为O(KN)

 

---------------------------------------------------------------------------------------------------------------------------------

优化:

1) for循环的问题

这个1-N只是在做选择,也可以用二分思路再优化下for,则时间复杂度可以降为O(K*N*logN)

2) 再改进DP

可以进一步降为O(KN)

3) 再使用数学方法

时间最优可以O(K*logN),空间复杂度到O(1)

 

1) 二分思路对for循环

首先我们根据dp(K, N)数组的定义(有K个鸡蛋面对N层楼,最少需要扔 dp(K, N) 次),很容易知道K固定时,这个函数随着N的增加一定是单调递增的,无论你策略多聪明,楼层增加的话,测试次数一定要增加。

那么注意dp(K - 1, i - 1)和dp(K, N - i)这两个函数,其中i是从 1 到N单增的,如果我们固定K和N,把这两个函数看做关于i的函数,前者随着i的增加应该也是单调递增的,而后者随着i的增加应该是单调递减的:

 

 

 

 

求二者的较大值,再求这些最大值之中的最小值,其实就是求这两条直线交点,也就是红色折线的最低点。

找的最低点:

for (int i = 1; i <= N; i++) {

    if (dp(K - 1, i - 1) == dp(K, N - i))

        return dp(K, N - i);

}

则:

def superEggDrop(self, K: int, N: int) -> int:

 

    memo = dict()

    def dp(K, N):

        if K == 1: return N

        if N == 0: return 0

        if (K, N) in memo:

            return memo[(K, N)]

        res = float('INF')

        # 用二分搜索代替线性搜索

        lo, hi = 1, N

        while lo <= hi:

            mid = (lo + hi) // 2

            broken = dp(K - 1, mid - 1) # 碎

            not_broken = dp(K, N - mid) # 没碎

 

            if broken > not_broken:

                hi = mid - 1

                res = min(res, broken + 1)

            else:

                lo = mid + 1

                res = min(res, not_broken + 1)

 

        memo[(K, N)] = res

        return res

 

    return dp(K, N)

 

效率:

函数本身的复杂度是 O(logN),子问题个数是不同状态组合数,即两个状态的乘积,也就是 O(KN)

总时间复杂度是 O(K*N*logN), 空间复杂度 O(KN)

 

 

2)状态转移重写

之前的思路下,肯定要穷举所有可能的扔法的,用二分搜索优化也只是做了「剪枝」,减小了搜索空间,但本质思路没有变。

稍微修改dp数组的定义,确定当前的鸡蛋个数和最多允许的扔鸡蛋次数,就知道能够确定F的最高楼层数。

dp[k][m] = n

# 当前有 k 个鸡蛋,可以尝试扔 m 次鸡蛋

# 这个状态下,最坏情况下最多能确切测试一栋 n 层的楼

 

# 比如说 dp[1][7] = 7 表示:

# 现在有 1 个鸡蛋,允许你扔 7 次;

# 这个状态下最多给你 7 层楼,

# 使得你可以确定楼层 F 使得鸡蛋恰好摔不碎

# (一层一层线性探查嘛)

最终要求的其实是扔鸡蛋次数m,但是这时候m在状态之中而不是dp数组的结果

int superEggDrop(int K, int N) {

    int m = 0;

    while (dp[K][m] < N) {

        m++;

        // 状态转移...

    }

    return m;

}

while循环结束的条件是dp[K][m] == N,也就是给你K个鸡蛋,允许测试m次,最坏情况下最多能测试N层楼。

 

 

  1. 最长递增2子序列

动态规划的通用技巧:数学归纳思想

 

子序列不一定是连续的,子串一定是连续的。

 

动态规划的核心设计思想是动态规划

(比如想证明一个数学结论,假设在k<n的时候成立,想办法证明k=n的时候成立,如果能证明出来,就说明这个结论对于k等于任何数都成立)

则相应的,设计动态规划算法,可以假设dp数组上[0..i-1]都被算出来,怎么通过这些结果算出dp[i]。

 

Dp[i]表示以nums[i]这个数结尾的最长递增子序列的长度,最终的结果是Dp数组中最大值

int res = 0;

for (int i = 0; i < dp.length; i++) {

    res = Math.max(res, dp[i]);

}

return res;

 

如何进行状态转移

例如已经知道了dp[0..4],如何通过这些推出dp[5]呢

依次从第一个的值开始比较,找到一个比当前值小的,选择找到的+1与目前存下的数据两者中的大的一个

 

 

 

 

base case。dp 数组应该全部初始化为 1,因为子序列最少也要包含自己,所以长度最小为 1。

 

 

 

 

时间复杂度 O(N^2)。

 

---------------------------------------------------------------------------------------------------------------------------------

优化

二分查找解法

这个解法的时间复杂度会将为 O(NlogN),但是说实话,正常人基本想不到这种解法(也许玩过某些纸牌游戏的人可以想出来)。

只能把点数小的牌压到点数比它大的牌上。如果当前牌点数较大没有可以放置的堆,则新建一个堆,把这张牌放进去。如果当前牌有多个堆可供选择,则选择最左边的堆放置。

牌的堆数就是我们想求的最长递增子序列的长度,证明略。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

编辑距离

 

 

 

 

 

前⽂「最⻓公共⼦序列」说过,解决两个字符串的动态规划问题,⼀般都是⽤两个指针  i,j  分别指向两个字符串的最后,然后⼀步步往前⾛,缩⼩问题的规模。

 

其中可以考虑下跳过,和提前删除的操作

 

Base case是i走完了s1,,或j走完了s2,可以直接返回另一个字符串剩下的长度

 

 

 

 

 

 

 

 

 

--------------------------------------------------

 

-------------------------------------------------------------------------------

 

优化

上面的存在重叠子问题(重复路径),可以优化

备忘录

 

 

 

 

DP table  自底向上

 

 

 

 

 

 

状态压缩

 

 

  1. 戳气球

 

 

 

 

 

 

 

 

最长公共子序列问题

 

dp[i][j]  的含义是:对于  s1[1..i]  和  s2[1..j] ,它们的 LCS ⻓度是  dp[i][j] 。

 

 

 

 

⽐如上图的例⼦,d[2][4] 的含义就是:对于  "ac"  和  "babc" ,它们的LCS ⻓度是 2。我们最终想得到的答案应该是  dp[3][6] 。

 

⽤两个指针  i  和  j  从后往前遍历  s1  和  s2 ,如果  s1[i]==s2[j] ,那么这个字符⼀定在  lcs  中;否则的话,  s1[i]  和  s2[j]  这两个字符⾄少有⼀个不在  lcs  中,需要丢弃⼀个。先看⼀下递归解法,⽐较容易理解:

 

 

 

 

 

 

 

 

 

DP table

 

 

 

 

 

 

 

  1. 最长回文子序列

⼀般来说,这类问题都是让你求⼀个最⻓⼦序列,因为最短⼦序列就是⼀个

字符嘛,没啥可问的。⼀旦涉及到⼦序列和最值,那⼏乎可以肯定,考察的

是动态规划技巧,时间复杂度⼀般都是 O(n^2)。

原因很简单,你想想⼀个字符串,它的⼦序列有多少种可能?起码是指数级

的吧,这种情况下,不⽤动态规划技巧,还想怎么着?

第一种思路模板是一个一维的 dp 数组

 

 

 

 

第二种思路模板是一个二维的 dp 数组

 

 

 

 

 

找状态转移需要归纳思维,说白了就是如何从已知的结果推出未知的部分

 

假设你知道了子问题dp[i+1][j-1]的结果(s[i+1..j-1]中最长回文子序列的长度),你是否能想办法算出dp[i][j]的值(s[i..j]中,最长回文子序列的长度)呢

 

这取决于s[i]和s[j]的字符。

如果它俩相等,那么它俩加上s[i+1..j-1]中的最长回文子序列就是s[i..j]的最长回文子序列

如果它俩不相等,说明它俩不可能同时出现在s[i..j]的最长回文子序列中,那么把它俩分别加入s[i+1..j-1]中,看看哪个子串产生的回文子序列更长即可

 

 

 

 

博弈问题

 

 

 

 

 

博弈问题的难点在于,两个要轮流进选择,且都贼精明,应该如何编程表这个过程呢?

 

 

 

 

 

 

 

 

 

 

根据前⾯对 dp 数组的定义,状态显然有三个:开始的索引 i,结束的索引j,当前轮到的⼈。

 

 

 

 

 

对于这个问题的每个状态,可以做的选择有两个:选择最左边的那堆⽯头,或者选择最右边的那堆⽯头。 我们可以这样穷举所有状态:

 

 

 

 

 

 

 

 

 

 

 

转态转移:

 

 

 

 

 

 

 

 

 

 正则表达

算法的设计是⼀个螺旋上升、逐步求精的过程,绝不是⼀步到位就能写出正确算法。

 

 

 

点号可以匹配任意⼀个字符,万⾦油嘛,其实是最简单的,稍加改造即可:

 

 

 

星号通配符可以让前⼀个字符重复任意次数,包括零次。

 

 

 

星号前⾯的那个字符到底要重复⼏次呢?这需要计算机暴⼒穷举来算,假设重复 N 次吧。

不管 N 是多少,当前的选择只有两个:匹配 0 次、匹配 1 次。

 

 

 

通过保留 pattern 中的「*」,同时向后推移 text,来实现将字符重复匹配多次的功能。

 

 

使用memo来降低复杂度

 

 

 

 

 

 

怎么知道这个问题是个动态规划问题呢,你怎么知道它就存在「重叠问题」呢,这似乎不容易看出来呀?

 

解答这个问题,最直观的应该是随便假设个输,然后画递归树,肯定是可以发现相同节点的。

 

 

 KMP字符匹配算法

 

 

 

 

Search函数

 

 

 

 

转态转移:

状态推进:果遇到的字符  c  和  pat[j]  匹配的话,状态就应该向前推进⼀个,也就是说  next = j + 1 。

转态重启: 如果字符  c  和  pat[j]  不匹配的话,状态就要回退(或者原地不动)。

 

 

 

 

 

 

 

 

base case,只有遇到 pat[0] 这个字符才能使状态从 0 转移到 1,遇到其它字符的话还是停留在状态 0(Java 默认初始化数组全为 0)。影⼦状态  X  是先初始化为 0,然后随着  j  的前进⽽不断更新的。

 

 

区间调度问题(贪心算法可以认作DP的一个特例,需要满足更多条件—贪心选择性质)

贪心:

先排序再选择

 

 

 

 

posted @ 2020-07-30 11:10  wrwr  阅读(157)  评论(0)    收藏  举报