五大排序算法:插入、交换、选择、归并排序以及堆排序

0 排序相关概念

排序:将序列中的元素按照关键字递增或递减的顺序重新排列的过程。

排序算法的稳定性:如果序列中有两个元素的关键字相等,在经过排序后,两者的相对位置保持不变,则称排序算法具有稳定性。例如序列 {a1 = 2, a2 = 1, a3 = 3, a4 = 2} 在经过某一排序后变为 {a2 = 1, a1 = 2, a4 = 2, a4 = 3},其中 a1 和 a4 在排序前与排序后的相对位置保持不变,则称该排序具有稳定性。

内部排序:排序期间所有的元素都存放在内存中的排序。

外部排序:排序期间元素无法同时存放在内存当中,需要不断的在内、外存之间移动元素的排序。

  • 序列中的元素可以存放在外存中,但是排序一定是在内存中实现的。
  • 本篇博客的排序均采用递增排序,数组下标从0开始。

1 插入排序

插入类排序特点:待排序元素直接插入到指定位置,从而其他的元素要做相应的移动

1.1 直接插入排序

  • 空间复杂度: O ( 1 ) O(1) O(1)
  • 时间复杂度: O ( n 2 ) O(n^2) O(n2)
  • 稳定性:稳定

基本思想:对于一个待排序列,取出无序部分第一个元素,在下图中即为“待排序元素 A(i)”,从头或尾遍历有序部分,找到合适的插入位置 k,然后将 [k, i] 之间的的所有元素向后移动一个位置,然后将 A(i) 插入。
在这里插入图片描述

void InsertSort(int A[], int n)
{
    int i, j, temp;
    for (i = 1; i < n; ++i)		// 初始时的有序部分即为原始序列的首元素
    {
        temp = A[i]; 			// 将待排序元素的值保存在 temp 中
        for (j = i - 1; temp < A[j]; --j)	// 从后向前寻找第一个不大于 temp 的元素
            A[j + 1] = A[j];	// for 循环兼顾寻找插入位置和向后移动元素的作用
        A[j + 1] = temp;		// 将 A[i] 插入待插入位置
    }
}

1.2 折半插入排序

  • 空间复杂度: O ( 1 ) O(1) O(1)
  • 时间复杂度: O ( n 2 ) O(n^2) O(n2)
  • 稳定性:稳定

基本思想:由前面可以看出,直接插入排序的特点是边比较边移动,折半插入排序的特点是先利用折半查找(二分法)找到插入位置,然后再一次移动完所有元素,最后再将待排序元素插入。折半查找的实现和原理可阅读二分法及其拓展全面讲解,折半插入排序属于该篇博客中“非严格递增(递减)数组的二分查找”的第二个问题:求出有序部分第一个大于等于元素 A[i] 的元素的位置。

二分法找的是有序部分第一个小于 A[i] 的位置

void BinaryInsertSort(int A[], int n)
{

    int i, j, temp;
    for (i = 0; i < n; ++i)
    {
        temp = A[i];
        int left = 0, right = i - 1, mid;
        while (left <= right)	// 这里一定是小于等于
        {	// 退出循环后的 left 就是待插入位置
            mid = (left + right) / 2;
            if (A[mid] > temp)
                right = mid - 1;
            else
                left = mid + 1;
        }
        for (j = i - 1; j >= left; --j)
            A[j + 1] = A[j];	// 向后移动元素
        A[j + 1] = temp; 		// 将 A[i] 插入待插入位置
    }
}

1.3 希尔排序

  • 空间复杂度: O ( 1 ) O(1) O(1)
  • 时间复杂度:比 O ( n 2 ) O(n^2) O(n2) 快得多,下界为 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)
  • 稳定性:不稳定。

基本思想:选定一个初始步长(间隔) d 1 d_1 d1,从序列中的第一个元素开始,将所有下标间隔 = 步长的元素放到一组里。对每一组的所有元素进行直接插入排序,使之成为序列的局部有序部分。接下来取第二个步长 d 2 d_2 d2 d 2 < d 1 d_2 < d_1 d2<d1,重复上述步骤,直到步长为1,意味着整个序列的所有元素都在一组中,此时进行最后一次直接插入排序。由于序列具有局部有序性,因此可以很快得到排序结果。一般来讲,每次步长都取上一次的一半。

