python learning----順序查找和二分法

  簡明算法系列:順序查找和二分法

查找(searching)是計算機算法的重要組成部分。它的內容本身理解起來不難,但真要動手寫起來,可能會有這樣那樣的細節問題。而且感覺除非經常和算法打交道,否則過段時間就很容易忘記具體細節,所以這裏嘗試着通過例子把要點總結起來。既希望是一個實踐性比較強的教程,也是自己的一個備忘錄。

這篇筆記會用Python,原因有幾個。一來當然是自己習慣Python,二來Python天生好懂,三來這裏有個在線的Python解釋器:http://repl.it/languages/Python/ 可以零安裝零配置零折騰地開始寫Python,方便到令人髮指。而由於Python本身就有點像僞代碼,所以習慣用C或者其它語言的朋友應該也不會感到隔閡。

Python由於太過方便,本身自帶了很多查找和排序的功能,所以這個例程會適時地禁用某些東西(比如,總不能直接調用sort()來寫一個快速排序吧)。這些會在各個例子裏註明,也因此會使得一些代碼看起來不那麼Pythonic或者解法是sub-optimal。

按照我的其它筆記的模式,從幾個例子開始講起,從最簡單的開始說。謹慎建議對於下面的例子,無論看起來多簡單,或者用Python,或者用上你熟悉的語言,動手自己寫一下,相信會有不一樣的發現。

這一節是關於順序查找和二分法。下一節則是哈希表。

 1.順序查找

例1 給定一個整數s和一個整數數組a,判斷s是否在a中。(注:不能用Python自帶的if s in a

既然沒有註明a有什麼特性,我們就只能假定它是一個很隨機的數組。要判斷s是否在a中,我們能做的,也就是逐個訪問a中的元素並和s比較。一旦找到,返回True,a遍歷完了還沒找到,則返回False。這個過程實現起來非常簡單:

def seq_search(s, a):   
     for i in range(len(a)):     
        if s == a[i]:          
        return True
     return False

給幾個測試例子

a = [13,42,3,4,7,5,6]
s = 7print seq_search(s,a)

a2 = [10,25,3,4,780,5,6]
s = 70print seq_search(s,a2)

如無意外的話,會輸出:

TrueFalse

如果要算它的時間複雜度也簡單,各種情況下,複雜度是這樣的:

情況最好最壞平均
找到了1nn/2
沒找到nnn

Big-O來表示,就是複雜度是O(n)。對於沒找到的情況,數組總是要遍歷一次的。而對於元素在數組中的情況,則要分運氣好壞,或許第一個就中了,或許最後一個纔是,平均而言,則是n/2

例2 註冊網站賬號時,用戶名常常要符合某些要求,比如不能含有英文的;!~這三個字符。寫一個函數,讀入用戶輸出的用戶名,返回“用戶名合法”或者“用戶名不合法”。

一個直觀的解法是這樣:

def username_check():
    username = input('輸入一個用戶名')    for s in ';!~':        if seq_search(s,username):           return '用戶名不合法'
    return '用戶名合法'

當然也可以這樣:

def username_check2():
    username = input('輸入一個用戶名')    for i in range(len(username)):        if seq_search(username[i],';!~'):            return '用戶名不合法'
    return '用戶名合法'

這裏都是多次調用我們之前寫好的seq_search(s,a)

   2. 二分查找和分治法

 上述的順序查找看起來簡單,技術含量不高,但對於一般的數組,確實也只能這樣查找了。但假如數組 本身是排序好的,則在查找的時候會省事一些。想象一下,如果你要在一堆人中找出體重和你一樣的人,一般情況下也沒有特殊的辦法,只能逐個去比,並期望於早一點找出那個人。但假如告訴你,面前的這一堆人已經按體重由輕到重排列好了,那顯然我們不會再一個個去比,而是先瞄一眼,看看有哪個 人可能體重和你無限接近,然後和他比較。如果你比他重,說明你要找的人在這個人的右邊,如果你比 他輕,則說明你要找的人在這個人的左邊。於是我們就把範圍縮小了,下一次的搜索,我們只需要其中 一邊去找。而對於這半邊,我們又可以故伎重演,找一個人,然後再將這半邊隊列分成兩半。

這其實就是二分查找。只不過對於數字,我們通常無法先主觀地找出一個“看起來差不多的”,因此我們會習慣性地從隊列中間將隊列等分劈成兩半。

來看一個具體的例子吧。

a = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10,11, 12]
s = 3

