动态规划(详解)

动态规划

目录

动态规划

文章目录

1. 摘要

2. 引言

3. 动态规划的基本原理

文章目录

1. 最优子结构

2. 重叠子问题

4. 动态规划的应用案例

文章目录

1. 01 背包问题:

2. 最长公共子序列

3. 最短路径问题

5. 动态规划优化与扩展

文章目录:

1. 状态压缩技巧

2. 多阶段决策问题

6. 例题讲解

文章目录:

2. P1091 [NOIP2004 提高组] 合唱队形

题目传送门

3. P1435 [IOI2000] 回文字串

7. 结论


文章目录


             1. 摘要
             2. 引言
             3. 动态规划的基本原理
             4. 动态规划的应用案例
             5. 动态规划优化与扩展
             6. 结论
             7. 例题讲解


1. 摘要


动态规划是一种高效解决问题的算法思想,在计算机科学和优化领域有着广泛的应用。本文将深入探讨动态规划的基本概念、核心特性以及在不同领域的实际应用案例。我们将揭示动态规划的奥秘,解析最优子结构的精妙之处,帮助我们更好地理解和运用这一强大的算法工具。

2. 引言


动态规划就如同他的名字,其基本思想就是将复杂问题分解为简单子问题,并通过保存子问题的解来避免重复计算,从而提高算法效率。


3. 动态规划的基本原理


文章目录


             1. 最优子结构
             2. 重叠子问题


1. 最优子结构


解决动态规划问题的关键在于找到最优子结构。最优子结构是指一个问题的整体最优解可以通过利用其子问题的最优解来获得。换句话说,问题的最优解包含了其子问题的最优解。

在动态规划中,我们通常将原问题分解为若干个子问题,并从底层的子问题开始逐步解决,将子问题的解保存起来,供后续使用。这种自底向上的解决方式可以避免重复计算,从而大幅提高算法效率。

为了利用最优子结构,我们需要满足两个基本条件:

1. 问题可以被分解为子问题:原问题可以被划分为若干个相似的、但规模较小的子问题。这些子问题在形式上和原问题是相同的,只是规模更小。

2. 子问题的最优解能构成原问题的最优解:原问题的最优解可以通过利用子问题的最优解来获得。这就是最优子结构的核心概念。

通过满足最优子结构的条件,我们可以使用递归或迭代的方式来求解问题。在递归求解时,我们先解决子问题,然后将子问题的解组合成原问题的解。在迭代求解时,我们从子问题的最小规模开始解决,逐步扩展到原问题的规模,并保存子问题的解,以供后续使用。

举个例子:

考虑一个经典的动态规划问题:斐波那契数列。斐波那契数列的定义如下:

F(0) = 0

F(1) = 1

F(n) = F(n-1) + F(n-2), 当 $n \le 2$

在这个问题中,我们可以看到每个斐波那契数都可以由它前面的两个数推导得出。这就是最优子结构的体现。比如,要求 $F(5)$,我们可以通过先求解 $F(4)$ 和 $F(3)$,然后将它们相加得到 $F(5)$。这样的求解过程可以一直递归下去,直到问题规模缩小到 $F(1)$$F(0)$,它们是递归的基本情况。

通过保存 $F(0)$ 到 $F(4)$ 的解,我们可以避免在计算 $F(5)$ 时重复计算子问题,从而提高算法的效率。

2. 重叠子问题


动态规划的另一个关键特性是重叠子问题,重叠子问题是指在递归求解过程中,同一个子问题被多次重复计算。这些子问题的解是相同的,但由于没有进行记忆化处理,导致在每次遇到相同子问题时都需要重新计算一次,造成了不必要的重复工作。

重叠子问题是动态规划效率低下的一个主要原因。如果我们能够避免重复计算相同的子问题,动态规划算法的执行效率将大大提高。

为了解决重叠子问题,通常采用两种主要的方法:

1. 备忘录法($Memoization$):备忘录法是一种自顶向下的解决方案,使用一个数据结构(如数组或哈希表)来保存已经计算过的子问题的解。在每次递归求解子问题之前,先检查备忘录中是否已经存在该子问题的解。如果已经计算过,直接返回备忘录中的解,避免重复计算。如果尚未计算过,进行递归计算,并将解保存在备忘录中。备忘录法能够有效地避免重复计算,提高动态规划算法的效率。

2. 自底向上的迭代方法:
    自底向上的迭代方法也被称为“$Bottom-up$”方法。该方法从子问题的最小规模开始解决,逐步扩展到原问题的规模。在迭代的过程中,保存子问题的解,以供后续使用。由于是自底向上的计算过程,每个子问题只需要计算一次,避免了重复计算的问题。最终,我们可以得到原问题的解。

举例说明重叠子问题:

考虑计算斐波那契数列的例子:

F(0) = 0

F(1) = 1

F(n) = F(n-1) + F(n-2), 当 $n \le 2$

在使用递归求解斐波那契数列时,如果不采取任何优化措施,将会出现重叠子问题。比如,要计算 $F(5)$,需要先计算 $F(4)$ 和 $F(3)$;而计算 $F(4)$ 需要先计算 $F(3)$ 和 $F(2)$,计算 $F(3)$ 又需要计算 $F(2)$$F(1)$,这里的 $F(3)$ 就重复计算了两次。

通过备忘录法或自底向上的迭代方法,我们可以避免重复计算。在备忘录法中,我们可以创建一个数组或哈希表,将已经计算过的 $F(n)$ 的值保存下来,在每次需要计算时先查找备忘录中是否已经有解,如果有,直接返回,避免重复计算。在自底向上的迭代方法中,我们从 $F(0)$ 和 $F(1)$ 开始计算,依次迭代计算 $F(2)$$F(3)$$F(4)$ 等,直到得到 $F(5)$ 的解。

4. 动态规划的应用案例


文章目录


             1. 背包问题
             2. 最长公共子序列
             3. 最短路径问题


1. 01 背包问题:


背包问题是动态规划中的一个经典应用,涉及在给定容量的背包中如何装入最有价值的物品。通常,每个物品都有自己的重量和价值,背包有一定的容量限制。目标是在不超过背包容量的前提下,使装入背包的物品总价值最大化。

背包问题可以分为两种主要类型:01 背包问题和无限背包问题。

1. 01 背包问题如同标题,就是要求每个物品要么装入背包,要么不装入,即每个物品的选择只有两种情况:选或不选。一旦选择了某个物品放入背包,就不能再次选择放入。

形式化定义如下:给定 n 个物品,其重量分别为 $w_1$,$w_2$, ... , $w_n$,价值分别为 $v_1$,$v_2$, ... ,$v_n$,以及背包的容量 $C$。求解在背包容量不超过 $C$ 的情况下,如何选择物品装入背包,使得装入背包的物品总价值最大。

举个例子:
假设有一个背包容量为 $C = 10$,现在有 $5$ 个物品,它们的重量和价值分别如下:

背包最大承受重量:$10$

物品1:重量$w1$ = $2$,价值 $v1$ = $6$

物品2:重量 $w2$ = $2$,价值 $v2$ = 10

物品3:重量 $w3$ = $3$,价值 $v3$ = $12$

物品4:重量 $w4$ = $4$,价值 $v4$ = $8$

物品5:重量 $w5$ = $5$,价值 $v5$ = $15$

现在我们要求在背包容量为 10 的情况下,如何选择物品放入背包,使得背包中物品的总价值最大。

解决方法:

我们可以使用动态规划来解决这个问题。我们可以创建一个二维数组 dp,其中 $dp_{i,j}$ 表示在前 i 个物品中选择,在背包容量为 $j$ 的情况下,所能获得的最大价值。

状态转移方程如下:
$dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])$
接下来,我们可以依次填充 dp 数组。首先考虑只有第一个物品的情况($i = 1$):

dp[1][0] = 0,表示在背包容量为0的情况下,不选第一个物品的最大价值为0
dp[1][1] = 0,表示在背包容量为1的情况下,不选第一个物品的最大价值为0
dp[1][2] = 6,表示在背包容量为2的情况下,选择第一个物品的最大价值为6
dp[1][3] = 6,表示在背包容量为3的情况下,选择第一个物品的最大价值为6
...
dp[1][10] = 6,表示在背包容量为10的情况下,选择第一个物品的最大价值为6


接着,我们考虑第二个物品($i = 2$):

dp[2][0] = 0,表示在背包容量为0的情况下,不选前两个物品的最大价值为0
dp[2][1] = 0,表示在背包容量为1的情况下,不选前两个物品的最大价值为0
dp[2][2] = 6,表示在背包容量为2的情况下,选择第一个物品的最大价值为6
dp[2][3] = 10,表示在背包容量为3的情况下,选择第二个物品的最大价值为10
...
dp[2][10] = 16,表示在背包容量为10的情况下,选择第二个物品的最大价值为16


依次类推,我们可以填充完整个 dp 数组。最后,$dp_{5,10}$ 的值就是我们所求的问题的最优解,即在背包容量为 $10$ 的情况下,选择物品 $2$$3$ 和 $5$ 可以获得最大价值,总价值为 $12 + 12 + 15 = 39$

所以,通过动态规划算法,我们得到了在背包容量为 $10$ 时的最优选择方案,它们是物品 $2$$3$$5$,总价值为 $39$

2. 完全背包问题:完全背包问题允许每个物品可以选择无限次放入背包,即每个物品的选择有无限个。这就意味着我们可以重复选择某个物品放入背包,直到超过背包的容量限制。

形式化定义如下:给定 n 个物品,其重量分别为 $w_1$,$w_2$, ... , $w_n$,价值分别为 $v_1$,$v_2$, ... , $v_n$,以及背包的容量 $C$。求解在背包容量不超过 $C$ 的情况下,如何选择物品放入背包,使得装入背包的物品总价值最大。

解决方法:

$01$ 背包问题的动态规划解法:

我们可以使用一个二维数组 $dp$ 来表示状态转移表。$dp[i][j]$ 表示在前 $i$ 个物品中选择,在背包容量为 j 的情况下,所能获得的最大价值。状态转移方程如下:

$dp[i][j] = max(dp[i-1][j], dp[i-1][j-w[i]] + v[i])$

其中,$dp_{i-1,j}$ 表示不选第 i 个物品,$dp_{i,j-w_{i}+v_{i}}$ 表示选第 $i$ 个物品。我们在遍历物品和背包容量的循环中填充 $dp$ 数组,最终得到 $dp _ {N,C}$ 即为问题的最优解。

完全背包问题的动态规划解法:

与 01 背包问题类似,我们同样使用一个二维数组 $dp$ 来表示状态转移表。$dp_{i,j}$ 表示在前 i 个物品中选择,在背包容量为 j 的情况下,所能获得的最大价值。状态转移方程如下:

$dp[i][j] = max(dp[i-1][j], dp[i][j-w[i]] + v[i])$

其中,$dp_{i-1,j}$ 表示不选第 $i$ 个物品,$dp_{i,j-w_{i}+v_{i}}$ 表示选择第 $i$ 个物品。与 01 背包问题不同的是,这里的第一个维度 $i$ 可以重复,表示我们可以反复选择第 $i$ 个物品。在遍历物品和背包容量的循环中填充 $dp$ 数组,最终得到 $dp _ {N,C}$ 即为问题的最优解。

具体也可以看我之前写的01背包

2. 最长公共子序列


最长公共子序列($Longest$ $Common$  $Subsequence$,简称 $LCS$)是动态规划中的一个经典应用,用于寻找两个序列(可以是字符串、数组等)中最长的共同子序列。这个问题在字符串处理和文本相似性比较等领域有着广泛的应用。

形式化定义:

给定两个序列 $X$ 和 $Y$,它们的长度分别为 m 和 n。一个序列 Z 被称为 $X$ 和 $Y$ 的公共子序列,如果 Z 既是 $X$ 的子序列,又是 $Y$ 的子序列。最长公共子序列是 $X$ 和 $Y$ 的所有公共子序列中,最长的一个。

举例说明:

假设有两个字符串 $X$ 和 $Y$

X = "AGGTAB"

Y = "GXTXAYB"

我们要找出 $X$ 和 $Y$ 的最长公共子序列。

解决方法:

动态规划是解决最长公共子序列问题的常用方法。我们可以创建一个二维数组 $dp$,其中 $dp_{i,j}$ 表示 $X$ 的前 i 个字符和 $Y$ 的前 j 个字符的最长公共子序列的长度。

状态转移方程如下:

if (X[i] == Y[j])
    dp[i][j] = dp[i-1][j-1] + 1
else
    dp[i][j] = max(dp[i-1][j], dp[i][j-1])


解释一下这个状态转移方程:当 $X$ 的第 i 个字符等于 $Y$ 的第 j 个字符时,最长公共子序列长度在原来的基础上加 1。当 $X$ 的第 i 个字符不等于 $Y$ 的第 j 个字符时,最长公共子序列的长度取决于 $X$ 的前 i-1 个字符和 $Y$ 的前 j 个字符的最长公共子序列长度,以及 $X$ 的前 i 个字符和 $Y$ 的前 $j-1$ 个字符的最长公共子序列长度,取两者的较大值。

接下来,我们可以依次填充 $dp$ 数组。首先,考虑边界情况,当 $i = 0$ 或 $j = 0$ 时,$dp_{i,j}$0,因为其中一个序列为空,它们的最长公共子序列长度必然为 0

然后,我们从 $i = 1$ 和 $j = 1$ 开始遍历,根据状态转移方程填充 $dp$ 数组。最后,$dp_{m,n}$ 的值就是 $X$ 和 $Y$ 的最长公共子序列的长度。

例如,在上面给定的例子中,填充 $dp$ 数组后如下所示:

  G  X  T  X  A  Y  B
A 0  0  0  0  1  1  1
G 1  1  1  1  1  1  1
G 1  1  1  1  1  1  1
T 1  1  2  2  2  2  2
A 1  1  2  2  3  3  3
B 1  1  2  2  3  3  4

所以,在 $X$ 和 $Y$ 的最长公共子序列问题中,"$GTAB$"是一个最长公共子序列,长度为 $4$

3. 最短路径问题


