《程式設計珠璣》程式碼之路11:最大子陣列和問題,花式七種解法

心理學演算法工程師發表於2018-11-23

給一個一維陣列,有正數也有負數,求最大子陣列和是多少。

這是《程式設計珠璣》第八章探討的一個主要問題,也是平時刷題和各大廠面試的常客。

作為這麼經典的一個問題,要是老生常談,那就沒什麼意義了,這裡為大家帶來七種解法,其中更有一個最優複雜度的線性演算法,博主在各大廠面試的時候,碰到的面試官也非常驚訝有這麼一個解法的存在。自然問到這個問題的面試,都理所當然的過了。(本部落格程式碼不會考慮溢位等工程問題)。

本部落格將包括以下七種解法的程式碼和講解:

1:基於暴力列舉的三次方演算法。

2:兩種用動態規劃稍加處理的平方演算法。

3:一種基於分治演算法的遞迴演算法,複雜度為O(n*logn),以及動態規劃優化後的線性時間複雜度解法。

4:兩種線性複雜度演算法,基於動態規劃的掃描演算法以及將最大子段和轉化為求差的兩種解法。

通過1到2的優化,會講述如何運用動態規劃的一些小技巧,3會談到遞迴分治演算法,4會重點講述動態規劃和一個有思維小技巧的演算法。如果面試碰到這個問題,從淺入深,從以上角度把這個問題分析一遍,瞬間就能表現出和臨時抱佛腳的人的差距,足夠折服一般的面試官了,不出意外,此面必過。

當然無論何時,學習和感悟,永遠是第一位,過面試,只是附屬品。

祝大家在漫長的學習旅途中,不僅僅內功越來越深厚,現實中的面試和工作,也能披荊斬棘。

好啦,進入正題,我們就先從最基礎的解法開始:

公式說明:sum(i,j)代表nums[i]一直加到nums[j],包含端點。

解法一:暴力解法

這個解法比較容易理解,不是要求最大子段和嘛,那我把所有的子陣列都列舉出來求和,找個最大的就好了,複雜度O(n^3),程式碼如下:

int force(){

	int ans = 0;

	for (int i = 0; i < nums.size(); ++i){
		for (int j = i; j < nums.size(); ++j){
			int sum = 0;
			for (int k = i; k <= j; ++k){
				sum += nums[k];
			}
			ans = max(ans, sum);
		}
	}

	return ans;
}

解法二:帶有初步動態規劃優化的解法:

我們在列舉子陣列求和的時候,子陣列1,2..j和1,2..j-1就只差一個nums[j],那麼我們在求和的時候,就沒必要每次都從1開始到j都加一遍,只需要在上一次j-1和的基礎上再加nums[j]就可以了,這樣就優化掉了最裡面的迴圈,複雜度變為O(n^2)

int dp1(){

	int ans = 0;

	for (int i = 0; i < nums.size(); ++i){
		int sum = 0;
		for (int j = i; j < nums.size(); ++j){
			sum += nums[j];
			ans = max(ans, sum);
		}
	}

	return ans;
}

解法三:儲存陣列前i項和:

我們要求某個子陣列i-j的和,其實可以轉化為前j項和減去前i-1項和,sum(i,j) = sum(0,j) - sum(0,i -1),那麼我們把前i項和放在一個陣列裡,用額外的空間儲存起來就可以在O(1)的時間求出某個子陣列和,用空間換取時間,空間複雜度O(n),時間複雜度O(n ^ 2),程式碼如下:

int cachePreSum(){
	int ans = 0;
	int cache[1000];

	cache[0] = 0;
	for (int i = 1; i <= nums.size(); ++i){
		cache[i] = cache[i - 1] + nums[i - 1];
	}

	for (int i = 0; i < nums.size(); ++i){
		int sum = 0;
		for (int j = i; j < nums.size(); ++j){
			sum = cache[j + 1] - cache[i];
			ans = max(ans, sum);
		}
	}

	return ans;
}

解法四:分治演算法

這個解法比較少見,其基本思想是,一個陣列的最大子段和只有三種情況:

情況一:最大子陣列出現在左半部分:

情況二:最大子陣列出現在右半部分:

情況三:最大子陣列一部分在左半部分的最右端,另一部分在右半部分的最左端。

0 0 0 1 1 1 1 0 0 0

如上表標為1的部分。

