数据结构 第八章 排序 习题

本文详细探讨了各种排序算法,包括内部排序和外部排序。内部排序中的插入排序(直接插入、折半插入、希尔排序)、交换排序(冒泡排序、快速排序)、选择排序(简单选择、堆排序)、归并排序和基数排序被逐一剖析,重点讨论了算法的稳定性、时间复杂度和适用场景。外部排序则讲解了多路平衡归并排序、败者树以及置换-选择排序等方法,强调了归并树和最佳归并树的设计。

8.1 排序的定义

排序分类

内部排序

排序期间元素全部存放在内存中的排序

插入(直接插入排序、折半插入排序、希尔排序);交换排序(冒泡排序、快速排序);选择排序(简单选择排序、堆排序);归并排序;基数排序

外部排序

排序期间元素无法全部同时存放在内存中,必须在排序的过程中根据要求不断地在内、外存之间移动
多路归并排序

算法的稳定性

若待排序表中有两个元素 R j R_j Rj R j R_j Rj,其对应的关键字相同,且在排序前 R i R_i Ri R j R_j Rj前面,若使用某一排序算法排序后, R i R_i Ri仍在 R j R_j Rj前面,则称这个排序算法是稳定的,否则称排序算法是不稳定的。

习题8.1

  1. 拓扑排序是将有向图中所有结点排成一个线性序列,在内存中进行,但不满足排序定义,不属于内部排序,也不属于外部排序。
  2. 对于任意基于比较的排序,比较次数至少为 ⌈ l o g 2 ( n ! ) ⌉ \lceil log_2^{(n!)}\rceil log2(n!)

8.2 插入排序

直接插入排序

待排序表L[1…n]在某次排序过程中的某一时刻状态下:
直接插入排序
要将元素L(i)插入到已有序的子序列L[1…i-1]中,需要执行以下操作:
1)查找出L(i)在L[1…i-1]中的插入位置k
2)将L[k…i-1]中的所有元素依次后移一个位置。
3)将L(i)复制到L(k)

void InsertSort(ElemType A[], int n){
	int i,j;
	for(i=2;i<=n;i++){
		if(A[i]<A[i-1]){
			A[0]=A[i];	//哨兵
			for(j=i-1;A[0]<A[j];--j)	//从后往前查找待插入位置
				A[j+1]=A[j];
			A[j+1]=A[0];
		}
	}
}

结合上代码具体实现过程演示(可能有小错误,R2这轮应该是不用的)
直接插入排序具体实现过程
算法性能分析

  • 空间效率:常数个辅助单元, O ( 1 ) O(1) O(1)
  • 时间效率:在排序过程中,向有序子表中逐个地插入元素的操作进行了 n − 1 n-1 n1趟,每趟操作都分为比较和移动,比较次数和移动次数取决于初始状态。
    最好情况:表中有序,只需比较,不需要移动, O ( n ) O(n) O(n)
    最坏情况:表为逆序,比较次数为 σ i = 2 n i \sigma_{i=2}^ni σi=2ni,移动次数为 σ i = 2 n ( i + 1 ) \sigma_{i=2}^n(i+1) σi=2n(i+1) O ( n 2 ) O(n^2) O(n2)
    平均情况:取上述最好与最坏情况的平均值, O ( n 2 ) O(n^2) O(n2)
  • 稳定性:稳定。
  • 适用性:顺序和链式都可以。

折半插入排序

直接插入排序算法的改进,将比较和移动操作分离,先折半查找出元素待插入位置,然后统一地移动待插入位置之后的所有元素。

void InsertSort(ElemType A[], int n){
	int i,j,low,high,mid;
	for(i=2;i<=n;i++){
		A[0]=A[i];
		low=1;
		high=i-1;
		while(low<=high){	//折半查找
			mid=(low+high)/2
			if(A[mid]>A[0])
				high=mid-1;
			else
				low=mid+1;
		}
		for(j=i-1;j>=high+1;--j)
			A[j+1]=A[j];
		A[high+1]=A[0];
	}
}

折半插入排序

算法性能分析

  • 空间复杂度: O ( 1 ) O(1) O(1)
  • 时间复杂度:比较次数为 O ( n l o g 2 n ) O(nlog_2^n) O(nlog2n),且与初始状态无关,仅取决于表长,但移动次数未改变,时间复杂度为 O ( n 2 ) O(n^2) O(n2)
  • 稳定性:稳定

希尔排序

希尔排序的基本思想是:先将待排序表分割成若干形如 L [ i , i + d , i + 2 d , . . . , i + k d ] L[i,i+d,i+2d,...,i+kd] L[i,i+d,i+2d,...,i+kd]的“特殊”子表,即把相隔某个“增量”的记录组成一个子表.对各个子表分别进行直接插入排序,当整个表中的元素已呈“基本有序”时,冉对全体记录进行一次直接插入序。
希尔排序的过程如下:先取一个小于 n n n的步长 d 1 d_1 d1。把表中的全部记录分成 d 1 d_1 d1组,所有距离为的倍数的记录放在同一组,在各组内进行直接插入排序;然后取第二个步长 d 2 < d 1 d_2<d_1 d2<d1,重复上述过程,直到所取到的 d t = 1 d_t=1 dt=1,即所有记录已放在同一组中,再进行直接插入排序,由于此时己经具有较好的局部有序性,故可以很快得到最终结果。到目前为止,尚未求得一个最好的增量序列,希尔提出的方法是 d 1 = n / 2 , d i + 1 = ⌊ d i / 2 ⌋ d_1=n/2,d_{i+1}=\lfloor d_i/2 \rfloor d1=n/2,di+1=di/2,并且最后一个增量等于1。

