常见查找算法(一)

常见查找算法——静态查找

查找问题

查找(Searching)问题是最为常见的问题之一,指的是判断在给定的数据元素(或记录)中,是否含有某个特定关键字(Key)。简单来说,就是解决“存在”还是“不存在”的问题。而这个给定的数据元素集合,我们称之为查找表(Search Table)。
查找方法有许多种,根据其操作方式可分为两种:静态查找动态查找

  • 静态查找:
    对查找表只作查找操作。如顺序查找(Sequential Search)、二分法查找(Binary Search)、插值查找(Interpolation Search)和斐波那契查找(Fibonacci Search)。

  • 动态查找:
    会在查找的过程中向查找表内插入元素或删除元素。动态查找算法将会在接下来的博文中进行相关介绍。

查找算法中最为直观也是最好理解的方式,即按照表中各元素的顺序逐个与关键字进行比较,如果某元素等于关键字的值,说明表中含有该关键字,可返回该元素在表中的位置,并结束查找过程;如果直到表中最后一个元素依然没有与关键字相匹配,则说明该表中并无此关键字。

public static int sequentialSearch(int[] arr, int num) {  
        for (int i = 0; i < arr.length; i++) {  
            if (arr[i] == num)  
                return i + 1; // i+1代表该元素在查找表中的实际位置 
        }  
        return -1;  

顺序查找算法的优点就是相对容易理解,并且对表是否有序不做要求。但是在最差的情况下,如果关键字在表的最后一位,或者表中并无此关键字,那么都需要将所有表中数据比对一遍,复杂度为O(n)。相较于其他几种方法,复杂度较高。

二分法查找又叫折半查找。顾名思义,该方法每次可以将表中有可能存在关键字的数据量减少一半,从而提高效率。但是,该方法有两大限制条件:数据类型为数组(内存连续)查找表必须为有序排列。第二条还比较好理解,如果是无序的表,无任何规律可寻的情况下我们不可能盲目的确定关键字的范围。至于第一条,为什么链表(不能随机存取元素)不可以使用该算法,请参照线性链表不能随机存取元素的原因。

由于查找表现在是有序的,那么如果我们想判断该表中是否含有关键字,那么每次我们可以与有效区间内处于中间位置的元素做对比,如果相等是最好的,即使不相等,我们也可根据关键字与中间元素的关系来判断该关键字处于中间元素左边还是右边。比如说对于数组{1,2,3,4,5},现在我的关键字为2,首先我们将关键字(2)与中间元素(3)比较,发现 2 < 3,因为知道这是一个有序表,那么 3 右边的元素一定大于 3,也就不会出现关键字 2,那么会出现关键字的有效区域就将缩减为 3 的左边。

这里的有效区间我们可以用 low 和 high 来表示下界和上界,而中间元素则可认为是查找表在 (high - low)/2 处的值。通过不断的比较,我们会不停的更新 low 和 high 的值,从而不断缩小范围,知道能够判断出查找表中是否含有该关键字。

public static int binarySearch(int[] arr,int num) {
        int low = 0;
        int high = arr.length - 1; //初始时下界为0,上界为arr.length-1,说明初始有效范围是整个数组

        while(low <= high){
            int mid = low + (high-low) / 2; // 下界加上中值可以算出该中值在查找表中的位置
            if(num == arr[mid]) return mid + 1;

            if(num < arr[mid]){
                high = mid - 1; //说明关键字在中值的左边,那么有效范围的上界更新为中值左边的第一个元素,下界不变
            }

            if(num > arr[mid]){
                low = mid + 1;//和上面相反,有效范围下界更新为中值右边第一个元素
            }


        return -1;
    }

由每次将范围缩小一半可知,该算法的复杂度为O(log n),大大降低了顺序查找的复杂度。但是既然我们将要缩小有效区间,那么为什么一定要对半分呢?我们能不能根据经验来更快的缩小有效区间呢?

在生活中我们通常能够通过生活经验来判断某目的地的位置。打个比方,我知道从寝室到教学楼之间有一座食堂,并且知道食堂的门牌号,可是我不知道具体位置。我们知道现实中门牌号一定是有序的,那么我可以像二分法一样先找到处在教学楼与宿舍楼正中间的某建筑的门牌号码,然后逐次比较,直到找到这座食堂。那么有没有更快的方法呢?根据我们的生活经验,食堂一般是更靠近生活住宿的地方,而离学习工作的地方会有一段更长的距离,那么我就可以根据生活经验来将需要做比对的建筑物选得更靠近宿舍区,从而更快的缩小有效区间。这就是插值算法的思想。

从上面的例子我们可以看出,插值算法是对二分法的一种改进,主要体现在他对中值的定义不再是盲目的选取中间值,而是根据经验来有倾向的靠近某边界。这里我们通过

mid = low + (num-arr[low])/(arr[high]-arr[low])*(high-low)

来确定中值。很好理解,如果关键字的值和下界的值的差值 (num-arr[low]) 相对于上下界的差值 (arr[high]-arr[low]) 占比例较大,说明 关键字更靠近上界,mid 的值会较大;反之如果比例较小,说明关键字与下界的值差别不大,mid 较小,说明我们更倾向于在下界附近来寻找关键字。

public static int interpolationSearch(int[] arr,int num) {
        int low = 0;
        int high = arr.length - 1;

        while(low <= high){
            int mid = low + (num-arr[low])/(arr[high]-arr[low])*(high-low); //这里我使用先除后乘,因为在实测过程中发现乘除的先后顺序会产生不同的结果,先乘后除可能导致死循环

            if(num == arr[mid]) return mid+1;

            if(num < arr[mid]){
                high = mid - 1;
            }

            if(num > arr[mid]){
                low = mid + 1;
            }

        }
        return -1;
    }

我们发现这种情况下会更加快速缩小范围来确定关键字的有无。算法的复杂度为O(log(log n))。

首先解释下什么是斐波那契数列。

斐波那契数列(Fibonacci sequence),又称黄金分割数列、因数学家列昂纳多·斐波那契(Leonardoda Fibonacci)以兔子繁殖为例子而引入,故又称为“兔子数列”,指的是这样一个数列:1、1、2、3、5、8、13、21、34、……在数学上,斐波纳契数列以如下被以递归的方法定义:F(0)=0,F(1)=1, F(n)=F(n-1)+F(n-2)(n>=2,n∈N*) —–百度百科

斐波那契算法同样是二分法的改进,利用黄金分割比例来进行中点的确认。如图所示:

常见查找算法(一)_第1张图片

首先我们根据斐波那契数列来构造一个辅助的数组 f[k],其中 k 代表斐波那契数列中的第 k 个值。根据性质,f[k]-1 = f[k-1]-1 + f[k-2]-1 + 1,其中最后一个常数项 1 是中间值的长度,只有一个数字所以为1。则总长度为 f[k]-1,中间值左边长度为 f[k-1]-1,右边长度为 f[k-2]-1。那么这么做的好处是什么?并不是因为黄金分割比的美感,才有了这个思想,相较于之前的二分法和插值查找,他们对于中值的定义都会出现乘除的运算。而在程序运行中,乘除法的运算时间会远大于加减法。而斐波那契查找就是通过斐波那契数列的性质来只通过加减法确定中间值进行比较。

这也就说明有效范围的长度一定需要时 f[k] 中某个值减一 。那么如果长度不够怎么办呢?我们需要来对有效范围内的数组进行补充,通常补充值均选取 high 处的值,这样不会向查找表中引入新的值,也方便返回关键字所在的位置。

当我们得到一个有效范围,我们通过斐波那契数列构成的 f[k-1] 的值来确定前半部分的长度,从而能够选取下一个中值:

mid = low + f[k-1] - 1

其中 f[k-1] - 1 是中点左边有效范围的长度。然后根据 f[k-1] 或者 f[k-2]的值来进行确定下一有效范围。

public static int fibonacciSearch(int[] arr,int num) {
        int[] f = new int[]{1,1,2,3,5,8,13,21,34,55}; //斐波那契数列,根据需要只选取前10个数
        int low = 0;
        int high = arr.length-1;
        int k = 0; 

        while(arr.length > f[k] - 1){
            k++; //找到能一个k值能满足f[k]-1的大小大于查找表的长度
        }

        int[] a = new int[f[k]-1];    //构建新的查找表
        for(int i=0; i//前半部分与原查找表相同
        }
        for(int j=arr.length; j//后半部分补充位数直到f[k]-1长度
        }

        while(low <= high){
            int mid = low + f[k-1] - 1;  //加上f[k-1] - 1说明加上了中点左边的长度
            if(num < a[mid]){
                high = mid - 1;
                k = k - 1; //f[k]-1=f[k-1]-1+1+f[k-2]-1,所以左边长度为f[k-1]-1
            }
            else if(num > a[mid]){
                low = mid + 1;
                k = k - 2;  ////f[k]-1=f[k-1]-1+1+f[k-2]-1,所以右边长度为f[k-2]-1
            }
            else{
                if(mid <= high) return mid + 1;
                else return high + 1;  //如果是补充的位置,因为值等于high,所以返回的是high处坐标
            }
        }

        return -1;
    }

小结

我们发现以上四种算法中其实可分为两类: 无序查找表查找有序查找表查找。 前者不在乎查找表是否已经排好顺序,限制因素较少,可运用顺序查找,但是复杂度较高;后者只能作用于有序查找表,根据与中间值的比较来缩小关键字可能出现的范围。二分法是基本的算法,在此之上插值查找和斐波那契查找都对中值的定义进行了改进。但是根据查找表数据分布情况还是要视情况进行选择,插值查找和斐波那契查找并不是任何情况下都要优于二分查找的。

你可能感兴趣的:(算法)