目录
一、排序的概念
1.1排序的概念
排序 :所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。稳定性 :假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j] ,且 r[i] 在 r[j] 之前,而在排序后的序列中, r[i] 仍在 r[j] 之前,则称这种排序算法是稳定的;否则称为不稳定的。内部排序 :数据元素全部放在内存中的排序。外部排序 :数据元素太多不能同时放在内存中,根据排序过程的要求不能在内外存之间移动数据的排序。
1.2排序运用
1.3排序算法
二、插入排序
基本思想:直接插入排序是一种简单的插入排序法,其基本思想是:把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为止,得到一个新的有序序列 。
实际中我们玩扑克牌时,就用了插入排序的思想:
2.1直接插入排序
当插入第 i(i>=1) 个元素时,前面的 array[0],array[1],…,array[i-1] 已经排好序,此时用 array[i] 的排序码与array[i-1],array[i-2],…的排序码顺序进行比较,找到插入位置即将 array[i] 插入,原来位置上的元素顺序后移。

直接插入排序就是把从第二个数开始的数当作外来的数据往数组里插入数据,我们把要插入的数先存下来,然后把它之前的每个数和它比较,如果比它大,就往后移动,如果它之前的数等于或小于它,就停止,然后插入。
void InsertSort(int* a, int n)
{
for (int i = 0; i < n - 1; i++)
{
int end = i;
int tmp = a[end + 1];//要插入的数
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + 1] = a[end];
end--;
}
else
break;
}
a[end + 1] = tmp;
}
}
直接插入排序的特性总结:
1、元素集合越接近有序,直接插入排序算法的时间效率越高。(因为它需要移动的次数少了)
2、时间复杂度:O(N^2)
3、空间复杂度:O(1),它是一种稳定的排序算法。
4、稳定性:稳定。
2.2希尔排序(缩小增量排序)
希尔排序法又称缩小增量法。希尔排序法的基本思想是:希尔排序 通过比较相距一定间隔的元素来进行,各趟比较所用的距离随着算法的进行而减小,直到只比较相邻元素的最后一趟排序为止。
希尔排序是对直接插入排序的优化,它的操作很简单,就是:
1、预排序
2、直接插入排序
预排序:选定一个gap值,将间隔为gap的数据分为一组,每一组进行插入排序。
例如:
像这样,选定的gap是3,对gap组数据进行插入排序,插入排序后的结果是:
进行预排序之后,再对它进行插入排序,因为已经优化了不少,它的时间复杂度会降低很多。
同时,我们通过这样还发现一个规律:
gap越大,大的数据可以越快跳到后面,小的数据可以越快跳到前面,但是,不是很接近有序,当gap越小,跳得越慢,越接近有序。当gap=1时,他就是单纯的直接插入排序,当然这个直接插入排序已经被优化了很多,于是,我们能否进一步优化希尔排序呢?我们可以设定一个gap,让这个gap从大到小,一直到gap=1,这时通过不断优化,它的时间复杂度被降到最低。
void ShellSort(int* a, int n)
{
int gap = n;
while (gap >1)
{
gap = gap / 2;
//插排
for (int i = 0; i < gap; i++)//gap组
{
for (int j = i; j < n - gap; j += gap)
{
//第一个和后一个比较
int end = j;
int tmp = a[end + gap];
while (end >= 0)
{
if (a[end] > tmp)
{
a[end + gap] = a[end];
end -= gap;
}
else
break;
}
a[end + gap] = tmp;
}
}
}
}
希尔排序的特性总结:1. 希尔排序是对直接插入排序的优化。2. 当gap > 1时都是预排序,目的是让数组更接近于有序。当gap == 1时,数组已经接近有序的了,这样再进行插入排序就会很快。3. 希尔排序的时间复杂度不好计算,我们知道时间复杂度一般都是选择实际会出现的最坏情况去计算,但是希尔排序实际不会出现最坏情况,它是一个不断优化的过程,无法实际求出,所以根据大量数据得出,推导出来平均时间复杂度: O(N^1.3—N^2)4. 稳定性:不稳定5.希尔排序的空间复杂度是0(1).
三、选择排序
基本思想:每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
3.1直接选择排序

