说明:这篇题解是我原来在准备视频题解的时候写的题解,目前来看已经没有什么用了,所以发布在这里,留一个备份。
二分查找经典问题:第 4 题:两个排序数组的中位数
这一节,我们来看一个非常经典且常见的问题,这道题是「力扣」第 4 号问题:寻找两个有序数组的中位数。
这道题的题意十分简单,题目中给出两个已经排好序的数组,让我们求出这两个有序数组的中位数,请注意,不是让我们分别求出两个数组的中位数,而是要我们求出,两个有序数组合并成一个有序数组以后的中位数。
这道题难在最后给出的要求:让我们在对数级别的时间复杂度解决这个问题。虽然这是这道题的难点,但首先,在有序数组中查找一个数,本身就是二分搜索方法能够解决的问题,同时对数级别的复杂度提示,也是在提示我们二分查找方法的确可以完成这道题。
我们先不考虑题目最后给出的时间复杂度的要求。看看怎么解决这个问题。其实刚刚在念题目的过程中,我已经把最简单、朴素的那个解法给说了出来,那就是依据定义。
方法一:暴力解法
将两个有序数组合并以后,再排序,然后求出中位数。注意,如果合并以后的数组的长度是奇数,此时中位数只有 1 个,把这个值返回即可。如果合并以后的数组的长度是偶数,此时中位数有 2 个,根据题意,返回它们二者的平均数即可。
这就是第一种解法,暴力解法。暴力解法虽然时间复杂度高,但是对数据的容错性最好,即使两个数组不是有序的,这个方法也能正确求出两个数组的中位数。并且在思考暴力解法的过程中,我们注意到了,需要中位数需要根据数组长度是奇数和偶数做不同情况的讨论。
Java 代码:
import java.util.Arrays;
public class Solution {
public double findMedianSortedArrays(int[] nums1, int[] nums2) {
int m = nums1.length;
int n = nums2.length;
int[] merge = new int[m + n];
System.arraycopy(nums1, 0, merge, 0, m);
System.arraycopy(nums2, 0, merge, m, n);
Arrays.sort(merge);
if (((m + n) & 1) == 1) {
return merge[(m + n - 1) >>> 1];
} else {
return (double) (merge[(m + n - 1) >>> 1] + merge[(m + n) >>> 1]) / 2;
}
}
}
复杂度分析:
- 时间复杂度: O ( ( M + N ) log ( M + N ) ) O((M + N) \log (M + N)) O((M+N)log(M+N)),这里 M M M 和 N N N 分别是两个数组的长度。
- 空间复杂度: O ( M + N ) O(M + N) O(M+N)。
方法二:实现归并排序的过程中找中位数
此外,依据排序的思路,我们学习过归并排序,归并排序最基本的情况就是合并两个有序数组。我们可以在合并两个有序数组的过程中,得到中位数。只不过我们不用真的合并完这两个有序数组,只需要直到合并到中位数所在位置那么多个的数即可。
这个方法我们留给大家来完成,相信是一个很容易实现的问题。
Java 代码:
public class Solution {
public double findMedianSortedArrays(int[] nums1, int[] nums2) {
int m = nums1.length;
int n = nums2.length;
// 最后要找到合并以后索引是 median_index 的这个数
int medianIndex = (m + n) >>> 1;
// 计数器从 -1 开始,在循环开始之前加 1
// 这样在退出循环的时候,counter 能指向它最后赋值的那个元素
int counter = -1;
// nums1 的索引
int i = 0;
// nums2 的索引
int j = 0;
int[] res = new int[]{
0, 0};
while (counter < medianIndex) {
counter++;
// 先写 i 和 j 遍历完成的情况,否则会出现数组下标越界
if (i == m) {
res[counter & 1] = nums2[j];
j++;
} else if (j == n) {
res[counter & 1] = nums1[i];
i++;
} else if (nums1[i] < nums2[j]) {
res[counter & 1] = nums1[i];
i++;
} else {
res[counter & 1] = nums2[j];
j++;
}
}
// 如果 m + n 是奇数,median_index 就是我们要找的
// 如果 m + n 是偶数,有一点麻烦,要考虑其中有一个用完的情况,其实也就是把上面循环的过程再进行一步
if (((m + n) & 1) == 1) {
return res[counter & 1];
} else {
return (double) (res[0] + res[1]) / 2;
}
}
}
复杂度分析:
-
时间复杂度: O ( M + N ) O(M + N) O(M+N),这里 M M M 和 N N N 分别是两个数组的长度,代码只看了数组长度之和的一半,常数系数视为 1 1 1。
-
空间复杂度: O ( 1 ) O(1) O(1),我们没有借助其它的空间,使用到的临时变量也只有常数个。
方法三:二分查找
接下来我们介绍一下如何使用二分查找解决这个问题,依然从中位数的定义出发。
根据暴力法的分析,在只有一个有序数组的时候:
(1)如果数组的元素个数是偶数,此时我们可以想象有一条分界线,把数组分成两个部分,中位数就是介于这个分界线两边的两个元素的平均值。
(2)如果数组的元素个数是奇数,此时我们也可以想象有一条分界线,把数组分成两个部分,此时我们让分割线左边多一个元素,此时分割线的左边的那个元素就是这个有序数组的中位数。
至于为什么把中位数分到这个分割线的左边而不是右边,我们马上就会看到。
接下来看两个有序数组的时候,我们依然是可以用这种画分界线的方式来找中位数。
这条分割线的特点是:
1、当数组的总长度为偶数的时候,分割线左右的数字个数总和相等;当数组的总长度为奇数的时候,分割线左数字个数比右边仅仅多 1;
2、分割线左边的所有元素都小于等于(不大于)分割线右边的所有元素。
如果这条分割线可以找到,那么中位数就可以确定下来,同样得分奇偶性:
(1)当数组的总长度为偶数的时候,中位数就是分割线左边的最大值与分割线右边的最小值的平均数;
(2)当数组的总长度为奇数的时候,中位数就是分割线左边的最大值。因此,在数组长度是奇数的时候,中位数就是分割线左边的所有数的最大值。
因此,我们让分割线左边在整个数组长度是奇数的时候,多 1 个数的原因,就是让引入分割线定义的中位数在 1 个数组和 2 个数组的时候统一起来。
因为两个数组本别是有序数组,因此,我们只需要判定交叉的关系中,是否满足左边依然小于等于右边即可,即
(1)第 1 个数组分割线左边的第 1 个数小于等于第 2 个数组分割线右边的第 1 的数;
(2)第 2 个数组分割线左边的第 1 个数小于等于第 1 个数组分割线右边的第 1 的数。
接下来,我们就来看一下分割线怎么着,需要在第 1 个数组上划一刀,再在第 2 个数组上划一刀,但事实上,分割线左边的元素个数是固定的,我们只要能确定 1 个数组上元素的位置,自然另一个位置就可以确定下来。它们之间的关系其实很简单,我们刚刚也已经分析过了。
我们把其中一个数组称之为 nums1
,另一个数组称之为 nums2
(1)当数组的总长度为偶数的时候,左边一共有 l e n ( n u m 1 ) + l e n ( n u m s 2 ) 2 \cfrac{len(num1) + len(nums2)}{2} 2len(num1)+len(nums2) 个元素;
(2)当数组的总长度为奇数的时候,左边一共有 l e n ( n u m 1 ) + l e n ( n u m s 2 ) 2 + 1 \cfrac{len(num1) + len(nums2)}{2} + 1 2len(num1)+len(nums2)+1 个元素;
我们仔细观察一下这两个表达式,发现奇数的时候,因为除以 2 2 2 是下取整,所以计算左边元素总数的时候,就得 + 1,事实上,我们可以修改这个下取整的行为,让它上取整。上面这两种情况就可以统一起来。
具体如下:我们计算左边元素个数的时候,可以用一个统一的式子,即:
l e n ( n u m 1 ) + l e n ( n u m s 2 ) + 1 2 \cfrac{len(num1) + len(nums2) + 1}{2} 2len(num1)+len(nums2)+1
这里用到了一个小技巧,把下取整,修改为上取整的时候,只需要在被除数的部分,加上除数减 1 即可,这里除数是 2 ,因此被除数加 1 即可。大家可以自行验证一下,就拿我们上面举出的例子来验证一下这个事实。
(1)当 len(nums1) = 5
、len(nums2) = 5
的时候, l e n ( n u m 1 ) + l e n ( n u m s 2 ) + 1 2 = 5 \cfrac{len(num1) + len(nums2) + 1}{2} = 5 2len(num1)+len(nums2)+1=5;
(2)当 len(nums1) = 4
、len(nums2) = 5
的时候, l e n ( n u m 1 ) + l e n ( n u m s 2 ) + 1 2 = 5 \cfrac{len(num1) + len(nums2) + 1}{2} = 5 2len(num1)+len(nums2)+1=5,左边比右边多一个元素。
这个问题解决以后,问题就转化为我们在其中一个数组找到 i
个元素,则另一个数组的元素个数就一定是 l e n ( n u m 1 ) + l e n ( n u m s 2 ) + 1 2 − i \cfrac{len(num1) + len(nums2) + 1}{2} - i 2len(num1)+len(nums2)+1−i。于是怎么找 i
的位置,就是我们要解决的问题。
找 i
个元素,我们通常的做法是找索引为 i
的元素,因为下标是从 0 开始编号的,因此编号为 i
的元素,就刚刚好前面有 i
个元素。因此,i
就是第 1 个数组分割线的右边的第 1 个元素。
下面我们来看怎么找 i
。
二分法的思想就是我们先根据搜索范围,找到中间的那个数,然后看目标元素在这个中间数的左边还是右边,或者就是中间数。因此我们先来看两个不是中间数的情况。
如图所示,此时能够保证分割线左边的元素总数比右边多 1,但是第 1 个数组分割线右边第 1 个数 6 小于第 2 个数组分割线左边第 1 个数 8。说明,第 1 个数组左边的数少了,分割线要右移。
如图所示,此时能够保证分割线左边的元素总数比右边多 1,但是第 1 个数组分割线左边第 1 个数 8 小于第 2 个数组分割线左边第 1 个数 7。说明,第 1 个数组左边的数多了,分割线要左移。
因此,就是在这种不断缩小搜索范围的方法中,定位这个索引 i
是多少。
这里要注意一个问题,那就是我们要在一个短的数组上搜索 i
,因为在搜索的过程中,我们会比较分割线左边和右边的数,即 nums[i]
、 nums[i - 1]
、 nums[j]
、 nums[j - 1]
,因此,这几个数的索引不能越界。
一个比较极端的情况如下:
此时,分割线在第 2 个数组的左边没有值,会导致 nums2[j - 1]
的访问越界。因此我们必须在短的数组上搜索 i
。i
的定义是分割线的右边,而它的左边一定有值
这样就能保证,分割线在第 2 个数组的左右两边一定有元素,即分割线一定可以在第 2 个数组的中间切一刀。
下面就是这个题目的最后一个细节了,那就是即使我在短数组上搜索边界 i
,还真就可能遇到 i
或者 j
的左边或者右边取不到元素的情况,它们一定出现在退出循环的时候。
为此,我们单独做一个判断就可以了。
我们把最关心的“边界线”两旁的 4 个数的极端值都考虑一下:
1、考虑 nums1
:
(1)当 i = 0
的时候,对应上图右边,此时数组 nums1
在红线左边为空,可以设置 nums1_left_max = 负无穷
。
这样在最终比较的时候,因为左边粉色部分要选择出最大值,它一定不会被选中,于是能兼容其它情况;
(2)当 i = m
的时候,对应上图左边,此时数组 nums1
在红线右边为空,可以设置 nums1_right_min = 正无穷
。
这样在最终比较的时候,因为右边蓝色部分要选择出最小值,它一定不会被选中,于是能兼容其它情况。
2、考虑 nums2
:
(1)当 j = 0
的时候,对应上图左边,此时数组 nums2
在红线左边为空,可以设置 nums2_left_max = 负无穷
。
这样在最终比较的时候,因为左边粉色部分要选择出最大值,它一定不会被选中,于是能兼容其它情况;
(2)当 j = n
的时候,对应上图右边,此时数组 nums2
在红线右边为空,可以设置 nums2_right_min = 正无穷
。
这样在最终比较的时候,因为右边蓝色部分要选择出最小值,它一定不会被选中,于是能兼容其它情况。
Java 代码:
public class Solution {
public double findMedianSortedArrays(int[] nums1, int[] nums2) {
// 为了让搜索范围更小,我们始终让 num1 是那个更短的数组,PPT 第 9 张
if (nums1.length > nums2.length) {
int[] temp = nums1;
nums1 = nums2;
nums2 = temp;
}
// 上述交换保证了 m <= n,在更短的区间 [0, m] 中搜索,会更快一些
int m = nums1.length;
int n = nums2.length;
// 使用二分查找算法在数组 nums1 中搜索一个索引 i
int left = 0;
int right = m;
// 因为 totalLeft 这个变量会一直用到,因此单独赋值,表示左边粉红色部分一共需要的元素个数
int totalLeft = (m + n + 1) >>> 1;
while (left < right) {
int i = (left + right) >>> 1;
int j = totalLeft - i;
if (nums2[j - 1] > nums1[i]) {
left = i + 1;
} else {
right = i;
}
}
int i = left;
int j = totalLeft - left;
int nums1LeftMax = i == 0 ? Integer.MIN_VALUE : nums1[i - 1];
int nums1RightMin = i == m ? Integer.MAX_VALUE : nums1[i];
int nums2LeftMax = j == 0 ? Integer.MIN_VALUE : nums2[j - 1];
int nums2RightMin = j == n ? Integer.MAX_VALUE : nums2[j];
if (((m + n) & 1) == 1) {
return Math.max(nums1LeftMax, nums2LeftMax);
} else {
return (double) ((Math.max(nums1LeftMax, nums2LeftMax) + Math.min(nums1RightMin, nums2RightMin))) / 2;
}
}
}
复杂度分析:
-
时间复杂度: O ( log ( min ( M , N ) ) ) O(\log(\min(M,N))) O(log(min(M,N))),为了使得搜索更快,我们把更短的数组设置为 nums1 ,因为使用二分查找法,在它的长度的对数时间复杂度内完成搜索。
-
空间复杂度: O ( 1 ) O(1) O(1),只使用了常数个的辅助变量。
这就是这一节的内容。通过这两道问题的学习,我们可以认识到,二分查找法不仅可以用于在一个有序数组里查找元素,甚至也可以应用与在一个范围里定位元素。而这个范围其实我们也可以把它看做一个有序的数组。
而更广义上,只要是能够减而治之,特别是一下子能排除一半甚至一半以上的区间,我们都可以使用二分法去完成。这就是这一节的内容,下一节我们学习在数组上的一类经典问题,“滑动窗口”问题的解法。