[CSP-J 2020] 表达式

GitHub跳转原题关系图返回列表

先按后缀表达式建树并求当前值,再从根向下传播“能否影响根”的标记,这样每个翻转询问都能 O(1) 回答。

OJ: luogu

题目 ID: P7073

难度:普及+/提高

标签:字符串思维

日期: 2026-06-19 21:43

题意

给出一个后缀表达式,以及每个变量的初始 0/10/1 取值。

每次询问翻转某一个变量的值,其余变量保持初始值不变,问整个表达式的新值。

思路

最直接的办法是每次询问都重新扫一遍整条后缀表达式。

先看一个可以直接验证想法的朴素解:

cpp
#include <bits/stdc++.h>
using namespace std;

vector<string> split_tokens(const string &s) {
    vector<string> tokens;
    int n = (int)s.size();
    int i = 0;
    while (i < n) {
        while (i < n && s[i] == ' ') {
            ++i;
        }
        if (i >= n) {
            break;
        }
        int j = i;
        while (j < n && s[j] != ' ') {
            ++j;
        }
        tokens.push_back(s.substr(i, j - i));
        i = j;
    }
    return tokens;
}

int evaluate_once(const vector<string> &tokens, const vector<int> &value, int flip_id) {
    vector<int> st;
    st.reserve(tokens.size());

    for (const string &token : tokens) {
        if (token[0] == 'x') {
            int id = stoi(token.substr(1));
            st.push_back(value[id] ^ (id == flip_id));
        } else if (token == "!") {
            int x = st.back();
            st.pop_back();
            st.push_back(x ^ 1);
        } else {
            int right = st.back();
            st.pop_back();
            int left = st.back();
            st.pop_back();
            if (token == "&") {
                st.push_back(left & right);
            } else {
                st.push_back(left | right);
            }
        }
    }
    return st.back();
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    string expr;
    getline(cin, expr);
    vector<string> tokens = split_tokens(expr);

    int n;
    cin >> n;
    vector<int> value(n + 1);
    for (int i = 1; i <= n; ++i) {
        cin >> value[i];
    }

    int q;
    cin >> q;
    while (q--) {
        int x;
        cin >> x;
        cout << evaluate_once(tokens, value, x) << '\n';
    }
    return 0;
}

brute.cpp 对每个询问都重新模拟一次后缀表达式求值,逻辑简单,但复杂度是 O(s×q)O(|s| \times q),显然不够。

关键观察:所有询问都基于同一组初始赋值,所以我们没必要每次都重算整棵表达式树。

建树

后缀表达式天然对应一棵表达式树,用栈就能建出来:

  • 遇到变量 x_i:创建一个叶子节点,把节点编号入栈。
  • 遇到一元运算符 !:从栈顶弹出一个节点作为孩子,创建非门节点,入栈。
  • 遇到二元运算符 &|:从栈顶弹出两个节点(先右后左),创建对应节点,入栈。

最后栈底剩下的就是根节点。

拿样例 x1 x2 & x3 | 举例,建树过程:

text
读 x1 → 栈: [1]       (节点1 = x1)
读 x2 → 栈: [1,2]     (节点2 = x2)
读 &  → pop 2,1 → 栈: [3]   (节点3 = &, 孩子 left=1, right=2)
读 x3 → 栈: [3,4]     (节点4 = x3)
读 |  → pop 4,3 → 栈: [5]   (节点5 = |, 孩子 left=3, right=4)

最终得到的 节点5 就是根 |,它的左孩子是 & 节点,右孩子是 x3

第一趟 DFS:自底向上求值

dfs1(u) 递归求节点 u 的值:

  • 叶子节点(变量):直接返回 init_val[var_id]
  • 非门:递归求孩子的值,取反。
  • 与门:递归求左右孩子的值,lval & rval
  • 或门:递归求左右孩子的值,lval | rval

这一趟结束后,每个节点都知道了自己的当前值,根节点的值就是整个表达式的值。