最短路径问题是动态规划中的另一个重要应用,它用于在加权图中寻找两个节点之间的最短路径。在这个问题中,每个边都有一个权重或者距离,我们的目标是找到从一个节点到另一个节点的路径,使得路径上所有边的权重之和最小。

最短路径问题有多种变种,其中最常见的有单源最短路径问题和多源最短路径问题。

单源最短路径问题:
单源最短路径问题是指在一个加权图中,给定一个起始节点,求解该节点到图中所有其他节点的最短路径。
最经典的单源最短路径算法是 $Dijkstra$ 算法,它使用贪心策略逐步确定从起始节点到其他节点的最短路径。$Dijkstra$ 算法在每一步中选择当前最短距离的节点,并更新与该节点相邻的节点的最短距离。通过不断迭代,直到所有节点的最短路径都被确定。

多源最短路径问题:
多源最短路径问题是指在一个加权图中,给定多个起始节点,求解每对起始节点之间的最短路径。
最常用的多源最短路径算法是 $Floyd-Warshall$ 算法,它使用动态规划的思想来求解任意两个节点之间的最短路径。$Floyd-Warshall$ 算法通过一个二维数组 dp,其中 $dp_{i,j}$ 表示节点 i 到节点 j 的最短路径的权重。算法的核心是三重循环,通过遍历每个节点作为中间节点,逐步更新 dp 数组,直到所有节点之间的最短路径都被确定。

应用举例:

假设有一个有向加权图,如下所示:

   2   3
A ---> B ---> C
 \    |    /
  \   |   /
   \  v  /
     D
      ^
      |
      5


图中的边上标记的数字表示边的权重或距离。例如,从节点 $A$ 到节点 $C$ 有两条路径:$A -> B -> C$ 的权重为 $3$$A -> D -> B -> C$ 的权重为 $2+5+3=10$。我们的目标是找到从节点 $A$ 到节点 $C$ 的最短路径,即 $A -> B -> C$ 的路径。

通过 $Dijkstra$ 算法或 $Floyd-Warshall$ 算法,我们可以求解最短路径问题,得到节点 $A$ 到节点 $C$ 的最短路径为 $A -> B -> C$,权重为 $3$

5. 动态规划优化与扩展


文章目录:

  1. 状态压缩技巧
  2. 多阶段决策问题

1. 状态压缩技巧


在动态规划中,状态压缩技巧是一种优化方法,用于将二维动态规划优化为一维,从而减少空间复杂度和提高算法执行速度。状态压缩通常适用于某些特定类型的问题,比如多维背包问题、旅行商问题等。

动态规划中的状态通常是由多个变量组成的,每个变量都有不同的取值范围。如果直接使用二维数组来表示状态转移表,空间复杂度会随着状态变量的增加而增加,导致空间开销较大。而状态压缩技巧通过巧妙地将多维状态压缩成一维,从而减少空间开销。

状态压缩的关键在于如何将多维状态映射为一维。一种常见的状态压缩方法是使用位运算来表示状态。对于每个状态变量,我们可以使用一个整数的二进制位来表示其取值。如果该状态变量有 $n$ 种取值,我们可以使用一个 $n$ 位的二进制数来表示。这样,整个多维状态就可以用一个整数来表示,从而实现状态压缩。

状态压缩的一般步骤如下:

确定状态变量:首先需要确定问题中的状态变量,即决定了问题的解空间。状态变量通常是与问题的特定条件和约束相关的,例如背包问题中的物品数量、旅行商问题中的访问城市状态等。

映射状态:对于每个状态变量,使用二进制数来表示其取值,并将多个状态变量组合成一个整数,形成状态压缩后的一维状态。

动态规划转移:在状态压缩后,我们可以使用一维数组来表示状态转移表,并且通过位运算来更新状态值。

举例说明:

考虑一个简化的旅行商问题,有 4 个城市(编号为 01$2$3),要求从城市 0 出发,遍历所有城市并回到城市 0,使得总旅行距离最小。城市之间的距离用一个二维数组 $dist_{i,j}$ 表示。

传统的动态规划方法会使用一个二维数组 $dp_{i,j}$,其中 i 表示状态,$j$ 表示最后一个访问的城市。状态 $i$ 是一个二进制数,用来表示已经访问过的城市。例如,$i = 5$ 表示已经访问了城市 $0$ 和城市 2

通过状态压缩技巧,我们可以将二维数组 $dp$ 优化为一个一维数组,用长度为 $2^n$ 的一维数组 $dp[mask]$ 表示状态压缩后的状态。其中 $mask$ 是一个二进制数,表示访问过的城市集合。例如,$mask = 5$ 表示已经访问了城市 0 和城市 $2$

状态转移方程如下:

$dp[mask][i] = min(dp[mask xor (1<<i)][j] + dist[j][i])$
,其中 $j$ 表示 $mask$ 中已经访问的最后一个城市
在状态压缩后,我们只需要一个长度为 $2^n$ 的一维数组 $dp$,就可以完成状态转移,并求得最优解。

