一、排序的基本概念与分类
(1)基本概念
假设含有N个记录的序列{r1,r2,r3,········rn},其相应的关键字分别为{k1,k2,k3,········kn},需确定1,2,3,······n的一种排列p1,p2,p3,·······,pn,使其相应的关键字满足kp1≤kp2≤kp3≤·········≤kpn非递减(或非递增)关系,即使得序列成为一个按关键字有序的序列,这样的操作就称为排序。
(2)稳定性
由于排序不仅是针对主关键字,那么对于次关键字,因为待排序的记录序列中可能存在两个或两个以上的关键字相等的记录,排序 结果可能会存在不唯一的情况,因此有了稳定与不稳定的定义。
假设ki=kj(1≤i≤n,1≤j≤n,i≠j)且在排序前的序列中ri领先于rj(即i<j)。如果排序后ri领先于rj,则称所用的排序方法是稳定的;反之,若可能使得排序后的序列中rj领先ri,则称所用的排序方法是不稳定的。
(3)内部排序与外部排序
根据在排序过程中待排序列的记录是否全部被放置在内存中,排序分为:内部排序和外部排序。
内部排序是在排序整个过程中,待排序的所有记录全部被放置在内存中。外部排序是由于排序的记录个数太多,不能同时放置在内存,整个排序过程需要在内外存之间多次交换数据才能进行。
对于内部排序来说,排序算法的性能只要是受三个方面影响:
时间性能:排序是数据处理中经常执行的一种操作,往往属于系统的核心部分,因此排序算法的时间开销是衡量其好坏的最重要的标志。在内部排序中,主要进行两种操作:比较和移动。比较是指关键字的比较,这是要做排序最起码的操作。移动指记录从一个位置移动到另外一个位置。高效率的内部排序算法应该是具有尽可能少的关键字比较次数和尽可能少的记录移动次数。
辅助空间:辅助存储空间是除了存放待排序所占用的存储空间之外,执行算法所需要的其他存储空间。
算法的复杂性:这里指的是算法本身的复杂度,而不是指算法的时间复杂度。显然算法过于复杂也会影响排序的性能。
(4)排序中用到的函数
由于排序中最常用到的操作是数组两元素的交换,我们将 它写成函数,在之后的讲解中会大量的用到。
void swap(int *a, int i, int j)
{
int temp = a[i];
a[i] = a[j];
a[j] = temp;
}
二、常用排序算法
(1)冒泡排序
冒泡排序是一种交换排序,它的基本思想是:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。冒泡的实现在细节上可以有很多种变化,现在将分别就三种不同的冒泡实现代码,来讲解冒泡排序的思想
/*对数组a做交换排序(冒泡排序初级版)*/
void BubbleSort0(int *a, int n)
{
int i, j;
for(i = 1; i <= n; i++)
{
for(j = i+1; j <= n; j++)
{
if(a[i] > a[j])
swap(a, i, j);
}
}
}
这段代码严格意义上来说,不算是标准的冒泡排序算法,因为它不满足“两两比较相邻记录”的冒泡排序思想,它更应该是最简单的交换排序而已。它的思想就是让每一个关键字,都和它后面的每一个关键字比较,如果大则交换,这样第一位置的关键字在一次循环后一定变成最小值(这种排序方法是有缺陷的,算法的效率非常低)。
/*对数组a做交换排序(正宗版冒泡排序)*/
void BubbleSort(int *a, int n)
{
int i, j;
for(i = 1; i <= n; i++)
{
for(j = n-1; j >= i; j--)
{
if(a[j] > a[j+1])
swap(a, j, j+1);
}
}
}
显然这种算法比前面的要有进步,数组中较小的数字如同气泡慢慢浮到上面,因此就将此算法命名为冒泡算法。
然而上面的冒泡排序是可以继续优化的,试想一下,如果我们待排序的序列{2,1,3,4,5,6,7,8,9}除了第一和第二的关键字需要交换外,别的都已经是正常的顺序。那么就无需进行后面的比较和移动了。因此,我们又引申出了下面优化的冒泡排序。
/*对数组a做交换排序(优化后冒泡排序)*/
void BubbleSort2(int *a, int n)
{
int i, j;
bool flag = true;
for(i = 1; i <= n && flag; i++)
{
flag = false;
for(j = n-1; j >= i; j--)
{
if(a[j] > a[j+1])
{
swap(a, j, j+1);
flag = true;
}
}
}
}
由上可知,冒泡排序总的时间复杂度为o(n^2)
(2)简单选择排序
简单选择排序法就是通过n-i此关键字间的比较,从n-i+1个记录中选出关键字最小的记录,并和第i(1≤i≤n)个记录交换之。
/*对数组a做简单选择排序*/
void SelectSort(int *a, int n)
{
int i, j, min;
for(i = 1; i < n; i++)
{
min = i;
for(j = i+1; j <= n; j++)
{
if(a[min] > a[j])
min = j;
}
if(i != min)
swap(a, i, min);
}
}
由上分析可知,选择排序最大的特点就是交换移动数据次数相当少,这样也就节约了相应的时间。分析它的时间复杂度发现,无论最好最差的情况,其比较次数都是一样的多,第i趟需要进行n-i次关键字的比较,此时需要比较(n*(n-1)/2)次。而对于交换次数而言,当最好的时候,交换为0次,最差的时候,也就是初始降序时,交换次数为n-1次,基于最终的排序时间是比较与交换的次数总和,因此,总的时间复杂度为o(n^2)。应该说,尽管与冒泡排序同为o(n^2),但简单选择排序的性能还是要略优于冒泡排序。
(3)直接插入排序
直接插入排序的基本操作是将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增1的有序表。
/*对顺序表a做直接插入排序*/
void InsertSort(int *a, int n)
{
int i, j;
int temp;
for(i = 2; i <= n; i++)
{
if(a[i] < a[i-1])
{
temp = a[i];
for(j = i-1; a[j] > a[0]; j--)
{
a[j+1] = a[j];
}
a[j+1] = temp;
}
}
}
由上分析可知,从空间上来看,这个算法只需要一个记录的辅助空间,因此关键看它的时间复杂度。当最好的情况,也就是要排序的表本身就是有序的,那么我们的比较次数也就是(n-1)次,没有移动的记录,时间复杂度为o(n)。当最坏的情况,即待排序表是逆序的情况,此时需要比较((n+2)(n-1)/2)次,而记录的移动次数也达到最大值((n+4)(n-1)/2)次。因此,我们得出直接插入排序的时间复杂度为o(n^2)。从这里也可以看出,同样的时间复杂度,直接插入排序比冒泡和简单选择排序的性能要好一些。
(4)希尔排序
接下来我们来说一下希尔排序,在这之前排序算法的时间复杂度基本都是o(n^2)的,希尔排序算法是突破这个时间复杂度的第一批算法之一。
我们之前讲的直接插入排序,它的效率在某些时候是很高的,比如当我们的记录本身就是基本有序的,我们只需要少量的插入操作,就可以完成整个记录集的排序工作,此时直接插入很高效。其次当记录数比较少时,直接插入的优势也很明显。我们来分析一下这两种情况:
①如何让待排序的记录个数较少呢?很容易就能想到的就是将原本大量记录数的记录进行分组。分割成若干子序列,此时每个子序列待排序的记录个数就比较少了,然后在这些子序列内分别进行直接插入排序,当整个序列都基本有序时,注意只是基本有序时,再对全体记录进行一次直接插入排序。
②我们一直再提的“基本有序”到底是什么样子?所谓基本有序就是小的关键字基本在前面,大的基本在后面,不大不小的基本在中间,像{2,1,3,6,4,7,5,8,9}这样可以称为基本有序,但是{1,5,9,3,7,8,2,4,6}就不能称为基本有序,因为9在第三位,2在倒数第三位。
希尔排序的定义就呼之欲出了,我们采用“跳跃分割”的策略:将相距某个“增量”的记录组成一个子序列,这样才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序而不是局部有序。
/*对数组a做希尔排序*/
void ShellSort(int *a, int n)
{
int i, j;
int increment = n;
int temp;
do
{
increment = increment/3+1;
for(i = increment+1; i < n; i++)
{
temp = a[i];
for(j = i-increment; j > 0 && temp > a[j]; j -= increment)
{
a[j+increment] = a[j];
}
a[j+increment] = temp;
}
}while(increment > 1)
}
通过上面的代码,大家可以看出,希尔排序的关键并不是随便分组后各自排序,而是将相隔某个“增量”的记录组成一个子序列,实现跳跃式的移动,使得排序的效率提高。其时间复杂度为o(n^1.5),要好于直接排序的o(n^2)。要注意的是,增量排序最后一个增量值必须等于1才行。另外,由于记录是跳跃式的移动,希尔排序并不是一种稳定的排序算法。
(5)堆排序
堆是具有下列性质的完全二叉树:每个节点的值都大于或者等于其左右孩子结点的值,称为大顶堆;或者每个结点的值都小于等于其左右孩子结点的值,称为小顶堆。
这里需要注意从堆的定义可知,根结点一定是堆中所有结点最大(小)者,较大的结点靠近根结点。如果按照层次遍历的方式给结点从1开始编号,则结点之间满足如下关系:
堆排序就是利用堆(假设利用大顶堆)进行排序的方法。它的基本思想是,将待排序的序列构造成一个大顶堆。此时,整个序列的最大值就是堆顶的根结点。将它移走(其实就是将其与堆数组的末尾元素交换,此时末尾元素就是最大值),然后将剩余的n-1个序列重新构造成一个堆,这样就会得到n个元素中的次大值。如此反复执行,便能得到一个有序序列了。
相信大家有些明白堆排序的基本思想了,不过要实现它还需要解决两个基本问题:
①如何由一个无序序列构建成一个堆?
②如果在输出堆顶元素后,如何调整剩余元素成为一个新的堆?
/*对数组a做堆排序*/
void HeapSort(int *a, int n)
{
int i;
for(i = n/2; i > 0; i--)
{
HeapAdjust(a, i, n);
}
for(i = n; i > 1; i--)
{
swap(a, 1, i);
HeapAdjust(a, i, i-1);
}
}
/*本函数调整a[s]的关键字,使a[s...m]成为一个大顶堆*/
void HeapAdjust(int *a, int s, int m)
{
int temp, j;
temp = a[s];
for(j = 2*s; j <= m; j *= 2)
{
if(j < m && a[j] < a[j+1])
j++;
if(temp >= a[j])
break;
a[s] = a[j];
s = j;
}
a[s] = temp;
}
堆排序的效率到底有多高呢?我们分析一下。
它的运行时间主要是消耗在初始构建堆和在重建堆时的反复筛选上。
在构建堆的过程中,因为我们是完全二叉树从最下层最右边的非终端结点开始构建,将它与其孩子进行比较和若有必要的交换,对于每个非终端结点来说,其实最多进行两次比较和互换操作,因此整个构建堆的时间复杂度是o(n).
在正式排序时,第i次取堆顶记录重建堆需要用o(logi)的时间(完全二叉树的某个结点到根结点的距离为),并且需要去n-1次堆顶记录,因此,重建堆的时间复杂度为o(nlogn)。
所以总体来说,堆排序的时间复杂度为o(nlogn)。由于堆排序对原始记录的排序状态并不敏感,因此它无论是最好、最坏和平均时间复杂度均为o(nlogn)。这在性能上显然要远远好过于冒泡、简单选择、直接插入的时间复杂度。空间复杂度上,它只有一个用来交换的暂存单元,也非常的不错。不过由于记录的比较与交换是跳跃式进行,因此堆排序也是一种不稳定的排序方法。
(6)归并排序
归并排序就是利用归并的思想实现的排序方法。它的原理是假设初始序列含有n个记录,则可以看成n个有序的子序列,每个子序列的长度位1,然后两两归并,·······,如此重复,直至得到一个长度为n的有序序列为止,这种排序方法称为2路归并排序。
/*将SR[s·····n]归并排序为TR1[s····t]*/
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);
}
}
/*将有序的SR[i····m]和SR[m+1····n]归并为有序的TR[i···n]*/
void Merge(int SR[], int TR[], int i, int m, int n)
{
int j, k, l;
for(j = m+1, k = i; i <= m && j <= n; k++)
{
if(SR[i] < SR[j])
TR[k] = SR[i++];
else
TR[k] = SR[j++];
}
if(i <= m)
{
for(l = 0; i <= m-i; i++)
TR[k+i] = SR[i+1];
}
if(j <= n)
{
for(l = 0; l <= n-j; l++)
TR[k+l] = SR[j+l];
}
}
我们来分析一下归并排序的时间复杂度,一趟归并需要将SR[1]~SR[n]中相邻的长度位h的有序序列进行两两归并。并将结果放到TR1[1]~TR1[n]中,这需要将待排序序列中所有记录扫描一遍,因此耗费o(n)时间,而由完全二叉树的深度可知,整个归并排序需要进行(log2n)次,因此,总的时间复杂度为o(nlogn),而且这是归并排序算法中最好、最坏、平均的时间性能。
由于归并排序在归并过程中需要与原始记录序列同样数量的存储空间存放归并结果以及递归深度为log2n的栈空间,因此空间复杂度为o(n+logn).
另外,对代码进行仔细研究,发现Merge函数中有if(SR[i]
(7)快速排序
快速排序的基本思想是:通过一趟排序将待排记录分割成独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序的目的。
/*对数组a的子序列a[low···high]做快速排序*/
void QSort(int * a, int low, int high)
{
int pivot;
if(low < high)
{
pivot = Partition(a, low, high);
QSort(a, low, pivot-1);
QSort(a, pivot+1, high);
}
}
/**/
int Partition(int *a, int low, int high)
{
int pivotkey;
pivotkey = a[low];
while(low < high)
{
while(low < high && a[high] >= pivotkey)
high--;
swap(a, low, high);
while(low < high && a[low] <= pivotkey)
low++;
swap(a, low, high);
}
return low;
}
我们来分析一下快速排序法的性能。快速排序的时间性能取决于快速排序递归的速度,可以用递归树来描述递归算法的执行情况。由数学归纳法可知,快速排序的时间复杂度为o(n^2)。就空间复杂度来说,主要是递归造成的栈空间的使用,递归树的深度为log2n,其空间复杂度也就为o(logn),最坏情况,需要进行n-1此递归调用,其空间复杂度为O(n),平均情况,空间复杂度也为o(logn).可惜的是,由于关键字的比较和交换是跳跃进行的,因此,快速排序也是一种不稳定的排序方法。
快速排序的优化:
优化选取枢轴
三、总结
从算法的简单性来看,我们将7种算法分为两类:
- 简单算法:冒泡、简单选择、直接插入
- 改进算法:希尔、堆、归并、快速排序
从平均情况来看,显然最后三种改进算法要胜过希尔排序,并远远胜过前三种简单算法。
从最好情况来说,冒泡和直接插入排序要更胜一筹。
从最坏情况看,堆排序与归并排序又强过快速排序和其它简单排序。
从空间复杂度来说,归并排序强调要马跑的快,就得给马吃饱;快排也有相应的空间要求;反而堆排序等都是少量索取,大量付出,对空间要求是o(1)。
从稳定性来看,归并排序独占鳌头。