什么是算法( A l g o r i t h m Algorithm Algorithm)?算法对于任何合法的输入,能够在有限的时间里获取满足一定要求的输出。它是对特定问题求解步骤的一种描述,它是指令的有限序列,其中每一条指令表示一个或多个操作。如图1所示。那怎样的算法才算是好的算法,一般不外乎两点:
- 时间效率:算法获取满足一定要求的输出所需的时间越少越好。
- 空间效率:除了输入输出所需要的额外的存储空间越少越好。
我们先来讨论一下算法的时间效率,也就是时间复杂度。在评估一个算法的时间复杂度的时候我们只考虑问题的规模,也就是输入,同时避开其它因素,这有以下几个原因:
- 同一个算法在不同的硬件配置下,运行所需时间是不一样的。假设现在要对1024个整数进行排序,每个整数占用4个字节的存储空间。假设两台计算机的其它配置一样,只是一计算机1的内存为1024个字节,计算机2的内存为10240个字节。这样这1024个整数不能一次完全放到计算机1的内存中,在程序运行时需要与硬盘进行IO操作,耗费大量时间。但是这样这1024个整数可以一次完全放到计算机2的内存中。这样就有可能因为硬盘IO操作的原因造成本来一个效率比较低下的算法对于同一个问题在计算机2上的运行时间比一个效率比较高效的算法对于同一个问题在计算机1上的运行时间还要少。
- 并不同的计算机语言写的程序的运行时间是不一样的,用越是高级的语言实现的程序运行的时间就越长。
- 对于同样的硬件配置和计算机语言,不同的编译器产生的二进制代码的质量对程序的云心时间也是有影响的。
这同时也说明使用绝对的时间单位去衡量一个算法的时间复杂度的好坏是不合理的(也是用程序实现算法然后放到计算机上去跑然后统计实际的运行时间来比较算法时间复杂度的好坏)。因此在评估算法时间复杂度的时候一般使用算法中基本操作(算术运算、赋值操作、比较操作等)的执行次数( f ( n ) f(n) f(n))来评估算法时间复杂度。一般情况下算法中基本操作的执行次数是问题规模 n n n,也就是输入,的某个函数 f ( n ) f(n) f(n)。
void Algorithm()
{
for (int i = 1; i <= n; i++)
{
/*perform 100 basic operations*/
for (int j = 1; j <= n; j++)
{
/*perform 2 basic operations*/
}
}
}
假设以上代码块是解决某问题的算法的代码实现,问题规模为n,则我们可以计算出该算法中基本操作的执行次数
f
(
n
)
=
100
∗
n
+
2
∗
n
2
f(n)=100*n+2*n^2
f(n)=100∗n+2∗n2,因此我们可以说该算法解决规模为n的该问题所需的基本操作的数量为
f
(
n
)
=
100
∗
n
+
2
∗
n
2
f(n)=100*n+2*n^2
f(n)=100∗n+2∗n2。如果此时一个基本操作的时间
t
t
t是已知的,则该算法解决规模为n的该问题所需的时间为
f
(
n
)
∗
t
=
(
100
∗
n
+
2
∗
n
2
)
∗
t
f(n)*t=(100*n+2*n^2)*t
f(n)∗t=(100∗n+2∗n2)∗t。然而就像我们前面提到的时间
t
t
t并不是完全独立的而是依赖于很多其它的外在因素:计算机硬件,编译器,不同的编程语言等等。为了避开这些因素,我们直接说该算法解决规模为n的该问题所需的时间与
f
(
n
)
=
100
∗
n
+
2
∗
n
2
f(n)=100*n+2*n^2
f(n)=100∗n+2∗n2成正比。
假设现在有解决规模为n的某问题的算法
A
A
A和算法
B
B
B,这两种算法的时间复杂度分别如下所示:
- 3 n 2 + 2 n + l o g 2 n + 1 4 n 3n^2+2n+log_2^{n}+\frac{1}{4n} 3n2+2n+log2n+4n1(算法A)
- 0.39 n 3 + n 0.39n^3+n 0.39n3+n(算法B)
在n的值超过一定值时算法A要比算法B的时间复杂度要低,因为一个是n的3次幂,一个是n的2次幂。也就是说我们基本可以通过
f
(
n
)
f(n)
f(n)的前导项大概分析出算法性能的好坏。这样也简化了算法分析的过程,这种通过
f
(
n
)
f(n)
f(n)的最高次幂项分析算法性能好坏的方法叫做渐进分析方法。
渐进分析方法主要集中于三点:
- 仅仅分析较大规模的输入的问题
- 仅仅考虑 f ( n ) f(n) f(n)的前导项也就是最高次幂项
- 忽略最高次幂项的系数
对于以下两种解决同一问题的算法,当 n = 1 n=1 n=1时,算法B的时间复杂度居然比算法A的时间复杂度还要低,但是当问题规模n超过一定值时,算法A的时间复杂度肯定比算法B的时间复杂度还要低,因此在分析问题时仅仅分析较大规模的输入的问题。
- 3 n 2 + 2 n + l o g 2 n + 1 4 n 3n^2+2n+log_2^{n}+\frac{1}{4n} 3n2+2n+log2n+4n1(算法A)
- 0.39 n 3 + n 0.39n^3+n 0.39n3+n(算法B)
忽略低次幂项是因为当问题规模开始变大时,低次幂项对整个问题的代价的贡献越来越小,下面是 f ( n ) = 100 ∗ n + 2 ∗ n 2 f(n)=100*n+2*n^2 f(n)=100∗n+2∗n2当n取不同值时 f ( n ) f(n) f(n)的值:
- f ( n ) = 100 ∗ 10 + 2 ∗ ( 10 ) 2 = 1000 + 200 f(n)=100*10+2*(10)^2=1000+200 f(n)=100∗10+2∗(10)2=1000+200,(n=10)
- f ( n ) = 100 ∗ 100 + 2 ∗ ( 100 ) 2 = 10000 + 20000 f(n)=100*100+2*(100)^2=10000+20000 f(n)=100∗100+2∗(100)2=10000+20000,(n=100)
- f ( n ) = 100 ∗ 1000 + 2 ∗ ( 1000 ) 2 = 100000 + 2000000 f(n)=100*1000+2*(1000)^2=100000+2000000 f(n)=100∗1000+2∗(1000)2=100000+2000000,(n=1000)
忽略最高次幂项的系数是因为对于最高次幂项的指数相同的算法其时间复杂度的增长率是相同的,比如对于最高次幂项为
2
n
2
2n^2
2n2和
2
n
2
2n^2
2n2的解决同一问题两种算法,时间复杂度的增长率是一样的,但是如果某一算法C的最高次幂项为
n
3
n^3
n3,那就可以明显的看出来算法C的时间复杂度的增长率要高于前两种算法。
下面我们来讲算法的时间复杂度的大
O
O
O表示。如果对于输入规模为n的问题,算法A所需的基本操作的个数为
f
(
n
)
f(n)
f(n)(注意这里的
f
(
n
)
f(n)
f(n)是经过渐进分析方法处理过的,只保留最高次幂项并去掉了系数),我们则说算法A的时间复杂度为
T
(
n
)
T(n)
T(n):
- T ( n ) = O ( f ( n ) ) T(n)=O(f(n)) T(n)=O(f(n))
这里的意思是存在一个正的常数k和一个正整数 n 0 n_0 n0,当 n > = n 0 n>=n_0 n>=n0时, ∣ T ( n ) ∣ |T(n)| ∣T(n)∣<= ∣ k ∗ f ( n ) ∣ |k*f(n)| ∣k∗f(n)∣,所以大 O O O表示给了算法的时间复杂度一个上界,如图2所示。常见的算法的时间复杂度排序如下, O ( 1 ) O(1) O(1)代表常数时间,不随问题规模的改变而改变:
- 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(1)<O(log_2^n)<O(n)<O(nlog_2^n)<O(n^2)<O(n^3)<O(2^n) O(1)<O(log2n)<O(n)<O(nlog2n)<O(n2)<O(n3)<O(2n)
void bubbleSort(int record[],int n)
{
bool recordExchangeStatus = true;/*One basic operation*/
for (int i = n - 1; (i >= 1) && recordExchange; i--) /*Three basic operation*/
{
recordExchangeStatus = false; /*One basic operation*/
for (int j = 0; j < i; j++)/*Three basic operation*/
{
if (record[j] > record[j + 1])/*One basic operation*/
{
recordExchange(record[j],record[j + 1]);/*One basic operation*/
recordExchangeStatus = true;/*One basic operation*/
}
}
}
}
下面我们以上面的冒泡排序算法(从小到大排序)的实现代码为例来简单过一遍分析过程,并注释了每一个基本操作。我们在分析算法的时间复杂度的时候又可以分为
- 最坏情况下的时间复杂度:此时待排序的数组是一个从大到小的有序序列, f ( n ) = 1 + ( n − 1 ) ∗ ( 3 + 1 ) + ∑ i = 1 n − 1 ( 3 i ) = 4 n + 3 + 3 2 ∗ ( n 2 − n ) = 3 2 ∗ n 2 + 5 2 ∗ n + 3 f(n)=1+(n-1)*(3+1)+\sum_{i=1}^{n-1}(3i)=4n+3+\frac{3}{2}*(n^2-n)=\frac{3}{2}*n^2+\frac{5}{2}*n+3 f(n)=1+(n−1)∗(3+1)+∑i=1n−1(3i)=4n+3+23∗(n2−n)=23∗n2+25∗n+3,因此可以推导出以上冒泡排序算法的时间复杂度为 T ( n ) = O ( f ( n ) ) = O ( n 2 ) T(n)=O(f(n))=O(n^2) T(n)=O(f(n))=O(n2)。这是因为 3 2 ∗ n 2 + 5 2 ∗ n + 3 < 2 n 2 + 3 n ≤ 2 n 2 + n 2 = 3 n 2 ( n ≥ 3 ) \frac{3}{2}*n^2+\frac{5}{2}*n+3<2n^2+3n\le2n^2+n^2=3n^2(n\geq3) 23∗n2+25∗n+3<2n2+3n≤2n2+n2=3n2(n≥3),因此存在一个正整数 n 0 = 3 n_0=3 n0=3和正的常数 k = 3 k=3 k=3,当 n ≥ n 0 n\geq n_0 n≥n0时 ∣ T ( n ) ∣ ≤ |T(n)|\le ∣T(n)∣≤ ∣ k ∗ f ( n ) ∣ |k*f(n)| ∣k∗f(n)∣,注意这里的 f ( n ) f(n) f(n)是经过渐进分析方法处理过的,只保留最高次幂项并去掉了系数即f(n)=n^2
- 平均情况下的时间复杂度:这个就有一点复杂了,就是你先求得每一种输入情况的时间复杂度并知道每一种情况出现的概率,然后求对输入的所有情况的时间复杂度求期望就是平均情况的时间复杂度。
- 最好情况下的时间复杂度:此时最好情况是原始输入序列已经从小到大有序。 f ( n ) = 1 + 2 + 1 + ( n − 1 ) ∗ ( 3 + 1 ) + 1 + 1 = 4 n + 2 f(n)=1+2+1+(n-1)*(3+1)+1+1=4n+2 f(n)=1+2+1+(n−1)∗(3+1)+1+1=4n+2,因此可以推导出以上冒泡排序算法最好情况的时间复杂度为 T ( n ) = O ( f ( n ) ) = O ( n ) T(n)=O(f(n))=O(n) T(n)=O(f(n))=O(n)。
严蔚敏的教材只给出了时间复杂度的大 O O O表示,也就是时间复杂度的上界。但是我看其它资料有介绍其它几种时间复杂度的表示方法,这里我也记录以下。
- 大 Ω \Omega Ω表示:它代表算法时间复杂度的下界,如果 T ( n ) = Ω ( f ( n ) ) T(n)=\Omega(f(n)) T(n)=Ω(f(n)),它表示的意思是存在一个正的常数k和一个正整数 n 0 n_0 n0,当 n > = n 0 n>=n_0 n>=n0时, ∣ T ( n ) ∣ ≥ ∣ k ∗ f ( n ) ∣ |T(n)|\geq|k*f(n)| ∣T(n)∣≥∣k∗f(n)∣,这里的 f ( n ) f(n) f(n)应该也是经过渐进分析方法处理过的,只保留最高次幂项并去掉了系数。
- 大 Θ \Theta Θ表示:它同时给定了算法时间复杂度的上界和下界,如果 T ( n ) = Θ ( f ( n ) ) T(n)=\Theta(f(n)) T(n)=Θ(f(n)),它表示的意思是存在两个正的常数 k 1 k_1 k1, k 2 k_2 k2和一个正整数 n 0 n_0 n0,当 n > = n 0 n>=n_0 n>=n0时, ∣ k 1 ∗ f ( n ) ∣ ≤ ∣ T ( n ) ∣ ≤ ∣ k 2 ∗ f ( n ) ∣ |k_1*f(n)|\leq|T(n)|\leq|k_2*f(n)| ∣k1∗f(n)∣≤∣T(n)∣≤∣k2∗f(n)∣。
- 小 o o o表示:它类似于大 O O O表示,但是没有把上界限制的那么死,如果 T ( n ) = o ( f ( n ) ) T(n)=o(f(n)) T(n)=o(f(n)),它表示的意思是对于任意一个大于0的常数k存在一个正整数 n 0 n_0 n0,当 n > = n 0 n>=n_0 n>=n0时, ∣ T ( n ) ∣ ≤ ∣ k ∗ f ( n ) ∣ |T(n)|\leq|k*f(n)| ∣T(n)∣≤∣k∗f(n)∣。
- 小 ω \omega ω表示:它类似于大 Ω \Omega Ω表示,但是没有把下界限制的那么死,如果 T ( n ) = ω ( f ( n ) ) T(n)=\omega(f(n)) T(n)=ω(f(n)),它表示的意思是对于任意一个大于0的常数k存在一个正整数 n 0 n_0 n0,当 n > = n 0 n>=n_0 n>=n0时, ∣ T ( n ) ∣ ≥ ∣ k ∗ f ( n ) ∣ |T(n)|\geq|k*f(n)| ∣T(n)∣≥∣k∗f(n)∣。
算法的空间复杂度是对算法所需存储空间的度量。一个上机执行的程序除了需要存储空间来存储本身的指令、常数、变量和输入数据外,也需要一些对数据进行操作的工作单元和存储一些为实现计算所需信息的辅助空间。若输入数据所占空间只取决于问题本身,和算法无关,则只需要分析除输入和程序之外的额外空间,否则应同时考虑输入本身所需空间(和输入数据的表示形式有关)。若额外空间相对于输入数据是常量,则称此算法为原地工作。这一段截取自严蔚敏版的数据结构教材。