有了这套模板,女朋友再也不用担心我刷不动 LeetCode 了
作者 | 李威
來源 | https://www.liwei.party/
整理?| 五分鐘學(xué)算法
全文包含 12000+ 字、30 張高清圖片,預(yù)計閱讀時間為 40 分鐘,強(qiáng)烈建議先收藏再仔細(xì)閱讀。
正文
下面的動畫以 「力扣」第 704 題:二分查找 為例,展示了使用這個模板編寫二分查找法的一般流程。
binary-search-template-new.gif以下“演示文稿”展示了本文所要講解的主要內(nèi)容,您可以只看這部分的內(nèi)容,如果您還想看得更仔細(xì)一點(diǎn),可以查看“演示文稿”之后的原文。
《十分好用的二分查找法模板》演示文稿
binary-search-template-1.pngbinary-search-template-2.png
binary-search-template-3.png
binary-search-template-4.png
binary-search-template-5.png
binary-search-template-6.png
binary-search-template-7.png
binary-search-template-8.png
binary-search-template-9.png
binary-search-template-10.png
binary-search-template-11.png
binary-search-template-12.png
binary-search-template-13.png
(上面的“演示文稿”是對以下文字的概括。)
1、導(dǎo)讀
本文介紹了我這半年以來,在刷題過程中使用“二分查找法”刷題的一個模板,包括這個模板的優(yōu)點(diǎn)、使用技巧、注意事項、調(diào)試方法等。
雖說是模板,但我不打算一開始就貼出代碼,因為這個模板根本沒有必要記憶,只要你能夠理解文中敘述的知識點(diǎn)和注意事項,并加以應(yīng)用(刷題),相信你會和我一樣喜歡這個模板,并且認(rèn)為使用它是自然而然的事情。
這個模板應(yīng)該能夠幫助你解決 LeetCode 帶“二分查找”標(biāo)簽的常見問題(簡單、中等難度)。
只要你能夠理解文中敘述的知識點(diǎn)和注意事項,并加以應(yīng)用(其實(shí)就是多刷題),相信你會和我一樣喜歡這個模板,并且認(rèn)為使用它是自然而然的事情。
2、歷史上有關(guān)“二分查找法”的故事
二分查找法雖然簡單,但寫好它并沒有那么容易。我們可以看看一些名人關(guān)于二分查找法的論述。
算法和程序設(shè)計技術(shù)的先驅(qū) Donald Ervin Knuth(中文名:高德納):
Although the basic idea of binary search is comparatively straightforward, the details can be surprisingly tricky …
譯:“雖然二分查找的基本思想相對簡單,但細(xì)節(jié)可能會非常棘手”。來自維基百科 Binary_search_algorithm,請原諒本人可能非常不優(yōu)雅的中文翻譯。
同樣是高德納先生,在其著作《計算機(jī)程序設(shè)計的藝術(shù) 第 3 卷:排序和查找》中指出:
二分查找法的思想在 1946 年就被提出來了。但是第 1 個沒有 Bug 的二分查找法在 1962 年才出現(xiàn)。
(因時間和個人能力的關(guān)系,我沒有辦法提供英文原文,如果能找到英文原文的朋友歡迎提供一下出處,在此先謝過。)
據(jù)說這個 Bug 在 Java 的 JDK 中都隱藏了將近 10 年以后,才被人們發(fā)現(xiàn)并修復(fù)。
《編程珠璣》的作者 Jon Bentley:
When Jon Bentley assigned binary search as a problem in a course for professional programmers, he found that ninety percent failed to provide a correct solution after several hours of working on it, mainly because the incorrect implementations failed to run or returned a wrong answer in rare edge cases.
譯:當(dāng) JonBentley 把二分查找作為專業(yè)程序員課程中的一個問題時,他發(fā)現(xiàn)百分之九十的人在花了幾個小時的時間研究之后,沒有提供正確的解決方案,主要是因為錯誤的實(shí)現(xiàn)無法正確運(yùn)行(筆者注:可能返回錯誤的結(jié)果,或者出現(xiàn)死循環(huán)),或者是不能很好地判斷邊界條件。
3、“傳統(tǒng)的”二分查找法模板的問題
(1)取中位數(shù)索引的代碼有問題
int?mid?=?(left?+?right)?/?2?這行代碼是有問題的,在 left 和 right 都比較大的時候,left + right 很有可能超過 int 類型能表示的最大值,即整型溢出,為了避免這個問題,應(yīng)該寫成:
int?mid?=?left?+?(right?-?left)?/?2?;事實(shí)上,int mid = left + (right - left) / 2 ?在 right 很大、 left 是負(fù)數(shù)且很小的時候, right - left 也有可能超過 int 類型能表示的最大值,只不過一般情況下 left 和 right 表示的是數(shù)組索引值,left 是非負(fù)數(shù),因此 ?right - left ?溢出的可能性很小。
更好的寫法是:
int?mid?=?(left?+?right)?>>>?1?;原因在后文介紹,請讀者留意:
使用“左邊界索引 + 右邊界索引”,然后“無符號右移 1 位”是推薦的寫法。
(2)循環(huán)可以進(jìn)行的條件寫成 while (left <= right) 時,在退出循環(huán)的時候,需要考慮返回 left 還是 right,稍不注意,就容易出錯
以本題(LeetCode 第 35 題:搜索插入位置)為例。
分析:根據(jù)題意并結(jié)合題目給出的 4 個示例,不難分析出這個問題的等價表述如下:
1、如果目標(biāo)值(嚴(yán)格)大于排序數(shù)組的最后一個數(shù),返回這個排序數(shù)組的長度,否則進(jìn)入第 2 點(diǎn)。
2、返回排序數(shù)組從左到右,大于或者等于目標(biāo)值的第 1 個數(shù)的索引。
事實(shí)上,當(dāng)給出數(shù)組中有很多數(shù)和目標(biāo)值相等的時候,我們返回任意一個與之相等的數(shù)的索引值都可以,不過為了簡單起見,也為了方便后面的說明,我們返回第 1 個符合題意的數(shù)的索引。
題目告訴你“排序數(shù)組”,其實(shí)就是在瘋狂暗示你用二分查找法。二分查找法的思想并不難,但寫好一個二分法并不簡單,下面就借著這道題為大家做一個總結(jié)。
剛接觸二分查找法的時候,我們可能會像下面這樣寫代碼,我把這種寫法容易出錯的地方寫在了注釋里:
參考代碼:針對本題(LeetCode 第 35 題)
public?class?Solution3?{public?int?searchInsert(int[]?nums,?int?target)?{int?len?=?nums.length;if?(nums[len?-?1]?<?target)?{return?len;}int?left?=?0;int?right?=?len?-?1;while?(left?<=?right)?{int?mid?=?(left?+?right)?/?2;//?等于的情況最簡單,我們應(yīng)該放在第?1?個分支進(jìn)行判斷if?(nums[mid]?==?target)?{return?mid;}?else?if?(nums[mid]?<?target)?{//?題目要我們返回大于或者等于目標(biāo)值的第?1?個數(shù)的索引//?此時?mid?一定不是所求的左邊界,//?此時左邊界更新為?mid?+?1left?=?mid?+?1;}?else?{//?既然不會等于,此時?nums[mid]?>?target//?mid?也一定不是所求的右邊界//?此時右邊界更新為?mid?-?1right?=?mid?-?1;}}//?注意:一定得返回左邊界 left,//?如果返回右邊界?right?提交代碼不會通過//?【注意】下面我嘗試說明一下理由,如果你不太理解下面我說的,那是我表達(dá)的問題//?但我建議你不要糾結(jié)這個問題,因為我將要介紹的二分查找法模板,可以避免對返回?left?和?right?的討論//?理由是對于?[1,3,5,6],target?=?2,返回大于等于?target?的第?1?個數(shù)的索引,此時應(yīng)該返回?1//?在上面的?while?(left?<=?right)?退出循環(huán)以后,right?<?left,right?=?0?,left?=?1//?根據(jù)題意應(yīng)該返回?left,//?如果題目要求你返回小于等于?target?的所有數(shù)里最大的那個索引值,應(yīng)該返回?rightreturn?left;} }說明:
1、當(dāng)把二分查找法的循環(huán)可以進(jìn)行的條件寫成 while (left <= right) 時,在寫最后一句 return 的時候,如果不假思索,把左邊界 left 返回回去,雖然寫對了,但可以思考一下為什么不返回右邊界 right 呢?
2、但是事實(shí)上,返回 left 是有一定道理的,如果題目換一種問法,你可能就要返回右邊界 right,這句話不太理解沒有關(guān)系,我也不打算講得很清楚(在上面代碼的注釋中我已經(jīng)解釋了原因),因為實(shí)在太繞了,這不是我要說的重點(diǎn)。
由此,我認(rèn)為“傳統(tǒng)二分查找法模板”使用的痛點(diǎn)在于:
傳統(tǒng)二分查找法模板,當(dāng)退出 while 循環(huán)的時候,在返回左邊界還是右邊界這個問題上,比較容易出錯。
那么,是不是可以回避這個問題呢?答案是肯定的,答案就在下面我要介紹的“神奇的”二分查找法模板里。
4、“神奇的”二分查找法模板的基本思想
(1)首先把循環(huán)可以進(jìn)行的條件寫成 while(left < right),在退出循環(huán)的時候,一定有 left == right 成立,此時返回 left 或者 right 都可以
或許你會問:退出循環(huán)的時候還有一個數(shù)沒有看啊(退出循環(huán)之前索引 left 或 索引 right 上的值)?
(什么時候需要看最后剩下的那個數(shù),什么時候不需要,會在第 5 點(diǎn)介紹。)
更深層次的思想是“夾逼法”或者稱為“排除法”。
(2)“神奇的”二分查找法模板的基本思想(特別重要)
“排除法”即:在每一輪循環(huán)中排除一半以上的元素,于是在對數(shù)級別的時間復(fù)雜度內(nèi),就可以把區(qū)間“夾逼” 只剩下 1 個數(shù),而這個數(shù)是不是我們要找的數(shù),單獨(dú)做一次判斷就可以了。
“夾逼法”或者“排除法”是二分查找算法的基本思想,“二分”是手段,在目標(biāo)元素不確定的情況下,“二分” 也是“最大熵原理”告訴我們的選擇。
還是 LeetCode 第 35 題,下面給出使用 while (left < right) 模板寫法的 2 段參考代碼,以下代碼的細(xì)節(jié)部分在后文中會講到,因此一些地方不太明白沒有關(guān)系,暫時跳過即可。
參考代碼 1:重點(diǎn)理解為什么候選區(qū)間的索引范圍是 [0, size]。
public?class?Solution?{public?int?searchInsert(int[]?nums,?int?target)?{#?返回大于等于?target?的索引,有可能是最后一個int?len?=?nums.length;if?(len?==?0)?{return?0;}int?left?=?0;#?如果?target?比?nums里所有的數(shù)都大,則最后一個數(shù)的索引?+?1?就是候選值,因此,右邊界應(yīng)該是數(shù)組的長度int?right?=?len;#?二分的邏輯一定要寫對,否則會出現(xiàn)死循環(huán)或者數(shù)組下標(biāo)越界while?(left?<?right)?{int?mid?=?left?+?(right?-?left)?/?2;if?(nums[mid]?<?target)?{left?=?mid?+?1;}?else?{right?=?mid;}}return?left;} }參考代碼 2:對于是否接在原有序數(shù)組后面單獨(dú)判斷,不滿足的時候,再在候選區(qū)間的索引范圍 [0, size - 1] 內(nèi)使用二分查找法進(jìn)行搜索。
public?class?Solution?{//?只會把比自己大的覆蓋成小的//?二分法//?如果有一連串?dāng)?shù)跟?target?相同,則返回索引最靠前的//?特例:3 5 5 5 5 5 5 5 5 5//?特例:3 6 7 8// System.out.println("嘗試過的值:"?+ mid);//?1?2?3?5?5?5?5?5?5?6?,target?=?5//?1?2?3?3?5?5?5?6?target?=?4public?int?searchInsert(int[]?nums,?int?target)?{int?len?=?nums.length;if?(len?==?0)?{return?-1;}if?(nums[len?-?1]?<?target)?{return?len;}int?left?=?0;int?right?=?len?-?1;while?(left?<?right)?{int?mid?=?left?+?(right?-?left)?/?2;if?(nums[mid]?<?target)?{//?nums[mid]?的值可以舍棄left?=?mid?+?1;}?else?{//?nums[mid]?不能舍棄right?=?mid;}}return?right;}public?static?void?main(String[]?args)?{int[]?nums?=?{1,?2,?3,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?5,?6};int?target?=?4;Solution2?solution2?=?new?Solution2();int?searchInsert?=?solution2.searchInsert(nums,?target);System.out.println(searchInsert);} }5、細(xì)節(jié)、注意事項、調(diào)試方法
(1)前提:思考左、右邊界,如果左、右邊界不包括目標(biāo)數(shù)值,會導(dǎo)致錯誤結(jié)果
例:LeetCode 第 69 題:x 的平方根
實(shí)現(xiàn) int sqrt(int x) 函數(shù)。
計算并返回 x 的平方根,其中 x 是非負(fù)整數(shù)。
由于返回類型是整數(shù),結(jié)果只保留整數(shù)的部分,小數(shù)部分將被舍去。
分析:一個非負(fù)整數(shù)的平方根最小可能是 0 ,最大可能是它自己。
例:LeetCode 第 287 題:尋找重復(fù)數(shù)
給定一個包含 n + 1 個整數(shù)的數(shù)組 nums,其數(shù)字都在 1 到 n 之間(包括 1 和 n),可知至少存在一個重復(fù)的整數(shù)。假設(shè)只有一個重復(fù)的整數(shù),找出這個重復(fù)的數(shù)。
分析:題目告訴我們“其數(shù)字都在 1 到 n 之間(包括 1 和 n)”。因此左邊界可以取 1 ,右邊界可以取 n。
要注意 2 點(diǎn):
如果 left 和 right 表示的是數(shù)組的索引,就要考慮“索引是否有效” ,即“索引是否越界” 是重要的定界依據(jù);
左右邊界一定要包括目標(biāo)元素,例如 LeetCode 第 35 題:“搜索插入位置” ,當(dāng) target 比數(shù)組中的最后一個數(shù)字還要大(不能等于)的時候,插入元素的位置就是數(shù)組的最后一個位置 + 1,即 (len - 1 + 1 =) len,如果忽略掉這一點(diǎn),把右邊界定為 len - 1 ,代碼就不能通過在線測評。
(2)中位數(shù)先寫 `int mid = (left + right) >>> 1 ;` 根據(jù)循環(huán)里分支的編寫情況,再做調(diào)整
理解這一點(diǎn),首先要知道:當(dāng)數(shù)組的元素個數(shù)是偶數(shù)的時候,中位數(shù)有左中位數(shù)和右中位數(shù)之分。
當(dāng)數(shù)組的元素個數(shù)是偶數(shù)的時候:
使用 int mid = left + (right - left) / 2 ; ?得到左中位數(shù)的索引;
使用 int mid = left + (right - left + 1) / 2 ; ?得到右中位數(shù)的索引。
當(dāng)數(shù)組的元素個數(shù)是奇數(shù)的時候,以上二者都能選到最中間的那個中位數(shù)。
其次,
int?mid?=?left?+?(right?-?left)?/?2?; int?mid?=?(left?+?right)?>>>?1;而
int?mid?=?left?+?(right?-?left?+?1)?/?2?; int?mid?=?(left?+?right?+?1)?>>>?1?我們使用一個具體的例子來驗證:當(dāng)左邊界索引 left = 3,右邊界索引 right = 4 的時候,
mid1?=?left?+?(right?-?left)?//?2?=?3?+?(4?-?3)?//?2?=?3?+?0?=?3 mid2?=?left?+?(right?-?left?+?1)?//?2?=?3?+?(4?-?3?+?1)?//?2?=?3?+?1?=?4左中位數(shù) mid1 是索引 left,右中位數(shù) mid2 是索引 right。
記憶方法:
(right - left) 不加 1 選左中位數(shù),加 1 選右中位數(shù)。
那么,什么時候使用左中位數(shù),什么時候使用右中位數(shù)呢?選中位數(shù)的依據(jù)是為了避免死循環(huán),得根據(jù)分支的邏輯來選擇中位數(shù),而分支邏輯的編寫也有技巧,下面具體說。
(3)先寫邏輯上容易想到的分支邏輯,這個分支邏輯通常是排除中位數(shù)的邏輯;
在邏輯上,“可能是也有可能不是”讓我們感到猶豫不定,但**“一定不是”是我們非常堅決的,通常考慮的因素特別單一,因此“好想” **。在生活中,我們經(jīng)常聽到這樣的話:找對象時,“有車、有房,可以考慮,但沒有一定不要”;找工作時,“事兒少、離家近可以考慮,但是錢少一定不去”,就是這種思想的體現(xiàn)。
例:LeetCode 第 69 題:x 的平方根
實(shí)現(xiàn) int sqrt(int x) 函數(shù)。
計算并返回 x 的平方根,其中 x 是非負(fù)整數(shù)。
由于返回類型是整數(shù),結(jié)果只保留整數(shù)的部分,小數(shù)部分將被舍去。
分析:因為題目中說“返回類型是整數(shù),結(jié)果只保留整數(shù)的部分,小數(shù)部分將被舍去”。例如 5 的平方根約等于 2.236,在這道題應(yīng)該返回 2。因此如果一個數(shù)的平方小于或者等于 x,那么這個數(shù)有可能是也有可能不是 x 的平方根,但是能很肯定的是,如果一個數(shù)的平方大于 x ,這個數(shù)肯定不是 x 的平方根。
注意:先寫“好想”的分支,排除了中位數(shù)之后,通常另一個分支就不排除中位數(shù),而不必具體考慮另一個分支的邏輯的具體意義,且代碼幾乎是固定的。
(4)循環(huán)內(nèi)只寫兩個分支,一個分支排除中位數(shù),另一個分支不排除中位數(shù),循環(huán)中不單獨(dú)對中位數(shù)作判斷
既然是“夾逼”法,沒有必要在每一輪循環(huán)開始前單獨(dú)判斷當(dāng)前中位數(shù)是否是目標(biāo)元素,因此分支數(shù)少了一支,代碼執(zhí)行效率更高。
以下是“排除中位數(shù)的邏輯”思考清楚以后,可能出現(xiàn)的兩個模板代碼。
二分查找法模板可以排除“中位數(shù)”的邏輯,通常比較好想,但并不絕對,這一點(diǎn)視情況而定。
分支條數(shù)變成 2 條,比原來 3 個分支要考慮的情況少,好處是:
不用在每次循環(huán)開始單獨(dú)考慮中位數(shù)是否是目標(biāo)元素,節(jié)約了時間,我們只要在退出循環(huán)的時候,即左右區(qū)間壓縮成一個數(shù)(索引)的時候,去判斷這個索引表示的數(shù)是否是目標(biāo)元素,而不必在二分的邏輯中單獨(dú)做判斷。
這一點(diǎn)很重要,希望讀者結(jié)合具體練習(xí)仔細(xì)體會,每次循環(huán)開始的時候都單獨(dú)做一次判斷,在統(tǒng)計意義上看,二分時候的中位數(shù)恰好是目標(biāo)元素的概率并不高,并且即使要這么做,也不是普適性的,不能解決絕大部分的問題。
還以 LeetCode 第 35 題為例,通過之前的分析,我們需要找到“大于或者等于目標(biāo)值的第 1 個數(shù)的索引”。對于這道題而言:
(1)如果中位數(shù)小于目標(biāo)值,它就應(yīng)該被排除,左邊界 left 就至少是 mid + 1;
(2)如果中位數(shù)大于等于目標(biāo)值,還不能夠肯定它就是我們要找的數(shù),因為要找的是等于目標(biāo)值的第 1 個數(shù)的索引,中位數(shù)以及中位數(shù)的左邊都有可能是符合題意的數(shù),因此右邊界就不能把 mid 排除,因此右邊界 right 至多是 mid,此時右邊界不向左邊收縮。
下一點(diǎn)就更關(guān)鍵了。
(5)根據(jù)分支邏輯選擇中位數(shù)的類型,可能是左中位數(shù),也可能是右位數(shù),選擇的標(biāo)準(zhǔn)是避免死循環(huán)
造成死循環(huán)的代碼死循環(huán)容易發(fā)生在區(qū)間只有 2 個元素時候,此時中位數(shù)的選擇尤為關(guān)鍵。選擇中位數(shù)的依據(jù)是:避免出現(xiàn)死循環(huán)。我們需要確保:
(下面的這兩條規(guī)則說起來很繞,可以暫時跳過)。
1、如果分支的邏輯,在選擇左邊界的時候,不能排除中位數(shù),那么中位數(shù)就選“右中位數(shù)”,只有這樣區(qū)間才會收縮,否則進(jìn)入死循環(huán);
2、同理,如果分支的邏輯,在選擇右邊界的時候,不能排除中位數(shù),那么中位數(shù)就選“左中位數(shù)”,只有這樣區(qū)間才會收縮,否則進(jìn)入死循環(huán)。
理解上面的這個規(guī)則可以通過具體的例子。針對以上規(guī)則的第 1 點(diǎn):如果分支的邏輯,在選擇左邊界的時候不能排除中位數(shù),例如:
偽代碼:
while?left?<?right:#?不妨先寫左中位數(shù),看看你的分支會不會讓你代碼出現(xiàn)死循環(huán),從而調(diào)整mid?=?left?+?(right?-?left)?//?2#?業(yè)務(wù)邏輯代碼if?(check(mid)):#?選擇右邊界的時候,可以排除中位數(shù)right?=?mid?-?1else:#?選擇左邊界的時候,不能排除中位數(shù)left?=?mid在區(qū)間中的元素只剩下 $2$ 個時候,例如:left = 3,right = 4。此時左中位數(shù)就是左邊界,如果你的邏輯執(zhí)行到 left = mid 這個分支,且你選擇的中位數(shù)是左中位數(shù),此時左邊界就不會得到更新,區(qū)間就不會再收縮(理解這句話是關(guān)鍵),從而進(jìn)入死循環(huán);
為了避免出現(xiàn)死循環(huán),你需要選擇中位數(shù)是右中位數(shù),當(dāng)邏輯執(zhí)行到 left = mid 這個分支的時候,因為你選擇了右中位數(shù),讓邏輯可以轉(zhuǎn)而執(zhí)行到 right = mid - 1 讓區(qū)間收縮,最終成為 1 個數(shù),退出 while 循環(huán)。
上面這段話不理解沒有關(guān)系,因為我還沒有舉例子,你有個印象就好,類似地,理解選擇中位數(shù)的依據(jù)的第 2 點(diǎn)。
(6)退出循環(huán)的時候,可能需要對“夾逼”剩下的那個數(shù)單獨(dú)做一次判斷,這一步稱之為“后處理”。
二分查找法之所以高效,是因為它利用了數(shù)組有序的特點(diǎn),在每一次的搜索過程中,都可以排除將近一半的數(shù),使得搜索區(qū)間越來越小,直到區(qū)間成為一個數(shù)。回到這一節(jié)最開始的疑問:“區(qū)間左右邊界相等(即收縮成 1 個數(shù))時,這個數(shù)是否會漏掉”,解釋如下:
1、如果你的業(yè)務(wù)邏輯保證了你要找的數(shù)一定在左邊界和右邊界所表示的區(qū)間里出現(xiàn),那么可以放心地返回 left 或者 right,無需再做判斷;
2、如果你的業(yè)務(wù)邏輯不能保證你要找的數(shù)一定在左邊界和右邊界所表示的區(qū)間里出現(xiàn),那么只要在退出循環(huán)以后,再針對 nums[left] 或者 nums[right] (此時 nums[left] == nums[right])單獨(dú)作一次判斷,看它是不是你要找的數(shù)即可,這一步操作常常叫做“后處理”。
如果你能確定候選區(qū)間里目標(biāo)元素一定存在,則不必做“后處理”。
例:LeetCode 第 69 題:x 的平方根
實(shí)現(xiàn) int sqrt(int x) 函數(shù)。
計算并返回 x 的平方根,其中 x 是非負(fù)整數(shù)。
由于返回類型是整數(shù),結(jié)果只保留整數(shù)的部分,小數(shù)部分將被舍去。
分析:非負(fù)實(shí)數(shù) x 的平方根在 [0, x] 內(nèi)一定存在,故退出 while (left < right) 循環(huán)以后,不必單獨(dú)判斷 left 或者 right 是否符合題意。
如果你不能確定候選區(qū)間里目標(biāo)元素一定存在,需要單獨(dú)做一次判斷。
例:LeetCode 第 704 題:二分查找
給定一個 n 個元素有序的(升序)整型數(shù)組 nums 和一個目標(biāo)值 target ?,寫一個函數(shù)搜索 nums 中的 target,如果目標(biāo)值存在返回下標(biāo),否則返回 -1。
分析:因為目標(biāo)數(shù)有可能不在數(shù)組中,當(dāng)候選區(qū)間夾逼成一個數(shù)的時候,要單獨(dú)判斷一下這個數(shù)是不是目標(biāo)數(shù),如果不是,返回 -1。
(7)取中位數(shù)的時候,要避免在計算上出現(xiàn)整型溢出;
int mid = (left + right) / 2; 的問題:在 left 和 right 很大的時候,left + right 會發(fā)生整型溢出,變成負(fù)數(shù),這是一個 bug ,得改!
int mid = left + (right - left) / 2; 在 right 很大、 left 是負(fù)數(shù)且很小的時候, right - left 也有可能超過 int 類型能表示的最大值,只不過一般情況下 left 和 right 表示的是數(shù)組索引值,left 是非負(fù)數(shù),因此 right - left 溢出的可能性很小。因此,它是正確的寫法。下面介紹推薦的寫法。
int mid = (left + right) >>> 1; 如果這樣寫, left + right 在發(fā)生整型溢出以后,會變成負(fù)數(shù),此時如果除以 2 ,mid 是一個負(fù)數(shù),但是經(jīng)過無符號右移,可以得到在不溢出的情況下正確的結(jié)果。
解釋“無符號右移”:在 Java 中,無符號右移運(yùn)算符 >>> 和右移運(yùn)算符 >> 的區(qū)別如下:
右移運(yùn)算符 >> 在右移時,丟棄右邊指定位數(shù),左邊補(bǔ)上符號位;
無符號右移運(yùn)算符 >>> 在右移時,丟棄右邊指定位數(shù),左邊補(bǔ)上 0,也就是說,對于正數(shù)來說,二者一樣,而負(fù)數(shù)通過 >>> 后能變成正數(shù)。
下面解釋上面的模板中,取中位數(shù)的時候使用先用“+”,然后“無符號右移”。
1、int mid = (left + right) / 2 與 int mid = left + (right - left) / 2 兩種寫法都有整型溢出的風(fēng)險,沒有哪一個是絕對安全的,注意:這里我們?nèi)∑骄涤玫氖浅?2,并且是整除:
int mid = (left + right) / 2 在 left 和 right 都很大的時候會溢出;
int mid = left + (right - left) / 2 在 right 很大,且 left 是負(fù)數(shù)且很小的時候會溢出;
2、寫算法題的話,一般是讓你在數(shù)組中做二分查找,因此 left 和 right 一般都表示數(shù)組的索引,因此 left 在絕大多數(shù)情況下不會是負(fù)數(shù)并且很小,因此使用 ?int mid = left + (right - left) // 2 ?相對 int mid = (left + right) // 2 更安全一些,并且也能向別人展示我們注意到了整型溢出這種情況,但事實(shí)上,還有更好的方式;
3、建議使用 int mid = (left + right) >>> 1 這種寫法,其實(shí)是大有含義的:
JDK8 中采用 int mid = (left + right) >>> 1 ,重點(diǎn)不在 + ,而在 >>> 。
我們看極端的情況,left 和 high 都是整型最大值的時候,注意,此時 32 位整型最大值它的二進(jìn)制表示的最高位是 0,它們相加以后,最高位是 1 ,變成負(fù)數(shù),但是再經(jīng)過無符號右移 >>>(重點(diǎn)是忽略了符號位,空位都以 0 補(bǔ)齊),就能保證使用 + 在整型溢出了以后結(jié)果還是正確的。
Java 中 Collections 和 Arrays 提供的 binarySearch 方法,我們點(diǎn)進(jìn)去看 left 和 right 都表示索引,使用無符號右移又不怕整型溢出,那就用 int mid = (left + right) >>> 1 ?好啦。位運(yùn)算本來就比使用除法快,這樣看來使用 + 和 <<< 真的是又快又好了。
我想這一點(diǎn)可能是 JDK8 的編寫者們更層次的考量。
看來以后寫算法題,就用 ?int mid = (left + right) >>> 1 吧,反正更多的時候 left 和 right 表示索引。
(8)編碼一旦出現(xiàn)死循環(huán),輸出必要的變量值、分支邏輯是調(diào)試的重要方法。
當(dāng)出現(xiàn)死循環(huán)的時候的調(diào)試方法:打印輸出左右邊界、中位數(shù)的值和目標(biāo)值、分支邏輯等必要的信息。
按照我的經(jīng)驗,一開始編碼的時候,稍不注意就很容易出現(xiàn)死循環(huán),不過沒有關(guān)系,你可以你的代碼中寫上一些輸出語句,就容易理解“在區(qū)間元素只有 2 個的時候容易出現(xiàn)死循環(huán)”。
6、總結(jié)
總結(jié)一下,我愛用這個模板的原因、技巧、優(yōu)點(diǎn)和注意事項:
(1)原因:
無腦地寫 while left < right: ,這樣你就不用判斷,在退出循環(huán)的時候你應(yīng)該返回 left 還是 right,因為返回 left 或者 right 都對;
(2)技巧:
先寫分支邏輯,并且先寫排除中位數(shù)的邏輯分支(因為更多時候排除中位數(shù)的邏輯容易想,但是前面我也提到過,這并不絕對),另一個分支的邏輯你就不用想了,寫出第 1 個分支的反面代碼即可(下面的說明中有介紹),再根據(jù)分支的情況選擇使用左中位數(shù)還是右中位數(shù);
說明:這里再多說一句。如果從代碼可讀性角度來說,只要是你認(rèn)為好想的邏輯分支,就把它寫在前面,并且加上你的注釋,這樣方便別人理解,而另一個分支,你就不必考慮它的邏輯了。有的時候另一個分支的邏輯并不太好想,容易把自己繞進(jìn)去。如果你練習(xí)做得多了,會形成條件反射。
我簡單總結(jié)了一下,左右分支的規(guī)律就如下兩點(diǎn):
如果第 1 個分支的邏輯是“左邊界排除中位數(shù)”(left = mid + 1),那么第 2 個分支的邏輯就一定是“右邊界不排除中位數(shù)”(right = mid),反過來也成立;
如果第 2 個分支的邏輯是“右邊界排除中位數(shù)”(right = mid - 1),那么第 2 個分支的邏輯就一定是“左邊界不排除中位數(shù)”(left = mid),反之也成立。
“反過來也成立”的意思是:如果在你的邏輯中,“邊界不能排除中位數(shù)”的邏輯好想,你就把它寫在第 1 個分支,另一個分支是它的反面,你可以不用管邏輯是什么,按照上面的規(guī)律直接給出代碼就可以了。能這么做的理論依據(jù)就是“排除法”。
(3)優(yōu)點(diǎn):
分支條數(shù)只有 2 條,代碼執(zhí)行效率更高,不用在每一輪循環(huán)中單獨(dú)判斷中位數(shù)是否符合題目要求,寫分支的邏輯的目的是盡量排除更多的候選元素,而判斷中位數(shù)是否符合題目要求我們放在最后進(jìn)行,這就是第 5 點(diǎn);
說明:每一輪循環(huán)開始都單獨(dú)判斷中位數(shù)是否符合要求,這個操作不是很有普適性,因為從統(tǒng)計意義上說,中位數(shù)直接就是你想找的數(shù)的概率并不大,有的時候還要看看左邊,還要看看右邊。不妨就把它放在最后來看,把候選區(qū)間“夾逼”到只剩 1 個元素的時候,視情況單獨(dú)再做判斷即可。
(4)注意事項 1:
左中位數(shù)還是右中位數(shù)選擇的標(biāo)準(zhǔn)根據(jù)分支的邏輯而來,標(biāo)準(zhǔn)是每一次循環(huán)都應(yīng)該讓區(qū)間收縮,當(dāng)候選區(qū)間只剩下 2 個元素的時候,為了避免死循環(huán)發(fā)生,選擇正確的中位數(shù)類型。如果你實(shí)在很暈,不防就使用有 2 個元素的測試用例,就能明白其中的原因,另外在代碼出現(xiàn)死循環(huán)的時候,建議你可以將左邊界、右邊界、你選擇的中位數(shù)的值,還有分支邏輯都打印輸出一下,出現(xiàn)死循環(huán)的原因就一目了然了;
(5)注意事項 2:
如果能確定要找的數(shù)就在候選區(qū)間里,那么退出循環(huán)的時候,區(qū)間最后收縮成為 1 個數(shù)后,直接把這個數(shù)返回即可;如果你要找的數(shù)有可能不在候選區(qū)間里,區(qū)間最后收縮成為 1 個數(shù)后,還要單獨(dú)判斷一下這個數(shù)是否符合題意。
最后給出兩個模板,大家看的時候看注釋,不必也無需記憶它們。
二分查找模板-1.png二分查找模板-2.png說明:我寫的時候,一般是先默認(rèn)將中位數(shù)寫成左中位數(shù),再根據(jù)分支的情況,看看是否有必要調(diào)整成右中位數(shù),即是不是要在 (right - left) 這個括號里面加 1 。
雖說是兩個模板,區(qū)別在于選中位數(shù),中位數(shù)根據(jù)分支邏輯來選,原則是區(qū)間要收縮,且不出現(xiàn)死循環(huán),退出循環(huán)的時候,視情況,有可能需要對最后剩下的數(shù)單獨(dú)做判斷。
我想我應(yīng)該是成功地把你繞暈了,如果您覺得啰嗦的地方,就當(dāng)我是“重要的事情說了三遍”吧,確實(shí)是重點(diǎn)的地方我才會重復(fù)說。
當(dāng)然,最好的理解這個模板的方法還是應(yīng)用它。
在此建議您不妨多做幾道使用“二分查找法”解決的問題,用一下我說的這個模板,在發(fā)現(xiàn)問題的過程中,體會這個模板好用的地方,相信你一定會和我一樣愛上這個模板的。
7、應(yīng)用提升
這里給出一些練習(xí)題,這些練習(xí)題都可以使用這個“神奇的”二分查找法模板比較輕松地寫出來,并且得到一個不錯的分?jǐn)?shù),大家加油!
LeetCode 第 704 題LeetCode 第 69 題LeetCode 第 153 題LeetCode 第 154 題LeetCode 第 287?LeetCode 第 1095 題LeetCode 第 658 題LeetCode 第 4 題
End
推薦閱讀:(點(diǎn)擊標(biāo)題即可跳轉(zhuǎn))
來和小伙伴們一起向上生長呀!
掃描下方二維碼,添加小詹微信,可領(lǐng)取千元大禮包并申請加入 Python 學(xué)習(xí)交流群,群內(nèi)僅供學(xué)術(shù)交流,日常互動,如果是想發(fā)推文、廣告、砍價小程序的敬請繞道!一定記得備注「交流學(xué)習(xí)」,我會盡快通過好友申請哦!
?長按識別,添加微信
(添加人數(shù)較多,請耐心等待)
總結(jié)
以上是生活随笔為你收集整理的有了这套模板,女朋友再也不用担心我刷不动 LeetCode 了的全部內(nèi)容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 大小仅1MB,超轻量级通用人脸检测模型登
- 下一篇: Python 已经饱和?我猜你一定不懂这