您的位置:首页 > 编程语言 > Python开发

python learning----顺序查找和二分法

2016-04-26 18:10 549 查看


简明算法系列:顺序查找和二分法

查找(searching)是计算机算法的重要组成部分。它的内容本身理解起来不难,但真要动手写起来,可能会有这样那样的细节问题。而且感觉除非经常和算法打交道,否则过段时间就很容易忘记具体细节,所以这里尝试着通过例子把要点总结起来。既希望是一个实践性比较强的教程,也是自己的一个备忘录。这篇笔记会用Python,原因有几个。一来当然是自己习惯Python,二来Python天生好懂,三来这里有个在线的Python解释器:http://repl.it/languages/Python/ 可以零安装零配置零折腾地开始写Python,方便到令人发指。而由于Python本身就有点像伪代码,所以习惯用C或者其它语言的朋友应该也不会感到隔阂。Python由于太过方便,本身自带了很多查找和排序的功能,所以这个例程会适时地禁用某些东西(比如,总不能直接调用sort()来写一个快速排序吧)。这些会在各个例子里注明,也因此会使得一些代码看起来不那么Pythonic或者解法是sub-optimal。按照我的其它笔记的模式,从几个例子开始讲起,从最简单的开始说。谨慎建议对于下面的例子,无论看起来多简单,或者用Python,或者用上你熟悉的语言,动手自己写一下,相信会有不一样的发现。这一节是关于顺序查找和二分法。下一节则是哈希表。

1.顺序查找

例1 给定一个整数s和一个整数数组a,判断s是否在a中。(注:不能用Python自带的
if s in a
)既然没有注明a有什么特性,我们就只能假定它是一个很随机的数组。要判断s是否在a中,我们能做的,也就是逐个访问a中的元素并和s比较。一旦找到,返回
True
,a遍历完了还没找到,则返回
False
。这个过程实现起来非常简单:
def seq_search(s, a):
for i in range(len(a)):
if s == a[i]:
return True
return False
给几个测试例子
a = [13,42,3,4,7,5,6]
s = 7print seq_search(s,a)

a2 = [10,25,3,4,780,5,6]
s = 70print seq_search(s,a2)
如无意外的话,会输出:
TrueFalse
如果要算它的时间复杂度也简单,各种情况下,复杂度是这样的:
情况最好最坏平均
找到了1nn/2
没找到nnn
Big-O
来表示,就是复杂度是
O(n)
。对于没找到的情况,数组总是要遍历一次的。而对于元素在数组中的情况,则要分运气好坏,或许第一个就中了,或许最后一个才是,平均而言,则是
n/2
例2 注册网站账号时,用户名常常要符合某些要求,比如不能含有英文的
;!~
这三个字符。写一个函数,读入用户输出的用户名,返回“用户名合法”或者“用户名不合法”。一个直观的解法是这样:
def username_check():
username = input('输入一个用户名')    for s in ';!~':        if seq_search(s,username):           return '用户名不合法'
return '用户名合法'
当然也可以这样:
def username_check2():
username = input('输入一个用户名')    for i in range(len(username)):        if seq_search(username[i],';!~'):            return '用户名不合法'
return '用户名合法'
这里都是多次调用我们之前写好的
seq_search(s,a)

2. 二分查找和分治法

