排序算法
O(N^2)的排序算法:冒泡、选择、插入
冒泡排序
冒泡的核心是两两比较,大数下沉,小数上浮,比较的轮数是数组的长度 N,每一轮比较的次数为 N - 当前轮的索引:
- 外层循环控制轮数 round: [1,N]
- 内层循环控制次数 i: [0,N - round)
在每一轮当中,内循环中两两比较相邻的两个数,大数下沉(交换),如果某一轮没有发生交换操作,则可以提前终止。
代码如下:
冒泡排序的特点:
- 时间复杂度是 O(N^2)
- 空间复杂度是 O(1),是原地排序算法
- 冒泡排序是稳定的排序算法
这里的稳定是因为相等的元素不会做交换操作。
选择排序
选择排序的做法是首先从剩余元素中选择一个最小的数与未排序的第一个元素进行交换:
接着再从剩余元素中选择一个最小的元素进行交换:
- 外层循环 i: [0,N)
- 内层循环 j: [i+1, N)
设当前 i 位置值为最小值,内层循环找到最小的数,记住下标,跟当前 i 位置进行交换。
代码如下:
选择排序的特点:
- 时间复杂度是 O(N^2)
- 空间复杂度是 O(1),是原地排序算法
- 选择排序是不稳定的排序算法
插入排序
每次从数组的无序区间中取一个元素插入到有序的区间中:
- 外层循环 i: [1,N)
- 内层循环 j: [i,0)
内层循环中将当前元素跟前一个元素比较,如果比前一个小就交换,否则结束内层循环。
代码如下:
这个代码还可以继续优化一下,我们可以先记住待插入的元素,然后让 j
从 i
位置往前寻找到合适的位置再插入。在寻找的过程中直接将前面的元素往后面位置覆盖,因为我们记住了一个元素,相当于留出了一个坑位,因此可以前面的元素依次往后挪一个位置,直到找到可插入位置为止。
外层先记住当前的 i
位置的值 tmp
,内层每次看 j - 1
位置的数,如果比tmp
大的就直接将 j - 1
位置覆盖到 j
位置,如果比tmp
小,就结束内层循环,将tmp
覆盖到 j
位置。
优化后的代码如下:
这个代码省略了交换操作,做到了原地交换。
插入排序的特点:
- 时间复杂度是 O(N^2)
- 空间复杂度是 O(1),是原地排序算法
- 插入排序是稳定的排序算法
O(N^2) 的排序算法性能比较:插入排序 > 选择排序 > 冒泡排序,插入排序性能最好,冒泡排序性能最差。
但是在LeetCode刷题的过程中,很少会用到O(N^2) 的排序算法,因为效率比较低,作为了解即可。
希尔排序
希尔排序的核心思想是先使部分有序,最后让整体有序。
这里递增序列也叫做步长,步长的计算公式有很多种,参见下表:
其中 k=1,2,3,4,5,6…N是数组长度。下面是选择步长公式为 (3^k - 1) / 2 的参考代码:
希尔排序的特点:
- 空间复杂度是 O(1),是原地排序算法
- 希尔排序是不稳定的排序算法
希尔排序的时间复杂度跟所选择的步长计算公式有关:
选择步长公式为 (3^k - 1) / 2 的时间复杂度是 O(n^3/2),而选择其他步长公式最差可以是 O(n^2)
在大规模乱序数组情况下,希尔排序优于插入排序。
O(NlogN)的排序算法:快排、 归并
快速排序
快排核心思想:选择数组中任一个数字作为分区点,小的放左边,大的放右边
快排按照分区点的选择方式不同,我整理的有两种版本的代码:
第一种:以最右边的元素作为分区点的分区逻辑(快慢指针)
注意,这里的partition
方法中一定要使用Random.nextInt
函数随机生成分区点,这点对快排的效率至关重要,如果不是随机的,快排的效率会降低。在生成随机分区点以后,需要将该位置交换到数组的最右边,主要是为了接下来的快慢指针扫描过程方便,并且在扫描处理完毕后,记得需要将分区点交换回slow
位置并返回slow
。
第二种:以最左边元素为分区点的分区逻辑(挖坑法)
这种方法同样是需要先使用Random.nextInt
函数随机生成分区点,然后交换到最左边,并且最后记得将分区点元素放入nums[L]
中,返回的是 L
。
这两种划分方式并不是真的直接选择最右边或最左边的元素作为分区点,而是要先使用随机函数随机生成分区点,只是它们处理分区逻辑的方式不同。
快排在刷题过程中会使用到的频率较高,所以以上代码模版务必牢记,如果你更喜欢快慢指针可以选择第一种,否则可选择第二种。但是第一种更容易扩展三路快排的代码模板。
快速排序的特点:
- 时间复杂度是 O(n*logn)
- 空间复杂度是 O(logn),是原地排序算法
- 快速排序是不稳定的排序算法
注意:并不是所有原地排序算法的空间复杂度一定就是O(1),而空间复杂度是O(1)的排序算法则一定是原地排序算法。
归并排序
代码如下:
这个代码,关键是理解递归调用中最后合并两个有序数组的部分,需要用额外数组暂存,然后在比较的时候是到暂存的temp
数组中比较,谁小就取谁,直接覆盖到原数组对应位置,如果有一个到头了则取另一个剩下的全部。
归并排序的特点:
- 时间复杂度是 O(n*logn)
- 空间复杂度是 O(n),不是原地排序算法
- 归并排序是稳定的排序算法
理解分治算法思想
分治算法(divide and conquer)的核心思想就是:分而治之
分治算法思想的描述:将一个规模较大的原问题划分成若干个规模较小的子问题,这些子问题的结构与原问题是相似的,子问题的求解方式和原问题的求解方式也是一样的,解决了这些子问题,然后再合并它们的结果,这样原问题就得到了解决。
从上面的分治算法的描述来看,分治算法和递归是有点类似的,实际上,分治算法是一种处理问题的思想,而递归是一种编程技巧。分治一般比较适合使用递归来实现。归并排序算法其实就是分治算法,也是使用递归来实现的。
在使用递归实现分治算法中,每一层递归其实包含三个操作:
- 拆解:将原问题拆解成若干个问题
- 解决:递归地求解各个子问题,直到最小子问题解决
- 合并:将子问题的结果合并起来,这样,原问题就解决了
分治算法能解决的问题,一般需要满足下面的几个条件:
- 原问题与拆分的子问题具有相同的结构
- 原问题拆解的子问题可以独立求解,子问题之间没有相关性
- 存在最优最小子问题的解
- 子问题解的结果可以合并,合并后的结构就是原问题的结果
前面的归并排序算法、快速排序算法都是分治算法思想的体现。这两种排序算法在LeetCode刷题过程中也会经常用到,因此需要重点掌握。
O(N)的排序算法:桶排序、计数排序、基数排序
桶排序
先根据数组最大值划分成若干桶,然后桶内元素进行排序,最后将桶的元素进行合并。
代码如下:
这里桶内排序算法最好选择 O(nlogn) 的算法,如快排。
桶排序的时间复杂度分析:
也就是说,n 个元素分配到 m 个桶中,只有当 m 与 n 比较接近的时候才是线性时间复杂度O(N)。
桶排序的特点:
- 空间复杂度是O(m),m 表示桶的个数,不是原地排序算法
- 桶排序是不是稳定排序算法取决于对桶内元素排序的算法
- 桶排序对数据要求非常苛刻:
- 桶与桶之间必须是有序的
- 待排序数据最好均匀的分配到每个桶中
计数排序
计数排序本质上是桶排序,桶的个数是数组中的最大值。
计数排序的主要步骤:
- 计数
- 计数累加
- 根据计数数组计算每个元素的输出位置
代码如下:
计数排序的特点:
- 时间复杂度是 O(n + k),k 表示数组元素的最大范围
- 空间复杂度是 O(n),不是原地排序算法
- 计数排序是稳定的排序算法(但只有计算输出位置时倒序遍历才是稳定的,否则是不稳定的)
计数排序只能用在数据范围不大的场景中,如果数据范围k
比排序的数据n
大很多,就不适合用计数排序了。
基数排序
基数排序的本质就是对数据的每一位进行计数排序。
使用基数排序有几个要求:
- 必须是稳定的排序算法
- 数据范围不大,如0~9
- 必须是线性时间复杂度O(N)的算法
代码如下:
基数排序的特点:
- 时间复杂度是 O(Cn),C 表示最大值的位数,可以省略
- 空间复杂度是 O(n),不是原地排序算法
- 基数排序是稳定的排序算法(但只有计算输出位置时倒序遍历才是稳定的,否则是不稳定的)
基数排序对要排序的数据是有要求的:
-
需要可以分割出来独立的“位”来比较,而且每一位的数据范围不能太大
-
要可以用线性排序算法来排序,否则基数排序的时间复杂度就无法做到O(n)了
不同排序算法的比较和选择
排序算法总结
- 1)不基于比较的排序,对样本数据有严格要求,不易改写
- 2)基于比较的排序,只要规定好两个样本怎么比大小就可以直接复用
- 3)基于比较的排序,时间复杂度的极限是O(N*logN)
- 4)时间复杂度O(N*logN)、额外空间复杂度低于O(N)、且稳定的基于比较的排序是不存在的。
- 5)为了绝对的速度选快排、为了省空间选堆排、为了稳定性选归并
O(N^2)排序算法的选择:
- 插入排序性能最好、其次是选择排序、冒泡排序性能最差
- 选择排序不是稳定的排序算法
- 插入排序是最好的选择
- 对于大规模的乱序数组的排序,可以使用希尔排序
O(NlogN)排序算法的选择:
- 快排时间复杂度最坏情况下是O(N^2),合理选择分区点
- 归并排序在任何情况下的时间复杂度都是O(NlogN)
- 归并排序的空间复杂度是O(N),快排空间复杂度是O(logN)
- 但是快排不是稳定的排序算法,归并是稳定的排序算法
Java内置排序算法
Arrays.sort(int[] data)
该方法的内部实现细节区别:
- 对于小数据量(小于47)的话使用插入排序
- 然后小数据量(大于47而小于286)的话使用递归实现的快速排序
- 对于大数据量使用迭代(自底朝上)实现的归并排序
Java内置的引用类型的排序方法:
- Arrays.sort(Object[] data)
- Arrays.sort(T[] data, Comparator< T > c),内部实现细节:对于小规模数据使用插入排序,大规模数据使用归并排序
Arrays.sort(Object[] data) 这种要求对象实现Comparable
接口
Collections.sort() 底层是基于 Arrays.sort() 实现的,同样有两个方法。
Comparable的使用:
或者直接下面这样写:
使用:
Comparator的使用:
在刷题当中实际使用更多的是匿名内部类和lambda箭头的写法:
前面提到的所有基础的排序算法,如快排,如果想比较对象的话,可以实现上面的接口,将比较大小的地方换成对应接口的方法即可。
查找算法
二分查找
上面代码中有两点需要注意:
while
循环的退出条件是L <= R
,当 left == right 只有一个元素的时候仍然需要执行循环体, 因此退出条件是 left <= right- 当数组长度为偶数的时候,默认
mid = left + (right - left) / 2
计算得到的默认是中间靠左边的位置,可以通过mid = left + (right - left + 1) / 2
得到中间靠右的位置。
二分查找的时间复杂度是O(logn)。
使用排除区间法实现二分查找
思路是在循环体内排除一定不会出现目标的区间,left==right 时跳出循环体,此时只剩下一个元素,可能是目标。
哈希查找
刷题时需要用到的时间复杂度 O(1) 的两种哈希数据结构:
还有一种是使用数组代替哈希结构,如建立长度与a-z
相等的长度为26
的int
数组来统计次数。
Set 和 Map
动态数组实现的Set比链表实现的Set的contains操作性能好的原因:
-
数组是一块连续的内存空间,在 cpu 读取数组中的一个元素的时候,会将这个元素旁边的多个元素一起加载进 cpu 的高速缓存,这样下次读取的话,就直接从高速缓存中读取。
-
链表的数据是分散在内存中的,cpu 每次读取元素的时候都需要从主存中读取,所以数组的顺序遍历会比链表的顺序遍历要快。
最简单的哈希方法就是使用 hashCode % 数组的长度。
哈希冲突解决 - 开放寻址法
从冲突的位置开始往后找到第一个空的位置插入
哈希冲突解决 - 链表法
在冲突的位置生成一个链表存储冲突的元素,冲突元素插入到链表的表尾。
装载因子
当数组中空余位置不多时,冲突概率会大大增加,引入装载因子增加剩余空间,可以减少冲突概率。当空间大小超过装载因子x数组长度时,就进行扩容而不是等于数组长度时才扩容。
Java内置的Set和Map
Java有序表的实现:TreeMap 和 TreeSet
TreeMap implements SortedMap
TreeSet implements SotredSet
TreeMap 的使用:
TreeMap的方法:
- TreeMap.firstKey():返回最小的key
- TreeMap.lastKey():返回最大的key
- TreeMap.floorKey(X):返回小于等于X的最大的key
- TreeMap.ceilingKey(X):返回大于等于X的最小的key
- TreeMap.lowerKey(X):返回小于X的最大的key
- TreeMap.higherKey(X):返回大于X的最小的key
TreeSet的方法:
- TreeSet.first() 返回最小的值
- TreeSet.last() 返回最大的值
- TreeSet.floor(X):返回小于等于X的最大元素
- TreeSet.ceiling(X):返回大于等于X的最小元素
- TreeSet.lower(X):返回小于X的最大元素
- TreeSet.higher(X):返回大于X的最小元素
floor就是从下面找小于等于的上界,ceiling就是从上面找大于等于的下界,lower higher跟这俩差不多,没有等于。first就是找最小的,last就是找最大的。
栈和队列
Java内置的栈和队列数据结构
Deque
接口继承了Queue
接口,而Deque
有两个实现类分别是 ArrayDeque
和LinkedList
。 这两个双端队列的实现类在刷题中会经常用到。
由于 add/remove
方法有可能抛出异常,所以最好使用 offer/poll
这一对方法来执行入队出队操作,如果是查看队首/栈顶元素一般是使用peek
方法。对于双端队列而言,只不过多了带 xxxFirst
和 xxxLast
的方法以及 push/pop
方法。
使用双端队列作为栈使用也是刷题时高频使用的方法,因为双端队列最大的好处就是既可以当作队列使用,又可以当作栈使用。
另外有一点注意,双端队列实现中:ArrayDeque.addFirst()/addLast()/push() 等方法不能接受null元素,但是LinkedList的相关方法可以。
单调栈
单调栈就是指栈中的元素必须是按照升序排列的栈,或者是降序排列的栈。
单调递增栈
栈中一直维护比栈顶元素大的元素的索引,即比栈顶元素大就入栈索引,直到遇到比栈顶小的元素时,就出栈栈顶元素进行处理;并且此时是while循环比较栈顶元素,如果一直比栈顶小就持续出栈栈顶的元素进行处理。
注意,栈中存的是元素值对应的索引下标值。
单调递减栈
栈中一直维护比栈顶元素小的元素的索引,即比栈顶元素小就入栈索引,直到遇到比栈顶大的元素时,就出栈栈顶元素进行处理;并且此时是while循环比较栈顶元素,如果一直比栈顶大就持续出栈栈顶的元素进行处理。
二叉堆 优先级队列(PriorityQueue)
二叉堆是一棵完全二叉树,分为两大类:大顶堆和小顶堆。大顶堆中每个节点的值都满足小于父节点的值,根节点是整棵树中的最大值。而小顶堆则恰恰相反。
可以使用动态数组来存储大顶堆对应的完全二叉树。
大顶堆—添加元素 Shift Up
- 新添加的元素添加到最后一个元素,然后不断跟父结点比较,父节点比它大的话就交换,不断上浮
大顶堆—删除堆顶元素 Shift Down
- 先将最后一个元素直接替换掉堆顶的元素值,删除最后一个元素,然后不断将堆顶元素与左右子节点比较,与较大的子节点交换,如果子节点比它小就不交换,这样不断下沉
堆化
- 将非叶子结点进行 Shift Down 操作
堆化时间复杂度为 O(n),二叉堆适合插入和查询都比较多的场景。
Java中内置的二插堆结构
Java 中的 PriorityQueue 优先级队列默认使用的是 小顶堆 的实现方式。我们可以通过自定义Comparator接口可以修改为 大顶堆 方式。
PriorityQueue 的使用:
二叉树
二叉树的存储
左子节点:data[2 * i + 1]
右子节点:data[2 * i + 2]
通过存储后的子节点可以找到父节点:(i - 1) / 2
如果将根节点存储在数组的第二个位置,可以更方便的计算子节点的父节点:i / 2
但是这样会浪费一个存储空间(第一个位置需要空着),并且如果是非完全二叉树的存储会浪费更多空间:
只有完全二叉树(或满二叉树)采用数组的存储方式才比较省内存(相对比于链表存储)
二叉树的遍历
DFS和BFS的合适解决方案:
-
DFS适合用栈,因为要记住之前的节点,而后去访问它。
-
BFS适合用队列,因为按层访问每一层是按先后顺序访问的。
二叉查找树
查找:与根节点比较,比根节点小就在左子树中,比根节点大就在右子树中
插入:与根节点比较,比根节点小就在左子树中插入,比根节点大就在右子树中插入
二叉查找树的时间复杂度:平均 O(logn) 最差 O(n)
平衡二叉树
AVL树
AVL树是一种平衡二叉树,AVL是两个人的名字。
高度差也称为平衡因子,如何计算平衡因子:
判断一棵树是否是二叉查找树:
- 判断中序遍历的结果(数组)是否是升序的
判断一棵树是否是平衡二叉树:
- 高度差小于1,递归左子树是平衡二叉树且右子树也是平衡二叉树
2-3查找树
红黑树
什么是红黑树
- 每个节点或者是红色,或者是黑色的
- 根节点是黑色的
- 每个叶子节点(最后的空节点)是黑色的
- 如果一个节点是红色的,那么他的孩子节点都是黑色的
- 从任意一个节点到叶子节点,经过的黑色节点都一样
红黑树类比2-3树:
红黑树主要是为了保持黑平衡,模拟2-3树
完全随机数据的情况下:插入操作 二叉查找树性能最好
有序的数据情况下:插入操作 红黑树比AVL树性能优,二叉查找树最差(退化成链表)
二叉查找树性能总结:
-
对于完全随机的数据来说,普通的二叉查找树的性能很好
-
普通的二叉查找树的缺点:在极端的情况下会退化成链表(或者高度不平衡)
-
对于查询较多的情况,AVL树的性能很好
-
红黑树牺牲了平衡性,它的高度为2logn,没有AVL平衡,但是红黑树的综合统计性能更优(综合增删改查所有的操作)
AVL树的查询性能较好,但是红黑树综合性能最好。