「劍指Offer」第 51 題:數組中的逆序數(分治思想,草稿)

大家好,這裏是「力扣」視頻題解。今天要和大家分享的是面試題第 51 題:數組中的逆序對。

這道題雖然是標註爲 hard 的一個問題,但其實它一個非常經典的問題,如果一開始沒有思路的話,可以把它當做一道例題來學習。

這道題要求我們計算一個數組中「逆序對」的個數。

什麼是「逆序對」呢? 我們看示例數組裏的 554455 排在 44 的前面,55 大於 44 ,因此它們構成了一個逆序對。

「逆序對」的個數反映了一個數組的有序程度,有兩個很特殊的例子:

1、對於「順序數組」來說,任意抽出的兩個數字都不存在逆序關係,所以任意順序數組的逆序對的個數就爲 00

例如:[1, 2, 3, 4, 5]

2、而對於「逆序數組」來說,任意抽出的兩個數字都構成了逆序關係,所以逆序數組的逆序對的個數就達到了最大。

這個數值就等於:在每個元素之後的「所有元素的」個數之和。

例子:[5, 4, 3, 2, 1]


(下一頁)

對於這個問題:一個非常容易想到的方法是,使用兩個 for 循環,枚舉所有不同下標的數對,只要發現一對逆序關係,就給計數器加 11

這個方法我們稱之爲暴力解法或者是依據定義的解法,時間複雜度是 O(N2)O(N^2),空間複雜度是 O(1)O(1)

這個方法的缺點是顯而易見的:我們在每一次比較的過程中,不管 比較的結果構成了順序對還是逆序對,都沒有有被記錄下來。也就是說:之前比較的結果不能爲以後的比較提供有用的信息。

Java 代碼:

public class Solution {

    public int reversePairs(int[] nums) {
        int len = nums.length;

        int res = 0;
        for (int i = 0; i < len - 1; i++) {
            for (int j = i + 1; j < len; j++) {
                if (nums[i] > nums[j]) {
                    res++;
                }
            }
        }
        return res;
    }
}

怎麼加速這個過程呢?

其實隱含在我們剛剛向大家介紹,計算逆序數組的「逆序對」的那個例子裏。正是因爲我們知道了這個數組裏的元素的大小關係,我們就可以一下子計算出:

  • 與數字 55 構成逆序對的元素的個數;
  • 與數字 44 構成逆序對的元素的個數;

依次加下去,而不用一個一個地去比較。


以上兩點事實告訴我們:在計算逆序對的過程中,掌握元素的大小關係可以加快計算

而掌握元素的大小關係就需要在計算逆序對的過程中,對已經看到的數字做一些順序上的調整

在高級的排序算法(歸併排序、快速排序)裏,能夠看到非常明顯的「階段排序效果 」的算法就是歸併排序。

說到這裏,大家不妨暫停一下視頻。想一想如何利用「歸併排序」算法,在給數組排序的過程中,計算出逆序對的個數。


我們來看「歸併排序」的一個關鍵步驟:「合併兩個有序數組」。

在這裏我們單獨看這樣一個過程:考察的是兩個緊挨着的有序子區間,然後將它們合併成爲一個更長的有序區間。

我們以

[2, 3, 5, 7, 1, 4, 6, 8] 

爲例。

這個數組的前半部分與後半部分,均是有序的。

  • 我們在合併之前,首先需要把它們拷貝到一個新的空間;

  • 然後使用兩個指針變量 ij 分別指向兩個有序子區間的第 1 個位置,再使用一個指針變量 k 指向合併回去的那個數組的第 1 個位置;

  • 我們的規則是:ij 指向的元素誰小,誰就先合併回去。

(具體)

  • 先看 2211 的大小,2211 大,11 先合併回去。由於兩個子區間都是有序的,我們就知道了,11 比它之前的所有元素都小,也就是 11 與它之前的所有的元素都構成了逆序關係,我們就可以一下子給計數器加上 44

這就是在合併的過程中,能夠加速計算逆序數的原因。我們利用了數組的部分有序性,而不用一個一個地去比較。

