หากสตริงไม่เปลี่ยนรูปใน. NET แล้วเหตุใด Substring จึงใช้เวลา O (n)


451

เนื่องจากสตริงนั้นไม่เปลี่ยนแปลงใน. NET ฉันจึงสงสัยว่าทำไมพวกเขาจึงถูกออกแบบมาอย่างนั้น string.Substring()ใช้เวลา O ( substring.Length) แทนO(1)?

เช่นอะไรคือการแลกเปลี่ยนถ้ามี?


3
@ Mehrdad: ฉันชอบคำถามนี้ คุณช่วยบอกฉันทีว่าเราสามารถกำหนด O () ของฟังก์ชันที่กำหนดใน. Net ได้อย่างไร ชัดเจนหรือเราควรคำนวณ ขอบคุณ
odiseh

1
@odiseh: บางครั้ง (เช่นในกรณีนี้) เป็นที่ชัดเจนว่าสตริงจะถูกคัดลอก หากไม่เป็นเช่นนั้นคุณสามารถดูเอกสารดำเนินการเปรียบเทียบหรือลองดูในซอร์สโค้ด. NET Framework เพื่อหาว่ามันคืออะไร
541686

คำตอบ:


423

อัปเดต: ฉันชอบคำถามนี้มากฉันเพิ่ง blogged ดูสตริงการเปลี่ยนแปลงไม่ได้และการคงอยู่


คำตอบสั้น ๆ คือ: O (n) คือ O (1) ถ้า n ไม่ใหญ่ขึ้น คนส่วนใหญ่แยกย่อยเล็ก ๆ จากสายเล็ก ๆ ดังนั้นวิธีการที่ซับซ้อนเติบโต asymptotically เป็นที่ไม่เกี่ยวข้องอย่างสมบูรณ์

คำตอบที่ยาวคือ:

โครงสร้างข้อมูลที่ไม่เปลี่ยนรูปซึ่งสร้างขึ้นเพื่อให้การดำเนินการบนอินสแตนซ์อนุญาตให้ใช้หน่วยความจำของต้นฉบับซ้ำได้ในจำนวนเล็กน้อย (โดยทั่วไปคือ O (1) หรือ O (lg n)) ของการคัดลอกหรือการจัดสรรใหม่เรียกว่า "ถาวร" โครงสร้างข้อมูลที่ไม่เปลี่ยนรูป สตริงใน. NET ไม่เปลี่ยนรูป คำถามของคุณเป็นหลัก "ทำไมพวกเขาไม่ขัดขืน"?

เนื่องจากเมื่อคุณดูการดำเนินการที่โดยปกติแล้วจะทำกับสตริงในโปรแกรม. NET มันเป็นไปในทางที่เกี่ยวข้องแทบจะไม่เลวเลยที่จะสร้างสตริงใหม่ทั้งหมด ค่าใช้จ่ายและความยากลำบากในการสร้างโครงสร้างข้อมูลแบบถาวรที่ซับซ้อนไม่ได้จ่ายเอง

โดยทั่วไปผู้คนใช้ "substring" เพื่อแยกสตริงสั้น ๆ - พูดอักขระสิบหรือยี่สิบ - จากสตริงที่ค่อนข้างยาว - อาจเป็นสองร้อยตัวอักษร คุณมีบรรทัดข้อความในไฟล์ที่คั่นด้วยเครื่องหมายจุลภาคและคุณต้องการแยกฟิลด์ที่สามซึ่งเป็นนามสกุล บรรทัดอาจจะยาวสองร้อยตัวอักษรชื่อจะเป็นสองสามโหล การจัดสรรสตริงและการคัดลอกหน่วยความจำห้าสิบไบต์มีความรวดเร็วอย่างน่าอัศจรรย์บนฮาร์ดแวร์ที่ทันสมัย การสร้างโครงสร้างข้อมูลใหม่ที่ประกอบด้วยตัวชี้ไปยังกึ่งกลางของสตริงที่มีอยู่บวกกับความยาวนั้นยังเร็วอย่างน่าอัศจรรย์ไม่เกี่ยวข้อง "เร็วพอ" คือคำจำกัดความเร็วพอ

