คำอธิบายเกี่ยวกับความซับซ้อนเชิงเวลาและความซับซ้อนเชิงพื้นที่ของอัลกอริทึม

คำอธิบายเกี่ยวกับความซับซ้อนเชิงเวลาและความซับซ้อนเชิงพื้นที่ของอัลกอริทึม

ลองนึกภาพโปรแกรมสองโปรแกรมที่ทำงานเหมือนกันทุกประการ ป้อนข้อมูลพันรายการให้แต่ละโปรแกรม คุณจะไม่สามารถแยกแยะความแตกต่างได้ แต่ถ้าป้อนข้อมูลพันล้านรายการ โปรแกรมหนึ่งจะทำงานเสร็จก่อนที่คุณจะปล่อยปุ่ม Enter ในขณะที่อีกโปรแกรมหนึ่งยังคงทำงานต่อไปจนถึงวันอังคารหน้า

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

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

ความซับซ้อนเชิงเวลาของอัลกอริทึมคืออะไร?

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

นี่คือฟังก์ชันที่น่าเบื่อที่สุดเท่าที่จะนึกออก:

python def print_all(items): for item in items: print(item)

สิบชิ้น? วนรอบสิบครั้ง หมื่นชิ้น? วนรอบหมื่นครั้ง

การทำงานจะเพิ่มขึ้นเป็นเส้นตรงตามอินพุต ดังนั้นเราจึงเรียกว่า O(n)

ทีนี้ บางทีการเดินทางแต่ละครั้งอาจต้องใช้การดำเนินการเล็กๆ สามครั้งแทนที่จะเป็นครั้งเดียวก็ได้ เอาล่ะ นั่นก็คือ 3n เอาจริงๆ แล้วไม่มีใครสนใจหรอก เพราะการเพิ่มรายการเป็นสองเท่าก็ทำให้งานเพิ่มขึ้นเป็นสองเท่าอยู่ดี และเส้นกราฟการเติบโตนั่นแหละคือส่วนที่เราต้องการ ตัวเลขที่แน่นอนถูกละทิ้งไปโดยเจตนา

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

เหตุใดเวลาในการประมวลผลจึงเป็นตัววัดประสิทธิภาพของอัลกอริทึมที่ไม่ดี

แล้วทำไมไม่ลองใช้ตัวจับเวลาในการประมวลผลดูล่ะ? หลายคนก็ทำแบบนั้น และผมเข้าใจ แต่ปัญหาคือเวลาในการประมวลผลจะดึงเอาสิ่งต่างๆ มากมายที่อัลกอริทึมของคุณไม่เกี่ยวข้องเข้ามาเกี่ยวข้องด้วย:

  • ฮาร์ดแวร์ แล็ปท็อปอายุห้าปีของผมกับอุปกรณ์จัดเก็บข้อมูลบนคลาวด์ตัวใหม่เอี่ยมจะให้ผลลัพธ์ที่แตกต่างกันอย่างมากสำหรับสคริปต์เดียวกัน
  • ภาษาโปรแกรม เขียนลูปเดียวกันในภาษา C และ Python คุณอาจเห็นความแตกต่างถึง 10 เท่า หรือมากกว่านั้น
  • โหลดระบบ การ อัปเดตเบื้องหลังบางอย่างเริ่มทำงาน ตัวเก็บขยะทำงาน แคชถูกปิดใช้งาน ผลลัพธ์ของคุณจึงเคลื่อนที่
  • ไม่ว่าคุณจะหยิบข้อมูลป้อนเข้า แบบใดก็ตาม การเรียงลำดับที่เร็วกับข้อมูลที่เกือบเรียงลำดับแล้ว อาจช้าลงกับข้อมูลที่สลับตำแหน่งกัน

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

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

คำอธิบายเกี่ยวกับสัญลักษณ์ Big O และสัญลักษณ์เชิงอะซิมโทติกอื่นๆ

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