void ShellSort(int A[], int n)
{
    int d, i, j, temp; // 定义步长 d
    for (d = n / 2; d >= 1; d /= 2)	// 初始步长取 n / 2,而后每次循环都减半
    {
        for (i = d; i < n; i += d)	// 此 for 循环即为直接插入排序,
        {	// i 从 d 开始,相当于直接插入排序中的 i 从1开始
            temp = A[i];
            for (j = i - d; temp < A[j]; j -= d) // i、j 均以 d 为变化
                A[j + d] = A[j];
            A[j + d] = temp;
        }
    }
}

2 交换排序

交换类排序特点:直接交换序列中的元素,而不需要移动元素

2.1 冒泡排序

  • 空间复杂度: O ( 1 ) O(1) O(1)
  • 时间复杂度: O ( n 2 ) O(n^2) O(n2)
  • 稳定性:稳定

基本思想每一趟冒泡排序都维护一个当前序列,从前往后两两比较相邻元素:如果 A[i] > A[i + 1] 则将两者的值进行交换,直到比较完当前序列的最后一个元素,其结果是最大的元素会被放到当前序列的最后面。第二趟冒泡不再比较前一趟的最后一个元素,因此其重新维护了一个序列(因为少了一个元素)。重复上述步骤,直到只剩一个元素为止,总共 n - 1 趟。
在这里插入图片描述

void BubbleSort(int A[], int n)
{
    int i, j, temp;
    for (i = 0; i < n - 1; ++i) // 第一个 for 循环表明只进行 n - 1 趟排序
    {
        bool flag = false;		// flag = true 表明本趟排序发生过交换
        for (j = 0; j < n - i - 1; ++j)	// 一次循环为一次冒泡
        {	// 注意下标是 j 不是 i
            if (A[j] > A[j + 1])		// 交换两者的值
            {
                temp = A[j];
                A[j] = A[j + 1];
                A[j + 1] = temp;
                flag = true;
            }
        }
        if (!flag) return;	// 如果本趟冒泡没有发生过交换,说明序列已经有序
    }
}

对 j < n - i - 1 做一下解释。每一趟排序都会将最大的元素放到序列的末尾,因此在下一趟冒泡中,最后一个元素就不参与比较了。而每一趟排序开始时,i 的大小就正好表明末尾多少个数不参与比较。因此 j < n - i - 1。

2.2 快速排序

  • 空间复杂度:用到了递归工作栈,故为栈的平均深度: O ( l o g 2 n ) O(log_2n) O(log2n)
  • 时间复杂度: O ( n l o g 2 n ) O(nlog_2n) O(nlog2n)
  • 稳定性:不稳定。

基本思想:快速排序基于分治的思想,在待排序列中选取一个元素 x 作为主元,通过一趟排序将序列分成两个部分,如下图所示。其中 k 是主元最终所在位置,A[0…(k - 1)] 中的所有元素均小于 A[k],A[(k+1)…(n - 1)] 中所有元素均大于 A[k],这个过程称为一趟快速排序。然后分别递归地对左右两部分重复以上过程,直至每部分内只有一个元素或空位置,即所有元素都放在了最终位置上。
在这里插入图片描述
具体的排序方法是基于 two pointers 的思想(字面意思:两个指针,不一定得是真正的指针)。这两个指针指向同一个序列的头和尾,或都指向头,或都指向尾分别或同时地往一个方向移动。具体例子如下:

2.2.1 two pointers

给定一个递增的序列和一个正整数 M,求两个不同位置上的数 a 和 b,使得 a + b = M。

while (i < j)	// i 和 j 分别指向序列的头部和尾部
{
    if (a[i] + a[j] == M)		// 两者和等于 M,打印 i 和 j 的值
    {
    	cout << i << " " << j;
        ++i;
        --j;
    }
    else (a[i] + a[j] < M) ++i;	// 两者和比 M 小,更小者往后移一位
    else --j;					// 两者和比 M 大,更大者往前移一位
}

2.2.2 序列合并

假设有两个递增序列 A 与 B,要求将它们合并为一个新的递增序列 C。

// n 和 m 分别为两个序列的长度
int merge(int A[], int B[], int C[], int n, int m)
{
    int i = 0, j = 0, index = 0; // i 指向 A[0], j 指向 B[0]
    while (i < n && j < m)
    {
        if (A[i] <= B[j]) C[index++] = A[i++];	// 将 A[i] 加入序列 C
        else C[index++] = B[j++];				// 将 B[j] 加入序列 C
    }
    while (i < n) C[index++] = A[i++]; // 将序列 A 的剩余元素加入序列 C
    while (j < m) C[index++] = B[j++]; // 将序列 B 的剩余元素加入序列 C
    
    return index;
}

