算法效率的度量
一般算法的效率是通过时间复杂度和空间复杂度来度量的。对于疯狂扩增的内存空间来说,我们一般更加关注时间复杂度。当然,在有些特殊场景,比如在单片机上运行算法时,就有可能要考虑空间复杂度。同一个问题,我们可以使用不同的算法来解决,我们应当根据实际情况来选取最适合的算法,例如,有些算法时间复杂度是 O ( n 2 ) O(n^2) O(n2),而空间复杂度 O ( 1 ) O(1) O(1),而另一些算法时间复杂度 O ( n ) O(n) O(n),空间复杂度也是 O ( n ) O(n) O(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)就是时间复杂度,举个例子:
for(i=1;i<n;++i){
++a;
++b;
}
上述代码的时间复杂度是 T ( n ) = O ( n ) T(n)=O(n) T(n)=O(n),因为该算法的执行时间随着 n n n的增大而线性增大。所以 T ( n ) = O ( f ( n ) ) T(n)=O(f(n)) T(n)=O(f(n))中的 O ( f ( n ) ) O(f(n)) O(f(n))表示算法的执行时间随着 n n n的增大,以 f ( n ) f(n) f(n)的方式增大。同时要注意,上述的例子计算中,我们省略了常数项。例子算法中的时间复杂度应当与 2 n 2n 2n成正比,但是因为我们关心的是时间随着数据输入的变化趋势,所以省略常数项,同时也要注意,我们只取最大变化的量。例如下面的例子:
for(i=1;i<n;++i){
++a;
++b;
for(j=i;j<n;++j){
++c;
}
}
上述的时间复杂度为 O ( n 2 / 2 + 2 n ) = O ( n 2 + n ) = O ( n 2 ) O(n^2/2+2n)=O(n^2+n)=O(n^2) O(n2/2+2n)=O(n2+n)=O(n2),我们关心的是最大变化的量,因为当数据输入规模增大到足够大的时候,该算法的执行速度主要取决于 O ( n 2 ) O(n^2) O(n2)的那一项。
分析算法的时间复杂度时,通常遵循以下两条规则:
-
加法规则
T ( n ) = T 1 ( n ) + T 2 ( n ) = O ( f ( n ) ) + O ( g ( n ) ) = O ( m a x ( f ( n ) , g ( n ) ) ) T(n)=T_1(n)+T_2(n)=O(f(n))+O(g(n))=O(max(f(n),g(n))) T(n)=T1(n)+T2(n)=O(f(n))+O(g(n))=O(max(f(n),g(n))) -
乘法规则
T ( n ) = T 1 ( n ) × T 2 ( n ) = O ( f ( n ) ) × O ( g ( n ) ) = O ( f ( n ) × g ( n ) ) T(n)=T_1(n)\times T_2(n)=O(f(n))\times O(g(n))=O(f(n)\times g(n)) T(n)=T1(n)×T2(n)=O(f(n))×O(g(n))=O(f(n)×g(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)
以下举几个例子来掌握时间复杂度的计算。
举例
常数阶 O ( 1 ) O(1) O(1)
int a = 4;
int c = 2;
c = 2*a;
上述操作只需要简单的赋值或计算,执行时间与输入的数据无关,时间复杂度为 O ( 1 ) O(1) O(1)。
对数阶 O ( l o g 2 n ) O(log_2n) O(log2n)
i=0;
while(i<n){
i *= 2;
}
假设上述的i*=2;
运行了
k
k
k次,则可以得出:
2
k
=
n
2^k=n
2k=n
k
=
l
o
g
2
n
k = log_2n
k=log2n
故该算法的时间复杂度为 O ( l o g 2 n ) O(log_2n) O(log2n)。
线性阶 O ( n ) O(n) O(n)
for(i=0;i<n;++i){
a = i;
printf("%d\n", a);
}
上述操作执行时间与输入的数据 n n n成正比,时间复杂度为 O ( n ) O(n) O(n)。
线性对数阶 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)
理解了对数阶,线性对数阶就是在对数阶的基础上循环了 n n n次。
for(i=0;i<n;++i){
j=0;
while(j<n){
j *= 2;
}
}
平方阶 O ( n 2 ) O(n^2) O(n2)
简单的平方阶就是在线性阶上嵌套运行一次。
for(x=1;i<=n;x++){
for(i=1;i<=n;i++){
j = i;
j++;
}
}
对于立方阶、次方阶等等其他复杂度类似。
空间复杂度
对于空间复杂度的计算方式与时间复杂度类似。一个算法在执行时,除了自身指令、输入数据、常数及变量所占空间之外的空间,就是该算法的空间复杂度需要度量的部分。
算法原地工作是指算法所需的辅助空间为常数阶 O ( 1 ) O(1) O(1) 。
复杂的复杂度计算
1. 线性筛素数
int primes[N], cnt = 0; //primes存放着小于N的素数
bool st[N] = {1, 1}; // st[i]如果i不是素数,则为1
void get_primes(int n)
{
for (int i = 2; i <= n; i++ )
{
if (!st[i]) primes[cnt ++ ] = i;
for (int j = 0; j < cnt && i * primes[j] <= n; j++ )
{
st[primes[j] * i] = true;
if (i % primes[j] == 0) break;
}
}
}
可以在
O
(
n
)
O(n)
O(n) 的时间复杂度内求出
1
∼
n
1∼n
1∼n 之间的所有质数。在程序运行结束时,primes
数组中的每个值最多访问一次。st
数组中的每个值也最多访问一次,所以时间复杂度为
O
(
n
)
O(n)
O(n)。
2. 欧几里得算法
求两个正整数的最大公约数,时间复杂度 O ( l o g 2 a b ) O(log_2 ab) O(log2ab) 。
int gcd(int a, int b)
{
return b ? gcd(b, a % b) : a;
}
证明如下:
- 若 a > = 2 b a>=2b a>=2b,则 $ a%b<a/2 $ 。
- 若 a < 2 b a<2b a<2b,则 a % b < = a − b < a / 2 a\%b<=a-b<a/2 a%b<=a−b<a/2。
不管哪种情况,较大的数在下次递归时,总会减少一半,则递归调用次数为 O ( l o g 2 a ) + O ( l o g 2 b ) = O ( l o g 2 a b ) O(log_2a)+O(log_2b)=O(log_2ab) O(log2a)+O(log2b)=O(log2ab),每次递归内部都是常数时间的复杂度,因此,这也就是该算法的整体时间复杂度。