排序算法 | 时间复杂度 | 是否基于比较 |
---|---|---|
冒泡 、插入、选择 | O(n^2) | 是 |
快排 、归并 | O(nlogn) | 是 |
一、适合小规模数据的排序:
排序算法 | 是否原地排序 | 是否稳定 | 实际应用 |
---|---|---|---|
冒泡排序(Bubble Sort) | 是 | 是 | 少 |
插入排序(Insertion Sort) | 是 | 是 | 多 |
选择排序(Selection Sort) | 是 | 否 | 少 |
原地排序:特指空间复杂度是 O(1) 的排序算法。
稳定性:若待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。
冒泡排序(Bubble Sort)
1.对相邻的两个元素进行比较,若不满足大小关系则互换。
2.一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次就完成了 n 个数据的排序。
3.当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再继续执行后续的冒泡操作。
// 冒泡排序,a表示数组,n表示数组大小
void bubbleSort(int[] a, int n) {
if (n <= 1) return;
for (int i = 0; i < n; ++i)
{
// 提前退出冒泡循环的标志位
bool flag = false;
for(int j = 0; j < n-i-1; ++j)
{
if (a[j] > a[j+1])
{
// 交换
int tmp = a[j];
a[j] = a[j+1];
a[j+1] = tmp;
// 表示有数据交换
flag = true;
}
}
// 没有数据交换,提前退出
if (!flag) break;
}
}
插入排序(Insertion Sort)
1.将数组中的数据分为已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的 第一个元素。
2.取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并将插入点之后的元素顺序往后移动一位,保证已排序区间数据一直有序。
3.重复该过程,直到未排序区间元素为空。
// 插入排序,a表示数组,n表示数组大小
void insertionSort(int[] a, int n) {
if (n <= 1) return;
for (int i = 1; i < n; ++i)
{
int value = a[i];
int j = i - 1;
// 查找插入的位置
for (; j >= 0; --j)
{
if (a[j] > value)
{
// 数据移动
a[j+1] = a[j];
}
else
{
break;
}
}
// 插入数据
a[j+1] = value;
}
}
选择排序(Selection Sort)
1.选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。
2.但选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
冒泡排序 VS 插入排序
从代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要 3 个赋值操作,而插入排序只需要 1 个。
冒泡排序中数据的交换操作:
if (a[j] > a[j+1]) {
// 交换
int tmp = a[j];
a[j] = a[j+1];
a[j+1] = tmp;
flag = true;
}
插入排序中数据的移动操作:
if (a[j] > value) {
// 数据移动
a[j+1] = a[j];
} else {
break;
}
二、适合大规模的数据排序:
排序算法 | 是否原地排序 | 是否稳定 | 实际应用 |
---|---|---|---|
归并排序(Merge Sort) | 否 | 是 | 少 |
快速排序(Quick Sort) | 是 | 否 | 多 |
归并排序(Merge Sort)
1.归并排序使用分治的思想,递归的方法,将一个大问题分解成一个个小的子问题来解决。
2.先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。
template<typename T>
void merge_sort_recursive(T arr[], T tmp[], int start, int end) {
//分解终止条件
if (start >= end)
return;
//分解
int mid = start + ((end - start)>> 1);
int start1 = start, end1 = mid;
int start2 = mid + 1, end2 = end;
merge_sort_recursive(arr, tmp, start1, end1);
merge_sort_recursive(arr, tmp, start2, end2);
//按顺序合并
int n = start;
while (start1 <= end1 && start2 <= end2)
tmp[n++] = arr[start1] < arr[start2] ? arr[start1++] : arr[start2++];
while (start1 <= end1)
tmp[n++] = arr[start1++];
while (start2 <= end2)
tmp[n++] = arr[start2++];
//转移
for (n = start; n <= end; n++)
arr[n] = tmp[n];
}
template<typename T>
void merge_sort(T arr[], const int len) {
T tmp[len];
merge_sort_recursive(arr, tmp, 0, len - 1);
}
快速排序(Quick Sort)
1.快排利用的也是分治思想,有点像归并排序,但思路其实完全不一样
2.归并排序中有一个 merge() 合并函数,快排有一个 partition() 分区函数
3.通过游标 i 把 arr[left…end-1]分成两部分。arr[left…i-1]的元素都是小于 pivot基数的“已处理区”,arr[i…end-1]是“未处理区”。每次从未处理区 arr[i…end-1]中取一个元素 arr[ j ]与 pivot 对比,若小于 pivot则将其与“已处理区间的元素对调,也就是 arr[ i ]的位置
//分区函数,左边为小于pivot的区,中间为pivot,右边为大于pivot的区
template<typename T>
int partition(T arr[], int start, int end)
{
int pivot = arr[end];
int i = j = start;
for(j; j < end-1; j++)
{
if( arr[j] < pivot )
{
std::swap(arr[i++], arr[j]);
}
}
std::swap(arr[i], arr[end]);
//返回 pivot 的下标
return i
}
//递归函数
template<typename T>
void quick_sort_recursive(T arr[], int start, int end) {
if (start >= end)
return;
//根据pivot分为左右两区
int pivot = partition(arr,start,end);
//再将pivot左右两边进行递归排序
quick_sort_recursive(arr, start, pivot-1);
quick_sort_recursive(arr, pivot+1, end);
}
//起始入口
template<typename T>
void quick_sort(T arr[], int len) {
quick_sort_recursive(arr, 0, len-1);
}
时间复杂度:
平均:
O(nlogn)
最好:
如果每次分区操作,都能正好把数组分成大小接近相等的两个小区间,那快排的时间复杂度递推求解公式跟归并是相同的, O(nlogn)
最差:
如果数组中的数据原来已经是有序的了,比如 1,3,5,6,8。如果我们每次选择最后一个元素作为 pivot,那每次分区得到的两个区间都是不均等的。我们需要进行大约 n 次分区操作,才能完成快排的整个过程。每次分区我们平均要扫描大约 n/2 个元素,这种情况下,快排的时间复杂度就从 O(nlogn) 退化成了 O(n^2)。
归并排序 VS 快速排序
归并排序的处理过程是由下到上的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,但是它是非原地排序算法。我们前面讲过,归并之所以是非原地排序算法,主要原因是合并函数无法在原地执行。快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。