这是一个经典算法设计题目,虽然很早就见过这个问题,理解过原理,但一直没有实际实现过,一个算法在实际实现的过程中总是会遇到一些没有实现的时候想不到的问题。在这里,通过三种不同的方式的实现,来体会算法设计的思想和降低程序运行实际的巧妙之处。
题目回顾
输入:一个n个元素的数组,数组元素有正数和负数
输出:一个或多个连续元素组成的子数组,这个子数组的元素之和是原数组所有子数组中最大的。
变换:假设有一个N天的股票数据,从这个股票数据数组中找出一段连续时期,使得这段时期的第一天到最后一天内股票价格变化的净值上升最大。可以将每天的价格与前一天做差,然后就变换为了上述最大子数组问题。
下面分别使用三种方法实现,每种方法都比前一种方法更高效。
直接求解
根据问题的描述,很容易可以求解:通过两层循环,对每个可能的连续子数组的和进行比较,找出最大的即可。
vector<int> maxSubArray(vector<int>& a)
{
int sum = 0;
vector<int> res(3, 0);
res[2] = INT_MIN;
for (int i = 0; i < a.size(); i++)
{
for (int j = 0; j < a.size(); j++)
{
sum = 0;
for (int k = i; k <= j; k++) sum += a[k];
if (sum > res[2])
{
res[0] = i;
res[1] = j;
res[2] = sum;
}
}
}
return res;
}
这里返回值为一个vector,第一个元素为子数组的起始下标,第二个元素为子数组的结尾下标,第三个元素为子数组的元素和。上述实现过程中虽然使用了第三层循环,但是第三层循环仅仅是为了求子数组元素和,与前两个循环计数器是依赖的,这个在实现过程中很容易造成误导,上述算法的实际运行时间为O(n^2)。
分治法
使用二分方法,最大子数组在左半数组;在右半数组;跨越中点的数组。这中分治方法与平面上的最近点对的分治求解方法类似,最后的子问题分解到单个元素组成的数组,可以直接求解。因此,关键问题就是求解跨越中点元素的最大子数组即可。具体实现如下:
vector<int> crossSubArray(vector<int> & a, int l, int m, int r)
{
int leftSum = INT_MIN, rightSum = INT_MIN, sum = 0;
int leftPos = 0, rightPos = 0;
for (int i = m; i >= l; i--)
{
sum += a[i];
if (sum > leftSum){
leftSum = sum;
leftPos = i;
}
}
sum = 0;
for (int i = m + 1; i < r; i++)
{
sum += a[i];
if (sum > rightSum){
rightSum = sum;
rightPos = i;
}
}
vector<int> tmp;
tmp.push_back(leftPos);
tmp.push_back(rightPos);
tmp.push_back(leftSum + rightSum);
return tmp;
}
vector<int> maxSubArray(vector<int>& a, int left, int right)
{
if (left == right - 1)
{
vector<int> res;
res.push_back(left);
res.push_back(right);
res.push_back(a[left]);
return res;
}
else
{
vector<int> leftRes, rightRes, crossRes;
int mid = (left + right) / 2;
leftRes = maxSubArray(a, left, mid);
rightRes = maxSubArray(a, mid, right);
crossRes = crossSubArray(a, left, mid-1, right);
if (leftRes[2] >= rightRes[2] && leftRes[2] >= crossRes[2]) return leftRes;
if (rightRes[2] >= leftRes[2] && rightRes[2] >= crossRes[2]) return rightRes;
if (crossRes[2] >= rightRes[2] && crossRes[2] >= leftRes[2]) return crossRes;
}
}
实现中遇到的问题主要是对于右边界的取舍。按照通常的习惯,右边界一般取开区间,左边界去闭区间。但是,对于分治的递归算法中,使用两边都为闭区间比较好,在归并排序、快速排序算法中都类似。上述实现中特意使用了右侧开区间的方式,主要边界点在crossSubArray函数中的“for (int i = m + 1; i < r; i++)”,以及maxSubArray中的“if (left == right - 1)”和“crossRes = crossSubArray(a, left, mid-1, right);”,以及递归调用时都传入mid,第一个表示mid为开区间不包括mid,第二个表示为闭区间。right都为开区间,调用方式为:
vector<int> res = maxSubArray(arr, 0, arr.size());
动态规划
最大子数组是可以分解求解的,而且最优解包含了子问题的解,因此存在最优子结构。这个方法在编程珠玑一书中有提到,具体可以使用如下的最优子结构表达式:
sum[0] = max{a[0], 0}
sum[i] = max{sum[i-1]+a[i] , 0}
具体实现的过程中遇到了新的问题就是如何动态移动子数组的左右边界,具体实现如下:
vector<int> maxSubArray(vector<int>& a)
{
vector<int> res(3, 0);
res[2] = a[0];
int left = 0, right = 0, tmp_max = a[0];
for (int i = 1; i < a.size(); i++)
{
if(tmp_max <= 0)
{
tmp_max = a[i];
left = i;
}
else
{
tmp_max += a[i];
right = i;
}
if (res[2] < tmp_max)
{
res[0] = left;
res[1] = right;
res[2] = tmp_max;
}
}
return res;
}
实现要点就是分别保存临时的左右边界点和临时的“最大和”,每一次迭代与实际的最大和比较,如果增大了就更新实际的边界点和最大和。
通过上述三个方法的实现,不仅在实际实现过程中解决了遇到的问题,同时对算法设计的巧妙之处不禁感叹~_~