【问题标题】:Most efficient way to find the index of the only '1' bit in a char variable (in C)查找 char 变量中唯一“1”位的索引的最有效方法(在 C 中)
【发布时间】:2018-04-29 10:04:35
【问题描述】:

这是一道面试题:
给你一个名为ch 的char 变量,当你知道它表示一个二进制形式的数字时,它的八位中只有一个等于'1'。 IE。 ,ch 的唯一可能值是:0x1, 0x2, 0x4, 0x8, 0x10, 0x20, 0x40, 0x80
给定变量ch,我需要编写最有效的代码来获取那个“1”位的索引。例如:如果ch == 0x1 -> 结果为0。如果ch == 0x4 -> 结果为2。

显而易见的方法是使用 switch-case,但我需要更高效的方法。
您可以在此处进行任何位操作以实现高效吗?

【问题讨论】:

  • ((ch & 0xf0) ? 4 : 0) | ((ch & 0xcc) ? 2 : 0) | ((ch & 0xaa) ? 1 : 0)
  • 我希望switch 接近最大效率。你总是可以硬编码一个查找表
  • @M.M 如果效率不是最高,那么您的编译器可以改进。
  • @squeamishossifrage:更神秘的是,05637042010UL>>(ch%11*3-3)&7。 Gcc 编译它没有分裂,但我认为你的操作码仍然更少。不过,我还是会选择 BSF。

标签: c algorithm performance bit-manipulation bits


【解决方案1】:

unsigned char 变量应该只有 8 位宽。为了编码位的位置,我们只需要 3 位。这意味着我们可以构建一个 24 位“表”,其中包含所有 8 个可能的 3 位答案的自然顺序

111 110 101 100 011 010 001 000 =

0xFAC688

如果您的变量 ch 已知仅包含一个 1 位,则它是 2 的幂。将某个值除以 ch 会将原始值右移您的 1 位的索引.因此,如果我们将上述“表格”除以您的ch三倍,答案将转移到结果的最低 3 位

unsigned position = (0xFAC688 / ch / ch / ch) & 0x7;

故事结束。在保留一般原则的同时,以上内容可能可以更有效地重写。


请注意,这与基于 De Bruijn 序列的方法中使用的原理基本相同。但是,De Bruijn 序列的目的是在原始“未打包”表(如我上面的表)不适合整数的情况下打包索引表。作为“令人不快”的副作用,De Bruijn 序列重新排序索引表,破坏了索引的原始自然序列。这需要额外的重新映射工作以从 De Bruijn 序列中提取正确的结果。

只有 24 位我们在这里没有这个问题,这意味着没有必要涉及 De Bruijn 及其附带的技巧。

另一方面,压缩表需要更短的移位,这将简化(并因此优化)除数的计算以实现所需的移位长度。在 De Bruijn 序列的情况下,根本不需要计算除数 - 你的 ch 已经是它了。因此,De Bruijn 序列可能很容易最终变得更有效率。

【讨论】:

  • 哈哈,关于 De Bruijn 序列的精彩观点。不过,我不知道这三个分界线。您可以将其更改为 3 次乘法和一次移位,但 De Bruijn 版本可能仍然更快,具体取决于您的 CPU。
  • 代替0xFAC688,是使用八进制076543210 的好时机 - 更有诗意。
  • +1 是最简洁的解决方案,但它可能不是最有效的(变量值除以 3 次),读者需要很长的注释才能掌握这个想法。一个简单的查找表似乎更简单、更有效。 builtin_clz() 可能更高效但不便携。
【解决方案2】:

char 类型可以是有符号的或无符号的(实现定义的行为)。为了安全地操作值0x80,我们应该明确地操作unsigned char数据。

我假设没有可用的特殊函数或多或少直接为我们提供位位置,例如ffs()(查找第一组)、clz()(计数前导零)或popcount()(人口计数),并且我们将仅使用标准 ISO C 来确定位位置。

一种方法是将ch 中的每个位位置分散到一个单独的半字节(一个四位组),然后执行寄存器内表查找,其中每个表元素包含一个 32 位 @987654329 中的半字节@。

