该题的难度分级是Hard,那么难在哪里呢?我们先来看题目。
给定两个大小为 m 和 n 的有序数组 nums1 和 nums2 。
请找出这两个有序数组的中位数。要求算法的时间复杂度为 O(log (m+n)) 。
示例 1:
nums1 = [1, 3] nums2 = [2] 中位数是 2.0
示例 2:
nums1 = [1, 2] nums2 = [3, 4] 中位数是 (2 + 3)/2 = 2.5
当你看到要求的时间复杂度为O(log (m+n)),你想到了什么?没错,二分法。
从示例上观察来看,如果两个数组的长度和是奇数,中位数就是大小在正中间的那个数;如果两个数组的长度和是偶数,那么中位数就是大小在正中间的两个数的和的平均数。
似乎我们需要分类讨论,并且考虑一些corner case才能完整的把这题解出来(请参考kth-smallest-member的实现),那么有没有更加巧妙的方法呢?没错,我都这么说了,肯定是有的。
下面的解法是官网社区讨论组里面一个大神的解法,我来搬运过来翻译一下,帮助我自己理解,也帮助有缘人解惑。
原post:https://leetcode.com/problems/median-of-two-sorted-arrays/discuss/2471/Very-concise-O(log(min(MN)))-iterative-solution-with-detailed-explanation
话不多说,让我们开始看大神如何装逼。
这个解法需要一种思维转换,我们能不能把长度和是奇数和长度和是偶数这两种情况看作是同一种情况呢?Yes!
首先,我们来看一下中位数的概念:
如果我们将一个有序数组切一刀,分成2个等长的部分,那么中位数就是前半部分的最大值和后半部分的最小值,这两者和的平均数。
没明白?没关系,举个例子:对于[2 3 5 7] 这个数组, 我们在3和5中间切一刀(对没错,就是这个红色的斜杠)
[2 3 / 5 7]
那么中位数median = (3+5)/2。后面我们都将用 '/' 代表一个切分,(数字 / 数字) 代表切分是切在一个数字上(嗯,结果就是一个我变成两个我)
对于数组 [2 3 4 5 6], 我们可以像这样切分,把4一切二:
[2 3 (4/4) 5 7]
由于我们把4切成了2份,前半部分数组和后半部分数组都包含了4。那么这时候中位数是多少?median = (4 + 4) / 2 = 4; 答案依旧正确。
好,现在为了方便,我们用L来表示切的那一刀左边第一个元素,R来表示切的那一刀右边的第一个元素。
也就是说,对于被切了一刀的数组 [2 3 / 5 7], 这个情况下,L = 3, R = 5。
于是我们可以观察到,对于一个长度为N的有序数组,L和R的数组下标有如下规律:
N(数组长度) L / R对应的数组下标
1 0 / 0
2 0 / 1 3 1 / 1 4 1 / 2 5 2 / 2 6 2 / 3 7 3 / 3 8 3 / 4
不难发现,INDEXL = (N-1)/2 而 INDEXR = N/2. 那么根据前面的分析,对于任意一个数组A, 其中位数median = (L + R)/2 = (A[(N-1)/2] + A[N/2])/2 。
往下看之前,请先确保以上内容你已经完全ok了解no破布。
好,我们继续。现在我们来讨论两个数组情况,我们需要在数组中假想一些#(井号)出来,这些#把每一个数组中的数字都包裹了起来,并且,无论是“#”还是数字我们都称作是一个position。
[6 9 13 18] -> [# 6 # 9 # 13 # 18 #] (N = 4) position index 0 1 2 3 4 5 6 7 8 (N_Position = 9) 共有9个position [6 9 11 13 18]-> [# 6 # 9 # 11 # 13 # 18 #] (N = 5) position index 0 1 2 3 4 5 6 7 8 9 10 (N_Position = 11) 共有11个position
可以看到,无论N是多少,一定会有 2*N+1 个 'positions' . 因此,无论N是奇数还是偶数,从position的角度来看,假设第一个position下标为0,那么要在正中间切一刀,一定是在第N个position(这里称为CutPosition)。
由于我们已经得出在一个数组中 index(L) = (N-1)/2, index(R) = N/2 , 所以我们进一步得出 index(L) = (CutPosition-1)/2, index(R) = (CutPosition)/2.
同样的,往下看之前,请先确保以上内容你已经完全ok了解no破布。
好,我们继续,对于2个数组的情况,
A1: [# 1 # 2 # 3 # 4 # 5 #] (N1 = 5, N1_positions = 11) A2: [# 1 # 1 # 1 # 1 #] (N2 = 4, N2_positions = 9)
与单个数组的问题类似,我们要找到一个切法(就是在两个数组上选一个position各切一刀),可以将两个数组分别分成两个部分,使得
“两个左半部分中包含的任意数字” <= “两个右半部分中包含的任意数字”
我们可以观察得出,对于这两个数组(长度分别为N1和N2):
-
总共有 (2N1+1)+(2N2+1) = 2N1 + 2N2 + 2 个position. 因此切完之后,左半边和右半边应该各包含 N1 + N2 个 positions,再加上每一刀的刀口,各占用一个position
-
在必须满足第1条的原则下,假设我们在数组A2的position下标 C2 的地方切一刀, 那么A1的切分position下标就必须是 C1 = N1 + N2 - C2. 来举个例子, 如果 C2 = 2, 那么就一定有 C1 = 4 + 5 - C2 = 7.
A1 [# 1 # 2 # 3 # (4/4) # 5 #] N1 = 5 (切在数字4上,所以用(4/4)表示)
position index 0 1 2 3 4 5 6 7 8 9 10 N_Positions = 11
A2 [# 1 / 1 # 1 # 1 #] N2 = 4 (由于切在#处,就用/代替#)
position index 0 1 2 3 4 5 6 7 8
N_Positions = 9
-
切完之后我们会得到2个L和2个R,分别是:
L1 = A1[(C1-1)/2]; R1 = A1[C1/2]; L2 = A2[(C2-1)/2]; R2 = A2[C2/2];
代入到上面的例子里,L1和L2就是,
L1 = A1[(7-1)/2] = A1[3] = 4; R1 = A1[7/2] = A1[3] = 4; L2 = A2[(2-1)/2] = A2[0] = 1; R2 = A1[2/2] = A1[1] = 1;
那么现在问题来了,我们怎么知道当前的切法是我们想要的切法?回顾一下我们想要满足的条件是:所有左半部分的数都比右半部分要小。因为两个数组是有序递增的,所以L1, L2 是左半部分最大的两个数,而R1, R2是右半部分最小的两个数, 所以其实只需要满足下列条件:
L1 <= R1 && L1 <= R2 && L2 <= R1 && L2 <= R2
由于数组是递增排好序的,L1 <= R1 and L2 <= R2 是一定可以满足的,我们只需要确保:
L1 <= R2 && L2 <= R1.
现在我们终于可以使用简单的二分搜索来找到合适的切法了:(这里逻辑很关键,暂时保留原文)
If (L1 > R2){
// it means there are too many large numbers on the left half of A1, then we must move C1 to the left (i.e. move C2 to the right);
// 意味着A1的左半边的大数字太多了,需要将切分position C1左移,或者说就是把C2右移(还记得C1和C2的关系吗?)
} If (L2 > R1){
// then there are too many large numbers on the left half of A2, and we must move C2 to the left.
// 意味着A2的左半边的大数字太多了,需要将切分position C2左移
} Otherwise, this cut is the right one.
否则就满足了条件,是正确的切分,通过计算 (max(L1,L2) + min(R1,R2)) / 2 得出中位数 After we find the cut, the medium can be computed as (max(L1, L2) + min(R1, R2)) / 2;
两个要注意的地方:
A. 由于C1和C2存在互相依赖的关系(也就是,确定了C1就确定了C2,反之亦然), 我们可以先移动其中的一个,另一个随之移动。然而,更加实用的方法是先移动C2 (在较短数组里的position下标) . 其原因是,在较短的那个数组里,所有的position都有可能是中位数的切分点,但是在较长的数组里,太靠左或者太靠右的数字几乎不可能是一个合法的切分。举个例子,对于这两个数组: [1], [2 3 4 5 6 7 8]. 显然在 2 和 3 之间切分的结果是不可能的, 因为,切完之后较短的数组的元素不足以使得左右两部分position数量相等,也就是 [3 4 5 6 7 8] 包含的数字太多了. 因此,从较短的数组开始尝试切分会更加简单一些,并且使得运行时间复杂度可以达到O(log(min(N1, N2)))
B. 本解法唯一的边缘情况就是,当一个切分落在第0个position(数组的第一个数字)或者第 2N个position(数组最后一个数字) 。举个例子,如果 C2 = 2N2(数组有2N2+1个切分点,下标从0到2N2), 那么 R2 = A2[2*N2/2] = A2[N2], 超过了数组的上界(N2-1)。为了解决这个问题,我们可以想象这两个数组都有两个额外的元素,其中INT_MIN 在 A[-1] 的位置 而 INT_MAX 在 A[N] 的位置。额外的位置并不会影响最终的结果,但是使得算法实现起来更加容易:如果任何一个 L 超出了数组的左边界, 那么 L = INT_MIN, 而如果任何一个R 超过了数组的右边界, 那么 R = INT_MAX.
ok,到此结束,最后附上代码实现,不过提交之后执行速度并没有比用kth smallest算法的实现快,可以说只是另辟蹊径吧。
class Solution { public double findMedianSortedArrays(int[] nums1, int[] nums2) { int N1 = nums1.length; int N2 = nums2.length; if (N1 < N2) return findMedianSortedArrays(nums2, nums1); // Make sure A2 is the shorter one. // 到这里,数组num1是较长的数组,数组num2是较短的数组,或者。。。至少一样长短吧 int lo = 0, hi = N2 * 2; // 初始化lo和hi指针,准备移动C2 while (lo <= hi) { int mid2 = (lo + hi) / 2; // C2赋值为中间position int mid1 = N1 + N2 - mid2; // 对应的计算出C1的值 // 接下来根据C1和C2分别计算L1, R1, L2, R2 double L1 = (mid1 == 0) ? Integer.MIN_VALUE : nums1[(mid1-1)/2]; // Get L1, R1, L2, R2 respectively double L2 = (mid2 == 0) ? Integer.MIN_VALUE : nums2[(mid2-1)/2]; double R1 = (mid1 == N1 * 2) ? Integer.MAX_VALUE : nums1[(mid1)/2]; double R2 = (mid2 == N2 * 2) ? Integer.MAX_VALUE : nums2[(mid2)/2]; // 根据取值重新选择C2的取值范围 if (L1 > R2) lo = mid2 + 1; // A1's lower half is too big; need to move C1 left (C2 right) else if (L2 > R1) hi = mid2 - 1; // A2's lower half too big; need to move C2 left. else return (Math.max(L1,L2) + Math.min(R1, R2)) / 2; // Otherwise, that's the right cut. } return -1; } }
Good Luck!