004.寻找两个正序数组的中位数
作者:沉默王二 发布时间:2023-12-14T09:11:24.568+0800
-
语雀专栏地址+密码可通过星球置顶帖查看:https://t.zsxq.com/15rEo9Pdu
-
技术派专栏地址:https://paicoding.com/column/7/ 4
-
增加代码注释,这样大家更容易看懂并掌握题解;
-
增加参考资料,这样大家可以从零基础直接开刷,减轻新手刷题的阻力;
-
增加手绘图,这样大家更容易理解解题思路;
-
增加 ACM 输入输出,这样大家可以在 Intellij IDEA 中进行调试;
-
增加题解模拟,这样大家可以根据题解模拟整个题解过程,看完会有一种“哦哦”恍然大悟的感觉;
-
图片上传至 OSS 并配合 CDN,提升大家在技术派网站中的阅读体感。
-
另外,这一版本会作为 PDF 的终稿随后在星球进行发布。
我每天都在读《二哥的 LeetCode 刷题笔记》。
------------------------by鲁迅
题意
给定两个大小分别为 m 和 n 的正序(从小到大)数组 nums1 和 nums2。请你找出并返回这两个正序数组的 中位数 。
你给出的算法的时间复杂度应该为 O(log (m+n)) 。
示例
输入:nums1=[1,3],nums2=[2]
解释:合并数组=[1,2,3],中位数2
输入:nums1=[1,2],nums2=[3,4]
解释:合并数组=[1,2,3,4],中位数(2+3)/2=2.5
难度
困难
好吧,第四道就遇到了 hard,算法新手很容易到这就被劝退了,但其实这道题并没有想象中那么可怕,请深吸一口气,告诉自己一定能行,笔试的时候其实也需要这样的心态。
尤其是在那种紧张氛围中,更需要保持冷静,不要被题目吓到,一定要相信自己,相信自己的能力,相信自己的实力。
实在做不出来,如果是比较热心肠的面试官,甚至会给你一些提示的,所以不要打退堂鼓,劳资打的就是精锐(😁)。
分析 1
首先,对于算法新手来说,这道题需要搞清楚几个概念:
- 正序的数组长什么样子?
- 两个数组如何合并?
- 中位数是什么?
正序的数组,也就是从小到大排序过的数组,就是数组中的元素从左到右,依次变大的数组,如下:
[1,2,3,4,5,6,7,8,9,10]
当然也可以跳着来,但一定是后面的数比前面的数大,如下:
[1,5,13,17,18,19]
两个数组如何合并呢?
最简单的就是使用 for 循环进行遍历,把两个数组复制到一个新的数组当中,这个我在《二哥的 Java 进阶之路》中讲**数组 **的时候讲过。
int[] array1 = {1, 2, 3};
// 创建一个新数组,长度为两个数组长度之和int[] mergedArray = new int[array1.length + array2.length]; // 复制第一个数组到新数组int index = 0;for (int element : array1) { mergedArray[index++] = element;} // 复制第二个数组到新数组for (int element : array2) { mergedArray[index++] = element;}
那高级一点的话,可以使用 Arrays.copyOfRange() 复制,它底层用到的是 System.arraycopy(),这个方法是 native 方法,效率更高(数组那一节里也讲过,球友可以过去看一眼,很快就能明白)。
那什么是中位数呢?
中位数,就是把一组数据按照从小到大的顺序排列,位于中间位置的那个数,如果数据的个数是奇数,那么中位数就是最中间的那个数,如果数据的个数是偶数,那么中位数就是最中间两个数的平均值。
- 奇数个数值:例如,在数据集 [1, 3, 3, 6, 7, 8, 9] 中,中位数是 6。
- 偶数个数值:例如,在数据集 [1, 2, 3, 4, 5, 6, 8, 9] 中,中间的两个数是 4 和 5,所以中位数是 (4 + 5) / 2 = 4.5。
中位数与平均值(或算术平均数)不同。平均值是所有数值的总和除以数值的个数,而中位数是数据集的中间值。
好,明白了这三个概念,问题就变得简单了,对吧?
来吧,先用 System.arraycopy 合并两个数组,然后再用 Arrays.sort () 排序,最后再根据数组的长度是奇数还是偶数,来计算中位数。
class Solution {
// 创建一个新数组,长度为两个数组长度之和 int[] temp = new int[nums1.length + nums2.length];
// 复制第一个数组到新数组 System.arraycopy(nums1, 0, temp, 0, nums1.length);
// 复制第二个数组到新数组 for (int i = 0; i < nums2.length; i++) temp[i + nums1.length] = nums2[i]; // 这里其实还可以继续用 System.arraycopy(),但是为了方便大家理解,我就 for 循环也用一下
// 对合并后的数组进行排序 Arrays.sort(temp);
// 计算中位数 int num1 = temp[temp.length / 2]; // 如果数组长度是奇数 if ((temp.length & 1) == 1) return num1 * 1.0; // 直接返回中间的元素 else // 如果数组长度是偶数,返回中间两个元素的平均值 return (num1 + temp[temp.length / 2 - 1]) / 2.0; }}
其中 (temp.length & 1) == 1 用到了**位运算 **,不懂的球友可以戳链接了解一下。
在二进制中,奇数的最低位总是 1,而偶数的最低位总是 0。因此,将 temp.length 与 1 进行按位与操作实际上是在检查 temp.length 的最低位是 1 还是 0。如果结果是 1,那么 temp.length 是奇数;如果结果是 0,则是偶数。
更直观的方式是用**取模运算符 ** %,我在讲 HashMap 的时候有讲过,可以戳链接去了解。
int[] array = {1, 2, 3, 4, 5};
// 使用模运算符 % 检查数组长度是否是奇数if (array.length % 2 == 1) { // 如果是奇数 System.out.println("数组长度是奇数。");} else { // 如果是偶数 System.out.println("数组长度是偶数。");}
模运算符 % 返回两个数相除的余数。如果数组长度除以 2 的余数为 1,则表示数组长度是奇数;如果余数为 0,则表示数组长度是偶数。
我的方法 findMedianSortedArrays 返回的是 double 类型,所以我们在求出中位数的时候在题解中使用 * 1.0 或 / 2.0 来确保结果是浮点数(double 类型),而不是整数。这在计算平均值或需要精确的小数结果时尤为重要,避免了整数除法的小数截断问题,大家可以试一下不乘以 1.0 或不除以 2.0 的结果。
好,我们来看一下题解效率。
虽然也通过了,但时间复杂度:,合并数组:O(m + n),其中 m 和 n 分别是两个数组的长度,排序数组:O((m + n) log (m + n)),并没有达到题目的要求。
分析 2
不知道球友们之前有没有学过 归并排序 ,如果没有的话,可以戳链接观看视频学习一下(下图来源于该视频教程,作者请叫我 AXin)。
这里总结一下,什么是归并排序?
归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用,且各层分治递归可以同时进行。
归并排序思路简单,速度仅次于快速排序,为稳定排序算法,一般用于对总体无序,但是各子项相对有序的数列。
归并排序的核心思想是分治法,先递归分解数组,再合并数组。将数组分解最小之后,然后合并两个有序数组,基本思路是比较两个数组的最前面的数,谁小就先取谁,取了后相应的指针就往后移一位。然后再比较,直至一个数组为空,最后把另一个数组的剩余部分复制过来即可。
例如,对数组 [3, 1, 4, 1, 5, 9, 2, 6] 进行归并排序:
- 分解为 [3, 1, 4, 1] 和 [5, 9, 2, 6]。
- 继续分解,比如 [3, 1, 4, 1] 分解为 [3, 1] 和 [4, 1]。
- 继续分解,直到子数组只包含一个元素。
- 开始合并,例如将 [3, 1] 合并为 [1, 3]。
- 继续合并其他子数组。
- 最后,将所有合并的子数组合并为最终的排序数组。
它每一层的拆分都是对半的,所以时间复杂度是,而每一层的合并都是对两个数组的合并,所以时间复杂度是,所以整体的时间复杂度是。
分解步骤涉及将数组递归地分成两半,直到每个子数组只包含一个元素。对于长度为 n 的数组,这个分解过程的时间复杂度是 O(log n)。
为什么是 O(log n)?因为每次递归调用都将数组分为两部分,所以深度为 log n(这里的对数是以 2 为底)。例如,一个长度为 8 的数组分解成长度为 1 的子数组需要 3 步(8 -> 4 -> 2 -> 1),因为 2^3 = 8。
归并步骤涉及将两个已排序的子数组合并成一个已排序的数组。每次合并操作的时间复杂度是 O(n),因为它涉及遍历两个子数组的所有元素。例如,合并长度为 4 的子数组需要 4 步(1 + 1 + 1 + 1)。
根据归并思想,我们可以写出下面这样的题解。
class Solution {
int n = nums1.length; int m = nums2.length; // 判断总元素数量是奇数还是偶数 boolean flag = ((m + n) % 2) == 0; int point1 = 0, point2 = 0; // 分别为 nums1 和 nums2 的指针 int remNum1 = 0, remNum2 = 0; // 用于存储当前遍历的元素和前一个元素
// 遍历两个数组的元素,直到到达中位数的位置 for (int i = 0; i <= (n + m) / 2; i++) { remNum2 = remNum1; // 存储前一个元素
// 比较两个数组当前元素,取较小的那个 if (point1 < n && (point2 >= m || nums1[point1] < nums2[point2])) { remNum1 = nums1[point1++]; } else { remNum1 = nums2[point2++]; } }
// 如果总元素数量是偶数,则中位数是中间两个数的平均值 if (flag) { return (remNum1 + remNum2) / 2.0; } // 如果总元素数量是奇数,则中位数是中间的那个数 return remNum1 * 1.0; }}
当输入是 nums1 = [1,2] 和 nums2 = [3,4] 时,我们来模拟整个题解过程:
- 初始化:
- nums1 的长度 n = 2,nums2 的长度 m = 2。
- 总长度 n + m = 4,所以总长度是偶数,flag = true。
- 设置两个数组的指针 point1 = 0,point2 = 0。
- 初始化 remNum1 和 remNum2 为 0,用来存储当前元素和前一个元素。
- 开始遍历数组:
- 第一次循环(i = 0):
- nums1[0] = 1,nums2[0] = 3。选择较小的 nums1[0],remNum1 = 1,point1 增加到 1。
- 第二次循环(i = 1):
- nums1[1] = 2,nums2[0] = 3。选择较小的 nums1[1],remNum2 = remNum1 = 1,remNum1 = 2,point1 增加到 2。
- 第三次循环(i = 2):
- nums1 已经遍历完,只剩 nums2。选择 nums2[0] = 3,remNum2 = remNum1 = 2,remNum1 = 3,point2 增加到 1。
- 第四次循环(i = 3):
- 同理,选择 nums2[1] = 4,remNum2 = remNum1 = 3,remNum1 = 4。
- 计算中位数:
- 因为总长度是偶数(flag = true),中位数是最后两个遍历的数的平均值。
- remNum1 = 4,remNum2 = 3,所以中位数是 (4 + 3) / 2.0 = 3.5。
因此,对于 nums1 = [1,2] 和 nums2 = [3,4],中位数是 3.5。
条件 point1 < n && (point2 >= m || nums1[point1] < nums2[point2]) 是用来确定在合并两个排序数组的过程中,下一个要选取的元素是来自 nums1 还是 nums2。
- point1 < n:
- 这是检查 nums1 的指针 point1 是否还在数组的范围内。如果 point1 已经等于 n,意味着 nums1 中的所有元素都已经被考虑过了。
- point2 >= m:
- 这是检查 nums2 的指针 point2 是否超出了数组的范围。如果 point2 大于或等于 m,意味着 nums2 中的所有元素都已经被考虑过了。
- nums1[point1] < nums2[point2]
- 这是比较 nums1 和 nums2 当前指针位置的元素。如果 nums1 当前元素小于 nums2 当前元素,这个条件成立。
在合并两个排序数组时,我们希望按照排序顺序依次选取两个数组中的元素。这个条件帮助我们决定接下来应该选取 nums1 还是 nums2 中的元素:
- 当 nums1 还有元素,并且 nums2 要么已经处理完,要么 nums1 的当前元素更小,我们就选择 nums1 的当前元素。
- 如果条件不成立(即 nums1 处理完了,或者 nums1 的当前元素不小于 nums2 的当前元素),我们就从 nums2 中选取元素。
这样的逻辑确保了合并后的数组是有序的,同时也高效地利用了两个已排序数组的性质。
时间复杂度:,归并到中位数:O(m + n),每次从两个数组的开头取较小的数,直到达到中位数的位置。
但题目要求的是 。
分析 3
根据中位数的定义,当数组长度为奇数时,中位数就是数组中的第(size + 1) / 2个,偶数的话,则是第(size + 1) / 2个数和第(size + 1) / 2 + 1个数的平均数。
来看这样子一组样例(从第一个开始计数)
nums1:[2,5,8]
合并后:[2,3,4,5,5,6,7,8,8,9,10]
nums1.length + nums2.length = 11,新数组的长度为奇数,所以中位数为(11 + 1) / 2,也就是新数组的第 6 个数(下标为 5)。
由于数组是排序过(升序)的,所以求中位数就变成了一个寻找第 K 小的数的问题了。这里的“第 K 小”指的是如果所有元素都按升序排序,那么位于第 K 个位置的元素(从 1 开始计数)。
例如,考虑数组 [3, 5, 2, 7, 4],升序后是 [2, 3, 4, 5, 7]:
- 第 1 小的数是 2(最小的数)。
- 第 2 小的数是 3。
- 第 3 小的数是 4。
- 以此类推。
关于寻找第 K 小的数,我这里有一个视频,讲得很好,大家可以戳链接观看:https://www.bilibili.com/video/BV1TR4y197X2
中位数本质上是数组排序后位于中间位置的数,因此它可以视为一个特殊的第 K 小的数的问题。
- 当总元素个数是奇数时:中位数是第 (m + n + 1) / 2 小的数,其中 m 和 n 分别是两个数组的长度。例如,如果两个数组总共有 11 个元素,那么中位数就是第 (11 + 1) / 2 = 6 小的数。
- 当总元素个数是偶数时:中位数是第 (m + n) / 2 和第 (m + n) / 2 + 1 小的数的平均值。例如,如果两个数组总共有 10 个元素,那么中位数就是第 10 / 2 = 5 小的数和第 6 小的数的平均值。
相信这个大家应该都明白了。
在这道题中,我们不需要合并两个数组来找到中位数。
相反,我们可以使用二分查找的方法找到第 K 小的数。这种方法在两个数组都是排序过的情况下特别有效,因为我们可以使用二分查找法搜索第 K / 2 个元素(即两个数组中第 K 小的元素),并且由于两个数组中的元素有序,所以我们可以比较这两个第 K / 2 个元素的值来决定如何继续搜索。
什么是二分查找法?
二分查找法是一种在有序数组中查找某一特定元素的搜索算法。搜索过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。如果在某一步骤数组为空,则代表找不到。这种搜索算法每一次比较都使搜索范围缩小一半。
比如说 A 数组「A1,A2,A3,A4,A5」,B 数组「B1,B2,B3,B4,B5」,一共有 10 个数,我们要找出第 5 小的数和第 6 小的数,它们和的二分之一就是合并后数组的中位数,对吧?
那也就是我们要求两次,一次求第 5 小的数,一次求第 6 小的数。
求第 5 小的数时,我们可以先从 A 数组中 K/2 找,K = 5/2 = 2,也就是 A2;再从 B 数组中找 K/2,K = 5/2 = 2,也就是 B2,然后比较 A2 和 B2。
如果 A2 < B2,那么 A1、A2 肯定不可能是第 5 小的数,因为最好的情况就是下面这样子,B2 都不是第 5 小,A1、A2 肯定不行,对吧?
所以我们可以把 A1-1、A2-3 排除掉(假设-后跟的值,方便大家理解),于是两个数组变成了这样子:
- A 数组「A1-5,A2-7,A3-8」
- B 数组「B1-2,B2-4,B3-6,B4-9,B5-10」
由于排除了两个数,所以 K = 5 - 2 = 3,变成了要找第 3 小的数,那么我们可以再从 A 数组中找 K/2,K = 3/2 = 1,也就是 A1;再从 B 数组中找 K/2,K = 3/2 = 1,也就是 B1,然后比较 A1 和 B1。
此时 B1-2 < A1-5,那么 B1 肯定不可能是第 3 小的数,因为最好的情况就是下面这样子,A1 可能是第 3 小(还不确定 B2、B3 的值),但 B1 肯定不行,对吧?
那把 B1 排除掉,于是两个数组变成了这样子:
- A 数组「A1-5,A2-7,A3-8」
- B 数组「B1-4,B2-6,B3-9,B4-10」
由于排除了一个数,所以 K = 3 - 1 = 2,变成了要找第 2 小的数,那么我们可以再从 A 数组中找 K/2,K = 2/2 = 1,也就是 A1;再从 B 数组中找 K/2,K = 2/2 = 1,也就是 B1,然后比较 A1 和 B1。
此时 B1-4 < A1-5,那么 B1 肯定不可能是第 2 小的数,
以此类推,直到 K = 1,也就是找到了第 1 小的数,也就是 5;再找第 6 小的数也是一样的道理,最后求平均值就是中位数了。
我们来看题解:
class Solution {
int ans1 = getKth(nums1, 0, nums1.length - 1, nums2, 0, nums2.length - 1, (nums1.length + nums2.length + 1) / 2); int ans2 = getKth(nums1,0, nums1.length - 1, nums2, 0, nums2.length - 1, (nums1.length + nums2.length + 2) / 2); //如果是奇数,则两次求的k都是一样的,即(nums1.length + nums2.length + 1) / 2 == (nums1.length + nums2.length + 2) / 2 return (ans1 + ans2) / 2.0; }
private int getKth(int[] nums1, int start1, int end1, int[] nums2, int start2, int end2, int k) { int siz1 = end1 - start1 + 1; int siz2 = end2 - start2 + 1; if (siz1 > siz2) return getKth(nums2, start2, end2, nums1, start1, end1, k); //为了方便知道哪个数组首先为空,我们设置siz1严格小于等于siz2,这样子一旦有数组被清空,则一定是nums1 if (siz1 == 0) return nums2[start2 + k - 1];//已经把第一个数组全部排除,只需要求第二数组的剩下数字中的第 k 个就行了(更新后的k) if (k == 1) return Math.min(nums1[start1], nums2[start2]);//求当前两个数组中最小的数字,当然直接返回两个当前中最小的即可 int pos1 = start1 + Math.min(siz1, k / 2) - 1; int pos2 = start2 + Math.min(siz2, k / 2) - 1;
if (nums1[pos1] > nums2[pos2]) return getKth(nums1, start1, end1, nums2, pos2 + 1, end2, k - (pos2 - start2 + 1));//排除第二个数组不符合条件的数字 else return getKth(nums1, pos1 + 1, end1, nums2, start2, end2, k - (pos1 - start1 + 1));//排除第一个数组不符合条件的数字 }}
当输入是 nums1 = [2,5,8] 和 nums2 = [3,4,5,6,7,8,9,10] 时,我们来模拟一下整个题解的过程:
- 计算中位数的位置:
- nums1.length = 3, nums2.length = 8,总长度 n + m = 11。
- 由于 11 是奇数,中位数是合并后数组中的第 (11 + 1) / 2 = 6 个元素。
- 第一次调用 getKth 方法:
- 寻找第 6 小的元素,调用 getKth(nums1, 0, 2, nums2, 0, 7, 6)。
- 递归查找中位数:
- 在 getKth 方法中,我们会不断递归缩小 k 的值,直到找到第 k 小的数。
- 对于第一次调用,我们比较 nums1[2] (8) 和 nums2[2] (5)。由于 8 > 5,我们可以确定在 nums2[0..2] 中没有第 6 小的数,这些元素可以被排除。
- 更新 k 的值:k = 6 - 3 = 3 (排除了 3 个元素)。
- 继续递归。
- 找到中位数:
- 最后找到中位数是 6.0。
如果还不明白的话,可以利用 ACM 输入输出的格式在 Intellij IDEA 中进行 debug 调试。
public class Main004 {
Scanner scanner = new Scanner(System.in); System.out.println("输入第一个数组"); // 读取一串字符,按照, 分割成 int 型数组 String[] nums1Str = scanner.nextLine().split(","); int[] nums1 = new int[nums1Str.length]; for (int i = 0; i < nums1Str.length; i++) { nums1[i] = Integer.parseInt(nums1Str[i]); }
System.out.println("输入第二个数组"); // 读取一串字符,按照, 分割成 int 型数组 String[] nums2Str = scanner.nextLine().split(","); int[] nums2 = new int[nums2Str.length]; for (int i = 0; i < nums2Str.length; i++) { nums2[i] = Integer.parseInt(nums2Str[i]); }
Solution004 solution = new Solution004(); double median = solution.findMedianSortedArrays(nums1, nums2); System.out.println("中位数是: " + median); }}
class Solution004 { public double findMedianSortedArrays(int[] nums1, int[] nums2) { int ans1 = getKth(nums1, 0, nums1.length - 1, nums2, 0, nums2.length - 1, (nums1.length + nums2.length + 1) / 2); int ans2 = getKth(nums1,0, nums1.length - 1, nums2, 0, nums2.length - 1, (nums1.length + nums2.length + 2) / 2); //如果是奇数,则两次求的k都是一样的,即(nums1.length + nums2.length + 1) / 2 == (nums1.length + nums2.length + 2) / 2 return (ans1 + ans2) / 2.0; } private int getKth(int[] nums1, int start1, int end1, int[] nums2, int start2, int end2, int k) { int siz1 = end1 - start1 + 1; int siz2 = end2 - start2 + 1; if (siz1 > siz2) { return getKth(nums2, start2, end2, nums1, start1, end1, k); } //为了方便知道哪个数组首先为空,我们设置siz1严格小于等于siz2,这样子一旦有数组被清空,则一定是nums1 if (siz1 == 0) { return nums2[start2 + k - 1];//已经把第一个数组全部排除,只需要求第二数组的剩下数字中的第 k 个就行了(更新后的k) } if (k == 1) { return Math.min(nums1[start1], nums2[start2]);//求当前两个数组中最小的数字,当然直接返回两个当前中最小的即可 } int pos1 = start1 + Math.min(siz1, k / 2) - 1; int pos2 = start2 + Math.min(siz2, k / 2) - 1; if (nums1[pos1] > nums2[pos2]) { //排除第二个数组不符合条件的数字 return getKth(nums1, start1, end1, nums2, pos2 + 1, end2, k - (pos2 - start2 + 1)); } else { //排除第一个数组不符合条件的数字 return getKth(nums1, pos1 + 1, end1, nums2, start2, end2, k - (pos1 - start1 + 1)); } }}
完整的跑一次就能明白了。
时间复杂度:
第一次,我们排除了(n + m) / 2 / 2个数字
第二次,我们排除了(n + m) / 2 / 2 / 2 个数字
最终我们排除了次,终于把(n + m) / 2 - 1个数字全都排除完了,所以时间复杂度是
轻松打败 100% 用户。
总结
本次题解我们一共经历了三个重大的思维转变:
- 暴力解法,时间复杂度为 。
- 归并排序,时间复杂度为 。
- 二分查找结合寻找第 K 小的数,时间复杂度为 。
涉及到知识点也比较多,大部分在《二哥的 Java 进阶之路》上都能找到,比如说:
力扣链接:https://leetcode.cn/problems/median-of-two-sorted-arrays/
一步一个脚印
不积跬步无以至千里,不积小流无以成江海。LeetCode - 100天从算法小白到卷王正式启动了,我们计划周一到周五至少每天更新一篇,周六周日更新一篇,目标 300 道 LeetCode 经典题。
该题解目前只针对**二哥编程星球 **的球友开放,如果你也想一起打卡学习算法的话,扫下面的优惠券加入我们吧,星球目前 4100+ 人,定价 149 元,从当初的 99 元一路涨上来的,所以早就是优势,别再犹豫了。
(优惠券仅 300 张,数量有限)
一个人可以走得很快,但一群人才能走得更远。如果想去面中大厂的话,LeetCode 几乎是必刷的,二哥的 LeetCode 题解通俗易懂、图文并茂,理解给你举一反三的解题能力,冲鸭。
- 语雀专栏地址+密码可通过星球置顶帖查看:[https://t.zsxq.com/15rEo9Pdu](https://t.zsxq.com/15rEo9Pdu)
-
技术派专栏地址:https://paicoding.com/column/7/ 4
-
增加代码注释,这样大家更容易看懂并掌握题解;
-
增加参考资料,这样大家可以从零基础直接开刷,减轻新手刷题的阻力;
-
增加手绘图,这样大家更容易理解解题思路;
-
增加 ACM 输入输出,这样大家可以在 Intellij IDEA 中进行调试;
-
增加题解模拟,这样大家可以根据题解模拟整个题解过程,看完会有一种“哦哦”恍然大悟的感觉;
-
图片上传至 OSS 并配合 CDN,提升大家在技术派网站中的阅读体感。
-
另外,这一版本会作为 PDF 的终稿随后在星球进行发布。
我每天都在读《二哥的 LeetCode 刷题笔记》。
------------------------by鲁迅
题意
给定两个大小分别为 m 和 n 的正序(从小到大)数组 nums1 和 nums2。请你找出并返回这两个正序数组的 中位数 。
你给出的算法的时间复杂度应该为 O(log (m+n)) 。
示例
输入:nums1=[1,3],nums2=[2]
解释:合并数组=[1,2,3],中位数2
输入:nums1=[1,2],nums2=[3,4]
解释:合并数组=[1,2,3,4],中位数(2+3)/2=2.5
难度
困难
好吧,第四道就遇到了 hard,算法新手很容易到这就被劝退了,但其实这道题并没有想象中那么可怕,请深吸一口气,告诉自己一定能行,笔试的时候其实也需要这样的心态。
尤其是在那种紧张氛围中,更需要保持冷静,不要被题目吓到,一定要相信自己,相信自己的能力,相信自己的实力。
实在做不出来,如果是比较热心肠的面试官,甚至会给你一些提示的,所以不要打退堂鼓,劳资打的就是精锐(😁)。
分析 1
首先,对于算法新手来说,这道题需要搞清楚几个概念:
- 正序的数组长什么样子?
- 两个数组如何合并?
- 中位数是什么?
正序的数组,也就是从小到大排序过的数组,就是数组中的元素从左到右,依次变大的数组,如下:
[1,2,3,4,5,6,7,8,9,10]
当然也可以跳着来,但一定是后面的数比前面的数大,如下:
[1,5,13,17,18,19]
两个数组如何合并呢?
最简单的就是使用 for 循环进行遍历,把两个数组复制到一个新的数组当中,这个我在《二哥的 Java 进阶之路》中讲**数组 **的时候讲过。
int[] array1 = {1, 2, 3};
// 创建一个新数组,长度为两个数组长度之和int[] mergedArray = new int[array1.length + array2.length]; // 复制第一个数组到新数组int index = 0;for (int element : array1) { mergedArray[index++] = element;} // 复制第二个数组到新数组for (int element : array2) { mergedArray[index++] = element;}
那高级一点的话,可以使用 Arrays.copyOfRange() 复制,它底层用到的是 System.arraycopy(),这个方法是 native 方法,效率更高(数组那一节里也讲过,球友可以过去看一眼,很快就能明白)。
那什么是中位数呢?
中位数,就是把一组数据按照从小到大的顺序排列,位于中间位置的那个数,如果数据的个数是奇数,那么中位数就是最中间的那个数,如果数据的个数是偶数,那么中位数就是最中间两个数的平均值。
- 奇数个数值:例如,在数据集 [1, 3, 3, 6, 7, 8, 9] 中,中位数是 6。
- 偶数个数值:例如,在数据集 [1, 2, 3, 4, 5, 6, 8, 9] 中,中间的两个数是 4 和 5,所以中位数是 (4 + 5) / 2 = 4.5。
中位数与平均值(或算术平均数)不同。平均值是所有数值的总和除以数值的个数,而中位数是数据集的中间值。
好,明白了这三个概念,问题就变得简单了,对吧?
来吧,先用 System.arraycopy 合并两个数组,然后再用 Arrays.sort () 排序,最后再根据数组的长度是奇数还是偶数,来计算中位数。
class Solution {
// 创建一个新数组,长度为两个数组长度之和 int[] temp = new int[nums1.length + nums2.length];
// 复制第一个数组到新数组 System.arraycopy(nums1, 0, temp, 0, nums1.length);
// 复制第二个数组到新数组 for (int i = 0; i < nums2.length; i++) temp[i + nums1.length] = nums2[i]; // 这里其实还可以继续用 System.arraycopy(),但是为了方便大家理解,我就 for 循环也用一下
// 对合并后的数组进行排序 Arrays.sort(temp);
// 计算中位数 int num1 = temp[temp.length / 2]; // 如果数组长度是奇数 if ((temp.length & 1) == 1) return num1 * 1.0; // 直接返回中间的元素 else // 如果数组长度是偶数,返回中间两个元素的平均值 return (num1 + temp[temp.length / 2 - 1]) / 2.0; }}
其中 (temp.length & 1) == 1 用到了**位运算 **,不懂的球友可以戳链接了解一下。
在二进制中,奇数的最低位总是 1,而偶数的最低位总是 0。因此,将 temp.length 与 1 进行按位与操作实际上是在检查 temp.length 的最低位是 1 还是 0。如果结果是 1,那么 temp.length 是奇数;如果结果是 0,则是偶数。
更直观的方式是用**取模运算符 ** %,我在讲 HashMap 的时候有讲过,可以戳链接去了解。
int[] array = {1, 2, 3, 4, 5};
// 使用模运算符 % 检查数组长度是否是奇数if (array.length % 2 == 1) { // 如果是奇数 System.out.println("数组长度是奇数。");} else { // 如果是偶数 System.out.println("数组长度是偶数。");}
模运算符 % 返回两个数相除的余数。如果数组长度除以 2 的余数为 1,则表示数组长度是奇数;如果余数为 0,则表示数组长度是偶数。
我的方法 findMedianSortedArrays 返回的是 double 类型,所以我们在求出中位数的时候在题解中使用 * 1.0 或 / 2.0 来确保结果是浮点数(double 类型),而不是整数。这在计算平均值或需要精确的小数结果时尤为重要,避免了整数除法的小数截断问题,大家可以试一下不乘以 1.0 或不除以 2.0 的结果。
好,我们来看一下题解效率。
虽然也通过了,但时间复杂度:,合并数组:O(m + n),其中 m 和 n 分别是两个数组的长度,排序数组:O((m + n) log (m + n)),并没有达到题目的要求。
分析 2
不知道球友们之前有没有学过 归并排序 ,如果没有的话,可以戳链接观看视频学习一下(下图来源于该视频教程,作者请叫我 AXin)。
这里总结一下,什么是归并排序?
归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用,且各层分治递归可以同时进行。
归并排序思路简单,速度仅次于快速排序,为稳定排序算法,一般用于对总体无序,但是各子项相对有序的数列。
归并排序的核心思想是分治法,先递归分解数组,再合并数组。将数组分解最小之后,然后合并两个有序数组,基本思路是比较两个数组的最前面的数,谁小就先取谁,取了后相应的指针就往后移一位。然后再比较,直至一个数组为空,最后把另一个数组的剩余部分复制过来即可。
例如,对数组 [3, 1, 4, 1, 5, 9, 2, 6] 进行归并排序:
- 分解为 [3, 1, 4, 1] 和 [5, 9, 2, 6]。
- 继续分解,比如 [3, 1, 4, 1] 分解为 [3, 1] 和 [4, 1]。
- 继续分解,直到子数组只包含一个元素。
- 开始合并,例如将 [3, 1] 合并为 [1, 3]。
- 继续合并其他子数组。
- 最后,将所有合并的子数组合并为最终的排序数组。
它每一层的拆分都是对半的,所以时间复杂度是,而每一层的合并都是对两个数组的合并,所以时间复杂度是,所以整体的时间复杂度是。
分解步骤涉及将数组递归地分成两半,直到每个子数组只包含一个元素。对于长度为 n 的数组,这个分解过程的时间复杂度是 O(log n)。
为什么是 O(log n)?因为每次递归调用都将数组分为两部分,所以深度为 log n(这里的对数是以 2 为底)。例如,一个长度为 8 的数组分解成长度为 1 的子数组需要 3 步(8 -> 4 -> 2 -> 1),因为 2^3 = 8。
归并步骤涉及将两个已排序的子数组合并成一个已排序的数组。每次合并操作的时间复杂度是 O(n),因为它涉及遍历两个子数组的所有元素。例如,合并长度为 4 的子数组需要 4 步(1 + 1 + 1 + 1)。
根据归并思想,我们可以写出下面这样的题解。
class Solution {
int n = nums1.length; int m = nums2.length; // 判断总元素数量是奇数还是偶数 boolean flag = ((m + n) % 2) == 0; int point1 = 0, point2 = 0; // 分别为 nums1 和 nums2 的指针 int remNum1 = 0, remNum2 = 0; // 用于存储当前遍历的元素和前一个元素
// 遍历两个数组的元素,直到到达中位数的位置 for (int i = 0; i <= (n + m) / 2; i++) { remNum2 = remNum1; // 存储前一个元素
// 比较两个数组当前元素,取较小的那个 if (point1 < n && (point2 >= m || nums1[point1] < nums2[point2])) { remNum1 = nums1[point1++]; } else { remNum1 = nums2[point2++]; } }
// 如果总元素数量是偶数,则中位数是中间两个数的平均值 if (flag) { return (remNum1 + remNum2) / 2.0; } // 如果总元素数量是奇数,则中位数是中间的那个数 return remNum1 * 1.0; }}
当输入是 nums1 = [1,2] 和 nums2 = [3,4] 时,我们来模拟整个题解过程:
- 初始化:
- nums1 的长度 n = 2,nums2 的长度 m = 2。
- 总长度 n + m = 4,所以总长度是偶数,flag = true。
- 设置两个数组的指针 point1 = 0,point2 = 0。
- 初始化 remNum1 和 remNum2 为 0,用来存储当前元素和前一个元素。
- 开始遍历数组:
- 第一次循环(i = 0):
- nums1[0] = 1,nums2[0] = 3。选择较小的 nums1[0],remNum1 = 1,point1 增加到 1。
- 第二次循环(i = 1):
- nums1[1] = 2,nums2[0] = 3。选择较小的 nums1[1],remNum2 = remNum1 = 1,remNum1 = 2,point1 增加到 2。
- 第三次循环(i = 2):
- nums1 已经遍历完,只剩 nums2。选择 nums2[0] = 3,remNum2 = remNum1 = 2,remNum1 = 3,point2 增加到 1。
- 第四次循环(i = 3):
- 同理,选择 nums2[1] = 4,remNum2 = remNum1 = 3,remNum1 = 4。
- 计算中位数:
- 因为总长度是偶数(flag = true),中位数是最后两个遍历的数的平均值。
- remNum1 = 4,remNum2 = 3,所以中位数是 (4 + 3) / 2.0 = 3.5。
因此,对于 nums1 = [1,2] 和 nums2 = [3,4],中位数是 3.5。
条件 point1 < n && (point2 >= m || nums1[point1] < nums2[point2]) 是用来确定在合并两个排序数组的过程中,下一个要选取的元素是来自 nums1 还是 nums2。
- point1 < n:
- 这是检查 nums1 的指针 point1 是否还在数组的范围内。如果 point1 已经等于 n,意味着 nums1 中的所有元素都已经被考虑过了。
- point2 >= m:
- 这是检查 nums2 的指针 point2 是否超出了数组的范围。如果 point2 大于或等于 m,意味着 nums2 中的所有元素都已经被考虑过了。
- nums1[point1] < nums2[point2]
- 这是比较 nums1 和 nums2 当前指针位置的元素。如果 nums1 当前元素小于 nums2 当前元素,这个条件成立。
在合并两个排序数组时,我们希望按照排序顺序依次选取两个数组中的元素。这个条件帮助我们决定接下来应该选取 nums1 还是 nums2 中的元素:
- 当 nums1 还有元素,并且 nums2 要么已经处理完,要么 nums1 的当前元素更小,我们就选择 nums1 的当前元素。
- 如果条件不成立(即 nums1 处理完了,或者 nums1 的当前元素不小于 nums2 的当前元素),我们就从 nums2 中选取元素。
这样的逻辑确保了合并后的数组是有序的,同时也高效地利用了两个已排序数组的性质。
时间复杂度:,归并到中位数:O(m + n),每次从两个数组的开头取较小的数,直到达到中位数的位置。
但题目要求的是 。
分析 3
根据中位数的定义,当数组长度为奇数时,中位数就是数组中的第(size + 1) / 2个,偶数的话,则是第(size + 1) / 2个数和第(size + 1) / 2 + 1个数的平均数。
来看这样子一组样例(从第一个开始计数)
nums1:[2,5,8]
合并后:[2,3,4,5,5,6,7,8,8,9,10]
nums1.length + nums2.length = 11,新数组的长度为奇数,所以中位数为(11 + 1) / 2,也就是新数组的第 6 个数(下标为 5)。
由于数组是排序过(升序)的,所以求中位数就变成了一个寻找第 K 小的数的问题了。这里的“第 K 小”指的是如果所有元素都按升序排序,那么位于第 K 个位置的元素(从 1 开始计数)。
例如,考虑数组 [3, 5, 2, 7, 4],升序后是 [2, 3, 4, 5, 7]:
- 第 1 小的数是 2(最小的数)。
- 第 2 小的数是 3。
- 第 3 小的数是 4。
- 以此类推。
关于寻找第 K 小的数,我这里有一个视频,讲得很好,大家可以戳链接观看:https://www.bilibili.com/video/BV1TR4y197X2
中位数本质上是数组排序后位于中间位置的数,因此它可以视为一个特殊的第 K 小的数的问题。
- 当总元素个数是奇数时:中位数是第 (m + n + 1) / 2 小的数,其中 m 和 n 分别是两个数组的长度。例如,如果两个数组总共有 11 个元素,那么中位数就是第 (11 + 1) / 2 = 6 小的数。
- 当总元素个数是偶数时:中位数是第 (m + n) / 2 和第 (m + n) / 2 + 1 小的数的平均值。例如,如果两个数组总共有 10 个元素,那么中位数就是第 10 / 2 = 5 小的数和第 6 小的数的平均值。
相信这个大家应该都明白了。
在这道题中,我们不需要合并两个数组来找到中位数。
相反,我们可以使用二分查找的方法找到第 K 小的数。这种方法在两个数组都是排序过的情况下特别有效,因为我们可以使用二分查找法搜索第 K / 2 个元素(即两个数组中第 K 小的元素),并且由于两个数组中的元素有序,所以我们可以比较这两个第 K / 2 个元素的值来决定如何继续搜索。
什么是二分查找法?
二分查找法是一种在有序数组中查找某一特定元素的搜索算法。搜索过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。如果在某一步骤数组为空,则代表找不到。这种搜索算法每一次比较都使搜索范围缩小一半。
比如说 A 数组「A1,A2,A3,A4,A5」,B 数组「B1,B2,B3,B4,B5」,一共有 10 个数,我们要找出第 5 小的数和第 6 小的数,它们和的二分之一就是合并后数组的中位数,对吧?
那也就是我们要求两次,一次求第 5 小的数,一次求第 6 小的数。
求第 5 小的数时,我们可以先从 A 数组中 K/2 找,K = 5/2 = 2,也就是 A2;再从 B 数组中找 K/2,K = 5/2 = 2,也就是 B2,然后比较 A2 和 B2。
如果 A2 < B2,那么 A1、A2 肯定不可能是第 5 小的数,因为最好的情况就是下面这样子,B2 都不是第 5 小,A1、A2 肯定不行,对吧?
所以我们可以把 A1-1、A2-3 排除掉(假设-后跟的值,方便大家理解),于是两个数组变成了这样子:
- A 数组「A1-5,A2-7,A3-8」
- B 数组「B1-2,B2-4,B3-6,B4-9,B5-10」
由于排除了两个数,所以 K = 5 - 2 = 3,变成了要找第 3 小的数,那么我们可以再从 A 数组中找 K/2,K = 3/2 = 1,也就是 A1;再从 B 数组中找 K/2,K = 3/2 = 1,也就是 B1,然后比较 A1 和 B1。
此时 B1-2 < A1-5,那么 B1 肯定不可能是第 3 小的数,因为最好的情况就是下面这样子,A1 可能是第 3 小(还不确定 B2、B3 的值),但 B1 肯定不行,对吧?
那把 B1 排除掉,于是两个数组变成了这样子:
- A 数组「A1-5,A2-7,A3-8」
- B 数组「B1-4,B2-6,B3-9,B4-10」
由于排除了一个数,所以 K = 3 - 1 = 2,变成了要找第 2 小的数,那么我们可以再从 A 数组中找 K/2,K = 2/2 = 1,也就是 A1;再从 B 数组中找 K/2,K = 2/2 = 1,也就是 B1,然后比较 A1 和 B1。
此时 B1-4 < A1-5,那么 B1 肯定不可能是第 2 小的数,
以此类推,直到 K = 1,也就是找到了第 1 小的数,也就是 5;再找第 6 小的数也是一样的道理,最后求平均值就是中位数了。
我们来看题解:
class Solution {
int ans1 = getKth(nums1, 0, nums1.length - 1, nums2, 0, nums2.length - 1, (nums1.length + nums2.length + 1) / 2); int ans2 = getKth(nums1,0, nums1.length - 1, nums2, 0, nums2.length - 1, (nums1.length + nums2.length + 2) / 2); //如果是奇数,则两次求的k都是一样的,即(nums1.length + nums2.length + 1) / 2 == (nums1.length + nums2.length + 2) / 2 return (ans1 + ans2) / 2.0; }
private int getKth(int[] nums1, int start1, int end1, int[] nums2, int start2, int end2, int k) { int siz1 = end1 - start1 + 1; int siz2 = end2 - start2 + 1; if (siz1 > siz2) return getKth(nums2, start2, end2, nums1, start1, end1, k); //为了方便知道哪个数组首先为空,我们设置siz1严格小于等于siz2,这样子一旦有数组被清空,则一定是nums1 if (siz1 == 0) return nums2[start2 + k - 1];//已经把第一个数组全部排除,只需要求第二数组的剩下数字中的第 k 个就行了(更新后的k) if (k == 1) return Math.min(nums1[start1], nums2[start2]);//求当前两个数组中最小的数字,当然直接返回两个当前中最小的即可 int pos1 = start1 + Math.min(siz1, k / 2) - 1; int pos2 = start2 + Math.min(siz2, k / 2) - 1;
if (nums1[pos1] > nums2[pos2]) return getKth(nums1, start1, end1, nums2, pos2 + 1, end2, k - (pos2 - start2 + 1));//排除第二个数组不符合条件的数字 else return getKth(nums1, pos1 + 1, end1, nums2, start2, end2, k - (pos1 - start1 + 1));//排除第一个数组不符合条件的数字 }}
当输入是 nums1 = [2,5,8] 和 nums2 = [3,4,5,6,7,8,9,10] 时,我们来模拟一下整个题解的过程:
- 计算中位数的位置:
- nums1.length = 3, nums2.length = 8,总长度 n + m = 11。
- 由于 11 是奇数,中位数是合并后数组中的第 (11 + 1) / 2 = 6 个元素。
- 第一次调用 getKth 方法:
- 寻找第 6 小的元素,调用 getKth(nums1, 0, 2, nums2, 0, 7, 6)。
- 递归查找中位数:
- 在 getKth 方法中,我们会不断递归缩小 k 的值,直到找到第 k 小的数。
- 对于第一次调用,我们比较 nums1[2] (8) 和 nums2[2] (5)。由于 8 > 5,我们可以确定在 nums2[0..2] 中没有第 6 小的数,这些元素可以被排除。
- 更新 k 的值:k = 6 - 3 = 3 (排除了 3 个元素)。
- 继续递归。
- 找到中位数:
- 最后找到中位数是 6.0。
如果还不明白的话,可以利用 ACM 输入输出的格式在 Intellij IDEA 中进行 debug 调试。
public class Main004 {
Scanner scanner = new Scanner(System.in); System.out.println("输入第一个数组"); // 读取一串字符,按照, 分割成 int 型数组 String[] nums1Str = scanner.nextLine().split(","); int[] nums1 = new int[nums1Str.length]; for (int i = 0; i < nums1Str.length; i++) { nums1[i] = Integer.parseInt(nums1Str[i]); }
System.out.println("输入第二个数组"); // 读取一串字符,按照, 分割成 int 型数组 String[] nums2Str = scanner.nextLine().split(","); int[] nums2 = new int[nums2Str.length]; for (int i = 0; i < nums2Str.length; i++) { nums2[i] = Integer.parseInt(nums2Str[i]); }
Solution004 solution = new Solution004(); double median = solution.findMedianSortedArrays(nums1, nums2); System.out.println("中位数是: " + median); }}
class Solution004 { public double findMedianSortedArrays(int[] nums1, int[] nums2) { int ans1 = getKth(nums1, 0, nums1.length - 1, nums2, 0, nums2.length - 1, (nums1.length + nums2.length + 1) / 2); int ans2 = getKth(nums1,0, nums1.length - 1, nums2, 0, nums2.length - 1, (nums1.length + nums2.length + 2) / 2); //如果是奇数,则两次求的k都是一样的,即(nums1.length + nums2.length + 1) / 2 == (nums1.length + nums2.length + 2) / 2 return (ans1 + ans2) / 2.0; } private int getKth(int[] nums1, int start1, int end1, int[] nums2, int start2, int end2, int k) { int siz1 = end1 - start1 + 1; int siz2 = end2 - start2 + 1; if (siz1 > siz2) { return getKth(nums2, start2, end2, nums1, start1, end1, k); } //为了方便知道哪个数组首先为空,我们设置siz1严格小于等于siz2,这样子一旦有数组被清空,则一定是nums1 if (siz1 == 0) { return nums2[start2 + k - 1];//已经把第一个数组全部排除,只需要求第二数组的剩下数字中的第 k 个就行了(更新后的k) } if (k == 1) { return Math.min(nums1[start1], nums2[start2]);//求当前两个数组中最小的数字,当然直接返回两个当前中最小的即可 } int pos1 = start1 + Math.min(siz1, k / 2) - 1; int pos2 = start2 + Math.min(siz2, k / 2) - 1; if (nums1[pos1] > nums2[pos2]) { //排除第二个数组不符合条件的数字 return getKth(nums1, start1, end1, nums2, pos2 + 1, end2, k - (pos2 - start2 + 1)); } else { //排除第一个数组不符合条件的数字 return getKth(nums1, pos1 + 1, end1, nums2, start2, end2, k - (pos1 - start1 + 1)); } }}
完整的跑一次就能明白了。
时间复杂度:
第一次,我们排除了(n + m) / 2 / 2个数字
第二次,我们排除了(n + m) / 2 / 2 / 2 个数字
最终我们排除了次,终于把(n + m) / 2 - 1个数字全都排除完了,所以时间复杂度是
轻松打败 100% 用户。
总结
本次题解我们一共经历了三个重大的思维转变:
- 暴力解法,时间复杂度为 。
- 归并排序,时间复杂度为 。
- 二分查找结合寻找第 K 小的数,时间复杂度为 。
涉及到知识点也比较多,大部分在《二哥的 Java 进阶之路》上都能找到,比如说:
力扣链接:https://leetcode.cn/problems/median-of-two-sorted-arrays/
一步一个脚印
不积跬步无以至千里,不积小流无以成江海。LeetCode - 100天从算法小白到卷王正式启动了,我们计划周一到周五至少每天更新一篇,周六周日更新一篇,目标 300 道 LeetCode 经典题。
该题解目前只针对**二哥编程星球 **的球友开放,如果你也想一起打卡学习算法的话,扫下面的优惠券加入我们吧,星球目前 4100+ 人,定价 149 元,从当初的 99 元一路涨上来的,所以早就是优势,别再犹豫了。
(优惠券仅 300 张,数量有限)
一个人可以走得很快,但一群人才能走得更远。如果想去面中大厂的话,LeetCode 几乎是必刷的,二哥的 LeetCode 题解通俗易懂、图文并茂,理解给你举一反三的解题能力,冲鸭。
- 语雀专栏地址+密码可通过星球置顶帖查看:[https://t.zsxq.com/15rEo9Pdu](https://t.zsxq.com/15rEo9Pdu)
-
技术派专栏地址:https://paicoding.com/column/7/ 4
-
增加代码注释,这样大家更容易看懂并掌握题解;
-
增加参考资料,这样大家可以从零基础直接开刷,减轻新手刷题的阻力;
-
增加手绘图,这样大家更容易理解解题思路;
-
增加 ACM 输入输出,这样大家可以在 Intellij IDEA 中进行调试;
-
增加题解模拟,这样大家可以根据题解模拟整个题解过程,看完会有一种“哦哦”恍然大悟的感觉;
-
图片上传至 OSS 并配合 CDN,提升大家在技术派网站中的阅读体感。
-
另外,这一版本会作为 PDF 的终稿随后在星球进行发布。
我每天都在读《二哥的 LeetCode 刷题笔记》。
------------------------by鲁迅
题意
给定两个大小分别为 m 和 n 的正序(从小到大)数组 nums1 和 nums2。请你找出并返回这两个正序数组的 中位数 。
你给出的算法的时间复杂度应该为 O(log (m+n)) 。
示例
输入:nums1=[1,3],nums2=[2]
解释:合并数组=[1,2,3],中位数2
输入:nums1=[1,2],nums2=[3,4]
解释:合并数组=[1,2,3,4],中位数(2+3)/2=2.5
难度
困难
好吧,第四道就遇到了 hard,算法新手很容易到这就被劝退了,但其实这道题并没有想象中那么可怕,请深吸一口气,告诉自己一定能行,笔试的时候其实也需要这样的心态。
尤其是在那种紧张氛围中,更需要保持冷静,不要被题目吓到,一定要相信自己,相信自己的能力,相信自己的实力。
实在做不出来,如果是比较热心肠的面试官,甚至会给你一些提示的,所以不要打退堂鼓,劳资打的就是精锐(😁)。
分析 1
首先,对于算法新手来说,这道题需要搞清楚几个概念:
- 正序的数组长什么样子?
- 两个数组如何合并?
- 中位数是什么?
正序的数组,也就是从小到大排序过的数组,就是数组中的元素从左到右,依次变大的数组,如下:
[1,2,3,4,5,6,7,8,9,10]
当然也可以跳着来,但一定是后面的数比前面的数大,如下:
[1,5,13,17,18,19]
两个数组如何合并呢?
最简单的就是使用 for 循环进行遍历,把两个数组复制到一个新的数组当中,这个我在《二哥的 Java 进阶之路》中讲**数组 **的时候讲过。
int[] array1 = {1, 2, 3};
// 创建一个新数组,长度为两个数组长度之和int[] mergedArray = new int[array1.length + array2.length]; // 复制第一个数组到新数组int index = 0;for (int element : array1) { mergedArray[index++] = element;} // 复制第二个数组到新数组for (int element : array2) { mergedArray[index++] = element;}
那高级一点的话,可以使用 Arrays.copyOfRange() 复制,它底层用到的是 System.arraycopy(),这个方法是 native 方法,效率更高(数组那一节里也讲过,球友可以过去看一眼,很快就能明白)。
那什么是中位数呢?
中位数,就是把一组数据按照从小到大的顺序排列,位于中间位置的那个数,如果数据的个数是奇数,那么中位数就是最中间的那个数,如果数据的个数是偶数,那么中位数就是最中间两个数的平均值。
- 奇数个数值:例如,在数据集 [1, 3, 3, 6, 7, 8, 9] 中,中位数是 6。
- 偶数个数值:例如,在数据集 [1, 2, 3, 4, 5, 6, 8, 9] 中,中间的两个数是 4 和 5,所以中位数是 (4 + 5) / 2 = 4.5。
中位数与平均值(或算术平均数)不同。平均值是所有数值的总和除以数值的个数,而中位数是数据集的中间值。
好,明白了这三个概念,问题就变得简单了,对吧?
来吧,先用 System.arraycopy 合并两个数组,然后再用 Arrays.sort () 排序,最后再根据数组的长度是奇数还是偶数,来计算中位数。
class Solution {
// 创建一个新数组,长度为两个数组长度之和 int[] temp = new int[nums1.length + nums2.length];
// 复制第一个数组到新数组 System.arraycopy(nums1, 0, temp, 0, nums1.length);
// 复制第二个数组到新数组 for (int i = 0; i < nums2.length; i++) temp[i + nums1.length] = nums2[i]; // 这里其实还可以继续用 System.arraycopy(),但是为了方便大家理解,我就 for 循环也用一下
// 对合并后的数组进行排序 Arrays.sort(temp);
// 计算中位数 int num1 = temp[temp.length / 2]; // 如果数组长度是奇数 if ((temp.length & 1) == 1) return num1 * 1.0; // 直接返回中间的元素 else // 如果数组长度是偶数,返回中间两个元素的平均值 return (num1 + temp[temp.length / 2 - 1]) / 2.0; }}
其中 (temp.length & 1) == 1 用到了**位运算 **,不懂的球友可以戳链接了解一下。
在二进制中,奇数的最低位总是 1,而偶数的最低位总是 0。因此,将 temp.length 与 1 进行按位与操作实际上是在检查 temp.length 的最低位是 1 还是 0。如果结果是 1,那么 temp.length 是奇数;如果结果是 0,则是偶数。
更直观的方式是用**取模运算符 ** %,我在讲 HashMap 的时候有讲过,可以戳链接去了解。
int[] array = {1, 2, 3, 4, 5};
// 使用模运算符 % 检查数组长度是否是奇数if (array.length % 2 == 1) { // 如果是奇数 System.out.println("数组长度是奇数。");} else { // 如果是偶数 System.out.println("数组长度是偶数。");}
模运算符 % 返回两个数相除的余数。如果数组长度除以 2 的余数为 1,则表示数组长度是奇数;如果余数为 0,则表示数组长度是偶数。
我的方法 findMedianSortedArrays 返回的是 double 类型,所以我们在求出中位数的时候在题解中使用 * 1.0 或 / 2.0 来确保结果是浮点数(double 类型),而不是整数。这在计算平均值或需要精确的小数结果时尤为重要,避免了整数除法的小数截断问题,大家可以试一下不乘以 1.0 或不除以 2.0 的结果。
好,我们来看一下题解效率。
虽然也通过了,但时间复杂度:,合并数组:O(m + n),其中 m 和 n 分别是两个数组的长度,排序数组:O((m + n) log (m + n)),并没有达到题目的要求。
分析 2
不知道球友们之前有没有学过 归并排序 ,如果没有的话,可以戳链接观看视频学习一下(下图来源于该视频教程,作者请叫我 AXin)。
这里总结一下,什么是归并排序?
归并排序是建立在归并操作上的一种有效的排序算法,该算法是采用分治法(Divide and Conquer)的一个非常典型的应用,且各层分治递归可以同时进行。
归并排序思路简单,速度仅次于快速排序,为稳定排序算法,一般用于对总体无序,但是各子项相对有序的数列。
归并排序的核心思想是分治法,先递归分解数组,再合并数组。将数组分解最小之后,然后合并两个有序数组,基本思路是比较两个数组的最前面的数,谁小就先取谁,取了后相应的指针就往后移一位。然后再比较,直至一个数组为空,最后把另一个数组的剩余部分复制过来即可。
例如,对数组 [3, 1, 4, 1, 5, 9, 2, 6] 进行归并排序:
- 分解为 [3, 1, 4, 1] 和 [5, 9, 2, 6]。
- 继续分解,比如 [3, 1, 4, 1] 分解为 [3, 1] 和 [4, 1]。
- 继续分解,直到子数组只包含一个元素。
- 开始合并,例如将 [3, 1] 合并为 [1, 3]。
- 继续合并其他子数组。
- 最后,将所有合并的子数组合并为最终的排序数组。
它每一层的拆分都是对半的,所以时间复杂度是,而每一层的合并都是对两个数组的合并,所以时间复杂度是,所以整体的时间复杂度是。
分解步骤涉及将数组递归地分成两半,直到每个子数组只包含一个元素。对于长度为 n 的数组,这个分解过程的时间复杂度是 O(log n)。
为什么是 O(log n)?因为每次递归调用都将数组分为两部分,所以深度为 log n(这里的对数是以 2 为底)。例如,一个长度为 8 的数组分解成长度为 1 的子数组需要 3 步(8 -> 4 -> 2 -> 1),因为 2^3 = 8。
归并步骤涉及将两个已排序的子数组合并成一个已排序的数组。每次合并操作的时间复杂度是 O(n),因为它涉及遍历两个子数组的所有元素。例如,合并长度为 4 的子数组需要 4 步(1 + 1 + 1 + 1)。
根据归并思想,我们可以写出下面这样的题解。
class Solution {
int n = nums1.length; int m = nums2.length; // 判断总元素数量是奇数还是偶数 boolean flag = ((m + n) % 2) == 0; int point1 = 0, point2 = 0; // 分别为 nums1 和 nums2 的指针 int remNum1 = 0, remNum2 = 0; // 用于存储当前遍历的元素和前一个元素
// 遍历两个数组的元素,直到到达中位数的位置 for (int i = 0; i <= (n + m) / 2; i++) { remNum2 = remNum1; // 存储前一个元素
// 比较两个数组当前元素,取较小的那个 if (point1 < n && (point2 >= m || nums1[point1] < nums2[point2])) { remNum1 = nums1[point1++]; } else { remNum1 = nums2[point2++]; } }
// 如果总元素数量是偶数,则中位数是中间两个数的平均值 if (flag) { return (remNum1 + remNum2) / 2.0; } // 如果总元素数量是奇数,则中位数是中间的那个数 return remNum1 * 1.0; }}
当输入是 nums1 = [1,2] 和 nums2 = [3,4] 时,我们来模拟整个题解过程:
- 初始化:
- nums1 的长度 n = 2,nums2 的长度 m = 2。
- 总长度 n + m = 4,所以总长度是偶数,flag = true。
- 设置两个数组的指针 point1 = 0,point2 = 0。
- 初始化 remNum1 和 remNum2 为 0,用来存储当前元素和前一个元素。
- 开始遍历数组:
- 第一次循环(i = 0):
- nums1[0] = 1,nums2[0] = 3。选择较小的 nums1[0],remNum1 = 1,point1 增加到 1。
- 第二次循环(i = 1):
- nums1[1] = 2,nums2[0] = 3。选择较小的 nums1[1],remNum2 = remNum1 = 1,remNum1 = 2,point1 增加到 2。
- 第三次循环(i = 2):
- nums1 已经遍历完,只剩 nums2。选择 nums2[0] = 3,remNum2 = remNum1 = 2,remNum1 = 3,point2 增加到 1。
- 第四次循环(i = 3):
- 同理,选择 nums2[1] = 4,remNum2 = remNum1 = 3,remNum1 = 4。
- 计算中位数:
- 因为总长度是偶数(flag = true),中位数是最后两个遍历的数的平均值。
- remNum1 = 4,remNum2 = 3,所以中位数是 (4 + 3) / 2.0 = 3.5。
因此,对于 nums1 = [1,2] 和 nums2 = [3,4],中位数是 3.5。
条件 point1 < n && (point2 >= m || nums1[point1] < nums2[point2]) 是用来确定在合并两个排序数组的过程中,下一个要选取的元素是来自 nums1 还是 nums2。
- point1 < n:
- 这是检查 nums1 的指针 point1 是否还在数组的范围内。如果 point1 已经等于 n,意味着 nums1 中的所有元素都已经被考虑过了。
- point2 >= m:
- 这是检查 nums2 的指针 point2 是否超出了数组的范围。如果 point2 大于或等于 m,意味着 nums2 中的所有元素都已经被考虑过了。
- nums1[point1] < nums2[point2]
- 这是比较 nums1 和 nums2 当前指针位置的元素。如果 nums1 当前元素小于 nums2 当前元素,这个条件成立。
在合并两个排序数组时,我们希望按照排序顺序依次选取两个数组中的元素。这个条件帮助我们决定接下来应该选取 nums1 还是 nums2 中的元素:
- 当 nums1 还有元素,并且 nums2 要么已经处理完,要么 nums1 的当前元素更小,我们就选择 nums1 的当前元素。
- 如果条件不成立(即 nums1 处理完了,或者 nums1 的当前元素不小于 nums2 的当前元素),我们就从 nums2 中选取元素。
这样的逻辑确保了合并后的数组是有序的,同时也高效地利用了两个已排序数组的性质。
时间复杂度:,归并到中位数:O(m + n),每次从两个数组的开头取较小的数,直到达到中位数的位置。
但题目要求的是 。
分析 3
根据中位数的定义,当数组长度为奇数时,中位数就是数组中的第(size + 1) / 2个,偶数的话,则是第(size + 1) / 2个数和第(size + 1) / 2 + 1个数的平均数。
来看这样子一组样例(从第一个开始计数)
nums1:[2,5,8]
合并后:[2,3,4,5,5,6,7,8,8,9,10]
nums1.length + nums2.length = 11,新数组的长度为奇数,所以中位数为(11 + 1) / 2,也就是新数组的第 6 个数(下标为 5)。
由于数组是排序过(升序)的,所以求中位数就变成了一个寻找第 K 小的数的问题了。这里的“第 K 小”指的是如果所有元素都按升序排序,那么位于第 K 个位置的元素(从 1 开始计数)。
例如,考虑数组 [3, 5, 2, 7, 4],升序后是 [2, 3, 4, 5, 7]:
- 第 1 小的数是 2(最小的数)。
- 第 2 小的数是 3。
- 第 3 小的数是 4。
- 以此类推。
关于寻找第 K 小的数,我这里有一个视频,讲得很好,大家可以戳链接观看:https://www.bilibili.com/video/BV1TR4y197X2
中位数本质上是数组排序后位于中间位置的数,因此它可以视为一个特殊的第 K 小的数的问题。
- 当总元素个数是奇数时:中位数是第 (m + n + 1) / 2 小的数,其中 m 和 n 分别是两个数组的长度。例如,如果两个数组总共有 11 个元素,那么中位数就是第 (11 + 1) / 2 = 6 小的数。
- 当总元素个数是偶数时:中位数是第 (m + n) / 2 和第 (m + n) / 2 + 1 小的数的平均值。例如,如果两个数组总共有 10 个元素,那么中位数就是第 10 / 2 = 5 小的数和第 6 小的数的平均值。
相信这个大家应该都明白了。
在这道题中,我们不需要合并两个数组来找到中位数。
相反,我们可以使用二分查找的方法找到第 K 小的数。这种方法在两个数组都是排序过的情况下特别有效,因为我们可以使用二分查找法搜索第 K / 2 个元素(即两个数组中第 K 小的元素),并且由于两个数组中的元素有序,所以我们可以比较这两个第 K / 2 个元素的值来决定如何继续搜索。
什么是二分查找法?
二分查找法是一种在有序数组中查找某一特定元素的搜索算法。搜索过程从数组的中间元素开始,如果中间元素正好是要查找的元素,则搜索过程结束;如果某一特定元素大于或者小于中间元素,则在数组大于或小于中间元素的那一半中查找,而且跟开始一样从中间元素开始比较。如果在某一步骤数组为空,则代表找不到。这种搜索算法每一次比较都使搜索范围缩小一半。
比如说 A 数组「A1,A2,A3,A4,A5」,B 数组「B1,B2,B3,B4,B5」,一共有 10 个数,我们要找出第 5 小的数和第 6 小的数,它们和的二分之一就是合并后数组的中位数,对吧?
那也就是我们要求两次,一次求第 5 小的数,一次求第 6 小的数。
求第 5 小的数时,我们可以先从 A 数组中 K/2 找,K = 5/2 = 2,也就是 A2;再从 B 数组中找 K/2,K = 5/2 = 2,也就是 B2,然后比较 A2 和 B2。
如果 A2 < B2,那么 A1、A2 肯定不可能是第 5 小的数,因为最好的情况就是下面这样子,B2 都不是第 5 小,A1、A2 肯定不行,对吧?
所以我们可以把 A1-1、A2-3 排除掉(假设-后跟的值,方便大家理解),于是两个数组变成了这样子:
- A 数组「A1-5,A2-7,A3-8」
- B 数组「B1-2,B2-4,B3-6,B4-9,B5-10」
由于排除了两个数,所以 K = 5 - 2 = 3,变成了要找第 3 小的数,那么我们可以再从 A 数组中找 K/2,K = 3/2 = 1,也就是 A1;再从 B 数组中找 K/2,K = 3/2 = 1,也就是 B1,然后比较 A1 和 B1。
此时 B1-2 < A1-5,那么 B1 肯定不可能是第 3 小的数,因为最好的情况就是下面这样子,A1 可能是第 3 小(还不确定 B2、B3 的值),但 B1 肯定不行,对吧?
那把 B1 排除掉,于是两个数组变成了这样子:
- A 数组「A1-5,A2-7,A3-8」
- B 数组「B1-4,B2-6,B3-9,B4-10」
由于排除了一个数,所以 K = 3 - 1 = 2,变成了要找第 2 小的数,那么我们可以再从 A 数组中找 K/2,K = 2/2 = 1,也就是 A1;再从 B 数组中找 K/2,K = 2/2 = 1,也就是 B1,然后比较 A1 和 B1。
此时 B1-4 < A1-5,那么 B1 肯定不可能是第 2 小的数,
以此类推,直到 K = 1,也就是找到了第 1 小的数,也就是 5;再找第 6 小的数也是一样的道理,最后求平均值就是中位数了。
我们来看题解:
class Solution {
int ans1 = getKth(nums1, 0, nums1.length - 1, nums2, 0, nums2.length - 1, (nums1.length + nums2.length + 1) / 2); int ans2 = getKth(nums1,0, nums1.length - 1, nums2, 0, nums2.length - 1, (nums1.length + nums2.length + 2) / 2); //如果是奇数,则两次求的k都是一样的,即(nums1.length + nums2.length + 1) / 2 == (nums1.length + nums2.length + 2) / 2 return (ans1 + ans2) / 2.0; }
private int getKth(int[] nums1, int start1, int end1, int[] nums2, int start2, int end2, int k) { int siz1 = end1 - start1 + 1; int siz2 = end2 - start2 + 1; if (siz1 > siz2) return getKth(nums2, start2, end2, nums1, start1, end1, k); //为了方便知道哪个数组首先为空,我们设置siz1严格小于等于siz2,这样子一旦有数组被清空,则一定是nums1 if (siz1 == 0) return nums2[start2 + k - 1];//已经把第一个数组全部排除,只需要求第二数组的剩下数字中的第 k 个就行了(更新后的k) if (k == 1) return Math.min(nums1[start1], nums2[start2]);//求当前两个数组中最小的数字,当然直接返回两个当前中最小的即可 int pos1 = start1 + Math.min(siz1, k / 2) - 1; int pos2 = start2 + Math.min(siz2, k / 2) - 1;
if (nums1[pos1] > nums2[pos2]) return getKth(nums1, start1, end1, nums2, pos2 + 1, end2, k - (pos2 - start2 + 1));//排除第二个数组不符合条件的数字 else return getKth(nums1, pos1 + 1, end1, nums2, start2, end2, k - (pos1 - start1 + 1));//排除第一个数组不符合条件的数字 }}
当输入是 nums1 = [2,5,8] 和 nums2 = [3,4,5,6,7,8,9,10] 时,我们来模拟一下整个题解的过程:
- 计算中位数的位置:
- nums1.length = 3, nums2.length = 8,总长度 n + m = 11。
- 由于 11 是奇数,中位数是合并后数组中的第 (11 + 1) / 2 = 6 个元素。
- 第一次调用 getKth 方法:
- 寻找第 6 小的元素,调用 getKth(nums1, 0, 2, nums2, 0, 7, 6)。
- 递归查找中位数:
- 在 getKth 方法中,我们会不断递归缩小 k 的值,直到找到第 k 小的数。
- 对于第一次调用,我们比较 nums1[2] (8) 和 nums2[2] (5)。由于 8 > 5,我们可以确定在 nums2[0..2] 中没有第 6 小的数,这些元素可以被排除。
- 更新 k 的值:k = 6 - 3 = 3 (排除了 3 个元素)。
- 继续递归。
- 找到中位数:
- 最后找到中位数是 6.0。
如果还不明白的话,可以利用 ACM 输入输出的格式在 Intellij IDEA 中进行 debug 调试。
public class Main004 {
Scanner scanner = new Scanner(System.in); System.out.println("输入第一个数组"); // 读取一串字符,按照, 分割成 int 型数组 String[] nums1Str = scanner.nextLine().split(","); int[] nums1 = new int[nums1Str.length]; for (int i = 0; i < nums1Str.length; i++) { nums1[i] = Integer.parseInt(nums1Str[i]); }
System.out.println("输入第二个数组"); // 读取一串字符,按照, 分割成 int 型数组 String[] nums2Str = scanner.nextLine().split(","); int[] nums2 = new int[nums2Str.length]; for (int i = 0; i < nums2Str.length; i++) { nums2[i] = Integer.parseInt(nums2Str[i]); }
Solution004 solution = new Solution004(); double median = solution.findMedianSortedArrays(nums1, nums2); System.out.println("中位数是: " + median); }}
class Solution004 { public double findMedianSortedArrays(int[] nums1, int[] nums2) { int ans1 = getKth(nums1, 0, nums1.length - 1, nums2, 0, nums2.length - 1, (nums1.length + nums2.length + 1) / 2); int ans2 = getKth(nums1,0, nums1.length - 1, nums2, 0, nums2.length - 1, (nums1.length + nums2.length + 2) / 2); //如果是奇数,则两次求的k都是一样的,即(nums1.length + nums2.length + 1) / 2 == (nums1.length + nums2.length + 2) / 2 return (ans1 + ans2) / 2.0; } private int getKth(int[] nums1, int start1, int end1, int[] nums2, int start2, int end2, int k) { int siz1 = end1 - start1 + 1; int siz2 = end2 - start2 + 1; if (siz1 > siz2) { return getKth(nums2, start2, end2, nums1, start1, end1, k); } //为了方便知道哪个数组首先为空,我们设置siz1严格小于等于siz2,这样子一旦有数组被清空,则一定是nums1 if (siz1 == 0) { return nums2[start2 + k - 1];//已经把第一个数组全部排除,只需要求第二数组的剩下数字中的第 k 个就行了(更新后的k) } if (k == 1) { return Math.min(nums1[start1], nums2[start2]);//求当前两个数组中最小的数字,当然直接返回两个当前中最小的即可 } int pos1 = start1 + Math.min(siz1, k / 2) - 1; int pos2 = start2 + Math.min(siz2, k / 2) - 1; if (nums1[pos1] > nums2[pos2]) { //排除第二个数组不符合条件的数字 return getKth(nums1, start1, end1, nums2, pos2 + 1, end2, k - (pos2 - start2 + 1)); } else { //排除第一个数组不符合条件的数字 return getKth(nums1, pos1 + 1, end1, nums2, start2, end2, k - (pos1 - start1 + 1)); } }}
完整的跑一次就能明白了。
时间复杂度:
第一次,我们排除了(n + m) / 2 / 2个数字
第二次,我们排除了(n + m) / 2 / 2 / 2 个数字
最终我们排除了次,终于把(n + m) / 2 - 1个数字全都排除完了,所以时间复杂度是
轻松打败 100% 用户。
总结
本次题解我们一共经历了三个重大的思维转变:
- 暴力解法,时间复杂度为 。
- 归并排序,时间复杂度为 。
- 二分查找结合寻找第 K 小的数,时间复杂度为 。
涉及到知识点也比较多,大部分在《二哥的 Java 进阶之路》上都能找到,比如说:
力扣链接:https://leetcode.cn/problems/median-of-two-sorted-arrays/
一步一个脚印
不积跬步无以至千里,不积小流无以成江海。LeetCode - 100天从算法小白到卷王正式启动了,我们计划周一到周五至少每天更新一篇,周六周日更新一篇,目标 300 道 LeetCode 经典题。
该题解目前只针对**二哥编程星球 **的球友开放,如果你也想一起打卡学习算法的话,扫下面的优惠券加入我们吧,星球目前 4100+ 人,定价 149 元,从当初的 99 元一路涨上来的,所以早就是优势,别再犹豫了。
(优惠券仅 300 张,数量有限)
一个人可以走得很快,但一群人才能走得更远。如果想去面中大厂的话,LeetCode 几乎是必刷的,二哥的 LeetCode 题解通俗易懂、图文并茂,理解给你举一反三的解题能力,冲鸭。
知识星球
扫码加入星球
查看更多优质内容
https://wx.zsxq.com/mweb/views/joingroup/join_group.html?group_id=15522885221412