【剑指Offer】动态规划问题

本文探讨了动态规划在《剑指Offer》中的一系列问题,包括绳子剪成段的最大乘积、字符串的公共最长子序列、背包问题和最长递增子序列。通过对这些问题的分析和解决方案的展示,揭示了动态规划的最优化子结构属性和重叠子问题集的特点,并提供了相应的递归和非递归代码实现。文章还提到了动态规划在解决合唱队形问题中的应用。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

问题描述

题目:给你一根长度为n的绳子,请把绳子剪成m段(m、n都是整数,n>1并且m>1),每段绳子的长度记为 k[0], k[1], … ,k[m]。请问 k[0]✖k[1]✖…✖k[m] 可能的最大乘积是多少? 例如,当绳子的长度为8时,我们把它剪成长度分别为 2、3、3 的三段,此时得到的最大乘积是 18。

解题思路

对于这道题目,适宜用动态规划的思想来解决。假设 f(n) 为把长度为n的绳子剪成若干段之后各段长度乘积的最大值。在剪第一刀的时候,我们有n-1种选择,也就是剪出来的绳子长度可能取值为 1, 2, …, n-1。所以 f(n) = max(f(n-i)✖f(i)),其中 i 取值范围为 1~n-1。

所以这道题的本质就变成了求子项的乘积的最大值,我们可以自下而上地去求取这个问题的结果。代码如下所示:

public class Main {

    public static int Solution(int n){
        if(n < 2){
            return 0;
        }
        if(n == 2){
            return 1;
        }
        if(n == 3){
            return 2;
        }
        int[] lens = new int[n+1];
        lens[0] = 0;
        lens[1] = 1;
        lens[2] = 2;
        lens[3] = 3;
        for (int i = 4; i <= n; i++){
            int max = 0;
            for (int j = 1; j < i/2+1; j++){
                if(lens[j] * lens[i-j] >= max){
                    max = lens[j] * lens[i-j];
                }
            }
            lens[i] = max;
        }
        return lens[n];
    }

    public static void main(String[] args) {
        System.out.println(Solution(8));
    }

}

在自下而上的过程中,我们首先判断绳子的长度是否小于4。当绳子长度小于4时,它们的最长乘积从0~3分别为0、0、1、2;而当长度大于等于4时,则采用一个数组保存前面长度(0 ~ n-1)的绳子可取得的乘积最大值,然后再求取长度为n的绳子的乘积最大值即可。


通过这个问题,可以大致总结出动态规划问题的使用场景:

  • 最优化的子结构属性:一个问题的最优解包含在一系列的子问题集的最优解就叫做最优化的子结构属性。
  • 重叠的子问题集:当一个递归算法再次访问同一个问题时,这种事重复出现,我们说这个最优化问题有重叠的子问题集。

接下来我们再来通过下面2道题来加强下对动态规划的印象。


题目一

给定两个字符串 x 和 y, 求它们的公共最长子序列具有的长度。

输入:

ABCBDAB
BDCABA

输出:

4

问题分析

这道题具有一定的迷惑性,注意:公共最长子序列并不是指子序列必须是相邻的。在上面的输入例子中,它们的最长子序列就是 BCAB,也就是下面标红的部分:

ABCBDAB
BDCABA

那么接下来我们分析一下这道题是否能用动态规划的方法解决:

  • 首先我们定义一个状态函数 s(n, m),其中 n 表示的是 x 的前n个子字符串,m表示的是y的前m个子字符串,其中 s(n, m) 的值表示 x 的前n个字符串和 y 的前m个子字符串的公共子序列长度。
  • 我们将问题由后往前分析,假设 x[n-1] == y[m-1],那么 x 和 y 的公共子序列长度就是 s(n-1, m-1) + 1。
  • 如果 x[n-1] != y[m-1],那么 x 和 y 的公共子序列长度则为Max{s(n-1,m), s(n,m-1)}。

可以看到,这个问题是符合动态规划的条件的,即最优化的子结构属性和重叠的子问题集。代码如下所示,笔者将其分为了递归和非递归两个版本:

public class Main {

	/**
	 * 递归版本
	 */
    public static int Solution(String str1, String str2){
        return Solution(str1, str2, str1.length()-1, str2.length()-1);
    }

    private static int Solution(String str1, String str2, int i, int j) {
    	// 当i或j为-1时,说明没有可比较的子字符串,直接返回0
        if(i == -1 || j == -1){
            return 0;
        }
		// 相等的时候为 s(n-1, m-1) + 1
        if(str1.charAt(i) == str2.charAt(j)){
            return Solution(str1, str2, i-1, j-1) + 1;
        }else {
        	// 不相等的时候为 max{s(n-1,m), s(n,m-1)}
            return Math.max(Solution(str1, str2, i-1, j), Solution(str1, str2, i, j-1));
        }
    }

