【问题标题】:Efficient Algorithm for String Concatenation with Overlap具有重叠的字符串连接的高效算法
【发布时间】:2010-11-20 02:28:41
【问题描述】:

我们需要通过连接来组合数据库中的 3 列。但是,这 3 列可能包含重叠部分,并且这些部分不应重复。例如,

  "a" + "b" + "c" => "abc"
  "abcde" + "defgh" + "ghlmn" => "abcdefghlmn"
  "abcdede" + "dedefgh" + "" => "abcdedefgh"
  "abcde" + "d" + "ghlmn" => "abcdedghlmn"
  "abcdef" + "" + "defghl" => "abcdefghl"

我们当前的算法非常慢,因为它使用蛮力来识别 2 个字符串之间的重叠部分。有谁知道一种有效的算法来做到这一点?

假设我们有 2 个字符串 A 和 B。算法需要找到最长的公共子字符串 S,以便 A 以 S 结尾,B 以 S 开头。

附上我们当前在 Java 中的蛮力实现以供参考,

public static String concat(String s1, String s2) {
    if (s1 == null)
        return s2;
    if (s2 == null)
        return s1;
    int len = Math.min(s1.length(), s2.length());

    // Find the index for the end of overlapping part
    int index = -1;
    for (int i = len; i > 0; i--) {
        String substring = s2.substring(0, i);
        if (s1.endsWith(substring)) {
            index = i;
            break;
        }
    }
    StringBuilder sb = new StringBuilder(s1);
    if (index < 0) 
        sb.append(s2);
    else if (index <= s2.length())
        sb.append(s2.substring(index));
    return sb.toString();
}

【问题讨论】:

  • 字符串的平均长度是多少?
  • 只有中间字符串比较长,平均约100个字符。前缀和后缀只有大约 20 个字符。这并不重要。数据库转换只发生一次。这大约需要几天时间,但在最坏的情况下,只需要几个小时来连接字符串。我试图找到最佳解决方案,只是为了好玩和挑战。我想我找到了。谢谢!

标签: algorithm language-agnostic string


【解决方案1】:

这是一个 Java 实现,它在 O(min(N,M)) 操作 ~ O(N) 中找到长度为 N 和 M 的两个字符串之间的最大重叠。

我和@sepp2k:s 有同样的想法,现在删除了答案,并进一步研究了它。似乎工作正常。这个想法是遍历第一个字符串并在找到与第二个字符串的开头匹配的内容后开始跟踪。发现如果假匹配和真匹配重叠,您可能需要同时进行多个跟踪。最后,您选择最长的轨道。

我还没有计算出绝对最坏的情况,比赛之间的重叠最大,但我不认为它会失控,因为我认为你不能重叠任意多场比赛。通常您一次只跟踪一两个匹配项:一旦出现不匹配,候选对象就会被删除。

static class Candidate {
    int matchLen = 0;
}

private String overlapOnce(@NotNull final String a, @NotNull final String b) {
    final int maxOverlap = Math.min(a.length(), b.length());
    final Collection<Candidate> candidates = new LinkedList<>();
    for (int i = a.length() - maxOverlap; i < a.length(); ++i) {
        if (a.charAt(i) == b.charAt(0)) {
            candidates.add(new Candidate());
        }
        for (final Iterator<Candidate> it = candidates.iterator(); it.hasNext(); ) {
            final Candidate candidate = it.next();
            if (a.charAt(i) == b.charAt(candidate.matchLen)) {
                //advance
                ++candidate.matchLen;
            } else {
                //not matching anymore, remove
                it.remove();
            }
        }

    }
    final int matchLen = candidates.isEmpty() ? 0 :
            candidates.stream().map(c -> c.matchLen).max(Comparator.comparingInt(l -> l)).get();
    return a + b.substring(matchLen);
}

private String overlapOnce(@NotNull final String... strings) {
    return Arrays.stream(strings).reduce("", this::overlapOnce);
}

还有一些测试:

