排序【由浅入深-数据结构】

文章目录

  • 前言
  • 排序的概念及其应用详解
    • 一、排序的基本概念
      • [1.1 什么是排序?](#1.1 什么是排序?)
      • [1.2 排序的稳定性](#1.2 排序的稳定性)
      • [1.3 排序的分类](#1.3 排序的分类)
        • [1.3.1 内排序 (Internal Sorting)](#1.3.1 内排序 (Internal Sorting))
        • [1.3.2 外排序 (External Sorting)](#1.3.2 外排序 (External Sorting))
        • [1.3.3 内排序与外排序的核心区别](#1.3.3 内排序与外排序的核心区别)
        • [1.3.4 文件归并排序(外排序的典型实现)流程](#1.3.4 文件归并排序(外排序的典型实现)流程)
        • [1.3.5 选择内排序还是外排序](#1.3.5 选择内排序还是外排序)
    • 二、排序的评价标准
      • [2.1 时间复杂度](#2.1 时间复杂度)
      • [2.2 空间复杂度](#2.2 空间复杂度)
      • [2.3 稳定性](#2.3 稳定性)
      • [2.4 使用场景](#2.4 使用场景)
    • 三、排序在实际生活中的应用
      • [3.1 日常生活中的排序](#3.1 日常生活中的排序)
      • [3.2 专业领域的排序应用](#3.2 专业领域的排序应用)
      • [3.3 排序算法的实际应用场景](#3.3 排序算法的实际应用场景)
    • [四、直接插入排序( Insertion Sort)](#四、直接插入排序( Insertion Sort))
      • [4.1 基本思想](#4.1 基本思想)
      • [4.2 C语言实现](#4.2 C语言实现)
      • [4.3 时间复杂度分析(讨论时间复杂度时,如果没有特别说明,我们默认指的是最坏情况。)](#4.3 时间复杂度分析(讨论时间复杂度时,如果没有特别说明,我们默认指的是最坏情况。))
        • [4.3.1 最好情况(O(n))](#4.3.1 最好情况(O(n)))
        • [4.3.2 最坏情况(O(n²))](#4.3.2 最坏情况(O(n²)))
        • [4.3.3 平均情况(O(n²))](#4.3.3 平均情况(O(n²)))
      • [4.4 空间复杂度分析](#4.4 空间复杂度分析)
      • [4.5 稳定性分析](#4.5 稳定性分析)
      • [4.6 实际应用](#4.6 实际应用)
    • [五、希尔排序(Shell Sort)](#五、希尔排序(Shell Sort))
      • [5.1 基本思想](#5.1 基本思想)
      • [5.2 C语言实现](#5.2 C语言实现)
      • [5.3 时间复杂度分析](#5.3 时间复杂度分析)
        • [5.3.1 最坏情况](#5.3.1 最坏情况)
        • [5.3.2 最好情况](#5.3.2 最好情况)
        • [5.3.3 平均情况](#5.3.3 平均情况)
      • [5.4 空间复杂度分析](#5.4 空间复杂度分析)
      • [5.5 稳定性分析](#5.5 稳定性分析)
      • [5.6 增量序列优化](#5.6 增量序列优化)
        • [5.6.1 Shell原始序列](#5.6.1 Shell原始序列)
        • [5.6.2 Knuth序列](#5.6.2 Knuth序列)
        • [5.6.3 Hibbard序列](#5.6.3 Hibbard序列)
      • [5.7 实际应用](#5.7 实际应用)
      • [5.8 与其他排序算法的比较](#5.8 与其他排序算法的比较)
    • [六、冒泡排序(Bubble Sort)](#六、冒泡排序(Bubble Sort))
      • [6.1 基本思想](#6.1 基本思想)
      • [6.2 C语言实现](#6.2 C语言实现)
      • [6.3 执行过程详解](#6.3 执行过程详解)
      • [6.4 时间复杂度分析](#6.4 时间复杂度分析)
        • [6.4.1 最坏情况](#6.4.1 最坏情况)
        • [6.4.2 最好情况](#6.4.2 最好情况)
        • [6.4.3 平均情况](#6.4.3 平均情况)
        • [6.4.4 精确计算与大O表示](#6.4.4 精确计算与大O表示)
      • [6.5 空间复杂度分析](#6.5 空间复杂度分析)
        • [6.5.1 计算过程](#6.5.1 计算过程)
        • [6.5.2 为什么是O(1)](#6.5.2 为什么是O(1))
      • [6.6 稳定性分析](#6.6 稳定性分析)
        • [6.6. 1 举例说明](#6.6. 1 举例说明)
      • [6.7 优化策略](#6.7 优化策略)
        • [6.7.1 提前终止优化](#6.7.1 提前终止优化)
        • [6.7.2 记录末次交换位置](#6.7.2 记录末次交换位置)
      • [6.8 与其他排序算法的比较](#6.8 与其他排序算法的比较)
      • [6.9 实际应用](#6.9 实际应用)
  • [七、 快速排序(Quick Sort)](#七、 快速排序(Quick Sort))
      • [7.1 基本思想](#7.1 基本思想)
      • [7.2 四种分区方法对比](#7.2 四种分区方法对比)
        • [7.2.1 Hoare分区法 (QuickSort1)](#7.2.1 Hoare分区法 (QuickSort1))
        • [7.2.2 前后指针法 (QuickSort2)](#7.2.2 前后指针法 (QuickSort2))
        • [7.2.3 挖坑法(前后指针法的另一种实现)](#7.2.3 挖坑法(前后指针法的另一种实现))
        • [7.2.4 非递归实现 (QuickSortNonR)](#7.2.4 非递归实现 (QuickSortNonR))
      • [7.3 辅助函数](#7.3 辅助函数)
      • [7.4 优化策略总结](#7.4 优化策略总结)
      • [7.5 时间与空间复杂度的详细分析](#7.5 时间与空间复杂度的详细分析)
      • [7.6 实际应用](#7.6 实际应用)
      • [7.7 快速排序的3路划分优化与C++ STL中的内省排序](#7.7 快速排序的3路划分优化与C++ STL中的内省排序)
        • [7.7.1 快速排序的3路划分优化](#7.7.1 快速排序的3路划分优化)
        • [7.7.2 C++ STL中使用的内省排序(Introsort)](#7.7.2 C++ STL中使用的内省排序(Introsort))
    • [八、选择排序(Selection Sort)](#八、选择排序(Selection Sort))
      • [8.1 选择排序的基本原理](#8.1 选择排序的基本原理)
      • [8.2 选择排序的C语言实现](#8.2 选择排序的C语言实现)
      • [8.3 时间复杂度分析](#8.3 时间复杂度分析)
      • [8.4 空间复杂度分析](#8.4 空间复杂度分析)
      • [8.5 稳定性分析](#8.5 稳定性分析)
      • [8.6 选择排序的特点与适用场景](#8.6 选择排序的特点与适用场景)
      • [8.7 选择排序的优化](#8.7 选择排序的优化)
      • [8.8 选择排序与其他排序算法的比较](#8.8 选择排序与其他排序算法的比较)
    • [九、 堆排序(Heap Sort)](#九、 堆排序(Heap Sort))
      • [9.1 堆排序的基本原理](#9.1 堆排序的基本原理)
      • [9.2 堆排序的C语言实现](#9.2 堆排序的C语言实现)
      • [9.3 时间复杂度分析](#9.3 时间复杂度分析)
        • [9.3.1 构建初始大顶堆(O(n))](#9.3.1 构建初始大顶堆(O(n)))
        • [9.3.2 交换与调整堆(O(n log n))](#9.3.2 交换与调整堆(O(n log n)))
        • [9.3.3 总时间复杂度](#9.3.3 总时间复杂度)
      • [9.4 空间复杂度分析](#9.4 空间复杂度分析)
      • [9.5 堆排序的稳定性分析](#9.5 堆排序的稳定性分析)
      • [9.6 堆排序的优缺点](#9.6 堆排序的优缺点)
      • [9.7 堆排序与其他排序算法的对比](#9.7 堆排序与其他排序算法的对比)
      • [9.8 堆排序的应用场景](#9.8 堆排序的应用场景)
      • [9.9 堆排序的优化点](#9.9 堆排序的优化点)
      • [9.10 堆排序的图解示例](#9.10 堆排序的图解示例)
      • [9.11 堆排序的常见误区](#9.11 堆排序的常见误区)
      • [9.12 总结](#9.12 总结)
    • [十、归并排序(Merge Sort)](#十、归并排序(Merge Sort))
      • [10.1 归并排序的基本原理](#10.1 归并排序的基本原理)
      • [10.2 归并排序的C语言实现](#10.2 归并排序的C语言实现)
      • [10.3 时间复杂度分析](#10.3 时间复杂度分析)
        • [10.3.1 递归关系分析](#10.3.1 递归关系分析)
        • [10.3.2 递归树分析](#10.3.2 递归树分析)
        • [10.3.3 为什么是O(n *log n)?](#10.3.3 为什么是O(n *log n)?)
      • [10.4 空间复杂度分析](#10.4 空间复杂度分析)
        • [10.4.1 空间使用分析](#10.4.1 空间使用分析)
        • [10.4.2 与原地排序算法对比](#10.4.2 与原地排序算法对比)
      • [10.5 稳定性分析](#10.5 稳定性分析)
      • [10.6 归并排序的优缺点](#10.6 归并排序的优缺点)
      • [10.7 归并排序与其他排序算法的对比](#10.7 归并排序与其他排序算法的对比)
      • [10.8 归并排序的优化](#10.8 归并排序的优化)
        • [10.8.1 减少临时数组分配](#10.8.1 减少临时数组分配)
        • [10.8.2 小规模数组使用插入排序](#10.8.2 小规模数组使用插入排序)
        • [10.8.3 迭代版本(非递归)](#10.8.3 迭代版本(非递归))
      • [10.9 归并排序的图解示例](#10.9 归并排序的图解示例)
      • [10.10 归并排序的常见误区](#10.10 归并排序的常见误区)
      • [10.11 文件的归并排序](#10.11 文件的归并排序)
        • [10.11.1 文件归并排序的详细步骤](#10.11.1 文件归并排序的详细步骤)
        • [10.11.2 文件归并排序的实现流程](#10.11.2 文件归并排序的实现流程)
        • [10.11.3 代码实现原理](#10.11.3 代码实现原理)
      • [10.12 总结](#10.12 总结)
    • [十一、非比较排序- 计数排序(Counting Sort)](#十一、非比较排序- 计数排序(Counting Sort))
      • [11.1 计数排序的基本原理](#11.1 计数排序的基本原理)
      • [11.2 计数排序的C语言实现](#11.2 计数排序的C语言实现)
      • [11.3 时间复杂度分析](#11.3 时间复杂度分析)
        • [11.3.1 分步计算](#11.3.1 分步计算)
        • [11.3.2 为什么是 O(n + range)?](#11.3.2 为什么是 O(n + range)?)
        • [11.3.3 与比较排序的对比](#11.3.3 与比较排序的对比)
      • [11.4 空间复杂度分析](#11.4 空间复杂度分析)
        • [11.4.1 空间使用分解](#11.4.1 空间使用分解)
        • [11.4.2 为什么不是 O(n)?](#11.4.2 为什么不是 O(n)?)
        • [11.4.3 空间复杂度示例](#11.4.3 空间复杂度示例)
      • [11.5 计数排序的稳定性](#11.5 计数排序的稳定性)
      • [11.6 计数排序的优缺点](#11.6 计数排序的优缺点)
      • [11.7 计数排序的典型应用场景](#11.7 计数排序的典型应用场景)
      • [11.8 计数排序的常见误区](#11.8 计数排序的常见误区)
      • [11.9 计数排序的执行示例](#11.9 计数排序的执行示例)
      • [11.10 计数排序与其他排序算法的对比](#11.10 计数排序与其他排序算法的对比)
      • [11.11 总结](#11.11 总结)

前言

本文介绍c语言实现排序的相关内容。(注意:该文会采用动图帮助理解,动图均来源于谷歌平台,具体创作者未知)

(【由浅入深】是一个系列文章,它记录了我个人作为一个小白,在学习c++技术开发方向计相关知识过程中的笔记,欢迎各位彭于晏刘亦菲从中指出我的错误并且与我共同学习进步,作为该系列的第一部曲-c语言,大部分知识会根据本人所学和我的助手------通义,DeepSeek等以及合并网络上所找到的相关资料进行核实誊抄,每一篇文章都可能会因为一些错误在后续时间增删改查,因为该系列按照我的网络课程学习笔记形式编写,我会使用绝大多数人使用的讲解顺序编写,所以基础框架和大部分内容案例会与他人一样,基础知识不会过于详细讲述)


排序的概念及其应用详解

一、排序的基本概念

1.1 什么是排序?

排序是计算机科学中一项基础而重要的操作,其核心定义为:

所谓排序,就是使一串记录,按照其中的某个或某些关键字的大小,递增或递减的排列起来的操作。

简单来说,排序就是将杂乱无章的数据按照特定规则重新排列,使其变得有序。例如,将一组数字从大到小排列,或将文件按名称字母顺序排列。

1.2 排序的稳定性

稳定性是排序算法的重要特性:

稳定性:假如在待排序的记录序列中,存在多个具有相同关键字的记录,如果经过排序,这些记录的相对次序保存不变,即在原序列中,r[i] = r[j],且r[i]在r[j]前,然后在排序后的序列中,r[i]仍在r[j]前,那么就叫这种排序是稳定的,否则就是不稳定的。

稳定性在实际应用中非常重要,特别是在多级排序中。例如,当对一个包含姓名和年龄的列表按年龄排序时,若年龄相同,希望保持原顺序(如按姓名的字母顺序),这就需要排序算法具有稳定性。

1.3 排序的分类

排序算法可以根据不同的标准进行分类,其中最重要的分类方式是内排序外排序

1.3.1 内排序 (Internal Sorting)
  • 定义:所有排序数据都能一次性放入内存中进行排序的算法
  • 特点
    • 数据全部在内存中处理,无需磁盘I/O操作
    • 速度快,受内存访问速度限制
    • 通常时间复杂度为O(n log n)
  • 适用场景:数据量在内存容量范围内(通常几MB到几GB)
  • 常见算法
    • 快速排序(平均时间复杂度O(n log n),最坏O(n²))
    • 归并排序(稳定排序,时间复杂度O(n log n),需要O(n)额外空间)
    • 堆排序(时间复杂度O(n log n),不需要额外空间)
    • 插入排序(小数据集效率高,时间复杂度O(n²))
    • 希尔排序(插入排序的改进版,时间复杂度O(n^(1.3)))
1.3.2 外排序 (External Sorting)
  • 定义:当数据量太大,无法一次性放入内存中进行排序时,需要借助外部存储(如磁盘)进行排序的算法
  • 特点
    • 数据分布在磁盘上,需要多次读写磁盘
    • 速度受磁盘I/O速度限制
    • 时间复杂度为O(n log n) + I/O开销
  • 适用场景:超大规模数据(GB级、TB级甚至PB级)
  • 典型算法
    • 文件归并排序(核心思想是"分而治之" + "归并")
    • 多路归并排序(提高归并效率)
1.3.3 内排序与外排序的核心区别
特性 内排序 外排序
数据存储 全部在内存中 部分在磁盘,部分在内存
I/O操作 大量文件读写
时间复杂度 O(n log n) O(n log n) + I/O开销
内存需求 O(n) 仅需处理小块数据
典型应用场景 小到中等规模数据(GB以下) 超大规模数据(GB级、TB级)
1.3.4 文件归并排序(外排序的典型实现)流程
  1. 分割阶段:将大文件分割成多个小文件,每个小文件大小可以放入内存
  2. 排序阶段:对每个小文件使用内排序算法进行排序
  3. 归并阶段:将排序后的小文件逐步合并为一个更大的有序文件

典型实现步骤

  1. 从原始文件读取n个数据,排序后写入file1
  2. 从原始文件读取n个数据,排序后写入file2
  3. 合并file1和file2为mfile
  4. 删除file1和file2,将mfile重命名为file1
  5. 从原始文件读取n个数据,排序后写入file2
  6. 重复步骤3-5,直到原始文件无法读取数据
1.3.5 选择内排序还是外排序
  • 选择内排序的条件

    • 数据量小于内存容量
    • 无需处理超大规模数据
    • 对排序速度要求高
    • 代码实现简单
  • 选择外排序的条件

    • 数据量远大于内存容量
    • 需要处理GB级或TB级数据
    • 内存有限但磁盘空间充足
    • 有足够时间等待排序完成

💡 简单判断 :如果数据量可以轻松放入内存(比如1GB内存处理100MB数据),使用内排序;如果数据量远大于内存(比如100GB数据处理在4GB内存的机器上),必须使用外排序。

二、排序的评价标准

选择合适的排序算法需要考虑以下几个关键标准:

2.1 时间复杂度

  • 定义:从序列的初始状态到经过排序算法的变换移位等操作变到最终排序好的结果状态的过程所花费的时间度量
  • 重要性:直接影响算法的效率,特别是在处理大规模数据时
  • 常见表示:O(n)、O(n²)、O(n log n)等

2.2 空间复杂度

  • 定义:从序列的初始状态经过排序移位变换的过程一直到最终的状态所花费的空间开销
  • 重要性:在内存受限的环境中(如嵌入式系统)尤为重要
  • 常见表示:O(1)、O(n)等

2.3 稳定性

  • 重要性:在需要保持相同元素相对顺序的场景中至关重要
  • 常见排序算法稳定性
    • 稳定排序:冒泡排序、插入排序、归并排序、基数排序
    • 不稳定排序:选择排序、快速排序、希尔排序

2.4 使用场景

  • 小规模数据(n < 100):插入排序、冒泡排序
  • 中等规模数据(100 ≤ n ≤ 10,000):希尔排序、快速排序
  • 大规模数据(n > 10,000):归并排序、快速排序

三、排序在实际生活中的应用

3.1 日常生活中的排序

  1. 图书馆和书店

    • 图书按照作者姓名或图书编号进行排序,方便读者查找
    • 例如:《计算机科学导论》按作者姓氏"张"排在《数据结构》之前
  2. 在线购物平台

    • 淘宝、京东等网站商品可以按价格从高到低、按评分或购买人数排序
    • 例如:搜索"笔记本电脑"后,可以选择"价格从低到高"或"销量最高"排序
  3. 社交媒体与新闻

    • 浏览器上的热榜排名,按点击量或热度排序
    • 例如:微博热搜榜按话题热度排序
  4. 文件管理

    • 电脑桌面或文件夹中的文件按名称、大小、日期排序
    • 例如:将"2023-10-01_会议记录.docx"排在"2023-09-30_会议记录.docx"之后

3.2 专业领域的排序应用

  1. 数据库管理

    • 数据库索引通常使用排序算法(如B+树)来提高查询效率
    • 例如:SQL查询中的ORDER BY子句
  2. 金融领域

    • 股票交易价格按时间顺序排序,便于分析走势
    • 例如:按时间顺序排列股票的每日收盘价
  3. 科学计算

    • 大规模科学数据处理中,排序是基础操作
    • 例如:气象数据按时间顺序排序,进行趋势分析
  4. 搜索引擎

    • 搜索结果按相关性排序,提高用户体验
    • 例如:Google搜索结果按页面重要性排序

3.3 排序算法的实际应用场景

根据数据规模和特性,不同排序算法适用于不同场景:

数据规模 适用排序算法 适用场景 原因
小规模数据(n < 100) 插入排序、冒泡排序 数据基本有序、链表排序、小数组微调 最好时间复杂度O(n)(接近有序时),空间复杂度O(1),稳定排序
中等规模数据(100 ≤ n ≤ 10,000) 希尔排序、快速排序 日志文件排序、通用数据排序 希尔排序平均时间复杂度O(n^1.3),突破O(n²)限制;快速排序平均时间复杂度O(n log n)
大规模数据(n > 10,000) 归并排序、快速排序 数据库索引、海量数据处理 归并排序稳定O(n log n),适合链表或分布式系统;快速排序平均效率高

四、直接插入排序( Insertion Sort)

4.1 基本思想

直接插入排序是一种简单直观的排序算法,其核心思想是将待排序的序列视为两个部分:有序区无序区。初始时,有序区包含第一个元素(单个元素自然有序),无序区包含其余元素。算法通过以下步骤实现排序:

  1. 从无序区取出第一个元素作为待插入元素
  2. 在有序区中从后向前扫描,寻找待插入元素的正确位置
  3. 将有序区中所有大于待插入元素的记录后移一位,为待插入元素腾出空间
  4. 将待插入元素放入腾出的位置
  5. 重复上述过程,直到无序区为空

这类似于整理扑克牌的过程:当我们拿到一张新牌时,会将其插入到已经排好序的牌堆中合适的位置。

4.2 C语言实现

以下是一个标准的直接插入排序C语言实现:

ai版本:

c 复制代码
#include <stdio.h>

void insertionSort(int arr[], int n) {
    int i, j, key;
    // 从第二个元素开始遍历(下标1到n-1)
    for (i = 1; i < n; i++) {
        key = arr[i];    // 取出当前待插入的元素
        j = i - 1;       // j指向有序区的最后一个元素
        
        // 在有序区中从后向前扫描,寻找key的正确插入位置
        // 同时将比key大的元素向后移动一位,为key腾出空间
        while (j >= 0 && arr[j] > key) {
            arr[j + 1] = arr[j]; // 将大于key的元素后移
            j--;                 // 指针前移,继续比较
        }
        // j+1的位置就是key应该插入的位置
        arr[j + 1] = key; 
    }
}

// 测试函数
int main() {
    int arr[] = {5, 2, 4, 6, 1, 3};
    int n = sizeof(arr) / sizeof(arr[0]);
    
    insertionSort(arr, n);
    
    printf("Sorted array: ");
    for (int i = 0; i < n; i++) {
        printf("%d ", arr[i]);
    }
    return 0;
}

ai版本代码执行过程详解

  1. 初始时,有序区为{5},无序区为{2, 4, 6, 1, 3}
  2. 第一轮:取2,与5比较,2<5,所以5后移,2插入到5前面 → 有序区{2, 5}
  3. 第二轮:取4,与5比较,4<5,所以5后移,再与2比较,4>2,所以4插入到2和5之间 → 有序区{2, 4, 5}
  4. 第三轮:取6,与5比较,6>5,无需移动,直接插入到5后面 → 有序区{2, 4, 5, 6}
  5. 第四轮:取1,与6比较,1<6,6后移;与5比较,1<5,5后移;与4比较,1<4,4后移;与2比较,1<2,2后移 → 1插入到最前面 → 有序区{1, 2, 4, 5, 6}
  6. 第五轮:取3,与6比较,3<6,6后移;与5比较,3<5,5后移;与4比较,3<4,4后移;与2比较,3>2,停止 → 3插入到2和4之间 → 有序区{1, 2, 3, 4, 5, 6}

完整版本:

c 复制代码
#include<stdio.h>
#include<stdlib.h>

// 交换两个整数
void Swap(int* p1, int* p2)
{
    int tmp = *p1;
    *p1 = *p2;
    *p2 = tmp;
}

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

// =============== 直接插入排序 ===============
// 基本思想:将数组分为有序区和无序区
// 1. 有序区初始为第一个元素(索引0)
// 2. 从第二个元素开始(索引1)遍历整个数组
// 3. 对每个待插入元素,从有序区末尾向前扫描
// 4. 将大于待插入元素的记录后移,为插入腾出位置
// 5. 将待插入元素放入正确位置
// 时间复杂度:最好O(n)(已有序),最坏O(n²)(逆序)
// 空间复杂度:O(1)(原地排序)
void InsertSort(int* a, int n)
{
    // 从第二个元素开始(索引1)遍历
    for (int i = 0; i < n-1; i++)
    {
        // [0, i] 为有序区,i+1 为待插入元素
        int end = i;                // 有序区最后一个元素索引
        int tmp = a[end + 1];       // 保存待插入元素
        
        // 从有序区末尾向前扫描
        while (end >= 0)
        {
            // 如果待插入元素小于有序区当前元素
            if (tmp < a[end])
            {
                a[end + 1] = a[end];  // 将当前元素后移
                end--;                // 继续向前比较
            }
            else
            {
                break;                // 找到正确位置,退出循环
            }
        }
        // 将待插入元素放入正确位置(end+1)
        a[end + 1] = tmp;
    }
}

// 插入排序测试函数
void TestInsertSort()
{
    int a[] = {5, 3, 9, 6, 2, 4, 7, 1, 8};
    PrintArray(a, sizeof(a) / sizeof(int));
    InsertSort(a, sizeof(a) / sizeof(int));
    PrintArray(a, sizeof(a) / sizeof(int));
}

int main()
{
    TestInsertSort();
    return 0;
}

4.3 时间复杂度分析(讨论时间复杂度时,如果没有特别说明,我们默认指的是最坏情况。)

直接插入排序的时间复杂度取决于输入数据的初始顺序,可分为三种情况:

4.3.1 最好情况(O(n))

当输入序列已经完全有序时,内层循环每次只需比较一次(与前一个元素比较),无需移动元素。此时:

  • 比较次数:n-1次
  • 移动次数:0次

计算过程:

  • 第1轮:1次比较
  • 第2轮:1次比较
  • ...
  • 第n-1轮:1次比较
  • 总比较次数 = 1+1+...+1 = n-1 = O(n)
4.3.2 最坏情况(O(n²))

当输入序列完全逆序时,每次插入都需要与有序区中的所有元素比较并移动:

  • 第1轮:1次比较,1次移动
  • 第2轮:2次比较,2次移动
  • ...
  • 第n-1轮:n-1次比较,n-1次移动

计算过程:

  • 总比较次数 = 1+2+...+(n-1) = n(n-1)/2 = O(n²)
  • 总移动次数 = 1+2+...+(n-1) = n(n-1)/2 = O(n²)
4.3.3 平均情况(O(n²))

对于随机排列的序列,平均每次插入需要移动有序区的一半元素:

  • 平均比较次数 = (1+2+...+(n-1))/n = (n-1)/2 = O(n)
  • 平均移动次数 = (1+2+...+(n-1))/n = (n-1)/2 = O(n)

总时间复杂度 = O(n) + O(n) = O(n²)

结论:直接插入排序的平均时间复杂度为O(n²),在数据基本有序时效率较高,适合小规模数据排序。

4.4 空间复杂度分析

直接插入排序的空间复杂度为O(1),即常数级空间复杂度。

计算过程

  • 排序过程仅使用了固定数量的额外空间:
    • 一个临时变量key(用于存储待插入元素)
    • 两个索引变量ij
  • 这些额外空间不随输入规模n的增大而增加
  • 算法是在原数组上进行排序,不需要额外的数组空间

为什么是O(1)

  • 空间复杂度衡量的是算法执行过程中占用的额外空间
  • 直接插入排序只需要常数级别的额外空间(固定数量的变量)
  • 与输入规模n无关,所以空间复杂度为O(1)

4.5 稳定性分析

直接插入排序是稳定的排序算法。

稳定性解释

  • 稳定性指排序后,相同元素的相对位置保持不变
  • 在直接插入排序中,当比较arr[j] > key时才移动元素,相等时不移动
  • 因此,当遇到相同元素时,不会改变它们的相对顺序

举例说明

假设排序序列:{5, 2, 5, 1, 3}

  • 第一轮:{2, 5, 5, 1, 3}
  • 第二轮:{2, 5, 5, 1, 3}(第二个5不会移动,因为相等)
  • 第三轮:{1, 2, 5, 5, 3}
  • 第四轮:{1, 2, 3, 5, 5}

排序后,两个5的相对位置保持不变,证明了直接插入排序的稳定性。

4.6 实际应用

直接插入排序在以下场景中表现良好:

  1. 小规模数据排序:当待排序元素数量很少(例如n < 50)时,虽然时间复杂度是O(n²),但常数因子很小,实际效率可能比一些更复杂的O(n log n)算法更高。
  2. 数据基本有序:如果序列已经大部分有序,需要进行的比较和移动操作会大幅减少,效率很高。
  3. 作为高级算法的子过程:在快速排序、归并排序等算法中,当递归分解到小规模子序列时,常会切换使用插入排序来优化整体性能。
  4. 链式存储结构:该算法可以很好地应用于链表排序,因为插入操作在链表中只需要修改指针,无需像在顺序表中那样大量移动元素。

五、希尔排序(Shell Sort)

5.1 基本思想

希尔排序(Shell Sort)是直接插入排序的改进版本,也称为"缩小增量排序"(Diminishing Increment Sort)。其核心思想是将整个待排序的序列分割成若干个子序列,分别进行直接插入排序,待整个序列中的记录"基本有序"时,再对全体记录进行一次直接插入排序。

希尔排序的优化原理

  • 直接插入排序在数据已接近有序时效率很高(可达到O(n)),但在数据完全逆序时效率很低(O(n²))
  • 希尔排序通过"分组"和"逐步缩小增量"的方式,使数组在最后进行直接插入排序前已基本有序
  • 通过分组插入排序,让元素可以远距离移动,提前将"离正确位置较远"的元素快速调整到更接近目标的位置

关键步骤

  1. 选择一个初始增量(gap),通常取数组长度的一半
  2. 将数组按增量gap分成多个子序列(间隔为gap的元素组成一个子序列)
  3. 对每个子序列进行直接插入排序
  4. 缩小增量(如gap = gap/2),重复步骤2-3
  5. 当增量缩小到1时,对整个数组进行一次直接插入排序,此时数组已基本有序

5.2 C语言实现

ai版本

c 复制代码
void ShellSort(int* a, int n) {
    int gap = n;
    while (gap > 1) {
        // 优化增量序列:gap = gap/3 + 1
        gap = gap / 3 + 1;
        
        // 对每个子序列进行插入排序
        for (int i = 0; i < n - gap; i++) {
            int end = i;
            int tmp = a[end + gap];
            
            // 在子序列中向前扫描(间隔为gap)
            while (end >= 0) {
                if (tmp < a[end]) {
                    a[end + gap] = a[end];  // 元素后移
                    end -= gap;             // 跳过gap个位置
                } else {
                    break;                  // 找到正确位置,退出循环
                }
            }
            a[end + gap] = tmp;  // 插入到正确位置
        }
    }
}

代码执行过程详解

  1. 初始时,gap = n(数组长度),将整个数组视为一个子序列
  2. 第一轮:gap = n/3 + 1,将数组分成多个子序列(间隔为gap)
  3. 对每个子序列进行插入排序,使数组部分有序
  4. 缩小gap,重复步骤2-3
  5. 当gap=1时,数组已基本有序,进行最后一次插入排序

完整版本

c 复制代码
#include<stdio.h>
#include<stdlib.h>
#include<time.h>

// 打印数组函数
void PrintArray(int* a, int n);
// 希尔排序(改进版插入排序)
void ShellSort(int* a, int n);

// 交换两个元素
void Swap(int* p1, int* p2);

// =============== 4.2 希尔排序 ===============
// 基本思想:通过分组插入排序(增量序列)逐步缩小增量
// 1. 初始增量gap = n(或采用gap = n/3 + 1优化)
// 2. 按gap分组进行插入排序(每组内元素间隔gap)
// 3. 逐步缩小gap(直到gap=1,此时为普通插入排序)
// 优势:先对远距离元素排序,减少后续移动次数
// 时间复杂度:平均O(n^1.3)(取决于增量序列)
// 空间复杂度:O(1)
void ShellSort(int* a, int n)
{
    int gap = n;  // 初始增量(取数组长度)
    while (gap > 1)
    {
        // 优化增量序列:gap = gap/3 + 1(避免退化为O(n²))
        gap = gap / 3 + 1;
        
        // 按当前gap分组进行插入排序
        for (int i = 0; i < n - gap; i++)
        {
            int end = i;                  // 分组内有序区末尾
            int tmp = a[end + gap];       // 待插入元素
            
            // 在分组内向前扫描(间隔为gap)
            while (end >= 0)
            {
                if (tmp < a[end])
                {
                    a[end + gap] = a[end];  // 后移元素
                    end -= gap;             // 跳过gap个位置
                }
                else
                {
                    break;
                }
            }
            a[end + gap] = tmp;  // 插入到正确位置
        }
    }
}

void Swap(int* p1, int* p2)
{
    int tmp = *p1;
    *p1 = *p2;
    *p2 = tmp;
}

void PrintArray(int* a, int n)
{
    for (int i = 0; i < n; i++)
    {
        printf("%d ", a[i]);
    }
    printf("\n");
}

void TestShellSort()
{
    int a[] = {5, 13, 9, 16, 12, 4, 7, 1, 28, 25, 3, 9, 6, 2, 4, 7, 1, 8};
    PrintArray(a, sizeof(a) / sizeof(int));
    ShellSort(a, sizeof(a) / sizeof(int));
    PrintArray(a, sizeof(a) / sizeof(int));
}

int main()
{
    // 测试希尔排序
    TestShellSort();
    return 0;
}

5.3 时间复杂度分析

希尔排序的时间复杂度取决于增量序列的选择,没有固定的O(n^k)形式。以下是详细分析:

5.3.1 最坏情况

当增量序列选择不当(如使用Shell原始序列:gap = n/2, n/4, ..., 1)时,希尔排序的最坏情况时间复杂度为O(n²)。

计算过程

  • 在最坏情况下,每次分组插入排序都需要进行大量的元素移动操作
  • 每次分组排序的平均时间复杂度为O(n²/gap),由于gap逐渐减小,总的时间复杂度为O(n²)
  • 例如,当gap=1时,等同于直接插入排序,最坏情况下需要O(n²)次比较和移动
5.3.2 最好情况

当输入序列已经完全有序时,希尔排序的时间复杂度为O(n)。

计算过程

  • 在数组已经有序的情况下,每个子序列在进行插入排序时,元素几乎不需要移动
  • 每次插入排序只需要比较一次,总比较次数为O(n)
  • 例如,当gap=1时,不需要任何移动,只需进行n-1次比较
5.3.3 平均情况

希尔排序的平均时间复杂度难以精确分析,它取决于增量序列的具体选择。经过大量实验和研究表明:

增量序列类型 平均时间复杂度 说明
Shell原始序列 O(n^1.5) 实现简单,但效率并非最优
Knuth序列 O(n^1.3) 实际应用中性能较好
Hibbard序列 O(n^(3/2)) 最坏情况有保证
Sedgewick序列 O(n^(1.3)) 实践中表现良好

计算过程(以Knuth序列为例):

  • Knuth序列定义为:h = 3*h + 1,初始h=1,直到h > n/3
  • 增量序列为:1, 4, 13, 40, 121, ...
  • 由于每次增量减小,排序效率逐渐提高
  • 平均时间复杂度约为O(n^1.3),比直接插入排序的O(n²)有显著提升

希尔排序的时间复杂度记为O(n^1.3)

5.4 空间复杂度分析

希尔排序的空间复杂度为O(1),即常数级空间复杂度。

计算过程

  • 排序过程仅使用了固定数量的额外空间:
    • 一个临时变量tmp(用于存储待插入元素)
    • 两个索引变量iend
    • 一个增量变量gap
  • 这些额外空间不随输入规模n的增大而增加
  • 算法是在原数组上进行排序,不需要额外的数组空间

为什么是O(1)

  • 空间复杂度衡量的是算法执行过程中占用的额外空间
  • 希尔排序只需要常数级别的额外空间(固定数量的变量)
  • 与输入规模n无关,所以空间复杂度为O(1)

5.5 稳定性分析

希尔排序是不稳定的排序算法。

举例说明

假设排序序列:{5A, 2, 1, 5B, 3}(5A和5B是值相同但需区分顺序的两个元素)

  • 希尔排序后可能得到:{1, 2, 3, 5B, 5A}
  • 排序前5A在5B前,排序后5A在5B后,相对顺序发生了改变

5.6 增量序列优化

增量序列的选择对希尔排序的性能影响很大,以下是几种常见的增量序列及其特点:

5.6.1 Shell原始序列
  • 计算方式:gap = n/2, n/4, ..., 1
  • 特点:实现简单,但效率并非最优
  • 时间复杂度:最坏情况O(n²),平均O(n^1.5)
5.6.2 Knuth序列
  • 计算方式:h = 3*h + 1(初始h=1),直到h > n/3

  • 特点:实际应用中性能较好,平均时间复杂度约为O(n^1.3)

  • 代码实现:

    c 复制代码
    int gap = 1;
    while (gap < n / 3) {
        gap = 3 * gap + 1;
    }
    while (gap > 0) {
        // 插入排序
        gap /= 3;
    }
5.6.3 Hibbard序列
  • 计算方式:1, 3, 7, 15, ..., 2^k-1

  • 特点:最坏情况时间复杂度为O(n^(3/2))

  • 代码实现:

    c 复制代码
    int gap = 1;
    while (gap < n) {
        gap = 2 * gap + 1;
    }
    while (gap > 0) {
        // 插入排序
        gap = (gap - 1) / 2;
    }

5.7 实际应用

希尔排序在以下场景中表现良好:

  1. 中等规模数据排序:当待排序元素数量在几千到几万之间时,希尔排序的效率通常优于直接插入排序和冒泡排序。
  2. 数据部分有序:如果序列已经部分有序,希尔排序的效率会更高,因为预排序过程会更快地使序列变得有序。
  3. 作为高级算法的子过程:在快速排序、归并排序等算法中,当递归分解到小规模子序列时,常会切换使用希尔排序来优化整体性能。
  4. 内存受限环境:希尔排序不需要额外的内存空间,适合在嵌入式系统等内存受限的环境中使用。

5.8 与其他排序算法的比较

排序算法 时间复杂度(平均) 空间复杂度 稳定性 适用场景
直接插入排序 O(n²) O(1) 稳定 小规模数据,已基本有序
希尔排序 O(n^1.3) O(1) 不稳定 中等规模数据,部分有序
冒泡排序 O(n²) O(1) 稳定 小规模数据
选择排序 O(n²) O(1) 不稳定 小规模数据
快速排序 O(n log n) O(log n) 不稳定 大规模数据
归并排序 O(n log n) O(n) 稳定 大规模数据

从表中可以看出,希尔排序在时间复杂度上优于直接插入排序和冒泡排序,且空间复杂度与它们相同,但稳定性不如直接插入排序和冒泡排序。在中等规模数据排序中,希尔排序是一种平衡效率和实现复杂度的良好选择。

六、冒泡排序(Bubble Sort)

6.1 基本思想

冒泡排序(Bubble Sort)是一种简单的排序算法。它重复地走访要排序的数列,一次比较两个相邻的元素,如果它们的顺序错误(如升序排列时前一个元素大于后一个元素)就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,也就是说该数列已经排序完成。

这个算法的名字由来是因为越小的元素会经由交换慢慢"浮"到数列的顶端(升序排列的情况),就如同碳酸饮料中二氧化碳的气泡最终会上浮到顶端一样。

6.2 C语言实现

c 复制代码
// 交换两个整数
void Swap(int* p1, int* p2)
{
    int tmp = *p1;
    *p1 = *p2;
    *p2 = tmp;
}

// 冒泡排序
void BubbleSort(int* a, int n)
{
    // 外层循环:最多n-1轮
    for (int j = 0; j < n-1; j++)
    {
        int exchange = 0;  // 标记本轮是否发生交换
        
        // 内层循环:从0到n-1-j(已排序部分不参与)
        for (int i = 1; i < n - j; i++)
        {
            // 相邻元素逆序则交换
            if (a[i - 1] > a[i])
            {
                Swap(&a[i - 1], &a[i]);
                exchange = 1;  // 发生交换
            }
        }
        
        // 本轮无交换,提前结束
        if (exchange == 0)
            break;
    }
}

6.3 执行过程详解

以数组 [5, 3, 9, 6, 2, 4] 为例:

第一轮(最大值"浮"到末尾):

  • 比较5和3,交换 → [3, 5, 9, 6, 2, 4]
  • 比较5和9,不交换 → [3, 5, 9, 6, 2, 4]
  • 比较9和6,交换 → [3, 5, 6, 9, 2, 4]
  • 比较9和2,交换 → [3, 5, 6, 2, 9, 4]
  • 比较9和4,交换 → [3, 5, 6, 2, 4, 9]
  • 最大值9已"浮"到末尾

第二轮(第二大值"浮"到倒数第二位):

  • 比较3和5,不交换 → [3, 5, 6, 2, 4, 9]
  • 比较5和6,不交换 → [3, 5, 6, 2, 4, 9]
  • 比较6和2,交换 → [3, 5, 2, 6, 4, 9]
  • 比较6和4,交换 → [3, 5, 2, 4, 6, 9]
  • 第二大值6已"浮"到倒数第二位

第三轮(第三大值"浮"到倒数第三位):

  • 比较3和5,不交换 → [3, 5, 2, 4, 6, 9]
  • 比较5和2,交换 → [3, 2, 5, 4, 6, 9]
  • 比较5和4,交换 → [3, 2, 4, 5, 6, 9]
  • 第三大值5已"浮"到倒数第三位

第四轮(第四大值"浮"到倒数第四位):

  • 比较3和2,交换 → [2, 3, 4, 5, 6, 9]
  • 比较3和4,不交换 → [2, 3, 4, 5, 6, 9]
  • 第四大值4已"浮"到倒数第四位

第五轮(已有序):

  • 比较2和3,不交换 → [2, 3, 4, 5, 6, 9]
  • 本轮无交换,提前结束

6.4 时间复杂度分析

冒泡排序的时间复杂度取决于输入数据的初始状态:

6.4.1 最坏情况

当输入数组是完全逆序时,冒泡排序需要进行最多的比较和交换操作。

  • 第1轮:比较n-1次
  • 第2轮:比较n-2次
  • ...
  • 第n-1轮:比较1次

总比较次数 = (n-1) + (n-2) + ... + 1 = n(n-1)/2

因此,最坏情况下的时间复杂度为O(n²)。

6.4.2 最好情况

当输入数组已经是有序时,冒泡排序只需要进行一轮比较,比较次数为n-1次。

因此,最好情况下的时间复杂度为O(n)。

6.4.3 平均情况

在平均情况下,数组的无序程度使得比较次数接近最坏情况,因此平均时间复杂度为O(n²)。

6.4.4 精确计算与大O表示
  • 最坏情况:n(n-1)/2 = (n² - n)/2
  • 最好情况:n-1
  • 平均情况:约n²/4

使用大O表示法:

  • 最坏情况:O(n²)
  • 最好情况:O(n)
  • 平均情况:O(n²)

6.5 空间复杂度分析

冒泡排序的空间复杂度为O(1),即常数级空间复杂度。

6.5.1 计算过程
  • 冒泡排序在排序过程中只需要使用少量的额外空间:
    • 一个临时变量用于交换元素(在Swap函数中)
    • 两个索引变量(i和j)
    • 一个标记变量(exchange)
  • 这些额外空间不随输入规模n的增大而增加
  • 算法是在原数组上进行排序,不需要额外的数组空间
6.5.2 为什么是O(1)
  • 空间复杂度衡量的是算法执行过程中占用的额外空间
  • 冒泡排序只需要常数级别的额外空间(固定数量的变量)
  • 与输入规模n无关,所以空间复杂度为O(1)

6.6 稳定性分析

冒泡排序是稳定的排序算法。

6.6. 1 举例说明

假设排序序列:{5A, 3, 2, 5B, 1}(5A和5B是值相同但需区分顺序的两个元素)

  • 排序后可能得到:{1, 2, 3, 5A, 5B}
  • 排序前5A在5B前,排序后5A仍在5B前,相对顺序保持不变

6.7 优化策略

6.7.1 提前终止优化

在每一轮排序后,如果发现没有发生任何交换,说明数组已经有序,可以提前终止排序。

c 复制代码
int exchange = 0;  // 标记本轮是否发生交换
for (int i = 1; i < n - j; i++)
{
    if (a[i - 1] > a[i])
    {
        Swap(&a[i - 1], &a[i]);
        exchange = 1;  // 发生交换
    }
}
if (exchange == 0)
    break;  // 本轮无交换,提前结束
6.7.2 记录末次交换位置

在每一轮排序中,记录最后一次交换的位置,这样下一轮只需排序到该位置,可以减少比较次数。

6.8 与其他排序算法的比较

排序算法 时间复杂度(平均) 空间复杂度 稳定性 适用场景
冒泡排序 O(n²) O(1) 稳定 小规模数据,已基本有序
直接插入排序 O(n²) O(1) 稳定 小规模数据,已基本有序
选择排序 O(n²) O(1) 不稳定 小规模数据
希尔排序 O(n^1.3) O(1) 不稳定 中等规模数据,部分有序
快速排序 O(n log n) O(log n) 不稳定 大规模数据
归并排序 O(n log n) O(n) 稳定 大规模数据

从表中可以看出,冒泡排序在时间复杂度上不如希尔排序、快速排序和归并排序,但其空间复杂度和稳定性与直接插入排序相当,适合小规模数据排序。

6.9 实际应用

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

  1. 小规模数据排序:当待排序元素数量较少(如几十个)时,冒泡排序的简单实现使其易于理解和使用。
  2. 已部分有序的数据:如果序列已经部分有序,冒泡排序的提前终止优化能显著提高效率。
  3. 教学目的:冒泡排序是学习排序算法的绝佳入门示例,因为其逻辑简单直观。
  4. 嵌入式系统:在资源受限的环境中,冒泡排序的低空间复杂度使其成为可行的选择。

注意:冒泡排序在大规模数据排序中效率较低,不适用于实际生产环境中的大数据量排序,但在教学和小规模数据处理中仍有其价值。

七、 快速排序(Quick Sort)

7.1 基本思想

快速排序(Quick Sort)是一种高效的分治排序算法。其核心思想是通过选择一个基准元素(pivot),将数组划分为两个子数组:一个子数组包含所有小于基准的元素,另一个子数组包含所有大于基准的元素。然后递归地对这两个子数组进行排序。

关键步骤

  1. 选择基准元素(pivot)
  2. 将数组划分为两部分:小于基准的元素和大于基准的元素
  3. 递归地对两部分进行快速排序

7.2 四种分区方法对比

7.2.1 Hoare分区法 (QuickSort1)

实现原理

  • 使用两个指针(left和right)从两端向中间移动
  • left指针向右移动直到找到大于基准的元素
  • right指针向左移动直到找到小于基准的元素
  • 交换这两个元素,直到left和right相遇
  • 最终基准位置在left(或right)处

优点

  • 分区效率高,交换次数少
  • 通常比前后指针法快10-20%
  • 实现简洁,代码行数少

缺点

  • 基准选择不当可能导致性能下降(但通过三数取中优化后已解决)
  • 基准位置不固定,需额外处理

理解版本

c 复制代码
// 快速排序1:Hoare分区法实现
// 1. 采用三数取中法选择基准(避免最坏情况)
// 2. 小区间(长度<=10)使用插入排序优化
// 3. 分区原理:两个指针从两端向中间移动
//    - right指针向左移动找小于基准的元素
//    - left指针向右移动找大于基准的元素
//    - 交换两个元素,直到指针相遇
// 4. 将基准与相遇点交换,完成分区
int GetMidi(int* a, int left, int right)
{
	int mid = (left + right) / 2;
	
	if (a[left] < a[mid])
	{
		if (a[mid] < a[right])
		{
			return mid;
		}
		else if (a[left] > a[right])
		{
			return left;
		}
		else
		{
			return right;
		}
	}
	else // a[left] > a[mid]
	{
		if (a[mid] > a[right])
		{
			return mid;
		}
		else if (a[left] < a[right])
		{
			return left;
		}
		else
		{
			return right;
		}
	}
}
void QuickSort1(int* a, int left, int right)
{
    // 区间无效(单元素或空区间)直接返回
    if (left >= right)
        return;

    // 小区间优化:长度<=10时使用插入排序
    if (right - left + 1 < 10)
    {
        InsertSort(a + left, right - left + 1);
    }
    else
    {
        int begin = left, end = right;

        // 三数取中选择基准(避免最坏情况)
        int midi = GetMidi(a, left, right);
        Swap(&a[left], &a[midi]);  // 将基准移到left位置

        int keyi = left;  // 基准索引
        while (left < right)
        {
            // 右指针向左移动:找小于基准的元素
            while (left < right && a[right] >= a[keyi])
            //在处理重复元素时,如果内层不再次判断 left < right,可能会导致无限循环。
            //如while (left < right && a[right] >= 5)  --right;
                --right;
            
            // 左指针向右移动:找大于基准的元素
            while (left < right && a[left] <= a[keyi])
                ++left;
            
            // 交换左右指针位置的元素
            Swap(&a[left], &a[right]);
        }

        // 将基准与相遇点交换
        Swap(&a[left], &a[keyi]);
        keyi = left;

        // 递归排序左右子数组, [begin, keyi-1]keyi[keyi+1, end]
        QuickSort1(a, begin, keyi - 1);
        QuickSort1(a, keyi + 1, end);
    }
}

ai完整实现版本

c 复制代码
// Hoare分区法快速排序
// 采用三数取中法选择基准,避免最坏情况
// 小区间优化:当子数组长度≤10时使用插入排序
void QuickSort1(int* a, int left, int right)
{
    // 区间无效(单元素或空区间)直接返回
    if (left >= right)
        return;
    
    // 小区间优化:长度≤10时使用插入排序,当区间长度 ≤ 10时,使用插入排序,因为对于小数组,插入排序的常数因子更小,效率更高
    if (right - left + 1 <= 10)
    {
        InsertSort(a + left, right - left + 1);
        return;
    }
    
    // 三数取中选择基准(避免最坏情况),通过交换确保:a[left] ≤ a[midi] ≤ a[right]
    int midi = (left + right) / 2;
    if (a[left] > a[midi])
        Swap(&a[left], &a[midi]);
    if (a[left] > a[right])
        Swap(&a[left], &a[right]);
    if (a[midi] > a[right])
        Swap(&a[midi], &a[right]);
    int pivot = a[midi];
    
    // Hoare分区法
    int i = left - 1;//i从左向右移动,寻找第一个大于等于pivot的元素
    int j = right + 1;//j从右向左移动,寻找第一个小于等于pivot的元素
    while (true)
    {
        do { i++; } while (a[i] < pivot);//从左找 ≥ pivot的元素
        do { j--; } while (a[j] > pivot);//从右找 ≤ pivot的元素
        if (i >= j)
            break;
        Swap(&a[i], &a[j]);
    }
    
    // 递归排序左右子数组,
    
    QuickSort1(a, left, j);
    QuickSort1(a, j + 1, right);
}

一个简单的示例

假设数组为:[5, 3, 7, 6, 4, 1, 0, 2],left=0, right=7

三数取中:选择a[0]=5, a[3]=6, a[7]=2的中位数

交换后:[2, 3, 7, 6, 4, 1, 0, 5],pivot=5

Hoare分区:

i=-1, j=8

i移动到0(a[0]=2 < 5,继续移动→i=1,a[1]=3 < 5,继续→i=2,a[2]=7 ≥ 5)

j移动到7(a[7]=5 ≥ 5,继续移动→j=6,a[6]=0 ≤ 5)

交换a[2]和a[6]:[2, 3, 0, 6, 4, 1, 7, 5]

i移动到3(a[3]=6 ≥ 5)

j移动到5(a[5]=1 ≤ 5)

交换a[3]和a[5]:[2, 3, 0, 1, 4, 6, 7, 5]

i移动到4(a[4]=4 < 5,继续→i=5,a[5]=6 ≥ 5)

j移动到4(a[4]=4 ≤ 5)

i=5, j=4,i > j,分区完成

分区结果:

左子数组:[2, 3, 0, 1, 4](left到j=4)

右子数组:[6, 7, 5](j+1=5到right=7)

7.2.2 前后指针法 (QuickSort2)

实现原理

  • 使用prev指针(指向小于基准的最后一个元素)和cur指针(遍历数组)
  • cur从left+1开始遍历,若a[cur] ≤ pivot,则prev++并交换a[prev]和a[cur]
  • 最后将pivot放在prev+1位置

优点

  • 实现清晰,逻辑易于理解
  • 代码结构简单,适合教学
  • 基准位置固定,便于调试

缺点

  • 分区效率略低于Hoare分区法
  • 交换次数较多,平均性能稍差

理解版本

c 复制代码
// 快速排序2:前后指针法实现
// 1. 基准选择为left位置(未使用三数取中优化)
// 2. 分区原理:prev指针指向小于基准的最后一个元素
//    - cur指针从left+1遍历到right
//    - 若a[cur] <= 基准,则prev++并交换a[prev]和a[cur]
// 3. 最后将基准与prev+1位置交换
void QuickSort2(int* a, int left, int right)
{
    // 区间无效(单元素或空区间)直接返回
    if (left >= right)
        return;

    int keyi = left;        // 基准索引(left位置)
    int prev = left;        // 小于基准的最后一个元素索引
    int cur = left + 1;     // 当前遍历指针

    // 遍历数组
    while (cur <= right)
    {
        // 若当前元素小于等于基准,则交换到prev位置
        if (a[cur] < a[keyi] && ++prev != cur)//&&的后者判断意味着默认prev后一个元素只能等于或者大于a[key]的值
            Swap(&a[prev], &a[cur]);
        
        ++cur;
    }

    // 将基准放到正确位置
    Swap(&a[keyi], &a[prev]);
    keyi = prev;

    // 递归排序左右子数组, [left, keyi-1]keyi[keyi+1, right]
    QuickSort2(a, left, keyi - 1);
    QuickSort2(a, keyi + 1, right);
}

完整实现

c 复制代码
// 前后指针法快速排序
// 采用三数取中法选择基准,避免最坏情况
// 小区间优化:当子数组长度≤10时使用插入排序
void QuickSort2(int* a, int left, int right)
{
    // 区间无效(单元素或空区间)直接返回
    if (left >= right)
        return;
    
    // 小区间优化:长度≤10时使用插入排序
    if (right - left + 1 <= 10)
    {
        InsertSort(a + left, right - left + 1);
        return;
    }
    
    // 三数取中选择基准(避免最坏情况)
    int midi = (left + right) / 2;
    if (a[left] > a[midi])
        Swap(&a[left], &a[midi]);
    if (a[left] > a[right])
        Swap(&a[left], &a[right]);
    if (a[midi] > a[right])
        Swap(&a[midi], &a[right]);
    int pivot = a[midi];
    
    // 前后指针法
    int prev = left - 1;
    for (int cur = left; cur <= right; cur++)
    {
        if (a[cur] <= pivot)
        {
            prev++;
            Swap(&a[prev], &a[cur]);
        }
    }
    
    // 递归排序左右子数组
    QuickSort2(a, left, prev - 1);
    QuickSort2(a, prev + 1, right);
}
7.2.3 挖坑法(前后指针法的另一种实现)

挖坑法的核心思想是:在基准位置"挖一个坑",然后从两端向中间移动,将比基准小的元素放入左边的坑,将比基准大的元素放入右边的坑,直到左右指针相遇,将基准放入最后的坑中。

优点

  • 实现简单,易于理解
  • 代码逻辑清晰
  • 交换次数少

缺点

  • 基准选择不当可能导致性能下降

ai完整实现版本

c 复制代码
// 挖坑法快速排序
// 采用三数取中法选择基准,避免最坏情况
// 小区间优化:当子数组长度≤10时使用插入排序
void QuickSort3(int* a, int left, int right)
{
    // 区间无效(单元素或空区间)直接返回
    if (left >= right)
        return;
    
    // 小区间优化:长度≤10时使用插入排序
    if (right - left + 1 <= 10)
    {
        InsertSort(a + left, right - left + 1);
        return;
    }
    
    // 三数取中选择基准(避免最坏情况)
    int midi = (left + right) / 2;
    if (a[left] > a[midi])
        Swap(&a[left], &a[midi]);
    if (a[left] > a[right])
        Swap(&a[left], &a[right]);
    if (a[midi] > a[right])
        Swap(&a[midi], &a[right]);
    int pivot = a[midi];
    
    // 挖坑法
    int i = left;
    int j = right;
    while (i < j)
    {
        // 从右向左移动j,直到找到小于pivot的元素
        while (i < j && a[j] >= pivot)
            j--;
        a[i] = a[j];
        
        // 从左向右移动i,直到找到大于pivot的元素
        while (i < j && a[i] <= pivot)
            i++;
        a[j] = a[i];
    }
    a[i] = pivot;
    
    // 递归排序左右子数组
    QuickSort3(a, left, i - 1);
    QuickSort3(a, i + 1, right);
}
7.2.4 非递归实现 (QuickSortNonR)

实现原理

  • 使用栈模拟递归调用
  • 将待排序区间(left, right)压入栈
  • 从栈中弹出区间,进行分区
  • 将分区后的左右区间压入栈
  • 重复直到栈为空

优点

  • 避免了递归调用的栈空间开销
  • 不会因递归过深导致栈溢出
  • 适合大规模数据排序

缺点

  • 实现相对复杂
  • 需要额外的栈空间(但通常小于递归栈空间)
  • 代码可读性稍差

理解版本

c 复制代码
// 快速排序非递归实现
// 1. 使用栈模拟递归调用
// 2. 先压入右区间,再压入左区间(保证较小区间先处理)
// 3. 分区使用前后指针法(同QuickSort2)
// 4. 通过栈迭代实现递归过程



//栈函数
typedef int STDataType;
typedef struct Stack
{
	STDataType* a;
	int top;
	int capacity;
}ST;
void STInit(ST* ps)
{
	assert(ps);

	ps->a = NULL;
	ps->top = 0;
	ps->capacity = 0;
}

void STDestroy(ST* ps)
{
	assert(ps);

	free(ps->a);
	ps->a = NULL;
	ps->top = ps->capacity = 0;
}
void STPush(ST* ps, STDataType x)
{
	assert(ps);

	// ˣ 
	if (ps->top == ps->capacity)
	{
		int newcapacity = ps->capacity == 0 ? 4 : ps->capacity * 2;
		STDataType* tmp = (STDataType*)realloc(ps->a, newcapacity * sizeof(STDataType));
		if (tmp == NULL)
		{
			perror("realloc fail");
			return;
		}

		ps->a = tmp;
		ps->capacity = newcapacity;
	}

	ps->a[ps->top] = x;
	ps->top++;
}

void STPop(ST* ps)
{
	assert(ps);
	assert(!STEmpty(ps));

	ps->top--;
}

STDataType STTop(ST* ps)
{
	assert(ps);
	assert(!STEmpty(ps));

	return ps->a[ps->top - 1];
}

int STSize(ST* ps)
{
	assert(ps);

	return ps->top;
}

bool STEmpty(ST* ps)
{
	assert(ps);

	return ps->top == 0;
}








void QuickSortNonR(int* a, int left, int right)
{
    ST st;
    STInit(&st);            // 初始化栈
    STPush(&st, right);     // 压入右边界
    STPush(&st, left);      // 压入左边界

    while (!STEmpty(&st))
    {
        int begin = STTop(&st);  // 弹出左边界
        STPop(&st);
        int end = STTop(&st);    // 弹出右边界
        STPop(&st);

        // 分区操作(使用前后指针法)
        int keyi = begin;
        int prev = begin;
        int cur = begin + 1;

        while (cur <= end)
        {
            if (a[cur] < a[keyi] && ++prev != cur)
                Swap(&a[prev], &a[cur]);
            ++cur;
        }
        Swap(&a[keyi], &a[prev]);
        keyi = prev;

        // 将左右子区间压入栈(先压右后压左)
        // [begin, keyi-1] keyi [keyi+1, end] 
        if (keyi + 1 < end)
        {
            STPush(&st, end);
            STPush(&st, keyi + 1);
        }
        if (begin < keyi - 1)
        {
            STPush(&st, keyi - 1);
            STPush(&st, begin);
        }
    }

    STDestroy(&st);  // 销毁栈
}

完整实现

c 复制代码
// 非递归快速排序
// 采用三数取中法选择基准,避免最坏情况
// 小区间优化:当子数组长度≤10时使用插入排序
void QuickSortNonR(int* a, int left, int right)
{
    // 使用栈模拟递归调用
    int stack[1000];  // 假设最大深度为1000
    int top = -1;
    
    // 将初始区间压入栈
    stack[++top] = left;
    stack[++top] = right;
    
    while (top >= 0)
    {
        // 弹出当前区间
        int r = stack[top--];
        int l = stack[top--];
        
        // 如果区间无效,跳过
        if (l >= r)
            continue;
        
        // 小数组优化:使用插入排序
        if (r - l <= 10)
        {
            InsertSort(a + l, r - l + 1);
            continue;
        }
        
        // 三数取中选择基准(避免最坏情况)
        int midi = (l + r) / 2;
        if (a[l] > a[midi])
            Swap(&a[l], &a[midi]);
        if (a[l] > a[r])
            Swap(&a[l], &a[r]);
        if (a[midi] > a[r])
            Swap(&a[midi], &a[r]);
        int pivot = a[midi];
        
        // Hoare分区法
        int i = l - 1;
        int j = r + 1;
        while (true)
        {
            do { i++; } while (a[i] < pivot);
            do { j--; } while (a[j] > pivot);
            if (i >= j)
                break;
            Swap(&a[i], &a[j]);
        }
        
        // 先压入右边区间,再压入左边区间
        stack[++top] = l;
        stack[++top] = j;
        
        stack[++top] = j + 1;
        stack[++top] = r;
    }
}

7.3 辅助函数

交换函数
c 复制代码
void Swap(int* p1, int* p2)
{
    int tmp = *p1;
    *p1 = *p2;
    *p2 = tmp;
}
插入排序(用于小规模数组)
c 复制代码
void InsertSort(int* a, int n)
{
    for (int i = 1; i < n; i++)
    {
        int tmp = a[i];
        int j = i;
        while (j > 0 && tmp < a[j - 1])
        {
            a[j] = a[j - 1];
            j--;
        }
        a[j] = tmp;
    }
}

7.4 优化策略总结

优化策略 作用 效果 实现方式
三数取中法 避免最坏情况 使平均性能接近O(n log n) 选择最左、最右和中间元素的中位数作为基准
小区间插入排序 减少递归开销 提高小规模数据排序效率 当子数组大小≤10时使用插入排序
尾递归优化 降低递归深度 将空间复杂度从O(n)优化到O(log n) 优先处理较小的子数组
双轴快排 提高分区效率 在处理重复元素时性能提升 使用两个基准元素,将数组分为三部分

7.5 时间与空间复杂度的详细分析

时间复杂度

平均情况(O(n log n))

快速排序的平均时间复杂度为O(n *log n),这是因为:

  1. 每次分区操作需要O(n)的时间,因为需要遍历整个数组
  2. 分区后,数组被分成两个子数组,平均情况下,这两个子数组的大小大致相等
  3. 递归深度为O(log n),因为每次递归将数组规模减半

具体计算:

  • 第一层:n
  • 第二层:n/2 + n/2 = n
  • 第三层:n/4 + n/4 + n/4 + n/4 = n
  • ...
  • 第log n层:n/2^(log n) * 2^(log n) = n

总时间 = n + n + n + ... (log n次) = n * log n

最坏情况(O(n²))

最坏情况发生在每次分区都选择最小或最大元素作为基准时,例如数组已经有序。此时:

  • 第一层:n
  • 第二层:n-1
  • 第三层:n-2
  • ...
  • 第n层:1

总时间 = n + (n-1) + (n-2) + ... + 1 = n(n+1)/2 = O(n²)

最好情况(O(n log n))

最好情况发生在每次分区都能将数组平均分成两半时,此时和平均情况相同,为O(n log n)。

最坏情况的数学分析

在最坏情况下,快速排序的时间复杂度为O(n²),这是因为:

  • 选择基准值后,分区操作将数组分为1个元素和n-1个元素
  • 递归处理n-1个元素的子数组
  • 递归深度为n
  • 总操作次数为1+2+3+...+n = n(n+1)/2

三数取中法的数学分析

使用三数取中法选择基准,可以将最坏情况发生的概率从1/2(固定选择首元素)降低到约2/3(当数组有序时):

  • 从数组的首、中、尾三个位置随机选择一个元素
  • 选择中位数作为基准,使基准值接近数组中位数
  • 使分区后两部分的大小更均衡

小数组优化的数学依据

对于小数组(n ≤ 10),插入排序的常数因子比快速排序小:

  • 插入排序的平均比较次数约为n²/4
  • 快速排序的递归开销(函数调用、栈操作等)比插入排序的比较开销大
  • 当n较小时,插入排序的常数因子更小,整体效率更高
空间复杂度

递归版本

空间复杂度主要由递归栈的深度决定。

  • 最坏情况:递归深度为n,空间复杂度为O(n)

    • 例如:当数组已经有序,每次分区只产生一个子数组
    • 递归调用链长度为n
  • 平均情况:递归深度为log n,空间复杂度为O(log n)

    • 例如:当每次分区将数组平均分成两部分
    • 递归调用链长度约为log n
  • 最好情况:递归深度为log n,空间复杂度为O(log n)

    • 例如:当每次分区将数组平均分成两部分

尾递归优化

通过尾递归优化(优先处理较小的子数组),可以将递归深度从O(n)降低到O(log n):

c 复制代码
void quickSort(int* a, int left, int right) {
    while (left < right) {
        int pivot = partition(a, left, right);
        if (pivot - left < right - pivot) {
            quickSort(a, left, pivot - 1);
            left = pivot + 1;
        } else {
            quickSort(a, pivot + 1, right);
            right = pivot - 1;
        }
    }
}

非递归版本

使用栈模拟递归,空间复杂度取决于栈的最大深度,与递归版本相同,为O(log n)。

空间复杂度的数学分析

  • 递归版本的空间复杂度为O(log n),因为递归深度为log n
  • 非递归版本的空间复杂度为O(log n),因为栈的最大深度为log n
  • 每次递归调用需要O(1)的空间(存储left和right)
稳定性分析

快速排序是不稳定的排序算法,因为分区过程中相同元素的相对位置可能改变:

  • 例如,[3, 3, 1]中,两个3的相对位置在分区后可能改变
  • 不稳定的原因:在分区过程中,相同元素可能被交换到不同的位置

7.6 实际应用

快速排序在以下场景中表现良好:

  1. 大规模数据排序:当待排序元素数量较大(如数千个以上)时,快速排序的效率通常优于其他简单排序算法。
  2. 内存受限环境:快速排序是原地排序算法,不需要额外的内存空间。
  3. 通用排序:在大多数编程语言的标准库中,快速排序被用作默认的排序算法。

实际应用中的注意事项

  1. 最坏情况的预防

    • 在实际应用中,应始终使用三数取中法或随机选择基准
    • 对于特定的输入数据(如已排序数组),三数取中法能有效避免最坏情况
  2. 性能优化

    • 对于小规模数据,使用插入排序代替快速排序
    • 采用尾递归优化,减少递归深度
    • 对于重复元素较多的数组,考虑使用三路快速排序
  3. 工程实现

    • Java的Arrays.sort()使用双轴快速排序(Dual-Pivot QuickSort)
    • C++的std::sort使用内省排序(IntroSort),结合了快速排序、堆排序和插入排序
    • Python的Timsort是混合排序算法,但快速排序的原理仍被广泛应用

快速排序在主流编程语言中的应用

  • Java:Arrays.sort()使用双轴快速排序(对于基本类型)和归并排序(对于对象类型)
  • C++:std::sort使用内省排序(IntroSort),避免快速排序的最坏情况
  • Python:sorted()和list.sort()使用Timsort,但快速排序的原理被广泛应用
  • Go:sort.Sort()使用快速排序

注意:快速排序在最坏情况下性能会退化为O(n²),但通过合理的基准选择策略(如三数取中法)和小数组优化,可以有效避免这种情况。在实际应用中,优化后的快速排序通常比其他排序算法更快,是许多编程语言标准库的默认排序算法。

7.7 快速排序的3路划分优化与C++ STL中的内省排序

7.7.1 快速排序的3路划分优化
  • 为什么需要3路划分?

    传统快速排序在处理大量重复元素的数组时效率会大幅下降。例如,当数组中大部分元素都相同时,每次划分都可能产生一个空数组和一个几乎完整的数组,导致时间复杂度退化为O(n²)。

  • 3路划分的核心思想--三路快排将数组划分为三部分

    • 小于基准值的部分
    • 等于基准值的部分
    • 大于基准值的部分
      这样,等于基准值的部分在划分后不需要再进行递归处理,大大提高了效率。
  • 3路划分的实现原理--三路划分使用三个指针

    • lt:指向小于基准值部分的最后一个元素
    • gt:指向大于基准值部分的第一个元素
    • i:当前遍历的元素位置
  • 具体步骤:

    1. 选取基准值(通常使用三数取中法)
    2. 初始化指针:lt = left, gt = right + 1, i = left
    3. i < gt时:
      • 如果a[i] < 基准值,交换a[i]a[lt]lt++i++
      • 如果a[i] > 基准值,交换a[i]a[gt]gt--
      • 如果a[i] == 基准值i++
  • 3路划分C语言实现

理解版本

c 复制代码
1. key默认取left位置的值。
2. left指向区间最左边,right指向区间最后边,cur指向left+1位置。
3. cur遇到⽐key⼩的值后跟left位置交换,换到左边,left++,cur++。
4. cur遇到⽐key⼤的值后跟right位置交换,换到右边,right--。
5. cur遇到跟key相等的值后,cur++。
6. 直到cur>right结束

void QuickSort(int* a, int left, int right)
{
    // 如果区间长度小于等于1,直接返回,无需排序
    if (left >= right)
        return;

    int begin = left;  // 区间的起始位置
    int end = right;   // 区间的结束位置

    // 随机选择一个基准值(pivot)的索引
    int randi = left + (rand() % (right - left + 1));
    // 将随机选择的基准值与区间的第一个元素交换,简化后续操作
    Swap(&a[left], &a[randi]);

    int key = a[left];  // 基准值
    int cur = left + 1; // 当前检查的元素位置

    // 遍历区间 [left+1, right],根据元素与基准值的比较结果进行分区
    while (cur <= right)
    {
        if (a[cur] < key)
        {
            // 如果当前元素小于基准值,将其与 [left+1, cur) 区间内的元素交换
            Swap(&a[cur], &a[left]);
            ++left;  // 更新小于基准值的区间结束位置
            ++cur;   // 移动到下一个元素
        }
        else if (a[cur] > key)
        {
            // 如果当前元素大于基准值,将其与 [cur, right] 区间内的元素交换
            Swap(&a[cur], &a[right]);
            --right; // 更新大于基准值的区间起始位置
        }
        else
        {
            // 如果当前元素等于基准值,直接跳过
            ++cur;
        }
    }

    // 此时,数组被分为三部分:
    // [begin, left-1]:小于基准值的元素
    // [left, right]:等于基准值的元素
    // [right+1, end]:大于基准值的元素

    // 递归对小于和大于基准值的区间进行排序
    QuickSort(a, begin, left - 1);
    QuickSort(a, right + 1, end);
}

ai版本

c 复制代码
#include <stdio.h>
#include <stdlib.h>

// 三数取中法选取基准值
int median_of_three(int *arr, int left, int right) {
    int mid = left + (right - left) / 2;
    if (arr[left] > arr[mid])
        swap(&arr[left], &arr[mid]);
    if (arr[left] > arr[right])
        swap(&arr[left], &arr[right]);
    if (arr[mid] > arr[right])
        swap(&arr[mid], &arr[right]);
    return arr[mid];
}

// 3路快排划分函数
void three_way_partition(int *arr, int left, int right, int *lt, int *gt) {
    int pivot = median_of_three(arr, left, right);
    int i = left;
    *lt = left;
    *gt = right + 1;
    
    while (i < *gt) {
        if (arr[i] < pivot) {
            swap(&arr[i], &arr[*lt]);
            (*lt)++;
            i++;
        } else if (arr[i] > pivot) {
            (*gt)--;
            swap(&arr[i], &arr[*gt]);
        } else {
            i++;
        }
    }
}

// 3路快排主函数
void three_way_quicksort(int *arr, int left, int right) {
    if (left >= right) return;
    
    int lt, gt;
    three_way_partition(arr, left, right, &lt, &gt);
    
    three_way_quicksort(arr, left, lt - 1);
    three_way_quicksort(arr, gt, right);
}

// 交换函数
void swap(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

// 测试函数
int main() {
    int arr[] = {4, 2, 2, 3, 1, 1, 1, 5, 5};
    int n = sizeof(arr) / sizeof(arr[0]);
    
    three_way_quicksort(arr, 0, n - 1);
    
    printf("Sorted array: ");
    for (int i = 0; i < n; i++)
        printf("%d ", arr[i]);
    printf("\n");
    
    return 0;
}
  • 3路划分的优势
    • 处理重复元素效率高:对于大量重复元素的数组,性能接近O(n)
    • 减少递归深度:不需要对等于基准值的部分进行递归
    • 减少交换次数:避免了不必要的交换
7.7.2 C++ STL中使用的内省排序(Introsort)
  • 为什么需要内省排序?

    传统快速排序的最坏情况 (O(n²))在某些特定输入下会发生,比如已经排好序的数组。C++ STL需要一种保证最坏情况性能的排序算法,同时保持平均性能优势。

  • 内省排序的核心思想--内省排序是快速排序、堆排序和插入排序的混合算法 :

    1. 快速排序:作为主要排序算法,平均性能好
    2. 堆排序:作为后备算法,保证最坏情况性能为O(n log n)
    3. 插入排序:用于处理小数组,效率更高
  • 内省排序的工作原理

    1. 初始阶段:使用快速排序
    2. 深度检测:监控递归深度,当深度超过阈值(2*log₂(n))时
    3. 切换到堆排序:避免快速排序性能退化
    4. 小数组处理:当数组大小小于阈值(通常16)时,使用插入排序
  • C++ STL中内省排序的实现
    理解版本

c 复制代码
/**
 * 内省排序(Introsort)实现
 * 结合了快速排序、堆排序和插入排序的优势
 * 保证最坏情况时间复杂度为O(n log n),同时保持平均性能
 * 适用于大规模数据排序
 */

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

/**
 * 交换两个整数
 * @param x 指向第一个整数的指针
 * @param y 指向第二个整数的指针
 */
void Swap(int* x, int* y)
{
    int tmp = *x;
    *x = *y;
    *y = tmp;
}

/**
 * 堆排序的向下调整函数
 * @param a 数组
 * @param n 数组大小
 * @param parent 父节点索引
 */
void AdjustDown(int* a, int n, int parent)
{
    int child = parent * 2 + 1;  // 左孩子索引
    while (child < n)
    {
        // 1. 选出左右孩子中较大的那个(如果右孩子存在且更大)
        if (child + 1 < n && a[child + 1] > a[child])
        {
            ++child;  // 选择右孩子
        }
        // 2. 如果较大的孩子比父节点大,则交换
        if (a[child] > a[parent])
        {
            Swap(&a[child], &a[parent]);
            parent = child;  // 更新父节点位置
            child = parent * 2 + 1;  // 更新孩子索引
        }
        else
        {
            break;  // 已满足堆性质,无需继续调整
        }
    }
}

/**
 * 堆排序实现
 * @param a 数组
 * @param n 数组大小
 */
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[end], &a[0]);  // 将最大值(堆顶)放到末尾
        AdjustDown(a, end, 0);  // 调整剩余部分(长度为end)
        --end;
    }
}

/**
 * 插入排序实现
 * 适用于小规模数组(效率高于快速排序)
 * @param a 数组
 * @param n 数组大小
 */
void InsertSort(int* a, int n)
{
    // 1. 从第二个元素开始遍历
    for (int i = 1; i < n; i++)
    {
        int end = i - 1;
        int tmp = a[i];
        // 2. 将当前元素插入到已排序部分的正确位置
        while (end >= 0)
        {
            if (tmp < a[end])
            {
                a[end + 1] = a[end];  // 元素后移
                --end;
            }
            else
            {
                break;  // 找到插入位置
            }
        }
        a[end + 1] = tmp;  // 插入元素
    }
}

/**
 * 内省排序核心函数
 * @param a 数组
 * @param left 左边界
 * @param right 右边界
 * @param depth 当前递归深度
 * @param defaultDepth 深度阈值(2*log2(n))
 */
void IntroSort(int* a, int left, int right, int depth, int defaultDepth)
{
    // 1. 基本终止条件:子数组长度<=1,无需排序
    if (left >= right)
        return;
    
    // 2. 小数组优化:当子数组长度<16时,使用插入排序
    // 为什么?插入排序在小数组上效率更高(常数因子小)
    if (right - left + 1 < 16)
    {
        InsertSort(a + left, right - left + 1);
        return;        
    }
    
    // 3. 深度控制:当递归深度超过阈值时切换到堆排序
    // 为什么?防止快速排序最坏情况(O(n²)),确保最坏情况O(n log n)
    if (depth > defaultDepth)
    {
        HeapSort(a + left, right - left + 1);
        return;
    }
    
    // 4. 递归深度增加(用于后续深度检查)
    depth++;
    
    // 5. 传统快速排序的划分过程
    int begin = left;
    int end = right;
    
    // 5.1 随机选择基准值(避免最坏情况,如已排序数组)
    int randi = left + (rand() % (right - left));
    Swap(&a[left], &a[randi]);
    
    // 5.2 三路划分(荷兰国旗问题):
    //   - [begin, keyi-1]:小于基准值的区域
    //   - [keyi]:基准值
    //   - [keyi+1, end]:大于基准值的区域
    int prev = left;  // 小于区域的右边界
    int cur = prev + 1;  // 当前遍历位置
    int keyi = left;  // 基准值索引
    
    // 5.3 遍历整个子数组
    while (cur <= end)
    {
        // 如果当前元素小于基准值,则交换到小于区域
        if (a[cur] < a[keyi] && ++prev != cur)
        {
            Swap(&a[prev], &a[cur]);
        }
        ++cur;
    }
    
    // 5.4 将基准值放到正确位置(小于区域和大于区域之间)
    Swap(&a[prev], &a[keyi]);
    keyi = prev;  // 更新基准值位置
    
    // 6. 递归排序左右两部分
    IntroSort(a, begin, keyi - 1, depth, defaultDepth);
    IntroSort(a, keyi + 1, end, depth, defaultDepth);
}

/**
 * 快速排序入口函数
 * @param a 数组
 * @param left 左边界
 * @param right 右边界
 */
void QuickSort(int* a, int left, int right)
{
    // 1. 计算最大递归深度阈值(2*log2(n))
    int depth = 0;
    int N = right - left + 1;
    for (int i = 1; i < N; i *= 2)
    {
        depth++;
    }
    
    // 2. 调用内省排序(初始深度为0,阈值为2*log2(n))
    IntroSort(a, left, right, 0, depth * 2);
}

/**
 * 排序数组的主函数
 * @param nums 待排序数组
 * @param numsSize 数组大小
 * @param returnSize 返回数组大小
 * @return 排序后的数组
 */
int* sortArray(int* nums, int numsSize, int* returnSize)
{
    srand(time(0));  // 初始化随机数种子(避免每次排序结果相同)
    QuickSort(nums, 0, numsSize - 1);
    *returnSize = numsSize;
    return nums;
}

/**
 * 测试函数
 */
int main()
{
    int arr[] = {5, 2, 9, 1, 5, 6, 3, 8, 4, 7};
    int n = sizeof(arr) / sizeof(arr[0]);
    int returnSize;
    
    int* sorted = sortArray(arr, n, &returnSize);
    
    printf("Sorted array: ");
    for (int i = 0; i < n; i++)
        printf("%d ", sorted[i]);
    printf("\n");
    
    return 0;
}

ai版本

cpp 复制代码
#include <algorithm>
#include <vector>
#include <iostream>
#include <cmath>

// 插入排序,用于小数组
void insertion_sort(int* begin, int* end) {
    for (int* i = begin + 1; i < end; ++i) {
        int key = *i;
        int* j = i - 1;
        while (j >= begin && *j > key) {
            *(j + 1) = *j;
            --j;
        }
        *(j + 1) = key;
    }
}

// 堆排序
void heap_sort(int* begin, int* end) {
    int n = end - begin;
    
    // 建堆
    for (int i = n / 2 - 1; i >= 0; --i) {
        int parent = i;
        while (true) {
            int left = 2 * parent + 1;
            int right = 2 * parent + 2;
            int largest = parent;
            
            if (left < n && *(begin + left) > *(begin + largest))
                largest = left;
            if (right < n && *(begin + right) > *(begin + largest))
                largest = right;
                
            if (largest == parent)
                break;
                
            std::swap(*(begin + parent), *(begin + largest));
            parent = largest;
        }
    }
    
    // 排序
    for (int i = n - 1; i > 0; --i) {
        std::swap(*(begin), *(begin + i));
        int parent = 0;
        while (true) {
            int left = 2 * parent + 1;
            int right = 2 * parent + 2;
            int largest = parent;
            
            if (left < i && *(begin + left) > *(begin + largest))
                largest = left;
            if (right < i && *(begin + right) > *(begin + largest))
                largest = right;
                
            if (largest == parent)
                break;
                
            std::swap(*(begin + parent), *(begin + largest));
            parent = largest;
        }
    }
}

// 内省排序
void introsort(int* begin, int* end, int depth = 0) {
    int n = end - begin;
    
    // 小数组使用插入排序
    if (n <= 16) {
        insertion_sort(begin, end);
        return;
    }
    
    // 深度超过阈值,切换到堆排序
    if (depth > 2 * std::log2(n)) {
        heap_sort(begin, end);
        return;
    }
    
    // 三数取中法选择基准
    int mid = begin + (n / 2);
    if (*(begin) > *(mid)) std::swap(*begin, *mid);
    if (*(begin) > *(end - 1)) std::swap(*begin, *(end - 1));
    if (*(mid) > *(end - 1)) std::swap(*mid, *(end - 1));
    
    // 快速排序分区
    int pivot = *(begin + n / 2);
    int* left = begin;
    int* right = end - 1;
    
    while (left <= right) {
        while (*left < pivot) left++;
        while (*right > pivot) right--;
        if (left <= right) {
            std::swap(*left, *right);
            left++;
            right--;
        }
    }
    
    // 递归排序
    introsort(begin, left, depth + 1);
    introsort(left, end, depth + 1);
}

// 使用示例
int main() {
    std::vector<int> data = {42, 5, 17, 23, 99, 3, 8, 1, 57, 61};
    
    std::cout << "Before sort: ";
    for (int x : data) std::cout << x << " ";
    std::cout << std::endl;
    
    introsort(data.data(), data.data() + data.size());
    
    std::cout << "After sort: ";
    for (int x : data) std::cout << x << " ";
    std::cout << std::endl;
    
    return 0;
}
  • 内省排序的优势

    • 平均情况性能:接近快速排序的O(n log n)
    • 最坏情况保证:O(n log n),不会退化为O(n²)
    • 小数组优化:使用插入排序处理小数组
    • 深度控制:通过递归深度监控防止栈溢出
  • 实际应用

    C++标准库中的std::sort正是使用了内省排序。在SGI STL中,内省排序的深度阈值设置为2*log₂(n),这确保了在最坏情况下也能保持O(n log n)的性能。

八、选择排序(Selection Sort)

8.1 选择排序的基本原理

选择排序(Selection Sort)是一种简单直观的排序算法,其核心思想是:每次从未排序序列中找出最小(或最大)元素,将其放到已排序序列的末尾(或起始位置),重复该过程直到整个序列有序。

具体步骤:

  1. 初始状态:整个数组被视为未排序部分
  2. 第一轮:在未排序部分(整个数组)中找到最小元素,将其与第一个元素交换
  3. 第二轮:在未排序部分(数组的第2个元素到末尾)中找到最小元素,将其与第二个元素交换
  4. 重复上述过程,直到整个数组有序

例如,对数组[64, 25, 12, 22, 11]进行升序排序:

  • 第一轮:找到11(最小值),与64交换 → [11, 25, 12, 22, 64]
  • 第二轮:在[25, 12, 22, 64]中找到12,与25交换 → [11, 12, 25, 22, 64]
  • 第三轮:在[25, 22, 64]中找到22,与25交换 → [11, 12, 22, 25, 64]
  • 第四轮:在[25, 64]中找到25,无需交换 → [11, 12, 22, 25, 64]

8.2 选择排序的C语言实现

AI版本

c 复制代码
void selection_sort(int arr[], int len) {
    int i, j, min, temp;
    // 外层循环控制已排序部分的边界
    for (i = 0; i < len - 1; i++) {
        // 假设当前i位置是最小元素的位置
        min = i;
        // 内层循环在未排序部分中寻找真正的最小元素
        for (j = i + 1; j < len; j++) {
            if (arr[j] < arr[min]) {
                min = j; // 更新最小元素的位置
            }
        }
        // 如果找到的最小元素不是当前i位置,则进行交换
        if (min != i) {
            temp = arr[i];
            arr[i] = arr[min];
            arr[min] = temp;
        }
    }
}

优化版本

c 复制代码
// 选择排序(双向选择排序)实现
// 通过每轮同时选择最小值和最大值,分别放到已排序区域的两端
// 时间复杂度:O(N²)(无论最好/最坏情况)
// 空间复杂度:O(1)(原地排序)
void SelectSort(int* a, int n)
{
    // 定义未排序区域的左右边界
    // begin: 未排序区域的起始索引(已排序区域的右边界+1)
    // end: 未排序区域的结束索引(已排序区域的左边界-1)
    int begin = 0, end = n - 1;
    
    // 当未排序区域存在元素时继续排序
    while (begin < end)
    {
        // 初始化最小值和最大值的位置
        // 初始假设第一个元素(begin位置)是最小值,也是最大值
        int mini = begin, maxi = begin;
        
        // 遍历当前未排序区域([begin, end])
        for (size_t i = begin + 1; i <= end; i++)
        {
            // 如果找到更小的元素,更新最小值位置
            if (a[i] < a[mini])
            {
                mini = i;
            }
            
            // 如果找到更大的元素,更新最大值位置
            if (a[i] > a[maxi])
            {
                maxi = i;
            }
        }
        
        // 将最小值放到已排序区域的起始位置(begin位置)
        Swap(&a[begin], &a[mini]);
        
        // 特殊情况处理:如果最大值恰好在begin位置
        // 因为mini已经被交换到begin位置,所以需要将maxi更新为mini
        if (maxi == begin)
        {
            maxi = mini;
        }
        
        // 将最大值放到已排序区域的结束位置(end位置)
        Swap(&a[end], &a[maxi]);
        
        // 更新未排序区域边界
        // 已排序区域向左扩展1位(begin+1)
        // 已排序区域向右扩展1位(end-1)
        ++begin;
        --end;
        
        // 可选:打印当前排序状态(调试用)
        // PrintArray(a, n);
    }
}

8.3 时间复杂度分析

选择排序的时间复杂度为O(n²),无论输入数据的初始状态如何。

比较次数分析

  • 第1轮:需要比较n-1次(从第1个元素到第n个元素)
  • 第2轮:需要比较n-2次(从第2个元素到第n个元素)
  • ...
  • 第n-1轮:需要比较1次

总比较次数 = (n-1) + (n-2) + ... + 1 = n(n-1)/2

计算过程:

n(n-1)/2 = (n² - n)/2 = O(n²)

因此,比较操作的时间复杂度为O(n²)。

交换次数分析

  • 最好情况(数组已有序):0次交换
  • 最坏情况(数组逆序):n-1次交换
  • 平均情况:约n/2次交换

由于交换操作的代价通常大于比较操作,选择排序在数据量较小时比冒泡排序更高效。

8.4 空间复杂度分析

选择排序是一种原地排序算法,它只需要常数级别的额外空间(用于临时交换变量),不需要额外的数组或数据结构来存储排序结果。

在代码中,只使用了几个临时变量(i, j, min, temp),这些变量的存储空间不随输入规模n的变化而变化。

因此,选择排序的空间复杂度为O(1)。

8.5 稳定性分析

选择排序是一种不稳定的排序算法

在选择排序中,当遇到相等的元素时,如果较小的元素(或较大的元素)位于较大的元素后面,那么在交换过程中,它们的相对位置会被打乱。

例如:序列[5, 8, 5, 2, 9]

  • 第一轮选择第1个元素5会和2交换,结果为[2, 8, 5, 5, 9]
  • 原序列中两个5的相对顺序(第一个5在第二个5前面)被破坏了

因此,选择排序是不稳定的排序算法。

8.6 选择排序的特点与适用场景

特点

  1. 时间复杂度固定为O(n²),与输入数据的初始状态无关
  2. 交换次数少(最多n-1次),比冒泡排序更高效
  3. 原地排序,空间复杂度为O(1)
  4. 不稳定排序算法

适用场景

  1. 数据量较小的场景(n < 1000)
  2. 交换操作成本较高的场景(因为交换次数少)
  3. 内存受限的嵌入式系统
  4. 作为教学示例,帮助初学者理解排序算法的基本思想

8.7 选择排序的优化

  1. 减少交换次数

    • 优化思路:先找到最小元素,最后再交换,而不是每次比较都交换
    • 代码中已经实现了这一点,通过先找到最小元素,最后再交换
  2. 双向选择排序

    • 优化思路:同时寻找最大值和最小值,分别放到数组的两端
    • 优化后,每轮可以确定两个元素的位置,减少轮数
  3. 提前终止检查

    • 优化思路:当已排序部分达到一定长度时,可以提前终止
    • 但选择排序的比较次数是固定的,所以这种优化效果有限

8.8 选择排序与其他排序算法的比较

  1. 与冒泡排序比较

    • 选择排序的交换次数比冒泡排序少(冒泡排序平均交换次数为n²/4)
    • 选择排序的比较次数与冒泡排序相同
    • 选择排序在数据量较小时通常比冒泡排序快
  2. 与插入排序比较

    • 插入排序在数据部分有序时效率更高(时间复杂度为O(n))
    • 选择排序的比较次数与数据初始状态无关
  3. 与快速排序、归并排序比较

    • 选择排序的时间复杂度为O(n²),而快速排序、归并排序为O(nlogn)
    • 选择排序的空间复杂度为O(1),快速排序为O(logn),归并排序为O(n)

选择排序虽然在大数组排序上效率较低,但因其简单性和低空间复杂度,在特定场景下仍有应用价值。


九、 堆排序(Heap Sort)

9.1 堆排序的基本原理

堆排序(Heap Sort)是一种基于堆(Heap)数据结构的高效排序算法。堆是一种特殊的完全二叉树,满足以下特性:

  • 大顶堆:每个节点的值都大于或等于其子节点的值
  • 小顶堆:每个节点的值都小于或等于其子节点的值

在堆排序中,我们通常使用 大顶堆 实现升序排序, 小顶堆 实现降序。堆排序的核心思想是:

  1. 将待排序数组构建成一个大顶堆(堆顶元素为最大值)
  2. 交换堆顶元素与堆末尾元素(将最大值放到正确位置)
  3. 将剩余元素重新调整为大顶堆
  4. 重复步骤2和3,直到整个数组有序

9.2 堆排序的C语言实现

理解版本

c 复制代码
// =============== 4.4 堆排序 ===============
// 基本思想:利用堆的性质进行排序(大根堆)
// 1. 建堆:从最后一个非叶子节点开始调整(O(n))
// 2. 交换堆顶与末尾元素(最大值归位)
// 3. 缩小堆范围,重新调整堆(O(log n))
// 4. 重复步骤2-3直到堆大小为1
// 时间复杂度:O(n *log n)(建堆O(n),调整O(n log n))
// 空间复杂度:O(1)
// 交换两个元素
void Swap(int* p1, int* p2) {
    int tmp = *p1;
    *p1 = *p2;
    *p2 = tmp;
}

// 调整堆,使以parent为根的子树满足大顶堆性质
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[child] > a[parent])
        {
            Swap(&a[child], &a[parent]);
            parent = child;       // 继续向下调整
            child = parent * 2 + 1; // 更新孩子索引
        }
        else
        {
            break;  // 已满足堆性质,退出
        }
    }
}

// 堆排序主函数
void HeapSort(int* a, int n)
{
    // 1. 建堆:从最后一个非叶子节点开始((n-1)/2)
    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个元素的堆
        end--;                 // 缩小堆范围
    }
}

ai版本

c 复制代码
// 交换两个元素
void Swap(int* p1, int* p2) {
    int tmp = *p1;
    *p1 = *p2;
    *p2 = tmp;
}

// 调整堆,使以parent为根的子树满足大顶堆性质
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]) {
            break;
        }
        
        // 交换父节点和较大的子节点
        Swap(&a[parent], &a[child]);
        parent = child;  // 移动到子节点位置
        child = parent * 2 + 1;  // 更新子节点
    }
}

// 堆排序主函数
void HeapSort(int* a, int n) {
    // 1. 构建大顶堆:从最后一个非叶子节点开始向上调整
    for (int i = (n - 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--;  // 堆大小减1
    }
}

9.3 时间复杂度分析

堆排序的时间复杂度为O(n *log n),这是由两个主要部分构成的:

9.3.1 构建初始大顶堆(O(n))

构建大顶堆的过程是从最后一个非叶子节点开始,向上调整每个子树。

  • 为什么是O(n)?

    • 从数学角度,构建大顶堆的时间复杂度可以通过以下方式计算:

      T(n) = Σ (i=1 to h) [2^(h-i) * i]

    其中h是树的高度(h = log₂n)

    • 通过等比数列求和,可以证明T(n) = O(n)
  • 直观理解

    • 树的底层节点(叶子节点)不需要调整,因为它们没有子节点
    • 从下往上,每层的调整次数是递减的
    • 底层节点(约n/2个)不需要调整
    • 次底层节点(约n/4个)最多调整1次
    • 依此类推,总调整次数约为n
9.3.2 交换与调整堆(O(n log n))
  • 每次交换堆顶元素与堆末尾元素后,需要调整堆
  • 一共需要进行n-1次这样的操作
  • 每次调整堆的时间复杂度为O(log n)(树的高度)
  • 因此,这部分的总时间复杂度为O(n log n)
9.3.3 总时间复杂度
复制代码
总时间复杂度 = 构建堆的时间 + 交换与调整堆的时间
             = O(n) + O(n log n)
             = O(n log n)

关键点

  • 无论输入数据的初始状态如何,堆排序的时间复杂度都是O(n log n)
  • 这是堆排序与快速排序、归并排序等算法相比的重要优势

9.4 空间复杂度分析

堆排序的空间复杂度为O(1),是原地排序算法

  • 原因

    • 堆排序只使用了常数级别的额外空间(用于交换操作的临时变量)
    • 不需要额外的数组或数据结构来存储排序结果
    • 所有操作都在原数组上进行
  • 对比

    • 冒泡排序、选择排序、插入排序也是O(1)空间复杂度
    • 归并排序需要O(n)额外空间
    • 快速排序最坏情况下需要O(n)额外空间(递归栈)

9.5 堆排序的稳定性分析

堆排序是一种不稳定的排序算法:

  • 为什么不稳定
    • 在调整堆的过程中,相等的元素可能会被交换
    • 例如,数组[5, 5, 3, 2, 1],第一个5和第二个5的相对位置在排序后可能改变

9.6 堆排序的优缺点

优点

  1. 时间复杂度稳定:最坏、最好、平均时间复杂度都是O(n log n)
  2. 空间复杂度低:O(1),原地排序
  3. 适合大规模数据排序:尤其在内存受限的环境中表现良好

缺点

  1. 不稳定性:相同元素的相对位置可能改变
  2. 实际性能:对于小规模数据或接近有序的数据,性能可能不如插入排序
  3. 实现复杂度:相比冒泡排序、插入排序等,实现稍复杂

9.7 堆排序与其他排序算法的对比

排序算法 时间复杂度 空间复杂度 稳定性 适用场景
堆排序 O(n log n) O(1) ❌ 不稳定 大规模数据排序,内存受限
快速排序 O(n log n)(最坏O(n²)) O(log n) ❌ 不稳定 通用排序,平均性能最好
归并排序 O(n log n) O(n) ✅ 稳定 链表排序,需要稳定排序
插入排序 O(n²)(最好O(n)) O(1) ✅ 稳定 小规模数据或接近有序数据
冒泡排序 O(n²) O(1) ✅ 稳定 教学演示,小规模数据

9.8 堆排序的应用场景

  1. 内存受限环境:如嵌入式系统,需要低空间复杂度的排序
  2. 求前K大/小元素:使用堆结构可以高效解决TOP-K问题
  3. 系统底层排序:Java的PriorityQueue、Python的heapq等都基于堆结构
  4. 大数据排序:当数据量较大时,堆排序的稳定性能表现优异

9.9 堆排序的优化点

  1. 建堆优化:使用向下调整法(从最后一个非叶子节点开始向上调整),时间复杂度O(n)比向上调整法(O(n log n))更优
  2. 减少交换次数:在交换堆顶与末尾元素后,直接调整堆,避免不必要的操作
  3. 处理重复元素:在调整堆时,可以优化对相等元素的处理

9.10 堆排序的图解示例

以数组[5, 3, 8, 4, 2, 7]为例:

  1. 构建大顶堆

    复制代码
         8
        / \
       5   7
      / \ /
     4  2 3
  2. 第一次交换:将8与3交换

    复制代码
         3
        / \
       5   7
      / \ /
     4  2 8

    调整剩余部分(前5个元素):

    复制代码
         7
        / \
       5   3
      / \
     4  2
  3. 第二次交换:将7与2交换

    复制代码
         2
        / \
       5   3
      / \
     4  7

    调整剩余部分(前4个元素):

    复制代码
         5
        / \
       4   3
      /
     2
  4. 继续交换,最终得到有序数组[2, 3, 4, 5, 7, 8]

9.11 堆排序的常见误区

  1. "堆排序是稳定的":错误!堆排序是不稳定的,这是由堆的调整过程决定的
  2. "构建堆的时间复杂度是O(n log n)":错误!构建堆的时间复杂度是O(n),不是O(n log n)
  3. "升序排序应该建小顶堆":错误!升序排序应该建大顶堆,降序排序才建小顶堆
  4. "堆排序比快速排序快":错误!在平均情况下,快速排序通常比堆排序快,但堆排序的最坏情况性能更好

9.12 总结

堆排序是一种基于堆数据结构的高效排序算法,具有以下特点:

  • 时间复杂度稳定为O(n log n)
  • 空间复杂度为O(1),是原地排序算法
  • 不稳定排序算法
  • 适合大规模数据排序,尤其在内存受限的环境中

虽然堆排序在实际应用中可能不如快速排序常用,但它是理解优先队列、TOP-K问题等高级算法的基础,也是排序算法中一个重要的知识点。


十、归并排序(Merge Sort)

10.1 归并排序的基本原理

归并排序(Merge Sort)是一种 分治法(Divide and Conquer) 的排序算法,其核心思想是将数组分成两个子数组,分别排序,然后将排序后的子数组合并成一个有序数组。

分治思想

  1. 分解:将数组分成大致相等的两半
  2. 解决:递归地对两个子数组进行排序
  3. 合并:将两个已排序的子数组合并成一个有序数组

关键特点

  • 递归地将数组分成更小的部分
  • 通过合并操作将小部分有序数组组合成更大的有序数组
  • 时间复杂度稳定,不受输入数据初始状态影响

10.2 归并排序的C语言实现

理解版本

c 复制代码
// 归并排序递归辅助函数
// 该函数负责将数组a的[begin, end]区间排序
// tmp: 用于合并操作的临时数组(全局或传入的临时空间)
void _MergeSort(int* a, int begin, int end, int* tmp)
{
    // 基本情况:区间只有一个元素,无需排序
    if (begin == end)
        return;

    // 计算中间位置
    int mid = (begin + end) / 2;
    // [begin, mid] [mid+1, end]
    // 递归排序左半部分 [begin, mid]
    _MergeSort(a, begin, mid, tmp);
    // 递归排序右半部分 [mid+1, end]
    _MergeSort(a, mid + 1, end, tmp);

    // 合并两个有序子数组
    int begin1 = begin, end1 = mid;      // 左子数组边界
    int begin2 = mid + 1, end2 = end;    // 右子数组边界
    int i = begin;                       // 临时数组的起始位置

    // 比较左右子数组的元素,将较小的放入临时数组
    while (begin1 <= end1 && begin2 <= end2)
    {
        if (a[begin1] <= a[begin2])      // 稳定排序:相等时优先取左子数组的元素
        {
            tmp[i++] = a[begin1++];
        }
        else
        {
            tmp[i++] = a[begin2++];
        }
    }

    // 处理左子数组剩余元素
    while (begin1 <= end1)
    {
        tmp[i++] = a[begin1++];
    }

    // 处理右子数组剩余元素
    while (begin2 <= end2)
    {
        tmp[i++] = a[begin2++];
    }

    // 将临时数组中的排序结果复制回原数组的[begin, end]区间
    memcpy(a + begin, tmp + begin, sizeof(int) * (end - begin + 1));
}

// 归并排序主函数
// a: 待排序数组,n: 数组长度
void MergeSort(int* a, int n)
{
    // 申请临时数组(大小为n)
    int* tmp = (int*)malloc(sizeof(int) * n);
    if (tmp == NULL)
    {
        perror("malloc fail");
        return;
    }

    // 调用递归辅助函数排序整个数组[0, n-1]
    _MergeSort(a, 0, n - 1, tmp);

    // 释放临时数组
    free(tmp);
    tmp = NULL;
}

ai版本

c 复制代码
// 合并两个有序子数组
void Merge(int* a, int left, int mid, int right) {
    // 创建临时数组存储合并结果
    int* temp = (int*)malloc((right - left + 1) * sizeof(int));
    int i = left;      // 左子数组起始索引
    int j = mid + 1;   // 右子数组起始索引
    int k = 0;         // 临时数组索引

    // 合并两个子数组
    while (i <= mid && j <= right) {
        if (a[i] <= a[j]) {
            temp[k++] = a[i++];
        } else {
            temp[k++] = a[j++];
        }
    }

    // 处理左子数组剩余元素
    while (i <= mid) {
        temp[k++] = a[i++];
    }

    // 处理右子数组剩余元素
    while (j <= right) {
        temp[k++] = a[j++];
    }

    // 将临时数组复制回原数组
    for (i = 0; i < k; i++) {
        a[left + i] = temp[i];
    }

    free(temp); // 释放临时数组
}

// 归并排序主函数
void MergeSort(int* a, int left, int right) {
    if (left < right) {
        int mid = left + (right - left) / 2; // 中间位置
        MergeSort(a, left, mid);             // 递归排序左半部分
        MergeSort(a, mid + 1, right);         // 递归排序右半部分
        Merge(a, left, mid, right);           // 合并两个有序部分
    }
}

10.3 时间复杂度分析

归并排序的时间复杂度为O(n *log n),无论输入数据的初始状态如何。

10.3.1 递归关系分析

归并排序的递归关系为:

复制代码
T(n) = 2T(n/2) + O(n)
  • 2T(n/2):表示将问题分解为两个规模为n/2的子问题
  • O(n):表示合并两个有序子数组所需的时间
10.3.2 递归树分析

通过递归树可以直观理解归并排序的时间复杂度:

  1. 树的高度:log₂n(因为每次将数组分成两半)
  2. 每层的工作量:O(n)(合并所有子数组需要O(n)时间)
  3. 总工作量:O(n) × log₂n = O(n log n)

示例

  • n=8时:
    • 第1层:1次合并,8个元素 → O(8)
    • 第2层:2次合并,每次4个元素 → 2×O(4)=O(8)
    • 第3层:4次合并,每次2个元素 → 4×O(2)=O(8)
    • 第4层:8次合并,每次1个元素 → 8×O(1)=O(8)
    • 总时间:O(8) × 3(层数)= O(24) = O(8 log 8)
10.3.3 为什么是O(n *log n)?
  • 最坏情况:数组完全逆序 → 仍然需要O(n log n)时间
  • 最好情况:数组已有序 → 仍然需要O(n log n)时间
  • 平均情况:O(n log n)

关键点 :归并排序的性能不依赖于输入数据的初始状态,这是它与快速排序等算法的重要区别。

10.4 空间复杂度分析

归并排序的空间复杂度为O(n),因为需要额外的临时数组来存储合并结果。

10.4.1 空间使用分析
  1. 临时数组:每次合并操作需要O(n)的额外空间(临时数组大小等于当前合并的数组长度)
  2. 递归栈:递归深度为log₂n,但递归栈空间为O(log n),在总空间复杂度中可以忽略
  3. 总空间:O(n)(主要由临时数组决定)

为什么不是O(1)

  • 归并排序需要额外的O(n)空间来存储合并结果
  • 无法在原数组上完成合并操作(需要临时存储)
10.4.2 与原地排序算法对比
排序算法 空间复杂度 是否原地排序
归并排序 O(n) ❌ 否
快速排序 O(log n) ✅ 是(平均)
堆排序 O(1) ✅ 是
插入排序 O(1) ✅ 是
选择排序 O(1) ✅ 是

10.5 稳定性分析

归并排序是一种稳定的排序算法

为什么稳定

  • 在合并过程中,当左右子数组的当前元素相等时,优先将左子数组的元素放入结果

    c 复制代码
    if (a[i] <= a[j]) {  // 使用 <= 而不是 <
        temp[k++] = a[i++];
    }
  • 这确保了相等元素的相对顺序在排序后保持不变

示例

  • 输入数组:[5, 3, 5, 2]
  • 排序后:[2, 3, 5, 5](两个5的相对顺序与输入相同)

10.6 归并排序的优缺点

优点

  1. 时间复杂度稳定:最坏、最好、平均时间复杂度都是O(n log n)
  2. 稳定性:是稳定的排序算法
  3. 适合大规模数据:尤其适合外部排序(数据量大,内存不足时)
  4. 并行性好:分治策略使其易于并行实现

缺点

  1. 空间复杂度高:需要O(n)的额外空间
  2. 常数因子较大:相比快速排序,实际运行时间可能稍慢
  3. 递归开销:递归调用有额外的栈空间开销

10.7 归并排序与其他排序算法的对比

排序算法 时间复杂度 空间复杂度 稳定性 适用场景
归并排序 O(n log n) O(n) ✅ 稳定 大规模数据,需要稳定排序
快速排序 O(n log n)(最坏O(n²)) O(log n) ❌ 不稳定 通用排序,平均性能最好
堆排序 O(n log n) O(1) ❌ 不稳定 大规模数据,内存受限
插入排序 O(n²)(最好O(n)) O(1) ✅ 稳定 小规模数据或接近有序数据
选择排序 O(n²) O(1) ❌ 不稳定 教学示例,小规模数据

10.8 归并排序的优化

10.8.1 减少临时数组分配
c 复制代码
// 全局临时数组
int* temp_arr = NULL;

void Merge(int* a, int left, int mid, int right) {
    if (temp_arr == NULL) {
        temp_arr = (int*)malloc((right - left + 1) * sizeof(int));
    }
    
    // 合并逻辑...
}
  • 避免每次合并都分配新内存
  • 在排序开始前分配一次足够大的临时数组
10.8.2 小规模数组使用插入排序
c 复制代码
void MergeSort(int* a, int left, int right) {
    // 当数组规模较小时,使用插入排序
    if (right - left < 10) {
        InsertSort(a + left, right - left + 1);
        return;
    }
    
    // 递归排序...
}
  • 当子数组长度小于阈值(如10)时,改用插入排序
  • 插入排序在小规模数据上通常比归并排序快
10.8.3 迭代版本(非递归)

理解版本

c 复制代码
// 归并排序非递归(迭代)实现
// a: 待排序数组,n: 数组长度
void MergeSortNonR(int* a, int n)
{
    // 申请临时数组(大小为n)
    int* tmp = (int*)malloc(sizeof(int) * n);
    if (tmp == NULL)
    {
        perror("malloc fail");
        return;
    }

    // gap表示当前子数组的大小(初始为1,每次翻倍)
    int gap = 1;

    // 迭代直到gap >= n(整个数组已排序)
    while (gap < n)
    {
        // 从数组开头开始,以gap为步长遍历
        for (int j = 0; j < n; j += 2 * gap)//j += 2 * gap是因为每次要合并两个相邻的子数组,每个子数组长度为 gap,
        //合并后形成长度为 2 * gap 的有序数组。
        {
            // 定义当前要合并的两个子数组的边界
            int begin1 = j;
            int end1 = begin1 + gap - 1;          // 左子数组结束位置
            int begin2 = begin1 + gap;             // 右子数组开始位置
            int end2 = begin2 + gap - 1;           // 右子数组结束位置

            // 处理边界情况:右子数组可能不存在或越界
            if (end1 >= n || begin2 >= n) 
                break;  // 跳过越界的子数组

            if (end2 >= n) 
                end2 = n - 1;  // 调整右子数组结束位置

            // 临时变量用于合并
            int i = j;
            int left = begin1, right = begin2;

            // 比较左右子数组的元素,将较小的放入临时数组
            while (left <= end1 && right <= end2)
            {
                if (a[left] <= a[right])  // 稳定排序:相等时优先取左子数组
                {
                    tmp[i++] = a[left++];
                }
                else
                {
                    tmp[i++] = a[right++];
                }
            }

            // 处理左子数组剩余元素
            while (left <= end1)
            {
                tmp[i++] = a[left++];
            }

            // 处理右子数组剩余元素
            while (right <= end2)
            {
                tmp[i++] = a[right++];
            }

            // 将临时数组中的排序结果复制回原数组的当前区间
            memcpy(a + j, tmp + j, sizeof(int) * (end2 - j + 1));
        }

        // 增大子数组大小(每次翻倍)
        gap *= 2;
    }

    // 释放临时数组
    free(tmp);
    tmp = NULL;
}

ai版本

c 复制代码
void MergeSortIterative(int* a, int n) {
    int* temp = (int*)malloc(n * sizeof(int));
    
    for (int width = 1; width < n; width *= 2) {
        for (int i = 0; i < n; i += 2 * width) {
            int left = i;
            int mid = i + width - 1;
            int right = i + 2 * width - 1;
            
            if (mid >= n) mid = n - 1;
            if (right >= n) right = n - 1;
            
            Merge(a, left, mid, right, temp);
        }
    }
    free(temp);
}
  • 使用循环代替递归,避免递归栈开销
  • 从底向上,逐步合并小区间

10.9 归并排序的图解示例

以数组[38, 27, 43, 3, 9, 82, 10]为例:

  1. 分解

    复制代码
    [38, 27, 43, 3, 9, 82, 10]
    → [38, 27, 43, 3] 和 [9, 82, 10]
    → [38, 27] 和 [43, 3] 和 [9, 82] 和 [10]
    → [38], [27], [43], [3], [9], [82], [10]
  2. 合并

    复制代码
    [27, 38] 和 [3, 43] → [3, 27, 38, 43]
    [9, 82] 和 [10] → [9, 10, 82]
    [3, 27, 38, 43] 和 [9, 10, 82] → [3, 9, 10, 27, 38, 43, 82]

10.10 归并排序的常见误区

  1. "归并排序是原地排序":错误!归并排序需要O(n)的额外空间。
  2. "归并排序比快速排序慢":在平均情况下,快速排序通常比归并排序快,但归并排序的最坏情况性能更好。
  3. "归并排序只能用于数组":归并排序可以用于链表,而且链表实现不需要额外的O(n)空间(因为链表的合并操作不需要临时数组)。
  4. "归并排序的合并操作是O(n²)":错误!合并操作是O(n),不是O(n²)。

10.11 文件的归并排序

  • 什么是文件归并排序

    文件归并排序(External Merge Sort)是一种用于处理超出内存容量的大型数据集的排序算法。它是一种外部排序算法,利用了归并排序的分治思想,通过将大文件分割成多个小文件进行排序,再逐步合并这些有序小文件,最终得到完整的有序大文件。

  • 为什么需要文件归并排序

    在日常编程中,我们经常需要对数据进行排序,但当数据量超过内存容量时,普通的内存排序算法(如快速排序、归并排序等)就无法直接使用。例如,当需要排序一个10GB的文件,而计算机内存只有4GB时,就无法一次性将所有数据加载到内存中进行排序。文件归并排序正是为了解决这类问题而设计的。

  • 文件归并排序的原理-文件归并排序的核心思想是"分而治之",具体包括三个阶段:

    1. 分割阶段:将大型文件分割成多个能被内存容纳的小文件
    2. 排序阶段:用内存排序算法(如快速排序)对每个小文件排序
    3. 合并阶段:将这些有序小文件逐步合并为一个更大的有序文件
10.11.1 文件归并排序的详细步骤
  1. 分割文件

    • 设定缓冲区大小:例如设定为1MB
    • 读取数据:每次从原文件读取缓冲区大小的数据
    • 内存排序:对读取的缓冲区数据进行内存排序(如使用qsort)
    • 写入临时文件:将排序后的数据写入临时小文件
    • 重复操作:继续从原文件读取数据,直到所有数据处理完毕
  2. 合并文件

    • 两两归并:将两个有序小文件合并为一个更大的有序文件
    • 处理奇数文件:如果小文件数量为奇数,最后一个文件直接进入下一轮合并
    • 重复合并:重复合并过程,直到最终只剩一个文件
  3. 清理

    • 删除临时文件:合并完成后删除不再需要的临时文件
    • 保留最终结果:最终的有序文件即为排序结果
10.11.2 文件归并排序的实现流程
  1. 从原始文件读取n个数据,排序后写入file1
  2. 从原始文件读取n个数据,排序后写入file2
  3. 利用归并排序的思想,将file1和file2合并为mfile
  4. 删除file1和file2,将mfile重命名为file1
  5. 从原始文件读取n个数据,排序后写入file2
  6. 重复步骤3-5,直到原始文件无法读取数据
  7. 最终归并出的有序数据在file1中
10.11.3 代码实现原理

理解版本

c 复制代码
#include<stdio.h>
#include<time.h>
#include<stdlib.h>

/**
 * 生成1000万个随机数并写入文件
 * 用于模拟大规模数据排序的测试数据
 */
void CreateNDate()
{
    // 1. 定义数据量:1000万
    int n = 10000000;
    
    // 2. 初始化随机数种子(确保每次生成不同数据)
    srand(time(0));
    
    // 3. 打开文件(覆盖模式,若文件存在则清空)
    const char* file = "data.txt";
    FILE* fin = fopen(file, "w");
    if (fin == NULL)
    {
        perror("fopen error");  // 错误处理:文件打开失败
        return;
    }

    // 4. 生成随机数并写入文件
    // 注意:使用rand() + i确保数据不重复且分布均匀
    for (int i = 0; i < n; ++i)
    {
        int x = rand() + i;  // 生成随机数(避免重复)
        fprintf(fin, "%d\n", x);  // 写入文件(每行一个数字)
    }

    // 5. 关闭文件
    fclose(fin);
}

/**
 * 比较函数,用于qsort排序
 * @param a 指向第一个整数的指针
 * @param b 指向第二个整数的指针
 * @return 负数表示a<b,0表示a==b,正数表示a>b
 */
int compare(const void* a, const void* b)
{
    return (*(int*)a - *(int*)b);
}

/**
 * 从文件读取n个数据,排序后写入新文件
 * @param fout 文件指针(指向输入文件)
 * @param n 要读取的数据量
 * @param file1 输出文件名
 * @return 实际读取并排序的数据个数
 */
int ReadNDataSortToFile(FILE* fout, int n, const char* file1)
{
    // 1. 分配内存存储n个整数
    int x = 0;
    int* a = (int*)malloc(sizeof(int) * n);
    if (a == NULL)
    {
        perror("malloc error");  // 错误处理:内存分配失败
        return 0;
    }

    // 2. 从文件读取最多n个数据
    int j = 0;  // 实际读取的元素个数
    for (int i = 0; i < n; i++)
    {
        // 读取一个整数,若到达文件末尾则退出
        if (fscanf(fout, "%d", &x) == EOF)
            break;
        a[j++] = x;  // 存储到数组
    }

    // 3. 检查是否读取到数据
    if (j == 0)
    {
        free(a);  // 无数据,释放内存
        return 0;
    }

    // 4. 使用qsort对读取的数据排序
    qsort(a, j, sizeof(int), compare);

    // 5. 打开输出文件(覆盖模式)
    FILE* fin = fopen(file1, "w");
    if (fin == NULL)
    {
        free(a);  // 释放内存
        perror("fopen error");  // 错误处理
        return 0;
    }

    // 6. 将排序后的数据写入新文件
    for (int i = 0; i < j; i++)
    {
        fprintf(fin, "%d\n", a[i]);
    }

    // 7. 释放内存并关闭文件
    free(a);
    fclose(fin);

    return j;  // 返回实际排序的数据个数
}

/**
 * 合并两个已排序的文件
 * @param file1 第一个已排序文件
 * @param file2 第二个已排序文件
 * @param mfile 合并后的输出文件
 */
void MergeFile(const char* file1, const char* file2, const char* mfile)
{
    // 1. 打开两个输入文件
    FILE* fout1 = fopen(file1, "r");
    if (fout1 == NULL)
    {
        perror("fopen error");  // 错误处理
        return;
    }

    FILE* fout2 = fopen(file2, "r");
    if (fout2 == NULL)
    {
        perror("fopen error");  // 错误处理
        return;
    }

    // 2. 打开输出文件
    FILE* mfin = fopen(mfile, "w");
    if (mfin == NULL)
    {
        perror("fopen error");  // 错误处理
        return;
    }

    // 3. 归并两个文件(类似归并排序的合并步骤)
    int x1 = 0;  // file1当前读取的值
    int x2 = 0;  // file2当前读取的值
    int ret1 = fscanf(fout1, "%d", &x1);  // 读取第一个值
    int ret2 = fscanf(fout2, "%d", &x2);  // 读取第二个值

    // 4. 比较两个文件的当前值,写入较小的值
    while (ret1 != EOF && ret2 != EOF)
    {
        if (x1 < x2)
        {
            fprintf(mfin, "%d\n", x1);  // 写入较小值
            ret1 = fscanf(fout1, "%d", &x1);  // 读取file1下一个值
        }
        else
        {
            fprintf(mfin, "%d\n", x2);  // 写入较小值
            ret2 = fscanf(fout2, "%d", &x2);  // 读取file2下一个值
        }
    }

    // 5. 处理剩余数据(一个文件可能未读完)
    while (ret1 != EOF)
    {
        fprintf(mfin, "%d\n", x1);
        ret1 = fscanf(fout1, "%d", &x1);
    }

    while (ret2 != EOF)
    {
        fprintf(mfin, "%d\n", x2);
        ret2 = fscanf(fout2, "%d", &x2);
    }

    // 6. 关闭所有文件
    fclose(fout1);
    fclose(fout2);
    fclose(mfin);
}

int main()
{
    // 1. 生成1000万随机数到data.txt
    CreateNDate();

    // 2. 定义文件名(用于分块处理)
    const char* file1 = "file1.txt";
    const char* file2 = "file2.txt";
    const char* mfile = "mfile.txt";

    // 3. 打开原始数据文件(用于分块读取)
    FILE* fout = fopen("data.txt", "r");
    if (fout == NULL)
    {
        perror("fopen error");  // 错误处理
        return 1;
    }
    
    // 4. 每次处理100万个数据(分块大小)
    int m = 1000000;
    
    // 5. 将data.txt分成两个文件(每个文件100万数据)
    int count1 = ReadNDataSortToFile(fout, m, file1);
    int count2 = ReadNDataSortToFile(fout, m, file2);

    // 6. 归并循环:将文件合并成一个有序文件
    while (1)
    {
        // 6.1 合并file1和file2到mfile
        MergeFile(file1, file2, mfile);
        
        // 6.2 删除旧文件(file1和file2)
        remove(file1);
        remove(file2);
        
        // 6.3 将合并结果重命名为file1(作为下一轮输入)
        rename(mfile, file1);
        
        // 6.4 检查是否还有数据可读(如果file2读不到数据,说明合并完成)
        int n = 0;
        if ((n = ReadNDataSortToFile(fout, m, file2)) == 0)
            break;  // 无数据,退出循环
        
        // 6.5 此处可添加进度输出(已注释)
        /*if (n < 100)
        {
            int x = 0;
        }*/
    }

    // 7. 关闭文件并退出
    fclose(fout);
    return 0;
}

ai版本

c 复制代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

// 比较函数用于 qsort
int compare(const void* a, const void* b) {
    return *(int*)a - *(int*)b;
}

// 从文件读取最多 n 个数据,排序后写入新文件
int ReadNDataSortToFile(FILE* fin, int n, const char* output_file) {
    // 1. 分配内存存储 n 个整数
    int* arr = (int*)malloc(sizeof(int) * n);
    if (!arr) {
        perror("Memory allocation failed");
        return 0;
    }

    // 2. 从输入文件读取最多 n 个数据
    int count = 0;
    int x;
    while (count < n && fscanf(fin, "%d", &x) != EOF) {
        arr[count++] = x;
    }

    // 3. 排序
    qsort(arr, count, sizeof(int), compare);

    // 4. 写入排序后数据到新文件
    FILE* fout = fopen(output_file, "w");
    if (!fout) {
        perror("Failed to open output file");
        free(arr);
        return 0;
    }
    for (int i = 0; i < count; i++) {
        fprintf(fout, "%d\n", arr[i]);
    }
    fclose(fout);
    free(arr);

    return count;
}

// 合并两个已排序文件
void MergeFile(const char* file1, const char* file2, const char* merged_file) {
    FILE* f1 = fopen(file1, "r");
    FILE* f2 = fopen(file2, "r");
    FILE* out = fopen(merged_file, "w");

    int x1, x2;
    int ret1 = fscanf(f1, "%d", &x1);
    int ret2 = fscanf(f2, "%d", &x2);

    // 归并两个文件
    while (ret1 != EOF && ret2 != EOF) {
        if (x1 < x2) {
            fprintf(out, "%d\n", x1);
            ret1 = fscanf(f1, "%d", &x1);
        } else {
            fprintf(out, "%d\n", x2);
            ret2 = fscanf(f2, "%d", &x2);
        }
    }

    // 处理剩余数据
    while (ret1 != EOF) {
        fprintf(out, "%d\n", x1);
        ret1 = fscanf(f1, "%d", &x1);
    }
    while (ret2 != EOF) {
        fprintf(out, "%d\n", x2);
        ret2 = fscanf(f2, "%d", &x2);
    }

    fclose(f1);
    fclose(f2);
    fclose(out);
}

// 完整文件归并排序:将输入文件排序后输出到输出文件
void FileMergeSort(const char* input_file, const char* output_file, int n) {
    FILE* fin = fopen(input_file, "r");
    if (!fin) {
        perror("Failed to open input file");
        return;
    }

    // 步骤 1: 将输入文件分割成多个小文件(每个大小为 n)
    char temp_files[100][20]; // 临时文件名列表(最多 100 个)
    int num_files = 0;
    
    while (1) {
        char temp_name[20];
        snprintf(temp_name, sizeof(temp_name), "temp_%d.txt", num_files);
        int items = ReadNDataSortToFile(fin, n, temp_name);
        if (items == 0) break; // 无数据可读
        strcpy(temp_files[num_files++], temp_name);
    }
    fclose(fin);

    // 步骤 2: 若输入为空,创建空输出文件
    if (num_files == 0) {
        FILE* fout = fopen(output_file, "w");
        fclose(fout);
        return;
    }

    // 步骤 3: 多路归并(两两合并直到只剩一个文件)
    while (num_files > 1) {
        int new_files = 0;
        char new_temp_files[100][20]; // 用于存储新合并的文件名
        
        for (int i = 0; i < num_files; i += 2) {
            char merged_name[20];
            snprintf(merged_name, sizeof(merged_name), "temp_merged_%d.txt", new_files);
            
            if (i + 1 < num_files) {
                // 合并两个文件
                MergeFile(temp_files[i], temp_files[i + 1], merged_name);
                strcpy(new_temp_files[new_files++], merged_name);
                // 删除旧文件
                remove(temp_files[i]);
                remove(temp_files[i + 1]);
            } else {
                // 剩余单文件直接保留
                strcpy(new_temp_files[new_files++], temp_files[i]);
                remove(temp_files[i]);
            }
        }
        
        // 更新临时文件列表
        memcpy(temp_files, new_temp_files, new_files * sizeof(char[20]));
        num_files = new_files;
    }

    // 步骤 4: 将最终结果重命名为输出文件
    rename(temp_files[0], output_file);
}

// 使用示例(可选,实际调用时注释掉)
/*
int main() {
    FileMergeSort("input.txt", "output.txt", 100); // 每个临时文件 100 个整数
    return 0;
}
*/
  • 优势

    1. 适用于大规模数据:能处理超出内存容量的大型数据集
    2. 排序结果稳定:保持了归并排序的稳定性
    3. 可并行处理:分割后的文件可以并行排序
    4. 内存效率高:只需处理能放入内存的数据块
  • 适用场景

    1. 大数据处理:如数据库排序大量数据
    2. 外部排序:数据存储在磁盘,无法全部载入内存
    3. 多字段排序:需要稳定排序的场景(如先按成绩排序,再按姓名排序)
    4. 分布式系统:可以利用多台机器并行处理不同文件
  • 与普通归并排序的区别

特性 普通归并排序 文件归并排序
数据存储 内存中 磁盘文件
空间需求 O(n) 临时数组 仅需处理小块数据
I/O操作 大量文件读写
适用场景 小到中等规模数据 大规模数据(GB级甚至TB级)
排序方式 递归/迭代 分块排序+归并
  • 实际应用

    1. 数据库系统:如MySQL、PostgreSQL等在处理大表排序时使用
    2. 大数据处理:Hadoop、Spark等大数据框架中的排序操作
    3. 文件处理:排序大型日志文件、CSV文件等
    4. 科学计算:处理大型科学数据集
  • 优化建议

    1. 增加归并轮次:使用多路归并(如4路、8路)减少合并轮次
    2. 调整缓冲区大小:根据内存大小优化缓冲区大小
    3. 并行处理:将不同小文件的排序任务分配给多线程
    4. 减少I/O:使用内存映射文件减少文件读写操作

10.12 总结

归并排序是一种高效、稳定的排序算法,具有以下特点:

  • 时间复杂度:O(n log n)(最坏、最好、平均情况)
  • 空间复杂度:O(n)(需要额外的临时数组)
  • 稳定性:稳定(相等元素的相对位置保持不变)
  • 适用场景:大规模数据排序、需要稳定排序的场景、外部排序

尽管归并排序需要额外的空间,但其稳定性和时间复杂度的稳定性使其在许多实际应用中非常受欢迎,特别是在需要稳定排序的场景中。在内存充足的情况下,归并排序是处理大规模数据排序的优秀选择。

十一、非比较排序- 计数排序(Counting Sort)

11.1 计数排序的基本原理

计数排序(Counting Sort)是一种非比较型排序算法 ,其核心思想是利用元素的值作为索引,通过统计每个元素出现的次数,直接将元素放置在排序后的位置。

关键特点

  • 适用于整数排序(或可转换为整数的类型)
  • 要求元素值范围有限(即最大值与最小值的差值不大)
  • 稳定排序算法(相等元素的相对位置保持不变)
  • 通过空间换时间实现高效排序

工作流程

  1. 确定范围:找到数组中的最小值和最大值
  2. 统计频率:创建计数数组,统计每个值出现的次数
  3. 位置计算:根据统计结果计算每个值的起始位置
  4. 构建结果:根据计数数组将元素放入正确位置

11.2 计数排序的C语言实现

理解版本

c 复制代码
// 计数排序(Counting Sort)是一种非比较型排序算法,适用于整数排序
// 核心思想:利用元素值作为索引,统计每个值出现的次数,直接将元素放置在排序后的位置
// 适用条件:元素值范围较小(max-min+1 不大),且为整数
// 时间复杂度:O(n + range) (n为数组长度,range为值域大小)
// 空间复杂度:O(range) (需要额外range大小的计数数组)
// 稳定性:稳定排序(相等元素的相对顺序保持不变)

void CountSort(int* a, int n)
{
    // 1. 查找数组中的最小值和最大值(确定值域范围)
    int min = a[0], max = a[0];
    for (int i = 1; i < n; i++)
    {
        // 更新当前最大值
        if (a[i] > max)
            max = a[i];
        // 更新当前最小值
        if (a[i] < min)
            min = a[i];
    }

    // 2. 计算值域范围(最大值-最小值+1)
    int range = max - min + 1;
    
    // 3. 申请计数数组(大小为range,用于存储每个值的出现次数)
    int* count = (int*)malloc(sizeof(int) * range);
    if (count == NULL)
    {
        perror("malloc fail");
        return;
    }

    // 4. 初始化计数数组(所有元素置0)
    memset(count, 0, sizeof(int) * range);

    // 5. 统计每个元素的出现次数
    // 关键:将元素值映射到计数数组的索引(a[i]-min)
    // 例如:min=5,元素值=7 → 索引=2(即count[2]++)
    for (int i = 0; i < n; i++)
    {
        count[a[i] - min]++;
    }

    // 6. 根据计数数组构建排序后的数组
    // j: 结果数组的当前索引
    int j = 0;
    for (int i = 0; i < range; i++)
    {
        // 将值(i+min)重复count[i]次放入结果数组
        // 例如:i=2, min=5 → 值=7,重复count[2]次
        while (count[i]--)
        {
            a[j++] = i + min;
        }
    }

    // 7. 释放计数数组(避免内存泄漏)
    free(count);
    count = NULL;
}

ai版本

c 复制代码
// 时间复杂度:O(N+range)
// 空间复杂度:O(range)
void CountSort(int* a, int n)
{
    // 1. 找到数组中的最小值和最大值
    int min = a[0], max = a[0];
    for (int i = 1; i < n; i++)
    {
        if (a[i] > max)
            max = a[i];  // 更新最大值

        if (a[i] < min)
            min = a[i];  // 更新最小值
    }

    // 2. 计算值的范围(最大值-最小值+1)
    int range = max - min + 1;
    
    // 3. 申请计数数组(大小为range)
    int* count = (int*)malloc(sizeof(int) * range);
    if (count == NULL)
    {
        perror("malloc fail");
        return;
    }

    // 4. 初始化计数数组(所有元素置0)
    memset(count, 0, sizeof(int) * range);

    // 5. 统计每个元素出现的次数
    for (int i = 0; i < n; i++)
    {
        // 将元素值转换为计数数组的索引(a[i] - min)
        count[a[i] - min]++;
    }

    // 6. 根据计数数组构建排序后的数组
    int j = 0;  // 结果数组的索引
    for (int i = 0; i < range; i++)
    {
        // 重复count[i]次,将值(i + min)放入结果数组
        while (count[i]--)
        {
            a[j++] = i + min;  // i + min 是实际的值
        }
    }

    // 7. 释放计数数组
    free(count);
    count = NULL;
}

11.3 时间复杂度分析

计数排序的时间复杂度为O(n + range),其中:

  • n:数组长度
  • range:元素值的范围(max - min + 1)
11.3.1 分步计算
步骤 操作 时间复杂度 说明
1 找到最小值和最大值 O(n) 遍历一次数组
2 统计元素频率 O(n) 遍历一次数组
3 构建排序后数组 O(n + range) 遍历计数数组(range)并填充结果(n)
11.3.2 为什么是 O(n + range)?
  • 最坏情况:当 range 远大于 n 时(例如数组范围很大但元素很少),时间复杂度接近 O(range)
  • 最好情况:当 range 接近 n 时(例如数组元素是连续整数),时间复杂度接近 O(n)
  • 平均情况:O(n + range)

💡 关键点 :计数排序的效率高度依赖于值的范围。当 range 远小于 n 时(例如范围是常数),计数排序可以达到线性时间复杂度 O(n)。

11.3.3 与比较排序的对比
排序算法 时间复杂度 适用场景
快速排序 O(n log n) 通用场景,值范围大
归并排序 O(n log n) 需要稳定排序,值范围大
计数排序 O(n + range) 值范围小的整数排序

11.4 空间复杂度分析

计数排序的空间复杂度为O(range),主要由计数数组占用。

11.4.1 空间使用分解
空间 说明 大小
计数数组 存储每个值的出现次数 range
临时变量 min, max, range, i, j 等 O(1)
总空间 O(range)
11.4.2 为什么不是 O(n)?
  • 计数排序不需要额外的与n成比例的数组(如归并排序的O(n)临时数组)
  • 空间消耗仅与值的范围有关,与输入数组大小n无关

💡 重要对比:当 range << n 时(例如范围是100,n=10000),计数排序的空间效率远高于归并排序(O(n))。

11.4.3 空间复杂度示例
情况 n range 空间复杂度
数组范围小 10000 100 O(100) = O(1)
数组范围大 10000 1000000 O(1000000) = O(range)
有序整数 10000 10000 O(10000) = O(n)

11.5 计数排序的稳定性

计数排序是一种稳定的排序算法
为什么稳定

  • 在构建结果数组时,我们按顺序处理计数数组的索引
  • 对于相等的元素,它们在原始数组中出现的顺序会被保留

示例

c 复制代码
// 输入: [3, 1, 2, 1, 3]
// 统计: count[0]=0, count[1]=2, count[2]=1, count[3]=2
// 构建: 
//   i=1: 放入两个1 → [1, 1]
//   i=2: 放入一个2 → [1, 1, 2]
//   i=3: 放入两个3 → [1, 1, 2, 3, 3]
// 两个1的相对顺序与输入相同

11.6 计数排序的优缺点

优点

  1. 线性时间复杂度:当 range << n 时,时间复杂度为 O(n)
  2. 简单高效:实现代码简洁,实际运行速度快
  3. 稳定排序:保持相等元素的相对顺序
  4. 非比较排序:不依赖比较操作,避免比较排序的下限 O(n log n)

缺点

  1. 值范围限制:要求元素值范围小(range 不能太大)
  2. 仅适用于整数:不能直接用于浮点数或字符串
  3. 空间消耗:当 range 很大时,空间消耗可能很高
  4. 不能处理负数:需要额外处理(但代码中已通过 min 处理)

11.7 计数排序的典型应用场景

  1. 固定范围的整数排序

    • 例如:成绩统计(0-100分)、年龄分布(0-120岁)
    • 示例:CountSort(scores, 10000),其中 scores 是 0-100 的整数
  2. 基数排序的子过程

    • 基数排序常使用计数排序作为辅助排序(对每一位进行计数排序)
  3. 大数据量小范围值

    • 例如:统计100万次考试中每道题的正确人数(0-100分)
  4. 嵌入式系统

    • 内存有限但值范围小的设备中,计数排序是理想选择

11.8 计数排序的常见误区

误区1:计数排序是 O(n) 时间复杂度

纠正:计数排序是 O(n + range)。当 range 很大时(如 range = n²),时间复杂度为 O(n²),比比较排序还差。

误区2:计数排序可以用于任何数据类型

纠正 :计数排序仅适用于整数(或可映射为整数的类型),不能直接用于浮点数、字符串等。

误区3:计数排序比快速排序快

纠正:当 range 很大时(如 range = n²),计数排序比快速排序慢;只有当 range << n 时,计数排序才更优。

误区4:计数排序需要额外 O(n) 空间

纠正:计数排序需要 O(range) 空间,与 n 无关。当 range 很小时,空间复杂度可以是 O(1)。

11.9 计数排序的执行示例

输入数组[4, 2, 2, 8, 3, 3, 1]

步骤1:确定范围

复制代码
min = 1, max = 8 → range = 8 - 1 + 1 = 8

步骤2:统计频率

复制代码
count[0] = 0 (1-1=0)
count[1] = 1 (2-1=1)
count[2] = 2 (3-1=2)
count[3] = 0
count[4] = 1 (5-1=4)
count[5] = 0
count[6] = 0
count[7] = 1 (8-1=7)

步骤3:构建结果数组

复制代码
i=0: count[0]=0 → 跳过
i=1: count[1]=1 → a[0]=1+1=2
i=2: count[2]=2 → a[1]=2+1=3, a[2]=3
i=3: count[3]=0 → 跳过
i=4: count[4]=1 → a[3]=4+1=5
i=5: count[5]=0 → 跳过
i=6: count[6]=0 → 跳过
i=7: count[7]=1 → a[4]=7+1=8

最终结果

复制代码
[1, 2, 2, 3, 3, 4, 8]

11.10 计数排序与其他排序算法的对比

排序算法 时间复杂度 空间复杂度 稳定性 适用场景
计数排序 O(n + range) O(range) ✅ 稳定 值范围小的整数排序
快速排序 O(n log n) O(log n) ❌ 不稳定 通用排序
归并排序 O(n log n) O(n) ✅ 稳定 需要稳定排序
基数排序 O(d*(n + range)) O(n + range) ✅ 稳定 大整数排序
插入排序 O(n²) O(1) ✅ 稳定 小规模数据

11.11 总结

计数排序是一种高效的非比较排序算法,具有以下核心特点:

  1. 时间复杂度:O(n + range)(当 range << n 时,接近 O(n))
  2. 空间复杂度:O(range)(与值的范围相关,与n无关)
  3. 稳定性:稳定排序(相等元素相对位置保持不变)
  4. 适用场景:值范围小的整数排序(如0-100的分数、年龄分布等)

💡 关键建议 :在实际应用中,当确定数据值范围很小时(例如0-1000),计数排序是最佳选择,比比较排序快10-100倍。但当值范围很大时(例如0-10^9),应选择比较排序(如快速排序)。

计数排序的精髓 :用值作为索引 ,通过空间换时间实现线性排序。这是它区别于其他排序算法的核心思想,也是为什么它在特定场景下如此高效。

相关推荐
xlq223222 小时前
15.list(上)
数据结构·c++·list
XH华3 小时前
数据结构第三章:单链表的学习
数据结构
No0d1es3 小时前
电子学会青少年软件编程(C/C++)六级等级考试真题试卷(2025年9月)
c语言·c++·算法·青少年编程·图形化编程·六级
Knox_Lai4 小时前
数据结构与算法学习(0)-常见数据结构和算法
c语言·数据结构·学习·算法
逐步前行4 小时前
C项目--羊了个羊(两关全)--含源码
c语言·开发语言
blammmp5 小时前
算法专题二十:贪心算法
数据结构·算法·贪心算法
小白程序员成长日记5 小时前
2025.11.17 力扣每日一题
数据结构·算法·leetcode
赖small强6 小时前
【Linux C/C++开发】第10周:STL容器 - 理论与实战
linux·c语言·c++·stl容器
q***58196 小时前
在21世纪的我用C语言探寻世界本质——字符函数和字符串函数(2)
c语言·开发语言