同樣地,我們想知道3是否在數組裏。因爲我們知道數組已排序好,所以我們可以直接先看數組的中間值。在這裏是6。由於3<6,我們於是知道了3只可能在數組的左半邊。接下來我們只需要在子數組

a_sub = [0, 1, 2, 3, 4, 5]

裏找就可以了。對於a_sub,我們也是取中間的那個值,在這裏是2(5/2取整,a[2]=2)。於是我們取右半邊:

a_sub_sub = [3,4,5]

再取中間值4。然後再取左半邊:

a_sub_sub_sub = [3]

這時的中間值剛好就是3了。通過不斷縮小範圍,我們成功地定位到3這個值。

請原諒略不雅觀的命名。

二分查找其實就是這樣:數組不斷裂變成原來的一半,(最壞情況下)最終到只剩一個元素。而運氣好的時候,某一次的中間值剛好就等於你要找的那個值。

下面我們寫出以上過程的代碼。

例3 寫一個函數實現二分查找來判斷整數s是否在升序排列的整數數組a當中。

def binary_search(s,array):
    found = False

    # left 和 right定義子數組的邊界,一開始搜索的是整個數組
    left = 0
    right = len(array)-1

    while left <= right:
        mid = (left+right) //2
        if array[mid] == s:            return True
        if s < array[mid]:            # 到左邊去找
            right = mid-1
        else:            # 到右邊去找
            left = mid + 1

    return False

left和right兩個變量,定義我們搜索的子數組的邊界。一開始我們查找的是整個數組。以後逐次按照上面的步驟,搜索左半邊或者右半邊。 只要是左邊邊界比右邊邊界小或者相等,就說明數組還至少有一個元素,那我們就持續執行這個裂變的循環。如果找到了,就停止搜索,如果在while循環裏一直找不到,那麼最後就返回False

如果你回顧上述過程,會發現每一次進入一個子數組,我們做的事情是一樣的:取中間值,然後比較,取左邊/右邊。於是這個過程也可以用遞歸來實現。

如果對遞歸沒有概念的話,可以先看看下面的例子。有接觸過遞歸的朋友就可以果斷跳過啦。

例4 寫一個函數對1-100之間的整數求和。

非遞歸的解法

def cal_sum(num):    sum = 0
    for i in range(1,num+1):        sum += i    return sum

上面的求和方法非常直觀,從1到100做一個循環。而用遞歸的思想來解決是這樣的:對1-100求和,其實等於是100加上前99個的和(sum99),而前99個的和,又等於99+sum98,如此反覆。把這個過程表示出來其實就是:

sum(i) = i + sum(i-1)

因此1-100求和的遞歸解法可以是:

def cal_sum(i):    if i == 1:        return 1
    return i + cal_sum(i-1)

是不是看起來更簡潔?我們一開始想知道cal_sum(100),通過遞歸,我們轉而去求解cal_sum(99)cal_sum(99)又會去調用cal_sum(98)。那麼到哪裏是個頭呢?

一個遞歸函數,必須有一個終止的條件,不然的話就等於是一級一級爬向無底深淵。在上述這個函數裏,我們的終止條件就是假如i==1,則不再繼續遞歸,而是返回1。

明白了遞歸的基礎知識,我們就可以將二分查找用遞歸來實現了。如下所示(慎重建議自己先寫一遍):

def binary_search_rec(s,array):   
     if len(array) == 0:# 數組已被掏空
     return False

    mid = (len(array)-1) //
    if array[mid] == s:   # 找到啦
        return True
    elif s < array[mid]:  # 要找的人在左邊
        return binary_search_rec(s, array[:mid])  
    else: # 要找的人在右邊
        return binary_search_rec(s, array[mid+1:])

