大话数据结构-算法(笔记整理)

0 阅读12分钟

大话数据结构-算法(笔记整理)

2.1 算法

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

2.2 数据结构与算法关系

简单来说 数据结构 和 算法 是相辅相成的关系,学习算法是为了帮助理解数据结构

先把这个关系放在脑子里,下面用一个具体求和例子看“同题不同算法”的差异。

2.3 两种算法的比较

这边先举个例子:计算1加到100,

初学者第一想法:

  1. 定义一个 sum=0 i=100
  2. for i
  3. sum += i 结果:sum=5050

结果是对的,但这样真的好吗?是否还有更优解?

高斯的做法:

  1. 定义一个sum=0 n=100
  2. 写个公式 (n+1) x n/2 结果:sum =5050

对比上面的做法,优在哪里呢?如果我要你计算10000遍,那上边的就要循环并累加10000遍,这运算时长明显会比高斯的方法慢很多,这也提及到了 时间复杂度(后面在讲)

上面的求和例子说明:算法不只要算对,还要算得快。要继续比较算法优劣,先要明确什么是算法。

2.4 算法定义

这里不再重复 2.1 的定义,重点补充“同一问题可以有多种算法,但效率不同”的实践理解。

一个问题,可以有多个解决的算法,你能说 初学者 的结果不对吗?只是运算速度上稍慢,还有一种的写法 (n * (n+1)) >> 1,没有通用的算法,只是针对不同的问题,写成更好,更能解决指定问题的算法,这就是学习算法的意义

定义明确之后,接下来就看一个合格算法应具备哪些基本特性。

2.5 算法的特性

算法具有五个基本特性:输入、输出、有穷性、确定性和可行性

输入 输出:算法具有零个或多个输入 和 具有1个或多个输出,就跟定义一个函数一样,你可以不传参数,但你要 return 结果出去,不然定义这个函数干嘛?

有穷性:指算法在执行有限的步骤之后,自动结束而不会出现无限循环,并且每一个步骤在可接受的时间内完成, 想想为什么要用到算法,不就是为了提高速度吗,那如果这个算法是无穷的,那还写这个算法干嘛?

确定性:算法的每一步骤都具有确定的含义,不会出现二义性,算法一般是为了解决特定问题,结果一定是特定问题的结果,不会出现其他答案.一样的道理,如果最后打结果是错误的,那还有什么意义

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

知道“算法是什么、应具备什么特性”后,还要落到实践:设计算法时要满足哪些要求。

2.6 算法设计的要求

2.6.1 正确性

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

大体分为以下四个层次。

  1. 算法程序没有语法错误。

  2. 算法程序对于合法的输入数据能够产生满足要求的输出结果。

  3. 算法程序对于非法的输入数据能够得出满足规格说明的结果。

  4. 算法程序对于精心选择的,甚至刁难的测试数据都有满足要求的输出结果

2.6.2 可读性

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

2.6.3 健壮性

健壮性:当输入数据不合法时,算法也能做出相关处理,而不是产生异常或莫名其妙的结果

2.6.4 时间效率高和存储量低

设计算法应该尽量满足时间效率高和存储量低的需求

明确设计要求后,下一步就是回答:怎样度量算法效率。

2.7 算法效率的度量方法

2.7.1 事后统计方法

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

2.7.2 事前分析估算方法

事前分析估算方法:在计算机程序编制前,依据统计方法对算法进行估算

  1. 算法采用的策略、方法。 算法好坏的根本,一个错误的策略和方法,直接导致结果的错误

  2. 编译产生的代码质量。

  3. 问题的输入规模。

  4. 机器执行指令的速度 这个还要看硬件和软件的性能

一个程序的运行时间,依赖于算法的好坏和问题的输入规模。所谓问题输入规模是指输入量的多少,回到之前举例的1加到100的例子

第一种: 执行了 1 + (n + 1) + n + 1 = 102 次