那麼分析情況三:在左半部分的最右端的那部分,一定是從最右端連讀到左邊所有數和最大的那部分。在右半部分最左端的,一定是從最左端連續到右邊所有數和最大的部分。由於分支的遞迴過程會把所有區間段都分解到只剩一個數,然後在遞迴反回的時候再合併兩個數的區間,由此向上不斷合併。那麼遞迴過程其實處理情況3就好了。複雜度為O(n * logn)。

程式碼如下:

int DivideConquer(int l, int r){
	//沒有元素是反回0
	if (l > r){
		return 0;
	}
	//只有一個元素,反回和0比較大的哪個
	if (l == r){
		return max(0, nums[l]);
	}

	int m = (l + r) / 2;

	int sum = 0;
	int leftMax = sum = 0;
	//計算左邊的最大欄位和
	for (int i = m; i >= 0; i--){
		sum += nums[i];
		leftMax = max(leftMax, sum);
	}

	//計算右邊的最大欄位和
	int rightMax = sum = 0;
	for (int i = m + 1; i <= r; ++i){
		sum += nums[i];
		rightMax = max(sum, rightMax);
	}

	//遞迴計算最大部分
	return max(max(leftMax + rightMax, DivideConquer(l, m)), DivideConquer(m + 1, r));
}

解法五:如果細心會發現,解法五中遞迴的時候會有重複計算,儲存下中間過程便可以把時間複雜度優化到線性時間,瞭解記憶化搜尋和動態規劃關係的同學,不難寫出程式碼來。

程式碼如下:

int cache5[1000][1000];
需要如下初始化:
for (int i = 0; i <= nums.size(); ++i){
	for (int j = 0; j <= nums.size(); ++j){
		cache5[i][j] = INT_MAX;
	}
}
int DivideConquerWithCatch(int l, int r){
	//沒有元素是反回0
	if (l > r){
		return 0;
	}
	//只有一個元素,反回和0比較大的哪個
	if (l == r){
		return max(0, nums[l]);
	}

	if (cache5[l][r] != INT_MAX){
		return cache5[l][r];
	}
 
	int m = (l + r) / 2;
 
	int sum = 0;
	int leftMax = sum = 0;
	//計算左邊的最大欄位和
	for (int i = m; i >= 0; i--){
		sum += nums[i];
		leftMax = max(leftMax, sum);
	}
 
	//計算右邊的最大欄位和
	int rightMax = sum = 0;
	for (int i = m + 1; i <= r; ++i){
		sum += nums[i];
		rightMax = max(sum, rightMax);
	}
 
	//遞迴計算最大部分
	int ans = max(max(leftMax + rightMax, DivideConquer(l, m)), DivideConquer(m + 1, r));
	cache5[l][r] = ans;

	return ans;
}

解法六:基於動態規劃的掃描演算法:

想想啊,對於以j為結尾這個位置來說,最大子段有兩種可能,一種可能是最大子段結尾就是j,一種可能是最大子段結尾不是j。

對於最大子段結尾就是j這種情況:

maxsum = max(sum(i, j-1)+ nums[j], 0);

意思是,從i開始的某個數,一直加到j

對於最大子段結尾不是j的情況:

maxsum 就是 在計算j-1的最大值。

程式碼如下:

int dp2(){

	int ans = 0, sum = 0;
	for (int i = 0; i < nums.size(); ++i){
		sum = max(sum + nums[i], 0);
		ans = max(ans, sum);
	}

	return ans;
}

解法七:和轉化為差的動態規劃解法:

這個解法極其少見,博主面試很多大廠的時候,面試官都表示沒見過這個解法,然後博主被問到這個問題的面試都毫無壓力的過了。

考慮這麼個情況,就是以j結尾的子段,最大子段和其實是sum(0,j) - min(sum(0, i)) i屬於[0,j-1]。

換句話說,當前子段0到j的和最大子段和,等於0到j的和,減去0到j之前連續子段和的最小值。

程式碼如下:

int solve6(){
	if (nums.size() == 0) {
		return 0;
	}

	int ans = 0x80000000;
    int preMin = 0, curSum = 0, preSum = 0;
    for (int i = 0; i < nums.size(); ++i){
        curSum += nums[i];
        ans = max(ans, curSum - preMin);
        preSum += nums[i];
        preMin = min(preMin, preSum);
    }

    return ans;
}

附上完整程式碼:


#include <iostream>
#include <cstdio>
#include <vector>
#include <algorithm>
 
using namespace std;
 
vector<int> nums;
 
int force();
int dp1();
int cachePreSum();
int DivideConquer(int l, int r);
int DivideConquerWithCatch(int l, int r);
int dp2();
int solve6();

