搞定二分查找,套路深得人心

在排序与查找算法中,二分查找是一种常用的用来在有序排列中查找指定元素的方法。

二分查找也称折半查找,是一种效率较高的查找方法。但是,折半查找要求线性表必须采用顺序存储结构,而且表中元素按照关键字有序排列。

二分查找的查找思想为:首先将待搜索序列进行排序,既可以按关键字从小到大,也可以从大到小进行排序,将有序序列从中间一分为二,比较中间值与目标值的大小关系,若待搜索序列是按从小到大进行排列的,且此时中间值比目标值小,那么说明目标值一定不在有序序列的左边,接下来重复上述步骤对右边的序列进行进一步二分查找,直到找到目标值或证明目标值不存在,从而完成一次对目标序列的完整的二分查找。其程序语言描述如下:

int binary_search(int *num, int n, int x) {
    int head = 0, tail = n - 1, mid;
    while ( head <= tail ) { 
        mid = (head + tail) >> 1;
        if ( num[mid] == x ) return mid;
        if ( num[mid] < x ) head = mid + 1;
        if ( num[mid] > x ) tail = mid - 1;
    }   
    return -1; 
}

二分查找算法考虑到目标元素与有序序列之间的关系,将搜索指定元素的时间复杂度降低到O(logn)级别。

以上是二分查找的最常规的用法(也可以称其为朴素二分),通过分而治之的思想,快速搜索目标序列中的特定元素是否存在。这里可以抽象的认为:在待搜索序列中,只要有一个元素满足了要求,就会返回该元素的索引位置,从而结束查找。但是当二分查找的应用场景变为“在待搜索序列(有序序列)中,找到‘最后一个’或‘第一个’满足要求的元素”时,就要对上述算法进行一点小小的改动。

  • 查找满足条件的最后一个元素

如果不能理解什么叫做“满足条件的最后一个元素”,不妨按照既定的“1”表示满足条件,“0”表示不满足条件,当一个有序序列仅由“1”和“0”构成时,例如:“111110000”,“满足条件的最后一个元素”就是该序列中的最后一个“1”。

如果直接使用“朴素二分”的算法,将会在找到任一满足条件的元素后就返回该元素的索引位置,而无法保证该元素是所有满足条件的最后一个元素。

要想找到“满足条件的最后一个元素”,可以这样来修改代码:

// 11111111100000000 find the last 1
int binary_last_search(int *num, int n) {
    int head = -1, tail = n - 1, mid;
    while ( head < tail ) { 
        mid = (head + tail + 1) >> 1;
        if ( num[mid] == 0 ) tail = mid - 1;
        if ( num[mid] == 1 ) head = mid;
    }   
    return head;
}

为什么要将左索引值(head)初始化为“-1”呢?假想,当一个序列中所有的元素都为“0”,也就是说没有一个元素是满足搜索条件的,此时循环会不断的向左缩小范围,直到左索引和右索引值相同时结束循环并返回左索引值,可想而知,此时返回的左索引值为下标“0”,但是连续存储空间的“0”位置原本是有数据的,且该数据不符合查找条件。为了在表示“未找到符合要求的元素”时不引起歧义,将左索引值初始化为“-1”来表示“该序列中无目标元素”。

除了每次对左、右索引值的修改以外,还要注意每次循环中对中间值的修改,mid = (head + tail + 1) >> 1;,之所以要“+1”,是考虑到整型数除2向下取整,防止mid = (head + tail + 1) >> 1;if ( num[mid] == 1 ) head = mid;在满足一定条件时导致出现死循环。

  • 查找满足条件的第一个元素

在理解查找“满足条件的第一个元素”时,可以照葫芦画瓢,即从有序序列“000011111”中找到第一个“1”。不再赘述,不理解如何这样处理的话,请再仔细揣摩一下“满足条件的最后一个元素”的描述。直接上代码:

// 00000111111111 find the first 1
int binary_first_search(int *num, int n) {
    int head = 0, tail = n, mid;
    while ( head < tail ) { 
        mid = (head + tail) >> 1;
        if ( num[mid] == 0 ) head = mid + 1;
        if ( num[mid] == 1 ) tail = mid;
    }   
    return head == n ? -1 : head;
}

