第九章 動態規劃

動態規劃

(1)含義
動態規劃(dynamic programming)是運籌學的一個分支,是求解決策過程最優化的數學方法。它基於最優化原理,利用各階段之間的關係,逐個求解,最終求得全局最優解。在設計動態規劃算法時,需要確認原問題與子問題、動態規劃狀態、邊界狀態結值、狀態轉移方程等關鍵要素。簡單來說,動態規劃是通過把原問題分解爲相對簡單的子問題的方式求解複雜問題的方法。
在算法面試中,動態規劃是最常考察的題型之一,大多數面試官都以是否能較好地解決動態規劃相關問題來區分候選是否"聰明"。
(2)基本原理

  1. 最優子結構
    用動態規劃求解最優化問題的第一步就是刻畫最優解的結構,如果一個問題的解結構包含其子問題的最優解,就稱此問題具有最優子結構性質。因此,某個問題是否適合應用動態規劃算法,它是否具有最優子結構性質是一個很好的線索。使用動態規劃算法時,用子問題的最優解來構造原問題的最優解。因此必須考查最優解中用到的所有子問題。
  2. 重疊子問題
    如果遞歸算法反覆求解相同的子問題,就稱爲具有重疊子問題(overlapping subproblems)性質。在動態規劃算法中使用數組來保存子問題的解,這樣子問題多次求解的時候可以直接查表(對於重複子問題,可以將結果保存到一個數組,需要時直接從數組中取值)而不用調用函數遞歸。

分治與動態規劃:

  1. 相同點
    二者都要求原問題具有最優子結構性質,都是將原問題分而治之,分解成若干個規模較小(小到很容易解決的程序)的子問題.然後將子問題的解合併,形成原問題的解.
  2. 不同點
    分治法將分解後的子問題看成相互獨立的,通過用遞歸來做。動態規劃將分解後的子問題理解爲相互間有聯繫,有重疊部分,需要記憶,通常用迭代來做。

(3)核心思想
理解一個算法就要理解一個算法的核心,動態規劃算法的核心是下面的一張圖片和一個小故事。
在這裏插入圖片描述

A * "1+1+1+1+1+1+1+1 =?" *

A : "上面等式的值是多少"
B : *計算* "8!"

A *在上面等式的左邊寫上 "1+" *
A : "此時等式的值爲多少"
B : *quickly* "9!"
A : "你怎麼這麼快就知道答案了"
A : "只要在8的基礎上加1就行了"
A : "所以你不用重新計算因爲你記住了第一個等式的值爲8!動態規劃算法也可以說是 '記住求過的解來節省時間'"

由上面的圖片和小故事可以知道動態規劃算法的核心就是記住已經解決過的子問題的解。
(4)基本思路
若要解一個給定問題,我們需要解其不同部分(即子問題),再合併子問題的解以得出原問題的解。 通常許多子問題非常相似,爲此動態規劃法試圖僅僅解決每個子問題一次,從而減少計算量: 一旦某個給定子問題的解已經算出,則將其記憶化存儲,以便下次需要同一個子問題解之時直接查表。 這種做法在重複子問題的數目關於輸入的規模呈指數增長時特別有用。
實現步驟:

  1. 確認原問題與子問題
  2. 描繪問題的解結構(一般通過一維/二維DP數組)並確認一般狀態(DP[i]的意義)
  3. 確認邊界狀態的值(可理解爲遞歸出口) & 狀態轉移方程(由邊界狀態推出一般狀態的規律,可理解爲遞歸函數)
    狀態轉移方程一般通過自頂向下遞歸分析,再自底向上動態規劃推導得出。

(5)兩種形式
上面已經知道動態規劃算法的核心是記住已經求過的解,記住求解的方式有兩種:①自頂向下的備忘錄法 ②自底向上。
爲了說明動態規劃的這兩種方法,舉一個最簡單的例子:求斐波拉契數列Fibonacci 。先看一下這個問題:

Fibonacci (n) = 1; n = 0
Fibonacci (n) = 1; n = 1
Fibonacci (n) = Fibonacci(n-1) + Fibonacci(n-2)

以前學c語言的時候寫過這個算法使用遞歸十分的簡單。先使用遞歸版本來實現這個算法:

public int fib(int n)
{
    if(n<=0)
        return 0;
    if(n==1)
        return 1;
    return fib( n-1)+fib(n-2);
}
//輸入6
//輸出:8

先來分析一下遞歸算法的執行流程,假如輸入6,那麼執行的遞歸樹如下:
在這裏插入圖片描述
上面的遞歸樹中的每一個子節點都會執行一次,很多重複的節點被執行,fib(2)被重複執行了5次。由於調用每一個函數的時候都要保留上下文,所以空間上開銷也不小。這麼多的子節點被重複執行,如果在執行的時候把執行過的子節點保存起來,後面要用到的時候直接查表調用的話可以節約大量的時間。下面就看看動態規劃的兩種方法怎樣來解決斐波拉契數列Fibonacci 數列問題。
①自頂向下的備忘錄法

public static int Fibonacci(int n)
{
        if(n<=0)
            return n;
        int []Memo=new int[n+1];        
        for(int i=0;i<=n;i++)
            Memo[i]=-1;
        return fib(n, Memo);
    }
    public static int fib(int n,int []Memo)
    {

        if(Memo[n]!=-1)
            return Memo[n];
    //如果已經求出了fib(n)的值直接返回,否則將求出的值保存在Memo備忘錄中。               
        if(n<=2)
            Memo[n]=1;

        else Memo[n]=fib( n-1,Memo)+fib(n-2,Memo);  

        return Memo[n];
    }

備忘錄法也是比較好理解的,創建了一個n+1大小的數組來保存求出的斐波拉契數列中的每一個值,在遞歸的時候如果發現前面fib(n)的值計算出來了就不再計算,如果未計算出來,則計算出來後保存在Memo數組中,下次在調用fib(n)的時候就不會重新遞歸了。比如上面的遞歸樹中在計算fib(6)的時候先計算fib(5),調用fib(5)算出了fib(4)後,fib(6)再調用fib(4)就不會在遞歸fib(4)的子樹了,因爲fib(4)的值已經保存在Memo[4]中。
②自底向上的動態規劃
備忘錄法還是利用了遞歸,上面算法不管怎樣,計算fib(6)的時候最後還是要計算出fib(1),fib(2),fib(3)……,那麼何不先計算出fib(1),fib(2),fib(3)……,呢?這也就是動態規劃的核心,先計算子問題,再由子問題計算父問題。

public static int fib(int n)
{
        if(n<=0)
            return n;
        int []Memo=new int[n+1];
        Memo[0]=0;
        Memo[1]=1;
        for(int i=2;i<=n;i++)
        {
            Memo[i]=Memo[i-1]+Memo[i-2];
        }       
        return Memo[n];
}

自底向上方法也是利用數組保存了先計算的值,爲後面的調用服務。觀察參與循環的只有 i,i-1 , i-2三項,因此該方法的空間可以進一步的壓縮如下。

public static int fib(int n)
    {
        if(n<=1)
            return n;

        int Memo_i_2=0;
        int Memo_i_1=1;
        int Memo_i=1;
        for(int i=2;i<=n;i++)
        {
            Memo_i=Memo_i_2+Memo_i_1;
            Memo_i_2=Memo_i_1;
            Memo_i_1=Memo_i;
        }       
        return Memo_i;
    }

一般來說由於備忘錄方式的動態規劃方法使用了遞歸,遞歸的時候會產生額外的開銷,使用自底向上的動態規劃方法要比備忘錄方法好。

自頂向下備忘錄法從結果(父問題)出發,逐步向下尋找出口(子問題)。它的本質還是遞歸,計算遞歸的過程可能有O(2^n)複雜度,它將結果存儲在備忘錄數組中。下一次調用相同結果可以直接從備忘錄數組中獲取。
自底向上動態規劃根據規律,從遞歸出口(子問題)出發(已知),設計動態規劃數組,逐漸尋找父問題的解。
其實,動態規劃應該先自頂向下思考,再自底向上求得結果。

