数据结构(十四)----排序算法(1)

目录

一.排序的基本概念

二.插入排序

1.直接插入排序

2.折半插入排序

三.希尔排序(Shell Sort)

四.交换排序

1.冒泡排序

2.快速排序

快速排序算法的效率:

快速排序算法的稳定性:


这一篇博客的重点主要是快速排序,下一篇博客会继续讲解其他重要的排序~

一.排序的基本概念

排序(Sort),就是重新排列表中的元素,使表中的元素满足按关键字有序的过程。

对于排序算法的评价指标,除了看这个排序算法的时间复杂度和空间复杂度外,还需要关注算法的稳定性。若待排序表中有两个元素Ri和Rj,其对应的关键字相同即key_{i}=key_{j},且在排序前Ri在Rj的前面,若使用某一排序算法排序后,Ri仍然在Rj的前面,则称这个排序算法是稳定的,否则称排序算法是不稳定的。

例如下图,表中有两个“3”,若经过排序算法,两个“3”的相对位置没有变,那么这个排序算法就是稳定的,否则就是不稳定的。

排序算法的分类:

内部排序是将所有要排序的数据放到内存中,内部排序适用于数据量不是很大的情况。而对于数据量过多,无法一次性全部放到内存中的情况,就采用外部排序(后文会详细展开)。

内部排序是在内存中完成的,内存是更高速的设备,所以排序算法的设计会关注算法的时间复杂度,空间复杂度。

而外部排序算法涉及读/写磁盘,磁盘是慢速设备,除了关注时间复杂度,空间复杂度外,还需要关注如何使读/写磁盘次数更少

二.插入排序

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)。

直接插入排序中,两个相等的元素并没有交换相对位置,所以其算法稳定性较高。

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^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;

