理想收入问题——朴素动态规划的优化详解

摘自《算法竞赛宝典 第二部》 张新华 编著


 

理想收入问题

题目描述

  楚继光:“墨老师,我也想买点股票,买哪只好呢?”

  墨老师:“股市交易很简单,一般来说,就是在低价时买进,在高价时卖出,但是股票风险很大的,所以买什么还是自己研究决定为好。我这里有每只股票在过去几年的价格走势图,你拿去研究下规律吧。对了,还有一点最重要,就是千万别相信所谓‘专家’的话哦。”

  现在已知楚继光手头有某只股票在过去几年中的价格,他试图据此模拟操作以获得理想收入。所谓理想收入是指在股票交易中,以1元为本金可能获得的最高收入,并且在理想收入中允许有非整数股票买卖。

  已知股票在第i天每股价格是 V[ i ]元,1 ≤ i ≤ n,求 n 天后的理想收入。

输入格式

  输入的第一行为 n (1 ≤ n ≤ 100 000)。下面有 n 个实数,依次为今后 n 天的股票价格。

这 n 个数可能分布在多行中。

输出格式

  对于给定的输入,输出 n 天后的理想输入(精确到 0.0001)。

输入样例

4

4.2 2.6

5.6 10.4

输出样例

4.0000

朴素动态规划

  首先应该想到一点:连续多次买入不如一次买入,连续多次卖出不如一次卖出。因为连续多次买入,必定有一次是这几次中价格最低的,那么其他次买入不如都在价格最低那天多买一些。后者同理。

  意味着,买卖操作将成对出现。即一次买入必定是一次卖出,一次卖出后必定是一次买入,而且最后一次操作必定是卖出,因为题目要求的是最后剩下的钱

  

  假如某一天要卖出股票,那么要使这次卖出得到的钱尽量多,那么在这天应保有尽量多的股票,就是说上一次卖出股票能得到尽量多的钱。从这里可以发现一个状态转移,假设 f[ i ]为第 i 天要卖出股票所能得到的最多的钱,那么 f[ i ]就可以由它之前某一天卖出股票所能得到最多的钱 f[ j ](0 ≤ j < i)转移来。而在这两天之间还有一次买的操作,可设在第 k 天买入股票。由此得到方程:

        f[ i ] = max{ f[ j ] / v[ k ] × v[ i ] }  (0 ≤ j < i,j < k ≤ i)

  其中初状态 f[ 0 ] = 1,表示开始前有 1 元钱;因为第一天之前就有收入,所以 j 可以取到 0 ;因为第 j 天收盘后不能再买入,所以 k > j,而在第 i 天可以先买入再卖出,所以 k ≤ i。

  参考程序如下所示:

 

 

 1 #include<iostream>
 2 #include<cstdio>
 3 #include<cmath>
 4 using namespace std;
 5 const int size = 100005;
 6 
 7 double v[size], f[size];
 8 int main() {
 9     int n;
10     scanf("%d", &n);
11     for(int i = 1;i <= n;i++) {
12         scanf("%lf", &v[i]);
13         f[i] = 0;
14     }
15     /* f[i] = max{f[j] / v[k] * v[i]} (1 <= j < i, j < k <= i, f[0] = 1) */
16     f[0] = 1;
17     for(int i = 1;i <= n;i++) 
18         for(int j = 0;j < i;j++)
19             for(int k = j + 1;k <= i;k++) {
20                 f[i] = max(f[i], f[j] / v[k] * v[i]);
21             }
22     printf("%.4lf\n", f[n]);
23     return 0;    
24 } 
朴素动态规划

 

 

