Nếu các chuỗi là bất biến trong .NET, thì tại sao Chuỗi con lại mất thời gian O (n)?


451

Cho rằng các chuỗi là bất biến trong .NET, tôi tự hỏi tại sao chúng được thiết kế sao cho string.Substring()mất substring.Lengththời gian O ( ) thay vì O(1)?

tức là sự đánh đổi là gì, nếu có?


3
@Mehrdad: Tôi thích câu hỏi này. Bạn có thể vui lòng cho tôi biết làm thế nào chúng ta có thể xác định O () của một hàm đã cho trong .Net không? Có rõ ràng hay chúng ta nên tính toán nó? Cảm ơn bạn
odiseh

1
@odiseh: Đôi khi (như trong trường hợp này) rõ ràng là chuỗi đang được sao chép. Nếu không, thì bạn có thể xem tài liệu, thực hiện điểm chuẩn hoặc thử tìm mã nguồn .NET Framework để tìm ra nó là gì.
dùng541686

Câu trả lời:


423

CẬP NHẬT: Tôi thích câu hỏi này rất nhiều, tôi chỉ viết blog nó. Xem Chuỗi, bất biến và kiên trì


Câu trả lời ngắn gọn là: O (n) là O (1) nếu n không phát triển lớn. Hầu hết mọi người trích xuất các chuỗi nhỏ từ các chuỗi nhỏ, vì vậy làm thế nào sự phức tạp tăng trưởng không có triệu chứng là hoàn toàn không liên quan .

Câu trả lời dài là:

Một cấu trúc dữ liệu bất biến được xây dựng sao cho các hoạt động trên một cá thể cho phép sử dụng lại bộ nhớ của bản gốc chỉ với một lượng nhỏ (thường là O (1) hoặc O (lg n)) sao chép hoặc phân bổ mới được gọi là "liên tục" cấu trúc dữ liệu bất biến. Chuỗi trong .NET là bất biến; Câu hỏi của bạn về cơ bản là "tại sao họ không kiên trì"?

Bởi vì khi bạn nhìn vào các hoạt động thường được thực hiện trên các chuỗi trong các chương trình .NET, thì theo mọi cách có liên quan hầu như không tệ hơn để tạo ra một chuỗi hoàn toàn mới. Chi phí và khó khăn trong việc xây dựng một cấu trúc dữ liệu liên tục phức tạp không phải trả cho chính nó.

Mọi người thường sử dụng "chuỗi con" để trích xuất một chuỗi ngắn - giả sử, mười hoặc hai mươi ký tự - trong chuỗi dài hơn một chút - có thể là vài trăm ký tự. Bạn có một dòng văn bản trong một tệp được phân tách bằng dấu phẩy và bạn muốn trích xuất trường thứ ba, đó là tên cuối cùng. Dòng sẽ có thể dài vài trăm ký tự, tên sẽ là vài chục. Phân bổ chuỗi và sao chép bộ nhớ năm mươi byte là nhanh đáng kinh ngạc trên phần cứng hiện đại. Việc tạo ra một cấu trúc dữ liệu mới bao gồm một con trỏ ở giữa một chuỗi hiện có cộng với độ dài cũng nhanh đến mức đáng kinh ngạc là không liên quan; "đủ nhanh" là theo định nghĩa đủ nhanh.

Các chất nền được chiết xuất thường có kích thước nhỏ và ngắn trong suốt cuộc đời; người thu gom rác sẽ sớm thu hồi chúng và họ đã không chiếm nhiều chỗ trong đống đầu tiên. Vì vậy, sử dụng một chiến lược bền bỉ khuyến khích tái sử dụng hầu hết bộ nhớ cũng không phải là một chiến thắng; tất cả những gì bạn đã làm là làm cho trình thu gom rác của bạn trở nên chậm hơn bởi vì bây giờ nó phải lo lắng về việc xử lý các con trỏ bên trong.

Nếu các hoạt động của chuỗi con mà mọi người thường làm trên các chuỗi là hoàn toàn khác nhau, thì sẽ có ý nghĩa với một cách tiếp cận bền bỉ. Nếu mọi người thường có chuỗi triệu ký tự và trích xuất hàng ngàn chuỗi con chồng chéo với kích thước trong phạm vi trăm nghìn ký tự và các chuỗi con đó tồn tại rất lâu trên đống, thì sẽ rất hợp lý khi đi theo chuỗi con liên tục tiếp cận; Sẽ thật lãng phí và dại dột phải không. Nhưng hầu hết các lập trình viên ngành kinh doanh không làm bất cứ điều gì thậm chí mơ hồ như những thứ đó. .NET không phải là một nền tảng phù hợp với nhu cầu của Dự án bộ gen người; Các lập trình viên phân tích DNA phải giải quyết các vấn đề với các đặc điểm sử dụng chuỗi đó mỗi ngày; tỷ lệ cược là tốt mà bạn không. Một số ít người xây dựng cấu trúc dữ liệu liên tục của riêng họ phù hợp chặt chẽ với các tình huống sử dụng của họ .

