前言
明天考试,但是实在没有复习的欲望,感觉丝毫没有前几天刷 leetcode 有意思,姑且就写一篇算法讲解的 blog,讲一下我觉得非常有意思的一道题:
给定两个大小分别为 和 的正序(从小到大)数组 和 。请你找出并返回这两个正序数组的中位数,算法的时间复杂度应该为 。
分析
看到有序 + 复杂度,显然这是一个二分查找的题,但是难点就在于这里同时有两个数组,如果做归并(merge)并提前返回,时间复杂度一定是 的,所以我们必须在不合并的前提下同时对两个数组做二分查找。
而且,令总数组长度为 ,对于长度不同的数组,我们所要二分查找的数还不一样:
- 对于奇数长度的数组,我们仅需要查找 索引处的数即可。
- 对于偶数长度的数组,我们需要查找 和 索引处的数相加除以 。
思路
首先,我们可以抽象出一个过程(Procedure),给定两个有序数组,找到索引为 的元素,函数原型类似于:
int findKth(const std::vector<int>& nums1, const std::vector<int>& nums2, int k);如果我们实现好了这个函数,那么我们最终的答案很简单的就是:
class Solution {
public:
double findMedianSortedArrays(std::vector<int>& nums1, std::vector<int>& nums2) {
int m = nums1.size(), n = nums2.size();
if ((m + n) % 2 == 0) {
return (double) (
findKth(nums1, nums2, (m + n) / 2 - 1) +
findKth(nums1, nums2, (m + n) / 2)
) / 2;
} else {
return findKth(nums1, nums2, (m + n) / 2);
}
}
};因此接下来我们需要实现 findKth 这个函数。
然后,令这两个有序数组分别为 和 ,归并后的数组为 ,并且不失一般性的,不妨设 ,以便后续分析。
对于 ,我们要找的数肯定要么位于 ,要么位于 ,要么作为重复元素同时位于 和 。不管怎样,我们都可以假设,在通过二分查找到索引为 的数时,在 中至少有 个数小于等于这个数,在 中至少有 个数小于等于这个数,则有 。
证明很简单,如果我们在合并后的数组 中根据索引 将其切成两半,那么左边正好有 个数。由于 和 各自有序,这个切分对应到两个数组中也一定是两个前缀,设这两个前缀长度分别为 和 ,自然有 。
举个例子,,我找的是 的元素,归并后找一下发现是 这个数。在 左侧, 贡献了 这 个数, 贡献了 这 个数。因此有 。
我们二分的时候所需要找的就是合适的 和 ,正好对应着两个数组中位于 左侧的前缀长度,而 或者 所在索引处正好对应着我们需要的索引为 的元素,即 。
因此,一个初步的二分模板如下所示:
int findKth(const std::vector<int>& nums1, const std::vector<int>& nums2, int k) {
int m = nums1.size(), n = nums2.size();
int l = std::max(0, k - n), r = std::min(m, k);
while (l <= r) {
int k1 = l + (r - l) / 2;
int k2 = k - k1;
// TODO
}
}其中 k2 由 k1 被 k 减去而得到,因此天然保证了 的约束,同时为了防止 k 越界,并且尽可能预先缩小二分区间,初始化 l 和 r 的时候相应的做了调整。
但是,我们怎么知道什么情况下 和 恰好是 左侧的前缀长度呢?这时候,我们就要思考一下如果恰好是的情况下,会有什么特性:
- 两数组有序,因此显然 且 。
,因此显然 且 。
- ,因此可证 ,否则,如果 ,则有 ,那么在 中,比 小的数至少有 个(从 到 ),而在 中,比 小的数有 个(从 到 ),因此在 中,比 小的数至少有 个,但是 ,矛盾!
- ,同理可证 。
纯数学语言可能不是很直观,画出来就很直观了。
如果 ,那么这种错误情况下的错误排列如下所示:
如果 ,那么这种错误情况下的错误排列如下所示:
而 和 恰好是 左侧的前缀长度时,对应的 和 数组中元素的正确排列方式为:
其中 ,或者
其中 ,或者
其中 ,或者
其中 。
因此,在二分查找过程中,只要 且 ,那么我们就找到了正确的 和 ,这便是二分查找的退出条件。
而如果 ,那么说明 太小了,而 太大了;如果 ,那么说明 太小了,而 太大了,这便是二分查找缩小边界的规则。
因此,我们可以有如下实现:
int findKth(const std::vector<int>& nums1, const std::vector<int>& nums2, int k) {
int m = nums1.size(), n = nums2.size();
int l = std::max(0, k - n), r = std::min(m, k);
while (l <= r) {
int k1 = l + (r - l) / 2;
int k2 = k - k1;
int l1 = (k1 == 0) ? INT_MIN : nums1[k1 - 1];
int r1 = (k1 == m) ? INT_MAX : nums1[k1];
int l2 = (k2 == 0) ? INT_MIN : nums2[k2 - 1];
int r2 = (k2 == n) ? INT_MAX : nums2[k2];
if (l1 <= r2 && l2 <= r1) {
return std::min(r1, r2);
} else if (l1 > r2) {
r = k1 - 1;
} else {
l = k1 + 1;
}
}
return -1; // not found
}工程上,因为可能会有数组越界的问题,所以如果遇到数组边界,我们就将边界设置为 INT_MIN 和 INT_MAX。
综上,这题的一种解法如下所示:
class Solution {
public:
double findMedianSortedArrays(std::vector<int>& nums1, std::vector<int>& nums2) {
if (nums1.size() > nums2.size()) {
return findMedianSortedArrays(nums2, nums1);
}
int m = nums1.size(), n = nums2.size();
if ((m + n) % 2 == 0) {
return (double) (
findKth(nums1, nums2, (m + n) / 2 - 1) +
findKth(nums1, nums2, (m + n) / 2)
) / 2;
} else {
return findKth(nums1, nums2, (m + n) / 2);
}
}
private:
int findKth(const std::vector<int>& nums1, const std::vector<int>& nums2, int k) {
int m = nums1.size(), n = nums2.size();
int l = std::max(0, k - n), r = std::min(m, k);
while (l <= r) {
int k1 = l + (r - l) / 2;
int k2 = k - k1;
int l1 = (k1 == 0) ? INT_MIN : nums1[k1 - 1];
int r1 = (k1 == m) ? INT_MAX : nums1[k1];
int l2 = (k2 == 0) ? INT_MIN : nums2[k2 - 1];
int r2 = (k2 == n) ? INT_MAX : nums2[k2];
if (l1 <= r2 && l2 <= r1) {
return std::min(r1, r2);
} else if (l1 > r2) {
r = k1 - 1;
} else {
l = k1 + 1;
}
}
return -1;
}
};其中,为了加快二分收敛,所以我们通过交换引用从而尽可能减少被二分的 nums1 的长度。
优化
其实,在这种解法之上,我们还可以再做一些优化,让 findKth 只需要调用一次!
注意到,我们在使用二分查找的时候,额外选用的是 和 这两个左边的索引,那么显然,如果我用 和 这两个右边的索引,然后把函数里面对应的符号修改一下,是不是也是一样的能够实现 findKth 函数?
再进一步,实际上 findKth 函数不仅找到了索引为 的数,同时还找到了索引为 的数!即,,且 !
证明也不难,把之前列举的那几种 和 的相对位置的情况全都重写一遍就结束了。
因此,优化后的解法如下所示:
class Solution {
public:
double findMedianSortedArrays(std::vector<int>& nums1, std::vector<int>& nums2) {
if (nums1.size() > nums2.size()) {
return findMedianSortedArrays(nums2, nums1);
}
int m = nums1.size(), n = nums2.size();
int l = 0, r = m;
while (l <= r) {
int k1 = l + (r - l) / 2;
int k2 = (m + n + 1) / 2 - k1;
int l1 = (k1 == 0) ? INT_MIN : nums1[k1 - 1];
int r1 = (k1 == m) ? INT_MAX : nums1[k1];
int l2 = (k2 == 0) ? INT_MIN : nums2[k2 - 1];
int r2 = (k2 == n) ? INT_MAX : nums2[k2];
if (l1 <= r2 && l2 <= r1) {
if ((m + n) % 2 == 0) {
return (double) (std::max(l1, l2) + std::min(r1, r2)) / 2;
} else {
return std::max(l1, l2);
}
} else if (l1 > r2) {
r = k1 - 1;
} else {
l = k1 + 1;
}
}
return -1; // not found
}
};其中,我们令 ,对于偶数长度的数组来说,,而 ,正好是求中位数所需的两个数;对于奇数长度的数组来说,,而 ,后者是我们求中位数所需的那个数。因此,不论是偶数还是奇数长度,我们都可以在一次二分查找中全部找到所需的数。
结语
这是一道非常经典并且有趣的二分查找的困难题,很久之前就在我校《算法基础》课程的考试中出现过,当时我实在没想出来有什么好的解法,直到最近刷 leetcode 才想到如此优雅高效并且易于理解的好方法,希望能帮助读者更好吃透这道优秀的题目。