C++ 中最快的排序算法

28 Aug 2024 | 阅读 20 分钟

排序是计算机编程中常见的操作,选择正确的排序算法可以显著影响程序的效率。在 C++ 中,有几种排序算法可供选择,每种算法都有其优点和缺点。在这些算法中,快速排序(Quick Sort)作为最快、最高效的排序方法之一脱颖而出。

在本指南中,我们将深入探讨快速排序,研究其在 C++ 中的实现,理解其时间复杂度,并考虑何时以及如何使用它。读完本文,您将清楚地了解为什么快速排序通常被认为是 C++ 中最快的排序算法。

理解快速排序

快速排序是一种高效的、基于比较的排序算法,它采用“分而治之”的策略来对数组或列表中的元素进行排序。它由 Tony Hoare 于 1960 年开发,并以其出色的平均情况性能而闻名。快速排序的基本思想是,从数组中选择一个“枢轴”(pivot)元素,并将其他元素划分为两个子数组:小于枢轴的元素和大于枢轴的元素。然后,此过程会递归地应用于子数组,直到整个数组排序完成。

该算法的效率归功于其能够快速地就地(in place)对小数组进行排序,从而降低了整体时间复杂度。

快速排序的关键特征

在深入研究 C++ 中快速排序的实现之前,让我们先了解一下使其成为排序任务的吸引人选择的关键特征。

  1. 平均时间复杂度:快速排序的平均时间复杂度为 O(n * log(n)),其中“n”代表数组中的元素数量。这使其成为可用的最快的排序算法之一,尤其适用于大型数据集。
  2. 就地排序:快速排序是一种就地排序算法,这意味着它不需要额外的内存来进行排序。它直接在输入数组上操作,这在内存资源有限时可能是有利的。
  3. 分而治之:该算法采用“分而治之”的策略,这意味着它将排序问题分解为更小的子问题。这些子问题独立求解,然后将解决方案合并以获得最终排序的数组。
  4. 随机枢轴选择:为了进一步提高其平均情况性能,快速排序通常采用随机方法来选择枢轴元素。这降低了遇到最坏情况的可能性。
  5. 实际效率:快速排序在实践中通常比其理论时间复杂度所暗示的要快。这是因为它具有良好的缓存行为,并最大程度地减少了数据移动。

在 C++ 中实现快速排序

现在,让我们深入研究 C++ 中快速排序的实现。我们将提供一个简单的示例,说明如何使用快速排序算法对整数数组进行排序。

输出

Sorted array: 5 6 7 11 12 13

解释

在上面的代码中,我们有两个主要函数:partition(分区)和 quickSort(快速排序)。partition 函数接受输入数组,并重新排列元素,使所有小于枢轴的元素位于左侧,所有大于枢轴的元素位于右侧。quickSort 函数递归地应用分区过程来排序子数组,直到整个数组排序完成。

  1. 分区函数 (partition)
    • partition 函数以整数数组 (std::vector<int>& arr)、低索引 (low) 和高索引 (high) 作为参数。
    • 它选择最后一个元素(在此代码中为 arr[high])作为枢轴。
    • 它将索引 i 初始化为 low - 1。
    • 一个 for 循环迭代从索引 low 到 high - 1 的元素。
    • 在循环内部,它检查当前元素 (arr[j]) 是否小于枢轴。如果是,则它会递增 i 并交换索引 i 和 j 处的元素,从而有效地将较小的元素移动到枢轴的左侧,将较大的元素移动到枢轴的右侧。
    • 最后,它将枢轴元素 (arr[high]) 与索引 i + 1 处的元素交换,将枢轴放置在其正确的位置,并返回 i + 1,即枢轴的索引。
  2. 快速排序函数 (quickSort)
    • quickSort 函数是一个递归函数,用于对输入数组进行排序。
    • 它以数组、低索引 (low) 和高索引 (high) 作为参数。
    • 它首先检查 low 是否小于 high。如果满足此条件,则子数组中至少有两个元素,需要进行排序。
    • 在函数内部,它调用 partition 函数来选择枢轴并重新排列元素,使得小于枢轴的元素位于左侧,大于枢轴的元素位于右侧。
    • 然后,它在枢轴的左侧和右侧的子数组上递归调用 quickSort(即 quickSort(arr, low, pivot - 1) 和 quickSort(arr, pivot + 1, high))。
  3. 主函数
    • 在 main 函数中,您创建一个要排序的整数示例数组 (arr)。
    • 您对该数组调用 quickSort 函数,指定要排序整个数组的索引(从 0 到 n - 1,其中 n 是数组的大小)。
    • 最后,您将排序后的数组打印到标准输出。

