数据结构与算法(八):排序算法

参考引用

1. 选择排序

  • 选择排序的工作原理非常直接:开启一个循环,每轮从未排序区间选择最小的元素,将其放到已排序区间的末尾,设数组的长度为 n
    • 初始状态下,所有元素未排序,即未排序(索引)区间为 [0, n-1]
    • 选取区间 [0, n-1] 中的最小元素,将其与索引 0 处元素交换。完成后,数组前 1 个元素已排序
    • 选取区间 [1, n-1] 中的最小元素,将其与索引 1 处元素交换。完成后,数组前 2 个元素已排序
    • 以此类推。经过 n-1 轮选择与交换后,数组前 n-1 个元素已排序
    • 仅剩的一个元素必定是最大元素,无须排序,因此数组排序完成
cpp 复制代码
/* 选择排序 */
// 时间复杂度:O(n^2)
// 空间复杂度:O(1)
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]);
    }
}

2. 冒泡排序

  • 冒泡过程可以利用元素交换操作来模拟:从数组最左端开始向右遍历,依次比较相邻元素大小,如果 "左元素 > 右元素" 就交换它俩。遍历完成后,最大的元素会被移动到数组的最右端

算法流程

  • 设数组的长度为 n,冒泡排序的步骤如下图所示
    • 首先,对 n 个元素执行 "冒泡",将数组的最大元素交换至正确位置
    • 接下来,对剩余 n-1 个元素执行 "冒泡",将第二大元素交换至正确位置
    • 以此类推,经过 n-1 轮 "冒泡" 后,前 n-1 大的元素都被交换至正确位置
    • 仅剩的一个元素必定是最小元素,无须排序,因此数组排序完成
cpp 复制代码
/* 冒泡排序(标志优化)*/
// 时间复杂度:O(n^2),引入 flag 优化后,最佳时间复杂度可达到 O(n)
// 空间复杂度:O(1)
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; // 此轮冒泡未交换任何元素,即数组已经是有序的,直接跳出
    }
}

3. 插入排序

  • 插入排序的工作原理与手动整理一副牌的过程非常相似。具体来说,在未排序区间选择一个基准元素,将该元素与其左侧已排序区间的元素逐一比较大小,并将该元素插入到正确的位置

  • 下图展示了数组插入元素的操作流程。设基准元素为 base ,需要将从目标索引到 base 之间的所有元素向右移动一位,然后再将 base 赋值给目标索引

3.1 算法流程

  • 初始状态下,数组的第 1 个元素已完成排序
  • 选取数组的第 2 个元素作为 base,将其插入到正确位置后,数组的前 2 个元素已排序
  • 选取第 3 个元素作为 base,将其插入到正确位置后,数组的前 3 个元素已排序
  • 以此类推,在最后一轮中,选取最后一个元素作为 base ,将其插入到正确位置后,所有元素均已排序
cpp 复制代码
/* 插入排序 */
// 时间复杂度:O(n^2),当输入数组完全有序时为 O(n)
// 空间复杂度:O(1)
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 赋值到正确位置
    }
}

3.2 插入排序优势

  • 插入排序的时间复杂度为 O(n^2),而快速排序的时间复杂度为 O(nlog n)

    • 尽管插入排序的时间复杂度相比快速排序更高,但在数据量较小的情况下,n^2 和 nlog n 数值接近,插入排序通常更快
  • 实际情况中,插入排序的使用频率显著高于冒泡排序和选择排序

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

