【数据结构】七种常见的排序
目录
1、排序的概念即运用
1.1、排序的概念
1.2、常见排序算法的分类
2、插入排序
2.1、排序原理
2.2、直接插入排序
2.3、希尔排序(缩小增量排序)
3、选择排序
3.1、直接选择排序
3.2、堆排序
4、选择排序
4.1、冒泡排序
4.2、快速排序
4.2.1、挖坑法实现快速排序
4.2.2、Hoare版实现快速排序
4.2.3、前后指针法实现快速排序
4.2.4、快速排序的优化
4.2.5、非递归实现快速排序
5、归并排序
5.1、递归实现归并排序
5.2、非递归实现归并排序
6、海量数据的排序问题
1、排序的概念即运用
1.1、排序的概念
- 排序就是对数据元素的逻辑顺序或物理顺序的一种重新排列。
- 排成非递减(递增)顺序称作正序,排成非递增(递减)顺序称作逆序。
- 排序的依据是排序码(可重复),即元素或记录中的用于作为排序依据的项。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持 不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
内部排序:数据元素全部放在内存中的排序
外部排序:数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
1.2、常见排序算法的分类
2、插入排序
2.1、排序原理
通过构建有序序列,对未排序数据,在已排序序列中从后向前扫描,找到相应的位置并插入
2.2、直接插入排序
算法实现步骤:
- 将第一个待排序序列第一个元素看做一个有序序列,把第二个元素到最后一个元素当作是未排序序列
- 定义i和j两个变量,i用来遍历未排序序列,j用来遍历已排序的序列,j跟在i下标位置之后,i从头到尾依次扫描未排序序列,将未排序序列当中i下标的数字,与已排好序的序列当中的数字依次进行比较,若未排序序列当中i下标的数字比已排序序列当中j下标的数字小,则将已排序序列当中的数字向后挪动一位,将i下标的数字插入当已排好序的序列中。(如果待插入的元素与有序序列当中的某个元素相等,则将代插入元素插入到相等元素的后面。)
代码实现:
public class Sort {
public static void insertSort(int[] array){
for (int i = 1; i < array.length; i++) {//i是从1位置开始,所以数组长度不需要减1,当i等于数组长度的时候,循环就会结束。
int tmp = array[i];//tmp中存入当前的i下标的值
int j = i-1;//定义一个j,遍历的时候,永远跟在i下标的后边
for (; j>=0; j--) {
if(array[j]>tmp){//如果j下标位置的值,比tmp大
array[j+1]=array[j];//将j位置的值,向前挪放到i的位置,也就是j+1的位置
}else{
//array[j+1] = tmp;
break;
}
}
array[j+1] = tmp;//表示循环结束之后还有将tmp中的值付给j+1的位置。
}
}
}
- 时间复杂度:最坏的情况下,给insertSort传一个逆序的数组,i每遍历一个元素,j相应的向后遍历i次。最后得到等差数列:1+2+3+.....+n,根据等差数列求和公式,可以得到结果为N^2,用O渐近表示法为O(N^2). 最好情况下:传给insertSort的数组为顺序的数组,每次只需要移动i下标,所以它的时间复杂度为O(N).从这里就可以看出,使用直接插入排序,当数据趋于有序的时候,排序的速度会非常快。直接插入排序的使用场景就是,数据基本有序的时候。
- 空间复杂度:O(1),在使用这个方法进行排序的时候,只是使用了三个变量。
- 稳定性:给一组数据{15、15、19、18、28}当i下标指向第二个15,j下标指向第一个15的时候,用下边的条件判断的时候,两个15不会交换位置。这样就判定成直接插入排序是稳定的排序算法。
if(array[j]>tmp){ array[j+1]=array[j]; }
但是用下边的条件判断的时候,两个15会交换位置。
if(array[j] >= tmp){ array[j+1]=array[j]; }
❓❓❓第二种判断条件,会将这个方法定义为不稳定的排序算法,这是为什么呢?
❗❗❗原因在于,如果一个排序算法是稳定的时候,可以实现为不稳定的,
但是当一个算法是不稳定的,就不可能将其实现为稳定的排序算法。
2.3、希尔排序(缩小增量排序)
希尔算法的基本思想:
- 希尔排序是插入排序的优化版本
- 先对数组的子数组(按照一定的规则划定而成)进行排序,使待排序数组变得相对有序,最后再对整个数组及进行一次排序完成整体的排序。当gap > 1时都是预排序,目的时让数组更接近于有序。当gap==1时,数组已接近有序,这样最后进行直接插入排序,这样代码的效率会很快,这样就达到了优化的效果
希尔排序的增量序列问题
到目前为止,尚未有人求得最好的增量序列,但大量的研究已得出一些局部的结论,Shell提出取gap = [n/2],gap = [n/2],直到gap = 1,后来Kunth提出取gap = [gap/3]+1。我们暂时就按照这两种方法来分组。
shellSort(希尔排序)方法的思路理解
shell方法的思路理解
代码示例
//希尔排序
public static void shellSort(int[] array){
int gap = array.length;//用gap定义分组的组数
while(gap > 1){//当组数大于1时,进入循环
shell(array,gap);//shell方法写在前面,那么进入循环之后,先将数组分为数组元素个数的组数
gap /= 2;
}
shell(array,1);//当gap等于1的时候不能进入循环,所以要将分组个数为1的情况,写在循环外,最后将数组整体排序。
}
//插入排序 gap为分组个数,分几组,每组之间的元素间隔为分的组数gap
public static void shell(int[] array,int gap){
for (int i = gap; i < array.length; i++) {//i从gap下标开始,向前依次遍历
int tmp = array[i];//tmp中存入当前的i下标的值
int j = i-gap;//定义一个j,遍历的时候,j下标永远指向同组i下标数组的后一位
for (; j>=0; j-=gap) {//将i下标所指的数据与j下标所指数据相比,j遍历同组数据的时候,向后挪动gap个元素个数
if(array[j]>tmp){//如果j下标位置的值,比tmp大
array[j+gap]=array[j];//将j位置的值,向前挪放到i的位置,也就是j+1的位置
}else{
//array[j+1] = tmp;
break;
}
}
array[j+gap] = tmp;//表示循环结束之后还有将tmp中的值付给j+gap的位置。
}
}
当然shellSort方法还有一种写法
public static void shellSort(int[] array){
int gap = array.length;//用gap定义分组的组数
while(gap > 1){//当组数大于1时,进入循环
gap /= 2;//进入循环之后,首先计算分组个数
shell(array,gap);//调用插入排序方法,对数组进行排序,这里不用在循环外再调用一个shell(插入排序)方法,因为gap等于1的时候,在循环内部。
}
}
- 希尔排序的时间复杂度:无法准确的算出希尔排序的时间复杂度,只能得到一个区间。
O(N^1.3) — O(N^1.5),主要记O(N^1.3),这是平均时间复杂度
- 空间复杂度为:O(1),没有申请额外的空间
- 稳定性:希尔排序是一个不稳定的排序
3、选择排序
选择排序的基本思想:
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完
3.1、直接选择排序
直接选择排序第一种写法
直接选择排序的思路理解
代码示例
public static void selectSort(int[] array){
for (int i = 0; i < array.length; i++) {//i下标指向数组中已排序序列的最后一个元素
int minIndex = i;//假定i下标所指的为最小的值
int j = i+1;//j指向i+1的位置
for (; j < array.length; j++) {
if(array[minIndex]>array[j]) {//当未排序序列当中的值,于i下标的值进行比较,将较小值的下标传给minIndex
minIndex = j;
}
}
swap(array,i,j);//当j将未排序序列的数组遍历完成之后,记录下最小的值的结点,将其于i下标的值进行交换
}
}
//交换位置
private static void swap(int[] array,int i,int j){
int tmp = array[i];
array[i] = array[j];
array[j] = tmp;
}
- 直接选择排序的时间复杂度为:O(N^2),当i遍历一个数据的时候,j要将未排序的数据全部遍历一遍。
- 直接选择排序空间复杂度为:O(1),没有申请额外的空间
- 直接选择排序的稳定性:不稳定
直接选择排序的第二种写法
代码示例
public static void selectSort2(int[] array){
int left = 0;//定义两个指针,指向未排序数组的最开始和结尾
int right = array.length;
while(left < right){
int minIndex = left;//未排序数组的最开始,minIndex与maxIndex同时指向left
int maxIndex = left;
for (int i = left+1; i < right; i++) {
if(array[i]>array[maxIndex]){//判断未排序数列的i下标位置比maxIndex(最大值)下标的值大,将i赋给maxIndex
maxIndex = i;
}
if(array[i]<array[minIndex]){//判断未排序数列的i下标值比minIndex(最小值)下标的值小,将i赋给minIndex
minIndex = i;
}
}
swap(array,minIndex,left);//将最小值,放在数组最前面
if(maxIndex == left){//如果数组中未排序数列的maxIndex与left指向同一个地方,那么在上面的交换中,丢失了最大值,
maxIndex = minIndex;//所以要将交换前最小值的下标传给maxIndex下标,将最大值找到
}
swap(array,maxIndex,right);//然后交换maxIndex与right所指位置的值
left++;//left向前走
right--;//right向后退
}
}
//交换位置
private static void swap(int[] array,int i,int j){
int tmp = array[i];
array[i] = array[j];
array[j] = tmp;
}
两种直接选择排序算法的时间复杂度是相等的,空间复杂度也是相等的,都没有申请多余的空间
3.2、堆排序
堆排序是指利用堆这种数据结构所设计的一种排序算法,它是选择排序的一种,他是通过堆来进行训责数据。
❓❓❓那么这里有一个问题,当我们要将一组数据按照从小到大的顺序进行排序,那么我们采用大根堆好,还是小根堆好??
- ❌这里肯定有很多同学认为使用小根堆好,每次将堆顶元素弹出,放在新的数组中,这样将数据弹完,数据也就有序了,这确实是一种方法,但是这样写这个方法的空间复杂度,会非常大。
- ✅这里我们使用大根堆排序升序(从小到大)的数据,当我们找到数据中的最大值的时候,他一定是在大根堆的堆顶,我们将堆顶元素与最后一个元素交换位置,将堆顶元素放在数组的最后一位。放好之后最后一位不进入接下来的比较,交换。
使用小根堆排序降序(从大到小)的数据,当我们找到最小值的时候,他一定在堆顶,做法与上述相同
❓❓❓很多同学在没有了解堆的概念的话,在看这个代码当中的堆排序(heapSort)方法时,会有这样一个疑问,为什么在将父节点和最后一个结点交换之后,没有对end减一,而是直接向下调整了,这样不会将刚交换的最大值,有放在0下标位置吗??
❗❗❗这里其实是你多虑了,当我们在实现堆排序(heapSort)时,定义的end不仅用来记录最后一个结点的位置,用来将end下标的结点和0下标的结点进行交换,也可以用来记录未排序数列的长度,传给shifDown(向下调整)方法。例如:原数组长为10,最后一个结点的下标为9,将0下标与9下标的值,进行交换,然后调用shifDown方法,传给这个方法的参数为,数组本身,0下标,可以认为是,未排序数列的长度,或者是结束位置是9下标,这样就将第10个已经交换了位置的元素,没有放入向下调整的方法中。
代码实现
//堆排序
public static void heapSort(int[] array){
createBigHeap(array);//创建大根堆
int end = array.length-1;//记录最后一个结点的下标,也是记录未排序数列的长度
while(end > 0){//当end大于0,表示end不是根节点
swap(array,0,end);//交换根结点与孩子结点的位置
shiftDown(array,0,end);//交换完成之后,向下调整,将堆调整成新的大根堆,传给的参数为数组本身,0下标位置,未排序数列的长度,或者是9下标位置,作为向下调整的结束条件
end--;//交换完成之后,最后一个结点不计算在内。
}
}
//创建大根堆
private static void createBigHeap(int[] array){
for (int parent = (array.length-1-1)/2; parent >= 0; parent--) {//建立大根堆,根据求父节点的公式得,当子节点大于0时,父节点为(i-1)/2,i结点为数组长度-1
shiftDown(array,parent,array.length);//向下调整
}
}
//向下调整
private static void shiftDown(int[] array,int parent,int len){//将数组传给这个方法,父节点下标。数组长度
int child = 2*parent+1;//根据传进来得父亲结点得下标,来计算左孩子结点下标,
while(child < len){//这里表示最起码要有左孩子结点得下标,才能进行孩子结点与父节点的比较,交换
if (child + 1 < len && array[child] < array[child+1]) {//表示存在有孩子结点,并且左孩子小于右孩子
child++;//孩子结点挪到右孩子结点处,记录右孩子结点
}
//若没有右孩子,则直接左孩子与父节点比较
//若存在右孩子节点,但是比左孩子结点小,那么不进入上边的判断,child记录的就是最大的孩子结点,直接与父亲结点比较
if(array[child] > array[parent]){//孩子结点比夫妻节点大
swap(array,child,parent);//交换
parent = child;//父节点指向孩子结点,作为新的父节点,进行上述操作
child = 2*parent+1;//孩子结点,重新计算结点下标,这样就实现了向下调整的操作
}else{//如果孩子结点没有父亲结点大,那么就直接结束,不需要调整
break;
}
}
}
画图理解:
- 堆排序的时间复杂度为:O(N*logN)
- 空间复杂度为:O(1)
- 稳定性:不稳定
4、选择排序
基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特 点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
4.1、冒泡排序
代码示例:
//冒泡排序
public static void bubbleSort(int[] array){
for (int i = 0; i < array.length-1; i++) {//冒泡排序的趟数
boolean flg = false;//记录有没有在进行比较
//一趟冒泡排序
for (int j = 0; j < array.length-1-i; j++) {//array.length-1-i表示的是,每进行完一趟,相应的比较次数少i次
if (array[j] > array[j+1]){//如果j下标的数据大于j+1下标的数据,则交换
swap(array,j,j+1);
flg = true;//满足条件,交换之后,flg变为true
}
}
//当在某趟比较的时候j下标的值,比j+1下标的值小,这一趟中没有进行交换,那么表示数组已排序完成,没有必要在进行下一趟比较了
if(flg == false){//flg==false表示j下标的值比j+1位置的值小,排序已完成,
return;
}
}
}
//交换位置
private static void swap(int[] array,int i,int j){
int tmp = array[i];
array[i] = array[j];
array[j] = tmp;
}
}
- 冒泡排序的时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:稳定
4.2、快速排序
基本思想:任取待排序元素序列中的某个元素作为基准值,设置两个指针遍历数组,一个找比基准值大的数据,一个找比基准值小的数据,将数组中比基准值小的都向前挪动,比基准值大的都向后移动。最终设置的两个指针相遇,将基准值放在两指针相遇的位置,就将数组遍历完成。然后将待排序数组,根据基准值位置分为两个子序列,重复以上操作,直到所有元素都有序。
4.2.1、挖坑法实现快速排序
画图解释:
代码示例:
//快速排序
public static void quickSort(int[] array){
quick(array,0,array.length-1);//传给quick方法 数组 0下标 数组结尾下标
}
//划分
public static void quick(int[] array,int start,int end){//start指向数组的0下标,end指向数组的结尾
if(start >= end){//倘若数组是顺序排序的,当在找基准的时候left和right相遇,则基
//准值的位置为这个位置,这样就形成了单分支的二叉树形式,基准值的右边递归的时候,子数组的
//开始位置为pivot+1,但是pivot在end的位置上,这样就导致了start大于end位置,这样就会出现
//错误,应该避免这个问题。
return;//则递归结束
}
int pivot = partition(array,start,end);//用pivot指向基准值,基准值为start与end共同指向的位置的值
//递归基准值的左边
quick(array,start,pivot-1);//在quick当前这个方法中,start指向0下标,找到基准值之后,0下标位置为start,结尾下标为基准值的前一位
//递归基准值的右边
quick(array,pivot+1,end);//在quick当前这个方法中,end指向数组的结尾,所以递归基准值的右边的时候,开始位置为pivot+1的位置
}
//挖坑法(找基准并且排序)
private static int partition(int[] array,int left,int right){//接收传过来的数组,并用left指向数组的最开始,right指向数组的结尾
int tmp = array[left];//每次找基准以left所指位置下标的值作为标准
while(left < right){//这层循环确保left和right两个指针将数组遍历完
//让right下标的值与tmp中的值比较,right的值大,则right向前移动,若right的值小,则不进入循环,将right所指的值放在left所指的位置,找到比tmp小的值,right就会停止移动
while(left < right && array[right] >= tmp){//这里要确保right不能越界,当right所指的值,一直都比tmp大时,right一直向前挪动,最后会指向-1,就会发生越界,那么就要right不能小于等于left
right--;
}
array[left] = array[right];
//让left下标的值与tmp比较,left的值比tmp的小,则一直向前挪动,直到left指向的值比tmp的值大,不进入循环,将left所指的值,放入刚刚right的位置,
while(left < right && array[left] <= tmp){//这里要确保left不会越界,就要在设置一个条件就是left的下标位置不能大于等于right的下标,否则就会发生越界
left++;
}
array[right] = array[left];
}
//当left和right相遇不进入循环,将tmp的值,放在最后一个坑中,无论是将tmp中的值,给left所指的位置还是right所指的位置都一样
array[left] = tmp;
return left;//基准值就是left与right相遇的位置,将其返回给方法的调用者
}
❗❗❗注意事项:
❓❓❓在quick方法中写到递归的结束条件(start >= end) ,为什么取(=),很好理解,当只有一个元素的时候,递归结束。但是为什么取(>),还是不太好理解的。这里我们来了解一下。
❗❗❗取(>)的作用是,当数组为有序的情况下,递归的时候,会出现单分支的情况,基准值的位置被partition方法确定为最左侧或者是最右侧的时候,下一步递归的时候,指定子序列的范围时,有可能end会越界成为-1,导致start大于end,也有可能start会越界,以10个数组元素为例,start成为10,导致start大于end.
❓❓❓这里提出一个问题,我们能将partition方法中的第2和第3个while循环中的判断条件中的=去掉吗???
while(left < right && array[right] > tmp){ right--; } array[left] = array[right]; while(left < right && array[left] < tmp){ left++; }
❗❗❗答案是不能的,当去掉之后,遇到这种情况就会陷入死循环
4.2.2、Hoare版实现快速排序
画图理解
代码实现
//快速排序
public static void quickSort(int[] array){
quick(array,0,array.length-1);//传给quick方法 数组 0下标 数组结尾下标
}
//划分
public static void quick(int[] array,int start,int end){//start指向数组的0下标,end指向数组的结尾
if(start >= end){//倘若start所指的下标大于等于end所指的下标
return;//则递归结束
}
int pivot = partition(array,start,end);//用pivot指向基准值,基准值为start与end共同指向的位置的值
//递归基准值的左边
quick(array,start,pivot-1);//在quick当前这个方法中,start指向0下标,找到基准值之后,0下标位置为start,结尾下标为基准值的前一位
//递归基准值的右边
quick(array,pivot+1,end);//在quick当前这个方法中,end指向数组的结尾,所以递归基准值的右边的时候,开始位置为pivot+1的位置
}
//Hoare版(找基准并排序)
private static int partition2(int[] array,int left,int right) {
int tmp = array[left];
int i = left;//用i记录基准值的下标,方便下边的left和right相遇时,交换基准值的位置
while(left<right){
while(left < right && array[right] >= tmp){//遍历到的值比tmp大,进入循环,将right向前移动一位;若是找到比tmp小的不进入循环,停止,right将这个值记录
right--;
}
while(left < right && array[left] <= tmp){//遍历到的值比tmp小,进入循环,将left向后移动一位;若找到比tmp大的不进入循环,停止,left将这个值记录
left++;
}
swap(array,left,right);//然后将left和right记录的下标传给交换函数,将比基准值大的放在后面,比基准值小的放在前面
}
//当left和right指针相遇,则交换基准值和两指针指向的位置的值
swap(array,left,i);//当两个指针相遇,不论那谁和i交换值都可以
return left;//基准值就是left与right相遇的位置,将其返回给方法的调用者
}
❓❓❓这里又产生一个问题,不论是以挖坑法找到基准值位置,还是以Hoare方法找到基准值位置,当以左边位置第一个元素作为key(基准值)时,都要先从右边开始找比基准值小的数据???
4.2.3、前后指针法实现快速排序
基本思路:定义基准值为left所指位置的值,定义prev和cur两个指针,起始prev指向0下标位置,cur指向prev的下一个位置,我们规定,prev每次都要指向从左到它本身之间最后一个小于基准值的数,如果cur的值大于基准值,这时只让cur++,如果cur指向的位置小于基准值,这是我们让prev向前移动一位,判断是否与cur的位置相等或者prev与cur所指位置的值是否相等,若不相等,则交换cur和prev的值。直到cur > right后,我们再将prev和基准值交换位置,这样我们就确定了基准值的位置
画图理解代码思路:
代码示例
private static int partition3(int[] array,int left,int right){
int prev = left;
int cur = left+1;
while(cur <= right){//遍历当cur超过数组范围时,结束循环
if(array[cur] < array[left] && array[++prev] != array[cur]){//如果cur遍历到的值小于基准值时,prev先向前移动一位,再判断prev与cur是否相遇,或者两个指针所指的值是否相等
swap(array,cur,prev);//若cur值小于基准值并且cur和prev没有相遇,两个指针所指的值不相等,则进入进行交换
}
//否则,cur向前移动
cur++;
}
//循环结束后,交换prev和基准值的位置
swap(array,prev,left);
return prev;//最终返回基准值的位置
}
- 时间复杂度:快速排序的时间复杂度,主要还是要看quick这个方法,找到基准值之后,他每次递归,都会从基准值的位置将数组划分为左右两个子数组。
- 上述图片中计算的时间复杂度是,快速排序最好情况下的时间复杂度
- 快速排序与其他的排序不同,当你给快速排序一个顺序排序的数列的时候,他会形成一个单分支的二叉树,那么树的高度就为n,它的时间复杂度就为N^2,这是它最坏情况下的时间复杂度。
- 空间复杂度:
- 最好的情况,那就是可以将数组分为满二叉树的情况,也就是说,可以均与分割数组,左树和右树高度相同,二叉树再递归的时候,是先递归左数再递归右数,左树递归完成之后,空间会被回收,所以它的空间复杂度为要么为左树的高度要么是右树的高度。所以空间复杂度为logN,
- 最坏的情况是,数组为有序数列,这样就会形成单分支的树,那么它的空间复杂度为N。
- 稳定性:不稳定
4.2.4、快速排序的优化
当快速排序以最坏的情况运行时,idea默认分配给栈的空间不大,我们以最坏的情况(单分支)运行,如果数据过大,这样就会导致栈溢出异常,针对这样的问题我们对快速排序做出了优化。
这里有两种方法,
- 随机选取基准法
- 三数取中法
随机选取基准法,更多的还是要看运气,这里我们主要了解三数取中法
如果这个数列是非常无序,杂乱无章的,那么快速排序的效率是非常高的,可是如果数列有序,往往快速排序的时间复杂度便由O(nlog2n)退化到O(n ^2),即相当于冒泡排序。所以,我们需要优化快速排序,这里用到的便是三数取中
三数取中法的基本思想:
- 取数组的首中尾位置的元素
- 找出三个数的中间大小的数
- 将中间大小的数作为基准值
代码示例:
//划分
public static void quick1(int[] array,int start,int end){//start指向数组的0下标,end指向数组的结尾
if(start >= end){//倘若start所指的下标大于等于end所指的下标
return;//则递归结束
}
//三数取中法
int index = midThree(array,start,end);//找到三个数中,中间大小的值
swap(array,index,start);//交换中间大小的值,到数组的起始位置,作为基准值
int pivot = partition(array,start,end);//用pivot指向基准值,基准值为start与end共同指向的位置的值
//递归基准值的左边
quick(array,start,pivot-1);//在quick当前这个方法中,start指向0下标,找到基准值之后,0下标位置为start,结尾下标为基准值的前一位
//递归基准值的右边
quick(array,pivot+1,end);//在quick当前这个方法中,end指向数组的结尾,所以递归基准值的右边的时候,开始位置为pivot+1的位置
}
//三数取中
private static int midThree(int[] array,int left,int right){
int mid = (left+right)/2;//找到中间位置
//判断left、right和mid所指的三个值的大小,中间大小的作为基准值
if(left < right){//当right大于left的情况
//下边的判断条件不能发生变换,要用前两个判断条件约束最后一个else
if(array[left]>array[mid]){
return left;//中间位置为left下标的值
}else if(array[right]<array[mid]){
return right;//中间位置为right下标的值
}else{
return mid;//最后mid的值不大于right也不小于left,为中间的值
}
}else{//当left大于right的情况
if(array[left]<array[mid]){
return left;
}else if(array[right]>array[mid]){
return right;
}else{
return mid;
}
}
}
还有一种优化方式,我们以下面的图为例,共有16个结点,但是后两层就占了12个结点,我们为了优化快排,后两层有12个结点,就意味着要递归12次,但是我们由知道快速排序递归的越往下,数据越有序,所以我们调用直接插入排序,对快速排序的次数进行优化,减少递归次数。
还是以上图为例,再拆分的环节中,规定当它的子数组元素个数小于多少的时候,我们采用直接插入排序的方式,将子树组中已经趋于有序的数据进行排序。当然调用直接插入排序,还是要将直接插入排序进行改动一下,将数组传给直接插入排序的时候,要给数组规定区间。若不给定区间的话,那就是将所有的数据进行直接插入排序了,那样就没有意义了。我们使用直接插入排序对快速排序进行优化的时候,是在数组的某个子区间内进行的,所以要给直接插入排序一个区间。
public static void quick3(int[] array,int start,int end){//start指向数组的0下标,end指向数组的结尾 if(start >= end){//倘若start所指的下标大于等于end所指的下标 return;//则递归结束 } if((end-start+1)<= 14){//这里用子数组结束位置下标-开始位置下标+1:表示子数组的元素个数 //调用直接插入排序 insertSort1(array,start,end);//传给直接插入排序子数组,开始位置,和结束位置(区间) return;//满足条件进入之后,用直接插入排序排完之后,不会再向下递归了。 } int pivot = partition(array,start,end);//用pivot指向基准值,基准值为start与end共同指向的位置的值 //递归基准值的左边 quick(array,start,pivot-1);//在quick当前这个方法中,start指向0下标,找到基准值之后,0下标位置为start,结尾下标为基准值的前一位 //递归基准值的右边 quick(array,pivot+1,end);//在quick当前这个方法中,end指向数组的结尾,所以递归基准值的右边的时候,开始位置为pivot+1的位置 } private static void insertSort1(int[] array,int left,int right){ for (int i = left+1; i <= right; i++) { int tmp = array[i];//tmp中存入当前的i下标的值 int j = i-1;//定义一个j,遍历的时候,永远跟在i下标的后边 for (; j>=left; j--) { if(array[j]>tmp){//如果j下标位置的值,比tmp大 array[j+1]=array[j];//将j位置的值,向前挪放到i的位置,也就是j+1的位置 }else{ //array[j+1] = tmp; break; } } array[j+1] = tmp;//表示循环结束之后还有将tmp中的值付给j+1的位置。 } }
4.2.5、非递归实现快速排序
算法思路:
代码示例:
public static void quickSort2(int[] array){
Deque<Integer> stack = new LinkedList<>();//示例化一个栈
int left = 0;
int right = array.length-1;
int pivot = partition(array,left,right);//第一次找基准值
if(pivot > left+1){//找到的基准值左侧最起码要有两个值
stack.push(left);
stack.push(pivot-1);//将基准值左侧的一个数据作为左侧结束位置
}
if (pivot < right - 1){//找到的基准值右侧最起码要有两个值
stack.push(pivot+1);//将基准值右侧的一个数据作为右侧开始位置
stack.push(right);
}
//到这里,栈中已经存在4个数据了
while(!stack.isEmpty()){//以栈为空作为结束条件
right = stack.pop();
left = stack.pop();
pivot = partition(array,left,right);
if(pivot > left+1){
stack.push(left);
stack.push(pivot-1);
}
if (pivot < right - 1){
stack.push(pivot+1);
stack.push(right);
}
}
}
❗❗❗注意:上述代码中的两个算法
pivot > left+1
pivot < right - 1
第一个是判断基准值左侧至少有两个数。这种写法,无论left是几,都可用。判读右侧同理
5、归并排序
5.1、递归实现归并排序
算法思路:
- 将长度为n的序列,通过找到数组中间位置mid,将其以mid的位置分为两个子序列(左边以mid位置结尾,右边以mid+1位置开始),以这种思路,直到拆解为n个长度为1的序列为止。
- 将序列两两进行合并,合并时将较小的元素放在前面,直到合并为有序序列长度为n时结束。
递归拆解的思路:
- 给定一个有n个元素的数组
- 取中间位置(int mid = (left+right)/2)。left:为起始位置的下标,right:为结束位置下标,mid为数组的分解点。
- 将其拆解成为左右两部分后,在对左右侧子序列,进行相同的拆解,左侧区间为(left,mid),右侧区间为(mid+1,right)
- 直到left和right相遇(只有一个元素的时候)拆解结束.
递归合并的思路:
- 将拆解完成的序列,两两合并,合并的时候每层递归都只走调用marge部分,marge上边的代码在递的时候已经走过了。
- 要确定待排序两个有序子序列的范围,所以我们要传入left,right,mid来确定
- 合并的规则时,设置两个指针s1和s2分别指向两个子序列的开始位置。用s1和s2遍历两个子序列
- 定义一个与两个子序列元素个数的和相同的数组tmp,用来存放排完序的有序序列。在定义数组的时候同时定义一个变量k,用来记录数组元素的下标。(int[] tmp = new int[right - left+1]表示的意思为虽然认为是两个子序列,但是在分解的时候,将他们的开始位置下标和结束位置下标在原数组中的位置传给了他们。)
- 假设排序是,从小到大的顺序,如果s1指向的数据小于s2指向的数据,则放入数组,s1++,记录数组tmp下标的k也向前挪动一位。如果s1 = mid,则这个子序列遍历完。s2也是同理,s2 = right,s2也遍历完成。
- 如果存在一个子序列当中的元素比里一个子序元素小,那么有一个子序列首先会被遍历完,一个子序列中还剩余多个元素,这样就需要再写一个循环将子序列当中剩余元素依次放入数组tmp当中。
- 最终将数组tmp中的内容拷贝到本层递归的array数组当中。
代码示例:
public static void mergeSort(int[] array){
mergeSortFunc(array,0,array.length-1);
}
//分割
private static void mergeSortFunc(int[] array,int left,int right){
if (left >= right){
return;
}
int mid = (left+right)/2;//找出每次数组的中间位置
//分割中间位置左边的子数组
mergeSortFunc(array,left,mid);//将数组传给这个方法,在栈上开辟空间,将栈上的数组进行分割,mid作为左边子数组的结束位置
mergeSortFunc(array,mid+1,right);
//将数组的左右子数组都分割完之后,合并
merge(array,left,right,mid);//将本层对应的中间值传给merge方法,在merge方法中用来确定两个子序列的开始位置,和结束位置
}
//合并
private static void merge(int[] array,int start,int end,int mid){
int s1 = start;//数组的开始位置和结束位置不动,用两个变量来遍历数组
//int e1 = mid;
int s2 = mid+1;
//int e2 = end;
int[] tmp = new int[end - start + 1];//定义一个与每层合并后元素个数相同的数组
int k = 0;//表示tmp数组的下标,添加一个元素+1
//s1 = mid时,数组中只有一个元素或者是在排序的时候s1向前挪动,走到了end位置,s2同理
while(s1 <= mid && s2 <= end){
if(array[s1] <= array[s2]){//s1和s2进行比较,s1的值小,放入tmp中,tmp中记录数组下标的k向前移动,s1也想前移动
tmp[k++] = array[s1++];
}else{//如果s2小,s2放入tmp数组中
tmp[k++] = array[s2++];
}
}
//通过比较,若一个数组s1中的元素较小,一个s2中的较大,刚好s1全部已经放完,那么就将s2中的剩余元素依次放入tmp数组中
while(s1 <= mid){//表示s1数组中有剩余元素
tmp[k++] = array[s1++];
}
while(s2 <= end){//表示s2数组中有剩余元素
tmp[k++] = array[s2++];
}
//当将所有的元素都放入tmp数组中,将tmp中的数组元素拷贝到递归本层的原数组中
for (int i = 0; i < tmp.length; i++) {
array[i+start] = tmp[i];//分割的时候,中间值右边的子数组,开始位置并不是从0下标开始的,所以我们要从start的位置开始拷贝
}
}
- 时间复杂度为:当一个数组有n个元素,在递归的过程中,每层递归的区间总体来所都是n。递归的二叉树的高度为logN,所以它的时间复杂度为:O(NlogN).
- 空间复杂度: 我们在归并的时候,每层都会申请一个空间,在最坏的情况下,就是归并到倒数第二层的时候,会申请一个与原数组一样大小的数组空间。所以它的空间复杂度为:O(N)
- 稳定性: 稳定的排序,在上述的merge方法中,当s1指向的值<=s2指向的值得时候,tmp数组中放入s1的值。s1所指的位置为start,s2所指的位置为mid+1,从区间上来看s1遍历的区间,在s2遍历的区间之前,所以当s1与s2所指位置的值相同时,原本数组中,s1所指的值在s2所指的值的前面,并没有调换位置。当然将=去掉,就会调换位置,从这里来看它满足稳定性的概念。
5.2、非递归实现归并排序
-
在数组初始状态时,两两合并,合并用到的算法是“合并有序的数组 merge”。即每次合并得到的都是有序的数组。
两两合并的规则是:将两个相同序列长度的序列进行合并。
第一趟合并(merge 1)调用了4次merge ,得到了4个有序的数组:"6,10","1,7","3,9","2,4"(每个合并后的序列长度为2)
第二趟合并(merge 2)调用了2次merge,得到了2个有序的数组:"1,6,7,10","2,3,4,9''(每个合并后的序列长度为4)
-
按步骤1的思想以此类推,经过多次合并最终得到有序的数组.
可以看出经过一共3趟合并,最终得到有序的数组。
可以看出每趟要执行的合并次数不同,第一趟合并执行4次,第二趟合并执行2次,第三趟合并行1次。
将单独元素两两合并之后,形成第一层,第一层要合并为第二层,规定第一层的区间为 (left,mid),(mid+1,right)。
图中i用作遍历,两两一组合并,当(6,10)与(1,7)调用merge合并完成之后,i遍历到(3,9)和(2,4).i每次需要向前移动 i = i+gap*2个位置。
left指向i指向的位置,right指向的位置为mid+gap位置,mid的计算方式为left+gap-1.
代码示例:
//非递归实现归并排序
public static void mergeSort1(int[] array){
int gap = 1;//认为传给这个方法的数组每个元素都是一个整体,每个整体都有一个元素。
while(gap < array.length){//当gap等于数组长度时,数组已经有序,循环结束
for (int i = 0; i < array.length; i += gap*2) {//两两一组,进行合并,一组合并完成之后,i向后移动gap*2的长度
int left = i;//left跟随i指向每组合并的两个元素的开始位置
int mid = left+gap-1;
if(mid >= array.length){//如果mid越界,这里就需要修正一下,让mid指向数组的最后一位
mid = array.length-1;
}
int right = mid+gap;
if(right >= array.length){//同样的right也要防止越界,若越界之后,要将其修正回来
right= array.length-1;
}
//每层两两一组排完序之后,合并这一层的下一组
merge(array,left,right,mid);
}
gap *= 2;//每层交换完成之后,每组之中元素个数乘2。
}
}
注意事项:
6、海量数据的排序问题
- 外部排序:排序过程需要在磁盘等外部存储进行的排序
- 前提:内存只有 1G,需要排序的数据有 100G
因为内存中因为无法把所有数据全部放下,所以需要外部排序,而归并排序是最常用的外部排序
- 先把文件切分成 200 份,每个 512 M
- 分别对 512 M 排序,因为内存已经可以放的下,所以任意排序方式都可以
- 进行 2路归并,同时对 200 份有序文件做归并过程,最终结果就有序了