《数据结构(C语言版)》学习笔记09 排序

目录

各排序算法的比较

一、直接插入排序

①算法思想

②实现方式

③性能分析

二、希尔排序

①算法思想

②实现方式

③性能分析

三、冒泡排序

①算法思想

②实现方式

③性能分析

四、简单选择排序

①算法思想

②实现方式

③性能分析

五、快速排序

①算法思想

②实现方式

③性能分析

六、堆排序

①算法思想

②实现方式

③性能分析

七、归并排序

①算法思想

②实现方式

③性能分析


各排序算法的比较

排序方法时间复杂度

空间

复杂度

稳定性

链表

适用性

能否每次确定

元素最终位置

平均最好最坏
简单插入排序O(n^2)O(n)O(n^2)O(1)
希尔排序O(n^1.3~1.5)O(1)
冒泡排序O(n^2)O(n)O(n^2)O(1)
快速排序O(nlog2n)O(nlog2n)O(n^2)O(log2n)
简单选择排序O(n^2)O(1)
堆排序O(nlog2n)O(1)
归并排序O(nlog2n)O(n)
基数排序O(d*(n+r))O(r)

一、直接插入排序

①算法思想

每次将一个待排序元素按关键字大小插入到已排序的子序列中

②实现方式

举个例子方便理解,假设 A[6] = { 1, 5, 4, 2, 1, 3}。(下划线区分两个 1)

首先,默认第一个元素有序,要从前向后遍历,判断第 A[i] 和 A[i-1] 的大小,如果小(升序),则把它拿出来,插到相应的位置(即 A[i-1]<=A[i]<A[i+1],等号在效率中分析)。

比如

第一趟排序发现 5>1,不用操作。

第二趟排序发现 4<5,4>1,变成 {1, 4, 5, 2, 1, 3}

第三趟排序发现 2<5,2<4,2>1变成 {1, 2, 4, 5, 1, 3}

第四趟排序发现 1<5,1<4,1<2,1=1,变成{1, 1, 2, 4, 5, 3}

第五趟排序发现 3<5,3<4,3>2,变成{1, 1, 2, 3, 4, 5}

排序完成,可以发现插入排序是稳定的。

所谓稳定性是指,如果表中有相同元素,排序后并不改变它们出现的顺序。

即上例中,1 在 1 前面,排序完成后仍是如此。

代码比较简单:

void InsertionSort(int A[], int n){
    int i, j, temp;
    for(i=1; i<n; i++){//循环遍历
        if(A[i]<A[i-1]){//找到待排序元素
            temp = A[i];//暂存待排序元素
            for(j=i-1; j>=0 && A[j]>temp; j--){//向前循环遍历
                A[j+1] = A[j];//比待排序元素大的元素依次后移
            }//end for
            A[j+1] = temp;//退出循环后,j指向<=待排序元素的位置,所以+1
        }//end if
    }//end for
}

值得一提的是,在内层的 for 循环中,判断条件是 i>=0 或 A[j]>temp,这其中包含了三个信息:

其一,终止条件,要么是 A[i] 前的元素都比 temp 大, 最后 j==-1 退出循环;要么是遇到了一个 A[j] <= temp,但是退出循环后,j 是指向那个小于等于 temp 的元素的,也就是说,真正该插 temp 的位置在 j+1 的位置。

其二,刚才提到,即使 A[j]==temp,也会插到 j+1 的位置,所以如果原本数列中有两个相同的关键字,这样操作也不会改变它们的位置。

其三,这个循环体中做的事情是把大于 temp 的元素依次后移,所以最后 A[j+1] 的位置其实已经覆盖到 A[j+2] 了。

另外,除了上面的实现方法,插入排序还有使用哨兵的排序。

本质上没有什么区别,只是把 A[0] 的位置空出来用作哨兵,起的作用和上述方法的 temp 类似,请看代码:

void InsertionSortS(int A[], int n){
    int i, j;//区别1
    for(i=2; i<n; i++){//区别2
        if(A[i]<A[i-1]){
            A[0] = A[i];//区别3
            for(j=i-1; A[j]>A[0]; j--){//区别4
                A[j+1] = A[j];
            }
            A[j+1] = A[0];//区别5
        }
    }
}

代码逻辑和上面完全相同,所以简述有区别的地方:

区别1,不定义 temp,节省了一个变量空间,但是 A[0] 也不存数据了,牺牲了一个空间;

区别2,由于 A[0] 用作哨兵,所以真正的元素下标是从 1 开始的,默认 1 有序,所以从 2 开始循环;

区别3,哨兵;

区别4,最后一次循环,j=0,A[j] == A[0],所以会因为这一点退出循环,这是一定的,所以不需要判断 j>=0 了;

区别5,j+1 是应当插入的位置,插入值已经存在哨兵里了。

③性能分析

空间复杂度 O(1),来自于 i j temp 这几个变量的空间开销。数组是传入的,所以不算在里面。

时间复杂度复杂度最好情况为 O(n),即数组内元素已经有序,不进入内层循环,每次只在外层循环中执行一次 if 比较。

时间复杂度复杂度最坏情况为 O(n^2),即数组内元素是逆序,共需要 n-1 趟处理,第 i 趟处理中,又需要对比元素 i+1 次(i 次来自于循环条件,1 次来自于 if 判断条件),移动元素 i+2 次( i 次来自于循环内,2 次来自于赋值)。

平均时间复杂度为 O(n^2),可以两者相加取平均,也可以直接取高阶。

稳定性:稳定。前面已经讨论过,在移动元素的时候,只会把大于待排序元素关键字的元素后移,等于它的位置不变。所以最后待排序元素插入的位置正好是它相等元素的后面。

适用性:适合顺序表和链表,但如果优化成折半查找则只适合顺序表。

最后,可以对插入排序进行优化,寻找待排序元素插入位置的操作本质上就是在前面已经有序的子序列中进行查找,故该查找操作可以用折半查找(二分查找)进行。折半查找停止时,如果 low>high,则在 high 位置插入;如果查找到了对应元素,即 A[mid] = A[0] 或 temp,那么为了保证稳定性,应当插入 mid 位置的右边,也就是让折半查找继续进行。简而言之,只需要把折半查找的终止条件设定为 low>high,而不管查询到的情况,就可以达成上述目的。还需注意,折半查找退出后,high < low 且 high+1 == low,这是一定的。另外,优化为折半查找之后,还是会有 n-1 次循环,循环中需要 log2(n) 数量级的关键字对比和 n 数量级的元素移动,所以 n*(n+log2(n)) ≈ n^2,时间复杂度没变(实际上可能好一点点)。

王道课件

二、希尔排序

①算法思想

