如何使用.NET按字符串拆分字符串并包含分隔符?

33
有许多类似的问题,但显然没有完美的匹配,这就是我提问的原因。我想通过一组字符串分隔符(例如xx, yy)拆分一个随机字符串(例如123xx456yy789),并将分隔符包含在结果中(这里是123xx456yy789)。良好的性能是一个不错的奖励。如果可能的话,应避免使用正则表达式。
更新:我进行了一些性能测试并比较了结果(虽然太懒了以至于没有正式检查它们)。测试过的解决方案如下(随机顺序):GabeGuffaMafuRegex。其他解决方案未经测试,因为它们要么与另一个解决方案相似,要么来得太晚。

这是测试代码:

class Program
{
    private static readonly List<Func<string, List<string>, List<string>>> Functions;
    private static readonly List<string> Sources;
    private static readonly List<List<string>> Delimiters;

    static Program ()
    {
        Functions = new List<Func<string, List<string>, List<string>>> ();
        Functions.Add ((s, l) => s.SplitIncludeDelimiters_Gabe (l).ToList ());
        Functions.Add ((s, l) => s.SplitIncludeDelimiters_Guffa (l).ToList ());
        Functions.Add ((s, l) => s.SplitIncludeDelimiters_Naive (l).ToList ());
        Functions.Add ((s, l) => s.SplitIncludeDelimiters_Regex (l).ToList ());

        Sources = new List<string> ();
        Sources.Add ("");
        Sources.Add (Guid.NewGuid ().ToString ());

        string str = "";
        for (int outer = 0; outer < 10; outer++) {
            for (int i = 0; i < 10; i++) {
                str += i + "**" + DateTime.UtcNow.Ticks;
            }
            str += "-";
        }
        Sources.Add (str);

        Delimiters = new List<List<string>> ();
        Delimiters.Add (new List<string> () { });
        Delimiters.Add (new List<string> () { "-" });
        Delimiters.Add (new List<string> () { "**" });
        Delimiters.Add (new List<string> () { "-", "**" });
    }

    private class Result
    {
        public readonly int FuncID;
        public readonly int SrcID;
        public readonly int DelimID;
        public readonly long Milliseconds;
        public readonly List<string> Output;

        public Result (int funcID, int srcID, int delimID, long milliseconds, List<string> output)
        {
            FuncID = funcID;
            SrcID = srcID;
            DelimID = delimID;
            Milliseconds = milliseconds;
            Output = output;
        }

        public void Print ()
        {
            Console.WriteLine ("S " + SrcID + "\tD " + DelimID + "\tF " + FuncID + "\t" + Milliseconds + "ms");
            Console.WriteLine (Output.Count + "\t" + string.Join (" ", Output.Take (10).Select (x => x.Length < 15 ? x : x.Substring (0, 15) + "...").ToArray ()));
        }
    }

    static void Main (string[] args)
    {
        var results = new List<Result> ();

        for (int srcID = 0; srcID < 3; srcID++) {
            for (int delimID = 0; delimID < 4; delimID++) {
                for (int funcId = 3; funcId >= 0; funcId--) { // i tried various orders in my tests
                    Stopwatch sw = new Stopwatch ();
                    sw.Start ();

                    var func = Functions[funcId];
                    var src = Sources[srcID];
                    var del = Delimiters[delimID];

                    for (int i = 0; i < 10000; i++) {
                        func (src, del);
                    }
                    var list = func (src, del);
                    sw.Stop ();

                    var res = new Result (funcId, srcID, delimID, sw.ElapsedMilliseconds, list);
                    results.Add (res);
                    res.Print ();
                }
            }
        }
    }
}

正如您所看到的,这只是一个快速而肮脏的测试,但我运行了多次测试,并以不同的顺序进行了测试,结果始终非常一致。测量时间范围为毫秒级到较大数据集的几秒钟。由于在实践中它们似乎微不足道,我在接下来的评估中忽略了低毫秒范围内的值。以下是我的输出结果:

S 0     D 0     F 3     11毫秒
1
S 0     D 0     F 2     7毫秒
1
S 0     D 0     F 1     6毫秒
1
S 0     D 0     F 0     4毫秒
0
S 0     D 1     F 3     28毫秒
1
S 0     D 1     F 2     8毫秒
1
S 0     D 1     F 1     7毫秒
1
S 0     D 1     F 0     3毫秒
0
S 0     D 2     F 3     30毫秒
1
S 0     D 2     F 2     8毫秒
1
S 0     D 2     F 1     6毫秒
1
S 0     D 2     F 0     3毫秒
0
S 0     D 3     F 3     30毫秒
1
S 0     D 3     F 2     10毫秒
1
S 0     D 3     F 1     8毫秒
1
S 0     D 3     F 0     3毫秒
0
S 1     D 0     F 3     9毫秒
1       9e5282ec-e2a2-4...
S 1     D 0     F 2     6毫秒
1       9e5282ec-e2a2-4...
S 1     D 0     F 1     5毫秒
1       9e5282ec-e2a2-4...
S 1     D 0     F 0     5毫秒
1       9e5282ec-e2a2-4...
S 1     D 1     F 3     63毫秒
9       9e5282ec - e2a2 - 4265 - 8276 - 6dbb50fdae37
S 1     D 1     F 2     37毫秒
9       9e5282ec - e2a2 - 4265 - 8276 - 6dbb50fdae37
S 1     D 1     F 1     29毫秒
9       9e5282ec - e2a2 - 4265 - 8276 - 6dbb50fdae37
S 1     D 1     F 0     22毫秒
9       9e5282ec - e2a2 - 4265 - 8276 - 6dbb50fdae37
S 1     D 2     F 3     30毫秒
1       9e5282ec-e2a2-4...
S 1     D 2     F 2     10毫秒
1       9e5282ec-e2a2-4...
S 1     D 2     F 1     10毫秒
1       9e5282ec-e2a2-4...
S 1     D 2     F 0     12毫秒
1       9e5282ec-e2a2-4...
S 1     D 3     F 3     73毫秒
9       9e5282ec - e2a2 - 4265 - 8276 - 6dbb50fdae37
S 1     D 3     F 2     40毫秒
9       9e5282ec - e2a2 - 4265 - 8276 - 6dbb50fdae37
S 1     D 3     F 1     33毫秒
9       9e5282ec - e2a2 - 4265 - 8276 - 6dbb50fdae37
S 1     D 3     F 0     30毫秒
9       9e5282ec - e2a2 - 4265 - 8276 - 6dbb50fdae37
S 2     D 0     F 3     10毫秒
1       0**

我比较了结果,发现如下:

  • 所有4个函数对于一般用途来说都足够快。
  • 朴素版本(即最初的版本)在计算时间方面最差。
  • 正则表达式在小数据集上稍微慢一点(可能是初始化开销导致的)。
  • 正则表达式在大数据上表现良好,并且与非正则表达式的解决方案速度相当。
  • 从性能角度来看,总体上最好的是Guffa的版本,这符合代码预期。
  • Gabe的版本有时会省略一个项目,但我没有调查这个问题(bug?)。

总之,我建议使用正则表达式,它相当快。如果性能很重要,则更喜欢Guffa的实现。

7个回答

47
尽管您不太愿意使用正则表达式,但它实际上可以通过在组合使用 Regex.Split 方法时使用一个组来很好地保留分隔符:

尽管您不太愿意使用正则表达式,但它实际上可以通过在组合使用 Regex.Split 方法时使用一个组来很好地保留分隔符:

string input = "123xx456yy789";
string pattern = "(xx|yy)";
string[] result = Regex.Split(input, pattern);
如果您从模式中删除括号,只使用"xx|yy",则定界符将不会保留。如果在模式中使用任何具有正则表达式中特殊含义的元字符,请确保对该模式使用Regex.Escape。这些字符包括\、*、+、?、|、{、[、(、)、^、$、.、#。例如,分隔符.应该被转义为\.。给定一组分隔符,您需要使用管道|符号来“OR”它们,并且该字符也需要转义。为了正确构建模式,请使用以下代码(感谢@gabe指出):
var delimiters = new List<string> { ".", "xx", "yy" };
string pattern = "(" + String.Join("|", delimiters.Select(d => Regex.Escape(d))
                                                  .ToArray())
                  + ")";
括号是串联在模式中而不是包含在模式中的,因为如果包含在模式中,对于您的目的来说,它们将被错误地转义。
编辑:此外,如果“delimiters”列表为空,则最终模式将错误地变为“()”,这将导致空匹配。 为了防止这种情况,可以使用“delimiters”的检查。 综上所述,代码段变为:
string input = "123xx456yy789";
// to reach the else branch set delimiters to new List();
var delimiters = new List<string> { ".", "xx", "yy", "()" }; 
if (delimiters.Count > 0)
{
    string pattern = "("
                     + String.Join("|", delimiters.Select(d => Regex.Escape(d))
                                                  .ToArray())
                     + ")";
    string[] result = Regex.Split(input, pattern);
    foreach (string s in result)
    {
        Console.WriteLine(s);
    }
}
else
{
    // nothing to split
    Console.WriteLine(input);
}
如果您需要对分隔符进行不区分大小写的匹配,请使用RegexOptions.IgnoreCase选项:Regex.Split(input, pattern, RegexOptions.IgnoreCase) 编辑#2:到目前为止,所提供的解决方案会匹配到可能是较大字符串的子字符串的拆分标记。如果要完全匹配拆分标记而不是子字符串的一部分(例如在句子中使用单词作为分隔符的情况),则应该在模式周围添加单词边界的\b元字符。
例如,考虑以下句子(是的,它很老套):"Welcome to stackoverflow... where the stack never overflows!" 如果分隔符是{"stack", "flow"},则当前解决方案将拆分“stackoverflow”并返回3个字符串{"stack", "over", "flow"}。如果您需要精确匹配,则唯一需要分割的地方是在句子后面的单词“stack”,而不是“stackoverflow”。
要实现精确匹配行为,请将模式更改为包括\b(delim1|delim2|delimN)\b
string pattern = @"\b("
                + String.Join("|", delimiters.Select(d => Regex.Escape(d)))
                + @")\b";

最后,如果希望在定界符之前和之后去掉空格,请在模式周围添加\s*,例如:\s*(delim1|delim2|delimN)\s*。可以与\b组合使用,如下所示:

string pattern = @"\s*\b("
                + String.Join("|", delimiters.Select(d => Regex.Escape(d)))
                + @")\b\s*";

你需要执行 pattern = "(" + String.Join("|", (from d in delimeters select Regex.Escape(d)).ToArray()) + ")",因为任何一个分隔符中都可能包含有 .| 或其它字符。 - Gabe
+1 我不知道你可以这样做!非常好。你只需要修复一下 Regex.Escape 代码就行了... - Mark Byers
@Sky,我不确定我理解你的建议。也许你在看到我的最后一次编辑之前就提出了这个建议?使用数组类似于使用列表;在Select之后还需要一个ToArray(),将IEnumerable<string>转换为string[]以供String.Join使用。 - Ahmad Mageed
+1 - 通常我不喜欢正则表达式。但是你的解决方案,连同代码注释的解释,非常有意义。它真的很易读,并且有清晰的意图,这是关键因素。 - Metro Smurf
@mafutrct:在正则表达式模式中,括号需要进行转义,因为它们用于分组。需要使用pattern = @"\(\)";pattern = Regex.Escape("()");来获得相同的结果,以避免任何奇怪的情况发生。Regex.Escape将转义:\, *, +, ?, |, {, [, (,), ^, $,., #。如果delimiters列表为空,则最终的模式构建将不正确地变成(), 因此需要检查以避免在空列表上进行拆分:if (delimiters.Count > 0) { // build pattern and then split, otherwise do nothing }。这个检查通常是很有必要的。 - Ahmad Mageed
显示剩余8条评论

12

好的,抱歉,也许是这个问题:

    string source = "123xx456yy789";
    foreach (string delimiter in delimiters)
        source = source.Replace(delimiter, ";" + delimiter + ";");
    string[] parts = source.Split(';');

2
对于包含 ; 的分隔符会失败。 - mafu
3
他实际上提出了一个可行的想法。也许可以列出可能的分隔符列表,每个分隔符可以是一个或多个字符。遍历该列表,检查可能的分隔符是否存在,并使用Nagg的逻辑来选择第一个通过测试的分隔符。 - Anthony Pegram
是的,但我真的希望有一种不依赖于字符串中某些分隔符字面值不存在的解决方案。我不认为这个想法可能实现,除非使用一些映射,但这很可能会严重影响性能。当然,我也愿意听取反例。 - mafu
我使用 {".","?","!"} 作为分隔符,将每个句子分割成新的一行。 我想保留分隔符在末尾。这个答案快速简单。修改了一个部分:source = source.Replace(delimiter, delimiter + ";");现在我的句号、问号等都在行末了。谢谢! - Proximo

4

这里有一个解决方案,它不使用正则表达式,也不会生成多于必要的字符串:

public static List<string> Split(string searchStr, string[] separators)
{
    List<string> result = new List<string>();
    int length = searchStr.Length;
    int lastMatchEnd = 0;
    for (int i = 0; i < length; i++)
    {
        for (int j = 0; j < separators.Length; j++)
        {
            string str = separators[j];
            int sepLen = str.Length;
            if (((searchStr[i] == str[0]) && (sepLen <= (length - i))) && ((sepLen == 1) || (String.CompareOrdinal(searchStr, i, str, 0, sepLen) == 0)))
            {
                result.Add(searchStr.Substring(lastMatchEnd, i - lastMatchEnd));
                result.Add(separators[j]);
                i += sepLen - 1;
                lastMatchEnd = i + 1;
                break;
            }
        }
    }
    if (lastMatchEnd != length)
        result.Add(searchStr.Substring(lastMatchEnd));
    return result;
}

我注意到这会产生与所有其他输出不同的结果。有时候一个项目似乎会丢失。 - mafu

3
一个天真的实现
public IEnumerable<string> SplitX (string text, string[] delimiters)
{
    var split = text.Split (delimiters, StringSplitOptions.None);

    foreach (string part in split) {
        yield return part;
        text = text.Substring (part.Length);

        string delim = delimiters.FirstOrDefault (x => text.StartsWith (x));
        if (delim != null) {
            yield return delim;
            text = text.Substring (delim.Length);
        }
    }
}

3

我之前针对类似情况提出了一个解决方案,可以高效地拆分字符串,即保留每个分隔符的下一个出现位置的列表。这样,您最大程度减少了查找每个分隔符的次数。

这种算法即使在长字符串和大量分隔符的情况下也能表现良好:

string input = "123xx456yy789";
string[] delimiters = { "xx", "yy" };

int[] nextPosition = delimiters.Select(d => input.IndexOf(d)).ToArray();
List<string> result = new List<string>();
int pos = 0;
while (true) {
  int firstPos = int.MaxValue;
  string delimiter = null;
  for (int i = 0; i < nextPosition.Length; i++) {
    if (nextPosition[i] != -1 && nextPosition[i] < firstPos) {
      firstPos = nextPosition[i];
      delimiter = delimiters[i];
    }
  }
  if (firstPos != int.MaxValue) {
    result.Add(input.Substring(pos, firstPos - pos));
    result.Add(delimiter);
    pos = firstPos + delimiter.Length;
    for (int i = 0; i < nextPosition.Length; i++) {
      if (nextPosition[i] != -1 && nextPosition[i] < pos) {
        nextPosition[i] = input.IndexOf(delimiters[i], pos);
      }
    }
  } else {
    result.Add(input.Substring(pos));
    break;
  }
}

(请注意可能存在错误,我刚刚匆忙地完成了这个版本,并没有彻底测试它。)


1

这将与String.Split默认模式具有相同的语义(因此不包括空令牌)。

通过使用不安全代码迭代源字符串,可以使其更快,但这需要您自己编写迭代机制,而不是使用yield return。 它分配了绝对最少量(每个非分隔符令牌一个子字符串加上包装枚举器),因此实际上要提高性能,您必须:

  • 使用更多的不安全代码(通过使用“CompareOrdinal”我有效地使用了)
    • 主要是避免在字符缓冲区中查找字符串的开销
  • 利用关于输入源或令牌的特定领域知识。
    • 您可能会满意地消除分隔符的空检查
    • 您可能知道分隔符几乎从不是单个字符

该代码编写为扩展方法

public static IEnumerable<string> SplitWithTokens(
    string str,
    string[] separators)
{
    if (separators == null || separators.Length == 0)
    {
        yield return str;
        yield break;
    }
    int prev = 0;
    for (int i = 0; i < str.Length; i++)
    {
        foreach (var sep in separators)
        {
            if (!string.IsNullOrEmpty(sep))
            {
                if (((str[i] == sep[0]) && 
                          (sep.Length <= (str.Length - i))) 
                     &&
                    ((sep.Length == 1) || 
                    (string.CompareOrdinal(str, i, sep, 0, sep.Length) == 0)))
                {
                    if (i - prev != 0)
                        yield return str.Substring(prev, i - prev);
                    yield return sep;
                    i += sep.Length - 1;
                    prev = i + 1;
                    break;
                }
            }
        }
    }
    if (str.Length - prev > 0)
        yield return str.Substring(prev, str.Length - prev);
}

啊 - 我意识到我在实现上与 Gabe 很相似。我的方法节省了一些分配,但基本上是相同的概念。 - ShuggyCoUk
你的实现如何节省分配? - Gabe
@gabe 我没有为分隔符令牌创建子字符串,这是一个小的改进,可以轻松添加到你的代码中(我看到你已经做了)。 - ShuggyCoUk
为什么不将分隔符作为参数的一部分呢? - Brady Moritz
还有,您忘记了扩展方法的“this”。 - Brady Moritz
显示剩余2条评论

1

我的第一篇帖子/回答...这是一种递归的方法。

    static void Split(string src, string[] delims, ref List<string> final)
    {
        if (src.Length == 0)
            return;

        int endTrimIndex = src.Length;
        foreach (string delim in delims)
        {
            //get the index of the first occurance of this delim
            int indexOfDelim = src.IndexOf(delim);
            //check to see if this delim is at the begining of src
            if (indexOfDelim == 0)
            {
                endTrimIndex = delim.Length;
                break;
            }
            //see if this delim comes before previously searched delims
            else if (indexOfDelim < endTrimIndex && indexOfDelim != -1)
                endTrimIndex = indexOfDelim;
        }
        final.Add(src.Substring(0, endTrimIndex));
        Split(src.Remove(0, endTrimIndex), delims, ref final);
    }

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