32

我正在寻找与Redis KEYS 命令接受的匹配的 glob 样式模式。报价:

  • h?llo 匹配 hello、hallo 和 hxllo
  • h*llo 匹配 hllo 和 heeeello
  • h[ae]llo 匹配 hello 和 hallo,但不匹配 hillo

但我不是与文本字符串匹配,而是将模式与另一个模式匹配,所有运算符在两端都有意义。

例如,这些模式应该在同一行中相互匹配:

prefix*       prefix:extended*
*suffix       *:extended:suffix
left*right    left*middle*right
a*b*c         a*b*d*b*c
hello*        *ok
pre[ab]fix*   pre[bc]fix*

这些不应该匹配:

prefix*       wrong:prefix:*
*suffix       *suffix:wrong
left*right    right*middle*left
pre[ab]fix*   pre[xy]fix*
?*b*?         bcb

所以我想知道...

  • 如果可以做到(实现验证算法),如果有的话?
  • 如果不可能,正则表达式的哪个子集是可能的?(即不允许 * 通配符?)
  • 如果确实有可能,什么是有效的算法?
  • 所需的时间复杂度是多少?

编辑:在 RegEx 子集上找到了另一个问题,但这与单词不完全相同,hello*并且*ok匹配不是彼此的子集/超集,但它们确实相交。

所以我猜在数学上,这可能被表述为;是否可以确定性地检查一个模式匹配的一组单词,与另一个模式匹配的一组单词相交,导致一个非空集?


编辑:一位朋友@neizod绘制了这张消除表,它巧妙地可视化了可能的/部分解决方案:消除规则


编辑:将为那些还可以提供工作代码(任何语言)和证明它的测试用例的人增加额外的赏金。


编辑:添加了 ?*b*? @DanielGimenez 在评论中发现的测试用例。

4

5 回答 5

24

现在见证这个全武装和可操作的战斗站的火力!

(我在这个答案上工作了太多,我的大脑已经坏了;应该有一个徽章。)

为了确定两个模式是否相交,我创建了一个递归回溯解析器——当遇到Kleene 星时,会创建一个新堆栈,这样如果将来失败,所有内容都会回滚,并且消耗下一个字符。

您可以查看此答案的历史,以确定这一切是如何得出的以及为什么需要这样做,但基本上仅通过向前看一个标记来确定交叉点是不够的,这就是我之前所做的。

这就是打破旧答案[abcd]d=>的情况*d。该集合与d号之后的匹配,因此左侧仍有标记,而右侧将是完整的。但是,这些模式两个在、和上相交,因此需要修复。我几乎O(N) 的答案被扔掉了。adbdcddd

词法分析器

词法分析过程很简单,除了处理转义字符并删除多余的号。标记分为集合号、通配符 (?)字符。这与我以前的版本不同,其中一个标记是一串字符而不是单个字符。随着越来越多的案例出现,将字符串作为标记更多的是障碍而不是优势。

解析器

解析器的大部分功能都很简单。给定左侧类型的开关调用一个函数,该函数是一个开关,该函数确定适当的函数以将其与右侧的类型进行比较。比较的结果将两个开关冒泡到原始被调用者,通常是解析器的主循环。

解析星星

简单以号结束。当遇到这种情况时,它会接管一切。首先,它将自己一方的下一个令牌与另一方的令牌进行比较,推进另一方直到找到匹配项。

一旦找到匹配项,它就会检查所有内容是否一直匹配到两个模式的末尾。如果是这样,则模式相交。否则,它将另一方的下一个令牌从与之进行比较的原始令牌推进,并重复该过程。

当遇到两个any时,从彼此的下一个令牌开始进入他们自己的替代分支。

