Jika string tidak dapat diubah dalam. NET, lalu mengapa Substring membutuhkan waktu O (n)?

451

Mengingat bahwa string tidak dapat diubah dalam. NET, saya bertanya-tanya mengapa mereka dirancang sedemikian rupa sehingga string.Substring()membutuhkan waktu O ( substring.Length), bukan O(1)?

yaitu apa pengorbanan, jika ada?

pengguna541686
sumber
3
@Mehrdad: Saya suka pertanyaan ini. Bisakah Anda memberi tahu saya bagaimana kami dapat menentukan O () dari fungsi yang diberikan di .Net? Apakah sudah jelas atau kita harus menghitungnya? Terima kasih
odiseh
1
@odiseh: Kadang-kadang (seperti dalam kasus ini) jelas bahwa string sedang disalin. Jika tidak, maka Anda bisa melihat dalam dokumentasi, melakukan benchmark, atau mencoba untuk melihat kode sumber .NET Framework untuk mencari tahu apa itu.
user541686

Jawaban:

423

UPDATE: Saya sangat menyukai pertanyaan ini, saya hanya menulis blognya. Lihat String, kekekalan, dan ketekunan


Jawaban singkatnya adalah: O (n) adalah O (1) jika n tidak tumbuh besar. Kebanyakan orang mengekstraksi substring kecil dari string kecil, jadi bagaimana kompleksitas tumbuh asimptotik sama sekali tidak relevan .

Jawaban panjangnya adalah:

Struktur data yang tidak dapat diubah yang dibangun sedemikian rupa sehingga operasi berdasarkan contoh mengizinkan penggunaan kembali memori sumber asli dengan hanya sejumlah kecil (biasanya O (1) atau O (lg n)) penyalinan atau alokasi baru disebut "persisten" struktur data tidak berubah. String dalam. NET tidak dapat diubah; pertanyaan Anda pada dasarnya adalah "mengapa mereka tidak gigih"?

Karena ketika Anda melihat operasi yang biasanya dilakukan pada string dalam program .NET, dalam segala hal yang relevan sama sekali lebih buruk sama sekali untuk hanya membuat string yang sama sekali baru. Biaya dan kesulitan membangun struktur data persisten yang kompleks tidak membayar untuk dirinya sendiri.

Orang biasanya menggunakan "substring" untuk mengekstraksi string pendek - katakanlah, sepuluh atau dua puluh karakter - dari string yang agak lebih panjang - mungkin beberapa ratus karakter. Anda memiliki satu baris teks dalam file yang dipisahkan koma dan Anda ingin mengekstrak bidang ketiga, yang merupakan nama belakang. Panjang barisnya mungkin beberapa ratus karakter, namanya beberapa lusin. Alokasi string dan penyalinan memori lima puluh byte sangat cepat pada perangkat keras modern. Bahwa membuat struktur data baru yang terdiri dari pointer ke tengah string yang ada ditambah panjangnya juga sangat cepat tidak relevan; "cukup cepat" menurut definisi cukup cepat.

Substring yang diekstraksi biasanya berukuran kecil dan pendek seumur hidup; pengumpul sampah akan mendapatkan kembali mereka segera, dan mereka tidak mengambil banyak ruang di tumpukan di tempat pertama. Jadi menggunakan strategi gigih yang mendorong penggunaan kembali sebagian besar memori juga bukan merupakan kemenangan; semua yang Anda lakukan adalah membuat pengumpul sampah Anda menjadi lebih lambat karena sekarang harus khawatir tentang penanganan pointer interior.