(6)實例
例1 最優解問題1——求能賺最多錢
題目描述
在這裏插入圖片描述
共有8個任務,如圖爲每個任務執行起始時間與結束時間,以及做完每件事情可賺金錢。
要求:執行每個任務的時間不能重疊
求如何選擇執行任務能夠賺最多錢?
解題思路
能夠賺最多金錢——最優解問題,可通過定義最優解數組完成
最優解數組即 包含i個子問題的最優解。這裏可理解爲:可執行i個任務所能夠賺的最多錢。
先自頂向下分析:一共有8個任務,每個任務都有2種狀態:選/不選。選擇該任務則可將多賺該任務的價值,但不能選擇與該任務重疊時間的任務;不選該任務則無法賺得該任務的價值,對其餘任務沒有影響。例如對於OPT(8)而言即求共有8個任務時最多能夠賺的金錢,如果選擇了第8個任務,則可賺4元(arr[8]=4)且剩下只能選擇前5個任務(prev[8]=5),此時最優解(最多可賺金錢)應該爲前5個任務最多可賺金錢+第8個任務可賺金錢=OPT(5)+4;如果沒有選擇第8個任務,則不賺任何錢,選擇前7(8-1)個任務可賺最多錢=OPT(7)。因此,含有8個任務的最優解爲max(OPT(5)+4,opt(7))。剩下依此類推,可畫出遞歸樹:
在這裏插入圖片描述
由遞歸樹,可總結出一般規律,即:

OPT(i) = max(OPT(i-1) , OPT(prev()) + arr[i])

遞歸出口爲:

OPT(0) = arr[0]; //只有一個任務時一定選擇該任務能得到最優解

因此這裏的8個任務可看做具有最優子結構(max(選/不選))的重疊子問題(都可用以上最優解方程求得最優解)。
程序代碼
(1)自頂向下遞歸備忘錄

	public int BestSolution1(List<Task> list) {
		
		//存儲解決i個問題時的最優解,即需要執行i個任務可賺得最多金錢,由遞歸樹可得代碼
		int[] OPT = new int[list.size()];
		//初始化前置數組,即如果選擇了第i個任務,則下一次只能選擇前prev[i]個任務
		int[] prev = new int[list.size()];
		for(int i=list.size()-1;i>=0;i--) {
			Integer startTime = list.get(i).startTime;
			prev[i] = -1;
			for(int j = i-1;j>=0;j--) {
				//往前遍歷,選取之前第一個結束時間在該任務開始事件之前的任務。
				Integer endTime = list.get(j).endTime;
				if(endTime<=startTime) {
					prev[i] = j;
					break;
				}
			}
		}
		
		int result = mem_BestSolution1(list,prev,OPT);
		return result;
	}
	
	public int mem_BestSolution1(List<Task> list,int[] prev,Integer i,int[] OPT) {
		//自頂向下備忘錄法求得解決第i個問題時的最優解
		//遞歸思想,複雜度爲O(2^n)
		//遞歸出口,第1個任務的最優解一定是執行完第一個任務所賺的錢
		if(i<0) return 0;//i<0表示沒有需要執行的任務,最優解(能賺的最多錢)=0
		else if(i==0) OPT[0] = list.get(0).value;
		else {
			//其餘任務則根據總結出的一般規律得出
			int choose_A = mem_BestSolution1(list,prev,i-1,OPT);//不選擇第i個任務時取前i-1個任務的最優解
			int choose_B = mem_BestSolution1(list,prev,prev[i],OPT) + list.get(i).value;//選擇第i個任務時取前prev[i]任務最優解 + 該任務所賺的錢
			OPT[i] = max(choose_A,choose_B);//取 選/不選 該任務的最大值 即爲最優解
		}
			
		return OPT[i];
	}

	public int max(int a,int b) {
		//獲取a,b中最大值
		return a>=b?a:b;
	}
	
	public static class Task{
		Integer startTime;	//起始時間
		Integer endTime;		//結束時間
		Integer value;		//可賺金錢
		
		public Task(Integer startTime,Integer endTime,Integer value){
			this.startTime = startTime;
			this.endTime = endTime;
			this.value = value;
		}
	}

(2)自底向上動態規劃

public int BestSolution1(List<Task> list) {
		
		//存儲解決i個問題時的最優解,即需要執行i個任務可賺得最多金錢,由遞歸樹可得代碼
		int[] OPT = new int[list.size()];
		//初始化前置數組,即如果選擇了第i個任務,則下一次只能選擇前prev[i]個任務
		int[] prev = new int[list.size()];
		for(int i=list.size()-1;i>=0;i--) {
			Integer startTime = list.get(i).startTime;
			prev[i] = -1;
			for(int j = i-1;j>=0;j--) {
				//往前遍歷,選取之前第一個結束時間在該任務開始事件之前的任務。
				Integer endTime = list.get(j).endTime;
				if(endTime<=startTime) {
					prev[i] = j;
					break;
				}
			}
		}
		
		int result = dp_BestSolution1(list,prev,OPT);
		return result;
	}

	public int dp_BestSolution1(List<Task> list,int[] prev,int[] OPT) {
		//自底向上動態規劃求得解決第i個問題時的最優解
		//遍歷思想,複雜度爲O(n)
		//遞歸出口,第1個任務的最優解一定是執行完第一個任務所賺的錢
		Integer task_number = list.size();
		OPT[0] = list.get(0).value;//最小子問題
		for(int i = 1;i<task_number;i++) {
			//由最小子問題逐漸向外遍歷求最優解,最終求得父問題最優解
			int choose_A = 0;
			int choose_B = 0;
			if(prev[i]==-1) {
				//選擇當前任務後不能再選其他任務(對數組越界(i = -1)單獨處理)
				choose_A = OPT[i-1];//不選擇第i個任務時取前i-1個任務的最優解
				choose_B = list.get(i).value;//該任務所賺的錢,下一次任務爲前OPT[-1],即表示選擇當前任務後不能再選取之前的任何任務	
			}else {
				choose_A = OPT[i-1];//不選擇第i個任務時取前i-1個任務的最優解
				choose_B = OPT[prev[i]] + list.get(i).value;//選擇第i個任務時取前prev[i]任務最優解 + 該任務所賺的錢		
			}
			OPT[i] = choose_A>choose_B?choose_A:choose_B;//取 選/不選 該任務的最大值 即爲最優解
		}
			
		return OPT[task_number-1];
	}
	
	public int max(int a,int b) {
		//獲取a,b中最大值
		return a>=b?a:b;
	}
	
	public static class Task{
		Integer startTime;	//起始時間
		Integer endTime;		//結束時間
		Integer value;		//可賺金錢
		
		public Task(Integer startTime,Integer endTime,Integer value){
			this.startTime = startTime;
			this.endTime = endTime;
			this.value = value;
		}
	}

例2 最優解問題2——求所選數組求和最大值
題目描述
在這裏插入圖片描述
選擇一堆數字,要求:

  1. 當選擇第i個數字時,不能選擇相鄰的兩個數字(不能選擇第i+1和第i-1個數字)
  2. 使所選數字的和最大

解題思路
求和的最大值,即最優解問題。可用例1的思路求解。通過定義最優解數組來存儲重複子問題進行求解。
定義一個最優解數組,數組中每個元素存儲 包含 i 個子問題時的最優解,本題可理解爲 含有 i+1 個元素的數組所能取到的最大值。

OPT(i) = 長度爲 i + 1 的數組的最佳方案

自頂向下分析:對於 i = 6 位置的數字,有兩種處理方式:選/不選。如果選擇該數字,則最優解加上該數字的值,但不能選 i = 5 位置的數字,此時只能取 i <= 4 位置的最優解,即求含有5個元素的數組的最優解,因此該情況下最優解爲:arr[6] + OPT(4),如果沒有選擇該數字,則和不變,取前6個位置的最優解,因此該情況下最優解爲:OPT(5)。因此,i = 6時的最優解爲選或不選兩種情況下最優解的最大值。即,OPT(6) = max(OPT(5),OPT(4)+arr[6]),同理可繪製遞歸樹:
在這裏插入圖片描述
由遞歸樹可得一般規律:

OPT(i) = max(OPT(i-1),OPT(i-2) + arr[i])

遞歸出口爲:

OPT(0) = arr[0]
OPT(1) = max(arr[0],arr[1])

