Data Structures & Algorithms · ساختمان‌داده و الگوریتم متوسطIntermediate ~41 دقیقه مطالعه~34 min read

درخت‌ها، BST، درخت‌های متوازن، Trie و HeapTrees, BSTs, Balanced Trees, Tries & Heaps

از صفر تا صد درخت‌ها را می‌آموزی — درخت دودویی و BST، درخت‌های خودمتوازن AVL و Red-Black، درخت‌های B/B+ برای ایندکس دیتابیس، Heap و PriorityQueue و Trie — با تشبیه‌های ملموس، کد واقعی جاوا و همهٔ دام‌ها و پرسش‌های مصاحبهٔ سطح سنیور.Learn trees zero-to-hero — binary trees and BSTs, self-balancing AVL and Red-Black trees, B/B+ trees behind database indexes, heaps and PriorityQueue, and tries — with concrete analogies, real Java code, and every senior-level gotcha and interview question.

پیش‌نیاز:Prerequisites: آرایه، لیست، پشته، صف و هشArrays, Lists, Stacks, Queues & Hashing


بیا با هم یکی از پرتکرارترین خانواده‌های ساختمان‌داده را از پایه بسازیم. درخت‌ها همه‌جا هستند: در TreeMap جاوا، در ایندکسِ هر دیتابیسی که تا حالا کوئری زده‌ای، در autocompleteِ گوشی‌ات، و در صف اولویتِ زمان‌بندِ سیستم‌عامل. اگر این فصل را کامل بفهمی، بخش بزرگی از مصاحبهٔ الگوریتمِ سطح سنیور را در جیب داری.

نقشهٔ راه این فصل

اول چند واژهٔ پایه را با تشبیه جا می‌اندازیم (گره، ریشه، برگ، ارتفاع). بعد قدم‌به‌قدم می‌رویم جلو: ۱) درخت، درخت دودویی و BST — تفاوت‌ها و ناوردای ترتیب. ۲) پیمایش‌ها (in/pre/post-order و BFS) به‌صورت بازگشتی و تکراری، تا Morris. ۳) عملیات BST: جست‌وجو، درج و حذفِ دام‌دار. ۴) توازن: AVL در برابر Red-Black، چرخش‌ها، و اینکه چرا TreeMap و HashMap جاوا Red-Black را انتخاب کردند. ۵) B-tree و B+ tree: چرا دیتابیس‌ها این‌ها را به‌جای BST دوست دارند. ۶) Heap و PriorityQueue: کمینه/بیشینه در O(log n) و ترفند heapify. ۷) Trie: جست‌وجوی پیشوندی و autocomplete. ۸) مسائل کلاسیک و ۱۵ پرسش مصاحبه با پاسخ کامل.

بخش ۰ — واژه‌هایی که باید بلد باشی

پیش از هر چیز، بیایید چند کلمه را که در کل فصل تکرار می‌شوند، با یک تصویر ساده جا بیندازیم تا هیچ‌جا گیر نکنی.

درخت مثل نمودار سازمانی یک شرکت

یک درخت را مثل چارت سازمانی یک شرکت تصور کن. بالاترین نفر مدیرعامل است؛ زیر او مدیران، زیر آن‌ها کارمندان. هر کارمند دقیقاً یک رئیس مستقیم دارد (به‌جز مدیرعامل که رئیس ندارد). هیچ حلقه‌ای وجود ندارد — نمی‌شود رئیسِ رئیسِ خودت باشی. این دقیقاً همان چیزی است که در ریاضی به آن «گراف همبند بدون‌دور» می‌گویند.

با این تصویر، واژه‌ها ساده می‌شوند:

  • گره (node): هر «جعبه» در چارت؛ یک واحد داده.
  • ریشه (root): همان مدیرعامل — گرهِ بالای درخت که والد ندارد.
  • والد و فرزند (parent / child): رابطهٔ رئیس-کارمندِ مستقیم.
  • برگ (leaf): کارمندی که هیچ زیردستی ندارد — گرهی بدون فرزند.
  • زیردرخت (subtree): یک مدیر به‌همراه تمام کسانی که زیر دستش هستند؛ خودش یک درخت کوچک‌تر است.
  • ارتفاع (height): طولانی‌ترین زنجیرهٔ فرمان از مدیرعامل تا پایین‌ترین کارمند (شمارش یال‌ها).
  • اندازه (size): تعداد کل گره‌ها، که با n نشانش می‌دهیم.
  • ناوردا (invariant): قانونی که همیشه باید برقرار بماند، مهم نیست چه عملیاتی انجام دادی. مثل قانونِ «هر کارمند فقط یک رئیس دارد» که هرگز نباید نقض شود.
چرا ارتفاع همه‌چیز است

تقریباً هر عملیات روی درخت (جست‌وجو، درج، حذف) به اندازهٔ ارتفاع درخت هزینه دارد، یعنی O(height). کل داستانِ «درخت‌های متوازن» یک جنگ است برای نگه‌داشتن ارتفاع در حدود O(log n) به‌جای O(n). یک درخت خوب مثل یک سلسله‌مراتبِ کوتاه و پهن است؛ یک درخت بد مثل یک صف تک‌نفره‌ی طولانی.

مدل ذهنی: از درخت تا BST

درخت دودویی

حالا درخت را محدود کنیم: بگوییم هر گره حداکثر دو فرزند دارد، یکی چپ (left) و یکی راست (right). به این می‌گوییم درخت دودویی (binary tree). توجه کن که به‌طور پیش‌فرض هیچ ترتیبی بین مقادیر نیست — فقط شکلِ حداکثر-دو-فرزند مهم است.

درخت جست‌وجوی دودویی (BST)

کتابخانه‌ای که همه‌چیز سر جای خودش است

یک درخت دودویی خالی مثل انبوهی کتاب روی زمین است: دو فرزند داری، ولی هیچ نظمی نیست. حالا یک قانون بگذار: «هر کتابی که شمارهٔ کوچک‌تری دارد، برو سمت چپ؛ هر کتابِ بزرگ‌تر، سمت راست.» ناگهان انبوهِ کتاب تبدیل به یک کتابخانهٔ مرتب می‌شود که می‌توانی هر کتاب را با نصف‌کردنِ پیاپیِ قفسه‌ها پیدا کنی. این قانون همان چیزی است که BST را می‌سازد.

یک درخت جست‌وجوی دودویی (binary search tree — BST) یک درخت دودویی است که این ناوردای ترتیب را اضافه می‌کند: برای هر گره، تمام کلیدهای زیردرخت چپ اکیداً کوچک‌ترند و همهٔ کلیدهای زیردرخت راست اکیداً بزرگ‌ترند (با فرض نبودِ تکراری). همین یک قانون است که یک درخت دودویی معمولی را به ساختاری قابل‌جست‌وجو تبدیل می‌کند — چون در هر گره می‌دانی به کدام سمت بروی.

واژگان شکل درخت

چند صفت هستند که مدام می‌شنوی؛ بگذار همین‌جا با یک جمله جا بیندازیم:

  • کامل (complete): همهٔ سطح‌ها پُرند، جز شاید آخرین سطح که آن هم از چپ به راست پر می‌شود. Heapها دقیقاً همین شکل‌اند و همین باعث می‌شود در آرایه جا بگیرند.
  • پُر/سره (full / proper): هر گره یا صفر فرزند دارد یا دقیقاً دو تا — هیچ گرهی با یک فرزندِ تنها نداریم.
  • بی‌نقص (perfect): همهٔ برگ‌ها در یک عمقِ یکسان‌اند؛ یک مثلثِ کامل.
  • متوازن (balanced): ارتفاع در حدِ O(log n) می‌ماند. این همان چیزی است که می‌خواهیم.
یک واقعیت که خیلی به‌کار می‌آید

یک درخت کامل با n گره، ارتفاعش دقیقاً ⌊log₂ n⌋ است. به‌همین دلیل است که یک Heap (که همیشه کامل است) را می‌توان بدون هیچ اشاره‌گری، فقط در یک آرایهٔ تخت نگه داشت — که در بخش Heap می‌بینی.

بیایید یک درخت نمونه و پیمایش‌هایش را ببینیم. این تصویر را نگه دار؛ در ادامه به آن برمی‌گردیم:

        8                 پیمایش‌ها روی این درخت:
       / \                pre  : 8 3 1 6 4 7 10 14 13   (گره، چپ، راست)
      3   10              in   : 1 3 4 6 7 8 10 13 14   (چپ، گره، راست) -> در BST مرتب!
     / \    \             post : 1 4 7 6 3 13 14 10 8   (چپ، راست، گره)
    1   6    14           level: 8 3 10 1 6 14 4 7 13   (BFS)
       / \   /
      4   7 13

پیمایش‌ها: چهار راه برای قدم‌زدن در درخت

«پیمایش (traversal)» یعنی بازدید از همهٔ گره‌ها به یک ترتیب مشخص. سه پیمایشِ عمق‌محور (in/pre/post-order) و یکی سطح‌محور (BFS) داریم.

سه جای متفاوت برای «کارِ خودت»

هر پیمایشِ عمق‌محور، اول چپ می‌رود بعد راست؛ تنها فرقشان این است که کارِ گرهِ فعلی را کِی انجام می‌دهی:

  • پیش‌ترتیب (pre-order): اول خودم، بعد بچه‌ها. مثل رئیسی که اول امضا می‌کند و بعد کار را به زیردستان می‌سپارد.
  • میان‌ترتیب (in-order): اول بچهٔ چپ، بعد خودم، بعد بچهٔ راست.
  • پس‌ترتیب (post-order): اول همهٔ بچه‌ها، بعد خودم. مثل مدیری که تا گزارش همهٔ تیم نرسد امضا نمی‌کند.
مهم‌ترین واقعیتِ کل فصل

پیمایش میان‌ترتیب (in-order) یک BST، کلیدها را دقیقاً به‌ترتیب صعودیِ مرتب می‌دهد. این را حفظ کن. اعتبارسنجی BST، پیداکردنِ kاُمین کوچک‌ترین، و پرس‌وجوهای بازه‌ای همگی روی همین یک واقعیت سوارند.

تعریف گره

public class TreeNode {
    int val;
    TreeNode left, right;
    TreeNode(int val) { this.val = val; }
}

پیمایش‌های بازگشتی

نسخهٔ بازگشتی زیباست: فقط جای یک خط را جابه‌جا می‌کنی تا از in-order به pre- یا post-order برسی.

void inorder(TreeNode n, List<Integer> out) {
    if (n == null) return;
    inorder(n.left, out);
    out.add(n.val);          // برای pre-order این خط را بالا، برای post-order پایین ببرید
    inorder(n.right, out);
}
بازگشت روی درختِ کج، پشته را می‌ترکاند

بازگشت تمیز است، اما از پشتهٔ فراخوانی (call stack) استفاده می‌کند و عمقش O(height) است. اگر درختت منحط باشد — یعنی مثل یک لیست پیوندیِ یک‌طرفه کشیده شده باشد — این عمق به O(n) می‌رسد و برنامه با StackOverflowError می‌ترکد. برای همین از کاندیدای سنیور انتظار می‌رود نسخهٔ تکراری (iterative) را هم بلد باشد که پشته را دستی مدیریت می‌کند.