function intersects(left, right) {
    var lt, rt,
        result = new CompareResult(null, null, true);

    lt = (!left || left instanceof Token) ? left : tokenize(left);
    rt = (!right || right instanceof Token) ? right : tokenize(right);

    while (result.isGood && (lt || rt)) {
        result = tokensCompare(lt, rt);

        lt = result.leftNext;
        rt = result.rightNext;
    }

    return result;
}

function tokensCompare(lt, rt) {
    if (!lt && rt) return tokensCompare(rt, lt).swapTokens();

    switch (lt.type) {
        case TokenType.Char: return charCompare(lt, rt);
        case TokenType.Single: return singleCompare(lt, rt);
        case TokenType.Set: return setCompare(lt, rt);
        case TokenType.AnyString: return anyCompare(lt, rt);
    }
}

function anyCompare(tAny, tOther) {
    if (!tOther) return new CompareResult(tAny.next, null);

    var result = CompareResult.BadResult;

    while (tOther && !result.isGood) {
        while (tOther && !result.isGood) {
            switch (tOther.type) {
                case TokenType.Char: result = charCompare(tOther, tAny.next).swapTokens(); break;
                case TokenType.Single: result = singleCompare(tOther, tAny.next).swapTokens(); break;
                case TokenType.Set: result = setCompare(tOther, tAny.next).swapTokens(); break;
                case TokenType.AnyString:
                    // the anyCompare from the intersects will take over the processing.
                    result = intersects(tAny, tOther.next);
                    if (result.isGood) return result;
                    return intersects(tOther, tAny.next).swapTokens();
            }

            if (!result.isGood) tOther = tOther.next;
        }

        if (result.isGood) {
            // we've found a starting point, but now we want to make sure this will always work.
            result = intersects(result.leftNext, result.rightNext);
            if (!result.isGood) tOther = tOther.next;
        }
    }

    // If we never got a good result that means we've eaten everything.
    if (!result.isGood) result = new CompareResult(tAny.next, null, true);

    return result;
}

function charCompare(tChar, tOther) {
    if (!tOther) return CompareResult.BadResult;

    switch (tOther.type) {
        case TokenType.Char: return charCharCompare(tChar, tOther); 
        case TokenType.Single: return new CompareResult(tChar.next, tOther.next);
        case TokenType.Set: return setCharCompare(tOther, tChar).swapTokens(); 
        case TokenType.AnyString: return anyCompare(tOther, tChar).swapTokens();
    }
}

function singleCompare(tSingle, tOther) {
    if (!tOther) return CompareResult.BadResult;

    switch (tOther.type) {
        case TokenType.Char: return new CompareResult(tSingle.next, tOther.next);
        case TokenType.Single: return new CompareResult(tSingle.next, tOther.next);
        case TokenType.Set: return new CompareResult(tSingle.next, tOther.next);
        case TokenType.AnyString: return anyCompare(tOther, tSingle).swapTokens();
    }
}
function setCompare(tSet, tOther) {
    if (!tOther) return CompareResult.BadResult;

    switch (tOther.type) {
        case TokenType.Char: return setCharCompare(tSet, tOther);
        case TokenType.Single: return new CompareResult(tSet.next, tOther.next);
        case TokenType.Set: return setSetCompare(tSet, tOther);
        case TokenType.AnyString: return anyCompare(tOther, tSet).swapTokens();
    }
}

function anySingleCompare(tAny, tSingle) {
    var nextResult = (tAny.next) ? singleCompare(tSingle, tAny.next).swapTokens() :
        new CompareResult(tAny, tSingle.next);
    return (nextResult.isGood) ? nextResult: new CompareResult(tAny, tSingle.next);
}

function anyCharCompare(tAny, tChar) {
    var nextResult = (tAny.next) ? charCompare(tChar, tAny.next).swapTokens() :
        new CompareResult(tAny, tChar.next);

    return (nextResult.isGood) ? nextResult : new CompareResult(tAny, tChar.next);
}

function charCharCompare(litA, litB) {
    return (litA.val === litB.val) ?
        new CompareResult(litA.next, litB.next) : CompareResult.BadResult;
}

function setCharCompare(tSet, tChar) {
    return (tSet.val.indexOf(tChar.val) > -1) ?
        new CompareResult(tSet.next, tChar.next) : CompareResult.BadResult;
}

function setSetCompare(tSetA, tSetB) {
    var setA = tSetA.val,
        setB = tSetB.val;

    for (var i = 0, il = setA.length; i < il; i++) {
        if (setB.indexOf(setA.charAt(i)) > -1) return new CompareResult(tSetA.next, tSetB.next);
    }
    return CompareResult.BadResult;
}

jsFiddle

时间复杂度

任何带有“递归回溯”字样的东西至少是 O(N 2 )。

可维护性和可读性

我故意用一个单一的开关将任何分支分解成自己的功能。当一个字符串就足够时,我还使用命名常量。这样做会使代码更长更冗长,但我认为它更容易理解。

测试

您可以查看 Fiddle 中的所有测试。您可以查看 Fiddle 输出中的注释以了解其用途。每种令牌类型都针对每种令牌类型进行了测试,但我还没有在一次测试中尝试过所有可能的比较。我还想出了一些随机的艰难的,比如下面的。

abc[def]?fghi?*nop*[tuv]uv[wxy]?yz=>a?[cde]defg*?ilmn[opq]*tu*[xyz]*

如果有人想自己测试一下,我在jsFiddle上添加了一个接口。一旦我添加了递归,日志记录就会被破坏。

我认为我没有尝试足够的负面测试,尤其是在我创建的最后一个版本中。

优化

目前解决方案是蛮力解决方案,但足以处理任何情况。我想在某个时候回到这一点,通过一些简单的优化来提高时间复杂度。

在开始时进行检查以减少比较可能会增加某些常见场景的处理时间。例如,如果一个模式以星形开头,一个以星形结尾,那么我们已经知道它们会相交。我还可以检查模式开头和结尾的所有字符,如果两个模式都匹配,则将其删除。这样他们就被排除在任何未来的递归之外。

致谢

在我提出自己的代码之前,我最初使用@m.buettner 的测试来测试我的代码。我还浏览了他的代码,以帮助我更好地理解问题。

于 2013-09-15T19:44:02.083 回答
7

使用您非常简化的模式语言,您的问题中的 pastebin 链接和 jpmc26 的评论几乎一直存在:主要问题是,您的输入字符串的左端和右端是否匹配。如果他们这样做,并且都包含至少一个*,则字符串匹配(因为您始终可以将其他字符串中间文字文本与该星匹配)。有一种特殊情况:如果其中只有一个是空的(去掉前缀和后缀),如果另一个完全由*s 组成,它们仍然可以匹配。

当然,在检查字符串的结尾是否匹配时,还需要考虑单字符通配符?和字符类。单字符通配符很简单:它不会失败,因为它总是匹配另一个字符。如果是一个字符类,而另一个只是一个字符,则需要检查该字符是否在该类中。如果它们都是类,则需要检查类的交集(这是一个简单的集合交集)。

以下是 JavaScript 中的所有内容(查看代码注释,了解我上面概述的算法如何映射到代码):

var trueInput = [
    { left: 'prefix*', right: 'prefix:extended*' },
    { left: '*suffix', right: '*:extended:suffix' },
    { left: 'left*right', right: 'left*middle*right' },
    { left: 'a*b*c', right: 'a*b*d*b*c' },
    { left: 'hello*', right: '*ok' },
    { left: '*', right: '*'},
    { left: '*', right: '**'},
    { left: '*', right: ''},
    { left: '', right: ''},
    { left: 'abc', right: 'a*c'},
    { left: 'a*c', right: 'a*c'},
    { left: 'a[bc]d', right: 'acd'},
    { left: 'a[bc]d', right: 'a[ce]d'},
    { left: 'a?d', right: 'acd'},
    { left: 'a[bc]d*wyz', right: 'abd*w[xy]z'},
];

var falseInput = [
    { left: 'prefix*', right: 'wrong:prefix:*' },
    { left: '*suffix', right: '*suffix:wrong' },
    { left: 'left*right', right: 'right*middle*left' },
    { left: 'abc', right: 'abcde'},
    { left: 'abcde', right: 'abc'},
    { left: 'a[bc]d', right: 'aed'},
    { left: 'a[bc]d', right: 'a[fe]d'},
    { left: 'a?e', right: 'acd'},
    { left: 'a[bc]d*wyz', right: 'abc*w[ab]z'},
];

// Expects either a single-character string (for literal strings
// and single-character wildcards) or an array (for character
// classes).
var characterIntersect = function(a,b) {
    // If one is a wildcard, there is an intersection.
    if (a === '?' || b === '?')
        return true;

    // If both are characters, they must be the same.
    if (typeof a === 'string' && typeof b === 'string')
        return a === b;

    // If one is a character class, we check that the other
    // is contained in the class.
    if (a instanceof Array && typeof b === 'string')
        return (a.indexOf(b) > -1);
    if (b instanceof Array && typeof a === 'string')
        return (b.indexOf(a) > -1);

    // Now both have to be arrays, so we need to check whether
    // they intersect.
    return a.filter(function(character) {
        return (b.indexOf(character) > -1);
    }).length > 0;
};

var patternIntersect = function(a,b) {
    // Turn the strings into character arrays because they are
    // easier to deal with.
    a = a.split("");
    b = b.split("");

    // Check the beginnings of the string (up until the first *
    // in either of them).
    while (a.length && b.length && a[0] !== '*' && b[0] !== '*')
    {
        // Remove the first character from each. If it's a [,
        // extract an array of all characters in the class.
        aChar = a.shift();
        if (aChar == '[')
        {
            aChar = a.splice(0, a.indexOf(']'));
            a.shift(); // remove the ]
        }
        bChar = b.shift();
        if (bChar == '[')
        {
            bChar = b.splice(0, b.indexOf(']'));
            b.shift(); // remove the ]
        }

        // Check if the two characters or classes overlap.
        if (!characterIntersect(aChar, bChar))
            return false;
    }

    // Same thing, but for the end of the string.
    while (a.length && b.length && a[a.length-1] !== '*' && b[b.length-1] !== '*')
    {
        aChar = a.pop();
        if (aChar == ']')
        {
            aChar = a.splice(a.indexOf('[')+1, Number.MAX_VALUE);
            a.pop(); // remove the [
        }
        bChar = b.pop();
        if (bChar == ']')
        {
            bChar = b.splice(b.indexOf('[')+1, Number.MAX_VALUE);
            b.pop(); // remove the [
        }

        if (!characterIntersect(aChar, bChar))
            return false;
    }

    // If one string is empty, the other has to be empty, too, or
    // consist only of stars.
    if (!a.length && /[^*]/.test(b.join('')) ||
        !b.length && /[^*]/.test(b.join('')))
        return false;

    // The only case not covered above is that both strings contain
    // a * in which case they certainly overlap.
    return true;
};

console.log('Should be all true:');
console.log(trueInput.map(function(pair) {
    return patternIntersect(pair.left, pair.right);
}));

console.log('Should be all false:');
console.log(falseInput.map(function(pair) {
    return patternIntersect(pair.left, pair.right);
}));

这不是最简洁的实现,但它可以工作并且(希望)仍然非常可读。检查开头和结尾有相当多的代码重复(reverse在检查开头后可以通过简单的方法来缓解 - 但我认为这只会使事情变得模糊)。可能还有很多其他方面可以大大改进,但我认为逻辑已经到位。

再多说几句:实现假定模式格式正确(没有不匹配的左括号或右括号)。另外,我从这个答案中获取了数组交集代码,因为它很紧凑 - 如果有必要,你当然可以提高它的效率。