int cache5[1000][1000];
 
int main(){
 
	freopen("in.txt", "r", stdin);
 
	int num;
	while (scanf("%d", &num) != EOF){
		nums.push_back(num);
	}
 
	cout << "1--" << force() << endl;
	cout << "2--" << dp1() << endl;
	cout << "3--" << cachePreSum() << endl;
	cout << "4--" << DivideConquer(0, nums.size() - 1) << endl;

	for (int i = 0; i <= nums.size(); ++i){
		for (int j = 0; j <= nums.size(); ++j){
			cache5[i][j] = INT_MAX;
		}
	}
	cout << "5--" << DivideConquerWithCatch(0, nums.size() - 1) << endl;
	cout << "6--" << dp2() << endl;
	cout << "7--" << solve6() << endl;
	return 0;
}
 
int force(){
 
	int ans = 0;
 
	for (int i = 0; i < nums.size(); ++i){
		for (int j = i; j < nums.size(); ++j){
			int sum = 0;
			for (int k = i; k <= j; ++k){
				sum += nums[k];
			}
			ans = max(ans, sum);
		}
	}
 
	return ans;
}
 
int dp1(){
 
	int ans = 0;
 
	for (int i = 0; i < nums.size(); ++i){
		int sum = 0;
		for (int j = i; j < nums.size(); ++j){
			sum += nums[j];
			ans = max(ans, sum);
		}
	}
 
	return ans;
}
 
int cachePreSum(){
	int ans = 0;
	int cache[1000];
 
	cache[0] = 0;
	for (int i = 1; i <= nums.size(); ++i){
		cache[i] = cache[i - 1] + nums[i - 1];
	}
 
	for (int i = 0; i < nums.size(); ++i){
		int sum = 0;
		for (int j = i; j < nums.size(); ++j){
			sum = cache[j + 1] - cache[i];
			ans = max(ans, sum);
		}
	}
 
	return ans;
}
 
int DivideConquer(int l, int r){
	//沒有元素是反回0
	if (l > r){
		return 0;
	}
	//只有一個元素,反回和0比較大的哪個
	if (l == r){
		return max(0, nums[l]);
	}
 
	int m = (l + r) / 2;
 
	int sum = 0;
	int leftMax = sum = 0;
	//計算左邊的最大欄位和
	for (int i = m; i >= 0; i--){
		sum += nums[i];
		leftMax = max(leftMax, sum);
	}
 
	//計算右邊的最大欄位和
	int rightMax = sum = 0;
	for (int i = m + 1; i <= r; ++i){
		sum += nums[i];
		rightMax = max(sum, rightMax);
	}
 
	//遞迴計算最大部分
	return max(max(leftMax + rightMax, DivideConquer(l, m)), DivideConquer(m + 1, r));
}


int DivideConquerWithCatch(int l, int r){
	//沒有元素是反回0
	if (l > r){
		return 0;
	}
	//只有一個元素,反回和0比較大的哪個
	if (l == r){
		return max(0, nums[l]);
	}

	if (cache5[l][r] != INT_MAX){
		return cache5[l][r];
	}
 
	int m = (l + r) / 2;
 
	int sum = 0;
	int leftMax = sum = 0;
	//計算左邊的最大欄位和
	for (int i = m; i >= 0; i--){
		sum += nums[i];
		leftMax = max(leftMax, sum);
	}
 
	//計算右邊的最大欄位和
	int rightMax = sum = 0;
	for (int i = m + 1; i <= r; ++i){
		sum += nums[i];
		rightMax = max(sum, rightMax);
	}
 
	//遞迴計算最大部分
	int ans = max(max(leftMax + rightMax, DivideConquer(l, m)), DivideConquer(m + 1, r));
	cache5[l][r] = ans;

	return ans;
}
 
int dp2(){
 
	int ans = 0, sum = 0;
	for (int i = 0; i < nums.size(); ++i){
		sum = max(sum + nums[i], 0);
		ans = max(ans, sum);
	}
 
	return ans;
}
 
int solve6(){
	if (nums.size() == 0) {
		return 0;
	}
 
	int ans = 0x80000000;
    int preMin = 0, curSum = 0, preSum = 0;
    for (int i = 0; i < nums.size(); ++i){
        curSum += nums[i];
        ans = max(ans, curSum - preMin);
        preSum += nums[i];
        preMin = min(preMin, preSum);
    }
 
    return ans;
}

 

相關文章