[Elementary-Algorithms/0]最小可用ID:改进一的学习理解

作者GitHub主页。里边可以下载到英文版的PDF文件,中文版的出书了,没提供下载。


最小ID问题就是:在一个含有N个非负整数的列表中找到一个最小的且不在该列表中的非负整数。最普通的做法就是循环遍历。然后作者提到了一个改进就是:

// 文字截自原中文版PDF,之前下载到的,贴一点应该问题不大
改进这一解法的关键基于这一事实:对于任何n个非负整数x1 , x2 , ..., xn ,
如果存在小于n的可用整数,必然存在某个xi不在[0, n)这个范围内。否则这些整数
一定是0, 1, ..., n − 1的某个排列,这种情况下,最小的可用整数是n。

如何理解这段话?一句一句来看。
第一句:“如果存在小于n的可用整数,必然存在某个xi不在[0, n)这个范围内。”如何理解这句话,其实可以换种说法-“(前提还是n个非负整数)如果存在一个或多个(“多个”肯定不大于n)小于n的可用非负整数,必然对应的(指个数对应)存在一个或多个xi不在[0, n)这个范围内(xi属于原来的n个非负整数列表,[0, n)这又是一个自然递增的非负整数列表)”。这其中的关系,举几个实栗就明了:先举个不存在小于n的可用非负整数的含有4个元素的列表4:{0, 1, 2, 3}(你会发现含有4个元素,且元素是非负整数,且不存在小于4的可用非负整数的列表,只能由这几个数组成。在这一前提下,扩展到n个元素的列表也如此,不信可以试试)那么[0, 4)组成的列表就是4:{0, 1, 2, 3},结合这两个列表,理解这句话“如果不存在小于n的可用非负整数,必然任意xi都在[0, n)这个范围内”,把n换成4,是不是第一个列表的元素都在第二个列表中啊?同一前提下,扩展到n个元素的列表也成立,这真的是一个事实:)至于如何用数学证明就不知道了。再举个存在小于n的可用非负整数的例子(只存在一个的情况),还是4个元素4:{0, 2, 3, 4},那么[0, 4)组成的列表4:{0, 1, 2, 3},是不是发现存在“1”这个非负整数不在第一个列表中,这种情况下必然存在一个元素“4”是不在第二个列表中的,这一前提下扩展到n个元素的列表都成立。
举完了栗子,看第二句:“否则这些整数一定是0, 1, …, n − 1的某个排列,这种情况下,最小的可用整数是n。”这句话的前提是不存在小于n的可用非负整数,结合举的第一个栗子,会发现栗子中的第一个列表中的所有元素就是0, 1, 2, 3这四个数的某个排列(排列百度百科),这样,在第一个列表中找个最小可用的非负整数是不是就是“n=4”?
一句话总结下–若不存在小于n的非负整数,则最小可用非负整数必然为n,反之存在的话,最小可用非负整数必然在区间[0, n)内。原文中用了这个表达式minfree(x1; x2; ...; xn) ≤ n来描述。


理解了上面的事实,来看下原文中找最小可用ID的伪码描述(注释自己加的):

    // 定义一个函数,从列表A中找最小可用ID
1: function Min-Free(A)
        // 这行表示将数组F中的每个元素初始化为False
        // 该数组用于标记[0, n)中的数是否可用,如0不在列表A中,则F[0] = False表示0为可用ID
        // 数组下标为[0, n],所以有n + 1个元素
2:      F ← [False; False; ...; False] where |F| = n + 1
        // 循环遍历任意x属于A
3:      for ∀x ∈ A do
            // 如果x小于n(n为列表A的元素个数)
4:          if x < n then
                // 则将该元素标记为不可用
5:              F[x] ← True
        // 标记完后,最小可用ID就可以在[0, n]内找了
        // 循环遍历[0, n]之间的数
6:      for i ← [0, n] do
            // 利用标记,找到第一个为False的数就是最小可用ID了
7:          if F[i] = False then
8:              return i

