P7073 表达式
继续快乐的写题(*^▽^*)
在经历了颓废——>停课——>做题+不会——>看题解+理解 的四天之后,就在今天,我终于把这道题(看懂了...)
(* ̄︶ ̄)
当然,虽然我不会做这道题,但是我可以学会啊(自我安慰)
好啦,话不多说,快点来一起看一下这道题吧!
题目描述
小 C 热衷于学习数理逻辑。有一天,他发现了一种特别的逻辑表达式。在这种逻辑表达式中,所有操作数都是变量,且它们的取值只能为 000 或 111,运算从左往右进行。如果表达式中有括号,则先计算括号内的子表达式的值。特别的,这种表达式有且仅有以下几种运算:
- 与运算:
a & b
。当且仅当 aaa 和 bbb 的值都为 111 时,该表达式的值为 111。其余情况该表达式的值为 000。 - 或运算:
a | b
。当且仅当 aaa 和 bbb 的值都为 000 时,该表达式的值为 000。其余情况该表达式的值为 111。 - 取反运算:
!a
。当且仅当 aaa 的值为 000 时,该表达式的值为 111。其余情况该表达式的值为 000。
小 C 想知道,给定一个逻辑表达式和其中每一个操作数的初始取值后,再取反某一个操作数的值时,原表达式的值为多少。
为了化简对表达式的处理,我们有如下约定:
表达式将采用后缀表达式的方式输入。
后缀表达式的定义如下:
- 如果 EEE 是一个操作数,则 EEE 的后缀表达式是它本身。
- 如果 EEE 是 E1 op E2E_1~\texttt{op}~E_2E1 op E2 形式的表达式,其中 op\texttt{op}op 是任何二元操作符,且优先级不高于 E1E_1E1 、E2E_2E2 中括号外的操作符,则 EEE 的后缀式为 E1′E2′opE_1' E_2' \texttt{op}E1′E2′op,其中 E1′E_1'E1′ 、E2′E_2'E2′ 分别为 E1E_1E1、E2E_2E2 的后缀式。
- 如果 EEE 是 E1E_1E1 形式的表达式,则 E1E_1E1 的后缀式就是 EEE 的后缀式。
同时为了方便,输入中:
- 与运算符(&)、或运算符(|)、取反运算符(!)的左右均有一个空格,但表达式末尾没有空格。
- 操作数由小写字母 xxx 与一个正整数拼接而成,正整数表示这个变量的下标。例如:
x10
,表示下标为 101010 的变量 x10x_{10}x10。数据保证每个变量在表达式中出现恰好一次。
输入格式
第一行包含一个字符串 sss,表示上文描述的表达式。
第二行包含一个正整数 nnn,表示表达式中变量的数量。表达式中变量的下标为 1,2,⋯ ,n1,2, \cdots , n1,2,⋯,n。
第三行包含 nnn 个整数,第 iii 个整数表示变量 xix_ixi 的初值。
第四行包含一个正整数 qqq,表示询问的个数。
接下来 qqq 行,每行一个正整数,表示需要取反的变量的下标。注意,每一个询问的修改都是临时的,即之前询问中的修改不会对后续的询问造成影响。
数据保证输入的表达式合法。变量的初值为 000 或 111。
输出格式
输出一共有 qqq 行,每行一个 000 或 111,表示该询问下表达式的值。
输入输出样例
x1 x2 & x3 | 3 1 0 1 3 1 2 3
1 1 0
x1 ! x2 x4 | x3 x5 ! & & ! & 5 0 1 0 1 1 3 1 3 5
0 1 1
说明/提示
样例 1 解释
该后缀表达式的中缀表达式形式为 (x1&x2)∣x3(x_1 \& x_2) | x_3(x1&x2)∣x3。
- 对于第一次询问,将 x1x_1x1 的值取反。此时,三个操作数对应的赋值依次为 000,000,111。原表达式的值为 (0&0)∣1=1(0\&0)|1=1(0&0)∣1=1。
- 对于第二次询问,将 x2x_2x2 的值取反。此时,三个操作数对应的赋值依次为 111,111,111。原表达式的值为 (1&1)∣1=1(1\&1)|1=1(1&1)∣1=1。
- 对于第三次询问,将 x3x_3x3 的值取反。此时,三个操作数对应的赋值依次为 111,000,000。原表达式的值为 (1&0)∣0=0(1\&0)|0=0(1&0)∣0=0。
样例 2 解释
该表达式的中缀表达式形式为 (!x1)&(!((x2∣x4)&(x3&(!x5))))(!x_1)\&(!((x_2|x_4)\&(x_3\&(!x_5))))(!x1)&(!((x2∣x4)&(x3&(!x5))))。
数据规模与约定
- 对于 20%20\%20% 的数据,表达式中有且仅有与运算(&)或者或运算(|)。
- 对于另外 30%30\%30% 的数据,∣s∣≤1000|s| \le 1000∣s∣≤1000,q≤1000q \le 1000q≤1000,n≤1000n \le 1000n≤1000。
- 对于另外 20%20\%20% 的数据,变量的初值全为 000 或全为 111。
- 对于 100%100\%100% 的数据,1≤∣s∣≤1×1061 \le |s| \le 1 \times 10^61≤∣s∣≤1×106,1≤q≤1×1051 \le q \le 1 \times 10^51≤q≤1×105,2≤n≤1×1052 \le n \le 1 \times 10^52≤n≤1×105。
其中,∣s∣|s|∣s∣ 表示字符串 sss 的长度。
ps:题面依旧存在bug,但是作为一个粗糙的汉纸,本人表示不介意啦~~
思考过程:
一开始当然是想正解啦,经过我长久的思考,终于找出来一种方法,但是因为某些神奇知识的缺乏以及个人能力不足,这个方法貌似是错误的....
所以这个题没有思路的原因到底是什么呢?其一在于不知道(或者更进一步说)是没能想到后缀表达式是要用栈来读入读出的;其二,这道题首先在读入方面就是一个很大的难点,而我个人并没有想出什么很好的读入方法;其三,也能够算是我在做这道题时唯一一点令我还算满意的地方,因为在之前,我就做过一道二进制题目,那道题目和这道题相似的地方在于都是对一个元素进行若干次操作,询问操作结束后元素的值,当时在做题的时候想到了这一点,所以思考方向就变成了:可不可以仿照那道题的思路,每次更改一个值,我们判断这个值的更改对于最后结果是否有影响,如果有影响,又因为我们所有输入的元素只有0 1 ,所以影响也不过就是把最后的结果从0变成1,或者从1变成0。依照这样的思路,我顺利推出了剩下的步骤,但是在关于数据的保存上面,以及运算顺序方面的不了解,导致解题失败,但是值得庆幸的是,这道题正解的解题思路和我本人的想法大致相同,很开心啊~~
下面就来看一下正解的思路吧:
其实正解的思路在上面已经说得差不多了,所以我干脆直接把那位巨神的解释再复制一遍,算是加深理解?
首先输入的一坨字符串要先解析,利用栈来建表达式树,这就是一个小模拟,相信正常人都会吧。 对于非运算,我直接用德·摩根定律,下传标记让子树信息都反一下。(其实没必要,当初这样写是因为觉得每个结点都二叉比较美,方便后续处理) 题目里有个信息是“每个变量在表达式中出现恰好一次”,而每个询问只改变一个变量的值,这对原答案来说就产生两个可能:变或不变。这听起来是一句废话,
其实蕴含的意思是:有些变量对整个表达式其决定作用,其改变则原答案也改变;有些变量对整个表达式根本没用,其变不变原答案都不变。 说明白一点,就是1 & x = x ,0 | x= x ,这两个公式里的 x 就起了决定性作用,而 0 & x = 0 ,1 | x= 1 的 x 就是一个废物。 那我们就给树上每个结点建一个废物标记,对& 来说,如果一棵子树是 0,那另外一棵子树内所有叶结点都应该打上废物标记,对|同理。 先计算出原表示答案ans,这样我们在查询的时候,没被标记的就说明它往上到根节点都不存在一种让它变成废物的运算,所以答案是!ans,如果有标记则答案依旧为ans。 时间复杂度 O(n+q)。
这样的话我们对于题目基本上就有了一个正确的思路,但是这个题的实际难点在于对于程序的编写,所以接下来,我先放上大佬的完整代码,接着我们来一点一点进行分析:
#include <bits/stdc++.h> using namespace std; typedef long long LL; const int INF = 0x3f3f3f3f; const LL mod = 1e9 + 7; const int N = 1000005; char s[N]; int a[N]; int son[N][2], ck; int flag[N], c[N]; int n, q; int dfs(int u, int g) { a[u] ^= g; if (u <= n) { return a[u]; } int x = dfs(son[u][0], g ^ flag[son[u][0]]); int y = dfs(son[u][1], g ^ flag[son[u][1]]); if (a[u] == 2) { if (x == 0) c[son[u][1]] = 1; if (y == 0) c[son[u][0]] = 1; return x & y; } else { if (x == 1) c[son[u][1]] = 1; if (y == 1) c[son[u][0]] = 1; return x | y; } } void dfs2(int u) { if (u <= n) return; c[son[u][0]] |= c[u]; c[son[u][1]] |= c[u]; dfs2(son[u][0]); dfs2(son[u][1]); } int main() { // freopen("expr.in", "r", stdin); // freopen("expr.out", "w", stdout); gets(s); scanf("%d", &n); ck = n; for (int i = 1; i <= n; i++) { scanf("%d", &a[i]); } stack<int> b; for (int i = 0; s[i]; i += 2) { if (s[i] == 'x') { int x = 0; i++; while (s[i] != ' ') { x = x * 10 + s[i] - '0'; i++; } i--; b.push(x); } else if (s[i] == '&') { int x = b.top(); b.pop(); int y = b.top(); b.pop(); b.push(++ck); a[ck] = 2; son[ck][0] = x; son[ck][1] = y; } else if (s[i] == '|') { int x = b.top(); b.pop(); int y = b.top(); b.pop(); b.push(++ck); a[ck] = 3; son[ck][0] = x; son[ck][1] = y; } else if(s[i] == '!'){ flag[b.top()] ^= 1; } } int ans = dfs(ck, flag[ck]); dfs2(ck); scanf("%d", &q); while (q--) { int x; scanf("%d", &x); printf("%d\n", c[x] ? ans : !ans); } return 0; }
第一步:读入
gets(s);//字符串读入 scanf("%d", &n); ck = n;//我们在接下来要对每个运算符的作用对象进行保存,但是这位巨神明显比较节约,所以他直接用了原来我们保
存元素的a[]数组来进行操作符的保存,这样的话我们为了不覆盖原来的元素值,就从n+1位开始保存操作符 for (int i = 1; i <= n; i++) { scanf("%d", &a[i]); } stack<int> b;//开栈来模拟运算顺序 for (int i = 0; s[i]; i += 2) {//i+=2的好处在于我们循环访问的时候直接跳过了空格,可以对每个有价值的元素直接进行保存 if (s[i] == 'x') { int x = 0; i++; while (s[i] != ' ') { x = x * 10 + s[i] - '0'; i++;//由于我们有可能读入成千上百个元素,所以我们元素的下标保存就要转化成int形式,注意这里
也是一个坑点,因为给出的样例中所有元素都是在10的范围之内的,也就是说如果你没有关注到元素可能是两位数,你就会挂 } i--;//在上一步我们实际上是向前多走了一步(为了判断下一个位置上是否还存在位数),所以我们要返回一步 b.push(x);//把你当前读到的值压入栈中 } else if (s[i] == '&') { int x = b.top(); b.pop(); int y = b.top(); b.pop();//去除栈头两个元素 b.push(++ck);//保存当前操作,并且标记当前位置上是一个运算而不是单纯的元素,因为这里ck是从n不断加一的,所以不会有重复 a[ck] = 2;//标记当前操作类型 son[ck][0] = x; son[ck][1] = y;//标记当前操作对应的元素 } else if (s[i] == '|') { int x = b.top(); b.pop(); int y = b.top(); b.pop(); b.push(++ck); a[ck] = 3; son[ck][0] = x; son[ck][1] = y;//道理和&运算差不多 } else if(s[i] == '!'){ flag[b.top()] ^= 1;//这个flag数组存在的意义是来标记当前元素是否取反,因为我们接下来的操作是对于每个操作数取异或,这样的话
如果当前元素值取反了,其实就相当于把 | 和 & 的操作换一下,所以我们的flag就是来判断操作是不是要换的 }
ps:其他读入的好方法:
sscanf()
int sscanf(const char *buffer, const char *format, ...)
用法类似scanf
,只不过在最前面多了一个字符串buffer
。
从空终止字符串buffer
读取数据,按照format
将结果存储到指定位置。
举个栗子:
char s[] = "123 4.56 abc"; int a; double b; char c[10]; sscanf(s, "%d%lf%s", &a, &b, c); //a=123, b=4.56, c="abc"
emm~~
然后这个题的读入就非常非常非常简单了。。。。
while(1) { scanf("%s",ss);//字符串还是要读入的,但是这里 if(ss[0]>='0'&&ss[0]<='9') break;//特判 l++; if(ss[0]=='x') { sscanf(ss+1,"%d",e+l);//从ss下一个字符开始 //读入,读入整数,把整数存储到e[l]中 xe[e[l]]=l;//没有看下面的代码,所以我也不知道 //这个xe有啥用 } if(ss[0]=='&') e[l]=-1; if(ss[0]=='|') e[l]=-2; if(ss[0]=='!') e[l]=-3; }
这里对于scanf的用法再来解释一下:
1.scanf 读入到空格停止,所以在上面我们要循环读入
2.scanf的“%s”只可以读入字符数组,而不可以读入字符串变量,如下面所示是正确的做法
#include<bits/stdc++.h> using namespace std; char s[50200]; int main() { scanf("%s",s);//读空格 cout<<s<<endl; return 0; }
而下面的做法则编译错误
#include<bits/stdc++.h> using namespace std; char s[50200]; int main() { scanf("%s",s);//读空格 cout<<s<<endl; return 0; }
放置一个关于scanf用法的链接:https://blog.csdn.net/weixin_44123362/article/details/96730486
第二步:调用函数进行原答案的计算(因为我么知道对于每一个询问,要么是对答案有影响,要么是对答案没影响,我们要先计算出原答案是什么,然后再判断每一个操作是否对原答案有影响,然后就可以快速进行每个询问的计算了,。这样的效率绝对比你暴力模拟强得多)
int ans = dfs(ck, flag[ck]);//递归求解原答案 /....../ int dfs(int u, int g) { a[u] ^= g;//这里就是我刚才说的,找当前操作是否更改,其实这里选择2 3 还有一个妙处,就是2^1=3,3^1=2,这样我们就可以快速改变每个操作。 if (u <= n) { return a[u]; }//因为下面我们是递归求解的,所以有可能存在一种情况就是我们递归着递归着,递归到我们保存元素的那个范围之内了(所以其实可以把操作符另开一个数组来存,当然那样的做法对于这个程序有什么奇怪的影响我就不知道了) int x = dfs(son[u][0], g ^ flag[son[u][0]]);//求解左子树的解 int y = dfs(son[u][1], g ^ flag[son[u][1]]);//求右子树的解 //因为是递归嘛,所以也就相当于记忆化了,降低程序运行规模 if (a[u] == 2) { if (x == 0) c[son[u][1]] = 1; if (y == 0) c[son[u][0]] = 1; return x & y;//保存每个子树上的节点是不是“废物”,这个在上面那位巨佬的解释中已经很清楚了 } else { if (x == 1) c[son[u][1]] = 1; if (y == 1) c[son[u][0]] = 1; return x | y;//同理 } } //这样遍历完,我们就可以把原答案求出来了||ヽ(* ̄▽ ̄*)ノミ|Ю
第三步:判断当前更改的值对于最终结果是否有影响
dfs2(ck); /......../ void dfs2(int u) { if (u <= n) return;//对于每个操作进行判断 c[son[u][0]] |= c[u];//如果这个节点已经是废物节点,那么他的孩子一定也是废物 c[son[u][1]] |= c[u]; dfs2(son[u][0]); dfs2(son[u][1]);//继续遍历儿子 //经过这样逐层遍历后(相当于二叉树的访问),就可以判断所有数如果取反,对结果是否有影响 }
第四步:O(1)判断输出答案
scanf("%d", &q); while (q--) { int x; scanf("%d", &x); printf("%d\n", c[x] ? ans : !ans); }
至此,所有的步骤就都完啦~~~
撒花✿✿ヽ(°▽°)ノ✿
总结:
这道题值得认真思考的地方有以下几点:
1.对于复杂内容的读入
2.对于后缀表达式的处理(栈)
3.二进制题目的类似通法:预处理每一个操作对于结果的影响,O(1)输出答案
---------------end-------------------