排序算法合集

1基本概念
    排序(Sorting)是计算机程序设计中的一种重要操作,其功能是对一个数据元素集合或序列重新排列成一个按数据元素某个项值有序的序列。作为排序依据的数据项称为排序码,也即数据元素的关键码。为了便于查找,通常希望计算机中的数据表是按关键码有序的。如有序表的折半查找,查找效率较高。还有,二叉排序树、B-树和B+树的构造过程就是一个排序过程。若关键码是主关键码,则对于任意待排序序列,经排序后得到的结果是唯一的;若关键码是次关键码,排序结果可能不唯一,这是因为具有相同关键码的数据元素,这些元素在排序结果中,它们之间的的位置关系与排序前不能保持。
    若对任意的数据元素序列,使用某个排序方法,对它按关键码进行排序:若相同关键码元素间的位置关系,排序前与排序后保持一致,称此排序方法是稳定的;而不能保持一致的排序方法则称为不稳定的。
排序分为两类:内排序和外排序。
内排序:指待排序列完全存放在内存中所进行的排序过程,适合不太大的元素序列。
外排序:指排序过程中还需访问外存储器,足够大的元素序列,因不能完全放入内存,只能使用外排序。
2 插入排序
2.1
直接插入排序
   设有 n 个记录,存放在数组 r 中,重新安排记录在数组中的存放顺序,使得按关键码有序。即
r[1].key≤r[2].key≤……≤r[n].key
先来看看向有序表中插入一个记录的方法:
设1 < n, r[1].key≤r[2].key≤……≤r[j-1].key ,将 r[j] 插入,重新安排存放顺序,使得 r[1].key≤r[2].key≤……≤r[j].key ,得到新的有序表,记录数增1。
【算法 1
r[0]=r[j] //r[j] r[0] 中,使 r[j] 为待插入记录空位
i=j-1
// 从第 i 个记录向前测试插入位置,用 r[0] 为辅助单元, 可免去测试 i<1
r[0].key≥r[i].key ,转 // 插入位置确定
r[0].key < r[i].key 时,
r[i+1]=r[i]
i=i-1 ;转 // 调整待插入位置
r[i+1]=r[0] ;结束。 // 存放待插入记录
【例 1 】向有序表中插入一个记录的过程如下:
r[1] r[2] r[3] r[4] r[5]
存储单元
2 10 18 25 9
r[5] 插入四个记录的有序表中, j=5
r[0]=r[j]
i=j-1 初始化,设置待插入位置
2 10 18 25 □ r[i+1]
为待插入位置
i=4
r[0] < r[i] r[i+1]=r[i] i-- 调整待插入位置
2 10 18 □ 25
i=3
r[0] < r[i] r[i+1]=r[i] i-- 调整待插入位置
2 10 □ 18 25
i=2
r[0] < r[i] r[i+1]=r[i] i-- 调整待插入位置
2 □ 10 18 25
i=1
r[0] ≥r[i] r[i+1]=r[0] 插入位置确定,向空位填入插入记录
2 9 10 18 25
向有序表中插入一个记录的过程结束
直接插入排序方法:仅有一个记录的表总是有序的,因此,对 n 个记录的表,可从第二个记录开始直到第 n 个记录,逐个向有序表中进行插入操作,从而得到 n 个记录按关键码有序的表。
【算法 2
void InsertSort(S_TBL &p)
{ for(i=2
i<=p->length i++)
if(p->elem[i].key < p->elem[i-1].key) /*
小于时,需将 elem[i] 插入有序表 */
{ p->elem[0].key=p->elem[i].key
/* 为统一算法设置监测 */
for(j=i-1
p->elem[0].key < p->elem[j].key j--)
p->elem[j+1].key=p->elem[j].key
/* 记录后移 */
p->elem[j+1].key=p->elem[0].key
/* 插入到正确位置 */
}
}
【效率分析】
空间效率:仅用了一个辅助单元。
时间效率:向有序表中逐个插入记录的操作,进行了 n-1 趟,每趟操作分为比较关键码和移动记录,而比较的次数和移动记录的次数取决于待排序列按关键码的初始排列。
最好情况下:即待排序列已按关键码有序,每趟操作只需 1 次比较 2 次移动。
总比较次数 =n-1
总移动次数 =2(n-1)
最坏情况下:即第 j 趟操作,插入记录需要同前面的 j 个记录进行 j 次关键码比较,移动记录的次数为 j+2 次。
平均情况下:即第 j 趟操作,插入记录大约同前面的 j/2 个记录进行关键码比较,移动记录的次数为 j/2+2 次。
由此,直接插入排序的时间复杂度为 O(n2) 。是一个稳定的排序方法。
2.2 折半插入排序
直接插入排序的基本操作是向有序表中插入一个记录,插入位置的确定通过对有序表中记录按关键码逐个比较得到的。平均情况下总比较次数约为 n2/4 。既然是在有序表中确定插入位置,可以不断二分有序表来确定插入位置,即一次比较,通过待插入记录与有序表居中的记录按关键码比较,将有序表一分为二,下次比较在其中一个有序子表中进行,将子表又一分为二。这样继续下去,直到要比较的子表中只有一个记录时,比较一次便确定了插入位置。
二分判定有序表插入位置方法:
low=1 high=j-1 r[0]=r[j] // 有序表长度为 j-1 ,第 j 个记录为待插入记录
//
设置有序表区间,待插入记录送辅助单元
low>high ,得到插入位置,转
low≤high m=(low+high)/2 // 取表的中点,并将表一分为二,确定待插入区间 */
r[0].key<r[m].key high=m-1 // 插入位置在低半区
否则, low=m+1 // 插入位置在高半区

