今天做秘书,围观老师面试虐学弟。
问题描述:
输入一组整数,求出这组数字子序列和中最大值。也就是只要求出最大子序列的和,不必求出最大的那个序列。例如:
序列:-2 11 -4 13 -5 -2,则最大子序列和为20。
序列:-6 2 4 -7 5 3 2 -1 6 -9 10 -2,则最大子序列和为16。
算法一:
//穷举法,复杂度O(n^3)
long maxSubSum1(const vector<int>& a)
{
long maxSum = 0;
for (int i = 0; i < a.size(); i++)
{
for (int j = i; j < a.size(); j++)
{
long thisSum = 0;
for (int k = i; k <= j; k++)
{
thisSum += a[k];
}
if (thisSum > maxSum)
maxSum = thisSum;
}
}
return maxSum;
}
这是一个O(N^3) 的算法,算法本身很容易理解,而且很直观的感觉做了很多无用操作。例如:i = 0, j = 3时,会计算a[0] + a[1] +…+ a[3];而当i = 0, j = 4时候又会计算a[0] + a[1] +…a[4]。
算法二:
通过撤出一个for循环来避免三次时间。
//也是穷举法,不过减去了上面的一些不必要操作O(n^2)
long maxSubSum2(const vector<int>& a)
{
long maxSum = 0;
for (int i = 0; i < a.size(); i++)
{
long thisSum = 0;
for (int j = i; j < a.size(); j++)
{
thisSum += a[j];
if (thisSum > maxSum)
maxSum = thisSum;
}
}
return maxSum;
}
这是一个非常直观的穷举法(比上面的分析还有简单些),而且没有多余重复的操作,复杂度为O(N^2) 。其中,thisSum表示a[i] + a[i+1] + … + a[j-1]。
算法三:
对这个问题,有一个相对复杂的O(NlogN)的解法,就是使用递归。如果要是求出序列的位置的话,这将是最好的算法了(因为我们后面还会有个O(N)的算法,但是不能求出最大子序列的位置)。该方法我们采用“分治策略”(divide-and-conquer)。
在我们例子中,最大子序列可能在三个地方出现,或者在左半部,或者在右半部,或者跨越输入数据的中部而占据左右两部分。前两种情况递归求解,第三种情况的最大和可以通过求出前半部分最大和(包含前半部分最后一个元素)以及后半部分最大和(包含后半部分的第一个元素)相加而得到。
//递归法,复杂度是O(nlogn)
long maxSumRec(const vector<int>& a, int left, int right)
{
if (left == right)
{
if (a[left] > 0)
return a[left];
else
return 0;
}
int center = (left + right) / 2;
long maxLeftSum = maxSumRec(a, left, center);
long maxRightSum = maxSumRec(a, center+1, right);
//求出以左边对后一个数字结尾的序列最大值
long maxLeftBorderSum = 0, leftBorderSum = 0;
for (int i = center; i >= left; i--)
{
leftBorderSum += a[i];
if (leftBorderSum > maxLeftBorderSum)
maxLeftBorderSum = leftBorderSum;
}
//求出以右边对后一个数字结尾的序列最大值
long maxRightBorderSum = 0, rightBorderSum = 0;
for (int j = center+1; j <= right; j++)
{
rightBorderSum += a[j];
if (rightBorderSum > maxRightBorderSum)
maxRightBorderSum = rightBorderSum;
}
return max3(maxLeftSum, maxRightSum,
maxLeftBorderSum + maxRightBorderSum);
}
long maxSubSum3(const vector<int>& a)
{
return maxSumRec(a, 0, a.size()-1);
}
另外max3(long,long,long)表示求三个long中的最大值:
//求出三个long中的最大值
long max3(long a, long b, long c)
{
if (a < b)
{
a = b;
}
if (a > c)
return a;
else
return c;
}
对这个算法进行分析:
T(1) = 1
T(N) = 2T(N/2) + O(N)
最后得出算法的复杂度为:O(NlogN) 。
算法四:
下面介绍一个线性的算法,这个算法是许多聪明算法的典型:运行时间是明显的,但是正确性则很不明显(不容易理解)。
//线性的算法O(N)
long maxSubSum4(const vector<int>& a)
{
long maxSum = 0, thisSum = 0;
for (int j = 0; j < a.size(); j++)
{
thisSum += a[j];
if (thisSum > maxSum)
maxSum = thisSum;
else if (thisSum < 0)
thisSum = 0;
}
return maxSum;
}
很容易理解时间界O(N) 是正确的,但是要是弄明白为什么正确就比较费力了。其实这个是算法二的一个改进。分析的时候也是i代表当前序列的起点,j代表当前序列的终点。如果我们不需要知道最佳子序列的位置,那么i就可以优化掉。
重点的一个思想是:如果a[i]是负数那么它不可能代表最有序列的起点,因为任何包含a[i]的作为起点的子序列都可以通过用a[i+1]作为起点来改进。类似的有,任何的负的子序列不可能是最优子序列的前缀。例如说,循环中我们检测到从a[i]到a[j]的子序列是负数,那么我们就可以推进i。关键的结论是我们不仅可以把i推进到i+1,而且我们实际可以把它一直推进到j+1。
举例来说,令p是i+1到j之间的任何一个下标,由于前面假设了a[i]+…+a[j]是负数,则开始于下标p的任意子序列都不会大于在下标i并且包含从a[i]到a[p-1]的子序列对应的子序列(j是使得从下标i开始成为负数的第一个下标)。因此,把i推进到j+1是安全的,不会错过最优解。注意的是:虽然,如果有以a[j]结尾的某序列和是负数就表明了这个序列中的任何一个数不可能是与a[j]后面的数形成的最大子序列的开头,但是并不表明a[j]前面的某个序列就不是最大序列,也就是说不能确定最大子序列在a[j]前还是a[j]后,即最大子序列位置不能求出。但是能确保maxSum的值是当前最大的子序列和。这个算法还有一个有点就是,它只对数据进行一次扫描,一旦a[j]被读入处理就不需要再记忆。它是一个联机算法。
联机算法:在任意时刻算法都能够对它已读入的数据给出当前数据的解。
常量空间线性时间的联机算法几乎是完美的算法。
附录:
程序测试:
先通过文件读写函数产生一组随机数并且读入到一个vector<int>中:
//COUNT和MAX_NUM分别表示随机数个数和最大值
const long COUNT = 1000;
const int MAX_NUM = 200;
//读文件
bool readFile(vector<int>& input, string fileName)
{
ifstream infile(fileName.c_str());
if (!infile)
return false;
int s;
while(infile>>s)
{
input.push_back(s);
}
return true;
}
//写大量随机测试数据
bool writeTestData(string fileName)
{
ofstream outfile(fileName.c_str());
if (!outfile)
return false;
srand((unsigned)time(NULL));
for (int i = 0; i < COUNT; i++)
{
if (rand() % 2 == 0)
outfile << rand() % MAX_NUM << '\n';
else
outfile << ~(rand() % MAX_NUM) << '\n';
}
return true;
}
测试可得:
当COUNT = 1000的时候maxSubSum1()要等10s,后三个很快。
当COUNT = 10000的时候maxSubSum2()要等8s,后两个很快。
当COUNT = 1000000的时候maxSubSum3()要等10s,maxSubSum4()要等4s。
其实当COUNT = 1000000这个时候但是作文件读写就要很耗时了,光是in.txt就达到了4.7MB了。
而COUNT = 10000000的时候光是文件读写就要半分钟,in.txt达到了47.2MB,这时候再做maxSubSum3()和maxSubSum4()的比较,maxSubSum4()需要56s,而maxSubSum3()这时候需要85s(包括了读文件的时间)。可见数据量比较大的情况下O(NlogN)的递归算法也是可行的,并不比O(N)低很多。尤其在要求出最大子序列位置的情况下,分治递归算法体现了强大的威力。
程序源码:
#include <iostream>
#include <string>
#include <vector>
#include <fstream>
#include <cstdlib>
#include <ctime>
using namespace std;
//COUNT和MAX_NUM分别表示随机数个数和最大值
const long COUNT = 10000;
const int MAX_NUM = 200;
//读文件
bool readFile(vector<int>& input, string fileName)
{
ifstream infile(fileName.c_str());
if (!infile)
return false;
int s;
while(infile>>s)
{
input.push_back(s);
}
return true;
}
//写大量随机测试数据
bool writeTestData(string fileName)
{
ofstream outfile(fileName.c_str());
if (!outfile)
return false;
srand((unsigned)time(NULL));
for (int i = 0; i < COUNT; i++)
{
if (rand() % 2 == 0)
outfile << rand() % MAX_NUM << '\n';
else
outfile << ~(rand() % MAX_NUM) << '\n';
}
return true;
}
//穷举法
long maxSubSum1(const vector<int>& a)
{
long maxSum = 0;
for (int i = 0; i < a.size(); i++)
{
for (int j = i; j < a.size(); j++)
{
long thisSum = 0;
for (int k = i; k <= j; k++)
{
thisSum += a[k];
}
if (thisSum > maxSum)
maxSum = thisSum;
}
}
return maxSum;
}
//也是穷举法,不过减去了上面的一些不必要操作O(n^2)
long maxSubSum2(const vector<int>& a)
{
long maxSum = 0;
for (int i = 0; i < a.size(); i++)
{
long thisSum = 0;
for (int j = i; j < a.size(); j++)
{
thisSum += a[j];
if (thisSum > maxSum)
maxSum = thisSum;
}
}
return maxSum;
}
//递归法,复杂度是O(nlogn)
long max3(long a, long b, long c)
{
if (a < b)
{
a = b;
}
if (a > c)
return a;
else
return c;
}
long maxSumRec(const vector<int>& a, int left, int right)
{
if (left == right)
{
if (a[left] > 0)
return a[left];
else
return 0;
}
int center = (left + right) / 2;
long maxLeftSum = maxSumRec(a, left, center);
long maxRightSum = maxSumRec(a, center+1, right);
//求出以左边对后一个数字结尾的序列最大值
long maxLeftBorderSum = 0, leftBorderSum = 0;
for (int i = center; i >= left; i--)
{
leftBorderSum += a[i];
if (leftBorderSum > maxLeftBorderSum)
maxLeftBorderSum = leftBorderSum;
}
//求出以右边对后一个数字结尾的序列最大值
long maxRightBorderSum = 0, rightBorderSum = 0;
for (int j = center+1; j <= right; j++)
{
rightBorderSum += a[j];
if (rightBorderSum > maxRightBorderSum)
maxRightBorderSum = rightBorderSum;
}
return max3(maxLeftSum, maxRightSum,
maxLeftBorderSum + maxRightBorderSum);
}
long maxSubSum3(const vector<int>& a)
{
return maxSumRec(a, 0, a.size()-1);
}
//线性的算法O(N)
long maxSubSum4(const vector<int>& a)
{
long maxSum = 0, thisSum = 0;
for (int j = 0; j < a.size(); j++)
{
thisSum += a[j];
if (thisSum > maxSum)
maxSum = thisSum;
else if (thisSum < 0)
thisSum = 0;
}
return maxSum;
}
int main ()
{
vector<int> input;
/**
if (!writeTestData("in.txt"))
{
cout << "写文件错误" << endl;
}
*/
if (readFile(input, "in.txt"))
{
//cout << maxSubSum1(input) << endl;
//cout << maxSubSum2(input) << endl;
cout << maxSubSum3(input) << endl;
cout << maxSubSum4(input) << endl;
}
return 0;
}
闲话少叙,直接上题。
(1) 最大子数组和
这个问题已经是toooooooooold了。原问题是:给定一个数组,求一个子数组(连续的一段),它们的和最大。一些细节就是是否允许啥都不选,不过这个无关紧要。其实不少书都把它作为动态规划的题目,我个人倒不太喜欢真的把它作为dp,之所以这么说,我觉得作为dp反倒禁锢了人的思路,有点大材小用的感觉,并且真正的dp比这个复杂得多。这个题解法相当多,扔掉暴力的,也有很多理解。
(1.1)我自己yy的,其实和(1.2)是一样的。连续的一段数我们可以把开头和结尾的负数扔掉,然后非负数连续的加在一起,负数加在一起,形成这样的“堆”:
正 负 正 负 正 负……
之所以这样是因为我们同一堆里 要么就都选 要么就都不选,所以可以作为一个数处理。 (这是因为正数堆要选肯定都选了,负数堆要选肯定是为了“连接”到下一个正数上,否则选负数干嘛?)于是我们就一个一个看,先选第一个正的,如果加上负数还大于0,我们就继续选,也就是说只要“前缀”是正的,我们就可以继续选数,前缀是负数的话,我们扔了重选。 其实(1.2)就是这个思路。
(1.2) 经典解法 end[i]表示以a[i]结尾子数组的和,则end[i + 1] = max(end[i] + a[i + 1], a[i + 1]), 其实这一步等价于end[i] >= 0 就取 end[i] + a[i + 1],否则就取a[i + 1],最优解肯定在某个地方结束,所以最终max{end[0..n - 1]}为所求。习惯上把这种东西叫做动态规划……叫什么都好,反正这个确实满足最优子问题,即end[i + 1]是由end[i]推出来的,但是只会这一种解法会影响理解后面的问题。
(1.3) 我个人非常喜欢的解法。因为体现了前缀和的思想。我们考虑prefix[i] = a[0] + a[1] + ...+a[i],即prefix[i]是一个前缀和,那么任何一个子数组就是两个前缀的差了。细节是要定义prefix[-1] = 0。 那么子数组a[i..j] = prefix[j] - prefix[i - 1]。那么我们如何求prefix[i]?循环i即可,prefix[i] = prefix[i - 1] + a[i]。那么如何求(1.1)中的end[i]?要以a[i]结尾最大的,那么我们只要用prefix[i] - prefix[j]就好了,而prefix[j]就是prefix[0..i]的最小值就可以了。所以我们循环i,每次用prefix[i]减去出现过的最小值就可以了……这是一个自然的思路,并且这个思路应用很广泛……感觉虽然(1.2)是动态规划,但是我们不能过于重视,而忽视了(1.3)的存在。
(1.4) 只有理论意义……因为是个O(nlogn)的方法。分治,从中间劈开数组,那么最大子数组要么在左半边,要么在右半边,这两部分递归解决。关键是最大子数组跨越中间点的情况。那么如果我们要求跨越中间点的最大和,我们就简单的从中间点往左扫一遍,看看连续加到哪里最大,同样往右边扫一边看看连续加到哪里最大就好了。这是因为我们确定了它一定要跨过中间点,所以有了一个“着力点”。主要是这部分的复杂度是O(n)的,所以整个递归分治算法的复杂度是O(nlogn)的。
顺便提一句,最优的算法(1.1)-(1.3)都是O(n)的。
(2) 允许交换一次的最大子数组和
比(1)难很多,就是允许交换两个元素,只交换一次,当然可以不交换,仍然是求最大子数组和。如果暴力枚举交换的两个元素,再求最大子数组和会到O(n^3)。尝试过先求普通最大子数组和,再贪心怎么换掉元素之类的,都能找到反例。这个题其实有O(n)的算法,是个经典的dp,如果没理解(1.2)的话或者(1.2)靠死记硬背的话,无法解决这个问题。这是codility的challenge
http://blog.youkuaiyun.com/caopengcs/article/details/36899787
(3) 绝对值最小的子数组的和
就是求一个子数组,和的绝对值最小。
这是codility的练习……,这个无法线性解决。而且死记硬背(1)的话也没用,也不能套用。这个需要用(1.3)的思路,子数组是两个前缀的差,那么我们对每个前缀找到之前的最接近它的前缀就可以了。“最接近”包括比它小的最大的,和比它大的最小的,这个用一个set就可以解决了。stl有著名的lower_bound函数,就是logn时间完成这个事,不然就要自己写二叉树了……
上个代码:
- // you can write to stdout for debugging purposes, e.g.
- // cout << "this is a debug message" << endl;
- #include <set>
- int solution(vector<int> &A) {
- // write your code in C++11
- set<int> have;
- have.insert(0);
- int sum = 0, r = 2000000000;
- for (int i = 0;r && (i < A.size()); ++i) {
- set<int>::iterator t = have.lower_bound(sum += A[i]);
- if (t != have.end()) {
- r = min(r, *t - sum);
- }
- if (t != have.begin()) {
- r = min(r, sum - *(--t));
- }
- have.insert(sum);
- }
- return r;
- }