สัญกรณ์ มันอธิบายถึงอะไร ความหมายตรงไปตรงมา ตัวอย่าง
บิ๊กโอ, โอ(ฟ(น)) ขอบเขตบน อัลกอริทึมเติบโตไม่เร็วกว่า f(n) การค้นหาเชิงเส้นคือ O(n)
บิ๊กโอเมก้า, Ω(f(n)) ขอบล่าง อัลกอริทึมนี้ต้องการงานอย่างน้อย f(n) ครั้ง การเรียงลำดับแบบเปรียบเทียบใดๆ ก็ตามคือ Ω(n log n)
บิ๊กธีตา, Θ(f(n)) ผูกแน่น ขอบเขตบนและล่างตรงกัน การเรียงลำดับแบบผสาน (Merge sort) มีประสิทธิภาพ Θ(n log n)

ในทางปฏิบัติ คุณจะได้ยินการใช้สัญลักษณ์ Big O ประมาณ 95% ของเวลาทั้งหมด มันเป็นการกำหนดขอบเขตบน หมายความว่าเวลาในการทำงานจะไม่เพิ่มขึ้นเร็วกว่าอัตราที่ระบุไว้ในวงเล็บ ในการตรวจสอบโค้ดครั้งแรกของผมในที่ทำงานจริง นักพัฒนาอาวุโสคนหนึ่งเขียนว่า "O(n²), โปรดแก้ไข" ไว้ข้างๆ ลูปซ้อนกัน และสิ่งที่พวกเขาหมายถึงก็คือ ความซับซ้อนของเวลาในกรณีที่เลวร้ายที่สุดคือแบบกำลังสอง และรายการที่จะแก้ไขนั้นกำลังจะยาวขึ้นมาก

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

มีรายละเอียดทางเทคนิคอย่างหนึ่งที่ทำให้ผมสับสนอยู่พักใหญ่ เนื่องจาก Big O เป็นเพียงค่าสูงสุด ดังนั้นฟังก์ชัน O(n) จึงเป็น O(n²) อย่างเคร่งครัด เหมือนกับที่คนสูง 5 ฟุต "ต่ำกว่า 7 ฟุต" จริงอยู่แต่ก็ไร้ประโยชน์ ทุกคนมักจะยกขอบเขตที่แคบที่สุดเท่าที่จะหาเหตุผลมาสนับสนุนได้ และผมก็จะทำเช่นเดียวกัน

ความซับซ้อนเชิงเวลาและความซับซ้อนเชิงพื้นที่

คลาสความซับซ้อนของเวลาทั่วไป พร้อมตัวอย่าง

ในโค้ดจริง คุณจะได้เห็นคลาสไม่มากนัก อาจจะแค่เจ็ดคลาสถ้าหากนับรวมคลาสที่ซับซ้อนด้วย นี่คือค่าใช้จ่ายโดยประมาณของแต่ละคลาสเมื่อ n = 1,000:

ระดับ ชื่อ ตัวอย่างทั่วไป การดำเนินงานที่ n = 1,000
O(1) คงที่ ดัชนีอาร์เรย์ การค้นหาในตารางแฮช 1
O(log n) ลอการิทึม การค้นหาแบบไบนารี ~10
บน) เชิงเส้น การสแกนรายการ 1,000
O(n log n) ลิเนียร์ลิธมิค การเรียงลำดับแบบผสาน (Merge sort), การเรียงลำดับแบบเร็ว (Quick sort) (ค่าเฉลี่ย) ~10,000
โอ(n²) กำลังสอง ลูปซ้อนกัน, การเรียงลำดับแบบบับเบิล 1,000,000
O(2ⁿ) เลขชี้กำลัง ลำดับฟิโบนาชชีแบบเรียกซ้ำอย่างง่าย มากกว่าอะตอมในจักรวาล
บน!) แฟกทอเรียล พนักงานขายเดินทางแบบใช้กำลังเข้าสู้ ใหญ่กว่านั้นอีก

O(1): เวลาคงที่

ไม่ว่าพจนานุกรม Python ของคุณจะมีคีย์สิบตัวหรือสิบล้านตัว การดึงคีย์ออกมาหนึ่งตัวก็มีค่าใช้จ่ายประมาณเท่ากัน และการอ่านรายการ[5] จากรายการก็เช่นกัน ความราบเรียบนั้นคือจุดประสงค์ทั้งหมดของเวลาคงที่