第二种:执行了 1 + 1 + 1 = 3次,

拓展一下: for里面在写一个for循环,那就是执行了 1 + n^n + 1 = 10002次,

对运行时间有消耗的 基本操作的执行次数 ,执行次数越少 运行时间越快

最终,在分析程序的运行时间时,最重要的是把程序看成是独立于程序设计语言的算法或一系列步骤

有了度量方法之后,还需要理解一个核心思想:输入规模变大时,增长趋势更关键。

2.8 函数的渐近增长

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

判断一下算法 a 和 b,哪个更好:算法 a 是 2n + 3,算法 b 是 3n + 1。

我们来假设n =1 , n=2,n=3,n=10

n = 1 a = 5 , b = 4

n = 2 a = 7 , b = 7

n =3 a = 9 , b = 10

n =10 a = 23 , b = 31

注意到了吗,当n=1的时候 算法 b 的执行次数要比 算法a的执行次数要少 ,当 n =3 的时候 算法a的次数又要比 算法b 的执行次数要少了,所以 算法 a 要在整体上要比算法b好些。当n越来越大时,后面的 +3或者+1影响其实已经很小了,所以我们可以忽略这些加法常数

上面例子忽略加法常数不太明显,接下来看:算法 C 是 4n + 8,算法 D 是 2n^2 + 1

n =1 c= 12 d = 3

n = 3 c =20 d =19

n = 10 c = 48 d = 201

n = 100 c=408 d = 20001

这样就很明显了,而且 2n^2 前面的 2 对结果没有很大的影响,当 n 越来越大时,n^2 是远远大于 n 的,得知 与最高次项相乘的常数并不重要

第三个例子 算法 E 是 2n^2 + 3n + 1,算法 F 是 2n^3 + 3n + 1

n=1 e=6 f=6

n=2 e=15 f=23

n=3 e=28 f=64

n= 10 e=231 f=2031

由上面例子我们可以得出:最高次项的指数大的,函数随着n的增长,结果也会变得增长特别快

第四个例子 算法 G 是 2n^2,算法 H 是 3n + 1,算法 I 是 2n^2 + 3n + 1

n=1 g=2 h=4 i=6

n=2 g=8 h=7 i=15

n=5 g=50 h=16 i=66

n= 10 g=200 h=31 i=231

可以注意到,当n越来越大时, 算法 g 和 算法 i 的结果越来越近,由此可以得知:判断一个算法的效率时,函数中的常数和其他次要项常常可以忽略,而更应该关注主项(最高阶项)的阶数

根据上面的例子,我们去判断.某个算法,随着n的增大,它会越来越优于另一算法,或者越来越差于另一算法

基于渐近增长的直觉,下面正式进入时间复杂度与大 O 表示法。

2.9 算法时间复杂度

2.9.1 算法时间复杂度定义

在进行算法分析时,语句总的执行次数 T(n) 是关于问题规模 n 的函数,进而分析 T(n) 随 n 的变化情况并确定 T(n) 的数量级。算法的时间复杂度,也就是算法的时间量度,记作:T(n) = O(f(n))。它表示随问题规模 n 的增大,算法执行时间的增长率和 f(n) 的增长率相同,称作算法的渐近时间复杂度,简称为时间复杂度。其中 f(n) 是问题规模 n 的某个函数

用大写的 O() 来表达算法时间复杂度,这里又分 O(1) 叫常数阶、O(n) 叫线性阶、O(n^2) 叫平方阶

2.9.2 推导大 O 阶方法

推导大 O 阶:

  1. 用常数1取代运行时间中的所有加法常数。

  2. 在修改后的运行次数函数中,只保留最高阶项。

  3. 如果最高阶项存在且不是1,则去除与这个项相乘的常数。得到的结果就是大 O 阶。

2.9.3 常数阶

看到高斯提供的方法

	int sum = 0,n = 100; /* 执行一次 */
	sum = (1 + n) * n / 2; /* 执行一次 */
	printf("%d", sum); /* 执行一次 */