میان‌ترتیب تکراری (با پشتهٔ صریح)

ترفند این است: تا جایی که می‌توانی در ستون فقرات چپ فرو برو و همه را در پشته بریز؛ بعد یکی‌یکی بیرون بکش و به راستِ هرکدام سر بزن.

List<Integer> inorderIter(TreeNode root) {
    List<Integer> out = new ArrayList<>();
    Deque<TreeNode> stack = new ArrayDeque<>();
    TreeNode cur = root;
    while (cur != null || !stack.isEmpty()) {
        while (cur != null) {        // در ستون فقرات چپ فرو می‌رویم
            stack.push(cur);
            cur = cur.left;
        }
        cur = stack.pop();           // چپ‌ترین گرهِ بازدیدنشده
        out.add(cur.val);
        cur = cur.right;             // سپس زیردرخت راستش را می‌کاویم
    }
    return out;
}

پیش‌ترتیب تکراری (pre-order)

List<Integer> preorderIter(TreeNode root) {
    List<Integer> out = new ArrayList<>();
    if (root == null) return out;
    Deque<TreeNode> stack = new ArrayDeque<>();
    stack.push(root);
    while (!stack.isEmpty()) {
        TreeNode n = stack.pop();
        out.add(n.val);
        if (n.right != null) stack.push(n.right); // راست را اول push کن تا چپ اول pop شود
        if (n.left != null)  stack.push(n.left);
    }
    return out;
}
چرا راست را اول push می‌کنیم؟

پشته LIFO است (آخرین ورودی، اولین خروجی). چون می‌خواهیم چپ قبل از راست بازدید شود، باید راست را اول بگذاریم تا زیرِ چپ قرار بگیرد و دیرتر بیرون بیاید. این وارونگیِ کوچک، همان جادوی pre-order تکراری است.

پس‌ترتیب (post-order) تکراری دشوارترین است: یا از دو پشته استفاده می‌کنی، یا یک پیش‌ترتیبِ تغییریافته (گره، راست، چپ) می‌سازی و آخرش نتیجه را معکوس می‌کنی، یا یک اشاره‌گر lastVisited نگه می‌داری. ترفندِ معکوس‌کردن تمیزترین است:

List<Integer> postorderIter(TreeNode root) {
    LinkedList<Integer> out = new LinkedList<>();
    Deque<TreeNode> stack = new ArrayDeque<>();
    if (root != null) stack.push(root);
    while (!stack.isEmpty()) {
        TreeNode n = stack.pop();
        out.addFirst(n.val);                      // افزودن به ابتدا -> خروجی را معکوس می‌کند
        if (n.left != null)  stack.push(n.left);
        if (n.right != null) stack.push(n.right);
    }
    return out;
}

سطح‌به‌سطح (BFS) با گروه‌بندی هر سطح

پیمایش سطح‌محور یعنی درخت را طبقه‌طبقه بخوانی، مثل خواندنِ یک ساختمان از طبقهٔ همکف به بالا. ابزارش صف است، نه پشته.

صفِ نانوایی برای BFS

BFS دقیقاً مثل صف نانوایی کار می‌کند: هر گره که وارد می‌شود ته صف می‌ایستد، و ما همیشه از سرِ صف سرویس می‌دهیم. وقتی به یک گره سرویس دادیم، بچه‌هایش را ته صف اضافه می‌کنیم. نتیجه این می‌شود که همهٔ گره‌های یک طبقه قبل از طبقهٔ بعد سرویس می‌گیرند.

List<List<Integer>> levelOrder(TreeNode root) {
    List<List<Integer>> res = new ArrayList<>();
    if (root == null) return res;
    Queue<TreeNode> q = new LinkedList<>();
    q.offer(root);
    while (!q.isEmpty()) {
        int size = q.size();                      // عکس فوری: دقیقاً یک سطح
        List<Integer> level = new ArrayList<>(size);
        for (int i = 0; i < size; i++) {
            TreeNode n = q.poll();
            level.add(n.val);
            if (n.left != null)  q.offer(n.left);
            if (n.right != null) q.offer(n.right);
        }
        res.add(level);
    }
    return res;
}
عکس فوریِ `q.size()` را فراموش نکن

خطِ int size = q.size() در ابتدای هر تکرار، عکسِ فوری از «اندازهٔ همین طبقه» می‌گیرد. اگر این را جا بیندازی و مستقیم روی صف حلقه بزنی، طبقه‌ها با هم قاطی می‌شوند و به‌جای یک لیستِ تودرتویِ طبقه‌بندی‌شده، یک لیستِ تختِ درهم می‌گیری.

پیمایش موریس — in-order با فضای O(1)

پاسخِ «ترفند پیشرفته را بلدم»

پیمایش موریس (Morris traversal) میان‌ترتیب را با فضای اضافیِ O(1) انجام می‌دهد — بدون پشته و بدون بازگشت. چطور؟ به‌طور موقت اشاره‌گر right هر برگ را به جانشین میان‌ترتیبش سیم‌کشی می‌کند (به این می‌گویند درخت دودویی «نخ‌کشی‌شده / threaded»)، از آن نخ برای برگشتن بالا استفاده می‌کند، و بعد نخ را باز می‌کند تا درخت به حالت اولش برگردد. زمان O(n)، فضای O(1). اگر مصاحبه‌گر پرسید «چطور بدون پشته in-order بگیرم؟»، این پاسخِ برنده است.

عملیات BST: جست‌وجو، درج، و حذفِ دام‌دار

جست‌وجو، درج و حذف همگی O(height) هستند. جست‌وجو و درج سرراست‌اند — در هر گره تصمیم می‌گیری چپ بروی یا راست. اما حذف همان جایی است که اغلب آدم‌ها خرابش می‌کنند.

class BST {
    TreeNode root;

    boolean contains(int key) {
        TreeNode n = root;
        while (n != null) {
            if (key == n.val) return true;
            n = key < n.val ? n.left : n.right;
        }
        return false;
    }

    TreeNode insert(TreeNode n, int key) {
        if (n == null) return new TreeNode(key);
        if (key < n.val)      n.left  = insert(n.left, key);
        else if (key > n.val) n.right = insert(n.right, key);
        // برابر -> نادیده (یا برای multiset یک شمارنده نگه دار)
        return n;
    }

    TreeNode delete(TreeNode n, int key) {
        if (n == null) return null;
        if (key < n.val)      n.left  = delete(n.left, key);
        else if (key > n.val) n.right = delete(n.right, key);
        else {
            // گرهِ موردنظر برای حذف پیدا شد
            if (n.left == null)  return n.right;   // ۰ یا ۱ فرزند (راست)
            if (n.right == null) return n.left;    // ۱ فرزند (چپ)
            // دو فرزند: با جانشین میان‌ترتیب (کوچک‌ترینِ زیردرخت راست) جایگزین کن
            TreeNode succ = n.right;
            while (succ.left != null) succ = succ.left;
            n.val = succ.val;                       // کلید جانشین را بالا کپی کن
            n.right = delete(n.right, succ.val);    // جانشین را حذف کن (فرزند چپ ندارد)
        }
        return n;
    }
}
حذف با دو فرزند: دامِ کلاسیک

وقتی گرهی که می‌خواهی حذف کنی دو فرزند دارد، نمی‌توانی صرفاً پاکش کنی — دو زیردرختِ آویزان می‌مانند. راه‌حل: جانشین میان‌ترتیب را پیدا کن، یعنی چپ‌ترین گرهِ زیردرخت راست (کوچک‌ترین مقداری که هنوز از گرهِ فعلی بزرگ‌تر است). این گره تضمیناً حداکثر یک فرزند دارد، پس حذفش آسان است. مقدارش را بالا کپی کن و بعد خودِ آن جانشین را حذف کن. (به‌جایش می‌توانی از سلفِ میان‌ترتیب — راست‌ترین گرهِ زیردرخت چپ — هم استفاده کنی.) اگر بخواهی هر دو زیردرخت را سرِهم به‌هم وصل کنی، ناوردای BST می‌شکند.

ضعف مرگبار BST سادهٔ بدون توازن

1, 2, 3, 4, 5 را به‌ترتیب در یک BST ساده درج کن. چه می‌شود؟ هر عدد از قبلی بزرگ‌تر است، پس همیشه سمت راست می‌رود، و درخت به یک چوبِ راست‌گرا — عملاً یک لیست پیوندی — تبدیل می‌شود. حالا ارتفاع O(n) است و جست‌وجو هم O(n). تمام مزیت BST دود شد. دقیقاً به همین دلیل درخت‌های خودمتوازن اختراع شدند.

توازن: AVL در برابر Red-Black

هر دوی این‌ها درخت‌های خودمتوازن‌اند: بعد از هر درج/حذف، خودشان را با چرخش (rotation) بازآرایی می‌کنند تا ارتفاع O(log n) بماند. اما فلسفهٔ متفاوتی دارند.

کتابدارِ وسواسی در برابر کتابدارِ عمل‌گرا

تصور کن دو کتابدار داری. AVL کتابدارِ وسواسی است: بعد از هر کتابی که جابه‌جا می‌شود، کل قفسه را دوباره مرتب می‌کند تا کاملاً متوازن بماند — پس پیداکردن کتاب فوق‌العاده سریع است، اما هر جابه‌جایی گران تمام می‌شود. Red-Black کتابدارِ عمل‌گراست: قفسه را «به‌اندازهٔ کافی خوب» نگه می‌دارد و فقط وقتی واقعاً لازم است دست به بازآرایی می‌زند — پس درج/حذف ارزان‌تر است، به قیمتِ اینکه قفسه‌ها گاهی کمی نامرتب‌ترند.

درخت‌های AVL یک ناوردای سخت‌گیرانه نگه می‌دارند: برای هر گره، اختلاف ارتفاع دو زیردرختش حداکثر ۱ است (ضریب توازن ∈ {−۱, ۰, +۱}). این «صلب‌تر متوازن» بودن یعنی جست‌وجوی سریع‌تر، اما درج/حذف ممکن است چرخش‌های بیشتری بخواهد تا این ناوردای تنگ را بازگرداند.

درخت‌های Red-Black شل‌ترند. گره‌ها را قرمز یا سیاه رنگ می‌کنند و پنج قاعده را اعمال می‌کنند؛ نتیجهٔ کلیدی این است که هیچ مسیر ریشه‌تا‌برگی بیش از دو برابرِ کوتاه‌ترین مسیر نمی‌شود، پس ارتفاع ≤ 2·log₂(n+1) می‌ماند. چرخش کمتر هنگام تغییر (حداکثر ۲ برای درج، ۳ برای حذف) به بهای درختِ کمی بلندتر.

