本文针对leetcode912:排序数组作一个排序的总结:题目链接
本题最快使用桶排序,归并排序也可以很快通过,所以这两个排序方法强烈推荐。
——————修改版本2020/7/5 :添加一些易错点和原理解释。——————修改版本2020/8/16:添加关于稳定性分类和复杂度归类对比。
八大排序:
排序稳定性:假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
——百度百科
算法按照稳定性为分类,共分为两类:
- 稳定排序算法
冒泡排序 插入排序 桶排序 归并排序 - 不稳定排序算法
选择排序 希尔排序 堆排序 快速排序
附加说明:理解选择排序的稳定性
选择类排序:每一轮可以定下来一个数的位置
简单选择排序
时间复杂度:O(n^2)
空间复杂度:O(1)
vector<int> SelectSort(vector<int>& nums) {
for(int i=0;i<nums.size();i++){
int mini_num=i; //作为一个key
//与key后面每个value进行比较
for(int j=i+1;j<nums.size();j++)
{
if(nums[mini_num]>nums[j]){
mini_num=j;
}
}
if(mini_num!=i){
swap(nums[mini_num],nums[i]);
}
}
return nums;
}
堆排序
原理:
#1.先改造成大顶堆,从最后一个子树开始调整。最后一棵子树的父节点为:i=nums.size()/2-1 (i从0开始)。
#2.调整好之后,把最顶端的节点与最后节点调换,此时最大值确定。
#3.确定最大值之后,需要将除最后节点的剩下树继续调整为大顶堆,以后以此类推。
时间复杂度:O(n*log(n)),稳定
空间复杂度:O(1)
vector<int> HeapSort(vector<int>& nums){
//建堆,从第一个非叶子结点开始调整大顶堆,非叶节点是n/2-1
for(int i=nums.size()/2-1;i>=0;i--)
{
adjust(nums,i,nums.size());
}
//将最顶端的最大值nums[0]与最后叶子nums[n-1]交换,再对最顶端进行调整
for(int i=nums.size()-1;i>0;i--){
swap(nums[i],nums[0]);
adjust(nums,0,i); //这里的size不同
}
return nums;
}
vector<int> adjust(vector<int>& nums,int n,int size){
int leftchild=2*n+1;
int rightchild=2*n+2;
int largest=n;
//比较以n为父亲的二叉子树最大值
if(leftchild<size && nums[leftchild]>nums[largest]){
largest=leftchild;
}
if(rightchild<size&& nums[rightchild]>nums[largest]){
largest=rightchild;//易错点1:这里的nums[largest]容易写成nums[n],这里的largest可能已经变了。
}
if(largest!=n){
swap(nums[largest],nums[n]);
//这里很容易错,交换以后想继续对以前的那个节点做排序,但是交换以后它是largest,而不是n
adjust(nums,largest,size);
}
return nums;
}
交换/插入类排序:常常比较冒泡和快排之间的差别
冒泡排序
时间复杂度: O(n^2)
如果有flag标志可以是O(1),适用于当一开始序列就已经依次排好的情况。
空间复杂度: O(1)
vector<int> BubbleSort(vector<int>& nums) {
int flag;
for(int i=nums.size()-1;i>=0;i--){
flag=0;
for(int j=1;j<=i;j++){
if(nums[j-1]>nums[j]){
flag=1;
swap(nums[j-1],nums[j]);
}
}
if(flag==0){
return nums;
}
}
return nums;
}
快排
时间复杂度:最优为O(n*log(n)),最差为O(n^2),不稳定
空间复杂度:O(1)
算法原理与比较:
快速排序,选出一个枢纽元素,将这个枢纽元素和数组所有元素进行比较,把彼枢纽元素大的元素放在枢纽元素右边,把比枢纽元素小的放在枢纽元素左边,
而对于一趟快速排序要比较n次,每一趟快排的时间复杂度是O(n)。
接下来你要对快排划分出来的两个子数组进行递归快排,如果每一趟快排很平均的将数组划分为两个子数组,那么递归快排的深度就是O(logn),所以总的时间复杂度就是O(nlogn)。
但是快排可以退化成冒泡,一旦每一趟快排,不幸的选择出最大或最小元素作为枢纽元素,那么递归深度将变成n,则时间复杂度变成了O(n^2),此时快排的效率降到最低,退化为冒泡。
所以快排对于枢纽元素的选择上很关键,如果能选择出每趟平均划分数组的枢纽元素,那么快排的效率最高,如何选择枢纽元素将成为衡量快排的关键,可以使用三者取中法来选择,每趟快排前,先将数组开始位置,中间位置,以及结尾位置的三个元素进行比较,选择其中的中间大的数做为枢纽元素,这样可以降低退化成冒泡的风险
代码中选择较简单的方法:随机取数组中的一个数做为枢纽元素,这样也可以降低风险。
C++代码:
vector<int> quicksort(int left, int right,vector<int>& nums)
{
if(left>=right){
return nums;
}
int low=left;
int high=right;
//随机取key值,降低风险
swap(nums[rand()%right+1],nums[left]);
int key=nums[left];
while(left<right){
//易错点1:key值的比较,等号应该跟着right,因为key值取的是nums[left],如果等号跟着left,则左边的那个比较相当于失效了。
for(;left<right&&nums[right]>=key;right--){;}
for(;left<right&&nums[left]<key;left++){;}
swap(nums[left],nums[right]);
}
quicksort(low,left,nums);
quicksort(left+1,high,nums);
return nums;
}
简单插入排序
时间复杂度:O(n^2)
空间复杂度:O(1)
vector<int> insertSort(vector<int>& nums)
{
for(int i=1;i<nums.size();i++){
for(int j=i;j>0;j--){
if(nums[j]<nums[j-1]){
swap(nums[j],nums[j-1]);
}
}
}
return nums;
}
希尔排序
时间复杂度:未解问题
空间复杂度:O(1)
vector<int> ShellSort(vector<int>& nums){
for(int gap=nums.size()/2;gap>0;gap/=2){
//增量最后为1,下面两个循环容易错
for(int i=gap;i<nums.size();i++){
for(int j=i;j-gap>=0;j-=gap)
{
if(nums[j]<nums[j-gap])
swap(nums[j],nums[j-gap]);
}
}
}
return nums;
}
归并排序:又稳定又快
分治+合并,算法示意图:
时间复杂度:O(n*log(n)) ,稳定
空间复杂度:O(n)
vector<int> mergeSort(int left,int right, vector<int>& nums)
{
if(left>right){return {};}
if(left==right){return {nums[left]};}
int mid=(left+right)/2; //易错点1:mid的地方应该基于left
//分治
auto ln=mergeSort(left,mid,nums);
auto rn=mergeSort(mid+1,right,nums);
vector<int> res;
int i=0,j=0;
while(i<ln.size()&&j<rn.size()){
if(ln[i]<rn[j]){ //易错点2:不是nums[i],而是ln[i],很容易写错
//合并
res.push_back(ln[i++]);
}
else{
res.push_back(rn[j++]);
}
}
while(i<ln.size()) {res.push_back(ln[i++]);}
while(j<rn.size()) {res.push_back(rn[j++]);}
return res;
}
计数排序/桶排序:很神!超快!
举例:现在给了待排序的数组[5, 3, 5, 2, 8]。那么:
#1.申请(8-2+1)共7个桶用于放元素,桶为:0~6号桶 ( 后面得到的旗子位置记得加上low:2)。
#2.遍历待排序的数组[5, 3, 5, 2, 8],把对应的桶里面放入小旗子,即 2-2=0、3-2=1、8-2=6 号桶各放了一个旗子, 5-2=3号 桶里放了两个旗子。
#3.从 0 到 6 遍历一遍所有的桶,如果桶里没有小旗子就跳过,有小旗子就往结果里放入小旗子个数的该元素,加上最小值2,得到[2, 3, 5, 5, 8]。
时间复杂度:O(n)
空间复杂度:O(n)
特点:时间复杂度低,但是空间复杂度高。而且在排序前需要提前知道一些信息,比如排序的大致范围。
vector<int> basketSort(vector<int>& nums){
int low=*min_element(nums.begin(),nums.end());
int high=*max_element(nums.begin(),nums.end());
int size=high-low+1;
//画桶
int basket[size];
for(int i=0;i<size;i++){
basket[i]=0;
}
for (int i=0;i<nums.size();i++){
basket[nums[i]-small]++; //易错点1:这里是-small,不是+small
}
vector<int> res;
for(int i=0;i<n;i++){
while(basket[i]>0){ //易错点2:这里的basket需要递减,否则nums中相同值只会出现一次。
res.push_back(i+small);
basket[i]--;}
}
return res;
}
各类排序算法复杂度对比:
稳定性分析
假定在待排序的记录序列中,存在多个具有相同的关键字的记录,若经过排序,这些记录的相对次序保持不变,即在原序列中,r[i]=r[j],且r[i]在r[j]之前,而在排序后的序列中,r[i]仍在r[j]之前,则称这种排序算法是稳定的;否则称为不稳定的。
复杂度分析
时间复杂度指执行算法所需要的计算工作量。
空间复杂度指算法在计算机内执行时所需存储空间的度量。