因此這裏的長度爲7的數組可看做具有最優子結構(max(選/不選))的重疊子問題(看成長度爲1、2、3…的數組,這些數組(子問題)都可用以上最優解方程求得最優解)。
程序代碼
(1)自頂向下遞歸備忘錄

		public int BestSolution2(int[] arr) {
			//存儲解決i+1個問題時的最優解,opt[i]中存儲長度爲i+1的數組求和所得最大值,由遞歸樹可得代碼
			int[] OPT = new int[arr.length];
			return dp_BestSolution2(arr,OPT);
		}
		
		public int mem_BestSolution2(int[] arr,int[] OPT,int i) {
			//自頂向下備忘錄法,採用遞歸方式求解,遞歸算法,需要用i來指定遞歸的層次
			//遞歸出口
			if(i == 0) {
				OPT[i] = arr[0];//如果數組中只有1個元素,則返回第一個元素的值
			}
			else if(i == 1) { 
				OPT[i] = max(arr[0],arr[1]);	//如果數組中含有2個元素,則返回第一個元素或第二個元素
			}
			else {
				// 其餘情況,根據遞歸樹規律。含有 i 個元素的數組的最優解爲
				// 含有 i-1 個元素的數組的最優解(不選擇第 i 個元素)
				// 含有 i-2 個元素的數組的最優解 + 第 i-1 個元素取值
				// 的最大值
				OPT[i] = mem_BestSolution2(arr,OPT,i-1);
				OPT[i] = mem_BestSolution2(arr,OPT,i-2) + arr[i];
			}
			return OPT[i];
		}

(2)自底向上動態規劃

		public int BestSolution2(int[] arr) {
			//存儲解決i+1個問題時的最優解,opt[i]中存儲長度爲i+1的數組求和所得最大值,由遞歸樹可得代碼
			int[] OPT = new int[arr.length];
			return dp_BestSolution2(arr,OPT);
		}

		public int dp_BestSolution2(int[] arr,int[] OPT) {
			// 自底向上動態規劃,採用遍歷思想
			// 從已知底端,即長度爲1的元素出發
			OPT[0] = arr[0];
			OPT[1] = max(arr[0],arr[1]);
			//遍歷
			for(int i=2;i<arr.length;i++) {
				OPT[i] = max(OPT[i-1],OPT[i-2]+arr[i]);
			}
			return OPT[arr.length-1];
		}

例3 最優解問題3——求是否存在所選數組求和=給定值
題目描述
在這裏插入圖片描述
對於數組arr,取出一組數字,且不能取相鄰數字,是否存在方案使得所取數字之和 = S?若存在,則返回true,否則返回false。
解題思路
採用動態規劃思想,定義數組subset[],第 i 個位置的元素表示包含 i 個元素的數組是否存在方案使所取數字和 = S,若存在方案使數字和 = S,則返回true,否則返回false。
採用自頂向下的思想進行分析:
對於長度爲 8 的數組arr,第8個元素包含選或不選兩種情況:如果選擇第 8 個元素,則此時解爲求解長度爲7的數組arr存在數字和 = S - 第8個元素;如果不選第8個元素,則此時解爲求解長度爲7的數組arr存在數字和 = S。長度爲 8 的數組arr存在數字和爲 S 的解爲 這兩種情況取或(只要有一種成立即可),長度爲7的數組arr,長度爲6的數組arr…可用相同的思想分析,因此得一般規律

subset(i,S) = subset(i-1,S) || subset(i-1,S-arr[i])

遞歸出口爲(遞歸出口的情況應該分析完整)

if(S == 0)return true;//如果取到0,則說明存在方案使取值=S
if(i == 0){
/*
	if(arr[i] == S)return true;
	else return false;//遍歷到第一個元素,若第一個元素=S,則存在方案,否則不存在方案
*/
	return arr[i] == S;
}
if(arr[i]>S)return subset(i-1,S);//若該元素大於S,則一定不選該元素

程序代碼
(1)自頂向下遞歸備忘錄

		public boolean BestSolution3(int[] arr,int S) {
			
			// 存儲解決i個問題時的最優解,即長度爲i的數組是否能夠取一組數字,使得數字求和 = S
			// 這裏最優解數組爲二維數組,由於每個子問題包含兩個變量,一個爲數組長度,一個爲求和S大小,橫座標表示長度爲i的數組,縱座標表示求和S
			// 即對於最優解數組SUBSET[i][j]表示長度爲 i+1 的數組是否存在數字和爲 j 的一組數
			boolean[][] SUBSET = new boolean[arr.length][S+1];
			
			boolean result = mem_BestSolution3(arr,S,arr.length-1,SUBSET);
			//boolean result = dp_BestSolution3(arr,S,SUBSET);
			return result;
		}
		
		public boolean mem_BestSolution3(int[] arr,int S,int i,boolean[][] SUBSET) {
			//採用自頂向下備忘錄法進行回溯
			//每一次遞歸求的是包含 i+1 個元素的數組arr是否存在和爲 S 的一組數
			if(S == 0)SUBSET[i][0] = true;//若求和S=0,則一定存在方案(剩下均不選)
			else if(i==0)
				{
					if(arr[0] == S)SUBSET[0][S] = true;
					else SUBSET[0][S] = false;//如果數組只含有1個元素,若該元素=S,則存在方案,否則不存在方案
				}
			else if(arr[i] > S) {
				//若該元素大於所需求和S,則求不選這個元素時的方案(取包含i-1個元素的數組方案)
				SUBSET[i][S] = mem_BestSolution3(arr,S,i-1,SUBSET);
			}
			else {
				// 不選該元素時,取包含 i-1 個元素是否存在求和爲 S 的方案
				// 選該元素時,取包含 i-1 個元素是否存在求和爲 S-arr[i] 的方案
				// 包含 i 個元素時的解爲這兩種方案求解的或
				SUBSET[i][S] = (mem_BestSolution3(arr,S-arr[i],i-1,SUBSET) || mem_BestSolution3(arr,S,i-1,SUBSET));
			}
			return SUBSET[i][S];
		}

(2)自底向上動態規劃

		public boolean BestSolution3(int[] arr,int S) {
			
			// 存儲解決i個問題時的最優解,即長度爲i的數組是否能夠取一組數字,使得數字求和 = S
			// 這裏最優解數組爲二維數組,由於每個子問題包含兩個變量,一個爲數組長度,一個爲求和S大小,橫座標表示長度爲i的數組,縱座標表示求和S
			// 即對於最優解數組SUBSET[i][j]表示長度爲 i+1 的數組是否存在數字和爲 j 的一組數
			boolean[][] SUBSET = new boolean[arr.length][S+1];
			
			boolean result = mem_BestSolution3(arr,S,arr.length-1,SUBSET);
			//boolean result = dp_BestSolution3(arr,S,SUBSET);
			return result;
		}

		public boolean dp_BestSolution3(int[] arr,int S,boolean[][] SUBSET) {
			//採用自底向上動態規劃,從已知開始構造求解數組
			
			//當S=0時,則一定存在方案。即SUBSET[i][0](0<=i<=arr.length-1)
			for(int i=0;i<=arr.length-1;i++)SUBSET[i][0] = true;
			//當i=0時,若arr[0]==S則一定存在,否則不存在
			for(int j=1;j<=S;j++) {
				if(arr[0] == j)SUBSET[0][j] = true;
				else SUBSET[0][j] = false;
			}
			
			for(int i=1;i<=arr.length-1;i++)
				for(int j=1;j<=S;j++) {
					//遍歷法求解
					if(arr[i]>j)SUBSET[i][j] = SUBSET[i-1][j];
					else {
						SUBSET[i][j] = (SUBSET[i-1][j] || SUBSET[i-1][j-arr[i]]);
					}
				}
			
			return SUBSET[arr.length-1][S];
		}

模板問題

線性模型

區間模型

揹包模型

leetcode

例1:爬樓梯(70)

題目描述
假設你正在爬樓梯。需要 n 階你才能到達樓頂。
每次你可以爬 1 或 2 個臺階。你有多少種不同的方法可以爬到樓頂呢?
注意:給定 n 是一個正整數。