โดยทั่วไปแล้วสารสกัดที่แยกออกมาจะมีขนาดเล็กและมีอายุการใช้งานสั้น ตัวเก็บขยะกำลังจะเรียกคืนพวกเขาในไม่ช้าและพวกเขาไม่ได้ใช้พื้นที่มากในกองตั้งแต่แรก ดังนั้นการใช้กลยุทธ์แบบถาวรที่กระตุ้นการใช้งานซ้ำส่วนใหญ่ของหน่วยความจำก็ไม่ชนะเช่นกัน สิ่งที่คุณทำคือทำให้ตัวเก็บขยะของคุณช้าลงเพราะตอนนี้คุณต้องกังวลเกี่ยวกับการจัดการตัวชี้ภายใน

หากการดำเนินการของสายอักขระย่อยที่คนทั่วไปทำกับสตริงนั้นแตกต่างไปจากเดิมอย่างสิ้นเชิงมันน่าจะเป็นไปได้ที่จะใช้วิธีถาวร หากคนทั่วไปมีสตริงอักขระเป็นล้านและแยกสตริงย่อยที่ซ้อนกันหลายพันรายการด้วยขนาดในช่วงหนึ่งแสนอักขระและสตริงย่อยเหล่านั้นอาศัยอยู่เป็นเวลานานบนฮีปดังนั้นจึงเหมาะสมอย่างยิ่งที่จะใช้สตริงย่อยแบบต่อเนื่อง วิธีการ; มันจะสิ้นเปลืองและไม่โง่ แต่โปรแกรมเมอร์สายงานธุรกิจส่วนใหญ่ไม่ทำอะไรเลยแม้แต่สิ่งที่คลุมเครือเช่นนั้น. .NET ไม่ใช่แพลตฟอร์มที่ได้รับการปรับให้เหมาะกับความต้องการของโครงการจีโนมมนุษย์ โปรแกรมเมอร์วิเคราะห์ดีเอ็นเอต้องแก้ปัญหาเกี่ยวกับลักษณะการใช้งานสตริงเหล่านั้นทุกวัน อัตราต่อรองเป็นสิ่งที่ดีที่คุณทำไม่ได้ ผู้ที่สร้างโครงสร้างข้อมูลถาวรของตนเองที่ตรงกับสถานการณ์การใช้งานของตนเอง

ตัวอย่างเช่นทีมของฉันเขียนโปรแกรมที่ทำการวิเคราะห์แบบทันทีของรหัส C # และ VB เมื่อคุณพิมพ์ ไฟล์โค้ดเหล่านี้บางไฟล์มีขนาดใหญ่มากและทำให้เราไม่สามารถทำการจัดการสตริง O (n) เพื่อแยกสตริงย่อยหรือแทรกหรือลบอักขระได้ เราได้สร้างเครือถาวรโครงสร้างข้อมูลไม่เปลี่ยนรูปสำหรับตัวแทนแก้ไขบัฟเฟอร์ข้อความที่อนุญาตให้เราได้อย่างรวดเร็วและมีประสิทธิภาพอีกครั้งใช้เป็นกลุ่มของข้อมูลสตริงที่มีอยู่และที่มีอยู่ในการวิเคราะห์คำศัพท์และประโยคเมื่อแก้ไขทั่วไป นี่เป็นปัญหาที่ยากในการแก้และโซลูชันได้รับการปรับให้เข้ากับโดเมนเฉพาะของการแก้ไขรหัส C # และ VB มันจะไม่สมจริงที่คาดว่าจะมีประเภทสตริงในตัวเพื่อแก้ปัญหานี้สำหรับเรา


47
มันจะน่าสนใจที่จะเปรียบเทียบว่า Java ทำ (หรืออย่างน้อยก็ในบางจุดที่ผ่านมา) มัน: Substring ส่งกลับสตริงใหม่ แต่ชี้ไปที่ถ่านเดียวกัน [] เป็นสตริงขนาดใหญ่ - นั่นหมายความว่าถ่านขนาดใหญ่ [] ไม่สามารถรวบรวมขยะได้อีกต่อไปจนกว่าสตริงย่อยจะออกนอกขอบเขต ฉันชอบการใช้งาน. net มาก
Michael Stum

13
ฉันเห็นโค้ดประเภทนี้แล้ว: string contents = File.ReadAllText(filename); foreach (string line in content.Split("\n")) ...หรือเวอร์ชั่นอื่น ๆ ฉันหมายถึงอ่านไฟล์ทั้งหมดแล้วประมวลผลส่วนต่าง ๆ การเรียงลำดับของรหัสนั้นจะเร็วกว่ามากและต้องการหน่วยความจำน้อยกว่าถ้าสตริงนั้นขัดขืน คุณจะมีไฟล์หนึ่งสำเนาในหน่วยความจำเสมอแทนที่จะคัดลอกแต่ละบรรทัดจากนั้นส่วนต่าง ๆ ของแต่ละบรรทัดจะดำเนินการ อย่างไรก็ตามอย่างที่ Eric พูด - นั่นไม่ใช่กรณีการใช้งานทั่วไป
กำหนดค่า

