问题

什么是动态规划(Dynamic Programming)?动态规划的意义是什么?

回答
什么是动态规划(Dynamic Programming)?

动态规划(Dynamic Programming,简称DP)是一种强大的算法设计技巧,它主要用于解决具有重叠子问题和最优子结构的优化问题。简单来说,动态规划就是将一个复杂的问题分解成若干个更小的、相互关联的子问题,然后逐个解决这些子问题,并存储它们的解,以避免重复计算。最终,通过组合这些子问题的解,得到原问题的最优解。

为了更详细地理解动态规划,我们可以从以下几个方面进行剖析:

1. 核心思想:分而治之与记忆化

动态规划的核心思想可以概括为:

分而治之 (Divide and Conquer): 将一个大问题分解成一系列更小的子问题。
记忆化 (Memoization) / 自底向上 (BottomUp): 存储并重用子问题的解。

这与递归(或分治策略)有相似之处,但动态规划的关键在于它明确地识别并利用了“重叠子问题”这一特性。

2. 两大关键特征

任何问题如果能够应用动态规划来解决,通常都具备以下两个关键特征:

最优子结构 (Optimal Substructure): 如果一个问题的最优解包含其子问题的最优解,那么该问题就具有最优子结构。换句话说,原问题的最优解可以通过组合其子问题的最优解来构建。
举例: 求解最短路径问题。如果从A到C的最短路径经过B,那么从A到B的最短路径必须是A到C这条路径的子路径。如果A到B的路径不是最短的,那么A到C的路径也就不是最短的了。
重叠子问题 (Overlapping Subproblems): 在解决原问题的过程中,会反复遇到相同的子问题。如果没有记忆化或者自底向上的方法,这些相同的子问题会被重复计算多次,导致效率低下。
举例: 斐波那契数列。计算 `fib(5)` 需要 `fib(4)` 和 `fib(3)`。计算 `fib(4)` 需要 `fib(3)` 和 `fib(2)`。可以看到 `fib(3)` 被计算了两次。随着计算的深入,重叠的子问题会越来越多。

3. 动态规划的实现方式

动态规划主要有两种实现方式:

自顶向下(带备忘录的递归 TopDown with Memoization):
这种方法保留了递归的思想,定义一个递归函数来解决问题。
在函数内部,首先检查当前子问题是否已经被计算过(例如,存储在哈希表或数组中)。
如果已经计算过,则直接返回存储的结果。
如果没有计算过,则计算该子问题的解,将其存储起来,然后返回。
优点: 思路直观,易于理解和实现。
缺点: 可能存在函数调用栈过深的问题。

自底向上(迭代 BottomUp / Tabulation):
这种方法不使用递归,而是通过迭代的方式,从最小的子问题开始计算,逐步构建出更大子问题的解,直到最终解决原问题。
通常需要一个数据结构(如数组或表)来存储所有子问题的解。
按照一定的顺序(通常是从小到大)填充这个表。
优点: 避免了递归的开销,通常效率更高,不会出现栈溢出的问题。
缺点: 需要仔细设计计算顺序,有时比自顶向下更难构思。

4. 动态规划的解决步骤

解决一个动态规划问题通常遵循以下步骤:

1. 理解问题: 充分理解问题的要求,确定目标是什么。
2. 确定状态: 定义一个或多个状态变量,用来描述子问题的状态。例如,对于斐波那契数列,状态可以是 `dp[i]` 表示第 `i` 个斐波那契数。
3. 确定状态转移方程: 找到当前状态与之前状态之间的关系。这就是动态规划的核心,它描述了如何从子问题的解推导出当前子问题的解。
递推关系: `dp[i] = f(dp[i1], dp[i2], ...)`
4. 确定初始状态: 确定最基本的、不需要依赖其他子问题的状态的解,也就是递推的边界条件。
5. 选择计算顺序: 确定是采用自顶向下还是自底向上,并安排好计算的顺序。
6. 求解问题: 根据状态转移方程和初始状态,计算出最终问题的解。

5. 动态规划的常见例子

斐波那契数列 (Fibonacci Sequence): `fib(n) = fib(n1) + fib(n2)`
背包问题 (Knapsack Problem): 如何在有限的容量下选择物品以最大化总价值。
最长公共子序列 (Longest Common Subsequence, LCS): 找到两个字符串中最长的公共子序列的长度。
最长递增子序列 (Longest Increasing Subsequence, LIS): 找到一个序列中最长的递增子序列的长度。
矩阵链乘法 (Matrix Chain Multiplication): 确定矩阵乘法的最优计算顺序以最小化乘法次数。
硬币找零问题 (Coin Change Problem): 用最少的硬币数量组成一个目标金额。

动态规划的意义是什么?

动态规划的意义在于它提供了一种系统性、高效的方法来解决那些看似复杂但本质上具有良好结构的问题。具体来说,它的意义体现在以下几个方面:

1. 提高效率,避免重复计算: 这是动态规划最直接的意义。通过将问题分解为子问题并存储子问题的解,动态规划有效地避免了同一子问题被反复计算,从而大大提高了算法的运行效率,尤其是在处理大规模数据时。如果没有动态规划,很多问题将需要指数级的时间复杂度才能解决,而动态规划可以将时间复杂度降低到多项式级别。

2. 解决优化问题: 许多实际问题本质上是优化问题,例如在有限的资源下追求最大化的效益(如背包问题),或者在满足约束条件下最小化成本(如最短路径问题)。动态规划能够系统地探索所有可能的解决方案,并找到全局最优解,而不是仅仅找到一个可行的解。

3. 结构化思维与问题分解: 动态规划要求开发者深入理解问题的结构,将其分解为相互关联的子问题。这个过程本身就是一种强大的思维训练,能够帮助人们更好地理解复杂系统,并将其拆解成可管理的部分。学会用动态规划的思想去分析问题,可以培养出严谨的逻辑思维和分析能力。

4. 提供一种通用算法设计范式: 动态规划并非只针对某一类特定问题,它是一种通用的算法设计范式,可以应用于许多不同的领域,包括计算机科学、运筹学、生物信息学、金融学等。掌握了动态规划的思想,就拥有了一把解决许多优化问题的“钥匙”。