示例 1:
輸入: 2
輸出: 2
解釋: 有兩種方法可以爬到樓頂。
1.  1 階 + 1 階
2.  2 階
示例 2:
輸入: 3
輸出: 3
解釋: 有三種方法可以爬到樓頂。
1.  1 階 + 1 階 + 1 階
2.  1 階 + 2 階
3.  2 階 + 1 階

動態規劃原理

  1. 確認原問題與子問題
    原問題爲求n階臺階所有走法的數量,子問題是求1階臺階、2階臺階、…、n-1階臺階的走法。
  2. 確認狀態
    本題的動態規劃狀態單一,第i個狀態即爲i階臺階的所有走法數量。
  3. 確認邊界狀態的值
    邊界狀態爲1階臺階與2階臺階的走法,1階臺階有1種走法,2階臺階有2種走法,即dp[1]=1;dp[2]=2;
  4. 確認狀態轉移方程
    將求第i個狀態的值轉移爲求第i-1個狀態的值與第i-2個狀態的值,動態規劃轉移方程爲dp[i]=dp[i-1]+dp[i-2] (i>=3)

算法思路

  1. 設置遞推數組dp[0…n],dp[i]代表到達第i階,有多少種走法。初始化數組元素爲0
  2. 設置到達第1階臺階有1種走法,到達第2階臺階有2種走法
    dp[1] = 1;dp[2] = 2;
  3. 利用循環遞推從第3階臺階至n階臺階結果:
    到達第i階臺階的方式數 = 到達第i-1階臺階的方式數 + 到達第i-2階臺階的方式數
    dp[i] = dp[i-1] + dp[i-2]

程序代碼

	public int climbStairs(int n) {
		// 採用自底向上動態規劃解決問題
		// dp[i] 表示爬 i 個臺階有dp[i]種方法,則對於第 i 個臺階可以從第i-1個臺階爬1個臺階或者從第i-2個臺階爬2個臺階
		// 邊界情況爲爬 1 個臺階時有一種方法,爬 2 個臺階有兩種方法
		// 根據狀態邊界與狀態轉移方程得到dp代碼
		if(n==0)return 0;
		if(n==1)return 1;
		if(n==2)return 2;
		else {
			int[] dp = new int[n+1];//dp[i]表示爬i級臺階的方法
			dp[0] = 0;// 0級臺階沒有方案
			dp[1] = 1;// 爬1級臺階時候,只有一種方法
			dp[2] = 2;// 爬2級臺階時可以 爬2次一級臺階或爬一次2級臺階
			for(int i=3;i<=n;i++) {
				// 爬i級臺階可以從i-1級臺階爬1級或者從i-2級臺階爬2級
				// 因此爬i級臺階的方法是爬 i-2 級臺階的方法 + 爬 i 級臺階的方法
				dp[i] = dp[i-1] + dp[i-2];
			}
			return dp[n];
		}
    }

例2:打家劫舍(198)

題目描述
你是一個專業的小偷,計劃偷竊沿街的房屋。每間房內都藏有一定的現金,影響你偷竊的唯一制約因素就是相鄰的房屋裝有相互連通的防盜系統,如果兩間相鄰的房屋在同一晚上被小偷闖入,系統會自動報警。
給定一個代表每個房屋存放金額的非負整數數組,計算你在不觸動警報裝置的情況下,能夠偷竊到的最高金額。

示例 1:
輸入: [1,2,3,1]
輸出: 4
解釋: 偷竊 1 號房屋 (金額 = 1) ,然後偷竊 3 號房屋 (金額 = 3)。
     偷竊到的最高金額 = 1 + 3 = 4 。
示例 2:
輸入: [2,7,9,3,1]
輸出: 12
解釋: 偷竊 1 號房屋 (金額 = 2), 偷竊 3 號房屋 (金額 = 9),接着偷竊 5 號房屋 (金額 = 1)。偷竊到的最高金額 = 2 + 9 + 1 = 12 。

算法思路

  1. 確認原問題與子問題
    原問題爲求n個房間的最優解,子問題爲求前1個房間,前2個房間,…,前n-1個房間的最優解
  2. 確認狀態
    第i個狀態即爲前i個房間能夠獲得的最大財寶(最優解)
  3. 確認邊界狀態的值
    前1個房間的最優解爲前1個房間的財寶;
    前2個房間的最優解爲第1,2個房間中較大財寶;
  4. 確認狀態轉移方程
    方案1:選擇第i個房間:前i-1個房間,前i-2個房間的最優解
    方案2:不選擇第i個房間:前i-1個房間的最優解
    則動態規劃轉移方程爲:dp[i] = max(dp[i-1],dp[i-2]+nums[i])(i>=3)

程序代碼

    public int rob(int[] nums) {
        // 採用自底向上動態規劃解決問題
    		// dp[i]表示共有i個房間時可以偷竊的最高金額,對於第i個房間,有選/不選2種方式,要麼選擇要麼不選
    		// 如果選擇的話,由於不能偷竊相鄰的房間的金額,因此此時 dp[i] = dp[i-2] + nums[i]
    		// 如果不選擇的話,則相當於求前 i-1 個房間的金額,因此此時dp[i] = dp[i-1]
    		// 邊界情況爲dp[0] = nums[0];dp[1] = max(nums[0],nums[1]);
    		int[] dp = new int[nums.length];
    		if(nums.length == 0)return 0;//沒有金額時,可偷金額爲0
    		if(nums.length == 1)return nums[0];
    		if(nums.length == 2)return max(nums[0],nums[1]);
    		else {
    			dp[0] = nums[0];//只有1個房間時,可偷金額爲該房間可偷竊金額
    			dp[1] = max(nums[0],nums[1]);//有2個房間時,可偷金額爲偷該房間的金額或偷第一個房間的金額
    		
    			for(int i=2;i<=nums.length-1;i++) {
    				dp[i] = max(dp[i-2] + nums[i],dp[i-1]);//遍歷剩餘情況,選擇該房間/不選該房間狀態
    			}
    		
    			return dp[nums.length-1];
    		}
    }
    
    public int max(int a,int b) {
    		return a>=b?a:b;
    }

例3:最大字段和(53)

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

示例:

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

算法思路
這道題的難點在於,如何確定第i個狀態(dp[i])?如果設置第i個狀態(dp[i])代表前i個數字組成的連續最大字段和,並能根據dp[i-1]、dp[i-2]、…、dp[0]推導出dp[i]。
但發現dp[i]與dp[i-1]並不相鄰,dp[i]無法通過dp[i-1]構成連續子數組,之間沒有內在聯繫,因此無法推導。
爲了讓第i個狀態的最優解與第i-1個狀態的最優解產生直接聯繫,思考:如果讓第i個狀態(dp[i])代表以第i個數字結尾的最大子段和,那麼dp[i]與dp[i-1]之間的關係是否可以推導?如何由此推出最終結果?
將求n個數的數組的最大子段和轉換爲分別求第1個、第2個、…、第i個、…、第n個數字結尾的最大子段和,再找出這n個結果中最大的作爲結果,動態規劃算法:
第i個狀態(dp[i])即爲以每個數字結尾的最大子段和(最優解)。由於以第i-1個數字結尾的最大子段和(dp[i-1])與nums[i]相鄰,故動態規劃轉移方程爲:
若dp[i-1]>0;dp[i] = dp[i-1] + nums[i];
否則dp[i] = nums[i];
邊界值:以第1個數字結尾的最大子段和dp[0] = nums[0]
程序代碼

 public int max(int a,int b) {
    		return a>=b?a:b;
    }
    
    //53.最大子序和
    //給定一個整數數組 nums ,找到一個具有最大和的連續子數組(子數組最少包含一個元素),返回其最大和。
    public int maxSubArray(int[] nums) {
        // 創建最優解數組,即滿足最優子結構及重複子結構
    		// 第i個狀態dp[i]表爲以第i個數字結尾的連續數字段的最大和。則求連續數字段的最大和即求max(dp)
    		// 一般規律爲:dp[i] = max(nums[i],dp[i-1]+nums[i])
    		// 邊界爲:dp[0] = nums[0]
    		if(nums.length == 0)return 0;
    		if(nums.length == 1)return nums[0];
    		
    		else {
    			int[] dp = new int[nums.length];
    			dp[0] = nums[0];//若數組中只有一個元素,則最大和爲該元素的值
    			int max_res = dp[0];
    			for(int i=1;i<=nums.length-1;i++) {
    				// 其餘元素遍歷求解,要麼選,要麼不選
    				// 對於dp[i]表示以第i個元素爲連續子數組的最後一個數字時的最大和
    				// 即只選擇最後一個數字或者以前一個數字爲最後一個數字的連續數字段
    				dp[i] = max(dp[i-1]+nums[i],nums[i]);
    				// 此時整數數組中的連續子數組的最大和爲dp中最大值
    				if(dp[i]>max_res) {
    					max_res = dp[i];
    				}
    			}
    			return max_res;
    		}
    }