用增量 d 将表分为若干个子表,再对每个子表进行排序,排序完成后缩小增量 d 继续排序,直至 d=1。例如 d=3,子表1下标为 0,3,6,...,子表2下标为 1,4,7,...,子表3下标为 2,5,8,...
一般第一趟排序让 d=n/2,d(n-1)=dn/2

②实现方式

举个例子比较好理解,取 a[7] = { 7, 6, 5, 4, 3, 1, 1}

d1= n/2 = 3,则分成了三个子表:

子表 L1 的元素对应 a 中下标为 0 3 6,即 L1 = { 7, 4, 1};

子表 L2 的元素对应 a 中下标为 1 4,即 L2 = { 6, 3};

子表 L3 的元素对应 a 中下标为 2 5,即 L3 = { 5, 1};

分别对三个子表进行排序,L1 = { 1, 4, 7},L2 = { 3, 6},L3 = { 1, 5},然后把三个表放回原数组对应位置,a = { 1, 3, 1, 4, 6, 5, 7}。

d2= d1/2 = 1,即执行一轮直接插入排序,结果必然有序。

希尔排序的目的是,在最后执行插入排序之前,让整个数组基本有序,使得每轮比较关键字次数和移动元素次数变少,以此优化效率。

再举个三轮的例子吧,取 a[8] = { 7, 4, 5, 1, 2, 1, 3, 6}

d1= n/2 = 4,分成四个子表:

子表 L1 的元素对应 a 中下标为 0 4,即 L1 = { 7, 2};

子表 L2 的元素对应 a 中下标为 1 5,即 L2 = { 4, 1};

子表 L3 的元素对应 a 中下标为 2 6,即 L3 = { 5, 3};

子表 L4 的元素对应 a 中下标为 3 7,即 L4 = { 1, 6};

分别对四个子表进行排序, L1 = { 2, 7},L2 = { 1, 4},L3 = { 3, 5},L4 = { 1, 6},然后把四个表放回原数组对应位置,a = { 2, 1, 3, 1, 7, 4, 5, 6}。

d2=d1/2 = 2,分成两个子表:

子表 L1 的元素对应 a 中下标为 0 2 4 6,即 L1 = { 2, 3, 7, 5};

子表 L2 的元素对应 a 中下标为 1 3 5 7,即 L2 = { 1, 1, 4, 6};

分别对两个子表进行排序,L1 = { 2, 3, 5, 7},L2 = { 1, 1, 4, 6},然后把两个表放回原数组对应位置,a = {2, 1, 3, 1, 5, 4, 7, 6}。

d3 = d2/2 = 1,插入排序,结束。

可以发现上述两个例子都把两个 1 的位置颠倒了,可见希尔排序是不稳定的。

另外书上的代码下标是从 1 开始的,把 A[0] 用作临时存储了。

void ShellSort(int A[], int n){
    int i, j, d;
    for(d=n/2; d>=1; d/=2){//增量序列每次除以2
        for(i=d+1; i<n; i++){//循环遍历后半个序列
            if(A[i]<A[i-d]){//比较和A[i]同子列的前一个元素关键字大小
                A[0] = A[i];//暂存要替换的值
                for(j=i-d; j>=0 && A[j]>A[0]; j-=d){//同子列中元素移动
                    A[j+d] = A[j];
                }//end for
                A[j+d] = A[0];//对应位置插入
            }//end if
        }//end for
    }//end for
}

还需注:在第二层循环 i 的终止条件的位置,王道课件中的代码写的是 i<=n,书上写的是 i<=L.length,我写的是 i<n。如果 n 是数组长度,那就用等于号,如果 n 是最大下标即定义的时候中括号里写的那个数,就用小于号。

另外,分析这个代码的执行过程,会发现它并不是按照上面我们分析的那样,对每个子表单独处理,然后拼到一起,而是交替处理几个子表,把上面的例子再拿过来理解一下:(红色是 i 所指元素,紫色是同子列元素)

  • 第一步:d=4
  • 处理前:{ 7, 4, 5, 1, 2, 1, 3, 6},L1 = { 7, 2};L2 = { 4, 1};L3 = { 5, 3};L4 = { 1, 6};
  • 处理后:{ 2, 4, 5, 1, 7, 1, 3, 6},L1 = { 2, 7};L2 = { 4, 1};L3 = { 5, 3};L4 = { 1, 6};
  • 第二步:d=4
  • 处理前:{ 2, 4, 5, 1, 7, 1, 3, 6},L1 = { 2, 7};L2 = { 4, 1};L3 = { 5, 3};L4 = { 1, 6};
  • 处理后:{ 2, 1, 5, 1, 7, 4, 3, 6},L1 = { 2, 7};L2 = { 1, 4};L3 = { 5, 3};L4 = { 1, 6};
  • 第三步:d=4
  • 处理前:{ 2, 4, 5, 1, 7, 1, 3, 6},L1 = { 2, 7};L2 = { 1, 4};L3 = { 5, 3};L4 = { 1, 6};
  • 处理后:{ 2, 1, 3, 1, 7, 4, 5, 6},L1 = { 2, 7};L2 = { 1, 4};L3 = { 3, 5};L4 = { 1, 6};
  • 第四步:d=4
  • 处理前:{ 2, 1, 3, 1, 7, 4, 5, 6},L1 = { 2, 7};L2 = { 1, 4};L3 = { 3, 5};L4 = { 1, 6};
  • 处理后:{ 2, 1, 3, 1, 7, 4, 5, 6},L1 = { 2, 7};L2 = { 1, 4};L3 = { 3, 5};L4 = { 1, 6};
  • 第五步:d=2
  • 处理前:{ 2, 1, 3, 1, 7, 4, 5, 6}, L1 = { 2, 3, 7, 5};L2 = { 1, 1, 4, 6};
  • 处理后:{ 2, 1, 3, 1, 7, 4, 5, 6}, L1 = { 2, 3, 7, 5};L2 = { 1, 1, 4, 6};
  • 第六步:d=2
  • 处理前:{ 2, 1, 3, 1, 7, 4, 5, 6}, L1 = { 2, 3, 7, 5};L2 = { 1, 1, 4, 6};
  • 处理后:{ 2, 1, 3, 1, 7, 4, 5, 6}, L1 = { 2, 3, 7, 5};L2 = { 1, 1, 4, 6};
  • 第七步:d=2
  • 处理前:{ 2, 1, 3, 1, 7, 4, 5, 6}, L1 = { 2, 3, 7, 5};L2 = { 1, 1, 4, 6};
  • 处理后:{ 2, 1, 3, 1, 7, 4, 5, 6}, L1 = { 2, 3, 7, 5};L2 = { 1, 1, 4, 6};
  • 第八步:d=2
  • 处理前:{ 2, 1, 3, 1, 7, 4, 5, 6}, L1 = { 2, 3, 7, 5};L2 = { 1, 1, 4, 6};
  • 处理后:{ 2, 1, 3, 1, 7, 4, 5, 6}, L1 = { 2, 3, 7, 5};L2 = { 1, 1, 4, 6};
  • 第九步:d=2
  • 处理前:{ 2, 1, 3, 1, 7, 4, 5, 6}, L1 = { 2, 3, 7, 5};L2 = { 1, 1, 4, 6};
  • 处理后:{ 2, 1, 3, 1, 5, 4, 7, 6}, L1 = { 2, 3, 5, 7};L2 = { 1, 1, 4, 6};
  • 第十步:d=2
  • 处理前:{ 2, 1, 3, 1, 5, 4, 7, 6}, L1 = { 2, 3, 5, 7};L2 = { 1, 1, 4, 6};
  • 处理后:{ 2, 1, 3, 1, 5, 4, 7, 6}, L1 = { 2, 3, 5, 7};L2 = { 1, 1, 4, 6};

