【尚硅谷_数据结构与算法】六、十大经典排序算法解析与java/c++实现

参考资料

  1. 十大经典排序算法动画与解析,看我就够了!(配代码完全版)
  2. https://www.bilibili.com/video/BV1Kb411W75N
  3. 归并排序
  4. 图解排序算法(四)之归并排序
  5. 菜鸟教程
  6. 计数排序
  7. 图解桶排序

0. 基本概念

  1. 排序:是计算机程序设计中的一项重要操作,其功能是指一个数据元素集合或序列重新排列成一个按数据元素某个数据项值有序的序列。
  2. 排序码(关键码):排序依据的数据项。
  3. 稳定排序:排序前与排序后相同关键码元素间的位置关系,保持一致的排序方法。
  4. 不稳定排序:排序前与排序后相同关键码元素间的相对位置发生改变的排序方法
  5. 排序分为两类:
    1. 内排序:指待排序列完全存放在内存中所进行的排序。内排序大致可分为五类:插入排序、交换排序、选择排序、归并排序和分配排序。
    2. 外排序:指数据量过大,无法全部加载到内存中,排序过程中还需访问外存储器的排序。

算法的时间复杂度

时间频度T(n)

  • 一个算法执行所耗费的时间,从理论上是不能算出来的,必须上机运行测试才能知道。但我们不可能也没有必要对每个算法都上机测试,只需知道哪个算法花费的时间多,哪个算法花费的时间少就可以了。并且一个算法花费的时间与算法中语句的执行次数成正比例,哪个算法中语句执行次数多,它花费时间就多。一个算法中的语句执行次数称为语句频度或时间频度。记为T(n)

时间复杂度O(n)

  • 一般情况下,算法中基本操作重复执行的次数是问题规模n的某个函数,用T(n)表示,若有某个辅助函数f(n),使得当n趋近于无穷大时,T(n)/f(n)的极限值为不等于零的常数,则称f(n)是T(n)的同数量级函数。记作T(n)=O(f(n)),称O(f(n)) 为算法的渐进时间复杂度,简称时间复杂度。
  • 在T(n)=4n²-2n+2中,就有f(n)=n²,使得T(n)/f(n)的极限值为4,那么O(f(n)),也就是时间复杂度为O(n²)
  • 对于不是只有常数的时间复杂度忽略时间频度的系数、低次项常数
  • 对于只有常数的时间复杂度,将常数看为1.

常见的时间复杂度

常数阶 O ( 1 ) O(1) O(1)

int i = 1;
i++;

无论代码执行了多少行,只要没有循环等复杂的结构,时间复杂度都是O(1)

对数阶 O ( l o g 2 n ) O(log_2n) O(log2n)

while(i<n) {
    i = i*2;
}

此处i并不是依次递增到n,而是每次都以倍数增长。假设循环了x次后i大于n。则 2 x = n 2^x = n 2x=n x = l o g 2 n x=log_2n x=log2n

线性阶 O ( n ) O(n) O(n)

for(int i = 0; i<n; i++) {
	i++;
}

这其中,循环体中的代码会执行n+1次,时间复杂度为 O ( n ) O(n) O(n)

线性对数阶 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)

for(int i = 0; i<n; i++) {
    j = 1;
	while(j<n) {
		j = j*2;
	}
}

此处外部为一个循环,循环了n次。内部也是一个循环,但内部f循环的时间复杂度是 l o g 2 n log_2n log2n
所以总体的时间复杂度为线性对数阶 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)

平方阶 O ( n 2 ) O(n^2) O(n2)

for(int i = 0; i<n; i++) {
	for(int j = 0; j<n; j++) {
		//循环体
	}
}

立方阶 O ( n 3 ) O(n^3) O(n3)

for(int i = 0; i<n; i++) {
	for(int j = 0; j<n; j++) {
		for(int k = 0; k<n; k++) {
			//循环体
		}
	}
}

可以看出平方阶、立方阶的复杂度主要是否循环嵌套了几层来决定的.

常见时间复杂度比较

常见的算法时间复杂度由小到大依次为: 0 ( 1 ) < O ( l o g 2 n ) < O ( n ) < O ( n l o g 2 n ) < 0 ( n 2 ) < o ( n 3 ) < O ( n k ) < O ( 2 n ) 0(1)<O(log_2n)<O(n)<O(nlog_2n)<0(n^2)<o(n^3)< O(n^k) < O(2^n) 0(1)<O(log2n)<O(n)<O(nlog2n)<0(n2)<o(n3)<O(nk)<O(2n) ,随着问题规模n的不断增大,上述时间复杂度不断增大,算法的执行效率越低。

平均时间复杂度和最坏时间复杂度

  1. 平均时间复杂度是指所有可能的输入实例均以等概率出现的情况下,该算法的运行时间。
  2. 最坏情况下的时间复杂度称最坏时间复杂度。一般讨论的时间复杂度均是最坏情况下的时间复杂度。这样做的
    原因是:最坏情况下的时间复杂度是算法在任何输入实例上运行时间的界限,这就保证了算法的运行时间不会比最坏情况更长。
  3. 平均时间复杂度和最坏时间复杂度是否一致,和算法有关.

空间复杂度

  1. 类似于时间复杂度的讨论,一个算法的空间复杂度(Space Complexity)定义为该算法所耗费的存储空间,它也是
    问题规模n的函数。
  2. 空间复杂度(Space Complexity)是对一个算法在运行过程中临时占用存储空间大小的量度。有的算法需要占用的
    临时工作单元数与解决问题的规模n有关,它随着n的增大而增大,当n较大时,将占用较多的存储单元,例如快速排序和归并排序算法,基数排序就属于这种情况
  3. 在做算法分析时,主要讨论的是时间复杂度。从用户使用体验上看,更看重的程序执行的速度。一些缓存产品(redis, memcache)和算法(基数排序)本质就是用空间换时间.

10大经典排序算法比较

在这里插入图片描述

