Algorithm进阶计划 -- 二分搜索

二分搜索

  • 二分搜索模板
  • 二分搜索运用
图片来源于网络

1. 二分搜索模板

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

二分搜索框架如下:

int binarySearch(int[] nums, int target) {
    int left = 0, right = ...;

    while(...) {
        int mid = left + (right - left) / 2;
        if (nums[mid] == target) {
            ...
        } else if (nums[mid] < target) {
            left = ...
        } else if (nums[mid] > target) {
            right = ...
        }
    }
    return ...;
}

分析二分查找的一个技巧是:不要出现 else,而是把所有情况用 else if 写清楚,这样可以清楚地展现所有细节

1.1 基本的二分搜索

    /**
     * 寻找一个数(基本的二分搜索)
     * <p>
     * 搜索一个数,如果存在,返回其索引,否则返回 -1
     * <p>
     * 缺陷:针对如 nums = [1,2,2,2,3],target为 2 时,此算法返回的索引是 2,而无法处理左侧边界索引1和右侧边界索引3
     * <p>
     * 初始化 right = nums.length - 1,决定了「搜索区间」是 [left, right]
     * 决定了 while (left <= right),同时也决定了 left = mid+1 和 right = mid-1
     * <p>
     * 只需找到一个 target 的索引即可,当 nums[mid] == target 时可以立即返回
     */
    private int binarySearch(int[] nums, int target) {
        int left = 0;
        int right = nums.length - 1;

        // [left, right],终止条件是 left == right + 1
        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] == target) {
                // 直接返回
                return mid;
            } else if (nums[mid] < target) {
                // 搜索区间变为 [mid+1, right]
                left = mid + 1;
            } else if (nums[mid] > target) {
                // right = ...
                right = mid - 1;
            }
        }

        return -1;
    }

1.2 寻找左侧边界的二分搜索

    /**
     * 寻找左侧边界的二分搜索
     * <p>
     * 初始化 right = nums.length,决定了「搜索区间」是 [left, right)
     * 决定了 while (left < right),同时也决定了 left = mid + 1 和 right = mid
     * <p>
     * 因为需找到 target 的最左侧索引,所以当 nums[mid] == target 时不要立即返回
     * 而要收紧右侧边界以锁定左侧边界
     */
    private int left_bound(int[] nums, int target) {
        if (nums.length == 0) return -1;
        int left = 0;
        int right = nums.length;

        // [left, right),终止的条件是 left == right
        while (left < right) {
            int mid = (left + right) / 2;
            if (nums[mid] == target) {
                right = mid;
            } else if (nums[mid] < target) {
                // left = ...
                left = mid + 1;
            } else if (nums[mid] > target) {
                // right = ...
                right = mid;
            }
        }

        return left;
    }

当然,上面算法也可以使用两边都闭的「搜索区间」来实现:

    /**
     * 寻找左侧边界的二分搜索  [left, right]
     */
    private int left_bound(int[] nums, int target) {
        if (nums.length == 0) return -1;
        int left = 0;
        int right = nums.length - 1;
        // 搜索区间为 [left, right]
        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] == target) {
                // 别返回,锁定左侧边界 (收缩右侧边界)
                right = mid - 1;
            } else if (nums[mid] < target) {
                // 搜索区间变为 [mid+1, right]
                left = mid + 1;
            } else if (nums[mid] > target) {
                // 搜索区间变为 [left, mid-1]
                right = mid - 1;
            }
        }

        // 最后要检查 left 越界的情况
        if (left >= nums.length || nums[left] != target) return -1;
        return left;
    }

1.3 寻找右侧边界的二分搜索

    /**
     * 寻找右侧边界的二分搜索
     * <p>
     * 初始化 right = nums.length,决定了「搜索区间」是 [left, right)
     * 决定了 while (left < right),同时也决定了 left = mid + 1 和 right = mid
     * <p>
     * 需找到 target 的最右侧索引,当 nums[mid] == target 时不要立即返回
     * 而要收紧左侧边界以锁定右侧边界
     * <p>
     * 又因为收紧左侧边界时必须 left = mid + 1,所以最后无论返回 left 还是 right,必须减一
     */
    private int right_bound(int[] nums, int target) {
        if (nums.length == 0) return -1;
        int left = 0;
        int right = nums.length;

        // [left, right)
        while (left < right) {
            int mid = (left + right) / 2;
            if (nums[mid] == target) {
                // 收紧左侧边界以锁定右侧边界
                left = mid + 1;
            } else if (nums[mid] < target) {
                // left = ...
                left = mid + 1;
            } else if (nums[mid] > target) {
                // right = ...
                right = mid;
            }
        }

        // return left - 1; // or return right - 1;
        if (left == 0) return -1;
        return nums[left-1] == target ? (left-1) : -1;
    }

