IOS查找算法之二分查找

二分查找(Binary Search)算法,也叫折半查找算法。二分查找的思想非常簡單,很多非計算機專業的同學很容易就能理解,但是看似越簡單的東西往往越難掌握好,想要靈活應用就更加困難。

無處不在的二分思想

舉個例子:我們假設只有 10 個訂單,訂單金額分別是:8,11,19,23,27,33,45,55,67,98。 還是利用二分思想,每次都與區間的中間數據比對大小,縮小查找區間的范圍。為了更加直觀,我畫了一張查找過程的圖。其中,low 和 high 表示待查找區間的下標,mid 表示待查找區間的中間元素下標。

image.png

總結一下:二分查找針對的是一個有序的數據集合,查找思想有點類似分治思想。每次都通過跟區間的中間元素對比,將待查找的區間縮小為之前的一半,直到找到要查找的元素,或者區間被縮小為 0。

O(logn) 驚人的查找速度

二分查找是一種非常高效的查找算法,高效到什么程度呢?我們來分析一下它的時間復雜度。

我們假設數據大小是 n,每次查找后數據都會縮小為原來的一半,也就是會除以 2。最壞情況下,直到查找區間被縮小為空,才停止。

image.png

可以看出來,這是一個等比數列。其中 n/2k=1 時,k 的值就是總共縮小的次數。而每一次縮小操作只涉及兩個數據的大小比較,所以,經過了 k 次區間縮小操作,時間復雜度就是 O(k)。通過 n/2k=1,我們可以求得 k=log2n,所以時間復雜度就是 O(logn)。

這是一種極其高效的時間復雜度,有的時候甚至比時間復雜度是常量級 O(1) 的算法還要高效。為什么這么說呢?

因為logn 是一個非常“恐怖”的數量級,即便n非常非常大,對應的logn也很小。比如n等于2的32次方,這個數很大了吧?大約是42億。也就是說,如果我們在42億個數據中用二分查找一個數據,最多需要比較32次。

我們前面講過,用大0標記法表示時間復雜度的時候,會省略掉常數、系數和低階。對于常量級時間復雜度的算法來說,0(1) 有可能表示的是一個非常大的常量值,比如O(1000)、O(10000)。 所以,常量級時間復雜度的算法有時候可能還沒有O(logn) 的算法執行效率高。

二分查找的遞歸與非遞歸實現

實際上,簡單的二分查找并不難寫,注意我這里的“簡單”二字。下一節,我們會講到二分查找的變體問題,那才是真正燒腦的。今天,我們來看如何來寫最簡單的二分查找。

最簡單的情況就是有序數組中不存在重復元素,我們在其中用二分查找值等于給定值的數據。我用IOS代碼實現了一個最簡單的二分查找算法。

- (NSInteger)gly_bsearchWithLoop:(NSString *)propertyName value:(double)value
{
    NSInteger low = 0;
    NSInteger high = self.count - 1;
    
    while (low <= high)
    {
        //為什么不寫(low + high) / 2,是因為如果 low 和 high 比較大的話,兩者之和就有可能會溢出。
        NSInteger mid = low + ((high - low) >> 1);
        double middleNumber = [[self[mid] valueForKey:propertyName] doubleValue];
        if ([@(middleNumber) compare:@(value)] == NSOrderedSame)
        {
            return mid;
        }
        else if ([@(middleNumber) compare:@(value)] == NSOrderedAscending)
        {
            low = mid + 1;
        }
        else
        {
            high = mid - 1;
        }
    }
    
    return -1;
}

這個代碼我稍微解釋一下,low、high、mid 都是指數組下標,其中 low 和 high 表示當前查找的區間范圍,初始 low=0, high=n-1。mid 表示 [low, high] 的中間位置。我們通過對比 self[mid] 與value 的大小,來更新接下來要查找的區間范圍,直到找到或者區間縮小為 0。我就著重強調一下容易出錯的 3 個地方。

1. 循環退出條件

注意是 low <= high,而不是 low < high。

2.mid的取值