void ShellSort(ElemType A[], int n){	
	for(dk=n/2;dk>=1;dk=dk/2){	//步长
		for(i=dk+1;i<=n;++i){
			if(A[i]<A[i-dk]){
				A[0]=A[i];
				for(j=i-dk;j>0&&A[0]<A[j];j-=dk)
					A[j+dk]=A[j];
				A[j+dk]=A[0];
			}
		}
	}
}

希尔排序(这边每轮的d均为上一轮除以2并向上取整,因此比书上多一轮)
希尔排序

算法的性能分析

  • 空间效率: O ( 1 ) O(1) O(1)
  • 时间效率:由于希尔排序的时间复杂度依赖于增量序列的函数,当n在某个特定范围时,希尔排序的时间复杂度为 O ( n 1.3 ) O(n^{1.3}) O(n1.3),在最坏情况下希尔排序的时间复杂度为 O ( n 2 ) O(n^{2}) O(n2)
  • 稳定性:不稳定;
  • 适用性:仅适用于顺序存储。

交换排序

冒泡排序

冒泡排序的基本思想是:从后往前(或从前往后)两两比较相邻元素的值,若为逆序(即A[i-1]>A[i]),则交换它们,直到序列比较完。我们称它为第一趟冒泡,结果是将最小的元素交换到待排序列的第一个位置(或将最大的元素交换到待排序列的最后一个位置),关键字最小的元素如气泡一般逐渐往上“漂浮”直至“水面”(或关键字最大的元素如石头一般下沉至水底).
下一趟冒泡时,前一确定的最小元素不再参与比较,每冒泡的结果是把序列中的最小元素(或最大元素)放到了序列的最终位置……这样最多做n-1趟冒泡就能把所有元素排好序。

void BubbleSort(ElemType A[], int n){
	for(i=0;i<n-1;i++){
		flag=false;
		for(j=n-1;j>i;j--)
			if(A{j-1]>A[j]){
				swap(A[j-1],A[j]); //交换函数
				flag=true;
			}
		if(flag==false)	//当遍历后没有发生交换,说明表已经有序
			return;
	}
}

