動態規劃求解最大子段和 (兩種寫法+還原最優解)

Amαdeus發表於2022-11-22

前言

在這篇文章中,我將介紹動態規劃求解 最大子段和(即最大子陣列累加和問題) 的兩種寫法思路及其還原最優解,後面還包含了一點小小的最佳化。?



最大子段和解析

最大子段和問題描述

給定一個長度為 len 的序列: a[0], a[1], a[2] ... a[len - 1] ,求在這個序列中一個子區間 [j, i] 使得 a[j] + a[j + 1] +...+ a[i] 的和最大,並求出這個最大累加和。
比如一個序列為 {-6, 7, -1, 5, 11, -7, 2},那麼其最大子段和結果為 22,所求子區間範圍是 [1, 4]

為什麼可以用動態規劃

最優子結構性質

最優子結構: 原問題的最優解包含了子問題的最優解。

假設求得了某兩個最大子段和區間,分別為 [j, i][j, i - 1],前一個子區間的元素分別為 {a[j], a[j + 1] ... a[i]},後一個子區間的元素分別為 {a[j], a[j + 1] ... a[i - 1]}。我們很容易發現,後一個子區間 [j, i - 1] 同時也是前一個子區間 [j, i] 的子區間。

假設我們的兩個子區間範圍內的最大累加和分別是 maxAmaxB,那麼可以得出 maxA 必然包含了 maxB。也就是說 maxA = maxB + a[i] or 0,當 a[i] 為正數時,我們可以加上 a[i],這樣 [j, i] 的最大累加和相較於 [j, i - 1] 就更大;當 a[i] 為負數時,我們加上它之後只會減小區間 [j, i] 的最大累加和,相較於 [j, i - 1] 反而更小,所以此時不加上 a[i]

由此可見,最大子段和問題是滿足最優子結構性質的。

無後效性

無後效性: 某階段的狀態一旦確定,則此後過程的演變不再受此前各狀態及決策的影響。
簡單地說,就是當前一個狀態確定之後,之後的過程中的任何決策都不會影響當前的這個已確定的狀態,只是和當前這個狀態有關而已。就好像是"未來與過去無關",我們無法改變過去,但是我們過往的事蹟是和我們的一生都息息相關的。?

對於最大子段和問題,我們都是在先前的狀態基礎上更新當前的最優解。假設求得之前某個最大子段和區間 [j, i - 1],那麼我們現在要求區間 [j, i] 的最大累加和,那麼我們只需要在之前區間 [j, i - 1] 確定狀態的基礎上,更新當前區間 [j, i] 的最優解,這個問題就是在上一個小標題裡我說到的是否加上 a[i]

由此可見,最大子段和是滿足無後效性的。

重疊子問題性質

重疊子問題: 在過程中重複計算相同的子問題,即子問題之間不是相互獨立的,子問題的最優解在之後的決策中會被用到。
在上面的闡述中也已經提到,假設我們求得某兩個最大子段和子區間分別是 [j, i][j, i - 1],區間 [j, i - 1] 的最大累加和maxB 是先前已經確定的狀態,我們求解 [j, i] 的最大累加和 maxA 需要用到這個 maxB,無需再次計算 maxB

很顯然,最大子段和問題也是滿足重疊子問題性質的。

綜上,我們可以用動態規劃演算法來求解最大子段和(最大子陣列累加和)問題。

演算法步驟

1、首先我們需要定義 dp[] 陣列來記錄每個狀態下的最優解;

2、為了還原最優解,我們需要定義 rec[] 陣列來記錄邊界下標;

3、構建遞推方程,求出最大子段和,並還原具體最優解。



兩種思路概述

在求解這個問題時,我想到了兩種求解最大子段和問題的思路。一種是從前往後記錄的方法,一種從後往前記錄的方法(這些名字都是我自己取的?)。
下面我會分別介紹這兩種思路,並且給出各自的遞推方程。???



從前往後記錄法

從前往後記錄法解釋

我們的 dp[] 陣列,在這個從前往後記錄的方法中,記錄的是以下標為 i結尾下標的子區間的最大累加和,與此同時 rec[] 陣列記錄的是對應以下標為 i結尾下標的子區間的左邊界(這些很重要?)。也就是說,如果我們的最大子段和最終答案是 dp[i],那麼對應的區間是 [rec[i], i],我們也可以定義 left = rec[i], right = i,這樣更清晰一點。初始情況下,dp[0] = a[0], rec[0] = 0

對於從前往後記錄的方法,我們可以得到如下的式子:

由此可以得到對應動態規劃遞推方程:


