算法入门(一):排序算法(上)

排序算法(上)

特征

首先我们要知道排序算法的好坏是怎么判别的,从而在实际应用中选择最合适的算法。一般来说,没有一种排序算法是严格好于其他所有算法的,这就要求我们熟悉各种各样的算法。

时间复杂度

提到一种方法的好坏,我们首先想到的就是这种算法需要多少时间,需要几次循环,几次遍历。

在常见排序算法中,基本上可以分为三类 O ( n 2 ) , O ( n log ⁡ n ) , O ( n + K ) O(n^2),O(n\log n),O(n+K) O(n2),O(nlogn),O(n+K)。我们的介绍顺序也是基于此的。

空间复杂度

算法需要占用多少内存,或者说栈帧空间也是我们要考虑的事。对于比较大的数组或者列表,如果占用的内存过多也是我们不愿看到的。

稳定性

有一个我们需要重视的地方在于,如果排序中碰到两个相等的数,我们不希望改变他们的相对顺序。在一重排序中这个性质无所谓,但在多重排序中发挥了关键作用。举个例子:在现实中,我们有可能是在对象的某个属性上进行排序。例如,学生有姓名和身高两个属性,我们希望实现一个多级排序

先按照姓名进行排序,得到$ (A, 180) (B, 185) (C, 170) (D, 170) $;接下来对身高进行排序。由于排序算法不稳定,我们可能得到 ( D , 170 ) ( C , 170 ) ( A , 180 ) ( B , 185 ) (D, 170) (C, 170) (A, 180) (B, 185) (D,170)(C,170)(A,180)(B,185)

可以发现,学生 D 和 C 的位置发生了交换,姓名的有序性被破坏了,而这是我们不希望看到的。

自适应性

非自适应排序指的是其性能不受输入数据的初始排序状态影响。换句话说,无论输入数据是部分排序还是完全随机,这些算法的执行时间和所需的操作步骤数量都保持不变。它们不会根据数据的初始状态“自适应”地调整自己的行为。

选择排序

「选择排序 selection sort」的工作原理非常直接:开启一个循环,每轮从未排序区间选择最小的元素,将其放到已排序区间的末尾。

设数组的长度为 n n n ,选择排序的算法流程如下图所示。

  1. 初始状态下,所有元素未排序,即未排序(索引)区间为 [ 0 , n − 1 ] [0, n-1] [0,n1]
  2. 选取区间 [ 0 , n − 1 ] [0, n-1] [0,n1] 中的最小元素,将其与索引 0 0 0 处元素交换。完成后,数组前 1 个元素已排序。
  3. 选取区间 [ 1 , n − 1 ] [1, n-1] [1,n1] 中的最小元素,将其与索引 1 1 1 处元素交换。完成后,数组前 2 个元素已排序。
  4. 以此类推。经过 n − 1 n - 1 n1 轮选择与交换后,数组前 n − 1 n - 1 n1 个元素已排序。
  5. 仅剩的一个元素必定是最大元素,无须排序,因此数组排序完成。
/* 选择排序 */
void selectionSort(vector<int> &nums) {
    int n = nums.size();
    // 外循环:未排序区间为 [i, n-1]
    for (int i = 0; i < n - 1; i++) {
        // 内循环:找到未排序区间内的最小元素
        int k = i;
        for (int j = i + 1; j < n; j++) {
            if (nums[j] < nums[k])
                k = j; // 记录最小元素的索引
        }
        // 将该最小元素与未排序区间的首个元素交换
        swap(nums[i], nums[k]);
    }
}
  • 时间复杂度为 O ( n 2 ) O(n^2) O(n2)、非自适应排序:外循环共 n − 1 n - 1 n1 轮,第一轮的未排序区间长度为 n n n ,最后一轮的未排序区间长度为 2 2 2 ,即各轮外循环分别包含 n n n n − 1 n - 1 n1 … \dots 3 3 3 2 2 2 轮内循环,求和为 ( n − 1 ) ( n + 2 ) 2 \frac{(n - 1)(n + 2)}{2} 2(n1)(n+2)
  • 空间复杂度 O ( 1 ) O(1) O(1)、原地排序:指针 i i i j j j 使用常数大小的额外空间。
  • 非稳定排序:元素 nums[i] 有可能被交换至与其相等的元素的右边,导致两者相对顺序发生改变。

对于这个算法我没有过多的评论,我的感觉是除了原地排序外没有其他的优点。硬要说的话可能就是代码简单。下面是我的一个简单示例,这里的数组 n u m s nums nums是有100 0000个元素,且每个元素在 ( 1 , 1000000 ) (1,100 0000) (1,1000000)之间。

