文字列が.NETで不変である場合、なぜSubstringはO(n)時間を要するのですか?


451

文字列は.NETで不変であることを考えると、なぜstring.Substring()O(substring.Length)時間ではなく、O()時間がかかるように設計されているのO(1)でしょうか。

すなわち、もしあればトレードオフは何でしたか?


3
@Mehrdad:私はこの質問が好きです。.Netの特定の関数のO()をどのように決定できるか教えていただけませんか?それは明確ですか、それとも計算する必要がありますか?ありがとう
odiseh

1
@odiseh:時々(この場合のように)文字列がコピーされていることは明らかです。そうでない場合は、ドキュメントを参照するか、ベンチマークを実行するか、.NET Frameworkソースコードを調べて、それが何であるかを理解してください。
user541686 2012年

回答:


423

更新:私はこの質問がとても好きだったので、ブログに書きました。文字列、不変性、永続性をご覧ください


簡単に言えば、nが大きくならない場合、O(n)はO(1)です。 ほとんどの人は小さな文字列から小さな部分文字列を抽出するので、複雑さが漸近的に成長する方法はまったく無関係です。

長い答えは:

インスタンスでの操作により、少量のコピー(通常はO(1)またはO(lg n))でオリジナルのメモリを再利用できるように構築された不変のデータ構造または新しい割り当ては、「永続的」と呼ばれます不変のデータ構造。.NETの文字列は不変です。あなたの質問は本質的に「なぜそれらは永続的ではないのですか」ですか?

.NETプログラムの文字列に対して通常行われる操作を見ると、まったく新しい文字列を作成するだけでは、関連するすべての点で悪くなることはほとんどありません複雑な永続データ構造を構築するための費用と難しさは、それだけでは採算が取れません。

人々は通常、「サブストリング」を使用して、少し長いストリング(おそらく数百文字)から短いストリング(例えば、10文字または20文字)を抽出します。コンマで区切られたファイルにテキスト行があり、姓である3番目のフィールドを抽出するとします。行はおそらく数百文字の長さで、名前は数十文字になります。50バイトの文字列割り当てとメモリコピーは、最新のハードウェアでは驚くほど高速です。既存の文字列の中央へのポインタと長さで構成される新しいデータ構造を作成することも、驚くほど高速であることは無関係です。「十分に速い」は、定義上十分に速いです。

抽出される部分文字列は通常、サイズが小さく、有効期間が短いです。ガベージコレクターはすぐにそれらを再利用する予定であり、そもそもヒープ上のスペースをあまり取りませんでした。したがって、ほとんどのメモリの再利用を促進する永続的な戦略を使用することもメリットではありません。これで、内部ポインタの処理について心配する必要があるため、ガベージコレクタの処理が遅くなります。

人々が文字列に対して通常行う部分文字列操作が完全に異なる場合、永続的なアプローチを採用することは理にかなっています。人々が通常100万文字の文字列を持ち、サイズが10万文字の範囲にある何千もの重複する部分文字列を抽出していて、それらの部分文字列がヒープ上で長期間存続している場合、永続的な部分文字列を使用するのが最適です。アプローチ; それは無駄で愚かです。しかし、ほとんどの基幹業務プログラマーは、そのようなことのように漠然とさえ何もしません。.NETは、Human Genome Projectのニーズに合わせて調整されたプラットフォームではありません。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")) ...または他のバージョンのコード。つまり、ファイル全体を読み取ってから、さまざまな部分を処理します。文字列が永続的である場合、この種のコードはかなり高速になり、必要なメモリが少なくなります。各行をコピーするのではなく、常にメモリ内にファイルのコピーを1つだけ保持し、次に各行の一部を処理します。しかし、エリックが言ったように-それは典型的なユースケースではありません。
コンフィギュレータ

18
@configurator:また、.NET 4では、File.ReadLinesメソッドがテキストファイルを行に分割します。最初にすべてをメモリに読み込む必要はありません。
Eric Lippert、2007

8
@Michael:Java Stringは永続的なデータ構造として実装されています(これは標準では指定されていませんが、私が知っているすべての実装でこれが行われます)。
Joachim Sauer

33
短い答え:元の文字列のガベージコレクションを可能にするために、データのコピーが作成されます
Qtax 2011

121

正確ので、文字列は不変で、.Substring元の文字列の少なくとも一部のコピーを作成する必要があります。nバイトのコピーを作成するには、O(n)時間かかります。

一定の時間に大量のバイトをコピーするとどう思いますか?


編集:Mehrdadは、文字列をまったくコピーせず、その一部への参照を維持することを推奨しています。

.Netで、誰かが.SubString(n, n+3)(文字列の中央にある任意のnに対して)呼び出すマルチメガバイトの文字列について考えてみます。

では、1つの参照が4文字を保持しているという理由だけで、文字列全体をガベージコレクションにすることはできません。それはとんでもないスペースの無駄のようです。

さらに、部分文字列(部分文字列の内部にある場合もある)への参照を追跡し、最適なタイミングでコピーを試みて(上記のように)GCの無効化を回避すると、概念が悪夢になります。をコピーし.SubStringて、単純な不変モデルを維持する方がはるかに簡単で信頼性が高くなります。


