动态规划方法记录

211 阅读12分钟

如何判断是否适用动态规划?

动态规划(DP)是解决具有特定结构问题的强大工具,但并不是所有问题都适用。要判断一个问题是否适用动态规划,我们需要考虑以下几个核心要素:

1. 核心判断依据:

  • 求方案数:如果问题本质上是在求解“方案数”或“最优解”,并且问题中的子问题是重叠的,动态规划通常是首选方法。
    例如,计算某个目标金额的不同组合数(例如背包问题),或者在给定条件下分配资源等,都会适用动态规划。

  • 子问题重叠:判断问题中是否存在子问题重叠。动态规划之所以高效,是因为它通过缓存已计算过的子问题的结果,避免了重复计算。例如,斐波那契数列就是一个经典的例子,它的每个子问题(如fib(n-1)fib(n-2))会在多个地方被重复调用,这正是动态规划的应用场景。

  • 最优子结构:是否能够利用前面计算的状态推导出后面的状态?动态规划依赖于最优子结构,意味着通过解决子问题,能逐步构造出大问题的最优解。最优子结构是动态规划的一个重要特征。例如,在背包问题中,最大价值是由各个物品的最优选择状态推导出来的。

2. 动态规划 vs 回溯 vs BFS:

  • 动态规划:适用于求解最优解或者方案数的问题,尤其是具有重叠子问题和最优子结构的情况。例如,考虑一串数字的组合情况,动态规划能够通过前面的计算结果来减少重复计算,优化效率。

  • 回溯:适用于需要找到所有解或者排列组合的情况,通常递归进行,探索所有可能的解空间。例如,在解决排列问题或选择问题时,回溯通过不断尝试每种选择并撤销来寻找所有可能的解。

  • BFS(广度优先搜索):适用于寻找最短路径的情况。BFS通过逐层扩展节点的方式,保证找到最短路径。例如,图中的最短路径问题可以通过BFS来求解,保证每次遍历到最小的步数。


动态规划的解题步骤

动态规划问题的解决过程通常包括以下几个步骤,下面将结合具体例子进行说明。

1. 确定 dp 数组及下标含义

  • 状态的定义:我们首先要明确问题中“状态”是什么意思。例如,在斐波那契数列问题中,状态dp[i]表示第i项的值,目标是通过递推计算出最终结果。

  • 如何推导子问题的解:在背包问题中,dp[i]表示在容量为i的背包中,能装下的最大价值。通过前面已知的背包容量和物品的选择情况,我们可以推导出当前背包容量的最优解。

2. 确定状态转移方程

状态转移方程是动态规划的核心,它定义了如何通过已知状态推导出未知状态。

  • 例如,斐波那契数列dp[i] = dp[i-1] + dp[i-2]。从前两个数推导出当前数值。

  • 凑零钱问题:定义状态dp[i]表示凑成金额i所需的最少硬币数。状态转移方程为:dp[i] = min(dp[i], dp[i - coin] + 1),表示选择一个硬币后,更新当前金额的最优解。

3. 初始化 dp 数组

  • 边界条件:例如,在斐波那契数列中,dp[0] = 0dp[1] = 1,这些是基本的初始值,必须先给定才能递推出后续值。

  • 在背包问题中,dp[0] = 0表示一个容量为0的背包最大价值为0。

4. 确定遍历顺序

  • 动态规划的遍历顺序依赖于问题的特性。例如:
    • 凑零钱问题中,我们通常从小到大遍历目标金额,因为每个子问题的解依赖于更小的子问题。
    • 对于分割问题,比如找到数组中两个不重叠子数组之和最大的,可以使用Kandane算法来从左往右找到最大子数组,再从右往左找到最大子数组,然后通过动态规划找到最大值。

5. 举例推导 dp 数组

  • 斐波那契数列

    int fib(int n) {
        if (n <= 1) return n;
        vector<int> dp(n+1);
        dp[0] = 0; dp[1] = 1;
        for (int i = 2; i <= n; ++i) {
            dp[i] = dp[i-1] + dp[i-2];
        }
        return dp[n];
    }
    
  • 背包问题

    int knapsack(int W, vector<int>& wt, vector<int>& val, int n) {
        vector<vector<int>> dp(n + 1, vector<int>(W + 1, 0));
        for (int i = 1; i <= n; ++i) {
            for (int w = 1; w <= W; ++w) {
                if (wt[i-1] <= w) {
                    dp[i][w] = max(dp[i-1][w], dp[i-1][w-wt[i-1]] + val[i-1]);
                } else {
                    dp[i][w] = dp[i-1][w];
                }
            }
        }
        return dp[n][W];
    }
    

动态规划的适用场景

动态规划非常适用于以下几种类型的问题:

1. 最优化问题

这类问题的目标是找到一种方案,使得某个指标(如成本、时间、利润等)达到最优。例如:

  • 最短路径问题:如在图中从起点到终点的最短路径,典型的动态规划应用。
  • 最大利润问题:例如股票买卖问题,如何通过动态规划来找到最佳的买卖时机。

2. 组合计数问题

