Binary Search 《二分查找》 2

更进一步:主要原理

        当你遇到一个你认为可用用二分查找解决的问题时,你需要用一些方法来验证它可行。现在我将在另一层进行抽象,这允许我们解决更多的问题,让二分查找的证明更简单并能实现它们。这一部分有点正式,但是加油,它也不是想象的那么难。

        假定一个断言p定义了一个某种顺序的集合S。这个查找区域问题的候选结果。在这片文章中,这个断言是一个返回布尔值的函数,truefalse(我们也可以用yesno来表示布尔值)。我们用这个断言根据问题的定义来判断一个候选值是否合法(没有违反一些条件)。

        我们把主要原理陈述为:二分查找能且只能对于所有的xS中,p(x)蕴含p(y)对于所有y>x。我们在去掉查找区域的第二部分的时候就用到了这个性质。这个说法同¬p(x)蕴含¬p(y)对于所有的y<x(符号¬表示逻辑非),这就是我们在去掉查找区域的前半部分时所用的原理。这个原理能个被轻松的证明,但是为了节约篇幅我就省略了证明。

        在模糊数学下,我建议如果你碰到了一个yesno的问题(断言),对于某个潜在的解决方案x选择yes,这就意味着所有在x后的元素都是yes。类似的,如果选择了no,那么你可以知道所有在x后的元素都将是no。因此,如果你要在查找区域中的每个元素(顺序),你都将得到no跟着一串连续的yes

        仔细的读者会发现,二分查找当断言得到的是连续的yes接着连续的no时都可以使用。这是正确的而且补充说断言也满足最初的前提。简而言之我们就只处理在原理中的断言。

        如果在主要原理中的条件满足了,我们能够用二分查找来找到合法的最小的值,例如最小的满足p(x)为真的x。根据二分查找的原理,首先是设计一个能够估值的断言同时它能够用于二分查找:我们需要选择这个算法要找的是什么。我们可以让它找到第一个满足p(x)为真的x或者最后一个满足p(x)为假的x。正如你所看到的,两者的区别很细微,但是很必要确定使用某一个。对于新手,让我们选择第一个yes(第一选择)。

        第二部分是证明二分查找可以用这个断言。这是我们使用主要原理的地方,判断在原理中的条件是否满足。证明不需要完全精确,你只需要说服你自己p(x)蕴含p(y)对于所有y大于x或者¬p(x)蕴含¬p(y)对于所有y小于x。这可以用简单的一两句话可以得以证明。

当断言的定义域是整数时,能满足证明p(x)蕴含p(x+1)或者¬p(x)蕴含¬p(x-1),剩下的就用归纳法就可以证明。

        这两个部分通常都是交错的:当我们认为一个问题可以用二分查找来解决时,我们的目标就是设计一个满足主原理条件的断言。

        有人可能会问为什么我们选择使用这个抽象而不用我们已经用过的看起来更简单的算法呢?因为很多问题都不能模型化成查找一个特定的值,但是定义和估值一个断言例如“是否有一个作业花费少于或等于x?”,当我们在寻找最小花销作业的时候。例如,熟悉的旅行商问题(TSP)寻找只访问每个城市依次的最便宜的回路。这里,目标值没有像这样定义,但是我们可以定义一个断言“是否有少于或等于x的回路?”然后用二分查找来找到满足断言的最小的x。这就叫做把原始问题化归为一个决策(是/否)问题。不幸的是,我们知道没有对于这个断言没高效的评价来估值所以旅行商问题用二分查找不能很容易的解决,但是很多最优问题能够用二分查找来解决。

现在让我们跳到一开始介绍抽象定义时在已排序数组上使用的二分查找。首先顺便重新陈述一下这个问题:“给你一个数组A和目标值,返回数组中第一个等于或大于目标值的数的索引。”,这或多或少用C++中的lower_bound做的。

        我们想要查找目标值的索引,于是在数组中所有的索引值都是候选答案。查找区域S是所有候选答案的集合,因此一个区域包括了所有的索引值。考虑这个断言A[x]是否要大于等于目标值?”。如果我们要查找第一个断言为真,我就准确的得到了我们在前边段落讨论的东西。

在主原理中的条件得到满足因为数组是升序排列的:如果A[X]大于或等于目标值,那么所有在它后面的原素都肯定是大于或等于目标值。

        让我们拿这个简单的序列开始:

0

5

13

19

22

41

55

68

72

81

98

        查找区域(索引):

1

2

3

4

5

6

7

8

9

10

11

        用我们的断言(目标值为55)得到:

no

no

no

no

no

no

yes

yes

yes

yes

yes

        这就是一个连续的no接着连续的yes,整如我们想要的。注意索引7(我们目标值的位置)是我们的断言第一个得到yes的位置,这就是我们的二分查找找到的。

实现这个离散的算法

        在开始编码之前我们要记住的最重要的事是选择两个你要维护的数字(下界和上界)的意思。一个可能的答案是一个肯定包括第一个满足p(x)为真的x的闭区间。你所有的代码都是在维护这个变量的指引下:它告诉你怎么正确的移动边界,这里是错误经常光顾的地方,如果你不仔细的话。

        另一个你要仔细的地方是要把上界设到多高。这里用高我实际的意思是宽,因为有两个变量要关注。每次一个编码员总结到在编码时他或她把宽度设的够大了,但是到在间隙的时候发现了一个反例(已经太迟)。不幸的是这里没有有用的建议给你除了把你的边界检查两遍三遍!同时由于执行时间随着边界进行着指数级增长,你可以把它们设置的高一点,只要你不违背了断言的值。时刻留意无处不在的溢出错误,尤其在计算中间值时。

        现在我们终于可以编码实现前面我们讨论的二分查找:

        关键的两行是hi = midlo = mid + 1。当p(mid)为真时,我们可以去掉查找区域的第二部分,因为断言对于这个区域里的值都为真(主原理)。但是,我们不能把中间值给去掉,因为它可能是第一个满足p为真的元素。这就是把上界移到mid是很好的,可以避免引入错误。

相似的,如果p(mid)为假,我们可以去掉查找区域的第一部分,这一次我们把mid也去掉了。p(mid)为假所以我们不需要它在我们的查找区域内。这样我们就可以把下界移到mid+1这个位置。

如果我们想找到最后一个满足p(x)为假的x,我们可以想出(用同上边相似的原理):

        你可以核实这段代码满足我们的条件,我们要查找的原始始终在(lo,hi)这个区间里。然后又有一个问题。想想当运行你的代码时有的查找区间时断言得到了下边的结果:

no

yes

        这段代码会是一个死循环。它将一直把第一个元素选择为mid,但是又不会移动下边界,因为它想要把no留在查找区域里。解决办法是把mid = lo + (hi - lo)/2改为mid = lo + (hi - lo + 1)/2,那样它就会向上取整而不是向下取整了。也有其他的办法可以解决这个问题,但是这是最简单的。记住一定要用之含有两个元素集合,第一个元素得到的是假,第二个元素得到的是真,来测试你的代码。

你也许会好奇为什么用mid = lo + (hi - lo)/2代替mid = (lo + hi)/2来计算中间值。这就是用来避免另一个潜在的取整错误:第一个计算式子,我们想让除法总是向下取整,向着下边界。但是由于除法截断,当lo + hi为负数时,它就向上取整。像这样编程是为了保证被除数总是为整数因此就会向我们想要的那样取整。当查找区域只包含正整数或实数时并不会表现出来,我决定整篇都这样写是为了一致性。

实数

        二分查找可以用于定义域是实数的单调函数。实现实数的二分查找通常要比整数的要容易,因为你不用注意怎样移动边界:

由于实数集合是密集的,应该清楚的是我们通常不能找到一个具体的值。然后我们可以快速的找到一些x满足f(x)是在noyes边界的一定误差范围内。我们有两种方法来决定何时终止:当查找区间小鱼预先定义的范围(例如说10-12)或者设定一个确定迭代次数。在TopCoder上你最好的选择是几百次迭代,这样可以给你最好的精度却不用太多的思考。100次迭代会把查找区域近似到初始大小的10-30,这样对大多数问题(不是全部)都足够了。

如果你需要尽量少的迭代次数,你可以在区间缩小到一定的时候就停止,但是需要做相应的边界值的相对比较,不要只做绝对的比较。这样做的原因是doubles不能精确到15位的精度,那么查找空间包括了大数(已排序的百万级)时,你不能得到小于10-7的绝对误差。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值