《数据结构——排序(中)》选择与交换的艺术:从直接选择到堆排序的性能跃迁

目录

[1. 选择排序与交换排序的核心思想](#1. 选择排序与交换排序的核心思想)

[1.1 选择排序思想](#1.1 选择排序思想)

[1.2 交换排序思想](#1.2 交换排序思想)

[1.3 为什么需要学习这些"低效"算法?](#1.3 为什么需要学习这些"低效"算法?)

[2. 直接选择排序:简单但有坑](#2. 直接选择排序:简单但有坑)

[2.1 基本思想](#2.1 基本思想)

[2.2 代码实现](#2.2 代码实现)

[2.3 性能分析](#2.3 性能分析)

[2.4 边界条件的特殊处理](#2.4 边界条件的特殊处理)

[2.5 适用场景](#2.5 适用场景)

[3. 堆排序:选择排序的高效进化版](#3. 堆排序:选择排序的高效进化版)

[3.1 堆数据结构回顾](#3.1 堆数据结构回顾)

[3.2 为什么升序要建大堆?](#3.2 为什么升序要建大堆?)

[3.3 代码实现](#3.3 代码实现)

[3.4 性能分析](#3.4 性能分析)

[3.5 堆调整的边界错误](#3.5 堆调整的边界错误)

[3.6 LeetCode实战:Top K 问题](#3.6 LeetCode实战:Top K 问题)

[4. 冒泡排序:交换排序的基础](#4. 冒泡排序:交换排序的基础)

[4.1 基本思想](#4.1 基本思想)

[4.2 代码实现](#4.2 代码实现)

[4.3 性能分析](#4.3 性能分析)

[4.4 适用场景](#4.4 适用场景)

[4.5 个人学习心得:优化带来的性能飞跃](#4.5 个人学习心得:优化带来的性能飞跃)

[5. 三种排序算法对比与适用场景](#5. 三种排序算法对比与适用场景)

[6. 思考题](#6. 思考题)

[7. 下期预告](#7. 下期预告)



排序算法系列中篇:上篇我们学习了插入排序家族,本篇将深入探讨选择排序与交换排序。作为大二学生,我曾经觉得选择排序"太简单",直到在数据结构实验课上被它的不稳定性坑惨...本文带你彻底掌握直接选择、堆排序和冒泡排序,文末还有LeetCode实战题解析!

1. 选择排序与交换排序的核心思想

在上篇中,我们学习了基于"插入"思想的排序算法。本篇将探讨两种新的排序思想:选择交换

1.1 选择排序思想

选择排序的基本思想非常直观:每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完。

1.2 交换排序思想

交换排序的基本思想是:根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置。它的特点是将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。

学习心得:选择排序和插入排序有什么区别

通过画图才明白:插入排序是"构建有序序列",而选择排序是"定位最值位置"。这个区别看似微小,却导致了它们在性能和稳定性上的巨大差异。

1.3 为什么需要学习这些"低效"算法?

你可能会问:既然这些算法时间复杂度都是O(N²),为什么还要学?作为大二学生,我曾经也有这样的疑惑。但后来在一次面试中,面试官问:

"如果给你1000个基本有序的数据,你会选择什么排序算法?为什么?"

这个问题让我明白:没有绝对最优的算法,只有最适合场景的算法。这也是本系列要传达的核心思想。

2. 直接选择排序:简单但有坑

2.1 基本思想

直接选择排序的步骤非常简单:

  1. 在元素集合array[i]~array[n-1]中选择关键码最大(小)的数据元素
  2. 若它不是这组元素中的最后一个(第一个)元素,则将它与这组元素中的最后一个(第一个)元素交换
  3. 在剩余的array[i+1]~array[n-1]集合中,重复上述步骤,直到集合剩余1个元素

2.2 代码实现

复制代码
#include <iostream>
using namespace std;

// 交换两个整数
void swap(int* a, int* b) {
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

// 直接选择排序
void SelectSort(int* a, int n) {
    int begin = 0, end = n - 1;
    while (begin < end) {
        int mini = begin, maxi = begin;
        
        // 选择最大和最小的元素
        for (int i = begin; i <= end; i++) {
            if (a[i] > a[maxi]) {
                maxi = i;
            }
            if (a[i] < a[mini]) {
                mini = i;
            }
        }
        
        // 处理特殊情况:begin位置就是最大值
        if (begin == maxi) {
            maxi = mini;
        }
        
        // 将最小值交换到begin位置
        swap(&a[mini], &a[begin]);
        // 将最大值交换到end位置
        swap(&a[maxi], &a[end]);
        
        ++begin;
        --end;
    }
}

// 打印数组
void PrintArray(int* a, int n) {
    for (int i = 0; i < n; i++) {
        cout << a[i] << " ";
    }
    cout << endl;
}

int main() {
    int a[] = {5, 3, 9, 6, 2, 4, 7, 1, 8};
    int n = sizeof(a) / sizeof(a[0]);
    
    cout << "排序前: ";
    PrintArray(a, n);
    
    SelectSort(a, n);
    
    cout << "排序后: ";
    PrintArray(a, n);
    
    return 0;
}

2.3 性能分析

  • 时间复杂度 :O(N²)
    • 无论数据是否有序,都需要进行n(n-1)/2次比较
  • 空间复杂度:O(1),只需要常数个额外空间
  • 稳定性不稳定

直接选择排序思考⾮常好理解,但是效率不是很好。实际中很少使⽤

稳定性验证 :考虑序列5 8 5 2 9。第一次选择最小值2,与第一个5交换,序列变为2 8 5 5 9。注意,两个5的相对顺序已经改变!这就是为什么直接选择排序是不稳定的。

2.4 边界条件的特殊处理

在数据结构实验课上,我按照教材实现了基础版选择排序(只找最小值),但老师要求我们实现优化版(同时找最大最小值,两端同时排序)。我的初始代码是这样的:

复制代码
void buggySelectSort(int* a, int n) {
    int begin = 0, end = n - 1;
    while (begin < end) {
        int mini = begin, maxi = begin;
        for (int i = begin; i <= end; i++) {
            if (a[i] > a[maxi]) maxi = i;
            if (a[i] < a[mini]) mini = i;
        }
        
        // 错误的交换顺序
        swap(&a[mini], &a[begin]);
        swap(&a[maxi], &a[end]); // 这里可能出错!
        
        begin++;
        end--;
    }
}

用测试用例{1, 5, 4, 3, 2}测试时,结果变成了{1, 2, 4, 5, 3},明显有问题。经过一晚上的调试,我发现当maxi == begin时,第一次交换会改变maxi位置的值,导致第二次交换出错。

调试心得 :在算法实现中,边界条件特殊情况 是最容易出错的地方。通过绘制交换过程图,我最终理解了为什么需要先判断if (begin == maxi) { maxi = mini; }。这个bug让我深刻认识到,算法不仅要逻辑正确,还要考虑各种边界情况。

2.5 适用场景

直接选择排序由于其简单的特性,适用于:

  • 数据量小(n < 100)
  • 对稳定性没有要求
  • 内存受限的环境(因为它是原地排序)

3. 堆排序:选择排序的高效进化版

3.1 堆数据结构回顾

在深入堆排序前,先回顾一下的概念:

  • 是一种特殊的完全二叉树
  • 大顶堆:每个节点的值都大于或等于其子节点的值
  • 小顶堆:每个节点的值都小于或等于其子节点的值

个人感悟:学堆的时候,我一直分不清大顶堆和小顶堆。直到我记住了这个口诀:"大顶堆,堆顶最大;小顶堆,堆顶最小"。理解了这个,堆排序就容易多了。

3.2 为什么升序要建大堆?

这是初学者最常困惑的问题之一。让我通过一个例子说明:

假设我们要对{5, 3, 9, 6, 2}进行升序排序(从小到大):

  1. 如果建大顶堆:堆顶是9(最大值)
  2. 将9交换到数组末尾,此时末尾就是最大值
  3. 调整剩余元素成大顶堆,堆顶是6(次大值)
  4. 将6交换到倒数第二位置
  5. 重复这个过程...

关键点:每次都将当前最大值放到已排序部分的开头,这样最后整个数组就是升序的。

反之,如果要降序排序,就应该建小顶堆,每次将最小值放到已排序部分的开头。

3.3 代码实现

复制代码
#include <iostream>
using namespace std;

// 交换两个整数
void swap(int* a, int* b) {
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

// 向下调整(建大堆)
void AdjustDown(int* a, int n, int parent) {
    int child = parent * 2 + 1;
    while (child < n) {
        // 选择左右孩子中较大的一个
        if (child + 1 < n && a[child + 1] > a[child]) {
            child++;
        }
        
        // 如果父节点小于最大孩子,交换
        if (a[parent] < a[child]) {
            swap(&a[parent], &a[child]);
            parent = child;
            child = parent * 2 + 1;
        } else {
            break;
        }
    }
}

// 堆排序(升序)
void HeapSort(int* a, int n) {
    // 1. 建堆 - 向下调整建大堆
    for (int i = (n - 1 - 1) / 2; i >= 0; i--) {
        AdjustDown(a, n, i);
    }
    
    // 2. 排序
    int end = n - 1;
    while (end > 0) {
        // 将堆顶(最大值)交换到末尾
        swap(&a[0], &a[end]);
        // 调整剩余元素为大堆
        AdjustDown(a, end, 0);
        end--;
    }
}

// 打印数组
void PrintArray(int* a, int n) {
    for (int i = 0; i < n; i++) {
        cout << a[i] << " ";
    }
    cout << endl;
}

int main() {
    int a[] = {5, 3, 9, 6, 2, 4, 7, 1, 8};
    int n = sizeof(a) / sizeof(a[0]);
    
    cout << "排序前: ";
    PrintArray(a, n);
    
    HeapSort(a, n);
    
    cout << "排序后: ";
    PrintArray(a, n);
    
    return 0;
}

3.4 性能分析

  • 时间复杂度 :O(NlogN)
    • 建堆时间复杂度:O(N)
    • 每次调整时间复杂度:O(logN),共需调整N次
  • 空间复杂度:O(1),原地排序
  • 稳定性不稳定

稳定性验证 :考虑序列2 2 2 2。在建堆和调整过程中,相同元素的相对位置可能会改变,因此堆排序是不稳定的。

3.5 堆调整的边界错误

在一次堆排序实验中,我的代码总是得到错误结果。特别是当数组长度为偶数时,排序结果的最后两个元素总是颠倒的。

复制代码
void buggyAdjustDown(int* a, int n, int parent) {
    int child = parent * 2 + 1;
    while (child < n) {
        // 错误:没有检查child+1是否越界
        if (a[child + 1] > a[child]) { // 当child是最后一个元素时,会越界访问
            child++;
        }
        
        if (a[parent] < a[child]) {
            swap(&a[parent], &a[child]);
            parent = child;
            child = parent * 2 + 1;
        } else {
            break;
        }
    }
}

这个bug在小数据测试时很难发现,但当数组较大时会导致程序崩溃。通过添加边界条件检查if (child + 1 < n && a[child + 1] > a[child]),问题终于解决了。

调试心得:堆调整过程中的边界条件尤为重要。建议在调整过程中添加打印语句,观察每次交换后堆的状态,这样能更快定位问题。我在调试时用纸笔画出了整个调整过程,虽然费时,但对理解算法原理帮助很大。

3.6 LeetCode实战:Top K 问题

LeetCode 215. Kth Largest Element in an Array 是堆排序的经典应用场景。

题目要求:找到数组中第k大的元素。

解题思路

  1. 构建大小为k的小顶堆

  2. 遍历剩余元素,如果比堆顶大,则替换堆顶并调整

  3. 最后堆顶就是第k大的元素

    class Solution {
    public:
    void AdjustDown(vector<int>& nums, int n, int parent) {
    int child = parent * 2 + 1;
    while (child < n) {
    if (child + 1 < n && nums[child + 1] < nums[child]) {
    child++;
    }
    if (nums[parent] > nums[child]) {
    swap(nums[parent], nums[child]);
    parent = child;
    child = parent * 2 + 1;
    } else {
    break;
    }
    }
    }

    复制代码
     int findKthLargest(vector<int>& nums, int k) {
         // 构建大小为k的小顶堆
         for (int i = (k - 1 - 1) / 2; i >= 0; i--) {
             AdjustDown(nums, k, i);
         }
         
         // 遍历剩余元素
         for (int i = k; i < nums.size(); i++) {
             if (nums[i] > nums[0]) {
                 swap(nums[i], nums[0]);
                 AdjustDown(nums, k, 0);
             }
         }
         
         return nums[0];
     }

    };

4. 冒泡排序:交换排序的基础

4.1 基本思想

冒泡排序之所以叫做冒泡排序,因为每一个元素都可以像小气泡一样,根据自身大小一点一点向数组的一侧移动。

具体过程:

  1. 比较相邻的元素,如果第一个比第二个大,就交换它们
  2. 对每一对相邻元素做同样的工作,从开始第一对到结尾的最后一对
  3. 针对所有的元素重复以上的步骤,除了最后一个
  4. 持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较

4.2 代码实现

复制代码
#include <iostream>
using namespace std;

// 交换两个整数
void swap(int* a, int* b) {
    int tmp = *a;
    *a = *b;
    *b = tmp;
}

// 冒泡排序
void BubbleSort(int* a, int n) {
    for (int i = 0; i < n; i++) {
        bool exchange = false; // 优化:判断是否发生交换
        
        for (int j = 0; j < n - i - 1; j++) {
            if (a[j] > a[j + 1]) {
                swap(&a[j], &a[j + 1]);
                exchange = true;
            }
        }
        
        // 优化:如果没有发生交换,说明已经有序
        if (!exchange) {
            break;
        }
    }
}

// 打印数组
void PrintArray(int* a, int n) {
    for (int i = 0; i < n; i++) {
        cout << a[i] << " ";
    }
    cout << endl;
}

int main() {
    int a[] = {5, 3, 9, 6, 2, 4, 7, 1, 8};
    int n = sizeof(a) / sizeof(a[0]);
    
    cout << "排序前: ";
    PrintArray(a, n);
    
    BubbleSort(a, n);
    
    cout << "排序后: ";
    PrintArray(a, n);
    
    return 0;
}

4.3 性能分析

  • 时间复杂度
    • 最好情况(已经有序):O(N),通过优化可提前终止
    • 最坏情况(完全逆序):O(N²)
    • 平均情况:O(N²)
  • 空间复杂度:O(1),只需要一个交换变量
  • 稳定性稳定,相同元素不会改变相对顺序

4.4 适用场景

冒泡排序在以下场景表现较好:

  • 小规模数据(n < 100)
  • 基本有序的数据
  • 教学演示(因为原理简单直观)

4.5 优化带来的性能飞跃

在数据结构实验中,我实现了一个基础版的冒泡排序(没有优化),当测试10000个随机数据时,运行时间超过10秒!后来加上了"提前终止"优化,对于基本有序的数据,性能提升非常明显。

性能对比

  • 10000个随机数据:基础版 12.5秒 vs 优化版 11.8秒(提升不大,因为数据无序)
  • 10000个基本有序数据:基础版 11.2秒 vs 优化版 0.03秒(提升373倍!)

这个例子就深刻体现了算法优化的重要性,以及为什么需要根据数据特点选择合适的算法。

5. 三种排序算法对比与适用场景

选择指南

  • 数据量 < 100 且需要稳定排序:选择冒泡排序(或插入排序)
  • 数据量 100~100万 且不需要稳定排序:选择堆排序
  • 数据量 > 100万:考虑快速排序归并排序(下篇会讲)
  • 有特殊要求(如Top K问题):堆排序是首选

6. 思考题

  1. 为什么堆排序在实际应用中比快速排序更少使用,尽管它们的时间复杂度相同
  2. 在什么情况下,冒泡排序的优化版能达到O(N)的时间复杂度

7. 下期预告

下篇将深入剖析分治思想的代表算法 (快速排序、归并排序) 为什么快速排序有多种实现方式(Hoare/挖坑/Lomuto)?归并排序为什么是稳定的?计数排序如何实现O(N)的时间复杂度?重磅彩蛋:我们将复现PDF中的性能测试代码,对比8大排序算法在10万数据量下的真实表现,并给出工程实战中的选型建议!

下期亮点:通过一个LeetCode 912. Sort an Array的真实案例,展示如何在面试中选择最优排序算法,以及C++ STL sort()背后的混合排序算法(Introsort)原理大揭秘!


系列导航

  • 上篇\] [插入排序与希尔排序](https://blog.csdn.net/dongaoran/article/details/155465607?fromshare=blogdetail&sharetype=blogdetail&sharerId=155465607&sharerefer=PC&sharesource=dongaoran&sharefrom=from_link "插入排序与希尔排序")

  • 下篇\] [快速排序、归并排序与非比较排序 + 全面性能对比](https://blog.csdn.net/dongaoran/article/details/155492909?fromshare=blogdetail&sharetype=blogdetail&sharerId=155492909&sharerefer=PC&sharesource=dongaoran&sharefrom=from_link "快速排序、归并排序与非比较排序 + 全面性能对比")

相关推荐
程序员-King.44 分钟前
day104—对向双指针—接雨水(LeetCode-42)
算法·贪心算法
牢七1 小时前
数据结构11111
数据结构
神仙别闹1 小时前
基于C++实现(控制台)应用递推法完成经典型算法的应用
开发语言·c++·算法
Ayanami_Reii1 小时前
进阶数据结构应用-一个简单的整数问题2(线段树解法)
数据结构·算法·线段树·延迟标记
Ccjf酷儿1 小时前
操作系统 蒋炎岩 4.数学视角的操作系统
笔记
yinchao1631 小时前
EMC设计经验-笔记
笔记
listhi5202 小时前
基于改进SET的时频分析MATLAB实现
开发语言·算法·matlab
黑客思维者2 小时前
LLM底层原理学习笔记:Adam优化器为何能征服巨型模型成为深度学习的“速度与稳定之王”
笔记·深度学习·学习·llm·adam优化器
松☆2 小时前
Flutter + OpenHarmony 实战:构建离线优先的跨设备笔记应用
笔记·flutter