Notes & software courses · Free to learn
Aph's Blog
On this page

ข้อ 71 · LC1268 Search Suggestions System (ระบบแนะนำคำค้น) 🟡

👋 อ่านฟรีทั้งหมดบน Aph's Blog — เนื้อหาภาษาไทย ทำตามทีละหน้าใน sidebar ได้เลย หากมีข้อเสนอแนะหรืออยากให้เพิ่มหัวข้อไหน บอกได้เสมอ

แนะนำสินค้าไม่เกิน 3 ชื่อตาม lexicographic order ทุกครั้งที่พิมพ์เพิ่มทีละตัว โดย sort (เรียง) ก่อนแล้วเก็บ suggestions ที่แต่ละ node

โจทย์ (LC1268): กำหนด array of string ชื่อ products และ string ชื่อ searchWord ให้ออกแบบระบบที่แนะนำชื่อสินค้าไม่เกิน 3 ชื่อจาก products หลังจากผู้ใช้พิมพ์ตัวอักษรแต่ละตัวของ searchWord สินค้าที่แนะนำต้องมี common prefix (คำนำหน้าร่วม) กับสิ่งที่พิมพ์มาแล้ว ถ้ามีมากกว่า 3 ชื่อที่ตรงเงื่อนไข ให้เลือก 3 ชื่อที่เรียงตาม lexicographical order (พจนานุกรม) น้อยที่สุด แล้ว return เป็น list of list ของคำแนะนำหลังพิมพ์ตัวอักษรแต่ละตัว

Example 1
Input:
products = ["mobile", "mouse", "moneypot", "monitor", "mousepad"], searchWord = "mouse"
Output:
[["mobile","moneypot","monitor"], ["mobile","moneypot","monitor"], ["mouse","mousepad"], ["mouse","mousepad"], ["mouse","mousepad"]]
Explanation:
พิมพ์ m, mo, mou ได้ 3 ชื่อแรกตามพจนานุกรมที่ขึ้นต้นด้วยสิ่งที่พิมพ์คือ mobile, moneypot, monitor พอพิมพ์ mous, mouse เหลือแค่ mouse กับ mousepad ที่ยังตรง prefix
Example 2
Input:
products = ["havana"], searchWord = "havana"
Output:
[["havana"], ["havana"], ["havana"], ["havana"], ["havana"], ["havana"]]
Explanation:
มีสินค้าตัวเดียวในระบบและตรง prefix ทุกตัวอักษรที่พิมพ์ จึงถูกแนะนำซ้ำทุกครั้งจนพิมพ์ครบคำ
Constraints (ข้อจำกัด)
  • 1 <= products.length <= 1000
  • 1 <= products[i].length <= 3000
  • ข้อความใน products ไม่ซ้ำกัน
  • 1 <= searchWord.length <= 1000
  • ผลรวมความยาวของ products ทั้งหมดไม่เกิน 2 × 10^4

แนวทาง — ต้องใช้อะไร & คิดยังไง

ข้อนี้ใช้ trie ผสมกับการ sort (เรียง) ล่วงหน้า idea คือถ้า sort products ก่อนหนึ่งครั้ง แล้วค่อย insert ลง trie คำที่ผ่านแต่ละ node จะมาตาม lexicographic order อยู่แล้ว เราจึงเก็บแค่ 3 ตัวแรกที่แต่ละ node พอ

วิธีตรงไปตรงมาคือทุกครั้งที่พิมพ์ ก็ filter products ทั้งหมดที่ขึ้นต้นด้วย prefix แล้ว sort เอา 3 ตัวแรก ซึ่งทำงานซ้ำ ๆ และช้าเมื่อพิมพ์ยาว ๆ การเก็บ suggestions ไว้ที่แต่ละ node ตั้งแต่ตอนสร้าง trie ทำให้ตอนตอบแค่ traverse (เดินไล่) ตาม prefix แล้วหยิบออกมาได้ทันที

  1. sort products ก่อนหนึ่งครั้ง
  2. insert แต่ละคำลง trie ไล่ตัวอักษร ระหว่าง traverse ให้ append (ต่อท้าย) คำนั้นเข้า node.suggestions ถ้ายังเก็บไม่ถึง 3 ตัว
  3. ตอบ searchWord: traverse ตามตัวอักษรที่พิมพ์ทีละตัวจาก root ถ้ายัง traverse ได้ก็หยิบ node.suggestions ใส่ผลลัพธ์
  4. ถ้าหลุดเส้นทางเมื่อไร ตั้ง node = None แล้ว append [] ให้ตัวอักษรที่เหลือทั้งหมด
