算法设计的要求

所谓算法就是解决问题的方法,对于同一问题会有很多解决方法,也就是会有很多算法,那么我们如何评估这些算法的优劣呢?
01 正确性
正确性是指算法至少有输入输出和加工处理无歧义性,能正确反映问题的需求、能得到问题的正确答案。
02 可读性
算法应当便于阅读、理解和交流
03 健壮性
当输入不合法的数据时,算法也能给出相关处理,而不是产生莫名其妙的异常和结果。
04 时间效率高和存储量低
在生活中,人们总是希望花最少的钱、用最短的时间办事,算法同样如此。因此用最短的时间,占用最少的存储空间完成同样的事就是最好的算法。

为什么要进行复杂度的分析?

我们可能会这么想,代码的实际执行时间和资源占用最准确直观的测量方式就是让代码运行起来统计就好了。那么为啥不都这么干呢?其实,这种评估代码的执行效率和资源消耗的方式被称为"事后统计法"。自然是有一定道理的。但是,缺点也很明显。

测试结果受测试环境影响
我们知道要想让代码跑起来,必须得搭好一套代码运行的环境才行。仅仅为了测试一下算法就这么兴师动众显然成本很高,其次测试环境的各种硬件和软件的不同会对测试结果带来极大的影响,单一环境的测试结果不具有说服力。

测试结果受数据规模影响
测试数据的规模大小也对极大的影响测试结果。那么这个测试结果就无法客观的反映算法的好坏。

综合以上两点,我们发现我们需要的是一套逻辑严密的理论测试方法,可以不依赖测试环境和测试数据的客观的科学的评估方法,这就是复杂度分析方法。这种无需运行代码就能评估的算法优劣的方式被称为事前分析法。

大O复杂度表示法

上面已经说过了,所谓好的算法一定执行效率高,即执行时间短。怎么计算算法代码的执行时间呢?
先来看下面这段代码

int i,sum=0,n=100;       // 执行了1次
for(i=0;i<=n;i++){        // 执行了n+1次
    sum=sum+i;           // 执行了n次
}
printf("%d",sum);       // 执行了1次

对于计算机来说,以上代码的每一行都重复者类似的操作:读数据-运算-写数据。为了粗略估计,我们假设每行代码的执行时间为固定值t,于是这段代码的执行时间就等于 1t+(n+1)t+nt+1t = (2n+3)*t。
通过这个例子我们发现所有代码的执行时间T(n)与每行代码的执行次数n成正比。用公式表达就是 T(n) = O(2n+3)。这就是大O复杂度表示法。
算法时间复杂度的定义:
在进行算法分析时,语句总的执行次数T(n)是关于问题规模n 的函数,进而分析T(n)随n的变化情况并确定T(n)的数量级。算法的时间复杂度,也就是算法的时间量度,记作T(n)=O(f(n))。它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐近时间复杂度,简称为时间复杂度。其中f(n)是问题规模n的某个函数。

时间复杂度分析方法

推导大O阶总则:
1、用常数1取代运行时间中的所有加法常数
2、在修改后的运行常数总函数中,只保留最高阶
3、如果最高阶存在且不是1,则去除与这个项相乘的常数,得到的结果就是大O阶。

方法一:只关注循环执行次数最多的一段代码
前面已经知道大O阶反映的是算法执行时间随数据规模的变化趋势。所以O(n)公式中的常量、低阶和系数都可忽略,所以在分析一段代码的时候只需要关注循环执行次数最多的一段代码即可。

举个之前的例子说明下:

int i,sum=0,n=100;       // 执行了1次
for(i=0;i<=n;i++){        // 执行了n+1次
    sum=sum+i;           // 执行了n次
}
printf("%d",sum);       // 执行了1次

对于第1、5行代码执行次数均为常量,与n无关,所以不会对时间复杂度产生影响,而2、3行代码执行次数为n+1、n次,所以总的时间复杂度就是O(n)。

方法二:加法法则,总复杂度等于量级最大的那段代码的复杂度

对于较为复杂的代码,可以分段分析,累加求复杂度

