关于c#:如果字符串在.NET中是不可变的,那么为什么Substring占用O(n)时间呢?

If strings are immutable in .NET, then why does Substring take O(n) time?

考虑到字符串在.NET中是不可变的,我想知道为什么它们的设计使string.Substring()需要O(substring.Length时间,而不是O(1)时间?

也就是说,如果有的话,权衡是什么?


更新:我非常喜欢这个问题,我只是在博客上写了它。参见字符串、不变性和持久性

简短的回答是:如果n不变大,o(n)是o(1)。大多数人从微小的字符串中提取微小的子串,所以复杂性如何渐进地增长是完全不相关的。

答案很长:

一种不可变的数据结构,其构造使得在一个实例上的操作只允许少量(通常为O(1)或O(lg n))复制或新分配的原始内存的重用,称为"持久"不可变的数据结构。.NET中的字符串是不可变的;您的问题本质上是"为什么它们不持久"?

因为当您查看通常在.NET程序中对字符串执行的操作时,仅仅生成一个全新的字符串在所有相关方面都不会更糟。构建一个复杂的持久性数据结构的开销和困难本身并没有代价。

人们通常使用"子字符串"从稍长的字符串中提取一个短字符串——比如说,10或20个字符——可能有几百个字符。在逗号分隔的文件中有一行文本,您希望提取第三个字段,这是姓氏。这行可能有几百个字符长,名字是几十个。在现代硬件上,50字节的字符串分配和内存复制速度惊人。创建一个新的数据结构(由指向现有字符串中间的指针加上一个长度)的速度也惊人地快,这与此无关;"足够快"的定义足够快。

提取的子串通常体积小,寿命短;垃圾收集器很快就会回收它们,而且它们一开始在堆上没有占用太多空间。因此,使用鼓励重用大多数内存的持久性策略也不是一个成功;您所做的只是让垃圾收集器变慢,因为现在它必须担心处理内部指针。

如果人们通常对字符串所做的子字符串操作完全不同,那么使用持久方法是有意义的。如果人们通常有一百万个字符串,并且正在提取成千上万个大小在十万个字符范围内的重叠子串,而这些子串在堆中生存了很长时间,那么使用持久的子串方法是完全有意义的;不这样做是浪费和愚蠢的。但大多数业务线程序员甚至都不做类似的事情。.NET不是一个为人类基因组计划量身定制的平台;DNA分析程序员每天都要解决这些字符串使用特性的问题;你不这样做的可能性很大。很少有人构建自己的持久数据结构,这些结构与他们的使用场景非常匹配。

例如,我的团队编写程序,在您键入代码时对C和VB代码进行动态分析。其中一些代码文件非常庞大,因此我们不能执行O(N)字符串操作来提取子字符串或插入或删除字符。我们已经构建了一系列持久不变的数据结构,用于表示对文本缓冲区的编辑,从而允许我们在典型编辑时快速高效地重用大量现有字符串数据以及现有的词汇和语法分析。这是一个很难解决的问题,其解决方案仅针对C和VB代码编辑的特定领域进行了调整。期望内置字符串类型为我们解决这个问题是不现实的。


正因为字符串是不可变的,所以.Substring必须至少复制原始字符串的一部分。复制n个字节需要O(n)个时间。

您认为如何在恒定时间内复制一组字节?

编辑:Mehrdad建议不要复制字符串,而是保留对其中一个字符串的引用。

在.NET中,一个多兆字节的字符串,在该字符串上有人调用.SubString(n, n+3)(对于字符串中间的任何n)。

现在,不能仅仅因为一个引用包含4个字符而对整个字符串进行垃圾收集?这似乎是一种荒谬的空间浪费。

此外,跟踪对子字符串的引用(甚至可能在子字符串内部),并尝试在最佳时间复制以避免破坏GC(如上所述),这使得该概念成为一场噩梦。在.Substring上复制并维护直接不变的模型要简单得多,而且更可靠。

编辑:这里有一个很好的阅读关于在较大的字符串中保留对子字符串的引用的危险。


Java(与.NET相反)提供了两种EDCOX1(3)的方式,可以考虑是否只保留一个引用或将整个子串复制到新的内存位置。

简单的.substring(...)与原始字符串对象共享内部使用的char数组,然后,如果需要,可以使用new String(...)复制到新的数组(以避免妨碍对原始数组的垃圾收集)。

我认为这种灵活性是开发人员的最佳选择。


Java用于引用更大的字符串,但是:

Java也将其行为更改为复制,以避免内存泄漏。

不过,我觉得可以改进一下:为什么不有条件地进行复制呢?

如果子字符串的大小至少是父字符串的一半,则可以引用父字符串。否则你只能复制一份。这样可以避免大量内存泄漏,同时还能提供显著的好处。


这里的答案都没有解决"括号问题",也就是说.NET中的字符串表示为BSTR(指针之前存储在内存中的长度)和CSTR(字符串以''结尾)的组合。

因此,字符串"hello there"表示为

1
0B 00 00 00 48 00 65 00 6C 00 6F 00 20 00 74 00 68 00 65 00 72 00 65 00 00 00

(如果在fixed语句中分配给char*,指针将指向0x48。)

此结构允许快速查找字符串的长度(在许多上下文中很有用),并允许在P/Invoke中将指针传递给期望以空结尾的字符串的Win32(或其他)API。

当你使用Substring(0, 5)时,"哦,但是我保证在最后一个字符后会有一个空字符"规则说你需要复制一份。即使在末尾有子字符串,也没有地方可以在不损坏其他变量的情况下放置长度。

但是,有时您确实想谈论"字符串的中间部分",并且不必关心p/invoke行为。最近添加的ReadOnlySpan结构可用于获取无副本子字符串:

1
2
3
string s ="Hello there";
ReadOnlySpan<char> hello = s.AsSpan(0, 5);
ReadOnlySpan<char> ell = hello.Slice(1, 3);

ReadOnlySpan的"substring"独立存储长度,并且不保证值结束后有一个''。它可以在许多方面"像字符串一样"使用,但它不是"字符串",因为它既没有BSTR特性,也没有CSTR特性(更不用说两者都有)。如果您从不(直接)P/Invoke,那么没有太大的区别(除非您要调用的API没有ReadOnlySpan重载)。

ReadOnlySpan不能用作引用类型的字段,因此也有ReadOnlyMemory(s.AsMemory(0, 5)),这是一种间接拥有ReadOnlySpan的方式,因此存在与string相同的差异。

以前答案的一些答案/评论说垃圾回收器必须保留一百万个字符串,而您继续谈论5个字符是浪费的。这正是使用ReadOnlySpan方法可以得到的行为。如果你只是做一些简短的计算,那么readonlyspan方法可能更好。如果您需要将其保持一段时间,并且只保留原始字符串的一小部分,那么执行适当的子字符串(以消除多余的数据)可能更好。中间有一个过渡点,但这取决于您的具体用法。