Ví dụ, nhóm của tôi viết các chương trình thực hiện phân tích nhanh chóng về mã C # và VB khi bạn nhập nó. Một số tệp mã đó rất lớn và do đó chúng tôi không thể thực hiện thao tác chuỗi O (n) để trích xuất chuỗi con hoặc chèn hoặc xóa ký tự. Chúng tôi đã xây dựng một loạt các cấu trúc dữ liệu dai dẳng bất biến cho đại diện chỉnh sửa vào đệm văn bản cho phép chúng tôi một cách nhanh chóng và hiệu quả tái sử dụng phần lớn các dữ liệu chuỗi đang tồn tại phân tích từ vựng và cú pháp hiện khi một biên tập điển hình. Đây là một vấn đề khó giải quyết và giải pháp của nó được điều chỉnh phù hợp với miền cụ thể của chỉnh sửa mã C # và VB. Sẽ là không thực tế khi mong đợi loại chuỗi tích hợp để giải quyết vấn đề này cho chúng ta.


47
Thật thú vị khi đối chiếu cách Java thực hiện (hoặc ít nhất là tại một thời điểm nào đó trong quá khứ): Chuỗi con trả về một chuỗi mới, nhưng chỉ vào cùng một char [] như chuỗi lớn hơn - có nghĩa là char lớn hơn [] không thể là rác được thu thập cho đến khi chuỗi con đi ra khỏi phạm vi. Tôi thích triển khai .net cho đến nay.
Michael Stum

13
Tôi đã thấy loại mã này khá nhiều: string contents = File.ReadAllText(filename); foreach (string line in content.Split("\n")) ...hoặc các phiên bản khác của nó. Tôi có nghĩa là đọc toàn bộ tập tin, sau đó xử lý các phần khác nhau. Loại mã đó sẽ nhanh hơn đáng kể và cần ít bộ nhớ hơn nếu một chuỗi liên tục; bạn sẽ luôn có chính xác một bản sao của tệp trong bộ nhớ thay vì sao chép từng dòng, sau đó là các phần của mỗi dòng khi bạn xử lý nó. Tuy nhiên, như Eric đã nói - đó không phải là trường hợp sử dụng điển hình.
cấu hình

18
@configurator: Ngoài ra, trong .NET 4, phương thức File.ReadLines chia nhỏ một tệp văn bản thành các dòng cho bạn mà không cần phải đọc tất cả vào bộ nhớ trước.
Eric Lippert

8
@Michael: Java Stringđược triển khai như một cấu trúc dữ liệu bền vững (điều đó không được quy định trong các tiêu chuẩn, nhưng tất cả các triển khai tôi biết đều làm điều này).
Joachim Sauer

33
Câu trả lời ngắn: Một bản sao của dữ liệu được tạo để cho phép thu gom rác của chuỗi gốc .
Qtax

121

Chính xác bởi vì Chuỗi là bất biến, .Substringphải tạo một bản sao của ít nhất một phần của chuỗi gốc. Tạo một bản sao của n byte sẽ mất thời gian O (n).

Làm thế nào để bạn nghĩ rằng bạn sẽ sao chép một loạt các byte trong thời gian liên tục ?


EDIT: Mehrdad đề nghị không sao chép chuỗi nào cả, nhưng giữ một tham chiếu đến một đoạn của chuỗi.

Hãy xem xét trong .Net, một chuỗi nhiều megabyte, trên đó ai đó gọi .SubString(n, n+3) (đối với bất kỳ n ở giữa của chuỗi).

Bây giờ, chuỗi ENTIRE không thể là Rác được thu thập chỉ vì một tham chiếu đang giữ đến 4 ký tự? Điều đó có vẻ như một sự lãng phí không gian vô lý.

Hơn nữa, theo dõi các tham chiếu đến các chuỗi con (thậm chí có thể nằm trong các chuỗi con) và cố gắng sao chép vào thời điểm tối ưu để tránh đánh bại GC (như mô tả ở trên), khiến khái niệm trở thành một cơn ác mộng. Nó đơn giản hơn nhiều, và đáng tin cậy hơn, để sao chép .SubStringvà duy trì mô hình bất biến đơn giản.


