「笔记」《大话数据结构》第二章:算法

版权声明:欢迎转载,转载请注明出处。 如果本文帮助到你,本人不胜荣幸,如果浪费了你的时间,本人深感抱歉。如果有什么错误,请一定指出,以免误导大家、也误导我。感谢关注。 https://blog.csdn.net/Airsaid/article/details/78377402

本文笔记来自《大话数据结构》

数据结构与算法关系

我的理解是,算法就是三国中的军师,比如说诸葛亮,提出了很多计谋,解决方法。而数据结构则就是士兵,来去处理军师所提出来的谋略。

两种算法的比较

给一个编程问题,求 1 到 100 的和,用编程实现。
相信我们很快就能想到第一种:

int sum = 0, count = 100;
for (int i = 1; i <= count; i++) {
    sum += i;
}
System.out.println(sum);// 5050

这就是一种算法,通过这种方式的却能得到最终结果,但是由于涉及到循环,当数据量庞大时,效率可想而知了。
再思考思考,能不能有更优的算法呢?

找找其中的规律,可以发现 1 + 100 = 101,2 + 99 = 101,依次类推。可以得到另一种解决方式:

int count = 100;
int sum = (count + 1) * count / 2;
System.out.println(sum);// 5050

这就是一个简单的求等差数列算法。
通过这个例子,可以发现算法的美妙之处了。
如果你在同一个类似程序中发现有的运行快,有的运行慢,其中就有可能是快的一方运用到了算法喔!

算法定义

算法(Algorithm)是描述解决问题的方法。如今普遍认可的对算法的定义是:

算法是解决特定问题求解步骤的描述,在计算机中变现为指令的有限序列,并且每条指令表示一个或多个操作。

算法的特性

算法具有几个基本特性,分别如下。

输入输出

算法具有零个或多个输入,并且至少有一个或多个输出。

尽管对于大多数算法来说,输入参数都是非常有必要的。但是对于个别情况,如打印 “Hello World!” 这样的代码是不需要参数的。
但是,算法是必须要有输出的。输出的形式可以是打印输出,也可以是返回一个或多个值。

有穷性

算法在执行有限的步骤后,自动结束而不会出现无限循环,并且每个步骤在可接受的时间内完成。

写的代码死循环了,那么这就不满足有穷性。当然这里的有穷性并不是指纯数学意义的,而是在实际应用中合理的、可以接受的 “有边界”。
要是写一个算法,运行个几十年后才有结果,这在数学意义上来说得确是满足有穷性了,但是意义也就不大了。

确定性

算法的每一个步骤都具有确定的含义,不会出现二义性。

算法在一定的条件下,只有一条执行路径,相同的输入只能有唯一的输出结果。

可行性

算法的每一步都必须是可行的,也就是说,每一步都能通过执行有限次数完成。

可行性意味着算法可以转换为程序运行,并得到正确的结果。

算法设计的要求

通过上面那个例子我们就能够看到,算法并不唯一。同一个问题,可能会有多个算法可以解决。
那么对于算法而言,也存在与好的算法和坏的算法。那么什么才叫好的算法呢?

首先第一个,也是最基本的一个。好的算法,必须是正确的。

正确性

算法的正确性是指算法至少应该具有输入、输出和加工处理无歧义性、能正确反映问题的需求、能够得到问题的正确答案。

但是算法的 “正确” 在用法上有很大的差别,大体分为如下四个层次:

  • 1,算法程序没有语法错误。
  • 2,算法程序对于合法的输入数据能够产生满足要求的结果。
  • 3,算法程序对于非法的输入数据能够得到满足规格说明的结果。
  • 4,算法程序对于精心选择的,甚至刁难的测试数据都有满足要求的输出结果。

对于这四层含义,我们通常以层次 3 作为一个算法是否正确的标准。

可读性

算法设计的另一目的是为了便于阅读、理解和交流。

也就是说,一个好的算法,应该不仅自己能够看懂,别人也能够看懂。
过分极致的追求最少的代码,往往会降低代码的可阅读性,这往往并不可取。

健壮性

当输入数据不合法时,算法也能够做出相关处理,而不是产生莫名的结果或者是异常。

一个好的算法还应该能够对输如不合法的数据进行合适的处理,比如说,输入的年龄不应该为负数。

时间效率高和存储量低

