目录
交换类排序
一、冒泡排序
1. 算法介绍
2.算法流程
3. 算法性能分析
(1)时间复杂度分析
(2) 空间复杂度分析
冒泡排序的特性总结:
二、快速排序
1.算法介绍
2. 执行流程
1). hoare版本
2). 挖坑法
3). 前后指针版本
3.快速排序优化(小demo)
1). 三数取中法选key
2). 递归到小的子区间时,可以考虑使用插入排序
4.快速排序非递归
5.快速排序的特性总结:
6.快排的进一步深入学习
1).快排之三路划分
eg:
2).快排之自省排序
7.分治思想(三路划分)
数组分三块:
交换类排序
基本思想:所谓交换,就是根据序列中两个记录键值的比较结果来对换这两个记录在序列中的位置,交换排 序的特点是:将键值较大的记录向序列的尾部移动,键值较小的记录向序列的前部移动。
说起快排,那一定从冒泡排序切入,我们先来回顾一下冒泡排序。
一、冒泡排序
1. 算法介绍
起泡排序又称冒泡排序。它是通过一系列的“交换”动作完成的。首先第一个关键字和第二个关键字比较,如果第一个大,则二者交换,否则不交换;然后第二个关键字和第三个关键字比较,如果第二个大,则二者交换,否则不交换······。一直按这种方式进行下去,最终最大的那个关键字被交换到了最后,一趟起泡排序完成。经过多趟这样的排序,最终使整个序列有序。在这个过程中,大的关键字像石头一样“沉底”,小的关键字像气泡一样逐渐向上“浮动”,冒泡排序的名字由此而来。
2.算法流程
原始序列:49 38 65 97 76 13 27 49
下面进行第一趟冒泡排序。
1)1号和2号进行比较,49 > 38,交换。
结果:38 49 65 97 76 13 27 49
2)2号和3号进行比较,49 < 65,不交换。
结果:38 49 65 97 76 13 27 49
3)3号和4号进行比较,65 < 97,不交换。
结果:38 49 65 97 76 13 27 49
4)4号和5号进行比较,97 > 76,交换。
结果:38 49 65 76 97 13 27 49
5)5号和6号进行比较,97 > 13,交换。
结果:38 49 65 76 13 97 27 49
6)6号和7号进行比较,97 > 27,交换。
结果:38 49 65 76 13 27 97 49
7)7号和8号进行比较,97 > 49 ,交换。
结果:38 49 65 76 13 27 49 97
至此一趟起泡排序结束,最大的97被交换到了最后,97到达了它最后的位置。接下来对序列38 49 65 76 13 27 49 按照同样的方法进行第二趟起泡排序。经过若干趟起泡排序后,最终序列有序。要注意的是,冒泡排序算法结束的条件是在一趟排序过程中没有发生关键字交换。
冒泡排序算法代码如下:
#include <iostream>
#include <vector>
using namespace std;
// 冒泡排序
void BubbleSort(vector<int>& v, int n)
{
for (int i = 0; i < n - 1; i++)
{
//j<n-1 就是每次排序都要排除最后已经被排好序的元素,从头开始排序
for (int j = 0; j < n - 1 - i; j++)
{
if (v[j] > v[j + 1])
{
//交换元素 大值往后换
int temp = v[j];
v[j] = v[j + 1];
v[j + 1] = temp;
}
}
}
for (auto e : v) cout << e << " "; cout << endl;
}
int main()
{
vector<int> v = { 49,38,65,97,76,13,27,49 };
BubbleSort(v, v.size());
return 0;
}
eg:
3. 算法性能分析
(1)时间复杂度分析
由起泡排序算法代码可知,可选取量内层循环中的关键字交换操作作为基本操作。
1)最坏情况,待排序列逆序,此时对于外层循环的每次执行,内层循环中if语句的条件R[j]<R[j-1]始终成立,即基本操作执行的次数为 n - i 。 i 的取值为1 ~ n - 1。因此,基本操作总的执行次数为(n - 1 + 1)(n - 1) / 2=n(n - 1) / 2,由此可知时间复杂度 O(n^2)。
2)最好情况,待排序列有序,此时内层循环中if 语句的条件始终不成立,交换不发生,且内层循环执行n - 1次后整个算法结束,可见时间复杂度为O(n)。
综合以上两种情况,平均情况下的时间复杂度为O(n^2)。
(2) 空间复杂度分析
由算法代码可以看出,额外辅助空间只有一个temp,因此空向复杂度为O(1)。
冒泡排序的特性总结:
1. 冒泡排序是一种非常容易理解的排序
2. 时间复杂度:O(N^2)
3. 空间复杂度:O(1)
4. 稳定性:稳定
二、快速排序
1.算法介绍
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:任取待排序元素序列中 的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右 子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
快速排序也是“交换”类的排序,它通过多次划分操作来实现排序。以升序为例,其执行流程可以概括为:每一趟选择当前所有子序列中的一个关键字(通常是第一个)作为枢轴,将子序列中比枢轴小的移到枢轴前面,比枢轴大的移到枢轴后面;当本趟所有子序列都被枢轴以上述规则划分完毕后会得到新的一组更短的子序列,它们成为下一趟划分的初始序列集。
说人话就是,找数组第一个元素为参考,用left 和 right 指针分别从右至左 和 从左至右 进行搜索,满足小于参考元素 或 大于的就进行交换,最后 一定是较小元素与参考元素nums[0] 进行交换。后面讲解。
// 假设按照升序对array数组中[left, right)区间中的元素进行排序
void QuickSort(int array[], int left, int right)
{
if(right - left <= 1)
return;
// 按照基准值对array数组的 [left, right)区间中的元素进行划分
int div = partion(array, left, right);
}
// 划分成功后以div为边界形成了左右两部分 [left, div) 和 [div+1, right)
// 递归排[left, div)
QuickSort(array, left, div);
// 递归排[div+1, right)
QuickSort(array, div+1, right);
2. 执行流程
上述为快速排序递归实现的主框架,发现与二叉树前序遍历规则非常像,同学们在写递归框架时可想想二叉 树前序遍历规则即可快速写出来,后序只需分析如何按照基准值来对区间中数据进行划分的方式即可。 将区间按照基准值划分为左右两半部分的常见方式有:
1). hoare版本
//hoare版本
void PartSort1(vector<int>& v, int left, int right)
{
if (left >= right) return;
int begin = left, end = right;
int temp = v[left];
while (begin < end)
{
while (begin < end && v[end] >= temp) end--;
while (begin < end && v[begin] <= temp) begin++;
swap(v[end], v[begin]);
}
swap(v[left], v[begin]);
PartSort1(v, left, begin - 1);
PartSort1(v, begin + 1, right);
}
int main()
{
vector<int> v = { 49,38,65,97,76,13,27,49 };
PartSort1(v, 0,v.size() - 1);
for (auto e : v) cout << e << " "; cout << endl;
return 0;
}
现在考虑为什么每次快排,以第一个元素为基准的时候,为什么都是right先走呢?
那是因为:
1.假如left 碰 right,右边先走,那么right停在了要被交换的小于基准的元素。这个时候left在开始向右寻找,结果在left < right 的条件下,left 碰了 right 这个时候 right下标的元素就是小于基准的,就也可以满足与基准进行交换,那么此时,就已经确定了基准元素的最终位置!
2.假如right 碰 left ,那么right先走,这个时候,left与right刚被交换完,此时还是right先走,这个时候right向左寻找,一直没找到满足小于基准的元素,这个时候碰到了left,可是left下标的元素就是刚跟right下标元素进行交换的元素,这个时候,left下标元素就是小于基准的 那么现在right 碰 left 就也是小于基准的元素,也满足 right 下标元素可以跟基准元素交换。
所以综上所述,可以看出,如果选最左边为基准,就right先走;如果选右边为基准,就left先走;
2). 挖坑法
挖坑法,也是称为最简单的快排思想,只需要有一个坑,找到一个满足大于或小于基准元素,将它填满就行。
将第一个元素作为基准元素,存放在temp中,让第一个元素的位置形成一个坑位。在后续的交换过程中,其实就是不停的填坑位。一个坑位被填满,另一个坑位就空出来了。
// 快速排序挖坑法
void PartSort2(vector<int>& v, int left, int right)
{
if (left >= right) return;
int begin = left, end = right;
int temp = v[left];
while (begin < end)
{
while (begin < end && v[end] >= temp) end--;
if (begin < end)
{
v[begin++] = v[end];
}
while (begin < end && v[begin] <= temp) begin++;
if (begin < end)
{
v[end--] = v[begin];
}
}
PartSort2(v, left, begin - 1);
PartSort2(v, begin + 1, right);
}
int main()
{
vector<int> v = { 49,38,65,97,76,13,27,49 };
PartSort2(v, 0,v.size() - 1);
for (auto e : v) cout << e << " "; cout << endl;
return 0;
}
3). 前后指针版本
初始时,prev指向数组首元素,cur指向prev下一个元素
前后指针法写起来比较简洁,只要考虑 prev 和 cur 两个指针对应的元素的大小进行交换即可。具体思想其实跟 hoare 大差不差。
实现思想:
仍然是用temp将第一个元素作为基准存起来,在满足cur <= right 当cur还没走到最右边时,就进行元素比较和交换。只要cur下标对应的元素小于temp && 在++prev != cur 时 防止自己跟自己交换,那么就将两者元素进行交换,这种双指针法思想也挺简单的。就是要注意,在交换前满足++prev
最后循环完了就交换prev 与 left 对应下标的元素,将left下边元素的值确定到最终位置。
// 快速排序前后指针法
void PartSort3(vector<int>& v, int left, int right)
{
if (left >= right) return;
int prev = left;
int cur = prev + 1;
int temp = v[left];
while (cur <= right)
{
while (v[cur] < temp && ++prev != cur)
swap(v[cur], v[prev]);
++cur;
}
swap(v[prev], v[left]);
PartSort3(v, left, prev - 1);
PartSort3(v, prev + 1, right);
}
int main()
{
vector<int> v = { 49,38,65,97,76,13,27,49 };
PartSort3(v, 0,v.size() - 1);
for (auto e : v) cout << e << " "; cout << endl;
return 0;
}
3.快速排序优化(小demo)
1). 三数取中法选key
每次选key 选择的都是最左边的left对应下标的元素,有点过于随机,可能会发生本身数组就是有序的,我还选left下标对应的元素,那么快排就可能会退化到O(N^2),为了防止这种随机的事件来拖慢快排的效率,那么我们就可以在选key时就保证不是选择的最大或最小,防止快排退化的过于严重。
那么就采取三数取中策略,取left mid= (left + right) / 2 right 三个下标对应的值来选取,既不选择最大的,也不选择最小的,那么第三个数就一定是中间值,不会存在极端情况。
//找中间值 三数取中
int GetMidi(vector<int>& v, int left, int right)
{
int midi = (left + right) / 2;
//left midi right
if (v[left] < v[midi])
{
if (v[midi] < v[right]) return midi;
else if (v[left] < v[right]) return right;//走了else 就说明v[midi]>v[right]
else return left;//剩下就是v[left] 就是中间值
}
else
{
if (v[midi] > v[right]) return midi;
else if (v[right] > v[left]) return left;
else return right;
}
}
三数取中,这样肯定能保证性能得到优化。
2). 递归到小的子区间时,可以考虑使用插入排序
递归到小的子区间优化,考虑插入排序,因为在一般O(N^2) 的较弱的排序中,插入排序对于小数组是非常nice的,吊打冒泡和交换排序。
如果快排 排序的数很多,就有可能会不断的递归造成栈溢出,为了减小栈溢出的概率,我们可以减少快排的递归的次数,在最后几次递归的过程中,对于小数组的递归,消耗较大,没有必要,这个时候就可以采用 递归到小的子区间时,可以考虑使用插入排序,这时就可以完全满足减少递归消耗,还能保证效率。
此时,就可以将小区间优化跟三数取中结合在一起:
void QuickSort(vector<int>& v, int left, int right)
{
if (left >= right) return;
//小区间优化,不在递归分割排序,减少递归次数
if ((right - left + 1) < 10)
{
//进行插入排序
InSertSort(v + left, right - left + 1);
}
else
{
//三数取中
int midi = GetMidi(v, left, right);
//将midi下标对应的元素 交换到left处
std::swap(v[left], v[midi]);
}
}
4.快速排序非递归
在部分面试笔试的过程中,可能会存在对于递归快排非常熟悉,要求手撕非递归快排,那么就要考虑用 栈stack 来模拟实现递归的过程,就可以达到同样的效果。
void QuickSortNonR(int* a, int left, int right)
{
Stack st;
StackInit(&st);
StackPush(&st, left);
StackPush(&st, right);
while (StackEmpty(&st) != 0)
{
right = StackTop(&st);
StackPop(&st);
left = StackTop(&st);
StackPop(&st);
if(right - left <= 1)
continue;
int div = PartSort1(a, left, right);
// 以基准值为分割点,形成左右两部分:[left, div) 和 [div+1, right)
StackPush(&st, div+1);
StackPush(&st, right);
StackPush(&st, left);
StackPush(&st, div);
}
StackDestroy(&s);
}
5.快速排序的特性总结:
1). 快速排序整体的综合性能和使用场景都是比较好的,所以才敢叫快速排序
2). 时间复杂度:O(N*logN)
3). 空间复杂度:O(logN)
4). 稳定性:不稳定
6.快排的进一步深入学习
1).快排之三路划分
(专治有大量重复的情况)
a.key默认取left位置的值
b.left指向区间最左边,right指向区间最后边,cur指向left+1位置
c.cur遇到比key小的值后跟left位置交换,换到左边,left++,cur++
d.cur遇到比key大的值后跟right位置交换,换到右边,right--
e.cur遇到跟key相等的值后,cur++
f.直到cur > right 结束
eg:
将left下标对应的元素存入key中,key=6;
1.v[cur] < key 跟v[left++]交换
2.此时v[cur] > key ,那么就跟v[right--] 无脑交换 ,不用在乎v[right] 是否大于v[cur]
3.此时继续判断v[cur] > key ,那么继续执行刚才的逻辑,继续无脑跟 v[right--] 进行交换
4.因为cur没动,继续判断v[cur] < key 那么此时swap(v[cur++], v[left] );进行交换,让left,和cur都向前走
5.此时当遇到 v[cur]==key 时,就让cur一直走,直到 cur > right 时 就停止运动,然后这个时候就进行左右递归
伪代码用例,可以加入QuickSort 专门解决出现大量重复数据的快排问题,速度很快。
//快排之三路划分
void Count_out_three(vector<int>& v, int left, int right)
{
int begin = left, end = right;
int key = v[left];
int cur = left + 1;
while (cur <= right)
{
if (v[cur] < key) swap(v[cur++], v[left++]);
else if (v[cur] > key) swap(v[cur], v[right--]);
else cur++; //v[cur] == key
}
//[begin,left-1] [left,right] [right+1,end]
Count_out_three(v, begin, left - 1);
Count_out_three(v, right + 1, end);
}
2).快排之自省排序
在前面的优化中,都是针对特定的情况下,可以时快排的性能得到较大的优化,但是在一些极端情况下,可能优化就不会那么明显,包括三数取中,三路划分等问题。
但是在工业中,面对C++,STL里面sort() 快排就是用的introsort 自省排序。当递归深度太深,如果继续使用当前的排序,那么性能可能大打折扣,那么此时可以改为堆排序,堆排序在给个情况下都是十分稳定的O(NlogN);
7.分治思想(三路划分)
讲到这里,其实可以看出,快排就是一种分治的思想,先排好一个元素,在排排左边的元素和右边的元素,最后得到有序数组。最核心的一步就是数据划分的步骤。朴素版本就是数据划分两部分。
但是但是,如果有很多重复元素的话,那么我们时间复杂度就退化成O(N^2)了,那么这个时候,我们就在可能存在重复元素的基础上多划分一步,变成数据划三份来进行排序。
[<key] [==key] [>key]
利用数据划分三块进行排序数组,来进行分类讨论:
数组分三块:
1.nums[i] < key swap(nums[++left],nums[i++])
2.nums[i] == key i++;
3.nums[i] > key swap(nums[--right],nums[i]) //这里i不++,因为此时i还要继续判断
优化:随机选key (逼近NlogN)
int r=rand()
return nums[r%(right-left+1)+left];就一定返回 [0,n-1] 这个区间的值
//数组分三块
class Solution {
public:
vector<int> sortArray(vector<int>& nums) {
srand(time(NULL)); //种下一颗随机数的种子
qsort(nums,0,nums.size()-1);
return nums;
}
void qsort(vector<int>& nums,int l,int r)
{
if(l>=r) return;
int key=getrand(nums,l,r); //得到这个随机数返回的nums的值
int i=l,left=l-1,right=r+1;
while(i<right)
{
if(nums[i]==key) i++;
else if(nums[i]<key) swap(nums[++left],nums[i++]);
else swap(nums[--right],nums[i]);
}
//[l,left] [left+1,right-1] [right,r]
qsort(nums,l,left);
qsort(nums,right,r);
}
int getrand(vector<int>& nums,int left,int right)
{
int r=rand();//得到这个随机数
return nums[r%(right-left+1)+left]; //控制在区间[0,n-1]之间取值
}
};
这个版本真的很重要,一定一定要写会!!!