冒泡排序

  • 空间效率: O ( 1 ) O(1) O(1)
  • 时间效率:
    最好情况下,初始序列为顺序,只需比较 n − 1 n-1 n1次,移动0次, O ( n ) O(n) O(n)
    最坏情况下,初始序列为逆序,需要进行n-1趟冒泡排序,第i趟排序需要进行 n − i n-i ni次关键字比较,而且每次比较后都必须移动3次来交换元素,这样情况下比较次数为 σ i = 1 n − 1 ( n − i ) = n ( n − 1 2 \sigma_{i=1}^{n-1}(n-i)=\frac{n(n-1}{2} σi=1n1(ni)=2n(n1,移动次数为 σ i = 1 n − 1 3 ( n − i ) = 3 n ( n − 1 2 \sigma_{i=1}^{n-1}3(n-i)=\frac{3n(n-1}{2} σi=1n13(ni)=23n(n1,因此时间复杂度为 O ( n 2 ) O(n^2) O(n2)
    平均时间复杂度 O ( n 2 ) O(n^2) O(n2)
  • 稳定性:稳定,且全局有序。

快速排序

快速排序的基本思想是基于分治法的:在待排序表L[l…n]中任取一个元素pivot作为枢轴(或基准,通常取首元素),通过一趟排序将待排序划分为独立的两部分L[1…k-1]和L[k+1…n]。使得L[1…k-1]中所有元素小于pivot,L[k+1…n]中的所有元素大于等于pivot,则pivot放在了其最终位置L(k)上,这个过程称为一趟快速排序(或一次划分)。然后分别递归地对两个子表重复上述过程,直至每部分内只有一个元素或空为止,即所有元素放在了其最终位置上。

int Partition(ElemType A[], int low, int high){
	ElemType pivot=A[low];
	while(low<high){
		while(low<high&&A[high]>=pivot)
			--high;
		A[low]=A[high];	//将比枢轴小的元素移到左端
		while(low<high&&A[low]<=pivot)
			++low;	
		A[high]=A[low]; //将比枢轴大的元素一道右端
	}
	return low;
}

void QuickSort(ElemType A[], int low, int high){
	if(low<high){
		int pivotpos=Partition(A,low,high);
		QuickSort(A,low,pivotpos-1);
		QuickSort(A,pivotpos+1,high);
	}
}

快排过程
快速排序

  • 空间效率:需要一个递归工作栈,其容量与递归调用的最大深度一致,
    最好情况为 O ( l o g 2 n ) O(log_2^n) O(log2n)
    最坏情况为 O ( n ) O(n) O(n)
    平均情况为 O ( n ) O(n) O(n)
  • 时间效率:与划分是否对称油管
    最坏情况:两个区域包含n-1和0个元素 O ( n 2 ) ; 最 好 情 况 : O(n^2); 最好情况: O(n2)O(nlog_2^n)$;

快速排序是所有内部排序算法中平均性能最优的排序算法。

习题8.3

选择题

  1. 快速排序时,每次的枢轴把表等分为长度相接近的两个子表时,速度是最快的(Q11)
  2. 快排的阶段性排序结果的特点是:第 i i i趟完成后,会有 i i i以上个数出现在它最重要出现的位置(Q14)

简答题

  1. 在使用非递归方法实现快速排序时,通常要利用一个栈记忆待排序区间的两个端点,能否用队列来实现这个栈?为什么?
    可以用队列来代替栈。在快排的过程中,一趟划分可以把一个待排序区间分为两个子区间,然后分别对这两个子区间施行相同的划分。栈的作用是在处理一个子区间时,保存另一个子区间的上界和下界,待该区间处理完后再从栈中取出另一子区间的边界。可以用队列实现,只需变动顺序即可。

  2. 编写双向冒泡排序算法,在正反两个方向交替进行扫描,即第一趟把关键字最大的元素放在序列的最后面,第二趟把关键字最小的元素放在序列的最前面,如此反复进行。

void BubbleSort(ElemType A[], int n){
	int low=0,high=n-1;
	bool flag=true;
	while(low<high&&flag){
		flag=false;
		for(i=low;i<high;i++)
			if(a[i]>a[i+1){
				swap(a[i],a[i+1]);
				flag=true;
			}
		high--;
		for(i=high;i>low;i--)
			if(a[i]<a[i-1){
				swap(a[i],a[i-1]);
				flag=true;
			}
		low++;
	}
}
  1. 已知线性表按顺序存储,且每个元素都是不相同的整数型元素,设计把所有奇数移动到所有偶数前边的算法(要求时间最少,辅助空间最少)。
void move(ElemType A[], int len){
	int i=0,j=len-1;
	while(i<j){
		while(i<j&&A[i]%2!=0) i++;	//从前往后找到第一个偶数元素
		while(i<j&&A[i]%2!=1) j++;	//从后往前找到第一个奇数元素
		if(i<j){
			swap(A[i],A[j]);
			i++;
			j--;
		}
	}
}
  1. 试重新编写考点精析中的快速排序的划分算法,使之每次选取的枢轴值都是随机地从当前子表中选择的。
int Partition2(ElemType A[], int low, int high){
	int rand_Index=low+rand()%(high-low+1);
	Swap(A[rand_Index],A[low]);
	ElemType pivot=A[low];
	int i=low;
	for(int j=low+1;j<=high;j++)
		if(A[j]<pivot)
			swap(A[++i],A[j]);
	swap(A[i],A[low]);
	return i;
}
  1. 试编写一个算法,使之能够在数组L[1…n]中找出第k小的元素(即从小到大序后处于第k个位置的元素。
    从数组L[1…n]中选择枢轴pivot进行和快速排序一样的划分,表L[1…n]被划分为L[1…m-1]和L[m+1…n],其中L(m)=pivot
    1)当m=k时,显然pivot就是所要寻找的元素;
    2)当m<k时,所要的元素一定落在L[m+1…n]中,因此可对L[m+1…n]递归查找第k-m小的元素;
    3)当m>k时,所要的元素一定落在L[1…n]中,因此可对L[1…n]递归查找第k-m小的元素;
int kth_elem(int a[], int low, int high, int k){
	int pivot=a[low];
	int low_temp=low;
	int high_temp=high;
	while(low<high){
		while(low<high&&a[high]>=pivot)
			--high;
		a[low]=a[high];
		while(low<high&&a[low]<=pivot)
			++low;
		a[high]=low];
	}
	a[low]=pivot;
	if(low==k)
		return a[low];
	else if(low>k)
		return kth_elem(a,low_temp,low-1,k);
	else
		return kth_elem(a,low+1,high_temp,k);
}

8.3.6
基本思想:将最小的 ⌊ n / 2 ⌋ \lfloor n/2 \rfloor n/2个元素放在 A 1 A_1 A1中,其余元素放在 A 2 A_2 A2即可。
这样基于快排的思想划分,会使得最后A1和A2集合的元素尽量相等,且A2中所有元素都大于轴和A1,A1周年所有元素都小于轴,可以使得|S1-S2|最大

int setPartition(int a[], int n){
	int pivotkey, low=0, low0=0, high=n-1, high0=n-1, flag=1, k=n/2, i;
	int s1=0,s2=0;
	while(flag){
		pivotkey=a[low];
		while(low<high){
			while(low<high && a[high]>=pivotkey) 
				--high;
			if(low!=high)
				a[low]=a[high];
			while(low<high && a[low]<=pivotkey)
				++low;
			if(low!=high)
				a[high]=a[low];	
		}
		a[low]=pivotkey;
		if(low==key-1)
			flag=0;	//当枢轴是第n/2小元素,划分成功
		else{
			if(low<k-1){	//否则i<
				low0=++low;
				high=high0;
			}
			else{
				high0=--high;
				low=low0;
			}
		}
	}
	for(i=0;i<k;i++)s1+=a[i];
	for(i=k;i<n;i++)s2+=a[i];
	return s2-s1;
}

时间复杂度为 O ( n ) O(n) O(n),空间复杂度为 O ( 1 ) O(1) O(1)

7、荷兰国旗问题:设有一个仅由红、白,蓝三种顧色的条块纽成的条块序列,请编写一个时间复杂度为 O ( n ) O(n) O(n)的算法,使得这些条块按红、白、蓝的顺序排好,即排成荷兰国旗图案。

算法思想:顺序扫描线性表,将红色条块交换到线性表最前面,蓝色条块交换到线性表的最后面。设立指针j为工作指针,表示当前扫描的元素,i以前的元素全部为红色,k以后的元素全部为蓝色。初始时i=0,k=n-1:
初始时i,j相同,且当为case RED时,i与j同步加加,所以不可能出现i与j同时指向红色且i与j不同的情况,也即要么i与j相同且同时指向红色,要么i与j不同且i与j之间全是白的。而i与k没能保证这样的关系。

typedef enum{RED,WHITE,BLUE} color;	//设置枚举数组
void Flag_Arrange(color a[], int n){
	int i=0,j=0,k=n-1;
	while(j<=k)
		switch(a[j]){
			case RED: 
				Swap(a[i],a[j]);
				i++;
				j++;
				break;
			case WHITE:
				j++;
				break;
			case BLUE:
				Swap(a[j],a[k]);
				k--;
				
		}
}

8.4 选择排序

简单选择排序

假设排序表为L[1…n],第i趟排序即从L[i…n]中选择关键字最小的元素与L(i)交换,每趟排序可以确定一个元素的最终位置。

void SelectSort(ElemType A[], int n){
	int i,min;
	for(i=0;i<n-1;i++){
		min=i;
		for(j=i+1;j<n;j++)
			if(A[j]<A[min] min=j;
		if(min!=i) swap(A[i],A[min]);
	}
}

很好理解所以不画图了。

  • 空间效率: O ( 1 ) O(1) O(1)
  • 时间效率: O ( n 2 ) O(n^2) O(n2)
  • 稳定性:不稳定

堆排序

n个关键字序列L[1…n]称为堆,当且仅当该序列满足:
1)L(i)>=L(2i)且L(i)>=L(2i+1)或;
2)L(i)<=L(2i)且L(i)<=L(2i+1);
可以将该一维数组视为一棵完全二叉树,满足条件1的堆称为大根堆,满足条件2的堆称为小根堆。
堆排序基本思路:首先将L[1…n]中的n个元素建成初始堆,由于堆本身的特点,堆顶元素就是最大值。输出堆顶元素后,通常将堆底元素送入堆顶,此时该堆已不满足大根堆性质,将堆顶元素向下调整时起继续保持大根堆的性质,再输出堆顶元素。

void BuildMaxHeap(ElemType A[], int len){
	for(int i=len/2;i>0;i--)
		HeadAdjust(A,i,len);
}

void HeadAdjust(ElemType A[], int k, int len){
	A[0]=A[k];	//暂存根节点
	for(i=2*k;i<=len;i*=2){
		if(i<len&&A{i]<A[i+1])
			i++;
		if(A[0]>=A[i])
			break;
		else{
			A[k]=A[i];
			k=i;
		}
	}
	A[k]=A{0];
}

void HeapSort(ElemType A[], int len){
	BuildMaxHeap(A,len);
	for(i=len;i>1;i--){
		Swap(A[i],A[1]);
		HeadAdjust(A,1,i-1);
	}
}

大根堆 一轮交换

  • 空间效率: ( 1 ) (1) (1)
  • 时间效率:建堆时间为 O ( n ) O(n) O(n),之后有n-1次向下调整操作,每次调整时间复杂度为 O ( h ) O(h) O(h),故在最好、最坏和平均情况下,堆排序的时间复杂度为$O(nlog_2^n)
  • 稳定性:不稳定

习题8.4

  1. 指出堆和二叉排序树的区别?
    堆只要满足每个子女结点大于(或小于)根结点即可,而二叉排序树需要满足每个双亲结点的关键字均大于左子树,并且均小于右子树。中序遍历二叉排序树可以得到一个有序序列,堆排序则不一定能得到。

  2. 若只想得到一个序列中第k(k≥5)个最小元素之前的部分排序序列,则最好采用什么排序方法?
    堆排序。
    对n个元素,建立初始堆的时间不超过 4 n 4n 4n,取得第k个最小元素之前的排序序列所花的时间为 k l o g 2 n klog_2^n klog2n,总时间为 4 n + k l o g 2 n 4n+klog_2^n 4n+klog2n;冒泡和简单选择排序所花时间为 k n kn kn,当 k ≥ 5 k≥5 k5时,堆排序最优。

  3. 有n个元素已构成一个小根堆,现在要增加一个元素 K n + 1 K_{n+1} Kn+1请文字简要说明如何在 l o g 2 n log_2^n log2n的时间内将其重新调整为一个堆.
    K n + 1 K_{n+1} Kn+1插入到数组的第 n + 1 n+1 n+1个位置,然后将其与双亲比较,若它大于其双亲则停止调整,否则将 K n + 1 K_{n+1} Kn+1与其双亲交换,重复地将 K n + 1 K_{n+1} Kn+1与其双亲比较,算法终止与 K n + 1 K_{n+1} Kn+1大于等于其双亲或 K n + 1 K_{n+1} Kn+1为根。

  4. 编写一个算法,在基于单链表表示的待排序关键字序列上进行简单选择排序。
    算法的思想是:每趟在原始链表中摘下关键字最大的结点,把它插入到结果链表的最前端。由于在原始链表中摘下的关键字越来越小,在结果链表前端插入的关键字也越来越小,因此最后形成的结果链表中的结点将按关键字非递减的顺序有序连接。

void selectSort(LinkedList &L){
	LinkNode *h=L,*p,*q,*r,*s;
	L=NULL;	//将L链表清空
	while(h!=NULL){
		p=s=h;
		//s和r记忆最大结点和其前驱;p为工作指针,q为前驱
		q=r=NULL;
		while(p!=NULL){	//一次遍历,找到当前最大结点
			if(p->data>s->data){
				s=p;
				p=p->link;
			}
		}
		if(s==h)
			h=h->link; 	//最大结点在原链表前端
		else
			r->link=s->link;	//最大结点在原链表表内
		s->link=L;	//将最大节点插入L
		L=s;
	}
}
  1. 试设计一个算法,判断一个数据序列是否构成一个小根堆。
	bool IsMinHeap(ElemType A[],int len){
		if(len%2==0){	//len为偶数,有一个单分支结点
			if(A[len/2]>A[len])
				return false;
			for(i=len/2-1;i>=1;i--)
				if(A[i]>A[2*i]||A[i]>A[2*i+1])
					return false;
		}
		else{	//len为奇数,无单分支结点。
			for(i=len/2;i>=1;i--)
				if(A[i]>A[2*i]||A[i]>A[2*i+1])
					return false;
		}
		return true;
	}

8.5 归并排序和基数排序

归并排序

归并排序与上述基于交换、选择等排序的思想不一样·“归并”的含义是将两个或两个以上
的有序表组合成一个新的有序表。假定待排序表含有n个记录,则可将其视为n个有序的子表,每个子表的长度为1,然后两两归并,得到 ⌈ n / 2 ⌉ \lceil n/2 \rceil n/2个长度为2或1的有序表:继续两两归并……如此重复,直到合并成一个长度为n的有序表为止,这种排序方法称为2路归并排序。

ElemType *B=(ElemType *)malloc((n+1)*sizeof(ElemType));
void Merge(ElemType A[], int low, int mid, int high){
	for(int k=low;k<=high;k++)
		B[k]=A[k];	//将A所有元素复制到B中
	for(i=low,j=mid+1,k=i;i<=id&&j<=high;k++){
		if(B[i]<=B[j])
			A[k]=B[i++];
		else
			A[k]=B[j++];
	}
	//以下仅执行一个
	while(i<=mid)
		A[k++]=B[i++];
	while(j<=high)
		A[k++]=B[j++];
}

void MergeSort(ElemType A[], int low, int high){
	if(low<high)
		int mid=(low+high)/2;
		MergeSort(A,low,mid);
		MergeSort(A,mid+1,high);
		Merge(A,low,mid,high);
}

归并排序

  • 空间效率:Merge()操作中需要辅助数组单元n个,因此 O ( n ) O(n) O(n)
  • 时间效率:每趟归并的时间复杂度为 O ( n ) O(n) O(n),共需要进行 ⌈ l o g 2 n ⌉ \lceil log_2^n \rceil log2n趟归并,算法时间复杂度为 O ( n l o g 2 n ) O(nlog_2^n) O(nlog2n)
  • 稳定性:稳定

k路归并排序,其排序趟数 m m m满足 k m = N k^m=N km=N m = ⌈ l o g k n ⌉ m=\lceil log_k^n \rceil m=logkn

基数排序

为实现多关键字排序,通常有两种方法:第一种是最高位优先(MSD)法,按关键字位权重递减依次逐层划分成若干更小的子序列,最后将所有子序列依次连接成一个有序序列。第二种是最低位优先(LSD)法,按关键字权重递增依次进行排序,最后形成一个有序序列。

  • 空间效率:需要r个队列 O ( r ) O(r) O(r),r为基数个数,10进制数即为10;
  • 时间效率:需要进行d趟分配和收集,一趟分配需要 O ( n ) O(n) O(n),一趟收集需要 O ( r ) O(r) O(r),时间复杂度为 O ( d ( n + r ) ) O(d(n+r)) O(d(n+r)),与序列初始状态无关。
  • 稳定性:稳定。

8.6 各种内部排序算法的比较及应用

8.6.1 内部排序算法的比较

内部排序算法比较

  • 时间复杂度与序列初始状态相关:直接插入排序、冒泡排序、简单排序(初始序列顺序最好,逆序最坏);快速排序(左右划分);
  • 需要借助辅助的空间大小:简单选择排序、插入排序、冒泡排序、希尔排序、堆排序(常数辅助空间);快速排序(辅助栈,平均 O ( l o g 2 n ) O(log_2^n) O(log2n),最坏 O ( n ) O(n) O(n));归并排序(辅助数组 O ( n ) O(n) O(n)

内部排序算法的应用

通常情况,对排序算法的比较和应用应考虑以下情况:
选取排序方法需要考虑的因素

  • 待排序的元素数目n
  • 元素本身信息量的大小
  • 关键字的结构及其分布情况
  • 稳定性的要求
  • 语言工具的条件,存储结构及辅助空间的大小等

排序算法小结

  • 若n较小,可采用直接插入排序或简单选择排序。由于直接插入排序所需的记录移动次数较简单选择排序的多,因而当记录本身信息量较大时,用简单选择排序较好。
  • 若文件的初始状态已按关键字基本有序,则选用直接插入或冒泡排序为宜。
  • 若n较大,则应采用时间复杂度为 O ( n l o g 2 n ) O(nlog_2^n) O(nlog2n)的排序方法:快速排序、堆排序或归并排序。快速排序被认为是目前基于比较的内部排序方法中最好的方法,当待排序的关键字随机分布时,快速排序的平均时间最短。堆排序所需的辅助空间少于快速排序,并且不会出现快速排序可能出现的最坏情况,这两种排序都是不稳定的。若要求排序稳定且时间复杂度为 O ( n l o g 2 n ) O(nlog_2^n) O(nlog2n),则可选用归并排序。但本章介绍的从单个记录起进行两两归并的排序算法并不值得提倡,通常可以将它和直接插入排序结合在一起使用。利用直接插入排序求得较长的有序子文件,然后两两归并。直接插入排序是稳定的,因此改进后的归并排序仍是稳定的。
  • 在基于比较的排序方法中,每次比较两个关键字的大小之后,仅出现两种可能的转移,因此可以用一棵二叉树来描述比较判定过程,由此可以证明:当文件的n个关键字随机分布时,任何借助于“比较”的排序算法,至少需要 O ( n l o g 2 n ) O(nlog_2^n) O(nlog2n)的时间。
  • 若n很大,记录的关键字位数较少且可以分解时,采用基数排序较好。
  • 当记录本身信息量较大时,为避免耗费大量时间移动记录,可用链表作为存储结构。

习题8.6

选择题

  1. 交换类的排序,其趟数和原始序列状态有关(冒泡等)(Q6)
  2. 希尔排序和堆排序用了顺序存储的随机访问特性,链式存储不支持这种性质(Q11)。

简答题

8.6.2
直接插入排序

void Insert_Sort(ElemType A[], int m, int n){
	int i,j;
	for(i=m+1;i<=m+n;i++)
		A[0]=A[i];
		for(j=i-1;A[j]>A[0];j--)
			A[j+1]=A[j];
		A[j+1]=A[0];
}

8.6.3
8.6.3

void CountSort(ElemType A[], ElemType B[], int){
	int count,i,j;
	for(i=0;i<n;i++){
		for(j=0,count=0;j<n;j++)
			if(A[j].key<A[i].key)
				count++;
		B[count]=A{i];
	}
}

关键码的比较次数为 O ( n 2 ) O(n^2) O(n2)
简单选择排序算法比该排序算法好,简单选择排序算法比较次数为n(n-1)/2,且只用一个交换记录的空间;该算法比较次数为 n 2 n^2 n2,且需要另一数组空间。

8.6.4
基本思想:以 K n K_n Kn为枢轴进行一趟快速排序。将快排算法改为以最后一个为枢轴从前向后再从后向前。

int Partition(ElemType K[],int n){
	int i=1,j=n;
	ElemType pivot=K[j];
	while(i<j){
		while(i<j&&K[i]<=pivot)
			i++;
		if(i<j)
			K[j]=K[i];
		while(i<j&&K[j]>=pivot)
			j--;
		if(i<j)
			K[i]=K[j];
	}
	K[i]=pivot;
	return i;
}

8.7 外部排序

外部排序的基本概念

在许多应用中,经常需要对大文件进行排序,因为文件中的记录很多、信息量庞大,无法将整个文件复制进内存中进行排序。因此,需要将待排序的记录存储在外存上,排序时再把数据一部分一部分地调入内存进行排序,在排序过程中需要多次进行内存和外存之间的交换。这种排序方法就称为外部排序。

外部排序的方法

文件通常是按块存储在磁盘上的,操作系统也是按块对磁盘上的信息进行读写的。因为磁盘写的机械动作所需的时间远远超过内存运算的时间(相比而言可以忽略不计)。因此在外部排序过程中的时间代价主要考虑访问磁盘的次數,即I/O次数.

外部排序通常采用归并排序法。它包括两个相对独立的阶段:

  1. 根据内存缓冲区大小,将外存上的文件分成若干长度为 l l l的子文件,依次读入内存并利用内部排序方法对它们进行排序,并将排序后得到的有序子文件重新写回外存,称这些有序子文件为归并段顺串
  2. 对这些归并段进行逐趟归并.使归并段(有序子文件)逐渐由小到大,直至得到整个有序文件为止。

一般情况下:
外 部 排 序 总 时 间 = 内 部 排 序 所 需 的 时 间 + 外 存 信 息 读 写 的 时 间 + 内 部 归 并 所 需 的 时 间 外部排序总时间=内部排序所需的时间+外存信息读写的时间+内部归并所需的时间 =++

一般地,对 r r r个初始归并段,做 k k k路平衡归并,归并树可用严格 k k k叉树来表示。第一趟可将 r r r个初始归并段并为 ⌈ r / k ⌉ \lceil r/k \rceil r/k个归并段,以后每趟归并将 m m m个归并拼成 ⌈ m / k ⌉ \lceil m/k \rceil m/k个归并段,直至最后形成一个大的归并段为止。树的高度= ⌈ l o g k r ⌉ \lceil log_k^r \rceil logkr=归并趟数 S S S
增大归并路数可以减少归并趟数,进而减少总的磁盘I/O次数。

多路平衡归并与败者树

做内部归并时,在 k k k个元素中选择最小的记录需要比较 k − 1 k-1 k1次,每趟归并 n n n个元素需要做 ( n − 1 ) ( k − 1 ) (n-1)(k-1) (n1)(k1)次比较, S S S趟归并总共需要的比较次数为 S ( n − 1 ) ( k − 1 ) = ⌈ l o g k r ⌉ ( n − 1 ) ( k − 1 ) = ⌈ l o g 2 r ⌉ ( n − 1 ) ( k − 1 ) ⌈ l o g 2 k ⌉ S(n-1)(k-1)=\lceil log_k^r \rceil(n-1)(k-1)=\lceil log_2^r \rceil(n-1)(k-1)\lceil log_2^k \rceil S(n1)(k1)=logkr(n1)(k1)=log2r(n1)(k1)log2k
内部归并时间随 k k k增长而增长,因此不能使用普通的内部归并算法。

败者树

为了使内部归并不受 k k k的增大的影响,引入了败者树。
败者树是树形选择排序的一种变体,可视为一棵完全二叉树。 k k k个叶结点分别存放 k k k个归并段在归并过程中当前参加比较的记录,内部结点用来记忆左右子树中的“失败者”,而让胜者往上继续进行比较,一直到根结点。若比较两个数,大的为失败者、小的为胜利者,则根结点指向的数为最小数。
5路归并败者树

k k k路归并的败者树深度为 ⌈ l o g 2 k ⌉ \lceil log_2^k \rceil log2k,因此 k k k个记录中选择最小关键字,最多需要 ⌈ l o g 2 k ⌉ \lceil log_2^k \rceil log2k次比较。所以总的比较次数为 S ( n − 1 ) ⌈ l o g 2 k ⌉ = ⌈ l o g k r ⌉ ( n − 1 ) ⌈ l o g 2 k ⌉ = ( n − 1 ) ⌈ l o g 2 r ⌉ S(n-1)\lceil log_2^k \rceil=\lceil log_k^r \rceil(n-1)\lceil log_2^k \rceil=(n-1)\lceil log_2^r\rceil S(n1)log2k=logkr(n1)log2k=(n1)log2r

归并路数 k k k并不是越大越好。归并路数 k k k增大时,相应地需要增加输入缓冲区的个数。若可供使用的内存空间不变,势必要减少每个输入缓冲区的容量,会使得内外存交换数据次数增大。

置换-选择排序(生成初始归并段)

若总的记录个数为 n n n,每个归并段的长度为 l l l,则归并段的个数 r ⌈ n / l ⌉ r\lceil n/l \rceil rn/l。采用内部排序方法得到的各个初始归并段长度都相同(除最后一段外),它依赖于内部排序时可用内存工作区的大小。因此,必须探索新的方法,用来产生更长的初始归并段;这就是本节要介绍的置换-选择算法。

设初始待排文件为FI,初始归并段输出文件为FO,内存工作区为WA,FO和WA的初始状态为空,WA可容纳 w w w个记录。置换-选择算法的步骤如下:
1)从FI输入 w w w个记录到工作区WA.
2)从WA中选出其中关键字取最小值的记录,记为MINIMAX记录。
3)将MINIMAX记录输出到FO中去。
4)若FI不空,则从FI输入下一个记录到WA中。
5)从WA中所有关键字比MINIMAX记录的关键字大的记录中选出最小关键字记录,作为新的MINIMAX记录。
6)重复3)~5),直至在WA中选不出新的MINIMAX记录为止,由此得到一个初始归并段,输出一个归并段的结束标志到FO中去。
7)重复2)、6),直至WA为空。由此得到全部初始归并段。

