内排序算法合集
文章目录
- 内排序算法合集
- 前言
- 冒泡排序
- 冒泡排序的实现
- 冒泡排序的简单实现
- 冒泡排序的优化版本
- 冒泡排序的复杂度分析
- 简单选择排序
- 简单选择排序的实现
- 简单选择排序的复杂度分析
- 直接插入排序
- 直接插入排序的实现
- 直接插入排序的复杂度分析
- 希尔排序
- 希尔排序原理
- 希尔排序的实现
- 希尔排序复杂度分析
- 堆排序
- 堆排序的原理
- 堆排序的实现(升序)
- 堆排序的复杂度分析
- 归并排序
- 二路归并
- 二路归并的实现
- 归并排序
- 自顶向下归并排序的实现
- 归并排序的时间复杂度分析
- 归并排序的应用——求逆序对
- 快速排序
- 快速排序的原理
- 枢轴(Pivot)
- 快速划分算法
- 快速划分算法的思路
- 快速排序的实现
- 快速排序的优化策略
- 随机选取
- K-选取算法
- 三数取中
- 小数组优化
- 桶排序
- 桶的划分与选取
- 桶排序的实现
- 基数排序
- 基数排序正确性的证明
- 基数排序的实现
- 计数排序
- 计数排序的原理
- 计数排序的实现
前言
根据对内存的使用情况,可将排序算法分为内排序和外排序两种。内排序是指排序期间全部元素都存储于内存,并在内存中调整等待排序元素的存放位置;外排序是指排序期间大部分元素存储于外存中,排序过程中借助内存,调整那些存放在外存且等待排序的元素的存放位置。
冒泡排序
无论你学习哪种编程语言,在学到循环和数组时,通常都会介绍一种排序算法来作为例子,而这个算法一般就是冒泡排序。并不是它的名称很好听,而是说这个算法的思路最简单,最容易理解。
冒泡排序的实现
冒泡排序(Bubble Sort)是一种交换排序,它的基本思想是:两两比较相邻记录的关键字,如果反序则交换,直到没有反序的记录为止。
如下为单趟冒泡的动图演示
冒泡排序的简单实现
- 单趟冒泡过程为从0开始枚j,只要arr[j] > arr[j + 1] (j < n - 1),就交换
- 执行n - 1次
void BubbleSort(vector<int> &arr)
{
for (int i = 0, n = arr.size(); i < n; i++)
{
for (int j = 0; j < n - 1; j++)
{
if (arr[j] > arr[j + 1])
swap(arr[j], arr[j + 1]);
}
}
}
冒泡排序的优化版本
对于n个元素的序列我们进行n - 1次冒泡可以使得n个元素全部有序,但是可能在小于n - 1次冒泡的时候我么就已经实现有序了,那么对于后面的操作都是冗余的,于是我们可以在此基础上进行优化。
- 执行冒泡排序规则
- 当某次冒泡没有发生交换,说明排序完成,我们退出循环
void BubbleSort(vector<int> &arr)
{
bool flag = true;
for (int i = 0, n = arr.size(); i < n && flag; i++)
{
flag = false;
for (int j = 0; j < n - 1; j++)
{
if (arr[j] > arr[j + 1])
swap(arr[j], arr[j + 1]), flag = true;
}
}
}
冒泡排序的复杂度分析
最好的情况:线性表本身有序,那么我们只需要进行n - 1次比较即可,时间复杂度为O(N)
最坏的情况:当线性表本身为逆序,需要进行n - 1次冒泡,那么我们总的比较次数为1 + 2 + … + n - 1 = (n - 1) * n / 2
因此平均时间复杂度为O(N^2)
空间复杂度:O(1)
简单选择排序
选择排序(Select Sort)的基本思想是每一趟在n-i+1(i=1,2,…,n - 1)个记录中选取关键字最小的记录作为有序序列的第i个记录。我们这里先介绍的是简单选择排序法。
如下为单个位置进行选择排序的动图演示
简单选择排序的实现
- 枚举i,在下标[ i , n - 1 ]内找最小元素下标mini
- 查找结束后交换arr[mini]和arr[i]
void SelectSort(vector<int> &arr)
{
for (int i = 0, n = arr.size(); i < n; i++)
{
int mini = i;
for (int j = i + 1; j < n; j++)
{
if (arr[j] < arr[mini])
mini = j;
}
swap(arr[mini], arr[i]);
}
}
简单选择排序的复杂度分析
简单排序的好处在于减少了交换移动的次数,降低了性能消耗,自然节约了时间,但是我们发现简单选择排序无论最好最坏情况,都要进行(n - 1) * n / 2次比较,因此简单选择排序的时间复杂度为:O(N^2)
虽然简单选择排序的时间复杂度和冒泡排序相同,但是由于前者不需要进行大量的交换,所以当数据分布均匀的时候简单选择排序的效率应该是略优于冒泡排序的。
空间复杂度:O(1)
直接插入排序
直接插入排序( Straight Insertion Sort )的基本操作是将一个记录插入到已经排好序的有序表中,从而得到一个新的、记录数增1的有序表。
如下为直接插入排序的动图演示
直接插入排序的实现
- 我们从前向后遍历线性表
- 记arr[ i ] = key,然后从i向前枚举j,找到合适的插入位置
- 当arr[ j ] > key,我们就继续向前枚举
- 否则退出循环,j + 1就是我们key的插入位置
void InsertSort(vector<int> &arr)
{
for (int i = 1, n = arr.size(); i < n; i++)
{
int key = arr[i];
int j = i - 1;
for (; j >= 0 && arr[j] > key; j--)
{
arr[j + 1] = arr[j];
}
arr[j + 1] = key;
}
}
直接插入排序的复杂度分析
最好的情况:序列有序,则进行一次遍历即可,即O(N)
最坏的情况:序列逆序,则我们要比较1 + 2 + … + n - 1 = (n - 1) * n / 2次,即O(N^2)
因此平均时间复杂度为:O(N ^ 2)
空间复杂度:O(1)
希尔排序
前面的三种排序的时间复杂度都是O(n2),在最初的时候,计算机学界的大多数声音是排序算法的时间复杂度不可能突破O(n2),但是有一天一名学者发布了一种超越O(n2)的排序算法后,紧接着如雨后春笋般冒出了好几种可以超越O(n2)的排序算法,并把内排序算法的时间复杂度提升到了O(nlogn),“不可能超越O(n^2)'的声音成为了历史。‘
希尔排序原理
希尔排序(Shell Sort)是D.L.Shell于1959年提出的一种排序算法,在此之前的排序算法的时间复杂度基本上都是O(n2),希尔排序可以说是突破O(n2)的第一批算法之一。
介绍希尔排序前我们介绍了直接插入排序,它在某些时候的排序效率其实是很高的,当我们的待排序序列接近有序的时候,只需要进行少量的插入,近似O(n)的时间复杂度即可达到有序。当待排序数据量较小的时候,直接插入排序的优势也比较明显。
我们希尔排序就是把原先大量的数据划分为若干个子序列,此时对于子序列来讲数据量是很小的(上面的特殊情况二),对每个子序列进行直接插入排序,当整个序列基本有序的时候(上面的特殊情况一 ),我们在对整个序列进行排序。
这里的子序列并非指连续子序列,而是指采用跳跃分割的策略,:将相距某个”增量“的记录组成一个子序列,这样才能保证在子序列内分别进行直接插入排序后得到的结果是基本有序而不是局部有序。
如下为gap为n / 2时的单次插入排序动画演示
希尔排序的实现
- 增量gap初始为序列长度n
- 对以gap划分的子序列进行直接插入排序
- 每进行一次插入排序,gap = gap / 3 + 1
- 循环结束条件是gap > 1,保证了最后一次对整个序列进行直接插入排序
void ShellSort(vector<int> &arr)
{
int n = arr.size();
int gap = n;
while (gap > 1)
{
gap = gap / 3 + 1;
for (int i = gap; i < n; i++)
{
int key = arr[i];
int j = i - gap;
for (; j >= 0 && arr[j] > key; j -= gap)
{
arr[j + gap] = arr[j];
}
arr[j + gap] = key;
}
}
}
希尔排序复杂度分析
希尔排序很关键的一点在于”增量“gap的选取,增量的选择目前仍然是一个数学难题。
Papernov和Stasevic于1965年提出了一种增量序列:dlta = {1 , 3 , 7 , 15 , 31 , 63 … , 2 ^ k - 1 , …},从后往前选取增量序列中的增量,希尔排序的时间复杂度可以改进为O(n^1.5)
Pratt于1971年也提出了自己的增量序列:
dlta = {1, 2 , 3 , 4, 6 , 8 , 9 , 12 , 16 , … , 2p*3q …}
可见其中各项只有2和3两种质因子,在此情况下希尔排序算法时间复杂度最坏为O(n(logn) ^ 2)
尽管Pratt序列的效率很高,但因其中各项的间距太小,会导致迭代趟数过多,因此Sedgewick综合Papernov-Stasevic序列和Pratt序列的优点,提出了以下增量序列:
dlta = {1,5,19,41,109,209,505,929,2161,3905,8929,…}
其中各项均为:9 * 4 ^ k - 9 * 2 ^ k + 1或者4 ^ k - 3 * 2 ^ k + 1的形式
如此改进后希尔排序的最坏时间复杂度为O(n(4/3))**,**平均时间复杂度为O(n(7/6)),在通常的应用环境中,这一增量序列的综合效率最佳。
堆排序
堆排序的原理
和希尔排序类似,堆排序也是基于基础排序的优化排序算法。
我们前面介绍了简单选择排序,我们在n个数据中进行n - 1次比较,得到最小元素,对于查找第一个数据是可以理解的,因为你要找到最小元素必须遍历整个序列。
可惜的是,这样的操作并没有把每一趟的比较结果保存下来。在后一趟的比较中,有许多比较在前一趟已经做过了,但由于前一趟排序时未保存这些比较结果,所以后一趟排序时又重复执行了这些比较操作,因而记录的比较次数较多。
如果可以做到每次在选择到最小记录的同时,并根据比较结果对其他记录做出相应的调整,那样排序的总体效率就会非常高了。而堆排序(Heap Sort),就是对简单选择排序进行的一种改进,这种改进的效果是非常明显的。**堆排序算法是Floyd和Williams在1964年共同发明的,同时,他们发明了“堆”这样的数据结构。**堆这一数据结构不仅仅在排序算法中涉及,在很多算法中也是优秀的辅助手段,因此堆排序算法的发明对于数据结构与算法的发展起到了很大的助推作用。
对于堆/二叉堆的概念及构造请见堆/二叉堆详解C/C++]-CSDN博客,博客对于两种建堆方式的时间复杂度分析也进行了讲解,非常详细。
假如你已经了解了堆的概念用途,以及构建方法,我们下面来介绍堆排序的实现。
即然简单选择排序的效率掣肘于每次对于最小元素的选择,那么我们可以以此为切入点来进行优化。对于我们的堆结构,堆顶就是最大/小元素,很符合我们的需求。
我们假如建立大根堆,堆顶元素就是最大元素,其对应位置应该是倒数倒数第一个待排序元素,我们把堆顶和倒数第一个待排序元素交换,这样堆顶元素到了它应该待的位置,但是破坏了堆结构,怎么办呢?
此时除了堆顶外堆的其他部分都满足大根堆条件,我们执行AdjustDown调整堆就又得到了一个大根堆。如此反复,最终可以得到一个升序序列。
堆排序动画演示如下
堆排序的实现(升序)
- 以基于向下调整算法的自底向上堆构建算法先建堆
- 每次把堆顶元素和待排序元素的最后一个元素交换
- 对堆中剩余未排序元素进行向下调整
- 执行n - 1次即可有序
void AdjustDown(int parent, vector<int> &arr, int n)
{
int child = parent * 2 + 1;
while (child < n)
{
if (child + 1 < n && arr[child] < arr[child + 1])
{
child++;
}
if (arr[parent] < arr[child])
{
swap(arr[parent], arr[child]);
parent = child;
child = parent * 2 + 1;
}
else
break;
}
}
void HeapSort(vector<int> &arr)
{
int n = arr.size();
for (int i = (n - 2) / 2; i >= 0; i--)
{
AdjustDown(i, arr, n);
}
for (int i = 1; i < n; i++)
{
swap(arr[0], arr[n - i]);
AdjustDown(0, arr, n - i);
}
}
堆排序的复杂度分析
堆排序的时间复杂度还是很好分析的,显然无论好坏平均都是O(nlogn)
由于初始构建堆所需的比较次数较多,因此,它并不适合待排序序列个数较少的情况。
归并排序
归并排序是分治算法的应用,归并排序的核心思路是将多个有序序列合并为一个新的有序序列。归并排序还有其特殊应用场景如:求序列中的逆序数对。
二路归并
相信很多编程初学者都遇到过这样一个问题,要求合并两个有序序列为一个新的有序序列。其基本思想是:对两个有序序列,分别取出这两个序列中键值最小的项,并选出两个数据项中最小的项放置到新的序列中,循环上述动作,直到两个序列中的所有数据都已被放置到新的序列中。此时,新的序列即为排序结果。二路归并算法的主循环最多执行n + m次,因此其复杂度为O(n+m)。
二路归并的实现
vector<int> two_way_merge(const vector<int> &arr1, const vector<int> &arr2)
{
int m = arr1.size(), n = arr2.size();
int l = 0, r = 0, idx = 0;
vector<int> ret(m + n);
while (l < m && r < n)
{
if (arr1[l] < arr2[r])
ret[idx++] = arr1[l++];
else
ret[idx++] = arr2[r++];
}
while (l < m)
ret[idx++] = arr1[l++];
while (r < n)
ret[idx++] = arr2[r++];
return ret;
}
归并排序
归并排序跟我们自底向上建堆的思想很像,都是把问题划分成若干个子问题。
归并排序算法的基本思想是:首先将序列拆分,直到被拆分的子序列长度为1,此时子序列显然有序。然后,使用二路归并算法,将有序的短序列依次合并为长序列,最终完成序列的排序。根据归并时对子序列划分方式的不同,**归并排序算法又可分为两种:自顶向下的归并排序和自底向上的归并排序。**我们这里只介绍自底向上的归并排序的实现。
自顶向下归并排序的实现
- _MergeSort(vector &src, vector &tmp, int left, int right)代表对arr进行归并排序排序结果存放于tmp
- 初始待排序序列arr,以[0,n-1]为待排序区间,arr为tmp调用_MergeSort
- _MergeSort中构建right + 1大小的newtmp辅助数组,
- 令mid = (right + left) >> 1,分别归并排序[left, mid],[mid + 1 , right],存于newtmp
- 将newtmp的[left, mid],[mid + 1 , right]区间二路归并到tmp
void Merge(vector<int> &src, vector<int> &tar, int left, int mid, int right)
{
int l = left, r = mid + 1, idx = left;
while (l <= mid && r <= right)
{
if (src[l] < src[r])
tar[idx++] = src[l++];
else
tar[idx++] = src[r++];
}
while (l <= mid)
tar[idx++] = src[l++];
while (r <= right)
tar[idx++] = src[r++];
}
void _MergeSort(vector<int> &src, vector<int> &tmp, int left, int right)
{
int mid = (right + left) >> 1;
vector<int> newtmp(right + 1);
if (left == right)
tmp[left] = src[left];
else
{
_MergeSort(src, newtmp, left, mid);
_MergeSort(src, newtmp, mid + 1, right);
Merge(newtmp, tmp, left, mid, right);
}
}
void MergeSort(vector<int> &arr)
{
_MergeSort(arr, arr, 0, arr.size() - 1);
}
归并排序的时间复杂度分析
每次从中间划分区间,最多划分logn次,递归过程可以看成一棵完全二叉树,每一层进行归并的总次数为N,所以时间复杂度为O(NlogN)
空间复杂度显然为O(N)
归并排序的应用——求逆序对
一串数字序列p1p2p3…pn中,如果有i < j并且pi > pj,那么我们称pi,pj为一对逆序对。现在要求设计算法求出给定序列的逆序对。
朴素的解法就是暴力枚举所有的数对,记录其中的逆序对数目,这样的话会是O(n^2)的时间复杂度,数据量在1e4以上的时候显然不合适。
我们前面提到了归并排序可以计算逆序对,为什么求逆序对能和归并排序扯上关系呢?我们归并排序是分治思想的经典应用,可以将大问题分治为不同的小问题去解决。
我们对于归并的两个区间有个特点就是相邻等长区间,我们不妨设区间L , R
对于L,R区间中的指针分别为left和right,对于L[left]能够放入临时数组tmp,说明L[left]比R[right]前面的数字都要大,因为只有当R[right]前面的数字都放完了L[left]才放,说明L[left]可以和R[right]前面的数字组成逆序对,从而我们可以知道R区间中的数字对于L[left]的贡献,因此归并排序的过程中我们可以得到L中每个数字可以和R中配对成逆序对的个数
我们不断归并就能得到每个数字右边的所有数字对于自己的贡献,从而得到数组中的逆序对的总数
代码如下:
class Solution {
public:
int cnt , n , tmp[50001];
void mergesort(vector<int>& arr , int l , int r)
{
if(r <= l)
return;
int mid = (r + l) >> 1;
mergesort(arr , l , mid);
mergesort(arr , mid + 1 , r);
int i = l , j = mid + 1 , pos = l;
while(i <= mid && j <= r)
{
if(arr[i] <= arr[j])
{
cnt += j - (mid + 1);
tmp[pos++] = arr[i++];
}
else
{
tmp[pos++] = arr[j++];
}
}
while(i <= mid)
{
cnt += j - (mid + 1);
tmp[pos++] = arr[i++];
}
while(j <= r)
tmp[pos ++] = arr[j++];
for(i = l ; i <= r ; i++)
arr[i] = tmp[i];
}
int reversePairs(vector<int>& record) {
cnt = 0 , n = record.size();
mergesort(record , 0 , n - 1);
return cnt;
}
};
快速排序
与归并排序算法一样,快速排序(quicksort) 算法°也是分治策略的典型应用,但二者之间也有本质区别。归并排序的计算量主要消耗于有序子序列的归并操作,而子序列的划分却几乎不费时间。快速排序恰好相反,它可以在O(1)时间内,由子问题的解直接得
到原问题的解;但为了将原问题划分为两个子问题,却需要O(n)时间。(前者直接划分,后者需要O(N)的时间去找划分点)
快速排序的原理
枢轴(Pivot)
如果序列a中,有下标j,任意i < j都满足a[ i ] < a[ j ] ,任意k大于i,都满足a[ k ] > a[ pivot ],我们就称下标i为枢轴(pivot)。
显然当每个下标都满足pivot的定义时,序列自然有序。
于是我们有如下思路:
给定待排序序列,将第一个关键字放到pivot的位置,然后对[left , pivot]和[pivot, right]分别进行同样的操作,如此分治最终得到有序序列。
快速划分算法
显然对于我们快速排序而言,突破口就在于如何快速找到枢轴然后对序列进行划分。
快速划分算法的思路
- 选取首元素为候选对象,保存于key中。记录此时位置为pivot
- 设置双指针l,r初始分别位于序列的两端
- r向左寻找第一个小于key的元素,然后将其置于pivot,pivot更新为r
- l向右寻找第一个大于key的元素,然后将其置于pivot,pivot更新为l
- 最终l和r相遇,即为枢轴的位置
代码如下:
int partition(vector<int> &arr, int left, int right)
{
int l = left, r = right, key = arr[left];
while (l < r)
{
while (l < r && arr[r] >= key)
{
r--;
}
arr[l] = arr[r];
while (l < r && arr[l] <= key)
{
l++;
}
arr[r] = arr[l];
}
arr[l] = key;
return l;
}
单次划分动画演示
快速排序的实现
- 待排序序列arr,区间[left , right]
- 先对区间[left , right]进行划分,得到枢轴pivot
- 对区间[left , pivot]进行快速排序
- 对区间[pivot + 1, right]快速排序
代码如下:
int partition(vector<int> &arr, int left, int right)
{
int l = left, r = right, key = arr[left];
while (l < r)
{
while (l < r && arr[r] >= key)
{
r--;
}
arr[l] = arr[r];
while (l < r && arr[l] <= key)
{
l++;
}
arr[r] = arr[l];
}
arr[l] = key;
return l;
}
void _quickSort(vector<int> &arr, int left, int right)
{
int pivot;
if (left < right)
{
pivot = partition(arr, left, right);
_quickSort(arr, left, pivot);
_quickSort(arr, pivot + 1, right);
}
}
void quickSort(vector<int> &arr)
{
_quickSort(arr, 0, arr.size() - 1);
}
快速排序的优化策略
为什么还要有快速排序的优化呢?它的时间复杂度虽然还没分析,但是很多人到这里就会想当然的认为其时间复杂度和归并排序相同。
但是我们不妨想想最坏的情况,待排序序列有序,那么每次找枢轴都要遍历整个排序区间,而且将待排序区间分治为第一个元素和剩余元素,这样要进行n - 1次划分,整体时间复杂度退化为了O(N^2),所以未经优化的快速排序在某些情况下性能很差劲。
随机选取
有人提出了选取[left , right]之间的随机数作为pivot,这样显然具有随机性,如果选到的值也为一个很小的值那么效率仍然很低,因此这种方法虽然有其可取之处但是不被我们采用。
K-选取算法
如果我们能够找到数组中的中位数,那么显然选此中位数来找枢轴是最佳的,考虑中位数前我们先来思考一下如何找第k大数。
如果要求以线性时间复杂度找到数据随机分布的数组中的第k大元素,会如何实现?
我们可以用堆在O(NlogN)的时间复杂度内实现,但是这样效率显然不佳。《算法导论》中给出了一种基于快速划分带常数线性复杂度的k-选取算法,该算法在数据随机的情况况下可以达到线性时间复杂度,但是仅限数据随机,否则虽然是线性时间复杂度,但由于常数过大,在实际应用中也不具备优势,而且中位数作为其特殊情况,反过来却是其中效率最低下者。
k-选取算法其实是基于快速划分的一种选取算法,我们快速划分每次能够确认一个元素的排名,判断该排名与k的关系,选择是返回结果还是递归到左区间或者有区间,由于最坏情况下也是O(N^2),引入前面的随机选取法优化可以达到线性时间复杂度
int quickSelect(vector<int>& a, int l, int r, int index) {
int q = randomPartition(a, l, r);
if (q == index) {//找到答案
return a[q];
} else {//分治到子区间
return q < index ? quickSelect(a, q + 1, r, index) : quickSelect(a, l, q - 1, index);
}
}
inline int randomPartition(vector<int>& a, int l, int r) {//将a[l]和区间内一个随机的数交换
int i = rand() % (r - l + 1) + l;
swap(a[i], a[l]);
return partition(a, l, r);
}
inline int partition(vector<int>& a, int l, int r) {//快速划分
int key = a[l];
while(l < r)
{
while(l < r && a[r] >= key)
r--;
a[l] = a[r];
while(l < r && a[l] <= key)
l++;
a[r] = a[l];
}
a[l] = key;
return l;
}
int findKthLargest(vector<int>& nums, int k) {//返回第k大的数
srand(time(0));//随机种子
return quickSelect(nums, 0, nums.size() - 1, nums.size() - k);
}
三数取中
还有一种比较容易实现的选取方法是三数取中。即取三个关键字先进行排序,将中间数作为枢轴,一般是取左端、右端和中间三个数,也可以随机选取。
代码逻辑非常简单。
int getmid(vector<int> &arr, int left, int right)
{
int mid = (right + left) >> 1;
if (arr[left] < arr[mid])
{
if (arr[left] > arr[right])
return left;
if (arr[mid] > arr[right])
return right;
return mid;
}
else
{
if (arr[mid] > arr[right])
return mid;
if (arr[left] > arr[right])
return right;
return left;
}
}
小数组优化
对于一个数学科学家、博士生导师,他可以攻克世界性的难题,可以培养最优秀的数学博士,但让他去教小学生“1+1=2”的算术课程,那还真未必会比常年在小学里耕耘的数学老师教得好。换句话说,大材小用有时会变得反而不好用。刚才我谈到了对于非常大的数组的解决办法。那么相反的情况,**如果数组非常小,其实快速排序反而不如直接插入排序来得更好(直接插入是简单排序中性能最好的)。**其原因在于快速排序用到了递归操作,在大量数据排序时,这点性能影响相对于它的整体算法优势而言是可以忽略的,但如果数组只有几个记录需要排序时,这就成了一个大炮打蚊子的大问题。因此我们需要改进一下我们的快速排序算法。
代码如下:
void _quickSort(vector<int> &arr, int left, int right)
{
int pivot;
if (right - left > 100)
{
pivot = partition(arr, left, right);
_quickSort(arr, left, pivot);
_quickSort(arr, pivot + 1, right);
}
else if (right - left <= 100)
{
_InsertSort(arr, left, right);
}
}
还有一种优化方法是非递归实现,这个读者对递归代码稍加修改,自行实现即可。
桶排序
桶排序(Bucket Sort)又称箱排序,其算法原理是将数组分到有限数量的桶里,再对每个桶分别排好序(可以是递归使用桶排序,也可以是使用其他排序算法将每个桶分别排好序),最后一次将每个桶中排好序的数输出。
学习桶排序是为了学习桶的思想,桶思想在很多问题中都有着重要的作用,如后缀排序以及哈希表中哈希桶的应用等。
桶的划分与选取
桶排序的实现非常直观,那么如何去划分桶并且确定桶的数目呢?我们先找出长度为n的待排序序列的最大值ma和最小值mi,那么我们以(ma - mi) / n + 1为区间划分桶,什么意思呢?
我们令gap = (ma - mi) / n + 1,那么数据落在[0 , gap - 1]的在0号桶,[gap , gap * 2 - 1]的在1号桶…
这样就把数据按照大小关系映射到了不同的桶,桶的数目bucketcnt = (ma - mi + gap) / gap(保证至少一个桶)
桶排序的动画演示如下
桶排序的实现
- 待排序序列arr,长度为n
- 找出最大值ma和最小值mi,确定划分范围gap和桶的数目bucketcnt
- 然后分别对各个桶执行排序,再从左往右依次拿出桶中数据
- 排序完成
void bucketSort(vector<int> &arr)
{
int n = arr.size();
int ma = *max_element(arr.begin(), arr.end()), mi = *min_element(arr.begin(), arr.end());
int gap = (ma - mi + n) / n; // 确定每个桶的数据范围
int bucketcnt = (ma - mi + gap) / gap; // 确定桶的个数,保证至少一个桶
vector<vector<int>> buckets(bucketcnt);
for (auto x : arr)
buckets[(x - mi) / gap].emplace_back(x);
for (int i = 0; i < bucketcnt; i++)
InsertSort(buckets[i]);
int idx = 0;
for (auto &a : buckets)
for (auto x : a)
arr[idx++] = x;
}
基数排序
试想这样一个情形,1995年11月31日出生的张三和2001年6月21日出生的李四在比较年龄,李四说我6月出生你11月出生,我应该比你大,张三却说我比你还早出生6年呢,应该是我比你大。
可见元素的比较有时候会由许多因素决定,我们的惯例是年-月-日的优先级来定义时间先后顺序,再例如扑克牌,有的游戏规则可能牌的大小由花色和点数共同决定,假如我们称这类元素为关键码,那么任意一组此类关键码如何排序呢?
假设关键码由t个字段{ kt, k(t-1),…,k1 }组成,其中字段kt (k1)的优先级最高(低)。于是,以其中任一字段k;为关键码,均可调用以上桶排序算法做一趟排序。稍后我们将证明,只需按照优先级递增的次序(从k1到kt)针对每一字段各做一趟桶排序,即可实现按整个关键码字典序的排序。
**基数排序(radix sort)也可以称为是一种低位优先的桶排序。它采用了低位字段优先( least significant digitfirst)**的策略。其中所做桶排序的趟数,取决于组成关键码的字段数。
我们给出一组示例,来见证基数排序的奇妙性。
输入序列 | 441 | 276 | 320 | 214 | 698 | 280 | 112 |
---|---|---|---|---|---|---|---|
以个位排序 | 320 | 280 | 441 | 112 | 214 | 276 | 698 |
以十位排序 | 112 | 214 | 320 | 441 | 276 | 280 | 698 |
以百位排序 | 112 | 214 | 276 | 280 | 320 | 441 | 698 |
可见,在分别针对个位、十位和百位做过一趟桶排序之后,最终的确得到了正确的排序结果。这一成功绝非偶然或幸运,整个算法的正确性可用数学归纳法证明。
基数排序正确性的证明
我们以如下命题作为归纳假设:在经过基数排序的前i趟桶排序之后,所有词条均已按照关键码最低的i个字段有序排列。
作为归纳的起点,在i = 1时这一假设不证自明。现在假定该命题对于前i - 1趟均成立,只需证第i趟桶排序后仍然成立。
任取一对词条,并比较其关键码的第i个字段,无非两种情况。其一,二者的这一字段不等。此时,由于刚刚针对该字段做过一趟桶排序,故前者的这一字段小于后者
其二,二者的这一字段相等。此时,二者的大小实际上取决于最低的i - 1个字段。由于假设对于i - 1趟成立,故此时前者的低i - 1字段小于后者,故前者小于后者
故对于第i趟排序后,所有词条均已按照关键码的i个字段有序排列,得证。
基数排序的实现
- 长度为n的待排序序列arr
- 获取最大元素ma及其位长cnt,创建10个桶
- 进行cnt次迭代,第i轮迭代按照第i位将arr中元素放入对应桶中
- 按照先进先出的原则依次取出桶中元素到arr
- cnt次迭代完成,排序完成
int getbit(int x)
{
int ret = 1;
while (x /= 10)
ret++;
return ret;
}
void RadixSort(vector<int> &arr)
{
int ma = *max_element(arr.begin(), arr.end());
int cnt = getbit(ma);
vector<queue<int>> buckets(10);
for (int i = 0; i < cnt; i++)
{
for (auto x : arr)
{
buckets[x / (int)pow(10, i) % 10].emplace(x);
}
int idx = 0;
for (auto &bucket : buckets)
while (!bucket.empty())
{
arr[idx++] = bucket.front();
bucket.pop();
}
}
}
计数排序
计数排序(count sort)也是桶思想的一种应用,其稳定的效率在数据范围确定且客观的情况下是一种不错的选择。
计数排序的原理
假如给定序列arr,数据范围已知为(0 , k),那么我们就创建k + 1个桶,将每个数据直接映射到其对应的桶中,桶中存放元素个数,那么对于元素arr[ i ]来讲,其排序后的下标就是 bucket[ 0 ] + bucket[ 1 ]…+bucket[ arr[ i ] - 1]
换句话讲,arr[ i ]所在桶左边的桶内元素都比arr[ i ]小,那么左边元素的数目就是其排序后的下标
可见,计数排序是一种空间换时间的暴力美学。计数排序的思想也经常与树状数组结合成为竞赛中的考点,(关于树状数组,更多请见[前缀和的动态维护——树状数组C/C++]-CSDN博客)。
下面是计数排序地动图演示
计数排序的实现
- 对于长度为n待排序序列arr,获取最大元素ma
- 创建ma个桶,将各个元素放入对应桶中
- 依次取出桶中元素到原数组中
- 排序完毕
void CountSort(vector<int> &arr)
{
int ma = *max_element(arr.begin(), arr.end());
vector<int> bucket(ma + 1);
for (auto x : arr)
bucket[x]++;
int idx = 0;
for (int i = 0; i <= ma; i++)
while (bucket[i]--)
{
arr[idx++] = i;
}
}
可见当数据量过大时,计数排序不是一种很优的策略。