1.数据结构
食谱和算法之间最大的区别就在于算法是严密的。食谱上经常会有描述得比较模糊的部分,
而算法的步骤都是用数学方式来描述的,所以十分明确。
算法和程序有些相似,区别在于程序是以计算机能够理解的编程语言编写而成的,可以在
计算机上运行,而算法是以人类能够理解的方式描述的,用于编写程序之前。不过,在这个过
程中到哪里为止是算法、从哪里开始是程序,并没有明确的界限。
就算使用同一个算法,编程语言不同,写出来的程序也不同;即便使用相同的编程语言,
写程序的人不同,那么写出来的程序也是不同的。
1-1什么是数据结构
数据存储于内存时,决定了数据顺序和位置关系的便是“数据结构”。
7 种数据结构。数据在内存中是呈线性排列的,但是我们
也可以使用指针等道具,构造出类似“树形”的复杂结构
1-2 链表
链表是数据结构之一,其中的数据呈线性排列。在链表中,数据的添加和删除都较为方便,
就是访问比较耗费时间
1)访数据
2)添加数据
3)删除
4)总结
1)时间复杂度
链表中的数据量记成n。访问数据时,我们需要从链表头部开始查找(线性查找),如果目标数据在链表最后的话,需要的时间就是O(n)。
另外,添加数据只需要更改两个指针的指向,所以耗费的时间与n 无关。如果已经到达了添加数据的位置,那么添加操作只需花费O(1) 的时间。删除数据同样也只需O(1) 的时间。2)补充
循环链表
可以在链表尾部使用指针,并且让它指向链表头部的数据,将链表变成环形。这便是“循环链表”,也叫“环形链表”。
循环链表没有头和尾的概念。想要保存数量固定的最新数据时通常会使用这种链表。
双向链表
我们可以把指针设定为两个,并且让它们分别指向前后数据,这就是“双向链表”。使用这种链表,不仅可以从前往后,还可以从后往前遍历数据,十分方便。
但是,双向链表存在两个缺点:一是指针数的增加会导致存储空间需求增加;二是添加和删除数据时需要改变更多指针的指向。
1-3 数组
数组也是数据呈线性排列的一种数据结构。与前一节中的链表不同,在数组中,访问数据十分
简单,而添加和删除数据比较耗工夫。
1)访问数据
2)添加数据
3)删除数据
4)总结
1)时间复杂度
假设数组中有n 个数据,由于访问数据时使用的是随机访问(通过下标可计算出内存地址),所以需要的运行时间仅为恒定的
O(1)。但另一方面,想要向数组中添加新数据时,必须把目标位置后面的数据一个个移开。所以,如果在数组头部添加数据,就需要O(n) 的时间。删除操作同理。2)补充
在链表和数组中,数据都是线性地排成一列。在链表中访问数据较为复杂,添加和删除数据较为简单;而在数组中访问数据比较简单,添加和删除数据却比较复杂。
1-4 栈
栈也是一种数据呈线性排列的数据结构,不过在这种结构中,我们只能访问最新添加的数据。
栈就像是一摞书,拿到新书时我们会把它放在书堆的最上面,取书时也只能从最上面的新书开始取。
1)访问数据
2)添加数据
3)删除数据
4)总结
1)像栈这种最后添加的数据最先被取出,即“后进先出”的结构,我们称为
Last InFirst Out
,简称LIFO
。
与链表和数组一样,栈的数据也是线性排列,但在栈中,添加和删除数据的操作只
能在一端进行,访问数据也只能访问到顶端的数据。想要访问中间的数据时,就必须通
过出栈操作将目标数据移到栈顶才行2)栈只能在一端操作这一点看起来似乎十分不便,但在只需要访问最新数据时,使用它就比较方便了。
1-5 队列
队列中的数据也呈线性排列。虽然与栈有些相似,但队列中
添加和删除数据的操作分别是在两端进行的。就和“队列”这个名字一样,把它想象成排成一
队的人更容易理解。在队列中,处理总是从第一名开始往后进行,而新来的人只能排在队尾。运用:
运用在深度优先搜索算法,通常会选择最新的数据作为候补顶点。在候补顶点的管理上就可以使用栈。
1)添加数据
2)删除数据
3)总结
像队列这种最先进去的数据最先被取来,即**“先进先出”的结构,我们称为
First In First Out
,简称FIFO
。
与栈类似,队列中可以操作数据的位置也有一定的限制。在栈中,数据的添加和删**
除都在同一端进行,而在队列中则分别是在两端进行的。队列也不能直接访问位于中间
的数据,必须通过出队操作将目标数据变成首位后才能访问。运用:
“先来的数据先处理”是一种很常见的思路,所以队列的应用范围非常广泛。用在广度优先搜索算法,通常就会从搜索候补中选择最早的数据作为下
一个顶点。此时,在候补顶点的管理上就可以使用队列。
1-6 哈希表
在哈希表这种数据结构中,使用将在5-3 节讲解的“哈希函数”,可以使数据的查询效率得
到显著提升
1)一般查询访问数据
2)快速查询
3)总结
在存储数据的过程中,如果发生冲突,可以利用链表在已有数据的后面插入新数据来解决冲突。这种方法被称为**“链地址法”。
除了链地址法以外,还有几种解决冲突的方法。其中,应用较为广泛的是“开放地**
址法”。这种方法是指当冲突发生时,立刻计算出一个候补地址(数组上的位置)并将数
据存进去。如果仍然有冲突,便继续计算下一个候补地址,直到有空地址为止。可以通
过多次使用哈希函数或“线性探测法”等方法计算候补地址。因为哈希表在数据存储上的灵活性和数据查询上的高效性,编程语言的关联数组等
也常常会使用它。
1-7 堆
堆是一种图的树形结构,被用于实现“优先队列”(priority queues).优先队列是一种数据结构,可以自由添加数据,但取出数据时要从最小值开始按顺
序取出。在堆的树形结构中,各个顶点被称为“结点”(node),数据就存储在这些结点中。
1)访问数据
2)添加数据
3)删除数据
4)总结
1)时间
堆中最顶端的数据始终最小,所以无论数据量有多少,取出最小值的时间复杂度都为O(1)。
另外,因为取出数据后需要将最后的数据移到最顶端,然后一边比较它与子结点数据的大小,一边往下移动,所以取出数据需要的运行时间和树的高度成正比。假设数据量为n,根据堆的形状特点可知树的高度为log2n ,那么重构树的时间复杂度便为O(logn)。添加数据也一样。在堆的最后添加数据后,数据会一边比较它与父结点数据的大小,一边往上移动,直到满足堆的条件为止,所以添加数据需要的运行时间与树的高度成正比,也是O(logn)。
2)运用
如果需要频繁地从管理的数据中取出最小值,那么使用堆来操作会非常方便。比如4-5 节中提到的狄克斯特拉算法,每一步都需要从候补顶点中选择距离起点最近的那个顶点。此时,在顶点的选择上就可以用到堆。
1-8 二叉查找树
二叉查找树(又叫作二叉搜索树或二叉排序树)是一种数据结构,采用了图的树形结构。数据存储于二叉查找树的各个结点中。
1)访问数据
2)添加数据
3)删除数据
4)总结
1)时间:
我们可以把二叉查找树当作是二分查找算法思想的树形结构体现(二分查找的详细说明在3-2 节)。因为它具有前面提到的那两个性质,所以在查找数据或寻找适合添加数据的位置时,只要将其和现有的数据比较大小,就可以根据比较结果得知该往哪边移动了。比较的次数取决于树的高度。所以如果结点数为n,而且树的形状又较为均衡的话,比较大小和移动的次数最多就是log2n。因此,时间复杂度为O(logn)。但是,如果树的形状朝单侧纵向延伸,树就会变得很高,此时时间复杂度也就变成了O(n)。
2)
有很多以二叉查找树为基础扩展的数据结构,比如“平衡二叉查找树”。这种数据结构可以修正形状不均衡的树,让其始终保持均衡形态,以提高查找效率。
另外,虽然文中介绍的二叉查找树中一个结点最多有两个子结点,但我们可以把子结点数扩展为m(m 为预先设定好的常数)。像这种子结点数可以自由设定,并且形状均衡的树便是B 树。
2.排序
2-1 什么是排序
排序是一个比较基础的问题,所以排序算法的种类也比较多。
同一个例子中不会出现相同的数字,但实际上,即使有相同的数字,算法依然可以正常运行。
2-2 冒泡排序
冒泡排序就是重复“从序列右边开始比较相邻两个数字的大小,再根据结果交换两个数字的位置”这一操作的算法。在这个过程中,数字会像泡泡一样,慢慢从右往左“浮”到序列的顶端,所以这个算法才被称为“冒泡排序”。
总结:
在冒泡排序中,第1 轮需要比较n - 1 次,第2 轮需要比较n - 2 次……第n - 1 轮需要比较1 次。因此,总的比较次数为(n - 1) + (n - 2) + … + 1 ≈ n2/2。这个比较次数恒定为该数值,和输入数据的排列顺序无关。
不过,交换数字的次数和输入数据的排列顺序有关。假设出现某种极端情况,如输入数据正好以从小到大的顺序排列,那么便不需要任何交换操作;反过来,输入数据要是以从大到小的顺序排列,那么每次比较数字后便都要进行交换。因此,冒泡排序的时间复杂度为O(n2)。
2-3 选择排序
选择排序就是重复“从待排序的数据中寻找最小值,将其与序列最左边的数字进行交换”
这一操作的算法。在序列中寻找最小值时使用的是线性查找。
选择排序使用了线性查找来寻找最小值,因此在第1 轮中需要比较n - 1 个数字,第2 轮需要比较n - 2 个数字……到第n - 1 轮的时候就只需比较1 个数字了。因此,总的比较次数与冒泡排序的相同,都是(n - 1) + (n - 2) + … + 1 ≈ n2/2 次。每轮中交换数字的次数最多为1 次。如果输入数据就是按从小到大的顺序排列的,便不需要进行任何交换。选择排序的时间复杂度也和冒泡排序的一样,都为O(n2)。
2-4 插入排序
插入排序是一种从序列左端开始依次对数据进行排序的算法。在排序过程中,左侧的数据陆续归位,而右侧留下的就是还未被排序的数据。插入排序的思路就是从右侧的未排序区域内取出一个数据,然后将它插入到已排序区域内合适的位置上
在插入排序中,需要将取出的数据与其左边的数字进行比较。就跟前面讲的步骤一样,如果左边的数字更小,就不需要继续比较,本轮操作到此结束,自然也不需要交换数字的位置。
然而,如果取出的数字比左边已归位的数字都要小,就必须不停地比较大小,交换数字,直到它到达整个序列的最左边为止。具体来说,就是第k 轮需要比较k - 1 次。因此,在最糟糕的情况下,第2 轮需要操作1 次,第3 轮操作2 次……第n 轮操作n -1次,所以时间复杂度和冒泡排序的一样,都为O(n2)。
和前面讲的排序算法一样,输入数据按从大到小的顺序排列时就是最糟糕的情况。
2-5 堆排序
堆排序的特点是利用了数据结构中的堆
堆排序一开始需要将n 个数据存进堆里,所需时间为O(nlogn)。排序过程中,堆从空堆的状态开始,逐渐被数据填满。由于堆的高度小于log2n,所以插入1 个数据所需要的时间为O(logn)。每轮取出最大的数据并重构堆所需要的时间为O(logn)。由于总共有n 轮,所以重构后排序的时间也是O(nlogn)。因此,整体来看堆排序的时间复杂度为O(nlogn)。这样来看,堆排序的运行时间比之前讲到的冒泡排序、选择排序、插入排序的时间O(n2) 都要短,但由于要使用堆这个相对复杂的数据结构,所以实现起来也较为困难。
2-6 归并排序
归并排序算法会把序列分成长度相同的两个子序列,当无法继续往下分时(也就是每个子序列中只有一个数据时),就对子序列进行归并。归并指的是把两个排好序的子序列合并成一个有序序列。该操作会一直重复执行,直到所有子序列都归并为一个整体为止。
归并排序中,分割序列所花费的时间不算在运行时间内(可以当作序列本来就是分割好的)。在合并两个已排好序的子序列时,只需重复比较首位数据的大小,然后移动较小的数据,因此只需花费和两个子序列的长度相应的运行时间。也就是说,完成一行归并所需的运行时间取决于这一行的数据量。看一下上面的图便能得知,无论哪一行都是n 个数据,所以每行的运行时间都为O(n)。而将长度为n 的序列对半分割直到只有一个数据为止时,可以分成log2n 行,因此,总共有log2n 行。也就是说,总的运行时间为O(nlogn),这与前面讲到的堆排序相同。
2-7 快速排序
快速排序算法首先会在序列中随机选择一个基准值(pivot),然后将除了基准值以外的数分为“比基准值小的数”和“比基准值大的数”这两个类别,再将其排列成以下形式。[ 比基准值小的数] 基准值 [ 比基准值大的数]接着,对两个“[ ]”中的数据进行排序之后,整体的排序便完成了。对“[ ]”里面的数据进行排序时同样也会使用快速排序。
快速排序是一种“分治法”。它将原本的问题分成两个子问题(比基准值小的数和比基准值大的数),然后再分别解决这两个问题。子问题,也就是子序列完成排序后,再像一开始说明的那样,把他们合并成一个序列,那么对原始序列的排序也就完成了。不过,解决子问题的时候会再次使用快速排序,甚至在这个快速排序里仍然要使用快速排序。只有在子问题里只剩一个数字的时候,排序才算完成。像这样,在算法内部继续使用该算法的现象被称为“递归”。
3.数组的查找
3-1 线性查找
线性查找是一种在数组中查找数据的算法(关于数组的详细讲解在1-3 节)。与将在3-2 节
中讲解的二分查找不同,即便数据没有按顺序存储,也可以应用线性查找。线性查找的操作很
简单,只要在数组中从头开始依次往下查找即可。虽然存储的数据类型没有限制,但为了便于
理解,这里我们假设存储的是整数。
线性查找需要从头开始不断地按顺序检查数据,因此在数据量大且目标数据靠后,或者目标数据不存在时,比较的次数就会更多,也更为耗时。若数据量为n,线性查找的时间复杂度便为O(n)。
3-2 二分查找
二分查找也是一种在数组中查找数据的算法。它只能查找已经排好序的数据。二分查找通过比较数组中间的数据与目标数据的大小,可以得知目标数据是在数组的左边还是右边。因此,比较一次就可以把查找范围缩小一半。重复执行该操作就可以找到目标数据,或得出目标数据不存在的结论。
二分查找利用已排好序的数组,每一次查找都可以将查找范围减半。查找范围内只剩一个数据时查找结束。数据量为n 的数组,将其长度减半log2n 次后,其中便只剩一个数据了。也就是说,在二分查找中重复执行“将目标数据和数组中间的数据进行比较后将查找范围减半”的操作log2n 次后,就能找到目标数据(若没找到则可以得出数据不存在的结论),因此它的时间复杂度为O(logn)。
4.图的搜索
4-1 什么是图
可以解决图的基本问题——最短路径问题的算法。
图的搜索指的就是从图的某一顶点开始,通过边到达不同的顶点,最终找到目标顶点的过
程。根据搜索的顺序不同,图的搜索算法可分为“广度优先搜索”和“深度优先搜索”这两种。
最短路径问题和前文提到的一样,就是要在从s 到t 的路径中,找到一条所经过的边的权重
总和最小的路径。
4-2 广度优先搜索
广度优先搜索是一种对图进行搜索的算法。假设我们一开始位于某个顶点(即起点),此时并不知道图的整体结构,而我们的目的是从起点开始顺着边搜索,直到到达指定顶点(即终点).在此过程中每走到一个顶点,就会判断一次它是否为终点。广度优先搜索会优先从离起点近的顶点开始搜索。
4-3 深度优先搜索
深度优先搜索和广度优先搜索一样,都是对图进行搜索的算法,目的也都是从起点开始搜索直到到达指定顶点(终点)。深度优先搜索会沿着一条路径不断往下搜索直到不能再继续为止,然后再折返,开始搜索下一条候补路径。
4-4 贝尔曼- 福特算法
贝尔曼- 福特(Bellman-Ford)算法是一种在图中求解最短路径问题的算法。最短路径问题就是在加权图指定了起点和终点的前提下,寻找从起点到终点的路径中权重总和最小的那条路径。
总结:
将图的顶点数设为n、边数设为m,我们来思考一下贝尔曼- 福特算法的时间复杂度是多少。该算法经过n 轮更新操作后就会停止,而在每轮更新操作中都需要对各个边进行1 次确认,因此1 轮更新所花费的时间就是O(m),整体的时间复杂度就是O(nm)。
4-5 狄克斯特拉算法
与前面提到的贝尔曼- 福特算法类似,狄克斯特拉(Dijkstra)算法也是求解最短路径问题的算法,使用它可以求得从起点到终点的路径中权重总和最小的那条路径路径。
比起需要对所有的边都重复计算权重和更新权重的贝尔曼- 福特算法,狄克斯特拉算法多了一步选择顶点的操作,这使得它在求最短路径上更为高效。
将图的顶点数设为n、边数设为m,那么如果事先不进行任何处理,该算法的时间复杂度就是O( n2)。不过,如果对数据结构进行优化,那么时间复杂度就会变为O(m+ nlogn)。