【202309】算法基础-L3-分治法(1)

二分查找

(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 UBLB+1

  • 当target不存在时,UB与LB反向交叉, U B − L B = = − 1 UB-LB == -1 UBLB==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,求 1N 拼接起来的字符串总长度

例如 N=11,拼接字符串为 "1234567891011",总长度为 13

解法1

解题思路:

常规解法(或称为 蛮力法 ): 按题意将字符串拼接起来,然后返回总长度

算法描述:

  1. 输入整数 N
  2. 初始化,令 s = ""
  3. For i : 从 1N :
  4.   s += str(i) (#注:伪代码,意思是先将 i 转换成字符串,然后拼接到 s 的末尾)
  5. 循环结束,返回字符串 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

解题思路:

我们发现题目只要求输出最终长度,而这个长度相当于计算 1N 的每个数的总位数之和,因此,我们可以计算每个数的总位数,进而相加,得到最终结果

算法描述:

  1. 输入整数N
  2. 初始化 totalLength = 0
  3. For i: 从 1 到 N
  4.   p = i
  5.   c = 1
  6.   while p >= 10
  7.     c++
  8.     p /= 10
  9.   totalLength += c
  10. 返回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
  • 拥有 十位数 的总数:除了数字 19,其他数字都有十位数,总共为(1234 - 9)
  • 拥有 百位数 的总数:除了 199,总共为 (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之内返回答案,我们可以看到不同的算法之间的效率有万亿级的差别,因此,提升算法效率的意义非常重大。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值