可以通过对输入进行两次平方来完成扩展,这会将位 [i] 移动到位 [4*i]。然后下面的代码使用一个特殊的技巧来允许通过乘法和右移来提取表元素,其中乘法将所需的表条目移动到中间结果的位 [31:28] 中。请注意,该表以可读的方式指定并等同于常量0x01234567,每个合理的编译器都会进行替换。

Compiler Explorer (Godbolt) shows uchar_bitpos() 的大部分执行时间成本是三个相关整数乘法加上几个其他指令。

此代码假定为 8 位 char 和 32 位 int。为了更好的可移植性,unsigned char 变量可以转换为uint8_t 变量,unsigned int 变量可以转换为uint32_t 变量。

#include <stdio.h>
#include <stdlib.h>

int uchar_bitpos (unsigned char ch)
{
    unsigned int ch_pow2, ch_pow4;
    const unsigned int table =
        ((0 << 28) | (1 << 24) | (2 << 20) | (3 << 16) | 
         (4 << 12) | (5 <<  8) | (6 <<  4) | (7 <<  0));
    ch_pow2 = ch * ch;
    ch_pow4 = ch_pow2 * ch_pow2;
    return (ch_pow4 * table) >> 28;
}

int main (void)
{
    unsigned char a = 0x80;
    do {
        printf ("a = %2x   bitpos=%d\n", a, uchar_bitpos (a));
        a = a / 2;
    } while (a);
    return EXIT_SUCCESS;
}

上述程序的输出应如下所示:

a = 80   bitpos=7
a = 40   bitpos=6
a = 20   bitpos=5
a = 10   bitpos=4
a =  8   bitpos=3
a =  4   bitpos=2
a =  2   bitpos=1
a =  1   bitpos=0

【讨论】:

  • 三个乘法,但仍然是最快的解决方案之一。双平方方法比 de Bruijn 更容易理解。不错,我喜欢!
【解决方案3】:

好吧,如果ch 设置了一个位,那么ch-1 中1 位的计数就是该位的索引。理想情况下,您希望在没有循环或分支的情况下找到它,因为分支很昂贵,所以我会这样写:

int index = ((unsigned char)ch)-1;
index = ((index & 0xAA)>>1)+(index & 0x55);  //sums of pairs of bits
index = ((index & 0xCC)>>2)+(index & 0x33);  //sums of 4s of bits
index = ((index & 0xF0)>>4)+(index & 0x0F);  //sum of 8 bits

还有一个非常聪明的答案,它以乘法和查找为代价使用更少的运算:

int index = indexMap[((((int)(unsigned char)ch)*DEBRUIJN)>>16)&7];

DEBRUIJN 中的位必须是 De Bruijn 序列 (https://en.wikipedia.org/wiki/De_Bruijn_sequence),确保查找索引对于 ch 的每个值都是不同的。 indexMap 将这些查找索引映射到您想要的结果。

还请注意,根据@rici 的评论,indexMap 非常小,您可以将其打包成一个 int。

【讨论】:

    【解决方案4】:

    编写最有效的代码来获取“1”位的索引。

    最有效的代码是以某种方式将ch 的值映射到它的位索引,即:

    0x01 -> 0
    0x02 -> 1
    0x04 -> 2
    0x08 -> 3
    ...
    

    朴素映射表

    最简单和天真的解决方案需要在映射表中查找所有可能的值ch。对于 8 位数字 (char),我们需要一个包含 28= 256 个元素的表格:

    char naive_table[256];
    
    naive_table[0x01] = 0;
    naive_table[0x02] = 1;
    naive_table[0x04] = 2;
    naive_table[0x08] = 3;
    naive_table[0x10] = 4;
    naive_table[0x20] = 5;
    naive_table[0x40] = 6;
    naive_table[0x80] = 7;
    

    在这张表中查找也很简单:

    index = naive_table[ch];
    

    哈希函数+映射表

    之前的解决方案简单快速,但是naive_table的大部分元素都被浪费了。考虑到 ch 是 2 的幂,对于任何 n 位数,只有 n 可能的索引。

    因此,我们可以使用一个只有 8 个元素的表和一个哈希函数来将 ch 的值映射到一个唯一索引映射表。

    这种散列函数的完美候选者是使用 de Bruijn 序列的函数。有一篇论文"Using de Bruijn Sequences to Index a 1 in a Computer Word" 指出:

    length-n de Bruijn 序列,其中n 是 2 的精确幂,是一个由 n 个 0 和 1 组成的循环序列,因此每个长度为 lg n 的 0-1 序列作为一个连续子串恰好出现一次.

    例如,一个长度为 8 的 de Bruijn 序列是 00011101。每个 3 位数字作为一个连续的子串恰好出现一次:从最左边的 3 位开始,一次将 3 位窗口向右移动一位,我们有 000, 001, 011, 111, 110, 101, 010(环绕),100(也环绕)。

    哈希函数的计算公式为:h(x)=(x * deBruijn)>>(n - lg n)

    那么,让我们试试这个散列函数来在我们的紧凑查找表中获得一个唯一索引:

    h(ch) = ((ch * 00011101b) >> (8 - 3)) & 0x7
    h(ch) = ((ch * 29) >> 5) & 0x7
    

    让我们计算ch 的所有值的哈希值,并确保哈希函数按预期工作,即所有哈希值都是唯一的:

    ch    h(ch)
    0x01  ((1 * 29) >> 5) & 0x7 = 0
    0x02  ((2 * 29) >> 5) & 0x7 = 1
    0x04  ((4 * 29) >> 5) & 0x7 = 3
    0x08  ((8 * 29) >> 5) & 0x7 = 7
    0x10  ((16 * 29) >> 5) & 0x7 = 6
    0x20  ((32 * 29) >> 5) & 0x7 = 5
    0x40  ((64 * 29) >> 5) & 0x7 = 2
    0x80  ((64 * 29) >> 5) & 0x7 = 4
    

    因此散列函数工作正常,并为ch 的两个值的每个幂生成唯一的散列。

    现在让我们使用上表中的哈希值创建一个紧凑的映射表:

    char compact_table[8];
    
    compact_table[0] = 0;
    compact_table[1] = 1;
    compact_table[3] = 2;
    compact_table[7] = 3;
    compact_table[6] = 4;
    compact_table[5] = 5;
    compact_table[2] = 6;
    compact_table[4] = 7;
    

    现在我们使用哈希值作为索引进行查找:

    h = ((ch * 29) >> 5) & 0x7;
    index = compact_table[h];
    

    哈希函数+位串

    之前的版本近乎完美:映射表中不再有浪费的元素。但由于所有索引都在 0-7 之间(即只有 3 位值),因此仍有改进的空间。让我们使用位串代替映射表,这样就不会浪费每个元素的最高位。

    首先,让我们使用ch 的所有值和之前版本的哈希值创建这样一个位串:

    ch    h(sh)  index
    0x01  0      0 (000b)
    0x02  1      1 (001b)
    0x04  3      2 (010b)
    0x08  7      3 (011b)
    0x10  6      4 (100b)
    0x20  5      5 (101b)
    0x40  2      6 (110b)
    0x80  4      7 (111b)
    

    现在让我们按哈希值排序这个表:

    ch    h(sh)  index
    0x01  0      0 (000b)
    0x02  1      1 (001b)
    0x40  2      6 (110b)
    0x04  3      2 (010b)
    0x80  4      7 (111b)
    0x20  5      5 (101b)
    0x10  6      4 (100b)
    0x08  7      3 (011b)
    

    所以位串将是这些 3 位索引的反向串联:

    011 100 101 111 010 110 001 000 = 0x72f588
    

    现在让我们像以前一样在这个位串中查找。请注意,我们的索引是 3 位的,因此我们需要将哈希值乘以 3:

    h = ((ch * 29) >> 5) & 0x7; // just like before
    bit_string = 0x72f588;
    index = (bit_string >> (h * 3)) & 0x7;
    

    简而言之:

    index = (0x72f588 >> ((((ch * 29) >> 5) & 0x7) * 3)) & 0x7;
    

    代码中没有除法/模数/条件,因此它应该在任何 CPU 上都能快速执行。

    概念验证代码:

    unsigned char ch;
    for (ch = 1; ch; ch <<= 1) {
            int index = (0x72f588 >> ((((ch * 29) >> 5) & 7) * 3)) & 7;
            printf("ch = 0x%02x index = %d\n", ch, index);
    }
    return 0;
    

    【讨论】:

    • 很好的答案!对于位字符串,我将使用 4 位索引而不是 3 位索引(011 => 0011 等),这将为您提供 4 的乘法因子,这使得进一步优化成为可能:bit_string = 0x34572610Uindex = (0x34572610U &gt;&gt; ((((ch * 29) &gt;&gt; 5) &amp; 7) * 4)) &amp; 7;,可以简化为index = (0x34572610U &gt;&gt; (((ch * 29) &gt;&gt; 3) &amp; 0b11100) ) &amp; 7;。这只是一次乘法和其他 4 次整数运算。
    • 您的两次乘法的答案不一定比my solution 快,后者使用浮点转换,相比之下,只有一次乘法的解决方案。
    【解决方案5】:

    一个快速且非常便携的解决方案是:

    int charindex(unsigned char c){
        union {   /* Assume both float and int are 32 bits, assume IEEE 754 floating point. */
            int i;
            float f;
        } x;
        x.f = (float)c;
        return (x.i >> 23) - 127;
    }
    

    请注意,许多处理器都支持计算前导零或尾随零的数量 一个整数。使用 gcc 可以轻松访问这些特定指令:gcc 具有内置函数 __builtin_ctz(),在具有合适硬件支持的平台上,它可能比 charindex 更有效。

    【讨论】:

    • 非常好的一个! :) 转换性能取决于平台,但您是对的,可能比少数乘法更快...
    【解决方案6】:

    有效的代码行数可以是通过位进行线性搜索。

    short bit=0;
    const char one=1;
    while(!((ch >> bit) & one)) ++bit;
    

    当然错误检查可能是个好主意,因此您还可以添加检查以确保您仍然处于有效位。

    short bit=0;
    const char one=1;
    while(++bit < 8 && !((ch >> bit) & one)) {}
    

    它的计算效率肯定不高,并且它无法检测到何时设置了多个位,因此 switch case 仍然可能是正确的方法。

    这家伙在装配中的跳跃比开关盒少,所以它在计算位时可能更有效。

    short bit=
        ch&0x2?1:
        (ch&0x4?2:
        (ch&0x8?3:
        (ch&0x10?4:
        (ch&0x20?5:
        (ch&0x40?6:
        (ch&0x80?7:8))))));
    

    您也可以跳过检查最后一位,并假设如果没有其他匹配项,则设置第 7 位可以节省一次比较。

    short bit=
        ch&0x2?1:
        (ch&0x4?2:
        (ch&0x8?3:
        (ch&0x10?4:
        (ch&0x20?5:
        (ch&0x40?6:7)))));
    

    【讨论】:

      【解决方案7】:

      您可以在这里使用二分搜索技术将比较次数从 7 减少到 3。

      assert((n & n-1) == 0);
      if(n & 0x0F) {
          if(n & 0x03){
              if(n & 0x01){
                  idx = 0;
              }
              else{
                  idx = 1;
              }
          }else{
              if(n & 0x04){
                  idx = 2;
              }
              else{
                  idx = 4;
              }
          }
      }else{
          if(n & 0x30){
              if(n & 0x10){
                  idx = 3;
              }
              else{
                  idx = 4;
              }
          }else{
              if(n & 0x40){
                  idx = 5;
              }
              else{
                  idx = 6;
              }
          }
      }
      

      【讨论】:

        【解决方案8】:

        一些架构包含popcount 的高效(单指令)实现,可通过内在函数或__builtin_popcount() 在C 编译器中使用。

        如果是这种情况,将很难击败popcount(x - 1),它将首先将单个设置位 (1 n的索引。

        一些 cmets 指出“位扫描正向”,但至少在 x86 架构中不如 popcount。始终了解您的硬件...

        【讨论】:

          【解决方案9】:

          一些不会超高效的方法(取决于您对效率的定义)。

          循环移位法。

          int ch = 32
          int i;
          for ( i=1;ch >>i ; i++) 
            printf("%i %i \n",i, ch>>i);
          printf("Final index:%i\n",i-1);
          

          调用 math.h log2

          int l=log2((double)ch);
          printf("math log2:%i\n",l);
          

          更高效:对于单个查找,可能很难击败 AnT 的版本。但是对于重复查找,查找表的性能可能会更好。

          int ltable[256]= { -1 };
          
          void initTable()
          {
            ltable[0x01]=0;
            ltable[0x02]=1;
            ltable[0x04]=2;
            ltable[0x08]=3;
            ltable[0x10]=4;
            ltable[0x20]=5;
            ltable[0x40]=6;
            ltable[0x80]=7;
          }
          
          int lookup(size_t ch)
          {
            return  ltable[ch];
          }
          

          表初始化 ASM

          init():
            push rbp
            mov rbp, rsp
            mov DWORD PTR ltable[rip+4], 0
            mov DWORD PTR ltable[rip+8], 1
            mov DWORD PTR ltable[rip+16], 2
            mov DWORD PTR ltable[rip+32], 3
            mov DWORD PTR ltable[rip+64], 4
            mov DWORD PTR ltable[rip+128], 5
            mov DWORD PTR ltable[rip+256], 6
            mov DWORD PTR ltable[rip+512], 7
            nop
            pop rbp
            ret
          

          表查找 ASM

          lookup(unsigned long):
            push rbp
            mov rbp, rsp
            mov QWORD PTR [rbp-8], rdi
            mov rax, QWORD PTR [rbp-8]
            mov eax, DWORD PTR ltable[0+rax*4]
            pop rbp
            ret
          

          输出

           1 16 
           2 8 
           3 4 
           4 2 
           5 1 
           Final index:5
           math log2:5
           Lookup[32]=>5
          

          【讨论】:

          • 设置ltable[0] to -1`的目的是什么?
          • log of 0 是未定义的,-1 可能是代表那个
          • 另外,这是我最初的想法,但我对硬编码表有点警惕。
          • 是 -1,表示未定义的值。您基本上可以将它们设置为您喜欢的错误代码,也许是 0xDEADBEEF。另请注意,查找表可能是一些较小的类型。
          【解决方案10】:

          如果您只有一个位设置为1,这意味着它是2 的幂。取logch 可以直接获取索引。当然,您必须使用基于 2 的日志。

          【讨论】:

            【解决方案11】:

            最简单的解决方案可能不是最快的,但只有针对其他解决方案进行分析才能让您确定这一点,并且仅适用于给定的架构和编译器。

            这是一个非常简单的解决方案:

            #include <math.h>
            
            int leadingbit(unsigned char c) {
                return log2(c);
            }
            

            这是一个带有查找表的解决方案:

            int leadingbit(unsigned char c) {
            #define N(x) ((076543210 / (x) / (x) / (x)) & 7)
            #define N8(x) N(x), N(x+1), N(x+2), N(x+3), N(x+4), N(x+5), N(x+6), N(x+7)
            #define N32(x) N8(x), N8(x+8), N8(x+16), N8(x+24)
                static unsigned char table[256] = {
                    N32(0), N32(32), N32(64), N32(96), N32(128), N32(160), N32(192), N32(224),
                };
            #undef N
            #undef N8
            #undef N32
                return table[c];
            }
            

            这是一个受 Matt Timmermans 启发但没有内存参考的例子:

            int leadingbit(unsigned char c) {
                int n = c - 1;
                n = ((n & 0xAA) >> 1) + (n & 0x55);  //sums of pairs of bits
                n = ((n & 0xCC) >> 2) + (n & 0x33);  //sums of 4s of bits
                return ((n >> 4) + n) & 7;
            }
            

            这是一个使用不可移植的builtin_clz() 函数(计算前导零):

            #include <limits.h>
            
            int leadingbit(unsigned char c) {
                return CHAR_BIT * sizeof(unsigned) - 1 - builtin_clz((unsigned)c);
            }
            

            请注意,以上所有内容都假设c2 的幂,其他值的行为可能未定义。你可以用一个简单的表达式检查c2 的幂:

            if (c && !(c & (c - 1))) {
                /* c is a power of 2 */
            }
            

            【讨论】:

              猜你喜欢
              • 1970-01-01
              • 1970-01-01
              • 1970-01-01
              • 2012-04-18
              • 2022-06-10
              • 1970-01-01
              • 2021-07-26
              • 1970-01-01
              • 1970-01-01
              相关资源
              最近更新 更多