像图示这样的仅去找最大的倒着排,或者找最小的正着排,未免效率太过低效。
void SelectSort(int* a, int n)
{
int begin = 0, end = n - 1;
while (begin < end)
{
int maxi = end, mini = begin;
int i = 0;
for (i = begin; i <= end; i++)
{
if (a[i] > a[maxi])
maxi = i;
if (a[i] < a[mini])
mini = i;
}
Swap(&a[begin], &a[mini]);
//修正
if (maxi == begin)
maxi = mini;
Swap(&a[end], &a[maxi]);
--end;
++begin;
}
}
注意,这里对maxi的修正,他这里主要是考虑到一些特殊情况,比如:

像这种情况,如果不进行修正,那么我们可以运行一下代码,会生成什么,首位begin和mini交换,然后maxi所指向的数是多少?是最小的数,很显然会产生错误。所以我们要进行修正。
那么,还有一个问题,为什么只对maxi进行修正?mini不需要进行修正吗?修正和我们先交换谁有关,如果我们先交换maxi和end所指向的,那么mini就需要修正。
void SelectSort(int* a, int n)
{
int begin = 0, end = n - 1;
while (begin < end)
{
int maxi = end, mini = begin;
for (int i = begin; i <=end; i++)
{
if (a[i] > a[maxi])
maxi = i;
if (a[i] < a[mini])
mini = i;
}
//修正和你选择交换的顺序有关
//如果选择先交换最大的和最后一个
Swap(&a[end], &a[maxi]);
if (mini == end)
mini = maxi;
Swap(&a[begin], &a[mini]);
++begin;
--end;
}
}

1. 直接选择排序思考非常好理解,但是效率不是很好,实际中很少使用。2. 时间复杂度: O(N^2)。3. 空间复杂度: O(1)。4. 稳定性:不稳定。5. 直接选择排序需要注意修正的问题。
3.2堆排序
堆排序是基于堆可以快速选出最大或最小的数以及它的双亲与子独特的关系而设计的一种排序。它是选择排序的一种。
1、动图演示
2、实现思路
之前博主在撰写介绍数据结构--堆这一章时,介绍过向上调整建堆和向下调整建堆这两种建堆方式,我们选择了向下调整建堆的方式,因为它时间复杂度更低。我们要利用它可以快速选出最大或最小的特点来排序。排升序建大堆,排降序则建小堆。
可能有的读者不太明白,排升降序和建大小堆的关系。博主就再啰嗦一下,以排升序建大堆为例。
我们的思路是:先把数组中的元素建成大堆,然后再把第一个数也就是根,和最后一个数交换,然后对第一个数进行向下调整建堆(但是最后一个数不参与建堆),这时候最大的数就已经选出来排在末尾,而第一个数的左右子树满足向下调整的要求,然后重复对剩下的数选出次大的,排到倒数第二个,依次选数倒着排。
void Swap(int* a, int* b)
{
int tmp = *a;
*a = *b;
*b = tmp;
}
void AdjustDown(int* a, int parent, int n)
{
int maxchild = parent * 2 + 1;
while (maxchild < n)
{
if (maxchild + 1 < n && a[maxchild + 1] > a[maxchild])
{
maxchild++;
}
if (a[maxchild] > a[parent])
{
Swap(&a[maxchild], &a[parent]);
parent = maxchild;
maxchild = parent * 2 + 1;
}
else
break;
}
}
void HeapSort(int* a, int n)
{
//建堆
for (int i = (n - 2) / 2; i >= 0; --i)
{
AdjustDown(a, i, n);
}
//排序
for (int j = 1; j < n; j++)
{
Swap(&a[0], &a[n - j]);
AdjustDown(a, 0, n - j);
}
}
void PrintArray(int* a, int n)
{
for (int i = 0; i < n; ++i)
{
printf("%d ", a[i]);
}
printf("\n");
}
int main()
{
int a[] = { 22,13,35,31,20,46,60,30,91,81,96 };
int n = sizeof(a) / sizeof(int);
HeapSort(a, n);
PrintArray(a, n);
return 0;
}
3.复杂度
堆排序的时间复杂度是0(N*logN),堆排序没有占用额外的空间,因此空间复杂度是O(1)。
四、交换排序
基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
4.1冒泡排序
冒泡排序比较简单,它是典型的交换排序。
void BubbleSort(int* a, int n)
{
for (int i = 0; i < n-1; ++i)//这里注意不是i<n
{
int flag = 1;
for (int j = 0; j < n - i-1; ++j)
{
if (a[j] > a[j + 1])
{
Swap(&a[j], &a[j + 1]);
flag = 0;
}
}
if (flag == 1)
break;
}
}
冒泡排序的特性总结:
1. 冒泡排序是一种非常容易理解的排序2. 时间复杂度: O(N^2)3. 空间复杂度: O(1)4. 稳定性:稳定
4.2快速排序
快速排序是 Hoare 于 1962 年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
将区间按照基准值划分为左右两半部分的常见方式有:1. hoare版本2. 挖坑法3. 前后指针版本
1.hoare版本
①快速排序未优化版
单趟排序:1、选一个key。(一般是第一个或者是最后一个)。2、单趟排序,要求小的在key的左边,大的在key的右边

