一次求两序列中位数分治算法探索历程

如今博主在上算法设计与分析这门课,前两天刚讲到了分治法。
下面照搬一下教材《算法设计与分析》(第2版,王红梅、胡明 编著)分治法的设计思想概述。

分治者,分而治之也。

分治法(divide and conquer method)将一个难以直接解决的发问题划分成一些规模较小的子问题,分别求解各个子问题,再合并子问题的解得到原问题的解。一般来说,分治法的求解过程由以下三个阶段组成:

  1. 划分:把规模为n的原问题划分为k个(通常k=2)规模较小的子问题。
  2. 求解子问题:各子问题的解法与原问题的解法通常是相同的,可以用递归的方法求解各个子问题,有时递归处理也可以用循环来实现。
  3. 合并:把各个子问题的解合并起来,合并的代价因情况不同有很大差异,分支算法的效率很大程度上依赖于合并的实现。

也就是说,分治法是把一个大问题划分为若干个子问题,分别求解各个子问题,然后再把子问题的解进行合并得到原问题的解。而与之类似的减治法同样是把一个大问题划分为若干个子问题,但是这些子问题不需要分别求解,只需求解其中的一个子问题,因而也无需对子问题的解进行合并。所以,严格地说,减治法应该是一种退化了的分治法。
而在求两个序列的中位数的算法设计中,其实更严格来说是减治法的思想。
课本详细问题描述此处不予展示,算法基本思想此处也不列举。总之,课本上已知条件是两个等长的升序序列A和B。
而关键一点是当序列A和序列B均只有一个元素时,返回较小者。而博主在网上搜索到一些文章则是返回两个元素的算数平均数,这里有什么不同呢?
我想,这里可就真有点区别了!
首先,按照课本上的意思,一个序列的中位数的值有两种情况:当序列个数的奇数时(如2n+1),那么此时中位数的值就是序号为n+1的那个。若序列个数为偶数也就是2n时,此时序列的中位数是序号为n的那个元素。
按照课本上的意思的话,算法自然是没有问题的。然而,却不知网上的一些最后返回两个元素的算数平均数的文章中关于中位数的定义是什么呢。
单纯从数学的角度上来看的话,似乎确实是当序列为偶数时求取两个数的平均值。
然而在算法设计求解的过程中,只是单纯满足当减治到两个序列都只有一个元素时才应用这样的定义是否可行呢?
拓展来说,就算当序列为偶数时,用求中间两个数的平均值作为中位数作为两个序列的中位数的比较的值就一定可以了吗?
比如两个序列A{1,9}、B{2,4},用以上的算法求得的中位数是多少?
课本上的话,由于定义为较小值的缘故,结果为2是没有问题的,然而若是网上的那些算法那么求取的平均值将为(1+4)/2= 2.5,对没错,是2.5,那么这2.5是否是序列C{1,2,4,9}的中位数呢?很明显不是的。
那么这问题出在了哪里?
问题就出在了如果中位数不一定为两个序列中的已有值而是有可能为两个数的平均值。
分治的算法很简单,依次减半,A和B各取一半,最后的结果也是A和B各出一个(当都为一个元素时),然而此时序列C的中位数可能是由同一个序列的两个相邻值计算得来的,如B中的(2+4)/2 = 3,这才是这个序列的平均值,而很明显,以上的算法并没有考虑到这个。
这就是,既想用分治简单思想,又想求严格中位数的结果,颇有四不像的味道。