其實可以簡單地理解成我們在當前的狀態下是否拋棄前面部分的最大累加和
當我們前半部分最大累加和 dp[i-1] 是一個正數,那麼我們當前元素 a[i] 加上它,就是當前以 i 為結尾的最大累加和,此時就是 dp[i] = a[i] + dp[i - 1]
當我們前半部分最大累加和 dp[i-1] 都已經是一個負值(或者0)了,那麼我們當前元素 a[i] 加上它只會讓當前以 i 為結尾的最大累加和更小,所以我們要丟棄它,從新的起點下標開始,也就是此時的 dp[i] = a[i]

同時我們寫出對應的 rec[] 的方程:


rec[i] 此時就是記錄的對應 dp[i] 的左邊界,即區間的起始下標。
dp[i - 1] 是一個正數,我們會加上它,自然也會延續之前的起始下標,也就是這裡的 rec[i] = rec[i - 1]
dp[i - 1] 是一個非正數,我們選擇丟棄前半部分,那麼我們以當前下標 i 為新的起始點,也就是 rec[i] = i

從前往後記錄法動態演示

我們以 {4, -5, 8, 9, -19, 3, 6, -1, 10, -2} 為例

(1)

(2)

(3)

(4)

(5)

(6)

(7)

(8)

(9)

(10)

從前往後記錄法實現程式碼

void Find_Subarray_Max1(int a[], int n){
    int *dp = new int[MAX];	        //用於儲存以下標為 i 作為結尾的最大子陣列累加和
    int *rec = new int[MAX];	        //用於記錄下標為 i 作為結尾的最大子陣列的開頭下標

    /*		 動態規劃部分	    	*/
    dp[0] = a[0];
    rec[0] = 0;			 	//從開頭開始
    for(int i = 1; i <= n; i++){
	if(dp[i - 1] > 0){
	    dp[i] = a[i] + dp[i - 1];
	    rec[i] = rec[i - 1];
	}else{
	    dp[i] = a[i];		//丟棄前部分和為負的子段
	    rec[i] = i;
        }
    }

    /*		 還原最優解部分		*/
    int max = INT_MIN;	 		//足夠小的負數
    int left, right;
    for(int i = 0; i <= n; i++){
	if(max < dp[i]){
	    max = dp[i];
	    left = rec[i];
	    right = i;
	}
    }

    cout<< "最大子陣列累加和為: " << max << endl;
    cout<< "起始下標為: " << left << "  終止下標為: " << right <<endl<<endl;
    delete[] dp;
    delete[] rec;
}

時間複雜度

動態規劃部分進行了 n - 1次掃描,還原最優解部分進行了 n 次掃描,由此可知時間複雜度為 O(n)



從後往前記錄法

從後往前記錄法解釋

說實話這個思路略有一點逆思維,其實只是掃描方向的區別,就好像是把陣列翻轉一下,事實證明從後往前記錄的順序也是同樣可以解決最大子段和問題的,只不過我在網上很少看到有人寫,所以決定自己來寫一下。?

不同的是我們的 dp[] 陣列,在這個從後往前記錄的方法中,記錄的是以下標為 i起始下標的子區間的最大累加和,與此同時 rec[] 陣列記錄的是對應以下標為 i起始下標的子區間的右邊界(這些還是很重要?)。也就是說,如果我們的最大子段和最終答案是 dp[i],那麼對應的區間是 [i, rec[i]],我們也可以最後定義 left = i, right = rec[i]。初始情況下,若陣列最後一個下標為n,那麼dp[n] = a[n], rec[n] = n
(注意和之前方法的不同喲~)

類似地對於從後往前記錄我們可以得到如下的式子:

由此得到對應的動態規劃遞推方程:


還是和前面的方法差不多的,不一樣的是,對於當前 dp[i],我們此時決定是否拋棄後半部分的 dp[i + 1]

同時我們寫出對應的 rec[] 的方程:


也是和前面一樣的,rec[i]決策變成了是否延續先前的 rec[i + 1] 所記錄的對應右邊界下標。

從後往前記錄法動態演示

我們以 {4, -5, 8, 9, -19, 3, 6, -1, 10, -2} 為例

(1)

(2)

(3)

(4)

(5)

(6)

(7)

(8)

(9)

(10)

從後往前記錄法實現程式碼

void Find_Subarray_Max2(int a[], int n) {
    int *dp = new int[MAX];             //用於儲存以下標為 i 作為開頭的最大子陣列累加和
    int *rec = new int[MAX];            //用於記錄下標為 i 作為開頭的最大子陣列的結尾下標

    /*		 動態規劃部分	    	*/
    dp[n] = a[n];
    rec[n] = n;			 	//從最後開始
    for(int i = n - 1; i >= 0 ; i--){  
	if(dp[i + 1] > 0){
	    dp[i] = a[i] + dp[i + 1];
	    rec[i] = rec[i + 1];
	}else{
	    dp[i] = a[i];		//丟棄後半部分和為負的子段
	    rec[i] = i;
	}
    }

    /*		 還原最優解部分		*/
    int max = INT_MIN;   		//足夠小的負數
    int left, right;
    for(int i = 0; i <= n; i++){
	if(max < dp[i]){
	    max = dp[i];
	    left = i;
	    right = rec[i];
	}
    }

    cout<< "最大子陣列累加和為: " << max << endl;
    cout<< "起始下標為: " << left << "  終止下標為: " << right <<endl<<endl;
    delete[] dp;
    delete[] rec;
}