同样的,上面算法也可以使用两边都闭的「搜索区间」来实现:

    /**
     * 寻找右侧边界的二分搜索 [left, right]
     */
    private int right_bound(int[] nums, int target) {
        int left = 0;
        int right = nums.length - 1;

        while (left <= right) {
            int mid = left + (right - left) / 2;
            if (nums[mid] == target) {
                // 别返回,锁定右侧边界 (收缩左侧边界)
                left = mid + 1;
            } else if (nums[mid] < target) {
                left = mid + 1;
            } else if (nums[mid] > target) {
                right = mid - 1;
            }
        }

        // 最后要检查 right 越界的情况
        if (right < 0 || nums[right] != target) return -1;
        return right;
    }

小结:

  • 分析二分查找代码时,不要出现 else,全部展开成 else if 方便理解。
  • 注意「搜索区间」和 while 的终止条件,如果存在漏掉的元素,记得在最后检查。
  • 如需定义左闭右开的「搜索区间」搜索左右边界,只要在 nums[mid] == target 时做修改即可,搜索右侧时需要减一。
  • 如果将「搜索区间」全都统一成两端都闭,只要稍改 nums[mid] == target 条件处的代码和返回的逻辑即可。

2. 二分搜索的运用

二分搜索除了在有序数组中搜索给定的某个目标值的索引,当搜索空间有序的时候,也可以过二分搜索「剪枝」,大幅提升效率。

2.1 爱吃香蕉的珂珂

力扣 875 题如下:

爱吃香蕉的珂珂

上面题目用暴力解法比较容易写出如下代码:

    /**
     * 暴力解法
     * <p>
     * Koko 每小时最多吃一堆香蕉,如果吃不下的话留到下一小时再吃;
     * 如果吃完了这一堆还有胃口,也只会等到下一小时才会吃下一堆。
     * 在这个条件下,确定珂珂吃香蕉的最小速度(根/小时)
     * <p>
     * 算法要求的「H小时内吃完香蕉的最小速度」speed,显然最少为 1,最大为 max(piles)
     */
    private int minEatingSpeed(int[] piles, int H) {
        // piles 数组的最大值
        int max = getMax(piles);
        for (int speed = 1; speed < max; speed++) {
            // 以 speed 是否能在 H 小时内吃完香蕉
            if (canFinish(piles, speed, H)) {
                return speed;
            }
        }
        return max;
    }

值得注意的是,上面的 for 循环是在连续的空间线性搜索,也就是二分查找可以发挥作用的标志

寻找左侧边界的二分搜索来代替线性搜索,如下:

    /**
     * 借助二分查找技巧,算法的时间复杂度为 O(NlogN)
     */
    int minEatingSpeed(int[] piles, int H) {
        int left = 1;
        int right = getMax(piles) + 1;
        // 套用 寻找左侧边界的二分搜索 框架
        while (left < right) {
            int mid = left + (right - left) / 2;
            if (canFinish(piles, mid, H)) {
                right = mid;
            } else {
                left = mid + 1;
            }
        }
        return left;
    }

    /**
     * 在规定时间内是否能吃完香蕉
     *
     * @param piles 香蕉数量
     * @param speed 吃香蕉速度
     * @param H     规定时间
     */
    boolean canFinish(int[] piles, int speed, int H) {
        int time = 0;
        for (int n : piles) {
            time += timeOf(n, speed);
        }
        return time <= H;
    }

    /**
     * 吃一堆香蕉的时间
     *
     * @param n     一堆香蕉的个数
     * @param speed 吃香蕉的速度
     */
    int timeOf(int n, int speed) {
        return (n / speed) + ((n % speed > 0) ? 1 : 0);
    }

    /**
     * 数组最大值
     */
    int getMax(int[] piles) {
        int max = 0;
        for (int n : piles) {
            max = Math.max(n, max);
        }
        return max;
    }

2.2 包裹运输问题

力扣 1011 题如下:

在 D 天内送达包裹的能力

上面题目本质上和珂珂吃香蕉的问题是一样的,需要确定运输的最小载重,假设其最小值和最大值分别为 max(weights)sum(weights),用寻找左侧边界的二分搜索优化线性搜索如下:

   /**
     * 最小载重
     */
    int shipWithDays(int[] weights, int D) {
        // 载重可能的最小值
        int left = getMax(weights);
        // 载重可能的最大值 + 1
        int right = getSum(weights) + 1;
        // 套用 寻找左侧边界的二分搜索 框架
        while (left < right) {
            int mid = left + (right - left) / 2;
            if (canFinish(weights, mid, D)) {
                right = mid;
            } else {
                left = mid + 1;
            }
        }
        return left;
    }

    /**
     * 若载重为 cap,是否能在 D 天内运完货物?
     */
    boolean canFinish(int[] weights, int cap, int D) {
        int i = 0;
        for (int day = 0; day < D; day++) {
            int maxCap = cap;
            while ((maxCap -= weights[i]) >= 0) {
                i++;
                if (i == weights.length) {
                    return true;
                }
            }
        }
        return false;
    }

    /**
     * 数组最大值
     */
    int getMax(int[] piles) {
        int max = 0;
        for (int n : piles) {
            max = Math.max(n, max);
        }
        return max;
    }

    /**
     * 数组和
     */
    int getSum(int[] weights) {
        int sum = 0;
        for (int n : weights) {
            sum += n;
        }
        return sum;
    }

2.3 分割数组的最大值

力扣 410 题如下:

分割数组的最大值

上面题目是固定了 m 的值,让我们确定一个最大子数组和;那可以反过来,限制一个最大子数组的和 max,来反推最大子数组的和为 max 时,至少可以将 nums 分割成几个子数组。定义函数如下:

    /**
     * 在每个子数组和不超过 max 的条件下,计算 nums 至少可以分割成几个子数组
     *
     * 如 nums = [7,2,5,10],若 max = 10,则split函数返回 3,
     * 即 nums 数组最少能分割成三个子数组,分别是[7,2],[5],[10]
     */
    int split(int[] nums, int max);

若能找到一个最小的 max 值,满足上述函数 split(nums, max) 的结果和 m 相等,那么这个 max 值不就是符合题意的「最小的最大子数组的和」吗?

接下来对 max 进行穷举,子数组至少包含一个元素,至多包含整个数组,那么最大子数组的和 max 的取值范围是闭区间 [max(nums), sum(nums)],即最大元素值到整个数组和之间,如下所示:

    /**
     * 计算最大子数组的和
     */
    int splitArray(int[] nums, int m){
        int low = getMax(nums);
        int high = getSum(nums);
        for (int max = low; max <= high; max++){
            // 如果最大子数组和是 max,至少可以把 nums 分割成 n 个子数组
            int n = split(nums, max);
            // 为什么是 <= 不是 == ?
            // split 函数采用了贪心的策略,计算的是 max 限制下至少能够将 nums 分割成几个子数组
            if (n <= m){
                return max;
            }
        }
        return -1;
    }

    /**
     * 在每个子数组和不超过 max 的条件下,计算 nums 至少可以分割成几个子数组
     *
     * 如 nums = [7,2,5,10],若 max = 10,则split函数返回 3,
     * 即 nums 数组最少能分割成三个子数组,分别是[7,2],[5],[10]
     */
    int split(int[] nums, int max){
        // 至少可以分割的子数组数量
        int count = 1;
        // 记录每个子数组的元素和
        int sum = 0;
        for (int i = 0; i< nums.length; i++){
            if (sum + nums[i] > max){
                // 如果当前子数组和大于 max 限制,则这个子数组不能再添加元素了
                count++;
                sum = nums[i];
            } else {
                // 当前子数组和还没达到 max 限制,还可以添加元素
                sum += nums[i];
            }
        }
        return count;
    }

    /**
     * 数组最大值
     */
    int getMax(int[] nums) {
        int max = 0;
        for (int n : nums) {
            max = Math.max(n, max);
        }
        return max;
    }

    /**
     * 数组和
     */
    int getSum(int[] nums) {
        int sum = 0;
        for (int n : nums) {
            sum += n;
        }
        return sum;
    }

上面代码是用暴力解法实现的,由于 split 是单调函数,且符合二分查找技巧进行优化的标志,因此可用二分搜索进行优化。

因为算法返回最小的那个 max,所以用寻找左侧边界的二分搜索优化如下:

    /**
     * 计算最大子数组的和
     * <p>
     * 假设 nums 元素个数为 N,元素和为 S,则 split 函数的复杂度为 O(N),二分查找的复杂度为 O(logS),
     * 所以算法的总时间复杂度为 O(N*logS)
     */
    int splitArray(int[] nums, int m) {
        int left = getMax(nums);
        // 一般搜索区间是左开右闭的,所以 right 要额外加一
        int right = getSum(nums) + 1;
        while (left < right) {
            int mid = left + (right - left) / 2;
            // 根据分割子数组的个数收缩搜索区间
            int n = split(nums, mid);
            if (n == m) {
                // 收缩右边界,达到搜索左边界的目的
                right = mid;
            } else if (n < m) {
                // 最大子数组和上限高了,减小一些
                right = mid;
            } else if (n > m) {
                // 最大子数组和上限低了,增加一些
                left = mid + 1;
            }
        }
        return left;
    }

    int split(int[] nums, int max) {... }
    int getMax(int[] nums) {... }
    int getSum(int[] nums) {... }

小结:
二分查找在实际问题中的应用,首先思考使用 for 循环暴力解决问题,观察代码是否如下形式:

for (int i = 0; i < n; i++){
    if (isOK(i)) return answer;
}

如果是,那么就可以使用二分搜索优化搜索空间:如果要求最小值就是搜索左侧边界的二分,如果要求最大值就用搜索右侧边界的二分。


参考链接:

我写了首诗,让你闭着眼睛也能写对二分搜索

如何运用二分查找算法

二分搜索怎么用?我和快手面试官进行了深度探讨

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 199,271评论 5 466
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 83,725评论 2 376
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 146,252评论 0 328
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 53,634评论 1 270
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 62,549评论 5 359
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 47,985评论 1 275
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,471评论 3 390
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,128评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,257评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,233评论 2 317
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,235评论 1 328
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,940评论 3 316
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,528评论 3 302
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,623评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,858评论 1 255
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,245评论 2 344
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 41,790评论 2 339

推荐阅读更多精彩内容