    public static void main(String[] args) {
        String s1 = "ABCBDAB";
        String s2 = "BDCABA";
        Solution(s1, s2);
    }
}

非递归版本:

public class Main {

	/**
	 * 非递归版本
	 */
    public static int Solution(String str1, String str2){
        int n = str1.length() + 1;
        int m = str2.length() + 1;
        int[][] lens = new int[n][m];
        for (int i = 1; i < n; i++){
            for (int j = 1; j < m; j++){
                if(str1.charAt(i-1) == str2.charAt(j-1)){
                    lens[i][j] = lens[i-1][j-1] + 1;
                } else {
                    lens[i][j] = Math.max(lens[i-1][j], lens[i][j-1]);
                }
            }
        }
        return lens[n-1][m-1];
    }

    public static void main(String[] args) {
        String s1 = "ABCBDAB";
        String s2 = "BDCABA";
        Solution(s1, s2);
    }
}

这里解释一下二维数组 lens,其中 lens[i][j] 表示的就是 s(i, j),上面无非就是通过两个 for 循环来进行遍历,找出最优解。


题目二

假如你是小偷,带了一个容量为V的包,此时你去商店偷商品(每一个商品都具有不同的价值),每偷一个商品将占用你背包一定的空间,请你偷价值尽量多的商品。

输入:

6
1 2 3 4
1 3 3 3

输出:

7

说明:

输入第一行表示背包容量V,第二行代表商品的体积,第三行代表商品的价值。上面的例子中,背包容量为6,偷取体积为1、2、3的商品获得价值最大,最大价值为7。

问题分析

  • 首先假设一个状态函数 B(x, V),表示从x件商品中获取的价值最多,其中 V 表示背包的剩余容量;并且假设 weights(i) 为第 i 个商品的体积,prices(i) 为第 i 个商品的价值。
  • 同样将问题从后往前推,我们如何对待最后一个商品:
    • 如果此时背包的容量大于等于最后一件商品的容量,那么我们必定会偷(多偷一件是一件)。
    • 如果此时背包的容量小于最后一件商品的容量,此时状态函数为 B(x-1, V)。此时又产生了一个新问题,倒数第二个商品是偷好,还是不偷好?
      • 偷,那么此时状态函数为 B(x-1, V-weights(x-1)) + prices(x-1);
      • 不偷,那么此时状态函数为 B(x-1, V)。

该问题的代码如下所示,我们同样分为递归和非递归两种方式:

递归版本如下所示:

public class Main {

    public static int Solution(int[] weights, int[] prices, int V){
        if(weights.length != prices.length){
            throw new IllegalArgumentException("weights.length must equals prices.length");
        }
        if(weights.length == 0 || V == 0){
            return 0;
        }

        return Solution(weights, prices, V, weights.length-1);
    }

    private static int Solution1(int[] weights, int[] prices, int V, int i) {
        if(V <= 0 || i < 0){
            return 0;
        }

        if(weights[i] <= V){
            return Math.max(Solution1(weights, prices, V, i-1),
                    Solution1(weights, prices, V-weights[i], i-1) + prices[i]);
        } else {
            return Solution1(weights, prices, V, i-1);
        }
    }

    public static void main(String[] args) {
        int[] weights = {1, 2, 3, 4};
        int[] prices = {1, 3, 3, 4};
        int V = 6;
        System.out.println(Solution(weights, prices, 6));
    }
}

非递归版本如下所示:

public class Main {

    public static int Solution(int[] weights, int[] prices, int V){
        if(weights.length != prices.length){
            throw new IllegalArgumentException("weights.length must equals prices.length");
        }
        if(weights.length == 0 || V == 0){
            return 0;
        }

        int x = weights.length;  // 商品数量
        int[][] b = new int[x+1][V+1];

        for (int i = 1; i <= x; i++){
            for (int j = 1; j <= V; j++){
                if(j >= weights[i-1]) {
                    b[i][j] = Math.max(b[i - 1][j], b[i - 1][j - weights[i - 1]] + prices[i - 1]);
                } else {
                    b[i][j] = b[i-1][j];
                }
            }
        }
        return b[x-1][V];
    }

    public static void main(String[] args) {
        int[] weights = {1, 2, 3, 4};
        int[] prices = {1, 3, 3, 4};
        int V = 6;
        System.out.println(Solution(weights, prices, 6));
    }
}

其中 b[x][v] 表示在 x 件商品中获利的最大值,此时背包剩余容量为 v。


题目三

