算法复杂度分析

什么是算法?

算法(algorithm)是对特定问题求解步骤的一种描述,它是指令的有限序列,其中每一条指令表示一个或多个操作;此外,一个算法通常来说具有以下五个特性:

  • 输入:一个算法应以待解决的问题的信息作为输入。
  • 输出:输入对应指令集处理后得到的信息。
  • 有穷性:算法执行的指令个数是有限的,每个指令又是在有限时间内完成的,因此整个算法也是在有限时间内可以结束的。
  • 可行性:算法是可行的,即算法中的每一条指令都是可以实现的,均能在有限的时间内完成。
  • 确定性:算法对于特定的合法输入,其对应的输出是唯一的。即当算法从一个特定输入开始,多次执行同一指令集结果总是相同的。

算法效率的度量

事后统计法

这种方法有两个缺陷:一是必须先运行一句算法编制的程序;二是所得时间的统计量依赖于计算机的硬件、软件、等环境因素,有时容易掩盖算法本身的优势。故一般采用事前估算的方法。

事前分析估算法

一个算法是由控制结构(顺序、分支和循环3种)和原操作(指固有数据类型的操作)构成的,则算法时间取决于两者的综合效果。为了便于比较同一个问题的不同算法,通常的做法是,从算法中选取一种对于所研究的问题(或算法类型)来说是基本操作的原操作,以该基本操作的重复执行的次数作为算法的时间量度。

一个用高级程序语言编写的程序在计算机上运行式所消耗的时间取决于下列因素:

  1. 算法采用的策略、方法
  2. 问题的规模
  3. 书写程序的语言
  4. 编译程序所产生的机器代码的质量
  5. 机器执行指令的速度

时间复杂度分析

渐进时间复杂度(asymptotic time complexity):若存在函数f(n),使得当n趋近于无穷大时,T(n)/f(n)的极限值为不等于零的常数,则称f(n)是T(n)的同量级函数。记作T(n)=O(f(n)),称O(f(n))为算法的渐进时间复杂度,简称时间复杂度。渐进时间复杂度用大写O来表示,所以也被称为大O表示法

T(n)=O(f(n))这个公式中,T(n)表示代码的执行时间;n表示数据规模的大小;f(n)表示每行代码执行的次数总和。因为这是一个公式,所以用f(n)来表示,公式中的O表示渐进于无穷的一种行为。大O时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随数据规模增长的变化趋势。

如何推导出时间复杂度呢?有如下几个原则:

  1. 如果运行时间是常数量级,用常数1表示。
  2. 只保留时间函数中的最高阶项
  3. 如果最高阶项存在,则省去最高阶项前面的系数。

或者换种说法:在”公式中的低阶、常量、系数三部分并不左右增长趋势,所以都可以忽略“基础上,

  1. 只关注循环执行次数最多的一段代码
  2. 加法法则:总复杂度等于量级最大的那段代码的复杂度

    如果T1(n)=O(f(n)),T2(n)=O(g(n));

    那么T(n)=T1(n)+T2(n)=max(O(f(n)),O(g(n)))=O(max(f(n),g(n)))

  3. 乘法法则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积

    如果 T1(n)=O(f(n)),T2(n)=O(g(n));

    那么 T(n)=T1(n)T2(n)=O(f(n))O(g(n))=O(f(n)g(n));

几种常见时间复杂度

按数量级递增:
常量阶 O(1)
对数阶 O(logn)
线性阶 O(n)
线性对数阶 O(nlogn)
平方阶 O(n^2)、立方阶 O(n^3)、...k次方阶 O(n^k)
指数阶 O(2^n)
阶乘阶 O(n!)

对于上述罗列的复杂度量级,可以分为:多项式量级非多项式量级。其中,非多项式量级只有两个:指数阶 O(2n)和阶乘阶 O(n!)。

我们把时间复杂度为非多项式量级的算法问题叫作NP(Non-Deterministic Polynomial,非确定多项式)问题。当数据规模 n 越来越大时,非多项式量级算法的执行时间会急剧增加,求解问题的执行时间会无限增长。所以,非多项式时间复杂度的算法其实是非常低效的算法。

下面结合简单的代码分析几种常见的多项式时间复杂度:

O(1)

public void fun(){//没有入参变量
    int sum = 0;//执行次数:1
    int p = 1;//执行次数:1
    for(; p <= 100; ++p){//执行次数为:100
        sum = sum + p;//执行次数为:100
    }
}