1. 插入排序

  • 基本思想
    每次将一个待排序的元素,按其关键字的大小插入到前面已经排好序的子文件的适当位置,直到全部记录插入完成为止。

1.1 直接插入排序

1.1.1 基本思想

n个待排序的元素看成为一个有序表和一个无序表,开始时有序表中只包含1个元素,无序表中包含有n-1个元素,排序过程中每次从无序表中取出第一个元素,把它的排序码依次与有序表元素的排序码进行比较,将它插入到有序表中的适当位置,使之成为新的有序表。
在这里插入图片描述

  • 例如, n=6,数组R的六个排序码分别为:[ 17, 3, 25, 14, 20, 9], 它的直接插入排序的执行过程如下:
    在这里插入图片描述
    java实现
package pers.chh3213.sort;

import java.util.Arrays;

/**
* DirectInsertSort.java
* @Description 直接插入法
* @author chh3213
* @version
* @date 2021年12月26日上午11:19:07
 */
public class DirectInsertSort {
	public static void main(String[] args) {
		DirectInsertSort insertSort = new DirectInsertSort();
		int[] arr = {9, -16, 310, 23, -30, -49, 25, 21, 30};
		insertSort.directInsertSort(arr);
		System.out.println(Arrays.toString(arr));
	}
	public void directInsertSort(int[] arr) {
		// 法一
		for (int i = 1; i < arr.length; i++) {
				int temp = arr[i];           // 记录要插入的数据
				int j=i;
				for ( ; j>0&&arr[j-1]>temp; j--) {// 从已经排序的序列最右边的开始比较,找到比其小的数
					arr[j]=arr[j-1]; //元素后移
				}
				if(j!=i)arr[j]=temp;	
		}
		//法二
//		for (int i = 1; i < arr.length; i++) {
//			for(int j=i;j>0;j--) {
//				if(arr[j]<arr[j-1])swap(arr, j, j-1);
//			}
//		}
	}
	public void swap(int[] arr, int i, int j) {
		int temp =arr[i];
		arr[i]=arr[j];
		arr[j]=temp;
	}


}

C++实现

#include <iostream>
#include <vector>

using namespace std;
void directSort(vector<int>&arr){
    for(int i=0;i<arr.size();i++){
        int tmp=arr[i];
        int j=i;
        for(;j>0&&arr[j-1]>tmp;j--){
            arr[j]=arr[j-1];
        }
        if(j!=i)arr[j]=tmp;
    }
}
int main(){
    vector<int>arr={9, -16, 310, 23, -30, -49, 25, 21, 30};
    directSort(arr);
    for(int a:arr){
        cout<<a<<' ';
    }
    return 0;
}

1.1.2 直接插入排序效率分析

  • 首先从空间来看,它只需要一个元素的辅助空间用于元素的位置交换。从时间分析,首先外层循环要进行n-1次插入,每次插入最少比较一次(正序),移动0次;最多比较i次(包括同temp的比较),移动i+1次(逆序)(i=2,3,…,n)。因此,直接插入排序的时间复杂度为 O ( n 2 ) O(n^2) O(n2)
  • 直接插入排序的元素移动是顺序的,该方法是稳定的。

1.2 希尔排序(缩小增量排序)

1.2.1 基本思想

先将整个待排元素序列分割成若干个子序列(由相隔某个增量的元素组成的)分别进行直接插入排序,待整个序列中的元素基本有序(增量足够小)时,再对全体元素进行一次直接插入排序。因为直接插入排序在元素基本有序的情况下(接近最好情况) ,效率是很高的,因此希尔排序在时间效率上有较大提高。

在这里插入图片描述

  • 例如,8个元素的关键码分别为:91,67,35,62,29,72,46,57,希尔排序算法的执行过程为:

d1=8/2=4;
d2=4/2=2;
d3=2/2=1;

在这里插入图片描述

  • 步骤
    1. 选择一个增量序列 t1,t2,……,tk,其中 ti > tj, tk = 1

    2. 按增量序列个数 k,对序列进行 k 趟直接插入排序;

    3. 每趟排序,根据对应的增量 ti,将待排序列分割成若干长度为 m 的子序列,分别对各子表进行直接插入排序。仅增量因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。

java实现

package pers.chh3213.sort;

import java.util.Arrays;

public class ShellSort {
	public static void main(String[] args) {
		ShellSort shell = new ShellSort();
		int[] arr = {9, -16, 310, 23, -30, -49, 25, 21, 30};
		shell.shellSort(arr);
		System.out.println(Arrays.toString(arr));
	}
	public void shellSort(int[] arr) {
		//第一次步长为数组长度/2,后面依次步长/2
		for (int step = arr.length /2; step >0; step /= 2) {
			//直接插入排序
			for (int i = step; i < arr.length; i++) {
				int temp = arr[i];//右侧待排序区第一个数
				int j=i-step;//左侧已排序区域第一个数索引
				for( ;j>=0&&arr[j]>temp;j-=step) {
					arr[j+step]=arr[j];//满足条件则把已排序数字往后挪一个位置
//					swap(arr, j, j+step);
				}
				//插入待排序数字到指定位置
				arr[j+step]=temp;
			}


		}
	}
	public void swap(int[] arr, int i, int j) {
		int temp =arr[i];
		arr[i]=arr[j];
		arr[j]=temp;
	}
}

C++实现

#include <iostream>
#include <vector>

using namespace std;
void directSort(vector<int>&arr){
    for(int i=0;i<arr.size();i++){
        int tmp=arr[i];
        int j=i;
        for(;j>0&&arr[j-1]>tmp;j--){
            arr[j]=arr[j-1];
        }
        if(j!=i)arr[j]=tmp;
    }
}

