【问题标题】:Efficient way to search a stream for a string在流中搜索字符串的有效方法
【发布时间】:2010-10-25 04:11:40
【问题描述】:

假设有一个文本流(或 Java 中的 Reader),我想检查一个特定的字符串。文本流可能非常大,所以一旦找到搜索字符串,我想返回 true 并尽量避免将整个输入存储在内存中。

天真地,我可能会尝试做这样的事情(在 Java 中):

public boolean streamContainsString(Reader reader, String searchString) throws IOException {
    char[] buffer = new char[1024];
    int numCharsRead;
    while((numCharsRead = reader.read(buffer)) > 0) {
        if ((new String(buffer, 0, numCharsRead)).indexOf(searchString) >= 0)
            return true;
    }
    return false;
}

当然,如果给定的搜索字符串出现在 1k 缓冲区的边界上,这将无法检测到它:

搜索文本:“stackoverflow”
流缓冲区 1:“abc............stack”
流缓冲区 2:“溢出.......xyz”

如何修改此代码,以便它正确地找到跨越缓冲区边界的给定搜索字符串,但不将整个流加载到内存中?

编辑:请注意,在流中搜索字符串时,我们会尝试尽量减少从流中读取的次数(以避免网络/磁盘中的延迟) 并保持内存使用量不变,无论流中的数据量如何。 string matching algorithm 的实际效率是次要的,但很明显,找到使用其中一种更有效算法的解决方案会很好。

