动态规划
动态规划方法通常用来求解最优化问题。这里借助运筹学来引入,运筹学解决优化问题的思想就是拉格朗日乘数法和KKT定理,不过其本身具有一定的局限性,比如在列出目标优化函数、资源约束和决策约束后,我们很难用一些线性规划(单纯形表法),或非线性规划的kkt思想去求解这类问题,此时就需要考虑使用动态规划的思想或方法去解决此类问题。
前言
参考《算法导论》的介绍:动态规划与分治方法相似,都是通过组合子问题的解来求解原问题。本文会先从分治思想开始分析二者的相似性和不同点;
一、分治法
许多有用的算法在结构上式“递归的”。
如何理解?
在解决某个问题是重复的使用某一规则去进行评价目标,体现到程序上就是使用while或者for循环去迭代求解。
将这类问题的解法抽象描述为分治法:即将原问题分解为几个规模较小但类似于原问题的子问题,递归的求解这些子问题,然后再合并这些子问题的解来建立原问题的解。
分治模式在每层递归时都有三个步骤:
分解原问题为若干子问题,这些子问题是原问题的规模较小的实例;
解决这些子问题,递归的求解各子问题。然而,若子问题的规模足够小,则直接求解。
合并这些子问题的解成原问题的解。
举例:
EX1:归并排序算法就完全遵循分治模式。
分解待排列的n个元素的序列为成各具n/2个元素的两个子序列;
解决使用归并排序递归的排列两个子序列。
合并两个已排序的子序列以产生已排序的答案。
治阶段,我们需要将两个已经有序的子序列合并成一个有序序列,比如上图中的最后一次合并,要将[4,5,7,8]和[1,2,3,6]两个已经有序的子序列,合并为最终序列[1,2,3,4,5,6,7,8],来看下实现步骤。
总结:归并排序(MERGE-SORT)是利用归并的思想实现的排序方法,该算法采用经典的分治(divide-and-conquer)策略(分治法将问题分(divide)成一些小的问题然后递归求解,而治(conquer)的阶段则将分的阶段得到的各答案"修补"在一起,即分而治之)。
注:这篇文章对归并排序有详细介绍:归并排序
除此之外,像快速傅里叶变化的蝶形算法也满足分治思想。
二、动态规划
1.动态规划和分治法的异同点
动态规划方法与分治法相似,都是通过组合子问题的解来求解原问题的。分治法将原问题划分为互不相交的子问题,递归的求解子问题,再将它们的解组合起来,求出原问题的解。与之相反,动态规划应用于子问题重叠的情况,即不同的子问题具有公共子的子子问题。在这种情况下,分治算法会做许多不必要的工作,它会反复地求解那些公共子子问题。而动态规划算法对每个子子问题只求解一次,将其保存在一个表格中,从而无需每次求解一个个子问题时都重新计算,避免这种不必要的计算工作。
代码如下(示例):
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import warnings
warnings.filterwarnings('ignore')
import ssl
ssl._create_default_https_context = ssl._create_unverified_context
2.动态规划问题详例
2.1 钢条切割:
Serling公司购买长钢条,将其切割成短钢条进行出售。切割工序本身没有成本支出。管理层希望得到最优的切割方法。
现在我们知道Serling公司出售一段长度为
i
i
i英寸的钢条的价格为 $p_i(i=1,2,3,…,n) $,单位为美元。钢条的长度都为整英寸。
长度i | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
价格 | 1 | 5 | 8 | 9 | 10 | 17 | 17 | 20 | 24 | 30 |
钢条切割问题:给定一段长度为n英寸的钢条和一个价格表 p i ( i = 1 , 2 , . . . , n ) p_i(i=1,2,...,n) pi(i=1,2,...,n),求钢条切割方案,使得收益 r n r_n rn最大。注意,如果长度为n英寸的钢条的价格 p n p_n pn足够大,最优解可能就是完全不需要切割。
以4英寸的钢条切割方案为例:
从图中可以看出共
2
3
=
8
2^3=8
23=8种切割方案;最优收益为
p
2
+
p
2
=
5
+
5
=
10
p_2+p_2=5+5=10
p2+p2=5+5=10;
将其抽象为数学模型:
长度为n英寸的钢条共有
2
n
−
1
2^{n-1}
2n−1种不同切割方案,因为在距离钢条左端
i
(
i
=
1
,
2
,
.
.
,
n
−
1
)
i(i=1,2,..,n-1)
i(i=1,2,..,n−1)英寸处总是选择切割或不切切割。
如果一个最优解将钢条切割为k端(对某个1≤k≤n),那么最优切割方案:
n
=
i
1
+
i
2
+
.
.
.
+
i
k
n=i_1+i_2+...+i_k
n=i1+i2+...+ik
将钢条切割为长度分别为
i
1
,
i
2
,
.
.
.
,
i
k
i_1,i_2,...,i_k
i1,i2,...,ik的小段得到最大收益:
r
n
=
p
i
1
+
p
i
2
+
.
.
.
+
p
i
k
r_n=p_{i_1}+p_{i_2}+...+p_{i_k}
rn=pi1+pi2+...+pik
结合图片切割方案来看,可以将钢条切割分为多次,即切完一刀后,分别对剩下的两部分进行切割,以此来确保最大收益。
用公式描述:第一次切割:
r
n
=
m
a
x
(
p
n
,
r
1
+
r
n
−
1
,
r
2
+
r
n
−
2
,
.
.
r
n
−
1
+
r
1
)
r_n = max(p_n,r_1+r_{n-1},r_2+r_{n-2},..r_{n-1}+r_1)
rn=max(pn,r1+rn−1,r2+rn−2,..rn−1+r1)
如何理解上述公式:
第一个参数
p
n
p_n
pn对应不切割,直接出售长度为n英寸的钢条切割方案。其它n-1个参数对应另外n-1个方案:对每个i=1,2,…,n-1,首先将钢条切割为长度为i和n-i的两段,接着求解两段的最优收益
r
i
和
r
n
−
i
r_i和r_{n-i}
ri和rn−i(每种方案的最优收益为两段的最优收益之和)。由于无法预知哪种方案会获得最优收益,必须考虑所有的可能的i,选取其中收益最大者。如果直接出售原钢条会获得最大收益,当然不做任何切割。
问题抽象:为了求解规模为n的原问题,我们先求解形式完全一样,但规模更小的子问题。即当完全首次切割后,我们将两段钢条看成两个独立的钢条切割问题实例。我们通过组合两个相关子问题的最优解,并在所有可能的两段切割方案中选取组合收益最大者,构成原问题的最优解。
钢条切割问题满足最优子结构:问题的最优解由相关子问题的最优解组合而成,而这些子问题可以独立求解。
除了上述求解方法外,钢条切割问题还存在一种相似的但更为简单的递归求解方法:
将钢条从左边切割下长度为i的一段,只对右边剩下 的长度为n-i的一段进行切割(递归求解),对左边的一段则不进行切割。即问题分解的方式为:将长度为n的钢条分解为左边开始一段,以及剩余部分分解的结果。即:
r
n
=
m
a
x
(
p
i
+
r
n
−
i
)
,其中
1
≤
i
≤
n
r_n = max(p_i+r_{n-i}) ,其中1≤i≤n
rn=max(pi+rn−i),其中1≤i≤n
上述公式中:第一段的长度为n,收益为
p
n
p_n
pn,剩余部分长度为0,对应的收益为
r
0
=
0
r_0=0
r0=0;原问题的最优解包含一个相关子问题(右端剩余部分)的解,而不是两个。
自顶向下递归实现
算法思路如下:
cut_rod(p,n)
if n==0
return 0;
q = -无穷
for i = 1 to n
q = max(q,p[i]+cut_rod(p,n-i))
return q
需要指数运算时间
2
n
2^n
2n
使用动态规划方法求解最优钢条切割问题
时间换空间
可能将一个指数时间的解转化为一个多项式时间的解。如果子问题的数量是输入规模的多项式函数,而我们可以在多项式时间内求解出每个子问题,那么动态规划方法的总运行时间就是多项式阶的。
动态规划的两种实现方法
带备忘的自顶向下法此方法仍按照自然的递归形式编写过程,但过程会保存每个子问题的解。当需要一个子问题的解时,过程首先检查是否已经保存过此解。如果是,则直接返回保存的值。从而节省了计算时间。
//伪代码
MEMOIZED-CUT-ROD(p,n)
let r[0...n] be a new array
// 将辅助数组r[0..n]的元素初始化为-无穷
for i=0 to n
r[i]=-∞
// 调用辅助过程函数
return MEMOIZED-CUT-ROD-AUX(p,n,r)
MEMOIZED-CUT-ROD-AUX(p,n,r)
// 查到所需值,直接返回保存值
if r[n]>=0
return r[i]
// 计算收益q
if n==0
q=0
else
q=-∞
for i=1 to n
q=max(q,p[i]+MEMOIZED-CUT-ROD-AUX(p,n-i,r))
// 每次存入r[n]
r[n]=q
return q
自底向上法这种方法一般需要恰当定义子问题“规模”的概念,使得任何子问题的求解都依赖于“更小的”子问题的求解。因此我们可以将子问题按规模排序,按由小到大的顺序进行求解。当求解某个子问题时,它所依赖的那些更小的子问题都已经求解完毕,结果已经保存。每个子问题只需求解一次,当我们求解它(也是第一次遇到它)时,它的所有前提子问题都已经求解完成。
//伪代码
BOTTOM-UP-CUT-ROD(p,n)
let r[0..n] be a new array
r[0]=0
for j=1 to n
q=-∞
// 从最小问题进行求解
for i=1 to j
q=max(q,p[i]+r[j-i])
r[j]=q
return r[n]
重构解问题
(1)问题描述
如何修改动态规划算法,使其不仅输出最优解,还输出最优切割方案?
(2)解题思路
根据简化后的递推式,其中左边部分不再切割,右边部分可以再切割。
对每个子问题,保存切割一次时左边切下的长度,即不再切割的部分,定义为s[j]。
3.动态规划总结
动态规划的核心原理是通过分解问题为重叠子问题,利用最优子结构性质,结合状态转移方程和记忆化存储来提高计算效率。 其核心思想体现在以下关键点:
最优子结构
动态规划问题的最优解包含其子问题的最优解。例如,在数塔问题中,从顶点到底层的最大路径和,可以通过每一层选择左右子路径的最优解逐步推导得出。类似地,背包问题的最优解依赖于物品选择和剩余容量的子问题最优解。
重叠子问题
在递归求解过程中,相同子问题会被多次计算。例如,在计算斐波那契数列时,F(n)=F(n−1)+F(n−2)F(n)=F(n−1)+F(n−2),直接递归会产生指数级重复计算。动态规划通过存储子问题的解(记忆化或填表法)避免重复。
状态定义与转移方程
状态定义:需明确问题的变量和状态表示。
状态转移方程:描述状态间的递推关系。
实现方法
自顶向下(记忆化搜索):通过递归+存储子问题结果实现,如数塔问题的递归解法优化。
自底向上(迭代填表):从最小子问题开始逐步求解,如背包问题通过二维数组递推。
动态规划的典型应用场景
最优化问题:如旅行商问题、最短路径问题。
组合计数:如字符串匹配、括号生成问题。
资源分配:如任务调度、股票买卖最佳时机。
动态规划的关键在于合理定义状态和设计转移方程,同时利用子问题解的存储避免重复计算。 例如,斐波那契数列的递归实现时间复杂度为 O ( 2 n ) O(2^n) O(2n),而动态规划可优化至O(n)。