最大子序列和问题(Max subsequence sum problem),简单描述如下:
含有N个整数的数列,找出其中所有子序列的最大和,子序列定义为 连续的一个子集,子序列和为该子序列所有数字之和。如何序列全部都是负数,则定义子序列最大和等于0。
这个问题有很多方法来解决,最简单的就是穷举所有子序列,然后对每个子序列求和,保留最大的那个和,作为结果。这种方法就是效率非常低下,随着序列所含整数个数N的增加,需要的时间会增长的非常快,时间复杂度是O(N的3次方)。下面是该方法的代码:
private static int ExhaustionCalculate(List<int> sequence)
{
int maxSum = 0;
for (int start = 0; start < sequence.Count; ++start)
for (int end = start; end < sequence.Count; ++end)
{
int tempSum = 0;
for (int i = start; i <= end; ++i)
{
tempSum += sequence[i];
}
if (tempSum > maxSum)
{
maxSum = tempSum;
}
}
return maxSum;
}
上面的代码,可以做一点简单的改进,就可以使它的时间复杂度下降一个数量级。上述方法由三个嵌套的for循环构成,第一个for循环代表子序列的起始位置,第二个for循环代表子序列的终止位置,而第三个for循环则是计算当前子序列的序列和,计算方法是从当前子序列的起始位置迭代到序列的终止位置,循环体中负责累加当前值到tempSum中,最后再和保存的maxSum作比较,大于maxSum,则替换maxSum,小于的话,则从头开始计算下一个子序列的和。
那么,很明显,对于子序列 [ i , j ],如果j没有到达序列的终点,那么最内层的for循环接下来要处理的子序列则应该是 [ i, j + 1 ] ,这个子序列的和就等于 子序列 [ i , j ]的和加上元素 [j+1],因此在最内层的for循环中,计算下一个子序列和的时候,不需要从头从i开始重新算起,而是可以直接复用上一个从i开始的子序列的和,改进后的代码如下所示:
private static int ExhaustionCalculateLittleImprovement(List<int> sequence)
{
int maxSum = 0;
for (int start = 0; start < sequence.Count; ++start)
{
int tempSum = 0;
for (int end = start; end < sequence.Count; ++end)
{
tempSum += sequence[end];
if (tempSum > maxSum)
{
maxSum = tempSum;
}
}
}
return maxSum;
}
上面改进后的代码,效率比第一个要好一点,时间复杂度是O(N的2次方)。但是,仍然不是最好的。这个问题,如果用分治法(Divide and Conquer)还可以更快。分治法,一般都由如下三步来组成:
- 分解原问题为一个或者多个相似的子问题
- 递归的解决子问题
- 合并各个子问题的解
那么,对于这个问题,也是这个过程,关键就是怎么来分,怎么来合并了。下面来具体分析,具有最大和的子序列,在整个序列中只可能存在于如下三个位置:
- 序列的左半部分
- 序列的右半部分
- 某个包含序列左半部分最右边那个元素以及序列右半部分最左边那个元素的子序列
所以,我们可以将原问题分解为这样的三个子问题:
- 求原序列左半部分组成的序列的最大子序列和
- 求原序列右半部分组成的序列的最大子序列和
- 求原序列中包含左半部分序列终点以及右半部分起点的序列的最大和
求出了这三个值之后,它们三个中的最大值,就是最终结果。求左右半部分的最大子序列和,比较好说,直接递归调用就可以了。关键在于求包含中间元素的最大子序列和,求解方法如下:
包含中间元素的序列,一定必须包含左半部分的终点(用LeftEnd表示)和右半部分的起点(RightStart表示),这样的序列也有很多,我们要找的是具有最大和的那个,比如用MaxMid来表示,那么这个序列可以看成包含两部分,左边部分在左半部分序列里面,用MaxMidLeft来表示,右边部分在右半部分序列里面,用MaxMidRight来表示,那么MaxMidLeft肯定是终点为LeftEnd的所有子序列中和最大的一个,如果不是的话,假设存在另一个终点为LeftEnd的这样的子序列,其和比MaxMidLeft大,那么用这个子序列来替换MaxMidLeft对应的子序列,我们可以得到一个比MaxMid更大的和,这就矛盾了。对于,右边的MaxMidRight也具有一样的性质。于是,求含有中间元素的最大子序列和的问题,就可以按如下步骤来做:
- 求以LeftEnd为终点的,所有元素在左半部分的子序列的最大和
- 求以RightStart为起点的,所有元素在有半部分的子序列的最大和
- 这两个最大和相加,就是我们要的包含中间元素的最大序列的和
下面是具体代码:
private static int RecursiveCalculate(List<int> sequence)
{
return RecursiveCalculate(sequence, 0, sequence.Count - 1);
}
private static int RecursiveCalculate(List<int> sequence, int start, int end)
{
if (end < start)
{
// empty sub-sequence
return 0;
}
else if (end == start)
{
// only one number
return (sequence[start] > 0) ? sequence[start] : 0;
}
int mid = (start + end) / 2;
int leftMaxSum = RecursiveCalculate(sequence, start, mid);
int rightMaxSum = RecursiveCalculate(sequence, mid + 1, end);
int tempSum = 0;
int leftMidMaxSum = 0;
for (int i = mid; i >= start; --i)
{
tempSum += sequence[i];
if (tempSum > leftMidMaxSum)
{
leftMidMaxSum = tempSum;
}
}
tempSum = 0;
int rightMidMaxSum = 0;
for (int i = mid + 1; i <= end; ++i)
{
tempSum += sequence[i];
if (tempSum > rightMidMaxSum)
{
rightMidMaxSum = tempSum;
}
}
return Math.Max(Math.Max(leftMaxSum, rightMaxSum), leftMidMaxSum + rightMidMaxSum);
}
上面用分治法来计算,时间复杂度比开始说的穷举的两个方法要快很多,时间复杂度是O(N*LogN),一般问题,这个速度已经挺好的了,但是这个最大子序列的问题,因为它的特性,还有一个更快的,时间复杂度是线性的方法,先看代码如下:
private static int CalculateLinear(List<int> sequence)
{
int maxSum = 0;
int tempSum = 0;
for (int i = 0; i < sequence.Count; ++i)
{
tempSum += sequence[i];
if (tempSum > maxSum)
{
maxSum = tempSum;
}
else if (tempSum < 0)
{
tempSum = 0;
}
}
return maxSum;
}
上面的代码,初看会觉得不好理解,怀疑它的正确性,但是仔细分析,会发现,它是有道理的。
具体的分析,肚子饿了,先吃饭,回头补上。
下面是一些相关的测试上面几个方法的代码,这里我创建了包含10,100,1000,10000,100000个随机整数的序列,用上面的几个方法来计算,然后给出统计时间。
下面的代码负责生成随机数列,并且保存到相应的文件中,这样不用每次测试都重新生成,而且可以保证测试前后的一致性:
private static List<int> sequence10 = null;
private static List<int> sequence100 = null;
private static List<int> sequence1000 = null;
private static List<int> sequence10000 = null;
private static List<int> sequence100000 = null;
private static void Initialize()
{
Initialize(10, ref sequence10);
Initialize(100, ref sequence100);
Initialize(1000, ref sequence1000);
Initialize(10000, ref sequence10000);
Initialize(100000, ref sequence100000);
}
private static void Initialize(int numberCount, ref List<int> sequence)
{
string numberFile = Path.Combine(Environment.CurrentDirectory, string.Format("numbers_{0}.txt", numberCount));
if (!File.Exists(numberFile))
{
Console.WriteLine("Re-generate number file {0}", numberFile);
Helper.GenerateRandomNumberSequence(numberFile, numberCount, -numberCount * 10, numberCount * 10);
}
sequence = Helper.ReadPreDefinedNumberFromFile(numberFile);
}
下面的代码是Main函数:
static void Main(string[] args)
{
Initialize();
Console.WriteLine("NumberCount\tMethod\tMaxSum\tCost(ms)");
Helper.ActionWithTimer(ExhaustionCalculate, sequence10);
Helper.ActionWithTimer(ExhaustionCalculateLittleImprovement, sequence10);
Helper.ActionWithTimer(RecursiveCalculate, sequence10);
Helper.ActionWithTimer(CalculateLinear, sequence10);
Helper.ActionWithTimer(ExhaustionCalculate, sequence100);
Helper.ActionWithTimer(ExhaustionCalculateLittleImprovement, sequence100);
Helper.ActionWithTimer(RecursiveCalculate, sequence100);
Helper.ActionWithTimer(CalculateLinear, sequence100);
Helper.ActionWithTimer(ExhaustionCalculate, sequence1000);
Helper.ActionWithTimer(ExhaustionCalculateLittleImprovement, sequence1000);
Helper.ActionWithTimer(RecursiveCalculate, sequence1000);
Helper.ActionWithTimer(CalculateLinear, sequence1000);
Helper.ActionWithTimer(ExhaustionCalculateLittleImprovement, sequence10000);
Helper.ActionWithTimer(RecursiveCalculate, sequence10000);
Helper.ActionWithTimer(CalculateLinear, sequence10000);
Helper.ActionWithTimer(RecursiveCalculate, sequence100000);
Helper.ActionWithTimer(CalculateLinear, sequence100000);
}
下面的代码是一些Helper方法,包含生成随机数列并写入文件,从文件读取随机数列保存到List中,最后一个是运行计算方法并且计时和打印结果:
static class Helper
{
public static void GenerateRandomNumberSequence(string outputFilePath, int numberCount, int minValue, int maxValue)
{
if (maxValue < minValue)
{
throw new ArgumentException("maxValue must be greater than or equal to minValue");
}
maxValue += 1; // Make Random.Next can return maxValue as candidate
Random random = new Random();
using (StreamWriter writer = new StreamWriter(outputFilePath))
{
while (numberCount > 0)
{
writer.WriteLine(random.Next(minValue, maxValue));
--numberCount;
}
}
}
public static List<int> ReadPreDefinedNumberFromFile(string numberFilePath)
{
List<int> numberList = new List<int>();
using (StreamReader reader = new StreamReader(numberFilePath))
{
string line = reader.ReadLine();
while (line != null)
{
numberList.Add(Int32.Parse(line));
line = reader.ReadLine();
}
}
return numberList;
}
public static void ActionWithTimer(Func<List<int>, int> action,List<int> sequence)
{
Stopwatch timer = new Stopwatch();
timer.Start();
int value = action(sequence);
timer.Stop();
Console.WriteLine("{0}\t{1}\t{2}\t{3}", sequence.Count, action.Method.Name, value, timer.ElapsedMilliseconds);
}
}
下面是在我的机器上跑的一次测试的结果,对于10000和100000的序列,由于穷举的两个方法速度很慢,所以选择性的运行:
NumberCount | Method | MaxSum | Cost(ms) |
10 | ExhaustionCalculate | 258 | 0 |
10 | ExhaustionCalculateLittleImprovement | 258 | 0 |
10 | RecursiveCalculate | 258 | 0 |
10 | CalculateLinear | 258 | 0 |
100 | ExhaustionCalculate | 5599 | 1 |
100 | ExhaustionCalculateLittleImprovement | 5599 | 0 |
100 | RecursiveCalculate | 5599 | 0 |
100 | CalculateLinear | 5599 | 0 |
1000 | ExhaustionCalculate | 158577 | 1369 |
1000 | ExhaustionCalculateLittleImprovement | 158577 | 6 |
1000 | RecursiveCalculate | 158577 | 0 |
1000 | CalculateLinear | 158577 | 0 |
10000 | ExhaustionCalculateLittleImprovement | 7485112 | 629 |
10000 | RecursiveCalculate | 7485112 | 2 |
10000 | CalculateLinear | 7485112 | 0 |
100000 | RecursiveCalculate | 280797383 | 25 |
100000 | CalculateLinear | 280797383 | 1 |