前言:本篇主要介绍常见的七大排序,实现语言为Java,其主要分为:直接插入排序,希尔排序,直接选择排序,堆排序,冒泡排序,快速排序,归并排序。
在介绍七大排序之前我们先来认识一下排序的概念。
一、排序的概念及引用
1.1 排序的概念
排序:所谓排序,就是使一串记录,按照其中的某个或某些关键词的大小,递增或递减的排列起来的操作。
稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序仍保持不变。即在原序列中,arr[i] = arr[j],且 arr[i]在arr[j]之前,而在排序后,arr[i]仍在arr[j]之前,则称此排序为稳定的排序,某则称为不稳定排序。
需要注意的是:一个本身稳定的排序可以实现不稳定,但是反之则不行。
1.2 常见排序算法:
二、常见排序算法的实现
2.1 插入排序
2.1.1 直接插入排序
直接插入排序是一种简单的插入排序算法,其基本思想是:
把待排序的记录按其关键码的大小逐个插入到一个已经排好序的有序队列中,直到所有的记录插入完为止;实际中打扑克牌时,就用到此思想。
以下为动图展示:
时间复杂度:
当待排列数有序时,时间复杂度最小,因为这时,内循环直接执行了break。
当待排列数逆序时,时间复杂度最大,每次内循环中都要进行交换,当i=1时,内层执行一次,i=2时,内层执行两次........当i=n-1时,内层执行n-1次,即:(n-1)n/2,大O阶渐进法为:n^2.
空间复杂度:
所创建变量不随着某个值增大,即为O(1).
稳定性:
先说结论,其本身是稳定排序,但是如果我们将if (num[j] > tmp)加上等号变为:if (num[j] >= tmp),可以实现不稳定排序。
代码实现:
/**
* 直接插入排序
* 时间复杂度:逆序的时候(最坏)-> O(n^2)
* 有序的时候(最好)-> O(n)
* 结论:在数据基本有序的时候,使用直接插入排序
* 空间复杂度:O(1)
* 稳定性:稳定
* @param num
*/
public static void insertSort(int[] num) {
for (int i = 1; i < num.length; i++) {
int tmp = num[i];
int j = i-1;
for (; j >= 0; j--) {
if (num[j] > tmp) {
num[j+1] = num[j];
}else {
break;
}
}
num[j+1] = tmp;
}
}
直接插入排序的总结:
- 元素集合越接近有序,直接插入排序算法的时间效率越高
- 时间复杂度:O(N^2)
- 空间复杂度:O(1),它是一种稳定的排序算法
- 稳定性:稳定
2.1.2 希尔排序(缩小增量排序)
希尔排序法又称缩小增量法,是直接插入排序的改进,希尔排序的基本思想是:它通过比较相距一定间隔的元素来进行,各趟比较所用的距离随着算法的进行而减小,直到只比较相邻元素的最后一趟排序为止。
分析:
gap = 5 时,之所以摒弃传统的,相邻两个数为一组的情况,是因为图中这种分法可以更好的将大的数放到后面,小的数放到前面。
gap = 1 时,就是将其作为一个整体,进行直接插入排序,
前面的两趟排序都是预排序,让待排列数趋向于有序。
代码实现:
/**
* 希尔排序
* 时间复杂度:O(n^1.3~n^1.5)
* 空间复杂度:O(1)
* 稳定性:不稳定
* @param num
*/
public static void shell(int[] num, int gap) {
for (int i = gap; i < num.length; i++) {
int tmp = num[i];
int j = i - gap;
for (; j >= 0; j -=gap) {
if (num[j] > tmp) {
num[j+gap] = num[j];
}else {
break;
}
}
num[j+gap] = tmp;
}
}
public static void shellSort(int[] num) {
int gap = num.length;
while (gap > 1) {
shell(num,gap);
gap /= 2;
}
shell(num,1);
}
可能有人会问,这样的缩小增量的方法,真的有助于提高效率嘛?分那么多次组,看上去很麻烦。
先说结论,既然是优化,那必然是有助于提高效率的,从时间复杂度的角度考量的话:
对于10000个数据,如果直接进行 直接插入排序,那么时间复杂度度就是n^2,也就是
10000*10000 = 1亿。如果将其分组,比如分为两组:100*100,那么每组的时间复杂度就是:
100*100,那么100组就是 100*100*100 = 100万,时间复杂度大大缩小了。
希尔排序的总结:
- 希尔排序是对直接插入排序的优化。
- 当gap>1 时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序了,再此基础上排序就会大大提高效率,达到最优情况。
- 希尔排序的时间复杂度不好计算,因为gap的取值可以有很多种方法,另一方面,许多书籍中对希尔排序的时间复杂度也都不固定。
- 稳定性:不稳定。
2.2 选择排序
2.2.1 直接选择排序
每次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完。
代码实现:
/**
* 直接选择排序
* 时间复杂度:O(n^2) -> 对数据不敏感,不管你是有序还是无序,时间复杂度不变
* 空间复杂度:O(1)
* 稳定性: 不稳定的排序
* @param num
*/
public static void selectSort(int[] num) {
for (int i = 0; i < num.length; i++) {
int minIndex = i;
for (int j = i+1; j < num.length; j++) {
if (num[j] < num[minIndex]) {
minIndex = j;
}
}
swap(num,i,minIndex);
}
}
private static void swap(int[] array, int i, int minIndex) {
int tmp = array[i];
array[i] = array[minIndex];
array[minIndex] = tmp;
}
时间复杂度:
当有n个数据的时候,内层循环需要比较n-1次,当有n-1个数据的时候,内层循环需要比较n-2次.......因此,时间复杂度 1+2+3+...+n-1 = (n-1)*n / 2。
直接选择排序总结:
- 直接选择排序思考非常好理解,但是效率不是很好。实际中很少使用
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:不稳定
以下为对选择排序的优化:
定义两个指针,left和right,在搜索的过程中,让left找最小的元素,right找最大的元素。
public static void selectSort2(int[] num) {
int left = 0;
int right = num.length-1;
while(left < right) {
int minIndex = left;
int maxIndex = left;
for (int i = left+1; i <= right; i++) {
if(num[i] > maxIndex) {
maxIndex = i;
}
if (num[i] < minIndex) {
minIndex = i;
}
}
swap(num,left,minIndex);
if (left == maxIndex) {
maxIndex = minIndex;
}
swap(num,right,maxIndex);
left++;
right--;
}
}
需要注意的是:在将left所指向的元素和minIndex指向的元素交换后,需要注意是否存在一种情况:left和maxIndex指向了同一个值。
为了避免这种状况的发生(执行了第一个swap后,将maxIndex指向的值修改了),于是这里加入了一个if语句。
2.2.2 堆排序
堆排序是指利用堆这种数据结构所设计的一种排序,它是选择排序的一种。需要注意的是排升序是要建立大堆,降序是要建小堆。
堆排序的流程:
建立一个大根堆,每次最后一个元素跟堆顶元素进行交换,之后进行向下调整,直到交换完成。
代码实现:
/**
* 时间复杂度:O(n*logn)
* 空间复杂度:O(1)
* 稳定性:不稳定的
* @param num
*/
public static void heapSort(int[] num) {
createBigHeap(num);
int end = num.length - 1;
while (end >= 0) {
swap(num,0,end);
shiftDown(num,0,end);
end--;
}
}
private static void createBigHeap(int[] num) {
for (int parent = (num.length-1-1)/2; parent >=0; parent--) {
shiftDown(num,parent,num.length);
}
}
private static void swap(int[] array, int i, int minIndex) {
int tmp = array[i];
array[i] = array[minIndex];
array[minIndex] = tmp;
}
/**
* 实现向下调整
* @param num
* @param parent 每颗子树的根节点的下标
* @param len 每颗子树调整的结束位置
*/
private static void shiftDown(int[] num,int parent,int len) {
int child = 2*parent + 1;
//最起码保证有左孩子
while (child < len) {
//判断左右孩子最大值的前提是必须有右孩子
if (child+1 < len && num[child] < num[child+1]) {
child++;//此时保存了最大值的下标
}
if (num[child] > num[parent]) {
swap(num,child,parent);
parent = child;
child = 2*parent+1;
}else {
break;
}
}
}
堆排序总结:
- 时间复杂度:O(N*logN)
- 空间复杂度:O(1)
- 稳定性:不稳定
2.3 交换排序
2.3.1 冒泡排序
冒泡排序,是一种简单的排序算法。其重复的走访待排序的数据,一次比较两个元素,如果它们的顺序错误就将它们交换过来。走访待排序的工作需要执行到不再交换为止。
代码实现:
/**
* 冒泡排序
* 时间复杂度:O(N^2)
* 空间复杂度:O(1)
* 稳定性:稳定
* @param num
*/
public static void bubbleSort(int[] num) {
for (int i = 0; i < num.length-1; i++) {
for (int j = 0; j < num.length-1-i; j++) {
if (num[j] > num[j+1]) {
swap(num,j,j+1);
}
}
}
}
可以在时间复杂度上进行优化,当待排序的数据有序的时候,就可以直接跳出循环,这样在有序的情况下,时间复杂度可以被优化为O(n)。
public static void bubbleSort2(int[] num) {
for (int i = 0; i < num.length-1; i++) {
boolean flag = false;
for (int j = 0; j < num.length-1-i; j++) {
if (num[j] > num[j+1]) {
swap(num,j,j+1);
flag = true;
}
}
if (!flag) {
break;
}
}
}
冒泡排序总结:
- 时间复杂度:O(N^2)
- 空间复杂度:O(1)
- 稳定性:稳定
2.3.2 快速排序
快排的基本思想是:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后对左右子序列重复该过程,直到所有的元素都排列到相应位置为止。
实现代码1(Hoare法):
可能有人会问:为什么在左边标注Key,而是右边的right先走,而不是左边的left先走呢?
如果先走左边的话,前面虽然过程都是一样的,但是left和right相遇之后的数据,是比基准(key)要大的数字,如果交换,那么就把比基准大的数字放到了前面。
/**
* 快速排序
* 时间复杂度:n*logN【最好状态】 O(N^2)【最坏情况】
* 空间复杂度:logN
* 稳定性:不稳定
* @param num
*/
public static void quickSortHoare(int[] num,int left,int right) {
//等于是因为代表只有一个节点,大于是因为left可能会大于right(有序或者逆序的情况)
if(left >= right) {
return;
}
int pivot = partition(num,left,right);
quickSortHoare(num,left,pivot-1);
quickSortHoare(num,pivot+1,right);
}
private static int partition(int[] num,int start,int end) {
int i = start;
int key = num[start];
while (start < end) {
//这里添加start < end 是因为可能会出现数组越界的情况
//为啥这里取等号?
//防止进入死循环,比如 3,4,2,5,3 这种情况。即 key与left和right相等。
while (start < end && num[end] >= key) {
end--;
}
while (start < end && num[start] <= key) {
start++;
}
swap(num,start,end);
}
swap(num,i,start);
return start;
}
需要注意的是,在待排列数据 有序或者逆序 且 数据量很大 的情况下,这种利用递归思路的快排可能会发生栈溢出的问题——时间复杂度O(N^2)。
这是因为我们递归的深度太深了,而函数的递归是在栈上开辟栈帧的。
ps:虽然IDEA提供了修改开辟栈帧大小的功能,但是不推荐使用。
快排的使用场景一般是无序的应用场景,有序一般是使用插入或者希尔排序:
实现代码2(挖坑法):
public static void quickSortHole(int[] num,int left,int right) {
//等于是因为代表只有一个节点,大于是因为left可能会大于right
if(left >= right) {
return;
}
int pivot = partition2(num,left,right);
quickSortHoare(num,left,pivot-1);
quickSortHoare(num,pivot+1,right);
}
/**
* 挖坑法
* @param num
* @param start
* @param end
* @return
*/
private static int partition2(int[] num,int start,int end) {
int key = num[start];
while (start < end) {
while (start < end && num[end] >= key) {
end--;
}
num[start] = num[end];
while (start < end && num[start] <= key) {
start++;
}
num[end] = num[start];
}
num[start] = key;
return start;
}
实现代码3(前后指针法):
基本思路:cur遇到比基准小的值就往后走,再次遇到小的时候(先遇到大的)停下来,交换,主要目的就是把大的往后移。
/**
* 前后指针法(快排)
* @param num
* @param left
* @param right
*/
public static void quickSortPointer(int[] num,int left,int right) {
//等于是因为代表只有一个节点,大于是因为left可能会大于right(有序或者逆序的情况)
if(left >= right) {
return;
}
int pivot = partition3(num,left,right);
quickSortHoare(num,left,pivot-1);
quickSortHoare(num,pivot+1,right);
}
private static int partition3(int[] num,int start,int end) {
int prev = start;
int cur = start+1;
while(cur <= end) {
if (num[cur] < num[start] && num[++prev] != num[cur]) {
swap(num,cur,prev);
}
cur++;
}
swap(num,start,prev);
return prev;
}
2.3.3 快速排序优化
- 三数取中法选key
- 递归到小的子区间时,可以考虑使用插入排序
分析:
所谓三数取中法:在带排列数据的最左端和最右端以及中间的元素中,找到三个数中的中位数,并
将这个中位数作为基准放到待排列数据中的第一位。
为什么使用三数取中的方法呢?
这是对于待排列数据是有序的情况下,如果没有对取基准作任何调整的话,可能会让递归的深度增加,影响算法的效率。
/**
* 快排优化
* 时间复杂度:n*logN
* 空间复杂度:最好:O(logN)。最坏。O(N) 当n足够大的时候,递归的深度就大
* 稳定性:不稳定。
* @param num
* @param start
* @param end
*/
public static void insertSort2(int[] num,int start,int end) {
for (int i = start+1; i <= end; i++) {
int tmp = num[i];
int j = i-1;
for (; j >= start; j--) {
if (num[j] > tmp) {
num[j+1] = num[j];
}else {
break;
}
}
num[j+1] = tmp;
}
}
public static void quickSort(int[] num,int left,int right) {
//等于是因为代表只有一个节点,大于是因为left可能会大于right(有序或者逆序的情况)
if(left >= right) {
return;
}
//小区间使用了插入排序,主要是优化了递归的深度
if (right - left + 1 <= 7) {
insertSort2(num,left,right);
return;
}
//三数取中,用了这个方法每次的待排序序列,每次基本都是采取二分的方法。
int index = midNumIndex(num,left,right);
swap(num,left,index);
int pivot = partition2(num,left,right);
quickSort(num,left,pivot-1);
quickSort(num,pivot+1,right);
}
/**
* 挖坑法
* @param num
* @param start
* @param end
* @return
*/
private static int partition2(int[] num,int start,int end) {
int key = num[start];
while (start < end) {
while (start < end && num[end] >= key) {
end--;
}
num[start] = num[end];
while (start < end && num[start] <= key) {
start++;
}
num[end] = num[start];
}
num[start] = key;
return start;
}
//全排列-六种情况
private static int midNumIndex(int[] num,int left,int right) {
int mid = (left + right) / 2;
if(num[left] < num[right]) {
if (num[mid] < num[left]) {
return left;
}else if(num[mid] > num[right]) {
return right;
}else {
return mid;
}
}else {
if (num[mid] < num[right]) {
return right;
} else if (num[mid] > num[left]) {
return left;
}else {
return mid;
}
}
}
2.3.4 非递归的快排
/**
* 非递归实现快排
* @param arr
*/
public static void quickSort(int[] arr) {
Stack<Integer> stack = new Stack<>();
int left = 0;
int right = arr.length-1;
int pivot = partition2(arr,left,right);
if (pivot > left+1) {
stack.push(left);
stack.push(pivot-1);
}
if (pivot < right-1) {
stack.push(pivot+1);
stack.push(right);
}
while (!stack.empty()) {
right = stack.pop();
left = stack.pop();
pivot = partition2(arr,left,right);
if (pivot > left+1) {
stack.push(left);
stack.push(pivot-1);
}
if (pivot < right-1) {
stack.push(pivot+1);
stack.push(right);
}
}
}
快速排序总结:
- 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(logN)
- 稳定性:不稳定
2.4 归并排序
归并排序是建立在归并操作上的一种有效的算法,该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再将两个有序表合并成一个有序表,称为二路归并。
实现代码:
private static void mergeSortFunc(int[] arr,int left, int right) {
if (left >= right) {
return;
}
int mid = (left + right) / 2;
// 1.分解左边
mergeSortFunc(arr,left,mid);
// 2.分解右边
mergeSortFunc(arr,mid+1,right);
// 3.进行合并
merge(arr,left,right,mid);
}
private static void merge(int[] arr, int start, int end,
int mid) {
int[] tmpArr = new int[end-start+1];
//tmpArr 数组的下标
int k = 0;
int s1 = start;
int s2 = mid+1;
//两个归并段都有数据
while (s1 <= mid && s2 <= end) {
if (arr[s1] <= arr[s2]) {
tmpArr[k++] = arr[s1++];
}else {
tmpArr[k++] = arr[s2++];
}
}
//到走这里时候,说明有一段的数据中没了数据,需要拷贝另一端的全部数据到数组中。
while (s1 <= mid) {
tmpArr[k++] = arr[s1++];
}
while (s2 <= end) {
tmpArr[k++] = arr[s2++];
}
//把排好序的数字拷贝会原数组
for (int i = 0; i < k; i++) {
arr[i+start] = tmpArr[i];
}
}
/**
* 时间复杂度;O(n*logN)
* 空间复杂度:O(N)
* 稳定性:稳定排序
* 直接插入排序,冒泡排序,归并
* @param arr
*/
public static void mergerSort(int[] arr) {
mergeSortFunc(arr,0,arr.length-1);
}
非递归实现归并排序:
/**
* 非递归实现归并排序
* @param arr
*/
public static void mergerSort2(int[] arr) {
int gap = 1;
while(gap < arr.length) {
for (int i = 0; i < arr.length; i += gap*2) {
int s1 = i;
int e1 = s1+gap-1;
if (e1 >= arr.length) {
e1 = arr.length-1;
}
int s2 = e1+1;
if (s2 >= arr.length) {
s2 = arr.length-1;
}
int e2 = s2+gap-1;
if (e2 >= arr.length) {
e2 = arr.length-1;
}
merge(arr,s1,e2,e1);
}
gap *= 2;
}
}
private static void merge(int[] arr, int start, int end,
int mid) {
int[] tmpArr = new int[end-start+1];
//tmpArr 数组的下标
int k = 0;
int s1 = start;
int s2 = mid+1;
//两个归并段都有数据
while (s1 <= mid && s2 <= end) {
if (arr[s1] <= arr[s2]) {
tmpArr[k++] = arr[s1++];
}else {
tmpArr[k++] = arr[s2++];
}
}
//到走这里时候,说明有一段的数据中没了数据,需要拷贝另一端的全部数据到数组中。
while (s1 <= mid) {
tmpArr[k++] = arr[s1++];
}
while (s2 <= end) {
tmpArr[k++] = arr[s2++];
}
//把排好序的数字拷贝会原数组
for (int i = 0; i < k; i++) {
arr[i+start] = tmpArr[i];
}
}
归并排序总结
- 归并的缺点在于需要O(N)的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
- 时间复杂度:O(N*logN)
- 空间复杂度:O(N)
- 稳定性:稳定
2.5 海量数据的排序问题
外部排序:排序过程需要在磁盘等外部存储进行的排序
前提:内存只有 1G,需要排序的数据有 100G
因为内存中因为无法把所有数据全部放下,所以需要外部排序,而归并排序是最常用的外部排序
- 先把文件切分成 200 份,每个 512 M
- 分别对 512 M 排序,因为内存已经可以放的下,所以任意排序方式都可以
- 进行 2路归并,同时对 200 份有序文件做归并过程,最终结果就有序了
三、排序算法复杂度及稳定性分析
排序方法 | 最好 | 平均 | 最坏 | 空间复杂度 | 稳定性 |
冒泡排序 | O(n) | O(n^2) | O(n^2) | O(1) | 稳定 |
插入排序 | O(n) | O(n^2) | O(n^2) | O(1) | 稳定 |
选择排序 | O(n^2) | O(n^2) | O(n^2) | O(1) | 不稳定 |
希尔排序 | O(n^1.3) | 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^2) | O(log(n)) ~ O(n) | 不稳定 |
归并排序 | O(n * log(n)) | O(n * log(n)) | O(n * log(n)) | O(n) | 稳定 |
四、计数排序(了解)
主要思路:
一种非比较排序。计数排序对一定范围内的整数排序时候的速度非常快,一般快于其他排序算法。但计数排序局限性比较大,只限于对整数进行排序,而且待排序元素值分布较连续、跨度小的情况。
如果一个数组里所有元素都是整数,而且都在 0-k 以内。对于数组里每个元素来说,如果能知道数组里有多少项小于或等于该元素,就能准确地给出该元素在排序后的数组的位置。
如给定一个 0~5 范围内的数组[2,5,3,0,2,3,0,3]
,对于元素 5 为其中最大的元素,创建一个大小为(5-0+1 = 6)的计数数组,如果原数组中的值对应计数数组的下标,则下标对应计数数组的值加 1。
提问:上面是通过数组的最大值来确定计数数组的长度的,但如果需要对学生的成绩进行排序,如学生成绩为:
[95,93,92,94,92,93,95,90]
,那应该如何处理呢?
如果按照上面的方法来处理,则需要一个大小为 100 的数组,但是可以看到其中的最小值为 90,那也就是说前面 0~89 的位置都没有数据存放,造成了资源浪费。
如果我们知道了数组的最大值和最小值,则计数数组的大小为(最大值 - 最小值 + 1),如上面数组的最大值为 99,最小值为 90,则定义计数数组的大小为(95 - 90 + 1 = 6)
代码实现:
/**
* 计数排序
* @param num
*/
public static void countSort(int[] num) {
int maxVal = num[0];
int minVal = num[0];
for (int i = 0; i < num.length; i++) {
if (num[i] < minVal) {
minVal = num[i];
}
if (num[i] > maxVal) {
maxVal = num[i];
}
}
int len = maxVal - minVal + 1;
int[] count = new int[len];
//开始遍历num数组,开始计数。
for (int i = 0; i < num.length; i++) {
int val = num[i];
count[val-minVal]++;
}
//这时num数组的下标
int index = 0;
for (int i = 0; i < count.length; i++) {
//确保当前count数组可以打印完成
while (count[i] != 0) {
num[index = i+minVal;
index++;
count[i]--;
}
}
}