【CSP考题扩展】动态规划入门(1)

avatar
作者
筋斗云
阅读量:0

动态规划(Dynamic Programming,简称DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学等领域中使用的,用来解决特定类型问题的算法思想。其核心思想在于解决复杂问题的过程中,把问题分解为相对简单的子问题,通过解决这些子问题来解决整个问题。以下是动态规划的基本思想和特点的详细介绍:

一、基本思想

动态规划的基本思想是将复杂问题分解成小的、相互关联的子问题,通过解决这些子问题,最终解决原问题。这种方法的关键在于识别出子问题是重叠的,即不同的大问题在解决过程中会重复求解相同的小问题。动态规划通过储存这些子问题的解(通常是在数组或者其他数据结构中),使得每个子问题只被解决一次,从而减少了计算量。

特点

  1. 子问题重叠:在动态规划中,子问题通常不是完全独立的,即一个子问题的解可能会在解决其他子问题时重复使用。这与分治算法的子问题是完全独立的特点不同。

  2. 最优子结构:动态规划所处理的问题应具有最优子结构性质,即问题的最优解包含其子问题的最优解。换句话说,从底层子问题的最优解可以构造出原问题的最优解。

  3. 状态存储:动态规划算法通常使用一个数组或者其他数据结构来存储中间子问题的解,这个存储的结构称为“状态表”或“记忆表”。通过保存这些已经解决的子问题的答案,算法可以避免重复的计算工作。

  4. 状态转移方程:这是动态规划中最核心的概念之一。状态转移方程描述了子问题之间是如何相互转换的。换句话说,它定义了从一个子问题的解如何转变到另一个子问题的解。

  5. 边界条件的定义:在动态规划中,需要明确定义初始条件,即最基本的子问题的解,这有助于算法正确初始化并开始解决问题。

二、DP经典问题

1.数塔问题

问题描述

        7        3   8      8   1   0    2   7   4   4  4   5   2   6   5 
  • 有一个r行的数塔,数塔上有若干数字。问从数塔的最高点到底部,在所有的路径中,经过的数字的和最大为多少?
  • 如上图,是一个5行的数塔,其中7—3—8—7—5的路径经过数字和最大,为30。

解题思路

  1. 朴素递归:从塔顶开始,对每一步可能到达的两个数字进行递归,寻找最大路径。这样的复杂度非常高,为 O ( 2 n ) O(2^n) O(2n),因为有许多重复的子问题被多次计算。

  2. 动态规划的解决方法: 动态规划的方法避免了重复计算相同的子问题。这可以通过创建一个二维数组dp来实现,其中dp[i][j]代表从塔顶到达第i行第j个数字时可能获得的最大总和。

    状态转移方程是:
    d p [ i ] [ j ] = m a x ( d p [ i − 1 ] [ j ] , d p [ i − 1 ] [ j − 1 ] ) + n u m [ i ] [ j ] dp[i][j] = max(dp[i-1][j], dp[i-1][j-1]) + num[i][j] dp[i][j]=max(dp[i1][j],dp[i1][j1])+num[i][j]

    表明当前位置的最大路径和等于其正上方和左上方两个位置的最大路径和中的较大者加上当前位置的数字。通过这样的方式,我们可以填充整个dp数组,数组的最底行中的最大值就是整个数塔问题的解。

结论

自顶向下的分析帮助我们理解问题和设计算法,而自底向上的计算使我们能够高效地实现算法。这种结合了理论分析和实际计算优势的方法,是动态规划算法设计中的一个核心原则。

1. 自顶向下的分析
  • 问题分解:从最终的目标出发,识别出要解决的问题可以分解为哪些子问题。这有助于我们理解问题的结构和各个子问题之间的关系。
  • 边界识别:明确问题的边界条件,这些是直接可知的解,通常是递归的基准情形。
  • 状态定义:明确状态的定义,即我们需要存储的子问题的解的形式。
  • 转移方程:根据问题的性质,定义状态转移方程,即如何从一个或多个较小的子问题的解得到当前问题的解。

通过自顶向下的分析,我们可以构建出一个清晰的动态规划模型,这是设计动态规划算法的关键步骤。我们通过分析来识别所有必要的组件,如状态和状态转移方程。

2. 自底向上的计算
  • 计算效率:动态规划的一个优势是减少重复计算,自底向上的计算正是通过填充已知的基础情况(边界条件)开始,逐步构建向上的解,确保每个子问题只计算一次。
  • 避免递归调用:虽然自顶向下的递归方法可以解决许多问题,但它可能会导致大量的重复计算和栈溢出。自底向上的迭代方法不需要栈调用,因此可以处理更大的问题实例,且更为高效。
  • 存储优化:在自底向上的计算过程中,我们可以更容易地实施存储优化措施,比如使用滚动数组来节约空间复杂度。

代码实现

#include <iostream> #include <vector> using namespace std;  int main() {     vector<vector<int>> num = {         {7},         {3, 8},         {8, 1, 0},         {2, 7, 4, 4},         {4, 5, 2, 6, 5}     };          int n = num.size(); // 塔的层数,可以通过num的大小动态确定     vector<vector<int>> dp(n, vector<int>(n, 0)); // 创建一个二维dp数组,用于存储到达每个位置的最大路径和      for (int i = 0; i < n; ++i) // 初始化dp数组的最底层     {         dp[n - 1][i] = num[n - 1][i];     }      // 自底向上计算路径最大和     for (int i = n - 2; i >= 0; --i) { // 从倒数第二层开始往上计算         for (int j = 0; j <= i; ++j) { // 第i层有i+1个数字             // 计算到达当前位置的最大路径和             dp[i][j] = max(dp[i + 1][j], dp[i + 1][j + 1]) + num[i][j];         }     }      cout << "The maximum sum path is: " << dp[0][0] << endl;      return 0; } 

2.数塔问题(扩展)——免费馅饼

问题描述

有一条10米长的小路,以小路起点为x轴的原点,小路终点为x=10,则共有x=0~10共计11个坐标点。(如下图)
请添加图片描述
接下来的n行每行有两个整数 x , T x,T x,T 表示一个馅饼将在第T秒掉落在坐标 x x x 上。同一秒在同一点上可能掉落有多个馅饼。
初始时你站在 x = 5 x=5 x=5 上,每秒可移动1m,最多可接到所在位置1m范围内某一点上的馅饼。比如你站在 x = 5 x=5 x=5 上,就可以接到 x = 4 , 5 , 6 x=4,5,6 x=4,5,6 其中一点上的所有馅饼。
问你最多可接到多少个馅饼?

样例

6 (表示有6个馅饼) 5 1(在第1s,有一个馅饼掉落在x=5上) 4 1(在第1s,有一个馅饼掉落在x=4上) 6 1 7 2(在第2s,有一个馅饼掉落在x=7上) 7 2(同1s可能有多个馅饼掉落在同一点上) 8 3 

样例中最多可接到4个馅饼。其中一种接法是:第1s接住 x = 5 x=5 x=5 的一个馅饼,第2s移动到 x = 6 x=6 x=6,接住 x = 7 x=7 x=7上的两个馅饼,第3s移动到 x = 7 x=7 x=7,接住 x = 8 x=8 x=8的一个馅饼,共计4个馅饼。

解题思路

这个问题可以看作是一种特殊的动态规划问题,与数塔问题相似,不过这次我们不是在寻找从顶部到底部的最大路径,而是寻找能够接到最多馅饼的路径。我们可以将每一秒钟的可能位置看作状态,然后计算每个状态可以接到的最大馅饼数量。

解题思路分为几个步骤:

  1. 状态定义: 定义 d p [ T ] [ x ] dp[T][x] dp[T][x] 表示在第 T T T 秒时,站在位置 x x x 可以接到的最大馅饼数。这是我们要填充的动态规划表。

  2. 状态转移:考虑到每一秒我们可以移动至多1m,因此对于 d p [ T ] [ x ] dp[T][x] dp[T][x],我们可以从 d p [ T − 1 ] [ x − 1 ] dp[T-1][x-1] dp[T1][x1] d p [ T − 1 ] [ x ] dp[T-1][x] dp[T1][x] d p [ T − 1 ] [ x + 1 ] dp[T-1][x+1] dp[T1][x+1] 这三个状态中的任何一个转移过来(如果存在),因为这代表了我们上一秒可能的位置。转移方程为:
    d p [ T ] [ x ] = m a x ( d p [ T − 1 ] [ x − 1 ] , d p [ T − 1 ] [ x ] , d p [ T − 1 ] [ x + 1 ] ) + p i e s [ T ] [ x ] dp[T][x] = max(dp[T-1][x-1], dp[T-1][x], dp[T-1][x+1]) + pies[T][x] dp[T][x]=max(dp[T1][x1],dp[T1][x],dp[T1][x+1])+pies[T][x]
    其中 p i e s [ T ] [ x ] pies[T][x] pies[T][x] 是在第 T T T x x x 位置掉落的馅饼数。

  3. 初始化:初始时刻( T = 0 T=0 T=0)我们站在 x = 5 x=5 x=5 上,所以 d p [ 0 ] [ 5 ] dp[0][5] dp[0][5] 初始为0(假设在第0秒没有馅饼掉落),其他位置初始化为负无穷,代表不可能到达的状态。

  4. 边界情况:在实际计算中,需要考虑边界情况,即当 x = 0 x=0 x=0 x = 10 x=10 x=10 时,我们只能从 x = 1 x=1 x=1 x = 9 x=9 x=9 移动过来,这需要特殊处理。

  5. 填表计算: 按照时间的顺序,从 T = 1 T=1 T=1 开始依次计算 d p [ T ] [ x ] dp[T][x] dp[T][x] 的值,直到最后一秒。

  6. 结果输出:在最后一秒的所有 d p [ T ] [ x ] dp[T][x] dp[T][x] 值中找到最大值,即为我们能接到的最大馅饼数。

这个问题与数塔问题最大的不同在于,数塔问题是一个严格的结构,而这个问题中每一秒的馅饼掉落情况都是动态变化的,我们需要根据输入的数据来动态更新我们的 d p dp dp 表。此外,我们的移动受到限制,每秒只能移动至多1m,这也需要在状态转移时考虑进去。

解题代码

#include <iostream> #include <vector> #include <algorithm> using namespace std;  int main() {     int n;     cin >> n;      vector<vector<int>> dp(100005, vector<int>(20, 0));     int maxT = 0; // 记录最大的时间,以知道需要计算的秒数      for (int i = 0; i < n; ++i) // 读取馅饼信息,并记录到对应的位置和时间上     {         int x, T;         cin >> x >> T;         dp[T][x + 1]++;         maxT = max(maxT, T);     }      for (int i = maxT - 1; i >= 0; i--) // 自底向上(从倒数第二层开始)     {         for (int j = 1; j <= 11; j++) // 无需边界处理,因为边界被初始化为0不影响结果         {             dp[i][j] = dp[i][j] + max(max(dp[i + 1][j], dp[i + 1][j + 1]), dp[i + 1][j - 1]); // 三者取最大         }     }     cout << "The maximum number of pies caught is: " << dp[0][6] << endl;      return 0; } 

广告一刻

为您即时展示最新活动产品广告消息,让您随时掌握产品活动新动态!