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

文章目录

  • 排序相关概念
  • 插入排序
    • 直接插入排序
    • 折半插入排序
    • 希尔排序
  • 交换排序
    • 冒泡排序
    • 快速排序
      • two pointers
      • 序列合并
      • 普通快排
      • 相关头文件
      • 生成随机数
      • 随机快排
      • 衍生:随机选择算法
  • 归并排序
    • 递归实现
    • 非递归实现
  • 选择排序
    • 简单选择排序
    • 堆排序

排序相关概念

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

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

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

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

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

插入排序

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

直接插入排序

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

基本思想:每次选中序列 A 中无序部分第一个元素(图中即为“待插入元素 A(i)”),遍历有序部分,找到插入位置 k,将 k 之后、i 之前的所有元素向后移动一个位置,然后将 A(i) 插入 k。
四大排序算法:插入、交换、选择以及归并排序_第1张图片

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) // 从后往前查找待插入位置
            A[j + 1] = A[j]; // 向后移动元素
        A[j + 1] = temp; // 将 A[i] 插入待插入位置
    }
}

折半插入排序

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

希尔排序

  • 空间复杂度: 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; // 定义步长
    for (d = n / 2; d >= 1; d /= 2) // 初始步长取 n / 2,而后每次循环都减半
    {
     
        for (i = d; i < n; ++i) // i 从 d 开始,相当于直接插入排序中的 i 从1开始
        {
      // 此 for 循环即为直接插入排序
            temp = A[i];
            for (j = i - d; temp < A[j]; j -= d) // 关键点在此,j 以 d 为变化
                A[j + d] = A[j];
            A[j + d] = temp;
        }
    }
}

交换排序

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

冒泡排序

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

基本思想:从第一个元素开始,从前往后两两比较相邻元素,如果 A[k] > A[k + 1] 则将 A[k] 和 A[k + 1] 的值进行交换,直到序列比较完。称这为个过程为一趟冒泡,其结果是最大的元素被放到待排序列的最后面。第二趟冒泡从第二个元素开始,重复上述步骤,直到最后一个元素为止,总共 n - 1 趟。

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

void BubbleSort(int A[], int n)
{
     
    int i, j, temp;
    for (i = 0; i < n - 1; ++i) // 最后一个元素不算一趟,故 i 最多到 i - 1
    {
     
        bool flag = false; // flag 为本趟排序是否发生了交换的标志
        for (j = 0; j < n - i; ++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; // 本趟冒泡没有发生过元素的交换,说明序列已经有序
    }
}

快速排序

  • 空间复杂度:用到了递归工作栈,故为栈的平均深度: 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 的思想。

two pointers

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

while (i < j)
    {
     
        if (a[i] + a[j] == M)
        {
     
            printf("%d %d\n", i, j);
            ++i; --j;
        }
        else (a[i] + a[j] < M)
            ++i;
        else
            --j;
    }

序列合并

假设有两个递增(递减)序列 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], b 指向 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 遍历,也可以相向遍历 → ← \rightarrow\leftarrow ,用较低的复杂度解决问题。

普通快排

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

  • 首先令两个下标 left 和 right 分别指向序列的头(最左边)和尾(最右边),将 A[left] 作为主元 x并将其值保存到 temp 中,这样就空出了 A[left] 以便交换其他元素。交换的基本思想就是小的放左边大的放右边。
  • 从右边先开始移动 right,只要 A[right] > temp,那么 right 就继续向左移动,也就是往 left 的方向走。一旦 A[right] <= temp,意味着出现比主元小的元素了,需要放到序列的左边。于是将 A[right] 的值交换给 A[left],同理这样空出了 A[right]。
  • 然后 right 停止移动,left 开始向右移动。只要 A[left] <= temp,那么 left 就继续向右移动,直到 A[left] > temp,意味着出现比主元大的元素了,需要放到序列的右边。于是将 A[left] 的值交换给 A[right]。
  • 重复前两个过程,直到 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),也就是说不存在一组特定的数据能使这个算法出现最坏情况。

相关头文件

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

