【LeetCode选讲·第二期】「寻找两个正序数组的中位数」(分治法)

365 阅读8分钟

T4 寻找两个正序数组的中位数

题目链接:leetcode.cn/problems/me…

思路一:数组合并法

一种很容易想到的方法是,我们可以设法对数组进行合并,使得新数组有序,然后直接计算中位数。

为了避免对数组长度奇偶性的讨论,可以直接使用如下代码进行计算:

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个比它小的数这一结论。但其缺陷也很明显,即查找的过程是从数组尾端开始一个一个排除,算法的效率较低。有没有更快的解法?

分治法

所谓的分治,便是把一个问题分解(化简)成相似的子问题,再把子问题分解(化简)成更小的子问题,直到最后子问题可以简单地直接求解,原问题的解即子问题解的合并。

如果你是初次接触分治法,或许还不明白上面的概念是什么意思。不要着急,我们一步步深入。

在利用分治法解题时,我们首先需要考虑清楚两点:

①最后可以直接求解的子问题是什么?
②如何将原问题分解(化简)成子问题?

我们先来考虑问题①。首先,我们不妨设两个数组为AB,假如令k = 1,那么第k小的数是谁呢?很简单吧,假设AB的有效长度都从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意味着什么?这不就是我们一开始在问题①中分析的最终可以直接求解的子问题嘛!于是我们可以得出答案,原数组AB的合并数组中,第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]).

又考虑到上述算法中的ijsisj可能会出现越界的情况,我们还需要对其进行完善。为了降低思维量,我们不妨规定A的有效区间始终小于B的有效区间,以简化对越界情况的处理

完善后的算法如下:

  • 设数组A的有效区间从 i 开始,数组B的有效区间从 j 开始.
  • A.length - i > B.length - j,为确保数组B的有效区间大于A,交换数组A和数组Bij. 若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的话,欢迎向您的同事和朋友推荐,您的支持是我们最大的动力!

QQ图片20220701165008.png