时间复杂度$O(n^2)$级排序算法
冒泡排序
冒泡排序是入门级的算法。通常来说,冒泡排序有三种写法:
- 一边比较一边向后两两交换,将最大值/最小值冒泡到最后一位;
- 经过优化写法:使用一个变量记录当前轮次的比较是否发生过交换,如果没有发生过交换表示已经有序,不需要再继续排序;
进一步优化的写法:除了使用一个变量记录当前轮次是否发生过交换,再使用一个变量记录上次发生交换的位置,下一轮排序时到达上次交换的位置就停止比较。
冒泡排序的第一种写法
1 | public static void bubbleSort(int[] num){ |
总共的比较次数为$(n-1)+(n-2)+(n-3)+…+1$。
冒泡排序的第二种写法
1 | public static void bubbleSort(int[] num){ |
冒泡排序的第三种写法
1 | public static void bubbleSort(int[] num){ |
交换的技巧
一般来说,交换数组中两个数字的函数如下:
1 | private static void swap(int[] num, int i, int j){ |
如何在不引入第三种中间变量的情况下,完成两个数字的交换。
1 | num[j+1] = num[j] + num[j+1]; |
还有一种先减后加的写法:
1 | num[j+1] = num[j] - num[j+1]; |
但这两种方式都可能导致数字越界。
更好的方式是通过位运算完成数字交换:
1 | num[i] = num[i] ^ num[j]; |
时间复杂度和空间复杂度
冒泡排序的空间复杂度为:$O(1)$,时间复杂度为:$O(n^{2})$,第二种、第三种冒泡排序由经过优化,最好的情况下只需要$O(n)$的时间复杂度。
最好的情况为:在数组已经有序的情况下,由于没有发生交换,排序结束。
最坏情况:数组逆序,每次比较都会发生交换。
优化后的冒泡排序平均时间复杂度仍然是$O(n^{2})$,所以这些优化对算法的性能并没有质的提升。
把数组排成最小的数
输入一个非负整数数组,把数组里所有数字拼接起来排成一个数,打印能拼接出的所有数字中最小的一个。
1 | 输入: [10,2] |
1 | class Solution { |
移动零
给定一个数组 nums
,编写一个函数将所有 0
移动到数组的末尾,同时保持非零元素的相对顺序。
1 | 输入: [0,1,0,3,12] |
1 | class Solution { |
选择排序
选择排序的思想是:双重遍历数组,每经过一轮比较,找到最小元素的下标,将其交换至首位。
1 | public static void selectionSort(int[] nums){ |
选择排序,每一轮都找到当前的最小值,将最小值交换至本轮首位。
冒泡排序和选择排序有什么异同点?
相同点:
- 都是两层循环,时间复杂度为:$O(n^{2})$
- 都只使用有限个变量,空间复杂度为:$O(1)$
不同点:
- 冒泡排序在比较过程中就不断交换;而选择排序增加了一个变量保存最小值/最大值的下标,遍历完成后才交换,减少了交换次数。
- 冒泡排序是稳定的,选择排序是不稳定的
排序算法的稳定性
假定在待排序的记录序列中,存在多个具有相同的关键字记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且在r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则为不稳定的。
二元选择排序
选择排序算法也可以进行优化,既然每轮遍历找出了最小值,何不把最大值也找出来?这就是二元选择排序的思想。
1 | public static void selectSort2(int[] nums){ |
时间复杂度和空间复杂度
选择排序使用两层循环,时间复杂度为$O(n^{2})$;只使用有限个变量,空间复杂度为$O(1)$。二元选择排序虽然比选择排序要快,但无法改变时间复杂度,二元选择排序的时间复杂度$O(n^{2})$;只使用有限个变量,空间复杂度为$O(1)$。
数组中的第K个最大元素
给定整数数组 nums
和整数 k
,请返回数组中第k 个最大的元素。请注意,你需要找的是数组排序后的第
k个最大的元素,而不是第
k` 个不同的元素。
1 | 输入: [3,2,1,5,6,4] 和 k = 2 |
1 | class Solution { |
插入排序
插入排序的思想非常简单,生活中一个常见的场景:在打扑克牌的时候,我们一边抓牌一边给扑克牌排序,每次摸一张牌,就将它插入手中已有的牌中合适的位置,逐渐完成整个排序。
插入排序有两种写法:
- 交换法:在新数字插入过程中,不断与前面的数字交换,直到找到自己合适的位置
- 移动法:在新数字插入的过程中,与前面的数字不断比较,前面的数字不断向后挪出位置,当数字找到自己的位置后,插入一次即可。
交换法插入排序
1 | public static void insertSort(int[] nums){ |
插入排序的过程不会破坏原有数组中相同关键字的相对次序,所以插入排序是一种稳定的排序算法。
时间复杂度和空间复杂度
插入排序过程需要两层循环,时间复杂度为:$O(n^{2})$;空间复杂度为:$O(1)$。
对链表进行插入排序
对链表进行插入排序。
1 | 输入: 4->2->1->3 |
1 | /** |
时间复杂度$O(nlogn)$级排序算法
希尔排序
由美国辛辛那提大学的数学博士(Donald Shell)在《ACM通讯》上发表了希尔排序算法,成为首批时间复杂度降到$O(n^{2})$以下的算法之一。虽然原始的希尔排序最坏时间复杂度仍然是$O(n^{2})$,但经过优化的希尔排序可以达到$O(n^{1.3})$甚至$O(n^{7/6})$。
希尔排序本质是对插入排序的一种优化,它利用了插入排序的简单,又克服了插入排序每次只交换相邻两个元素的缺点。它的基本思想是:
- 将待排序的数组按照一定的间隔分为多个子数组,每组分别进行插入排序。这里按照间隔分组指的不是取连续的一段数组,而是每跳跃一定间隔取一个值组成一组;
- 逐渐缩小间隔进行下一轮排序;
- 最后一轮,取间隔为1,也就相当于直接使用插入排序。但这时经过前面的【宏观调控】,数组已经基本有序了,所以此时的插入排序只需进行少量交换便可完成。
1 | public static void Shell(int[] nums){ |
虽然插入排序是稳定的排序算法,但希尔排序不是稳定的。在增量较大时,排序过程可能会破坏原有数组中相同关键字的相对次序。
时间复杂度和空间复杂度
事实上,希尔排序的时间复杂度非常难以分析,它的平均复杂度界于$O(n)$到$O(n^{2})$之间,普遍认为它最好的时间复杂度为$O(n^{1.3})$。希尔排序的空间复杂度为:$O(1)$。
堆排序
快速排序
快速排序算法由C.A.R.Hoare在1960年提出。它的时间复杂度也是$O(nlogn)$,但它的时间复杂度为$O(nlogn)$级的几种排序算法中,大多数情况下效率更高,所以快速排序的应用非常广泛。
快速排序的基本思想是:
- 从数组中取出一个数,称之为基数(pivot)
- 遍历数组,将比基数大的数字放到它的右边,比基数小的数字放到它的左边。遍历完成后,数组被分成了左右两个区域
- 将左右两个区域视为两个数组,重复前两个步骤,直到排序完成。
事实上,快速排序的每一次遍历,都将基数摆到最终位置上。第一轮遍历排好1个基数,第二轮遍历排好2个基数,第三轮遍历排好4个基数,以此类推。总遍历次数为$logn$~$n$次,每轮遍历的时间复杂度为$O(n)$,所以很容易分析出来快速排序的时间复杂度为$O(nlogn)$~$O(n^{2})$,平均时间复杂度为$O(nlogn)$。
快速排序递归框架
1 | public static void quickSort(int[] nums){ |
退出递归的边界条件
很容易想到,当某个区域只剩下一个数字的时候,自然不需要排序了,此时推出递归函数。
1 | private static void quickSort(int[] nums. int lo, int hi){ |
分区算法实现
快速排序中最重要的便是分区算法,也就是partition函数。partition函数需要做的事情就是将nums从lo到hi分区,左边区域比基数小,右边区域比基数大,然后返回中间值的下标。那么首先我们要做的事情就是选择一个基数,基数我们一般称为pivot,意味“轴”。整个数组就像围绕这个轴进行旋转,小于轴的数字旋转到左边,大于轴的数字旋转到右边。(双轴快排就是一次选取两个基数,将数组分为三个区域进行旋转。)
基数的选择
基数的选择没有固定的标准,随意地选择区间任何一个数字做基数都可以,通常来讲有三种选择方式:
- 选择第一个元素作为基数
- 选择最后一个元素作为基数
- 选择区间内一个随机元素作为基数
选择的基数不同,算法的实现也不同。实际上第三种选择方式的平均时间复杂度是最优的。
最简单的分区算法
分区方式也有很多种,最简单的思路是:从left开始,遇到比基数大的数,记录其下标;再从right往前遍历,找到第一个比基数小的数,记录其下标;然后交换这两个数。继续遍历,直到left和right相遇。最后交换基数和中间值,并返回中间值的索引。
1 | private static int partition(int[] nums, int lo, int hi){ |
快速排序是一种不稳定的排序算法,在分区过程中,相同数字的相对顺序可能被修改。
时间复杂度和空间复杂度
快速排序的时间复杂度,平均情况下为:$O(nlogn)$,最坏的时间复杂度为$O(n^{2})$,空间复杂度与递归的层数有关,每层递归会产生一些临时变量,所以空间复杂度为:$O(logn)$~$O(n)$,平均空间复杂度为:$O(logn)$。
为什么说随机选择剩余数组中的一个元素作为基数的方案平均复杂度最优呢?先要知道什么情况下快速排序算法的时间复杂度最高,一共有两种情况:
- 数组为正序
- 数组为逆序
理想中的快速排序在第K轮遍历中,可以排好$2^{k-1}$个基数。但当在数组为正序或逆序时,我们将第一个数作为基数的话,每轮分区后,都有一个区域是空的,也就是说数组剩下的数组都被分到同一个区域!就导致了每一轮遍历只能排好一个基数。所以总的比较次数为$(n-1)+(n-2)+…+1$,此时快速排序的时间复杂度达到了$O(n^{2})$级。
解决的思路也很简单,只要每轮选择的基数不是剩余数组中最大或最小的值就可以了。具体方案有很多,比较常用的有三种。
快速排序的优化思想
第一种就是我们在前文中提到的,每轮选择基数时,从剩余的数组中随机选择一个数字作为基数。这样每轮都选到最大或最小值的概率就会变得很低。所以我们才说这种方式选择基数,其平均时间复杂度是最优的。
第二种解决方案是在排序之前,先用洗牌算法将数组的原有顺序打乱,以防止原数组正序或逆序。Java将洗牌算法封装到集合类中,即Collections.shuffle()函数。洗牌算法由Ronald A.Fisher和Frank Yates于1983年发明,思路是每次从未处理的数据中随机取出一个数字,然后把该数字放在数组中所有未处理数据的尾部。
第三种解决方案,既然数组重复排序的情况如此常见,那么我们可以先对数组做个判断,如果已经有序则直接返回,如果是逆序则直接倒序即可。
归并排序
约翰冯诺伊曼在1954年提出了归并排序。在讲到归并排序之前,首先思考一个问题:如何将两个有序的列表合并成一个有序的列表。
将两个有序的列表合并成一个有序的列表
最简单的思路是,将两个列表拼接成一个列表,然后使用排序算法。觉得太暴力,那换个思路。
开辟一个长度等同于两个数组长度之和的新数组,并使用两个指针来遍历原有的两个数组,不断将较小的数字添加到新数组中,并移动对应的指针即可。
根据这个思路我们可以写出两个有序列表的代码:
1 | private static int[] merge(int[] nums1, int[] nums2){ |
合并有序数组的问题解决了,但我们排序时用的都是无序数组,那么上哪里去找这两个有序的数组呢?
我们可以把数组不断地拆成两份,直到剩下一个数字时,这个数字组成的数组我们就可以认为它是有序的。
然后通过上述合并有序列表的思路,将1个数字组成的有序数组合并成一个包含2个数字的有序数组,再将2个数字组成的有序数组合并成包含4个数字的有序数组,直到整个数组排序完成,这就是归并排序(Merge Sort)的思想。
将数组拆分成有序数组
拆分过程使用了二分思想,这是一个递归的过程,归并排序使用的递归框架如下:
1 | public static void mergeSort(int[] nums){ |
时间复杂度和空间复杂度
归并排序的复杂度比较容易分析,拆分数组的过程中,会将数组拆分$logn$次,每层执行的比较次数都约等于n次,所以时间复杂度为:$O(nlogn)$。空间复杂度是:$O(n)$,主要占用空间的就是我们在排序前创建的长度为n的result数组。
分析归并可知,归并排序是一种稳定的排序算法。
总结起来,归并排序分成两步,一是拆分数组,二是合并数组,它是分治思想的典型应用。分治的思想是“分而治之”,分的时候体现了二分的思想,治是一个滚雪球的过程,将一个数字组成的有序数组合并成一个包含2个数字的有序数组,再将2个数字组成的有序数组合并成4个数字的有序数组。
由于性能较好,且排序稳定,归并排序应用非常广泛,Arrays.sort()源码中的TimSort就是归并排序的优化版。
合并排序的数组
给定两个排序后的数组 A 和 B,其中 A 的末端有足够的缓冲空间容纳 B。 编写一个方法,将 B 合并入 A 并排序。
初始化 A 和 B 的元素数量分别为 m 和 n。
1 | 输入: |
1 | class Solution { |
数组中的逆序对
在数组中的两个数字,如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。输入一个数组,求出这个数组中的逆序对的总数。
1 | 输入: [7,5,6,4] |
1 | class Solution { |
时间复杂度$O(n)$级排序算法
计数排序
1959年7月,希尔排序通过交换相邻元素,打破了$O(n^{2})$的魔咒,使得排序算法复杂度降到了$O(nlogn)$级,此后的快速排序,堆排序都是基于这样的思想,所以它们的时间复杂度都是$O(nlogn)$。
那么,排序算法最好的时间复杂度就是$O(nlogn)$吗?是否有比$O(nlogn)$级还要快的排序算法呢?能否在$O(n)$的时间复杂度下完成排序呢?
事实上,$O(n)$级的排序算法存在已久,但它们只能适用于特定的场景。
计数排序就是一种时间复杂度为$O(n)$的排序算法,该算法于1954年由Harold H.Seward提出。在对一定范围内的整数排序时,它的复杂度为$O(n+k)$(其中k是整数的范围大小。)
伪计数排序
若我们需要对一列数组排序,这个数组中每个元素都是[1,9]区间内的整数。我们可以构建一个长度为9的数组用于计数,计数数组的下标分别对应区间内的9个整数。然后遍历待排序的数组,将区间内每个整数出现的次数统计到计数数组中对应下标的位置。最后遍历计数数组,将每个元素输出,输出的次数就是对应位置记录的次数。
1 | public static void countSort(int[] nums){ |
算法非常简单,但这里的排序算法并不是真正的计数排序。因为现在的实现有一个非常大的弊端:排序完成后,nums中记录的元素已经不是最开始的那个元素了,它们只是值相等,但却不是同一个对象。
在纯数字排序中,这个弊端或许看起来无伤大雅,但在实际的工作中,这样的排序算法几乎无法使用。因为被排序的对象往往都会携带其他的属性,但这份算法将被排序对象的其他属性都丢失了。
伪计数排序2.0
对于这个问题,我们很容易想到另外一种解决方案:在统计元素出现的次数时,同时把真实的元素保存到列表中,输出时,从列表中取真实的元素。
1 | public static void countSort(int[] nums){ |
通过队列来保存真实的元素,计数完成后,将队列中真实的元素赋值到nums列表中,这就解决了信息丢失的问题,并且使用队列还可以保证排序算法的稳定性。
但是这也不是真正的计数排序,计数排序中使用了一种更巧妙的方法解决这个问题。
真正的计数排序
计数排序并不是把计数数组的下标直接作为结果输出,而是通过计数的结果,计算出每个元素在排序完成后的位置,然后将元素赋值到对应位置。
1 | public static void countSort(int[] nums){ |
首先我们将每位元素出现的次数记录到temp数组中。然后将temp[i]跟新为数字i在最终排序结果中的起始下标位置。这个位置等于前面比自己小的数字的总数。
接下来从头访问nums数组,根据temp中计算出下标位置,将nums的每个元素直接放到最终位置上,然后跟新temp中的下标位置。
时间复杂度和空间复杂度
从计数排序的实现代码中,可以看到,每次遍历都是进行n次或者k次,所以计数排序的时间复杂度为$O(n+k)$,k表示数据的范围大小。
用到空间主要是长度为k的计数数组的和长度为n的结果数组,所以空间复杂度也是$O(n+k)$。
需要注意的是,一般分析时间复杂度和空间复杂度时,常数项都是忽略不计的。但计数排序的常数项可能非常大,以至于我们无法忽略。
计数排序存在一个非常大的隐患,例如我们想要对这个数组排序:
1 | int[] nums = new int[]{1, Integer.MAX_VALUES}; |
尽管它只包含两个元素,但是数据范围是$[1,2^{31}]$,我们知道java中int占用4个字节,一个长度为$2^{31}$次方的int数组大约占用8G的空间。如果使用计数排序,仅仅排序这两个元素,声明计数数组就会占用超大的内存,甚至导致OOM异常。
所以计数排序只适用于数据范围不大的场景。例如对考试成绩排序就非常适合计数排序,如果需要排序的数字中存在一位小数,可以将所有数字乘以10,再去计算最终的下标位置。
数组的相对排序
给你两个数组,arr1 和 arr2,arr2 中的元素各不相同,arr2 中的每个元素都出现在 arr1 中。
对 arr1 中的元素进行排序,使 arr1 中项的相对顺序和 arr2 中的相对顺序相同。未在 arr2 中出现过的元素需要按照升序放在 arr1 的末尾。
1 | 输入:arr1 = [2,3,1,3,2,4,6,7,9,2,19], arr2 = [2,1,4,3,9,6] |
1 | class Solution { |