#include // 提供了 rand 和 srand 函数
#include // 提供 time 和 srand 函数
#include // 提供 round 函数
#include // 提供 swap 和 rand 函数

生成随机数

生成十个随机数的代码:

#include
#include
#include // 提供 srand 函数

int main()
{
     
    srand((unsigned)time(NULL));
    for (int i = 0; i < 10; ++i)
        printf("%d ", rand());

    return 0;
}

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

#include
#include
#include

int main()
{
     
    srand((unsigned)time(NULL));
    for (int i = 0; i < 10; ++i)
        printf("%d ", rand() % 2); // [0, 1]
    printf("\n");
    for (int i = 0; i < 10; ++i)
    	printf("%d ", 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
#include // C 语言中为 stdlib.h
#include // C 语言中为 time.h

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

    return 0;
}

随机快排

在此基础上将随机数引入快排,不妨生成一个范围在 [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 初值为序列首尾下标(例如1与 n)
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 大的数。如 {4, 9, 3, 6} 中第一大的是3,第2大的是4,第三大是6,第四大是9。下面的内容有点绕,容易产生误解,读者可以动笔作图同步理解。

数 N 第 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 // 提供 swap 函数
#include // 提供 srand 函数
#include // 提供 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)
        return RandSelect(A, p + 1, right, K - M); // 往右侧寻找第 K - M 大
    else
        return RandSelect(A, left, p - 1, K); // 往左侧寻找第 K 大
}

归并排序

归并排序的特点是由下至上,将元素分为多组进行排序,再将多组合并为一组进行排序,不停地往上合并直至只剩下一组为止。

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

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

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

const int maxn = 100;

// 将数组 A 的 [L1, R1] 与 [L2, R2] 区间合并为有序区间,此处 L2 即为 L1 加一
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
}

递归实现

下面是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); // 将左右子区间合并
    }
}

非递归实现

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

void MergeSort(int A[], int n)
{
     
    // step 为组内元素个数,step / 2 为左子区间元素个数,注意等号可以不取
    for (int step = 2; step / 2 <= n; step *= 2)
    {
     
        // 每 step 个元素一组,组内前 step / 2 和后 step / 2 个元素进行合并
        for (int i = 0; i < n; i += step) // 对每一组
        {
     
            int mid = i + step / 2 - 1; // 左子区间元素个数为 step / 2
            if (mid + 1 <= n) // 右子区间存在元素则合并
                // 左子区间为 [i, mid],右子区间为 [mid + 1, min(i + step - 1), n]
                Merge(A, i, mid, mid + 1, min((i + step - 1), n));
        }
    }
}

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

void mergeSort(int A[], int n)
{
     
    // step 为组内元素个数,step / 2 为左子区间元素个数,注意等号可以不取
    for (int step = 2; step / 2 <= n; step *= 2)
    {
     
        // 每 step 个元素一组,组内前 step / 2 和后 step / 2 个元素进行合并
        for (int i = 1; i <= n; i += step) // 对每一组
        {
     
            sort(A + i, A + min(i + step, n + 1));
        }
        // 此处输出归并排序的某一趟结束的序列
    }
}

选择排序

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

简单选择排序

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

基本思想:开始第1趟,首先从 A[0…(n - 1)] 中选出最小的元素,将其和 A[0] 交换元素值。第二趟就从 A[1…(n - 1)] 中选出最小的元素,将其和 A[1] 交换元素值。一直重复该步骤直到只剩下一个元素为止。
四大排序算法:插入、交换、选择以及归并排序_第2张图片

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;
        if (mini != i) // 如果发生过下标变化才交换
        {
     
            temp = A[mini];
            A[mini] = A[i];
            A[i] = temp;
        }
    }
}

堆排序

  • 空间复杂度:空间复杂度与树的高度有关,为 O ( h ) O(h) O(h)
  • 时间复杂度: O ( n ) O(n) O(n)
  • 稳定性:不稳定。

基本思想

你可能感兴趣的:(Data,Structures,And,Algorithms,排序算法,数据结构,算法导论)