面試:誰說的無序就不能用二分查詢?

nanchen2251發表於2018-07-05

在演算法面試中,面試官總是喜歡圍繞連結串列、排序、二叉樹、二分查詢來做文章,而大多數人都可以跟著專業的書籍來做到倒背如流。而面試官並不希望招收的是一位記憶功底很好,但不會活學活用的程式設計師。所以學會數學建模和分析問題,並用合理的演算法或資料結構來解決問題相當重要。

面試題:列印出旋轉陣列的最小數字

題目:把一個陣列最開始的若干個元素搬到陣列的末尾,我們稱之為陣列的旋轉。輸入一個遞增排序的陣列的一個旋轉,輸出旋轉陣列的最小元素。例如陣列 {3,4,5,1,2} 為陣列 {1,2,3,4,5} 的一個旋轉,該陣列的最小值為 1。

要想實現這個需求很簡單,我們只需要遍歷一遍陣列,找到最小的值後直接退出迴圈。程式碼實現如下:

public class Test08 {

    public static int getTheMin(int nums[]) {
        if (nums == null || nums.length == 0) {
            throw new RuntimeException("input error!");
        }
        int result = nums[0];
        for (int i = 0; i < nums.length - 1; i++) {
            if (nums[i + 1] < nums[i]) {
                result = nums[i + 1];
                break;
            }
        }
        return result;
    }

    public static void main(String[] args) {
        // 典型輸入,單調升序的陣列的一個旋轉
        int[] array1 = {3, 4, 5, 1, 2};
        System.out.println(getTheMin(array1));

        // 有重複數字,並且重複的數字剛好的最小的數字
        int[] array2 = {3, 4, 5, 1, 1, 2};
        System.out.println(getTheMin(array2));

        // 有重複數字,但重複的數字不是第一個數字和最後一個數字
        int[] array3 = {3, 4, 5, 1, 2, 2};
        System.out.println(getTheMin(array3));

        // 有重複的數字,並且重複的數字剛好是第一個數字和最後一個數字
        int[] array4 = {1, 0, 1, 1, 1};
        System.out.println(getTheMin(array4));

        // 單調升序陣列,旋轉0個元素,也就是單調升序陣列本身
        int[] array5 = {1, 2, 3, 4, 5};
        System.out.println(getTheMin(array5));

        // 陣列中只有一個數字
        int[] array6 = {2};
        System.out.println(getTheMin(array6));

        // 陣列中數字都相同
        int[] array7 = {1, 1, 1, 1, 1, 1, 1};
        System.out.println(getTheMin(array7));
    }
}
複製程式碼

列印結果沒什麼毛病。不過這樣的方法顯然不是最優的,我們看看有沒有辦法找出更加優質的方法處理。

有序,還要查詢?

找到這兩個關鍵字,我們不免會想到我們的二分查詢法,但不少小夥伴肯定會問,我們這個陣列旋轉後已經不是一個真正的有序陣列了,不過倒像是兩個遞增的陣列組合而成的,我們可以這樣思考。

我們可以設定兩個下標 low 和 high,並設定 mid = (low + high)/2,我們自然就可以找到陣列中間的元素 array[mid],如果中間的元素位於前面的遞增陣列,那麼它應該大於或者等於 low 下標對應的元素,此時陣列中最小的元素應該位於該元素的後面,我們可以把 low 下標指向該中間元素,這樣可以縮小查詢的範圍。

同樣,如果中間元素位於後面的遞增子陣列,那麼它應該小於或者等於 high 下標對應的元素。此時該陣列中最小的元素應該位於該中間元素的前面。我們就可以把 high 下標更新到中位數的下標,這樣也可以縮小查詢的範圍,移動之後的 high 下標對應的元素仍然在後面的遞增子陣列中。

不管是更新 low 還是 high,我們的查詢範圍都會縮小為原來的一半,接下來我們再用更新的下標去重複新一輪的查詢。直到最後兩個下標相鄰,也就是我們的迴圈結束條件。

