大家好!这篇我们讲一下直接选择排序和快速排序。
文章目录
1 直接选择排序
1.1 基本思想
每一次从待排序的数据元素中选出最小(或最大)的一个元素,存放在序列的起始位置,直到全部待排序的数据元素排完 。
1.2 思想流程
我们在这里写一个比较优的解法:就是一次找两个数(最大的,最小的),放到合适的位置。
我们把最小的放到最左边,最大的放到最右边。
然后再从下一位开始,依次类推。
1.3 具体实现
根据上面的思路,我们转换成代码:
我们进行测试,可以发现出现了问题:
这是为什么呢?我们调试来看一下:
我们可以看到此时最小的下标为1,最大的下标为0,然后交换第一个后,我们再看:
此时交换结束后,maxi指向的还是0,但是此时最大的数9已经到下标为1的位置了。所以就会发生错误,我们要在这里做一下调整。
1.4 时间复杂度
这里,我们写的是直接选择排序的优化方法,首次是N,然后是N-2,然后是N-4,然后是N-6…所以时间复杂度是O(N^2),而冒泡排序是N,N-1,N-2,N-3…所以直接选择排序比冒泡排序好点。
但是在顺序有序的情况下:直接选择排序不能判断它是否顺序有序,还是要一层一层的去选择,而冒泡排序会很快。
2 快速排序
2.1 hoare基本思想
快速排序是Hoare于1962年提出的一种二叉树结构的交换排序方法,其基本思想为:
任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。
2.2 hoare思想流程
首先,我们来看一下单趟的,单趟的也分为三个版本:
第一个版本:hoare版本
选出一个key,一般是第一个数或者是最后一个数。
要求左边的值都比key小,右边的值都比key大,和key相等的随便在哪。
我们来看一下动图流程:
从动图我们可以看到:R找比key小,L找比key大,找到后交换,再继续。相遇以后,把相遇位置的值跟key位置的值交换。
在这里,可能会有同学问一个问题:
如果左边的key值比相遇时的值小,该怎么办?
这个算法只要满足一个条件就可以避免这个问题:
右边先走就可以保证。
它分为两种情况:
第一种情况:R先停下来,L走过去遇到R
两者交换之后,R先走,R找比key小的,也就是3停下
然后L走,要找比key大的,但走一步就和R相等了。
相遇的3比key小。
第二种情况:刚交换完,R先走,R没有找到比key小的直接跟L相遇。
这种情况,也是比key小的。
上面的例子我们是以左边为key,那么我们要右边为key该怎么办呢?
同样的道理,我们左边先走,就可以保证相遇位置的值比key大。
2.3 hoare法具体实现
首先,我们实现单趟的:
但是,这样写会有两个问题。
第一个问题:
在下面的这组数据,它会出现死循环。
可以看到key和right都是5,不满足循环条件,不进入循环。
然后key和left都是5,也不满足循环条件,不进入循环。
交换left和right都没有发生改变,所以发生了死循环。
我们可以这样去修改:
第二个问题:
修改之后还是有问题。我们看下面的这组数据:
这里key为1,right一直减减,当right=1时,满足循环条件,又减了一次,就越界了。
所以,我们还要加一个条件:
单趟排完以后,key已经放在正确的位置了。
如果左边有序,右边有序,那么我们整体就有序了,那么左边和右边如何有序呢?
分治解决子问题。
单趟排序的结果如下:
现在6已经在正确的位置上了,现在我们要让6的左边和右边也有序。
在进行一次单趟排序后,你会发现3也到了正确的位置上。然后我们在分治3的左边和右边。
完整流程如下:
完整代码如下:
2.4 挖坑法思想流程
首先,我们来看一下动图展示:
然后,我来详细说一下挖坑法的流程:
首先,我们将最左边的放到key里,这样最左边就形成了一个坑位。然后,R先走找比key小。
找到之后,我们将5放到坑位里。这样5的位置就变成了新的坑位。
然后L再走找比key大,我们找到7后,将7放到坑位。
R再找小,找到4放到坑位里。
L再找大,找到9放到坑位里。
R再找小,把3放到坑位里。
L再找大,但和R相遇了,就把key放到坑位里,就结束了。
这样,挖坑法单趟的流程就结束了。
我们看一下它的动态流程图:
那么挖坑法和hoare法有什么区别呢?
本质上没有什么区别。只是挖坑法更好理解。
1.不需要理解为什么最终相遇位置比key小。
2.不需要理解为什么左边做key,右边先走。
因为开始我们选的左边为坑,那么肯定就会自然的认为,R先走找小,把坑补上,最后相遇时把坑补上就行。
2.5 挖坑法具体实现
和上面的思路类似:
2.6 前后指针法思想流程
首先,我们来看一下动图过程:
然后,我说一下具体的流程:
一开始,我们定义一个prev指针指向序列开头,定义一个cur指针指向prev指针的后一个位置。
然后判断cur指针指向的数据是否小于key,若小于,则prev指针后移一位,然后和cur指针指向的内容交换。然后cur指针++
从上组数据,我们可以看出1,2,都是小于key的,和prev交换也不改变什么,当cur一直走到3的位置:
此时,prev指针应该后移一位,然后和cur指针指向的内容交换,然后cur指针++
此时,cur指向的4是小于6的,然后怕prev向后移动一位,交换内容后,cur++
此时cur指向的还是比key小的,prev++后,交换cur的内容和prev的内容。
然后cur++,此时10比key大,cur再++,8比key大,再++
当cur出序列时,交换key和prev的内容。
这样单趟的就结束了。
在这里,prev和cur的关系是:
1.cur还没遇到比key大的值时,prev紧跟着cur,一前一后。
2.cur遇到比key大的值以后,prev和cur之间间隔一段比key大的值的区间。
我们看一下它的动态流程图:
2.7 前后指针法具体实现
有人会问,如果key选的是最右边要怎么写?
我们需要将prev和cur错位一下:
我们将cur给left,prev给left-1
其它还是一样的,cur找小,6比8小,++prev,此时prev和cur指向的数6相等。所以cur再++
依次类推下去…
此时,cur指向的3比key小,++prev,交换
cur++依次下去。
当cur指向key时,就结束。
但是这里,我们不能直接交换prev和cur,需要prev++后再交换key
代码如下:
2.8 时间复杂度
快速排序的时间复杂度是多少呢?
最好的情况下:每次选key都是中位数
意思就是:每次单趟排序后,key在中间
它就会形成这样的:
每层选key的都需要走n次,一共有logN层,所以时间复杂度就会为:O(N*logN)
最坏的情况下:每次选的key是最小或者最大的,也就是顺序有序的情况下
也就是这样的:
那么就是一个等差数列了,它的时间复杂度为:O(N^N)
2.9 如何优化快速排序
2.9.1 三数取中法选key
选不是最大的,也不是最小的那个。
这样,如果有序,我们也不会选到最大的和最小的了。
所以,我们先写一个三数取中的一个函数:
然后,把选到中间的数和最左边或最右边的交换,其它的思路不变。
2.9.2 小区间优化
什么叫做小区间优化呢?
就是区间很小时,不再使用递归划分的思路让它有序,而是直接使用插入排序对小区间排序,减少递归调用。
什么意思呢?我们来看一下快排递归调用展开简化图:
假设,我们有1000个数,也就是有10层,最后一层都是1个数,也有2^9个。倒数第二层都是3个数,有2 ^8个。所以,我们可以把后面几层的递归改成直接插入排序:
void QuickSort(int* a, int begin, int end)
{
// 小区间直接插入排序控制有序
//当区间个数小于等于10个数时,就直接插入排序
if (end - begin + 1 <= 10)
{
InsertSort(a + begin, end - begin + 1);
}
else
{
int keyi = PartSort3(a, begin, end);
// [begin, keyi-1]keyi[keyi+1, end]
QuickSort2(a, begin, keyi - 1);
QuickSort2(a, keyi + 1, end);
}
}
因为是闭区间,个数为二者相减+1
每次的插入排序的开头,我们应该是a+begin