int main(){
    //std::vector<int> nums = {9,8,7,6,5,4,3,2,1,0};
    std::ifstream file("/Users/orion008/C++_learn/random_numbers.txt");
    std::vector<int> nums;
    int number;

    while (file >> number) {
        nums.push_back(number);
    }


    /* 选择排序 */
    auto start = std::chrono::high_resolution_clock::now();
    selectionSort(nums); 
    auto end = std::chrono::high_resolution_clock::now();
    std::chrono::duration<double> elapsed = end - start;
    std::cout << "Function took " << elapsed.count() << " seconds to execute." << std::endl;
    int n = nums.size();
    for (int i = 0; i < 10 ; ++i){
        std::cout << nums[i] << " ";
    }
    std::cout << std::endl;
    return 0;
}

运行时间为:Function took 12.7828 seconds to execute.

前十个数字为:3 5 6 9 9 10 11 11 12 12

冒泡排序

设数组的长度为 n n n ,冒泡排序的步骤如下图所示。

  1. 首先,对 n n n 个元素执行“冒泡”,将数组的最大元素交换至正确位置
  2. 接下来,对剩余 n − 1 n - 1 n1 个元素执行“冒泡”,将第二大元素交换至正确位置
  3. 以此类推,经过 n − 1 n - 1 n1 轮“冒泡”后, n − 1 n - 1 n1 大的元素都被交换至正确位置
  4. 仅剩的一个元素必定是最小元素,无须排序,因此数组排序完成。

我们发现,如果某轮“冒泡”中没有执行任何交换操作,说明数组已经完成排序,可直接返回结果。因此,可以增加一个标志位 flag 来监测这种情况,一旦出现就立即返回。

/* 冒泡排序(标志优化)*/
void bubbleSortWithFlag(vector<int> &nums) {
    // 外循环:未排序区间为 [0, i]
    for (int i = nums.size() - 1; i > 0; i--) {
        bool flag = false; // 初始化标志位
        // 内循环:将未排序区间 [0, i] 中的最大元素交换至该区间的最右端
        for (int j = 0; j < i; j++) {
            if (nums[j] > nums[j + 1]) {
                // 交换 nums[j] 与 nums[j + 1]
                // 这里使用了 std::swap() 函数
                swap(nums[j], nums[j + 1]);
                flag = true; // 记录交换元素
            }
        }
        if (!flag)
            break; // 此轮冒泡未交换任何元素,直接跳出
    }
}
  • 时间复杂度为 O ( n 2 ) O(n^2) O(n2)、自适应排序:各轮“冒泡”遍历的数组长度依次为 n − 1 n - 1 n1 n − 2 n - 2 n2 … \dots 2 2 2 1 1 1 ,总和为 ( n − 1 ) n / 2 (n - 1) n / 2 (n1)n/2 。在引入 flag 优化后,最佳时间复杂度可达到 O ( n ) O(n) O(n)
  • 空间复杂度为 O ( 1 ) O(1) O(1)、原地排序:指针 i i i j j j 使用常数大小的额外空间。
  • 稳定排序:由于在“冒泡”中遇到相等元素不交换。

这样看我们看不出什么问题,直接对随机数排序,面对结果进行分析。

运行时间为: Function took 36.8393 seconds to execute.

前十个数字为: 3 5 6 9 9 10 11 11 12 12

冒泡排序和选择排序都是简单的排序算法,它们的时间复杂度在最坏情况下都是 O ( n 2 ) O(n^2) O(n2)。然而,这两种算法在元素交换的次数和比较的次数上有所不同,这影响了它们的执行速度。

冒泡排序的特点是:

  • 比较次数:对于长度为 n n n的数组,冒泡排序最坏情况下需要进行 n ( n − 1 ) 1 \frac{n(n-1)}{1} 1n(n1)次比较。

  • 交换次数:在最坏情况(完全逆序)下,冒泡排序也需要进行 n ( n − 1 ) 1 \frac{n(n-1)}{1} 1n(n1)次交换,因为每次比较都可能导致一次交换。

  • 重复遍历:每完成一次遍历,列表中的最大(或最小)元素就会被放到正确的位置。然后算法会重复遍历未排序的部分。