在WA中选择MINMAX记录的过程需要利用败者树来实现。

最佳归并树

若初始归并段不足以构成一棵严格k叉树时,需添加长度为0的虚段,按照哈夫曼树的原则,权为0的叶子应离根最远。
设度为0的结点有 n 0 ( = n ) n_0(=n) n0(=n)个,度为k的结点有 n k n_k nk个,则对严格k叉树有 n 0 = ( k − 1 ) n k + 1 n_0=(k-1)n_k+1 n0=(k1)nk+1,由此可以的 n k = ( n 0 − 1 ) / ( k − 1 ) n_k=(n_0-1)/(k-1) nk=(n01)/(k1)

  • ( n 0 − 1 ) (n_0-1)%(k-1)=0 (n01),则说明这 n 0 ( = n ) n_0(=n) n0(=n)个叶结点正好可以构成k叉归并数。此时,内结点有 n k n_k nk个。
  • ( n 0 − 1 ) (n_0-1)%(k-1)=u≠0 (n01),则说明对于这 n 0 ( = n ) n_0(=n) n0(=n)个叶结点,其中有u个多余,不能包含在k叉归并数里。为构造包含所有 n 0 ( = n ) n_0(=n) n0(=n)初始归并段的k叉归并树,应在原有 n k n_k nk个内结点的基础上再增加1个内结点。它在归并树中代替了一个叶结点的位置,被代替的叶结点加上刚才多出的u个叶结点,即再加上 k − u − 1 k-u-1 ku1个空归并段,就可以建立归并树。

