vlambda博客
学习文章列表

【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函数的作用区域不停地调用其直到满足条件就好了!