AVL Red-Black
تضمین توازن ارتفاع ≈ ۱.۴۴·log₂n ارتفاع ≤ ۲·log₂n
سرعت جست‌وجو سریع‌تر (تنگ‌تر) کمی کندتر
چرخش درج/حذف بیشتر کمتر (O(1) سرشکن)
بهترین برای خواندن‌محور نوشتن‌محور / عمومی
کاربران واقعی برخی ایندکس‌های DB Java TreeMap/TreeSet، Linux CFS، C++ std::map

چرخش‌ها — عنصر بنیادینِ مشترک هر دو

چرخش یک بازآراییِ محلی است که ترتیبِ BST را حفظ می‌کند اما شکل را تغییر می‌دهد تا ارتفاع کم شود. به تصویر داخل کد دقت کن: y پایین می‌آید و x بالا می‌رود، ولی هنوز A < x < B < y < C برقرار می‌ماند.

// چرخش راست حول y؛ x ریشهٔ جدید زیردرخت می‌شود.
//        y            x
//       / \          / \
//      x   C   ->    A   y
//     / \               / \
//    A   B             B   C
TreeNode rotateRight(TreeNode y) {
    TreeNode x = y.left;
    TreeNode B = x.right;
    x.right = y;
    y.left  = B;
    // (در AVL: ارتفاع y سپس x را اینجا دوباره محاسبه کن)
    return x;                 // ریشهٔ جدید این زیردرخت
}

چرا TreeMap از Red-Black استفاده می‌کند

java.util.TreeMap و TreeSet زیرِ پرده درخت‌های Red-Black هستند. منطق طراحی چیست؟ یک map مرتبِ همه‌منظوره با آمیزه‌ای از خواندن و نوشتن روبه‌روست. بازموازنهٔ ارزان‌ترِ Red-Black (چرخش کمتر در هر تغییر) روی این بارِکاریِ واقع‌بینانهٔ آمیخته برنده است. توازن تنگ‌ترِ AVL فقط وقتی سود می‌دهد که خواندن‌ها بسیار غالب باشند. به‌علاوه Red-Black تعداد چرخشِ بدترین‌حالتِ کران‌دار دارد، که برای تأخیرِ (latency) پیش‌بینی‌پذیر مهم است.

تمام get/put/remove/ceilingKey/floorKey/firstKey برابر O(log n) هستند؛ پیمایش میان‌ترتیب O(n) است و کلیدهای مرتب می‌دهد.

TreeMap<Integer, String> map = new TreeMap<>();
map.put(10, "a"); map.put(5, "b"); map.put(20, "c");
map.firstKey();          // 5   (چپ‌ترین)
map.ceilingKey(6);       // 10  (کوچک‌ترین کلید >= 6)
map.floorKey(15);        // 10  (بزرگ‌ترین کلید <= 15)
map.headMap(10);         // {5=b}  نمای کلیدهای < 10، متکی بر درخت
map.subMap(5, true, 20, false); // نمای بازه‌ای، یافتن انتها O(log n)
treeificationِ HashMap از جاوا ۸

از جاوا ۸ به بعد، HashMap هم از درخت Red-Black استفاده می‌کند، اما فقط در یک حالت خاص. وقتی چند کلید هشِ بدی می‌گیرند و همه در یک سطل (bucket) روی هم تلنبار می‌شوند، آن زنجیرهٔ لیست پیوندی می‌تواند طولانی و کند شود. راه‌حل جاوا: وقتی یک سطل ≥ ۸ مدخل داشت و کل جدول ≥ ۶۴ سطل بود، آن سطل از لیست پیوندی به یک درخت Red-Black تبدیل می‌شود (به این می‌گویند treeification)، و جست‌وجوی بدترین‌حالتِ سطل را از O(n) به O(log n) می‌برد. اگر جدول کوچک‌تر از ۶۴ باشد، به‌جای treeify کردن ترجیح می‌دهد resize کند. وقتی مدخل‌ها زیر ۶ بیایند، دوباره به لیست بازمی‌گردد (untreeify).

چرا دیتابیس‌ها از B-tree / B+ tree استفاده می‌کنند نه BST

رفت‌وآمد به انبار پشتِ خیابان

تصور کن هر گرهِ درخت روی یک صفحهٔ دیسک است، و خواندنِ هر صفحه مثل رفتنِ پیاده به یک انبارِ دور است — کند و پرهزینه. حالا اگر درختت انشعابِ ۲ داشته باشد، برای یک میلیارد ردیف باید حدود ۳۰ بار به انبار بروی و برگردی؛ فاجعه. اما اگر هر «قفسه» را آن‌قدر بزرگ کنی که صدها کتاب را یکجا در خودش جا دهد، با ۳ تا ۴ رفت‌وآمد کل کار تمام می‌شود. B-tree همین ایده است: قفسه‌های پهن به‌جای قفسه‌های باریک.

یک BST متوازن انشعاب (fan-out) ۲ دارد — یعنی هر گره فقط دو فرزند. برای یک میلیارد ردیف، ارتفاعش حدود ۳۰ می‌شود. روی دیسک/SSD، هر دسترسی به گره بالقوه یک خواندنِ صفحه (page read) است، پس ۳۰ ورودی/خروجیِ تصادفی به‌ازای هر جست‌وجو فاجعه است.

B-tree و B+ tree درخت‌های متوازنِ m-راهه‌اند: هر گره صدها کلید را نگه می‌دارد، هم‌اندازهٔ یک صفحهٔ دیسک (مثلاً ۸ تا ۱۶ کیلوبایت). با انشعابِ حدود ۵۰۰، یک میلیارد ردیف در درختی با ارتفاع ۳ تا ۴ جا می‌شود → فقط ۳ تا ۴ خواندنِ صفحه. کل هدف این است: کمینه‌کردن I/O با بیشینه‌کردنِ انشعاب.

مزیت تعریف‌کنندهٔ B+ tree: برگ‌های زنجیرشده

B+ tree (که PostgreSQL، MySQL InnoDB و بیشتر ایندکس‌های RDBMS از آن استفاده می‌کنند) این ایده را پالایش می‌کند: گره‌های داخلی فقط کلید نگه می‌دارند (صرفاً برای مسیریابی)، و همهٔ داده/اشاره‌گرهای واقعی در برگ‌ها زندگی می‌کنند. مهم‌تر از همه، برگ‌ها در یک لیست پیوندیِ دوطرفه به‌هم متصل‌اند. برای همین پویش‌های بازه‌ای مثل WHERE x BETWEEN a AND b یا ORDER BY بدیهی می‌شوند: برگِ شروع را پیدا کن، بعد زنجیرهٔ برگ‌ها را ترتیبی راه برو. همین اتصالِ برگ‌ها، مزیتِ اصلیِ B+ نسبت به B-tree ساده است.

Heap و PriorityQueue

خاصیت Heap

سلسله‌مراتبِ نظامی

یک Heap مثل یک سلسله‌مراتبِ نظامی است، اما با یک قانونِ ضعیف‌تر از BST. در یک min-heap، هر فرمانده از هر زیردستِ مستقیمش رتبهٔ کوچک‌تری دارد (کوچک‌تر = مهم‌تر)، پس در رأس، کوچک‌ترین مقدار نشسته. اما توجه کن: بین دو زیردستِ یک نفر هیچ ترتیبی نیست — فقط رابطهٔ والد/فرزند مهم است، نه چپ/راست. برای همین می‌توانی فوراً به فرماندهٔ کل نگاه کنی، اما برای پیداکردنِ یک سربازِ خاص باید کل ارتش را بگردی.

یک Heap دودویی (binary heap) یک درخت دودویی کامل است که خاصیت heap را برآورده می‌کند: در min-heap هر والد ≤ فرزندانش (ریشه کمینه است)؛ در max-heap هر والد ≥ فرزندانش (ریشه بیشینه است). چون این ناوردا ضعیف‌تر از BST است، heap به تو O(1) نگاه به کمینه/بیشینه و O(log n) درج/استخراج می‌دهد، اما جست‌وجوی یک مقدار دلخواه O(n) است.

ذخیره در آرایه — بدون هیچ اشاره‌گری

چون heap همیشه کامل است، اصلاً نیازی به اشاره‌گر نداری؛ کل درخت در یک آرایهٔ تخت جا می‌شود. برای آرایه‌های ۰-ایندکس:

  • والدِ i = (i-1)/2
  • فرزند چپ = 2i+1، فرزند راست = 2i+2
// عملیات sift مربوط به min-heap، مبتنی بر آرایه.
void siftUp(int[] h, int i) {              // پس از درج در انتها
    while (i > 0) {
        int p = (i - 1) / 2;
        if (h[i] >= h[p]) break;           // والد از قبل کوچک‌تر است -> تمام
        swap(h, i, p);
        i = p;
    }
}
void siftDown(int[] h, int i, int size) {  // پس از حذف ریشه (عنصر آخر به ریشه منتقل شد)
    while (true) {
        int l = 2*i + 1, r = 2*i + 2, smallest = i;
        if (l < size && h[l] < h[smallest]) smallest = l;
        if (r < size && h[r] < h[smallest]) smallest = r;
        if (smallest == i) break;
        swap(h, i, smallest);
        i = smallest;
    }
}

siftUp را بعد از درج در انتها صدا می‌زنی: عنصرِ تازه را آن‌قدر بالا می‌بری تا از والدش کوچک‌تر نباشد. siftDown را بعد از حذف ریشه صدا می‌زنی: عنصر آخر را به رأس می‌آوری و آن‌قدر پایین می‌فرستی تا خاصیت heap برقرار شود.

(سخت) ساختن heap از `n` عنصر: `O(n)` یا `O(n log n)`؟

این یکی از محبوب‌ترین دام‌های مصاحبه است. اگر heap را با درجِ مکرر (siftUp روی هر عنصر) بسازی، هزینه O(n log n) می‌شود. اما اگر آرایه را یکجا داری و پایین‌به‌بالا heapify کنی — یعنی siftDown را روی همهٔ گره‌های داخلی از n/2-1 تا 0 اجرا کنی — هزینه فقط O(n) است، نه O(n log n).

چرا؟ بیشتر گره‌ها نزدیک پایینِ درخت‌اند و فقط فاصلهٔ ناچیزی می‌توانند sift شوند؛ فقط تعداد کمی گرهِ نزدیک ریشه راهِ زیادی دارند. مجموعِ ریاضی Σ (n/2^h)·h به 2n همگرا می‌شود، یعنی خطی. پس اگر مصاحبه‌گر پرسید «heapify چقدر طول می‌کشد؟»، پاسخِ درست به جهت (پایین‌به‌بالا در برابر بالا‌به‌پایین) و به اینکه بیشتر گره‌ها کجا زندگی می‌کنند بستگی دارد.

PriorityQueue جاوا