例4:找零錢(322)

題目描述
給定不同面額的硬幣 coins 和一個總金額 amount。編寫一個函數來計算可以湊成總金額所需的最少的硬幣個數。如果沒有任何一種硬幣組合能組成總金額,返回 -1。

示例 1:
輸入: coins = [1, 2, 5], amount = 11
輸出: 3 
解釋: 11 = 5 + 5 + 1
示例 2:
輸入: coins = [2], amount = 3
輸出: -1
說明:
你可以認爲每種硬幣的數量是無限的。

算法思路

  1. 是否可以用貪心?
    鈔票面值爲[1,2,5,7,10],金額爲14,最優解需要2張7元。如果用貪心思想,每次優先使用較大面值的金額,選1張10元,剩下4元選2張2元。一共用3張。錯解。
    因此貪心思想在個別面值組合是可以的(如[1,2,5,10,20,50,100]),但本題面值不確定,因此不能用貪心思想。
  2. 採用動態規劃的解決方案?
    分析鈔票面值coins=[1,2,5,7,10],金額:14
    dp[i]代表金額i的最優解(即最少使用鈔票的數量)。在計算dp[i]時,dp[0]、dp[1]、dp[2]、…、dp[i-1]都是已知的:而金額i可由:
    金額 i-1 與coins[0](1)組合;
    金額 i-2 與coins[1](2)組合;
    金額 i-5 與coins[2](5)組合;
    金額 i-7 與coins[3](7)組合;
    金額 i-10 與coins[4](10)組合;
    即狀態可由狀態i-1、i-2、i-5、i-7、i-10這5個狀態轉移到,因此dp[i] = min(dp[i-1],dp[i-2],dp[i-5],dp[i-7],dp[i-10]) + 1

程序代碼

    public int coinChange(int[] coins, int amount) {
        // dp[i] 代表金額i的最優解(即湊成金額 i 的最小使用鈔票數)
    		// 假設對於[1,2,5,7,10] 若需要的最小鈔票數i 即爲 (i-1,i-2,i-5,i-7,i-10 所需要的最小鈔票數)中最小值 + 1
    		// 即若可通過添加某個硬幣獲得金額 i ,則金額 i 的狀態爲獲取該硬幣前的狀態 加上 該硬幣
    		// 即金額i的最優解(所需最少鈔票數) = 獲取該硬幣前的最優解(所需最少鈔票數) + 1
    		// dp[i] = min( dp[i-1],dp[i-2],dp[i-5],dp[i-7],dp[i-10]) + 1
    	
    		int[] dp = new int[amount+1];//dp[i]表示金額爲i時的最優解(最少使用的鈔票數目)
    		for(int i=0;i<=amount;i++)
    			dp[i] = -1;//初始化dp數組,最初所有金額的初始值均爲-1,表示不可到達
    		dp[0]=0;//金額爲0的最優解爲0
    		for(int i=1;i<=amount;i++) {//遍歷所有金額,對1~所求金額求最優解
    			for(int j=0;j<coins.length;j++) {
    				//若可通過添加某個硬幣得到該金額,則此時 金額i的最優解 = 獲取該硬幣前(金額i - coins[j])的最優解 + 1
    				if(i >= coins[j] && dp[i-coins[j]] != -1) {//若所求金額>硬幣的值(可通過添加硬幣得到金額i) 且  獲取硬幣前的狀態可達
    					if(dp[i] > dp[i-coins[j]]+1 || dp[i]==-1) {//若該方案比之前取硬幣方案所需硬幣數更小 或者 爲第一個方案
    						dp[i] = dp[i-coins[j]]+1;//取所有方案的最小值
    					}
    				}
    			}
    		}
    		return dp[amount];//返回金額爲amount的最優解
    }

例5:三角形(120)

題目描述
給定一個三角形,找出自頂向下的最小路徑和。每一步只能移動到下一行中相鄰的結點上。

例如,給定三角形:
[
     [2],
    [3,4],
   [6,5,7],
  [4,1,8,3]
]
自頂向下的最小路徑和爲 11(即,2 + 3 + 5 + 1 = 11)。

算法思路
在這裏插入圖片描述
在這裏插入圖片描述

  1. 設置一個二維數組,最優解三角形dp[][],並初始化數組元素爲0,dp[i][j]代表從底向上遞推時,走道三角形第i行第j列的最優解。
  2. 從三角形的底部向三角形上方進行動態規劃:
    a. 動態規劃邊界條件:底端上的最優解即爲數字三角形的最後一層
    b.利用i循環,從倒數第二層遞推至第一層,對於每層的各列,進行動態規劃遞推:
    第i行,第j列的最優解爲dp[i][j] = min(dp[i+1][j],dp[i+1][j+1]) + triangle[i][j]
  3. 返回dp[0][0]

程序代碼

    public int minimumTotal(List<List<Integer>> triangle) {
        // 構造二維數組,dp[i][j]表示自底向上遞推,走到三角形第i行j列時的最優解
    		// 自頂向下推到三角形第i行j列位置時的最小路徑和 的逆推
    		// 轉換數據類型->方便處理
    		int length = triangle.get(triangle.size()-1).size();// 三角形最後一行的長度
    		int[][] dp = new int[length][length];// 三角形的最優解數組
    		//初始化
    		for(int i=0;i<length;i++) 
    		{
    			List<Integer> row = triangle.get(i);
    			for(int j=0;j<row.size();j++) {
    				dp[i][j] = 0;
    			}
    		}
    		
    		for(int i=0;i<length;i++) {
    			// 對於自底向上遞推時,最優解最底層的數即爲原三角形最底層的數
    			dp[length-1][i] = triangle.get(length-1).get(i);
    		}
    		
    		for(int i = length-2;i>=0;i--) {
    			List<Integer> row = triangle.get(i);
    			for(int j=0;j<row.size();j++) {
    				// 遍歷求自底向上遞推時各個位置的最優解
    				// 即爲向下兩個位置的較小值 + 該位置的值
    				dp[i][j] = min(dp[i+1][j],dp[i+1][j+1]) + row.get(j);
    			}
    		}
    		
    		return dp[0][0];
    }
    
    public int min(int a,int b) {
    		return a>=b?b:a;
    }

法2

	int[][] tri;	// 原三角形二維數組形式
	int[][] tri_sum;	// 原三角形路徑和形式
	int length;
    public int minimumTotal(List<List<Integer>> triangle) {
    		// 將List<List<Integer>> 轉化爲二維數組形式
    		if(triangle == null || triangle.size() == 0)return 0;
    		length = triangle.size();
    		tri = new int[length][length];
    		tri_sum = new int[length][length];
    		
    		for(int i=0;i<length;i++) {
    			for(int j=0;j<=i;j++) {
    				tri[i][j] = triangle.get(i).get(j);
    			}
    		}
    		
    		constructTriSum();
    		
    		int min = tri_sum[length-1][0];
    		for(int i=1;i<length;i++)
    			if(tri_sum[length-1][i]<min)min = tri_sum[length-1][i];
    		return min;
    		

    }
    
    public void constructTriSum() {
    		// 自底向上備忘錄
    		// tri_sum[i][j] 表示到tri[i][j]時路徑的最小值
    		tri_sum[0] = tri[0];
    		for(int i=1;i<length;i++) {
    			// 每一行的首元素和尾元素單獨處理
    			tri_sum[i][0] = tri_sum[i-1][0] + tri[i][0];
    			tri_sum[i][i] = tri_sum[i-1][i-1] + tri[i][i];
    			
    			for(int j=1;j<i;j++) {
    				// 中間元素爲左上和上部元素較小值加上本身
    				tri_sum[i][j] =Integer.min(tri_sum[i-1][j-1],tri_sum[i-1][j])+tri[i][j];
    			}
    		}
    }

