github地址:https://github.com/saipengxin/DataStructure
数据结构是一门研究非数值计算的程序设计问题中的操作对象,以及它们之间的关系和操作等相关问题的学科。学好数据结构可以写出更加有效率的代码。程序 = 数据结构 + 算法。
定义:数据结构是相互之间存在一种或多种特定关系的数据元素的集合。
数据结构的起源
1968年,美国的高德纳(Donald E. Knuth)教授在其所写的《计算机程序设计艺术》第一卷《基本算法》中,较系统的阐述了数据的逻辑结构和存储结构及其操作,开创了数据结构的课程体系。
同年,“数据结构”作为一门独立的课程,在计算机科学的学位课程中开始出现。
基本概念和术语
数据结构,所以我们先来看看什么叫数据。
数据
数据是描述客观事物的符号,是计算机中可以操作的对象,是能被计算机识别并输入给计算机处理的符号集合。
数据不仅包括数值型,还包括字符及声音、图像、视频等非数值型。
数据具有的特点:
- 可以输入到计算机中
- 能被计算机程序处理
对于数值型,可以进行数值计算。
对于字符数据类型,需要进行非数值的处理。
声音、图像、视频等其实可以通过编码手段变成字符数据来处理的。
数据元素
是组成数据的、有一定意义的基本单位,在计算机中通常作为整体处理。也被称为记录
数据项
一个数据元素可以由若干个数据项组成。
数据项是数据不可分割的最小单位。
我理解我们在日常开发需求的时候,一张mysql表存储的都是数据。而其中一行数据称为数据元素,一行数据中的不同字段是不同的数据项。
数据对象
是性质相同的数据元素的集合。是数据的子集。
性质相同指的是数据元素具有相同数量和类型的数据项。
数据结构
结构:简单的理解就是关系,比如分子结构,就是说组成分子的原子之间的排列方式。
严格点说,结构是指各个组成部分相互搭配和排列的方式。在现实世界中,不同数据元素之间不是独立的,而是存在特定的关系,我们将这些关系称为结构。
数据结构:是相互之间存在一种或多种特定关系的数据元素的集合。
逻辑结构和物理结构
逻辑结构
逻辑结构是指数据对象中数据元素之间的相互关系。主要分为以下四种:
集合结构
集合结构:集合结构中的数据元素除了同属与一个集合外,它们之间没有其他关系。
线性结构
- 线性结构的特点是数据元素之间存在一对一的线性关系
- 线性结构有两种不同的存储结构:顺序存储结构和链式存储结构
- 顺序存储的线性表称为顺序表,顺序表中存储的元素,在内存中内存地址是连续的。
- 链式存储的线性表称为链表,链表中存储的元素在内存中内存地址不一定是连续的,元素节点中存放数据元素已经相邻元素的地址信息。
- 线性结构常见的有:数组,对列,链表和栈
树形结构
树形结构的元素之间存在一对多的层次关系。
图形结构
图形结构的元素是多对多的关系。
我们用示意图表示数据的逻辑结构时,要注意两点:
- 一个数据元素看作是一个节点,用圆圈表示
- 数据之间的逻辑关系用节点间的连线来表示,如果这个关系时有方向的,用带箭头的连线表示。
物理结构(存储结构)
物理结构:是指数据的逻辑结构在计算机中的存储形式。
数据的存储结构应该正确反应数据元素之间的逻辑关系,这才是最为关键的。
数据元素的存储结构有两种:顺序存储和链式存储。
顺序存储结构
顺序存储结构是把数据元素存放在地址连续的存储单元里,其数据间逻辑关系和物理关系是一致的。
数组就是顺序存储结构。当你告诉计算机你要建立一个有9个整形数据的数组时,计算机就在内存中找了一片空地,按照整形所占空间的大小乘以9,开辟一段连续的空间。第一个数组数据放在第一个位置,第二个数组数据放在第二个位置。
链式存储结构
链式存储结构,是把元素存放在任意的存储单元里,这组存储单元可以时连续的,也可以是不连续的。
数据元素的存储关系并不能反映其逻辑关系,因此需要一个指针存放数据元素的地址,这样就可以通过地址找到相关联元素的位置。
逻辑结构是面向问题的,而物理结构就是面向计算机的,其基本的目标就是将数据及其逻辑关系存储到计算机的内存中。
数据类型
数据类型:是指一组性质相同的值的集合及定义在此集合上的一些操作的总称。
数据类型定义
在高级语言中,每个变量、常量和表达式都有各自的取值范围。类型就用来说明变量或表达式的取值范围和所能进行的操作。
比如,在C语言中变量声明int a, b,这就意味着,在给变量α和b赋值时不能超出 int 的取值范围,变量α和b之间的运算只能是int类型所允许的运算。
为什么计算机要考虑数据类型呢?
在计算机中,内存也不是无限大的,你要计算一个像1+1=2、3+5=8这样的整型数字的加减乘除运算,显然不需要开辟很大的内存空间。于是计算机的研究者们就考虑,要对数据进行分类,分出来多种数据类型。
在C语言中,按照取值不同,数据类型可以分为两类:
- 原子类型:不可再分解的基本类型,例如整形,字符型
- 结构类型:由若干类型组合而成,可以再分解。例如整形数组是由若干整形数据组成的
抽象数据类型
我们对已有的数据类型进行抽象,就有了抽象数据类型。
抽象数据类型:一个数学模型及定义在该模型上的一组操作。
抽象数据类型的定义仅取决于他的一组逻辑特征,而与其在计算机内部如何表示和实现无关。
比如说各个计算机,不管是大型机、小型机、PC、平板电脑、和智能手机都拥有“整数”类型,也需要整数间的运算,那么整型其实就是—个抽象数据类型,尽管它在上面提到的这些在不同计算机中实现方法上可能不一样,但由于其定义的数学特性相同,在计算机编程者看来,它们都是相同的。因
此,,抽象的意义在于数据类型的数学抽象特性。
抽象数据类型体现了程序设计中问题分解、抽象和信息隐藏的特性。抽象数据类型把实际生活中的问题分解为多个规模小且容易处理的问题,然后建立—个计算机能处理的数据模型,并把每个功能模块的实现细节作为—个独立的单元,从而使具体实现过程隐藏起来。
数据结构与算法的关系
数据结构和算法密切相关,它们在计算机科学中是解决问题的基础。可以把它们的关系理解为“工具”和“策略”的关系。
- 数据结构是指数据如何组织、存储和管理的方式。不同的数据结构用于存储和处理数据的效率不同。常见的数据结构包括数组、链表、栈、队列、树、图、哈希表等。
- 算法是解决特定问题的步骤或过程。算法依赖于底层数据结构的选择,因为合适的数据结构可以使算法的执行更加高效。例如,排序算法、搜索算法和图论算法都依赖于特定的数据结构来提高效率。
比较两种算法
现在我们要计算1到100的和,程序应该怎么写呢?
正常大家都会想到循环:
package main
func main() {
sum := 0
for i := 0; i <= 100; i++ {
sum += i
}
println(sum)
}
但是上面的算法是这个问题的最优解嘛?
大家可以了解一下数学家高斯的快速求和的故事,他给我们提出了一个新的解题方案:
sum = 1+2+3+…+99+100
sum = 100+99+98+…+2+1
上下两个公式相加可以得到:
2*sum = 101+101+101+…+ 101+101
所以2倍的sum等于100个101相加,sum 就等于 101 * 100 / 2 = 5050
根据这个思路,我们可以将我们的算法优化成这样:
package main
func main() {
sum := 0
n := 100
sum = (1 + n) * n / 2
println(sum)
}
我们将算法优化之后,即使要算1到一亿的总和也就是瞬间的事,但是第一种算法要循环一亿次。
算法定义
算法是解决特定问题求解步骤的描述,在计算机中表现为指令的优先序列,并且每条指令表示一个或多个操作。
算法的特性
算法有5个基本特性:输入、输出、有穷性、确定性、可行性
输入输出
算法一般具有零个或多个输入。
算法至少有一个或多个输出。输出的形式可以是打印输出,也可以返回一个或多个值。
有穷性
算法在执行有限的步骤后,自动结束而不会出现无限循环,并且每一个步骤在可接受的时间内完成。
确定性
算法的每一步骤都具有确定的含义,不会出现二义性。算法在一定条件性爱,只有一条执行路径,相同的输入只能有唯一的输出结果。算法的每个步骤被精确定义而无歧义。
可行性
算法的每一步都必须是可行的,也就是说,每一步都能通过执行有限次数完成。
算法的设计要求
正确性
算法的正确性是指算法至少应该具有输入、输出和加工处理无歧义性,能正确反映问题的需求,能够得到问题的正确答案。
可读性
算法的设计要便于理解,阅读。
方便他人理解算法逻辑,也方便自己调试。
健壮性
当输入数据不合法时,算法也能做出相关处理,而不是产生异常或莫名其妙的结果。
时间效率高和存储量低
时间效率指的是算法的执行时间。对干同一个问题,如果有多个算法能够解决,执行时间短的算法效率高,执行时间长的效率低。存储量需求指的是算法在执行过程中需要的最大存储空间,主要指算法程序运行时所占用的内存或外部硬盘存储空间。设计算法应该尽量满足时间效率高和存储量低的需求。
算法效率的度量方法
事后统计法
事后统计法:这种方法主要是通过设计好的测试程序和数据,利用计算机计时器对不同算法编制的程序的运行时间进行比较,从而确定算法效率的高低。
但是这种方法有几个缺陷:
- 这种方法需要事先编好程序,但是如果最后测试的结果不符合预期,哪这个工作就白做了
- 计算机硬件和软件环境会影响算法本身的优劣,在老式计算机上和现在的4核、8核计算机上运行算法,运算速度肯定有很大差异。
- 算法的测试数据设计困难,少量的数据基本不会体现出算法的性能差异,需要准备大量数据才行,而且测试数据的随机程度也会导致算法的比较不够客观。、
综上,事后统计法我们很少使用。
事前分析估算方法
在计算机程序编制前,依据统计方法对算法进行估算。
我们评估的时候忽略计算机硬件和软件相关的因素,只评估算法本身,那么一个程序的运行时间,依赖于算法的好坏和问题的输入规模,所谓问题输入规模是指输入量的多少。
我们可以对比一下上面介绍的求和的算法,第一种算法:
package main
func main() {
sum := 0 // 执行一次
for i := 0; i <= 100; i++ { // 执行 n + 1 次 (这个n指要循环的次数)
sum += i // 执行 n 次
}
println(sum) // 执行一次
}
第二种算法:
package main
func main() {
sum := 0 // 执行一次
n := 100 // 执行一次
sum = (1 + n) * n / 2 // 执行一次
println(sum) // 执行一次
}
第一种算法执行了:1 + (n+1) + n + 1 = 2n+3次,第二种算法执行了1+1+1+1 = 4次。
我们也可以忽略开头的定义和结尾的输出,同时把整个循环看作一个整体,忽略头尾循环判断的开销,那么这两个算法其实就是n次和1次的差距。
我们在延伸一下上面的例子:
package main
func main() {
i, j, x, sum := 0, 0, 0, 0 // 执行一次
n := 100 // 执行一次
for i = 0; i < n; i++ {
for j = 0; j < n; j++ {
x++
sum += x // 执行 n * n 次
}
}
println(sum) // 执行一次
}
上面这个算法,我们还是忽略开头和结尾的开销,只关注中间的过程,一共执行了 n * n
次,也就是n2。 显然,这个算法在同样输入规模的情况下(n=100),执行的次数要远高于上面两个算法。而随着n的增加,执行的时间也会远远多于前两个算法。
同样问题的输入规模是n,求和算法的第一种’求1+2+…+n 需要一段代码运行n次。那么这个问题的输入规模使得操作数量是f(n) = n,显然运行100次的同一段代码规模是运算10次的10倍。而第二种,无论n为多少,运行次数都为1, 即f(n) = 1,第三种运算100次是运算10次的100倍,因为它是f(n) = n2。
我们在分析—个算法的运行时间时,重要的是把基本操作的运行次数与输入规模关联起来,即基本操作的运行次数必须表示成输入规模的函数(如下图所示)
算法的时间复杂度
时间复杂度定义
在进行算法分析时,语句总的执行次数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):常数阶
- O(n):线性阶
- O(n2):平方阶
推导大O阶方法
- 用常数1取代运行时间中所有的加法常数。
- 在修改后的运行次数函数中,只保留最高阶项。
- 如果最高阶项存在且系数不是1,则去除这个项相乘的系数。
得到的结果就是大O阶。
常数阶
package main
func main() {
sum := 0 // 执行一次
n := 100 // 执行一次
sum = (1 + n) * n / 2 // 执行一次
println(sum) // 执行一次
}
这个算法,f(n) = 3,根据大O推导方法,第一步把常数项3改成1,所以这个算法的时间复杂度就是O(1)。
不能计作O(3)、O(4)等等,不管常数是多少,我们都计作O(1)
线性阶
分析算法的复杂度,关键就是要分析循环结构的运行情况。
package main
func main() {
sum := 0 // 执行一次
for i := 0; i <= 100; i++ { // 执行 n + 1 次 (这个n指要循环的次数)
sum += i // 执行 n 次
}
println(sum) // 执行一次
}
这个算法的时间复杂度就是O(n)。
他总共执行了2n+3次,根据大O推导定义,首先用1取代常数,所以变成了2n+1,然后只保留最高阶,变成了2n,最高阶系数去除,所以变成了n。
对数阶
package main
func main() {
for i := 1; i < n; {
i = i * 2
}
}
每次 i 乘以 2后,都距离n更近了一分,也就是说,有多少个2相乘之后就会大于n并退出循环,由2x=n得到x=log2n。所以这个循环的时间复杂度为O(logn)。
平方阶
package main
func main() {
i, j, x, sum := 0, 0, 0, 0 // 执行一次
n := 100 // 执行一次
for i = 0; i < n; i++ {
for j = 0; j < n; j++ {
x++
sum += x // 执行 n * n 次
}
}
println(sum) // 执行一次
}
上面的算法就是O(n2)。
常见的时间复杂度
阶 | 非正式术语 |
---|---|
O(1) | 常数阶 |
O(n) | 线性阶 |
O(n2) | 平方阶 |
O(logn) | 对数阶 |
O(nlogn) | nlogn阶 |
O(n3) | 立方阶 |
O(2n) | 指数阶 |
常用的时间复杂度所耗费的时间从小到大依次是:
O(1)<O(logn)<O(n)<O(nlogn)<O(n2)<O(n3)<O(2n)<O(n!)<O(nn)
O(n3)和它后面的时间复杂度,执行时间已经太长了,所以我们基本不会讨论。
算法空间复杂度
我们在实现算法的时候,可以使用空间换时间的思路,比如说我们要实现一个计算闰年的逻辑,我们可以自己写一个算法,这样每一个年份都是通过算法计算所得的。还有另一种方法,就是我们可以维护一个映射,这个映射中,将年份和是否是闰年进行关联,闰年为1,不是闰年为0,这样我们计算一个年份是否是闰年,计算就变成了查找映射中的值。此时我们的计算已经最小化了,但是我们需要额外的存储空间去存储这个映射关系。
算法的空间复杂度通过计算算法所需的空间来实现,算法的空间复杂度公式记作:S(n) = O(f(n))
,其中n是问题的规模,f(n)是语句关于n所占存储空间的函数。
一般情况下,一个程序在机器上执行时,除了需要存储程序本身的指令、常数、变量和输入数据外,还需要存储对数据操作的存储单元。若输入数据所占空间只取决于问题本身,和算法无关,这样只需要分析该算法在实现时需要的辅助单元即可。若算法执行时所需要的辅助空间相对于输入数据量而言是个常数,则称此算法为原地工作,空间复杂度为O(1)。