上述的顺序查找看起来简单,技术含量不高,但对于一般的数组,确实也只能这样查找了。但假如数组 本身是排序好的,则在查找的时候会省事一些。想象一下,如果你要在一堆人中找出体重和你一样的人,一般情况下也没有特殊的办法,只能逐个去比,并期望于早一点找出那个人。但假如告诉你,面前的这一堆人已经按体重由轻到重排列好了,那显然我们不会再一个个去比,而是先瞄一眼,看看有哪个 人可能体重和你无限接近,然后和他比较。如果你比他重,说明你要找的人在这个人的右边,如果你比 他轻,则说明你要找的人在这个人的左边。于是我们就把范围缩小了,下一次的搜索,我们只需要其中 一边去找。而对于这半边,我们又可以故伎重演,找一个人,然后再将这半边队列分成两半。这其实就是二分查找。只不过对于数字,我们通常无法先主观地找出一个“看起来差不多的”,因此我们会习惯性地从队列中间将队列等分劈成两半。来看一个具体的例子吧。
a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,11, 12]
s = 3
同样地,我们想知道3是否在数组里。因为我们知道数组已排序好,所以我们可以直接先看数组的中间值。在这里是6。由于
3<6
,我们于是知道了3只可能在数组的左半边。接下来我们只需要在子数组
a_sub = [0, 1, 2, 3, 4, 5]
里找就可以了。对于
a_sub
,我们也是取中间的那个值,在这里是2(5/2取整,a[2]=2)。于是我们取右半边:
a_sub_sub = [3,4,5]
再取中间值4。然后再取左半边:
a_sub_sub_sub = [3]
这时的中间值刚好就是3了。通过不断缩小范围,我们成功地定位到3这个值。请原谅略不雅观的命名。二分查找其实就是这样:数组不断裂变成原来的一半,(最坏情况下)最终到只剩一个元素。而运气好的时候,某一次的中间值刚好就等于你要找的那个值。下面我们写出以上过程的代码。例3 写一个函数实现二分查找来判断整数s是否在升序排列的整数数组a当中。
def binary_search(s,array):
found = False

# left 和 right定义子数组的边界,一开始搜索的是整个数组
left = 0
right = len(array)-1

while left <= right:
mid = (left+right) //2
if array[mid] == s:            return True
if s < array[mid]:            # 到左边去找
right = mid-1
else:            # 到右边去找
left = mid + 1

return False
left和right两个变量,定义我们搜索的子数组的边界。一开始我们查找的是整个数组。以后逐次按照上面的步骤,搜索左半边或者右半边。 只要是左边边界比右边边界小或者相等,就说明数组还至少有一个元素,那我们就持续执行这个裂变的循环。如果找到了,就停止搜索,如果在
while
循环里一直找不到,那么最后就返回
False
。如果你回顾上述过程,会发现每一次进入一个子数组,我们做的事情是一样的:取中间值,然后比较,取左边/右边。于是这个过程也可以用递归来实现。如果对递归没有概念的话,可以先看看下面的例子。有接触过递归的朋友就可以果断跳过啦。例4 写一个函数对1-100之间的整数求和。非递归的解法
def cal_sum(num):    sum = 0
for i in range(1,num+1):        sum += i    return sum
上面的求和方法非常直观,从1到100做一个循环。而用递归的思想来解决是这样的:对1-100求和,其实等于是100加上前99个的和(sum99),而前99个的和,又等于99+sum98,如此反复。把这个过程表示出来其实就是:
sum(i) = i + sum(i-1)
因此1-100求和的递归解法可以是:
def cal_sum(i):    if i == 1:        return 1
return i + cal_sum(i-1)
是不是看起来更简洁?我们一开始想知道
cal_sum(100)
,通过递归,我们转而去求解
cal_sum(99)
cal_sum(99)
又会去调用
cal_sum(98)
。那么到哪里是个头呢?一个递归函数,必须有一个终止的条件,不然的话就等于是一级一级爬向无底深渊。在上述这个函数里,我们的终止条件就是假如
i==1
,则不再继续递归,而是返回1。明白了递归的基础知识,我们就可以将二分查找用递归来实现了。如下所示(慎重建议自己先写一遍):
def binary_search_rec(s,array):
if len(array) == 0:# 数组已被掏空
return False

