二分查找及其优化
概念
二分查找,又称折半查找。
- 基本思想:减小查找序列的长度,分而治之地进行关键字的查找。
- 前提:该序列必须是有序的。
- 查找过程:在有序表中,取中间的记录作为比较关键字,若给目标值与中间记录的关键字相等,则查找成功;若目标值小于中间记录的关键字,则在中间记录的左半区间继续查找;若目标值大于中间记录的关键字,则在中间记录的右半区间继续查找;不断重复这个过程,直到查找成功,否则查找失败。
- 实现:通常设置3个指针: low, high, mid 。二分查找要求数组必须是有序的,可以是升序,也可以是降序。
基础实现
// 二分查找 int half_search(int *num, int len, int tar) { int left = 0, right = len - 1; int mid = 0; while (left <= right) { mid = left + (right - left) / 2; if (num[mid] == tar) return mid + 1; else { if (num[mid] > tar) right = mid - 1; else left = mid + 1; } } return 0; }
时间复杂度
实际上,二分查找的过程可以绘制成一棵二叉树,每次二分查找的过程就相当于把原来的树划分为两棵子树,所以每次二分之后下次就只需要查找其中一半的数据就可以了。在最好的情况下,只需要查找一次就可以了,因为这时候中间记录的关键字与要查找的tar是相等,自然一次就够了。在最坏的情况下是从根节点查找到最下面的叶子结点,这个过程需要的时间复杂度是$O(log n)$。
优缺点
需要注意的是,虽然二分查找算法的效率很高(这也是二分查找算法被广泛应用的原因),但是仍然是有使用条件的:有序。所以在需要频繁进行插入或者删除操作的数据记录中使用二分查找算法不太划算,因为要维持数据的有序还需要额外的排序开销。
变种:循环右(左)移
题目描述
升序数组a经过循环右移后,使用二分查找目标元素x。
如$a = {1,2,3,4,5,6,7}$,则循环移动后$a = {5,6,7,1,2,3,4}$。
解题思路
(1)类似正常的二分查找,变化是不断移动左右边界,所以判断条件更加复杂一点。
(2)每次计算中间元素mid,和左边界的元素left比较,总能确定有一边的区间是升序的;
(3)然后对升序那边进行分析,若这个区间不可能包含x,则不再考虑这个区间;若可能包含x,则将查找范围限制在这个区间。即每次都可以排除一半区间。
代码实现(C)
int b_search(int *num, int len, int tar) { int left = 0, right = len - 1; int mid = 0; while (left <= right) { mid = left + (right - left) / 2; if (num[mid] == tar) return mid + 1; if (num[mid] > num[left]) { //左边是升序 if (num[left] > tar) left = mid + 1; else { if (num[mid] > tar) right = mid - 1; else left = mid + 1; } } else { //右边是升序 if (num[right] < tar) right = mid - 1; else { if (num[mid] < tar) left = mid + 1; else right = mid - 1; } } } return 0; }
优化一:插值查找算法
可以发现二分查找每次都是选取中间的记录关键字作为划分依据的,而在有些情况下,使用二分查找算法并不是最合适的。举个例子:在1~1000中,一共有1000个关键字,如果要查找目标值10,按照二分查找算法,需要从500开始划分,这样的话效率就比较低了,所以有人提出了插值查找算法。说白了就是改变划分的比例,比如三分或者四分。
插值查找算法对二分查找算法的改进主要体现在mid的计算公式上,其计算公式为:$$mid = left + \frac{tar - num[left]}{num[right] - num[left]}(right - left)$$
而原来的二分查找公式为:$$mid = left +\frac{1}{2}(right - left)$$
主要变化的地方是$\frac{1}{2}$这个比例系数。其思想可以总结为:插值查找是根据要查找的目标值与查找表中最大最小记录的关键字比较之后的查找算法。核心是上述mid的计算公式。由于大体框架与二分查找算法是一致的,所以时间复杂度仍然是$O(log n)$。
优化二:斐波那契查找算法
从前面的分析中可以看到,无论划分的关键字太大或者太小都不合适,所以又有人提出了斐波那契查找算法,其利用了黄金分割比原理来实现。
一个数列如果满足$F(n) = F(n - 1) + F(n - 2)$,则称这个数列为斐波那契数列。在斐波那契查找算法中计算mid的公式如下:$$mid = left + F(k - 1) - 1$$
斐波那契查找的前提是待查找的查找表必须顺序存储并且有序。
波那契查找与折半查找很相似,根据斐波那契序列的特点对有序表进行分割。要求待查找数组的长度为某个斐波那契数:$len = Fk - 1$。则
首先将tar值与第$F(k - 1)$位置的记录进行比较,即 mid = low + F(k - 1) - 1 。比较的结果分为三种:
<1> tar == num[mid] ,mid位置的元素即为所求;
<2> tar > num[mid] ,则 low = mid + 1, k -= 2; 。前者说明待查找的元素在[mid + 1, high]范围内,后者说明范围[mid + 1, high]内的元素个数为$len - F(k - 1) = Fk - 1 - F(k - 1) = Fk - F(k - 1) - 1 = F(k - 2) - 1$个,所以可以递归地应用斐波那契查找;
<3> key < num[mid] ,则 high = mid - 1, k -= 1 。前者说明待查找的元素在[low, mid - 1]范围内,后者说明范围[low, mid - 1]内的元素个数为$F(k - 1) - 1$ 个,所以可以递归地应用斐波那契查找;
代码实现(C)
void fib_arr(int *F, int n) { F[0] = 0; F[1] = 1; for (int i = 2; i < n; i++) F[i] = F[i - 1] + F[i - 2]; return; } int fib_search(int *num, int len, int tar, int *F) { int left = 0, right = len - 1; int mid = 0; int *F = (int *)malloc(20 * sizeof(int)); fib_arr(F, 20); // 构造一个长度为20的斐波拉契数列 int k = 0; while (F[k] - 1 < len) k++; // 根据待查找数组的长度len确定k的值 //将数组num扩展到F[k]-1的长度 int *temp = (int *)malloc((F[k] - 1) * sizeof(int)); memcpy(temp, num, sizeof(temp)); for (int i = len; i < F[k] - 1; i++) temp[i] = num[len - 1]; while (left <= right) { mid = left + F[k - 1] - 1; if (temp[mid] == tar) { if (mid < len) return mid + 1; //若相等, 则说明mid即为查找到的位置 else return len; //若mid >= len, 则说明是扩展的数值,返回len } else if (temp[mid] > tar) { right = mid - 1; k -= 1; } else { left = mid + 1; k -= 2; } } free(temp); free(F); return 0; }
【注意:斐波拉契数列第26个突破十万,第31个突破一百万,第36个突破一千万,第40个突破一亿,第45突破10亿,第50突破100亿。(数列从坐标1开始计数)】
斐波那契查找的核心是:
- 当 tar = num[mid] 时,查找成功;
- 当 tar < num[mid] 时,新的查找范围是第left个到第mid - 1个,此时范围个数为$F[k - 1] - 1$个,即数组左边的长度,所以要在[low, F[k - 1] - 1]范围内查找;
- 当 tar > num[mid] 时,新的查找范围是第mid + 1个到第right个,此时范围个数为$F[k - 2] - 1$个,即数组右边的长度,所以要在[F[k - 2] - 1]范围内查找。
关键点1:
关于斐波那契查找, 如果要查找的记录在右侧,则左侧的数据都不用再判断了,不断反复进行下去,对处于当中的大部分数据,其工作效率要高一些。所以尽管
斐波那契查找的时间复杂度也为$O(log n)$,但就平均性能来说,斐波那契查找要优于折半查找。可惜如果是最坏的情况,比如这里tar = 1,那么始终都处于左侧在查找,则查找效率低于折半查找。
关键点2:
(1)折半查找是进行加法与除法运算:mid = left + (right - left) / 2;
(2)插值查找则进行更复杂的四则运算:mid = left + (right - left) * ((tar- num[left]) / (num[left] - num[left]));
(3)而斐波那契查找只进行最简单的加减法运算:mid = left + F[k-1] - 1,在海量数据的查找过程中,这种细微的差别可能会影响最终的效率。
(整理自网络)
参考资料:
https://blog.csdn.net/hacker00011000/article/details/48252131