實際上,mid=(low+high)/2 這種寫法是有問題的。因為如果low和high比較大的話,兩者之和就有可能會溢出。改進的方法是將mid的計算方式寫成low+(high- -low)/2。更進一步,如果要將性能優化到極致的話,我們可以將這里的除以2操作轉化成位運算low+((high-low)>>1)。因為相比除法運算來說,計算機處理位運算要快得多。

3.low和high的更新

low=mid+1, high=mid-1。 注意這里的+1和-1,如果直接寫成low=mid或者high=mid,就可能,會發生死循環。比如,當high=3, low=3 時,如果a[3]不等于value,就會導致一直循環不退出。

如果你留意我剛講的這三點,我想一個簡單的二分查找你已經可以實現了。實際上,二分查找除了用循環來實現,還可以用遞歸來實現,過程也非常簡單。

- (NSInteger)gly_bsearchWithRecursion:(NSString *)propertyName value:(double)value
{
    return [self gly_bsearchInternally:propertyName value:value low:0 high:self.count - 1];
}

- (NSInteger)gly_bsearchInternally:(NSString *)propertyName value:(double)value low:(NSInteger)low high:(NSInteger)high
{
    if (low > high)
    {
        return -1;
    }
    
    //因為相比除法運算來說,計算機處理位運算要快得多。這里也等同于(NSInteger mid = low + (high - low) / 2)
    NSInteger mid = low + ((high - low) >> 1);
    double middleNumber = [[self[mid] valueForKey:propertyName] doubleValue];
    if ([@(middleNumber) compare:@(value)] == NSOrderedSame)
    {
        return mid;
    }
    else if ([@(middleNumber) compare:@(value)] == NSOrderedAscending)
    {
        return [self gly_bsearchInternally:propertyName value:value low:mid + 1 high:high];
    }
    else
    {
        return [self gly_bsearchInternally:propertyName value:value low:low high:mid - 1];
    }
}

二分查找應用場景的局限性

前面我們分析過,二分查找的時間復雜度是O(logn),查找數據的效率非常高。不過,并不是什么情況下都可以用二分查找,它的應用場景是有很大局限性的。那什么情況下適合用二分查找,什么情況下不適合呢?

首先,二分查找依賴的是順序表結構,簡單點說就是數組。

那二分查找能否依賴其他數據結構呢?比如鏈表。答案是不可以的,主要原因是二分查找算法需要按照下標隨機訪問元素。我們在數組和鏈表那兩節講過,數組按照下標隨機訪問數據的時間復雜度是0(1),而鏈表隨機訪問的時間復雜度是O(n)。所以,如果數據使用鏈表存儲,- -分查找的時間復雜就會變得很高。

二分查找只能用在數據是通過順序表來存儲的數據結構上。如果你的數據是通過其他數據結構存儲的,則無法應用二分查找。

其次,二分查找針對的是有序數據。

二分查找對這一點的要求比較苛刻,數據必須是有序的。如果數據沒有序,我們需要先排序。前面章節里我們講到,排序的時間復雜度最低O(nlogn),所以,如果我們針對的是-組靜態的數據,沒有頻繁地插入、刪除,我們可以進行一次排序,多次二分查找。這樣排序的成本可被均攤,二分查找的邊際成本就會比較低。

但是,如果我們的數據集合有頻繁的插入和刪除操作,要想用二分查找,要么每次插入、刪除操作之后保證數據仍然有序,要么在每次二分查找之前都先進行排序。針對這種動態數據集合,無論哪種方法,維護有序的成本都是很高的。

所以,二分查找只能用在插入、刪除操作不頻繁,一 次排序多次查找的場景中。針對動態變化的數據集合,二分查找將不再適用。那針對動態數據集合,如何在其中快速查找某個數據呢?別急,等到二叉樹那一節我會詳細講。

再次,數據量太小不適合二分查找。

如果要處理的數據量很小,完全沒有必要用二分查找,順序遍歷就足夠了。比如我們在一個大小為10的數組中查找一個元素, 不管用二分查找還是順序遍歷,查找速度都差不多。只有數據量比較大的時候,二分查找的優勢才會比較明顯。