5. 揭示问题的本质联系: 动态规划的状态转移方程揭示了问题中不同部分之间的内在联系。通过分析这些联系,我们可以更深刻地理解问题是如何构建起来的,以及解决方案是如何一步步形成的。这有助于我们发现更优化的解决方案或设计更简洁的算法。

6. 对学习算法和解决实际问题至关重要: 在学习算法的道路上,动态规划是必经之路。许多著名的算法和数据结构都建立在动态规划的基础上。在实际工作中,无论是软件开发、数据分析还是人工智能领域,都可能遇到需要用动态规划来解决的优化问题。

总而言之,动态规划的意义在于它提供了一种高效、系统且结构化的方法来解决具有最优子结构和重叠子问题的优化问题。它不仅能够显著提升算法的性能,还能培养解决复杂问题的思维能力,是计算机科学中最重要和最常用的算法设计技术之一。

网友意见

user avatar

动态规划中递推式的求解方法不是动态规划的本质。

我曾经作为省队成员参加过NOI,保送之后也给学校参加NOIP的同学多次讲过动态规划,我试着讲一下我理解的动态规划,争取深入浅出。希望你看了我的答案,能够喜欢上动态规划。

0. 动态规划的本质,是对问题状态的定义状态转移方程的定义

引自维基百科

dynamic programming is a method for solving a complex problem by breaking it down into a collection of simpler subproblems.

动态规划是通过拆分问题,定义问题状态和状态之间的关系,使得问题能够以递推(或者说分治)的方式去解决。

本题下的其他答案,大多都是在说递推的求解方法,但如何拆分问题,才是动态规划的核心。

拆分问题,靠的就是状态的定义状态转移方程的定义

1. 什么是状态的定义?


首先想说大家千万不要被下面的数学式吓到,这里只涉及到了函数相关的知识。

我们先来看一个动态规划的教学必备题:

给定一个数列,长度为N,
求这个数列的最长上升(递增)子数列(LIS)的长度.

1 7 2 8 3 4
为例。
这个数列的最长递增子数列是 1 2 3 4,长度为4;
次长的长度为3, 包括 1 7 8; 1 2 3 等.

要解决这个问题,我们首先要定义这个问题和这个问题的子问题。

有人可能会问了,题目都已经在这了,我们还需定义这个问题吗?需要,原因就是这个问题在字面上看,找不出子问题,而没有子问题,这个题目就没办法解决。

所以我们来重新定义这个问题:

给定一个数列,长度为N,
设为:以数列中第k项结尾的最长递增子序列的长度.
求 中的最大值.

显然,这个新问题与原问题等价。

而对于来讲,都是的子问题:因为以第k项结尾的最长递增子序列(下称LIS),包含着以第中某项结尾的LIS。

上述的新问题也可以叫做状态,定义中的“为数列中第k项结尾的LIS的长度”,就叫做对状态的定义。

之所以把做“状态”而不是“问题” ,一是因为避免跟原问题中“问题”混淆,二是因为这个新问题是数学化定义的。


对状态的定义只有一种吗?当然不是

我们甚至可以二维的,以完全不同的视角定义这个问题:

给定一个数列,长度为N,
设为:
在前i项中的,长度为k的最长递增子序列中,最后一位的最小值. .
若在前i项中,不存在长度为k的最长递增子序列,则为正无穷.
求最大的x,使得不为正无穷。

这个新定义与原问题的等价性也不难证明,请读者体会一下。

上述的就是状态,定义中的“为:在前i项中,长度为k的最长递增子序列中,最后一位的最小值”就是对状态的定义。


2. 什么是状态转移方程

上述状态定义好之后,状态和状态之间的关系式,就叫做状态转移方程。

比如,对于LIS问题,我们的第一种定义:

设为:以数列中第k项结尾的最长递增子序列的长度.

设A为题中数列,状态转移方程为:

(根据状态定义导出边界情况)

用文字解释一下是:

以第k项结尾的LIS的长度是:保证第i项比第k项小的情况下,以第i项结尾的LIS长度加一的最大值,取遍i的所有值(i小于k)。

第二种定义:

设为:在数列前i项中,长度为k的递增子序列中,最后一位的最小值

设A为题中数列,状态转移方程为:

若则
否则:

(边界情况需要分类讨论较多,在此不列出,需要根据状态定义导出边界情况。)

大家套着定义读一下公式就可以了,应该不难理解,就是有点绕。

这里可以看出,这里的状态转移方程,就是定义了问题和子问题之间的关系。

可以看出,状态转移方程就是带有条件的递推式。

3. 动态规划迷思

本题下其他用户的回答跟动态规划都有或多或少的联系,我也讲一下与本答案的联系。

a. “缓存”,“重叠子问题”,“记忆化”:

这三个名词,都是在阐述递推式求解的技巧。以Fibonacci数列为例,计算第100项的时候,需要计算第99项和98项;在计算第101项的时候,需要第100项和第99项,这时候你还需要重新计算第99项吗?不需要,你只需要在第一次计算的时候把它记下来就可以了。

上述的需要再次计算的“第99项”,就叫“重叠子问题”。如果没有计算过,就按照递推式计算,如果计算过,直接使用,就像“缓存”一样,这种方法,叫做“记忆化”,这是递推式求解的技巧。这种技巧,通俗的说叫“花费空间来节省时间”。都不是动态规划的本质,不是动态规划的核心。

b. “递归”:

递归是递推式求解的方法,连技巧都算不上。

c. "无后效性",“最优子结构”:

上述的状态转移方程中,等式右边不会用到下标大于左边i或者k的值,这是"无后效性"的通俗上的数学定义,符合这种定义的状态定义,我们可以说它具有“最优子结构”的性质,在动态规划中我们要做的,就是找到这种“最优子结构”。

在对状态和状态转移方程的定义过程中,满足“最优子结构”是一个隐含的条件(否则根本定义不出来)。对状态和“最优子结构”的关系的进一步解释,什么是动态规划?动态规划的意义是什么? - 王勐的回答 写的很好,大家可以去读一下。

需要注意的是,一个问题可能有多种不同的状态定义和状态转移方程定义,存在一个有后效性的定义,不代表该问题不适用动态规划。这也是其他几个答案中出现的逻辑误区:

动态规划方法要寻找符合“最优子结构“的状态和状态转移方程的定义在找到之后,这个问题就可以以“记忆化地求解递推式”的方法来解决。而寻找到的定义,才是动态规划的本质。

有位答主说:

分治在求解每个子问题的时候,都要进行一遍计算
动态规划则存储了子问题的结果,查表时间为常数

这就像说多加辣椒的菜就叫川菜,多加酱油的菜就叫鲁菜一样,是存在误解的。

文艺的说,动态规划是寻找一种对问题的观察角度,让问题能够以递推(或者说分治)的方式去解决。寻找看问题的角度,才是动态规划中最耀眼的宝石!(大雾)

user avatar

希望本文不仅能告诉你什么是动态规划,也能给你一种如何分析、求解动态规划问题的思考方式。

0001b 动态规划介绍

  1. 运筹学中的动态规划

动态规划(Dynamic Programming,简称DP)是运筹学的一个分支,它是解决多阶段决策过程最优化的一种数学方法。把多阶段问题变换为一系列相互联系的的单阶段问题,然后逐个加以解决

这里提到动态规划其实是一种数学方法,是求解某类问题的一种方法,而不是一种特殊的算法,没有一个标准的数学表达式或明确定义的一种规则。

比如我们接触过的”排序算法“,”二叉树遍历算法“等,这些算法都是有固定范式的,遇到此类问题我们只需要照搬算法即可。但动态规划却不是,它告诉你的是解决某类问题的一种思路,或者是一种更高意义上的算法,是一种道,而不是术。所以动态规划难就难在我们即使学会了这种思想,遇到具体问题也需要具体分析,很可能因为我们构造不出动态规划所需要的形式而无法解决,甚至根本想不到这是一个动态规划问题。

如下图。我们在解决某些最优问题时,可将解决问题的过程按照一定次序分为若干个互相联系的阶段(1, 2, ..., N),从而将一个大问题化成一系列子问题,然后逐个求解。

在每一个阶段都需要作出决策,从而使整个过程达到最优。各个阶段决策的选取仅依赖当前状态(这里的当前状态指的是当前阶段的输入状态),从而确定输出状态。当各个阶段决策确定后,就组成了一个决策序列,这个决策序列就决定了问题的最终解决方案。这种把一个问题可看作是一个前后关联具有链状结构的多阶段过程就称为多阶段决策过程

前面说到”仅需要依赖当前状态“,指的是问题的历史状态不影响未来的发展,只能通过当前的状态去影响未来,当前的状态是以往历史的一个总结。这个性质称为无后效性(即马尔科夫性)。 上图中,在阶段2时所做出的决策,仅依赖于阶段2的输入状态,与阶段1的输入状态无关。

动态规划定义中提到按照一定次序分成互相联系的子阶段,这里面的关键是互相联系。如果是独立的子问题,那是分治法,分而治之的意思。动态规划将一个大问题化成一族互相联系、同类型的子问题。既然是同类型,我们在逐个解决子问题的时候,就可以利用相同的决策,从而更容易的解决问题。互相联系利用前面已经解决的子问题的最优化结果来依次进行计算接下来的子问题,当最后一个子问题得到最优解时,就是整个问题的最优解。

这里面包括两个关键点:

  1. 每一个阶段可能包括很多状态,前后阶段的状态通过决策联系在一起。如果要利用前阶段子问题的结果解决现阶段的子问题,必须要能够建立前后阶段状态的转移关系,最好可以通过方程表示。用专业术语我们又叫做”状态转移方程“
  2. 我们在衡量最优解的时候需要有一个指标函数,最优解就是让这个指标函数达到最优值,比如最大或者最小。如果我们可以将问题拆分成子问题,那么这个指标函数也必须具有分离性,也就是必须能够利用子问题的最优递推的求出整体最优。当整体最优求解出以后,就可以知道各个子问题的最优解。

可以看到,上面两个关键点无论是状态转移方程还是指标函数分离,其实都是需要列出递推关系式,如果恰当的选取状态,让每个子问题的状态能够表示出子问题的最优解,那我们就可以用状态转移方程递推求出指标函数的最优解。实际中我们也是经常这么做的。

2. 动态规划举例

上面的描述如果觉得有些抽象,可以看完下面的栗子再回过头看一遍,可能理解会更加深刻。如下图,要求出从A点到F点的最短路径。我们来分析一下如何用动态规划的思想来解决这个问题。

第一步,我们将这个问题拆分成多阶段子问题,然后选择状态变量,使其满足无后效性。如下图,我们分为3个阶段。阶段1有1个输入状态A,输出状态B, C,阶段1的输出状态就是阶段2的输入状态,所以阶段2有2个输入状态{B, C},阶段3有2个输入状态{D, E},输出状态F。

以状态D为例,从状态D做决策选择最短路径的时候,我们只关心如何从D到F,不关心从前一阶段是从B还是C到达的D,满足无后效性。

第二步,确定决策集合。假设当前处在状态B,此时的允许决策集合就是 { D, E},不同的决策会使得状态转移的路径不同,从而影响到下一个状态。当选择决策D时,对应的决策变量 。

第三步,确定状态转移方程、分离指标函数。

把从A到F的路径看成一个指标函数,这个指标函数的最小值就是最短路径。我们接着分离指标函数,找到递推关系。

我们用 表示从初始节点到第k阶段节点s的最短路径,当我们这样拆成子问题时,它既是整个问题的最优子结构(因为当k为N时,就是原问题本身),又可以定义成状态。 如果我们可以列出状态转移方程,也就可以递推求出最优解。

,

接下来就是如何利用最短路径推导出 ,从而构造出递推方程。我们有

同理有

有了上面这个状态转移方程,我们可以容易的求出

于是,上述问题的最短路径就是7。

3. 《算法导论》中的动态规划

动态规划的思想最初是由美国数学家贝尔曼在1951提出,前面我们也是从数学的角度去理解动态规划,它背后的思想是最优化定理。

在《算法导论》中也讲到了”动态规划“,我在前面提到,动态规划难就难在它不是一成不变的,是一种更高意义上的算法思想;它不是一种特殊的招式,而是无招胜有招,需要见招拆招。