1.2.2 希尔排序的效率分析

  • 虽然我们给出的算法是三层循环,最外层循环 l o g 2 ( n ) log_2(n) log2(n)数量级,中间的for循环n数量级的,内循环远远低于n数量级,因为当分组较多时,组内元素少此循环次数少;当分组较少时,组内元素增多,但已接近有序,循环次数并不增加。因此,希尔排序的时间复杂性在 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) O ( n 2 ) O(n^2) O(n2)之间,大致为 O ( n 1.3 ) O(n^{1.3}) O(n1.3)
  • 由于希尔排序对每个子序列单独比较,在比较时进行元素移动,有可能改变相同排序码元素的原始顺序,因此希尔排序是不稳定的。

2. 交换排序

  • 主要是通过排序表中两个记录关键码的比较,弱于排序要求相逆(不符合升序或降序),则将两者交换。

2.1 冒泡排序(Bubble Sort)

2.1.1 基本思想

冒泡排序通过重复地走访要排序的数列,一次比较两个元素,如果他们的顺序错误就把他们交换过来。

  • 原理描述
    通过对待排序序列从前向后,依次比较相邻元素的排序码,若发现逆序则交换,使排序码较大的元素逐渐从前部移向后部。

  • 实现步骤(默认升序的情况)

    1. 比较相邻的元素,如果前一个比后一个大,就交换这两个数;
    2. 针对所有元素重复以上的步骤,最后一个除外,直到没有任何一对数字需要交换位置为止。

在这里插入图片描述

  • 示例
package pers.chh3213.sort;
import java.util.Iterator;
import java.util.Scanner;
public class BubbleSort {
	public static void main(String[] args) {
		int[] arr = {5,4,1,966,2,3,56,89,12,0,56562};
		System.out.println("before sort:");
		for (int i : arr) {
			System.out.print(i+"\t");
		}
		for (int i = 0; i < arr.length-1; i++) {
			for (int j = 0; j < arr.length-1-i; j++) {
				if(arr[j]>arr[j+1]) {
					int temp = arr[j];
					arr[j] = arr[j+1];
					arr[j+1] = temp;
				}
			}
		}
		System.out.println();
		System.out.println("after sort:");
		for (int i : arr) {
			System.out.print(i+"\t");
		}
	}
}

因为排序的过程中,各元素不断接近自己的位置,如果一趟比较下来没有进行过交换,就说明序列有序, 因此可以在排序过程中设置一个标志swap判断元素是否进行过交换,从而减少不必要的比较。改进代码如下:

	public void bubbleSort(int[] data) {
		for (int i = 0; i < data.length-1; i++) {
			boolean swap = false;
			//每次排序会确定一个最大的元素
			for (int j = 0; j < data.length-i-1; j++) {
				if(data[j]>data[j+1]) {
					int temp = data[j];
					data[j]= data[j+1];
					data[j+1] = temp;
					swap = true;
				}
			}
			if(!swap)break; //如果第一趟并未发生交换,说明原数组有序,故停止排序
		}
	}

C++实现

void bubbleSort(vector<int>&arr){
    for(int i=0;i<arr.size()-1;i++){
        bool flag=false;
        for(int j=0;j<arr.size()-i-1;j++){
            if(arr[j]>arr[j+1]){{
                int tmp=arr[j];
                arr[j]=arr[j+1];
                arr[j+1]=tmp;
                flag=true;
            }}
        }
        if(!flag)break;
    }
}

2.1.2 冒泡排序的效率分析

  • 从冒泡排序的算法可以看出,若待排序的元素为正序,则只需进行一趟排序,比较次数为(n-1)次,移动元素次数为0;若待排序的元素为逆序,则需进行n-1趟排序,比较次数为 ( n 2 − n ) / 2 (n^2-n)/2 (n2n)/2,移动次数为 3 ( n 2 − n ) / 2 3(n^2-n )/2 3(n2n)/2,因此冒泡排序算法的时间复杂度为 O ( n 2 ) O(n^2) O(n2) 。由于其中的元素移动较多,所以属于内排序中速度较慢的一种。因为冒泡排序算法只进行元素间的顺序移动,所以是一个稳定的算法。

2.2 快速排序(Quick Sort)

  • 由图灵奖获得者Tony Hoare发明,被列为20世纪十大算法之一,是迄今为止所有内排序算法中速度最快的一种。冒泡排序的升级版,交换排序的一种。快速排序的时间复杂度为 O ( n l o g ( n ) ) O(nlog(n)) O(nlog(n))
  • 快排采用了分治法的思想。

2.2.1 排序思想

  1. 从数列中挑出一个元素,称为基准(pivot),一般取第一个元素;
  2. 通过一次划分,将待排元素分为左右两个子序列,所有元素比基准值小的摆放在左序列,所有元素比基准值大的摆在右序列(相同的数可以到任一边)。在这个分区结束之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
  3. 然后分别对两个子序列继续进行划分,直至每一个序列只有一个元素为止;
  4. 最后得到的序列便是有序的序列。
    (注:图片来源:参考资料1)

在这里插入图片描述

  • 一次划分的具体过程

    1. low指向待划分区域首元素(index=0), high指向待划分区域尾元素(index=R.length-1);
    2. base=R[low] (为了减少数据的移动,将作为标准的元素暂存到临时变量base中,最后再放入最终位置);
    3. high从后往前移动直到R[high]<base;
    4. R[low]=R[high], low++;
    5. low从前往后移动直到R[low]>=base;
    6. R[high]=R[low], high–;
    7. goto 3;
    8. 直到low==high时, R[low]=base (即将作为标准的元素放到其最终位置)。

    概括地说,一次划分就是从表的两端交替地向中间进行扫描,将小的放到左边,大的放到右边,作为标准的元素放到中间。
    之后采用递归的方式分别对前半部分和后半部分排序,当前半部分和后半部分均有序时该数组就自然有序了。

  • 一次划分的具体过程示例

    1. low指向待划分区域首元素, high指向待划分区域尾元素;
      在这里插入图片描述
    2. base=R[low] (为了减少数据的移动,将作为标准的元素暂存到base中,最后再放入最终位置);
      在这里插入图片描述
    3. high从后往前移动直到R[high]<base;
      在这里插入图片描述
    4. R[low]=R[high], low++;