不過,這里有一個例外。如果數據之間的比較操作非常耗時,不管數據量大小,我都推薦使用二分查找。比如,數組中存儲的都是長度超過300的字符串,如此長的兩個字符串之間比對大小,就會非常耗時。我們需要盡可能地減少比較次數,而比較次數的減少會大大提高性能,這個時候二分查找就比順序遍歷更有優勢。

最后,數據量太大也不適合二分查找。

二分查找的底層需要依賴數組這種數據結構,而數組為了支持隨機訪問的特性,要求內存空間連續,對內存的要求比較苛刻。比如,我們有1GB大小的數據,如果希望用數組來存儲,那就需要1GB的連續內存空間。

注意這里的“連續”二字,也就是說,即便有2GB的內存空間剩余,但是如果這剩余的2GB內存空間都是零散的,沒有連續的1GB大小的內存空間,那照樣無法申請一個1GB大小的數組。而我們的二分查找是作用在數組這種數據結構之上的,所以太大的數據用數組存儲就比較吃力了,也就不能用二分查找了。

但是,上面介紹的只是最簡單的一種二分查找的代碼實現。接下來我們來講幾種二分查找的變形問題。

你可能會說,我們上面學的二分查找的代碼實現并不難寫啊。那是因為上面講的只是二分查找中最簡單的一種情況,在不存在重復元素的有序數組中,查找值等于給定值的元素。最簡單的二分查找寫起來確實不難,但是,二分查找的變形問題就沒那么好寫了。

二分查找的變形問題很多,我只選擇幾個典型的來講解,其他的你可以借助我今天講的思路自己來分析。

image.png

變體一:查找第一個值等于給定值的元素

前面中的二分查找是最簡單的一種,即有序數據集合中不存在重復的數據,我們在其中查找值等于某個給定值的數據。如果我們將這個問題稍微修改下,有序數據集合中存在重復的數據,我們希望找到第一個值等于給定值的數據,這樣之前的二分查找代碼還能繼續工作嗎?

比如下面這樣一個有序數組, 其中,a[5], a[6], a[7] 的值都等于8,是重復的數據。我們希望查找第一個等于8的數據,也就是下標是5的元素。

image.png

如果我們用前面講的二分查找的代碼實現,首先拿8與區間的中間值a[4]比較,8比6大,于是在下標5到9之間繼續查找。下標5和9的中間位置是下標7,a[7]正好等于8,所以代碼就返回了。

盡管a[7]也等于8,但它并不是我們想要找的第一個等于8的元素,因為第一個值等于8的元素是數組下標為5的元素。我們前面講的二分查找代碼就無法處理這種情況了。所以,針對這個變形問題,我們可以稍微改造一下前面的代碼。

100個人寫二分查找就會有100種寫法。網上有很多關于變形二分查找的實現方法,有很多寫得非常簡潔,比如下面這個寫法。但是,盡管簡潔,理解起來卻非常燒腦,也很容易寫錯。

- (NSInteger)gly_bsearchFirstItemWithLoop:(NSString *)propertyName value:(double)value
{
    NSInteger low = 0;
    NSInteger high = self.count - 1;
    
    while (low <= high)
    {
        //為什么不寫(low + high) / 2,是因為如果 low 和 high 比較大的話,兩者之和就有可能會溢出。
        NSInteger mid = low + ((high - low) >> 1);
        double middleNumber = [[self[mid] valueForKey:propertyName] doubleValue];
        if ([@(middleNumber) compare:@(value)] == NSOrderedDescending)
        {
            high = mid - 1;
        }
        else if ([@(middleNumber) compare:@(value)] == NSOrderedAscending)
        {
            low = mid + 1;
        }
        else
        {
            if (mid == 0 || [@([[self[mid - 1] valueForKey:propertyName] doubleValue]) compare:@(value)] != NSOrderedSame)
            {
                return mid;
            }
            else
            {
                high = mid - 1;
            }
        }
    }
    
    return -1;
}

