表达式树求值的空间复用

回忆一致 \(\mathsf{NC}^1\) 电路是说一个 \(O(\log n)\) 深度, 可以由对数空间 Turing 机生成的布尔电路, 这个 \(O(\log n)\) 层的电路暴力展开就是一颗 \(n^{O(1)}\) 大小的表达式树.

反过来, 对于任何一颗表达式树, 我们也可以用树分治的方法将其对数空间规约到一个 \(O(\log n)\) 深度的树.

在这个电路 (或者说表达式树) 上 DFS 地求值, 由于树本身的深度原因, 栈空间和要记录的其他额外信息只有 \(O(\log n)\) 位, 也就是说这天然是个对数空间算法.

那么有没有可能比对数空间更好呢? 有没有可能不记录栈上的信息呢? 答案是可以, 而且更具体地说, 只需要三个 bit.

首先将问题进行适当简化, 由于已经有了 NOT 和 AND, 我们可以假设电路里没有 OR. 也就是说, 电路里的每个中间节点都是一个 AND 门, 或者是一个 NOT 门, 叶子节点都是某个变量 \(x_i\).

现在对于一个节点 \(u\), 三个寄存器 \(r_1, r_2, r_3\), 我们记 \(P_{u, i}\) 是一系列指令, 指令执行的效果是让 \(r_i\) 异或上 \(u\) 的节点值, 并且不变化另外两个寄存器. 也就是说, \(P_{u, i}\) 的效果是 \(r_i \leftarrow r_i \oplus u\).

如何生成这么一套指令呢? 我们可以用归纳的办法.

  • 如果 \(u\) 是个叶子节点, 那么 \(P_{u, i}\) 只有一条指令: \(r_i \leftarrow r_i \oplus x_u\).
  • 如果 \(u\) 是个 NOT 门, 设孩子是 \(v\), 那么 \(P_{u, i}\) 是如下指令

\[\begin{align*} & P_{v, i}, \\ &r_i \gets r_i \oplus 1, \\ \end{align*} \]

的拼接, 显然, 这一套指令做完之后, \(r_i\) 就是异或了 \(u\) 的节点值.

  • 如果 \(u\) 是个 AND 门, 设孩子是 \(l, r\), 不妨设 \(i=1\) (另外两种情况可以对称考虑), 那么 \(P_{u, 1}\) 是如下指令:

\[\begin{align*} & r_1 \gets r_1 \oplus r_2r_3,\\ & P_{l, 2} , \\ & r_1 \gets r_1 \oplus r_2r_3,\\ & P_{r, 3},\\ & r_1 \gets r_1 \oplus r_2r_3,\\ & P_{l, 2},\\ & r_1 \gets r_1 \oplus r_2r_3,\\ & P_{r, 3},\\ \end{align*} \]

等等, 这一团操作做完了是什么? 让我们逐步分析一下. 最开始三个寄存器的状态就是 \((r_1, r_2, r_3)\), 然后每个 \(2, 3\) 的位置的操作都被做了两次, 说明 \(2\), \(3\), 最后其实没变, 而 \(1\) 的效果总共是加了如下四项 (接下来用加法代替异或):

\[r_2 r_3 + (r_2 + l) r_3 + (r_2 + l) (r_3 + r) + r_2(r_3 + r), \]

容易发现抵消完之后只剩下 \(lr\). 也就是说, 这一团操作等价于 \(r_1\gets r_1 + lr\), 也就等价于 \(r_1\gets r_1 \oplus u\).

那么对于一个深度为 \(d\) 的电路, 我们归纳生成了一套长为 \(O(4^d)\) 的指令, 这套指令只操作 \(3\) 个寄存器, 最后将 \(r_i\) 异或上了根节点的值.

等等, 但是我们是不是作弊了? 如果没有一个额外的计数器, 我们怎么知道接下来该执行哪一个指令呢? 所以其实我们并没有 \(O(1)\) 空间解决这个问题, 但是看起来这个做法确实比较强, 因为指令本身几乎和各 \(x_i\) 的值是没有关系的.

一个对这种计算模型的抽象是分支程序 (branching program). 这是一个类似于 DFA 的东西, 区别是它不是按顺序读字符的. 一个分支程序有两个参数, 一个是长度 \(L\), 一个是宽度 \(W\). 这个程序其实就是一个深度为 \(L\) 的 DAG, 每个节点上有一个编号 \(x_i\) 和两条出边, 一条是 \(0\), 一条是 \(1\). 走到这个节点之后, 会根据 \(x_i\) 的值走到对应的边上. 宽度限制的就是每一层最多有 \(W\) 个节点. 最后一层走到的节点决定是接受还是拒绝.

刚刚的构造用到了 \(3\) 个 bit, 其实也就是说明宽度 \(W = 2^3 = 8\) 足够了. 而 Barrington 定理说的大概是如下事实.

(一致) \(\mathsf{NC}^1\) 电路可以被一个大小为 \(n^{O(1)}\), 宽度不超过 \(5\) 的 (一致) 分支程序判定, 反之亦然.

这个定理的有趣之处在于, 它在某种意义上陈述了我们可以极致复用空间, 甚至只需要常数个状态用于转移!

Ian Mertz 做了如下类比. 众所周知有这样一个交换两个数的小技巧:

\[x\gets x\oplus y, \quad y\gets x\oplus y, \quad x\gets x\oplus y, \]

这个技巧摆脱了额外内存的使用, 虽然看起来有点像作弊, 但是接下来我们将看到一个更加具体的应用.

更一般的表达树求值 TreeEval 问题

我们考虑一个更一般的问题, 给定深度 \(O(\log n)\) 的一个表达式树, 区别是现在的字符集大小是 \(|\Sigma| = n^{O(1)}\) 的, 每个非叶节点有两个孩子, 节点自己还有一个 \(\Sigma \times \Sigma \to \Sigma\) 的表格, 表示左右子树结果的组合如何得到自己的结果.

如果直接暴力做, 由于栈的每一层都是 \(O(\log n)\) 的, 这样空间就成了 \(O(\log^2 n)\) 了.

然而, 类似 Barrington 定理的思路可以让我们在这个问题上真正地节省空间.

定理 (Cook, Mertz, 2024) \(\mathsf{TreeEval} \in \mathsf{SPACE}[\log n \cdot \log \log n]\).

我们的思路是设计一个指令序列, 使得这个指令序列只用到足够少的寄存器空间.

现在设 \(K \geq \log |\Sigma|\), 我们知道编码一个字符需要 \(K\) 个 bit. 接下来我们将任何一个节点上的运算关系 \(\Sigma \times \Sigma \to \Sigma\) 看做 \(\{0, 1\}^K \times \{0, 1\}^K \to \{0, 1\}^K\), 把函数拆位, 这其实是 \(K\) 个二进制数组

\[\{0, 1\}^{2K} \to \{0, 1\}. \]

为了模仿 Barrington 定理, 我们需要把它代数化, 也就是, 存在一个 \(2K\) 次多项式 \(F(x_1,\dots,x_K, y_1,\dots, y_K)\) 使得它在 \(\{0, 1\}^{2K}\) 上的取值和这个函数一样.

接下来, 考虑选取一个有限域 \(\mathbb F\), 我们将每个 bit 的 \(0, 1\) 就放在这个有限域里考虑. 我们的寄存器还是三个, 但是这次是三个向量 \(R_1, R_2, R_3 \in \mathbb F^K\).

对于给定的 \(\tau \in \mathbb F\)\(1\leq i\leq 3\), 树的节点 \(u\), 我们定义指令 \(P_{u, i}\)

\[R_i \gets R_i + \operatorname{Val}(u) \cdot \tau. \]

显然, 叶子处的指令是平凡的, 那么我们考虑如何生成非叶子节点的指令.

如果 \(\mathbb F\) 里有一个 \(M > 2K\) 次单位根 \(\omega\), 考虑

\[\frac 1 M \sum_{0\leq j < M} F(\omega^j R_2 + \operatorname{Val}(l), \omega^j R_3 + \operatorname{Val}(r) ), \]

由于 \(F\) 这个多项式是 \(2K\) 次的, 这个和其实就是 \(F( \operatorname{Val}(l), \operatorname{Val}(r))\). 也就是说, 我们可以用这个抵消掉寄存器本身的影响. 所以, 我们的指令 \(R_1 \gets R_1 + \tau \operatorname{Val}(u)\) 是如下过程:

对于 \(0\leq j< M\),

  • 递归执行 \(R_2 \gets R_2 + \operatorname{Val}(l)\). (也即 \(\tau = 1\))
  • 递归执行 \(R_3 \gets R_3 + \operatorname{Val}(r)\). (也即 \(\tau = 1\))
  • \(R_1 \gets R_1 + M^{-1} \tau F(R_2, R_3)\). (注意这里是有 \(K\) 个多项式各自逐点计算, 然后加上结果)
  • 递归执行 \(R_2 \gets R_2 + (-1) \operatorname{Val}(l)\). (也即 \(\tau = -1\))
  • 递归执行 \(R_3 \gets R_3 + (-1) \operatorname{Val}(r)\). (也即 \(\tau = -1\))
  • \(R_2 \gets \omega R_2\),
  • \(R_3 \gets \omega R_3\).

根据前面的单位根求和等式可知, 这个过程最后的 \(R_1\) 就是 \(R_1 + \tau \operatorname{Val}(u)\), 而 \(R_2, R_3\) 转了一圈, 之后就回来了.

为了让上面这个算法能工作, 我们只需要取一个大小合适的 \(|\mathbb F| = O(K)\). 注意寄存器有 \(3K\)\(\mathbb F\) 的元素, 一个 \(\mathbb F\) 编码的空间是 \(O(\log K)\), 也就是寄存器的总空间是 \(O(K\log K)\) 的.

而总共的指令数基本上取决于递归的代价, 层数是 \(d=O(\log n)\), 而递归分治数是 \(M \leq |\mathbb F| = O(K)\), 所以总共的指令数是 \(M^d = 2^{O(\log n\log \log n)}\) 的. 所以维护指令集本身的信息也只需要 \(O(\log n\log \log n)\) 的空间.

这确实是一个很漂亮的想法, 检验的时候会反复怀疑是不是真的. 以及, TreeEval 到底有没有 \(O(\log n)\) 空间的算法呢?

posted @ 2024-05-18 12:59  EntropyIncreaser  阅读(156)  评论(0编辑  收藏  举报