有序向量的查找算法

声明:本文是对 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 也正是我们符合语义的结果。

继续考虑,

之前我们的版本,都是未考虑待查找元素值以及区间元素分布规律的。

假设我们的元素分布都是 均匀且独立 的随机分布,

这里给了我们另一种思路,即不一定每次都固定的选取 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 的值需要用到乘除,也不仅仅像二分查找只要做加减法。

更加完美的方案是:

时间: 2024-11-05 17:30:42

有序向量的查找算法的相关文章

有序向量的去重算法

声明:本文参考 Xuetangx 数据结构 丁俊晖 老师的相关课程,不失为一个个人总结. 首先,这肯定是一个简单而且看起来一目了然的命题.对于有序向量,特别注意是“有序”向量,抓住重要的一个特点,那就是,相同的元素必然是在同一个不间断的区段内的,即相同的元素都是紧邻的构成一个区间. 像这样: 最后要做到: 即重复元素只保留了一个. 考虑到重复元素都是紧邻的,很容易直接写出以下的算法: 也就是,每一元素相同的区间只保留单个元素即可. 由 while 循环和 remove 操作可以得知,算法复杂度为

大话数据结构 - 查找算法总结

1. 顺序表查找(Sequential Search) 1> 算法思想:顺序表查找应该是查找算法中最简单的了.顺序表中所有的记录都是无序的,因此在查找时,没有对查找对象范围的可能线索,唯一的方法就是沿着一个方向一直比较,直到和查找对象相等.完成查找的过程.这里一个优化点是设置一个哨兵,放在顺序表的开始或者结束,每次在搜索顺序表的时候就不必考虑索引是否越界,减少了比较的过程,性能得到提高. 2> 时间复杂度:最好的情况是第一次比较就找到了,时间复杂度是O(1),最坏情况是到最后一个记录才找到,或

【算法练习题】力扣练习题——数组(6): 在有序数组中查找元素存在的范围

原题说明:给定一个按照升序排列的整数数组 nums,和一个目标值 target.找出给定目标值在数组中的开始位置和结束位置. 你的算法时间复杂度必须是 O(log n) 级别. 如果数组中不存在目标值,返回 [-1, -1]. 原题链接:https://leetcode-cn.com/problems/find-first-and-last-position-of-element-in-sorted-array 题目分析: 这道题目当然也是用二分查找来解题.经过上道题的教训,这次我详细考察了各个

排序、查找算法

1> 插入排序 //插入排序(把第一个当作也排好序,然后对后面的依次插入到已排好序的队列中)平均时间复杂度0(n^2) public void insertSort(int[] a){ for(int i = 1;i<a.length;i++){ for(int j = i;j > 0;j--){ if(a[j] < a[j-1]){ int tmp = a[j]; a[j] = a[j-1]; a[j-1] = tmp; } } } } 2> 希尔排序 /*希尔排序:平均时

二分查找算法的 JavaScript 实现

二分查找在查找[指定值]在[有序]数据中的[位置]时是一种高效的算法. 以下仅提供 ES5 版本. var arr = [0, 2, 4, 27, 28, 54, 67, 74, 75, 79, 86, 97, 289, 290, 678] function binarySearch(arr, val) { var start = 0, end = arr.length - 1; while (start <= end) { var mid = Math.floor((start + end)

Java学习 (七)、数组,查找算法,二分查找法,冒泡排序,选择排序,插入排序

一.常用数组查找算法 工作原理:它又称为顺序查找,在一列给定的值中进行搜索,从一端的开始逐一检查每个元素,知道找到所需元素的过程. 例1:查找指定的数在数组中出现的位置,找到返回下标,找不到返回-1 1 import java.util.Scanner; 2 public class LinearSearch{ 3 public static void main(String []argas) 4 { 5 int [] array={10,100,90,65,80,92}; 6 System.o

算法_001_二分查找算法

 二分查找算法是在有序数组中用到的较为频繁的一种算法,在未接触二分查找算法时,最通用的一种做法是,对数组进行遍历,跟每个元素进行比较,其时间为O(n).但二分查找算法则更优,因为其查找时间为O(lgn),譬如数组{1, 2, 3, 4, 5, 6, 7, 8, 9},查找元素6,用二分查找的算法执行的话,其顺序为:     1.第一步查找中间元素,即5,由于5<6,则6必然在5之后的数组元素中,那么就在{6, 7, 8, 9}中查找,    2.寻找{6, 7, 8, 9}的中位数,为7,7>

Java学习之二分查找算法

好久没写算法了.只记得递归方法..结果测试下爆栈了. 思路就是取范围的中间点,判断是不是要找的值,是就输出,不是就与范围的两个临界值比较大小,不断更新临界值直到找到为止,给定的集合一定是有序的. 自己写的代码: 1 package com.gh; 2 3 import java.util.Arrays; 4 /** 5 * 二分查找算法实现 6 * @author ganhang 7 * 8 */ 9 public class Search { 10 public static void mai

二分查找算法java实现

今天看了一下JDK里面的二分法是实现,觉得有点小问题.二分法的实现有多种今天就给大家分享两种.一种是递归方式的,一种是非递归方式的.先来看看一些基础的东西. 1.算法概念. 二分查找算法也称为折半搜索.二分搜索,是一种在有序数组中查找某一特定元素的搜索算法.请注意这种算法是建立在有序数组基础上的. 2.算法思想. ①搜素过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜素过程结束: ②如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间