目录
这一篇博客的重点主要是快速排序,下一篇博客会继续讲解其他重要的排序~
一.排序的基本概念
排序(Sort),就是重新排列表中的元素,使表中的元素满足按关键字有序的过程。
对于排序算法的评价指标,除了看这个排序算法的时间复杂度和空间复杂度外,还需要关注算法的稳定性。若待排序表中有两个元素Ri和Rj,其对应的关键字相同即,且在排序前Ri在Rj的前面,若使用某一排序算法排序后,Ri仍然在Rj的前面,则称这个排序算法是稳定的,否则称排序算法是不稳定的。
例如下图,表中有两个“3”,若经过排序算法,两个“3”的相对位置没有变,那么这个排序算法就是稳定的,否则就是不稳定的。
排序算法的分类:
内部排序是将所有要排序的数据放到内存中,内部排序适用于数据量不是很大的情况。而对于数据量过多,无法一次性全部放到内存中的情况,就采用外部排序(后文会详细展开)。
内部排序是在内存中完成的,内存是更高速的设备,所以排序算法的设计会关注算法的时间复杂度,空间复杂度。
而外部排序算法涉及读/写磁盘,磁盘是慢速设备,除了关注时间复杂度,空间复杂度外,还需要关注如何使读/写磁盘次数更少。
补充:
对7个关键字在进行基于比较的排序,至少要进行()次关键字之间的两两比较?
答案:13
解答:在基于比较的排序算法中,每次比较两个关键字后,仅出现两种可能的转移。假设整个排序过程至少要做t次比较,显然会有 2^t 种情况。由于n个记录共有n!种不同的排列,因此有 n!种不同的比较路径,于是有2^t>n!,即 。所以比较次数为
带入n=7,得到比较次数至少为13。
二.插入排序
1.直接插入排序
算法思想:每次将一个待排序的记录按其关键字大小插入到前面已排好序的子序列中直到全部记录插入完成。
算法过程:
插入算法会从表的第二个元素开始入手,而当前处理的元素之前的元素被认为已经排好序:
将38与之前排好序的元素进行对比,若大于38,则后移,并把38前移:
65继续与之前的元素对比,由于65大于之前的元素,所以不用移动,97同理。当移到76这个数据元素时,将76这个元素与之前已经排好序的元素依次对比:97大于76,所以将97后移一位:
76大于65,所以76插到65的后面即可:
对于13这个数据元素,由于13小于前面所有已排序好的元素,所以前面的元素依次后移,13插到最前面的位置:
对于49这个元素,同样将比他大的元素向后移动(与他一样大的元素不移动),如下图所示,将49插入到“4”位置:
代码如下:
//直接插入排序
void InsertSort(int A[],int n){
int i,j,temp;
for(i=1;i<n;i++) //将各元素插入已排好序的序列中
if(A[i]<A[i-1]){ //若A[i]关键字小于前驱
temp=A[i]; //用temp暂存A[i]
for(j=i-1;j>=0 && A[j]>temp;--j) //检查所有前面已排好序的元素
A[j+1]=A[j]; //所有大于temp的元素都向后挪位
A[j+1]=temp; //复制到插入位置
}
}
王道书中使用的是带哨兵的方法,即,数据元素从数组的“1”位置开始存放,数组“0”位置放置当前要处理的元素。例如下图,要处理"38"这一数据元素时,"0"位置放置"38":
//直接插入排序(带哨兵)
void InsertSort(int A[],int n){
int i,j;
for(i=2;i<=n;i++) //依次将A[2]~A[n]插入到前面已排序序列
if(A[i]<A[i-1]){ //若A[i]关键码小于其前驱,将A[i]插入有序表
A[0]=A[i]; //复制为哨兵,A[0]不存放元素
for(j=i-1;A[0]<A[j];--j) //从后往前查找待插入位置
A[j+1]=A[j]; //向后挪位
A[j+1]=A[0]; //复制到插入位置
}
}
要处理"38"这一数据元素时,"0"位置放置"38":
从后往前寻找待插入位置,j=i-1,j=49,由于49>38,所以49向后移动一位
--j,由于A[j]=A[0],所以跳出for循环,并将"0"位置的元素复制到插入位置"A[j+1]=A[0]"
后者写法的优点是不用每轮循环都判断j>=0,循环执行效率更高:
//直接插入排序
void InsertSort(int A[],int n){
int i,j,temp;
for(i=1;i<n;i++) //将各元素插入已排好序的序列中
if(A[i]<A[i-1]){ //若A[i]关键字小于前驱
temp=A[i]; //用temp暂存A[i]
for(j=i-1;j>=0 && A[j]>temp;--j) //检查所有前面已排好序的元素
A[j+1]=A[j]; //所有大于temp的元素都向后挪位
A[j+1]=temp; //复制到插入位置
}
}
//直接插入排序(带哨兵)
void InsertSort(int A[],int n){
int i,j;
for(i=2;i<=n;i++) //依次将A[2]~A[n]插入到前面已排序序列
if(A[i]<A[i-1]){ //若A[i]关键码小于其前驱,将A[i]插入有序表
A[0]=A[i]; //复制为哨兵,A[0]不存放元素
for(j=i-1;A[0]<A[j];--j) //从后往前查找待插入位置
A[j+1]=A[j]; //向后挪位
A[j+1]=A[0]; //复制到插入位置
}
}
算法效率:
直接插入排序算法的空间复杂度是O(1),因为只需要定义i,j两个用于循环的变量,以及辅助变量temp(写法1),A[0](写法2)。
时间复杂度主要来自对比关键字、移动元素若有 n个元素,则需要 n-1趟处理。
最好的情况:若原始表中的元素本来就是有序的,那么共n-1趟处理,每一趟只需要对比关键字1次不用移动元素。最好时间复杂度为O(n)。
最坏的情况:原始表中的元素是逆序的,这样的话,每一次处理都需要将当前元素与之前的每一个数据元素都进行对比,并且将之前排序好的元素后依次往后移动。就拿带哨兵的算法举例:第一个处理的元素是70,① 将70移动到哨兵位置;②将70与80这个数据元素进行对比,80>70,80向后移动;③--j,将A[j]与A[0]进行对比;④由于A[j]=A[0],所以将A[0]移动到A[j+1]。中间经历了3次移动和2次对比。
以此类推:
最坏时间复杂度为O(n^2)。由于最好时间复杂度为O(n),所以平均时间复杂度为O(n^2)。
直接插入排序中,两个相等的元素并没有交换相对位置,所以其算法稳定性较高。
补充:
若不考虑与哨兵的比较,逆序时(最坏情况),直接插入排序需要进行次关键字比较,正序时(最好情况),需要进行n-1次关键字比较。
2.折半插入排序
折半插入排序是直接插入排序的优化,就是用折半查找找到应该插入的位置,再移动元素。
在A[0]处保存当前处理的元素55:
对当前处理的元素前面的区域进行折半查找,并将mid指向的元素与当前元素进行对比,由于55>50,所以55应该插入到50右边的区域:
于是,low=mid+1;mid=high+low/2,mid指针指向70,由于70>55,所以55应该插入到70左边的区域:
于是high=mid-1;mid=high+low/2,mid指针指向60,由于60>55,所以55应该插入到60左边的区域:
于是high=mid-1;由于low>high,所以折半查找停止,应将 [low,i-1] 内的元素全部右移,并将 A[0] 复制到 low 所指位置。
接下来要处理的元素是60,前面操作相同,当mid指针指向60时,60这个数据元素与当前正在处理的数据元素相等。按照折半查找的规则,当mid指向的元素与目标关键字相同时,停止折半查找。在这里,为了保证插入排序的稳定性,当发现和当前处理元素相同的数据元素时,继续在该元素(mid所指位置)右边寻找当前处理元素要插入的位置:
于是low=mid+1,mid=low+high/2,由于70>60,所以60应该插入在70左边的位置:
于是high=mid-1,由于low>high,停止折半查找。将 [low, i-1] 内的元素全部右移,并将 A[0]复制到 low 所指位置。
下一个需要处理的数据元素是90,同样,当low>high时,停止折半查找,由于low>i-1,所以不用移动任何元素。
//折半插入排序
void InsertSort(int A[],int n){
int i,j,low,high,mid;
for(i=2;i<=n;i++){ //依次将A[2]~A[n]插入前面的已排序序列
A[0]=A[i]; //将A[i]暂存到A[0]
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[i+1]=A[j]; //统一后移元素,空出插入位置
A[low]=A[0]; //插入操作
//A[high+1]=A[0]; //效果相同
}
}
折半插入排序,比起“直接插入排序”,比较关键字的次减少了,但是移动元素的次数没变,整体来看,最好时间复杂度依然是O(n)(不需要移动元素),最坏时间复杂度是O(n^2)。
补充:
对链表就不能使用折半插入排序了,可以采用直接插入排序。与顺序表的直接插入排序不同,链表在移动元素时,只需要修改几个指针即可,不需要右移动大量的数据元素。虽然移动元素的次数变少了,但是关键字对比的次数依然是O(n^2)数量级,整体来看时间复杂度依然是O(n^2)。
// 定义链表节点结构体
typedef struct ListNode {
int data;
struct ListNode *next;
} ListNode, *List;
// 直接插入排序
void InsertSort(List* head) {
if (*head == NULL || (*head)->next == NULL) {
return; // 链表为空或者只有一个节点,无需排序
}
ListNode* sorted = NULL; // 已排序部分的头指针
ListNode* current = *head; // 当前待排序节点
while (current) {
ListNode* next = current->next; // 记录下一个待排序节点
if (sorted == NULL || current->data < sorted->data) {
//检查链表是否为空,或者当前待排序节点的数据是否小于已排序部分的头节点的数据。
//如果是,说明当前待排序节点应该成为新的头节点。
current->next = sorted;
sorted = current;
} else {
ListNode* temp = sorted;
while (temp->next && temp->next->data < current->data) {
//找到当前待排序节点 current 应该插入的位置。
temp = temp->next;
}
current->next = temp->next;
temp->next = current;
}
current = next; // 移动到下一个待排序节点
}
*head = sorted; // 更新头指针
}
上面的注解应该比较清楚了,现在解释一下这几句:
current->next = temp->next;
temp->next = current;① 从头节点开始遍历,当遍历到的元素(temp)小于当前待排序节点的数据:
temp=temp->next;
② 由于60>55,所以将55插入到60这个数据元素之前:
current->next=temp->next;
③ temp->next=current;
折半插入排序与直接插入排序的区别与联系:
对同一待排序序列:
1.排序的总趟数取决于元素个数n,两者都是进行n-1趟排序。
2.元素的移动次数取决于初始序列,两者相同。
3.两者使用的辅助空间数量都是O(1)。
4.折半插入排序的比较次数与序列初态无关,而直接插入排序的比较次数与序列初态有关。
三.希尔排序(Shell Sort)
希尔排序是插入排序的优化,在插入排序中说到,如果要排序的元素之间基本有序,那么采用插入排序能得到很好的执行效率。
所以,在希尔排序中会先追求表中元素部分有序,再逐渐逼近全局有序。
算法思想:先将待排序表分割成若干形如 L[i , i+ d , i+ 2d ,......, i+ kd] 的“特殊”子表,对各个子表分别进行直接插入排序。缩小增量d,重复上述过程,直到d=1为止。
对于下表进行分析:
① 第一趟排序的增量是4:d1=n/2=4。所有相距为d1的数据元素,看作同一个子表的元素:
对各个子表进行直接插入排序:
所以第一躺直接插入排序后,各个位置的数据元素如下:
② 在第二趟的处理中,会缩小d的值,也就是d2=d1/2=2,所以第二趟的处理中,会把相距为2的数据元素划分为同一个子表:
对各子表的数据元素进行直接插入排序:
第二趟直接插入排序后,各个位置的数据元素如下:
③ 第三趟的处理中,会继续减小增量,d3=d2/2=1,即所有的数据元素被划分为同一个子表:
经过前面的两趟处理,这个表的数据元素其实已经基本有序了,再对整体进行一次"直接插入排序",就比直接进行"直接插入排序"效率提高很多。
注:在本例中选用的增量序列是4,2,1,也就是每次缩小一半的增量序列,这也是希尔建议的增量的选取方式。但是在考试中可能遇到各种增量,具体按题目来看。
//希尔排序
void ShellSort(int A[],int n){
int d, i, j;
//A[0]只是暂存单元,不是哨兵,当j<=0时,移动到插入位置
for(d= n/2;d>=1;d=d/2) //步长变化
for(i=d+1; i<=n; ++i)
//在直接插入排序中,是从第二个位置开始处理的,所以刚开始i会指向第一个子表中的第二个元素
if(A[i]<A[i-d]){ //需将A[i]插入有序增量子表
A[0]=A[i]; //暂存在A[0]
for(j= i-d; j>0 && A[0]<A[j]; j-=d)
A[j+d]=A[j]; //记录后移,查找插入的位置
A[j+d]=A[0]; //插入
}//if
}
举例说明一下这个代码:
① 由于直接插入排序从第2个元素开始处理,所以刚开始 i 指向第1个子表的第2个元素:
for(i=d+1;i<n;++i)
由于76>49,所以不需要改变相对位置,不进入"if"。
② 进入第二轮“for”循环,i++,即处理第二个子表,由于13<38,进入:if(A[i]<A[i-d]);
将A[i]暂存到A[0]中:A[0]=A[i];
依次往前比较,若前面的元素大于i,则将前面的元素后移:
for(j=i-d;j>0; && A[0]<A[j]; j-=d)
A[j+d]=A[j];//注意是在子表中的后移,后移d位
执行一轮for循环后j=j-d,即继续往前检索子表中的数据元素。但在这个例子中d=4,j=2,所以j-d=-2,由于j<0,所以跳出for循环。
③ 最后,将A[0]中的数值放到 A[j+d] 中,就是放到A[2]中:
这样,第二个子表的直接插入排序也完成了。
再说明一下d=2的情况:
① 首先 i 会指向子表中的第二个元素:
由于27<49,所以将49往后移动d位,并把27放到49的位置:
② 接下来会让i++,使其指向另外一个子表(注意这里的代码和手动模拟的过程是不同的):
由于49>13,不满足A[i]<A[i-d],所以不用调整两个元素的相对位置。
③继续i++,则又回到了之前的子表,并且要处理子表中的76这个数据元素,由于不满足A[i]<A[i-d],所以不用移动位置。
④i++,切换到另一个子表:
由于38<49,所以进入"if"语句,执行"for"循环,之前讲过,这里不细讲:
以此类推,即可完成第二趟处理:
如何一次遍历完整张子表,再遍历另一张子表,而不反复横跳?
可以观察到d(增量)等于多少,就有多少张子表,所以:
void ShellSort(int A[], int n) {
int d, i, j;
// A[0]只是暂存单元,不是哨兵,当j<=0时,移动到插入位置
for (d = n / 2; d >= 1; d = d / 2) { // 步长变化
// 对每个子表内的元素进行排序
for (i = 1; i <= d; ++i) {
// 对当前子表内的元素进行排序
for (j = i + d; j <= n; j += d) {
if (A[j] < A[j - d]) { // 需将A[j]插入有序增量子表
int temp = A[j]; // 暂存A[j]
int k = j - d;
while (k >= i && A[k] > temp) {
A[k + d] = A[k]; // 记录后移,查找插入的位置
k -= d;
}
A[k + d] = temp; // 插入
}//if
}//for
}//for
}//for
}
算法效率:
希尔排序的空间复杂度也是O(1)
时间复杂度的分析比较复杂。如下图所示,是两种增量下的排序结果:
采用不同增量,直接插入排序的趟数会不同,同时每趟处理中每个元素移动和对比的次数也都不同。所以,时间复杂度和增量序列 d1,d2,d3.... 的选择有关,目前无法用数学手段证明确切的时间复杂度:最坏时间复杂度为 O(n^2),即d1=1,希尔排序退化为直接插入排序;当n在某个范围内时,可达O(n^1.3)。
算法稳定性:
如下图所示的希尔排序中,第一趟的d=2,则将49和65划为一组,由于65>49,所以49和65互换位置,再经过d=1的处理后,完成希尔排序。
可以看到,原本在后面的49,经过希尔排序后,被插到了前面,所以这个算法是不稳定的。
算法适应性:
希尔排序仅适用于顺序表,不适用于链表。
四.交换排序
基于“交换”的排序:根据序列中两个元素关键字的比较结果来对换这两个记录在序列中的位置。
1.冒泡排序
从后往前(或从前往后)两两比较相邻元素的值,若为逆序(即A[i-1] > A[i]),则交换它们,直到序列比较完。称这样过程为“一趟”冒泡排序。
举个例子:
若想将表中元素变为递增序列:
① 第1趟排序会先对比最后的两个元素,由于27<49,所以不用交换位置:
13和27同理:
由于76>13,所以两个数据元素互换位置:
其余同理,第一趟排序会将关键字值最小的数据元素移动到最前面:
② 第2趟排序同理,从后往前将相邻的元素两两对比。注意,第一个元素已经确定了最终位置,所以不用再进行对比了。
③ 其余趟的排序处理相同,每一趟排序都会确定一个关键字的最终位置,当处理到第5趟时,这一趟排序的数据元素没有发生"交换",说明此时已经整体有序了。
所以经过5趟处理后,整个表就有序了,不需要再进行排序了:
代码实现如下:
//交换
void swap(int &a,int &b){
int temp=a;
a=b;
b=temp;
}
//冒泡排序
void BubbleSort(int A[],int n){
for(int i=0;i<n-1;i++){
bool flag=false; //表示本趟冒泡是否发生交换的标志
for(int j=n-1;j>i;j--) //一趟冒泡过程
if(A[j-1]>A[j]){ //若为逆序
swap(A[j-1],A[j]); //交换
flag=true;
}
if(flag==false)
return; //本趟遍历后没有发生交换,说明表已经有序
}
}
在上面代码中,由于 A[j-1]>A[j] 才进行交换,也就是说,当两个数据元素不相同时,不进行交换,所以这个算法是稳定的。
算法的效率:
冒泡算法的空间复杂度是O(1),因为这个算法只需要定义几个变量,所以只需要常数级的空间。
时间复杂度:若要排序的表本来就是有序的,那么进行第一趟排序时,冒泡排序就会发现没有数据元素进行交换,用代码来说就是flag==false,那么算法直接结束。所以若表是有序的,那么只需要进行一趟排序,并且比较次数为n-1,交换次数为0,最好时间复杂度为O(n)。
最坏时间复杂度:若要排序的表是逆序的,由于冒泡排序是从尾到头相邻的数据元素两两对比,所以从尾到头相邻的数据元素都要进行交换。也就是说每对比一次都要交换一次。
所以比较次数=(n-1)+(n-2)+(n-3).....+1==交换次数(这里指的是调用swap的次数,如果探讨的是移动元素的次数,那么每一次swap都需要移动元素3次)。
最坏时间复杂度为O(n^2),平均时间复杂度为O(n^2)。
冒泡排序也适用于链表:
如下表所示,从链头元素开始,将相邻的数据元素两两对比:
如果指针当前指向的元素大于其后面的元素,那么将两个元素交换:
如果小于其后面的元素,那么指针向后移动即可:
最后得到的第一趟冒泡排序结果为:
typedef struct Node {
int data;
struct Node* next;
} Node, *List;
void BubbleSort(List* head) {
if (*head == NULL || (*head)->next == NULL) {
//如果是空链表或者链表中只有一个元素,那就不需要进行排序,直接返回
return;
}
int swapped; //用于标记是否进行了交换操作
List ptr1; //用于遍历列表
List lptr = NULL; //用于标记已经排好序的末尾节点
do {
swapped = 0;
ptr1 = *head; //从头到尾开始遍历
while (ptr1->next != lptr) {
if (ptr1->data > ptr1->next->data) {
//将ptr1指向的元素与其后面的元素进行对比,如果>后面元素,进行交换
int temp = ptr1->data;
ptr1->data = ptr1->next->data;
ptr1->next->data = temp;
swapped = 1; //交换完成后swapped赋为1
}
ptr1 = ptr1->next; //进行下一轮比较
}
lptr = ptr1; //一轮比较后,将末尾已经排好序的节点赋值为lptr
} while (swapped); //一直循环交换操作,直到数据没有交换位置为止,算法停止
}
2.快速排序
算法思想:
在待排序表L[1....n]中任取一个元素pivot作为枢轴(或基准,通常取首元素),通过一趟排序将待排序表划分为独立的两部分L[1....k-1]和L[k+1....n],使得L[1....k-1]中的所有元素小于pivot,L[k+1..n]中的所有元素大于等于pivot,则pivot放在了其最终位置L(k)上,这个过程称为一次“划分”。然后分别递归地对两个子表重复上述过程,直至每部分内只有一个元素或空为止,即所有元素放在了其最终位置上。
算法过程:
low和high分别指向表头元素和表尾元素,将low指向的元素作为基准元素。一次"划分"的目的是将所有49的元素放到high的右边,49的元素放到low的左边。
① high现在指向的元素49,所以下标为7的数据元素不需要移动,将high指针向左移动:
② 由于当前high所指的元素27<49,所以将27移动到low所指的位置,并且low指针右移:
low指针现在指向的元素38<49,所以low指针继续右移:
③由于当前low指针指向的元素6549,所以将65移动到high指针所指位置,并且让high指针左移:
由于high指针所指元素小13<49,所以将13放到low所指的位置,并且将low向右移:
④由于low指向的元素9749,所以将97移动到high所指的位置,并且将high指针左移:
high指针指向的元素76依然49,所以将high指针左移:
当low=high时,达到目的:比基准元素49更小的数据元素都在low指针左边,比基准元素49更大的数据元素都在high的右边,而49,则放在low和high共同指向的位置:
由于49的最终位置已经确定,所以不需要再管这个数据元素,只需要将左右两个子表用同样的方法进行划分即可。
左边子表排序后:
由于27左右两个部分都只有一个元素了,所以不需要对其进行处理了。
右边子表排序后:
将76这个数据元素的右半部分只有一个数据元素了,所以不用进行进一步处理,继续对其左半部分进行处理:
最终得到:
//用第一个元素将待排序序列划分成左右两个部分
int Partition(int A[],int low,int high){
int pivot=A[low]; //第一个元素作为枢轴
while(low<high){ //用low、high搜索枢轴的最终位置
while(low<high && A[high]>=pivot) --high;
A[low]=A[high]; //比枢轴小的元素移动到左端
while(low<high && A[low]<=pivot) ++low;
A[high]=A[low]; //比枢轴大的元素移动到右端
}
A[low]=pivot; //枢轴元素存放到最终位置
return low; //返回存放枢轴的最终位置
}
//快速排序
void QuickSort(int A[],int low,int high){
if(low<high){ //递归跳出条件
int pivotpos=Partition(A,low,high); //划分
QuickSort(A,low,pivotpos-1); //划分左子表
QuickSort(A,pivotpos-1,high); //划分右子表
}
}
由于快速排序是采用递归实现的,所以这里细致讲解一下递归过程:
① 将A[ ]数组以及最左元素,最右元素的下标,QuickSort执行到的行号都保存到递归工作栈(函数调用栈)中。
由于满足if( )条件,所以进行一次Partition(),完成一次划分后,再根据递归调用栈的信息回到之前执行的行:
第一次划分的结果如下:
② 接下来划分他的左子表:QuickSort(A,low,pivotpos-1);
这一层的QuickSort的low=0,high=2,并且要把上一层QuickSort执行到什么位置记录下来:#97,表示上一层QuickSort执行到了97行。
执行Partition()后,pivotpos的值==1,并且第二层QuickSort执行到的行数是96行:
③ 继续对pivotpos的左子表进行划分:
由于左子表只有下标为0的元素,所以在第三层的QuickSort中保存的是i=0,h=0:
由于不满足low<high这个条件,所以这一层的QuickSort不进入if,直接返回上一层的递归调用,由于上一层的递归调用执行到了97行,所以继续执行98行,也就是划分第二层的右子表:
③ 由于右子表的low=high=2,所以也不满足if()条件,直接返回上一层。
上一层已经处理到了98行,没有代码需要运行了,所以继续返回上一层函数,如下图所示,上一层函数执行到97行,所以继续执行98行,也就是划分第一层的右子表:
④ 由于右子表的low=4,high=7,满足if条件,调用Partition函数,进行一次划分:
划分后,基准元素被放在了下标为6的位置:
⑤ 继续划分其左子表,经过划分后,基准元素的值为4,也就是low=4,high=5这个区间内的pivotpos=4:
继续执行97行:QuickSort(A,low,pivotpos-1);传入的参数low=4,pivotpos-1=3,由于不满足low<high,直接返回上一层:
上一层执行到97行,继续执行98行:QuickSort(A,pivotpos+1,high);如下图所示,由于不满足low=high,所以直接返回上一层:
由于上一层已经执行到98行,所以继续返回:
由于第二层已经执行到了97行,所以继续执行98行:QuickSort(A,pivotpos+1,high);
根据这一层保存的信息,这一层的low=4,high=7,pivotpos=6,所以传入的参数分别为7,7
由于不满足low<high,所以这一层的函数调用什么都没有做,直接返回。
如上图所示,递归工作栈中的两层都已经执行到了98行,所以从上至下依次返回,至此递归部分完成。
快速排序算法的效率:
时间复杂度
① 对表中序列进行快速排序的过程,其实就是不断划分表的过程。对于初始序列,需要进行一次划分,即low指针和high指针一起扫描表中的元素,时间复杂度不会超过O(n)。
② 第一层的QuickSort处理后,需要对左右两个子表分别进行划分,也就是进行两次划分,由于两张表的元素<n,所以处理的时间复杂度也不会超过O(n)。
③同理,下图的四次划分,时间复杂度也不会超过O(n) 。
④所以,对于下表的数据元素,需要经历4层QuickSort,每一层QuickSort只需要处理剩余待排序元素, 时间复杂度不超过O(n)。
所以总的时间复杂度=O(n*递归层数)
空间复杂度
由于快速排序算法使用到递归,递归调用的层数越深,那么空间复杂度就越高,所以:
空间复杂度=O(递归层数)
那么,快速排序算法递归调用层数有多深呢?
如下图所示,每一层的QuickSort会把当前需要处理的子区间继续划分为两个部分。把n个元素组织成二叉树,二叉树的层数就是递归调用的层数:
所以快速排序递归调用的层数的计算可以转化为二叉树高度的计算。对于n个结点的二叉树:
最小高度:或()
最大高度:n
所以:
算法效率较高的情况:
从肉眼上看,若每一次选中的“枢轴”将待排序序列划分为均匀的两个部分,则递归深度最小,算法效率最高。例如下图:
第一趟将第1个元素与后面的7个元素进行比较,将原序列划分为长度为3和4的两个子表,比较7次;
第二趟对两个子表进行划分,将长度为3的子表划分为长度为1的两个子表(不用继续划分),比较2次,将长度为4的子表划分为长度为1和2的两个子表,比较3次;
第三趟将长度为2的子表划分为长度为1的子表,比较1次。至此,排序结束,共进行的比较次数是7+2+3+1=13。
最坏的情况:
若每一次选中的“枢轴”将待排序序列划分为很不均匀的两个部分,则会导致递归深度增加,算法效率变低。
如下图所示,若序列本身就是有序的,那么有n个元素,就需要进行n层的QuickSort函数调用:
下图序列的比较次数为:
第一趟进行7次比较,第二趟进行6次比较,一次类推,比较次数为:
7+6+5....+2+1=28
可以总结:当初始序列有序或逆序时,快速排序的性能最差(因为每次选择的都是最靠边的元素)。
针对上面的分析,我们可以对快速排序算法进行优化,也就是尽量选择可以把数据中分的枢轴元素。
① 选头,中,尾三个位置的元素,取中间值作为枢轴元素。
② 随机选一个元素作为枢轴元素。
总结:
快速排序的算法效率
与其他排序方法相比,快速排序是所有内部排序算法中平均性能最优的排序算法。
快速排序算法的稳定性:
快速排序算法是不稳定的。
对于下面的待排序序列:
① 将low指向的元素作为枢轴:
② 由于high指向的元素<2,所以放到low所指向的位置:
③ low指针右移指向2,由于2和枢轴元素相等,所以位置不变,low指针继续右移。此时,low=high,将枢轴元素放到low和high共同指向的位置:
如下图所示,两个2的位置调换了,所以快速排序算法是不稳定的:
注:408原题中说,对所有尚未确定最终位置的所有元素进行一遍处理称为“一趟”排序,因此一次“划分”≠一趟排序。一次划分可以确定一个元素的最终位置,而一趟排序也许可以确定多个元素的最终位置。
例如下图,将左右两个子表都进行一次划分称为"一趟"排序,即对所有尚未确定最终位置的所有元素都进行一遍处理。而一次"划分",则是针对左子表或右子表的一次划分。
例题:
1.下列序列中,( )可能是执行第一趟快速排序后所得到的序列(按从大到小排序和从小到大排序来分别讨论)。
I.{68,11,18,69,23,93,73} II.{68,11,69,23, 18,93, 73} III.{93,73,68,11,69,23,18}
IV.{68,11,69,23,18, 73,93}
A. I、IV B. Ⅱ、Ⅲ C.Ⅲ、IV D.只有IV答案:C
显然,若按从小到大排序,则最终有序的序列是(11,18,23,68,69,73,93};若按从大到小排序,则最终有序的序列是(93,73,69,68,23,18,11)。对比可知选项Ⅰ、Ⅱ中没有处于最终位置的元素,所以I、Ⅱ项都不可能。Ⅲ项中73 和 93 处于从大到小排序后的最终位置,而且73 将序列分割成大于 73 和小于 73 的两部分,所以 Ⅲ 项是有可能的。IV 项中 73 和 93 处于从小到大排列后的最终位置,73 也将序列分割成大于73 和小于73的两部分。
2.下列选项中,不可能是快速排序第二趟排序结果的是()
A.2,3,5,4,6,7,9 B.2,7,5,6,4,3,9 C.3,2,5,4,7,6,9 D.4,2,3,5,7,6,9
答案:C
解答:
对n个元素进行第一趟快速排序后,会确定一个基准元素,根据这个基准元素在数组中的位置,有两种情况:①基准元素在数组的首端或尾端,接下来对剩下的 n-1 个元素构成的子序列进行第二趟快速排序,再确定一个基准元素。这样,在两趟排序后就至少能确定两个元素的最终位置,其中至少有一个元素是在数组的首端或尾端。②基准元素不在数组的首端或尾端,第二趟快快速排序对基准元素划分开的两个子序列分别进行一次划分,两个子序列各确定一个基准元素。这样,两趟排序后就至少能确定三个元素的最终位置。基于上述结论,观察题中的四个选项,A项的 2,3,6,7,9符合第一种或第二种情况;B项中 2,9符合第一种情况;D 项中5,9符合第一种情况;最后看C项,只有9处于最终位置,因此不可能是快速排序第二趟的结果。
3.排序过程中,对尚未确定最终位置的所有元素进行一遍处理称为一“趟”。下列序列中,不可能是快速排序第二趟结果的是()
A.5,2,16,12,28,60,32,72 B.2,16,5,28,12,60,32,72
C.2,12,16,5,28,32,72,60 D.5,2,12,28,16,32,72,60
答案:D
解答:
基于上题中分析得出的结论,观察题中的四个选项,A项的28,72 符合第一种情况;B项的2,72 符合第一种情况;C项的2,28,32 符合第一种或第二种情况;最后看D项,只有 12 和32 处于最终位置,既不符合第一种情况,又不符合第二种情况。
总结:
以上算法的时间/空间复杂度:
以上算法的稳定性:
直接插入排序:稳定
折半插入排序:稳定
希尔排序:不稳定
冒泡排序:稳定
快速排序算法:不稳定
以上算法排序趟数:
直接插入排序:每趟都插入一个元素,排序趟数固定为n-1,和序列初始状态无关。
折半插入排序:趟数固定为n-1。
希尔排序:希尔排序的趟数取决于所选的d(增量序列)。不同的增量序列会导致不同的排序趟数。
冒泡排序:冒泡排序的趟数是1~n-1,和初始序列有关。
快速排序算法:排序的趟数为log2n~n-1,具体取决于序列的原始状态(还取决于划分方法,例如枢轴元素的位置)
以上算法总比较次数:
直接插入排序:比较次数与初始序列有关。
折半插入排序:折半插入排序每趟的比较次数都为O(log2m)(m为当前已排序好的子序列的长度),因此总比较次数的确定的。
希尔排序:希尔排序的比较次数也取决于增量序列。
冒泡排序:比较次数与初始序列有关。
快速排序算法:比较次数与初始序列有关。
以上算法能否并行执行:
直接插入排序:需要基于上次排序好的序列继续排序,不能并行执行。
折半插入排序:折半插入排序需要在已排序好的部分找到正确的插入位置,不能并行执行。
希尔排序:无法并行执行,需要基于上一次已经排序好的序列排序。
冒泡排序:每趟对未排序的元素进行一趟处理,无法并行执行。
快速排序算法:快速选择排序每趟划分子序列互不影响,可以并行执行。
顺序存储更换为链式存储:
直接插入排序:换为链式存储时间复杂度仍为O(n)。
折半插入排序:利用顺序存储随机访问的特性,不能使用链式存储。
希尔排序:利用顺序存储随机访问的特性,不能使用链式存储。
冒泡排序:可以使用链式存储,时间复杂度仍为O(n)。
快速排序算法:一般不使用链式存储。
各排序算法的特点:
直接插入排序:进行一次比较后,前2个元素有序。适用于基本有序的序列,并且适合于元素较少的序列。
折半插入排序:一次折半插入排序,前2个元素有序;两次折半插入排序,3个元素有序..
希尔排序:每次对划分的子表进行排序,得到局部有序的结果,所以不能保证每趟排序结束都能确定一个元素的最终位置。
冒泡排序:进行一趟排序算法后,会有一个元素在其最终位置。要么在开头,要么在结尾。
快速排序算法:对n个元素进行第一趟快速排序后,会确定一个基准元素,根据这个基准元素在数组中的位置,有两种情况:①基准元素在数组的首端或尾端,接下来对剩下的 n-1 个元素构成的子序列进行第二趟快速排序,再确定一个基准元素。这样,在两趟排序后就至少能确定两个元素的最终位置,其中至少有一个元素是在数组的首端或尾端。②基准元素不在数组的首端或尾端,第二趟快快速排序对基准元素划分开的两个子序列分别进行一次划分,两个子序列各确定一个基准元素。这样,两趟排序后就至少能确定三个元素的最终位置。
快速排序算法适用于无序的序列,并且适合于元素较多的序列。