这里同样要注意如何处理序列中不存在满足条件的元素的返回值问题。

以上两段代码描述的就是当“朴素二分”遇到特殊情况时的处理方式,当然,特殊情况下的二分查找并不是真的只适合用来在一堆“1”和“0”中找到那个满足条件的最后一个“1”或者第一个“1”,这里的“1”表示满足条件,既然是“条件”,当然就可以根据具体问题具体分析了,举一个简单的例子,幼儿园里有一群小朋友,每个小朋友手中都拿着一定数量的糖果,按照每个小朋友手中的糖果数量对小朋友进行排队,糖果数量多的小朋友站在前面,现在要找到队伍中所有手中糖果数量超过5个的小朋友的最后一位。是不是就可以简化为“11111000”模型,从而套用对应的算法来解决问题。

除了上面提到的“朴素二分”和“特殊情况下的二分”以外,在处理一些实际问题时,还经常遇到一些限制条件,从而使得“二分查找”复杂化,但万变不离其宗,究其根本,还是要回归到“二分”的思想中来的。总结了在练习中遇到的一些题目,找其共性,发现这些带有“附加(限制)条件”的问题,也是有规律的,或者说有解题套路的。

首先归纳遇到的题目:

  1. 某林业局现在有 N 根原木,长度分别为 Xi,为了便于运输,需要将它们切割成长度相等的 M 根小段原木(只能切割成整数长度,可以有剩余),小段原木的长度越长越好,现求小段原木的最大长度。例如,有 3 根原木长度分别为 6, 15, 22,现在需要切成 8 段,那么最大长度为 5。
  2. 某公司的程序猿每天都很暴躁,因为他们每个人都认为其他程序猿和自己风格不同,无法一同工作,当他们的工位的编号距离太近时,他们可能会发生语言甚至肢体冲突,为了尽量避免这种情况发生,现在公司打算重新安排工位,因为有些关系户的工位是固定的,现在只有一部分工位空了出来,现在有 N 个程序猿需要分配在 M 个工位中,第 i 个工位的编号为 Xi,工位编号各不相同,现在要求距离最近的两个程序猿之间的距离最大,求这个最大距离是多少。Xi 和 Xj 工位之间距离为 |Xi−Xj|。例如,1, 2, 8, 4, 9 号工位上安排3个程序猿,其最近的两个程序猿的距离最大为3。
  3. 小明小时候很贪玩,在他童年时期的某一天,他在地上丢了 A 个瓶盖,为了简化问题,我们可以当作这 A 个瓶盖丢在一条直线上,现在他想从这些瓶盖里找出 B 个,使得距离最近的 2 个瓶盖间距离最大,他想知道,最大可以到多少呢?
  4. 有 N 条绳子,它们的长度分别为 Li。如果从它们中切割出 K 条长度相同的绳子,这 K 条绳子每条最长能有多长?答案保留到小数点后 2 位(直接舍掉 2 位后的小数)。
  5. “跳石头”比赛将在一条笔直的河道中进行,河道中分布着一些巨大岩石。组委会已经选择好了两块岩石作为比赛起点和终点。在起点和终点之间,有 N 块岩石(不含起点和终点的岩石)。在比赛过程中,选手们将从起点出发,每一步跳向相邻的岩石,直至到达终点。为了提高比赛难度,组委会计划移走一些岩石,使得选手们在比赛过程中的最短跳跃距离尽可能长。由于预算限制,组委会至多从起点和终点之间移走 M 块岩石(不能移走起点和终点的岩石)。
  6. 现在要把 m 本有顺序的书分给 k 人复制(抄写),每一个人的抄写速度都一样,一本书不允许给两个(或以上)的人抄写,分给每一个人的书,必须是连续的,比如不能把第一、第三、第四本书给同一个人抄写。现需要设计一种方案,使得复制时间最短。复制时间为抄写页数最多的人用去的时间。如果有多解,则尽可能让前面的人少抄写。

