十大排序算法
归并排序和快速排序为一类(使用递归);
堆排序:优先队列,在leetcode刷题中使用较多;
计数排序、桶排序和基数排序为一类(使用累加数组);
我看了很多博客,对于计数排序和基数排序都没有清晰的讲解。在理解如何之后来给大家分享。如果存在错误,请多包涵。
代码实现后,可以使用leetcode 912题数组排序来检验正确性。912. 数组排序
十大排序算法
0、算法概述
算法分类
十种常见排序算法可以分为两大类:
- 比较类排序:通过比较来决定元素间的相对次序,由于其时间复杂度不能突破O(nlogn),因此也称为非线性时间比较类排序。
- 非比较类排序:不通过比较来决定元素间的相对次序,它可以突破基于比较排序的时间下界,以线性时间运行,因此也称为线性时间非比较类排序。
算法复杂度
相关概念
- 稳定:如果a原本在b前面,而a=b,排序之后a仍然在b的前面。
- 不稳定:如果a原本在b的前面,而a=b,排序之后 a 可能会出现在 b 的后面。
- 时间复杂度:对排序数据的总的操作次数。反映当n变化时,操作次数呈现什么规律。
- **空间复杂度:**是指算法在计算机内执行时所需存储空间的度量,它也是数据规模n的函数。
5、归并排序(Merge Sort,稳定)
归并排序是建立在归并操作上的一种有效的排序算法。该算法是采用分治法(Divide and Conquer)的一个非常典型的应用。先使每个子序列有序,再使子序列段间有序。
递归(递去归来):递去分,归来并。因为当只有一个元素时,一定有序。
和选择排序一样,归并排序的性能不受输入数据的影响,但表现比选择排序好的多,因为始终都是O(n log n)的时间复杂度。代价是需要额外的内存空间。
5.1 算法描述
- 把长度为n的输入序列分成两个长度为n/2的子序列;
- 递归划分,直到有序(也就是子序列只有一个元素)
- 对两个子序列分别进行归并排序;在归来的过程中一直合并子序列;
- 将两个排序好的子序列合并成一个最终的排序序列。
5.2 动图演示
5.3 代码实现
归并排序——将两段排序好的数组结合成一个排序数组
思路:将数组一分为二,分别进行排序,但是如果数组中元素大于1个无法进行直接判断(因此递归划分直到子序列只有一个元素)。
要保证排好序,需要从最少个数(一个)进行归并,满足两个有序序列才能正确合并。
#include<iostream>
#include<vector>
#include<algorithm>
class Solution {
vector<int> temp;
public:
vector<int> sortArray(vector<int>& nums) {
if (nums.size() < 2) return nums;
temp.resize(nums.size()); //注意要开辟空间,否则无法使用[]
mergeSort(nums,0,nums.size()-1);
return nums;
}
//两两合并需要左中右三个指针进行区间划分(left,mid) 和 (mid+1,right)
void merge(vector<int>& arr, int l, int mid, int r) {
//左区间的开始,右区间开始,记录数组的开始
int i = l, j = mid + 1, k = 0;
//合并两个区间:都存在元素和一个区间已经为空
while (i <= mid && j <= r) {
if (arr[i] <= arr[j]) temp[k++] = arr[i++];
else temp[k++] = arr[j++];
}
while (i <= mid) temp[k++] = arr[i++];
while (j <= r) temp[k++] = arr[j++];
//覆盖到原来的数组,合并的区间为l~r,拷贝也是一样
for (int i = l, j = 0; i <= r; i++, j++) arr[i] = temp[j];
}
void mergeSort(vector<int>& arr, int left, int right) {
if (left < right) {
int mid = (left + right) / 2;
mergeSort(arr, left, mid);
mergeSort(arr, mid + 1, right);
merge(arr, left, mid, right);
}
}
};
6、快速排序(Quick Sort)
快速排序的基本思想:通过一趟排序将待排记录分隔成独立的两部分,其中一部分记录的关键字均比另一部分的关键字小,则可分别对这两部分记录继续进行排序,以达到整个序列有序。
6.1 算法描述
快速排序使用分治法来把一个串(list)分为两个子串(sub-lists)。具体算法描述如下:
- 从数列中挑出一个元素,称为 “基准”(pivot);
- 重新排序数列,所有元素比基准值小的摆放在基准前面,所有元素比基准值大的摆在基准的后面(相同的数可以到任一边)。在这个分区退出之后,该基准就处于数列的中间位置。这个称为分区(partition)操作;
- 递归地(recursive)把小于基准值元素的子数列和大于基准值元素的子数列排序。
6.2 动图演示
6.3 代码实现
有序的时候最坏,每次的基准数不能很好的二分。
(1)先找一个基准数,(比如设置最左为基准数:arr[left])所有数都与其比较;
(2)将当前分区的左右进行记录(i=left,j=right),不记录后面无法定位下一个分区;
(3)挖坑填数:while(i<j) ,先从后向前找小于基准数的数前移,然后从前往后找大于基准数的数后移;注意设置设置标志准确实现当前该前移还是后移。一轮过后(i=j),基准数找到了准确的位置;此时将基准数(基准数一定要提前记录,因为数组下标随时在改变)放到这个准确的位置。
(4)通过绘图分析发现,基准数将左右两边进行了分区,利用递归实现排序—递归(注意终止条件)
与归并算法的区别就在于:快排为递去的过程解决问题,归并在归来的过程出来问题。
//使用挖坑填数法:
//先后往前移动,才能从前往后寻找。
void quicksort(vector<int>& nums, int l, int r){
if (l >= r) return;
int i = l, j = r;
int pivot = rand() % (r - l + 1) + l;
swap(nums[l],nums[pivot]);
//int pivot = nums[l];//不用定义,否则容易出现覆盖
while (i < j) {
while (i < j && nums[j] >= nums[l]) j--;
while (i < j && nums[i] <= nums[l]) i++;
swap(nums[i], nums[j]);
}
swap(nums[i],nums[l]);
quicksort(nums, l, i-1);
quicksort(nums, i+1, r);
}
7、堆排序(Heap Sort)
堆排序(Heapsort)是指利用堆这种数据结构所设计的一种排序算法。堆积是一个近似完全二叉树的结构,并同时满足堆积的性质:即子结点的键值或索引总是小于(或者大于)它的父节点。
7.1 算法描述
- 将初始待排序关键字序列(R1,R2….Rn)构建成大顶堆,此堆为初始的无序区;
- 将堆顶元素R[1]与最后一个元素R[n]交换,此时得到新的无序区(R1,R2,……Rn-1)和新的有序区(Rn),且满足R[1,2…n-1]<=R[n];
- 由于交换后新的堆顶R[1]可能违反堆的性质,因此需要对当前无序区(R1,R2,……Rn-1)调整为新堆,然后再次将R[1]与无序区最后一个元素交换,得到新的无序区(R1,R2….Rn-2)和新的有序区(Rn-1,Rn)。不断重复此过程直到有序区的元素个数为n-1,则整个排序过程完成。
7.2 动图演示
7.3 递归代码实现
堆:完全二叉树(结点从左到右加入,可用数组表示)。大顶堆:每个结点的值都大于或者等于其左右子树的值,根就是最大的值。用于升序和降序排序。如何排序:由于是完全二叉树,满足左结点N[2i+1],右结点N[2i+2],根结点N[(i-1)/2]。
如何根据二叉树调整为堆
(1)初始化堆:先从倒数第二层开始判断是否较大,将较大的往上交换,直到交换到根结点;
(2)根结点下沉:将根和最后一个元素交换,然后重复上面的交换过程。最后最大的元素就会在最后一个,然后将倒数第二和根交换,重复直到排好序。
因为为完全二叉树,知道结点和数组下标的位置关系:2i+1; 2i+2; (i-1)/2
//-----堆排序:根据数组下标和节点的对应关系:father = son * 2 + 1 和 son * 2 + 2;
void HeapSort(vector<int>& arr) {
int n = arr.size() - 1; // n为最后一个下标
//(1)初始化大顶堆:从最后一个父节点开始进行调整
for (int i = n / 2; i >= 0; i--) {
heapify(arr, i, n);
}
//(2)堆排序:交换堆顶元素和当前最后第一个元素
for (int i = n; i > 0; i--) {
swap(arr[0], arr[i]);
n -= 1;
heapify(arr, 0, n);
}
}
// 递归
void heapify(vector<int>& arr, int start, int end) {
if (start >= end) return;
int father = start, son = father * 2 + 1;
if (son > end) return; //左不存在
if (son + 1 <= end && arr[son] < arr[son + 1]) son++; //左右都存在需要比较
if (arr[son] <= arr[father]) return; //如果父节点大于孩子,直接返回
else swap(arr[son], arr[father]); //交换之后继续向下判断
heapify(arr, son, end);
}
//迭代
void heapify2(vector<int>& arr, int start, int end) {
int father = start, son = father * 2 + 1;
//当存在孩子就需要进行比较
while (son <= end) {
if (son + 1 <= end && arr[son] < arr[son + 1]) son++;
if (arr[son] <= arr[father]) break;
else swap(arr[son], arr[father]);
father = son;
son = father * 2 + 1;
}
}
8、计数排序(Counting Sort)
计数排序不是基于比较的排序算法,其核心在于将输入的数据值转化为键存储在额外开辟的数组空间中。 作为一种线性时间复杂度的排序,计数排序要求输入的数据必须是有确定范围的整数(比如按照年龄进行排序)。
计数排序是一个稳定的排序算法。当输入的元素是 n 个 0到 k 之间的整数时,时间复杂度是O(n+k),空间复杂度也是O(n+k),其排序速度快于任何比较排序算法。当k不是很大并且序列比较集中时,计数排序是一个很有效的排序算法。
8.1 算法描述
- 找出待排序的数组中最大和最小的元素;
- 统计数组中每个值为 i 的元素出现的次数,存入数组C的第 i 项;开辟的数组空间为待排序元素的最大值。
- 对所有的计数累加(从C中的第一个元素开始,每一项和前一项相加);
- 反向填充目标数组:将每个元素i放在新数组的第C(i)项,每放一个元素就将C(i)减去1。
8.2 动图演示
8.3 代码实现
void countSort(vector<int>& arr){
int amax=*max_element(arr.begin(),arr.end());
//统计元素出现的次数
vector<int> countarr((amax+1),0); //拓展:考虑不从0开始如何计数
for(auto a:arr){
countarr[a]++;
}
//重新构建arr
arr.clear();
for(int i=0;i<amax+1;i++){
while((i<amax+1) && countarr[i]!=0){
arr.push_back(i);
countarr[i]--;
}
}
}
//以上方法不稳定,如何调整-->使用累加数组
void countSort1(vector<int>& arr) {
int n = arr.size();
int max = *max_element(arr.begin(), arr.end());
vector<int> count(max + 1, 0); //从0开始需要max+1个位置
for (auto x : arr) count[x]++; //统计出现次数
//保证稳定性,使用累加数组
for (int i = 1; i < count.size(); i++) count[i] += count[i - 1];
//根据累加数组 反向重建数组
vector<int> temp(n);
for (int i = n - 1; i >= 0; i--) {
//找到arr[i]在哪一个位置,根据count就可以知道存放的位置。用前先--,对应存放位置
temp[--count[arr[i]]] = arr[i];
}
arr.assign(temp.begin(), temp.end());
}
8.4 图解累加数组原理
9、桶排序(Bucket Sort)
桶排序是计数排序的升级版。它利用了函数的映射关系,高效与否的关键就在于这个映射函数的确定。桶排序 (Bucket sort)的工作的原理:假设输入数据服从均匀分布,将数据分到有限数量的桶里,每个桶再分别排序(有可能再使用别的排序算法或是以递归方式继续使用桶排序进行排序)。
桶排序最好情况下使用线性时间O(n),桶排序的时间复杂度,取决与对各个桶之间数据进行排序的时间复杂度,因为其它部分的时间复杂度都为O(n)。很显然,桶划分的越小,各个桶之间的数据越少,排序所用的时间也会越少。但相应的空间消耗就会增大。
9.1 算法描述
- 设置一个定量的数组当作空桶;
- 遍历输入数据,并且把数据一个一个放到对应的桶里去;
- 对每个不是空的桶进行排序;
- 从不是空的桶里把排好序的数据拼接起来。
9.2 图片演示
9.3 代码实现
(1)初始化桶,利用最大最小值计算需要多少个桶 (count=(max-min)/n+1)
(2)利用均匀分布的思想进行映射到需要存放的桶(k=(arr[i]-min)/n)
(3)对每一个桶进行排序,然后进行拼接。(insertSort之后拼接(insert))
void bucketSort(vector<int>& arr) {
//初始化桶,桶的个数及开辟空间
int aminid = 0, amaxid = 0;
int n = arr.size();
for (int i = 1; i < n; i++) {
if (arr[i] > arr[amaxid]) amaxid = i;
if (arr[i] < arr[aminid]) aminid = i;
}
int amax = arr[amaxid], amin = arr[aminid];
int count = (amax - amin) / n + 1;
vector<vector<int>> bucket(count, vector<int>());
//将元素映射到每个桶中,利用均匀分布的思想进行映射
for (int i = 0; i < n; i++) {
int k = (arr[i]-amin)/ n;//映射到 需要放在哪一个桶
bucket[k].push_back(arr[i]);//放入桶中
}
arr.clear();
//对每个桶进行排序(插入排序),然后拼接
for (int i = 0; i < count; i++) {
insertSort(bucket[i]);
arr.insert(arr.end(),bucket[i].begin(),bucket[i].end());
}
}
//插入排序的思想:将无序序列插入到有序序列中,先记录插入位置最后再插入
void insertSort(vector<int> arr){
int n=arr.size();
for(int i=1;i<n;i++) //第一个当作有序序列,从第二个开始
{
int pre=i-1; //记录当前有序序列最后一个
int cur=arr[i]; //记录当前需要进行插入的数字
//先移动后插入
while(pre>=0 && cur<arr[pre]){
arr[pre+1]=arr[pre];
pre--;
}
arr[pre+1]=cur;
}
}
10、基数排序(Radix Sort)
基数排序是按照低位先排序,然后收集;再按照高位排序,然后再收集;依次类推,直到最高位。
按照个位分配,按照十位分配(当实现按照十位分配,可以看到十位数不同的已经实现了排序)然后重复分配重复收集。因此收集分配的次数和最大值的位数相同。
基数排序的空间复杂度为O(n+k),其中k=10为桶的数量。一般来说n>>k。
10.1 算法描述
- 取得数组中的最大数,并取得位数;
- arr为原始数组,从最低位开始取每个位的情况组成计数数组(累加数组);
- 利用计数排序的思想重建数组;
- 重复 最大数的位数次 的排序;
10.2 动图演示
10.3 代码实现
以上动画其实也无法理解 基数排序的具体实现。基数排序就是在计数排序的思想上进行拓展(计数排序需要的桶为min—max),而基数排序按照每一位的取值(0~9)进行排序只需要10个桶即可。弄懂了计数排序,基数排序也就明了很多了。
重点是理解计数排序的累加数组。
void radixSort(vector<int>& arr) {
int n = arr.size();
//计算最大值的位数
int max = *max_element(arr.begin(), arr.end());
int digit = 0;
int base = 1;
while (max / base > 0) {
digit++;
base *= 10;
}
//需要排序的次数=位数
base = 1;
for (int i = 0; i < digit; i++) {
//统计出现次数
int bucket[10] = { 0 };
for (int j = 0; j < n; j++)
bucket[(arr[j] / base) % 10]++;
//使用累加数组
for (int j = 1; j < 10; j++)
bucket[j] += bucket[j - 1];
//根据累加数组 反向重建数组
vector<int> temp(n);
for (int k = n - 1; k >= 0; k--) {
temp[--bucket[(arr[k] / base) % 10]] = arr[k];
}
//将排好序的元素覆盖到原数组
arr.assign(temp.begin(), temp.end());
base *= 10;
}
}
1、冒泡排序(Bubble Sort,稳定)
两层循环,挨着判断是否需要交换。算法的名字由来是因为越小的元素会经由交换慢慢“浮”到数列的顶端。
1.1 算法描述
- 比较相邻的元素。如果第一个比第二个大,就交换它们两个;
- 对每一对相邻元素作同样的工作,从开始第一对到结尾的最后一对,这样在最后的元素应该会是最大的数;
1.2 动图演示
void BubbleSort(vector<int>& nums) {
//common
int n = nums.size();
for (int i = 0; i < n - 1; i++) {
for (int j = 0; j < n - 1 - i; j++) {
if (nums[j] > nums[j + 1])
swap(nums[j], nums[j + 1]);
}
}
//优化1:如果某一轮中没有出现交换,说明已经有序
int n = nums.size();
for (int i = 0; i < n - 1; i++) {
bool flag = true;
for(int j = 0; j < n - 1 - i; j++) {
if (nums[j] > nums[j + 1]) {
swap(nums[j], nums[j + 1]);
flag = false;
}
}
if (flag) break;
}
//优化二:如果前半部分无序后半部分有序,可以每次记录最后交换的位置
int n = nums.size();
int lastpos = n - 1;
for (int i = 0; i < n - 1; i++) {
int k = lastpos;
bool flag = true;
for (int j = 0; j < k; j++) {
if (nums[j] > nums[j + 1]) {
swap(nums[j], nums[j + 1]);
flag = false;
lastpos = j;
}
}
if (flag) break;
}
return nums;
}
2、选择排序
2.1 算法描述
选择排序是不稳定的排序算法,例如 2 2 1。因为第一个2和1进行交换后;2,2的相对位置改变了。
选择排序数据规模越小越好。唯一的好处可能就是不占用额外的内存空间。
n个记录的直接选择排序可经过n-1趟直接选择排序得到有序结果。具体算法描述如下:
- 初始状态:无序区为R[1…n],有序区为空;
- 第i趟排序(i=1,2,3…n-1)开始时,当前有序区和无序区分别为R[1…i-1]和R(i…n)。该趟排序从当前无序区中-选出关键字最小的记录 R[k],将它与无序区的第1个记录进行交换,使有序区增加1个和无序区减少1个;
- n-1趟结束,数组有序化了。
2.2 动图演示
2.3 代码实现
初始化最小下标的位置,遇到较小的即更换下标。检索到最后一个时进行交换。一趟之后,有序区1个元素,无序区n-1个元素。重复上述过程n-1趟即可排序完成。
void SelectSort(vector<int>& nums) {
int n = nums.size();
//选择排序:选择最小的元素,记录最小元素下标后进行交换
for (int i = 0; i < n - 1; i++) {
int idx = i;
for (int j = i + 1; j < n; j++) {
if (nums[j] < nums[idx])
idx = j;
}
if (idx != i) swap(nums[i],nums[idx]);
}
}
3、插入排序(Insertion Sort,稳定)
基本有序 or 数据量少,插入效率高
std::sort原理:对要排序的元素数目有一个阈值,如果大于该阈值则是用快速排序,如果小于阈值则用插入排序。
工作原理:构建有序序列,将未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。
3.1 算法描述
一般来说,插入排序都采用in-place(O(1)空间)在数组上实现。具体算法描述如下:
- 从第一个元素开始,该元素可以认为已经被排序;
- 取出下一个元素 cur,在已经排序的元素序列中从后向前扫描;
- 如果有序区的元素大于cur,将该元素移到下一位置 (交换);
- 重复步骤3,直到找到有序区的元素小于或者等于cur;
- 重复上述。
3.2 动图演示
3.2 代码实现
//将第一个看成有序列;下一个无序的元素找合适的位置(边找边移动);最后再插入。
//-------------插入排序----------------
void InsertSort(vector<int>& nums) {
int n = nums.size();
for (int i = 0; i < n - 1; i++) {
int pre = i;
//当前元素和前面有序区间元素比较并交换,然后继续向前比较
while (pre >= 0 && nums[pre + 1] < nums[pre]) {
swap(nums[pre+1], nums[pre]);
pre--;
}
}
}
4、希尔排序(Shell Sort)
1959年Shell发明,第一个突破O(n2)的排序算法,是简单插入排序的改进版。它与插入排序的不同之处在于,它会优先比较距离较远的元素。希尔排序又叫缩小增量排序。
4.1 算法描述
将整个待排序的记录序列分割成为若干子序列分别进行直接插入排序,具体算法描述:
- 选择一个增量序列 t1,t2,…,tk,其中ti>tj,tk=1;
- 按增量序列个数k,对序列进行k 趟排序;
- 每趟排序,根据对应的增量ti,将待排序列分割成若干长度为m 的子序列,分别对各子表进行直接插入排序。仅增量因子为1 时,整个序列作为一个表来处理,表长度即为整个序列的长度。
4.2 代码实现
先设置一个增量,然后将数据进行分组,在每个分组进行插入排序。减小增量,重复排序,直到增量为1。初始选择增量gap=length/2;在每一个分割的子序列中进行插入排序;缩小增量继续以gap = gap/2的方式。
但是算法推荐增量为 :h(i+1)=3h(i)+1,也就是incr=length, incr=incr/3+1。
两层for循环:第一层是增量变化;第二层(增量为1,可以将元素分到每一个小组)在每个分组内(进行下标的切换即可)进行插入排序。
//--------------希尔排序------------
//增量控制避免最坏情况,注意插入中的操作都是 j+incr 或者 j-incr
void ShellSort(vector<int>& nums) {
int n = nums.size();
for (int incr = n / 2; incr > 0; incr = incr / 2) {
//进行插入排序
//i++是关键;incr前的元素都是单个分组中有序序列,将后面的无序序列找到对应的分组进行插入排序
for (int j = incr; j < n; j++) {
int pre = j - incr;
while (pre >= 0 && nums[pre] > nums[pre + incr]) {
swap(nums[pre], nums[pre + incr]);
pre -= incr;
}
}
}
}