int cal(int n) {
   int sum_1 = 0;
   int p = 1;
   for (; p < 100; ++p) {
     sum_1 = sum_1 + p;
   }

   int sum_2 = 0;
   int q = 1;
   for (; q < n; ++q) {
     sum_2 = sum_2 + q;
   }
 
   int sum_3 = 0;
   int i = 1;
   int j = 1;
   for (; i <= n; ++i) {
     j = 1; 
     for (; j <= n; ++j) {
       sum_3 = sum_3 +  i * j;
     }
   }
 
   return sum_1 + sum_2 + sum_3;
 }

我们将此段代码分为三部分分别计算时间复杂度,第一部分时间复杂度为:
O(1)(注意此处的p=100为常量,按照总则只要是常量无论多大复杂度都表示为O(1)); 第二部分的时间复杂度为O(n);第三部分的时间复杂度为O(n²)。按照总则第二条只保留最高阶很容易就得出整段代码的时间复杂度为O(n²)。

方法三:乘法法则,嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

这种方法主要用来分析嵌套代码的复杂度。
举例分析下:

int cal(int n) {
   int ret = 0; 
   int i = 1;
   for (; i < n; ++i) {
     ret = ret + f(i);
   } 
 } 
 
 int f(int n) {
  int sum = 0;
  int i = 1;
  for (; i < n; ++i) {
    sum = sum + i;
  } 
  return sum;
 }

先来看第五行代码执行次数为n,时间复杂度为O(n),其中第五行代码又调用了f函数,而f函数的时间复杂度也为O(n),那么总的时间复杂度T(n)=O(n*n)=O(n²)。

几种常见的时间复杂度案例分析

执行次数函数


非正式术语

100

O(1)

常量阶

5log₂n+20

O(logn)

对数阶

2n+3

O(n)

线性阶

2n+3nlog₂ n+19

O(nlogn)

线性对数阶

3n²+2n+1

O(n²)

平方阶

2n³+3n²+n+1

O(n³)

立方阶

2ⁿ

O(2ⁿ)

指数阶

n!

O(n!)

阶乘阶

nⁿ

O(nⁿ)

n次方阶

表格所列时间复杂度从上到下耗费时间逐渐增多,即效率变差。参考下图:

深度学习算法复杂性实验_深度学习算法复杂性实验

对于以上复杂度量级可分为两类:多项式量级非多项式量级,非多项式量级只有2ⁿ指数阶和n!阶乘阶。
实际上一般算法的时间复杂度只有O(1)、O(n)、O(logn)、O(nlogn)、O(n²)。对于O(n³)复杂度量级已经超出了正常可以接受的范围,这样的算法本身就是不合理的。而对于非多项式量级的复杂度2ⁿ指数阶和n!阶乘阶,简直就是一场灾难。

举例分析一下常见的复杂度:

常量阶O(1)

int sum = 0,n=100;
sum =(1+n)*n/2;
printf("%d",sum);

该段代码的执行次数函数f(n)=3,但是时间复杂度是O(1)而不是O(3)。这是为什么呢?按照推导大O阶的第一天原则就是将常量全部替换为1,因为常量与数据规模n没有任何关系,所以无论常量是多少都视为常数阶,用O(1)表示。

线性阶O(n)

int i;
for(i=0;i<=n;i++){
print("d%",i);
}

因为循环体中的代码执行次数为n,所以时间复杂度为O(n)。

对数阶O(logn)

int count =1;
while(count<n){
   count = count*2;
}

通过计算可知该段代码最多执行此数x=log₂n,底数常量可忽略,那么时间复杂度为O(logn)

平方阶O(n²)

int i,j;
for(int i=0;i<n;i++){
	for(j=0;j<n;j++){
	/* n²阶 */
    }
 }

这段代码是一个双层的嵌套循环,所以应该内外执行次数相乘 得到时间复杂度为:O(n*n) =O(n²)。

空间复杂度分析

类比时间复杂度,算法的空间复杂度就是渐进空间复杂度,反映的是算法的存储空间随数据规模增长的变化关系。举例分析一下:

void print(int n) {
  int i = 0;
  int[] a = new int[n];
  for (i; i <n; ++i) {
    a[i] = i * i;
  }

  for (i = n-1; i >= 0; --i) {
    print out a[i]
  }
}

跟时间复杂度分析一样,第 2 行代码中,我们申请了一个空间存储变量 i,但是它是常量阶的,跟数据规模 n没有关系,所以我们可以忽略。第 3 行申请了一个大小为 n的 int 类型数组,除此之外,剩下的代码都没有占用更多的空间,所以整段代码的空间复杂度就是 O(n)。