如果字符串在.NET中是不可变的,那么为什么Substring需要O(n)时间?

时间:2011-07-19 06:05:39

标签: c# .net string substring time-complexity

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

即。什么是权衡,如果有的话?

5 个答案:

答案 0 :(得分:416)

更新:我非常喜欢这个问题,我只是在写博客。见Strings, immutability and persistence


简短的回答是:如果n不变大,则O(n)为O(1)。大多数人从微小的字符串中提取微小的子串,因此复杂性渐近增长的方式是完全不相干

答案很长:

构建一个不可变数据结构,使得对实例的操作允许重复使用原始内存,只需少量(通常为O(1)或O(lg n))复制或新分配称为“持久”不可变数据结构。 .NET中的字符串是不可变的;你的问题基本上是“为什么他们不坚持”?

因为当您查看通常在.NET程序中对字符串执行的操作时,它会以各种相关方式几乎不会更糟来简单地创建一个全新的串。 构建复杂的持久性数据结构的费用和难度并不能为此付出代价。

人们通常使用“substring”来提取一个短字符串 - 比方说,十个或二十个字符 - 用一个稍长的字符串 - 可能是几百个字符。您在逗号分隔文件中有一行文本,并且您想要提取第三个字段,这是一个姓氏。该行可能是几百个字符长,名称将是几十个。在现代硬件上,五十个字节的字符串分配和内存复制非常快。这使得一个新的数据结构由一个指向现有字符串中间的指针加上一个长度非常快而无关紧要; “足够快”在定义上足够快。

提取的子串通常尺寸小,寿命短;垃圾收集器很快就要收回它们,并且它们首先没有在堆上占用太多空间。因此,使用鼓励重用大部分内存的持久策略也不是一个胜利;你所做的就是让你的垃圾收集器变慢,因为现在它不得不担心处理内部指针。

如果人们通常对字符串做的子字符串操作完全不同,那么采用持久方法是有意义的。如果人们通常拥有百万字符的字符串,并且正在提取数千个大小在十万字符范围内的重叠子字符串,并且这些子字符串在堆上存在很长时间,那么使用持久子字符串将是完全合理的办法;不要浪费和愚蠢。但是,大多数业务线程序员都没有做任何事情甚至模糊不清的事情。 .NET不是专为满足人类基因组计划需求而定制的平台; DNA分析程序员必须每天解决这些字符串使用特征的问题;你不这样做的几率很高。少数人构建自己的持久数据结构,与他们的使用场景紧密匹配。

例如,我的团队编写的程序可以在您键入时对C#和VB代码进行实时分析。其中一些代码文件 huge ,因此我们不能进行O(n)字符串操作来提取子字符串或插入或删除字符。我们构建了一堆持久不可变数据结构,用于表示对文本缓冲区的编辑,使我们能够快速有效地重用大量现有字符串数据现有的词法和句法分析。典型的编辑。这是一个难以解决的问题,其解决方案针对C#和VB代码编辑的特定领域进行了狭窄的定制。期望内置字符串类型为我们解决这个问题是不现实的。

答案 1 :(得分:119)

准确地因为字符串是不可变的,.Substring必须至少复制原始字符串的一部分。复制 n 字节应该花费O(n)时间。

您认为如何在常量时间复制一堆字节?


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

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

现在,ENTIRE字符串不能仅仅因为一个引用持有4个字符而被收集垃圾? 这似乎是一种荒谬的浪费空间。

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


编辑:这是关于在较大字符串中保留对子字符串的引用的危险的good little read

答案 2 :(得分:33)

Java(与.NET相对)提供了两种执行Substring()的方法,您可以考虑是仅保留引用还是将整个子字符串复制到新的内存位置。

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

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

答案 3 :(得分:11)

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

Java changed its behavior to copying也是为了避免内存泄漏。

我觉得它可以改进:为什么不只是有条件地进行复制?

如果子字符串的大小至少是父字符串的一半,则可以引用父字符串。否则就可以复制一份。这样可以避免泄漏大量内存,同时仍能提供显着的优势。

答案 4 :(得分:2)

这里没有答案解决“包围问题”,也就是说,.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

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

此结构允许快速查找字符串的长度(在许多情况下很有用),并允许将指针以P / Invoke的形式传递给期望以空值终止的字符串的Win32(或其他)API。 / p>

当您执行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存在相同的差异。

关于先前答案的一些答案/评论谈到,当您继续谈论5个字符时,让垃圾收集器必须保留一百万个字符的字符串很浪费。这就是使用ReadOnlySpan<char>方法可以获得的行为。如果您只是进行简短的计算,则ReadOnlySpan方法可能更好。如果您需要将其保留一段时间,并且只保留原始字符串的一小部分,那么执行适当的子字符串(以修剪掉多余的数据)可能会更好。中间有一个过渡点,但这取决于您的特定用法。