18
@ ผู้สร้าง: นอกจากนี้ใน. NET 4 เมธอด File.ReadLines จะแบ่งไฟล์ข้อความเป็นบรรทัดสำหรับคุณโดยไม่ต้องอ่านทั้งหมดลงในหน่วยความจำก่อน
Eric Lippert

8
@Michael: Java Stringถูกนำมาใช้เป็นโครงสร้างข้อมูลถาวร (ที่ไม่ได้ระบุไว้ในมาตรฐาน แต่การใช้งานทั้งหมดที่ฉันรู้ทำ)
Joachim Sauer

33
คำตอบสั้น ๆ : สำเนาของข้อมูลที่ถูกสร้างขึ้นมาเพื่อช่วยให้การเก็บขยะของสตริงเดิม
Qtax

121

แม่นยำเพราะสตริงไม่เปลี่ยนรูป.Substringต้องทำสำเนาอย่างน้อยส่วนหนึ่งของสตริงต้นฉบับ การทำสำเนาของnไบต์ควรใช้เวลา O (n)

คุณคิดว่าคุณจะคัดลอกจำนวนไบต์ในเวลาคงที่ได้อย่างไร


แก้ไข: Mehrdad ไม่แนะนำให้คัดลอกสตริงเลย แต่อ้างอิงถึงชิ้นส่วนของมัน

ลองพิจารณาใน. Net ซึ่งเป็นสตริงที่มีหลายเมกะไบต์ซึ่งบางคนเรียก.SubString(n, n+3)(สำหรับ n ใด ๆ ที่อยู่ตรงกลางของสตริง)

ตอนนี้สตริงทั้งหมดไม่สามารถรวบรวมขยะได้เพียงเพราะมีการอ้างอิงเดียวที่เก็บไว้ที่ 4 ตัวอักษร? ดูเหมือนว่าจะเป็นการสิ้นเปลืองพื้นที่ไร้สาระ

นอกจากนี้การติดตามการอ้างอิงถึงสารตั้งต้น (ซึ่งอาจอยู่ภายในสารตั้งต้น) และพยายามทำสำเนาในเวลาที่เหมาะสมเพื่อหลีกเลี่ยงการเอาชนะ GC (ดังที่อธิบายไว้ข้างต้น) ทำให้แนวคิดเป็นฝันร้าย มันง่ายกว่าและน่าเชื่อถือมากกว่าในการคัดลอก.SubStringและรักษาโมเดลที่ไม่เปลี่ยนแปลงอย่างตรงไปตรงมา


แก้ไข: นี่เป็นเพียงการอ่านเล็ก ๆ น้อย ๆเกี่ยวกับอันตรายของการเก็บการอ้างอิงถึงสตริงย่อยภายในสตริงขนาดใหญ่


5
+1: ความคิดของฉัน ภายในอาจใช้memcpyซึ่งยังคงเป็น O (n)
leppie

7
@abelenky: ฉันเดาว่าอาจจะไม่คัดลอกเลยก็ได้? มีอยู่แล้วทำไมคุณต้องคัดลอก?
user541686

2
@ Mehrdad: ถ้าคุณเป็นหลังการแสดง เพียงไปไม่ปลอดภัยในกรณีนี้ จากนั้นคุณจะได้รับchar*สตริงย่อย
leppie

9
@ Mehrdad - คุณอาจคาดหวังมากเกินไปที่นั่นเรียกว่าStringBuilderและมันก็เป็นเงื่อนไขการสร้างที่ดี มันไม่ได้ชื่อว่า StringMultiPurposeManipulator
MattDavey

3
@SamuelNeff, @Mehrdad: สตริงใน. NET จะไม่ NULLถูกยกเลิก ตามที่อธิบายไว้ในโพสต์ของ Lippert , 4 ไบต์แรกมีความยาวของสตริง นั่นเป็นเหตุผลที่ Skeet ชี้ให้เห็นว่าพวกเขาสามารถมี\0ตัวละครได้
Elideb

33

Java (ตรงกันข้ามกับ. NET) มีสองวิธีในการทำSubstring()คุณสามารถพิจารณาว่าคุณต้องการเก็บไว้เป็นข้อมูลอ้างอิงหรือคัดลอกซับสตริงทั้งหมดไปยังตำแหน่งหน่วยความจำใหม่

การ.substring(...)แชร์charอาร์เรย์ที่ใช้ภายในอย่างง่ายกับวัตถุ String ดั้งเดิมซึ่งคุณnew String(...)สามารถคัดลอกไปยังอาร์เรย์ใหม่ได้หากจำเป็น (เพื่อหลีกเลี่ยงการขัดขวางการรวบรวมขยะของต้นฉบับ)

ฉันคิดว่าความยืดหยุ่นแบบนี้เป็นตัวเลือกที่ดีที่สุดสำหรับนักพัฒนา


50
คุณเรียกมันว่า "ความยืดหยุ่น" ฉันเรียกมันว่า "วิธีการใส่บั๊กที่ยากต่อการวินิจฉัยข้อผิดพลาด (หรือปัญหาด้านประสิทธิภาพ) ลงในซอฟต์แวร์โดยไม่ตั้งใจเพราะฉันไม่รู้ตัวว่าต้องหยุดและคิดเกี่ยวกับสถานที่ทั้งหมดที่รหัสนี้อาจเป็นได้ เรียกจาก (รวมถึงผู้ที่จะถูกประดิษฐ์ขึ้นในรุ่นถัดไป) เพียงเพื่อให้ได้ 4 ตัวอักษรจากกลางสตริง "
Nir

3
downvote retracted ... หลังจากการดูโค้ดอย่างระมัดระวังมากขึ้นดูเหมือนว่าสตริงย่อยใน java จะอ้างอิงอาร์เรย์ที่ใช้ร่วมกันอย่างน้อยในเวอร์ชัน openjdk และถ้าคุณต้องการให้แน่ใจว่าสตริงใหม่มีวิธีการทำเช่นนั้น
Don Roby

11
@Nir: ฉันเรียกมันว่า "status quo bias" สำหรับคุณแล้ววิธีการทำ Java ดูเหมือนว่าเต็มไปด้วยความเสี่ยงและวิธี. Net เป็นทางเลือกที่ละเอียดอ่อนเท่านั้น สำหรับโปรแกรมเมอร์ Java นั้นตรงกันข้าม
Michael Borgwardt

7
ฉันชอบ. NET มาก แต่ดูเหมือนว่า Java อย่างถูกต้อง มีประโยชน์ที่นักพัฒนาซอฟต์แวร์จะได้รับอนุญาตให้เข้าถึงวิธีการ O (1) Substring อย่างแท้จริง (โดยไม่ต้องเลื่อนประเภทสตริงของคุณเองซึ่งจะเป็นอุปสรรคต่อการทำงานร่วมกันกับไลบรารีอื่น ๆ ทุกแห่งและจะไม่มีประสิทธิภาพเท่ากับโซลูชันในตัว ) วิธีแก้ปัญหาของ Java อาจไม่มีประสิทธิภาพ (ต้องการวัตถุฮีปอย่างน้อยสองวัตถุอันหนึ่งสำหรับสตริงต้นฉบับและอีกอันสำหรับซับสตริง); ภาษาที่รองรับการแบ่งส่วนแทนที่วัตถุที่สองอย่างมีประสิทธิภาพด้วยตัวชี้คู่หนึ่งบนสแต็ก
Qwertie

10
ตั้งแต่ JDK 7u6 มันไม่เป็นความจริงอีกต่อไปตอนนี้ Java จะคัดลอกเนื้อหา String สำหรับแต่ละ.substring(...)รายการเสมอ
Xaerxess

12

Java ใช้เพื่ออ้างถึงสตริงที่ใหญ่กว่า แต่:

Java เปลี่ยนพฤติกรรมการคัดลอกเช่นกันเพื่อหลีกเลี่ยงหน่วยความจำรั่ว

ฉันรู้สึกว่ามันสามารถปรับปรุงได้แม้ว่า: ทำไมไม่ทำเพียงแค่การคัดลอกอย่างมีเงื่อนไข?

หากซับสตริงมีขนาดอย่างน้อยครึ่งหนึ่งของพาเรนต์หนึ่งสามารถอ้างอิงพาเรนต์ได้ มิฉะนั้นจะสามารถทำสำเนาได้ สิ่งนี้หลีกเลี่ยงการรั่วไหลของหน่วยความจำจำนวนมากในขณะที่ยังให้ประโยชน์ที่สำคัญ


