10 排序算法:冒泡排序与快速排序(算法原理、算法实现、时间和空间复杂度分析)

目录

1 十大常见的排序算法

1.1 算法的稳定性

2 冒泡排序

2.1 算法原理

2.2 算法实现

2.3 时间空间复杂度分析

2.3.1 时间复杂度分析

2.3.2 空间复杂度分析

3 快速排序

3.1 算法原理

3.1.1 排序思想

3.1.2 递归过程

3.2 示例

3.2.1 示例 1

3.2.2 示例 2

3.2.3 示例 3

3.3 算法实现

3.4 时间空间复杂度分析

3.4.1 时间复杂度分析

3.4.2 空间复杂度分析


1 十大常见的排序算法

        排序算法众多,实现方式各异,其时间复杂度、空间复杂度和稳定性各不相同,如下图所示:

1.1 算法的稳定性

  • 稳定排序算法:如果一个排序算法在排序前后,相等的元素之间的相对顺序保持不变,那么这个算法是稳定的
  • 不稳定排序算法:如果一个排序算法在排序前后,相等的元素之间的相对顺序可能发生改变,那么这个算法是不稳定的

        假设有一个学生记录列表,每个记录包含学生的姓名和成绩:

[("Alice", 85), ("Bob", 90), ("Charlie", 85), ("David", 90)]

按成绩升序排序:

  • 稳定排序算法

    • 排序结果:[("Alice", 85), ("Charlie", 85), ("Bob", 90), ("David", 90)]
    • 相同成绩的学生(85 分的 Alice 和 Charlie,90 分的 Bob 和 David)保持了原来的顺序
  • 不稳定排序算法

    • 排序结果:[("Charlie", 85), ("Alice", 85), ("David", 90), ("Bob", 90)]
    • 相同成绩的学生的顺序可能发生变化。

2 冒泡排序

2.1 算法原理

        冒泡排序是一种直观且易于理解的排序算法,其基本思想是通过不断地交换相邻的、顺序错误的元素,逐步将较大的元素(如果是升序排列的话)像水泡一样 “浮” 到序列的末尾。具体步骤如下:

        1. 比较相邻元素:从数组的第一个元素开始,逐一比较相邻的两个元素。

        2. 交换位置:如果前一个元素大于后一个元素(对于升序排序而言),则交换这两个元素的位置。如果是降序排序,则当发现前一个元素小于后一个元素时进行交换。

        3. 一趟遍历完成:经过一次完整的遍历后,数组中最大的元素(升序时)或最小的元素(降序时)会被移动到数组的最后一个位置。

        4. 重复遍历与交换:在排除了已排序的最后一个元素后,再次从头开始重复第 1 步和第 2 步的操作,直到整个数组中的所有元素都被正确地排序。

        5. 循环遍历直至完全排序:整个过程会不断重复,每完成一次遍历就使得更多的元素处于正确的位置,直到没有更多的交换发生,即整个数组被完全排序为止。

        通过上述步骤,冒泡排序能够有效地对一组数字进行排序,尽管它的效率在大规模数据集上不如更高级的排序算法,如快速排序或归并排序,但对于小规模数据集来说,冒泡排序仍然是一个很好的选择

        如下图所示:

2.2 算法实现

#include <stdio.h>

// 冒泡排序函数
void bubbleSort(int arr[], int size)
{
    // 外层循环控制遍历次数(n-1)次
    for (int i = 0; i < size - 1; i++)
    {
        // 内层循环进行相邻元素的比较和交换 (n-1-i)次
        for (int j = 0; j < size - i - 1; j++)
        {
            // 如果前一个元素大于后一个元素,则交换
            if (arr[j] > arr[j + 1])
            {
                // 交换 arr[j] 和 arr[j+1]
                int temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;
            }
        }
    }
}