第二趟 DFS:自顶向下传播影响

核心问题是:翻转某个变量后,根的值会变吗?

答案取决于这个变量的改变能否沿着表达式树一路传到根。这就是 dfs2(u, about) 做的事。

about 表示“当前节点 u 的值发生变化时,是否会影响根节点的值”。

传播规则:

  • 变量节点(叶子):直接记录 can_affect[u] = about——这就是最终答案。
  • 非门 !u:把 about 原样传递给子节点。因为 ! 只取反,孩子变了,非门的结果一定变。
  • 与门 u & v:这里有关键的短路逻辑:
    • 如果 about == false(整个 & 节点不影响根),两个孩子更不可能影响根,直接传 false
    • 如果 about == true,左孩子是否能影响根?答案是:只有当右孩子的当前值为 1 时。因为 0 & x = 0 已经被右路的 0 卡死,只有右路是 11 & 1 = 1 这个 1 才会随左路翻转。
  • 或门 u | v:逻辑对称:
    • 如果 about == false,两个孩子都传 false
    • 如果 about == true,左孩子是否能影响根?答案是:只有当右孩子的当前值为 0 时。因为 1 | x = 1 已经被右路的 1 卡死,只有右路是 00 | 0 = 0 这个 0 才会随左路翻转。

直观理解:

about 理解成一根从根放下来的绳子。每个门要么让绳子穿过去,要么剪断它。

  • ! 永远让绳子通过。
  • & 只有当另一边1 时才让绳子通过。如果另一边是 0,绳子被卡断。
  • | 只有当另一边0 时才让绳子通过。如果另一边是 1,绳子被卡断。

最后一根绳子连到某个变量叶子节点上,就说明翻转这个变量能改变根的结果。

回答询问

预处理出 can_affect[] 后,每个询问就是 O(1):

text
如果 can_affect[var_pos[x]] == true:
    输出 root_val ^ 1
否则:
    输出 root_val

因为整个表达式树的所有输入都是确定的,翻转一个变量只会让路径上的节点值取反(如果它确实能影响根的话),所以根的值也一定取反,不存在其他情况。

代码

cpp
/**
 * Author by Rainboy blog: https://rainboylv.com github: https://github.com/rainboylvx
 * rbook: -> https://rbook.roj.ac.cn  https://rbook2.roj.ac.cn
 * rainboy的学习导航网站: https://idx.roj.ac.cn
 * create_at: 2025-11-28 15:41
 * update_at: 2025-11-28 15:41
 */
/*
 * 题目:[CSP-J 2020] 表达式 (luogu 7073)
 * 核心思路:
 * 1. 表达式为后缀表达式,通过栈将其转化为表达式树。
 * 2. 第一次 DFS (dfs1):从下往上求出所有节点原本的值。
 * 3. 第二次 DFS (dfs2):从上往下传递"当前节点的改变是否会影响最终结果"。
 *    - 对于非门 (!),影响直接向下传递。
 *    - 对于与门 (&),如果另一半的值为 1,则本侧的改变会影响整体结果;否则被短路。
 *    - 对于或门 (|),如果另一半的值为 0,则本侧的改变会影响整体结果;否则被短路。
 * 4. 预处理结束后,针对每个查询只需 O(1) 查表。
 */

#include <iostream>
#include <string>
#include <vector>
#include <stack>
using namespace std;

// ===== 输入数据 =====
const int MAXN = 1e6+5;
int n, q;
int init_val[MAXN];
vector<string> tokens; // 后缀表达式的所有 token

// ===== 表达式树 =====
struct Node {
    char type; // 'x' 变量, '!' 非门, '&' 与门, '|' 或门
    int lch, rch;
    int var_id;
    int val;
} node[MAXN];
int node_cnt = 0;

int var_node[MAXN]; // 变量 x_i 对应的节点编号
stack<int> stk;

