The version of 2019.
前言
掌握好数据结构与算法,可以写出更高效、运行得更快的代码。
- 能写出高效、快速、优雅的代码;
- 能权衡各种写法的优劣;
- 能合理判断适用于给定情况的最优方案。
内容
- 第1章和第2章:数据结构和算法,时间复杂度,数组、集合和二分查找。
- 第3章:大记法。
- 第4章、第5章和第6章:大记法,各种排序算法,包括冒泡排序、选择排序和插入排序。
- 第7章和第8章:数据结构,包括散列表、栈和队列。
- 第9章:递归。
- 第10章:运用递归实现的算法,包括快速排序和快速选择。
- 第11章、第12章和第13章:基于结点的数据结构,包括链表、二叉树和图。
- 第14章:空间复杂度。
mindmap
数据结构和算法
数据结构
数组、集合
有序数组
散列表、栈和队列
链表、二叉树和图
算法
查找算法:线性查找、二分查找
排序算法:冒泡排序、选择排序和插入排序
递归算法:快速排序和快速选择
大O记法
时间复杂度
空间复杂度
第1章 数据结构为何重要
编程基本上就是在和数据打交道:接收、操作或返回。
数据指代各种类型的信息,包括最基本的数字和字符串。
数据结构指数据的组织形式,极大地影响着代码的运行速度。
1.0 前置共性
想了解某个数据结构的性能,得分析程序怎样操作这一数据结构。
一般数据结构都有以下4种操作/用法:读取、查找、插入、删除。
- 读取:查看数据结构中
某一位置上的数据。 - 查找:从数据结构中找出
某个数据值的所在。 - 插入:给数据结构
增加一个数据值。 - 删除:从数据结构中
移走一个数据值。
操作的速度,即时间复杂度。并不按时间计算,而是按步数计算。
受硬件影响的计时方法,非常不可靠。但按步数来算,则确切得多。
速度、时间复杂度、效率、性能,指的都是步数。
1.1 基础数据结构:数组
数组是一个含有数据的列表。通过名为索引的数字来标识每项数据在数组中的位置。在大多数编程语言中,索引是从0算起的。
对于数组来说,其操作:
- 读取:查看某个索引所指的数据值。
- 查找:检查其是否包含某个值,如果包含,返回其索引。
- 插入:加多一个格子并填入一个值。
- 删除:把数组中的某个数据项移走。
1.1.1 读取
读取:查看数组中某个索引所指的数据值。如:
索引3有什么值?
只要一步就够了,因为计算机本身就有跳到任意索引位置的能力。
计算机的内存可以被看成一堆格子。每个格子都有各自的地址,用一个普通的数字表示,比前一个大1。
当程序声明一个数组时,它会先划分出一些连续的空格子以备使用。
总之,计算机之所以在读取数组中某个索引所指的值时,能直接跳到那个位置上,是因为具备以下条件:
- 计算机可以一步就跳到任意一个内存地址上。
- 数组本身会记有第一个格子的内存地址。即:计算机知道这个数组的开头在哪里。
- 数组的索引从0算起。
数组好用的原因之一:一步读取任意索引的值。
1.1.2 查找
查找:检查其是否包含某个值,如果包含,返回其索引。如:
"dates"在不在数组里?
计算机只能一步一步地检查整个数组:先从索引0开始,检查其值,如果不匹配,则继续下一个索引,以此类推,直至找到为止或查遍每个格子都没找到。
逐个格子去检查的做法,就是最基本的查找方法 —— 线性查找。一个N格的数组,其线性查找的最多步数是N(N可以是任何自然数)。
无论多长的数组,查找都比读取要慢:查找可能需要多步。
1.1.3 插入
插入:插入一个新值到数组之中。
往数组里插入一个新元素的速度,取决于你想把它插入到哪个位置上。
- 末尾:只需
一步。计算机知道数组开头的内存地址,也知道数组包含多少个元素,所以可以算出要插入的内存地址,然后一步跳到那里插入即可。 - 开头或中间:需要
移动其他元素以腾出空间,得花费额外的步数。
最低效(花费最多步数)的插入是在数组开头:需要把数组所有的元素都往右移。
一个含有N个元素的数组,其插入数据的最坏情况会花费N+1步。即插入在数组开头,导致N次移动,加上1次插入。
1.1.4 删除
删除:消掉其某个索引上的数据。
删除本身只需要1步,但需要额外的步骤将数据左移以填补删除所带来的空隙。
跟插入一样,删除的最坏情况就是删掉数组的第一个元素。因为数组不允许空元素,当索引0空出,剩下的所有元素都要往左移去填空。
对于含有N个元素的数组,删除操作最多需要N步。
1.1.5 小结
| 操作 | 最大步数 | 说明 |
|---|---|---|
| 读取 | 1 | 目标内存地址 = 开始内存地址 + 索引 |
| 查找 | N | 线性查找 |
| 插入 | N+1 | 先右移腾出空位,再插入 |
| 删除 | N | 直接删除,再左移填补空位 |
1.2 集合:一条规则决定性能
集合是一种不允许元素重复的数据结构。
集合有不同形式。基于数组的这种集合就是一个带有“不允许重复”这种简单限制的数组。该限制导致它在4种基本操作中的插入与数组性能不同。
对于集合,计算机得先确定要插入得值不存在于其中 —— 每次插入要先来一次查找。也就是说,在N个元素的集合中进行插入:
- 最好的情况是在集合的末尾插入,需要
N+1步:N步去确认被插入的值不在集合中,加上最后插入的1步。 - 最坏的情况是在集合的开头插入,需要
2N+1步:N步去确认被插入的值不在集合中,然后用N步来把所有值右移,最后再用1步来插入新值。
1.3 总结
理解数据结构的性能,关键在于分析操作所需的步数。
| 数据结构 | 特点 | 读取 | 查找 | 插入 | 删除 |
|---|---|---|---|---|---|
| 数组 | 元素连续 | 1 | N | N+1 | N |
| 集合 | 元素不重复 | 1 | N | 2N+1 | N |
- 集合的插入,比
数组多一次查找。 - 通过步数分析来判断某种应用该选择数组还是集合:
- 在需要保证数据不重复的场景中,
集合优于数组的。 - 否则,选择插入比集合快的
数组会更好一些。
- 在需要保证数据不重复的场景中,
第2章 算法何为重要
即使是同一种数据结构,不同的算法有不同的时间复杂度。
算法只是解决某个问题的一套流程。在计算机的世界里,算法是指某项操作的过程。如:读取、查找、插入和删除。
一种操作可能会有不止一种做法。即,一种操作会有多种算法的实现。
不同的算法能使代码变快或者变慢 —— 高负载时甚至慢到停止工作。
2.1 有序数组
常规的数组不考虑是否有序。
有序数组就是指值总是保持有序的数组。即每次插入新值时,它会被插入到适当的位置,使整个数组的值仍然按顺序排列。
- 先找出那个适当的位置;
- 将其及以后的值右移来腾出空间给新值。
往有序数组中插入新值,需要先做一次查找以确定插入的位置。
2.2 查找有序数组
常规数组的查找方式:线性查找 —— 从左至右,逐个格子检查,直至找到。
有序数组相比常规数组的一大优势就是除了可以用线性查找,还可以使用另一种查找算法:二分查找。
常规数组因为无序,所以不可能运用二分查找。
有序数组的线性查找大多数情况下都会快于常规数组。除非要找的值是最后那个,或者比最后的值还大,不得不一直查到最后。
2.3 二分查找
首先,设定下界和上界,以限定所查之值可能出现的区域。循环检查上界和下界之间的最中间的元素。当下界超越上界,便知数组里没有我们要找的值。
- 在开始时,以数组的第一个元素为下界,以最后一个元素为上界。如此找出最中间的格子的索引,获取该中间格子的值。
- 如果该值正是我们想查的,算法结束。
- 否则对比要查的值的大小决定往左边还是右边查找来调整下界或上界。
2.4 二分查找与线性查找
二分查找会在每次猜测后排除掉一半的元素。每次有序数组长度乘以2,二分查找所需的最多步数只会加1。
线性查找则是元素有多少,最多步数就是多少。数组长度翻倍,线性查找的最多步数就会翻倍。
2.5 总结
计算一样东西并不只有一种方法,换种算法可能会极大地影响程序的性能。同时,世界上并没有哪种适用于所有场景的数据结构或者算法。
在经常插入而很少查找的情况下,插入迅速的常规数组会是更好的选择。
比较算法的方式就是比较各自的步数。
第1章和第2章:数据结构和算法,时间复杂度,数组、集合和二分查找。
mindmap
数据结构和算法
数据结构
数组:元素连续
集合:元素不重复
有序数组:元素有顺序
算法
线性查找:多少个元素,多少步数
二分查找:元素翻倍,步数加1
第3章 大O记法
算法分析的专业工具:
统一、量化、规范化描述。
影响算法性能的主要因素是其所需的步数。但一个算法的步数并不是固定的。
采用量化或者规范化语言描述,那就是大O记法 —— 轻松指出一个算法的性能级别。
3.1 大O:数步数
为了统一描述,大不关注算法所用的时间,只关注其所用的步数。
- 数组不论多大,读取、数组末尾的插入与删除都只需要
1步。大记法表示为O(1)。:一种算法无论面对多大的数据量,其步数总是相同的。
- 对于具有N个元素的数组,线性查找最多需要
N步。大记法表示为O(N)。:处理一个N元素的数组需花N步的算法的效率。
3.2 常数时间与线性时间
大记法表示算法的步数,基于要处理的数据量来描述算法所需的步数。或者说,大解答的是这样的问题:当数据增长时,步数如何变化?
- 算法所需的步数等于数据量,意思是当数组增加一个元素时,算法就要增加1步。——
线性时间 - 算法无论面对多大的数组,其步数都不变。——
常数时间
大主要关注的是数据量变化时算法的性能变化。即使一个算法的恒定步数不是1,也可以被归类为。
常数时间:不管数据量怎样变化,步数恒定,大记法表示为O(1)。O(1)用来表示所有数据量增长但步数不变的算法。
3.3 同一算法,不同场景
大可以用来表示给定算法的最好和最坏的情景,但无特殊说明,大记法一般都是指最坏情况。
知道各种算法会差到什么程度,能使我们做好最坏打算,以选出最合适的算法。
3.4 第三种算法
用大记法描述二分查找。
二分查找的步数会随着数据量的增长而增长,但是步数比元素数量要少得多。即:二分查找的时间复杂度介于和之间 —— ,意味着当数据量翻倍时,步数加1。
3.5 对数
log即是对数(logarithm),是指数的反函数。
- 指数:
- 对数:
- 意思是:要把2乘以自身多少次才能得到8
- 或者说:将8不断地除以2直到1,需要多少个2
- 或者说:将8不断地除以2,要除多少次才能到1
3.6 解释O(logN)
其实指的是,为了方便省略了2。代表算法处理N个元素需要步。
二分查找:不断地将数组拆成两半,直至范围缩小到只剩你要找的那个元素。
简单来说,算法的步数等于二分数据直至剩余1个的次数。
3.7 三种算法效率对比
| N个元素 | |||
|---|---|---|---|
| 线性时间 | 对数时间 | 常数时间 | |
| 8 | 8 | 3 | 1 |
| 16 | 16 | 4 | 1 |
| 32 | 32 | 5 | 1 |
| 64 | 64 | 6 | 1 |
| 128 | 128 | 7 | 1 |
| 256 | 256 | 8 | 1 |
| 512 | 512 | 9 | 1 |
| 1024 | 1024 | 10 | 1 |
曲线的微弯,使其效率略差于,却远胜于。
3.8 总结
大记法,让算法比较有了一致的参考系。
第4章 运用大O来给代码提速
大记法能客观地衡量各种算法的时间按复杂度,是比较算法的利器。
4.1 冒泡排序
排序算法:如何将一个无序的数字数组整理成升序?
冒泡排序 是一种很基本的排序算法,步骤如下:
- 指向数组中两个相邻的元素(开始为数组的头两个元素),比较它们的大小。
- 如果其顺序错了(即左边的值大于右边),就互换位置。否则,这一步什么都不用做。
- 将两个指针右移一格。重复第(1)步和第(2)步,直至指针到达数组末尾。
- 重复第(1)至(3)步,直至从头到尾都无须再做交换,即数组已排好序。
重复的第(1)至(3)步是一个
轮回。
这个算法的主要步骤被“轮回”执行,直至整个数组的顺序正确。
冒泡排序:每一次轮回过后,未排序的值中最大的那个都会“冒”到正确的位置上。
主要步骤:比较、交换 —— 轮回。
结束标识:本次轮回没有任何交换,即可知整个数组已排好序。
4.2 冒泡排序的效率
冒泡排序的执行步骤可分为两种:
- 比较:比较两个数看哪个更大。
- 交换:交换两个数的位置以使它们按顺序排列。
元素量呈倍数增长,步数呈指数增长。随着的增长,步数大约增长为。因此,描述冒泡排序效率的大记法为:。
也被叫作二次时间。
4.3 二次问题
大测量的步数与数据量的关系。
- 首先搞清楚这个算法有哪些步骤,以及其最坏情况是什么。
嵌套循环算法的效率就是。
4.4 线性解决
检查数组中是否有重复值
// 嵌套循环
function hasDuplicateValue(array) {
var steps = 0;
for (var i=0; i < array.length; i++) {
for (var j=0; j < array.length; j++) {
steps++;
if (i !==j && array[i]==array[j]) {
return true;
}
}
}
console.log(steps);
return false;
}
// 线性解决
function hasDuplicateValue(array) {
var step = 0;
var existingNumbers=[];
for (var i=0; i < array.length; i++) {
steps++;
if (existingNumbers[array[i]]===undefined) {
existingNumbers[array[i]]=1;
} else {
return true;
}
}
console.log(steps);
return false;
}
| 算法效率比较 | 嵌套循环 | 线性解决 |
|---|---|---|
| 检查数组中是否有重复值 |
4.5 总结
大记法能使我们发现低效的代码,有助于我们挑选出更快的算法。
第5章 用或不用大O来优化代码
如何分辨那些效率貌似一样的算法,从而选出较快的那个。
5.1 选择排序
选择排序的步骤如下:
- 从左至右检查数组的每个格子,
找出值最小的那个。在此过程中,我们会用一个变量来记住检查过的数字的最小值(事实上记住的是索引)。如果一个格子中的数字比记录的最小值还要小,就把变量改成该格子的索引。 - 知道哪个格子的值最小之后,将该格与本次检查的起点
交换。 - 重复第(1)(2)步,直至数组排好序。
5.2 选择排序的实现
function selectionSort(array) {
for (var i=0; i < array.length; i++) {
var lowestNumberIndex=i;
for (var j=i + 1; j < array.length; j++) {
if (array[j] < array[lowestNumberIndex]) {
lowestNumberIndex=j;
}
}
if (lowestNumberIndex !=i) {
var temp=array[i];
array[i]=array[lowestNumberIndex];
array[lowestNumberIndex]=temp;
}
}
return array;
}
5.3 选择排序的效率
选择排序的步骤可分为两类:
- 比较:在每轮检查中把未排序的值跟该轮已遇到的最小值做比较。
- 交换:将最小值与该轮起点的值交换以使其位置正确。
5.4 忽略常数
选择排序的步数大概只有冒泡排序的一半,即选择排序比冒泡排序快一倍。但选择排序的大记法跟冒泡排序一样:。
大记法忽略常数:大记法不包含一般数字,除非是指数。
5.5 大O的作用
大的作用:区分不同算法的长期增长率。
临界点:当数据量达到一定程度时。—— 大记法忽略常数的原因。
大记法只表明,对于不同分类,存在一临界点,在这一点之后,一类算法会快于另一类,并永远保持下去。至于这个点在哪里,大并不关心。
类别&&大数据临界点
大记法非常适用于不同大分类下的算法对比。
第6章 乐观地调优
顾及最坏情况以外的场景
最坏情况不是唯一值得考虑的情况,全面分析各种情况,能帮助你为不同场景选择适当的算法。
6.1 插入排序
插入排序包括以下步骤:
- 在第一轮里,暂时将索引1(第2格)的值移走,并用一个临时变量来保存它。将该索引处
留空。在之后的轮回,移走后面索引的值。 - 平移阶段:拿空隙左侧的每一个值与临时变量的值进行
比较。- 如果空隙左侧的值大于临时变量的值,则将该值右移一格。值右移,空隙左移。
- 如果遇到比临时变量小的值,或者空隙已经到了数组的最左端,就结束
平移。
- 将临时移走的值
插入当前空隙。 - 重复第(1)至(3)步,直至数组完全有序。
6.2 插入排序的效率
插入排序包含4种步骤:
- 移除:每一轮里发生一次。
- 比较:每次拿
temp_value跟空隙左侧的值比大小。 - 平移:每次将值右移一格。
- 插入:每一轮里发生一次。
大只保留最高阶的。
在最坏的情况里,插入排序的时间复杂度跟冒泡排序、选择排序一样,都是。
6.3 平均情况
平均情况:那些最常遇见的情况。最快情况和最好情况都是不常见的。
- 如果数组大致有序,那么插入排序比较好。
- 如果数组大致逆序,那么选择排序会更快。
- 如果无法确定,即平均情况,两种都可以。
6.4 一个实例
找出两个数组的交集
// 简单的嵌套循环:数组一的每个值与数组二的每个值比较
function intersection(first_array, second_array) {
var result=[];
for (var i=0; i < first_array.length; i++) {
for (var j=0; j < second_array.length; j++) {
if (first_array[i]==second_array[j]) {
result.push(first_array[i]);
}
}
}
return result;
}
// break 中断内部循环,节省时间和步数:知道数组二中存在数组一的那个值就可以了
function intersection(first_array, second_array) {
var result=[];
for (var i=0; i < first_array.length; i++) {
for (var j=0; j < second_array.length; j++) {
if (first_array[i]==second_array[j]) {
result.push(first_array[i]);
break;
}
}
}
return result;
}
性能调整为介于和之间。
6.5 总结
| 排序算法 | 步骤 | 步数 | 大记法 | 说明 |
|---|---|---|---|---|
| 冒泡排序 | 从0开始,两两相邻比较、及时交换,本次轮回没有交换即结束 | |||
| 选择排序 | 从0开始,比较找最小值、跟本轮起点位置交换,没有提早结束某一轮的机制(每一轮都得比较所选索引右边的所有值) | 大记法忽略常数 | ||
| 插入排序 | 从1开始移除、比较找最小值、跟本轮起点位置交换、最后插入最小值,本次轮回遇到比临时变量小的值,或者空隙已经到了数组的最左端即结束 | 大记法只保留最高阶的 |
mindmap
算法
排序算法
冒泡排序
选择排序
插入排序
大O记法
类别
常数时间
对数时间
线性时间
二次时间
特点
忽略常数
只保留最高阶的N
默认采用最坏情况
第7章 查找迅速的散列表
7.1 探索散列表
散列 --> 散列函数 --> 散列表
大多数编程语言都自带散列表这种能够快速读取的数据结构。但在不同语言中,有不同的名字:散列、映射、散列映射、字典、关联数组。
散列表就是一堆成对的元素。一对数据里,一个叫作键,另一个叫作值。键和值应该具有某种意义上的关系。
在散列表中查找值的平均效率为,只要一步:先计算出键的散列值,然后根据散列值跳到对应的格子去。
将字符串转为数字串的过程就是散列,其中用于对照的密码,就是散列函数。散列函数可以有多种。
一个散列函数需要满足以下条件才有效:每次对同一字符串调用该散列函数,返回的都应是同一数字串。
- 散列:转换过程
- 散列函数:转换规则
- 散列表:一堆键-值成对的元素
7.2 处理冲突
往已被占用的格子里放东西,就会造成冲突。
一种经典的做法是分离链接。当冲突发生时,将值放到该格子所关联的数组里。
若散列表的格子含有数组,查找步数会多于1,因为要在关联的数组里执行线性查找。
为了避免这种情况,散列表的设计应该尽量避免减少冲突,以便查找都能以完成。
7.3 找到平衡
散列表的效率取决于以下因素:
- 要存多少数据
- 有多少可用的格子
- 用什么样的散列函数
如果要放的数据很多,格子却很少,就会造成大量冲突,导致效率降低。
一个好的散列函数,应当能将数据分散到所有可用的格子里去。
使用散列表时需要权衡:既要避免冲突,又要节约空间。
黄金法则:每增加7个元素,就增加10个格子。
数据量与格子数的比值称为负载因子。理想的负载因子是0.7(7个元素/10个格子)。
一般编程语言都自带散列表的管理机制,它会帮忙决定散列表的大小、散列函数的逻辑以及扩展的时机。
7.4 一个例子
散列表有各种用途,如:提高算法速度。
把数组作为集合,数据是直接放到格子里的。用散列表的话,则是将数据作为键,值可以为任何形式。
散列表在JavaScript里叫作对象。其读取和插入的效率都是。
第8章 用栈和队列来构造灵巧的代码
栈和队列:是多加了一些约束条件的数组,是处理临时数据的灵活工具。
临时数据:一些处理完便不再有用的信息,没有保留的必要。
栈和队列将数据按顺序处理,并在处理完成后将数据抛弃。
8.1 栈
栈的约束条件有3:
- 只能在末尾插入数据
- 只能读取末尾的数据
- 只能移除末尾的数据
栈的插入、读取、移除操作都只能在栈的末尾进行。
栈的相关概念:
- 栈顶:栈的末尾
- 栈底:栈的开头
- 压栈:往栈里插入数据
- 出栈:从栈顶移除数据
栈的特点:
- 后进先出:
LIFO(last in, first out),最后入栈的元素,会最先出栈。
栈的适用场景:
- 跟踪括号的配对情况
- 跟踪函数的调用情况,如:网络应用程序的函数调用
- 文字处理器的“撤销”动作
当数据的处理顺序要与接收顺序相反时,用栈就对了。
8.2 队列
队列的约束条件有3:
- 只能在末尾插入数据
- 只能读取开头的数据
- 只能移除开头的数据
队列的插入操作只能在队列的末尾进行,读取、移除操作都只能在队列的开头进行。
队列的特点:
- 先进先出:
FIFO(first in, first out),最先入队的元素,会最先出队。
队列的适用场景:
- 飞机排队起飞
- 病人排队看医生
当数据的处理顺序按接收的顺序来执行时,用队列就好了。
8.3 总结
| 特点 | 约束条件 | 适用场景 | |
|---|---|---|---|
| 栈 | 先进后出 | 插入、读取、移除都只能在末尾进行 | 数据的处理顺序与接收顺序相反 |
| 队列 | 先进先出 | 插入只能在末尾进行,读取、移除只能在开头进行 | 数据的处理顺序与接收顺序相同 |
第9章 递归
函数调用自身,就叫作递归。无限递归用处不大,甚至还挺危险。但是有限的递归很强大。
9.1 用递归代替循环
几乎所有循环都能够转换成递归。但能用不代表该用。递归的强项在于巧妙地解决问题。
9.2 基准情形
在递归领域,不再递归的情形称为基准情形。
9.3 阅读递归代码
可以按照以下流程来阅读递归代码:
- 找出基准情形
- 看该函数在基准情形下会做什么
- 看该函数在到达基准情形的前一步会做什么
- 就这样往前推,看每一步都在做什么
9.4 计算机眼中的递归
计算机是用栈来记录每个调用中的函数。这个栈就叫作调用栈。
无限递归的程序会一直将同一方法加到调用栈上,直到计算机的内存空间不足,最终导致栈溢出的错误。
9.5 递归实战
递归可以自然地用于实现那些需要重复自身的算法。
递归可以增强代码的可读性,但不会改变算法的大。
9.6 总结
递归十分适用于那些无法预估计算深度的问题。
第10章 飞快的递归算法
为了免去大家重复编写排序算法的烦恼,大多数编程语言都自带用于数组排序的函数,其中很多采用的都是快速排序。
研究其原理,学习递归的运用。
10.1 分区
快速排序依赖于一个名为
分区的概念。
分区指的是从数组随机选取一个值,以其为轴,将比它小的值放到它左边,比它大的值放到它右边。步骤如下:
- 左指针逐个格子向右移动,当遇到大于或等于轴的值时,就停下来。
- 右指针逐个格子向左移动,当遇到小于或等于轴的值时,就停下来。
- 将两指针所指的值交换位置。
- 重复上述步骤,直至两指针重合,或左指针移动到右指针的右边。
- 将轴与左指针所指的值交换位置。
当分区完成时,在轴左侧的那些值肯定比轴要小,在轴右侧的那些值肯定比轴要大。因此,轴的位置也就确定了,虽然其他值的位置还没完全确定。
10.2 快速排序
快速排序严重依赖于分区。其运作方式如下:
- 把数组分区,使轴到正确的位置。
- 对轴左右的两个子数组递归地重复第1、2步。即两个子数组都各自分区,并形成各自的轴以及轴分隔的更小的子数组。然后也对这些子数组分区,以此类推。
- 当分出的子数组的长度为
0或1时,即达到基准情形,无须进一步操作。
10.3 快速排序的效率
从分区开始,分解来看包含两种步骤。
- 比较:每个值都要与
轴做比较。 - 交换:在适当时候将左右指针所指的两个值交换位置。
每次分区时,左右指针都会从两端开始靠近,直到相遇。
只含1个元素的子数组就是基准情形,无须任何交换和比较,所以只有元素量大于或等于2的子数组才要算分区。
快速排序的步数接近。最佳情况应该是每次分区后轴都刚好落在子数组的中间。
10.4 最坏情况
快速排序最坏的情况就是每次分区都使轴落在数组的开头或结尾。如:数组已升序排列,或已降序排列。
在最常遇见的平均情况,快速排序的比插入排序的好得多,所以总体来说,快速排序优于插入排序。
| 最好情况 | 平均情况 | 最坏情况 | |
|---|---|---|---|
| 插入排序 | |||
| 快速排序 |
由于快速排序在平均情况下表现优异,很多编程语言自带的排序函数都采用它来实现。
10.5 快速选择
跟快速排序类似,快速选择需要对数组分区。或者把它想象成快速排序和二分查找的结合。
分区的作用就是把轴排到正确的格子上。快速选择就利用了这一点 —— 像是不断将查找范围缩小一半的二分查找,下一次的分区操作只需要在上一次分出的一半区域上进行,即值可能存在的那一半。
快速选择的优势在于不需要把整个数组都排序就可以找到正确位置的值。
快速选择的效率为。
10.6 总结
运用递归,快速排序和快速选择可以将棘手的问题解决得既巧妙又高效。
其实能递归的不只有算法,还有数据结构。链表、二叉树以及图,利用自身递归的特性,提供了迅速的数据操作方式。
| 递归 | 举例 |
|---|---|
| 算法 | 快速排序、快速选择 |
| 数据结构 | 链表、二叉树、图 |
第11章 基于结点的数据结构
基于结点的数据结构拥有独特的存取方式。链表是最简单的一种基于结点的数据结构,看上去和数组差不多,但在性能上却各有所长。
11.1 链表
像数组一样,链表也用来表示一系列的元素。
能用数组来做的事情,一般也可以用链表来做。
与数组不同的是,组成链表的格子不是连续的。它们可以分布在内存的各个地方。这种不相邻的格子,就叫作结点。
每个结点除了保存数据,还保存着链表里的下一结点的内存地址。这份用来指示下一结点的内存地址的额外数据,被称为链。
结点 = 数据 + 下一结点的内存地址
每个结点都需要2个格子,头一格用作数据存储,后一格用作指向下一结点的链(最后一个结点的链是null)。
若想使用链表,需要知道第一个结点在内存的什么位置。
链表相对于数组的一个好处是,可以将数据分散到内存各处,无须事先寻找连续的空格子。
11.2 读取
因为链表的结点可以分布在内存的任何地方。程序知道的只有第1个结点的内存地址,须先读取索引0的链,然后顺着该链去找索引1...依次遍历读取指定索引。
读取链表的时间复杂度为。
11.3 查找
查找:从列表中找出某个特定值所在的索引。对于数组和链表来说,都是从第一格开始逐个格子地找,直至找到或遍历完整个链表都没找到。所以链表的查找效率跟数组一样,都是。
11.4 插入
链表的最坏情况和最好情况与数组刚好相反。
| 场景 | 数组 | 链表 |
|---|---|---|
| 在前端插入 | 最坏情况 | 最好情况 |
| 在中间插入 | 平均情况 | 平均情况 |
| 在末端插入 | 最好情况 | 最坏情况 |
- 数组插入时要先留空插入位,涉及
平移。 - 链表插入时要先查找插入位,涉及
读取。
因此,链表的插入效率与数组一样,为。
11.5 删除
删除和插入效率几乎一模一样。
11.6 小结
链表与数组的性能对比
| 操作 | 数组 | 链表 |
|---|---|---|
| 读取 | ||
| 查找 | ||
| 插入 | ||
| 删除 |
高效地遍历单个列表并删除其中多个元素,是链表的亮点之一:只需改动结点中链的指向,然后就可以继续检查下一个元素地址了。
11.7 双向链表
链表的另一个引人注目的应用,就是作为队列的底层数据结构。
双向链表跟链表差不多,只是它每个结点都含有两个链:
- 一个指向下一结点
- 一个指向前一结点
能直接访问第一个和最后一个结点。
采用双向链表这一链表的变种,就能使队列的插入和删除都为。
第12章 让一切操作都更快的二叉树
散列表:
- 既保持顺序 —— 有序数组
- 又快速查找、插入和删除 —— 散列表
12.1 二叉树
树里面的每个结点,可以包含有多个链分别指向其他多个结点。
谈论树的时候,会用到以下术语:
- 根
- 父节点 v.s 子节点
- 层
基于数的数据结构有很多种,二叉树是一种遵守以下规则的树:
- 每个结点的子结点数量可以为0、1、2。
- 如果有两个子结点,则其中一个子结点的值必须小于父节点;另一个子结点的值必须大于父节点。
12.2 查找
二叉树的查找算法先从根结点开始。
- 检视该结点的值。
- 如果正是所要找的值,太好了!
- 如果要找的值小于当前结点的值,则在该结点的左子树查找。
- 如果要找的值大于当前结点的值,则在该结点的右子树查找。
二叉树查找跟有序数组的二分查找拥有同样的时间复杂度,都是。因为每行进一步,就把剩余的结点排除了一半。
12.3 插入
首先找出要插入的值应该被链接到哪个结点上。从根结点开始找起,找到一个没有子结点的结点,没法再往下找了,意味着可以做插入了。
插入这1步总是发生在查找之后,按照大忽略常数来说,二叉树的插入效率为。
有序数组的插入是,因为该过程除了查找,还得移动过大量的元素来给新元素腾出空间。
只有用随意打乱的数据创建出来的树才有可能是比较平衡的。如果插入的都是已排序的数据,那么这棵树就失衡了,用起来会比较低效。
因此,使用有序数组里的数据来创建二叉树,最好先把数据洗乱。
在完全失衡的最坏情况下,二叉树的查找需要。在理想平衡的最好情况下,则是。
12.4 删除
删除是二叉树的各种操作中最麻烦的一个。
首先,查找出要删掉的结点所在,然后一步将该结点删掉。
删除操作遵循以下规则:
- 如果要删除的结点没有子结点,那直接删掉它就好。
- 如果要删除的结点有一个子结点,那删掉它之后,还要将子结点填到被删除结点的位置上。
- 如果要删除的结点有两个子结点,则将该结点替换成其后继结点。
一个结点的
后继结点,就是所有比被删除结点大的子结点中,最小的那个。 —— 跳到被删除结点的右子结点,然后一路只往左子结点上跳,直到没有左子结点为止,则所停留的结点就是被删除节点的后继结点。- 如果后继结点带有右子结点,则在后继结点填补被删除结点以后,用此右子结点替代后继结点的父结点的左子结点。
跟查找和插入一样,平均情况下二叉树的删除效率也是。
- 一次查找。
- 少量额外的步骤去处理悬空的子结点。
有序数组的删除则由于需要左移元素去填补被删除元素产生的空隙,最终导致的时间复杂度。
12.5 总结
二叉树是一种强大的基于结点的数据结构,既能维持元素的顺序,又能快速地查找、插入和删除。
二叉树在查找、插入和删除上引以为傲的效率,使其成为了存储和修改有序数据的一大利器。尤其适用于需要经常改动的数据。
树形的数据结构除了二叉树以外还有很多种,包括堆、B树、红黑树、2-3-4树等。
第13章 连接万物的图
13.1 图
图是一种善于处理关系型数据的数据结构,使用它可以很轻松地表示数据之间是如何关联的。
图的术语:
- 顶点:每个结点。
- 边:每条线段。
- 当两个顶点通过一条边联系在一起时,这两个顶点是相邻的。
图的实现形式有很多,最简单的方法之一就是用散列表。
- 有向图:在图中用箭头表示线段方向。
- 无向图:在图中表示为普通的线段。
13.2 广度优先搜索
图有两种经典的遍历方式:广度优先搜索和深度优先搜索。
广度优先搜索算法需要用队列来记录后续要处理哪些顶点。
该队列最初只含有起步的顶点。
首先处理起步顶点:将其移出队列,标为“已访问”,并记为当前顶点。
接着按照以下3步去做:
- 找出当前顶点的所有
邻接点。如果有哪个是没访问过的,就把它标为“已访问”,并且将它入队。 - 如果当前顶点没有未访问的邻接点,
- 队列不为空,那就再从队列中移出一个顶点作为
当前顶点。 - 队列为空,那么算法完成。
- 队列不为空,那就再从队列中移出一个顶点作为
将算法的步骤分为两类后,可以看出图的广度优先搜索的效率。
- 让顶点出队,将其设为当前顶点: —— 有V个顶点,就有V次出队。
- 访问每个顶点的邻接点: —— 有E条边,就会有2E步来访问邻接点。
访问邻接点所用的步数,是图中边数的两倍。因为一条边连接着两个顶点,对于每个顶点,都要访问其所有邻接点。
广度优先搜索有次出队,还有次访问,效率为。
13.3 图数据库
图数据库 v.s 关系型数据库:找人物关系。
关系型数据库存储信息需要两张表:
- 一张保存个人信息
- 一张保存朋友信息
计算机从个人信息表找一行的速度大概是。对于每个朋友都要执行以此步数为的搜索,即有M个朋友,提取他们的个人信息的效率为。
相比之下,后端为图数据库时,一旦在数据库中定位到了指定人,只需一步就能查到其任一朋友的信息。因为数据库中的每个顶点已经包含了该用户的所有信息。
也就是说,用图数据库的话,有N个朋友就需要步去获取他们的数据。
| 数据库 | 效率 |
|---|---|
| 关系型数据库 | |
| 图数据库 |
开源的图数据库有:
- Neo4j
- ArangoDB
- Apache Giraph
13.4 加权图
加权图跟普通图类似,但边上带有信息。还可以是有方向的。
可以借助加权图来解决最短路径问题。
13.5 Dijkstra算法
解决最短路径问题的算法有好几种,其中一种有趣的算法是由Edsger Dijkstra于1959年发现的Dijkstra算法。
Dijkstra算法的规则如下:
- 以起步的顶点为当前顶点。
- 检查当前顶点的所有邻接点,计算起点到所有已知顶点的权重,并记录下来。
- 从未访问过(未曾作为当前顶点)的邻接点中,选取一个起点能到达的总权重最小的顶点,作为下一个当前顶点。
- 重复前3步,直至图中所有顶点都被访问过。
第14章 对付空间限制
在分析各种算法的效率时,时间复杂度关注的是它们运行得有多快,空间复杂度去估计它们会消耗多少内存。
14.1 描述空间复杂度的大O记法
大记法,既描述时间复杂度,也描述空间复杂度。
- 大记法描述一个算法的
速度:当所处理的数据有个元素时,该算法所需的步数相对于元素数量是多少。 - 大记法描述一个算法需要多少
空间:当所处理的数据有个元素时,该算法还需要额外消耗多少元素大小的内存空间。
// 空间效率 O(N)
function makeUpperCase(array) {
var newArray=[];
for(var i=0; i < array.length; i++) {
newArray[i]=array[i].toUpperCase();
}
return newArray;
}
// 空间效率 O(1):
function makeUpperCase(array) {
for(var i=0; i < array.length; i++) {
array[i]=array[i].toUpperCase();
}
return array;
}
| 版本 | 时间复杂度 | 空间复杂度 |
|---|---|---|
| 1 | ||
| 2 |
时间复杂度相同,但是版本2对内存的使用效率更高。选择版本2更为合理。
不消耗额外的内存空间,其空间复杂度为。
- 时间复杂度的意味着一个算法无论处理多少数据,其速度恒定。
- 空间复杂度的意味着一个算法无论处理多少数据,其消耗的内存恒定。
空间复杂度是根据额外需要的内存空间(也叫辅助空间)来算的,也就是说原本的数据不纳入计算。
有些参考书在计算空间复杂度时是连原始输入也一起算的,那没问题。但需留意一下是否计算原始输入。
14.2 时间和空间之间的权衡
从全局看待问题:
- 如果想要程序跑得超级快,而且内存十分充足,那需要重点考虑时间复杂度;
- 如果不看重速度,而且程序是泡在需要谨慎使用内存的嵌入式系统上,那需要重点关注空间复杂度。
14.3 写在最后的话
调优方向/技术分析框架/思路:分析数据结构和算法 —— 代码的速度、内存占用、可读性。
明白计算包含各种细节,尽管大之类的理论会建议哪种做法更好,但若考虑其他因素,可能会做出不同的选择。机器对内存的管理方式和编程语言的底层实现,都会影响程序的性能。
性能测试工具:测试代码速度和内存消耗,检验调优的具体实现是否有效。
结尾
数据结构
| 数据结构 | 区别 |
|---|---|
| 数组 | 元素连续 |
| 集合 | 元素不重复 |
| 有序数组 | 元素有顺序 |
| 散列表 | 元素成对 |
| 栈 | 先进后出,元素的插入、读取、移除操作只能在末尾进行 |
| 队列 | 先进先出,元素的插入只能在末尾进行,读取、移除只能在开头进行 |
| 链表 | 元素不连续 |
| 二叉树 | 有约束的树:每个结点的子节点数量可为0、1、2;如果有两个子结点,则其中一个子结点的值必须小于父结点,另一个子结点的值必须大于父结点 |
| 图 | 一种善于处理关系型数据的数据结构 |
集合、有序数组、栈、队列、链表,是加多了一些约束条件的数组。散列表,在JavaScript里叫对象。链表、二叉树和图,是基于结点的数据结构。
算法
分析算法效率思路
- 拆解算法步骤
- 时间复杂度:数步数
- 空间复杂度:检查是否额外消耗内存空间