题目描述:
逻辑表达式是计算机科学中的重要概念和工具,包含逻辑值、逻辑运算、逻辑运算优先级等内容。
在一个逻辑表达式中,元素的值只有两种可能:0(表示假)和 1(表示真)。元素之间有多种可能的逻辑运算,本题中只需考虑如下两种:“与”(符号为 &
)和“或”(符号为 |
)。其运算规则如下:
0&0=0&1=1&0=0,1&1=1;
0 | 0=0,0∣1=1∣0=1∣1=1。
在一个逻辑表达式中还可能有括号。规定在运算时,括号内的部分先运算;两种运算并列时,&
运算优先于 |
运算;同种运算并列时,从左向右运算。
比如,表达式 0|1&0
的运算顺序等同于 0|(1&0)
;表达式 0&1&0|1
的运算顺序等同于 ((0&1)&0)|1
。
此外,在 C++ 等语言的有些编译器中,对逻辑表达式的计算会采用一种“短路”的策略:在形如 a&b
的逻辑表达式中,会先计算 a
部分的值,如果 a = 0,那么整个逻辑表达式的值就一定为 0,故无需再计算 b
部分的值;同理,在形如 a|b
的逻辑表达式中,会先计算 a
部分的值,如果 a = 1,那么整个逻辑表达式的值就一定为 1,无需再计算 b
部分的值。
现在给你一个逻辑表达式,你需要计算出它的值,并且统计出在计算过程中,两种类型的“短路”各出现了多少次。需要注意的是,如果某处“短路”包含在更外层被“短路”的部分内则不被统计,如表达式 1|(0&1)
中,尽管 0&1
是一处“短路”,但由于外层的 1|(0&1)
本身就是一处“短路”,无需再计算 0&1
部分的值,因此不应当把这里的 0&1
计入一处“短路”。
输入格式
输入共一行,一个非空字符串 ss 表示待计算的逻辑表达式。
输出格式
输出共两行,第一行输出一个字符 0
或 1
,表示这个逻辑表达式的值;第二行输出两个非负整数,分别表示计算上述逻辑表达式的过程中,形如 a&b
和 a|b
的“短路”各出现了多少次。
输入输出样例
输入 #1复制
0&(1|0)|(1|1|1&0)
输出 #1复制
1 1 2
输入 #2复制
(0|1&0|1|1|(1|1))&(0&1&(1|0)|0|1|0)&0
输出 #2复制
0 2 3
说明/提示
【样例解释 #1】
该逻辑表达式的计算过程如下,每一行的注释表示上一行计算的过程:
0&(1|0)|(1|1|1&0)
=(0&(1|0))|((1|1)|(1&0)) //用括号标明计算顺序
=0|((1|1)|(1&0)) //先计算最左侧的 &,是一次形如 a&b 的“短路”
=0|(1|(1&0)) //再计算中间的 |,是一次形如 a|b 的“短路”
=0|1 //再计算中间的 |,是一次形如 a|b 的“短路”
=1
【样例 #3】
见附件中的 expr/expr3.in
与 expr/expr3.ans
。
【样例 #4】
见附件中的 expr/expr4.in
与 expr/expr4.ans
。
【数据范围】
设∣s∣ 为字符串 s 的长度。
对于所有数据,1≤∣s∣≤10^6。保证 s 中仅含有字符 0
、1
、&
、|
、(
、)
且是一个符合规范的逻辑表达式。保证输入字符串的开头、中间和结尾均无额外的空格。保证 ss 中没有重复的括号嵌套(即没有形如 ((a))
形式的子串,其中 a
是符合规范的逻辑表 达式)。
测试点编号 | ∣s∣≤ | 特殊条件 |
---|---|---|
1∼2 | 3 | 无 |
3∼4 | 5 | 无 |
5 | 2000 | 1 |
6 | 2000 | 2 |
7 | 2000 | 3 |
8∼10 | 2000 | 无 |
11∼12 | 10^6 | 1 |
13∼14 | 10^6 | 2 |
15∼17 | 10^6 | 3 |
18∼20 | 10^6 | 无 |
其中:
特殊性质 1 为:保证 s 中没有字符 &
。
特殊性质 2 为:保证 s 中没有字符 |
。
特殊性质 3 为:保证 s 中没有字符 (
和 )
。
【提示】
以下给出一个“符合规范的逻辑表达式”的形式化定义:
- 字符串
0
和1
是符合规范的; - 如果字符串
s
是符合规范的,且s
不是形如(t)
的字符串(其中t
是符合规范的),那么字符串(s)
也是符合规范的; - 如果字符串
a
和b
均是符合规范的,那么字符串a&b
、a|b
均是符合规范的; - 所有符合规范的逻辑表达式均可由以上方法生成。
思路:
分治/伪表达式树:
vp 完的那个夜晚,大家讨论做法时都一致认定这道题在考表达式树,于是我就看了看他们发的代码。清一色的要么是建表达式树,最后跑一遍遍历,要么是用一个栈弹来弹去,还有厉害的则先将中缀表达式转成前/后缀表达式,然后再求值。
我还是太弱了,看着大佬们一百多行的代码陷入了沉思。
我的评价是:太抽象了!有没有一种方法既不用建表达式树,不用栈弹来弹去,也不用转成前/后缀表达式,实现直接在中缀表达式求值呢?
我们可以先来思考一下人类是怎么算中缀表达式的:
就先拿样例 1 举例:
0&(1|0)|(1|1|1&0)
人类会先看向第一层的 |
左边的 0&(1|0)
,再看向 &
左边的 0,此时形成了一个短路,所以这部分的值为 0。
此时式子为 0|(1|1|1&0)
。
此时 |
左边的值计算完毕,我们该看 第一层的 |
右边的 (1|1|1&0)
。
此时我们会先看到 1|1|1&0
,发现第二层的 |
左边为 1,构成短路,此部分的值为 1,后面的操作都不用算。
此时式子变为 0|(1|1&0)
我们会看到 (1|1&0)
,发现又构成一次短路,此部分的值为 1。
此时式子会变成 0|1
,最后算出答案为 1。
为什么要分析的那么仔细?因为我们等会就要实现通过模拟人脑计算中缀表达式的过程计算中缀表达式。
我们先来分析一下我们做的过程。我们会先找到第一层优先级最高的,先算这个符号的左半边,判断存不存在短路,如果不存在,继续找找到第二层优先级最高的,算这个符号的左半边,判断存不存在短路……以此类推。当不存在比他优先级更高的时候,计算这一层的值并将当前算式替换成这部分的值继续求解。
对于刚才样例的人脑操作,流程图如下。
我们会先找到第一层优先级最高的,也就是第一层的 |
左半边,然后我们进入左半边,找这个左半边里优先级最高的,自然是第二层的 &
。看他的左半边,是一个 0,造成了短路,所以这一部分的值就是 0。然后我们回到第一层,第一层的左半边我们算完了,值为 0,但因为第一层是 |
,所以不造成短路。我们继续找 |
号的右半边优先级最高的,发现是第二层的 |
,于是我们再去左半边找优先级最高的运算符,此时我们会找到第三层的 |
,看他的左半边。发现没有运算符,只有一个数字,为 1!并且他右边的运算符为 |
,构成一个短路,于是这部分的值就是 1。第三层算完了,我们回到第二层,发现是一个 |
,并且我们刚才的计算结果为 1,于是又构成一次短路。此时回到第一层,我们只需要计算出 0|1
的结果就行,答案为 1。
我们来回顾一下刚才的过程。我们每次都会重复寻找当前层优先级最高的符号,并先到符号左边继续寻找当前层优先级最高的符号……直到当前层没有符号,即只有数字为止,此时我们当前层的结果就是这个数字,返回到上一层。此时我们通过刚才计算的结果和这一层的符号判断是否存在短路,如果没有短路就计算出符号右边的值后再计算在这一层的值,并回到上层。否则我们不用计算右边的值,直接回到上一层利用已算出的值继续计算……
把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并,这个算法叫什么?
对了!分治。
那我们该怎么分解问题呢?
我们每次会找到这一层不在括号里的最后一个运算级最低的运算符,然后递归到他的左半边和右半边分别求解,这一层的值就等于左半边的值和右半边的值做这个运算符的操作。(如果是 |
那么就是左半边的值 |
右半边的值,&
同理。
那问题来了,我们不是优先级高的先算吗?为什么要“找到这一层不在括号里的最后一个运算级最低的运算符”呢?
好问题!恰恰因为先找,所以才后算。递归是用类似于栈的先进后出机制,我们每次把运算级最低的先进栈,最后回溯的时候反而运算级最低的在最底下了。此时我们回溯就会先从运算级最高的开始算并返回到上一层了。大家可以在纸上画个表达式,自己玩一玩。
那如果这一层没有“不在括号里的最后一个运算级最低的运算符”怎么办?那就要分两种情况了。一种情况是整个算式都被括号包起来了,此时我们把括号去掉就行了。还有一种是只有数字,此时我们只要返回这个数字就行了,也就是我们分治的边界条件。
那我为什么要叫它伪表达式树呢?
来看这张图:
通过中缀表达式树的建立也可以用上面的代码实现——找到最后一个在括号外的符号,让他作为根节点,符号的左边就是他的左子树,右边就是他的右子树。但有没有必要把它存下来再遍历呢?多此一举!因为我们在遍历的时候就相当于在通过计算机能理解的顺序访问这棵树了,可以边访问边计算。
思路部分完结撒花!
错误代码:
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
#define N (int)1e6 + 1
char str[N];
int sum1;
int sum2;
int dfs(int l,int r) {
int x = 0,orpos = 0, andpos = 0;//记录括号层数、最后一个 | 出现的位置、最后一个 & 出现的位置
for (int i = l; i<=r; ++i) {//遍历左右区间查找运算符
if (str[i] == '(') {
++x;//增加一层括号
}else {
if (str[i] == ')') {
--x;//减少一层括号
}else {
if (!x) {//不在括号中
if (str[i] == '|') {
orpos = i;
}else {
if (str[i] == '&') {
andpos = i;
}
}
}
}
}
}
if (orpos) {//注意,因为 | 比 &优先级低,要先判断存不存在 |
if (str[orpos] == '|') {
int tmp1 = dfs(l, orpos - 1);//遍历左区间
if (tmp1 == 1) {//如果是 1,触发了 | 短路
++sum1;
return 1;//不需要计算右区间,直接返回 1
}else {
int tmp2 = dfs(orpos + 1, r);//计算右区间
return (tmp1 | tmp2);
}
}
}
if (andpos) {
if (str[andpos] == '&') {
int tmp1 = dfs(l, andpos - 1);
if (tmp1 == 0) {//如果是 0,触发了 & 短路
++sum2;
return 0;//不需要计算右区间,直接返回 0
}else {
int tmp2 = dfs(andpos + 1, r);
return (tmp1 & tmp2);
}
}
}
//不在括号内的运算符不存在
if (str[l] == '(' && str[r] == ')') {//如果都被括号包裹着
return dfs(l + 1, r - 1);//去掉括号
}else {
return str[l] - '0';//否则左右区间一定重合,返回数字就行了
}
}
int main(int argc, const char * argv[]) {
scanf("%s",str + 1);
int len = strlen(str + 1);
int sum = dfs(1, len);
printf("%d\n%d %d\n",sum, sum2, sum1);//注意!一定要开个 sum 把dfs结果先记下来,否则直接输出会导致 UB
return 0;
}
为什么会这样呢?
原因很简单——刚才的程序时间复杂度为 O(n^2)O(n2)。因为我们在每次分治求解的时候都遍历一遍字符串,时间效率太低。如果我们能预处理出来字符串,那时间复杂度将降低为 O(n)O(n)。
那我们怎么预处理呢?好办,我们对每个位置,处理出这个位置左侧和它同层的最后一个(离它最近的)运算符。这样一来,我们在递归时,想看这一层的最后一个运算符,也就只需访问与右边界同层的最后一个运算符就行了,省掉了每次都遍历一遍的过程。注意,别忘了判断一下这个运算符的位置是否在左边界的右边,否则就说明这一层没有运算符。
依然是结合代码讲解。
优化版:
#include <iostream>
#include <algorithm>
#include <cstring>
using namespace std;
#define N (int)1e6 + 1
char str[N];
int c1[N];
int c2[N];
int l1[N];
int l2[N];
int cnt1;
int cnt2;
int dfs(int l,int r) {//记得先看主函数的预处理
if (c1[r] >= l) {//如果最后一个和 r 同层的 | 在 l 和 r 的范围内
int ans = dfs(l, c1[r] - 1);
if (ans == 1) {
++cnt1;
return 1;
}
return (ans | dfs(c1[r] + 1, r));
}
if (c2[r] >= l) {//如果最后一个和 r 同层的 & 在 l 和 r 的范围内
int ans = dfs(l, c2[r] - 1);
if (ans == 0) {
++cnt2;
return 0;
}
return (ans & dfs(c2[r] + 1, r));
}
if (str[l] == '(' && str[r] == ')') {
return dfs(l + 1, r - 1);
}
return str[l] - '0';
}
int main(int argc, const char * argv[]) {
scanf("%s",str + 1);
int len = strlen(str + 1);
int x = 0;//括号层数
//l1[x] 代表目前最后一个在 x 层括号的 | 运算符
//l2[x] 代表目前最后一个在 x 层括号的 & 运算符
//c1[i] 代表目前和 i 同层的最后一个 | 运算符
//c2[i] 代表目前和 i 同层的最后一个 & 运算符
for (int i = 1; i<=len; ++i) {
if (str[i] == '(') {
++x;
}else if (str[i] == ')') {
--x;
}else if (str[i] == '|') {
l1[x] = i;
}else if (str[i] == '&') {
l2[x] = i;
}
c1[i] = l1[x];//最后一个在 i 这个位置前且与 i 同层的 | 运算符
c2[i] = l2[x];//最后一个在 i 这个位置前且与 i 同层的 & 运算符
}
int ans = dfs(1, len);
printf("%d\n%d %d\n",ans,cnt2,cnt1);
return 0;
}
总结:
本题解亮点:不用建表达式树或者用栈,这道题没有那么难,大家都想复杂了
题目链接:
[CSP-J 2022] 逻辑表达式 - 洛谷https://www.luogu.com.cn/problem/P8815