来自维基百科的定义:动态规划(英语:Dynamic programming,简称DP)是一种在数学、管理科学、计算机科学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
也就是说,动态规划是一种用来解决一类最优化问题的算法思想和策略,并不是一种具体的算法。但这种算法思想非常强大且应用非常广泛,也是各类算法竞赛里的常客,所以大家一定要好好掌握。
再来看看经典的 Fibonacci 数列问题:给一个正整数 n,寻找 Fibonacci 数列的第 n 项数字是多少?
斐波那契数以递归的方法来定义,其数学表达如下所示:
用文字来描述,就是斐波那契数列是从 0 和 1 开始,之后的斐波那契数是由之前的两数相加而得。
从第 1 项开始,斐波那契数列前几项依次是:
需要注意的是,斐波那契数列中,0 是第零项,而不是第一项
我们之前通常是用递归去完成的,这也很自然,毕竟斐波那契数列本身的定义就是一种递归形式。
代码如下:
递归的方式很好理解,实现也非常简单。以 fib(4)
为例:
fib(4)
,你需要先求 fib(3)
和 fib(2)
。fib(3)
,你需要先求 fib(2)
和 fib(1)
。fib(2)
,你需要先求 fib(1)
和 fib(0)
。一直往下求,直到已知的 fib(0)
和 fib(1)
。
大家发现问题了吗?为了计算 fib(4)
,我们计算了 fib(3)
和 fib(2)
,在计算 fib(3)
时,又计算了一次 fib(2)
,实际上,由于每次调用 fib()
都会产生两次额外的调用,如果 fib(n)
中的 n
稍大一点,就会产生很多重复的计算,这种重复计算会变得极其浪费时间,n
越大,程序会越慢,可能会慢到无法接受。
如何改进这个问题呢?直观能想到的是把每次求出的 fib(n)
结果保存下来,如果下次需要再用到,直接拿出来用就是,而不是重新再算一遍,省时又省力。
按照这个思路改进后的代码如下:
通过数组来存放每次计算的结果。经过改进后,由于每个 fib()
只计算一次,算法的时间复杂度从 降为 。
刚刚这种解决问题的方式就是一种动态规划的算法思想,通过缓存子问题结果来避免重复计算。
动态规划不是一个具体的算法,而是一种解决问题的思维方式。它通常用来解决那些具有重叠子问题和最优子结构特性的问题。
动态规划的精髓在于:
要用动态规划解决一个问题,我们通常需要明确以下几个关键点:
fib(k)
,所以状态就是这个 k
。我们通常用 dp[k]
来表示 fib(k)
的值。(i, j)
有多少种方法?” 那么状态就是 (i, j)
。我们通常用 dp[i][j]
来表示到达格子 (i, j)
的方法数。dp[i]
(或 dp[i][j]
),它的解可以由哪些 更小规模 的状态 dp[k]
(其中 k < i
) 或 dp[x][y]
(其中 x <= i, y <= j
且 (x,y) != (i,j)
) 组合或计算得到?dp[k]
的值取决于 dp[k-1]
和 dp[k-2]
。状态转移方程就是:dp[k] = dp[k-1] + dp[k-2]
。(i, j)
,只能从上面 (i-1, j)
或者左边 (i, j-1)
过来。所以状态转移方程是:dp[i][j] = dp[i-1][j] + dp[i][j-1]
。dp[0] = 0
, dp[1] = 1
。这是递推的起点。dp[0][0]
是起点。如果只能向下或向右,那么第一行 dp[0][j]
和第一列 dp[i][0]
的走法通常只有一种(除非有障碍物)。例如,dp[0][0] = 1
,dp[i][0] = 1
(只能一路向下),dp[0][j] = 1
(只能一路向右)。dp[i]
时,它所依赖的那些状态 dp[k]
(k < i) 必须 已经被计算出来。因此,我们需要确定一个合适的计算顺序。上面讲到,动态规划问题求解通常有两种实现方式,一种自顶向下(Top-Down),一种自底向上(Bottom-Up)。
下面我们分别来看看,仍然以 fib(n)
为例。
前面我们经过改进后求 Fibnacci 数列第 n 项的递归方法就是用的自顶而下的 DP 求解。
自顶向下 DP 一般是通过改进递归来实现的,避免重复计算相同子问题的一种方法就是缓存这些子问题的结果(维护一个已计算的值表)。
这种通过缓存子问题结果的方法也被称为备忘录(Memoization),通过备忘录可以消除许多递归调用。
通过缓存子问题结果,我们的算法时间复杂度降到 ;另外,要求 fib(n)
,我们需要计算 fib(n-1)
和 fib(n-2)
,一直到 fib(1)
和 fib(0)
,这意味着我们的空间复杂度也为 。
【补充优化后加注释的代码】
如果解决一个问题的办法是先解 「较小」的子问题,再逐步解决更大的问题,直到解决最终问题,这种方法就被称为自底向上的方法,自底向上一般是通过迭代来解决,
以上代码中,f[i]
表示斐波那契数列的第 i
项。我们使用动态规划的方法,通过状态转移方程和边界条件,避免了重复计算,从而高效地求解了斐波那契数列问题。
使用 “自底而上” 的方法,只需要保留当前计算所需的子问题的解,而在“自顶而下“的方法中,需要保存所有子问题的解。
对于求 Fibonacci 数例第 n 项这个示例来说,”自底而上“只需要保留前两个数字的值,因此空间复杂度从 降低到 。
我们再来看看是否还可以优化?
我们注意到,在整个计算的过程中,计算 dp[i]
其实只需要 dp[i-1]
和 dp[i-2]
的值。我们并不需要保存整个 dp
数组。可以用两个变量滚动计算。
按照这个思路,在计算过程中,我们只需要保留三个值,当前值和前两项的值,其它不需要的值都可以丢弃。
经过再次改进的代码如下:
优化后版本,时间复杂度仍然是 ,但空间复杂度是恒定的,不再随输入的大小而增大,因此空间复杂度为 ,相比我们最初的普通递归版本,效率已大大提升!
注:这种优化技巧在很多 DP 问题中都可能用到,当计算当前状态只需要前面少数几个状态时,可以大大减少空间复杂度。
当遇到一个动态规划的问题时,可以尝试遵循以下步骤来进行求解:
dp[i]
或 dp[i][j]
代表什么含义。这是关键的一步,状态定义得好,后面的状态转移议程就比较好找。dp[i]
或 dp[i][j]
是如何由前面的状态推导出来的。这是 DP 的核心逻辑。在求解动态规划相关的问题时,如何识别和定义状态和状态转移方程是核心,也是最难的部分。
动态规划的应用非常广泛,是一些经典算法问题的基础:
这里的“一维”指的就是我们用来定义子问题状态的关键变量只有一个。
回顾一下前面举的求斐波那契数列第 n 项的例子,子问题状态就是 dp[i]
,要解决关于第 i
项的问题,只需要知道前 dp[i-1]
项和 dp[i-2]
项,这里的 i
就是那个唯的关键变量,它代表了问题的规模,求斐波那契数列第 n 项这个问题就是一个一维动态规划问题,一维动态规划问题通常也简称:一维 DP。
简单来说,一维 DP 就是用一个一维数组 dp[i]
来记录状态,并且 dp[i]
的计算通常只依赖于 dp[j]
(其中 j < i
) 的值。就像多米诺骨牌一样,dp[0]
(或 dp[1]
) 是第一块牌,然后你可以用它推倒 dp[1]
,再用 dp[1]
推倒 dp[2]
... 一直推到你想要的最终答案 dp[n]
。
一维 DP 通常用于解决与序列、路径、计数、最大/最小相关的问题,且问题的状态可以被一个单一的维度(如下标、长度、数值等)刻画。
问题描述
给一定一个整数数组 a
,找到一具有最大和的连续子数组(子数组最少包含一个元素),返回其最大和。
例如,对于一组 个整数,,这 7 个整数为 -2 13 12 9 14 -10 2
,其具有最大和的连续子数组为 13 12 9 14
,最大和为 48
。
样例输入数据
样例输出数据
题目分析
状态定义:dp[i]
表示以数组元素 a[i]
结尾的连续子数组的最大和。
状态转移方程:对于 dp[i]
,以 a[i]
结尾的最大和子数组只有两种可能,dp[i]
要么是 a[i]
,要么是 dp[i-1] + a[i]
,取两者中的较大值,用代码表示出来就是:dp[i] = max(a[i], dp[i-1] + a[i])
(对于 i >= 1)
初始状态:dp[0] = a[0]
,表示第一个元素结尾的最大和就是它本身。
最后我们只需要找到 dp[i]
中的最大值即可。
代码实现
代码实现(空间优化版本)
同样的,你会发现,dp[i]
仅仅依赖 dp[i - 1]
,在这种情况下,我们其实不需要存储整个 dp
数组,与前面的斐波那契数列空间优化一样,用几个变量来滚动保存所需的状态即可,这样可以将空间复杂度从 优化到 。
优化的参考代码如下:
首先我们要明白什么是子序列,给定一个序列(比如一个数组 q[]
),它的子序列是通过删除原序列中的零个或多个元素,但保持剩下元素的相对顺序不变得到的序列。
例如,对于序列 `q = [3, 1, 4, 1, 5, 9]
[1, 4, 5, 9]
是一个子序列(删除了 3
和第一个 1)[3, 4, 9]
是一个子序列 (删除了 1
、 1
和 5
)。[1, 3, 4]
不是 子序列,因为 1
和 3
的相对顺序改变了。[1, 5, 6]
不是 子序列,原序列中没有数字 6
“递增”指的是子序列中的元素从左到右是 严格增大 的,需要注意的是,有时题目中要求的是非递减子序列,即允许相邻元素是相等的。我们这里讨论的是严格递增的子序列。
综合起来,最长递增子序列(Longest Increasing Subsequence, LIS)就是在一个给定的序列中,找到一个最长的、且元素严格递增的子序列。
例如,对于序列 q = [3, 1, 4, 1, 5, 9, 2, 6]
[1, 4, 5, 9]
是一个递增子序列,长度为 4。[1, 4, 5, 6]
是一个递增子序列,长度为 4。[3, 4, 5, 9]
是一个递增子序列,长度为 4。[3, 4, 5, 6]
是一个递增子序列,长度为 4。[1, 2, 6]
是一个递增子序列,长度为 3。[1, 1, 5, 9]
不是递增子序列 (因为有两个 1)。[3, 1]
不是递增子序列。在这个例子中,最长的递增子序列长度是 4。可能有多个 LIS,但它们的长度都是一样的。我们通常只关心这个 最大长度。
LIS 问题也是算法面试和编程竞赛中的常见题型。显然,你可以通过枚举法,把所有的子序列全找出来,然后求出每个子序列的长度,再找出长度的最大值,但我们这里主要讨论的是效率更高的动态规划解法。
状态定义
第一步,需要找到合适的状态定义。
我们要求整个序列的 LIS 长度,能不能把它分解成子问题?
对于含有 n
个元素的序列 q[n]
要求其最大递增子序列的长度,如果我们知道以 q[i]
这个元素为结尾的最长递增子序列长度是多少,就只需要推导出以 q[i+1]
为结尾的最长递增子序列长度是多少就行了。
状态定义:dp[i]
表示在原序列 q
中,以元素 q[i]
为结尾的最长递增子序列的长度。
注意:这里定义的 dp[i]
不是前 i
个元素的 LIS 长度,而是 q[i]
这个特定元素为结尾的 LIS 长度。
状态转移方程
仍然以序列 q = [3, 1, 4, 1, 5, 9, 2, 6]
为例。
先看最简单的情况,dp[1]
就表示以 q[1]
这个元素为结尾的 LIS 长度,显然:
dp[1] = 1
,表示以 q[1]
,也就是元素 3
为结尾的 LIS 长度,因为只有一个元素,长度当然就是 1
再看 dp[2]
【补充内容】
初始状态/边界条件
对于每一个 q[i]
,以它为结尾的 LIS 最差也就是它一个元素,因此初始值为 1
。
计算顺序
dp[i]
的计算依赖于 dp[j]
(j < i)。所以,我们需要按照 i
从 0 到 n-1
的顺序来计算 dp
数组。
我们计算出的 dp[i]
是以 a[i]
结尾的 LIS 长度。但整个序列的 LIS 不一定是以最后一个元素 a[n-1]
结尾的,它可能以任何一个 a[i]
结尾。
所以,最终的答案是 所有 dp[i]
(0 <= i < n) 中的最大值。
参考代码
i
从 0 到 n-1
,内部有一个嵌套循环 j
从 0 到 i-1
。主要的计算在内层循环的比较和更新。总的计算次数大约是 1 + 2 + ... + (n-1)
,这是 级别的。dp
数组来存储中间结果,大小为 n
。所以空间复杂度是 。虽然 O(n^2) 的 DP 方法很直观,但在 n
比较大(例如 n=10^5
)时会超时。存在一种更优化的方法,可以将时间复杂度降到 O(n log n)。
这个方法的核心思想有点不同,它不再直接计算 dp[i]
(以 a[i]
结尾的 LIS 长度),而是维护一个 “最优”结构。
核心思想: 维护一个数组 tails
(或者叫 min_end
),其中 tails[k]
存储的是 所有长度为 k+1
的递增子序列中,结尾元素的最小值。
为什么这个有用? 因为如果我们要构成一个更长的递增子序列,我们肯定希望它的结尾元素尽可能小,这样后面才更有可能接上新的元素。
算法流程:
tails
。a
中的每个元素 num
:
tails
数组中查找第一个 大于或等于 num
的元素的位置 idx
。
lower_bound
函数。tails
中所有元素都比 num
小(即 lower_bound
返回 tails.end()
),说明 num
可以扩展目前最长的递增子序列,使其长度加 1。将 num
添加到 tails
的末尾。idx
,说明 num
可以替换掉 tails[idx]
。为什么?因为 num <= tails[idx]
,用 num
作为长度为 idx+1
的递增子序列的结尾,比用原来的 tails[idx]
作为结尾 更优(或者一样优,但值更小),因为它为后续元素提供了更多可能性。所以,更新 tails[idx] = num
。tails
数组的 长度 就是原序列 LIS 的长度。参考代码
n
个元素。对于每个元素,我们在 tails
数组上执行一次二分查找。tails
数组的长度最多为 n
。二分查找的时间复杂度是 O(log k)
,其中 k
是 tails
的当前长度 (k <= n
)。所以总时间复杂度是 O(n log n)
。tails
数组来存储信息,其最大长度为 n
。所以空间复杂度是 O(n)
。