排序算法是一种非常重要的算法,排序主要有五种大类、八种不同的方法,他们各有千秋。本文我们来揭开八大排序的奥妙。
⭕ps: 本文默认为升序排序
我们可以写这样一个TestOP函数,来比较各排序算法的效率
void TestOP()
{
srand(time(0));
const int N = 100000;
int* a1 = (int*)malloc(sizeof(int) * N);
int* a2 = (int*)malloc(sizeof(int) * N);
int* a3 = (int*)malloc(sizeof(int) * N);
int* a4 = (int*)malloc(sizeof(int) * N);
int* a5 = (int*)malloc(sizeof(int) * N);
int* a6 = (int*)malloc(sizeof(int) * N);
for (int i = 0; i < N; ++i)
{
a1[i] = rand();
a2[i] = a1[i];
a3[i] = a1[i];
a4[i] = a1[i];
a5[i] = a1[i];
a6[i] = a1[i];
}
int begin1 = clock();
InsertSort(a1, N);
int end1 = clock();
int begin2 = clock();
ShellSort(a2, N);
int end2 = clock();
int begin3 = clock();
SelectSort(a3, N);
int end3 = clock();
int begin4 = clock();
HeapSort(a4, N);
int end4 = clock();
int begin5 = clock();
QuickSort(a5, 0, N - 1);
int end5 = clock();
int begin6 = clock();
MergeSort(a6, N);
int end6 = clock();
printf("InsertSort:%d\n", end1 - begin1);
printf("ShellSort:%d\n", end2 - begin2);
printf("SelectSort:%d\n", end3 - begin3);
printf("HeapSort:%d\n", end4 - begin4);
printf("QuickSort:%d\n", end5 - begin5);
printf("MergeSort:%d\n", end6 - begin6);
free(a1);
free(a2);
free(a3);
free(a4);
free(a5);
free(a6);
}
直接插入排序是一种简单的插入排序,它的基本思想如下:
把待排序的记录按其关键码值的大小逐个插入到一个已经排好序的有序序列中,直到所有的记录插入完为 止,得到一个新的有序序列
实际上,我们在打扑克牌时,开局前的拣牌,就用到了插入排序的思想。
代码实现
void InsertSort(int* a, int n)
{
for (int i = 0;i < n - 1;i++)
{
//end代表每一个已排好序的有序序列的最后元素的索引
int end = i;
int tmp = a[end + 1];
//找到插入的恰当位置,同时挪动数据
while (end >= 0 && a[end] > tmp)
{
a[end + 1] = a[end];
end--;
}
//插入
a[end + 1] = tmp;
}
}
直接插入排序的特性总结
当序列有序,用时O(N)
稳定性的概念在后面补充
希尔排序是对直接插入排序的优化升级,其基本思想如下:
先选定一个整数gap,把待排序的序列中所有记录分成gap个组,所有距离为gap的记录分在同一组内,并对每一组进行排序。然后让gap自减1,重复上述分组和排序的工作。当到达gap==1时,就是直接插入排序,排完后得到有序序列。
ps:(gap=组数)
根据直接插入排序的特性我们知道,序列越接近有序,直接插入排序的效率越高。希尔排序的gap==1前的操作都是为了让序列趋近于有序。
代码实现
void ShellSort(int* a, int n)
{
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1;//保证最后的gap为1
//
for (int j = 0;j < gap;j++)//同一个gap,分不同组直接插入
{
for (int i = j;i < n - gap;i += gap)
{
int end = i;
int tmp = a[end + gap];
//找到插入的恰当位置,同时挪动数据
while (end >= 0 && a[end] > tmp)
//这里边界条件end>=0和end>=j都行,因为当end到达上边界的前一个时,必然小于0
{
a[end + gap] = a[end];
end -= gap;
}
//插入
a[end + gap] = tmp;
}
}
}
}
希尔排序的特性总结:
希尔排序是对直接插入排序的优化。
当gap > 1时都是预排序,目的是让数组更接近于有序。 当gap == 1时,数组已经接近有序的了,这样就
会很快。这样整体而言,可以达到优化的效果。我们实现后可以进行性能测试的对比。
通过TestOP函数对比直接插入排序和希尔排序的效率,设置N=100000,可以看到差距非常明显。
希尔排序的时间复杂度不好计算,因为gap的取值方法很多,导致很难去计算,因此在很多书中给出的
希尔排序的时间复杂度都不固定。因为我们的gap是按照Knuth提出的方式取值的,而且Knuth进行了大量的试验统计,我们暂时就按照:O(N1.3) 来算。
稳定性:不稳定
直接选择排序的基本思想是:
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置(或末尾位置),直到全部待排序的数据元素排完 。
代码实现
void SelectSort(int* a, int n)
{
int cur = 0;
int findMin = 0;
for (cur = 0;cur < n - 1;cur++)
{
int mini = cur;
//找出cur往后序列的最小值,并与cur交换
for (findMin = cur + 1;findMin < n;findMin++)
{
if (a[findMin] < a[mini])
{
mini = findMin;
}
}
//
if (mini != cur)
{
Swap(&a[cur], &a[mini]);
}
}
}
------------------------------------------------
//升级版:同时找最大和最小,区间由两边缩进
void SelectSortPlus(int* a, int n)
{
int left = 0;
int right = n - 1;
while (left < right)
{
int mini = left, maxi = left;
//
for (int find = left + 1;find < n;find++)
{
if (a[find] < a[mini])
{
mini = find;
}
if (a[find] > a[maxi])
{
maxi = find;
}
}
//
if (mini != left)
{
Swap(&a[mini], &a[left]);
}
if (maxi != right)
{
if (maxi == left)//如果maxi是left,那么已经被交换了,此时应该重置maxi
{
maxi = mini;
}
Swap(&a[maxi], &a[right]);
}
//
left++;
right--;
}
}
堆排序就是运用堆进行排序,是一种比较高效的算法。实现堆排序分为两个步骤
两个步骤都需要用到向下调整算法,因此掌握向下调整对堆排序的实现尤其重要。
⭕实现方法:
给定一个数组,要求将其排为升序:
int a[] = { 17,4,20,3,5,16 };
代码实现:
void AdjustDown(int* a, int n, int parent)//向下调整函数
{
assert(a);
int TheChild = parent * 2 + 1;
//
while (TheChild < n)//孩子的范围必须在数组下标范围内
{
//找到最小(最大)孩子
if (TheChild + 1 < n && a[TheChild + 1] < a[TheChild])//根据建立大小堆改变符号
{
TheChild++;
}
//判断:最小(最大)孩子和父亲是否交换
if (a[TheChild] < a[parent])
{
Swap(&a[parent], &a[TheChild]);
//移动父亲和孩子的下标
parent = TheChild;
TheChild = parent * 2 + 1;
}
else//不用交换,则终止调整,跳出循环
{
break;
}
}
}
//
void HeapCreat(int* a, int n)//建堆函数
{
assert(a);
//找到第一个调整的节点
int aN = ((n - 1) - 1) / 2;
//第一个调整的节点往前的每个节点都要向下调整
while (aN >= 0)
{
AdjustDown(a, n, aN);
--aN;
}
}
//
void HeapSort(int* a, int n)//堆排序
{
// 1.建堆
HeapCreat(a, n);
// 2.交换并删除
int len = n;// len表示可更新的序列长度
while (len > 1)
{
Swap(a[0], a[len - 1]);
//
--len;
//
AdjustDown(a, len, 0);
}
}
测试运行结果
堆排序的特性总结:
基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
冒泡排序是我们在新手村就见过的"小boss",是一个比较简单的排序算法。
void BubbleSort(int* a, int n)
{
for (int i = 1;i < n;i++)//共走n-1趟
{
int flag = 1;//假设上一趟已经排序成功
for (int j = 0;j < n - i;j++)//每趟比较 n-i 次
{
if (a[j] > a[j + 1])// “>”保证稳定性
{
Swap(&a[j], &a[j + 1]);
flag = 0;//本趟进行了交换,说明假设不成立
}
}
//
if (flag)//假设成立
break;
}
}
快速排序是日常用的最频繁的一种排序算法,是一种基于二叉树结构的交换排序方法,运用了递归的思想,其基本思想是:
任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
快速排序的总体框架如下:
代码框架
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)//区间非法,则返回
{
return;
}
//将区间[begin,end]分割成两部分,返回基准值的位置div
int div = PartSort(a, begin, end);
QuickSort(a, begin, div - 1);//递归左序列
QuickSort(a, div + 1, end);//递归右序列
}
ps:蓝色部分为每次找到的基准值位置div
而这里面的细节很多,重点是如何实现PartSort函数,下面分别介绍分析PartSort的几个版本
下面我们称小于array[key]的数为小数,大于array[key]的数为大数
hoare版本的思路如下:
规定左右两个指针,右指针找小数,左指针找大数,分别找到之后,进行交换,重复上述操作,最后当二者相遇时,交换相遇处元素和array[key],并返回相遇处索引(left和right皆可)。
注意:这里必须是右指针先走,否则最后相遇处的元素是大数,进行交换后会出现大数在基准值前面的错误。
int PartSortHoare(int* a, int begin, int end)
{
int key = begin;
int left = begin;
int right = end;
while (left < right)
{
//右找小
while (left < right && a[right] >= a[key])
{
right--;
}
//左找大
while (left < right && a[left] <= a[key])
{
left++;
}
//二者交换
if (left != right)
{
Swap(&a[left], &a[right]);
}
}
//最后交换key和相遇处的元素
Swap(&a[key], &a[left]);
//返回相遇处的索引
return left;
}
挖坑法的思路如下:
规定左右两个指针,并且有一个指针hole为“坑”。初始规定hole在左指针位置,并定义一个变量key存储初始坑内的值,右指针开始找小数,找到后将小数填到坑里,然后换新坑(左右互换),左指针开始找大数,找到后将大数填到坑里,然后换新坑。重复上述操作直到左右指针相遇,将key填入相遇处即可。
注意:与hoare版本不同,挖坑法左右指针谁先走都行,只需要修改一下初始坑即可。
代码实现
int PartSortHole(int* a, int begin, int end)//right先走的版本
{
int key = a[begin];
//
int left = begin;
int right = end;
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] = key;
return hole;
}
int PartSortHole2(int* a, int begin, int end)//left先走的版本
{
int key = a[end];
//
int left = begin;
int right = end;
int hole = right;
while (left < right)
{
//左找大
while (left < right && a[left] <= key)
{
left++;
}
//把大数扔到坑里,然后换新坑
a[hole] = a[left];
hole = left;
//右找小
while (left < right && a[right] >= key)
{
right--;
}
//把小数扔到坑里,然后换新坑
a[hole] = a[right];
hole = right;
}
a[hole] = key;
return hole;
}
前后指针法的基本思路如下:
定义前后两个指针prev和cur,初始时prev置于首元素位置,cur置于第二元素位置。比较基准值和cur所指向的元素大小,若array[cur]小于基准值,则prev先自增1,array[cur]与array[prev]交换,然后cur再自增1。重复上述操作,直到cur越界,再将基准值与prev所指位置元素交换即可。
也就是说,( array[prev], array[cur] ]
这个前开后闭区间内,只能有大于基准值的数,或者区间不成立(即prev=cur)。
代码实现
int PartSortPC(int* a, int begin, int end)
{
int keyi = begin;
int prev = begin;
int cur = prev + 1;
while (cur <= end)
{
if (a[cur] < a[keyi])
{
prev++;
if (prev != cur)
{
Swap(&a[prev], &a[cur]);
}
}
cur++;
}
//将基准值与prev所指位置元素交换
Swap(&a[prev], &a[keyi]);
//prev就是我们想要的div
return prev;
}
上面我们对基准值的选择一直都是默认为首元素。但这样做真的好吗?我们看下面图片。
可以看到,若选择该序列的首元素2为基准值,一次PartSort之后,元素2就到了蓝色方块位置,此时以该位置的中心分左右区间,会导致左右区间非常不对称,降低排序的效率。
因此我们得出结论,当一个序列接近有序或已经有序时,若默认首元素为基准值,序列元素会呈现出一边倒的趋势,此时快速排序的时间复杂度达到最坏的情况逼近O(N2),如图:
对此我们需要进行优化,使分割左右子区间尽可能地对称,这里我们可以采用三数取中法,既在首元素、中间元素和尾元素中选择中间值作为基准值,从概率上优化了。
代码实现
int GetMidIndex(int* a, int begin, int end)
{
int mid = begin + (end - begin) / 2;
if (a[begin] < a[end])
{
if (a[mid] < a[begin])
{
return begin;
}
if (a[mid] > a[end])
{
return end;
}
else
{
return mid;
}
}
else
{
if (a[mid] < a[end])
{
return end;
}
else if (a[mid] > a[begin])
{
return begin;
}
else
{
return mid;
}
}
}
在前后指针法的PostSort中应用三数取中法:
int PartSortPC(int* a, int begin, int end)
{
//三数取中法
int mid = GetMidIndex(a, begin, end);
Swap(&a[begin], &a[mid]);
//
int keyi = begin;
int prev = begin;
int cur = prev + 1;
while (cur <= end)
{
if (a[cur] < a[keyi])
{
prev++;
if (prev != cur)
{
Swap(&a[prev], &a[cur]);
}
}
cur++;
}
Swap(&a[prev], &a[keyi]);
return prev;
}
其实,用快速排序对一个很长的序列进行排序时,当递归到小的子区间时(既区间长度小于某个值时),就不必继续递归下去了,再递归显得有点杀鸡用牛刀,可以用插入排序对小区间进行排序,不仅节约了开辟函数栈帧的空间,还提高了排序的效率。
就像图中紫色方框内的小区间,用插入排序会简单且高效一些。
对快速排序的总体框架优化如下
void QuickSort(int* a, int begin, int end)
{
if (begin >= end)
{
return;
}
if (end - begin <= 8)//设定当区间长度小于8时,使用插入排序
{
InsertSort(a, end - begin + 1);
return;
}
int div = PartSort(a, begin, end);
QuickSort(a, begin, div - 1);
QuickSort(a, div + 1, end);
}
快速排序的特性总结:
从前面的总结我们可知,快速排序的递归实现因为需要建立很多函数栈帧,因此空间消耗是比较大的。为了减少空间消耗,我们可以尝试着用非递归方法实现快速排序。非递归快排要用到栈的数据结构。
基本思路如下:
模拟递归的过程。创建一个栈,将区间的左右指针入栈并PostSort该区间,然后弹出该区间两个指针,找到div后,再入栈该区间的左右区间。重复上述操作即可。
代码实现
void QuickSortNonR(int* a, int begin, int end)
{
Stack s;
StackInit(&s);
StackPush(&s, begin);
StackPush(&s, end);
//
while (!StackIsEmpty(&s))
{
int right = StackTop(&s);
StackPop(&s);
int left = StackTop(&s);
StackPop(&s);
int div = PartSortPC(a, left, right);
//先入右区间
if (div + 1 < right)//判断区间合法性
{
StackPush(&s, div + 1);
StackPush(&s, right);
}
//再入左区间
if (left < div - 1)//判断区间合法性
{
StackPush(&s, left);
StackPush(&s, div - 1);
}
}
StackDestroy(&s);
}
非递归与递归的运行结果比较
归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法的一个非常典型的应用。将已有序的子序列合并,得到完全有序的序列;即先使每个子序列有序,再使子序列段间有序。若将两个有序表合并成一个有序表,称为二路归并。 归并排序核心步骤:
代码实现
void _mergesort(int* a, int left, int right, int* tmp)
{
//递归的终点,既区间不存在,或只剩一个元素
if (left >= right)
{
return;
}
// 1.求出分割点
int div = left + (right - left) / 2;
// 2.分而治之
//左区间 [left,div] 归并使其有序
_mergesort(a, left, div, tmp);
//右区间 [div+1,right] 归并使其有序
_mergesort(a, div + 1, right, tmp);
// 3.归并
int begin1 = left, end1 = div;
int begin2 = div + 1, end2 = right;
int i = left;
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++];
}
// 4.把tmp上的数据倒回原数组
//注意:原数组和tmp数组拷贝数据的位置要对应
memcpy(a + left, tmp + left, sizeof(int) * (right - left + 1));
}
void MergeSort(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
exit(-1);
}
//
_mergesort(a, 0, n - 1, tmp);
//
free(tmp);
tmp = NULL;
}
归并排序的特性总结:
O(N*logN)
O(N)
代码实现
void MergeSortNonR(int* a, int n)
{
int* tmp = (int*)malloc(sizeof(int) * n);
if (tmp == NULL)
{
perror("malloc fail");
exit(-1);
}
//
int gap = 1;//gap表示归并的两组的begin的距离
while (gap < n)
{
int j = 0;
for (j = 0;j < n;j += 2 * gap)
{
int begin1 = j, end1 = j + gap - 1;
int begin2 = j + gap, end2 = j + 2 * gap - 1;
int i = j;
//边界修正(关键所在)
if (end1 >= n)
{
break;
}
if (begin2 >= n)
{
break;
}
if (end2 >= n)
{
end2 = n - 1;
}
//
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++];
}
//把tmp上的数据倒回原数组
memcpy(a + j, tmp + j, sizeof(int) * (end2 - j + 1));
}
gap *= 2;
}
//
free(tmp);
tmp = NULL;
}
计数排序应用了映射的思想,其操作步骤如下:
假如每次开辟的countArr数组都要有与原数组元素绝对对应的下标,那么若原数组中的元素较大时,会浪费一部分空间,这是不可取的。因此我们采用相对映射的概念。
代码实现
void CountSort(int* a, int n)
{
//先找出最大最小数
int i = 0;
int min = a[0], max = a[0];
for (i = 0;i < n;i++)
{
if (a[i] < min)
{
min = a[i];
}
if (a[i] > max)
{
max = a[i];
}
}
//建立countArr计数数组
int len = max - min + 1;
//注意这里要用calloc,保证计数数组中的数初始值都为0
int* countArr = (int*)calloc(len, sizeof(int));
if (countArr == NULL)
{
perror("calloc fail");
exit(-1);
}
//开始计数
for (i = 0;i < n;i++)
{
countArr[a[i] - min]++;
}
//将数据倒回原数组
int j = 0;
for (i = 0;i < len;i++)
{
while (countArr[i]--)
{
a[j++] = i + min;
}
}
}
计数排序的特性总结:
O(N)
最差情况(max-min)>>N时,O(max-min)
O(max-min)
前面一直讲到了排序的稳定性这个概念,那么稳定性究竟是什么呢?
稳定性: 假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的,否则称为不稳定的。只要我们能通过人为控制保证一个算法是稳定的,那么就称这个排序算法是稳定的。
性能比较
明显能看出,在数据量较大的情况下,算法的效率快慢还是比较明显的。
完。