二分只能用來查詢元素嗎?

RioTian發表於2020-10-10

二分搜尋簡介

在電腦科學中,二分搜尋(binary search)也稱折半搜尋(half-interval search)、對數搜尋(logarithmic search),是在有序陣列中查詢某一特定元素的搜尋演算法。

其基本思想是通過逐次比較陣列特定範圍的中間元素與目標元素的大小,每次縮小一半的搜尋範圍,來提高搜尋效率。

二分搜尋的時間複雜度是 \(O(log n)\),空間複雜度為 \(O(1)\)

二分查詢到底能運用在哪裡?

最常見的就是教科書上的例子,也就是上文介紹的一般。在有序陣列中搜尋給定的某個目標值的索引。再推廣一點,如果目標值存在重複,修改版的二分查詢可以返回目標值的左側邊界索引或者右側邊界索引。

PS:以上提到的三種二分查詢演算法形式在東哥的 二分查詢演算法詳解 有程式碼詳解,如果沒看過強烈建議看看。

拋開有序陣列這個枯燥的資料結構,二分查詢如何運用到實際的演算法問題中呢?當搜尋空間有序的時候,就可以通過二分搜尋「剪枝」,大幅提升效率。

說起來玄乎得很,本文用「Koko 吃香蕉」和「貨物運輸」的問題來舉個例子。

一、Koko 吃香蕉

也就是說,Koko 每小時最多吃一堆香蕉,如果吃不下的話留到下一小時再吃;如果吃完了這一堆還有胃口,也只會等到下一小時才會吃下一堆。在這個條件下,讓我們確定 Koko 吃香蕉的最小速度(根/小時)。

如果直接給你這個情景,你能想到哪裡能用到二分查詢演算法嗎?如果沒有見過類似的問題,恐怕是很難把這個問題和二分查詢聯絡起來的。

那麼我們先拋開二分查詢技巧,想想如何暴力解決這個問題呢?

首先,演算法要求的是「H小時內吃完香蕉的最小速度」,我們不妨稱為speed請問speed最大可能為多少,最少可能為多少呢?

顯然最少為 1,最大為max(piles),因為一小時最多隻能吃一堆香蕉。那麼暴力解法就很簡單了,只要從 1 開始窮舉到max(piles),一旦發現發現某個值可以在H小時內吃完所有香蕉,這個值就是最小速度:

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 迴圈,就是在連續的空間線性搜尋,這就是二分查詢可以發揮作用的標誌****。

由於我們要求的是最小速度,所以可以用一個搜尋左側邊界的二分查詢來代替線性搜尋,提升效率:

int minEatingSpeed(int[] piles, int H) {
    // 套用搜尋左側邊界的演算法框架
    int left = 1, 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;
}

剩下的輔助函式也很簡單,可以一步步拆解實現:

// 時間複雜度 O(N)boolean canFinish(int[] piles, int speed, int H) {
    int time = 0;
    for (int n : piles) {
        time += timeOf(n, speed);
    }
    return time <= H;
}

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;
}

至此,藉助二分查詢技巧,演算法的時間複雜度為 \(O(NlogN)\)

二、包裹運輸問題

類似的,再看一道運輸問題:

要在D天內運輸完所有貨物,貨物不可分割,如何確定運輸的最小載重呢(下文稱為cap)?

其實本質上和 Koko 吃香蕉的問題一樣的,首先確定cap的最小值和最大值分別為max(weights)sum(weights)

類似剛才的問題,我們要求最小載重,可以用 for 迴圈從小到大遍歷,那麼就可以用搜尋左側邊界的二分查找演算法優化線性搜尋:

// 尋找左側邊界的二分查詢int shipWithinDays(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, D, mid)) {
            right = mid;
        } else {
            left = mid + 1;
        }
    }
    return left;
}

// 如果載重為 cap,是否能在 D 天內運完貨物?boolean canFinish(int[] w, int D, int cap) {
    int i = 0;
    for (int day = 0; day < D; day++) {
        int maxCap = cap;
        while ((maxCap -= w[i]) >= 0) {
            i++;
            if (i == w.length)
                return true;
        }
    }
    return false;
}

通過這兩個例子,你是否明白了二分查詢在實際問題中的應用呢?

首先思考使用 for 迴圈暴力解決問題,觀察程式碼是否如下形式:

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

如果是,那麼就可以使用二分搜尋優化搜尋空間:如果要求最小值就是搜尋左側邊界的二分,如果要求最大值就用搜尋右側邊界的二分。

總結

很多人覺得二分搜尋很簡單,實際上二分搜尋也可以出比較難的題。甚至有些題目,你不一定能想到用二分法來解決。

同時在不同的資料結構和不同的應用場景中,都可以使用二分搜尋的思想。

這裡 是 leetcode 中和二分搜尋有關的習題。
leetcode 上還有個二分查詢的 專題練習卡片

最簡單的二分搜尋

參考

https://leetcode-cn.com/circle/article/OPV954/

相關文章