FunctionGHW

立志成为真正程序员的码农,伪全栈,目前主要写C#

[旧文整理]两个等长数组2n个数,找出中位数

FunctionGHW posted @ 2013年4月13日 23:51 in 算法学习 with tags c 递归 搜索 O(log n) , 4499 阅读

最近两个星期,用C语言写了几小段示例代码,外加几个算法题的实现, 无奈自身水平捉鸡,总是出错问题,尤其是有指针的地方,每次都要调试好大一会儿,当真是苦不堪言啊。 C语言确实是小巧而又强大,不过也是让人又爱又恨。水平低如我等目测是没办法在大学期间搞定它了。 为了生存,还是先学点C#,python之类的语言吧<^_^>。

正文

题目大概是这样的:
已知两个给定的整数数组,数组长度都是n, 记为x[n], y[n]。 两个数组已被排序(升序),求这2n个整数的中位数。

嘿嘿,看到这个问题,有一点必须要知道:因为整数一共有2n个,所以根据小学数学知识, 中位数是用"中间"的两个数的和 再除以2得到的。 这样就可以把问题转化为在 2n个整数里查找2个"大小适中"的整数。

那么最容易想到的办法自然就是把这两个数组重新排序到一个数组里,然后直接取第(n-1)个和第 n 个数计算。但是如果就这样,这道题岂不是出得太无聊了?

简单地想了想,发现两个数组的关系分成大概可以分成三种(以下关系名称是我自己取的, 如有雷同,纯属巧合,呵呵)。(下文中,数组A数组B分别代指两个不同数组中的某一个,且在任意两条中相互无联系。此处用\(Min(X)\), \(Max(X)\)分别表示数组X的最小值与最大值)。

  • 包含:\(Min(A) \leq Min(B)\)并且\(Max(A) \geq Max(B)\)
  • 独立:\(Max(A) \leq Min(B)\);若恰好\(Max(A) = Min(B)\),可以想象成两个数组正好首尾"粘"在一起。
  • 交叉:\(Min(B) \lt Min(A) \lt Max(B)\)并且\(Max(A) \gt Max(B)\)

画个图比划一下,不难看出"独立"的情况是最简单的,直接就能算出答案。 而对于"包含"的情况,可以做出判断,计算中位数所用的两个整数必然是在数组B中,但是想直接确定到底是哪两个元素似乎是不可能的。 (如果您通过分析证明找到一种可以快速确定这两个元素的方法,我灰常欢迎您在评论区赐教,哈哈)。 但是在"交叉"这种情况下,似乎真的没什么好办法了。

还是想到了给出的条件:数组已经排序。看到这个条件不能说没有一点想法吧? 想到到二分搜索算法,每一次都把问题的规模缩小。我们是否可以"借鉴"一下,排除掉大量"无关答案"的元素, 从而快速筛选出想要的元素呢?

首先,如果数组长度是 1,那么答案很清楚。

如果数组长度是 2,总共有4个整数,考虑到上面提到的三种关系, 不好直接选出两个元素,干脆排个序,选出中间两个数计算中位数。

