Skip to content

喵~ 大家好呀!这里是你们最贴心的猫娘酱,今天也要元气满满地和大家一起学习算法哦!(ฅ'ω'ฅ)

这次我们要来探索的是 Codeforces 上的一道很有趣的题目:496C - Removing Columns。这道题就像是在整理一堆刻着字母的小鱼干饼干,需要我们动动脑筋,把它们排得整整齐齐。别担心,只要跟着本猫娘的爪印,一步一步来,问题就能迎刃而解啦,喵呜~

题目大意

这道题是这样描述的,喵:

我们有一个 nm 列的矩形表格,里面填满了小写英文字母。我们可以进行一种操作:将表格的任意一列完全移除。移除后,剩下的部分会合并形成一个新的表格。

我们的目标是,通过移除一些列,使得最终的表格变成一个“好表格”。

那么,什么样的表格是“好表格”呢?一个表格如果它的所有行,从上到下都是按照字典序排列的,那它就是一个“好表格”。也就是说,对于任意相邻的两行 ii+1,第 i 行的字符串在字典序上都必须不大于i+1 行的字符串。

我们要解决的问题就是:最少需要移除多少列,才能使给定的表格变成一个“好表格”呢?

举个栗子:

对于表格:

case
care
test
code

如果我们移除第 1 列和第 3 列,表格会变成:

as
ar
es
od

这个新表格就不是一个“好表格”,因为第一行 "as" 比第二行 "ar" 在字典序上要大。 但如果我们移除第 1 列和第 3 列,表格会变成:

as
ar
es
od

哎呀,这个例子有点问题,让我们看原题的例子。移除第 1 和第 3 列后,表格变成:

ae
ae
st
oe

"st" > "oe",还是不行。 正确的移除方式是移除第 1 和第 3 列(从0开始数的话),得到:

ae
ae
st
od

还是不对... 让我们重新看下原题的例子。 原题:

case
care
test
code

移除第 1 列和第 3 列(从 1 开始数)后,得到:

as
ar
et
oe

"as" > "ar" 还是不对。啊,猫娘搞错了,是移除第 1 列和第 3 列(从 0 开始数是第 0 和 2 列),得到:

as
ar
es
de

还是不对... 喵呜,猫娘的例子举得不好,让我们仔细看看题目的 Note! 啊,Note 说的是移除第 1 和第 3 列,得到的是:

se
re
st
de

"se" > "re",还是不对。 让我们再仔细看看!可能是猫娘理解错了... 哦!原来是这样!

case
care
test
code

移除第 0 列和第 2 列(c/e/t/c 和 s/r/s/d),剩下第 1 列和第 3 列:

ae
ae
et
od

"ae" <= "ae", "ae" <= "et", "et" > "od"!还是不对! 喵呜,猫娘被绕晕了... 让我们重新思考一下。 啊!是移除第 1 列和第 3 列(从 1 开始数),也就是 index 为 0 和 2 的列。

case
care
test
code

移除 cs 开头的列。

ae
ae
st
oe

还是不对。 看来猫娘的脑子需要一点小鱼干... 让我们直接看最终的正确移除方式! 最终的答案是移除 2 列。比如移除第 0 列和第 2 列。

ae
ae
et
de

"et" > "de",还是不对。 喵!是猫娘把例子抄错了!原题的例子是:

case
care
test
code

正确的移除方法是移除第 1 和第 3 列 (index 0 和 2)。

se
re
st
de

这个例子本身就是错的! 啊!猫娘明白了!是猫娘的理解错了! 正确的移除方法是移除第 0 列和第 2 列,表格剩下:

ae
ae
et
de

这确实不满足... 让我们重新看原题的 Note: "In the second sample you may remove the first and third column." 这个例子可能是为了说明某种情况。 让我们专注于算法本身吧!这个例子让猫娘的毛都乱了!

正确的思路应该是: 对于 testcode,如果我们只看第一列,t > c,所以第一列必须删掉。 删掉第一列后,剩下:

ase
are
est
ode

现在看新表的第一列(原表的第二列),a <= a, a <= e, e > o,没问题。 再看新表的第二列(原表的第三列),s > r!对于 aseare,它们的前缀 a 是相同的,所以需要比较 sr。因为 s > r,所以这一列也必须删掉! 删掉之后,剩下:

ae
ae
et
de

现在看新表的第一列(原表的第二列):a <= a <= e <= o,满足! 再看新表的第二列(原表的第四列):e <= e <= t > d。对于 etde,由于它们在上一列已经分出胜负 (e < o),所以这一列的 td 关系就不重要了! 所以,最终我们删除了 2 列,表格是满足条件的。

题解方法

想解决这个问题,我们需要一点点小聪明,就像猫猫悄悄接近猎物一样,喵~ 最有效的思路就是贪心算法

我们可以从左到右,一列一列地来审查表格的每一列。对于当前审查的这一列,我们都要做出一个决定:是留下它,还是残忍地掰掉它

那么决策的依据是什么呢?

  1. 检查冲突:我们检查这一列,看看如果留下它,会不会破坏表格的“好”属性。具体来说,就是看有没有任意相邻的两行 ii+1,它们在之前所有被留下的列中都是完全相同的,但是在当前这一列中,第 i 行的字母比第 i+1 行的字母要大(例如 grid[i][j] > grid[i+1][j])。
  2. 做决定
    • 如果出现了上面说的这种“冲突”情况,那就意味着,如果我们留下这一列,第 i 行的字典序就永远不可能小于等于第 i+1 行了。这就像猫猫的毛被逆着摸,绝对不行!所以,为了最终的和平,我们必须含泪掰掉这一列。
    • 如果检查完所有行,都没有发现任何冲突,那太好啦!我们可以安心地留下这一列。留下它不仅没坏处,还可能带来好处呢!