由以上两个例子可以看出,two pointers 的含义就是利用问题本身与序列的特性,使用两个下标 i、j 对序列进行遍历,既可以同向 → → \rightarrow \rightarrow →→ / ← ← \leftarrow \leftarrow ←← 遍历,也可以相向遍历 → ← \rightarrow\leftarrow →←,用较低的复杂度解决问题。

2.2.3 普通快排

那接下来看看快排是如何利用 two pointers 的思想。

  • 首先令两个指针 left 和 right 分别指向序列的头部和尾部,将 A[left] 作为主元并将其值保存到 temp 中,这样就空出了 A[left] 以便交换其他元素。交换的基本思想就是小的放左边大的放右边
  • 循环移动 right 和 left,每次循环都是先移动 right,再移动 left。
  • 如果 A[right] > temp,那么 right 就往左移动,一直到 A[right] <= temp,即出现不大于主元的元素为止。此时需要将 A[right] 放到序列的左边,执行 A[left] = A[right]。从而空出了 A[right]。
  • right 停止移动,left 开始向右移动。如果 A[left] <= temp,那么 left 就往右移动,直到 A[left] > temp,即出现大于主元的元素为止。此时需要 A[left] 将放到序列的右边,执行 A[right] = A[left]。从而又空出了 A[left]。
  • 进入下一次循环,重复前两个过程,直到 left >= right,即两者相遇为止,此时的 left 就是主元 x 最终应放在的位置。其左边的元素全部小于等于 x,右边的元素全部大于 x。
  • 递归处理左边的部分和右边的部分直到所有元素都排序完成。

在这里插入图片描述

由上面的过程可以知道,在快速排序算法中,并不会产生有序的子序列,但每趟排序都会将一个元素放到其最终的位置上。

// 快速排序的主体,对区间 [left, right] 进行划分
int Partition(int A[], int left, int right)
{
    int temp = A[left];		// 将 A[left] 存放至临时变量 temp
    while (left < right)	// left 与 right 相遇时结束循环
    {
        while (left < right && A[right] > temp)
            --right;		// 左移 right
        A[left] = A[right]; // 将 A[right] 挪到 A[left]
        while (left < right && A[left] <= temp)
            ++left;			// 右移 left
        A[right] = A[left]; // 将 A[left] 挪到 A[right]
    }
    A[left] = temp; // 把 temp 放到 left 与 right 相遇的地方
    return left;	// 返回相遇的下标
}

// 快速排序算法的接口,left 与 right 的初值为序列首尾下标(例如0与 n - 1)
void QuickSort(int A[], int left, int right)
{
    if (left < right) // 该条件为真同时也表示当前区间的长度超过1
    {
        // 将 [left, right] 按 A[left] 一分为二
        int pos = Partition(A, left, right);
        QuickSort(A, left, pos - 1); 	// 对左子区间递归进行快排
        QuickSort(A, pos + 1, right); 	// 对右子区间递归进行快排
    }
}

快速排序算法的时间复杂度比较依赖于每次划分区间时左右元素的数量,数量越接近效率越高。当原始序列中的元素顺序比较随机时效率很高,当序列中元素接近有序时复杂度甚至会降到 O ( n 2 ) O(n^2) O(n2)。解决这个问题的一个办法是随机选择主元,这样对于任意输入数据的期望时间复杂度都能达到 O ( n l o g 2 n ) O(nlog_2n) O(nlog2n),也就是说不存在一组特定的数据能使这个算法出现最坏情况。

2.2.4 生成随机数

下面的部分会涉及到这些头文件,如果引入了 ctime 和 algorithm 头文件,就不需要引入 cstdlib 头文件了。

#include <cmath> 		// 提供 round 函数
#include <cstdlib>		// 提供 rand 和 srand 函数
#include <ctime> 		// 提供 time 和 srand 函数
#include <algorithm> 	// 提供 swap 和 rand 函数

生成十个随机数的代码:

#include <iostream>
#include <cstdlib>
#include <ctime>
using namespace std;

int main()
{
    srand((unsigned)time(NULL));
    for (int i = 0; i < 10; ++i)
    	cout << rand() << " ";

    return 0;
}