这样应该能看出怎么交替处理子列了吧。在对每个子列的处理过程中,其实就是一次插入排序,但是简单插入排序中增量序列为 1,这里每次增量序列为 d,仅此而已。

③性能分析

空间复杂度 O(1),来自于常数个变量。

时间复杂度无法确切证明,最坏为 O(n^2),n 在某个范围内可达 O(n^1.3)

稳定性:不稳定。上述已经讨论过了。

适用性:仅适用顺序表,不适用于链表。因为链表无法按照增量序列分块。

三、冒泡排序

①算法思想

每一趟从前往后(或从后往前)两两比较元素的值,如果是逆序,则交换两元素位置。共需执行 n-1 趟,当剩余元素已经有序(没有发生交换)时可以提前结束

②实现方式

这个比较好理解,还是举个例子,对于 { 7, 4, 5, 1, 2, 1, 3, 6} 的冒泡排序(从后往前):

首先,第一轮循环(第一趟),从后往前两两对比元素关键字大小,6>3,不需要处理;3>1,不需要处理;1<2,需要处理,交换他俩的位置,变成 { 7, 4, 5, 1, 2, 1, 3, 6}。

然后还在第一轮循环,还没走到头呢,2>1,不需要处理;1<5,需要处理,交换他俩的位置,变成 { 7, 4, 1, 5, 2, 1, 3, 6}。

继续第一轮,1<4,换,{ 7, 1, 4, 5, 2, 1, 3, 6}。

还在第一轮,1<7,换,{ 1, 7, 4, 5, 2, 1, 3, 6}

没有下一个元素了,至此第一趟排序完成,可以发现,经过这轮排序,最头的一个元素关键字一定是最小的,因为每次交换都是把更小的那个拿到前面去。{ 1, 7, 4, 5, 2, 1, 3, 6},用红色表示已经有序的子序列。

第二轮循环,从后往前,6>3,不用管;3>1,不用管;1<2,需要交换,变成 { 1, 7, 4, 5, 1, 2, 3, 6}。

接着还在第二轮循环,应该不难发现,1<5,1<4,1<7,所以把 1 连换三次换到了 7 前面,而 1=1,所以不用交换两个相同元素的位置,保证了稳定性。即 { 1, 1, 7, 4, 5, 2, 3, 6}(偷个懒),此时前两位肯定有序了,因为第二轮循环把除了 1 之外的最小的元素放到了最前面。标个颜色 { 1, 1, 7, 4, 5, 2, 3, 6}

然后是第三轮循环,6>3 不动,3>2 不动,2<5,2<4,2<7,2>1,所以 2 经过三次交换到了 7 的前面,1 的后面。即{ 1, 1, 2, 7, 4, 5, 3, 6},此时前三位肯定有序,原因同上。标个颜色 { 1, 1, 2, 7, 4, 5, 3, 6}。

第四轮循环,6>3 不动,3<5,3<4,3<7,3>2 所以 3 经过三次交换到了 7 的前面。即 { 1, 1, 2, 3, 7, 4, 5, 6}。前四位有序,标个颜色 { 1, 1, 2, 3, 7, 4, 5, 6}。

第五轮循环,6>5 不动,5>4 不动,4<7,交换,{ 1, 1, 2, 3, 4, 7, 5, 6}。前五位有序,标个颜色 { 1, 1, 2, 3, 4, 7, 5, 6}。

第六轮循环,6>5 不动,5<7 交换,{ 1, 1, 2, 3, 4, 5, 7, 6}。前六位有序,标个颜色 { 1, 1, 2, 3, 4, 5, 7, 6}。

第七轮循环,6<7 交换,{ 1, 1, 2, 3, 4, 5, 6, 7}。前七位有序,标个颜色 { 1, 1, 2, 3, 4, 5, 6, 7}。

第八轮不需要进行了,因为就剩一个,肯定是最大的那个。这也是为什么只需要 n-1 轮循环而不需要 n 轮的原因了。

另外,不难发现,第一轮(i=0) 循环前,前 0 位是有序的,第二轮 (i=1) 循环前,前 1 位是有序的。也就是说,第 i+1 轮循环(循环变量等于 i),前 i 位是有序的。而有序子序列的最后一个元素下标为 i-1。

而每一轮排序的时候,对于已经有序的序列,是不需要对比的,它们中最大的那个也一定是小于等于当前待排序序列中最小的那个关键字的(前一轮循环决定了最小的元素跑到最前面)。当 j=i 的时候,会对比 A[j] 和 A[j-1],但是 A[j-1] 已经在有序子序列中了,所以不需要这一步对比,即每一趟循环只需要对比到 j>i 即可。

还有,上例是完完整整地进行了 n-1 次循环,考察下例:

{ 2, 3, 4, 1}

第一轮循环毫无疑问会将 1 换到最前面,即 { 1, 2, 3, 4};

第二轮循环,会依次比较 (3,4), (2,3), (1,2),但并不会发生任何交换,因为整个序列已经有序了,此时冒泡排序就可以提前结束。因而说“序列有序”和说“不发生交换”其实是等价的。故可以在函数中设置一个标志位,用来指示是否发生过交换,如果没有,则排序完成,不需要继续循环。        

void BubbleSort(int A[], int n){
    int i,j, temp;
    for(i=0; i<n-1; i++){//外层控制循环次数
        bool flag = false;//交换标志位
        for(j=n-1; j>i; j--){//从最后一个元素开始,不对比有序子列
            if(A[j]<A[j-1]){//交换元素
                temp = A[j];
                A[j] = A[j-1];
                A[j-1] = temp;
                flag = true;
            }//end if
        }//end for
        if(flag==false) return ;//全部有序则退出
    }//end for
}

③性能分析

空间复杂度 O(1),来自于常数个变量。