int main()
{
    // 定义一个待排序的数组
    int arr[] = {3, 1, 5, 4, 2};
    int size = sizeof(arr) / sizeof(arr[0]); // 计算数组长度

    // 输出原始数组
    printf("原始数组: ");
    for (int i = 0; i < size; i++)
    {
        printf("%d ", arr[i]);
    }
    printf("\n");

    // 调用冒泡排序函数
    bubbleSort(arr, size); // 传递数组名即传递数组首元素的地址

    // 输出排序后的数组
    printf("排序后的数组: ");
    for (int i = 0; i < size; i++)
    {
        printf("%d ", arr[i]);
    }
    printf("\n");

    return 0;
}

        输出结果如下所示: 

        下面是优化后的冒泡排序:

#include <stdio.h>

// 冒泡排序函数
void bubbleSort(int arr[], int size)
{
    // 外层循环控制遍历次数 (n-1)次
    for (int i = 0; i < size - 1; i++)
    {
        // 设置标志位,用于检测本轮是否有元素交换
        int swapped = 0;

        // 内层循环进行相邻元素的比较和交换 (n-1-i)次
        for (int j = 0; j < size - i - 1; j++)
        {
            // 如果前一个元素大于后一个元素,则交换
            if (arr[j] > arr[j + 1])
            {
                // 交换 arr[j] 和 arr[j+1]
                int temp = arr[j];
                arr[j] = arr[j + 1];
                arr[j + 1] = temp;

                // 标记发生了交换
                swapped = 1;
            }
        }

        // 如果没有发生任何交换,说明数组已经有序,提前退出,避免不必要的遍历。
        if (!swapped)
        {
            break;
        }
    }
}

优化点解释:

        设置标志位 swapped

  • 在每次内层循环开始时,设置一个标志位 swapped 为 0。
  • 如果在内层循环中发生了元素交换,将 swapped 设置为 1。
  • 内层循环结束后,检查 swapped 是否仍为 0。如果是,则说明数组已经有序,可以提前退出外层循环,避免不必要的遍历

注意:

        即使外层循环 i < size - 1 不小心写成了 i < size,内层循环 j < size - i - 1 不小心写成了 j < size - 1,最终的结果也不会错。这是因为在这些情况下,虽然会进行一些多余的比较和交换,但不会影响最终的排序结果。

  1. 外层循环 i < size

    • 正确的外层循环条件是 i < size - 1,这样可以确保在最后一趟遍历时,只剩下最后一个元素,而这个元素已经是有序的。
    • 如果写成 i < size,外层循环会多进行一次遍历,即进行 size 次遍历。
    • 在第 size 次遍历时,内层循环 j < size - 1 会比较所有相邻的元素,但由于数组已经排序完成,不会有元素交换发生。因此,这次遍历是多余的,但不会影响最终的排序结果。
  2. 内层循环 j < size - 1

    • 正确的内层循环条件是 j < size - i - 1,这样可以确保每次遍历只比较未排序部分的元素。
    • 如果写成 j < size - 1,内层循环会比较所有相邻的元素,包括已经排序好的部分。
    • 由于已经排序好的部分不会再发生交换,这些多余的比较不会影响最终的排序结果。

        尽管这些错误不会影响最终的排序结果,但会导致不必要的比较和交换,从而降低算法的效率。因此,建议使用正确的循环条件以提高性能。

2.3 时间空间复杂度分析

2.3.1 时间复杂度分析

  • 最坏情况(Worst Case)

    • 当输入数组是完全逆序的时,冒泡排序需要进行最多的比较和交换操作
    • 每次内层循环都需要进行 n−1 次比较和最多 n−1 次交换
    • 因此,总的时间复杂度为 O(n^2)
  • 最好情况(Best Case)

    • 当输入数组已经是有序的时,冒泡排序只需要进行一次完整的遍历,而且不会进行任何交换
    • 使用优化后的冒泡排序(带有标志位 swapped),可以在第一次遍历后就提前结束。
    • 因此,总的时间复杂度为 O(n)
  • 平均情况(Average Case)

    • 对于随机分布的数组,冒泡排序的时间复杂度通常也是 O(n^2)
    • 这是因为在大多数情况下,数组不会完全有序,也不会完全逆序,但仍然需要多次比较和交换。

