Skip to content

S 老师的签到 - 题解

标签与难度

标签: 广度优先搜索 (BFS), 贪心算法, 动态规划, 字符串, 字典序, 网格图 难度: 1600

题目大意喵~

各位亲爱的小伙伴们,大家好呀,喵~ S 老师给我们出了一道有趣的签到题哦!

题目是这样的:我们被丢进了一个 n x m 大小的矩阵里,每个格子上都有一个小写字母。我们的任务是从左上角的 (1, 1) 出发,一路探险到右下角的 (n, m). 但是呢,我们不是可以随便乱跑的哦,每次只能向或者向移动一步,就像一只乖巧的小猫咪一样,不能走回头路的说!

我们走过的每一个格子上的字母会连成一个字符串。在所有可能的路径中,我们需要找到那条能组成字典序最小的字符串的路径,并把这个字符串告诉S老师。

举个例子,如果路径是 (1,1) -> (1,2) -> (2,2),格子上的字母分别是 'a', 'c', 'b',那么形成的字符串就是 "acb"。我们的目标就是找到那个最 "小" 的字符串,呐!

解题思路分析

喵哈哈,这道题看起来是不是有点像迷宫寻路?但是目标不是找最短路,而是找字典序最小的路径字符串。一看到这种“最优”问题,我们的小脑袋瓜里可能会蹦出好几种想法,比如深度优先搜索(DFS)、广度优先搜索(BFS)或者动态规划(DP),对吧?

朴素的想法和它的问题

最直接的想法就是用 DFS 遍历所有从起点到终点的路径,把每条路径的字符串都记录下来,最后找一个最小的。但是,等一下喵!从 (1, 1)(n, m) 的路径数量可是非常非常多的,呈指数级增长。如果 nm 稍微大一点,比如都等于20,路径数量就会是个天文数字,直接搜索肯定会超时,S老师可等不了那么久哦!

聪明的想法:贪心 + BFS

既然要字典序最小,我们的贪心小雷达就响起来了!字典序比较是从字符串的第一个字符开始的。也就是说,我们希望路径的第一个字符尽可能小,然后是第二个,第三个……以此类推。

这引导我们得出一个关键思路:在每一步决策时,都选择当前能走到的、字母最小的格子

但是这里有个小陷阱!如果在某一步,我们有多个选择,它们对应的字母都是当前最小的,我们该怎么办呢?比如从A点可以走到B点(字母'c')和C点(字母'c')。如果我们只随便选一个(比如B),但从C点出发的后续路径可能比从B点出发的要好得多。

所以,正确的贪心策略应该是:在每一步,我们都要考虑所有能通往最小字母格子的路径。我们不能只保留一条“最优”路径,而是要保留一个“最优路径的集合”,这个集合里的所有路径,到目前为止生成的字符串前缀都是一样的,而且是所有可能路径中字典序最小的。

这听起来是不是有点像 BFS 的思想?一层一层地扩展!

“逐层”构建最小字典序字符串

我们可以把所有从 (1,1) 出发,走 k 步能到达的格子看作是第 k 层。

  • 第0步: 我们只有一个选择,就是在起点 (1,1)。所以我们已有的路径集合只包含起点,当前字符串就是 c[1][1]
  • 第1步: 从第0步的位置(也就是起点)出发,我们可以走到 (1,2) 或者 (2,1)。我们看看这两个格子的字母,找出其中较小的那一个,比如是 'a'。那么,所有下一步能走到字母 'a' 的格子,就构成了我们新的“最优路径集合”的前沿。
  • 第k步: 假设我们已经知道了走 k-1 步能到达的所有最优位置。现在,我们把这些位置所有能走的下一步(向下或向右)都收集起来。在这些“下一步”的候选格子中,找到最小的字母 min_char。然后,我们把 min_char 添加到结果字符串的末尾。新的最优位置集合,就是这些候选格子中字母恰好为 min_char 的那些。

我们重复这个过程,一步一步地确定结果字符串的每一个字符。总共需要走 (n-1) + (m-1) 步,所以路径(字符串)的总长度是 n + m - 1

这个过程就像一个特殊的 BFS,我们不是按距离扩展,而是按路径长度(步数)来分层扩展。在每一层,我们都贪心地选择最小的字符,并保留所有通往这个最小字符的可能性,直到我们走到终点。这样就能保证我们得到的字符串一定是字典序最小的啦,喵~

举个栗子 🌰: 假设有如下3x3的网格:

a b d
c a c
f e b
  1. 第0步: 路径只有 (1,1),字符串是 "a"。当前位置集合 S = {(1,1)}
  2. 第1步: 从 (1,1) 可以到 (1,2) (b) 和 (2,1) (c)。最小字母是 'b'。所以第2个字符是 'b'。新的位置集合 S = {(1,2)}。当前字符串 "ab"。
  3. 第2步: 从 (1,2) 可以到 (1,3) (d) 和 (2,2) (a)。最小字母是 'a'。所以第3个字符是 'a'。新的位置集合 S = {(2,2)}。当前字符串 "aba"。
  4. 第3步: 从 (2,2) 可以到 (2,3) (c) 和 (3,2) (e)。最小字母是 'c'。所以第4个字符是 'c'。新的位置集合 S = {(2,3)}。当前字符串 "abac"。
  5. 第4步: 从 (2,3) 只能到 (3,3) (b)。最小字母是 'b'。所以第5个字符是 'b'。新的位置集合 S = {(3,3)}。当前字符串 "abacb"。 最终到达终点,得到的最小字典序字符串就是 "abacb",喵~

代码实现

下面就是我根据这个思路,精心为大家准备的代码啦!注释写得很详细,希望能帮助大家理解哦~

cpp
#include <iostream>
#include <vector>
#include <string>
#include <algorithm>
#include <set>

// 使用 C++ 标准命名空间,喵~
using namespace std;

void solve() {
    int n, m;
    cin >> n >> m;

    // 用 vector<string> 来存储网格,访问起来很方便,就像 grid[row][col]
    vector<string> grid(n);
    for (int i = 0; i < n; ++i) {
        cin >> grid[i];
    }

    // 路径总长度是 n + m - 1
    // 我们需要走 n + m - 2 步
    int total_steps = n + m - 2;

    // 结果字符串,先放上起点的字符
    string result_path = "";
    result_path += grid[0][0];

    // `current_front` 存储当前所有最优路径的前沿位置
    // 使用 vector 就足够了,因为我们每一步都会生成一个全新的
    vector<pair<int, int>> current_front;
    current_front.push_back({0, 0}); // 0-indexed 坐标

    // 循环 n + m - 2 次,一步一步构建字符串
    for (int step = 0; step < total_steps; ++step) {
        char min_char = 'z' + 1; // 初始化为比 'z' 大的字符

        // `next_candidates` 存储所有可能的下一步位置
        // 使用 set 可以自动去重并排序,让逻辑更清晰
        set<pair<int, int>> next_candidates;

        // 1. 从当前所有最优位置出发,找出所有可能的下一步
        for (const auto& pos : current_front) {
            int r = pos.first;
            int c = pos.second;

            // 向右走
            if (c + 1 < m) {
                next_candidates.insert({r, c + 1});
            }
            // 向下走
            if (r + 1 < n) {
                next_candidates.insert({r + 1, c});
            }
        }

        // 2. 在所有下一步的候选位置中,找到最小的字符
        for (const auto& pos : next_candidates) {
            min_char = min(min_char, grid[pos.first][pos.second]);
        }
        
        // 3. 将这个最小字符追加到我们的结果中
        result_path += min_char;

        // 4. 准备下一轮的 `current_front`
        // 它只包含那些字符等于 `min_char` 的位置
        vector<pair<int, int>> next_front;
        for (const auto& pos : next_candidates) {
            if (grid[pos.first][pos.second] == min_char) {
                next_front.push_back(pos);
            }
        }
        // 更新当前的前沿位置集合
        current_front = next_front;
    }

    cout << result_path << endl;
}

int main() {
    // 加速输入输出,让程序跑得更快,喵~
    ios_base::sync_with_stdio(false);
    cin.tie(NULL);

    solve();

    return 0;
}

复杂度分析

  • 时间复杂度: O((n+m)min(n,m))O((n+m) \cdot \min(n, m))

    • 主循环执行 n + m - 2 次,代表路径的每一步。
    • 在循环内部,我们需要处理 current_front 里的所有点。current_front 里的点都位于同一条“反对角线”上(即所有坐标 (r, c) 满足 r + c = step)。这样的点的数量最多是 min(n, m)
    • current_front 生成 next_candidates 的时间与 current_front 的大小成正比。next_candidates 的大小最多是 2 * |current_front|
    • 遍历 next_candidates 找最小字符和构建下一轮的 next_front 也与 next_candidates 的大小成正比。
    • 所以,每一步操作的复杂度大约是 O(min(n,m))O(\min(n, m))。总时间复杂度就是 O((n+m)min(n,m))O((n+m) \cdot \min(n, m)),对于这道题的规模来说是相当高效的!
  • 空间复杂度: O(nm)O(n \cdot m)

    • 主要的空间开销是存储输入的 n x m 网格,即 O(nm)O(n \cdot m)
    • 算法运行中,current_frontnext_candidates 两个容器用来存储位置。它们的大小最大都不会超过 O(n+m)O(n+m)(实际上是 O(min(n,m))O(\min(n,m))),所以辅助空间的开销小于存储网格的开销。
    • 结果字符串 result_path 的长度是 n + m - 1,占用 O(n+m)O(n+m) 的空间。
    • 因此,总的空间复杂度由输入网格主导,为 O(nm)O(n \cdot m)

知识点总结

这道题真是一次愉快的思维体操呢,喵~ 从中我们可以学到:

  1. 贪心算法的应用: 对于求解字典序最小的问题,贪心是一个非常强大的武器。关键在于正确地定义每一步的“贪心选择”。
  2. 逐层BFS (Layer-by-Layer BFS): 这种方法非常适合处理“按步数”或“按距离”分阶段决策的问题。它保证了我们在做出第 k 步决策时,已经拥有了所有关于第 k-1 步的最优信息。
  3. 状态的定义: 在复杂的搜索问题中,如何定义“状态”至关重要。在这道题里,我们的状态不是单一的路径,而是“所有已走过相同最优前缀的路径的前沿位置集合”。这避免了过早地放弃潜在的最优解。
  4. 数据结构的选择: 使用 std::set 来存储下一步的候选位置,可以优雅地处理重复坐标,让代码更简洁。虽然用 std::vector 加上排序去重也能实现,但 set 的意图更明确。

希望这篇题解能帮助到你,如果还有不明白的地方,随时可以来问我哦!一起加油,AC更多的题目吧,喵~!