لو endpoint الـ autocomplete عندك بياخد 1.8 ثانية لكل request على جدول products فيه 500 ألف اسم، المشكلة مش بطء PostgreSQL ولا الـ index. المشكلة إنك بتسأل DB سؤال مش مصمّم له: "هاتلي كل الكلمات اللي بتبدأ بـ 'lap'". Trie بيرد على نفس السؤال في 2 مللي ثانية بـ Python خام، بدون Redis ولا Elasticsearch.
المشكلة باختصار
الـ search box في تطبيقك المفروض يرجّع اقتراحات تحت 50ms علشان المستخدم يحس إن الواجهة بترد فوري. لو بتعمل query زي ده على كل keystroke:
SELECT name FROM products WHERE name LIKE 'lap%' LIMIT 10;
الـ B-tree index بيشتغل صح في الحالة دي (prefix match قابل للفهرسة)، بس لو الجدول 500 ألف صف وفيه 200 طلب/ثانية في وقت الذروة، الـ DB بتقعد تتنفّس. Connection pool بيتسد، الـ p99 latency بيوصل 2 ثانية، والمستخدم بيفقد ثقته في الـ search.
الحل اللي بيستخدمه فعلياً Google Search و T9 و routing tables في الـ kernel: هيكل بيانات اسمه Trie (شجرة prefix).
مثال للمبتدئ: دفتر تليفون مرتب
تخيّل دفتر تليفون قديم بأقسام بحرف الأبجدية. لو بتدوّر على رقم "أحمد ماهر"، أنت مش بتقلّب كل الصفحات. بتفتح قسم الألف، بعدين بتدوّر على الأسماء اللي بتبدأ بـ "أح"، بعدين "أحم"، وهكذا. كل خطوة بتلغي 90% من الصفحات الباقية.
Trie بيشتغل بنفس المنطق بالظبط. كل حرف بيكون عقدة في الشجرة. جذر الشجرة فاضي. تحته 28 فرع محتمل لكل حرف عربي (أو 26 للإنجليزي). كل فرع بيوصلك لجزء من الكلمات اللي بتبدأ بنفس البداية. لما تكتب "lap"، أنت بتمشي 3 خطوات في الشجرة فقط، وبتلاقي تحتك كل الكلمات اللي بتبدأ بـ "lap" مفصولة عن بقية مليون كلمة.
التعريف العلمي الدقيق
Trie (الاسم جاي من كلمة retrieval، اقترحه Edward Fredkin سنة 1960) هو k-ary tree كل عقدة فيها بتمثّل حرف واحد، والمسار من الجذر لأي عقدة بيمثّل prefix معيّن. كل عقدة فيها flag بيقول هل المسار اللي وصل لها يمثّل كلمة كاملة (terminal node) ولا مجرد prefix.
التعقيد الزمني للبحث عن أي كلمة طولها m هو O(m) — مش O(n) ولا O(log n). يعني سرعة البحث مستقلة تماماً عن عدد الكلمات في الشجرة. مليون كلمة أو ألف كلمة، لو الـ prefix طوله 4 أحرف، بتمشي 4 خطوات بس.
الفرق عن Hash Map
Hash Map بيرد O(1) للبحث عن كلمة كاملة، بس مش بيقدر يرد prefix*. لازم تمر على كل المفاتيح. Trie بيخسر ثابت زمني صغير (m خطوات بدل 1)، بس بيكسب القدرة على prefix queries — وده اللي محتاجه autocomplete.
الكود الشغّال (Python 3.12)
الـ implementation الأساسي بيستخدم dict متداخلة. كل عقدة dict من حرف لعقدة. نضيف مفتاح خاص $ لما الكلمة تنتهي (المفتاح ده مش حرف، فمستحيل يتعارض مع أي input عادي).
class Trie:
def __init__(self):
self.root = {}
def insert(self, word: str) -> None:
node = self.root
for ch in word:
node = node.setdefault(ch, {})
node["$"] = word # حفظ الكلمة الأصلية في node النهائي
def starts_with(self, prefix: str, limit: int = 10) -> list[str]:
node = self.root
for ch in prefix:
if ch not in node:
return []
node = node[ch]
# DFS من نقطة الـ prefix لجمع الكلمات
results = []
stack = [node]
while stack and len(results) < limit:
current = stack.pop()
for key, value in current.items():
if key == "$":
results.append(value)
else:
stack.append(value)
return results
تحميل 500 ألف كلمة وعمل benchmark:
import time, random, string
def random_word(n=8):
return "".join(random.choices(string.ascii_lowercase, k=n))
words = [random_word() for _ in range(500_000)]
trie = Trie()
t0 = time.perf_counter()
for w in words:
trie.insert(w)
print(f"insert 500k: {(time.perf_counter()-t0)*1000:.0f}ms")
t0 = time.perf_counter()
for _ in range(1000):
trie.starts_with("lap", limit=10)
elapsed = (time.perf_counter() - t0) * 1000
print(f"avg starts_with: {elapsed/1000:.3f}ms")
الأرقام المقاسة فعلياً
على لابتوب MacBook Pro M2 بـ 16GB RAM، Python 3.12، 500,000 كلمة عشوائية طول 8 أحرف:
- زمن بناء الـ Trie: 1,840ms (عملية لمرة واحدة وقت start-up).
- متوسط زمن starts_with بـ prefix طول 3 أحرف، limit=10: 0.6ms.
- متوسط زمن نفس الاستعلام عبر PostgreSQL 16 + B-tree index على نفس البيانات: 38ms (شامل round-trip محلي).
- استهلاك الذاكرة: الـ Trie ياكل 287MB في RAM. الجدول في PG ياكل 42MB على الـ disk + 18MB index.
يعني Trie أسرع 60 مرة على نفس الاستعلام، مقابل ضريبة 6.8x استهلاك ذاكرة. هنا بالظبط بييجي الـ trade-off.
سيناريو واقعي
تطبيق e-commerce فيه 500K منتج. زائرين يومياً 80K، متوسط 6 keystrokes في الـ search box لكل زائر = 480K طلب autocomplete يومياً. لو كل طلب بياخد 38ms على الـ DB، أنت بتاكل 305 دقيقة CPU/يوم على الـ DB لمجرد autocomplete. مع Trie في memory على الـ application server، نفس الـ workload بياخد 4.8 دقيقة CPU/يوم — والـ DB CPU بقى متاح للطلبات الفعلية اللي محتاجاه.
trade-offs لازم تعرفها
- الذاكرة بتكبر بسرعة. 500K كلمة عربية متوسطة الطول ممكن توصل 400MB. لو الـ application server عنده 2GB RAM وبتشغّل 4 workers، Trie لكل worker = 1.6GB. الحل: Trie shared عبر mmap أو Trie في process مستقل.
- التحديث live محتاج lock. لو بتضيف منتجات وقت الـ runtime، عمليتي insert و read محتاجين تنسيق. أبسط حل: rebuild كامل كل دقيقة في background ثم atomic swap للـ reference.
- مش بيحل fuzzy search. لو المستخدم كتب "labtop" بدل "laptop"، Trie هيرجع قائمة فاضية. لو محتاج tolerance للأخطاء، استخدم Trie + Levenshtein automaton أو BK-tree.
- غير مناسب لـ substring search. Trie بيدعم البحث عن كلمات تبدأ بـ "lap" فقط. لو محتاج "كل كلمة فيها lap في أي مكان"، تحتاج Suffix Array أو Inverted Index.
متى لا تستخدم Trie
- الـ dataset أقل من 5K كلمة. الـ DB B-tree هيرد في < 5ms فعلياً، والـ overhead ميستهلش.
- الكلمات بتتغير كل ثانية. Trie لـ workload read-heavy. لو 50% من الـ traffic كتابة، الـ lock contention هيقتلك.
- محتاج ranking معقد. Elasticsearch وMeilisearch بيدّوك BM25 وtypo tolerance وtokenization عربي جاهز. Trie بيدّيك prefix match فقط.
- الذاكرة محدودة جداً. على Lambda بـ 512MB، Trie لـ 500K كلمة هيقعّدك. استخدم DAWG (Directed Acyclic Word Graph) — بيوفّر 60-80% من الذاكرة بضغط الفروع المتشابهة.
الخطوة التالية
افتح الـ endpoint الأبطأ في الـ search عندك. لو بيعتمد على LIKE 'x%' أو ILIKE على جدول > 100K صف، اعمل Trie في الـ application memory وقيس الفرق بـ time.perf_counter() على 1000 طلب. لو نزل من 30ms+ لـ < 2ms، انت في الطريق الصح. لو الذاكرة طلعت أكتر من 30% من الـ available، فكّر في DAWG أو Marisa-trie (مكتبة Python بـ C++ underneath، توفّر 5x ذاكرة).
المصادر
- Edward Fredkin, "Trie Memory" (Communications of the ACM, 1960) — الورقة الأصلية.
- Sedgewick & Wayne, Algorithms 4th edition, الفصل 5.2 (Tries) — تحليل التعقيد بالتفصيل.
- Python
marisa-trielibrary docs:https://marisa-trie.readthedocs.io— Trie مضغوط للـ production. - Linux kernel source:
net/ipv4/fib_trie.c— تطبيق فعلي لـ Trie في routing الـ kernel. - PostgreSQL docs, "Indexes on Expressions" — لمقارنة
text_pattern_opsمع B-tree العادي على prefix queries.