非单调性解空间的二分查找
前言
我们在判断一个问题的答案是否可以通过二分搜索快速获得的两个重要判断是:
1.能否确定问题的解空间范围。
2.解空间的分布是否具备单调性。
判定1.通常是显然成立的,因为大部分解空间有范围的前提下,我们才会采用搜索算法。
而作为lgn复杂度的二分搜索显然是不可轻易错过的搜索算法。
但是二分搜索算法有一个要命前提的第二个条件,就是“解空间分布具有单调性”。
比如解空间随着变量单调递增或者递减,或者水平。又或者是允许采用分治思想,分段处理,使其满足在每一段解空间具备单调性。
但是我们生活中大部分解分布都是不具备单调性的(倾向于紊乱状态)。
而本文针对讨论一类相对有规律的“紊乱解空间分布”。
即,解空间在整体上是单调性的,允许解空间在某一个单调性的回归线上来回波动。比如,疫情期间的股市行情,就是在某一个单调递减回归线上来回波动,并最终暴跌。
如果要在这类曲线下搜索某一特征唯一的股价数据所对应的时间点,还是可以的。
我们可以先采用二分搜索定位到可能满足特征要求的数据大致区间范围,然后再做进一步搜索。
比起暴力搜索,我们等于成功把时间复杂度从O(n)降低为O(lgn).
这一步已经达到了我们的目的,也许你会说,这是在为了做二分搜索,而采用二分搜索。但是在面对陌生的问题时,幸运地我们都会有多个解题思路,而正确的做法应该包容的解搜多个解答,也许他不是最优的,但他不见得不会给我们带来新的启发。
而且二分搜索有一个我们难以抗拒的魅力,就是他的空间复杂度是O(1)。我们完全没理由不去考虑二分搜索的可行性。
例题分析
接下来我们以一道例题展开讲解:
167. 两数之和 II - 输入有序数组
给定一个已按照升序排列 的有序数组,找到两个数使得它们相加之和等于目标数。
函数应该返回这两个下标值 index1 和 index2,其中 index1 必须小于 index2。
说明:
返回的下标值(index1 和 index2)不是从零开始的。
你可以假设每个输入只对应唯一的答案,而且你不可以重复使用相同的元素。
示例:
输入: numbers = [2, 7, 11, 15], target = 9
输出: [1,2]
解释: 2 与 7 之和等于目标数 9 。因此 index1 = 1, index2 = 2 。
本题来源于:leetcode.con
题目分析
这道题目最暴力的解法,就是按照题目要求找出所有解,然后再进行搜索。
其时间复杂度为O(n*n), 我们一遍遍历一遍算,所以空间复杂度也是O(1)。
而官方最优解法是利用滑窗算法来求解,我们用头尾指针,作为滑窗的起点,尾指针往左移,表示把当前值减小,头指针往右移动,表示把当前值增大,我们将当前值跟目标值target比较,通过跳转头尾指针逼近目标值。由于我们要确保头指针不能大于尾指针(题目要求),因此我们可以轻易算出时间复杂度是O(n),空间复杂度是O(1)。
相当优秀,将复杂度降低为线性。
如果采用我们二分搜索的思想,又会怎样呢?
搜索我们能确定解空间的范围就是从下标[0][0]~[n-1][n-1]。
按照二维数组在OS中连续内存分布的顺序,得到解空间的分布显然不是线性的。
这里我直接给出答案:
最终的分布大概是这样的
| /
| /\ /
| / \ /
| /\ / \/
| / \/
| /\/
| /
|——————————————————————————
比如例题 输入: numbers = [2, 7, 11, 20], target = 9
[0][0] 不满足题目要求
[0][1] 9
[0][2] 13
[0][3] 22
[1][1] 不满足题目要求
[1][2] 18
[1][3] 27
[2][2] 不满足题目要求
[2][3] 31
[3][3] 不满足题目要求
([0][3]为22,而[1][2]为18,明明升高了,又降下来,等于破坏了单调性)
我们从题目可以看出,他总体是具备单调性的。
那么下一个问题就是怎么将原解空间建模为一个具备单调性的解空间呢?
我们知道原解空间的自变量是二维的,我们可以从将具备单调性的维度挑出来,最简单的方法就是直接将问题变成1维,我们原函数是:
f([i][j]),他不具备单调性,如果我们把它变成一维,只需要将j变成一个常量,即当i=k时,第一个j为k+1。我们令j永远等于k+1即可,这里的k可以认为是j的基准地址,1才是变量的第一个常量值,所以k+1仍然可以认为是一个常量。
那么问题则变为 f([i][0]),这个解空间显然是具备单调性的。
-
我们利用二分搜索搜索这个函数的解空间,判定函数就变为“当i=k时,是否可能存在target,即最小值是否小于target或者最大值大于target”。
这个是否我们通过这个新的模糊判定函数,过滤到很多个i。 -
紧接着只需要扫描这些i的所有解空间即可。对于每一个i的搜索过程,我们采用暴力搜索的话,需要花费o(n)的复杂度。如果有m个i则总复杂度就是o(lgn + m * n) = o(m*n)
-
在本题当前,第二个维度j其实具有单调性的,意味着我们可以再一次进行二分搜索,那么总体复杂度就变成O(lgn + m * lgn) = o(m*lgn)
而其中m是一个动态值,他的最大值,也就是最坏的情况,即每一列i的j上下限都相等,就意味着,我们利用二分搜索的过滤出来的i有将近n个,复杂度则变为
O(lgn + nlgn) = o(nlgn),最好的情况就是恰好过滤出1个i,则复杂度为o(lgn + 1*lgn)= o(lgn)
平均复杂度就是O(nlgn)了。
这就是意味着,在某些趋势比较明显情况下,我们可以得到比一个o(n)更快的计算过程。
简单的写了一下代码,作为参考:
class Solution {
public static int[] mynumbers;
public int[] twoSum(int[] numbers, int target) {
if( numbers.length == 2 ) {
return new int[] {1,2};
}
mynumbers = numbers;
// 找最低点i
int left = 1;
int right = numbers.length - 1;
int privot = 0;
while(left <= right) {
privot = (right - left) / 2 + left;
if(mapMax(privot) <= target && mapMax(privot+1) >= target) {
break;
} else if (mapMax(privot) > target) {
right = privot;
} else if( mapMax(privot+1) < target) {
left = privot + 1;
}
}
if( mynumbers[privot] + mynumbers[privot+1] == target ) {
return new int[]{privot+1,privot+2};
}
int low = privot;
// 找最高点i
left = 1;
right = numbers.length - 1;
privot = 0;
while(left <= right) {
privot = (right - left) / 2 + left;
if(mapMin(privot) <= target && mapMin(privot+1) >= target) {
break;
} else if (mapMin(privot) > target) {
right = privot;
} else if( mapMin(privot+1) < target) {
left = privot + 1;
}
}
if( mynumbers[0] + mynumbers[privot+1] == target ) {
return new int[]{1, privot+2};
}
int hight = privot;
/** 将最高点和最低点之间的列,入队,其实可以不用队列,直接记下最大值最小值即可,因为i具备连续性。**/
List<Integer> list = new ArrayList<>();
for(int j = low; j <= hight; j++) {
list.add(j);
}
/** 二分遍历所有列 **/
for(int i=0; i < list.size(); i++) {
int k = list.get(i);
int left2 = 0;
int right2 = k - 1;
privot = 0;
boolean found = false;
while(left2 <= right2) {
privot = (right2 - left2) / 2 + left2;
if(sum(privot, k) == target) {
found = true;
break;
} else if (sum(privot, k) > target) {
right2 = privot - 1;
} else if(sum(privot, k) < target) {
left2 = privot + 1;
}
}
if(found) {
return new int[]{privot+1, k+1};
}
}
return new int[]{-9999, -9999};
}
// 1 <= i <= len - 1
// 该列最大值
int mapMax(int i) {
return mynumbers[i-1] + mynumbers[i];
}
// 1 <= i <= len - 1
// 该列最小值
int mapMin(int i) {
return mynumbers[0] + mynumbers[i];
}
// 二项和
int sum(int i, int k) {
return mynumbers[i] + mynumbers[k];
}
}
总结:
最终还是提倡具体问题具体方法来解决。这里只是处于对二分查找的性质进一步学习而发起研究。在写代码的过程中,我们也意识到一个点就是二分查找的麻烦之处在于边界值的处理。虽然他的思想比较通俗易懂,但是能否很好的处理边界则是另外一回事了。