4. 快速排序

  • 快速排序是一种基于分治策略的排序算法

  • 快速排序的核心操作是 "哨兵划分" ,其目标是:选择数组中的某个元素作为 "基准数",将所有小于基准数的元素移到其左侧,而大于基准数的元素移到其右侧

    • 选取数组最左端元素作为基准数,初始化两个指针 i 和 j 分别指向数组的两端
    • 设置一个循环,在每轮中使用 i(j)分别寻找第一个比基准数大(小)的元素,然后交换这两个元素
    • 循环执行步骤 2,直到 i 和 j 相遇时停止,最后将基准数交换至两个子数组的分界线

    哨兵划分的实质是将一个较长数组的排序问题简化为两个较短数组的排序问题

    • 哨兵划分完成后,原数组被划分成三部分:左子数组、基准数、右子数组,且满足 "左子数组任意元素 ≤ 基准数 ≤ 右子数组任意元素"。因此,接下来只需对这两个子数组进行排序
cpp 复制代码
/* 元素交换 */
void swap(vector<int> &nums, int i, int j) {
    int tmp = nums[i];
    nums[i] = nums[j];
    nums[j] = tmp;
}

/* 哨兵划分 */
int partition(vector<int> &nums, int left, int right) {
    // 以 nums[left] 作为基准数
    int i = left, j = right;
    while (i < j) {
        while (i < j && nums[j] >= nums[left])
            j--;          // 从右向左找首个小于基准数的元素
        while (i < j && nums[i] <= nums[left])
            i++;          // 从左向右找首个大于基准数的元素
        swap(nums, i, j); // 交换这两个元素
    }
    swap(nums, i, left);  // 将基准数交换至两子数组的分界线
    return i;             // 返回基准数的索引
}

4.1 算法流程

  • 首先,对原数组执行一次 "哨兵划分",得到未排序的左子数组和右子数组
  • 然后,对左子数组和右子数组分别递归执行 "哨兵划分"
  • 持续递归,直至子数组长度为 1 时终止,从而完成整个数组的排序
cpp 复制代码
/* 快速排序 */
// 时间复杂度:O(nlog n),最差情况下 O(n^2)
// 空间复杂度:O(n)
void quickSort(vector<int> &nums, int left, int right) {
    // 子数组长度为 1 时终止递归
    if (left >= right)
        return;
    // 哨兵划分
    int pivot = partition(nums, left, right);
    // 递归左子数组、右子数组
    quickSort(nums, left, pivot - 1);
    quickSort(nums, pivot + 1, right);
}

4.2 快排为什么快

  • 快速排序在效率方面应该具有一定的优势。尽管快速排序的平均时间复杂度与 "归并排序" 和 "堆排序" 相同,但通常快速排序的效率更高 ,主要有以下原因
    • 出现最差情况的概率很低
      • 虽然快速排序的最差时间复杂度为 O(n^2),没有归并排序稳定,但在绝大多数情况下,快速排序能在 O(nlog n) 的时间复杂度下运行
    • 缓存使用效率高
      • 在执行哨兵划分操作时,系统可将整个子数组加载到缓存,因此访问元素的效率较高。而像 "堆排序" 这类算法需要跳跃式访问元素,从而缺乏这一特性
    • 复杂度的常数系数低
      • 在上述三种算法中,快速排序的比较、赋值、交换等操作的总数量最少。这与 "插入排序" 比 "冒泡排序" 更快的原因类似

