โครงสร้างข้อมูล Trie: อัญมณีที่ถูกทอดทิ้ง

เผยแพร่แล้ว: 2022-03-11

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

เกมคำศัพท์เป็นตัวอย่างทดลอง

ขั้นแรก ให้พิจารณาปริศนาคำศัพท์ง่ายๆ: ค้นหาคำที่ถูกต้องทั้งหมดในกระดานตัวอักษรขนาด 4x4 เชื่อมต่อตัวอักษรที่อยู่ติดกันในแนวนอน แนวตั้ง หรือแนวทแยงมุม ตัวอย่างเช่น ในกระดานต่อไปนี้ เราเห็นตัวอักษร 'W', 'A', 'I' และ 'T' เชื่อมต่อกันเพื่อสร้างคำว่า "WAIT"

ปริศนาคำศัพท์ง่ายๆ

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

ตอนนี้ เป้าหมายของเราคือการค้นหาโครงสร้างข้อมูลที่ดีที่สุดเพื่อใช้ตัวตรวจสอบคำที่ถูกต้อง กล่าวคือ คำศัพท์ของเรา ข้อควรจำบางประการ:

  • เราต้องการสำเนาคำแต่ละคำเพียงชุดเดียว กล่าวคือ คำศัพท์ของเราเป็นชุด จากมุมมองเชิงตรรกะ
  • เราจำเป็นต้องตอบคำถามต่อไปนี้สำหรับคำที่กำหนด:
    • ลำดับอักขระปัจจุบันประกอบด้วยคำที่ถูกต้องหรือไม่
    • มีคำที่ยาวขึ้นที่ขึ้นต้นด้วยลำดับนี้หรือไม่? หากไม่เป็นเช่นนั้น เราสามารถละทิ้งการสำรวจเชิงลึกเป็นอันดับแรก เนื่องจากการลงลึกลงไปจะไม่ได้รับคำที่ถูกต้อง

เพื่ออธิบายประเด็นที่สอง ให้พิจารณากระดานต่อไปนี้: ไม่มีประเด็นใดที่จะสำรวจการเคลื่อนไหวครั้งต่อๆ ไป เนื่องจากไม่มีคำในพจนานุกรมที่ขึ้นต้นด้วย “ASF”

ไม่มีอะไรขึ้นต้นด้วย asf

เราอยากให้โครงสร้างข้อมูลของเราตอบคำถามเหล่านี้โดยเร็วที่สุด ~O(1) เวลาเข้าถึง (สำหรับตรวจสอบลำดับ) จะเหมาะที่สุด!

เราสามารถกำหนดอินเทอร์เฟซคำศัพท์ได้ (ดูที่นี่สำหรับ GitHub repo):

 public interface Vocabulary { boolean add(String word); boolean isPrefix(String prefix); boolean contains(String word); }

ทดลองโครงสร้างข้อมูลกับทางเลือกอื่น

