目录
[1.1 排序概念](#1.1 排序概念)
[1.2 排序的实际应用](#1.2 排序的实际应用)
[1.3 常见的排序算法](#1.3 常见的排序算法)
[2.1.1 直接插入排序](#2.1.1 直接插入排序)
[2.1.1.1 思想特点详解](#2.1.1.1 思想特点详解)
[2.1.1.3 代码演示(vs中)](#2.1.1.3 代码演示(vs中))
[2.1.2.1 思想特点详解](#2.1.2.1 思想特点详解)
[2.1.1.2 希尔排序的思路流程图](#2.1.1.2 希尔排序的思路流程图)
[2.1.1.4 代码演示(vs)](#2.1.1.4 代码演示(vs))
[2.2 选择排序](#2.2 选择排序)
[2.2.1 直接选择排序](#2.2.1 直接选择排序)
[2.2.1.3 排序性能分析:](#2.2.1.3 排序性能分析:)
[2.2.1.4 代码演示(vs)](#2.2.1.4 代码演示(vs))
[2.2.2 堆排序](#2.2.2 堆排序)
[2.2.2.1 思想特点详解](#2.2.2.1 思想特点详解)
[2.2.2.2 排序性能分析](#2.2.2.2 排序性能分析)
[2.2.2.3 代码演示(vs)](#2.2.2.3 代码演示(vs))
[2.3 交换排序](#2.3 交换排序)
[2.3.1 冒泡排序](#2.3.1 冒泡排序)
[2.3.1.1 思想特点详解](#2.3.1.1 思想特点详解)
[2.3.1.2 代码演示(vs)](#2.3.1.2 代码演示(vs))
[2.3.1.3 排序性能分析](#2.3.1.3 排序性能分析)
[2.3.2 快速排序](#2.3.2 快速排序)
[2.3.2.1 思想特点详解](#2.3.2.1 思想特点详解)
[2.3.2. 排序性能分析](#2.3.2. 排序性能分析)
[2.3.2.3 快速排序的优化](#2.3.2.3 快速排序的优化)
[2.4 归并排序](#2.4 归并排序)
[2.4.1.1 思想特点详解](#2.4.1.1 思想特点详解)
[2.4.1.2 排序性能分析](#2.4.1.2 排序性能分析)
在数据结构初阶中,清楚了顺序表,链表的使用,熟悉了二叉树的基本特性,这一篇就来看看数据结构的算法,排序篇。这一节,我们将熟悉排序的概念和应用场景,各种排序算法的基本实现和优化方法,以及哪些排序是稳定的,哪些排序是不稳定的,它们的时间复杂度和空间复杂度是多少,这些都是需要了解并掌握的。这次只关注内部排序,不对外部排序做描述。
1.排序的概述以及应用
1.1 排序概念
排序:就是使一串可以进行比较的字符串,元素或者结构体集合,按照元素的某个关键字进行比较 之后最终获得一串降序/升序的有序排列。在生活中,最常见的我们都知道的排序其实是直接插入,我们在打扑克牌或者麻将,都是要对手里的牌进行排列组合,直到手里的牌都按照一定规律有序,其实这就是排序。
排序的稳定性:在排序之前,很有可能会出现有相同元素的序列,这些相同的元素在排序完后相对位置如果是不变的,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,那这个排序就可以说是稳定的,不然要是排序完,相同元素相对位置发生了交换,就是不稳定的。
内排序:数据元素全在内存中的排序。
外排序:数据不可能一下子全放在内存中,处理无法一次性载入内存的大量数据模块的排序算法,通过内存和外存之间的数据交换完成排序。
1.2 排序的实际应用
在现实生活中,需要运用到的排序场景其实非常多,比如12306买票,你要选择时间最短,或者票价从最低到最高。再者就是淘宝,京东购物,你从价格从高到低,综合人气从高到低,都是排序,考研中查看录取名单,也是分数从高到低排序的。每个场景采用的排序算法也不尽相同。那么有哪些常见的算法会被应用到实际场景中呢?
1.3 常见的排序算法
排序算法一般分为插入排序,选择排序,交换排序和归并排序,这些都是内部排序
- 插入排序:直接插入排序,希尔排序;
- 选择排序:堆排序,选择排序;
- 交换排序:冒泡排序(经典),快排;
- 归并排序:归并;
- 外部排序:计数排序,桶排序,基数排序;
2.排序算法的基本实现和优化方法
2.1插入排序
插入排序是一种比较简单的排序思路,就是把第一个元素当作已经排好序的元素,剩下的算作一个未排序的序列,将里面的元素和新排好的进行对比大小,直到所有未排序列全部插入完毕,得到新的有序序列,可以是降序,也可以是升序。
2.1.1 直接插入排序
2.1.1.1 思想特点详解
想象成打扑克,你在玩牌的过程中就是在实现直接插入,把它转换为代码思想就是,当插入第i(i>=1)个元素时,前面的array[0],array[1],...,array[i-1]已经排好序,此时用array[i]的排序与 array[i-1],array[i-2],...的排序顺序进行比较,找到插入位置即将array[i]插入,原来位置上的元素顺序后移。
那么可以看出直接插入有一些特性,如果原始序列本身就比较有序,那所需要排序的就少,该算法的排序效率就比较高,时间复杂度为,最好情况是已经有序,前后依次比较只需要n-1次,不用进行移动。复杂度为
。最坏的情况是倒序,一个序列是从小到大排的,但是需求是反过来,所有要比较1+2+3+......+(n-1)次,移动次数也是,每个元素都要移动到tmp,再移动到需要插入的位置里,这是固定的。模糊算出来有平方项。它是稳定的,因为当有一个数和前面有序序列中的元素值一样时,它会插入到那个数的后面,所以相对位置不会变。
2.1.1.2排序性能分析:
时间复杂度:
空间复杂度:
稳定性:稳定
2.1.1.3 代码演示(vs中)
cpp
void Insertsort(int*a,int n)
{
//升序排序
for(int i=1;i<n,i++)
{
int tmp=a[i]
for(int j=i-1;j>=0&&tmp<a[j];j--)
{
a[j+1]=a[j];
}
a[j+1]=tmp;
}
2.1.2希尔排序
2.1.2.1 思想特点详解
希尔排序有个增量系数,这个系数不是固定的,但不能太小也不能太大。希尔排序法的基本思想是:先选定一个整数(系数),把待排序文件中所有记录分成n个组,所有距离为系数的记录分在同一组内,并对每一组内的记录进行排序,可以是降序或者升序。一轮排序完,把间隔数缩小,再次组内排序,重复上述分组和排序的工 作。当到达=1时,所有记录在统一组内排好序。
希尔排序的特点:
- 希尔排序是对直接插入的优化版本,因为在数据量比较大的时候,用直接插入的效率其实并不高。
- 在完成最后一次排序之前,前面的排序可以看作是预排序,也就是gap>1时,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样就会很快。这样整体而言,可以达到优化的效果。
- 由于gap取值不唯一,所以希尔排序的时间复杂度并不好计算。严老师的书里有这么一段话。

2.1.2.2 希尔排序的思路流程图
更直观的感受到希尔排序的思路是如何排序的:
希尔
2.1.2.3排序性能分析:
时间复杂度:没有具体的确切值
空间复杂度:
稳定性:不稳定,在不同组之间的交换后,很有可能会发生不同组之间相同元素位置发生变化。
2.1.2.4 代码演示(vs)
cpp
void shellsort(int *a ,int n)
{
int gap = n;
while(gap>1)
{
//控制gap
gap/=2;
// 分组,按组来排序
for (int j = 0; j < gap; j++)
{
//组内排序
for (int i = j; i < n - gap; i += gap)
{
int end = i;
int tmp = a[i + gap];
while (end >= 0)
{
if (tmp < a[end])
{
a[end + gap] = a[end];
end -= gap;
}
//刚好有序就直接跳出这次循环
else
{
break;
}
}
a[end + gap] = tmp;
}
}
}
代码解释:当gap大于1时,始终是预排序,是为了让序列更加有序,直到gap为1时,就直接成为了直接插入。希尔排序的循环比较多,第一层是分几个组,一般组数和gap数大小一致,第二层是组内排序,第三层是循环移动元素,找到当前元素的正确位置。
2.2 选择排序
每一次从待排序的数据元素中选出最小/最大的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
2.2.1 直接选择排序
2.2.1.1思想特点详解
在元素集合中选择关键码最大(小)的数据元素 若它不是这组元素中的最后一个元素,则将它与这组元素中的起始元素交换,在剩余
集合中,重复上述步骤,直到集合剩余1个元素。直接选择的理解很方便,很直观,但是效率其实并不高,在现实中不怎么常用。
2.2.1.2选择排序的思路流程图:
选择排序思路图
2.2.1.3 排序性能分析:
时间复杂度:
空间复杂度:
稳定性:从流程图可以看出,不稳定,一开始左边是黑3,右边是红,最后俩元素位置相反。
2.2.1.4 代码演示(vs)
cpp
void SelectSort(int* a, int n)
{
int left = 0, right = n - 1;
while (left < right)
{
int mini = left, maxi = left;
for (int i = left + 1; i <= right; i++)
{
if (a[i] < a[mini])
{
mini = i;
}
if (a[i] > a[maxi])
{
maxi = i;
}
}
Swap(&a[left], &a[mini]);
// 如果left和maxi重叠,交换后修正一下
if (left == maxi)
{
maxi = mini;
}
Swap(&a[right], &a[maxi]);
++left;
--right;
}
然而在这组代码中,是做了一定优化的,做了一定变形,可以直接选两个最值,一次遍历同时找到当前区间最小值和最大值,会一定程度减少排序轮次,
2.2.2 堆排序
2.2.2.1 思想特点详解
堆排序(Heapsort)是指利用堆积树(堆)这种数据结构所设计的一种排序算法,它是选择排序的一种。它是通过堆来进行选择数据。需要注意的是排升序要建大堆,排降序建小堆 。这个在二叉树里有详细的说明,这里就不特别展开了。传送门在这树:二叉树的认识(下)-CSDN博客
2.2.2.2 排序性能分析
- 堆排序用堆来选数,效率会高很多。
- 时间复杂度:
- 空间复杂度:
- 稳定性:不稳定
2.2.2.3 代码演示(vs)
cpp
// 左右子树都是大堆/小堆
void AdjustDown(int* a, int n, int parent)
{
int child = parent * 2 + 1;
while (child < n)
{
// 选出左右孩子中大的那一个
if (child + 1 < n && a[child + 1] > a[child])
{
++child;
}
if (a[child] > a[parent])
{
Swap(&a[child], &a[parent]);
parent = child;
child = parent * 2 + 1;
}
else
{
break;
}
}
}
void HeapSort(int* a, int n)
{
// 建堆 -- 向下调整建堆 -- O(N)
for (int i = (n - 1 - 1) / 2; i >= 0; --i)
{
AdjustDown(a, n, i);
}
int end = n - 1;
while (end > 0)
{
Swap(&a[end], &a[0]);
AdjustDown(a, end, 0);
--end;
}
}
2.3 交换排序
基本思想 :所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
2.3.1 冒泡排序
2.3.1.1 思想特点详解
经典的排序算法之一,教学常用。用起来也比较简单,非常容易理解,就是俩俩之间比较,升序的话,大的元素往后面,元素小的往前面放。排完一轮就会局部有序,然后再一次重复待排序列表,比较相邻元素并交换它们的位置,第一轮会把最大的元素放在最后面,第二轮是放次小的,如此重复n-1轮。由于比较简单,直接就上代码演示
2.3.1.2 代码演示(vs)
cpp
void BubbleSort(int* a, int n)
{
for (int j = 0; j < n; j++)
{
bool exchange = false;
for (int i = 1; i < n-j; i++)
{
if (a[i - 1] > a[i])
{
Swap(&a[i - 1], &a[i]);
exchange = true;
}
}
if (exchange == false)
{
break;
}
}
}
2.3.1.3 排序性能分析
时间复杂度:
空间复杂度:
稳定性:稳定。
2.3.2 快速排序
顾名思义,快速排序之所以敢称之为快排,那肯定有比其他算法效率更高的优势,没错,在效率上,通常是更注重时间效率而牺牲空间 ,就是以空间换时间。
2.3.2.1 思想特点详解
快排其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列 ,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值 ,然后左右子序列重复该过程,直到所有元素都排列在相应位置上为止。由此可见是要递归的,空间复杂度会比较大。下面是递归的主体框架,看起来和二叉树的前序遍历比较像.
cpp
// 假设按照升序对array数组中[left, right)区间中的元素进行排序
void QuickSort(int array[], int left, int right)
2
{
if(left>=right)
return;
// 按照基准值对array数组的 [left, right)区间中的元素进行划分
int div = partion(array, left, right);
// 划分成功后以div为边界形成了左右两部分 [left, div) 和 [div+1, right)
// 递归排[left, div)
QuickSort(array, left, div);
// 递归排[div+1, right)
QuickSort(array, div+1, right);
}
这个partion有三个版本,是按照不同基准值,也可以叫做中轴,把区间分成左右俩部分,一种是最初的版本,hoare版本,挖坑法和前后指针法。时间复杂度其实没有区别,但是实际运行效率不一致。
1.hoare版本,也是最原始的双向指针法
左右指针同时向中间逼近,选基准pivot,左指针找大,右指针找小,找到元素后交换位置,直到两者相遇,相遇位置放上pivot。
cpp
int PartSort1(int* a, int left, int right)
{
int keyi = left;
while (left < right)
{
// 右边找小
while (left < right && a[right] >= a[keyi])
--right;
// 左边找大
while (left < right && a[left] <= a[keyi])
++left;
Swap(&a[left], &a[right]);
}
Swap(&a[keyi], &a[left]);
keyi = left;
return keyi;
}
2.挖坑法
先把pivot值保存起来,然后把那个数的下标位置记作坑位,左边找小元素填左坑,右边找大元素填右坑,最后的坑位hole下标放入一开始存的pivot。
cpp
int PartSort2(int* a, int left, int right)
{
int pivot = a[left];
int hole = left;
while (left < right)
{
// 右边找小
while (left < right && a[right] >= key)
--right;
a[hole] = a[right];
hole = right;
// 左边找大
while (left < right && a[left] <= key)
++left;
a[hole] = a[left];
hole = left;
}
a[hole] = pivot;
return hole;
}
3.前后指针法
单方向遍历,设定一个prev指针,慢指针,cur=left+1快指针往前遍历,当cur遇到比pivot值小的元素,prev++,交换cur和prev,最后交换pivot和prev的值。(经典教材写法)
cpp
int PartSort3(int* a, int left, int right)
{
int keyi = left;
int prev = left;
int cur = left + 1;
while (cur <= right)
{
if (a[cur] < a[keyi] && ++prev != cur)
Swap(&a[cur], &a[prev]);
++cur;
}
Swap(&a[prev], &a[keyi]);
keyi = prev;
return keyi;
}
2.3.2. 2排序性能分析
从比较次数来看,前后指针法需要比较次数很多,大于挖坑法,然后挖坑法比霍尔版本的要稍微多一些。也就是前后指针法>挖坑法>霍尔法。实际性能上也是霍尔大佬的版本最快,前后指针最慢,单数理论上时间复杂度是没有区别的。
2.3.2.3 快速排序的优化
除了找到这三者中快的,我们还可以更快,做更快的优化,就是在选pivot(key)中选择三数取中或者随机选key的方式,递归到小的子区间时,可以考虑使用插入排序,以最大化的提高性能。
-
三数取中:也很好理解,就是在最左边和最右边,然后取一个中间值,比较三者之间的大小,选择中间的那个数,比如 4,9 ,20那就选9。就是需要不断的判断三者关系,函数代码如下
cppint GetMidNumi(int* a, int left, int right) { int mid = (left + right) / 2; if (a[left] < a[mid]) { if (a[mid] < a[right]) { return mid; } else if (a[left] > a[right]) { return left; } else { return right; } } else // a[left] > a[mid] { if (a[mid] > a[right]) { return mid; } else if (a[left] < a[right]) { return left; } else { return right; } } }这样选出来的值不会很极端,普通的值选出来在数列比较有序的情况下,pivot永远是最大值或者最小值,分区是不平均的,会导致一个分区没有元素,另一个分区全是数字。而三数取中的中位数是一个平均值,会解决上述的所有问题,更适合递归,减少了递归深度,防止溢出问题,那么它的价值就体现的淋漓尽致,很低的开销,打破了有序数组下的最坏情况,让快排性能稳定,递归平衡,就连随机选key也失去光辉。
-
随机选key:取到数组中的随机数,这样很大概率上避免了直接固定死了,万一这个数组第一个很小或者是个比较有序的数组就会效率很低,递归失衡 。效果是比普通左边选key要好,极端数据不太可能出现。但是同样也会有缺点,在数据量特别大的时候,开销要比三数取中大,而且也不是绝对稳定,三数取中是百分百避开,而随机选key是概率问题,概率只是变小,不是不可能存在。
cppint randi = left + (rand() % (right - left)); Swap(&a[left], &a[randi]);*/
我个人还是喜欢三数取中,虽然说代码比随机选key长,但是优点比较明显。所以结合一下hoare法和三数取中,然后在递归到后面的小区间,其实就不用再三数取中了,直接插入排序就可以了。
cpp
int PartSort(int* a, int left, int right)
{
// 三数取中
int midi = GetMidNumi(a, left, right);
if (midi != left)
Swap(&a[midi], &a[left]);
int keyi = left;
while (left < right)
{
// 右边找小
while (left < right && a[right] >= a[keyi])
--right;
// 左边找大
while (left < right && a[left] <= a[keyi])
++left;
Swap(&a[left], &a[right]);
}
Swap(&a[keyi], &a[left]);
keyi = left;
return keyi;
}
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
// 小区间优化--小区间直接使用插入排序
if ((right - left + 1) > 10)
{
int keyi = PartSort(a, left, right);
QuickSort(a, left, keyi - 1);
QuickSort(a, keyi + 1, right);
}
else
{
InsertSort(a+left, right - left + 1);
}
}
2.4 归并排序
2.4.1归并排序
2.4.1.1 思想特点详解
归并排序(MERGE-SORT)是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
2.4.1.2选择排序的思路流程图:
归并思路图
2.4.1.2 排序性能分析
1.归并的缺点在于需要的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。
-
时间复杂度:
-
空间复杂度:
-
稳定性:稳定
2.4.1.3 代码演示(vs)
cpp
void _MergeSort(int* a, int begin, int end, int* tmp)
{
if (begin >= end)
return;
int mid = (begin + end) / 2;
// [begin, mid] [mid+1,end],子区间递归排序
_MergeSort(a, begin, mid, tmp);
_MergeSort(a, mid+1, end, tmp);
// [begin, mid] [mid+1,end]归并
int begin1 = begin, end1 = mid;
int begin2 = mid+1, end2 = end;
int i = begin;
while (begin1 <= end1 && begin2 <= end2)
{
if (a[begin1] < a[begin2])
{
tmp[i++] = a[begin1++];
}
else
{
tmp[i++] = a[begin2++];
}
}
while (begin1 <= end1)
{
tmp[i++] = a[begin1++];
}
while (begin2 <= end2)
{
tmp[i++] = a[begin2++];
}
memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));
}
void MergeSort(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail\n");
return;
}
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
}
3.算法排序的应用场景
不同排序算法适用于不同的场景,根据数据规模,数据的分布规律和电脑本身的性能选择适合的算法。
对于小规模数据:可以使用插入排序,和冒泡排序;
对于大规模数据:建议使用快速排序和归并排序。
特定条件数据:基数排序,计数排序,桶排序
4.排序算法的复杂度和稳定性总结


5.总结
到这里基本上数据结构初阶的内容都讲的差不多了,还有一些细节博主学的还不好,不了解,比如外部排序和快排的非递归和归并的非递归,比较难以理解,后面有机会再单独写一篇仔细学习,那么数据结构部分也是告一段落了,从顺序表到链表,从栈到队列,一直到排序算法。学习完就对常用的数据结构和算法有了一定的了解。接下来我会开启新篇章,更新关于C++,C++还是蛮晦涩难懂的,其实已经学了一部分了,只是没有吸收,很多都是初步理解,还没有画思维导图巩固,博主会继续努力补功课的!感谢大家观看,如有错误请积极指正。