对排序的分类、分析及实现
如果按排序过程中依据的不同原则读内部排序方法进行分类,则大致可分为插入排序,交换排序,选择排序,归并排序和计数排序;如果按内部排序过程中所需的工作量来区分,则可分为3类:简单的排序方法,O(n*n), 先进的排序方法,O(nlogn) , 基数排序,时间复杂度为O(d*n);
学习目标:知道每个排序算法的时间复杂度,并且知道在一些情况下的最优算法:
一般的排序只需要完成两个步骤即可:
1 比较两个关键字的大小(对于链表来说,就是其存放的数据大小,对于二叉树就是键值大小)
2 将记录从一个位置移动至另一个位置(这种操作可以通过改变记录的存储方式来避免)
那么,主要有以下三种存储方式:
1 将待排序的一组记录存放在地址连续的一组存储单元上,类似于线性表的顺序存储结构。在这种存储方式中,记录之间的次序关系由其存储位置决定,则实现排序必须借助移动记录。
2 一组待排序记录存放在静态链表中(静态链表相当于是用一个数组来实现线性表的链式存储结构), 记录之间的次序关系由指针指示,则实现排序不需要移动记录,仅需修改指针即可;
3 待排序记录本身存储在一组地址连续的存储单元中, 同时另设一个指示各个记录存储位置的地址向量,在排序过程中不移动记录本身,而移动地址向量中这些记录的“地址”,在排序结束之后再按照地址向量中的值调整记录的存储位置。
上述第二组存储方式的排序称为(链)表排序,第三种存储方式实现的排序又称为地址排序。
以第一组存储方式为例,来讨论各种排序算法:
1 直接插入排序
这种方法最简单,它的基本操作是将一个记录插入到已排序的有序表中,从而得到一个新的、记录数增一的有序表
这种方法的基本思想:将一个无序的顺序表分为两个连续的部分,前一部分是已经有序排列的(一般情况下刚开始只有一个元素),后一部分为待排序的。
总是将待排序的第一个元素和已排序的最后一个元素,进行比较, 如果待排序的元素小,就将其存储在顺序表的头处即序号0处,然后开始往后移动顺序表,从最后一个元素开始,当到达有序部分时,将序号0的元素与有序部分的元素进行比较,如果序号0元素小,则后移有序部分元素,直到序号为0的元素插入有序部分的正确位置。这是,有序部分长度增加1无序部分长度减少1
因此,这种算法的时间复杂度最坏为O(n*n); 最好为O(n)
它与冒泡排序的不同在于,冒泡排序必须经过 n-1+ n-2+ n-3+ ···+ 2+1 次的比较时间复杂度为O(n*n)。即在最好的情况下,对于无序排序的数组,冒泡排序还需要经过这么多次的比较,
C/C++ 代码实现:
//直接插入排序: 外部需要传入一个比较大小的函数
bool isLess(int a, int b)
{
return a>b?ERROR:TRUE;
}
//SIS (Straight Insertion Sort)
void SIS(List &L, bool (*p)(int, int))
{
for(int i =2; i<=L.length;i++)
if(p(L.r[i].data, L.r[i-1].data))
{
L.r[0].data = L.r[i].data;
int j;
for(j = i-1; p(L.r[0].data, L.r[j].data);j--)
L.r[j+1].data = L.r[j].data;
L.r[j+1].data = L.r[0].data;
}
}
以下的三种排序,都属于插入排序,在这里,主要分析一下它们的异同
对于,直接插入排序法而言,它容易实现,且算法简单. 当待排序记录的规模很小时,这是一种很好的排序算法。但是,通常待排序的记录的数量n很大,则不宜采用直接插入排序。因此,需要对这种插入排序算法进行改进。
在直接插入排序的基础上,从减小”比较” 和 ”移动” 这两种操作的次数着眼,可得以下排序算法(对于前两个排序算法本文中不实现)。
2 折半插入排序
由于,插入排序的基本操作为: 在一个有序表中进行查找和插入。因此,从各查找的算法可知,这个查找可用 ”折半查找”来实现。由此得名。
折半插入排序所需附加存储空间和直接插入排序相同,从时间上比较,折半查找只是
少了一些关键字之间的比较次数,而记录的移动次数不变。因此, 折半插入排序的时间复杂度仍为O(n*n);
2-路插入排序
2-路插入排序是在折半查找的基础上进一步改进, 其目的是减少排序过程中移动记录次数, 但为此,需要增加n个空间,作为辅助空间。这一种算法,的思想是,额外设置一个静态链表d,将待排序链表中第一个元素作为链表d中处于中间位置的记录(这个中间位置指值得大小,而不是序列的位置,序列位置为第一个),然后从链表第二个元素开始,逐个与该元素比大小,如果比之大,这放在这个元素的左边,反之,放在该元素的右边。而为了
2-路插入排序简单的说是以待排序序列中第一个记录为标准将序列分为两部分:小于该记录值的部分和大于该记录值的部分,两个部分都使用折半插入排序来完成排序。
希尔排序
希尔排序又称”缩小增量排序”(DiminishingIncrement Sort),它也是一种插入排序类的方法,但在时间效率上较前述几种排序方法有较大改进。
从对直接插入排序可知,顺序表被分为了两个部分,前一部分为排好序的,后一部分为待排序的。1 如果待排序的部分也基本上是排好序的,或者乱序的地方较少,那么直接插入排序的效率就可以大大的提高。2 由于直接插入排序算法简单,在n值很小时效率也比较高。希尔排序,正式从这两点出发,对直接插入排序进行改进,得到的一种插入排序方法。
它的基本思想是:有点类似微分的方法。即将整个序列分为多干小序列,对每个序列进行直接插入排序,使得整个序列”基本有序”, 然后再堆全体序列进行一次直接插入排序。
这个可以举例说明:
初始序列: 49 38 65 97 76 13 27 49 55 04
按照: (1, 6), (2,7).... 分为若干子序列,并进行直接插入排序
第一趟排序结果:
49 13
38 27
65 49
97 55
76 04
一趟排序结果: 13 27 49 55 04 49 38 65 97 76
我们,称这一趟排序为希尔排序。这一趟的特点是 以5为间距,即平分了整个链表。那么在第二趟中,在以3为间距, 将链表分为以下序列:
(1,4,7,10), (2,5,8),(3, 6, 9)
然后,进行一趟希尔排序。
最后,再对全体进行一趟直接插入排序。 即可。
直接插入,每次移动是以1作为增量的,而在希尔排序中,移动总是以所设间隔为增量。
综上所述,希尔排序 操作的核心思想是: 先以一个较大的数为间隔,将序列分为几个小序列,每个序列的增量都是所设间隔数,然后对其进行直接插入排序;再设定小一点的间隔数,对上一轮的排序再进行希尔排序。知道,当N=0为止, N为所设间隔数.
希尔排序的时间复杂度,肯定是比直接插入排序低的,它的分析是一个复杂的问题,由于涉及到一些尚未解决的数学难题,所以这里不再讨论。
C/C++ 代码实现:
//排序中要用到的比较函数
bool isLess(int a, int b)
{
return a>b?ERROR:TRUE;
}
/************写一个希尔排序****************/
//首先以某个间隔ds为增量,写一个直接插入排序
void shellA(List *L, int ds, bool (*p)(int, int))
{
for(int i = ds+1; i<=L->length; i++)
if(p(L->r[i].data, L->r[i-ds].data))
{
L->r[0].data = L->r[i].data;
L->r[i].data = L->r[i-ds].data;
int j;
for(j = i-2*ds; j>0&&p(L->r[0].data, L->r[j].data); j-=ds)
L->r[j+ds].data = L->r[j].data;
L->r[j+ds].data = L->r[0].data;
}
}
//在写一个有多趟shellA的排序, 其中t为排序趟数
void shellSort(List *L, int*d, int t, bool (*p)(int ,int))
{
for(int k=0; k<t-1; k++)
shellA(L, d[k], p);
}
可以看到,这种排序,需要自己输入每次的增量数,以及进行排序的趟数。当增量为0时即为全直接插入排序,其它情况只是对以增量构成的部分序列进行排序。
当需要排序的数据量很大时,这种排序便没有可实践的意义。因此,急需另一种排序方法。
快速排序
快速排序是对冒泡排序的一种改进。 那么,如果熟悉了冒泡排序的整个过程后,可以归纳快速排序的基本思想是:
通过一趟排序将待排序记录分割为独立的两部分,其中一部分记录的关键字均比另一部分记录的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。
那么,如何才能将一个有序序列分为这样的两部分呢? 首先,介绍枢轴的概念:即以序列中某一个元素(通常为序列中的第一个元素)为枢轴,比枢轴小的元素都在枢轴的左边,比枢轴大的元素在枢轴的右边。
因此,快速排序操作的基本步骤为:1用枢轴的概念,将序列分为两个部分,具体为,设置第一个元素为枢轴,然后分别设两个指针(low, high)分别指向序列的第一个元素和最后一个元素。 Hight向前查找第一个比枢轴小的元素,然后替换与枢轴的位置,紧接着,low向后查找第一个比枢轴大的元素,然后替换与枢轴的位置,直到low = high为止、
然后对每个部分进行步骤1,如此递归下去。递归的终止条件就是,low<high。
需要注意的是,在每次交换移动时,都要进行三个移动赋值操作,即 high =枢轴,再low=枢轴,枢轴=low为一个交换。 但是由于枢轴的最终位置是在最后一次的交换中,所以在一般的操作中,只有两次赋值操作。
直接贴C/C++代码:
关于 单链表的实现:
/********************用链表实现快速排序***********************/
//PS: 链表必须是头结点为 双指针, 因为,在排序的过程,它要改变指向
#include <iostream>
typedef int ElemType;
using namespace std;
//定义一个结点
typedef struct Node
{
ElemType data;
struct Node *next;
}LinkNode, *LinkList;
//创建一个链表
void createLst(LinkList *L)
{
*L = new LinkNode();
(*L)->data = 0;
(*L)->next = NULL;
}
//记录链表的长度
int lengthLst(LinkList L)
{
int lenth = 0;
while(L=L->next)
lenth++;
return lenth;
}
//在末尾插入元素
void pushLst(LinkList *L, ElemType e)
{
LinkNode *p, *q;
p = (*L);
while(p->next)
p=p->next;
q = new LinkNode();
if(!q) return ;
q->data = e;
p->next =q;
p = p->next;
p->next = NULL;
}
//显示链表中的所有元素
void printLst(LinkList L)
{
LinkNode *p;
p = L;
cout<< "链表的元素依次为:" <<endl;
while(p->next)
{
cout<<p->next->data<<endl;
p = p->next;
}
cout<< "链表的长度为:" <<lengthLst(L)<<endl;
}
//比大小操作
bool isLess(int a, int b)
{
return a>= b?0:1;
}
/*****************实现一个链表的快排*******************/
//首先是,枢轴的确定
LinkList *partition(LinkList *begin, LinkNode *end)
{
LinkNode *loop, *loop_temp; //作为循环链表
LinkList *pivote; //作为枢轴位置,也作为前一部分的end, 和后一部分的begin
LinkNode *q; // 作为新建的元素
loop_temp = *begin;
loop = (*begin)->next;
pivote = new LinkList();
createLst(pivote);
*pivote = (*begin)->next; // pivote 指向第一个元素
while(loop&&loop!=end) {
if(isLess(loop->data, (*pivote)->data))
{
q = new LinkNode();
if(!q) exit(1);
q->data = loop->data;
q->next= (*begin)->next;
(*begin)->next = q; // 插入到第一元素的前面
loop = loop->next;
loop_temp->next = loop; //删除比枢轴小的元素
} else{
loop_temp = loop;
loop = loop->next; //继续跟进
}
}
return pivote;
}
//利用递归实现最快排序
void QSort(LinkList *begin, LinkNode *end)
{
if((*begin)->next != end) //注意递归的结束条件
{
LinkList *pivote ;
pivote =partition(begin, end);
//createLst(pivote);
QSort(begin, *pivote);
QSort(pivote, end);
}
}
void main()
{
LinkList L;
createLst(&L);
int d[] = {18, 34, 16, 13, 27, 7, 19, 18, 8};
for(int i= 0; i<sizeof(d)/ sizeof(int ); i++)
pushLst(&L, d[i]);
printLst(L);
QSort(&L, NULL);
printLst(L);
}
链表的实现需要注意的是:对于节点next指向的改变,该节点必须是双指针(能够对地址的内容操作)。
快速排序的平均时间为Tavg= knlnn,在所有同数量级(nlnn) 的此类排序方法中,就平均时间而言,快速排序是目前被认为最好的一种内部排序方法。
但是,若初始记录序列按关键字有序或基本有序时,快速排序将退化为冒泡排序。
就时间复杂度而言,以上讨论的各种排序中,快速排序算法是最好的。但是就空间而言,除了2路插入排序算法,需要一个额外的序列之外,其它的方法我、都只有一个记录序列。但是,由于快速排序采用了递归算法,因此,它比别的方法多了一个栈空间。
如果,每一趟的快速排序中,能够将序列的两个部分分成相等的长度,那么栈空间的大小为(取下整数O(nlogn))+1,此外,其大小可能最坏为n。如果改写上述算法,先对两个部分中长度短的部分先排序,则栈的最大深度为 O(logn).
对于以下三种排序算法,由于选择排序的时间复杂度相对于直接插入排序没有改变,所以只介绍它的核心思想即可。 归并排序的时间复杂度为nlnn,且适用于处理大批量数据,因此将重点介绍。而对于最后一种基数排序,它是对多关键字的数据序列进行的一种排序方法,本文不予讨论,留在以后讨论。
选择排序
选择排序的基本思想是:每一趟在n-i+1(i=1,2, …, n-1)个记录中选取关键字最小的记录,并和第i个记录进行互换。
最为我们熟悉的就是简单选择排序。 它的算法可以用在锦标赛的模型当中。
简单选择排序:
简单选择排序,就是利用上述排序基本思想,进行了n-1的选择排序。它其实还是在冒泡的基础上,做了不同操作当相同思想的改变。
但是,我们能够继续对简单选择排序进行改进:
如果在第i趟时,利用第i-1趟的信息,让第i趟的比较次数减少,就达到了改进的目的。而这一改进可以借鉴锦标赛的比赛规则,即如果甲胜乙,乙胜丙,那么甲就胜丙。
由此,导出了树形选择排序以及它的改进者堆排序,在本文中不予介绍。
归并排序
归并是一类又不同的排序方法。归并的含义是将两个或两个以上的有序表组合到一个新的有序表中。如 下例所示:
初始关键字 49, 38, 65, 97, 76, 13, 27
一趟归并之后 (38, 49) (65, 97) (13, 76), 27
两趟归并之后 (38, 49, 65, 97) (13, 27, 76)
最后一趟就有序了
其算法的核心思想是:
在每一趟的归并中,我们可以发现,局部已经有序了,这要用到归并的一个核心操作:
我们总是假设,归并的两个表是有序的,则继而可以,将第一个表中的第一个元素与第二个表中的每个元素依次进行比较,如果小,则放入到合并表中的第一个元素位置,后续元素依次类推,然后将剩余元素(可能是第一个表,也可能是第二个表)依次插入到归并表的后面。
这个如果用单链表实现,可能不太适合。可以把链表转成线性表,再排序。这个算法的实现就不多说了。