我将我仓库里的排序算法给大家汇总整理了一下,写的非常非常细,还对每个算法制作了动画,一定能够对大家有所帮助,欢迎大家阅读。另外我也对 leetcode 上面可以用排序算法秒杀的算法题进行了总结,会在后面的文章中进行发布,欢迎大家阅读。
另外大家如果需要其他动画题解的话,可以看下这个仓库,大概 100 来张动画了
GitHub - chefyuan/algorithm-base: 一位酷爱做饭的程序员,立志用动画将算法说的通俗易懂。我的面试网站 www.chengxuchu.com
@
目录
- 实际应用及排序算法详解
- 冒泡排序(Bubble Sort)
- 最简单的排序实现
- 改进
- 简单选择排序
- 希尔排序 (Shell's Sort)
- 快速排序
- 挖坑填数
- 交换位置
- 快速排序的迭代写法
- 快速排序优化
- 三数取中法
- 三数取中法
- 和插入排序搭配使用
- 三数取中+插入排序
- 三数取中+三向切分+插入排序
- 堆排序
- 利用上浮操作建堆
- 下沉建堆
- 归并排序
- 递归实现
- 迭代实现
- 计数排序
实际应用及排序算法详解
写在前面
袁记菜馆内
袁厨:小二,最近快要过年了,咱们店也要给大家发点年终奖啦,你去根据咱们的红黑豆小本本,看一下大家都该发多少的年终奖,然后根据金额从小到大排好,按顺序一个一个发钱,大家回去过个好年,你也老大不小了,回去取个媳妇。
小二:好滴掌柜的,我现在马上就去。
我用过的一些还不错的算法资料,大家也可以看一哈,个人认为很不错,一定会对你有所帮助。
下载地址
上面说到的按照金额从大到小排好就是我们今天要讲的内容 --- 排序。
排序是我们生活中经常会面对的问题,体育课的时候,老师会让我们从矮到高排列,考研录取时,成绩会按总分从高到底进行排序(考研的各位读者,你们必能收到心仪学校给你们寄来的大信封),我们网购时,有时会按销量从高到低,价格从低到高,将最符合咱们预期的商品列在前面。
概念:将杂乱无章的数据元素,通过一定的方法(排序算法)按关键字(k)顺序排列的过程叫做排序。例如我们上面的销量和价格就是关键字
排序算法的稳定性
什么是排序算法的稳定性呢?
因为我们待排序的记录序列中可能存在两个或两个以上的关键字相等的记录,排序结果可能会存在不唯一的情况,所以我们排序之后,如果相等元素之间原有的先后顺序不变。则称所用的排序方法是稳定的,反之则称之为不稳定的。见下图
例如上图,我们的数组中有两个相同的元素 4, 我们分别用不同的排序算法对其排序,算法一排序之后,两个相同元素的相对位置没有发生改变,我们则称之为稳定的排序算法,算法二排序之后相对位置发生改变,则为不稳定的排序算法。
那排序算法的稳定性又有什么用呢?
在我们做题中大多只是将数组进行排序,只需考虑时间复杂度空间复杂度等指标,排序算法是否稳定,一般不进行考虑。但是在真正软件开发中排序算法的稳定性是一个特别重要的衡量指标。继续说我们刚才的例子。我们想要实现年终奖从少到多的排序,然后相同年终奖区间内的红豆数也按照从少到多进行排序。
排序算法的稳定性在这里就显得至关重要。这是为什么呢?见下图
第一次排序之后,所有的职工按照红豆数从少到多有序。
第二次排序中,我们使用稳定的排序算法,所以经过第二次排序之后,年终奖相同的职工,仍然保持着红豆的有序(想对位置不变),红豆仍是从小到大排序。我们使用稳定的排序算法,只需要两次排序即可。
稳定排序可以让第一个关键字排序的结果服务于第二个关键字排序中数值相等的那些数。
上述情况如果我们利用不稳定的排序算法,实现这一效果是十分复杂的。
比较类和非比较类
我们根据元素是否依靠与其他元素的比较来决定元素间的相对次序。以此来区分比较类排序算法和非比较类排序算法。
内排序和外排序
内排序是在排序的整个过程中,待排序的所有记录全部被放置在内存中。外排序是由于排序的记录个数太多,不能同时放置在内存中,整个排序过程需要在内外存之间多次交换数据才能进行,常见的内部排序算法有:插入排序、希尔排序、选择排序、冒泡排序、归并排序、快速排序、堆排序、基数排序等。
对我们内排序来说,我们主要受三个方面影响,时间性能,辅助空间,算法的复杂性
时间性能
在我们的排序算法执行过程中,主要执行两种操作比较和交换,比较是排序算法最起码的操作,移动指记录从一个位置移动到另一个位置。所以我们一个高效的排序算法,应该尽可能少的比较和移动。
辅助空间
执行算法所需要的辅助空间的多少,也是来衡量排序算法性能的一个重要指标
算法的复杂度
这里的算法复杂度不是指算法的时间复杂度,而是指算法本身的复杂度,过于复杂的算法也会影响排序的性能。
下面我们一起复习两种简单排序算法,冒泡排序和简单选择排序,看看有没有之前忽略的东西。
冒泡排序(Bubble Sort)
估计我们在各个算法书上介绍排序时,第一个估计都是冒泡排序。主要是这个排序算法思路最简单,也最容易理解,(也可能是它的名字好听,哈哈),学过的老哥们也一起来复习一下吧,我们一起深挖一下冒泡排序。
冒泡排序的基本思想是,两两比较相邻记录的关键字,如果是反序则交换,直到没有反序为止。冒泡一次冒泡会让至少一个元素移动到它应该在的位置,那么如果数组有 n 个元素,重复 n 次后则能完成排序。根据定义可知那么冒泡排序显然是一种比较类排序。
最简单的排序实现
我们来看一下这段代码
class Solution {public int[] sortArray(int[] nums) {int len = nums.length;for (int i = 0; i < len; ++i) {for (int j = i+1; j < len; ++j) {if (nums[i] > nums[j]) {swap(nums,i,j);}}}return nums;}public void swap(int[] nums,int i,int j) {int temp = nums[i];nums[i] = nums[j];nums[j] = temp;}
}
我们来思考一下上面的代码,每次让关键字 nums[i] 和 nums[j] 进行比较如果 nums[i] > nums[j] 时则进行交换,这样 nums[0] 在经过一次循环后一定为最小值。那么这段代码是冒泡排序吗?
显然不是,我们冒泡排序的思想是两两比较相邻记录的关键字,注意里面有相邻记录,所以这段代码不是我们的冒泡排序,下面我们用动图来模拟一下冒泡排序的执行过程,看完之后一定可以写出正宗的冒泡排序。
题目代码
class Solution {public int[] sortArray(int[] nums) {int len = nums.length;for (int i = 0; i < len; ++i) {for (int j = 0; j < len - i - 1; ++j) {if (nums[j] > nums[j+1]) {swap(nums,j,j+1);}}}return nums; }public void swap(int[] nums,int i,int j) {int temp = nums[i];nums[i] = nums[j];nums[j] = temp;}
}
改进
上图中的代码则为正宗的冒泡排序代码,但是我们是不是发现了这个问题
我们此时数组已经完全有序了,可以直接返回,但是动图中并没有返回,而是继续执行,那我们有什么办法让其完全有序时,直接返回,不继续执行吗?
我们设想一下,我们是通过 nums[j] 和 nums[j+1] 进行比较,如果大于则进行交换,那我们设想一下,如果一个完全有序的数组,我们进行冒泡排序,每次比较发现都不用进行交换。
那么如果没有交换则说明当前完全有序。那我们可不可以通过一个标志位来进行判断是否发生了交换呢?当然是可以的
冒泡排序改进
class Solution {public int[] sortArray(int[] nums) {int len = nums.length;//标志位boolean flag = true;//注意看 for 循环条件for (int i = 0; i < len && flag; ++i) {//如果没发生交换,则依旧为false,下次就会跳出循环flag = false;for (int j = 0; j < len - i - 1; ++j) {if (nums[j] > nums[j+1]) {swap(nums,j,j+1);//发生交换,则变为true,下次继续判断flag = true;}} }return nums;}public void swap(int[] nums,int i,int j) {int temp = nums[i];nums[i] = nums[j];nums[j] = temp;}
}
这样我们就避免掉了已经有序的情况下无意义的循环判断。
冒泡排序时间复杂度分析
最好情况,就是要排序的表完全有序的情况下,根据改进后的代码,我们只需要一次遍历即可,只需 n -1 次比较,时间复杂度为 O(n)。最坏情况时,即待排序表逆序的情况,则需要比较(n-1) + (n-2) +.... + 2 + 1= n(n-1)/2 ,并等量级的交换,则时间复杂度为O(n^2)。
平均情况下,需要 n(n-1)/4 次交换操作,比较操作大于等于交换操作,而复杂度的上限是 O(n^2),所以平均情况下的时间复杂度就是 O(n^2)。
冒泡排序空间复杂度分析
因为冒泡排序只是相邻元素之间的交换操作,只用到了常量级的额外空间,所以空间复杂度为 O(1)
冒泡排序稳定性分析
那么冒泡排序是稳定的吗?当然是稳定的,我们代码中,当 nums[j] > nums[j + 1] 时,才会进行交换,相等时不会交换,相等元素的相对位置没有改变,所以冒泡排序是稳定的。
算法名称 | 最好时间复杂度 | 最坏时间复杂度 | 平均时间复杂度 | 空间复杂度 | 是否稳定 |
---|---|---|---|---|---|
冒泡排序 | O(n) | O(n^2) | O(n^2) | O(1) | 稳定 |
简单选择排序
我们的冒泡排序不断进行交换,通过交换完成最终的排序,我们的简单选择排序的思想也很容易理解,主要思路就是我们每一趟在 n-i+1 个记录中选取关键字最小的记录作为有序序列的第 i 个记录。
例如上图,绿色代表已经排序的元素,红色代表未排序的元素。我们当前指针指向 4 ,则我们遍历红色元素,从中找到最小值,然后与 4 交换。我们发现选择排序执行完一次循环也至少可以将 1 个元素归位。
下面我们来看一下代码的执行过程,看过之后肯定能写出代码的。
注:我们为了更容易理解,min 值保存的是值,而不是索引,实际代码中保存的是索引
简单选择排序代码
class Solution {public int[] sortArray(int[] nums) {int len = nums.length;int min = 0;for (int i = 0; i < len; ++i) {min = i;//遍历到最小值for (int j = i + 1; j < len; ++j) { if (nums[min] > nums[j]) min = j; }if (min != i) swap(nums,i,min); }return nums;}public void swap (int[] nums, int i, int j) {int temp = nums[i];nums[i] = nums[j];nums[j] = temp;}
}
简单选择排序时间复杂度分析
从简单选择排序的过程来看,他最大的特点就是交换移动数据次数相当少,这样也就节省了排序时间,简单选择和冒泡排序不一样,我们发现无论最好情况和最坏情况,元素间的比较次数是一样的,第 i 次排序,需要 n - i 次比较,n 代表数组长度,则一共需要比较(n-1) + (n-2) +.... + 2 + 1= n*(n-1)/2 次,对于交换而言,最好情况交换 0 次,最坏情况(逆序时)交换 n - 1次。那么简单选择排序时间复杂度也为 O(n^2) 但是其交换次数远小于冒泡排序,所以其效率是好于冒泡排序的。
简单选择排序空间复杂度分析
由我们动图可知,我们的简单选择排序只用到了常量级的额外空间,所以空间复杂度为 O(1)。
简单选择排序稳定性分析
我们思考一下,我们的简单选择排序是稳定的吗?显然不是稳定的,因为我们需要在指针后面找到最小的值,与指针指向的值交换,见下图。
此时我们需要从后面元素中找到最小的元素与指针指向元素交换,也就是元素 2 。但是我们交换后发现,两个相等元素 3 的相对位置发生了改变,所以简单选择排序是不稳定的排序算法。
算法名称 | 最好时间复杂度 | 最坏时间复杂度 | 平均时间复杂度 | 空间复杂度 | 是否稳定 |
---|---|---|---|---|---|
简单选择排序 | O(n^2) | O(n^2) | O(n^2) | O(1) | 不稳定 |
## 直接插入排序(Straight Insertion Sort)
袁记菜馆内
袁厨:好嘞,我们打烊啦,一起来玩会扑克牌吧。
小二:好啊,掌柜的,咱们玩会斗地主吧。
相信大家应该都玩过扑克牌吧,我们平常摸牌时,是不是一边摸牌,一边理牌,摸到新牌时,会将其插到合适的位置。这其实就是我们的插入排序思想。
直接插入排序:将一个记录插入到已经排好序的有序表中,从而得到一个新的有序表。通俗理解,我们首先将序列分成两个区间,有序区间和无序区间,我们每次在无序区间内取一个值,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间一直有序。下面我们看一下动图吧。
注:为了更清晰表达算法思想,则采用了挖掉待排序元素的形式展示,后面也会采取此形式表达。
另外关于这个算法对链表的排序大家可以去这个仓库看一哈链表插入排序
直接插入排序代码
class Solution {public int[] sortArray(int[] nums) {//注意 i 的初始值为 1,也就是第二个元素开始for (int i = 1; i < nums.length; ++i) {//待排序的值int temp = nums[i];//需要注意int j;for (j = i-1; j >= 0; --j) {//找到合适位置if (temp < nums[j]) {nums[j+1] = nums[j];continue;} //跳出循环break;}//插入到合适位置,这也就是我们没有在 for 循环内定义变量的原因nums[j+1] = temp;}return nums;}
}
直接插入排序时间复杂度分析
最好情况时,也就是有序的时候,我们不需要移动元素,每次只需要比较一次即可找到插入位置,那么最好情况时的时间复杂度为O(n)。
最坏情况时,即待排序表是逆序的情况,则此时需要比较2+3+....+n = (n+2)(n-1)/2,移动次数也达到了最大值,3 +4+5+....n+1 = (n+4)(n-1)/2,时间复杂度为O(n^2).
我们每次插入一个数据的时间复杂度为O(n),那么循环执行 n 次插入操作,平均时间复杂度为O(n^2)。
直接插入排序空间复杂度分析
根据动画可知,插入排序不需要额外的存储空间,所以其空间复杂度为O(1)
直接插入排序稳定性分析
我们根据代码可知,我们只会移动比 temp 值大的元素,所以我们排序后可以保证相同元素的相对位置不变。所以直接插入排序为稳定性排序算法。
希尔排序 (Shell's Sort)
我们在之前说过直接插入排序在记录基本有序的时候和元素较少时效率是很高的,基本有序时,只需执行少量的插入操作,就可以完成整个记录的排序工作。当元素较少时,效率也很高,就比如我们经常用的 Arrays.sort (),当元素个数少于47时,使用的排序算法就是直接插入排序。那么直接希尔排序和直接插入排序有什么关系呢?
希尔排序是插入排序的一种,又称“缩小增量排序”(Diminishing Increment Sort),是直接插入排序的高级变形,其思想简单点说就是有跨度的插入排序,这个跨度会逐渐变小,直到变为 1,变为 1 时记录也就基本有序,这时用到的也就是我们之前讲的直接插入排序了。
基本有序:就是小的关键字基本在前面,大的关键字基本在后面,不大不小的基本在中间。见下图。
我们已经了解了希尔排序的基本思想,下面我们通过一个绘图来描述下其执行步骤。
先逐步分组进行粗调,在进行直接插入排序的思想就是希尔排序。我们刚才的分组跨度(4,2,1)被称为希尔排序的增量,我们上面用到的是逐步折半的增量方法,这也是在发明希尔排序时提出的一种朴素方法,被称为希尔增量,下面我们用动图模拟下使用希尔增量的希尔排序的执行过程
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7kcFzlRK-1621395235780)(https://cdn.jsdelivr.net/gh/tan45du/test1@master/20210122/希尔排序.4vxwr7bkbjw0.gif)]
大家可能看了视频模拟,也不是特别容易写出算法代码,不过你们看到代码肯定会很熟悉滴。
希尔排序代码
class Solution {public int[] sortArray(int[] nums) {int increment = nums.length;//注意看结束条件while (increment > 1) {//这里可以自己设置increment = increment / 2;//根据增量分组for (int i = 0; i < increment; ++i) {//这快是不是有点面熟,回去看看咱们的插入排序for (int j = i + increment; j < nums.length; j += increment) {int temp = nums[j];int k;for (k = j - increment; k >= 0; k -= increment) {if (temp < nums[k]) {nums[k+increment] = nums[k];continue;}break;}nums[k+increment] = temp;}}}return nums;}
}
我们刚才说,我们的增量可以自己设置的,我们上面的例子是用的希尔增量,下面我们看这个例子,看看使用希尔增量会出现什么问题。
我们发现无论是以 4 为增量,还是以 2 为增量,每组内部的元素没有任何交换。直到增量为 1 时,数组才会按照直接插入排序进行调整。所以这种情况希尔排序的效率是低于直接插入排序呢?
我们的希尔增量因为每一轮之间是等比的,所以会有盲区,这里增量的选取就非常关键了。
下面给大家介绍两个比较有代表性的 Sedgewick 增量和 Hibbard 增量
Sedgewick 增量序列如下:
1,5,19,41,109.。。。
通项公式 94^k - 92^
利用此种增量方式的希尔排序,最坏时间复杂度是O(n^(4/3))
Hibbard增量序列如下:
1,3,7,15......
通项公式2 ^ k-1
利用此种增量方式的希尔排序,最坏时间复杂度为O(n^(3/2))
上面是两种比较具有代表性的增量方式,可究竟应该选取怎样的增量才是最好,目前还是一个数学难题。不过我们需要注意的一点,就是增量序列的最后一个增量值必须等于1才行。
希尔排序时间复杂度分析
希尔排序的时间复杂度跟增量序列的选择有关,范围为 O(n^(1.3-2)) 在此之前的排序算法时间复杂度基本都是O(n^2),希尔排序是突破这个时间复杂度的第一批算法之一。
希尔排序空间复杂度分析
根据我们的视频可知希尔排序的空间复杂度为 O(1),
希尔排序的稳定性分析
我们见下图,一起来分析下希尔排序的稳定性。
通过上图,可知,如果我们选用 4 为跨度的话,交换后,两个相同元素 2 的相对位置会发生改变,所以希尔排序是一个不稳定的排序
快速排序
今天我们来说一下快速排序,这个排序算法也是面试的高频考点,原理很简单,我们一起来扒一下他吧。
我们先来说一下快速排序的基本思想。
1.先从数组中找一个基准数
2.让其他比它大的元素移动到数列一边,比他小的元素移动到数列另一边,从而把数组拆解成两个部分。
3.再对左右区间重复第二步,直到各区间只有一个数。
见下图
上图则为一次快排示意图,下面我们再利用递归,分别对左半边区间也就是 [3,1,2] 和右半区间 [7,6,5,8] 执行上诉过程,直至区间缩小为 1 也就是第三条,则此时所有的数据都有序。
简单来说就是我们利用基准数通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比基准数小,另一部分记录的关键字均比基准数大,然后分别对这两部分记录继续进行排序,进而达到有序。
我们现在应该了解了快速排序的思想,那么大家还记不记得我们之前说过的归并排序,他们两个用到的都是分治思想,那他们两个有什么不同呢?见下图
注:快速排序我们以序列的第一个元素作为基准数
虽然归并排序和快速排序都用到了分治思想,但是归并排序是自下而上的,先处理子问题,然后再合并,将小集合合成大集合,最后实现排序。而快速排序是由上到下的,先分区,然后再处理子问题。归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,但是它是非原地排序算法。我们前面讲过,归并之所以是非原地排序算法,主要原因是合并函数无法在原地执行。快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题
我们根据思想可知,排序算法的核心就是如何利用基准数将记录分区,这里我们主要介绍两种容易理解的方法,一种是挖坑填数,另一种是利用双指针思想进行元素交换。
挖坑填数
下面我们先来介绍下挖坑填数的分区方法
基本思想是我们首先以序列的第一个元素为基准数,然后将该位置挖坑,下面判断 nums[hight] 是否大于基准数,如果大于则左移 hight 指针,直至找到一个小于基准数的元素,将其填入之前的坑中,则 hight 位置会出现一个新的坑,此时移动 low 指针,找到大于基准数的元素,填入新的坑中。不断迭代直至完成分区。
大家直接看我们的视频模拟吧,一目了然。
注:为了便于理解所以采取了挖坑的形式展示
是不是很容易就理解啦,下面我们直接看代码吧。
class Solution {public int[] sortArray(int[] nums) { quickSort(nums,0,nums.length-1);return nums;}public void quickSort (int[] nums, int low, int hight) {if (low < hight) {int index = partition(nums,low,hight);quickSort(nums,low,index-1);quickSort(nums,index+1,hight);} }public int partition (int[] nums, int low, int hight) {int pivot = nums[low];while (low < hight) {//移动hight指针while (low < hight && nums[hight] >= pivot) {hight--;}//填坑if (low < hight) nums[low] = nums[hight];while (low < hight && nums[low] <= pivot) {low++;}//填坑if (low < hight) nums[hight] = nums[low];}//基准数放到合适的位置nums[low] = pivot;return low;}
}
交换位置
下面我们来看一下交换思路,原理一致,实现也比较简单。
见下图。
其实这种方法,算是对上面方法的挖坑填坑步骤进行合并,low 指针找到大于 pivot 的元素,hight 指针找到小于 pivot 的元素,然后两个元素交换位置,最后再将基准数归位。两种方法都很容易理解和实现,即使是完全没有学习过快速排序的同学,理解了思想之后也能自己动手实现,下面我们继续用视频模拟下这种方法的执行过程吧。
两种方法都很容易实现,对新手非常友好,大家可以自己去 AC 一下啊。
class Solution {public int[] sortArray (int[] nums) { quickSort(nums,0,nums.length-1);return nums;}public void quickSort (int[] nums, int low, int hight) {if (low < hight) {int index = partition(nums,low,hight);quickSort(nums,low,index-1);quickSort(nums,index+1,hight);} }public int partition (int[] nums, int low, int hight) {int pivot = nums[low];int start = low;while (low < hight) {while (low < hight && nums[hight] >= pivot) hight--; while (low < hight && nums[low] <= pivot) low++;if (low >= hight) break;swap(nums, low, hight); }//基准值归位swap(nums,start,low);return low;} public void swap (int[] nums, int i, int j) { int temp = nums[i];nums[i] = nums[j];nums[j] = temp;}
}
快速排序的时间复杂度分析
快排也是用递归来实现的。所以快速排序的时间性能取决于快速排序的递归树的深度。如果每次分区操作,都能正好把数组分成大小接近相等的两个小区间,那么此时的递归树是平衡的,性能也较好,递归树的深度也就和之前归并排序求解方法一致,然后我们每一次分区需要对数组扫描一遍,做 n 次比较,所以最优情况下,快排的时间复杂度是 O(nlogn)。
但是大多数情况下我们不能划分的很均匀,比如数组为正序或者逆序时,即 [1,2,3,4] 或 [4,3,2,1] 时,此时为最坏情况,那么此时我们则需要递归调用 n-1 次,此时的时间复杂度则退化到了 O(n^2)。
快速排序的空间复杂度分析
快速排序主要时递归造成的栈空间的使用,最好情况时其空间复杂度为O (logn),对应递归树的深度。最坏情况时则需要 n-1 次递归调用,此时空间复杂度为O(n).
快速排序的稳定性分析
快速排序是一种不稳定的排序算法,因为其关键字的比较和交换是跳跃进行的,见下图。
此时无论使用哪一种方法,第一次排序后,黄色的 1 都会跑到红色 1 的前面,所以快速排序是不稳定的排序算法。
好啦,快速排序我们掌握的差不多了,下面我们一起来看看如何对其优化吧。
快速排序的迭代写法
该方法实现也是比较简单的,借助了栈来实现,很容易实现。主要利用了先进后出的特性,这里需要注意的就是入栈的顺序,这里算是一个小细节,需要注意一下。
class Solution {public int[] sortArray(int[] nums) {Stack<Integer> stack = new Stack<>();stack.push(nums.length - 1);stack.push(0);while (!stack.isEmpty()) {int low = stack.pop();int hight = stack.pop();if (low < hight) {int index = partition(nums, low, hight);stack.push(index - 1);stack.push(low);stack.push(hight);stack.push(index + 1);}}return nums;}public int partition (int[] nums, int low, int hight) {int pivot = nums[low];int start = low;while (low < hight) {while (low < hight && nums[hight] >= pivot) hight--; while (low < hight && nums[low] <= pivot) low++;if (low >= hight) break;swap(nums, low, hight); }swap(nums,start,low);return low;} public void swap (int[] nums, int i, int j) { int temp = nums[i];nums[i] = nums[j];nums[j] = temp;}
}
快速排序优化
三数取中法
我们在上面的例子中选取的都是 nums[low] 做为我们的基准值,那么当我们遇到特殊情况时呢?见下图
我们按上面的方法,选取第一个元素做为基准元素,那么我们执行一轮排序后,发现仅仅是交换了 2 和 7 的位置,这是因为 7 为序列的最大值。所以我们的 pivot 的选取尤为重要,选取时应尽量避免选取序列的最大或最小值做为基准值。则我们可以利用三数取中法来选取基准值。
也就是选取三个元素中的中间值放到 nums[low] 的位置,做为基准值。这样就避免了使用最大值或最小值做为基准值。
所以我们可以加上这几行代码实现三数取中法。
int mid = low + ((hight-low) >> 1);if (nums[low] > nums[hight]) swap(nums,low,hight);if (nums[mid] > nums[hight]) swap(nums,mid,hight);if (nums[mid] > nums[low]) swap(nums,mid,low);
其含义就是让我们将中间元素放到 nums[low] 位置做为基准值,最大值放到 nums[hight],最小值放到 nums[mid],即 [4,2,3] 经过上面代码处理后,则变成了 [3,2,4].此时我们选取 3 做为基准值,这样也就避免掉了选取最大或最小值做为基准值的情况。
三数取中法
class Solution {public int[] sortArray(int[] nums) { quickSort(nums,0,nums.length-1);return nums;}public void quickSort (int[] nums, int low, int hight) {if (low < hight) {int index = partition(nums,low,hight);quickSort(nums,low,index-1);quickSort(nums,index+1,hight);} }public int partition (int[] nums, int low, int hight) {//三数取中,大家也可以使用其他方法int mid = low + ((hight-low) >> 1);if (nums[low] > nums[hight]) swap(nums,low,hight);if (nums[mid] > nums[hight]) swap(nums,mid,hight);if (nums[mid] > nums[low]) swap(nums,mid,low); //下面和之前一样,仅仅是多了上面几行代码int pivot = nums[low];int start = low;while (low < hight) {while (low < hight && nums[hight] >= pivot) hight--; while (low < hight && nums[low] <= pivot) low++;if (low >= hight) break;swap(nums, low, hight); }swap(nums,start,low);return low;} public void swap (int[] nums, int i, int j) { int temp = nums[i];nums[i] = nums[j];nums[j] = temp;}
}
和插入排序搭配使用
我们之前说过,插入排序在元素个数较少时效率是最高的,所以当元素数量较少时,快速排序反而不如插入排序好用,所以我们可以设定一个阈值,当元素个数大于阈值时使用快速排序,小于等于该阈值时则使用插入排序。我们设定阈值为 7 。
三数取中+插入排序
class Solution {private static final int INSERTION_SORT_MAX_LENGTH = 7;public int[] sortArray(int[] nums) { quickSort(nums,0,nums.length-1);return nums;}public void quickSort (int[] nums, int low, int hight) {if (hight - low <= INSERTION_SORT_MAX_LENGTH) {insertSort(nums,low,hight);return;} int index = partition(nums,low,hight);quickSort(nums,low,index-1);quickSort(nums,index+1,hight); }public int partition (int[] nums, int low, int hight) {//三数取中,大家也可以使用其他方法int mid = low + ((hight-low) >> 1);if (nums[low] > nums[hight]) swap(nums,low,hight);if (nums[mid] > nums[hight]) swap(nums,mid,hight);if (nums[mid] > nums[low]) swap(nums,mid,low); int pivot = nums[low];int start = low;while (low < hight) {while (low < hight && nums[hight] >= pivot) hight--; while (low < hight && nums[low] <= pivot) low++;if (low >= hight) break;swap(nums, low, hight); }swap(nums,start,low);return low;} public void insertSort (int[] nums, int low, int hight) {for (int i = low+1; i <= hight; ++i) {int temp = nums[i];int j;for (j = i-1; j >= 0; --j) {if (temp < nums[j]) {nums[j+1] = nums[j];continue;} break;}nums[j+1] = temp;}} public void swap (int[] nums, int i, int j) {int temp = nums[i];nums[i] = nums[j];nums[j] = temp;}
}
三数取中+三向切分+插入排序
我们继续看下面这种情况
我们对其执行一次排序后,则会变成上面这种情况,然后我们继续对蓝色基准值的左区间和右区间执行相同操作。也就是 [2,3,6,3,1,6] 和 [8,6] 。我们注意观察一次排序的结果数组中是含有很多重复元素的。
那么我们为什么不能将相同元素放到一起呢?这样就能大大减小递归调用时的区间大小,见下图。
这样就减小了我们的区间大小,将数组切分成了三部分,小于基准数的左区间,大于基准数的右区间,等于基准数的中间区间。
下面我们来看一下如何达到上面的情况,我们先来进行视频模拟,模拟之后应该就能明白啦。
我们来剖析一下视频,其实原理很简单,我们利用探路指针也就是 i,遇到比 pivot 大的元素,则和 right 指针进行交换,此时 right 指向的元素肯定比 pivot 大,则 right--,但是,此时我们的 nums[i] 指向的元素并不知道情况,所以我们的 i 指针不动,如果此时 nums[i] < pivot 则与 left 指针交换,注意此时我们的 left 指向的值肯定是 等于 povit的,所以交换后我们要 left++,i++, nums[i] == pivot 时,仅需要 i++ 即可,继续判断下一个元素。 我们也可以借助这个思想来解决经典的荷兰国旗问题。
好啦,我们下面直接看代码吧。
class Solution {private static final int INSERTION_SORT_MAX_LENGTH = 7;public int[] sortArray(int[] nums) {quickSort(nums,0,nums.length-1);return nums;}public void quickSort(int nums[], int low, int hight) {//插入排序if (hight - low <= INSERTION_SORT_MAX_LENGTH) {insertSort(nums,low,hight);return;}//三数取中int mid = low + ((hight-low) >> 1);if (nums[low] > nums[hight]) swap(nums,low,hight);if (nums[mid] > nums[hight]) swap(nums,mid,hight);if (nums[mid] > nums[low]) swap(nums,mid,low);//三向切分int left = low, i = low + 1, right = hight;int pvoit = nums[low];while (i <= right) {if (pvoit < nums[i]) {swap(nums,i,right);right--;} else if (pvoit == nums[i]) {i++;} else {swap(nums,left,i);left++;i++;}}quickSort(nums,low,left-1);quickSort(nums,right+1,hight);}public void insertSort (int[] nums, int low, int hight) {for (int i = low+1; i <= hight; ++i) {int temp = nums[i];int j;for (j = i-1; j >= 0; --j) {if (temp < nums[j]) {nums[j+1] = nums[j];continue;} break;}nums[j+1] = temp;}} public void swap (int[] nums, int i, int j) {int temp = nums[i];nums[i] = nums[j];nums[j] = temp;}
}
好啦,一些常用的优化方法都整理出来啦,还有一些其他的优化算法九数取中,优化递归操作等就不在这里进行描述啦,感兴趣的可以自己看一下。好啦,这期的文章就到这里啦,我们下期见,拜了个拜。
堆排序
说堆排序之前,我们先简单了解一些什么是堆?堆这种数据结构应用场景非常多,所以我们需要熟练掌握呀!
那我们了解堆之前,先来简单了解下,什么是完全二叉树?
我们来看下百度百科的定义,完全二叉树:叶子结点只能出现在最下层和次下层,且最下层的叶子结点集中在树的左部。
哦!我们可以这样理解,除了最后一层,其他层的节点个数都是满的,而且最后一层的叶子节点必须靠左。
下面我们来看一下这几个例子
上面的几个例子中,(1)(4)为完全二叉树,(2)(3)不是完全二叉树,通过上面的几个例子,我们了解了什么是完全二叉树,
那么堆到底是什么呢?
下面我们来看一下二叉堆的要求
(1)必须是完全二叉树
(2)二叉堆中的每一个节点,都必须大于等于(或小于等于)其子树中每个节点的值。
若是每个节点大于等于子树中的每个节点,我们称之为大顶堆,小于等于子树中的每个节点,我们则称之为小顶堆。见下图
下面我们再来看一下二叉堆的具体例子。
上图则为大顶堆和小顶堆,我们再来回顾一下堆的要求,看下是否符合
(1)必须是完全二叉树
(2)堆中的每一个节点,都必须大于等于(或小于等于)其子树中每个节点的值。
好啦,到这里我们已经完全掌握二叉堆了,那么二叉堆又是怎么存储的呢?因为堆是完全二叉树,所以我们完全可以用数组存储。具体思想见下图,我们仅仅按照顺序将节点存入数组即可,我们通过小顶堆进行演示。
注:我们是从下标 1 开始存储的,这样能省略一些计算,下文中我们将二叉堆简称为堆
我们来看一下为什么我们可以用数组来存储堆呢?
我们首先看根节点,也就是值为 1 的节点,它在数组中的下标为 1 ,它的左子节点,也就是值为 4 的节点,此时索引为 2,右子节点也就是值为 2 的节点,它的索引为 3。
我们发现其中的关系了吗?
数组中,某节点(非叶子节点)的下标为 i , 那么其左子节点下标为 2*i (这里可以直接通过相乘得到左孩子,如果从0 开始存,需要 2i+1 才行), 右子节点为 2i+1,其父节点为 i/2 。既然我们完全可以根据索引找到某节点的 左子节点 和 右子节点,那么我们用数组存储是完全没有问题的。
好啦,我们知道了什么是堆和如何用数组存储堆,那我们如何完成堆排序呢?
堆排序其实主要有两个步骤
- 建堆
- 排序
下面我们先来了解下建堆
我们刚才说了用数组来存储大顶(小顶)堆,此时的元素已经满足某节点大于等于(或小于等于)子树节点,但是随机给我们一个数组,此时并不一定满足上诉要求,所以我们需要调整数组,使其满足大顶堆或小顶堆的要求。这个就是堆化,也可以称其为建堆。
建堆我们这里提出两种方法,利用上浮操作,也就是不断插入元素进行建堆,另一种是利用下沉操作,遍历父节点,不断将其下沉,进行建堆,我们一起来看吧。
我们先来说下第一种建堆方式
利用上浮操作建堆
说之前我们先来了解下,如何往已经建好的堆里,插入新的元素,我们直接看例子吧,一下就懂啦。
假设让我们插入新的元素 1 (绿色节点),我们发现此时 1 小于其父节点 的值 7 ,并不遵守小顶堆的规则,那我们则需要移动元素 1 。让 1 与 7 交换,(如果新插入元素大于父节点的值,则说明插入新节点后仍满足小顶堆规则,无需交换)。
之前我们说过,我们可以用数组保存堆,并且可以通过 i/2 得到其父节点的值,那么此时我们就明白怎么做啦。
将插入节点与其父节点,交换。
交换之后,我们继续将新插入元素,也就是 1 与其父节点比较,如果大于其父节点,则无需交换,循环结束。若小于则需要继续交换,直到 1 到达适合他的地方。大家是不是已经直到该怎么做啦!下面我们直接看动图吧。
看完动图是不是就妥了,其实很简单,我们只需将新加入元素与其父节点比较,判断是否小于堆顶元素(小顶堆),如果小于则进行交换,(让更小的节点为父节点)直到符合堆的规则位置,大顶堆则相反。
我们发现,我们新插入的元素是不是一层层的上浮,直到找到属于自己的位置,我们将这个操作称之为上浮操作。
那我们知道了上浮,岂不是就可以实现建堆了?是的,我们则可以依次遍历数组,就好比不断往堆中插入新元素,直至遍历结束,这样我们就完成了建堆,这种方法当然是可以的。
我们一起来看一下上浮操作代码。
public void swim (int index) {while (index > 1 && nums[index/2] > nums[index]) {swap(index/2,index);//交换index = index/2;}
}
既然利用上浮操作建堆已经搞懂啦,那么我们再来了解一下,利用下沉操作建堆吧,也很容易理解。
下沉建堆
给我们一个无序数组(不满足堆的要求),见下图
我们发现,7 位于堆顶,但是此时并不满足小顶堆的要求,我们需要把 7 放到属于它的位置,我们应该怎么做呢?
废话不多说,我们先来看视频模拟,看完保准可以懂
看完视频是不是懂个大概了,但是不知道大家有没有注意到这个地方。为什么 7 第一次与其左孩子节点 2 交换,第二次与右孩子节点 3 交换。见下图
其实很容易理解,我们需要与孩子节点中最小的那个交换,因为我们需要交换后,父节点小于两个孩子节点,如果我们第一步,7 与 5 进行交换的话,则同样不能满足小顶堆。
那我们怎么判断节点找到属于它的位置了呢?主要有两个情况
- 待下沉元素小于(大于)两个子节点,此时符合堆的规则,无序下沉,例如上图中的 6
- 下沉为叶子节点,此时没有子节点,例如 7 下沉到最后变成了叶子节点。
我们将上面的操作称之为下沉操作。
这时我们又有疑问了,下沉操作我懂了,但是这跟建堆有个锤子关系啊!
不要急,我们继续来看视频,这次我们通过下沉操作建个大顶堆。
初始数组 [8,5,7,9,2,10,1,4,6,3]
我们一起来拆解一下视频,我们只需要从最后一个非叶子节点开始,依次执行下沉操作。执行完毕后我们就能够完成堆化。是不是一下就懂了呀。
好啦我们一起看哈下沉操作的代码吧。
public void sink (int[] nums, int index,int len) {while (true) {//获取子节点int j = 2 * index;if (j < len-1 && nums[j] < nums[j+1]) {j++;}//交换操作,父节点下沉,与最大的孩子节点交换if (j < len && nums[index] < nums[j]) {swap(nums,index,j);} else {break;} //继续下沉index = j;}}
好啦,两种建堆方式我们都已经了解啦,那么我们如何进行排序呢?
了解排序之前我们先来,看一下如何删除堆顶元素,我们需要保证的是,删除堆顶元素后,其他元素仍能满足堆的要求,我们思考一下如何实现呢?见下图
假设我们想要去除堆顶的 11 ,那我们则需要将其与堆的最后一个节点交换也就是 2 ,2然后再执行下沉操作,执行完毕后仍能满足堆的要求,见下图
好啦,其实你已经学会如何排序啦!你不信?那我给你放视频
好啦,大家是不是已经搞懂啦,下面我们总结一下堆排序的具体执行过程
1.建堆,通过下沉操作建堆效率更高,具体过程是,找到最后一个非叶子节点,然后从后往前遍历执行下沉操作。
2.排序,将堆顶元素(代表最大元素)与最后一个元素交换,然后新的堆顶元素进行下沉操作,遍历执行上诉操作,则可以完成排序。
好啦,下面我们一起看代码吧
class Solution {public int[] sortArray(int[] nums) {int len = nums.length;int[] a = new int[len + 1];for (int i = 0; i < nums.length; ++i) {a[i+1] = nums[i];} //下沉建堆for (int i = len/2; i >= 1; --i) {sink(a,i,len);}int k = len;//排序while (k > 1) {swap(a,1,k--);sink(a,1,k);}for (int i = 1; i < len+1; ++i) {nums[i-1] = a[i];}return nums;}public void sink (int[] nums, int k,int end) {//下沉while (2 * k <= end) {int j = 2 * k;//找出子节点中最大或最小的那个if (j + 1 <= end && nums[j + 1] > nums[j]) {j++;}if (nums[j] > nums[k]) {swap(nums, j, k);} else {break;}k = j;}}public void swap (int nums[], int i, int j) {int temp = nums[i];nums[i] = nums[j];nums[j] = temp;}}
好啦,堆排序我们就到这里啦,是不是搞定啦,总的来说堆排序比其他排序算法稍微难理解一些,重点就是建堆,而且应用比较广泛,大家记得打卡呀。
好啦,我们再来分析一下堆排序的时间复杂度、空间复杂度以及稳定性。
堆排序时间复杂度分析
因为我们建堆的时间复杂度为 O(n),排序过程的时间复杂度为 O(nlogn),所以总的空间复杂度为 O(nlogn)
堆排序空间复杂度分析
这里需要注意,我们上面的描述过程中,为了更直观的描述,空出数组的第一位,这样我们就可以通过 i * 2 和 i * 2+1 来求得左孩子节点和右孩子节点 。我们也可以根据 i * 2 + 1 和 i * 2 + 2 来获取孩子节点,这样则不需要临时数组来处理原数组,将所有元素后移一位,所以堆排序的空间复杂度为 O(1),是原地排序算法。
堆排序稳定性分析
堆排序不是稳定的排序算法,在排序的过程,我们会将堆的最后一个节点跟堆顶节点交换,改变相同元素的原始相对位置。
最后我们来比较一下我们快速排序和堆排序
1.对于快速排序来说,数据是顺序访问的。而对于堆排序来说,数据是跳着访问的。这样对 CPU 缓存是不友好的
2.相同的的数据,排序过程中,堆排序的数据交换次数要多于快速排序。
所以上面两条也就说明了在实际开发中,堆排序的性能不如快速排序性能好。
好啦,今天的内容就到这里啦,咱们下期见。
归并排序
归并排序是必须要掌握的排序算法,也算是面试高频考点,下面我们就一起来扒一扒归并排序吧,原理很简单,大家一下就能搞懂。
袁记菜馆内
第 23 届食神争霸赛开赛啦!
袁厨想在自己排名前4的分店中,挑选一个最优秀的厨师来参加食神争霸赛,选拔规则如下。
第一场 PK:每个分店选出两名厨师,首先进行店内 PK,选出店内里的胜者
第二场 PK: 然后店内的优胜者代表分店挑战其他某一分店的胜者(半决赛)
第三场 PK:最后剩下的两名胜者进行PK,选出最后的胜者。
示意图如下
上面的例子大家应该不会陌生吧,其实我们归并排序和食神选拔赛的流程是有些相似的,下面我们一起来看一下吧
归并这个词语的含义就是合并,并入的意思,而在我们的数据结构中的定义是将两个或两个以上的有序表和成一个新的有序表。而我们这里说的归并排序就是使用归并的思想实现的排序方法。
归并排序使用的就是分治思想。顾名思义就是分而治之,将一个大问题分解成若干个小的子问题来解决。小的子问题解决了,大问题也就解决了。分治后面会专门写一篇文章进行描述滴,这里先简单提一下。
下面我们通过一个图片来描述一下归并排序的数据变换情况,见下图。
我们简单了解了归并排序的思想,从上面的描述中,我们可以知道算法的归并过程是比较难实现的,这也是这个算法的重点,看完我们这个视频就能懂个大概啦。
视频中归并步骤大家有没有看懂呀,没看懂也不用着急,下面我们一起来拆解一下,归并共有三步走。
第一步:创建一个额外大集合用于存储归并结果,长度则为那两个小集合的和,从视频中也可以看的出
第二步:我们从左自右比较两个指针指向的值,将较小的那个存入大集合中,存入之后指针移动,并继续比较,直到某一小集合的元素全部都存到大集合中。见下图
第三步:当某一小集合元素全部放入大集合中,则需将另一小集合中剩余的所有元素存到大集合中,见下图
好啦,看完视频和图解是不是能够写出个大概啦,了解了算法原理之后代码写起来就很简单啦,
递归实现
下面我们看代码吧。
class Solution {public int[] sortArray(int[] nums) {mergeSort(nums,0,nums.length-1);return nums;}public void mergeSort(int[] arr, int left, int right) {if (left < right) {int mid = left + ((right - left) >> 1);mergeSort(arr,left,mid);mergeSort(arr,mid+1,right);merge(arr,left,mid,right);}} //归并public void merge(int[] arr,int left, int mid, int right) {//第一步,定义一个新的临时数组int[] temparr = new int[right -left + 1];int temp1 = left, temp2 = mid + 1;int index = 0;//对应第二步,比较每个指针指向的值,小的存入大集合while (temp1 <= mid && temp2 <= right) {if (arr[temp1] <= arr[temp2]) {temparr[index++] = arr[temp1++];} else {temparr[index++] = arr[temp2++];}}//对应第三步,将某一小集合的剩余元素存到大集合中if (temp1 <= mid) System.arraycopy(arr, temp1, temparr, index, mid - temp1 + 1);if (temp2 <= right) System.arraycopy(arr, temp2, temparr, index, right -temp2 + 1); //将大集合的元素复制回原数组System.arraycopy(temparr,0,arr,0+left,right-left+1); }
}
归并排序时间复杂度分析
我们一趟归并,需要将两个小集合的长度放到大集合中,则需要将待排序序列中的所有记录扫描一遍所以时间复杂度为O(n)。归并排序把集合一层一层的折半分组,则由完全二叉树的深度可知,整个排序过程需要进行 logn(向上取整)次,则总的时间复杂度为 O(nlogn)。另外归并排序的执行效率与要排序的原始数组的有序程度无关,所以在最好,最坏,平均情况下时间复杂度均为 O(nlogn) 。虽然归并排序时间复杂度很稳定,但是他的应用范围却不如快速排序广泛,这是因为归并排序不是原地排序算法,空间复杂度不为 O(1),那么他的空间复杂度为多少呢?
归并排序的空间复杂度分析
归并排序所创建的临时结合都会在方法结束时释放,单次归并排序的最大空间是 n ,所以归并排序的空间复杂度为 O(n).
归并排序的稳定性分析
归并排序的稳定性,要看我们的 merge 函数,我们代码中设置了 arr[temp1] <= arr[temp2] ,当两个元素相同时,先放入arr[temp1] 的值到大集合中,所以两个相同元素的相对位置没有发生改变,所以归并排序是稳定的排序算法。
算法名称 | 最好时间复杂度 | 最坏时间复杂度 | 平均时间复杂度 | 空间复杂度 | 是否稳定 |
---|---|---|---|---|---|
归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 稳定 |
等等还没完嘞,不要走。
迭代实现
归并排序的递归实现是比较常见的,也是比较容易理解的,下面我们一起来扒一下归并排序的迭代写法。看看他是怎么实现的。
我们通过一个视频来了解下迭代方法的思想,
是不是通过视频了解个大概啦,下面我们来对视频进行解析。
迭代实现的归并排序是将小集合合成大集合,小集合大小为 1,2,4,8,.....。依次迭代,见下图
比如此时小集合大小为 1 。两个小集合分别为 [3],[1]。然后我们根据合并规则,见第一个视频,将[3],[1]合并到临时数组中,则小的先进,则实现了排序,然后再将临时数组的元素复制到原来数组中。则实现了一次合并。
下面则继续合并[4],[6]。具体步骤一致。所有的小集合合并完成后,则小集合的大小变为 2,继续执行刚才步骤,见下图。
此时子集合的大小为 2 ,则为 [2,5],[1,3] 继续按照上面的规则合并到临时数组中完成排序。 这就是迭代法的具体执行过程,
下面我们直接看代码吧。
注:递归法和迭代法的 merge函数代码一样。
class Solution {public int[] sortArray (int[] nums) {//代表子集合大小,1,2,4,8,16.....int k = 1;int len = nums.length;while (k < len) {mergePass(nums,k,len);k *= 2;}return nums;}public void mergePass (int[] array, int k, int len) {int i;for (i = 0; i < len-2*k; i += 2*k) {//归并merge(array,i,i+k-1,i+2*k-1);}//归并最后两个序列if (i + k < len) {merge(array,i,i+k-1,len-1);}}public void merge (int[] arr,int left, int mid, int right) {//第一步,定义一个新的临时数组int[] temparr = new int[right -left + 1];int temp1 = left, temp2 = mid + 1;int index = 0;//对应第二步,比较每个指针指向的值,小的存入大集合while (temp1 <= mid && temp2 <= right) {if (arr[temp1] <= arr[temp2]) {temparr[index++] = arr[temp1++];} else {temparr[index++] = arr[temp2++];}}//对应第三步,将某一小集合的剩余元素存到大集合中if (temp1 <= mid) System.arraycopy(arr, temp1, temparr, index, mid - temp1 + 1);if (temp2 <= right) System.arraycopy(arr, temp2, temparr, index, right -temp2 + 1); //将大集合的元素复制回原数组System.arraycopy(temparr,0,arr,0+left,right-left+1); }
}
计数排序
今天我们就一起来看看线性排序里的计数排序到底是怎么回事吧。
我们将镜头切到袁记菜馆
因为今年袁记菜馆的效益不错,所以袁厨就想给员工发些小福利,让小二根据员工工龄进行排序,但是菜馆共有 100000 名员工,菜馆开业 10 年,员工工龄从 0 - 10 不等。看来这真是一个艰巨的任务啊。
当然我们可以借助之前说过的 归并排序 和 快速排序解决,但是我们有没有其他更好的方法呢?
了解排序算法的老哥可能已经猜到今天写什么啦。是滴,我们今天来写写用空间换时间的线性排序。
说之前我们先来回顾一下之前的排序算法,最好的时间复杂度为 O(nlogn) ,且都基于元素之间的比较来进行排序,
我们来说一下非基于元素比较的排序算法,且时间复杂度为 O(n),时间复杂度是线性的,所以我们称其为线性排序算法。
其优势在于在对一定范围内的整数排序时,它的复杂度为Ο(n+k),此时的 k 则代表整数的范围。快于任何一种比较类排序算法,不过也是需要牺牲一些空间来换取时间。
下面我们先来看看什么是计数排序,这个计数的含义是什么?
我们假设某一分店共有 10 名员工,
工龄分别为 1,2,3,5,0,2,2,4,5,9
那么我们将其存在一个长度为 10 的数组里,,但是我们注意,我们数组此时存的并不是元素值,而是元素的个数。见下图
注:此时我们这里统计次数的数组根据最大值来决定,上面的例子中最大值为 9 ,则长度为 9 + 1 = 10。暂且先这样理解,后面会对其优化 。
我们继续以上图的例子来说明,在该数组中,索引代表的为元素值(也就是上面例子中的工龄),数组的值代表的则是元素个数(也就是不同工龄出现的次数)。
即工龄为 0 的员工有 1 个, 工龄为 1 的员工有 1 个,工龄为 2 的员工有 3 个 。。。
然后我们根据出现次数将其依次取出看看是什么效果。
0,1,2,2,2,3,4,5,5,9
我们发现此时元素则变成了有序的,但是这并不是排序,只是简单的按照统计数组的下标,输出了元素值,并没有真正的给原始数组进行排序。
这样操作之后我们不知道工龄属于哪个员工。
见下图
虽然喵哥和杰哥工龄相同,如果我们按照上面的操作输出之后,我们不能知道工龄为 4 的两个员工,哪个是喵哥哪个是杰哥。
所以我们需要借助其他方法来对元素进行排序。
大家还记不记得我们之前说过的前缀和,下面我们通过上面统计次数的数组求出其前缀和数组。
因为我们是通过统计次数的数组得到了前缀和数组,那么我们来分析一下 presum 数组里面值的含义。
例如我们的 presum[2] = 5 ,代表的则是原数组小于等于 2 的值共有 5 个。presum[4] = 7 代表小于等于 4 的元素共有 7 个。
是不是感觉计数排序的含义要慢慢显现出来啦。
其实到这里我们已经可以理解的差不多了,还差最后一步,
此时我们要从后往前遍历原始数组,然后将遍历到的元素放到临时数组的合适位置,并修改 presum 数组的值,遍历结束后则达到了排序的目的。
我们从后往前遍历,nums[9] = 9,则我们拿该值去 presum 数组中查找,发现 presum[nums[9]] = presum[9] = 10 ,大家还记得我们 presum 数组里面每个值得含义不,我们此时 presum[9] = 10,则代表在数组中,小于等于的数共有 10 个,则我们要将他排在临时数组的第 10 个位置,也就是 temp[9] = 9。
我们还需要干什么呢?我们想一下,我们已经把 9 放入到 temp 数组里了,已经对其排好序了,那么我们的 presum 数组则不应该再统计他了,则将相应的位置减 1 即可,也就是 presum[9] = 10 - 1 = 9;
下面我们继续遍历 5 ,然后同样执行上诉步骤
我们继续查询 presum 数组,发现 presum[5] = 9,则说明小于等于 5 的数共有 9 个,我们将其放入到 temp 数组的第 9 个位置,也就是
temp[8] = 5。然后再将 presum[5] 减 1 。
是不是到这里就理解了计数排序的大致思路啦。
这个排序的过程像不像查字典呢?通过查询 presum 数组,得出自己应该排在临时数组的第几位。然后再修改下字典,直到遍历结束。
那么我们先来用动画模拟一下我们这个 bug 版的计数排序,加深理解。
注:我们得到 presum 数组的过程在动画中省略。直接模拟排序过程。
但是到现在就完了吗?显然没有,我们思考下这个情况。
假如我们的数字为 90,93,94,91,92 如果我们根据上面方法设置 presum 数组的长度,那我们则需要设置数组长度为 95(因为最大值是94),这样显然是不合理的,会浪费掉很多空间。
还有就是当我们需要对负数进行排序时同样会出现问题,因为我们求次数的时候是根据 nums[index] 的值来填充 presum 数组的,所以当 nums[index] 为负数时,填充 presum 数组时则会报错。而且此时通过最大值来定义数组长度也不合理。
所以我们需要采取别的方法来定义数组长度。
下面我们来说一下偏移量的概念。
例如 90,93,94,91,92,我们 可以通过 max ,min 的值来设置数组长度即 94 - 90 + 1 = 5 。偏移量则为 min 值,也就是 90。
见下图。
这样我们填充 presum 数组时就不会出现浪费空间的情况了,负数?出现负数的情况当然也可以。继续看
例如:-1,-3,0,2,1
一样可以,哦了,到这里我们就搞定了计数排序,下面我们来看一哈代码吧。
class Solution {public int[] sortArray(int[] nums) {int len = nums.length;if (nums.length < 1) {return nums;}//求出最大最小值int max = nums[0];int min = nums[0];for (int x : nums) {if (max < x) max = x; if (min > x) min = x; }//设置 presum 数组长度,然后求出我们的前缀和数组,//这里我们可以把求次数数组和前缀和数组用一个数组处理int[] presum = new int[max-min+1];for (int x : nums) {presum[x-min]++;}for (int i = 1; i < presum.length; ++i) {presum[i] = presum[i-1]+presum[i]; }//临时数组int[] temp = new int[len];//遍历数组,开始排序,注意偏移量for (int i = len-1; i >= 0; --i) {//查找 presum 字典,然后将其放到临时数组,注意偏移度int index = presum[nums[i]-min]-1;temp[index] = nums[i];//相应位置减一presum[nums[i]-min]--; }//copy回原数组System.arraycopy(temp,0,nums,0,len);return nums;}
}
好啦,这个排序算法我们已经搞定了,下面我们来扒一扒它。
计数排序时间复杂度分析
我们的总体运算量为 n+n+k+n ,总体运算是 3n + k 所以时间复杂度为 O(N+K);
计数排序空间复杂度分析
我们用到了辅助数组,空间复杂度为 O(n)
计数排序稳定性分析
稳定性在我们最后存入临时数组时有体现,我们当时让其放入临时数组的合适位置,并减一,所以某元素前面的相同元素,在临时数组,仍然在其前面。所以计数排序是稳定的排序算法。
虽然计数排序效率不错但是用到的并不多。
-
这是因为其当数组元素的范围太大时,并不适合计数排序,不仅浪费时间,效率还会大大降低。
-
当待排序的元素非整数时,也不适用,大家思考一下这是为什么呢?
好啦,今天的文章就到这啦,我们下期再见,拜了个拜.
另外关于可以用排序算法秒杀的题目,后面会给大家更新上来,如果现在需要的话,可以去我的仓库。
仓库地址:GitHub - chefyuan/algorithm-base: 一位酷爱做饭的程序员,立志用动画将算法说的通俗易懂。我的面试网站 www.chengxuchu.com
里面还有其他很多动画题解,欢迎各位阅读。