选择排序的特点是:

  • 比较次数:对于长度为 n n n的数组,选择排序最坏情况下也需要进行 n ( n − 1 ) 2 \frac{n(n-1)}{2} 2n(n1)次比较。

  • 交换次数:选择排序在每轮选择中只进行一次交换,因此总共进行 n − 1 n-1 n1次交换。

  • 减少交换:由于选择排序在每轮只进行一次交换,这使得它在包含大量数据的列表中比冒泡排序更有效率。

为什么冒泡排序更慢?

尽管两种算法在比较次数上相似,冒泡排序由于在每次比较时都可能进行交换,因此交换次数远多于选择排序。数据交换(尤其是在内存中)通常比比较操作更耗时,因为它涉及到更多的读写操作。因此,即使在相同数量的比较下,冒泡排序由于其较多的交换操作而通常比选择排序慢。

总结

选择排序通常比冒泡排序更快,主要是因为它在排序过程中进行的数据交换次数较少。尽管两者在理论上的最坏情况时间复杂度相同,但实际执行效率会受到数据交换成本的影响。

插入排序

插入排序的整体流程如下图所示。

  1. 初始状态下,数组的第 1 个元素已完成排序。
  2. 选取数组的第 2 个元素作为 base ,将其插入到正确位置后,数组的前 2 个元素已排序
  3. 选取第 3 个元素作为 base ,将其插入到正确位置后,数组的前 3 个元素已排序
  4. 以此类推,在最后一轮中,选取最后一个元素作为 base ,将其插入到正确位置后,所有元素均已排序
/* 插入排序 */
void insertionSort(vector<int> &nums) {
    // 外循环:已排序元素数量为 1, 2, ..., n
    for (int i = 1; i < nums.size(); i++) {
        int base = nums[i], j = i - 1;
        // 内循环:将 base 插入到已排序部分的正确位置
        while (j >= 0 && nums[j] > base) {
            nums[j + 1] = nums[j]; // 将 nums[j] 向右移动一位
            j--;
        }
        nums[j + 1] = base; // 将 base 赋值到正确位置
    }
}
  • 时间复杂度 O ( n 2 ) O(n^2) O(n2)、自适应排序:最差情况下,每次插入操作分别需要循环 n − 1 n - 1 n1 n − 2 n-2 n2 … \dots 2 2 2 1 1 1 次,求和得到 ( n − 1 ) n / 2 (n - 1) n / 2 (n1)n/2 ,因此时间复杂度为 O ( n 2 ) O(n^2) O(n2) 。在遇到有序数据时,插入操作会提前终止。当输入数组完全有序时,插入排序达到最佳时间复杂度 O ( n ) O(n) O(n)
  • 空间复杂度 O ( 1 ) O(1) O(1)、原地排序:指针 i i i j j j 使用常数大小的额外空间。
  • 稳定排序:在插入操作过程中,我们会将元素插入到相等元素的右侧,不会改变它们的顺序。

运行时间为: Function took 8.62171 seconds to execute.

前十个数字为: 3 5 6 9 9 10 11 11 12 12

插入排序的时间复杂度为 O ( n 2 ) O(n^2) O(n2) ,而我们即将学习的快速排序的时间复杂度为 O ( n log ⁡ n ) O(n \log n) O(nlogn) 。尽管插入排序的时间复杂度相比快速排序更高,但在数据量较小的情况下,插入排序通常更快

这个结论与线性查找和二分查找的适用情况的结论类似。快速排序这类 O ( n log ⁡ n ) O(n \log n) O(nlogn) 的算法属于基于分治的排序算法,往往包含更多单元计算操作。而在数据量较小时, n 2 n^2 n2 n log ⁡ n n \log n nlogn 的数值比较接近,复杂度不占主导作用;每轮中的单元操作数量起到决定性因素。

实际上,许多编程语言(例如 Java)的内置排序函数都采用了插入排序,大致思路为:对于长数组,采用基于分治的排序算法,例如快速排序;对于短数组,直接使用插入排序。

虽然冒泡排序、选择排序和插入排序的时间复杂度都为 O ( n 2 ) O(n^2) O(n2) ,但在实际情况中,插入排序的使用频率显著高于冒泡排序和选择排序,主要有以下原因。

  • 冒泡排序基于元素交换实现,需要借助一个临时变量,共涉及 3 个单元操作;插入排序基于元素赋值实现,仅需 1 个单元操作。因此,冒泡排序的计算开销通常比插入排序更高
  • 选择排序在任何情况下的时间复杂度都为 O ( n 2 ) O(n^2) O(n2)如果给定一组部分有序的数据,插入排序通常比选择排序效率更高
  • 选择排序不稳定,无法应用于多级排序。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值