最好时间复杂度 O(n),即原本有序,只需对比 n-1 次关键字,没有交换。

最坏时间复杂度 O(n^2),即原本逆序,需要 n-1 次循环,第 i 次循环会对比关键字 i 次,每次交换两个元素,总交换次数 n(n+1)/2,而移动元素的次数则需要在此基础上乘以 3。

平均时间复杂度 O(n^2),取高阶。

稳定性:稳定

适用性:顺序表和链表都适用

四、简单选择排序

①算法思想

每一趟在待排序元素中选择最小的加入有序子列(“加入”有序子列是通过交换元素实现的)。

②实现方式

简单选择排序之所以叫这个名字,确实是因为挺简单的。

首先明确,要将一个无序数组排成有序数组,每轮又要找到最小的那个元素。所以一共需要 n-1 轮循环,因为进行 n-1 轮排序后,最后一个剩下的元素必然是最大的。

另外,第 1 轮循环,找到最小的元素后要放到数组下标为 0 的位置,以此类推,第 i+1 轮循环,找到的最小元素要放到数组下标为 i 的位置。这就要求找到最小元素的下标,然后交换它和下标为 i 的元素的位置。

void SelectionSort(int A[], int n){
    int i, j, temp;
    for(i=0; i<n-1; i++){//外层控制循环次数
        int min=i;//最小值下标
        for(j=i+1; j<n; j++){//找最小值
            if(A[j]<A[min]) min=j;
        }//end for
        if(min!=i){//如果最小值下标不是 i,则交换元素
            temp = A[i];
            A[i] = A[min];
            A[min] = temp;
        }//end if
    }//end for
}

我觉得要点有两个:

其一,内层循环 j 从 i+1 开始,因为是从 i 下标位置后面找最小元素,如果没找到,那么 min 的数值不会被更新,仍然等于 i;

第二,也正因此,当 min 仍等于 i 的时候,认为后面元素都比 A[i] 大,那么 A[i] 就是最小的元素,其存储位置就是顺序的位置,所以不发生交换。只有当找到了一个下标不是 i 的元素的时候,才要交换。

③性能分析

空间复杂度 O(1),来自常数个变量。

时间复杂度 O(n^2),这里不分好坏,因为不论是正序还是逆序,总循环次数是一样的,找最小值比较的次数是一样的。第 i+1 次循环(循环变量等于 i),需要对比元素的次数为 n-i-1(计算方法(n-1)-(i+1)+1,从下标 i+1 到下标 n-1 有多少个元素),所以把 i 从 0 到 n-2(共 n-1 轮循环)加起来即可得到 n^2 数量级的数。

稳定性:不稳定。考察这样一个例子:{2, 2, 1},第一轮循环会找到最小的 1 和 2 交换,变成 {1, 2, 2},后续过程不会再交换元素了,所以排序结束后两个 2 的顺序发生了变化。

适用性:既可用于顺序表,也可用于链表。链表中,需要用一个指针控制总遍历 i 指向的元素,另一个元素遍历剩下结点,找到最小的一个,然后交换两个结点。

五、快速排序

①算法思想

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

看起就不太好理解,所以我尝试着自己解释了一下,人话版:快速排序每轮的目的是,对于选取的位置为 pivotpos 的元素,在此轮排序中确定它的最终位置。即它左边的元素关键字都比它小,它右边的元素关键字都比它大。为此,在每轮排序中,需要从表的右边挑小的元素放到 pivotpos 左边,从左边挑大的元素放到 pivotpos 右边。一轮排序后,整个表被 pivot 位置分成两部分,左侧和右侧还要分别递归进行上述过程,直到所有元素的位置都被确定。

②实现方式

举例子,{ 4, 7, 5, 1, 2, 1, 3, 6},先形象化地理解一下算法执行过程。

首先,第一轮的枢轴位置也即 pivotpos 取 0,对应的枢轴元素 pivot 就是 4。

注意这个 pivot 是实际出现的变量,作用一部分相当于前面的 temp,一方面起到了暂存数据的作用(另一方面就是分割数组)。另外,pivotpos 在逻辑上其实是不断向右移动的(见注*),这就要求从右边找较小的元素填充到左边。而右边较小的元素放到左边之后,右边空出的位置则需要左边较大的元素填充。

(注*:pivotpos 逻辑上指向 pivot 的位置,而在代码中,一轮排序结束之前 pivotpos 物理上始终等于 0,但是 pivot 最终确定的位置会随着比它小的元素的左移向右移动,个人谓之“逻辑上”)

pivot=4,且在一轮循环中的值不变。相当于把 A[0] 的元素暂存至 pivot,那么 A[0] 算是空出来了,需要往这个位置放比 pivot 小的元素,从最右边开始找,6>4,3<4,于是把 3 放到 A[0],变成 { 3, 7, 5, 1, 2, 1,  , 6}。

接着,3 原来的位置空出来了(黄色标记),此时需要从左边找比 pivot 更大的元素放到这个位置,而 3 已经比 pivot 小了,所以从 3 的下一个位置开始。7>4,于是把 7 放到黄色位置,变成 { 3,  , 5, 1, 2, 1, 7, 6}。

7 原来的位置空出来了,此时需要从右边找比 pivot 更小的元素放到这个位置上,而 7 已经比 pivot 大了,所以从 7 的前一个位置开始。1<4,于是把 1 放到黄色位置,变成 { 3, 1, 5, 1, 2,  , 7, 6}。

1 原来的位置空出来了,此时需要从左边找比 pivot 更大的元素放到这个位置上,而 1 已经比 pivot 小了,所以从 1 的后一个位置开始。5>4,于是把 5 放到黄色位置,变成 { 3, 1,  , 1, 2, 5, 7, 6}。

5 原来的位置空出来了,此时需要从右边找比 pivot 更小的元素放到这个位置上,而 5 已经比 pivot 大了,所以从 5 的前一个位置开始。2<4,于是把 2 放到黄色位置,变成 { 3, 1, 2, 1 , 5, 7, 6}。

2 原来的位置空出来了,此时需要从左边找比 pivot 更大的元素放到这个位置上,而 2 已经比 pivot 小了,所以从 2 的后一个位置开始。1<4,再往后就没了,所以此时的黄色位置便是 pivot 最终的位置,并且也是整个排序最终的位置,变成{ 3, 1, 2, 1, 4, 5, 7, 6}。

第二轮中,由于 4 的位置已经确定,它将整个数组分割成了 { 3, 1, 2, 1} 和 {5, 7, 6} 两部分。对这两部分都取 pivotpos=0,会依次确定 3 和 5 的最终位置,即 { 1, 2, 1, 3}(注意两个 1 交换了位置,因为从右开始依次放到左边的元素是 1,2,1)和 {5, 7, 6}(没变)。