int PartSort1(int* a, int left, int right)
{
int keyi = left;
while (left < right)
{
//因为选取在左侧,所以要right先走
while (left<right && a[right]>=a[keyi])//右选小
{
right--;
}
while (left < right && a[left] <= a[keyi])//左选大
{
left++;
}
//选到左大,右小,进行交换
if(left<right)
Swap(&a[left], &a[right]);
}
int meeti = left;
Swap(&a[keyi], &a[meeti]);
return meeti;
}
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
int meeti = PartSort1(a, left, right);
QuickSort(a, left, meeti-1);
QuickSort(a, meeti + 1, right);
}
这是用递归实现的快排,这里我想说明两个问题。
1、keyi的选取和left和right哪个先走有什么联系?
2、未优化的快排有什么缺陷?
先来说明第一个问题:
这段代码keyi选取的是第一个数的下标,即left。通过图解可以说明一些问题:
如果L先走,我们很明显就会发现最后keyi左侧的不是都小于keyi所指向的那个数。
所以,这里博主直接给出结论:
如果左边第一个作为keyi,一定要R先走
如果右边第一个作为keyi,一定有L先走
未优化的快排有什么缺陷呢?
我们所写的快排是基于递归实现的,而且我们每次都选取都是最左侧的数来作为keyi,如果是排无序的数没什么问题,如果是排较为有序的一组数呢?
博主设计让直接插入排序和堆排排同样一组数据,然后让快排去排堆排排过的数据,看看耗费时间。
int main()
{
srand(time(0));
const int N = 2000;
int* a1 = (int*)malloc(sizeof(int) * N);
int* a4 = (int*)malloc(sizeof(int) * N);
for (int i = 0; i < N; ++i)
{
a1[i] = rand();
a4[i] = a1[i];
}
int begin1 = clock();
InsertSort(a1, N);
int end1 = clock();
int begin4 = clock();
HeapSort(a4, N);
int end4 = clock();
int begin5 = clock();
QuickSort(a4, 0, N - 1);//有序进行快排,栈溢出
int end5 = clock();
printf("InsertSort:%d\n", end1 - begin1);
printf("HeapSort:%d\n", end4 - begin4);
printf("QuickSort:%d\n", end5 - begin5);
free(a1);
free(a4);
return 0;
}
我们惊奇地发现,未优化的快排对一组有序的数据排序耗费的时间,竟要超过插入排序。这是为什么呢?我们把我们所写的快排的递归本质来分析一下:
当我们排有序的数的时候,它的递归深度是非常深的,这会调用大量堆栈,甚至程序崩溃,而它的复杂度也接近0(N^2)。这样看来我们需要对它的选数进行调整。
②快速排序三数取中版
我们选数要选一个合适的数,不能过大也不能过小,大小适中最为适合,于是我们提出了三数取中,即:取第一个数,最后一个数以及中间的数三个数比较,取中位数。
int GetMidIndex(int* a, int begin, int end)
{
int mid = begin + (end - begin) / 2;
if (a[begin] < a[mid])
{
if (a[mid] < a[end])
return mid;
else//a[mid]>=a[end]
{
return a[end] > a[mid] ? end : mid;
}
}
else//(a[begin]>=a[mid])
{
if (a[mid] > a[end])
return mid;
else//a[mid]<=a[end]
return a[begin] > a[end] ? end : begin;
}
}
int PartSort2(int* a, int left, int right)
{
int mid = GetMidIndex(a,left,right);
Swap(&a[left], &a[mid]);
int keyi = left;
while (left < right)
{
//因为选取在左侧,所以要right先走
while (left < right && a[right] >= a[keyi])//右选小
{
right--;
}
while (left < right && a[left] <= a[keyi])//左选大
{
left++;
}
//选到左大,右小,进行交换
if (left < right)
Swap(&a[left], &a[right]);
}
int meeti = left;
Swap(&a[keyi], &a[meeti]);
return meeti;
}
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
int meeti = PartSort2(a, left, right);
QuickSort(a, left, meeti-1);
QuickSort(a, meeti + 1, right);
}
三数取中有个细节需要提醒一下,如果找到中位数,不是把这个中位数的下标作为keyi,而是把这个数和最左侧的数进行交换,还是以left作为keyi。
我们再来看一下三数取中模式递归调用堆栈的情况。
我们看到三数取中已经很不错了,但是它最后三层调用了80%的堆栈,如果这样的快排去排更多的数据,很可能会栈溢出,为了避免,我们可以进一步优化,把最后接近有序的数据用插排来排,这样会快上不少,也能避免栈溢出。
void QuickSort(int* a, int left, int right)
{
if (left >= right)
return;
if (right - left <= 8)
{
InsertSort(a + left, right - left + 1);
}
else
{
int meeti = PartSort2(a, left, right);
QuickSort(a, left, meeti - 1);
QuickSort(a, meeti + 1, right);
}
}
2.挖坑法
挖坑法是快排的另一种玩法,效率和hoare版本的差不多,也是用到了三数取中。
//挖坑法
int PartSort3(int* a, int left, int right)
{
int mid = GetMidIndex(a, left, right);
Swap(&a[left], &a[mid]);
int key = a[left];//存下来
int hole = left;
while (left < right)
{
//因为选取在左侧,所以要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;
//选到左大,右小,进行交换
if (left < right)
Swap(&a[left], &a[right]);
}
a[hole] = key;
return hole;
}
3.前后指针版
int PartSort4(int* a, int left, int right)
{
int mid = GetMidIndex(a, left, right);
Swap(&a[left], &a[mid]);
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[keyi], &a[prev]);
return prev;
}
4.非递归实现快排
有些公司在面试的时候曾经提出这个问题,当然对于没有接触过的,当时可能就懵了。
我们来分析一下递归的时候,栈做了什么:
递归版本我们借助栈做了什么?不就是对这组数据不断细分然后进行三数取中再排,之前我们压栈放的这些分组,借助我们自己实现的栈去放也是一样的道理。
只不过我们需要先调用栈:
#include"Stack.h"
#pragma once
#include<stdio.h>
#include<stdbool.h>
#include<assert.h>
#include<stdlib.h>
typedef int STDataType;
typedef struct Stack
{
STDataType* a;
int top;
int capacity;
}ST;
void StackInit(ST* ps);
void StackDestory(ST* ps);
void StackPush(ST* ps,STDataType x);
void StackPop(ST* ps);
STDataType StackTop(ST* ps);
bool StackEmpty(ST* ps);
int StackSize(ST* ps);
void StackInit(ST* ps)
{
assert(ps);
ps->a = NULL;
ps->top = 0;
ps->capacity = 0;
}
void StackDestory(ST* ps)
{
assert(ps);
free(ps->a);
ps->a = NULL;
ps->top = ps->capacity = 0;
}
void StackCheckCapacity(ST* ps)
{
assert(ps);
if (ps->top == ps->capacity)
{
int newcapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
STDataType* newStack = (STDataType*)realloc(ps->a, sizeof(STDataType) * newcapacity);
if (newStack == NULL)
{
perror("realloc fail");
exit(-1);
}
else
{
ps->a = newStack;
ps->capacity = newcapacity;
}
}
}
void StackPush(ST* ps, STDataType x)
{
assert(ps);
StackCheckCapacity(ps);
ps->a[ps->top] = x;
++ps->top;
}
bool StackEmpty(ST* ps)
{
assert(ps);
return ps->top == 0;
}
void StackPop(ST* ps)
{
assert(ps);
assert(!StackEmpty(ps));
--ps->top;
}
STDataType StackTop(ST* ps)
{
assert(ps);
return ps->a[ps->top - 1];
}
int StackSize(ST* ps)
{
assert(ps);
return ps->top;
}
void QuickSortNonr(int* a, int left, int right)
{
ST st;
StackInit(&st);
StackPush(&st, left);
StackPush(&st, right);
while (!StackEmpty(&st))
{
int right = StackTop(&st);
StackPop(&st);
int left = StackTop(&st);
StackPop(&st);
int keyi=PartSort4(a, left, right);
if (keyi + 1 < right)
{
StackPush(&st, keyi+1);
StackPush(&st, right);
}
if (left < keyi - 1)
{
StackPush(&st, left);
StackPush(&st, keyi - 1);
}
}
StackDestory(&st);
}
快速排序特性总结:
1. 快速排序整体的综合性能和使用场景都是比较好的。2. 时间复杂度:O(N*logN)3. 空间复杂度:O(logN).4. 稳定性:不稳定.
五、归并排序
基本思想:归并排序( MERGE-SORT )是建立在归并操作上的一种有效的排序算法 , 该算法是采用分治法( Divide and Conquer)的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。
5.1递归版归并排序
我们观察归并排序的过程,就会发现,它本质是不断细分,(不断递归),直到只剩两个数,然后借助第三空间,取小尾插,再返回上一级,层层递进,最后再把第三空间排号的数拷贝到原数组中,这时候就完成了归并的过程。
//归并排序
void _MergeSort(int* a, int begin, int end, int* tmp)
{
if (begin >= end)
return;
int mid = begin + (end - begin) / 2;
_MergeSort(a, begin, mid, tmp);
_MergeSort(a, mid + 1, end, tmp);
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");
return;
}
_MergeSort(a, 0, n - 1, tmp);
free(tmp);
tmp = NULL;
}
5.2非递归版归并排序
非递归版要比递归版本考虑更多情形,我们知道递归版本是递归到2个数据归并,然后返回,变为4个数据归并,不断返回。如果,我们刚开始就把数组里的数据从2个开始归并,然后开始不断扩大区间。
因为数据的个数不一定是整数倍,但是我们计算是按照整数倍来计算的,存在越界的情况,因此我们要修正一些场景。
我们要想明白归并排序是两组数据归并之后进行比较,取小的尾插,因此必须要有两组数据进行比较,我们分析越界情况有如下几种:
1.第一组end1越界。这种情况就没有了第二组,因此直接break,让它在其他gap值重新分组归并。
2.第二组全部越界。这种也是只有第一组
3.第二组越界部分越界。这种情况需要修正边界,修正第二组的right为数组末尾。可以比较。
void MergeSortNonR(int* a,int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
return;
}
int gap = 1;
while (gap < n)
{
//gap个数据归并
for (int j = 0; j < n; j +=2* gap)//因为是两组数据
{
int begin1 = j, end1 = j + gap - 1;
int begin2 = j + gap, end2 = j + gap * 2 - 1;
//第一组部分越界
if (end1 >= n)
{
break;
}
//第二组全部越界
if (begin2 >= n)
{
break;
}
//第二组部分越界
if (end2 >= n)
{
end2 = n - 1;
}
int i = j;
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 + j, tmp + j, (end2 - j + 1) * sizeof(int));
}
gap *= 2;
}
free(tmp);
tmp = NULL;
}
归并排序的特性总结:1. 归并的缺点在于需要 O(N) 的空间复杂度,归并排序的思考更多的是解决在磁盘中的外排序问题。2. 时间复杂度: O(N*logN)3. 空间复杂度: O(N)4. 稳定性:稳定
六、非比较排序(计数排序)
思想:计数排序又称为鸽巢原理,是对哈希直接定址法的变形应用。 操作步骤:1. 统计相同元素出现次数。2. 根据统计的结果将序列回收到原来的序列中。
计数排序的思想很有趣,统计数组中每个数出现的次数存到另一个数组中,另一个数组的下标加上最小的值就是要排序数组里的元素,而这些下标对应的值就是它出现的次数。
void CountSort(int* a, int n)
{
int max = a[0], min = a[0];
for (int i = 1; i < n; ++i)
{
if (a[i] > max)
max = a[i];
if (a[i] < min)
min = a[i];
}
int range = max - min+1;//这是范围
int* countA = (int*)malloc(sizeof(int) * range);
if (countA == NULL)
{
perror("malloc fail");
return;
}
memset(countA, 0, sizeof(int) * range);
for (int i = 0; i < n; ++i)
{
countA[a[i] - min]++;
}
int j = 0;
for (int i = 0; i < range; ++i)
{
while (countA[i]--)
{
a[j]=i+min;
++j;
}
}
free(countA);
}
计数排序的特性总结:1. 计数排序在数据范围集中时,效率很高,但是适用范围及场景有限。2. 时间复杂度: O(MAX(N, 范围 ))3. 空间复杂度: O( 范围 )4. 稳定性:稳定
七、排序算法复杂度及稳定性分析