习题8.7

选择题

  1. 磁盘上有 n n n个记录,做 r r r路归并,内存区能容纳 k k k个记录,则需要 S = l o g r n / k S=log_r^{n/k} S=logrn/k趟排序(Q1)
  2. k k k个记录中选出最小的需要做 k − 1 k-1 k1次比较,一共有 n n n个记录,因此需要做 n − 1 n-1 n1词比较,需要比较唱次数为 ( n − 1 ) ( k − 1 ) (n-1)(k-1) (n1)(k1)(Q2)
  3. 若采用败者树, r r r路归并意味着败者树的外结点有r个,败者树的高度 h = l o g 2 r h=log_2^r h=log2r,每次在参加比较的记录中选择一个关键字最小的记录,比较次数不超过 h h h
  4. 做m路平衡归并排序的过程中,为实现输入/内部归并/输出的并行处理,需要设置 2 m 2m 2m个缓冲区和 2 2 2个输出缓冲区

简答题

  1. 多路平衡归并排序是外部排序的主要方法,试问多路平衡归并排序包括哪两个相对独立
    的阶段?每个阶段完成何种工作?
    1)生成初始归并段:根据内存工作区的大小,将有n个记录的磁盘文件分批输入内存,采用有效的内部排序方法分别进行排序,生成若干有序的子文件,即初始归并段;
    2)多趟归并排序阶段:多路归并方法将这些归并段逐趟归并,最后归并成一个有序文件。

  2. 若某个文件经内部排序得到80个初始归并段,试问:
    1)若使用多路平衡归并执行3趟完成排序,则应取得的归并路数至少应为多少?
    2)若操作系统要求一个程序同时可用的输人/输出文件的总数不超过15个,则按多路归并至少需要几趟可以完成排序?若限定趟数,可取的最低路数是多少?