我來稍微解釋一下 這段代碼。self[mid] 跟要查找的value的大小關系有三種情況:大于、小于、等于。對于self[mid]>value的情況,我們需要更新high= mid-1;對于self[mid]<value的情況,我們需要更新low=mid+1。這兩點都很好理解。那當self[mid]=value的時候應該如何處理呢?

如果我們查找的是任意一個值等于給定值的元素,當self[mid]等于要查找的值時,self[mid] 就是我們要找的元素。但是,如果我們求解的是第一個值等于給定值的元素,當self[mid]等于要查找的值時,我們就需要確認一下這個self[mid]是不是第一個值等于給定值的元素。

我們重點看一下if (mid == 0 || [@([[self[mid - 1] valueForKey:propertyName] doubleValue]) compare:@(value)] != NSOrderedSame)。如果mid等于0,那這個元素已經是數組的第一個元素,那它肯定是我們要找的;如果mid不等于0,但self[mid]的前一個元素self[mid-1]不等于value,那也說明self[mid]就是我們要找的第一個值等于給定值的元素。

如果經過檢查之后發現self[mid]前面的一個元素self[mid-1]也等于value,那說明此時的self[mid]肯定不是我們要查找的第一一個值等于給定值的元素。那我們就更新high=mid-1,因為要找的元素肯定出現在[low, mid-1]之間。

對比上面的兩段代碼,是不是下面那種更好理解?實際上,很多人都覺得變形的二分查找很難寫,主要原因是太追求第一種那樣完美、簡潔的寫法。而對于我們做工程開發的人來說,代碼易讀懂、沒Bug,其實更重要,所以我覺得第二種寫法更好。

變體二:查找最后一個值等于給定值的元素

前面的問題是查找第一個值等于給定值的元素,我現在把問題稍微改一下,查找最后一個值等于給定值的元素,又該如何做呢?如果你掌握了前面的寫法,那這個問題你應該很輕松就能解決。你可以先試著實現一下,然后跟我寫的對比一下。

- (NSInteger)gly_bsearchLastItemWithLoop:(NSString *)propertyName value:(double)value
{
    NSInteger low = 0;
    NSInteger high = self.count - 1;
    
    while (low <= high)
    {
        //為什么不寫(low + high) / 2,是因為如果 low 和 high 比較大的話,兩者之和就有可能會溢出。
        NSInteger mid = low + ((high - low) >> 1);
        double middleNumber = [[self[mid] valueForKey:propertyName] doubleValue];
        if ([@(middleNumber) compare:@(value)] == NSOrderedDescending)
        {
            high = mid - 1;
        }
        else if ([@(middleNumber) compare:@(value)] == NSOrderedAscending)
        {
            low = mid + 1;
        }
        else
        {
            if (mid == self.count - 1 || [@([[self[mid + 1] valueForKey:propertyName] doubleValue]) compare:@(value)] != NSOrderedSame)
            {
                return mid;
            }
            else
            {
                low = mid + 1;
            }
        }
    }
    
    return -1;
}

我們還是重點看第11行代碼。如果a[mid]這個元素已經是數組中的最后一個元素了,那它肯定是我們要找的;如果a[mid]的后一個元素a[mid+1]不等于value,那也說明a[mid]就是我們要找的最后一個值等于給定值的元素。

如果我們經過檢查之后,發現a[mid]后面的一個元素a[mid+1]也等于value,那說明當前的這個a[mid]并不是最后一個值等于給定值的元素。我們就更新low=mid+1,因為要找的元素肯定出現在[mid+1, high]之間。

變體三:查找第一個大于等于給定值的元素

現在我們再來看另外- -類變形問題。在有序數組中,查找第一個大于等 于給定值的元素。比如,數組中存儲的這樣一個序列: 3, 4,6,7, 10。如果查找第一個大于等于5的元素,那就是6。

實際上,實現的思路跟前面的那兩種變形問題的實現思路類似,代碼寫起來甚至更簡潔。