4.3 基准数优化

  • 快速排序在某些输入下的时间效率可能降低

    • 例如:假设输入数组是完全倒序的,由于选择最左端元素作为基准数,那么在哨兵划分完成后,基准数被交换至数组最右端,导致左子数组长度为 n-1、右子数组长度为 0。如此递归下去,每轮哨兵划分后的右子数组长度都为 0,分治策略失效,快速排序退化为 "冒泡排序"
  • 为避免这种情况发生,可以优化哨兵划分中的基准数的选取策略

    • 例如,可以随机选取一个元素作为基准数。然而,如果运气不佳,每次都选到不理想的基准数,效率仍然不尽如人意
    • 可以在数组中选取三个候选元素(通常为数组的首、尾、中点元素),并将这三个候选元素的中位数作为基准数。这样,基准数 "既不太小也不太大" 的概率将大幅提升。还可以选取更多候选元素,以进一步提高算法的稳健性
    cpp 复制代码
    /* 选取三个元素的中位数 */
    int medianThree(vector<int> &nums, int left, int mid, int right) {
        // 此处使用异或运算来简化代码
        // 异或规则为 0 ^ 0 = 1 ^ 1 = 0, 0 ^ 1 = 1 ^ 0 = 1
        if ((nums[left] < nums[mid]) ^ (nums[left] < nums[right]))
            return left;
        else if ((nums[mid] < nums[left]) ^ (nums[mid] < nums[right]))
            return mid;
        else
            return right;
    }
    
    /* 哨兵划分(三数取中值) */
    int partition(vector<int> &nums, int left, int right) {
        // 选取三个候选元素的中位数
        int med = medianThree(nums, left, (left + right) / 2, right);
        // 将中位数交换至数组最左端
        swap(nums, left, med);
        // 以 nums[left] 作为基准数
        int i = left, j = right;
        while (i < j) {
            while (i < j && nums[j] >= nums[left])
                j--; // 从右向左找首个小于基准数的元素
            while (i < j && nums[i] <= nums[left])
                i++;          // 从左向右找首个大于基准数的元素
            swap(nums, i, j); // 交换这两个元素
        }
        swap(nums, i, left); // 将基准数交换至两子数组的分界线
        return i;            // 返回基准数的索引
    }

4.4 尾递归优化

  • 在某些输入下,快速排序可能占用空间较多

    • 以完全倒序的输入数组为例,由于每轮哨兵划分后右子数组长度为 0,递归树的高度会达到 n-1,此时需要占用 O(n) 大小的栈帧空间
  • 为了防止栈帧空间的累积,可以在每轮哨兵排序完成后,比较两个子数组的长度

    • 仅对较短的子数组进行递归 。由于较短子数组的长度不会超过 n/2,因此这种方法能确保递归深度不超过 log n,从而将最差空间复杂度优化至 O(log n)
    cpp 复制代码
    /* 快速排序(尾递归优化) */
    void quickSort(vector<int> &nums, int left, int right) {
        // 子数组长度为 1 时终止
        while (left < right) {
            // 哨兵划分操作
            int pivot = partition(nums, left, right);
            // 对两个子数组中较短的那个执行快排
            if (pivot - left < right - pivot) {
                quickSort(nums, left, pivot - 1); // 递归排序左子数组
                left = pivot + 1;                 // 剩余未排序区间为 [pivot + 1, right]
            } else {
                quickSort(nums, pivot + 1, right); // 递归排序右子数组
                right = pivot - 1;                 // 剩余未排序区间为 [left, pivot - 1]
            }
        }
    }

5. 归并排序

  • 归并排序是一种基于分治策略的排序算法,包含下图所示的 "划分" 和 "合并" 阶段
    • 划分阶段:通过递归不断地将数组从中点处分开,将长数组的排序问题转换为短数组的排序问题
    • 合并阶段:当子数组长度为 1 时终止划分,开始合并,持续地将左右两个较短的有序数组合并为一个较长的有序数组,直至结束