11 合併回去以後,比較 22442244 小,在 22 合併回去的同時,我們可以觀察到 22 點:

  • 第 1 :22 與之前合併回去的數 11 構成了逆序對,只不過我們在剛纔,把 11 合併回去的時候,已經把 2211 的逆序關係計算了一次,因此在這一步我們沒有必要再計算一遍;

  • 第 2 :2211 之後的所有元素都不構成逆序關係,因此在 22 合併回去這一步,我們什麼都不用做。

  • 接下來比較 33443344 小,和上一步一樣,33 合併回去以後,什麼都不用做;

  • 接下來比較 55445544 大,這個時候 44 合併回去。與此同時,我們就知道了,44 與第 1 個數組裏還沒有被合併回去的所有元素: 5577 構成了逆序關係。把 44 合併回去以後,我們就需要給計數器直接加上 22

相信說到這裏,大家也就看出來了:我們只需要在第 2 個有序數組的元素歸併回去以後,把第 1 個數組裏還沒有被歸併回去的元素個數加到計數變量裏。而計算第 1 個數組裏當前還沒有歸併回去的 元素的個數 可以通過下標 i 的數值,以 O(1)O(1) 的時間複雜度計算出來。

接下來比較 55665566 小,55 合併回去,什麼都不做;
接下來比較 7766 的大小,7766 大,這個時候第 1 個有序數組裏只有 1 個 77,我們把 66 合併回去以後,給計數器加上 11
接下來比較 77887788 小,77 合併回去以後,什麼都不做;
最後我們把 88 合併回去,由於第 1 個有序數組的所有元素都合併回去了,也就是 88 在這個區間裏不與任何元素構成逆序關係,我們可以認爲是計數器加上 00


總結一下這個算法,我們只需要在第 2 個有序數組的元素合併回去的時候,把當前第 1 個數組裏還有幾個元素還沒有合併回去的個數,加到計數變量裏就好了。

依然要和大家強調的是:可以這樣計算逆序對個數的前提是:兩個子區間分別有序。


下面,我們再從整體向大家介紹一下優化算法的思路:

  • 這個算法在開始的時候對輸入數組一直對半拆分,直到區間裏只剩下一個元素的時候;
  • 1 個元素的數組,肯定是有序數組;
  • 然後我們再依次合併兩個有序數組,而計算逆序對的個數,就發生在合併的過程中;
  • 每一次合併完成以後,得到了一個更長的有序子區間,同時也爲 下一次的合併做好了準備;
  • 直至整個數組有序,我們就計算出了整個數組裏逆序對的個數;
  • 這個過程是非常典型的「分治算法」的思想。

我們把一個規模較大的問題劃分成同等結構的子問題,將子問題逐一解決以後,原問題就得到了解決。

這個算法裏計算逆序對的結構是:

  • 先計算兩個子區間內部的逆序對的個數;
  • 然後在合併的過程中計算跨越兩個子區間的逆序對的個數。

下面我們來看一下代碼:

Java 代碼:

public class Solution {

    public int reversePairs(int[] nums) {
        int len = nums.length;
        if (len < 2) {
            return 0;
        }

        int[] copy = new int[len];
        for (int i = 0; i < len; i++) {
            copy[i] = nums[i];
        }

        int[] temp = new int[len];
        return reversePairs(copy, 0, len - 1, temp);
    }

    private int reversePairs(int[] nums, int left, int right, int[] temp) {
        if (left == right) {
            return 0;
        }

        int mid = left + (right - left) / 2;
        // 先計算 nums[left..mid] 逆序對的個數,再計算 nums[mid + 1..right] 逆序對的個數
        int leftPairs = reversePairs(nums, left, mid, temp);
        int rightPairs = reversePairs(nums, mid + 1, right, temp);

        if (nums[mid] <= nums[mid + 1]) {
            return leftPairs + rightPairs;
        }

        int crossPairs = mergeAndCount(nums, left, mid, right, temp);
        return leftPairs + rightPairs + crossPairs;
    }

