👽 博客主页: ➡➡ <芝士盘盘>⬅⬅
❤️🔥💗💓一键三连:👍 点赞 ✌️ 收藏 👌 关注
👻小小勉励:没有一个冬天不可逾越,没有一个春天不会来临。
文章全览
一、基于比较的排序
1.插入排序
One 直接插入排序
官方解释:
插入排序,一般也被称为直接插入排序。对于少量元素的排序,它是一个有效的算法 。插入排序是一种最简单的排序方法,它的基本思想是将一个记录插入到已经排好序的有序表中,从而一个新的、记录数增1的有序表。在其实现过程使用双层循环,外层循环对除了第一个元素之外的所有元素,内层循环对当前元素前面有序表进行待插入位置查找,并进行移动。
其实在我们日常生活中,就经常会用到插入排序,例如我们平常打扑克,摸牌的过程就可以看作是插入排序,摸第一张牌的时候自然是有序的,从摸第二张牌开始,我们每摸一张牌就跟之前有的牌从后往前一一比较大小,遇到比此时摸的牌小的就停下,将这张牌插入到小牌的前面,以此类推。
了解了原理,让我们来看看代码实现
/**
* 插入排序
*
* 时间复杂度:最坏情况O(N^2)
* 最好情况O(N),当数据越趋于有序时,排序速度越快
* 也就是说,直接插入排序一般使用场景就是数据基本有序的时候
* 空间复杂度:O(1)
* 稳定性:稳定
*
* 待排序数组
* @param array
*/
public static void insertionSort(int[] array) {
//从1开始遍历,当数组只有一个或者零个数据时不进入循环
for (int i = 1; i < array.length; i++) {
int tmp = array[i];
int j = i - 1;
//依次从后向前比较
for (; j >= 0; j--) {
if(tmp < array[j]) {
array[j + 1] = array[j];
}else {
break;
}
}
array[j+1] = tmp;
}
}
插入排序的代码十分简单,我们来简单分析一下。插入排序的时间复杂度🔍由嵌套循环即可分析出为O(N^2),且代码并没有申请新的空间,所以空间复杂度🔍为O(1),且插入排序是稳定的。值得一提的是,根据代码来看,假设数组是有序的,那么比较次数都是一次,时间复杂度就变为了0(N)。也就是说,当数组越趋于有序时,插入排序会越快,所以插入排序运用场景一般用于数据基本有序的情况下。
Two 希尔排序
官方解释:
希尔排序(Shell’s Sort)是插入排序的一种又称“缩小增量排序”(Diminishing IncrementSort),是直接插入排序算法的一种更高效的改进版本。希尔排序是非稳定排序算法。该方法因 D.L.Shell 于 1959 年提出而得名。
希尔排序是把记录按下标的一定增量分组,对每组使用直接插入排序算法排序;随着增量逐渐减少,每组包含的关键词越来越多,当增量减至 1 时,整个文件恰被分成一组,算法便终止。
看上去有点难理解,其实很简单,希尔排序实际上就是将数组先分成几组,这几组都用插入排序一次,然后再使分组数量变小,比如第一次分了四组,第二次分了两组,每次分完组后都进行插入排序,到最后再以整个数组进行一次插入排序。增量其实指的就是分多少组的意思,减小增量就是分组数量减少。
ok,理解了就来看看代码。
/**
* 希尔排序
*
* 时间复杂度:O(N^1.3) - O(N^1.5) 无法精准确定
* 空间复杂度:O(1)
* 稳定性:不稳定
*
* 待排序数组
* @param array
*/
public static void shellSort(int[] array) {
int gap = array.length;
//进行分组,最后一次gap为1,排完之后跳出循环
while (gap > 1) {
gap /= 2;
shell(array, gap);
}
}
private static void shell(int[] array, int gap) {
//i从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 {
break;
}
}
array[j+gap] = tmp;
}
}
到这,你可能会有一个疑问,看上去这个希尔排序不是比插入排序更麻烦吗,为什么它会比插入排序效率高呢?实际上,在进行分组的时候,每组的数据量少了,进行插入排序不会耗费太多时间,到后面虽然分组数变少,每组数据增多,但是由于数组经历了之前的插入排序,数组已经趋于有序了,这样最后以整个数组进行插入排序的时候就会变得很快了。但是也正是因为这点,增量序列的不确定,以及每次排序一次后数组变成什么样了也不确定,所以希尔排序的时间复杂度是无法确定的,只能通过大量实验得出在O(N^1.3) - O(N^1.5)的范围内,空间复杂度也是0(1),而与插入排序不同,希尔排序是不稳定的。
2.选择排序
One 选择排序
官方解释:
选择排序(Selectionsort)是一种简单直观的排序算法。它的工作原理是:第一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,然后再从剩余的未排序元素中寻找到最小(大)元素,然后放到已排序的序列的末尾。以此类推,直到全部待排序的数据元素的个数为零。选择排序是不稳定的排序方法。
选择排序相较于前两个排序来说,可以说是十分好理解的了,我们可以看完官方解释后直接来看代码即可。
/**
* 选择排序
*
* 时间复杂度:O(N^2)
* 空间复杂度:O(1)
* 稳定性:不稳定
*
* 待排序数组
* @param array
*/
//第一种写法
public static void selectionSort(int[] array) {
for (int i = 0; i < array.length; i++) {
int minIndex = i;
int j = i + 1;
for (; j < array.length; j++) {
if(array[minIndex] > array[j]) {
minIndex = j;
}
}
swap(array,i,minIndex);
}
}
//第二种写法
public static void selectionSort1(int[] array) {
int left = 0;
int right = array.length-1;
while (left < right) {
int minIndex = left;
int maxIndex = left;
for (int j = left + 1; j <= right; j++) {
if(array[j] > array[maxIndex]) {
maxIndex = j;
}
if(array[j] < array[minIndex]) {
minIndex = j;
}
}
swap(array,left,minIndex);
/*为了防止最大值在left的位置上,这时候最大值会被交换到minIndex的位置上
所以应该使maxIndex等于minIndex*/
if(left == maxIndex) {
maxIndex = minIndex;
}
swap(array,right,maxIndex);
left++;
right--;
}
}
以上两种写法皆可,其实原理是一样的,第一种就是每次遍历数组把最小值往前放,第二种就是每次遍历在找最小值的同时,也找到最大值,都记录下来,最小值往前放,最大值往后放。两种写法的时间复杂度:由嵌套循环判断都为O(N^2),空间复杂度:都为O(1),且选择排序是不稳定的。
Two 堆排序
官方解释:
堆排序(英语:Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。
想要明白堆排序,首先要知道堆是什么,堆实际上是一个完全二叉树,底层堆是以层序遍历🔍用数组顺序存储的,堆分为大根堆和小根堆🔍,大根堆就是堆的父亲节点比所有孩子节点都大,且每棵子树也满足这一点,小根堆则相反。堆排序,就是利用大根堆,因为大根堆堆顶的元素是所有元素中最大的,我们将堆顶元素与最后一个元素交换,然后向下调整搜索除去最后一个节点的剩余元素,使剩余元素还是满足大根堆,以此类推,每次都将堆顶元素放到最后,这样就可以实现排序。
光讲可能很难理解,让我们结合一下代码
/**
* 堆排序
*
* 时间复杂度:O(N*logN)
* 空间复杂度:O(1)
* 稳定性:不稳定
*
* @param array
*/
public static void heapSort(int[] array) {
createBigHeap(array);
int end = array.length-1;
while (end > 0) {
swap(array, 0, end);
downwardAdjustment(array, 0, end);
end--;
}
}
//以大根堆的方式排序
private static void createBigHeap(int[] array) {
//找到最后一个节点的父亲节点
int parents = (array.length-1-1)/2;
for (; parents >= 0; parents--) {
downwardAdjustment(array, parents, array.length);
}
}
//向下调整
private static void downwardAdjustment(int[] array, int parents, int len) {
int child = 2*parents+1;
while (child < len) {
if(child + 1 < len && array[child] < array[child+1]) {
child++;
}
if(array[child] > array[parents]) {
swap(array, child, parents);
parents = child;
child = 2*parents + 1;
}else {
break;
}
}
}
根据以上代码,我们先对传入的待排序数组进行操作,使其满足大根堆的特点,我们以向下调整🔍的方式建堆,因为向下调整建堆比向上调整🔍建堆稍快一些。建堆完成之后就是按照之前所说的将堆顶元素与最后一个元素交换,然后再向下调整,循环反复,最后数组变为有序。堆排序的时间复杂度因为每个元素都要交换之后都要进行一次向下调整,向下调整函数的时间复杂度为O(log2N),所以堆排序的时间复杂度为O(N*log2N),空间复杂度为O(1),堆排序是不稳定的。
3.交换排序
One 冒泡排序
官方解释:
冒泡排序(Bubble Sort),是一种计算机科学领域的较简单的排序算法。
它重复地走访过要排序的元素列,依次比较两个相邻的元素,如果顺序(如从大到小、首字母从Z到A)错误就把他们交换过来。走访元素的工作是重复地进行,直到没有相邻元素需要交换,也就是说该元素列已经排序完成。
这个算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端(升序或降序排列),就如同碳酸饮料中二氧化碳的气泡最终会上浮到顶端一样,故名“冒泡排序”。
冒泡排序相信大家都不陌生了,这是大家接触最多的就是冒泡排序了,原理很简单,就是每次循环,从第一个元素开始往遍历,遇到后一个元素比前一个元素小的时候,就进行交换,这样每次都把最大值放到了数组的最后,循环n次,数组达到有序。
话不多说,来看代码
/**
* 冒泡排序
*
* 时间复杂度:最坏情况O(N^2),优化后最好情况可达到O(N)
* 空间复杂度:O(1)
* 稳定性:稳定
*
* @param array
*/
public static void bubbleSort(int[] array) {
for (int i = 0; i < array.length; i++) {
boolean flag = true;
for (int j = 0; j < array.length-1-i; j++) {
if(array[j] > array[j+1]) {
flag = false;
swap(array, j, j+1);
}
}
if(flag) {
return;
}
}
}
很明显,冒泡排序的时间复杂度为O(N*2),空间复杂度为O(1),冒泡排序是稳定的。这上面的代码有一个小细节,定义了一个flag变量,这样可以使当我们遇到数组是有序的时候,我们只需要遍历一次数组,就会返回函数,这样就不用还是遍历n次数组,让时间复杂度变为了O(N)。
Two 快速排序
官方解释:
快速排序(Quicksort),计算机科学词汇,适用领域Pascal,c++等语言,是对冒泡排序算法的一种改进。
虽说快速排序官方解释说的是,冒泡排序的一种改进,但我个人感觉还是有差异。快速排序就是每次在数组中找一个基准值,让大于基准值的元素全放在基准值的右边,小于的放在左边。然后再在基准值的右边一串元素中用同样的方法找基准值进行同样操作,右边亦是如此。所以实际上快速排序类似于二叉树,用递归来做很好理解。
我们来看看代码是怎么实现的。
/**
*快速排序
*
* 时间复杂度:O(N*logN)
* 最好情况:O(N*logN)
* 最坏情况:数据有序时,树的高度为N,O(N^2) 所以要进行优化!!
* 空间复杂度:最好情况:O(logN)
* 最坏情况:O(N)
* 稳定性:不稳定
*
* @param array
*/
public static void quickSort(int[] array) {
quick(array, 0, array.length-1);
}
private static void quick(int[] array, int left, int right) {
if(left >= right) {
return;
}
//优化一:当数组元素少时,可以不用继续递归,且此时数组已经趋于有序了,可以直接使用插入排序
if(left-right + 1< 3) {
insertionSort2(array, left ,right);
return;
}
//优化二:三数取中法优化
int k = midThree(array, left, right);
swap(array, k, left);
//这里找基准用下面三种找基准的方法任意一个都可
int pivot = partition2(array, left, right);
quick(array, left, pivot-1);
quick(array, pivot+1, right);
}
public static void insertionSort2(int[] array, int left, int right) {
for (int i = left+1; i <= right; i++) {
int tmp = array[i];
int j = i - 1;
for (; j >= left; j--) {
if(tmp < array[j]) {
array[j + 1] = array[j];
}else {
break;
}
}
array[j+1] = tmp;
}
}
//三数取中法
private static int midThree(int[] array, int left, int right) {
int mid = (left+right)/2;
if(array[left] < array[right]) {
if(array[mid] > array[right]) {
return right;
}else if(array[mid] < array[left]) {
return left;
}else {
return mid;
}
}else {
if(array[mid] < array[right]) {
return right;
}else if(array[mid] > array[left]) {
return left;
}else {
return mid;
}
}
}
//挖坑法划分
private static int partition(int[] array, int left, int right) {
int tmp = array[left];
while (left < right) {
if(array[right] < tmp) {
array[left] = array[right];
break;
}else {
right--;
}
}
while (left < right) {
if(array[left] > tmp) {
array[right] = array[left];
break;
}else {
left++;
}
}
array[left] = tmp;
return left;
}
//Hoare法划分
private static int partition1(int[] array, int left, int right) {
int tmp = array[left];
int i = left;
//这里只能让右边先走,再走左边,有可能会把比基准值大的值放到数组前面。
while (left < right) {
while (left < right && array[right] >= tmp) {
right--;
}
//这里必须写array[left] <= tmp,不能写array[left] < tmp
//这样会导致第一次left永远都不会移动
while (left < right && array[left] <= tmp) {
left++;
}
swap(array, left, right);
}
swap(array, left, i);
return left;
}
//前后指针法
private static int partition2(int[] array, int left, int right) {
int prev = left;
int cur = prev+1;
while (cur <= right) {
if(array[cur] < array[left] && array[++prev] != array[cur]) {
swap(array, prev, cur);
}
cur++;
}
swap(array, prev, left);
return prev;
}
首先就是要找基准值,有三种方法,分别是挖坑法,Hoare法,前后指针法。前后指针法了解即可,挖坑法和Hoare法类似,所以我以挖坑法进行举例讲解,挖坑法很简单,就是将数组第一个元素记录下来,这就是基准值,然后创建两个变量left和right,分别指向数组的左和右,right向左遍历找到比记录的值小的值,就让left指向的元素与right指向的元素交换。接着让left向右遍历,找到比记录的值大的值后,也让left和right指向的元素进行交换,直到left等于right,退出循环,让记录的值放在left和right相等的位置上,这样就完成了找基准值的操作。
我们再来看主函数是怎么写的,类似于二叉树,我们找完基准值后,返回基准值的下标,然后再以其左边为一个数组进行操作,右边也一样,这样到最后,整个数组都会实现有序。
有两个小细节,优化一:通过二叉树的特点,我们知道越往下递归,树的节点越多,需要遍历的次数也就成指数增加,所以我们可以让数组的长度小于一定范围的时候就不再递归,这时候小范围的数组已经趋于有序,我们可以直接使用插入排序,这样快速排序的效率又会提升不少。优化二:当我们传入的数组有序或者逆序的时候,我们每次取完基准值后,基准值右边或者左边将会没有数据,这样会导致效率降低,为了防止这种情况,使用三数取中法可以有效避免。三数取中法的原理很简单,就是每次从数组找三个数,然后进行比较,把中间大小的元素与数组最左边的元素交换,这样就成功避免了上面所说的情况了。
4.归并排序
One 归并排序
官方解释:
归并排序是建立在归并操作上的一种有效,稳定的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
归并排序看图其实就可以看出和二叉树很相似,其实跟快速排序也有点类似,快速排序是每次划分的同时处理数据,归并排序是先划分,划分到只剩一个数据之后再进行排序合并。
代码实现
/**
* 归并排序
*
* 时间复杂度:O(N*logN)
* 空间复杂度:O(N)
* 稳定性:稳定
*
* @param array
*/
public static void mergeSort(int[] array) {
int left = 0;
int right = array.length-1;
mergeSortFunc(array, left, right);
}
private static void mergeSortFunc(int[]array, int left, int right) {
if(left >= right) {
return;
}
int mid = (left+right)/2;
//划分一次之后,继续划分左边
mergeSortFunc(array, left, mid);
//划分右边
mergeSortFunc(array, mid+1, right);
//排序合并
merge(array, mid, left ,right);
}
private static void merge(int[]array, int mid, int left, int right) {
int s1 = left;
int s2 = mid+1;
int n = right-left + 1;
int[] tmp = new int[n];
int k = 0;
while (s1 <= mid && s2 <= right) {
if(array[s1] <= array[s2]) {
tmp[k++] = array[s1++];
}else {
tmp[k++] = array[s2++];
}
}
//这里要用while,不能用if ,因为可能有多个数据
while (s1 <= mid) {
tmp[k++] = array[s1++];
}
while (s2 <= right) {
tmp[k++] = array[s2++];
}
for (int i = 0; i < tmp.length; i++) {
array[i+left] = tmp[i];
}
}
代码很好理解,注意看注释即可。
二、无需比较的排序
One 计数排序
官方解释:
计数排序是一个非基于比较的排序算法,该算法于1954年由 Harold H. Seward 提出。它的优势在于在对一定范围内的整数排序时,它的复杂度为Ο(n+k)(其中k是整数的范围),快于任何比较排序算法。 当然这是一种牺牲空间换取时间的做法,而且当O(k)>O(nlog(n))的时候其效率反而不如基于比较的排序(基于比较的排序的时间复杂度在理论上的下限是O(nlog(n)), 如归并排序,堆排序)
计数排序的原理很简单,就是遍历整个数组,假设遍历到元素为4,新的这个数组的4下标就加1,以此类推,遍历完成后。再去遍历新的数组,当遍历到元素不为0时,就输出当前下标到原数组,元素大小为几就输出几次。
还是来看看代码,便于理解
/**
* 计数排序
*
* 时间复杂度:O(N+范围)
* 空间复杂度:O(范围)
* 稳定性:不稳定
*
* 适用于一组数据集中在一定范围内
*
* @param array
*/
public static void countingSort(int[] array) {
int min = array[0];
int max = array[0];
for (int i = 0; i < array.length; i++) {
if(array[i] < min) {
min = array[i];
}
if(array[i] > max) {
max = array[i];
}
}
int len = max-min+1;
int[] count = new int[len];
for (int i = 0; i < array.length; i++) {
count[array[i] - min]++;
}
int j = 0;
for (int i = 0; i < count.length; i++) {
while (count[i] > 0) {
array[j++] = i + min;
count[i]--;
}
}
}
由于计数排序是将原数组的元素大小,让新数组的该元素大小的下标位置加1,这就说明计数排序更使用于一组数据比较集中的数组,假设一组数据为{1,20,100,1000},那么我们新创建的数组长度至少为1000,但是这样中间很多空间都浪费掉了。
总结:
以上排序,只有插入排序,冒泡排序,还有归并排序是稳定的。快速排序,堆排序和归并排序时间复杂度虽然都为O(n*log(n)),但总体来说,还是快速排序效率较高,毕竟人家叫快速排序嘛。
tip:
1.以上对于排序的分类,只是粗略的分组,无需过多在意,例如堆排序也可以放在交换排序下面。
2.以上所讲到的排序方法并不是所有,例如不基于比较的排序方法还有基数排序🔍,桶排序🔍等,基数排序很有意思,比较巧妙,有兴趣的可以去了解一下。
图片来源:百度百科