二分查找的實際應用


一、什么是二分查找?

  二分查找針對的是一個有序的數據集合,每次通過跟區間中間的元素對比,將待查找的區間縮小為之前的一半,直到找到要查找的元素,或者區間縮小為0。

 

二、驚人的查找速度 O(logn)

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

  可以看出來,這是一個等比數列。其中 n/2k=1 時,k 的值就是總共縮小的次數。而每一次縮小操作只涉及兩個數據的大小比較,所以,經過了 k 次區間縮小操作,時間復雜度就是 O(k)。通過 n/2k=1,我們可以求得 k=log_2n,所以時間復雜度就是 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) 的算法執行效率高。

  反過來,對數對應的就是指數。有一個非常著名的“阿基米德與國王下棋的故事”,你可以自行搜索一下,感受一下指數的”恐怖”。這也是為什么我們說,指數級時間復雜度的算法在大規模數據面前是無效的。

 

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

  最簡單的情況就是有序數組中不存在重復元素,使用二分查找值等於給定值的數據。代碼如下:

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;
}

着重強調一下容易出錯的 3 個地方。

  1. 循環退出條件:注意是 low<=high,而不是 low<high
  1. mid 的取值:mid=(low+high)/2 這種寫法是有問題的。因為如果 low 和 high 比較大的話,兩者之和就有可能會溢出。改進的方法是將 mid 的計算方式寫成 low+(high-low)/2。更進一步,如果要將性能優化到極致的話,我們可以將這里的除以 2 操作轉化成位運算 low+((high-low)>>1)。因為相比除法運算來說,計算機處理位運算要快得多。
  1. low 和 high 的更新:low=mid+1,high=mid-1。注意這里的 +1 和 -1,如果直接寫成 low=mid 或者 high=mid,就可能會發生死循環。比如,當 high=3,low=3 時,如果 a[3] 不等於value,就會導致一直循環不退出。

結合以上三點用遞歸實現,代碼如下:

// 二分查找的遞歸實現
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(nlogn)。所以,如果我們針對的是一組靜態的數據,沒有頻繁地插入、刪除,我們可以進行一次排序,多次二分查找。這樣排序的成本可被均攤,二分查找的邊際成本就會比較低。

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

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

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

  數據量很小時,順序遍歷就足夠了,完全沒有必要用二分查找。只有數據量比較大的時候,二分查找的優勢才會比較明顯。

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

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

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

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

五、如何在 1000 萬個整數中快速查找某個整數?

  我們的內存限制是 100MB,每個數據大小是 8 字節,最簡單的辦法就是將數據存儲在數組中,內存占用差不多是 80MB,符合內存的限制。我們可以先對這 1000 萬數據從小到大排序,然后再利用二分查找算法,就可以快速地查找想要的數據了。

 

六、二分查找之變形問題

1、查找第一個值等於給定值的元素

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

    

  極致簡潔的寫法:

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;}
}

  易懂的寫法:

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;
}

2、查找最后一個值等於給定值的元素

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;
}

3、查找第一個大於等於給定值的元素

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;
}

4、查找最后一個小於等於給定值的元素

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;
}

  

七、不用庫函數求一個數的平方根

  (1)采用 牛頓迭代法。

    (2)牛頓迭代法簡介

               假設方程 在  附近有一個根,那么用以下迭代式子:
                                         
       依次計算、……,那么序列將無限逼近方程的根。

                牛頓迭代法的原理很簡單,其實是根據f(x)在x0附近的值和斜率,估計f(x)和x軸的交點,看下面的動態圖:

                                 

     (3)用牛頓迭代法開平方

                   令:                        
                  所以f(x)的一次導是 : 
                  牛頓迭代式:
                                              

                  隨便一個迭代的初始值,例如,代入上面的式子迭代。

                  例如計算,即a=2:
                                
                                
                               

     (4)代碼