通过状态压缩技巧,代码减少了空间复杂度,并且在一些特定问题中大幅提高动态规划算法的执行速度。但需要注意的是,状态压缩并不适用于所有类型的动态规划问题,它仅适用于一些满足特定条件的问题。因此,在解决问题时,需要根据问题的特点和状态变量的情况来决定是否使用状态压缩技巧。

2. 多阶段决策问题


多阶段决策问题是动态规划中的一类扩展问题,它涉及到多个决策阶段的决策过程。在这类问题中,我们需要在多个决策阶段做出一系列决策,每个决策会影响到后续决策,并最终导致问题的最优解。

多阶段决策问题通常可以用有向无环图(Directed Acyclic Graph,简称DAG)来表示。图中的每个节点表示一个决策阶段,边表示决策阶段之间的转移和影响关系。问题的目标是找到从初始决策阶段到最终决策阶段的一条路径,使得路径上的决策使得问题的目标函数最优。

解决方法:

解决多阶段决策问题通常采用动态规划算法。这里我们可以使用自底向上的方式进行求解。

1. 状态定义:我们需要定义动态规划的状态。在多阶段决策问题中,状态通常与决策阶段和问题的特定约束相关。我们可以使用状态数组 dp[i] 表示在第 i 个决策阶段的最优决策值。

2. 状态转移方程:根据问题的特点,我们需要确定状态之间的转移关系。这里的状态转移方程通常涉及到前一个决策阶段的最优决策值和当前决策阶段的决策值。

3. 初始化:在动态规划的起始阶段,我们需要初始化状态数组。通常情况下,初始决策阶段的状态是已知的,我们可以利用这些已知信息来初始化状态数组。

4. 自底向上求解:在动态规划中,我们一般从较小规模的子问题开始,逐步递推求解更大规模的问题,直到求解出整个问题的最优解。

应用举例:

考虑一个多阶段决策问题:某项目有 $3$ 个阶段(阶段 $1$、阶段 $2$ 和阶段 $3$),每个阶段都有若干任务可供选择。每个任务都有不同的收益和时间开销。目标是选择一系列任务,使得项目的总收益最大,同时保证任务的时间开销不超过预算。

该问题可以用一个有向无环图来表示,图中的每个节点表示一个阶段,节点之间的边表示任务的选择。

解决这个问题时,我们可以使用动态规划算法。假设 $dp_{i}$ 表示在第 $i$ 个阶段的最大收益,那么我们可以根据前一个阶段的最优收益和当前阶段的任务收益来更新 $dp$ 数组。

状态转移方程如下:
 

dp[i] = max(dp[i], dp[j] + 收益[i]),其中j表示在第i个阶段选择任务后影响收益的前一个阶段



通过自底向上的动态规划求解,最终我们可以得到问题的最优解,即项目的最大收益。

6. 例题讲解


文章目录:


             1. P1020 [NOIP1999 普及组] 导弹拦截
             2. P1091 [NOIP2004 提高组] 合唱队形
             3. P1435 [IOI2000] 回文字串


1. P1020 [NOIP1999 普及组] 导弹拦截
题目传送门

第一问
将拦截的导弹的高度提出来成为原高度序列的一个子序列,根据题意这个子序列中的元素是单调不增的(即后一项总是不大于前一项),我们称为单调不升子序列。本问所求能拦截到的最多的导弹,即求最长的单调不升子序列。

考虑记 $dp_{i}$
  表示「对于前 i 个数,在选择第 i 个数的情况下,得到的单调不升子序列的长度最长是多少」。于是可以分两种情况:

i 个数是子序列的第一项。则 $\mathit{dp}_ i\gets 1$
i 个数不是子序列的第一项。选择的第 i 个数之前选择了第 j 个数。根据题意,第 j 个数的值 $h(j)$ 应当小于第 $i$ 个数的值 $h(i)$。枚举这样的 $j$,可以得到状态转移方程:

$\mathit{dp}_i=\max_{j<i,h(j)\ge h(i)} \{\mathit{dp_j}+1\}$

综合这两种情况,得到最终的状态转移方程:

$\mathit{dp}_i=\max\{1,\max_{j<i,h(j)\ge h(i)}\{\mathit{dp}_j+1\}\}$


值得注意的是,第 $n$ 个数不一定是最长单调不升子序列的最后一项。为了求出答案,我们需要枚举最后一项是哪个:

$\mathit{ans}=\max_{1\le i\le n}\{\mathit{dp}_i\}$

直接枚举进行状态转移,时间复杂度显然是 $\mathcal O(n^2)$。 下面考虑优化。

记 $f_i$ 表示「对于所有长度为 $i$ 的单调不升子序列,它的最后一项的大小」的最大值。特别地,若不存在则 $f_i=0$。下面证明:

随 $i$ 增大,$f_i$
  单调不增。即 $f_i\ge f_{i+1}$
考虑使用反证法。假设存在 $u<v$,满足 $f_u<f_v$。考虑长度为 $v$ 的单调不升子序列,根据定义它以 $f_v$
  结尾。显然我们可以从该序列中挑选出一个长度为 $u$ 的单调不升子序列,它的结尾同样是 $f_v$
 那么由于 $f_v>f_u$
 ,与 $f_u$
  最大相矛盾,得出矛盾。

因此 $f_i$ 应该是单调不增的。

现在考虑以 $i$ 结尾的单调不升子序列的长度的最大值 $\mathit{dp}_i$。由于我们需要计算所有满足 h(j)>h(i) 的 j 中,\mathit{dp}_j
  的最大值,不妨考虑这 \mathit{dp}_j 的值是啥。设 $\mathit{dp}_j=x$,那么如果 $h(i)> f_x$,由于 $f_x\ge h(j)$,就有 $h(i)>h(j)$,矛盾,因此总有 $h(i)\le f_x$

根据刚刚得出的结论,$f_i$
  单调不增,因此我们要找到尽可能大的 x 满足 h(i)\le f_x
 。考虑二分。

绿色区域表示合法的 f_x
 (即 f_x\ge h(i)),红色区域表示不合法的 f_x
 (即 f_x < h(i)),我们需要找到红绿之间的交界点。

假设二分区域为 [l,r)(注意开闭区间。图上黄色区域标出来了二分区域内实际有效的元素)。每次取 m=\frac{l+r}{2} ,如果 f_m
  在绿色区域内,我们就把 l 移动到此处(l\gets m);否则把 r 移动到此处(r \gets m)。

r-l=1 时,l 处位置即为我们需要找的位置。转移 \mathit{dp}_i\gets l+1 即可。记得更新 f。但是我们只用更新 f_{\mathit{dp}_i} ,这是因为 f_1,f_2,\cdots f_{\mathit{dp_i}-1}
  的大小肯定都是不小于 h(i) 的。f_{\mathit{dp}_i}
  是最后一个不小于 h(i) 的位置,f_{\mathit{dp}_i+1} 则小于 h(i)

时间复杂度 \mathcal O(n\log n),可以通过该问。

第二问
考虑贪心。

从左到右依次枚举每个导弹。假设现在有若干个导弹拦截系统可以拦截它,那么我们肯定选择这些系统当中位置最低的那一个。如果不存在任何一个导弹拦截系统可以拦截它,那我们只能新加一个系统了。

假设枚举到第 i 个导弹时,有 m 个系统。我们把这些系统的高度按照从小到大排列,依次记为 g_1,g_2,\cdots g_m
 。容易发现我们就是要找到最小的 g_x
  满足 g_x\ge h_i
 (与第一问相同,这是可以二分得到的),然后更新 g_x
  的值。更新之后,g_1,g_2\cdots g_x
  显然还是单调不增的,因此不用重新排序;如果找不到符合要求的导弹拦截系统,那就说明 g_m<h_i
 ,直接在后头增加一个就行。

时间复杂度 \mathcal O(n\log n),可以通过该问。

参考代码:

#include<bits/stdc++.h>
#define up(l,r,i) for(int i=l,END##i=r;i<=END##i;++i)
#define dn(r,l,i) for(int i=r,END##i=l;i>=END##i;--i)
using namespace std;
typedef long long i64;
const int INF =2147483647;
const int MAXN=1e5+3;
int n,t,H[MAXN],F[MAXN];
int main(){
    while(~scanf("%d",&H[++n])); --n;
    t=0,memset(F,0,sizeof(F)),F[0]=INF;
    up(1,n,i){
        int l=0,r=t+1; while(r-l>1){
            int m=l+(r-l)/2;
            if(F[m]>=H[i]) l=m; else r=m;
        }
        int x=l+1;  // dp[i]
        if(x>t) t=x; F[x]=H[i];
    }
    printf("%d\n",t);
    t=0,memset(F,0,sizeof(F)),F[0]=0;
    up(1,n,i){
        int l=0,r=t+1; while(r-l>1){
            int m=l+(r-l)/2;
            if(F[m]<H[i]) l=m; else r=m;
        }
        int x=l+1;
        if(x>t) t=x; F[x]=H[i];
    }
    printf("%d\n",t);
    return 0;
}


观察第二问的代码,与第一问进行比较,可以发现这段代码等价于计算最长上升子序列(严格上升,即后一项大于前一项)。这其实是 \text{Dilworth} 定理(将一个序列剖成若干个单调不升子序列的最小个数等于该序列最长上升子序列的个数),本处从代码角度证明了该结论。

2. P1091 [NOIP2004 提高组] 合唱队形

题目传送门