要注意,rand() 函数只能生成 [0, RAND_MAX] 范围内的整数,RAND_MAX 是 stdlib.h 中的一个常数,在不同系统中的值不同。如果要输出给定范围 [a, b] 内的随机数,需要使用 rand() % (b - a + 1),它输出的范围是 [0, b - a],加上 a 后就成了 [a, b]。

#include <iostream>
#include <cstdlib>
#include <ctime>
using namespace std;

int main()
{
    srand((unsigned)time(NULL));
    for (int i = 0; i < 10; ++i)
    	cout << rand() % 2 << " ";		// 范围是 [0, 1]
    cout << endl;
    for (int i = 0; i < 10; ++i)
    	cout << rand() % 5 + 3 << " ";	// 范围是 [3, 7]
    	
    return 0;
}

但是上面的做法只对左右端点相差不超过 RAND_MAX 的区间的随机数有效,如果需要生成更大的数就不行了。要生成大范围的随机数有以下几种做法:

  • 多次生成 rand 随机数,然后用位运算拼接起来;
  • 将两个 rand 随机数相乘;
  • 随机选每一个数位的值,然后拼成一个大整数。

这里采用另一个做法:先用 rand() 生成一个 [0, RAND_MAX] 范围内的随机数,然后将这个随机数除以 RAND_MAX,这样就会得到一个 [0, 1] 范围内的浮点数。然后将这个浮点数乘以 (b - a),再加上 a 即可,即 (int)(round(1.0 * rand() / RAND_MAX * (b - a) + a)),相当于这个浮点数就是 [a, b] 范围内的比例位置。如下为生成一个 [10000, 60000] 范围内的随机数的示例:

由于 rand() 生成的随机数是整数,所以需要乘上1.0转换为浮点数。

#include <iostream>
#include <cstdlib>
#include <ctime>
using namespace std;

int main()
{
    srand((unsigned)time(NULL));
    for (int i = 0; i < 10; ++i)
    	cout << (int)(round(1.0 * rand() / RAND_MAX * 50000 + 10000)) << " ";

    return 0;
}

2.2.5 随机快排

在此基础上将随机数引入快排来降低其时间复杂度。不妨生成一个范围在 [left, right] 内的随机数 p,然后以 A[p] 作为主元来进行划分。具体做法是:将 A[p] 与 A[left] 交换,然后按原先 Partition 函数的写法即可,代码如下:

// 选取随机主元,对区间 [left, right] 进行划分
int RandPartition(int A[], int left, int right)
{
	// 生成 [left, right] 内的随机数 p
	int p =  (round(1.0 * rand() / RAND_MAX * (right - left) + left));
	swap(A[p], A[left]);	// 交换 A[p] 与 A[left]
    int temp = A[left];		// 将 A[left] 存放至临时变量 temp
    while (left < right)	// left 与 right 相遇时结束循环
    {
        while (left < right && A[right] > temp)
            --right; 		// 左移 right
        A[left] = A[right]; // 将 A[right] 挪到 A[left]
        while (left < right && A[left] <= temp)
            ++left; 		// 右移 left
        A[right] = A[left]; // 将 A[left] 挪到 A[right]
    }
    A[left] = temp; 		// 把 temp 放到 left 与 right 相遇的地方
    return left;			// 返回相遇的下标
}

// 快速排序,left 与 right 初值为序列首尾下标(例如0与 n - 1)
void QuickSort(int A[], int left, int right)
{
    if (left < right) // 该条件为真同时也表示当前区间的长度超过1
    {
        // 将 [left, right] 按 A[left] 一分为二
        int pos = Partition(A, left, right);
        QuickSort(A, left, pos - 1); 	// 对左子区间递归进行快排
        QuickSort(A, pos + 1, right); 	// 对右子区间递归进行快排
    }
}

衍生:第 K 大

下面的内容有点绕,容易产生误解,读者可以动笔作图同步理解。

有这样一个问题:从一个元素互不相同的无序序列中找出第 K 大的数。如 {4, 9, 3, 6} 中第一大的是3,第2大的是4,第三大是6,第四大是9。

第 K 大 的意思是如果序列按从小到大排列的话 N 在第 K 位,K 越大表明相应的数越大。比如第一大是第一位 ,而不是最大。