java.util.PriorityQueue به‌طور پیش‌فرض یک min-heap دودویی (بر اساس ترتیب طبیعی)، بی‌کران و مبتنی بر آرایه است. اگر یک Comparator بدهی، ترتیب عوض می‌شود. چند نکتهٔ حیاتی که هر سنیوری باید بداند:

  • offer/poll برابر O(log n)؛ peek برابر O(1).
  • remove(Object) و contains(Object) برابر O(n) هستند — پویش خطی می‌کنند، چون heap برحسب مقدار قابل‌جست‌وجو نیست.
  • ترتیب پیمایش مرتب نیست. for (x : pq) آرایهٔ پشتیبان را به‌ترتیبِ دلخواهِ heap می‌پیماید. فقط poll() مکرر خروجیِ مرتب می‌دهد.
  • thread-safe نیست؛ برای هم‌روندی از PriorityBlockingQueue استفاده کن.
  • تکراری مجاز است؛ اما null مجاز نیست.
// max-heap: کمپراتور معکوس
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Comparator.reverseOrder());

// kاُمین بزرگ‌ترین عنصر: یک min-heap با اندازهٔ k از بزرگ‌ترین‌های تاکنون نگه دار.
int kthLargest(int[] nums, int k) {
    PriorityQueue<Integer> minHeap = new PriorityQueue<>(); // min-heap
    for (int x : nums) {
        minHeap.offer(x);
        if (minHeap.size() > k) minHeap.poll(); // کوچک‌ترین را بیرون بینداز -> heap شاملِ top-k
    }
    return minHeap.peek();  // کوچک‌ترینِ top-k = kاُمین بزرگ‌ترین. O(n log k)
}
ترفندِ خلافِ‌شهودِ «kاُمین بزرگ‌ترین»

برای پیداکردن kاُمین بزرگ‌ترین، از یک min-heap با اندازهٔ k استفاده کن (بله، min برای بزرگ‌ترین!). این min-heap فقط k عنصرِ برترِ فعلی را نگه می‌دارد، و ریشه‌اش کوچک‌ترینِ همین top-k است — که دقیقاً همان kاُمین بزرگ‌ترینِ کل است. زمان O(n log k) و فضای O(k). راه دیگر — یک max-heap شاملِ همهٔ n عنصر — هم کار می‌کند اما O(n + k log n) زمان و O(n) فضا می‌برد. وقتی k ≪ n باشد، نسخهٔ min-heap اندازه‌k به‌وضوح بهتر است.

جاوا الگوهای مبتنی بر PriorityQueue برای مسائل کلاسیکِ دیگری هم دارد: top-K پرتکرار، ادغام k لیستِ مرتب، و میانهٔ یک جریان (با دو heap — یک max-heap برای نیمهٔ پایین و یک min-heap برای نیمهٔ بالا).

Trie (درخت پیشوندی)

فهرستِ درخت‌واریِ یک دیکشنری

یک Trie مثل ساختارِ داخلیِ یک دیکشنری است که با حرف‌ها شاخه می‌خورد. از ریشه شروع می‌کنی؛ برای رسیدن به کلمهٔ «cat» اول شاخهٔ c، بعد a، بعد t را دنبال می‌کنی. زیبایی‌اش این است: همهٔ کلماتی که با «ca» شروع می‌شوند، زیرِ همان گرهِ «ca» جمع‌اند. برای همین autocomplete با Trie بدیهی می‌شود — کافی است به گرهِ پیشوند برسی و از آنجا به پایین بگردی.

یک Trie یک درخت m-تایی است که با کاراکترهای رشته‌ها کلید می‌خورد؛ مسیر از ریشه تا یک گره، یک پیشوند را هجی می‌کند. Trie درج/جست‌وجوی O(L) می‌دهد که L طول کلید است — مستقل از تعداد کلیدهای ذخیره‌شده — و مهم‌تر، پرس‌وجوی پیشوندیِ بومی: autocomplete، تطبیق بلندترین پیشوند (جدول‌های مسیریابی)، بررسی املا، جست‌وجوی IP.

class Trie {
    static class Node {
        Node[] next = new Node[26];   // الفبای ثابت a-z؛ برای یونیکد از HashMap<Character,Node> استفاده کن
        boolean isWord;
    }
    private final Node root = new Node();

    void insert(String w) {
        Node cur = root;
        for (char c : w.toCharArray()) {
            int i = c - 'a';
            if (cur.next[i] == null) cur.next[i] = new Node();
            cur = cur.next[i];
        }
        cur.isWord = true;            // پایانه را علامت بزن
    }

    boolean search(String w) { Node n = find(w); return n != null && n.isWord; }
    boolean startsWith(String p) { return find(p) != null; }

    private Node find(String s) {
        Node cur = root;
        for (char c : s.toCharArray()) {
            int i = c - 'a';
            if (cur.next[i] == null) return null;
            cur = cur.next[i];
        }
        return cur;
    }

    // تکمیل خودکار: همهٔ کلمات زیر گرهِ پیشوند را جمع کن.
    List<String> autocomplete(String prefix, int limit) {
        List<String> res = new ArrayList<>();
        Node start = find(prefix);
        if (start != null) dfs(start, new StringBuilder(prefix), res, limit);
        return res;
    }
    private void dfs(Node n, StringBuilder path, List<String> res, int limit) {
        if (res.size() >= limit) return;
        if (n.isWord) res.add(path.toString());
        for (char c = 'a'; c <= 'z'; c++) {
            Node child = n.next[c - 'a'];
            if (child != null) {
                path.append(c);
                dfs(child, path, res, limit);
                path.deleteCharAt(path.length() - 1); // عقب‌گرد (backtrack)
            }
        }
    }
}
مصالحهٔ Trie: حافظه

آرایهٔ ثابتِ ۲۶-اشاره‌گری در هر گره، برای دادهٔ تُنُک (sparse) اسراف است — کلی خانهٔ خالی. Trieهای محصولی به‌جایش یک HashMap<Character,Node> در هر گره می‌گذارند، یا زنجیرهای تک‌فرزندی را در یک یال فشرده می‌کنند — به این می‌گویند درخت رادیکس (radix tree) / Patricia trie (که در مسیریابی خودِ جاوا، جدول‌های IP و برخی دیتابیس‌ها به‌کار می‌رود). Trie دقیقاً وقتی از hash map بهتر است که به عملیاتِ پیشوندی نیاز داری — کاری که هش هرگز نمی‌تواند بکند، چون هش‌کردن مجاورت و ترتیب را نابود می‌کند. برای شمارشِ پیشوند، یک count در هر گره نگه دار.

مسائل کلاسیک

اعتبارسنجی یک BST (دام شمارهٔ ۱)

// تلاش ساده‌لوحانهٔ اشتباه فقط گره را با فرزندان بلافصل چک می‌کند — نقض‌های
// بین‌زیردرختی را از دست می‌دهد. درست: کران (min, max) را پایین ببر.
boolean isValidBST(TreeNode n, long min, long max) {
    if (n == null) return true;
    if (n.val <= min || n.val >= max) return false;      // باید اکیداً درون کران باشد
    return isValidBST(n.left, min, n.val)                // کران بالا را تنگ کن
        && isValidBST(n.right, n.val, max);              // کران پایین را تنگ کن
}
// فراخوانی: isValidBST(root, Long.MIN_VALUE, Long.MAX_VALUE)

ایده این است که به‌جای مقایسهٔ گره فقط با فرزندانِ بلافصل، یک بازهٔ مجاز (min, max) را در حین پایین‌رفتن تنگ‌تر می‌کنی. وقتی به چپ می‌روی، سقفِ مجاز مقدارِ گرهِ فعلی می‌شود؛ وقتی به راست می‌روی، کفِ مجاز.

از کران `long` استفاده کن

اگر کران‌ها را int بگذاری، گرهی که دقیقاً Integer.MAX_VALUE یا Integer.MIN_VALUE است می‌تواند به‌اشتباه رد شود. با long این لبه پوشش داده می‌شود. جایگزینِ زیبا: به‌جای کران، یک پیمایش میان‌ترتیب انجام بده و تأیید کن که دنباله اکیداً صعودی است — چون یادت هست که in-orderِ یک BST همیشه مرتب است.

پایین‌ترین جد مشترک (Lowest Common Ancestor)

برای یک BST، از ترتیب بهره ببر — O(height):

TreeNode lcaBST(TreeNode n, int p, int q) {
    while (n != null) {
        if (p < n.val && q < n.val)      n = n.left;   // هر دو کوچک‌تر -> چپ برو
        else if (p > n.val && q > n.val) n = n.right;  // هر دو بزرگ‌تر -> راست برو
        else return n;                                 // نقطهٔ انشعاب = LCA
    }
    return null;
}

ایده: اولین گره‌ای که p و q در دو سمتِ مختلفش قرار می‌گیرند (یا خودش یکی از آن‌هاست)، همان جدِ مشترکِ پایینی است.

برای یک درخت دودویی عمومی (بدون ترتیب)، بازگشتی برو و بیاب کجا دو هدف از هم جدا می‌شوند:

TreeNode lca(TreeNode root, TreeNode p, TreeNode q) {
    if (root == null || root == p || root == q) return root;
    TreeNode l = lca(root.left, p, q);
    TreeNode r = lca(root.right, p, q);
    if (l != null && r != null) return root;   // p و q در زیردرخت‌های متفاوت پیدا شدند
    return l != null ? l : r;                  // هر دو یک‌طرف (یا هیچ‌کدام)
}

سریال‌سازی / بازسازی (پیش‌ترتیب با نشانگرِ null)

static final String NULL = "#", SEP = ",";

String serialize(TreeNode n) {
    StringBuilder sb = new StringBuilder();
    ser(n, sb);
    return sb.toString();
}
void ser(TreeNode n, StringBuilder sb) {
    if (n == null) { sb.append(NULL).append(SEP); return; }
    sb.append(n.val).append(SEP);
    ser(n.left, sb);
    ser(n.right, sb);
}
TreeNode deserialize(String data) {
    Deque<String> q = new ArrayDeque<>(Arrays.asList(data.split(SEP)));
    return deser(q);
}
TreeNode deser(Deque<String> q) {
    String t = q.poll();
    if (NULL.equals(t)) return null;
    TreeNode n = new TreeNode(Integer.parseInt(t));
    n.left  = deser(q);   // پیش‌ترتیب: ابتدا زیردرخت چپ مصرف می‌شود
    n.right = deser(q);
    return n;
}
چرا نشانگرِ null لازم است؟

پیش‌ترتیب با نشانگرِ nullِ صریح خود-تفکیک‌کننده (self-delimiting) است — یعنی از روی همان یک رشته می‌توانی درخت را یکتا بازسازی کنی، بدون هیچ پیمایش دومی. یک پیمایشِ تنها بدونِ null مبهم است و درخت‌های مختلف می‌توانند خروجی یکسان بدهند. اگر بدون null کار کنی، به میان‌ترتیب به‌علاوهٔ پیش‌ترتیب یا پس‌ترتیب نیاز داری تا ابهام رفع شود. #ها این نیاز را حذف می‌کنند.

