8.1 算法分析初步

尽管直观,适用范围广,但枚举,回溯等暴力方法常常无法走出低效的阴影
越是通用的算法,越不能深入挖掘问题的特殊性
本章介绍一些经典问题的高效算法,由于是量身定制,这些算法从概念思路到程序实现都是千差万别的
本章开始,读者刚刚开始接触严肃的算法设计理论

算法分析初步所需要解决的问题就是在写程序之前按估计程序的时空开销,并作出决策,如果算法又复杂速度又慢,就可以先缓缓,等下写

8.1.1 渐进时间复杂度

最大连续和问题
最简单的方法就是枚举,得出如下程序

点击查看代码
tot = 0;
best = A[1];//初始化最大值 
for(int i = 1; i <= n; i++)
  for(int j = 1; j <= n; j++) { //检查连续子序列A[i],...,A[j] 
  	int sum = 0;
  	for(int k = i; k <= j; k++) { sum += A[k]; tot++; }//累加元素和 
	if(sum > best) best = sum; //更新最大值 
}

注意这边best的初值是A[1],这是最保险的做法,不要写best=0,因为未必序列中存在非负数,可能全都是负数
这边计算的tot与机器的运行速度无关,不同机器的速度不一样,运行时间也会有差异,但tot值一定相同。换句话说,它去掉了机器相关的因素,只衡量算法的工作量大小,依旧是加法操作的次数

统计程序中“基本操作”的数量,可以排除机器速度的影响,衡量算法本身的优劣程度

在本题中,将加法操作作为基本操作,类似地可以把其他四则运算,比较运算作为基本操作,一般不会严格定义基本操作的类型,而是根据不同情况灵活处理
可以计算得出输入规模为n时,其加法操作次数为T(n) = n(n+1)(n+2)/6
上面的式子在n很大的情况下,平方项和一次项对式子的影响不大,也就是T(n) = O(n^3)
这二者是同阶的,具体可以参考高数的同阶无穷大的定义
同阶的含义简单来说就是增长情况相同,我们可以只保留最大项,并忽略其系数,得到的简单式子称为算法的渐进时间复杂度(asymptotic time complexity)
基本操作的数量往往可以写成关于输入规模的表达式,保留最大项并忽略系数后的简单表达式称为算法的渐进时间复杂度,用于衡量算法中基本操作数随规模的增长情况

点击查看代码
#include<iostream>
#include<time.h>
using namespace std;

int main() {
  clock_t start, end;
  start = clock();                                                         
  int n, tot = 0;
  cin >> n;
  for(int i = 0; i < n; i++) {
    for(int j = 0; j < n; j++) {
      for(int k = 0; k < n; k++) {
      	tot++;
	  }
	}
  }
  end = clock();
  cout << (double)(end-start) / CLOCKS_PER_SEC << endl;
  return 0;
}

以上的代码可以体会到n扩大2倍的时候,时间的扩大近似8倍,注意这边只是近似,因为对于进本操作来说我们这里忽略了其他运算,并且一次项二次项我们也忽略了
尽管如此,算法分析的效果还是比较精确的,因为抓住了主要矛盾————执行的最多的运算是加法
渐进时间复杂度忽略了很多因素,因而分析结果只能作为参考,并不是精确的。尽管如此,如果成功抓住了最主要的运算量所在,算法分析的结果常常十分有用

8.1.2 上界分析

每次都要作一番复杂的数学推到才能得到渐进时间复杂度吗?当然不必
下面是另外一种推导方法,算法包含三层循环,内层最坏情况下需要循环n次,中层循环最坏情况下也需要n次,外层循环最坏情况下仍然需要n次,因此总运算次数不超过n^3,这里采用了上界分析,假定所有最坏情况同时取到,尽管这是不可能的。不难预料,这样的分析和实际情况肯定会有一定的偏差,但是数量级是正确的,上界也有记号:T(n) = O(n^3)

在算法设计中,常常不进行精确分析,而是假定各种最坏情况同时取到,得到上界。在很多情况下,这个上界和实际情况同阶(称为“紧”的上界),但也有可能会因为分析方法不够好,得到“松”的上界
松的上界也是正确的上界,但可能让人过高估计程序运行的时间(从而不敢编写程序),而即使上界是紧的,过大(100)或者过小(1/100)的最高想系数同样可能引起错误的估计
换句话说,算法分析不是万能的,要谨慎对待分析结果,如果预感到上界不紧,系数过大或者过小,最好还是要编程实践一下

