Sort
排序类算法是非常常见的算法,包括了比较类排序与非比较类排序
排序能够解决很多问题,有的编程语言也提供了一些排序算法函数(比如C++的sort)但是掌握基本的排序算法原理以及写法仍然是很重要的,并且排序也是面试常考的题目。
菜鸟教程给出了排序算法的复杂度描述
解释
- n指的是数据规模,即正常的大O表达法的n
- k指的是“桶”的个数
- in-place指占用常数级别的内存,不需要额外占用内存,Out-place则相反
- 稳定性:排序后 2 个相等键值的顺序和排序之前它们的顺序相同
目录
比较类排序
非比较类排序
交换排序
Bubble Sort
Bubble Sort,冒泡排序,冒泡排序的基本思想是重复的访问数据,一次比较两个元素,如果它们的顺序错误就把它们交换过来。走访数列的工作是重复地进行直到没有再需要交换,
How it works
- 比较相邻元素,如果前面的比后面的大,就交换
- 对所有相邻元素重复操作
- 对所有元素重复如上操作
- 重复如上步骤
Code
示意图
代码
#include<iostream>
#include<iomanip>
using namespace std;
void BubbleSort(int* a, int len) {
for (int i = 0; i < len - 1; i++) { //Bubble Sort的实现主体
for (int j = 0; j < len - 1; j++) {
if (a[j] > a[j + 1]) {
int temp = a[j];
a[j] = a[j + 1];
a[j + 1] = temp;
}
}
}
}
冒泡排序改进版:
考虑这样一个序列{2,1,3,4,5,6,7,8},除了最开始的2,1,其余的元素都已经有序,但是在执行冒泡排序的时候仍然会执行整个比较流程,但实际上他又是多余的。
这里我们可以这样修改
void BubbleSort(int* a, int len) { //改进版
bool swap = true; //增加控制变量
for (int i = 0; i < len - 1 && swap; i++) { //如果控制变量为false,表明数任取两个元素都已经有序,那么整体自然也有序
swap = false;
for (int j = 0; j < len - 1; j++) {
if (a[j] > a[j + 1]) {
int temp = a[j];
a[j] = a[j + 1];
a[j + 1] = temp;
swap = true;
}
}
}
}
复杂度分析
如果是优化过后冒泡排序,而整个数据都是有序的,那么冒泡排序就不需要交换数据,此时只执行n-1次比较,时间复杂度为O(n),但是如果是最坏的情况,每次都要交换,那么就需要1+2+3+…+n-1 = n(n-1)/2,时间复杂度为O(n2),所以总的时间复杂度为O(n2)
Quick Sort
Quick Sort,快速排序,是一种分治算法,每次将一组数据分成两个区域,比基准值大的排一个区域,比基准值小的排另一个区域,最终使得整个数据有序,也是冒泡排序的升级版
How it works
- 首先选定一个元素,作为所有元素的基准(pivot)
- 排序元素,比基准小的排一边,比基准大的元素排另一边
- 在两个边区重复如上操作
Code
void QuickSort(int* a, int left,int right) {//算法导论写法
if (left > right) {
return;
}
int pivot = a[right]; //定义基准pivot
int fast = left; //定义快慢指针
int slow = left;
for (; fast < right;fast++) { //QuickSort实现主体
if (a[fast] <= pivot) {
int temp = a[fast];
a[fast] = a[slow];
a[slow] = temp;
slow++;
}
}
a[right] = a[slow];
a[slow] = pivot;
QuickSort(a, 0, slow - 1); //二分区间递归
QuickSort(a, slow + 1, right);
}
void QuickSort(int* a, int start,int end) {//选第一个元素为基准
if (start > end) {
return;
}
int key = a[start];
int left = end, right = end;
for (; left > start; left--) {
if (a[left] >= key) {
swap(a[right],a[left]);
right--;
}
}
a[start] = a[right];
a[right] = key;
cout << key << ' ' << right + 1 << endl;
QuickSort(a, start, right - 1);
QuickSort(a, right + 1, end);
}
快排的递归还有一种写法:
int Mid(int* a, int begin, int end) {
int key = a[end];
int left = begin;
int right = end;
while (left < right) {
while (left < right && a[left] <= key) {
left++;
}
if (left < right) {
a[right--] = a[left];
}
while (left < right && a[right] >= key) {
right--;
}
if (left < right) {
a[left++] = a[right];
}
}
a[left] = key;
return left;
}
void QuickSort(int* a, int begin, int end) {
if (begin > end) {
return;
}
int mid = Mid(a, begin, end);
QuickSort(a, begin, mid - 1);
QuickSort(a, mid + 1, end);
}
以上都是使用了递归写法,但是递归众所周知耗时很久,那么有没有非递归写法呢?
答案是有的:
int Mid(int* a, int begin, int end) {
int key = a[end];
int left = begin;
int right = end;
while (left < right) {
while (left < right && a[left] <= key) {
left++;
}
if (left < right) {
a[right--] = a[left];
}
while (left < right && a[right] >= key) {
right--;
}
if (left < right) {
a[left++] = a[right];
}
}
a[left] = key;
return left;
}
void QuickSort(int* a, int left, int right) {//非递归写法
stack<int> s;
s.push(left);
s.push(right);
while (!s.empty()) {
int tempRight = s.top();
s.pop();
int tempLeft = s.top();
s.pop();
int mid = Mid(a, tempLeft, tempRight);
if (tempLeft < mid - 1) {
s.push(tempLeft);
s.push(mid - 1);
}
if (tempRight > mid + 1) {
s.push(mid + 1);
s.push(tempRight);
}
}
}
复杂度分析
快速排序算法的时间复杂度主要取决于基准的选择,如果基准选的好,每次都能是处于区间数列中间的数,那么快速排序就会运行得非常快,根据递归调用可知,只需要log2n次调用,每次需要比较n / i(i为划分次数)个数字,总体复杂度为O(nlogn),但是如果基准选的不好,每次都是区间数列的边缘数,那么就意味着大量的递归调用和大量的比较,此时复杂度为O(n^2)
空间性能上,考虑递归调用次数,最好的情况就是O(logn),最坏情况为O(n)。
快速排序优化
1.优化基准的选取
考虑到快速排序基准选择对快速排序算法运行时间影响的重要性,我们可以通过优化基准的选择来改进快速排序算法。
1.随机选取
在left与right之前随机选取一个数作为基准
2.三数取中
int mid = left + (right - left) / 2;
if (a[left] < a[right]){
swap(a[left],a[right]);
}
if (a[mid] < a[right]){
swap(a[mid],a[right]);
}
if (a[mid] < a[left]){
swap(a[mid],a[left]);
}
int pivot = a[left];
3.九数取中
对于小数组三数取中可能已经足够解决问题,但是如果数组更大,可以采用九数取中。
先从数组中分3次取样,每次取3个数,从三个样品中取出中数,然后从这3个中数当中再取出1个中数作为pivot
2.优化小数组时的排序方案
虽然快速排序算法非常强大,但是如果我们使用快速排序算法去解决一些非常简单的排序问题,有时候未免有些大材小用。因此,我们可以在数据量小的时候直接使用插入排序,因为插入排序是简单排序当中性能相对最优的。
选择排序
Selection Sort
Selection Sort,中文名简单选择排序,就是依次筛选出元素中最大(小)的元素,排列在数组的某一侧
How it works
Selection Sort的执行过程,主要是利用双循环,先确定第一个元素一定是所有元素中最小(大)的,然后再排序第二个最小(大)的,逐渐递进,从而实现排序
算法执行
- 从第一个数组元素开始,获取一个数组元素,记为a
- 获取当前获取到的数组元素后一位的元素,这里记为b
- 如果b小于a就交换ab,否则不交换
- 重复执行2-3直至数组结尾
- 当4执行完毕后,重复执行1-4直至数组结尾
Code
#include<iostream>
#include<iomanip>
using namespace std;
void SelectionSort(int* a, int len) {
for (int i = 0; i < len; i++) { //Selection Sort的实现主体
int index = i;
for (int j = i + 1; j < len; j++) {
if (a[index] > a[j]) {
index = j;
}
}
int temp = a[index];
a[index] = a[i];
a[i] = temp;
}
}
复杂度分析
对于简单选择排序,如果所有的数据都是有序的,那么也只需要n-1次比较,时间复杂度为O(n)如果是最坏的情况,就需要1+2+3+…+n-1 = n(n - 1)/2次比较,时间复杂度为O(n^2)
但是要注意,因为每次只对索引进行操作,而冒泡排序对数值操作,所以实际上简单选择排序的运算时间性能上还是比冒泡排序要好。
Heap Sort
Heap Sort,中文名堆排序,是选择排序的升级版
要了解堆排序,首先我们要了解堆这一数据结构。
堆是具有下列性质的完全二叉树:每个结点的值都大于或等于其左右孩子结点的值,称为大顶堆 (左图所示) 或者每个结点的值都小于或等于其左右孩子结点的值,称为小顶堆(右图所示)。
因此我们可以得出堆这一数据结构的一个重要性质:根结点一定是整个树中的最大(最小)值对应的结点。
How it works
假设我们当前的堆排序利用大顶堆,那么首先我们将待排序的序列构造成一个大顶堆。此时,整个序列的最大值就是堆顶的根结点。将它移走,然后将剩余的序列重新构造成一个堆,这样就会得到到剩下的元素中的最大。如此反复执行,便能得到有序序列了
Code
需要注意,由于完全二叉树的性质,所以数组从1开始会相对容易理解。
void HeapAdjust(int* a,int i,int len) {//大顶堆
int s = i, temp = a[i];
for (int j = 2 * i; j <= len;j *= 2) {
if (j < len && a[j] < a[j + 1]) {
j++;
}
if (temp >= a[j]) {
break;
}
a[s] = a[j];
s = j;
}
a[s] = temp;
}
void HeapSort(int* a, int len) {//升序排序
int i;
for (i = len / 2; i > 0; i--) {
HeapAdjust(a,i,len);
}
for (i = len; i > 1;i--) {
int temp = a[i];
a[i] = a[1];
a[1] = temp;
HeapAdjust(a,1,i - 1);
}
}
复杂度分析
注意我们的堆排序起点是从完全二叉树最下层的最右边的非终端结点开始的,他只比较当前的结点和孩子判断是否需要交换,所以时间复杂度为O(n)
构建堆时,根据完全二叉树的性质,最大深度为log2n+1,所以每次构建堆都需要logn的时间复杂度,总共进行n-1次,所以时间复杂度为O(nlogn)
由于堆排序不依赖于数据本身是否有序,所以堆排序在时间性能上相当稳定,在任何情况下都是O(nlogn)
而堆排序也不依赖于额外的空间,所以堆排序的空间复杂度也很低,但是堆排序由于是跳跃性的,所以堆排序也是一种不稳定的排序算法。
插入排序
Insertion Sort
Insertation Sort,中文名插入排序,一般说到插入排序,指的都是简单插入排序
How it works
Insertation Sort的工作过程,实际上就是从第i项(a[i - 1],i>=1)开始,不断对前i-1项进行排序,最后再将第i项放入其中。
算法执行
- 从第一项数据开始,默认其已经排好序
- 取出下一个元素,从该元素开始(不包括该元素)从后往前扫描
- 如果扫描到的元素大于该元素,则将当前扫描到的元素后移
- 重复步骤3,直到该元素大于当前扫描到的元素
- 将该元素插入到新位置
- 重复如上步骤2-5
Code
#include<iostream>
#include<iomanip>
using namespace std;
void InsertationSort(int* a, int len) {
for (int i = 1; i < len; i++) { //Insertation Sort的实现主体
int key = a[i];
int j = i - 1;
while (j >= 0 && a[j] > key) {
a[j + 1] = a[j];
j--;
}
a[j + 1] = key;
}
}
复杂度分析
首先考虑最好的情况,如果数据本身已经有序,那么只需进行n-1次比较,此时时间复杂度为O(n),下面考虑最坏的情况,即整个数据都是逆序,此时需要比较和移动的次数都大约为n^2 / 4次,总的时间复杂度为O(n^2)
但是相比于冒泡排序和简单选择排序,插入排序的效率还是比他们高
折半插入排序
本质上就是利用二分法找到待插入的元素需要插入的位置
void InsertionSort(int* a, int len) {//升序
for (int i = 1; i < len; i++) {
int j = i - 1;
int key = a[i];
int l = 0, r = j,mid;
while (l <= r) {
mid = (l + r) / 2;
if (a[mid] <= key) {
l = mid + 1;
}
else {
r = mid - 1;
}
}
for (int x = i; x > l; x--) {
a[x] = a[x - 1];
}
a[l] = key;
}
}
希尔排序
Shell Sort
Shell Sort,希尔排序,简单插入排序的改进版
How it works
希尔排序的思想是,首先我们将n个数据分组,然后对每一个组进行简单插入排序,完成后再细分组,直到组数为1为止,此时进行最后一次插入排序,循环结束,排序完成。
此处引用简书上希尔排序的一个图片,非常清楚的演示了希尔排序思想及其执行过程
关于希尔排序如何分组,至今仍是一个数学难题,一般我们写题目使用二分(d = d / 2)即可
并且由于记录的跳跃性,所以希尔排序算法是一种不稳定的排序算法。
void ShellSort(int* nums,int len){
int temp,i,j,d;
for (d = len / 2;d >= 1;d /= 2){
for (i = d;i < len;i++){
temp = nums[i];
for (j = i - d;j >= 0 && temp < nums[j];j -= d){
nums[j+d] = nums[j];
}
nums[j+d] = temp;
}
}
}
复杂度分析
希尔排序的复杂度取决于分组和数据的组有序程度,其中如何分组是最为重要的,根据分组的不同,时间复杂度为介于O(n1.3)-O(n2)之间
但是总的来说,由于希尔排序的跳跃性,所以希尔排序的时间性能还是比简单插入排序来的好。
归并排序
Merge Sort
Merge Sort,归并排序,是一种基于归并操作修改而来的算法。该算法采用分治的办法,先使子序列有序,再使子序列段间有序,最后归并
How it works
- 将序列分成两个子序列
- 对子序列采用归并排序
- 将排序好的两个子序列归并
Code
递归版本示意图
递归代码
void Merge(int* tempArr, int* ans, int start, int mid, int end) {
int i, j, k = start;
for (j = mid + 1, i = start; i <= mid && j <= end; k++) {
if (tempArr[j] > tempArr[i]) {
ans[k] = tempArr[i++];
}
else {
ans[k] = tempArr[j++];
}
}
for (; i <= mid; k++) {
ans[k] = tempArr[i++];
}
for (; j <= end; k++) {
ans[k] = tempArr[j++];
}
}
void MergeSort(int* a, int* ans, int start, int end) {//递归
int* tempArr = new int[end + 1];
if (start == end) {
ans[start] = a[end];
}
else {
int mid = (start + end) / 2;
MergeSort(a, tempArr, start, mid);
MergeSort(a, tempArr, mid + 1, end);
Merge(tempArr, ans, start, mid, end);
}
}
非递归版本
void Merge(int* tempArr, int* ans, int start, int mid, int end) {
int i, j, k = start;
for (j = mid + 1, i = start; i <= mid && j <= end; k++) {
if (tempArr[j] > tempArr[i]) {
ans[k] = tempArr[i++];
}
else {
ans[k] = tempArr[j++];
}
}
for (; i <= mid; k++) {
ans[k] = tempArr[i++];
}
for (; j <= end; k++) {
ans[k] = tempArr[j++];
}
}
void MergePass(int* a,int* ans,int k,int size) {
int i = 1;
int j;
while (i <= size - 2 * k + 1) {
Merge(a,ans,i,i + k - 1,i + 2 * k - 1);
i += 2 * k;
}
if (i < size - k + 1) {//归并最后两个序列
Merge(a, ans, i, i + k - 1, size);
}
else {//序列只有一个数
for (j = i; j <= size; j++) {
ans[j] = a[j];
}
}
}
void MergeSort(int* a, int size) {
int* ans = new int[size];
int k = 1;
while (k < size) {
MergePass(a,ans,k,size);
k = 2 * k;
MergePass(ans,a,k,size);
k = 2 * k;
}
}
复杂度分析
由于归并排序每一趟的归并过程都需要将数组中一确定长度的有序序进行两两归并,因此我们就需要遍历整个数组,这需要O(n),而归并过程需要执行log2n次,所以总的时间复杂度为O(nlogn),同时由于每次运行都需要额外的空间来保存当前的归并结果,所以空间复杂度为O(n)
同时,因为归并排序会逐个比较,所以归并排序是一种稳定的算法。
但是,由于使用递归会造成log2n的栈深度,使用归并排序的时候还是使用非递归(迭代)方式更好。
非比较类排序
Counting Sort
Counting Sort,计数排序,该算法要已知当前数据的最大项与最小项
How it works
1.首先求得当前数据之中的最大值与最小值
2.定义一个计数数组,全部赋初值为0
3.一次遍历所有数据,记录各个数据出现的次数
4.再遍历一次计数数组,根据数组中ai项的个数重新给数据赋值,遍历结束,排序完成
Code
void CountingSort(int* a, int len) {//不考虑负数
int max = INT_MIN, i;
for (i = 0; i < len; i++) {
max = a[i] > max ? a[i] : max;
}
int* count = new int[max + 1];
for (i = 0; i <= max; i++) {
count[i] = 0;
}
for (i = 0; i < len; i++) {
count[a[i]]++;
}
i = 0;
for (int x = 0; x <= max; x++) {
while (count[x] != 0) {
a[i++] = x;
count[x]--;
}
}
}
复杂度分析
计数排序是一种稳定的排序算法,其时间复杂度和空间复杂度均为O(n+k),当数据比较小的时候,选择计数排序是相当合适的。
BucketSort
桶排序首先假设所有的数据都服从均匀分布,在此基础上将数组的所有的元素均匀的分布到数量有限的每个桶里,再对每个桶进行单独的排序(此事发生的排序可能是别的排序算法或者继续递归桶排序),最后将所有桶的元素取出即形成一个有序的序列。
桶排序的思想,是一种极端的分治思想,其基于数组的所有元素都服从均匀分布,因此可以给数组的元素划定范围,根据范围确定有多少个桶。
此时就可以基于某种映射函数f,将待排序的数组元素映射到桶中,数学表示为f:k->B[i],其中k为待排序的数组元素,B[i]为第i个桶。对每个桶进行排序,合并每个桶的序列,就完成了桶排序。
一般来说,映射函数为f = a[i] / k,k ^ 2 = n,n为数组元素个数
由桶排序的基本概念及定义可知,为了提升桶排序的效率,我们需要:
1.在空间充足的情况下尽可能提升桶的数量
2.使用的映射函数f能保证数据均匀的分布在各个桶中
3.对于桶中的元素,选择的排序算法时候合适
How it works
1.设定一个数组表示桶
2.遍历数组,将数组元素放入桶
3.对非空桶进行排序
4.将桶中的元素放回原数组中
Code
#define NBUCKET 6 // 桶的数量
#define INTERVAL 10 // 每个桶能存放的元素个数
typedef struct Bucket {
int data;
Bucket* next;
};
Bucket* InsertionSort(Bucket* list) {
Bucket* k, * nodeList;
if (list == NULL || list->next == NULL) {
return list;
}
nodeList = list;
k = list->next;
nodeList->next = NULL;
while (k != NULL) {
Bucket* ptr;
if (nodeList->data > k->data) {
Bucket* tmp;
tmp = k;
k = k->next;
tmp->next = nodeList;
nodeList = tmp;
continue;
}
for (ptr = nodeList; ptr->next != 0; ptr = ptr->next) {
if (ptr->next->data > k->data)
break;
}
if (ptr->next != 0) {
Bucket* tmp;
tmp = k;
k = k->next;
tmp->next = ptr->next;
ptr->next = tmp;
continue;
}
else {
ptr->next = k;
k = k->next;
ptr->next->next = 0;
continue;
}
}
return nodeList;
}
void BucketSort(int* arr,int len) {
int i, j;
Bucket** buckets;
buckets = new Bucket * [NBUCKET];
for (i = 0; i < NBUCKET; ++i) {
buckets[i] = NULL;
}
for (i = 0; i < len; ++i) {
Bucket* current;
int pos = arr[i] * 10;
current = new Bucket{ arr[i],buckets[pos] };
buckets[pos] = current;
}
for (i = 0; i < NBUCKET; ++i) {
buckets[i] = InsertionSort(buckets[i]);
}
for (j = 0, i = 0; i < NBUCKET; ++i) {
Bucket* node = buckets[i];
while (node) {
arr[j++] = node->data;
node = node->next;
}
}
}
复杂度分析
对于桶排序,最好的情况自然是数组已经有序,此时我们只需要完成放桶,放回的操作,时间复杂度为O(n),空间复杂度为O(n + M),M为桶个数,对于最坏情况,无非所有的桶都要执行排序,那么时间复杂度就退化为O(n^2),由于是依次遍历的,所以桶排序保证了排序算法的稳定性。因此,桶排序是一种平均时间复杂度为O(n + M),空间复杂度为O(n + M)的稳定的排序算法。
基数排序
Radix Sort
基数排序是一种非比较类的排序,它是利用了比较关系的时候存在的优先级的思想。
LSD(最低优先法)
比如,我们对7和15排序,虽然7的个位大于15的个位,但是我们仍然认为15大于7,这是因为比个位权值更高的十位上15的值为1而7为0。这种权值比较的思想贯穿了整个基数排序。
举个简单的栗子:比如我们要对{1,2,47,40,85,74,16,77,27,62}排序,我们首先获得他们当中的最大数85,得知了85包括了十位和个位,又因为十位的权值高于个位,所以我们相当于得到了一个贯穿整个基数排序的权值排列:十位 > 个位
然后,我们只需要从最低优先级的权值开始,将数的对应权值的数放入对应的桶,比如16在个位是6,就放入bucket[6],以此类推,直到整个数组遍历完成,此时按照每个桶中的数,重新放回数组,还是我们前面的例子,此时的结构如下:
接下来我们先按照桶编号,再按照放入桶中的顺序,一次给数组赋值,从而到如下数组:
此时最低优先级的权值已经完成,接下来运行十位这一权值,注意此时我们遍历的数组a是已经更新了的数组a
此时我们再把桶中的数取出,排序完成。
How it works
1.首先计算出最大的数的位数
2.从最低权值开始,遍历数组,放入对应的桶
3.重复2操作直到有序
typedef struct Bucket {
int num;
Bucket* next;
};
int getMax(int* a, int len) {
int max = -65535;
for (int i = 0; i < len; i++) {
max = max > a[i] ? max : a[i];
}
return max;
}
void RadixSort(int* a, int len) {
int maxNum = getMax(a, len);
int maxDigit = 0;
while (maxNum) {
maxDigit++;
maxNum /= 10;
}
int mod, di = 1;
for (int i = 0; i < maxDigit; i++) {
Bucket* L = new Bucket[10];
for (int i = 0; i < 10; i++) {
L[i].next = NULL;
}
for (int x = 0; x < len; x++) {
mod = a[x] / di % 10;
if (L[mod].next == NULL) {
L[mod].next = new Bucket{ a[x],NULL };
}
else {
Bucket* temp = L[mod].next;
while (temp->next) {
temp = temp->next;
}
temp->next = new Bucket{ a[x],NULL };
}
}
di *= 10;
int index = 0;
for (int i = 0; i < 10; i++) {
Bucket* temp = L[i].next;
while (temp) {
a[index++] = temp->num;
temp = temp->next;
}
}
delete[] L;
}
}
复杂度分析
由于基数排序先遍历到的数最后一定会出现在前面,所以基数排序是稳定的排序,假设基数排序至少需要k个关键字,数组的长度为n,那么每次排序都需要执行k*(2n + k)次,由于k一般远远小于n,所以可以视为时间复杂度为O(2kn)或者说是线性的时间复杂度。
至于空间复杂度,由于每次都需要开桶,所以需要O(n+k)空间复杂度