昨天沒能完成 34,今天來補上。恰好第 35 題也是二分查找算法的應用,放到一起來記錄。
首先看下二分查找算法的概念:
❝二分查找也稱折半查找(Binary Search),它是一種效率較高的查找方法。但是,折半查找要求線性表必須採用順序存儲結構,而且表中元素按關鍵字有序排列。百度百科:二分查找
❞
使用的題目中,一般會提到要求時間複雜度爲 O(log n) 級別,以及涉及到的列表、數組是有序排列的。結合今天要記的三道題,我們來練習下這種解法的應用。在難度上,第 35 題簡單,33、34 是中等難度,我們先看簡單的。
題目一
「第 35 題:搜索插入位置」
給定一個排序數組和一個目標值,在數組中找到目標值,並返回其索引。如果目標值不存在於數組中,返回它將會被按順序插入的位置。
你可以假設數組中無重複元素。
示例 1:
輸入: [1,3,5,6], 5
輸出: 2
示例 2:
輸入: [1,3,5,6], 2
輸出: 1
示例 3:
輸入: [1,3,5,6], 7
輸出: 4
示例 4:
輸入: [1,3,5,6], 0
輸出: 0
#來源:力扣(LeetCode)
#鏈接:https://leetcode-cn.com/problems/search-insert-position
題目分析
要在排序數組中找目標值的位置,很典型的二分查找的應用場景。當目標不存在時,返回目標應該被插入的位置。這個我們先把特殊情況擇出來:列表長度不到 2 的情況,目標值大小超出列表值範圍情況。正常二分查找的操作類似雙指針法,定義一前一後兩個索引,每次取其中間位置的值來進行判斷,直到最終定位出結果。
比如示例 1,我們分別先取第一位 1 和最後一位 6 作爲前後位置,此時中間位置爲第 2 位 3,3 小於目標值,所以我們把範圍縮小到右半部;縮小範圍後,第 2 位 3 成了左側邊界,最後一位 6 仍是右邊界,此時中間位置爲 第 3 位 恰好爲 5 目標值,所以返回第 3 位的座標 2,任務完成。
這題要注意的點是,若存在目標值,返回其座標;若不存在,要返回目標應該插入的位置。
代碼實現
看二分法,通常都會糾結於比較完中點值後,對之後左右邊界如何劃分,究竟取 mid、mid-1 還是 mid+1 作爲新的座標,這個要具體來分析。
class Solution:
def searchInsert(self, nums: List[int], target: int) -> int:
# 空列表情況
if nums==[]:
return 0
length = len(nums)
# 只有一個數的列表情況
if length<2:
if nums[0]>=target:
return 0
else:
return 1
# 目標值不在列表值範圍內情況
if target<=nums[0]:
return 0
if target> nums[-1]:
return length
# 二分查找法開始
# 定義左右邊界位置,最左端和最右端索引
l,r = 0,length-1
# while 循環控制 l 和 r 來縮小範圍
while l<r:
# 取中點,這裏的取值會偏左,比如第 1、2 位取的中點是第 1 位
mid = (l+r)//2
# 如果中點處值爲目標,直接返回中點索引
if nums[mid]==target:
return mid
# 若中點值大於目標,目標在左邊部分,此時將右邊界變爲 mid
# 這裏不取 mid-1 的原因:取中點是偏左的,是無法取到右邊界處的;如果 mid-1 處恰好爲目標值,將其定爲右邊界就會導致中點無法取到該位置,故右邊界我們取 mid
elif nums[mid]>target:
r = mid
# 左邊界可以跳過 mid 取 mid+1 原因在於,取中點可以取到左邊界
else:
l = mid+1
# 無論是否存在目標,經歷過循環後,l 爲目標的位置索引
return l
提交代碼測試:
執行用時 : 40 ms, 在所有 Python3 提交中擊敗了 79.75% 的用戶
內存消耗 : 14.4 MB, 在所有 Python3 提交中擊敗了 7.14% 的用戶
當然也有其它取巧的方法,我們先忽略,主要練習這個二分查找,繼續看下一道
題目二
「第 33 題:搜索旋轉排序數組」
假設按照升序排序的數組在預先未知的某個點上進行了旋轉。
( 例如,數組 [0,1,2,4,5,6,7] 可能變爲 [4,5,6,7,0,1,2] )。
搜索一個給定的目標值,如果數組中存在這個目標值,則返回它的索引,否則返回 -1 。
你可以假設數組中不存在重複的元素。
你的算法時間複雜度必須是 O(log n) 級別。
示例 1:
輸入: nums = [4,5,6,7,0,1,2], target = 0
輸出: 4
示例 2:
輸入: nums = [4,5,6,7,0,1,2], target = 3
輸出: -1
題目分析
如果暴力遍歷的話,時間複雜度是 O(n) 級別,因爲需要對 n 個元素一一處理。若使用二分查找,時間複雜度就變爲 log2 n 了,因爲每次都是對半,比如有 8 個數、我們 3 次對半分便能完成定位。
此題目中提到原本排好序的列表,被調整了一次。看似不太符合二分查找時對排序列表的要求,但即使列表被調整,當我們對半分時,總有一半是完全排序的,我們依據這半部分來分析同樣可以完成任務。比如示例 1 ,在列表中找目標 0,如下圖:
我們先取中點,值爲 7,此時左邊正常排序,右邊順序有變。此時,判斷目標不在正常排序的左邊,所以將邊界調整,直接取右半部分。
接下來再取中點,值爲 0,此時右邊正常排序,但中點的值已經是目標值了,任務結束,返回此刻中點左邊即可。
類似地,我們每次取中點,找兩邊正常排序的部分,看目標是否位於該部分,然後調整邊界縮小範圍至一半,直至最終定位。
代碼實現
在這段代碼中,爲了不糾結縮小範圍換邊界時究竟選用 mid 還是 mid+1、mid-1,我就單獨把邊界處可能取到目標值的情況也給做了處理,一旦檢測到目標值,直接返回。
class Solution:
def search(self, nums: List[int], target: int) -> int:
length = len(nums)
# 對空列表和只有一個數的列表特殊處理
if length<2:
if nums and nums[0]==target:
return 0
else:
return -1
# l 左邊界,r 右邊界
l,r = 0,length-1
# l 與 r 相等時結束循環
while l<r:
# 取中值
mid = (r+l)//2
# 如果中值處爲目標值,直接返回
if nums[mid]==target:
return mid
# 如果邊界處爲目標值,也直接返回
if nums[l]==target:
return l
if nums[r]==target:
return r
# 如果左邊界小於中點的值,說明左部是正常排序
if nums[l]<nums[mid]:
# 若此時目標值在左部,將右邊界縮到 mid-1
if nums[l]<target and target < nums[mid]:
r = mid-1
# 否則調整左邊界到 mid+1
else:
l = mid+1
# 右部正常排序的情況
else:
# 若目標值位於右部,調整左邊界到 mid+1
if nums[mid]<target and target < nums[r]:
l = mid+1
# 否則,調整右邊界
else:
r = mid-1
# 若上述過程循環完仍未取到目標值,說明沒有,返回 -1
return -1
提交測試表現:
執行用時 : 32 ms, 在所有 Python3 提交中擊敗了 97.01% 的用戶
內存消耗 : 13.9 MB, 在所有 Python3 提交中擊敗了 7.69% 的用戶
表現是挺亮眼的,接下來,會一會最費時間的題目。
題目三
「第 34 題:在排序數組中查找元素的第一個和最後一個位置」
給定一個按照升序排列的整數數組 nums,和一個目標值 target。找出給定目標值在數組中的開始位置和結束位置。
你的算法時間複雜度必須是 O(log n) 級別。
如果數組中不存在目標值,返回 [-1, -1]。
示例 1:
輸入: nums = [5,7,7,8,8,10], target = 8
輸出: [3,4]
示例 2:
輸入: nums = [5,7,7,8,8,10], target = 6
輸出: [-1,-1]
題目分析
這道題很奇怪,你說它能用二分查找法吧,應該是能用的:排序列表,查找元素位置。但是要查目標值出現的起始和結束兩個位置,這個要怎麼做?
同時找兩個位置肯定不好找,我們需要分步來:先用二分法查找起始位置。查完後,再從起始位置到列表結束,繼續用二分法查找結束位置。
比如示例 1,先通過二分法定位到目標 8 的起點;再從該起點開始繼續二分查找終點位置:與之前不同的點在於,找起點位置過程中,即使取到的中點值與目標值相等,我們仍然要取左側部分繼續分析,因爲我們要找的目標值的起點;同理,找結束位置時,即使取到的中點值與目標相等,我們仍要取右側部分繼續分析。
代碼實現
class Solution:
def searchRange(self, nums: List[int], target: int) -> List[int]:
length = len(nums)
# 特殊情況處理
if length<2:
# 單數列表,且該數與目標值相等
if nums and nums[0]==target:
return [0,0]
# 不匹配的單數列表、空列表
else:
return [-1,-1]
# 目標值超出列表值範圍情況
if target<nums[0] or target>nums[-1]:
return [-1,-1]
# 先定義好默認值
left,right = -1,-1
# 起始位置的二分查找開始
l,r = 0,length-1
# 老樣子,while 循環
while l<r:
# 取中點
mid = (l+r)//2
# 若中點小於目標值,調整左邊界至 mid+1
# 因爲取中點偏左邊界,所以可以不取 mid 直接下一位 mid+1,即使 mid+1 真就是起始位置,也可以通過取中點取到該處
if nums[mid]<target:
l = mid+1
# 若中點值大於等於目標,因爲我們要找目標的起點,所以把右邊界調整到 m
# 不取 mid-1 的原因在於,若 mid-1 爲起點,把其作爲右邊界的話,無法通過取中點定位到該位置
elif nums[mid]>=target:
r = mid
# while 循環結束時,l 即起點位置,這時要檢查下列表中該位置是否存在目標值
# 若列表該位置確實存在目標值,更新起始位置到 left
if nums[l]==target:
left = l
# 若不存在,列表中沒有目標值,返回 -1 相關結果
else:
return [-1,-1]
# 接下來找結束位置
# 若此時已經是最後一位,那麼結束位置與起始位置相同,直接返回
if l+1==length:
return [left,left]
# 若後續還有其它位,重新開啓二分查找
# 取起始位置下一位開始作爲邊界
l,r = l+1,length-1
while l<r:
mid = (l+r)//2
# 如果中點值大於目標,更新右邊界爲 mid
if nums[mid]>target:
r = mid
# 如果中點值小於等於目標,將左邊界更新
elif nums[mid]<=target:
l = mid+1
# 當while循環結束時,有可能 l 是結束位置,也可能是結束位置的下一位
# 若爲結束位置
if nums[l]==target:
right = l
# 若其上一位是結束位置
elif nums[l-1]==target:
right = l-1
# 將更新完畢的位置返回
return[left,right]
最後求結束位置時還分情況討論了下,按照我們區分邊界的分析,最終求出的左邊 l 應該是結束位置的下一位,結束位置應該是 l-1;但是若該列表以重複出現的目標值作爲最後元素,比如 [1,2,2] 目標值爲 2,又因爲右邊界的值無法通過中點取到,所以最終拿到的結束位置 l = 2,恰好也是目標值,所以也需要對這種情況下做一個判斷處理。
提交測試表現:
執行用時 : 40 ms, 在所有 Python3 提交中擊敗了 81.86% 的用戶
內存消耗 : 14.6 MB, 在所有 Python3 提交中擊敗了 7.69% 的用戶
結論
經過三道題目兩天的練習,就基本可以掌握二分查找的用法了。該算法麻煩的點在於取完中點值後對下一半部左右邊界的取值,以及配合題意變換做一些特殊情況考慮處理。
乍一看會覺得一團糟,但理清思路後,便可以一步步完成代碼了。當然,還是會有些問題,比如 34 題求最後結束位置時對不同情況的特殊處理,這個一般還挺難考慮到,得通過提交測試時返回的特殊例子來檢查出漏洞予以修復。
今天 10:30-12:00,試着參與了下 LeetCode 周賽,4 道題只做出前兩道,排名很慘烈。對於題目解法的儲備不足,沒有頭緒是最浪費時間的,之後還要多加練習~