分析快速排序的时间复杂度

快速排序的时间复杂度是理解其效率的关键因素。以下是时间复杂度的分析:

最佳情况时间复杂度:最佳情况发生在每一步选择的枢轴将数组几乎均等地分成两个部分。在这种情况下,快速排序的时间复杂度为 O(n * log(n))。

平均情况时间复杂度:快速排序的平均时间复杂度也为 O(n * log(n))。这对于大多数实际数据集都是如此。

最坏情况时间复杂度:在最坏情况(即,枢轴始终选择为最小或最大的元素)下,时间复杂度可能降至 O(n^2)。然而,遇到这种情况的概率相对较低,尤其是在使用随机枢轴选择方法时。

实际上,快速排序通常比其他排序算法(即使是具有相同平均时间复杂度的算法)更快。这是因为它具有良好的缓存行为和最少的数据移动,使其成为高效排序大型数据集的首选。

快速排序的优点

快速排序提供了许多优点,使其成为 C++ 和其他编程语言中排序任务的流行选择:

  1. 效率:快速排序的平均时间复杂度 O(n * log(n)) 使其成为最快的排序算法之一,尤其适用于大型数据集。
  2. 就地排序:它不需要额外的内存来进行排序,因此内存效率高。
  3. 分而治之:分而治之的方法简化了问题并优化了排序过程。
  4. 随机枢轴:通过随机选择枢轴,算法最大限度地降低了遇到最坏情况的可能性,从而提高了其性能。
  5. 缓存效率:快速排序表现出良好的缓存行为,减少了内存访问时间并提高了性能。

快速排序的应用

快速排序是一种流行的排序算法,它通过将数组分成两个子数组并递归地对它们进行排序来工作。它以其效率而闻名,并广泛用于需要排序的各种应用程序。以下是快速排序的一些常见应用:

  1. 通用排序:快速排序主要用于对数组或列表中的元素进行排序。它非常高效,平均时间复杂度为 O(n log n)。因此,它通常在编程语言和库中用于对数据集合进行排序。
  2. 数值分析:快速排序可用于数值分析,以解决查找中位数、四分位数或其他统计计算等问题。在这种情况下,它允许对大型数据集进行高效排序。
  3. 数据库系统:快速排序可用于数据库系统,以高效地对大量数据进行排序。排序是数据库管理系统中检索或显示排序结果时的基本操作。
  4. 实现其他算法:快速排序通常用作其他算法中的子例程。例如,它可以作为分而治之算法(如归并排序)或图算法(如用于最小生成树的 Kruskal 算法)的一部分。
  5. 顺序统计:快速排序可用于查找数组中的第 k 个最小(或最大)元素。通过选择一个枢轴元素并对数组进行分区,可以在不排序整个数组的情况下快速确定第 k 个元素。
  6. 文件系统:快速排序可用于文件系统,以根据名称、大小或其他属性对文件和目录进行排序和组织。它有助于改进文件系统中的搜索和检索操作。
  7. 计算生物学:快速排序用于各种生物信息学应用,例如 DNA 序列比对、基因组组装或识别相似序列。排序在这些过程中起着至关重要的作用,而快速排序提供了高效的解决方案。
  8. 枢轴选择:快速排序中选择枢轴元素的概念在数据挖掘、聚类和机器学习算法等各个领域都有应用。选择合适的枢轴会显着影响这些算法的性能。
  9. 语言处理和自然语言处理 (NLP):快速排序可用于涉及对单词、短语或句子进行排序的语言处理和 NLP 任务。它通常用于文本分析、信息检索和机器翻译等应用程序。

何时使用快速排序

快速排序是一种多功能的排序算法,可在各种场景中使用。以下是快速排序特别适合的几种情况:

  1. 通用排序:当您需要对元素列表或数组进行排序时,快速排序通常是首选,因为它具有高效的平均情况性能。
  2. 大型数据集:当处理大型数据集时,快速排序的效率尤为突出,其 O(n * log(n)) 的时间复杂度可确保快速排序。
  3. 就地排序:如果您内存资源有限,并且需要一种就地排序算法,快速排序是一个极好的选择。
  4. 排序用户定义对象:通过定义自定义比较函数,可以使用快速排序对用户定义的对象或结构进行排序。
  5. 实时系统:在实时系统中,可预测的性能至关重要,快速排序的平均情况性能使其成为合适的选择。

注意事项和变体