不管这些实现细节如何,我想我也可以回答你的复杂性问题:外循环同时遍历两个字符串,一次遍历一个字符。这就是线性复杂度。循环中的所有内容都可以在恒定时间内完成,除了字符类测试。如果一个字符是字符类而另一个不是,则需要线性时间(以类的大小为参数)来检查字符是否在类中。但这并不能使它成为二次方,因为类中的每个字符都意味着外循环的迭代次数减少了一次。所以这仍然是线性的。因此,最昂贵的事情是两个字符类的交集。这可能比线性时间更复杂,但最糟糕的是O(N log N):毕竟,您可以对两个字符类进行排序,然后在线性时间内找到一个交集。我认为你甚至可以通过将字符类中的字符散列到它们的 Unicode 代码点(.charCodeAt(0)在 JS 中)或其他数字来获得整体线性时间复杂度——并且可以在线性时间内找到散列集中的交集。所以,如果你真的想,我认为你应该能够开始O(N)

什么是N?上限是两种模式的长度之和,但在大多数情况下它实际上会更短(取决于两种模式的前缀和后缀的长度)。

请指出我的算法丢失的任何边缘情况。我也很高兴建议的改进,如果它们改进或至少不降低代码的清晰度。

这是 JSBin 的现场演示(感谢 chakrit 将其粘贴在那里)。


编辑:正如丹尼尔指出的那样,我的算法错过了一个概念性的边缘情况。如果(在消除开头和结尾之前或之后)一个字符串包含 no*而另一个包含 no,则在某些情况下,两者仍然会发生冲突。不幸的是,我现在没有时间调整我的代码片段以适应该问题,但我可以概述如何解决它。

消除字符串的两端后,如果两个字符串都为空或都包含至少*,它们将始终匹配(通过*完全消除后可能的 - 分布来查看这一点)。唯一不重要的情况是一个字符串仍然包含*,但另一个不包含(无论是否为空)。我们现在需要做的是再次从左到右走两个字符串。让我称包含*A 的字符串和不包含*B的字符串。

我们从左到右遍历 A,跳过所有*(只关注?、字符类和文字字符)。对于每个相关的标记,我们从左到右检查它是否可以在 B 中匹配(在第一次出现时停止)并将 B 光标推进到该位置。如果我们在 A 中找到一个在 B 中找不到的标记,它们就不匹配。如果我们设法为 A 中的每个标记找到匹配项,它们确实匹配。这样,我们仍然使用线性时间,因为不涉及回溯。这里有两个例子。这两个应该匹配:

A: *a*[bc]*?*d* --- B: db?bdfdc
    ^                    ^
A: *a*[bc]*?*d* --- B: db?bdfdc
      ^                   ^
A: *a*[bc]*?*d* --- B: db?bdfdc
           ^               ^
A: *a*[bc]*?*d* --- B: db?bdfdc
             ^               ^

这两个不应该匹配:

A: *a*[bc]*?*d* --- B: dbabdfc
    ^                    ^
A: *a*[bc]*?*d* --- B: dbabdfc
      ^                   ^
A: *a*[bc]*?*d* --- B: dbabdfc
           ^               ^
A: *a*[bc]*?*d* --- B: dbabdfc
             !               

它失败了,因为在?second 之前不可能匹配d,之后 B 中没有进一步d的内容来容纳dA中的最后一个。

如果我花时间将字符串正确解析为令牌对象,这可能很容易添加到我当前的实现中。但是现在,我不得不再次麻烦地解析这些字符类。我希望这个添加的书面大纲是足够的帮助。

PS:当然,我的实现也没有考虑转义元字符,并且可能会阻塞*内部字符类。

于 2013-09-12T19:44:05.647 回答
6