การคัดลอกเสมอช่วยให้คุณสามารถลบอาร์เรย์ภายใน ลดจำนวนการจัดสรรฮีพลงครึ่งหนึ่งบันทึกหน่วยความจำในกรณีทั่วไปของสตริงสั้น ๆ นอกจากนี้ยังหมายความว่าคุณไม่จำเป็นต้องข้ามไปในทางอ้อมเพิ่มเติมสำหรับการเข้าถึงตัวละครแต่ละตัว
CodesInChaos

2
ผมคิดว่าสิ่งสำคัญที่จะต้องใช้เวลาจากนี้ก็คือ Java การเปลี่ยนแปลงจริงจากการใช้ฐานเดียวกันchar[](กับคำแนะนำที่แตกต่างกันที่จะเริ่มต้นและสิ้นสุด) Stringเพื่อสร้างใหม่ Stringนี้แสดงให้เห็นชัดเจนว่าการวิเคราะห์ต้นทุนและผลประโยชน์จะต้องแสดงการตั้งค่าสำหรับการสร้างใหม่
Phylogenesis

2

ไม่มีคำตอบที่ระบุที่นี่ "ปัญหาการถ่ายคร่อม" ซึ่งจะบอกว่าสตริงใน. NET จะแสดงเป็นชุดของ BStr (ความยาวที่เก็บไว้ในหน่วยความจำ "ก่อน" ตัวชี้) และ CStr (สตริงสิ้นสุดใน '\ 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

(หากกำหนดให้ a char*ใน - สถานะfixedตัวชี้จะชี้ไปที่ 0x48)

โครงสร้างนี้อนุญาตให้ค้นหาความยาวของสตริงได้อย่างรวดเร็ว (มีประโยชน์ในบริบทจำนวนมาก) และอนุญาตให้ตัวชี้ถูกส่งผ่านใน P / Invoke to Win32 (หรืออื่น ๆ ) APIs ซึ่งคาดว่าสตริงที่สิ้นสุดด้วยค่า 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>"substring" ร้านค้าที่มีความยาวเป็นอิสระและมันก็ไม่ได้รับประกันว่ามี '\ 0' หลังจากการสิ้นสุดของค่า มันสามารถใช้งานได้หลายวิธี "เหมือนสตริง" แต่ไม่ใช่ "สตริง" เนื่องจากไม่มีคุณสมบัติ BStr หรือ CStr (ทั้งสองอย่างน้อยกว่า) หากคุณไม่เคย (โดยตรง) P / เรียกใช้มีความแตกต่างไม่มากนัก (ยกเว้นว่า API ที่คุณต้องการโทรไม่มีReadOnlySpan<char>ภาระงานมากเกินไป)

ReadOnlySpan<char>ไม่สามารถนำมาใช้เป็นข้อมูลอ้างอิงจากประเภทนี้ดังนั้นยังมีReadOnlyMemory<char>( s.AsMemory(0, 5)) ซึ่งเป็นทางอ้อมของการมีReadOnlySpan<char>ดังนั้นเดียวกันแตกต่าง-from- stringมีอยู่

บางคำตอบ / ความคิดเห็นเกี่ยวกับคำตอบก่อนหน้านี้พูดคุยกันว่าการสิ้นเปลืองเพื่อให้ตัวเก็บขยะต้องเก็บสตริงล้านอักขระไว้รอบในขณะที่คุณพูดต่อไปประมาณ 5 ตัวอักษร นั่นคือพฤติกรรมที่คุณจะได้รับเมื่อReadOnlySpan<char>เข้าใกล้ หากคุณเป็นเพียงการคำนวณสั้น ๆ แนวทาง ReadOnlySpan น่าจะดีกว่า หากคุณต้องการคงอยู่ชั่วขณะหนึ่งและคุณจะเก็บเพียงเล็กน้อยของสตริงเดิมการทำสตริงย่อยที่เหมาะสม (เพื่อตัดข้อมูลส่วนเกิน) น่าจะดีกว่า มีจุดเปลี่ยนอยู่ตรงกลาง แต่ขึ้นอยู่กับการใช้งานเฉพาะของคุณ

โดยการใช้ไซต์ของเรา หมายความว่าคุณได้อ่านและทำความเข้าใจนโยบายคุกกี้และนโยบายความเป็นส่วนตัวของเราแล้ว
Licensed under cc by-sa 3.0 with attribution required.