2.1 两种算法的比较
编写1+2+3+······+100的程序:
大多数人会马上写出下面的C语言代码(或其他语言的代码):
int i, sum = 0, n = 100;
for(i = 1; i < = n; i++)
{
sum = sum + i;
}
printf(" %d ", sum)
这是最简单的计算机程序之一,但是这样是不是真的是很好?是不是最高效?
伟大数学家高斯的故事中老师就是要求每个学生计算1+2+3+······+100的结果。高斯很快就得到了答案,是5050。老师非常吃惊,因为老师自己也是通过1+2=3,3+3=6,6+4=10,······,4950+100=5050这样算出来的。但高斯是这样算的:
sum = 1 + 2 + 3 + ······ + 99 + 100
sum = 100 + 99 + 98 + ······ + 2 + 1
2×sum = 101 + 101 + 101 + ······ + 101 + 101(共100个)
所以sum = 5050
用程序来实现如下:
int sum = 0, n = 100;
sum = (1 + n) * n / 2;
printf("%d", sum)
高斯用的方法相当于另一种求等差数列的算法,不仅可以用于1加到100,就算加到1000、10000、100000000(需要更改整型变量类型为长整型,否则会溢出),也是瞬间的事。但是如果用刚才的程序,显然计算机需要循环一千、一万、一亿次的加法运算。人脑比电脑算的快,似乎成为了现实。
2.2 算法定义及特性
算法是解决特定问题求解步骤的描述,在计算机中表现为指令的有限序列,并且每条指令表示一个或多个操作。
1.输入输出
算法具有零个或多个输入,至少一个或多个输出。(对于直接打印“hello world!”这样的代码,不需要任何输入参数)
2.有穷性
算法在执行有限的步骤之后,自动结束而不会出现无限循环,并且每一个步骤在可接受的时间内完成。
3.确定性
算法的每一步骤都具有确定的含义,不会出现二义性。
4.可行性
算法的每一步都必须是可行的,也就是说,每一步都能通过执行有限次数完成。
2.3 算法设计的要求
1. 正确性:算法的正确性是指算法至少应该具有输入、输出和加工处理无歧义性、能正确反映问题的需求,能够得到问题的正确答案。
但是算法的“正确”通常在用法上有很大的区别,大体分为以下四个层次:
- 算法程序没有无法错误。
- 算法程序对于合法的输入数据能够产生满足要求的输出结果。
- 算法程序对于非法的输入数据能够得出满足规格说明的结果。
- 算法程序对于精心选择的,甚至刁难的测试数据都有满足要求的输出结果。
一般情况下,我们把层次3作为一个算法是否标准的依据。
2. 可读性:算法设计的另一目的是为了便于阅读理解和交流。晦涩难懂的算法往往隐含错误,不易被发现,并且难于调试和修改。
3. 健壮性:当输入数据不合法时,算法也能做出相关处理,而不是产生异常或莫名其妙的结果。比如输入的时间或者距离不应该是负数。
4.时间效率高和存储量低
时间效率是指算法的执行时间,对于同一个问题,如果有多个算法能够解决,执行时间短的算法效率高,执行时间长的算法效率低。
存储量需求指的是算法在算法在执行过程中所需要的最大存储空间,主要指算法程序运行时所占用的内存或外部硬盘存储空间。
2.4 算法效率的度量方法
1. 事后统计方法:这种方法主要是通过设计好的测试程序和数据,利用计算机计时器对不同算法编制的程序的运行时间进行比较,从而确定算法效率的高低。
但这种方法显然有很大的缺陷:
- 必须依据算法事先编制好程序,这通常需要花费大量的时间和精力。如果编制出来发现算法很糟糕,不是竹篮打水一场空么?
- 时间的比较依赖计算机硬件和软件等环境因素,有时会掩盖算法本身的优劣。
- 算法的测试数据设计困难,并且程序的运行时间往往还与测试数据的规模有很大的关系效率高的算法在小的测试数据面前往往得不到体现。
基于事后统计方法的诸多缺陷,我们考虑不予采纳。
2. 事前分析估算方法:在计算机程序编制前,依据统计方法对算法进行估算。
经分析发现,一个用高级程序编写的程序在计算机上运行时所消耗的时间取决于下列因素:
- 算法采用的策略、方法。
- 编译产生的代码质量。
- 问题的输入规模。
- 机器执行命令的速度。
第 1 条是算法好坏的根本,第 2 条要由软件来支持,第 4 条要看硬件性能。也就是说,抛开这些与计算机硬件、软件有关的因素,一个程序的运行时间,依赖于算法的好坏和问题的输入规模。所谓问题输入规模是指输入量的多少。
之前的两个求和算法的例子:
第一种算法:
int i, sum = 0, n = 100; /* 执行1次 */
for(i = 1; i < = n; i++) /* 执行n+1次 */
{
sum = sum + i; /* 执行n次 */
}
printf(" %d ", sum) /* 执行1次 */
第二种算法:
int sum = 0, n = 100; /* 执行1次 */
sum = (1 + n) * n / 2; /* 执行1次 */
printf("%d", sum) /* 执行1次 */
显然,第一种算法,执行了 1+(n+1)+n+1 = 2n+3 次;而第二种算法,是 1+1+1=3 次。事实上两个算法的第一条和最后一条语句是一样,所以我们关注的代码其实是中间的部分,我们把循环看作一个整体,忽略头尾循环判断的开销,那么两个算法其实就是 n 次与 1 次的差距。算法好坏显而易见。
我们再来延伸一下上面的例子:
int i, j, x = 0; sum = 0, n = 100; /* 执行1次 */
for(i = 1; i < = n; i++)
{
for(j = 1;j <= n; j++)
{
x++; /* 执行n×n次 */
sum = sum + x;
}
}
printf(" %d ", sum) /* 执行1次 */
这个例子中,i 从 1 到 100,每次都要让i循环 100 次,而当中的 x++ 和 sum = sum + x ; 其实就是 1+2+3+···+10000,也就是
1002
,所以这个算法中,循环部分的代码整体需要执行
n2
(忽略循环体头尾的开销)次。显然这个算法的执行次数对于同样的输入规模 n = 100,要多于前面两种算法,这个算法的执行时间随着 n 的增加也将远远多于前面两个。
此时你就会看到,测定运行时间最可靠的方法就是计算对运行时间有消耗的基本操作的执行次数。运行时间与这个计数成正比。所以,在分析程序的运行时间时,最重要的是把程序看成是独立于程序设计语言的算法或一系列步骤。
同样问题的输入规模是 n,求和算法的第一种,求 1+2+3+···+n 需要一段代码运行 n 次。那么这个问题的输入规模使得操作数量是 f(n) = n;而第二种,运行次数都为 1,即 f(n) = 1;第三种,f(n) =
n2
。