時間複雜度

和前面一樣的,依然是 O(n)



兩個最佳化點

狀態轉移過程的最佳化

在遞推過程中,我們發現每一次更新當前區間範圍最優解 dp[i] ,只用到了上一層的 dp[i - 1]。那麼我們是否可以不用 dp[] 陣列呢?
答案是可以的。我們可以定義一個變數 sum,來一直記錄當前子區間範圍的最優解。這樣可以省去 dp[] 陣列,雖然我自己寫程式碼時使用的動態分配儲存空間的陣列,後期能夠回收記憶體空間。主要是對於不太會用這個技巧的童鞋,省去 dp[] 陣列,自然可以節省一定的儲存空間。?

還原最優解的最佳化

在上面的實現程式碼中,動態規劃遞推部分和還原最優解部分是分開的,那麼是否可以同時進行呢?
我們藉助狀態轉移最佳化中重新定義的變數 sum,同時在定義一個變數 maxmax 是記錄當前已經遍歷到的地方為止的最優解,我們可以在 sum 被不斷更新地同時,也不斷更新 max。一直到整個陣列被遍歷完,此時 max 記錄的自然是整個最大子段和問題的最優解。
因此我們可以得到如下的關係:

同時為了還原具體最優解, rec[] 也不能閒著,還是需要記錄其邊界。
若為從前往後記錄,那麼 rec[i]sum > 0 時就等於 rec[i - 1];在 sum <= 0 時就等於 i。並且還需要一個 right 不斷記錄右邊界。
若為從後往前記錄,那麼 rec[i]sum > 0 時就等於 rec[i + 1];在 sum <= 0 時就等於 i。並且還需要一個
left 不斷記錄左邊界。

最佳化後的兩個寫法思路實現程式碼

/*		    從前往後        		*/
void Find_Subarray_Max3(int a[], int n){
    int *rec = new int[MAX];	//用於記錄下標為 i 作為結尾的最大子陣列的開頭下標

    /*		 省去dp陣列儲存	   	*/
    rec[0] = 0;
    int sum = a[0], max = INT_MIN, right;
    for(int i = 1; i <= n; i++){
	if(sum > 0){
	    sum += a[i];
	    rec[i] = rec[i - 1];
	}else{
	    sum = a[i];		//丟棄前半部分和為負的子段和
	    rec[i] = i;
	}

	/*	在過程中直接得到最優解	*/
	if(sum > max){
	    max = sum;
	    right = i;      	//可以獲得右邊界	rec記錄對應左邊界
	}
    }

    cout<< "最大子陣列累加和為: " << max << endl;
    cout<< "起始下標為: " << rec[right] << "  終止下標為: " << right <<endl<<endl;
    delete[] rec;
}


/*		    從後往前        		*/
void Find_Subarray_Max4(int a[], int n){
    int *rec = new int[MAX];	//用於記錄下標為 i 作為開頭的最大子陣列的結尾下標

    /*		 省去dp陣列儲存	   	*/
    rec[n] = n;
    int sum = a[n], max = INT_MIN, left;
    for(int i = n - 1; i >= 0; i--){
	if(sum > 0){
	    sum += a[i];
	    rec[i] = rec[i + 1];
	}else{
	    sum = a[i];     	//丟棄後半部分和為負的子段和
	    rec[i] = i;
	}

	/*	在過程中直接得到最優解	*/
	if(sum > max){
	    max = sum;
	    left = i;       	//可以求得左邊界 rec記錄對應右邊界
	}
    }

    cout<< "最大子陣列累加和為: " << max << endl;
    cout<< "起始下標為: " << left << "  終止下標為: " << rec[left] <<endl<<endl;
    delete[] rec;
}

時間複雜度

可以看到,我們少了一次迴圈,雖然演算法時間複雜度依然是 O(n),但實際上是少了 n 次掃描的。



完整程式

完整程式原始碼

#include <iostream>
using namespace std;
#define MAX 100


