题目:给定两个大小分别为 m 和 n 的正序(从小到大)数组 nums1 和 nums2。请你找出并返回这两个正序数组的 中位数 。
算法的时间复杂度应该为 O(log (m+n)) 。
作为目前遇到的第一个困难级别题目,我感觉这题还是挺难的,研究了三天总算研究明白了,下面就给大家分享一下这题的几种解法,请大家跟着我的思路循序渐进地来解这道题。
鉴于这题的难度,我们先来一个娱乐解法给大家先放松一下,而且在项目上遇到这样的需求我想绝大多少都会用这种方式直接处理,而且这种思路更符合人的最直观思维。简单说下思路,就是直接用语言自带的方法先把两个数组拼接起来,然后对拼接后的数组排序,最后根据数组个数奇偶直接获取值。代码如下:
//使用内置方法直接合并数组并排序法
public static double MergedArraySort(int[] nums1, int[] nums2)
{
var merged = nums1.Concat(nums2).ToArray();
Array.Sort(merged);
var len = merged.Length;
if (len % 2 == 0)
{
return (merged[(len / 2) - 1] + merged[len / 2]) / 2.0;
}
else
{
return merged[len / 2];
}
}
当然我们做算法题是为了研究算法思维,寻找更优的解决方案,因此我们继续探索更算法的解法。
虽说上面的解法有娱乐的成分,但是却也提供了最直观的思路,我们沿用上面的整体思路,把调用语言内置方法的地方改为我们自己实现。先梳理一下整体思路。
1.首先构建一个可以容纳两个数组的大数组;
2.然后同时从两个数组的起始位置开始比较,如果值较小则进入大数组,值较大则继续和后面元素比较;
3.处理数组1没有数据,数组2有数据的情况;
4.处理数组2没有数据,数组1有数据的情况;
5.处理其他情况;
6.这样两个数组元素都处理完成,则数组合并完成,并且是有序的;
7.根据大数组元素个数奇偶取出中位数。
可以看看下图实际步骤详解:
实现代码如下:
//双指针合并数组后取值法
public static double TwoPointerMergedArray(int[] nums1, int[] nums2)
{
//创建一个可以容纳两个数组的大数组
var list = new int[nums1.Length + nums2.Length];
//数组1的索引
var idx1 = 0;
//数组2的索引
var idx2 = 0;
//大数组的索引
var idx = 0;
//如果两个数组都处理完成则退出,每次大数组索引前进1位
while (idx1 < nums1.Length || idx2 < nums2.Length)
{
if (idx1 >= nums1.Length)
{
//如果数组1没有数据了,则把数组2当前值填充至大数组后,把数组2索引前进1位
list[idx] = nums2[idx2++];
}
else if (idx2 >= nums2.Length)
{
//如果数组2没有数据了,则把数组1当前值填充至大数组,把数组1索引前进1位
list[idx] = nums1[idx1++];
}
else
{
//当两个数组都有数据,则比较当前两个数,数小的则填充至大数组,并且把其对应的索引前进1位
if (nums1[idx1] < nums2[idx2])
{
list[idx] = nums1[idx1++];
}
else
{
list[idx] = nums2[idx2++];
}
}
//大数字索引前进1位
idx++;
}
if (idx % 2 == 0)
{
//偶数则取中间两位数,并取平均数
return (list[(idx / 2) - 1] + list[idx / 2]) / 2.0;
}
else
{
//奇数则去中间一位数据直接返回
return list[idx / 2];
}
}
因为构建了大数组所以空间复杂度是O(n+m),因为把两个数组都遍历了一遍,所以此算法时间复杂度为O(n+m),因此不能满足题目要求的O(log(n+m))。虽然此解法不是我们所要的,但是也给我们继续寻找更好解法提供了逻辑基础。
想想题目,我们是要求中位数,回想一下上一节的解法,我们完全没有必要构建一个大数组,也没有必要把两个数组都处理完,只有找到中位数就可以了,也就是如果偶然我们只需要取两个值,而如果是奇数我们只需要取一个值。因此上一节解法中存在大量没必要的计算。
因此上一节整个解题思路架构可以保留下来,把不必要的去掉即可,思路如下:
1.同时从两个数组的起始位置开始比较,如果值较小则存入临时变量中;
2.处理数组1没有数据,数组2有数据的情况;
3.处理数组2没有数据,数组1有数据的情况;
4.处理其他情况;
5.到达右中位数位置处,则结束处理;
6.根据奇偶和临时变量取出中位数。
先来看代码:
//双指针直接取值法
public static double TwoPointerDirectValue(int[] nums1, int[] nums2)
{
//两个数组总长度
var len = nums1.Length + nums2.Length;
//左数,偶数:左中位数,奇数:中位数前一位
var left = 0;
//右数,偶数:右中位数,奇数:中位数
var right = 0;
//数组1索引
var idx1 = 0;
//数组2索引
var idx2 = 0;
//当中位数位处理完则退出
while (true)
{
//把上一个值先放入左数,右数存当前值
left = right;
if (idx1 >= nums1.Length)
{
//如果数组1没有数据了,则把数组2当前值放入右数,把数组2索引前进1位
right = nums2[idx2++];
}
else if (idx2 >= nums2.Length)
{
//如果数组2没有数据了,则把数组1当前值放入右数,把数组1索引前进1位
right = nums1[idx1++];
}
else
{
//当两个数组都有数据,则比较当前两个数,数小的则放入右数,并且把其对应的索引前进1位
if (nums1[idx1] < nums2[idx2])
{
right = nums1[idx1++];
}
else
{
right = nums2[idx2++];
}
}
//当中位数位处理完则结束处理
if (len / 2 == idx1 + idx2 - 1)
{
break;
}
}
if (len % 2 == 0)
{
//偶数则取左数和右数平均数
return (left + right) / 2.0;
}
else
{
//奇数则直接返回右数
return right;
}
}
通过代码可以发现和上一节解法有几个小差别以及关键的。
1.去掉了大数组,改为添加了left,right两个变量来存储中位数;
3.关键点为什么if (len / 2 == idx1 + idx2 - 1)作为结束处理标志,首先为什么idx1 + idx2 – 1要减1,这是因为在上面取值的同时都进行了后++操作,所以这里要把这个1减掉。然后为什么是len / 2作为结束点,因为/是向下取整,因此一个偶然和比这个偶数大1的奇数,在经过/2处理后值是一样的,又因为len表示的是数组元素个数,而元素个数正好是比索引大1,因此当数组为奇数时len/2就表示中位数,当数组为偶数时len/2就表示右中位数,因此可以视为处理结束标志。
因为去掉了构建大数组因此空间复杂度是O(1),因为处理的数据量减少了一半因此时间复杂度是O((n+m)/2),也就是O(n+m)。因此还是没满足要求。
显然要想达到O(log(n+m))的时间复杂度,就不能一个元素一个元素处理,这不由的让我们想到二分思想,既然一个一个处理不行,那么我们就一次多处理几个,把不满足要求的数据都排除掉,一次排除一半,这样不就离我们的目标值越来越近了嘛,最后就到达我们的目标值了。
我们继续沿用上一节解法思想框架,做一些改动,思路如下:
1.同时从两个数组的左中位数位置的一半处开始比较,如果值较小则排除掉;
2.处理数组1没有数据,数组2有数据的情况,并结束处理;
3.处理数组2没有数据,数组1有数据的情况,并结束处理;
4.处理指针停留在左中位数位置的情况,并结束处理;
5.一次比较完成后,继续取当前位置到左中位数位置的一半处,进行下一次比较;
6.根据奇偶和临时变量取出中位数。
下面我们结合图例来做个详细说明。
找到左中位数也就意味着可以同时直接处理左右中位数。
我们在来看看代码实现:
//双指针二分排除查找法
public static double TwoPointerBinaryExclude(int[] nums1, int[] nums2)
{
//数组总长度
var len = nums1.Length + nums2.Length;
//目标数,表示中位数在数组中第几个数,偶数则代表左中位数,奇数则代表中位数
var target = (len + 1) / 2;
//左中位数
int left;
//右中位数
int right;
//数组1索引
var idx1 = 0;
//数组2索引
var idx2 = 0;
//当中位数位处理完则退出
while (true)
{
//数组1没数据了,则直接在数组2中获取中位数
if (idx1 >= nums1.Length)
{
//因为数组1没有数据了,所以数组2索引前进到目标数处
idx2 += target - 1;
//直接取中位数
if (len % 2 == 0)
{
//偶数
//左中位数为当前值
left = nums2[idx2];
//右中位数为下一个值
right = nums2[idx2 + 1];
}
else
{
//奇数,左右中位数相同都是当前值
left = right = nums2[idx2];
}
break;
}
//数组2没数据,则直接在数组1中获取中位数
if (idx2 >= nums2.Length)
{
//因为数组2没有数据了,所以数组1索引前进到目标数处
idx1 += target - 1;
//直接取中位数
if (len % 2 == 0)
{
//偶数
//左中位数为当前值
left = nums1[idx1];
//右中位数为下一个值
right = nums1[idx1 + 1];
}
else
{
//奇数,左右中位数相同都是当前值
left = right = nums1[idx1];
}
break;
}
//当目标数为1时,表明当前值就是要找的值
if (target == 1)
{
//直接取中位数
if (len % 2 == 0)
{
//偶数
if (nums1[idx1] < nums2[idx2])
{
//如果nums1当前值比较小,则查看其之后是否还有元素
if (idx1 + 1 > nums1.Length - 1)
{
//如果其之后没有元素,则左中位数为nums1当前值,右中位数为nums2当前值
left = nums1[idx1];
right = nums2[idx2];
}
else
{
//如果其之后有元素,则左中位数为nums1当前值,右中位数则为nums1当前值后一个值和nums2当前值中较小值
var temp = nums1[idx1 + 1];
left = nums1[idx1];
right = Math.Min(nums2[idx2], temp);
}
}
else
{
//如果nums2当前值比较小,则查看其之后是否还有元素
if (idx2 + 1 > nums2.Length - 1)
{
//如果其之后没有元素,则左中位数为nums2当前值,右中位数为nums1当前值
left = nums2[idx2];
right = nums1[idx1];
}
else
{
//如果其之后有元素,则左中位数为nums2当前值,右中位数则为nums2当前值后一个值和nums1当前值中较小值
var temp = nums2[idx2 + 1];
left = nums2[idx2];
right = Math.Min(nums1[idx1], temp);
}
}
}
else
{
//奇数,左右中位数相同,取nums1当前值和nums2当前值中较小值
left = right = Math.Min(nums1[idx1], nums2[idx2]);
}
break;
}
//取出目标数位置的一半
var half = target / 2;
//确定nums1比较数,并确保其不会大于自身长度
var compare1 = Math.Min(idx1 + half, nums1.Length);
//确定nums2用比较数,并确保其不会大于自身长度
var compare2 = Math.Min(idx2 + half, nums2.Length);
//比较两个数组比较数,compare-1因为比较数表示第几个数,减1转为索引
if (nums1[compare1 - 1] < nums2[compare2 - 1])
{
//nums1的比较数 小,则排除掉
//要查找的目标数需要减掉已经排除掉的个数
target -= compare1 - idx1;
//同时nums1当前索引前进到被排除掉元素的后一位
idx1 = compare1;
}
else
{
//nums2的比较数 小,则排除掉
//要查找的目标数需要减掉已经排除掉的个数
target -= compare2 - idx2;
//同时nums2当前索引前进到被排除掉元素的后一位
idx2 = compare2;
}
}
return (left + right) / 2.0;
}
虽然上面代码还很符合我们的思维理解方式的,但是有两个问题需要好好思考:
1.为什么当两个数组值比较时值较小的数及其之前的数可以直接被排除掉?
2.三处结束处理标记代码块中都包含了对奇偶不同情况的处理,使得代码冗长复杂,有什么更好的处理方式吗?
在这个解法里,我们来解答上面的两个问题。
其实上一节解法中已经包含了二分查找第K小数的核心代码,因为我们在上一解法中最核心的就是先找到左中位数,所以只要把获取右中位数处理及奇偶判断相关代码删掉,其实就得到一份二分查找第K小数算法了,然后分别把左第K小数和右第K小数分别带入算法得到左中位数和右中位数,最后平均得到中位数。
具体代码如下:
//双指针二分查找第K小数法
public static double TwoPointerBinaryFindKth(int[] nums1, int[] nums2)
{
//左第K小数,当为偶数,代表左中位数,当为奇数,代表中位数
var leftKth = (nums1.Length + nums2.Length + 1) / 2;
//右第K小数,当为偶数,代表右中位数,当为奇数,代表中位数(因为/向下取整特性)
var rightKth = (nums1.Length + nums2.Length + 2) / 2;
//获取左中位数
var left = TwoPointerBinaryFindKth(leftKth, nums1, nums2);
//获取右中位数
var rigth = TwoPointerBinaryFindKth(rightKth, nums1, nums2);
return (left + rigth) / 2.0;
}
//双指针二分查找第K小数
public static int TwoPointerBinaryFindKth(int kth, int[] nums1, int[] nums2)
{
//数组1索引
var idx1 = 0;
//数组2索引
var idx2 = 0;
//找到第K小数则退出
while (true)
{
//数组1没数据了,则直接在数组2中查找K
if (idx1 >= nums1.Length)
{
//因为数组1没有数据了,所以数组2索引前进到K数索引处
idx2 += kth - 1;
return nums2[idx2];
}
//数组2没数据,则直接在数组1中查找K
if (idx2 >= nums2.Length)
{
//因为数组2没有数据了,所以数组1索引前进到K数索引处
idx1 += kth - 1;
return nums1[idx1];
}
//当第K小数为1时,表明当前值就是要找的值
if (kth == 1)
{
return Math.Min(nums1[idx1], nums2[idx2]);
}
//取出第K小数位置的一半
var half = kth / 2;
//确定nums1比较数,并确保其不会大于自身长度
var compare1 = Math.Min(idx1 + half, nums1.Length);
//确定nums2用比较数,并确保其不会大于自身长度
var compare2 = Math.Min(idx2 + half, nums2.Length);
//比较两个数组比较数,compare-1因为比较数表示第几个数,减1转为索引
if (nums1[compare1 - 1] < nums2[compare2 - 1])
{
//nums1的比较数 小,则排除掉
//要查找的第K小数需要减掉已经排除掉的个数
kth -= compare1 - idx1;
//同时nums1当前索引前进到被排除掉元素的后一位
idx1 = compare1;
}
else
{
//nums2的比较数 小,则排除掉
//要查找的第K小数需要减掉已经排除掉的个数
kth -= compare2 - idx2;
//同时nums2当前索引前进到被排除掉元素的后一位
idx2 = compare2;
}
}
}
通过代码可以发现这个解法已经解决了上一节中的第二个问题,通过对奇偶两种情况左右中位数是第几位数的表示进行了统一,解决了需要判断奇偶做不同处理逻辑的问题,同时因为只关心一个数K,所以整个代码就简洁清晰了。
对比也能发现两个解法最核心的二分思想部分代码完全一样,差别就是一次处理一个值和一次处理两个值的差别。
这里面也有问题和上一个解法第一个问题一样,为什么当两个数组值比较时值较小的数及其之前的数可以直接被排除掉?
下面我们就来详细讲解一下这里可以直接排除掉的原理。
先梳理一下思路,为什么可以直接排除?如果我能证明每次排除的数都是再第K小的左边那边排除不就成立了嘛,因此上面的问题可以转化为证明每次排除掉的数都在第K小数的左边。而我们一直再从左边排除数,因此左边一直再变化着,但是右边的数一直没有变,因此我们再把问题转换一下,我们来证明被排除数右边的数量大于等于(n-k+1)个数。
下面我们以下面两个数组第一次排除为例,做个详细证明,其他次排除同理。
nums1:[1,4,8,9,11]
nums2:[2,3,5,6,7,10]
因为两数组总个数为11,所以我们找第6小数,即k为6,因此第一次从第6/2=3个数开始比较,即比较num1[idx2]与num2[idx2]。如下图。
因为num1[idx2] > num2[idx2],所以[2,3,5]需要排除掉,因此要证明红框中的个数一定是大于等于(n-k+1=11-6+1=6)。
首先看数组nums1因为8>5所以8肯定是被留下的,因此nums1至少有(len1-idx2)个数是大于5的,再看数组num2因为5要被排除,所以num2有(len2-(idx2+1))个数大于5。
因此大于5的数至少有[(len1-idx2)+(len2-(idx2+1))]
又因为idx2=k/2
所以大于5的数至少有[(len1-k/2)+(len2-(k/2+1))]
化简可得[len1+len2-k+1]
即[n-k+1]
同理如果数组为偶数也可证明,因此为能证明每次排的数右侧都剩下大于等于(n-k+1)个数,因此就证明排除的数肯定是再第K小数的左边可以直接排除,并且从上图可以发现排除的数并不一定是顺序的,但是这不重要,只有在第K小数左边就可以删除。
上面两种二分解法已经满足题目要求了,但是这一题还有更优的解法,我们继续来探索。
首先思考一下中位数有什么特性,我们是否能用中位数自身的特性来解题呢?
中位数又叫中值,就是按顺序排列的一组数据中居于中间位置的值,由此我们可以得出两个特性:
1.以中位数为分界线,则中位数左右两边数据个数相等;
2.以中位数为分界线,则中位数左边的最大数必然小于等于中位数右边的最小数。
那要如何应用这两个特性来解题呢?
如上图,就是如果我们找到一条线来分割两个数组,同时以分割线为准,分割线左边部分看作一个整体当作中位数左边,分割线右边部分看作一个整体当作中位数右边,如果左右两部分能满足上面两个特性就意味着这题就解决了,题目就转变成求解这条分割线了。
显然上图是不可能满足的因为数组总算是奇数,因此无论怎么分割左边和右边数都不可能相当的,除非把分割线画在一个数上,把一个数一分两半,显然不可能,这就没法满足第一个特性。
遇到问题就解决问题,既然对于奇数问题,不能使左右两边相等,那么我们就使得一边总比另一边多一个数总可以了吧,因为有序特性,因此这样的改变并不会影响第二个特性,也就相当于满足了两个特性。
到这里解题的核心思想框架就有了:找到一条分割两个数组的线,使得其能直接或间接满足中位数的两个特性。
核心思想框架有了,接下来就要填充关键逻辑处理点了。
第一个问题:从哪里开始处理?
当然我们还需要继续采用二分思想,因此从数组1中间位置开始处理。
第二个问题:既然从数组1中间开始位置处理,那么数组2怎么办?怎么取值?
并且当数组1当前索引idx1确定后数组2当前索引idx2就可以通过idx1计算得到。
因为当数组总长为偶数时,左右两边个数相等,所以可以得到
idx1+idx2=len1-idx1+len2-idx2,即idx2=(len1+len2)/2 – i;
当数组总长为计算时,我们设定左边比右边多一个数,可以得到
idx1+idx2=len1-idx1+len2-idx2+1,即idx2=(len1+len2+1)/2 – i;
第三个问题:既然分割线是要找出来的那,又是从数组1中间位置开始处理,那应该如何确定往那个方向找呢?
可以通过分割后左边最大值和右边最小值比较确定往那个方向找,就是要做到前进的方向可以把较小值分到左边,较大值分到右边。
第四个问题:处理的节奏是什么?
第一个问题也说了,因为我们还是继续采用二分思想,所以每次处理都会取上次的一半。
第五个问题:还有什么要注意的点吗?
剩下的就是对特殊边界的处理问题了,比如分割到数组的两端,以及数组总数奇偶性单独处理的部分。
到这里整个解题思路就梳理完成了,主要梳理了核心思想框架以及关键逻辑处理点,并没有具体去说有哪些特色边界问题、如何判断前进方向等,因为我感觉核心思想框架+关键逻辑处理点才是重中之重,然后我们再辅助一些图例,最后再看源码,基本就是一看就明白了。
下面以图例来看看两个数组应用二分查找平分法的完整查找过程。
具体代码如下:
//双指针二分查找平分法
public static double TwoPointerBinaryHalves(int[] nums1, int[] nums2)
{
//当数组1长度比数组2长度大,则交换两个数组,保证数组1为较短的数组
if (nums1.Length > nums2.Length)
{
//交换两个变量的值,C#7.0中 元组解构赋值 语法
(nums1, nums2) = (nums2, nums1);
}
//左指针
int idxLeft = 0;
//右指针
int idxRight = nums1.Length;
//对数组1进行二分查找
while (idxLeft <= idxRight)
{
//计算得到数组1分割后的当前索引
var idx1 = (idxLeft + idxRight) / 2;
//计算得到数组2分割后的当前索引
var idx2 = ((nums1.Length + nums2.Length + 1) / 2) - idx1;
//当数组2左边最大值大于数组1右边最小值时,左指针向右前进
if (idx2 != 0 && idx1 != nums1.Length && nums2[idx2 - 1] > nums1[idx1])
{
idxLeft = idx1 + 1;
}
//当数组1左边最大值大于数组2右边最小值时,右指针向左前进
else if (idx1 != 0 && idx2 != nums2.Length && nums1[idx1 - 1] > nums2[idx2])
{
idxRight = idx1 - 1;
}
else
{
//左边最大值
int leftMax;
//如果分割到数组1的最左边即数组1当前索引为0,则左边最大值直接取数组2
if (idx1 == 0)
{
leftMax = nums2[idx2 - 1];
}
//如果分隔到数组2的最左边即数组2当前索引为0,则左边最大值直接取数组1
else if (idx2 == 0)
{
leftMax = nums1[idx1 - 1];
}
//否则左边最大值为数组1和数组2中左边较大的值
else
{
leftMax = Math.Max(nums1[idx1 - 1], nums2[idx2 - 1]);
}
//如果数组为计算,则直接返回左边最大值,结束计算
if ((nums1.Length + nums2.Length) % 2 == 1)
{
return leftMax;
}
//右边最小值
int rightMin;
//如果分隔到数组最右边即数组1索引为其自身长度,则右边最小值直接取数组2
if (idx1 == nums1.Length)
{
rightMin = nums2[idx2];
}
//如果分隔到数组最右边即数组2索引为其自身长度,则右边最小值直接取数组1
else if (idx2 == nums2.Length)
{
rightMin = nums1[idx1];
}
//否则右边最小值为数组1和数组2中右边较小的值
else
{
rightMin = Math.Min(nums2[idx2], nums1[idx1]);
}
return (leftMax + rightMin) / 2.0;
}
}
return 0.0;
}
最后我们来对6种解法进行一组基准对比测试,每个方法分三组测试,三组分别表示数组大小为100,1000,10000,并且生成的两个数组中会随机控制为99或100、999或1000、9999或10000,即使得奇偶随机,同时对每一组随机生成10000组测试数据,进行对比测试。测试结果如下。
通过测试可以发现性能如下:
解法六双指针+二分查找平分法 > 解法四双指针+二分排除法 > 解法五双指针+二分查找第K小数法 > 解法三双指针+直接取值法 > 解法二双指针+合并数组法 > 解法一娱乐法(内置方法),二分查找第K小数法比二分排除法要差一些也正常,毕竟它调用了两次算法。
注:测试方法代码以及示例源码都已经上传至代码库,有兴趣的可以看看。https://gitee.com/hugogoos/Planner