
下面我们将讲述七大基于比较的排序算法的基本原理及实现。并从稳定性、时间复杂度、空间复杂度 3种性能对每种排序进行分析。
重点:快速排序和堆排序;难点:快速排序和归并排序
目录
[2.1 插入排序](#2.1 插入排序)
[2.1.1 直接插入排序](#2.1.1 直接插入排序)
[2.1.2 希尔排序(缩小增量排序)](#2.1.2 希尔排序(缩小增量排序))
[2.2 选择排序](#2.2 选择排序)
[2.2.1 直接选择排序](#2.2.1 直接选择排序)
[2.2.2 直接选择排序优化](#2.2.2 直接选择排序优化)
[2.2.3 堆排序](#2.2.3 堆排序)
[2.3 交换排序](#2.3 交换排序)
[2.3.1 冒泡排序](#2.3.1 冒泡排序)
[2.3.2 快速排序](#2.3.2 快速排序)
[1、Hoare 法](#1、Hoare 法)
[2.3.3 快速排序优化](#2.3.3 快速排序优化)
[2.3.4 快速排序非递归](#2.3.4 快速排序非递归)
[2.4 归并排序](#2.4 归并排序)
[2.4.2 归并排序非递归实现](#2.4.2 归并排序非递归实现)
[4.1 计数排序](#4.1 计数排序)
一、排序概念
**稳定性:**假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持 不变,即在原序列中,r[i]=r[j],且 r[i] 在 r[j] 之前,而在排序后的序列中,r[i] 仍在r[j] 之前,则称这种排序算法是稳 定的;否则称为不稳定的。

**内部排序:**数据元素全部放在内存中的排序。
**外部排序:**数据元素太多不能同时放在内存中,根据排序过程的要求在内外村之间移动数据的排序,例如磁盘上的排序。
二、常见排序算法的实现
2.1 插入排序
2.1.1 直接插入排序
基本思想: 把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列。例如玩扑克牌前对手里的牌进行整理。
比如现在你手上有一副卡牌,不看花色:10、2、9、4、7,从左往右的第2张发现比第一张小,因此将其抽出插到第一张的前面。

java
public class Sort {
public static void insertSort(int[] array){
for (int i = 1; i < array.length; i++) {
int tmp = array[i];
int j = i-1;
for (; j >= 0; j--) {
if (array[j] > tmp) { // 若此处加了 = ,则得到的数据不稳定,但实际上直接插入排序法是稳定的
array[j + 1] = array[j];
}else{
array[j + 1] = array[j];
break;
}
}
array[j+1] = tmp;
}
}
}
直接插入排序的特性:
元素集合越接近有序,直接插入排序算法的时间效率越高,时间复杂度为O(N);
时间复杂度:最坏情况下是 O(N²);
空间复杂度:O(1),没有开辟空间;
稳定性:稳定。
2.1.2 希尔排序(缩小增量排序)
**基本思想:**先选定一个整数作为增量,把待排序文件中的所有数据按照这个整数为距离分成多个组,即所有距离为增量的数据分在同一组内,将每一组的数据进行排序。然后,取另一个小于原来增量的整数(可以是原来的 1/2 或者 1/3+1),重复上述的分组和排序规则,当增量 = 1时,所有数据在同一组内排好序。是直接插入排序的优化,所以直接插入排序的代码可以直接拿来修改。如下图

java
public static void shellSort(int[] array){
int gap = array.length;
while (gap > 1){
gap /= 2; // 最后都能等于1
shell(array, gap);
}
}
private static void shell(int[] array, int gap) {
for (int i = gap; i < array.length; i++) {
int tmp = array[i];
int j = i-gap;
for (; j >= 0 ; j -= gap) {
if (array[j] > tmp){
array[j+gap] = array[j];
}else{
array[j+gap] = tmp;
break;
}
}
array[j+gap] = tmp;
}
}
希尔排序的特性:
希尔排序是对直接插入排序的优化。
当 gap > 1 时都是预排序,目的是让数组更接近于有序。当 gap == 1 时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在好些书中给出的希尔排 、序的时间复杂度都不固定,归纳总结其时间复杂度的范围在O(n^1.3 ~ n^1.5)之间;
4、空间复杂度:O(1);
5、稳定性:不稳定。
性能测试:main() 方法中只有 testEfficiency() 测试数组是有序的或者无序时,各种排序方法的效率;而 testSimple() 则是查看是否排序成功。
java
package sort;
import java.util.Arrays;
import java.util.Random;
public class Test {
public static void orderArray(int[] array){ // 初始化有序的数组
for (int i = 0; i < array.length; i++) {
// 顺序
array[i] = i;
// 倒序
//array[i] = array.length-1-i;
}
}
public static void notOrderArray(int[] array){ // 初始化无序的数组
Random random = new Random();
for (int i = 0; i < array.length; i++) {
array[i] = random.nextInt(10_0000); // 生成0到10万的随机数
}
}
public static void testInsertEfficiency(int[] array){
array = Arrays.copyOf(array, array.length); // 不改变原来数组的顺序,而是将排序后的新的数组复制一份
long startTime = System.currentTimeMillis();
Sort.insertSort(array);
long finishTime = System.currentTimeMillis();
System.out.println("直接插入排序耗时:"+(finishTime-startTime));
}
public static void testShellEfficiency(int[] array){
array = Arrays.copyOf(array, array.length); // 不改变原来数组的顺序,而是将排序后的新的数组复制一份
long startTime = System.currentTimeMillis();
Sort.shellSort(array);
long finishTime = System.currentTimeMillis();
System.out.println("希尔排序耗时:"+(finishTime-startTime));
}
public static void main(String[] args) {
//testSimple(); // 测试例子给的数组
testEfficiency(); // 测试耗时效率
}
public static void testSimple(){
int[] array = {9,3,4,7,1,5,8,6,5,2};
System.out.println("排序前:"+Arrays.toString(array));
Sort.shellSort(array);
System.out.println("排序后:"+Arrays.toString(array));
}
public static void testEfficiency(){
int[] array = new int[10_0000]; // 数组元素个数为10万
orderArray(array);
//notOrderArray(array);
testInsertEfficiency(array);
testShellEfficiency(array);
}
}
对于有序的数组,直接插入排序耗时要比希尔排序短一点,但差不了多少,而如果是对无序的数组进行排序,希尔排序耗时要远比直接插入排序耗时短,几乎达到1:100。
2.2 选择排序
**基本思想:**每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
2.2.1 直接选择排序
假设0下标的元素为最小值,在后面的元素中找到实际最小值,然后交换......
java
public static void DirectSelectSort(int[] array){
for (int i = 0; i < array.length; i++) {
int minIndex = i;
for (int j = i+1; j < array.length; j++) {
if (array[j] < array[minIndex]){
minIndex = j;
}
}
swap(array, i, minIndex);
}
}
private static void swap(int[] array, int i, int j) {
int tmp = array[i];
array[i] = array[j];
array[j] = tmp;
}
直接选择排序的特性:
直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用,因此重要的是算法思想;
时间复杂度:O(n²),和数组是否有序无关
空间复杂度:O(1)
稳定性:不稳定
2.2.2 直接选择排序优化
从左端点开始向右找到最小值将下标存到 minIndex 中,找到最大值并将下标存到 maxIndex 中。

上图的步骤看似没毛病,编写代码如下:
java
public static void selectSort(int[] array){
int left = 0;
int right = array.length-1;
while (left < right) {
int minIndex = left;
int maxIndex = left;
for (int i = left+1; i <= right; i++) {
if (array[i] > array[maxIndex]){
maxIndex = i;
}
if (array[i] < array[minIndex]){
minIndex = i;
}
}
swap(array,left,minIndex);
swap(array,right,maxIndex);
left++;
right--;
}
}
但是如果我们换一组数据进行测试,得到的结果如下:


所以在更换完最小值之后需要增加一项条件。
java
public static void selectSort(int[] array){
int left = 0;
int right = array.length-1;
while (left < right) {
int minIndex = left;
int maxIndex = left;
for (int i = left+1; i <= right; i++) {
if (array[i] > array[maxIndex]){
maxIndex = i;
}
if (array[i] < array[minIndex]){
minIndex = i;
}
}
swap(array,left,minIndex);
// 如果最大值正好是 left 下标,那么交换 left 之后,最大值给到 minIndex 下标
if (maxIndex == left){
maxIndex = minIndex;
}
swap(array,right,maxIndex);
left++;
right--;
}
}
对于无序的数组,选择排序的耗时如下:

2.2.3 堆排序
利用堆这种数据结构来实现排序,需要注意的是升序建大根堆,降序则是建小根堆。
我的Java集合中的优先级队列(堆)中有提及。
java
public static void heapSort(int[] array){
createHeap(array); // 1、建大根堆
int end = array.length-1;
while (end > 0){
swap(array,0,end); // 2、交换根节点和最后一个节点的值
// 3、将除了最后一个元素(已是最大值)的其他节点重新整理成大根堆
siftDown(array,0,end);
end--; // 4、倒着重复上面的操作
}
}
public static void createHeap(int[] array){
int parent = (array.length-1-1)/2;
for (; parent >= 0; parent--) {
siftDown(array,parent,array.length);
}
}
private static void siftDown(int[] array, int parent, int size) {
int child = 2*parent+1;
while (child < size){
if(child+1 < size && array[child+1] > array[child]){
child++;
}
if (array[child] > array[parent]){
swap(array,child,parent);
parent = child;
child = 2*parent+1;
}else{
break;
}
}
}
堆排序的特性:
堆排序使用堆来选数,效率就高了很多;
时间复杂度:O(N*log₂N) ,(目前效率最快的排序方法)
空间复杂度:O(1)
稳定性:不稳定
2.3 交换排序
**基本思想:**所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
2.3.1 冒泡排序
关键词:趟数、循环嵌套、j+1不要越界
java
public static void bubbleSort(int[] array){
for (int i = 0; i < array.length-1; i++) {
boolean tag = false;
for (int j = 0; j < array.length-1-i; j++) {
if (array[j] > array[j+1]){
swap(array,j, j+1);
tag = true;
}
}
if (!tag){
break;
}
}
}
1、时间复杂度 O(N²)
【一般讨论没有优化情况下的时间复杂度,即没有boolean元素和 -i 的操作】
【优化之后,时间复杂度可以达到 O(N)】
2、空间复杂度 O(1)
3、稳定性:稳定
2.3.2 快速排序
快速排序是Hoare于1962年提出的一种二叉树结构 的基于分治法 的交换排序方法,其基本思想 为:
任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
java
public static void quickSort(int[] array){
quick(array,0,array.length-1);
}
public static void quick(int[] array, int start, int end){
if (start >= end){ // 递归结束条件
return;
}
//按照基准值对数组进行分割
//int pivot = partitionHoare(array,start,end); // Hoare
//int pivot = partitionDig(array,start,end); // 挖坑
//int pivot =partitionDoublePointer(array,start,end); // 双指针解法1
int pivot = partitionDoublePointer2(array,start,end); // 双指针解法2
// 递归左边的
quick(array, start, pivot-1);
// 递归右边的
quick(array,pivot+1,end);
}
上述为快速排序递归实现的主框架,与二叉树前序遍历规则非常像,下面的3种分割方法都是在分割之后应用上面的递归方法进行排序的(如下图右侧内容),因此下面只讨论不同分割方法的主要思想。
1、Hoare 法

java
public static void quickSort(int[] array){
quick(array,0,array.length-1);
}
public static void quick(int[] array, int start, int end){
if (start >= end){ // 递归结束条件
return;
}
int pivot = partitionHoare(array,start,end);
quick(array, start, pivot-1);
quick(array,pivot+1,end);
}
private static int partitionHoare(int[] array, int left, int right){
int tmp = array[left];
int tmpIndex = left;
while(left < right){
while (left < right && array[right] >= tmp){
right--;
}
while (left < right && array[left] <= tmp){
left++;
}
swap(array, left, right);
}
swap(array,left,tmpIndex);
return left;
}
问题:画图理解 / 调试解决
1、为什么从后面开始找,而不是从前面开始找?
2、为什么在 while 条件中有 等于号?
快排序的特性:
1、时间复杂度:
最坏情况下:当给定数据是1 2 3 4 5 6...... / ......9 8 7 6 5 4 3 2 1这种有序的情况下,是O(N²);
最好情况下:是 O(N*log₂N),对于满二叉树来说,对上面的代码进行优化后可实现。
2、空间复杂度:递归一定会开辟内存
最坏情况下: O(N),单分支二叉树;
最好情况下: O(log₂N),对于满二叉树,递归完左边再递归右边时,左边开辟的内存会被回收。
3、稳定性:不稳定
2、🔴挖坑法
如果在题目中遇到快速排序的结果,但没有明确指出是哪种快排时,优先考虑挖坑法。

java
public static void quickSort(int[] array){
quick(array,0,array.length-1);
}
public static void quick(int[] array, int start, int end){
if (start >= end){ // 递归结束条件
return;
}
int pivot = partitionDig(array,start,end);
quick(array, start, pivot-1);
quick(array,pivot+1,end);
}
private static int partitionDig(int[] array, int left, int right){
int key = array[left];
while(left < right){
while (left < right && array[right] >= key){
right--;
}
array[left] = array[right];
while (left < right && array[left] <= key){
left++;
}
array[right] = array[left];
}
array[left] = key;
return left;
}
3、前后指针
两种分割的方法:



【对于有序的数组】当我们在测试快排序的效率时,程序崩溃了,这是因为递归需要开辟内存空间,对于十万如此大量的且有序的数据就得开辟十万个空间。而将数据量减小,比如1万,并不会崩,但是效率还是比较低。
2.3.3 快速排序优化
1、三数取中法
在前面测试有序数组时程序崩溃了,这是因为递归有n个节点的单分支的二叉树需要开辟n个内存,那么如果我们找到有序数组的中间值并将其作为分割的基准值,就能将有序数组分为两部分。

java
public static void quick(int[] array, int start, int end){
if (start >= end){ // 递归结束条件
return;
}
// 三数取中法优化,有序的数组不再是单分支的二叉树了
//System.out.println("start: " + start + " end: " + end); // 打印也会耗时
int midIndex = getMiddleIndex(array, start, end);
swap(array,start,midIndex);
//int pivot = partitionHoare(array,start,end); // Hoare
int pivot = partitionDig(array,start,end); // 挖坑
//int pivot =partitionDoublePointer(array,start,end); // 双指针解法1
//int pivot = partitionDoublePointer2(array,start,end); // 双指针解法2
quick(array, start, pivot-1);
quick(array,pivot+1,end);
}
private static int getMiddleIndex(int[] array, int start, int end) {
int mid = (start+end)/2;
if (array[start] < array[end]) {
if (array[mid] < array[start]) {
return start;
} else if (array[mid] > array[end]) {
return end;
} else {
return mid;
}
} else {
if (array[mid] > array[start]) {
return start;
} else if (array[mid] < array[end]) {
return end;
} else {
return mid;
}
}
}
2、减少递归深度
因为在多次的预排序后数组已经趋于有序了,所以当递归到小的子区间时,可以考虑使用插入排序。
比如当数据量剩下的范围是0到7之间时,直接使用插入排序,注意修改原来编写的插入排序的开始和结束范围,以及使用插入排序之后及时结束进程:
java
public static void quick(int[] array, int start, int end){
if (start >= end){ // 递归结束条件
return;
}
// 减少递归深度优化
if (end - start <= 7){
insertSortRage(array, start, end);
return; // 记得此处要结束循环了
}
// 三数取中法优化,有序的数组不再是单分支的二叉树了
//System.out.println("start: " + start + " end: " + end); // 打印也会耗时
int midIndex = getMiddleIndex(array, start, end);
swap(array,start,midIndex);
//int pivot = partitionHoare(array,start,end); // Hoare
int pivot = partitionDig(array,start,end); // 挖坑
//int pivot =partitionDoublePointer(array,start,end); // 双指针解法1
//int pivot = partitionDoublePointer2(array,start,end); // 双指针解法2
quick(array, start, pivot-1);
quick(array,pivot+1,end);
}
private static void insertSortRage(int[] array, int start, int end) {
for (int i = start+1; i <= end; i++) {
int tmp = array[i];
int j = i-1;
for (; j >= start; j--){
if (array[j] > tmp){
array[j+1] = array[j];
}else{
array[j+1] = tmp;
break;
}
}
array[j+1] = tmp;
}
}
2.3.4 快速排序非递归
**基本思想:**使用一个栈来存放分割后要使用二叉树数据结构进行排序的开始下标和结束下标。再拿出下标进行分割,就是不断地分割、存放下标、拿出下标、分割......的过程。

java
public static void quickSort(int[] array){
// 非递归的方法
quickNor(array,0,array.length-1);
}
private static void quickNor(int[] array, int start, int end) {
Deque<Integer> stack = new ArrayDeque<>();
int pivot = partitionDig(array,start,end);
// 第一次分割后存放下标
if (pivot > start+1){
stack.push(start);
stack.push(pivot-1);
}
if (pivot < end-1){
stack.push(pivot+1);
stack.push(end);
}
// 拿出下标再进行分割
while (!stack.isEmpty()){
end = stack.pop();
start = stack.pop();
pivot = partitionDig(array,start,end);
if (pivot > start+1){
stack.push(start);
stack.push(pivot-1);
}
if (pivot < end-1){
stack.push(pivot+1);
stack.push(end);
}
}
}
快速排序的特性:
1、 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序;
2、时间复杂度:O(N*logN);
3、空间复杂度:O(logN),即树的高度;
- 稳定性:不稳定。
2.4 归并排序
2.4.1
**基本思想:**归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。 归并排序核心步骤:

java
public static void mergeSort(int[] array){
mergeSortTmp(array, 0, array.length-1);
}
public static void mergeSortTmp(int[] array, int left, int right){
if (left >= right){
return;
}
int mid = (left + right)/2;
mergeSortTmp(array, left, mid);
mergeSortTmp(array, mid+1, right);
// 到这里分解结束
// 下面进行合并
merge(array,left,mid,right);
}
private static void merge(int[] array, int left, int mid, int right) {
int[] tmp = new int[right-left+1];
int k = 0;
int s1 = left;
//int e1 = mid;
int s2 = mid+1;
//int e2 = right;
while (s1 <= mid && s2 <= right){
if (array[s1] < array[s2]){
tmp[k++] = array[s1++];
}else{
tmp[k++] = array[s2++];
}
}
while (s1 <= mid){ // 要用 while 的原因是有可能有多组
tmp[k++] = array[s1++];
}
while (s2 <= right){
tmp[k++] = array[s2++];
}
for (int i = 0; i < k; i++) {
array[i+left] = tmp[i]; // +left处理巧妙解决右边下标对应问题
}
}
归并排序特性:
1、归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外部排序问题;
2、时间复杂度:O(N*log₂N);
3、空间复杂度:O(N)
4、稳定性:稳定
海量数据的排序问题:
外部排序:排序过程需要在磁盘等外部存储进行的排序
前提:内存只有 1G,需要排序的数据有 100G
因为内存中因为无法把所有数据全部放下,所以需要外部排序,而归并排序是最常用的外部排序
先把文件切分成 200 份,每个 512 M
分别对 512 M 排序,因为内存已经可以放的下,所以任意排序方式都可以
进行 2路归并,同时对 200 份有序文件做归并过程,最终结果就有序了
2.4.2 归并排序非递归实现
**基本思想:**将数组多次分解之后合并为一整个数组进行排序。

java
private static void merge(int[] array, int left, int mid, int right) { // 合并
int[] tmp = new int[right-left+1];
int k = 0;
int s1 = left;
//int e1 = mid;
int s2 = mid+1;
//int e2 = right;
while (s1 <= mid && s2 <= right){
if (array[s1] < array[s2]){
tmp[k++] = array[s1++];
}else{
tmp[k++] = array[s2++];
}
}
while (s1 <= mid){ // 要用 while 的原因是有可能有多组
tmp[k++] = array[s1++];
}
while (s2 <= right){
tmp[k++] = array[s2++];
}
for (int i = 0; i < k; i++) {
array[i+left] = tmp[i];
}
}
// 归并排序非递归
public static void mergeSortNor(int[] array){
int gap = 1;
while (gap < array.length) {
for (int i = 0; i < array.length; i = i + gap * 2) {
int left = i;
int mid = left + gap - 1;
int right = mid + gap;
merge(array, left, mid, right); // 合并
}
gap *= 2;
}
}
上面的 mergeSortNor() 代码看似没有问题,但是如果是如下情况,该如何完善?

java
public static void mergeSortNor(int[] array){
int gap = 1;
while (gap < array.length) {
for (int i = 0; i < array.length; i = i + gap * 2) {
int left = i;
int mid = left + gap - 1;
if (mid >= array.length){ // 处理越界情况
mid = array.length -1;
}
int right = mid + gap;
if (right >= array.length){
right = array.length -1;
}
merge(array, left, mid, right); // 合并
}
gap *= 2;
}
}
三、特性总结
堆排序、快速排序、归并排序时间复杂度都是 O(N*log₂N);但相比空间复杂度,堆排序占优势
插入排序、冒泡排序和归并排序都是稳定的;
排序方法 | 最好 | 平均 | 最坏 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n²)(优化之后能达到O(n)) | O(n²) | O(n²) | O(1) | 稳定 |
插入排序 | O(n) | O(n²) | O(n²) | O(1) | 稳定 |
选择排序 | O(n²) | O(n²) | O(n²) | O(1) | 不稳定 |
希尔排序 | O(n) | O(n^1.3) | O(n^1.5) | O(1) | 不稳定 |
堆排序 | O(n * log₂n) | O(n * log₂n) | O(n * log₂n) | O(1) | 不稳定 |
快速排序 | O(n * log₂n) | O(n * log₂n) | O(n²) | O(log₂n)~O(n) | 不稳定 |
归并排序 | O(n * log₂n) | O(n * log₂n) | O(n * log₂n) | O(n) | 稳定 |
一些操作关键词:

四、其他非基于比较排序
4.1 计数排序
应用场景:
集中在某个范围内的一组数据。
操作步骤:
1、新建一个计数数组来利用下标统计相同元素出现的次数;
2、根据统计的结果将序列回收到原来的序列中。

java
public static void countSort(int[] array){
// 1、找出数组中的最大值和最小值,得到申请空间的大小
int max = array[0];
int min = array[0];
for (int i = 1; i < array.length; i++) {
if (array[i] > max){
max = array[i];
}
if (array[i] < min){
min = array[i];
}
}
int len = max - min + 1;
int[] count = new int[len];
// 2、遍历数组计算出现的次数
for (int i = 0; i < array.length; i++) {
int index = array[i];
count[index-min]++;
}
// 3、将计数数组中的数据按顺序拿出放到 array
int index = 0;
for (int i = 0; i < count.length; i++) {
while (count[i] != 0) {
array[index] = i + min;
index++;
count[i]--;
}
}
}
计数排序的特性:
1、计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。
2、时间复杂度:O(MAX(N,范围))
3、空间复杂度:O(范围)
4、稳定性:稳定
其他:桶排序、基数排序。