一、为什么会有不同方式的查找
查找的目的在于从一些数据中寻找一个特定的值,这看似简单的工作之所以产生了形形色色的各种方法,无非都是为了追求更高的效率与更方便的操作。
在范围较小的时候,无论采取什么方法查找,所花费的时间都相差无几,在这种情况下,算法上简单易行,且对存储格式要求较低的线性查找无疑就可以满足我们的要求。
但当所要查找的范围达到了一定程度时,这种方法在耗时上的弱点就逐渐突显,比如我们想在一本字典中查找一个英文单词,如果一页一页一词一词地去搜索,显然是极为愚蠢的。通常,我们会采取这样的方法,比如我们想查找“nature”这个词,一翻字典翻到了前缀为“m”的部分,我们就会往后翻几页,一看翻过了,到了前缀为“o”的部分,就又往会翻几页,这样我们很快就缩小了查找的范围,并能找到想查单词的精确位置。这种方法在查找范围较大时比较可行,但是它要求所要查找的数据必须按照一定的顺序排列,比如字典中的单词,如果在字母顺序上不呈现一定的规律性,这种方法就无从谈起了。这种方式的查找根据数据的分布规律不同还可以有更细的方法分支,其中最简单和典型的是折半查找,此外还有Fibonacci查找(又称费氏查找),插补查找等,其实都可以算是准折半查找,只是“半”的概念有所不同罢了。另外,在数据源变动频繁的情况下,还可以使用二叉查找树来存储数据。
当数据量更大,即使用这种方法也要很多次查找才能确定精确的位置时,我们又想到可以用某种公式将一个有某一特征的数据放在一个固定的位置,需要查找时只需直接用公式计算出它的所在位置就可以引用,这就是杂凑查找。这种方法在查找时间上的效率已趋于极限,但是很难找到一个恰到好处的公式使得数据按这种格式存放时不产生空隙,所以这种方法对于空间的要求就多一些。
笔者用下表总结出这三类查找方式的特点,可以看出,没有哪一种方式是绝对的好或不好的,根据具体的问题和环境,选用最适合的方式,方能得到最佳的效率。
查找方式 |
对数据存储空间的要求 |
对数据存储格式的要求 |
查找时间复杂度 |
线性查找 |
低 |
无 |
高 |
准折半查找 |
低 |
按照一定顺序存储 |
中 |
杂凑查找 |
高 |
数据和位置间根据一定的关系建立映射 |
低 |
二、线性查找
线性查找有叫顺序查找,是从数组中第一笔数据开始查找比较,如果找到则返回该值或该位置,如果没有找到则往下一笔数据查找比较,直到查找到最后一笔数据为止。线性查找在思维上简单易行,代码容易实现,是处理少量数据时很好的一种选择,但是由于其平均状况的时间复杂度为O(n),随着数据量的增大其效率就明显降低,这时我们就应该选用其他的方法。
三、折半查找
折半查找法也称为二分查找法,它充分利用了元素间的次序关系,采用分治策略,可在最坏的情况下用O(log n)完成搜索任务。它的基本思想是,将n个元素分成个数大致相同的两半,取a[n/2]与欲查找的x作比较,如果x=a[n/2]则找到x,算法终止。如果x<a[n/2],则我们只要在数组a的左半部继续搜索x(这里假设数组元素呈升序排列)。如果x>a[n/2],则我们只要在数组a的右半部继续搜索x。二分搜索法的应用极其广泛,而且它的思想易于理解,但是要写一个正确的二分搜索算法也不是一件简单的事。第一个二分搜索算法早在1946年就出现了,但是第一个完全正确的二分搜索算法直到1962年才出现。Bentley在他的著作《Writing Correct Programs》中写道,90%的计算机专家不能在2小时内写出完全正确的二分搜索算法。问题的关键在于准确地制定各次查找范围的边界以及终止条件的确定,正确地归纳奇偶数的各种情况,其实整理后可以发现它的具体算法是很直观的,我们可用C++描述如下:
template<class Type> int BinarySearch(Type a[],const Type& x,int n) { int left=0; int right=n-1; while(left<=right){ int middle=(left+right)/2; if (x==a[middle]) return middle; if (x>a[middle]) left=middle+1; else right=middle-1; } return -1; }
|
模板函数BinarySearch在a[0]<=a[1]<=...<=a[n-1]共n个升序排列的元素中搜索x,找到x时返回其在数组中的位置,否则返回-1。容易看出,每执行一次while循环,待搜索数组的大小减少一半,因此整个算法在最坏情况下的时间复杂度为O(log n)。在数据量很大的时候,它的线性查找在时间复杂度上的优劣一目了然。
四、折半查找的变形—— Fibonacci查找和插补查找
与折半查找法类似,Fibonacci查找和插补查找都是将数据查找范围切成两半,直至能够确定具体的位置或所查找不在范围之内。但是它们在效率和应用范围上也有一定的区别。
折半查找运用除法运算来减少查找范围,而Fibonacci查找法则利用了Fibonacci数列建立相应的树状结构,在缩小范围的过程中仅用到加减法。在计算机处理运算指令中,加减运算的效率高于乘除运算,所以Fibonacci查找法的效率也会优于折半查找。
下面来介绍一下Fibonacci查找的具体过程:
(1)假设数据共有n笔,则先在Fibonacci数列中找到最小的a使满足F(a)<=n+1,然后令root=F(a-1),distance_1=F(a-2),distance_2=F(a-3)。
(2)如果欲查找的数据x<data[root-1],表示x出现在data[root-1]之前,此时令root=root-distance_2,temp=distance_1,distance_1=distance_2,distance_2=temp-distance_2。
(3)如果x>data[root-1],表示x出现在data[root-1]之后,此时令root=root+distance_2,distance_1=distance_1-distance_2,distance_2=distance_2-distance_1。
(4)如果x=data[root-1],表示已查到数据。
重复(2)(3)(4),直到找到相应数据,或直到distance_2为负则表示x不在数据范围内。
插补查找也和折半查找有着类似之处,但它的特色在于借助了比例的概念,与欲查找数据所比较的并不是数据的中间项,而是以内插法按照比例所选择的一项,具体来说就是令middle不再按(left+right)/2取值,而是令它等于left+(x-data[left])*(right-left)/(data[right]-data[left]),其余过程则于折半查找相同,这样一来,就能够在查找平均分布的数据时取得很高的效率,时间复杂度可达到O(log log n),比折半查找更优。然而对于不平均分布的数据,其效率却并不好,甚至在最坏的状况下时间复杂度达到了O(n),与线性查找相同。
为了弥补插补查找的这个缺点,还有一种改良型的插补查找法,被称为加强型插补查找法。是取
num1=left+gap
num2=right-gap
num3=left+(x-data[left])*(right-left)/(data[right]-data[left])
三数中的中间数作为middle值,其中gap是right-left+1的平方根,这样就对不平均分布的数据做出了一定的修正。加强型插补查找法对于平均分布的数据,时间复杂度仍是O(log log n),而对不平均分布的数据则为O((logn)^2),虽然还不如折半查找法,但是对比于插补查找在最差状况时的O(n),已经取得了很好的改善。
五、什么时候使用二叉查找树
二叉查找树任一结点的左子结点的值小于或等于其父结点的值,而右子结点的值大于其父结点的值。我们可以用二叉树排序将查找范围内的数据组织成二叉查找树,然后取得类似折半查找法的效果。然而采用树状结构的最大优点就是对于数据的新增和删除可以用很少的步骤解决,而不必像使用数组那样大批量地移动数据,所以当查找范围内的数据需要做频繁的变动时,最好采用树状结构来存储数据。
六、杂凑查找
之前我们所介绍的查找方法,大体都采用了一个相同的策略,即逐步缩小数据的范围,直至能准确地确定数据的位置,只不过对于线性查找,我们一次只能缩小一个数据的范围,而对于非线性查找则可以缩小一片数据。下面将要介绍的杂凑查找(又名哈希查找)则是一种全新的概念,利用它我们往往可以一次找到所需的数据。
这种方法的原理是,通过特定的杂凑函数建立一个数据和地址间的映射,所有的数据都通过杂凑函数运算后存储于杂凑表中,当我们进行杂凑查找时,只需要将数据再通过杂凑函数计算后,便可求得数据的位置。在使用杂凑查找时有两个主要问题,一是选择不同的杂凑函数会占据不同的存储空间,其中的一部分会浪费大量的未用空间;二是有些数据经过杂凑函数的计算后会映射到相同的空间造成冲突。解决这两个问题是杂凑查找的主要任务,因此选择均匀的杂凑函数和制定解决冲突的政策显得十分重要。
比较常用的杂凑函数有以下几种:
方法名称 |
方法简介 |
直接法 |
每一个键值对应一个存储空间,而不经过任何的数学运算过程 |
减去法 |
数据的键值减去一个特定的数值作为数据存储的位置 |
余数法 |
数据的键值除以数组的大小后取其余数作为数据存储的位置 |
数值抽出法 |
将数据的键值中的某几位数取出后作为数据存储的位置 |
中间平方法 |
将数据的键值中的前几位数取出后平方,再从新产生的数值中抽出某几位数作为数据存储的位置 |
折叠法 |
将数据的键值分为多层,相加后取其结果作为数据存储的位置 |
旋转法 |
将数据的键值进行旋转(通常不直接使用,而是搭配其他的杂凑函数) |
常用的杂凑碰撞解决方法则有以下几种:
杂凑碰撞(冲突)解决法 |
方法简介 |
线性开放寻址法 |
当杂凑函数产生的数据已有数据存在时,往下一笔数据位置寻找可用空间存储数据 |
差值解决法 |
当杂凑函数产生的数据已有数据存在时,以现在的数据地址加上一个固定的差值,当数据地址超出数组大小时,则让数据地址采用循环的方式处理 |
链表解决法 |
当杂凑函数产生的数据已有数据存在时,以现在的数据地址再串连一个新的链表来存储数据 |
分桶杂凑法 |
将数据分为几个大类,而每一个大类中可放置相同大类的数据多笔,当经杂凑函数运算后属同一大类的数据即放在同一大类中,直到这一大类的数据全填满后才往下一大类存储数据 |
我们可以看出,采用存储较紧凑的杂凑函数,出现碰撞的几率相应会高一些,而采用过于稀疏的杂凑函数则是对空间的严重浪费,所以杂凑查找往往需要具体情况具体分析,采取最优的杂凑函数和碰撞解决方法。经验表明,一般使得填装因子(处理元素个数与表空间的比值)介于0.65到0.85之间比较合适。
七、结语
时间和空间的矛盾是计算机世界许多相同的功能出现很多不同方法的根本原因,不同查找方法的同时存在就印证了这一点,它们各有优劣,各有所用,至于能不能用好,就在于程序员和设计者分析具体情况的素质了。希望这篇文章能给您的查找设计带来点滴帮助。