【问题标题】:How can I perform a culture-sensitive "starts-with" operation from the middle of a string?如何从字符串中间执行文化敏感的“开始于”操作?
【发布时间】:2013-04-05 12:13:09
【问题描述】:

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

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

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

  • 文化敏感
  • 不区分大小写
  • 就从光标点开始(不是以后;我想看看光标是否在“查看”候选月份名称)
  • 快速
  • ...之后我需要知道使用了多少个字符

使用CompareInfo.Compare 通常可以使用current code。它实际上是这样的(仅用于匹配部分 - 实物中有更多代码,但与匹配无关):

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 专家或自己实现它的情况下做到这一点:)

(在野田时间以bug 210 提出,以防有人想遵循任何最终结论。)

我喜欢标准化的想法。我需要详细检查 a) 正确性和 b) 性能。假设我可以让它正常工作,我仍然不确定是否值得全面改变 - 这种事情可能永远不会真正出现在现实生活中,但可能会损害我所有用户的性能:(

我还检查了 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”可以正常解析。

好的,再来几个数据点:

  • 使用SubstringIsPrefix 的成本很高,但并不可怕。在我的开发笔记本电脑上的“2013 年 4 月 12 日星期五 20:28:42”示例中,它将我在一秒钟内可以执行的解析操作的数量从大约 460K 更改为大约 400K。如果可能的话,我宁愿避免这种减速,但这并不是糟糕。

  • 规范化不像我想象的那样可行 - 因为它在可移植类库中不可用。我可能会将它只是用于非 PCL 构建,从而允许 PCL 构建不太正确。标准化测试的性能损失 (string.IsNormalized) 将性能降低到每秒大约 445K 调用,这是我可以忍受的。我仍然不确定它是否能满足我的所有需求 - 例如,在许多文化中,包含“ß”的月份名称应该与“ss”匹配,我相信......而规范化并不能做到这一点。

【问题讨论】:

  • 虽然我理解您希望避免创建子字符串对性能造成的影响,但最好这样做,但在游戏的早期,通过将所有内容转换为选择的 unicode 规范化形式 FIRST 然后了解您可以“逐点”行走。可能是 D 型。
  • @IDisposable:是的,我确实想知道这一点。显然,我可以事先规范月份名称本身。至少我可以只做一次标准化。我想知道规范化程序是否检查是否需要先做任何事情。我在规范化方面没有太多经验 - 绝对是一个值得研究的途径。
  • 如果您的text 不是太长,您可以使用if (compareInfo.IndexOf(text, candidate, position, options) == position)msdn.microsoft.com/en-us/library/ms143031.aspx 但如果 text 很长,那将浪费大量时间搜索超出需要的位置。
  • 在这种情况下,at all 直接绕过String 类,直接使用Char[]。您最终会编写更多代码,但这就是您想要高性能时发生的事情......或者您应该使用 C++/CLI 进行编程;-)
  • CompareOptions.IgnoreNonSpace 不会自动为您处理这个问题吗?在我看来(来自 docco,抱歉,无法通过此 iPad 进行测试!)好像这可能是该选项的 (the?) 用例。 "表示字符串比较必须忽略非空格组合字符,例如变音符号。"

标签: .net string unicode


【解决方案1】:

我将首先考虑许多一个/多个案例映射的问题,并将其与处理不同的规范化形式分开。

例如:

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

例如,这适用于 Java(甚至是 Javascript),给定的字符串是 Normal Form C:

//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 来获得真正的否定/肯定,并且仅在必要时进行标准化。


所以综上所述,处理是先保证normal form C,然后case fold。对已处理的字符串进行二进制比较,并在当前移动光标时移动光标。

【讨论】:

  • 谢谢你 - 我需要更详细地研究规范化形式 C,但这些都是很好的指针。我想我可以忍受“它在 PCL 下不能正常工作”(它不提供规范化)。在这里使用 3rd 方库进行案例折叠将是多余的——我们目前没有 3rd 方依赖项,并且仅针对即使 BCL 也无法处理的极端案例引入一个库会很痛苦。据推测,大小写折叠是文化敏感的,顺便说一句(例如土耳其语)?
  • @JonSkeet 是的,Turkic 在 casefold 映射中应该有自己的模式:P 请参阅 CaseFolding.txt 标题中的格式部分
  • 这个答案似乎有一个根本缺陷,因为它暗示只有在大小写折叠时字符才会映射到连字(反之亦然)。不是这种情况;无论大小写如何,有些连字都被认为等于字符。例如,在 en-US 文化下,æ 等于 ae 等于 ffi。 C-规范化根本不处理连字,因为它只允许兼容性映射(通常仅限于组合字符)。
  • KC 和 KD 规范化确实可以处理一些连字,例如 ,但会忽略其他的,例如 æ。文化之间的差异使问题变得更糟——æ 在 en-US 下等于 ae,但在 da-DK 下不是,正如在 strings 的 MSDN 文档中所讨论的那样.因此,规范化(任何形式)和案例映射都不足以解决这个问题。
  • 对我之前评论的小修正:C 规范化只允许 规范 映射(例如用于组合字符),不允许兼容性映射(例如用于连字)。
【解决方案2】:

看看这是否符合要求..:

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 只执行一次sourceprefix 开头;如果没有,则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。

【讨论】:

  • 真的宁愿不必像这样循环。诚然,如果它发现了某些东西,它只需要循环,但我仍然宁愿不必进行 8 个字符串比较来匹配例如“February”。感觉必须有更好的方法。此外,初始的IndexOf 操作必须从起始位置查看整个字符串,如果输入字符串很长,这将是一个性能问题。
  • @JonSkeet:谢谢。也许可以添加一些东西来检测是否可以减少循环。我会考虑的。
  • @JonSkeet:你会考虑使用反射吗?由于我追溯了方法,它们很快就陷入了调用原生方法。
  • 确实如此。 Noda Time 不想涉足 Unicode 细节的业务 :)
  • 我曾经解决过类似的问题(HTML 中的搜索字符串突出显示)。我也是这样做的。您可以通过首先检查可能的情况来调整循环和搜索策略,使其快速完成。这样做的好处是它似乎完全正确,并且没有 Unicode 细节泄漏到您的代码中。
【解决方案3】:

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

我们需要比较相同数量的文本元素而不是相同数量的字符,但仍然返回匹配字符的数量。

我从ValueCursor.cs in Noda Time 创建了MatchCaseInsensitive 方法的副本,并对其稍作修改,以便可以在静态上下文中使用:

// 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

最后两个测试是测试源字符串比匹配字符串短的情况。在这种情况下,原始(野田时间)方法也会成功。

【讨论】:

  • 非常感谢您。我需要详细查看它以了解它的性能如何,但它看起来是一个很好的起点。需要比我希望更多的 Unicode 知识(在代码本身中),但是如果平台不满足要求,我对此无能为力:(跨度>
  • @JonSkeet:很高兴能提供任何帮助!是的,与 Unicode 支持匹配的子字符串肯定应该包含在框架中......
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2018-06-05
  • 2019-01-12
  • 1970-01-01
  • 1970-01-01
  • 2017-04-09
  • 2020-07-15
  • 1970-01-01
相关资源
最近更新 更多