- (NSInteger)gly_bsearchMoreWithLoop:(NSString *)propertyName value:(double)value
{
    NSInteger low = 0;
    NSInteger high = self.count - 1;
    
    while (low <= high)
    {
        NSInteger mid = low + ((high - low) >> 1);
        double middleNumber = [[self[mid] valueForKey:propertyName] doubleValue];
        if ([@(middleNumber) compare:@(value)] == NSOrderedAscending)
        {
            low = mid + 1;
        }
        else
        {
            if (mid == 0 || [@([[self[mid - 1] valueForKey:propertyName] doubleValue]) compare:@(value)] == NSOrderedAscending)
            {
                return mid;
            }
            else
            {
                high = mid - 1;
            }
        }
    }
    
    return -1;
}

如果self[mid]小于要查找的值value,那要查找的值肯定在[mid+1, high]之間,所以,我們更新low=mid+1。

對于self[mid]大于等于給定值value的情況,我們要先看下這個self[mid]是不是我們要找的第一個值大于等于給定值的元素。如果a[mid]前面已經沒有元素,或者前面一個元素小于要查找的值value,那self[mid]就是我們要找的元素。這段邏輯對應的代碼是if (mid == 0 || [@([[self[mid - 1] valueForKey:propertyName] doubleValue]) compare:@(value)] == NSOrderedAscending)這行。

如果self[mid-1]也大于等于要查找的值value,那說明要查找的元素在[low, mid-1]之間,所以,我們將high更新為mid-1。

變體四:查找最后一個小于等于給定值的元素

現在,我們來看最后一種二分查找的變形問題,查找最后一個小于等于給定值的元素。比如,數組中存儲了這樣一組數據: 3, 5, 6, 8, 9, 10。最后一一個小于等于7的元素就是6。是不是有點類似上面那一種?實際上,實現思路也是一樣的。

有了前面的基礎,你完全可以自己寫出來了,所以我就不詳細分析了。我把代碼貼出來,你可以寫完之后對比一下。

- (NSInteger)gly_bsearchLessWithLoop:(NSString *)propertyName value:(double)value
{
    NSInteger low = 0;
    NSInteger high = self.count - 1;
    
    while (low <= high)
    {
        NSInteger mid = low + ((high - low) >> 1);
        double middleNumber = [[self[mid] valueForKey:propertyName] doubleValue];
        if ([@(middleNumber) compare:@(value)] == NSOrderedDescending)
        {
            high = mid - 1;
        }
        else
        {
            if (mid == self.count - 1 || [@([[self[mid + 1] valueForKey:propertyName] doubleValue]) compare:@(value)] == NSOrderedDescending)
            {
                return mid;
            }
            else
            {
                low = mid + 1;
            }
        }
    }
    
    return -1;
}

內容小結

前面我說過,凡是用二分查找能解決的,絕大部分我們更傾向于用散列表或者二叉查找樹。即便是二分查找在內存使用上更節省,但是畢竟內存如此緊缺的情況并不多。那二分查找真的沒什么用處了嗎?

實際上,前面講的求“值等于給定值”的二分查找確實不怎么會被用到,二分查找更適合用在“近似”查找問題,在這類問題上,二分查找的優勢更加明顯。比如今天講的這幾種變體問題,用其他數據結構,比如散列表、二叉樹,就比較難實現了。

變體的二分查找算法寫起來非常燒腦,很容易因為細節處理不好而產生Bug,這些容易出錯的細節有:終止條件、區間上下界更新方法、返回值選擇。所以今天的內容你最好能用自己實現一遍,對鍛煉編碼能力、邏輯思維、寫出Bug free代碼,會很有幫助。

最后:

自己寫了一個NSArray+GLYLookup算法分類,只需1行代碼,即可完成快速查找。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,702評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,143評論 3 415
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,553評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,620評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,416評論 6 405
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,940評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,024評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,170評論 0 287
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,709評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,597評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,784評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,291評論 5 357
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,029評論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,407評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,663評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,403評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,746評論 2 370