声明:本文是对 xuetangx 清华大学 丁俊晖 老师 数据结构 课程的个人总结。
说到有序向量的查找算法,首先蹦入脑海的肯定是二分查找算法。
然而,即便是简单的二分查找也没有想象的那么简单。
首先考虑一些特殊情形:
1、查找的元素不存在; 2、要查找的元素值存在多个。
当然,对于不存在的情况,我们可以简单的返回一个 -1 代表未查找到,但很多时候,这样做往往是不够的。比如说,我们在调用查找之后,很有可能紧接着需要考虑插入一个值使原向量依然保持有序,而如果我们仅仅只是返回一个未查找到的 -1 ,显然是不足以作为插入操作的有效依据的。所以,即使是查找失败,我们也需要给出让新元素插入的适当位置,给后续操作作为参考的依据。同样,即便是有重复的元素,我们也需要返回一个有效的位置。
统一定义一个语义:(假定向量是从小到大有序排列的,e为待查找的元素)
(注意对向量两边都插入哨兵这样对线性数据结构通用的使用技巧,假定为一些特殊的值,无穷小、无穷大等等)
这样的语义定义是十分优秀的:因为它可以保持这个语序向量的稳定性:即在保持向量有序性的同时,也同时保持了相同元素按照插入的先后次序。
算法实现:
版本A:
当然,这里未查找到还是简明的返回 -1。
值得注意的就是中间的 if 语句,要注意的有:
1、两个条件都统一采用 “<” 号的方式,这是一种良好的习惯,便于阅读,一看就知道两元素的大小关系,即 小的在左边,大的在右边,符合人们的日常思维习惯,和我们通常画的图也是吻合的。也相当于 A[mi] 是一个界桩,e 在左右哪个区间一目了然。
2、e 在左区间和在右区间需要的比较次数是不同的。当 e 在左区间,只需要一次比较,即执行 hi = mi;而 e 在右区间,要比较两次后,才执行 lo = mi + 1。注意,大小比较操作相对于等否比较以及赋值操作来讲,效率都是很低的。
显然,这个算法渐进意义上的复杂度是 O(logn) 的。
我们现在从更加细微的地方来考察它的复杂度,也就是渐进复杂度 logn 前面的常系数。
关键在于,我们每次选取 mi 的依据都是取 lo 和 hi 的中点,也就是我们粗略的考虑,想要使两边都趋于平衡,这是很容易理解的。
当然,这里的前提是,每一个元素出现的概率是相等的。
可以证明,如果 mi 每次都取中点,复杂度大致为 O(1.50 logn) 的。
实际上,这还有可以改进的空间。
对于我们版本A的实现,左右区间并不是平衡的,也就是上面所说的要注意的第二点,每次进入右区间都比左区间要多比较一次。
即:
很自然地,我们会考虑尽可能的让待查找的目标项落入左边区间,这样就显然地可以减少比较的次数。
成本高的我们希望做的少,而成本底的我们希望做的多。
搜索树对比像这样:
很自然,我们考虑改进。而很有意思的是,这个改进跟 Fibonacci 数密切相关(而 Fibonacci 数跟 黄金分割点 又有着神秘的密切关联)。
改进的关键在于,我们每次将 mi 选取在 lo 和 hi 的较大的黄金分割点(0.6180339)处,也即 Fibonacci 数的 a(n-1)/a(n) 处。
这也就是 Fibonacci Search:
具体实现:(注意区间 [lo, hi) 都是左闭右开的)
实际上,上述两个查找版本的本质不同就是 mi 轴点的选取位置不同,那到底选取到哪常系数上是最优的呢?
数学上的证明:
也就是黄金分割点。所以 Fibonacci Search 实际上已经对常系数的优化达到了最优。
反思以上的过程,既然我们注意到了版本A中造成效率略低的原因是左右分支的转向代价不平衡,那么我们为什么不将两者做得平衡呢?
也就是在任何一个位置,无论最后是向左还是向右,都只需要一次比较。
这样,我们每一次迭代都只能有两个分支而不是版本A的三个,也就是隐藏版本A中 a[mi] 与 e 相等那个分支。
具体来说:
作为牺牲的是,我们不能立即判断出当前元素是否和目标元素相等,必须等到最后区间宽度变为 1 才能真正的判断。
但毕竟,正好相等的情况对比所有情况概率是极低的,整体上而言,我们相当于每次减少了一次比较,是很可观的。
二分查找,版本B实现:(注意边界哨兵)
实际上,以上的各个版本,并没有完全实现我们之前所约定的语义:返回不大于 e 的最后一个元素(包含哨兵)。
在版本B的基础上,我们可以略作调整得到版本C:
(mi 更好的计算方式是 lo + ((hi - lo) >> 1),防止加法运算溢出)
虽然看起来和版本B差别不大,实际上很多细节有着本质上的差别:
此版本并没有任何算法上的漏洞和差错。
正确性分析:
算法的单调性是不言而喻的,问题的规模都能有效的减少。
而对于不变性:
每一次迭代并未改变不变性,最后迭代到区间为空即退出循环,单调性也没有问题。
而最后返回的 --lo 也正是我们符合语义的结果。
与之对偶的“返回大于等于目标元素的最小的元素的位置(即目标元素所在位置(查找到时)或 插入目标元素后向量依然有序的位置(未查找到时))”:
1 class Solution: 2 # @param {integer[]} nums 3 # @param {integer} target 4 # @return {integer} 5 def searchInsert(self, nums, target): 6 lo, hi = -1, len(nums) - 1 7 while lo < hi: 8 mi = (lo + hi + 1) >> 1 9 if target <= nums[mi]: 10 hi = mi - 1 11 else: 12 lo = mi 13 return hi + 1
来自:https://leetcode.com/problems/search-insert-position/
继续考虑,
之前我们的版本,都是未考虑待查找元素值以及区间元素分布规律的。
假设我们的元素分布都是 均匀且独立 的随机分布,
这里给了我们另一种思路,即不一定每次都固定的选取 mi 相对于 lo 和 hi 的值,而是可以根据具体值来动态的确定 mi 。
这就是 插值查找:
注意我们的前提假设,如果不满足,有可能退化成 线性的顺序查找, 即 O(n) 的。
满足的情况下,则可以极快的收敛,甚至在第一次猜测的时候就直接命中。
而对于每次的 lo 和 hi 的确定,应遵循以下原则:
“严格地说,在插值查找过程中,向左和向右深入时,取整的方向应该不同。具体地:
- 移动lo时,向上取整(ceiling)
- 移动hi时,向下取整(floor)”
原因是为了保证问题规模严格缩小,而不致原地踏步。也就是说,lo和hi至少其一会因此移动(并彼此靠拢)。采用不同的去整方向,即可保证这一点,也就是保证算法的单调性。
插值查找算法性能分析:
有一个结论,平均情况:每经过一次比较,n 缩至 sqrt(n)。
最后可得出是 O(loglogn) 的复杂度。
怎样分析的呢?
我们并希望过多的使用准确的数学分析,而是学会如何去进行估算。
对于这个例子:
对于区间长度 n ,用二进制打印出来的长度是 logn。
而每一次将 n 变为 sqrt(n), 二进制打印宽度即变成了 1/2*logn。
即 字宽折半。
如果说 二分查找 是对 n 的数值每次折半的话,那 插值查找 实际上是对 n 的二进制位宽度来做二分查找。
二分查找的迭代次数,我们知道是 logn 的,而 长度是 logn 的,所以最后插值查找的迭代次数就是 loglogn 的。
这种字宽折半的,不用数学进行的,宏观的,把握大趋势的分析,正是我们需要锻炼的。
实际上,除非查找区间宽度极大,或者比较操作成本极高,改进并不明显,而且存在上述所说的在局部区域或者由于分布情况插值算法被“蒙骗”的情况,而计算 mi 的值需要用到乘除,也不仅仅像二分查找只要做加减法。
更加完美的方案是:
[ by Maples7 ]
[ Copyright @Maples7,转载请注明出处。 ]