大家好,這裏是「力扣」視頻題解。今天要和大家分享的是面試題第 51 題:數組中的逆序對。
這道題雖然是標註爲 hard 的一個問題,但其實它一個非常經典的問題,如果一開始沒有思路的話,可以把它當做一道例題來學習。
這道題要求我們計算一個數組中「逆序對」的個數。
什麼是「逆序對」呢? 我們看示例數組裏的 和 , 排在 的前面, 大於 ,因此它們構成了一個逆序對。
「逆序對」的個數反映了一個數組的有序程度,有兩個很特殊的例子:
1、對於「順序數組」來說,任意抽出的兩個數字都不存在逆序關係,所以任意順序數組的逆序對的個數就爲 ;
例如:[1, 2, 3, 4, 5]
2、而對於「逆序數組」來說,任意抽出的兩個數字都構成了逆序關係,所以逆序數組的逆序對的個數就達到了最大。
這個數值就等於:在每個元素之後的「所有元素的」個數之和。
例子:[5, 4, 3, 2, 1]
(下一頁)
對於這個問題:一個非常容易想到的方法是,使用兩個 for
循環,枚舉所有不同下標的數對,只要發現一對逆序關係,就給計數器加 。
這個方法我們稱之爲暴力解法或者是依據定義的解法,時間複雜度是 ,空間複雜度是 。
這個方法的缺點是顯而易見的:我們在每一次比較的過程中,不管 比較的結果構成了順序對還是逆序對,都沒有有被記錄下來。也就是說:之前比較的結果不能爲以後的比較提供有用的信息。
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;
}
}
怎麼加速這個過程呢?
其實隱含在我們剛剛向大家介紹,計算逆序數組的「逆序對」的那個例子裏。正是因爲我們知道了這個數組裏的元素的大小關係,我們就可以一下子計算出:
- 與數字 構成逆序對的元素的個數;
- 與數字 構成逆序對的元素的個數;
依次加下去,而不用一個一個地去比較。
以上兩點事實告訴我們:在計算逆序對的過程中,掌握元素的大小關係可以加快計算。
而掌握元素的大小關係就需要在計算逆序對的過程中,對已經看到的數字做一些順序上的調整。
在高級的排序算法(歸併排序、快速排序)裏,能夠看到非常明顯的「階段排序效果 」的算法就是歸併排序。
說到這裏,大家不妨暫停一下視頻。想一想如何利用「歸併排序」算法,在給數組排序的過程中,計算出逆序對的個數。
我們來看「歸併排序」的一個關鍵步驟:「合併兩個有序數組」。
在這裏我們單獨看這樣一個過程:考察的是兩個緊挨着的有序子區間,然後將它們合併成爲一個更長的有序區間。
我們以
[2, 3, 5, 7, 1, 4, 6, 8]
爲例。
這個數組的前半部分與後半部分,均是有序的。
-
我們在合併之前,首先需要把它們拷貝到一個新的空間;
-
然後使用兩個指針變量
i
和j
分別指向兩個有序子區間的第 1 個位置,再使用一個指針變量k
指向合併回去的那個數組的第 1 個位置; -
我們的規則是:
i
和j
指向的元素誰小,誰就先合併回去。
(具體)
- 先看 和 的大小, 比 大, 先合併回去。由於兩個子區間都是有序的,我們就知道了, 比它之前的所有元素都小,也就是 與它之前的所有的元素都構成了逆序關係,我們就可以一下子給計數器加上 。
這就是在合併的過程中,能夠加速計算逆序數的原因。我們利用了數組的部分有序性,而不用一個一個地去比較。
合併回去以後,比較 和 。 比 小,在 合併回去的同時,我們可以觀察到 點:
-
第 1 : 與之前合併回去的數 構成了逆序對,只不過我們在剛纔,把 合併回去的時候,已經把 和 的逆序關係計算了一次,因此在這一步我們沒有必要再計算一遍;
-
第 2 : 與 之後的所有元素都不構成逆序關係,因此在 合併回去這一步,我們什麼都不用做。
-
接下來比較 和 , 比 小,和上一步一樣, 合併回去以後,什麼都不用做;
-
接下來比較 和 , 比 大,這個時候 合併回去。與此同時,我們就知道了, 與第 1 個數組裏還沒有被合併回去的所有元素: 和 構成了逆序關係。把 合併回去以後,我們就需要給計數器直接加上 ;
相信說到這裏,大家也就看出來了:我們只需要在第 2 個有序數組的元素歸併回去以後,把第 1 個數組裏還沒有被歸併回去的元素個數加到計數變量裏。而計算第 1 個數組裏當前還沒有歸併回去的 元素的個數 可以通過下標 i
的數值,以 的時間複雜度計算出來。
接下來比較 和 , 比 小, 合併回去,什麼都不做;
接下來比較 和 的大小, 比 大,這個時候第 1 個有序數組裏只有 1 個 ,我們把 合併回去以後,給計數器加上 ;
接下來比較 和 , 比 小, 合併回去以後,什麼都不做;
最後我們把 合併回去,由於第 1 個有序數組的所有元素都合併回去了,也就是 在這個區間裏不與任何元素構成逆序關係,我們可以認爲是計數器加上 。
總結一下這個算法,我們只需要在第 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) $,感興趣的朋友可以在互聯網上搜索一下這個結論是如何得到的,需要藉助一個叫做「主定理」的理論進行具體的推導;
- 由於我們在「合併兩個有序數組」的時候,需要使用和原始數組同等大小的空間,空間複雜度就是 ,這裏遞歸調用,使用的方法棧的大小是 ,由於它比 小,因此在計算複雜度的時候被忽略。
這就是這道題的視頻題解,感謝大家的收看。