注:本文为《算法导论》中相关内容的笔记。对此感兴趣的读者还望支持原作者。
问题概述
假如你有一天突发奇想,准备投资股票大捞一笔,从而别墅靠海,走向人生巅峰。经过深思熟虑,你最终选定一家公司的股票。你被准许可以在某个时刻买进一股该公司的股票,并在之后某个日期将其卖出,买进卖出都是当天交易结束后进行。为了补偿这一限制,你可以了解股票将来的价格(是不是感到很兴奋啊!)。股票未来一段时间内的价格如图所示。
你可能直观地认为,直接在最低价格时买入股票,在最高价格时卖出股票,即可最大化收益。然后由于时序限制,我们可能根本无法满足此条件。例如,图中第1天股票价格最高,第7天股票价格最低,然而我们不可能在第7天买入股票,接着在第一天卖出,谁让我们没有时光机呢。因此,如何使得利润最大化成为了我们需要解决的难题。
解决思路
不妨转换一下思路,我们的目标是寻找一段日期,使得第一天到最后一天的股票价格差值和最大。因此,我们不再从每日价格的角度去看待数据,而是考察每日价格变化,第i天的价格变化定义为第 i i i天和第 i − 1 i-1 i−1天的价格差。因此,如果我们将这一段时间的股票价格的价格差看作一个数组 A A A,那么问题就转化为寻找 A A A中和最大的非空连续子数组。我们称这一连续子数组为最大子数组。例如, A A A数组具体如下所示。
则 A [ 8 … 11 ] A[8\ldots 11] A[8…11]这一连续数组就是我们寻求的最大子数组。当你在第8天买入股票,并在第11天卖出股票,则每股净赚43元,岂不美哉!
分治法
那么又该如何求解最大子数组问题呢?答案就是分治法。关于分治法,博主在之前的博客介绍过,这里不再赘述。使用分治法意味着我们要将数组递归地划分为两个规模尽量相等的子数组。假设我们现在需要寻找数组 A [ l o w … h i g h ] A[low\ldots high] A[low…high]的最大子数组。则根据分治法,我们首先找到子数组的中央位置 m i d mid mid,然后考虑求解两个子数组 A [ l o w … m i d ] A[low\ldots mid] A[low…mid]和 A [ m i d + 1 … h i g h ] A[mid+1\ldots high] A[mid+1…high]。如此,数组 A [ l o w … h i g h ] A[low\ldots high] A[low…high]的最大子数组所处位置无外乎以下三种情况:
- 完全位于子数组 A [ l o w … m i d ] A[low\ldots mid] A[low…mid]中,因此 l o w ≤ i ≤ j ≤ m i d low\leq i\leq j \leq mid low≤i≤j≤mid;
- 完全位于子数组 A [ m i d + 1 … h i g h ] A[mid+1\ldots high] A[mid+1…high]中,因此 m i d < i ≤ j ≤ h i g h mid< i\leq j \leq high mid<i≤j≤high;
- 跨越了中间点,因此 l o w ≤ i ≤ m i d ≤ j ≤ h i g h low\leq i\leq mid\leq j\leq high low≤i≤mid≤j≤high.
我们可以递归地求解 A [ l o w … m i d ] A[low\ldots mid] A[low…mid]和 A [ m i d + 1 … h i g h ] A[mid+1\ldots high] A[mid+1…high],因为这两个子问题仍然是最大子数组问题,只是规模较小。因此,剩下的工作就是寻找跨越中点的最大子数组,然后在三种情况中选取和最大者。
而我们可以很容易地在线性时间内求出跨越中点的最大子数组。因为它加入了额外限制——求出的最大子数组必须跨越中点。因此,我们只需找出形如 A [ i … m i d ] A[i\ldots mid] A[i…mid]和 A [ m i d + 1 … j ] A[mid+1\ldots j] A[mid+1…j]的最大子数组,然后将其合并即可。
算法改进
然而,分治法就是求解最大子数组问题的最简单方法吗?NO!我们完全可以在线性时间内找到最大子数组,让你一夜暴富!如果我们已知 A [ 1 … j ] A[1 \ldots j] A[1…j]的最大子数组,那么 A [ 1 … j + 1 ] A[1 \ldots j+1] A[1…j+1]的最大子数组无非是以下两种情况:
- A [ 1 … j ] A[1 \ldots j] A[1…j]的最大子数组;
- 某个子数组 A [ i … j + 1 ] ( 1 ≤ i ≤ j + 1 ) A[i \ldots j+1](1 \le i \le j+1) A[i…j+1](1≤i≤j+1)。
在已知 A [ 1 … j ] A[1 \ldots j] A[1…j]的最大子数组的情况下,可以在线性时间内找出形如 A [ i … j + 1 ] A[i \ldots j+1] A[i…j+1]的最大子数组。因此,我们完全可以在线性时间内找出最大子数组。为了方便大家理解,博主给出了伪代码以供参考。
代码示例
千言万语,不如代码一段,废话少说,直接上代码。下面给出了寻求最大子数组的Java实现版本。
import java.util.Random;
import java.lang.Integer;
/**
* 寻找最大子数组(两种方法)
* @author 爱学习的程序员
* @version V1.0
*/
public class MaxSubArray{
// 内部类,用于存储寻找到的最大子数组信息
static class SubArray{
// 子数组下界
int left = 0;
// 子数组上界
int right = 0;
// 子数组元素之和
int sum = 0;
public SubArray(int left, int right, int sum){
this.left = left;
this.right = right;
this.sum = sum;
}
}
/**
* 寻找跨越中间点的最大子数组
* @param arr 数组
* @param low 数组的下界
* @param mid 数组的中间点
* @param high 数组的上界
* @return 跨越中间点最大子数组的上界、下界与元素的和构成的三元组(以SubArray类的对象的形式)
*/
public static SubArray getCrossSubArray(int[]arr, int low, int mid, int high){
// leftSum存储左边最大子数组元素之和, sum存储左边数组元素之和, left存储左边最大子数组下界
int leftSum = Integer.MIN_VALUE, sum = 0, left = 0;
int i = 0;
for(i = mid; i >= low; i--){
sum += arr[i];
// 更新
if(sum > leftSum){
leftSum = sum;
left = i;
}
}
sum = 0;
// rightSum存储右边最大子数组元素之和, right存储左边最大子数组下界
int rightSum = Integer.MIN_VALUE, right = 0;
for(i = mid + 1; i <= high; i++){
sum += arr[i];
// 更新
if(sum > rightSum){
rightSum = sum;
right = i;
}
}
return new SubArray(left, right, leftSum + rightSum);
}
/**
* 分治法寻找最大子数组
* @param arr 原数组
* @param low 数组的下界
* @param high 数组的下界
* @return 最大子数组的上界、下界与元素的和构成的三元组(以SubArray类的对象的形式)
*/
public static SubArray getMaxSubArray1(int[] arr, int low, int high){
// 子数组只有一个元素
if(low == high){
//SubArray oneElement = new SubArray(low, high, arr[low]);
return new SubArray(low, high, arr[low]);
}
else{
int mid = (low + high) / 2;
// 寻找左边最大子数组
SubArray left = getMaxSubArray1(arr, low, mid);
// 寻找右边最大子数组
SubArray right = getMaxSubArray1(arr, mid+1, high);
// 寻找跨越中间点的最大子数组
SubArray cross = getCrossSubArray(arr, low, mid, high);
// 确定三种情况中,何种情况的最大子数组最大
if(left.sum >= right.sum && left.sum >= cross.sum)
return left;
else if(right.sum >= left.sum && right.sum >= cross.sum)
return right;
else
return cross;
}
}
/**
* 线性时间内寻找最大子数组
* @param arr 原数组
* @return 最大子数组的上界、下界与元素的和构成的三元组(以SubArray类的对象的形式)
*/
public static SubArray getMaxSubArray2(int[] arr){
// 最大子数组的三元组
SubArray subArray = new SubArray(-1, -1, Integer.MIN_VALUE);
// 遍历时的元素和,元素下标
int sum = 0;
int index = 0;
for(int i = 0; i < arr.length; i++){
sum += arr[i];
// 更新最大子数组
if(sum > subArray.sum){
subArray.sum = sum;
subArray.left = index;
subArray.right = i;
}
// 重新遍历
if(sum < 0){
sum = 0;
index = i + 1;
}
}
return subArray;
}
public static void main(String[] args){
//测试数组生成
int[] arr = new int[10];
Random rand = new Random();
System.out.println("测试数组:");
int i = 0;
int length = arr.length;
for(i = 0; i < length; i++){
arr[i] = rand.nextInt(100) - 50;
System.out.print(arr[i]+" ");
}
// 寻找最大子数组
SubArray maxSubArray = getMaxSubArray1(arr, 0, 9);
System.out.println("\n"+"最大子数组下界:"+maxSubArray.left+"\t"+"最大子数组上界:"+maxSubArray.right+"\t"+
"最大子数组元素之和:"+maxSubArray.sum);
maxSubArray = getMaxSubArray2(arr);
System.out.println("\n"+"最大子数组下界:"+maxSubArray.left+"\t"+"最大子数组上界:"+maxSubArray.right+"\t"+
"最大子数组元素之和:"+maxSubArray.sum);
}
}
总结
至此,我们使用两种方法求解了最大子数组问题。首先,我们遇见了股票价格问题,绞尽脑汁希望能使利润最大化从而别墅靠海,走向人生巅峰,并发现其问题本质,将其转化为最大子数组问题;接着,我们利用分治法的思想将原数组的最大子数组问题不断分割成子数组的最大子数组问题进而求解出最大子数组问题;然后,我们根据最大子数组的特点,在线性时间内求解最大子数组问题。最后,朋友,去搏一搏,单车变摩托吧!