考虑到在.NET中字符串是不可变的,我在想为什么设计中string.Substring()
的时间复杂度是O(substring.Length
),而不是O(1)?
也就是说,是否存在任何权衡取舍?
考虑到在.NET中字符串是不可变的,我在想为什么设计中string.Substring()
的时间复杂度是O(substring.Length
),而不是O(1)?
也就是说,是否存在任何权衡取舍?
更新:我非常喜欢这个问题,所以我写了一篇博客。请参见字符串、不变性和持久性
简短的回答是:如果n不会增长很大,那么O(n)就等同于O(1)。大多数人从小字符串中提取小子串,因此复杂度是如何渐进性增长的完全无关紧要。
长的回答是:
如果一个不可变数据结构被建立起来,使得对该实例的操作只需要进行少量(通常是O(1)或O(lg n))的复制或新分配便能重用原始内存,则称之为“持久”不可变数据结构。在.NET中,字符串是不可变的;你的问题本质上是“为什么它们不是持久的”?
因为当你查看.NET程序中通常对字符串执行的操作时,与其简单地创建一个全新的字符串相比,在每一个相关方面,它本质上几乎没有更差的表现。因此,构建一个复杂的持久化数据结构的费用和难度并没有得到回报。
人们通常使用“子字符串”来提取短字符串——比如十或二十个字符——从稍长的字符串中——也许是几百个字符。你有一个包含逗号分隔文件中的一行文本,你想要提取第三个字段,即姓氏。该行可能有几百个字符,名字可能有几十个。在现代硬件上,字符串分配和内存复制50个字节非常快。构建一个由指向现有字符串中间的指针加上长度组成的新数据结构同样非常快,但这是不相关的;“足够快”在定义上就是足够快的。
提取的子字符串通常很小且生命周期短暂;垃圾回收器将很快回收它们,并且它们一开始就没有占用堆中太多空间。因此,采用鼓励大部分内存重复使用的持久化策略也不是一个胜利;你所做的只是使你的垃圾回收器变慢,因为现在它必须担心处理内部指针。string contents = File.ReadAllText(filename); foreach (string line in content.Split("\n")) ...
或其他类似的版本。我的意思是读取整个文件,然后处理各个部分。如果一个字符串是持久的话,这种代码会明显更快,并且需要更少的内存;你始终只有一个文件的副本在内存中,而不是在处理每行时复制每行和每行的部分。然而,正如Eric所说 - 这不是典型的用例。 - configuratorString
被实现为一种持久化数据结构(尽管这并未在标准中规定,但我所知道的所有实现都是如此)。 - Joachim Sauer正是因为字符串是不可变的,所以.Substring
必须复制至少原始字符串的一部分。复制n个字节应该需要O(n)的时间。
你认为如何在常数时间内复制一堆字节?
编辑:Mehrdad建议根本不要复制字符串,而是保留对其一部分的引用。
考虑在.Net中,一个多兆字节的字符串,有人调用.SubString(n, n+3)
(对于任何在字符串中间的n)。
现在,整个字符串不能被垃圾回收,仅因为有一个引用持有4个字符? 这似乎是一种荒谬的浪费空间。
此外,跟踪对子字符串(甚至可能在子字符串内部)的引用,并尝试在最佳时间进行复制以避免破坏GC(如上所述),使概念成为一场噩梦。更简单、更可靠的方法是在.SubString
上进行复制,并维护直观的不可变模型。
编辑:这里有一篇不错的文章,关于在较大的字符串中保留子字符串的引用可能会带来的危险。
memcpy
,它仍然是O(n)的。 - leppiechar*
子字符串。 - leppieNULL
作为结尾的。如Lippert的文章所解释的那样,前4个字节包含了字符串的长度。这就是为什么,正如Skeet指出的那样,它们可以包含\0
字符的原因。 - ElidebJava(与.NET相对)提供了两种做Substring()
的方法,您可以考虑是只想保留一个引用还是需要将整个子字符串复制到新的内存位置。
简单的.substring(...)
与原始的String对象共享内部使用的char数组,如果需要,可以使用new String(...)
将其复制到新数组中(以避免妨碍原始数组的垃圾回收)。
我认为这种灵活性是开发人员的最佳选择。
.substring(...)
复制String内容。 - Grzegorz RożnieckiJava过去使用引用较大的字符串,但:
不过我认为这可以改进:为什么不根据情况有选择地进行复制呢?
如果子串至少是父串长度的一半,就可以引用父串。否则只需进行复制即可。这样可以避免泄漏大量内存,同时仍然提供重要的好处。
char[]
(具有不同的指向开头和结尾的指针)转变为创建一个新的String
。这清楚地表明,成本效益分析必须显示出对创建新的String
的偏好。 - Phylogenesis这里没有解决“括号问题”,即在.NET中,字符串表示为BStr(指针“前面”存储的长度)和CStr(字符串以'\0'结尾)的组合。
因此,“Hello there”这个字符串的表示形式是
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。)
该结构允许快速查找字符串的长度(在许多上下文中很有用),并允许将指针传递到需要空终止字符串的Win32(或其他)API的P/Invoke中。
当您执行Substring(0,5)
时,“哦,但我承诺最后一个字符之后会有一个空字符”规则表明您需要复制。即使您在末尾获取了子字符串,也没有地方可以放置长度,而不会破坏其他变量。
然而,有时确实需要谈论“字符串的中间”,并且您并不一定关心P/Invoke行为。最近添加的ReadOnlySpan<T>
结构可用于获取无需复制的子字符串:
string s = "Hello there";
ReadOnlySpan<char> hello = s.AsSpan(0, 5);
ReadOnlySpan<char> ell = hello.Slice(1, 3);
ReadOnlySpan<char>
"子字符串"独立存储长度,并且不保证值末尾有'\0'。它可以像字符串一样用于许多方面,但它不是“字符串”,因为它既没有BStr特征也没有CStr特征(更不要说它们两个都有了)。如果你从未(直接)使用过P/Invoke,那么没有太大的区别(除非你想调用的API没有ReadOnlySpan<char>
重载)。
ReadOnlySpan<char>
不能用作引用类型的字段,所以还有ReadOnlyMemory<char>
(s.AsMemory(0,5)
),这是一种间接获得ReadOnlySpan<char>
的方法,因此与string
存在相同的差异。ReadOnlySpan<char>
方法实现的行为。如果你只是进行短时间计算,使用ReadOnlySpan方法可能更好。如果你需要将其持久化一段时间,并且只保留原始字符串的一小部分,则进行适当的子字符串(以修剪多余的数据)可能更好。在中间有一个转换点,但它取决于你的具体用途。48 00 65 00 6C 00 6F 00 20 00 74 00 68 00 65 00 72 00 65 00
这一部分只有一个 6C 00
,所以实际上应该是 "Helo there"
而不是 "Hello there"
。 - Pang