例6:最長上升子序列(300)

題目描述
給定一個無序的整數數組,找到其中最長上升子序列的長度。

示例:
輸入: [10,9,2,5,3,7,101,18]
輸出: 4 
解釋: 最長的上升子序列是 [2,3,7,101],它的長度是 4。

算法思路
若第i個狀態dp[i]代表前i個元素中最長上升子序列的長度,則dp[i-1]代表前i-1個元素中最長上升子序列的長度,則dp之間沒有直接聯繫,無法遞推。
若第i個狀態dp[i]代表以第i個元素結尾的最長上升子序列的長度,則nums[i]一定是dp[i]所對應的最長上升子序列中最大元素(位於末尾),最終結果爲dp[0],dp[1],dp[2],…,dp[i],…,dp[n-1]中的最大值
設置動態規劃數組dp[],第i個狀態dp[i]代表以第i個元素結尾的最長上升子序列的長度:
動態規劃邊界:dp[0] = 1;
初始化最長上升子序列的長度LIS = 1;
從1到n-1,循環i,極端dp[i];
從0至i-1,循環j,若nums[i]>nums[j],說明nums[i]可放置在nums[j]的後面,組成最長上升子序列:
若dp[i] < dp[j]+1;
dp[i] = dp[j] +1
LIS爲dp[0],dp[1],…,dp[i],…,dp[n-1]中最大的。
程序代碼

 public int lengthOfLIS(int[] nums) {
    		// dp[i]動態規劃數組表示以第 i 個元素結尾的最長上升子序列的長度
    		// 對於dp[i] 中對應的第 i 個元素 nums[i] 一定爲dp[i]的最大值,即最後一個元素。
    		// 因此應在 nums 數組中尋找小於nums[i] 的元素,則 nums[i] 一定可以排在這些元素後面作爲一個新的上升子序列
    		// 因此以 nums[i] 爲結尾(最大值)的最長上升子序列一定爲這些上升子序列的長度中最大的
    		// 即設 min_nums[] 爲nums[] 中小於nums[i] 的數組
    		// 則 dp[i] = max(min_nums[]) + 1;
    		if(nums.length == 0) return 0;//原數組長度爲0,則其最長子序列長度爲0
        int dp[] = new int[nums.length];
        //初始化,默認以第 i 個元素爲結尾的上升子序列長度爲 1(自身)
        for(int i=0;i<nums.length;i++)dp[i] = 1;
        //遞歸出口,對於第1個元素結尾的最長上升子序列爲1
        dp[0] = 1;
        for(int i=1;i<nums.length;i++) {
        		int num = nums[i];//當前數字,需要加入數組的數字
        		int max_length = 1;//初始化以當前數字爲結尾的所有最長上升子序列的最大值
        		// 遍歷dp數組,獲得所有dp數組中最大值(對應nums[i]),若當前數字大於dp數組的最大值,則該數字可加入到該dp數組後作爲一個新的上升子序列
        		// 最終需要這些新的上升子序列中最長的那個子序列
        		for(int j=i-1;j>=0;j--) {
        			if(nums[j]<num && max_length<dp[j]+1)
        				max_length = dp[j]+1;
        		}
        		dp[i] = max_length;
        }
        // dp 數組中最長的最長上升子序列即爲解
        int result = max_in_array(dp);
        return result;
    }
    
    int max_in_array(int[] dp) {
    	//取一個數組中最大值
    		int max = dp[0];
    		for(int i=1;i<dp.length;i++)
    			if(dp[i]>max)max = dp[i];
    	    return max;
    }

例7:最小路徑和(64)

題目描述
給定一個包含非負整數的 m x n 網格,請找出一條從左上角到右下角的路徑,使得路徑上的數字總和爲最小。
說明:每次只能向下或者向右移動一步。

示例:
輸入:
[
  [1,3,1],
  [1,5,1],
  [4,2,1]
]
輸出: 7
解釋: 因爲路徑 1→3→1→1→1 的總和最小。

算法思路

  1. 定義一個動態規劃二維數組dp[][],其中dp[i][j]表示移動到網格grid[i][j]時最小路徑的值
  2. 因爲每次只能向下或者向右移動一步,因此dp[i][j]的狀態一定是從dp[i-1][j]或者dp[i][j-1]轉移
  3. 則dp[i][j] = min(dp[i-1][j],dp[i][j-1]) + grid[i][j];

程序代碼

    public int minPathSum(int[][] grid) {
        	// 定義一個動態規劃二維數組dp[][],其中dp[i][j]表示移動到網格grid[i][j]時最小路徑的值
    		// 因爲每次只能向下或者向右移動一步,因此dp[i][j]的狀態一定是從dp[i-1][j]或者dp[i][j-1]轉移
    		// 則dp[i][j] = min(dp[i-1][j],dp[i][j-1]) + grid[i][j];
    		// 初始狀態 dp[0][0] = grid[0][0];
    		// 第一行的數據只能一直向右,第一列的數據只能一直向下
    		// 故第一行與第一列數據也可初始化得出
    		if(grid.length == 0)return 0;//網格爲空,最小路徑和爲0
    		int grid_row = grid.length;//網格行數
    		int grid_col = grid[0].length;//網格列數
    		int[][]dp = new int[grid_row][grid_col];
    		for(int i =0;i<grid_row;i++)
    			for(int j=0;j<grid_col;j++)
    				dp[i][j] = 0;
    		// 初始化第一行與第一列的數據
    		dp[0][0] = grid[0][0];
    		for(int i=1;i<grid_row;i++) {
    			//第一列的數據只能不斷向下移動
    			dp[i][0] = dp[i-1][0] + grid[i][0];
    		}
    		for(int i=1;i<grid_col;i++) {
    			//第一行的數據只能不斷向右移動
    			dp[0][i] = dp[0][i-1] + grid[0][i];
    		}
    		// 遍歷繼續擴展
    		// dp[i][j] 爲從dp[i-1][j]向下移動或者dp[i][j-1]向右移動所得,取較小值加上自身值
    		for(int i=1;i<grid_row;i++)
    			for(int j=1;j<grid_col;j++)
    				dp[i][j] = min(dp[i-1][j],dp[i][j-1]) + grid[i][j];
    		// 返回到達終點時的最小路徑
    		return dp[grid_row-1][grid_col-1];
    }
    
        public int min(int a,int b) {
    		return a>=b?b:a;
    }

例8:地牢遊戲(174)

題目描述
一些惡魔抓住了公主(P)並將她關在了地下城的右下角。地下城是由 M x N 個房間組成的二維網格。我們英勇的騎士(K)最初被安置在左上角的房間裏,他必須穿過地下城並通過對抗惡魔來拯救公主。
騎士的初始健康點數爲一個正整數。如果他的健康點數在某一時刻降至 0 或以下,他會立即死亡。
有些房間由惡魔守衛,因此騎士在進入這些房間時會失去健康點數(若房間裏的值爲負整數,則表示騎士將損失健康點數);其他房間要麼是空的(房間裏的值爲 0),要麼包含增加騎士健康點數的魔法球(若房間裏的值爲正整數,則表示騎士將增加健康點數)。
爲了儘快到達公主,騎士決定每次只向右或向下移動一步。
編寫一個函數來計算確保騎士能夠拯救到公主所需的最低初始健康點數。
例如,考慮到如下佈局的地下城,如果騎士遵循最佳路徑 右 -> 右 -> 下 -> 下,則騎士的初始健康點數至少爲 7。

-2 -3 3
-5 -10 1
10 30 -5