    /**
     * nums[left..mid] 有序,nums[mid + 1..right] 有序
     *
     * @param nums
     * @param left
     * @param mid
     * @param right
     * @param temp
     * @return
     */
    private int mergeAndCount(int[] nums, int left, int mid, int right, int[] temp) {
        for (int i = left; i <= right; i++) {
            temp[i] = nums[i];
        }

        int i = left;
        int j = mid + 1;
        int count = 0;

        for (int k = left; k <= right; k++) {
            if (i == mid + 1) {
                nums[k] = temp[j];
                j++;
            } else if (j == right + 1) {
                nums[k] = temp[i];
                i++;
            } else if (temp[i] <= temp[j]) {
                nums[k] = temp[i];
                i++;
            } else {
                nums[k] = temp[j];
                j++;
                count += (mid - i + 1);
            }
        }
        return count;
    }
}

Java 代碼:

public class Solution {

    // 後有序數組中元素出列的時候,計算逆序個數

    public int reversePairs(int[] nums) {
        int len = nums.length;
        if (len < 2) {
            return 0;
        }
        int[] temp = new int[len];
        return reversePairs(nums, 0, len - 1, temp);
    }

    /**
     * 計算在數組 nums 的索引區間 [left, right] 內統計逆序對
     *
     * @param nums  待統計的數組
     * @param left  待統計數組的左邊界,可以取到
     * @param right 待統計數組的右邊界,可以取到
     * @return
     */
    private int reversePairs(int[] nums, int left, int right, int[] temp) {
        // 極端情況下,就是隻有 1 個元素的時候,這裏只要寫 == 就可以了,不必寫大於
        if (left == right) {
            return 0;
        }

        int mid = (left + right) >>> 1;

        int leftPairs = reversePairs(nums, left, mid, temp);
        int rightPairs = reversePairs(nums, mid + 1, right, temp);

        int reversePairs = leftPairs + rightPairs;
        if (nums[mid] <= nums[mid + 1]) {
            return reversePairs;
        }

        int reverseCrossPairs = mergeAndCount(nums, left, mid, right, temp);
        return reversePairs + reverseCrossPairs;

    }

    /**
     * [left, mid] 有序,[mid + 1, right] 有序
     * @param nums
     * @param left
     * @param mid
     * @param right
     * @param temp
     * @return
     */
    private int mergeAndCount(int[] nums, int left, int mid, int right, int[] temp) {
        // 複製到輔助數組裏,幫助我們完成統計
        for (int i = left; i <= right; i++) {
            temp[i] = nums[i];
        }

        int i = left;
        int j = mid + 1;
        int res = 0;
        for (int k = left; k <= right; k++) {
            if (i > mid) {
                // i 用完了,只能用 j
                nums[k] = temp[j];
                j++;
            } else if (j > right) {
                // j 用完了,只能用 i
                nums[k] = temp[i];
                i++;
            } else if (temp[i] <= temp[j]) {
                // 此時前數組元素出列,不統計逆序對
                nums[k] = temp[i];
                i++;
            } else {
                // 此時後數組元素出列,統計逆序對,快就快在這裏,一次可以統計出一個區間的個數的逆序對
                nums[k] = temp[j];
                j++;
                res += (mid - i + 1);
            }
        }
        return res;
    }
}

最後我們來看一下複雜度分析:

  • 這個算法時間複雜度其實就是「歸併排序」的時間複雜度, $O(\N \logN) $,感興趣的朋友可以在互聯網上搜索一下這個結論是如何得到的,需要藉助一個叫做「主定理」的理論進行具體的推導;
  • 由於我們在「合併兩個有序數組」的時候,需要使用和原始數組同等大小的空間,空間複雜度就是 O(N)O(N),這裏遞歸調用,使用的方法棧的大小是 O(logN)O(\log N),由於它比 O(N)O(N) 小,因此在計算複雜度的時候被忽略。

這就是這道題的視頻題解,感謝大家的收看。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章