优化算法1

  观察动态转移方程f[ i ] = max{ f[ j ] / v[ k ] × v[ i ] }  (0 ≤ j < i,j < k ≤ i)及其核心代码如下:

  for(int i = 1;i <= n;i++) 
      for(int j = 0;j < i;j++)
          for(int k = j + 1;k <= i;k++) {
              f[i] = max(f[i], f[j] / v[k] * v[i]);
          }

 

  可以发现方程等价于f[ i ] = max{ f[ j ] / v[ k ]}  × v[ i ]   (0 ≤ j < i,j < k ≤ i),而代码中最内层的二重循环就是用来确定这个 max{ f[ j ] / v[ k ]} 的。
  然而实际上在计算 f[ i - 1] 时,已经计算过了  max{ f[ j ] / v[ k ] }   (0 ≤ j < i - 1,j < k ≤ i - 1),而通过数学转换可得max{ f[ j ] / v[ k ] }  (0 ≤ j < i,j < k ≤ i) = max{ max{ f[ j ] / v[ k ] } *  × v[ i ]   (0 ≤ j < i - 1,j < k ≤ i - 1),f[ j ] / v[ i ]  (0 ≤ j < i)}。
  其中等号左边的式子正是计算f[ i ] 的二重循环需要确定的值,这意味着可以省掉这二重循环的时间,直接由上次的计算结果得到这次的 max{ f[ j ] / v[ k ] }。
  设 Maxfv[ i ] = max{ f[ j ] / v[ k ] } (0 ≤ j < i,j < k ≤ i) 代入上式的递推式:
        Maxfv[ i ] = max { Maxfv[i - 1],f[ j ] / v[ i ] (0 ≤ j < i)}
  其中初始值 Maxfv[1] = f[0] / v[1],为保持代码一致性,可令 Maxfv[0] = 0 再由递推式计算出。
  同时简化状态转移方程为: f[ i ] = Maxfv[ i ] × v[ i ]
  参考代码如下所示:
 
 1 #include<iostream>
 2 #include<cstdio>
 3 #include<cmath>
 4 using namespace std;
 5 const int size = 100005;
 6 
 7 double v[size], f[size], Maxfv[size];
 8 int main() {
 9     int n;
10     scanf("%d", &n);
11     for(int i = 1;i <= n;i++) {
12         scanf("%lf", &v[i]);
13         f[i] = 0;
14     }
15     /*
16     Maxfv[i] = max{Maxfv[i - 1], f[j] / v[i] (0 <= j < i)}
17     f[i] = Maxfv[i] * v[i]
18     */
19     f[0] = 1, Maxfv[0] = 0;
20     for(int i = 1;i <= n;i++) {
21         Maxfv[i] = Maxfv[i - 1];
22         for(int j = 0;j < i;j++)
23             Maxfv[i] = max(Maxfv[i], f[j] / v[i]);
24         f[i] = Maxfv[i] * v[i];
25     }
26     printf("%.4lf\n", f[n]);
27     return 0;    
28 } 
优化算法1

 

优化算法2-8及贪心算法我会另找时间完成(先睡觉了)

2018-10-19  23:25:25


2018-10-20  19:05:10 更新

 

优化算法2

  一般来说,改变动态规划中状态表示的含义是优化动态规划的常用方法,现设 P[ i ]表示前 i 天能获得的最多股票数,可列出如下动态转移方程:

      P[ i ] = 1.0 / v[ 1 ]      

      p[ i ] = Max{ P[ i  - 1],P[ j ] × v[ j ] / v[ i ] }  ( 0 ≤ j < i)

  该方程的含义是:前 i 天所能获得的最多股票数,要么是前 i - 1 天获得的最多股票数,要么是在第 j 天所能获得的最多的股票全部卖出,再买入第 i 天的股票。

  最后将股票数乘以股价即为最大收入。其时间复杂度为 O(n2)。

  参考代码如下所示:

 

 1 #include<iostream>
 2 #include<cstdio>
 3 #include<cmath>
 4 using namespace std;
 5 const int size = 100005;
 6 
 7 double v[size], p[size];
 8 
 9 int main() {
10     int n;
11     scanf("%d", &n);
12     for(int i = 1;i <= n;i++) {
13         scanf("%lf", &v[i]);
14     }
15     p[1] = 1.0 / v[1];
16     for(int i = 2;i <= n;i++) {
17         p[i] = p[i - 1];
18         for(int j = 1;j < i;j++)
19             p[i] = max(p[i], p[j] * v[j] / v[i]);
20     }    
21     printf("%.4lf\n", p[n] * v[n]);
22     return 0;    
23 } 
优化算法2

 

 

