本文算是算法的入门,从三种基础排序算法开始,简单介绍了算法分析,全文包含了选择排序、冒泡排序、插入排序、希尔排序、归并排序、快速排序、堆排序、计数排序、桶排序、基数排序。
本文的大部分内容是参考自文末列出的四本参考教材,这几本教材都很棒,在此致谢。另外chatgpt也参与了部分内容的编写。
基础算法
关于算法
算法的出现远早于计算机的出现。算法完全独立于计算机系统。
算法是一组步骤明确的有序集合,它产生结果并在有限的时间内终止。算法必须是一组定义良好且有序的指令集合。算法的每一步都必须有清晰、明白的定义。算法必须产生结果,否则该算法就没有意义。结果可以是返回给调用算法的数据或其他 效果(如打印)。算法必须能够终止(停机)。
算法(algorithm) 就是任何良定义的计算过程,该过程取某个值或值的集合作为输入并产生某个值或值的集合作为输出。这样算法就是把输入转换成输出的计算步骤的一个序列。
Informally, an algorithm is any well-defined computational procedure that takes some value, or set of values, as input and produces some value, or set of values, as output in a finite amount of time. An algorithm is thus a sequence of computational steps that transform the input into the output.
许多程序会使用排序作为一个中间步骤,所以排序是计算机科学的一个基本操作。
为了更容易理解后面抽象的描述,我们先来看一个简单的问题:求最大值或最小值。
求最大值
从一组任意的整数中找到最大值。这很简单,如下图:
// C++ program to find maximum element in an array
#include <iostream>
using namespace std;
int findMax(int arr[], int n) {
int max = arr[0];
for (int i = 1; i < n; i++) {
if (arr[i] > max) {
max = arr[i];
}
}
return max;
}
int main() {
int arr[] = {64, 34, 25, 12, 22, 11, 90};
int n = sizeof(arr)/sizeof(arr[0]);
int max = findMax(arr, n);
cout << "Maximum element is " << max << endl;
return 0;
}
//编译命令
// g++ max_element.cpp -o max_element
//运行命令
// ./max_element
选择排序
本节将介绍三种排序算法。选择排序、冒泡排序、插入排序。这三种方法是当今计算机科学中使用快速排序的基础。
这里排序统一为从小到大排序。
在选择排序中,数字列表可分为两个子列表(已排序的和未排序的).它们通过假想的一堵墙分开。求未排序子列表中最小的元素并把它和未排序子列表中的第一个元素进行交换,经过每次选择和交换,两个子列表中假想的这堵墙向前移动一个元素,这样每次排序列表中将增加一个元素而未排序列表中将减少一个元素,每次把一个元素从未排序列表移到已排序列表就完成了一轮排序。一个含有 $n$ 个元素的数字列表需要 $n-1$ 轮排序来完成数据的重新排列。选择排序的流程图如图所示。
下面是c++实现
#include <iostream>
#include <vector>
using namespace std;
void selectionSort(vector<int>& arr)
{
int n = arr.size();
for (int i = 0;i < n-1; i++){
//从最左边开始,找到最小的数,放到最左边
int minIndex = i; //已排序和未排序分隔墙位置
for (int j=i+1;j<n;j++){
if (arr[j] < arr[minIndex])
{
minIndex = j;
}
}
//交换
if (minIndex != i){
swap(arr[i], arr[minIndex]);
}
}
}
int main()
{
vector<int> arr = {64, 25, 12, 22, 11,23,78};
int n = arr.size();
cout << "排序前的数组: ";
for (int i=0;i<n;i++){
cout << arr[i] << " ";
}
cout << endl;
selectionSort(arr);
cout << "排序后的数组: ";
for (int i=0;i<n;i++){
cout << arr[i] << " ";
}
cout << endl;
return 0;
}
由于python的语法和一些实现与c++略有不同,这里顺便给一下python实现,后面的排序算法就仅使用c++实现了。
def selection_sort(arr):
n = len(arr)
for i in range(n):
min_idx = i
for j in range(i+1, n):
if arr[j] < arr[min_idx]:
min_idx = j
arr[i], arr[min_idx] = arr[min_idx], arr[i]
return arr
# 示例
arr = [64, 34, 25, 12, 22, 11, 90]
print("排序前的数组:", arr)
sorted_arr = selection_sort(arr)
print("排序后的数组:", sorted_arr)
冒泡排序
在冒泡排序(bubble sort)方法中,数字列表被分为两个子列袭,已排序的和未排序的。在未排序子列表中,最小的元素通过冒泡的方法选出来并移到已排序子列表中。当把最小的元素移到已排序列表后,墙向前移动一个元素,使得已排序元素的个数增$1$。而未排序元素的个数减少$1$ 。每次元素从未排序子列表中移到已排序子列表中,便完成一轮。一个含有 $n$ 个元素的列表,冒泡排序需要 $n-1$ 轮来完成数据排序。
注意,在墙到达列表末尾之前, 我们已经停止了,因为列表已经是有序的。我们总是能在算法中包含一个指示器,如果在一轮中没有数据交换,那就停止排序。通过减少数,这个事实能用来改善冒泡排序的性能。
// 冒泡排序
#include <iostream>
#include <vector>
void bubbleSort(std::vector<int>& arr)
{
int n = arr.size();
for (int i=0;i<n-1;i++) // 外层循环:控制已排序区间
{
bool flag = false; // 初始化标志位
//从末尾开始寻找最小的数
for (int j = n-1; j > i;j--)// 内层循环:从末尾向前找
{
if (arr[j] < arr[j-1]) // 比较相邻元素
{
std::swap(arr[j], arr[j-1]);
flag = true; // 记录交换发生
}
}
if (!flag) // 若没有发生交换,说明已排序区间已有序,则退出循环
break;
}
}
int main()
{
std::vector<int> arr = {64, 25, 12, 22, 11,23,78};
int n = arr.size();
std::cout << "排序前的数组: ";
for (int i=0;i<n;i++){
std::cout << arr[i] << " ";
}
std::cout << std::endl;
bubbleSort(arr);
std::cout << "排序后的数组: ";
for (int i=0;i<n;i++){
std::cout << arr[i] << " ";
}
std::cout << std::endl;
return 0;
}
插入排序
在插入排序(Insertion Sort)中,和前面两种排序方法一样,排序列表被分为两部分:已排序的和未排序的。在每轮,把未排序子列表中的第一个元素转移到已排序子列表中,并且插 入合适的位置。可以看到,一个含有 $n$ 个元素的列表至少需要 $n-1$ 轮排序。
-
时间复杂度:插入排序的最坏情况和平均情况时间复杂度为 $O(n^2)$,最好情况(数组已经有序)的时间复杂度为 $O(n)$。
-
空间复杂度:插入排序是原地排序算法,空间复杂度为 $O(1)$。
-
稳定性:插入排序是一种稳定的排序算法,即相同值的元素在排序后的相对顺序保持不变。
-
适用性:适用于数据量较小或部分有序的数组。在处理小规模数据或需要在线性时间内完成排序的情况下表现良好。
适用于数据量较小或部分有序的情况。虽然它在大规模数据排序时效率较低,但在某些特定场景下(如几乎有序的数据)表现良好。由于其稳定性和低空间复杂度,插入排序在某些应用中仍然有其独特的优势。
// 插入排序
#include <iostream>
#include <vector>
using namespace std;
void insertionSort(vector<int>& arr) {
int n = arr.size();
//外循环:已排序区间为[0, i-1]
for(int i=1; i<n; i++) // 从第二个元素开始
{
int key = arr[i]; // 待插入元素
int j = i-1; // 从i-1开始比较
//内循环:待插入元素key插入到已排序区间[0, i-1]的正确位置
while(j>=0 && arr[j]>key) // 找到比key小的元素
{
arr[j+1] = arr[j]; // 元素向右移动一位
j--;
}
arr[j+1] = key; // 将该元素插入到正确位置
}
}
int main()
{
vector<int> arr = {64, 25, 12, 22, 11,23,78};
int n = arr.size();
cout << "排序前的数组: ";
for (int i=0;i<n;i++){
cout << arr[i] << " ";
}
cout << endl;
insertionSort(arr);
cout << "排序后的数组: ";
for (int i=0;i<n;i++){
cout << arr[i] << " ";
}
cout << endl;
return 0;
}
上面讨论的三种排序算法是效率最低的排序算法,如果要排序的列表中有多于几百个元素,那么不应该使用这些算法。在这里讨论这些算法只是出于学习的目的,但它们不实用。讨论这三个排序算法的原因是:
- 它们是最简单的算法,容易理解和分析。
- 它们是更高效算法的基础,如希尔排序、快速排序等。
希尔排序
例如在插入排序基础上的改进算法:希尔排序(Shell Sort)
希尔排序是一种基于插入排序的排序算法,它通过将待排序的数组分割成若干个子序列(通过一定的步长),对每个子序列分别进行插入排序,从而减少插入排序在原数组上进行时的移动次数。希尔排序是插入排序的一种改进,能够在某些情况下比插入排序更高效。
希尔排序的核心思想是在插入排序的基础上,通过逐渐减少步长来分配元素进行排序。初始时,步长较大,对元素进行分组;然后逐步减少步长,直到步长为 1,最后再进行一次插入排序。
经典的希尔步长序列:$ \text{步长} = \text{数组长度} / 2 $,然后逐步减少。更高效的步长序列:如 Hibbard 序列、Sedgewick 序列等。
最坏时间复杂度:对于经典的步长序列($n / 2, n / 4, \dots$),最坏时间复杂度为 $O(n^2)$。平均时间复杂度:取决于步长序列,通常是 $O(n^{3/2})$。在某些情况下,步长序列选择得当时,最好的情况是 $O(n \log n)$。
希尔排序比插入排序更高效,尤其是在处理较大数据时。
希尔排序是原地排序,空间复杂度为 $O(1)$
有经验的程序员有时会选择希尔排序,因为对于中等大小的数组它的运行时间是可以接受的。它的代码量很小,且不需要使用额外的内存空间。在下面的几节中我们会看到更加高效的算法,但除了对于很大的N,它们可能只会比希尔排序快两倍(可能还达不到),而且更复杂。如果你需要解决一个排序问题而又没有系统排序函数可用(例如直接接触硬件或是运行于嵌入式系统中的代码),可以先用希尔排序,然后再考虑是否值得将它替换为更加复杂的排序算法。
#include <iostream>
#include <vector>
using namespace std;
// 希尔排序
void shellSort(vector<int>& arr) {
int n = arr.size();
// 初始步长为数组长度的一半
for (int gap = n / 2; gap > 0; gap /= 2) {
// 按当前步长对数组进行插入排序
for (int i = gap; i < n; i++) {
int temp = arr[i];
int j = i;
// 将大于 temp 的元素移动到当前位置
while (j >= gap && arr[j - gap] > temp) {
arr[j] = arr[j - gap];
j -= gap;
}
// 将 temp 插入到适当位置
arr[j] = temp;
}
}
}
// 打印数组
void printArray(const vector<int>& arr) {
for (int num : arr) {
cout << num << " ";
}
cout << endl;
}
int main() {
vector<int> arr = {64, 25, 12, 22, 11, 23, 78};
cout << "排序前的数组:";
printArray(arr);
// 调用希尔排序
shellSort(arr);
cout << "排序后的数组:";
printArray(arr);
return 0;
}
算法分析
停机问题不可解,因而至少有一个问题是计算机无法解决的,在计算机科学领域,问题分为两类:可解问题和不可解问题。有很多问题都无法用计算机解决,也有无数的问题可以用计算机解决,我们关心的是:计算机需要花多长时间去解决一个问题,即:这个问题有多复杂?
问题的复杂度可以用不同的方法衡量,如运行时间、需要的内存等。衡量可解问题复杂度的一个方法是找出计算机运行该程序时要执行的运算数量。这样,复杂度问题不是依赖于运行程序的计算机速度, 而是依赖于输入的数目。
排序算法的优劣取决于以下因素:
- the number of items to be sorted
- the extent to which the items are already somewhat sorted
- possible restrictions on the item values
- the architecture of the computer
- the kind of storage devices to be used: main memory, disks, or even—archaically—tapes.
计算模型
分析算法的结果意味着预测算法需要的资源。虽然有时我们主要关心像内存、通信带宽或计算机硬件这类资源,但是通常我们想度量的是计算时间。
现在我们假定一个通用的单处理器计算模型:随机访问机 random-access machine (RAM),在这个模型中指令顺序执行,无并发操作,RAM模型中包含真实计算机中的常见指令:算术指令(加法、减法、乘法、除法、取余、向下取整、向上取整)、数据移动指令(加载、存储、复制)、控制指令(条件跳转、无条件跳转、函数调用与返回),每条指令的执行时间都是常量。
RAM模型中的数据有整数型和浮点型实数,每个数据字的规模有一个范围,对于n位输入,整数由 $c lg n$ 位表示,其中c为大于等于1的常数。RAM模型是一个简化模型,我们这里假定指数运算也是常量时间内完成的操作,不考虑内存层次,没有高速缓存和虚拟内存等机制。
输入规模:输入规模的最佳概念依赖于研究的问题,对于排序或离散傅里叶变换等,输入规模的度量是输入中的项数,对于两个整数相乘等问题,输入规模的度量是用二进制表示输入所需的总位数,对于图论问题,输入规模可以用图中的顶点数和边数来表示。
运行时间是指执行的基本操作数或步数,现在我们假设执行每行伪代码需要常量时间。
教材《算法导论(原书第3版)》(ISBN:9787111407010)在第2.2节给出了详细的示例,计算插入排序所需的运行时间,最终计算得出结论:对于插入排序,最佳情况运行时间为 $an + b$ 结构,是n的线性函数,最坏情况下运行时间为 $an^2 + bn + c$ 结构,是n的二次函数。
我们往往只关心最坏情况的运行时间,即对于规模为 $n$ 的任意输入,算法最长的运行时间,理由如下:
- 知道了时间上界,就能确保该算法绝不需要更长时间。
- 对于某些算法,最坏的情况经常出现,例如检索某条信息是否为缺失信息时。
- 所谓的平均情况往往和最坏情况一样差。
现在我们做出一种更简化的抽象:即我们真正感兴趣的运行时间的增长率或增长量级。所以我们只考虑公式中最重要的项(例如,$an^2$), 因为当n 的值很大时,低阶项相对来说不太重要。我们也忽略最重要的项的常系数,因为对大的输入,在确定计算效率时常量因子不如增长率重要。对于插入排序,当我们忽略低阶项和最重要的项的常系数时,只剩下最重要的项中的因子 $n^2$ 。我们记插入排序具有最坏情况运行时间 $ \varTheta (n^2)$ (读作"theta n 平方")。这种简化思想在工科非常常见,对于大多数工科学生来说应该是很容易理解的。
如果一个算法的最坏情况运行时间具有比另一个算法更低的增长量级,那么我们通常认为前者比后者更有效。
大O表示法
大 $O$ 表示一个渐近的上界,在这种表示方法中,运算量是输入量的函数,符号 $O(n)$ 表示有 $n$ 个输入,执行 $n$ 次运算;符号 $O(n^2)$ 表示有 $n$ 个输入,执行 $n^2$ 的平方次运算。
假设有三个不同的程序来解决同一个问题,第一个程序的复杂度为 $O(log_{10} n)$,第二个程序的复杂度为 $O(n)$,第三个程序的复杂度为 $O(n^2)$。如果输入的大小为100万,某台指定计算机的执行时间为1微秒(每秒100万次),那么
- 第一个程序: $n=1000 \thinspace 000 \quad O(log_{10} n) \rarr 6 \qquad Time \rarr 6 \mu s$
- 第二个程序: $n=1000 \thinspace 000 \quad O(n) \rarr 1000 \thinspace 000 \qquad Time \rarr 1 s$
- 第三个程序: $n=1000 \thinspace 000 \quad O(n^2) \rarr 10^{12} \qquad Time \rarr 277h $
多项式问题:如果程序复杂度为 $O(log \, n)$ 、 $O(n)$ 、 $O(n^2)$ 、 $O(n^3)$ 、 $O(n^k)$ (k为常数)等,则被称为多项式问题,以当今的计算机速度,对于一个有合理输入数量(如1百万以内)的多项式问题,计算机均可解决。
下图是一些更严格准确的分析,这里不再展开,教材在本文末尾给出。
增长数量级的分类
对于问题规模 $N$,有下表:
算法稳定性
如果一个排序算法能够保留数组中重复元素的相对位置则可以被称为是稳定的。这个性质在许多情况下很重要。例如,考虑一个需要处理大量含有地理位置和时间戳的事件的互联网商业应用程序。首先,我们在事件发生时将它们挨个存储在一个数组中,这样在数组中它们已经是按照时间顺序排好了的。现在假设在进一步处理前将按照地理位置切分。一种简单的方法是将数组按照位置排序。如果排序算法不是稳定的,排序后的每个城市的交易可能不会再是按照时间顺序排列的了。
一部分算法是稳定的(插入排序和归并排序),但很多不是(选择排序、希尔排序、快速排序和堆排序)。有很多办法能够将任意排序算法变成稳定的,但一般只有在稳定性是必要的情况下稳定的排序算法才有优势。人们很容易觉得算法具有稳定性是理所当然的,但事实上没有任何实际应用中常见的方法不是用了大量额外的时间和空间才做到了这一点。
评价排序算法的标准主要包括以下几个方面:
1. 时间复杂度:这是衡量算法效率的一个重要指标,通常用大 $O$ 符号表示。主要考虑最坏情况、平均情况和最好情况的时间复杂度。常见的时间复杂度有 $O(n^2)、O(n log n)、O(n)、O(log n)$ 等。
2. 空间复杂度:这是衡量算法所需额外存储空间的指标。空间复杂度可以分为原地排序(in-place)和非原地排序。原地排序算法的空间复杂度通常为 $O(1)$ 或 $O(log n)$。
3. 稳定性:一个排序算法是稳定的,如果两个相等的元素在排序前后的相对位置保持不变。稳定排序对某些应用场景(如多关键字排序)非常重要。
4. 算法的复杂性:指算法本身的实现难度、代码复杂度和维护难度。简单易实现的算法更容易理解和维护,但在性能上可能不如复杂算法。
5. 适用性:某些算法在特定条件下表现更好。例如,快速排序在平均情况下非常高效,但在最坏情况下(如输入数组已经有序)可能表现不佳;而堆排序和归并排序则在各种情况下都有较为稳定的性能。
6. 输入敏感性:一些算法对输入数据的特定排列方式有较好的处理效果,比如插入排序在数据几乎有序的情况下表现优异。
7. 并行化:某些排序算法更容易进行并行化处理,例如归并排序和快速排序,这对大数据处理非常有用。
在嵌入式系统中,由于资源有限,通常会优先考虑空间复杂度较低且实现简单的算法,如插入排序或选择排序。
分治策略
分治思想:将原问题分解为几个规模较小但类似于原问题的子问题,递归地解决这些子问题,然后再合并这些子问题的解来产生原问题的解。
分治法每层递归时都有三个步骤:分解、解决、合并。
当子问题足够大,需要递归求解时,我们称之为递归情况(recursive case) 。当子问题变得足够小,不再需要递归时,我们说递归已经“触底”,进入了基本情况(base case) 。有时,除了与原问题形式完全一样的规模更小的子问题外,还需要求解与原问题不完全一样的子问题。我们将这些子问题的求解看做合并步骤的一部分。
递归式与分治法是紧密相关的,下面的递归式刻画了分治算法的运行时间:
求解可得到:$T(n) = \varTheta(nlogn)$。
归并排序(Merge Sort)
归并排序算法完全遵循分治策略:
- 分解:分解待排序的n个元素序列为两个长度为n/2的子序列。
- 解决:使用归并排序递归地排序两个子序列。
- 合并:合并两个已排序的子序列,得到一个已排序的结果序列。
当待排序的序列长度为1时,递归开始回升。
归并排序的关键操作是“合并”步骤中两个已排序序列的合并,我们通过调用一个辅助过程 $MERGE(A, p, q, r)$ 来完成合并,其中 $A$ 是待排序数组,$p, q, r$ 是数组下标且 $p \leq q < r$,假设子数组 $A[p...q]$ 和 $A[q+1...r]$ 都已排好序,它合并这两个数组形成单一的已排好序的数组 $A[p...r]$。
MERGE过程的运行时间为 $\varTheta(n)$,其中 $n=r-p+1$。
#include <iostream>
#include <vector>
using namespace std;
/**
* @brief 合并两个已排序的子数组
*
* @param arr 原数组,包含需要排序的元素
* @param left 左子数组的起始索引
* @param mid 左子数组的结束索引(右子数组的起始索引为 mid + 1)
* @param right 右子数组的结束索引
*/
void merge(vector<int>& arr, int left, int mid, int right)
{
int i = left, j = mid + 1; // i指向左子数组的起始,j指向右子数组的起始
int k = 0; // 临时数组的索引
vector<int> temp(right - left + 1); // 临时数组,用于存储合并后的结果
// 比较左右子数组的元素,将较小的元素加入临时数组
while (i <= mid && j <= right)
{
if (arr[i] <= arr[j])
{
temp[k++] = arr[i++];
}
else
{
temp[k++] = arr[j++];
}
}
// 将左子数组剩余元素加入临时数组
while (i <= mid)
{
temp[k++] = arr[i++];
}
// 将右子数组剩余元素加入临时数组
while (j <= right)
{
temp[k++] = arr[j++];
}
// 将临时数组中的元素复制回原数组的对应位置
for (int m = 0; m < temp.size(); m++)
{
arr[left + m] = temp[m];
}
}
/**
* @brief 归并排序主函数(递归分治)
*
* @param arr 原数组,包含需要排序的元素
* @param left 当前子数组的起始索引
* @param right 当前子数组的结束索引
*/
void mergeSort(vector<int>& arr, int left, int right)
{
if (left < right)
{
int mid = left + (right - left) / 2; // 防止整数溢出的计算方式
// 递归排序左半部分
mergeSort(arr, left, mid);
// 递归排序右半部分
mergeSort(arr, mid + 1, right);
// 合并左右两部分
merge(arr, left, mid, right);
}
}
/**
* @brief 主函数,测试归并排序
*/
int main()
{
vector<int> arr = {64, 25, 12, 22, 11, 23, 78}; // 测试数组
int n = arr.size();
// 输出排序前的数组
cout << "排序前的数组: ";
for (int i = 0; i < n; i++)
{
cout << arr[i] << " ";
}
cout << endl;
// 调用归并排序函数
mergeSort(arr, 0, n - 1);
// 输出排序后的数组
cout << "排序后的数组: ";
for (int i = 0; i < n; i++)
{
cout << arr[i] << " ";
}
cout << endl;
return 0;
}
归并排序最吸引人的性质是它能够保证将任意长度为 $N$ 的数组排序所需时间和 $NlogN$ 成正比;它的主要缺点则是它所需的额外空间和 $N$ 成正比。
最大子数组问题
在一个整数数组中,寻找一个连续子数组,使得该子数组的所有元素之和最大,并返回这个最大值。(子数组必须是连续的,可以包含一个元素,也可以是整个数组。)
数学定义:给定一个长度为 $ n $ 的数组 $ A = [a_1, a_2, \dots, a_n] $,目标是找到一对索引 $ i $ 和 $ j $(满足 $ 1 \leq i \leq j \leq n $),使得:$
\text{最大化 } \text{sum} = \sum_{k=i}^j a_k $
- 输入:
nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4]
- 输出:
6
- 解释:最大和的子数组是
[4, -1, 2, 1]
,其和为 $ 4 + (-1) + 2 + 1 = 6 $。
暴力求解法:枚举所有可能的子数组,并计算其和,找出最大的和,时间复杂度为$\Omega(n^2)$
分治法:
假设输入是一个数组 A
和它的左右边界 low
和 high
。
先将数组划分为左右两部分,然后递归求解:左半部分的最大子数组和、右半部分的最大子数组和、跨越中间部分的最大子数组和、返回三者中的最大值。
FUNCTION MaxSubarray(A, low, high):
IF low == high: // 基本情况:只有一个元素
RETURN A[low]
mid = (low + high) / 2 // 计算中点
// 递归求解左右部分的最大子数组和
leftSum = MaxSubarray(A, low, mid)
rightSum = MaxSubarray(A, mid + 1, high)
// 求解跨越中间部分的最大子数组和
crossSum = MaxCrossingSubarray(A, low, mid, high)
// 返回三者中的最大值
RETURN max(leftSum, rightSum, crossSum)
// 计算跨越中间部分的最大子数组和
FUNCTION MaxCrossingSubarray(A, low, mid, high):
// 从中间向左计算最大和
leftMaxSum = -∞
sum = 0
FOR i FROM mid DOWNTO low:
sum = sum + A[i]
IF sum > leftMaxSum:
leftMaxSum = sum
// 从中间向右计算最大和
rightMaxSum = -∞
sum = 0
FOR i FROM mid + 1 TO high:
sum = sum + A[i]
IF sum > rightMaxSum:
rightMaxSum = sum
// 返回跨越中点的最大和
RETURN leftMaxSum + rightMaxSum
总体复杂度:$T(n) = O(n \log n)$
最大子数组问题是一个经典的算法题,广泛应用于财务数据分析、信号处理等领域。
除了最大子数组问题外,还有矩阵乘法的Strassen算法等用到分治策略,关于矩阵乘法的Strassen算法可以参阅文末列出的参考资料。
堆排序
优先队列和堆
优先队列(priority queue):许多应用程序都需要处理有序的元素,但不一定要求它们全部有序,或是不一定要一次就将它们排序。很多情况下我们会收集一些元素,处理当前键值最大的元素,然后再收集更多的元素,再处理当前键值最大的元素,如此这般。例如,你可能有一台能够同时运行多个应用程序的电脑(或者手机)。这是通过为每个应用程序的事件分配一个优先级,并总是处理下一个优先级最高的事件来实现的。例如,绝大多数手机分配给来电的优先级都会比游戏程序的高。
优先队列的一些重要的应用场景包括模拟系统,其中事件的键即为发生的时间,而系统需要按照时间顺序处理所有事件;任务调度,其中键值对应的优先级决定了应该首先执行哪些任务;数值计算,键值代表计算错误,而我们需要按照键值指定的顺序来修正它们。
在这种情况下,一个合适的数据结构应该支持两种操作:删除最大元素和插入元素。这种数据类型叫做优先队列。优先队列的使用和队列(删除最老的元素)以及栈(删除最新的元素)类似,但高效地实现它则更有挑战性。
优先队列最重要的操作就是删除最大元素和插入元素。对于栈和队列,我们要求能在常数时间内完成所有操作,而对于优先队列,我们要求在最坏的情况下插入元素和删除最大元素这两个操作之一需要线性时间完成,这种需求可以通过堆这种数据结构来实现。
堆heap:是一种满足特定条件的完全二叉树
数据结构二叉堆能够很好地实现优先队列的基本操作。在二叉堆的数组中,每个元素都要保证大于等于另两个特定位置的元素。相应地,这些位置的元素又至少要大于等于数组中的另两个元素,以此类推。如果我们将所有元素画成一棵二叉树,将每个较大元素和两个较小的元素用边连接就可以很容易看出这种结构。
大顶堆(最大堆):每个节点的值都大于或等于其子节点的值。
小顶堆(最小堆):每个节点的值都小于或等于其子节点的值。
在堆排序算法中通常使用最大堆,优先队列中通常使用最小堆,不同应用的实际需求不同。
如果用指针表示堆,就需要三个指针来找到元素的上下节点,即一个父节点和两个子节点。完全二叉树(complete binary tree只有最底层的节点未被填满,且最底层节点尽量靠左填充。)特别适合用数组表示,元素代表节点值,索引代表节点在二叉树中的位置。节点指针通过索引映射公式来实现,如下图,图片引自《hello-algo》,这是一本很不错的教材。
一棵大小为N的完全二叉树的高度为 $\varTheta(lgN)$
堆的上浮 swim操作:当某个结点的优先级上升(或是在堆底加入一个新的元素)时,我们需要由下至上恢复堆的顺序。某节点的值比它父节点的值大,只需要交换它们的值即可,交换后新的父节点一定比它的两个子节点的值都大,如果它仍然比现在的父节点大,则重复操作,直到遇到一个比它大的父节点。如果节点位置是 $k$ ,则父节点的位置是 $k/2$ 。
堆的下沉 sink:如果堆的有序状态因为某个结点变得比它的两个子结点或是其中之一更小了而被打破了,那么我们可以通过将它和它的两个子结点中的较大者交换来恢复堆。位置 $k$ 处节点的子节点为 $2k$ 和 $2k+1$ 。
插入元素:将新元素添加到数组末尾,增加堆的大小并让这个新元素上浮到合适位置。
删除最大元素:从数组顶端删去最大元素并将数组的最后一个元素放到顶端,然后减小堆的大小并将这个元素下沉到合适的位置。
对于一个含有N个元素的基于堆的优先队列,插入元素操作只需要不超过 $(lgN +1)$ 次比较,删除最大元素的操作最多只需要不超过 $2 lgN$ 次比较。对于需要大量混杂的插入和删除最大元素操作的典型应用来说,这意味一个重要的性能突破。
创建堆的方法一:首先创建一个空堆,然后遍历列表,依次对每个元素执行“插入元素操作”,即先将元素添加至堆的尾部,再对该元素执行上浮操作。该方法的复杂度为 $O(nlogn)$ 。
创建堆的方法二:倒序遍历堆(层序遍历的倒序),依次对每个非叶节点执行下沉操作。这种方法的复杂度是线性的,这意味着我们可以在线性时间内将一个无序列表转换为一个最大堆。
堆作为优先队列时,也有最大优先队列和最小优先队列。优先队列 priority queue 是一种用来维护由一组元素构成的集合 $S$ 的数据结构,其中每一个元素都有一个相关的值,称为关键字 key。一个最大优先队列支持的操作为:插入元素(insert)、删除最大元素(delete-max)、获取最大元素(max)、修改元素的关键字(increase-key)。
一般堆的数组中索引从 1 开始,即 1 号元素是堆的根节点。
堆排序算法
输入一个无序数组A[1,n]并建立大顶堆,此时最大元素在堆顶,即根节点A[1],将堆顶元素A[1]与堆底元素A[n]交换,然后将堆的大小减一(A. heapsize-1),并对堆顶元素执行下沉操作,以此类推,直到堆的大小从n-1降到2。
堆排序是唯一能够同时最优地利用空间和时间的方法,是一种原地排序算法,时间复杂度为 $O(nlogn)$ ,空间复杂度为 $O(1)$ ,循环$n-1$次。
注意:数据结构中的堆结构与内存管理中的堆(堆和栈,动态内存分配)概念不同,只是碰巧名称相同。
#include <iostream>
#include <vector>
using namespace std;
// 调整堆,使得以i为根的子树满足最大堆性质
void heapify(vector<int>& arr, int n, int i) {
int largest = i; // 假设根节点是最大的
int left = 2 * i + 1; // 左子节点的索引
int right = 2 * i + 2; // 右子节点的索引
// 如果左子节点比根节点大,则更新最大值
if (left < n && arr[left] > arr[largest]) {
largest = left;
}
// 如果右子节点比当前最大值还大,则更新最大值
if (right < n && arr[right] > arr[largest]) {
largest = right;
}
// 如果最大值不是根节点,交换根节点和最大子节点
if (largest != i) {
swap(arr[i], arr[largest]);
// 递归调整堆,保证交换后的子树仍然满足堆的性质
heapify(arr, n, largest);
}
}
// 堆排序的主函数
void heapSort(vector<int>& arr) {
int n = arr.size();
// 1. 构建最大堆
// 从最后一个非叶子节点开始调整堆
for (int i = n / 2 - 1; i >= 0; i--) {
heapify(arr, n, i);
}
// 2. 一个一个地将最大元素(根节点)移到数组的末尾,并重新调整堆
for (int i = n - 1; i >= 1; i--) {
// 交换当前最大元素与最后一个元素
swap(arr[0], arr[i]);
// 调整剩余的部分,恢复堆的性质
heapify(arr, i, 0);
}
}
// 打印数组
void printArray(const vector<int>& arr) {
for (int i : arr) {
cout << i << " ";
}
cout << endl;
}
int main() {
vector<int> arr = {64, 25, 12, 22, 11,23,78};
cout << "排序前的数组:";
printArray(arr);
// 调用堆排序函数
heapSort(arr);
cout << "排序后的数组:";
printArray(arr);
return 0;
}
快速排序
快速排序(Quick Sort)可能是应用最广泛的排序算法,它实现简单,尽管最坏情况时间复杂度为 $O(n^2)$ ,但它的平均时间复杂度为 $O(nlogn)$ ,且复杂度的常数系数小,又是原址排序,因此它很快。
快速排序也运用了分治策略,通常比其他 $O(n \log n)$ 算法(如归并排序和堆排序)更快。其核心思想是选择一个基准元素(pivot),然后将数组分成两个部分:小于基准元素的部分和大于基准元素的部分,分别对这两个部分进行递归排序。
分解:数组 $A[p..r]$,被划分为两个(可能为空的子数组),$A[p..q-1]和A[q+1..r]$,其中$q$是基准元素的位置,$A[p..q-1]$中的每一个元素都小于等于$A[q]$,$A[q+1..r]$中的每一个元素都大于等于$A[q]$。
解决:递归地对$A[p..q-1]$和$A[q+1..r]$进行排序。
合并:两个子数组都是原址排序,因此不需要合并操作,数组$A[p..r]$已经排好序。
QUICKSORT(A, p, r)
if p < r
q = PARTITION(A, p, r)
QUICKSORT(A, p, q-1)
QUICKSORT(A, q+1, r)
快速排序的核心操作:分区 Partition分区操作是快速排序的关键,它实现了对子数组的原址重排。
PARTITION(A, p, r)
x = A[r]
i = p - 1
for j = p to r - 1
if A[j] <= x
i = i + 1
swap A[i] and A[j]
swap A[i+1] and A[r]
return i+1
上面的伪代码展示了PARTITION的思路,选择一个x=A[r]作为基准元素(pivot element),下面的一幅图示很清楚地说明了PARTITION 操作过程。
PARTITION一次迭代中可能的情况:
快速排序的运行时间依赖于划分是否平衡,而平衡与否又依赖于用于划分的元素。如果划分是平衡的,那么快速排序算法性能与归并排序一样。如果划分是不平衡的,那么快速排序的性能就接近于插入排序了。
举一个极端例子,假设输入数组是完全倒序的,由于我们选择
最左端元素作为基准数,那么在划分完成后,基准数被交换至数组最右端,导致左子数组长度为𝑛 − 1、右子数组长度为0。如此递归下去,每轮划分后都有一个子数组的长度为0,分治策略失效,快速排序退化为“冒泡排序”的近似形式。
解决这个问题的一种办法是随机选取一个元素作为基准数。
进一步的改进则为在数组中选取三个候选元素(通常为数组的首、尾、中点元素),并将这三个候选元素的中位数作为基准数。这样一来,基准数“既不太小也不太大”的概率将大幅提升。(三取样切分法)
另一种优化方法是在排序小数组时,切换到插入排序,一般5-15之间的参数都可以。
在待排序数组存在大量相同值的情况下,例如人员的出生日期或者性别等,快速排序就有了很大的改进空间。有一种基于信息熵计算三向切分上下界的方法,随着数组规模的增加,三向切分的快速排序法能将包含大量重复元素的数组排序时间降低到线性级别。
经过精心调优的快速排序在绝大多数计算机上的绝大多数应用中都会比其他基于比较的排序算法更快。快速排序在今天的计算机业界中的广泛应用正是因为我们讨论过的数学模型说明了它在实际应用中比其他方法的性能更好,而近几十年的大量实验和经验也证明了这个结论。
#include <iostream>
#include <vector>
using namespace std;
// 分区操作,将基准元素放置在正确位置,并返回该位置的索引
int partition(vector<int>& arr, int low, int high) {
int pivot = arr[high]; // 选择最后一个元素作为基准
int i = low - 1; // i表示小于基准元素的部分的最后一个元素的索引
// 遍历数组,找到小于基准元素的元素并交换
for (int j = low; j < high; j++) {
if (arr[j] <= pivot) {
i++; // 小于基准的部分右移
swap(arr[i], arr[j]); // 交换元素
}
}
// 将基准元素放到正确的位置(即它应该位于索引i+1的位置)
swap(arr[i + 1], arr[high]);
// 返回基准元素的位置
return i + 1;
}
// 快速排序主函数
void quickSort(vector<int>& arr, int low, int high) {
if (low < high) {
// 获取基准元素的索引,使得它的位置已经排好
int pivotIndex = partition(arr, low, high);
// 对基准元素左边的子数组进行递归排序
quickSort(arr, low, pivotIndex - 1);
// 对基准元素右边的子数组进行递归排序
quickSort(arr, pivotIndex + 1, high);
}
}
// 打印数组
void printArray(const vector<int>& arr) {
for (int num : arr) {
cout << num << " ";
}
cout << endl;
}
int main() {
vector<int> arr = {64, 25, 12, 22, 11,23,78};
cout << "排序前的数组:";
printArray(arr);
// 调用快速排序
quickSort(arr, 0, arr.size() - 1);
cout << "排序后的数组:";
printArray(arr);
return 0;
}
线性时间的排序算法
前面几种在 $O(nlogn)$ 时间内排序 $n$ 个数的算法,都是比较排序算法:在排序的最终结果中,各元素的次序依赖于它们之间的比较。我们可以在数学上严格证明,对于包含 $n$个元素的输入序列,任何比较排序算法都需要经过 $ \Omega(nlogn)$ 次比较。下面介绍几种线性时间复杂度的排序算法,这几种算法不是通过比较大小来确定顺序的,因此下界为 $O(nlogn)$ 对它们不适用。
定理:在最坏情况下,任何比较排序算法都至少需要做 $\Omega(nlogn)$ 次比较。
计数排序
Counting sort 计数排序,通常应用于整数数组。基本思想是对每个元素x,统计比x小的元素的个数,例如数组中有5个数比x小,那么x排序就在第6个位置。
假设输入数组A[1..n],A.length=n,我们另外定义两个数组,B[1..n]用于存放排序的输出,C[0..k]用于计数,伪代码:
COUNTING-SORT(A,B,k)
C[0..k] := 0 // 计数数组初始化为0
for i = 1 to A.length do
C[A[i]] := C[A[i]] + 1 // 统计每个元素的出现次数
for i = 1 to k do
C[i] := C[i] + C[i-1] // 计算每个元素的排序位置
for i = A.length downto 1 do // 反向遍历数组
B[C[A[i]]] := A[i] // 将元素放入输出数组
C[A[i]] := C[A[i]] - 1 // 元素计数减1
return B
计数排序的一个重要性质就是它是稳定的。
计数排序对于范围较小且数据量较大的整数排序非常适用。它在时间复杂度上具有线性复杂度,但在空间复杂度上会消耗较多内存,尤其当数据范围很大时。
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// 计数排序函数
void countingSort(vector<int>& arr) {
if (arr.empty()) return; // 如果数组为空,直接返回
// 找出数组中最大和最小的元素
int maxVal = *max_element(arr.begin(), arr.end());
int minVal = *min_element(arr.begin(), arr.end());
// 创建计数数组,大小为范围的大小(最大值 - 最小值 + 1)
int range = maxVal - minVal + 1;
vector<int> count(range, 0); // 初始化计数数组
vector<int> output(arr.size()); // 输出排序后的数组
// 统计每个元素的出现次数
for (int num : arr) {
count[num - minVal]++;
}
// 统计累加频次(将计数转换为位置)
for (int i = 1; i < range; i++) {
count[i] += count[i - 1];
}
// 将元素放回输出数组,根据累积的频次找到正确位置
for (int i = arr.size() - 1; i >= 0; i--) {
output[count[arr[i] - minVal] - 1] = arr[i];
count[arr[i] - minVal]--;
}
// 将排序后的结果拷贝回原数组
arr = output;
}
// 打印数组
void printArray(const vector<int>& arr) {
for (int num : arr) {
cout << num << " ";
}
cout << endl;
}
int main() {
vector<int> arr = {64, 25, 12, 22, 11,23,78};
cout << "排序前的数组:";
printArray(arr);
// 调用计数排序
countingSort(arr);
cout << "排序后的数组:";
printArray(arr);
return 0;
}
基数排序
基数排序(Radix Sort)是一种非比较型排序算法,常用于对数字进行排序。它利用计数排序(Counting Sort)作为子程序,按每个位(从最低位到最高位或从最高位到最低位)对数组进行排序。
例如对学号进行排序,学号有8位,对8位学号的每一位依次执行计数排序即可。另比如对年月日的日期进行排序,先比较年,再比较月、日。
- 按位排序:基数排序从数字的最低有效位(Least Significant Digit, LSD)开始,对每一位进行排序,逐步排到最高有效位(Most Significant Digit, MSD)。
- 子排序算法:每个位的排序使用计数排序,因为计数排序是稳定排序,可以确保同一位上的元素排序后不会影响之前的排序结果。(注意是要从最低位到最高位排序,因为后一轮排序会覆盖前一轮排序的结果,数字的高位优先级高于低位)
伪代码:
RADIX-SORT(A,d)
for i = 1 to d
use a stable sorting algorithm to sort A according to the i-th digit
return A
给定n个d位数,其中每一个数有k个可能值,如果RADIX-SORT使用稳定排序算法耗时 $O(n+k)$,则它就可以在 $O(d(n+k))$ 时间内排序n个数。
通常情况下$d$和$k$取值都较小,时间复杂度趋向于线性 $O(n)$。
空间复杂度为 $O(n+d)$ ,非原地排序。
利用计数排序作为中间过程的基数排序是稳定排序。
基数排序在数据范围较大但长度有限的情况下效果显著,因为它能够在近似线性时间内完成排序。它依赖于计数排序的稳定性,通过逐位对数据进行排序,从而实现整体排序。在某些特定场景下,如排序电话号码或身份证号码,基数排序表现非常优异。
浮点数不适合使用基数排序,因为其位数 $k$ 过大,可能导致时间复杂度 $O(nk) \gg O(n^2)$ 。
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// 计数排序,用于基数排序
void countingSortForRadix(vector<int>& arr, int exp) {
int n = arr.size();
vector<int> output(n); // 用于存储排序后的结果
vector<int> count(10, 0); // 基数为10,所以计数数组大小为10
// 根据当前位(exp),统计每个数字出现的次数
for (int i = 0; i < n; i++) {
int digit = (arr[i] / exp) % 10; // 提取当前位的数字
count[digit]++;
}
// 更新计数数组,计算累积位置
for (int i = 1; i < 10; i++) {
count[i] += count[i - 1];
}
// 按当前位的数字,将元素放入正确位置
for (int i = n - 1; i >= 0; i--) {
int digit = (arr[i] / exp) % 10;
output[count[digit] - 1] = arr[i];
count[digit]--; // 更新计数数组
}
// 将排序结果复制回原数组
for (int i = 0; i < n; i++) {
arr[i] = output[i];
}
}
// 基数排序主函数
void radixSort(vector<int>& arr) {
// 找到数组中的最大值,确定最高位数
int maxVal = *max_element(arr.begin(), arr.end());
// 从最低有效位(exp = 1, 即个位)开始,一直到最高位
for (int exp = 1; maxVal / exp > 0; exp *= 10) {
countingSortForRadix(arr, exp); // 按当前位进行计数排序
}
}
// 打印数组
void printArray(const vector<int>& arr) {
for (int num : arr) {
cout << num << " ";
}
cout << endl;
}
int main() {
vector<int> arr = {170, 45, 75, 90, 802, 24, 2, 66};
cout << "排序前的数组:";
printArray(arr);
// 调用基数排序
radixSort(arr);
cout << "排序后的数组:";
printArray(arr);
return 0;
}
桶排序
桶排序(Bucket Sort)假设输入数据服从均匀分布,将数据分到不同的桶中,然后在每个桶内分别执行排序(使用插入排序等排序算法),最后将桶中的元素合并到原数组中。
先创建桶:将数据分布到指定数量的桶中,桶的数量和范围可以根据数据特点确定。然后分配元素到桶,根据每个元素的值计算其对应的桶索引,然后将其放入相应的桶中。对每个桶中的元素进行排序(通常使用插入排序或快速排序)。最后按桶的顺序依次将桶中元素合并到输出数组。
平均时间复杂度:$ O(n + k) $,其中:$n$是数据量,$k$是桶的数量。最坏时间复杂度:$O(n^2)$,(当所有元素都被分配到一个桶中时)。空间复杂度:$O(n + k)$。 经数学计算,即使数据不服从均匀分布,只要数据满足:所有的桶的大小的平方和与总的元素数呈线性关系,桶排序就能在线性时间内完成。
// 桶排序(使用插入排序进行桶内排序)
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// 插入排序函数,用于桶内排序
void insertionSort(vector<float>& bucket) {
for (size_t i = 1; i < bucket.size(); i++) {
float key = bucket[i];
int j = i - 1;
// 将 key 插入到已排序部分
while (j >= 0 && bucket[j] > key) {
bucket[j + 1] = bucket[j];
j--;
}
bucket[j + 1] = key;
}
}
// 桶排序主函数
void bucketSort(vector<float>& arr) {
int n = arr.size();
if (n <= 1) return; // 如果数组大小为 1 或 0,无需排序
// 创建 n 个桶(每个桶是一个 vector)
vector<vector<float>> buckets(n);
// 将元素分配到桶中
float maxVal = *max_element(arr.begin(), arr.end());
float minVal = *min_element(arr.begin(), arr.end());
float range = maxVal - minVal;
for (int i = 0; i < n; i++) {
int bucketIndex = (arr[i] - minVal) / range * (n - 1); // 计算桶索引
buckets[bucketIndex].push_back(arr[i]); // 将元素放入桶中
}
// 对每个桶内进行排序
for (int i = 0; i < n; i++) {
insertionSort(buckets[i]); // 对桶内元素排序
}
// 合并所有桶中的元素到原数组
int index = 0;
for (int i = 0; i < n; i++) {
for (float value : buckets[i]) {
arr[index++] = value;
}
}
}
// 打印数组
void printArray(const vector<float>& arr) {
for (float num : arr) {
cout << num << " ";
}
cout << endl;
}
int main() {
vector<float> arr = {0.897, 0.565, 0.656, 0.123, 0.665, 0.343};
cout << "排序前的数组:";
printArray(arr);
// 调用桶排序
bucketSort(arr);
cout << "排序后的数组:";
printArray(arr);
return 0;
}
分配元素到桶:$ \text{bucketIndex} = (\text{arr[i]} - \text{minVal}) / \text{range} \times (n - 1) $。
桶排序特别适合数据范围固定且分布均匀的场景,通过使用插入排序对桶内进行排序,可以确保排序的稳定性和效率。
全文总结:
参考资料
- 计算机科学导论(第四版) [美] 贝赫鲁兹·佛罗赞 机械工业出版社 ISBN: 9787111654636
- 《算法导论(原书第3版)》机械工业出版社,作者: Thomas H.Cormen / Charles E.Leiserson / Ronald L.Rivest / Clifford Stein,ISBN: 9787111407010
- 《算法(第4版)》人民邮电出版社[美] Robert Sedgewick Kevin Wayne,ISBN: 9787115293800
- 《Hello 算法》靳宇栋 @krahets https://www.hello-algo.com/
- 菜鸟教程
- 【知乎】详解矩阵乘法中的Strassen算法 - Coder LL的文章 - 知乎
- 【CSDN博客】《算法导论》学习(四)---- 矩阵乘法的Strassen(斯特拉森)算法