如何从字符串的中间执行对区域性敏感的“开始于”操作?


106

我有一个相对模糊的要求,但感觉应该可以使用BCL。

对于上下文,我正在解析Noda Time中的日期/时间字符串。我在输入字符串中的位置保持逻辑光标。因此,尽管完整的字符串可能是“ 2013年1月3日”,但逻辑光标可能位于“ J”处。

现在,我需要解析月份名称,并将其与区域性的所有已知月份名称进行比较:

  • 文化敏感
  • 不区分大小写
  • 只是从光标的位置开始(不晚;我想查看光标是否正在“查看”候选月份名称)
  • 很快
  • ...然后我需要知道使用了多少个字符

使用的当前代码通常可以正常运行CompareInfo.Compare。实际上是这样的(仅适用于匹配部分-真实代码中有更多代码,但与匹配无关):

internal bool MatchCaseInsensitive(string candidate, CompareInfo compareInfo)
{
    return compareInfo.Compare(text, position, candidate.Length,
                               candidate, 0, candidate.Length, 
                               CompareOptions.IgnoreCase) == 0;
}

但是,这取决于候选对象和我们比较的区域具有相同的长度。在大多数情况下都可以,但是在某些特殊情况下不可以。假设我们有这样的东西:

// U+00E9 is a single code point for e-acute
var text = "x b\u00e9d y";
int position = 2;
// e followed by U+0301 still means e-acute, but from two code points
var candidate = "be\u0301d";

现在,我的比较将失败。我可以使用IsPrefix

if (compareInfo.IsPrefix(text.Substring(position), candidate,
                         CompareOptions.IgnoreCase))

但:

  • 这需要我创建一个子字符串,我确实希望避免这样做。(我将Noda Time视为有效的系统库;解析性能对于某些客户端可能很重要。)
  • 它没有告诉我事后将光标前进多远

实际上,我强烈怀疑这种情况不会经常出现……但是我真的很想在这里做正确的事。我也很想能够做到这一点,而无需成为Unicode专家或自己实现它:)

(在Noda Time中作为bug 210引发,以防万一有人想遵循任何最终结论。)

我喜欢规范化的想法。我需要详细检查a)正确性和b)性能。假设我可以使其正常运行,我仍然不确定是否值得一揽子改变-这种事情在现实生活中可能永远不会真正出现,但是会损害我所有用户的性能: (

我还检查了BCL-似乎也无法正确处理此BCL。样例代码:

using System;
using System.Globalization;

class Test
{
    static void Main()
    {
        var culture = (CultureInfo) CultureInfo.InvariantCulture.Clone();
        var months = culture.DateTimeFormat.AbbreviatedMonthNames;
        months[10] = "be\u0301d";
        culture.DateTimeFormat.AbbreviatedMonthNames = months;

        var text = "25 b\u00e9d 2013";
        var pattern = "dd MMM yyyy";
        DateTime result;
        if (DateTime.TryParseExact(text, pattern, culture,
                                   DateTimeStyles.None, out result))
        {
            Console.WriteLine("Parsed! Result={0}", result);
        }
        else
        {
            Console.WriteLine("Didn't parse");
        }
    }
}

将自定义月份的名称更改为文本值为“ bEd”的“ bed”即可。

好的,还有几个数据点:

  • 使用Substring和的成本很高,IsPrefix但并不可怕。在我的开发笔记本电脑上的示例“ 2013年4月12日星期五20:28:42”上,它将我可以执行的每秒解析操作的次数从大约460K更改为大约400K。如果可能的话,我宁愿避免这种速度下降,但是还算不错。

  • 标准化比我想象的要难-因为在可移植类库中不可用。我可能会将其用于非PCL构建,从而使PCL构建的正确性稍差一些。测试归一化(string.IsNormalized)的性能影响使性能降低到每秒约44.5万次调用,我可以忍受。我仍然不确定它是否可以完成我需要做的所有事情-例如,在许多文化中,包含“ß”的月份名称应与“ ss”匹配,我相信...而规范化并不能做到这一点。


虽然我了解您希望避免创建子字符串对性能造成影响,但最好这样做,但是在游戏开始时,将所有内容转移到选定的Unicode规范化形式FIRST,然后知道您可以“逐点走” ”。可能是D形。
IDisposable

@IDisposable:是的,我确实对此感到奇怪。显然,我可以预先对月份名称进行规范化。至少我只能进行一次标准化。我想知道标准化过程是否首先检查是否需要做任何事情。我在规范化方面没有太多经验-绝对是研究的一种途径。
乔恩·斯基特

1
如果您text的时间不太长,可以这样做if (compareInfo.IndexOf(text, candidate, position, options) == position)msdn.microsoft.com/zh-cn/library/ms143031.aspx 但是,如果text时间太长,那将浪费大量时间进行超出所需位置的搜索。
Jim Mischel

1
只是在这种情况下完全绕过使用String该类并直接使用a 。您最终将编写更多的代码,但这就是当您想要高性能时会发生的事情……或者您应该使用C ++ / CLI进行编程;-)Char[]
intrepidis 2013年