public class Sqr {
    public static void main(String[] args) {
        // TODO 自動生成的方法存根
        Scanner scan = new Scanner(System.in);

        DecimalFormat df = new DecimalFormat("#.000");
        int sc = scan.nextInt();
        System.out.print(sc + "的算術平方根是:");
        System.out.println(df.format(SQR(sc)));
    }
    public static double SQR(int a) {
        double x1 = 1, x2;
        x2 = x1 / 2.0 + a / (2 * x1);//牛頓迭代公式
        while (Math.abs(x2 - x1) > 1e-4) {
            x1 = x2;
            x2 = x1 / 2.0 + a / (2 * x1);
        }
        return x2;
    }
}

  

  二分法解答,代碼如下:

public class SqrtUtil {
    /**
     * 方法1 精確度差
     * @param data
     * @return
     */
    public static double getSquareRoot(int data){
        if(data < 0){
            return -1;
        }
        double low = 0.0;
        double high = data;
        double mid = 0.0;
        while(isNumOfDigLessThenInput(mid, 6) ){
            mid = low + ((high - low) / 2);
            if(data == mid*mid){
                return mid;
            }else if(data < mid*mid){
                high = mid;
            }else{
                low = mid;
            }
        }
        return mid;
    }

    private static boolean isNumOfDigLessThenInput(double data,int num){
        String dataStr = String.valueOf(data);
        int index = dataStr.indexOf(".");
        if(index == -1){
            return true;
        }
        int numofDig = dataStr.length() - index;
        return numofDig <= 6;
    }

    /**
     * 方法2,易於理解,精確度高
     * @param a
     * @return
     */
    public static Double squareRoot(int a){
        double x = 0;
        double low = 0;
        double high = a;
        while(low<=high){
            x = (low+high)/2;
            if(x>a/x){
                high = x-0.000001;
            }
            //防止溢出
            if(x<a/x){
                low = x+0.000001;
            }
            if(x==a/x){
                //剛好整除
                return x+0.000001;
            }
        }
        //精確到六位小數
        return new BigDecimal(x).setScale(6, BigDecimal.ROUND_HALF_UP).doubleValue();
    }
    public static void main(String[] args) {
        System.out.println( SqrtUtil.squareRoot(5));
    }
}

  

八、為什么二分法使用數組作為數據結構,鏈表可以嗎?

  • 二分查找 數組查詢時間復雜度 O(logn)。

  • 假設鏈表長度為n,二分查找每次都要找到中間點(計算中忽略奇偶數差異):
    第一次查找中間點,需要移動指針n/2次;
    第二次,需要移動指針n/4次;
    第三次需要移動指針n/8次;
    ......
    以此類推,一直到1次為值

  • 總共指針移動次數(查找次數) = n/2 + n/4 + n/8 + ...+ 1,這顯然是個等比數列,根據等比數列求和公式:Sum = n - 1.

  • 最后算法時間復雜度是:O(n-1),忽略常數,記為O(n),時間復雜度和順序查找時間復雜度相同

  • 但是稍微思考下,在二分查找的時候,由於要進行多余的運算,嚴格來說,會比順序查找時間慢

九、一個循環有序數組,比如 4,5,6,1,2,3,如何實現一個求“值等於給定值”的二分查找算法呢?

class Solution {
    public int search(int[] nums, int target) {
        int left = 0;
        int right = nums.length-1;
        int mid = left + (right-left)/2;
        while(left <= right){
            if(nums[mid] == target){
                return mid;
            }
            if(nums[left] <= nums[mid]){  //左邊升序
                if(target >= nums[left] && target <= nums[mid]){//在左邊范圍內
                    right = mid-1;
                }else{//只能從右邊找
                    left = mid+1;
                }
            }else{ //右邊升序
                if(target >= nums[mid] && target <= nums[right]){//在右邊范圍內
                    left = mid +1;
                }else{//只能從左邊找
                    right = mid-1;
                }
            }
            mid = left + (right-left)/2;
        }
        return -1;  //沒找到
    }
}

  

參考文獻:

  https://www.cnblogs.com/hezhiyao/p/7544593.html

  https://time.geekbang.org/column/article/42520

  https://www.jianshu.com/p/4d0f476af2b7

 


免責聲明!

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



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