大话数据结构笔记-第一、二章
主要内容
数据结构介绍
算法推导大O阶的方法
线性表结构的介绍
顺序结构与链式结构的介绍
二叉树前中后序遍历
线索二叉树
哈夫曼树及应用
图结构的介绍
图的深度、广度遍历
最小生成树两种算法
最短路径两种算法
拓扑排序与关键路径算法
查找应用的相关介绍
折半查找、插值查找、斐波那契查找等静态查找
稠密索引、分块索引、倒排索引等索引技术
二叉排序树、平衡二叉树等动态查找
B树、B+树技术
散列表技术
排序应用的相关介绍
冒泡、选择、插入等简单排序
希尔、堆、归并、快速等改进排序
各种排序算法的对比等
1.数据结构绪论
数据结构:是相互之间存在一种或多种特定关系的数据元素的集合
谨记:数据结构是怎么学的?
数据结构是一门研究非数值计算的程序设计问题中的操作对象,以及它们之间的关系和操作等相关问题的学科
程序 = 数据结构 + 算法
一、基本概念和术语
1、数据:是描述客观事物的符号,是计算机中可以操作的对象,是能被计算机识别并输入给计算机处理的符号集合
数据其实就是符号,这些符号必须具备两个条件:
a.可以输入给计算机中
b.能被计算机程序处理
数据类型:
整型、实型等数值类型可以进行数值计算
字符数据类型,非数值的处理
2、数据元素:是组成数据的、有一定意义的基本单位,在计算机中通常作为整体处理,也被称为记录
比如:人类中,人是数据元素
3、数据项:一个数据元素可以有若干个数据项组成
数据项是数据不可分割的最小单位
4、数据对象:是性质相同的数据元素的集合,是数据的子集。
ps:性质相同是指数据元素具有相同数量和类型的数据项
在实际应用中,处理的数据元素通常具有相同性质,在不产生混淆的情况下,我们都将数据对象简称数据
数据 > 数据对象 > 数据元素 > 数据项
5、结构:不同数据元素之间不是独立的,而是存在特定的关系,我们将这些关系称为结构。
数据结构:是相互之间存在一种或多种特定关系的数据元素的集合
二、逻辑结构和物理结构
逻辑结构是面向问题的,而物理结构就是面向计算机的,其基本的目标就是将数据及其逻辑关系存储到计算机中的内存中。
1、逻辑结构:数据对象中数据元素之间的相互关系
- 逻辑结构分为以下四种:
- 集合结构:集合结构中的数据元素除了同属于一个集合外,他们之间没有其他关系。
- 线性结构:线性结构中的数据元素之间是一对一的关系。
- 树形结构:树形结构中的数据元素之间存在一种一对多的层次关系
- 图形结构:图形结构的数据元素是多对多的关系
3、物理结构(存储结构):数据的逻辑结构在计算机中的存储形式
- 关键:数据的存储结构应正确反映数据元素之间的逻辑关系
- 重点和难点:如何存储数据元素之间的逻辑关系
- 数据元素的存储结构形式有两种:顺序存储和链式存储
- 顺序存储结构:把数据元素存放在地址连续的存储单元里,其数据间的逻辑关系和物理关系是一致的。
数组就是这样的存储结构 - 链式存储结构:把数据元素存放在任意的存储单元里,这组存储单元可以是连续的,也可以是不连续的。
数据元素的存储关系并不能反映其逻辑关系,因此需要用一个指针存放数据元素的地址,这样通过地址就可以找到相关联数据元素的位置。
三、数据类型
1、数据类型:是指一组性质相同的值的集合及定义在此集合上的一些操作的总称。
2、抽象数据类型:一个数据模型及定义在该模型上的一组操作。
一个抽象数据类型定义了:一个数据对象、数据对象中各数据元素之间的关系及对数据元素的操作。
抽象数据类型体现了程序设计中问题分解、抽象和信息隐藏的特性。
抽象数据类型把实际生活中的问题分解为多个规模小且容易处理的问题,然后建立一个计算机能处理的数据模型,并把每个功能模块的实际细节作为一个独立的单元,从而使具体实现过程隐藏起来。
2.算法
算法:算法是解决特定问题求解步骤的描述,在计算机中表现为指令的有限序列,并且每条指令表示一个或多个操作。
一、算法的特性
有五个基本特性:输入、输出、有穷性、确定性和可行性
- 输入输出
算法具有零个或多个输入
算法至少有一个或多个输出 - 有穷性
算法在执行有限的步骤之后,自动结束而不会出现无限循环,并且每一个步骤在可接受的时间内完成。
这里有穷并不是纯数学意义的,而是在实际应用中合理的、可以接受的“有边界” - 确定性
算法的每一个步骤都具有确定的含义,不会出现二义性。
算法在一定条件下,只有一条执行路径,相同的输入只能有唯一的输出结果。算法的每个步骤被精确定义而无歧义 - 可行性
算法的每一个步骤都必须是可行的,也就是每一步都能通过执行有限次数完成。
二、算法设计的要求
什么才算好的算法?
- 正确性
算法至少应该具有输入、输出和加工处理无歧义性,能正确反应问题的需求,能够得到问题的正确答案。
算法的“正确”通常在用法上有很大的差别,大体分为以下四个层次:
(1)算法程序没有语法错误。
(2)算法程序对于合法的输入数据能够产生满足要求的输出结果。
(3)算法程序对于非法的输入数据能够得出满足规格说明的结果。
(4)算法程序对于精心选择的,甚至刁难的测试数据都有满足要求的输出结果。
一般情况下,把层次(3)作为一个算法是否正确的标准。 - 可读性
算法设计的另一个目的是为了便于阅读、理解和交流。 - 健壮性
当输入数据不合法时,算法也能做出相关处理,而不是产生异常或莫名其妙的结果。 - 时间效率高和存储量低
三、算法效率的度量方法
- 事后统计方法
这种方法主要是通过设计好的测试程序和数据,利用计算机计时器对不同算法编制的程序的运行时间进行比较,从而确定算法效率的高低。
缺陷:
(1)必须依据算法实现编制好的程序,这需要花费大量的时间和精力。
(2)时间的比较依赖计算机硬件和软件,有时会掩盖算法本身的劣势。
(3)算法的测试数据设计困难,程序的运算时间往往还与测试数据的规模有很大关系,到底用多少数据?测试多少次才算可以?这是很难判断的问题。 - 事前分析估算方法
在计算机程序编制前,依据统计方法对算法进行估算。
一个用高级程序语言编写的程序在计算机上运行时所消耗的时间取决于:
(1)算法采用的策略和方法 [算法好坏的根本]
(2)编译产生的代码质量 [软件支持]
(3)问题的输入规模
(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次。
再延伸一种:
int i, j, x = 0, sum = 0, n = 100; //执行1次
for(i = 1; i <= n; i++){
for(j = 0; j <= n; j++){
x++;
sum = sum + x; //执行n*n次
}
}
printf("%d",sum); //执行1次
忽略头尾循环判断的开销,就是n,1,n2
在分析程序的运算时间时,最重要的是把程序看成是独立于程序设计语言的算法或一系列步骤。
分析一个算法的运行时间时,重要的是基本操作的数量与输入规模关联起来,即基本操作的数量必须表示成输入规模的函数
四、函数的渐近增长
函数的渐近增长:给定两个函数 f(n) 和 g(n),如果存在一个整数N,使得对于所有n > N, f(n) 总是比 g(n) 大,那么,我们说 f(n) 的增长渐近快于g(n) 。
例1:
看看谁更快
算法A:2n+3
算法B:3n+1
次数 | 2n+3 | 2n | 3n+1 | 3n |
---|---|---|---|---|
n=1 | 5 | 2 | 4 | 3 |
n=2 | 7 | 4 | 7 | 6 |
n=3 | 9 | 6 | 10 | 9 |
n=10 | 23 | 20 | 31 | 30 |
n=100 | 203 | 200 | 301 | 300 |
n=1, 算法A效率不如算法B
n=2,效率相同
n>2, 算法A优于算法B
随着n的增加,算法A比算法B越来越好了(执行的次数比B要少)。得出结论,算法A总体好过算法B
输入规模n在没有限制的情况下,只要超过一个数值N,这个函数就总是大于另一个函数,我们称函数是渐近增长的。
对于2n+3 和 3n+1 ,随着n的增大,+3和+1不影响最终的算法变化,我们可以忽略这些加法常数
例2:
算法C:4n+8
算法D:2n2
次数 | 4n+8 | n | 2n2 | n2 |
---|---|---|---|---|
n=1 | 12 | 1 | 3 | 1 |
n=2 | 16 | 2 | 9 | 4 |
n=3 | 20 | 3 | 19 | 9 |
n=10 | 48 | 10 | 201 | 100 |
n=100 | 408 | 100 | 20001 | 10000 |
n小于等于3时,算法C差于算法D
n>3,算法C越来越优于算法D
去掉与n相乘的常事,结果没发生改变。与最高项相乘的常数并不重要
例3:
算法E:2n2 + 3n + 1
算法F:2n3 + 3n + 1
次数 | 2n2 + 3n + 1 | n2 | 2n3 + 3n + 1 | n3 |
---|---|---|---|---|
n=1 | 6 | 1 | 6 | 1 |
n=2 | 15 | 4 | 23 | 8 |
n=3 | 28 | 9 | 64 | 27 |
n=10 | 231 | 100 | 2031 | 1000 |
n=100 | 408 | 100 | 2000301 | 1000000 |
n=1,算法E与算法F相同
n>1,算法E优于算法F,随着n的增大,差异也越来越明显
最高次项的指数大的,函数随着n的增长,结果也会增长更快
例4:
算法G:2n2
算法H:3n+1
算法I:2n2 + 3n + 1
次数 | 2n2 | 3n+1 | 2n2 + 3n + 1 |
---|---|---|---|
n=1 | 2 | 4 | 6 |
n=2 | 8 | 7 | 15 |
n=5 | 50 | 16 | 66 |
n=10 | 200 | 31 | 231 |
n=100 | 20 000 | 301 | 2031 |
n=1000 | 2000 000 | 3001 | 203001 |
n=10000 | 200 000 000 | 30001 | 2003001 |
n=100000 | 20 000 000 000 | 300001 | 200030001 |
n=1000000 | 2000 000 000 000 | 3000001 | 20000300001 |
清除看到,当n的值越来越大时,3n+1没法和2n2 的结果相比较。
随着n值变得非常大以后,算法G其实已经趋近于算法I
判断一个算法的效率时,函数中的常数和其他次要项常常可以忽略,而更应该关注主项(最高阶项)的阶数。
某个算法,随着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(n2)。取名为(非官方):O(1) 叫常数阶、O(n) 叫线性阶、O(n2) 叫平方阶。
- 推导大O阶方法
- 用常数1取代运算时间中的所有加法常数
- 在修改后的运行次数函数中,只保留最高阶项
- 如果最高阶项存在且其系数不是1,则去除与这个项相乘的系数。
得到的结果就是大O阶
- 常数阶
用高斯算法举例:
int sum = 0, n = 100; //执行1次
sum = (1 + n) * n/2; //执行1次
printf("%d",sum); //执行1次
这个算法运行次数函数是f(n)=3。推导大O阶的方法,第一步就是把常数项3改为1。在保留最高阶项时发现,它根本没有最高阶项,所以这个算法时间复杂度O(1)。
注意:不管这个常数是多少,我们都记作 O(1) ,而不能是 O(3) 等其他任何数字
对于分支结构而言,无论是真,还是假,执行的次数都是恒定的,不会随着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,则会退出循环。由22 = n得到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 < n; i++){
for(j = i; j < n; j++){
//时间复杂度为O(1)的程序步骤序列
}
}
由于当i=0时,内循环执行了n次,当i=1时,执行了n-1次,…当i=n-1时,执行了1次。所以总的执行次数为:
n+(n+1)+(n-2)+…+1 = n(n+1)/2 = n2 /2 + n/2
用推导大O阶的方法,第一条,没有加法常数不予考虑;第二条,只保留最高阶项,因此保留n2 /2 ;第三条,去除与这个项相乘的常数,也就是去除1/2,最终这段代码的时间复杂度为O(n2)。
理解大O阶推到不算难,难得是对数列的一些相关运算,这更多的考察数学知识和能力
六、常见的时间复杂度
执行次数函数 | 阶 | 非正式术语 |
---|---|---|
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(2n)和O(n!) 等除非是很小的n值,否则哪怕n只是100,都是噩梦般的运行时间。所以这种不切实际的算法时间复杂度,一般我们都不去讨论。
七、最坏情况与平均情况
平均运行时间是所有情况中最有意义的,因为它是期望的运行时间。
一般在没有特殊说明的情况下,都是指最坏时间复杂度。
平均时间复杂度:计算所有情况的平均值
最坏时间复杂度:记算最坏情况下的时间复杂度
八、算法空间复杂度
算法的空间复杂度通过计算算法所需的存储空间实现,算法空间复杂度的计算公式记作:S(n) = O(f(n)),其中,n为问题的规模,f(n)为语句关于n所占存储空间的函数。
一般情况下,一个程序在机器上执行是,除了需要存储程序本身的指令、常数、变量和输入数据外,都需要存储对数据操作的存储单元。
若输入数据所占空间只取决于问题本身,和算法无关,这样只需要分析该算法在实现时所需的辅助单元即可。若算法执行时所需的辅助空间相对于输入数据量而言是个常数,则称此算法为原地工作,空间复杂度为O(1)。
“时间复杂度”来指运行时间的需求,使用“空间复杂度”指空间需求。
当只说“复杂度”时,通常都是指时间复杂度。