此时再去想的话,由于减治法总是能将中位数求解减少到两个序列都只剩一个或两个的情况,所以当A{a,b}、B{c,d}的时候,a,b,c,d之间的大小关系实际上只有两种。a<=b、c<=d的大小关系总是确定的。将值域分为(1)< c,(2)c<=&&<=d,(3)> d三种,那么ab两个可能都在1,都在2,都在3,分别在12,13,23。
当都为1或3或者为12、23的时候,符合之前分治算法的求解条件,当都为2或者分居13的时候,很明显中位数自然是(a+b)/2或者(c+d)/2了,而这其实也只是之前分治算法的拓展而已。
显然,两个个数为2的序列的比较情况可以推广至3及以上的了。
那么问题是:如何进行归并呢?
经简单分析可知,同样的对A和B序列从中间刨开分割为两个部分,对于个数为奇数(2n+1)的序列,两部分各包含序列的中位数【n+1】,对于偶数(2n),两部分各包含【n】及【n+1】。举例而言如A为[1,2,3],则A被分片为[1,2]和[2,3],两个分片的代表分别为2和2,若A为[1,2,3,4],则两个分组为[1,2]和[3,4],分片的代表分别为2和3。
然后A组和B组的两个小端两两猜拳,大的获胜,平的进A和B都行,大端也是如此,只不过较小的获胜。取两个获胜的分片接着递归直到求出结果。(注意:这种情况下,取到的应当是两个分属不同的分组,否则的话,直接就能输出结果了不是吗)这种情况下最好的情况是O(1),如A的分片代表分居B的分片代表两侧。(这里请读者自己体会)
再拓展开来,如果两个数组不等长呢?
博主之前还是这样一种方式,不过显然出问题了,问题在哪呢?
发现有的提前就成了1个元素了,而这时相应的判断标准要做出变化,这时如果序列个数为奇(2n+1),则取n-1,n,n+1三个值,都有用。如果序列为偶(2n),则取n,n+1两个数即可,此时用那一个元素与这几个元素比较确定之间的大小关系,从而求得中位数。
OK!
然后编写了随机生成数组测试程序,发现结果不对!!!
卧槽,什么鬼!
发现总是取值偏了一些,做了调试之后发现了最终的问题所在,同时也是减治法解决中位数求解的核心所在:减治法之所以能够逐渐缩小取值范围不将待求值遗漏出去的关键所在是每次都能左边抛去一定数量的保证比中位数小(反正是不大于)的元素,而右边抛去一定数量的保证比中位数大(反正是不小于)的元素,最关键的在于两边的数量是一致的,而在两序列大小不等的情况下两边去掉的元素个数极有可能不等,这样就会使中位数的取值范围发生偏移,从而取到错误的结果!所以,解决之道就是两个序列一个去大端一个去小端相同的数量就可以,而这数量取决于个数少的那个序列,直到为1或分居两旁为止等。
下面放上三次实验的代码,用aardio写的,博主非常喜欢的一门语言。
当然,没了解过也没关系,很容易看得懂。和JavaScript有点像的。

版本1:课本较小值版

import console;

console.setTitle("算法实验书中版本");

//返回子数组
//para: tab:数组;flag:标志
//flag 为1,返回后半部分数组,否则返回前半部分数组
 var subArray = function(tab,flag){
    var temp = {};
    var length = table.len(tab);
    if(flag == 1){
        for(i=math.floor(length/2)+1;length;1){
            table.push(temp,tab[i]);
        }
    }
    else {
        for(i=1;math.floor((length+1)/2);1){
            table.push(temp,tab[i]);
        }   
    }
    return temp; 
 }

 var findmiddle;//找中位数程序
 findmiddle = function(tab1,tab2){
    var lenOfTab1 = table.len(tab1);
    var lenOfTab2 = table.len(tab2);
    //如果两个数组长度不等,返回null
    if(lenOfTab1!= lenOfTab2){
        return null;
    }
    //如果两个数组为空,返回null
    if(lenOfTab1 == 0){
        return null;
    }
    //如果两个数组的长度为1,则直接返回两个数组的第一个元素的较小值
    if(lenOfTab1 == 1){
        return math.min(tab1[1],tab2[1]); 
    }
    var value1 = tab1[math.floor((lenOfTab1+1)/2)];
    var value2 = tab2[math.floor((lenOfTab2+1)/2)]; 
    //如果两数组的中位数的值相等,则直接返回此值
    if (value1 == value2){
        return value1; 
    }
    //若数组tab1的中位数的值大于tab2的中位数的值,则对tab1前半部分数组和tab2后半部分数组继续此方法
    if(value1 > value2){
        return findmiddle(subArray(tab1,0),subArray(tab2,1));
    }
    //若数组tab1的中位数的值小于tab2的中位数的值,则对tab1后半部分数组和tab2前半部分数组继续此方法
    else {
        return findmiddle(subArray(tab1,1),subArray(tab2,0)); 
    }   
}
//几组测试用例
var taba = {1;3;5;7;9};
var tabb = {2;4;6;8;10};
console.log(findmiddle(taba,tabb));
var tabc = {1;6;8;9};
var tabd = {2;5;7;8};
console.log(findmiddle(tabc,tabd));
var tabe = {3;5;6;7;9};
var tabf = {2;4;6;8;10};
console.log(findmiddle(tabe,tabf));
var tabg = {1;5;9};
var tabh = {2;3;4};
console.log(findmiddle(tabg,tabh));
console.pause();

