算法思想 - 動態規劃算法,0/1揹包,旅行商問題,最大和子串 - java實現

動態規劃算法

動態規劃(dynamic programming)是運籌學的一個分支,是求解決策過程(decision process)最優化的數學方法。

在這裏插入圖片描述

一、前序知識

  • 最優化原理:
    簡單來說就是一個最優策略的子策略也是必須是最優的,而所有子問題的局部最優解將導致整個問題的全局最優。如果一個問題能滿足最優化原理,就稱其具有最優子結構性質

一、基本思想

  動態規劃算法通常用於求解具有某種最優性質的問題。在這類問題中,可能會有許多可行解。每一個解都對應於一個值,我們希望找到具有最優值的解。動態規劃算法與分治法類似,其基本思想也是將待求解問題分解成若干個子問題,先求解子問題,然後從這些子問題的解得到原問題的解。與分治法不同的是,適合於用動態規劃求解的問題,經分解得到子問題往往不是互相獨立的。若用分治法來解這類問題,則分解得到的子問題數目太多,有些子問題被重複計算了很多次。如果我們能夠保存已解決的子問題的答案,而在需要時再找出已求得的答案,這樣就可以避免大量的重複計算,節省時間。我們可以用一個表來記錄所有已解的子問題的答案。不管該子問題以後是否被用到,只要它被計算過,就將其結果填入表中。這就是動態規劃法的基本思路。具體的動態規劃算法多種多樣,但它們具有相同的填表格式。

二、適用情況

  1. 最優子結構性質:如果問題的最優解所包含的子問題的解也是最優的,我們就稱該問題具有最優子結構性質(即滿足最優化原理)。最優子結構性質爲動態規劃算法解決問題提供了重要線索。
  2. 無後效性:即子問題的解一旦確定,就不再改變,不受在這之後、包含它的更大的問題的求解決策影響。
  3. 子問題重疊性質:子問題重疊性質是指在用遞歸算法自頂向下對問題進行求解時,每次產生的子問題並不總是新問題,有些子問題會被重複計算多次。動態規劃算法正是利用了這種子問題的重疊性質,對每一個子問題只計算一次,然後將其計算結果保存在一個表格中,當再次需要計算已經計算過的子問題時,只是在表格中簡單地查看一下結果,從而獲得較高的效率。(重疊子問題)

三、一般解題思路

  1. 將原問題轉化爲子問題:
    • 子問題與原問題形式相同或類似,只是問題規模變小了,從而變簡單了;
    • 子問題一旦求出就要保存下來,保證每個子問題只求解一遍
  2. 確定狀態:
    • 狀態:和子問題相關的各個變量的一組取值 ,稱之爲一個“狀態”。一個“狀態”對應於一個或多個子問題,所謂某“狀態的值”,就是這個“狀態”所對應的子問題的解。
    • 狀態空間:所有“狀態”的集合,構成問題的“狀態空間”,“狀態空間”的大小,與用動態規劃解決問題的時間複雜度直接相關。
  3. 確定一些初始狀態(邊界狀態)
    可以理解爲遞歸的終止條件
  4. 確定狀態轉移方程:
    狀態的轉移可以用遞推公式表示,此地推公式也可以被稱作“狀態轉移方程”。
    形式類似如下:
    在這裏插入圖片描述

四、經典案例

1、0/1揹包問題

0-1 揹包問題: 給定 n 種物品和一個容量爲 C 的揹包,物品 i 的重量是 wi,其價值爲 vi
問:應該如何選擇裝入揹包的物品,使得裝入揹包中的物品的總價值最大?。