三.希尔排序(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=\frac{n*(n-1)}{2}=交换次数(这里指的是调用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指向的元素作为基准元素。一次"划分"的目的是将所有\geq49的元素放到high的右边,<49的元素放到low的左边。

① high现在指向的元素\geq49,所以下标为7的数据元素不需要移动,将high指针向左移动:

② 由于当前high所指的元素27<49,所以将27移动到low所指的位置,并且low指针右移:

low指针现在指向的元素38<49,所以low指针继续右移:

③由于当前low指针指向的元素65\geq49,所以将65移动到high指针所指位置,并且让high指针左移:

由于high指针所指元素小13<49,所以将13放到low所指的位置,并且将low向右移:

④由于low指向的元素97\geq49,所以将97移动到high所指的位置,并且将high指针左移:

high指针指向的元素76依然\geq49,所以将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个结点的二叉树:

最小高度\left \lfloor log_{2}n \right \rfloor+1

最大高度:n

所以:

算法效率较高的情况:

从肉眼上看,若每一次选中的“枢轴”将待排序序列划分为均匀的两个部分,则递归深度最小,算法效率最高:

最坏的情况:

若每一次选中的“枢轴”将待排序序列划分为很不均匀的两个部分,则会导致递归深度增加,算法效率变低。

如下图所示,若序列本身就是有序的,那么有n个元素,就需要进行n层的QuickSort函数调用

 

可以总结:当初始序列有序或逆序时,快速排序的性能最差(因为每次选择的都是最靠边的元素)。

针对上面的分析,我们可以对快速排序算法进行优化,也就是尽量选择可以把数据中分的枢轴元素

选头,中,尾三个位置的元素,取中间值作为枢轴元素。

随机选一个元素作为枢轴元素。

总结:

快速排序的算法效率

与其他排序方法相比,快速排序是所有内部排序算法中平均性能最优的排序算法

快速排序算法的稳定性:

快速排序算法是不稳定的。

对于下面的待排序序列:

① 将low指向的元素作为枢轴:

② 由于high指向的元素<2,所以放到low所指向的位置:

③ low指针右移指向2,由于2和枢轴元素相等,所以位置不变,low指针继续右移。此时,low=high,将枢轴元素放到low和high共同指向的位置:

 如下图所示,两个2的位置调换了,所以快速排序算法是不稳定的:

注:408原题中说,对所有尚未确定最终位置的所有元素进行一遍处理称为“一趟”排序,因此一次“划分”≠一趟排序。一次划分可以确定一个元素的最终位置,而一趟排序也许可以确定多个元素的最终位置。

例如下图,将左右两个子表都进行一次划分称为"一趟"排序,即对所有尚未确定最终位置的所有元素都进行一遍处理。而一次"划分",则是针对左子表或右子表的一次划分。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/bicheng/12386.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

2024小红书电商实战营,养号打造IP/选爆品/开店铺/爆款笔记/等等(24节)

我们非常荣幸地为大家带来2024小红书电商实战营的第一期&#xff0c;在这里我们将带领大家一起深入学习如何利用小红书平台&#xff0c;实现个人品牌的发展和商业利益的增长。 首先&#xff0c;我们将讨论养号的重要性以及如何打造个人品牌。无论是建立自己的受众群体还是提高…

微信小程序知识点归纳(一)

前言&#xff1a;适用于有一定基础的前端开发同学&#xff0c;完成从网页开发到小程序开发的知识转换。 先立框架&#xff0c;后砌墙壁 回顾&#xff1a;了解微信小程序开发流程-CSDN博客 初始页面结构&#xff0c;三部分pages、utils、配置&#xff0c;分别存放页面、工具类…

【解决】Unity Build 应用程序运行即崩溃问题

开发平台&#xff1a;Unity 2021.3.7f1c1   一、问题描述 编辑器 Build 工程结束&#xff0c;但控制台 未显示 Build completed with a result of Succeeded [时间长度] 信息。该情况下打包流程正常&#xff0c;但应用程序包打开即崩溃。   二、问题测试记录 测试1&#xf…

百面算法工程师 | 传统图像处理——OpenCV

本文给大家带来的百面算法工程师是传统图像处理的面试总结&#xff0c;文章内总结了常见的提问问题&#xff0c;旨在为广大学子模拟出更贴合实际的面试问答场景。在这篇文章中&#xff0c;我们将总结一些几何变换和图像平滑处理&#xff0c;并提供参考的回答及其理论基础&#…

C++自定义日期类的精彩之旅(详解)

在学习了C的6个默认成员函数后&#xff0c;我们现在动手实现一个完整的日期类&#xff0c;来加强对这6个默认成员函数的认识。 这是日期类中所包含的成员函数和成员变量&#xff1a; 构造函数 // 函数&#xff1a;获取某年某月的天数 inline int GetMonthDay(int yea…

常见磁盘分区问题

给磁盘分区有几个主要的原因&#xff1a; 组织和管理数据&#xff1a;分区可以帮助用户更好地组织和管理数据。例如&#xff0c;你可以在一个分区上安装操作系统&#xff0c;而在另一个分区上存储个人文件。这样&#xff0c;即使操作系统崩溃或需要重新安装&#xff0c;你的个…

Docker 使用 Fedora 镜像

Fedora 在 Docker 中的使用也非常简单&#xff0c;直接使用命令 docker run -it fedora:latest bash 就可以 pull 到本地的容器中并且运行。 C:\Users\yhu>docker run -it fedora:latest bash Unable to find image fedora:latest locally latest: Pulling from library/fed…

【瑞萨RA6M3】2. UART 实验

https://blog.csdn.net/qq_35181236/article/details/132789258 使用 uart9 配置 打印 void hal_entry(void) {/* TODO: add your own code here */fsp_err_t err;uint8_t c;/* 配置串口 */err g_uart9.p_api->open(g_uart9.p_ctrl, g_uart9.p_cfg);while (1){g_uart9.…

mysql的隔离性——MVCC

MVCC通过undolog版本链和readview来实现 更新和删除时会写入undolog中。 读已提交&#xff1a;在事务任意读时创建readview&#xff0c;读最新提交的事务 可重复读&#xff1a;在事务第一次读时创建readview

使用Caché管理工具

Cach通过一个web工具来对其进行系统管理和完成管理任务,该方法的一个好处是不必将Cach安装到用于管理的系统上。目前,通过网络远程管理和控制对站点的访问,这些都比较容易。因为数据及其格式信息都直接来自被管理的系统,因此,这也可以最小化跨版本的兼容问题。 本文将描述…

Kubernetes二进制(单master)部署

文章目录 Kubernetes二进制&#xff08;单master&#xff09;部署一、常见的K8S部署方式1. Minikube2. Kubeadmin3. 二进制安装部署4. 小结 二、K8S单&#xff08;Master&#xff09;节点二进制部署1. 环境准备1.1 服务器配置1.2 关闭防火墙1.3 修改主机名1.4 关闭swap1.5 在/e…

(done) 关于 pytorch 代码里常出现的 batch_first 到底是啥?

参考文章&#xff1a;https://pytorch.org/docs/stable/generated/torch.nn.utils.rnn.pad_sequence.html 首先看参考文章里的解释&#xff0c;如下图 从文章描述来看&#xff0c;当 batch_first True 时&#xff0c;输出的张量的 size 是 B x T x *。当 batch_first False…

umi搭建react项目

UMI 是一个基于 React 的可扩展企业级前端应用框架&#xff0c;提供路由、状态管理、构建和部署等功能&#xff0c;可以帮助开发者快速构建复杂的单页面应用&#xff08;SPA&#xff09;和多页面应用&#xff08;MPA&#xff09;。它与 React 的关系是&#xff0c;UMI 构建在 R…

移动端自动化测试工具 Appium 之 main 启动

文章目录 一、背景二、生成xml文件2.1、创建xml方法2.2、执行主类MainTest2.3、自动生成的xml2.4、工程目录2.5、执行结果 三、命令行执行appium服务四、主方法启动类五、集成Jenkins六、总结 一、背景 Jenkins 做集成测试是不错的工具&#xff0c;那么UI自动化是否可以&#…

图解自动驾驶中的运动规划(Motion Planning),附几十种规划算法

目录 1 自动驾驶驶向何处&#xff1f;2 什么是运动规划&#xff1f;3 运动规划实战教程4 加入我们5 订阅需知 1 自动驾驶驶向何处&#xff1f; 自动驾驶&#xff0c;又称无人驾驶&#xff0c;是依靠计算机与人工智能技术在没有人为操纵的情况下&#xff0c;完成完整、安全、有效…

2.1.2 事件驱动reactor的原理与实现

LINUX 精通 2 day14 20240513 day15 20240514 算法刷题&#xff1a;2维前缀和&#xff0c;一二维差分 耗时 135min 习题课 4h 课程补20240425 耗时&#xff1a;4h 课程链接地址 回顾 怎么学0voice课网络io——一请求一线程&#xff0c;一个client一个连接再accpet分配io f…

linux系统修改网卡名称

说明&#xff1a; 因操作过程需要停用网卡&#xff0c;导致ssh远程连接不上&#xff0c;需要控制台登录操作。 测试环境&#xff1a; CentOS7.9、8.2虚拟机 Suse15 SP4虚拟机 操作步骤&#xff1a; 方法一&#xff1a; 1、 查看网卡当前名称及状态 ip a2、 将网卡状态从启用…

RK3566(泰山派):GP7101背光驱动

RK3566&#xff08;泰山派&#xff09;&#xff1a;GP7101背光驱动 文章目录 RK3566&#xff08;泰山派&#xff09;&#xff1a;GP7101背光驱动GP7101背光驱动电路配置i2c1设备树创建驱动编写Makefilegp7101_bl.c驱动触摸I2C驱动框架。驱动中的结构体probe函数devm_backlight_…

过滤器Filter和拦截器Interceptor实现登录校验

一.过滤器 Filter过滤器可以把对资源的请求拦截下来&#xff0c;从而实现一些登录验证的功能 1.Filter的快速入门 1.定义Filter:定义一个类&#xff0c;实现Filter接口&#xff0c;并重写其所有方法。2.配置 public class dofilter implements Filter {Override //初始化只…