题意分析
题目中要求的“合唱队形”满足的要求其实就是:t_1, t_2, t_3, \dots t_i
  成上升序列,t_i, t_{i + 1}, t_{i + 2}\dots t_n
  成下降序列。题目中要求算出最少出列人数,其实就是要求哪个 i 能使得以 a_i
  为结尾的最长上升子序列和最长下降子序列之和最大。

做法
这道题想要用 O(n \log n) 的复杂度做,首先要用 O(n \log n) 求出最长上升子序列(下面简称 LIS)的长度。

O(n \log n) 求 LIS 长度
在 O(n^2) 的做法中,求 LIS 长度的做法需要通过枚举两个数,假如是满足上升的,就将长度更新为原长度和新长度加一的最大值。通过这个朴素做法我们可以发现,以当前数结尾的 LIS 长度是否能达到 k,取决于它是否能比一个长度为 k-1 的序列的结尾数大。我们可以观察一组数据。

该组数据中,我们记下以每个数结尾的 LIS 长度,由于上面所讲的,对于长度为 k 的数据,我们只需要关注结尾数最小的,因为这代表它有可能产生的长度为 k+1 的 LIS 长度最多。于是我们可以去掉一些数据。

还是上面的道理,我们把还留着的数据的上方的数放到一个数轴上,这样当我们要查询一个数的 LIS 长度时,只需要看这个数处于哪个区间中,它的 LIS 长度就是往前(不包括本身)最靠近它的数所对应的 LIS 长度加一。

那么可以很快找到 6 的 LIS 长度为 3 + 1 = 4。这一个找区间的步骤使用复杂度 O(\log n) 的二分查找。

实现其实并不困难,按照上述的步骤做就行了。
 

#include <bits/stdc++.h>
using namespace std;

const int M = 1e5 + 5, INF = 1e9;
int n, a[M];
int f[M], g[M], len;
// f[i]   a[i] 为结尾的 LIS 长度
// g[i]   上升子序列长度为 i 时结尾最小值
// len    LIS 长度

int main() {
    int n;
    scanf ("%d", &n);
    for (int i = 1; i <= n; i++) scanf ("%d", &a[i]);
    for (int i = 1; i <= n; i++) {
        int pos = lower_bound(g + 1, g + len + 1, a[i]) - g; //二分查找区间
        f[i] = pos;
        g[pos] = a[i];//查找到之后还要更新最小值
        len = max(len, pos);
    }
    cout << *max_element(f + 1, f + n + 1); //输出最长的 LIS 长度
    return 0;
}



实际上,我们并不需要 f 数组,可以直接输出 len。但在本题中需要记录以每个数结尾的 LIS 长度,所以这样写了。

这种做法的时间复杂度是 O(n \log n)。依照相同的思路,最长下降子序列长度的求法只需要倒着枚举 i 即可。

本题解法
如我们分析的题意一样,本题只需要记录以 a_i
  为最后一个数的最长上升子序列长度和以 a_i
  为第一个数的最长下降子序列长度即可,我们用 f1 和 f2 来记录这两组数,第 a_i
  个数的最长合唱队形就是 f1[i] + f2[i] - 1(中间有重合所以减去 1)。用总人数减去最大值就是答案了。

#include <bits/stdc++.h>
using namespace std;

const int M = 1e5 + 5, INF = 1e9;
int a[M], f1[M], f2[M], g[M], len, ans = -INF;

int main() {
    int n;
    scanf ("%d", &n);
    for (int i = 1; i <= n; i++) scanf ("%d", &a[i]);
    len = 0;
    for (int i = 1; i <= n; i++) {
        int pos = lower_bound(g + 1, g + len + 1, a[i]) - g;
        f1[i] = pos;
        g[pos] = a[i]; 
        len = max(len, pos);
    }
    len = 0;
    memset(g, 0, sizeof g);
    for (int i = n; i >= 1; i--) {
        int pos = lower_bound(g + 1, g + len + 1, a[i]) - g;
        f2[i] = pos;
        g[pos] = a[i];
        len = max(len, pos);
    }
    for (int i = 1; i <= n; i++) ans = max(ans, f1[i] + f2[i] - 1);
    cout << n - ans;
    return 0;
}

3. P1435 [IOI2000] 回文字串

题目传送门

解题思路:该题说是考察如何将一个字符串添加成一个回文串的,不如说是一道求最长公共自序列的变式题,为啥这么说呢?肯定是有原因在里面的

首先,我们要摸清回文串的特性,回文就是正着读反着读一样,一种非常对称不会逼死强迫症的字符串;这就是我们的突破口。。。你难道以为是逼死强迫症么?哈哈,太天真了,突破口其实是因为回文正着读反着读都相同的特性。。。这样我们就可以再建一个字符数组存储倒序的字符串

我们先分析下样例:ab3bd

它的倒序是: db3ba

这样我们就可以把问题转化成了求最长公共子序列的问题,为啥可以这么转化呢?