使用遞歸,要注意終止遞歸的條件是什麼,在這裏,如果數組已經是空了,則表示沒找到,不再遞歸,返回False,如果找到,也是直接返回,不再折騰子數組了。要注意遞歸函數被調用時,前面要加return。這是因爲這裏的遞歸就好像一層一層進入一個深淵去尋找寶藏,而一旦找到,不能簡單地滿足於在深淵裏喊一聲“我找到了”,而是要逐層傳遞回來,直到地面上的人(第一級函數)也收到了信息。

二分查找顯然要比順序查找省時間,每一次分裂,搜索的長度都變爲之前的二分之一。假設一個數組原來的長度爲n,則一次之後,變爲n/2,再裂變後變爲n/4,n/8...不難看出其時間複雜度是O(log(n))

例5 一個本來按升序排序好的數組被切成兩部分,這兩部分調換位置變成一個新數組。用二分查找找出數組中的最小元素。假設數組不含重複元素。

比如,一個數組本來是a=[1,2,3,4,5,6,7,8,9]。現在沿着4和5之間切一刀,調換位置,變成一個新數組a=[5,6,7,8,9,1,2,3,4]

這是LeetCode的153題,難度標記爲中等。

考慮一個沒被攔腰截斷的升序數組,除了最大的那一個,其餘所有元素,當向右望的時候,都會發現右邊的元素比它大。這是正常的情況。因此,假如當你向右望而發現有元素比你小的時候,可想而知你右邊的那一塊是被動過手腳的。也就是那個“切斷點”就藏在右邊。反過來想,如果右半邊沒有一個先降後升的過程,那你不可能找到比你小的元素。而先降後升的那個轉折點,就是我們要找的。因此,你可以把搜索範圍縮小到右半邊。而如果右半邊的元素比你大,而將搜索範圍轉到左半邊。這其實和我們上面的例4很接近。

def slice_point(num,left,right):    if left==right:        # 只剩一個元素
        return num[right]

    mid = (right+left)//2

    if num[mid]>num[right]:        return slice_point(num,mid+1,right)    else:        return slice_point(num,left,mid)

注意我們這裏將left和right也作爲參數傳遞,這樣是爲了更直觀,其實也完全可以不用。就像上面的那個例子一樣,直接對Python的list進行slice操作(e.g. num[mid:])。代碼和例3非常像,但你發現幾乎相同的一段代碼,卻可以用來回答不同的問題。

練習 試着用循環(而不是遞歸)來實現上述查找。

這個就不給答案了。

例6 用二分查找實現開平方根函數square(x,p)x是被開方根的數,假定輸入都爲非負整數,p是誤差上限,輸出一個浮點數結果。

這是LeetCode的69題,略有改動。原題是輸出一個整數,這裏直接輸出浮點數,難度其實是降低了。原題難度標記爲中等。

如何用二分查找思考這個問題?

我們要查找的,其實是某數的近似平方根。那麼供我們查找的數組在哪裏呢?搜索範圍是什麼?

顯然,一個整數的平方根,不會小於0,也不會大於他本身。於是我們的搜索範圍,其實就可以定位[0, x]。在這個範圍裏我們應用二分搜索,取中間值mid=0.5x,如果mid*mid > x,說明我們改走左半邊。反之,走右半邊。代碼如下:

def square(x,p):    return square_helper(x,0.0,float(x),p)def square_helper(x,left,right,p):    if abs(left-right) < p*2:        # 左邊右邊距離已經很小了
        #print (left+right)/2.0
        return (left+right)/2.0

    # 折半
    mid = float((left+right)/2.0) 


    if mid*mid - x > 0:        return square_helper(x,left,mid,p)    else:        return square_helper(x,mid,right,p)

你可以通過隨意控制p來輸出不同精度的值。到這裏你可以看到,無論是用循環還是遞歸,二分查找的套路都是極其類似的:

  • 一個終止條件,前面那些數組的例子,終止條件就是:找到或者沒找到(一個確定的答案)。開平方根,條件就是,滿足精度要求。注意,程序的編寫還應考慮各種情況。在各種情況下都應有退出機制。

  • 取中間值,然後根據中間值的情況,確定往左還是往右。

