【问题标题】:Bit tricks to find the first position where the number of 0s equals the number of 1s找到 0 的数量等于 1 的数量的第一个位置的位技巧
【发布时间】:2017-04-17 10:18:39
【问题描述】:

假设我有一个 32 位或 64 位无符号整数。

找到最左边位的索引 i 以使最左边 i 位中 0 的数量等于最左边 i 位中 1 的数量的最快方法是什么? 我在想一些像here 提到的小技巧。

我对最近的 x86_64 处理器感兴趣。这可能与某些处理器支持指令有关,例如 POPCNT(计算 1 的数量)或 LZCNT(计算前导 0 的数量)。

如果有帮助,可以假设第一位始终具有特定值。

示例(16 位): 如果整数是

1110010100110110b 
         ^ 
         i

则 i=10 对应标记的位置。

16 位整数的可能(慢)实现可能是:

mask = 1000000000000000b
pos = 0
count=0
do {
    if(x & mask)
        count++;
    else
        count--;

    pos++;
    x<<=1;
} while(count)

return pos;

编辑:根据@njuffa 评论修复代码中的错误。

【问题讨论】:

  • i = 0 会被封禁,对吧?不然有点无聊
  • 确实是的 :) 我必须在 1,...,size 范围内,其中 size 是整数的位数。
  • 我不清楚规范。您能否提供一个简单(缓慢)的参考实现来说明您的想法?在我看来,这样一个最左边的位位置并不总是可以找到,一个简单的 32 位示例是 0xFFFFFFFE(或者在这种情况下结果是 32?)
  • 你可以假设这样的位置一直存在。换句话说,如果这样的位置不存在,任何结果都可以。参考原帖中的实现。
  • @Steven 根据您的文字描述,参考代码不应返回count,而是返回count == 0 之前遍历的位数。

标签: binary bit-manipulation x86-64 bit iec10967


【解决方案1】:

这是一种使用经典位旋转技术的 32 位数据解决方案。中间计算需要 64 位算术和逻辑运算。我必须尽可能地坚持便携式操作。需要一个 POSIX 函数 ffsll 的实现,以在 64 位 long long 中查找最低有效 1 位,以及一个自定义函数 rev_bit_duos,它可以反转 32 位整数中的位二重奏。后者可以替换为特定于平台的位反转内在函数,例如 ARM 平台上的__rbit intrinsic

基本观察是,如果可以提取具有相同数量的 0 位和 1 位的位组,则它必须包含偶数位。这意味着我们可以检查 2 位组中的操作数。我们可以进一步限制自己跟踪每个 2 位是否增加(0b11)、减少(0b00)或保持不变(0b010b10)位的运行平衡。如果我们用单独的计数器计算正负变化,4位计数器就足够了,除非输入是00xffffffff,可以单独处理。根据问题的 cmets,这些情况不应该发生。通过从每个 2 位组的正变化计数中减去负变化计数,我们可以找到余额变为零的组。可能有多个这样的位组,我们需要找到第一个。

处理可以由expanding each 2-bit group into a nibble 并行化,然后可以用作更改计数器。前缀和可以通过整数乘以适当的常数来计算,该常数在每个半字节位置提供必要的移位和加法操作。并行半字节减法的有效方法是众所周知的,同样有一个众所周知的technique due to Alan Mycroft for detecting zero-bytes 可以简单地更改为零半字节检测。然后应用 POSIX 函数 ffsll 来查找该半字节的位位置。

需要提取最左位组而不是最右位组的要求有点问题,因为艾伦·迈克罗夫特的技巧只适用于找到第一个零-从右侧轻咬。此外,处理最左边位组的前缀和需要使用mulhi 操作,这可能不容易获得,并且可能比标准整数乘法效率低。我通过简单地预先对原始操作数进行位反转来解决这两个问题。

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

/* Reverse bit-duos using classic binary partitioning algorithm */
inline uint32_t rev_bit_duos (uint32_t a)
{
    uint32_t m;
    a = (a >> 16) | (a << 16);                            // swap halfwords
    m = 0x00ff00ff; a = ((a >> 8) & m) | ((a << 8) & ~m); // swap bytes
    m = (m << 4)^m; a = ((a >> 4) & m) | ((a << 4) & ~m); // swap nibbles
    m = (m << 2)^m; a = ((a >> 2) & m) | ((a << 2) & ~m); // swap bit-duos
    return a;
}