دام‌های رایج

  • عمق بازگشت: درخت‌های عمیق/کج پشته را می‌ترکانند. فرم‌های تکراری را بلد باش.
  • تکراری‌ها در BST: سیاست خود را تعیین کن (رد، شمارش، یا چپ/راستِ سازگار) — قاعدهٔ ناسازگار جست‌وجو را بی‌سروصدا خراب می‌کند.
  • == در برابر .equals در درختِ اشیا: باگ‌های مقایسهٔ هویت در برابر مقدار.
  • سرریز عدد صحیح (overflow) در کران‌های isValidBST و در نقاط میانی (lo+hi)/2 در جاهای دیگر.
  • پیمایش PriorityQueue مرتب نیست — منبع اصلیِ باگ‌های «چرا خروجی‌ام غلط است».
  • ناسازگاری Comparator با equals باعث خراب‌شدن TreeMap/TreeSet می‌شود — دو کلیدی که 0 مقایسه شوند، صرف‌نظر از equals برابر تلقی می‌شوند، پس یک کمپراتور بد می‌تواند مدخل‌ها را بی‌سروصدا بیندازد.
  • remove(Object) در heap برابر O(n) است — یک Dijkstra را ساده‌لوحانه روی آن نساز؛ از الگوهای کاهش-کلید (decrease-key) یا حذف تنبل (lazy deletion) استفاده کن.

بهترین‌روش‌ها

  • وقتی به پیمایش مرتب، به floor/ceiling/higher/lower، یا به نماهای بازه‌ای نیاز داری سراغ TreeMap/TreeSet برو — نه فقط برای عضویت (برای آن HashMap بهتر است).
  • برای top-K جریانی، زمان‌بندی، Dijkstra/Prim و مسائل ادغام از PriorityQueue استفاده کن. برای الگوی top-K-largest، ترفندِ min-heap اندازه‌k را به‌یاد داشته باش.
  • از Trie فقط وقتی استفاده کن که واقعاً به عملیات پیشوندی نیاز داری؛ در غیر این صورت یک HashMap ساده‌تر و اغلب برای تطبیق دقیق سریع‌تر است.
  • برای دادهٔ مرتبِ پایدار/روی‌دیسک، زیرِ پرده B+ tree است — انشعاب و اتصالِ برگ را بفهم حتی اگر هرگز یکی پیاده نکنی.
  • در کد کتابخانه‌ای/محصولی که عمقِ ورودی نامعتمد است، پیمایشِ تکراری را ترجیح بده.

پرسش‌های مصاحبه

۱. پیمایش میان‌ترتیب یک BST چه تولید می‌کند؟ چرا مفید است؟

ترتیب صعودیِ مرتب. این خاصیت زیربنای اعتبارسنجی BST، پیداکردنِ kاُمین کوچک‌ترین، و استخراجِ بازه‌ای در O(n) / O(h+k) است. اگر فقط یک واقعیت از این فصل یادت بماند، همین باشد.

۲. چرا `TreeMap` از Red-Black استفاده می‌کند نه AVL؟

هر دو O(log n)اند، اما Red-Black چرخشِ کمتری در هر درج/حذف نیاز دارد (≤۲ برای درج، ≤۳ برای حذف) و بازموازنهٔ کران‌دار دارد، پس در بارِکاریِ آمیختهٔ خواندن/نوشتن برنده است. توازن تنگ‌ترِ AVL فقط برای بارهای خواندن‌غالب می‌ارزد.

۳. (سخت) ساختن heap از `n` عنصر — `O(n)` یا `O(n log n)`؟

heapifyِ پایین‌به‌بالای siftDown برابر O(n) است (مجموعِ وزن‌دار به ارتفاع، Σ (n/2^h)·h، به 2n همگرا می‌شود). ساختن با درجِ مکررِ siftUp برابر O(n log n) است. تمایز به جهتِ کار و به محلِ زندگیِ بیشتر گره‌ها (نزدیک پایین) بستگی دارد.

۴. kاُمین بزرگ‌ترین را در یک جریان بیاب. کدام heap و چرا؟

یک min-heap با اندازهٔ k. ریشه‌اش کوچک‌ترینِ top-k فعلی است، یعنی kاُمین بزرگ‌ترین. زمان O(n log k)، فضای O(k) — از مرتب‌سازی یا از یک max-heapِ n-عنصری، وقتی k ≪ n، بهتر است.

۵. (دام) چرا این اعتبارسنجیِ BST غلط است؟
boolean bad(TreeNode n){ if(n==null) return true;
  if(n.left!=null && n.left.val>=n.val) return false;
  if(n.right!=null && n.right.val<=n.val) return false;
  return bad(n.left)&&bad(n.right); }

این فقط فرزندانِ بلافصل را چک می‌کند. یک گرهِ عمیق در زیردرخت چپ می‌تواند از یک جدِ بالادست بزرگ‌تر باشد و باز هم این چک را پاس کند. باید کرانِ (min,max) را حین پایین‌رفتن پایین ببری (یا از یکنواختیِ صعودیِ میان‌ترتیب استفاده کنی).

۶. حذف گرهی با دو فرزند از BST — چگونه؟

کلیدش را با جانشین میان‌ترتیب (چپ‌ترینِ زیردرخت راست) یا سلف (راست‌ترینِ زیردرخت چپ) — که ≤۱ فرزند دارد — جایگزین کن، سپس آن گره را حذف کن. حذفِ ساده‌لوحانهٔ خودِ گره، درخت را می‌شکند چون دو زیردرختِ آویزان می‌مانند.

۷. چرا B+ tree برای ایندکس DB و نه BST متوازن؟

انشعاب. یک BST انشعابِ ۲ دارد → ارتفاع حدود ۳۰ برای ۱ میلیارد ردیف → ۳۰ I/O تصادفی. B+ tree صدها کلید را در هر صفحهٔ دیسک می‌گنجاند → انشعابِ حدود ۵۰۰ → ارتفاعِ ۳ تا ۴. به‌علاوه برگ‌های B+ به‌هم متصل‌اند و پویشِ بازه‌ای را به I/O ترتیبی تبدیل می‌کنند.

۸. heap چه چیزی را که BST می‌دهد، نمی‌دهد؟

پیمایشِ مرتب و جست‌وجوی دلخواهِ O(log n). heap فقط والد را در برابر فرزند مرتب می‌کند، پس جست‌وجوی یک مقدار دلخواه O(n) است. دقیقاً به همین دلیل PriorityQueue.contains/remove(Object) برابر O(n) است.

۹. (دام) این چه چاپ می‌کند؟
PriorityQueue<Integer> pq = new PriorityQueue<>();
pq.addAll(List.of(5,1,3,2,4));
System.out.println(pq);   // ?

نه [1,2,3,4,5]. آرایهٔ پشتیبان را به‌ترتیبِ heap چاپ می‌کند، مثلاً [1, 2, 3, 5, 4]. فقط poll() مکرر خروجیِ مرتب می‌دهد. پیمایش و toString مرتب نیستند.

۱۰. `HashMap` کِی و به چه چیزی درخت می‌شود؟

از جاوا ۸، سطلی با زنجیرهٔ تصادمِ منحط، وقتی جدول ≥۶۴ سطل دارد، در ≥۸ مدخل به یک درخت Red-Black تبدیل می‌شود (وگرنه به‌جایش resize می‌کند)، و جست‌وجوی بدترین‌حالتِ سطل را O(log n) می‌دهد. زیر ۶ مدخل به لیست بازمی‌گردد.

۱۱. Trie در برابر HashMap برای autocomplete؟

یک HashMap تطبیقِ دقیقِ O(1) می‌دهد اما نمی‌تواند پرس‌وجوی پیشوندی کند — هش‌کردن ترتیب و مجاورت را نابود می‌کند. Trie جست‌وجوی O(L) و شمارشِ همهٔ کلیدهای زیرِ یک پیشوند را با DFS از گرهِ پیشوند می‌دهد. Trie حافظهٔ بیشتری می‌خواهد (که با فشرده‌سازیِ radix/Patricia کاهشش می‌دهی).

۱۲. (سخت) یک درخت دودویی را طوری سریال کن که رفت‌وبرگشتِ کامل داشته باشد. حداقل اطلاعات چیست؟

پیش‌ترتیب (یا سطح‌به‌سطح) با نشانگرِ nullِ صریح خود-تفکیک‌کننده است و یکتا بازسازی می‌شود. یک پیمایشِ تنها بدونِ null مبهم است؛ برای رفعِ ابهام به میان‌ترتیب به‌علاوهٔ پیش‌ترتیب یا پس‌ترتیب نیاز داری.

۱۳. ناوردای توازنِ AVL در برابر Red-Black چیست؟

AVL: اختلافِ ارتفاعِ زیردرخت‌های هر گره ≤۱ (ارتفاع ≈۱.۴۴ log n). Red-Black: ۵ قاعدهٔ رنگ ⇒ هیچ مسیری بیش از ۲ برابرِ دیگری نیست ⇒ ارتفاع ≤ ۲ log(n+1).

۱۴. (دام) یک `TreeSet` بی‌سروصدا عنصری را که افزودی می‌اندازد. چرا؟

چون Comparator/compareTo آن برای دو شیءِ متمایز 0 برگرداند. TreeSet/TreeMap برابری را با کمپراتور تعریف می‌کنند و equals را نادیده می‌گیرند. یک کمپراتورِ ناسازگار با equals، اشیای «متفاوت» را در یکی فرو می‌ریزد و یکی از آن‌ها بی‌صدا حذف می‌شود.

۱۵. پیمایش موریس — چه مشکلی را حل می‌کند؟

پیمایشِ میان‌ترتیب در فضای اضافیِ O(1) (بدون پشته/بازگشت)، با نخ‌کشیِ اشاره‌گرِ راستِ برگ به جانشینِ میان‌ترتیب و سپس بازکردنِ نخ. زمان O(n). این پاسخِ سنیورِ «ترفند را بلدم» برای پیمایشِ محدودِ حافظه است.

جمع‌بندی
  • درخت یک گراف همبندِ بدون‌دور با ریشه است؛ هر عملیات O(height) هزینه دارد، پس نگه‌داشتن ارتفاع در O(log n) کل بازی است.
  • BST ناوردای ترتیب دارد؛ in-order یک BST مرتب است — پرکاربردترین واقعیتِ فصل.
  • پیمایش‌ها را هم بازگشتی و هم تکراری بلد باش (برای درختِ کج، تا StackOverflowError نگیری)؛ Morris نسخهٔ فضای O(1) است.
  • در حذفِ BST با دو فرزند، جانشین/سلفِ میان‌ترتیب را ارتقا بده.
  • AVL صلب و خواندن‌محور؛ Red-Black شل و نوشتن‌محور. TreeMap/TreeSet و (از جاوا ۸) سطل‌های شلوغِ HashMap از Red-Black استفاده می‌کنند.
  • دیتابیس‌ها B+ tree به‌کار می‌برند: انشعابِ بالا و برگ‌های زنجیرشده، برای کمینه‌کردنِ I/O و پویشِ بازه‌ایِ ترتیبی.
  • Heap خاصیتِ ضعیف‌ترِ والد/فرزند دارد: کمینه/بیشینه در O(1)، درج/استخراج در O(log n)، جست‌وجوی دلخواه در O(n). heapifyِ پایین‌به‌بالا O(n) است. PriorityQueue مرتب پیمایش نمی‌شود.
  • Trie جست‌وجوی O(L) و پرس‌وجوی پیشوندی می‌دهد؛ برای صرفه‌جوییِ حافظه از radix/Patricia استفاده کن.