O(log n): เวลาเชิงลอการิทึม

ผมคิดว่านี่คืออัลกอริธึมประเภท "เดาตัวเลขระหว่าง 1 ถึง 100" ซึ่งการเดาแต่ละครั้งจะตัดตัวเลือกที่เหลือออกไปครึ่งหนึ่ง การค้นหาแบบไบนารีก็ทำแบบนั้นกับรายการที่เรียงลำดับแล้วเช่นกัน:

python def binary_search(arr, target): lo, hi = 0, len(arr) - 1 while lo <= hi: mid = (lo + hi) // 2 if arr[mid] == target: return mid if arr[mid] < target: lo = mid + 1 else: hi = mid - 1 return -1

เมื่อมีรายการที่จัดเรียงแล้วเป็นพันล้านรายการ จะต้องเปรียบเทียบประมาณ 30 ครั้ง ในขณะที่การเปรียบเทียบทีละรายการอาจใช้เวลาถึงพันล้านรายการ และผมก็ยังคงรู้สึกว่ามันตลกอยู่เสมอ

O(n) และ O(n log n)

อัลกอริทึมเชิงเส้นจะตรวจสอบแต่ละองค์ประกอบเป็นจำนวนครั้งคงที่ เหมือนกับการค้นหาตัวเลขที่ใหญ่ที่สุดในคอลัมน์ของสเปรดชีตด้วยสายตา O(n log n) คืออัลกอริทึมการเรียงลำดับที่ดี ส่วนการเรียงลำดับแบบผสาน (Merge sort) จะแบ่งรายการออกเป็นรายการเดี่ยวๆ (ระดับการแบ่ง log n) จากนั้นจะทำการประมวลผลเชิงเส้นหนึ่งครั้งต่อระดับในขณะที่ผสานเข้าด้วยกัน ดังนั้นเวลาในการทำงานจึงคงที่ ไม่ว่าข้อมูลจะเรียงลำดับแล้วหรือไม่ก็ตาม

O(n²) และแย่กว่านั้น

โค้ดแบบกำลังสองมักจะมีลูปซ้อนอยู่ในลูปอีกอันเสมอ เช่น การตรวจสอบค่าซ้ำที่เปรียบเทียบทุกคู่ดังนี้:

python def has_duplicate(items): for i in range(len(items)): for j in range(i + 1, len(items)): if items[i] == items[j]: return True return False

การเติบโตแบบเลขชี้กำลังและแบบแฟกทอเรียลแย่กว่ามาก สูตรฟิโบนาชี่แบบเวียนซ้ำในตำราเรียนจะแก้ปัญหาเดิมซ้ำแล้วซ้ำเล่า ดังนั้นทุกๆ การเพิ่ม n ขึ้น 1 จะทำให้งานเพิ่มขึ้นเป็นสองเท่าโดยประมาณ ที่ n = 20 คุณจะไม่สังเกตเห็นความเปลี่ยนแปลง แต่ที่ n = 60 คุณสามารถไปทำอาหารเย็น กินข้าว ล้างจาน แล้วงานก็ยังไม่เสร็จอยู่ดี

วิธีการคำนวณความซับซ้อนของเวลาทีละขั้นตอน

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

  1. กำหนดค่า n และ ระบุว่าขนาดของข้อมูลนำเข้าคืออะไร เช่น ความยาวของลิสต์ จำนวนโหนดในกราฟ หรือจำนวนหลักในตัวเลข
  2. นับปริมาณงานในแต่ละบรรทัด คำสั่งง่ายๆ (การกำหนดค่า การเปรียบเทียบ การคำนวณทางคณิตศาสตร์) ใช้ทรัพยากร O(1)
  3. เพิ่มบล็อกตามลำดับ ลูป สองลูปต่อกัน โดยแต่ละลูปวนซ้ำ n จะได้ n + n = 2n
  4. คูณ บล็อกที่ซ้อนกัน การ วนลูปผ่าน n ภายในลูปผ่าน n อีกครั้งจะให้ผลลัพธ์เป็น n × n = n²
  5. ตัดค่าคงที่ออก 2n กลายเป็น n และ 500 กลายเป็น 1
  6. เก็บเฉพาะพจน์ที่เด่นที่สุดไว้ ใน n² + n ส่วนของ n² จะมีค่ามากกว่าส่วนที่เหลืออย่างมากเมื่อ n เพิ่มขึ้น