在这里插入图片描述

  1. low从前往后移动直到R[low]>=base;
    在这里插入图片描述
    6. R[high]=R[low], high–;
    在这里插入图片描述
    7. goto 3;

在这里插入图片描述在这里插入图片描述在这里插入图片描述
8. 直到low==high时, R[low]=base (即将作为标准的元素放到其最终位置)。
在这里插入图片描述- 示例

package pers.chh3213.sort;
public class QuickSort {
	public static void main(String[] args) {
		System.out.println("quick sort test");
		int[] arr = {9, -16, 30, 23, -30, -49, 25, 21, 30};
		System.out.println("before sort:");
		for (int i : arr) {
			System.out.print(i+"\t");
		}
		QuickSort quick = new QuickSort();
		quick.quickSort(arr, 0, arr.length-1);
		System.out.println();
		System.out.println("after sort:");
		for (int i : arr) {
			System.out.print(i+"\t");
		}

	}
	public  void quickSort(int[] arr,int start, int end) {
		if(start<end) {
			 int index = partition(arr, start, end); //将表一分为2
			 quickSort(arr, start, index-1); // 对左子序列进行快速排序
			 quickSort(arr, index+1, end); //对右子序列进行快速排序
		}

	}
//	一次划分
	public  int partition(int[] arr, int low,int high) {

		int base = arr[low]; //暂存基准元素到base
		while (low<high) {//从表的两端交替的向中间扫描
			while(low<high && arr[high]>=base)high--;//右端扫描
			if(low<high) {
				arr[low]=arr[high];//把比基准小的元素放到基准前面
				low++;
			}
			while(low<high && arr[low]< base)low++;//左端扫描
			if(low<high) {
				arr[high]=arr[low];//把比基准大的元素放到基准后面
				high--;
			}
		}
		arr[low] = base;//把基准元素放到最终位置

		return low;//返回基准元素所在的位置
	}
}

c++实现

int partition(vector<int>&arr,int low,int high){
    int base = arr[low]; //暂存基准元素到base
    while (low<high) {//从表的两端交替的向中间扫描
        while(low<high && arr[high]>=base)high--;//右端扫描
        if(low<high) {
            arr[low]=arr[high];//把比基准小的元素放到基准前面
            low++;
        }
        while(low<high && arr[low]< base)low++;//左端扫描
        if(low<high) {
            arr[high]=arr[low];//把比基准大的元素放到基准后面
            high--;
        }
    }
    arr[low] = base;//把基准元素放到最终位置

    return low;//返回基准元素所在的位置
}

void quickSort(vector<int>&arr,int start,int end){
    if(start<end) {
        int index = partition(arr, start, end); //将表一分为2
        quickSort(arr, start, index-1); // 对左子序列进行快速排序
        quickSort(arr, index+1, end); //对右子序列进行快速排序
    }

2.2.2 快速排序的递归树

  • 快速排序的递归过程可用一棵二叉树形象地给出。下图为待排序列49,38,65,97,76,13,27,49所对应的快速排序递归调用过程的二叉树(简称为快速排序递归树)。
    在这里插入图片描述

  • 从快速排序算法的递归树可知,快速排序的趟数取决于递归树的高度。

2.2.3 快速排序的时间复杂度

  • 如果每次划分对一个对象定位后,该对象的左子序列与右子序列的长度相同,则下一步将是对两个长度减半的子序列进行排序,这是最理想的情况。

  • 假设n是2的幂, n = 2 k , ( k = l o g 2 n ) n=2^k,(k=log_2n) n=2k,(k=log2n),假设基准位置位于序列中间,这样划分的子区间大小基本相等。
    n + 2 ( n / 2 ) + 4 ( n / 4 ) + . . . + n ( n / n ) = n + n + . . . + n = n ∗ k = n ∗ l o g 2 n n+2(n/2)+4(n/4)+ ...+n(n/n)=n+n+ ...+n=n*k=n*log_2n n+2(n/2)+4(n/4)+...+n(n/n)=n+n+...+n=nk=nlog2n

  • 因此,快速排序的最好时间复杂度为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n) 。而且在理论上已经证明,快速排序的平均时间复杂度也为 O ( n l o g 2 n ) O (nlog_2n) O(nlog2n) 。实验结果表明:就平均计算时间而言,快速排序是所有内排序方法中最好的一个。

  • 在最坏的情况,即待排序对象序列已经按其排序码从小到大排好序的情况下,其递归树成为单支树,每次划分只得到一个比上一次少一个对象的子序列(蜕化为冒泡排序)。必须经过n-1趟才能把所有对象定位,而且第i趟需要经过n-i次排序码比较才能找到第i个对象的安放位置,总的排序码比较次数将达到
    ∑ i = 1 n − 1 ( n − i ) = 1 2 n ( n − 1 ) ≈ n 2 2 \sum_{i=1}^{n-1}{(n-i)}=\frac{1}{2}n(n-1) \approx \frac{n^2}{2} i=1n1(ni)=21n(n1)2n2
    因此,快速排序的最坏时间复杂度为 O ( n 2 ) O(n^2) O(n2)

2.2.4 快速排序的空间复杂度及稳定性

  • 快速排序是递归的,需要有一个存放每层递归调用时的指针和参数。最大递归调用层次数与递归树的高度一致。理想情况为 l o g 2 ( n + 1 ) log_2(n+1) log2(n+1);最坏情况即待排序对象序列已经按其排序码从小到大排好序的情况下,其递归树成为单支树,深度为n。因此,快速排序最好的空间复杂度为 O ( l o g 2 n ) O (log_2n) O(log2n) ,最坏的空间复杂度为 O ( n ) O(n) O(n)(即快速排序所需用的辅助空间)。
  • 快速排序是一种不稳定的排序方法。

3. 选择排序

  • 基本原理: 将待排序的元素分为已排序(初始为空)和未排序两组,依次将未排序的元素中值最小的元素放入已排序的组中。