可以如此解决:当对 A[left, right] 执行 RandPartition 函数之后,主元左右侧的元素就是确定的——左侧的元素小于主元,右侧的元素大于主元。假设用 p 接受返回的值,那么 A[p] 即为主元,也为序列中第 M = p - left + 1 大的元素。此时有三种情况:

  • 如果 K == M,说明序列中第 K 大的数就是 A[p];
  • 如果 K > M,说明第 K 大的数在 A[p] 的右侧,问题变为寻找序列 A[(p + 1)…right] 中的第 K - M 大的数,往右侧递归即可;
  • 如果 K < M,说明第 K 大的数在 A[p] 的左侧,问题变为寻找序列 A[left…(p - 1)] 中的第 K 大的数,往左侧递归即可;

注意第 K 大的 K 是从1算起的,left 无论是不是0都无影响,right 最多取到 n - 1。

// 以下头文件必须添加
#include <algorithm>	// 提供 swap 函数
#include <ctime> 		// 提供 srand 函数
#include <cmath> 		// 提供 round 函数

// 随机选择算法,从 A[left, right] 返回第 K 大的数
int RandSelect(int A[], int left, int right, int K)
{
    if (left == right || K > right + 1)
    	return A[left]; 		// 边界返回
    int p = RandPartition(A, left, right); // 用 p 接受划分后的主元的位置
    int M = p - left + 1;		// A[p] 是 A[left, right] 中第 M 大
    if (K == M) return A[p];	// 找到第 K 大的数
    else if (K > M)				// 往右侧寻找第 K - M 大的数
        return RandSelect(A, p + 1, right, K - M); 
    else						// 往左侧寻找第 K 大的数
        return RandSelect(A, left, p - 1, K);
}

3 归并排序

归并排序的特点是将元素分为多组进行排序,再将其中多组合并为一组进行排序,不停地合并直至只剩下一组(序列本身)为止。

  • 空间复杂度: O ( 1 ) O(1) O(1)
  • 时间复杂度: O ( n 2 ) O(n^2) O(n2)
  • 稳定性:不稳定

基本思想:归并排序类似于希尔排序,也是需要选取步长 d,它们的区别在于组内元素选择的方法。归并排序是从序列头部开始每 d 个元素分为一组,末尾不足 d 个也为一组,组内进行排序。组内排序完后,从第一组开始每 d 组合并为一组,末尾不足 d 个也为一组,组间进行排序。重复该步骤直到只剩下一组元素,也就是整个序列为止。如下为 Merge 函数,其作用是将相邻的两组元素按从小到大的顺序合并到一组。

N 路归并排序的意思是步长为 N。

const int maxn = 100;

// 将数组 A 的 [L1, R1] 与 [L2, R2] 区间合并为有序区间,此处 L2 = L1 + 1
void Merge(int A[], int L1, int R1, int L2, int R2)
{
    int i = L1, j = L2; // i 指向 A[L1], b 指向 B[L2]
    int temp[maxn], index = 0;
    while (i <= R1 && j <= R2)
    {
        if (A[i] <= A[j]) temp[index++] = A[i++]; 	// 将 A[i] 加入序列 temp
        else temp[index++] = A[j++]; 				// 将 A[j] 加入序列 temp
    }
    while (i <= R1) temp[index++] = A[i++];	// 将 [L1, R1] 的剩余元素加入序列 temp
    while (j <= R2) temp[index++] = A[j++]; // 将 [L2, R2] 的剩余元素加入序列 temp
    for (i = 0; i < index; ++i)
        A[L1 + i] = temp[i]; // 将合并后的序列赋值回数组 A
}

3.1 递归实现

下面是2路归并排序算法的递归实现代码:

// 将 array 数组当前区间 [left, right] 进行归并排序
void MergeSort(int A[], int left, int right)
{
    if (left < right) // 当 left >= right 时停止循环
    {
        int mid = (left + right) / 2;	// 取 [left, right] 的中点
        MergeSort(A, left, mid); 		// 递归,将左子区间 [left, mid] 归并排序
        MergeSort(A, mid + 1, right);	// 递归,将右子区间 [mid + 1, right] 归并排序
        Merge(A, left, mid, mid + 1, right); // 将左右子区间合并
    }
}

3.2 非递归实现

要非递归实现2路归并排序,初始时每两个元素为一组,后续每两组为一组,那么就相当于步长在每次循环中都乘以2(因为是根据下标访问数组元素而进行运算)。而每一次循环的步长就是一组中的元素个数。循环结束的边界是步长的一半大于序列的元素个数。每次循环的最后一组元素个数可能不足 step 个,因此其右子区间要取可能边界值的较小者。下面是2路归并排序算法的非递归实现代码:

void MergeSort(int A[], int n)
{
    // step 为每一组的元素个数,step 初值为2,每次循环乘2,相当于两组合并为了一组
    for (int step = 2; step / 2 <= n; step *= 2)
    {	// 每 step 个元素为一组,组内前 step / 2 和后 step / 2 个元素进行合并
        for (int i = 0; i < n; i += step)
        {   // 左子区间为 [i, mid],右子区间为 [mid + 1, min(i + step - 1), n]
            int mid = i + step / 2 - 1; // 左子区间元素个数为 step / 2
            if (mid + 1 <= n)           // 右子区间存在元素才进行合并操作
                Merge(A, i, mid, mid + 1, min((i + step - 1), n));
        }
    }
}

如果题目中只要求给出归并排序每一趟结束时的序列,那么完全可以使用 sort 函数来代替 merge函数(只要时间限制允许),如下所示:

void MergeSort(int A[], int n)
{
    // step 为每一组的元素个数,step 初值为2,每次循环乘2,相当于两组合并为了一组
    for (int step = 2; step / 2 <= n; step *= 2)
    {	// 每 step 个元素为一组,组内前 step / 2 和后 step / 2 个元素进行合并
        for (int i = 0; i < n; i += step)
            sort(A + i, A + min(i + step, n));
        // 此处输出归并排序的某一中间序列
    }
}

4 选择排序

选择排序和直接插入排序有些许的相似之处,其特点是每一趟都从待排序元素中选取最小的,然后放入有序子序列中去。

4.1 简单选择排序

  • 空间复杂度: O ( 1 ) O(1) O(1)
  • 时间复杂度: O ( n 2 ) O(n^2) O(n2)
  • 稳定性:不稳定

基本思想:第一趟,从 A[0…(n - 1)] 中选出最小的元素,将其和 A[0] 交换元素值。第二趟就从 A[1…(n - 1)] 中选出最小的元素,将其和 A[1] 交换元素值。一直重复该步骤直到只剩下一个元素为止。
在这里插入图片描述

void SelectSort(int A[], int n)
{
    int i, j, temp;
    for (i = 0; i < n - 1; ++i) // 最后一个元素不参与选择,故 i < n - 1
    {
        int mini = i;			// 令最小值的下标为 i
        for (j = i + 1; j < n; ++j)
            if (A[j] < A[mini]) // 如果发现更小的,更换最小值下标
                mini = j;
        temp = A[mini];
        A[mini] = A[i];
        A[i] = temp;
    }
}

5 堆排序

堆是一棵完全二叉树,树中的每个结点的值都不小于(或不大于)其左右孩子结点的值。通常我们把父结点不小于孩子结点的堆称为大顶堆,把父结点不大于孩子结点的堆称为小顶堆。注意在普通的堆中,只要求父结点跟孩子结点的大小关系,并不对左右孩子结点的大小关系做出限定。

  • 空间复杂度: O ( n ) O(n) O(n)
  • 时间复杂度: O ( l o g 2 n ) O(log_2n) O(log2n)
  • 稳定性:不稳定

应用堆排序时需要先将序列变成大顶堆或者小顶堆的情况保存,再进行排序。先看看如何将一个普通的完全二叉树变成一个大顶堆。

5.1 构造大顶堆