这段代码的时间复杂度为O(1)。这段代码执行了100次,是一个常量的执行时间,跟n的规模无关。即只要代码的执行时间不随n的增长而增长,代码的时间复杂度都记作为O(1)。

O(n)

public void fun(){
    int n = 100;//执行次数:1
    int sum = 0;//执行次数:1
    for(int j = 1; j <= n; ++j){//执行次数:n
        sum += j;//执行次数:n
    }
}

即T(n)=1+1+n+n=2n+2,根据前面说到的算法分析原则,当n趋向于无穷大时,可以忽略低阶项和最高项系数,则时间复杂度为O(n)。

O(logn)、O(nlogn)

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

实际上,我们只需找出循环执行次数最多的代码,求出该代码被执行了多少次,就能知道整段代码的时间复杂度。从代码中可以看出,变量i的值从1开始,每循环一次乘以2,实际上i的取值是一个等比数列。通过2x=n,求出x=log2n,所以这段代码的时间复杂度为O(log2n)。

int i = 1;
while (i <= n) {
    i = i * 3;
}

根据上述思路,得出这段代码的时间复杂度为O(log3n))。

实际上,不管是以 2 为底、以 3 为底,还是以 10 为底,我们可以把所有对数阶的时间复杂度都 记为 O(logn)。

我们知道,对数之间是可以互相转换的,log n 就等于 log 2 log n,所以 O(log n) = O(C log n),其中 C=log 2 是一个常量。基于我们前面的一个理论:在采用大 O 标记复杂度的时 候,可以忽略系数,即 O(Cf(n)) = O(f(n))。所以,O(log n) 就等于 O(log n)。因此,在对数阶 时间复杂度的表示方法里,我们忽略对数的“底”,统一表示为 O(logn)。

如果一段代码的时间复杂度是 O(logn),我们循环执行 n 遍,时间复杂度就是 O(nlogn) 了。而且,O(nlogn) 也是一种非常常见的算法时间复杂度。比如,归并排序、快速排序的时间复杂度都是 O(nlogn)。

O(n2)

public void fun(){
    int n = 100;//执行次数:1
    int sum = 0;//执行次数:1
    for(int i = 1; i <= n; ++i){//执行次数:n
        for(int j = 1; j <= n; ++j){//执行次数n*n
            sum += j;//执行次数n*n
        }
    }
}

根据乘法规则:嵌套代码的复杂度等于嵌套内外代码复杂度的乘积。故时间复杂度为O(n2)。

O(m+n)

public int fun(int m, int n){
    int sum1 = 0;
    int i = 1;
    for(; i < m; ++i){
        sum1 = sum1 + i;
    }
    
    int sum2 = 0;
    int j = 1;
    for(; j < n; ++j){
        sum2 = sum2 + j;
    }
    return sum1 + sum2;
}

从代码中可以看出,m 和 n 是表示两个数据规模。我们无法事先评估 m 和 n 谁的量级大,所以我们在表示复杂度的时候,就不能简单地利用加法法则,省略掉其中一个。所以,上面代码的时间复杂度就是 O(m+n)。

空间复杂度分析

空间复杂度(Space Complexity)是对一个算法在运行过程中临时占用存储空间大小的量度。

算法的空间复杂度通过计算算法所需的存储空间实现,算法的空间复杂度的计算公式记作:S(n)=O(f(n)),其中,n为问题的规模,f(n)为语句关于n所占存储空间的函数。

程序执行时所需存储空间包括以下两部分。  
(1)固定部分。这部分空间的大小与输入/输出的数据的个数多少、数值无关。主要包括指令空间(即代码空间)、数据空间(常量、简单变量)等所占的空间。这部分属于静态空间。
(2)可变空间,这部分空间的主要包括动态分配的空间,以及递归栈所需的空间等。这部分的空间大小与算法有关。

一个算法的空间复杂度只考虑在运行过程中为局部变量分配的存储空间的大小,它包括为参数表中形参变量分配的存储空间和为在函数体中定义的局部变量分配的存储空间两个部分。

public void fun(int n){
    int[] a = new int[n];
    for(int i = 1; i < n; ++i){
        a[i] = i * i;
    }
}

从代码中可以看出,申请了一个空间存储变量i,但是它是常量阶的,跟数据规模n无关。第二行代码为申请了一个大小为n的int类型数组,除此之外,没有占用更多的空间,所以整段代码的空间复杂度就是O(n)。

最好、最坏、平均情况时间复杂度