Let's build one of the most-tested families of data structures from the ground up. Trees are everywhere: in Java's TreeMap, in the index of every database you've ever queried, in your phone's autocomplete, and in the priority queue of your OS scheduler. Master this chapter and you've pocketed a huge slice of any senior-level algorithm interview.

Roadmap for this chapter

First we plant a few core words with analogies (node, root, leaf, height). Then we walk forward step by step:

  1. Tree, binary tree, and BST — the differences and the ordering invariant.
  2. Traversals (in/pre/post-order and BFS), recursive and iterative, all the way to Morris.
  3. BST operations: search, insert, and the gotcha-laden delete.
  4. Balancing: AVL vs Red-Black, rotations, and why Java's TreeMap and HashMap chose Red-Black.
  5. B-trees and B+ trees: why databases love them over BSTs.
  6. Heaps and PriorityQueue: min/max in O(log n) and the heapify trick.
  7. Tries: prefix search and autocomplete.
  8. Classic problems and 15 interview questions with full answers.

Part 0 — words you must know

Before anything else, let's anchor a handful of words that recur throughout, each with a simple picture so you never get stuck.

A tree is like a company org chart

Picture a tree as a company's org chart. The top person is the CEO; below them are managers, below them employees. Every employee has exactly one direct boss (except the CEO, who has none). There are no loops — you can't be your own boss's boss. That's exactly what mathematicians call a "connected acyclic graph."

With that picture, the words become easy:

  • Node: any "box" in the chart; one unit of data.
  • Root: the CEO — the top node, which has no parent.
  • Parent / child: the direct boss-employee relationship.
  • Leaf: an employee with no reports — a node with no children.
  • Subtree: a manager together with everyone under them; itself a smaller tree.
  • Height: the longest chain of command from CEO down to the lowest employee (counting edges).
  • Size: the total number of nodes, which we call n.
  • Invariant: a rule that must always hold no matter what operation you run — like "every employee has exactly one boss," which must never be violated.
Why height is everything

Almost every operation on a tree (search, insert, delete) costs as much as the tree's height, i.e. O(height). The entire "balanced trees" saga is a war to keep height around O(log n) instead of O(n). A good tree is a short, wide hierarchy; a bad tree is a long single-file line.

Mental model: from tree to BST

Binary tree

Now let's constrain the tree: say each node has at most two children, a left (left) and a right (right). That's a binary tree. Note there's no ordering between values by default — only the at-most-two-children shape matters.

Binary search tree (BST)

A library where everything has its place

An unordered binary tree is like a heap of books on the floor: you have two children per node, but no order. Now add a rule: "any book with a smaller number goes left; any bigger book goes right." Suddenly the pile becomes a sorted library where you can find any book by repeatedly halving the shelves. That rule is what makes a BST.

A binary search tree (BST) is a binary tree that adds this ordering invariant: for every node, all keys in the left subtree are strictly smaller and all keys in the right subtree are strictly greater (assuming no duplicates). That single rule turns an ordinary binary tree into a searchable structure — because at every node you know which way to go.

Tree-shape vocabulary

A few adjectives you'll keep hearing; let's nail them in one line each:

  • Complete: every level is full except possibly the last, which fills left-to-right. Heaps are exactly this shape, which is why they fit in an array.
  • Full / proper: every node has either zero or exactly two children — no lonely single-child nodes.
  • Perfect: all leaves sit at the same depth; a full triangle.
  • Balanced: height stays around O(log n). This is what we want.
A fact that comes in handy

A complete tree of n nodes has height exactly ⌊log₂ n⌋. That's precisely why a heap (which is always complete) can be stored with no pointers at all, in a flat array — as you'll see in the Heap section.

Let's look at a sample tree and its traversals. Keep this picture; we'll refer back to it:

        8                 Traversal orders on this tree:
       / \                pre  : 8 3 1 6 4 7 10 14 13   (node, left, right)
      3   10              in   : 1 3 4 6 7 8 10 13 14   (left, node, right)  -> sorted for a BST!
     / \    \             post : 1 4 7 6 3 13 14 10 8   (left, right, node)
    1   6    14           level: 8 3 10 1 6 14 4 7 13   (BFS)
       / \   /
      4   7 13

Traversals: four ways to walk a tree

A "traversal" means visiting every node in some defined order. We have three depth-first traversals (in/pre/post-order) and one breadth-first (BFS).

Three different moments to "do your own work"

Every depth-first traversal goes left then right; the only difference is when you do the current node's work:

  • Pre-order: me first, then my children. Like a boss who signs off first, then delegates.
  • In-order: left child first, then me, then right child.
  • Post-order: all children first, then me. Like a manager who won't sign until every team report is in.
The single most important fact in this chapter

In-order traversal of a BST yields the keys in exactly ascending sorted order. Memorize this. Validate-BST, kth-smallest, and range queries all ride on this one fact.

Node definition

public class TreeNode {
    int val;
    TreeNode left, right;
    TreeNode(int val) { this.val = val; }
}

Recursive traversals

The recursive version is beautiful: you just move one line to go from in-order to pre- or post-order.

void inorder(TreeNode n, List<Integer> out) {
    if (n == null) return;
    inorder(n.left, out);
    out.add(n.val);          // move this line up for pre-order, down for post-order
    inorder(n.right, out);
}
Recursion on a skewed tree blows the stack

Recursion is clean, but it uses the call stack, and its depth is O(height). If your tree is degenerate — stretched out like a one-way linked list — that depth reaches O(n) and the program crashes with StackOverflowError. That's why senior candidates are expected to know the iterative versions that manage the stack by hand.

Iterative in-order (explicit stack)

The trick: dive as far down the left spine as you can, pushing everything; then pop one at a time and visit each node's right.

List<Integer> inorderIter(TreeNode root) {
    List<Integer> out = new ArrayList<>();
    Deque<TreeNode> stack = new ArrayDeque<>();
    TreeNode cur = root;
    while (cur != null || !stack.isEmpty()) {
        while (cur != null) {        // dive down the left spine
            stack.push(cur);
            cur = cur.left;
        }
        cur = stack.pop();           // leftmost unvisited node
        out.add(cur.val);
        cur = cur.right;             // then explore its right subtree
    }
    return out;
}

Iterative pre-order

List<Integer> preorderIter(TreeNode root) {
    List<Integer> out = new ArrayList<>();
    if (root == null) return out;
    Deque<TreeNode> stack = new ArrayDeque<>();
    stack.push(root);
    while (!stack.isEmpty()) {
        TreeNode n = stack.pop();
        out.add(n.val);
        if (n.right != null) stack.push(n.right); // push right FIRST so left pops first
        if (n.left != null)  stack.push(n.left);
    }
    return out;
}
Why push right first?

A stack is LIFO (last in, first out). Since we want left visited before right, we must push right first so it sits underneath left and comes out later. That little inversion is the whole magic of iterative pre-order.

Iterative post-order is the trickiest: either use two stacks, or do a modified pre-order (node, right, left) and reverse the result at the end, or track a lastVisited pointer. The reverse trick is the cleanest:

List<Integer> postorderIter(TreeNode root) {
    LinkedList<Integer> out = new LinkedList<>();
    Deque<TreeNode> stack = new ArrayDeque<>();
    if (root != null) stack.push(root);
    while (!stack.isEmpty()) {
        TreeNode n = stack.pop();
        out.addFirst(n.val);                      // prepend -> reverses pre-order-ish output
        if (n.left != null)  stack.push(n.left);
        if (n.right != null) stack.push(n.right);
    }
    return out;
}

Level-order (BFS) with per-level grouping

Breadth-first means reading the tree floor by floor, like reading a building from the ground up. The tool here is a queue, not a stack.

A bakery line for BFS

BFS works exactly like a bakery line: every node that arrives stands at the back, and we always serve from the front. When we serve a node, we add its children to the back. The result is that every node on one floor gets served before the next floor.

List<List<Integer>> levelOrder(TreeNode root) {
    List<List<Integer>> res = new ArrayList<>();
    if (root == null) return res;
    Queue<TreeNode> q = new LinkedList<>();
    q.offer(root);
    while (!q.isEmpty()) {
        int size = q.size();                      // snapshot: exactly one level
        List<Integer> level = new ArrayList<>(size);
        for (int i = 0; i < size; i++) {
            TreeNode n = q.poll();
            level.add(n.val);
            if (n.left != null)  q.offer(n.left);
            if (n.right != null) q.offer(n.right);
        }
        res.add(level);
    }
    return res;
}
Don't forget the `q.size()` snapshot

The line int size = q.size() at the top of each iteration snapshots "how big is this exact level." Miss it and loop directly over the queue, and the levels blur together — you get a flat, jumbled list instead of a nicely grouped list-of-levels.

Morris traversal — in-order in O(1) space

The "I know the advanced trick" answer

Morris traversal does in-order in O(1) extra space — no stack, no recursion. How? It temporarily rewires each leaf's right pointer to its in-order successor (this is called a "threaded" binary tree), uses that thread to climb back up, then undoes the thread so the tree returns to normal. O(n) time, O(1) space. If an interviewer asks "how do you do in-order without a stack?", this is the winning answer.

BST operations: search, insert, and the gotcha delete

Search, insert, and delete are all O(height). Search and insert are straightforward — at each node you decide left or right. But delete is where most people trip.

class BST {
    TreeNode root;

    boolean contains(int key) {
        TreeNode n = root;
        while (n != null) {
            if (key == n.val) return true;
            n = key < n.val ? n.left : n.right;
        }
        return false;
    }

    TreeNode insert(TreeNode n, int key) {
        if (n == null) return new TreeNode(key);
        if (key < n.val)      n.left  = insert(n.left, key);
        else if (key > n.val) n.right = insert(n.right, key);
        // equal -> ignore (or store a count for a multiset)
        return n;
    }

    TreeNode delete(TreeNode n, int key) {
        if (n == null) return null;
        if (key < n.val)      n.left  = delete(n.left, key);
        else if (key > n.val) n.right = delete(n.right, key);
        else {
            // found the node to delete
            if (n.left == null)  return n.right;   // 0 or 1 child (right)
            if (n.right == null) return n.left;    // 1 child (left)
            // two children: replace with in-order successor (smallest in right subtree)
            TreeNode succ = n.right;
            while (succ.left != null) succ = succ.left;
            n.val = succ.val;                       // copy successor's key up
            n.right = delete(n.right, succ.val);    // delete successor (has no left child)
        }
        return n;
    }
}
Deleting a node with two children: the classic gotcha

When the node you want to delete has two children, you can't just remove it — two subtrees would be left dangling. The fix: find the in-order successor, i.e. the leftmost node of the right subtree (the smallest value still greater than the current node). That node is guaranteed to have at most one child, so it's easy to delete. Copy its value up, then delete that successor. (You could equally use the in-order predecessor — the rightmost node of the left subtree.) If you try to re-link both subtrees ad hoc, the BST invariant breaks.

