二分查找
(1) 二分查找常规解法
核心代码如下:
while (left <= right) {
int mid = (left + right) / 2;
if (nums[mid] > target) right = mid - 1;
else if (nums[mid] < target) left = mid + 1;
else return mid;
}
return -1;
对以上代码的概要说明(建议自己画图加深理解):
如果中间数 < target:
说明targe在右边
移动左边界到中心+1
如果中间数 > target:
说明target在左边
移动右边界到中心-1
以上常规解法的特点:
优点:
- 简单清晰,不容易出错
- 找到任意一个target就能立刻返回
缺点:
- 当有多个相同target时,不确定具体找到哪一个
- 无法确定相同的target的个数
- 无解时,无法提供更多信息
(2) 二分查找的上界与下界
下界:(LowerBound, LB)
- 数组中大于等于target的第一个数的位置
- 无target时,返回target的插入位置
上界: (UpperBound, UB)
- 数组中小于等于target的最后一个数的位置
- 无target时,返回targe插入位置的前一个位置
数组中target的个数为: U B – L B + 1 UB – LB + 1 UB–LB+1
- 当target不存在时,UB与LB反向交叉, U B − L B = = − 1 UB-LB == -1 UB−LB==−1
下界的实现代码如下:
static int lowerBound(int[] a, int t) {
int left = 0, right = a.length - 1;
while (left < right) {
int mid = (left + right) / 2; // (#1)
if (a[mid] >= t)
right = mid;
else // a[mid] < t
left = mid + 1;
}
return left;
}
上界的实现代码如下:
static int upperBound(int[] a, int t) {
int left = 0, right = a.length - 1;
while (left < right) {
int mid = (left + right + 1) / 2; // (#2)
if (a[mid] > t)
right = mid - 1;
else // a[mid] <= t
left = mid;
}
return left;
}
以上两段代码可以保存下来当作模板使用,值得留意的是这两段代码在很多细微的地方存在很小的差别,但写错足以影响整个程序的对错,同学们一定要注意,尤其是当中求mid的那行代码(标记了#1和#2的地方),如果写反了有机会造成程序死循环。
(而且这类错误在生产开发过程中是最可怕的,因为编译和运行过程都没法检查到这个错误给你报错,所以只能通过良好的编码习惯来避免)
补充例题:力扣2594. 修车的最少时间
在讲授二分查找的时候刚好力扣的每日一题就出了一道应用题,正好顺带讲解一下:
问题描述:(完整问题描述请到力扣网原题链接)
本题若从正向思考,需要进行最优规划,难度不小;但若是反向思考,则会简单很多:我们假设先让计算机猜一个答案,例如
t
=
20
t=20
t=20分钟,我们能否算出每个工人在20分钟内最多能修多少辆车呢?进一步,是否就可以算出所有工人能在20分钟内修车的总数,以及这个总数是否大于
c
a
r
s
=
10
cars=10
cars=10辆?
- 如果验证结果为修不够 c a r s cars cars辆车,说明下面我们至少要往 t + 1 t+1 t+1去搜索
- 如果验证结果为能够修大于等于 c a r s cars cars辆车,则应该进一步搜索有没有比 t t t更小的答案
综上所述,问题转化为:搜索一个时间 t t t,使得在 t t t分钟内所有工人能否修好至少 c a r s cars cars辆车,进一步在满足这个条件的 t t t当中,搜索最小的一个,这恰好就是我们上面讲到的下界的定义。
算法描述:(略,同下界模板一样)
代码实现:
java实现:
class Solution {
public long repairCars(int[] ranks, int cars) {
long l = 0L, r = 100000000000000L;
while (l < r) {
long m = l + r >> 1;
// 当前猜答案为m分钟,反过来计算能修好多少辆车
long n = 0;
for (int ra : ranks)
n += (long)Math.sqrt(m / ra);
// 套用二分查找的下界模板
if (n >= (long)cars)
r = m;
else
l = m + 1;
}
return l;
}
}
Python实现:(可以使用Python内置的bisect_left方法)
class Solution:
def repairCars(self, ranks: List[int], cars: int) -> int:
# 校验方法:返回给定t最多能修多少辆车
def check(t):
cnt = 0
for r in ranks:
n = int((t / r) ** 0.5)
cnt += n
return cnt
# 二分查找,目标是修好cars辆车,而且是最少的时间(下界)
return bisect_left(range(10**14), cars, key=check)
课后思考题:求拼接字符串的长度
问题描述:
给定正整数 N
,求 1
到 N
拼接起来的字符串总长度
例如 N=11
,拼接字符串为 "1234567891011"
,总长度为 13
解法1
解题思路:
常规解法(或称为 蛮力法 ): 按题意将字符串拼接起来,然后返回总长度
算法描述:
- 输入整数 N
- 初始化,令
s = ""
- For i : 从
1
到N
: -
s += str(i)
(#注:伪代码,意思是先将i
转换成字符串,然后拼接到s
的末尾) - 循环结束,返回字符串
s
总长度
算法实现:
public static int getLength1(int n) {
String s = "";
for (int i = 1; i <= n; i++)
s += i;
int totalLength = s.length();
return totalLength;
}
算法分析:
很容易看出,本算法的时间复杂度为: O(N)
然而实际运行效果如何呢?我们来看下实际执行效果:
请输入N: 100000
答案为:488895 时间为8658毫秒
对于10万级别的数据,执行时间达到了将近10秒,这么慢的执行结果似乎不符合常识中 O(N)
的表现。为什么会这样呢?原因在于在操作系统中,执行一个字符串变量的拼接操作其实没有这么简单:首先必须在系统内存中找到一个足够大的区域,用于创建一个新的字符串空间,然后将拼接前的两个字符串分别复制到新的位置。很明显这是一个非常消耗时间空间的操作,但在算法描述中却被忽略了。这个例子告诉我们,我们学习算法,同时要对操作系统有比较深入的了解,才能避免掉入这种时间复杂度的陷阱。
找到原因后,我们尝试设计新的算法,看能否优化时间效率。
解法2
解题思路:
我们发现题目只要求输出最终长度,而这个长度相当于计算 1
到 N
的每个数的总位数之和,因此,我们可以计算每个数的总位数,进而相加,得到最终结果
算法描述:
- 输入整数N
- 初始化 totalLength = 0
- For i: 从 1 到 N
- p = i
- c = 1
- while p >= 10
- c++
- p /= 10
- totalLength += c
- 返回totalLength作为最终结果
算法实现:
public static int getLength2(int n) {
int totalLength = 0;
for (int i = 1; i <= n; ++i) {
int p = i;
int c = 1; // 求p的总位数
while (p >= 10) {
c++;
p /= 10;
}
totalLength += c;
}
return totalLength;
}
算法分析:
此算法在解法1的基础上,循环体里面多了一重循环,执行次数为平均每个数的位数,所以总时间复杂度为O(NlgN)
,然后我们通过实际运行结果看看执行时间:
请输入N: 100000
答案为:488895 时间为3毫秒
请输入N: 250000000
答案为:2138888898 时间为2398毫秒
可以看到比起解法1,解法2的时间效率有了巨大的提升,即使把数量级增加到2亿5千万,执行时间也仅为2秒多
进一步优化:在解法2的基础上,本节课的例题是否存在更优化的解法?
解题思路:
我们可以尝试寻找规律,通过直接计算得到结果
下面我们以 N=1234
为例,试下寻找其中的规律
- 拥有 个位数 的总数:很明显所有数字都有个位数,即总共有
1234
个 - 拥有 十位数 的总数:除了数字
1
到9
,其他数字都有十位数,总共为(1234 - 9)
个 - 拥有 百位数 的总数:除了
1
到99
,总共为(1234 - 99)
个 - 拥有 千位数 的总数:总共为
(1234 - 999)
个
然后我们将上述结果加到一起即可
示例代码:
public static int getLength3(int n) {
int totalLength = 0;
int b = 1;
while (n >= b) {
totalLength += n - b + 1;
b *= 10;
}
return totalLength;
}
执行结果:
请输入N: 250000000
答案为:2138888898 时间为0毫秒
总结
本题旨在演示算法给计算效率带来的提升:最解法一的10万级输入已经需要将近10秒,到最后上亿级的输入都能在1ms之内返回答案,我们可以看到不同的算法之间的效率有万亿级的差别,因此,提升算法效率的意义非常重大。