2幂拆分问题

最近没有合适的床头书可以看,于是索性把CS:APP(深入理解计算机系统)取下来放在床边,睡不着觉时随意翻一翻,以期稳故知新。在CS:APP第2.3.6小节中提到,由于整数乘法指令通常会比加减法和位运算指令会慢上许多,因此编译器有时会做一个优化:用移位和加减法的组合来代替乘以常数因子的乘法,比如x * 18可以写成(x<<4) + (x<< 1), 而x * 7可以写成(x<<3) - x。也就是先把常数因子拆成2幂的组合,然后再进行运算。显然,拆出来的项要越少越好,比如7 = 4 + 2 + 1就不如7 = 8 – 1. 那么任意给定一个整数,怎么将它拆成项数最少的2幂组合呢?抛开编译器优化啥的不谈,这本身也是一个非常有趣的问题,所以我决定来好好分析一下。当然从最后的结果来看,花在这个问题上的时间还是非常值得的,因为它虽然没有预想中的简单,但是绝对更有趣。

再陈述下这个问题,为了显得正式一点,我随便给它取了个名字.

2幂拆分问题:  任意一个整数x都可以写成一个系列2的幂的组合,这里的组合是指用加减法把它们接连起来。令f(x)为表示x需要的最少的2的幂的项数,求f(x)。

显然有f(x) = f(-x), 为了讨论的方便,后文一致假设x非负。另外,上面的问题陈述对于x=0的情况显得略微有点不太自然,这里再特别地定义一下f(0) = 0,如果你比较细节控的话。


大概很多同学瞬间就能想到一个解法。令floor2(x)表示不大于x的最大2幂, ceil2(x)表示不小于x的最小2幂,由上面x = 7的例子可以得到启发,最优的组合要么是floor2(x)加上其它一些项,或者是ceil2(x)减去其它一些项,于是可以得到求f(x)的一个递归过程如下,

# 算法1
def f(x):
    if x == 0: return 0
    l = floor2(x)
    u = ceil2(x)
    if l == u: return 1
    return 1 + min(f(x - l), f(u - x))

虽然上面的解法只是一个yy, 但认真考虑一下便可以知道这的确是正确的。证明的过程都是一些很显然的推导,这里略过。我更感兴趣的是,算法1在最坏情况下的复杂度是多少? (不感兴趣的可以直接跳到后面看算法2。)

但在求最坏情况复杂度之前,我们首先需要知道,该算法的最坏情况是什么,即当输入的规模相当时,x取哪些值时会使得该算法的递归次数达到最大。从上面的递归过程你大概可以感觉到算法1的复杂度与x的2进制位数相关,所以这里的“输入规模相当”的意思可以说得更明确一点,就是x的2进制位数相同。

为了找到更多一些感觉,再来仔细看一下算法1的递归过程,当算法1运行到最后一项时,显然x – l和u - x都至少比x少一位,而且x – l和u - x不可能同时只比x少一位。也就是说一个输入为b位的递归过程会分裂为输入分别不超过b - 1位和b - 2位的两个子过程。如果我们能构造出一个输入使得在所有的递归过程中都分裂为恰好b - 1位和b - 2位的子过程,那么这个输入肯定是一种最坏情况。

构造出这样一种输入也并不难,比如当位数为奇数时可以令x = 101010…1,(二进制表示,后同),当位数是偶数时可以令x = 101010…11。用S(n)表示(10){n}1,即n个10后面再跟一个1,用T(n)表示(10){n}11 = Sn1,即n个10后面再跟两个1.  那么当输入为S(n)或者T(n)时算法1的递归过程如下,

S(n) –> S(n-1), T(n-1)

T(n) –> T(n-1), S(n)

上面两式在所有n>0的情况下都满足。于是我们确定了S(n)和T(n)即是算法1的最坏情况。如果你觉得这里的推导不够严谨的话还可以自己用数学归纳法来证明。另,S(n)和T(n)并不是唯一的最坏情况输入,有兴趣的话可以找找另一种。

最坏情况输入有了,现在我们来复杂度,令s(n)表示输入为S(n)时算法1的递归次数, t(n)表示输入为T(n)时算法1的递归次数,则有,

s(n) = s(n-1) + t(n-1) + 1

t(n) = t(n-1) + s(n) + 1

将上面第2式代入第1式进行展开,可以得到一个关于s(n)的递归式,

    

另有s(0) = 1. 这个递归式看起来有些麻烦, 因为右边的项数是随着n变化的。所幸,我们还有万能的生成函数!令G(z)为s(n)的生成函数,

    

利用生成函数的一些基本技巧(移位,卷积,求导)可以得到以下几个式子,

   

   

   

   

注意到上面四个式子分别对应于s(n)的递归式的右边四项,于是有,

   

上面最后减了一个2是为了满足结束条件s(0) = G(0) = 1。由上式可以解出,

   

对于这种形式的生成函数,我们有个通用的手段将其展开,通过Rational Expansion Theorem(pdf, page10)可以得到,

   

于是有,

   

其中φ = 1.618…, 为黄金分割率。s(n)知道了,t(n)也可以很容易求出来。同时也可以推出算法1的复杂度F(x)为,

   

上面费了老大劲来写算法1的分析过程,倒不是说算法1有多么的好(实际上是稀烂无比),而是分析的过程本身很有趣,我觉得值得和大家分享。解递归式神马的最有意思了。


现在来看看怎么改进算法1,容易观察到,算法1的递归过程中包含了大量的重复运算,那么一个很自然的想法就是把它改成记忆化的动态归划,大概像这样,

# 算法2
def f(x):
    if x == 0: return 0
    if x in table: return table[x]
    l = floor2(x)
    u = ceil2(x)
    if l == u: return 1
    r = 1 + min(f(x - l), f(u - x))
    table[x] = r
    return r

那么算法2的复杂度又如何呢。我们需要分析在算法2的递归过程中会产生哪些不重复的串。这个比较简单,因为对于任意的输入x, 在递归过程中新产生的输入都一定都是x的后缀或者是~x+1(即x取反再加1)的后缀. 比如若x = 1010110, ~x+1 = 0101010,那么f(x)在递归过程会产生以下一些串:

1010110 –> 10110 –>  110 -> 10

0101010 –> 1010

你可能已经观察,所产生的所有串的数目刚好是x的位数减去末尾的0的数目。比如上面的例子中x有7位,末尾有1个0,因此产生的串的数目刚好是7 – 1 = 6。这也可以通过归纳法来证明。于是我们知道,算法2的时间复杂度为O(lgx), 同时还需要一张大小为O(lgx)的hash表。


经过上面对算法2的分析,我们对整个递归过程所有可能产生的串都已经非常了解了,于是我们可以实现一个自底而上,递推的动态归划,从而去掉算法2中那个让人很不舒服的hash表。

# 算法3
def f(x):
    if x == 0: return 0
    while x & 1 == 0: x = x >> 1
    u = 1
    d = 1
    x = x >> 1
    while x > 0:     
        if x & 1 == 1:
            u = min(u, d) + 1
        else:
            d = min(u, d) + 1
        x = x >> 1
    return u

显然,算法3的时间复杂度为O(lgx),空间复杂度为O(1)。Done!

另外,本文虽然只讨论了怎么计算f(x),但是上面的算法通过简单的改动就能在计算f(x)的同时返回这f(x)项的2幂具体应该怎样组合。

posted @ 2012-08-04 16:23  atyuwen  阅读(4153)  评论(5编辑  收藏  举报