几种常见的排序算法
索引
排序算法中的概念
排序的概念
假设含有n个记录的序列为\(\{r_1,r_2,\cdots,r_n\}\), 其相应的关键字为\(\{k_1,k_2,\cdots,k_n\}\),
需要确定\(1,2,\cdots,n\)的一种排列\(\{p_1,p_2,\cdots,p_n\}\),
使其相应的关键字满足\(k_{p1} \leq k_{p2} \leq \cdots \leq k_{pn}\)(非递减或非递增)关系,
即使得序列成为一个按关键字有序的序列\(\{ r_{p1}, r_{p2}, \cdots, r_{pn} \}\),这样的操作就称为排序。
排序的稳定性
假设\(k_i=k_j\) (1≤i≤n, 1≤j≤n, i≠j),且在排序前的序列中\(r_i\)领先于\(r_j\)(即i<j)。
如果排序后\(r_i\)仍然领先于\(r_j\),则称所用的排序方法是稳定的;
反之,若有可能使得排序后的序列中\(r_j\)领先\(r_i\),则称所用的排序方法是不稳定的。
内排序和外排序
内排序是在排序整个过程中,待排序的所有记录全部被放置在内存中。
外排序是由于排序的记录个数太多,不能同时放置在内存,整个待排序过程需要在内外存之间多次交换数据才能进行。
对于内排序来说,排序算法的性能主要受3个方面影响:
- 时间性能:主要有两种操作:比较和移动。高效的内排序算法应该具有尽可能少的关键字比较次数和尽可能少的记录移动次数。
- 辅助空间:算法所需要的辅助存储空间。辅助存储空间是指除了存放待排序所占用的存储空间外,执行算法所需要的其他存储空间。
- 算法的复杂性:这里说的是算法本身的复杂性,不是指算法的时间复杂度。按照算法的复杂度分为两大类:
- 简单算法:冒泡排序,简单选择排序,直接插入排序。
- 改进算法:希尔排序,堆排序,归并排序,快速排序。
几种常见的排序
公共代码:
// 交换两个变量的值
void swap(int *a, int *b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
1 冒泡排序
冒泡排序是一种交换排序,基本思想是:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。
第一种方式
void bubble_sort1(int *data, int length)
{
int i = 0, j = 0;
for (i = 0; i < length - 1; i++) { // i从左向右移动,i每次需要向右移动时,就代表着i位置的元素被确定了
for (j = i + 1; j < length; j++) { // j从i右边开始向右出发
if (data[i] > data[j]) { // 只要j位置元素遇到比i位置元素小,就交换位置,使i位置是最小的元素
swap(&data[i], &data[j]);
}
}
}
}
过程解析:
-
i指向第一个元素,然后j为i后面的元素,j依次向后移动和i进行比较,前面的大就交换位置,第一轮结束后,第1个位置的元素就是最小的。
-
i向后移动一个位置,然后与后面的所有位置j元素进行比较,结束后,第2个位置的元素就是第2小的元素。
-
依次进行,直到i到达倒数第二个位置,就将所有的元素比较一遍了。此时数组就是有序的。
-
每一次遍历,就是把剩下的当中的最小值放到最前面,最小值就是这样像冒泡的方式一点一点移动到最前面的。
第二种方式
这一种是正宗的冒泡排序,因为冒泡排序的定义是比较相邻的两个元素。
void bubble_sort2(int *data, int length)
{
int i = 0, j = 0;
for (i = 0; i < length - 1; i++) { // i从左向右移动,i每次需要向右移动时,就代表着i位置的元素被确定了
for (j = length - 1; j > i; j--) { // j从右向左移动,遇到i结束
if (data[j - 1] > data[j]) { // 把i到末尾之间的最小元素一步一步的移动到i位置
swap(&data[j - 1], &data[j]);
}
}
}
}
过程解析:
- n个元素,先比较第n-1和n元素,再比较 n-2和n-1,直到 比较第1个和第2个元素,至此,第1个元素就是最小的了。
- 然后再按同样的方式,排序后面的n-1个元素。
- 每一次遍历,就是把剩下的当中的最小值放到最前面,最小值就是这样像冒泡的方式一点一点移动到最前面的。
第三种方式
改进第二种方式:假如后面n-m个元素已经是顺序的了,但是按照第2种方式,依然还是要对后面的n-m所有的元素进行遍历比较。
void bubble_sort3(int *data, int length)
{
int i = 0, j = 0;
int need_sort = 1;
for (i = 0; i < length - 1 && need_sort; i++) { // 如果不需要排序,则退出循环
need_sort = 0; // 默认置为0
for (j = length - 1; j > i; j--) {
if (data[j - 1] > data[j]) {
swap(&data[j - 1], &data[j]);
need_sort = 1; // 如果有数据交换,则还需要排序
}
}
}
}
时间复杂度为\(O(n^2)\):
- 如果已经是一个排序好的数组,则仅需要比较n-1次,为\(O(n)\)。
- 如果是一个逆序的数组,则需要比较 \((n-1)+(n-2)+...+3+2+1=\frac{n(n+1)}{2}\)次,为\(O(n^2)\)。
2 简单选择排序
冒泡排序的思想就是不断的地在交换,通过交换完成最终的排序。但是频繁的交换也会损耗性能,能不能只移动一次就完成相应关键字的排序定位工作呢?这就是选择排序算法的初步思想。
简单选择排序就是通过n-i次关键字间的比较,从n-i+1个记录中选出关键字最小的记录,并和第i(1<=i<=n)个记录交换。
void select_sort(int *data, int length)
{
int i = 0, j = 0;
for (i = 0; i < length - 1; i++) { // i从左向右移动,i每次需要向右移动时,就代表着i位置的元素被确定了
int min = i; // 假定当前位置(i位置)的元素最小
for (j = i + 1; j < length; j++) { // 找到i后面元素中最小的元素,并记录下它的位置
if (data[min] > data[j]) {
min = j;
}
}
// 遍历完了一次,找到了最小值的下标就是min,如果min不是第一个,则和第一个交换,使第一个元素是最小的
if (min != i) {
swap(&data[i], &data[min]);
}
// 然后将剩下的n-i个元素也按这样的方式排序
}
}
过程解析:
- 和冒泡排序的判断方式一样,不同的是,简单选择排序每次判断后并不交换数据,只是将它的位置记录下来。
- 等到遍历完成一次,再将最小元素交换到前面。
特点:
- 和冒泡排序的遍历方式差不多,但是比较2个元素需要交换时,选择排序只是将它记下来。
- 找到当前遍历完一遍时最小的元素,然后再交换。
- 这样,最好的情况下交换次数为0,最差的情况下交换次数为n-1。
- 但是无论最好最差情况下,比较次数都是固定的\((n-1)+(n-2)+...+3+2+1=\frac{n(n+1)}{2}\)次,为\(O(n^2)\)。
- 时间复杂度依然为\(O(n^2)\),和冒泡排序一样,但是性能上还是略优于冒泡排序。
- 简单选择排序最大的特点就是交换移动数据的次数相当少。
- 简单选择排序是不稳定的,因为当两个元素交换时,假如前面的这个元素被交换到了最后一位,那么原本在它后面的相同值的元素就跑到它前面去了。
3 直接插入排序
概念:直接插入排序的基本操作就是将一个记录插入到已经排好序的有序表中,从而得到一个新的记录数增1的有序表。
可以类比为打扑克牌,手上已有的牌是有序的,然后将新牌插入到有序的牌中,形成一个新的张数加1的有序牌。
void insert_sort(int *data, int length)
{
int i = 0, j = 0;
for (i = 1; i < length; i++) { // 下标为0的元素自己就是有序的,因此这从1开始,每次要插入的元素就是i位置的元素
// 如果要插入的元素比它前面的有序数列最后一个还大,则不需要移动任何元素,
// 如果要插入的元素比它前面的有序数列最后一个还小,则要把当前这个数插入到前面的有序表中
if (data[i] < data[i - 1]) {
// 核心代码,将data[i]插入到前面正确的位置
int tmp = data[i];
for (j = i - 1; j >= 0 && data[j] > tmp; j--) {
data[j + 1] = data[j]; // 向后移动
}
// 此时,比tmp大的元素都已经移动到后面去了,j+1的位置就空了出来,来存放新插入的元素tmp
data[j + 1] = tmp;
}
}
}
时间复杂度:
- 最好的情况下:本身就是有序的,仅需比较n-1次,移动0次,时间复杂度是\(O(n)\)。
- 最坏的情况下:本身是逆序的。
- 需要比较 \(2+3+...+n=\frac{(n+2)(n-1)}{2}\)次。
- 需要移动\(3+4+...+n+n+1=\frac{(n+4)(n-1)}{2}\)次。
- 时间复杂度为\(O(n^2)\)。
- 同样为\(O(n^2)\)时间复杂度,直接插入排序比冒泡和简单选择排序的性能要好一些。
4 希尔排序
我们先明确直接插入排序的特点:
- 如果一个记录基本上接近是有序的,那么直接插入排序效率是很高的,因为只需要少量的插入操作,就可以完成整个记录集的排序工作。
- 还有就是记录数比较少的时候,直接插入排序优势也比较明显。
但实际排序中,满足上面条件是不可能的。
希尔排序就是为直接插入排序创造这样的条件,让它每次排序都尽可能的少的元素或尽可能接近有序的元素参与排序,
让直接插入排序的优势发挥出来。
思想
- 第1步,将他们分成很多组,每组只有2个元素,让它们使用直接插入排序,每组都变成有序状态。 这满足了直接插入排序的 元素较少时效率高的特点。
- 第2步,将分组减少,每组的元素变多了一点,但是它们相比于没有初始状态下,经过了第1步,有序的程度较高了一些。
- 第3步,再将分组减少,此时的每组的元素增多,但每组的有序程度都比之前更好了。
- 经过n次之后,当分组是有1个的时候,此时的整个记录是基本有序的了,
此时再次使用直接插入排序对整个记录排序,效率将非常的好,因为这满足了上面插入排序的特点1。
需要说明的是:基本有序:就是小的元素基本上在前面,大的元素基本上在后面,不大不小的元素基本在中间。
因此,如何分组,是关键。将相距某个"增量"的记录组成一个子序列,这样才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序而不是局部有序。
注意:
- 增量序列的最后一个增量值必须等于1才行,因为这需要完整的对整个集合排序。
- 由于元素是跳跃式的移动,因此希尔排序并不是一种稳定的排序算法。
实现
void shell_sort(int *data, int length)
{
int gap = 0;
int i = 0, j = 0;
int temp = 0;
// 先让每组2个元素,length/2就是组数,也就是"增量"
// 然后依次降低组数,直到增量为1,此时就剩1组了,也就是整个待排集合
for (gap = length / 2; gap >= 1; gap /= 2) {
// 对每组使用直接插入排序方法
for (i = gap; i < length; i++) {
temp = data[i];
// 下面的代码,对比上一篇的直接插入排序,gap就是1
// 这里是希尔排序的精华所在,它将较小的元素,不是一步一步往前挪动,而是跳跃式的向前移
// 从而使得每次完成最外层的for循环一次,整个序列就朝着有序坚实的迈进一步。
for (j = i - gap; j >= 0 && temp < data[j]; j = j - gap) {
data[j + gap] = data[j];
}
data[j + gap] = temp;
}
}
}
5 归并排序
第一种方式:递归
思想:
- 分而治之,先把一个整体,无限制的分成最小。
- 然后再把这些小的组一点点有序的归并起来,最后归并成一个完整的有序的集合
- 先递归细分(自上而下) -> 再排序归并(自下而上)
// 核心代码:将[start, middle]和[middle+1, end]的两个有序集合合并为一个[start, end]的有序集合
void _sort(int *data, int *temp, int start, int middle, int end)
{
int i = start, j = middle + 1, k = start;
// 只要有其中一个集合中的数据取完了,就应该结束循环,
// 然后将另一个有序集合中剩余的元素追加到新集合中
while (i <= middle && j <= end) {
// 这里应该加上等号
// 假如data[i]==data[j],如果不加等号,那么就会把data[j]放到前面,造成排序是不稳定的
if (data[i] <= data[j]) {
temp[k++] = data[i++];
} else {
temp[k++] = data[j++];
}
}
// 将剩下的追加到temp后面
while (i <= middle) {
temp[k++] = data[i++];
}
while (j <= end) {
temp[k++] = data[j++];
}
// 再把临时数据拷贝到data中
for (i = start; i <= end; i++) {
data[i] = temp[i];
}
}
// 只要是start<end就继续拆分,start<end表示每个组还有多个元素
// 当每个组就只有1个元素时,就开始返回,开始执行_sort进行两两归并
void _merge_sort(int *data, int *temp, int start, int end)
{
if (start < end) {
int middle = start + (end - start) / 2; // 分成2部分
_merge_sort(data, temp, start, middle); // 将前面的部分进行排序
_merge_sort(data, temp, middle + 1, end); // 将后面的部分进行排序
_sort(data, temp, start, middle, end); // 将两个有序的和并成为一个有序的
}
}
// 申请临时空间,递归函数_merge_sort的入口
int merge_sort(int *data, int length)
{
// 准备临时空间
int *temp = (int *) malloc(length * sizeof(int));
if (temp == NULL) {
return 1;
}
_merge_sort(data, temp, 0, length - 1);
free(temp);
return 0;
}
时间复杂度:\(O(nlogn)\)。
空间复杂度:由于只申请了一块和原数据长度一样的内存空间,因此空间复杂度是\(O(n)\)。
第二种方式:非递归
思想:
- 分而治之,把整体的每一个元素当成最小的组。
- 然后再把这些小的组一点点有序的归并起来,最后归并成一个完整的有序的集合
- 自下而上排序归并
// 核心代码:将(data中的)[start, middle]和[middle+1, end]的两个有序集合合并为一个[start, end]的有序集合(存放在temp中)
// 和上一个例子中的_sort不同,这里最后不需要再将temp里面的数据拷贝会data中
// 注意:middle是包含在第1组里面的
void _sort(const int *data, int *temp, int start, int middle, int end)
{
int i = start, j = middle + 1, k = start;
// 只要有其中一个集合中的数据取完了,就应该结束循环,
// 然后将另一个有序集合中剩余的元素追加到新集合中
while (i <= middle && j <= end) {
// 这里应该加上等号
// 假如data[i]==data[j],如果不加等号,那么就会把data[j]放到前面,造成排序是不稳定的
if (data[i] <= data[j]) {
temp[k++] = data[i++];
} else {
temp[k++] = data[j++];
}
}
// 将剩下的追加到temp后面
while (i <= middle) {
temp[k++] = data[i++];
}
while (j <= end) {
temp[k++] = data[j++];
}
// 再把临时数据拷贝到data中,这里不再需要拷贝,
// 如果是像上一篇那样,使用递归的方式,就需要拷贝回去,
// 因为递归当中,每次使用的源数据都是在data中的,
// 做不到像这样,数据一会在data中,一会在temp中。
// for (i = start; i <= end; i++) {
// data[i] = temp[i];
// }
}
// 核心代码
// 将src中相邻的长度为gap的子序列两两归并到dst中
void _merge_pass(int *src, int *dst, int gap, int length)
{
int i = 0;
int j;
// length - 2*gap, 2gap就是2组的长度, length-2*gap出来就是恰好剩余是2组长度的时的下标。
// 如果i大于这个下标,就表示从i开始到最后已经没有2组的长度了,
// 有可能剩1组多点,也有可能剩下的不到1组。退出循环。
while (i <= length - 2 * gap) {
// 两两归并 ([i, i+gap-1] 是第1组,[i+gap, i+2*gap-1]是第2组)
_sort(src, dst, i, i + gap - 1, i + 2 * gap - 1);
i += 2 * gap; // 向后跳2个子序列长度
}
// 如果i后面剩下的超过1组,但是不到2组的长度,说明还有2个子序列,只是第2个子序列比第1个要短而已,
// 我们依然要合并这2个子序列
// 这里就不需要=号,因为如果等于就表示恰好剩余的长度是1个组的长度,那也就不需要合并,直接拷贝进来就行了
// 因为单个子序列本身就是有序的
if (i < length - gap) {
_sort(src, dst, i, i + gap - 1, length - 1);
} else {
// 剩下的就是1个有序的子序列
for (j = i; j < length; j++) {
dst[j] = src[j];
}
}
}
// 对data中的数据进行归并排序
void _merge_sort(int *data, int *temp, int length)
{
int k = 1; // 每个组的长度,也可以称为组距。默认情况下,每个元素自己就是一个组,这样就确保每个组都是有序的
// 每组的间距还没有到达整个数组的长度,说明还有多组,还需要继续归并
while (k < length) {
// 这次归并,把数据从data中归并到temp中
_merge_pass(data, temp, k, length);
k *= 2; // 经过上面的归并,子序列长度加倍
// 这次归并,再把temp中的数据归并到data中,他们两个依次的交换过来交换过去
// 这样做的好处是,不用每次使用完temp,就立即把temp里面的数据再拷贝回来,减少拷贝次数
// 有可能会出现这样的情况:就是上面一次归并结束后,就已经只剩下一个组了,那么整个数组就已经是有序的了
// 那么执行下面的代码,就仅仅是从temp把数据拷贝到data中来而已。
_merge_pass(temp, data, k, length);
k *= 2; // 经过上面的归并,子序列长度加倍
}
}
// 这个函数仅仅就是对_merge_sort函数包装一下,申请了临时空间而已。
int merge_sort(int *data, int length)
{
// 准备临时空间
int *temp = (int *) malloc(length * sizeof(int));
if (temp == NULL) {
return 1;
}
_merge_sort(data, temp, length);
free(temp);
return 0;
}
特点:
-
时间复杂度:\(O(nlogn)\)。
-
空间复杂度:由于只申请了一块和原数据长度一样的内存空间,因此空间复杂度是\(O(n)\)。
-
并且避免递归也在时间性能上有一定的提示,应该说,使用归并排序时,尽量考虑用非递归方法。
6 快速排序
思想
- 分而治之,将一个整体分为两部分,其中一部分均比另一部分的元素小。
- 再分别对这两部分继续排序,已达到整个序列有序的目的。
实现
// 核心是partition函数
void _quick_sort(int *data, int start, int end)
{
if (start < end) {
// 把基准的位置返回,基准左边的元素都小于等于基准,右边的元素都大于等于基准
int pivot = partition(data, start, end);
_quick_sort(data, start, pivot - 1); // 对基准左边的元素使用同样的方法排序
_quick_sort(data, pivot + 1, end); // 对基准右边的元素使用同样的方法排序
}
}
// 为了和其他排序方法的参数一致,这里包装一下
void quick_sort(int *data, int length)
{
_quick_sort(data, 0, length - 1);
}
第一种partition函数实现方式
int partition(int *data, int start, int end)
{
int pivot = end; // 这里取最后一个值作为基准值
int i = start, j = end - 1; // i从start向后扫描,j从end前面向前扫描
do {
// i从前向后找,找到比基准值大的,就退出循环
while (i < end && data[i] <= data[pivot])
i++;
// j从后面往前找,找到比基准值小的,就退出循环
while (j > start && data[j] >= data[pivot])
j--;
// 将位于前面的比基准值大的数 和 位于后面的比基准值小的数 交换位置
if (i < j) {
swap(&data[i], &data[j]);
}
} while (i < j); // 继续两端向中间扫描
// 因为基准数据选择的是最后一个,因此要和i交换位置,此时i位置要么是一个比基准值大的数,要么就是基准值本身
// 由于是从小到大排列,所以要将大的数据放到后面去,因此要将i位置的数放到最后,和基准值交换位置
// j位置要么是一个比基准值小的数,要么是最开头的数
swap(&data[pivot], &data[i]);
return i; // 我们把基准值放在了i位置,返回基准值的位置,所以就是i
}
过程解析:
- 把最后一个元素作为基准值,设置从前向后扫描和从后向前扫描(i和j是其对应的下标)。
- i从向后进行扫描时,如果遇到值比基准值大,则停下来;j向前进行扫描时,如果遇到值比基准值小,也停下来。
- 交换i和j元素的位置,并继续重复第2步,直到i不再小于j。
- 此时将基准值和i位置的元素进行交换。
- 至此,基准值左边的元素都不大于基准值,基准值右边的元素都不小于基准值。
- 将基准值的位置返回,外层将按照基准值的位置将整个序列分为前后两个部分,两个部分再分别进行同样的排序。
第二种partition函数实现方式
int partition2(int *data, int start, int end)
{
int i = start, j = end;
int pivotkey = data[start]; // 取第一个值作为基准值
while (i < j) {
// j从后向前找,找到比基准值小的,就退出循环
while (i < j && data[j] >= pivotkey)
j--;
data[i] = data[j]; // 将data[j]这个较小的值放到前面去
// i从前向后找,找到比基准值大的,就退出循环
while (i < j && data[i] <= pivotkey)
i++;
data[j] = data[i]; // data[i]这个较大的值放到后面去
}
// 将key放到"中间"
// 为什么这里是i位置不是j位置? (虽然i等于j)
// 可以看到while循环最后是把i位置的值移动到了j位置上面,因此是i位置被空了出来,所以将pivotkey放在i位置上面。
data[i] = pivotkey;
return i;
}
过程解析:
- 取第1个位置作为基准值,把值保存下来,此时第1个位置的数据我们认为是多余的了,也可以说成第1个位置被空出来了。
- 先从后向前找(这里对应的是下标j),找到比基准值小的值x1,把x1移动到刚才空出来的位置(也就是基准值的位置),此时x1对应的位置被空了出来。
- 再从前向后找(这里对应的是下标i),找到比基准值大的值y1,把y1移动到之前空出来的位置(也就是x1的位置),此时y1位置就被空了出来。
- 重复第2和第3步,直到i和j相遇,则说明数组被遍历一遍了,此时i(i等于j)位置就存放基准值。
- 至此,基准值左边的元素都不大于基准值,基准值右边的元素都不小于基准值。
- 将基准值的位置返回,外层将按照基准值的位置将整个序列分为前后两个部分,两个部分再分别进行同样的排序。
复杂度
-
空间复杂度:主要是递归造成的栈空间的使用。
-
时间复杂度:
- 最好情况下:每次partition后都平均分成两半,为\(O(logn)\)。
- 最坏情况下:正序或逆序时,每次划分之后,一个比上一次记录少一个的子序列,另一个为空,如果用递归树画出来,它就是一颗斜树, 为\(O(n)\)。
- 平均:\(O(logn)\)。