第三轮中,4 3 5 三个元素位置确定,又将数组分割成 { 1, 2, 1} {3} {4} {5} {7, 6},只需对 { 1, 2, 1} 和 {7, 6} 两部分排序,都去 pivotpos=0,会依次确定 1 和 7 的最终位置,即 { 1, 2, 1}(没变)和 {6, 7}。

第四轮中,4 3 5 1 7 的位置确定,又将数组分割成 {1} {2, 1} {3} {4} {5} {6} {7},{6}虽然没排,但它所在的子列显然有序了。故只剩下 {2, 1},处理后 2 的位置确定,整个数组被分割成 {1} {1} {2} {3} {4} {5} {6} {7}。不需要对任何子列进行处理了,所以排序结束。

可以看出,上面从左边找大于 pivot 的元素的过程是一直向右进行,并且不回头的,所以可以用一个 low 指针指向左边的元素,low 指针依次右移来找小元素;同样,从右边找大于 pivot 的元素过程也可以用一个 high 指针来指示。而 low 指向左边的较大元素,high 指向左边的较小元素,当较小的元素换到左边时,high 所指位置为空(逻辑上);当较大的元素换到右边时,low 所指位置为空(逻辑上)。

先看负责处理单轮排序的代码:

int Partition(int A[], int low, int high){
    int pivot = A[low];//pivot取子列最左边元素
    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];//把较大元素放到右边
    }//end while, low==high
    A[low] = pivot;//pivot 放到最终位置
    return low;//把确定的位置返回,用来递归对左右子列排序
}

这个函数的名字叫“划分”,比较专业,知道就行。

首先,pivot=A[low],在第一轮中为 0,在后续的每轮中为待排序子列的第一个元素。

然后,while 内部大致应该能看明白,就是从右边找小的放到左边,从左边找小的放到右边。

需要明白的是,当 while 循环退出时,low==high 并且指向 pivot 最终应该存放的位置。

可以分为如下两种情况(注意,前提是后续不发生元素交换了):

其一,最近一次交换是把左边较大的元素放到右边。此时 low 还指向那个较大的元素,而 high-- 的过程中,没有比 pivot 更小的元素,最终会导致 high-- 到等于 low,循环退出。

其二,最近一次交换是把右边较小的元素放到左边。此时 high 还指向那个较小的元素,而 low++ 的过程中,没有比 pivot 更大的元素,最终会导致 low++ 到等于 high,循环退出。

最后两句代码,不论是 A[low] = pivot; return low 或是 A[high] = pivot; return high 哪怕是混着写,都是一样的。

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);
    }
}

然后是外层控制递归的代码部分,Partition 函数返回上一轮排序确定的元素的位置,由此会把数组从 pivotpos 位置划分成左右两半,再分别对左右部分进行划分、排序即可。 

③性能分析

分析一下,不难发现,空间复杂度主要来自于递归工作栈,就是递归深度的数量级。而时间复杂度,在每一轮排序的时候,处理的元素不超过 n 个(上例中第一轮处理 n 个,第二轮 n-1 个,第三轮 n-3 个...但是没有固定规律),一共排序的次数也是递归深度数量级的。所以需要求递归深度。

其实可以发现,整个排序过程是递归分成左子列和右子列的,这和二叉树的构成很相似,因此,可以建立算法分析树。还拿上面的例子举例子:{ 4, 7, 5, 1, 2, 1, 3, 6}。

第一轮,要确定的是 4 的位置,则以 4 为根结点,最终排序的结果为 { 3, 1, 2, 14, 5, 7, 6},左子树为 {3, 1, 2, 1},右子树为 {5, 7, 6},构建树(不知道为啥我本地图片有下划线传上来没了,所以我手画了一下):

第二轮,要确定的是 3 和 5 的位置,以 3 和 5 为根结点,最终排序的结果为 { 1, 2, 1, 3, 4, 5, 7, 6},3 的左子树为 {1, 2, 1},5 的右子树为 {7, 6},构建树(下划线画的挺直):

以此类推,最终:

既然都画成树了,就可以知道树高即为递归深度且 n 个结点的树最小高度为 ⌊log2(n)⌋+1,最大高度为 n。反过来推一下,什么样的序列才可以让树高最小?即每次的 pivot 最终位置都在子列的最中间,也就是 pivot 的取值接近子序列的中位数。那么最差的情况,就是顺序或者逆序,每次 pivotpos 都只是将子列划分成为长度为 0 和 n-1 的子列而已,树就会朝着左边或者右边一个方向延申。

所以结论:

最好空间复杂度 O(log2(n)),pivot 取中位数,来自递归工作栈。

最坏空间复杂度 O(n),正序或逆序,来自于递归工作栈。

最好时间复杂度 O(nlog2(n)),pivot 取中位数,别忘了除了递归深度还有每层递归处理的不大于 n 个的元素。

最坏时间复杂度 O(n^2),正序或逆序。

平均时间复杂度 O(nlog2(n)),至于为啥不取高阶,一开始就有序的数列还是少,另外也是给“快速”俩字一个面子。

稳定性:不稳定,前面例子有佐证。

书上没讨论快排的适用性。但是原理上链表可以(没试过)。

六、堆排序

①算法思想

首先,什么是堆?就不照搬定义了,我的概括是:构建一棵完全二叉树,结点中的元素在数组中的存储下标就等于结点在完全二叉树中的编号。这里是放弃了 0 下标的,即数据元素从数组下标 1 开始。

举个例子:

图自 王道课件

如图所示,右边是一棵完全二叉树,其结点中的关键字是有一定的大小关系的,先不管这个关系是什么。按照完全二叉树的定义,可以按照层序将每个结点编号(这里是从 1 开始)。

关于完全二叉树的内容,请参考:

《数据结构(C语言版)》学习笔记6 树_学生罢了的博客-优快云博客数据结构中树的介绍,包含普通的树、二叉树、森林的存储方式以及相互之间的转换,另含树和森林的遍历,二叉树的线索化,哈夫曼树等https://blog.youkuaiyun.com/vv0610_/article/details/126799910于是从这棵树中,可以知道 87 编号为 1,45 编号为 2,78 编号为 3....

按照这些编号,可以组织成为一个数组,即左侧的数组,0 下标空出来,1 下标存 87,2 下标存45,3 下标存 78...

还得明确一下,堆是左边的那个数组,其逻辑结构可以理解成右边的完全二叉树,堆不是树。

那么什么是大根堆什么是小根堆呢?

大根堆指的是,下标为 i 的元素关键字大于下标为 2i 的关键字和 2i+1 的关键字,根据完全二叉树的性质可以知道,编号为 i 的结点左孩子为 2i,右孩子为 2i+1,所以从逻辑视角来看,大根堆指的就是根结点元素关键字大于左、右子树根结点元素关键字。