/* Return the number of most significant (leftmost) bits that must be extracted
   to achieve an equal count of 1-bits and 0-bits in the extracted bit group.
   Return 0 if no such bit group exists.
*/   
int solution (uint32_t x)
{
    const uint64_t mask16 = 0x0000ffff0000ffffULL; // alternate half-words
    const uint64_t mask8  = 0x00ff00ff00ff00ffULL; // alternate bytes
    const uint64_t mask4h = 0x0c0c0c0c0c0c0c0cULL; // alternate nibbles, high bit-duo
    const uint64_t mask4l = 0x0303030303030303ULL; // alternate nibbles, low bit-duo
    const uint64_t nibble_lsb = 0x1111111111111111ULL;
    const uint64_t nibble_msb = 0x8888888888888888ULL; 
    uint64_t a, b, r, s, t, expx, pc_expx, nc_expx;
    int res;

    /* common path can't handle all 0s and all 1s due to counter overflow */
    if ((x == 0) || (x == ~0)) return 0;

    /* make zero-nibble detection work, and simplify prefix sum computation */
    x = rev_bit_duos (x); // reverse bit-duos

    /* expand each bit-duo into a nibble */
    expx = x;
    expx = ((expx << 16) | expx) & mask16;
    expx = ((expx <<  8) | expx) & mask8;
    expx = ((expx <<  4) | expx);
    expx = ((expx & mask4h) * 4) + (expx & mask4l);

    /* compute positive and negative change counts for each nibble */
    pc_expx =  expx & ( expx >> 1) & nibble_lsb;
    nc_expx = ~expx & (~expx >> 1) & nibble_lsb;

    /* produce prefix sums for positive and negative change counters */
    a = pc_expx * nibble_lsb;
    b = nc_expx * nibble_lsb;

    /* subtract positive and negative prefix sums, nibble-wise */
    s = a ^ ~b;
    r = a | nibble_msb;
    t = b & ~nibble_msb;
    s = s & nibble_msb;
    r = r - t;
    r = r ^ s;

    /* find first nibble that is zero using Alan Mycroft's magic */
    r = (r - nibble_lsb) & (~r & nibble_msb);
    res = ffsll (r) / 2;  // account for bit-duo to nibble expansion

    return res;
}

/* Return the number of most significant (leftmost) bits that must be extracted
   to achieve an equal count of 1-bits and 0-bits in the extracted bit group.
   Return 0 if no such bit group exists.
*/   
int reference (uint32_t x)
{
    int count = 0;
    int bits = 0;
    uint32_t mask = 0x80000000;
    do {
        bits++;
        if (x & mask) {
            count++;
        } else {
            count--;
        }
        x = x << 1;
    } while ((count) && (bits <= (int)(sizeof(x) * CHAR_BIT)));
    return (count) ? 0 : bits;
}

int main (void)
{
    uint32_t x = 0;
    do {
        uint32_t ref = reference (x);
        uint32_t res = solution (x);
        if (res != ref) {
            printf ("x=%08x  res=%u ref=%u\n\n", x, res, ref);
        }
        x++;
    } while (x);
    return EXIT_SUCCESS;
}

【讨论】:

  • 我还没有理解你的整个算法。如果您有一个尾随零计数,即从另一端计数的ffsll,您能否避免位反转? (包括 x86 在内的许多平台在 asm 中都有此功能,但我忘记了是否有可移植的 C 函数。)或者您是否需要按顺序排列的位才能在乘法中进行进位传播?
  • 用于零半字节检测的 Mycroft 技术可以标记多个零半字节,但由于可能的进位传播,它只能标记最右边(最不重要)的零半字节具有 100% 的准确度。我最初忘记了这一点,直接使用输入(没有初始反转),使用clz 从左侧搜索零半字节(最高有效位),结果发现我的代码因许多参数而失败> 0xc0000000 .此外,非逆向处理需要 64 位 mulhi 来生成前缀和,这通常必须使用内部函数或内联汇编进行编码。
【解决方案2】:

一种可能的解决方案(对于 32 位整数)。我不确定它是否可以改进/避免使用查找表。这里 x 是输入的整数。

//Look-up table of 2^16 elements.
//The y-th is associated with the first 2 bytes y of x.
//If the wanted bit is in y, LUT1[y] is minus the position of the bit
//If the wanted bit is not in y, LUT1[y] is the number of ones in excess in y minus 1 (between 0 and 15)
LUT1 = ....

//Look-up talbe of 16 * 2^16 elements.
//The y-th element is associated to two integers y' and y'' of 4 and 16 bits, respectively.
//y' is the number of excess ones in the first byte of x, minus 1
//y'' is the second byte of x. The table contains the answer to return.
LUT2 = ....

if(LUT1[x>>16] < 0)
    return -LUT1[x>>16];

return LUT2[ (LUT1[x>>16]<<16) | (x & 0xFFFF) ]

这需要 ~1MB 用于查找表。 同样的想法也适用于使用 4 个查找表(x 的每个字节一个)。这需要更多操作,但会将内存减少到 12KB。