这类问题通常涉及到在某些限制条件下,计算有多少种不同的方案。例如:

  • 背包问题:在一定容量下选择不同物品,使得总价值最大化。
  • 凑零钱问题:求解给定硬币面额和目标金额时,如何选择硬币组合。

3. 序列问题

需要在序列或字符串中找到最优解或符合某种条件的子序列。例如:

  • 最长公共子序列(LCS):给定两个字符串,找到最长的公共子序列。
  • 最长递增子序列(LIS):找到一个数组的最长递增子序列。

4. 划分问题

将一个集合分割成若干部分,使每部分满足某种条件并且最优化。例如:

  • 分割问题:将一个数组分成两部分,使得两部分的和最接近。

5. 博弈与决策问题

这类问题涉及到在一定规则下进行决策,求出最优策略。例如:

  • 最优策略:在棋类游戏中如何通过动态规划来确定每一步的最优决策。

典型问题

1. 斐波那契数列

斐波那契数列的计算可以通过动态规划来优化。通过存储中间计算结果,避免了重复计算。

斐波那契数列:状态=数列序号n;没有选择;dp长度为n+1的一维数组

f(n)={1,n=1,2f(n1)+f(n2),n>2f(n) = \begin{cases} 1, & n = 1, 2 \\ f(n - 1) + f(n - 2), & n > 2 \end{cases}

2. 背包问题

在背包问题中,我们通过动态规划来找出在有限容量下可以获得的最大价值。

3. 最长公共子序列问题

给定两个字符串,要求找到它们的最长公共子序列。使用动态规划逐步构造解,并通过状态转移方程推导出最优解。

使用动态规划

动规五部曲如下:

  1. 确定dp数组(dp table)以及下标的含义

    dp[i]:包括下标i(以nums[i]为结尾)的最大连续子序列和为dp[i]

  2. 确定递推公式

    dp[i]只有两个方向可以推出来:

    • dp[i - 1] + nums[i],即:nums[i]加入当前连续子序列和
    • nums[i],即:从头开始计算当前连续子序列和

    一定是取最大的,所以dp[i] = max(dp[i - 1] + nums[i], nums[i]);

  3. dp数组如何初始化

    从递推公式可以看出来dp[i]是依赖于dp[i - 1]的状态,dp[0]就是递推公式的基础。

    dp[0]应该是多少呢?

    根据dp[i]的定义,很明显dp[0]应为nums[0]即dp[0] = nums[0]。

  4. 确定遍历顺序

    递推公式中dp[i]依赖于dp[i - 1]的状态,需要从前向后遍历。

  5. 举例推导dp数组

    以示例一为例,输入:nums = [-2,1,-3,4,-1,2,1,-5,4],对应的dp状态如下:

class Solution {
public:
    int maxSubArray(vector<int>& nums) {
        int n = nums.size();
        if (n == 0) {
            return 0;
        }
        vector<int> dp(n);
        dp[0] = nums[0];
        int res = dp[0];

        for (int i = 1; i < n; i++) {
            dp[i] = max(nums[i] + dp[i - 1], nums[i]);
            res = max(dp[i], res);
        }
        return res;
    }
};

题目:翻转增益的最大子数组和

问题描述

给定一个数组,目标是找到两个不重叠的子数组,使它们的和最大。你可以选择一个子数组进行翻转,翻转后的数组中,两个子数组的和加起来得到的最大值就是答案。我们需要返回通过翻转操作后,可能获得的最大子数组和。

例如,考虑数组 1, 2, 3, -1, 4,我们可以选择翻转子数组 -1, 4 得到 1, 2, 3, 4, -1,或翻转 1, 2, 3, -1 得到 -1, 3, 2, 1, 4,在这两种情况下,最大的子数组和都是 10

输入

  • N:数组的长度。
  • data_array:一个长度为 N 的整数数组。

输出

  • 返回翻转操作后可能获得的最大子数组和。

示例

样例1:

输入:N = 5, data_array = [1, 2, 3, -1, 4]

输出:10

样例2:

输入:N = 4, data_array = [-3, -1, -2, 3]

输出:3

样例3:

输入:N = 3, data_array = [-1, -2, -3]

输出:-1

样例4:

输入:N = 6, data_array = [-5, -9, 6, 7, -6, 2]

输出:15

样例5:

输入:N = 7, data_array = [-8, -1, -2, -3, 4, -5, 6]

输出:10


解题思路

暴力解法

暴力解法的思路是:

  1. 首先计算原数组的最大子数组和,使用 Kadane 算法。
  2. 然后枚举所有可能的翻转区间 [i, j],对每个子区间进行翻转,并计算翻转后数组的最大子数组和。
  3. 更新全局最大值,返回结果。

步骤:

  1. 使用 Kadane 算法计算原数组的最大子数组和。
  2. 对于每个可能的子数组 [i, j],翻转该子数组并计算最大子数组和。
  3. 返回最大值。

暴力解法的时间复杂度为 O(N3)O(N^3),其中:

  • Kadane 算法的时间复杂度为 O(N)O(N)
  • 枚举翻转区间的时间复杂度为 O(N2)O(N^2)
#include <iostream>
#include <vector>
#include <algorithm>
#include <climits>

// 求最大子数组和的工具函数
int kadane(const std::vector<int>& arr) {
    int max_sum = INT_MIN, current_sum = 0;
    for (int num : arr) {
        current_sum = std::max(num, current_sum + num);
        max_sum = std::max(max_sum, current_sum);
    }
    return max_sum;
}

// 主函数
int solution(int N, std::vector<int>& data_array) {
    // 求原始数组的最大子数组和
    int max_sum = kadane(data_array);

    // 枚举翻转每一个子数组
    for (int i = 0; i < N; ++i) {
        for (int j = i; j < N; ++j) {
            // 翻转子数组 [i, j]
            std::vector<int> flipped_array = data_array;
            std::reverse(flipped_array.begin() + i, flipped_array.begin() + j + 1);

            // 计算翻转后的最大子数组和
            int flipped_sum = kadane(flipped_array);

            // 更新全局最大值
            max_sum = std::max(max_sum, flipped_sum);
        }
    }

    return max_sum;
}

int main() {
    // 测试用例
    std::vector<int> data1 = {1, 2, 3, -1, 4};
    std::vector<int> data2 = {-3, -1, -2, 3};
    std::vector<int> data3 = {-1, -2, -3};
    std::vector<int> data4 = {-5, -9, 6, 7, -6, 2};
    std::vector<int> data5 = {-8, -1, -2, -3, 4, -5, 6};
    
    std::cout << solution(5, data1) << std::endl; // 输出: 10
    std::cout << solution(4, data2) << std::endl; // 输出: 3
    std::cout << solution(3, data3) << std::endl; // 输出: -1
    std::cout << solution(6, data4) << std::endl; // 输出: 15
    std::cout << solution(7, data5) << std::endl; // 输出: 10

    return 0;
}

动态规划优化

为了优化暴力解法,我们可以使用动态规划,通过预处理最大子数组和来加速计算。

优化思路:

  1. 使用 Kadane 算法分别计算从左到右和从右到左的最大子数组和,存储在 leftMaxrightMax 数组中。
  2. 然后遍历所有可能的分割点,利用 leftMax[i-1]rightMax[i] 计算两个区间的和,更新最大值。

这种方法的时间复杂度为 O(N)O(N),因为我们只需要两次遍历数组一次计算 leftMaxrightMax,一次遍历计算最终的最大和。

#include <iostream>
#include <vector>
#include <climits>

int Kadane(const std::vector<int>& nums) {
    int max_normal_sum = INT_MIN, current_sum = 0;
    for (int num : nums) {
        current_sum = std::max(num, current_sum + num);
        max_normal_sum = std::max(max_normal_sum, current_sum);
    }
    return max_normal_sum;
}

int maxTwoSubArraySum(const std::vector<int>& nums) {
    int n = nums.size();
    if (n < 2) return INT_MIN;

    // Step 1: Compute leftMax
    std::vector<int> leftMax(n, 0);
    int currentSum = 0, maxSum = INT_MIN;
    for (int i = 0; i < n; ++i) {
        currentSum = std::max(nums[i], currentSum + nums[i]);
        maxSum = std::max(maxSum, currentSum);
        leftMax[i] = maxSum;
    }

    // Step 2: Compute rightMax
    std::vector<int> rightMax(n, 0);
    currentSum = 0, maxSum = INT_MIN;
    for (int i = n - 1; i >= 0; --i) {
        currentSum = std::max(nums[i], currentSum + nums[i]);
        maxSum = std::max(maxSum, currentSum);
        rightMax[i] = maxSum;
    }

    // Step 3: Find the maximum sum of two subarrays
    int result = INT_MIN;
    for (int i = 0; i < n - 1; ++i) {
        result = std::max(result, leftMax[i] + rightMax[i + 1]);
    }

    return result;
}

int solution(int N, const std::vector<int>& data_array) {
    if (N == 0) {
        return 0;
    }
    int max_normal_sum = Kadane(data_array);
    if (max_normal_sum < 0) {
        return max_normal_sum;
    }

    return max(max_normal_sum, maxTwoSubArraySum(data_array));
}

int main() {
    std::cout << (solution(5, {1, 2, 3, -1, 4}) == 10) << std::endl;
    std::cout << (solution(4, {-3, -1, -2, 3}) == 3) << std::endl;
    std::cout << (solution(3, {-1, -2, -3}) == -1) << std::endl;
    std::cout << (solution(6, {-5, -9, 6, 7, -6, 2}) == 15) << std::endl;
    std::cout << (solution(7, {-8, -1, -2, -3, 4, -5, 6}) == 10) << std::endl;

    return 0;
}

这道题通过翻转子数组并计算最大子数组和,要求我们找到通过翻转所能得到的最大结果。暴力解法采用了枚举所有子数组


总结

动态规划是解决最优化问题、组合计数问题、序列问题等的一种强大工具。通过识别问题是否具有最优子结构和重叠子问题特征,我们可以判断是否适用动态规划。掌握动态规划的解题步骤,理解状态转移方程,并结合具体问题进行实践,能够帮助我们高效地解决复杂的计算问题。