【问题标题】:Minimum Window Substring最小窗口子串
【发布时间】:2021-11-29 12:23:28
【问题描述】:

我正在做 Leetcode "Minimum Window Substring" 练习题:

给定两个长度分别为mn的字符串st,返回s最小窗口子字符串,这样每个t 中的字符(包括重复项)包含在窗口中。如果没有这样的子字符串,则返回空字符串“”。

将生成测试用例,使得答案是唯一的。

示例 1:

输入:s = "ADOBECODEBANC", t = "ABC"
输出:“银行”

示例 2:

输入:s = "a", t = "a"
输出:“a”

示例 3:

输入:s = "a", t = "aa"
输出:“”
说明:t'a's 都必须包含在窗口中。由于s的最大窗口只有一个'a',所以返回空字符串。

我的解决方案使用两个地图来跟踪字符数:

  • strrmap 是用来统计窗口中的字符数和
  • patt 映射用于给定的模式字符串。

它还使用两个索引startend 来跟踪当前窗口(包括end)。

解决方案的核心是一个将end 推进的外循环,将新字符添加到strr。只要窗口有效,它就会运行一个内部循环:

  • 检查和更新目前看到的最短窗口
  • 删除窗口中的第一个字符
  • 提前start.

一旦外循环结束,它遇到的最短窗口应该是答案。

#include <iostream>
#include <unordered_map>

bool check_map(std::unordered_map<char, int> patt, std::unordered_map<char, int> strr)
{
  for(auto data:patt)
  {
    if(strr[data.first] != data.second)
      return false; 
  }
  return true;
}

std::string Substring(std::string s, std::string t)
{
  std::unordered_map<char, int> patt;
  std::unordered_map<char, int> strr;
  std::string ans;
  for(int i=0; i<t.length(); i++)
    patt[t[i]]++;
  int start = 0, length = INT_MAX;;
  for(int end=0; end<s.length(); end++)
  {
    strr[s[end]]++;
    while(check_map(patt, strr))
    {
      if(length > (end-start+1))
      {
        ans = s.substr(start, end+1);
        length = end-start+1;
      }
      strr[s[start]]--;
      if(strr[s[start]] == 0)
        strr.erase(s[start]);
      
      start++;
    }
  }
  return ans;
}

int main()
{
  std::string s = "ADOBECODEBANC",
    pattern = "ABC";
  std::cout << "String: " << s << std::endl
            << "Pattern: " << pattern << std::endl
            << "Minimum Window Substring is " << Substring(s, pattern) << std::endl;
  return 0;
}

例如问题中的 1,程序应该返回“BANC”,而是返回“ADOBEC”。程序输出:

字符串:ADOBECODEBANC 图案:ABC 最小窗口子字符串是 ADOBEC

我的代码中的错误在哪里?

【问题讨论】:

  • “你能帮我解决代码中的错误吗”什么错误??
  • 你能解释一下你的代码应该做什么吗?为什么要使用两张地图?每个的目的是什么?你为什么要从字符串中删除?没有解释的代码很难找到问题。
  • 我练习编码问题,这就是它卡在上面的原因。不,不是作业@outis

标签: c++ string algorithm data-structures


【解决方案1】:

非常抱歉,我无法回答您“我的代码中的错误在哪里”的具体问题。

但我能做的是帮助您理解问题、开发算法并展示众多潜在解决方案之一。

问题的标题已经暗示,应该使用什么算法:所谓的“滑动窗口”算法。

你会从 Said Sryheni here 那里找到一个很好的解释。

对于您的问题,我们将使用灵活大小的滑动窗口方法。

我们将逐个字符地遍历源字符串并等待,直到满足某个条件。在这种情况下,直到我们“看到”所有需要搜索的字符。然后,我们会找到一个窗口,所有这些字符都在其中。

在给定的示例中,滑动窗口的结尾始终是源字符串中最后读取的字符。这是因为最后读取的字符满足条件。然后我们需要找到窗口的开始。在这种情况下,仍然满足条件的源字符串中最右边的字符(搜索字符)的位置。