@Test
public void testOverlapOnce() throws Exception {
    assertEquals("", overlapOnce("", ""));
    assertEquals("ab", overlapOnce("a", "b"));
    assertEquals("abc", overlapOnce("ab", "bc"));
    assertEquals("abcdefghqabcdefghi", overlapOnce("abcdefgh", "efghqabcdefghi"));
    assertEquals("aaaaaabaaaaaa", overlapOnce("aaaaaab", "baaaaaa"));
    assertEquals("ccc", overlapOnce("ccc", "ccc"));
    assertEquals("abcabc", overlapOnce("abcabc", "abcabc"));

    /**
     *  "a" + "b" + "c" => "abc"
     "abcde" + "defgh" + "ghlmn" => "abcdefghlmn"
     "abcdede" + "dedefgh" + "" => "abcdedefgh"
     "abcde" + "d" + "ghlmn" => "abcdedghlmn"
     "abcdef" + "" + "defghl" => "abcdefghl"
     */
    assertEquals("abc", overlapOnce("a", "b", "c"));
    assertEquals("abcdefghlmn", overlapOnce("abcde", "defgh", "ghlmn"));
    assertEquals("abcdedefgh", overlapOnce("abcdede", "dedefgh"));
    assertEquals("abcdedghlmn", overlapOnce("abcde", "d", "ghlmn"));
    assertEquals("abcdefghl", overlapOnce("abcdef", "", "defghl"));


    // Consider str1=abXabXabXac and str2=XabXac. Your approach will output abXabXabXacXabXac because by
    // resetting j=0, it goes to far back.
    assertEquals("abXabXabXac", overlapOnce("abXabXabXac", "XabXac"));

    // Try to trick algo with an earlier false match overlapping with the real match
    //  - match first "aba" and miss that the last "a" is the start of the
    // real match
    assertEquals("ababa--", overlapOnce("ababa", "aba--"));
}