2.3.2 空间复杂度分析

  • 原地排序
    • 冒泡排序是一种原地排序算法,即它只需要常数级的额外存储空间。
    • 主要的额外空间用于临时变量(如交换元素时使用的 temp 变量)。
    • 因此,空间复杂度为 O(1)

3 快速排序

3.1 算法原理

        快速排序(Quick Sort)是由图灵奖获得者 Tony Hoare 发明的一种高效排序算法,被列为 20 世纪十大算法之。快速排序以其卓越的性能和简洁的实现方式,在实际应用中非常广泛,尤其是在处理大规模数据时表现尤为出色。快速排序的时间复杂度为 O(n log n)通常比其他同样具有 O(n log ⁡n) 时间复杂度的排序算法更快

3.1.1 排序思想

        快速排序的核心思想是 “分治法”,即通过递归的方式将大问题分解成小问题来解决。具体步骤如下:

        1. 选择基准(Pivot)从数列中挑选一个元素作为 “基准”(pivot)。选择基准的方法有多种,常见的包括选择第一个元素、最后一个元素、中间元素或随机选择一个元素。

        2. 分区(Partition)重新排列数列,使得所有比基准值小的元素都摆放在基准的前面,所有比基准值大的元素都摆放在基准的后面。相同的元素可以任意放置。在这个分区操作结束之后,基准元素会处于数列的中间位置,这个位置称为基准的最终位置。

        3. 递归排序递归地对基准左侧的子数列和右侧的子数列进行快速排序。递归的最底层情况是子数列的大小为零或一,这时子数列已经自然排序好了。

3.1.2 递归过程

  • 递归的最底层当子数列的大小为零或一时,递归结束。这是因为单个元素或空数列自然是有序的。

  • 递归的迭代在每次递归调用中,快速排序都会将一个元素放到其最终位置。因此,随着递归的进行,越来越多的元素会被正确排序,最终整个数列将变得有序。

3.2 示例

3.2.1 示例 1

        假设有一个数列 [3, 6, 8, 10, 1, 2, 1],我们选择第一个元素 3 作为基准:

1. 分区

  • 重新排列数列,使得所有比 3 小的元素在左边,所有比 3 大的元素在右边。结果可能是 [1, 2, 1, 3, 10, 6, 8]

2. 递归排序

  • 对基准左侧的子数列 [1, 2, 1] 进行快速排序。
  • 对基准右侧的子数列 [10, 6, 8] 进行快速排序。

3. 递归结束

  • 当子数列的大小为零或一时,递归结束。

        通过上述步骤,快速排序能够高效地对数列进行排序。尽管快速排序在最坏情况下(例如数列已经有序或完全逆序)的时间复杂度退化为 O(n^2),但在实际应用中,通过合理选择基准和优化分区操作,快速排序通常能保持其优秀的性能。

3.2.2 示例 2

3.2.3 示例 3

        第 1 轮操作:

        第 2 轮操作:

3.3 算法实现

#include <stdio.h>


// 子排序函数,实现快速排序的核心逻辑
void subSort(int arr[], int start, int end)
{
    if (start < end)
    {
        // 选择基准元素,这里选择数组的第一个元素
        int base = arr[start];
        int low = start;
        int high = end + 1;

        // 分区操作
        while (1)
        {
            // 从左向右查找大于基准的元素
            while (low < end && arr[++low] <= base)
                ;

            // 从右向左查找小于基准的元素
            while (high > start && arr[--high] >= base)
                ;

            // 如果找到了需要交换的元素
            if (low < high)
            {
                // 交换两个位置的元素
                int temp = arr[low];
                arr[low] = arr[high];
                arr[high] = temp;
            }
            else
            {
                // 分区结束,跳出循环
                break;
            }
        }

        // 交换基准元素和 high 位置的元素
        int temp1 = arr[start];
        arr[start] = arr[high];
        arr[high] = temp1;

        // 递归调用子排序函数,对基准左侧和右侧的子数组进行排序
        subSort(arr, start, high - 1);
        subSort(arr, high + 1, end);
    }
}