然后我们会继续读取源字符串,等待下一个条件满足。然后我们会重新计算滑动窗口的位置。

顺便说一句。其他字符,除了源字符串中的搜索字符外,只是噪声,只会扩展滑动窗口的宽度。

但是我们如何满足条件呢?

尤其是,由于搜索字符的顺序无关紧要,甚至可以有双字符?

解决方案是我们将“计数”。

首先,我们将统计搜索字符串中所有字符的出现次数。此外,我们将使用第二个计数器来指示是否所有字符都匹配。

然后,在对源字符串进行迭代时,我们将减少我们看到的任何字符的计数器。如果搜索字符的计数达到 0,那么我们将递减“匹配”计数器。并且,如果为 0,我们找到了所有搜索字符并且满足条件。然后我们就可以开始计算窗口位置了。

请注意:我们只会减少匹配计数器,如果在减少字符计数器后,这将是 0。

示例(我将省略带有“x”的噪音):

  • 搜索字符串“ABC”,源字符串:“xxAxxxxBBBxCAxx”。
  • 初始字符计数器将为 1,1,1,匹配计数器将为 3。
  • 读取第一个“A”。计数器:0,1,1  2
  • 读取第一个“B”。计数器:0,0,1  1
  • 读取第二个“B”。计数器:0,-1,1  1(仅当字符计数器达到 0 时,我们才会减少匹配计数器)。
  • 阅读第 3 个“B”。计数器:0,-2,1  1(仅当字符计数器达到 0 时,我们才会减少匹配计数器)。
  • 读取第一个“C”。计数器:0,-2,0  0。匹配计数器为 0,条件满足。

请注意。负数的字符数表示更右端有更多相同的字符。

接下来,既然条件已经满足,我们来检查滑动窗口的位置。结束位置很明确。这是源字符串中最后读取的字符。这导致了条件的满足。所以,很简单。

要获取滑动窗口的起始位置,我们将从源字符串的开头开始检查,我们可以在其中找到一个搜索字符。我们将增加它的计数,如果计数大于 0,我们将再次增加匹配计数。如果匹配计数大于 0,我们就找到了一个起始位置。现在计数器:1,-2,0  1

下一次检查的起始位置将递增。我们永远不会从 0 开始,而只会从最后使用的开始位置开始。

好的,找到开始和结束位置后,我们有了第一个窗口,并将寻找可能的更小的窗口。我们将继续读取源字符串并检查

  • 计算滑动窗口位置后,计数器为:1,-2,0  1
  • 阅读下一个“A”。计数器:0,-2,0  0。再次满足条件。
  • 我们继续进行滑动窗口检测。最后一个起始位置指向第一个“A”之后的字符“x”
  • 增加起始位置并跳过所有“x”。继续
  • 读取第一个“B”。计数器:0,-1,0  0
  • 读取第二个“B”。计数器:0,0,0  0
  • 读取 3d 'B'。计数器:0,1,0  1. 窗口位置计算完成。起始位置是第3个B,这个窗口比上一个窗口小,拿去吧。

由于源字符串被消耗,我们完成并找到了解决方案。

如何实现。我们将对计数器进行一个小抽象,并将其打包到一个迷你类中。这将封装字符和匹配计数的内部处理,并且可以在以后进行优化。

适用于所有字符类型的计数器可以如下实现:

struct SpecialCounterForGeneralChar {

    std::unordered_map<char, int> individualLetter{};
    int necessaryMatches{};

    SpecialCounterForGeneralChar(const std::string& searchLetters) {
        for (const char c : searchLetters) individualLetter[c]++;
        necessaryMatches = individualLetter.size();
    }
    inline void incrementFor(const char c) {
        individualLetter[c]++;
        if (individualLetter[c] > 0)
            ++necessaryMatches;
    }
    inline void decrementFor(const char c) {
        individualLetter[c]--;
        if (individualLetter[c] == 0)
            --necessaryMatches;
    }
    inline bool allLettersMatched() { return necessaryMatches == 0; }
};

如果我们对输入数据了解更多,例如限制为 8 位字符,我们也可以使用:

struct SpecialCounter {

