目录
引言
排序算法是计算机科学中一个重要的主题,是一种对一组元素进行重新排列的算法,用于对数据进行有序排列。在实际开发中,我们经常需要根据特定的需求选择适合的排序算法。本篇博客将深入探讨四种常见的排序算法:冒泡排序、选择排序、快速排序和插入排序,详细介绍它们的原理、实现以及优缺点。
一、冒泡排序
冒泡排序是一种简单而直观的排序算法,它通过相邻元素的比较和交换来完成排序。
1.1 排序步骤
- 首先,从数组的第一个元素开始,依次比较相邻的两个元素。
- 如果第一个元素大于第二个元素,则交换它们的位置,确保较大的元素被交换到后面。
- 继续向后遍历数组,重复上述比较和交换的过程,直到遍历到倒数第二个元素。
- 一轮遍历后,最大的元素会"冒泡"到数组的末尾。
- 重复执行上述步骤,但每次遍历时不考虑已经排序好的末尾部分,因为它们已经是最大的,只需要关注未排序部分。
- 继续进行多轮遍历,直到所有的元素都被排序
通过不断重复这个过程,最大(或最小)的元素会像气泡一样逐渐"冒泡"到正确的位置,而较小(或较大)的元素会逐渐沉到底部。最终,整个数组会按照升序(或降序)排列。
1.2 代码实现
template <typename T>
void bubbleSort(T arr[], int n) {
for (int i = 0; i < n - 1; ++i) {
// 在每一轮遍历中,比较相邻的元素并交换它们的位置
for (int j = 0; j < n - i - 1; ++j) {
if (arr[j] > arr[j + 1]) {
// 如果前一个元素大于后一个元素,进行交换
swap(arr[j], arr[j + 1]);
}
}
}
}
1.3 分析
冒泡排序的时间复杂度为O(n^2),其中n是待排序序列的长度。
最好的情况是输入数据已经是有序的,这样只需要进行一轮遍历即可,时间复杂度为O(n)。最坏的情况是输入数据完全逆序,需要进行n轮遍历,时间复杂度为O(n^2)。
平均情况下,冒泡排序的时间复杂度也为O(n^2)。
冒泡排序是一种稳定的排序算法,即在相等元素的情况下会保持它们的相对顺序不变。这是因为冒泡排序只对相邻元素进行比较和交换,如果两个相等的元素没有发生交换,它们的相对顺序将保持不变。
尽管冒泡排序的时间复杂度较高,但它具有实现简单、代码易于理解的优点。它适用于小规模数据集或者已经基本有序的数据集的排序任务。然而,在大规模数据集上,冒泡排序的性能会明显下降,因此在实际应用中需要根据情况选择更高效的排序算法。
二、选择排序
基本思想是每次从待排序的数据中选择最小(或最大)的元素,放到已排序部分的末尾。通过不断重复这个过程,最终将整个序列排序完成。
2.1 排序步骤
- 将待排序序列分为已排序部分和未排序部分。初始时,已排序部分为空,未排序部分包含整个序列。
- 在未排序部分中找到最小(或最大)的元素,并记录其位置(索引)。
- 将最小(或最大)元素与未排序部分的第一个元素进行交换(第一次排序时均为未排序内容,所以将最大(小)元素与第一个元素交换)。
- 扩大已排序部分,将其包含的元素数量增加一个,同时缩小未排序部分,将其包含的元素数量减少一个。
- 重复步骤 2~4,直到未排序部分为空。
2.2 代码实现
template <typename T>
void selectionSort(T arr[], int n) {
for (int i = 0; i < n - 1; ++i) {
// 假设当前未排序部分的第一个元素是最小值
int min_idx = i;
// 在未排序部分中找到最小值的索引
for (int j = i + 1; j < n; ++j) {
if (arr[j] < arr[min_idx]) {
min_idx = j;
}
}
// 将最小值与当前未排序部分的第一个元素交换位置
swap(arr[i], arr[min_idx]);
}
}
2.3 分析
选择排序的时间复杂度为O(n^2),其中n是待排序序列的长度。这是因为选择排序的算法中有两层嵌套的循环。
外层循环从0遍历到n-1,表示已排序部分的末尾位置。在每一次外层循环中,内层循环从外层循环的下一个位置开始,遍历到数组的末尾。内层循环用于找到未排序部分中的最小(或最大)元素。
对于每次外层循环,内层循环需要比较未排序部分中的元素,并找到最小(或最大)元素的索引。因此,内层循环的迭代次数是n-i-1,其中i是外层循环的迭代次数。
总的比较次数可以通过求和得到:
(n-1) + (n-2) + ... + 1 = (n-1) * n / 2 = (n^2 - n) / 2
这个和的时间复杂度可以表示为O(n^2)。
在最坏情况下,无论输入数据的顺序如何,选择排序都需要进行相同数量的比较和交换操作。因此,选择排序的最好、最坏和平均时间复杂度都是O(n^2)。
选择排序是一种不稳定的排序算法。在选择最小(或最大)元素并进行交换时,可能改变相等元素的相对顺序。例如,对于序列[5, 5, 3],选择排序后可能得到[3, 5, 5],导致相等元素的相对顺序发生了变化。
选择排序是一种原地排序算法,不需要额外的空间。它只需要常数级别的额外空间用于存储一些辅助变量,所以空间复杂度是O(1)。
尽管选择排序的时间复杂度较高,但由于其实现简单、代码易于理解,对于小规模的数据集来说仍然具有一定的应用价值。然而,在大规模数据集上,选择排序的性能较差,通常不是首选的排序算法。在实际应用中,如果对排序的性能有较高要求,可以选择其他更高效的排序算法,如快速排序或归并排序。
三、快速排序
基本思想是通过选择一个基准元素将待排序序列划分为两个子序列,其中一个子序列的所有元素都小于基准元素,而另一个子序列的所有元素都大于基准元素。然后对这两个子序列分别进行递归地排序,最终得到整个序列有序。
3.1 步骤
- 选择一个基准元素(通常是待排序序列的第一个元素)。
- 将序列中的其他元素与基准元素进行比较,将小于基准元素的元素放在基准元素的左边,将大于基准元素的元素放在基准元素的右边。
- 对基准元素左边和右边的子序列分别重复上述步骤,直到每个子序列只剩下一个元素或为空。
- 最后,将所有子序列合并起来,即得到排序完成的序列。
示例:
待排序数组:[64, 25, 12, 22, 11]
- 选择第一个元素64作为基准元素。
- 将其他元素与基准元素进行比较,将小于64的元素放在左边,将大于64的元素放在右边。此时,我们得到的序列是[25, 12, 22, 11, 64]。
- 对左边的子序列[25, 12, 22, 11]和右边的子序列[64]分别重复上述步骤。
- 对左边的子序列进行快速排序,选择第一个元素25作为基准元素,得到[11, 12, 22, 25]。
- 对右边的子序列进行快速排序,只有一个元素无需排序。
- 合并左边的子序列和右边的子序列,得到最终有序序列:[11, 12, 22, 25, 64]。
这样,通过递归地划分和排序子序列,最终整个序列就会有序。
3.2 代码实现
template <typename T>
int partition(T arr[], int low, int high) {
T pivot = arr[low]; // 将第一个元素作为基准元素
int i = low, j = high;
while (i < j) {
// 从右向左找第一个小于基准元素的元素
while (i < j && arr[j] >= pivot)
j--;
// 将找到的小于基准元素的元素放到左边
arr[i] = arr[j];
// 从左向右找第一个大于等于基准元素的元素
while (i < j && arr[i] < pivot)
i++;
// 将找到的大于等于基准元素的元素放到右边
arr[j] = arr[i];
}
// 将基准元素放到正确的位置
arr[i] = pivot;
// 返回基准元素的索引
return i;
}
template <typename T>
void quickSort(T arr[], int low, int high) {
if (low < high) {
// 划分子序列并获得基准元素的索引
int pivotIndex = partition(arr, low, high);
// 对基准元素左边的子序列进行快速排序
quickSort(arr, low, pivotIndex - 1);
// 对基准元素右边的子序列进行快速排序
quickSort(arr, pivotIndex + 1, high);
}
}
3.3 分析
partition
函数用于将待排序序列划分为两个子序列,并返回基准元素的索引。它使用了左右两个指针(i
和j
)从序列的两端向中间移动,找到需要交换的元素,将小于基准元素的元素放在基准元素的左边,将大于等于基准元素的元素放在基准元素的右边。
quickSort
函数用于递归调用快速排序。它首先划分子序列并获得基准元素的索引,然后对基准元素左边和右边的子序列分别进行递归调用快速排序,直到子序列长度为1或0时停止递归
快速排序的时间复杂度为O(nlogn)。
在最好情况下,每次划分都能将待排序序列均匀地划分为两个长度相等的子序列,此时快速排序的时间复杂度为O(nlogn)。
在最坏情况下,每次划分只能将待排序序列划分为一个长度为1的子序列和一个长度为n-1的子序列,此时快速排序的时间复杂度为O(n^2)。
然而,在平均情况下,快速排序的时间复杂度为O(nlogn),并且具有较好的性能。
快速排序是一种原地排序算法,不需要额外的空间。它通过递归调用划分子序列并排序,只需要常数级别的额外空间用于存储一些辅助变量,所以空间复杂度是O(1)。
快速排序是一种不稳定的排序算法,即在相等元素的情况下可能改变它们的相对顺序。这是因为快速排序中的划分过程不保证相等元素的顺序不变。
快速排序是一种常用且高效的排序算法,尤其适用于大规模数据集的排序。它的原理简单,实现相对容易,而且具有较好的性能。
四、插入排序
基本思想是将待排序序列分为已排序部分和未排序部分,逐个将未排序部分的元素插入到已排序部分的适当位置,直到整个序列有序为止。
当我们使用插入排序算法对一个数组进行排序时,我们可以通过一个生活中的例子来形象解释其过程。
- 1.假设你手上有一摞打乱顺序的扑克牌,你希望将它们按照从小到大的顺序排列。你可以采用插入排序的思路来完成这个任务。
- 2.开始时,你手上只有一张牌,它是已排序部分。接下来,你从桌子上拿起一张牌,假设它是4。你将4与已排序部分中的牌逐个比较,找到它应该插入的位置,排序后顺序为[2, 4]。
- 3.然后,你再次从桌子上拿起一张牌,假设它是7。你将7与已排序部分中的牌逐个比较,找到它应该插入的位置。根据已排序部分[2, 4],你发现7应该插入到4的后面,得到[2, 4, 7]。
- 4.你重复这个过程,每次拿起一张牌,将它与已排序部分中的牌逐个比较,找到合适的位置插入。最终,你将手上的所有牌都插入到了已排序部分的适当位置。
通过这个例子,你可以看到插入排序的过程就像是你在手上整理一摞扑克牌,每次从桌子上拿起一张牌,将它插入到已排序部分的适当位置。通过逐步插入,整个序列逐渐有序。
4.1 步骤
将数组分为已排序部分和未排序部分。初始时,已排序部分只有一个元素,即数组的第一个元素;未排序部分包括数组剩余的元素。
从未排序部分选择一个元素,将其插入到已排序部分的合适位置。
比较选中的元素与已排序部分的元素,找到合适的插入位置。
将大于选中元素的元素后移,为选中元素腾出插入位置。
将选中的元素插入到正确的位置,完成插入。
重复步骤 2~5,直到未排序部分为空
4.2 代码实现
template <typename T>
void insertionSort(T arr[], int n) {
for (int i = 1; i < n; ++i) {
T key = arr[i]; // 当前待插入的元素
int j = i - 1;
// 将比当前元素大的元素向后移动,为当前元素腾出插入位置
while (j >= 0 && arr[j] > key) {
arr[j + 1] = arr[j];
j--;
}
// 将当前元素插入到正确的位置
arr[j + 1] = key;
}
}
举个例子来说明插入排序的原理:
假设有一个待排序数组:[7, 2, 4, 1, 5]
初始时,将7视为已排序部分,[2, 4, 1, 5]视为未排序部分。
我们从未排序部分依次选择元素,并将其插入已排序部分的合适位置:
选择2,将其插入已排序部分。此时,已排序部分变为[2, 7],未排序部分变为[4, 1, 5]。
选择4,将其插入已排序部分。此时,已排序部分变为[2, 4, 7],未排序部分变为[1, 5]。
选择1,将其插入已排序部分。此时,已排序部分变为[1, 2, 4, 7],未排序部分变为[5]。
选择5,将其插入已排序部分。此时,已排序部分变为[1, 2, 4, 5, 7],未排序部分为空。
最终,通过将未排序部分的元素逐个插入到已排序部分的合适位置,我们得到了一个有序的数组。
4.3 分析
插入排序的时间复杂度为O(n^2),其中n是待排序序列的长度。
在最坏情况下,即待排序序列逆序排列时,插入排序的比较次数为n(n-1)/2,即O(n^2)。
在最好情况下,即待排序序列已经有序时,插入排序的比较次数为n-1,即O(n)。
平均情况下,插入排序的比较次数也接近O(n^2)。
插入排序是一种稳定的排序算法,即在相等元素的情况下不改变它们的相对顺序。这是因为只有在当前元素小于已排序部分的元素时才进行元素的移动。
插入排序是一种原地排序算法,不需要额外的空间。它只需要常数级别的额外空间用于存储一些辅助变量,所以空间复杂度是O(1)。
总结来说,插入排序是一种简单直观的排序算法,通过逐个插入元素并将其放置在合适的位置来构建有序序列。由于插入排序的特性,适用于小规模数据集或部分有序的情况。通过不断将元素插入已排序部分并进行移动,最终实现整个序列的排序。