用java实现归并排序、快速排序、冒泡排序、插入排序、堆排序。主要就是为了准备面试手撕排序代码
1 经典的基础的排序算法
[1]参考了《大话数据结构》的部分代码思想
[2] 本人原创、保留权利经典的归并、快排、堆排序,基础的冒泡、插入、希尔、选择
可以拿力扣 912. 排序数组 来练手
1、归并排序
归并排序 MergeSort
就是利用 归并 的思想实现的排序方法。
假设初始序列含有n 个记录,则可以看成是n 个有序(内部有序)的子序列,每个子序列的长度为 1 , 然后两两归并,得到[n /2] ([x ]表示不小于x 的最小整数)个长度为2 或 1 的有序子序列;再两两归并,……,如此重复,直至得到一个长度为n 的有序序列为止。
1.1 递归实现
需要两个函数:一个函数mergeSort
用来递归,将原数组平分为两个部分、分别递归调用生成有序数列;一个函数merge
合并两个有序子数列,主要采用两个指针轮流后移的方法。
mergeSort实现递归调用
- 递归的出口是最后一次递归的子数列长度为1 即 left == right
- 每次循环递归两次、左半一次 右半一次
merge实现合并两有序子数列
- 输入数组、输出数组、左索引、中间索引、右索引
- 使用指针移动来实现
递归实现代码如下:
1 | class Solution { |
1.3 迭代实现
非递归的迭代做法更加直截了当,从最小的序列开始归并直至完成。即从最开始两两归并长度为1的子列、再两两归并长度为2的子列、再4、再8再、、、直接顺着一遍来就好了。需要两个函数MergePass、Merge和一个主函数MergeSort。
- 一个是MergePass:
- 用来将SR[]中相邻长度为s的子序列两两归并到TR[]
- 一个是Merge实现合并两有序子数列
- 输入数组、输出数组、左索引、中间索引、右索引
- 使用指针推移实现
- 主函数MergeSort实现迭代调用MergePass。
- 一个while循环、k以两倍的速度增长
- 每次循环都调用MergePass,参数数组要变换位置
迭代实现代码如下:
1 | //我还不会.... |
2、快速排序
通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到使整个序列有序的目的。
我的解释是,通过一个简单高效的交换将待排序数组二分为两个(一个的所有数小于另一个的所有数),然后再分别排那两个二分的数组(当然分别排也是用这样的策略继续二分继续排)、
所以我们需要两个函数、一个是递归调用的,排数组就要排它的两个分开好了的子数组,一层层递归调用该函数;一个是如何将一个数组分成两个、一个数组的所有数小于另一个数组的所有数,通过指定一个枢纽,然后替换整理,将比枢纽小的数放在枢纽之前,比枢纽大的数放在枢纽之后。
2.1 递归实现
- quickSort函数、递归调用。
- findIndex函数 找到枢纽值合适的下标索引,并整理好数组。
1 | class Solution { |
3、堆排序
堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的 值,称为大顶堆;或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆。堆排序(Heap Sort ) 就是利用堆(假设利用大顶堆)进行排序的方法。
基本思想是,将待排序的序列构造成一个大顶堆。此时,整个序列的最大值就是堆顶的根结点。将它移走(其实就是将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的n - 1 个序列重新构造成一个堆,这样就会得到n 个元素中的次大值。如此反复执行,便能得到一个有序序列了。
3.1 堆排序的性能
堆排序的时间复杂度为O(nlogn)。由于堆排序对原始记录的排序 状态并不敏感,因此它无论是最好、最坏和平均时间复杂度均为O(nlogn)。
空间复杂度上,它只有一个用来交换的暂存单元,也非常的不错。不过由于记录 的比较与交换是跳跃式进行,因此堆排序也是一种不稳定的排序方法。
由于初始构建堆所需的比较次数较多,因此,它并不适合待排序序列个数 较少的情况
3.2 代码实现
主要要做的两个工作:1、将一个无序序列构成一个堆;2、在交换堆顶元素之后,如何调整剩余元素成为一个堆。(交换堆顶元素:每次将最大、次大的放到数组的n-1、n-2位置上,所以最后数组就有序了)
- 构建大顶堆
- 用到一个for循环,循环每个有子节点的结点,即从
i=length/2;i>0;i--
- 每次循环调用
heapAdjust(nums,i,length)
,,每一次循环都将当前结点子树的最大值交换到这个结点上来了!!
- 用到一个for循环,循环每个有子节点的结点,即从
- 调整剩余元素成为一个堆
heapAdjust
函数!!一个循环,循环从当前结点的左子节点开始,每次递增二,,讲不太清楚、这个函数最关键。
代码实现:
1 | class Solution { |
4、冒泡排序
一个位置进行一次遍历,每次遍历从后往前,交换相邻两元素,将最小的值放到该位置.
1 | for(int i=1;i<length;i++){ |
优化后的冒泡排序
为了避免已经有序的数列重复循环比较、引入一个标记变量flag,如果有数据交换置为true,为真才进行下一次循环。如果flag仍为false表示i位置之后是有序的,外层循环就不必继续了,此时的数组就是最终的结果
复杂度分析
- 最坏的情况是O(n2),挺低效的。
4.1 代码实现
1 | package com.leetcode.sort; |
5、插入排序
直接插入排序的基本操作是将一个记录插入到已经排 好序的有序表中,从而得到一个新的、记录数增1 的有序表。
需要一个多余的辅助空间、如果要把最后面的元素移到前面来,每一个元素都要挪一次位置。
平均比较和移动的次数为n^2/4。时间复杂度也是O(n2)。但比冒泡排序和简单选择排序性能好一点。
5.1 代码实现
1 | package com.leetcode.sort; |
2 排序算法的性能和稳定性分析
2.1 排序算法的时间复杂度
这个结论三岁蒙童都应该知道吧,经典排序O(NlogN),基础排序O(N^2)。但是为什么呢?要分析分析
对于冒泡、插入和选择,他们的最坏和平均时间复杂度都是O(N^2),因为都是双重循环,这点没什么疑问。特别的对于插入排序和冒泡排序(含flag标识),如果数组本身就是有序的,那么最好的时间复杂度是O(N),因为只用搜索一次,根本就不需要插入或者交换。
所以主要看三个经典排序:归并、快排、堆排序的时间复杂度
1 归并排序:
一共只需要归并O(logN)次,每次归并需要比较N次,所以时间复杂度是O(NlogN)。归并排序的最好、最坏、平均的时间复杂度都是O(NlogN)。归并时需要用到一个临时数组,因此认为需要O(N)的辅助空间。
2 堆排序:
初始建堆的时间复杂度是O(N),每次调整堆的时间复杂度是O(logN),共需要调整N次,所以时间复杂度是O(NlogN)。同样堆排序的最好、最差、平均时间复杂度也都是O(NlogN)。堆排序不需要递归,除去堆本身占用的空间、只使用一个交换单元,空间复杂度是O(1)。
3 快速排序:
快排的时间复杂度可以这么理解:
- 最优的情况下,每次取得的基准都能平分两个数组,这样跟归并排序一样、一共需要O(logN)次递归。然后每次递归,整理数组将比基准小的元素放到基准之前的复杂度是O(N)…所以最优的时间复杂度是O(NlogN)
- 最坏的情况下,每次取得的基准取到最大(小)的数,每次都只排好了一个元素。这样就需要O(N)次递归,所以相应的时间复杂度就是O(N^2)….这样的情况肯定是不多的,但是在数组本身是有序的情况下,每次取基准时就会出现这样的情况!!
- 平均时间复杂度还是O(NlogN)的。平均空间复杂度递归栈的大小O(logN)
2.2 排序算法的稳定性
“假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。“ [百度百科]
稳定性,简单的来讲就是相同的元素,在排序前后,其相对位置是一致的!!本来在前的排完序之后依然在前面
1 冒泡排序(稳定)
每次比较相邻的两个元素,把较小的移到前面去,如果两元素相等肯定不会傻到多交换一次。所以是稳定的。
2 插入排序(稳定)
每次先比较当前元素与前一个元素的大小,只有当前元素小于前一个元素,才会将当前元素插入到前面那个已经排好序的序列中,元素相同,先插入的元素还是在前面。所以也是稳定的。(因为根本就没有交换的过程,只有插入、根本不可能乱序)
3 选择排序(不稳定)
选择排序,每次从当前位置向后遍历无序的部分,将无序部分最小的那个元素交换到当前位置上来。交换的过程可能将前面的元素交换到了较后的位置。举个栗子:序列4 6 4 2 5,第一次交换将第一个4与2交换了,改变了两个4之前的相对位置。因此是不稳定的!!
4 快速排序(不稳定)
快排典型的不稳定,快排之所以不稳定的原因,在于最后移动基准的时候,将基准移到了和基准相同的元素的前面(导致顺序发生了变化)。举个例子:1 2 6 7 8 5 9 5,选择最后一个5作为基准的话,一趟排序之后,会将6与基准5的位置交换,也就改变了两个5之前的先后关系。
5 堆排序(不稳定)
可以这么说,1 最初从数组创建堆的过程,原始序列中项目顺序的信息可能就丢失了。2另外,在调整堆的过程中,数据的先后顺序也极有可能出现改变。因此堆排序是不稳定的。
举个栗子:{2a,2b,2b}三个2的数组这里用abc标识最初的顺序。建堆的过程中已经有序不调整,然后我们会将2a移动到数组末尾(作为最大的元素)…显然这时数据的先后关系已经改变了。
在举一个栗子:原始序列:{1、5、2a,3、2b,6、2c};建堆之后的序列:{1、2b,2a,3、5、6、2c};堆排序之后的序列:{1、2c,2b,2a,3、5、6}….显而易见,顺序改变了。
6 归并排序(稳定)
归并排序只需要考虑两两归并的过程中,顺序有没有可能改变。显然两个本来子序列,只要归并出现相对的元素时,先选择前一个子序列的元素,就可以保证相同元素的顺序不变!!因此归并排序是稳定的。(换句话说,只要没有交换,就一定是稳定的…..我暂时这么理解、不知道对不对)
2.3 排序算法的选择
这里没有看到讲的特别好的博客,自己随便扯一扯
1 对于小数据量或者基本有序的数据,可以使用插入排序或者冒泡排序,(千万别快排)
2 对于排序稳定性有要求、并且对空间没有限制的情况,用归并排序,归并排序稳定,但需要额外的O(n)空间
3 对于稳定性无要求,并且数据没有基本有序的情况,可以使用快排
4 对于数据量特别大的情况可以使用堆排序,堆排序对数据的是否有序不敏感,并且不用递归、除去堆以外无需要多余的空间。
3 排序的变种力扣题
剑指 Offer 51. 数组中的逆序对
在数组中的两个数字,如果前面一个数字大于后面的数字,则这两个数字组成一个逆序对。输入一个数组,求出这个数组中的逆序对的总数。
示例 1:
输入: [7,5,6,4]
输出: 5
思路:利用归并排序!!
- 两两归并的时候我们可以很方便的计算逆序对的个数,
- 前后两个子序列,如果选择的是前一个子序列的元素,那么就无逆序对(因为前面的>后面的)
- 如果后面子序列的元素的大,说明该元素大于前面序列的
i
到mid
的所有元素,所以逆序对的个数count += (mid-i+1)
代码:建一个全局count就好了….
1 | class Solution { |
215. 数组中的第K个最大元素
给定整数数组 nums 和整数 k,请返回数组中第 k 个最大的元素。
请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。
例:输入: [3,2,1,5,6,4] 和 k = 2 输出: 5
解法一:快速排序!!
思路:
要找k大的数,就是要找n-k+1小的数,就是找n-k索引的数
快速排序,直到基准的索引等于n-k为止。索引小于基准、排基准左边的数;索引大于基准、排基准右边的数
代码:只需要改动quickSort中的内容,加上返回值!
1 | class Solution { |
解法二:堆排序(最小堆)
思路:
用优先队列的思路也很清晰,要找第k大的,就是找n-k+1小的
维护一个容量为k的小顶堆,,先进k个数,剩下的n-k个数依次进入,堆顶一共出了n-k个较小的数,最后堆的堆顶就是第n-k+1小的数
可以先判断是否小于堆顶元素,如果小于堆顶就直接“丢弃”,否则出堆顶然后进该元素
代码:
1 | class Solution { |
剑指 Offer 45. 把数组排成最小的数
输入一个非负整数数组,把数组里所有数字拼接起来排成一个数,打印能拼接出的所有数字中最小的一个。
示例 1: 输入: [10,2] 输出: “102”
示例 2: 输入: [3,30,34,5,9] 输出: “3033459”
解法一:快速排序!!(其他经典排序也可以的)
本质上是要做一个排序:第一位上的数最小的放最前面;第一位相等时,第二位最小的放前面;当位数截断时,该最后一位和相同的后面一位比较
上面是直接能想到的,,但是这个排序就显得很复杂。其实可以等价为 两个数x y拼接:xy或yx,拼接的结果越小,那么就是那个小结果的前后关系
具体可以写一个快速排序,在判断元素大小的部分,使用拼接判断大小的规则。注意需要用到字符串的compareTo()方法
代码:判断条件(nums[j]+""+flag).compareTo(flag+""+nums[j])<0
1 | class Solution { |
解法二:字符串数组的sort()函数
- 直接将数组转为字符串数组,然后调用sort(),并使用lamda表达式,指定排序方式….
- 记住
int
类型数组不能直接用lamda
表达式比较,,只有引用类型的Integer和String才可以
代码
1 | class Solution { |