前言
这一章主要讲解了各种排序算法,也是数据结构的最后一章了!
【数据结构系列】【前一章:查找】【已完结】
目录
八、排序
概念:使得序列成为一个按关键字有序的序列,这样的操作称为排序。
1、排序的稳定性
假设ki=kj(1<=i<=n,1<=j<=n,i!=j),且在排序前的序列中ri领先于rj(即i<j),如果排序后ri仍领先于rj,则称所用的排序方法是稳定的;反之,若可能使得排序后的序列中rj领先于ri,则称所用的排序方法是不稳定的。通俗的讲就是当两值相等时,原先是谁在前,等排序后还是应该那个数在前即是稳定的,否则不稳定。
2、内排序与外排序
根据在排序过程中待排序的记录是否全部被放置在内存中,排序分为:内排序和外排序。
对于内排序来说,排序算法的性能主要受3个方面影响:
(1)时间性能
高效率的内排序算法应该是具有尽可能少的关键字比较次数和尽可能少的记录移动次数。
(2)辅助空间
辅助存储空间是除了存放待排序所占用的存储空间之外,执行算法所需要的其他存储空间。
(3)算法的复杂度
这里指的是算法本身的复杂程度,显然算法过于复杂也会影响排序的性能。
根据排序过程中借助的主要操作,把内排序分为:插入排序、交换排序、选择排序和归并排序
3、排序用到的结构与函数
用于排序的顺序结构:
#define MAXSIZE 10 //用于要排序数组个数最大值,可根据需要修改
typedef struct
{
int r[MAXSIZE+1]; //用于存储要排序的数组,r[0]用作哨兵或临时变量
int length; //用于记录顺序表的长度
}SqList;
排序中最常用的操作就是两元素的交换
//交换L中数组r的下标为i和j的值
void swap(SqList *L,int i,int j)
{
int temp=L->r[i];
L->r[i]=L->r[j];
L->r[j]=temp;
}
4、冒泡排序
冒泡排序一种交换排序,基本思想是:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。
下面是两种毕竟简单的冒泡排序,第一种是从头慢慢冒泡到最后,最新排序好的是前面,第二种是从尾慢慢冒泡到开头,最新排序好的是后面。
//第一种
void BubbleSort0(SqList *L)
{
for (int i = 0; i < L->length; i++)
{
for (int j = i + 1; j < L->length; j++)
{
if (L->r[i] > L->r[j])
{
swap(L, i, j);
}
}
}
}
//第二种
void BubbleSort1(SqList *L)
{
for (int i = 0; i < L->length; i++)
{
for (int j = L->length - 1; j > i; j--)
{
if (L->r[j] < L->r[j- 1])
{
swap(L, j, j - 1);
}
}
}
}
冒泡排序的优化:
如果我们待排序的序列是{2,1,3,4,5,6,7,8,9},也就是说除了第一和第二的关键字需要交换外,别的都已经是正常的顺序,当i=1时,交换2和1,此时序列已经有序,但是算法仍然不依不饶地将i=2到i=9以及每个循环中的j循环执行一遍,尽管没有交换数据,但是之后的大量比较还是大大的多余,当i=2时,我们已经对9和8,8和7,...,3和2做了比较,没有任何数据交换,这就说明此序列已经有序,不需要再继续后面的循环判断工作,为了实现这个想法,我们需要改进一下代码,增加一个标记变量flag来实现这一算法的改进。
bool flag = true;
void BubbleSort1(SqList *L)
{
for (int i = 0; i < L->length&&flag; i++)
{
flag = false;
for (int j = L->length - 1; j > i; j--)
{
if (L->r[j] < L->r[j- 1])
{
swap(L, j, j - 1);
flag = true;
}
}
}
}
时间复杂度:O()
5、选择排序
//简单的选择排序
void SelectSort(SqList *L)
{
for (int i = 0; i < L->length; i++)
{
int min = i;
for (int j = i + 1; j < L->length; j++)
{
if (L->r[j] < L->r[min])
min = j;
}
if (min != i) //找到最小值
{
swap(L, min, i);
}
}
}
时间复杂度:O()
6、直接插入排序
直接插入排序的基本操作是将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增1的有序表。
//插入排序
void InsertSort(SqList *L)
{
//i=0作为哨兵,i=1算是已经排序好了,后面在此基础上进行插入
for (int i = 2; i < L->length; i++)
{
if (L->r[i] > L->r[i - 1]) //需将L->r[i]插入有序子表
{
L->r[0] = L->r[i]; //设置哨兵
int j;
for ( j = i - 1; L->r[j] > L->r[0]; j--)
{
L->r[j + 1] = L->r[j]; //记录后移
}
L->r[j + 1] = L->r[0]; //插入到正确位置
}
}
}
时间复杂度:O()
7、希尔排序
在这之前排序算法的时间复杂度基本上都是O(),希尔排序算法是突破这个时间复杂度的第一批算法之一。当数据较少且本身基本有序时,前面介绍的插入排序效率就挺高的,但这两个条件都过于苛刻,现实中记录少或者基本有序都属于特殊情况。
不过当条件不存在时,我们可以创造条件,首先将原本大量的数据进行分组,分割成若干个子序列,此时每个子序列待排序的记录个数就比较少了,这就提供了第一个条件,然后在这些子序列中分别进行直接插入排序,当整个序列基本有序时,注意只是基本有序时,即提供了第二个条件,再对全体记录进行一次直接插入排序。
这里强调一下:所谓基本有序,就是小的关键字基本在前面,大的基本在后面,不大不小的基本在中间。像{2,1,3,6,4,7,5,8,9}这样可以称为基本有序了,但像{1,5,9,3,7,8,2,4,6}这样的9在第三位,2在倒数第三位就谈不上基本有序。
分割待排序记录的目的是减少待排序记录的个数,并使整个序列基本有序发展。如果有些分组后达不到我们的要求,因此需要采取跳跃分割策略:将相距某个“增量”的记录组成一个子序列,这样才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序而不是局部有序。
下面看下代码:
//希尔排序
void ShellSort(SqList *L)
{
int increment = L->length; //跳跃交换的距离
do
{
increment = increment / 3 + 1; //最后的距离一定是1
for (int i = increment + 1; i < L->length; i++)
{
if (L->r[i] < L->r[i - increment])
{//需要将L->[i]插入到前面有序的子列中
L->r[0] = L->r[i];
int j;
for (j = i - increment; j > 0 && L->r[j] > L->r[0]; j -= increment)
{
L->r[j + increment] = L->r[j]; //往后移动,只是移动的距离是increment
}
L->r[j + increment] = L->r[0]; //插入
}
}
} while (increment>1);
}
大致原理也是和之前讲的插入排序差不多,只是现在交换是跳跃性的交换,并且跳跃的距离越来越小,目的是为了先基本有序,最后为1,即是相连之间交换。
时间复杂度:O()
由于记录是跳跃式的移动,希尔排序并不是一种稳定的排序算法
8、堆排序
堆是具有下列性质的完全二叉树:每个节点的值都大于或等于其左右孩子节点的值,称为大顶堆;或者每个节点的值都小于或等于其左右孩子节点的值,称为小顶堆。
如果按照层序遍历的方式给节点从1开始编号,则节点之间满足如下关系:
或
堆排序就是利用堆(假设利用大顶堆)进行排序的方法。基本思想是:将待排序的序列构造成一个大顶堆,此时,整个序列的最大值就是堆顶的根节点,将它移走(其实就是将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的n-1个序列重新构造成一个堆,这样就会得到n个元素中次小值,如此反复执行,便能得到一个有序序列。
主要解决两个问题:
(1)如何由一个无序序列构建成一个堆
(2)如果在输出堆顶元素后,调整剩余元素成为一个新的堆
下面看下代码:
//形成堆结构
void HeapAdjust(SqList *L,int s,int m)
{
int temp=L->r[s];
for(int j=s*2;j<=m;j=j*2)
{//沿关键字较大的孩子节点向下不断交换
if(L->r[j]<L->r[j+1])
{
j++; //j为关键字中较大的记录下标
}
if(temp>L->r[j])
break;
L->r[s]=L->r[j];
s=j;
}
L->r[s]=temp; //插入
}
#堆排序
void HeapSort(SqList *L)
{
for(int i=1;i<L->length/2;i++)
{//构建一个大顶堆
HeapAdjust(L,i,L->length);
}
for(int i=L->length;i>1;i--)
{
swap(L,i,1); //将堆顶记录与未经排序的最后一个记录交换
HeapAdjust(L,1,i-1); //将L->r[1..i-1]重新调整为大顶堆
}
}
时间复杂度:O(nlogn)
9、归并排序
对于堆排序的结构是比较难构造出来的,但利用完全二叉树的结构,效率都不会低,所以归并排序也是利用了完全二叉树的结构,不过是一个倒置的完全二叉树,让无序数组序列两两合并排序后再合并。
归并排序原理:假设初始序列含有n个记录,则可以看成是n个有序的子序列,每个子序列的长度为1,然后两两归并,得到[n/2]个长度为2或1的有序子序列;再两两归并,...,如此重复,直至得到一个长度为n的有序序列为止,这种排序方法称为2路归并排序。
先来看一下归并程序:
void Merge(int SR[], int TR[], int i, int m, int n)
{
int j = m + 1;
int k = i;
while (i <=m&&j <=n)
{
if (SR[i] < SR[j])
{
TR[k++] = SR[i];
i++;
}
else
{
TR[k++] = SR[j];
j++;
}
}
if (i <= m)
{
for (int w = i; w <= m; w++)
TR[k++] = SR[w];
}
else if (j <= n)
{
for (int w = j; w <= n; w++)
TR[k++] = SR[w];
}
}
下面来举例说明:
假设一个序列分成前后两部分,且都分别有序,比如SR={10,30,50,70,90,20,40,60,80},在这里存储都是从1开始存储了,下标0没有用到,注意一下,对于这个SR序列,前5个和后4个序列都分别是从小到大排序的,现在就是要对这个序列做一个整体排序,使最后结果整体从小到大。很明显我们可以把它分成两个序列来看待,用两个下标来分别遍历,i作为SR的首下标,m作为前半个序列的末下标,j作为后半个序列的首下标,n作为后半个序列的末下标,如图1所示:
图1
过程也很简单,就是i和j同时遍历,当哪个更小就放入TR中,并且下标往后移一位,哪个较大的就不动,进行第二次比较,当有一方已经到了末尾时,即停止遍历,若还有一方有剩余的元素只需要全部放在TR后面即可。
下面来看下归并排序:
void MSort(int SR[], int TR1[], int s, int t)
{
int m;
int TR2[MAXSIZE + 1];
if (s == t)
TR1[s] = SR[s];
else
{
m = (s + t) / 2;
MSort(SR, TR2, s, m);
MSort(SR, TR2, m + 1, t);
Merge(TR2, TR1, s, m, t);
}
}
void MergeSort(SqList *L)
{
MSort(L->r, L->r, 1, L->length);
}
来看下它的递归过程:
图2
如图2所示,只画出了第一轮递归,红色的MSort代表着程序中第一个MSort,绿色的MSort代表着程序中的第二个MSort,很容易看出一直从红色MSort递归下去,当到s=t时结束,接着执行下面第二个MSort,即最后一行的绿色MSort,发现s=t结束,接着执行Merge进行归并,即第一层结束,接着反推回去。
时间复杂度:O(nlongn) 空间复杂度:O(n+logn)
归并排序是一种比较占用内存,但效率高且稳定的算法
非递归实现归并排序:
归并排序大量引用了递归,尽管在代码上比较清晰,容易理解,但这会造成时间和空间上的性能损耗,我们追求的就是效率,现在就把递归改成迭代,性能上进一步的提高。
10、快速排序
快速排序的基本思想是:通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。
第一步:得到中心轴,并使中心轴左边的数小于中心轴,中心轴右边的数大于中心轴。
//求中心轴下标
int Partition(SqList *L, int low, int high)
{
int pivotkey;
pivotkey = L->r[low]; //以第一个数作为中心轴数据
while (low < high)
{
while (low < high&&L->r[high] >= pivotkey) //比中心轴大的数据放在中心轴的右边
high--;
swap(L, low, high);
while (low < high&&L->r[low] <= pivotkey) //比中心轴小的数据放在中心轴坐边
low++;
swap(L, low, high);
}
return low;
}
第二步:采用递归,先分成两部分,再分别对两部分再分
void QSort(SqList *L,int low,int high)
{
if(low < high)
{
int p_index = Partition(L, low, high); //将L一分为二
QSort(L, low, p_index - 1); //对前一部分采用递归排序
QSort(L, p_index + 1, high); //对后一部分采用递归排序
}
}
void QuickSort(SqList *L)
{
QSort(L, 1, L->length);
}
时间复杂度:O(nlogn)
空间复杂度:最坏情况:O(n) 最好情况:O(logn)
)但排序不稳定
(1)快速排序优化
1)优化选取中心轴
每次在选取中心轴时,都默认第一个数为中心轴,但如果第一个数特别小或者特别大,则第一轮交换并没有改变多少。排序速度的快慢取决于L->r[1]的关键字处在整个序列的位置,L->r[1]太小或者太大,都会影响性能。因为在现实中,待排序的序列极大可能是基本有序的,此时总固定选取第一个关键字作为首个中心轴就变成了极不合理的做法。
改进办法,有人提出随机获得low与high之间的数rnd,让它的关键字L->r[rnd]与L->r[low]交换,这称为随机选取枢轴法。但随机就是不确定,就有些撞大运。
再改进,于是有了三数取中法。即取三个关键字先进行排序,将中间数作为中心轴,一般取左端、右端和中间三个数,也可以随机选取。
看看取左端、右端和中间三个数的实现代码:
int m=low+(high-low)/2; //计算数组中间元素的下标
if(L->r[low]>L->r[high])
{
swap(L,low,high);
}
if(L->r[m]>L->r[high])
swap(L,high,m);
if(L->r[m]>L->r[low])
swap(L,m,low);
//此时L->[low]已经是整个序列左中右三个关键字的中间值
三数取中对于小数组来说有很大的概率选择到一个比较好的pivotkey,但是对于非常大的待排序的序列来说还是不足以保证能够选择出一个好的pivotkey,因此还有个办法是九数取中。它先从数组中分三次取样,每次取三个数,三个样本各取出中数,然后从这三个中数当中再取出一个中数作为枢轴。
2)优化不必要的交换
我们发现,50这个关键字,其位置变化是1——9——3——6——5,可其实它的最终目标就是5,当中交换其实是不需要的,因此我们可以对函数代码再进行优化。对原先交换的地方,采用替换。
//求中心轴下标
int Partition(SqList *L, int low, int high)
{
int pivotkey;
pivotkey = L->r[low]; //以第一个数作为中心轴数据
L->r[0] = pivotkey;
while (low < high)
{
while (low < high&&L->r[high] >= pivotkey) //比中心轴大的数据放在中心轴的右边
high--;
//swap(L, low, high);
L->r[low] = L->r[high];
while (low < high&&L->r[low] <= pivotkey) //比中心轴小的数据放在中心轴坐边
low++;
//swap(L, low, high);
L->r[high] = L->r[low];
}
L->r[low] = L->r[0];
return low;
}
3)优化小数组时的排序方案
当数组个数较少时,快速排序并没有插入排序好,所以我们可以先对排序的个数做一个判断,当个数较少时,就选择插入排序。
4)优化递归操作
递归对性能是有一定的影响的,因此能减少递归,将会大大提高性能。
void QSort1(SqList *L, int low, int high)
{
while (low < high)
{
int p = Partition(L, low, high);
QSort1(L, low, p - 1);
low = p + 1; //Partition(L,p+1,high)
}
}
11、总结
图3 根据排序借助的主要操作分类
排序方法 | 平均情况 | 最好情况 | 最坏情况 | 辅助空间 | 稳定性 |
冒泡排序 | O() | O(n) | O() | O(1) | 稳定 |
简单选择排序 | O() | O() | O() | O(1) | 稳定 |
直接插入牌序 | O() | O(n) | O() | O(1) | 稳定 |
希尔排序 | O(nlogn)~O() | O() | O() | O(1) | 不稳定 |
堆排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(1) | 不稳定 |
归并排序 | O(nlogn) | O(nlogn) | O(nlogn) | O(n) | 稳定 |
快速排序 | O(nlogn) | O(nlogn) | O() | O(logn)~O(n) | 不稳定 |
表1 各种指标对比
从算法的简单性来看,将7种算法分为两类:
- 简单算法:冒泡、简单选择、直接插入
- 改进算法:希尔、堆、归并、快速