leetCode 4 Median of Two Sorted Arrays
題目描述(困難難度)
已知兩個有序陣列,找到兩個數組合並後的中位數。
解法一
簡單粗暴,先將兩個數組合並,兩個有序陣列的合併也是歸併排序中的一部分。然後根據奇數,還是偶數,返回中位數。
程式碼
public double findMedianSortedArrays(int[] nums1, int[] nums2) {
int[] nums;
int m = nums1.length;
int n = nums2.length;
nums = new int[m + n];
if (m == 0) {
if (n % 2 == 0) {
return (nums2[n / 2 - 1] + nums2[n / 2]) / 2.0;
} else {
return nums2[n / 2];
}
}
if (n == 0) {
if (m % 2 == 0) {
return (nums1[m / 2 - 1] + nums1[m / 2]) / 2.0;
} else {
return nums1[m / 2];
}
}
int count = 0;
int i = 0, j = 0;
while (count != (m + n)) {
if (i == m) {
while (j != n) {
nums[count++] = nums2[j++];
}
break;
}
if (j == n) {
while (i != m) {
nums[count++] = nums1[i++];
}
break;
}
if (nums1[i] < nums2[j]) {
nums[count++] = nums1[i++];
} else {
nums[count++] = nums2[j++];
}
}
if (count % 2 == 0) {
return (nums[count / 2 - 1] + nums[count / 2]) / 2.0;
} else {
return nums[count / 2];
}
}
時間複雜度:遍歷全部陣列,O(m + n)
空間複雜度:開闢了一個數組,儲存合併後的兩個陣列,O(m + n)
解法二
其實,我們不需要將兩個陣列真的合併,我們只需要找到中位數在哪裡就可以了。
開始的思路是寫一個迴圈,然後裡邊判斷是否到了中位數的位置,到了就返回結果,但這裡對偶數和奇數的分類會很麻煩。當其中一個數組遍歷完後,出了 for 迴圈對邊界的判斷也會分幾種情況。總體來說,雖然複雜度不影響,但程式碼會看起來很亂。然後在 這裡 找到了另一種思路。
首先是怎麼將奇數和偶數的情況合併一下。
用 len 表示合併後陣列的長度,如果是奇數,我們需要知道第 (len + 1)/ 2 個數就可以了,如果遍歷的話需要遍歷 int ( len / 2 ) + 1 次。如果是偶數,我們需要知道第 len / 2 和 len / 2 + 1 個數,也是需要遍歷 len / 2 + 1 次。所以遍歷的話,奇數和偶數都是 len / 2 + 1 次。
返回中位數的話,奇數需要最後一次遍歷的結果就可以了,偶數需要最後一次和上一次遍歷的結果。所以我們用兩個變數 left 和 right ,right 儲存當前迴圈的結果,在每次迴圈前將 right 的值賦給 left 。這樣在最後一次迴圈的時候,left 將得到 right 的值,也就是上一次迴圈的結果,接下來 right 更新為最後一次的結果。
迴圈中該怎麼寫,什麼時候 A 陣列後移,什麼時候 B 陣列後移。用 aStart 和 bStart 分別表示當前指向 A 陣列和 B 陣列的位置。如果 aStart 還沒有到最後並且此時 A 位置的數字小於 B 位置的陣列,那麼就可以後移了。也就是aStart < m && A[aStart] < B[bStart]。
但如果 B 陣列此刻已經沒有數字了,繼續取數字B [ bStart ],則會越界,所以判斷下 bStart 是否大於陣列長度了,這樣 || 後邊的就不會執行了,也就不會導致錯誤了,所以增加為 aStart < m && ( bStart >= n || A [ aStart ] < B [ bStart ] ) 。
程式碼
public double findMedianSortedArrays(int[] A, int[] B) {
int m = A.length;
int n = B.length;
int len = m + n;
int left = -1, right = -1;
int aStart = 0, bStart = 0;
for (int i = 0; i <= len / 2; i++) {
left = right;
if (aStart < m && (bStart >= n || A[aStart] < B[bStart])) {
right = A[aStart++];
} else {
right = B[bStart++];
}
}
if ((len & 1) == 0)
return (left + right) / 2.0;
else
return right;
}
時間複雜度:遍歷 len/2 + 1 次,len = m + n ,所以時間複雜度依舊是 O(m + n)。
空間複雜度:我們申請了常數個變數,也就是 m,n,len,left,right,aStart,bStart 以及 i 。
總共 8 個變數,所以空間複雜度是 O(1)。
解法三
上邊的兩種思路,時間複雜度都達不到題目的要求 O ( log ( m + n ) )。看到 log ,很明顯,我們只有用到二分的方法才能達到。我們不妨用另一種思路,題目是求中位數,其實就是求第 k 小數的一種特殊情況,而求第 k 小數有一種演算法。
解法二中,我們一次遍歷就相當於去掉不可能是中位數的一個值,也就是一個一個排除。由於數列是有序的,其實我們完全可以一半兒一半兒的排除。假設我們要找第 k 小數,我們可以每次迴圈排除掉 k / 2 個數。看下邊一個例子。
假設我們要找第 7 小的數字。
我們比較兩個陣列的第 k / 2 個數字,如果 k 是奇數,向下取整。也就是比較第 3 個數字,上邊陣列中的 8 和 下邊陣列中的 3 ,如果哪個小,就表明該陣列的前 k / 2 個數字都不是第 k 小數字,所以可以排除。也就是 1,2,3 這三個數字不可能是第 7 小的數字,我們可以把它排除掉。將 1389 和 45678910 兩個陣列作為新的陣列進行比較。
更一般的情況 A [ 1 ],A [ 2 ],A [ 3 ],A [ k / 2] … ,B[ 1 ],B [ 2 ],B [ 3 ],B[ k / 2] … ,如果 A [ k / 2 ] < B [ k / 2 ] ,那麼 A [ 1 ],A [ 2 ],A [ 3 ],A [ k / 2] 都不可能是第 k 小的數字。
A 陣列中比 A [ k / 2 ] 小的數有 k / 2 - 1 個,B 陣列中,B [ k / 2 ] 比 A [ k / 2 ] 小,假設 B [ k / 2 ] 前邊的數字都比 A [ k / 2 ] 小,也只有 k / 2 - 1 個,所以比 A [ k / 2 ] 小的數字最多有 k / 2 - 1 + k / 2 - 1 = k - 2 個,所以 A [ k / 2 ] 最多是第 k - 1 小的數。而比 A [ k / 2 ] 小的數更不可能是第 k 小的數了,所以可以把它們排除。
橙色的部分表示已經去掉的數字。
由於我們已經排除掉了 3 個數字,就是這 3 個數字一定在最前邊,所以在兩個新陣列中,我們只需要找第 7 - 3 = 4 小的數字就可以了,也就是 k = 4 。此時兩個陣列,比較第 2 個數字,3 < 5,所以我們可以把小的那個陣列中的 1 ,3 排除掉了。
我們又排除掉 2 個數字,所以現在找第 4 - 2 = 2 小的數字就可以了。此時比較兩個陣列中的第 k / 2 = 1 個數,4 = 4 ,怎麼辦呢?由於兩個數相等,所以我們無論去掉哪個陣列中的都行,因為去掉 1 個總會保留 1 個的,所以沒有影響。為了統一,我們就假設 4 > 4 吧,所以此時將下邊的 4 去掉。
由於又去掉 1 個數字,此時我們要找第 1 小的數字,所以只需判斷兩個陣列中第一個數字哪個小就可以了,也就是 4 。
所以第 7 小的數字是 4 。
我們每次都是取 k / 2 的數進行比較,有時候可能會遇到陣列長度小於 k / 2 的時候。
此時 k / 2 等於 3 ,而上邊的陣列長度是 2 ,我們此時將箭頭指向它的末尾就可以了。這樣的話,由於 2 < 3 ,所以就會導致上邊的陣列 1,2 都被排除。造成下邊的情況。
由於 2 個元素被排除,所以此時 k = 5 ,又由於上邊的陣列已經空了,我們只需要返回下邊的陣列的第 5 個數字就可以了。
從上邊可以看到,無論是找第奇數個還是第偶數個數字,對我們的演算法並沒有影響,而且在演算法進行中,k 的值都有可能從奇數變為偶數,最終都會變為 1 或者由於一個數組空了,直接返回結果。
所以我們採用遞迴的思路,為了防止陣列長度小於 k / 2 ,所以每次比較 min ( k / 2,len ( 陣列 ) ) 對應的數字,把小的那個對應的陣列的數字排除,將兩個新陣列進入遞迴,並且 k 要減去排除的數字的個數。遞迴出口就是當 k = 1 或者其中一個數字長度是 0 了。
程式碼
public double findMedianSortedArrays(int[] nums1, int[] nums2) {
int n = nums1.length;
int m = nums2.length;
int left = (n + m + 1) / 2;
int right = (n + m + 2) / 2;
//將偶數和奇數的情況合併,如果是奇數,會求兩次同樣的 k 。
return (getKth(nums1, 0, n - 1, nums2, 0, m - 1, left) + getKth(nums1, 0, n - 1, nums2, 0, m - 1, right)) * 0.5;
}
private int getKth(int[] nums1, int start1, int end1, int[] nums2, int start2, int end2, int k) {
int len1 = end1 - start1 + 1;
int len2 = end2 - start2 + 1;
//讓 len1 的長度小於 len2,這樣就能保證如果有陣列空了,一定是 len1
if (len1 > len2) return getKth(nums2, start2, end2, nums1, start1, end1, k);
if (len1 == 0) return nums2[start2 + k - 1];
if (k == 1) return Math.min(nums1[start1], nums2[start2]);
int i = start1 + Math.min(len1, k / 2) - 1;
int j = start2 + Math.min(len2, k / 2) - 1;
if (nums1[i] > nums2[j]) {
return getKth(nums1, start1, end1, nums2, j + 1, end2, k - (j - start2 + 1));
}
else {
return getKth(nums1, i + 1, end1, nums2, start2, end2, k - (i - start1 + 1));
}
}
時間複雜度:每進行一次迴圈,我們就減少 k / 2 個元素,所以時間複雜度是 O(log(k)),而 k = (m + n)/ 2 ,所以最終的複雜也就是 O(log(m + n))。
空間複雜度:雖然我們用到了遞迴,但是可以看到這個遞歸屬於尾遞迴,所以編譯器不需要不停地堆疊,所以空間複雜度為 O(1)。
解法四
我們首先理一下中位數的定義是什麼
中位數(又稱中值,英語:Median),統計學中的專有名詞,代表一個樣本、種群或概率分佈中的一個數值,其可將數值集合劃分為相等的上下兩部分。
所以我們只需要將陣列進行切。
一個長度為 m 的陣列,有 0 到 m 總共 m + 1 個位置可以切。
我們把陣列 A 和陣列 B 分別在 i 和 j 進行切割。
將 i 的左邊和 j 的左邊組合成「左半部分」,將 i 的右邊和 j 的右邊組合成「右半部分」。
-
當 A 陣列和 B 陣列的總長度是偶數時,如果我們能夠保證
-
左半部分的長度等於右半部分
i + j = m - i + n - j , 也就是 j = ( m + n ) / 2 - i
-
左半部分最大的值小於等於右半部分最小的值 max ( A [ i - 1 ] , B [ j - 1 ])) <= min ( A [ i ] , B [ j ]))
那麼,中位數就可以表示如下
(左半部分最大值 + 右半部分最大值 )/ 2 。
(max ( A [ i - 1 ] , B [ j - 1 ])+ min ( A [ i ] , B [ j ])) / 2
-
-
當 A 陣列和 B 陣列的總長度是奇數時,如果我們能夠保證
-
左半部分的長度比右半部分大 1
i + j = m - i + n - j + 1也就是 j = ( m + n + 1) / 2 - i
-
左半部分最大的值小於等於右半部分最小的值 max ( A [ i - 1 ] , B [ j - 1 ])) <= min ( A [ i ] , B [ j ]))
那麼,中位數就是 左半部分最大值,也就是左半部比右半部分多出的那一個數。 max ( A [ i - 1 ] , B [ j - 1 ])
-
上邊的第一個條件我們其實可以合併為 j = ( m + n + 1) / 2 - i,因為如果 m + n 是偶數,由於我們取的是 int 值,所以加 1 也不會影響結果。當然,由於 0 <= i <= m ,為了保證 0 <= j <= n ,我們必須保證 m <= n 。
最後一步由於是 int 間的運算,所以 1 / 2 = 0。
而對於第二個條件,奇數和偶數的情況是一樣的,我們進一步分析。為了保證 max ( A [ i - 1 ] , B [ j - 1 ])) <= min ( A [ i ] , B [ j ])),因為 A 陣列和 B 陣列是有序的,所以 A [ i - 1 ] <= A [ i ],B [ i - 1 ] <= B [ i ] 這是天然的,所以我們只需要保證 B [ j - 1 ] < = A [ i ] 和 A [ i - 1 ] <= B [ j ] 所以我們分兩種情況討論:
-
B [ j - 1 ] > A [ i ],並且為了不越界,要保證 j != 0,i != m
此時很明顯,我們需要增加 i ,為了數量的平衡還要減少 j ,幸運的是 j = ( m + n + 1) / 2 - i,i 增大,j 自然會減少。
-
A [ i - 1 ] > B [ j ] ,並且為了不越界,要保證 i != 0,j != n
此時和上邊的情況相反,我們要減少 i ,增大 j 。
上邊兩種情況,我們把邊界都排除了,需要單獨討論。
-
當 i = 0 , 或者 j = 0 ,也就是切在了最前邊。
此時左半部分當 j = 0 時,最大的值就是 A [ i - 1 ] ;當 i = 0 時 最大的值就是 B [ j - 1] 。右半部分最小值和之前一樣。
-
當 i = m 或者 j = n ,也就是切在了最後邊。
此時左半部分最大值和之前一樣。右半部分當 j = n 時,最小值就是 A [ i ] ;當 i = m 時,最小值就是B [ j ] 。
所有的思路都理清了,最後一個問題,增加 i 的方式。當然用二分了。初始化 i 為中間的值,然後減半找中間的,減半找中間的,減半找中間的直到答案。
class Solution {
public double findMedianSortedArrays(int[] A, int[] B) {
int m = A.length;
int n = B.length;
if (m > n) {
return findMedianSortedArrays(B,A); // 保證 m <= n
}
int iMin = 0, iMax = m;
while (iMin <= iMax) {
int i = (iMin + iMax) / 2;
int j = (m + n + 1) / 2 - i;
if (j != 0 && i != m && B[j-1] > A[i]){ // i 需要增大
iMin = i + 1;
}
else if (i != 0 && j != n && A[i-1] > B[j]) { // i 需要減小
iMax = i - 1;
}
else { // 達到要求,並且將邊界條件列出來單獨考慮
int maxLeft = 0;
if (i == 0) { maxLeft = B[j-1]; }
else if (j == 0) { maxLeft = A[i-1]; }
else { maxLeft = Math.max(A[i-1], B[j-1]); }
if ( (m + n) % 2 == 1 ) { return maxLeft; } // 奇數的話不需要考慮右半部分
int minRight = 0;
if (i == m) { minRight = B[j]; }
else if (j == n) { minRight = A[i]; }
else { minRight = Math.min(B[j], A[i]); }
return (maxLeft + minRight) / 2.0; //如果是偶數的話返回結果
}
}
return 0.0;
}
}
時間複雜度:我們對較短的陣列進行了二分查詢,所以時間複雜度是 O(log(min(m,n)))。
空間複雜度:只有一些固定的變數,和陣列長度無關,所以空間複雜度是 O ( 1 ) 。
總結
解法二中體會到了對情況的轉換,有時候即使有了思路,程式碼也不一定寫的優雅,需要多鍛鍊才可以。解法三和解法四充分發揮了二分查詢的優勢,將時間複雜度降為 log 級別。