ตัวอย่างที่ทำได้อย่างรวดเร็ว จำนวนที่ได้คือ T(n) = 3n² + 5n + 2 แทนค่าหนึ่งล้านลงไป: 3n² คือสามล้านล้าน, 5n คือห้าล้าน เกิดข้อผิดพลาดจากการปัดเศษ เรียกได้ว่าเป็น O(n²) เสร็จแล้ว

ลูปที่กระโดดข้ามไปมาแทนที่จะก้าวทีละขั้นนั้นซับซ้อนกว่า:

python def count_halvings(n): steps = 0 while n > 1: n //= 2 steps += 1 return steps

ในแต่ละรอบการทำงาน n จะลดลงครึ่งหนึ่ง ดังนั้นลูป while นี้จะหยุดทำงานหลังจากประมาณ log₂(n) รอบ ซึ่งโดยพื้นฐานแล้วนี่คือคำตอบทั้งหมดของคำถามที่ว่า "เมื่อใดที่ความซับซ้อนของเวลาเป็น O(log n)?" ถ้าสิ่งที่เหลืออยู่ลดลงด้วยปัจจัยคงที่ในแต่ละขั้นตอน นั่นหมายความว่าถึงจุดนั้นแล้ว

การเรียกซ้ำต้องการเครื่องมืออีกอย่างหนึ่ง นั่นคือความสัมพันธ์เวียนเกิด การเรียงลำดับแบบผสาน (Merge sort) ทำงานขนาดครึ่งหนึ่งสองครั้งแล้วจึงผสานเข้าด้วยกัน ดังนั้น T(n) = 2T(n/2) + n ทฤษฎีบทหลัก (Master Theorem) เป็นเหมือนคู่มือลัดสำหรับความสัมพันธ์เวียนเกิดที่มีรูปร่างแบบนั้น และสำหรับความสัมพันธ์เวียนเกิดนี้ มันจะให้ผลลัพธ์เป็น O(n log n)

และกฎง่ายๆ จากการเขียนโปรแกรมเชิงแข่งขันที่ผมใช้บ่อยๆ คือ เครื่องคอมพิวเตอร์ทั่วไปจะประมวลผลการคำนวณง่ายๆ ได้ประมาณ 10⁸ ครั้งต่อวินาที เป็นการประมาณอย่างคร่าวๆ แต่ก็เพียงพอที่จะตรวจสอบความซับซ้อนของเวลาในการแก้ปัญหา ก่อนที่จะกดส่ง ถ้า n เท่ากับ 10⁵ O(n²) จะใช้เวลาประมาณ 10¹⁰ ครั้ง ดังนั้นจึงหมดเวลา ส่วน O(n log n) จะใช้เวลาน้อยกว่า 2 ล้านครั้ง จึงผ่านไปได้อย่างราบรื่น

การวิเคราะห์กรณีเลวร้ายที่สุด กรณีดีที่สุด และกรณีเฉลี่ย

หากป้อนข้อมูลที่แตกต่างกันให้กับอัลกอริทึมเดียวกัน ปริมาณงานก็อาจเปลี่ยนแปลงไปอย่างมาก การค้นหาเชิงเส้นแสดงให้เห็นสิ่งนี้ได้อย่างชัดเจน:

  • กรณีที่ดีที่สุด: สิ่งที่คุณต้องการจะอยู่ด้านหน้าสุด ถ้าเปรียบเทียบเพียงครั้งเดียว คุณก็ตกรอบไปเลย
  • กรณีที่แย่ที่สุด: มันอยู่ตอนท้ายสุด หรือไม่มีอยู่เลย นั่นคือการเปรียบเทียบ
  • กรณีเฉลี่ย: ถ้าเป็นไปได้ทุกที่ที่มีโอกาสเท่ากัน คุณจะตรวจสอบประมาณ n/2 โดยเฉลี่ย ซึ่งยังคงเป็น O(n) อยู่ดี