【讨论】:

    【解决方案2】:

    大多数其他答案都集中在常数因子优化上,但也可以渐近地做得更好。看看你的算法:它是 O(N^2)。这似乎是一个可以比这更快解决的问题!

    考虑Knuth Morris Pratt。它跟踪我们迄今为止匹配的最大子字符串数量。这意味着它知道有多少 S1 在 S2 结束时匹配,这就是我们正在寻找的值!只需修改算法以继续而不是在早期匹配子字符串时返回,并让它返回匹配的数量而不是最后的 0。

    这给了你一个 O(n) 算法。不错!

        int OverlappedStringLength(string s1, string s2) {
            //Trim s1 so it isn't longer than s2
            if (s1.Length > s2.Length) s1 = s1.Substring(s1.Length - s2.Length);
    
            int[] T = ComputeBackTrackTable(s2); //O(n)
    
            int m = 0;
            int i = 0;
            while (m + i < s1.Length) {
                if (s2[i] == s1[m + i]) {
                    i += 1;
                    //<-- removed the return case here, because |s1| <= |s2|
                } else {
                    m += i - T[i];
                    if (i > 0) i = T[i];
                }
            }
    
            return i; //<-- changed the return here to return characters matched
        }
    
        int[] ComputeBackTrackTable(string s) {
            var T = new int[s.Length];
            int cnd = 0;
            T[0] = -1;
            T[1] = 0;
            int pos = 2;
            while (pos < s.Length) {
                if (s[pos - 1] == s[cnd]) {
                    T[pos] = cnd + 1;
                    pos += 1;
                    cnd += 1;
                } else if (cnd > 0) {
                    cnd = T[cnd];
                } else {
                    T[pos] = 0;
                    pos += 1;
                }
            }
    
            return T;
        }
    

    OverlappedStringLength("abcdef", "defghl") 返回 3

    【讨论】:

    • 对我的实现运行这个超过一百万个随机生成的字符串(从字母 a-z,长度 8-23,对两个实现使用相同的字符串集合)你需要两倍的时间。这种实现实际上是否更好将在很大程度上取决于被连接的实际字符串中重叠的性质,因此一如既往...... ZZ Coder 需要在决定实际上最有效之前对他的数据集进行测量.
    • 即便如此,为使用 Knuth +1 ;)
    • 是的,在这个实现中看起来有很多固定时间。如果字符串的大小与 OP 中的大小相同,则可能不值得。必须配置文件。
    • 我同意您对小的随机字符串的搜索会更快。事实上,如果输入是随机的,您可以通过检查越来越多的字符逐步过滤可能的起始位置来实现预期线性时间算法。但是依靠输入来做任何事情都是一个坏主意。如果你得到长字符串、重复字符串、只有 0 和 1 的字符串等怎么办?你的算法的性能依赖于一个合理的随机输入,这在实践中可能适用也可能不适用(或者将来可能停止应用)。例如。英文字符频率会稍微影响你的表现。
    • 我的目标是找到一个 O(n) 的解决方案,我想就是这样。谢谢!
    【解决方案3】:

    这是一个 perl -pseudo oneliner:

    $_ = s1.s2;

    s/([\S]+)\1/\1/;

    perl 正则表达式非常有效,您可以查看它们使用的算法,但它们肯定实现了某种类型的 FSM 等,因此您会得到非常好的 O(..) 结果。

    【讨论】:

      【解决方案4】:

      这个问题似乎是最长公共子序列问题的变体,可以通过动态规划来解决。

      http://www.algorithmist.com/index.php/Longest_Common_Subsequence

      【讨论】:

        【解决方案5】:

        我正在努力使这个 C# 尽可能易于阅读。

            public static string Concatenate(string s1, string s2)
            {
                if (string.IsNullOrEmpty(s1)) return s2;
                if (string.IsNullOrEmpty(s2)) return s1;
                if (s1.Contains(s2)) return s1;
                if (s2.Contains(s1)) return s2;
        
                char endChar = s1.ToCharArray().Last();
                char startChar = s2.ToCharArray().First();
        
                int s1FirstIndexOfStartChar = s1.IndexOf(startChar);
                int overlapLength = s1.Length - s1FirstIndexOfStartChar;
        
                while (overlapLength >= 0 && s1FirstIndexOfStartChar >=0)
                {
                    if (CheckOverlap(s1, s2, overlapLength))
                    {
                        return s1 + s2.Substring(overlapLength);
                    }
        
                    s1FirstIndexOfStartChar = 
                        s1.IndexOf(startChar, s1FirstIndexOfStartChar);
                    overlapLength = s1.Length - s1FirstIndexOfStartChar;
        
                }
        
                return s1 + s2;
            }
        
            private static bool CheckOverlap(string s1, string s2, int overlapLength)
            {
                if (overlapLength <= 0)
                    return false;
        
                if (s1.Substring(s1.Length - overlapLength) == 
                    s2.Substring(0, overlapLength))
                    return true;
        
                return false;            
            }
        

        编辑:我看到这与 jerryjvl 的解决方案几乎相同。唯一的区别是这将适用于“abcde”、“d”的情况。

        【讨论】:

        • 这仍然会为CheckOverlap 复制字符串,...我至少会使用就地比较循环重新实现这个辅助方法。
        • 我不确定我是否理解你的“唯一的区别是......”......我的算法从原始问题给出了该示例的正确结果......
        【解决方案6】:

        这里有一个 Python 解决方案。它应该更快,因为不需要一直在内存中构建子字符串。这项工作在 _concat 函数中完成,该函数连接两个字符串。 concat 函数是连接任意数量的字符串的助手。

        def concat(*args):
            result = ''
            for arg in args:
                result = _concat(result, arg)
            return result
        
        def _concat(a, b):
            la = len(a)
            lb = len(b)
            for i in range(la):
                j = i
                k = 0
                while j < la and k < lb and a[j] == b[k]:
                    j += 1
                    k += 1
                if j == la:
                    n = k
                    break
            else:
                n = 0
            return a + b[n:]
        
        if __name__ == '__main__':
            assert concat('a', 'b', 'c') == 'abc'
            assert concat('abcde', 'defgh', 'ghlmn') == 'abcdefghlmn'
            assert concat('abcdede', 'dedefgh', '') == 'abcdedefgh'
            assert concat('abcde', 'd', 'ghlmn') == 'abcdedghlmn'
            assert concat('abcdef', '', 'defghl') == 'abcdefghl'
        

        【讨论】:

        • 你的意思是:def _concat(a, b): la = len(a) for i in range(la): if a[i:] == b[:la-i]: return a + b[la-i:] return a + b def _concat(a, b): la = len(a) for i in range(la): if a[i:] == b[:la-i ]: return a + b[la-i:] return a + b
        • 或者运行这个: print "\ndef _concat(a, b):\n\tla = len(a)\n\tfor i in range(la):\n\t\tif a [i:] == b[:la-i]:\n\t\t\treturn a + b[la-i:]\n\treturn a + b\n".replace(r'\n', '\n').replace(r'\t', '\t')
        • 是的,但这很不错,对吧?您运行它,它会自行格式化。
        • 无论如何,您的版本会创建可能需要更多时间的子字符串。但是由于很多这种情况发生在 C-land,它可能仍然运行得更快,我还没有检查过。但移植到 C# 时可能不会发生同样的情况。
        • 我这样做是为了清晰,而不是速度。 _concat 的正文:5 行对 14 行。
        【解决方案7】:

        我认为这会很快:

        你有两个字符串,string1 和 string2。通过 string1 向后(从右到左)查看 string2 的第一个字符。一旦你有了那个位置,确定是否有重叠。如果没有,您需要继续搜索。如果有,您需要确定是否有可能进行另一场比赛。

        为此,只需探索两个字符串中较短的一个,以重复出现重叠字符。即:如果在string1 中匹配的位置留下了一个短的string1,则从string1 中的新起点重复初始搜索。相反,如果 string2 的不匹配部分较短,则搜索它以查找重叠字符的重复。

        根据需要重复。

        任务完成!

        这在内存分配方面不需要太多(所有搜索都在原地完成,只需要分配结果字符串缓冲区)并且只需要(最多)一次通过重叠的字符串之一。

        【讨论】:

        • 最好从尽可能长的重叠开始,然后从那里向后工作……看我的解决方案;)
        • 我不同意。您的算法可能稍微短一些,但 String.LastIndexOf 的实现可能正在实现我的算法。
        • 我使用LastIndexOf 扫描单个字符...我根本看不出这与您的算法有何对应...
        • 那么,你有一些功课。实现这两种算法,针对同一组数百万个随机生成的字符串运行它们,并测量每个算法的相对性能。
        • 如果你愿意,我很想看看结果。
        【解决方案8】:

        怎么样(请原谅 C#):

        public static string OverlapConcat(string s1, string s2)
        {
            // Handle nulls... never return a null
            if (string.IsNullOrEmpty(s1))
            {
                if (string.IsNullOrEmpty(s2))
                    return string.Empty;
                else
                    return s2;
            }
            if (string.IsNullOrEmpty(s2))
                return s1;
        
            // Checks above guarantee both strings have at least one character
            int len1 = s1.Length - 1;
            char last1 = s1[len1];
            char first2 = s2[0];
        
            // Find the first potential match, bounded by the length of s1
            int indexOfLast2 = s2.LastIndexOf(last1, Math.Min(len1, s2.Length - 1));
            while (indexOfLast2 != -1)
            {
                if (s1[len1 - indexOfLast2] == first2)
                {
                    // After the quick check, do a full check
                    int ix = indexOfLast2;
                    while ((ix != -1) && (s1[len1 - indexOfLast2 + ix] == s2[ix]))
                        ix--;
                    if (ix == -1)
                        return s1 + s2.Substring(indexOfLast2 + 1);
                }
        
                // Search for the next possible match
                indexOfLast2 = s2.LastIndexOf(last1, indexOfLast2 - 1);
            }
        
            // No match found, so concatenate the full strings
            return s1 + s2;
        }
        

        在确定需要复制的内容之前,此实现不会复制任何字符串(部分或其他),这对性能有很大帮助。

        此外,匹配检查首先测试潜在匹配区域的极端值(2 个单个字符),这在普通英文文本中应该很有可能避免检查任何其他字符是否不匹配。

        只有当它建立了最长的匹配,或者根本不可能匹配时,才会连接两个字符串。我在这里使用了简单的“+”,因为我认为算法其余部分的优化已经消除了您原来的大部分低效率。试一试,让我知道它是否足以满足您的目的。

        【讨论】:

        • 还请注意,由于“LastIndexOf”,它甚至不考虑所有可能的重叠偏移,只考虑可能匹配的那些,这意味着它可以做的次数明显少于 O(n) 次while 循环。
        • 我认为使用内置函数(如 LastIndexOf)并假设实现非常高效总是很危险。值得重写它,因此它可以在任何语言中工作(鉴于问题与语言无关),这意味着在实现中不使用 String.LastIndexOf 。也就是说,该算法的基本前提看起来是合理的。
        • 我希望答案保持相对简短 ;) ... 实现一个“LastIndexOf”,在此实现中可以做出的假设内进行有效的字符扫描应该不是很复杂(它确实不需要对第二个参数做任何边界检查)。
        • 另外请注意,如果需要更多性能,如果s2 明显长于@987654326,则通过重复使用LastIndexOfs2 进行的字符扫描可能不是最佳的@,在这种情况下,您可以根据 s1s2 的相对长度将其设为特殊情况,而相反的版本显然需要使用 IndexOf 等等......这有点超出了不过问题的范围。
        • 再想一想...可以通过在相反方向上迭代“ix”来将快速消除不匹配的单独“单字符检查”折叠到比较循环中。
        【解决方案9】:

        或者你可以在 mysql 中使用以下存储函数:

        DELIMITER //
        
        DROP FUNCTION IF EXISTS concat_with_overlap //
        
        CREATE FUNCTION concat_with_overlap(a VARCHAR(100), b VARCHAR(100))
          RETURNS VARCHAR(200) DETERMINISTIC
        BEGIN 
          DECLARE i INT;
          DECLARE al INT;
          DECLARE bl INT;
          SET al = LENGTH(a);
          SET bl = LENGTH(a);
          IF al=0 THEN 
            RETURN b;
          END IF;
          IF bl=0 THEN 
            RETURN a;
          END IF;
          IF al < bl THEN
             SET i = al;
          ELSE
             SET i = bl;
          END IF;
        
          search: WHILE i > 0 DO
             IF RIGHT(a,i) = LEFT(b,i) THEN
            RETURN CONCAT(a, SUBSTR(b,i+1));
             END IF;
             SET i = i - 1;
          END WHILE search;
        
          RETURN CONCAT(a,b);
        END//
        

        我用你的测试数据试了一下:

        mysql> select a,b,c,
            -> concat_with_overlap( concat_with_overlap( a, b ), c ) as result 
            -> from testing //
        +-------------+---------+--------+-------------+
        | a           | b       | c      | result      |
        +-------------+---------+--------+-------------+
        | a           | b       | c      | abc         |
        | abcde       | defgh   | ghlmn  | abcdefghlmn |
        | abcdede     | dedefgh |        | abcdedefgh  |
        | abcde       | d       | ghlmn  | abcdedghlmn |
        | abcdef      |         | defghl | abcdefghl   |
        | abXabXabXac | XabXac  |        | abXabXabXac |
        +-------------+---------+--------+-------------+
        6 rows in set (0.00 sec)
        

        【讨论】:

        • 这是一个非常有趣的。如果我们使用相同的数据库,但我们正在从 Sybase 迁移到 MySQL,我肯定会使用它。
        • 所以,到达后在 MySQL 中执行 ;-)
        • 我们的新架构只有组合列。我必须创建一些临时列来执行此操作。我们在 MySQL 中删除列(天)需要很长时间,因为数据库相当大,所以我们尽量避免它。
        【解决方案10】:

        您可以使用 DFA。例如,字符串XYZ 应该被正则表达式^((A)?B)?C 读取。该正则表达式将匹配与XYZ 字符串的后缀匹配的最长前缀。使用这样的正则表达式,您既可以匹配并获得匹配结果,也可以生成 DFA,您可以在其上使用状态指示“cut”的正确位置。

        在 Scala 中,第一个实现——直接使用正则表达式——可能是这样的:

        def toRegex(s1: String) = "^" + s1.map(_.toString).reduceLeft((a, b) => "("+a+")?"+b) r
        def concatWithoutMatch(s1 : String, s2: String) = {
          val regex = toRegex(s1)
          val prefix = regex findFirstIn s2 getOrElse ""
          s1 + s2.drop(prefix length)
        }
        

        例如:

        scala> concatWithoutMatch("abXabXabXac", "XabXacd")
        res9: java.lang.String = abXabXabXacd
        
        scala> concatWithoutMatch("abc", "def")
        res10: java.lang.String = abcdef
        
        scala> concatWithoutMatch(concatWithoutMatch("abcde", "defgh"), "ghlmn")
        res11: java.lang.String = abcdefghlmn
        

        【讨论】:

        • 很好 :) ... 尽管如此,它仍然错过了我的实现中的(可能重要的)优化,该优化基于正在考虑的重叠的第一个和最后一个字符丢弃最典型的不匹配。
        • 并非如此。考虑我们正在匹配第二个字符串。对于第二个字符串的每个字符,匹配可以完成也可以不匹配。一旦找到第一个不匹配的字符,您就知道最大可能匹配是多少。这 never 必须多次比较一个字符。现在,创建和编译正则表达式的开销可能过多。
        • 我可能遗漏了一些东西,但尽管它从不多次比较 's1' 中的字符,但它可能必须检查 's2' 中的同一个字符两次,对吗? ...例如在您的第一个示例中,“s1”包含“XabXabXac”? ...它必须检查 's2' 的前三个字符至少两次。
        • 将 NFA 转换为 DFA 会导致状态呈指数增长。您刚刚将复杂性从暴力扫描转移到构建 DFA 状态。
        • 是的,如果使用循环和替代方案,它会导致状态呈指数级增长。此正则表达式的状态数等于第一个字符串的大小,加上初始状态和不接受终止状态。
        【解决方案11】:

        如果你在数据库之外做,试试 perl:

        sub concat {
          my($x,$y) = @_;
        
          return $x if $y eq '';
          return $y if $x eq '';
        
          my($i) = length($x) < length($y) ?  length($x) : length($y);
          while($i > 0) {
              if( substr($x,-$i) eq substr($y,0,$i) )  {
                  return $x . substr($y,$i);
              }
              $i--;
          }
          return $x . $y;
        }
        

        这和你的算法完全一样,如果 java 或 perl 更快,我只是好奇 ;-)

        【讨论】:

          【解决方案12】:

          为什么不做这样的事情。 首先获取三列中的第一个字符或单词(将表示重叠)。

          然后,开始将第一个字符串添加到字符串缓冲区,一次一个字符。

          每次查看是否到达与第二或第三字符串重叠的部分。

          如果是,则开始连接包含第一个字符串中的内容的字符串。

          完成开始后,如果没有重叠,则从第二个字符串开始,然后是第三个字符串。

          因此,在问题的第二个示例中,我将 d 和 g 保留在两个变量中。

          然后,当我添加第一个字符串时 abc 来自第一个字符串,然后我看到 d 也在第二个字符串中,所以我转向从第二个字符串添加 def 是从字符串 2 添加的,然后我继续并以字符串 3 结束。

          如果您在数据库中执行此操作,为什么不使用存储过程来执行此操作?

          【讨论】:

          • 我们的 DBA 认为此操作太复杂,无法在存储过程中执行。此外,我们正在从 Sybase 迁移到 MySQL。所以这将通过一个批处理作业来完成,它可以用任何语言编写。
          猜你喜欢
          • 2014-10-03
          • 2011-03-13
          • 2019-04-13
          • 2010-10-11
          • 2014-01-16
          • 1970-01-01
          • 1970-01-01
          • 2011-10-21
          • 1970-01-01
          相关资源
          最近更新 更多