3.1 简单选择排序

3.1.1 基本过程

  1. 在一组元素R[i]R[n]中选择具有最小关键码的元素
  2. 若它不是这组元素中的第一个元素,则将它与这组元素中的第一个元素对调。
  3. 除去具有最小关键字的元素,在剩下的
    元素中重复第1、2步,直到剩余元素只有一个为止。

在这里插入图片描述

在这里插入图片描述

package pers.chh3213.sort;

import java.util.Arrays;

public class SelectSort {
	public static void main(String[] args) {
		SelectSort select = new SelectSort();
		int[] arr = {9, -16, 310, 23, -30, -49, 25, 21, 30};
		select.selectSort(arr);
		System.out.println(Arrays.toString(arr));
	}
	public void selectSort(int[] arr) {
		for (int i = 0; i < arr.length-1; i++) {//进行n-1趟排序
			int min = i;
			for (int j = i+1; j < arr.length; j++) {
				if(arr[min]>arr[j])min=j; // 记录目前能找到的最小值元素的下标
			}
			 // 找到最小值后,再将找到的最小值和i位置所在的值进行交换
			if(i!=min)swap(arr, i, min);
		}
	}
	public void swap(int[] arr, int i, int j) {
		int temp =arr[i];
		arr[i]=arr[j];
		arr[j]=temp;
	}
}


C++实现

void selectSort(vector<int>&arr){
    for (int i = 0; i < arr.size()-1; i++) {//进行n-1趟排序
        int min = i;
        for (int j = i+1; j < arr.size(); j++) {
            if(arr[min]>arr[j])min=j; // 记录目前能找到的最小值元素的下标
        }
        // 找到最小值后,再将找到的最小值和i位置所在的值进行交换
        if(i!=min) {
            int temp =arr[i];
            arr[i]=arr[min];
            arr[min]=temp;
        };
    }
}

3.1.2 简单选择排序的效率分析

  1. 无论初始状态如何,在第i趟排序中选择最小关键码的元素,需做n-i次比较,因此总的比较次数为:
    ∑ i = 1 n − 1 n − i = n ( n − 1 ) / 2 = O ( n 2 ) \sum_{i=1}^{n-1}{n-i}=n(n-1)/2=O(n^2) i=1n1ni=n(n1)/2=O(n2)(即时间复杂度)

  2. 最好情况:序列为正序时,移动次数为 0 0 0, 最坏情况:序列为反序时,每趟排序均要执行交换操作,总的移动次数取最大值 3 ( n − 1 ) 3(n-1) 3(n1)

  3. 由于在直接选择排序中存在着不相邻元素之间的互换,因此,直接选择排序是一种不稳定的排序方法。例如,给定排序码为3, 7, 3’, 2, 1,排序后的结果为1, 2, 3’, 3, 7

3.2 堆排序

  • 堆排序是简单选择排序的改进。用直接选择排序从n个记录中选出关键字值最小的记录要做n-1次比较,然后从其余n-1个记录中选出最小者要作n-2次比较显然,相邻两趟中某些比较是重复的,为了避免重复比较,可以采用树形选择排序比较。
  • 树形选择排序总的比较次数为 O ( n l o g 2 n ) O(nlog2 n) O(nlog2n),与直接选择排序比较减少了比较次数,但需要增加额外的存储空间存放中间比较结果和排序结果。故引入堆排序。

