T4 寻找两个正序数组的中位数
思路一:数组合并法
一种很容易想到的方法是,我们可以设法对数组进行合并,使得新数组有序,然后直接计算中位数。
为了避免对数组长度奇偶性的讨论,可以直接使用如下代码进行计算:
function getMidNum(arr) {
let len = arr.length;
let midNum1 = arr[ Math.ceil(len / 2) - 1];
let midNum2 = arr[ Math.ceil( (len+1) / 2) - 1 ];
let midNum = (midNum1 + midNum2) / 2;
return midNum;
}
我们可以直接使用语言自带的排序算法进行数组的合并:
function findMedianSortedArrays(arr1, arr2) {
let arr = [...arr1, ...arr2].sort();
console.log(getMidNum(arr));
}
我们也可以选择其他算法实现数组的合并。下面给出一种利用二分查找进行合并的实现代码:
function findMedianSortedArrays(arr1, arr2) {
const arr = Array.from(arr1);
arr2.forEach(n => {
let i = 0;
let j = arr.length - 1;
while(i <= j) {
let mid = Math.floor( (i + j) / 2 );
if(arr[mid] > n) {
j = mid - 1;
} else {
i = mid + 1;
}
}
arr.splice(i, 0, n);
});
console.log(getMidNum(arr));
}
思路二:问题转化法
我们首先需要将原问题转化为“从两个有序数组(的有序合并数组中)中找第k小的数”。然后我们根据合并数组的长度length讨论计算中位数时k的值,再找出第k小的数就能解决问题。
分类讨论如下,不理解的同学可以自己举个例子尝试一下:
1.当
length为偶数时,只需找出第length/2小的数和第length/2+1小的数,两者平均值即为中位数;
2.当length为奇数时,只需找出第ceil(length/2)小的数,即为中位数.
用代码表示即为:
function findMedianSortedArrays(arr1, arr2) {
const totalLength = arr1.length + arr2.length;
const ans = totalLength % 2 === 0 ?
(find(arr1, arr2, totalLength/2) + find(arr1, arr2, totalLength/2 + 1)) / 2
: find(arr1, arr2, Math.ceil(totalLength/2));
console.log(ans);
}
朴素解法
我们首先容易想到类似于下面的朴素解法:
function find(arr1, arr2, k) {
let len1 = arr1.length;
let len2 = arr2.length;
let i = Math.min(k - 1, len1 - 1);
let j = Math.min(k - 1, len2 - 1);
while(i + j + 2 > k) {
if(arr1[i] > arr2[j] && i >= 0) {
i--;
} else if(j >= 0) {
j--;
}
}
if(i >= 0 && j >= 0) {
return Math.max(arr1[i], arr2[j]);
} else if(i >= 0 && j <0) {
return arr1[i];
} else if(j >= 0 && i <0) {
return arr2[j];
}
}
上面的代码主要利用了第k小的数前面应有k-1个比它小的数这一结论。但其缺陷也很明显,即查找的过程是从数组尾端开始一个一个排除,算法的效率较低。有没有更快的解法?
分治法
所谓的分治,便是把一个问题分解(化简)成相似的子问题,再把子问题分解(化简)成更小的子问题,直到最后子问题可以简单地直接求解,原问题的解即子问题解的合并。
如果你是初次接触分治法,或许还不明白上面的概念是什么意思。不要着急,我们一步步深入。
在利用分治法解题时,我们首先需要考虑清楚两点:
①最后可以直接求解的子问题是什么?
②如何将原问题分解(化简)成子问题?
我们先来考虑问题①。首先,我们不妨设两个数组为A和B,假如令k = 1,那么第k小的数是谁呢?很简单吧,假设A和B的有效长度都从i = 0开始计数的话,答案就是min(A[0], B[0])。也就是说我们只需要最终将原题最终分解(化简)成前述的k = 1问题,就可以直接求解出答案。问题①搞定!
那么问题②怎么办呢?我们下面直接通过一个具体的例子来说明。
设A=[4,5,8,9],B=[1,2,6,7,10],假设现在k = 6。为了讲述方便,我们不妨规定数组下标从1开始计数。
第一轮化简
我们首先选取A数组的子数组(记作a),下标区间为[1, k\2],即a=[4,5,8];再选取B数组的子数组(记作b),下标区间为[1, k-k\2],即b[1,2,6]。由题意,A和B都是升序的,即a和b都是升序的,所以a和b中的最大项分别为a[3](值为8)和b[3](值为6)。
经比较,发现a[3]>b[3]。根据题意中数组的升序性质,我们知道,a[3]≥a[2]≥a[1],同时a[3]>b[3]≥b[2]≥b[1],也就是说a[3]为数组a和b合并数组中第(k\2)+(k-k\2)小的数,即第k=6小的数。
但是事实上我们很容易发现,A[3](即a[3])>B[4],A[3]明明是第7小的数!请不要担心,这和我们上文的推理并不矛盾。因为a和b只不过是数组A和B的子数组,整个数组的情况在对子数组进行分析的时候我们是无法把握的。因此a[3](即A[3]),至小是数组A和B中第k=6小的数。
下面我们考虑B[3](即b[3])。由于A[3]>B[3],且A[3]至小是数组A和B中第6小的数,于是B[3]至多是第5小的数(注意这里的至小和至多)。而现在我们寻找的是第k=6小的数,因而B[1]、B[2]、B[3]都不可能是是我们需要寻找的目标,直接将它们从数组B中排除即可。
记经过排除后的数组A'=[4,5,8,9],B='[7,10]。由于原先B数组中的前3项都被排除掉了,我们的问题也就随之简化为了寻找第k'=k-3=3小的数。 我们离最简单的子问题进了一步!
第二轮化简
我们进行类似第一轮化简的操作。选取A'数组的子数组a',下标区间为[1, k'\2],即a'=[4]。选取B'数组的子数组b',下标区间为[1, k'-k'\2],即b'=[7, 10]。经比较发现b'[2]>a'[1],即B'[2]>A'[1]。类似第一轮分解,我们可以将A'[1]从数组A'中排除。
记经过排除后的数组A''=[5, 8, 9],B''=[7, 10],我们的问题进一步被简化为寻找第k''=k'-1=2小的数。
第三轮化简
我们继续进行化简操作。选取A''数组的子数组a'',下标区间为[1, k''\2],即a''=[5]。选取B''数组的子数组b'',下标区间为[1, k''-k''\2],即b''=[7]。经比较发现b''[1]>a''[1],即B''[1]>A''[1]。类似前两轮的化简,我们可以将A''[1]从数组A''中排除。
现在A'''=[8, 9],B'''=[7, 10],我们的问题进一步被简化为寻找第k'''=k''-1=1小的数。
现在请你回忆一下,k'''=1意味着什么?这不就是我们一开始在问题①中分析的最终可以直接求解的子问题嘛!于是我们可以得出答案,原数组A和B的合并数组中,第k=6小的数(即在经过排除后的数组A'''和B'''中第k'''=1小的数)为min(A'''[0], B'''[0]),答案就是7。
规律总结
对比先前提到的朴素解法,不难发现,当采用分治解法后,每一次化简k的规模都会缩小为原先的一半,有效地提高了算法效率。并且当k为奇数时,上述的分治方法在k为奇数时仍然成立,感兴趣的读者可以自行尝试一下。
下面我们将本题的分治算法进行归纳(为了方便代码实现,规定数组A和B的起始下标为i = 0)。需要注意的是,虽然我们在上面的具体例子中,我们在化简问题时创建了A'、B'等一系列新数组,而在实际开发中这势必会带来一定的内存开销。因此为了保证程序的性能,我们引入数组有效区间的概念,来模拟排除元素后创建新数组的过程。
- 设数组A的有效区间从
i开始,数组B的有效区间从j开始,其中[i,si - 1]是数组A的前k \ 2个元素,[j,sj - 1]是数组B的前k - k \ 2个元素.- 当
A[si - 1] > B[sj - 1]时,则表示第k小的数一定不在[j,sj - 1]中. 令k = k - (sj - j),j = sj.- 当
A[si - 1] ≤ B[sj - 1]时,则表示第k小的数一定不在[i,si - 1]中. 令k = k - (si - i),i = si.- 循环往复上述过程,直至
k = 1时,求出ans = min(A[i], B[j]).
又考虑到上述算法中的i、j、si、sj可能会出现越界的情况,我们还需要对其进行完善。为了降低思维量,我们不妨规定A的有效区间始终小于B的有效区间,以简化对越界情况的处理。
完善后的算法如下:
- 设数组
A的有效区间从i开始,数组B的有效区间从j开始.- 如
A.length - i > B.length - j,为确保数组B的有效区间大于A,交换数组A和数组B、i和j. 若i ≥ A.length,直接求出ans = B[j + k - 1],程序结束.
若i < A.length,执行如下步骤:
- 使得
[i,si - 1]至多是数组A的前k \ 2个元素,[j,sj - 1]至多是数组B的前k - k \ 2个元素. 其中si = min(A.length, i + k \ 2),sj = j + k - k \ 2.- 当
A[si - 1] > B[sj - 1]时,则表示第k小的数一定不在[j,sj - 1]中. 令k = k - (sj - j),j = sj.- 当
A[si - 1] ≤ B[sj - 1]时,则表示第k小的数一定不在[i,si - 1]中. 令k = k - (si - i),i = si.
- 循环上述过程,直至
k = 1时,求出ans = min(A[i], B[j]).
实现代码
下面我们采用递归代码实现上述的利用分治法求解第k小数的算法:
function find(A, B, k, i = 0, j = 0) {
const LenA = A.length;
const LenB = B.length;
//保证A的长度始终比B小
if(LenA - i > LenB - j) return find(B, A, k, j, i);
//处理A数组有效区间已越界的情况
if(i >= LenA) return B[j + k - 1];
//处理k=1的情况
if(k === 1) return Math.min(A[i], B[j]);
//常规操作
let si = Math.min( LenA, i + Math.floor(k / 2) );
let sj = j + k - Math.floor(k / 2);
if(A[si - 1] > B[sj - 1]) {
return find(A, B, k - (sj - j), i, sj);
} else {
return find(A, B, k - (si - i), si, j);
}
}
配合上文中我们已经归纳出的函数findMedianSortedArrays,即可得到原题的正确解答!
写在文末
我是来自学生组织江南游戏开发社的PAK向日葵,我们目前正在致力于开发自研的非营利性网页端同人游戏《植物大战僵尸:旅行》。
我们诚挚邀请您体验我们作品。如果您喜欢TA的话,欢迎向您的同事和朋友推荐,您的支持是我们最大的动力!