3061.题解

Fifnmar edited 4 年,8 月前

大体划分

解决问题的基本思路,是从抽象到具体、从整体到局部。现在,我们先思考:为了解决这个问题,可以大致把它分成几步:

  1. 输入 Morse Code 文本
  2. 切段,基本上每一段对应一个字符
  3. 翻译,查询 Morse -> Plaintext 字典,把切段翻译成明文。
  4. 输出 Plain text 文本。

这样看上去,只有中间两步需要我们细细考虑。

分析重点、细化决策

功能实现起来往往没有一开始想象得那么简单,因为我们可能遇到各种特殊要求。我们还应该对「边界情况」(Corner Cases) 有足够的敏感。而这种敏感来源于对问题的细致理解。

本题中,一个易于想到的想法是,把 '/' 看作「定界符」(Delimiter),凡遇到它就切段。这是很普遍的做法,只不过一般情况下,定界符是空白。

现在有一个特殊要求:三个 '/' 代表一个 ' ',五个 '/' 代表一个 '.'。这是本题唯一的小难点。这说明「有的 '/' 并不是定界符」。虽然我们应该避免设计出功能杂糅的标记,但有时不得不处理它们。为此,可以提出两种解决方案:

  1. 把这个问题在「切段模块」解决:每次遇到疑似定界符的 '/' 时,都「窥视」(Peek) 一下后面的那个字符是不是 '/',只有这样才能确定该不该在此切段。
  2. 把这个问题在「翻译模块」解决:依然把 '/' 当作单纯的定界符,凡是遇到 '/' 就切段,这样会出现一些空段。翻译时,如果发现四个空段,就翻译成 '.';不然,如果发现两个空段,就翻译成 ' '

如果采用第一种方案,那么不得不对 '/' 做出复杂的判断才能找对分界的位置。而如果采用第二种方案,虽然引入了空段,但处理起来相对简单。我认为,不应该把翻译相关的任务( '/' 涉及到的翻译细节)交给 split() 函数。所以,我们采用第二种方案。感兴趣的同学们可以试试第一种。

开始实现

在切割模块中,需要注意最后一段的切割问题——它之后可能没有 '/',也就是说一个切段既可能以 '/' 结尾,也可能以 '\0' 结尾。这将复杂化我们的判断逻辑。为此,可以使用「哨兵」(Sentinel) 思想。哨兵是用来简化逻辑的元素。如果我们在最后一段以 '\0' 结尾的情况下给 str 结尾添加一个 '/',就可以简化逻辑。下面是一份直观的实现:

vector<string> split(string str) {
    if (str.empty() || str.back() != '/')
        str.push_back('/'); // 哨兵,简化逻辑。
    vector<string> ret;     // 本书中要返回的对象一般都命名为 ret。
    auto i = str.begin(), ed = str.end();  // i 标记切段的开头
    for (auto iter = i; iter != ed; ++iter) {
        if (*iter == '/') {
            ret.push_back({i, iter});
            i = iter + 1;
        } // 确认了一个切段。现在把这个切段装进 ret 中,并重新设置 i。
    }
    return ret;
}

然后是是解析模块。有前一个模块的帮助,现在思路可以很明确了。如果是普通的切段,就翻译得到一个字符。特别地,如果有连续两个空段,就是一个 ' ';如果有连续四个空段,就是一个 '.'。这个模块同样有「处理最后一组切段」的特殊问题。因为出现两个空白切段时还不能确定是空格还是句号,所以我采用的方法是先记下目前遇到的空白切段数量,等遇到一个普通切段时再做决定(延迟决策,Lazy Policy)。但如果切段数组的末尾有空段,那么循环退出时记录将得不到更新。所以我不得不再加一次检验。如果你使用的是别的方法,比如加哨兵,或许不需要这样做。

string translate(vector<string> vec) {
    // 下面是我们的字典。map 类型可以把一种「键」(Key) 和一个「值」(Value) 对应起来。
    // 没有必要在每次调用 translate() 时都创建一个字典,不如设为 static。( ̄▽ ̄)"
    const static unordered_map<string, char> dic{
        {".-", 'A'},    {"-...", 'B'},  {"-.-.", 'C'},  {"-..", 'D'},   {".", 'E'},
        {"..-.", 'F'},  {"--.", 'G'},   {"....", 'H'},  {"..", 'I'},    {".---", 'J'},
        {"-.-", 'K'},   {".-..", 'L'},  {"--", 'M'},    {"-.", 'N'},    {"---", 'O'},
        {".--.", 'P'},  {"--.-", 'Q'},  {".-.", 'R'},   {"...", 'S'},   {"-", 'T'},
        {"..-", 'U'},   {"...-", 'V'},  {".--", 'W'},   {"-..-", 'X'},  {"-.--", 'Y'},
        {"--..", 'Z'},  {"-----", '0'}, {".----", '1'}, {"..---", '2'}, {"...--", '3'},
        {"....-", '4'}, {".....", '5'}, {"-....", '6'}, {"--...", '7'}, {"---..", '8'},
        {"----.", '9'}}; // map 的使用方法可以查阅 cppreference.com

    string ret;
    uint32_t blank_cnt = 0;
    for (auto const &i : vec) {
        if (i == "") {
            ++blank_cnt;
        } else {
            if (blank_cnt) {
                ret.push_back(blank_cnt == 2 ? ' ' : '.');
                blank_cnt = 0;
            }
            ret.push_back(dic.at(i));
        }
    }
    if (blank_cnt) // Do not forget trailing blanks.
        ret.push_back(blank_cnt == 2 ? ' ' : '.');
    return ret;
}

注意我使用一个独立的词典来记录「莫尔斯电码 -> 明文」的对应关系。这种思想是「数据与逻辑分离」。不应该把相关数据都写成逻辑判断(比如 if-else),那样会使数据和处理的逻辑杂糅在一起,不但不能使代码清晰易读,还难以修改。

很好,有了这两个模块,主逻辑就极为清晰了。

int main() {
    uint32_t t; cin >> t;
    for (uint32_t query = 0; query < t; ++query) {
        string morse; cin >> morse;
        printf("case #%u:\n%s\n", query, translate(split(morse)).data());
    }
}

很多语言标准库的 string 自带切分函数。如果你使用的是这样的语言,可以直接调用它。尽管如此,自己实现一个类似功能的函数也是非常好的锻炼——相信如果没有看过本答案的话,你可能会在不少地方犹豫、踩坑。这样考虑各种细节的过程都是很好的思维训练。

Comments

Fifnmar

我的注释中似乎暴露了什么 Orz。

这是我正在写的东西的一个摘录。