數據結構與算法_16 _ 二分查找(下):如何快速定位IP對應的省份地址


通過IP地址來查找IP歸屬地的功能,不知道你有沒有用過?沒用過也沒關系,你現在可以打開百度,在搜索框里隨便輸一個IP地址,就會看到它的歸屬地。

這個功能並不復雜,它是通過維護一個很大的IP地址庫來實現的。地址庫中包括IP地址范圍和歸屬地的對應關系。

當我們想要查詢202.102.133.13這個IP地址的歸屬地時,我們就在地址庫中搜索,發現這個IP地址落在[202.102.133.0, 202.102.133.255]這個地址范圍內,那我們就可以將這個IP地址范圍對應的歸屬地“山東東營市”顯示給用戶了。

[202.102.133.0, 202.102.133.255]  山東東營市 
[202.102.135.0, 202.102.136.255]  山東煙台 
[202.102.156.34, 202.102.157.255] 山東青島 
[202.102.48.0, 202.102.48.255] 江蘇宿遷 
[202.102.49.15, 202.102.51.251] 江蘇泰州 
[202.102.56.0, 202.102.56.255] 江蘇連雲港

現在我的問題是,在龐大的地址庫中逐一比對IP地址所在的區間,是非常耗時的。假設我們有12萬條這樣的IP區間與歸屬地的對應關系,如何快速定位出一個IP地址的歸屬地呢?

是不是覺得比較難?不要緊,等學完今天的內容,你就會發現這個問題其實很簡單。

上一節我講了二分查找的原理,並且介紹了最簡單的一種二分查找的代碼實現。今天我們來講幾種二分查找的變形問題。

不知道你有沒有聽過這樣一個說法:“十個二分九個錯”。二分查找雖然原理極其簡單,但是想要寫出沒有Bug的二分查找並不容易。

唐納德·克努特(Donald E.Knuth)在《計算機程序設計藝術》的第3卷《排序和查找》中說到:“盡管第一個二分查找算法於1946年出現,然而第一個完全正確的二分查找算法實現直到1962年才出現。”

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

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

需要特別說明一點,為了簡化講解,今天的內容,我都以數據是從小到大排列為前提,如果你要處理的數據是從大到小排列的,解決思路也是一樣的。同時,我希望你最好先自己動手試着寫一下這4個變形問題,然后再看我的講述,這樣你就會對我說的“二分查找比較難寫”有更加深的體會了。

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

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

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

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

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

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

public int bsearch(int[] a, int n, int value) {
  int low = 0;
  int high = n - 1;
  while (low <= high) {
    int mid = low + ((high - low) >> 1);
    if (a[mid] >= value) {
      high = mid - 1;
    } else {
      low = mid + 1;
    }
  }

if (low < n && a[low]==value) return low;
else return -1;
}

看完這個實現之后,你是不是覺得很不好理解?如果你只是死記硬背這個寫法,我敢保證,過不了幾天,你就會全都忘光,再讓你寫,90%的可能會寫錯。所以,我換了一種實現方法,你看看是不是更容易理解呢?

public int bsearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + ((high - low) >> 1);
if (a[mid] > value) {
high = mid - 1;
} else if (a[mid] < value) {
low = mid + 1;
} else {
if ((mid == 0) || (a[mid - 1] != value)) return mid;
else high = mid - 1;
}
}
return -1;
}

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

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

我們重點看第11行代碼。如果mid等於0,那這個元素已經是數組的第一個元素,那它肯定是我們要找的;如果mid不等於0,但a[mid]的前一個元素a[mid-1]不等於value,那也說明a[mid]就是我們要找的第一個值等於給定值的元素。

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

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

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

前面的問題是查找第一個值等於給定值的元素,我現在把問題稍微改一下,查找最后一個值等於給定值的元素,又該如何做呢?

如果你掌握了前面的寫法,那這個問題你應該很輕松就能解決。你可以先試着實現一下,然后跟我寫的對比一下。

public int bsearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + ((high - low) >> 1);
if (a[mid] > value) {
high = mid - 1;
} else if (a[mid] < value) {
low = mid + 1;
} else {
if ((mid == n - 1) || (a[mid + 1] != value)) 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。

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

public int bsearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + ((high - low) >> 1);
if (a[mid] >= value) {
if ((mid == 0) || (a[mid - 1] < value)) return mid;
else high = mid - 1;
} else {
low = mid + 1;
}
}
return -1;
}

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

對於a[mid]大於等於給定值value的情況,我們要先看下這個a[mid]是不是我們要找的第一個值大於等於給定值的元素。如果a[mid]前面已經沒有元素,或者前面一個元素小於要查找的值value,那a[mid]就是我們要找的元素。這段邏輯對應的代碼是第7行。

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

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

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

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

public int bsearch7(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = low + ((high - low) >> 1);
if (a[mid] > value) {
high = mid - 1;
} else {
if ((mid == n - 1) || (a[mid + 1] > value)) return mid;
else low = mid + 1;
}
}
return -1;
}

解答開篇

好了,現在我們回頭來看開篇的問題:如何快速定位出一個IP地址的歸屬地?

現在這個問題應該很簡單了。如果IP區間與歸屬地的對應關系不經常更新,我們可以先預處理這12萬條數據,讓其按照起始IP從小到大排序。如何來排序呢?我們知道,IP地址可以轉化為32位的整型數。所以,我們可以將起始地址,按照對應的整型值的大小關系,從小到大進行排序。

然后,這個問題就可以轉化為我剛講的第四種變形問題“在有序數組中,查找最后一個小於等於某個給定值的元素”了。

當我們要查詢某個IP歸屬地時,我們可以先通過二分查找,找到最后一個起始IP小於等於這個IP的IP區間,然后,檢查這個IP是否在這個IP區間內,如果在,我們就取出對應的歸屬地顯示;如果不在,就返回未查找到。

內容小結

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

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

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

課后思考

我們今天講的都是非常規的二分查找問題,今天的思考題也是一個非常規的二分查找問題。如果有序數組是一個循環有序數組,比如4,5,6,1,2,3。針對這種情況,如何實現一個求“值等於給定值”的二分查找算法呢?

歡迎留言和我分享,我會第一時間給你反饋。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM