【问题标题】:An elegant way to find a power of 2一种优雅的方法来找到 2 的幂
【发布时间】:2014-03-06 11:08:48
【问题描述】:

我的问题和Fastest way of computing the power that a "power of 2" number used?很相似:

x=2^y 作为输入,我想输出y。 不同之处在于我使用 C 而不是 C++ 进行编码,而且我确信我的输入中只有一个位设置,所以我想知道是否有更有效的方法来解决这个问题。

这是我的尝试:

unsigned int get_power_of_two(unsigned int x)
{
    unsigned int y=0;
    for(unsigned int input=x; input>0; input=input>>1)
    {
        if(input & 1U)
        {
            break;
        }
        y++;
    }
    return y;
}

与@Dave 的答案中建议的查找表相比,这种效率是多少? (同样,我用 C 编码,所以我没有像 lower_bound 这样的迭代器函数)

【问题讨论】:

标签: c math bit-manipulation


【解决方案1】:

您的算法的效率是O(log x),而Dave(执行二的幂次方搜索)的效率是O(log log x)。所以他的速度越来越快。

当然,最快的方法是使用BSF 指令。

【讨论】:

  • 我不知道BSF / BSR 但我猜它不是在所有硬件上都可用的吧?
  • 这是一条 i386 指令。其他架构上也有类似的说明。
  • TL;DR:所有架构都有一个领先的零计数指令,但 C 和 C++ 从未对其进行标准化。它是最近针对 C++ 提出的,最早可能在 2017 年采用。
  • 虽然可能比线性搜索更好,但在这里可能值得注意的是,当 n 固定为 int 的大小时,谈论渐近复杂度几乎没有什么意义。任何解决方案都应该进行速度测试,分支预测和原始指令计数等将在这里发挥作用。在戴夫的回答中,二进制搜索可能(但不是肯定)通过使用掩码的二进制搜索更快地完成,所有位都设置在掩码的一半以上,然后来回移动。当然还有其他不使用二分搜索的有效方法。
【解决方案2】:

除了前面所说的其他方式,例如严格依赖底层ISA的BSF或CLZ指令,还有一些其他方式,例如:

http://graphics.stanford.edu/~seander/bithacks.html#IntegerLogObvious

事实上,你可以在这里找到很多“小技巧”。