    char individualLetter[256]{};
    int necessaryMatches{};

    SpecialCounter(const std::string& searchLetters) {
        for (const char c : searchLetters) {
            if (individualLetter[c] == 0) ++necessaryMatches;
            individualLetter[c]++;
        }
    }
    inline void incrementFor(const char c) {
        individualLetter[c]++;
        if (individualLetter[c] > 0)
            ++necessaryMatches;
    }
    inline void decrementFor(const char c) {
        individualLetter[c]--;
        if (individualLetter[c] == 0)
            --necessaryMatches;
    }
    inline bool allLettersMatched() { return necessaryMatches == 0; }
};

这会比上面的稍快(在给定的限制下)

然后,程序的其余部分将只有 15 行代码。

这里的重要信息是,在我们开始实现第一行代码之前,我们需要考虑很长时间。

一个好的算法和设计,将帮助我们找到一个最佳的解决方案。

请参阅下面的完整示例解决方案:

#include <string>
#include <iostream>
#include <unordered_map>
#include <limits>

using Index = unsigned int;

// We want to hide the implementation of the special counter to the outside world
struct SpecialCounter {

    char individualLetter[256]{};
    int necessaryMatches{};

    SpecialCounter(const std::string& searchLetters) {
        for (const char c : searchLetters) {
            if (individualLetter[c] == 0) ++necessaryMatches;
            individualLetter[c]++;
        }
    }
    inline void incrementFor(const char c) {
        individualLetter[c]++;
        if (individualLetter[c] > 0)
            ++necessaryMatches;
    }
    inline void decrementFor(const char c) {
        individualLetter[c]--;
        if (individualLetter[c] == 0)
            --necessaryMatches;
    }
    inline bool allLettersMatched() { return necessaryMatches == 0; }
};


std::string solution(std::string toBeSearchedIn, std::string toBeSearchedFor) {

    // Counter with somespecial properties
    SpecialCounter counter(toBeSearchedFor);

    // This will be slided. End of window is always last read character. Start of window may increase 
    Index currentWindowStart {};

    // The potential solution
    Index resultingWindowStart {};
    Index resultingWindowWith{ std::numeric_limits<size_t>::max() };

    // Iterate over all characters of the string under evaluation
    for (Index index{}; index < toBeSearchedIn.length(); ++index) {

        // We saw a character. So, subtract from characters to be searched
        counter.decrementFor(toBeSearchedIn[index]);

        // If we hit and found all necessary characters and adjusted the sliding windows start position
        while (counter.allLettersMatched()) {

            // Calculate start and width of sliding window. So, if we found a new, more narrow window
            const unsigned int currentWindowWith{ index - currentWindowStart + 1 };
            if (currentWindowWith < resultingWindowWith) {

                // Remember one potential solution
                resultingWindowWith = currentWindowWith;
                resultingWindowStart = currentWindowStart;
            }

            // Now, for the sliding window. We saw and decremented thsi character before
            // Now we see it in the sliding window and increment it again.
            counter.incrementFor(toBeSearchedIn[currentWindowStart]);

            // Slide start of window to one to the right
            currentWindowStart++;
        }
    }
    return (resultingWindowWith != std::numeric_limits<size_t>::max()) ? toBeSearchedIn.substr(resultingWindowStart, resultingWindowWith) : "No solution";
}

int main()
{
    const std::string toBeSearchedIn{ "KKKADOBECODEBBBAANCKKK" };
    const std::string toBeSearchedFor = { "AABBC" };

    std::cout << "Solution:\n" << solution(toBeSearchedIn, toBeSearchedFor) << '\n';
}

