摘要: 本题是 2020 CSP-J 的第三题,难度较高(为该年最难的一题)。题意:给定一个后缀逻辑表达式,包含与
&、或|、非!三种运算,每个变量初始值已知。需要快速回答 q q q 次独立查询:临时取反某个变量后,整个表达式的值是多少?暴力求值会超时,核心解决方案是利用栈构建表达式树 ,再通过一次自顶向下的 DFS ,结合短路运算性质 预处理每个变量的"敏感度"------该变量改变能否影响根节点的值。预处理后,每次查询只需 O ( 1 ) O(1) O(1) 查表即可得出答案。
CSP-J 2020-T3 表达式
题目描述
小 C 热衷于学习数理逻辑。有一天,他发现了一种特别的逻辑表达式。在这种逻辑表达式中,所有操作数都是变量,且它们的取值只能为 0 0 0 或 1 1 1,运算从左往右进行。如果表达式中有括号,则先计算括号内的子表达式的值。特别的,这种表达式有且仅有以下几种运算:
- 与运算:
a & b。当且仅当 a a a 和 b b b 的值都为 1 1 1 时,该表达式的值为 1 1 1。其余情况该表达式的值为 0 0 0。 - 或运算:
a | b。当且仅当 a a a 和 b b b 的值都为 0 0 0 时,该表达式的值为 0 0 0。其余情况该表达式的值为 1 1 1。 - 取反运算:
!a。当且仅当 a a a 的值为 0 0 0 时,该表达式的值为 1 1 1。其余情况该表达式的值为 0 0 0。
小 C 想知道,给定一个逻辑表达式和其中每一个操作数的初始取值后,再取反某一个操作数的值时,原表达式的值为多少。
为了化简对表达式的处理,我们有如下约定:
表达式将采用后缀表达式的方式输入。
后缀表达式的定义如下:
- 如果 E E E 是一个操作数,则 E E E 的后缀表达式是它本身。
- 如果 E E E 是 E 1 op E 2 E_1~\texttt{op}~E_2 E1 op E2 形式的表达式,其中 op \texttt{op} op 是任何二元操作符,且优先级不高于 E 1 E_1 E1 、 E 2 E_2 E2 中括号外的操作符,则 E E E 的后缀式为 E 1 ′ E 2 ′ op E_1' E_2' \texttt{op} E1′E2′op,其中 E 1 ′ E_1' E1′ 、 E 2 ′ E_2' E2′ 分别为 E 1 E_1 E1、 E 2 E_2 E2 的后缀式。
- 如果 E E E 是 E 1 E_1 E1 形式的表达式,则 E 1 E_1 E1 的后缀式就是 E E E 的后缀式。
同时为了方便,输入中:
- 与运算符(&)、或运算符(|)、取反运算符(!)的左右均有一个空格 ,但表达式末尾没有空格。
- 操作数由小写字母 x x x 与一个正整数拼接而成,正整数表示这个变量的下标。例如:
x10,表示下标为 10 10 10 的变量 x 10 x_{10} x10。数据保证每个变量在表达式中出现恰好一次。
输入格式
第一行包含一个字符串 s s s,表示上文描述的表达式。
第二行包含一个正整数 n n n,表示表达式中变量的数量。表达式中变量的下标为 1 , 2 , ⋯ , n 1,2, \cdots , n 1,2,⋯,n。
第三行包含 n n n 个整数,第 i i i 个整数表示变量 x i x_i xi 的初值。
第四行包含一个正整数 q q q,表示询问的个数。
接下来 q q q 行,每行一个正整数,表示需要取反的变量的下标。注意,每一个询问的修改都是临时的,即之前询问中的修改不会对后续的询问造成影响。
数据保证输入的表达式合法。变量的初值为 0 0 0 或 1 1 1。
输出格式
输出一共有 q q q 行,每行一个 0 0 0 或 1 1 1,表示该询问下表达式的值。
输入输出样例 #1
输入 #1
x1 x2 & x3 |
3
1 0 1
3
1
2
3
输出 #1
1
1
0
输入输出样例 #2
输入 #2
x1 ! x2 x4 | x3 x5 ! & & ! &
5
0 1 0 1 1
3
1
3
5
输出 #2
0
1
1
说明/提示
样例 1 解释
该后缀表达式的中缀表达式形式为 ( x 1 and x 2 ) or x 3 (x_1 \operatorname{and} x_2) \operatorname{or} x_3 (x1andx2)orx3。
- 对于第一次询问,将 x 1 x_1 x1 的值取反。此时,三个操作数对应的赋值依次为 0 0 0, 0 0 0, 1 1 1。原表达式的值为 ( 0 & 0 ) ∣ 1 = 1 (0\&0)|1=1 (0&0)∣1=1。
- 对于第二次询问,将 x 2 x_2 x2 的值取反。此时,三个操作数对应的赋值依次为 1 1 1, 1 1 1, 1 1 1。原表达式的值为 ( 1 & 1 ) ∣ 1 = 1 (1\&1)|1=1 (1&1)∣1=1。
- 对于第三次询问,将 x 3 x_3 x3 的值取反。此时,三个操作数对应的赋值依次为 1 1 1, 0 0 0, 0 0 0。原表达式的值为 ( 1 & 0 ) ∣ 0 = 0 (1\&0)|0=0 (1&0)∣0=0。
样例 2 解释
该表达式的中缀表达式形式为 ( not x 1 ) and ( not ( ( x 2 or x 4 ) and ( x 3 and ( not x 5 ) ) ) ) (\operatorname{not}x_1)\operatorname{and}(\operatorname{not}((x_2\operatorname{or}x_4)\operatorname{and}(x_3\operatorname{and}(\operatorname{not}x_5)))) (notx1)and(not((x2orx4)and(x3and(notx5))))。
数据规模与约定
- 对于 20 % 20\% 20% 的数据,表达式中有且仅有与运算(&)或者或运算(|)。
- 对于另外 30 % 30\% 30% 的数据, ∣ s ∣ ≤ 1000 |s| \le 1000 ∣s∣≤1000, q ≤ 1000 q \le 1000 q≤1000, n ≤ 1000 n \le 1000 n≤1000。
- 对于另外 20 % 20\% 20% 的数据,变量的初值全为 0 0 0 或全为 1 1 1。
- 对于 100 % 100\% 100% 的数据, 1 ≤ ∣ s ∣ ≤ 1 × 10 6 1 \le |s| \le 1 \times 10^6 1≤∣s∣≤1×106, 1 ≤ q ≤ 1 × 10 5 1 \le q \le 1 \times 10^5 1≤q≤1×105, 2 ≤ n ≤ 1 × 10 5 2 \le n \le 1 \times 10^5 2≤n≤1×105。
其中, ∣ s ∣ |s| ∣s∣ 表示字符串 s s s 的长度。
思路要点
这道题有一个特别长的逻辑公式(包含与 &、或 |、非 !),公式里每一个变量都有一个初始的 0 或 1。 我们现在要做两件事:
-
先算出一开始整个大表达式的值是多少。
-
接着会提出 q q q次查询,每次问:"如果我仅仅把变量 x i x_i xi的值临时反过来( 0 0 0变 1 1 1, 1 1 1变 0 0 0),整个大表达式的最终结果会改变吗?"(注意:每次提问都是互相独立的,问完就恢复原状)。
-
题目外壳: 逻辑表达式的求值,以及对单个变量取反后的结果询问。
-
语法与算法本质: 栈(Stack)构建表达式树 + 树上自顶向下 DFS(利用短路运算性质进行敏感度标记)。
关键思路
排除暴力解法
如果不动脑筋,每次询问都重新计算一遍整个表达式。表达式长度极长( ∣ s ∣ ≤ 10 6 \vert{}s\vert{} \le 10^6 ∣s∣≤106),询问次数极大( q ≤ 10 5 q \le 10^5 q≤105),暴力做法的时间复杂度为 O ( q × ∣ s ∣ ) O(q \times \vert{}s\vert{}) O(q×∣s∣),计算量高达 10 11 10^{11} 1011级别,绝对会超时(TLE)。
引入树形结构
后缀表达式天然符合树的后序遍历。我们可以用一个栈 把它还原成一棵表达式树:
-
叶子节点 :变量 x i x_i xi。
-
非叶子节点 :操作符
&、|、!。
如此一来,计算表达式的值就变成了"自底向上"的树形递推。
"短路运算"的伟大性质
什么情况下修改某个叶子节点,根节点的值会改变?什么情况下修改某个叶子节点,根节点的值不变 ?答案:短路运算!
-
对于与运算
A & B:如果 A = 0 A = 0 A=0,那么无论 B B B 是 0 0 0 还是 1 1 1,结果必定是 0 0 0!此时我们称 B B B 的变化被 A A A 短路了( B B B 对全局结果毫无影响)。 -
对于或运算
A | B:如果 A = 1 A = 1 A=1,那么无论 B B B 是 0 0 0 还是 1 1 1,结果必定是 1 1 1!同样, B B B 对全局结果毫不在意。
DFS:从"自底向上"转为"自顶向下"(标记贡献法)
既然我们已经通过一次后序遍历算出了每个子树的初始值,我们可以通过一次 DFS 预处理,确定每个节点在逻辑电路中的"敏感度"(即:该节点值的改变是否会导致最终根节点输出的改变)。
-
我们定义布尔数组
c[u]标记:树上第 u u u个节点如果发生改变,是否会导致根节点的值改变?-
c u = 1 cu = 1 cu=1:表示节点 u u u 是敏感的 (一旦 u u u 的值改变,根节点结果必变)。
-
c u = 0 cu = 0 cu=0:表示节点 u u u 是钝化的 (受其兄弟节点的掩蔽,无论 u u u 怎么变,根节点结果不变)。
-
推导规则如下:
-
初始状态: 根节点
c[rt] = 1(根节点即自身,必敏感)。 -
取反运算
!: 单目没有掩蔽效应,直接继承父节点的敏感度: c l c = c u clc = cu clc=cu。cpp[ 父节点 u ] <-- 此时正在处理的节点 x (操作符 '!') / / (取反单目,只有左孩子,没有右孩子) [ 左孩子 lc ] -
与运算
&: 只有当父节点敏感,且兄弟节点不引发"短路掩蔽"时,左(右)孩子才敏感。cpp[ 父节点 u ] (若 c[u]=1,则父节点处于"激活"状态) / \ / \ [ 左孩子 lc ] [ 右孩子 rc ] (待计算) (已知其值 tr[rc].x)-
如果右孩子
tr[rc].x是 1 1 1:它对&运算没有"短路"能力,此时左孩子lc的生死决定了整个表达式的结果。所以lc继承了u的敏感度。 -
如果右孩子
tr[rc].x是 0 0 0:它把&运算"短路"锁死在 0 0 0 了,无论左孩子lc怎么变,整体结果都没变。所以lc的敏感度被置为 0 0 0(钝化)。 -
因此左孩子敏感条件:父节点敏感 ( c u = 1 cu=1 cu=1) ∩ \cap ∩ 右孩子不短路 ( t r r c . x = 1 trrc.x = 1 trrc.x=1)。代码实现:
c[lc] = c[u] & tr[rc].x; -
同理,右孩子敏感条件:父节点敏感 ( c u = 1 cu=1 cu=1) ∩ \cap ∩ 左孩子不短路 ( t r l c . x = 1 trlc.x = 1 trlc.x=1)。代码实现:
c[rc] = c[u] & tr[lc].x;
-
-
或运算
|: 同样,只有当父节点敏感,且兄弟节点不引发"短路掩蔽"时,孩子才敏感。cpp[ 父节点 u ] (若 c[u]=1,则父节点处于"激活"状态) / \ / \ [ 左孩子 lc ] [ 右孩子 rc ] (待计算) (已知其值 tr[rc].x)-
如果右孩子
tr[rc].x是 0 0 0:它对|运算没有"短路"能力,此时左孩子lc的生死决定了整个表达式的结果。所以lc继承了u的敏感度。 -
如果右孩子
tr[rc].x是 1 1 1:它把|运算"短路"锁死在 1 1 1了,无论左孩子lc怎么变,整体结果都没变。所以lc的敏感度被置为 0 0 0(钝化)。 -
因此左孩子敏感条件:父节点敏感 ( c u = 1 cu=1 cu=1) ∩ \cap ∩右孩子不短路 ( t r r c . x = 0 trrc.x = 0 trrc.x=0)。代码实现:
c[lc] = c[u] & (!tr[rc].x); -
同理,右孩子敏感条件:父节点敏感 ( c u = 1 cu=1 cu=1) ∩ \cap ∩左孩子不短路 ( t r l c . x = 0 trlc.x = 0 trlc.x=0)。代码实现:
c[rc] = c[u] & (!tr[lc].x);
总结:不管是
&还是|,其核心逻辑其实都是:"当前节点的敏感度 = 父节点的敏感度 AND (兄弟节点是否放行)"。-
如果你是
&门,兄弟节点必须是 1 1 1才放行; -
如果你是
|门,兄弟节点必须是 0 0 0才放行。
通过这一步,我们成功将 q q q 次询问的复杂计算,转化为了 O ( 1 ) O(1) O(1) 的直接查表,将整体时间复杂度优化到了最优。
-
解题步骤
我们以样例输入 1 为例,模拟代码的执行过程:
输入数据: s = "x1 x2 & x3 |",n = 3, 变量初值为 a[1] = 1, a[2] = 0, a[3] = 1
-
变量定义与初始化:
-
定义
tr数组存储表达式树,st为辅助建树的栈。 -
a[id]存变量初值,b[id]存变量 x i d x_{id} xid在树中的节点编号,c[u]存节点 u u u是否能够影响总结果。
-
-
扫描后缀表达式并建树与求值(
init()函数):-
读到
x1:创建节点 1 1 1,值tr[1].x = a[1] = 1,记录映射b[1] = 1,节点 1 1 1入栈。 -
读到
x2:创建节点 2 2 2,值tr[2].x = a[2] = 0,记录映射b[2] = 2,节点 2 2 2入栈。 -
读到
&:弹出栈顶 2 2 2(右孩子)和 1 1 1(左孩子)。创建节点 3 3 3,算得其值tr[3].x = 1 & 0 = 0。将节点 3 3 3入栈。 -
读到
x3:创建节点 4 4 4,值tr[4].x = a[3] = 1,记录映射b[3] = 4,节点 4 4 4入栈。 -
读到
|:弹出栈顶 4 4 4(右孩子)和 3 3 3(左孩子)。创建节点 5 5 5,算得其值tr[5].x = 0 | 1 = 1。节点 5 5 5入栈。 -
循环结束,整棵树的根节点为
rt = 5,此时整个表达式的初始结果就是tr[5].x = 1。 -
我们把样例 1 的表达式
x1 x2 & x3 |(即中缀的(x1 & x2) | x3)画出完整的树如下:cpp⑤ [ | ] (值: 0 | 1 = 1 ) <-- 根节点 / \ / \ ③ [ & ] (值: 1 & 0 = 0 ) ④ [ x3 ] (值: 1) / \ / \ ① [ x1 ] (值: 1) ② [ x2 ] (值: 0)
-
-
dfs(rt)函数:自顶向下标记敏感度数组c[]-
先把根节点设为敏感:
c[5] = 1。 -
遍历节点 5 5 5(
|运算,左孩子为 3 3 3,右孩子为 4 4 4):-
计算左孩子的敏感度:
c[3] = c[5] & (!tr[4].x)。因为右孩子tr[4].x的值是 1 1 1,在或运算中把左边短路 了!所以计算结果c[3] = 1 & (!1) = 0(节点 3 3 3怎么都不影响全局了)。 -
计算右孩子的敏感度:
c[4] = c[5] & (!tr[3].x)。由于左孩子tr[3].x的值是 0 0 0,没短路,算得c[4] = 1 & (!0) = 1(节点 4 4 4对根敏感)。
-
-
继续递归节点 3 3 3(
&运算,左孩子 1 1 1,右孩子 2 2 2):- 因为它们父亲的敏感度
c[3]已经变成了 0 0 0,不管它俩怎么折腾,根据公式求出c[1] = 0,c[2] = 0(完全被隔绝,对大局无影响)。
- 因为它们父亲的敏感度
-
递归叶子节点 1 , 2 , 4 1, 2, 4 1,2,4: 到达叶子,结束递归,回溯。
-
DFS 结束,得到最终敏感度:
-
变量 x 1 x_1 x1(对应节点 1 1 1):
c[b[1]] = 0(不敏感) -
变量 x 2 x_2 x2(对应节点 2 2 2):
c[b[2]] = 0(不敏感) -
变量 x 3 x_3 x3(对应节点 4 4 4):
c[b[3]] = 1(敏感)
-
-
-
O ( 1 ) O(1) O(1)极速回答询问:
-
询问 1 1 1(反转 x 1 x_1 x1): 查表发现
c[b[1]] == 0,不影响结果,直接输出原树根结果:1。 -
询问 2 2 2(反转 x 2 x_2 x2): 查表发现
c[b[2]] == 0,同样不影响,输出原结果:1。 -
询问 3 3 3(反转 x 3 x_3 x3): 查表发现
c[b[3]] == 1,结果一定会改变!直接将原结果取反:!tr[rt].x,输出:0。
-
本题易错点
-
坑一:变量下标可能是多位数
要点提醒 :题目输入虽然写着
x1,但也可能会出现x10086。扫描字符串时切忌只读一个数字字符就停止!必须像代码中的g()函数一样,用while循环提取完整的数字下标。 -
坑二:栈出栈时的顺序问题
要点提醒:在处理二元运算符时,栈顶第一个出栈的是右操作数 (
r),第二个才是左操作数 (l)。虽然对于&和|来说左右颠倒结果不受影响(满足交换律),但如果碰到减法或除法,颠倒就会大错特错,保持良好严谨的编程习惯很重要。 -
坑三:DFS 传递标记的时候漏掉父节点的
c[x]要点提醒: 写逻辑时,有时会直觉认为:"只要我兄弟节点为 0 0 0(在或运算中),我就是敏感的!" ------这是大忌!如果父亲本身就被上一层短路了 ,那就算你能影响老爸,也很难影响整个根节点。所以必须带有
c[x] &这个前置限制。
参考代码
cpp
#include <bits/stdc++.h>
#define maxn 1000005 // 树的节点数最多和字符串长度相等
using namespace std;
string s;
int n, k, len, q, rt;
int a[maxn], b[maxn], c[maxn]; // a 存初值,b 存变量对应树节点编号,c 存该节点对根是否有影响(敏感度)
struct Node {
char c; // 节点类型:'x'代表变量,'&' '|' '!'代表操作符
int lc, rc; // 左、右孩子编号
bool x; // 该子树计算得到的布尔值(0或1)
} tr[maxn];
// 引用传参解析数字:从字符串中提取下标(例如 "x100" 提取出 100)
int g(int &p) {
int t = 0;
while (p < len && s[p] >= '0' && s[p] <= '9') {
t = t * 10 + (s[p] - '0');
p++;
}
return t;
}
// 二元运算求解
bool f(char op, int l, int r) {
if (op == '&') return tr[l].x & tr[r].x;
else return tr[l].x | tr[r].x;
}
// 用栈将后缀表达式转化成一棵树,并自底向上计算各子树的值
void init() {
stack<int> st;
len = s.size();
for (int i = 0; i < len; i++) {
if (s[i] == ' ') continue;
if (s[i] == 'x') { // 遇到操作数(变量)
int id = g(++i); // 解析出变量下标
k++;
tr[k].c = 'x';
tr[k].x = a[id]; // 赋初值
b[id] = k; // 记录第id个变量对应的节点编号是 k
st.push(k);
}
else if (s[i] == '!') { // 遇到单目运算符:非
int t = st.top(); st.pop();
k++;
tr[k].c = '!';
tr[k].lc = t;
tr[k].x = !tr[t].x; // 逻辑取反
st.push(k);
}
else if (s[i] == '&' || s[i] == '|') { // 遇到双目运算符
int r = st.top(); st.pop(); // 注意:先出栈的是右操作数
int l = st.top(); st.pop();
k++;
tr[k].c = s[i];
tr[k].lc = l;
tr[k].rc = r;
tr[k].x = f(s[i], l, r);
st.push(k);
}
}
rt = st.top(); // 最终栈顶就是树根
}
// 自顶向下 DFS:计算各个节点的值被修改后,是否能影响到全局根节点
void dfs(int x) {
if (tr[x].c == 'x') return; // 到达叶子节点,终止递归
int l = tr[x].lc, r = tr[x].rc;
if (tr[x].c == '!') { // '!'没有短路功能,孩子直接继承父亲的影响力
c[l] = c[x]; dfs(l);
}
else if (tr[x].c == '&') { // 与运算性质:当另边为 1 时,变化才能影响父节点;同时父节点本身必须要能影响根
c[l] = c[x] & tr[r].x; dfs(l);
c[r] = c[x] & tr[l].x; dfs(r);
}
else if (tr[x].c == '|') { // 或运算性质:当另边为 0 时,变化才能影响父节点;同时父节点本身必须要能影响根
c[l] = c[x] & (!tr[r].x); dfs(l);
c[r] = c[x] & (!tr[l].x); dfs(r);
}
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
getline(cin, s);
cin >> n;
for (int i = 1; i <= n; i++) {
cin >> a[i];
}
init(); // 建树初始化
c[rt] = 1; // 根节点肯定能改变自己的结果
dfs(rt); // 下推敏感度标记
cin >> q;
while (q--) {
int p;
cin >> p;
// 如果变量 p 对应的节点是敏感的(c[b[p]] == 1),结果取反;否则保持原树根结果
int ans = c[b[p]] ? (!tr[rt].x) : tr[rt].x;
cout << ans << "\n";
}
return 0;
}