冒泡排序 Bubble Sort
简明解释
通过依次比较、交换相邻的元素大小(按照由小到大的顺序,如果符合这个顺序就不用交换)。1 次这样的循环可以得到一个最大值,n - 1
次这样的循环可以排序完毕。
属性
- 稳定
- 时间复杂度
O(n²)
- 交换
O(n²)
- 对即将排序完成的数组进行排序
O(n)
(但是这种情况下不如插入排序块,请继续看下文)
核心概念
- 利用交换,将最大的数冒泡到最后
基本实现
首先定义一个数组,并求出数组长度,将这两个以参数形式传入函数实现
int arr[] = {1, 5, 4, 2, 7, 8, 9, 3, 6};
size_t length = end(arr) - begin(arr);
cout << "冒泡排序:" << endl;
BubbleSort(arr, length);
for (auto iter : arr) {
cout << iter << " ";
}
实现:
void BubbleSort(int *p, size_t length) {
for (auto i = 0; i < length; ++i) {
for (auto j = 0; j < length - i - 1; ++j) {
swap(p[j], p[j + 1]);
}
}
}
加入我有一个需求,以一个函数指针为参数,用以实现升序或降序的需求呢
BubbleSort(arr, length, [](int a, int b) { return b - a; });
void BubbleSort(int *p, size_t length, function<int(int lhs, int rhs)> compareFunc) {
for (auto i = 0; i < length; ++i) {
for (auto j = 0; j < length - i - 1; ++j) {
if (compareFunc(p[j], p[j + 1]) > 0) {
swap(p[j], p[j + 1]);
}
}
}
}
调试输出:
选择排序 Selection Sort
简明解释
每一次内循环遍历寻找最小的数,记录下 minIndex
,并在这次内循环结束后交换 minIndex
和 i
的位置。
重复这样的循环 n - 1
次即得到结果。
属性
- 不稳定
Θ(n²)
无论什么输入,均为Θ(n²)
Θ(n) 交换
: 注意,这里只有n
次的交换,选择排序的唯一优点*
核心概念
- “可预测”的时间复杂度,什么进来都是
O(n²)
,但不稳定,唯一的优点是减少了swap
次数。
基本实现
void SelectionSort(int *p, size_t length) {
for (auto i = 0; i < length - 1; ++i) {
int min = i;
for (auto j = i + 1; j < length; ++j) {
if (p[min] > p[j]) {
min = j;
}
}
if (min != i) {
swap(p[i], p[min]);
}
}
}
插入排序 Insertion Sort
默认 a[0]
为已排序数组中的元素,从 arr[1]
开始逐渐往已排序数组中插入元素,从后往前一个个比较,如果待插入元素小于已排序元素,则已排序元素往后移动一位,直到待插入元素找到合适的位置并插入已排序数组。
经过 n - 1
次这样的循环插入后排序完毕。
属性
- 稳定
- 适合场景:对快要排序完成的数组时间复杂度为
O(n)
- 非常低的开销
- 时间复杂度
O(n²)
由于它的优点(自适应,低开销,稳定,几乎排序时的
O(n)
时间),插入排序通常用作递归基本情况(当问题规模较小时)针对较高开销分而治之排序算法, 如希尔排序或快速排序。
核心概念
- 高性能(特别是接近排序完毕时的数组),低开销,且稳定
- 利用二分查找来优化
基本实现
void InsertSort(int *p, int *begin, int *end) {
for (auto iter = begin + 1; iter != end; ++iter) {
const int tmp = *iter;
auto index = iter - begin;
auto j = index;
while (j > 0 && tmp > p[j - 1]) {
p[j] = p[j - 1];
--j;
}
if (j != index) {
p[j] = tmp;
}
}
}
代码可能比for i循环类型的难理解,原理一样,也可以利用其它语言的语法糖。
希尔排序 Shell Sort
简明解释
希尔排序是插入排序的改进版,它克服了插入排序只能移动一个相邻位置的缺陷(希尔排序可以一次移动 gap
个距离),利用了插入排序在排序几乎已经排序好的数组的非常快的优点。
使用可以动态定义的 gap
来渐进式排序,先排序距离较远的元素,再逐渐递进,而实际上排序中元素最终位置距离初始位置远的概率是很大的,所以希尔排序大大提升了性能(尤其是 reverse 的时候非常快,想象一下这时候冒泡排序和插入排序的速度)。
而且希尔排序不仅效率较高(比冒泡和插入高),它的代码相对要简短,低开销(继承插入排序的优点),追求这些特点(效率要求过得去就好,代码简短,开销低,且数据量较小)的时候希尔排序是好的 O(n·log(n))
算法的替代品。
总而言之:希尔排序的性能优化来自增量队列的输入和 gap
的设定。
属性
- 不稳定
- 在快要排序完成的数组有
O(n·log(n))
的时间复杂度(并且它对于反转数组的速度非常快) O(n^3/2)
time as shown
关于不稳定:
我们知道, 单次直接插入排序是稳定的,它不会改变相同元素之间的相对顺序,但在多次不同的插入排序过程中, 相同的元素可能在各自的插入排序中移动,可能导致相同元素相对顺序发生变化。因此, 希尔排序并不稳定。
关于 worse-case time 有一点复杂:
The worse-case time complexity of shell sort depends on the increment sequence. For the increments 1 4 13 40 121…, which is what is used here, the time complexity is O(n3/2). For other increments, time complexity is known to be O(n4/3) and even O(n·log2(n)).
核心概念
希尔排序是基于插入排序的以下两点性质而提出改进方法的:
- 插入排序在对几乎已经排好序的数据操作时,效率高,即可以达到
O(n)
的效率; - 但插入排序一般来说是低效的,因为插入排序每次只能将数据移动一位 ;
其中 gap
(增量)的选择是希尔排序的重要部分。只要最终 gap
为 1 任何 gap
序列都可以工作。算法最开始以一定的 gap
进行排序。然后会继续以一定 gap
进行排序,直到 gap = 1
时,算法变为插入排序。
Donald Shell 最初建议 gap
选择为 n / 2
并且对 gap
取半直到 gap
达到 1 。虽然这样取可以比 O(n²) 类的算法(插入排序、冒泡排序)更好,但这样仍然有减少平均时间和最差时间的余地。
void ShellSort(int *arr, size_t len) {
int gap = 1;
while (gap < len) {
gap = gap * 3 + 1;
}
while (gap > 0) {
for (auto i = gap; i < len; ++i) {
auto idx = i - gap;
auto tmp = arr[i];
while (idx >= 0 && tmp < arr[idx]) {
arr[idx + gap] = arr[idx];
idx -= gap;
}
arr[idx + gap] = tmp;
}
gap = (int)floor(gap / 3);
}
}
快速排序 Quick Sort
简明解释
- 从数列中挑出一个元素,称为"基准"(pivot),
- 重新排序数列,所有比基准值小的元素摆放在基准前面,所有比基准值大的元素摆在基准后面(相同的数可以到任何一边)。在这个分区结束之后,该基准就处于数列的中间位置。这个称为分区(partition)操作。
- 递归地(recursively)把小于基准值元素的子数列和大于基准值元素的子数列排序。
属性
- 不稳定
- O(n²) time, 但是通常都是 O(n·log(n)) time (或者更快)
- O(log(n)) extra space
When implemented well, it can be about two or three times faster than its main competitors, merge sort and heap sort
核心概念
- 使用了分而治之的思想
基本实现
cout << "\n快速排序:" << endl;
QuickSort(arr, 0, end(arr) - begin(arr) - 1);
for (auto iter : arr) {
cout << iter << " ";
}
int Partition(int *p, int l, int r) {
auto x = p[l];
while (l < r) {
while (l < r && p[r] >= x) // 从右向左找第一个小于x的数
r--;
if (l < r)
p[l++] = p[r];
while (l < r && p[l] < x) // 从左向右找第一个大于等于x的数
l++;
if (l < r)
p[r--] = p[l];
}
p[l] = x;
return l;
}
void QuickSort(int *p, int l, int r) {
auto position = 0;
if (l < r) {
position = Partition(p, l, r); //返回划分元素的最终位置
QuickSort(p, l, position - 1); //划分左边递归
QuickSort(p, position + 1, r); //划分右边递归
}
}
二分查找 BinarySearch
这里补充一下二分查找的算法的实现。
核心概念是:折半。
//调用处
cout << "\n二分查找:" << endl;
auto index = BinarySearch(arr, length, 9);
cout << "\n二分查找结果:" << index << endl;
//定义
int BinarySearch(int p[] /*此处int *p 也可以*/, size_t len, int target) {
int min = 0;
int max = static_cast<int>(len - 1);
while (min <= max) {
const auto mid = (min + max) / 2;
cout << " find index: " << p[mid] << endl;
if (target == p[mid]) {
cout << " target finded ! " << p[mid] << endl;
return mid;
} else if (target < p[mid]) {
max = mid - 1;
} else if (target > p[mid]) {
min = mid + 1;
}
}
cout << " target not found" << endl;
return 0;
}
总结
- 数据几乎快排序完成时?
插入排序不解释
- 数据量小,对效率要求不高,代码简单时?
性能大小:希尔排序 > 插入排序 > 冒泡排序 > 选择排序
- 数据量大,要求稳定的效率(不会像快速排序一样有
O(n²)
的情况)(如数据库中)?
堆排序
- 数据量大,要求效率高,而且要稳定?
归并排序
- 数据量大,要求最好的平均效率?
性能大小:快速排序 > 堆排序 > 归并排序
因为虽然堆排序做到了 O(n·log(n)
,而快速排序的最差情况是 O(n²)
,但是快速排序的绝大部分时间的效率比 O(n·log(n)
还要快,所以快速排序真的无愧于它的名字。(十分快速)
- 选择排序绝对没用吗?
选择排序只需要 O(n)
次交换,这一点它完爆冒泡排序。