5.1 算法流程

  • "划分阶段" 从顶至底递归地将数组从中点切分为两个子数组
    • 计算数组中点 mid,递归划分左子数组(区间 [left, mid])和右子数组(区间 [mid + 1, right])
    • 递归执行步骤 1,直至子数组区间长度为 1 时,终止递归划分
  • "合并阶段" 从底至顶地将左子数组和右子数组合并为一个有序数组。需要注意的是,从长度为 1 的子数组开始合并,合并阶段中的每个子数组都是有序的
  • 归并排序与二叉树后序遍历的递归顺序是一致的

    • 后序遍历:先递归左子树,再递归右子树,最后处理根节点
    • 归并排序:先递归左子数组,再递归右子数组,最后处理合并
    cpp 复制代码
    /* 合并左子数组和右子数组 */
    // 左子数组区间 [left, mid]
    // 右子数组区间 [mid + 1, right]
    void merge(vector<int> &nums, int left, int mid, int right) {
        // 初始化辅助数组
        vector<int> tmp(nums.begin() + left, nums.begin() + right + 1);
        // 左子数组的起始索引和结束索引
        int leftStart = left - left, leftEnd = mid - left;
        // 右子数组的起始索引和结束索引
        int rightStart = mid + 1 - left, rightEnd = right - left;
        // i, j 分别指向左子数组、右子数组的首元素
        int i = leftStart, j = rightStart;
        // 通过覆盖原数组 nums 来合并左子数组和右子数组
        for (int k = left; k <= right; k++) {
            // 若"左子数组已全部合并完",则选取右子数组元素,并且 j++
            if (i > leftEnd)
                nums[k] = tmp[j++];
            // 否则,若"右子数组已全部合并完"或"左子数组元素 <= 右子数组元素",则选取左子数组元素,并且 i++
            else if (j > rightEnd || tmp[i] <= tmp[j])
                nums[k] = tmp[i++];
            // 否则,若"左右子数组都未全部合并完"且"左子数组元素 > 右子数组元素",则选取右子数组元素,并且 j++
            else
                nums[k] = tmp[j++];
        }
    }
    
    /* 归并排序 */
    // 时间复杂度:O(nlog n)
    // 空间复杂度:O(n)
    void mergeSort(vector<int> &nums, int left, int right) {
        // 终止条件
        if (left >= right)
            return; // 当子数组长度为 1 时终止递归
        // 划分阶段
        int mid = (left + right) / 2;    // 计算中点
        mergeSort(nums, left, mid);      // 递归左子数组
        mergeSort(nums, mid + 1, right); // 递归右子数组
        // 合并阶段
        merge(nums, left, mid, right);
    }

merge() 函数注意事项

  • nums 的待合并区间为 [left, right] ,但由于 tmp 仅复制了 nums 该区间的元素,因此 tmp 对应区间为 [0, right - left]
  • 在比较 tmp[i] 和 tmp[j] 的大小时,还需考虑子数组遍历完成后的索引越界问题,即 i > leftEnd 和 j > rightEnd 的情况。索引越界的优先级是最高的,如果左子数组已经被合并完了,那么不需要继续比较,直接合并右子数组元素即可

6. 堆排序

  • 堆排序是一种基于堆数据结构实现的高效排序算法。可利用 "建堆操作" 和 "元素出堆操作" 实现堆排序

    • 输入数组并建立小顶堆,此时最小元素位于堆顶
    • 不断执行出堆操作,依次记录出堆元素,即可得到从小到大排序的序列

    以上方法虽然可行,但需要借助一个额外数组来保存弹出的元素,比较浪费空间

算法流程

  • 1、输入数组并建立大顶堆
    • 完成后,最大元素位于堆顶
  • 2、将堆顶元素(第一个元素)与堆底元素(最后一个元素)交换
    • 完成交换后,堆的长度减 1,已排序元素数量加 1
  • 3、从堆顶元素开始,从顶到底执行堆化操作
    • 完成堆化后,堆的性质得到修复
  • 4、循环执行第 2 和 3 步
    • 循环 n-1 轮后,即可完成数组排序
cpp 复制代码
/* 堆的长度为 n ,从节点 i 开始,从顶至底堆化 */
void siftDown(vector<int> &nums, int n, int i) {
    while (true) {
        // 判断节点 i, l, r 中值最大的节点,记为 ma
        int l = 2 * i + 1;
        int r = 2 * i + 2;
        int ma = i;
        if (l < n && nums[l] > nums[ma])
            ma = l;
        if (r < n && nums[r] > nums[ma])
            ma = r;
        // 若节点 i 最大或索引 l, r 越界,则无须继续堆化,跳出
        if (ma == i) {
            break;
        }
        // 交换两节点
        swap(nums[i], nums[ma]);
        // 循环向下堆化
        i = ma;
    }
}

