一、复杂度分析
首先要明确一点,数据结构和算法本质是解决“快”和“省”的问题。要描述一个算法的好坏就需要用到复杂度分析了,复杂度分析可分为如下两种。
-
时间复杂度
-
空间复杂度
时间复杂度就是描述算法的快,空间复杂度则是描述算法的省。一般说的复杂度都是时间复杂度,毕竟现代计算机存储空间已经不那么拮据了,时间复杂度是我们重点研究的内容。
二、大 O
复杂度表示法
首先看一段代码,求从 1~n
的累加之和。
int demo(int n) {
int i;
int sum = 0;
for(i=1; i<n; i++) {
sum += i;
}
return sum;
}
现在就来估算一下这段代码的执行时间(下面都是以时间复杂度为例讲解,空间复杂度最后再讲)。
从 CPU
的角度来看,每一行代码都执行着类似的操作读数据-运算-写数据。这里为了方便计算,假设每行代码的执行时间都是一样的,用 t
表示执行一行代码所需要的时间,n
表示数据规模的大小,T(n)
表示代码执行的总时间。
那么这段代码总执行时间是多少呢?我们来数一下。
首先,函数体内有 5
条语句,第 1、2、5
条语句总共执行了 3
次,所需时间是 3*t
;第 3、4
条语句各自执行了 n
次,所需时间是 2*n*t
。把这两个代码段执行的时间相加,所得到的结果就是这段代码总共所需的时间。
T ( n ) = ( 2 n + 3 ) t T(n)=(2n+3)t T(n)=(2n+3)t
通过上述公式可以得到一个规律,T(n)
随着 n
变大而变大,变小而变小。所以,T(n)
与 n
是成正比的,用数学符号表示就可以写成。
T ( n ) = O ( f ( n ) ) T(n)=O(f(n)) T(n)=O(f(n))
其中 f(n)
是代码段执行所需的时间之和,O
表示 T(n)
与 f(n)
之间的关系是成正比的。
由公式可得代码段执行所需的时间可表示为 T ( n ) = O ( 2 n + 3 ) T(n)=O(2n+3) T(n)=O(2n+3)。这就是大 O
时间复杂度表示法。大 O
时间复杂度实际上并不具体表示代码真正的执行时间,而是表示代码执行时间随着数据规模增长的变化趋势,所以,也叫做渐进时间复杂度简称时间复杂度。
其实 O ( 2 n + 3 ) O(2n+3) O(2n+3)并不是最终时间复杂度的表示方式。在实际的复杂度分析中,一般会把公式中的常量、系数、低阶忽略。因为这三部分并不影响增长趋势(还记得时间复杂度其实是渐进时间复杂度吧!),所以只需要记录一个最大量级就可以了,时间复杂度的最终表示方式就是 O ( n ) O(n) O(n)。
三、复杂度的分析方法
1. 最大量阶
int demo(int n) {
int i;
int sum = 0;
for(i=1; i<n; i++) {
sum += i;
}
return sum;
}
在分析一个算法、一段代码的时间复杂度的时候,只关注循环执行次数最多的那一段代码即可。
2. 加法法则
int demo(int n) {
int i;
int sum = 0;
for(i=1; i<n; i++) {
sum += i;
}
for(i=1; i<n; i++) {
int j;
for (j=1; j<n; j++)
sum += i;
}
return sum;
}
如果代码中存在着不同量级的时间复杂度,总的时间复杂度就等于量级最大的那段代码的时间复杂度。
3. 乘法法则
int demo(int n) {
int i;
int sum = 0;
for(i=1; i<n; i++) {
int j;
for (j=1; j<n; j++)
sum += i;
}
return sum;
}
如果是嵌套、函数调用、递归等操作,只需要将各部分相乘即可。
四、复杂度的量级
-
常量阶: O ( 1 ) O(1) O(1)
-
对数阶: O ( log n ) O(\log n) O(logn)
-
线性阶: O ( n ) O(n) O(n)
-
线性对数阶: O ( n log n ) O(n \log n) O(nlogn)
-
平方阶: O ( n 2 ) O(n^2) O(n2)
-
立方阶: O ( n 3 ) O(n^3) O(n3)
-
k
次方阶: O ( n k ) O(n^k) O(nk) -
指数阶: O ( 2 n ) O(2^n) O(2n)
-
阶乘阶: O ( n ! ) O(n!) O(n!)
对于上述不同的量级可以分为两类:多项式量级和非多项式量级。其中,非多项式量级只有两个: O ( 2 n ) O(2^n) O(2n)和 O ( n ! ) O(n!) O(n!),非多项式也叫做 NP
问题。
一般情况下,我们常见的复杂度只有 O ( 1 ) O(1) O(1)、 O ( log n ) O(\log n) O(logn)、 O ( n ) O(n) O(n)、 O ( n log n ) O(n \log n) O(nlogn)、 O ( n 2 ) O(n^2) O(n2) 这五个,常用的分析方法有最大量阶、加法法则、乘法法则这三个。只要把这些掌握,基本上就没有太大问题了。
五、时间复杂度
我们已经分析了时间复杂度,但是还是有一点儿小问题,比如我们要查找某个元素在长度为 n
的数组中的下标。如果按照顺序遍历,最理想的情况是第一个就是我们要找的,所以时间复杂度是 O(1)
;如果最后一个才找到我们要的数据,那么它的时间复杂度是 O(n)
。
为了解决同一段代码在不同情况下时间复杂度出现量级差异,我们就需要对时间复杂度进一步细化分类,为了更准确、更全面的描述代码的时间复杂度,引入了一下 4
个概念。
1. 最好情况时间复杂度
代码在最理想情况下执行的时间复杂度。
2. 最坏情况时间复杂度
代码在最坏情况下执行的时间复杂度。
3. 平均情况时间复杂度
上面两个最好、最坏情况都是小概率事件,平均情况时间复杂度才是最能代表一个算法的时间复杂度。因为平均情况时间复杂度需要引入概率进行分析,所以也叫做加权平均时间复杂度。
4. 均摊时间复杂度
正常情况下,代码在执行过程中都处于低阶的复杂度,极个别情况会出现高阶的复杂度,这是我们就可以将高阶的复杂度均摊到每个低阶的复杂度上,这种分析使用的是摊还分析法的思想。
其实我们只需要知道时间复杂度就够了。这四种方法都是对时间复杂度的一些特殊情况的补充,也没必要花大力气去研究它,大概知道有这种时间复杂度分类就可以了,如果你自己想学或者有脑残面试官要问这些,那你就自己去查找资料研究研究,这里不会展开讲解。
六、空间复杂度
前面讲解过,时间复杂度是渐进时间复杂度,表示算法的执行时间与数据规模之间的增长关系。那么空间复杂度就是渐进空间复杂度,表示算法的存储空间与数据规模之间的增长关系。
看一段代码,定义一个新数组,赋值后遍历输出。
void demo(int n) {
int i;
int data[n];
for(i=0; i<n; i++) {
data[i] = i * i;
}
for(i=0; i<n; i++) {
printf("%d\n", data[i]);
}
}
跟时间复杂度分析一样,函数体内第 1
条语句是常量阶,直接忽略;第 2
条语句申请了一个大小为 n
的 int
类型数组,所以整段代码的空间复杂度就是 O ( n ) O(n) O(n)。