3.2.1 堆的定义

  • n个元素的序列{k1, k2 , .... , kn },当且仅当满足
    { k i ≤ k 2 i k i ≤ k 2 i + 1 (1) \begin{cases} k_i\le k_{2i} \\ k_i \le k_{2i+1} \tag{1} \end{cases} {kik2ikik2i+1(1)
    或者
    { k i ≥ k 2 i k i ≥ k 2 i + 1 (1) \begin{cases} k_i\ge k_{2i} \\ k_i \ge k_{2i+1} \tag{1} \end{cases} {kik2ikik2i+1(1)
    称之为堆。

  • 若将此排序码按顺序组成一棵完全二叉树,则(1)称为小顶堆(二叉树的所有结点值小于或等于左右孩子的值) , (2)称为**大顶堆(**二叉树的所有结点值大于或等于左右孩子的值).

  • 一般升序排序采用大顶堆,降序排序使用小顶堆

  • 小顶堆和大顶堆示例
    在这里插入图片描述

3.2.2 堆排序的基本思想

  1. 建初始堆
    将排序码k1, k2, k, ..,kn表示成一棵完全二叉树,然后从第n/2个排序码(即树的最后一个非终端结点)开始筛选,使由该结点作根结点组成的子二叉树符合堆的定义,然后从第n/2-1个排序码重复刚才操作,直到第一个排序码止。这时候,该二叉树符合堆的定义,初始堆已经建立。

  2. 堆排序
    将堆中第一个结点(二叉树根结点)和最后一个结点的数据进行交换(k1,与kn,),再将 k 1 k_1 k1~ k n − 1 k_{n-1} kn1,重新建堆,然后 k 1 k_1 k1 k n − 2 k_{n-2} kn2交换,如此重复下去,每次重新建堆的元素个数不断减1,直到重新建堆的元素个数仅剩一个为止。这时堆排序已经完成,则排序码 k 1 , k 2 , k 3 , . . , k n k1, k2, k3, .., kn k1,k2,k3,..,kn已排成一个有序序列。

  3. 堆排序的两大步骤

    • 根据初始输入数据形成初始堆
    • 通过一系列的元素交换和重新调整堆进行排序。
  4. 堆排序的关键问题

    • 如何由一个无序序列建成一个堆?
    • 如何在输出堆顶元素之后,调整剩余元素,使之成为一个新的堆?
      在这里插入图片描述
package pers.chh3213.sort;

import java.util.Arrays;

public class HeapSort {

	public static void main(String[] args) {
		HeapSort hSort = new HeapSort();
		int[] arr = {9, 160, -31, 25, -30, 49, 25, 21, 30};
		hSort.heapSort(arr);
		System.out.println(Arrays.toString(arr));
	}
	public int[] heapSort(int[] arr) {
		buildMaxHeap(arr);
		int len = arr.length;
		for (int i = len - 1; i > 0; i--) {
			swap(arr, 0, i);
			len--;
			heapify(arr, 0, len);
		}
		return arr;
	}
	private void buildMaxHeap(int[] arr) {
		int len = arr.length;
		for (int i = (int) Math.floor(len / 2); i >= 0; i--) {
			heapify(arr, i, len);
		}
	}

	private void heapify(int[] arr, int i, int len) {
		int left = 2*i+1;
		int right = 2*i+2;
		int largest = i;

		if(left<len&&arr[left]>arr[largest])largest=left;
		if(right<len&&arr[right]>arr[largest])largest=right;
		if(largest!=i) {
			swap(arr, i, largest);
			heapify(arr, largest, len);
		}
	}
	/**
	 * 交换元素
	 * @param arr
	 * @param i
	 * @param j
	 */
    private void swap(int[] arr, int i, int j) {
        int temp = arr[i];
        arr[i] = arr[j];
        arr[j] = temp;
    }
}

4. 二路归并排序

4.1 基本思想

  • 将两个有序表合并成一个有序表。
    例如,将下列两个已排序的顺序表合并成一个已排序表。顺序比较两者的相应元素,小者移入另一表中,反复如此,直至其中任一表都移入另一表为止。

在这里插入图片描述

  • 示例
    在这里插入图片描述(图片来源:参考资料3

  • 特点

    1. 在“递”的过程中,对数组均等一分为二,再将子数组,一分为二…;
    2. 在“归”的过程中,将这两个有序的子数组合并成一个有序的子数组;
package pers.chh3213.sort;

import java.util.Arrays;
/**
 *
* MergeSort.java
* @Description 归并排序
* @author chh3213
* @version
* @date 2021年12月26日下午4:54:53
 */
public class MergeSort {
	public static void main(String[] args) {
		MergeSort Sort = new MergeSort();
		int[] arr = {9, -16, 310, 23, -30, -49, 25, 21, 30};
		Sort.mergeSort(arr,0,arr.length-1);
		System.out.println(Arrays.toString(arr));
	}
	public void mergeSort(int[] arr,int left, int right) {
		int mid = (left+right)/2;
		if(left<right) {
			//递归
			mergeSort(arr, left, mid);//左边归并排序,使得左子序列有序
			mergeSort(arr, mid+1, right);//右边归并排序,使得右子序列有序
			//合并
			merge(arr, left, right, mid);//将两个有序子数组合并操作
		}
	}

	public void merge(int[] arr, int left,int right, int mid) {
		/*
		 * 将两个有序数组合并
		 */
		int[] temp = new int[right-left+1]; //建好一个临时数组
		int i=left; //左子序列索引(理解成指针)
		int j = mid+1;//右子序列索引(理解成指针)
		int k =0;//临时数组的索引(理解成指针)
		while(i<=mid && j<=right) {
			if(arr[i]<=arr[j]) {
				temp[k++]=arr[i++];
			}
			else {
				temp[k++]=arr[j++];
			}
		}
		while(i<=mid)temp[k++]=arr[i++];//将左子序列剩余元素填充进temp中
		while(j<=right)temp[k++]=arr[j++];//将右子序列剩余元素填充进temp中
		//将temp中的元素全部拷贝回原数组中
		for (int k2 = 0; k2 < temp.length; k2++) {
			arr[k2+left]=temp[k2];
		}
	}
}

4.2 效率分析

  • 二路归并排序的时间复杂度等于归并趟数与每一趟时间复杂度的乘积。对n个元素的表,将这n个元素看作叶结点,若将两两归并生成的子表看作它们的父结点,则归并过程对应由叶向根生成一棵二叉树的过程。所以归并趟数等于二叉数的高度减1,即 l o g 2 n log_2n log2n。每一趟归并需移动n个元素,即每一趟归并的时间复杂度为 O ( n ) O(n) O(n)。因此, 二路归并排序的时间复杂度为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)
  • 利用二路归并排序时,需要利用与待排序数组相同的辅助数组作临时单元故该排序方法的空间复杂度为 O ( n ) O(n) O(n) ,比前面介绍的其它排序方法占用的空间大。
  • 由于二路归并排序中,每两个有序表合并成一个有序表时,若分别在两个有序表中出现有相同排序码,则会使前一个有序表中相同排序码先复制,后一有序表中相同排序码后复制,从而保持它们的相对次序不会改变。所以, 二路归并排序是一种稳定的排序方法。

5. 计数排序

  • 计数排序的核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数。

5.1 基本思想

  • 找出原数组中元素值最大的,记为max

  • 创建一个新数组count,其长度是max加1,其元素默认值都为0。

  • 遍历原数组中的元素,以原数组中的元素作为count数组的索引,以原数组中的元素出现次数作为count数组的元素值。

  • 创建结果数组result,起始索引index

  • 遍历count数组,找出其中元素值大于0的元素,将其对应的索引作为元素值填充到result数组中去,每处理一次,count中的该元素值减1,直到该元素值不大于0,依次处理count中剩下的元素。

第六步:返回结果数组result
在这里插入图片描述

package pers.chh3213.sort;

import java.util.Arrays;

/**
 *
* CountingSort.java
* @Description 计数排序
* @author chh3213
* @version
* @date 2022年2月15日上午10:46:13
 */
public class CountingSort{
	public static void main(String[] args) {
		CountingSort cSort = new CountingSort();
		int[] arr = {9, 16, 31, 23, 30, 49, 25, 21, 30};
		int[] result = cSort.countingSort(arr);
		System.out.println(Arrays.toString(result));
	}
	public int[] countingSort(int[] arr) {
		int maxValue = getMaxValue(arr);
		// 创建一个新数组,长度为max+1
		int countLen = maxValue+1;
		int[] count = new int[countLen];
		//统计数组中每个值为i的元素出现的次数,存入数组的第i项
		for (int i : arr) {
			count[i]++;
		}
		int[] result = new int[countLen];
		// 创建结果数组的起始索引
		int sortedIndex = 0;
		 // 遍历计数数组,将计数数组的索引填充到结果数组中
		for (int i = 0; i < countLen; i++) {
			while(count[i]>0) {
				result[sortedIndex++]=i;
				count[i]--;
			}
		}
		return result;
	}

	/**
	 * 找出待排序的数组中最大的元素
	 * @param arr 待排序的数组
	 * @return 返回最大值
	 */
	public int getMaxValue(int[] arr) {
		int maxValue = arr[0];
		for (int i = 0; i < arr.length; i++) {
			if(maxValue<arr[i]) {
				maxValue=arr[i];
			}
		}
		return maxValue;
	}

}

5.2 算法改进

  • 基本思想能够解决一般的情况,但是存在空间浪费的问题。比如一组数据{101,109,108,102,110,107,103},其中最大值为110,按照基础版的思路,我们需要创建一个长度为111的计数数组,但是我们可以发现,它前面的[0,100]的空间完全浪费了,那怎样优化呢?

  • 将数组长度定为max-min+1,即不仅要找出最大值,还要找出最小值,根据两者的差来确定计数数组的长度。

package pers.chh3213.sort;

import java.util.Arrays;
import java.util.Iterator;

/**
 *
* CountingSort2.java
* @Description 计数排序
* @author chh3213
* @version
* @date 2022年2月15日上午10:46:13
 */
public class CountingSort2{
	public static void main(String[] args) {
		CountingSort2 cSort = new CountingSort2();
		int[] arr = {9, 16, -31, 23, 30, 49, 25, 21, 30};
		int[] result = cSort.countingSort(arr);
		System.out.println(Arrays.toString(result));
	}
	public int[] countingSort(int[] arr) {
		int maxValue = getMaxValue(arr);
		int minValue = getMinValue(arr);
		// 创建一个新数组,长度为max+1
		int countLen = maxValue-minValue+1;
//		System.out.println(countLen);
		int[] count = new int[countLen];
		//统计数组中每个值为i的元素出现的次数,存入数组的第i项
		for (int i : arr) {
			// A中的元素要减去最小值,再作为新索引
			count[i-minValue]++;
		}
		int[] result = new int[countLen];
		// 创建结果数组的起始索引
		int sortedIndex = 0;
		 // 遍历计数数组,将计数数组的索引填充到结果数组中
		for (int i = 0; i < countLen; i++) {
			while(count[i]>0) {
			 // 再将减去的最小值补上
				result[sortedIndex++]=i+minValue;
				count[i]--;
			}
		}
		return result;
	}

	/**
	 * 找出待排序的数组中最大的元素
	 * @param arr 待排序的数组
	 * @return 返回最大值
	 */
	public int getMaxValue(int[] arr) {
		int maxValue = arr[0];
		for (int i = 0; i < arr.length; i++) {
			if(maxValue<arr[i]) {
				maxValue=arr[i];
			}
		}
		return maxValue;
	}
	/**
	 * 找出待排序的数组最小的元素
	 * @param arr 待排序的数组
	 * @return 返回最小值
	 */
	public int getMinValue(int[] arr) {
		int minValue = arr[0];
		for (int i = 0; i < arr.length; i++) {
			if(minValue>arr[i]) {
				minValue=arr[i];
			}
		}
		return minValue;
	}

}

5.3 效率分析

  • 当输入的元素是 n 个 0 到 k 之间的整数时,它的运行时间是$ O(n + k)$。计数排序不是比较排序,排序的速度快于任何比较排序算法。

  • 由于用来计数的数组C的长度取决于待排序数组中数据的范围(等于待排序数组的最大值与最小值的差加上1),这使得计数排序对于数据范围很大的数组,需要大量时间和内存。例如:计数排序是用来排序0到100之间的数字的最好的算法,但是它不适合按字母顺序排序人名。但是,计数排序可以用在基数排序中的算法来排序数据范围很大的数组。

  • 通俗地理解,例如有 10 个年龄不同的人,统计出有 8 个人的年龄比 A 小,那 A 的年龄就排在第 9 位,用这个方法可以得到其他每个人的位置,也就排好了序。当然,年龄有重复时需要特殊处理(保证稳定性),这就是为什么最后要反向填充目标数组,以及将每个数字的统计减去 1 的原因。

6. 桶排序

桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。为了使桶排序更加高效,我们需要做到这两点:

  • 在额外空间充足的情况下,尽量增大桶的数量
  • 使用的映射函数能够将输入的 N 个数据均匀的分配到 K 个桶中

同时,对于桶中元素的排序,选择何种比较排序算法对于性能的影响至关重要。

  1. 什么时候最快
    当输入的数据可以均匀的分配到每一个桶中。

  2. 什么时候最慢
    当输入的数据被分配到了同一个桶中。

  3. 示意图
    元素分布在桶中:
    在这里插入图片描述
    然后,元素在每个桶中排序:

    在这里插入图片描述

6.1 基本思想

  • 得到无序数组的取值范围
    在这里插入图片描述

  • 根据取值范围"创建"对应数量的"空桶"。
    在这里插入图片描述

  • 遍历数组,把数据放到对应的桶中。
    在这里插入图片描述
    (注:图片来源:参考资料7)

  • 对每个不为空的桶中数据进行排序。对于桶中元素的排序,选择何种比较排序算法对于性能的影响至关重要。

  • 拼接不为空的桶中数据,得到结果

"桶"是一种容器,这个容器可以用多种数据结构实现,包括数组、队列或者栈。

在这里插入图片描述(注:图片来源:参考资料1)

package pers.chh3213.sort;

import java.lang.reflect.Array;
import java.util.Arrays;

/**
 *
* BucketSort.java
* @Description 桶排序
* @author chh3213
* @version
* @date 2022年2月15日下午2:20:23
 */
public class BucketSort {
	//使用快速排序对每个桶进行排序
	QuickSort quickSort = new QuickSort();
	public static void main(String[] args) {
		BucketSort bucket = new BucketSort();
		int[] arr = {9, 160, -31, 23, -30, 49, 25, 21, 30};
		arr = bucket.bucketSort(arr, 5);
		System.out.println(Arrays.toString(arr));
	}
	
	/**
	 * 桶排序
	 * @param arr 待排序数组
	 * @param bucketSize 桶的大小(容量)
	 * @return
	 */
	public int[] bucketSort(int[] arr, int bucketSize) {
		if(arr.length==0)return arr;
		//获取最大、最小值
		int maxValue = arr[0];
		int minValue = arr[0];
		for (int i : arr) {
			if(i<minValue)minValue=i;
			if(i>maxValue)maxValue=i;
		}

		//桶的数量
		int bucketCount = (int)Math.floor((maxValue-minValue)/bucketSize)+1;

		//根据取值范围"创建"对应数量的"桶"
		int[][] buckets = new int[bucketCount][0];

		// 利用映射函数将数据分配到各个桶中
		for (int i = 0; i < arr.length; i++) {
			int index = (int)Math.floor(arr[i]-minValue)/bucketSize;
			buckets[index]=arrAppend(buckets[index], arr[i]);
		}

		int arrIndex = 0;
		for (int[] bucket : buckets) {
			if(bucket.length<=0)continue;
			//对每个桶进行排序,这里使用了快速排序
			quickSort.quickSort(bucket, 0, bucket.length-1);
			for (int value : bucket) {
				arr[arrIndex++]=value;
			}
		}
		return arr;
	}
	/**
	 * 自动扩容,并保存数据
	 * @param arr
	 * @param value
	 * @return
	 */
	private int[] arrAppend(int[] arr, int value) {
		arr = Arrays.copyOf(arr, arr.length+1);
		arr[arr.length-1] = value;
		return arr;
	}
}

7. 基数排序

  • 基数排序法是属于稳定性的排序,基数排序法的是效率高的稳定性排序法
  • 基数排序(Radix Sort)是桶排序的扩展
  • 基数排序是1887年赫尔曼·何乐礼发明的。它是这样实现的:将整数按位数切割成不同的数字,然后按每个位数分别比较。

7.1 基本思想

  • 将所有待比较数值(正整数)统一为同样的数位长度,数位较短的数前面补零

  • 从最低位开始,依次进行一次排序

  • 从最低位排序一直到最高位(个位->十位->百位->…->最高位)排序完成以后, 数列就变成一个有序序列

    需要我们获得最大数的位数,可以通过将最大数变为String类型,再求得它的长度即可

在这里插入图片描述(注:图片来源:参考资料1)

换句话说,其原理是将整数按位数切割成不同的数字,然后按每个位数分别比较。由于整数也可以表达字符串(比如名字或日期)和特定格式的浮点数,所以基数排序也不是只能使用于整数。

package pers.chh3213.sort;

import java.util.Arrays;

public class RadixSort {

	public static void main(String[] args) {
		RadixSort radix = new RadixSort();
		int[] arr = {9, 160, -31, 25, -30, 49, 25, 21, 30};
		radix.radixSort(arr);
		System.out.println(Arrays.toString(arr));
	}
	/**
	 * 获取数字位数
	 * @param num
	 * @return
	 */
    protected int getNumLenght(long num) {
//        if (num == 0) {
//            return 1;
//        }
//        int lenght = 0;
//        for (long temp = num; temp != 0; temp /= 10) {
//            lenght++;
//        }
//        return lenght;
      //将最大值转为字符串,它的长度就是它的位数
        int digits = (num + "").length();
        return digits;
    }
	/**
	 * 获取最大数的位数
	 * @param arr
	 * @return
	 */
	private int getMaxDigit(int[] arr) {
		int max = arr[0];
		for (int i : arr) {
			if(max<i)max=i;
		}
		return getNumLenght(max);
	}

	public int[] radixSort(int[] arr) {
		int mod = 10;
		int dev = 1;
		int maxDigit = getMaxDigit(arr);
		for (int i = 0; i < maxDigit; i++) {
			 // 考虑负数的情况,这里扩展一倍队列数,其中 [0-9]对应负数,[10-19]对应正数 (bucket + 10)
			int[][]counter = new int[mod*2][0];
			for (int j = 0; j < arr.length; j++) {
				int bucket = ((arr[j]%mod)/dev)+mod;
				counter[bucket]=arrayAppend(counter[bucket], arr[j]);
			}
			int pos = 0;
			for (int[] bucket : counter) {
				for (int value : bucket) {
					arr[pos++]=value;
				}
			}
			dev*=10;
			mod*=10;
		}
		return arr;
	}
    /**
     * 自动扩容,并保存数据
     *
     * @param arr
     * @param value
     */
    private int[] arrayAppend(int[] arr, int value) {
        arr = Arrays.copyOf(arr, arr.length + 1);
        arr[arr.length - 1] = value;
        return arr;
    }
}

7.2 效率分析

  1. 基数排序是对传统桶排序的扩展,速度很快。
  2. 基数排序是经典的空间换时间的方式,占用内存很大,当对海量数据排序时,容易造成OutOfMemoryError
  3. 基数排序是稳定的。[注:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的:否则称为不稳定的
  4. 有负数的数组,我们不用基数排序来进行排序,如果要支持负数,参考: https://code.i-harness.com/zh-CN/q/e98fa9

7.3 基数排序 vs 计数排序 vs 桶排序

这三种排序算法都利用了桶的概念,但对桶的使用方法上有明显差异:

  • 基数排序:根据键值的每位数字来分配桶;
  • 计数排序:每个桶只存储单一键值;
  • 桶排序:每个桶存储一定范围的数值;

以上所有代码见于gitee仓库

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

CHH3213

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值