// 快速排序主函数
void quickSort(int arr[], int size)
{
    // 调用子排序函数,从数组的第一个元素到最后一个元素
    subSort(arr, 0, size - 1);
}

// 打印数组
void print(int arr[], int size)
{
    for (int i = 0; i < size; i++)
    {
        printf("%d  ", arr[i]);
    }
    printf("\n");
}

int main()
{
    // 定义一个待排序的数组
    int arr[] = {9, -16, 40, 23, -30, -49, 25, 21, 30};
    int length = sizeof(arr) / sizeof(int); // 计算数组长度

    // 输出排序前的数组
    printf("排序前的数组: ");
    print(arr, length);

    // 调用快速排序函数
    quickSort(arr, length);

    // 输出排序后的数组
    printf("排序后的数组: ");
    print(arr, length);


    return 0;
}

        输出结果如下所示:

        另一种实现方法:

#include <stdio.h>

// 函数声明
void quickSort(int arr[], int low, int high);
int partition(int arr[], int low, int high);

// 主函数
int main()
{
    int arr[] = {10, 7, 8, 9, 1, 5, -13, -20, -19, -50};
    int n = sizeof(arr) / sizeof(arr[0]);

    printf("原始数组: \n");
    for (int i = 0; i < n; i++)
    {
        printf("%d ", arr[i]);
    }

    // 调用快速排序函数
    quickSort(arr, 0, n - 1);

    printf("\n排序后的数组: \n");
    for (int i = 0; i < n; i++)
    {
        printf("%d ", arr[i]);
    }

    return 0;
}

// 快速排序函数
void quickSort(int arr[], int low, int high)
{
    if (low < high)
    {
        // pi 是分区操作后的基准元素索引
        int pi = partition(arr, low, high);

        // 分别对基准元素左边和右边的子数组进行递归排序
        quickSort(arr, low, pi - 1);  // 排序左子数组
        quickSort(arr, pi + 1, high); // 排序右子数组
    }
}

// 分区函数
int partition(int arr[], int low, int high)
{
    int pivot = arr[high]; // 选择最后一个元素作为基准
    int i = (low - 1);     // i 指向较小元素的索引

    for (int j = low; j <= high - 1; j++)
    {
        // 如果当前元素小于或等于基准
        if (arr[j] <= pivot)
        {
            i++; // 增加较小元素的索引
            // 交换 arr[i] 和 arr[j]
            int temp = arr[i];
            arr[i] = arr[j];
            arr[j] = temp;
        }
    }
    // 交换 arr[i+1] 和 arr[high] (或 pivot)
    int temp = arr[i + 1];
    arr[i + 1] = arr[high];
    arr[high] = temp;

    return (i + 1); // 返回基准元素的最终位置
}

        输出结果如下所示:

3.4 时间空间复杂度分析

3.4.1 时间复杂度分析

  • 最坏情况(Worst Case)

    • 当输入数组已经有序或完全逆序时,每次划分只能将数组分成一个元素和剩下的元素,导致递归深度为 n。
    • 每次划分需要 O(n) 的时间,因此总的时间复杂度为 O(n^2)
  • 最好情况(Best Case)

    • 当每次划分都能将数组均匀分成两个相等的部分时,递归深度为 log n。
    • 每次划分需要 O(n) 的时间,因此总的时间复杂度为 O(n log n)
  • 平均情况(Average Case)

    • 对于随机分布的数组,快速排序的平均时间复杂度也是 O(n log n)。
    • 这是因为在大多数情况下,数组的划分接近均匀。

3.4.2 空间复杂度分析

  • 递归栈空间

    • 快速排序是一个递归算法,递归调用的深度决定了所需的空间。
    • 在最坏情况下,递归深度为 n,因此空间复杂度为 O(n)。
    • 在最好和平均情况下,递归深度为 log⁡ n,因此空间复杂度为 O(log n)
  • 辅助空间

    • 快速排序是原地排序算法,除了递归栈空间外,只需要常数级的额外存储空间。
    • 因此,辅助空间复杂度为 O(1)。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Thanks_ks

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值