写在前面:
- 本系列笔记主要以《数据结构(C语言版)》为参考(本章部分图片来源于王道),结合下方视频教程对数据结构的相关知识点进行梳理。所有代码块使用的都是C语言,如有错误欢迎指出。
- 视频链接:第01周a--前言_哔哩哔哩_bilibili
- 基数排序部分的代码参考了一位小伙伴分享的代码,特此说明一下,其它C代码均由笔者根据书上的类C代码进行编写。
五、归并排序
(1)归并排序就是将两个或两个以上的有序表合并成一个有序表的过程。将两个有序表合并成一个有序表的过程称为2-路归并,2-路归并最为简单和常用。下面以2-路归并为例,介绍归并排序算法。
(2)归并排序算法的思想是:假设初始序列含有n个记录,则可将其看成n个有序的子序列,每个子序列的长度为1,然后两两归并,得到个长度为2或1的有序子序列,再两两归并,如此重复,直至得到一个长度为n的有序序列为止。
(3)算法实现:
①相邻两个有序子序列归并:
cpp
void Merge(RedType R[], RedType T[], int low, int mid, int high)
{
int i = low;
int j = mid + 1;
int k = low;
while (i <= mid && j <= high) //将R中的记录由小到大地并入T中
{
if (R[i].key <= R[j].key)
T[k++] = R[i++];
else
T[k++] = R[j++];
}
while (i <= mid)
T[k++] = R[i++]; //把R中剩余的元素复制到T中
while (j <= high)
T[k++] = R[j++]; //把R中剩余的元素复制到T中
}
②核心部分:
cpp
void MSort(RedType R[], RedType T[], int low, int high)
{
if (low == high)
T[low] = R[low];
else
{
RedType S[MAXSIZE + 1];
int mid = (low + high) / 2;
MSort(R, S, low, mid);
MSort(R, S, mid + 1, high);
Merge(S, T, low, mid, high);
}
}
void MergeSort(SqList* L) //2-路归并排序
{
MSort(L->r, L->r, 1, L->length);
}
(4)该算法的时间复杂度为O(),空间复杂度为O(n)。
(5)该算法能实现稳定排序,可用于链式结构。
六、基数排序
1、概述
(1)分配类排序不需要比较关键字的大小,它是根据关键字中各位的值,通过对待排序记录进行若干趟"分配"与"收集"来实现排序的,是一种借助于多关键字排序的思想对单关键字进行排序的方法。
(2)假设记录的逻辑关键字由d个关键字组成,每个关键字可能取rd个值,只要从最低数位关键字起,按关键字的不同值将序列中记录分配到rd个队列中后再收集,如此重复d次完成排序,按这种方法实现排序称之为基数排序,其中"基"指的是rd的取值范围。基数排序是典型的分配类排序,又叫桶排序或箱排序。
2、链式基数排序
(1)举例:首先以链表存储n个待排记录,并令表头指针指向第一个记录,如下图所示,然后通过以下3趟分配和收集操作来完成排序。
①第一趟分配对最低数位关键字(个位数)进行,改变记录的指针值将链表中的记录分配至10个链队列中,每个队列中记录的关键字的个位数相等。
②第一趟收集是改变所有非空队列的队尾记录的指针域,令其指向下一个非空队列的队头记录,重新将10个队列中的记录链成一个链表。
③第二趟分配对次低数位关键字(十位数)进行,改变记录的指针值将链表中的记录分配至10个链队列中,每个队列中记录的关键字的十位数相等。
④第二趟收集是改变所有非空队列的队尾记录的指针域,令其指向下一个非空队列的队头记录,重新将10个队列中的记录链成一个链表。
⑤第三趟分配对最高数位关键字(百位数)进行,改变记录的指针值将链表中的记录分配至10个链队列中,每个队列中记录的关键字的百位数相等。
⑥第三趟收集是改变所有非空队列的队尾记录的指针域,令其指向下一个非空队列的队头记录,重新将10个队列中的记录链成一个链表。
(2)基数排序算法的说明如下:
(3)基数排序算法的实现(以对3位数排序为例):
①静态链表的定义:
cpp
typedef int InfoType;
typedef int KeyType;
#define MAXBIT 3 //排序的关键字为3位
#define RADIX 10 //对10进制数进行排序
#define MAX_SPACE 100 //最多可以有99个待排序元素,因为有一个头结点
struct SLCell //元素类型
{
KeyType keys[MAXBIT]; //关键字,存储个位、十位、百位
InfoType other; //其它信息
int next; //存放下一个元素在数组中的位置
};
struct SLList //静态链表类型
{
SLCell r[MAX_SPACE]; //r[0]不存放数据,类似于链表的头指针
int bitnumber; //当前的关键字个数,表示此静态链表对n位数排序
int length; //链表当前长度
};
typedef int RadixArr[RADIX]; //用于创建first, end数组
②分配函数:
cpp
void Distrubute(SLCell *r, int i, RadixArr first, RadixArr end) //分配
{
//r表示SLCell数组的首地址,i=0、i=1、i=2 分别表示对百位、十位、个位进行分配
//first数组存放首个被分配的下标,end存放first指向的最后元素
memset(first, 0, sizeof(int) * RADIX);
memset(end, 0, sizeof(int) * RADIX);
for (int p = r[0].next; p; p = r[p].next)
{
//因为r[0]为头指针,所以p指向表中第一个元素
int j = r[p].keys[i]; //j为下标,等式右边则表示映射关系
if (!first[j]) //如果first[j]==0,说明first指向任何元素,直接把p赋给first[j]
first[j] = p;
else // first[j]已经有指向,那么需要找到end[j],并把它们连起来
r[end[j]].next = p;
end[j] = p; //因为p指向新加入的元素,所以最后一个元素变成p
}
}
③收集函数:
cpp
void Collect(SLCell *r, int i, RadixArr first, RadixArr end) //收集
{
//此时分配已经完成,需要做的是按顺序把分配的元素连起来,即收集
int j = 0;
while (!first[j]) //寻找第一个非空的first子表
j++;
//此时j指向第一个非空子表
r[0].next = first[j]; //让头指针指向此子表
int tail = end[j]; //tail代表此子表最后元素的下标
for (j = j + 1; j < RADIX; j++) //寻找第2个非空子表,依此类推,直到j>=Radix
{
if (!first[j]) //如果子表为空则跳过
continue;
else //当子表不为空时
{
r[tail].next = first[j]; //让上一个子表的最后一个元素指向first[j]
tail = end[j]; //此时更新尾部下标
}
}
//收集完毕
r[tail].next = 0;
}
④核心部分:
cpp
void RadixSort(SLList *L) //基数排序
{
RadixArr first, end; //创建first,end数组,不需要初始化,因为Distrubute函数会对其进行初始化
for (int i = 0; i < L->length; ++i)
{
L->r[i].next = i + 1; //更新next
}
L->r[L->length].next = 0; //设置结束表示0
for (int i = L->bitnumber - 1; i >= 0; --i) //依次对个位、十位、百位进行分配并收集
{
Distrubute(L->r, i, first, end);
Collect(L->r, i, first, end);
}
}
(3)该算法的时间复杂度为O(d(n+rd)),空间复杂度为O(n+rd)。
(4)该算法能实现稳定排序,可用于链式结构和顺序结构,时间复杂度能达到O(n)。该算法的使用有严格的要求,必须要知道各级关键字的主次关系和各级关键字的取值范围。
七、外部排序
1、概述
如果待排序的记录数目很大,无法一次性调入内存,整个排序过程就必须借用外存分批调入内存才能完成,需要为该过程设计外部排序算法。
2、外部排序的基本方法
首先,按可用内存大小,将外存上含n个记录的文件分成若干长度为l的子文件或段,将其依次读入内存并利用有效的内部排序方法对它们进行排序,并将排序后得到的有序子文件重新写入外存,通常称这些有序子文件为归并段或顺串;然后,对这些归并段进行逐趟归并,使归并段(有序的子文件)逐渐由小至大,直至得到整个有序文件为止。
假设有一个含10000个记录的文件,首先通过10次内部排序得到10个初始归并段R1~R10,其中每一段都含1000个记录,然后对它们进行如下图所示的两两归并,直至得到一个有序文件为止。从下图可见,由10个初始归并段到一个有序文件,共进行了4趟归并,每一趟从m个归并段得到个归并段,这种归并方法称为2-路平衡归并。
k路平衡归并排序:
(1)硬件配置:在内存中分配k个输入缓冲区和1个输出缓冲区。
(2)算法步骤:首先生成m个初始归并段(对L个记录进行内部排序,组成一个有序的初始归并段),然后进行S趟归并(),其中进行k路归并的方法为如下。
①把k个归并段的块读入k个输入缓冲区。
②用"归并排序"的方法从k个归并段中选出几个最小记录暂存到输出缓冲区中。
③当输出缓冲区满时,写出外存。
为了减少归并趟数,可以从两个方面改进,分别是增加归并段的个数k和减少初始归并段的个数m。
3、使用败者树实现k路平衡归并
(1)败者树可视为一棵完全二叉树(多了一个头结点记录"冠军"),k个叶结点分别是当前参加比较的元素,非叶子结点用来记忆左右子树比较的"失败者",而让胜者往上继续进行比较,一直到根结点。
(2)使用多路平衡归并可减少归并趟数,但是用老土方法从k个归并段选出一个最小/最大元素需要对比关键字k-1次,而构造败者树可以使关键字对比次数减少到。
4、置换-选择排序
(1)置换-选择排序的特点是在整个排序(得到所有初始归并段)的过程中,选择最小(或最大)关键字和输入、输出交叉或平行进行。使用置换-选择排序,可以让每个初始归并段的长度超越内存工作区大小的限制。
(2)设初始待排文件为FI,初始归并段输出文件为FO,内存工作区为WA、FO和WA的初始状态为空,WA可容纳w个记录。置换-选择算法的步骤如下:
①从FI输入w个记录到工作区WA。
②从WA中选出其中关键字取最小值的记录,记为MINIMAX记录。
③将MINIMAX记录输出到FO中去。
④若FI不空,则从FI输入下一个记录到WA中。
⑤从WA中所有关键字⽐MINIMAX记录的关键字大的记录中选出最⼩关键字记录,作为新的MINIMAX记录。
⑥重复步骤3~步骤5,直至在WA中选不出新的MINIMAX记录为止,由此得到一个初始归并段,输出一个归并段的结束标志到FO中去。
⑦重复步骤2~步骤6,直至WA为空,由此得到全部初始归并段。
5、最佳归并树
(1)假设由置换-选择得到9个初始归并段,其长度(记录数)依次为9、30、12、18、3、17、2、6、24。现对其进行3-路平衡归并,其归并树(表示归并过程的图)如下图所示,图中每个圆圈表示一个初始归并段,圆圈中数字表示归并段的长度。假设每个记录占一个物理块,则两趟归并所需对外存进行读/写的次数为(9+30+12+18+3+17+2+6+24) * 2*2 = 484。
(2)若对长度不等的m个初始归并段,构造一棵哈夫曼树作为归并树,便可使在进行外部归并时所需对外存进行读/写的次数达到最少。例如,对上述9个初始归并段可构造一棵下图所示的归并树,按此树进行归并,仅需对外存进行((2+3+6)*3 + (9+12+17+24+18)*2 + 30*1)*2=446次读/写,这棵归并树便称作最佳归并树。
(3)假若只有8个初始归并段,例如在前面例子中少了一个长度为30的归并段:
如果在设计归并方案时,缺额的归并段留在最后,即除了最后一次进行2-路归并外,其它各次归并都是3-路归并,容易看出此归并方案的外存读/写次数为386,显然这不是最佳方案。
正确的做法是,当初始归并段的数目不足时,需附加长度为0的"虚段",按照哈夫曼树构造的原则,权为0的叶子应离树根最远,因此,这个只有8个初始归并段的归并树应如下图所示。
添加虚段数目的判断:
①若(初始归并段数量 - 1)% (k-1)= 0,说明刚好可以构成严格k叉树,此时不需要添加虚段。
②若(初始归并段数量 - 1)% (k-1)= u ≠ 0,则需要补充(k-1) - u个虚段。
(4)k叉的最佳归并树一定是一棵严格的k叉树,即树中只包含度为k、度为0的结点。设度为k的结点有个,度为0的结点有个 ,归并树总结点数为n ,则有:
八、各种内部排序方法性能的比较
九、算法设计举例
1、例1
(1)问题描述:试以单链表为存储结构,实现简单选择排序算法。
(2)代码:
cpp
void T1(LinkList &L)
{
for (LinkList p = (*L).next; p->next; p = p->next)
{
LinkList q = p;
LinkList r;
for (r = p->next; r; r = r->next)
{
if (r->key < q->key)
q = r;
}
if (q != p)
{
ElemType tmp = q->key;
q->key = p->key;
p->key = tmp;
}
}
}
2、例2
(1)问题描述:有n个记录存储在带头结点的双向链表中,利用双向冒泡排序法对其按升序进行排序(双向冒泡排序即相邻两趟排序向相反方向冒泡)。
(2)代码:
cpp
void T2(DuLinkList &L)
{
int exchange = 1; //是否发生交换的标记
DuLinkList head = L; //双向链表头,向下冒泡的开始结点
DuLinkList tail = NULL; //双向链表尾,向上冒泡的开始结点
while (exchange)
{
if (head->next == tail)
break;
DuLinkList p = head->next;
exchange = 0;
while (p->next != tail)
{
if (p->key > p->next->key)
{
DuLinkList tmp = p->next;
exchange = 1;
p->next = tmp->next;
if (tmp->next != NULL)
tmp->next->prior = p;
tmp->next = p;
p->prior->next = tmp;
tmp->prior = p->prior;
p->prior = tmp;
}
else
p = p->next;
}
tail = p;
p = tail->prior;
while (exchange&&p->prior != head)
{
if (p->key < p->prior->key)
{
DuLinkList tmp = p->prior;
exchange = 1;
p->prior = tmp->prior;
tmp->prior->next = p;
tmp->prior = p;
p->next->prior = tmp;
tmp->next = p->next;
p->next = tmp;
}
else
p = p->prior;
}
head = p;
}
}
3、例3
(1)问题描述:设有顺序放置的n个桶,每个桶中装有一粒砾石,每粒砾石的颜色是红、白、蓝之一,要求重新安排这些砾石,使得所有红色砾石在前,所有白色砾石居中,所有蓝色砾石在后,重新安排时对每粒砾石的颜色只能看一次,并且只允许用交换操作来调整砾石的位置
(2)代码:
cpp
typedef struct
{
color key;
}ElemType_color;
typedef struct SqList_T3
{
ElemType_color* elem; //存储空间的基地址
int length; //当前长度
}SqList_T3;
void T3(SqList_T3 &L)
{
int right = L.length;
int left = 1;
int i = 1;
while (i <= L.length)
{
if (L.elem[i].key == red)
{
color tmp = L.elem[i].key;
L.elem[i].key = L.elem[left].key;
L.elem[left].key = tmp;
left++;
i++;
}
else if (L.elem[i].key == white)
{
i++;
}
else if (L.elem[i].key == blue)
{
color tmp = L.elem[i].key;
L.elem[i].key = L.elem[right].key;
L.elem[right].key = tmp;
right--;
}
}
}