求一个序列的最长递增子序列,给出该序列的值的大小。

输入:

6
4 5 7 1 3 9

输出:

4

说明:

输入第一行的值为输入序列的个数。第二行为序列的值。
以上面的例子为例,最长子序列为 4 5 7 9,所以输出 4。

问题分析

这道题同样是一道很典型的动态规划问题,我们假设此时的状态函数为 d[i],它表示的是以任意一个 A[i] 为末尾元素组成的最长递增子序列的长度。找出所有位于 i 之前且比 A[i] 小的元素 A[j],此时可出现两种情况:

  • 若存在 A[j] < A[i],取所有比 A[i] 小的元素中 A[j] 中,对应的 d[j] 最大的加 1。
  • 若不存在 A[j] < A[i],则 A[i] 直接取值为 1,即 A[i] 自己构成一个序列。

实现代码如下:

public class Main {

    public static void main(String[] args) {
        Scanner in = new Scanner(System.in);
        while (in.hasNext()){
            int n = in.nextInt();
            int[] members = new int[n];
            for (int i = 0; i < n; i++){
                members[i] = in.nextInt();
            }
            Solution(members);
        }
    }


    public static void Solution(int[] members){
        int[] d = new int[members.length];
        for (int i = 0; i < members.length; i++){
            d[i] = 1;
            for (int j = 0; j < i; j++){
            	// if 中第一个条件判断 A[i] 之前是否存在 A[j] 使得 A[j] < A[i]
                // 如果存在 A[j] 就与其对应的 d[j] 进行比较找出最大的 d[j] 值然后加 1 
                if(members[i] > members[j] && d[j] >= d[i]){
                    d[i] = d[j] + 1;
                }
            }
        }
        System.out.println(d[members.length-1]);
    }
    
}

题目四

计算最少出列多少位同学,使得剩下的同学排成合唱队形。

说明:
N位同学站成一排,音乐老师要请其中的(N-K)位同学出列,使得剩下的K位同学排成合唱队形。

合唱队形是指这样的一种队形:设K位同学从左到右依次编号为 1,2…,K,他们的身高分别为 T1,T2,…,TK,则他们的身高满足存在i(1 ≤ i ≤ K)使得 T1<T2<…<Ti-1Ti+1>…>TK。

你的任务是,已知所有N位同学的身高,计算最少需要几位同学出列,可以使得剩下的同学排成合唱队形。

输入:

8
186 186 150 200 160 130 197 200

输出:

4

问题分析

这道题一开始看起来可能没什么头绪,但实际上它是题目三最长子序列的变种,即最长子序列的拼接。我们在这个数组中,只要求出从队列首到位置 i 的最长上升子序列长度加上从队尾开始到位置 i 的最长上升子序列的长度就能求出合唱队形的总长度。然后用总人数减去总长度就是我们要求的最小值了。代码实现如下:

public class Main {

    public static void main(String[] args) {
        Scanner in = new Scanner(System.in);
        while (in.hasNext()){
            int n = in.nextInt();
            int[] members = new int[n];
            for (int i = 0; i < n; i++){
                members[i] = in.nextInt();
            }
            Solution(members);
        }
    }

    private static void Solution(int[] members) {
        int[] ltr = getLongestIncrementSequence(members);
        int[] rtl = getLongestDecrementSequence(members);
        int max = 0;
        for (int i = 0; i < members.length; i++){
            max = Math.max(max, ltr[i] + rtl[i] - 1);
        }
        System.out.println(members.length - max);
    }

    private static int[] getLongestDecrementSequence(int[] members) {
        int[] rtl = new int[members.length];
        for (int i = members.length - 1; i >= 0; i--){
            rtl[i] = 1;
            for (int j = members.length - 1; j > i; j--){
                if(members[j] < members[i] && rtl[j] >= rtl[i]){
                    rtl[i] = rtl[j] + 1;
                }
            }
        }
        return rtl;
    }

    private static int[] getLongestIncrementSequence(int[] members) {
        int[] ltr = new int[members.length];
        for (int i = 0; i < members.length; i++){
            ltr[i] = 1;
            for (int j = 0; j < i; j++){
                if(members[j] < members[i] && ltr[j] >= ltr[i]){
                    ltr[i] = ltr[j] + 1;
                }
            }
        }
        return ltr;
    }
}

参考

剑指Offer第二版

  • 第二章 面试需要的基础知识
    • 2.4 算法和数据操作
      • 2.4.4 动态规划与贪婪算法

「动态规划后篇」:适用场景

苏苏酱陪你学动态规划(三)——背包问题

【华为OJ】【090-合唱队】


希望这篇文章对您有所帮助~

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值