如果数组长度是n(\(n \gt 3\)),共计2n个数,依然称两个数组为A[1:n]B[1:n]

  • n是奇数,假设\(n=2k+1(k \geq 1)\)。取A[k+1]B[k+1](恰好是对应数组的中位数)。
    (1).\(A[k+1] = B[k+1]\)。因为有\(A[1:k] \leq A[k+1]=B[k+1], B[1:k] \leq B[k+1]=A[k+1]\),所以这2n个元素的升序序列里,必然有B[1:k], A[1:k]都在A[k+1], B[k+1]两个元素左边;同理因为\(A[k+2:2k+1] \geq A[k+1]=B[k+1], B[k+2:2k+1] \geq B[k+1]=A[k+1]\), 必然有A[k+2:2k+1], B[k+2:2k+1]都在A[k+1], B[k+1]右边。在A[k+1]左边和B[k+1]的右边都有2k个元素,所以A[k+1], B[k+1]正好是位于序列的中间,这样就得到中位数了。
    (2).\(A[k+1] \lt B[k+1]\),则必有\(A[1:k] \leq A[k+1] \lt B[k+1]\), 同时必有\(B[k+2:2k+1] \geq B[k+1] \gt A[k+1]\),即在2n个元素的升序序列中,A[1:k]在最左端,B[k+2:2k+1]在最右端,"对称"分布,中间"夹着"2k+2个元素。可以确定,中位数必然在这2k+2数"中间"了,且因为A[1:k]B[k+2:2k+1]是"对称"的,去掉后,剩余元素的中位数保持不变, 剩下的两个小数组A[k+1:2k+1], B[1:k+1],长度都是k+1,这 2k+2个数的中位数与原数组相等,因此继续递归求解它们的中位数。
    (3).对于\(B[k+1] \lt A[k+1]的情况,与(2)类似,不细说了。

  • n是偶数,假设\(n=2k(k \geq 2)\)。与n是奇数的情况相似,但是注意,此时两个数组无法以某一个元素划分成两段等长的小数组, 这里取A[k]和B[k+1]A[k]数组A分成A[1:k-1](长度为k-1)和A[k+1:2k](长度为k)的两段; B[k+1]数组B分成B[1:k](长度为k)和A[k+2:2k](长度为k-1)的两段。
    (1).\(A[k]=B[k+1]\),因为\(A[1:k-1] \leq A[k]=B[k+1], B[1:k] \leq B[k+1]=A[k]\),在这2n个元素的升序序列里,必然有B[1:k],A[1:k]共计2k-1个元素在A[k],B[k+1]左边;同理A[k+1:2k],b[k+2:2k]共计2k-1个元素都在A[k],B[k+1]右边。所以A[k+1], B[k+1]也正好是位于中间的两个数。
    (2).\(A[k] \lt B[k+1]\),因为\(A[1:k-1] \leq A[k] \lt B[k+1]\)\(B[k+2:2k] \geq B[k+1] \gt A[k]\),即在2n=4k个元素的升序序列中,A[1:k-1]共计k-1个元素在最左端,B[k+2:2k]共计k-1个元素在最右端,和奇数的情况类似,将A[1:k-1],B[k+2:2k]去掉后,对中位数也没有影响。 剩下的小数组A[k:2k], B[1:k+1],长度都是k+1,问题规模缩小,继续(递归)求解。
    (3).\(B[k+1]\lt A[k]\)的情况,也与(2)类似,只是去除的元素数是2k,而不是2k-2

代码实现

按照上面的方法,写出递归方案:

#include <stdio.h>
#include <stdlib.h>
#include <time.h>

//用于qsort库函数参数的比较函数, 不过也在get_median函数用了.
inline int compare(const void* x, const void* y)
{
    int num_x = *((int*)x);
    int num_y = *((int*)y);
    return (num_x > num_y) ? 1 : ((num_x == num_y) ? 0 : -1);
}

//因为只有4个数, 并且最多调用一次,
//所以就直接冒泡了, 简单.
void sort_four_nums(int arr[])
{
    int n = 4,
        i, j, temp;
    for (i = 0; i < n; ++i)
    {   for (j = i + 1; j < n; ++j)
        {   if (arr[i] > arr[j])
            {
                temp = arr[i];
                arr[i] = arr[j];
                arr[j] = temp;
            }
        }
    }
}
//这个变量用来计数 搜索的次数;
static int count = 0;

/* 给定两个长度为 n(n > 0) 的数组x[], y[],
 * 两个数组已被排序(升序). 求两个数组共计2n个元素的中位数.
 * 此算法每次递归完成都使问题的规模缩小大约一半,直到找出答案.
 * 因此算法时间复杂度为 O(log n).
 * */
double get_median(const int x[], const int y[], const int n)
{
    if (n == 1)
    {
        count++;
        return (x[0] + y[0]) / 2.0;
    }
    if(n == 2)
    {
        int nums[] = {x[0], x[1], y[0], y[1]};
        sort_four_nums(nums);
        count++;
        return (nums[1] + nums[2]) / 2.0;
    }
    int key_index_x = (n - 1) >> 1;
    int key_index_y = key_index_x;
    if ((n & 1) == 0)//n是偶数情况下的处理
    {
        key_index_y++;
    }
    switch ( compare( (void*)(x + key_index_x), (void*)(y + key_index_y) ) )
    {
        case 0:
            count++;
            return x[key_index_x];
            break;
        case 1:
            count++;
            return get_median(x, y + key_index_y, n - key_index_y);
            break;
        case -1:
            count++;
            return get_median(x + key_index_x, y, n - key_index_x);
            break;
    }
}

//Application start.
int main(void)
{
    //数组长度设为一亿, 小内存机器请注意, 两个数组加起来大约要750MB的内存,
    const int LEN = 100000000;
    int* nums_1 = (int*)malloc(LEN * sizeof(nums_1[0]));
    int* nums_2 = (int*)malloc(LEN * sizeof(nums_2[0]));

    int i;
    srand((unsigned int)time(NULL));
    for (i = 0; i < LEN; ++i)
    {
        nums_1[i] = rand();
    }
    srand((unsigned int)time(NULL));
    for (i = 0; i < LEN; ++i)
    {
        nums_2[i] = rand();
    }
    //排序, 一亿个数排序就需要大约18s, 请耐心等待.
    qsort((void*)nums_1, LEN, sizeof(nums_1[0]), compare);
    qsort((void*)nums_2, LEN, sizeof(nums_2[0]), compare);

    double middan;
    const int ITER = 1000000;
    time_t start_time = clock();
    //之所以要循环执行多次,是因为单次执行时间太小,小到测不出来
    for (i = 0; i < ITER; ++i)
    {   //调用是在这里
        middan = get_median(nums_1, nums_2, LEN);
    }
    time_t stop_time = clock();

    printf("time used: %f\n", (double)(stop_time - start_time) / CLOCKS_PER_SEC);
    printf("serching count: %d, the middan is : %.2f\n", (count / ITER) , middan);
    getchar();
    return 0;
}
                
                

下面看一下工作表现,首先,根据前面的分析,这个算法的时间复杂度自然就是\(O(\log n)\)了。但是最初测试性能的时候,因为输入较小,执行得太快了, 所以时间小到根本测不到。逐步增加输入,当数组长度是一亿的时候,这个小小的程序运行时能吃掉大约750MB内存(好心痛啊!)。 但是仍然测不到执行时间,最后只得循环执行一百万次,终于测到时间了——竟然只有0.3s左右。

想想也是,最坏情况也不过需要搜索28次左右罢了。尽管知道\(O(\log n)\)的算法很快,但还是没想到居然会这么快。 看来如果算法时间复杂度是\(O(\log n)\),除非常数太大, 几乎不用担心性能问题了。

旧文链接

旧文的代码是迭代实现,风格可能比我现在的代码风格还糟糕,要看的话请做好心理准备,另外, 根据我的简单测试,输入量同是一亿,迭代实现的执行一百万次的时间大约是0.2s, 比递归稍快。至于原因,可能是因为递归实现每一次都需要压栈和清栈吧。
求两个已排序(升序)等长的整数数组所有元素的中位数

 

Last modify: 2013-4-15

Kingsley Chen 说:
2013年4月15日 13:50

一看后面的逻辑分析就知道有bug。
<pre>
int a[] = {1,3,5,7,9,11};
int b[] = {2,4,5,6,8,10};
double ret = get_median(a, b, 6);
printf("%f", ret);

>>>>5.0000
</pre>

你的逻辑分析其实是有bug的。最开始的包含独立交叉什么的都没用,重点在于两个数组中位数的相关关系可以导出的结果。

O哥你的数学还是太弱。

Avatar_small
FunctionGHW 说:
2013年4月15日 17:32

最开始的独立包含是我知道没啥用,那个只是我刚看到这道题的时候分析用的,我就是记录下自己的思维过程罢了。 到后面才想到这种"二分"的方法。

我写blog就是想把自己想到的过程也写出了,又不是出书,哈哈。

我数学差 这个我承认啊,你不能用论文的标准来衡量啊,而且这也算是我第一次写这种带逻辑分析的文章,尽管我自己都觉得"不伦不类"。

我重新整理修改了一下,你说的bug也修改了下,你感觉是不是好点?


登录 *


loading captcha image...
(输入验证码)
or Ctrl+Enter