线性表:
非线性表:
排序算法 | 平均时间复杂度 | 空间复杂度 | 是否原地排序 | 是否稳定 | 是否基于比较 | |
---|---|---|---|---|---|---|
冒泡 | O(n^2) | O(1) | Y | Y | Y | |
插入 | O(n^2) | O(1) | Y | Y | Y | |
选择 | O(n^2) | O(1) | Y | N | Y | |
希尔 | O(n^2) | O(1) | Y | N | Y | |
递归思想 | 快排 | O(nlogn) | O(1) | Y | N | Y |
递归思想 | 归并 | O(nlogn) | O(n) | N | Y | Y |
堆排序 | O(nlogn) | O(1) | Y | N | Y | |
线性排序 | 桶排序 | O(n) | N | Y | N | |
线性排序 | 计数排序 | O(n) | N | Y | N | |
线性排序 | 基数排序 | O(n) | N | Y | N |
希尔排序是对插入排序的优化。
原地排序算法:指空间复杂度是 O(1) 的排序算法。
稳定性是指:如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间保持原有的先后顺序。
冒泡排序只会操作相邻的两个数据。
每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求,如果不满足就让他两互换。
一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序。
例如对一组数据 [4,5,6,3,2,1] 进行一趟冒泡排序操作是这样的:
整个冒泡过程如下:
冒泡过程还可以优化。
下面的例子有6个元素,只需要4次就可以:
一个本来有序的数组,在插入一个元素后,使其保持有序。这个过程就相当于插入排序算法的过程。
插入排序算法:
以数据 [4,5,6,1,3,2] 为例,其中左侧为已排序区间,右侧是未排序区间,其排序过程如下:
选择排序也分为已排序区和未排序区,每次从未排序区中找到最小的元素,将其放到已排序区的末尾。
归并排序是一个先分后合的过程。
如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
两个有序数组的 merge 过程:
快排也是利用分治思想。
如果要排序数组中下标从 p 到 r 之间的数据,我们选择 p 到 r 之间的任意一个数据作为 pivot(分区点,一般选择区间中的最后一个),遍历 p 到 r 之间的数据:
那么,p 到 r 之间的数据被分成了三个部分:
根据分治、递归思想,可以用递归排序下标从 p 到 p-1 之间的数据和 q+1 到 r 之间的数据,直到区间缩小到 1。
如何实现一个通用的排序算法?
查找算法 | 时间复杂度 | 空间复杂度 |
---|---|---|
有序链表 | O(n) | O(1) |
二分查找 | O(logn) | O(1) |
跳表 | O(logn) | O(n) |
哈希表 | O(1) | |
二叉查找树 | O(logn) | |
红黑树 | O(logn) | |
最大/最小堆 | O(logn) | O(1) |
图的广度优先搜索 | O(V+E) | O(v) |
图的深度优先搜索 |
V是顶点的个数,E是边的个数
几种常见的复杂度量级
O(1)
O(logn)
,线性阶 O(n)
,线性对数阶 O(nlogn)
O(n^2)
,立方阶 O(n^3)
,K 方阶 O(n^k)
O(2^n)
,阶乘阶 O(n!)
二分查找,针对的是一个有序的数据集,每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为 0。
例子:在 [8,11,19,23,27,33,45,55,67,98] 中查找数字19。
二分查找应用场景的局限性
之前讲的二分查找都是基于数组结构,如果数据存储在链表中,而非数组中,需要对链表稍加改造,就可以支持二分查找。这种改造后的数据结构就是跳表。
跳表基于有序链表(可以很好的支持范围查找),是一种各个方面都比较优秀的动态数据结构,可以支持快速的插入,删除,查找操作,实现起来也不复杂,甚至可以替代红黑树。
对于有序的单链表来说,查找一个元素只能从头到尾挨个查找,时间复杂度是O(n)。
为了使链表支持更快的查找,可以为链表建立“索引”,每两个节点向上一级抽取一个节点,这一级称为索引层。索引层使用down 指针指向原始链表。
当查找一个节点的时候,先从索引层查找,再到原始链表查找,这样可以提高速度。
如果再向上抽取一层,如下:
当数据量比较大的时候,跳表可以明显的提高单链表的查找速度。
哈希表是数组的一种扩展,依赖数组下标的随机访问,没有数组就没有哈希表。
哈希表用的就是数组支持按照下标随机访问的时候,时间复杂度是 O(1) 的特性。
哈希函数
哈希函数设计的基本要求:
对于第3个要求,实际上是不可能实现的。即便像业界著名的MD5、SHA、CRC等哈希算法,也无法完全避免这种散列冲突。另外数组的存储空间有限,也会大大增加哈希冲突的可能性。
哈希算法
将任意长度的二进制值串映射为固定长度的二进制值串,这个映射的规则就是哈希算法,而通过原始数据映射之后得到的二进值串就是哈希值。
哈希算法满足的四点要求:
MD5 算法就是一种哈希算法,哈希值是固定的128位二进制串,也就是32位字符串。其最多能表示 2^128 个数据。
// 当多于这个数的时候,md5 值就会发生冲突,已经是天文数字了
2^128 = 340282366920938463463374607431768211456
下面两个串的哈希值就是相同的:
装载因子
装载因子 = 填入表中的元素个数 / 散列表的长度
装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降
当装载因子过大时,就需要对哈希表进行动态扩容
当动态因子过小时,为避免空间浪费,也可以缩容
解决哈希冲突的办法
开放寻址法:如果发生冲突,就再找一个空闲位置,比如:
链表法:比较常用,每个桶都对应一个链表
Java 中 LinkedHashMap 就采用了链表法解决冲突,ThreadLocalMap 是通过线性探测的开放寻址法来解决冲突。
工业级哈希表(Java HashMap)分析
什么是树形结构
几个概念:
二叉树
二叉树的存储方法
二叉树的遍历
二叉树遍历的时间复杂度是 O(n)。
伪代码如下:
// 前序遍历
void preOrder(Node* root) {
if (root == null) return;
print root // 此处为伪代码,表示打印 root 节点
preOrder(root->left);
preOrder(root->right);
}
// 中序遍历
void inOrder(Node* root) {
if (root == null) return;
inOrder(root->left);
print root // 此处为伪代码,表示打印 root 节点
inOrder(root->right);
}
// 后续遍历
void postOrder(Node* root) {
if (root == null) return;
postOrder(root->left);
postOrder(root->right);
print root // 此处为伪代码,表示打印 root 节点
}
二叉查找树
二叉查找树最大的特点就是,支持动态数据集合的快速插入、删除、查找操作。对于任意一个节点:
二叉查找树的查找过程:
平衡二叉树
二叉查找树在极端情况下,时间复杂度会退化到O(n),所以出现了平衡二叉树。
红黑树
红黑树是一颗二叉搜索树,它在每个节点上增加了一个存储位来表示节点的颜色,可以是黑或红。
通过对任何一条从根到叶子的简单路径上各个节点的颜色进行约束,红黑树确保没有一条路径会比其他路径长出 2 倍,因而是近似平衡。
树中每个节点包含 5 个属性:color,key,left,right,p。
红黑树的定义:
红黑树的优点:
什么是堆 ?
用数组存储堆
堆可以用数组来实现。
注:文中图片引自《数据结构与算法》