การใช้เมธอด contain( contains() ต้องใช้โครงสร้างข้อมูลสำรองที่ช่วยให้คุณค้นหาองค์ประกอบได้อย่างมีประสิทธิภาพ ในขณะที่ isPrefix() กำหนดให้เราต้องค้นหา "องค์ประกอบที่ใหญ่กว่าถัดไป" กล่าวคือ เราจำเป็นต้องจัดเรียงคำศัพท์ด้วยวิธีใดวิธีหนึ่ง

เราสามารถแยกชุดที่ใช้ hash-based ออกจากรายชื่อผู้สมัครได้อย่างง่ายดาย: ในขณะที่โครงสร้างดังกล่าวจะทำให้เราตรวจสอบตลอดเวลาสำหรับ contain( contains() มันจะทำงานได้ค่อนข้างแย่ใน isPrefix() ในกรณีที่เลวร้ายที่สุดคือเราต้องสแกนทั้งชุด ชุด.

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

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

เราสามารถใช้คำศัพท์ที่ได้รับการสนับสนุนจากอาร์เรย์ซึ่งจะรักษาการเรียงลำดับเช่นนี้ไว้เสมอ โดยใช้ java.util.ArrayList มาตรฐาน และ java.util.Collections.binarySeach :

 public class ListVocabulary implements Vocabulary { private List<String> words = new ArrayList<String>(); /** * Constructor that adds all the words and then sorts the underlying list */ public ListVocabulary(Collection<String> words) { this.words.addAll(words); Collections.sort(this.words); } public boolean add(String word) { int pos = Collections.binarySearch(words, word); // pos > 0 means the word is already in the list. Insert only // if it's not there yet if (pos < 0) { words.add(-(pos+1), word); return true; } return false; } public boolean isPrefix(String prefix) { int pos = Collections.binarySearch(words, prefix) ; if (pos >= 0) { // The prefix is a word. Check the following word, because we are looking // for words that are longer than the prefix if (pos +1 < words.size()) { String nextWord = words.get(pos+1); return nextWord.startsWith(prefix); } return false; } pos = -(pos+1); // The prefix is not a word. Check where it would be inserted and get the next word. // If it starts with prefix, return true. if (pos == words.size()) { return false; } String nextWord = words.get(pos); return nextWord.startsWith(prefix); } public boolean contains(String word) { int pos = Collections.binarySearch(words, word); return pos >= 0; } }

หากเราตัดสินใจใช้ไบนารีทรี การใช้งานอาจสั้นลงและสวยงามยิ่งขึ้น (อีกครั้ง นี่คือลิงก์ไปยังโค้ด):

 public class TreeVocabulary extends TreeSet<String> implements Vocabulary { public TreeVocabulary(Collection<String> c) { super(c); } public boolean isPrefix(String prefix) { String nextWord = ceiling(prefix); if (nextWord == null) { return false; } if (nextWord.equals(prefix)) { Set<String> tail = tailSet(nextWord, false); if (tail.isEmpty()) { return false; } nextWord = tail.iterator().next(); } return nextWord.startsWith(prefix); } /** * There is a mismatch between the parameter types of vocabulary and TreeSet, so * force call to the upper-class method */ public boolean contains(String word) { return super.contains(word); } }

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

แอปพลิเคชัน Trie: เมื่อใดและเหตุใดจึงควรใช้ Tries

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

  • เราสามารถสรุปได้อย่างปลอดภัยว่าทุกคำเป็นตัวพิมพ์เล็ก
  • เรารับเฉพาะตัวอักษร az เท่านั้น ไม่มีเครื่องหมายวรรคตอน ไม่มีขีดกลาง ไม่มีเครื่องหมายเน้นเสียง ฯลฯ
  • พจนานุกรมประกอบด้วยรูปแบบผันแปรหลายรูปแบบ: พหูพจน์ กริยาผัน คำประกอบ (เช่น บ้าน –> แม่บ้าน) ดังนั้น หลายคำจึงมีต้นกำเนิดเดียวกัน
  • คำมีความยาวจำกัด ตัวอย่างเช่น หากเรากำลังทำงานกับบอร์ด 4x4 คำทั้งหมดที่ยาวเกิน 16 ตัวอักษรสามารถทิ้งได้

นี่คือที่มาของ trie (ออกเสียงว่า "try") แต่จริงๆ แล้ว trie คือ อะไรกันแน่? ความพยายามเป็นโครงสร้างข้อมูลที่ถูกละเลย ซึ่งพบในหนังสือ แต่ไม่ค่อยพบในห้องสมุดมาตรฐาน

สำหรับแรงจูงใจ อันดับแรก ให้พิจารณาลูกโปสเตอร์ของวิทยาการคอมพิวเตอร์: ต้นไม้ไบนารี ตอนนี้ เมื่อเราวิเคราะห์ประสิทธิภาพของไบนารีทรีและบอกว่า operation x คือ O(log(n)) เรากำลังพูดถึง log ฐาน 2 อยู่ตลอดเวลา แต่ถ้าเราใช้ ternary tree แทนไบนารีทรีล่ะ ทุกโหนดมีลูกสามคน (หรือแฟนจากสามคน) จากนั้น เรากำลังพูดถึงบันทึกฐาน 3 (นั่นคือการปรับปรุงประสิทธิภาพ แม้ว่าจะมีเพียงปัจจัยคงที่เท่านั้น) โดยพื้นฐานแล้ว ต้นไม้ของเราจะกว้างขึ้นแต่สั้นลง และเราสามารถทำการค้นหาน้อยลงเนื่องจากเราไม่ต้องลงมาเลย ลึกมาก.

ก้าวไปอีกขั้น ถ้าเรามีต้นไม้ที่มีการกระจายเท่ากับจำนวนค่าที่เป็นไปได้ของประเภทข้อมูลของเรา

นี่คือแรงจูงใจเบื้องหลังการทดลอง และอย่างที่คุณอาจเดาได้แล้วว่า Trie นั้นเป็นต้นไม้จริง ๆ จริงๆ แล้ว Trie ก็คือต้นไม้ Trie นั่นเอง!

แต่ตรงกันข้ามกับไบนารีทรีส่วนใหญ่ที่คุณใช้สำหรับการเรียงลำดับสตริง ที่เก็บคำทั้งหมดไว้ในโหนดของพวกเขา แต่ละโหนดของ trie มีอักขระตัวเดียว (และไม่ใช่เช่นนั้น อย่างที่เราจะเห็นในเร็วๆ นี้) และ มีการกระจายสูงสุดเท่ากับความยาวของตัวอักษร ในกรณีของเรา ความยาวของตัวอักษรคือ 26; ดังนั้นโหนดของ trie จึงมี fan-out สูงสุดที่ 26 และในขณะที่ไบนารีทรีที่สมดุลมี log2(n) ความลึก ความลึกสูงสุดของ trie จะเท่ากับความยาวสูงสุดของคำ! (อีกครั้งกว้างขึ้น แต่สั้นลง)

ภายในการทดลอง คำที่มีต้นกำเนิดเดียวกัน (คำนำหน้า) แบ่งพื้นที่หน่วยความจำที่สอดคล้องกับต้นกำเนิด

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

นึกภาพไตร่ตรอง

การใช้งาน Java Trie

ดังที่เราทราบ ในแผนผังนั้น ตัวชี้ไปยังองค์ประกอบลูกมักจะใช้กับตัวแปรซ้ายและขวา เนื่องจากการกระจายสูงสุดคงที่ที่สองตัว

ในการพยายามสร้างดัชนีตัวอักษร 26 ตัว แต่ละโหนดมีลูกที่เป็นไปได้ 26 ตัว ดังนั้นจึงมีตัวชี้ที่เป็นไปได้ 26 ตัว แต่ละโหนดจึงมีอาร์เรย์ของทรีย่อย 26 (ตัวชี้ไปยัง) ซึ่งแต่ละค่าอาจเป็นโมฆะ (หากไม่มีลูกดังกล่าว) หรือโหนดอื่น

แล้วเราจะค้นหาคำในการทดลองได้อย่างไร? นี่คือวิธีการที่ ให้ String s จะระบุโหนดที่สอดคล้องกับอักษรตัวสุดท้ายของคำ หากมีอยู่ในทรี:

 public LowercaseTrieVocabulary getNode(String s) { LowercaseTrieVocabulary node = this; for (int i = 0; i < s.length(); i++) { int index = LOWERCASE.getIndex(s.charAt(i)); LowercaseTrieVocabulary child = node.children[index]; if (child == null) { // There is no such word return null; } node = child; } return node; }

LOWERCASE.getIndex(s.charAt(i)) วิธีการจะคืนค่าตำแหน่งของอักขระ ith ในตัวอักษร บนโหนดที่ส่งคืน node คุณสมบัติบูลีนระบุว่าโหนดนั้นสอดคล้องกับตัวอักษรสุดท้ายของคำ กล่าวคือ ตัวอักษรที่มีเครื่องหมายสีแดงในตัวอย่างก่อนหน้านี้ เนื่องจากแต่ละโหนดเก็บตัวนับจำนวนลูกไว้ หากตัวนับนี้เป็นค่าบวก พจนานุกรมจะมีคำที่ยาวกว่าที่มีสตริงปัจจุบันเป็นคำนำหน้า หมายเหตุ: โหนดไม่จำเป็นต้องอ้างอิงถึงอักขระที่สัมพันธ์กันจริง ๆ เพราะมันอยู่ในตำแหน่งโดยนัยในการทดสอบ

กำลังวิเคราะห์ประสิทธิภาพ

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

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

เมื่อพิจารณาถึงข้อกำหนดด้านพื้นที่ (และจำได้ว่าเราได้ระบุขนาดไบต์ของพจนานุกรมด้วย M ) กลุ่มทดลองอาจมีโหนด M ในกรณีที่เลวร้ายที่สุด ถ้าไม่มีสองสตริงร่วมกันคำนำหน้า แต่เนื่องจากเราสังเกตว่าพจนานุกรมมีความซ้ำซ้อนในระดับสูง จึงมีการบีบอัดจำนวนมากที่ต้องทำ พจนานุกรมภาษาอังกฤษที่ใช้ในโค้ดตัวอย่างคือ 935,017 ไบต์และต้องใช้โหนด 250,264 โดยมีอัตราการบีบอัดประมาณ 73%

อย่างไรก็ตาม ถึงกระนั้นก็ตาม การบีบอัดข้อมูลมักจะต้องการหน่วยความจำมากกว่าทรีหรืออาร์เรย์ เนื่องจากในแต่ละโหนดจำเป็นต้องมีไบต์ sizeof(pointer) อย่างน้อย 26 x บวกกับโอเวอร์เฮดสำหรับอ็อบเจ็กต์และแอตทริบิวต์เพิ่มเติม บนเครื่อง 64 บิต แต่ละโหนดต้องการมากกว่า 200 ไบต์ ในขณะที่อักขระสตริงต้องการไบต์เดียว หรือสองไบต์ หากเราพิจารณาสตริง UTF

ที่เกี่ยวข้อง: ข้อผิดพลาดทั่วไป 10 อันดับแรกที่นักพัฒนา Java ทำ: บทช่วยสอนสำหรับผู้เริ่มต้นใช้งาน Java

การทดสอบและการทดสอบประสิทธิภาพ

แล้วประสิทธิภาพล่ะ? การใช้งานคำศัพท์ได้รับการทดสอบในสองสถานการณ์ที่แตกต่างกัน: ตรวจสอบสตริงสุ่ม 20,000,000 ชุดและค้นหาคำศัพท์ทั้งหมดในกระดาน 15,000 รายการที่สร้างแบบสุ่มจากรายการคำศัพท์เดียวกัน

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

ผลการดำเนินงาน

จำนวนการเคลื่อนไหวเฉลี่ยในการแก้กระดานคือ 2,188 สำหรับการย้ายแต่ละครั้ง การค้นหาคำและการค้นหาคำนำหน้าจะเสร็จสิ้น กล่าวคือ สำหรับการตรวจสอบกระดานทั้งหมด มีการค้นหาคำมากกว่า 32 ล้านคำและการค้นหาคำนำหน้า 32 ล้านคำ หมายเหตุ: สิ่งเหล่านี้สามารถทำได้ในขั้นตอนเดียว ฉันแยกมันออกจากกันเพื่อความชัดเจนในการอธิบาย การอัดให้แน่นในขั้นตอนเดียวจะช่วยลดเวลาในการแก้กระดานได้เกือบครึ่งหนึ่ง และน่าจะชอบการทดลองนี้มากกว่า

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

ห่อ

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

อ้างอิง

คำอธิบายโดยละเอียดเกี่ยวกับการพยายามและตัวอักษรสามารถพบได้ในบทที่ 5 ของหนังสือ "Algorithms, 4th edition" ของ Robert Sedgewick เว็บไซต์ร่วมที่ Princeton มีรหัสสำหรับการติดตั้งใช้งานของ Alphabet และ TrieST ที่กว้างขวางกว่าตัวอย่างของฉัน

คำอธิบายการทดลองและการนำไปใช้สำหรับภาษาต่างๆ ยังสามารถพบได้ใน Wikipedia และคุณสามารถดูแหล่งข้อมูลการทดลองใช้ของมหาวิทยาลัยบอสตันได้เช่นกัน

ที่เกี่ยวข้อง: เข็มในกองหญ้า: บทแนะนำอัลกอริธึมการค้นหาข้อความขนาดใหญ่ที่ดี