一场从按位或数组索引开始的思维发散

日常

引言

前几天,某知名视频博主发布新视频, 又一次引发了全网热烈讨论,其中最引人注目的是视频中一闪而过的几行代码, 我个人将这次的争议称之为“按位或数组索引事件”。 这个事件其实并没有看起来那么简单,从不同方向延伸一下,它涉及了很多编程相关的知识。

位掩码

首先,我大胆假设一下他那么写的意图。我不太懂嵌入式开发,但是我第一感觉他应该是跟C语言项目中常用的 位掩码(bit masking)搞混了。

这应该属于很常见的C语言设计模式?对我个人来说,最常见的应用应该是 UNIX 的很多系统调用接口里面经常见用位掩码表示的各种 flags 标志位,比如 fcntl.h。在这种设计模式下,同时把多个标志位置 1 那肯定是按位或了。

那么,视频中的写法能否真的能实现呢?

当然,最容易想到的方法就是用宏。 这个 已经有人 给出了 C++ 的实现方式。 甚至 还有人 整了个更大的活,写了个编译原理作业一样的东西。

C/C++ 的宏我只用过最简单的功能,这里就不乱写了。原理可以参考上面提到的视频。

运算符重载

上面提到的宏的方案其实直接把输入的数字字面量拿正则表达式解析了一下,这一步还有一种处理方式,就是重载按位或运算符。

更进一步说,如果真的要实现这样的功能,从语言使用者的角度,我更想要的不是一个函数调用, 而是直接用 [] 来批量赋值, 就像 v[1 | 5] = 10 这样。 直接像上面提到的造一门 DSL 也太浪费了,能否用编程语言自带的功能实现呢?

C++ 我写得少,但我感觉应该也许大概能办到。一想到还要学很多东西,我就放弃了。有时间试试。

稍微用 rust 试了下,首先定义一个类型作为索引,为它重载一下std::ops::BitOr, 然后为Vec实现一个自己定义的trait,作为赋值用的函数, 最后整一个宏 he! 实现用字面量定义索引变量(学习了这里),效果像这样:

fn main() {
    let mut v = vec![0u8; 6];
    v.power_con(he!{ 1 | 5 }, 10);

    assert_eq!(v, vec![0, 10, 0, 0, 0, 10]);
}

但是如果要进一步实现重载[]运算符配合 rust 的 = 移动语义, 就超出我能力范围了,感觉是不行。 好像也没找到有人想要这么干(当然没有!)

python 也可以通过魔术方法重载运算符,由于是动态类型语言,实现 __setitem__[]批量赋值,以及实现 __or__| 合并数组作为索引都很简单,但因为没有宏,想要直接用数字字面量按位或表示索引应该就办不到了。

聊聊 Numpy 的实现

谈下一话题,关于视频中的错误,置顶评论给出了一个普通程序员思路的修正方案: 建一个数组存住要修改的索引,然后写一个循环遍历修改原数组。 那么不写循环行不行呢?

调参工作者们肯定要说了,这我见过,numpy 里就有,直接 ndarray 做索引就可以批量赋值。

>>> import numpy as np
>>> v = np.array([0] * 10)
>>> v
array([0, 0, 0, 0, 0, 0, 0, 0, 0, 0])
>>> indices = np.array([1, 5])
>>> v[indices] = 10
>>> v
array([ 0, 10,  0,  0,  0, 10,  0,  0,  0,  0])

实现批量赋值用上一节提到的重载魔术方法很好办,但 numpy 并非用 python 写的原生库,这种情况下实现运算符重载这就涉及到我盲区了,而且 numpy 的 C 语言底层实现本身也是个很大的话题。

最后我太菜了,在源码里找了好久也没有搞清楚 numpy 这个项目里运算符究竟是怎么重载的,恐怕得再了解下 CPython 和 numpy 的 C API 才能给出答案。

树状数组

按位或的话题就到此为止了。然而,由此稍微发散一下,数组 + 索引 + 位运算, 很难不让人联想到一种数据结构,树状数组(Binary Indexed Tree)。

树状数组是一种被设计用来做区间查询和单点修改的数据结构,两种操作都能够以$O(\log{n})$的时间进行。 (作为对比,普通数组:查询$O(n)$, 修改$O(1)$;前缀和:查询$O(1)$, 修改$O(n)$)

核心思想是数组每个元素存储一个区间内的和(或其他集合操作),通过索引的二进制表示可以判断出它究竟存了哪些值的和(如果树状数组的索引为 $i$ (这里是从1开始数),那么存储区间为 $(i-LSB(i), i]$。$LSB(x)$ 表示x的二进制表示仅保留最右边的一个1得到的数,位运算求法为 $x \&(-x)$。因此,不断索引将右边的1去掉,对应元素求和,直到索引为0,就能得到前缀和。更新时,索引不断加$LSB(i)$就能依次得到包含该索引的和的所有索引。

n = len(a)
bit = [0] * (n + 1)

def lsb(x):
    return x & (-x)

def prefix_sum(i):
    ans = 0
    while i > 0:
        ans += bit[i]
        i -= lsb(i)
    return ans

def range_sum(a, b):
    return prefix_sum(b) - prefix_sum(a - 1)

def update(i, d):
    if i < 1:
        return
    while i <= n:
        bit[i] += d
        i += lsb(i)

for i in range(len(a)):
    update(i + 1, a[i])

实现起来也很简单,但是对我来说不太友好,我属于一旦涉及数组的“区间开闭、第x个、索引从x开始、间隔x个、区间内共x个” 这些概念两个及以上同时出现的时候绝对会晕的体质,完了没救了。

总结

首先锐评一下事件本身,感觉相关从业者而且还是走 DIY 路线,不熟悉什么是按位或属实是不太应该。但是仔细一想,虽然这个写法很乱来又好像不完全是乱想的,确实有类似的写法能够免去写循环。

然后发表一下感想,这次从一个小事件联想到了很多东西,有些自己解决了,有些没能解决。 发现需要学的东西还是太多了,我太菜了,而且从 numpy 这块也能看出自己对大型项目的掌控能力还是很欠缺。

See Also