10大排序算法学习目录
1. 冒泡排序(Bubble Sort)
1.1 算法原理
冒泡排序的基本思想是:对待排序元素的关键字从后往前进行多遍扫描,遇到相邻两个关键字次序与排序规则不符时,就将这两个元素进行交换。这样一来,关键字较小的那个元素就像是气泡一样,从最后面冒到最前面来。
具体来说,冒泡排序算法可以分为以下步骤:
-
比较相邻的两个元素,如果前一个元素比后一个元素大,就交换这两个元素的位置;
-
对第0个到第n-1个数据做同样的工作。这时,最大的元素就"浮"到了数组最后的位置上;
-
针对所有的元素重复以上的步骤,除了最后一个;
-
持续每次对越来越少的元素重复上面的步骤,直到没有任何一对数字需要比较,排序完成。
我们用一个具体的例子来解释冒泡排序的过程。假设我们有一个数组 arr = [5, 1, 4, 2, 8],排序的规则是从小到大,冒泡排序是这么做的:
第一轮:
原始数组: 5 1 4 2 8
比较 5 和 1, 交换: 1 5 4 2 8
比较 5 和 4, 交换: 1 4 5 2 8
比较 5 和 2, 交换: 1 4 2 5 8
比较 5 和 8, 不交换: 1 4 2 5 8
结果: 1 4 2 5 8
第一轮冒泡结束,最大的元素8已经在数组末尾了。
第二轮:
原始数组: 1 4 2 5 8
比较 1 和 4, 不交换: 1 4 2 5 8
比较 4 和 2, 交换: 1 2 4 5 8
比较 4 和 5, 不交换: 1 2 4 5 8
结果: 1 2 4 5 8
第二轮冒泡结束,第二大的元素5已经在数组倒数第二位了。
第三轮:
原始数组: 1 2 4 5 8
比较 1 和 2, 不交换: 1 2 4 5 8
比较 2 和 4, 不交换: 1 2 4 5 8
结果: 1 2 4 5 8
第三轮冒泡结束,数组已经完全有序了。
第四轮:
原始数组: 1 2 4 5 8
比较 1 和 2, 不交换: 1 2 4 5 8
结果: 1 2 4 5 8
第四轮冒泡结束,数组已经完全有序,排序完成。
可见,冒泡排序就是通过不断交换相邻的"逆序"元素,使得每一轮冒泡都把最大的元素交换到数组的末尾,直到整个数组有序。它的原理简单明了,是最容易理解和实现的排序算法之一。
1.2 代码实现
以下是冒泡排序的C++实现:
#include <iostream>
using namespace std;
// 冒泡排序函数
void bubbleSort(int arr[], int n) {
int i, j;
for (i = 0; i < n-1; i++) {
// 标志变量,用于优化,如果在一轮冒泡中没有发生交换,说明数组已经有序,可以提前结束
bool swapped = false;
// 每轮冒泡从第一个元素开始,比较到倒数第i+1个元素
// 因为每轮冒泡会将最大的元素移动到数组末尾,所以下一轮冒泡可以减少一次比较
for (j = 0; j < n-i-1; j++) {
// 如果前一个元素比后一个元素大,交换它们
if (arr[j] > arr[j+1]) {
swap(arr[j], arr[j+1]);
// 标记发生了交换
swapped = true;
}
}
// 如果在本轮冒泡中没有发生交换,说明数组已经有序,可以提前结束
if (swapped == false)
break;
}
}
// 打印数组函数
void printArray(int arr[], int size) {
int i;
for (i = 0; i < size; i++)
cout << arr[i] << " ";
cout << endl;
}
// 测试代码
int main() {
int arr[] = {64, 34, 25, 12, 22, 11, 90};
// 计算数组的长度
int n = sizeof(arr)/sizeof(arr[0]);
cout << "原始数组: \n";
printArray(arr, n);
bubbleSort(arr, n);
cout << "排序后的数组: \n";
printArray(arr, n);
return 0;
}
在这个实现中,我们对基础的冒泡排序做了一点优化:
-
引入了一个标志变量
swapped
,用于标记在一轮冒泡中是否发生了交换。 -
如果在一轮冒泡中没有发生任何交换,说明数组已经有序,可以提前结束排序。
-
每轮冒泡结束后,检查
swapped
变量,如果它为false
,说明没有发生交换,可以通过break
语句提前结束外层循环。
这个优化可以在数组已经有序或者接近有序的情况下,大大减少不必要的比较和交换操作,提高排序的效率。
在main
函数中,我们先打印原始数组,然后调用bubbleSort
函数进行排序,最后再打印排序后的数组,以验证排序的正确性。
值得一提的是这行代码:
// 计算数组的长度
int n = sizeof(arr)/sizeof(arr[0]);
在C++中,我们经常需要计算数组的长度。但是,与其他一些编程语言不同,C++没有提供直接获取数组长度的方法。这行代码使用了一个常见的技巧来计算数组的长度。
让我们分解这个表达式:
-
sizeof(arr)
: 这个表达式返回整个数组arr
在内存中占用的字节数。 -
sizeof(arr[0])
: 这个表达式返回数组arr
中单个元素占用的字节数。 -
sizeof(arr)/sizeof(arr[0])
: 这个表达式用整个数组占用的字节数除以单个元素占用的字节数,得到的结果就是数组中元素的个数,也就是数组的长度。
这个方法可以适用于任何类型的数组,因为sizeof
运算符可以返回任何类型的变量或数组占用的字节数。
使用这种方法计算数组长度的优点是,如果我们改变了数组的大小,不需要手动更新长度变量,这个表达式会自动计算出新的长度。这使得我们的代码更加灵活和menos容易出错。
然而,这个方法也有一个限制:它只能用于在函数内部定义的数组,对于作为函数参数传递的数组,这种方法不再适用,因为数组作为参数传递时会退化为指针,sizeof
运算符返回的是指针的大小,而不是数组的大小。在这种情况下,我们通常需要显式地将数组的长度作为另一个参数传递给函数。
1.3 时间复杂度和空间复杂度分析
时间复杂度:
冒泡排序的时间复杂度取决于数组的初始状态。让我们考虑最好情况、平均情况和最坏情况。
-
最好情况:
如果数组已经是有序的(升序或降序),那么在第一次遍历后,内层循环不会进行任何交换操作,因为所有元素已经在正确的位置上。在这种情况下,冒泡排序只需要进行 n-1 次比较,时间复杂度为 O(n)。这是冒泡排序的最佳时间复杂度。
-
平均情况和最坏情况:
在平均情况和最坏情况下,冒泡排序需要进行两个嵌套循环。外层循环运行 n-1 次,每次确定一个元素的最终位置。内层循环比较相邻的元素,每次外层循环都需要进行 n-i-1 次比较,其中 i 是外层循环的当前索引。因此,总的比较次数是 (n-1) + (n-2) + … + 2 + 1 = n(n-1)/2,约等于 n^2/2。因此,冒泡排序的平均和最坏情况下的时间复杂度都是 O(n^2)。
综上所述,冒泡排序的时间复杂度为:
- 最好情况: O(n)
- 平均情况: O(n^2)
- 最坏情况: O(n^2)
空间复杂度:
冒泡排序是一种原地排序算法,它只需要常量级别的额外空间。在排序过程中,我们只需要一个临时变量来存储交换时的中间值。因此,冒泡排序的空间复杂度为 O(1),即只需要常量级别的额外空间。
需要注意的是,尽管冒泡排序的最佳时间复杂度是 O(n),但这种情况很少出现。在大多数情况下,冒泡排序的时间复杂度都是 O(n^2),这使得它在处理大型数据集时效率较低。然而,由于冒泡排序的简单性和易于理解,它仍然是一个重要的排序算法,特别是在教学和学习排序算法的概念时。
在实际应用中,对于小型数据集或者几乎有序的数据集,冒泡排序可能是一个不错的选择。但对于大型数据集,我们通常会选择更高效的排序算法,如快速排序、归并排序等。
1.4 优化方法
虽然冒泡排序是一个简单而直观的排序算法,但其时间复杂度较高,尤其在处理大型数据集时效率较低。然而,我们可以对基本的冒泡排序算法进行一些优化,以提高其性能。下面我们来看看两种常见的优化方法。
提前终止
在前面的实现中,我们已经介绍了这种优化。基本思路是,如果在一次遍历中没有发生任何交换,那么说明数组已经有序,我们可以提前终止算法。
这可以通过在每次遍历开始时设置一个标志变量swapped
来实现。如果在遍历过程中发生了交换,则将swapped
设置为true
。如果完成一次遍历后swapped
仍为false
,则说明数组已经有序,我们可以终止算法。
以下是优化后的冒泡排序C++代码:
void bubbleSort(int arr[], int n) {
int i, j;
bool swapped;
for (i = 0; i < n-1; i++) {
swapped = false;
for (j = 0; j < n-i-1; j++) {
if (arr[j] > arr[j+1]) {
swap(arr[j], arr[j+1]);
swapped = true;
}
}
if (swapped == false)
break;
}
}
2. 选择排序(Selection Sort)
2.1 算法原理
选择排序是一种简单直观的排序算法。它的工作原理如下:首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。
具体步骤如下:
-
初始状态:无序区为R[1…n],有序区为空;
-
第i趟排序(i=1,2,3…n-1)开始时,当前有序区和无序区分别为R[1…i-1]和R(i…n)。该趟排序从当前无序区中选出关键字最小的记录 R[k],将它与无序区的第1个记录R交换,使R[1…i]和R[i+1…n)分别变为记录个数增加1个的新有序区和记录个数减少1个的新无序区;
-
n-1趟结束,数组有序化了。
示意图:
以数组 arr = [64, 25, 12, 22, 11] 为例,选择排序的过程如下:
第一次从数组 [64, 25, 12, 22, 11] 中选择最小值 11,与第一个元素 64 交换位置,数组变为 [11, 25, 12, 22, 64]。
第二次从数组 [25, 12, 22, 64] 中选择最小值 12,与第二个元素 25 交换位置,数组变为 [11, 12, 22, 64]。
第三次从数组 [22, 64] 中选择最小值 22,与第三个元素 22 交换位置,数组变为 [11, 12, 22, 64]。
第四次从数组 [64] 中选择最小值 64,与第四个元素 64 交换位置,数组变为 [11, 12, 22, 64]。
在每一轮选择中,我们都是从当前无序区中选择最小的元素,然后将其放到有序区的末尾。
选择排序是一种不稳定的排序方法,也就是说,多个相同的值的相对位置也许会在算法结束时产生变动。
在最好情况下,时间复杂度为O(n2)。在最坏情况下,时间复杂度也为O(n2)。因此,选择排序的时间复杂度始终为O(n^2),不管输入数据是什么,时间复杂度都不会改变。
选择排序的主要优点与冒泡排序类似,都是代码简单,容易实现。但缺点也很明显,即使是最好情况,其时间复杂度也是O(n^2)。不适合大规模的数据排序。
2.2 代码实现
以下是选择排序的C++实现:
#include <iostream>
using namespace std;
// 选择排序函数
void selectionSort(int arr[], int n) {
int i, j, minIndex, temp;
for (i = 0; i < n-1; i++) {
minIndex = i;
for (j = i+1; j < n; j++) {
if (arr[j] < arr[minIndex]) {
minIndex = j;
}
}
// 交换arr[i]和arr[minIndex]
temp = arr[i];
arr[i] = arr[minIndex];
arr[minIndex] = temp;
}
}
// 打印数组函数
void printArray(int arr[], int size) {
int i;
for (i = 0; i < size; i++)
cout << arr[i] << " ";
cout << endl;
}
// 测试代码
int main() {
int arr[] = {64, 25, 12, 22, 11};
int n = sizeof(arr)/sizeof(arr[0]);
cout << "原始数组: \n";
printArray(arr, n);
selectionSort(arr, n);
cout << "排序后的数组: \n";
printArray(arr, n);
return 0;
}
在这个实现中,我们定义了一个selectionSort
函数,它接受一个数组arr
和数组的大小n
作为参数。
函数的主要部分包含两个嵌套的循环:
-
外层循环
i
从0到n-2
(最后一个元素自然就位,不需要选择),表示当前正在选择的位置。 -
内层循环
j
从i+1
到n-1
,在当前未排序的部分中找到最小的元素。
在内层循环中,我们通过比较arr[j]
和arr[minIndex]
来更新minIndex
,以跟踪最小元素的索引。
在内层循环结束后,minIndex
就是当前未排序部分的最小元素的索引。然后,我们将这个最小元素与当前位置的元素(arr[i]
)交换。
这个过程不断重复,直到整个数组都被排序。
在main
函数中,我们先打印原始数组,然后调用selectionSort
函数进行排序,最后再打印排序后的数组,以验证排序的正确性。
选择排序的优点是简单直观,容易实现。但是,由于它的时间复杂度始终为O(n^2),在处理大型数据集时效率较低。尽管如此,对于小型数据集或者作为更高效算法的子程序,选择排序仍然有其用武之地。
理解选择排序的原理和实现是学习更高级排序算法的基础。在后续的讨论中,我们将介绍更高效的排序算法,如归并排序和快速排序。
2.3 时间复杂度和空间复杂度分析
现在让我们分析选择排序的时间复杂度和空间复杂度。
时间复杂度:
选择排序的时间复杂度不依赖于数据的初始状态,无论数组是已经有序还是完全逆序,选择排序都将执行相同数量的比较和交换。
在选择排序中,我们有两个嵌套的循环:
-
外层循环运行
n-1
次,其中n
是数组的大小。这是因为最后一个元素将自动就位,不需要选择。 -
对于每次外层循环,内层循环运行
n-i-1
次,其中i
是当前外层循环的索引。这是因为在每次外层循环中,我们需要在未排序的部分(从i+1
到n-1
)中找到最小的元素。
因此,总的比较次数是:
(n-1) + (n-2) + (n-3) + … + 2 + 1
= n(n-1)/2
= (n^2-n)/2
这意味着,无论输入数据如何,选择排序总是执行大约n^2/2
次比较。因此,选择排序的时间复杂度为O(n^2)。
需要注意的是,尽管选择排序和冒泡排序有相同的时间复杂度,但选择排序通常执行的交换操作要少得多。在选择排序中,每次外层循环只执行一次交换,总共执行n-1
次交换。相比之下,冒泡排序可能执行多达n(n-1)/2
次交换。这使得选择排序在某些情况下可能比冒泡排序更受欢迎,特别是当交换操作的代价很高时。
空间复杂度:
选择排序是一种原地排序算法,它只需要恒定的额外空间。在排序过程中,我们只需要一个临时变量来存储最小元素的索引和一个临时变量来执行交换。因此,选择排序的空间复杂度为O(1)。
总之,选择排序的时间复杂度为O(n^2),空间复杂度为O(1)。这使得它不适合于大型数据集,但对于小型数据集或者作为更复杂排序算法的子程序,它可能是一个不错的选择。
理解选择排序的复杂度分析,可以帮助我们在实际问题中选择合适的排序算法。在后续的讨论中,我们将看到一些时间复杂度更低的排序算法,如归并排序和快速排序,它们更适合处理大型数据集。
3. 插入排序(Insertion Sort)
3.1 算法原理
插入排序(Insertion Sort)是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
插入排序的基本思想是:每次从无序序列中取出一个元素,把它插入到有序序列的适当位置,使有序序列仍然有序。
具体步骤如下:
-
从第一个元素开始,该元素可以认为已经被排序。
-
取出下一个元素,在已经排序的元素序列中从后向前扫描。
-
如果该元素(已排序)大于新元素,将该元素移到下一位置。
-
重复步骤3,直到找到已排序的元素小于或者等于新元素的位置。
-
将新元素插入到该位置后。
-
重复步骤2~5,直到所有元素都插入完毕。
示意图:
以数组 arr = [12, 11, 13, 5, 6] 为例,插入排序的过程如下:
第一次:[12] 11 13 5 6
第一个元素 12 已经在正确的位置,不需要插入。数组仍然是 [12, 11, 13, 5, 6]。
第二次:[11, 12] 13 5 6
第二个元素 11 比第一个元素 12 小,将 11 插入到 12 前面。数组变为 [11, 12, 13, 5, 6]。
第三次:11 12 [13] 5 6
第三个元素 13 比第二个元素 12 大,不需要移动。数组仍然是 [11, 12, 13, 5, 6]。
第四次:11 12 13 [5] 6
第四个元素 5 比第三个元素 13 小,需要向前找到正确的插入位置。
11 12 [5] 13 6
11 [5] 12 13 6
[5] 11 12 13 6
最终 5 被插入到第一个位置。数组变为 [5, 11, 12, 13, 6]。
第五次:5 11 12 13 [6]
第五个元素 6 比第四个元素 13 小,需要向前找到正确的插入位置。
5 11 12 [6] 13
5 11 [6] 12 13
数组最终变为 [5, 6, 11, 12, 13]。
在每一步插入中,我们都要反复移动已经排好序的元素,为新元素腾出空间,然后再插入新元素。
插入排序在实现上,通常采用in-place排序(即只需用到O(1)的额外空间的排序),因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。
尽管插入排序是简单直观的,但在数据量较大时,由于每次插入操作都需要反复移动元素,效率较低。但对于数据量较小的数据集,或者部分有序的数据集,插入排序的效率可能比较高,有时甚至高于那些更加复杂的排序算法。
好的,这里给出插入排序的C++实现:
#include <iostream>
using namespace std;
// 插入排序函数
void insertionSort(int arr[], int n) {
int i, key, j;
for (i = 1; i < n; i++) {
key = arr[i];
j = i - 1;
/* 移动元素 arr[0..i-1],如果它们比key大,
则将它们的位置向后移动一位 */
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j = j - 1;
}
arr[j + 1] = key;
}
}
// 打印数组函数
void printArray(int arr[], int n) {
int i;
for (i = 0; i < n; i++)
cout << arr[i] << " ";
cout << endl;
}
// 测试代码
int main() {
int arr[] = { 12, 11, 13, 5, 6 };
int n = sizeof(arr) / sizeof(arr[0]);
cout << "原始数组: \n";
printArray(arr, n);
insertionSort(arr, n);
cout << "排序后的数组: \n";
printArray(arr, n);
return 0;
}
在这个实现中,我们定义了一个insertionSort
函数,它接受一个数组arr
和数组的大小n
作为参数。
函数的主要部分是一个for循环,它从索引1开始遍历到索引n-1。这是因为我们将第一个元素视为一个已排序的子数组,然后从第二个元素开始插入。
在每次迭代中:
-
我们首先将当前元素
arr[i]
存储在变量key
中。 -
然后,我们初始化另一个变量
j
为i-1
,它表示已排序子数组的最后一个元素。 -
我们使用一个while循环,将
key
与已排序子数组中的元素进行比较。如果arr[j]
大于key
,我们就将arr[j]
向后移动一位。我们不断向前移动j
,直到找到一个小于或等于key
的元素,或者已经到达子数组的开头。 -
在while循环结束后,我们将
key
插入到正确的位置,即arr[j+1]
。
这个过程不断重复,直到我们遍历完整个数组。
在main
函数中,我们先打印原始数组,然后调用insertionSort
函数进行排序,最后再打印排序后的数组,以验证排序的正确性。
插入排序的优点是简单直观,容易实现。当数组已经部分有序时,插入排序可以提供相当好的性能。特别是当数组较小时,插入排序可能比更复杂的排序算法更有效。然而,对于大型数组,由于需要移动大量元素,插入排序的性能可能会下降。
在后续的讨论中,我们将介绍更高效的排序算法,如归并排序和快速排序,它们可以更好地处理大型数据集。
3.3 时间复杂度和空间复杂度分析
现在让我们分析插入排序的时间复杂度和空间复杂度。
时间复杂度:
插入排序的时间复杂度取决于数组的初始状态。我们将考虑最好情况、最坏情况和平均情况。
-
最好情况:
当数组已经是升序排列时,插入排序的最佳时间复杂度发生。在这种情况下,内层循环将永远不会执行,因为每个元素已经大于它前面的元素。因此,插入排序只需要进行
n-1
次比较,时间复杂度为O(n)。 -
最坏情况:
当数组是降序排列时,插入排序的最差时间复杂度发生。在这种情况下,每个元素都需要与它前面的所有元素进行比较。对于第
i
个元素,需要进行i-1
次比较。因此,总的比较次数是:(n-1) + (n-2) + (n-3) + … + 2 + 1 = n(n-1)/2 = (n^2-n)/2
因此,在最坏情况下,插入排序的时间复杂度为O(n^2)。
-
平均情况:
在平均情况下,我们假设数组是随机排列的。在这种情况下,每个元素平均需要比较一半的元素。因此,平均情况下的时间复杂度也是O(n^2)。
综上所述,插入排序的时间复杂度为:
- 最好情况: O(n)
- 最坏情况: O(n^2)
- 平均情况: O(n^2)
空间复杂度:
插入排序是一种原地排序算法,它只需要恒定的额外空间。在排序过程中,我们只需要一个临时变量来存储当前正在插入的元素。因此,插入排序的空间复杂度为O(1)。
总之,插入排序的时间复杂度在最好情况下是O(n),在最坏和平均情况下是O(n^2)。它的空间复杂度是O(1)。这使得插入排序成为小型数据集或几乎有序的数据集的好选择。
然而,对于大型数据集,O(n^2)的时间复杂度可能无法满足性能要求。在这些情况下,我们通常转向更高效的排序算法,如归并排序或快速排序,它们的平均时间复杂度为O(n log n)。
尽管如此,理解插入排序仍然很重要,因为它是更复杂排序算法的基础,并且在某些情况下可能更受欢迎,例如当数据几乎有序时。此外,许多高级排序算法,如希尔排序和内省排序,都是基于插入排序的思想。
4. 希尔排序(Shell Sort)
4.1 算法原理
希尔排序(Shell Sort)是一种改进版的插入排序。它通过允许远距离的元素交换来克服插入排序的局限性。与插入排序不同,希尔排序不会一次移动一个元素,而是使用一个称为增量(gap)的值来决定在排序过程中一次移动多少个元素。
希尔排序的基本思想是:先将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,待整个序列中的记录"基本有序"时,再对全体记录进行依次直接插入排序。
具体步骤如下:
-
选择一个增量序列 t 1 , t 2 , . . . , t k t_1, t_2, ..., t_k t1,t2,...,tk,其中 t 1 > t 2 , t k = 1 t_1 > t_2, t_k = 1 t1>t2,tk=1。增量序列可以有不同的选择方式,一种常见的选择是 t k = N / 2 k t_k = N/2^k tk=N/2k,其中 N N N 是数组的大小。
-
按增量序列个数 k k k,对序列进行 k k k 趟排序。
-
每趟排序,根据对应的增量 t i t_i ti,将待排序列分割成若干长度为 m m m 的子序列,分别对各子表进行直接插入排序。仅增量因子为 1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
换句话说,希尔排序通过将原始数组分隔成几个较小的子数组来工作,然后使用插入排序对这些子数组进行排序。在对子数组进行排序时,它们的元素比较远,使得较小的元素可以相对快速地向数组的前面移动。
随着算法的进展,增量值逐渐减小,直到最后一次运行时,增量值变为1,此时算法等同于简单的插入排序,但是此时大部分元素已经在正确的位置上,因此此时的插入排序非常高效。
示意图:
以数组 arr = [64, 34, 25, 12, 22, 11, 90] 为例,希尔排序的过程如下:
假设我们选择的增量序列为 {4, 2, 1}。
第一次增量为 4:
sub-array1: 64 | - | - | - | 22 | - | - | -
sub-array2: - | 34 | - | - | - | 11 | - | -
sub-array3: - | - | 25 | - | - | - | 90 | -
sub-array4: - | - | - | 12 | - | - | - | -
在每个子数组内进行插入排序后:
sub-array1: 22 | - | - | - | 64 | - | - | -
sub-array2: - | 11 | - | - | - | 34 | - | -
sub-array3: - | - | 25 | - | - | - | 90 | -
sub-array4: - | - | - | 12 | - | - | - | -
第二次增量为 2:
sub-array1: 22 | - | 64 | - | 25 | - | 90 | -
sub-array2: - | 11 | - | 34 | - | 12 | - | -
在每个子数组内进行插入排序后:
sub-array1: 22 | - | 25 | - | 64 | - | 90 | -
sub-array2: - | 11 | - | 12 | - | 34 | - | -
第三次增量为 1:
此时,整个数组被视为一个子数组,我们对整个数组进行插入排序:
11 12 22 25 34 64 90
希尔排序的性能在很大程度上取决于所选择的增量序列。上面的例子使用了一个非常简单的增量序列,但在实践中,有更复杂和更有效的增量序列,如Knuth序列(1, 4, 13, 40, 121, 364, 1093, …)。
希尔排序的最佳时间复杂度取决于增量序列,在最佳情况下可以达到O(n log n),但这需要非常精心设计的增量序列。在最坏情况下,时间复杂度为O(n log^2 n)或O(n^2),具体取决于增量序列。
希尔排序的主要优点是它可以快速地将一个数组"大致"排序,然后使用一个高效的算法(如插入排序)来完成排序过程。这在处理中等大小和大型数组时特别有用。
好的,这里给出希尔排序的C++实现:
#include <iostream>
using namespace std;
// 希尔排序函数
void shellSort(int arr[], int n) {
// 从大到小计算增量值
for (int gap = n/2; gap > 0; gap /= 2) {
// 对每个增量值执行插入排序
for (int i = gap; i < n; i++) {
int temp = arr[i];
int j;
for (j = i; j >= gap && arr[j - gap] > temp; j -= gap) {
arr[j] = arr[j - gap];
}
arr[j] = temp;
}
}
}
// 打印数组函数
void printArray(int arr[], int n) {
for (int i=0; i<n; i++)
cout << arr[i] << " ";
cout << endl;
}
// 测试代码
int main() {
int arr[] = {64, 34, 25, 12, 22, 11, 90};
int n = sizeof(arr)/sizeof(arr[0]);
cout << "原始数组: \n";
printArray(arr, n);
shellSort(arr, n);
cout << "排序后的数组: \n";
printArray(arr, n);
return 0;
}
在这个实现中,shellSort
函数接受一个整型数组arr
和数组的大小n
作为参数。
函数的主要部分包括两个嵌套循环:
-
外层循环控制增量值的变化。我们从
n/2
开始,每次循环都将增量值减半,直到增量值变为1。 -
内层循环对每个增量值执行插入排序。但与传统的插入排序不同,我们不是比较相邻的元素,而是比较相距一个增量值的元素。
-
我们从索引
gap
开始,一直到数组的末尾。对于每个位置,我们临时存储当前元素的值,并将其与前面相距一个增量值的元素进行比较。 -
如果前面的元素更大,我们就将其向后移动一个增量值的位置。我们不断移动,直到找到一个小于或等于当前元素的元素,或者到达子数组的开头。
-
然后,我们将当前元素插入到正确的位置。
-
这个过程不断重复,直到增量值减为1。当增量值为1时,算法本质上就是一个标准的插入排序,但此时数组已经"大致"有序,因此插入排序会非常高效。
在main
函数中,我们先打印原始数组,然后调用shellSort
函数进行排序,最后再打印排序后的数组,以验证排序的正确性。
希尔排序的一个关键优点是,它在处理中等大小和大型数组时非常高效,因为它可以快速地将元素移动到很长的距离。然而,它的性能在很大程度上取决于所选择的增量序列。虽然上面的实现使用了一个简单的增量序列(每次减半),但在实践中,有更复杂和更有效的增量序列。
在后续的讨论中,我们将介绍更高级的排序算法,如归并排序和快速排序,它们提供了更好的理论性能保证。
4.3 时间复杂度和空间复杂度分析
现在让我们分析希尔排序的时间复杂度和空间复杂度。
时间复杂度:
希尔排序的时间复杂度分析是复杂的,因为它在很大程度上取决于所选择的增量序列。不同的增量序列会导致不同的性能特征。
在最佳情况下,使用精心设计的增量序列,希尔排序可以达到O(n log n)的时间复杂度。然而,找到这样的最佳增量序列是一个未解决的研究问题。
在最坏情况下,希尔排序的时间复杂度取决于增量序列:
-
对于某些增量序列,如Shell最初提出的序列(增量为N/2^k ,其中N是数组大小,k从1开始),最坏情况下的时间复杂度为O(n^2)。
-
对于其他一些增量序列,如Hibbard序列(增量为2^k-1) ,最坏情况下的时间复杂度为O(n^(3/2))。
-
对于Sedgewick提出的增量序列(增量为4k+3*2(k-1)+1 或 4k-3*2(k-1)+1,其中k从1开始),最坏情况下的时间复杂度被证明为O(n^(4/3))。
在平均情况下,希尔排序的时间复杂度也依赖于增量序列,但通常认为它优于插入排序的O(n^2),并且接近于O(n log n)。
需要注意的是,尽管希尔排序的最佳时间复杂度可以达到O(n log n),但这需要非常精心设计的增量序列。在实践中,使用简单的增量序列(如每次减半)通常可以获得不错的性能,特别是对于中等大小的数组。
空间复杂度:
希尔排序是一种原地排序算法,它只需要恒定的额外空间。在排序过程中,我们只需要一些临时变量来存储中间值。因此,希尔排序的空间复杂度为O(1)。
总之,希尔排序的时间复杂度在很大程度上取决于所选择的增量序列,在最佳情况下可以达到O(n log n),但在最坏情况下可能是O(n^2) 或O(n^(3/2)) 或O(n^(4/3)),具体取决于增量序列。它的空间复杂度是O(1)。
希尔排序的主要优点是它在处理中等大小和大型数组时通常比简单的插入排序更有效,因为它允许元素进行远距离的交换。然而,它的性能保证不如一些更高级的排序算法,如归并排序和快速排序,它们提供了O(n log n)的最坏情况时间复杂度。
尽管如此,理解希尔排序仍然很重要,因为它展示了如何通过允许远距离的元素交换来改进插入排序。这种思想启发了其他一些排序算法,如组合排序(Comb Sort)。
5. 归并排序(Merge Sort)
5. 归并排序(Merge Sort)
5.1 算法原理
归并排序(Merge Sort)是一种高效的分治算法。它的基本思想是将一个大的无序数组分割成两个较小的子数组,然后递归地对子数组进行排序,最后将两个有序的子数组合并成一个有序的数组。
归并排序的步骤如下:
-
分割(Divide):如果数组中只有一个元素或者为空,则返回。否则,将数组平均分割成两个子数组。
-
征服(Conquer):递归地对两个子数组进行归并排序。
-
合并(Combine):将两个已排序的子数组合并成一个有序的数组。
归并排序的核心在于合并步骤。合并两个有序数组的过程如下:
-
创建一个临时数组,其大小为两个子数组的大小之和。
-
设置两个指针,初始位置分别为两个子数组的起始位置。
-
比较两个指针所指向的元素,选择较小的元素放入临时数组,并将指针向后移动。
-
重复步骤3,直到其中一个子数组的元素全部放入临时数组。
-
将另一个子数组的剩余元素直接复制到临时数组的末尾。
-
将临时数组中的元素复制回原始数组。
示意图:
以数组 arr = [64, 34, 25, 12, 22, 11, 90] 为例,归并排序的过程如下:
[64, 34, 25, 12, 22, 11, 90]
/ \
[64, 34, 25, 12] [22, 11, 90]
/ \ / \
[64, 34] [25, 12] [22, 11] [90]
/ \ / \ / \
[64] [34] [25] [12] [22] [11] [90]
\ / \ / \ /
[34, 64] [12, 25] [11, 22] [90]
\ / \ /
[12, 25, 34, 64] [11, 22, 90]
\ /
[11, 12, 22, 25, 34, 64, 90]
在上图中,原始数组首先被分割成两个子数组,然后每个子数组再递归地进行分割,直到每个子数组只包含一个元素。然后,相邻的子数组被合并成有序的子数组,最后所有的子数组被合并成一个有序的数组。
归并排序是一种稳定的排序算法,这意味着相等的元素在排序后保持其相对顺序不变。
归并排序的时间复杂度为 O(n log n),其中 n 是数组的大小。这是因为算法将数组分割成两个子数组,然后递归地对子数组进行排序。在每个层次上,合并步骤需要线性时间,总共有 log n 个层次。
归并排序的主要优点是保证了最坏情况下 O(n log n) 的时间复杂度,并且是一种稳定的排序算法。然而,它的缺点是需要额外的空间来存储临时数组。
好的,这里给出归并排序的C++实现:
#include <iostream>
#include <vector>
using namespace std;
void merge(vector<int>& a, int left, int right, int mid);
void msort(vector<int>& a, int left, int right);
void merge(vector<int>& a, int left, int right, int mid) {
int n1 = mid - left + 1;
int n2 = right - mid;
vector<int> L(n1), R(n2); // 创建左右两个临时向量
// 复制数据到临时向量 L[] 和 R[]
for (int i = 0; i < n1; i++)
L[i] = a[left + i];
for (int j = 0; j < n2; j++)
R[j] = a[mid + 1 + j];
// 合并临时向量到原向量 a[]
int i = 0, j = 0;
int k = left;
while (i < n1 && j < n2) {
if (L[i] <= R[j]) {
a[k] = L[i];
i++;
} else {
a[k] = R[j];
j++;
}
k++;
}
// 复制剩余的元素
while (i < n1) {
a[k] = L[i];
i++;
k++;
}
while (j < n2) {
a[k] = R[j];
j++;
k++;
}
}
void msort(vector<int>& a, int left, int right) {
if (left >= right) return;
int mid = left + (right - left) / 2;
msort(a, left, mid);
msort(a, mid + 1, right);
merge(a, left, right, mid);
}
int main() {
int n;
cin >> n;
vector<int> a(n); // 使用 vector<int>
for (int i = 0; i < n; i++) {
cin >> a[i];
}
msort(a, 0, n - 1);
for (int i = 0; i < n; i++) {
cout << a[i] << " ";
}
cout << endl;
return 0;
}
在这个实现中,mergeSort
函数是主函数,它递归地分割数组直到子数组的大小为1,然后调用merge
函数合并两个有序的子数组。
merge
函数是合并两个有序子数组的关键。它首先将两个子数组复制到两个临时数组L
和R
中。然后,它比较L
和R
中的元素,选择较小的元素放入原始数组中。当一个临时数组为空时,它将另一个临时数组的剩余元素复制到原始数组中。
在main
函数中,我们首先打印原始数组,然后调用mergeSort
函数对整个数组进行排序,最后打印排序后的数组以验证排序的正确性。
归并排序是一种高效且稳定的排序算法,适用于各种情况,特别是当数据量很大且需要稳定性时。然而,由于它需要额外的空间来存储临时数组,因此在空间受限的情况下,它可能不是最佳选择。
在接下来的讨论中,我们将介绍另一种高效的排序算法:快速排序。
5.3 时间复杂度和空间复杂度分析
现在让我们分析归并排序的时间复杂度和空间复杂度。
时间复杂度:
归并排序是一种分治算法,它将问题分成子问题,解决子问题,然后合并结果。归并排序的时间复杂度可以用递归方程表示:
T(n) = 2T(n/2) + O(n)
这个方程表示,归并排序将问题分成两个子问题,每个子问题的大小是原问题的一半(2T(n/2)),然后在线性时间内合并两个子问题的解(O(n))。
求解这个递归方程,我们可以得到:
T(n) = O(n log n)
这意味着,归并排序的时间复杂度在所有情况下都是 O(n log n),包括最好情况、平均情况和最坏情况。这是因为,无论输入数据的初始状态如何,归并排序总是将问题分成两个子问题,直到子问题的大小为1,然后在合并阶段中执行线性操作。
这是归并排序相对于一些其他排序算法(如快速排序)的一个优点,快速排序在最坏情况下的时间复杂度为 O(n^2)。
空间复杂度:
归并排序的一个缺点是,它需要额外的空间来存储临时数组。在合并阶段,我们需要一个额外的数组来存储合并的结果。这个额外的数组的大小与输入数组的大小相同。
因此,归并排序的空间复杂度是 O(n)。这可能在某些情况下是一个问题,特别是当内存限制较严格时。
然而,有一种归并排序的变体,叫做"原地归并排序"(In-place Merge Sort),它通过一些智能的技巧来避免使用额外的空间。但这种方法的实现较为复杂,而且会改变相等元素的相对顺序,因此不再是一种稳定的排序算法。
总之,归并排序的时间复杂度在所有情况下都是 O(n log n),这使得它成为一种非常高效的排序算法。然而,它的空间复杂度为 O(n),这可能在某些情况下是一个缺点。尽管如此,在大多数情况下,归并排序仍然是一个非常好的选择,特别是当稳定性很重要时。
在之后的讨论中,我们将介绍快速排序,它在实践中通常比归并排序更快,但在最坏情况下的时间复杂度为 O(n^2),并且不是一种稳定的排序算法。
6. 快速排序(Quick Sort)
6.1 算法原理
快速排序(Quick Sort)是一种高效的分治排序算法。它的基本思想是选择一个元素作为"基准"(pivot),然后围绕这个基准重新排列数组,使得所有小于基准的元素都在基准的左边,所有大于基准的元素都在基准的右边。这个过程称为分区(partition)。如果我们递归地对基准左右的子数组应用同样的过程,整个数组最终将会被排序。
快速排序的步骤如下:
-
如果数组中的元素少于2个,则返回。这是递归的基本情况。
-
否则,选择一个元素作为基准。这个元素可以是数组的第一个元素,最后一个元素,中间的元素,或者随机选择的元素。
-
分区:重新排列数组,使得所有小于基准的元素都在基准的左边,所有大于基准的元素都在基准的右边。基准元素放在它最后的位置。这一步完成后,数组被分成两个子数组。
-
递归地对左右子数组应用同样的步骤,直到整个数组都被排序。
示意图:
以数组 arr = [64, 34, 25, 12, 22, 11, 90] 为例,选择第一个元素64作为基准,快速排序的过程如下:
第一次分区:
pivot = 64
pivot
|
[64, 34, 25, 12, 22, 11, 90]
| |
11 < pivot 90 > pivot
swap
[11, 34, 25, 12, 22, 64, 90]
| |
34 < pivot 22 < pivot
swap
[11, 22, 25, 12, 34, 64, 90]
| |
25 < pivot 12 < pivot
swap
[11, 22, 12, 25, 34, 64, 90]
| |
12 < pivot
swap
[11, 22, 12, 25, 34, 64, 90]
左子数组: [11, 22, 12, 25, 34]
右子数组: [90]
对左子数组递归应用快速排序:
[11, 22, 12, 25, 34]
| | |
11 12 25
pivot
[11, 12, 22, 25, 34]
左子数组: [11]
右子数组: [22, 25, 34]
对右子数组递归应用快速排序:
[22, 25, 34]
| |
22 25
pivot
[22, 25, 34]
左子数组: []
右子数组: [34]
此时,整个数组都被排序:
[11, 12, 22, 25, 34, 64, 90]
快速排序的最佳时间复杂度为O(n log n),这发生在每次分区都将数组分成两个大小相等的子数组时。然而,在最坏情况下,如果每次分区都选择最小或最大的元素作为基准,快速排序会退化为O(n^2)。
快速排序的平均时间复杂度为O(n log n),它通常比同样平均复杂度的归并排序要快,因为它的内循环(inner-loop)可以在大多数架构上很有效地被实现。
快速排序是一种不稳定的排序算法,因为在分区过程中,相等的元素可能会被交换,从而改变它们的相对顺序。
快速排序的主要优点是它的速度。在实践中,它通常比其他O(n log n)算法更快,因为它的内部循环(比较和交换)很简单。然而,在最坏情况下,它的性能可能会退化到O(n^2),而其他一些算法,如堆排序和归并排序,可以保证O(n log n)的最坏情况性能。此外,快速排序是递归的,在某些情况下可能会导致堆栈溢出。
好的,这里给出快速排序的C++实现:
#include <iostream>
using namespace std;
// 交换两个元素
void swap(int* a, int* b) {
int t = *a;
*a = *b;
*b = t;
}
// 分区函数
int partition(int arr[], int low, int high) {
int pivot = arr[high]; // 选择最后一个元素作为基准
int i = (low - 1); // 指向比基准小的元素的指针
for (int j = low; j <= high - 1; j++) {
// 如果当前元素小于或等于基准
if (arr[j] <= pivot) {
i++; // 增加指向比基准小的元素的指针
swap(&arr[i], &arr[j]);
}
}
swap(&arr[i + 1], &arr[high]);
return (i + 1);
}
// 快速排序函数
void quickSort(int arr[], int low, int high) {
if (low < high) {
// 分区
int pi = partition(arr, low, high);
// 分别对两个子数组进行排序
quickSort(arr, low, pi - 1);
quickSort(arr, pi + 1, high);
}
}
// 打印数组
void printArray(int arr[], int size) {
int i;
for (i = 0; i < size; i++)
cout << arr[i] << " ";
cout << endl;
}
// 测试代码
int main() {
int arr[] = {64, 34, 25, 12, 22, 11, 90};
int n = sizeof(arr) / sizeof(arr[0]);
cout << "原始数组: \n";
printArray(arr, n);
quickSort(arr, 0, n - 1);
cout << "排序后的数组: \n";
printArray(arr, n);
return 0;
}
在这个实现中,quickSort
函数是主函数,它递归地调用自身来对子数组进行排序。
partition
函数是快速排序的关键。它选择最后一个元素作为基准(pivot),然后将数组分为两部分:一部分是所有小于等于基准的元素,另一部分是所有大于基准的元素。这是通过维护一个指针i
来实现的,i
指向最后一个小于等于基准的元素。在遍历数组的过程中,每当我们发现一个小于等于基准的元素,我们就将其与i
指向的元素交换,并增加i
。在遍历结束时,我们将基准元素与i+1
指向的元素交换,这样基准元素就处于正确的位置。最后,函数返回基准元素的索引。
swap
函数用于交换两个元素。
在main
函数中,我们首先打印原始数组,然后对整个数组调用quickSort
函数,最后打印排序后的数组以验证排序的正确性。
快速排序是一种高效且广泛使用的排序算法。它的平均时间复杂度为O(n log n),在实践中通常比其他同样复杂度的排序算法更快。然而,它的最坏情况时间复杂度为O(n^2),并且它是一种不稳定的排序算法。
在下一部分,我们将讨论快速排序的时间复杂度和空间复杂度。
6.3 时间复杂度和空间复杂度分析
现在让我们分析快速排序的时间复杂度和空间复杂度。
时间复杂度:
快速排序的时间复杂度取决于分区的质量,也就是每次分区后,基准元素的位置。
-
最佳情况:
如果每次分区都将数组分成两个大小相等的子数组,那么快速排序的时间复杂度就是最佳的。在这种情况下,递归树的深度为log n,在每一层上,我们需要处理所有的n个元素。因此,最佳情况下的时间复杂度为O(n log n)。
-
最坏情况:
如果每次分区都选择最小或最大的元素作为基准,那么分区将非常不平衡。在最坏的情况下,分区可能总是将数组分成一个大小为0的子数组和一个大小为n-1的子数组。在这种情况下,递归树的深度为n,我们需要在每一层处理所有的n个元素。因此,最坏情况下的时间复杂度为O(n^2)。
-
平均情况:
在平均情况下,我们假设每次分区都能合理地分割数组。在这种情况下,快速排序的时间复杂度为O(n log n)。这可以使用数学方法证明,但直观地说,在每次分区中,我们需要处理所有的n个元素,而递归树的深度大约为log n。
需要注意的是,最坏情况在实践中很少发生,特别是当我们使用随机选择基准元素的策略时。但是,对于某些特定的输入(如已经排序或反向排序的数组),最坏情况可能会发生。
空间复杂度:
快速排序的空间复杂度主要由递归引起,递归的深度决定了栈空间的使用量。
在最佳情况下,递归树的深度为log n,因此空间复杂度为O(log n)。
在最坏的情况下,递归树的深度可能为n,因此空间复杂度为O(n)。
在平均情况下,递归树的深度为log n,因此空间复杂度为O(log n)。
需要注意的是,快速排序的原地版本(in-place version)只需要O(1)的额外空间,因为它使用了尾递归,可以被编译器优化为迭代。
总之,快速排序的平均时间复杂度为O(n log n),最坏情况下为O(n^2)。它的平均空间复杂度为O(log n),最坏情况下为O(n)。尽管在最坏情况下,快速排序的性能可能会退化,但它在实践中通常被认为是最快的排序算法之一,特别是当我们使用随机化策略来选择基准元素时。
6.4 优化方法
虽然快速排序在平均情况下已经非常快,但我们仍然可以使用一些优化技巧来进一步提高其性能,特别是在处理某些特殊情况时。以下是一些常见的优化方法:
-
三数取中(Median-of-three):
在选择基准元素时,我们可以比较数组的第一个、中间和最后一个元素,然后选择它们的中位数作为基准。这有助于避免在已经排序或几乎排序的数组上出现最坏情况。
int medianOfThree(int arr[], int low, int high) { int mid = low + (high - low) / 2; if (arr[mid] < arr[low]) swap(&arr[mid], &arr[low]); if (arr[high] < arr[low]) swap(&arr[high], &arr[low]); if (arr[high] < arr[mid]) swap(&arr[high], &arr[mid]); swap(&arr[mid], &arr[high - 1]); return arr[high - 1]; }
-
随机化(Randomization):
我们可以随机选择一个元素作为基准,而不是总是选择最后一个元素。这有助于避免在某些特定的输入上出现最坏情况。
int randomPartition(int arr[], int low, int high) { int pivot = rand() % (high - low + 1) + low; swap(&arr[pivot], &arr[high]); return partition(arr, low, high); }
-
小数组使用插入排序:
对于小的子数组(例如,大小小于10的子数组),使用插入排序可能会更快。这是因为快速排序在小数组上的递归调用开销可能超过了其带来的好处。
void insertionSort(int arr[], int low, int high) { for (int i = low + 1; i <= high; i++) { int key = arr[i]; int j = i - 1; while (j >= low && arr[j] > key) { arr[j + 1] = arr[j]; j--; } arr[j + 1] = key; } } void quickSort(int arr[], int low, int high) { while (low < high) { if (high - low + 1 < 10) { insertionSort(arr, low, high); break; } else { int pivot = partition(arr, low, high); if (pivot - low < high - pivot) { quickSort(arr, low, pivot - 1); low = pivot + 1; } else { quickSort(arr, pivot + 1, high); high = pivot - 1; } } } }
-
三路划分(Three-way Partitioning):
当数组中包含大量重复元素时,我们可以使用三路划分来提高性能。这种方法将数组分为三部分:小于基准的元素、等于基准的元素和大于基准的元素。
void threeWayQuickSort(int arr[], int low, int high) { if (low < high) { int pivot = arr[high]; int i = low - 1, j = high, p = low - 1, q = high; while (true) { while (arr[++i] < pivot); while (pivot < arr[--j]) if (j == low) break; if (i >= j) break; swap(&arr[i], &arr[j]); if (arr[i] == pivot) swap(&arr[++p], &arr[i]); if (arr[j] == pivot) swap(&arr[--q], &arr[j]); } swap(&arr[i], &arr[high]); j = i - 1; i = i + 1; for (int k = low; k <= p; k++, j--) swap(&arr[k], &arr[j]); for (int k = high - 1; k >= q; k--, i++) swap(&arr[i], &arr[k]); threeWayQuickSort(arr, low, j); threeWayQuickSort(arr, i, high); } }
这些优化方法在某些情况下可以显著提高快速排序的性能。然而,它们也增加了代码的复杂性。在实践中,标准的快速排序实现通常已经足够快,除非你正在处理非常大的数据集或有特殊的性能要求。
快速排序的优化是一个很大的话题,还有许多其他的优化技巧,如基于切换到堆排序的内省式排序(Introsort)等。这些优化方法各有其优点和复杂性,需要根据具体的使用场景来选择。
7. 堆排序(Heap Sort)
7.1 算法原理
堆排序(Heap Sort)是一种基于二叉堆数据结构的比较排序算法。该算法的基本思想是将数组构建成一个最大堆(或最小堆),然后反复从堆顶取出最大(或最小)元素并将其放到数组的末尾,同时调整堆以保持最大堆(或最小堆)的性质。
堆排序的步骤如下:
-
构建最大堆(或最小堆):
从最后一个非叶子节点开始,自底向上地比较和交换元素,使每个节点的值都大于(或小于)其子节点的值。
-
排序:
- 交换堆顶元素(最大值或最小值)与堆的最后一个元素。
- 将堆的大小减1,并调整堆以保持最大堆(或最小堆)的性质。
- 重复上述步骤直到堆的大小为1。
在堆排序中,我们使用数组来表示堆。对于索引为i
的节点:
- 其父节点的索引为
(i-1)/2
。 - 其左子节点的索引为
2*i+1
。 - 其右子节点的索引为
2*i+2
。
示意图:
以数组 arr = [4, 10, 3, 5, 1] 为例,使用最大堆进行堆排序的过程如下:
构建最大堆:
4
/ \
10 3
/ \
5 1
交换 4 和 10:
10
/ \
4 3
/ \
5 1
调整堆:
10
/ \
5 3
/ \
4 1
交换 10 和 1:
1
/ \
5 3
/ \
4 10
调整堆:
5
/ \
1 3
/ \
4 10
交换 5 和 10:
10
/ \
1 3
/
4
5
调整堆:
4
/ \
1 3
10
5
交换 4 和 5:
5
/ \
1 3
4
10
调整堆:
3
/ \
1 5
4
10
交换 3 和 10:
10
/ \
1 5
3
4
最终的排序结果:
[1, 3, 4, 5, 10]
堆排序的时间复杂度为O(n log n),其中n是数组的长度。这是因为构建初始堆需要O(n)的时间,而在排序阶段,每次从堆中取出最大(或最小)元素并调整堆都需要O(log n)的时间,总共需要进行n-1次。
堆排序的主要优点是它的时间复杂度保证为O(n log n),而且它在实践中通常比快速排序更快。此外,堆排序是一种原地排序算法,它只需要恒定的额外空间。
然而,堆排序的缺点是它不是一种稳定的排序算法,即相等的元素在排序后的相对位置可能会改变。此外,堆排序的实现相对于一些其他排序算法(如快速排序)来说可能更复杂一些。
好的,这里给出堆排序的C++实现:
#include <iostream>
using namespace std;
// 调整堆
void heapify(int arr[], int n, int i) {
int largest = i; // 初始化最大值为根节点
int l = 2*i + 1; // 左子节点
int r = 2*i + 2; // 右子节点
// 如果左子节点大于根节点
if (l < n && arr[l] > arr[largest])
largest = l;
// 如果右子节点大于当前最大值
if (r < n && arr[r] > arr[largest])
largest = r;
// 如果最大值不是根节点
if (largest != i) {
swap(arr[i], arr[largest]);
// 递归调整受影响的子树
heapify(arr, n, largest);
}
}
// 堆排序主函数
void heapSort(int arr[], int n) {
// 构建最大堆
for (int i = n / 2 - 1; i >= 0; i--)
heapify(arr, n, i);
// 逐个提取元素
for (int i=n-1; i>=0; i--) {
// 将当前最大值移到末尾
swap(arr[0], arr[i]);
// 调用 max heapify 在缩小的堆上
heapify(arr, i, 0);
}
}
// 打印数组
void printArray(int arr[], int n) {
for (int i=0; i<n; ++i)
cout << arr[i] << " ";
cout << "\n";
}
// 测试代码
int main() {
int arr[] = {12, 11, 13, 5, 6, 7};
int n = sizeof(arr)/sizeof(arr[0]);
heapSort(arr, n);
cout << "排序后的数组是 \n";
printArray(arr, n);
}
在这个实现中,heapSort
函数是主函数,它首先调用heapify
函数来构建最大堆,然后通过反复提取堆顶元素并重新调整堆来进行排序。
heapify
函数是维护堆性质的关键。它比较当前节点与其子节点的大小,如果当前节点小于其任何一个子节点,那么它就与最大的子节点交换位置。然后,它递归地对受影响的子树进行同样的操作,以确保整个树满足堆的性质。
在heapSort
函数中:
-
我们首先从最后一个非叶子节点开始(
n/2-1
),自底向上地调用heapify
函数来构建初始的最大堆。 -
然后,我们反复进行以下步骤,直到堆的大小减为1:
- 将堆顶元素(当前最大值)与堆的最后一个元素交换。
- 将堆的大小减1,并在缩小的堆上调用
heapify
函数来恢复堆的性质。
在main
函数中,我们创建一个测试数组,对其调用heapSort
函数进行排序,然后打印排序后的数组。
堆排序是一种高效且保证O(n log n)时间复杂度的排序算法。它利用了二叉堆的数据结构,二叉堆在许多其他算法中也有广泛的应用,如优先队列。理解堆排序可以加深对这种重要数据结构的理解。
在下一部分,我们将讨论堆排序的时间和空间复杂度。
7.3 时间复杂度和空间复杂度分析
现在让我们分析堆排序的时间复杂度和空间复杂度。
时间复杂度:
堆排序的时间复杂度是O(n log n),其中n是要排序的元素数量。让我们分别看看构建初始堆和排序阶段的时间复杂度。
-
构建初始堆:
在构建初始堆的阶段,我们从最后一个非叶子节点开始,自底向上地调用
heapify
函数。对于每个节点,heapify
函数需要O(log n)的时间来比较和可能交换节点。但是,堆中的节点数量随着树的层数呈指数减少。在最底层,大约有n/2个节点,它们各自需要O(1)的时间来进行
heapify
操作。在倒数第二层,大约有n/4个节点,它们各自需要O(log 2)的时间。依此类推,直到根节点,它需要O(log n)的时间。因此,构建初始堆的总时间复杂度是:
T(n) = O(1) * (n/2) + O(log 2) * (n/4) + O(log 3) * (n/8) + ... + O(log n) * 1 = O(n * (1/2 + 1/4 + 1/8 + ... + 1/n)) = O(n)
所以,构建初始堆的时间复杂度是O(n)。
-
排序阶段:
在排序阶段,我们反复从堆中提取最大元素,并将其放到数组的末尾。每次提取操作都需要O(log n)的时间来重新调整堆。我们需要进行n-1次提取操作。
因此,排序阶段的时间复杂度是O(n log n)。
综上所述,堆排序的总时间复杂度是O(n) + O(n log n) = O(n log n)。
空间复杂度:
堆排序是一种原地排序算法,它只需要恒定的额外空间。在排序过程中,我们只需要一些额外的变量来临时存储数据,不需要额外的数组。因此,堆排序的空间复杂度是O(1)。
总之,堆排序保证了O(n log n)的时间复杂度,并且只需要恒定的额外空间。这使得它成为一种非常高效且实用的排序算法,特别是在空间复杂度是一个关键考虑因素的情况下。
然而,堆排序的一个潜在缺点是它不是一种稳定的排序算法。相等的元素在排序后的相对位置可能会改变。如果在某些应用中需要保持相等元素的相对顺序,那么可能需要考虑其他的排序算法,如归并排序。
在下一部分,我们将介绍计数排序,这是一种非基于比较的排序算法,在某些特定情况下可以提供更好的性能。
8. 计数排序(Counting Sort)
8.1 算法原理
计数排序(Counting Sort)是一种非基于比较的整数排序算法。它的基本思想是,对于给定的输入序列中的每一个元素x,确定该序列中值小于等于x的元素的个数(此时假设序列中的所有值都为非负整数)。有了这个信息,我们就可以直接把x放到它在输出序列中的正确位置上。
计数排序的步骤如下:
-
找出待排序的数组中最大和最小的元素。
-
统计数组中每个值为i的元素出现的次数,存入数组C的第i项。
-
对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加)。
-
反向填充目标数组:将每个元素i放在新数组的第C[i]项,每放一个元素就将C[i]减去1。
示意图:
以数组 arr = [4, 2, 2, 8, 3, 3, 1] 为例,计数排序的过程如下:
原始数组:[4, 2, 2, 8, 3, 3, 1]
找出最大和最小元素:
最小值 = 1
最大值 = 8
创建计数数组(索引范围从最小值到最大值):
Index: 0 1 2 3 4 5 6 7 8
Count: 0 1 2 2 1 0 0 0 1
累加计数数组:
Index: 0 1 2 3 4 5 6 7 8
Count: 0 1 3 5 6 6 6 6 7
反向填充目标数组:
当前元素 计数 目标数组
8 7 [_, _, _, _, _, _, _, 8]
3 5 [_, _, _, 3, _, _, _, 8]
3 4 [_, _, _, 3, 3, _, _, 8]
4 6 [_, _, _, 3, 3, 4, _, 8]
2 3 [_, _, 2, 3, 3, 4, _, 8]
2 2 [_, _, 2, 2, 3, 4, _, 8]
1 1 [1, _, 2, 2, 3, 4, _, 8]
最终的排序结果:
[1, 2, 2, 3, 3, 4, 8]
计数排序的时间复杂度为O(n+k),其中n是待排序数组的长度,k是数组中元素的取值范围。当k不太大并且大部分元素的值都比较集中时,计数排序可以提供非常好的性能。然而,如果k很大(如果所有的n个元素的值都不同,那么k=n),那么计数排序的空间复杂度就变成了O(n),这可能会成为一个问题。
计数排序的主要优点是它的时间复杂度是线性的,这在大多数情况下都优于基于比较的排序算法。然而,它的缺点是它需要额外的空间来存储计数数组,并且它只适用于非负整数。
8.2 代码实现
以下是计数排序的C++实现:
#include <iostream>
#include <vector>
using namespace std;
void countSort(vector<int>& arr) {
int max = *max_element(arr.begin(), arr.end());
int min = *min_element(arr.begin(), arr.end());
int range = max - min + 1;
vector<int> count(range), output(arr.size());
for (int i = 0; i < arr.size(); i++)
count[arr[i] - min]++;
for (int i = 1; i < count.size(); i++)
count[i] += count[i - 1];
for (int i = arr.size() - 1; i >= 0; i--) {
output[count[arr[i] - min] - 1] = arr[i];
count[arr[i] - min]--;
}
for (int i = 0; i < arr.size(); i++)
arr[i] = output[i];
}
void printArray(vector<int>& arr) {
for (int i = 0; i < arr.size(); i++)
cout << arr[i] << " ";
cout << "\n";
}
int main() {
vector<int> arr = {-5, -10, 0, -3, 8, 5, -1, 10};
cout << "原始数组: ";
printArray(arr);
countSort(arr);
cout << "排序后的数组: ";
printArray(arr);
return 0;
}
在这个实现中,我们首先找出数组中的最大值和最小值,以确定计数数组的范围。然后,我们遍历输入数组,统计每个值出现的次数。接下来,我们对计数数组进行累加操作。最后,我们反向遍历输入数组,将每个元素放在输出数组的正确位置上,同时减少相应的计数。
这个实现假设输入数组可以包含负数。如果输入数组只包含非负整数,那么可以简化代码,省去计算最小值的步骤。
计数排序是一种在特定条件下非常高效的排序算法。当输入元素的范围比较集中并且不太大时,它可以提供线性时间的性能。然而,当元素的范围很大时,计数排序的空间复杂度可能会成为一个问题。
在下一部分,我们将讨论计数排序的时间和空间复杂度。
8.3 时间复杂度和空间复杂度分析
现在让我们分析计数排序的时间复杂度和空间复杂度。
时间复杂度:
计数排序的时间复杂度为O(n+k),其中n是待排序数组的长度,k是数组中元素的取值范围。让我们分别看看算法的每个步骤:
-
找出待排序数组中的最大值和最小值:
这一步需要遍历整个数组,因此时间复杂度为O(n)。
-
创建计数数组并统计每个元素出现的次数:
这一步也需要遍历整个数组,对于每个元素,将相应的计数增加1。因此,这一步的时间复杂度也是O(n)。
-
对计数数组进行累加:
这一步需要遍历计数数组,计数数组的大小为k(元素的取值范围)。因此,这一步的时间复杂度为O(k)。
-
反向填充目标数组:
这一步需要再次遍历输入数组,对于每个元素,将其放置在输出数组的正确位置。这需要O(n)的时间。
综上所述,计数排序的总时间复杂度为O(n) + O(n) + O(k) + O(n) = O(n+k)。
在最好、平均和最坏情况下,计数排序的时间复杂度都是O(n+k)。这是因为无论元素的分布如何,算法都需要遍历输入数组并创建大小为k的计数数组。
空间复杂度:
计数排序的空间复杂度为O(n+k)。这是因为除了输入数组,我们还需要两个额外的数组:
-
计数数组:
这个数组的大小为k(元素的取值范围)。在最坏情况下,每个元素都不同,那么k=n,计数数组的大小为O(n)。
-
输出数组:
这个数组的大小与输入数组相同,为n。
因此,计数排序的总空间复杂度为O(k) + O(n) = O(n+k)。
需要注意的是,当k远大于n时,计数排序的空间复杂度可能会成为一个问题。在这种情况下,其他的排序算法,如基于比较的排序算法,可能会更加适合。
总之,计数排序在时间复杂度上具有很大的优势,尤其是当k(元素的取值范围)显著小于n(元素的数量)时。然而,它的空间复杂度相对较高,并且它只适用于整数排序(或者可以转化为整数的情况)。尽管如此,在适当的情况下,计数排序仍然是一个非常有用且高效的算法。
在下一部分,我们将介绍桶排序,这是一种更加通用的非比较排序算法。
9. 桶排序(Bucket Sort)
9.1 算法原理
桶排序(Bucket Sort)是一种基于分布的排序算法。它的基本思想是将元素分布到一定数量的桶中,然后对每个桶内的元素进行排序(通常使用其他的排序算法或递归使用桶排序),最后将所有桶中的元素按顺序连接起来。
桶排序的步骤如下:
-
创建一定数量的空桶。
-
遍历输入数组,将每个元素放入对应的桶中。通常,元素的桶索引 = floor(元素值 * 桶的数量 / (最大值 + 1))。
-
对每个非空的桶内的元素进行排序。这可以使用其他的排序算法,如插入排序,或者递归使用桶排序。
-
将所有桶中的元素按顺序连接起来。
示意图:
以数组 arr = [0.897, 0.565, 0.656, 0.1234, 0.665, 0.3434] 为例,桶排序的过程如下:
原始数组:[0.897, 0.565, 0.656, 0.1234, 0.665, 0.3434]
创建桶(假设我们创建10个桶):
Index: 0 1 2 3 4 5 6 7 8 9
Bucket: [] [] [] [] [] [] [] [] [] []
将元素分配到桶中:
Index: 0 1 2 3 4 5 6 7 8 9
Bucket: [0.1234] [0.3434] [] [0.565, 0.565] [0.656, 0.665] [] [] [] [0.897] []
对每个桶内的元素进行排序(这里使用插入排序):
Index: 0 1 2 3 4 5 6 7 8 9
Bucket: [0.1234] [0.3434] [] [0.565, 0.565] [0.656, 0.665] [] [] [] [0.897] []
将所有桶中的元素按顺序连接:
[0.1234, 0.3434, 0.565, 0.565, 0.656, 0.665, 0.897]
桶排序的时间复杂度取决于数据的分布和所使用的内部排序算法。在平均情况下,如果数据均匀分布在桶中,并且每个桶的大小是O(n/k),其中k是桶的数量,那么使用插入排序作为内部排序算法时,桶排序的时间复杂度为O(n+k)。然而,在最坏情况下,如果所有的元素都集中在一个桶中,那么时间复杂度就退化为内部排序算法的时间复杂度(对于插入排序,是O(n^2))。
桶排序的主要优点是,当元素均匀分布在桶中时,它可以提供很好的性能。此外,桶排序可以用于浮点数和字符串等非整数数据类型。然而,它的缺点是,在元素分布不均匀时,性能可能会显著下降。此外,桶排序需要额外的空间来存储桶,空间复杂度为O(n+k)。
9.2 代码实现
好的,这里给出桶排序的C++实现:
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
// 函数to实现桶排序
void bucketSort(float arr[], int n) {
// 创建 n 个空桶
vector<float> b[n];
// 将数组 arr 的元素放入对应的桶中
for (int i = 0; i < n; i++) {
int bi = n * arr[i]; // 映射到 [0,n-1] 的范围
b[bi].push_back(arr[i]);
}
// 对每个桶内的元素进行排序
for (int i = 0; i < n; i++)
sort(b[i].begin(), b[i].end());
// 将所有桶中的元素按顺序连接
int index = 0;
for (int i = 0; i < n; i++)
for (int j = 0; j < b[i].size(); j++)
arr[index++] = b[i][j];
}
// 打印数组
void printArray(float arr[], int n) {
for (int i = 0; i < n; i++)
cout << arr[i] << " ";
cout << endl;
}
// 测试代码
int main() {
float arr[] = {0.897, 0.565, 0.656, 0.1234, 0.665, 0.3434};
int n = sizeof(arr) / sizeof(arr[0]);
cout << "原始数组: \n";
printArray(arr, n);
bucketSort(arr, n);
cout << "排序后的数组: \n";
printArray(arr, n);
return 0;
}
在这个实现中,bucketSort
函数接受一个浮点数数组arr
和数组的大小n
作为参数。
首先,我们创建n个空桶。然后,我们遍历输入数组,将每个元素映射到对应的桶中。这里,我们使用bi = n * arr[i]
来计算桶的索引,这将元素映射到[0,n-1]
的范围内。
接下来,我们对每个非空桶内的元素进行排序。这里,我们使用C++标准库中的sort
函数,但你也可以使用其他的排序算法,如插入排序。
最后,我们将所有桶中的元素按顺序连接起来。我们使用两个嵌套的循环来实现这一步:外层循环遍历所有的桶,内层循环遍历每个桶中的元素。
在main
函数中,我们创建一个测试数组,打印原始数组,然后调用bucketSort
函数进行排序,最后打印排序后的数组。
桶排序在元素均匀分布在桶中时可以提供很好的性能。然而,它的性能在很大程度上取决于元素的分布和桶的数量。在实践中,选择合适的桶数量可能需要一些试验和调整。
在下一部分,我们将讨论桶排序的时间和空间复杂度。
9.3 时间复杂度和空间复杂度分析
9.3 时间复杂度和空间复杂度分析
现在让我们分析桶排序的时间复杂度和空间复杂度。
时间复杂度:
桶排序的时间复杂度取决于几个因素:数据的分布、桶的数量以及用于对每个桶内的元素进行排序的算法。
让我们假设:
- 有n个元素需要排序。
- 将这些元素分布到k个桶中。
- 使用插入排序对每个桶内的元素进行排序。
现在,让我们分析算法的每个步骤:
-
创建桶:
创建k个桶需要O(k)的时间。
-
将元素分配到桶中:
遍历所有n个元素,并将每个元素放入对应的桶中。这一步需要O(n)的时间。
-
对每个桶内的元素进行排序:
在平均情况下,如果元素均匀分布在桶中,每个桶内大约有n/k个元素。使用插入排序对每个桶内的元素进行排序,时间复杂度为O((n/k)^2)。因为有k个桶,所以总的时间复杂度是O(k * (n/k)^2) = O(n^2/k)。
-
将所有桶中的元素连接起来:
将所有桶中的元素按顺序连接起来需要遍历所有n个元素,因此需要O(n)的时间。
综上所述,桶排序的总时间复杂度是O(k) + O(n) + O(n^2/k) + O(n) = O(n + n^2/k + k)。
在最佳情况下,当元素均匀分布在桶中时,每个桶内的元素数量是O(n/k)。如果我们选择k ≈ n,那么每个桶内的元素数量是O(1),排序每个桶的时间复杂度也是O(1)。在这种情况下,总的时间复杂度就是O(n)。
在最坏情况下,如果所有的元素都集中在一个桶中,那么桶排序就退化为插入排序,时间复杂度为O(n^2)。
空间复杂度:
桶排序的空间复杂度主要取决于桶的数量。
-
桶:
我们需要创建k个桶,每个桶是一个动态数组(如vector),可以存储变量数量的元素。在最坏情况下,所有的元素都在一个桶中,因此桶的空间复杂度是O(n)。
-
输入数组:
输入数组的大小为n,因此空间复杂度为O(n)。
因此,桶排序的总空间复杂度为O(n + k)。
需要注意的是,如果我们选择k ≈ n,那么空间复杂度就变成了O(n)。
总之,桶排序的时间复杂度在很大程度上取决于元素的分布和桶的数量。在最佳情况下,当元素均匀分布在桶中并且桶的数量接近元素的数量时,桶排序可以达到O(n)的时间复杂度。然而,在最坏情况下,当所有元素都集中在一个桶中时,时间复杂度退化为O(n^2)。桶排序的空间复杂度为O(n + k)。
在实践中,桶排序通常用于元素已经均匀分布或可以均匀分布的情况。对于未知分布的输入,桶排序可能不是最佳选择。在这种情况下,其他的排序算法,如快速排序或归并排序,可能更加合适。
10. 基数排序(Radix Sort)
10.1 算法原理
基数排序(Radix Sort)是一种非比较型整数排序算法,它根据数字的每一位来排序。它的基本思想是:将所有待比较数值(正整数)统一为同样的数位长度,数位较短的数前面补零。然后,从最低位开始,依次进行一次排序。这样从最低位排序一直到最高位排序完成以后,数列就变成一个有序序列。
基数排序的步骤如下:
-
找出数组中的最大数,并确定它的位数 d。
-
从最低有效位(Least Significant Digit,LSD)开始,对每一位执行以下步骤:
a. 使用计数排序(或任何稳定的排序算法)根据当前位的数字对元素进行排序。
-
重复步骤 2,直到处理完最高位(Most Significant Digit,MSD)。
基数排序有两种变体:
- LSD(Least Significant Digit)基数排序:从最低有效位开始排序。
- MSD(Most Significant Digit)基数排序:从最高有效位开始排序。
这里,我们将重点讨论 LSD 基数排序。
示意图:
以数组 arr = [170, 45, 75, 90, 802, 24, 2, 66] 为例,LSD 基数排序的过程如下:
原始数组:[170, 45, 75, 90, 802, 24, 2, 66]
第一次排序(按个位数排序):
[170, 90, 802, 2, 24, 45, 75, 66]
第二次排序(按十位数排序):
[802, 2, 24, 45, 66, 170, 75, 90]
第三次排序(按百位数排序):
[2, 24, 45, 66, 75, 90, 170, 802]
最终的排序结果:
[2, 24, 45, 66, 75, 90, 170, 802]
在每一次排序中,我们使用计数排序(或任何其他稳定的排序算法)根据当前位的数字对元素进行排序。重要的是,在每一次排序后,元素的相对顺序被保留了下来。
基数排序的时间复杂度为 O(d * (n + k)),其中 d 是最大数的位数,n 是数组的大小,k 是每一位的取值范围(对于十进制数,k 为 10)。当 d 是常数,且 k 远小于 n 时,基数排序的时间复杂度接近于 O(n)。
基数排序的主要优点是它的时间复杂度接近于线性,在某些情况下可以比基于比较的排序算法更快。然而,它的缺点是它只适用于整数或可以转换为整数的数据类型。此外,基数排序需要额外的空间来存储中间结果,其空间复杂度为 O(n + k)。
10.2 代码实现
好的,这里给出基数排序的C++实现:
#include <iostream>
#include <vector>
using namespace std;
// 获取数组中的最大值
int getMax(int arr[], int n) {
int mx = arr[0];
for (int i = 1; i < n; i++)
if (arr[i] > mx)
mx = arr[i];
return mx;
}
// 对数组按照 exp 位进行计数排序
void countSort(int arr[], int n, int exp) {
vector<int> output(n); // 输出数组
int i, count[10] = {0};
// 存储count数目
for (i = 0; i < n; i++)
count[(arr[i] / exp) % 10]++;
// 更改count[i],使count[i]现在包含它在output[]中的实际位置
for (i = 1; i < 10; i++)
count[i] += count[i - 1];
// 构建输出数组
for (i = n - 1; i >= 0; i--) {
output[count[(arr[i] / exp) % 10] - 1] = arr[i];
count[(arr[i] / exp) % 10]--;
}
// 将输出数组复制到arr[],这样arr[]现在就包含了按照当前位排序的数字
for (i = 0; i < n; i++)
arr[i] = output[i];
}
// 基数排序
void radixSort(int arr[], int n) {
// 找出数组中的最大数字
int m = getMax(arr, n);
// 对每一位进行计数排序,从最低有效位开始
for (int exp = 1; m / exp > 0; exp *= 10)
countSort(arr, n, exp);
}
// 打印数组
void print(int arr[], int n) {
for (int i = 0; i < n; i++)
cout << arr[i] << " ";
cout << endl;
}
// 测试代码
int main() {
int arr[] = {170, 45, 75, 90, 802, 24, 2, 66};
int n = sizeof(arr) / sizeof(arr[0]);
cout << "原始数组: \n";
print(arr, n);
radixSort(arr, n);
cout << "排序后的数组: \n";
print(arr, n);
return 0;
}
在这个实现中,radixSort
函数是主函数,它首先通过调用getMax
函数找到数组中的最大数字。然后,它从最低有效位(个位)开始,对每一位调用countSort
函数进行排序。这个过程一直持续到处理完最高有效位。
countSort
函数实现了计数排序算法,它根据指定的位(通过exp
参数)对数组进行排序。这个函数的工作原理与我们之前讨论的计数排序类似,只是在计算元素的位置时,我们只考虑当前位的数字。
getMax
函数简单地遍历数组以找到最大的元素。
在main
函数中,我们创建一个测试数组,打印原始数组,然后调用radixSort
函数进行排序,最后打印排序后的数组。
基数排序的一个关键优点是,当数字的位数是常数时(如在这个例子中),它的时间复杂度接近于线性。然而,它的空间复杂度较高,因为在每一次计数排序时,我们都需要一个额外的数组来存储中间结果。
在下一部分,我们将讨论基数排序的时间和空间复杂度。
10.3 时间复杂度和空间复杂度分析
现在让我们分析基数排序的时间复杂度和空间复杂度。
时间复杂度:
基数排序的时间复杂度为O(d * (n + k)),其中d是最大数的位数,n是数组的大小,k是每一位的取值范围(对于十进制数,k为10)。
让我们分析算法的每个步骤:
- 找出数组中的最大数:
这一步需要遍历整个数组,因此时间复杂度为O(n)。
- 对每一位进行计数排序:
外层循环运行d次,其中d是最大数的位数。在每次迭代中,我们调用计数排序。
计数排序的时间复杂度为O(n + k),其中n是数组的大小,k是当前位的取值范围。
因此,对每一位进行计数排序的总时间复杂度为O(d * (n + k))。
综上所述,基数排序的总时间复杂度为O(n) + O(d * (n + k)) = O(d * (n + k))。
在最佳、平均和最坏情况下,基数排序的时间复杂度都是O(d * (n + k))。这是因为无论数字的分布如何,算法都需要处理每个数字的每一位。
当d是常数,且k远小于n时,基数排序的时间复杂度接近于O(n)。
空间复杂度:
基数排序的空间复杂度为O(n + k)。这是因为除了输入数组,我们还需要额外的空间:
- 输出数组:
在每次计数排序中,我们都需要一个大小为n的输出数组来存储中间结果。
- 计数数组:
在每次计数排序中,我们都需要一个大小为k的计数数组来统计每个数字的出现次数。
因此,基数排序的总空间复杂度为O(n + k)。
需要注意的是,虽然基数排序的空间复杂度高于一些比较排序算法(如堆排序),但当k远小于n时,这个额外的空间开销可能是可以接受的。
总之,基数排序在时间复杂度上有很大的优势,尤其是当d是常数且k远小于n时。然而,它的空间复杂度较高,并且它只适用于整数排序(或者可以转化为整数的情况)。尽管如此,在适当的情况下,基数排序仍然是一个非常有用且高效的算法。
在实践中,基数排序通常用于以下情况:
- 数据范围较小:如果数据的取值范围k较小,那么基数排序的时间复杂度接近于O(n)。
- 数据位数较少:如果数据的最大位数d较小,那么基数排序的效率会很高。
- 稳定性要求:基数排序是一种稳定的排序算法,如果需要保持相等元素的相对顺序,那么基数排序是一个不错的选择。
在下一部分,我们将总结和比较所有的排序算法。
11. 总结与对比
11.1 各排序算法的特点、适用场景
在前面的章节中,我们详细讨论了10种常见的排序算法。现在,让我们总结每种算法的特点和适用场景。
-
冒泡排序(Bubble Sort):
- 特点:简单直观,容易实现,稳定的排序算法。
- 适用场景:数据量较小,或者数据已经部分有序的情况。
-
选择排序(Selection Sort):
- 特点:简单直观,不稳定的排序算法。
- 适用场景:数据量较小,或者交换操作的代价较低的情况。
-
插入排序(Insertion Sort):
- 特点:简单直观,稳定的排序算法,对于部分有序的数据有很好的性能。
- 适用场景:数据量较小,或者数据已经部分有序的情况。
-
希尔排序(Shell Sort):
- 特点:基于插入排序,通过增量序列来提高效率,不稳定的排序算法。
- 适用场景:数据量中等,或者数据已经部分有序的情况。
-
归并排序(Merge Sort):
- 特点:分治法,稳定的排序算法,保证最坏情况下的时间复杂度。
- 适用场景:数据量较大,或者需要稳定排序的情况。
-
快速排序(Quick Sort):
- 特点:分治法,不稳定的排序算法,在平均情况下性能非常好。
- 适用场景:数据量较大,或者需要快速排序的情况。
-
堆排序(Heap Sort):
- 特点:基于堆数据结构,不稳定的排序算法,保证最坏情况下的时间复杂度。
- 适用场景:数据量较大,或者需要在原地排序的情况。
-
计数排序(Counting Sort):
- 特点:非比较排序,稳定的排序算法,在数据范围较小时性能非常好。
- 适用场景:数据范围较小,或者数据已经均匀分布的情况。
-
桶排序(Bucket Sort):
- 特点:非比较排序,稳定的排序算法,在数据均匀分布时性能非常好。
- 适用场景:数据均匀分布,或者数据范围已知的情况。
-
基数排序(Radix Sort):
- 特点:非比较排序,稳定的排序算法,在数据位数较少时性能非常好。
- 适用场景:数据范围较小,或者数据位数较少的情况。
在实践中,我们需要根据具体的问题和数据特点来选择合适的排序算法。一些因素需要考虑:
- 数据量的大小
- 数据的初始分布
- 数据的范围
- 稳定性的要求
- 空间复杂度的限制
通常,对于小规模数据,简单的排序算法(如插入排序)可能就足够了。对于中等规模数据,希尔排序或者快速排序可能是不错的选择。对于大规模数据,归并排序或者堆排序可以提供稳定的性能。如果数据的范围较小,非比较排序(如计数排序、桶排序、基数排序)可以提供线性时间的性能。
理解每种排序算法的特点和适用场景,可以帮助我们在实践中做出最佳的选择。
11.2 算法的时间复杂度和空间复杂度比较
现在让我们比较一下各种排序算法的时间复杂度和空间复杂度。
算法 | 最佳时间复杂度 | 平均时间复杂度 | 最坏时间复杂度 | 空间复杂度 | 稳定性 |
---|---|---|---|---|---|
冒泡排序 | O(n) | O(n^2) | O(n^2) | O(1) | 稳定 |
选择排序 | O(n^2) | O(n^2) | O(n^2) | O(1) | 不稳定 |
插入排序 | O(n) | O(n^2) | O(n^2) | O(1) | 稳定 |
希尔排序 | O(n log n) | 取决于增量序列 | O(n^2) | O(1) | 不稳定 |
归并排序 | O(n log n) | O(n log n) | O(n log n) | O(n) | 稳定 |
快速排序 | O(n log n) | O(n log n) | O(n^2) | O(log n) | 不稳定 |
堆排序 | O(n log n) | O(n log n) | O(n log n) | O(1) | 不稳定 |
计数排序 | O(n + k) | O(n + k) | O(n + k) | O(n + k) | 稳定 |
桶排序 | O(n + k) | O(n + k) | O(n^2) | O(n + k) | 稳定 |
基数排序 | O(d(n + k)) | O(d(n + k)) | O(d(n + k)) | O(n + k) | 稳定 |
k是数据的范围,d是最大数的位数
从这个表格可以看出:
-
冒泡排序、选择排序和插入排序在平均和最坏情况下都是O(n^2)的时间复杂度,不适合大规模数据。但是它们的空间复杂度都是O(1),适合空间受限的情况。
-
希尔排序的时间复杂度取决于增量序列的选择,在最坏情况下也是O(n2)。但它通常比简单的O(n2)算法要快。
-
归并排序和堆排序保证了O(n log n)的最坏情况时间复杂度,但归并排序需要O(n)的额外空间,而堆排序只需要O(1)的额外空间。
-
快速排序在平均情况下是非常快的,时间复杂度为O(n log n)。但在最坏情况下,它退化为O(n^2)。它需要O(log n)的递归栈空间。
-
计数排序、桶排序和基数排序都是线性时间的排序算法,但它们都需要额外的O(n + k)或O(n + d)的空间。它们适用于数据范围较小或者数据位数较少的情况。
-
所有的O(n log n)比较排序算法都是基于比较的。根据信息论的下界,基于比较的排序算法的最佳时间复杂度不可能低于O(n log n)。
-
计数排序、桶排序和基数排序不是基于比较的排序算法,因此它们可以突破O(n log n)的下界。但是它们对数据的类型和范围都有一定的要求。
在实践中,我们需要根据具体的问题和数据特点来选择合适的排序算法。理解每种排序算法的时间复杂度和空间复杂度,可以帮助我们做出明智的决策。
此外,很多编程语言的标准库都提供了高效的排序函数(如C++的std::sort,Java的Arrays.sort),它们通常使用了一种或多种上述排序算法的优化版本。在大多数情况下,直接使用这些内置的排序函数就可以满足我们的需求。
11.3 稳定性分析
排序算法的稳定性是一个重要的性质,它对于某些应用场景非常重要。一个排序算法是稳定的,如果对于相等的元素,排序后它们的相对位置保持不变。换句话说,如果原始序列中 A[i] = A[j] 且 i < j,那么在排序后的序列中,A[i] 仍然在 A[j] 的前面。
让我们分析一下每个排序算法的稳定性:
-
冒泡排序(Bubble Sort):
冒泡排序是稳定的。在每次交换时,只有当前面的元素大于后面的元素时,才会进行交换。因此,相等的元素不会改变它们的相对位置。 -
选择排序(Selection Sort):
选择排序是不稳定的。在选择最小元素时,如果最小元素不是第一个元素,那么它会与第一个元素交换,这可能会改变相等元素的相对位置。 -
插入排序(Insertion Sort):
插入排序是稳定的。在插入一个元素时,只有当前面的元素大于要插入的元素时,才会移动元素。因此,相等的元素不会改变它们的相对位置。 -
希尔排序(Shell Sort):
希尔排序是不稳定的。在使用增量序列时,相等的元素可能会被分到不同的子序列中,从而改变它们的相对位置。 -
归并排序(Merge Sort):
归并排序是稳定的。在合并两个有序子序列时,如果两个子序列中有相等的元素,我们总是先取左子序列的元素。因此,相等元素的相对位置保持不变。 -
快速排序(Quick Sort):
快速排序是不稳定的。在分区过程中,相等的元素可能会被交换到数组的两侧,从而改变它们的相对位置。 -
堆排序(Heap Sort):
堆排序是不稳定的。在构建和调整堆的过程中,相等元素的相对位置可能会改变。 -
计数排序(Counting Sort):
计数排序是稳定的。在计数过程中,我们记录每个元素的出现次数,然后根据计数和原始位置,我们可以将元素放到正确的位置上,而不改变相等元素的相对位置。 -
桶排序(Bucket Sort):
桶排序是稳定的,如果使用稳定的内部排序算法(如插入排序)对每个桶进行排序。在将元素插入桶中时,相等的元素会进入同一个桶,并保持它们的相对位置。 -
基数排序(Radix Sort):
基数排序是稳定的。在按位排序时,我们使用稳定的计数排序作为内部排序算法。因此,相等元素的相对位置在整个过程中保持不变。
在某些应用中,排序算法的稳定性非常重要。例如,在对学生记录进行排序时,我们可能首先按成绩排序,然后按姓名排序。如果成绩排序算法是稳定的,那么具有相同成绩的学生在按姓名排序后仍然保持相对位置不变。
因此,在选择排序算法时,除了考虑时间和空间复杂度外,还需要考虑算法的稳定性是否满足应用的需求。