說了一堆,似乎已經繞的雲裡霧裡了,我們不妨就拿題幹中的這個輸入來模擬驗證一下我們的演算法。

  1. input:{3,4,5,1,2}
  2. 此時 low = 0,high = 4,mid = 2,對應的值分別是:num[low] = 3,num[high] = 2,num[mid] = 5
  3. 由於 num[mid] > num[low],所以 num[mid] 應該是在左邊的遞增子陣列中。
  4. 更新 low = mid = 2,num[low] = 5,mid = (low+high)/2 = 3,num[mid] = 1;
  5. high - low ≠ 1 ,繼續更新
  6. 由於 num[mid] < num[high],所以斷定 num[mid] = 1 位於右邊的自增子陣列中;
  7. 更新 high = mid = 3,由於 high - mid = 1,所以結束迴圈,得到最小值 num[high] = 1;

我們再來看看 Java 中如何用程式碼實現這個思路:

public class Test08 {

    public static int getTheMin(int nums[]) {
        if (nums == null || nums.length == 0) {
            throw new RuntimeException("input error!");
        }
        // 如果只有一個元素,直接返回
        if (nums.length == 1)
            return nums[0];
        int result = nums[0];
        int low = 0, high = nums.length - 1;
        int mid;
        // 確保 low 下標對應的值在左邊的遞增子陣列,high 對應的值在右邊遞增子陣列
        while (nums[low] >= nums[high]) {
            // 確保迴圈結束條件
            if (high - low == 1) {
                return nums[high];
            }
            // 取中間位置
            mid = low + (high - low) / 2;
            // 代表中間元素在左邊遞增子陣列
            if (nums[mid] >= nums[low]) {
                low = mid;
            } else {
                high = mid;
            }
        }
        return result;
    }

    public static void main(String[] args) {
        // 典型輸入,單調升序的陣列的一個旋轉
        int[] array1 = {3, 4, 5, 1, 2};
        System.out.println(getTheMin(array1));

        // 有重複數字,並且重複的數字剛好的最小的數字
        int[] array2 = {3, 4, 5, 1, 1, 2};
        System.out.println(getTheMin(array2));

        // 有重複數字,但重複的數字不是第一個數字和最後一個數字
        int[] array3 = {3, 4, 5, 1, 2, 2};
        System.out.println(getTheMin(array3));

        // 有重複的數字,並且重複的數字剛好是第一個數字和最後一個數字
        int[] array4 = {1, 0, 1, 1, 1};
        System.out.println(getTheMin(array4));

        // 單調升序陣列,旋轉0個元素,也就是單調升序陣列本身
        int[] array5 = {1, 2, 3, 4, 5};
        System.out.println(getTheMin(array5));

        // 陣列中只有一個數字
        int[] array6 = {2};
        System.out.println(getTheMin(array6));

        // 陣列中數字都相同
        int[] array7 = {1, 1, 1, 1, 1, 1, 1};
        System.out.println(getTheMin(array7));

        // 特殊的不知道如何移動
        int[] array8 = {1, 0, 1, 1, 1};
        System.out.println(getTheMin(array8));
    }
}
複製程式碼

前面我們提到在旋轉陣列中,由於是把遞增排序陣列的前面的若干個數字搬到陣列後面,因為第一個數字總是大於或者等於最後一個數字,而還有一種特殊情況是移動了 0 個元素,即陣列本身,也是它自己的旋轉陣列。這種情況本身陣列就是有序的了,所以我們只需要返回第一個元素就好了,這也是為什麼我先給 result 賦值為 nums[0] 的原因。

上述程式碼就完美了嗎?我們通過測試用例並沒有達到我們的要求,我們具體看看 array8 這個輸入。先模擬計算機執行分析一下:

  1. low = 0, high = 4, mid = 2, nums[low] = 1, nums[high] = 1,nums[mid] = 1;
  2. 由於 nums[mid] >= nums[low],故認定 nums[mid] = 1 在左邊遞增子陣列中;
  3. 所以更新 high = mid = 2,mid = (low+high)/2 = 1;
  4. nums[low] = 1,nums[mid] = 1,nums[high] = 1;
  5. high - low ≠ 1,繼續迴圈;
  6. 由於 nums[mid] >= nums[low],故認定 nums[mid] = 1 在左邊遞增子陣列中;
  7. 所以更新 high = mid = 1,由於 high - low = 1,故退出迴圈,得到 result = 1;

