我們前文 我作了首詩,保你閉著眼睛也能寫對二分查找 詳細介紹了二分搜索的細節問題,探討了「搜索一個元素」,「搜索左側邊界」,「搜索右側邊界」這三個情況,教你如何寫出正確無 bug 的二分搜索算法。
但是前文總結的二分搜索代碼框架僅僅局限于「在有序數組中搜索指定元素」這個基本場景,具體的算法問題沒有這么直接,可能你都很難看出這個問題能夠用到二分搜索。
對于二分搜索算法在具體問題中的運用,前文 二分搜索的運用(一) 和前文 二分搜索的運用(二) 有過介紹,但是還沒有抽象出來一個具體的套路框架。
所以本文就來總結一套二分搜索算法運用的框架套路,幫你在遇到二分搜索算法相關的實際問題時,能夠有條理地思考分析,步步為營,寫出答案。
警告:本文略長略硬核,建議清醒時學習。
原始的二分搜索代碼
二分搜索的原型就是在「有序數組」中搜索一個元素target,返回該元素對應的索引。
如果該元素不存在,那可以返回一個什么特殊值,這種細節問題只要微調算法實現就可實現。
還有一個重要的問題,如果「有序數組」中存在多個target元素,那么這些元素肯定挨在一起,這里就涉及到算法應該返回最左側的那個target元素的索引還是最右側的那個target元素的索引,也就是所謂的「搜索左側邊界」和「搜索右側邊界」,這個也可以通過微調算法的代碼來實現。
我們前文 二分搜索算法框架詳解 詳細探討了上述問題,對這塊還不清楚的讀者建議復習前文,已經搞清楚基本二分搜索算法的讀者可以繼續看下去。
在具體的算法問題中,常用到的是「搜索左側邊界」和「搜索右側邊界」這兩種場景,很少有讓你單獨「搜索一個元素」。
因為算法題一般都讓你求最值,比如前文 二分搜索的運用(一) 中說的例題讓你求吃香蕉的「最小速度」,讓你求輪船的「最低運載能力」,前文 二分搜索的運用(二) 講的題就更魔幻了,讓你使每個子數組之和的「最大值最小」。
求最值的過程,必然是搜索一個邊界的過程,所以后面我們就詳細分析一下這兩種搜索邊界的二分算法代碼。
「搜索左側邊界」的二分搜索算法的具體代碼實現如下:
// 搜索左側邊界int left_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0, right = nums.length;
while (left 《 right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
// 當找到 target 時,收縮右側邊界
right = mid;
} else if (nums[mid] 《 target) {
left = mid + 1;
} else if (nums[mid] 》 target) {
right = mid;
}
}
return left;
}
假設輸入的數組nums = [1,2,3,3,3,5,7],想搜索的元素target = 3,那么算法就會返回索引 2。
「搜索右側邊界」的二分搜索算法的具體代碼實現如下:
// 搜索右側邊界int right_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0, right = nums.length;
while (left 《 right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
// 當找到 target 時,收縮左側邊界
left = mid + 1;
} else if (nums[mid] 《 target) {
left = mid + 1;
} else if (nums[mid] 》 target) {
right = mid;
}
}
return left - 1;
}
輸入同上,那么算法就會返回索引 4:
好,上述內容都屬于復習,我想讀到這里的讀者應該都能理解。記住上述的圖像,所有能夠抽象出上述圖像的問題,都可以使用二分搜索解決。
二分搜索問題的泛化
什么問題可以運用二分搜索算法技巧?
首先,你要從題目中抽象出一個自變量x,一個關于x的函數f(x),以及一個目標值target。
同時,x, f(x), target還要滿足以下條件:
1、f(x)必須是在x上的單調函數(單調增單調減都可以)。
2、題目是讓你計算滿足約束條件f(x) == target時的x的值。
上述規則聽起來有點抽象,來舉個具體的例子:
給你一個升序排列的有序數組nums以及一個目標元素target,請你計算target在數組中的索引位置,如果有多個目標元素,返回最小的索引。
這就是「搜索左側邊界」這個基本題型,解法代碼之前都寫了,但這里面x, f(x), target分別是什么呢?
我們可以把數組中元素的索引認為是自變量x,函數關系f(x)就可以這樣設定:
// 函數 f(x) 是關于自變量 x 的單調遞增函數// 入參 nums 是不會改變的,所以可以忽略,不算自變量int f(int x, int[] nums) {
return nums[x];
}
其實這個函數f就是在訪問數組nums,因為題目給我們的數組nums是升序排列的,所以函數f(x)就是在x上單調遞增的函數。
最后,題目讓我們求什么來著?是不是讓我們計算元素target的最左側索引?
是不是就相當于在問我們「滿足f(x) == target的x的最小值是多少」?
如果遇到一個算法問題,能夠把它抽象成這幅圖,就可以對它運用二分搜索算法。
算法代碼如下:
// 函數 f 是關于自變量 x 的單調遞增函數int f(int x, int[] nums) {
return nums[x];
}
int left_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0, right = nums.length;
while (left 《 right) {
int mid = left + (right - left) / 2;
if (f(mid, nums) == target) {
// 當找到 target 時,收縮右側邊界
right = mid;
} else if (f(mid, nums) 《 target) {
left = mid + 1;
} else if (f(mid, nums) 》 target) {
right = mid;
}
}
return left;
}
這段代碼把之前的代碼微調了一下,把直接訪問nums[mid]套了一層函數f,其實就是多此一舉,但是,這樣能抽象出二分搜索思想在具體算法問題中的框架。
運用二分搜索的套路框架
想要運用二分搜索解決具體的算法問題,可以從以下代碼框架著手思考:
// 函數 f 是關于自變量 x 的單調函數int f(int x) {
// ...
}
// 主函數,在 f(x) == target 的約束下求 x 的最值int solution(int[] nums, int target) {
if (nums.length == 0) return -1;
// 問自己:自變量 x 的最小值是多少?
int left = ...;
// 問自己:自變量 x 的最大值是多少?
int right = ... + 1;
while (left 《 right) {
int mid = left + (right - left) / 2;
if (f(mid) == target) {
// 問自己:題目是求左邊界還是右邊界?
// ...
} else if (f(mid) 《 target) {
// 問自己:怎么讓 f(x) 大一點?
// ...
} else if (f(mid) 》 target) {
// 問自己:怎么讓 f(x) 小一點?
// ...
}
}
return left;
}
具體來說,想要用二分搜索算法解決問題,分為以下幾步:
1、確定x, f(x), target分別是什么,并寫出函數f的代碼。
2、找到x的取值范圍作為二分搜索的搜索區間,初始化left和right變量。
3、根據題目的要求,確定應該使用搜索左側還是搜索右側的二分搜索算法,寫出解法代碼。
下面用幾道例題來講解這個流程。
例題一、珂珂吃香蕉
珂珂每小時最多只能吃一堆香蕉,如果吃不完的話留到下一小時再吃;如果吃完了這一堆還有胃口,也只會等到下一小時才會吃下一堆。
他想在警衛回來之前吃完所有香蕉,讓我們確定吃香蕉的最小速度K。函數簽名如下:
int minEatingSpeed(int[] piles, int H);
那么,對于這道題,如何運用剛才總結的套路,寫出二分搜索解法代碼?
按步驟思考即可:
1、確定x, f(x), target分別是什么,并寫出函數f的代碼。
自變量x是什么呢?回憶之前的函數圖像,二分搜索的本質就是在搜索自變量。
所以,題目讓求什么,就把什么設為自變量,珂珂吃香蕉的速度就是自變量x。
那么,在x上單調的函數關系f(x)是什么?
顯然,吃香蕉的速度越快,吃完所有香蕉堆所需的時間就越少,速度和時間就是一個單調函數關系。
所以,f(x)函數就可以這樣定義:
若吃香蕉的速度為x根/小時,則需要f(x)小時吃完所有香蕉。
代碼實現如下:
// 定義:速度為 x 時,需要 f(x) 小時吃完所有香蕉// f(x) 隨著 x 的增加單調遞減int f(int[] piles, int x) {
int hours = 0;
for (int i = 0; i 《 piles.length; i++) {
hours += piles[i] / x;
if (piles[i] % x 》 0) {
hours++;
}
}
return hours;
}
target就很明顯了,吃香蕉的時間限制H自然就是target,是對f(x)返回值的最大約束。
2、找到x的取值范圍作為二分搜索的搜索區間,初始化left和right變量。
珂珂吃香蕉的速度最小是多少?多大是多少?
顯然,最小速度應該是 1,最大速度是piles數組中元素的最大值,因為每小時最多吃一堆香蕉,胃口再大也白搭嘛。
這里可以有兩種選擇,要么你用一個 for 循環去遍歷piles數組,計算最大值,要么你看題目給的約束,piles中的元素取值范圍是多少,然后給right初始化一個取值范圍之外的值。
我選擇第二種,題目說了1 《= piles[i] 《= 10^9,那么我就可以確定二分搜索的區間邊界:
public int minEatingSpeed(int[] piles, int H) {
int left = 1;
// 注意,right 是開區間,所以再加一
int right = 1000000000 + 1;
// ...
}
3、根據題目的要求,確定應該使用搜索左側還是搜索右側的二分搜索算法,寫出解法代碼。
現在我們確定了自變量x是吃香蕉的速度,f(x)是單調遞減的函數,target就是吃香蕉的時間限制H,題目要我們計算最小速度,也就是x要盡可能小:
這就是搜索左側邊界的二分搜索嘛,不過注意f(x)是單調遞減的,不要閉眼睛套框架,需要結合上圖進行思考,寫出代碼:
public int minEatingSpeed(int[] piles, int H) {
int left = 1;
int right = 1000000000 + 1;
while (left 《 right) {
int mid = left + (right - left) / 2;
if (f(piles, mid) == H) {
// 搜索左側邊界,則需要收縮右側邊界
right = mid;
} else if (f(piles, mid) 《 H) {
// 需要讓 f(x) 的返回值大一些
right = mid;
} else if (f(piles, mid) 》 H) {
// 需要讓 f(x) 的返回值小一些
left = mid + 1;
}
}
return left;
}
PS:關于mid是否需要 + 1 的問題,前文 二分搜索算法詳解 進行了詳細分析,這里不展開了。
至此,這道題就解決了,現在可以把多余的 if 分支合并一下,最終代碼如下:
public int minEatingSpeed(int[] piles, int H) {
int left = 1;
int right = 1000000000 + 1;
while (left 《 right) {
int mid = left + (right - left) / 2;
if (f(piles, mid) 《= H) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
// f(x) 隨著 x 的增加單調遞減int f(int[] piles, int x) {
// 見上文
}
PS:我們代碼框架中多余的 if 分支主要是幫助理解的,寫出正確解法后建議合并多余的分支,可以提高算法運行的效率。
例題二、運送貨物
再看看力扣第 1011 題「在 D 天內送達包裹的能力」:
要在D天內按順序運輸完所有貨物,貨物不可分割,如何確定運輸的最小載重呢?
函數簽名如下:
int shipWithinDays(int[] weights, int days);
和上一道題一樣的,我們按照流程來就行:
1、確定x, f(x), target分別是什么,并寫出函數f的代碼。
題目問什么,什么就是自變量,也就是說船的運載能力就是自變量x。
運輸天數和運載能力成反比,所以可以讓f(x)計算x的運載能力下需要的運輸天數,那么f(x)是單調遞減的。
函數f(x)的實現如下:
// 定義:當運載能力為 x 時,需要 f(x) 天運完所有貨物// f(x) 隨著 x 的增加單調遞減int f(int[] weights, int x) {
int days = 0;
for (int i = 0; i 《 weights.length; ) {
// 盡可能多裝貨物
int cap = x;
while (i 《 weights.length) {
if (cap 《 weights[i]) break;
else cap -= weights[i];
i++;
}
days++;
}
return days;
}
對于這道題,target顯然就是運輸天數D,我們要在f(x) == D的約束下,算出船的最小載重。
2、找到x的取值范圍作為二分搜索的搜索區間,初始化left和right變量。
船的最小載重是多少?最大載重是多少?
顯然,船的最小載重應該是weights數組中元素的最大值,因為每次至少得裝一件貨物走,不能說裝不下嘛。
最大載重顯然就是weights數組所有元素之和,也就是一次把所有貨物都裝走。
這樣就確定了搜索區間[left, right):
public int shipWithinDays(int[] weights, int days) {
int left = 0;
// 注意,right 是開區間,所以額外加一
int right = 1;
for (int w : weights) {
left = Math.max(left, w);
right += w;
}
// ...
}
3、需要根據題目的要求,確定應該使用搜索左側還是搜索右側的二分搜索算法,寫出解法代碼。
現在我們確定了自變量x是船的載重能力,f(x)是單調遞減的函數,target就是運輸總天數限制D,題目要我們計算船的最小載重,也就是x要盡可能小:
這就是搜索左側邊界的二分搜索嘛,結合上圖就可寫出二分搜索代碼:
public int shipWithinDays(int[] weights, int days) {
int left = 0;
// 注意,right 是開區間,所以額外加一
int right = 1;
for (int w : weights) {
left = Math.max(left, w);
right += w;
}
while (left 《 right) {
int mid = left + (right - left) / 2;
if (f(weights, mid) == days) {
// 搜索左側邊界,則需要收縮右側邊界
right = mid;
} else if (f(weights, mid) 《 days) {
// 需要讓 f(x) 的返回值大一些
right = mid;
} else if (f(weights, mid) 》 days) {
// 需要讓 f(x) 的返回值小一些
left = mid + 1;
}
}
return left;
}
到這里,這道題的解法也寫出來了,我們合并一下多余的 if 分支,提高代碼運行速度,最終代碼如下:
public int shipWithinDays(int[] weights, int days) {
int left = 0;
int right = 1;
for (int w : weights) {
left = Math.max(left, w);
right += w;
}
while (left 《 right) {
int mid = left + (right - left) / 2;
if (f(weights, mid) 《= days) {
right = mid;
} else {
left = mid + 1;
}
}
return left;
}
int f(int[] weights, int x) {
// 見上文
}
本文就到這里,總結來說,如果發現題目中存在單調關系,就可以嘗試使用二分搜索的思路來解決。搞清楚單調性和二分搜索的種類,通過分析和畫圖,就能夠寫出最終的代碼。
責任編輯:haq
-
框架
+關注
關注
0文章
399瀏覽量
17437 -
搜索
+關注
關注
0文章
69瀏覽量
16651 -
代碼
+關注
關注
30文章
4753瀏覽量
68369
原文標題:我寫了一個套路,助你隨心所欲運用二分搜索
文章出處:【微信號:TheAlgorithm,微信公眾號:算法與數據結構】歡迎添加關注!文章轉載請注明出處。
發布評論請先 登錄
相關推薦
評論