思考,是指将抽象和具象进行平衡,然后留存在心中。在这个过程中,最为重要的特性是清晰。
由于我对python3相对比较熟悉,所以编程语言采用python3.
问题
问题:给定一个非空浮点数升序列表,和一个浮点数,如果这个浮点数在列表中,则返回最小索引,否则返回-1
问题的形式化
问题的形式化描述:
输入:非空浮点数升序列表alist,浮点数num
输出:索引index
涉及的基本概念
不管是使用迭代的写法还是使用递归的写法,在思考编程中不可避免要对基本概念进行明确。基本概念是思考的基石,通过它们我们实现对问题的抽象,并且在具象的考虑中有锚点供我们检验。
那么有哪些基本概念呢?查找问题,需要考虑查找的目标和查找的地方,也就是查找对象和查找域。然后要考虑查找的范围,针对数组,就是查找区间,这个区间可以使用子数组表示,也可以使用数组的索引区间表示,这里考虑到需要返回索引,我们使用索引区间表示。还要考虑的一个点就是有序数组的元素大小比较关系,也就是序关系和相等。最后,还要考虑返回值。
二分查找的想法,就是通过将查找对象与二分点值进行大小比较将查找区间长度缩减一半,最终查找区间长度只有1,就可以通过一次大小比较确定输出index还是-1。这个过程中需要考虑的概念是初始查找区间,查找区间长度,二分点,继续缩减条件, 查找区间变换公式,查找区间长度变换公式,最终状态,返回值。
基本概念的代码化
下面的代码化,我不追求简洁,而是追求清晰,即更好地呈现出基本概念。
查找问题的基本概念
- 查找对象:
num
- 查找域:
alist
- 查找区间:
[left, right]
- 序关系:
<,>
- 相等:
def isEqual(a, b):
if abs(a-b)<1e-7:
return True
else:
return False
注意,相等关系要先于序关系进行判断,也就是:相等,不相等(包含<,>
)
- 返回值:
index
或者-1
二分查找问题的基本概念
- 初始查找区间:
[left=0, right=len(alist)-1]
- 查找区间长度:
length = right-left+1
- (左)二分点:
mid = left + (right - left) // 2
- (右)二分点:
mid = right - (right - left) // 2
- 继续缩减条件:查找区间长度大于1,即
length > 1
- 查找区间变换公式:如果是返回最小索引则使用左二分点,如果是返回最大索引则使用右二分点,此部分以及本节后续部分显示的都是只考虑返回最小索引的情形
if isEqual(num, alist[mid]):
left, right = left, mid
elif num<alist[mid]:
left, right = left, mid - 1
elif num>alist[mid]:
left, right = mid + 1, right
- 查找区间长度变换公式:下面只是为了讲解,并不是严谨的代码
if isEqual(num, alist[mid]):
length = mid - left + 1
elif num<alist[mid]:
length = mid - 1 - left + 1
elif num>alist[mid]:
length = right - (mid+1)+ 1
命题:在继续缩减条件length>1
下,(左)二分点在[left,right]
中,并且变换后的查找区间长度均大于等于0且小于变换前的长度(特别地,如果查找对象在查找域中,则长度始终大于等于1,但是如果查找对象不在查找域中,则长度可能为0,即right-left=-1
)
证明:我们使用变化前的区间表达变换后的区间长度:
isEqual(num, alist[mid])
时有
1=<mid-left+1 = (right-left)//2+1<right-left+1
num<alist[mid]
时有
0=<mid-left=(right-left)//2<right-left+1
num>alist[mid]
时有
0=<right-mid=(right-left)-(right-left)//2<right-left+1
这个命题,保证了缩减持续进行,直到达到最终状态
- 最终状态:
[left, left]
即length=1
即right-left=0
(在查找对象不在查找域时,是会出现[left, left-1]
的状态的,所以有两个最终状态,虽然编程时可以同等处理,但是概念上要清晰)
注意,我们将查找区间作为我们的这三个状态表达中的最基本概念。一般最终状态对应的是最小规模的原问题。最小规模非空有序列表就是只含有一个元素的列表(但是由于缩减过程,导致[left, left-1]
,也是最小规模问题,所以我们加一个length
判断,为了让alist[left]
总有意义,我们加上越界判断)。
此时,就剩下最后的比较,并据此给出返回值了:
# 判断越界条件,虽然此时left肯定不会越界,但是为了代码更加通用加上了
if left>=len(alist) or right<0 or right<left:
return -1
if isEqual(num, alist[left]):
return left
else:
return -1
总结以上,得到求解代码:
def binarySearch(alist, num):
# 初始化查找区间和查找区间长度
left, right = 0, len(alist)-1
length = right - left + 1
# 在缩减条件成立时应用查找区间变换公式,直到达到最终状态
while length>1:
mid = left + (right - left) // 2
if isEqual(num, alist[mid]):
left, right = left, mid
elif num<alist[mid]:
left, right = left, mid - 1
elif num>alist[mid]:
left, right = mid + 1, right
length = right - left + 1
# 依据最终状态,给出返回值
if left>=len(alist) or right<0 or right<left:
return -1
if isEqual(num, alist[left]):
return left
else:
return -1
def isEqual(a, b):
if abs(a-b)<1e-7:
return True
else:
return False
if __name__ == "__main__":
alist1 = [1., 2., 2., 3.2, 5., 6.]
alist2 = [2., 2]
alist3 = [1., 1]
alist4 = [3,3]
num = 2
print(binarySearch(alist1, num))
验证
一个问题是,上一节的返回值确实给出了问题的解吗?转化到基本概念上,也就是如果查找对象在查找域中,那么最终状态的查找区间是否就是最小索引呢?
最一开始,最小索引当然在查找区间中,于是只需证明下面的命题。
命题:如果查找对象在查找域中,那么缩减查找区间的操作会让最小索引保持在缩减后的查找区间中。
证明:还是分三种情况讨论。
-
isEqual(num, alist[mid])
时
因为[left, right]
中有最小索引,而此时的mid
必定大于等于最小索引,所以[left,mid]
中必有最小索引。 -
num<alist[mid]
时,可知最小索引小于等于mid-1
于是可以。 -
num>alist[mid]
时,可知最小索引大于等于mid+1
于是可以。
左二分点vs右二分点
这一节我们讨论最微妙的细节。
我们首先明确,因为要保留最小索引,在 isEqual(num, alist[mid])
时,我们要将查找区间变换到[left,mid]
。而为了让区间长度严格减小,必须保证mid<right
,这就要求二分点mid
必须是左二分点。如果不好理解的话,考虑查找对象2和查找域[2.,2.],则查找区间为[left, right=left+1]
,则此时的左二分点就是left
而右二分点就是right
,此时只有取左二分点才能缩减区间长度。
左右指针框架
这一节,我们做一点抽象化的扩展。把上面的代码形成一种思维框架。上面的问题,输入的是列表,输出的是索引,所以我们使用索引区间表示查找区间,而最为关键的就是查找区间的两个端点指针
第一部分:左右指针初始化,循环条件(循环变量)初始化
left, right = xxx, yyy
循环条件初始化
第二部分:在循环条件满足时,对左右指针进行更新,并更新循环条件(循环变量)
while 循环条件:
左右指针更新公式
循环变量更新公式
第三部分:确定最终状态,即循环条件的临界破坏情况,一般对应最小规模的问题。在最终状态下,求解最小规模问题。
求解最小规模问题,使用左右指针确定返回值
把这三部分结合起来,一般写代码就不会有遗漏了,而且条理清晰。
所以,我们发现什么了呢?其实不管是递归也好,迭代也好,我们的策略都是具象和抽象的平衡:
- 具体的问题,求解最小规模问题
- 抽象化概念,确定缩减变量,缩减条件,缩减变量的更新公式,把大规模问题缩减为最小规模问题。这个过程要保持解的不变性,以及缩减的有效性。为此,可以使用集合论的思考方法,确定”可行域“,缩减”可行域“,考虑"可行域"的度量。
上面的左右指针框架,实际上就是用左右指针表达出可行域和可行域的度量,并且用左右指针来验证解的不变性,以及缩减的有效性。
最后还是以某某大神的话收尾,要给对象一个名字,你才能记住她。