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