版本2:严格中位数序列个数相等版

import console;

console.setTitle("算法实验,改进版,求严格中位数");

var subarray = function(tab,index1,index2){
    var temp = {};
    if(index1 > index2){
        index1,index2 = index2,index1;
    }
    for(i=index1;index2;1){
        table.push(temp,tab[i]);
    }
    return temp; 
}
 var isEven = function(number){
    return number%2 == 0;
 }
 var sortedRandomArray = function(n,k){
    var temp = {};
    math.randomize();
    for(i=1;n;1){
        table.push(temp,math.random(1, k));
    }
    table.sort(temp);
    return temp; 
 }
 var printArray = function(tab){
    var str = "";
    for(i=1;table.len(tab);1){
        str = str++tostring(tab[i])++" ";  
    }   
    console.log(str);
 }

 var findmiddle;//找中位数程序
 findmiddle = function(tab1,tab2){
    var length = table.len(tab1);
    //如果两个数组长度不等,返回null
    if(length != table.len(tab2)){
        return null;
    }
    //如果两个数组为空,返回null
    if(length == 0){
        return null;
    }
    //如果两个数组的长度为1,则直接返回两个数组的第一个元素的和的平均值
    if(length == 1){
        return (tab1[1]+tab2[1])/2; 
    }   
    if(isEven(length)){
        var taba;
        var tabb;
        if(tab1[length/2] < tab2[length/2]){
            taba = subarray(tab2,1,length/2);
        }
        else {
            taba = subarray(tab1,1,length/2);
        }
        if(tab1[length/2 + 1] > tab2[length/2 + 1]){
            tabb = subarray(tab2,length/2 + 1,length);
        }
        else {
            tabb = subarray(tab1,length/2 + 1,length);
        }
        return findmiddle(taba,tabb); 
    }
    var value1 = tab1[(length+1)/2];
    var value2 = tab2[(length+1)/2];
    if(value1 == value2){
        return value1; 
    }
    elseif(value1 < value2){
        return findmiddle(subarray(tab1,(length+1)/2,length),subarray(tab2,1,(length+1)/2)); 
    }
    else {
        return findmiddle(subarray(tab2,(length+1)/2,length),subarray(tab1,1,(length+1)/2)); 
    }
}
var test = function(n,k){
    var taba = sortedRandomArray(n,k);
    var tabb = sortedRandomArray(n,k*2);
    console.log("taba:");
    printArray(tabb);
    console.log("tabb:");
    printArray(taba);
    var tabc= table.concat(taba,tabb);
    table.sort(tabc);
    console.log("tabc");
    //console.dump(tabc);
    printArray(tabc);
    console.log(findmiddle(taba,tabb));
}

//几组测试用例
var taba = {1;5;9};
var tabb = {2;3;4};
//console.log(findmiddle(taba,tabb));
//console.log(time());
test(10,30);
console.pause();

版本3:严格中位数+序列长度不等版

import console;