解題步驟:

  1. 將原問題轉化爲子問題:
    子問題:
    當揹包容量爲 0 時,從 0 ~ n個物品中選擇放入揹包,獲得的最優解。
    當揹包容量爲 1 時,從 0 ~ n個物品中選擇放入揹包,獲得的最優解。
    當揹包容量爲 2 時,從 0 ~ n個物品中選擇放入揹包,獲得的最優解。
    。。。。。。
    當揹包容量爲 8 時,從 0 ~ n個物品中選擇放入揹包,獲得的最優解。
    一共有 (c+1) * (n+1) 個子問題。(c:揹包容量,n:物品數量)
  2. 確定狀態:
    定義狀態,對於 0/1 揹包問題,可以設計一個二維數組dp[n][c]來存放子問題的狀態, 而每一個子問題的狀態爲 d[i][j] 表示 從物品 1 ~ i 物品中選擇放入容量爲 j 的揹包得到的最優解。
  3. 確定一些初始狀態(邊界狀態):
    當不放入物品時,價值爲0:dp[0][0~c] = 0
    當揹包容量爲0時,價值爲0:dp[0~n][0] = 0
  4. 確定狀態轉移方程:
    該問題的狀態轉移方程即:分析得出 dp[i][j] 的計算方法
    • j < w[i] 的情況,這時候揹包容量不足以放下第 i 件物品,只能選擇不拿
      dp[ i ][ j ] = dp[ i-1 ][ j ]

    • j >= w[i] 的情況,這時揹包容量可以放下第 i 件物品,我們就要考慮拿這件物品是否能獲取更大的價值。

      • 如果拿取:dp[ i ][ j ] = dp[ i-1 ][ j-w[ i ] ] + v[ i ]。 這裏的 dp[ i-1 ][ j-w[ i ]]是從 1 ~ i-1 件物品選擇,揹包容量爲 j-w[i] 時的最大價值。
      • 如果不拿:dp[ i ][ j ] = dp[ i-1 ][ j ]

      通過比較這兩種情況哪種價值最大,來判斷是否要放入第 i 個物品,即:

		if(j >= w[i])
    		dp[i][j] = max(dp[i-1][j],dp[i-1][j-w[i]]+v[i]);
		else
    		dp[i][j] = dp[i-1][j];

實戰:
假如有1個揹包,揹包容量是8。有4個物品,重量分別爲:{2,3,4,5},價值爲:{3,4,5,6}
要求在不超過揹包容量的情況下,使揹包裝載物品的價值最大。

dp[n][c]數組計算結果如下:當揹包容量爲8,從4個物品中選,最優解爲 dp[4][8] = 10

物品編號 i \ 揹包容量 j 0 1 2 3 4 5 6 7 8
0 0 0 0 0 0 0 0 0 0
1 0 0 3 3 3 3 3 3 3
2 0 0 3 4\color{blue}4 4 7 7 7 7
3 0 0 3 4 5 7 8 9 9
4 0 0 3 4 5 7 8 9 10\color{red}10

計算過程:以表格中的藍色的4,即 dp[2][3] 進行分析:
在這裏插入圖片描述
dp[2][3]: 當不裝入2物品時,dp[2][3] = dp[1][3] = 3;當裝入2物品時,揹包容量爲3,去除2物品的重量,揹包容量還剩0,即 dp[2][3] = dp[1][0] + w[2] = 0 + 4
選兩者的最大值4,即dp[2][3] = 4,填入dp[][]表。

通過這一種方式,我們可以得到揹包問題中的最優解,但是並不能知道在最優解的情況下,揹包中放入了哪些物品。
爲了想要知道在最優解時揹包中放了哪些物品,我們可以採取回溯的方法。從表格的右下角(動態規劃的終點)開始回溯,利用性質 dp[i][j] == dp[i-1][lj] 表示第 i 個物品沒有放入揹包,不相等則表示放入揹包。

  • 回溯
    dp[4][8] != dp[3][8]:第 4 個物品放入揹包。第4個物品放入揹包,揹包的容量爲 8-5=3,這時候回溯到 dp[3][3]。
    dp[3][3] == dp[2][3]:第 3 個物品沒有放入揹包。這時候可以回溯到dp[2][3]
    dp[2][3] != dp[1][3]:第 2 個物品放入揹包。第2個物品放入揹包,揹包的容量爲 3-3=0,這時候回溯到 dp[1][0]。
    dp[1][0] == dp[0][0]:第 1 個物品沒有放入揹包
    最終可以得出在最優解爲10時,此時揹包中放入的物品爲:物品{2,4}

算法實現

package indi.pentiumcm.thought;