說明:
騎士的健康點數沒有上限。
任何房間都可能對騎士的健康點數造成威脅,也可能增加騎士的健康點數,包括騎士進入的左上角房間以及公主被監禁的右下角房間。
算法思路
從右下向左上遞推:
dp[i][j]代表若要到達右下角,至少有多少血量,能在行走的過程中至少保持生命值爲1.
則dp[0][0] = max(1,1-dungeon[0][0])
若代表地牢的二維數組爲1n或n1的數組:
1n,i從n-2至0:dp[0][i] = max(1,dp[0][i+1]-dungeon[0][i]);
n
1,i從n-2至0:dp[i][0] = max(1,dp[i+1][0]-dungeon[i][0]);
若代表地牢的二維數組爲n*m:
i代表行,從n-2至0:
j代表列,從n-2至0:
設dp_min = min(dp[i+1][j],dp[i][j+1])
dp[i][j] = max(1,dp_min - dungeon[i][j])
程序代碼

    public int calculateMinimumHP(int[][] dungeon) {
        int row = dungeon.length;//行數
        int col = dungeon[0].length;//列數
        if(row == 0)return 1;//當網格爲空,保證騎士健康的最小值爲1
        // 動態規劃數組dp[i][j] 表示通過倒推到達原數組dungeon[i][j]位置時的最小血量
        // 因此dp[0][0] 即爲初始時的最小血量
        int[][] dp = new int[row][col];
        // 初始化
        // 最後一個位置的最小血量爲 健康點數1 - 進入最後一個位置dungeon[row-1][col-1]所消耗的血量(消耗最後一個位置前血量)
        // 若倒推得最後一行消耗前血量<1,則說明消耗最後一個位置前血量最小值即爲健康點數
        dp[row-1][col-1] = max(1,1-dungeon[row-1][col-1]);
        // 最後一行均由後一個位置向左遞推
        for(int i=col-2;i>=0;i--)dp[row-1][i] = max(1,dp[row-1][i+1] - dungeon[row-1][i]);
        // 最後一列均由後一個位置向上遞推
        	for(int i=row-2;i>=0;i--)dp[i][col-1] = max(1,dp[i+1][col-1] - dungeon[i][col-1]);
        	// 其餘位置由下方位置或右方位置向上或者向左推得
        	for(int i=row-2;i>=0;i--)
        		for(int j=col-2;j>=0;j--) {
        			// 到達下方位置或者到達右方位置的最小生命值 中較小生命值
        			int min_dp = min(dp[i+1][j],dp[i][j+1]);
        			// 到達該位置的最小生命值 爲到達下一個位置的較小生命值 - 該位置消耗的生命值
        			// 如果消耗該位置的生命值 < 0 ,則能保證到達該位置前的生命值爲最小健康值即可。
        			dp[i][j] = max(1,min_dp - dungeon[i][j]);
        		}
        	return dp[0][0];//最初位置的值即爲最初需要的最少生命值
    }
        public int min(int a,int b) {
    		return a>=b?b:a;
    }
    
        public int max(int a,int b) {
    		return a>=b?a:b;
    }

劍指offer

例1:連續子數組的最大和(30)

題目描述
HZ偶爾會拿些專業問題來忽悠那些非計算機專業的同學。今天測試組開完會後,他又發話了:在古老的一維模式識別中,常常需要計算連續子向量的最大和,當向量全爲正數的時候,問題很好解決。但是,如果向量中包含負數,是否應該包含某個負數,並期望旁邊的正數會彌補它呢?例如:{6,-3,-2,7,-15,1,2,2},連續子向量的最大和爲8(從第0個開始,到第3個爲止)。給一個數組,返回它的最大連續子序列的和,你會不會被他忽悠住?(子向量的長度至少是1)
程序代碼

    // 30.連續子數組的最大和
    // HZ偶爾會拿些專業問題來忽悠那些非計算機專業的同學。
    // 今天測試組開完會後,他又發話了:在古老的一維模式識別中,常常需要計算連續子向量的最大和,
    // 當向量全爲正數的時候,問題很好解決。但是,如果向量中包含負數,是否應該包含某個負數,並期望旁邊的正數會彌補它呢?
    // 例如:{6,-3,-2,7,-15,1,2,2},連續子向量的最大和爲8(從第0個開始,到第3個爲止)。
    // 給一個數組,返回它的最大連續子序列的和,你會不會被他忽悠住?(子向量的長度至少是1)
    public int FindGreatestSumOfSubArray(int[] array) {
        // 採用動態規劃
    		// 設F(i) 表示以array[i]爲結尾的子數組的最大值,則
    		// F(i) = max(F(i-1)+array[i],array[i]);
    		// 利用數組maxArray存儲這些數組的最大值,則array中子數組最大值爲maxValueOf(maxArray)
    			int[] maxArray = constructMaxSubArray(array);// maxArrays存儲以array[i]爲末尾的子數組的最大值
    			int maxSum = Integer.MIN_VALUE;				// 記錄子數組最大值
        		for(int i=0;i<maxArray.length;i++)
    			if(maxArray[i]>maxSum)maxSum = maxArray[i];
    		
    		return maxSum;
    }
    
    // 自頂向下備忘錄法
    public int[] constructMaxSubArray(int[] array) {
    		int[] maxArray = new int[array.length];
    		maxArray[0] = array[0]; 
    		for(int i=1;i<array.length;i++) {
    			maxArray[i] = Integer.max(maxArray[i-1] + array[i], array[i]);
    		}
    		return maxArray;
    }

例2:滑動窗口最大值(63)

題目描述
給定一個數組和滑動窗口的大小,找出所有滑動窗口裏數值的最大值。例如,如果輸入數組{2,3,4,2,6,2,5,1}及滑動窗口的大小3,那麼一共存在6個滑動窗口,他們的最大值分別爲{4,4,6,6,6,5}; 針對數組{2,3,4,2,6,2,5,1}的滑動窗口有以下6個: {[2,3,4],2,6,2,5,1}, {2,[3,4,2],6,2,5,1}, {2,3,[4,2,6],2,5,1}, {2,3,4,[2,6,2],5,1}, {2,3,4,2,[6,2,5],1}, {2,3,4,2,6,[2,5,1]}。
程序代碼

    // 63.滑動窗口的最大值
    // 給定一個數組和滑動窗口的大小,找出所有滑動窗口裏數值的最大值。
    // 例如,如果輸入數組{2,3,4,2,6,2,5,1}及滑動窗口的大小3,那麼一共存在6個滑動窗口,他們的最大值分別爲{4,4,6,6,6,5};
    // 針對數組{2,3,4,2,6,2,5,1}的滑動窗口有以下6個:
    // {[2,3,4],2,6,2,5,1}, {2,[3,4,2],6,2,5,1}, {2,3,[4,2,6],2,5,1},
    // {2,3,4,[2,6,2],5,1}, {2,3,4,2,[6,2,5],1}, {2,3,4,2,6,[2,5,1]}。
    ArrayList<Integer> maxInWindowsList = new ArrayList<Integer>();	// 窗口最大值列表
    public ArrayList<Integer> maxInWindows(int [] num, int size)
    {
        if(size > num.length || size<=0)return maxInWindowsList;
        getMaxWindowsList(num,size);
        return maxInWindowsList;
    }
    
    public void getMaxWindowsList(int[] num,int size) {
    		// 填充窗口最大值列表的第 i 位置元素,即
    		// 以第 i+size-1 個元素作爲窗口尾端元素時窗口的最大值
    		// 一共需要填充 num.length-size+1 個元素
    		// 以第 i 個元素爲窗口尾端元素時窗口的最大值 = 上一個滑動窗口的最大值
    		// 初始化,窗口最大值列表
    		maxInWindowsList.add(findMaxInArray(num,0,size-1));
    		for(int i=1;i<num.length-size+1;i++) {
    			// 填充窗口最大值列表的第i個位置
    			// 爲以 j 爲末端的長爲size的窗口最大值
    			// 若新添加數 num[j]> maxInWindowsList[i-1],則最大值爲num[j]
    			// 否則,若最大值不爲上一個滑動窗口的首元素,則最大值爲maxInWindowsList[i-1]
    			// 否則,重新通過findMaxInArray遍歷窗口元素尋找最大值
    			int j = i+size-1;	// 滑動窗口最末端元素
    			
    			int lastMax = maxInWindowsList.get(i-1);
    			if(num[j] > lastMax)maxInWindowsList.add(num[j]);
    			else {
    				if(num[i-1] != lastMax)maxInWindowsList.add(lastMax);
    				else maxInWindowsList.add(findMaxInArray(num,i,j));
    			}
    		}
    }
    