最后,好的算法还应该具备时间效率高和存储量低的特点。

  • 时间效率指的是算法的执行时间。
  • 存储量需求指的是算法在执行过程中需要最大的存储空间,越低越好。主要指算法程序运行时所占用的内存或外部存储空间。

我们在设计算法时,应该尽量去做到这一点。

综上,一个好的算法应该具备:正确性、可读性、健壮性、时间效率高和存储量低的特征。

算法效率的度量方法

算法的效率大都指的是算法的执行时间。那么我们如何去计算算法所执行的时间呢?

事后统计方法

这种方法主要是通过设计好的测试程序和数据,利用计算机对不同算法编制的程序的运行时间进行比较,从而确定算法效率的高低。

但是这种方法会有很大的弊端:

  • 需要花费大量时间和精力来根据算法编写测试程序或数据。
  • 依赖的计算机环境并不一致。
  • 算法的测试数据设计困难。

事前分析估算方法

在计算机程序编制前,依据统计方法对算法进行估算。

计算机前辈们,为了对算法的评判更科学,研究出了一套事前分析估算的方法来计算算法的效率。
经过分析,我们发现,一个用高级语言编写的程序在计算机上运行所消耗的时间取决于下列因素:

  1. 算法采用的策略、方法。
  2. 编译产生的代码质量。
  3. 问题的输入规模。
  4. 机器执行指令的速度。

第一条是算法好坏的根本,第二条由软件来支持,第四条要看机器的硬件性能。也就是说,抛开这些与自身和软件、硬件因素,剩下的就只有问题的输入规模了。

问题的输入规模就是指的输入量的多少。

我们在分析一个算法的运行时间时,重要的是要把操作的数量和输入规模相关联起来。

来看看开头的第一个例子,输入规模就指的就是 count:

int sum = 0, count = 100;
for (int i = 1; i <= count; i++) {
    sum += i;// 一次计算操作(操作数量随输入规模)
}
System.out.println(sum);

可以看到,输入规模越大,操作数量也越多。 1 + 2 + 3 + n… 需要一段代码运行 n 次,那么这个问题的输入规模使得操作数量是 f(n)= n。

再来看看第二个例子:

int count = 100;
int sum = (count + 1) * count / 2;// 执行一次(操作数量)
System.out.println(sum);

在该例子中,无论 count 为多少,其操作数量依然为 1。即 f(n)= 1。算法的优劣,由此可见。

我们并不关心编写程序所用的语言是什么,也不关心这些程序将跑在什么样的计算机中,我们只关心它所实现的算法。这样,不计那些循环索引的递增和循环终止条件、变量声明、打印结果等操作。
最终,在分析程序的运行时间时,最重要的是把程序看成是独立与程序设计语言的算法或一系列步骤。

函数的渐进增长

我们再来比较下两种算法哪个更好,假设 A 算法需要执行 2n + 3 次操作,可以理解为先有一个 n 次的循环,执行完成后再有一个 n 次的循环,最后有三次赋值运算。
B 算法要做 3n + 1 次操作,你觉得哪个更快呢?

准确来说,答案是不固定的。还是得看 n 的次数。

当 n = 1 时,算法 A 效率不如算法 B (次数要比算法 B 多一些)。而当 n = 2 时,两者效率相同。当 n = 3 时,算法 A 就开始优于 B 了。当 n 越来越大时,A 的效率也越来越比 B 好。

此时我们给出这样的定义,输入规模 n 在没有限制的情况下,只要超过一个数值 N,这个函数就总是大于另一个函数,我们称函数是渐进增长的:

函数的渐进增长:给定两个函数 f(n) 和 g(n),如果存在一个整数 N,使得对于所有的 n > N,f(n)总是比 g(n)大,那么,我们说 f(n)的增长渐快与 g(n)。

从上面的例子中我们可以发现,随着 n 的增大,后面的 +3 还是 +1 其实是不影响最终算法的变化的,所以,我们可以忽略这些加法常数。

某个算法,随着 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(n)、O(1)。如果有双重循环,则就是 O(n 2 )。

推导大 O 阶方法

那么如何分析一个算法的时间复杂度呢?即如何推导大 O 阶呢?我们给出了下面的推导方法:
1. 用常数 1 取代运行时间中的所有加法常数。
2. 在修改后的运行次数函数中,只保留最高阶项。
3. 如果最高阶项存在且不是 1,则去除与这个项相乘的常数。得到的结果就是大 O 阶。

我们好像已经得到了一个推导算法时间复杂度的万能公式。可事实上,分析一个算法事件复杂度,没有这么简单,我们还需要看看如下的例子。