【讨论】:

    【解决方案2】:

    由于该问题是练习尝试的一部分,因此该答案不会为激发它的练习问题提供完整的解决方案。相反,它会做被问到的事情:它会指出发布代码的主要问题,以及如何发现它。

    代码考试

    一种巧妙的方法是检查需求、设计和实现之间的不匹配;巧妙,因为这种方法更像是一门艺术而不是一门科学,你很容易误入歧途。这基本上涉及在您的头脑中运行设计和实现,就好像您是处理器一样,尽管可能一次只检查一小部分代码。

    一些实现看起来不错,例如:end 在外循环中前进,检查较小的窗口(并替换之前的最小窗口)。有些可以经过更仔细的检查,例如在检查窗口有效后从窗口直方图中删除条目(为了算法的正确性,考虑好的循环不变量非常有用,例如“窗口应该始终有效”,并确保它们永远成立)。

    但是,当您查看 check_map 时,发现不匹配。一个问题要求是:

    t 中的每个字符(包括重复项)都包含在窗口中

    虽然措辞有一点含糊(如果来自t 的字符出现在窗口中t 更多,那么该窗口是否有效?),直读要求是s 中的字符数必须至少是t 中的字符数。在check_map 中,正在精确比较计数。这强烈暗示了一个需要更仔细检查的地方。

    测试

    可以捕获各种错误的半自动化、系统化方法是使用单元测试和集成测试(搜索本网站和整个网络将解释这些术语)。测试的一个关键部分是确定要测试的边缘案例。例如,如果您尝试使用搜索字符串“ACBA”和模式“AB”,示例程序会正确找到最小窗口“BA”。但是,对于搜索字符串“ACBBA”,它返回“ACB”作为最小窗口。这表明该实现存在字符计数问题,这使得check_map 成为主要嫌疑人(而更新strr 的行成为次要嫌疑人)。

    对于另一个测试,请考虑搜索字符串“A123B12345A12BA123A”和模式“AAB”。这有 3 个潜在窗口,中间最短。如果您修复check_map 并针对此测试用例测试您的代码,代码将返回“A12BA123A”,而不是“A12BA”。这表明测试窗口有效性(再次check_map)或设置答案有问题。一些脚手架代码(例如在更新时打印startendans)将揭示原因。

    调试

    可以揭示实现正确性问题的最通用方法是使用交互式调试器。对于示例代码,可以在各个关键点设置断点,例如循环开始和分支。您还可以使这些断点有条件,在代码应该找到新窗口时的索引处。如果你这样做,你会发现check_map 在你期望它返回true 的情况下返回false。从那里,您可以开始进入 check_map 以观察它为什么这样做。

    修复后,代码仍然存在问题,但您需要一个测试用例,例如上面带有“A123B12345A12BA123A”的测试用例,因为“ADOBECODEBANC”测试用例不明显。单步执行内部循环并检查各种变量会发现问题所在。

    检查 API

    错误基本上都有一个原因:您希望代码做一件事,但它做了不同的事情。原因之一是对 API 的误解,因此阅读 API 文档以确保您的理解正确可能会有所帮助。通常,在访问 API 之前,您会希望找到与您理解的行为不符的特定 API 调用,而调试可以揭示这些调用。我之所以提到这一点,是因为示例代码中有一个 API 调用不正确。

    结论

    上述每种方法都会导致相同的错误:check_map 中的比较。如果有合适的测试用例,其中两个也可能导致额外的错误。

    附加说明

    效率

    Substring 不仅检查和跟踪t 中的那些字符,还检查和跟踪所有字符。这将导致对s 中的每个字符执行内部循环体(包括更新ans),而不仅仅是模式中存在的那些。通常,您应该使实现正确,然后使其高效。但是,在这种情况下,让Substring 忽略不在模式中且更接近问题描述的字符是微不足道的。

    类型

    这个答案的早期表述,解决了问题的早期表述,涵盖了检查类型以检查它们是否最合适。对于更新后的问题,这不再导致发现错误。

    早期公式中的一点仍然适用于设计解决方案。

    从概念上讲,模式字符和当前窗口中的字符最合适的数据类型是multiset。随着窗口的移动,可以简单地从多重集中添加和删除字符。当前窗口的有效性是一个简单的子集操作(pattern ⊆ window)。但是,STL 中的multiset 不对应于数学多重集。

    【讨论】:

      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2013-10-09
      • 2020-06-26
      • 1970-01-01
      • 2012-03-21
      • 2011-05-27
      • 1970-01-01
      相关资源
      最近更新 更多