Jika operasi substring yang biasanya dilakukan orang pada string sama sekali berbeda, maka masuk akal untuk menggunakan pendekatan yang gigih. Jika orang biasanya memiliki string jutaan karakter, dan mengekstraksi ribuan substring yang tumpang tindih dengan ukuran dalam kisaran seratus ribu karakter, dan substring tersebut bertahan lama di heap, maka masuk akal jika menggunakan substring yang persisten. pendekatan; akan sia-sia dan bodoh untuk tidak melakukannya. Tetapi kebanyakan programmer lini bisnis tidak melakukan apa-apa bahkan secara samar-samar seperti hal-hal semacam itu. .NET bukan platform yang dirancang untuk kebutuhan Proyek Genom Manusia; Pemrogram analisis DNA harus menyelesaikan masalah dengan karakteristik penggunaan string tersebut setiap hari; kemungkinan besar Anda tidak melakukannya. Beberapa yang memang membangun struktur data persisten mereka sendiri yang sangat cocok dengan skenario penggunaannya.

Misalnya, tim saya menulis program yang melakukan analisis langsung kode C # dan VB saat Anda mengetiknya. Beberapa file kode itu sangat besar dan karenanya kita tidak dapat melakukan manipulasi string O (n) untuk mengekstraksi substring atau menyisipkan atau menghapus karakter. Kami telah membangun sekelompok struktur data berubah terus-menerus untuk mewakili suntingan ke buffer teks yang memungkinkan kita untuk dengan cepat dan efisien kembali menggunakan sebagian besar data string yang ada dan analisis leksikal dan sintaksis yang ada di atas sunting khas. Ini adalah masalah yang sulit untuk dipecahkan dan solusinya secara sempit disesuaikan dengan domain spesifik dari pengeditan kode C # dan VB. Tidak realistis mengharapkan tipe string bawaan untuk menyelesaikan masalah ini bagi kami.

Eric Lippert
sumber
47
Akan menarik untuk membandingkan bagaimana Java melakukannya (atau setidaknya pada suatu saat di masa lalu): Substring mengembalikan string baru, tetapi menunjuk pada char yang sama [] seperti string yang lebih besar - itu berarti bahwa char yang lebih besar [] tidak bisa lagi menjadi sampah yang dikumpulkan sampai substring keluar dari cakupan. Saya lebih suka implementasi .net sejauh ini.
Michael Stum
13
Saya telah melihat kode semacam ini sedikit: string contents = File.ReadAllText(filename); foreach (string line in content.Split("\n")) ...atau versi lain dari itu. Maksud saya membaca seluruh file, lalu memproses berbagai bagian. Kode semacam itu akan jauh lebih cepat dan membutuhkan lebih sedikit memori jika sebuah string tetap ada; Anda akan selalu memiliki satu salinan file dalam memori alih-alih menyalin setiap baris, lalu bagian-bagian dari setiap baris sebagai proses Anda. Namun, seperti kata Eric - itu bukan kasus penggunaan khas.
konfigurator
18
@configurator: Juga, dalam .NET 4 metode File.ReadLines memecah file teks menjadi beberapa baris untuk Anda, tanpa harus membacanya terlebih dahulu ke dalam memori.
Eric Lippert
8
@ Michael: Java Stringdiimplementasikan sebagai struktur data yang persisten (itu tidak ditentukan dalam standar, tetapi semua implementasi yang saya tahu melakukan ini).
Joachim Sauer
33
Jawaban singkat: Salinan data dibuat untuk memungkinkan pengumpulan sampah dari string asli .
Qtax
121

Justru karena String tidak dapat diubah, .Substringharus membuat salinan setidaknya sebagian dari string asli. Membuat salinan n byte harus memakan waktu O (n).

Bagaimana menurut Anda Anda akan menyalin banyak byte dalam waktu yang konstan ?


EDIT: Mehrdad menyarankan untuk tidak menyalin string sama sekali, tetapi menyimpan referensi untuk sepotong itu.

Pertimbangkan dalam. Net, string multi-megabyte, tempat seseorang memanggil .SubString(n, n+3)(untuk sembarang n di tengah-tengah string).

Sekarang, SELURUH string tidak dapat Dikumpulkan Sampah hanya karena satu referensi berpegang pada 4 karakter? Itu tampak seperti pemborosan ruang.

Selanjutnya, melacak referensi ke substring (yang bahkan mungkin berada di dalam substring), dan mencoba menyalin pada waktu yang optimal untuk menghindari mengalahkan GC (seperti dijelaskan di atas), membuat konsep mimpi buruk. Adalah jauh lebih sederhana, dan lebih dapat diandalkan, untuk menyalin .SubString, dan memelihara model yang tidak berubah secara langsung.


