对于实现每个开发者都有自己的想法,但并不是每一个算法都会得到很好的应用和推广,受限于运行环境和运行时间的要求,总是会有一些算法虽然也能够得到想要的结果但是终究会被摒弃.而评价一个算法好坏的一个重要标准就是算法的时间复杂度.
对于算时间复杂度,总体上会有两种衡量方法:
事后统计法
对于特性的算法只要使用用例在指定的设备上进行运算就可以获取到算法执行的时间,从而评估算法在执行时间上的优劣.但是这种方法具有明显的缺陷:
- 执行时间严重依赖于硬件以及运行时各种不确定的环境因素:两个算法在不同的硬件上进行测试,运行差异会有差别;在例如,同样一台机器,也会受限于CPU,内训的使用情况以及时间片的切换等不停因素导致运行时间不尽相同,所以就没有办法依据时间来作为算法好坏的唯一标准;
- 既然要测试用例的执行时间,就必须要编写测试用例,而编写测试用例就要考虑到算法在各种不同场景用例上的时间消耗;
- 测试数据很难做到绝对的公平:一些算法在测试数据数量比较小时表现出明显的优势,一些算法在大量数据进行测试时才表现出算法优势,这样就很难选择公正的测试用例.
估算代码执行次数
事实上对于算法来讲,获取某一组数据的精确执行时间并没有太大的意义,而且也完全没有必要,只需要预估算法需要代码的指令数量量级就可以大致知道算法需要消耗的相对时间。一般情况下,执行次数少的算法肯定要比执行次数多的花费更少的时间,这样既可以简化时间复杂度的评定标准,又能快速的对两个算法的优劣作出评估.
Example 1:
int test(int n) {
if (n > 80) {
printf("Excelent");
} else if (n > 60) {
printf("Good");
} else {
printf("Bad");
}
for (int index = 0; index < n; index++) {
printf("Execute once");
}
}
在这个方法中:
- if…else…结构中,只需要判断执行一次,输出执行一次,指令总共执行1+1=2次;
- 在for结构中,初始化赋值index=0执行一次,在之后的循环中,index每取一个值,比较执行一次,循环体执行一次,自增运算执行一次,共执行三次,而循环结构需要循环n次,所以需要执行1+3 * n = 3n + 1次;
所以对于上边的方法来讲,假设指令执行的时间一样,就可以大概得出方法一共需要执行指令=3+3n次;
Example 2:
void test(int n) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
printf("Execute once");
}
}
}
在这个方法中:
- 最外层循环,初始化赋值i一次,对于每一次循环i参与比较一次,自增一次,循环体执行一次,所以针对外层循环来讲,时间复杂度O(n) =1+n+n+n*(循环体)
- 而对于内层循环来讲,初始化赋值j一次,对于每一次循环j参与比较一次,自增一次,循环体执行一次,所以内层循环的时间复杂度=1+n+n+n
所以总的时间复杂度=1+n+n+n*(1+n+n+n) = 1+2n+n*(3n+1) = 3 n 2 + 3 n + 1 3n^2+3n+1 3n2+3n+1次;
Example 3:
void test(int n) {
for (int i = 0; i < n; i++) {
for (int j = i; j < n; j++) {
printf("Execute once");
}
}
}
在这个方法中:
-
对于内层循环来讲,对于每一个i值,内层需要执行的指令数Q(n)
Q ( n ) = 1 + ( n − i ) + ( n − i ) + ( n − i ) = 1 + 3 ( n − i ) Q(n) = 1 +(n-i) + (n-i) + (n-i) = 1+3(n-i) Q(n)=1+(n−i)+(n−i)+(n−i)=1+3(n−i) -
对于外层循环来讲,每一次循环需要指令指令的次数为Q(n),所以需要将i依次代入Q(n)中进行求和F(n)
F ( n ) = ∑ 0 n − 1 Q ( i ) = 1 + 3 n + 1 + 3 ( n − 1 ) + . . . + 1 + 3 ( n − ( n − 1 ) ) = n + 3 ∑ 1 n j = 3 n 2 2 − n 2 F(n)=\sum_0^{n-1} Q(i)=1+3n + 1+3(n-1) + ... + 1 + 3(n-(n-1))=n+3\sum_1^nj={3n^2\over 2} - {n\over2} F(n)=0∑n−1Q(i)=1+3n+1+3(n−1)+...+1+3(n−(n−1))=n+31∑nj=23n2−2n
Example 4:
void test(int n) {
while((n = n / 2) > 0) {
printf("Execute once");
}
}
在每次while循环中,n都会被折半,相当于以2为底的对数,因此
n
=
n
/
2
n=n/2
n=n/2
这个操作会执行
l
o
g
2
n
log_2 n
log2n次,同样的条件判断也需要执行
l
o
g
2
n
log_2 n
log2n次,循环也需要执行
l
o
g
2
n
log_2n
log2n次,所以该方法一共需要执行指令此时F(n)
F
(
n
)
=
l
o
g
2
n
+
l
o
g
2
n
+
l
o
g
2
n
=
3
l
o
g
2
n
F(n) = log_2n + log_2n + log_2n = 3log_2n
F(n)=log2n+log2n+log2n=3log2n
Example 5:
void test (int n) {
for (int i = 0; i < n;i *= 2) {
for (int j = 0; j < n; j++) {
printf("Execute once");
}
}
}
-
内层循环中,初始化赋值j执行一次,每一次内层循环中j参与比较一次,自增一次,内层循环体执行一次,所以针对每一次外循环内层需要执行指令数 Q ( n ) Q(n) Q(n)
Q ( n ) = 1 + n + n + n = 3 n + 1 Q(n) = 1+ n + n + n = 3n+1 Q(n)=1+n+n+n=3n+1 -
在外层循环中,初始化赋值 i i i一次,每次 i = i ∗ 2 i=i*2 i=i∗2,相当于以2为底数的指数递增,所以 i ∗ = 2 i*=2 i∗=2共执行 l o g 2 n log_2n log2n次,所以 i i i参与比较的次数为 l o g 2 n log_2n log2n,外层循环体执行的次数 l o g 2 n log_2n log2n。所以该函数一共执行指令数 F ( n ) F(n) F(n)
F ( n ) = 1 + l o g 2 n + l o g 2 n + l o g 2 n ∗ Q ( n ) = 3 n l o g 2 n + 3 l o g 2 n + 1 F(n)=1+log_2n+log_2n+log_2n * Q(n) = 3nlog_2n+3log_2n+1 F(n)=1+log2n+log2n+log2n∗Q(n)=3nlog2n+3log2n+1
Example 5:
void test5(int n) {
int a = 10;
int b = 20;
int c = a + b;
int *arr = (int *)malloc(sizeof(n));
for (int i = 0; i < n; i++) {
printf("Execute once");
}
}
- a,b,c赋值各执行一次,数组初始化执行一次;
- for循环中,初始化赋值i一次,i参与比较操作n次,i自增运行n次,循环体执行n次;所以方法共执行指令的次数
F
(
n
)
F(n)
F(n):
F ( n ) = 4 + 1 + 3 n = 3 n + 5 F(n) = 4 + 1 + 3n = 3n+5 F(n)=4+1+3n=3n+5
时间复杂度大O表示法
在上边的例子中,使用了
n
n
n的函数来表示指令执行的次数来描述算法的时间复杂度。而在实际估算算法时间复杂度的过程中,常数的影响几乎是可以忽略的,只需要关心算法中关于规模
n
n
n的数量级即可大致描述算法的优劣.
算法的时间复杂度通常用
O
O
O表示,定义为:
T
(
n
)
=
O
(
F
(
n
)
)
T(n)=O(F(n))
T(n)=O(F(n))
习惯上称
T
(
n
)
T(n)
T(n)以
F
(
n
)
F(n)
F(n)为界或者
T
(
n
)
T(n)
T(n)受限于
F
(
n
)
F(n)
F(n).如果一个问题的规模为
n
n
n,解决这一问题的某一算法的所需要的时间为
T
(
n
)
T(n)
T(n),则
T
(
n
)
T(n)
T(n)称为这一算法的时间复杂度.当规模
n
n
n逐渐增大时,时间复杂度的极限情形称为算法的“渐进时间复杂度”.
大
O
O
O表示法只关注算法关于规模的
n
n
n的量级,所以具有以下特征:
-
忽略表达式的常数,系数以及低阶项
忽略常数:如果有关于 n n n的项,可以直接忽略表达式中的常数项;如果不包含任何关于 n n n的项,则算法的时间复杂度为 O ( 1 ) O(1) O(1);
忽略系数: 在关于 n n n的表达式中,只需要关心 n n n的量级,系数可以忽略为1;所以在 O O O表示法中, F ( n ) = 3 n F(n)=3n F(n)=3n与 F ( n ) = n F(n)=n F(n)=n具有同样的时间复杂度;
忽略低阶项:由于 O O O表示法是一个只关注关于 n n n量级的算法复杂度表示法,所以如果存在关于 n n n更高量级的项,则底量级的项可以忽略。例如 F ( n ) = 3 n l o g 2 n + 3 l o g 2 n + 1 F(n)= 3nlog_2n+3log_2n+1 F(n)=3nlog2n+3log2n+1可以表示为 O ( n ) = n l o g 2 n O(n)=nlog_2n O(n)=nlog2n -
对数阶忽略底数
由于在对数运算中,任意对数底都可以通过乘以指定常数转化为任意指数底,例如:
l o g 9 n = l o g 2 n l o g 2 9 log_9n= {log_2n \over log_29} log9n=log29log2n
所以底数并不影响算法的量级,因此对数阶时间复杂度可以忽略算法的底数; -
大 O O O分析法只是一种在不执行运算的前提的下粗略估算算法时间复杂度的一种方法.
-
由于算法在不同场景下表现出的耗时并不相同,所以时间复杂度多数指最坏的复杂度.
常见的算法算法复杂度阶
执行次数 | 复杂度 | 非正式术语 |
---|---|---|
常数 | O ( 1 ) O(1) O(1) | 常数阶 |
3 n + 5 3n+5 3n+5 | O ( n ) O(n) O(n) | 线性阶 |
3 n 2 + 3 n + 1 3n^2+3n+1 3n2+3n+1 | O ( n 2 ) O(n^2) O(n2) | 平方阶 |
3 l o g 2 n 3log_2n 3log2n | O ( l o g 2 n ) O(log_2n) O(log2n) | 对数阶 |
3 n l o g 2 n + 3 l o g 2 n + 1 3nlog_2n+3log_2n+1 3nlog2n+3log2n+1 | O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) | nlogn阶 |
3 n 3 + 3 n 2 + 3 3n^3+3n^2+3 3n3+3n2+3 | O ( n 3 ) O(n^3) O(n3) | 立方阶 |
2 n 2^n 2n | O ( 2 n ) O(2^n) O(2n) | 指数阶 |
当
n
n
n逐渐增大时,算法时间复杂度:
O
(
1
)
<
O
(
l
o
g
2
n
)
<
O
(
n
)
<
O
(
n
l
o
g
2
n
)
<
O
(
n
2
)
<
O
(
n
3
)
<
O
(
2
n
)
<
O
(
n
!
)
<
O
(
n
n
)
O(1)<O(log_2n)<O(n)<O(nlog_2n)<O(n^2)<O(n^3)<O(2^n)<O(n!)<O(n^n)
O(1)<O(log2n)<O(n)<O(nlog2n)<O(n2)<O(n3)<O(2n)<O(n!)<O(nn)
随着
n
n
n的增大,
O
(
n
2
)
<
O
(
n
3
)
<
O
(
2
n
)
<
O
(
n
!
)
<
O
(
n
n
)
O(n^2)<O(n^3)<O(2^n)<O(n!)<O(n^n)
O(n2)<O(n3)<O(2n)<O(n!)<O(nn)阶的算法复杂度会快速的增加,所以应该尽可能优化或者摒弃这种阶的算法复杂度.
评价算法的时间复杂度
通过使用大
O
O
O时间复杂度表示法可以快速的预估一个算法的时间复杂度,通过对比不同算法对同一问题实现的时间复杂度即可对算法的优劣作出初步的判断.
例如,需要求出1~100的自然数和 .
算法1:可以尝试使用递归来处理
int sum(int n) {
if (n == 1) {
return 1;
}
return n + sum(n-1);
}
在每次递归中,首先要判断一次,如果
n
=
1
n=1
n=1则会直接返回,否则需要执行一次
n
+
s
u
m
(
n
−
1
)
n+sum(n-1)
n+sum(n−1),需要一共需要判断n次,进行加法操作
n
−
1
n-1
n−1次(最后一次当
n
=
1
n=1
n=1时直接返回),所以
F
(
n
)
F(n)
F(n)
F
(
n
)
=
n
+
n
−
1
=
2
n
−
1
F(n) = n + n-1=2n-1
F(n)=n+n−1=2n−1
所以时间复杂度为:
O
(
n
)
=
O
(
F
(
n
)
)
=
O
(
n
)
O(n)=O(F(n))=O(n)
O(n)=O(F(n))=O(n)
算法 2: 可以采用著名的高斯定理:
int sum = n * (n + 1) / 2
这样的话算法的时间复杂度就变成了常数级的运算,时间复杂度变为:
O
(
n
)
=
O
(
1
)
O(n) = O(1)
O(n)=O(1)
通过两种算法时间复杂度的比较,由于
O
(
1
)
<
O
(
n
)
O(1)<O(n)
O(1)<O(n)所以使用算法2比使用算法1更加具有优势。
算法的时间复杂度与空间复杂度
在尽可能的情况下,需要对算法的时间复杂度和空间复杂度进行优化,以实现尽可能小的时间复杂度和尽可能小的空间复杂度。事实的多数情况是,你只能牺牲其中的一种复杂度来成就另一种复杂度,以时间换空间或者以空间换时间。
例如,爬楼梯问题:
数组的每个索引作为一个阶梯,第 i个阶梯对应着一个非负数的体力花费值 cost[i](索引从0开始)。
每当你爬上一个阶梯你都要花费对应的体力花费值,然后你可以选择继续爬一个阶梯或者爬两个阶梯。
您需要找到达到楼层顶部的最低花费。在开始时,你可以选择从索引为 0 或 1 的元素作为初始阶梯。Example1:
输入: cost = [10, 15, 20]
输出: 15
解释: 最低花费是从cost[1]开始,然后走两步即可到阶梯顶,一共花费15。Example 2
输入: cost = [1, 100, 1, 1, 1, 100, 1, 1, 100, 1]
输出: 6
解释: 最低花费方式是从cost[0]开始,逐个经过那些1,跳过cost[3],一共花费6。
在这个问题中可以尝试使用一个数组
r
e
s
u
l
t
result
result来保存到达某个台阶上的最小花费值,这样对于任意目标台阶
i
i
i,存在:
F
(
i
)
=
m
i
n
(
r
e
s
u
l
t
[
i
−
2
]
+
c
o
s
t
[
i
−
2
]
,
r
e
s
u
l
t
[
i
n
d
e
x
−
1
]
+
c
o
s
t
[
i
n
d
e
x
−
1
]
)
F(i) = min(result[i-2]+cost[i-2], result[index - 1] + cost[index - 1])
F(i)=min(result[i−2]+cost[i−2],result[index−1]+cost[index−1])
由此可以得到第一种算法:
int minValue(int a, int b) {
return a < b ? a : b;
}
int minCostClimbingStairs(int* cost, int costSize) {
int *result = malloc(sizeof(int) * (costSize + 1));
memset(result, 0, sizeof(int) * (costSize + 1));
result[0] = 0;
result[1] = 0;
for (int index = 2; index <= costSize; index++) {
result[index] = minValue(result[index - 2] + cost[index - 2], result[index - 1] + cost[index - 1]);
}
return result[costSize];
}
算法中,时间复杂度为 O ( n ) O(n) O(n),空间复杂度为 O ( n ) O(n) O(n).而事实上,在循环中 ,可以看到其实只需要保存当前台阶的前一个台阶和前前一个台阶的值就可以满足算法,其他的空间都是没有用的,所以算法可以简化为
int minValue(int a, int b) {
return a < b ? a : b;
}
int minCostClimbingStairs(int* cost, int costSize) {
int previousEle = 0;
int eleBeforePrevious = 0;
for (int index = 2; index <= costSize; index++) {
int temp = previousEle;
previousEle = minValue(eleBeforePrevious + cost[index-2], previousEle + cost[index - 1]);
eleBeforePrevious = temp;
}
return previousEle;
}
这样,经过优化的算法空间复杂度就降低为 O ( 1 ) O(1) O(1),而这个算法的时间优化比较困难,并没有很好的思路.