Leetcode DAY5 寻找两个正序数组的中位数
一、问题描述
给定两个大小分别为 m 和 n 的正序(从小到大)数组 nums1 和 nums2。请你找出并返回这两个正序数组的 中位数 。
示例 :
输入:nums1 = [1,2], nums2 = [3,4]
输出:2.50000
解释:合并数组 = [1,2,3,4] ,中位数 (2 + 3) / 2 = 2.5
二、问题分析
2.1 合并数组找中位数
题目示例都告诉我们直接合并数组了,那就直接合并数组再按数组长度找中位数吧,今天的学习就到这里啦,大家再见~代码如下:
class Solution {
public:
double findMedianSortedArrays(vector<int>& nums1, vector<int>& nums2) {
vector<int> nums;
int length = nums1.size() + nums2.size();
double medium;
nums.resize(length);
merge(nums1.begin(),nums1.end(),nums2.begin(),nums2.end(),nums.begin());
auto i = nums.begin() + length/2;
if(length%2 == 0){
medium = *i;
--i;
medium = (medium + *i)/2;
}
else medium = *i;
return medium;
}
};
好啦骗人的,还是得分析,只不过这是第一次没看解析做出来的题目,还是困难难度,非常值得纪念hhhh而且刚好这次用上了前几次学到的vector和迭代器的知识,也算是检验了一下我的学习成果~几个需要注意的点放在第三部分知识点扩展里进行阐述。
2.2 二分查找法(保姆式教程)
此题若要求时间复杂度为O(log(m+n)),也就是说应使用二分查找法(二分查找的时间复杂度可以证明为O(logn)。
在此题中,我们需要查找的数是中位数,其中数组的长度为奇数或偶数将影响中位数的查找。因为数组是从小到大的有序数组,则在长度为奇数的数组中,中位数为nums[length/2+1],在长度为偶数的数组中,中位数为(nums[length/2]+nums[length/2+1])/2.0。
总之不管怎么样,寻找中位数也就是寻找第k小的数,这个k是length/2+1,或者是length/2。那么这道寻找中位数的题就转换为寻找第k小的数,而解决此类问题的方法可以使用二分查找法。
二分查找法的精髓在于通过将数组中的数与目标数比较,不断缩小目标数的寻找范围,在实际算法中,就是在有序数组中,舍弃掉从左到右或者从右到左的一些数,而舍弃的这些数究竟有多少个呢?由二分查找的名字就可以得知,一次性舍弃二分之一的数,但问题又来了,是谁的二分之一呢?整个数组的二分之一?或者是目标数的二分之一?因为在实际算法中,我们舍弃的数是连续的段落,因此答案很明显不是整个数组的二分之一,因为目标数一定会在数组的左二分之一或者右二分之一,我们无法确定它到底在哪一边,而贸然舍弃整个数组的二分之一很可能会导致把目标数舍弃。因此我们采取舍弃目标数的二分之一数量的数,只要选择合适的段落,就可以保证一定不会把目标数不小心舍弃掉,在有序数组中,通过左二分之一右边界的数和右二分之一左边界的数与目标数比较,以此确定应该舍弃哪一边。
在以上描述中,隐含着二分查找的具体步骤,也就是:
1、将段落边界数与目标数比较。(比较)
2、舍弃数量为目标数的二分之一的数。(剪枝)
而在本题中,二分查找的范围不是单纯的一个有序数组,变成了在两个有序数组中进行二分查找,事情变得难办了起来,说好的舍弃数量为目标数二分之一的数,而且这些数都是有序的,那两个数组的数,他们的顺序仅在自己所在的数组里是有序,但是在另外一个数组里是未知的,应该怎么分配每个数组应该舍弃的数量呢?
别急,让我们再回头看看二分查找的精髓是什么,“通过将数组中的数与目标数比较,不断缩小目标数的寻找范围”,也就是说在本题中,我们要舍弃的这些数是连续的段落,它们只需要比目标数小,总数量为目标数的二分之一即可,至于它在数组里的位置,who care?即使是在三个数组、四个数组、n个数组里,我们都只需要满足以上条件即可,无需关注在数组中的位置。
因此,在这道题中,我们得找满足以下条件的数进行舍弃(剪枝):
条件1:一些连续的数
条件2:这些数比目标数小
条件3:总数量为目标数的二分之一
那么在实际算法中怎么实现以上条件呢?
题意中目标数是第k小的数,目标数的二分之一即为k/2。因此我们舍弃k/2个数,满足条件3。为了满足条件1,假设要舍弃的这k/2个连续的数在一个数组里,已知数组是顺序数组,要舍弃k/2个数,也就是舍弃nums1[0] ~ nums1[k/2-1]或nums2[0] ~ nums2[k/2-1],但是舍弃哪个数组的数呢?答案是舍弃边界数小的那一堆数,也就是比较nums1[k/2-1]和nums2[k/2-1],谁小就舍弃哪个数组的nums[0] ~ nums[k/2-1]。
至此,我们已经实现了三个条件中的两个,那么条件2我们实现了吗?可以证明,在以上实现条件1和条件3的过程中,我们就已经实现了条件2,证明如下:
针对被舍弃的那一堆连续的k/2个数nums1[0] ~ nums1[k/2-1],在实现条件1时,我们对两个数组的nums[k/2-1]进行了比较,所以已知nums1[k/2-1]一定比nums2[k/2-1]小,而nums1[k/2-1]和nums2[k/2-1]前面的(k/2-1)个数nums2[0] ~ nums2[k/2-2]相比,不一定谁小谁大,nums1[k/2-1]在第二个组里最多能找到(k/2-1)个数比它小,因此将两个数组比它小的数加起来,最多只有(k-2)个数比它小,也就是说,nums1[k/2-1]最多是第(k-1)小的数,它不可能是第k小的数。至此,我们就证明了舍弃的这些数比目标数第k小的数更小,满足条件3。
好了,经过以上步骤,我们可算是知道如何寻找二分查找里需要丢弃的那些数了,接下来要做的就是一直重复以上步骤,直到最后只剩下一个数,那个数闪闪发光,它,就是我们踏破铁鞋寻找到的目标数~呼,二分查找到此结束 ~
wait,你以为就这么轻松吗?理想总是丰满的,而现实总是残酷的。在实际算法中,仍有很多细枝末节需要我们注意。比如,我们知道第一次要淘汰k/2个数,第二次呢?还是淘汰k/2个数吗?还有舍弃数的具体操作又是什么样的呢?数又没有实体,没办法真的扔进垃圾桶里就不考虑了。而且针对此题,我们在一个数组里舍弃k/2个数,那万一这个数组本身的长度就没有k/2又怎么办呢?
思维可以超越肉体的束缚飞到任何地方,而要实现想法却一定要脚踏实地。让我们来看看以上问题。
二分查找的精髓是“通过将数组中的数与目标数比较,不断缩小目标数的寻找范围”,也就是目标数是第k小的数,那我们就丢弃k/2个数,目标数的第k小是针对整个数组而言的。当第一次丢弃这k/2个数之后,数组就变小了,目标数不再是原先的第k小的数了,此时k=k-k/2,也就是说这个第k小数的k是会变化的,并且它是以当前数组长度为参考,而不是以原数组长度为参考。
关于舍弃数,或者称之为剪枝,在实际算法中,并不是真的使数组删去这部分数,而是通过设置索引index,营造出删掉了这部分数的假象。很明显index的初始值为0,在进行剪枝后,index发生变化,index=index+剪枝数量。并且index的变化仅限于需要剪枝的数组。
根据以上分析,我们可以得知,在不断重复二分查找的过程中,变化的参数只有两个,一个是目标数第k小数的k,另一个是索引index。索引index的变化实质上是剪枝后数组长度length的“变化”。因此在二分查找的过程中,变化的参数为目标数k和数组长度length。
为什么要明确二分查找的过程中具体有哪些参数进行变化呢?因为我们可以根据这些参数的变化观察到二分查找最本质的变化过程和边界条件。
目标数k的边界条件很明显是k=1,这时候也不用什么二分查找法了,直接找到有序数组的首或尾即可,对于本题而言,直接比较两个数组的第一个元素,返回最小的那个元素即可。
数组长度length在没到达边界之前,会先遇到数组长度length小于需要剪枝的数量k/2的问题,就没办法舍弃k/2个数,因为它只有这么一点数,我们也没办法给它多编一些数出来,那就最多只能舍弃整个数组的数,那么我们就降低一点点标准,就让这个发育不良的数组的末尾,也就是它最大的数和另一个数组的nums[k/2-1]进行比较,并且做好舍弃整个数组的准备。
除此之外,数组长度length的边界条件是length=0(仅一个数组为空数组)。这时候也不用二分查找法了,对于本题而言,只用在另一个非空数组里找到第k小的数即可。
经过以上分析,我们已经了解了全部的特殊情况以及它的解决方法。也就是:
1、k=1
2、数组长度length<剪枝数量k/2
3、其中一个数组的长度length=0,即为空数组
讨论完全部的特殊情况以及参数变量的变化过程之后,我们可以获得参数变量的表达式了,如下所示,有些重点请看注释:
//“初始”索引
int index1 = 0, index2 = 0;
//用于判断数组长度与剪枝数量的关系
int newindex1 = min(index1 + k / 2 - 1, m - 1);
int newindex2 = min(index2 + k / 2 - 1, n - 1);
//判断舍弃哪个数组的数
if (nums1[newindex1] <= nums2[newindex2]) {
/*前文提到过k是以剪枝后的数组长度为参考的
但从newindex的定义来看,它是以整个原数组长度为参考的
因此两者之间进行运算时必须进行参考坐标的转换!!!
*/
k = k -(newindex1 + 1) +index1;
//“初始”索引更新
index1 = newIndex1 + 1;
}
特殊情况或者说边界条件的具体实现都比较简单,此处不讲。
最后回到本题,我们需要寻找这两个数组的中位数,又根据两个数组长度总和的奇偶,中位数分别是nums[length/2+1]或(nums[length/2]+nums[length/2+1])/2.0。那么这个问题可以转化为寻找第k小数,而寻找第k小数的方法如上述文字。因此本题的具体代码如下:
class Solution {
public:
int getKthElement(const vector<int>& nums1, const vector<int>& nums2, int k) {
int m = nums1.size();
int n = nums2.size();
int index1 = 0, index2 = 0;
//知识点3:while(true)跳出循环的方法
while (true) {
// 边界情况
if (index1 == m) {
return nums2[index2 + k - 1];
}
if (index2 == n) {
return nums1[index1 + k - 1];
}
if (k == 1) {
return min(nums1[index1], nums2[index2]);
}
// 正常情况
int newIndex1 = min(index1 + k / 2 - 1, m - 1);
int newIndex2 = min(index2 + k / 2 - 1, n - 1);
int pivot1 = nums1[newIndex1];
int pivot2 = nums2[newIndex2];
if (pivot1 <= pivot2) {
k -= newIndex1 - index1 + 1;
index1 = newIndex1 + 1;
}
else {
k -= newIndex2 - index2 + 1;
index2 = newIndex2 + 1;
}
}
}
double findMedianSortedArrays(vector<int>& nums1, vector<int>& nums2) {
int totalLength = nums1.size() + nums2.size();
if (totalLength % 2 == 1) {
return getKthElement(nums1, nums2, (totalLength + 1) / 2);
}
else {
return (getKthElement(nums1, nums2, totalLength / 2) + getKthElement(nums1, nums2, totalLength / 2 + 1)) / 2.0;
}
}
};
三、知识点扩展
3.1 温故而知新(我写的代码里几个值得注意的点)
知识点1 vector的概念与性质
请参考两数之和中的知识点1。
知识点2 用于容器的合并排序merge函数
merge函数有两种使用方法,这里只介绍本文用到的一种。首先,先来看看merge函数的template定义:
template <class InputIterator1, class InputIterator2, class OutputIterator>
OutputIterator merge (InputIterator1 first1, InputIterator1 last1,
InputIterator2 first2, InputIterator2 last2,
OutputIterator result);
可以看出,merge函数主要针对可以使用迭代器的容器,输入为两个序列的首迭代器和尾迭代器,输出最终序列的首迭代器。
但是,merge函数又对输入输出的序列有什么样的要求呢?请看merge函数的概念:
Combines the elements in the sorted ranges [first1,last1) and [first2,last2), into a new range beginning at result with all its elements sorted.
(将有序序列[first1,last1)和[first2,last2)中的元素进行结合,形成一个全新的有序序列,该序列的首迭代器为result)
*ranges:序列
*first,last,result:均指迭代器
读完概念后,我们应该对merge函数有了一个更加清楚的认知,则merge函数的使用有以下2点需要注意:
1、merge函数要求输入是有序序列,并输出有序序列。
2、merge函数的具体参数均为迭代器,并且依据容量迭代器的性质,有效序列是用迭代器表示的左闭右开的内容。
参考文献:
merge函数
3.2 二分查找
知识点3 while(true)跳出循环的方法
由while()循环的循环条件可知,若为true那么会一直循环,为了跳出循环,一般使用
以下三种方式。其中return是直接结束函数。
break;
continue;
return ;