high+1 即为待插入位置,从 j-1 high+1 的记录,逐个后移, r[high+1]=r[0] ;放置待插入记录。
【算法 3
void InsertSort(S_TBL *s)
{ /*
对顺序表 s 作折半插入排序 */
for(i=2
i<=s->length i++)
{ s->elem[0]=s->elem[i]
/* 保存待插入元素 */
low=i
high=i-1 /* 设置初始区间 */
while(low<=high) /*
该循环语句完成确定插入位置 */
{ mid=(low+high)/2

if(s->elem[0].key>s->elem[mid].key)
low=mid+1
/* 插入位置在高半区中 */
else high=mid-1
/* 插入位置在低半区中 */
}/* while */
for(j=i-1
j>=high+1 j--) /* high+1 为插入位置 */
s->elem[j+1]=s->elem[j]
/* 后移元素,留出插入空位 */
s->elem[high+1]=s->elem[0]
/* 将元素插入 */
}/* for */
}/* InsertSort */
【时间效率】
确定插入位置所进行的折半查找,关键码的比较次数至多为 ,次,移动记录的次数和直接插入排序相同,故时间复杂度仍为 O(n2) 。是一个稳定的排序方法。
2.3
表插入排序
直接插入排序、折半插入排序均要大量移动记录,时间开销大。若要不移动记录完成排序,需要改变存储结构,进行表插入排序。所谓表插入排序,就是通过链接指针,按关键码的大小,实现从小到大的链接过程,为此需增设一个指针项。操作方法与直接插入排序类似,所不同的是直接插入排序要移动记录,而表插入排序是修改链接指针。用静态链表来说明。
#define SIZE 200
typedef struct{
ElemType elem
/* 元素类型 */
int next
/* 指针项 */
}NodeType
/* 表结点类型 */
typedef struct{
NodeType r[SIZE]
/* 静态链表 */
int length
/* 表长度 */
}L_TBL
/* 静态链表类型 */
假设数据元素已存储在链表中,且 0 号单元作为头结点,不移动记录而只是改变链指针域,将记录按关键码建为一个有序链表。首先,设置空的循环链表,即头结点指针域置 0 ,并在头结点数据域中存放比所有记录关键码都大的整数。接下来,逐个结点向链表中插入即可。
【例 2 】表插入排序示例
MAXINT 49 38 65 97 76 13 27 49
0 - - - - - - - -
MAXINT 49 38 65 97 76 13 27 49
1 0 - - - - - - -
MAXINT 49 38 65 97 76 13 27 49
2 0 1 - - - - - -
MAXINT 49 38 65 97 76 13 27 49
2 3 1 0 - - - - -
MAXINT 49 38 65 97 76 13 27 49
2 3 1 4 0 - - - -
MAXINT 49 38 65 97 76 13 27 49
2 3 1 5 0 4 - - -
MAXINT 49 38 65 97 76 13 27 49
6 3 1 5 0 4 2 - -
MAXINT 49 38 65 97 76 13 27 49
6 3 1 5 0 4 7 2 -
MAXINT 49 38 65 97 76 13 27 49
6 8 1 5 0 4 7 2 3
1
表插入排序得到一个有序的链表,查找则只能进行顺序查找,而不能进行随机查找,如折半查找。为此,还需要对记录进行重排。
重排记录方法:按链表顺序扫描各结点,将第 i 个结点中的数据元素调整到数组的第 i 个分量数据域。因为第 i 个结点可能是数组的第 j 个分量,数据元素调整仅需将两个数组分量中数据元素交换即可,但为了能对所有数据元素进行正常调整,指针域也需处理。
【算法 3
1. j=l->r[0].next
i=1 // 指向第一个记录位置,从第一个记录开始调整
2.
i=l->length 时,调整结束;否则,
a.
i=j j=l->r[j].next i++ ;转 (2) // 数据元素应在这分量中,不用调整,处理下一个结点
b.
j>i l->r[i].elem<-->l->r[j].elem // 交换数据元素
p=l->r[j].next
// 保存下一个结点地址
l->r[j].next=l->[i].next
l->[i].next=j // 保持后续链表不被中断
j=p
i++ ;转 (2) // 指向下一个处理的结点
c.
j<i while(j<i) j=l->r[j].next //j 分量中原记录已移走,沿 j 的指针域找寻原记录的位置
转到 (a)
【例 3 】对表插入排序结果进行重排示例
MAXINT 49 38 65 97 76 13 27 49
6 8 1 5 0 4 7 2 3
MAXINT 13 38 65 97 76 49 27 49
6 (6) 1 5 0 4 8 2 3
MAXINT 13 27 65 97 76 49 38 49
6 (6) (7) 5 0 4 8 1 3
MAXINT 13 27 38 97 76 49 65 49
6 (6) (7) (7) 0 4 8 5 3
MAXINT 13 27 38 49 76 97 65 49
6 (6) (7) (7) (6) 4 0 5 3
MAXINT 13 27 38 49 49 97 65 76
6 (6) (7) (7) (6) (8) 0 5 4
MAXINT 13 27 38 49 49 65 97 76
6 (6) (7) (7) (6) (8) (7) 0 4
MAXINT 13 27 38 49 49 65 76 97
6 (6) (7) (7) (6) (8) (7) (8) 0
2
【时效分析】
表插入排序的基本操作是将一个记录插入到已排好序的有序链表中,设有序表长度为 i ,则需要比较至多 i+1 次,修改指针两次。因此,总比较次数与直接插入排序相同,修改指针总次数为 2n 次。所以,时间复杂度仍为 O(n2)
2.4 希尔排序 (Shell’s Sort)
希尔排序又称缩小增量排序,是 1959 年由 D.L.Shell 提出来的,较前述几种插入排序方法有较大的改进。
直接插入排序算法简单,在 n 值较小时,效率比较高,在 n 值很大时,若序列按关键码基本有序,效率依然较高,其时间效率可提高到 O(n) 。希尔排序即是从这两点出发,给出插入排序的改进方法。
希尔排序方法:
1.
选择一个步长序列 t1 t2 tk ,其中 ti>tj tk=1
2.
按步长序列个数 k ,对序列进行 k 趟排序;
3.
每趟排序,根据对应的步长 ti ,将待排序列分割成若干长度为 m 的子序列,分别对各子表进行直接插入排序。仅步长因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
【例 4 】待排序列为 39 80 76 41 13 29 50 78 30 11 100 7 41 86
步长因子分别取 5 3 1 ,则排序过程如下:
p=5 39 80 76 41 13 29 50 78 30 11 100 7 41 86
└─────────┴─────────┘
└─────────┴──────────┘
└─────────┴──────────┘
└─────────┴──────────┘
└─────────┘
子序列分别为 {39 29 100} {80 50 7} {76 78 41} {41 30 86} {13 11}
第一趟排序结果:
p=3 29 7 41 30 11 39 50 76 41 13 100 80 78 86
└─────┴─────┴─────┴──────┘
└─────┴─────┴─────┴──────┘
└─────┴─────┴──────┘
子序列分别为 {29 30 50 13 78} {7 11 76 100 86} {41 39 41 80}
第二趟排序结果:
p=1 13 7 39 29 11 41 30 76 41 50 86 80 78 100
此时,序列基本 有序 ,对其进行直接插入排序,得到最终结果:
7 11 13 29 30 39 41 41 50 76 78 80 86 100
3
【算法 5
void ShellInsert(S_TBL &p
int dk)
{ /*
一趟增量为 dk 的插入排序, dk 为步长因子 */
for(i=dk+1
i<=p->length i++)
if(p->elem[i].key < p->elem[i-dk].key) /*
小于时,需 elem[i] 将插入有序表 */
{ p->elem[0]=p->elem[i]
/* 为统一算法设置监测 */
for(j=i-dk
j>0&&p->elem[0].key < p->elem[j].key j=j-dk)
p->elem[j+dk]=p->elem[j]
/* 记录后移 */
p->elem[j+dk]=p->elem[0]
/* 插入到正确位置 */
}
}
void ShellSort(S_TBL *p int dlta[] int t)
{ /*
按增量序列 dlta[0 1… t-1] 对顺序表 *p 作希尔排序 */
for(k=0
k<t t++)
ShellSort(p
dlta[k]) /* 一趟增量为 dlta[k] 的插入排序 */
}
【时效分析】
希尔排序时效分析很难,关键码的比较次数与记录移动次数依赖于步长因子序列的选取,特定情况下可以准确估算出关键码的比较次数和记录的移动次数。目前还没有人给出选取最好的步长因子序列的方法。步长因子序列可以有各种取法,有取奇数的,也有取质数的,但需要注意:步长因子中除 1 外没有公因子,且最后一个步长因子必须为 1 。希尔排序方法是一个不稳定的排序方法。
3 交换排序
交换排序主要是通过两两比较待排记录的关键码,若发生与排序要求相逆,则交换之。
3.1
冒泡排序 (Bubble Sort)
先来看看待排序列一趟冒泡的过程:设 1<j≤n r[1],r[2],···,r[j] 为待排序列,
通过两两比较、交换,重新安排存放顺序,使得 r[j] 是序列中关键码最大的记录。一趟冒泡方法为:
i=1 // 设置从第一个记录开始进行两两比较
i≥j ,一趟冒泡结束。
比较 r[i].key r[i+1].key ,若 r[i].key≤r[i+1].key ,不交换,转
r[i].key>r[i+1].key 时, r[0]=r[i] r[i]=r[i+1] r[i+1]=r[0]
r[i] r[i+1] 交换
i=i+1 调整对下两个记录进行两两比较,转
冒泡排序方法:对 n 个记录的表,第一趟冒泡得到一个关键码最大的记录 r[n] ,第二趟冒泡对 n-1 个记录的表,再得到一个关键码最大的记录 r[n-1] ,如此重复,直到 n 个记录按关键码有序的表。
【算法 6
j=n // n 记录的表开始
j<2 ,排序结束
i=1 // 一趟冒泡,设置从第一个记录开始进行两两比较,
i≥j ,一趟冒泡结束, j=j-1 ;冒泡表的记录数 -1 ,转
比较 r[i].key r[i+1].key ,若 r[i].key≤r[i+1].key ,不交换,转
r[i].key>r[i+1].key 时, r[i]<-->r[i+1] r[i] r[i+1] 交换
i=i+1 调整对下两个记录进行两两比较,转
【效率分析】
空间效率:仅用了一个辅助单元。
时间效率:总共要进行 n-1 趟冒泡,对 j 个记录的表进行一趟冒泡需要 j-1 次关键码比较。
移动次数:
最好情况下:待排序列已有序,不需移动。
3.2
快速排序
快速排序是通过比较关键码、交换记录,以某个记录为界 ( 该记录称为支点 ) ,将待排序列分成两部分。其中,一部分所有记录的关键码大于等于支点记录的关键码,另一部分所有记录的关键码小于支点记录的关键码。我们将待排序列按关键码以支点记录分成两部分的过程,称为一次划分。对各部分不断划分,直到整个序列按关键码有序。
一次划分方法:
1≤p<q≤n r[p],r[p+1],...,r[q] 为待排序列
low=p high=q // 设置两个搜索指针, low 是向后搜索指针, high 是向前搜索指针
r[0]=r[low]
// 取第一个记录为支点记录, low 位置暂设为支点空位
low=high ,支点空位确定,即为 low
r[low]=r[0]
// 填入支点记录,一次划分结束
否则, low<high ,搜索需要交换的记录,并交换之
low<high r[high].key≥r[0].key // high 所指位置向前搜索,至多到 low+1 位置
high=high-1
;转 // 寻找 r[high].key<r[0].key
r[low]=r[high]
// 找到 r[high].key<r[0].key ,设置 high 为新支点位置,
//
小于支点记录关键码的记录前移。
low<high r[low].key<r[0].key // low 所指位置向后搜索,至多到 high-1 位置
low=low+1
;转 // 寻找 r[low].key≥r[0].key
r[high]=r[low]
// 找到 r[low].key≥r[0].key ,设置 low 为新支点位置,
//
大于等于支点记录关键码的记录后移。
// 继续寻找支点空位
【算法 7
int Partition(S_TBL *tbl,int low,int high) /*
一趟快排序 */
{ /*
交换顺序表 tbl 中子表 tbl->[low…high] 的记录,使支点记录到位,并反回其所在位置 */
/*
此时,在它之前 ( ) 的记录均不大 ( ) 于它 */
tbl->r[0]=tbl->r[low]; /*
以子表的第一个记录作为支点记录 */
pivotkey=tbl->r[low].key; /*
取支点记录关键码 */
while(low<higu) /*
从表的两端交替地向中间扫描 */
{ while(low<high&&tbl->r[high].key>=pivotkey) high--;
tbl->r[low]=tbl->r[high]; /*
将比支点记录小的交换到低端 */
while(low<high&&tbl-g>r[high].key<=pivotkey) low++;
tbl->r[low]=tbl->r[high]; /*
将比支点记录大的交换到低端 */
}
tbl->r[low]=tbl->r[0]; /*
支点记录到位 */
return low; /*
反回支点记录所在位置 */
}
【例 5 】一趟快排序过程示例
r[1] r[2] r[3] r[4] r[5] r[6] r[7] r[8] r[9] r[10]
存储单元
49 14 38 74 96 65 8 49 55 27
记录中关键码
low=1
high=10 设置两个搜索指针, r[0]=r[low] 支点记录送辅助单元,
□ 14 38 74 96 65 8 49 55 27
↑ ↑
low high
第一次搜索交换
high 向前搜索小于 r[0].key 的记录,得到结果
27 14 38 74 96 65 8 49 55 □
↑ ↑
low high
low 向后搜索大于 r[0].key 的记录,得到结果
27 14 38 □ 96 65 8 49 55 74
↑ ↑
low high
第二次搜索交换
high 向前搜索小于 r[0].key 的记录,得到结果
27 14 38 8 96 65 □ 49 55 74
↑ ↑
low high
low 向后搜索大于 r[0].key 的记录,得到结果
27 14 38 8 □ 65 96 49 55 74
↑ ↑
low high
第三次搜索交换
high 向前搜索小于 r[0].key 的记录,得到结果
27 14 38 8 □ 65 96 49 55 74
↑↑
low high
low 向后搜索大于 r[0].key 的记录,得到结果
27 14 38 8 □ 65 96 49 55 74
↑↑
low high
low=high
,划分结束,填入支点记录
27 14 38 8 49 65 96 49 55 74
【算法 8
void QSort(S_TBL *tbl,int low,int high) /*
递归形式的快排序 */
{ /*
对顺序表 tbl 中的子序列 tbl->[low…high] 作快排序 */
if(low<high)
{ pivotloc=partition(tbl,low,high); /*
将表一分为二 */
QSort(tbl,low,pivotloc-1); /*
对低子表递归排序 */
QSort(tbl,pivotloc+1,high); /*
对高子表递归排序 */
}
}