反之,小根堆指的就是根结点元素关键字小于左、右子树根结点元素关键字。

另外,在完全二叉树中,i<=⌊n/2⌋ 为分支结点(非终端结点),i>⌊n/2⌋ 为叶子结点,其中 n 为结点个数(堆长度)

进入正题:

算法思想:排序是针对堆进行的,所以需要先把待排序数组调整为大/小根堆。然后,每次把堆顶元素和堆底元素互换位置(大根堆),此时待排序子列中最大的元素被置于堆底的有序子列中(升序),再将待排序子列重新调整为大根堆,并重复上述过程直至所有元素有序

②实现方式

思想还是比较抽象哈,我们一个一个说,首先从调整数组为大根堆开始。

调整为堆的思想为:对每个非终端(叶)结点,判断其是否符合大根堆要求(根>左、右),如果不满足,则选择它两个孩子中最大的一个,并和它交换位置。结点调整到新位置后,需要继续判断是否满足大根堆条件。

思想中已经明确,调整是针对非终端结点进行的(i<=⌊n/2⌋),但是应该从前往后调?还是从后往前调?原理上,从后往前排,会把小的元素先往后放,并且这个往后放的长度是比较短的。反之,如果从前往后排,则每次需要比较的次数会更多(个人理解)。这个不理解也没关系,理论上来说从前排和从后排差别不会特别大,记住这里是从后往前调即可。

举个例子:① 53 ② 17 ③ 78 ④09 ⑤ 45 ⑥ 65 ⑦ 87 ⑧ 32(圈中是对应元素下标,从1开始)

图自 王道考研

n/2=8/2=4,所以从 4 号,也即最后一个非终端结点开始调整。

④号 09,左孩子编号为 2*4=8, ⑧号 32;右孩子编号为 2*4+1=9,没有右孩子。32>9,所以④号和⑧号要互换位置,即 ① 53 ② 17 ③ 78 ④ 32 ⑤ 45 ⑥ 65 ⑦ 87 ⑧ 09

图自 王道考研

但是到了这一步还没有结束,09 到了新位置,其编号为 ⑧,还需看它是否小于新的孩子,好在左孩子编号 16,右孩子编号 17,已经超出堆长度了,所以对 09 的调整就结束了。

然后是③号 78,左孩子编号为 2*3=6,⑥号 65;右孩子编号为 2*3+1=7,⑦号 87。87>65 且 87>78,所以③号要和⑦号互换位置,即 ① 53 ② 17 ③ 87 ④ 32 ⑤ 45 ⑥ 65 ⑦ 78 ⑧ 09。

图自 王道考研

78 到了⑦号之后,同样没有新的左右孩子,所以 78 调整结束。

然后是②号17,左孩子编号为 2*2=4,④号 32;右孩子编号为 2*2+1=5,⑤号 45。45>32 且 45>17,所以要调整②号和⑤号的位置,即 ① 53 ② 45 ③ 87 ④ 32 ⑤ 17 ⑥ 65 ⑦ 78 ⑧ 09。

图自 王道考研

17 到了⑤号之后,同样没有新的左右孩子,所以 17 调整结束。

然后是①号53,左孩子编号为 2*1=2,②号45;右孩子编号为 2*1+1=3,③号87。87>45 且 87>53,所以要调整①号和③号的位置,即 ① 87 ② 45 ③ 53 ④ 32 ⑤ 17 ⑥ 65 ⑦ 78 ⑧ 09。

但是当 53 到了③号之后,其左孩子编号为 2*3=6,⑥号 65;右孩子编号为 2*3+1=7,⑦号78。78>65 且 78>53,所以③号和⑦号还要接着互换,即 ① 87 ② 45 ③ 78 ④ 32 ⑤ 17 ⑥ 65 ⑦ 53 ⑧ 09。

图自 王道考研

至此,所以非终端结点处理结束,就把一个数组调整为了一个大根堆。会发现每个结点关键字都大于它的左右子树根结点关键字。

调整的代码如下:

void BuildMaxHeap(int A[], int len){//建立大根堆
    for(int i=len/2; i>0; i--){//对每个非终端结点进行调整
        HeapAdjust(A, i, len);//对第 i 个元素进行调整
    }
}

void HeapAdjust(int A[], int k, int len){
    A[0] = A[k];//A[0]用来暂存,起到temp的作用
    for(int i=2*k; i<=len; i*=2){//定位到当前结点的左孩子
        if(i<len&&A[i]<A[i+1]){//如果有两个孩子且右孩子大于左孩子
            i++;//i指向右孩子
        }//end if
        if(A[0]>A[i]) break;//如果最大的孩子都小于根结点则退出循环
        else{//否则需要发生替换
            A[k] = A[i];//用k结点最大的孩子i替换k
            k = i;//待调整结点的新位置为 i
        }//end if
    }//end for
    A[k] = A[0];//把待调整结点放到位置k
}

结合注释和上面的例子,应该不难理解。BuildMaxHeap函数的作用是将一个数组转换成堆,其函数内部对每个非终端结点调用HeapAdjust,将结点调整到该去的位置。HeapAdjust中,外层循环用来控制当前层数,i*=2 代表进入下一层(编号为 i 的结点的左孩子)。第一个 if 中,i<len 代表左孩子存在,而 i<len 的等价条件为 i+1<=len,代表右孩子存在;同时,A[i]<A[i+1],如果左孩子小于右孩子,那么执行 i++,找到更大的孩子(右孩子)的编号。如果左孩子大于右孩子,则不会进第一个 if,i 仍然是左孩子的编号。第二个 if,A[i] 此时是左右孩子中最大的那个,如果最大的都小于根结点 A[k],则不需要调整,break退出循环。否则,用编号 i 的更大的结点覆盖编号 k 的结点。同时,k 指向 i,代表待调整结点被交换到 i 位置上,方便下一步循环继续对比。以上循环结束后,将 A[0] 放到编号 k 的位置上,调整结束。

随后,就可以进行堆排序了。

排序的思想是:

每次把堆顶元素和堆底元素互换位置(大根堆),此时待排序子列中最大的元素被置于堆底的有序子列中(升序),再将待排序子列重新调整为大根堆,并重复上述过程直至所有元素有序。

分析一下,首先,一个大根堆堆顶,也就是数组①号位置的元素一定是最大的,那么升序排序要求由小到大排,所以最大的元素在最后,就可以将堆顶和堆底元素互换。此时,堆的最后一个元素是有序的(只有它自己)。随后,将前面的 n-1 个元素重新调整为大根堆(其实只需要对刚换过去的堆顶元素进行调整即可),再重复上述步骤,将新的堆顶元素换到堆底(堆此时已经不包括最后一个有序元素了,所以我们将堆的部分称为待排序子列)。

