1. 引言
刚刷了一道OJ题,需要在一个非降序排列的整型数组中查找到最后一个小于等于key的元素。在考试、面试中经常会遇到类似的问题(由于它很简单,常作为一个大问题中的子问题)。对于一个有序数组,以比较大小为条件查找满足相应条件的元素,需要用到二分查找(Binary Search)。
也有称之为“折半查找”,尊重它的英文原义,还是叫它“二分查找”吧。
2. 朴素二分查找与基于比较的搜索问题
最普通的二分查找,即从规模为n的有序数组nums中找到值为key的元素。若该元素存在,返回其在nums中的下标,若元素不存在返回-1。实现代码如下:
int bsearch(const int *nums, int n, int key)
{
int low = 0, high = n - 1, mid;
while(low <= high) {
mid = (low + high) / 2;
if(key == nums[mid])
return mid;
if(key < nums[mid])
high = mid - 1;
else /* key > nums[mid] */
low = mid + 1;
}
return -1;
}
求解思想:对于给定的查找范围nums[low],…,nums[high](初始值分别为0和(n - 1)),我们可以把nums[low]理解为key值查找的下界,把nums[high]理解为key值查找的上界。先用key与查找范围内最中间的数nums[mid]进行比较。
case 1:若key = nums[mid],说明目标已经找到,返回mid值;
case 2:若key < nums[mid],在已知大于nums[mid]的空间上(即nums[mid + 1],…,nums[high])搜索key值是没有意义的,即搜索空间进行剪枝,故将下一轮迭代的搜索空间更新为nums[low],…,nums[mid - 1];
case 3:与case 2同理,对搜索空间进行剪枝。
基于这种对搜索空间二分剪枝的思想,在脑海中不难想象:给定一个有序序列在未知key的情况下,整个搜索空间的结构应该一棵平衡树。如果把nums的值映射成树节点val的话,整个搜索树是一棵AVL树(BST Binary Search/Sort Tree 二叉搜索/排序树的一种)。在AVL树中寻找val为key的节点,代码实现如下:
const struct avlt_node *avlt_search(const struct avlt_node *root, int key)
{
while(root) {
if(key == root->val)
return root;
if(key < root->val)
root = root->lchild;
else /* key > root->val */
root = root->rchild;
}
return root; /* return NULL; */
}
其中key = root->val映射case 1;key < root->val映射case 2;key > root->val映射case 3。对AVL树的中序遍历映射对nums的顺序访问。此外,AVL树由根向叶子节点访问的过程就是二分查找剪枝过程最直观的映射。同样,对于迭代结束条件,在二分查找中是low > high,数学上的空集,在AVL树中也对应着一个不存在于AVL树中的NULL指针。
对于二分查找和AVL树之间的映射关系我们很好理解。接下来,再举一个例子来方便我们认识基于比较的搜索问题。它曾经是一道高频的面试题(已经考烂了的水题,不太可能出现在现在的面试中,此处仅做思想借鉴)。这个问题是求一个数组中第k小(大)的元素值。根据题目描述,这是一道基于比较的搜索问题。很多人把这个题的解题思想归结为“快速排序”,对于这一点我并不认同。它只是使用了经典快速排序的partition过程,其核心思想还是与二分查找相同——“定界+剪枝”。为何这么说?看一下搜索kth问题的代码:
int partition(int *nums, int low, int high)
{
int privot_key = nums[low];
while(low < high) {
while(low < high && nums[high] >= privot_key)
--high;
nums[low] = nums[high];
while(low < high && nums[low] <= privot_key)
++low;
nums[high] = nums[low];
}
nums[low] = privot_key;
return low;
}
int search_kth(int *nums, int cnt, int k)
{
int low = 0, high = cnt - 1, pos;
if(k > cnt)
return -1;
while(low <= high) {
pos = partition(nums, low, high);
if(k == pos)
return nums[k];
if(k < pos)
high = pos - 1;
else
low = pos + 1;
}
return -1;
}
为了让大家能更直观地感受二分查找和其他基于比较的搜索问题在思想上的相同之处,我故意把search_kth写得很bsearch(啊啊啊,写得有点丑陋),实际上这段代码可以写得更漂亮些。不难看出,在搜索kth问题中partition的作用就是迭代地(收敛地)去找剪枝的上下界;而在快速排序中的partition起到的作用更多是Divid and Conquer中的Divid而没有剪枝(也不可能进行剪枝)。另外,在快速排序用partition还能确定privot_key在有序序列中的位置。当然,二者还是有相似之处——它们都是基于比较的算法,而且都用到了partition。partition在很多基于比较的问题中都有着很好的应用,它是一种实现简单且有着线性时间复杂度的二分类器。partition虽然是一个很实用的算法,它有一个比较致命的缺陷——如果partition得不平衡,算法效果就会很差,换言之,partition总是存在adversary case。在学习快速排序时讨论算法的最坏情况实际上就是partition的adversary case。解决adversary case的方法有很多,这里列举一个方法——遇事不决就随机。采用随机化算法,往往会有很好的效果。另外,对于搜索kth问题,如果数据均匀分布,或者采用随机化方法让privot_key选取均匀的话,每次partition划分的期望是平衡的,因此其时间代价为公比为1/2的等比级数,整个算法的时间复杂度是O(n)的。
小结:基于比较的搜索问题可以归结为“定界+剪枝”。
扩展:实际上,α-β剪枝运用了同样的思想:遍历规则是α-β树中根遍历,通过极大极小值定界,再剪掉那些不会改变祖先权值的枝。
3.朴素二分查找的简单变形
回顾一下引言中的提到问题“在一个非降序排列的整型数组中查找到最后一个小于等于key的元素(1)”,类似的查找对象还有“第一个大于等于Key的元素(2)”、“最后一个小于Key的元素(3)”和“第一个大于Key的元素(4)”。(1)和(3),(2)和(4)的描述非常相近只是差了一个“等于”。不过,遗憾的是(1)和(3),(2)和(4)并非同类问题,事实上(1)和(4),(2)和(3)是同类问题。
举个例子:
case 1:在{1, 2, 4, 5}中查找,key值为3;
case 2:在{1, 2, 3, 3, 3, 4, 5}中查找,key值为3;
条件(1)(2)(3)(4)的查找结果如图所示:
回顾一下朴素二分查找,它映射一棵AVL树,当找到目标元素后,迭代停止并返回元素所在位置。对于查询条件(1)(2)(3)(4)的问题,通过二分查找找到目标元素后,迭代仍需进行,这里就需要将key = nums[mid]的情况进行分化,依据查询条件把它分化到key < nums[mid]或key > nums[mid]中去:
当查找条件为(1)(4)时,查询的范围应该“向右收缩,左剪枝”;当查找条件为(2)(3)时,查询范围应该“向左收缩,右剪枝”,直到low > high为止。映射的AVL树,就是由根节点遍历到叶子节点为止。
最后一轮迭代的情况:二分查找:low = high = mid;映射的AVL树:root->lchild = root->rchild = NULL(叶子节点)。迭代结束的情况:二分查找:low > high(low = high + 1),映射的AVL树:root = NULL。
迭代结束后的low,high与结果关系如图所示:
因此,对于查找条件为(1)(2)(3)(4)的搜索问题,在朴素二分查找的程序稍作修改即可:
3.1 最后一个小于等于key的元素(1)
int bsearch(int *nums, int n, int key)
{
int low = 0, high = n - 1, mid;
while(low <= high) {
mid = (low + high) / 2;
if(key < nums[mid])
high = mid - 1;
else /* key >= nums[mid] */
low = mid + 1;
}
return high < 0 ? -1 : high;
}
3.2 第一个大于等于Key的元素(2)
int bsearch(int *nums, int n, int key)
{
int low = 0, high = n - 1, mid;
while(low <= high) {
mid = (low + high) / 2;
if(key <= nums[mid])
high = mid - 1;
else /* key > nums[mid] */
low = mid + 1;
}
return low < n ? low : -1;
}
3.3 最后一个小于Key的元素(3)
int bsearch(int *nums, int n, int key)
{
int low = 0, high = n - 1, mid;
while(low <= high) {
mid = (low + high) / 2;
if(key <= nums[mid])
high = mid - 1;
else /* key > nums[mid] */
low = mid + 1;
}
return high < 0 ? -1 : high;
}
3.4 第一个大于Key的元素(4)
int bsearch(int *nums, int n, int key)
{
int low = 0, high = n - 1, mid;
while(low <= high) {
mid = (low + high) / 2;
if(key < nums[mid])
high = mid - 1;
else /* key >= nums[mid] */
low = mid + 1;
}
return low < n ? low : -1;
}
不难看出,只是对朴素二分查找做了两处修改。第一处,将key = nums[mid]的情形进行分化,反馈到代码上就是删除if(key == nums[mid])分支,依照图片所示的分化结果修改if(key < nums[mid])的条件;第二处,修改返回值,依照图片所以填low或者high,注意low会上溢,high会下溢。