หากคุณถามนักพัฒนาซอฟต์แวร์เกี่ยวกับ "ความซับซ้อน" ของบางสิ่งบางอย่าง พวกเขามักจะบอกคุณถึงความซับซ้อนของเวลาในกรณีที่เลวร้ายที่สุด เพราะตัวเลขนั้นเป็นคำมั่นสัญญาที่คุณสามารถวางแผนได้

สมมติว่า API ของคุณประมวลผลคำขอได้ในเวลา O(n) ในกรณีที่แย่ที่สุด เยี่ยมเลย ตอนนี้คุณรู้แล้วว่าคำขอที่ช้าที่สุดทำงานอย่างไร และคุณก็พร้อมรับมือเมื่อมีคนส่งข้อมูลขยะมาให้คุณโดยเจตนา แน่นอนว่ามีคนทำแบบนี้: ถ้าผู้โจมตีรู้ว่าคีย์ใดทำให้ตารางแฮชของคุณเกิดการชนกัน คีย์เหล่านั้นจะเป็นคีย์เดียวที่คุณจะได้เห็นไปสักพัก

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

ความซับซ้อนของพื้นที่และพื้นที่เสริมคืออะไร?

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

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

ตัวอย่างกรณีศึกษาอย่างรวดเร็ว:

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

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

การเพิ่มประสิทธิภาพส่วนใหญ่ก็คือการแลกเปลี่ยนหน่วยความจำกับเวลา หรือในทางกลับกัน การจดจำผลลัพธ์ (Memoization) จะแคชผลลัพธ์ที่คุณคำนวณไว้แล้ว ซึ่งทำให้การคำนวณแบบ Fibonacci ง่ายๆ ใช้เวลาน้อยลงจาก O(2ⁿ) เหลือ O(n) โดยแลกกับหน่วยความจำ O(n) ตารางแฮชช่วยให้คุณค้นหาข้อมูลได้โดยเฉลี่ย O(1) โดยใช้พื้นที่กับบัคเก็ต บนเซิร์ฟเวอร์ที่มี RAM 64 GB การแลกเปลี่ยนแบบนี้มักจะคุ้มค่า แต่บนไมโครคอนโทรลเลอร์หรือภายในสัญญาอัจฉริยะ คุณอาจต้องคิดให้ดีก่อน

ความซับซ้อนเชิงเวลาและความซับซ้อนเชิงพื้นที่

ความซับซ้อนด้านเวลาและพื้นที่ของโครงสร้างข้อมูลทั่วไป

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

โครงสร้างข้อมูล เข้าถึง ค้นหา แทรก ลบ ช่องว่าง
อาร์เรย์ O(1) บน) บน) บน) บน)
รายการเชื่อมโยง บน) บน) O(1) O(1) บน)
ตารางแฮช ไม่มีข้อมูล O(1) O(1) O(1) บน)
ต้นไม้ค้นหาไบนารีที่สมดุล O(log n) O(log n) O(log n) O(log n) บน)
ฮีปไบนารี O(1) สำหรับ min/max บน) O(log n) O(log n) บน)

หมายเหตุสองข้อ การแทรกและการลบในลิสต์แบบเชื่อมโยงใช้เวลาเพียง O(1) หากคุณถือโหนดนั้นอยู่แล้ว และตารางแฮชอาจใช้เวลาถึง O(n) เมื่อมีคีย์จำนวนมากชนกัน

อัลกอริทึมการเรียงลำดับมีความหลากหลายมากเช่นกัน:

อัลกอริทึมการเรียงลำดับ ดีที่สุด เฉลี่ย แย่ที่สุด พื้นที่เสริม
การเรียงลำดับแบบฟอง บน) โอ(n²) โอ(n²) O(1)
การเรียงลำดับแบบผสาน O(n log n) O(n log n) O(n log n) บน)
เรียงลำดับเร็ว O(n log n) O(n log n) โอ(n²) O(log n)
การเรียงลำดับแบบฮีป O(n log n) O(n log n) O(n log n) O(1)