mid = (len(array)-1) //
if array[mid] == s:   # 找到啦
return True
elif s < array[mid]:  # 要找的人在左边
return binary_search_rec(s, array[:mid])
else: # 要找的人在右边
return binary_search_rec(s, array[mid+1:])
使用递归,要注意终止递归的条件是什么,在这里,如果数组已经是空了,则表示没找到,不再递归,返回
False
,如果找到,也是直接返回,不再折腾子数组了。要注意递归函数被调用时,前面要加
return
。这是因为这里的递归就好像一层一层进入一个深渊去寻找宝藏,而一旦找到,不能简单地满足于在深渊里喊一声“我找到了”,而是要逐层传递回来,直到地面上的人(第一级函数)也收到了信息。二分查找显然要比顺序查找省时间,每一次分裂,搜索的长度都变为之前的二分之一。假设一个数组原来的长度为
n
,则一次之后,变为
n/2
,再裂变后变为
n/4
,
n/8
...不难看出其时间复杂度是
O(log(n))
例5 一个本来按升序排序好的数组被切成两部分,这两部分调换位置变成一个新数组。用二分查找找出数组中的最小元素。假设数组不含重复元素。比如,一个数组本来是
a=[1,2,3,4,5,6,7,8,9]
。现在沿着4和5之间切一刀,调换位置,变成一个新数组
a=[5,6,7,8,9,1,2,3,4]
。这是LeetCode的153题,难度标记为中等。考虑一个没被拦腰截断的升序数组,除了最大的那一个,其余所有元素,当向右望的时候,都会发现右边的元素比它大。这是正常的情况。因此,假如当你向右望而发现有元素比你小的时候,可想而知你右边的那一块是被动过手脚的。也就是那个“切断点”就藏在右边。反过来想,如果右半边没有一个先降后升的过程,那你不可能找到比你小的元素。而先降后升的那个转折点,就是我们要找的。因此,你可以把搜索范围缩小到右半边。而如果右半边的元素比你大,而将搜索范围转到左半边。这其实和我们上面的例4很接近。
def slice_point(num,left,right):    if left==right:        # 只剩一个元素
return num[right]

mid = (right+left)//2

if num[mid]>num[right]:        return slice_point(num,mid+1,right)    else:        return slice_point(num,left,mid)
注意我们这里将left和right也作为参数传递,这样是为了更直观,其实也完全可以不用。就像上面的那个例子一样,直接对Python的list进行slice操作(e.g. num[mid:])。代码和例3非常像,但你发现几乎相同的一段代码,却可以用来回答不同的问题。练习 试着用循环(而不是递归)来实现上述查找。这个就不给答案了。例6 用二分查找实现开平方根函数
square(x,p)
x
是被开方根的数,假定输入都为非负整数,
p
是误差上限,输出一个浮点数结果。这是LeetCode的69题,略有改动。原题是输出一个整数,这里直接输出浮点数,难度其实是降低了。原题难度标记为中等。如何用二分查找思考这个问题?我们要查找的,其实是某数的近似平方根。那么供我们查找的数组在哪里呢?搜索范围是什么?显然,一个整数的平方根,不会小于0,也不会大于他本身。于是我们的搜索范围,其实就可以定位
[0, x]
。在这个范围里我们应用二分搜索,取中间值
mid=0.5x
,如果
mid*mid > x
,说明我们改走左半边。反之,走右半边。代码如下:
def square(x,p):    return square_helper(x,0.0,float(x),p)def square_helper(x,left,right,p):    if abs(left-right) < p*2:        # 左边右边距离已经很小了
#print (left+right)/2.0
return (left+right)/2.0

# 折半
mid = float((left+right)/2.0)

if mid*mid - x > 0:        return square_helper(x,left,mid,p)    else:        return square_helper(x,mid,right,p)
你可以通过随意控制
p
来输出不同精度的值。到这里你可以看到,无论是用循环还是递归,二分查找的套路都是极其类似的:一个终止条件,前面那些数组的例子,终止条件就是:找到或者没找到(一个确定的答案)。开平方根,条件就是,满足精度要求。注意,程序的编写还应考虑各种情况。在各种情况下都应有退出机制。

取中间值,然后根据中间值的情况,确定往左还是往右。

