时间与空间复杂度

269 阅读9分钟

这是我参与8月更文挑战的第31天,活动详情查看:8月更文挑战


在《数据结构和算法概述》中有说过,算法的设计要求其中有两点是:时间效率高、存储量低。 ​

判断一个算法的时间效率的高低可以看它的「时间复杂度」,存储量的高低可以看它的「空间复杂度」。 ​

1. 时间复杂度

在计算机科学中,算法的时间复杂度(Time complexity)是一个函数,它定性描述该算法的运行时间。

1.1 度量方法

算法的时间复杂度该如何度量呢?有哪些法则可以参考,以此来判断算法的时间效率的高低。 ​

1.1.1 事后统计法

判断一个算法时间效率的高低,可能最先想到的就是执行一遍,看看它消耗了多长时间。这种通过事先设计好的测试程序和数据,通过计算机执行算法来根据执行时间判断时间效率的方式也被称作「事后统计法」。 ​

事后统计法」存在以下问题:

  1. 编写测试程序和造测试数据需要花费一定的时间。
  2. 算法的执行时间依赖于计算机硬件和运行环境,结果可能存在差异。
  3. 算法的测试数据设计比较困难。

如果花费了大量的时间和精力做测试,结果发现算法的时间效率太低,那就是竹篮打水一场空了。因此,一般很少采用这种方式来评估。 ​

1.1.2 事前分析法

计算机前辈们,为了对算法的时间效率的评判更加的科学,研究除了一种事前分析估算的方法。 ​

事前分析法:在计算机程序编制前,依据统计方法对算法进行估算。

高级程序语言编写的程序在计算机上运行所消耗的时间取决于四大因素:

  1. 算法采用的策略、方法。
  2. 编译器产生的代码质量。
  3. 问题的输入规模。
  4. 机器执行指令的速度。

第2条由编译器决定,第4条由计算机硬件决定。因此算法的好坏依赖于其采用的策略和问题的输入规模。 ​

何为输入规模? 输入规模指算法需要处理的数据输入量。例如一个算法的功能是计算1到N的和,N就代表了问题的输入规模。 ​

随着输入规模越来越大,不同算法在时间效率上的差异也会越来越大。 ​

1.2 大O表示法

在进行算法分析时,语句总的执行次数T(n)是关于问题规模n的函数,进而分析T(n)随n的变化情况并确定T(n)的数量级。算法的时间复杂度,也就是算法的时间度量,记作:T(n) = O(f(n))。它表示随着问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐进时间复杂度,简称为时间复杂度。其中f(n)是问题规模n的某个函数。

T(n) = O(f(n))这样用大写的O来表示算法的时间复杂度的方法,称作「大O表示法」。一般来说,随着规模n的增大,T(n)增长最慢的算法为最优算法。 ​

如何推导大O阶?

  1. 用常数1取代所有的加法常数。
  2. 然后,只保留最高阶项。
  3. 如果最高阶项不是1,则去除这个项相乘的常数。

经过以上三分步骤的推导之后,得到的结果就是大O阶! ​

1.3 常见的时间复杂度

列举一些常见的时间复杂度和算法示例,感受一下。

术语
O(1)常数阶
O(n)线性阶
O(n^2)平方阶
O(n^3)立方阶
O(log^n)对数阶
O(nlog^n)nlogn阶
O(2^n)指数阶
O(n!)阶乘阶

各复杂度的时间效率如下所示: image.png

1.3.1 常数阶O(1)

如下是求数字1到n的总和算法,它无需从1挨个加到n,使用的是高斯求和公式。

// 高斯求和
public int gaoSiCount(int n) {
    int sum = n + 1;// 执行一次
    sum = n * sum;// 执行一次
    sum = sum / 2;// 执行一次
    return sum;
}

我们按照上述的步骤来推导该算法的大O阶,如下:

  1. f(n) = 3
  2. 用1取代所有常数,因此:f(n) = 1
  3. 无最高阶,因此:T(n) = O(f(n)) = O(1)

不管规模n多大,算法的时间复杂度始终是O(1)。

1.3.2 线性阶O(n)

如下是求数字1到n的总和算法,它使用的是最笨的累加发。

// 求 1到n 的和
public int count(int n) {
    int sum = 0;// 执行一次
    for (int i = 1; i <= n; i++) {// 执行n次
        sum += i;// 执行n次
    }
    return sum;
}

