代码改变世界

拆分自然数:纯while实现 (Part 2 - 实现)

2009-06-28 15:35  Cat Chen  阅读(3686)  评论(4编辑  收藏

在《拆分自然数:纯while实现 (Part 1 - 思路)》这篇文章里面,我提供了解答Jeff《编程小练习:拆分自然数》问题的一种解答思路,并且使用了两个例子来解释这种思路,不知道你是否已经成功利用这种思路解题了呢?

首先,这道题的搜索域是什么?那就是[min, min, min, ..., min]到[max, max, max, ..., max],或者是它的子集。就算你完全不懂算法,我相信你凭直觉也知道要在[min, min, min, ..., min]到[max, max, max, ..., max]之间搜索。因此,一个算法好不好,就看你能否有效缩小搜索域了。

在不缩小搜索域的情况下,你可以排列组合出搜索域内的所有可能性,逐一验证是否为所求解,这也就是Jeff的DoSimple示例所做的。接着,为了保持解的不重复性,你可能想到了有效解一定是一个不下降序列,因此对所有非不下降序列进行剪枝,这也就是Jeff的DoBetter示例所做的事情。然后,你需要把不下降序列中总和不等于 sum的情况给砍掉,这个剪枝就是最难的一个剪枝了,也就是DoBest所做的事情。

Jeff 在DoBest中的做法是,对于当前操作的第i位,求得itemMinInclusive与itemMaxInclusive,且 minInclusive <= itemMinInclusive <= itemMaxInclusive <= maxInclusive,其中[minInclusive, itemMinInclusive)和(itemMaxInclusive, maxInclusive]这两个区间的取值是无效的,只有[itemMinInclusive, itemMaxInclusive]区间的取值才是有效的。

dragonpig的展开采用了try方式的剪枝,虽然也把无效枝剪掉了,但在循环上还是要遍历到无效枝的首个无效节点,必须在上面try一下看返回false才剪掉,因此性能介于DoBetter与DoBest之间。徐少侠的展开进行了有效的剪枝,在时间复杂度上是与DoBest一致的,不过缓存数据占用空间为实际状态所需空间的3倍,这些空间其实是浪费掉的。我认为比较理想的做法是仅仅缓存sum值,而不缓存下界(即itemMinInclusive)与上界(即itemMaxInclusive),这样是保证性能的前提下最省空间的。至于为什么我说混存下界与上界没用,看一下我的写法就知道了:

function main(m, n, min, max) {
    
var array = new Array(n);
    
var i = 0;
    
    
var write = function() {
        console.log(m 
+ ' = ' + array.join(' + '));
    };
    
    
var scan = function() {
        
if (scan.start) {
            scan.start 
= false;
            scan.sum 
= 0;
        }
        scan.sum 
+= array[i];
        
return (array[i] > array[n - 1- 2);
    };
    
    
var step = function() {
        array[i]
++;
        fill.sum 
= scan.sum - array[i];
        i
++;
    };
    
    
var fill = function() {
        
while (i < n) {
            array[i] 
= Math.max((fill.sum - (n - i - 1* max), ((i == 0? min : array[i - 1]));
            fill.sum 
-= array[i];
            i
++;
        }
        i
--;
    };
    
    fill.sum 
= m;
    fill();
    write();
    
while (true) {
        scan.start 
= true;
        
while (i >=0 && scan()) {
            i
--;
        }
        
if (i < 0) {
            
break;
        }
        step();
        fill();
        write();
    }
}

可能有些人还没有理解scan、step、fill这种结构,所以我先解释一下它们分别负责什么:

  • scan - 剪枝。scan所需要做的工作,就是从最深的节点开始向根部遍历,找到第一个可以进行合法迭代的节点。scan是整个算法实现的核心,它必须有一个有效的判断准则,使得它跳过所有无法再做合法迭代的子节点,直到它找到可以合法迭代的节点为止。
  • step - 迭代。在当前节点迭代到下一个合法取值。由于scan保证了当前位必然可以迭代出下一个合法取值,所以step的工作就超级轻松了,只需轻轻向前跳一步。
  • fill - 填充。在迭代结束后,需要把低位的值全部填充为字典顺序中排在最前面的第一个合法取值组合。

在这几个函数里面,最容易写的就是step了,基本上任何人闭上眼睛都能写,它就是把当前位的取值进行加一操作。其次就是fill了,无论是我的 fill,还是dragonpig的Reset,抑或是徐少侠那段没有独立成函数的变量j迭代,本质上都是一样的。只不过,dragonpig的 Reset带有了try,因为调用Reset的代码并不知道调用时是否可能生成合法解;而徐少侠的变量j迭代生成了大量中间变量,并且都缓存起来,但我认为其中的上界和下界是没必要缓存的,甚至是没必要生成的。

最难写的代码,其实在于scan,也就是scan中return的那一个条件语句。这个条件语句用于判定当前节点是否还能迭代出下一个合法解,如果这个判断你写对了,那么其它都不会是问题;如果这个判断你写错了,或者写不出来,那么整套思路都没用了。在说这道题目的scan条件之前,我们先回想一下上一篇文章两道题目的scan是怎么写的。

在第一道题目里面,我们只要简单的迭代指定长度内所有的二进制数,因此scan的条件很简单。如果当前为的值为1,也就是说当前位已经达到上界了,我们就返回true,让scan继续执行下去,直到遇到值为0的位。因为值为0的位还没有达到上界,所以它可以迭代下去,于是返回false,结束scan。在第二道题目里面,我们看0110这个具体例子,末端的0不能迭代,否则成为0111,值为1的位数改变了;值为1的位也不能迭代,因为这是上界。因此,我们设计scan条件,在至少经历过一个1后找首个0,因此迭代为1110,随后再通过fill修正低位1的位数,获得 1001

在Jeff的题目里面,我们可以通过一些例子来观察scan应有的条件。对于(5, 3, 1, 3)的输入,[1, 1, 3]和[1, 2, 2]是两个有效解,也就是说对[1, 1, 3]进行scan时,应该跳过末尾的3,而定位到中间的1上面去。那么为什么[1, 1, 4]不是合法的迭代结果呢?一方面4越了上界,另一方面总和超出sum了,而最低位的迭代是无法通过后面的fill来重新调整总和的。假如我们再考虑一下(5, 3, 1, 4)的输入,那么我们可以知道4越了上界这个条件其实是可有可无的,总和超出sum这个条件才是关键的。

那为什么[1, 1, 3]迭代为[1, 2, *]是合法的,但是[1, 2, 2]迭代为[1, 3, *]就不是合法的呢?通过观察我们可以得知,因为[1, 1, 3]中末两位相差2,所以我们可以确信进行[1, 1 + 1, 3 - 1]调整后,这仍然是一个不下降序列。但是[1, 2, 2]就不符合这个条件了,因为[1, 2 + 1, 2 - 1]不是一个不下降序列。为此,我们可以认为寻找相邻两个相差大于或等于2的位,就是scan的目标。

接着我们再来观察一个例子,输入为(15, 3, 4, 6) ,初始解为[4, 5, 6],还能迭代下去吗?用上面的scan判定是无法迭代下去了,因为任何两位之间都仅仅相差1。但实际上,还有一个解是[5, 5, 5]。问题出在哪呢?我们之前的观察忽略了非相邻位的调整。没错,[4 + 1, 5 - 1, 6]和[4, 5 + 1, 6 - 1]都不是合法解,但[4 + 1, 5, 6 - 1]是合法解,这就是我们所忽略了的。因此,scan需要寻找的不是相差大于或等于2的相邻两位,而是相差大于或等于2的任意两位,并且要求这两位尽量低位(因为迭代优先从较深节点展开)。这看起来使得scan的逻辑复杂无比,首先要把任何相差大于或等于2的两位配对找出来,再在里面找最低位的一对。

实际上,不下降序列的特性为我们很好地解决了这个问题,我们只需要利用scan找到首个跟末位相差大于或等于2的位就可以了。例如[1, 2, 3, 4, 5, 6],我们知道[1, 6], [2, 6], ..., [4, 6], [1, 5], [2, 5], ..., [1, 3]都是符合条件的配对,但最靠近低位的一定是[*, 6]。我们假设[x, 5]符合条件,对于同一个x,[x, 6]一定是符合条件的,同理[x, 4], [x, 3], ...也不用看了,只要针对[x, 6]找到x最大的合法取值就可以了。为此,我们可以写下简单的scan继续执行条件,那就是array[i] > array[n - 1] - 2。

看到这里,相信大家都明白了为什么我说scan的一个判定条件如此重要,因为它肩负着不依赖于上下文信息进行剪枝的任务——不知道下界与上界为何物,也不知道整个数组的sum是多少,所有这些状态信息都暗含在上一次fill好的数组中,无需为scan提供额外的信息(包括缓存信息)它就能工作。scan结束后,它只需要为fill提供两个正确的信息——i和sum,随后fill必然能够给出下一个迭代解的低位。

最后,我来解释一下为什么不应该缓存下界和上界,只要缓存sum。首先,我们要为缓存这件事达成一个共识,那就是只有当一个东西会一次写入多次读取时,我们才需要进行缓存。下界信息是一定不需要缓存的,在首次fill后每一位的下界就确定下来了,随后迭代总是在某一位加一,下界这个值永远无需被重新访问,也就没必要缓存下来。同理,上界信息其实也在首次fill后确定下来了,并且随着每一次fill动态调整,因为array[n - 1]的上界就是它自身,而其它array[i]的上界必然小于或等于array[n - 1] - 2,这个逻辑已经暗含在scan里面了,自然就没必要缓存上界了。最后,sum的缓存是我在scan里面没做的,所以每次scan都要重新计算sum值,缓存sum值有助于性能的提升。

P.S.临摹其实是会限制你的思维的。由于Jeff的DoBest使用了itemMinInclusive和itemMaxInclusive进行剪枝,这影响到其它人在做递归展开时也优先考虑用同样的方法进行剪枝,然而在递归中最好的剪枝方法并不一定在展开后仍然是最好的,因为递归时桟信息是暗含的,你无法直接访问,在展开后你就能直接访问了。