喫透二分查找—— LeetCode 第 33、34、35 題記

昨天沒能完成 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 道題只做出前兩道,排名很慘烈。對於題目解法的儲備不足,沒有頭緒是最浪費時間的,之後還要多加練習~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章