一、基本概念
动态规划过程是:每次决策依赖于当前状态,又随即引起状态的转移。一个决策序列就是在变化的状态中产生出来的,所以,这种多阶段最优化决策解决问题的过程就称为动态规划。
二、基本思想与策略
基本思想与分治法类似,也是将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解。
由于动态规划解决的问题多数有重叠子问题这个特点,为减少重复计算,对每一个子问题只解一次,将其不同阶段的不同状态保存在一个二维数组中。
与分治法最大的差别是:适合于用动态规划法求解的问题,经分解后得到的子问题往往不是互相独立的(即下一个子阶段的求解是建立在上一个子阶段的解的基础上,进行进一步的求解)。
三、适用的情况
能采用动态规划求解的问题的一般要具有3个性质:
(1) 最优化原理:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。
(2) 无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响以前的状态,只与当前状态有关。
(3)有重叠子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势)
四、求解的基本步骤
动态规划所处理的问题是一个多阶段决策问题,一般由初始状态开始,通过对中间阶段决策的选择,达到结束状态。这些决策形成了一个决策序列,同时确定了完成整个过程的一条活动路线(通常是求最优的活动路线)。如图所示。动态规划的设计都有着一定的模式,一般要经历以下几个步骤。
初始状态→│决策1│→│决策2│→…→│决策n│→结束状态
图1 动态规划决策过程示意图
(1)划分阶段:按照问题的时间或空间特征,把问题分为若干个阶段。在划分阶段时,注意划分后的阶段一定要是有序的或者是可排序的,否则问题就无法求解。
(2)确定状态和状态变量:将问题发展到各个阶段时所处于的各种客观情况用不同的状态表示出来。当然,状态的选择要满足无后效性。
(3)确定决策并写出状态转移方程:因为决策和状态转移有着天然的联系,状态转移就是根据上一阶段的状态和决策来导出本阶段的状态。所以如果确定了决策,状态转移方程也就可写出。但事实上常常是反过来做,根据相邻两个阶段的状态之间的关系来确定决策方法和状态转移方程。
(4)寻找边界条件:给出的状态转移方程是一个递推式,需要一个递推的终止条件或边界条件。
一般,只要解决问题的阶段、状态和状态转移决策确定了,就可以写出状态转移方程(包括边界条件)。
实际应用中可以按以下几个简化的步骤进行设计:
(1)分析最优解的性质,并刻画其结构特征。
(2)递归的定义最优解。
(3)以自底向上或自顶向下的记忆化方式(备忘录法)计算出最优值
(4)根据计算最优值时得到的信息,构造问题的最优解
五、算法实现的说明
动态规划的主要难点在于理论上的设计,也就是上面4个步骤的确定,一旦设计完成,实现部分就会非常简单。
使用动态规划求解问题,最重要的就是确定动态规划三要素:
(1)问题的阶段 (2)每个阶段的状态
(3)从前一个阶段转化到后一个阶段之间的递推关系。
递推关系必须是从次小的问题开始到较大的问题之间的转化,从这个角度来说,动态规划往往可以用递归程序来实现,不过因为递推可以充分利用前面保存的子问题的解来减少重复计算,所以对于大规模问题来说,有递归不可比拟的优势,这也是动态规划算法的核心之处。
确定了动态规划的这三要素,整个求解过程就可以用一个最优决策表来描述,最优决策表是一个二维表,其中行表示决策的阶段,列表示问题状态,表格需要填写的数据一般对应此问题的在某个阶段某个状态下的最优值(如最短路径,最长公共子序列,最大价值等),填表的过程就是根据递推关系,从1行1列开始,以行或者列优先的顺序,依次填写表格,最后根据整个表格的数据通过简单的取舍或者运算求得问题的最优解。
五、算法应用示例
用一个实际例子来体现动态规划的算法思想——硬币找零问题。
硬币找零问题描述:现存在一堆面值为 V1、V2、V3 … 个单位的硬币,问最少需要多少个硬币才能找出总值为 T 个单位的零钱?假设这一堆面值分别为 1、2、5、21、25 元,需要找出总值 T 为 63 元的零钱。
很明显,只要拿出 3 个 21 元的硬币就凑够了 63 元了。
基于上述动态规划的思想,我们可以从 1 元开始计算出最少需要几个硬币,然后再求 2 元、3元…每一次求得的结果都保存在一个数组中,以后需要用到时则直接取出即可。那么我们什么时候需要这些子问题的解呢?如何体现出由子问题的解得到较大问题的解呢?
其实,在我们从 1 元开始依次找零时,可以尝试一下当前要找零的面值(这里指 1 元)是否能够被分解成另一个已求解的面值的找零需要的硬币个数再加上这一堆硬币中的某个面值之和,如果这样分解之后最终的硬币数是最少的,那么问题就得到答案了。
单是上面的文字描述太抽象,先假定以下变量:
values[] : 保存每一种硬币的币值的数组
valueKinds :币值不同的硬币种类数量,即values[]数组的大小
money : 需要找零的面值
coinsUsed[] : 保存面值为 i 的纸币找零所需的最小硬币数
算法描述:
当求解总面值为 i 的找零最少硬币数 coinsUsed[ i ] 时,将其分解成求解 coinsUsed[ i – cents]和一个面值为 cents 元的硬币,由于 i – cents < i , 其解 coinsUsed[ i – cents] 已经存在,如果面值为 cents 的硬币满足题意,那么最终解 coinsUsed[ i ] 则等于 coinsUsed[ i – cents] 再加上 1(即面值为 cents)的这一个硬币。
下面用代码实现并测试一下:
- public class CoinsChange {
- /**
- * 硬币找零:动态规划算法
- *
- * @param values
- * :保存每一种硬币的币值的数组
- * @param valueKinds
- * :币值不同的硬币种类数量,即coinValue[]数组的大小
- * @param money
- * :需要找零的面值
- * @param coinsUsed
- * :保存面值为i的纸币找零所需的最小硬币数
- */
- public static void makeChange(int[] values, int valueKinds, int money,
- int[] coinsUsed) {
- coinsUsed[0] = 0;
- // 对每一分钱都找零,即保存子问题的解以备用,即填表
- for (int cents = 1; cents <= money; cents++) {
- // 当用最小币值的硬币找零时,所需硬币数量最多
- int minCoins = cents;
- // 遍历每一种面值的硬币,看是否可作为找零的其中之一
- for (int kind = 0; kind < valueKinds; kind++) {
- // 若当前面值的硬币小于当前的cents则分解问题并查表
- if (values[kind] <= cents) {
- int temp = coinsUsed[cents - values[kind]] + 1;
- if (temp < minCoins) {
- minCoins = temp;
- }
- }
- }
- // 保存最小硬币数
- coinsUsed[cents] = minCoins;
- System.out.println("面值为 " + (cents) + " 的最小硬币数 : "
- + coinsUsed[cents]);
- }
- }
- public static void main(String[] args) {
- // 硬币面值预先已经按降序排列
- int[] coinValue = new int[] { 25, 21, 10, 5, 1 };
- // 需要找零的面值
- int money = 63;
- // 保存每一个面值找零所需的最小硬币数,0号单元舍弃不用,所以要多加1
- int[] coinsUsed = new int[money + 1];
- makeChange(coinValue, coinValue.length, money, coinsUsed);
- }
- }
0/1背包问题的动态规划法求解,前人之述备矣,这里所做的工作,不过是自己根据理解实现了一遍,主要目的还是锻炼思维和编程能力,同时,也是为了增进对动态规划法机制的理解和掌握。
值得提及的一个问题是,在用 JAVA 实现时, 是按算法模型建模,还是用对象模型建模呢? 如果用算法模型,那么 背包的值、重量就直接存入二个数组里;如果用对象模型,则要对背包以及背包问题进行对象建模。思来想去,还是采用了对象模型,尽管心里感觉算法模型似乎更好一些。有时确实就是这样,对象模型虽然现在很主流,但也不是万能的,采用其它的模型和视角,或许可以得到更好的解法。
背包建模:
- public class Knapsack {
- /** 背包重量 */
- private int weight;
- /** 背包物品价值 */
- private int value;
- /***
- * 构造器
- */
- public Knapsack(int weight, int value) {
- this.value = value;
- this.weight = weight;
- }
- public int getWeight() {
- return weight;
- }
- public int getValue() {
- return value;
- }
- public String toString() {
- return "[weight: " + weight + " " + "value: " + value + "]";
- }
- }
- import java.util.ArrayList;
- /**
- * 求解背包问题:
- * 给定 n 个背包,其重量分别为 w1,w2,……,wn, 价值分别为 v1,v2,……,vn
- * 要放入总承重为 totalWeight 的箱子中,
- * 求可放入箱子的背包价值总和的最大值。
- *
- * NOTE: 使用动态规划法求解 背包问题
- * 设 前 n 个背包,总承重为 j 的最优值为 v[n,j], 最优解背包组成为 b[n];
- * 求解最优值:
- * 1. 若 j < wn, 则 : v[n,j] = v[n-1,j];
- * 2. 若 j >= wn, 则:v[n,j] = max{v[n-1,j], vn + v[n-1,j-wn]}。
- *
- * 求解最优背包组成:
- * 1. 若 v[n,j] > v[n-1,j] 则 背包 n 被选择放入 b[n],
- * 2. 接着求解前 n-1 个背包放入 j-wn 的总承重中,
- * 于是应当判断 v[n-1, j-wn] VS v[n-2,j-wn], 决定 背包 n-1 是否被选择。
- * 3. 依次逆推,直至总承重为零。
- *
- * 重点: 掌握使用动态规划法求解问题的分析方法和实现思想。
- * 分析方法: 问题实例 P(n) 的最优解S(n) 蕴含 问题实例 P(n-1) 的最优解S(n-1);
- * 在S(n-1)的基础上构造 S(n)
- * 实现思想: 自底向上的迭代求解 和 基于记忆功能的自顶向下递归
- */
- public class KnapsackProblem {
- /** 指定背包 */
- private Knapsack[] bags;
- /** 总承重 */
- private int totalWeight;
- /** 给定背包数量 */
- private int n;
- /** 前 n 个背包,总承重为 totalWeight 的最优值矩阵 */
- private int[][] bestValues;
- /** 前 n 个背包,总承重为 totalWeight 的最优值 */
- private int bestValue;
- /** 前 n 个背包,总承重为 totalWeight 的最优解的物品组成 */
- private ArrayList<Knapsack> bestSolution;
- public KnapsackProblem(Knapsack[] bags, int totalWeight) {
- this.bags = bags;
- this.totalWeight = totalWeight;
- this.n = bags.length;
- if (bestValues == null) {
- bestValues = new int[n+1][totalWeight+1];
- }
- }
- /**
- * 求解前 n 个背包、给定总承重为 totalWeight 下的背包问题
- *
- */
- public void solve() {
- System.out.println("给定背包:");
- for(Knapsack b: bags) {
- System.out.println(b);
- }
- System.out.println("给定总承重: " + totalWeight);
- // 求解最优值
- for (int j = 0; j <= totalWeight; j++) {
- for (int i = 0; i <= n; i++) {
- if (i == 0 || j == 0) {
- bestValues[i][j] = 0;
- }
- else
- {
- // 如果第 i 个背包重量大于总承重,则最优解存在于前 i-1 个背包中,
- // 注意:第 i 个背包是 bags[i-1]
- if (j < bags[i-1].getWeight()) {
- bestValues[i][j] = bestValues[i-1][j];
- }
- else
- {
- // 如果第 i 个背包不大于总承重,则最优解要么是包含第 i 个背包的最优解,
- // 要么是不包含第 i 个背包的最优解, 取两者最大值,这里采用了分类讨论法
- // 第 i 个背包的重量 iweight 和价值 ivalue
- int iweight = bags[i-1].getWeight();
- int ivalue = bags[i-1].getValue();
- bestValues[i][j] =
- Math.max(bestValues[i-1][j], ivalue + bestValues[i-1][j-iweight]);
- } // else
- } //else
- } //for
- } //for
- // 求解背包组成
- if (bestSolution == null) {
- bestSolution = new ArrayList<Knapsack>();
- }
- int tempWeight = totalWeight;
- for (int i=n; i >= 1; i--) {
- if (bestValues[i][tempWeight] > bestValues[i-1][tempWeight]) {
- bestSolution.add(bags[i-1]); // bags[i-1] 表示第 i 个背包
- tempWeight -= bags[i-1].getWeight();
- }
- if (tempWeight == 0) { break; }
- }
- bestValue = bestValues[n][totalWeight];
- }
- /**
- * 获得前 n 个背包, 总承重为 totalWeight 的背包问题的最优解值
- * 调用条件: 必须先调用 solve 方法
- *
- */
- public int getBestValue() {
- return bestValue;
- }
- /**
- * 获得前 n 个背包, 总承重为 totalWeight 的背包问题的最优解值矩阵
- * 调用条件: 必须先调用 solve 方法
- *
- */
- public int[][] getBestValues() {
- return bestValues;
- }
- /**
- * 获得前 n 个背包, 总承重为 totalWeight 的背包问题的最优解值矩阵
- * 调用条件: 必须先调用 solve 方法
- *
- */
- public ArrayList<Knapsack> getBestSolution() {
- return bestSolution;
- }
- }
- public class KnapsackTest {
- public static void main(String[] args) {
- Knapsack[] bags = new Knapsack[] {
- new Knapsack(2,13), new Knapsack(1,10),
- new Knapsack(3,24), new Knapsack(2,15),
- new Knapsack(4,28), new Knapsack(5,33),
- new Knapsack(3,20), new Knapsack(1, 8)
- };
- int totalWeight = 10;
- KnapsackProblem kp = new KnapsackProblem(bags, totalWeight);
- kp.solve();
- System.out.println(" -------- 该背包问题实例的解: --------- ");
- System.out.println("最优值:" + kp.getBestValue());
- System.out.println("最优解【选取的背包】: ");
- System.out.println(kp.getBestSolution());
- System.out.println("最优决策矩阵表:");
- int[][] bestValues = kp.getBestValues();
- for (int i=0; i < bestValues.length; i++) {
- for (int j=0; j < bestValues[i].length; j++) {
- System.out.printf("%-5d", bestValues[i][j]);
- }
- System.out.println();
- }
- }
- }