在C#字符串中获取第一个非空白字符的索引

25

有没有一种方法可以在不编写自己的循环代码的情况下,在C#中获取字符串中第一个非空白字符的索引(或更一般地匹配条件的第一个字符的索引)?

编辑

通过“编写自己的循环代码”,我实际上是指我正在寻找一种简洁的表达式,它可以解决问题而不会使我正在处理的逻辑混乱。

对此造成的任何困惑,我深表歉意。

12个回答

44

一个string当然是一个IEnumerable<char>,所以你可以使用Linq:

int offset = someString.TakeWhile(c => char.IsWhiteSpace(c)).Count();

3
我认为如果在失败时(即字符串为空或仅由空格字符组成),返回-1更符合框架语义。 - Douglas
2
Resharper 提示:someString.TakeWhile(char.IsWhiteSpace).Count(); - Loren Pechtel
如果它被用作偏移量,那么返回0与-1相比更好。这将取决于应用程序,但如果只是匹配空白字符,这样可以跳过对这些异常情况的检查。 - Nathan Champion

18

我想定义自己的扩展方法,以便在序列中返回满足自定义谓词的第一个元素的索引。

/// <summary>
/// Returns the index of the first element in the sequence 
/// that satisfies a condition.
/// </summary>
/// <typeparam name="TSource">
/// The type of the elements of <paramref name="source"/>.
/// </typeparam>
/// <param name="source">
/// An <see cref="IEnumerable{T}"/> that contains
/// the elements to apply the predicate to.
/// </param>
/// <param name="predicate">
/// A function to test each element for a condition.
/// </param>
/// <returns>
/// The zero-based index position of the first element of <paramref name="source"/>
/// for which <paramref name="predicate"/> returns <see langword="true"/>;
/// or -1 if <paramref name="source"/> is empty
/// or no element satisfies the condition.
/// </returns>
public static int IndexOf<TSource>(this IEnumerable<TSource> source, 
    Func<TSource, bool> predicate)
{
    int i = 0;

    foreach (TSource element in source)
    {
        if (predicate(element))
            return i;

        i++;
    }

    return -1;
}

你可以使用LINQ来解决你的原始问题:
string str = "   Hello World";
int i = str.IndexOf<char>(c => !char.IsWhiteSpace(c));

1
我并不是想争论这个问题,但我认为问题的重点是如何在不编写循环的情况下完成它,所以这个答案让我感到困惑(再次强调,我并不是在抱怨 - 只是好奇)。 - Aaron Anodide
@AaronAnodide:是的,但我没有看到比编写扩展方法更好的解决方案。如果我的话没有表达清楚,我深感抱歉。 - Eric J.
2
@AaronAnodide:有道理,但我更自由地解释为OP不想每次需要获取满足条件的第一个字符的索引时都编写循环。几乎所有的LINQ都是使用内部循环编写的;你可以将上述内容视为原始版本中遗漏的另一种LINQ方法。 - Douglas
@HenkHolterman:为什么?它提供了一个更灵活的通用等效版本。str.IndexOf(...)将使用在框架中定义的非泛型版本(我已编辑答案以调用泛型版本 :-) - Eric J.
1
是的,我想这是可以接受的,而且幸运的是<char>并不是必需的。但是像 IndexOfWhere() 这样的名称可能更清晰明了。 - H H
显示剩余4条评论

6
string s= "   \t  Test";
Array.FindIndex(s.ToCharArray(), x => !char.IsWhiteSpace(x));

返回6

要添加一个条件,只需执行...

Array.FindIndex(s.ToCharArray(), x => !char.IsWhiteSpace(x) && your condition);

5
受这个字符串修剪解决方案的启发,但通过使用ReadOnlySpan更加高效:
string s = "   xyz";
int index = s.Length - s.AsSpan().TrimStart().Length;
// index is 3

.AsSpan().TrimStart()都不会创建字符串的副本,它们只是存储一个对字符串字符和长度的引用。

  • .AsSpan()String的扩展方法,它创建了一个指向字符串第一个字符的范围。它的长度是整个字符串的长度。
  • .TrimStart()ReadOnlySpan<char>的扩展方法,它创建了一个指向第一个非空格字符的范围。它的长度是整个字符串的长度减去第一个非空格字符的位置。

这种模式可以通用地用于跳过任何给定字符的列表:

string s = "foobar";
int index = s.Length - s.AsSpan().TrimStart("fo").Length;
// index is 3

我对这种方法以及此Q&A中的其他几种方法进行了基准测试,使用BenchmarkDotNet我的基准测试代码):

方法

意义

误差

标准偏差

Regex_Compiled 45.05微秒 0.043微秒 0.034微秒
ReadOnlySpan_Trim (本答案) 50.24微秒 0.073微秒 0.061微秒
String_Trim 94.64微秒 0.458微秒 0.428微秒
Regex_Interpreted 114.41微秒 0.224微秒 0.210微秒
Regex_StaticMethod (请参阅下文!) 114.19微秒 0.056微秒 0.046微秒
FirstNonMatch 150.58微秒 0.214微秒 0.190微秒
Array_FindIndex 200.40微秒 1.951微秒 1.730微秒
StringExt_IndexOfPredicate 336.31微秒 0.896微秒 0.838微秒
Linq_TakeWhile 490.97微秒 0.994微秒 0.930微秒
我没想到 RegEx_Compiled 会是最快的。实际上,RegEx_StaticMethod 应该和 RegEx_Compiled 一样快(因为静态 Regex 方法缓存编译模式),但是由于 BenchmarkDotNet 每次测试运行都创建一个新进程,所以该缓存没有任何效果。 String_Trim 基准测试取决于第一个非空白字符之后有多少个字符,因为它会复制子字符串。对于短文本,性能可能接近于 ReadOnlySpan_Trim,但对于长文本,性能将会更差。该基准测试的输入文本包含 50k 个非空白字符,因此已经存在显着差异。

你有对差异进行基准测试吗? - Eric J.
@EricJ。还没有。这可能比使用谓词的方法甚至更快,所以结果会非常有趣。 - zett42
快速设置。我对结果很感兴趣。https://benchmarkdotnet.org/articles/overview.html - Eric J.
@EricJ。我已经这样做了。 - zett42
有趣的结果。我也很惊讶正则表达式稍微更快,至少对于测试中的输入数据而言。感谢您的整理! - Eric J.
只有编译后的正则表达式才更快,这并不奇怪。普通的正则表达式要慢得多。 - Reg Edit

4
您可以使用String.IndexOfAny函数,该函数返回指定 Unicode 字符数组中任何字符的第一个匹配项。
或者,您可以使用String.TrimStart函数从字符串开头删除所有空格字符。第一个非空格字符的索引是原始字符串长度与修剪后的字符串长度之差。
您甚至可以选择要修剪的字符集 :)
基本上,如果您正在寻找一组有限的字符(比如数字),您应该使用第一种方法。
如果您想忽略一组有限的字符(比如空格),则应该使用第二种方法。
最后一种方法是使用Linq方法:
string s = "        qsdmlkqmlsdkm";
Console.WriteLine(s.TrimStart());
Console.WriteLine(s.Length - s.TrimStart().Length);
Console.WriteLine(s.FirstOrDefault(c => !Char.IsWhiteSpace(c)));
Console.WriteLine(s.IndexOf(s.FirstOrDefault(c => !Char.IsWhiteSpace(c))));

输出:

qsdmlkqmlsdkm
8
q
8

trim方法可以不带任何参数调用,在这种情况下,它将删除所有空格。至于linq查询,您可以使用IsWhiteSpace函数。我会更新代码示例。 - Samy Arous

4
var match = Regex.Match(" \t test  ", @"\S"); // \S means all characters that are not whitespace
if (match.Success)
{
    int index = match.Index;
    //do something with index
}
else
{
    //there were no non-whitespace characters, handle appropriately
}

如果您经常这样做,出于性能原因,应缓存编译的Regex,例如:

static readonly Regex nonWhitespace = new Regex(@"\S");

那么就像这样使用它:
nonWhitespace.Match(" \t test  ");

1
虽然使用正则表达式是一个好的解决方案并且完全有效,但当有更简单的内置解决方案时,我总是避免使用它们。正则表达式在性能方面自然很重。 - Samy Arous
@EricJ. 你可以缩短成两行,使用var index = match.Success ? match.Index : -1;(如果不匹配,则使用(int?)null或者default(int?),如果你喜欢null而不是-1)。@lcfseth 很好的观点。提问者要求不要用循环,“更一般地说,是第一个满足条件的字符的索引”,这看起来更像是正则表达式。对于像“空格”这样简单的情况,我同意你的看法,但在其他情况下,这可能很有用。 - Tim S.
请注意,new Regex(@"\S") 不会创建编译的正则表达式,但是 new Regex(@"\S", RegexOptions.Compiled) 会。 - zett42

3

由于这里有几个解决方案,所以我决定进行一些性能测试,以查看每个解决方案的表现。决定将这些结果分享给那些感兴趣的人...

    int iterations = 1000000;
    int result = 0;
    string s= "   \t  Test";

    System.Diagnostics.Stopwatch watch = new Stopwatch();

    // Convert to char array and use FindIndex
    watch.Start();
    for (int i = 0; i < iterations; i++)
        result = Array.FindIndex(s.ToCharArray(), x => !char.IsWhiteSpace(x)); 
    watch.Stop();
    Console.WriteLine("Convert to char array and use FindIndex: " + watch.ElapsedMilliseconds);

    // Trim spaces and get index of first character
    watch.Restart();
    for (int i = 0; i < iterations; i++)
        result = s.IndexOf(s.TrimStart().Substring(0,1));
    watch.Stop();
    Console.WriteLine("Trim spaces and get index of first character: " + watch.ElapsedMilliseconds);

    // Use extension method
    watch.Restart();
    for (int i = 0; i < iterations; i++)
        result = s.IndexOf<char>(c => !char.IsWhiteSpace(c));
    watch.Stop();
    Console.WriteLine("Use extension method: " + watch.ElapsedMilliseconds);

    // Loop
    watch.Restart();
    for (int i = 0; i < iterations; i++)
    {   
        result = 0;
        foreach (char c in s)
        {
            if (!char.IsWhiteSpace(c))
                break;
            result++;
        }
    }
    watch.Stop();
    Console.WriteLine("Loop: " + watch.ElapsedMilliseconds);

结果以毫秒为单位....

当 s = " \t Test" 时
将其转换为字符数组并使用 FindIndex: 154
修剪空格并获取第一个字符的索引: 189
使用扩展方法: 234
循环: 146

当 s = "Test" 时
将其转换为字符数组并使用 FindIndex: 39
修剪空格并获取第一个字符的索引: 155
使用扩展方法: 57
循环: 15

当 s =(没有空格的1000个字符的字符串)
将其转换为字符数组并使用 FindIndex: 506
修剪空格并获取第一个字符的索引: 534
使用扩展方法: 51
循环: 15

当 s =(以“ \t Test”开头的1000个字符的字符串)
将其转换为字符数组并使用 FindIndex: 609
修剪空格并获取第一个字符的索引: 1103
使用扩展方法: 226
循环: 146

自行得出结论,但我的结论是使用您最喜欢的方法,因为在实际场景中性能差异微不足道。


2

这里有很多将字符串转换为数组的解决方案。然而,这并不是必要的。一个字符串中的单个字符可以像数组中的元素一样访问。

以下是我认为非常高效的解决方案:

private static int FirstNonMatch(string s, Func<char, bool> predicate, int startPosition = 0)
{
    for (var i = startPosition; i < s.Length; i++)
        if (!predicate(s[i])) return i;

    return -1;
}

private static int LastNonMatch(string s, Func<char, bool> predicate, int startPosition)
{
    for (var i = startPosition; i >= 0; i--)
        if (!predicate(s[i])) return i;

    return -1;
}

要使用这些功能,请按照以下步骤进行:

var x = FirstNonMatch(" asdf ", char.IsWhiteSpace);
var y = LastNonMatch(" asdf ", char.IsWhiteSpace, " asdf ".Length);

1
有一个非常简单的解决方案。
string test = "    hello world";
int pos = test.ToList<char>().FindIndex(x => char.IsWhiteSpace(x) == false);

pos将为4

您可以有更复杂的条件,例如:

pos = test.ToList<char>().FindIndex((x) =>
                {
                    if (x == 's') //Your complex conditions go here
                        return true;
                    else 
                        return false;
                }
            );

你可以简写为 return (x == 's'); - Eric J.

1
您可以使用Trim函数、获取第一个字符并使用IndexOf函数。

Trim 创建字符串的副本。虽然不是最理想的,但是功能上可行。 - Eric J.
最好使用 TrimStart,并查看结果字符串缩短了多少。然而,仍会创建一个副本。 - erikH
1
@Eric J.,这就是为什么我说这不是最好的解决方案。但它很简单,可以包装在扩展方法中。这里有很多其他好的解决方案(我可能会选择可读性最好的那个)。 - Damian Schenkelman
喜欢自我评论:)。一旦您使用StartTrim函数从开头修剪了字符串,您只需要取两个长度之间的差异即可获得索引。 - Samy Arous
1
即使使用linq,也会创建一个副本。 - Samy Arous

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