考虑到可能会占用太长的篇幅,具体的代码就不在这里展示了,如果小伙伴们对其中的哪个题目有问题,可以私信我进行交流。下面主要说明上述问题的解题套路。

就上述这样一类在“特殊二分”情况下还有附加限制的问题,将其归结为“答案二分”。顾名思义,就是在所有可能的答案中进行二分查找,找到最符合要求的那一项。

解题步骤为:

  1. 根据题目要求,找到“答案”的可能范围,如果无法精确到具体值,可以适当的扩大范围来简化问题;
  2. 找到答案的约束条件,例如上面题目中的第一题,要使切割完的最大长度为5,还要同时满足“切成8段”的约束;
  3. 判断是在所有满足条件中查找“最后一个”还是“第一个”,这里介绍一种简单的做法,就是结合所给输入输出样例,列举样例两边的值,若左边的值比样例小但同时满足条件,那么说明是在“11111000”中找到最后一个“1”,反之就是找“000011111”中的第一个“1”;有时候题目中的“尽可能少”、“尽可能多”一类的修饰词也可以作为线索。
  4. 在答案范围中套用对应的“特殊二分”,对中间值对应的答案进行处理,判断是否满足约束条件,缩小答案范围,继续进行“二分”,直到找到“最佳”答案。

可能上述的解题步骤看起很是生硬,不容易理解,那下面以切割木头的问题为例,描述如何通过“答案二分”来解决这一类问题。

问题描述:

某林业局现在有 N 根原木,长度分别为 Xi,为了便于运输,需要将它们切割成长度相等的 M 根小段原木(只能切割成整数长度,可以有剩余),小段原木的长度越长越好,现求小段原木的最大长度。例如,有 3 根原木长度分别为 6, 15, 22,现在需要切成 8 段,那么最大长度为 5。

输入:

第一行两个整数 N,M。(1≤N≤100,000,1≤M≤100,000,000),

接下来 N 行,每行一个数,表示原木的长度 Xi。(1≤Xi≤100,000,000)

输出:

输出小段原木的最大长度, 保证可以切出 M 段。

样例输入:

3 8

6

15

22

样例输出:

5

代码如下:

#include <iostream>
using namespace std;

int n, m, num[100005], lr;

int func(int len) {
	int s = 0;
	for (int i = 0; i < n; i++) {
		s += num[i] / len;
	}
	return s;
}

int main() {
	cin >> n >> m;
	for (int i = 0; i < n; i++) {
		cin >> num[i];
        lr = max(lr, num[i]);
 	}
 	int l = 1, r = lr;
 	while (l != r) {
 		int mid = (l + r + 1) / 2;
 		int s = func(mid);
 		if (s >= m) {
 			l = mid;
 		} else {
 			r = mid - 1;
 		}
}
 	cout << r << endl;
 	return 0;
}

首先,答案所指即问题所问,问:输出小段原木的最大长度,那么答案就是木头切割完的长度,确定答案的范围,因为要切割成整数长度,那么最短为1,最长为lr(所有木头原本长度的最大值);

其次,找到约束条件,由问题描述得,在满足段数的要求上,尽可能长的切割木头,这里的段数就是约束条件,这里定义func函数来计算对应答案的木头段数;

然后,判断是套用“1111000”还是“0001111”模板,样例输出为5,表示切割完的木头长度为5满足8段的要求,通过计算可以发现,当长度为4, 长度为3时都是满足要求的,但不是最佳的解,因此可以得知这是一个“1111000”找最后一个“1”的问题。(满足约束条件的最长木头,最长);

最后,准备工作做好了,开始对答案进行二分,套用模板,直到找到解。

总结一下,本文由“二分查找”的算法思想出发,将常见的“二分”问题归纳为三类,分别为“朴素二分”、“特殊二分”以及“答案二分”,并分析了具体使用场景。

已标记关键词 清除标记
相关推荐
©️2020 CSDN 皮肤主题: 数字20 设计师:CSDN官方博客 返回首页