是否会觉得等价于 O(3)?这是不对的。看到 2.9.2 第一条,应该是 O(1),再看

	int sum = 0,n = 100; /* 执行一次 */
	sum = (1 + n) * n / 2; /* 执行一次 */
	sum = (1 + n) * n / 2; /* 执行一次 */
	sum = (1 + n) * n / 2; /* 执行一次 */
	sum = (1 + n) * n / 2; /* 执行一次 */
	sum = (1 + n) * n / 2; /* 执行一次 */
	sum = (1 + n) * n / 2; /* 执行一次 */
	sum = (1 + n) * n / 2; /* 执行一次 */
	sum = (1 + n) * n / 2; /* 执行一次 */
	sum = (1 + n) * n / 2; /* 执行一次 */
	sum = (1 + n) * n / 2; /* 执行一次 */
	printf("%d", sum); /* 执行一次 */

执行十次 sum 等价于 O(12)?可惜,依旧是 O(1)

方便记忆点:当执行次数不随 n 的变化而变化时,统一写成 O(1),这也就是常数阶

2.9.4 线性阶

线性阶的循环结构会复杂很多,因此我们要分析算法的复杂度,关键就是要分析循环结构的运行情况

想想 for 循环,它的时间复杂度就是 O(n)

2.9.5 对数阶

int count = 1; 
while (count < n) {
 count = count * 2; /* 时间复杂度为O(1)的程序步骤序列 */ 
 }

这个时间复杂度为 O(log n)

2.9.6 平方阶

for 循环里面再套一层 for 循环,时间复杂度就是 O(n^2)

如果套在里面的for循环 循环体改成了m ,那时间复杂度就是O(n x m)

由此我们可以得到:循环的时间复杂度等于循环体的复杂度乘以该循环运行的次数

那下面代码的循环嵌套,他的时间复杂度是多少呢?

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

首先我们得知道他执行了多少次

当 i = 0,执行 n 次,当 i = 1 时,执行 n-1 次..... 可以推断出公式 n^2/2 + n/2

回想推导大 O 阶法:

  1. 忽略常数阶 (n/2 忽略)

  2. 只保留最高阶 (n^2/2 保留)

  3. 去除项得常数 (也就是 /2 去除)

最终时间复杂度为 O(n^2) (我现在知道为什么学算法要数学好了)

理解了几种典型阶后,可以把常见复杂度放在同一张“增长速度”对照表里看。

2.10 常见的时间复杂度

常用的时间复杂度所耗费的时间从小到大依次是:O(1) < O(log n) < O(n) < O(n log n) < O(n^2) < O(n^3) < O(2^n)(2 的 n 次方) < O(n!) < O(n^n)(n 的 n 次方)

除了阶的大小,实际分析中还要区分最坏情况和平均情况。

2.11 最坏情况与平均情况

我们来举个例子:[1,2,4,3,5]

我现在要你找数组里等于1的数,那我一下就找到了,时间复杂度就是O(1)

那现在要找数组里等于5的数呢,那时间复杂度就是O(n)了

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

平均运行时间也就是从概率的角度看,这个数字在每一个位置的可能性是相同的,所以平均的查找时间为n/2次后发现这个目标元素

平均运行时间是所有情况中最有意义的,因为它是期望的运行时间

前面主要看的是时间开销,最后补上另一半:算法在空间上的代价。

2.12 算法空间复杂度

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

判断某某年是不是闰年,

我们可以写一个算法,去判断是不是闰年,但每次给一个年份,都要通过算法计算是不是闰年。

我们可以用另一个办法,也就是使用空间,定义一个有2050个数据的数组,如果不是闰年就传递0,是闰年就传递1,

这样,就从 判断某一年是否是闰年 变成了 查找这个数组的某一项的值是多少,这样就是 运算是最小化 ,但空间上多了,内存中需要存储这2050个0和1