/* 堆排序 */
// 时间复杂度:O(nlog n)
// 空间复杂度:O(1)
void heapSort(vector<int> &nums) {
    // 建堆操作:堆化除叶节点以外的其他所有节点
    for (int i = nums.size() / 2 - 1; i >= 0; --i) {
        siftDown(nums, nums.size(), i);
    }
    // 从堆中提取最大元素,循环 n-1 轮
    for (int i = nums.size() - 1; i > 0; --i) {
        // 交换根节点与最右叶节点(即交换首元素与尾元素)
        swap(nums[0], nums[i]);
        // 以根节点为起点,从顶至底进行堆化
        siftDown(nums, i, 0);
    }
}

7. 桶排序

  • 前述的几种排序算法都属于 "基于比较的排序算法",它们通过比较元素间的大小来实现排序。此类排序算法的时间复杂度无法超越 O(nlog n)。接下来,将探讨几种 "非比较排序算法",它们的时间复杂度可以达到线性阶
  • 桶排序是分治策略的一个典型应用
    • 它通过设置一些具有大小顺序的桶,每个桶对应一个数据范围,将数据平均分配到各个桶中
    • 然后,在每个桶内部分别执行排序
    • 最终按照桶的顺序将所有数据合并

算法流程

  • 考虑一个长度为 n 的数组,元素是范围 [0, 1) 的浮点数
    • 初始化 k 个桶,将 n 个元素分配到 k 个桶中
    • 对每个桶分别执行排序
    • 按照桶的从小到大的顺序,合并结果
cpp 复制代码
/* 桶排序 */
// 时间复杂度:O(n + k),最差(所有元素被分至同一个桶中)时间复杂度是 O(n^2)
// 空间复杂度:O(n + k)
void bucketSort(vector<float> &nums) {
    // 初始化 k = n/2 个桶,预期向每个桶分配 2 个元素
    int k = nums.size() / 2;
    vector<vector<float>> buckets(k);
    // 1. 将数组元素分配到各个桶中
    for (float num : nums) {
        // 输入数据范围 [0, 1),使用 num * k 映射到索引范围 [0, k-1]
        int i = num * k;
        // 将 num 添加进桶 bucket_idx
        buckets[i].push_back(num);
    }
    // 2. 对各个桶执行排序
    for (vector<float> &bucket : buckets) {
        // 使用内置排序函数,也可以替换成其他排序算法
        sort(bucket.begin(), bucket.end());
    }
    // 3. 遍历桶合并结果
    int i = 0;
    for (vector<float> &bucket : buckets) {
        for (float num : bucket) {
            nums[i++] = num;
        }
    }
}

8. 小结

相关推荐
passer__jw7672 小时前
【LeetCode】【算法】3. 无重复字符的最长子串
算法·leetcode
passer__jw7672 小时前
【LeetCode】【算法】21. 合并两个有序链表
算法·leetcode·链表
sweetheart7-72 小时前
LeetCode22. 括号生成(2024冬季每日一题 2)
算法·深度优先·力扣·dfs·左右括号匹配
懒惰的bit2 小时前
基础网络安全知识
学习·web安全·1024程序员节
李元豪3 小时前
【智鹿空间】c++实现了一个简单的链表数据结构 MyList,其中包含基本的 Get 和 Modify 操作,
数据结构·c++·链表
2401_858286113 小时前
L7.【LeetCode笔记】相交链表
笔记·leetcode·链表
我不是星海3 小时前
1.集合体系补充(1)
java·数据结构
UestcXiye3 小时前
《TCP/IP网络编程》学习笔记 | Chapter 9:套接字的多种可选项
c++·计算机网络·ip·tcp
一丝晨光4 小时前
编译器、IDE对C/C++新标准的支持
c语言·开发语言·c++·ide·msvc·visual studio·gcc
景鹤4 小时前
【算法】递归+回溯+剪枝:78.子集
算法·机器学习·剪枝