我们按照上述的步骤来推导该算法的大O阶,如下:

  1. f(n) = 1+2n
  2. 只保留最高阶,因此:f(n) = 2n
  3. 除最高阶项相乘的常数,因此:f(n) = n
  4. T(n) = O(f(n)) = O(n)

综上所述,该求和算法的时间复杂度为O(n),随着问题规模n的增大,算法的时间消耗也会越来越大。 ​

1.3.3 平方阶O(n^2)

如下算法,它会打印一个n排n列的星星矩阵。

// 打印一个 n*n 的矩阵
public void printfMatrix(int n) {
    for (int i = 0; i < n; i++) {// 执行 n 次
        for (int j = 0; j < n; j++) {
            System.out.print("* ");// 执行 n*n 次
        }
        System.out.println();// 执行 n 次
    }
}

我们按照上述的步骤来推导该算法的大O阶,如下:

  1. f(n) = n^2 + 2n
  2. 保留最高阶,f(n) = n^2
  3. 最高阶项是1,因此:T(n) = O(f(n)) = O(n^2)

1.3.4 立方阶O(n^3)

平方阶是双层循环,立方阶是三层循环,这里不再举例。

1.3.5 对数阶O(log^n)

如下算法,给定一个长度为n的有序数组arr,查找数字num在数组中的下标,不存在则返回-1。

// 给定一个有序数组arr,查找num所在的索引,不存在返回-1
public int binarySearch(int[] arr, int n, int num) {
    int begin = 0;// 执行一次
    int end = n - 1;// 执行一次
    while (begin <= end) {// 执行 2^x=n 次,x = log^n
        int middle = (begin + end) / 2;
        int i = arr[middle];
        if (i == num) {
            return middle;
        }
        if (i > num) {
            end = middle - 1;
        } else {
            begin = middle + 1;
        }
    }
    return -1;
}

这个算法有点长,我们直接忽略常数加法项,只看while循环的次数。由于采用的是折半查找算法,因此只需要查找2^x=n,即x=log^n

在计算机科学中,若无特殊说明,对数log均以2为底。

忽略掉常数加法项后,时间复杂度为T(n) = O(f(n)) = O(log^n)。

1.3.6 指数阶O(2^n)

如下算法,会返回斐波那契数列的第n个数字。

// 斐波那契数列的第n位的值
public int fibonacciSequence(int n) {
    if (n == 0) {
        return 0;
    }
    if (n == 1) {
        return 1;
    }
    return fibonacciSequence(n - 1) + fibonacciSequence(n - 2);
}

斐波那契数列的特点就是第n项是第n-1项与第n-2项之和,因此采用递归的方式,不断的去计算前两个数,直到计算出第n个斐波那契数。 ​

该算法的时间复杂度为O(2^n),时间效率非常的低,当n为40时,计算就已经较为缓慢了,在实际应用中,要极力避免写出这种算法。

1.4 极端、平均情况

对于一些像查找类的算法,可能会存在一些极端情况,运气好的时候可能一下就找到了,运气差的时候要遍历完所有的数据才找到,这就要分析算法的最好和最坏时间复杂度了。最坏时间复杂度是一种保证,它代表算法的执行效率不会再低了,若无特殊说明,一般说的时间复杂度都指最坏时间复杂度。 ​

算法运气不会一直好,也不会一直差,大多数时候还是一个中间状态,因此算法的平均时间复杂度是最有参考价值的,它代表了算法期望的运行时间。

1.4.1 最好或最坏时间复杂度

假如我现在要从一个长度为n的随机数组int[] arr中查找数字num所在的下标,我直接遍历整个数组,从下标为0开始一直找到末尾。那么最好的情况就是第一次就找到了,时间复杂度是O(1),最坏的情况就是没找到这个数,时间复杂度为O(n)。

对于这种情况,我们一般说它的时间复杂度就是O(n)。这是一种保证,算法的执行效率不会比这个再低了。

1.4.2 平均时间复杂度

平均时间复杂度,又称「加权平均时间复杂度」,为什么要加权?因为每一种情况它的概率都是不一样的,需要将概率也作为计算的一个参数。 ​

平均时间复杂度参考价值比较大,它没有最好时间复杂度那么幸运,也没有最坏时间复杂度那么倒霉,它代表了算法期望的运行时间。 ​

如下算法,查找数字num在数组arr中的下标。

/**
 * 查找num在arr中的下标
 * @param arr 数组
 * @param n 数组长度
 * @param num 要查找的数
 * @return
 */