常数阶

首先顺序结构的时间复杂度。再来看看开头的第二个算法:

int count = 100;// 执行一次
int sum = (count + 1) * count / 2;// 执行一次
System.out.println(sum);// 执行一次

为什么时间复杂度不是 O(3),而是 O(1)?
这个算法的运行次数函数是 f(n) = 3,根据推导大 O 阶的方法,第一步就是把常数项 3 改为 1,再保留最高阶项时发现,它根本就没有最高阶,所以这个算法的时间复杂度为 O(1)。

与问题的大小无关(n 的多少),执行时间恒定的算法,我们称之为具有 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 得到 x = log 2 n。
所以上面这段代码的时间复杂度为 O(logn)。

平方阶

int i, j;
for(i = 0; i < n; i++){
    for(j = 0; j < n; j++){
        // 时间复杂度为 O(1)的程序步骤序列
    }
}

上面的循环嵌套例子,内循环我们已经分析过了,时间复杂度是 O(n)。
而对于外层的循环,不过是内部这个时间复杂度为 O(n)的语句,再循环一次,也就是 O(n 2 )。

我们再把外循环的循环次数改为 m,事件复杂度就变为 O(m × n)。

int i, j;
for (i = 0; i < m; i++) {
    for (j = 0; j < n; j++) {
        // 时间复杂度为 O(1)的程序步骤序列
    }
}

所以我们可以总结出,循环的时间复杂度等于循环体的时间复杂度乘以该循环运行的次数。

常见的时间复杂度

前面我们已经了解到了其中的 O(1)常数阶、O(logn)对数阶、O(n)线性阶、O(n 2 )平方阶。

常用的时间复杂度所耗费的时间从少到多依次是:

O(1) < O(logn) < O(n) < O(nlogn) < O(n 2 ) < O(n 3 ) < O(2 n ) < O(n!) < O(n n )

最坏情况与平均情况

假设我们在一个随机数字的数组中查找一个数,有时候运气好,可能查找到的第一个就是的,这时候算法的时间复杂度是 O(1)。
但是也有可能这个数在最后一个位置待着,那么算法的时间复杂度则是 O(n),这是最坏的一种情况了。

最坏情况运行时间是一种保证,那就是运行时间将不会再坏了。在应用中,这是一种最重要的需求,通常,除非特别指定,我们提到的运行时间都是最坏情况的运行时间。

而平均运行时间也就是从概率的角度看,这个数字在每一个位置的可能性是相同的,所以平均的查找时间为 n / 2 次后发现这个目标元素。
平均运行时间是所有情况中最有意义的,因为它是期望的运行时间。也就是,我们运行一段程序代码时,是希望看到平均运行时间的。
但是在现实中,平均运行时间很难通过分析得到,一般都是通过运行一定数量级的实验数据后估算出来的。

对算法的分析,一种方法是计算所有情况的平均值,这种时间复杂度的计算方法成为平均时间复杂度。
另一种方法是计算最坏情况下的时间复杂度,这种方法称为最坏时间复杂度。一般在没有特殊说明的情况下,都是指的是最坏时间复杂度。

算法空间复杂度

算法的空间复杂度通过计算算法所需的存储空间实现,算法空间复杂度的计算公式记作:S(n)= O(f(n)),其中,n 为问题的规模,f(n)为语句关于 n 所占用存储空间的函数。

我们在写代码时,完全可以用空间换取时间。比如说,要判断某某年是不是闰年,你可能需要花时间和心思来写一个算法来计算了。这样在每次获取时,都要通过计算才能得到闰年的结果。
还有一种方法,就是新建一个数组,把年份按下标存储起来,是闰年,对于数据项的值就为 1,否则为 0。这样,所谓的判断某一年是否是闰年就变成了查找和这个数组的某一项是否为 1 了。
当然,此时就要牺牲一些硬盘中或者说内存中的空间了。

这是通过一笔空间换时间的例子,到底哪个好,其实还是要根据实际情况来。

总结回顾

在这章中,对算法算是有了一个大概的了解,以及了解到了算法和数据结构的关系是密不可分的,还学习了如何推导大 O 阶。
算法的优劣决定了程序的运行速度,要想让你的程序比别人更快,那么在平时多注意自己的代码,写出并运用到好的算法吧。

猜你喜欢

转载自blog.csdn.net/Airsaid/article/details/78377402