我正在学习的这门课程名叫“数据结构与算法”,这样看来,它们二者一定是有着某种关联。事实上,在学习数据结构的过程中,谈到“算法”是为了帮我们更好地理解数据结构。单说数据结构当然可以,在很短的时间内我们就可以学习完几种重要的数据结构。但是这样的话,我们学完后不会有什么感觉,也不知道数据结构有何作用。下面主要讨论一下有关算法的方方面面。
两种算法的比较
在之前,无论是学习C语言也好,学习Python也好,学习Java也好,我们一定写过这样一个程序:1-100求和。这是一个再简单不过的程序,我们也很快能写出这样的代码:
#include <stdio.h>
int main(void) {
int i, sum = 0;
for (i = 1; i <= 100; i++) {
sum += i;
}
printf("%d\n", sum);
return 0;
}
这个简单的程序就是一种算法。程序本身没有什么需要解释的地方,就是应用了一个for
循环来对1-100这些数字进行累加。我相信这也是大多数人拿到这个题目的第一想法。
相信很多人都听说过数学家高斯的一个故事。老师让所有的学生计算1+2+3+…+100的结果,这道题难倒了很多学生,但只有高斯很快地算出了结果。这让老师非常惊讶(可能老师也是通过1+2=3, 3+3=6, 6+4=10…这样的方法来计算的吧)。当被问到怎么样能如此快地计算出结果的时候,高斯解释道:
高斯用的方法相当于求等差数列前n项和的方法,用代码实现如下:
#include <stdio.h>
int main(void) {
int n = 100, sum = 0;
sum = (1 + n) * n / 2;
printf("%d\n", sum);
return 0;
}
这个程序并没有用到循环。试想一下,如果我们要对1至1亿的所有数字进行求和(当然,这里的int要替换为长整型),如果用循环的方法,计算机要进行1亿次的循环累加,而应用高斯求和的方法,计算机运算出结果只是一瞬间的事。这大大节省了计算机运算的时间。
什么是算法
前面说了这么多,我们似乎还是摸不清头脑,什么是算法呢?
现在普遍认可的定义是:算法(Algorithm)是解决特定问题求解步骤的描述,在计算机中表现为指令的有限序列,并且每条指令表示一个或多个操作。
在算法的定义中提到了指令。指令能被人或机器等计算装置执行,它可以是计算机指令,也可以是我们平时的语言文字。为了解决某个或某类问题,需要把指令表示成一定的操作序列,操作序列包括一组操作,每一个操作都完成特定的功能,这就是算法。
算法的特性
算法具有5个基本特性:输入,输出,有穷性,确定性,可行性。
输入
算法具有零个,一个或多个输入。对于大多数算法来说,输入是必要的,但也有个别算法不需要输入任何参数。
输出
算法至少有一个或多个输出,输出的形式可以是打印输出,也可以是返回一个或多个值等。
有穷性
算法在执行有限的步骤之后,自动结束而不会出现无限循环,并且每一个步骤在可接受的时间内完成。这里的“有穷”并不是纯数学意义的,它更多是指在实际应用当中合理的、可以接受的“有边界”。
确定性
算法的每一步骤都具有确定的含义,不会出现二义性。算法在一定条件下,只有一条执行路径,相同的输入只能有唯一的输出结果。算法的每个步骤被精确定义而无歧义。
可行性
算法的每一步都必须是可行的,也就是说,每一步都能够通过执行有限次数完成。可行性意味着算法可以转换为程序上机运行,并得到正确的结果。
算法设计的要求
算法设计有以下几个要求:正确性,可读性,健壮性,时间效率高和存储量低。
正确性
一个好的算法,最起码得是正确的。如果连正确都谈不上,再谈别的要求也没有意义。
算法的正确性是指算法至少应该具有输入、输出和加工处理无歧义性、能正确反映问题的需求,能够得到问题的正确答案。“正确”大体分为以下4个层次:
1.算法程序没有语法错误。
2.算法程序对于合法的输入数据能够产生满足要求的输出结果。
3.算法程序对于非法的输入数据能够得出满足规格说明的结果。
4.算法程序对于精心选择的,甚至刁难的测试数据都有满足要求的输出结果。
一般情况下,我们把层次3作为一个算法是否正确的标准。
可读性
算法设计的另一目的是为了便于阅读、理解和交流。可读性高有助于人们理解算法,晦涩难懂的算法往往隐含错误,不易被发现,并且难于调试和修改。
我们写代码的目的,一方面是为了让计算机执行,另一个很重要的方面是为了便于他人阅读,让人理解和交流。自己将来也有可能阅读自己的代码,如果可读性不好的话,时间长了自己都不知道写了些什么。由此可见,可读性是算法(包括实现它的代码)好坏的很重要的标准。
健壮性
一个好的算法应该能对输入数据不合法的情况做合适的处理。当输入数据不合法时,算法也能做出相关处理,而不是产生异常或莫名奇妙的结果。
时间效率高和存储量低
好的算法还应该具备时间效率高和存储量低的特点。时间效率指的是算法的执行时间,执行时间短的算法效率高。存储量需求指的是算法在执行过程中需要的最大存储空间,主要指算法程序运行时所占用的内存或外部硬盘存储空间。设计算法应该尽量满足时间效率高和存储量低的需求。
算法效率的度量方法
我们如何度量一个算法的执行时间呢?很简单,我们通过对算法的数据测试,利用计算机的计时功能,来计算不同算法的效率是高还是低。
事后统计方法
事后统计方法主要是通过设计好的测试程序和数据,利用计算机计时器对不同算法编制的程序的运行时间进行比较,从而确定算法效率的高低。
但是这种方法具有很大的缺陷。如果我们花费了大量的时间和精力,最后编制出算法之后发现它根本就是很糟糕的,就会造成“竹篮打水一场空”的尴尬。
除此之外,算法的测试数据设计困难,而且程序运行时间也跟测试数据的规模大小密切相关。计算机的硬件、软件等环境因素有时也会掩盖算法本身的优劣。
综上所述,我们一般不采纳事后统计方法。
事前分析估算方法
事前分析估算方法是指在计算机程序编制前依据统计方法对算法进行估算。一个用高级程序语言编写的程序在计算机上运行时所消耗的时间取决于下列因素:
1.算法采用的策略、方法。
2.编译产生的代码质量。
3.问题的输入规模。
4.机器执行指令的速度。
第1条当然是算法好坏的根本,第2条要由软件来支持,第4条要看硬件性能。抛开这些与计算机硬件、软件有关的因素,一个程序的运行时间,依赖于算法的好坏和问题的输入规模。所谓问题输入规模,就是指输入量的多少。
我们还是拿1-100求和这个问题举例子。
// 算法1:循环求和
int i, sum = 0; // 执行1次
for (i = 1; i <= 100; i++) { // 执行(n+1)次
sum += i; // 执行n次
}
printf("%d\n", sum); // 执行1次
// 算法2:高斯求和
int n = 100, sum = 0; // 执行1次
sum = (1 + n) * n / 2; // 执行1次
printf("%d\n", sum); // 执行1次
循环求和算法执行了[1+(n+1)+n+1]次=(2n+3)次,而高斯求和算法执行了(1+1+1)次=3次。事实上,两个算法的第一条和最后一条语句是一样的,我们关注的代码其实是中间的部分。我们把循环看作一个整体,忽略头尾循环判断的开销,那么这两个算法其实就是n次与1次的差距。
我们不关心编写程序使用了什么语言,也不关心这些程序将会在什么样的计算机中运行,我们只关心它所实现的算法。在分析程序的运行时间时,最重要的是把程序看成是独立于程序设计语言的算法或一系列步骤。
由上图可以明显看出,随着n的不断增大,它们在时间效率上的差异也就越来越大。
函数的渐进增长
给定两个函数f(n)和g(n),如果存在一个整数N,使得对于所有的n>N,f(n)总是比g(n)大,那么,我们说f(n)的增长渐进快于g(n)。也就是说,输入规模n在没有限制的情况下,只要超过一个数值N,这个函数就总是大于另一个函数,我们称函数是渐进增长的。
判断一个算法的效率时,函数中的常数和其他次要项常常可以忽略,而更应该关注主项(最高阶项)的阶数。某个算法,随着n的增大,它会越来越优于另一算法,或者越来越差于另一算法。
时间复杂度
定义
在进行算法分析时,语句总的执行次数T(n)是关于问题规模n的函数,进而分析T(n)随n的变化情况并确定T(n)的数量级。算法的时间复杂度,也就是算法的时间量度,记作:T(n)=O(f(n))。它表示随问题规模n的增大,算法执行时间的增长率和f(n)的增长率相同,称作算法的渐进时间复杂度,简称为时间复杂度。其中f(n)是问题规模n的某个函数。
这样用大写O()来体现算法时间复杂度的记法,我们称之为大O记法。
一般情况下,随着n的增大,T(n)增长最慢的算法为最优算法。
推导大O阶方法
1.用常数1取代运行时间中的所有加法常数。
2.在修改后的运行次数函数中,只保留最高阶项。
3.如果最高阶项存在且不是1,则去除与这个项相乘的函数。
经过上述3步运算,得到的结果就是大O阶。
常数阶
我们还是以高斯求和算法为例。
int n = 100, sum = 0; // 执行1次
sum = (1 + n) * n / 2; // 执行1次
printf("%d\n", sum); // 执行1次
这个算法的运行次数函数是f(n)=3。根据推导大O阶的方法,第1步,我们把常数项3改为1。它没有最高阶项,所以这个算法的时间复杂度为O(1)。
这种与问题的大小无关(n的多少),执行时间恒定的算法,我们称之为具有O(1)的时间复杂度,又叫常数阶。不管这个常数是多少,我们都记作O(1),括号里不能是其他任何数字。
对于分支结构而言,无论是真还是假,执行的次数都是恒定的,不会随着n的变大而发生变化,所以单纯的分支结构(不包含在循环结构中),其时间复杂度也是O(1)。
线性阶
下面这段代码,它的循环的时间复杂度为O(n),因为循环体中的代码执行了n次。
int i;
for (i = 0; i < n; i++) {
/* 时间复杂度为O(1)的程序步骤序列 */
}
对数阶
我们再来看一段代码:
int count = 1;
while (count > n) {
count = count * 2;
/* 时间复杂度为O(1)的程序步骤序列 */
}
由于每次count
乘以2之后,就距离n更近了一分,也就是说,有多少个2相乘后大于n,则会退出循环。由
2
x
=
n
2^x=n
2x=n可得
x
=
l
o
g
2
n
.
x=log_2n.
x=log2n.所以这个循环的时间复杂度为O(logn)。
平方阶
下面的例子是一个循环嵌套:
int i, j;
for (i = 0; i < n; i++) {
for (j = 0; j < n; j++) {
/* 时间复杂度为O(1)的程序步骤序列 */
}
}
刚才我们分析过,内循环的时间复杂度为O(n)。而对于外层的循环,其实就是内层的时间复杂度为O(n)的语句再循环n次。所以这段代码的时间复杂度为O(n2)。
我们对上面的代码稍作修改:
int i, j;
for (i = 0; i < m; i++) {
for (j = 0; j < n; j++) {
/* 时间复杂度为O(1)的程序步骤序列 */
}
}
在这段代码中,外循环的循环次数改为了m,那么时间复杂度就变为O(m×n)。
我们可以总结得出:循环的时间复杂度=循环体的复杂度×该循环运行的次数。
我们再来看一个循环嵌套:
int i, j;
for (i = 0; i < n; i++) {
for (j = i; j < n; j++) {
/* 时间复杂度为O(1)的程序步骤序列 */
}
}
当i=0时,内循环执行了n次;当i=1时,内循环执行了(n-1)次……当i=n-1时,内循环执行了一次。所以有总的执行次数为
n
+
(
n
−
1
)
+
(
n
−
2
)
+
.
.
.
+
1
=
n
(
n
+
1
)
2
=
n
2
2
+
n
2
.
n+(n-1)+(n-2)+...+1=\frac{n(n+1)}{2}=\frac{n^2}{2}+\frac{n}{2}.
n+(n−1)+(n−2)+...+1=2n(n+1)=2n2+2n.
用推导大O阶的方法,最终这段代码的时间复杂度为O(n2)。
接下来我们来看一段相对复杂的代码:
void function(int count) {
int j;
for (j = count; j < n; j++) {
/* 时间复杂度为O(1)的程序步骤序列 */
}
}
n++;
function(n);
int i, j;
for (i = 0; i < n; i++) {
function(i);
}
for (i = 0; i < n; i++) {
for (j = i; j < n; j++) {
/* 时间复杂度为O(1)的程序步骤序列 */
}
}
函数function()
的时间复杂度为O(n)。为了方便,我们把代码的下半部分单独拿出来,注释上每条语句的执行次数。
n++; // 执行次数为1
function(n); // 执行次数为n
int i, j;
for (i = 0; i < n; i++) { // 执行次数为n×n
function(i);
}
for (i = 0; i < n; i++) { // 执行次数为n(n+1)/2
for (j = i; j < n; j++) {
/* 时间复杂度为O(1)的程序步骤序列 */
}
}
由上面的分析可得 f ( n ) = 1 + n + n 2 + n ( n + 1 ) 2 = 3 2 n 2 + 3 2 n + 1. f(n)=1+n+n^2+\frac{n(n+1)}{2}=\frac{3}{2}n^2+\frac{3}{2}n+1. f(n)=1+n+n2+2n(n+1)=23n2+23n+1.根据推导大O阶的方法,最终这段代码的时间复杂度也是O(n2)。
常见的时间复杂度
常见的时间复杂度如下表所示。
执行次数函数 | 阶 | 非正式术语 |
---|---|---|
12 | O(1) | 常数阶 |
2n+3 | O(n) | 线性阶 |
3n2+2n+1 | O(n2) | 平方阶 |
5log2n+20 | O(logn) | 对数阶 |
2n+3nlog2n+19 | O(nlogn) | nlogn阶 |
6n3+2n2+3n+4 | O(n3) | 立方阶 |
2n | O(2n) | 指数阶 |
常用的时间复杂度所耗费的时间从小到大依次是:O(1)<O(logn)<O(n)<O(nlogn)<O(n2)<O(n3)<O(2n)<O(n!)<O(nn)。
O(n3)之后的时间复杂度,过大的n都会使结果变得不现实。尤其是指数阶和阶乘阶等,除非n很小,否则就是噩梦般的运行时间。这种时间复杂度是不切实际的,我们一般不去讨论它。
最坏情况与平均情况
比如说我们查找一个有n个随机数字数组中的某个数字,最好的情况是第一个数字就是我们要找的数字,那么算法的时间复杂度为O(1)。最坏的情况就是我们要找的数字在数组的最后一个位置上,那么算法的时间复杂度就是O(n)。这就像我们在日常生活中找东西,运气好的话我们可以一下找到,不会花费太长时间;运气不好的话,我们怎么也找不到,花费的时间也是相当长。
最坏情况运行时间是一种保证,那就是运行时间将不会再长了。在应用中,这是一种最重要的需求,通常,除非特别指定,我们提到的运行时间都是最坏情况的运行时间。
平均运行时间是所有情况中最有意义的,因为它是期望的运行时间。从概率的角度看,这个数字在每一个位置的可能性是相同的,所以平均的查找时间为n/2次后发现这个目标元素。
一般在没有特殊说明的情况下,时间复杂度指最坏时间复杂度。
空间复杂度
算法的空间复杂度通过计算算法所需的存储空间实现,算法空间复杂度的计算公式记作S(n)=O(f(n)),其中,n为问题的规模,f(n)为语句关于n所占存储空间的函数。
我们在写代码时,可以用空间来换取时间。
一般情况下,一个程序在机器上执行时,除了需要存储程序本身的指令、常数、变量和输入数据外,还需要存储对数据操作的存储单元。若输入数据所占空间只取决于问题本身,和算法无关,这样只需要分析该算法在实现时所需的辅助单元即可。若算法执行时所需的辅助空间相对于输入数据量而言是个常数,则称此算法为原地工作,空间复杂度为O(1)。