การเรียงลำดับแบบผสาน (Merge sort) คาดเดาผลลัพธ์ได้ แต่ต้องการหน่วยความจำเพิ่มเติม การเรียงลำดับแบบฮีป (Heap sort) เรียงลำดับในตำแหน่งเดิมและคงกรณีที่เลวร้ายที่สุดไว้เหมือนเดิม ส่วนการเรียงลำดับแบบเร็ว (Quicksort) แม้จะมีข้อจำกัดที่ซับซ้อนกว่า แต่โดยทั่วไปแล้วมักจะได้ผลดีกว่าในทางปฏิบัติ

ความซับซ้อนเชิงเวลาในบล็อกเชนและสัญญาอัจฉริยะ

นี่แหละคือจุดที่สนุก บนเซิร์ฟเวอร์ของคุณเอง อัลกอริทึมที่หละหลวมจะสิ้นเปลือง CPU และอาจทำให้ผู้ใช้รำคาญ แต่บน Ethereum หรือเครือข่าย EVM ใดๆ มันจะสิ้นเปลืองเงิน ทุกคำสั่งที่สัญญาอัจฉริยะดำเนินการจะเสียค่าธรรมเนียมแก๊ส ซึ่งจ่ายด้วยเหรียญดั้งเดิมของเครือข่าย ดังนั้นจำนวนการดำเนินการของคุณจะปรากฏเป็นรายการในบิลของใครบางคน กล่าวอีกนัยหนึ่ง ความซับซ้อนของเวลาที่ไม่ดีนั้นมาพร้อมกับราคาที่ต้องจ่าย

สมมติว่าคุณมีสัญญาที่วนลูปจ่ายรางวัลให้กับผู้ถือโทเค็นทุกราย O(n) และค่าธรรมเนียมแก๊สจะเพิ่มขึ้นเรื่อย ๆ ทุกครั้งที่มีผู้ถือโทเค็นรายใหม่เข้ามา ในวันเปิดตัวไม่มีใครสังเกตเห็น

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

วิธีแก้ปัญหาคือการคิดเชิงซับซ้อนอย่างแท้จริงและตรงไปตรงมา:

  • แบ่งงานออกเป็นกลุ่มย่อยขนาดคงที่หลายกลุ่ม โดยแต่ละกลุ่มจะทำผ่านธุรกรรมหลายรายการ
  • พลิกกลับด้านเพื่อให้ผู้ใช้แต่ละคนรับรางวัลของตนเอง การเรียกใช้แต่ละครั้งใช้เวลา O(1)
  • เปลี่ยนจากการตรวจสอบด้วยลูปเป็นการตรวจสอบด้วยเมอร์เคิล

ฉันชอบ Merkle proofs ในฐานะตัวอย่างที่แสดงให้เห็นว่าทำไม log n ถึงมีความสำคัญมากบนบล็อกเชน Merkle tree จะทำการแฮชข้อมูลจำนวนมากให้ไปอยู่ในแฮชรากเดียว

ในการพิสูจน์ว่ารายการหนึ่งเป็นส่วนหนึ่งของเซตที่มี n รายการ คุณต้องใช้แฮชประมาณ log₂(n) ครั้ง สำหรับรายการหนึ่งล้านรายการ ก็ประมาณ 20 ครั้ง ไม่ใช่หนึ่งล้านรายการ การแจกเหรียญฟรี (Airdrop) อาศัยหลักการนี้ และกระเป๋าเงิน Bitcoin แบบเบา (Light Wallet) ก็ใช้เทคนิคเดียวกันนี้ในการยืนยันธุรกรรมโดยไม่ต้องดาวน์โหลดบล็อกทั้งหมด