EDIT: Đây là một ít đọc về sự nguy hiểm của việc giữ các tham chiếu đến các chuỗi con trong các chuỗi lớn hơn.


5
+1: Chính xác là suy nghĩ của tôi. Trong nội bộ, nó có thể sử dụng memcpymà vẫn là O (n).
leppie

7
@abelenky: Tôi đoán có lẽ bằng cách không sao chép nó? Nó đã ở đó, tại sao bạn phải sao chép nó?
user541686

2
@Mehrdad: NẾU bạn đang sau khi thực hiện. Chỉ cần đi không an toàn trong trường hợp này. Sau đó, bạn có thể nhận được một char*chuỗi con.
leppie

9
@Mehrdad - bạn có thể mong đợi quá nhiều ở đó, nó được gọi là StringBuilder và đó là một chuỗi xây dựng tốt . Nó không được gọi là StringMultiPurposeManipulator
MattDavey

3
@SamuelNeff, @Mehrdad: Chuỗi trong .NET không NULL bị chấm dứt. Như đã giải thích trong bài đăng của Lippert , 4 byte đầu tiên chứa độ dài của chuỗi. Đó là lý do tại sao, như Skeet chỉ ra, chúng có thể chứa các \0ký tự.
Elideb

33

Java (trái ngược với .NET) cung cấp hai cách làm Substring() , bạn có thể xem xét liệu bạn muốn giữ chỉ một tham chiếu hay sao chép toàn bộ chuỗi con vào một vị trí bộ nhớ mới.

Đơn giản .substring(...)chia sẻ charmảng được sử dụng nội bộ với đối tượng String ban đầu, sau đó bạn có new String(...)thể sao chép sang một mảng mới, nếu cần (để tránh cản trở bộ sưu tập rác của đối tượng gốc).

Tôi nghĩ loại linh hoạt này là một lựa chọn tốt nhất cho một nhà phát triển.


50
Bạn gọi nó là "tính linh hoạt" Tôi gọi nó là "Một cách vô tình chèn một lỗi khó chẩn đoán (hoặc vấn đề về hiệu năng) vào phần mềm vì tôi không nhận ra mình phải dừng lại và nghĩ về tất cả những nơi mà mã này có thể có được gọi từ (bao gồm cả những thứ sẽ chỉ được phát minh trong phiên bản tiếp theo) chỉ để nhận 4 ký tự từ giữa chuỗi "
Nir

3
downvote đã rút lại ... Sau khi duyệt mã cẩn thận hơn một chút, nó trông giống như một chuỗi con trong java tham chiếu một mảng được chia sẻ, ít nhất là trong phiên bản openjdk. Và nếu bạn muốn đảm bảo một chuỗi mới, có một cách để làm điều đó.
Don Roby

11
@Nir: Tôi gọi nó là "thiên vị nguyên trạng". Đối với bạn cách làm Java có vẻ như đầy rủi ro và cách .Net là sự lựa chọn hợp lý duy nhất. Đối với các lập trình viên Java, điều ngược lại là trường hợp.
Michael Borgwardt

7
Tôi rất thích .NET, nhưng điều này nghe có vẻ như Java đã đúng. Điều hữu ích là nhà phát triển được phép truy cập vào phương thức Chuỗi con O (1) thực sự (không cuộn kiểu chuỗi của riêng bạn, điều này sẽ cản trở khả năng tương tác với mọi thư viện khác và sẽ không hiệu quả như một giải pháp tích hợp ). Mặc dù vậy, giải pháp của Java có thể không hiệu quả (yêu cầu ít nhất hai đối tượng heap, một cho chuỗi gốc và một cho chuỗi con); các ngôn ngữ hỗ trợ các lát có hiệu quả thay thế đối tượng thứ hai bằng một cặp con trỏ trên ngăn xếp.
Qwertie

10
Vì JDK 7u6, điều đó không còn đúng nữa - bây giờ Java luôn sao chép nội dung Chuỗi cho mỗi nội dung .substring(...).
Xaerxess

12

Java được sử dụng để tham chiếu các chuỗi lớn hơn, nhưng:

Java cũng thay đổi hành vi của mình thành sao chép , để tránh rò rỉ bộ nhớ.

Tôi cảm thấy như nó có thể được cải thiện mặc dù: tại sao không chỉ sao chép một cách có điều kiện?

Nếu chuỗi con ít nhất bằng một nửa kích thước của cha mẹ, người ta có thể tham chiếu cha mẹ. Nếu không, người ta chỉ có thể tạo một bản sao. Điều này tránh rò rỉ rất nhiều bộ nhớ trong khi vẫn cung cấp một lợi ích đáng kể.


