有没有更快的方法来执行String.Split()函数?

24

我正在读取一个CSV文件的每一行,需要获取每个列中的值。目前我只是使用以下代码:

values = line.Split(delimiter);

这里的 line 是一个包含由定界符分隔的值的字符串。

在测量我的 ReadNextRow 方法的性能时,我注意到它花费了66% 的时间在 String.Split 上,所以我想知道是否有更快的方法来实现这个功能。

谢谢!


  • 我知道CSV文件的确切内容,所以我不必担心转义字符等问题。
  • 我使用JetBrains的dotTrace进行性能分析。
  • 实际上,我在代码的其他部分中使用Code Project CsvReader。
  • 性能对于这段代码非常重要,这也是我提出疑问的原因。
- user65199
感谢所有的回复。很抱歉我的评论没有直接显示出来,因为这个评论框似乎忽略了换行符。 - user65199
14个回答

25

BCL实现的string.Split实际上非常快,我在这里进行了一些测试,试图超越它并不容易。

但有一件事情你可以做,那就是将其实现为生成器:

public static IEnumerable<string> GetSplit( this string s, char c )
{
    int l = s.Length;
    int i = 0, j = s.IndexOf( c, 0, l );
    if ( j == -1 ) // No such substring
    {
        yield return s; // Return original and break
        yield break;
    }

    while ( j != -1 )
    {
        if ( j - i > 0 ) // Non empty? 
        {
            yield return s.Substring( i, j - i ); // Return non-empty match
        }
        i = j + 1;
        j = s.IndexOf( c, i, l - i );
    }

    if ( i < l ) // Has remainder?
    {
        yield return s.Substring( i, l - i ); // Return remaining trail
    }
}

上述方法在处理小字符串时不一定比string.Split更快,但它会随着发现结果而返回结果,这是惰性评估的威力。如果您有很长的行或需要节省内存,那么这是一个好选择。

上述方法受IndexOf和Substring的性能限制,它们对索引越界进行了过多的检查。为了更快速地执行操作,您需要优化它们并实现自己的辅助方法。您可以击败string.Split的性能,但需要使用巧妙的整数技巧。您可以在这里阅读我的帖子。


显然,没有必要节省内存,但需要节省CPU。 - Dave Van den Eynde
1
@Dave Van den Eynde - 我认为两者都很重要!但是,大多数程序员都忽视了内存优化。 - John Leidegren
3
我曾经尝试了一种类似的方法,但速度比使用Split的现有算法慢,不过由于我们处理的字符串非常大(多兆字节),使用这种方法可以节省约30%的内存消耗。 - torial
1
你知道,那段代码并没有被优化,而string.Split更快的原因是它使用了不安全代码。如果你在这里加入它,运行时间是一样的。只是这个方法更加内存高效。 - John Leidegren
我知道这已经过时了,但我想指出这个解决方案似乎从返回的集合中删除了空项。调用 "1,,3".GetSplit(',') 返回一个仅包含 2 个项的集合。一个是 1,另一个是 3。这与 .net 的 split 方法的行为不同。 - Gary Brunton
是的,这是设计上的。如果您不想默认使用此行为,则需要调整代码。 - John Leidegren

18

需要指出的是,在解析CSV文件时,split()方法存在疑问,如果文件中出现逗号,可能会导致错误。

1,"Something, with a comma",2,3
不知道您如何进行性能剖析,但我想指出的是要小心剖析这种低级别的细节。Windows/PC计时器的粒度可能会影响到性能,在循环中可能会产生显著的开销,因此需要使用某种控制值。
话虽如此,“split()”被设计为处理正则表达式,而正则表达式显然比您所需的更复杂(并且不是处理转义逗号的正确工具)。此外,“split()”会创建大量的临时对象。
因此,如果您想加快速度(我很难相信这部分的性能真的有问题),那么您应该手动编写代码,并重用缓冲区对象,以便不断地创建对象并使垃圾回收器在清理它们时有工作可做。
该算法相对简单: - 在每个逗号处停止; - 当遇到引号时,继续直到遇到下一个引号集; - 处理转义引号(即 \")和逗号(\,)。
顺便提一下,为了让您了解正则表达式的成本,有人想要将每个第n个字符替换为字符串的问题(Java而不是C#,但原则相同)。我建议在String上使用replaceAll(),Jon Skeet手动编写了循环。出于好奇,我比较了这两个版本,他的版本快了一个数量级。
因此,如果您真的想要性能,那么现在是手动解析的时候了。
或者,更好的方法是使用其他人优化过的解决方案,比如这个快速CSV阅读器
顺便说一句,虽然这与Java有关,但它涉及正则表达式的性能(这是普遍适用的)以及replaceAll()与手动编写循环之间的性能:将字符放入java字符串中每N个字符

我在类似主题的答案中添加了一个关于字符串替换方法的链接,你可以在我的回答末尾找到该链接。 - John Leidegren
我只想说谢谢。你重申了我的想法,并强迫我再次查看我的代码,找出我效率低下的地方。结果发现我的条件语句顺序有误,如果没有看到你的帖子,我想我可能就放弃了。 - kemiller2002
在Excel生成的CSV文件中,转义引号使用的是"",而不是"。 - Rado
现在的情况和 Span<T> 怎么样? - Akmal Salikhov

7
这是一个使用ReadOnlySpan的非常基本的示例。在我的机器上,它大约需要150纳秒,而string.Split()则需要大约250纳秒。这是一个很好的40%的改进。
string serialized = "1577836800;1000;1";
ReadOnlySpan<char> span = serialized.AsSpan();

Trade result = new Trade();

index = span.IndexOf(';');
result.UnixTimestamp = long.Parse(span.Slice(0, index));
span = span.Slice(index + 1);

index = span.IndexOf(';');
result.Price = float.Parse(span.Slice(0, index));
span = span.Slice(index + 1);

index = span.IndexOf(';');
result.Quantity = float.Parse(span.Slice(0, index));

return result;

请注意,ReadOnlySpan.Split()将很快成为框架的一部分。请参见https://github.com/dotnet/runtime/pull/295

非常聪明!我想这正是这种方法的用途。 - Joe Phillips

4

根据用途,你可以使用Pattern.split代替String.split来加快速度。如果你在循环中使用此代码(我假设你可能正在从文件中解析行),String.split(String regex)将在每次循环执行该语句时调用Pattern.compile对你的正则表达式字符串进行编译。为了优化这个问题,应该在循环外部一次性地编译模式,然后在循环内部使用Pattern.split来分割你想要分割的行。

希望这有所帮助。


3
我发现了这个实现方法,它比Dejan Pelzel's blog中的方法快30%。我从那里引用如下: 解决方案

考虑到这一点,我开始创建一个字符串分割器,它将类似于StringBuilder的内部缓冲区。它使用非常简单的逻辑来浏览字符串,并在沿途保存值部分到缓冲区中。

public int Split(string value, char separator)
{
    int resultIndex = 0;
    int startIndex = 0;

    // Find the mid-parts
    for (int i = 0; i < value.Length; i++)
    {
        if (value[i] == separator)
        {
            this.buffer[resultIndex] = value.Substring(startIndex, i - startIndex);
            resultIndex++;
            startIndex = i + 1;
        }
    }

    // Find the last part
    this.buffer[resultIndex] = value.Substring(startIndex, value.Length - startIndex);
    resultIndex++;

    return resultIndex;

如何使用

如下面的示例所示,StringSplitter类非常简单易用。只需注意在循环中重复使用StringSplitter对象,而不是创建一个新实例或仅用于一次。在这种情况下,最好使用内置的String.Split。

var splitter = new StringSplitter(2);
splitter.Split("Hello World", ' ');
if (splitter.Results[0] == "Hello" && splitter.Results[1] == "World")
{
    Console.WriteLine("It works!");
}

Split方法返回找到的项目数,因此您可以轻松地通过以下方式迭代结果:
var splitter = new StringSplitter(2);
var len = splitter.Split("Hello World", ' ');
for (int i = 0; i < len; i++)
{
    Console.WriteLine(splitter.Results[i]);
}

这种方法有优点和缺点。


1
    public static unsafe List<string> SplitString(char separator, string input)
    {
        List<string> result = new List<string>();
        int i = 0;
        fixed(char* buffer = input)
        {
            for (int j = 0; j < input.Length; j++)
            {
                if (buffer[j] == separator)
                {
                    buffer[i] = (char)0;
                    result.Add(new String(buffer));
                    i = 0;
                }
                else
                {
                    buffer[i] = buffer[j];
                    i++;
                }
            }
            buffer[i] = (char)0;
            result.Add(new String(buffer));
        }
        return result;
    }

1

你可能认为有一些优化可以实现,但实际上你会在其他地方付出代价。

例如,你可以自己进行分割并遍历所有字符,并在遇到每个列时处理它,但从长远来看,你仍然会复制字符串的所有部分。

例如,在C或C++中,我们可以做的一个优化是用'\0'字符替换所有分隔符,并保留指向列开头的指针。然后,我们就不必复制整个字符串数据才能获取其中的一部分。但这在C#中是无法做到的,也不应该这样做。

如果源中的列数与所需列数之间存在很大差异,则手动遍历字符串可能会产生一些好处。但这种好处将花费您开发和维护的时间。

我听说90%的CPU时间花费在10%的代码上。这个“真理”有各种变化。在我看来,如果处理CSV是您的应用程序需要做的事情,那么在Split中花费66%的时间并不算太糟糕。

Dave


0

这是我的解决方案:

Public Shared Function FastSplit(inputString As String, separator As String) As String()
        Dim kwds(1) As String
        Dim k = 0
        Dim tmp As String = ""

        For l = 1 To inputString.Length - 1
            tmp = Mid(inputString, l, 1)
            If tmp = separator Then k += 1 : tmp = "" : ReDim Preserve kwds(k + 1)
            kwds(k) &= tmp
        Next

        Return kwds
End Function

这是一个带有基准测试的版本:

Public Shared Function FastSplit(inputString As String, separator As String) As String()
        Dim sw As New Stopwatch
        sw.Start()
        Dim kwds(1) As String
        Dim k = 0
        Dim tmp As String = ""

        For l = 1 To inputString.Length - 1
            tmp = Mid(inputString, l, 1)
            If tmp = separator Then k += 1 : tmp = "" : ReDim Preserve kwds(k + 1)
            kwds(k) &= tmp
        Next
        sw.Stop()
        Dim fsTime As Long = sw.ElapsedTicks

        sw.Start()
        Dim strings() As String = inputString.Split(separator)
        sw.Stop()

        Debug.Print("FastSplit took " + fsTime.ToString + " whereas split took " + sw.ElapsedTicks.ToString)

        Return kwds
End Function

以下是一些关于长度不同但相对较小的字符串的结果,块大小最多为8kb。(时间以滴答声计算)
FastSplit花费了8,而split则花费了10
FastSplit花费了214,而split则花费了216
FastSplit花费了10,而split则花费了12
FastSplit花费了8,而split则花费了9
FastSplit花费了8,而split则花费了10
FastSplit花费了10,而split则花费了12
FastSplit花费了7,而split则花费了9
FastSplit花费了6,而split则花费了8
FastSplit花费了5,而split则花费了7
FastSplit花费了10,而split则花费了13
FastSplit花费了9,而split则花费了232
FastSplit花费了7,而split则花费了8
FastSplit花费了8,而split则花费了9
FastSplit花费了8,而split则花费了10
FastSplit花费了215,而split则花费了217
FastSplit花费了10,而split则花费了231
FastSplit花费了8,而split则花费了10
FastSplit花费了8,而split则花费了10

FastSplit花费了7,而split花费了9

FastSplit花费了8,而split花费了10

FastSplit花费了10,而split花费了1405

FastSplit花费了9,而split花费了11

FastSplit花费了8,而split花费了10

此外,我知道有人会反对我使用ReDim Preserve而不是使用列表...原因是,在我的基准测试中,列表并没有提供任何速度差异,所以我回到了“简单”的方式。


0

正如其他人所说,String.Split()在处理CSV文件时并不总是有效。考虑一个看起来像这样的文件:

"First Name","Last Name","Address","Town","Postcode"
David,O'Leary,"12 Acacia Avenue",London,NW5 3DF
June,Robinson,"14, Abbey Court","Putney",SW6 4FG
Greg,Hampton,"",,
Stephen,James,"""Dunroamin"" 45 Bridge Street",Bristol,BS2 6TG

(例如,引号的不一致使用,包含逗号和引号的字符串等)

这个CSV读取框架将处理所有这些问题,并且非常高效:

Sebastien Lorien的LumenWorks.Framework.IO.Csv


0

通常我喜欢 .Net Perls,但我认为他们的比较不公平。如果你知道你将经常使用正则表达式,那么编译它并从循环中提取它。使用这种策略可以大大减少总时间。 - torial
文章已被删除,这是在dotnetperls.com上的存档版本:http://web.archive.org/web/20090316210342/http://dotnetperls.com/Content/String-Split-Benchmark.aspx - Stanislav Prusac
它又回到了dotnetperls:https://www.dotnetperls.com/split 我的发现是:10000000个Regex.split比10000000个string.Split慢10%(.net framework 4) - OzBob

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