LDAP - 题解【模拟】
题面
该题为CCF-CSP认证考试真题,试题编号为202303-3。我参加了这次CSP认证(虽然说认证成绩没有达到预期emmm),原题链接见:202303-3。下面搬运题面如下:
题目背景
西西艾弗岛运营公司是一家负责维护和运营岛上基础设施的大型企业,拥有数千名员工。公司内有很多 IT 系统。为了能够实现这些 IT 系统的统一认证登录,公司 IT 部门决定引入一套 LDAP 系统来管理公司内的用户信息。轻型目录访问协议(Lightweight Directory Access Protocol,LDAP)是一种用于访问和维护目录服务的应用层协议,基于它的数据库可以用树形结构来组织和存储数据。每一笔数据,都包含了一个唯一的标识符(DN,Distinguished Name),以及一系列的属性(Attribute)。
不同的 IT 系统,允许访问的用户是不相同的。每个信息系统都有一个表达式,用来描述允许访问的用户。
这个表达式可以按照某一个属性的值作为条件来匹配用户,也可以用多个条件的逻辑组合来匹配用户。
小 C 被安排来实现这样一个算法,给定一个 IT 系统的匹配表达式,找到所有与之匹配的用户的 DN。
问题描述
为了简化该问题,我们约定,每个用户的 DN 是一个正整数,且不会重复。有若干种用户的属性,用正整数编号。每个用户可以具有这些属性中的若干个,且每个属性只能有一个值。每个属性的值也是一个正整数。例如,假定有两个用户:用户 1 和用户 2,他们的 DN 分别是 1 和 2。一共有 3 种属性。用户 1 具有属性 1 和属性 2,且属性 1 的值为 2,属性 2 的值为 3;但不具有属性 3。用户 2 具有属性 2 和属性 3,且属性 2 的值为 3,属性 3 的值为 1;但不具有属性 1。如下表所示:
DN | 属性 1 | 属性 2 | 属性 3 |
---|---|---|---|
1 | 2 | 3 | N/A |
2 | N/A | 3 | 1 |
一个匹配表达式可以是一个属性的值,也可以是多个匹配表达式的逻辑组合。只匹配一个属性的值的表达式称为原子表达式,原子表达式的形式为 <属性编号><操作符><属性值>
。其中操作符有两种:断言与反断言。断言操作符为 :
,表示匹配具有该属性且值与之相等的用户;反断言操作符为 ~
,表示匹配具有该属性且值与之不等的用户。例如,表达式 1:2
可以与上述用户 1 相匹配,但不能与用户 2 相匹配;而表达式 3~1
则不能与任何一个用户相匹配。
表达式可以进行逻辑组合,其语法是:<操作符>(表达式 1)(表达式 2)
。其中操作符有两种:与(&
)和或(|
)。如果操作符为与,则当且仅当两个表达式都与某一用户相匹配时,该表达式与该用户相匹配;如果操作符为或,则当且仅当两个表达式中至少有一个与某一用户相匹配时,该表达式与该用户相匹配。例如,表达式 &(1:2)(2:3)
可以与用户 1 相匹配,但不能与用户 2 相匹配;而表达式 |(1:2)(3:1)
则可以与两个用户都相匹配。
形式化地,上述语法用 BNF 范式表示如下:
NON_ZERO_DIGIT = "1" / "2" / "3" / "4" /
"5" / "6" / "7" / "8" / "9"
DIGIT = "0" / NON_ZERO_DIGIT
NUMBER = NON_ZERO_DIGIT / (NON_ZERO_DIGIT DIGIT*)
ATTRIBUTE = NUMBER
VALUE = NUMBER
OPERATOR = ":" / "~"
BASE_EXPR = ATTRIBUTE OPERATOR VALUE
LOGIC = "&" / "|"
EXPR = BASE_EXPR / (LOGIC "(" EXPR ")" "(" EXPR ")")
EASY_EXPR = BASE_EXPR /
(LOGIC "(" BASE_EXPR ")" "(" BASE_EXPR ")")
输入格式
从标准输入读入数据。
输入的第一行包含一个正整数\(n\),表示用户的数目。
接下来\(n\)行,每行包含空格分隔的若干个正整数,第一个正整数表示该用户的DN,第二个正整数表示该用户具有的属性个数,此后的每两个正整数表示该用户具有的一个属性及其值。这些属性按照属性编号从小到大的顺序给出。
接下来一行包含一个正整数\(q\),表示匹配表达式的数目。
接下来\(q\)行,每行包含一个匹配表达式。
输出格式
输出到标准输出。
输出\(q\)行,每行包含零个或多个正整数,用空格分隔,表示与对应的匹配表达式相匹配的用户的 DN,由小到大排序。
样例输入1
2
1 2 1 2 2 3
2 2 2 3 3 1
4
1:2
3~1
&(1:2)(2:3)
|(1:2)(3:1)
样例输出1
1
1
1 2
样例解释
本组输入是题目描述中的例子。
数据范围与时空限制
对于 20% 的输入,有\(1 \le n \le 100\),\(1 \le m \le 10\),每个用户的属性个数不超过 10,全部属性编号不超过 100,且表达式是原子表达式,即符合 BNF 语法 BASE_EXPR。
对于 40% 的输入,有$1 \le m \le 100 $,每个用户的属性个数不超过 10,全部属性编号不超过 100,且表达式中至多含有两个原子表达式的逻辑组合,即符合 BNF 语法 EASY_EXPR。
对于 70% 的输入,有全部属性编号不超过500。
对于全部输入,有\(1 \le n \le 2500\) , \(1 \le m \le 500\) ,每个用户的属性个数不超过 500,全部属性编号、属性值和 DN 均不超过 \(10^9\) ,每个表达式语句都符合题设语法,且语句字符长度不超过 2000。
时间限制12s,空间限制1GB。
题解
本题在CSP认证中位于第三题,一般来说会是一道大模拟。不过这次的大模拟题对于学过编译原理并在编译课设靠小组努力白嫖老师一个键盘的我来说还算比较亲切。
在原始信息读入方面,因为DA的值不确定,所以可以用一个d数组存储第i行的DA,用一个二维的二元有序对数组a存储属性-值对。在C++里面,我使用vector开辟动态数组,用pair描述二元组,其中first代表属性,second代表值。这样在检索的时候,就可以转化为0~n-1范围内的下标搜索。
接下来就是查询。先看一下查询语法,查询表达式有两种形式:<属性编号><操作符><属性值>
以及<操作符>(表达式 1)(表达式 2)
,其中表达式可以嵌套,意思是诸如&(|(1:2)(3~4))(555:666)
这样的表达式是合法的。为了分析表达式,首先可以先进行词法分析。简单来说,词法分析就是从表达式中提取出Token。举个例子,上面那个表达式中与逻辑符号&、括号()、数字3和555等都属于Token。一般词法分析可以采用自动机来实现,但是本题的Token大多只有一位,只有数字可能出现多位,这就为Token的提取提供了方便。提取Token的代码如下所示:
string s;
int ptr;
string getToken() {
string ret = "";
if (isdigit(s[ptr])) { //是数字
while (isdigit(s[ptr])) { //一直往ret加入数字
ret += s[ptr];
++ptr;
}
} else { //否则返回当前符号
ret += s[ptr];
++ptr;
}
return ret;
}
ptr代表当前访问到表达式s的哪个位置,初始为0。随着Token的提取,ptr会自动后移。当然对于数字Token,还需要将字符串转化成数字表示。
接下来就是语法、语义分析部分,语法分析的任务就是对给出的查询表达式进行解析,判断是否满足语法。但是本题给出的表达式保证满足语法,因此可以弱化语法分析,重点关注语义分析。语义分析的任务就是将符合语法的表达式进行语义解析,对于本题,就是要通过查询表达式找出符合要求的用户DA。根据语法结构,语义分析天然适合采用递归方式进行。这在编译原理里面称为“递归下降子程序”。
我们定义一个分析函数work(),其接收一个待分析集合st,例如初始有n个用户记录,待分析集合里面就是0~n-1下标。该函数返回一个分析结果ret,代表满足分析要求的下标集合。
首先,我们分析当前的Token。如果是一个数字,意味着当前要分析的子表达式是“数字 操作符 数字”类型,直接分析即可。如果是一个&或者|符号,那么子表达式一定是“逻辑运算符 左括号 表达式1 右括号 左括号 表达式2 右括号”类型。这时,我们分析表达式1的策略是:保存当前的逻辑运算符,跳过一个Token(因为一定是左括号),然后递归调用自身获取一个分析结果,跳过下一个Token(右括号)。第二个表达式同理,但是如果是“与”操作符,意味着取交集,此时将表达式1的返回集合作为表达式2的分析集合,调用work函数即可;如果是“或”操作,则分析同表达式1,最后取交集即可。分析函数的实现如下:
vector<int> work(vector<int>& st) {
string tk = getToken(); //当前Token
vector<int> ret; //返回结果
if (!isdigit(tk[0])) { //是操作符
char op = tk[0]; //保存操作符
vector<int> r1;
getToken(); //跳过左括号
r1 = work(st); //满足表达式1的用户
getToken(); //跳过右括号
getToken(); //跳过左括号
if (op == '&') {
ret = work(r1); //与操作,待分析集为满足表达式1的集合
} else {
vector<int> r2 = work(st); //或操作,获取满足表达式2的用户集合
r1.insert(r1.end(), r2.begin(), r2.end());
sort(r1.begin(), r1.end());
ret.insert(ret.end(), r1.begin(), unique(r1.begin(), r1.end())); //取交集
}
getToken(); //最后还要跳过右括号
} else { //原子表达式
int att = toNum(tk);
char op = getToken()[0];
tk = getToken();
int val = toNum(tk); //获取属性、操作符、值
for (int i : st) { //对于待分析集合里每个下标
auto it = lower_bound(a[i].begin(), a[i].end(), make_pair(att, 0));
if (it == a[i].end() || it->first != att) {
continue; //找不到该属性,跳过
}
if ((op == ':' && it->second == val) || (op == '~' && it->second != val)) {
ret.push_back(i); //属性值满足要求,加入
}
}
}
return ret; //返回满足要求的用户集合
}
在主函数中,调用work()函数的返回结果,从d数组中提取出用户的DA,排序输出就可以了。
cin >> s; //读取表达式
ptr = 0; //初始指向表达式开头
vector<int> ta = work(pri); //获取下标,pri数组里面初始是0~n-1
vector<int> ans;
for (int i : ta) {
ans.push_back(d[i]); //从d数组中获取DA值
}
sort(ans.begin(), ans.end()); //排序并输出
for (int i : ans) {
cout << i << ' ';
}
cout << '\n';
本题时间足足给了12秒,数据范围也不是特别大,因此直接返回vector、对vector去重等在正常题目中的脑淤血操作是可以被允许的。在这里也不进行复杂度分析了 (才不是因为懒)。我本人的代码跑了2.1秒,应该还能再优化,但是赛场上过了就不管了。
代码
#include <bits/stdc++.h>
#define FAST ios::sync_with_stdio(false);cin.tie(0);
using namespace std;
int n, ptr;
string s;
vector<int> d;
vector<vector<pair<int, int> > > a;
string getToken() {
string ret = "";
if (isdigit(s[ptr])) {
while (isdigit(s[ptr])) {
ret += s[ptr];
++ptr;
}
} else {
ret += s[ptr];
++ptr;
}
return ret;
}
int toNum(string& s) {
int ret = 0;
for (char ch : s) {
ret *= 10;
ret += ch - '0';
}
return ret;
}
vector<int> work(vector<int>& st) {
string tk = getToken();
vector<int> ret;
if (!isdigit(tk[0])) {
char op = tk[0];
vector<int> r1;
getToken();
r1 = work(st);
getToken();
getToken();
if (op == '&') {
ret = work(r1);
} else {
vector<int> r2 = work(st);
r1.insert(r1.end(), r2.begin(), r2.end());
sort(r1.begin(), r1.end());
ret.insert(ret.end(), r1.begin(), unique(r1.begin(), r1.end()));
}
getToken();
} else {
int att = toNum(tk);
char op = getToken()[0];
tk = getToken();
int val = toNum(tk);
for (int i : st) {
auto it = lower_bound(a[i].begin(), a[i].end(), make_pair(att, 0));
if (it == a[i].end() || it->first != att) {
continue;
}
if ((op == ':' && it->second == val) || (op == '~' && it->second != val)) {
ret.push_back(i);
}
}
}
return ret;
}
int main() {
FAST;
cin >> n;
d.resize(n);
a.resize(n);
vector<int> pri;
for (int i = 0;i < n;++i) {
cin >> d[i];
int k;
cin >> k;
a[i].resize(k);
for (int j = 0;j < k;++j) {
cin >> a[i][j].first >> a[i][j].second;
}
pri.push_back(i);
}
int q;
cin >> q;
while (q--) {
cin >> s;
ptr = 0;
vector<int> ta = work(pri);
vector<int> ans;
for (int i : ta) {
ans.push_back(d[i]);
}
sort(ans.begin(), ans.end());
for (int i : ans) {
cout << i << ' ';
}
cout << '\n';
}
return 0;
}
/*
_ _ _ _
/\ | | | | | | (_)
/ \ | | _____ _| |__| | ___ _ __ _ _ __ __ _
/ /\ \ | |/ _ \ \/ / __ |/ _ \| '__| | '_ \ / _` |
/ ____ \| | __/> <| | | | (_) | | | | | | | (_| |
/_/ \_\_|\___/_/\_\_| |_|\___/|_| |_|_| |_|\__, |
__/ |
|___/
*/