刚才调整好的堆长这样:① 87 ② 45 ③ 78 ④ 32 ⑤ 17 ⑥ 65 ⑦ 53 ⑧ 09。

第一步,将①号和⑧号互换,① 09 ② 45 ③ 78 ④ 32 ⑤ 17 ⑥ 65 ⑦ 53 ⑧ 87。此时,87 已经在它的最终位置了,所以后续调整和它无关,认为这部分已经有序。那么接着将①号 09 调整到堆中正确的位置,即 ① 78 ② 45 ③ 65 ④ 32 ⑤ 17 ⑥ 09 ⑦ 53 ⑧ 87

第二步,将①号和⑦号互换,① 53 ② 45 ③ 65 ④ 32 ⑤ 17 ⑥ 09 ⑦ 78 ⑧ 87。78 的最终位置确定,加入了有序部分。此时还需调整 53 的位置,即 ① 65 ② 45 ③ 53 ④ 32 ⑤ 17 ⑥ 09 ⑦ 78 ⑧ 87

第三步,将①号和⑥号互换,① 09 ② 45 ③ 53 ④ 32 ⑤ 17 ⑥ 65 ⑦ 78 ⑧ 87。65 的最终位置确定,加入了有序部分。此时还需调整 09 的位置,即① 53 ② 45 ③ 09 ④ 32 ⑤ 17 ⑥ 65 ⑦ 78 ⑧ 87

第四步,将①号和⑤号互换,① 17 ② 45 ③ 09 ④ 32 ⑤ 53 ⑥ 65 ⑦ 78 ⑧ 87。53 的最终位置确定,加入了有序部分。此时还需调整 17 的位置,即 ① 45 ② 32 ③ 09 ④ 17 ⑤ 53 ⑥ 65 ⑦ 78 ⑧ 87

第五步,将①号和④号互换,① 17 ② 32 ③ 09 ④ 45 ⑤ 53 ⑥ 65 ⑦ 78 ⑧ 87。45 的最终位置确定,加入了有序部分。此时还需调整 17 的位置,即 ① 32 ② 17 ③ 09 ④ 45 ⑤ 53 ⑥ 65 ⑦ 78 ⑧ 87

第六步,将①号和③号互换,① 09 ② 17 ③ 32 ④ 45 ⑤ 53 ⑥ 65 ⑦ 78 ⑧ 87。32 的最终位置确定,加入了有序部分。此时还需调整 09 的位置,即 ① 17 ② 09 ③ 32 ④ 45 ⑤ 53 ⑥ 65 ⑦ 78 ⑧ 87

第七步,将①号和②号互换,① 09 ② 17 ③ 32 ④ 45 ⑤ 53 ⑥ 65 ⑦ 78 ⑧ 87。17 的最终位置确定,加入了有序部分。此时,数组中只剩下一个元素,它必然是最小的,所以整个数组也构成有序。故结果为 ① 09 ② 17 ③ 32 ④ 45 ⑤ 53 ⑥ 65 ⑦ 78 ⑧ 87

上述过程中,八个元素一共只进行了七步排序,原因在于第 n-1 次排序完成后,只剩下 1 个元素,必定和整体构成有序。

代码如下:

void HeapSort(int A[], int len){
    BuildMaxHeap(A, len);//将数组建立成大根堆
    for(int i=len; i>1; i--){
        int temp = A[i];
        A[i] = A[1];
        A[1] = temp;//互换堆顶和堆底元素
        HeapAdjust(A, 1, i-1);//调整待排序子列为大根堆
    }
}

for 循环中,循环变量 i 从后往前遍历。是因为有序元素都在堆底,且每次和堆顶元素置换的元素都在末尾,并且不断向前。i>1 作为终止条件,是因为 i==1 时,其实只剩下一个元素,不需要在进行换位。HeapAdjust传入的 len=i-1,是因为到这一步时,i 指向有序子列的第一个,i-1 才是待排序子列的末尾,而下一轮循环时会自动 i--,让 i 移到堆底。

③性能分析

空间复杂度 O(1),来自常数个变量

时间复杂度 O(nlog2(n)),其中建堆 O(n),排序 O(nlog2(n)),相加取高阶。

稳定性:不稳定。比如大根堆 2 1 2,第一次排序,将 2 和 2 互换,得到 2 1 2。然后调整大根堆,不用调整。第二次排序,将 2 和 1 互换,得到 1 2 2。明显两个 2 的顺序改变了。因为互换堆顶和堆底的过程并没有对比元素,默认堆顶就是最大的。

适用性:不适用链表,因为要直接通过下标找孩子。

关于时间复杂度,计算比较复杂,具体计算请参考课件:

图自 王道考研
图自 王道考研

但是单从记忆的角度来说,在建堆的过程中,没有循环,要对 n/2 个结点进行调整,对于编号为 k 的结点,其左子树为 2k,右子树为 2k+1,左子树的左子树为 4k,左子树的右子树为 4k+1,右子树的左子树为 4k+2,右子树的右子树为 4k+3。可以发现,对于某一个非终端结点的处理,并不会有过程的重复。而重复来自于不同结点的处理,可能会对相同编号的某些结点对比多次。但是这个处理过程是达不到 n^2 数量级的。所以,理解的角度上来说,可以将建堆的 O(n) 具象化一些。

另外,排序过程中,一共循环 n-1 次,每次都是把堆底元素放到堆顶,然后从堆顶开始重新调整,最多的调整次数不超过树高,即 log2(n) 数量级。二者相乘,得到 O(nlog2(n))。

七、归并排序

①算法思想

把一个无序数列分成 n 个有序子列,两两归并成为 n/2 个有序子列,继续两两归并成为 n/4 个子列,直到最后归并成为一个。归并的过程中,从两个子列中依次选择最小/最大的元素组成一个新数组。

②实现方式

首先明确一下什么是归并,有两个有序子列 A={1 3} 和 B={ 2 4 5},它们各自有序,归并的目的就是要把它们合成为一个有序数列。显然结果是 C={ 1 2 3 4 5},但这个过程是如何进行的呢?

答案是,对比 A[0] 和 B[0],A[0] 更小,则把 A[0] 放到 C[0],得到 C={1};对比 A[1] 和 B[0],B[0] 更小,则把 B[0] 放到 C[1],得到 C={1, 2};对比 A[1] 和 B[1],A[1] 更小,则把 A[1] 放到 C[2],得到 C={1, 2, 3}。此时应该对比 A[2] 和 B[1],但是 A 中没有下标 2,所以 A 中所有元素都放到 C 里,这也意味着 B 中剩下的元素肯定都比 C 中的大,并且有序,则可以将 B[1] B[2] 依次放进 C,得到 C={ 1 2 3 4 5}。