看完了伪码,接下来看真正的代码:
别看伪码几行就把算法描述出来了,等用相应的编程语言实现的时候,就不一样了。先理解清楚如何用C语言描述伪码中的标记数组F,源码来自原文:

// 为了提高效率,避免每次动态申请内存,直接定义一个很大的数组
// 一百万,表示该数组可标记有一百万个元素的列表(N并不是数组长度!)
# define N 1000000 // 1 million
// 一个字节8位,sizeof(int)表示int在对应系统占几个字节
// 乘以8就表示int在对应系统占几位。如32位系统,就占32位
# define WORD_LENGTH sizeof(int) * 8

// 定义一个长度为(N / WORD_LENGTH + 1)的数组。为什么数组长度不是N?
// 为了省内存,我们用0和1来标记列表中的元素可用不可用
// 而0或者1只需要一个二进制位就可以储存了。如果我们把1存到一个int里边去,
// 如果int占4个字节,就是32个二进制位,那1只占了1/32,其余的31/32的空间就严重浪费了
// (N / WORD_LENGTH + 1)怎么来的?看个问题:我们需要1000000个二进制位,
// 假设int占4个字节,32位,那么需要几个int就够1000000了?解个小学数学题:
// 设需要x个,则x * 32 = 1000000,32用WORD_LENGTH替换,1000000用N替换,
// 那么x = N / WORD_LENGTH,表示我们需要(N / WORD_LENGTH)个int就可以了。
// +1是因为0~10000000有1000001个元素嘛
unsigned int bits [N / WORD_LENGTH + 1];

// 下面定义了一个函数,作用是将元素i的对应二进制位标记为0或1,也就是可用或不可用
// 我们要标记n个元素,如果i小于n的话,i既可表示元素,也可表示标记数组的下标
// bits就是我们的标记数组
void setbit (unsigned int * bits , unsigned int i ){
    // 假设unsigned int占4个字节,则一个bits[index]就可标记32个元素
    // 0~31的元素,就用bits[0]标记;32~63就用bits[1]标记;以此类推。
    // 为什么呢?0~31的数除以32(WORD_LENGTH)为0嘛,
    // 32~63的数除以32(WORD_LENGTH)为1嘛。
    // 接下来如何标记元素i对应的二进制位呢?0~31的数分别向32取余可以得到0~31的数,
    // 刚好对应32个二进制位,32~63的数分别向32取余也会得到0~31的数,
    // 这样bits[0]的32个二进制位用来标记0~31的数,bits[1]用来标记32~63的数,以此类推。
    // (i % WORD_LENGTH)只能得到0~31的数,1左移0~31位会得到0001、0010、0100一系列
    // 这样的数,当然每个数有32位,这里只是用4位举例。
    // 再举个实栗就清楚了:我们标记0这个数,i = 0;bits[i / WORD_LENGTH] → bits[0 / 32] → bits[0];1 << (0 % 32) == 1 << 0 == 0001;
    // bits[0]会被初始化为0,所以0000 | 0001 == 0001,如果某个列表中含有0这个元素,且现在0元素对应的二进制位被标记为1了,说明0不是可用非负整数
    // 同样的过程可以标记1、2、3、...、n(n不大于bits数组长度)的数。
    bits [i / WORD_LENGTH ] |= 1 << (i % WORD_LENGTH);
}

// 下面定义的函数的作用是测试元素i( i <= n(列表的元素个数) < N(bits数组长度) )在对应二进制位的标记是0还是非0
// 注意:该函数的返回值并不是0或1,而是0或>0的数
int testbit (unsigned int * bits , unsigned int i){
    // 原理上面注释有讲,再举个实栗就清楚了:测试0元素对应二进制位的标记,0在[0~31]里边,所以是用bits[0]标记的
    // 假设bits[0]的二进制数为0...0001,也就是说0元素对应二进制位被标记为了1(1表示不可用,0表示可用),
    // 1 << (0 %32) == 0...0001; 0...0001 & 0...0001 == 1。打印一下这个return值会得到1,这里只是恰巧为1而已,如果测试其它非0元素就不是1了。
    // 同样的过程测试下其它元素就了解了:)
    return bits [i / WORD_LENGTH] & (1 << (i % WORD_LENGTH));
}