就这么简单。至于是用循环还是递归,对于一个算法题来说,大部分时候是一个个人喜好的问题。我自己偏好递归,各位请随意。按照这个套路,我们再来看一个例子。例7 给定一个升序排序的数组,以及一个目标值。如果目标值在数组里,返回对应元素的下标,如果不在,返回该插入的位置。这是LeetCode的35题。难度标记为中等(LeetCode的难度值随便看看就好,我个人觉得不太反映真实难度)。这个题和二分查找的原型(例3)非常像:如果找到了,那么都一样返回。只不过,当最后找不到的时候,我们不是返回
False
,而是讨论应该把数插在哪里。但是这一点其实也不难,我们做二分查找的过程,其实就是一步步逼近那个最像的值。所以最后即使找不到目标值,该插入的位置,也就是在最后那个剩下的元素的左边或者右边了。以下是循环的解法。
def searchInsert(A,target):
left = 0
right = len(A)-1

while left != right:
mid = (left+right)//2
if target == A[mid]:
return mid
elif target < A[mid]:
right = mid
else:
left = mid+1

if target>A[left]:
return left+1
return left
接着是递归的解法。
def searchHelper(A,target,left,right):
if right==left:
if A[left] < target:
return left+1
return left

mid = (left+right)//2
# print mid
if target == A[mid]:
return mid
elif target < A[mid]:
return searchHelper(A,target,left,mid)
else:
return searchHelper(A,target,mid+1,right)
相信你都已经找到解题的模板了。熟悉了以上的套路后,我们最后来看一题稍稍有点不同的。例8 给定浮点数x和整数n,计算
power(x,n)
,即x的n次方。这是LeetCode的50题。难度标记为中等。很显然,我们这里禁用Python的
x**n
以及其它可能的库函数。很显然的一个简单粗暴的方法是,跑n-1次循环,每次都把乘积相乘。这样我们一共需要做n-1次浮点数相乘。有没有更简单的办法?这一题可能有点不太好想。即使你明知要用二分法,但如何二分呢?我们之前的例子,基本都有一个目标值,而我们清楚地知道这个目标值的上界和下界。而这一题里,目标值是有,但我们无从知道它的上下界,而且也无从检验一个值离目标值有多远。所以可能要换一种思路。上面提到的暴力解法,总共要做n-1次乘法。所以如果我们要取得突破,可能的途径是减少乘法的次数。比如说,
2^64
,总共要63次乘法。有没有办法少做一些?当然是有的,幂运算本身可以和低次幂进行换算。比如,
2^64=(2*2)^32=4^32
,接着来
4^32=(4*4)^16=16^16
。做完第一次换算之后,我们多做了一个乘法,但是!幂从64变成32,少了一半!,第二次之后,我们又用一次乘法,换来总乘法运算次数的减半。顺着这个思路下去,我们可以不断用一次乘法的代价,将总乘法次数减半。直到……直到不能再减,也就是n变成0或者是1。目前为止,只有一个问题:假如n本身不能减半呢?假如是
2^63
呢?很简单,看成
2^62*2=4^31*2=16^15*4*2
。如果n是计数,就把一个x拆出来,这样n-1就是偶数了。所以剩下的问题就不太难了:
def power(x,n):
if n == 0:
return 1
elif n == 1:
return x
elif n < 0:        # 这里把负数的n先变成正数,处理起来简单一些
return pow(1.0/x,-n)
elif n%2 == 0:        # n是偶数
return power(x*x,n/2)
else:        # n是奇数
return power(x*x,(n-1)/2)*x
你可以看到,虽然这一题要考虑的问题多一些,但其实整个套路还是二分查找的那个样式。具体理解起来不复杂,就不啰嗦了~

转载声明

如果你喜欢这篇文章,可以随意转载。但请原作者StevenSLXie;

原链接(https://github.com/StevenSLXie/Tutorials-for-Web-Developers/blob/master/%E7%AE%80%E6%98%8E%E7%AE%97%E6%B3%95%E7%B3%BB%E5%88%97%EF%BC%9A%E9%A1%BA%E5%BA%8F%E6%9F%A5%E6%89%BE%E5%92%8C%E4%BA%8C%E5%88%86%E6%B3%95.md);
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: