正则表达式用于部分匹配长度 - 字符串相似度

14

假设有字符串"Torcellite"和另一个字符串"Tor" - 这两个字符串的相似度长度为3,因为它们都以"Tor"开头。现在另一个字符串"christmas"和"mas"的相似度为0,因为它们没有以相同的字符集开头。

在这两种情况下,第二个字符串是第一个字符串的后缀。

一个更清晰的例子:

字符串长度:1到10^5

字符串:abaabc

后缀:abaabc, baabc, aabc, abc, bc, c

相似度:abaabc,无,aab,无,无

相似度长度:6、0、1、2、0、0

答案:6+0+1+2+0+0 = 9

我有一个使用正则表达式找到这些部分后缀匹配的低效逻辑。

算法:

  • 查找给定字符串的所有子字符串。
  • 从后缀的子字符串中制作一个模式。

    for(int i=1; i<substrings[i].length; i++) {
        Pattern p = Pattern.compile("^"+substrings[i].substring(0, i));
        Matcher m = p.find(string); //the given string for which similarities need to be  calculated
        if(m.find())
            similaryLengths +=  i;
    }
    
  • 由于我需要遍历字符串的后缀和子串来查找模式,因此复杂度约为O(n^2)。

  • 我考虑过使用模式匹配中的分组来查找这些组,但我不确定正则表达式应该是什么样子的。我想到的第一个子串是:((((((a)b)a)a)b)c),然后找到最长的组匹配。

是否存在更有效率的算法来实现这个功能?


@Valdar - 抱歉,我是指后缀。我会更正的。 - Karthik Balakrishnan
一些快捷方式(非基于正则表达式):1)后缀始终是完整字符串,因此可以跳过其长度的分数;2)任何不以字符串的第一个字符开头的后缀都会添加零,可以跳过。 - Carl Manaster
@CarlManaster - 感谢您,我已经实现了它们。 :) - Karthik Balakrishnan
1
当你说“在这两种情况下,第二个字符串是第一个字符串的后缀”时,为什么“Tor”是“Torcellite”的后缀? - Brian Stephens
1
既然你只是在使用子字符串,为什么要使用正则表达式呢? - user557597
显示剩余2条评论
7个回答

3
到目前为止,最好的方法是在输入字符串上构建一个后缀树。构建后缀树只需要O(n)时间,其中n是字符串的长度。后缀树逻辑上由一棵树组成,在该树中,可以通过从根到每个叶子节点进行遍历找到字符串的所有后缀。您可以阅读维基百科以了解有关这些树如何工作的更多详细信息。
实质上,后缀树将允许您将当前问题简单地重新转化为“在后缀树中查找”原始字符串的问题。当您沿着树向下走时,计算每个子树中后缀的数量,并乘以您当前的匹配长度来确定您的分数。这个“搜索”也需要O(n)时间。
因此,最终结果是您可以在保证O(n)时间和O(n)空间的情况下解决问题,预处理时间为O(n)。这非常高效!而且,没有产生二次行为的“最坏情况”。这样,您可能可以轻松处理长度达到10^7的字符串。
唯一的实现困难在于构建后缀树,但您可以找到免费可用的代码。

绝对惊人。也许您可以提供一些示例来可视化树遍历期间的分数计算;维基百科没有解释这部分,而且为什么这甚至有效也不是很明显。 - Ruud Helderman
@nneonneo - 感谢您指引我正确的方向。我一直有一种感觉,认为在这个问题中使用正则表达式太复杂了。 - Karthik Balakrishnan

2
与Valdar Moridin已经发布的相似算法类似,但不需要创建子字符串(每次调用substring都会创建一个新的String对象,其中包含其源的char[]的指定范围的副本)。这不会改善时间复杂度,但可能会减少常数因子的总运行时间:
public static int partialSuffixMatch(CharSequence input) {
    int count = input.length();
    for (int i = 1; i < input.length(); i++) {
        for (int a = 0, b = i; b < input.length(); a++, b++) {
            if (input.charAt(a) != input.charAt(b))
                break;
            count++;
        }
    }
    return count;
}

在短暂的预热后,该算法能够在我的电脑上大约40毫秒内处理一串包含10,000个相等字符的 String,并且在包含100,000个相等字符的情况下,大约需要4秒钟。


我喜欢你避免了子字符串的事实,但是我猜这个算法对于超过100,000个字符的任何内容都会很低效?如果我错了,你能给我展示一些实际的输出吗?我目前无法测试代码。 - Karthik Balakrishnan
经过预热,我可以在大约4秒内处理100,000个字符(第一次运行:21秒)。由于输入长度是10,000个字符的10倍,因此时间因素应该是10x10左右,这似乎是合理的。 - isnot2bad
Java 的时间限制为 4 秒。猜测可能有更好的算法。 :/ - Karthik Balakrishnan
@Torcellite 我认为是有的。可能是 Knuth-Morris-Pratt 算法(http://en.wikipedia.org/wiki/Knuth%E2%80%93Morris%E2%80%93Pratt_algorithm)的部分匹配表是一个接近线性解决方案的提示。 - isnot2bad
1
@Torcellite 但请注意,4s是最糟糕的情况。对于其他任何情况,它都运行得更快!我尝试了一个随机的100,000个“a”和“b”的序列,时间降至约1-2毫秒! - isnot2bad
显示剩余5条评论

1
这是我根据您上述描述的做法。我不知道这是什么意思,但由于您指定只需要匹配字符串的开头,即使它是O(n ^ 2),大多数情况下它也不会运行到n的全部长度。最坏的情况显然是像“aaaaaaaaaaaaaaaaa”这样的字符串。在我的计算机上,处理一个包含60,000个'a'字符的字符串需要不到5秒钟。
我认为没有必要引入生成和编译严格前缀匹配正则表达式的开销。我错过了什么吗?
int similarity(String input) {
    int count = 0;
    for (int i = 0; i < input.length() ; i++) {
        String sub = input.substring(i);
        for (int j = 0; j < sub.length(); j++) {
            if (input.charAt(j) != sub.charAt(j))
                break;
            count++;
        }
    }
    return count;
}

我应该提到字符串的长度可以达到10^5。 - Karthik Balakrishnan
我同意这个算法不需要正则表达式,而且只会影响性能。 - Brian Stephens

1

abaabc的例子中,我了解到您正在尝试查找与原始字符串开头匹配的所有子字符串。这可以通过单个正则表达式完成,与您提出的模式有些相似。当然,该正则表达式的长度将与原始字符串成比例增长。正则表达式本身非常简单;它代表整个字符串,但是字符串的尾部(任意长度)是可选的。实际上,该正则表达式匹配字符串的任何前缀。对于字符串abcdef,正则表达式如下:

(?=(a(?:b(?:c(?:d(?:ef?)?)?)?)?))

备注:

  • 我在除了最外面的子模式之外的每个子模式中都使用了(?: ... ),以避免产生许多不必要的捕获。
  • 我使用前瞻模式(?= ... ),因为匹配可以(并且会)重叠。如果没有使用前瞻模式,则第一个匹配项(即整个字符串abcdef)将消耗整个输入,导致所有其他可能的匹配项被跳过。

当然,abcdef不是一个有趣的例子;它没有重复的子字符串,所以正则表达式只有一个匹配项,即整个字符串abcdef。你提供的例子abaabc更好,所以我为它制作了一个小工具。正如你所指出的那样,它找到了3个匹配项:abaabc、a和ab。
http://regex101.com/r/vJ8uQ9/1

请随意尝试,但不要忘记,对于测试字符串的每个更改,您需要相应地更改正则表达式。对于长字符串,这很繁琐。幸运的是,一个简单的递归程序可以为任何给定的字符串生成正则表达式。

function generateRegex(string input)
{
    return input.substring(0, 1) +
           (input.length > 2 ? "(?:" + generateRegex(input.substring(1)) + ")" : input.substring(1)) +
           "?";
}

string myRegex = "(?=(" + generateRegex(myInput) + "))";

我手头没有Java测试环境,但我在JavaScript中进行了测试。性能似乎不错(对于9000个字符的字符串,少于一秒钟),但是当针对超过9361个字符的字符串进行测试时(Firefox 31.0),我确实遇到了“正则表达式太复杂”的异常。希望Java的正则表达式引擎不会这么严格。如果不是这样,那么有一个可能的优化方法。如果您相当确定重复的子字符串从未超过1000个字符,那么您可以考虑仅为字符串的前1000个字符生成正则表达式。您将会错过第一个匹配的一部分(即整个字符串),但纠正它是很容易的。http://jsfiddle.net/gqehcjf9/1/

看起来很有前途,我会在Java上尝试并告诉您结果。 - Karthik Balakrishnan
正向前瞻在Java中似乎会出现奇怪的问题,或者我只是不理解它。当使用lookAhead时,如果lookingAt返回true,则结束标记将作为索引0返回。如果我删除前瞻标志,匹配器将提供有关匹配内容的正确信息。 - JBert
@JBert: m.end() - m.start() 为零是因为整个正则表达式是一个零宽度断言。请获取第一个捕获子模式的偏移量:m.end(1) - m.start(1)。我必须承认我没有在Java中测试过它;如果行为与我的期望不同,请告诉我。 - Ruud Helderman

0
这在你的数据集上表现如何?
int sum = s.length;
for (int i = 1; i < s.length; i++) {
    for (int j = i; j < s.length; j++) {
        for (int k = 0; k < s.length - j; k++) {
            if (s.charAt(i+k) != s.charAt(j+k)) break;
            sum++;
        }
    }
}

你可以寻找 s.charAt(0) 的下一个出现位置,而不是迭代 i。


这将非常缓慢。数据集包含长度为10^5的字符串。 - Karthik Balakrishnan
1
嗯,你的算法对于样例输入“abaabc”返回了23。但是应该是9。 - isnot2bad

0
请尝试以下方法。我已经测试过了。
无论输入字符串的长度为1~10^5,执行时间在我的电脑上都不到20毫秒
public static int oneTry(CharSequence input) {
    int tail = input.length();
    for (int i = 1; i < input.length(); i++) {
        if (input.charAt(i) == input.charAt(0)) {
            tail = i;
            break;
        }
    }

    int count = 0;

    int head = 0;
    int next = 0;
    int base = 0;
    int two = -1;
    boolean start = false;
    boolean end = false;
    for (int i = tail; i < input.length(); i++) {
        if (input.charAt(i) == input.charAt(next)) {

            count++;

            if (next>0 && !start && input.charAt(i) == input.charAt(0)) {
                base = 1;
                start = true;
            }

            if (start) {
                if (!end && input.charAt(i) == input.charAt(head)) {
                    count = count + base;
                    head++;
                    head = head < tail ? head : 0;
                    if(head == 0) {
                        base++;
                    }
                } else {
                    end = true;
                }

                if(end) {
                    if(two <0 && input.charAt(i) == input.charAt(0)) {
                        two = i;
                    }
                }
            }

            next++;

            if(i==input.length()-1 && two > 0) {
                i = two - 1;

                next = 0;
                base = 0;
                two = -1;
                start = false;
                end = false;
                head = 0;
            }

        } else {
            if(two > 0) {
                i = two - 1;

                next = 0;
                base = 0;
                two = -1;
                start = false;
                end = false;
                head = 0;
            } else {
                if(end || !start) {
                    if(input.charAt(i) == input.charAt(0)) i--;

                    next = 0;
                    base = 0;
                    two = -1;
                    start = false;
                    end = false;
                    head = 0;
                } else {
                    i--;

                    next = next - tail;
                    base = base -1;
                    two = -1;
                    start = base==0 ? false : true;
                    end = false;
                    //head = 0;
                }                   
            }
        }
    }
    count = count + input.length();
    return count;
}

这看起来很复杂。介意解释一下吗?另外,像“ababababab...”这样的字符串的性能如何,其中有50000个重复的“ab”? - nneonneo
对于输入字符串"ababababab...",其中有50000个"ab"的重复,我的PC测试结果为16毫秒。性能提升非常显著。 实际上,我的算法非常适合第一级无重复(例如:aaa....,abab...,abcabc...,abcdabcd...)。但对于第二级或以上级别的重复,它的循环计数与isnot2bad的相似(例如:ababdababd...,abcabcdabcabcd...)。 同时,该算法是为了减少第一级的重复计数,只是为了增加基础值。我仍在思考是否有其他适用于所有级别重复的好方法。 - jawee
@nneonneo 是的。最坏情况是“aabaabaab...”,其中'aab'重复33333次。此时,性能与isnot2bad的方法相比几乎要快一倍。第二部分取决于您的重复模式。如果模式只包含一级重复,则性能非常好(例如:abbbcdeeeeefff....def,其中只有一个'a')。如果超过一个级别,则第一级别的重复长度越长,性能就越好。因此,“abbbddabbdd...”比“abbbabbb...”更好。在您的100个长度重复中,最糟糕的情况是“aabaabaabaab...”。供您参考。谢谢。 - jawee
@nneonneo 在最坏的情况下,尽管循环计数与isnot2bad的方法相同,但我每个循环中的更多检查将导致几乎是isnot2bad方法的两倍时间。我认为应该有一种好的方法(适用于所有重复模式)。希望有人能找到它。供参考。 - jawee
@nneonneo 非常有趣且聪明的想法。(使用后缀树)然而,我对整个性能(构建后缀树+生成最终字符串相似度值)有些怀疑。希望有人能给我们一些测试结果和实现。 - jawee
显示剩余2条评论

0

在我看来,无论你选择哪种方法来实现它,通常都会有自己的最坏情况。
不同之处在于其最坏情况的性能。
例如,我已经在我的电脑上测试了isnot2bad的方法、我的第一次实现(oneTry)和我的第二次实现(secondTry)。
最坏情况的测试结果如下:
isnot2bad的方法:~330秒(2*10^5),~74秒(10^5),~0.8秒(10^4),~0.01秒(10^3)
我的第一次实现(oneTry):~200秒(2*10^5),~45秒(10^5),~0.5秒(10^4),~0.01秒(10^3)
我的第二次实现(secondTry):~4秒(10^6),~0.4秒(10^5),~0.05秒(10^4),~0.007秒(10^3)

从测试结果可以看出,“secondTry”的最坏性能时间几乎是线性的,而其他方法则几乎是平方的


secondTry 实现的想法如下:
对于任何字符串输入T(T0...Tn-1, len=n),,总字符串的相似度值(St)是字符串 S 中每个单独字符的相似度值(Si)的总和。
例如:St = S0 + ...+Si+...+Sn-1
显然,子串[T0...Ti]中 T0 的总数大于等于 Si 大于等于 1。
Si 的确切值等于子串 [T0...Ti] 中 T0 的总数,这些 T0 连续匹配到 Ti。
例如:T="aabaab",则 T2='b',只有 T0('a') 可以继续到 T2,而 T1('a') 无法继续到 T2。因此,S2=1。
因此,我们需要跟踪哪些 T0 被继续使用了(如果是,将其保留在数组中;否则,从数组中删除它)。然后,就可以轻松计算每个 Ti 的相似度。
同时,为了提高性能,我们不需要检查每个连续的 T0。实际上,对于一些 T0,它们可以合并在一起。因为它们属于重复模式。(可能是长模式或短模式)。
例如:
ababababab... :T0、T2、T4、T6... 可以合并为一个整体。
aaaaaaaaaa... :T0、T1、T2、T3... 可以合并为一个整体。
aaaabaaaabaaaab...:
T0、T5、T10、T15... 可以合并成一个整体。
T1、T2、T3 可以合并成一个整体。
T6、T7、T8 可以合并成一个整体。
...
详细的实现代码如下所示。 希望有人能够发布他们在这个主题上的最佳实现和测试结果。 谢谢。
    public static List<ANode> anodes = null;
    public static List<ANode> tnodes = null;
    public static void checkANodes(CharSequence input, int num) {
        tnodes = new Vector<ANode>(); 
        for(int i=anodes.size()-1; i>=0; i--) {
            ANode anode = anodes.get(i);
            if(input.charAt(num) == input.charAt(num-anode.pos)) {
                tnodes.add(anode);
            }else {
                if(tnodes.size() > 0) {
                    // ok to do the changes
                    ANode after = tnodes.get(tnodes.size()-1);
                    tnodes.remove(after);
                    if(after.c > 1) {
                        tnodes.add(new ANode(after.pos + after.shift, after.shift ,after.c-1)); 
                        tnodes.add(new ANode(after.pos, after.pos-anode.pos + anode.shift,1));
                    }else {
                        tnodes.add(new ANode(after.pos, after.pos-anode.pos + anode.shift,1));  
                    }
                }
            }
        }

        anodes.clear();
        for(int i=tnodes.size() - 1; i >= 0; i--) {
            anodes.add(tnodes.get(i));
        }
    }

    public static int secondTry(CharSequence input) {
        anodes = new Vector<ANode>();

        int start = 0;
        for (int i = 1; i < input.length(); i++) {
            if (input.charAt(i) == input.charAt(0)) {
                start = i;
                break;
            }
        }

        int count = 0;
        int base = 0;
        for (int i = start; i < input.length(); i++) {
            checkANodes(input, i);
            if(input.charAt(0) == input.charAt(i)) {
                if(anodes.size() == 0) {
                    anodes.add(new ANode(i,  i, 1));
                }else {
                    ANode last = anodes.get(anodes.size()-1);
                    int shift = i - last.pos;
                    int mod = shift % last.shift;
                    if(mod == 0) {
                        last.c++;
                    }else {
                        anodes.add(new ANode(i, mod, 1));
                    }
                }
            }

            base = 0;
            for(ANode anode : anodes) {
                base = base + anode.c;
            }           
            count = count + base;
        }

        count = count + input.length();
        return count;
    }

public class ANode {
    public int pos = 0;
    public int c = 1;
    public int shift = 0;

    public ANode(int pos, int shift, int c) {
        this.pos = pos;
        this.shift = shift;
        this.c = c;
    }
}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接