The fatal weakness of a plain, unbalanced BST

Insert 1, 2, 3, 4, 5 in order into a plain BST. What happens? Each number is bigger than the last, so it always goes right, and the tree degenerates into a right-leaning stick — effectively a linked list. Now the height is O(n) and so is search. All of the BST's advantage evaporated. This is exactly why self-balancing trees were invented.

Balancing: AVL vs Red-Black

Both of these are self-balancing: after every insert/delete they rearrange themselves via rotations to keep height O(log n). But they follow different philosophies.

The perfectionist librarian vs the pragmatic one

Imagine two librarians. AVL is the perfectionist: after every single book that moves, she re-sorts the whole shelf to stay perfectly balanced — so finding a book is blazing fast, but each move is expensive. Red-Black is the pragmatist: she keeps the shelf "good enough" and only reshuffles when she truly must — so inserts/deletes are cheaper, at the cost of shelves that are sometimes a bit less tidy.

AVL trees maintain a strict invariant: for every node, the heights of its two subtrees differ by at most 1 (balance factor ∈ {-1, 0, +1}). Being "more rigidly balanced" means faster lookups, but inserts/deletes may need more rotations to restore that tight invariant.

Red-Black trees are looser. They color nodes red or black and enforce five rules; the key consequence is that no root-to-leaf path is more than twice as long as the shortest, so height stays ≤ 2·log₂(n+1). Fewer rotations on mutation (at most 2 for insert, 3 for delete), at the cost of a slightly taller tree.

AVL Red-Black
Balance guarantee height ≈ 1.44·log₂n height ≤ 2·log₂n
Lookup speed faster (tighter) slightly slower
Insert/delete rotations more fewer (O(1) amortized)
Best for read-heavy write-heavy / general
Real users DB indexes (some), memory DBs Java TreeMap/TreeSet, Linux CFS, C++ std::map

Rotations — the primitive both share

A rotation is a local rearrangement that preserves the BST ordering but changes the shape to reduce height. Look at the diagram inside the code: y moves down and x moves up, yet A < x < B < y < C still holds.

// Right rotation around y; x becomes the new subtree root.
//        y            x
//       / \          / \
//      x   C   ->    A   y
//     / \               / \
//    A   B             B   C
TreeNode rotateRight(TreeNode y) {
    TreeNode x = y.left;
    TreeNode B = x.right;
    x.right = y;
    y.left  = B;
    // (in AVL: recompute heights of y then x here)
    return x;                 // new root of this subtree
}

Why TreeMap uses Red-Black

java.util.TreeMap and TreeSet are Red-Black trees under the hood. What's the design reasoning? A general-purpose ordered map faces a mix of reads and writes. Red-Black's cheaper rebalancing (fewer rotations per mutation) wins on that realistic mixed workload. AVL's tighter balance only pays off when reads massively dominate. On top of that, Red-Black has a bounded worst-case rotation count, which matters for predictable latency.

All of get/put/remove/ceilingKey/floorKey/firstKey are O(log n); in-order iteration is O(n) and yields sorted keys.

TreeMap<Integer, String> map = new TreeMap<>();
map.put(10, "a"); map.put(5, "b"); map.put(20, "c");
map.firstKey();          // 5   (leftmost)
map.ceilingKey(6);       // 10  (least key >= 6)
map.floorKey(15);        // 10  (greatest key <= 15)
map.headMap(10);         // {5=b}  view of keys < 10, backed by the tree
map.subMap(5, true, 20, false); // range view, O(log n) to locate endpoints
HashMap treeification since Java 8

Since Java 8, HashMap also uses a Red-Black tree, but only in one special case. When several keys hash badly and pile up in one bucket, that linked-list chain can grow long and slow. Java's fix: when a bucket holds ≥ 8 entries and the whole table has ≥ 64 buckets, that bucket converts from a linked list into a Red-Black tree (this is called treeification), turning worst-case bucket lookup from O(n) into O(log n). If the table is smaller than 64, it prefers to resize instead of treeifying. When entries drop below 6, it converts back to a list (untreeify).

Why databases use B-trees / B+ trees, not BSTs

Trips to a warehouse across town

Imagine each tree node lives on a disk page, and reading a page is like walking to a distant warehouse — slow and costly. If your tree has fan-out 2, then for a billion rows you'd have to make about 30 round trips; a catastrophe. But if you make each "shelf" so big it holds hundreds of books at once, the whole job is done in 3-4 trips. That's the B-tree idea: wide shelves instead of narrow ones.

A balanced BST has fan-out 2 — each node has only two children. For a billion rows its height is ~30. On disk/SSD, every node access is potentially a page read, so 30 random I/Os per lookup is catastrophic.

B-trees and B+ trees are m-way balanced trees: each node holds hundreds of keys, sized to one disk page (say 8-16 KB). With fan-out ~500, a billion rows fit in a tree of height 3-4 → just 3-4 page reads. The whole point is this: minimize I/O by maximizing fan-out.

The defining advantage of a B+ tree: linked leaves

The B+ tree (used by PostgreSQL, MySQL InnoDB, and most RDBMS indexes) refines the idea: internal nodes hold only keys (pure routing), and all the actual data/pointers live in the leaves. Crucially, the leaves are linked into a doubly-linked list. That makes range scans like WHERE x BETWEEN a AND b or ORDER BY trivial: find the start leaf, then walk the leaf chain sequentially. That leaf linking is the defining B+ advantage over a plain B-tree.

Heaps and PriorityQueue

The heap property

A military hierarchy

A heap is like a military hierarchy, but with a weaker rule than a BST. In a min-heap, every commander has a smaller rank than each of their direct subordinates (smaller = more important), so the smallest value sits at the very top. But note: there's no order between two subordinates of the same person — only the parent/child relationship matters, not left/right. That's why you can instantly look at the supreme commander, but to find a specific soldier you'd have to scan the whole army.

A binary heap is a complete binary tree satisfying the heap property: in a min-heap every parent ≤ its children (root is the minimum); in a max-heap every parent ≥ its children (root is the maximum). Because this invariant is weaker than a BST's, a heap gives you O(1) peek at the min/max and O(log n) insert/extract, but arbitrary-value search is O(n).

Stored in an array — with no pointers at all

Because a heap is always complete, you don't need any pointers; the whole tree fits in a flat array. For 0-indexed arrays:

  • parent of i = (i-1)/2
  • left child = 2i+1, right child = 2i+2
// Min-heap sift operations, array-backed.
void siftUp(int[] h, int i) {              // after inserting at the end
    while (i > 0) {
        int p = (i - 1) / 2;
        if (h[i] >= h[p]) break;           // parent already smaller -> done
        swap(h, i, p);
        i = p;
    }
}
void siftDown(int[] h, int i, int size) {  // after removing root (moved last elem to root)
    while (true) {
        int l = 2*i + 1, r = 2*i + 2, smallest = i;
        if (l < size && h[l] < h[smallest]) smallest = l;
        if (r < size && h[r] < h[smallest]) smallest = r;
        if (smallest == i) break;
        swap(h, i, smallest);
        i = smallest;
    }
}

You call siftUp after inserting at the end: bubble the newcomer up until it's no smaller than its parent. You call siftDown after removing the root: move the last element to the top and sink it until the heap property holds.

(Hard) Building a heap from `n` elements: `O(n)` or `O(n log n)`?

This is one of the most beloved interview traps. If you build the heap by repeated insertion (siftUp per element), it costs O(n log n). But if you already have the array and heapify bottom-up — running siftDown on every internal node from n/2-1 down to 0 — it costs only O(n), not O(n log n).

Why? Most nodes are near the bottom of the tree and can only sift a tiny distance; only a few nodes near the root have far to travel. The math sum Σ (n/2^h)·h converges to 2n, i.e. linear. So when an interviewer asks "how long does heapify take?", the correct answer hinges on direction (bottom-up vs top-down) and on where most nodes live.

Java's PriorityQueue

java.util.PriorityQueue is a binary min-heap by default (natural ordering), unbounded, array-backed. Pass a Comparator to change the ordering. A few critical caveats every senior must know:

  • offer/poll are O(log n); peek is O(1).
  • remove(Object) and contains(Object) are O(n) — they linear-scan, because a heap isn't searchable by value.
  • Iteration order is NOT sorted. for (x : pq) walks the backing array in arbitrary heap order. Only repeated poll() yields sorted output.
  • It is not thread-safe; use PriorityBlockingQueue for concurrency.
  • Duplicates are allowed; null is not.
// Max-heap: reverse comparator
PriorityQueue<Integer> maxHeap = new PriorityQueue<>(Comparator.reverseOrder());

// k-th LARGEST element: keep a size-k MIN-heap of the largest-so-far.
int kthLargest(int[] nums, int k) {
    PriorityQueue<Integer> minHeap = new PriorityQueue<>(); // min-heap
    for (int x : nums) {
        minHeap.offer(x);
        if (minHeap.size() > k) minHeap.poll(); // evict smallest -> heap holds top k
    }
    return minHeap.peek();  // smallest of the top-k = k-th largest. O(n log k)
}
The counter-intuitive "k-th largest" trick

To find the k-th largest, use a size-k min-heap (yes, min, for largest!). That min-heap keeps only the current top-k elements, and its root is the smallest of those top-k — which is exactly the k-th largest overall. O(n log k) time, O(k) space. The alternative — a max-heap of all n elements — also works but costs O(n + k log n) time and O(n) space. When k ≪ n, the size-k min-heap version clearly wins.

Java has PriorityQueue-based patterns for other classics too: top-K frequent, merge k sorted lists, and median of a stream (using two heaps — a max-heap for the lower half and a min-heap for the upper half).

Tries (prefix trees)

A dictionary's tree-shaped index

A trie is like the internal structure of a dictionary that branches by letters. You start at the root; to reach the word "cat" you follow the c branch, then a, then t. The beauty: every word starting with "ca" is gathered under that same "ca" node. That's why autocomplete with a trie is trivial — just reach the prefix node and search downward from there.

A trie is an m-ary tree keyed by the characters of strings; the path from root to a node spells a prefix. Tries give O(L) insert/search where L is the key length — independent of how many keys are stored — and, crucially, native prefix queries: autocomplete, longest-prefix match (routing tables), spell-check, IP lookup.

class Trie {
    static class Node {
        Node[] next = new Node[26];   // fixed alphabet a-z; use HashMap<Character,Node> for Unicode
        boolean isWord;
    }
    private final Node root = new Node();

    void insert(String w) {
        Node cur = root;
        for (char c : w.toCharArray()) {
            int i = c - 'a';
            if (cur.next[i] == null) cur.next[i] = new Node();
            cur = cur.next[i];
        }
        cur.isWord = true;            // mark terminal
    }

    boolean search(String w) { Node n = find(w); return n != null && n.isWord; }
    boolean startsWith(String p) { return find(p) != null; }

    private Node find(String s) {
        Node cur = root;
        for (char c : s.toCharArray()) {
            int i = c - 'a';
            if (cur.next[i] == null) return null;
            cur = cur.next[i];
        }
        return cur;
    }