public int find(int[] arr, int n, int num) {
	for (int i = 0; i < n; i++) {
		if (arr[i] == num) {
			return i;
		}
	}
	return -1;
}

对于问题规模n,假设它需要查找X次才能找到,那么X可能有:

1234、...、n、n(没找到的情况)

一共有n+1种可能,因此它的平均时间复杂度为: image.png 根据大O阶的推导方法,去掉常数、系数、保留最高阶,最终结果为O(n)。

1.4.3 均摊时间复杂度

均摊时间复杂度的使用场景非常特殊和有限,它针对那些大部分情况下时间复杂度都很低,只有个别情况下时间复杂度很高,而且这种情况有一定的规律和时序关系。 ​

如下算法会将数字n插入到数组arr的下标i处,平常的时间复杂度都是O(1),一旦数组空间不够了需要扩容,则时间复杂度会上升为O(n)。而且O(n)是有规律的,每次数组都成倍扩容,下次填满了才会又一次O(n)。

/**
 * 将数字n插入到数组arr的下标i处
 * @param arr
 * @param i
 * @param n
 * @return
 */
public int[] add(int[] arr, int i, int n) {
	if (arr.length <= i) {
		// 扩容
		int[] newArr = new int[arr.length << 1];
		for (int j = 0; j < arr.length; j++) {
			newArr[j] = arr[j];
		}
		arr = newArr;
	}
	arr[i] = n;
	return arr;
}

对于问题规模n,前n次操作的时间复杂度都是O(1),第n+1次扩容时间复杂度上升为O(n),因此均摊时间复杂度为: image.png 根据大O阶推导,去掉常数、系数、保留最高阶,均摊时间负载为O(1)。 ​

均摊时间复杂度适用于:大多数操作时间复杂度都很低,只有个别情况下时间复杂度高的场景。它可以将时间复杂度高的那次操作平摊到其他时间复杂度低的操作上,总体上看时间复杂度依然很低。

2. 空间复杂度

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

空间复杂度说明了算法在执行过程中,所消耗的空间大小,这里所指的空间一般是指内存。大多数情况下,我们在编写算法时,完全可以利用空间来换取时间。例如上面的「计算斐波那契数列的第n个数」例子中,可以事先就计算好并保存到数组中,计算时直接从数组中取对应下标的值就好了,时间复杂度直接从指数阶降到常数阶。

2.1 常数阶O(1)

如下算法,判断给定的年份是否是闰年。

// 判断year是否是闰年
public boolean isLeapYear(int year) {
    if (year % 400 == 0) {
        return true;
    }
    return year % 4 == 0 && year % 100 != 0;
}

它的空间复杂度很低为O(1),缺点是每次都需要进行计算。 ​

2.2 线性阶O(n)

如下算法,还是判断给定的年份是否是闰年。但是它有别于前一个例子,它默认会计算五千个年份然后缓存到数组中,判断时直接从数组中取对应下标的值即可。

public class LeapYear {
    static final boolean[] arr = new boolean[5000];

    static {
        // 事先计算好五千年
        for (int i = 0; i < arr.length; i++) {
            arr[i] = calcLeapYear(i);
        }
    }

    public boolean isLeapYear(int year) {
        return arr[year];
    }

    private static boolean calcLeapYear(int year) {
        if (year % 400 == 0) {
            return true;
        }
        return year % 4 == 0 && year % 100 != 0;
    }
}

该算法很明显,就是利用空间换取时间,空间复杂度虽然上升到了O(n),但是不用频繁计算了,在内存越来越廉价的今天,这样做也许是值得的。 ​

3. 总结

算法时间复杂度的度量方法分为事后统计和事前分析,事后统计费时费力可能结果还不准确,一般采用事前分析。最常用的就是大O表示法,它用来分析随着问题规模n的变化情况来确定T(n)的数量级,最优时间复杂度就是O(1),对数阶、线性阶也很常见,但是要极力避免指数阶和阶乘阶。 ​

空间复杂度用来表示算法在执行过程中所消耗的存储空间,鱼和熊掌不可兼得,多数情况下可以用空间来换取时间。开发者需要权衡空间和时间两者的权重,以此来选择更合适的算法。

大O阶的推导过程本身并不复杂,难点在于程序员对数学知识的掌握程度,笔者数学忘的差不多了,借着本篇文章再复习了一下,惭愧惭愧。