经典排序算法
总述
分析一个算法主要从以下几方面来考虑:
1、排序算法的执行效率
又分为最好情况、最坏情况、平均情况时间复杂度。
2、排序算法的内存消耗
内存消耗可以通过空间复杂度来衡量
原地排序(Sorted in place),就是特指空间复杂度是 O(1) 的排序算法。
3、排序稳定性
稳定性,就是如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。
经过某种排序算法排序之后:
如果两个元素的前后顺序没有改变,那我们就把这种排序算法叫作稳定的排序算法;
如果前后顺序发生变化,那对应的排序算法就叫作不稳定的排序算法。
排序算法 | 是否稳定排序 | 是否原地排序 | 最好时间复杂度 | 最坏时间复杂度 | 平均时间复杂度 |
---|---|---|---|---|---|
冒泡 | 是 | 是 | o(n) | o(n^2) | o(n^2) |
选择 | 否 | 是 | o(n^2) | o(n^2) | o(n^2) |
插入 | 是 | 是 | o(n) | o(n^2) | o(n^2) |
快排 | 否 | 是 | O(nlogn) | o(n^2) | O(nlogn) |
归并 | 是 | 否 | O(nlogn) | O(nlogn) | O(nlogn) |
桶排序 | 是 | 否 | O(n) | O(n) | O(n) |
计数排序 | 是 | 否 | O(n) | O(n) | O(n) |
基数排序 | 是 | 否 | O(n) | O(n) | O(n) |
各排序算法
冒泡排序
1、冒泡排序只会操作相邻的两个数据,看是否满足关系要求,不满足就互换。
2、一次冒泡会让至少一个元素移动到它应该在的位置。
3、重复 n 次,就完成了 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;
}
}
}
插入排序
1、将数组中的数据分为两个区间,已排序区间和未排序区间。初始已排序区间只有一个元素,就是数组的第一个元素。
2、取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,保证已排序区间数据一直有序。
3、重复这个过程,直到未排序区间中元素为空,算法结束。
void insertSort(int* a, int n) {
if (n <= 1) return;
for(int i = 1; i < n; ++i) {
int key = a[i];
int j = i - 1;
for(; j >= 0; --j) {
if(key < a[j]) {
a[j+1] = a[j];
} else {
break;
}
}
if (j != i-1) {
a[j+1] = key;
}
}
}
选择排序
1、类似插入排序,也分已排序区间和未排序区间,初始已排序区间为空。
2、每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。
3、重复步骤2,直到末尾。
void selectSort(int* a, int n) {
if (n <= 1) return;
for(int i = 0; i < n-1; ++i){
int minindex = i;
for(int j = i + 1; j < n; ++j){
if (a[minindex] > a[j]) {
minindex = j;
}
}
if (minindex != i) {
int tmp = a[minindex];
tmp = a[i];
a[i] = a[minindex];
a[minindex] = tmp;
}
}
}
归并排序
归并排序使用的是分治思想。
1、先把数组从中间分成前后两部分,然后对前后两部分分别排序。
2、再将排好序的两部分合并在一起,这样整个数组就都有序了。
void mergeSort(int* a, int n){
mergeSortC(a, 0, n-1);
}
void mergeSortC(int* a, int l, int r) {
if (l < r) {
int q = (l + r) / 2;
mergeSortC(a, l, q);
mergeSortC(a, q+1, r);
merge(a, l, q, r);
}
}
void merge(int* a, int l, int q, int r) {
int b[10] = {0};
int first = l;
int second = q + 1;
int num = 0;
while(first <= q && second <= r) {
if(a[first] <= a[second]) {
b[num++] = a[first++];
} else {
b[num++] = a[second++];
}
}
while (first <= q) {
b[num++] = a[first++];
}
while (second <= r) {
b[num++] = a[second++];
}
first = l;
for (int i = 0; i < num; ++i) {
a[first] = b[i];
first++;
}
}
快速排序
快排与归并排序的区别:
1、归并排序的处理过程是由下到上的,先处理子问题,然后再合并。
2、快排正好相反,它的处理过程是由上到下的,先分区,然后再处理子问题。
快排有个partition() 分区函数。partition() 分区函数功能是选择一个元素作为 pivot(一般情况下,可以选择 p 到 r 区间的最后一个元素),然后对 A[p…r]分区,函数返回 pivot 的下标。
选取pivot的方式:
1、选择第一个
2、选择最后一个
3、选取第一个、最后一个以及中间的元素的中位数
快排递归代码
思路一:
类似选择排序。我们通过游标 i 把 A[p…r-1]分成两部分:
A[p…i-1]的元素都是小于 pivot 的,我们暂且叫它“已处理区间”,A[i…r-1]是“未处理区间”。
每次都从未处理的区间 A[i…r-1]中取一个元素 A[j],与 pivot 对比,如果小于 pivot,则将其加入到已处理区间的尾部,也就是 A[i]的位置。
思路二:
维护左右指针,将分区元素与左右指针相比。
void quickSort(int* a, int n){
quickSortn(a, 0, n-1);
}
void quickSortn(int* a, int l, int r) {
if (l >= r) return;
int pivot = partition2(a, l, r);
quickSortn(a, l, pivot-1);
quickSortn(a, pivot+1, r);
}
int partition(int* a, int l, int r) {
int pivot = a[r];
int i = l;
int j = l;
for (; j < r; j++){
if (a[j] < pivot) {
int tmp = a[j];
a[j] = a[i];
a[i] = tmp;
i++;
}
}
int tmp = a[i];
a[i] = a[r];
a[r] = tmp;
return i;
}
int partition2(int* a, int low, int high) {
int pivot = a[low];
while(low < high) {
while (low < high && a[high] >= pivot) {
high--;
}
a[low] = a[high];
while (low < high && a[low] <= pivot) {
low++;
}
a[high] = a[low];
}
a[high] = pivot;
return low;
}
快排非递归代码
借助栈来实现。
void quickSortNoRecusive(int* a, int l, int r) {
stack<int> s;
if (l < r) {
int pivot = partition(a, l, r);
if (pivot - 1 > l) {
s.push(pivot - 1);
s.push(l);
}
if (pivot + 1 < r) {
s.push(r);
s.push(pivot + 1);
}
while (!s.empty()) {
int start = s.top();
s.pop();
int end = s.top();
s.pop();
int pivot = partition(a, start, end);
if (pivot - 1 > start) {
s.push(pivot - 1);
s.push(start);
}
if (pivot + 1 < end) {
s.push(end);
s.push(pivot + 1) ;
}
}
}
}
快速排序的优化
优化1、使用三数取中法获取枢轴元素
三数取中(随机数算法效果相同)在处理升序数组时效率较高。
优化2、当待排序序列的长度分割到一定大小后,使用插入排序。
原因:对于很小的数组,快排不如插排效率搞。
优化3、在一次分割结束后,可以把与Key相等的元素聚在一起,继续下次分割时,不用再对与key相等元素分割。
例如:待排序序列 1 4 6 7 6 6 7 6 8 6
通过三数取中选取枢轴:下标为4的数6
转换后,待分割序列:6 4 6 7 1 6 7 6 8 6
本次划分后,未对与key元素相等处理的结果:1 4 6 6 7 6 7 6 8 6
下次的两个子序列为:1 4 6 和 7 6 7 6 8 6
本次划分后,如果对与key元素相等处理的结果:1 4 ==6 6 6 6 6 == 7 8 7
下次的两个子序列为:1 4 和 7 8 7
可以看出,在一次划分后,把与key相等的元素聚在一起,能减少迭代次数,效率会提高不少
template <class T>
void QSort(T arr[], int low, int high)
{
int first = low;
int last = high;
int left = low;
int right = high;
int leftLen = 0;
int rightLen = 0;
if (high - low + 1 < 10) {
insertSort(arr,low,high);
return;
}
//一次分割
int key = NumberOfThree(arr,low,high);//使用三数取中选择枢轴
while(low < high) {
while(high > low && arr[high] >= key) {
if (arr[high] == key) {//处理相等元素
Swap(arr[right],arr[high]);
right--;
rightLen++;
}
high--;
}
arr[low] = arr[high];
while(high > low && arr[low] <= key) {
if (arr[low] == key) {
Swap(arr[left],arr[low]);
left++;
leftLen++;
}
low++;
}
arr[high] = arr[low];
}
arr[low] = key;
//一轮快排结束后,把与key相等的元素移到key周围
int i = low - 1;
int j = first;
while(j < left && arr[i] != key)
{
Swap(arr[i],arr[j]);
i--;
j++;
}
i = low + 1;
j = last;
while(j > right && arr[i] != key)
{
Swap(arr[i],arr[j]);
i++;
j--;
}
QSort(arr, first, low - 1 - leftLen);
QSort(arr, low + 1 + rightLen, last);
}
int NumberOfThree(int* arr,int low,int high) {
int mid = low + ((high - low) >> 1);
if (arr[mid] > arr[high]){
Swap(arr[mid],arr[high]);
}
if (arr[low] > arr[high]){
Swap(arr[low],arr[high]);
}
if (arr[mid] > arr[low]){
Swap(arr[mid],arr[low]);
}
return arr[low];
}
桶排序
算法思想:
1、将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。
2、桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。
适用场景:
1、桶排序比较适合用在外部排序中。
所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。
2、要排序的数据需要很容易就能划分成 m 个桶,并且,桶与桶之间有着天然的大小顺序。
这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序。
3、数据在各个桶之间的分布是比较均匀的。
如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不平均,那桶内数据排序的时间复杂度就不是常量级了。在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。
计数排序
算法思想:
计数排序是桶排序的一种特殊情况。
当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 k,我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。
适用场景:
1、计数排序适用在数据范围不大的场景中。如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。
2、计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。
以统计分数排名为例:
假设只有 8 个考生,分数在 0 到 5 分之间。这 8 个考生的成绩我们放在一个数组 A[8]中,它们分别是:2,5,3,0,2,3,0,3。对其进行排序。方法如下:
1、考生的成绩从 0 到 5 分,我们使用大小为 6 的数组 C[6]表示桶,其中下标对应分数。不过,C[6]内存储的并不是考生,而是对应的考生个数。
2、对 C[6]数组顺序求和,C[6]存储的数据就变成:C[k]里存储小于等于分数 k 的考生个数。
3、维护一个数据R[8]存放排序后的结果。
4、从后到前依次扫描数组 A。比如,当扫描到值3 时,从数组 C 中取出下标为 3 的值 7,也就是说,到目前为止,包括自己在内,分数小于等于 3 的考生有 7 个,也就是说 3 是数组 R 中的第 7 个元素(也就是数组 R 中下标为 6 的位置)。当 3 放入到数组 R 中后,小于等于 3 的元素就只剩下了 6 个了,所以相应的 C[3]要减 1,变成 6。
5、重复上述第5步骤,当扫描完整个数组 A 后,数组 R 内的数据就是按照分数从小到大有序排列的了。
void countingSort(int* a, int n) {
if (n <= 1) {
return;
}
//计算最大的数值
int max = 0;
for (int i = 0; i < n; ++i) {
if (a[i] > max) {
max = a[i];
}
}
//初始化c数组
int * c = new int[max + 1];
for (int i = 0; i <= max; ++i) {
c[i] = 0;
}
for (int i = 0; i < n; ++i) {
c[a[i]]++;
}
//依次累加c数组
for (int i = 1; i <= max; ++i) {
c[i] = c[i-1] + c[i];
}
//依次累加c数组
for (int i = 1; i <= max; ++i) {
c[i] = c[i-1] + c[i];
}
//临时数组r,存放排序之后的结果
int * r = new int[n];
for (int i = n -1; i >= 0; --i) {
int index = c[a[i]] - 1;
r[index] = a[i];
c[a[i]]--;
}
// 将结果拷贝给a数组
for (int i = 0; i < n; ++i) {
a[i] = r[i];
}
}
基数排序
算法思想:
按位排序,要求数据可以划分成高低位,位之间有递进关系。比较两个数,我们只需要比较高位,高位相同的再比较低位。
以手机号码排序为例,先按照最后一位来排序手机号码,然后,再按照倒数第二位重新排序,以此类推,最后按照第一位重新排序。经过 11 次排序之后,手机号码就都有序了。
适用场景:
1、要排序的数据需要可以分割出独立的“位”来比较,而且位之间有递进的关系。
如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。
2、每一位的数据范围不能太大。
如果数据范围太大就不能用线性排序算法来排序,时间复杂度就无法做到 O(n) 了。
如何实现一个通用高效排序算法
对比各排序算法:使用快排
1、线性排序时间复杂度很低但使用场景特殊,如果要写一个通用排序函数,不能选择线性排序。
2、为了兼顾任意规模数据的排序,一般会首选时间复杂度为O(nlogn)的排序算法来实现排序函数。
3、同为O(nlogn)的快排和归并排序相比,归并排序不是原地排序算法,所以最优的选择是快排。
通用排序函数实现技巧
1、当元素个数小于某个常数小时,可以考虑使用O(n^2)级别的插入排序
2、当元素个数较多时,使用快排,注意优化快排分区点的选择。
3、防止堆栈溢出,可以选择在堆上手动模拟调用栈解决
4、用哨兵简化代码,每次排序都减少一次判断,尽可能把性能优化到极致
Java 中的排序算法 Arrays.sort ,综合了插入排序、堆排序、归并排序、快排。
1、若数组元素个数总数小于47,使用插入排序
2、若数据元素个数总数在47~286之间,使用快速排序。应该是使用的优化版本的三值取中的优化版本。
3、若大于286的个数,使用归并排序。