console.setTitle("算法实验,改进版,求严格中位数+两个不等长数组");
//获取子数组
 var subarray = function(tab,index1,index2){
    var temp = {};
    if(index1 > index2){
        index1,index2 = index2,index1;
    }
    for(i=index1;index2;1){
        table.push(temp,tab[i]);
    }
    return temp; 
}
//判断是否偶数
 var isEven = function(number){
    return number%2 == 0;
 }
 //返回已按从大到小顺序排序的个数为n,取值范围从1到k的数组
 var sortedRandomArray = function(n,k){
    var temp = {};
    math.randomize();
    for(i=1;n;1){
        table.push(temp,math.random(1, k));
    }
    table.sort(temp);
    return temp; 
 }
 //打印数组
 var printArray = function(tab){
    var str = "";
    for(i=1;table.len(tab);1){
        str = str++tostring(tab[i])++" ";  
    }   
    console.log(str);
 }
//取得数组的中位数
 var getmiddle = function(tab){
    var length = table.len(tab);
    //若数组2个数为偶,则返回中间两个数的平均值
    if(isEven(length)){
        return (tab[length/2] + tab[length/2+1])/2; 
    }
    //否则返回中位数
    else {
        return tab[(length+1)/2]; 
    }
}

 var findmiddle;//找中位数程序
 findmiddle = function(tab1,tab2){
    var length1 = table.len(tab1);
    var length2 = table.len(tab2);
    //如果两个数组同时为空,则返回null
    if((length1 == 0) &&(length2 == 0)){
        return null;
    }
    //如果两个数组的长度为1,则直接返回两个数组的第一个元素的和的平均值
    if((length1 == 1)&&(length2 == 1)){
        return (tab1[1]+tab2[1])/2; 
    }
    if(length2 < length1){//通过交换可以省略几部分代码
        tab1,tab2 = tab2,tab1;
        length1,length2 = length2,length1;
    }
    //如果数组1为空,则返回数组2的中位数
    if(length1 == 0){
        return getmiddle(tab2); 
    }
    if(length1 == 1){
        if(isEven(length2)){
            if(tab1[1] < tab2[length2/2]){
                return tab2[length2/2];
            }
            if(tab1[1] > tab2[length2/2+1]){
                return tab2[length2/2+1]; 
            }
            return tab1[1]; 
        }
    }
    var temp1;
    var temp2;
    var sublength = math.floor(length1/2);
    if(tab1[math.floor((length1+1)/2)] > tab2[math.floor((length2+1)/2)]){//取tab1前边部分
        if(tab1[math.ceil((length1+1)/2)] <= tab2[math.ceil((length2+1)/2)]){//取tab1后边部分,此时中位数即tab1的中位数
            return getmiddle(tab1); 
        }
        //取tab1前,取tab2后
        temp1 = subarray(tab1,1,length1-sublength);
        temp2 = subarray(tab2,sublength+1,length2);
    }
    else {//取tab2前边部分
        if(tab1[math.ceil((length1+1)/2)] >= tab2[math.ceil((length2+1)/2)]){//取tab2后边部分
            return getmiddle(tab2); 
        }
        //取tab2前,取tab1后
        temp1 = subarray(tab2,1,length2-sublength);
        temp2 = subarray(tab1,sublength+1,length1);
    }
    console.log("temp1:");
    printArray(temp1);
    console.log("temp2:");
    printArray(temp2);
    return findmiddle(temp1,temp2); 
}
//测试驱动程序
 var test = function(n,k){
    var taba = sortedRandomArray(n,k);
    var tabb = sortedRandomArray(math.floor(n/2),k*2);
    console.log("taba:");
    printArray(tabb);
    console.log("tabb:");
    printArray(taba);
    var tabc= table.concat(taba,tabb);
    table.sort(tabc);
    console.log("tabc");
    //console.dump(tabc);
    printArray(tabc);
    console.log(findmiddle(taba,tabb));
}

test(10,30);
console.pause();

终于大功告成啦!

点赞