第十章 排序
10.1 概述 10.2 插入排序 10.3 快速排序 10.4 选择排序 10.5 归并排序 10.6 基数排序 10.7 各种排序方法的综合比较 10.8 外部排序
10.1 概 述 一、排序的定义 二、内部排序和外部排序 三、内部排序方法的分类
一、什么是排序? 排序是计算机内经常进行的一种操作,其目的是将一组“无序”的记录序列调整为“有序”的记录序列。 例如:将下列关键字序列 52, 49, 80, 36, 14, 58, 61, 23, 97, 75 调整为 14, 23, 36, 49, 52, 58, 61 ,75, 80, 97
一般情况下, 假设含n个记录的序列为{ R1, R2, …, Rn } 其相应的关键字序列为 { K1, K2, …,Kn } 这些关键字相互之间可以进行比较,即在 它们之间存在着这样一个关系 : Kp1≤Kp2≤…≤Kpn 按此固有关系将上式记录序列重新排列为 { Rp1, Rp2, …,Rpn } 的操作称作排序。
二、内部排序和外部排序 反之,若参加排序的记录数量很大, 若整个排序过程不需要访问外存便能完成,则称此类排序问题为内部排序; 若整个排序过程不需要访问外存便能完成,则称此类排序问题为内部排序; 反之,若参加排序的记录数量很大, 整个序列的排序过程不可能在内存中 完成,则称此类排序问题为外部排序。
三、内部排序的方法 内部排序的过程是一个逐步扩大 记录的有序序列长度的过程。 有序序列区 无 序 序 列 区 经过一趟排序 有序序列区 内部排序的过程是一个逐步扩大 记录的有序序列长度的过程。 有序序列区 无 序 序 列 区 经过一趟排序 有序序列区 无 序 序 列 区
基于不同的“扩大” 有序序列长度的方法,内部排序方法大致可分下列几种类型: 插入类 交换类 选择类 归并类 其它方法
待排记录的数据类型定义如下: #define MAXSIZE 1000 // 待排顺序表最大长度 typedef int KeyType; // 关键字类型为整数类型 typedef struct { KeyType key; // 关键字项 InfoType otherinfo; // 其它数据项 } RcdType; // 记录类型 typedef struct { RcdType r[MAXSIZE+1]; // r[0]闲置 int length; // 顺序表长度 } SqList; // 顺序表类型
1. 插入类 将无序子序列中的一个或几个记录“插入”到有序序列中,从而增加记录的有序子序列的长度。
2. 交换类 通过“交换”无序序列中的记录从而得到其中关键字最小或最大的记录,并将它加入到有序子序列中,以此方法增加记录的有序子序列的长度。
3. 选择类 从记录的无序子序列中“选择”关键字最小或最大的记录,并将它加入到有序子序列中,以此方法增加记录的有序子序列的长度。
4. 归并类 通过“归并”两个或两个以上的记录有序子序列,逐步增加记录有序序列的长度。 5. 其它方法
10. 2 插 入 排 序
一趟直接插入排序的基本思想: 有序序列R[1..i-1] 无序序列 R[i..n] R[i] 有序序列R[1..i]
1.在R[1..i-1]中查找R[i]的插入位置, 实现“一趟插入排序”可分三步进行: 1.在R[1..i-1]中查找R[i]的插入位置, R[1..j].key R[i].key < R[j+1..i-1].key; 2.将R[j+1..i-1]中的所有记录均后移 一个位置; 3.将R[i] 插入(复制)到R[j+1]的位置上。
直接插入排序(基于顺序查找) 折半插入排序(基于折半查找) 表插入排序(基于链表存储) 希尔排序(基于逐趟缩小增量) 不同的具体实现方法导致不同的算法描述 直接插入排序(基于顺序查找) 折半插入排序(基于折半查找) 表插入排序(基于链表存储) 希尔排序(基于逐趟缩小增量)
一、直接插入排序 利用 “顺序查找”实现 “在R[1..i-1]中查找R[i]的插入位置” 算法的实现要点:
从R[i-1]起向前进行顺序查找, 监视哨设置在R[0]; j j=i-1 插入位置 R[0] = R[i]; // 设置“哨兵” for (j=i-1; R[0].key<R[j].key; --j); // 从后往前找 循环结束表明R[i]的插入位置为 j +1
对于在查找过程中找到的那些关键字不小于R[i].key的记录,并在查找的同时实现记录向后移动; for (j=i-1; R[0].key<R[j].key; --j); R[j+1] = R[j] R[0] R[i] j j= i-1 插入位置 上述循环结束后可以直接进行“插入”
令 i = 2,3,…, n, 实现整个序列的排序。 for ( i=2; i<=n; ++i ) if (R[i].key<R[i-1].key) { 在 R[1..i-1]中查找R[i]的插入位置; 插入R[i] ; }
for ( i=2; i<=L.length; ++i ) void InsertionSort ( SqList &L ) { // 对顺序表 L 作直接插入排序。 for ( i=2; i<=L.length; ++i ) if (L.r[i].key < L.r[i-1].key) { } } // InsertSort L.r[0] = L.r[i]; // 复制为监视哨 for ( j=i-1; L.r[0].key < L.r[j].key; -- j ) L.r[j+1] = L.r[j]; // 记录后移 L.r[j+1] = L.r[0]; // 插入到正确位置
内部排序的时间分析: 实现内部排序的基本操作有两个: (1)“比较”序列中两个关键字的 大小; (2)“移动”记录。
对于直接插入排序: “比较”的次数: “移动”的次数: “比较”的次数: “移动”的次数: 最好的情况(关键字在记录序列中顺序有序): 最坏的情况(关键字在记录序列中逆序有序): “比较”的次数: “移动”的次数:
二、折半插入排序 因为 R[1..i-1] 是一个按关键字有序的有序序列,则可以利用折半查找实现“在R[1..i-1]中查找R[i]的插入位置”,如此实现的插入排序为折半插入排序。
void BiInsertionSort ( SqList &L ) { } // BInsertSort for ( i=2; i<=L.length; ++i ) { } // for L.r[0] = L.r[i]; // 将 L.r[i] 暂存到 L.r[0] 在 L.r[1..i-1]中折半查找插入位置; for ( j=i-1; j>=high+1; --j ) L.r[j+1] = L.r[j]; // 记录后移 L.r[high+1] = L.r[0]; // 插入
low = 1; high = i-1; while (low<=high) { } m = (low+high)/2; // 折半 if (L.r[0].key < L.r[m].key) high = m-1; // 插入点在低半区 else low = m+1; // 插入点在高半区
插入 位置 例如: i L.r 14 36 49 52 80 58 61 23 97 75 low high low low high m m 插入 位置 m 再如: i L.r 14 36 49 52 58 61 80 23 97 75 low high low high high m m m
三、表插入排序 为了减少在排序过程中进行的 “移动”记录的操作,必须改变排序过 程中采用的存储结构。利用静态链表 进行排序,并在排序完成之后,一次 性地调整各个记录相互之间的位置, 即将每个记录都调整到它们所应该在 的位置上。
如何在排序之后调整记录序列? 算法中使用了三个指针: 其中:p指示第i个记录的当前位置 i指示第i个记录应在的位置 q指示第i+1个记录的当前位置
void Arrange ( Elem SL[ ], int n ) { p = SL[0].next; // p指示第一个记录的当前位置 for ( i=1; i<n; ++i ) { while (p<i) p = SL[p].next; q = SL[p].next; // q指示尚未调整的表尾 if ( p!= i ) { SL[p]←→SL[i]; // 交换记录,使第i个记录到位 SL[i].next = p; // 指向被移走的记录 } p = q; // p指示尚未调整的表尾, // 为找第i+1个记录作准备 } // Arrange
四、希尔排序(又称缩小增量排序) 基本思想:对待排记录序列先作“宏观”调整,再作“微观”调整。 所谓“宏观”调整,指的是,“跳跃式” 的插入排序。 具体做法为:
其中,d 称为增量,它的值在排序过程中从大到小逐渐缩小,直至最后一趟排序减为 1。 将记录序列分成若干子序列,分别对每个子序列进行插入排序。 例如:将 n 个记录分成 d 个子序列: { R[1],R[1+d],R[1+2d],…,R[1+kd] } { R[2],R[2+d],R[2+2d],…,R[2+kd] } … { R[d],R[2d],R[3d],…,R[kd],R[(k+1)d] } 其中,d 称为增量,它的值在排序过程中从大到小逐渐缩小,直至最后一趟排序减为 1。
例如: 16 25 12 30 47 11 23 36 9 18 31 第一趟希尔排序,设增量 d =5 11 23 12 9 18 16 25 36 30 47 31 第二趟希尔排序,设增量 d = 3 9 18 12 11 23 16 25 31 30 47 36 第三趟希尔排序,设增量 d = 1 9 11 12 16 18 23 25 30 31 36 47
void ShellInsert ( SqList &L, int dk ) { for ( i=dk+1; i<=n; ++i ) if ( L.r[i].key< L.r[i-dk].key) { L.r[0] = L.r[i]; // 暂存在R[0] for (j=i-dk; j>0&&(L.r[0].key<L.r[j].key); j-=dk) L.r[j+dk] = L.r[j]; // 记录后移,查找插入位置 L.r[j+dk] = L.r[0]; // 插入 } // if } // ShellInsert
void ShellSort (SqList &L, int dlta[], int t) for (k=0; k<t; ++t) ShellInsert(L, dlta[k]); //一趟增量为dlta[k]的插入排序 } // ShellSort
10.3 快 速 排 序 一、起泡排序 二、一趟快速排序 三、快速排序 四、快速排序的时间分析
假设在排序过程中,记录序列R[1..n]的状态为: 一、起泡排序 假设在排序过程中,记录序列R[1..n]的状态为: n-i+1 无序序列R[1..n-i+1] 有序序列 R[n-i+2..n] 比较相邻记录,将关键字最大的记录交换到 n-i+1 的位置上 第 i 趟起泡排序 无序序列R[1..n-i] 有序序列 R[n-i+1..n]
void BubbleSort(Elem R[ ], int n) { while (i >1) { } // while } // BubbleSort i = n; if (R[j+1].key < R[j].key) { Swap(R[j], R[j+1]); lastExchangeIndex = j; //记下进行交换的记录位置 } //if lastExchangeIndex = 1; for (j = 1; j < i; j++) i = lastExchangeIndex; // 本趟进行过交换的 // 最后一个记录的位置
2. 一般情况下,每经过一趟“起泡”,“i 减一”,但并不是每趟都如此。 注意: 1. 起泡排序的结束条件为, 最后一趟没有进行“交换记录”。 2. 一般情况下,每经过一趟“起泡”,“i 减一”,但并不是每趟都如此。 例如: 2 3 5 1 3 5 1 5 7 9 8 9 i=2 i=6 i=7 for (j = 1; j < i; j++) if (R[j+1].key < R[j].key) …
n-1 时间分析: “比较”的次数: “移动”的次数: “比较”的次数: “移动”的次数: 最好的情况(关键字在记录序列中顺序有序): 只需进行一趟起泡 “比较”的次数: “移动”的次数: n-1 最坏的情况(关键字在记录序列中逆序有序): 需进行n-1趟起泡 “比较”的次数: “移动”的次数:
二、一趟快速排序(一次划分) 目标:找一个记录,以它的关键字作为“枢轴”,凡其关键字小于枢轴的记录均移动至该记录之前,反之,凡关键字大于枢轴的记录均移动至该记录之后。 致使一趟排序之后,记录的无序序列R[s..t]将分割成两部分:R[s..i-1]和R[i+1..t],且 R[j].key≤ R[i].key ≤ R[j].key (s≤j≤i-1) 枢轴 (i+1≤j≤t)。
将 R[high].key 和 枢轴的关键字进行比较,要求R[high].key ≥ 枢轴的关键字 例如 R[0] s 52 t 23 14 52 80 low low low low low high high high high high high 设 R[s]=52 为枢轴 将 R[high].key 和 枢轴的关键字进行比较,要求R[high].key ≥ 枢轴的关键字 将 R[low].key 和 枢轴的关键字进行比较,要求R[low].key ≤ 枢轴的关键字
可见,经过“一次划分” ,将关键字序列 52, 49, 80, 36, 14, 58, 61, 97, 23, 75 调整为: 23, 49, 14, 36, (52) 58, 61, 97, 80, 75 在调整过程中,设立了两个指针: low 和high,它们的初值分别为: s 和 t, 之后逐渐减小 high,增加 low,并保证 R[high].key≥52,和 R[low].key≤52,否则进行记录的“交换”。
int Partition (RedType& R[], int low, int high) { pivotkey = R[low].key; while (low<high) { while (low<high && R[high].key>=pivotkey) --high; R[low]←→R[high]; while (low<high && R[low].key<=pivotkey) ++low; } return low; // 返回枢轴所在位置 } // Partition
int Partition (RedType R[], int low, int high) { R[0] = R[low]; pivotkey = R[low].key; // 枢轴 while (low<high) { } while(low<high&& R[high].key>=pivotkey) -- high; // 从右向左搜索 R[low] = R[high]; while (low<high && R[low].key<=pivotkey) ++ low; // 从左向右搜索 R[high] = R[low]; R[low] = R[0]; return low;
三、快速排序 首先对无序的记录序列进行“一次划分”,之后分别对分割所得两个子序列“递归”进行快速排序。 分别进行快速排序 一次划分 无 序 的 记 录 序 列 一次划分 无序记录子序列(1) 枢轴 无序子序列(2) 分别进行快速排序
void QSort (RedType & R[], int s, int t ) { // 对记录序列R[s..t]进行快速排序 if (s < t-1) { // 长度大于1 } } // QSort pivotloc = Partition(R, s, t); // 对 R[s..t] 进行一次划分 QSort(R, s, pivotloc-1); // 对低子序列递归排序,pivotloc是枢轴位置 QSort(R, pivotloc+1, t); // 对高子序列递归排序
void QuickSort( SqList & L) { // 对顺序表进行快速排序 QSort(L.r, 1, L.length); 第一次调用函数 Qsort 时,待排序记录序列的上、下界分别为 1 和 L.length。 void QuickSort( SqList & L) { // 对顺序表进行快速排序 QSort(L.r, 1, L.length); } // QuickSort
四、快速排序的时间分析 假设一次划分所得枢轴位置 i=k,则对n 个记录进行快排所需时间: T(n) = Tpass(n) + T(k-1) + T(n-k) 其中 Tpass(n)为对 n 个记录进行一次划分所需时间。 若待排序列中记录的关键字是随机分布的,则 k 取 1 至 n 中任意一值的可能性相同。
由此可得快速排序所需时间的平均值为: 设 Tavg(1)≤b 则可得结果: 结论: 快速排序的时间复杂度为O(nlogn)
若待排记录的初始状态为按关键字有序时,快速排序将蜕化为起泡排序,其时间复杂度为O(n2)。 为避免出现这种情况,需在进行一次划分之前,进行“予处理”,即: 先对 R(s).key, R(t).key 和 R[(s+t)/2].key,进行相互比较,然后取关键字为 “三者之中”的记录为枢轴记录。
10.4 选 择 排 序 简 单 选 择 排 序 堆 排 序
一、简单选择排序 假设排序过程中,待排记录序列的状态为: 有序序列R[1..i-1] 无序序列 R[i..n] 第 i 趟 简单选择排序 从中选出 关键字最小的记录 第 i 趟 简单选择排序 有序序列R[1..i] 无序序列 R[i+1..n]
简单选择排序的算法描述如下: void SelectSort (Elem R[], int n ) { // 对记录序列R[1..n]作简单选择排序。 for (i=1; i<n; ++i) { // 选择第 i 小的记录,并交换到位 } } // SelectSort j = SelectMinKey(R, i); // 在 R[i..n] 中选择关键字最小的记录 if (i!=j) R[i]←→R[j]; // 与第 i 个记录交换
移动记录的次数,最小值为 0, 最大值为3(n-1) 。 时间性能分析 对 n 个记录进行简单选择排序,所需进行的 关键字间的比较次数 总计为: 移动记录的次数,最小值为 0, 最大值为3(n-1) 。
改进:如何降低比较次数? 思想:如何利用第1趟n-1次比较所得的信息,减少以后各趟选择排序中的比较次数。 现实生活中的例子: 体育比赛中的锦标赛
冠军
亚军
季军
树形选择排序(锦标赛排序) 12 27 38 12 27 38 65 12 76 27 48 38 65 97 76 12 ∞ 27 49 缺点: 辅助存储空间较多 与“最大值”进行多余比较
如何继续改进? 1964年 Willioms 提出了另一种形式的选择排序 堆排序 ( Heap Sort )
二、堆排序 堆的定义: 堆是满足下列性质的数列{r1, r2, …,rn}: 或 例如: 是小顶堆 不是堆 (小顶堆) (大顶堆) {12, 36, 27, 65, 40, 34, 98, 81, 73, 55, 49} 是小顶堆 {12, 36, 27, 65, 40, 14, 98, 81, 73, 55, 49} 不是堆
不 ri r2i r2i+1 若将该数列视作完全二叉树, 则 r2i 是 ri 的左孩子; r2i+1 是 ri 的右孩子。 例如: 是堆 12 36 27 65 40 14 34 98 不 是堆 81 73 55 49
堆排序即是利用堆的特性对记录序列进行排序的一种排序方法。 堆排序即是利用堆的特性对记录序列进行排序的一种排序方法。 例如: { 40, 55, 49, 73, 12, 27, 98, 81, 64, 36 } 建大顶堆 { 98, 81, 49, 73, 36, 27, 40, 55, 64, 12 } 交换 98 和 12 { 12, 81, 49, 73, 36, 27, 40, 55, 64, 98 } 经过筛选 重新调整为大顶堆 { 81, 73, 49, 64, 36, 27, 40, 55, 12, 98 }
定义堆类型为: typedef SqList HeapType; // 堆采用顺序表表示之 两个问题: 如何“建堆”? 如何“筛选”?
所谓“筛选”指的是,对一棵左/右子树 均为堆的完全二叉树,“调整”根结点 使整个二叉树也成为一个堆。 筛选 堆 堆
例如: 是大顶堆 但在 98 和 12 进行互换之后,它就不是堆了, 因此,需要对它进行“筛选”。 12 12 81 98 73 81 49 比较 12 81 98 比较 73 81 49 64 73 36 27 40 55 64 12 98 12 98 是大顶堆 但在 98 和 12 进行互换之后,它就不是堆了, 因此,需要对它进行“筛选”。
void HeapAdjust (RcdType &R[], int s, int m) { // 已知 R[s..m]中记录的关键字除 R[s] 之外均 // 满足堆的特征,本函数自上而下调整 R[s] 的 // 关键字,使 R[s..m] 也成为一个大顶堆 } // HeapAdjust rc = R[s]; // 暂存 R[s] for ( j=2*s; j<=m; j*=2 ) { // j 初值指向左孩子 自上而下的筛选过程; } R[s] = rc; // 将调整前的堆顶记录插入到 s 位置
if ( j<m && R[j].key<R[j+1].key ) ++j; // 左/右“子树根”之间先进行相互比较 // 令 j 指示关键字较大记录的位置 if ( rc.key >= R[j].key ) break; // 再作“根”和“子树根”之间的比较, // 若“>=”成立,则说明已找到 rc 的插 // 入位置 s ,不需要继续往下调整 R[s] = R[j]; s = j; // 否则记录上移,尚需继续往下调整
void HeapSort ( HeapType &H ) { // 对顺序表 H 进行堆排序 for ( i=H.length/2; i>0; --i ) HeapAdjust ( H.r, i, H.length ); // 建大顶堆 for ( i=H.length; i>1; --i ) { H.r[1]←→H.r[i]; // 将堆顶记录和当前未经排序子序列 // H.r[1..i]中最后一个记录相互交换 HeapAdjust(H.r, 1, i-1); // 对 H.r[1] 进行筛选 }
现在,左/右子树都已经调整为堆,最后只要调整根结点,使整个二叉树是个“堆”即可。 建堆是一个从下往上进行“筛选”的过程。 例如: 排序之前的关键字序列为 40 98 81 55 98 49 49 73 81 73 36 12 36 27 27 98 40 49 81 55 73 64 64 12 12 36 现在,左/右子树都已经调整为堆,最后只要调整根结点,使整个二叉树是个“堆”即可。
因此,堆排序的时间复杂度为O(nlogn)。 堆排序的时间复杂度分析: 1. 对深度为 k 的堆,“筛选”所需进行的关键字 比较的次数至多为2(k-1); 2. 对 n 个关键字,建成深度为h(=log2n+1)的堆, 所需进行的关键字比较的次数至多 4n; 3. 调整“堆顶” n-1 次,总共进行的关键 字比较的次数不超过 2 (log2(n-1)+ log2(n-2)+ …+log22) < 2n(log2n) 因此,堆排序的时间复杂度为O(nlogn)。
10.5 归 并 排 序 归并排序的过程基于下列基本思想进行: 将两个或两个以上的有序子序列 “归并” 为一个有序序列。
在内部排序中,通常采用的是2-路归并排序。即:将两个位置相邻的记录有序子序列 在内部排序中,通常采用的是2-路归并排序。即:将两个位置相邻的记录有序子序列 有序子序列 R[l..m] 有序子序列 R[m+1..n] 归并为一个记录的有序序列。 有 序 序 列 R[l..n] 这个操作对顺序表而言,是轻而易举的。
… … void Merge (RcdType SR[], RcdType &TR[], int i, int m, int n) { // 将有序的记录序列 SR[i..m] 和 SR[m+1..n] // 归并为有序的记录序列 TR[i..n] } // Merge for (j=m+1, k=i; i<=m && j<=n; ++k) { // 将SR中记录由小到大地并入TR if (SR[i].key<=SR[j].key) TR[k] = SR[i++]; else TR[k] = SR[j++]; } … …
if (i<=m) TR[k..n] = SR[i..m]; // 将剩余的 SR[i..m] 复制到 TR if (j<=n) TR[k..n] = SR[j..n]; // 将剩余的 SR[j..n] 复制到 TR
归并排序的算法 如果记录无序序列 R[s..t] 的两部分 R[s..(s+t)/2] 和 R[(s+t)/2+1..t] 分别按关键字有序, 则利用上述归并算法很容易将它们归并成整个记录序列是一个有序序列。 由此,应该先分别对这两部分进行 2-路归并排序。
例如: 52, 23, 80, 36, 68, 14 (s=1, t=6) [ 52, 23, 80] [36, 68, 14] [ 52, 23][80] [36, 68][14] [ 52] [23] [36][68] [ 23, 52] [36, 68] [ 23, 52, 80] [14, 36, 68] [ 14, 23, 36, 52, 68, 80 ]
… … void Msort ( RcdType SR[], RcdType &TR1[], int s, int t ) { // 将SR[s..t] 归并排序为 TR1[s..t] if (s= =t) TR1[s]=SR[s]; else { } } // Msort … …
m = (s+t)/2; // 将SR[s..t]平分为SR[s..m]和SR[m+1..t] Msort (SR, TR2, s, m); // 递归地将SR[s..m]归并为有序的TR2[s..m] Msort (SR, TR2, m+1, t); //递归地SR[m+1..t]归并为有序的TR2[m+1..t] Merge (TR2, TR1, s, m, t); // 将TR2[s..m]和TR2[m+1..t]归并到TR1[s..t]
void MergeSort (SqList &L) { MSort(L.r, L.r, 1, L.length); } // MergeSort 容易看出,对 n 个记录进行归并排序的时间复杂度为Ο(nlogn)。即: 每一趟归并的时间复杂度为 O(n), 总共需进行 log2n 趟。
10.6 基 数 排 序
基数排序是一种借助“多关键字排序”的思想来实现“单关键字排序”的内部排序算法。 多关键字的排序 链式基数排序
一、多关键字的排序 n 个记录的序列 { R1, R2, …,Rn} 对关键字 (Ki0, Ki1,…,Kid-1) 有序是指: 对于序列中任意两个记录 Ri 和 Rj (1≤i<j≤n) 都满足下列(词典)有序关系: (Ki0, Ki1, …,Kid-1) < (Kj0, Kj1, …,Kjd-1) 其中: K0 被称为 “最主”位关键字 Kd-1 被称为 “最次”位关键字
实现多关键字排序 通常有两种作法: 最高位优先MSD法 最低位优先LSD法
先对K0进行排序,并按 K0 的不同值将记录序列分成若干子序列之后,分别对 K1 进行排序,
先对 Kd-1 进行排序,然后对 Kd-2 进行排序,依次类推,直至对最主位关键字 K0 排序完成为止。 排序过程中不需要根据 “前一个” 关键字的排序结果,将记录序列分割成若干个(“前一个”关键字不同的)子序列。
系别、班号和班内的序列号,其中以系别为最主位关键字。 例如:学生记录含三个关键字: 系别、班号和班内的序列号,其中以系别为最主位关键字。 LSD的排序过程如下: 无序序列 3,2,30 1,2,15 3,1,20 2,3,18 2,1,20 对K2排序 1,2,15 2,3,18 3,1,20 2,1,20 3,2,30 对K1排序 3,1,20 2,1,20 1,2,15 3,2,30 2,3,18 对K0排序 1,2,15 2,1,20 2,3,18 3,1,20 3,2,30
二、链式基数排序 假如多关键字的记录序列中,每个关键字的取值范围相同,则按LSD法进行排序时,可以采用“分配-收集”的方法,其好处是不需要进行关键字间的比较。 对于数字型或字符型的单关键字,可以看成是由多个数位或多个字符构成的多关键字,此时可以采用这种“分配-收集”的办法进行排序,称作基数排序法。
例如:对下列这组关键字 {209, 386, 768, 185, 247, 606, 230, 834, 539 } 首先按其 “个位数” 取值分别为 0, 1, …, 9 “分配” 成 10 组,之后按从 0 至 9 的顺序将 它们 “收集” 在一起; 然后按其 “十位数” 取值分别为 0, 1, …, 9 “分配” 成 10 组,之后再按从 0 至 9 的顺序将它们 “收集” 在一起; 最后按其“百位数”重复一遍上述操作。
2.“分配” 时,按当前“关键字位”所取值,将记录分配到不同的 “链队列” 中,每个队列中记录的 “关键字位” 相同; 在计算机上实现基数排序时,为减少所需辅助存储空间,应采用链表作存储结构,即链式基数排序,具体作法为: 1.待排序记录以指针相链,构成一个链表; 2.“分配” 时,按当前“关键字位”所取值,将记录分配到不同的 “链队列” 中,每个队列中记录的 “关键字位” 相同; 3.“收集”时,按当前关键字位取值从小到大将各队列首尾相链成一个链表; 4.对每个关键字位均重复 2) 和 3) 两步。
例如: 进行第一次分配 f[0] r[0] →230← f[7] r[7] →367 ← →167 →237 f[8] r[8] →138← p→369→367→167→239→237→138→230→139 进行第一次分配 f[0] r[0] →230← f[7] r[7] →367 ← →167 →237 f[8] r[8] →138← f[9] r[9] →369 ← →239 →139 进行第一次收集 p→230 →367→167→237 →138 →368→239→139
进行第二次分配 f[3] r[3] →230 ← →237 →138 →239 →139 f[6] r[6] →367 ← →167 p→230→367→167→237→138→368→239→139 进行第二次分配 f[3] r[3] →230 ← →237 →138 →239 →139 f[6] r[6] →367 ← →167 →368 进行第二次收集 p→230→237→138→239→139 →367→167→368
进行第三次分配 进行第三次收集之后便得到记录的有序序列 f[1] r[1] →138 ← →139 →167 f[2] r[2] p→230→237→138→239→139→367→167→368 进行第三次分配 f[1] r[1] →138 ← →139 →167 f[2] r[2] →230 ← →237 →239 f[3] r[3] →367 ← →368 进行第三次收集之后便得到记录的有序序列 p→138→139→167 →230→237→239 →367→368
提醒注意: 1.“分配”和“收集”的实际操作仅为修改链表中的指针和设置队列的头、尾指针; 2.为查找使用,该链表尚需应用算法Arrange 将它调整为有序表。
基数排序的时间复杂度为O(d(n+rd)) 其中:分配为O(n) 收集为O(rd)(rd为“基”) d为“分配-收集”的趟数
10.7 各种排序方法的综合比较
一、时间性能 1. 平均的时间性能 时间复杂度为 O(nlogn): 快速排序、堆排序和归并排序 时间复杂度为 O(n2): 1. 平均的时间性能 时间复杂度为 O(nlogn): 快速排序、堆排序和归并排序 时间复杂度为 O(n2): 直接插入排序、起泡排序和 简单选择排序 时间复杂度为 O(n): 基数排序
2. 当待排记录序列按关键字顺序有序时 直接插入排序和起泡排序能达到O(n)的时间复杂度, 快速排序的时间性能蜕化为O(n2) 。 3. 简单选择排序、堆排序和归并排序的时间性能不随记录序列中关键字的分布而改变。
2. 快速排序为O(logn),为递归程序执行过程中,栈所需的辅助空间; 二、空间性能 指的是排序过程中所需的辅助空间大小 1. 所有的简单排序方法(包括:直接插入、 起泡和简单选择) 和堆排序的空间复杂度为O(1); 2. 快速排序为O(logn),为递归程序执行过程中,栈所需的辅助空间;
3. 归并排序所需辅助空间最多,其空间复杂度为 O(n); 4. 链式基数排序需附设队列首尾指针,则空间复杂度为 O(rd)。
2. 当对多关键字的记录序列进行LSD方法排序时,必须采用稳定的排序方法。 三、排序方法的稳定性能 1. 稳定的排序方法指的是,对于两个关键字相等的记录,它们在序列中的相对位置,在排序之前和经过排序之后,没有改变。 排序之前 : { · · · · · Ri(K) · · · · · Rj(K) · · · · · } 排序之后 : { · · · · · Ri(K) Rj(K) · · · · ·· · · · · } 2. 当对多关键字的记录序列进行LSD方法排序时,必须采用稳定的排序方法。
例如: 排序前 ( 56, 34, 47, 23, 66, 18, 82, 47 ) 若排序后得到结果 ( 18, 23, 34, 47, 47, 56, 66, 82 ) 则称该排序方法是稳定的; 若排序后得到结果 ( 18, 23, 34, 47, 47, 56, 66, 82 ) 则称该排序方法是不稳定的。
3. 对于不稳定的排序方法,只要能举出一个实例说明即可。 3. 对于不稳定的排序方法,只要能举出一个实例说明即可。 例如 : 对 { 4, 3, 4, 2 } 进行快速排序, 得到 { 2, 3, 4, 4 } 4. 快速排序、堆排序和希尔排序是不稳定的排序方法。
四、关于“排序方法的时间复杂度的下限” 本章讨论的各种排序方法,除基数排序外,其它方法都是基于“比较关键字”进行排序的排序方法。 可以证明, 这类排序法可能达到的最快的时间复杂度为O(nlogn)。 (基数排序不是基于“比较关键字”的排序方法,所以它不受这个限制。)
1.树上的每一次“比较”都是必要的; 2.树上的叶子结点包含所有可能情况。 例如:对三个关键字进行排序的判定树如下: K1<K2 K2<K1<K3 K1<K3 K1<K2<K3 K3<K2<K1 K2<K3<K1 K3<K1<K2 K1<K3<K2 1.树上的每一次“比较”都是必要的; 2.树上的叶子结点包含所有可能情况。
排序方法,可能达到的最快的时间复杂度为 O(nlogn)。 一般情况下,对n个关键字进行排序,可能得到的结果有n! 种,由于含n! 个叶子结点的二叉树的深度不小于log2(n!) +1, 则对 n 个关键字进行排序的比较次数至少是 log2(n!) nlog2n (斯蒂林近似公式)。 所以,基于“比较关键字”进行排序的 排序方法,可能达到的最快的时间复杂度为 O(nlogn)。
10.8 外 部 排 序
一. 问题的提出 1.待排序的记录数量很大,不能一次装入内存,则无法利用前几节 讨论的排序方法 (否则将引起频繁访问内存);
2.对外存中数据的读/写是以“数据块” 为单位进行的; 读/写外存中一个“数据块”的数据所需要的时间为: TI/O = tseek + tla + n twm 其中 tseek 为寻查时间(查找该数据块所在磁道) tla 为等待(延迟)时间 n twm 为传输数据块中n个记录的时间。
二、外部排序的基本过程 由相对独立的两个步骤组成: 1.按可用内存大小,利用内部排序 方法,构造若干( 记录的) 有序子序列,通常称外存中这些记录有序子序列为 “归并段”; 2.通过“归并”,逐步扩大 (记录的)有序子序列的长度,直至外存中整个记录序列按关键字有序为止。
例如:假设有一个含10,000个记录的磁盘 文件,而当前所用的计算机一次只 能对1000个记录进行内部排序,则 首先利用内部排序的方法得到10个 初始归并段,然后进行逐趟归并。 假设进行2路归并(即两两归并),则 第一趟由10个归并段得到5个归并段; 第二趟由 5 个归并段得到3个归并段; 第三趟由 3 个归并段得到2个归并段; 最后一趟归并得到整个记录的有序序列。
分析上述外排过程中访问外存(对外 存进行读/写)的次数: 假设“数据块”的大小为200,即每一次访问外存可以读/写200个记录。 则对于10,000个记录,处理一遍需访问外存100次(读和写各50次)。
由此,对上述例子而言, 1)求得10个初始归并段需访问外存100次; 2)每进行一趟归并需访问外存100次; 3)总计访问外存 100 + 4 100 = 500次。
外排总的时间还应包括内部排序 所需时间和逐趟归并时进行内部归并 的时间,显然,除去内部排序的因素 外,外部排序的时间取决于逐趟归并 所需进行的“趟数”。 例如,若对上述例子采用5路归并, 则只需进行2趟归并,总的访问外存的 次数将压缩到 100 + 2 100 = 300 次。
一般情况下,假设待排记录序列 含 m 个初始归并段,外排时采用 k路 归并,则归并趟数为 logkm,显然, 大小可选,但需综合考虑各种因素。
1. 了解排序的定义和各种排序方法的特点。熟悉各种方法的排序过程及其依据的原则。基于“关键字间的比较”进行排序的方法可以按排序过程所依据的不同原则分为插入排序、交换排序、选择排序、归并排序和计数排序等五类。
2. 掌握各种排序方法的时间复杂度的分析方法。能从“关键字间的比较次数”分析排序算法的平均情况和最坏情况的时间性能。 按平均时间复杂度划分,内部排序可分为三类:O(n2)的简单排序方法,O(nlogn)的高效排序方法 和 O(dn)的基数排序方法。
3.理解排序方法“稳定”或“不稳定”的含义,弄清楚在什么情况下要求应用的排序方法必须是稳定的。 4. 了解外部排序的基本过程及其时间分析。