虽然快速排序在许多情况下是一种出色的排序算法,但仍有一些注意事项需要牢记:

  1. 最坏情况场景:尽管快速排序的最坏情况时间复杂度相对较少见,但在需要可预测性的情况下,您可以考虑其他排序算法,例如归并排序,它能保证恒定的时间复杂度。
  2. 随机枢轴:为了确保良好的平均情况性能,建议使用随机枢轴选择方法。然而,这可能会给您的算法带来一些不确定性,这可能不适用于所有应用程序。
  3. 稳定性:快速排序不是稳定的排序算法,这意味着它不会保持相等元素的相对顺序。如果稳定性是必需的,您应该选择其他排序算法,例如归并排序或 Tim Sort。
  4. 实现细节:快速排序的实现可能因您项目的具体要求而异。您可能需要调整分区策略,选择枢轴选择方法,或为用户定义类型实现自定义比较。

1. 快速排序 vs. 归并排序

快速排序

  • 平均时间复杂度:O(n * log(n))
  • 最佳情况时间复杂度:O(n * log(n))
  • 最坏情况时间复杂度:O(n^2)(少见,但可能)

就地排序:

稳定性:否(可能改变相等元素的相对顺序)

主要优点:快速的平均情况性能、就地排序、缓存效率以及对各种数据类型和结构的适应性。

合并排序

  • 平均时间复杂度:O(n * log(n))
  • 最佳情况时间复杂度:O(n * log(n))
  • 最坏情况时间复杂度:O(n * log(n))

就地排序:

稳定性:是(保持相等元素的相对顺序)

主要优点:稳定的排序、恒定的性能以及对大型数据集的适应性。

比较

总体而言,快速排序在实践中通常比归并排序更快,尤其是在大型数据集上。但是,归并排序提供了更可预测、更一致的性能。

快速排序是一种就地排序算法,这在内存资源有限时很有用,而归并排序需要额外的内存。

归并排序是一种稳定的排序算法,这意味着它能保留相等元素的顺序。快速排序不稳定。

2. 快速排序 vs. 堆排序

快速排序

  • 平均时间复杂度:O(n * log(n))
  • 最佳情况时间复杂度:O(n * log(n))
  • 最坏情况时间复杂度:O(n^2)(少见,但可能)

就地排序:是

稳定性:否

主要优点:快速的平均情况性能、就地排序、缓存效率以及对各种数据类型和结构的适应性。

堆排序

  • 平均时间复杂度:O(n * log(n))
  • 最佳情况时间复杂度:O(n * log(n))
  • 最坏情况时间复杂度:O(n * log(n))

就地排序:是

稳定性:否

主要优点:可预测的性能、就地排序、无需额外内存,以及适用于排序优先队列(通过堆数据结构)。

比较

快速排序和堆排序具有相似的平均和最坏情况时间复杂度,使其在效率方面相对具有竞争力。

快速排序在实践中通常优于堆排序,因为它具有良好的缓存行为,可减少数据移动。

堆排序是可预测且稳定的排序算法,而快速排序不稳定,且可能出现罕见的最坏情况。

3. 快速排序 vs. Tim Sort

快速排序

  • 平均时间复杂度:O(n * log(n))
  • 最佳情况时间复杂度:O(n * log(n))
  • 最坏情况时间复杂度:O(n^2)(少见,但可能)

就地排序:是

稳定性:否

主要优点:快速的平均情况性能、就地排序、缓存效率以及对各种数据类型和结构的适应性。

Tim Sort

  • 平均时间复杂度:O(n * log(n))
  • 最佳情况时间复杂度:O(n)
  • 最坏情况时间复杂度:O(n * log(n))

就地排序:否

稳定性:是

主要优点:稳定的排序、对真实数据集的适应性,以及结合了归并排序和插入排序以提高效率。

比较

Tim Sort 结合了归并排序和插入排序的优点,提供了一种混合排序算法,在真实世界的数据中具有良好的性能。

在某些情况下,快速排序可能比 Tim Sort 更快,尤其是在数据集非常大或包含大部分唯一元素时。

Tim Sort 稳定且对于具有现有顺序或部分排序数据的数据集非常高效,使其成为实际应用的绝佳选择。

总而言之,快速排序因其平均情况性能、就地排序和适应性而受到青睐,使其成为许多排序任务的首选。然而,排序算法的选择应基于您项目的具体要求、数据的性质以及稳定性、内存使用和可预测性能等因素之间的权衡。这些竞争性的排序算法各有优缺点,适用于不同的用例。

快速排序的历史

快速排序是最广泛使用的排序算法之一,其悠久的历史可以追溯到 20 世纪 60 年代初。它由英国计算机科学家 Tony Hoare 发明,他最初在剑桥大学的 Ferranti Mark 1 计算机上工作时构思了该算法。

