数据结构:算法的分析方法

著名计算机科学家沃斯(Wirth)教授曾提出:数据结构 + 算法 = 程序。

简单来说,程序设计的实质就是:针对实际问题,选择一种好的数据结构,并在此结构基础上施加一种好的算法。

一个“好”的程序,需要具备两个特性:“快”和“省”。即代码不仅运行快,而且还省存储空间。这也就是说,执行效率是衡量算法的主要指标。

那么,该如何衡量一个算法的执行效率呢?这就是本篇文章要讨论的内容:时间和空间复杂度分析。

一、时间复杂度分析

时间复杂度,也称渐进时间复杂度(asymptotic time complexity),它表示的是算法的执行时间与数据规模之间的增长关系。通常用大O表示:

T(n)=O(f(n))T(n)=O(f(n))

其中,n是数据规模,f(n)表示代码的执行次数,T(n)表示代码的执行时间,O表示代码的执行时间T(n)与代码的执行次数f(n)成正比。

1.1、分析方法

表示方法有了,具体该如何分析一段代码的时间复杂度呢? 有一个比较实用的分析法则,关注执行次数最多的一段代码。下面我们来看几个具体示例。

1.1.1、示例1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(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;
}

return sum_1 + sum_2;
}

在这段代码中,for循环前的赋值语句都是常量级的执行时间,并不会影响时间复杂度。第一个for循环,执行了100次,与n的规模无关,所以也是一个常量级的执行时间。需要重点关注的是第二个for循环,因为它的执行次数最多,且与n的大小有关,所以这段代码的时间复杂度就是O(n)。

这里我们要知道的是,大O时间复杂度表示法,描述的是代码执行时间随数据规模增长的变化趋势。在实际使用中,通常会忽略掉常量、低阶和系数这些不左右增长趋势的部分,只需要记录一个最大阶的量级。所以,在分析一个算法(或一段代码)时,只需要关注执行次数最多的一段代码。

1.1.2、示例2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
int main(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;
}

在这段代码中,第一个for循环,执行了100次,与n的规模无关,所以是一个常量级的执行时间;第二个for循环,执行次数与n有关,其时间复杂度为O(n)。第三个for循环,执行次数与n有关,其时间复杂度为O(n^2^)。综合整这三个for循环的时间复杂度,取其中最大的量级。所以,整段代码的时间复杂度就为 O(n^2^)。也就是说:代码的复杂度就等于量级最大的那段代码的时间复杂度。

这里我们需要注意的是,不管常量的执行次数多大,都可以忽略掉,因为它本身对增长趋势并没有影响。比如,在第一个for循环中,即便循环了10000 次、100000 次,只要是一个已知的数,跟 n 无关,照样也是常量级的执行时间。

1.1.3、示例3

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int f(int n) {
int sum = 0;
int i = 1;
for (; i < n; ++i) {
sum = sum + i;
}
return sum;
}

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

在这段代码中,函数main调用了函数f,如果函数f只是一个复杂度为O(1)的简单函数,那么这段代码的时间复杂度就是O(n)。但实际上,函数f是一个复杂度为O(n)的函数,所以这段代码的时间复杂度应该是O(n^2^),也就是嵌套内外代码复杂度的乘积。

1.2、常见的复杂度量级

在实际应用中,虽然代码千差万别,但常见的复杂度量级并不多。无外乎以下几种(按量级递增):

  • 常量阶O(1)
  • 对数阶O(logn)
  • 线性阶O(n)
  • 线性对数阶O(nlogn)
  • 次方阶O(n^2^)、O(n^3^)、···
  • 指数阶O(2^n^)
  • 阶乘阶O(n!)

这些复杂度量级,可粗略地分为两类:多项式量级和非多项式量级。其中,非多项式量级只有两个:O(2^n^)和O(n!)。我们把时间复杂度为非多项式量级的算法问题叫作NP(Non-Deterministic Polynomial,非确定多项式)问题。当数据规模n越来越大时,非多项式量级算法的执行时间会急剧增加,也即代码的执行时间会无限增长。因此,可以说非多项式时间复杂度的算法其实是非常低效的算法。

1.2.1、O(1)

对于复杂度O(1),我们需要知道的是,它只是常量级时间复杂度的一种表示方法,并非指只执行了一行代码。比如:

1
2
3
int i = 8;
int j = 6;
int sum = i + j;

这段代码虽然有3行,但它的时间复杂度是O(1),而不是O(3)。可以这样说,只要代码的执行时间不随数据规模n的增大而增长,那么代码的时间复杂度都记作O(1)。或者说,一般情况下,只要代码中不存在循环语句、递归语句,即使有成千上万行的代码,其时间复杂度就是O(1)。