点击查看代码
S[0] = 0;
for(int i = 1; i <= n; i++) S[i] = S[i-1] + A[i]; //递推前缀和S 
for(int i = 1; i <= n; i++) 
  for(int j = i; j <= n; j++) best = max(best, S[j]-S[i-1]);//更新最大值
//S[j]-S[i-1]:从i到j的序列和 

注意上面的程序用到了递推的思想,从小到大依次计算S[1],S[2],S[3],...,每个只需要在前一个的基础上加上一个元素,换句话说,“计算S”这个步骤的时间复杂度为O(n)
接下来一个二重循环,用类似的方法可以分析出:
T(n) = n(n+1)/2
用上街分析可以更快的得出结论,内存循环最坏情况下要执行n次,外层也是,所以时间复杂度为O(n^2)

8.1.3 分治法
现在使用分治法来解决这个问题
分治的思想如下:
划分问题:把问题的实例划分成子问题
递归求解:递归解决子问题
合并问题:合并子问题的解得到原问题的解

以本题为例:
划分:就是把序列元素个数尽量相等的两半
递归:就是分别求出完全位于左半边或者完全位于右半的最佳序列
合并:就是求出起点位于左半,终点位于右半的最大连续和序列,并和子问题比较

以下是笔者尝试的比较n^3和nlogn的时间耗费

点击查看笔者代码
#include<iostream>
#include<cstring>
#include<ctime>
#include<cstdlib>
using namespace std;

constexpr int MAXN = 5000, MAXM = 100;
int num[MAXN];

void setD(int& len) {
  for(int i = 0; i < len; i++) num[i] = rand()%MAXM - MAXM/2;
}

int bs(int x, int y) {
  if(x+1 == y) return num[x];
  int center = (x+y)/2;
  int m1 = bs(x, center), m2 = bs(center, y);
  int b1 = num[center-1], b2 =num[center], sum = 0;
  for(int i = center-1; i >= x; i--) {
  	sum += num[i]; 
  	if(sum > b1) b1 = sum;
  }
  sum = 0;
  for(int i = center; i < y; i++) {
  	sum += num[i];
  	if(sum > b2) b2 = sum;
  }
  int m3 = b1 + b2;
  if(m1 > m3 && m1 > m2) return m1;
  else if(m2 > m3) return m2;
  else return  m3;
} 

int main() {
  srand(time(NULL));
  int len =  MAXN + rand()%MAXN;
  setD(len);
  clock_t start, end;
  start = clock();
  int best = num[0];
  for(int i = 0; i < len; i++) {
  	for(int j = i; j < len; j++) {
  	  int sum = 0;
	  for(int k = i; k <= j; k++) {
  	  	sum += num[k];
	  }
	  if(sum > best) best = sum;
	}
  }
  end = clock();
  cout << (double)(end-start)/CLOCKS_PER_SEC << endl;
  cout << best << endl;
  start =clock();
  cout << bs(0, len) << endl;
  end = clock();
  cout << (double)(end-start)/CLOCKS_PER_SEC << endl;
  return 0;
}

注意这边作者采用的方法是很明显对于[i, j]最大的连续和,是[i, d]从d开始的最大左连续和和[d, j]从d开始的最大右连续和加起来后,和[i, d]以及[d, j]之间的最大子连续和进行比较,最大的就是[i, j]之间的最大连续和

点击查看代码
int maxsum(int* A, int x, int y) { //返回数组在左闭右开区间[x,y]中的最大连续和 
  int v, L, R, maxs;
  if(y - x == 1) return A[x]; //只有一个元素直接返回 
  int m = x + (y-x)/2; //分治第一步,划分成[x, m)和[m, y)
  int maxs = max(maxsum(A, X, M), maxsum(A, m, y)); //分治第二步,递归求解 
  int v, L, R;
  v = 0; L = A[m-1]; //分治第三步,合并(1)--从分界点开始往左的最大连续和L 
  for(int i = m-1; i >= x; i--) L = max(L, v += A[i]);//赋值运算本身具有返回值
  V = 0; R = A[m]; //分治第三步,合并(2)--从分界点开始往右的最大连续和R 
  for(int i = m; i < y; i++) R = max(R, v += A[i]); 
  return max(maxs, L+R); //把子问题的解与L和R比较 
} 