对于序列 3 1 2 8 7 5 9 4 6 来说,通常来说,将序列从数组下标为1的位置开始保存会使得操作起来更加方便。假设该序列保存在数组 heap中,那按照下标与值的对应就如下图所示:
在这里插入图片描述
将这么一个序列构造成大顶堆的话,我们通常从最后一个非叶结点开始,在此例中就是 heap[4] = 8。我们将其与左右孩子进行比较,符合大顶堆的定义;接着看倒数第二个非叶结点,即 heap[3] = 2,它不满足大顶堆的要求,要使以下标为3的结点为根结点的子树也满足大顶堆的规则,就需要将其与左右孩子结点中较大者的值进行交换,所以交换 heap[3] 和 heap[7] 的值,以满足大顶堆的要求,得到下图:
在这里插入图片描述
紧接着看倒数第三个非叶结点,即 heap[2] = 1,同理它不满足大顶堆的要求,需要将 heap[2] 的值和 heap[4] 的值进行交换(heap[4] 更大)。但是问题来了,此时以 heap[4] = 1 为根结点的子树又不满足大顶堆的要求了,因此要继续将 heap[4] 与 heap[9] 进行交换,得到下图:
在这里插入图片描述
最后就是看倒数第四个非叶结点,也就是根结点 heap[1] = 3 了,将其与孩子结点中较大者 heap[3] = 9 交换后,还需要再与孩子结点中的较大者 heap[6] = 5 进行交换,得到该序列的大顶堆图:
在这里插入图片描述
可以发现,整个构造过程的时间复杂度只跟非叶结点的数量有关,非叶结点有几个,就需要检查几次。而对于一棵有 n 个结点的完全二叉树来说(换句话说,有 n 个整数的序列),其非叶结点的数量就是 ⌊ n 2 ⌋ \left \lfloor \frac{n}{2} \right \rfloor 2n(数组下标从1开始)。比如此题中最后一个非叶结点的下标就是 ⌊ 9 2 ⌋ = 4 \left \lfloor \frac{9}{2} \right \rfloor = 4 29=4。因此如果我们要构建一个大顶堆,只需从下标 i = ⌊ n 2 ⌋ \left \lfloor \frac{n}{2} \right \rfloor 2n 开始,不停地自减直到 i = 0 为止。而且对于一棵完全二叉树来说,若将其所有结点保存在下标从1开始的数组中,对于任意一个结点 i 来说,其左孩子结点的编号就为 2 * i,右孩子结点的编号就为 2 * i + 1,在上图中也能清楚地看到这一特征。

构建大顶堆的算法代码如下:

// 序列保存在下标从1开始的数组 heap 中,low 和 high 分别为构建区间的下界和上界
void DownAdjust(int low, int high)
{
    int i = low, j = i * 2;		// i 为欲调整结点,j 为其左孩子
    while (j <= high)
    {
        if (j + 1 <= high && heap[j + 1] > heap[j]) j = j + 1;    // 右孩子比左孩子大就让 j 保存右孩子下标
        if (heap[j] <= heap[i]) break;  // 如果孩子结点不大于父结点 i,就不用往下继续调整
        swap(heap[j], heap[i]);     	// 否则交换最大的孩子与父结点 i 的结点值
        i = j;                      	// 较大的孩子结点作为新的欲调整结点,继续向下调整堆
        j = i * 2;
    }
}

// 构建大顶堆,从 n / 2 开始往1枚举即可
void CreatBigHeap()
{
	for (int i = n / 2; i >= 1; --i)
		DownAdjust(i, n);	// 左界为 i,右界为序列最后一个元素
}

5.2 针对大顶堆实现堆排序

那么如何对上述大顶堆(即如下图)进行操作,才使其变成一个由上至下,由左至右递增的堆呢?
在这里插入图片描述
首先交换堆顶堆尾的结点的值,即交换 heap[1] 和 heap[9],这样堆中最大的值就放到了堆尾,如下图所示:
在这里插入图片描述
此时我们需要固定 heap[9] 的值,也不对其进行任何交换。因为交换之后堆不再是大顶堆了,所以需要对堆顶的结点执行一次向下调整,使整个堆除了 heap[9] 之外重新满足大顶堆的要求得到下图:
在这里插入图片描述

此时可以看到,我们已经将序列中第二大的元素交换到了堆顶,相信读者已经知道接下来要做什么了,没错,我们继续交换堆顶堆尾的结点的值,即交换 heap[1] 和 heap[8],这样堆中第二大的值就放到了堆尾,如下图所示:
在这里插入图片描述
此时我们需要固定 heap[8] 的值,也不对其进行任何交换。因为交换之后堆不再是大顶堆了,所以需要对堆顶的结点执行一次向下调整,使整个堆除了 heap[8] 和 heap[9] 之外重新满足大顶堆的要求,得到下图:
在这里插入图片描述
那么接下来的每一步都是重复这么一个步骤,因为对大顶堆进行排序的算法代码如下:

void HeapSort()
{
	for (int i = n; i > 1; --i)		// 从堆尾结点开始枚举
	{
		swap(heap[i], heap[1]);		// 交换堆顶和堆尾结点的值
		DownAdjust(1, i - 1);		// 从堆顶开始向下调整
	}
}

希望本篇博客能对你有所帮助,也希望看官能动动小手点个赞哟~~。

  • 2
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值