1.2.2、O(lognlog_n)、O(nlognnlog_n)

O(logn)和O(nlogn)都属于对数阶时间复杂度。对数阶时间复杂度是最常见的一种时间复杂度,同时也是最难分析的一种时间复杂度。下面,我们通过几段简单代码,来熟悉如何分析此类时间复杂度。

首先,我们来看一个简单的例子:

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

在这段代码中,while循环的执行次数最多。因此,只要计算出这行代码被执行了多少次,就能知道整段代码的时间复杂度。从代码中,我们可以看出,变量i的值从1开始取,每循环一次就乘以2。当i大于n时,循环结束。如果把循环过程列出来,应该是这个样子的:

1
1 * 2 * 2 * ··· * 2 > n

可以看出,变量i的取值会构成一个等比数列。循环次数与n的关系为:

2(循环次数1)>n等价于2x>n2^{(循环次数-1)} > n 等价于 2^x > n

由此可知,这段代码的执行次数就是x的取值。而x=log2nlog_2 n,所以,这段代码的时间复杂度就是O()log2nlog_2 n

然后,我们再来分析下面这段代码:

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

根据前面的分析,我们可以很快得出,这段代码的时间复杂度是O(log3nlog_3 n)。

这里,我们需要知道的是,不管是以2为底,还是以10为底,对于对数阶的时间复杂度,我们都记作O(lognlog_n)。而这又是为什么呢?

因为在采用大O表示法标记时间复杂度时,可以忽略系数,即O(C * f(n)) = O(f(n))。而我们知道,对数之间是可以互相转换的,比如log3nlog_3 n 就等于log32log_3 2 * log2nlog_2 n。所以O(log3nlog_3 n) = O(C * log2nlog_2 n),其中C = log32log_3 2是一个常量,可以忽略,也就是O(log3nlog_3 n) = O(log2nlog_2 n)。因此,在表示对数阶时间复杂度时,可以忽略对数的“底”,统一表示为O(lognlog_n)。

理解了O(lognlog_n),那O(nlognlog_n)就很容易理解了。在前面我们说过,嵌套代码的复杂度等于嵌套内外代码复杂度的乘积。如果一段代码的时间复杂度为O(lognlog_n),把这段代码循环n遍,其时间复杂度就是O(nlognlog_n)了。常用的排序算法,比如归并排序、快速排序等的时间复杂度都是O(nlognlog_n)。

1.2.3、O(m+n)、O(m*n)

在有些情况下,我们不能直接使用前面提到的分析方法。比如,下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(int m, int n) {
int sum_1 = 0;
int i = 1;
for (; i < m; ++i) {
sum_1 = sum_1 + i;
}

int sum_2 = 0;
int j = 1;
for (; j < n; ++j) {
sum_2 = sum_2 + j;
}

return sum_1 + sum_2;
}

在这段代码中,时间复杂度由两个数据规模m和n来决定。因为我们无法事先评估m和n谁的量级大,所以不能直接使用前面的分析方法。也就是说,这段代码的时间复杂度,即不是O(m),也不是O(n),而是O(m + n)。

需要注意的是,如果在一个算法中,两个数据规模不同的代码之间是嵌套关系,那么乘法法则依然有效。

1.3、分析方法进阶

掌握了前面的内容,就能轻松分析出多数代码的时间复杂度。然而,仅仅掌握这些知识是不够的。比如,下面这段代码:

1
2
3
4
5
6
7
8
9
10
11
12
// n表示数组array的长度
int find(int[] array, int n, int x) {
int i = 0;
int pos = -1;
for (; i < n; ++i) {
if (array[i] == x) {
pos = i;
break;
}
}
return pos;
}

这段代码的功能是,在一个无序数组中,查找变量x出现的位置。如果没有找到,就返回-1。此时,问题来了,这段代码的时间复杂度是多少?是O(1)?还是O(n)?我们该如何分析它?

显然,这段代码的时间复杂度无法用之前提到的方法来分析。因为,要查找的变量x可能出现在数组的任意位置。如果数组中第一个元素正好是要查找的变量x,那就不需要继续遍历剩下的n-1个数据了,此时的时间复杂度就是O(1)。但如果数组中不存在变量x,那我们就需要把整个数组遍历一遍,此时的时间复杂度就成了O(n)。所以,在不同的情况下,这段代码的时间复杂度是不一样的。

为了表示在不同情况下代码的时间复杂度,我们需要引入三个概念:最好、最坏情况时间复杂度,平均情况时间复杂度和均摊时间复杂度。

1.3.1、最好、最坏情况时间复杂度