【问题讨论】:

    标签: java algorithm string search stream


    【解决方案1】:

    这里有三个很好的解决方案:

    1. 如果您想要一些简单且相当快速的东西,请不要使用缓冲区,而是实现一个简单的非确定性有限状态机。您的状态将是您正在搜索的字符串的索引列表,您的逻辑看起来像这样(伪代码):

      String needle;
      n = needle.length();
      
      for every input character c do
        add index 0 to the list
        for every index i in the list do
          if c == needle[i] then
            if i + 1 == n then
              return true
            else
              replace i in the list with i + 1
            end
          else
            remove i from the list
          end
        end
      end
      

      如果它存在,这将找到字符串,您将永远不需要 缓冲区。

    2. 工作量稍大但速度更快:执行 NFA 到 DFA 的转换,提前计算出可能的索引列表,并将每个索引分配给一个小整数。 (如果您在 Wikipedia 上阅读了有关字符串搜索的内容,这称为 powerset 构造。)然后您有一个单一的状态,并且您对每个传入的字符进行状态到状态的转换。您想要的 NFA 只是字符串的 DFA,其前面带有一个不确定地丢弃字符或尝试使用当前字符的状态。您还需要一个明确的错误状态。

    3. 如果您想要更快的速度,请创建一个大小至少为 n 两倍的缓冲区,并由用户 Boyer-Moore 从 needle 编译状态机。你会遇到很多额外的麻烦,因为 Boyer-Moore 实现起来并不简单(尽管你会在网上找到代码),而且你必须安排将字符串滑过缓冲区。您必须构建或找到一个可以“滑动”而无需复制的 圆形 缓冲区;否则,您可能会回馈您从 Boyer-Moore 获得的任何性能提升。

    【讨论】:

    • Boyer Moore 似乎需要一个完整的搜索空间,因为它从模式字符串的最后一个字符开始向后工作。在这种情况下,问题的一半是缓冲区可能不完整,是的,使用两倍于模式字符串大小的缓冲区会降低部分读取的概率,但是读取部分模式的情况是没有被淘汰。
    • 我没有仔细看,错过了循环缓冲区位。我认为 Norman 的选项 3 是最有效的解决方案。 +1。
    • 为什么不在中间使用缓冲流来最小化来自外部流的读取;那么一次读取一个字符的惩罚至少是?
    • 因为我喜欢解决方案 1,所以我对这个答案的投票感到很遗憾 - 对于大多数人来说,超级简单且性能足够好(我想这很明显,但也许值得注意的是,所用列表的长度会最多增长到模式的长度)。但是我不赞成投票,因为我不相信 Boyer-Moore(具有左右行为),即使使用滑动循环缓冲区,对流搜索也有意义。我为此投票给 KMP。
    • 对于解决方案 1,当您写“状态将是您正在搜索的字符串的索引列表”时,您不应该写“搜索 for”,即索引是针而不是被搜索的文本。
    【解决方案2】:

    我对用于部分搜索的 Knuth Morris Pratt 算法进行了一些更改。由于实际比较位置总是小于或等于下一个位置,因此不需要额外的内存。带有 Makefile 的代码也可以在 github 上找到,它是用 Haxe 编写的,可以同时针对多种编程语言,包括 Java。

    我还写了一篇相关文章:searching for substrings in streams: a slight modification of the Knuth-Morris-Pratt algorithm in Haxe。文章提到了Jakarta RegExp,现在退休并在 Apache Attic 休息。 RE 类中的 Jakarta Regexp 库“match”方法使用 CharacterIterator 作为参数。

    class StreamOrientedKnuthMorrisPratt {
        var m: Int;
        var i: Int;
        var ss:
        var table: Array<Int>;
    
        public function new(ss: String) {
            this.ss = ss;
            this.buildTable(this.ss);
        }
    
        public function begin() : Void {
            this.m = 0;
            this.i = 0;
        }
    
        public function partialSearch(s: String) : Int {
            var offset = this.m + this.i;
    
            while(this.m + this.i - offset < s.length) {
                if(this.ss.substr(this.i, 1) == s.substr(this.m + this.i - offset,1)) {
                    if(this.i == this.ss.length - 1) {
                        return this.m;
                    }
                    this.i += 1;
                } else {
                    this.m += this.i - this.table[this.i];
                    if(this.table[this.i] > -1)
                        this.i = this.table[this.i];
                    else
                        this.i = 0;
                }
            }
    
            return -1;
        }
    
        private function buildTable(ss: String) : Void {
            var pos = 2;
            var cnd = 0;
    
            this.table = new Array<Int>();
            if(ss.length > 2)
                this.table.insert(ss.length, 0);
            else
                this.table.insert(2, 0);
    
            this.table[0] = -1;
            this.table[1] = 0;
    
            while(pos < ss.length) {
                if(ss.substr(pos-1,1) == ss.substr(cnd, 1))
                {
                    cnd += 1;
                    this.table[pos] = cnd;
                    pos += 1;
                } else if(cnd > 0) {
                    cnd = this.table[cnd];
                } else {
                    this.table[pos] = 0;
                    pos += 1;
                }
            }
        }
    
        public static function main() {
            var KMP = new StreamOrientedKnuthMorrisPratt("aa");
            KMP.begin();
            trace(KMP.partialSearch("ccaabb"));
    
            KMP.begin();
            trace(KMP.partialSearch("ccarbb"));
            trace(KMP.partialSearch("fgaabb"));
    
        }
    }
    

    【讨论】:

    • 最后一个跟踪应该返回 10 还是您没有同时调用 KMP 开始的事实。如果您正在寻找的只是存在感,您是否需要? Haxe 需要生成 javadocs :)
    • @i30817 你的运行结果是什么?它应该返回 8,因为“aa”出现在第 8 位:“ccarbbfgaabb”
    • @sw:谢谢你的贡献,太棒了!!但是不清楚,您的代码的许可证是什么?是公共领域吗?
    • @Sorin Postelnicu 它是公共领域。
    【解决方案3】:

    Knuth-Morris-Pratt search algorithm 从不备份;这只是您想要用于流搜索的属性。我以前用过它来解决这个问题,尽管使用可用的 Java 库可能有更简单的方法。 (当我遇到这个问题时,我在 90 年代使用 C 语言工作。)

    KMP 本质上是一种构建字符串匹配 DFA 的快速方法,就像 Norman Ramsey 的建议 #2。

    【讨论】:

    • 这个问题专门关于搜索流,所以我同意@DariusBacon 的观点,即 KMP 非常适合这个问题(从左到右的行为,即从不备份)。而且很容易实现,这总是好的:)
    【解决方案4】:

    此答案适用于问题的初始版本,其中关键是仅在必要时读取流以匹配字符串(如果存在该字符串)。此解决方案不符合保证固定内存利用率的要求,但如果您发现此问题并且不受该约束的约束,则可能值得考虑。

    如果您受到常量内存使用约束的约束,Java 会将任何类型的数组存储在堆上,因此将引用置空不会以任何方式释放内存;我认为任何涉及循环中数组的解决方案都会消耗堆上的内存并需要 GC。


    对于简单的实现,也许 Java 5 的 Scanner 可以接受 InputStream 并使用 java.util.regex.Pattern 来搜索输入,这可能会让您不必担心实现细节。

    这是一个潜在实现的示例:

    public boolean streamContainsString(Reader reader, String searchString)
                throws IOException {
          Scanner streamScanner = new Scanner(reader);
          if (streamScanner.findWithinHorizon(searchString, 0) != null) {
            return true;
          } else {
            return false;
          }
    }
    

    我正在考虑正则表达式,因为它听起来像是有限状态自动机的工作,它从初始状态开始,逐个字符地更改状态,直到它拒绝字符串(不匹配)或进入接受状态。

    我认为这可能是您可以使用的最有效的匹配逻辑,并且您如何组织信息的读取可以与性能调整的匹配逻辑分开。

    这也是正则表达式的工作方式。

    【讨论】:

    • java.util.regex(和大多数主流的正则表达式引擎)使用回溯方案,对于这样的事情实际上效率极低。想象一个以“.*”开头的模式(记住“*”是贪婪的)。当然这可以更有效地完成,但是对于这个看起来不支持元字符的特定问题,正则表达式可能不是要走的路。
    • 如果您使用以 .* 开头的正则表达式,如果它太慢是您自己的错。但是您可以避免转义搜索字符串的任何与正则表达式相关的并发症,即:"\\Q"+searchString+"\\E"
    • @AlanM,如果搜索字符串中包含“\\E”,这有点危险。而“.*”的例子就是这样,一个例子。正则表达式有很多方法不能很好地解决这个问题。 @safetydan,我在 javadocs 中没有看到 Pattern.escape() 。我错过了什么吗?
    • 该方法称为quote(),而不是escape(),它通过将字符串包装在\Q 和\E 中来工作。它还处理可能已经在字符串中的任何 \E。我没有提到它,因为我认为它只在 JDK 1.6+ 中可用,但它从 JDK 1.5 开始就存在。
    • 扫描仪是一个不错的解决方案,但不幸的是,它不符合内存效率的要求。 findWithinHorizo​​n 状态的 javadocs 状态:“[视野为 0] 在这种情况下,它可能会缓冲所有搜索模式的输入。”事实上调试类,你可以看到这正是它所做的。这么近,但没有雪茄……:p
    【解决方案5】:

    不要让你的缓冲区是一个数组,而是使用一个实现循环缓冲区的抽象。您的索引计算将是buf[(next+i) % sizeof(buf)],并且您必须小心一次将缓冲区填满一半。但只要搜索字符串适合缓冲区的一半,您就会找到它。

    【讨论】:

      【解决方案6】:

      我相信解决这个问题的最佳方法是尽量保持简单。请记住,因为我正在从流中读取,所以我想将流中的读取次数保持在最低限度(因为网络或磁盘延迟可能是一个问题),同时保持使用的内存量不变(因为流可能是尺寸非常大)。字符串匹配的实际效率不是第一目标(因为这已经是studied to death)。

      根据 AlbertoPL 的建议,这是一个简单的解决方案,将缓冲区与搜索字符串逐个字符进行比较。关键是因为一次只搜​​索一个字符,所以不需要回溯,因此不需要循环缓冲区或特定大小的缓冲区。

      现在,如果有人可以基于Knuth-Morris-Pratt search algorithm 提出类似的实现,那么我们将有一个非常有效的解决方案;)

      public boolean streamContainsString(Reader reader, String searchString) throws IOException {
          char[] buffer = new char[1024];
          int numCharsRead;
          int count = 0;
          while((numCharsRead = reader.read(buffer)) > 0) {
              for (int c = 0; c < numCharsRead; c++) {
                  if (buffer[c] == searchString.charAt(count))
                      count++;
                  else
                      count = 0;
                  if (count == searchString.length()) return true;
              }
          }
          return false;
      }
      

      【讨论】:

      • 我不明白:如果流中的针串落在缓冲区的边界上怎么办,比如第一部分在索引1021、1022、1023 和第二部分在索引 1024, 1025 1026 这在那种情况下是行不通的。
      • @Esailija 它应该仍然有效;变量 count 跟踪成功匹配的 needle 字符串中的索引。如果您越过缓冲区的边界,它保持不变,您只需重新开始匹配新缓冲区的开始。请注意,仅当找到与针字符串不匹配的字符时才会重置计数。
      • 啊,我完全错过了,天才。我会将我的反对票改为赞成票,但帖子需要编辑才能这样做:)
      • 这不会在某些搜索字符串上失败吗?考虑“abac”对“ababac”:count 在部分匹配时增加,直到 'c' 对 'b',然后重置为 0,其中 c = 3,并且搜索从不考虑从 c = 2 开始的匹配。跨度>
      • 我同意@DariusBacon - 此代码将无法在“AAB”中找到“AB” - 所以我否决了这个答案。人类在字符串搜索上花费了无数时间,所以如果算法还没有已知名称,例如KMP 或其他什么,这可能是有充分理由的。请参阅 Charras 和 Lecroq 的“精确字符串匹配算法手册”,了解有关该主题的更多信息:)
      【解决方案7】:

      Ujorm 框架的 RingBuffer 类实现了对流的快速搜索。查看示例:

       Reader reader = RingBuffer.createReader("xxx ${abc} ${def} zzz");
      
       String word1 = RingBuffer.findWord(reader, "${", "}");
       assertEquals("abc", word1);
      
       String word2 = RingBuffer.findWord(reader, "${", "}");
       assertEquals("def", word2);
      
       String word3 = RingBuffer.findWord(reader, "${", "}");
       assertEquals("", word3);
      

      SourceForge 上提供了单类实现: 如需更多信息,请参阅link。

      【讨论】:

        【解决方案8】:

        实现一个滑动窗口。让你的缓冲区四处走动,将缓冲区中的所有元素向前移动一个,最后在缓冲区中输入一个新字符。如果缓冲区等于您搜索的单词,则包含它。

        当然,如果您想提高效率,您可以寻找一种方法来防止移动缓冲区中的所有元素,例如通过使用循环缓冲区和“循环”相同的字符串表示缓冲区的方式,因此您只需要检查内容相等性。这样可以节省移动缓冲区中的所有元素。

        【讨论】:

          【解决方案9】:

          我认为您需要在缓冲区之间的边界处缓冲少量。

          例如,如果您的缓冲区大小为 1024 并且 SearchString 的长度为 10,那么除了搜索每个 1024 字节的缓冲区之外,您还需要搜索两个缓冲区之间的每个 18 字节的转换(距离末尾的 9 个字节)前一个缓冲区与从下一个缓冲区开始的 9 个字节连接在一起)。

          【讨论】:

            【解决方案10】:

            我会说切换到逐个字符的解决方案,在这种情况下,您将扫描目标文本中的第一个字符,然后当您发现该字符增加一个计数器并查找下一个字符。每次找不到下一个连续字符时,都会重新启动计数器。它会像这样工作:

            public boolean streamContainsString(Reader reader, String searchString) throws IOException {
            char[] buffer = new char[1024];
            int numCharsRead;
            int count = 0;
            while((numCharsRead = reader.read(buffer)) > 0) {
                if (buffer[numCharsRead -1] == searchString.charAt(count))
                    count++;
                else
                    count = 0;
            
                if (count == searchString.size())    
                 return true;
            }
            return false; 
            }
            

            唯一的问题是当您正在浏览字符时......在这种情况下,需要有一种方法来记住您的计数变量。除了作为整个班级的私有变量之外,我没有看到这样做的简单方法。在这种情况下,您不会在此方法中实例化 count。

            【讨论】:

              【解决方案11】:

              如果您不习惯使用 Reader,那么您可以使用 Java 的 NIO API 来有效地加载文件。例如(未经测试,但应该接近工作):

              public boolean streamContainsString(File input, String searchString) throws IOException {
                  Pattern pattern = Pattern.compile(Pattern.quote(searchString));
              
                  FileInputStream fis = new FileInputStream(input);
                  FileChannel fc = fis.getChannel();
              
                  int sz = (int) fc.size();
                  MappedByteBuffer bb = fc.map(FileChannel.MapMode.READ_ONLY, 0, sz);
              
                  CharsetDecoder decoder = Charset.forName("UTF-8").newDecoder();
                  CharBuffer cb = decoder.decode(bb);
              
                  Matcher matcher = pattern.matcher(cb);
              
                  return matcher.matches();
              }
              

              这基本上是 mmap() 是要搜索的文件,并且依赖于操作系统在缓存和内存使用方面做正确的事情。但是请注意,对于小于约 10 KiB 的文件,仅将文件读入大缓冲区的 map() 成本更高。

              【讨论】:

                【解决方案12】:

                您也许可以使用快速傅里叶变换实现一个非常快速的解决方案,如果实施得当,您可以在 O(nlog(m)) 时间内进行字符串匹配,其中 n 是要匹配的较长字符串的长度被匹配,m 是较短字符串的长度。例如,您可以在收到长度为 m 的流输入后立即执行 FFT,如果匹配,则可以返回,如果不匹配,则可以丢弃流输入中的第一个字符,等待让一个新字符通过流出现,然后再次执行 FFT。

                【讨论】:

                  【解决方案13】:

                  您可以通过使用一些string search algorithm 来提高搜索非常大字符串的速度

                  【讨论】:

                  • 那些并不真正适用,因为他有一个字符流进来。他无权访问整个字符串。所以他只能按顺序扫描。而且他明确表示他不想将整个字符串加载到内存中。
                  • 有些算法不需要一次性加载全文。例如 Boyer-Moor 算法将索引应用于搜索字符串而不是全文
                  【解决方案14】:

                  如果您正在寻找常量子字符串而不是正则表达式,我建议您使用 Boyer-Moore。网上有很多源代码。

                  另外,使用循环缓冲区,避免对缓冲区边界考虑太多。

                  迈克。

                  【讨论】:

                  • Boyer-Moore 速度很快,但想向前看。
                  【解决方案15】:

                  我也遇到了类似的问题:从 InputStream 中跳过字节,直到指定字符串(或字节数组)。这是基于循环缓冲区的简单代码。它不是很有效,但可以满足我的需求:

                    private static boolean matches(int[] buffer, int offset, byte[] search) {
                      final int len = buffer.length;
                      for (int i = 0; i < len; ++i) {
                        if (search[i] != buffer[(offset + i) % len]) {
                          return false;
                        }
                      }
                      return true;
                    }
                  
                    public static void skipBytes(InputStream stream, byte[] search) throws IOException {
                      final int[] buffer = new int[search.length];
                      for (int i = 0; i < search.length; ++i) {
                        buffer[i] = stream.read();
                      }
                  
                      int offset = 0;
                      while (true) {
                        if (matches(buffer, offset, search)) {
                          break;
                        }
                        buffer[offset] = stream.read();
                        offset = (offset + 1) % buffer.length;
                      }
                    }
                  

                  【讨论】:

                    【解决方案16】:

                    这是我的实现:

                    static boolean containsKeywordInStream( Reader ir, String keyword, int bufferSize ) throws IOException{
                        SlidingContainsBuffer sb = new SlidingContainsBuffer( keyword );
                        char[] buffer = new char[ bufferSize ];
                        int read;
                        while( ( read = ir.read( buffer ) ) != -1 ){
                            if( sb.checkIfContains( buffer, read ) ){
                                return true;
                            }
                        }
                        return false;
                    }
                    

                    SlidingContainsBuffer 类:

                    class SlidingContainsBuffer{
                    
                        private final char[] keyword;
                        private int keywordIndexToCheck = 0;
                        private boolean keywordFound = false;
                    
                        SlidingContainsBuffer( String keyword ){
                            this.keyword = keyword.toCharArray();
                        }
                    
                        boolean checkIfContains( char[] buffer, int read ){
                            for( int i = 0; i < read; i++ ){
                                if( keywordFound == false ){
                                    if( keyword[ keywordIndexToCheck ] == buffer[ i ] ){
                                        keywordIndexToCheck++;
                                        if( keywordIndexToCheck == keyword.length ){
                                            keywordFound = true;
                                        }
                                    } else {
                                        keywordIndexToCheck = 0;
                                    }
                                } else {
                                    break;
                                }
                            }
                            return keywordFound;
                        }
                    }
                    

                    这个答案完全符合任务:

                    1. 实现能够找到搜索的关键字,即使它在缓冲区之间拆分
                    2. 由缓冲区大小定义的最小内存使用量
                    3. 使用更大的缓冲区可以最大限度地减少读取次数

                    【讨论】:

                      猜你喜欢
                      • 2012-01-29
                      • 2012-12-18
                      • 2019-04-06
                      • 2011-08-07
                      • 2012-03-23
                      • 2017-10-22
                      • 2018-10-13
                      • 2011-11-02
                      • 2021-08-20
                      相关资源
                      最近更新 更多