2019校招真題

例1:牛牛找工作(1)

題目描述
爲了找到自己滿意的工作,牛牛收集了每種工作的難度和報酬。牛牛選工作的標準是在難度不超過自身能力值的情況下,牛牛選擇報酬最高的工作。在牛牛選定了自己的工作後,牛牛的小夥伴們來找牛牛幫忙選工作,牛牛依然使用自己的標準來幫助小夥伴們。牛牛的小夥伴太多了,於是他只好把這個任務交給了你。
程序代碼

	// 1. 牛牛找工作
//	// 方法1:貪心(超時)
//	public class Work{		// 自定義工作類
//		public int d;		// 工作的難度
//		public int p;		// 工作的報酬
//		
//		public Work(int _d,int _p) {
//			this.d = _d;
//			this.p = _p;
//		}
//	}
//	
//	public class WorkComparator implements Comparator<Work> {
//
//	    @Override
//	    public int compare(Work w1, Work w2) {	// 進行比較的工作
//	        if (w1.d > w2.d) {					// 難度從小到大排序
//	            return 1;
//	        } else return -1;
//	    }
//	}
//	
//	public void findWork() {
//		// 輸入
//		Scanner sc = new Scanner(System.in);
//		int n = sc.nextInt();	// 工作數量
//		int m = sc.nextInt();	// 夥伴數量
//		if(n==0 || m==0)return;
//		ArrayList<Work> workList = new ArrayList<Work>();			// 工作列表
//		ArrayList<Integer> aOfFriendList = new ArrayList<Integer>(); // 夥伴們能力列表
//		for(int i=0;i<n;i++) {
//			int d = sc.nextInt();
//			int p = sc.nextInt();
//			workList.add(new Work(d,p));
//		}
//		for(int i=0;i<m;i++) {
//			int a = sc.nextInt();
//			aOfFriendList.add(a);
//		}
//		helpFriendsFindWork(n,m,workList,aOfFriendList);
//	}
//	
//	public void helpFriendsFindWork(int n,int m,ArrayList<Work> workList,ArrayList<Integer> aOfFriendList) {
//		// 對workList按照難度升序排序
//		Collections.sort(workList, new WorkComparator());
//		// 爲每個夥伴尋找對應合適的工作
//		for(int i=0;i<m;i++) {
//			Integer ability = aOfFriendList.get(i);
//			Integer bestP = 0;
//			for(int j=0;j<n;j++) {
//				if(ability >= workList.get(j).d) {	// 能力值滿足 
//					if(bestP < workList.get(j).p)bestP = workList.get(j).p;
//				}else break;
//			}
//			System.out.println(bestP);
//		}
//	}
	
	// 方法2:揹包
	// 揹包思想:當前工作難度/能力值對應報酬 = max(低於該難度值工作對應最大報酬,已存在的該難度值對應的報酬)
//	找到難度不大於能力的所有工作裏,報酬最多的。核心是用HashMap來記錄難度和不超過該難度的最大報酬。
//	先把工作的難度和報酬映射到HashMap
//	把人的能力也全部讀進來,放到HashMap,報酬可以先設爲0.
//	最後按難度從小到大(所以需要先排序)更新HashMap,key爲難度,value爲不超過難度的最大報酬。
	public void findWork() {
		// 輸入
		Scanner sc = new Scanner(System.in);
		int n = sc.nextInt();	// 工作數量
		int m = sc.nextInt();	// 夥伴數量
		if(n==0 || m==0)return;
		int[] dList  = new int[m+n];	// 對應工作能力/難度(記錄所有工作能力)
		int[] aList = new int[m];	// 夥伴對應工作能力
		HashMap<Integer,Integer> pOfD = new HashMap<Integer,Integer>();	// 不超過該難度d所能得到的最大報酬p(d,p)
		for(int i=0;i<n;i++) {
			int d = sc.nextInt();
			int p = sc.nextInt();
			dList[i] = d;
			pOfD.put(d, p);
		}
		for(int i=0;i<m;i++) {
			int a = sc.nextInt();
			dList[i+n] = a;	// 將員工的工作能力加入數組
			aList[i] = a;
			if(!pOfD.containsKey(a))pOfD.put(a, 0);	// 初始化員工能力對應的報酬爲0
		}
		// 對工作難度升序排序
		Arrays.sort(dList);
		int maxP = 0;
		for(int i=0;i<m+n;i++) {
			// 由於工作難度升序排序,所以當前能力值對應的報酬爲 max (maxP(小於該能力值所對應的最大報酬), dOfA(已存在該工作難度對應的報酬))
			// 對HashMap進行更新
			int d = dList[i];
			maxP = Math.max(maxP, pOfD.get(d));
			pOfD.replace(d, maxP);
		}
		for(int i=0;i<m;i++) 
			System.out.println(pOfD.get(aList[i]));
	}
	
	public static void main(String[] args) {
		DP dp = new DP();
		dp.findWork();
	}

例2:牛牛的揹包問題(8)

題目描述
牛牛準備參加學校組織的春遊, 出發前牛牛準備往揹包裏裝入一些零食, 牛牛的揹包容量爲w。
牛牛家裏一共有n袋零食, 第i袋零食體積爲v[i]。
牛牛想知道在總體積不超過揹包容量的情況下,他一共有多少種零食放法(總體積爲0也算一種放法)。
輸入描述:
輸入包括兩行
第一行爲兩個正整數n和w(1 <= n <= 30, 1 <= w <= 2 * 10^9),表示零食的數量和揹包的容量。
第二行n個正整數v[i](0 <= v[i] <= 10^9),表示每袋零食的體積。
輸出描述:
輸出一個正整數, 表示牛牛一共有多少種零食放法。
程序代碼

	// 8. 牛牛的揹包問題
	// 牛牛準備參加學校組織的春遊, 出發前牛牛準備往揹包裏裝入一些零食, 牛牛的揹包容量爲w。
	// 牛牛家裏一共有n袋零食, 第i袋零食體積爲v[i]。
	// 牛牛想知道在總體積不超過揹包容量的情況下,他一共有多少種零食放法(總體積爲0也算一種放法)。
	Integer count = 0;		// 零食放法
	long[] v;				// 零食體積列表
	public void bagQuestion() {
		// 典型揹包問題:
		// 基本思想:
		// 1. 揹包裏共有n個位置,遞歸每個位置
		// 2. 遞歸第 i 個位置,可選擇放/不放零食,
		// 3. 每個位置都有2個選擇,一共需要遞歸 2^n 種可能。
		// 遍歷到最後一位置,如果容量<w,則記爲一次可行的放置,count++,最終count爲零食總放法
		// 這種暴力窮舉的算法,複雜度2^n,AC率爲80%,考慮剪枝優化
		// 思想2:
		// 1. 若零食總體積<揹包容量,說明所有零食均可放或者不可放,直接返回 2^n
		// 2. 對零食體積列表進行排序,此時對於第 i 個位置
		// 	如果位置 i 處,加入第i個零食時容量已>w,則再加入後面的零食(更大的零食)一定不可行。此時後面的零食只有不放入的可能,因此直接count++,返回。
		//  否則,則按思想1繼續遞歸
 		Scanner sc = new Scanner(System.in);
		int n = sc.nextInt();	// 零食的數量
		long w = sc.nextLong();	// 揹包的容量
		v = new long[n];
		long sum = 0;
		for(int i=0;i<n;i++) { 
			v[i] = sc.nextLong();
			sum += v[i];
		}
		if(sum <= w) {
			System.out.println((int)Math.pow(2, n));
			return;
		}
		Arrays.sort(v);
		addSnackInBag(0,n,w,0);
		System.out.println(count);
	}
	
	public void addSnackInBag(int i,int n,long w, long sum) {
		// 在第 i 個位置放零食, sum 表示前 i-1 個位置零食所佔容量
		if(i == n && sum<=w) {count++;return;}
		if(sum + v[i] <= w) {
			addSnackInBag(i+1,n,w,sum+v[i]);
			addSnackInBag(i+1,n,w,sum);
		}else {
			// addSnackInBag(i+1,n,w,sum);
			count++;return;
		}
	}
發佈了37 篇原創文章 · 獲贊 7 · 訪問量 5258
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章