LUT1 = ... //2^8 elements
LUT2 = ... //8 * 2^8 elements
LUT3 = ... //16 * 2^8 elements
LUT3 = ... //24 * 2^8 elements

y = x>>24
if(LUT1[y] < 0)
    return -LUT1[y];

y = (LUT1[y]<<8) | ((x>>16) & 0xFF);
if(LUT2[y] < 0)
    return -LUT2[y];

y = (LUT2[y]<<8) | ((x>>8) & 0xFF);
if(LUT3[y] < 0)
    return -LUT3[y];

return LUT4[(LUT2[y]<<8) | (x & 0xFF) ];

【讨论】:

  • 我通过测量计算所有 32 位整数的结果所需的总时间,用 4 个查找表与 @harold 的解决方案对解决方案进行了基准测试。在我的机器上,这个解决方案需要 ~5600000 个时钟滴答,而 harold 需要 ~25000000 个时钟滴答(每个 C 的 clock() 函数)。
  • 您可以使用perf stat 轻松测量核心时钟周期内的整个程序,而不是实时测量。如果您没有内存瓶颈,则可以从等式中删除 CPU 频率缩放(speedstep / turbo)。 (但英特尔 CPU 上的私有每核 L2 缓存仅为 256kiB,因此可能并非如此)。顺便说一句,5.6M 与 25M 相比更容易阅读。此外,您测试的硬件以及是否使用 AVX 可能很重要。编译器选项通常很重要。
  • 但是,微基准测试仍然使 2^16 条目 LUT 看起来比实际程序中的更好。在微基准测试中,整个事物在 L2 中保持热状态(您使用的部分可能在 L1 中保持热状态,具体取决于输入)。在实际程序中,其他代码将在调用此函数之间逐出 LUT。此外,您没有说明您的微基准测试是测试延迟还是吞吐量。
  • 无论如何,有用的想法和值得测试,但微基准测试很难,并且并不总能告诉你在实际程序中什么会更好。
【解决方案3】:

我对此没有任何技巧,但我确实有一个 SIMD 技巧。

首先观察一下,

  • 将 0 解释为 -1,此问题变为“找到第一个 i 以便第一个 i 位和为 0”。
  • 0 是偶数,但在此解释下所有位都有奇数值,这表明i 必须是偶数,并且可以通过 2 位块来分析此问题。
  • 01 和 10 不会改变平衡。

将 2 组分散到字节后(以下均未测试),

// optionally use AVX2 _mm_srlv_epi32 instead of ugly variable set
__m128i spread = _mm_shuffle_epi8(_mm_setr_epi32(x, x >> 2, x >> 4, x >> 6),
                   _mm_setr_epi8(0, 4, 8, 12, 1, 5, 9, 13, 2, 6, 10, 14, 3, 7, 11, 15));
spread = _mm_and_si128(spread, _mm_set1_epi8(3));

将 00 替换为 -1,将 11 替换为 1,将 01 和 10 替换为 0:

__m128i r = _mm_shuffle_epi8(_mm_setr_epi8(-1, 0, 0, 1,  0,0,0,0,0,0,0,0,0,0,0,0),
                             spread);

计算前缀总和:

__m128i pfs = _mm_add_epi8(r, _mm_bsrli_si128(r, 1));
pfs = _mm_add_epi8(pfs, _mm_bsrli_si128(pfs, 2));
pfs = _mm_add_epi8(pfs, _mm_bsrli_si128(pfs, 4));
pfs = _mm_add_epi8(pfs, _mm_bsrli_si128(pfs, 8));

找到最高的0:

__m128i iszero = _mm_cmpeq_epi8(pfs, _mm_setzero_si128());
return __builtin_clz(_mm_movemask_epi8(iszero) << 15) * 2;

&lt;&lt; 15*2 出现是因为生成的掩码是 16 位,但 clz 是 32 位,它被移动了一位,因为如果顶部字节为零,则表示采用 1 组 2,而不是零。

【讨论】:

  • 感谢您的回答。我完全理解解决方案的想法,但我仍然不确定说明在做什么(而且我发现的文档有点模糊)。你介意解释一下还是给我一个好的参考?
  • @Steven 除了pshufb (_mm_shuffle_epi8) 之外,他们中的大多数人看起来都很无辜,这就是问题所在吗?
  • 是的,我也不明白您为什么要使用 _mm_setr_epi8 调用的参数进行屏蔽。
猜你喜欢
  • 2013-02-04
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2021-12-18
  • 2021-07-27
  • 2020-02-14
  • 1970-01-01
  • 2011-10-12
相关资源
最近更新 更多