[Elementary-Algorithms/1]最小可用ID:改进二的学习理解

改进二涉及到分治思想和递归,不仅省去了那个占空间的标记数组,且同样高效。下面是原文中的改进描述:

我们可以将所有满足xi ≤ ⌊n/2⌋的整数放入一个子序列A';将剩余的其他整
数放入另外一个序列A''。根据公式1,如果序列A'的长度正好是⌊n/2⌋,这说明
前一半的整数已经“满了”,最小的可用整数一定可以在A''中递归地找到。否
则,最小的可用整数可以在A'中找到。总之,通过这一划分,问题的规模减小
了。

这里要清楚一点:描述说把满足xi <= ⌊n/2⌋的整数放入子序列A',其余放入A'',这里说的放入,并不是要创建两个新列表,然后将元素存到里边(如果是这样的话,那肯定得消耗额外空间的),而是把整个列表A中的元素划分成两部分(这两部分并不一定均等),左边部分的元素<= ⌊n/2⌋,右边部分的元素> ⌊n/2⌋,n是列表A的长度,元素还是在列表A中,只不过将其左边部分称作子序列A'右边部分称作子序列A''。至于如何将元素划分为左右两部分,这个到代码部分再讲(这里的划分,也并非将元素严格地递增排序,严格的递增排序,除两个边界元素,其余每个元素肯定比其前一个元素大,比接下来的一个小,而是以⌊n/2⌋为临界点,只需和该点比较就行了,左边的元素肯定小于右边的)。
原文中似乎并没有公式1,这个不管它,理解它后面那句话就可以了。什么情况下序列A'的长度正好是⌊n/2⌋呢?那就是序列A'中的元素必定是[0, n/2]的某个排列(还不清楚的话,举个实栗就知道了:有3个元素,且这3个元素不大于3,那么这3个元素只能是{0, 1, 2, 3}。好吧,举完栗子我才发现原文这句长度正好是⌊n/2⌋是错的,不应该是⌊n/2⌋,不过代码中的描述是正确的,到代码部分再讲下这个问题0),这时最小可用ID只能在序列A''中找了。若序列A''的长度也是⌊n/2⌋,且元素是[(n/2) + 1, n)的某个排列,那么最小可用ID必定是n,反之最小可用ID必定能在A''中找到。以这种思想递归的在列表A中找下去,最终可以得到结果。


再看下下面这段话(来自原文),有助于代码的理解。没什么好解释的,看完就懂了:

需要注意的是,当我们在子序列A''中递归查找时,边界情况发生了一些变
化,我们不再是从0开始寻找最小可用整数,查找的下界变成了⌊n/2⌋ + 1。因
此我们的算法应定义为minf_ree(A, l, u),其中l和u分别是上下界。
递归结束的边界条件是当待查找的序列变为空的时候,此时我们只需要返回
下界作为结果即可。
根据上述思路,分而治之的解法可以形式化地定义为一个函数:

截图来自原文
截图也来自原文。这图并不涉及太过数学的东西,上面两段描述文字理解的话,这个也可以看懂,用等式描述就是简单形象,不像文字那样累赘。说下其中几个的意思:|A|表示列表A的长度;φ表示空集;这句∀x ∈ A ∧ x ≤ m读做任意x属于A,存在x小于等于m,意思就是序列A'中的元素是列表A中不大于m的元素。


看懂了文字,必须得看代码,文字和代码并不是一回事。原作者用了递归和迭代两个版本(其实还有函数式编程的实现,这个不看了,语言不同),先看C语言递归版:

// binary search
//  xs: array, n: length of xs
//  l: lower bound, u: upper bound
// 定义了一个函数,从含有n个元素的数组xs中查找最小可用ID
// l是下边界,u是上边界,其实就是对应数组元素的index
// 如果是第一层递归的话,l就是数组xs的第一个元素的index,u是第n个元素的index
int binary_search(int* xs, int n, int l, int u){
    // 递归结束条件就是列表的长度为0了,这时左边界l就是最小可用ID
    // 为什么u不行?当n=0时,表明所有不可用ID都排除了,
    // 此时l正好为前一个状态的(l + u)的值的一半再加一,就是第一个最小可用ID,而u会比l小一
    // 这样说也不是很清楚,拿这几个列表:xs:{φ}, xs:{0},xs:{1},xs:{1, 2, 3}试试就知道了
    if(n==0) return l;
    // 按描述里说的应该 m=n/2,实际上应该是下面这种情况,因为m为数组xs中每次折半后的
    // 元素的index,是相对于xs中的绝对位置,而不是子序列中的位置。(l + u) / 2 <= n / 2
    // 只根据n是无法确定每次折半后的index的哦。
    int m = (l + u) / 2;
    // 这两个变量是用来将列表中的元素排序成左右两个子序列的
    int right, left = 0;
    //0 ... <=m ... left ... >m ... right ...? ...
    // 遍历从0到n-1的元素
    for(right = 0; right < n; ++ right)
        // 如果当前以right为index的元素<=m
        if(xs[right] <= m){
            // 则和以left为index的元素交换,这时xs[left]是<=m的。拿个具体的数组按此过程执行一遍就清楚了
            swap(xs[left], xs[right]);
            // 同时将left这个index指向下一位
            ++left;
        } // 执行完这个for循环后,当前列表的元素就分成左右两个序列了
    // 将列表的元素分为左右两边后,此时left为最后一个<=m的元素的下一个元素的index,
    // 也就是第一个>m的元素的index。其实也代表<=m的元素个数
    // 然后判断<=m的元素个数是否等于整个数组折半后的长度,m-l+1是计算整个数组折半后左半边的长度
    // 相等就表示左边的子序列元素‘满了’,不理解的可以看前面的解释序列 A’ 的长度如何=n/2
    if(left == m - l + 1) // 上边提到的 问题0 ,正确的是m-l+1,而不是n/2,当n为奇/偶数时,这两个值可能相等或不等
        // 这时,最小可用ID肯定不在左边这个序列了,因为该序列的元素为[0, m]的某个排列
        // 然后递归地在右边的序列查找,而右边序列的元素的起始地址是从xs+left开始的,
        // 长度为总长n减去左边序列的长度left,边界l为m+1,边界u不变,拿个具体数组看下就知道这是怎么回事了
        return binary_search(xs+left, n-left, m+1, u);
    else
        // 如果左边序列‘没满’,则最小ID肯定可以在左边这个序列递归地找到,
        // 因为至少有一个元素是不在[0, m]中的
        // 左边序列的元素的地址从xs开始,长度为left,边界l不变,边界u则为m
        return binary_search(xs, left, l, m);
}

// min-free, divide and conquer, with recursion.
// 这个函数只是对上面那个函数的封装
int dc_min_free(int* xs, int n){
    return binary_search(xs, n, 0, n-1);
}

要理解上面的递归函数,当然要理解递归,这里不是讲递归,就不细讲了。很粗略地讲下吧:递归必须有个终止条件,当达到这个条件时,递归才可以退出,问题也可以得到解决,没有终止条件的话,递归是解决不了问题的;然后,还有一系列解决问题的步骤,这个步骤可以解决相同的问题,比如一个大问题和由这个大问题分解后的一系列小问题;如果执行完上面的步骤,问题还没得到解决,就递归地执行上面的步骤,直到问题解决。这是我对递归解决问题的一点理解吧。


看完递归版,再看下迭代版(代码来自原文):

// min-free, divide and conquer, eliminate recursion.
int dc_min_free_iter(int* xs, int n){
    int l=0;
    int u=n-1;
    // 迭代的结束条件就是n=0时,n=0就表明我们将所有不可用的ID都排除掉了
    while(n){
        // 下面这些后和递归中的一样,就不多说了
        int m = (l + u) / 2;
        int right, left = 0;
        //0 ... <=m ... left ... >m ... right ...? ...
        for(right = 0; right < n; ++ right)
            if(xs[right] <= m){
                swap(xs[left], xs[right]);
                ++left;
            }
        // 同样的,当左边的序列‘满了’
        if(left == m - l + 1){
            // 我们就循环地在右边查找。
            xs = xs + left; // 右边元素地址从xs+left开始
            n  = n - left;  // 元素个数当然就是减去左边元素个数啦
            l  = m+1;       // 下边界变为m+1,因为左边的序列包括m这个元素
        }
        // 反之,左边‘没满’
        else{
            n = left; // 左边的元素地址还是从xs开始,只不过元素个数变为了left
            u = m;    // 上边界发生了改变,为m,左边序列包括m
        }
    }
    // 同理,当n为0时,下边界就是最小可用ID了
    return l;
}

整个改进二的理解就是这样了:)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值