数据结构学习笔记
8、排序
一般要保证元素相等的情况下,不去改变元素的位置。
8.1 简单排序
8.1.1 冒泡排序
//伪代码
void Bubble_Sort(ElementType A[], int N){
for(P=N-1; P>=0 ; P--){ //N次
flag = 0; //方便提前出循环
for(i=0; i<P; i++){ //一趟排序
if(A[i] > A[i+1]){
Swap(A[i], A[i+1]);
flag = 1; //数组未排完序
}
}
if(flag == 0) break; //数组已经排完序
}
}
最好情况:O(N);
最坏情况:O(N2)
8.1.2 插入排序
//伪代码
//每次掏一个未插入的元素,插入前面排完的数组里
void Insertion_Sort(ElementType A[], int N){
for(P=1; P<N; P++){
Tmp = A[P]; //摸一张牌
for(i=P; i>0 && A[i-1]>Tmp; i--)
A[i] = A[i-1]; //移出空位
A[i] = Tmp; //插入
}
}
最好情况:O(N);
最坏情况:O(N2)
8.2 希尔排序
以Dk-间隔,来排序。直到间隔为1。
注意:“Dk间隔”有序的序列,在执行“Dk-1间隔”排序后,仍然是“Dk间隔”有序。
8.2.1 原始希尔排序
增量序列设置:DM = [ N/2 ], Dk = [ Dk+1/2]
void Shell_sort(ElementType A[], int N){
for(D=N/2; D>0; D/=2){ //希尔增量序列
for(P=D; P<N; P++){ //改编插入排序,把1变成D
Tmp = A[P];
for(i=P; i>=D && A[i-D]>Tmp; i-=D)
A[i] = A[i-D];
A[i] = Tmp;
}
}
}
如果数组是2间隔有序,那么就会导致8间隔有序,4间隔有序。影响排序效率。所以增量序列应该保证是互质的。最坏时间复杂度:θ(N2)
8.2.2 更多增量序列
Hibbard增量序列
- Dk = 2k - 1
- 最坏情况:T = θ(N3/2)
- 猜想:平均复杂度 = O(N5/4)
Sedgewick增量序列
{1, 5, 19, 41, 109,…}
计算公式:9*4i - 9*2i + 1 或者 4i - 3*2i + 1
猜想:Tavg = O(N7/6), Tworst = O(N4/3)
void ShellSort( ElementType A[], int N )
{ /* 希尔排序 + 用Sedgewick增量序列 */
int Si, D, P, i;
ElementType Tmp;
/* 这里只列出一小部分增量 */
int Sedgewick[] = {929, 505, 209, 109, 41, 19, 5, 1, 0};
for ( Si=0; Sedgewick[Si]>=N; Si++ )
; /* 初始的增量Sedgewick[Si]不能超过待排序列长度 */
for ( D=Sedgewick[Si]; D>0; D=Sedgewick[++Si] )
for ( P=D; P<N; P++ ) { /* 插入排序*/
Tmp = A[P];
for ( i=P; i>=D && A[i-D]>Tmp; i-=D )
A[i] = A[i-D];
A[i] = Tmp;
}
}
8.3 堆排序
8.3.1 选择排序
void Selection_Sort(ElementType A[], int N){
for(i=0; i<N; i++){
//从A[i]到A[N-1]中找最小,并将其位置赋给MinPosition
MinPosition = ScanForMin(A, i, N-1);
//将上面找到的最小元换到有序部分的最后位置
Swap(A[i], A[MinPosition]);
}
}
如何找到最小元是选择排序的关键。
8.3.2 堆排序
void Heap_Sort(ElementType A[], int N){
for(i=N/2; i>=0; i--) //建立堆
PercDown(A, i, N);
for(i=N-1; i>0; i--){
Swap(&A[0], &A[i]); //此时A[0]是最大或者最小值,存在最后
PercDown(A, 0, i); //0当做根结点,i是个数
}
}
定理:堆排序处理N个不同元素的随机排列的平均比较次数是
2NlogN - O(NloglogN) 。
8.4 归并排序
时间复杂度:O(NlogN)。最大的缺点是需要额外的空间,而且执行过程中需要复制过来复制过去。实际应用中一般不用于内排序,适合外排序。
8.4.1 第一步:实现有序子列的归并
// L=左边起始位置,R=右边起始位置,RightEnd=右边终点位置
void Merge(ElementType A[], ElementType TmpA[],
int L, int R, int RightEnd){
LeftEnd = R - 1; //左边终点位置。假设左右两列挨着
Tmp = L; //临时数组的当前插入位置
NumElements = RightEnd - L + 1; //元素总量
while(L <= LeftEnd && R <= RightEnd){
if(A[L] <= A[R]) TmpA[Tmp++] = A[L++];
else TmpA[Tmp++] = A[R++];
}
while(L <= LeftEnd) //复制剩下的
TmpA[Tmp++] = A[L++];
while(R <= RightEnd)
TmpA[Tmp++] = A[R++];
//复制回原数组,因为此时L变化,可用RightEnd倒着复制
for(i=0; i<NumElements; i++, RightEnd--)
A[RIghtEnd] = TmpA[RightEnd];
}
8.4.2 归并排序的递归算法
//分而治之
void MSort(ElementType A[], ElementType TmpA[],
int L, int RightEnd){
int Center;
if(L < RightEnd){
Center = (L + RightEnd)/2;
MSort(A, TmpA, L, Center);
MSort(A, TmpA, Center+1, RightEnd);
Merge(A, TmpA, L, Center+1, RightEnd);
}
}
//统一函数接口
void Merge_sort(ElementType A[], int N){
ElementType *TmpA;
TmpA = malloc(N * sizeof(ElementType));
if(TmpA != NULL){
MSort(A, TmpA, 0, N-1);
free(TmpA);
}
else Error("空间不足");
}
不在Merge里创建TmpA的原因:会不断的申请释放空间。
8.4.3 归并排序的非递归算法
//一趟归并的函数
void Merge_pass(ElementType A[], ElementType TmpA[], int N,
int length){ //length是当前有序子列的长度,初始为1
//每次要跳过两段,去找下一段
//i <= N-2*length是为了剩个尾巴,因为有可能不是2个子列,要特别处理
for(i=0; i <= N-2*length; i += 2*length)
//归并两个序列,[i,i+length-1]和[i+length, i+2*length-1]
//Merge1是把A中元素归并到TmpA
Merge1(A, TmpA, i, i+length, i+2*length-1);
if(i+length < N) //说明还有2个子列
Merge1(A, TmpA, i, i+length, N-1);
else //最后只剩1个子列
for(j=i; j<N; j++) TmpA[j] = A[j];
}
//统一的接口
void Merge_sort(ElementType A[], int N){
int length = 1; //初始化子序列长度
ElementType *TmpA;
TmpA = malloc(N * sizeof(ElementType));
if(TmpA != NULL){
while(length < N){
Merge_pass(A, TmpA, N, length); //第一次有序数列存在TmpA里
length *= 2;
Merge_pass(TmpA, A, N, length); //第二次存回A里
length *= 2;
}
free(TmpA);
}
else Error("空间不足");
}
8.5 快速排序
//分而治之
void QuickSort(ElementType A[], int N){
if(N < 2) return; //递归结束条件
//选取主元
pivot = 从A[]中选一个主元;
//将A[]分成两个子集
A1 = {a ≤ pivot};
A2 = {a ≥ pivot};
//递归调用QuickSort
QuickSort(A1,N1);
QuickSort(A2,N2);
}
决定快速排序的关键是:1、怎么选取主元;2、怎么划分成两个子集
8.5.1 选主元
最佳情况:该主元能刚好平分数组,这种时间复杂度就能是O(NlogN)。
最糟糕情况:数组元素全划分在主元的某一侧,将会导致O(N2)。
选主元的方式:
- 直接选第一个当主元。最糟糕的情况是O(N2)。等于白搭。
- 随机取pivot。但是rand()函数不便宜
- 取头、中、尾三个数中的中位数。
ElementType Median3(ElementType A[], int Left, int Right){ int Center = (Left + Right)/2; //实现 A[Left] <= A[Center] <= A[Right] if(A[Left] > A[Center]) Swap(&A[Left], &A[Center]); if(A[Left] > A[Right]) Swap(&A[Left], &A[Right]); if(A[Center] > A[Right]) Swap(&A[Center], &A[Right]); Swap(&A[Center], &A[Right-1]); //将pivot藏到右边 //只需要考虑 A[Left+1]...A[Right-2] //因为这种选主元的过程实际上已经考虑了三个数的位置了 return A[Right-1]; //返回pivot }
8.5.2 子集划分
假设是用中位数的方法取主元。如上图,6是主元放在最右边。定义两侧的指针–i和j。移动比较。i 遇到比6大的就会停下,j 遇到比6小的就会停下。然后Swap。
终止情况:i , j移动到已经划分完的区域停下,此时下标 j < i。然后主元和 i 所在的位置交换。
如果有元素正好等于pivot怎么办?
- 停下来交换:好处是让主元靠近中间位置,尽量平分数组,实现O(NlogN);坏处是多了很多交换。
- 不理它,继续移动指针:好处是不用交换;坏处是最糟糕的时候可能导致O(N2)的情况。
为了效率,选择停下来交换这种。
8.5.3 小规模数据的处理
快速排序的问题:
- 是递归。。(利用的栈)
- 对于小规模的数据(例如N不到100)可能还不如插入排序快。
解决方案:
当递归的数据规模充分小,则停止递归,直接在这一层调用简单排序。
在程序中定义一个Cutoff的阈值。
8.5.4 算法实现
void QuickSort(ElementSort A[], int Left, int Right){
if(Cutoff <= Right-Left){ //阈值控制处理方式
//1.选择主元
Pivot = Median3(A, Left, Right);
//2.子集划分
i = Left; j = Right - 1;
//实际是要对[Left+1, Right-2]进行划分,Pivot藏在A[Right-1]
for( ; ; ){
while(A[++i] < Pivot){}
while(A[--j] < Pivot){}
if(i < j)
Swap(&A[i], &A[j]);
else break;
}
//3.插入主元
Swap(&A[i], &A[Right-1]);
//4.递归
QuickSort(A, Left, i-1);
QuickSort(A, i+1, Right);
} else //规模小,用插入排序解决
Insertion_Sort(A+Left, Right-Left+1);
}
//统一接口
void Quick_Sort(ElementType A[], int N){
QuickSort(A, 0, N-1);
}
8.5.5 直接调用库函数
/* 快速排序 - 直接调用库函数 */
#include <stdlib.h>
/*---------------简单整数排序--------------------*/
int compare(const void *a, const void *b){
/* 比较两整数。非降序排列 */
return (*(int*)a - *(int*)b);
}
/* 调用接口 */
qsort(A, N, sizeof(int), compare);
/*---------------简单整数排序--------------------*/
/*--------------- 一般情况下,对结构体Node中的某键值key排序 ---------------*/
struct Node {
int key1, key2;
} A[MAXN];
int compare2keys(const void *a, const void *b){
/* 比较两种键值:按key1非升序排列;如果key1相等,则按key2非降序排列 */
int k;
if ( ((const struct Node*)a)->key1 < ((const struct Node*)b)->key1 )
k = 1;
else if ( ((const struct Node*)a)->key1 > ((const struct Node*)b)->key1 )
k = -1;
else { /* 如果key1相等 */
if ( ((const struct Node*)a)->key2 < ((const struct Node*)b)->key2 )
k = -1;
else
k = 1;
}
return k;
}
/* 调用接口 */
qsort(A, N, sizeof(struct Node), compare2keys);
/*--------------- 一般情况下,对结构体Node中的某键值key排序 ---------------*/
8.6 表排序
8.6.1 间接排序
实际上,排序的单个元素不再是数字,而是结构体对象,可能很大。此时,不能直接去排序这些对象,因为不管啥排序都会去交换元素的空间。而应该通过间接排序的方式,解决排序问题。
根据key,给table(结构体下标)排序,通过下标,就可以按顺序访问数组。
8.6.2 物理排序
在表排序完后,已经可以有序访问数组。但是此时还想要根据表排序的结果,在实际空间里实现有序。【在线性时间内,实现该效果。】
已知:N个数字的排列由若干个独立的环组成。
关于环:
形成第一个环的过程:访问table[0]得到下一个是3,访问table[3]得到下一个是1,访问table[1]得到5,访问table[5]得到0。此时形成了环。
关于独立的理解:
可以看出,这些环是没有交集的,是独立的。
完成环的排序:
例如完成红色环的排序:Temp存A[0],A[0]存 A[table[0]] 即A[3]的值。
A[3] = A[1]。A[1] = A[5]。A[5] = Temp。
如何判断一个环结束:
每次访问一个空位i后,就令table[i] = i。如果发现 table[i]==i,说明环结束了。
复杂度:O(mN) 。m是结构体复制时间。
8.7 基数排序
8.7.1 桶排序
假设有M个桶,准备把N个元素塞入桶中。插入元素,先判断放于哪个桶中,然后插到该桶链表的表头。实现在线性时间内做排序。
void Bucket_Sort(ElementType A[], int N){
count[]初始化;
while(读入1个元素的key)
将该生插入count[key]链表;
for(i=0; i<M; i++)
if(count[i]) //如果链表不为空
输出整个count[i]的链表;
}
时间复杂度:O(M+N);
8.7.2 基数排序
桶排序的升级版。处理当M>>N的情况。
Pass 1根据数字的个位排序;Pass2根据十位排序;Pass3根据百位排序。
时间复杂度:O(P(N+B));
P是趟数,N是数字个数,B是桶数
8.7.3 多关键字的排序
用“主位优先”(MSD)排序:以主键建立桶。先根据主键把元素分别扔进桶里,再处理桶内部元素的排序。
次位优先:先以次键建立桶,然后扔进去元素,将结果合并;再以主键建立桶,然后再扔进去元素。自然就得到了主键优先,次键其次的排序结果。
8.7.4 算法实现
/* 基数排序 - 次位优先 */
/* 假设元素最多有MaxDigit个关键字,基数全是同样的Radix */
#define MaxDigit 4 //趟数,数字的最大位数
#define Radix 10 //桶数,对于数字,因为是0~9,10个数
/* 桶元素结点 */
typedef struct Node *PtrToNode;
struct Node {
int key;
PtrToNode next;
};
/* 桶头结点 */
struct HeadNode {
PtrToNode head, tail;
};
typedef struct HeadNode Bucket[Radix];
//返回第D位的数字,第1位是个位
int GetDigit ( int X, int D ){
/* 默认次位D=1, 主位D<=MaxDigit */
int d, i;
for (i=1; i<=D; i++) {
d = X % Radix;
X /= Radix;
}
return d;
}
//基数排序 - 次位优先
void LSDRadixSort( ElementType A[], int N ){
int D, Di, i;
Bucket B;
PtrToNode tmp, p, List = NULL;
for (i=0; i<Radix; i++) /* 初始化每个桶为空链表 */
B[i].head = B[i].tail = NULL;
for (i=0; i<N; i++) { //头插法
tmp = (PtrToNode)malloc(sizeof(struct Node));
tmp->key = A[i];
tmp->next = List;
List = tmp;
}
/* 下面开始排序 */
for (D=1; D<=MaxDigit; D++) { //对数据的每一位循环处理
//下面是扔进桶的过程
p = List;
while (p) {
Di = GetDigit(p->key, D); //获得当前元素的当前位数字
/* 从List中摘除 */
tmp = p; p = p->next;
/* 插入B[Di]号桶尾 */
tmp->next = NULL;
if (B[Di].head == NULL)
B[Di].head = B[Di].tail = tmp;
else {
B[Di].tail->next = tmp;
B[Di].tail = tmp;
}
}
//下面是把桶里的元素,再串成一个List的过程
List = NULL;
for (Di=Radix-1; Di>=0; Di--) {
if (B[Di].head) { /* 如果桶不为空 */
/* 整桶插入List表头 */
B[Di].tail->next = List;
List = B[Di].head;
B[Di].head = B[Di].tail = NULL; /* 清空桶 */
}
}
}//排序结束
/* 将List倒入A[]并释放空间 */
for (i=0; i<N; i++) {
tmp = List;
List = List->next;
A[i] = tmp->key;
free(tmp);
}
}
//基数排序 - 主位优先
void MSD( ElementType A[], int L, int R, int D )
{ /* 核心递归函数: 对A[L]...A[R]的第D位数进行排序 */
int Di, i, j;
Bucket B;
PtrToNode tmp, p, List = NULL;
if (D==0) return; /* 递归终止条件 */
for (i=0; i<Radix; i++) /* 初始化每个桶为空链表 */
B[i].head = B[i].tail = NULL;
for (i=L; i<=R; i++) { /* 将原始序列逆序存入初始链表List */
tmp = (PtrToNode)malloc(sizeof(struct Node));
tmp->key = A[i];
tmp->next = List;
List = tmp;
}
/* 下面是分配的过程 */
p = List;
while (p) {
Di = GetDigit(p->key, D); /* 获得当前元素的当前位数字 */
/* 从List中摘除 */
tmp = p; p = p->next;
/* 插入B[Di]号桶 */
if (B[Di].head == NULL) B[Di].tail = tmp;
tmp->next = B[Di].head;
B[Di].head = tmp;
}
/* 下面是收集的过程 */
i = j = L; /* i, j记录当前要处理的A[]的左右端下标 */
for (Di=0; Di<Radix; Di++) { /* 对于每个桶 */
if (B[Di].head) { /* 将非空的桶整桶倒入A[], 递归排序 */
p = B[Di].head;
while (p) {
tmp = p;
p = p->next;
A[j++] = tmp->key;
free(tmp);
}
/* 递归对该桶数据排序, 位数减1 */
MSD(A, i, j-1, D-1);
i = j; /* 为下一个桶对应的A[]左端 */
}
}
}
//统一接口
void MSDRadixSort( ElementType A[], int N ){
MSD(A, 0, N-1, MaxDigit);
}
8.8 排序算法的比较
排序方法 | 平均时间复杂度 | 最坏情况下时间复杂度 | 额外空间复杂度 | 稳定性 |
---|---|---|---|---|
简单选择排序 | O(N2) | O(N2) | O(1) | 不稳定 |
冒泡排序 | O(N2) | O(N2) | O(1) | 稳定 |
直接插入排序 | O(N2) | O(N2) | O(1) | 稳定 |
希尔排序 | O(Nd) | O(N2) | O(1) | 不稳定 |
堆排序 | O(NlogN) | O(NlogN) | O(1) | 不稳定 |
快速排序 | O(NlogN) | O(N2) | O(logN) | 不稳定 |
归并排序 | O(NlogN) | O(NlogN) | O(N) | 稳定 |
基数排序 | O(P(N+B)) | O(P(N+B)) | O(N+B) | 稳定 |
简单选择排序、冒泡排序、直接插入排序:都是简单排序,代码好写,效率差。
冒泡排序、直接插入排序:因为交换的都是相邻元素,所以稳定。
简单选择排序:因为是跳着交换的,所以不稳定。
希尔排序:效率能突破N2,下界时间复杂度取决于增量序列的选择。
堆排序、归并排序:是理论上效率最好的。
归并排序:缺点是有额外空间,优点是稳定。
堆排序、快速排序:都是不稳定的。
快速排序:因为是递归的,所以需要额外的O(logN)的空间。在实际应用中快速排序是比较快的。