知道标记数组的原理后,就可以用这个数组来找最小可用ID了,看代码(来自原文):

// 该函数的作用是从含有 n 个元素的数组 xs 中找一个最小可用非负整数
int min_free(int* xs , int n){
    // len为什么等于这个,在标记数组部分有讲,用来初始化标记数组
    int i , len = N / WORD_LENGTH + 1;
    // 下面这个循环就是将每个bits[i]都初始化为0,这样赋值后,整个bits[i]的32位都为0
    for (i = 0; i < len; ++i)
        bits[i] = 0;
    // 这个循环则遍历数组xs中的每个元素
    for (i = 0; i < n; ++i)
        // 如果元素小于n(我们不用管>n的数,最小可用非负整数不可能>n,原理在“事实”部分有讲)
        if (xs[i] < n)
            // 则将该元素对应的二进制位标记为1,该函数的原理在标记数组部分有讲
            setbit(bits, xs[i]);
    // 将数组xs中所有小于n的数都标记后,最小可用非负整数就可以在区间[0, n]中找了
    for (i = 0; i <= n; ++i)
        // 如果i对应的二进制位为0,则表示i这个元素不在数组xs中
        if (!testbit(bits, i))
            // 直接返回第一个不在数组xs中的i就是最小可用ID了
            return i;
}

其实理解标记数组部分的内容后,min_free()这个函数就很简单了,再看最后一点,原作者对该函数的最后一个for循环进行了一点改进(代码来自原文):

// 改进后的效果和原来的一样,但是代码还变复杂了,一个循环变成了两个循环
// 要理解这几行代码,先举个实栗:假设我们有8个二进制位,
// 然后我们要检查第8个元素是否可用,这时我们就检查8这个元素对应
// 的第8个二进制位是0还是1,假设是1,就像这样 1000 0000 ,那么我们从右往左
// 依次检查,个位/2位/4位/8位/16位/32位/64位/128位(这里就像十进制的个十百千万一样)
// 发现128位是1,表示8不可用。检查8是这样,那检查9,10,100也一样,每次都从个位到。。。位
// 这样太慢了,可不可以快点?当然可以!第8个二进制位在左边的4个二进制位中,我们可以跳过
// 右边那4位,以4位为单位进行检查是不比以1位为单位快?
// 为什么这样可以?因为我们检查8这个元素时,表示[0, 7]都是不可用的,这样右边的4个
// 二进制位都为1,按位取反(~)后就为0,直接跳过这部分,检查左边的4个二进制位,
// 1000按位取反后为0111,不为0,这样就可以找到第一个为0的二进制位了。
for (i = 0; ; ++ i) // 代码里不是以4位进行跳跃,而是以32位进行跳跃的。这层循环控制第几个单位
    if (~bits[i] != 0 ) // 如果该单位的数按位取反后不为0,说明最小可用元素可以在这找到
        for (j = 0; ; ++j) // 这层循环控制确定查找的元素在第i个单位中的第j个位置
            if (!testbit(bits, i * WORD_LENGTH + j )) // 检查第i个bits中的第j个位置的二进制位,如果为0
                return i * WORD_LENGTH + j; // 则返回该二进制位对应的元素

如果不清楚上面的解释,举个栗子:假设bits[2]这32个二进制位中存在一个可用最小非负整数,如何确定它的位置?最小非负整数在bits[2]中(就说明不可能在bits[0]和bits[1]中了,这个可以理解吧),当i = 0~bits[0] == 0,这样就不会在bits[0]中检查了,跳转到第一层循环的下一循环,直到i == 2,这时就进入第二层for循环,从bits[2]的第0个二进制位开始遍历,假设在bits[2]中的第8个二进制位为0,这时就return了,这两个循环都退出。这句话i * WORD_LENGTH + j,作用是将第8这个在bits[2]中的相对位置,转换成在整个数组长度中的绝对位置,结果就是2 * 32 + 8 == 72,也就是在bits[2]中第8的位置在整个数组中的第72个位置。


最后,改进一就讲完了:)

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值