// ===== 预处理结果 =====
bool can_affect[MAXN]; // 该节点的变化是否影响根节点

// dfs1: 从下至上求出每个节点的值
int dfs1(int u) {
    if (node[u].type == 'x') {
        return node[u].val = init_val[node[u].var_id];
    }
    if (node[u].type == '!') {
        int child_val = dfs1(node[u].lch);
        return node[u].val = child_val ^ 1;
    }
    int lval = dfs1(node[u].lch);
    int rval = dfs1(node[u].rch);
    if (node[u].type == '&') {
        return node[u].val = lval & rval;
    }
    return node[u].val = lval | rval;
}

// dfs2: 从根向下传播"影响标记"
// affects_root: 当前节点的变化是否会影响根节点
void dfs2(int u, bool affects_root) {
    can_affect[u] = affects_root;
    if (node[u].type == 'x') return;
    if (!affects_root) {
        dfs2(node[u].lch, false);
        if (node[u].type != '!')
            dfs2(node[u].rch, false);
        return;
    }
    if (node[u].type == '!') {
        dfs2(node[u].lch, true);
        return;
    }
    int lch = node[u].lch, rch = node[u].rch;
    if (node[u].type == '&') {
        dfs2(lch, node[rch].val == 1);
        dfs2(rch, node[lch].val == 1);
    } else {
        dfs2(lch, node[rch].val == 0);
        dfs2(rch, node[lch].val == 0);
    }
}

// 读入表达式所有 token 和变量个数 n
void init_tokens() {
    string tok;
    while (cin >> tok) {
        if (tok[0] >= '0' && tok[0] <= '9') {
            n = stoi(tok);
            break; // 表达式 token 只含 x... ! & |,遇到数字就是 n
        }
        tokens.push_back(tok);
    }
}

// 根据全局 tokens 建立表达式树,返回根节点编号
int build_tree() {
    for (const string &tok : tokens) {
        if (tok[0] == 'x') {
            int id = stoi(tok.substr(1));
            node[++node_cnt] = {'x', 0, 0, id, 0};
            var_node[id] = node_cnt;
            stk.push(node_cnt);
        } else if (tok[0] == '!') {
            int child = stk.top(); stk.pop();
            node[++node_cnt] = {'!', child, 0, 0, 0};
            stk.push(node_cnt);
        } else {
            int right = stk.top(); stk.pop();
            int left  = stk.top(); stk.pop();
            node[++node_cnt] = {tok[0], left, right, 0, 0};
            stk.push(node_cnt);
        }
    }
    return stk.top();
}

int main() {
    ios::sync_with_stdio(false);
    cin.tie(nullptr);

    init_tokens();
    for (int i = 1; i <= n; i++) {
        cin >> init_val[i];
    }

    int root = build_tree();
    dfs1(root);
    dfs2(root, true);

    cin >> q;
    int root_val = node[root].val;
    while (q--) {
        int x;
        cin >> x;
        if (can_affect[var_node[x]])
            cout << (root_val ^ 1) << '\n';
        else
            cout << root_val << '\n';
    }
    return 0;
}

复杂度

建树、求值、影响传播都是线性的,所以预处理复杂度是 O(s)O(|s|),每个询问 O(1)O(1),总复杂度是 O(s+q)O(|s| + q),空间复杂度是 O(s)O(|s|)

总结

这题的难点不在后缀表达式本身,而在看出"翻转一个变量是否有用"可以预处理。把"每次重算整式"改成"先判断哪些变量能影响根",复杂度就从 O(s×q)O(|s| \times q) 降到 O(s+q)O(|s| + q)

另一个重要的思维是短路传播:二叉树中一个节点的值是否受子树影响,往往取决于另一个子树的值。这种"一侧卡死另一侧"的模式在表达式求值、决策树剪枝中都可能出现。

一图流解析

这张图把本题的建模、关键转移、实现检查和训练方法压缩到一页,适合读完正文后复盘。

一图流解析