而归并排序的基础就是上面这一过程。在归并排序中,并不能确保有两部分子列各自有序,所以需要把整个数组 n 等分,每个子列中只有 1 个元素。而 1 个元素必定是有序的,则可以把这 n 个子列两两进行归并,得到 n/2[*注] 个子列。此时这 n/2 个子列也是各自有序的,则又可以把它们两两归并,形成 n/4 个子列。以此类推,最终回到一个完整的有序数组。

(*注:n/2 是为了方便理解,实际上如果 n=3,第一部分分为 3 个,第二步则会形成 2 个,所以实际上是对 n/2 向上取整,n/4、n/8...同理)

上面的过程描述的是一种递归思想,最后一步是将 2 个子列归并成 1 个,而这 2 个子列又分别来自各自的 2 个子列。

所以可以写出代码:

void MergeSort(int 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);//两部分归并
    }//end if
}

代码中,通过 mid 将整个数组分成左右两部分,左半部分下标从 0...mid,右半部分下标从 mid+1...high。对这两部分递归执行排序,递归结束后,将左右两个有序子列合成一个。

显然核心是 Merge 函数。

void Merge(int A[], int low, int mid, int high){
    int *B=(int *)malloc(sizeof(int)*(high+1));//创建数组 B
    int i, j, k;
    for(k=low; k<=high; k++){//数组 B 存储原 A 中元素
        B[k] = A[k];
    }//end for
    for(i=low, j=mid+1, k=i; i<=mid, j<=high; k++){//归并
        if(B[i]<=B[j]){//如果右子列大或等
            A[k] = B[i++];//取左子列元素放回 A
        }
        else{//如果左子列大
            A[k] = B[j++];//取右子列元素放回 A
        }
    }//end for
    while(i<=mid) A[k++] = B[i++];//将左子列剩余元素直接放回 A
    while(j<=high) A[k++] = B[j++];//将右子列剩余元素直接放回 B
}

Merge 函数和我们之前举的归并的例子不太相同,但本质上是一致的。例子中,我们将两个数组中的元素提取出来存放到一个新数组中,而在 Merge 函数中,我们先将原数组中的所有元素放到 B 中,再从 B 中取出元素放回 A。值得一提,B 本质上是一个数组,但是通过 low, mid, high 三个变量将 B 分成了两个部分,看作两个子列。

在第二个 for 循环中,用 i 指向左子列,用 j 指向右子列,用 k 指向原数列。而其中 if 的判断条件是小于等于,则保证左边子列元素和右边子列元素相等时,优先把左边子列的元素放回 A,保证了相等元素出现的顺序,保证了稳定性。

举个例子:

A[7] = { 49, 38, 65, 97, 76, 13, 27}

第一层递归,mid=7/2=3,low=0,high=6。则 A1={ 49, 38, 65, 97},A2={ 76, 13, 27},先调用 MergeSort(A, 0, 3) 处理 A1={ 49, 38, 65, 97},进入第二层递归。

第二层递归,low=0,high=3,mid=3/2=1。则 A11={ 49, 38},A12={ 65, 97},先调用 MergeSort(A, 0, 1) 处理 A11={ 49, 38},进入第三层递归。

第三层递归,low=0,high=1,mid=1/2=0。则 A111={49},A112={38},先调用 MergeSort(A, 0, 0) 处理 A111={49},进入第四层递归。

第四层递归,由于 low=high=0,返回上一层递归。

第三层递归,再调用 MergeSort(A, 1, 1) 处理 A112={38},进入第四层递归。

第四层递归,由于 low=high=1,返回上一层递归。

第三层递归,调用 Merge(A, 0, 0, 1),将 A111 和 A112 归并,A11={ 38, 49}。第三层递归执行结束,返回上一层递归。

第二层递归,再调用 MergeSort(A, 2, 3) 处理 A12={ 65, 97},进入第三层递归。

第三层递归,low=2,high=3,mid=5/2=2。则 A121={65},A122={97},先调用 MergeSort(A, 2, 2) 处理 A121={65},进入第四层递归。

第四层递归,由于 low=high=2,返回上一层递归。

第三层递归,再调用 MergeSort(A, 3, 3) 处理 A122={97},进入第四层递归。

第四层递归,由于 low=high=3,返回上一层递归。

第三层递归,调用 Merge(A, 2, 2, 3),将 A121 和 A122 归并,A12={ 65, 97}。第三层递归执行结束,返回上一层递归。

第二层递归,调用 Merge(A, 0, 1, 3),将 A11 和 A12 归并,A1={ 38, 49, 65 97}。第二层递归执行结束,返回上一层递归。

第一层递归,调用 MergeSort(A, 4, 6) 处理 A2={ 76, 13, 27},进入第二层递归。

第二层递归,low=4,high=6,mid=10/2=5。则 A21={ 76, 13},A22={27}。先调用 MergeSort(A, 0, 1) 处理 A21={ 76, 13}。

第三层递归,A211={76},A212={13},先处理 A211。

第四层递归,low=high,返回上一层。

第三层递归,处理A212。

第四层递归,low=high,返回上一层。

第三层递归,归并 A211 和 A212 得到 A21={ 13, 76}。返回上一层。

第二层递归,处理 A22={27}。

第三层递归,low=high,返回上一层。

第二层递归,归并 A21 和 A22 得到 A2={13, 27, 76}。返回上一层。

第一层递归,归并 A1 和 A2 得到 A={13, 27, 38, 49, 65, 76, 97}。返回上一层,递归结束。

后半部分简写了,应该不难看懂。前半部分中,相同颜色代表在同一层递归,带下划线的是参数。

数组 B 在执行中起到的作用,其实就是上面的 A1 A12 这些,存储了子列。

上面没分析归并的过程,主要是分析了递归的过程。代码最后两行只会执行一行,满足其中一个条件的同时必不满足另一个条件。而这两个 while 的目的,是当一个子列全部被放回 A 后(for循环退出后必有一个子列元素全被放回 A,因为退出循环条件是 i>mid 或 j>high),另一个子列剩下的元素直接放回 A。

③性能分析

空间复杂度 O(n),来自于辅助数组 B。

时间复杂度 O(nlog2(n)),其中一共需要 log2(n) 数量级的递归深度和每次递归 n 数量级的单趟归并时间复杂度。分析过程和折半查找比较类似,因为每次会将数组分成两份,所以可以建立一棵二叉树,递归深度即为树高,为 log2(n) 数量级。

稳定性:稳定。前文已经分析了,在 for 循环中 if 的小于等于号体现,两个元素相等时,优先把靠前的存进新数组。

适用性:不讨论,因为要用下标把数组折成两部分,但链表操作也可实现。

==总结==

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值