【讨论】:

    【解决方案3】:

    作为旁注,您应该考虑将函数 get_power_of_two 重命名为 get_log_two

    如果你经常调用这个函数,那么你可以初始化一个相对较小的查找表。

    使用此表,您可以逐字节检查每个输入的数字,如下所示:

    #include <limits.h>
    
    static unsigned int table[1<<CHAR_BIT];
    
    void init_table() // should be called once
    {
        for (unsigned int n=0; n<CHAR_BIT; n++)
            table[1<<n] = n;
    }
    
    unsigned int get_log_two(unsigned int x)
    {
        for (unsigned int n=0; x>0; n+=CHAR_BIT, x>>=CHAR_BIT)
        {
            unsigned int y = x & ((1<<CHAR_BIT)-1);
            if (y > 0)
                return n+table[y];
        }
        return ~0; // will never be reached during runtime
    }
    

    就“纯粹的学术复杂性”而言,这不一定是最有效的方法,因为函数 get_log_two 不执行二进制搜索。

    尽管如此,考虑到sizeof(unsigned int)任何平台上的相对较小的值(通常为 4),它在平均情况和最坏情况下的性能几乎相同。

    【讨论】:

      【解决方案4】:

      在您的情况下,因为您知道只设置了一位,因此计算尾随零就足够了。这可以在没有硬件指令的情况下非常快速地完成。看看这个answer,这就是下面代码的来源(我不是一个篡改完美的人......有时)。

      unsigned v;  // this is the number with one bit set
      unsigned r;  // this becomes the exponent in v == pow(2, r)
      static const unsigned MultiplyDeBruijnBitPosition[32] = 
      {
        0, 1, 28, 2, 29, 14, 24, 3, 30, 22, 20, 15, 25, 17, 4, 8, 
        31, 27, 13, 23, 21, 19, 16, 7, 26, 12, 18, 6, 11, 5, 10, 9
      };
      r = MultiplyDeBruijnBitPosition[((unsigned)((v & -v) * 0x077CB531U)) >> 27];
      

      在您的情况下,因为v 只有一个位集,所以您不需要找到最低位集;因此你可以跳过v &amp; -v。你的代码版本变成了这样:

      unsigned v;  // this is the number with one bit set
      unsigned r;  // this becomes the exponent in v == pow(2, r)
      static const unsigned MultiplyDeBruijnBitPosition[32] = 
      {
        0, 1, 28, 2, 29, 14, 24, 3, 30, 22, 20, 15, 25, 17, 4, 8, 
        31, 27, 13, 23, 21, 19, 16, 7, 26, 12, 18, 6, 11, 5, 10, 9
      };
      r = MultiplyDeBruijnBitPosition[(v * 0x077CB531U) >> 27];
      

      查看链接了解更多信息,它又链接到它的源信息。

      【讨论】:

      • 谢谢,我已经阅读了这个 bithack,因为上面给出了链接,但我很难理解并适应我的具体情况:这似乎是高级别的优化!
      • @Cocoop,乘法位黑客是一种黑魔法。 IMO 最简单的理解方法是:乘法为最高有效 5 位中的每个一位设置值产生一个唯一值,当转移到最低有效位时,该值在 [0, 31] 范围内。已经进行了研究以找到这个神奇的因素,为什么这个数字恰好具有这个属性并不那么重要,只是它确实如此,而且为什么存在这样的数字是有道理的。然后只需制作一个查找表来匹配映射到的任何两个的幂。
      • @Cocoop, 被淘汰的 (v & -v) 部分是将具有任何值的数字转换为仅在原始数字的最低有效位集的位置设置一位的数字.通过二进制补码否定,技巧相当于 (v & (~v + 1))。如果你考虑一下对二进制数的这些操作,你就会明白为什么它会这样工作。位集上方的部分是按位非,因此“与”将其“取消”为零,而下面的部分全是 1,直到将 1 加到它上面翻转为 1 来代替位集,而 0下面。
      • @Cocoop,这应该很快。这是三个指令(两个操作和一个内存读取)。那么您现在了解代码以及它如何适应您的特殊情况了吗?
      【解决方案5】:

      这是一个有趣的想法...

      如果你知道只有 1 位设置,那为什么不使用开关呢?

      unsigned int get_log_two(unsigned int x)
      {
              switch(x)
              {
                  case 1<<0: return 0;
                  case 1<<1: return 1;
                  case 1<<2: return 2;
                  case 1<<3: return 3;
                  case 1<<4: return 4;
                  case 1<<5: return 5;
                  case 1<<6: return 6;
                  case 1<<7: return 7;
                  case 1<<8: return 8;
                  case 1<<9: return 9;
                  case 1<<10: return 10;
                  case 1<<11: return 11;
                  case 1<<12: return 12;
                  case 1<<13: return 13;
                  case 1<<14: return 14;
                  case 1<<15: return 15;
              }
              return 0;
      }
      

      将此扩展到31,您将拥有一个很好的功能。 这应该很快;但只有设置了一个位时才会起作用。

      【讨论】:

      • 与循环相比,它确实应该更快,因为在您的情况下,您不必增加迭代器。但我想这仍然是一个 O(log X) 操作。
      • 这是一个常数时间的方法,结果是“order 1”:O(1)
      • 我认为这不是 O(1)。在得到结果之前,您必须进行一些比较(取决于输入)。
      • 当然这是 O(1),因为有恒定数量的案例;这将进行不超过 16 次比较,并且 16 是一个常数,所以这是 O(1)。但总的来说,没有理由相信随着案例数量的增加,运行代码所花费的时间保持不变!开关并不神奇。这样想:假设您正在用一种没有开关的语言编写这段代码——因为那是编译器必须做的。随着案例数量的增加,您如何将其写为 O(1)?
      • 感谢@EricLippert 我从来没有真正想过开关在内部做什么。所以我看了一下反汇编,并做了一些基准测试。 1)我看到开关使用所有标签的重复存储命令('rep stosd'),因此标签元素增加需要更长的时间。 2) 将 switch 从 16 条语句扩展到 32 条语句的执行时间大约延长了 2%,并不是很重要(出乎意料);然而,从 16 到 32 执行循环代码的时间增加了大约 40%(预期)。再次感谢您,这真的很有趣:-)
      猜你喜欢
      • 2011-08-30
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2019-12-05
      相关资源
      最近更新 更多