Gomamon Algo二分与快速排序
Posted 哥玛萌的小树洞
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Gomamon Algo二分与快速排序相关的知识,希望对你有一定的参考价值。
01
—
二分法
二分法适用的问题:从一个单调序列中查找值。
在一个无序数组中查找值,这个问题显然是On的复杂度,因为数组内某一个元素与待查找结果的大小关系,与其他的元素无关,所以必须要遍历每一个元素才行。但是单调序列就不同了,当我们知道了某一个元素比目标值大,那么比它更大的元素就肯定不可能是结果了,这样我们就可以抛弃它左边or右边的数组部分。
所以二分法的核心思想就是:(以单调递增序列为例)每次拿当前搜索范围中间的元素和目标值比对,如果比目标值大,那么抛弃数组中比中间元素更大的部分,把新的搜索范围更新成中间元素左边的子序列,如果比目标值小则把新的搜索范围更新成中间元素右边的子序列。这样我们就可以把算法优化到OlogN。
那么为什么一定要取中间元素,而不是1/3或者1/4处的元素呢?这个算法之所以比穷举高效,就是因为在不断抛弃序列中不可能包含结果的部分,所以我们可以简单算下不同元素位置抛弃序列比率的期望:
当取1/2处元素时,目标有一半可能在左边也有一半可能在右边,所以我们有0.5几率抛弃右边1/2个序列,也有0.5几率抛弃左边1/2个序列,抛弃序列比率的期望=0.5 * 1/2 + 0.5 * 1/2 = 1/2;当取1/3处元素时,目标有1/3可能在左边1/3,也有2/3可能在右边2/3,所以我们有1/3几率抛弃右边2/3个序列,也有2/3几率抛弃左边1/3个序列,抛弃序列比率的期望=1/3 * 2/3 + 2/3 * 1/3 = 4/9 < 1/2。
类似的我们可以得出,取1/2处元素抛弃序列比率的期望时最大的,所以二分的效率最高~
下面我们从代码中来看一些细节问题:
const int N = 10;
int a[N];
int target = 3;
int fuc(int l, int r) {
while(l < r) {
int mid = (l + r + 1) / 2;
if(a[mid] > target) r = mid - 1;
else l = mid;
}
if(a[l] == target) return l;
else return -1;
}
以上代码是在一个长度为10的递增数组中,查找3的二分算法,细心的读者会发现这里的 mid = (l + r + 1) / 2 而不是 (l + r) / 2 。这是为了避免死循环的出现,当l = r - 1也就是l和r紧挨着的时候,这时候如果mid = (l + r) / 2就会使得mid = l,而此时如果a[mid] <= target,就会触发l = mid的逻辑,循环回去又是mid = l,这样就会没完没了地死循环。
所以我们只要记住,当我们更换边界的时候如果l = mid,那么算mid的时候就要加1,而如果l = mid + 1的话算mid就不用加1了。
02
—
快速排序
快速排序是一种基于分治思想的排序算法。
假设我们有一个函数可以:从数组中选择一个元素(pivot),调整数组使得该元素左边的都是小于等于它的,右边的都是大于等于它的,最终返回pivot的位置。那么我们只要再分别对该元素左边部分和右边部分进行同样的操作,最终就可以得到排序好的数组了。这个函数就叫做partition函数,它所进行的操作叫做一趟快排。
虽然网上有一些不借助partition函数,只需要一个函数的快排写法,但是我在这里还是建议大家学习partition函数的版本,因为这个函数在一些常见面试题(最大/小的k个数)中也需要使用,还是比较重要的,而且这样分开写看起来复杂但逻辑反而更易于理解。
下面我们来看一下这个重要的partition函数在干什么~
0 |
1 |
2 | 3 |
4 |
5 | 6 |
34 | 12 |
3 |
15 |
8 |
19 |
30 |
在这个数组中,我们选择中间值15作为pivot,一趟快排后的结果为:
0 |
1 |
2 |
3 | 4 | 5 | 6 |
8 |
12 |
3 | 15 |
34 |
19 |
30 |
在调用一次partition后,数组中15的左边都是比15小的(8,12,3),右边都是比15大的(34,19,30),而这两部分是不要求有序的。一趟快排的复杂度为On,我们只要对于15左边和右边的部分再次调用partition函数,直到partition函数负责的数组只有一个元素为止,那么整个大数组就是有序的了。
这是整个算法的分层示意图,每一个线段是一个partition函数负责的范围,可以看出虽然随着递归层数的增加,每一层调用的partition函数在增加,但每层的复杂度总量还是On,整个层数平均有logn层,所以算法的复杂度为nlogn。
下面让我们看看它如何实现这个关键的partition函数吧~
int partition(int l, int r, int a[]) {
int p = a[l + r >> 1];
swap(a[l], a[l + r >> 1]);
while(l < r) {
while(a[r] > p && l < r) r--;
a[l] = a[r];
while(a[l] < p && l < r) l++;
a[r] = a[l];
}
a[l] = p;
return l;
}
函数首先选择负责区域的中点元素作为pivot,再把它交换到最左边,这是为了避免算法在特殊数据下退化到n方,如果不太理解的话直接取最左端元素也可以。之后我们从右边开始,找到第一个不比pivot大的元素,直接把它放到最左边,虽然覆盖了pivot的值,不过我们有一个变量p记录着pivot所以不用担心。因为我们右指针左移的条件是a[r] > p,所以可以保证右指针右边的值都符合条件了,左指针同理,这样当我们跳出while循环时只要把被覆盖的pivot放入左右指针重合的位置,就完成了一趟快排的任务。
只要我们有了partition函数,接下来的快排主函数就很简单了:
void q_sort(int l, int r, a[]) {
if(l >= r) return;
int pos = partition(l, r, a[]);
q_sort(l, pos - 1);
q_sort(pos+1, r);
}
递归的跳出条件是l >= r,每次调用partition进行一趟快排,这样pos左边的都是比pos小的,右边都是比pos大的,我们再分别对这两部分调用q_sort就好了!
快排讲到这里就结束了,聪明的同学可以想一下我前面讲的,如何利用partition函数在On的复杂度下找到数组第k大的数?
很简单,既然partition函数可以返回一个位置pos,满足pos左边的数都比它小,右边的都比它大,那么只要pos == k - 1它就是数组第k大的数了,如果pos太大就去右边部分找,太小就去左边找,合理地调整partition函数的作用区域不停地调用其直到满足条件就好了!
以上是关于Gomamon Algo二分与快速排序的主要内容,如果未能解决你的问题,请参考以下文章
Java八股文面试题 基础篇 -- 二分查找算法冒泡排序选择排序插入排序希尔排序快速排序