EDIT: Ini sedikit bacaan yang bagus tentang bahaya menyimpan referensi ke substring dalam string yang lebih besar.

kasar
sumber
5
+1: Persis seperti yang saya pikirkan. Secara internal mungkin menggunakan memcpyyang masih O (n).
leppie
7
@abelenky: Saya kira mungkin dengan tidak menyalinnya sama sekali? Sudah ada di sana, mengapa Anda harus menyalinnya?
user541686
2
@Mehrdad: JIKA Anda mengejar kinerja. Pergi saja tidak aman dalam kasus ini. Maka Anda bisa mendapatkan char*substring.
leppie
9
@Mehrdad - Anda mungkin berharap terlalu banyak di sana, itu disebut StringBuilder , dan ada baiknya string bangunan . Ini tidak disebut StringMultiPurposeManipulator
MattDavey
3
@SamuelNeff, @Mehrdad: String di .NET tidak NULL dihentikan. Seperti dijelaskan dalam posting Lippert , 4 byte pertama berisi panjang string. Itu sebabnya, seperti yang ditunjukkan Skeet, mereka dapat berisi \0karakter.
Elideb
33

Java (sebagai lawan dari .NET) menyediakan dua cara untuk melakukan Substring(), Anda dapat mempertimbangkan apakah Anda ingin hanya menyimpan referensi atau menyalin seluruh substring ke lokasi memori baru.

Sederhana .substring(...)berbagi chararray yang digunakan secara internal dengan objek String asli, yang kemudian Anda new String(...)dapat salin ke array baru, jika diperlukan (untuk menghindari pengumpulan sampah menghalangi yang asli).

Saya pikir fleksibilitas semacam ini adalah pilihan terbaik bagi pengembang.

sll
sumber
50
Anda menyebutnya "fleksibilitas" Saya menyebutnya "Suatu cara untuk secara tidak sengaja memasukkan bug yang sulit didiagnosis (atau masalah kinerja) ke dalam perangkat lunak karena saya tidak menyadari bahwa saya harus berhenti dan memikirkan semua tempat yang mungkin menjadi kode ini. dipanggil dari (termasuk yang hanya akan ditemukan di versi berikutnya) hanya untuk mendapatkan 4 karakter dari tengah-tengah string "
Nir
3
downvote retracted ... Setelah sedikit lebih hati-hati menelusuri kode itu memang terlihat seperti substring di referensi java array bersama, setidaknya dalam versi openjdk. Dan jika Anda ingin memastikan string baru ada cara untuk melakukannya.
Don Roby
11
@Nir: Saya menyebutnya "bias status quo". Bagi Anda cara Jawa melakukannya tampaknya penuh dengan risiko dan. Net cara satu-satunya pilihan yang masuk akal. Untuk programmer Java, yang terjadi adalah sebaliknya.
Michael Borgwardt
7
Saya sangat suka NET., Tapi ini terdengar seperti satu hal yang benar Java. Berguna bahwa pengembang diizinkan untuk memiliki akses ke metode Substring O (1) yang benar-benar (tanpa menggulirkan tipe string Anda sendiri, yang akan menghambat interoperabilitas dengan setiap perpustakaan lain, dan tidak akan seefisien solusi bawaan. ). Solusi Java mungkin tidak efisien (membutuhkan setidaknya dua objek tumpukan, satu untuk string asli dan satu lagi untuk substring); bahasa yang mendukung irisan secara efektif mengganti objek kedua dengan sepasang petunjuk di tumpukan.
Qwertie
10
Sejak JDK 7u6 itu tidak benar lagi - sekarang Java selalu menyalin konten String untuk masing-masing .substring(...).
Xaerxess
12

Java digunakan untuk referensi string yang lebih besar, tetapi:

Java mengubah perilakunya menjadi penyalinan juga, untuk menghindari kebocoran memori.