import java.util.ArrayList;
import java.util.List;

/**
 * @projName: algorithm
 * @packgeName: indi.pentiumcm.thought
 * @className: DpBack
 * @author: pentiumCM
 * @email: [email protected]
 * @date: 2020/3/25 23:38
 * @describe: 動態規劃 - 0/1揹包問題
 */
public class DpBack {


    /**
     * 0/1揹包算法實現
     *
     * @param backCap 揹包的容量
     * @param weights 物品的重量數組
     * @param values  物品的價值數組
     * @return
     */
    public void backPro(int backCap, int[] weights, int[] values) {

//      物品個數
        int goodNum = weights.length;

//      1. 確定狀態
//      dp[i][j] 表示前i件物品放入重量爲j的揹包時的最大價值
        int[][] dp = new int[goodNum + 1][backCap + 1];

//      2. 確定一些初始狀態:dp[0][0~backCap]=0,dp[0~goodNum][0]=0
        for (int i = 0; i <= backCap; i++) {
            dp[0][i] = 0;
        }
        for (int i = 0; i <= goodNum; i++) {
            dp[i][0] = 0;
        }

//      3.確定狀態轉移方程:dp[i][j] = max(dp[i-1][j],dp[i-1][j-w[i]]+v[i]);
        for (int i = 1; i <= goodNum; i++) {
//          j 從 1 遍歷到揹包容量
            for (int j = 1; j <= backCap; j++) {
                if (weights[i - 1] <= j) {
                    dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weights[i - 1]] + values[i - 1]);
                } else {
                    dp[i][j] = dp[i - 1][j];
                }
            }
        }


//      回溯,看揹包裏面放了哪些物品
        int tempCap = backCap;
        List<Integer> backList = new ArrayList<>();
        for (int i = goodNum; i > 0; i--) {
            if (dp[i][tempCap] != dp[i - 1][tempCap]) {
                backList.add(i);
                tempCap -= weights[i - 1];
            }

        }

        System.out.print("揹包中放了物品:");
        for (int i = backList.size() - 1; i >= 0; i--) {
            System.out.print(" " + backList.get(i));
        }


        System.out.print("\n" + "獲得的最大價值爲: " + dp[goodNum][backCap]);
    }

    public static void main(String[] args) {
//      揹包容量
        int backCap = 8;
//      物品的重量
        int[] weights = {2, 3, 4, 5};
//      物品的價值
        int[] values = {3, 4, 5, 6};

        new DpBack().backPro(backCap, weights, values);
    }
}

運行結果:
在這裏插入圖片描述

2、旅行商問題

旅行商問題(TSP問題):
是指旅行家要旅行n個城市,要求各個城市經歷且僅經歷一次然後回到出發城市,並要求所走的路程最短。

示例:
其中0,1,2,3,4代表五個城市,例如從城市0出發,經過城市1,2,3,4,最後回到城市0。
此模型可抽象爲圖,城市之間的距離可用鄰接矩陣 C 表示,如下圖所示:
在這裏插入圖片描述解題步驟:

  1. 將原問題轉化爲子問題:
    原問題:
      從0出發,經過{1,2,3,4}這幾個城市,然後回到0,使得路程最短。
    子問題:

    • 從頂點 0 出發,到1,然後再從1出發,經過{2,3,4}這幾個城市,然後回到0,使得路程最短。
      • 從頂點 0 出發,到1,然後再從1出發到2,經過{3,4}幾個城市,最後回到0,使得路程最短。
        。。。。。。
    • 從頂點 0 出發,到2,然後再從2出發,經過{1,3,4}這幾個城市,然後回到0,使得路程最短。
      • 。。。。。。
    • 從頂點 0 出發,到3,然後再從3出發,經過{1,2,4}這幾個城市,然後回到0,使得路程最短。
      • 。。。。。。
    • 從頂點 0 出發,到4,然後再從4出發,經過{1,2,3}這幾個城市,然後回到0,使得路程最短。
      • 。。。。。。
  2. 確定狀態:
    假設從頂點s出發,令d(i, V)表示從頂點 i 出發經過V(點的集合)中各個頂點一次且僅一次,最後回到出發點s的最短路徑長度。

  3. 確定一些初始狀態(邊界狀態):
    當V爲空集,那麼d(i, V)爲 d(i,{∅}),表示直接從 i 回到 s 了,此時d(i,V) = Cis(Cis表示頂點 i 和 s 之間的距離)

		// 初始化dp表的第一列,d(i,{∅})
		for(int i =0;i <n;i++){                      
    		dp[i][0] = C[i][0];                        
		}
  1. 確定狀態轉移方程:
    當V不爲空,那麼就是對子問題的最優求解。須在V這個城市集合中,嘗試每一個,並求出最優解。
    d(i,V) = min{ Cik + d(i,V-k) } (Cik:相鄰城市 i 和 k的距離,d(i,V-k):子問題)
    所以狀態轉移方程爲:
    在這裏插入圖片描述過程步驟:
    假設從城市 0 出發,分別要經過{1,2,3,4},最終回到城市0。所以原問題爲:d(0,{1,2,3,4})。
    在這裏插入圖片描述

