问题简介
题目描述
给定两个大小分别为 m 和 n 的正序(从小到大)数组 nums1 和 nums2。请你找出并返回这两个正序数组的 中位数。
算法的时间复杂度应该为 O(log (m+n))。
示例说明
✅ 示例 1:
输入:nums1 = [1,3], nums2 = [2]
输出:2.00000
解释:合并数组 = [1,2,3] ,中位数 2
✅ 示例 2:
输入:nums1 = [1,2], nums2 = [3,4]
输出:2.50000
解释:合并数组 = [1,2,3,4] ,中位数 (2 + 3) / 2 = 2.5
✅ 示例 3:
输入:nums1 = [], nums2 = [1]
输出:1.00000
解题思路
💡 方法一:暴力合并(不符合题目要求,但作为理解基础)
- 将两个数组合并成一个有序数组
- 直接计算中位数
- 时间复杂度:O(m + n),空间复杂度:O(m + n)
❌ 缺点:不满足 O(log(m+n)) 的时间复杂度要求
💡 方法二:二分查找(推荐解法)
核心思想:将问题转化为寻找第 k 小的元素
步骤详解:
-
统一处理奇偶情况:
- 如果总长度为奇数,中位数 = 第
(m+n+1)/2小的数 - 如果总长度为偶数,中位数 = (第
(m+n)/2小的数 + 第(m+n)/2+1小的数) / 2
- 如果总长度为奇数,中位数 = 第
-
设计 findKth 函数:
- 在两个有序数组中找到第 k 小的元素
- 每次比较两个数组的第 k/2 个元素
- 排除较小的那一部分,因为它们不可能包含第 k 小的元素
-
边界处理:
- 当一个数组为空时,直接返回另一个数组的第 k 个元素
- 当 k = 1 时,返回两个数组首元素的较小值
-
递归/迭代实现:
- 通过不断缩小搜索范围,最终找到目标元素
💡 方法三:二分分割点(更优雅的二分法)
核心思想:在较短的数组上进行二分,找到合适的分割点
- 确保 nums1 是较短的数组(如果不是则交换)
- 在 nums1 上进行二分搜索,寻找分割点 i
- 对应的 nums2 分割点为 j = (m + n + 1) / 2 - i
- 检查分割是否有效:
nums1[i-1] <= nums2[j] && nums2[j-1] <= nums1[i] - 根据奇偶性计算中位数
代码实现
java
// Java 实现 - 方法二:二分查找第k小元素
class Solution {
public double findMedianSortedArrays(int[] nums1, int[] nums2) {
int m = nums1.length, n = nums2.length;
int total = m + n;
if (total % 2 == 1) {
return findKth(nums1, 0, nums2, 0, total / 2 + 1);
} else {
return (findKth(nums1, 0, nums2, 0, total / 2) +
findKth(nums1, 0, nums2, 0, total / 2 + 1)) / 2.0;
}
}
private int findKth(int[] nums1, int start1, int[] nums2, int start2, int k) {
// 确保 nums1 剩余长度 <= nums2 剩余长度
if (nums1.length - start1 > nums2.length - start2) {
return findKth(nums2, start2, nums1, start1, k);
}
// 边界情况:nums1 已经遍历完
if (start1 >= nums1.length) {
return nums2[start2 + k - 1];
}
// 边界情况:k = 1
if (k == 1) {
return Math.min(nums1[start1], nums2[start2]);
}
// 计算比较位置
int mid1 = Math.min(start1 + k / 2 - 1, nums1.length - 1);
int mid2 = start2 + k / 2 - 1;
if (nums1[mid1] <= nums2[mid2]) {
// 排除 nums1 的前半部分
return findKth(nums1, mid1 + 1, nums2, start2, k - (mid1 - start1 + 1));
} else {
// 排除 nums2 的前半部分
return findKth(nums1, start1, nums2, mid2 + 1, k - k / 2);
}
}
}
// Java 实现 - 方法三:二分分割点
class Solution2 {
public double findMedianSortedArrays(int[] nums1, int[] nums2) {
if (nums1.length > nums2.length) {
return findMedianSortedArrays(nums2, nums1);
}
int m = nums1.length, n = nums2.length;
int left = 0, right = m;
int median1 = 0, median2 = 0;
while (left <= right) {
int i = (left + right) / 2;
int j = (m + n + 1) / 2 - i;
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 (nums1LeftMax <= nums2RightMin) {
median1 = Math.max(nums1LeftMax, nums2LeftMax);
median2 = Math.min(nums1RightMin, nums2RightMin);
left = i + 1;
} else {
right = i - 1;
}
}
return (m + n) % 2 == 0 ? (median1 + median2) / 2.0 : median1;
}
}
go
// Go 实现 - 方法二:二分查找第k小元素
func findMedianSortedArrays(nums1 []int, nums2 []int) float64 {
m, n := len(nums1), len(nums2)
total := m + n
if total%2 == 1 {
return float64(findKth(nums1, 0, nums2, 0, total/2+1))
} else {
return float64(findKth(nums1, 0, nums2, 0, total/2)+findKth(nums1, 0, nums2, 0, total/2+1)) / 2.0
}
}
func findKth(nums1 []int, start1 int, nums2 []int, start2 int, k int) int {
// 确保 nums1 剩余长度 <= nums2 剩余长度
if len(nums1)-start1 > len(nums2)-start2 {
return findKth(nums2, start2, nums1, start1, k)
}
// 边界情况:nums1 已经遍历完
if start1 >= len(nums1) {
return nums2[start2+k-1]
}
// 边界情况:k = 1
if k == 1 {
return min(nums1[start1], nums2[start2])
}
// 计算比较位置
mid1 := min(start1+k/2-1, len(nums1)-1)
mid2 := start2 + k/2 - 1
if nums1[mid1] <= nums2[mid2] {
// 排除 nums1 的前半部分
return findKth(nums1, mid1+1, nums2, start2, k-(mid1-start1+1))
} else {
// 排除 nums2 的前半部分
return findKth(nums1, start1, nums2, mid2+1, k-k/2)
}
}
func min(a, b int) int {
if a < b {
return a
}
return b
}
// Go 实现 - 方法三:二分分割点
func findMedianSortedArrays2(nums1 []int, nums2 []int) float64 {
if len(nums1) > len(nums2) {
return findMedianSortedArrays2(nums2, nums1)
}
m, n := len(nums1), len(nums2)
left, right := 0, m
var median1, median2 int
for left <= right {
i := (left + right) / 2
j := (m + n + 1) / 2 - i
nums1LeftMax := math.MinInt32
if i > 0 {
nums1LeftMax = nums1[i-1]
}
nums1RightMin := math.MaxInt32
if i < m {
nums1RightMin = nums1[i]
}
nums2LeftMax := math.MinInt32
if j > 0 {
nums2LeftMax = nums2[j-1]
}
nums2RightMin := math.MaxInt32
if j < n {
nums2RightMin = nums2[j]
}
if nums1LeftMax <= nums2RightMin {
median1 = max(nums1LeftMax, nums2LeftMax)
median2 = min(nums1RightMin, nums2RightMin)
left = i + 1
} else {
right = i - 1
}
}
if (m+n)%2 == 0 {
return float64(median1+median2) / 2.0
}
return float64(median1)
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
示例演示
📌 以示例1为例:nums1 = [1,3], nums2 = [2]
方法二执行过程:
- 总长度 = 3(奇数),需要找第 2 小的元素
- 调用 findKth([1,3], 0, [2], 0, 2)
- 比较 nums1[0] = 1 和 nums2[0] = 2
- 1 < 2,排除 nums1[0],在 [3] 和 [2] 中找第 1 小的元素
- 返回 min(3, 2) = 2
方法三执行过程:
- nums1 较短,在 nums1 上二分
- i = 1, j = (2+1+1)/2 - 1 = 1
- 检查分割:nums1[0] = 1 <= nums2[1] = ∞ ✓,nums2[0] = 2 <= nums1[1] = 3 ✓
- 左侧最大值 = max(1, 2) = 2,返回 2
答案有效性证明
✅ 正确性保证:
-
方法二:
- 每次递归都能正确排除 k/2 个不可能的元素
- 边界条件处理完整
- 最终必然能找到第 k 小的元素
-
方法三:
- 通过二分确保找到正确的分割点
- 分割后左侧元素数量 = 右侧元素数量(或左侧多1)
- 中位数由分割点附近的四个值决定
✅ 数学归纳法验证:
- 基础情况:当一个数组为空时,结果显然正确
- 归纳步骤:每次递归/迭代都保持问题规模减小,且不丢失正确答案
复杂度分析
| 方法 | 时间复杂度 | 空间复杂度 | 是否满足要求 |
|---|---|---|---|
| 暴力合并 | O(m + n) | O(m + n) | ❌ |
| 二分查找第k小 | O(log(m + n)) | O(log(m + n)) | ✅ |
| 二分分割点 | O(log(min(m, n))) | O(1) | ✅ |
💡 最优解:方法三的时间复杂度更优,为 O(log(min(m, n)))
问题总结
📌 关键洞察:
- 中位数问题可以转化为寻找第 k 小元素的问题
- 利用数组有序的特性,可以通过二分法高效排除不可能的区域
- 选择在较短数组上进行二分可以进一步优化时间复杂度
📌 技巧要点:
- 边界处理:空数组、k=1 等特殊情况
- 索引计算:避免数组越界,使用 min/max 保护
- 奇偶统一:通过 (m+n+1)/2 和 (m+n+2)/2 统一处理
📌 面试建议:
- 先给出暴力解法展示思路
- 再优化到 O(log(m+n)) 的二分解法
- 重点解释为什么每次可以排除 k/2 个元素
- 强调边界条件的处理逻辑