Saya merasa seperti itu dapat ditingkatkan: mengapa tidak melakukan penyalinan secara kondisional?

Jika substring setidaknya setengah ukuran induk, orang dapat mereferensikan induk. Kalau tidak, orang hanya dapat membuat salinan. Ini menghindari kebocoran banyak memori sambil tetap memberikan manfaat yang signifikan.

pengguna541686
sumber
Selalu menyalin memungkinkan Anda untuk menghapus array internal. Membagi dua jumlah alokasi tumpukan, menghemat memori dalam kasus umum string pendek. Ini juga berarti Anda tidak perlu melompati tipuan tambahan untuk setiap akses karakter.
CodesInChaos
2
Saya pikir hal penting yang harus diambil dari ini adalah bahwa Java benar-benar berubah dari menggunakan basis yang sama char[](dengan pointer berbeda ke awal dan akhir) untuk membuat yang baru String. Ini jelas menunjukkan bahwa analisis biaya-manfaat harus menunjukkan preferensi untuk penciptaan yang baru String.
Filogenesis
2

Tidak ada jawaban di sini yang membahas "masalah bracketing", yaitu untuk mengatakan bahwa string dalam. NET direpresentasikan sebagai kombinasi dari BStr (panjang yang disimpan dalam memori "sebelum" pointer) dan CStr (string berakhir dengan '\ 0').

String "Hello there" dengan demikian direpresentasikan sebagai

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

(jika ditugaskan ke char*dalam- fixedpernyataan yang pointer akan menunjuk ke 0x48.)

Struktur ini memungkinkan pencarian cepat dari panjang string (berguna dalam banyak konteks) dan memungkinkan penunjuk untuk diteruskan dalam API P / Invoke to Win32 (atau lainnya) yang mengharapkan string yang diakhiri dengan null.

Ketika Anda melakukan Substring(0, 5)"oh, tapi saya berjanji akan ada karakter nol setelah karakter terakhir" aturan mengatakan Anda perlu membuat salinan. Bahkan jika Anda mendapatkan substring di akhir maka tidak akan ada tempat untuk meletakkan panjang tanpa merusak variabel lainnya.


Namun, kadang-kadang, Anda benar-benar ingin berbicara tentang "tengah-tengah string", dan Anda tidak perlu peduli dengan perilaku P / Invoke. Struktur yang baru ditambahkan ReadOnlySpan<T>dapat digunakan untuk mendapatkan substring tanpa salinan:

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

The ReadOnlySpan<char>"substring" toko panjang secara independen, dan hal itu bukan jaminan bahwa ada '\ 0' setelah akhir nilai. Ini dapat digunakan dalam banyak cara "seperti string", tetapi itu bukan "string" karena tidak memiliki karakteristik BStr atau CStr (apalagi keduanya). Jika Anda tidak pernah (secara langsung) P / Aktifkan maka tidak ada banyak perbedaan (kecuali API yang ingin Anda panggil tidak memiliki ReadOnlySpan<char>kelebihan).

ReadOnlySpan<char>tidak dapat digunakan sebagai bidang tipe referensi, jadi ada juga ReadOnlyMemory<char>( s.AsMemory(0, 5)), yang merupakan cara tidak langsung untuk memiliki ReadOnlySpan<char>, sehingga perbedaan-dari-yang sama stringada.

Beberapa jawaban / komentar pada jawaban sebelumnya berbicara tentang pemborosan untuk membuang sampah dengan jutaan karakter, sementara Anda terus berbicara sekitar 5 karakter. Itulah perilaku yang bisa Anda dapatkan dengan ReadOnlySpan<char>pendekatan itu. Jika Anda hanya melakukan perhitungan singkat, pendekatan ReadOnlySpan mungkin lebih baik. Jika Anda perlu bertahan sebentar dan Anda hanya akan menyimpan sebagian kecil dari string asli, melakukan substring yang tepat (untuk memotong kelebihan data) mungkin lebih baik. Ada titik transisi di suatu tempat di tengah, tetapi itu tergantung pada penggunaan khusus Anda.

Bartonjs
sumber