/*		    從前往後        		*/
void Find_Subarray_Max1(int a[], int n){
    int *dp = new int[MAX];	//用於儲存以下標為 i 作為結尾的最大子陣列累加和
    int *rec = new int[MAX];	//用於記錄下標為 i 作為結尾的最大子陣列的開頭下標

    /*		 動態規劃部分	    	*/
    dp[0] = a[0];
    rec[0] = 0;			//從開頭開始
    for(int i = 1; i <= n; i++){
        if(dp[i - 1] > 0){
	    dp[i] = a[i] + dp[i - 1];
	    rec[i] = rec[i - 1];
	}else{
	    dp[i] = a[i];	//丟棄前部分和為負的子段
	    rec[i] = i;
	}
    }

    /*		 還原最優解部分		*/
    int max = INT_MIN;	 	//足夠小的負數
    int left, right;
    for(int i = 0; i <= n; i++){
	if(max < dp[i]){
	    max = dp[i];
	    left = rec[i];
	    right = i;
	}
    }

    cout<< "最大子陣列累加和為: " << max << endl;
    cout<< "起始下標為: " << left << "  終止下標為: " << right <<endl<<endl;
    delete[] dp;
    delete[] rec;
}


/*		    從後往前        		*/
void Find_Subarray_Max2(int a[], int n) {
    int *dp = new int[MAX];     //用於儲存以下標為 i 作為開頭的最大子陣列累加和
    int *rec = new int[MAX];    //用於記錄下標為 i 作為開頭的最大子陣列的結尾下標

    /*		 動態規劃部分	    	*/
    dp[n] = a[n];
    rec[n] = n;			//從最後開始
    for(int i = n - 1; i >= 0 ; i--){  
	if(dp[i + 1] > 0){
	    dp[i] = a[i] + dp[i + 1];
	    rec[i] = rec[i + 1];
	}else{
	    dp[i] = a[i];	//丟棄後半部分和為負的子段
	    rec[i] = i;
	}
    }

    /*		 還原最優解部分		*/
    int max = INT_MIN;   	//足夠小的負數
    int left, right;
    for(int i = 0; i <= n; i++){
	if(max < dp[i]){
	    max = dp[i];
	    left = i;
	    right = rec[i];
	}
    }

    cout<< "最大子陣列累加和為: " << max << endl;
    cout<< "起始下標為: " << left << "  終止下標為: " << right <<endl<<endl;
    delete[] dp;
    delete[] rec;
}


/*		    從前往後(最佳化)        		*/
void Find_Subarray_Max3(int a[], int n){
    int *rec = new int[MAX];	//用於記錄下標為 i 作為結尾的最大子陣列的開頭下標

    /*		 省去dp陣列儲存	   	*/
    rec[0] = 0;
    int sum = a[0], max = INT_MIN, right;
    for(int i = 1; i <= n; i++){
	if(sum > 0){
	    sum += a[i];
	    rec[i] = rec[i - 1];
	}else{
	    sum = a[i];		//丟棄前半部分和為負的子段和
	    rec[i] = i;
	}

	/*	在過程中直接得到最優解	*/
	if(sum > max){
	    max = sum;
	    right = i;      	//可以獲得右邊界	rec記錄對應左邊界
	}
    }

    cout<< "最大子陣列累加和為: " << max << endl;
    cout<< "起始下標為: " << rec[right] << "  終止下標為: " << right <<endl<<endl;
    delete[] rec;
}


/*		    從後往前 (最佳化)       		*/
void Find_Subarray_Max4(int a[], int n){
    int *rec = new int[MAX];	//用於記錄下標為 i 作為開頭的最大子陣列的結尾下標

    /*		 省去dp陣列儲存	   	*/
    rec[n] = n;
    int sum = a[n], max = INT_MIN, left;
    for(int i = n - 1; i >= 0; i--){
	if(sum > 0){
	    sum += a[i];
	    rec[i] = rec[i + 1];
	}else{
	    sum = a[i];     	//丟棄後半部分和為負的子段和
	    rec[i] = i;
	}

	/*	在過程中直接得到最優解	*/
	if(sum > max){
	    max = sum;
	    left = i;       	//可以求得左邊界 rec記錄對應右邊界
	}
    }

    cout<< "最大子陣列累加和為: " << max << endl;
    cout<< "起始下標為: " << left << "  終止下標為: " << rec[left] <<endl<<endl;
    delete[] rec;
}


main() {
    int a[10] = {4, -5, 8, 9, -19, 3, 6, -1, 10, -2};
    for(int i = 0; i < 9; i++)
	cout<<a[i]<<" ";
    cout<<endl<<endl;

    Find_Subarray_Max1(a, 9);
    Find_Subarray_Max2(a, 9);
    Find_Subarray_Max3(a, 9);
    Find_Subarray_Max4(a, 9);
}

程式執行結果

包含了兩種寫法思路實現的結果和最佳化後兩種寫法思路實現的結果

相關文章