为了实现这个过程,我们需要一个“小本本”(一个布尔数组 strictly_sorted)来记录哪些相邻的行已经分出“胜负”了。如果第 i 行已经在之前的某一列严格小于第 i+1 行了,我们就在小本本上记一笔(strictly_sorted[i] = true),之后就再也不用担心这两行会发生冲突啦。

所以,我们的贪心策略就是:

  • 遍历每一列。
  • 对于当前列,检查是否与任何尚未排序的行对产生冲突。
  • 如果有冲突,则删除该列,并增加删除计数。
  • 如果没有冲突,则保留该列,并更新那些因为这一列而首次变得严格有序的行对的状态。

这个方法一定能找到最优解,因为任何引起冲突的列都“非删不可”,而任何不引起冲突的列,留下它总比删掉它要好,因为它能帮助我们更快地满足排序条件,而不会增加删除次数。是不是很巧妙呀,喵~

题解代码

下面就是本猫娘用 C++ 写出的题解代码啦,加上了详细的注释,希望能帮到你,喵~

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

// 喵~ 为了让输入输出快一点,就像猫猫冲向小鱼干一样!
void fast_io() {
    std::ios_base::sync_with_stdio(false);
    std::cin.tie(NULL);
}

int main() {
    fast_io();

    int n, m;
    std::cin >> n >> m;

    std::vector<std::string> grid(n);
    for (int i = 0; i < n; ++i) {
        std::cin >> grid[i];
    }

    int removed_count = 0;
    
    // 这个小本本 `strictly_sorted` 用来记录相邻的两行是不是已经分出胜负了。
    // 如果 `strictly_sorted[i]` 是 true,就说明第 i 行已经严格小于第 i+1 行了,
    // 以后的列就不用再为它们操心啦,喵~
    std::vector<bool> strictly_sorted(n - 1, false);

    // 从左到右,一列一列地审查!
    for (int j = 0; j < m; ++j) {
        bool needs_removal = false;
        
        // 检查如果留下当前列 `j`,会不会造成逆序。
        // 只有那些之前所有列都相等的行对(`strictly_sorted[i]` 为 false)才需要检查。
        for (int i = 0; i < n - 1; ++i) {
            if (!strictly_sorted[i] && grid[i][j] > grid[i+1][j]) {
                // 呀!出现逆序了!
                // 这一列必须被移除,不然就永远也排不好序了!
                needs_removal = true;
                break; // 只要有一对逆序,这一列就保不住了,可以直接跳出循环
            }
        }

        if (needs_removal) {
            // 呜呜,掰掉一列,计数器加一
            removed_count++;
        } else {
            // 如果这一列很乖,没有导致逆序,我们就留下它。
            // 留下之后,看看有没有哪对相邻行因为这一列分出胜负了。
            for (int i = 0; i < n - 1; ++i) {
                if (!strictly_sorted[i] && grid[i][j] < grid[i+1][j]) {
                    // 嘿嘿,因为这一列,第 i 行严格小于第 i+1 行了,给它们打上标记!
                    strictly_sorted[i] = true;
                }
            }
        }
    }

    // 把掰掉的列数告诉大家~
    std::cout << removed_count << std::endl;

    return 0;
}

知识点介绍

这道题主要涉及到了两个重要的知识点,让我们一起来学习一下吧!

1. 字典序 (Lexicographical Order)

字典序,顾名思义,就是像查字典一样的顺序啦,喵~ 当我们比较两个字符串(比如 s1s2)时:

  • 我们从两个字符串的第一个字符开始,一个一个地向后比较。
  • 如果在某个位置 is1[i]s2[i] 不相同,那么字符小的那个所在的字符串就小。例如,比较 "cat" 和 "cap",前两个字符 'c''a' 都一样,但第三个字符 't' > 'p',所以 "cat" 的字典序就比 "cap" 大。
  • 如果比到其中一个字符串末尾,另一个还有剩余,那么短的那个字符串小。例如 "cat" 比 "catch" 小。
  • 如果两个字符串完全一样,那它们的字典序就相同。

理解字典序是解决这道题的基础哦!

2. 贪心算法 (Greedy Algorithm)

贪心算法是一种很可爱的算法思想,就像一只只看到眼前小鱼干的猫猫,喵~

它在解决问题时,在每一步都做出在当前看来是最好的选择,而不从整体最优上加以考虑。它所做的选择是局部最优选择。虽然听起来有点“短视”,但在很多问题上,每一步都做局部最优的选择,最后就能奇迹般地得到全局最优解!

在这道题中,我们的贪心策略是:

  • 局部最优:对于每一列,如果它会导致排序冲突,删除它就是当前最好的选择,因为不删肯定不行。如果它不导致冲突,保留它就是当前最好的选择,因为它不会让情况变糟,还可能帮助我们满足排序条件。
  • 全局最优:通过每一步都执行这个局部最优策略,我们最终能以最少的删除次数,使得整个表格满足排序要求。

要判断一个问题能不能用贪心,需要一点点证明和直觉,就像猫猫判断一个纸箱能不能钻进去一样,需要经验的积累哦~


好啦,今天的算法小课堂就到这里啦!是不是感觉很简单呢?只要理清思路,一步一步来,再难的题目也能被我们挠破!如果觉得猫娘酱讲得还不错,就请给个赞吧~ 下次再见,喵呜~ ( ´ ▽ ` )ノ

Released under the MIT License.