第1章 数据结构为何重要
1.1 基础数据结构:数组
数组:含有数据的列表。索引从0开始。
结论:时间复杂度=速度=效率=性能:其实都是步数,而不是时间。
1.1.1 读取
如果要查看索引2的值,计算机会直接跳到索引2,一步到位。
1.1.2 查找
一个N格数组,线性查找的最多步数是N。
1.1.3 插入
一个含有N个元素的数组,其插入数据最坏的情况会花费N+1步,即插入在数组开头,导致N此移动,加上一次插入。
1.1.4 删除
对于含有N个元素的数组,删除操作最多需要N步(1次删除,N-1次移动)
1.2 集合:一条规则决定性能
集合:不允许元素重复的数据结构。
我们这里讨论基于数组的集合,它与数组唯一的区别在于:集合不允许插入重复的值。
1.2.1 读取和查找
与数组相同。
1.2.2 插入
集合的插入要先确定要插入的值,是否在其中,因此每次插入都需要先来一次查找。
- 插入到集合尾部:需要N+1步,N此查找+1次插入。
- 插入到集合头部:需要2N+1步,N此查找+N此右移+1次插入。
1.2.3 删除(自己分析)
对于含有N个元素的数组,集合的删除最多需要N+1步。
- 集合中没有该数据:N此查找即可。
- 集合中有该数据,且索引为X,则需要X+1此查找,1次删除,N-(X+1)次左移,共N+1次。
1.3 总结
不同算法(即使是在同一种数据结构上),也有不同的时间复杂度。
第2章 算法为何重要
2.1 有序数组
有序数组:要求其值总是保持有序。
2.2 查找有序数组
- 线性查找:从左至右,逐个查询,直到找到。
例如数组[3,17,75,80,202]中查找22,当查到75时就结束,因为22不可能在75的右边。 - 二分查找:例如对于一个递增的有序数组,每次都比较中间的数,如果要找的数X比中间数大,则从数组后半部分查找,否则从前半部分查找,以此类推。
2.3 线性查找与二分查找
总结:每次有序数组的长度2,线性查找的最多步数也会2,而二分查找所需的最多步数只会+1。
第3章 大O记法
大O记法:考虑的是最坏情况。
要解决的问题:基于要处理的数据量来描述算法所需要的步数,当数据增长时,步数是如何变化的?
3.1 大O:数步数
O(1):常数时间:无论数组有多大,读取元素都只要1步。
O(N):线性时间:处理一个N元素数组,需要花N步。
3.2 常数时间、线性时间与对数时间
- 常数时间:O(1):无论数据量多少,算法的步数都恒定。
- 线性时间:O(N):
- 对数时间:O(logN):算法步数=二分数据直至元素剩余1个的次数。
- 二次时间:见4.2
第4章 运用大O来给代码提速
4.1 冒泡排序
每一次轮回之后,未排序的值中最大的那个(递增排序)都会“冒”到正确的位置上。
冒泡排序的原理与实现:
冒泡排序
4.2 冒泡排序的效率
- 比较:1+2+…+(N-1)=N(N-1)/2
- 交换:1+2+…+(N-1)=N(N-1)/2
- 总效率:二次时间:O(N(N-1))=O(N²)
第5章 用或不用大O来优化代码
有一些算法的大O一样,但是实际的步骤却大相径庭。
5.1 选择排序
选择排序的原理、实现:参考
数据结构-选择排序
5.2 选择排序的效率
- 比较:1+2+…+(N-1)=N(N-1)/2
- 交换:N-1
- 总效率:二次时间:O(N(N-1)/2+N-1)=O(N²)
5.3 大O的作用
- 如上:冒泡排序的实际操作步数为N(N-1),而选择排序的实际操作步数为N(N-1)/2+(N-1),当N很大时,选择排序的实际操作步数远小于冒泡排序,当两种排序方法的大O记法却相同,都为:O(N²)
- 大O记法的作用
大O记法忽略常数的原因:对于不同的分类,存在一个临界点,在这一点之后,一类算法会快于另一类算法,并永远保持下去,至于这个点在哪里,大O记法并不关心。
5.4 实例
实例:从一个数组中取出间隔的元素,组成一个新的数组。
算法1:单步跳:读取每一个元素,如果该元素的索引为偶数,则复制此元素到新数组。
算法1效率分析:读取N,复制N/2,总效率:O(1.5N);记作:O(N)
算法2:双步跳:通过算数逻辑(加减乘除等)(而非操作逻辑(计算机的增删改查)),各一个数,读一个元素,复制到新数组。
算法2效率分析:读取N/2,复制N/2,总效率:O(N)。
第6章 乐观的调优
最坏的情况不是唯一值得考虑的情况,全面分析各种情况,能帮助你为不同场景选择适当的算法。
6.1 插入排序
6.2 插入排序的效率分析
对于一个包含N个元素是数列。下列操作:
(1)取值:默认第一个个值是有序的,因此插入排序共有N-1次取值
(2)比较:对于最坏的情况,比较的总次数为1+2+…+N-1=N(N-1)/2.
(3)平移:对于最坏的情况,平移的总次数为1+2+…+N-1=N(N-1)/2.
(4)插入:与取值相同,共需要N-1次插入。
因此,插入排序的总效率为O(N²+N-2)=O(N²)
6.3 排序的比较
排序方法 | 最好情况 | 平均情况 | 最坏情况 |
---|---|---|---|
冒泡排序 | O(N(N-1)) | O(N²) | |
选择排序 | O(N(N-1)/2+N-1) | O(N²) | |
插入排序 | O(N²+N-2) | O(N²) | |
快速排序 | O(NlogN) | O(NlogN) | O(N²) |
PS:快速排序分析:第10章。
上表并不能说明比冒泡排序和插入排序快一倍的选择排序是三者中最优的,因为我们不仅仅要考虑最坏情况,还要考虑平均情况。
6.4 平均情况
现实中最常出现的是平均情况:如下钟形的曲线。
- 例如:对于插入排序
(1)最好情况,[1,2,3,4]:3次比较,0次平移。
(2)最坏情况,[4,3,2,1]:6次比较,6次平移。
(3)平均情况,[1,3,4,2]:4次比较,2次平移。 - 选择排序,无论何种情况,都是N²/2次。
结论:使用选择排序还是插入排序,要看情况。对于平均情况,他们的性能相近;如果数组大致有序,选择插入排序;如果大致逆序,选择选择排序;如果无法确定,就算是平均情况,两种都可以。
第7章 查找迅速的散列表(字典)
7.1 散列表的基本知识
- 散列表的数据有:键:值。组成。
- 散列表中查找值的平均效率为O(1)。
7.2 用散列函数做散列
A=1;B=2;C=3;D=4;E=5。则ACE=135,CAB=312。
散列:将字符串转化为数字串。
散列函数:散列中对应的编码。
PS:一个散列函数有效的条件;每次对一个字符串调用该散列函数,返回的都必须是同一数字串。
7.3 散列表的存值与取值
- 存值
我们此处用乘法作为散列函数:当执行语句“thesaurus[“bad”]=“evil””后,计算机会先计算BAD=214=8,然后将散列表thesaurus对应内存的第8个位置存入“evil”。
- 取值
同样,执行thesaurus[“bad”]时,先计算BAD=214=8,然后直接取值thesaurus[8]=“evil”。相当于索引查找。
因此,散列表的读取效率为O(1).
7.4 处理冲突
- 冲突产生:thesaurus[“dab”]=“pat”,BAD=DAB=214=8,此时就产生了冲突。
- 冲突处理:分离链接:当冲突发生时,将值放在该格子所关联的数组。
若散列表的格子中含有数组,那么查找就相当于在数组上进行,因此散列表的最坏情况就是O(N)。为了避免这种情况,散列表的设计应该尽量减少冲突。以便查找都能以O(1)完成。
7.5 寻找平衡
- 散列表的效率取决于以下因素:
(1)要存多少数据。
(2)有多少可用的格子。
(3)用什么样的散列函数。
如果要存的数据很多,而格子又很少,就会造成大量的冲突;散列函数应尽可能的将数据分散到所有可能的格子里去。 - 散列表的使用时的动态平衡:既要节约空间,又要避免冲突。
- 负载因子:数据量/格子数;理想的负载因子为0.7.
PS:一般编程语言都会自带散列表的管理机制,它会帮我们决定散列表的大小、散列函数的逻辑以及扩展的时机。
7.6 数据作为键值
用散列表时,可以将数据作为键,值可以是任意形式,例如数组1,或者布尔值true等。
第8章 用栈和队列来构造灵活的代码
8.1 栈
栈就是一个子弹夹:先入后出、后入先出。
8.2 队列
队列就是一个水管:先进先出、后进后出。
第9章 递归
9.1 用递归代替循环
几乎所有的循环都能用递归带起,但能用不代表该用。
计算机是用栈来完成递归的。
9.2 基准情形
在递归领域,不再递归的情形称为基准情形。
第10章 飞快的递归算法
10.1 快速排序
- 分区:从数组随机选取一个值,以其为轴,将比它小的值放到它的左边,比它大的值放到右边。
- 快速分区的操作
(1)把数组分区。使轴到正确的位置上去。
(2)对轴左右的两个子数组递归的重复第1,2步,也就是说,两个子数组都各自分区,并形成各自的轴以及由轴分隔的更小的子数组。然后对这些子数组分区,一次类推。
(3)因为只含有1个元素的子数组就是基准情形,无须任何比较,所以只有元素量大于或等于2的子数组才算分区。因此,当分出的子数组长度为2时,便无须进行下一步操作。
10.2 快速排序的原理,实现请参考
10.3 快速排序的效率
- 关于快排的效率我们从分区入手,分区主要包含两个步骤:
比较:每个值都需要与轴做比较。
交换:在适当的时候将左右指针所指的两个值交换位置。 - 一次分区
对于比较:对于整个数组来说,每次分区,比较次数都是与N线性相关的次数(即使是二次分区,左右两个分区的比较和也是与N线性相关)。
对于交换:交换次数取决于数据的排列情况,一次分区最少会有一次,最多会有N/2次。
因此,一次分区的总效率可以理解为N。 - 分区次数
- 因为只含有1个元素的子数组就是基准情形,无须任何比较,所以只有元素量大于或等于2的子数组才算分区。
对于长度为N的矩形,只需要logN次分区即可将数组分为长度为2的分区,例如:长度为8的数组,只需要log8=3次分区,即可保证每个分区长度为2。 - 总效率:一次分区分区次数=NlogN,即O(N*logN)
10.4 最坏情况
- 最坏情况
快速排序的最坏情况就是每次分区都使轴落在数组的开头或者结尾。导致这种情况的原因有很多,包括数组已升序或降序排序。
对于已升序的数组,如[1,2,3,4,5,6,7,8],假设每次都以数组的最右端为轴,那么每次分区比较的次数为N的线性相关,但是需要N次分区(这也是这种情况是最坏情况的原因)。尽快每次分区不需要交换,但这种情况已经足以使得算法效率为O(N²)。
各种算法效率比较见第六章。 - 最坏情况的解决
为了避免最坏情况,就要尽量减少分区次数,最理想的情况是,每次分区的轴都选择上一分区的中位数。
10.5 快速选择
快速选择的效率可以比快速排序更高;快速选择可以理解成快速排序和二分查找的结合。
每次的轴选择剩余分区的第二大(或者小)的元素作为轴,这样就只需要比较比轴小(或者大)的元素,这样减少比较次数,不需要对整个数组的元素都排列。
此处理解的不是很到位。
第11章,基于节点的数据结构
11.1 链表
- 每个节点都需要2个格子,头一格用作数据存储,后一格用作指导下一节点的链(最后一个节点的链是null)。
- 链表相对于数组的好处就是,它可以将数据分散到内存各处,无须事先寻找连续的空格子。
11.2 链表操作
11.2.1. 链表读取
最坏情况,是读取到最后一个索引值,需要N步,因此读取链表的效率为O(N)。
11.2.2. 链表查找
同链表读取,查找链表的效率为O(N)。
11.2.3. 链表插入
链表插入需要两步:查找和链接。
1.查找:效率为O(N)
2.链接:插入节点挂链、插入点挂链2步。
3. 总效率:O(N)。
11.2.4 链表删除
链表删除也需要两步:查找、改变链接。
- 查找:O(N)
- 改变链接:只需要将删除节点的前一节点的链指向删除节点的后一节点即可。
- 总效率:O(N)。
操作 | 数组 | 链表 | 双向链表 |
---|---|---|---|
读取 | O(1) | O(N) | O(1) |
查找 | O(N) | O(N) | O(1) |
插入 | O(N)(在末端是O(1)) | O(N)(在表头是O(1)) | O(1) |
删除 | O(N)(在末端是O(1)) | O(N)(在表头是O(1)) | O(1) |
11.3 双向链表
11.3.1 基本概念
- 双向链表:每个节点都含有两个链,一个指向下一个节点,另一个指向上一个节点。
- 双向链表可以直接访问第一个和最后一个节点。
11.3.2 双向链表的效率
双向链表的读取、查找、插入、删除都是O(1)。
因为对于节点P,他自己有前驱和后驱,只需要知道P,便可以知道P-Pro和P–Next,因此可以一步到位。然后进行插入和删除即可,
第12章 让一切操作都更快的二叉树
12.1 树
- 根节点:最上面的节点“j”
- 父节点:“j”是“m”,“b”的父节点,
- 子节点:“m”,“b”是“j”的子节点。
- 分层:树可以分层。
12.2 二叉树
- 每个子节点数量可以是0,1,2。
- 如果有两个子节点,那么必须其中一个子节点必须小于父节点,用左箭头来表示;另一个大于父节点,用右箭头来表示。
12.3 二叉树的查找
12.3.1 二叉树查找步骤
- 从根节点开始,检视该节点的值。
- 如果正是所要找的值,太好了。
- 如果要找的值小于当前节点值,则在该节点的左子树查找。
- 如果要找的值大于当前节点值,则在该节点的右子树查找。
12.3.2 二叉树查找的效率。
- 在最好的情况(理想的平衡二叉树)下,二叉树查找的效率为O(logN),因为每进行一步,我们就把节点排除了一半。
- 只有用随机打乱的数据创建出来的树才可能是比较平衡的,要是插入已经排序的数据,这棵树就失衡了。例如对于[1,2,3,4,5]
12.4 二叉树的插入
二叉树的插入需要先查找,再插入
- 查找:O(logN)
- 插入:1
- 总效率:O(logN)。
12.5 二叉树的删除
12.5.1 二叉树的删除遵循的原则
- 如果要删除的节点没有子节点,直接删除就好。
- 如果要删除的节点有一个子节点,那删除它之后还需要将子节点填到被删除的位置上。
- 如果要删除的子节点有两个子节点,需要从两个子节点中寻找一个后继节点,后继节点就是所有比被删除节点大的子节点中,最小的那个。
寻找后继节点的算法:跳到被删除节点的右子节点,然后一路只往左子节点上跳,直到没有左子节点为止,则所停留的节点就是被删除节点的后继节点。
下例:我们需要删除根节点50,我们先跳到它的右子节点“75”,在一路往左子节点跳,找到52,此时,52就是后继节点。
此刻产生了悬空的子节点。 - 对于后继节点带有右子节点,则在后继节点填补被删除节点之后,用此右子节点替代后继节点的父节点的左子节点。
12.5.1 二叉树的删除效率
- 平均情况下,二叉树的删除效率是O(logN),因为包括一次查找和少量额外的步骤去处理悬空的子节点。
- 有序数组的删除需要左移元素去填补被删除元素产生的空隙。因此效率为O(N)。
第13章 连接万物的图
13.1 图
图的实现方式有很多,最典型的是散列表(字典)。
13.4 广度优先搜索
13.4.1 广度优先搜索步骤:
- 找出当前定点的所有邻接点。如果哪个是没访问过的,就让它入队,并标为“已访问”。
- 如果当前顶点没有未访问的邻接点,且队列不为空,就从队列中移出一个顶点作为当前顶点。
- 如果当前顶点没有未访问的邻接点,且队列里也没有其他顶点,则算法完成。
13.4.2 广度优先搜索的效率
- 处理顶点:有V个顶点就有V次出队。
- 处理边:访问邻接点所用的步数,是图中边的两倍,因为一条边连接着两个顶点,对于每个顶点,我们都要访问邻接点,所以每条边会被访问两次。
因此,有E条边,就会有2E步来访问邻接点。 - 总效率:O(V)+O(2E)=O(V+E)。
13.5 深度优先搜索
13.4 图数据库
图数据库由两部分组成:信息表和关系表。
-
信息表:存储具体数据
-
关系表:存储数据之间的关系,即边的信息
13.5 加权图
13.5.1 加权图的概念
与普通的图类似,但边上带有信息。
13.5.2 Dijkstra算法
- Dijkstra算法主要解决求两个节点之间最短路径问题。
- Dijkstra算法的规则:
(1)以起步顶点为当前顶点
(2)检查当前顶点的所有邻接点,计算起点到所有已知顶点的权重,并记录下来。
(3)从未访问过(未曾作为当前顶点)的临近节点中,选取一个起点能到达的总权重最小的顶点,作为下一个当前顶点。
(4)重复前三步,直到图中所有顶点都被访问过。
Dijkstra算法原理及MATLAB实现请参考
第14章 对付空间限制
到目前为止,我们只关注了各种算法的时间复杂度,但有些时候,我们还得考虑空间复杂度,即他们会消耗多少内存。
14.1 描述空间复杂度的大O记法
描述空间复杂的的大O记法,当所处理的数据有N个元素时,该算法还需要额外消耗多少元素大小的内存空间。
14.2 实例分析
我们要实现如下功能:对一个数组,将其中的小写字符返回大写字符。
- 函数1
当我们运行该函数时,内存里会有两个数组,[“amy”,“bob”,“cindy”,“derek”]和[“ARM”,“BOB”,“CINDY”,“DEREK”],也就是说,此函数接受一个N元素的数组,就会产生另一个新的N元素数组。我们记这个函数的空间复杂度为O(N)。 - 函数2
运行此函数,所需要的额外空间都一样(为0),因此我们认为该函数的空间效率为O(1)。
14.3 时间和空间的权衡
函数功能:检查数组中是否含有重复的值。
以上函数,时间复杂度为O(N²),但空间复杂度为O(1)。
以上函数,虽然时间复杂度为O(N),但是他的空间复杂度为O(N)。
所以在实际情况中,我们要具体情况具体分析,权衡时间和空间。
至此,该书的内容就基本总结完了,其中的一些内容,总结的不是很具体,如果有什么不足,请多指教。