优化算法3

 

  设Q[ i ]表示前 i 天能达到的最大收入,可列出如下状态转移方程

      Q[ 1 ] = 1

      Q[ i ] = Max{ Q[ i - 1 ],Q[ j ] / v[ j ] × v[ i ] }  (0 ≤ j < i)

  该方程的含义是:前 i 天所能获得的最大收入,要么是前 i - 1 天所能达到的最大收入,要么是在第 j 天买入股票,再在第 i 天能达到的最大收入。

  时间复杂度为 O(n2)。

  参考代码如下所示:

 

 1 #include<iostream>
 2 #include<cstdio>
 3 #include<cmath>
 4 using namespace std;
 5 const int size = 100005;
 6 
 7 double v[size], q[size];
 8 
 9 int main() {
10     int n;
11     scanf("%d", &n);
12     for(int i = 1;i <= n;i++) {
13         scanf("%lf", &v[i]);
14     }
15     q[1] = 1.0;
16     for(int i = 2;i <= n;i++) {
17         q[i] = q[i - 1];
18         for(int j = 1;j < i;j++)
19             q[i] = max(q[i], q[j] / v[j] * v[i]);
20     }    
21     printf("%.4lf\n", q[n]);
22     return 0;    
23 }
优化算法3

 

 优化算法4

 

  在朴素算法中,f[ i ] 的定义为第 i 天要卖出的股票所能得到的最多的钱。现在对这个定义稍微修改一下:到第 i 天收盘为止所能得到的最多的钱。即不再要求第 i 天必须要卖出。

  这意味着,到第 i 天为止有两个选择:

  (1) 这天不卖出。那么这天所能得到的钱应该等于前一天得到的钱,即 f[ i ] = f[ i - 1 ]。

  (2) 这天卖出。那么和朴素算法一样,之前一定有个买入操作,假设在第 j + 1 天买入,那么 f[ i ] = f[ j ] / v[ j + 1 ] × v[ i ]。这里可以发现,在定义更改了之后,原来的 f[ j ] / v [ k ] 也被改为了 f[ j ] / v[ j + 1]。这是因为在新定义中, f[ j ] 表示第 j 天收盘时所得到的最多的钱,也就是第 j + 1 天开盘时所能得到的最多的钱,这节约了之前确定上次在哪一天卖出才能收入最高的一层循环。

  那么可得状态转移方程:

      f[ i ] = max { f [ i  - 1 ],f [ j ] / v[ j + 1 ] * v[ i ] }  (0 ≤ j < i)

  参考代码如下所示:

 

 1 #include<iostream>
 2 #include<cstdio>
 3 #include<cmath>
 4 using namespace std;
 5 const int size = 100005;
 6 
 7 double v[size], f[size];
 8 
 9 int main() {
10     int n;
11     scanf("%d", &n);
12     for(int i = 1;i <= n;i++) {
13         scanf("%lf", &v[i]);
14     }
15     
16     /* f[i] = max{f[i - 1], f[j] / v[j + 1] * v[i]} (0 <= j < i) */
17     f[0] = 1;
18     for(int i = 1;i <= n;i++) {
19         f[i] = f[i - 1];
20         for(int j = 0;j < i;j++) {
21             f[i] = max(f[i], f[j] / v[j + 1] * v[i]);
22         }
23     }
24     printf("%.4lf\n", f[n]);
25     return 0;    
26 }
优化算法4

 

 

优化算法5

 

  经过优化算法4后,程序的时间复杂的依然是O(n2),这对于题目要求的 n ≤ 100 000 来说还是太慢。我们尝试讲程序优化到 O(n)。

  优化算法4中的状态转移方程如下:

    f[ i ] = max { f [ i  - 1 ],f [ j ] / v[ j + 1 ] * v[ i ] }  (0 ≤ j < i)

      =max { f [ i  - 1 ],max { f [ j ] / v[ j + 1 ] } * v[ i ]   (0 ≤ j < i)}

  借鉴算法1的思路,我们对max { f [ j ] / v[ j + 1 ] } * v[ i ]   (0 ≤ j < i)部分进行再优化。

      设 g[ i ] = max { f[ j ] / v[ j + 1] }   (0 ≤ j < i)

  那么经过数学转换可得

      g[ i ] = max { g[ i - 1],f[ i - 1] / v[ i ] }

  整理方程得

      g[ i ] = max { g[ i - 1],f[ i - 1] / v[ i ] }

      f[ i ] = max { f[ i - 1],g[ i ] * v[ i ] }

  初始状态 g[ 1 ] = f[ 0 ] / v[ i ],可令g[ 0 ] = 0 再计算得到,因而设初状态 g[ 0 ] = 0。

  此时,方程中只剩一个变量 i (1 ≤ i ≤ n),即时间复杂度成功降至 O(n)。

  参考代码如下所示:

 

 1 #include<iostream>
 2 #include<cstdio>
 3 #include<cmath>
 4 using namespace std;
 5 const int size = 100005;
 6 
 7 double v[size], f[size], g[size];
 8 
 9 int main() {
10     int n;
11     scanf("%d", &n);
12     for(int i = 1;i <= n;i++) {
13         scanf("%lf", &v[i]);
14     }
15     
16     /* 
17     g[i] = max{g[i - 1], f[i - 1] / v[i]}
18     f[i] = max{f[i - 1], g[i] * v[i]}
19     */
20     f[0] = 1, g[0] = 0;
21     for(int i = 1;i <= n;i++) {
22         g[i] = max(g[i - 1], f[i - 1] / v[i]);
23         f[i] = max(f[i - 1], g[i] * v[i]);
24     }
25     printf("%.4lf\n", f[n]);
26     return 0;    
27 }
优化算法5

 

 

