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


451

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

即,权衡是什么(如果有)?


3
@Mehrdad:我喜欢这个问题。您能否告诉我如何确定.Net中给定函数的O()?很清楚还是应该计算?谢谢
odiseh 2011年

1
@odiseh:有时(在这种情况下)很明显,正在复制字符串。如果不是,则可以查看文档,执行基准测试,也可以尝试查看.NET Framework源代码以了解它是什么。
user541686

Answers:


423

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


简短的答案是:如果n不大,O(n)为O(1)。 大多数人从微小的字符串中提取微小的子字符串,因此复杂度渐近地增长完全无关紧要

长答案是:

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

因为当您查看通常在.NET程序中对字符串执行的操作时,简单地创建一个全新的字符串在所有相关方面都几乎不会恶化建立复杂的持久性数据结构的费用和困难并不能自负。

人们通常使用“子字符串”从稍长的字符串(可能是数百个字符)中提取一个短字符串(例如,十个或二十个字符)。您在逗号分隔的文件中有一行文本,并且要提取第三个字段,即姓氏。该行可能长几百个字符,名字将是几十个。在现代硬件上,字符串分配和50个字节的内存复制速度非常快。这使得它由一个指针到一个现有的字符串的中间加一个长度为一个新的数据结构惊人地快无关; 按照定义,“足够快”足够快。

提取的子字符串通常尺寸小且寿命短;垃圾收集器将很快回收它们,并且它们最初并没有在堆上占用太多空间。因此,使用鼓励重复使用大多数内存的持久策略也不是一件容易的事。您要做的只是使您的垃圾收集器变慢,因为现在它不得不担心处理内部指针。

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

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


47
对比一下Java的工作方式(或至少在过去的某个时候)是很有意思的:子字符串返回一个新字符串,但指向与较大字符串相同的char []-这意味着较大的char []子字符串超出范围之前,不能再进行垃圾回收。到目前为止,我更喜欢.net的实现。
Michael Stum

13
我已经看过很多此类代码:string contents = File.ReadAllText(filename); foreach (string line in content.Split("\n")) ...或其他版本。我的意思是读取整个文件,然后处理各个部分。如果字符串是持久的,则这种代码会更快,并且需要更少的内存。您将始终在内存中只保留文件的一个副本,而不是复制每一行,然后复制每一行的部分进行处理。但是,就像Eric所说的那样-这不是典型的用例。
配置器

18
@configurator:此外,在.NET 4中,File.ReadLines方法将文本文件分成几行,而无需先将其全部读取到内存中。
埃里克·利珀特

8
@Michael:Java String被实现为一个持久的数据结构(标准中没有指定,但是我知道的所有实现都可以做到这一点)。
约阿希姆·绍尔

33
简短的答案:复制数据以允许对原始字符串进行垃圾回收
Qtax 2011年

121

正是由于字符串是不可变的,因此.Substring必须至少复制原始字符串的一部分。复制n个字节应花费O(n)时间。

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


编辑:Mehrdad建议根本不复制该字符串,但保留对其一部分的引用。

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

现在,仅因为一个引用保留了4个字符,便无法收集整个字符串吗?这似乎是对空间的荒谬浪费。

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


编辑: 这是有关在较大的字符串中保留对子字符串的引用的危险的少量阅读资料


5
+1:完全是我的想法。在内部,它可能使用memcpy仍为O(n)的值。
leppie 2011年

7
@abelenky:我想也许根本不复制它?它已经存在了,为什么要复制它呢?
user541686 2011年

2
@Mehrdad:如果您追求表演。在这种情况下,请注意安全。然后,您可以获得一个char*子字符串。
leppie 2011年

9
@Mehrdad-您在那里可能期望太多,它被称为StringBuilder,并且很好地构建字符串。它不称为StringMultiPurposeManipulator
MattDavey

3
@ SamuelNeff,@ Mehrdad:.NET中的字符串不会 NULL终止。如Lippert的帖子所述,前4个字节包含字符串的长度。正如Skeet所指出的,这就是为什么它们可以包含\0字符的原因。
Elideb 2011年

33

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

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

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


50
您称其为“灵活性”,我称之为“一种意外地将难以诊断的错误(或性能问题)插入软件的方法,因为我没有意识到我必须停下来思考一下此代码可能存在的所有地方调用(包括将仅在下一个版本中发明的字符串)只是为了从字符串中间获取4个字符”
Nir11,

3
downvote已收回...在更仔细地浏览了代码之后,它的确看起来像java中的子字符串引用了共享数组,至少在openjdk版本中。而且,如果您要确保使用新字符串,则可以采用这种方法。
唐·罗比

11
@Nir:我称之为“现状偏见”。对您来说,Java的执行方式似乎充满了风险,而.Net方式是唯一明智的选择。对于Java程序员而言,情况恰恰相反。
Michael Borgwardt

7
我非常喜欢.NET,但这听起来像Java做对了。允许开发人员访问真正的O(1)子字符串方法(而无需滚动自己的字符串类型,这将妨碍与其他所有库的互操作性,并且效率不如内置解决方案),这很有用。 )。Java的解决方案可能效率不高(至少需要两个堆对象,一个用于原始字符串,另一个用于子字符串)。支持切片的语言有效地用堆栈上的一对指针替换了第二个对象。
Qwertie 2012年

10
从JDK 7u6开始,它不再是真的了 -现在Java总是复制每个字符串的内容.substring(...)
Xaerxess

12

Java曾经引用较大的字符串,但是:

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

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

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


始终复制允许您删除内部阵列。将堆分配数量减半,在短字符串的常见情况下节省内存。这也意味着您无需为每个字符访问都跳过其他间接访问。
CodesInChaos

2
我认为重要的是Java实际上已经从使用相同的基础char[](使用不同的起点和终点指针)变为创建一个new String。这清楚地表明,成本效益分析必须显示出对创建新产品的偏好String
系统发育

2

此处的答案均未解决“包围问题”,也就是说,.NET中的字符串表示为BStr(指针“之前”存储在内存中的长度)和CStr(字符串以A结尾)的组合。 '\ 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。

当您执行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)),这是具有a的间接方式ReadOnlySpan<char>,因此string存在相同的差异。

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

By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.