这些特殊模式远不如完整的正则表达式强大,但我会指出,即使使用通用正则表达式可以做你想做的事情。这些必须是“真正的”正则表达式,即仅使用 Kleene 星号、交替(| 操作)以及与任何固定字母表加上空字符串和空集的连接。当然,您也可以在这些操作上使用任何语法糖:一个或多个 (+)、可选 (?)。字符集只是一种特殊的交替 [ac] == a|b|c。

该算法原则上很简单:使用标准结构将每个正则表达式转换为 DFA:Thompson 后跟 powerset。然后使用叉积构造计算两个原件的交集 DFA。最后检查这个交集 DFA 以确定它是否至少接受一个字符串。这只是从开始状态看是否可以达到接受状态的dfs。

如果您不熟悉这些算法,很容易在 Internet 上找到参考资料。

如果交集 DFA 至少接受一个字符串,则原始正则表达式之间存在匹配,并且 dfs 发现的路径给出了一个同时满足两者的字符串。否则没有匹配。

于 2013-09-18T00:42:50.217 回答
5

好问题!

这里的主要复杂性是处理字符类 ( [...])。我的方法是用一个正则表达式替换每一个,该正则表达式查找恰好一个指定字符(或?另一个包含至少一个指定字符的字符类。所以对于[xyz],这将是:([xyz?]|\[[^\]]*[xyz].*?\])- 见下文:

正则表达式可视化

然后对于“前缀”(第一个之前的所有内容*),放在^开头或对于“后缀”(最后一个之后的所有内容*),放在$最后。

更多详细信息:-

  1. 还需要替换 with 的所有实例?(\[.*?\]|[^\\]])使其匹配字符类或单个字符(不包括左方括号)。
  2. 还需要替换不在字符类中的每个单独的字符,并且不?使其匹配相同的字符?或包含该字符的字符类。例如a会变成([a?]|\[[^\]]*a.*?\]). (有点啰嗦,但事实证明是必要的 - 请参阅下面的评论)。
  3. 测试应按以下两种方式进行:测试前缀#1 转换为正则表达式与前缀#2,然后测试前缀#2 转换为正则表达式与前缀#1。如果任一匹配,则可以说前缀“相交”。
  4. 对后缀重复步骤 (3.):对于肯定的结果,前缀和后缀必须相交。

编辑:除上述之外,还有一种特殊情况,即其中一个模式至少包含一个*但另一个不包含。在这种情况下,整个模式 with*应该被转换成一个正则表达式:*应该匹配任何东西,但前提是它只包括整个字符类。这可以通过用 替换所有实例来*完成(\[.*?\]|[^\\]])

为了避免这个答案变得庞大,我不会发布完整的代码,但这里有一个带有单元测试的工作演示:http: //jsfiddle.net/mb3Hn/4/

编辑#2 - 已知的不完整性:在其当前形式中,演示不适合转义字符(例如\[)。这不是一个很好的借口,但我只是在当天晚些时候才注意到这些 - 问题中没有提到它们,只有链接。为了处理它们,需要一些额外的正则表达式复杂性,例如检查[. 这应该是相当轻松的负面回顾但不幸的是 Javascript 不支持它。有一些解决方法,例如用负前瞻来反转字符串和正则表达式,但我不热衷于使用这种额外的复杂性降低代码的可读性,并且不确定它对 OP 的重要性,因此将其保留为“练习读者”。回想起来,也许应该选择一种具有更全面的正则表达式支持的语言!

于 2013-09-15T15:07:37.187 回答
0

使用greenery确定一个正则表达式是否匹配另一个正则表达式的子集:

首先,pip3 install https://github.com/ferno/greenery/archive/master.zip

然后:

from greenery.lego import parse as p
a_z = p("[a-z]")
b_x = p("[b-x]")
assert a_z | b_x == a_z
m_n = p("m|n")
zero_nine = p("[0-9]")
assert not m_n | zero_nine == m_n
于 2015-08-31T22:56:37.630 回答