一、基本概念
動(dòng)態(tài)規(guī)劃過程是:每次決策依賴于當(dāng)前狀態(tài),又隨即引起狀態(tài)的轉(zhuǎn)移。一個(gè)決策序列就是在變化的狀態(tài)中產(chǎn)生出來的,所以,這種多階段最優(yōu)化決策解決問題的過程就稱為動(dòng)態(tài)規(guī)劃。
二、基本思想與策略
基本思想與分治法類似,也是將待求解的問題分解為若干個(gè)子問題(階段),按順序求解子階段,前一子問題的解,為后一子問題的求解提供了有用的信息。在求解任一子問題時(shí),列出各種可能的局部解,通過決策保留那些有可能達(dá)到最優(yōu)的局部解,丟棄其他局部解。依次解決各子問題,最后一個(gè)子問題就是初始問題的解。
由于動(dòng)態(tài)規(guī)劃解決的問題多數(shù)有重疊子問題這個(gè)特點(diǎn),為減少重復(fù)計(jì)算,對每一個(gè)子問題只解一次,將其不同階段的不同狀態(tài)保存在一個(gè)二維數(shù)組中。
與分治法最大的差別是:適合于用動(dòng)態(tài)規(guī)劃法求解的問題,經(jīng)分解后得到的子問題往往不是互相獨(dú)立的(即下一個(gè)子階段的求解是建立在上一個(gè)子階段的解的基礎(chǔ)上,進(jìn)行進(jìn)一步的求解)。
三、適用的情況
能采用動(dòng)態(tài)規(guī)劃求解的問題的一般要具有3個(gè)性質(zhì):
(1) 最優(yōu)化原理:如果問題的最優(yōu)解所包含的子問題的解也是最優(yōu)的,就稱該問題具有最優(yōu)子結(jié)構(gòu),即滿足最優(yōu)化原理。
(2) 無后效性:即某階段狀態(tài)一旦確定,就不受這個(gè)狀態(tài)以后決策的影響。也就是說,某狀態(tài)以后的過程不會影響以前的狀態(tài),只與當(dāng)前狀態(tài)有關(guān)。
(3)有重疊子問題:即子問題之間是不獨(dú)立的,一個(gè)子問題在下一階段決策中可能被多次使用到。(該性質(zhì)并不是動(dòng)態(tài)規(guī)劃適用的必要條件,但是如果沒有這條性質(zhì),動(dòng)態(tài)規(guī)劃算法同其他算法相比就不具備優(yōu)勢)
四、求解的基本步驟
動(dòng)態(tài)規(guī)劃所處理的問題是一個(gè)多階段決策問題,一般由初始狀態(tài)開始,通過對中間階段決策的選擇,達(dá)到結(jié)束狀態(tài)。這些決策形成了一個(gè)決策序列,同時(shí)確定了完成整個(gè)過程的一條活動(dòng)路線(通常是求最優(yōu)的活動(dòng)路線)。如圖所示。動(dòng)態(tài)規(guī)劃的設(shè)計(jì)都有著一定的模式,一般要經(jīng)歷以下幾個(gè)步驟。
初始狀態(tài)→│決策1│→│決策2│→…→│決策n│→結(jié)束狀態(tài)
圖1 動(dòng)態(tài)規(guī)劃決策過程示意圖
(1)劃分階段:按照問題的時(shí)間或空間特征,把問題分為若干個(gè)階段。在劃分階段時(shí),注意劃分后的階段一定要是有序的或者是可排序的,否則問題就無法求解。
(2)確定狀態(tài)和狀態(tài)變量:將問題發(fā)展到各個(gè)階段時(shí)所處于的各種客觀情況用不同的狀態(tài)表示出來。當(dāng)然,狀態(tài)的選擇要滿足無后效性。
(3)確定決策并寫出狀態(tài)轉(zhuǎn)移方程:因?yàn)闆Q策和狀態(tài)轉(zhuǎn)移有著天然的聯(lián)系,狀態(tài)轉(zhuǎn)移就是根據(jù)上一階段的狀態(tài)和決策來導(dǎo)出本階段的狀態(tài)。所以如果確定了決策,狀態(tài)轉(zhuǎn)移方程也就可寫出。但事實(shí)上常常是反過來做,根據(jù)相鄰兩個(gè)階段的狀態(tài)之間的關(guān)系來確定決策方法和狀態(tài)轉(zhuǎn)移方程。
(4)尋找邊界條件:給出的狀態(tài)轉(zhuǎn)移方程是一個(gè)遞推式,需要一個(gè)遞推的終止條件或邊界條件。
一般,只要解決問題的階段、狀態(tài)和狀態(tài)轉(zhuǎn)移決策確定了,就可以寫出狀態(tài)轉(zhuǎn)移方程(包括邊界條件)。
實(shí)際應(yīng)用中可以按以下幾個(gè)簡化的步驟進(jìn)行設(shè)計(jì):
(1)分析最優(yōu)解的性質(zhì),并刻畫其結(jié)構(gòu)特征。
(2)遞歸的定義最優(yōu)解。
(3)以自底向上或自頂向下的記憶化方式(備忘錄法)計(jì)算出最優(yōu)值
(4)根據(jù)計(jì)算最優(yōu)值時(shí)得到的信息,構(gòu)造問題的最優(yōu)解
五、算法實(shí)現(xiàn)的說明
動(dòng)態(tài)規(guī)劃的主要難點(diǎn)在于理論上的設(shè)計(jì),也就是上面4個(gè)步驟的確定,一旦設(shè)計(jì)完成,實(shí)現(xiàn)部分就會非常簡單。
使用動(dòng)態(tài)規(guī)劃求解問題,最重要的就是確定動(dòng)態(tài)規(guī)劃三要素:
(1)問題的階段 (2)每個(gè)階段的狀態(tài)
(3)從前一個(gè)階段轉(zhuǎn)化到后一個(gè)階段之間的遞推關(guān)系。
遞推關(guān)系必須是從次小的問題開始到較大的問題之間的轉(zhuǎn)化,從這個(gè)角度來說,動(dòng)態(tài)規(guī)劃往往可以用遞歸程序來實(shí)現(xiàn),不過因?yàn)檫f推可以充分利用前面保存的子問題的解來減少重復(fù)計(jì)算,所以對于大規(guī)模問題來說,有遞歸不可比擬的優(yōu)勢,這也是動(dòng)態(tài)規(guī)劃算法的核心之處。
確定了動(dòng)態(tài)規(guī)劃的這三要素,整個(gè)求解過程就可以用一個(gè)最優(yōu)決策表來描述,最優(yōu)決策表是一個(gè)二維表,其中行表示決策的階段,列表示問題狀態(tài),表格需要填寫的數(shù)據(jù)一般對應(yīng)此問題的在某個(gè)階段某個(gè)狀態(tài)下的最優(yōu)值(如最短路徑,最長公共子序列,最大價(jià)值等),填表的過程就是根據(jù)遞推關(guān)系,從1行1列開始,以行或者列優(yōu)先的順序,依次填寫表格,最后根據(jù)整個(gè)表格的數(shù)據(jù)通過簡單的取舍或者運(yùn)算求得問題的最優(yōu)解。
五、算法應(yīng)用示例
用一個(gè)實(shí)際例子來體現(xiàn)動(dòng)態(tài)規(guī)劃的算法思想——硬幣找零問題。
硬幣找零問題描述:現(xiàn)存在一堆面值為 V1、V2、V3 … 個(gè)單位的硬幣,問最少需要多少個(gè)硬幣才能找出總值為 T 個(gè)單位的零錢?假設(shè)這一堆面值分別為 1、2、5、21、25 元,需要找出總值 T 為 63 元的零錢。
很明顯,只要拿出 3 個(gè) 21 元的硬幣就湊夠了 63 元了。
基于上述動(dòng)態(tài)規(guī)劃的思想,我們可以從 1 元開始計(jì)算出最少需要幾個(gè)硬幣,然后再求 2 元、3元…每一次求得的結(jié)果都保存在一個(gè)數(shù)組中,以后需要用到時(shí)則直接取出即可。那么我們什么時(shí)候需要這些子問題的解呢?如何體現(xiàn)出由子問題的解得到較大問題的解呢?
其實(shí),在我們從 1 元開始依次找零時(shí),可以嘗試一下當(dāng)前要找零的面值(這里指 1 元)是否能夠被分解成另一個(gè)已求解的面值的找零需要的硬幣個(gè)數(shù)再加上這一堆硬幣中的某個(gè)面值之和,如果這樣分解之后最終的硬幣數(shù)是最少的,那么問題就得到答案了。
單是上面的文字描述太抽象,先假定以下變量:
values[] : 保存每一種硬幣的幣值的數(shù)組
valueKinds :幣值不同的硬幣種類數(shù)量,即values[]數(shù)組的大小
money : 需要找零的面值
coinsUsed[] : 保存面值為 i 的紙幣找零所需的最小硬幣數(shù)
算法描述:
當(dāng)求解總面值為 i 的找零最少硬幣數(shù) coinsUsed[ i ] 時(shí),將其分解成求解 coinsUsed[ i – cents]和一個(gè)面值為 cents 元的硬幣,由于 i – cents < i , 其解 coinsUsed[ i – cents] 已經(jīng)存在,如果面值為 cents 的硬幣滿足題意,那么最終解 coinsUsed[ i ] 則等于 coinsUsed[ i – cents] 再加上 1(即面值為 cents)的這一個(gè)硬幣。
下面用代碼實(shí)現(xiàn)并測試一下:
- public class CoinsChange {
- /**
- * 硬幣找零:動(dòng)態(tài)規(guī)劃算法
- *
- * @param values
- * :保存每一種硬幣的幣值的數(shù)組
- * @param valueKinds
- * :幣值不同的硬幣種類數(shù)量,即coinValue[]數(shù)組的大小
- * @param money
- * :需要找零的面值
- * @param coinsUsed
- * :保存面值為i的紙幣找零所需的最小硬幣數(shù)
- */
- public static void makeChange(int[] values, int valueKinds, int money,
- int[] coinsUsed) {
-
- coinsUsed[0] = 0;
- // 對每一分錢都找零,即保存子問題的解以備用,即填表
- for (int cents = 1; cents <= money; cents++) {
-
- // 當(dāng)用最小幣值的硬幣找零時(shí),所需硬幣數(shù)量最多
- int minCoins = cents;
-
- // 遍歷每一種面值的硬幣,看是否可作為找零的其中之一
- for (int kind = 0; kind < valueKinds; kind++) {
- // 若當(dāng)前面值的硬幣小于當(dāng)前的cents則分解問題并查表
- if (values[kind] <= cents) {
- int temp = coinsUsed[cents - values[kind]] + 1;
- if (temp < minCoins) {
- minCoins = temp;
- }
- }
- }
- // 保存最小硬幣數(shù)
- coinsUsed[cents] = minCoins;
-
- System.out.println("面值為 " + (cents) + " 的最小硬幣數(shù) : "
- + coinsUsed[cents]);
- }
- }
-
- public static void main(String[] args) {
-
- // 硬幣面值預(yù)先已經(jīng)按降序排列
- int[] coinValue = new int[] { 25, 21, 10, 5, 1 };
- // 需要找零的面值
- int money = 63;
- // 保存每一個(gè)面值找零所需的最小硬幣數(shù),0號單元舍棄不用,所以要多加1
- int[] coinsUsed = new int[money + 1];
-
- makeChange(coinValue, coinValue.length, money, coinsUsed);
- }
- }
0/1背包問題的動(dòng)態(tài)規(guī)劃法求解,前人之述備矣,這里所做的工作,不過是自己根據(jù)理解實(shí)現(xiàn)了一遍,主要目的還是鍛煉思維和編程能力,同時(shí),也是為了增進(jìn)對動(dòng)態(tài)規(guī)劃法機(jī)制的理解和掌握。
值得提及的一個(gè)問題是,在用 JAVA 實(shí)現(xiàn)時(shí), 是按算法模型建模,還是用對象模型建模呢? 如果用算法模型,那么 背包的值、重量就直接存入二個(gè)數(shù)組里;如果用對象模型,則要對背包以及背包問題進(jìn)行對象建模。思來想去,還是采用了對象模型,盡管心里感覺算法模型似乎更好一些。有時(shí)確實(shí)就是這樣,對象模型雖然現(xiàn)在很主流,但也不是萬能的,采用其它的模型和視角,或許可以得到更好的解法。
背包建模:
- public class Knapsack {
-
- /** 背包重量 */
- private int weight;
-
- /** 背包物品價(jià)值 */
- private int value;
- /***
- * 構(gòu)造器
- */
- 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 個(gè)背包,其重量分別為 w1,w2,……,wn, 價(jià)值分別為 v1,v2,……,vn
- * 要放入總承重為 totalWeight 的箱子中,
- * 求可放入箱子的背包價(jià)值總和的最大值。
- *
- * NOTE: 使用動(dòng)態(tài)規(guī)劃法求解 背包問題
- * 設(shè) 前 n 個(gè)背包,總承重為 j 的最優(yōu)值為 v[n,j], 最優(yōu)解背包組成為 b[n];
- * 求解最優(yōu)值:
- * 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]}。
- *
- * 求解最優(yōu)背包組成:
- * 1. 若 v[n,j] > v[n-1,j] 則 背包 n 被選擇放入 b[n],
- * 2. 接著求解前 n-1 個(gè)背包放入 j-wn 的總承重中,
- * 于是應(yīng)當(dāng)判斷 v[n-1, j-wn] VS v[n-2,j-wn], 決定 背包 n-1 是否被選擇。
- * 3. 依次逆推,直至總承重為零。
- *
- * 重點(diǎn): 掌握使用動(dòng)態(tài)規(guī)劃法求解問題的分析方法和實(shí)現(xiàn)思想。
- * 分析方法: 問題實(shí)例 P(n) 的最優(yōu)解S(n) 蘊(yùn)含 問題實(shí)例 P(n-1) 的最優(yōu)解S(n-1);
- * 在S(n-1)的基礎(chǔ)上構(gòu)造 S(n)
- * 實(shí)現(xiàn)思想: 自底向上的迭代求解 和 基于記憶功能的自頂向下遞歸
- */
- public class KnapsackProblem {
-
- /** 指定背包 */
- private Knapsack[] bags;
-
- /** 總承重 */
- private int totalWeight;
-
- /** 給定背包數(shù)量 */
- private int n;
-
- /** 前 n 個(gè)背包,總承重為 totalWeight 的最優(yōu)值矩陣 */
- private int[][] bestValues;
-
- /** 前 n 個(gè)背包,總承重為 totalWeight 的最優(yōu)值 */
- private int bestValue;
-
- /** 前 n 個(gè)背包,總承重為 totalWeight 的最優(yōu)解的物品組成 */
- 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 個(gè)背包、給定總承重為 totalWeight 下的背包問題
- *
- */
- public void solve() {
-
- System.out.println("給定背包:");
- for(Knapsack b: bags) {
- System.out.println(b);
- }
- System.out.println("給定總承重: " + totalWeight);
-
- // 求解最優(yōu)值
- 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 個(gè)背包重量大于總承重,則最優(yōu)解存在于前 i-1 個(gè)背包中,
- // 注意:第 i 個(gè)背包是 bags[i-1]
- if (j < bags[i-1].getWeight()) {
- bestValues[i][j] = bestValues[i-1][j];
- }
- else
- {
- // 如果第 i 個(gè)背包不大于總承重,則最優(yōu)解要么是包含第 i 個(gè)背包的最優(yōu)解,
- // 要么是不包含第 i 個(gè)背包的最優(yōu)解, 取兩者最大值,這里采用了分類討論法
- // 第 i 個(gè)背包的重量 iweight 和價(jià)值 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 個(gè)背包
- tempWeight -= bags[i-1].getWeight();
- }
- if (tempWeight == 0) { break; }
- }
- bestValue = bestValues[n][totalWeight];
- }
-
- /**
- * 獲得前 n 個(gè)背包, 總承重為 totalWeight 的背包問題的最優(yōu)解值
- * 調(diào)用條件: 必須先調(diào)用 solve 方法
- *
- */
- public int getBestValue() {
- return bestValue;
- }
-
- /**
- * 獲得前 n 個(gè)背包, 總承重為 totalWeight 的背包問題的最優(yōu)解值矩陣
- * 調(diào)用條件: 必須先調(diào)用 solve 方法
- *
- */
- public int[][] getBestValues() {
-
- return bestValues;
- }
-
- /**
- * 獲得前 n 個(gè)背包, 總承重為 totalWeight 的背包問題的最優(yōu)解值矩陣
- * 調(diào)用條件: 必須先調(diào)用 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(" -------- 該背包問題實(shí)例的解: --------- ");
- System.out.println("最優(yōu)值:" + kp.getBestValue());
- System.out.println("最優(yōu)解【選取的背包】: ");
- System.out.println(kp.getBestSolution());
- System.out.println("最優(yōu)決策矩陣表:");
- 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();
- }
- }
- }
|