但我們一眼瞭然,明顯我們的最小值不是 1 ,而是 0 ,所以當 array[low]、array[mid]、array[high] 相等的時候,我們的程式並不知道應該如何移動,按照目前的移動方式就預設 array[mid] 在左邊遞增子陣列了,這顯然是不負責任的做法。

我們修正一下程式碼:

public class Test08 {

    public static int getTheMin(int nums[]) {
        if (nums == null || nums.length == 0) {
            throw new RuntimeException("input error!");
        }
        // 如果只有一個元素,直接返回
        if (nums.length == 1)
            return nums[0];
        int result = nums[0];
        int low = 0, high = nums.length - 1;
        int mid = low;
        // 確保 low 下標對應的值在左邊的遞增子陣列,high 對應的值在右邊遞增子陣列
        while (nums[low] >= nums[high]) {
            // 確保迴圈結束條件
            if (high - low == 1) {
                return nums[high];
            }
            // 取中間位置
            mid = (low + high) / 2;
            // 三值相等的特殊情況,則需要從頭到尾查詢最小的值
            if (nums[mid] == nums[low] && nums[mid] == nums[high]) {
                return midInorder(nums, low, high);
            }
            // 代表中間元素在左邊遞增子陣列
            if (nums[mid] >= nums[low]) {
                low = mid;
            } else {
                high = mid;
            }
        }
        return result;
    }

    /**
     * 查詢陣列中的最小值
     *
     * @param nums  陣列
     * @param start 陣列開始位置
     * @param end   陣列結束位置
     * @return 找到的最小的數字
     */
    public static int midInorder(int[] nums, int start, int end) {
        int result = nums[start];
        for (int i = start + 1; i <= end; i++) {
            if (result > nums[i])
                result = nums[i];
        }
        return result;
    }

    public static void main(String[] args) {
        // 典型輸入,單調升序的陣列的一個旋轉
        int[] array1 = {3, 4, 5, 1, 2};
        System.out.println(getTheMin(array1));

        // 有重複數字,並且重複的數字剛好的最小的數字
        int[] array2 = {3, 4, 5, 1, 1, 2};
        System.out.println(getTheMin(array2));

        // 有重複數字,但重複的數字不是第一個數字和最後一個數字
        int[] array3 = {3, 4, 5, 1, 2, 2};
        System.out.println(getTheMin(array3));

        // 有重複的數字,並且重複的數字剛好是第一個數字和最後一個數字
        int[] array4 = {1, 0, 1, 1, 1};
        System.out.println(getTheMin(array4));

        // 單調升序陣列,旋轉0個元素,也就是單調升序陣列本身
        int[] array5 = {1, 2, 3, 4, 5};
        System.out.println(getTheMin(array5));

        // 陣列中只有一個數字
        int[] array6 = {2};
        System.out.println(getTheMin(array6));

        // 陣列中數字都相同
        int[] array7 = {1, 1, 1, 1, 1, 1, 1};
        System.out.println(getTheMin(array7));

        // 特殊的不知道如何移動
        int[] array8 = {1, 0, 1, 1, 1};
        System.out.println(getTheMin(array8));

    }
}
複製程式碼

我們再用完善的測試用例放進去,測試通過。

總結

本題其實考察的點挺多的,實際上就是考察對二分查詢的靈活運用,不少小夥伴死記硬背二分查詢必須遵從有序,而沒有學會這個二分查詢的思想,這樣會導致只能想到迴圈查詢最小值了。

不少小夥伴在面試中表態,Android 原生態基本都封裝了常用演算法,對面試這些無作用的演算法表示抗議,其實這是相當愚蠢的。我們不求死記硬背演算法的實現,但求學習到其中巧妙的思想。只有不斷地提升自己的思維能力,才能助自己收穫更好的職業發展。

這也大概是大家一直到處叫大佬,埋怨自己工資總是跟不上別人的一方面原因吧。

我是南塵,只做比心的公眾號,歡迎關注我。

面試:誰說的無序就不能用二分查詢?
做不完的開源,寫不完的矯情。歡迎掃描下方二維碼或者公眾號搜尋「nanchen」關注我的微信公眾號,目前多運營 Android ,儘自己所能為你提升。如果你喜歡,為我點贊分享吧~
nanchen

相關文章