顾名思义,最好情况时间复杂度,是指在最理想的情况下,代码的时间复杂度。比如,在上面的find函数中,最理想的情况是,要查找的变量x正好是数组的第一个元素,此时的时间复杂度就是最好情况时间复杂度为O(1)。

同理,最坏情况时间复杂度,是指在最糟糕的情况下,代码的时间复杂度。我们还是以上面的find函数为例,其最糟糕的情况是,数组中没有要查找的变量x,需要把整个数组遍历一遍,此时的时间复杂度就是最坏情况时间复杂度为O(n)。

这里,我们需要知道的是,最好情况时间复杂度和最坏情况时间复杂度,都是极端情况下的时间复杂度,发生的概率其实并不大,不具备代表性。为了更好地表示普遍情况下的时间复杂度,需要引入另一个概念:平均情况时间复杂度。

1.3.2、平均情况时间复杂度

平均情况时间复杂度,简称为平均时间复杂度。这里我们先不解释它的具体含义,直接以前面的find函数为例,熟悉如何分析代码的平均时间复杂度。

我们知道,要查找的变量x在数组中的位置,一共会有n+1种情况:x不在数组中和x在数组0~n-1的某个位置上。把每种情况下查找需要遍历的元素个数累加起来,然后再除以n+1,就可以得到需要遍历的元素个数的平均值,即:

1+2+3+n++n+nn+1=n(n+3)2(n+1)\frac{1+2+3+n+···+n+n}{n+1}=\frac{n(n+3)}{2(n+1)}

我们知道,在大O表示法中,可以省略掉系数、低阶和常量。所以,把这个公式简化之后,得到的平均时间复杂度是O(n)。虽然这个计算过程没有问题,但其结论并不准确。因为上述的n+1种情况出现的概率是不一样的。所以,在分析时,需要考虑每种情况出现的概率。

我们知道,要查找的变量x,那么在数组中,要么不在数组中。这两种情况下的概率统计起来很麻烦,为了方便理解,我们假设其概率都为12\frac{1}{2}。而要查找的数据出现在0~n-1这n个位置的概率是一样的,为1n\frac{1}{n}。因此,根据概率乘法法则,要查找的数据出现在0~n-1中任意位置的概率为12n\frac{1}{2n}。综上,在考虑了每种情况发生的概率后,平均时间复杂度为:

112n+212n+312n++n12n+n12=3n+141*\frac{1}{2n}+2*\frac{1}{2n}+3*\frac{1}{2n}+···+n*\frac{1}{2n}+n*\frac{1}{2}=\frac{3n+1}{4}

这个值就是概率论中的加权平均值,也叫作期望值,所以平均时间复杂度的全称应该叫加权平均时间复杂度或者期望时间复杂度。引入概率之后,前面那段代码的加权平均值为 3n+14\frac{3n+1}{4}。用大 O 表示法来表示,去掉系数和常量,这段代码的加权平均时间复杂度仍然是 O(n)。

实际上,在大多数情况下,我们并不需要区分最好、最坏、平均情况时间复杂度三种情况。只有当同一块代码在输入不同的情况下,时间复杂度有量级差距时,才会用这三种复杂度表示法来区分。

1.3.3、均摊时间复杂度

除了上述的最好、最坏、平均三种复杂度之外,其实还有一种时间复杂度:均摊时间复杂度。最好、最坏和平均复杂度只在某些特殊情况下才会用到,而均摊时间复杂度的应用场景则更加特殊和有限。话不多说,我们还是直接看代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// array表示一个长度为n的数组
// 代码中的array.length就等于n
int[] array = new int[n];
int count = 0;

void insert(int val) {
if (count == array.length) {
int sum = 0;
for (int i = 0; i < array.length; ++i) {
sum = sum + array[i];
}
array[0] = sum;
count = 1;
}

array[count] = val;
++count;
}

这段代码实现了往一个数组中插入数据的功能。当数组满了(即count == array.length 时)之后,用 for 循环遍历数组求和,并清空数组,将求和之后的 sum 值放到数组的第一个位置,然后再将新的数据插入。但如果数组一开始就有空闲空间,则直接将数据插入数组。那这段代码的时间复杂度是多少呢?

最好的情况下,数组中有空闲空间,我们只需要将数据插入到数组下标为 count 的位置就可以了,所以最好情况时间复杂度为 O(1)。

最坏的情况下,数组中没有空闲空间了,我们需要先做一次数组的遍历求和,然后再将数据插入,所以最坏情况时间复杂度为 O(n)。

那平均时间复杂度是多少呢?答案是 O(1)。我们还是可以通过概率论的方法来分析。假设数组的长度是 n,根据数据插入的位置的不同,我们可以分为 n 种情况,每种情况的时间复杂度是 O(1)。除此之外,还有一种“额外”的情况,就是在数组没有空闲空间时插入一个数据,这个时候的时间复杂度是 O(n)。而且,这 n+1 种情况发生的概率一样,都是 1n+1\frac{1}{n+1}。所以,根据加权平均的计算方法,我们求得的平均时间复杂度就是:

11n+1+11n+1++11n+1+n1n+1=O(1)1*\frac{1}{n+1} + 1*\frac{1}{n+1} +···+ 1*\frac{1}{n+1} + n*\frac{1}{n+1}= O(1)

但其实这个例子里的平均复杂度分析并不需要这么复杂,可以不引入概率论的知识。如果对比一下这个 insert() 的例子和前面的find() 例子,我们就会发现这两者有很大差别。

  • 首先,find() 函数在极端情况下,复杂度才为 O(1)。但 insert() 在大部分情况下,时间复杂度都为 O(1)。只有个别情况下,复杂度才比较高,为 O(n)。这是 insert()区别于 find() 的第一个地方。

  • 第二个不同的地方是,对于 insert() 函数来说,O(1) 时间复杂度的插入和 O(n) 时间复杂度的插入,出现的频率是非常有规律的,而且有一定的时间前后关系,一般都是在一个 O(n) 插入之后,紧跟着 n-1 个 O(1) 的插入操作,循环往复。

针对这样一种特殊场景的复杂度分析,可以不用平均复杂度分析法,而是用一种更加简单的分析方法:摊还分析法。通过摊还分析得到的时间复杂度,我们将其称之为均摊时间复杂度。那究竟如何使用摊还分析法来分析算法的均摊时间复杂度呢?

以上面的insert函数为例。每一次 O(n) 的插入操作,都会跟着 n-1 次 O(1) 的插入操作,所以把耗时多的那次操作均摊到接下来的 n-1 次耗时少的操作上,均摊下来,这一组连续的操作的均摊时间复杂度就是 O(1)。这就是均摊分析的大致思路。

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

最后,我们需要知道的是,均摊时间复杂度和摊还分析的应用场景比较特殊,并不会经常用到。

二、空间复杂度分析

空间复杂度,也称渐进空间复杂度(asymptotic space complexity),它表示的是算法的存储空间与数据规模之间的增长关系。用大O表示就是:

S(n)=O(f(n))S(n)=O(f(n))

一般情况下,一个程序在计算机上执行时,所占用的存储空间通常由三部分组成:算法本身所占用的存储空间、算法的输入输出数据所占用的存储空间和算法在运行过程中临时占用的存储空间。

在分析算法的空间复杂度时,只需关注算法在运行过程中临时占用的存储空间。这是因为,算法本身所占用的存储空间是很有限的,基本可以忽略不计,而输入输出数据所占用的存储空间取决于问题本身,与算法无关,所以也无需考虑。如果算法在运行过程中临时占用的存储空间相对于输入数据量而言是一个常数,则称这个算法为原地算法,其空间复杂度记作O(1)。下面我们来看一个简单示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 算法1
for(i=0;i<n/2;i++) {
t=a[i];
a[i]=a[n-i-1];
a[n-i-1]=t;
}

// 算法2
for(i=0;i<n;i++) {
b[i]=a[n-i-1];
}
for(i=0;i<n;i++) {
a[i]=b[i]
}

上述这两个算法实现的功能是相同的,将数组a中的n个数逆序存放到原数组中。其中,算法1仅需借助一个临时变量t,与问题规模n的大小无关,所以其空间复杂度为O(1);算法2需要借助一个大小为n的临时数组b,所以其空间复杂度为O(n)。

对于空间复杂度,我们要知道的是,与时间复杂度相比,空间复杂度分析要简单很多。常见的空间复杂度就是 O(1)、O(n)、O(n^2^),像 O(lognlog_n)、O(nlognnlog_n) 这样的对数阶复杂度几乎用不到。

三、总结

对于一个算法,其时间复杂度和空间复杂度往往是相互影响的,当追求一个较好的时间复杂度时,可能会导致占用较多的存储空间,即空间复杂度的性能可能会变差,反之亦然。

通常情况下,鉴于运算空间较为充足,常常会以算法的时间复杂度作为衡量算法优劣的核心指标。

image-20231203171554996

四、参考

《数据结构与算法之美》

《数据结构(C语言版 第2版)》

《数据结构 自考02331》


数据结构:算法的分析方法
https://kuberxy.github.io/2023/11/26/数据结构:算法的分析方法/
作者
Mr.x
发布于
2023年11月26日
许可协议