1
CompareOptions.IgnoreNonSpace不照顾这个自动的给你?在我看来(从docco,而不是从这款iPad对不起!一个位置,测试),好像这可能是一个(?)用例该选项。“ 表示字符串比较必须忽略
不限空格的

Answers:


41

我将首先考虑许多个案映射的问题,并与处理不同的Normalization形式分开考虑。

例如:

x heiße y
  ^--- cursor

匹配heisse但将光标1移得太多。和:

x heisse y
  ^--- cursor

匹配heiße但将光标1移得太少。

这将适用于没有简单的一对一映射的任何字符。

您将需要知道实际匹配的子字符串的长度。但是CompareIndexOf.. etc会将这些信息丢弃。这可能是可能的正则表达式,但实现不执行完全折叠的情况下,因此不匹配 ß,以ss/SS在不区分大小写的模式,即使.Compare.IndexOf做。无论如何,为每个候选人创建新的正则表达式可能会很昂贵。

最简单的解决方案是将字符串以大小写折叠的形式存储在内部,并与大小写折叠的候选对象进行二进制比较。然后,仅.Length因为光标用于内部表示,才可以正确移动光标。您还可以从不必使用中获得大部分损失的性能CompareOptions.IgnoreCase

不幸的是,没有内置的案例折叠功能,并且穷人的案例折叠也不起作用,因为没有完整的案例映射-该ToUpper方法没有ß变成SS

例如,在给定标准格式C的字符串的情况下,这可以在Java(甚至Javascript)中运行:

//Poor man's case folding.
//There are some edge cases where this doesn't work
public static String toCaseFold( String input, Locale cultureInfo ) {
    return input.toUpperCase(cultureInfo).toLowerCase(cultureInfo);
}

有趣的是,Java的忽略大小写比较没有像C#那样进行完整的大小写折叠CompareOptions.IgnoreCase。因此,它们在这方面是相反的:Java进行完整的大小写映射,但进行简单的大小写折叠-C#进行简单的大小写映射,但进行全大小写折叠。

因此,在使用字符串之前,可能需要第3方库来对字符串进行大小写折叠。


在执行任何操作之前,必须确保您的字符串格式为C。可以使用针对拉丁脚本优化的以下初步快速检查:

public static bool MaybeRequiresNormalizationToFormC(string input)
{
    if( input == null ) throw new ArgumentNullException("input");

    int len = input.Length;
    for (int i = 0; i < len; ++i)
    {
        if (input[i] > 0x2FF)
        {
            return true;
        }
    }

    return false;
}

这会带来误报,但不会带来误报,我不希望它在使用拉丁脚本字符时完全降低460k解析/秒的速度,尽管它需要在每个字符串上执行。如果误报是肯定的,那么您将IsNormalized获得真正的否定/肯定,只有在必要时才进行归一化处理。


因此,总而言之,该处理过程是先确保标准形式C,然后是大小写折叠。对已处理的字符串进行二进制比较,并在当前移动光标时移动光标。


谢谢您-我需要更详细地研究规范化形式C,但是这些都是很好的指针。我认为我可以忍受“它在PCL下无法正常工作”(不提供规范化)。在这里使用第三方库进行案例折叠将是多余的-我们目前还没有第三方库,而仅针对即使BCL也无法处理的极端案例引入一个库也很麻烦。可能情况下,案例折叠对文化敏感,顺便说一句(例如土耳其语)?
乔恩·斯基特

2
@JonSkeet是的,Turkic在casefold映射中应有自己的模式:P请参阅CaseFolding.txt标题中的format部分
Esailija 2013年

这个答案似乎有一个根本性的缺陷,那就是它意味着字符仅在折叠时才映射到连字(反之亦然)。不是这种情况; 无论是否使用大小写,连字都被认为等于字符。例如,在美国文化下,æ等于ae等于ffi。C规范化根本不处理连字,因为它仅允许兼容性映射(通常仅限于组合字符)。
道格拉斯

KC和KD规范化确实处理了一些连字,例如,但是却错过了其他连字,例如æ。文化之间的差异使问题变得更糟- æ等于aeen-US 之下,而不是 da-DK之下,正如MSDN文档中有关字符串的讨论一样。因此,规范化(以任何形式)和大小写映射不足以解决此问题。
道格拉斯

对我之前的评论进行了小幅修正:C规范化仅允许规范映射(例如用于组合字符),而不允许兼容性映射(例如用于连字)。
道格拉斯

21

看看是否符合要求..:

public static partial class GlobalizationExtensions {
    public static int IsPrefix(
        this CompareInfo compareInfo,
        String source, String prefix, int startIndex, CompareOptions options
        ) {
        if(compareInfo.IndexOf(source, prefix, startIndex, options)!=startIndex)
            return ~0;
        else
            // source is started with prefix
            // therefore the loop must exit
            for(int length2=0, length1=prefix.Length; ; )
                if(0==compareInfo.Compare(
                        prefix, 0, length1, 
                        source, startIndex, ++length2, options))
                    return length2;
    }
}

compareInfo.Compare仅从source开始开始执行prefix; 如果不是,则IsPrefix返回-1;否则为中使用的字符长度source

不过,我除了增量不知道length2通过1与下面的情况:

var candidate="ßssß\u00E9\u0302";
var text="abcd ssßss\u0065\u0301\u0302sss";

var count=
    culture.CompareInfo.IsPrefix(text, candidate, 5, CompareOptions.IgnoreCase);

更新

我试图提高一些性能。但是,下面的代码并没有证明是否有错误:

public static partial class GlobalizationExtensions {
    public static int Compare(
        this CompareInfo compareInfo,
        String source, String prefix, int startIndex, ref int length2, 
        CompareOptions options) {
        int length1=prefix.Length, v2, v1;

        if(0==(v1=compareInfo.Compare(
            prefix, 0, length1, source, startIndex, length2, options))
            ) {
            return 0;
        }
        else {
            if(0==(v2=compareInfo.Compare(
                prefix, 0, length1, source, startIndex, 1+length2, options))
                ) {
                ++length2;
                return 0;
            }
            else {
                if(v1<0||v2<0) {
                    length2-=2;
                    return -1;
                }
                else {
                    length2+=2;
                    return 1;
                }
            }
        }
    }

    public static int IsPrefix(
        this CompareInfo compareInfo,
        String source, String prefix, int startIndex, CompareOptions options
        ) {
        if(compareInfo.IndexOf(source, prefix, startIndex, options)
                !=startIndex)
            return ~0;
        else
            for(int length2=
                    Math.Min(prefix.Length, source.Length-(1+startIndex)); ; )
                if(0==compareInfo.Compare(
                        source, prefix, startIndex, ref length2, options))
                    return length2;
    }
}