以下是快速排序的历史和演变的简要概述:

1960 年:快速排序的诞生

快速排序由 Tony Hoare 在 1960 年发明,当时他正在研究早期的计算机 Ferranti Mark 1。那时,他正在寻找一种高效的数据排序方法,并提出了快速排序算法作为解决方案。

该算法首次由 Hoare 在他 1962 年 1 月发表于《ACM 通讯》(Association for Computing Machinery)期刊上的研究论文《Algorithm 64: Quicksort》中介绍。这篇论文概述了快速排序的概念并提供了初始算法。

1961 年:Hoare 的首次实现

1961 年,Tony Hoare 首次实现了快速排序算法。他最初的实现使用递归将数据分成更小的子数组并高效地对其进行排序。

1962 年:发表论文

如前所述,Hoare 于 1962 年发表的论文《Algorithm 64: Quicksort》正式将快速排序介绍给了计算界。这一出版物在普及该算法方面发挥了关键作用。

20 世纪 70 年代:广泛采用

快速排序在 20 世纪 70 年代在计算界广受欢迎。其效率和简洁性使其成为学术界和工业界排序任务的有吸引力的选择。

1995 年:引入 C++ 标准库

快速排序的重要性促使其作为 C++ 标准库中可供开发人员使用的排序算法之一。自那时以来,它一直是库的标准排序函数的一部分。

变体和优化

多年来,人们提出了快速排序的各种变体和优化,以提高其性能。这些包括用于选择枢轴元素的策略,在某些情况下切换到替代排序算法(例如,对小数组使用插入排序),以及调整算法以适应并行和多核计算环境。

快速排序经历了各种优化和改进。提出了该算法的不同变体,例如随机快速排序和三路分区快速排序,以进一步提高其性能并处理特定场景。

如今,快速排序在实践中被广泛使用,并且通常是许多编程语言和库中排序的默认选择。其高效的性质、易于实现的特点和多功能性使其成为各种应用的流行选择。

示例

示例 1:C++ 中使用字符串数组的快速排序

输出

Original Array: apple banana cherry date grape fig 
Sorted Array: apple banana cherry date fig grape

解释

  • 包含库:代码包含用于输入/输出和向量的库。
  • 分区函数

partition 接受字符串向量 arr 和两个索引 low 和 high。

它将最后一个元素 (arr[high]) 选作枢轴。

它将范围 [low, high-1] 中的每个元素与枢轴进行比较。

如果元素小于或等于枢轴,则将其移到枢轴的左侧。

循环结束后,它将枢轴放置在其正确的位置,较小的元素在左侧,较大的元素在右侧,并返回枢轴索引。

  • 快速排序函数

quickSort 通过递归地选择枢轴和分区数组来对字符串向量进行排序。

它从 low 和 high 定义的范围开始。

如果 low 小于 high,则它会

调用 partition 来查找枢轴索引 (pi) 并对数组进行分区。

递归地将 quickSort 应用于枢轴的左侧和右侧子数组(枢轴之前和之后)。

  • 主函数

初始化一个字符串向量 arr,其中包含未排序的元素。

打印原始数组。

调用 quickSort 对数组进行排序。

  • 打印排序后的数组。

在此示例中,快速排序用于对字符串向量进行排序。分区和交换操作基于字符串比较进行。编译并运行此代码,以查看快速排序如何应用于不同的数据类型。

示例 2:对浮点数数组进行排序

输出

Original Array: 9.1 7.2 5.3 11.4 2.5 1.6
Sorted Array: 1.6 2.5 5.3 7.2 9.1 11.4

解释

  • 从数组中选择一个枢轴元素。
  • 重新排列元素,使小于或等于枢轴的元素位于一边,大于枢轴的元素位于另一边。
  • 递归地将快速排序应用于枢轴两侧的子数组。
  • 当子数组包含零个或一个元素时,递归停止。
  • 随着元素相对于枢轴被正确放置,原始数组被排序。
  • 对于分区过程中创建的每个子数组,都会重复此过程。
  • 快速排序是一种高效的、基于比较的排序算法。
  • 平均时间复杂度为 O(n log n),使其成为最快的通用排序算法之一。
  • 枢轴选择会影响性能。
  • 快速排序是就地排序,不需要额外内存。
  • 最坏情况时间复杂度为 O(n^2),如果枢轴选择一直很差。
  • 常用于排序大型数据集。

示例 3:排序自定义对象

输出

Original Students:
Alice - 85
Bob - 72
Charlie - 94
David - 68
Eve - 91

Sorted Students (by score):
David - 68
Bob - 72
Alice - 85
Eve - 91
Charlie - 94