以上是選擇了路徑爲:0 → 1 → 2 → 3 → 4 → 0

d(0,{1,2,3,4}) = min{ d(1,{2,3,4}) + C01,d(2,{1,3,4}) + C02,d(3,{1,2,4}) + C03,d(4,{1,2,3}) + C04 }

d(1,{2,3,4}) = min{ d(2,{3,4}) + C12,d(3,{2,4}) + C13,d(4,{2,3}) + C14 }

d(2,{3,4}) = min{ d(3,{4}) + C23,d(4,{3}) + C24 }

d(3,{4}) = min{ d(4,{∅}) + C34 }

d(4,{∅}) = C40

dp表結構:
設置一個二維的動態規劃表dp:
先確定一下dp表的大小,有n個城市,從0開始編號,那麼dp表的行數就是n,列數就是2(n-1),集合{1,2,3,4}的子集個數。在求解的時候,第一列的值對應這從鄰接矩陣可以導出,後面的列可以有前面的列和鄰接矩陣導出。

其次,爲了編程方便,建立{ }二進制編碼轉換:{1,3,4}表示爲二進制的1101,十進制的13,對於規則爲:其中集合裏面有的數對應的二進制中位數寫成1,沒有的寫成0。

  • 對於第y個城市,他的二進制表達爲,1 << (y-1)
  • 對於數x,要看它的第 i 位是不是1,那麼可以通過判斷布爾表達式 (((x >> (i - 1) ) & 1) == 1的真值來實現。
  • 符號{1,2,3,4}: 表示經過{1,2,3,4}這幾個城市,然後回到0。那麼題目就是求dp[0][{1,2,3,4}],即dp[0][15]。
    (d[i][j]:i 表示某一步的起點城市,j 爲需要經過城市集合的十進制,如{1,2,3}爲二進制的111,對應的十進制爲7。)
  • 編程過程中的狀態轉移:
    dp[ i ][ j ] = C[ i ][ k ] + dp[ k ][ j ^ (1 << (k - 1)) ],解釋如下,等式左邊表示問題:dp[ i ][ j ]從 i 出發經過 j 對應的頂點集。等式右邊表示將等式左邊的問題拆分爲子問題:從 i 出發到 j 對應的點集中的 頂點 k,然後從頂點 k 經過 剩餘的點集。
    j ^ (1 << (k - 1)): 這個其實就是 j 對應的點集中去除 頂點 K 之後的點集。相信很多朋友一下子理解不了,我舉個例子來解釋一下,當 j = 7時,二進制爲111,對應的頂點集爲{1,2,3},我們選出頂點 3,則剩下的點集爲{1,2},這個過程:j 對應爲 111,3 對應爲 100 = 1 << (3-1),將 j 的 111 和 3 的100 進行異或便可求出剩餘的點集,也可以從減法的角度理解: 111(2) - 100(2) = 11(2)
{∅} {1} {2} {1,2,3} {1,2,3,4}
二進制 0 1 10 111 1111
十進制 0 1 2 7 15

所以求出的動態規劃表就是:

{∅} {1} {2} {1,2} {3} {1,3} {2,3} {1,2,3} {4} {1,4} {2,4} {1,2,4} {3,4} {1,3,4} {2,3,4} {1,2,3,4}
索引 i \ j 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
0 ∞ (C00) 6 (C01+C10) ∞(C02+C20) 16 21 18 18 17 18 37 32 24 23
1 3 (C10) ∞(C11+C10) 18 15 14 15 33 20
2 ∞ C(20) 6(C21+C10) 12 17 12 11 31 26
3 8 (C30) 13(C31+C10) 10 (min{10+∞, 4+6}) 29 24 16 15
4 9 (C40) 8(C41+C10) 9 (min{3+∞, 3+6}) 28 23 15 20
子問題 d(i,{∅}) = Ci0 d(i,{1}) = Ci1+d(1,{∅}) = Ci1 + C10 d(i,{2}) = Ci2+d(2,{∅}) = Ci2 + C20 d(i,{1,2})=min{ Ci1+d(1,{2}) ,Ci2+d(2,{1}) } d(i,{3}) = Ci3+d(3,{∅}) = Ci3+C30 d(i,{1,3})=min{ Ci1+d(1,{3}) ,Ci3+d(3,{1}) }


同樣,這種方式只能獲取到最優值,無法獲取獲取到最優值情況中的路徑,爲了獲取到最優路徑,同樣可以採用回溯的方法。
  • 回溯:
    如:dp[0]{1,2,3,4} = dp[0][15] = min{ C01 + dp[1]{2,3,4},C02 + dp[2]{1,3,4},C03 + dp[3]{1,2,4},C04 + dp[4]{1,2,3} } = C01 + dp[1]{2,3,4}。可得這一路徑爲:0 -> 1,接下來從 dp[1]{2,3,4} 開始回溯,直到所有城市頂點被遍歷完。

算法實現:

package indi.pentiumcm.thought;

import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static java.lang.Double.POSITIVE_INFINITY;

/**
 * @projName: algorithm
 * @packgeName: indi.pentiumcm.thought
 * @className: DpTSP
 * @author: pentiumCM
 * @email: [email protected]
 * @date: 2020/3/28 22:05
 * @describe: 動態規劃 - 旅行商問題
 */
public class DpTSP {

    // 無窮大
    static int max = (int) POSITIVE_INFINITY / 2;

    /**
     * 旅行商問題算法實現
     *
     * @param distances 各個頂點之間距離的鄰接矩陣的二維數組
     */
    public void tsp(int[][] distances) {
        int cityNum = distances.length;

        int colNum = 1 << cityNum - 1;
//      1. 構建動態規劃dp表
        int[][] dp = new int[cityNum][colNum];

//      2. 確定一些初始狀態:初始化dp表的第一列,即d(i,{∅}) = Ci0
        for (int i = 0; i < cityNum; i++) {
//          每個城市回到起點的距離
            dp[i][0] = distances[i][0];
        }

//      3. 確定狀態轉移方程,d[i][j]:i - 城市,j - {1,2,3,4}等
        for (int j = 1; j < colNum; j++) {

            for (int i = 0; i < cityNum; i++) {
                dp[i][j] = max;
//              {j}中的頂點包含起點i ,就continue
                if (((j >> (i - 1)) & 1) == 1) {
                    continue;
                }

//              遍歷{ }裏面的點集
                for (int k = 1; k < cityNum; k++) {
//                  排除{V}中不包含的頂點,如j = 5(二進制101)時,{}爲{1,3},這時候並不含有2,4,需要排除掉,直接continue
                    if (((j >> (k - 1)) & 1) == 0) {
                        continue;
                    }
//                  從i出發到 k,然後從k出發經過{V-k}
                    if (dp[i][j] > distances[i][k] + dp[k][j ^ (1 << (k - 1))]) {
                        dp[i][j] = distances[i][k] + dp[k][j ^ (1 << (k - 1))];
                    }
                }
            }
        }

        int minDistance = dp[0][colNum - 1];

//      回溯,求最短路徑的流行
        List<Integer> path = new ArrayList<>();

//      存放剩餘未走的城市頂點,key-value:城市頂點標號
        Map<Integer, Integer> remPath = new HashMap<>();
        for (int i = 1; i < cityNum; i++) {
            remPath.put(i, i);
        }


//      將起點先存入列表
        path.add(0);
        int choseCity = 0;

        for (int j = cityNum - 1; j > 0; j--) {

            int col2 = 0;
            for (Integer cityIndex : remPath.keySet()) {
                col2 += 1 << remPath.get(cityIndex) - 1;
            }


            for (int i = 1; i < cityNum; i++) {

                choseCity = path.get(path.size() - 1);
                int cDis = distances[choseCity][i];

                if (dp[i][col2 ^ (1 << (i - 1))] + cDis == minDistance) {
                    path.add(i);

                    remPath.remove(i);
                    minDistance -= cDis;
                    break;
                }
            }
        }
        path.add(0);

        System.out.print("選擇的最佳路徑爲:");
        for (int i = 0; i < path.size(); i++) {
            System.out.print(" " + path.get(i));
        }

        System.out.print("\n" + "最佳路徑長度爲:" + dp[0][colNum - 1]);
    }

    public static void main(String[] args) {

//      各個城市之間的距離的鄰接矩陣
        int[][] distances = {
                {max, 3, max, 8, 9},
                {3, max, 3, 10, 5},
                {max, 3, max, 4, 3},
                {8, 10, 4, max, 20},
                {9, 5, 3, 20, max}
        };

        new DpTSP().tsp(distances);
    }
}

運行結果:
在這裏插入圖片描述


3. 最大和子串問題

  1. 問題描述
    給定一個整數數組 nums ,找到一個具有最大和的連續子數組(子數組最少包含一個元素),返回其最大和。

    示例:
    輸入: [-2,1,-3,4,-1,2,1,-5,4],
    輸出: 6
    解釋: 連續子數組 [4,-1,2,1] 的和最大,爲 6。

解題套路一致,分析過程在代碼註釋中,如下↓:

package indi.pentiumcm.leetcode;

import java.util.Arrays;

/**
 * @projName: algorithm
 * @packgeName: indi.pentiumcm.leetcode
 * @className: Q15
 * @author: pentiumCM
 * @email: [email protected]
 * @date: 2020/4/1 10:55
 * @describe: 53. 最大子序和 - 動態規劃解決
 */
public class Q15 {

    public void maxSubArray(int[] nums) {

//      1. 原問題劃分爲子問題
//      原問題:當序列長度爲 n 時,求最大和
//      子問題:當序列長度爲 n -1 時,求最大和
//      子問題:當序列長度爲 n -2 時,求最大和

//      2. 定義狀態:dp[i]爲 遍歷到第 i 個元素時,子串的最大和
        int[] dp = new int[nums.length];

//      3. 初始狀態 dp[0] = nums[0]
        dp[0] = nums[0];

//      4. 狀態轉移方程:dp[i] = max{dp[i-1] + nums[i], nums[i]}
        for (int i = 1; i < nums.length; i++) {
            int maxSum = dp[i - 1] + nums[i] > nums[i] ? dp[i - 1] + nums[i] : nums[i];
            dp[i] = maxSum;
        }

//      遍歷dp[],dp[]最大的值即爲連續子串的最大和
        int maxSum = dp[0];
        int maxIndex = 0;
        for (int i = 0; i < dp.length; i++) {
            if (dp[i] > maxSum) {
                maxSum = dp[i];
                maxIndex = i;
            }
        }

        System.out.println("最大子串和:" + dp[maxIndex]);

//      回溯, 求最大連續子串的元素
        System.out.print("最大連續子串元素:");
        for (int i = maxIndex; i >= 0; i--) {
            if (maxSum == nums[i]) {
                System.out.print(" " + nums[i]);
                break;
            } else {
                maxSum -= nums[i];
                System.out.print(" " + nums[i]);
            }
        }
    }

    public static void main(String[] args) {
        int[] arr = {-2, 1, -3, 4, -1, 2, 1, -5, 4};
        new Q15().maxSubArray(arr);
    }
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章