我测试了这种特殊情况,比较结果低至约3。


真的宁愿不必像这样循环。可以肯定的是,只要发现了一些东西,就只需要循环就可以了,但是我还是不想为了比较例如“ 2月”而进行8个字符串比较。感觉必须有更好的方法。同样,初始IndexOf操作必须从起始位置开始看整个字符串,如果输入字符串很长,这会带来性能上的麻烦。
乔恩·斯基特

@JonSkeet:谢谢。也许可以添加一些东西来检测循环是否可以减少。我会考虑的。
肯健

@JonSkeet:您会考虑使用反射吗?自从我追踪了这些方法以来,它们很快就陷入了调用本地方法的困境。
肯健

3
确实。Noda Time不想
涉足

2
我曾经解决过类似的问题(HTML中的搜索字符串突出显示)。我也做了类似的事情。您可以通过首先检查可能的情况来调整循环和搜索策略,以使其快速完成。这样做的好处是,它似乎是完全正确的,并且没有Unicode详细信息泄漏到您的代码中。
usr

9

实际上,无需标准化也无需使用,这是可能的IsPrefix

我们需要比较相同数目的文本元素和相同数目的字符,但仍返回匹配字符的数目。

我已经在Noda Time中MatchCaseInsensitiveValueCursor.cs创建了该方法的副本,并对其进行了一些修改,以便可以在静态上下文中使用它:

// Noda time code from MatchCaseInsensitive in ValueCursor.cs
static int IsMatch_Original(string source, int index, string match, CompareInfo compareInfo)
{
    unchecked
    {
        if (match.Length > source.Length - index)
        {
            return 0;
        }

        // TODO(V1.2): This will fail if the length in the input string is different to the length in the
        // match string for culture-specific reasons. It's not clear how to handle that...
        if (compareInfo.Compare(source, index, match.Length, match, 0, match.Length, CompareOptions.IgnoreCase) == 0)
        {
            return match.Length;
        }

        return 0;
    }
}

(仅供参考,这是您所知道的无法正确比较的代码)

该方法的以下变体使用框架提供的StringInfo.GetNextTextElement。这个想法是比较文本元素和文本元素以找到匹配项,如果找到则返回源字符串中匹配字符的实际数量:

// Using StringInfo.GetNextTextElement to match by text elements instead of characters
static int IsMatch_New(string source, int index, string match, CompareInfo compareInfo)
{
    int sourceIndex = index;
    int matchIndex = 0;

    // Loop until we reach the end of source or match
    while (sourceIndex < source.Length && matchIndex < match.Length)
    {
        // Get text elements at the current positions of source and match
        // Normally that will be just one character but may be more in case of Unicode combining characters
        string sourceElem = StringInfo.GetNextTextElement(source, sourceIndex);
        string matchElem = StringInfo.GetNextTextElement(match, matchIndex);

        // Compare the current elements.
        if (compareInfo.Compare(sourceElem, matchElem, CompareOptions.IgnoreCase) != 0)
        {
            return 0; // No match
        }

        // Advance in source and match (by number of characters)
        sourceIndex += sourceElem.Length;
        matchIndex += matchElem.Length;
    }

    // Check if we reached end of source and not end of match
    if (matchIndex != match.Length)
    {
        return 0; // No match
    }

    // Found match. Return number of matching characters from source.
    return sourceIndex - index;
}

至少根据我的测试用例(基本上只是测试您提供的字符串的几个变体:"b\u00e9d""be\u0301d"),该方法才能正常工作。

但是,GetNextTextElement方法为每个文本元素创建一个子字符串,因此此实现需要大量子字符串比较-这将对性能产生影响。

因此,我创建了另一个不使用GetNextTextElement的变体,而是跳过了Unicode组合字符以找到字符中的实际匹配长度

// This should be faster
static int IsMatch_Faster(string source, int index, string match, CompareInfo compareInfo)
{
    int sourceLength = source.Length;
    int matchLength = match.Length;
    int sourceIndex = index;
    int matchIndex = 0;

    // Loop until we reach the end of source or match
    while (sourceIndex < sourceLength && matchIndex < matchLength)
    {
        sourceIndex += GetTextElemLen(source, sourceIndex, sourceLength);
        matchIndex += GetTextElemLen(match, matchIndex, matchLength);
    }

    // Check if we reached end of source and not end of match
    if (matchIndex != matchLength)
    {
        return 0; // No match
    }

    // Check if we've found a match
    if (compareInfo.Compare(source, index, sourceIndex - index, match, 0, matchIndex, CompareOptions.IgnoreCase) != 0)
    {
        return 0; // No match
    }

    // Found match. Return number of matching characters from source.
    return sourceIndex - index;
}

该方法使用以下两个帮助程序:

static int GetTextElemLen(string str, int index, int strLen)
{
    bool stop = false;
    int elemLen;

    for (elemLen = 0; index < strLen && !stop; ++elemLen, ++index)
    {
        stop = !IsCombiningCharacter(str, index);
    }

    return elemLen;
}

static bool IsCombiningCharacter(string str, int index)
{
    switch (CharUnicodeInfo.GetUnicodeCategory(str, index))
    {
        case UnicodeCategory.NonSpacingMark:
        case UnicodeCategory.SpacingCombiningMark:
        case UnicodeCategory.EnclosingMark:
            return true;

        default:
            return false;
    }
}

我还没有做过基准测试,所以我真的不知道更快的方法是否真的更快。我也没有做任何扩展测试。

但这应该回答您的问题,即如何对可能包含Unicode组合字符的字符串执行文化敏感的子字符串匹配。

这些是我使用的测试用例:

static Tuple<string, int, string, int>[] tests = new []
{
    Tuple.Create("x b\u00e9d y", 2, "be\u0301d", 3),
    Tuple.Create("x be\u0301d y", 2, "b\u00e9d", 4),

    Tuple.Create("x b\u00e9d", 2, "be\u0301d", 3),
    Tuple.Create("x be\u0301d", 2, "b\u00e9d", 4),

    Tuple.Create("b\u00e9d y", 0, "be\u0301d", 3),
    Tuple.Create("be\u0301d y", 0, "b\u00e9d", 4),

    Tuple.Create("b\u00e9d", 0, "be\u0301d", 3),
    Tuple.Create("be\u0301d", 0, "b\u00e9d", 4),

    Tuple.Create("b\u00e9", 0, "be\u0301d", 0),
    Tuple.Create("be\u0301", 0, "b\u00e9d", 0),
};

元组的值为:

  1. 源字符串(干草堆)
  2. 源代码中的起始位置。
  3. 匹配字符串(针)。
  4. 预期的匹配长度。

在这三种方法上运行这些测试将得到以下结果:

Test #0: Orignal=BAD; New=OK; Faster=OK
Test #1: Orignal=BAD; New=OK; Faster=OK
Test #2: Orignal=BAD; New=OK; Faster=OK
Test #3: Orignal=BAD; New=OK; Faster=OK
Test #4: Orignal=BAD; New=OK; Faster=OK
Test #5: Orignal=BAD; New=OK; Faster=OK
Test #6: Orignal=BAD; New=OK; Faster=OK
Test #7: Orignal=BAD; New=OK; Faster=OK
Test #8: Orignal=OK; New=OK; Faster=OK
Test #9: Orignal=OK; New=OK; Faster=OK

最后两个测试正在测试源字符串短于匹配字符串的情况。在这种情况下,原始(Noda时间)方法也将成功。


非常感谢你做的这些。我需要详细查看它的性能,但是它看起来像是一个很好的起点。对Unicode(在代码本身中)的了解比我期望的要多,但是如果平台不执行所需的工作,那么我将无能为力:(
Jon Skeet 2014年

@JonSkeet:很乐意提供帮助!是的,支持Unicode串匹配应该肯定已被列入框架...
貂Wikström
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.