题意
咕咕东的雪梨电脑的操作系统在上个月受到宇宙射线的影响,时不时发生故障,他受不了了,想要写一个高效易用零bug的操作系统 —— 这工程量太大了,所以他定了一个小目标,从实现一个目录管理器开始。前些日子,东东的电脑终于因为过度收到宇宙射线的影响而宕机,无法写代码。他的好友TT正忙着在B站看猫片,另一位好友瑞神正忙着打守望先锋。现在只有你能帮助东东!
初始时,咕咕东的硬盘是空的,命令行的当前目录为根目录 root。
目录管理器可以理解为要维护一棵有根树结构,每个目录的儿子必须保持字典序。
现在咕咕东可以在命令行下执行以下命令:
TREE输出范例:
Input
输入文件包含多组测试数据,第一行输入一个整数表示测试数据的组数 T (T <= 20);
每组测试数据的第一行输入一个整数表示该组测试数据的命令总数 Q (Q <= 1e5);
每组测试数据的 2 ~ Q+1 行为具体的操作 (MKDIR、RM 操作总数不超过 5000);
面对数据范围你要思考的是他们代表的 “命令” 执行的最大可接受复杂度,只有这样你才能知道你需要设计的是怎样复杂度的系统。
Output
每组测试数据的输出结果间需要输出一行空行。注意大小写敏感。
输入样例
1
22
MKDIR dira
CD dirb
CD dira
MKDIR a
MKDIR b
MKDIR c
CD …
MKDIR dirb
CD dirb
MKDIR x
CD …
MKDIR dirc
CD dirc
MKDIR y
CD …
SZ
LS
TREE
RM dira
TREE
UNDO
TREE
输出样例
OK
ERR
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
OK
9
dira
dirb
dirc
root
dira
a
b
c
dirb
x
dirc
y
OK
root
dirb
x
dirc
y
OK
root
dira
a
b
c
dirb
x
dirc
y
提示
Time limit 6000 ms
Memory limit 1048576 kB
分析
这个题同样是实现目标比较繁琐,需要整体性的架构建立。同时,需要根据数据范围慎重考虑解题方法的时间复杂度。
- 题目分析
题目本身其实并不复杂,数据结构也很清晰,属于树结构。那么我们也比较容易整理出以下较为关键的信息:
- 每次操作都是基于当前停留的目录之下
- 所有操作中只有mkdir、rm、cd三种操作能够通过undo被撤销
- ls和tree两种操作在后代目录数不同时有不同的输出格式
- 子目录必须根据字典序排序
除此之外,根据题目给出的数据范围,我们可以推测:
- 最多可能有5000个mkdir操作,这意味着最多会有5000个树节点
- 其他查询操作的次数约为1e5 - 5000 ≈ 1e5
- 解题思路
整理清楚这些关键信息,有利于我们建构整体性的解题框架,接下来我们就逐一分析。
首先当然是要考虑,目录节点如何实现,以及树如何存储。
1. 节点类型 & 树的存储
1)存储方式
每一个目录对应一个节点,不同的目录之间相连接。很容易就想到可以采取图式的存储方法。但是如果完全采用图示的存储方式,似乎后面的各种操作实现起来稍微有点繁琐,且不必要。
那么将其简化,仍然采用类似思想,用一个依次数组存储所有节点即可。而每个节点的孩子节点都存储在其节点之中就可以了。
2)节点类型
一个目录中需要存储哪些基本信息呢?
- 目录名称
- 其下的所有子目录(按字典序)
如果仅仅是保存子目录的名称足够吗?显然不够。因为要想定位一个目录节点,就需要知道它在存储数组中的下标。但是只是存储下标,却又没办法满足按字典序排序。
这时候就想到了stl中map。map会自动根据key进行排序,而其映射的两方又可以完全自定义。因此在目录节点中直接用一个string到int的映射就可以存储所有子目录,符合所有要求了。
除此之外,节点还需要存储其他信息吗?
- 子目录数
如果是我最开始写,可能并不会想到还要加其他数据。但是当我想到要返回每个目录下的子目录数时,我一定会下意识地想到最简单快捷的办法就是直接在每个节点添加一个数据来记录,在建树过程中更新。
但事实上,根据数据范围对时间限制的估算,可以发现:
假设20组数据中都有5000个节点,且所有查询操作都是在查询子目录数,那么每次查询时遍历树结构来计算目录数的方法最多需要耗时 : 20 * 5000 * 1e5
显然这个处理数是无法在本题的时间范围内做到的。由此可知,我之前下意识想到的方法,其实就是通过这种估算来证明其可行性的。
- 前序序列
同样的证明方法,可以发现每次都通过前序遍历来返回前序序列这一操作的时间复杂度也超过了题目限制。
那么这又该如何简化呢?
从节点类型本身去考虑的思想就是全局思维。而优化操作时间的方式就是“懒更新”。
也就是说,为每个节点单独保存它们的序列。在每次需要被更新的时候,才被更新。以此省去多余重复的操作。
因此,在节点中需要两个数组来记录前序序列。为什么是两个?是因为题目要求只输出10个以内的序列节点或是前五个及后五个前序序列中的节点。因此,完全可以只在节点中保存需要输出的序列,节省空间。
再者,添加一个bool变量来标记当前节点是否需要更新。
2. 可撤销的前三种操作mkdir、rm、cd
这三种操作都比较容易实现。不过需要注意前两个节点增删的操作对其操作对象所影响的所有父节点目录的子目录数的改变,以及序列的改变。所以只要有节点执行该操作,其父节点及以上的所有父节点都应该被标记为序列待更新。
除此之外,另外一个关键是,这三种操作如何撤销呢?
其实撤销操作本身也不复杂,例如:
mkdir的撤销即为rm
rm的撤销即为mkdir
cd的撤销即为返回执行该操作时的当前目录
但是,如何判断哪些操作我们可以撤销,这些操作执行时的具体信息又如何保存呢?
1)如何判断撤销操作
显然,我们需要用数组记录下所有成功执行的可撤销操作。每次执行undo时,只需要取出最后一个放进数组的操作,进行撤销就可以了。
2)如何保存操作信息
这其实也不难。自然会想到要创建一个单独的结构体来存储这些操作信息。而其中包含的数据也比较清晰:
- 当前命令及参数
- 当前目录
- 当前执行操作对象(如增加的节点、删除的节点…)
用一个vector来动态存储所有确认执行成功的前三个操作即可。
3. 查询操作sz、ls
这两个操作比较简单,直接输出要求的内容即可。
需要注意的一个小点是:
ls中输出最后五个目录时,这五个目录输出的顺序仍然应该是升序。
3. 返回序列操作tree
这是其中最复杂最难的操作了。
根据“懒更新”的思想,我们只需要在需要节点的序列时再来判断其节点是否是最新的。如果不是就进行更新即可。
1)正向序列或完整序列
所有节点不论其孩子数,都需要记录序列中至少前五个节点。所以对一个节点的序列进行更新时,首先要对其保存前五或前十节点的数组更新。
而在更新这个数组的过程中,显然这个节点需要保存的节点是以它为根的树中完整序列的前五或十个以内的节点。而每个节点的序列都是由其孩子的前五个序列组成。因此只需要将其孩子的正向序列依次存入当前节点的数组中足够数量个即可。那么如果其子目录数大于9,只需要存满5个即可。
那么当在遍历过程中,在存储操作前,对当前子目录节点进行判定,如果他需要更新,就直接对他调用更新函数即可。
2)反向序列
反向序列的关键在于其反向顺序。
当前节点的反向序列中存储的节点实际上也是其最后五个子目录数的反向序列中的最后五个。【有点绕但是意思就是最后的最后的最后的五个233】
那么我们可以先反向从最后一个子目录的反向序列中的最后一个节点开始,依次向前将序列中节点存储到当前节点的反向序列中。
所以这最后得到的序列其实是真正要输出的反向序列的反向序列。因此,当放慢五个后将该数组中的所有元素反向,就可以得到正确顺序的序列了。
还有一个问题是:
通过迭代器从一个vector的end()指针前向遍历时需要注意,迭代器并不存在像end一样位于数组首位元素前一个的指针。因此需要我们手动暂停遍历。也就是当我们进入下一次遍历之前发现,当前已经时begin,说明我们已经反向遍历并且操作完成了。
3)输出序列
输出序列容易出错的地方在于如何以正确的正向顺序输出反向序列。
解决的办法就是,将i从大到小递减,再用数组的size减去i。显然,这就能达到目的。
- 问题
这是一个🩸的教训❗️❗️
又是一次从下午改到凌晨三点,再从第二天中午改到下午才发现的玄幻小错误。【冷漠jpg】
但是非常值得注意,这确实是个容易忽略的很小的易错点:
当关闭同步流后,相同类型的不同输入输出操作不能混用。
也就是cin和scanf不能混用,cout和printf不能混用!!
总结
- 因为一些易错却难以察觉的小错误而经历长久的调试真的是心理和生理的折磨和考验。不知道我要写多久的代码才能做到心如止水呢。实在是按耐不住半夜三点的怒火🤯真的是留下了深刻的教训。但是起码证明了我对自己代码的自信是可靠的!
- 感谢助教的帮助和助教帮我找到的err错误!!🙏🏻
代码
//
// main.cpp
// lab1
//
//
#include <iostream>
#include <vector>
#include <string>
#include <map>
#include <algorithm>
#include <queue>
using namespace std;
struct Node //目录节点
{
string name; //目录名称
map<string,int> child; //子目录
int father; //父节点
int sz; //该目录下的目录数
vector<string> pre,back; //以该点为根的前序遍历前五he后五个
bool judge;
void initialize(string s,int f) //初始化新节点
{
name = s;
father = f;
sz = 1;
judge = false;
pre.clear();
back.clear();
child.clear();
}
};
struct comd //命令结构体
{
int types; //命令类型
string para; //命令的参数
};
struct op //记录所有命令
{
comd cmd; //当前命令
int node = -1; //当前操作对象
int cur = -1; //当前目录
};
map<string,int> commands;
Node root; //根节点
Node theTree[10010]; //存储所有目录
int current,num = 0; //当前目录节点
vector<op> cmd; //存储所有成功执行的可撤销命令
op Now; //代表当前正在进行的命令
void change_size(int start,int n) //更新目录数
{
while( start != -1 ) //若没有遍历完所有上层目录
{
// cout<<" --- "<<start<<endl;
theTree[start].judge = false; //该节点标记为未更新,因为此时其子目录一定发生变化,序列需要重新确认
theTree[start].sz += n; //改变目录数
start = theTree[start].father; //上升到其父节点
}
}
void mkdir(string s) //新增
{
ios::sync_with_stdio(false);
if( theTree[current].child.find(s) != theTree[current].child.end() ) //说明该目录已存在
{
cout<<"ERR"<<endl;
return;
}
Node now; //新建节点
now.initialize(s, current);
theTree[++num] = now;
theTree[current].child[now.name] = num; //将新节点添加到当前目录的孩子中
change_size(current, 1); //所有父亲目录的目录数+1
Now.node = num;
cmd.push_back(Now); //将操作成功的可撤销指令存储
cout<<"OK"<<endl;
}
void rm(string s) //删除
{
//若目录不存在或不存于当前目录下
if( theTree[current].child.find(s) == theTree[current].child.end() )
{
cout<<"ERR"<<endl;
return;
}
Now.node = theTree[current].child[s]; //记录当前命令的对象索引
cmd.push_back(Now); //将操作成功的可撤销指令存储
theTree[current].child.erase(s); //将该目录从其父节点的孩子中去除
change_size(current, (-1) * theTree[Now.node].sz ); //所有父亲目录的目录数都要少去该目录之下的目录总数
cout<<"OK"<<endl;
}
void cd(string s) //进入目录
{
if( s == ".." ) //进入上一层
{
if( current > 0 ) //若还有上层目录
{
current = theTree[current].father;
cout<<"OK"<<endl;
cmd.push_back(Now); //将操作成功的可撤销指令存储
}
else //若当前已经是根目录
cout<<"ERR"<<endl;
return;
}
//若目录不存在或不存于当前目录下
if( theTree[current].child.find(s) == theTree[current].child.end() )
{
cout<<"ERR"<<endl;
return;
}
cmd.push_back(Now); //将操作成功的可撤销指令存储
current = theTree[current].child[s]; //进入该子目录
cout<<"OK"<<endl;
}
void sz() //输出目录数
{
cout<<theTree[current].sz<<endl; //输出当前目录下的目录总数
}
void ls() //输出子目录
{
if( theTree[current].child.size() == 0 ) //如果当前目录无子目录
{
cout<<"EMPTY"<<endl;
return;
}
if( theTree[current].child.size() >= 1 && theTree[current].child.size() <= 10 )
{
for( auto it = theTree[current].child.begin() ; it != theTree[current].child.end() ; it++ )
cout<<it->first<<endl;
return;
}
//否则
auto it = theTree[current].child.begin();
for( int i = 0 ; i < 5 ; i++) //输出前五个
{
cout<<it->first<<endl;
it++;
}
cout<<"..."<<endl;
it = theTree[current].child.end(); //迭代器中end是最后一个元素的后一个
for( int i = 0; i < 5; i++ ) //将迭代器移到最后五个的起始
it--;
for( int i = 0 ; i < 5 ; i++ ) //输出后五个(注意顺序)
{
cout<<it->first<<endl;
it++;
}
}
void undo() //撤销
{
if( cmd.size() == 0 ) //若没有成功执行的可撤销指令
{
cout<<"ERR"<<endl;
return;
}
op object = cmd[cmd.size() - 1]; //选中最后一个指令(这个指令就是最近成功执行的可撤销指令)
cmd.pop_back(); //删除该指令
switch (object.cmd.types)
{
case 1: //若待撤销命令为新增
{
theTree[object.cur].child.erase(theTree[object.node].name); //将新增节点从其父节点的孩子中删除
change_size(object.cur, (-1) * theTree[object.node].sz); //将父节点们的目录数减去该目录下的目录数
break;
}
case 2: //若待撤销命令为删除
{
//将删除节点重新加入父节点的孩子中
theTree[object.cur].child[theTree[object.node].name] = object.node;
change_size(object.cur, theTree[object.node].sz );
//将父节点及以上的节点的目录数加上该目录的目录数
break;
}
case 3:
{
current = object.cur; //直接返回该操作所停留的当前目录
break;
}
default:
break;
}
cout<<"OK"<<endl;
}
void Pre(int start);
void Back(int start);
void change_pre(int start) //更新前序序列
{
theTree[start].pre.clear(); //清空当前节点的序列数组
theTree[start].back.clear();
Pre(start); //先进行普通前序遍历
if( theTree[start].sz > 10 ) //若目录数大于10再进行反向
Back(start);
else //否则将两个设置为一致
theTree[start].back = theTree[start].pre;
theTree[start].judge = true; //标记当前节点已经更新
}
void Pre(int start) //普通前序
{
theTree[start].pre.push_back(theTree[start].name); //首先放入自己的目录名
if( theTree[start].sz == 1 ) //若没有孩子,直接返回
return;
if( theTree[start].sz > 1 && theTree[start].sz <= 10 ) //若孩子加自己只有10及以内,记录所有pre
{
for( auto it = theTree[start].child.begin() ; it != theTree[start].child.end() ; it++ )
{
if( !theTree[it->second].judge ) //若当前节点的序列还未更新
change_pre(it->second);
//当前节点的序列为其孩子节点的序列之和
//且先遍历的孩子序列在之前(前序)
theTree[start].pre.insert(theTree[start].pre.end(), theTree[it->second].pre.begin(), theTree[it->second].pre.end());
}
return;
}
//否则,pre中只需要记录前五个
int n = 1;
for( auto it = theTree[start].child.begin() ; it != theTree[start].child.end() ; it++ )
{
if( !theTree[it->second].judge ) //若当前节点的序列还未更新
change_pre(it->second);
//记录孩子的pre序列
for( int i = 0 ; i < theTree[it->second].pre.size() ; i++ )
{
theTree[start].pre.push_back(theTree[it->second].pre[i]);
n++; //记录当前一共记录的序列长度
if( n >= 5 ) //等于5就停止
break;
}
if( n >= 5 ) //等于5就停止
break;
}
}
void Back(int start) //反向
{
int n = 0;
auto it = theTree[start].child.end();
it--; //得到孩子节点中的最后一个
for( ; ; it-- )
{
if( !theTree[it->second].judge ) //若当前节点的序列还未更新
change_pre(it->second);
//记录孩子的back序列的最后几个
for( int i = theTree[it->second].back.size() - 1 ; i >= 0 ; i-- )
{
theTree[start].back.push_back(theTree[it->second].back[i]);
n++; //记录当前一共记录的序列长度
if( n >= 5 ) //等于5就停止
{
//将此时存取的序列反向
reverse(theTree[start].back.begin(), theTree[start].back.end());
break;
}
}
if( n >= 5 ) //等于5就停止
break;
//不能放在for里是因为没有一个迭代器指针指向begin的前一个,但是等于begin时仍然要进入循环
if( it == theTree[start].child.begin() ) //若当前到起点就结束
break;
}
}
void tree()
{
if( !theTree[current].judge ) //若当前节点的前序序列没有更新
change_pre(current);
if( theTree[current].sz == 1 )
{
cout<<"EMPTY"<<endl;
return;
}
if( theTree[current].sz > 1 && theTree[current].sz <= 10 ) //输出全部
{
for( int i = 0 ; i < theTree[current].pre.size() ; i++ )
cout<<theTree[current].pre[i]<<endl;
}
else
{
//输出pre的前五个
for( int i = 0 ; i < 5 ; i++ )
cout<<theTree[current].pre[i]<<endl;
cout<<"..."<<endl;
//输出back的后五个
for( int i = 5 ; i >= 1 ; i-- )
cout<<theTree[current].back[theTree[current].back.size() - i]<<endl;
}
}
void ini() //初始化
{
num = 0; //记录节点序号
current = 0; //起始目录为root
cmd.clear(); //清空所有记录的命令
theTree[0].initialize("root", -1); //重新初始化根节点
}
int main()
{
ios::sync_with_stdio(false);
commands["MKDIR"] = 1;
commands["RM"] = 2;
commands["CD"] = 3;
commands["SZ"] = 4;
commands["LS"] = 5;
commands["TREE"] = 6;
commands["UNDO"] = 7;
int t = 0,q = 0;
cin>>t;
string s1,s2;
bool j = false;
while( t-- )
{
if( j ) //每组数据间有空行
cout<<endl;
j = true;
ini();
cin>>q; //输入命令数
for( int i = 0 ; i < q ; i++ )
{
Now.cur = current; //记录执行该命令时的当前目录
cin>>s1;
Now.cmd.types = commands[s1]; //记录该命令
if( Now.cmd.types < 4 ) //可撤销指令
{
cin>>s2;
Now.cmd.para = s2; //记录该命令的参数
}
switch (Now.cmd.types)
{
case 1: //新增
mkdir(Now.cmd.para);
break;
case 2: //删除
rm(Now.cmd.para);
break;
case 3: //进入
cd(Now.cmd.para);
break;
case 4:
sz(); //返回目录数
break;
case 5:
ls(); //输出子目录名
break;
case 6:
tree(); //输出序列
break;
case 7:
undo(); //撤销
break;
default:
break;
}
}
}
return 0;
}