常见动规规划模型


LIS 最长上升子序列

Longest Increasing Subsequence


给定一个序列A,求序列中的最长严格递增子序列。

子序列的定义为,在不改变原序列的次序下,任选部分元素组成的新序列,在这里A的子序列可以是它自己。

例如 LeetCode 300. 最长递增子序列

public class LIS {

    public static int[] of(int[] A) {
        if (A.length == 0) return new int[]{};
        int n = A.length, maxL = 1;
        int[] dp = new int[n];
        for (int i = 0; i < n; i++) {
            dp[i] = 1;
            for (int j = i - 1; j >= 0; j--)
                if (A[i] > A[j] && dp[j] >= dp[i])
                    dp[i] = dp[j] + 1;
            if (dp[i] > maxL) maxL = dp[i];
        }
        int[] ans = new int[maxL];
        for (int i = maxL, j = n; i > 0 && j > 0;) {
            while (dp[--j] != i);
            ans[--i] = A[j];
        }
        return ans;
    }

    public static int max(int a, int b) { return a > b ? a : b; }
}

顺序枚举序列A的元素,不难发现以A[i]为结尾,构成的LIS的长度为max({A[j]}) + 1,其中i > jj >= 0

于是有状态转移方程:

d p [ i ] = { 1 A [ i ] = m a x ( A , 0 , i ) m a x ( { d p [ j ] } ) + 1 j ∈ [ 0 , i ) dp[i]=\left\{ \begin{array}{l|r} 1&A[i] = max(A, 0, i )\\ max(\{dp[j]\})+ 1&j \in [0,i)\\ \end{array} \right. dp[i]={1max({dp[j]})+1A[i]=max(A,0,i)j[0,i)

值得注意的是,dp[n]不一定为LIS的长度,为了减少开销,我们还需要额外定义一个变量来保存真正的LIS的长度。

当然这么做的值得的,因为你会发现动态规划部分执行完后,我们是可以从dp[]中溯回出完整的LIS

时 间 复 杂 度 O ( n 2 ) , 空 间 复 杂 度 O ( n ) 时间复杂度O(n^2) ,空间复杂度O(n) O(n2),O(n)

不过,如果你只需最长子序列的长度,我们还有更好的算法。

public class LISLength {

    public static int of(int[] A) {
        int n = A.length, top = 0;
        int[] stack = new int[n];
        for (int i = 0; i < n; i++) {
            int idx = upperBound(stack, 0, top, A[i]);
            if (idx == top) top++;
            stack[idx] = A[i];
        }
        return top;
    }


    public static int upperBound(int[] arr, int start, int length, int key) {
        while (length > 0) {
            int half = length >> 1;
            if (key <= arr[start + half])
                length = half;
            else {
                start += half + 1;
                length -= half + 1;
            }
        }
        return start;
    }
}

时 间 复 杂 度 O ( n l o g n ) , 空 间 复 杂 度 O ( n ) 时间复杂度O(nlogn) ,空间复杂度O(n) O(nlogn),O(n)

这个算法比较像是在维护一个单调栈,但和栈的先入后出不同。

对于一次枚举,
如果栈为空,或当前元素比栈顶元素大,那么当前元素入栈,此时即为当前最优解。
如果当前元素小于栈顶元素,那么我们将其覆盖掉栈里第一个大于等于它的元素。

由于该博讨论的重点是动态规划,这里就不在做过多阐述。


MIS 最大子段和问题

Maximum Interval Sum


给定一个序列A,求序列中最大的子段和。

子段的定义为,任选部分连续的元素组成的新序列,在这里A的子段可以是它自己。

例如 剑指 Offer 42. 连续子数组的最大和
不过按我给出的定义子段可以为空,而这道例题不行。

public class MIS {

    public static int of(int[] A) {
        int n = A.length;
        if (n == 0) return 0;
        int[] dp = new int[n];
        int ans = 0;
        if (A[0] > 0) 
        	ans = dp[0] = A[0];
        for (int i = 1; i < n; i++) {
            dp[i] = A[i] + dp[i - 1];
            if (dp[i] < 0) dp[i] = 0;
            else if (dp[i] > ans) ans = dp[i];
        }
        return ans;
    }
}

dp[i]定义为选择[0, i]能拿到的最大子段和,我们规定,当dp[i] < 0时,放弃这一块,即设dp[i] = 0。对于每一个dp[i],我们都默认选择连接i - 1,因为已经处理过的状态必然大于或等于零,

于是有状态转移方程:

d p [ i ] = { 0 d p [ i ] + d p [ i − 1 ] < 0 d p [ i ] + d p [ i − 1 ] dp[i]=\left\{ \begin{array}{l|r} 0&dp[i] + dp[i - 1] < 0\\ dp[i] + dp[i - 1]&\\ \end{array} \right. dp[i]={0dp[i]+dp[i1]dp[i]+dp[i1]<0

时 间 复 杂 度 O ( n ) , 空 间 复 杂 度 O ( n ) 时间复杂度O(n) ,空间复杂度O(n) O(n),O(n)

不过我们需要的答案可不在这里面,于是需要额外定义一个变量来保存MIS,并且通过观察我们发现,每次访问的状态都是在上一个状态后面,也就是说我们可以用回滚数组的思想来把空间复杂度降至O(1),这里就留给读者把。


LCS 最长公共子序列

Longest Common Subsequence


先写背包


背包问题

Knapsack Problem


背包问题(Knapsack problem)是一种组合优化的NP完全问题。问题可以描述为:给定一组物品,每种物品都有自己的重量和价格,在限定的总重量内,我们如何选择,才能使得物品的总价格最高。

也可以将背包问题描述为决定性问题,即在总重量不超过W的前提下,总价值是否能达到V。

问题的名称来源于如何选择最合适的物品放置于给定背包中。

(摘自WIKI)


0-1 背包

0-1 Knapsack


01背包是背包问题最简单、最基本的形式。

问题可以基本概述为:

给定 n n n 个物品,和一个容量为 W W W 的背包,每个物品的重量为 w 1 ,   w 2 , . . .   ,   w n w_{1},\ w_{2}, ...\ ,\ w_{n} w1, w2,... , wn,价值为 v 1 ,   v 2 , . . .   ,   v n v_{1},\ v_{2}, ...\ ,\ v_{n} v1, v2,... , vn,要求选择若干个重量总和不超过 W W W 的物品,使其价值总和最大。

对于这种问题,我们可以定义状态 d p ( i , j ) dp(i,j) dp(i,j)

其含义为仅前 i i i 个物品可供选择的情况下,在大小为 j j j 的背包中能选择的最大价值。

而在01背包中,每个物品仅有选与不选两个策略,对应可写成转移方程: d p ( i , j ) = m a x { d p ( i − 1 , j ) , d p ( i − 1 , j − w i ) + v i } dp(i,j) = max\{dp(i - 1, j),dp(i-1,j-w_{i})+v_{i}\} dp(i,j)=max{dp(i1,j),dp(i1,jwi)+vi}最终 d p ( n , W ) dp(n,W) dp(n,W) 就是最优选择的价值总和。

for (int i = 1; i <= n; i++)
	for (int j = w[i]; j <= W; j++)
		dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - w[i]] + v[i]);

此外,状态递推时有强顺序性,可以使用滚动数组进行优化。

这里希望读者自行体会枚举方向的改变。

// dp = new int[n + 1][W + 1];
dp = new int[W + 1];
for (int i = 1; i <= n; i++)
	for (int j = W; j >= w[i]; j--)
		dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

时 间 复 杂 度 O ( n W ) , 空 间 复 杂 度 O ( n ) 时间复杂度O(nW) ,空间复杂度O(n) O(nW),O(n)

选择回溯部分,从最大容量的背包逆推,当发现与上一层价值不同时,视为结果选择了 i i i,保存并将最大容量减去 w i w_{i} wi,直至 W W W 0 0 0

W = dp[n][W];
for (int i = n; W > 0 && i > 0; i--)
	if (dp[i][W] > dp[i - 1][W]) {
		W -= w[i];
		print i;
	}

完全背包


完全背包问题可视为01背包的一个变种,它和01背包的区别在于,完全背包问题中,一件物品可以被选择 0 ∼ ∞ 0 \sim \infty 0 次。

一种朴实的想法是,将每一件物品再次放入 ⌊ W ÷ w i ⌋ − 1 \lfloor W ÷ w_{i}\rfloor - 1 W÷wi1个,变化成01背包问题,或者在枚举到物品 i i i 时,枚举放入多个 i i i 的情况。

那可真蠢

在01背包降到滚动数组优化时,我们发现,从左至右的枚举每个容量的背包,会在枚举到 j j j 时使用 1 ∼ j − 1 1 \sim j -1 1j1 的状态去更新它,这显然有违我们给出的状态转移方程。

而在完全背包问题中,从左至右的枚举,意味着在更新到状态 j j j 时, j − w i j-w_{i} jwi 的状态已经被更新过。

也就是说,我们使用相同的状态转移方程就能解决完全背包问题。

for (int i = 1; i <= n; i++)
	for (int j = w[i]; j <= W; j++)
		dp[j] = max(dp[j], dp[j - w[i]] + v[i]);

复杂度同01背包。


多重背包


多重背包问题也可视为01背包的一个变种,区别在于,多重背包问题中,一件物品可以被选择 0 ∼ k 0 \sim k 0k 次。

按理来讲,有限个选择的问题应该被放在基本问题和无穷问题之间,

但在背包问题中,多重背包值得讨论的点比前二者多太多。

首先还是那个朴素,

我们将选择 k k k 次视为新增 k − 1 k - 1 k1 个相同的物品,然后套用 01 背包的公式,这样做的复杂程度在 O ( W ∑ i = 1 n k i ) O(W\sum_{i=1}^n k_{i}) O(Wi=1nki)

然后就将死了


对于有限个数的选择,我们可以将其拆分成多个二的幂,然后组合选择,举个例子:

给定物品 i i i,该物品可以被选择 8 8 8 次。

我们可以将 8 8 8 分成 1 + 2 + 4 + 1 1+2+4+1 1+2+4+1

当选择 k ∈ [ 0 , 8 ] k \in [0,8] k[0,8] 个物品时,总能从 1 , 2 , 4 , 1 {1,2,4,1} 1,2,4,1 中选出若干个数组成 k k k

这样划分后,时间复杂度能将至 O ( W ∑ i = 1 n log ⁡ k i ) O(W\sum_{i=1}^n \log k_{i}) O(Wi=1nlogki)


上述为分组优化,就不给出具体的实现程序了。

现在要介绍的是一种能将多重背包问题复杂度降至 O ( n W ) O(nW) O(nW) 的策略。

先睡,明天写

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值