1.冒泡排序1.0(Bubble Sort)
- 基本思想:通过交换使相邻的两个数变成小数在前大数在后,这样每次遍历后,最大的数就“沉”到最后面了。重复N次即可以使数组有序。
- 假设有n个无序数,第一趟需要比较n-1次,将最大的数沉到最后。此时最后一个数
r[n-1]
属于有序状态,所以第二趟我们只需要对r[0]~r[n-2]
进行交换排序,再将当前最大的数沉到底部。
原始冒泡排序算法1.0
void BubbleSort(int *r)
{
int i,j;
for(i=0;i<Maxsize;i++)
for(j=1;j<Maxsize-i;j++)
if(r[j-1]>r[j])
Swap(r[j-1],r[j]);//此处是伪代码
}
- 时间复杂度:O(n2)
- 空间复杂度:O(1),无论多少个数据元素,两两交换只需要一个额外的临时存储空间,故为O(1)
2.升级版:冒泡排序2.0
- 基本思想:通过设置标志flag,判断此趟比较是否有发生交换,从而判断排序是否提前完成,是否可以提前返回,从而减少不必要的比较。
- 优化前的算法:假设我们有10个数据元素,即Maxsize=10。此时优化前的冒泡排序,需要走10趟,如果在第5趟时就已经排好,函数也不会返回,而会继续执行接下来的5趟,这5趟虽然不会发生交换,但是仍会两两比较。
- 优化后的算法:设置
flag初始化为Flase
,设定如果当这一趟发生了交换,那么说明此趟还未排好序,那么我们将flag
设置为True
;如果此趟未发生交换,则flag的值仍为False
,循环退出! - 总而言之,升级版的冒泡排序2.0通过设置交换标志flag来记录当前的排序是否完成,同时控制循环是否继续执行,有效的实现提前完成提前结束,提前完成提前返回,避免多余的比较,从而优化算法效率。
冒泡排序的优化版本2.0
void BubbleSort2(int *r)
{
int j,k;
Bool flag;//c语言没有布尔类型,此处写的是python的语法!C语言自行转换成0和1
k=Maxsize;
flag=True;
while (flag)
{
flag=False;
for (j=1;j<k;j++)
{
if (r[j-1]>r[j])
{
Swap(r[j-1],r[j]);//此处是伪代码
flag=True;
}
}
k--;
}
}
3.二次升级版:冒泡排序3.0
- 算法思想:在冒泡排序2.0的基础之上,又多引入了一个量
lastSwappedLoc
用于记录此趟的最后交换位置,在lastSwappedLoc
位置之前的是待排序的无序序列,在lastSwappedLoc
之后的是排好序的有序序列。也就是说lastSwappedLoc
是有序与无序的边界(Border)。 lastSwappedLoc
边界之后的数据本身已经有序,而对于有序的部分进行比较是没有意义的,相当于在白白浪费资源,所以我们每次与边界前的元素进行比较即可。- (待补充)
冒泡排序的优化版本3.0
void BubbleSort3(int *r)
{
int i,j;
Bool flag; //用于标记数组是否有序
int lastSwappedLoc=0; //记录最后一次交换的位置
int sortBorder=Maxsize-1; //将有序和无序部分的边界初始化为最后一个元素
while(flag)
{
flag=False; //初始化为 false
for(j=1;j<sortBorder;j++)
{
if (r[j-1]>r[j])
{
Swap(r[j-1],r[j]);
flag=True;
lastSwappedLoc=j;
}
}
sortBorder=lastSwappedLoc;
}
}
- 还可以将代码写的更简洁:
冒泡排序的优化版本3.0+
void BubbleSort3(int *r)
{
int j,k;
int flag;
flag=Maxsize;
while(flag>0)
{
k=flag;
flag=0;
for(j=1;j<k;j++)
if(r[j-1]>r[j])
{
Swap(r[j-1],r[j]);
flag=j;
}
}
}
4.再升级:快速排序(Quick Sort)
- 算法思想:
① 先从数列中取出一个数作为枢轴。
② 分区过程,将比这个数大的数全放到它的右边,小于或等于它的数全放到它的左边。
③ 递归的对左右子序列进行快速排序,直到各区间只有一个数或者为空。
- 最后完成一趟快排的结果就是:此趟快排的区间内所选取的枢轴pivot的左边所有元素均小于pivot,枢轴pivot的右边所有元素均大于pivot,那么一趟快排过后, 此时枢轴pivot的位置是处于有序的状态。而pivot左边的子区间的元素虽然全部小于枢轴pivot,但是可能仍是无序的,pivot右边的子区间也同理。
- 实现方法:挖坑填数 + 分治法(Divide-and-Conquer / Partition)
- 分治法:把一个复杂的一个问题分成两个或多个相同或相似的子问题,再把子问题分成更小的子问题直到最后子问题可以简单地直接求解。说白了快速排序的分治法体现在——递归!当确定枢轴位置后,将其左右区间分别递归去找各自的枢轴,直到左右区间都只有一个数据元素为止,递归结束。
- 挖坑填数:顺序:“左右左右左右···”(或者“右左右左右左···”)
①Left=0;
Right=Maxsize-1;
将枢轴挖出形成第一个坑r[Left]
。
②Right--;
由后向前找比它小的数,找到后挖出此数填前一个坑r[Left]
中。
③Left++
由前向后找比它大的数,找到后也挖出此数填到前一个坑r[Right]
中。
④ 再重复执行2,3二步,直到Left==Right
,将枢轴填入r[Left] //r[Right]也行
中。
- 算法实现步骤:
- ① 选取枢轴,在这里我们每次都选取当前处理区间的最左边那个元素作为枢轴pivot,即
pivot=r[left]; // left为0
- ② 先定义两个指针
left=0;
和right=Maxsize-1;
,第一次选取的pivot
是r[0]
,将r[0]
保存到pivot
即pivot=r[0];
此时因为已经用pivot
保存了r[0]
,所以r[0]
可看做是我们挖到第一个坑。然后用right指针
从右向左找比pivot
小的元素,然后放到(填补)第一个坑中,那么填一个坑的同时,该元素自身的原位置就产生出了新坑(第二个坑),由此往复,直到left指针
和right指针
都走到中间位置(此趟的最后一个坑)时,即left==right
时,退出循环,我们将最开始储存的pivot放入最后一个坑中,即r[left]=pivot; //此时left==right,所以写成 r[right]=Pivot也可以
,就完成了一趟快排。 - ③ 用递归实现分治法:我们进行完一趟之后,枢轴pivot是有序状态,而枢轴的左右子区间,仍是无序状态,我们可以用递归再去分别处理左边的区间,和右边的区间。递归划分区间直到所划分得区间只有一个元素,即
left==right;
时,递归结束!
快速排序算法4.0
int Partition(int *r,int left,int right)
{
int pivot;//枢轴
pivot=r[left];//从枢轴从左开始
while(left<right)//挖坑填数
{
while(left<right&&r[right]>=pivot)//右:找比pivot小的数(枢轴从左边开始,就先处理右边;否则相反)
{
right--;
}
r[left]=r[right];
while(left<right&&r[left]<=pivot)//左:找比pivot大的数
{
left++;
}
r[right]=r[left];
}
//pivot插在中间
r[left]=pivot; //此时left==right,所以写成 r[right]=Pivot也可以
return left; //同理,此处写成return right 也可
}
void Quick_Sort(int *r,int left,int right)
{
int PivotLoc;//枢轴的位置
if(left<right)//区间划分的条件,当left==right;即划分的区间只有一个元素时,退出递归!
{
PivotLoc=Partition(r,left,right);//第一趟快排,获取枢轴位置PivotLoc
Quick_Sort(r,left,PivotLoc-1); //递归处理枢轴左边
Quick_Sort(r,PivotLoc+1,right); //递归处理枢轴右边
}
}
void QuickSort(int *r)//初始数据源的预输入
{
Quick_Sort(r,0,Maxsize-1);
}
快速排序为什么快?
- 搬动次数与步长:快速排序和冒泡排序本质上的思想都是:大的数往后走,小的数往前走。冒泡排序每次都只能走一个单位。而快速排序,通过枢轴将区间一份为二,"小区间"和"大区间"之间互搬元素,实现了大的数尽量往后搬,小的数尽量往前搬。实现了大步走,尽快到达指定位置,从而减少搬动次数
- 比较:快速排序每次比较只需要和枢轴比,大幅避免了小数与大数发生只比较而不交换这种情况,大幅减少无效比较。
快速排序时间复杂度分析
- 由上图,每一轮快排调用函数Partition去定位枢轴的总时间复杂度是O(n),要计算快速排序整体时间复杂度,就是计算快排所走了多少轮。
- 我们先将快排的递归理解成递归树的形式(看下图),快排所形成的递归树的层数就对应了轮数。 每一趟快排,最好的情况下,枢轴能将当前区间分割成等长的两半,并且每次都能二分区间,这样形成的递归树就类似于满二叉树,所以递归树的深度为log2 n,所以递归层数数为log2 n 。最坏情况,每次枢轴选取到达都是当前的最大或者最小值,此时形成的递归树是单支树,所以递归深度为n,即递归层数为n。
- 根据上述:
整体个算法时间复杂度 = 递归层数 x Partition的时间复杂度O(n)
,得: - 最好时间复杂度:O(log2 n)x O(n)= O(nlog2 n)
- 最坏时间复杂度:O(n)x O(n) = O(n2)
- 我们还可以用数学的方程迭代法,也可以的到一样的结论:
- 空间复杂度:快排需要的额外空间是递归所需占用的堆栈空间,即与递归树的深度有关,所以时间复杂度为O(nlog2 n)~ O(n2)
快速排序再优化
1.对枢轴的选取进行优化:
- 根据上述对快速排序(Quick Sort)的复杂度分析,我们知道影响快速排序效率的因素主要是 枢轴的选取 , 如果枢轴的选取每次都能二分子序列,则性能最优。若选取的枢轴每次都是最大值或者最小值,性能退化成冒泡排序,所以,我们可以通过 优化枢轴的选取来避免最坏情况发生。
① 随机选取枢轴:
随机选取枢轴法可以有效的解决 升序数组 和 降序数组 的问题,但是随机选取并不能保证每次都选取到最优枢轴位置,但是能使快速排序有效的避免最坏情况。
快速排序升级算法4.1
int RandomPivot(int *r,int left,int right)
{
int pivotloc;
int temp;
//随机在left和right之间选取pivot
srand((unsigned)time(NULL)); //随机播种(详细请看C语言srand函数用法)
pivotloc=rand()%(right-left)+left; //(详细请看C语言rand函数用法)
Swap(r[pivotloc],r[left]); /*此处要把选出来的pivot的值换到当前left的位置,
因为Partition函数中设定了枢轴从左边开始选,
从右边开始处理,所以每次都要讲pivot换到当前left的位置*/
return r[left];
}
int Partition(int *r,int left,int right)
{
int pivot;//枢轴
pivot=RandomPivot(r,left,right);//随机选取pivot,每次在当前low和high之间随机选取枢轴
while(left<right)//挖坑填数
{
while(left<right&&r[right]>=pivot)//右(枢轴从左边开始,就先处理右边;否则相反)
{
right--;
}
r[left]=r[right];
while(left<right&&r[left]<=pivot)//左
{
left++;
}
r[right]=r[left];
}
//pivot插在中间
r[left]=pivot; //此时left=right,所以写成 r[right]=Pivot也可以
return left; //同理,此处写成return right 也可
}
void Quick_Sort(int *r,int left,int right)
{
int PivotLoc;//枢轴的位置
if(left<right)//区间划分的条件,当left==right;即划分的区间只有一个元素时,退出递归!
{
PivotLoc=Partition(r,left,right);//第一趟快排,获取枢轴位置PivotLoc
Quick_Sort(r,left,PivotLoc-1); //递归处理枢轴左边
Quick_Sort(r,PivotLoc+1,right); //递归处理枢轴右边
}
}
void QuickSort(int *r)//数据预输入
{
Quick_Sort(r,0,Maxsize-1);
}
② 三数取中法选取枢轴(median of three):
随机枢轴(基准)选取的随机性,使得它并不能很好的适用于所有情况(即使是同一个数组,多次运行的时间也大有不同)。目前,比较好的方法是使用三数取中选取枢轴(基准)。它的思想是:选取数组开头,中间和结尾的元素,通过比较,选择介于中间大小的值作为快排的枢轴(基准)。这种方式能很好的解决待排数组基本有序的情况。
快速排序升级算法4.2
#include <stdio.h>
#define Maxsize 10
int Median_of_Three(int *r,int left,int right)
{
int mid=left+((right-left)/2);//取中值
if(r[mid]>r[right])
{
Swap(r[mid],r[right]);
}
if(r[left]>r[right])
{
Swap(r[left],r[right]);
}
if(r[mid]>r[left])//注意此处,正常应该是 r[left]≤r[mid]≤r[right],但是还要把Swap(r[mid],r[left]);
{
Swap(r[left],r[mid]);
}
//此时r[mid]≤r[left]≤r[right]
return r[left];
}
int Partition(int *r,int left,int right)
{
int pivot;//枢轴
pivot=Median_of_Three(r,left,right);//三数取中选取枢轴
while(left<right)//挖坑填数
{
while(left<right&&r[right]>=pivot)//右(枢轴从左边开始,就先处理右边;否则相反)
{
right--;
}
r[left]=r[right];
while(left<right&&r[left]<=pivot)//左
{
left++;
}
r[right]=r[left];
}
//pivot插在中间
r[left]=pivot; //此时left=right,所以写成 r[right]=Pivot也可以
return left; //同理,此处写成return right 也可
}
void Quick_Sort(int *r,int left,int right)
{
int PivotLoc;//枢轴的位置
if(left<right)//区间划分的条件,当left==right;即划分的区间只有一个元素时,退出递归!
{
PivotLoc=Partition(r,left,right);//第一趟快排,获取枢轴位置PivotLoc
Quick_Sort(r,left,PivotLoc-1); //递归处理枢轴左边
Quick_Sort(r,PivotLoc+1,right); //递归处理枢轴右边
}
}
void QuickSort(int *r)//数据预输入
{
Quick_Sort(r,0,Maxsize-1);
}
2.对排序本身进行优化:
① 序列长度达到一定大小时,使用插入排序:
当划分序列很小的时候,快排的交换已经完成的差不多了,此时再对的基本有序的小序列使用快速排序效率不高,改用插入排序。由 《数据结构与算法分析》(Mark Allen Weiness所著) 可知,当待排序列长度为5~20之间,此时使用插入排序能避免一些有害的退化情形。
快速排序升级算法4.3
void Quick_Sort(int *r,int left,int right)
{
int PivotLoc;//枢轴的位置
if((right-left+1)<10)
{
InsertSort(r,left,right);
return;
}
if(left<right)//区间划分的条件,当left==right;即划分的区间只有一个元素时,退出递归!
{
PivotLoc=Partition(r,left,right);
Quick_Sort(r,left,PivotLoc-1);
Quick_Sort(r,PivotLoc+1,right);
}
}
② 优化尾递归:
使用尾递归优化后,可以缩减堆栈的深度,由原来的O(n)缩减为O(logn)。
- 尾递归概念:如果一个函数中所有递归形式的调用都出现在函数的末尾,当递归调用是整个函数体中最后执行的语句且它的返回值不属于表达式的一部分时,这个递归调用就是尾递归。尾递归函数的特点是在回归过程中不用做任何操作,这个特性很重要,因为大多数现代的编译器会利用这种特点自动生成优化的代码。
- (此部分内容未完,待续!)
快速排序升级算法4.4
void Quick_Sort(int *r,int left,int right)
{
int PivotLoc;//枢轴的位置
if((right-left+1)<10)
{
InsertSort(r,left,right);
return;
}
while(left<right)//注意尾递归此处要换成循环语句while
{
PivotLoc=Partition(r,left,right);
Quick_Sort(r,left,PivotLoc-1);
left=PivotLoc+1;//用循环语句代替递归操作
}
}
③ 聚集相同元素:
在一次分割结束后,将与本次基准相等的元素聚集在一起,再分割时,不再对聚集过的元素进行分割。
- 具体过程有两步:
① 在划分过程中将与基准值相等的元素放入数组两端,
② 划分结束后,再将两端的元素移到基准值周围。
快速排序升级算法4.5
int Quick_Sort(int *r,int left,int right)
{
int low=left;
int high=right;
int lowlen=0;
int highlen=0;
int start=left;
int end=right;
int pivot;//枢轴
int temp;
if(right-left<10)//当数据规模较小的时候改用插入排序
{
InsertSort(r,left,right);
return;
} //若此处不用这种方法,需要加上递归的结束条件!
pivot=Median_of_Three(r,left,right);//从枢轴从左开始
while(left<right)//挖坑填数
{
while(left<right&&r[right]>=pivot)//右(枢轴在左边开始,从右边开始扫描; 否则相反)
{
if(r[right]==pivot)//处理相等的元素
{
//Swap(r[high},r[right]);
temp=r[high];
r[high]=r[right];
r[right]=temp;
high--;
highlen++;
}
right--;
}
r[left]=r[right];
while(left<right&&r[left]<=pivot)//左
{
if(r[left]==pivot)//处理相等的元素
{
//Swap(r[low],r[left]);
temp=r[low];
r[low]=r[left];
r[left]=temp;
low++;
lowlen++;
}
left++;
}
r[right]=r[left];
}
//pivot插在中间
r[left]=pivot; //此时left=right,所以写成 r[right]=Pivot也可以
//一次快排结束
//把与枢轴pivot相同的元素移到枢轴最终位置周围
int i=low-1;
int j=start;
while(j<low&&r[i]!=pivot)
{
//Swap(r[i],r[j]);
temp=r[i];
r[i]=r[j];
r[j]=temp;
i--;
j++;
}
i=low+1;
j=end;
while(j>high&&r[i]!=pivot)
{
//Swap(r[i],r[j]);
temp=r[i];
r[i]=r[j];
r[j]=temp;
i++;
j--;
}
Quick_Sort(r,start,low-1-lowlen); //递归处理枢轴左边
Quick_Sort(r,low+1+highlen,end); //递归处理枢轴右边
}
测试结果:
(此处借用insistGoGo、Tyler_Zx的测试数据,后续会上传自己的测试数据)