1)设归并路数为 m m m,初始归并段个数 r = 80 r=80 r=80,根据归并趟数计算公式 S = ⌈ l o g m r ⌉ = ⌈ l o g m 8 0 ⌉ = 3 S=\lceil log_m^r\rceil=\lceil log_m^80\rceil=3 S=logmr=logm80=3,得 m ≥ 5 m≥5 m5,因此至少为5.
2)设多路归并的归并路数为 m m m,需要 m m m个输入缓冲区和1个输出缓冲区。一个缓冲区对应一个文件,有 m + 1 = 15 m+1=15 m+1=15,因此 m = 14 m=14 m=14,可做14路归并。由 S = ⌈ l o g 1 4 8 0 ⌉ = 2 S=\lceil log_14^80\rceil=2 S=log1480=2,至少需要2趟归并。若限定趟数为2,有 80 ≤ m 2 80≤m^2 80m2,可取的最低路数为9.

  1. 假设文件有4500个记录,在磁盘上每个块可放75个记录,计算机中用于排序的内存区可容纳450个记录。试问:
    1)可以建立多少个初始归并段?每个初始归并段有多少记录?存放于多少个块中?
    2)应采用几路归并?请写出归并过程及每趟需要读写磁盘的块数。

1)可建立 4500 / 450 = 10 4500/450=10 4500/450=10个初始归并段。每个初始归并段有450个自录,存于 450 / 75 = 6 450/75=6 450/75=6个块中。
2)内存区可容纳6个块,可建立6个缓冲区,5个输入缓冲区,1个输出缓冲区。采用5路归并,共2趟归并,每趟需要读取60块,写60块。

  1. 给出12个初始归并段,其长度分别为30,44,8,6,3,20,60,18,9,62,68,85。现要做4路外归并排序,画出最佳归并树并计算带权路径长度WPL。
    设初始归并段个数 n = 12 n=12 n=12,外归并路数 k = 4 k=4 k=4,计算 ( n − 1 ) % ( k − 1 ) = 11 % 3 = 2 (n-1)\%(k-1)=11\%3=2 (n1)%(k1)=11%3=2,需要添加 k − 2 − 1 = 1 k-2-1=1 k21=1个长度为0的空归并段。此时,归并树的内结点应该有 ( n − 1 + 1 ) / ( k − 1 ) = 12 / 3 = 4 (n-1+1)/(k-1)=12/3=4 (n1+1)/(k1)=12/3=4
    之后就按哈夫曼树的建立方法建立归并数。
    8.7.5
    W P L = ( 3 + 6 + 8 ) × 3 + ( 9 + 18 + 20 + 30 + 44 + 60 + 62 ) × 2 + ( 68 + 85 ) × 1 = 51 + 486 + 153 = 690 WPL=(3+6+8)×3+(9+18+20+30+44+60+62)×2+(68+85)×1=51+486+153=690 WPL=(3+6+8)×3+(9+18+20+30+44+60+62)×2+(68+85)×1=51+486+153=690
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值