从算法的角度来看,什么时候可以使用动态规范方法解决问题呢?这其中包括了两个要素,分别是”重叠子结构“”最优子结构“。下面我们就借鉴《算法导论》,从算法的维度再来剖析一遍动态规划,看看和运筹学中的”动态规划“能不能找到什么共性?

1)重叠子结构

前面我们说动态规划是将一个多阶段问题分成几个相互联系的单阶段问题。而这几个相互联系的单阶段问题一定要是一族同类型的子问题,这样分成多阶段决策才是有意义的,否则我们每次面对的决策都是不同的问题,也就失去了分成单阶段的意义。

在算法上,我们把这种同类型的子问题又叫做”重叠子结构“。重叠不是相同,而是同类型,我们必须得利用前面子结构计算出的结果,用递推不断地调用同一个问题。

所以说,《算法导论》中的重叠子结构其实就是对应了数学中将多阶段问题分成单阶段问题的过程,只不过强调了这个单阶段问题必须是”重叠的“,这样才能够用同一种算法递推地解决问题。

2)最优子结构

利用前面已经解决的子问题最优解来构造并解决后面的子问题。能够构造的前提就是要满足指标函数的分离性,也就是全局最优解一定能够拆成子问题的最优解,从而递推解决。

对应到算法中,就是按照自底向上的策略,首先找到子问题的最优解,解决子问题,然后逐步向上找到问题的一个最优解。也就是说,整体问题的最优解包括了子问题的最优解。我们又叫做”最优子结构“

这么分析来,《算法导论》中的最优子结构其实就是对应了数学中指标函数的分离性。两者只是从不同的维度描述而已。

所以动态规划在数学上和算法上的描述归根到底都是相同的,只是使用数学语言和算法语言的区别。

于是我们按照定义看看如何辨别DP问题。

先来看看必须提到的斐波那契数列,它的递推表达式为

  • 从数学上来看,这个问题本身不是一个最优化问题,也就是没有一个指标函数去衡量最优值;而且需要依赖当前状态和前一个状态,不满足无后效性。所以从数学上来说它不是一个动态规划问题。
  • 从算法上看,它虽然具有重叠子结构,但本身不是一个最优化问题,没有最优子结构,不是动态规划问题。虽然不满足无后效性,但是依赖有限个状态,同样可以列出状态转移方程。

虽然不是DP问题,但我们也没有必要那么教条,毕竟动态规划是用来一系列比较复杂的最优化问题,当然也可以利用动态规划当中的一些思想去解决类似的问题,因为相对于动态规划问题,斐波那契数列这种问题算是太小儿科了。

接下来我们看看什么样的问题是动态规划问题,以及如何运用上面介绍的步骤进行解题。

0010b 刷题

学了这么多动态规划的知识,需要刷刷题,由简入难,体会一下动态规划到底怎么用,如何将这种思想付诸于实践。如果觉得学的比较明白了,也需要刷刷题打击一下自信。(下面所有的解法都是为了详细提现如何使用动态规划,所以在时间复杂度和空间复杂度上都没有经过优化)

题目来自LeetCode

53. 最大子序和 (难度:简单)

给定一个整数数组 nums ,找到一个具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
示例: 输入: [-2,1,-3,4,-1,2,1,-5,4], 输出: 6
解释: 连续子数组 [4,-1,2,1] 的和最大,为 6。

虽然难度为简单,但我觉得从动态规划的角度来考虑,这道题一点也不简单。

首先找关键字,要求最大子序和,有关键字”最大“,是一道最优问题,可能是一道DP问题。

接下来就是最关键的一步,也是最难的一步。如何构造出子问题,每个子问题必须满足”重叠子结构“和”最优子结构“两个要求才能用动态规划来解决。

首先想到的是如果原问题是”N个元素的最大子序和“,那子问题能不能是”N-1个元素的最大子序和“,也就是能否找到N-1个元素最大子序和跟N个元素最大子序和的递推关系,并且能用同类型的算法去解决。如何可以找到,就满足”重叠子结构“和”最优子结构“。

首先来看其中的一种情况。

如果我们求出了 ,其中 表示从第1个元素到第N个元素的最大子序和,如下图。那么的解对的解有没有帮助呢?也就是能不能求出 和 的递推关系呢?

容易得出 ,最大子序列是第2个元素1。而 ,最大子序列是第4个元素4。求最大子序和必须找到最大子序列,当我们求 时,由于 中最大子序列的位置是不知道的,所以新增加一个元素时,这个元素是否能添加到前面的最大子序列中也是不知道的。比如对于子序列{ 2, -2},最大子序和为2,新添加一个元素后子序列变为{2, -2, 1},此时虽然添加了一个正的元素1,但无法利用前一个最大子序列,因为无法判断是否应该连在一起构成一组新的最大子序列。

所以这里问题就来了,这种子问题的构造方式无法构成”最优子结构“,也就是子问题的最优解无法递推地解决下一个子问题,无法列出状态转移方程,也就不能用动态规划来解决。所以说即使理解动态规划,但是子问题的构造方式还是十分有技巧的,否则动态规划的大招还是使不出来。我们要换另外一种构造子问题的方式。

前面构造的子问题是”前N个元素的最大子序和“,产生了一个问题,在增加一个新元素的时候,不知道前一个最大子序列的位置,所以无法判断新添加的元素是否应该加到前面的子序列中,因为有可能被中间不在子序列中的元素打断,所以也就无法构造最优解。

那如果想要新添加的元素不被中间的元素打断,就得在构造子序列 的时候保证每一个子序列都是以第n个元素结尾。于是子问题变为"前N个元素中以第N个元素结尾的最大子序和"。

如上图,第n个元素为-3,子序列中必须包含这个元素,所以

这样,新添加一个元素4构成 的时候,我们只需要判断 是否为正即可。如果是正数,对子序列和起到正增益的作用,则包含到新的子序列中;否则,抛弃前面的子序列。例如

于是,我们可以构造出 的递推关系

