15 | 二分查詢(上):如何用最省記憶體的方式實現快速查詢功能?
二分查詢(上):如何用最省記憶體的方式實現快速查詢功能?
今天我們講一種針對有序資料集合的查詢演算法:二分查詢(Binary Search)演算法,也叫折半查詢演算法。二分查詢的思想非常簡單,很多非計算機專業的同學很容易就能理解,但是看似越簡單的東西往往越難掌握好,想要靈活應用就更加困難。
老規矩,我們還是來看一道思考題。
假設我們有 1000 萬個整數資料,每個資料佔 8 個位元組,如何設計資料結構和演算法,快速判斷某個整數是否出現在這 1000 萬資料中? 我們希望這個功能不要佔用太多的記憶體空間,最多不要超過 100MB,你會怎麼做呢?帶著這個問題,讓我們進入今天的內容吧!
無處不在的二分思想
二分查詢是一種非常簡單易懂的快速查詢演算法,生活中到處可見。比如說,我們現在來做一個猜字遊戲。我隨機寫一個 0 到 99 之間的數字,然後你來猜我寫的是什麼。猜的過程中,你每猜一次,我就會告訴你猜的大了還是小了,直到猜中為止。你來想想,如何快速猜中我寫的數字呢?
假設我寫的數字是 23,你可以按照下面的步驟來試一試。(如果猜測範圍的數字有偶數個,中間數有兩個,就選擇較小的那個。)
7 次就猜出來了,是不是很快?這個例子用的就是二分思想,按照這個思想,即便我讓你猜的是 0 到 999 的數字,最多也只要 10 次就能猜中。不信的話,你可以試一試。
這是一個生活中的例子,我們現在回到實際的開發場景中。假設有 1000 條訂單資料,已經按照訂單金額從小到大排序,每個訂單金額都不同,並且最小單位是元。我們現在想知道是否存在金額等於 19 元的訂單。如果存在,則返回訂單資料,如果不存在則返回 null。
最簡單的辦法當然是從第一個訂單開始,一個一個遍歷這 1000 個訂單,直到找到金額等於 19 元的訂單為止。但這樣查詢會比較慢,最壞情況下,可能要遍歷完這 1000 條記錄才能找到。那用二分查詢能不能更快速地解決呢?
為了方便講解,我們假設只有 10 個訂單,訂單金額分別是:8,11,19,23,27,33,45,55,67,98。
還是利用二分思想,每次都與區間的中間資料比對大小,縮小查詢區間的範圍。為了更加直觀,我畫了一張查詢過程的圖。其中,low 和 high 表示待查詢區間的下標,mid 表示待查詢區間的中間元素下標。
看懂這兩個例子,你現在對二分的思想應該掌握得妥妥的了。我這裡稍微總結昇華一下,二分查詢針對的是一個有序的資料集合,查詢思想有點類似分治思想。每次都通過跟區間的中間元素對比,將待查詢的區間縮小為之前的一半,直到找到要查詢的元素,或者區間被縮小為 0。
O(logn) 驚人的查詢速度
二分查詢是一種非常高效的查詢演算法,高效到什麼程度呢?我們來分析一下它的時間複雜度。
我們假設資料大小是 n,每次查詢後資料都會縮小為原來的一半,也就是會除以 2。最壞情況下,直到查詢區間被縮小為空,才停止。
可以看出來,這是一個等比數列。其中 n/2k=1 時,k 的值就是總共縮小的次數。而每一次縮小操作只涉及兩個資料的大小比較,所以,經過了 k 次區間縮小操作,時間複雜度就是 O(k)。通過 ,我們可以求得 ,所以時間複雜度就是 O(logn)。
二分查詢是我們目前為止遇到的第一個時間複雜度為 O(logn) 的演算法。後面章節我們還會講堆、二叉樹的操作等等,它們的時間複雜度也是 O(logn)。我這裡就再深入地講講 O(logn) 這種對數時間複雜度。這是一種極其高效的時間複雜度,有的時候甚至比時間複雜度是常量級 O(1) 的演算法還要高效。為什麼這麼說呢?
因為 logn 是一個非常“恐怖”的數量級,即便 n 非常非常大,對應的 logn 也很小。比如 n 等於 2 的 32 次方,這個數很大了吧?大約是 42 億。也就是說,如果我們在 42 億個資料中用二分查詢一個資料,最多需要比較 32 次。
我們前面講過,用大 O 標記法表示時間複雜度的時候,會省略掉常數、係數和低階。對於常量級時間複雜度的演算法來說,O(1) 有可能表示的是一個非常大的常量值,比如 O(1000)、O(10000)。所以,常量級時間複雜度的演算法有時候可能還沒有 O(logn) 的演算法執行效率高。
反過來,對數對應的就是指數。有一個非常著名的“阿基米德與國王下棋的故事”,你可以自行搜尋一下,感受一下指數的“恐怖”。這也是為什麼我們說,指數時間複雜度的演算法在大規模資料面前是無效的。
二分查詢的遞迴與非遞迴實現
實際上,簡單的二分查詢並不難寫,注意我這裡的“簡單”二字。下一節,我們會講到二分查詢的變體問題,那才是真正燒腦的。今天,我們來看如何來寫最簡單的二分查詢。
最簡單的情況就是有序陣列中不存在重複元素,我們在其中用二分查詢值等於給定值的資料。我用 Java 程式碼實現了一個最簡單的二分查詢演算法。
public int bsearch(int[] a, int n, int value) {
int low = 0;
int high = n - 1;
while (low <= high) {
int mid = (low + high) / 2;
if (a[mid] == value) {
return mid;
} else if (a[mid] < value) {
low = mid + 1;
} else {
high = mid - 1;
}
}
return -1;
}
這個程式碼我稍微解釋一下,low、high、mid 都是指陣列下標,其中 low 和 high 表示當前查詢的區間範圍,初始 low=0, high=n-1。mid 表示 [low, high] 的中間位置。我們通過對比 a[mid] 與 value 的大小,來更新接下來要查詢的區間範圍,直到找到或者區間縮小為 0,就退出。如果你有一些程式設計基礎,看懂這些應該不成問題。現在,我就著重強調一下容易出錯的 3 個地方。
- 迴圈退出條件
注意是 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,就會導致一直迴圈不退出。
如果你留意我剛講的這三點,我想一個簡單的二分查詢你已經可以實現了。實際上,二分查詢除了用迴圈來實現,還可以用遞迴來實現,過程也非常簡單。
我用 Java 語言實現了一下這個過程,正好你可以藉此機會回顧一下寫遞迴程式碼的技巧
// 二分查詢的遞迴實現
//val是要查詢的數字,n為陣列的長度,a為要查詢的陣列
public int bsearch(int[] a, int n, int val) {
return bsearchInternally(a, 0, n - 1, val);
}
private int bsearchInternally(int[] a, int low, int high, int value) {
if (low > high) return -1;
int mid = low + ((high - low) >> 1);
if (a[mid] == value) {
return mid;
} else if (a[mid] < value) {
return bsearchInternally(a, mid+1, high, value);
} else {
return bsearchInternally(a, low, mid-1, value);
}
}
二分查詢應用場景的侷限性
前面我們分析過,二分查詢的時間複雜度是 O(logn),查詢資料的效率非常高。不過,並不是什麼情況下都可以用二分查詢,它的應用場景是有很大侷限性的。那什麼情況下適合用二分查詢,什麼情況下不適合呢?
首先,二分查詢依賴的是順序表結構,簡單點說就是陣列。
那二分查詢能否依賴其他資料結構呢?比如連結串列。答案是不可以的,主要原因是二分查詢演算法需要按照下標隨機訪問元素。我們在陣列和連結串列那兩節講過,陣列按照下標隨機訪問資料的時間複雜度是 O(1),而連結串列隨機訪問的時間複雜度是 O(n)。所以,如果資料使用連結串列儲存,二分查詢的時間複雜就會變得很高。
二分查詢只能用在資料是通過順序表來儲存的資料結構上。如果你的資料是通過其他資料結構儲存的,則無法應用二分查詢。
其次,二分查詢針對的是有序資料。
二分查詢對這一點的要求比較苛刻,資料必須是有序的。如果資料沒有序,我們需要先排序。前面章節裡我們講到,排序的時間複雜度最低是 O(nlogn)。所以,如果我們針對的是一組靜態的資料,沒有頻繁地插入、刪除,我們可以進行一次排序,多次二分查詢。這樣排序的成本可被均攤,二分查詢的邊際成本就會比較低。
但是,如果我們的資料集合有頻繁的插入和刪除操作,要想用二分查詢,要麼每次插入、刪除操作之後保證資料仍然有序,要麼在每次二分查詢之前都先進行排序。針對這種動態資料集合,無論哪種方法,維護有序的成本都是很高的。
所以,二分查詢只能用在插入、刪除操作不頻繁,一次排序多次查詢的場景中。針對動態變化的資料集合,二分查詢將不再適用。那針對動態資料集合,如何在其中快速查詢某個資料呢?別急,等到二叉樹那一節我會詳細講。
再次,資料量太小不適合二分查詢。
如果要處理的資料量很小,完全沒有必要用二分查詢,順序遍歷就足夠了。比如我們在一個大小為 10 的陣列中查詢一個元素,不管用二分查詢還是順序遍歷,查詢速度都差不多。只有資料量比較大的時候,二分查詢的優勢才會比較明顯。
不過,這裡有一個例外。如果資料之間的比較操作非常耗時,不管資料量大小,我都推薦使用二分查詢。比如,陣列中儲存的都是長度超過 300 的字串,如此長的兩個字串之間比對大小,就會非常耗時。我們需要儘可能地減少比較次數,而比較次數的減少會大大提高效能,這個時候二分查詢就比順序遍歷更有優勢。
最後,資料量太大也不適合二分查詢。
二分查詢的底層需要依賴陣列這種資料結構,而陣列為了支援隨機訪問的特性,要求記憶體空間連續,對記憶體的要求比較苛刻。比如,我們有 1GB 大小的資料,如果希望用陣列來儲存,那就需要 1GB 的連續記憶體空間。
注意這裡的“連續”二字,也就是說,即便有 2GB 的記憶體空間剩餘,但是如果這剩餘的 2GB 記憶體空間都是零散的,沒有連續的 1GB 大小的記憶體空間,那照樣無法申請一個 1GB 大小的陣列。而我們的二分查詢是作用在陣列這種資料結構之上的,所以太大的資料用陣列儲存就比較吃力了,也就不能用二分查詢了。
解答開篇
二分查詢的理論知識你應該已經掌握了。我們來看下開篇的思考題:如何在 1000 萬個整數中快速查詢某個整數?
這個問題並不難。我們的記憶體限制是 100MB,每個資料大小是 8 位元組,最簡單的辦法就是將資料儲存在陣列中,記憶體佔用差不多是 80MB,符合記憶體的限制。藉助今天講的內容,我們可以先對這 1000 萬資料從小到大排序,然後再利用二分查詢演算法,就可以快速地查詢想要的資料了。
看起來這個問題並不難,很輕鬆就能解決。實際上,它暗藏了“玄機”。如果你對資料結構和演算法有一定了解,知道雜湊表、二叉樹這些支援快速查詢的動態資料結構。你可能會覺得,用雜湊表和二叉樹也可以解決這個問題。實際上是不行的。
雖然大部分情況下,用二分查詢可以解決的問題,用雜湊表、二叉樹都可以解決。但是,我們後面會講,不管是雜湊表還是二叉樹,都會需要比較多的額外的記憶體空間。如果用雜湊表或者二叉樹來儲存這 1000 萬的資料,用 100MB 的記憶體肯定是存不下的。而二分查詢底層依賴的是陣列,除了資料本身之外,不需要額外儲存其他資訊,是最省記憶體空間的儲存方式,所以剛好能在限定的記憶體大小下解決這個問題。
內容小結
今天我們學習了一種針對有序資料的高效查詢演算法,二分查詢,它的時間複雜度是 O(logn)。
二分查詢的核心思想理解起來非常簡單,有點類似分治思想。即每次都通過跟區間中的中間元素對比,將待查詢的區間縮小為一半,直到找到要查詢的元素,或者區間被縮小為 0。但是二分查詢的程式碼實現比較容易寫錯。你需要著重掌握它的三個容易出錯的地方:迴圈退出條件、mid 的取值,low 和 high 的更新。
二分查詢雖然效能比較優秀,但應用場景也比較有限。底層必須依賴陣列,並且還要求資料是有序的。對於較小規模的資料查詢,我們直接使用順序遍歷就可以了,二分查詢的優勢並不明顯。二分查詢更適合處理靜態資料,也就是沒有頻繁的資料插入、刪除操作。
課後思考
如何程式設計實現“求一個數的平方根”?要求精確到小數點後 6 位。
我剛才說了,如果資料使用連結串列儲存,二分查詢的時間複雜就會變得很高,那查詢的時間複雜度究竟是多少呢?如果你自己推導一下,你就會深刻地認識到,為何我們會選擇用陣列而不是連結串列來實現二分查詢了。
經典回覆
二分法求一個數x的平方根y?
解答:根據x的值,判斷求解值y的取值範圍。假設求解值範圍min < y < max。若0<x<1,則min=x,max=1;若x=1,則y=1;x>1,則min=1,max=x;在確定了求解範圍之後,利用二分法在求解值的範圍中取一箇中間值middle=(min+max)÷2,判斷middle是否是x的平方根?若(middle+0.000001)(middle+0.000001)>x且(middle-0.000001)(middle-0.000001)<x,根據介值定理,可知middle既是求解值;若middlemiddle > x,表示middle>實際求解值,max=middle; 若middlemiddle < x,表示middle<實際求解值,min =middle;之後遞迴求解!
備註:因為是保留6位小數,所以middle上下浮動0.000001用於介值定理的判斷
說說第二題吧,感覺爭議比較大:
假設連結串列長度為n,二分查詢每次都要找到中間點(計算中忽略奇偶數差異):
第一次查詢中間點,需要移動指標n/2次;
第二次,需要移動指標n/4次;
第三次需要移動指標n/8次;
......
以此類推,一直到1次為值
總共指標移動次數(查詢次數) = n/2 + n/4 + n/8 + ...+ 1,這顯然是個等比數列,根據等比數列求和公式:Sum = n - 1.
最後演算法時間複雜度是:O(n-1),忽略常數,記為O(n),時間複雜度和順序查詢時間複雜度相同
但是稍微思考下,在二分查詢的時候,由於要進行多餘的運算,嚴格來說,會比順序查詢時間慢
個人覺得二分查詢進行優化時,還個細節注意:
將mid = lo + (hi - lo) /2,將除法優化成移位運算時,得注意運算子的優先順序,千萬不能寫成這樣:mid = lo + (hi - lo) >> 1
相關文章
- Python 實現二分法快速查詢Python
- PHP 實現二分查詢PHP
- Golang實現二分查詢法Golang
- 二分查詢(函式實現)函式
- Js實現二分查詢,加油JS
- 查詢——二分查詢
- 查詢賬單功能的實現
- CQengine高效能記憶體資料快取查詢框架記憶體快取框架
- 二分查詢實現符號表符號
- 二分查詢(一)——純粹的二分查詢
- excel表格查詢功能在哪裡 excel表格怎麼快速查詢Excel
- Java實現二分查詢演算法Java演算法
- 查詢演算法__二分查詢演算法
- 查詢windows記憶體卡槽及卡槽支援的最大記憶體Windows記憶體
- 二分查詢
- 二分查詢 | 二分查詢的一種推薦寫法
- 二分法查詢(遞迴實現)遞迴
- 查詢演算法之二分查詢演算法
- 想要批次查詢快遞,怎麼操作可以一鍵快速查詢物流資訊
- [20190202]使用smem查詢oracle記憶體使用.txtOracle記憶體
- SSH:hiberate實現資料的查詢(單查詢和全查詢)
- 二分查詢法
- PHP二分查詢PHP
- leetcode——二分查詢LeetCode
- leetcode -- 二分查詢LeetCode
- 二分查詢的兩種實現形式遞迴和迭代遞迴
- 陣列的查詢(搜尋):線性查詢和二分法查詢陣列
- 二分查詢的定義
- 二分查詢【折半查詢】演算法 PHP 版演算法PHP
- SSH整合實現分頁查詢(兩種方式)
- 分析高效記憶體池的實現方式記憶體
- 快遞到哪了怎麼查詢?有上百單怎麼樣可以快速查詢?
- [翻譯]查詢Windows記憶體洩露的幾種方法Windows記憶體洩露
- 轉載 ]查詢Windows記憶體洩露的幾種方法Windows記憶體洩露
- labuladong_二分查詢
- 二分查詢(c++)C++
- 704.二分查詢
- 詳解二分查詢