ChatGPT解决这个技术问题 Extra ChatGPT

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

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

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

@Mehrdad:我喜欢这个问题。你能告诉我我们如何确定.Net中给定函数的O()吗?清楚还是我们应该计算一下?谢谢
@odiseh:有时(就像在这种情况下)很明显正在复制字符串。如果不是,那么您可以查看文档、执行基准测试或尝试查看 .NET Framework 源代码以找出它是什么。

C
Callum Watkins

更新:我非常喜欢这个问题,我只是写了博客。请参阅Strings, immutability and persistence

简短的回答是:如果 n 没有变大,则 O(n) 是 O(1)。大多数人从微小的字符串中提取微小的子字符串,因此复杂性如何渐近增长完全无关紧要。

长答案是:

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

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

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

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

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

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


对比一下 Java 的做法(或至少在过去的某个时候)会很有趣:Substring 返回一个新字符串,但指向与较大字符串相同的 char[] - 这意味着较大的 char[]在子字符串超出范围之前,不能再进行垃圾收集。到目前为止,我更喜欢 .net 的实现。
我见过很多这样的代码:string contents = File.ReadAllText(filename); foreach (string line in content.Split("\n")) ... 或它的其他版本。我的意思是读取整个文件,然后处理各个部分。如果字符串是持久的,那么这种代码会更快并且需要更少的内存;您将始终在内存中拥有该文件的一个副本,而不是复制每一行,然后将每一行的部分作为您的处理它。但是,就像 Eric 所说的那样——这不是典型的用例。
@configurator:此外,在 .NET 4 中,File.ReadLines 方法为您将文本文件分成几行,而无需先将其全部读入内存。
@Michael:Java 的 String 实现为持久数据结构(标准中未指定,但我知道的所有实现都这样做)。
简短的回答:制作数据的副本以允许对原始字符串进行垃圾收集。
a
abelenky

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

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

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

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

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

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

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


+1:正是我的想法。在内部它可能使用仍然是 O(n) 的 memcpy
@abelenky:我想也许根本不复制它?它已经在那里了,为什么还要复制它?
@Mehrdad:如果您追求表演。在这种情况下不安全。然后您可以获得一个 char* 子字符串。
@Mehrdad - 你可能期望太多,它被称为 StringBuilder,它是一个很好的构建字符串。它不叫 StringMultiPurposeManipulator
@SamuelNeff,@Mehrdad:.NET 中的字符串没有 NULL终止。如 Lippert's post 中所述,前 4 个字节包含字符串的长度。这就是为什么,正如 Skeet 所指出的,它们可以包含 \0 个字符。
P
Paŭlo Ebermann

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

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

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


你称之为“灵活性”我称之为“一种在软件中意外插入难以诊断的错误(或性能问题)的方法,因为我没有意识到我必须停下来思考这段代码可能存在的所有地方调用 from(包括那些只会在下一个版本中发明的)只是为了从字符串中间获取 4 个字符“
downvote 撤回了......在仔细浏览代码之后,它看起来确实像 java 中的子字符串引用了一个共享数组,至少在 openjdk 版本中是这样。如果你想确保一个新的字符串,有办法做到这一点。
@Nir:我称之为“现状偏见”。对您来说,Java 的做法似乎充满了风险,而 .Net 的做法是唯一明智的选择。对于 Java 程序员来说,情况正好相反。
我非常喜欢 .NET,但这听起来像是 Java 做对的一件事。允许开发人员访问真正的 O(1) 子字符串方法很有用(无需滚动您自己的字符串类型,这会阻碍与所有其他库的互操作性,并且不会像内置解决方案那样高效)。 Java 的解决方案可能效率低下(至少需要两个堆对象,一个用于原始字符串,另一个用于子字符串);支持切片的语言有效地将第二个对象替换为堆栈上的一对指针。
Since JDK 7u6 it's not true anymore - 现在 Java 总是为每个 .substring(...) 复制字符串内容。
C
Community

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

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

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

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


始终复制允许您删除内部数组。将堆分配的数量减半,在短字符串的常见情况下节省内存。这也意味着您不需要为每个字符访问跳过额外的间接。
我认为重要的是,Java 实际上从使用相同的基础 char[](指向开始和结束的指针不同)变为创建新的 String。这清楚地表明,成本效益分析必须显示对创建新 String 的偏好。
b
bartonjs

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

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

这种结构允许快速查找字符串的长度(在许多情况下很有用),并允许在 P/Invoke 中将指针传递给期望以 null 结尾的字符串的 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)),它是具有 ReadOnlySpan<char> 的间接方式,因此存在与 string 相同的差异。

对先前答案的一些答案/评论谈到让垃圾收集器在您继续谈论 5 个字符时必须保留一百万个字符的字符串是很浪费的。这正是您可以使用 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" 😉