上面的代码用到了赋值运算本身具有返回值的特点,在一定程度上简化了代码,但不会牺牲可读性

注意此时求和技巧已经不再适用,需要用递归的思路进行分析:设序列长度为n时的tot值为T(n),则T(n) = 2T(n/2)+n,T(1) = 1。
其中2T(n/2)是两次长度为n/2的递归调用,而最后的n是合并时间(整个序列恰好扫描一遍)
注意这个方程是近似的,因为当n为奇数时两次递归的序列长度分别为(n-1)/2和(n+1)/2,而不是n/2,幸运的是这样的近似对于最终结果影响很小,在分析算法时,而可以忽略他

在算法分析中,往往可以忽略除法结果是否为整数,而直接按照实数除法分析。这样的近似对最终结果影响小,一般不会改变渐进时间复杂度
(应该偏指整数除法)

最后得到T(n) = O(nlogn)
由于nlogn增长很慢,当n扩大两倍时,运行时间的可扩大倍数只是略大于2

递归方程T(n) = 2T(n/2)+O(n), T(1) = 1的解为T(n) = O(nlogn)

在结束对分治算法讨论之前,有必要谈谈上述程序中的两个细节。首先是范围表示。上面的程序用左闭右开区间来表示一个范围,好处是在处理“数组分割”时比较自然,区间[x, y)被分成[x, m)和[m, y),不需要任何地方加减一,另外空区间表示[x, x),比[x, x-1]顺眼多了

另一个细节是“分成元素个数尽量相等的两半”时分界点的计算。在数学上,分界点应当是x和y的平均数,此处用的却是x+(y-x)/2,在数学上二者相等,但在计算机中却有差别
这边需要注意"/"的取整方向是朝零方向,而不是向下取整
也就是5/22,-5/2-2!=-3
为了方便分析,此处用x+(y-x)/2来确保分界点总是靠近区间起点,这在本题中是不必要的,但在后面分析的二分查找中是相当重要的技巧

8.4.1 正确对待算法分析结果
前面的最大连续和问题还可以成为O(n),代码如下:

点击查看笔者代码
#include<iostream>
#include<cstring>
#include<algorithm>
using namespace std;

int main() {
  int n[10];
  int pre[10], now[10];
  memset(pre, 0, sizeof(pre));
  memset(now, 0, sizeof(now));
  for(int i = 0; i < 10; i++) n[i] = i+1;
  int ans = n[0];
  for(int i = 0; i < 10; i++) {
  	if(i) {
	  now[i] = now[i-1] + n[i];
	}
  	else now[i] = n[i];
  	if(now[i]-pre[i] > ans) ans = now[i]-pre[i];
    if(i < 9 && i) pre[i] = min(pre[i-1], now[i]); 
  }
  cout << ans << endl;
  return 0;
}

当j确定时,S[j]-S[i-1]最大相当于S[i-1]最小,因此只需要扫描一次数组,维护目前遇到过的最小S就可以了

注意接下来是算法竞赛中需要熟知的内容
在1秒之内能解决最大问题规模

点击查看代码
运算量         n!  2^n  n^3    n^2    nlog2n       n
最大规模       11  26   464   10000  4.5*10^6  100000000
速度扩大两倍后  11  27   584   14142  8.6*10^6  200000000

渐进时间为多项式的算法称为多项式时间算法,也称有效算法,而n!或者2^n这样的低效的算法称为指数时间算法
不过需要注意的是,上界分析的结果在趋势上能反映算法的效率,但有两个不精确性:一是公式本身的不精确性。例如非主流基本操作的影响,隐藏在大O记号后的最低此项和最高项系数:二是对程序实现细节与计算机硬件的依赖性,例如,对复杂表达式的优化计算,把内存访问方式设计的更加cache友好(内存友好)
一般可以参照该列表进行适当的算法选取

posted @   banyanrong  阅读(122)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· 全程不用写代码,我用AI程序员写了一个飞机大战
· DeepSeek 开源周回顾「GitHub 热点速览」
· 记一次.NET内存居高不下排查解决与启示
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· .NET10 - 预览版1新功能体验(一)
点击右上角即可分享
微信分享提示