胜利就在前方,但是这样构造有一个问题,要求的是最大子序和,而不是以某个元素结尾的最大子序和。但是无论是最大子序列是什么,一定是以1 - N当中的某一个元素结尾,所以前面拆分的子问题是最优子问题,我们只需要找到最优子问题当中解的最大值,就是最终问题的解。下面上代码

       public class MaxSubArray {     public static int maxSubArray(int[] nums) {         // 构造子问题,dp[n]代表以n结尾的最大子序和         int[] dp = new int[nums.length];         // 状态初始化         dp[0] = nums[0];         int ans = nums[0];         for (int i = 1; i < dp.length; i++) {             // 分离指标函数,列出状态转移方程             dp[i] = Math.max(dp[i - 1], 0) + nums[i];              // 最优解是最优子结构当中的最大值             ans = Math.max( ans, dp[i] );         }         return ans;     }      public static void main(String[] args) {         int[] nums = new int[]{-2, 1, -3, 4, -1, 2, 1, -5, 4};         System.out.println("MaxSubArray Length = " + maxSubArray(nums));     } }     

输出结果

       MaxSubArray Length = 6     

通过这个问题可以看到,动态规划最难的部分是构造状态,使其满足最优子结构,并且列出状态转移方程。如果这部分完成了,那代码其实就是将我们的思路写下来而已。


72. 编辑距离 (难度:困难)

据说这道题也是鹅厂的面试题,在测量两段基因相似度的时候会采用编辑距离这一概念,编辑距离越短,则基因越相似。

给你两个单词 word1word2,请你计算出将 word1 转换成 word2 所使用的最少操作数 。 你可以对一个单词进行如下三种操作:
1. 插入一个字符
2.删除一个字符
3.替换一个字符
示例: 输入: word1 = "horse", word2 = "ros" 输出:3
解释:
horse -> rorse (将 'h' 替换为 'r')
rorse -> rose (删除 'r')
rose -> ros (删除 'e')

同样,看到了关键字”最少“,应该是一道DP问题。

首先,构造子问题1 word1="h",word2=”r"时的最小操作数 ,其中下标1,1表示word1和word2的字符串长度都是1。但是即使知道这个最少操作数,如何构造下一个子问题呢?下个子问题貌似还不明确,也就是没有找到状态转移方程,那么其实状态还是没有被明确定义出来。下一个子问题应该如何定义字符串呢?我试着将问题最简化,子问题2只在word1增加一个字符,变为 word1="ho",word2="r"的最少操作数 。显然,子问题2的最少操作数就是在子问题1基础上将字符'o'删除,也就是 。那如果有一个子问题2’是word1="h",word2="ro",同样,就是在子问题1基础上增加一个字符'o',也就是 。我可能会想到列出这样一个等式

但很快,我就会发现这个等式有一个问题,如果要求 ,是应该用 还是用 呢?由于要最少操作数,我可能会把上面的式子改成

好像已经找到状态转移方程了。不会这么简单吧,我们回头看一看题目,状态转移方程中仅用到了插入,删除两个操作,还少了一个替换字符的操作,也就是如果已经得出来 ,我只需要将两个字符串中位于m+1和n+1的字符串进行替换,同样也可以实现转换。于是我再修正一下上式,使其包含替换的操作

细心的朋友可能会想到,替换的操作不一定是必须的。比如题目中给的例子,如果解决了word1=”h",word2=”r“这个子问题1解决了以后,那么对于word1="ho", word2="ro"这个子问题也就解决了,因为最后一个字符是相同的,无需任何操作。所以上面的方程还需要改造。

状态转移方程我们已经列出来了,我们用表格来展现一下上面的关系。

但是递归需要初始条件,那怎么进行初始化呢?从上面的表格来看,也就是如何初始化表格的最左侧一列和最上面一行。

到此,有了初始化条件和状态转移方程,我们就可以写具体实现了。这样初始化虽然可以实现,但有一个不太好的地方,就是初始化是需要依赖输入字符串,如果出现相同字符,初始化值是不同的。而我们希望的是一套统一的算法,无论输入字符串是什么,初始化方式是固定的。那么我们应该如何改造初始化,让它不依赖于输入字符串呢?那就是初始子问题尽量的简单。上面的初始化对应的是 和 ,我们能不能再简化初始化条件?让 和 由更基本的初始化条件推导出来呢?所以我们需要定义 和 ,那 对应的就是空字符,如下图。

有了空字符,则初始化条件就简单了,由空字符生成任意字符串,都是加字符,所以第一行和第一列对应的就是字符个数,这样初始化条件就简单了。而其它的位置,我们只需要按照前面的递推关系填进去就可以了,如下图,有点像填杨辉三角形。

其中绿色的部分是因为字符相同,直接取左上方的值,其它则是取左,上,左上方的最小值加1。表格填完了,下面按照前面的思路上代码,

       public class MinDistance {     public static int minDistance(String a, String b) {         int length1 = a.length();         int length2 = b.length();         int[][] dp = new int[length1 + 1][length2 + 1];                  // 初始化第一列         for (int row = 0; row < length1 + 1; row++) {             dp[row][0] = row;         }         // 初始化第一行         for (int column = 0; column < length2 + 1; column++) {             dp[0][column] = column;         }          for (int row = 1; row < length1 + 1; row++) {             for (int column = 1; column < length2 + 1; column++) {                 // 如果字符相等,则直接取左上方的值                 if ( a.charAt(row - 1) == b.charAt(column - 1) ) {                     dp[row][column] = dp[row-1][column-1];                 } else { // 否则取左,上,左上三个值中的最小值+1                     dp[row][column] = Math.min(                         dp[row-1][column-1],                          Math.min(                             dp[row][column-1],                              dp[row-1][column]                          )) + 1;                 }             }         }         // 递推后,表格最右下方的值就是整个问题的最优解         return dp[length1][length2];     }      public static void main(String[] args) {         String a = "horse";         String b = "ros";         System.out.println("minDistance Result: " + minDistance(a, b));     }  }     

输出结果

       minDistance Result: 3     

887. 鸡蛋掉落难度:困难

你将获得 K 个鸡蛋,并可以使用一栋从 1 到 N 共有 N 层楼的建筑。 每个蛋的功能都是一样的,如果一个蛋碎了,你就不能再把它掉下去。 你知道存在楼层 F ,满足 0 <= F <= N 任何从高于 F 的楼层落下的鸡蛋都会碎,从 F 楼层或比它低的楼层落下的鸡蛋都不会破。 每次移动,你可以取一个鸡蛋(如果你有完整的鸡蛋)并把它从任一楼层 X 扔下(满足 1 <= X <= N)。 你的目标是确切地知道 F 的值是多少。 无论 F 的初始值如何,你确定 F 的值的最小移动次数是多少? 示例 1: 输入:K = 1, N = 2 输出:2
解释: 鸡蛋从 1 楼掉落。如果它碎了,我们肯定知道 F = 0 。 否则,鸡蛋从 2 楼掉落。如果它碎了,我们肯定知道 F = 1 。 如果它没碎,那么我们肯定知道 F = 2 。 因此,在最坏的情况下我们需要移动 2 次以确定 F 是多少。

这道题也很经典,作为思考题吧。

0011b 总结

解释了两道题,相信已经对动态规划有一定感觉了,难点不是动态规划本身,而是如何准确的定义状态,列出状态转移方程。从我的经验来看,可以按以下两个方法尝试

  1. 将问题简化成最简,定义状态,这样更有利于观察到问题本质,寻找递推关系。
  2. ”大胆假设,小心求证“。在定义状态的时候,如果无法找到状态转移方程,可以大胆尝试几种不同的状态定义方式,不要怕错,然后再仔细考察是否满足最优子结构,列出状态转移方程。

user avatar

0. intro

  很有意思的问题。以往见过许多教材,对动态规划(DP)的引入属于“奉天承运,皇帝诏曰”式:不给出一点引入,见面即拿出一大堆公式吓人;学生则死啃书本,然后突然顿悟。针对入门者的教材不应该是这样的。恰好我给入门者讲过四次DP入门,迭代出了一套比较靠谱的教学方法,所以今天跑过来献丑。

  现在,我们试着自己来一步步“重新发明”DP。

1. 从一个生活问题谈起

  先来看看生活中经常遇到的事吧——假设您是个土豪,身上带了足够的1、5、10、20、50、100元面值的钞票。现在您的目标是凑出某个金额w,需要用到尽量少的钞票。

  依据生活经验,我们显然可以采取这样的策略:能用100的就尽量用100的,否则尽量用50的……依次类推。在这种策略下,666=6×100+1×50+1×10+1×5+1×1,共使用了10张钞票。

  这种策略称为“贪心”:假设我们面对的局面是“需要凑出w”,贪心策略会尽快让w变得更小。能让w少100就尽量让它少100,这样我们接下来面对的局面就是凑出w-100。长期的生活经验表明,贪心策略是正确的。

  但是,如果我们换一组钞票的面值,贪心策略就也许不成立了。如果一个奇葩国家的钞票面额分别是1、5、11,那么我们在凑出15的时候,贪心策略会出错:
  15=1×11+4×1 (贪心策略使用了5张钞票)
  15=3×5 (正确的策略,只用3张钞票)
  为什么会这样呢?贪心策略错在了哪里?

  鼠目寸光。
  刚刚已经说过,贪心策略的纲领是:“尽量使接下来面对的w更小”。这样,贪心策略在w=15的局面时,会优先使用11来把w降到4;但是在这个问题中,凑出4的代价是很高的,必须使用4×1。如果使用了5,w会降为10,虽然没有4那么小,但是凑出10只需要两张5元。
  在这里我们发现,贪心是一种只考虑眼前情况的策略。

  那么,现在我们怎样才能避免鼠目寸光呢?

  如果直接暴力枚举凑出w的方案,明显复杂度过高。太多种方法可以凑出w了,枚举它们的时间是不可承受的。我们现在来尝试找一下性质。


  重新分析刚刚的例子。w=15时,我们如果取11,接下来就面对w=4的情况;如果取5,则接下来面对w=10的情况。我们发现这些问题都有相同的形式:“给定w,凑出w所用的最少钞票是多少张?”接下来,我们用f(n)来表示“凑出n所需的最少钞票数量”。

  那么,如果我们取了11,最后的代价(用掉的钞票总数)是多少呢?
  明显 ,它的意义是:利用11来凑出15,付出的代价等于f(4)加上自己这一张钞票。现在我们暂时不管f(4)怎么求出来。
  依次类推,马上可以知道:如果我们用5来凑出15,cost就是 。

  那么,现在w=15的时候,我们该取那种钞票呢?当然是各种方案中,cost值最低的那一个

  - 取11:
  - 取5:
  - 取1:

  显而易见,cost值最低的是取5的方案。我们通过上面三个式子,做出了正确的决策

  这给了我们一个至关重要的启示—— 只与 相关;更确切地说:

  这个式子是非常激动人心的。我们要求出f(n),只需要求出几个更小的f值;既然如此,我们从小到大把所有的f(i)求出来不就好了?注意一下边界情况即可。代码如下:

  我们以 的复杂度解决了这个问题。现在回过头来,我们看看它的原理:

  - 只与的相关。
  - 我们只关心 的,不关心是怎么凑出w的。

  这两个事实,保证了我们做法的正确性。它比起贪心策略,会分别算出取1、5、11的代价,从而做出一个正确决策,这样就避免掉了“鼠目寸光”!

  它与暴力的区别在哪里?我们的暴力枚举了“使用的硬币”,然而这属于冗余信息。我们要的是答案,根本不关心这个答案是怎么凑出来的。譬如,要求出f(15),只需要知道f(14),f(10),f(4)的值。其他信息并不需要。我们舍弃了冗余信息。我们只记录了对解决问题有帮助的信息——f(n).

  我们能这样干,取决于问题的性质:求出f(n),只需要知道几个更小的f(c)。我们将求解f(c)称作求解f(n)的“子问题”。


  这就是DP(动态规划,dynamic programming).

  将一个问题拆成几个子问题,分别求解这些子问题,即可推断出大问题的解

思考题:请稍微修改代码,输出我们凑出w的方案

2. 几个简单的概念

【无后效性】

  一旦f(n)确定,“我们如何凑出f(n)”就再也用不着了。

  要求出f(15),只需要知道f(14),f(10),f(4)的值,而f(14),f(10),f(4)是如何算出来的,对之后的问题没有影响。

  “未来与过去无关”,这就是无后效性

  (严格定义:如果给定某一阶段的状态,则在这一阶段以后过程的发展不受这阶段以前各段状态的影响。)


【最优子结构】

  回顾我们对f(n)的定义:我们记“凑出n所需的最少钞票数量”为f(n).

  f(n)的定义就已经蕴含了“最优”。利用w=14,10,4的最优解,我们即可算出w=15的最优解。

  大问题的最优解可以由小问题的最优解推出,这个性质叫做“最优子结构性质”。


  引入这两个概念之后,我们如何判断一个问题能否使用DP解决呢?


  能将大问题拆成几个小问题,且满足无后效性、最优子结构性质。

3. DP的典型应用:DAG最短路

  问题很简单:给定一个城市的地图,所有的道路都是单行道,而且不会构成环。每条道路都有过路费,问您从S点到T点花费的最少费用。

  这个问题能用DP解决吗?我们先试着记从S到P的最少费用为f(P).
  想要到T,要么经过C,要么经过D。从而.

  好像看起来可以DP。现在我们检验刚刚那两个性质:
  - 无后效性:对于点P,一旦f(P)确定,以后就只关心f(P)的值,不关心怎么去的。
  - 最优子结构:对于P,我们当然只关心到P的最小费用,即f(P)。如果我们从S走到T是 ,那肯定S走到Q的最优路径是 。对一条最优的路径而言,从S走到沿途上所有的点(子问题)的最优路径,都是这条大路的一部分。这个问题的最优子结构性质是显然的。

  既然这两个性质都满足,那么本题可以DP。式子明显为:

  其中R为有路通到P的所有的点, 为R到P的过路费。

  代码实现也很简单,拓扑排序即可。

4. 对DP原理的一点讨论

【DP的核心思想】

  DP为什么会快?
  无论是DP还是暴力,我们的算法都是在可能解空间内,寻找最优解

  来看钞票问题。暴力做法是枚举所有的可能解,这是最大的可能解空间。
  DP是枚举有希望成为答案的解。这个空间比暴力的小得多。

  也就是说:DP自带剪枝。

  DP舍弃了一大堆不可能成为最优解的答案。譬如:
  15 = 5+5+5 被考虑了。
  15 = 5+5+1+1+1+1+1 从来没有考虑过,因为这不可能成为最优解。

  从而我们可以得到DP的核心思想:尽量缩小可能解空间。

  在暴力算法中,可能解空间往往是指数级的大小;如果我们采用DP,那么有可能把解空间的大小降到多项式级。

  一般来说,解空间越小,寻找解就越快。这样就完成了优化。


【DP的操作过程】

  一言以蔽之:大事化小,小事化了。

  将一个大问题转化成几个小问题;
  求解小问题;
  推出大问题的解。

【如何设计DP算法】

  下面介绍比较通用的设计DP算法的步骤。

  首先,把我们面对的局面表示为x。这一步称为设计状态
  对于状态x,记我们要求出的答案(e.g. 最小费用)为f(x).我们的目标是求出f(T).
找出f(x)与哪些局面有关(记为p),写出一个式子(称为状态转移方程),通过f(p)来推出f(x).

【DP三连】

  设计DP算法,往往可以遵循DP三连:

  我是谁? ——设计状态,表示局面
  我从哪里来?
  我要到哪里去? ——设计转移

  设计状态是DP的基础。接下来的设计转移,有两种方式:一种是考虑我从哪里来(本文之前提到的两个例子,都是在考虑“我从哪里来”);另一种是考虑我到哪里去,这常见于求出f(x)之后,更新能从x走到的一些解。这种DP也是不少的,我们以后会遇到。

  总而言之,“我从哪里来”和“我要到哪里去”只需要考虑清楚其中一个,就能设计出状态转移方程,从而写代码求解问题。前者又称pull型的转移,后者又称push型的转移。(这两个词是 @阮止雨 妹妹告诉我的,不知道源出处在哪)

思考题:如何把钞票问题的代码改写成“我到哪里去”的形式?
提示:求出f(x)之后,更新f(x+1),f(x+5),f(x+11).

5. 例题:最长上升子序列

  扯了这么多形而上的内容,还是做一道例题吧。

  最长上升子序列(LIS)问题:给定长度为n的序列a,从a中抽取出一个子序列,这个子序列需要单调递增。问最长的上升子序列(LIS)的长度。
  e.g. 1,5,3,4,6,9,7,8的LIS为1,3,4,6,7,8,长度为6。


  如何设计状态(我是谁)?

  我们记 为以 结尾的LIS长度,那么答案就是 .


  状态x从哪里推过来(我从哪里来)?

  考虑比x小的每一个p:如果 ,那么f(x)可以取f(p)+1.
  解释:我们把 接在 的后面,肯定能构造一个以 结尾的上升子序列,长度比以 结尾的LIS大1.那么,我们可以写出状态转移方程了:

  至此解决问题。两层for循环,复杂度 .

  从这三个例题中可以看出,DP是一种思想,一种“大事化小,小事化了”的思想。带着这种思想,DP将会成为我们解决问题的利器。

  最后,我们一起念一遍DP三连吧——我是谁?我从哪里来?我要到哪里去?

6. 习题

如果读者有兴趣,可以试着完成下面几个习题:

一、请采取一些优化手段,以 的复杂度解决LIS问题。

提示:可以参考这篇博客 Junior Dynamic Programming--动态规划初步·各种子序列问题

二、“按顺序递推”和“记忆化搜索”是实现DP的两种方式。请查阅资料,简单描述“记忆化搜索”是什么。并采用记忆化搜索写出钞票问题的代码,然后完成P1541 乌龟棋 - 洛谷

三、01背包问题是一种常见的DP模型。请完成P1048 采药 - 洛谷


谢谢您看完本文 ⁄(⁄ ⁄ ⁄ω⁄ ⁄ ⁄)⁄

2019.3.3

类似的话题

  • 回答
    什么是动态规划(Dynamic Programming)?动态规划(Dynamic Programming,简称DP)是一种强大的算法设计技巧,它主要用于解决具有重叠子问题和最优子结构的优化问题。简单来说,动态规划就是将一个复杂的问题分解成若干个更小的、相互关联的子问题,然后逐个解决这些子问题,并存.............
  • 回答
    日本动漫,一个在全球范围内拥有庞大粉丝群体的独特艺术形式,虽然内容千变万化,风格也五花八门,但细细品味,我们依然能从中窥见一些贯穿始终的共通之处,它们像是隐藏在繁复色彩和奇幻剧情下的骨架,支撑起了一个个鲜活的世界。首先,日本动漫对于“情感的细腻描绘”可以说是炉火纯青。无论是少年漫画中那种即使面对绝境.............
  • 回答
    日本制作并上线的动画作品,无论是在电视上播放还是在流媒体平台上线,都需要遵守一系列的规定和标准。这些规定涵盖了内容审查、版权保护、播出时间限制、广告以及其他相关领域。以下我将尽量详细地介绍这些规定和标准:一、内容审查与制作规范(最核心的部分)日本对于动画内容的审查主要由放送伦理・番組向上機構 (BP.............
  • 回答
    话说,内地学生一到澳门,不少人在社交媒体上突然切换到繁体字,这事儿挺有意思的,细琢磨起来,里头藏着不少心思。首先,这是一种“入境随俗”的表达。澳門毕竟是个特别行政区,有它独特的文化印记,而繁体字就是其中一个非常显眼的符号。对于一个初来乍到的人来说,模仿当地人,或者说,尝试融入当地的文化符号,是一种很.............
  • 回答
    想象一下,你在玩一款速度飞快的赛车游戏,当你的车头灯划破黑夜,或者疾驰而过掠过赛道两旁的景物时,你有没有注意到那些快速移动的物体,它们在画面上似乎被拉长、拖曳,形成一种模糊的效果?这就是游戏里的动态模糊,也被叫做“运动模糊”。它可不是因为你的显示器坏了,也不是因为游戏卡顿了。这是一种刻意添加的视觉特.............
  • 回答
    这可真是个有意思的问题。朋友圈分组隐藏,这背后其实藏着挺多小九九的。我感觉,这就像是在玩一场社交心理的游戏,每个人都有自己的考量。首先,最直接的一种情况,就是“不想让某些人知道我的全部生活。”你想想,咱们朋友圈里什么都有啊,可能今天发了和朋友聚会的照片,明天就分享了工作上的小成就,后天可能又发了点生.............
  • 回答
    那些很少在朋友圈发布动态的人,他们对朋友圈的态度,其实比你想象的要复杂和多元得多。这并非简单的“不在乎”或“看不上”,而是背后有着各种各样的考量和选择。1. 朋友圈更像是一个“信息速递站”,而非“生活秀场”。对于这类人来说,朋友圈的主要功能是接收信息、了解朋友的近况。他们习惯于静静地浏览,看看大家都.............
  • 回答
    动态锁存比较器(Dynamic Latch Comparator)是数字设计中一种非常常用且高效的比较器电路,尤其在高速ADC(模数转换器)和锁存器中扮演着关键角色。其核心优势在于能够利用时钟信号进行同步操作,并且在特定阶段实现高增益,从而快速且准确地进行电压比较。理解动态锁存比较器的MOS管宽长比.............
  • 回答
    动力集中式列车,顾名思义,就是动力集中在列车头部或尾部,由一台或多台机车牵引或推送整个列车前进。这就像我们小时候玩的火车玩具,通常前面有一个火车头,后面拖着几节车厢。动力集中式列车,就是火车头负责拉着一整列车厢往前跑。那么,这种动力集中式的火车,跟我们平时坐的“高铁”或者“动车”有什么不一样呢?区别.............
  • 回答
    这个问题很有意思,我们平时看电影时,可能不太会深究镜头实现的技术门槛,但其实动画在某些方面确实能玩得比真人电影更开。这里就聊聊一些动画相对容易实现,而真人电影拍摄起来会非常困难甚至是不太现实的镜头吧。一、超越物理定律的夸张变形与超现实的视觉效果动画最核心的优势在于它不受现实物理世界的束缚。你可以随心.............
  • 回答
    好的,咱们就来好好聊聊英语里的系动词,以及为什么它们会有这么个名字。别担心,这事儿说起来一点不复杂,但弄明白了,对理解英语句子结构可就大有裨益了。什么是系动词?简单来说,系动词在英语句子里的作用就像一座桥梁,它连接句子里的主语(我们说的是谁或什么事)和后面的表语(用来描述或说明主语的词或短语)。系动.............
  • 回答
    .......
  • 回答
    .......
  • 回答
    .......
  • 回答
    这个问题很有趣,也常常引起大家的好奇心。当说到地球上除了人类之外最聪明的动物,人们脑海里首先浮现的往往是海豚和黑猩猩。这两种动物确实在认知能力上表现出了非凡的才华,但要给出一个绝对的“最”字,其实并不容易,因为“智商”这个概念在不同物种身上有不同的体现方式,而且科学界对动物认知能力的评估也在不断发展.............
  • 回答
    真正的动物保护是一个复杂且多层次的理念和实践,其核心在于尊重所有生命,并致力于维护动物福祉、种群数量和栖息地的健康,以实现人与自然和谐共生的目标。 它不仅仅是施舍或同情,更是一种系统性的、基于科学的、长远的承诺。为了更详细地解释“真正的动物保护”,我们可以从以下几个关键维度来展开:一、 核心理念与价.............
  • 回答
    “推不动”、“推不好”、“推不开”,这几个词看似简单,实则蕴含着很多生活中的细节和场景。它们描述的是力量施加的有效性问题,但侧重点各有不同。咱们来细细道道:推不动“推不动”,顾名思义,就是你使出了力气,但物体纹丝不动,仿佛它拥有自己的意志,并且那个意志是“坚决不挪窝”。这通常是因为: 阻力过大,.............
  • 回答
    这大概是一个连我自己都时不时会问自己的问题。每次看到那些能把一件事情做得风生水起,几年甚至几十年都不曾懈怠的人,我总会好奇,到底是什么在支撑着他们?于我而言,驱动我把一件事坚持下来的动力,并不是单一而强大的力量,而更像是一种混合体,由几个不同层次的因素交织而成,随着时间的推移和事情的进展,它们又会此.............
  • 回答
    .......
  • 回答
    .......

本站所有内容均为互联网搜索引擎提供的公开搜索信息,本站不存储任何数据与内容,任何内容与数据均与本站无关,如有需要请联系相关搜索引擎包括但不限于百度google,bing,sogou

© 2025 tinynews.org All Rights Reserved. 百科问答小站 版权所有