Luôn sao chép cho phép bạn loại bỏ các mảng nội bộ. Giảm một nửa số lượng phân bổ heap, tiết kiệm bộ nhớ trong trường hợp phổ biến của các chuỗi ngắn. Điều đó cũng có nghĩa là bạn không cần phải vượt qua một chỉ dẫn bổ sung cho mỗi lần truy cập nhân vật.
CodeInChaos

2
Tôi nghĩ rằng điều quan trọng cần rút ra từ đây là Java thực sự đã thay đổi từ việc sử dụng cùng một cơ sở char[](với các con trỏ khác nhau để bắt đầu và kết thúc) để tạo ra một cái mới String. Điều này rõ ràng cho thấy rằng phân tích lợi ích chi phí phải cho thấy một ưu tiên cho việc tạo ra một cái mới String.
Phylogenesis

2

Không có câu trả lời nào ở đây đề cập đến "vấn đề đặt dấu ngoặc", nghĩa là các chuỗi trong .NET được biểu diễn dưới dạng kết hợp của BStr (độ dài được lưu trong bộ nhớ "trước" con trỏ) và CStr (chuỗi kết thúc bằng một '\ 0').

Do đó, chuỗi "Xin chào" được thể hiện dưới dạng

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

(nếu được gán cho char*mộtfixed , con trỏ sẽ trỏ đến 0x48.)

Cấu trúc này cho phép tra cứu nhanh độ dài của chuỗi (hữu ích trong nhiều ngữ cảnh) và cho phép con trỏ được truyền trong API P / Gọi tới Win32 (hoặc loại khác) có chuỗi kết thúc null.

Khi bạn thực hiện Substring(0, 5)quy tắc "oh, nhưng tôi đã hứa sẽ có một ký tự null sau ký tự cuối cùng" nói rằng bạn cần tạo một bản sao. Ngay cả khi bạn có chuỗi con ở cuối thì sẽ không có nơi nào để đặt độ dài mà không làm hỏng các biến khác.


Tuy nhiên, đôi khi, bạn thực sự muốn nói về "giữa chuỗi" và bạn không nhất thiết phải quan tâm đến hành vi P / Gọi. ReadOnlySpan<T>Cấu trúc được thêm gần đây có thể được sử dụng để có được chuỗi con không sao chép:

string s = "Hello there";
ReadOnlySpan<char> hello = s.AsSpan(0, 5);
ReadOnlySpan<char> ell = hello.Slice(1, 3);

ReadOnlySpan<char>" Chuỗi con" lưu trữ độ dài một cách độc lập và không đảm bảo rằng có '\ 0' sau khi kết thúc giá trị. Nó có thể được sử dụng theo nhiều cách "như một chuỗi", nhưng nó không phải là "một chuỗi" vì nó không có các đặc điểm BStr hoặc CStr (ít hơn cả hai). Nếu bạn không bao giờ (trực tiếp) P / Gọi thì sẽ không có nhiều sự khác biệt (trừ khi API bạn muốn gọi không bị ReadOnlySpan<char>quá tải).

ReadOnlySpan<char>không thể được sử dụng làm trường của loại tham chiếu, do đó, cũng có ReadOnlyMemory<char>( s.AsMemory(0, 5)), một cách gián tiếp để có một ReadOnlySpan<char>, do đó, sự khác biệt tương tự stringtồn tại.

Một số câu trả lời / nhận xét về các câu trả lời trước đã nói về việc thật lãng phí khi người thu gom rác phải giữ một chuỗi ký tự hàng triệu ký tự trong khi bạn tiếp tục nói về 5 ký tự. Đó chính xác là hành vi bạn có thể nhận được với ReadOnlySpan<char>cách tiếp cận. Nếu bạn chỉ thực hiện các tính toán ngắn, phương pháp ReadOnlySpan có lẽ tốt hơn. Nếu bạn cần duy trì nó trong một thời gian và bạn sẽ chỉ giữ một tỷ lệ nhỏ của chuỗi gốc, thực hiện một chuỗi con thích hợp (để cắt bớt dữ liệu dư thừa) có lẽ tốt hơn. Có một điểm chuyển tiếp ở đâu đó ở giữa, nhưng nó phụ thuộc vào cách sử dụng cụ thể của bạn.

Khi sử dụng trang web của chúng tôi, bạn xác nhận rằng bạn đã đọc và hiểu Chính sách cookieChính sách bảo mật của chúng tôi.
Licensed under cc by-sa 3.0 with attribution required.