นอกเครือข่ายก็เช่นเดียวกัน ลองนึกภาพระบบแบ็กเอนด์การชำระเงินที่จับคู่ธุรกรรมบล็อกเชนขาเข้าแต่ละรายการกับใบแจ้งหนี้โดยการสแกนใบแจ้งหนี้ที่ยังเปิดอยู่ทั้งหมด การประมวลผลต่อการชำระเงินหนึ่งครั้งจะเป็น O(n) และจะช้าลงเมื่อร้านค้าเติบโตขึ้น หากนำใบแจ้งหนี้ไปใส่ในแผนที่แฮชโดยใช้ที่อยู่หรือรหัสเป็นคีย์ การจับคู่แต่ละครั้งจะเป็น O(1) และหากคุณไม่อยากสร้างและดูแลระบบนั้นด้วยตัวเอง เกตเวย์การชำระเงินคริปโตอย่าง Plisio ก็จะจัดการให้คุณได้

มีคำถามอะไรไหม?

ไม่ เพราะมันนับการทำงานโดยสัมพันธ์กับขนาดของข้อมูลนำเข้า และฮาร์ดแวร์ไม่มีส่วนเกี่ยวข้องกับการนับนั้น สิ่งที่คุณจะเห็นแตกต่างกันไปในแต่ละเครื่องคือเวลาในการประมวลผล CPU ที่เร็วจะทำงาน O(n²) เสร็จเร็วกว่า CPU ที่ช้า แต่ถ้าเพิ่มข้อมูลนำเข้าเป็นสองเท่า งานก็จะเพิ่มขึ้นประมาณสี่เท่า

เวลาคงที่ O(1) เนื่องจากงานไม่เพิ่มขึ้นเลย เวลาแบบลอการิทึม O(log n) เป็นอันดับสองรองลงมา และสามารถจัดการรายการหนึ่งพันล้านรายการได้ในประมาณ 30 ขั้นตอน ถัดมาคือเชิงเส้น จากนั้นคือ O(n log n) ตามด้วยกำลังสอง และคลาสเลขชี้กำลังอยู่ท้ายสุด

เมื่ออินพุตมีขนาดใหญ่ขึ้น ก็ใช่ แต่ถ้าอินพุตมีขนาดเล็ก ผลลัพธ์อาจเป็นได้ทั้งสองทาง Big O ไม่สนใจปัจจัยคงที่ ดังนั้นการเรียกใช้ O(1) ที่ใช้ขั้นตอน 10,000 ขั้นตอน อาจแพ้การวนลูป O(n) ที่ประมวลผล 50 รายการ ซึ่งเป็นเหตุผลว่าทำไมการสแกนอาร์เรย์ขนาดเล็กบางครั้งจึงดีกว่าการค้นหาในตารางแฮช

ความซับซ้อนเชิงเวลาอธิบายว่าจำนวนการดำเนินการเพิ่มขึ้นอย่างไรเมื่อข้อมูลป้อนเข้าเพิ่มขึ้น และความซับซ้อนเชิงพื้นที่อธิบายว่าหน่วยความจำเพิ่มขึ้นอย่างไร การเรียงลำดับแบบผสาน (Merge sort) รวดเร็วแต่ต้องการอาร์เรย์เพิ่มเติม O(n) ในขณะที่การเรียงลำดับแบบฟอง (Bubble sort) ช้าและใช้พื้นที่ O(1) ดังนั้นทั้งสองจึงไม่จำเป็นต้องนำมาใช้ร่วมกัน

สรุปแล้วขึ้นอยู่กับวิธีการเปลี่ยนแปลงตัวแปรในลูป การเพิ่มทีละ 1 จนถึง n จะได้ประสิทธิภาพ O(n) การเพิ่มเป็นสองเท่าหรือครึ่งหนึ่งในแต่ละรอบจะได้ประสิทธิภาพ O(log n) และหากมีลูปวนซ้ำรอบ n อยู่ภายในตัวลูปเอง ประสิทธิภาพก็จะเพิ่มขึ้นเป็น O(log n) เช่นกัน

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

Ready to Get Started?

Create an account and start accepting payments – no contracts or KYC required. Or, contact us to design a custom package for your business.

Make first step

Always know what you pay

Integrated per-transaction pricing with no hidden fees

Start your integration

Set up Plisio swiftly in just 10 minutes.