解释

  • 学生类

定义了一个名为 Student 的自定义类来表示学生。

每个 Student 对象都有两个属性:name(字符串)和 score(整数)。

提供了一个构造函数,用于在创建 Student 对象时初始化这些属性。

  • 比较函数

定义了一个名为 compareStudents 的自定义比较函数。

此函数接受两个 Student 对象作为输入,并根据它们的 score 属性进行比较。

如果第一个学生的 score 小于第二个学生的 score,则返回 true,表示第一个学生在排序顺序中应排在第二个学生之前。

  • 主函数

在 main 函数中,创建了一个名为 students 的向量。

该向量包含多个 Student 对象,每个对象代表一个具有姓名和分数的学生。

  • 打印原始学生

程序将原始学生列表打印到控制台。

对于 students 向量中的每个学生,它会显示学生的姓名和分数。

  • 排序学生

使用 std::sort 函数对 students 向量进行排序。

它根据学生的分数以升序重新排列学生。

compareStudents 函数用作比较标准,以确定排序过程中学生的顺序。

  • 打印排序后的学生

排序后,程序再次将学生列表打印到控制台。

这一次,它按分数的升序显示学生,显示他们的姓名和分数。

总之,此程序演示了如何定义自定义类(Student),创建该类的对象,根据特定属性(score)对其进行排序,并显示原始和排序后的学生列表。排序是使用带有自定义比较函数的 std::sort 函数执行的。

在此示例中,我们有一个自定义 Student 类,我们使用快速排序根据分数对 Student 对象向量进行排序。

这些示例演示了快速排序在对不同数据类型和自定义对象进行排序方面的灵活性。您可以通过为特定用例定义适当的比较函数或运算符重载来使快速排序与各种数据结构协同工作。

排序技术(从快到慢)

排序算法根据输入数据和特定场景具有不同的时间复杂度和性能特征。以下是常见排序技术的排名(从快到慢),并附有简要说明:

快速排序

对于通用排序任务,快速排序通常是最快的。它具有 O(n * log(n)) 的平均时间复杂度,并以其出色的缓存行为和对不同数据类型的适应性而闻名。

合并排序

归并排序以其一致的性能而闻名。它具有 O(n * log(n)) 的平均和最坏情况时间复杂度,使其能够高效地对大型数据集进行排序。它也很稳定,这意味着它保留了相等元素的相对顺序。

堆排序

堆排序具有 O(n * log(n)) 的平均和最坏情况时间复杂度。它是一种就地排序算法,具有可预测的性能。它常用于需要可靠、内存高效的排序解决方案的场景。

Tim Sort

Tim Sort 是一种混合排序算法,它结合了归并排序和插入排序的元素。它旨在高效处理真实世界的数据,并且是稳定的。其平均时间复杂度为 O(n * log(n))。

Intro Sort

Intro Sort 是快速排序的一个变体,当递归深度超过特定阈值时会切换到堆排序。它结合了快速排序的速度优势和堆排序的最坏情况性能保证。

冒泡排序

冒泡排序在最坏和平均情况下的时间复杂度均为 O(n^2),因此效率低于之前的算法。由于其简单性,它主要用于教育目的。

选择排序

选择排序在最坏和平均情况下的时间复杂度也均为 O(n^2)。它很简单,但对于大型数据集来说效率不如其他算法。

插入排序

插入排序,在最坏和平均情况下的时间复杂度为 O(n^2),常用于小型数据集或作为 Tim Sort 等更复杂算法的一部分。

希尔排序

希尔排序通过在排序较远的元素之前对它们进行比较来改进插入排序。它的时间复杂度比基本的二次算法要好,但仍比前面提到的更高级的算法慢。

这些排序算法的实际性能可能因数据集大小、数据初始顺序以及硬件和软件环境等因素而异。排序算法的选择应基于您项目的特定要求和限制。

结论

快速排序通常被认为是 C++ 中最快的排序算法,因为它具有 O(n * log(n)) 的平均时间复杂度和高效的就地排序。其分而治之策略、随机枢轴选择和缓存效率都对其在实践中的出色性能做出了贡献。

在 C++ 中选择排序算法时,请考虑您数据的特征和项目的具体要求。快速排序是通用排序任务的绝佳选择,尤其是在处理大型数据集和内存限制时。

然而,在使用随机枢轴选择时,重要的是要记住最坏情况的可能性和算法的不确定性。在某些情况下,其他排序算法(如归并排序或 Tim Sort)可能更合适。

通过了解快速排序的优点和注意事项,您可以明智地决定它是否是您编程需求的正确排序算法,最终优化您应用程序的效率。