[算法]一次商品交易利益最大化
[问题背景]
已知某个商品一段时间的价格,求在某一天买进,然后在之后的某一天卖出,求最大利益。
[问题分析](着急看算法的小盆友请直接跳过这一节)
不难发现,有个很特殊的情况,如果价格一直在涨,显然第一天买进,最后一天卖出能够获得最大利益。所以,当价格有涨有跌的时候,这个问题才有意义。
首先,最容易的想法是暴力搜索。我们可以枚举买进和卖出的日期,然后计算对应的获利,最后得到一个最大利益。这种方式的时间复杂度是O(n^2)的,如下(c代码):
1 #include <stdio.h> 2 #define MaxN 120 3 #define INF 0x3f 4 int main() 5 { 6 int a[MaxN], n; 7 int i, j; 8 int Ans, Delta, st = 0, ed = 0; 9 scanf("%d", &n); 10 for(i = 1; i <= n; i++) 11 scanf("%d", &a[i]); 12 /*暴力枚举买入日期和卖出日期,然后计算其中的获利*/ 13 Ans = -INF; 14 for(i = 1; i < n; i++) //显然不能在最后一天买入 15 for(j = i + 1; j <= n; j++) //至少在后一天卖出 16 { 17 Delta = a[j] - a[i]; 18 if(Ans < Delta) 19 { 20 Ans = Delta; 21 st = i; 22 ed = j; 23 } 24 } 25 printf("获利:%d元\n在第%d天买入,在第%d天卖出\n", Ans, st, ed); 26 return 0; 27 }
然后,我们觉得这种方法的时间复杂度太高了,于是思考更优的方法。
有一种尝试,将原来的保存每天的价格的数组改变一下,变成保存当前价格相对于前一天的变化。为了叙述方便,我们将先前的保存每天的价格的数组记为a[],其中a[i]表示第i天的商品价格是多少;将记录价格变化(价格差)的新的数组记为b[],其中b[i]表示第i天的价格相比于第i-1天涨了多少(负数表示降价),也可以理解为b[i]保存的是“如果第i-1天买进第i天卖出所得利益”。于是,我们发现,第i天买进然后第j天卖出,可以分解为:第i天买进、第i+1天卖出、第i+1天买进、第i+2天卖出……第j-1天买进、第j天卖出。
咋一看觉得把复杂度提高了,因为原本求“第i天买进,第j天卖出”所得利益只需要O(1)的复杂度就可以搞定了,现在还需要做一次累加,变成了O(n)级别的操作了。各位看官先别着急,咱们先分析一下问题转换成了什么,再分析复杂度不迟。
对a[]的问题是,求两个位置i和j,其中i<j,使得a[j]-a[j]最大;对b[]的问题是,求两个位置i和j,使得a[i]+a[i+1]+……+a[j]最大(这个和式也就是所得利益)。也就是说,现在的问题变成了求连续子数组最大和。我们仍然从暴力搜索开始思考,一个很朴素的想法是,枚举买进的日期,然后计算在之后的每一天卖出所能获得的利益。最后我们同样可以获得最大利益,时间复杂度仍然是O(n^2),如下(c代码):
1 #include <stdio.h> 2 #define MaxN 120 3 #define INF 0x3f 4 int main() 5 { 6 int a[MaxN], n; 7 int i, j; 8 int Ans, Sum, st = 0, ed = 0; 9 scanf("%d", &n); 10 a[0] = 0; 11 for(i = 1; i <= n; i++) 12 scanf("%d", &a[i]); 13 for(i = n; i >= 1; i--) 14 a[i] -= a[i - 1]; //这里的a[]就是指的新得到的b[] 15 /*暴力枚举买入日期*/ 16 Ans = -INF; 17 for(i = 1; i < n; i++) //显然不能在最后一天买入 18 { 19 Sum = 0; 20 for(j = i + 1; j <= n; j++) //至少在后一天卖出 21 { 22 Sum += a[j]; 23 if(Ans < Sum) 24 { 25 Ans = Sum; 26 st = i; 27 ed = j; 28 } 29 } 30 } 31 printf("获利:%d元\n在第%d天买入,在第%d天卖出\n", Ans, st, ed); 32 return 0; 33 }
现在估计有看官觉得zyy又在装神弄鬼了…… 明明还是O(n^2)的好吗!还弄这么复杂很好玩吗啊摔!w(゚Д゚)w
……摸摸,都说了别着急…… 下面上干货。
我们来分析一下对于数组b[]来说,答案会出现在什么地方。如果对数组b[]一分为二,那么最后求得的答案只能是三种情况:要么是全在左边的连续子数组,要么是全在右边的连续子数组,要么是横跨中点的连续子数组。说到这里就应该有一种灵光一现的感觉了吧~ 是的,这是一种基于分治思想的算法(你觉得是二分,或者是线段树,甚至树状数组那都没问题,不过我们这里说的是算法的思想),下面给出算法的具体实现。
[算法描述及实现]
对于一个数组,求下标范围为[left,right]的连续子数组最大和。将数组一分为二,记mid=(left+right)/2(具体实现个人更倾向于mid=left+(right-left)/2的写法,这里不作解释),那么最优解要么是从[left,mid]得到,要么是从[mid+1,right]得到,要么是横跨中点的一个答案。对于前两种情况,是将问题完全转化为一个规模更小的、本质不变的子问题,递归进去实现就可以了,这里我们需要解决的是如何求横跨中点的情况。我们很容易发现,想要mid左右两边选出来的连续的数之和达到最大,让左右两边分别达到最大即可。于是第三种情况:最大和=mid向左侧延伸的最大和+mid向右侧延伸的最大和。这个在同一级别的区间并起来看时间复杂度是O(n)的,二分的复杂度是O(lg n)的,所以总的复杂度是O(n lg n)。
下面是实现(c++代码):
1 #include <iostream> 2 #include <cstdio> 3 using namespace std; 4 #define MaxN 120 5 #define INF 0x3f 6 int Get_Max_Sum(int *a, int L, int R, int &st, int &ed) 7 { 8 int Ret, tmp, mid = L + (R - L) / 2; 9 int s, e; 10 int Max1, Max2; 11 int i; 12 /*单个点的情况,直接返回*/ 13 if(L == R) 14 { 15 Ret = a[L]; 16 st = ed = L; 17 return Ret; 18 } 19 /*从左侧得到答案*/ 20 Ret = Get_Max_Sum(a, L, mid, s, e); 21 st = s; ed = e; 22 /*从右侧得到答案*/ 23 tmp = Get_Max_Sum(a, mid + 1, R, s, e); 24 if(Ret < tmp) 25 { 26 Ret = tmp; 27 st = s; ed = e; 28 } 29 /*横跨mid的情况,实际上是指至少包括a[mid]和a[mid+1]*/ 30 //左侧 31 s = mid; 32 tmp = 0; 33 Max1 = a[mid]; 34 for(i = mid; i >= L; i--) 35 { 36 tmp += a[i]; 37 if(tmp > Max1) 38 { 39 Max1 = tmp; 40 s = i; 41 } 42 } 43 //右侧 44 e = mid + 1; 45 tmp = 0; 46 Max2 = a[mid + 1]; 47 for(i = mid + 1; i <= R; i++) 48 { 49 tmp += a[i]; 50 if(tmp > Max2) 51 { 52 Max2 = tmp; 53 e = i; 54 } 55 } 56 tmp = Max1 + Max2; 57 if(tmp > Ret) 58 { 59 Ret = tmp; 60 st = s; ed = e; 61 } 62 return Ret; 63 } 64 int main() 65 { 66 int a[MaxN], n; 67 int i; 68 int Ans, st = 0, ed = 0; 69 scanf("%d", &n); 70 for(i = 1; i <= n; i++) 71 scanf("%d", &a[i]); 72 for(i = n; i >= 2; i--) 73 a[i] -= a[i - 1]; //这里的a[]就是指的新得到的b[] 74 Ans = Get_Max_Sum(a, 2, n, st, ed); 75 printf("获利:%d元\n在第%d天买入,在第%d天卖出\n", Ans, st - 1, ed); 76 return 0; 77 }
解释一下,这里写的是c++的代码,因为c语言中不包括变参,函数返回值处理略麻烦。
目前三个代码没有经过对拍,最后一种方式当然是正确的,但不保证我写得没有错误……挖个坑吧~