จุดพลาดที่พบบ่อย

พอพิมพ์ตัวอักษรที่ทำให้หลุดเส้นทางใน trie แล้ว ตัวอักษรที่เหลือหลังจากนั้นต้องแนะนำเป็นลิสต์ว่างทั้งหมด อย่าหยุดเติมผลลัพธ์ ต้องเติม [] ต่อไปให้ครบความยาว searchWord

▶ เฉลยละเอียด (ลองเองก่อนนะ)
เฉลย (Python) — โค้ดนี้รันได้จริงpython
class TrieNode:
    def __init__(self):
        self.children = {}
        self.suggestions = []   # สินค้าไม่เกิน 3 ตัวแรก (เรียงแล้ว) ที่ผ่าน node นี้

def suggested_products(products, searchWord):
    root = TrieNode()

    # sort ก่อน เพื่อให้คำที่ใส่เข้า trie มาตามลำดับพจนานุกรม
    for product in sorted(products):
        node = root
        for ch in product:
            if ch not in node.children:
                node.children[ch] = TrieNode()
            node = node.children[ch]
            # เก็บได้แค่ 3 ตัวแรกพอ (มาตามลำดับ sort อยู่แล้ว)
            if len(node.suggestions) < 3:
                node.suggestions.append(product)

    result = []
    node = root
    for ch in searchWord:
        # ถ้ายังเดินตาม prefix ได้ ก็หยิบ suggestions ที่ node นั้น
        if node and ch in node.children:
            node = node.children[ch]
            result.append(node.suggestions)
        else:
            # หลุดเส้นทางแล้ว ที่เหลือไม่มีคำแนะนำ
            node = None
            result.append([])
    return result

print(suggested_products(
    ["mobile", "mouse", "moneypot", "monitor", "mousepad"], "mouse"))
# [['mobile','moneypot','monitor'], ['mobile','moneypot','monitor'],
#  ['mouse','mousepad'], ['mouse','mousepad'], ['mouse','mousepad']]
Output
[['mobile', 'moneypot', 'monitor'], ['mobile', 'moneypot', 'monitor'], ['mouse', 'mousepad'], ['mouse', 'mousepad'], ['mouse', 'mousepad']]

ไอเดียคือ sort products ก่อนหนึ่งครั้ง ทำให้เวลาไล่ insert คำเข้า trie ตามลำดับ ทุก node จะได้รับสินค้าตาม lexicographic order อยู่แล้ว เราจึงเก็บแค่ 3 ตัวแรกที่ผ่าน node นั้นไว้ใน suggestions เมื่อผู้ใช้พิมพ์ prefix มาเรื่อย ๆ ก็แค่ traverse ตามตัวอักษรแล้วหยิบ suggestions ที่ node ปลายทางออกมาได้ทันที ไม่ต้อง search ใหม่ทุกครั้ง

จุดสำคัญคือเมื่อพิมพ์ตัวอักษรที่ทำให้หลุดเส้นทางใน trie (ไม่มีสินค้าไหนขึ้นต้นแบบนั้นแล้ว) ตัวอักษรที่เหลือหลังจากนั้นต้องแนะนำเป็น empty list (ลิสต์ว่าง) ทั้งหมด โค้ดจึงตั้ง node = None แล้ว append [] ต่อไปเรื่อย ๆ

Time O(N log N + total) โดย N คือจำนวนสินค้า มาจากการ sort (N log N) บวกกับการสร้าง trie ตามจำนวนตัวอักษรรวมของทุกคำ ส่วนการตอบ searchWord ใช้ O(ความยาว searchWord) · Space O(total) เก็บตัวอักษรทั้งหมดใน trie (แต่ละ node เก็บ suggestions ไม่เกิน 3)

💡 สรุป pattern

เมื่อต้องตอบ prefix query ซ้ำ ๆ ให้ preprocess (ประมวลผลล่วงหน้า) — sort + เก็บ suggestions ที่ node แลก memory นิดหน่อยเพื่อให้ตอบแต่ละครั้งเร็วในหนึ่งการ traverse — เป็นแก่นของระบบ autocomplete