53. 最大子序和(劍指 Offer 42)
知識點:陣列;字首和;哨兵;動態規劃;貪心;分治;
題目描述
輸入一個整型陣列,陣列中的一個或連續多個整陣列成一個子陣列。求所有子陣列的和的最大值。
要求時間複雜度為O(n)。
示例
輸入: nums = [-2,1,-3,4,-1,2,1,-5,4]
輸出: 6
解釋: 連續子陣列 [4,-1,2,1] 的和最大,為 6。
解法一:字首和+哨兵
連續子陣列 --> 字首和
從前往後遍歷求字首和,維持兩個變數,一個是最大子陣列和,也就是答案,一個是最小的字首和,我們可以把這個值理解為哨兵,這個就是我們用來獲取答案的,因為每次字首和-這個最小的肯定就是最大的。
class Solution {
public int maxSubArray(int[] nums) {
int pre = 0; //字首和;
int minPre = 0; //最小的字首和:哨兵;
int maxSum = Integer.MIN_VALUE;
for(int i = 0; i < nums.length; i++){
pre += nums[i];
maxSum = Math.max(maxSum, pre-minPre);
minPre = Math.min(pre, minPre);
}
return maxSum;
}
}
解法二:貪心
這道題貪心怎麼解?貪什麼呢?想一下在這個過程中,比如-2 1,我們需要-2嗎?不需要!因為負數只會拉低我們最後的和,只起副作用的索性不如不要了。直接從1開始就行了; 貪的就是負數和一定會拉低結果。
所以我們的貪心選擇策略就是:只選擇和>0的,對於和<=0的都可以捨棄了。
class Solution {
public int maxSubArray(int[] nums) {
int maxSum = Integer.MIN_VALUE;
int sum = 0;
for(int i = 0; i < nums.length; i++){
sum += nums[i];
maxSum = Math.max(sum, maxSum);
if(sum <= 0){
sum = 0; //對於<=0的字首和,已經沒要意義了,從下一位置開始;
continue;
}
}
return maxSum;
}
}
解法三:分治
這道題可以用分治去解。期望去求解一個區間[l,r]內的最大子序和,按照分而治之的思想,可以將其分為左區間和右區間。
左區間L:[l, mid]和右區間R:[mid + 1, r].
lSum 表示 [l,r] 內以 l 為左端點的最大子段和
rSum 表示 [l,r] 內以 r 為右端點的最大子段和
mSum 表示 [l,r] 內的最大子段和
iSum 表示 [l,r] 的區間和
遞迴地求解出L.mSum以及R.mSum之後求解M.mSum。因此首先在分治的遞迴過程中需要維護區間最大連續子列和mSum這個資訊。
接下來分析如何維護M.mSum。具體來說有3種可能:
- M上的最大連續子列和序列完全在L中,即M.mSum = L.mSum
- M上的最大連續子列和序列完全在R中,即M.mSum = R.mSum
- M上的最大連續子列和序列橫跨L和R,則該序列一定是從L中的某一位置開始延續到mid(L的右邊界),然後從mid + 1(R的左邊界)開始延續到R中的某一位置。因此我們還需要維護區間左邊界開始的最大連續子列和leftSum以及區間右邊界結束的最大連續子列和rightSum資訊
class Solution {
public class Status{
public int lSum, rSum, mSum, iSum;
// lSum 表示 [l,r] 內以 l 為左端點的最大子段和
// rSum 表示 [l,r] 內以 r 為右端點的最大子段和
// mSum 表示 [l,r] 內的最大子段和
// iSum 表示 [l,r] 的區間和
public Status(int lSum, int rSum, int mSum, int iSum){
this.lSum = lSum;
this.rSum = rSum;
this.mSum = mSum;
this.iSum = iSum;
}
}
public Status getInfo(int[] a, int l, int r){
if(l == r) return new Status(a[l], a[l], a[l], a[l]); //終止條件;
int mid = l + ((r-l) >> 1);
Status lsub = getInfo(a, l, mid);
Status rsub = getInfo(a, mid+1, r);
return pushUp(lsub, rsub);
}
//根據兩個子串得到整個序列結果;
public Status pushUp(Status l, Status r){
int iSum = l.iSum + r.iSum;
int lSum = Math.max(l.lSum, l.iSum+r.lSum);
int rSum = Math.max(r.rSum, r.iSum+l.rSum);
int mSum = Math.max(Math.max(l.mSum, r.mSum), l.rSum+r.lSum);
return new Status(lSum, rSum, mSum, iSum);
}
public int maxSubArray(int[] nums) {
return getInfo(nums, 0, nums.length-1).mSum;
}
}
解法四:動態規劃
- 1.確定dp陣列和其下標的含義:dp[i]表示以i結尾的連續子陣列的最大和;
- 2.確定遞推公式,即狀態轉移方程:以i結尾想一下我們有幾種可能,一種是i-1過來的,也就是上一個的連續子陣列延續到i處了,那和就為dp[i-1]+nums[i],另一種呢,就是自己開始,前面那個連續子陣列不行,那就是nums[i]了,想一下為什麼前面那個不行,還不是前面的和會拖累自己,那就意味著前面的和是負數;這其實就引出貪心的方法了。不過我們這裡不用這麼麻煩,直接用一個max函式,取兩者大的那個就行;
- 3.dp初始化base case:dp[0]只有一個數,所以dp[0] = nums[0];
class Solution {
public int maxSubArray(int[] nums) {
int len = nums.length;
int[] dp = new int[len]; //以i結尾的連續子陣列的最大和為dp[i];
if(nums == null || len <= 1) return nums[0];
dp[0] = nums[0];
for(int i = 1; i < len; i++){
dp[i] = Math.max(dp[i-1]+nums[i], nums[i]); //狀態轉移;
}
//注意我們要遍歷一遍返回最大的dp;
int maxSum = dp[0];
for(int i = 1; i < len; i++){
maxSum = Math.max(maxSum, dp[i]);
}
return maxSum;
}
}
當然上述程式可以優化,因為我們的dp[i]其實只和前一狀態i-1有關,所以可以採用一個滾動變數來記錄,而不用整個陣列。
class Solution {
public int maxSubArray(int[] nums) {
int pre = 0; //記錄前一狀態;
int res = nums[0]; //記錄最後結果的最大值;
for (int num : nums) {
pre = Math.max(pre + num, num);
res = Math.max(res, pre);
}
return res;
}
}
體會
這道題目是一道很典型的題目,用到了各種方法和思想。要常看常做,分治是其中比較困難的,但是要會這種思想。這道題目最好的方法還是哨兵和動態規劃, 其實貪心就是從動態規劃的一個特殊情況過去的,體會兩者的關係;