它可以这么理解,正序与倒序“公共”的部分就是我们回文的部分,如果把正序与倒序公共的部分减去你就会惊奇的发现剩余的字符就是你所要添加的字符,也就是所求的正解

ad da 把不回文的加起来就是我们梦寐以求的东西:回文串(太没追求了

adda 加起来成回文串就是 adb3bda,所以这个问题就可以转化成了求最长公共子序列的问题,将字符串的长度减去它本身的“回文的”(最长公共自序列)字符便是正解

找到解题思路后我们就可以开始写了,最长公共子序列问题是个经典的 dp 问题,
最容易想到的方法就是开个二维数组 dp_{i,j}ij 分别代表两种状态;

那么我们的动态转移方程应该就是

if(str1[i] == str2[j]) dp[i][j]=dp[i-1][j-1]+1;

else dp[i][j] = max(dp[i-1][j], dp[i][j-1];


依此即可解出最长公共自序列,用字符串长度减去即是正解

#include<cstdio>
#include<cstring>
#include<iostream>
#include<cstdlib>
using namespace std;
int n;
int dp[5001][5001];
char str1[5001],str2[5001];
int main()
{
    //freopen("palindrome.in", "r", stdin);
    //freopen("palindrome.out", "w", stdout);
    scanf("%s", str1+1);
    n = strlen(str1+1);
    for(int i = 1; i <= n; i++)
        str2[i] = str1[n-i+1];                                //做一个逆序的字符串数组 
    for(int i = 1; i<=n; i++)
        for(int j = 1; j <= n; j++)
            if(str1[i] == str2[j])
                dp[i][j] = dp[i-1][j-1] + 1;        //最长公共自序列匹配 
            else
                dp[i][j] = max(dp[i-1][j], dp[i][j-1]);        //不匹配的往下匹配状态 
    printf("%d\n", n-dp[n][n]);                        //字符串长度减去匹配出的最长公共子序列的值 
    return 0;                                        //即需要添加的字符数 
}



可这并不是最优解法,只是能 AC 这道题而已 若是将内存限制改为 2MB 呢?

不过,没关系,正常开一个 5001 \times 5001 的数组一定会爆掉的,此时你是不是在思考另一种解决方案来优化一下空间复杂度,如果我可以把它用一维数组代替二维数组中的状态量是不是也可以求出正解

没错。它真的能求出正解;

如果你仔细研究一下就会发现每次搜索到 $str1$ 的第 $i$ 个元素的时候,数组 dp 真正使用到的元素仅仅是 dp_{i,j} 和 dp_{i-1,k}0 \le k \le n(n = strlen(str1)

dp 的第一下标从 0 \to i-2 就没有用处了,因此我们可以开辟两个滚动数组来降低空间复杂度

Dp1 用来记录当前状态,dp2 用来记录之前的状态也就相当于刚才的 dp_{i-1,j}

动态转移方程应该这么表达:
 

if(str1[i] == str2[i]) dp1[j] = dp2[j-1] +1;

else dp1[j] = max(dp1[j-1], dp2[j]);

源代码在此,天下我有:
 

#include<cstdio>
#include<cstring>
#include<iostream>
#include<cstdlib>
using namespace std;
int n;
int dp1[5001],dp2[5001];            //此处用两个滚动数组记录,一个记录之前的状态,一个记录此时的状态
char str1[5001],str2[5001];
int main()
{
    //freopen("palindrome.in", "r", stdin);
    //freopen("palindrome.out", "w", stdout);
    scanf("%s", str1+1);
    n = strlen(str1+1);
    for(int i = 1; i <= n; i++)
        str2[i] = str1[n-i+1];                                //做一个逆序的字符串数组 
    for(int i = 1; i<=n; i++)
    {
        for(int j = 1; j <= n; j++)
            if(str1[i] == str2[j])                     
                dp1[j] = dp2[j-1]+1;                //“发现”匹配就记录
            else
                dp1[j] = max(dp1[j-1],dp2[j]);        //不匹配就匹配后面的状态 
        memcpy(dp2, dp1, sizeof(dp1));                //记录之前的状态“滚动”匹配 
    }
    printf("%d\n", n-dp1[n]);            //字符串长度减去匹配出的最长公共子序列的值                          
    return 0;                            //即需要添加的字符数
}



7. 结论


动态规划是一种高效的算法思想,它在计算机科学和优化问题领域得到广泛应用。本文深入讲解了动态规划的核心概念,包括最优子结构和重叠子问题。最优子结构使得我们可以将复杂的问题分解为简单的子问题,并通过子问题的最优解来得到原问题的最优解。而重叠子问题的特性使得我们可以避免重复计算,从而提高算法的执行效率。

好了,今天就到这里,下期再见,拜拜ヾ( ̄▽ ̄)Bye~Bye~

  • 27
    点赞
  • 42
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值