优化算法6

 

  经过优化算法5后,时间复杂度下降至 O(n),空间复杂度也为 O(n),可以完美解决这到题目了。但是精益求精,我们可以将空间复杂度下降到 O(1)。

  首先观察程序中的主要循环体,参考代码如下:

1 for(int i = 1;i <= n;i++) {
2      g[i] = max(g[i - 1], f[i - 1] / v[i]);
3      f[i] = max(f[i - 1], g[i] * v[i]);
4   }

 

 

  马上就可以看出,其中 v 数组在每次循环中只有第 i 个元素被使用,因此我们可以不必再一开始就读入所有的 v 值,而是在每次循环开始时再度如对应的 v 值。这样子就可以将一个数组简化到一个变量了。

  应该注意,在刚才的循环体中,并没有采用max() 函数,反而用了更麻烦的实现方式,先将 g[ i - 1] 转存至 g[ i ],将新值与之比较,若新值更大就覆盖 g[ i ]。

  这是为接下来的优化做准备。可以发现在第一行之后,g[ i - 1 ]再也没有出现了,并且第一行是将 g[ i - 1 ] 转存到 g[ i ] 。也就是说,完全可以将 g 整个数组变为一个变量:

1 // 原作者的代码风格与不符,所以我在之前的代码中修改了一下,直接使用了max()函数,但个人认为对整体的思考没有影响
2 // 仍是修改后的代码 ——十甫
3 
4 for(int i = 1;i <= n;i++) {
5     //因为只剩一个变量g, 可以省略转存步骤
6     g = max(g, f[i - 1] / v);
7     f[i] = max(f[i - 1], g * v); // 这里的g表示g[i] 
8 }

 

  再观察 f 数组,在第7行中只有 f[ i - 1 ],之后只有 f [ i ],因此同样可以压缩:

1 for(int i = 1;i <= n;i++) {
2     g = max(g, f / v); // 这里的f表示f[i - 1] 
3     //因为只剩一个变量f, 可以省略转存步骤
4     f = max(f, g * v); // 这里的f表示f[i] 和 f[i - 1] 
5 }

  如此一来,空间复杂度就下降为 O(1)了。

  完整代码如下所示:

 

 

 1 #include<iostream>
 2 #include<cstdio>
 3 #include<cmath>
 4 using namespace std;
 5 const int size = 100005;
 6 
 7 int main() {
 8      int n;
 9      scanf("%d", &n);
10      /* f[i] = max{f[i - 1], f[j] / v[j + 1] * v[i] */
11      double v, f = 1, g = 0; // f[0] = 1, g[0] = 1
12      for(int i = 1;i <= n;i++) {
13          scanf("%lf", &v);
14          g = max(g, f / v);
15          f = max(f, g * v);
16      }
17      printf("%.4lf\n", f);
18      return 0;    
19 }
优化算法6

 

 

优化算法7

  现在我们从另一个角度来定义方程。

  设 f [ i ] 表示到第 i 天收盘时所能获得的最多的钱,这个定义同优化算法5是相同的。

  设 g [ i ] 表示到第 i 天收盘时所能获得的最多的股票。

  注意第 i 天收盘时 和 第 i + 1 天开盘时两个时间是等价的。

  可得方程:

    f [ i ] = max { f [ i - 1],g [ i - 1] * v[ i ] },表示第 i 天若不卖出,则收入同前一天,否则为今天开盘时拥有的最多股票数全卖出的钱。

    g [ i ] = max { g [ i - 1],f [ i - 1] / v[ i ]},表示第 i 天若不买入,则股票数同前一天,否则为今天开盘时拥有的最多钱全买入的股票。

  初始状态为 f [ 0 ] = 1,g [ 0 ] = 0,表示第一天开盘前拥有1元钱,没有股票。

  参考代码如下所示:

 

 1 #include<iostream>
 2 #include<cstdio>
 3 #include<cmath>
 4 using namespace std;
 5 const int size = 100005;
 6 
 7 double v[size], f[size], g[size];
 8 
 9 int main() {
10     int n;
11     scanf("%d", &n);
12     for(int i = 1;i <= n;i++) {
13         scanf("%lf", &v[i]);
14     }
15     /*
16     g[i] = max{g[i - 1], f[i - 1] / v[i]}
17     f[i] = max{f[i - 1], g[i - 1] * v[i]} 
18     */
19     f[0] = 1, g[0] = 0;
20     for(int i = 1;i <= n;i++) {
21         g[i] = max(g[i - 1], f[i - 1] / v[i]);
22         f[i] = max(f[i - 1], g[i - 1] * v[i]);
23     }
24     printf("%.4lf\n", f[n]);
25     return 0;    
26 }
优化算法7

 

 优化算法8

 

  这个方程同样可以压缩空间复杂度。观察主要循环如下:

1 for(int i = 1;i <= n;i++) {
2     g[i] = max(g[i - 1], f[i - 1] / v[i]);
3     f[i] = max(f[i - 1], g[i - 1] * v[i]);
4 }

  发现 g [ i ] 和 g [ i - 1 ] 交错出现,不存在优化算法6的规律。

  我们对方程进行分析:   

    f [ i ] = max { f [ i - 1],g [ i - 1] * v[ i ] }

    g [ i ] = max { g [ i - 1],f [ i - 1] / v[ i ]}

  若 f [ i - 1 ] > g [ i - 1 ] × v [ i ] ,那么 g [ i - 1 ] < f [ i - 1 ] / × v [ i ] 。反之 f [ i - 1 ] < g [ i - 1 ] × v [ i ] ,那么 g [ i - 1 ] > f [ i - 1 ] / × v [ i ] 。也就是说 f [ i - 1 ] = g [ i - 1 ] × v [ i ] 和 g [ i - 1 ] = f [ i - 1 ] / × v [ i ]两条式子,每次只有其中一条会被执行。依次重写代码如下:

1 for(int i = 1;i <= n;i++) {
2     if(f[i - 1] < g[i - 1] * v[i]) f[i] = g[i - 1] * v[i], g[i] = g[i - 1];
3     else f[i] = f[i - 1], g[i] = f[i - 1] / v[i];
4 }

 

  如此一来代码分为两个部分(if 部分和 else 部分),每次只有一个部分会被执行,因此可以分别分析两个部分。现在可以发现这两部分代码都是符合优化算法5中发现的规律的。因此可以将 f,g 两数组压缩成两个变量。

  参考代码如下所示:

 

 1 #include<iostream>
 2 #include<cstdio>
 3 #include<cmath>
 4 using namespace std;
 5 const int size = 100005;
 6 
 7 int main() {
 8     int n;
 9     scanf("%d", &n);
10     double v, f = 1, g = 0;
11     for(int i = 1;i <= n;i++) {
12         scanf("%lf", &v);
13         if(f < g * v) f = g * v;
14         else g = f / v;
15     }
16     printf("%.4lf\n", f);
17     return 0;    
18 }
优化算法8

 

 

贪心算法

 

  在价格曲线的极小点买入,极大点卖出,水平直线则压缩为点。

  根据题目容易得到一条公式:

      总价值 = 钱 + 股票数 × 当天股价

  从这条公式可以看出:

    (1) 若一直没有进行股票买卖,那么总价值不会上升,当然也不会下降,因此钱能够保值。

    (2) 而一对操作中,只有买入比卖出时股价低,才能使总价值上升,即只有股价上涨才能使总价值上升。

  如此可以得到一个贪心策略:平时用钱来保值,当股价快要上涨时再将钱换成股票,使总价值能够上涨。

  这个策略比较好证明:

    首先可以之证明,每次操作必定是将钱全换成股票或者股票全换成钱。

    因此股票上涨后的总价值 = 原来总价值 / v [ 前 ] × v [ 后 ] = k × 原总价值。

    每次涨价的比率 k = v [ 后 ] / v [ 前 ],是不变的。因此每次是总价值上升肯定比只选取一部分涨价时机进行升值要高。

  具体操作如下:

    (1) 若第二天股价比今天低,那么不操作。

    (2) 若第二天股价比今天高,那么将钱全换成股票。第二天升值后再将股票换回钱。

  参考代码如下所示:

 

 1 #include<iostream>
 2 #include<cstdio>
 3 #include<cmath>
 4 using namespace std;
 5 const int size = 100005;
 6 
 7 int main() {
 8     int n;
 9     scanf("%d", &n);
10     double m = 1, v[2];
11     scanf("%lf", &v[1]);
12     for(int i = 2;i <= n;i++) {
13         scanf("%lf", &v[i % 2]);
14         if(v[i % 2] > v[(i + 1) % 2])
15             m *= v[i % 2] / v[(i + 1) % 2];
16     }
17     printf("%.4lf\n", m);
18     return 0;    
19 }
贪心算法

 

 


 

这本书的动态规划部分很详细,我学到了很多,希望这篇文章可以帮到更多的人。

posted @ 2018-10-19 23:26  十甫  阅读(426)  评论(0)    收藏  举报