編集: ここでは、より大きな文字列内の部分文字列への参照を維持することの危険性について少し読んでみましょう。


5
+1:まさに私の考え。内部的にはおそらくmemcpyO(n)を使用します。
レッピー、2007

7
@abelenky:多分それをまったくコピーしないことによると思いますか?すでにそこにありますが、なぜそれをコピーしなければならないのですか?
user541686 '19 / 07/19

2
@Mehrdad:あなたがパフォーマンスの後なら。この場合は、危険を冒してください。次に、char*部分文字列を取得できます。
レッピー、2007

9
@Mehrdad-期待しすぎているかもしれません。これはStringBuilderと呼ばれ、文字列を構築するのに適してます。StringMultiPurposeManipulatorと呼ばれていません
MattDavey

3
@SamuelNeff、@Mehrdad:.NETの文字列はありません NULL終了し。で説明したようにリッペルトの後、最初の4つのバイトは、文字列の長さを含みます。これが、スキートが指摘するように、\0文字を含むことができる理由です。
Elideb 2011

33

Java(.NETとは対照的に)には2つの方法がSubstring()あります。参照のみを保持するか、部分文字列全体を新しいメモリ位置にコピーするかを検討できます。

シンプルな.substring(...)株式内部的に使用charして、あなたのオリジナルのStringオブジェクトの配列、new String(...)必要に応じて新しい配列にコピーすることができますが、(元1の妨げガベージコレクションを避けるため)。

この種の柔軟性は、開発者にとって最良のオプションだと思います。


50
あなたはそれを「柔軟性」と呼んでいます。「コードを診断するのが難しいバグ(またはパフォーマンスの問題)を誤ってソフトウェアに挿入する方法です。文字列の中央から4文字を取得するために(次のバージョンでのみ発明されるものを含む)から呼び出されました」
Nir

3
撤回投票の取り消し...コードを少し注意深く参照すると、Javaの部分文字列は、少なくともopenjdkバージョンでは共有配列を参照しているように見えます。そして、新しい文字列を確実にしたい場合は、それを行う方法があります。
Don Roby、2011

11
@ニル:私はそれを「現状維持バイアス」と呼んでいます。あなたにとって、Javaのやり方はリスクに満ちているようで、.Netのやり方は唯一の賢明な選択です。Javaプログラマーにとっては、その逆です。
Michael Borgwardt、2011

7
私は.NETを強く好みますが、これはJavaが正しく機能しているように思えます。開発者が真にO(1)サブストリングメソッドへのアクセスを許可されていると便利です(独自の文字列タイプをローリングせずに他のすべてのライブラリとの相互運用性を妨げ、組み込みソリューションほど効率的ではありません) )。ただし、Javaのソリューションはおそらく非効率的です(少なくとも2つのヒープオブジェクトが必要です。1つは元の文字列用で、もう1つはサブ文字列用です)。スライスをサポートする言語は、2番目のオブジェクトをスタック上のポインターのペアで効果的に置き換えます。
Qwertie

10
JDK 7u6以降、これは真実ではなくなりました。現在、Javaは常にそれぞれのコンテンツをStringにコピーします.substring(...)
Xaerxess 2013年

12

以前は、より大きな文字列を参照するためにJavaが使用されていましたが、

Javaは、メモリのリークを回避するために、動作もコピーに変更しました

私はそれが改善できるように感じます:なぜ条件付きでコピーをしないのですか?

部分文字列が親のサイズの半分以上である場合、親を参照できます。それ以外の場合は、コピーを作成できます。これにより、多くのメモリのリークを回避しながら、大きなメリットを提供します。


常にコピーすると、内部配列を削除できます。ヒープ割り当ての数を半分にして、短い文字列の一般的なケースでメモリを節約します。また、キャラクターへのアクセスごとに追加の間接参照をジャンプする必要がないことも意味します。
CodesInChaos 2014

2
これから取るべき重要なことは、Javaが実際に同じベースchar[](開始と終了への異なるポインターを使用)を使用することから、新しいを作成することに変更されたことStringです。これは明らかに、費用便益分析が新しいの作成に対する選好を示さなければならないことを示していますString
系統発生2015

2

ここでの回答はいずれも「ブラケット問題」に対処していません。つまり、.NETの文字列はBStr(ポインタの「前」にメモリに格納されている長さ)とCStr(文字列は'\ 0')。

文字列「こんにちは」はこのように表されます

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*でaに割り当てられている場合fixed、ポインターは0x48を指します。)

この構造により、文字列の長さの高速ルックアップが可能になり(多くのコンテキストで役立ちます)、ポインターをP / InvokeでWin32(または他の)APIに渡して、nullで終了する文字列を期待できます。

あなたが行う場合は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文字について話し続けている間、100万文字の文字列を保持しなければならないのは無駄であると説明しました。それがまさにReadOnlySpan<char>アプローチで得られる行動です。短い計算をしているだけなら、ReadOnlySpanアプローチがおそらくより良いでしょう。しばらく保持する必要があり、元の文字列のごく一部のみを保持する場合は、適切な部分文字列を実行する(余分なデータを削除する)ほうがよいでしょう。途中のどこかに遷移点がありますが、それはあなたの特定の使用法に依存します。

弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.