算法复杂度

算法复杂度分为时间复杂度和空间复杂度。其作用:

  • 时间复杂度是指执行这个算法所需要的计算工作量;
  • 空间复杂度是指执行这个算法所需要的内存空间;

时间复杂度基础概念

在计算机科学中,算法的时间复杂度(Time complexity)是一个函数,它定性描述该算法的运行时间。这是一个代表算法输入值的字符串的长度的函数。时间复杂度常用大O符号表述,不包括这个函数的低阶项和首项系数。使用这种方式时,时间复杂度可被称为是渐近的,亦即考察输入值大小趋近无穷时的情况。

算法运算次数

我们假设计算机运行一行基础代码需要执行一次运算。

1
2
3
4
int aFunc(void) {
    printf("Hello, World!\n");      //  需要执行 1 次
    return 0;       // 需要执行 1 次
}

那么上面这个方法需要执行 2 次运算

1
2
3
4
5
6
int aFunc(int n) {
    for(int i = 0; i<n; i++) {         // 需要执行 (n + 1) 次
        printf("Hello, World!\n");      // 需要执行 n 次
    }
    return 0;       // 需要执行 1 次
}

这个方法需要 (n + 1 + n + 1) = 2n + 2 次运算。

我们把 算法需要执行的运算次数 用 输入大小n 的函数 表示,即 T(n) 。

由算法运算次数推出时间复杂度

  1. 我们知道常数项对函数的增长速度影响并不大,所以当 T(n) = c,c 为一个常数的时候,我们说这个算法的时间复杂度为 O(1);如果 T(n) 不等于一个常数项时,直接将常数项省略。

    1
    2
    3
    
    比如
    第一个 Hello, World 的例子中 T(n) = 2,所以我们说那个函数(算法)的时间复杂度为 O(1)
    T(n) = n + 29,此时时间复杂度为 O(n)
    
  2. 我们知道高次项对于函数的增长速度的影响是最大的。n^3 的增长速度是远超 n^2 的,同时 n^2 的增长速度是远超 n 的。 同时因为要求的精度不高,所以我们直接忽略低此项。

    1
    2
    
    比如
    T(n) = n^3 + n^2 + 29,此时时间复杂度为 O(n^3)
    
  3. 因为函数的阶数对函数的增长速度的影响是最显著的,所以我们忽略与最高阶相乘的常数。

    1
    2
    
    比如
    T(n) = 3n^3,此时时间复杂度为 O(n^3)
    

    综合起来:如果一个算法的执行次数是 T(n),那么只保留最高次项,同时忽略最高项的系数后得到函数 f(n),此时算法的时间复杂度就是 O(f(n))。

四个便利的法则/案例

  1. 对于一个循环,假设循环体的时间复杂度为 O(n),循环次数为 m,则这个循环的时间复杂度为 O(n×m)。

    1
    2
    3
    4
    5
    
    void aFunc(int n) {
        for(int i = 0; i < n; i++) {         // 循环次数为 n
            printf("Hello, World!\n");      // 循环体时间复杂度为 O(1)
        }
    }
    

    此时时间复杂度为 O(n × 1),即 O(n)。

  2. 对于多个循环,假设循环体的时间复杂度为 O(n),各个循环的循环次数分别是a, b, c…,则这个循环的时间复杂度为 O(n×a×b×c…)。分析的时候应该由里向外分析这些循环。

    1
    2
    3
    4
    5
    6
    7
    
    void aFunc(int n) {
        for(int i = 0; i < n; i++) {         // 循环次数为 n
            for(int j = 0; j < n; j++) {       // 循环次数为 n
                printf("Hello, World!\n");      // 循环体时间复杂度为 O(1)
            }
        }
    }
    

    此时时间复杂度为 O(n × n × 1),即 O(n^2)。

  3. 对于顺序执行的语句或者算法,总的时间复杂度等于其中最大的时间复杂度。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    
    void aFunc(int n) {
        // 第一部分时间复杂度为 O(n^2)
        for(int i = 0; i < n; i++) {
            for(int j = 0; j < n; j++) {
                printf("Hello, World!\n");
            }
        }
        // 第二部分时间复杂度为 O(n)
        for(int j = 0; j < n; j++) {
            printf("Hello, World!\n");
        }
    }
    

    此时时间复杂度为 max(O(n^2), O(n)),即 O(n^2)。

  4. 对于条件判断语句,总的时间复杂度等于其中 时间复杂度最大的路径 的时间复杂度。

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    15
    
    void aFunc(int n) {
        if (n >= 0) {
            // 第一条路径时间复杂度为 O(n^2)
            for(int i = 0; i < n; i++) {
                for(int j = 0; j < n; j++) {
                    printf("输入数据大于等于零\n");
                }
            }
        } else {
            // 第二条路径时间复杂度为 O(n)
            for(int j = 0; j < n; j++) {
                printf("输入数据小于零\n");
            }
        }
    }
    

    此时时间复杂度为 max(O(n^2), O(n)),即 O(n^2)。

常见的时间复杂度

  • O(1)—常数阶:最低的时空复杂度,也就是耗时与输入数据大小无关,无论输入数据增大多少倍,耗时/耗空间都不变。哈希算法就是典型的O(1)时间复杂度,无论数据规模多大,都可以在一次计算后找到目标。
  • O(n)—线性阶,就代表数据量增大几倍,耗时也增大几倍。比如常见的遍历算法。
  • O(n^2)—平方阶, 就代表数据量增大n倍时,耗时增大n的平方倍,这是比线性更高的时间复杂度。比如冒泡排序,就是典型的O(n x n)的算法,对n个数排序,需要扫描n x n次。
  • O(logn)—对数阶,当数据增大n倍时,耗时增大logn倍(这里的log是以2为底的,比如,当数据增大256倍时,耗时只增大8倍,是比线性还要低的时间复杂度)。二分查找就是O(logn)的算法,每找一次排除一半的可能,256个数据中查找只要找8次就可以找到目标。
  • O(nlogn)—线性对数阶,就是n乘以logn,当数据增大256倍时,耗时增大256*8=2048倍。这个复杂度高于线性低于平方。归并排序就是O(nlogn)的时间复杂度。

o(logn)

我们都知道二分查找在最坏的情况下依次是n/2,n/4,n/8。。。。一直到1为止。

然后,意思就是要循环多少次才能查找到目标数呢,我们假设是x次。

然后我们可以观察到分母是每次都乘以1/2,分子不变,所以可以根据题意列出下面等式:

n*(1/2)^x = 1

运算一下,就是:2^x = n

再运算一下,就是::log2(n) = x

最后对数函数的底数省略掉,也就是:log(n) = x

常见时间复杂度大小排序

O(1)<O(logn)<O(n)<O(nlogn)<O(n^2)<O(n^3)

o(logn), o(nlogn), 这里的 log 是以 2 为底的

如果 img ,即 a的x次方(或a的幂函数) 等于N(a>0,且a≠1),那么数x叫做以a为底N的对数(logarithm),记作 img。其中,a叫做对数的底数,N叫做真数,x叫做“以a为底N的对数”。

Last but not least

时间复杂度分析的基本策略是:从内向外分析,从最深层开始分析。如果遇到函数调用,要深入函数进行分析。

参考文章