就這麼簡單。至於是用循環還是遞歸,對於一個算法題來說,大部分時候是一個個人喜好的問題。我自己偏好遞歸,各位請隨意。

按照這個套路,我們再來看一個例子。

例7 給定一個升序排序的數組,以及一個目標值。如果目標值在數組裏,返回對應元素的下標,如果不在,返回該插入的位置。

這是LeetCode的35題。難度標記爲中等(LeetCode的難度值隨便看看就好,我個人覺得不太反映真實難度)。

這個題和二分查找的原型(例3)非常像:如果找到了,那麼都一樣返回。只不過,當最後找不到的時候,我們不是返回False,而是討論應該把數插在哪裏。但是這一點其實也不難,我們做二分查找的過程,其實就是一步步逼近那個最像的值。所以最後即使找不到目標值,該插入的位置,也就是在最後那個剩下的元素的左邊或者右邊了。

以下是循環的解法。

def searchInsert(A,target):
    left = 0
    right = len(A)-1

    while left != right:
        mid = (left+right)//2
        if target == A[mid]:      
            return mid     
        elif target < A[mid]:
            right = mid    
        else:
            left = mid+1

    if target>A[left]:     
       return left+1
    return left

接着是遞歸的解法。

def searchHelper(A,target,left,right):  
  if right==left:     
     if A[left] < target:    
         return left+1
      return left

    mid = (left+right)//2
    # print mid
    if target == A[mid]:     
       return mid   
     elif target < A[mid]:     
        return searchHelper(A,target,left,mid) 
     else:      
        return searchHelper(A,target,mid+1,right)

相信你都已經找到解題的模板了。

熟悉了以上的套路後,我們最後來看一題稍稍有點不同的。

例8 給定浮點數x和整數n,計算power(x,n),即x的n次方。

這是LeetCode的50題。難度標記爲中等。

很顯然,我們這裏禁用Python的x**n以及其它可能的庫函數。

很顯然的一個簡單粗暴的方法是,跑n-1次循環,每次都把乘積相乘。這樣我們一共需要做n-1次浮點數相乘。有沒有更簡單的辦法?

這一題可能有點不太好想。即使你明知要用二分法,但如何二分呢?我們之前的例子,基本都有一個目標值,而我們清楚地知道這個目標值的上界和下界。而這一題裏,目標值是有,但我們無從知道它的上下界,而且也無從檢驗一個值離目標值有多遠。所以可能要換一種思路。

上面提到的暴力解法,總共要做n-1次乘法。所以如果我們要取得突破,可能的途徑是減少乘法的次數。比如說,2^64,總共要63次乘法。有沒有辦法少做一些?

當然是有的,冪運算本身可以和低次冪進行換算。比如,2^64=(2*2)^32=4^32,接着來4^32=(4*4)^16=16^16。做完第一次換算之後,我們多做了一個乘法,但是!冪從64變成32,少了一半!,第二次之後,我們又用一次乘法,換來總乘法運算次數的減半。順着這個思路下去,我們可以不斷用一次乘法的代價,將總乘法次數減半。直到……直到不能再減,也就是n變成0或者是1。

目前爲止,只有一個問題:假如n本身不能減半呢?假如是2^63呢?很簡單,看成2^62*2=4^31*2=16^15*4*2。如果n是計數,就把一個x拆出來,這樣n-1就是偶數了。

所以剩下的問題就不太難了:

def power(x,n):   
 if n == 0:     
    return 1
 elif n == 1:      
    return x   
 elif n < 0:        # 這裏把負數的n先變成正數,處理起來簡單一些
    return pow(1.0/x,-n)   
 elif n%2 == 0:        # n是偶數
    return power(x*x,n/2)  
 else:        # n是奇數
    return power(x*x,(n-1)/2)*x

你可以看到,雖然這一題要考慮的問題多一些,但其實整個套路還是二分查找的那個樣式。具體理解起來不復雜,就不囉嗦了~


   轉載聲明

如果你喜歡這篇文章,可以隨意轉載。但請

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