最好情况时间复杂度:代码在最理想情况下执行的时间复杂度。

最坏情况时间复杂度:代码在最坏情况下执行的时间复杂度。

平均情况时间复杂度:用代码在所有情况下执行的次数的加权平均值表示,简称平均时间复杂度。

public int find(int[] array, int n. int x){
    int index = -1;
    for(int i = 0; i < n; ++i){
        if(array[i] == x){
            index = i;
            break;
        }
    }
    return index;
}

在这段代码中,如果要查找的变量x出现在第一个元素,那就不需要继续遍历剩下的n-1个数据了,那么时间复杂度就是O(1),为最好情况时间复杂度。但如果数组中不存在变量x,那我们就需要把整个数组遍历一遍,时间复杂度就成O(n),为最坏情况时间复杂度。故在不同情况下代码的时间复杂度是不一样的。

最好情况时间复杂度和最坏情况时间复杂度都是在极端情况下的代码时间复杂度,发生的概率不大。为了更好地表示平均情况下的复杂度,我们需要引入一个新的概念:平均时间复杂度

在这段代码中,要查找的变量x在数组中的位置有n+1中情况:在数组的0~n-1位置中和不在数组中。假设在数组中和不在数组中的概率都为1/2;变量x出现在0~n-1这n个位置的概率为1/n,根据概率乘法规则,要查找的数据出现在0~n-1中任意位置的概率为1/(2n)。故平均时间复杂度的计算为:(查找需要遍历的元素个数乘以相应的权术)

$$1*1/(2n)+2*1/(2n)+3*1/(2n)+···+n*1/(2n)+n*1/2=(3n+1)/4$$

这个值为加权平均值,也叫期望值。所以平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度。故这段代码的平均时间复杂度为O(n)。

加权平均值即将各数值乘以相应的权数,然后加总求和得到总体值,再除以总的单位数。

在大多数情况下,我们并不需要区分最好、最坏、平均情况时间复杂度三种情况。我们使用一个复杂度就可以满足需求了。只有同一块代码在不同的情况下,时间复杂度有量级的差距,我们才会使用这三种复杂度表示法来区分。

均摊时间复杂度

对一个数据结构进行一组连续操作中,大部分情况下时间复杂度都很低,只有个别情况下时间复杂度比较高,而且这些操作之间存在前后连贯的时序关系,这个时候,我们就可以将这一组操作放在一块儿分析,看是否能将较高时间复杂度那次操作的耗时,平摊到其他那些时间复杂度比较低的操作上。而且,在能够应用均摊时间复杂度分析的场合,一般均摊时间复杂度就等于最好情况时间复杂度。

请看下面这段代码:

int[] array = new int[10];//声明一个大小为10的数组array
int length = 10;
int i = 0;
//往数组里添加一个元素
public void addElement(int element){
    if(i > = len){
        int[] new_array = new int[len*2];
        for(int j = 0; j < len; ++j){
            //复制数据到new_array数组中
            new_array[j] = array[j];
        }
        array = new_array;
        len = len * 2;
    }
    //将element插入到下标为i的位置
    array[i] = element;
    ++i;
}

在这段代码中,当i<len时,不走for循环,所以时间复杂度为O(1);

当i>=len时,即当i=n时,for循环进行数组的复制,只有这一次的时间复杂度为O(n)。

由此可知:

  • 该算法的最好情况时间复杂度为O(1);
  • 最坏情况时间复杂度为O(n);
  • 平均情况时间复杂度为:

    $$1*1/(n+1)+1*1/(n+1)+···+1*1/(n+1)+n*1/(n+1)=2n/(n+1)$$

    故平均时间复杂度为O(1)。

  • 均摊复杂度:前n个操作复杂度都是O(1),第n+1次操作的复杂度是O (n),所以把最后一次的复杂度分摊到前n次上,那么均摊下来每次操作的复杂度为O(1)。

总结:渐进式时间,空间复杂度分析只是一个理论模型,只能提供给粗略的估计分析,一个低阶的时间复杂度程序有极大的可能性会优于一个高阶的时间复杂度程序,针对不同的宿主环境,不同的数据集,不同的数据量的大小,在实际应用上面可能真正的性能会不同。针对不同的实际情况, 进而进行一定的性能基准测试是很有必要的,进而选择适合特定应用场景下的最优算法。

文章同步在微信公众号,习惯微信上看文章的可以关注微信公众号:加二减壹

相关推荐