    // Autocomplete: collect all words under the prefix node.
    List<String> autocomplete(String prefix, int limit) {
        List<String> res = new ArrayList<>();
        Node start = find(prefix);
        if (start != null) dfs(start, new StringBuilder(prefix), res, limit);
        return res;
    }
    private void dfs(Node n, StringBuilder path, List<String> res, int limit) {
        if (res.size() >= limit) return;
        if (n.isWord) res.add(path.toString());
        for (char c = 'a'; c <= 'z'; c++) {
            Node child = n.next[c - 'a'];
            if (child != null) {
                path.append(c);
                dfs(child, path, res, limit);
                path.deleteCharAt(path.length() - 1); // backtrack
            }
        }
    }
}
The trie trade-off: memory

A fixed 26-pointer array per node is wasteful for sparse data — lots of empty slots. Production tries instead put a HashMap<Character,Node> at each node, or compress single-child chains into one edge — that's a radix tree / Patricia trie (used in Java's own routing, IP tables, and some databases). A trie beats a hash map precisely when you need prefix operations — something hashing can never do, because hashing destroys locality and ordering. For prefix counting, store a count in each node.

Classic problems

Validate a BST (the #1 gotcha)

// WRONG naive attempt only checks node vs immediate children — misses violations
// across subtrees. Correct: carry down (min, max) bounds.
boolean isValidBST(TreeNode n, long min, long max) {
    if (n == null) return true;
    if (n.val <= min || n.val >= max) return false;      // must be strictly within bounds
    return isValidBST(n.left, min, n.val)                // tighten upper bound
        && isValidBST(n.right, n.val, max);              // tighten lower bound
}
// call: isValidBST(root, Long.MIN_VALUE, Long.MAX_VALUE)

The idea: instead of comparing a node only against its immediate children, you carry a permitted range (min, max) down as you descend, tightening it. Go left and the ceiling becomes the current node's value; go right and the floor does.

Use `long` bounds

If your bounds are int, a node holding exactly Integer.MAX_VALUE or Integer.MIN_VALUE can be wrongly rejected. Using long covers that edge. The elegant alternative: instead of bounds, do an in-order traversal and verify the sequence is strictly increasing — because you remember that a BST's in-order is always sorted.

Lowest Common Ancestor

For a BST, exploit the ordering — O(height):

TreeNode lcaBST(TreeNode n, int p, int q) {
    while (n != null) {
        if (p < n.val && q < n.val)      n = n.left;   // both smaller -> go left
        else if (p > n.val && q > n.val) n = n.right;  // both larger -> go right
        else return n;                                 // split point = LCA
    }
    return null;
}

The idea: the first node where p and q fall on opposite sides (or one of them is the node itself) is the lowest common ancestor.

For a general binary tree (no ordering), recurse and find where the two targets split:

TreeNode lca(TreeNode root, TreeNode p, TreeNode q) {
    if (root == null || root == p || root == q) return root;
    TreeNode l = lca(root.left, p, q);
    TreeNode r = lca(root.right, p, q);
    if (l != null && r != null) return root;   // p and q found in different subtrees
    return l != null ? l : r;                  // both on one side (or neither)
}

Serialize / deserialize (pre-order with null markers)

static final String NULL = "#", SEP = ",";

String serialize(TreeNode n) {
    StringBuilder sb = new StringBuilder();
    ser(n, sb);
    return sb.toString();
}
void ser(TreeNode n, StringBuilder sb) {
    if (n == null) { sb.append(NULL).append(SEP); return; }
    sb.append(n.val).append(SEP);
    ser(n.left, sb);
    ser(n.right, sb);
}
TreeNode deserialize(String data) {
    Deque<String> q = new ArrayDeque<>(Arrays.asList(data.split(SEP)));
    return deser(q);
}
TreeNode deser(Deque<String> q) {
    String t = q.poll();
    if (NULL.equals(t)) return null;
    TreeNode n = new TreeNode(Integer.parseInt(t));
    n.left  = deser(q);   // pre-order: left subtree consumed first
    n.right = deser(q);
    return n;
}
Why the null markers are needed

Pre-order with explicit null markers is self-delimiting — you can reconstruct the tree uniquely from that single string, no second traversal required. A single traversal without nulls is ambiguous; different trees can produce the same output. Without nulls, you'd need in-order plus pre- or post-order to disambiguate. The # markers remove that need.

Common pitfalls

  • Recursion depth: deep/skewed trees blow the stack. Know the iterative forms.
  • BST duplicates: decide your policy (reject, count, or lean-left/right consistently) — an inconsistent rule silently corrupts search.
  • == vs .equals in a tree of objects: identity-vs-value comparison bugs.
  • Integer overflow in isValidBST bounds and in (lo+hi)/2 midpoints elsewhere.
  • PriorityQueue iteration is unordered — a top source of "why is my output wrong" bugs.
  • Comparator inconsistency with equals breaks TreeMap/TreeSet — two keys that compare 0 are treated as equal regardless of equals, so a bad comparator can silently drop entries.
  • Heap remove(Object) is O(n) — don't build a Dijkstra on it naively; use decrease-key patterns or lazy deletion.

Best practices

  • Reach for TreeMap/TreeSet when you need ordered iteration, floor/ceiling/higher/lower, or range views — not just membership (use HashMap for that).
  • Use PriorityQueue for streaming top-K, scheduling, Dijkstra/Prim, and merge problems. For the top-K-largest pattern, remember the size-k min-heap trick.
  • Use a trie only when you genuinely need prefix operations; otherwise a HashMap is simpler and often faster for exact-match.
  • For persistent/on-disk ordered data, it's B+ trees under the hood — understand fan-out and leaf-linking even if you never implement one.
  • Prefer iterative traversals in library/production code where input depth is untrusted.

Interview Questions

1. In-order traversal of a BST produces what? Why is that useful?

Sorted ascending order. It underpins validate-BST, kth-smallest, and range extraction in O(n) / O(h+k). If you remember only one fact from this chapter, make it this one.

2. Why does `TreeMap` use a Red-Black tree instead of AVL?

Both are O(log n), but Red-Black needs fewer rotations per insert/delete (≤2 / ≤3) and has bounded rebalancing, winning on mixed read/write workloads. AVL's tighter balance only pays for read-dominated loads.

3. (Hard) Building a heap from `n` elements — `O(n)` or `O(n log n)`?

Bottom-up siftDown heapify is O(n) (the height-weighted sum Σ (n/2^h)·h converges to 2n). Building by repeated siftUp insertion is O(n log n). The distinction hinges on the direction and where most nodes live (near the bottom).

4. Find the k-th largest in a stream. Which heap and why?

A size-k min-heap. Its root is the smallest of the current top-k, i.e. the k-th largest. O(n log k) time, O(k) space — beats sorting or an n-element max-heap when k ≪ n.

5. (Gotcha) Why is this validate-BST wrong?
boolean bad(TreeNode n){ if(n==null) return true;
  if(n.left!=null && n.left.val>=n.val) return false;
  if(n.right!=null && n.right.val<=n.val) return false;
  return bad(n.left)&&bad(n.right); }

It only checks immediate children. A node deep in the left subtree can exceed a higher-up ancestor and still pass this check. You must carry down (min, max) bounds (or use in-order monotonicity).

6. Delete a node with two children from a BST — how?

Replace its key with the in-order successor (leftmost of the right subtree) or predecessor (rightmost of left) — which has ≤1 child — then delete that node. Naively removing the node itself breaks the tree, because two subtrees would be left dangling.

7. Why B+ trees for DB indexes and not balanced BSTs?

Fan-out. A BST has fan-out 2 → height ~30 for 1B rows → 30 random I/Os. B+ trees pack hundreds of keys per disk page → fan-out ~500 → height 3-4. Plus B+ leaves are linked, making range scans sequential I/O.

8. What does a heap NOT give you that a BST does?

Ordered traversal and O(log n) arbitrary search. A heap only orders parent vs child, so searching for an arbitrary value is O(n). That's exactly why PriorityQueue.contains/remove(Object) is O(n).

9. (Gotcha) What does this print?
PriorityQueue<Integer> pq = new PriorityQueue<>();
pq.addAll(List.of(5,1,3,2,4));
System.out.println(pq);   // ?

Not [1,2,3,4,5]. It prints the backing array in heap order, e.g. [1, 2, 3, 5, 4]. Only repeated poll() yields sorted output. Iteration/toString are unordered.

10. When does `HashMap` become a tree, and to what?

Since Java 8, a bucket with a degenerate collision chain treeifies into a Red-Black tree at ≥8 entries when the table has ≥64 buckets (else it resizes instead), giving O(log n) worst-case bucket lookup. It untreeifies below 6.

11. Trie vs HashMap for autocomplete?

A HashMap does O(1) exact match but cannot do prefix queries — hashing destroys ordering and locality. A trie does O(L) lookup and enumerates all keys under a prefix by DFS from the prefix node. A trie costs more memory (mitigate with radix/Patricia compression).

12. (Hard) Serialize a binary tree so it round-trips. What's the minimal info?

Pre-order (or level-order) with explicit null markers is self-delimiting and reconstructs uniquely. A single traversal without nulls is ambiguous; you'd need in-order plus pre- or post-order to disambiguate.

13. What's the balance invariant of AVL vs Red-Black?

AVL: per-node subtree heights differ by ≤1 (height ≈1.44 log n). Red-Black: 5 color rules ⇒ no path more than 2× another ⇒ height ≤ 2 log(n+1).

14. (Gotcha) A `TreeSet` silently drops an element you added. Why?

Because its Comparator/compareTo returned 0 for two distinct objects. TreeSet/TreeMap define equality by the comparator, ignoring equals. An inconsistent-with-equals comparator collapses "different" objects into one, and one of them gets silently dropped.

15. Morris traversal — what problem does it solve?

In-order traversal in O(1) extra space (no stack/recursion) by threading leaf right-pointers to the in-order successor, then unthreading. O(n) time. The senior "I know the trick" answer to space-constrained traversal.

In a nutshell
  • A tree is a connected acyclic graph with a root; every operation costs O(height), so keeping height at O(log n) is the whole game.
  • A BST adds the ordering invariant; a BST's in-order is sorted — the most-used fact in the chapter.
  • Know traversals both recursively and iteratively (for skewed trees, to avoid StackOverflowError); Morris is the O(1)-space version.
  • In BST delete with two children, promote the in-order successor/predecessor.
  • AVL is rigid and read-heavy; Red-Black is loose and write-heavy. TreeMap/TreeSet and (since Java 8) crowded HashMap buckets use Red-Black.
  • Databases use B+ trees: high fan-out and linked leaves, to minimize I/O and make range scans sequential.
  • A heap has the weaker parent/child property: min/max in O(1), insert/extract in O(log n), arbitrary search in O(n). Bottom-up heapify is O(n). PriorityQueue doesn't iterate in sorted order.
  • A trie gives O(L) lookup and prefix queries; use radix/Patricia to save memory.