【效率分析】
空间效率:快速排序是递归的,每层递归调用时的指针和参数均要用栈来存放,递归调用层次数与上述二叉树的深度一致。因而,存储开销在理想情况下为 O(log2n) ,即树的高度;在最坏情况下,即二叉树是一个单链,为 O(n)
时间效率:在 n 个记录的待排序列中,一次划分需要约 n 次关键码比较,时效为 O(n) ,若设 T(n) 为对 n 个记录的待排序列进行快速排序所需时间。
理想情况下:每次划分,正好将分成两个等长的子序列,则
T(n)≤cn+2T(n/2) c 是一个常数
≤cn+2(cn/2+2T(n/4))=2cn+4T(n/4)
≤2cn+4(cn/4+T(n/8))=3cn+8T(n/8)
······
≤cnlog2n+nT(1)=O(nlog2n)
最坏情况下:即每次划分,只得到一个子序列,时效为 O(n2)
快速排序是通常被认为在同数量级( O(nlog2n) )的排序方法中平均性能最好的。但若初始序列按关键码有序或基本有序时,快排序反而蜕化为冒泡排序。为改进之,通常以 三者取中法 来选取支点记录,即将排序区间的两个端点与中点三个记录关键码居中的调整为支点记录。快速排序是一个不稳定的排序方法。
4
选择排序
选择排序主要是每一趟从待排序列中选取一个关键码最小的记录,也即第一趟从 n 个记录中选取关键码最小的记录,第二趟从剩下的 n-1 个记录中选取关键码最小的记录,直到整个序列的记录选完。这样,由选取记录的顺序,便得到按关键码有序的序列。
4.1
简单选择排序
操作方法:第一趟,从 n 个记录中找出关键码最小的记录与第一个记录交换;第二趟,从第二个记录开始的 n-1 个记录中再选出关键码最小的记录与第二个记录交换;如此,第 i 趟,则从第 i 个记录开始的 n-i+1 个记录中选出关键码最小的记录与第 i 个记录交换,直到整个序列按关键码有序。
【算法 9
void SelectSort(S_TBL *s)
{ for(i=1
i<s->length i++)
{ /*
length-1 趟选取 */
for(j=i+1
t=i j<=s->length j++)
{ /*
i 开始的 length-n+1 个记录中选关键码最小的记录 */
if(s->elem[t].key>s->elem[j].key)
t=j
/* t 中存放关键码最小记录的下标 */
}
s->elem[t]<-->s->elem[i]
/* 关键码最小的记录与第 i 个记录交换 */
}
}
从程序中可看出,简单选择排序移动记录的次数较少,但关键码的比较次数依然是
4.2 树形选择排序
按照锦标赛的思想进行,将 n 个参赛的选手看成完全二叉树的叶结点,则该完全二叉树有 2n-2 2n-1 个结点。首先,两两进行比赛 ( 在树中是兄弟的进行,否则轮空,直接进入下一轮 ) ,胜出的再兄弟间再两两进行比较,直到产生第一名;接下来,将作为第一名的结点看成最差的,并从该结点开始,沿该结点到根路径上,依次进行各分枝结点子女间的比较,胜出的就是第二名。因为和他比赛的均是刚刚输给第一名的选手。如此,继续进行下去,直到所有选手的名次排定。
【例 6 16 个选手的比赛 (n=24)
从叶结点开始的兄弟间两两比赛,胜者上升到父结点;胜者兄弟间再两两比赛,直到根结点,产生第一名 91 。比较次数为 23+22+21+20=24-1=n-1
将第一名的结点置为最差的,与其兄弟比赛,胜者上升到父结点,胜者兄弟间再比赛,直到根结点,产生第二名 83 。比较次数为 4 ,即 log2n 次。其后各结点的名次均是这样产生的,所以,对于 n 个参赛选手来说,即对 1 ,故时间复杂度为 O(nlog2n) 。该方法占用空间较多,除需输出排序结果的 n 个单元外,尚需 n-1 个辅助单元。 - n + 1)log2n - n 个记录进行树形选择排序,总的关键码比较次数至多为 (n
4.3 堆排序 (Heap Sort)
设有 n 个元素的序列 k1 k2 kn ,当且仅当满足下述关系之一时,称之为堆。
若以一维数组存储一个堆,则堆对应一棵完全二叉树,且所有非叶结点的值均不大于 ( 或不小于 ) 其子女的值,根结点的值是最小 ( 或最大 ) 的。
设有 n 个元素,将其按关键码排序。首先将这 n 个元素按关键码建成堆,将堆顶元素输出,得到 n 个元素中关键码最小 ( 或最大 ) 的元素。然后,再对剩下的 n-1 个元素建成堆,输出堆顶元素,得到 n 个元素中关键码次小 ( 或次大 ) 的元素。如此反复,便得到一个按关键码有序的序列。称这个过程为堆排序。
因此,实现堆排序需解决两个问题:
1.
如何将 n 个元素的序列按关键码建成堆;
2.
输出堆顶元素后,怎样调整剩余 n-1 个元素,使其按关键码成为一个新堆。
首先,讨论输出堆顶元素后,对剩余元素重新建成堆的调整过程。
调整方法:设有 m 个元素的堆,输出堆顶元素后,剩下 m-1 个元素。将堆底元素送入堆顶,堆被破坏,其原因仅是根结点不满足堆的性质。将根结点与左、右子女中较小 ( 或小大 ) 的进行交换。若与左子女交换,则左子树堆被破坏,且仅左子树的根结点不满足堆的性质;若与右子女交换,则右子树堆被破坏,且仅右子树的根结点不满足堆的性质。继续对不满足堆性质的子树进行上述交换操作,直到叶子结点,堆被建成。称这个自根结点到叶子结点的调整过程为筛选。
【例 6
再讨论对 n 个元素初始建堆的过程。
建堆方法:对初始序列建堆的过程,就是一个反复进行筛选的过程。 n 个结点的完全
子树成为堆,之后向前依次对各结点为根的子树进行筛选,使之成为堆,直到根结点。
【例 7
堆排序:对 n 个元素的序列进行堆排序,先将其建成堆,以根结点与第 n 个结点交换;调整前 n-1 个结点成为堆,再以根结点与第 n-1 个结点交换;重复上述操作,直到整个序列有序。
【算法 10
void HeapAdjust(S_TBL *h
int s int m)
{/*r[s…m]
中的记录关键码除 r[s] 外均满足堆的定义,本函数将对第 s 个结点为根的子树筛选,使其成为大顶堆 */
rc=h->r[s]

for(j=2*s
j<=m j=j*2) /* 沿关键码较大的子女结点向下筛选 */
{ if(j<m&&h->r[j].key<h->r[j+1].key)
j=j+1
/* 为关键码较大的元素下标 */
if(rc.key<h->r[j].key) break
/* rc 应插入在位置 s */
h->r[s]=h->r[j]
s=j /* 使 s 结点满足堆定义 */
}
h->r[s]=rc
/* 插入 */
}
void HeapSort(S_TBL *h)
{ for(i=h->length/2
i>0 i--) /* r[1..length] 建成堆 */
HeapAdjust(h
i h->length)
for(i=h->length
i>1 i--)
{ h->r[1]<-->h->r[i]
/* 堆顶与堆低元素交换 */
HeapAdjust(h
1 i-1) /* r[1..i-1] 重新调整为堆 */
}
}
次,交换记录至多 k 次。所以,在建好堆后,排序过程中的筛选次数不超过下式:
 + … +
û 2) - log2(n ë + û 1) - log2(n ë ( 2   ) û log22 < nlog2n 2
而建堆时的比较次数不超过 4n 次,因此堆排序最坏情况下,时间复杂度也为 O(nlog2n)
10.5
二路归并排序
二路归并排序的基本操作是将两个有序表合并为一个有序表。
r[u…t] 由两个有序子表 r[u…v-1] r[v…t] 组成,两个子表长度分别为 v-u t-v+1 。合并方法为:
i=u j=v k=u // 置两个子表的起始下标及辅助数组的起始下标
i>v j>t ,转 // 其中一个子表已合并完,比较选取结束
// 选取 r[i] r[j] 关键码较小的存入辅助数组 rf
如果 r[i].key<r[j].key rf[k]=r[i] i++ k++
否则, rf[k]=r[j] j++ k++
// 将尚未处理完的子表中元素存入 rf
如果 i<v ,将 r[i…v-1] 存入 rf[k…t] // 前一子表非空
如果 j<=t ,将 r[i…v] 存入 rf[k…t] // 后一子表非空
合并结束。
【算法 11
void Merge(ElemType *r
ElemType *rf int u int v int t)
{
for(i=u
j=v k=u i<v&&j<=t k++)
{ if(r[i].key<r[j].key)
{ rf[k]=r[i]
i++ }
else
{ rf[k]=r[j]
j++ }
}
if(i<v) rf[k…t]=r[i…v-1]

if(j<=t) rf[k…t]=r[j…t]

}
. 两路归并的迭代算法
1
个元素的表总是有序的。所以对 n 个元素的待排序列,每个元素可看成 1 个有序子
表长度均为 2 。再进行两两合并,直到生成 n 个元素按关键码有序的表。
【算法 12
void MergeSort(S_TBL *p
ElemType *rf)
{ /*
*p 表归并排序, *rf 为与 *p 表等长的辅助数组 */
ElemType *q1
*q2
q1=rf
q2=p->elem
for(len=1
len<p->length len=2*len) /* q2 归并到 q1*/
{ for(i=1
i+2*len-1<=p->length i=i+2*len)
Merge(q2
q1 i i+len i+2*len-1) /* 对等长的两个子表合并 */
if(i+len-1<p->length)
Merge(q2
q1 i i+len p->length) /* 对不等长的两个子表合并 */
else if(i<=p->length)
while(i<=p->length) /*
若还剩下一个子表,则直接传入 */
q1[i]=q2[i]

q1<-->q2
/* 交换,以保证下一趟归并时,仍从 q2 归并到 q1*/
if(q1!=p->elem) /*
若最终结果不在 *p 表中,则传入之 */
for(i=1
i<=p->length i++)
p->elem[i]=q1[i]

}
}
. 两路归并的递归算法
【算法 13
void MSort(ElemType *p
ElemType *p1 int s int t)
{ /*
p[s…t] 归并排序为 p1[s…t]*/
if(s==t) p1[s]=p[s]
else
{ m=(s+t)/2
/* 平分 *p */
MSort(p
p2 s m) /* 递归地将 p[s…m] 归并为有序的 p2[s…m]*/
MSort(p
p2 m+1 t) /* 递归地将 p[m+1…t] 归并为有序的 p2[m+1…t]*/
Merge(p2
p1 s m+1 t) /* p2[s…m] p2[m+1…t] 归并到 p1[s…t]*/
}
}
void MergeSort(S_TBL *p)
{ /*
对顺序表 *p 作归并排序 */
MSort(p->elem
p->elem 1 p->length)
}

【效率分析】
需要一个与表等长的辅助元素数组空间,所以空间复杂度为 O(n)
n 个元素的表,将这 n 个元素看作叶结点,若将两两归并生成的子表看作它们的父结点,则归并过程对应由叶向根生成一棵二叉树的过程。所以归并趟数约等于二叉树的高度 -1 ,即 log2n ,每趟归并需移动记录 n 次,故时间复杂度为 O(nlog2n)
6 基数排序
基数排序是一种借助于多关键码排序的思想,是将单关键码按基数分成 多关键码 进行排序的方法。
6.1 多关键码排序
扑克牌中 52 张牌,可按花色和面值分成两个字段,其大小关系为:
花色: 梅花 < 方块 < 红心 < 黑心
面值: 2 < 3 < 4 < 5 < 6 < 7 < 8 < 9 < 10 < J < Q < K < A
若对扑克牌按花色、面值进行升序排序,得到如下序列:
梅花 2,3,...,A ,方块 2,3,...,A ,红心 2,3,...,A ,黑心 2,3,...,A
即两张牌,若花色不同,不论面值怎样,花色低的那张牌小于花色高的,只有在同花色情况下,大小关系才由面值的大小确定。这就是多关键码排序。
为得到排序结果,我们讨论两种排序方法。
方法 1 :先对花色排序,将其分为 4 个组,即梅花组、方块组、红心组、黑心组。再对每个组分别按面值进行排序,最后,将 4 个组连接起来即可。
方法 2 :先按 13 个面值给出 13 个编号组 (2 号, 3 号, ... A ) ,将牌按面值依次放入对应的编号组,分成 13 堆。再按花色给出 4 个编号组 ( 梅花、方块、红心、黑心 ) ,将 2 号组中牌取出分别放入对应花色组,再将 3 号组中牌取出分别放入对应花色组, …… ,这样, 4 个花色组中均按面值有序,然后,将 4 个花色组依次连接起来即可。
n 个元素的待排序列包含 d 个关键码 {k1 k2 kd} ,则称序列对关键码 {k1 k2 kd} 有序是指:对于序列中任两个记录 r[i] r[j](1≤i≤j≤n) 都满足下列有序关系:
其中 k1 称为最主位关键码, kd 称为最次位关键码。
多关键码排序按照从最主位关键码到最次位关键码或从最次位到最主位关键码的顺序
逐次排序,分两种方法:
最高位优先 (Most Significant Digit first) 法,简称 MSD 法:先按 k1 排序分组,同一组中记录,关键码 k1 相等,再对各组按 k2 排序分成子组,之后,对后面的关键码继续这样的排序分组,直到按最次位关键码 kd 对各子组排序后。再将各组连接起来,便得到一个有序序列。扑克牌按花色、面值排序中介绍的方法一即是 MSD 法。
最低位优先 (Least Significant Digit first) 法,简称 LSD 法:先从 kd 开始排序,再对 kd-1 进行排序,依次重复,直到对 k1 排序后便得到一个有序序列。扑克牌按花色、面值排序中介绍的方法二即是 LSD 法。
6.2 链式基数排序
将关键码拆分为若干项,每项作为一个 关键码 ,则对单关键码的排序可按多关键码排序方法进行。比如,关键码为 4 位的整数,可以每位对应一项,拆分成 4 项;又如,关键码由 5 个字符组成的字符串,可以每个字符作为一个关键码。由于这样拆分后,每个关键码都在相同的范围内 ( 对数字是 0 9 ,字符是 'a' 'z') ,称这样的关键码可能出现的符号个数为 ,记作 RADIX 。上述取数字为关键码的 10 ;取字符为关键码的 26 。基于这一特性,用 LSD 法排序较为方便。
基数排序:从最低位关键码起,按关键码的不同值将序列中的记录 分配 RADIX 个队列中,然后再 收集 之。如此重复 d 次即可。链式基数排序是用 RADIX 个链队列作为分配队列,关键码相同的记录存入同一个链队列中,收集则是将各链队列按关键码大小顺序链接起来。
【例 8 】以静态链表存储待排记录,头结点指向第一个记录。链式基数排序过程如下图。
(a) :初始记录的静态链表。
(b): 第一趟按个位数分配,修改结点指针域,将链表中的记录分配到相应链队列中。
(c): 第一趟收集:将各队列链接起来,形成单链表。

(d): 第二趟按十位数分配,修改结点指针域,将链表中的记录分配到相应链队列中。
(e): 第二趟收集:将各队列链接起来,形成单链表。
(f): 第三趟按百位数分配,修改结点指针域,将链表中的记录分配到相应链队列中。
(g): 第三趟收集:将各队列链接起来,形成单链表。此时 , 序列已有序。
10
【算法 14
#define MAX_KEY_NUM 8 /*
关键码项数最大值 */
#define RADIX 10 /*
关键码基数,此时为十进制整数的基数 */
#define MAX_SPACE 1000 /*
分配的最大可利用存储空间 */
typedef struct{
KeyType keys[MAX_KEY_NUM]
/* 关键码字段 */
InfoType otheritems
/* 其它字段 */
int next
/* 指针字段 */
}NodeType
/* 表结点类型 */
typedef struct{
NodeType r[MAX_SPACE]
/* 静态链表, r[0] 为头结点 */
int keynum
/* 关键码个数 */
int length
/* 当前表中记录数 */
}L_TBL
/* 链表类型 */
typedef int ArrayPtr[radix]
/* 数组指针,分别指向各队列 */
void Distribute(NodeType *s int i ArrayPtr *f ArrayPtr *e)
{ /*
静态链表 ltbl r 域中记录已按 (kye[0] keys[1] keys[i-1]) 有序 )*/
/*
本算法按第 i 个关键码 keys[i] 建立 RADIX 个子表,使同一子表中的记录的 keys[i] 相同 */
/*f[0…RADIX-1]
e[0…RADIX-1] 分别指向各子表的第一个和最后一个记录 */
for(j=0
j<RADIX j++) f[j]=0 /* 各子表初始化为空表 */
for(p=r[0].next
p p=r[p].next)
{ j=ord(r[p].keys[i])
/*ord 将记录中第 i 个关键码映射到 [0…RADIX-1]*/
if(!f[j]) f[j]=p

else r[e[j]].next=p

e[j]=p
/* p 所指的结点插入到第 j 个子表中 */
}
}
void Collect(NodeType *r int i ArrayPtr f ArrayPtr e)
{/*
本算法按 keys[i] 自小到大地将 f[0…RADIX-1] 所指各子表依次链接成一个链表 *e[0…RADIX-1] 为各子表的尾指针 */
for(j=0
!f[j] j=succ(j)) /* 找第一个非空子表, succ 为求后继函数 */
r[0].next=f[j]
t=e[j] /*r[0].next 指向第一个非空子表中第一个结点 */
while(j<RADIX)
{ for(j=succ(j)
j<RADIX-1&&!f[j] j=succ(j)) /* 找下一个非空子表 */
if(f[j]) {r[t].next=f[j]
t=e[j] } /* 链接两个非空子表 */
}
r[t].next=0
/*t 指向最后一个非空子表中的最后一个结点 */
}
void RadixSort(L_TBL *ltbl)
{ /*
ltbl 作基数排序,使其成为按关键码升序的静态链表, ltbl->r[0] 为头结点 */
for(i=0
i<ltbl->length i++) ltbl->r[i].next=i+1
ltbl->r[ltbl->length].next=0
/* ltbl 改为静态链表 */
for(i=0
i<ltbl->keynum i++) /* 按最低位优先依次对各关键码进行分配和收集 */
{ Distribute(ltbl->r
i f e) /* i 趟分配 */
Collect(ltbl->r
i f e) /* i 趟收集 */
}
}
【效率分析】
时间效率:设待排序列为 n 个记录, d 个关键码,关键码的取值范围为 radix ,则进行链式基数排序的时间复杂度为 O(d(n+radix)) ,其中,一趟分配时间复杂度为 O(n) ,一趟收集时间复杂度为 O(radix) ,共进行 d 趟分配和收集。
空间效率:需要 2*radix 个指向队列的辅助空间,以及用于静态链表的 n 个指针。
7 外排序
7.1
外部排序的方法
   外部排序基本上由两个相互独立的阶段组成。首先,按可用内存大小,将外存上含 n 个记录的文件分成若干长度为 k 的子文件或段 (segment) ,依次读入内存并利用有效的内部排序方法对它们进行排序,并将排序后得到的有序子文件重新写入外存。通常称这些有序子文件为归并段或顺串;然后,对这些归并段进行逐趟归并,使归并段 ( 有序子文件 ) 逐渐由小到大,直至得到整个有序文件为止。
显然,第一阶段的工作已经讨论过。以下主要讨论第二阶段即归并的过程。先从一个例子来看外排序中的归并是如何进行的?
假设有一个含 10000 个记录的
文件,首先通过 10 次内部排序得到
10
个初始归并段 R1 R10 ,其中每
一段都含 1000 个记录。然后对它们
作如图 11 所示的两两归并,直至
得到一个有序文件为止。

从图 11 可见,由 10 个初始归并段到一个有序文件,共进行了四趟归并,每一趟
将两个有序段归并成一个有序段的过程,若在内存中进行,则很简单,前面讨论的 2- 路归并排序中的 Merge 函数便可实现此归并。但是,在外部排序中实现两两归并时,不仅要调用 Merge 函数,而且要进行外存的读 / 写,这是由于我们不可能将两个有序段及归并结果同时放在内存中的缘故。对外存上信息的读 / 写是以 物理块 为单位。假设在上例中每个物理块可以容纳 200 个记录,则每一趟归并需进行 50 50 ,四趟归并加上内部排序时所需进行的读 / 写,使得在外排序中总共需进行 500 次的读 / 写。
一般情况下,外部排序所需总时间 =
内部排序 ( 产生初始归并段 ) 所需时间 m*tis
+
外存信息读写的时间 d*tio
+
内部归并排序所需时间 s*utmg
其中: tis 是为得到一个初始归并段进行的内部排序所需时间的均值; tio 是进行一次外存读 / 写时间的均值; utmg 是对 u 个记录进行内部归并所需时间; m 为经过内部排序之后得到的初始归并段的个数; s 为归并的趟数; d 为总的读 / 写次数。由此,上例 10000 个记录利用 2- 路归并进行排序所需总的时间为:
10*tis+500*tio+4*10000tmg
其中 tio 取决于所用的外存设备,显然, tio tmg 要大的多。因此,提高排序效率应主要着眼于减少外存信息读写的次数 d
下面来分析 d 归并过程 的关系。若对上例中所得的 10 个初始归并段进行 5- 平衡归并 ( 即每一趟将 5 个或 5 个以下的有序子文件归并成一个有序子文件 ) ,则从下图可见,仅需进行二趟归并,外部排序时总的读 / 写次数便减少至 2×100+100=300 ,比 2- 路归并减少了 200 次的读 / 写。
R1 R2 R3 R4 R5 R6 R7 R8 R9 R10
└─┴─┼─┴─┘ └─┴─┼─┴─┘
R1' R2'
└────┬────┘
有序文件
12
可见,对同一文件而言,进行外部排序时所需读 / 写外存的次数和归并的趟数 s 成正比。而在一般情况下,对 m 个初始归并段进行 k- 路平衡归并时,归并的趟数
可见,若增加 k 或减少 m 便能减少 s 。下面分别就这两个方面讨论之。
7.2
多路平衡归并的实现
从上式可见,增加 k 可以减少 s ,从而减少外存读 / 写的次数。但是,从下面的讨论中又可发现,单纯增加 k 将导致增加内部归并的时间 utmg 。那末,如何解决这个矛盾呢?
先看 2- 路归并。令 u 个记录分布在两个归并段上,按 Merge 函数进行归并。每得到归并后的含 u 个记录的归并段需进行 u-1 次比较。
再看 k- 路归并。令 u 个记录分布在 k 个归并段上,显然,归并后的第一个记录应是 k 个归并段中关键码最小的记录,即应从每个归并段的第一个记录的相互比较中选出最小者,这需要进行 k-1 次比较。同理,每得到归并后的有序段中的一个记录,都要进行 k-1 次比较。显然,为得到含 u 个记录的归并段需进行 (u-1)(k-1) 次比较。由此,对 n 个记录的文件进行外部排序时,在内部归并过程中进行的总的比较次数为 s(k-1)(n-1) 。假设所得初始归并段为 m 个,则可得内部归并过程中进行比较的总的次数为

k
而减少外存信息读写时间所得效益,这是我们所不希望的。然而,若在进行 k- 路归并时利用 败者树 ”(Tree of Loser) ,则可使在 k 个记录中选出关键码最小的记录时仅需进
它不再随 k 的增长而增长。
何谓 败者树 ?它是树形选择排序的一种变型。相对地,我们可称图 10.5 和图 10.6 中二叉树为 胜者树 ,因为每个非终端结点均表示其左、右子女结点中 胜者 。反之,若在双亲结点中记下刚进行完的这场比赛中的败者,而让胜者去参加更高一层的比赛,便可得到一棵 败者树
【例 9
(a) (b)
13 实现 5- 路归并的败者树
13(a) 即为一棵实现 5- 路归并的败者树 ls[0…4] ,图中方形结点表示叶子结点 ( 也可看成是外结点 ) ,分别为 5 个归并段中当前参加归并的待选择记录的关键码;败者树中根结点 ls[1] 的双亲结点 ls[0] 冠军 ,在此指示各归并段中的最小关键码记录为第三段中的记录;结点 ls[3] 指示 b1 b2 两个叶子结点中的败者即是 b2 ,而胜者 b1 b3(b3 是叶子结点 b3 b4 b0 经过两场比赛后选出的获胜者 ) 进行比较,结点 ls[1] 则指示它们中的败者为 b1 。在选得最小关键码的记录之后,只要修改叶子结点 b3 中的值,使其为同一归并段中的下一个记录的关键码,然后从该结点向上和双亲结点所指的关键码进行比较,败者留在该双亲,胜者继续向上直至树根的双亲。如图 10.13(b) 所示。当第 3 个归并段中第 2 个记录参加归并时,选得最小关键码记录为第一个归并段中的记录。为了防止在归并过程中某个归并段变为空,可以在每个归并段中附加一个关键码为最大的记录。当选出的 冠军 记录的关键码为最大值时,表明此次归并已完成。由于实现 k- 路归并的败者树
的初始化也容易实现,只要先令所有的非终端结点指向一个含最小关键码的叶子结点,然后从各叶子结点出发调整非终端结点为新的败者即可。
下面程序中简单描述了利用败者树进行 k- 路归并的过程,为了突出如何利用败者树进行归并,避开了外存信息存取的细节,可以认为归并段已存在。
【算法 15
typedef int LoserTree[k]; /*
败者树是完全二叉树且不含叶子,可采用顺序存储结构 */
typedef struct{
KeyType key;
}ExNode,External[k]; /*
外结点,只存放待归并记录的关键码 */
void K_Merge(LoserTree *ls,External *b) /*k- 路归并处理程序 */
{ /*
利用败者树 ls 将编号从 0 k-1 k 个输入归并段中的记录归并到输出归并段 */
/*b[0]
b[k-1] 为败者树上的 k 个叶子结点,分别存放 k 个输入归并段中当前记录的关键码 */
for(i=0;i<k;i++) input(b[i].key); /*
分别从 k 个输入归并段读入该段当前第一个记录的 */
/*
关键码到外结点 */
CreateLoserTree(ls); /*
建败者树 ls ,选得最小关键码为 b[0].key*/
while(b[ls[0]].key!=MAXKEY)
{ q=ls[0]; /*q
指示当前最小关键码所在归并段 */
output(q); /*
将编号为 q 的归并段中当前 ( 关键码为 b[q].key 的记录写至输出归并段 )*/
input(b[q].key); /*
从编号为 q 的输入归并段中读入下一个记录的关键码 */
Adjust(ls,q); /*
调整败者树,选择新的最小关键码 */
}
output(ls[0]); /*
将含最大关键码 MAXKEY 的记录写至输出归并段 */
}

void Adjust(LoserTree *ls,int s) /*
选得最小关键码记录后,从叶到根调整败者树,选下一个最小关键码 */
{ /*
沿从叶子结点 b[s] 到根结点 ls[0] 的路径调整败者树 */
t=(s+k)/2; /*ls[t]
b[s] 的双亲结点 */
while(t>0)
{ if(b[s].key>b[ls[t]].key) s<-->ls[t]; /*s
指示新的胜者 */
t=t/2;
}
ls[0]=s;
}
void CreateLoserTree(LoserTree *ls) /* 建立败者树 */
{ /*
已知 b[0] b[k-1] 为完全二叉树 ls 的叶子结点存有 k 个关键码,沿从叶子到根的 k 条路径 */
/*
ls 调整为败者树 */
b[k].key=MINKEY; /*
MINKEY 为关键码可能的最小值 */
for(i=0;i<k;i++) ls[i]=k; /*
设置 ls 败者 的初值 */
for(i=k-1;k>0;i--) Adjust(ls,i); /*
依次从 b[k-1],b[k-2],…,b[0] 出发调整败者 */
}
最后要提及一点, k 值的选择并非越大越好,如何选择合适的 k 是一个需要综合考虑的问题。
 
 
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值