সরাসরি পর্বে যান
পর্ব ২৯ · ৭২-এর মধ্যে · মডিউল ৪

Red-black tree আর ভারসাম্যপূর্ণ tree-এর তুলনা

Red-Black Trees & Balanced Search Trees Compared — Five Rules, the Height Proof, 2-3-4 Trees, Insert Fix-up, Double-Black Deletion, LLRB, AVL vs Treap vs Skip List vs B-tree vs Splay

Taught in Bangla, this lesson covers the five red-black tree rules, the black-height proof that height stays within 2 log(n+1), the link to 2-3-4 trees, insertion fix-up with a full traced example, double-black deletion, left-leaning red-black trees, and when to choose AVL, treap, skip list, B-tree or splay tree, with C++ and Python code.

এই পর্বে যা শিখবেন

  • Red-black tree-র পাঁচটি নিয়ম মুখস্থ নয়, কারণসহ বোঝা — আর একটি tree বৈধ কি না যাচাই করা
  • Black-height আর induction দিয়ে উচ্চতা ≤ 2 log₂(n+1)-এর প্রমাণ নিজে লিখতে পারা
  • যেকোনো red-black tree-কে 2-3-4 tree হিসেবে দেখা, আর fix-up-এর কেসগুলোকে node ভাঙা বা জোড়া হিসেবে ব্যাখ্যা করা
  • Insert-এর তিনটি কেস হাতে trace করা, আর C++/Python-এ পুরো insert ও যাচাই লেখা
  • AVL, red-black, treap, skip list, B-tree আর splay tree-র মধ্যে কাজ অনুযায়ী সঠিকটি বেছে নেওয়া

১ · AVL-এর পরে আরেকটি নিয়ম কেন — “যথেষ্ট ভালো” ভারসাম্য

পর্ব ২৭-এ দেখেছেন, একটি সাধারণ binary search tree (BST) সাজানো ক্রমে ইনপুট পেলে একটি লম্বা লাঠির মতো হয়ে যায় — উচ্চতা n − 1, আর প্রতিটি খোঁজা O(n). পর্ব ২৮-এর AVL tree এর সমাধান দিয়েছে কঠোর একটি নিয়মে: প্রতিটি node-এর বাঁ আর ডান subtree-র উচ্চতার পার্থক্য সর্বোচ্চ ১। নিয়ম ভাঙলেই rotation করে ঠিক করা হয়।

AVL চমৎকার কাজ করে, কিন্তু তার কঠোরতার একটি দাম আছে। প্রতিটি insert বা delete-এর পর পথের প্রতিটি node-এর উচ্চতা হালনাগাদ করতে হয়, আর delete-এর পর rotation-এর একটি ঢেউ root পর্যন্ত চলতে পারে — সবচেয়ে খারাপ ক্ষেত্রে O(log n) টি rotation. যে প্রোগ্রামে প্রতি সেকেন্ডে লাখো insert আর delete হয় (যেমন operating system-এর scheduler, বা একটি ব্যস্ত সার্ভারের timer-তালিকা), সেখানে প্রশ্ন জাগে: এত নিখুঁত ভারসাম্য কি সত্যিই দরকার? যদি উচ্চতা “সর্বোচ্চ দ্বিগুণ” হলেও চলে, কিন্তু প্রতিটি পরিবর্তন সস্তা হয়?

ঢাকার একটি কল্পিত রাস্তা-ব্যবস্থার কথা ভাবুন। শহরের কেন্দ্র (root) থেকে প্রতিটি শেষ গন্তব্যে যাওয়ার রাস্তায় কয়েকটি ট্রাফিক সিগন্যাল (কালো node) আছে, আর মাঝে মাঝে সিগন্যালবিহীন ছোট সংযোগ সড়ক (লাল node). সিটি কর্পোরেশন দুটি নিয়ম দিল: (১) কেন্দ্র থেকে যেকোনো গন্তব্যে যেতে ঠিক সমান সংখ্যক সিগন্যাল পার হতে হবে; (২) দুটি সংযোগ সড়ক কখনো পরপর থাকবে না — প্রতিটি সংযোগ সড়কের পরেই একটি সিগন্যাল। তাহলে সবচেয়ে ছোট রুটে হয়তো শুধু সিগন্যাল, আর সবচেয়ে লম্বা রুটে সিগন্যাল আর সংযোগ সড়ক পালা করে — কিন্তু লম্বা রুট কখনো ছোটটির দ্বিগুণের বেশি হতে পারে না। রাস্তাগুলো পুরো সমান নয়, তবে কোনোটিই অসহ্য রকম লম্বা নয়। Red-black tree ঠিক এই চুক্তি।

এই “ঢিলেঢালা কিন্তু নিশ্চিত” ভারসাম্যই red-black tree-র মূল কথা। এটি একটি self-balancing BST: প্রতিটি node-এ শুধু একটি বাড়তি bit (লাল না কালো), উচ্চতা সবসময় 2 log₂(n+1)-এর মধ্যে, আর প্রতিটি insert-এ সর্বোচ্চ ২টি ও delete-এ সর্বোচ্চ ৩টি rotation. রং বদল (recoloring) কখনো কখনো ওপরের দিকে চলে, কিন্তু সেটি শুধু একটি bit উল্টানো — pointer বদলানোর চেয়ে অনেক সস্তা। এই কারণেই C++ আর Java-র লাইব্রেরি, আর Linux kernel — তিনটিই red-black tree বেছে নিয়েছে।

১৯৭২ সালে জার্মান কম্পিউটার বিজ্ঞানী Rudolf Bayer “symmetric binary B-tree” নামে একটি স্ট্রাকচার প্রকাশ করেন — B-tree-র (পর্ব ৩০) ধারণাকে binary tree-তে আনার চেষ্টা। ১৯৭৮ সালে Leonidas Guibas আর Robert Sedgewick “A dichromatic framework for balanced trees” প্রবন্ধে সেই কাঠামোকে দুই রঙের নিয়মে প্রকাশ করেন, আর “red-black” নামটি সেখান থেকেই। পাঠ্যবইয়ের আজকের রূপটি — বিশেষ করে Cormen, Leiserson, Rivest ও Stein-এর Introduction to Algorithms (CLRS) বইয়ের — এই পর্বের কোডের ভিত্তি। ২০০৮ সালে Sedgewick নিজেই একটি সরল রূপ দেন: left-leaning red-black tree (অংশ ৮).

২ · পাঁচটি নিয়ম — লাল, কালো আর NIL

Red-black tree একটি সাধারণ BST — বাঁয়ে ছোট, ডানে বড় — যার প্রতিটি node-এ একটি রং আছে। CLRS-এর ধারায় একটি সুবিধাজনক কল্পনা করা হয়: যেখানে কোনো সন্তান নেই, সেখানে একটি কাল্পনিক “খালি leaf” বসানো, যাকে বলে NIL leaf. ফলে প্রতিটি আসল node-এর ঠিক দুটি সন্তান থাকে (আসল বা NIL), আর সব পথ শেষ হয় NIL-এ। বাস্তব কোডে NIL হয় nullptr/None, অথবা CLRS-এর মতো একটি একক কালো sentinel node.

Red-black tree-র পাঁচটি নিয়ম
  1. রং: প্রতিটি node হয় লাল, নয় কালো।
  2. Root কালো।
  3. প্রতিটি NIL leaf কালো।
  4. লাল নিয়ম: কোনো লাল node-এর সন্তান লাল হতে পারে না — দুই সন্তানই কালো। অর্থাৎ কোনো পথে দুটি লাল পরপর নেই।
  5. কালো নিয়ম: যেকোনো node x থেকে তার নিচের প্রতিটি NIL leaf পর্যন্ত সব পথে কালো node-এর সংখ্যা সমান।

পঞ্চম নিয়মের সংখ্যাটির একটি নাম আছে: black-height, লেখা হয় bh(x) — x থেকে নিচের যেকোনো NIL পর্যন্ত পথে কালো node-এর সংখ্যা, x-কে বাদ দিয়ে, NIL-কে ধরে। নিয়ম ৫ বলে এই সংখ্যা পথ বাছাইয়ের ওপর নির্ভর করে না, তাই সংজ্ঞাটি অর্থবহ। নিচের tree-টি দেখুন (R = লাল, B = কালো, ছোট NIL-গুলো কালো):

                  20B
               /       \
          10B             30B
         /    \          /    \
      5R       15R    25R      NIL
     /  \     /  \    /  \
   NIL  NIL NIL  NIL NIL  NIL

bh(20) = 2   যেমন পথ 20 → 10 → 5 → NIL:  কালো 10, NIL  = 2
             পথ 20 → 30 → NIL:          কালো 30, NIL  = 2
bh(10) = 1,  bh(30) = 1,  bh(5) = bh(15) = bh(25) = 1
একটি বৈধ red-black tree: root কালো, কোনো লাল–লাল জোড়া নেই, আর root থেকে প্রতিটি NIL-এর পথে ঠিক ২টি কালো node (NIL-সহ, root বাদে)।

লক্ষ করুন, পথগুলোর দৈর্ঘ্য আলাদা: 20 → 30 → NIL পথে মাত্র ২টি ধাপ, 20 → 10 → 5 → NIL পথে ৩টি। কিন্তু দুটিতেই কালো node ২টি — পার্থক্য তৈরি করছে শুধু লাল node. নিয়ম ৪ লাল node-এর সংখ্যা সীমিত রাখে: কোনো পথে লাল node কালো node-এর চেয়ে বেশি হতে পারে না, কারণ প্রতিটি লালের ঠিক নিচে একটি কালো আছে।

কোনটি বৈধ নয় — তিনটি সাধারণ ভুল

tree (R = লাল, B = কালো)কোন নিয়ম ভাঙলকেন
root 10R, সন্তান 5B আর 15Bনিয়ম ২root লাল। (সহজ সমাধান: root কালো করলে প্রতিটি পথে কালো একটি করে বাড়ে, তাই নিয়ম ৫ ঠিক থাকে।)
10B → ডানে 20R → ডানে 30Rনিয়ম ৪লাল 20-এর সন্তান লাল 30 — দুটি লাল পরপর
10B, বাঁয়ে 5B, ডানে NILনিয়ম ৫বাঁ পথে কালো 5 আর NIL = ২, ডান পথে শুধু NIL = ১
শুধু কালো node দিয়ে একটি perfect treeকোনোটিই নাসব node কালো হলেও বৈধ, যদি সব leaf একই গভীরতায় থাকে

সাধারণ ভুল। “লাল node-এর সন্তান কালো” মানে উল্টোটি নয় — কালো node-এর সন্তান লাল বা কালো যেকোনোটি হতে পারে, এমনকি দুটি কালো node পরপরও থাকতে পারে। আর black-height গোনার নিয়ম বই ভেদে একটু আলাদা হয় (কেউ x-কে ধরেন, কেউ NIL বাদ দেন)। পরীক্ষায় যে সংজ্ঞা ব্যবহার করছেন, তা শুরুতেই এক লাইনে লিখে দিন — তাহলে সংখ্যা ভিন্ন হলেও যুক্তি পরিষ্কার থাকে।

৩ · উচ্চতা কেন 2 log₂(n+1)-এর বেশি নয় — black-height দিয়ে প্রমাণ

পাঁচটি নিয়মের পুরো উদ্দেশ্য একটিই: উচ্চতা O(log n) রাখা। প্রমাণটি সুন্দর আর ছোট, আর বিশ্ববিদ্যালয় বা চাকরির লিখিত পরীক্ষায় প্রায়ই চাওয়া হয়। এখানে n = আসল (internal) node-এর সংখ্যা, NIL বাদে; আর h = root থেকে সবচেয়ে দূরের NIL পর্যন্ত edge-এর সংখ্যা।

দাবি ১ (induction): যেকোনো node x-এর subtree-তে কমপক্ষে 2bh(x) − 1 টি আসল node আছে।

  • ভিত্তি: x যদি NIL হয়, bh(x) = 0, আর 20 − 1 = 0 — ঠিক, NIL-এর subtree-তে আসল node শূন্য।
  • ধাপ: x একটি আসল node, bh(x) = b. x-এর প্রতিটি সন্তান c-এর black-height হয় b (c লাল হলে, কারণ c নিজে কালো গোনায় আসে না) অথবা b − 1 (c কালো হলে)। দুই ক্ষেত্রেই bh(c) ≥ b − 1. সন্তানের subtree x-এর চেয়ে ছোট, তাই induction-এর অনুমান অনুযায়ী প্রতিটিতে কমপক্ষে 2b−1 − 1 টি node. x-কে যোগ করে মোট কমপক্ষে 2(2b−1 − 1) + 1 = 2b − 1. ∎

দাবি ২ (লাল নিয়ম থেকে): bh(root) ≥ h/2. সবচেয়ে লম্বা পথটি নিন — root-এর পরে তাতে h টি node (শেষটি NIL). নিয়ম ৪ বলে দুটি লাল পরপর নেই, আর NIL কালো; তাই এই h টি node-এর কমপক্ষে অর্ধেক কালো। সুতরাং bh(root) ≥ h/2.

জোড়া লাগাই: দাবি ১-এ x = root বসিয়ে n ≥ 2bh(root) − 1 ≥ 2h/2 − 1. দুই দিকে ১ যোগ করে log নিলে log₂(n + 1) ≥ h/2, অর্থাৎ

h ≤ 2 log₂(n + 1)

এর সরাসরি ফল: খোঁজা, সর্বনিম্ন/সর্বোচ্চ, আগের/পরের key (successor) — যেকোনো কাজ যা একটি পথ ধরে চলে, worst case-এ O(log n) সময় নেয়। আর এর জন্য বাড়তি জায়গা প্রতি node-এ মাত্র এক bit, মোট Θ(n). তুলনা করে দেখুন সংখ্যায় কেমন দাঁড়ায় (স্তর = root থেকে সবচেয়ে গভীর node পর্যন্ত পথের node-সংখ্যার সর্বোচ্চ সীমা):

nনিখুঁত ভারসাম্য (⌊log₂ n⌋ + 1)AVL (< 1.44 log₂(n+2) − 0.33)Red-black (≤ 2 log₂(n+1))সাধারণ BST, worst case
১,০০০১০১৪১৯১,০০০
১০ লাখ২০২৮৩৯১০ লাখ
১০০ কোটি৩০৪২৫৯১০০ কোটি

এগুলো সর্বোচ্চ সীমা। বাস্তবে এলোমেলো ইনপুটে red-black tree-র উচ্চতা নিখুঁত ভারসাম্যের খুব কাছাকাছি থাকে। আর worst case-এও ১০০ কোটি key-তে ৫৯টি তুলনা — সাধারণ BST-এর ১০০ কোটির সাথে তুলনাই চলে না।

মূল ধারণা

প্রমাণের দুটি অংশ দুটি নিয়ম থেকে আসে। কালো নিয়ম (৫) বলে tree-টি “কালো চোখে” একদম নিখুঁত — শুধু কালো node গুনলে প্রতিটি পথ সমান, তাই node অনেক (দাবি ১)। লাল নিয়ম (৪) বলে লাল node পথকে সর্বোচ্চ দ্বিগুণ লম্বা করতে পারে (দাবি ২)। একটি নিয়ম বাদ দিলেই প্রমাণ ভেঙে পড়ে: লাল নিয়ম ছাড়া যেকোনো BST-কে root বাদে সব node লাল রং করে “বৈধ” বলা যেত।

৪ · 2-3-4 tree-র ছদ্মবেশ — লাল link মানে জোড়া লাগানো node

পাঁচটি নিয়ম প্রথমে খামখেয়ালি মনে হয়। কেন লাল-কালো? কেন ঠিক এই নিয়মগুলো? উত্তরটি লুকিয়ে আছে আরেকটি tree-তে: 2-3-4 tree. এতে প্রতিটি node-এ ১, ২ বা ৩টি key থাকতে পারে (তাই সন্তান ২, ৩ বা ৪টি) — এদের বলে 2-node, 3-node আর 4-node. আর এর একটি কঠোর নিয়ম: সব leaf একই গভীরতায়. অর্থাৎ 2-3-4 tree সবসময় নিখুঁতভাবে ভারসাম্যপূর্ণ। এটি আসলে পর্ব ৩০-এর B-tree-র সবচেয়ে ছোট রূপ।

বিয়েবাড়ির খাবারের হলে ছোট ছোট টেবিল থাকে। অতিথি বেশি এলে দুটি বা তিনটি টেবিল পাশাপাশি জোড়া দিয়ে একটি লম্বা টেবিল বানানো হয়। দূর থেকে দেখলে সেটি একটিই টেবিল, কাছ থেকে দেখলে কয়েকটি। Red-black tree-তে কালো node হলো মূল টেবিল, আর তার সাথে লাল link-এ যুক্ত সন্তান হলো জোড়া দেওয়া পাশের টেবিল। একটি কালো node আর তার লাল সন্তানেরা মিলে একটি 2-3-4 node. সর্বোচ্চ দুটি পাশের টেবিল জোড়া যায় — তাই লাল সন্তান সর্বোচ্চ ২টি, আর লালের নিচে আরেকটি লাল নয়।

নিচের ডায়াগ্রামে একই tree দুই রূপে দেখুন। 2-3-4 রূপ থেকে red-black রূপে যাওয়ার নিয়ম সরল: 2-node → একটি কালো node; 3-node → একটি কালো node আর একটি লাল সন্তান; 4-node → মাঝের key কালো, দুই পাশের key লাল সন্তান।

ওপরে 2-3-4 tree, নিচে red-black treeলাল লেখা = লাল node, দাগ = এই ধাপে যোগ হলো

            
প্রতিটি ধাপের পর
যে 2-3-4 node রূপান্তর হলো
red-black-এ যা হলো
root থেকে প্রতিটি পথে কালো node (NIL বাদে)
  1. 2-3-4 tree. Root একটি 2-node [20]; তার বাঁয়ে একটি 4-node [5 10 15], ডানে একটি 3-node [25 30]. দুই leaf একই স্তরে — 2-3-4 tree-তে এটিই নিয়ম। মোট ৬টি key.
  2. 2-node → কালো node. [20]-এ একটিই key, তাই সেটি সরাসরি একটি কালো node 20B — red-black tree-র root.
  3. 4-node → কালো মাঝখান, দুই লাল পাশ. [5 10 15]-এর মাঝের key 10 কালো হলো, আর 5 ও 15 তার লাল সন্তান। এই তিনটি একসাথে আসলে একটিই 2-3-4 node — লাল link মানে “একই node-এর অংশ”.
  4. 3-node → কালো আর একটি লাল. [25 30]-এ দুটি key: একটি কালো, অন্যটি তার লাল সন্তান। এখানে 30 কালো আর 25 তার বাঁ সন্তান লাল। tree সম্পূর্ণ: root থেকে যেকোনো পথে কালো node ঠিক ২টি।
  5. নিয়মগুলো কোথা থেকে এল. প্রতিটি পথে কালো সংখ্যা সমান (নিয়ম ৫), কারণ 2-3-4 tree-তে সব leaf একই স্তরে আর প্রতিটি 2-3-4 node-এ ঠিক একটি কালো। দুটি লাল পরপর নেই (নিয়ম ৪), কারণ একটি 2-3-4 node-এ সর্বোচ্চ ৩টি key. পাঁচটি নিয়ম আসলে 2-3-4 tree-র নিয়মেরই অনুবাদ।
  6. একাধিক রূপ. 3-node [25 30]-কে উল্টো দিকেও লেখা যায়: 25 কালো, 30 তার ডান সন্তান লাল। এটিও বৈধ red-black tree. তাই একটি 2-3-4 tree-র কয়েকটি red-black রূপ থাকতে পারে, কিন্তু প্রতিটি red-black tree থেকে একটিই 2-3-4 tree পাওয়া যায়।

এই সম্পর্কটি শুধু সুন্দর নয়, কাজেরও। 2-3-4 tree-র উচ্চতা স্পষ্টতই O(log n) (প্রতিটি node-এর কমপক্ষে ২টি সন্তান, আর সব leaf এক স্তরে)। Red-black tree-তে শুধু কালো node গুনলে সেই 2-3-4 tree-র স্তর পাওয়া যায়, আর লাল node প্রতিটি স্তরে বড়জোর একটি বাড়তি ধাপ যোগ করে — অংশ ৩-এর “সর্বোচ্চ দ্বিগুণ” এখান থেকেই। পরের অংশে দেখবেন, insert-এর প্রতিটি কেসও একটি 2-3-4 ঘটনা: “uncle লাল” মানে একটি 4-node ভেঙে মাঝের key ওপরে পাঠানো, আর rotation মানে একটি 3-node-কে 4-node বানানোর সময় আকৃতি ঠিক করা।

৫ · Insert — লাল node বসান, তারপর সারান (fix-up)

Insert দুই ধাপে। প্রথমে সাধারণ BST-এর মতো key-টি জায়গামতো একটি leaf হিসেবে বসান। তারপর তার রং ঠিক করুন — আর এখানেই প্রথম বুদ্ধি: নতুন node সবসময় লাল. কেন? কালো বসালে ওই একটি পথে কালো node একটি বেড়ে যেত, আর নিয়ম ৫ ভাঙত পুরো tree জুড়ে — সারানো কঠিন। লাল বসালে কোনো পথের কালো সংখ্যা বদলায় না; ভাঙতে পারে শুধু দুটি নিয়ম: নিয়ম ২ (নতুন node যদি root হয়) আর নিয়ম ৪ (যদি তার parent-ও লাল হয়)। নিয়ম ২ সারানো সহজ — শেষে root কালো করে দিন। বাকি থাকে শুধু “লাল–লাল” সমস্যা।

প্রথমে মনে করুন — rotation

পর্ব ২৮-এ AVL-এ rotation দেখেছেন। এটি একটি স্থানীয় পুনর্বিন্যাস: একটি সন্তানকে ওপরে তোলা আর parent-কে নিচে নামানো, এমনভাবে যে BST-এর ক্রম (inorder traversal-এ a < x < b < y < c) অটুট থাকে। মাত্র তিনটি pointer বদলায়, তাই সময় O(1).

        x                               y
       / \       left-rotate(x)        / \
      a   y      ------------->       x   c
         / \     <-------------      / \
        b   c    right-rotate(y)    a   b

inorder দুই দিকেই: a, x, b, y, c   (b-এর subtree শুধু x-এর এক পাশ থেকে অন্য পাশে যায়)
Left আর right rotation একে অপরের উল্টো। রং বদলায় না — রং আলাদা করে ঠিক করতে হয়।

তিনটি কেস — সব নির্ভর করে “uncle”-এর রঙের ওপর

ধরুন z হলো লাল node, যার parent p-ও লাল। p লাল, তাই p root নয় (root কালো), সুতরাং p-এর parent g (grandparent) আছে, আর g অবশ্যই কালো (কারণ fix-up শুরুর আগে tree বৈধ ছিল)। এখন দেখুন g-এর অন্য সন্তান — z-এর uncle u. ধরি p হলো g-এর বাঁ সন্তান (ডান হলে সব কিছু আয়নার মতো উল্টো):

কেসঅবস্থাকাজএরপর2-3-4 অর্থে
১u লালp আর u কালো, g লাল (শুধু রং বদল)z = g, দুই স্তর ওপরে আবার দেখা4-node ভাঙা (split): মাঝের g ওপরের node-এ যায়
২u কালো, z হলো p-এর ডান সন্তান (“ত্রিভুজ”)p-এ left rotation, z = pকেস ৩-এ পরিণতনতুন key 3-node-এর ভেতরে ঢুকছে; আকৃতি সোজা করা
৩u কালো, z হলো p-এর বাঁ সন্তান (“সরলরেখা”)p কালো, g লাল, তারপর g-এ right rotationশেষ — আর কিছু করতে হয় না3-node থেকে বৈধ 4-node তৈরি
Pseudocode — RB-INSERT (CLRS-style; NIL leaves are black)

RB-INSERT(T, z):
    BST-INSERT(T, z)                     // ordinary BST insert at a leaf position
    z.color = RED
    while z.p.color == RED:              // the only possible violation: red z, red parent
        if z.p == z.p.p.left:
            u = z.p.p.right              // uncle
            if u.color == RED:                         // case 1: recolour
                z.p.color = BLACK;  u.color = BLACK
                z.p.p.color = RED;  z = z.p.p          // the problem moves two levels up
            else:
                if z == z.p.right:                     // case 2: triangle -> line
                    z = z.p;  LEFT-ROTATE(T, z)
                z.p.color = BLACK;  z.p.p.color = RED  // case 3: line
                RIGHT-ROTATE(T, z.p.p)
        else: the same with "left" and "right" exchanged
    T.root.color = BLACK

প্রথমে uncle কালো হওয়ার কেস দেখি। একটি ছোট উদাহরণ: tree-তে আছে 10 (কালো) আর তার ডান সন্তান 20 (লাল); এখন 15 insert করুন। এখানে p = 20 হলো g-এর ডান সন্তান, তাই টেবিলের আয়না-রূপ খাটবে: ত্রিভুজ মানে z হলো p-এর বাঁ সন্তান।

Red-black tree আর তার 2-3-4 রূপলাল লেখা = লাল node, দাগ = z

            
প্রতিটি ধাপের পর
z, p, g, u
আকৃতি
এই ধাপের কাজ
মোট rotation
  1. শুরু. 10B-এর ডান সন্তান 20R. বৈধ tree; 2-3-4 রূপে এটি একটি 3-node [10 20].
  2. BST insert, লাল রং. 15 > 10, তাই ডানে; 15 < 20, তাই 20-এর বাঁয়ে। 15 লাল, তার parent 20-ও লাল — নিয়ম ৪ ভাঙা। uncle হলো 10-এর বাঁ সন্তান, অর্থাৎ NIL — কালো। আর 10 → ডান → বাঁ: আকৃতি একটি ত্রিভুজ (কেস ২).
  3. কেস ২: ত্রিভুজকে সরলরেখা. p = 20-এ right rotation: 15 ওপরে উঠল, 20 তার ডান সন্তান হলো। এখন z = 20 (পুরোনো parent), আর তার parent 15 — দুজনেই লাল, কিন্তু এবার 10 → ডান → ডান, সরলরেখা (কেস ৩). BST ক্রম ঠিক আছে: 10 < 15 < 20.
  4. কেস ৩: রং বদল আর rotation. p = 15 কালো, g = 10 লাল, তারপর 10-এ left rotation. ফল 15B-এর দুই সন্তান 10R আর 20R: কোনো লাল–লাল নেই, প্রতিটি পথে কালো ১টি (NIL বাদে)। 2-3-4 রূপে 3-node [10 20] এখন 4-node [10 15 20] — মোট ২টি rotation, আর কাজ শেষ।

এবার uncle লাল হওয়ার কেস। এখানে rotation লাগে না, শুধু রং বদল — কিন্তু সমস্যাটি দুই স্তর ওপরে সরে যেতে পারে। উদাহরণ হিসেবে সবচেয়ে কঠিন ইনপুট নিই: সাজানো ক্রম। 1, 2, …, 7 insert করার পর tree যেমন দাঁড়ায়, সেখানে 8 insert করা দেখুন (সাধারণ BST হলে এতক্ষণে উচ্চতা 7 হয়ে যেত)।

Red-black tree (1 … 7 সাজানো ক্রমে insert করা)লাল লেখা = লাল node, দাগ = z

            
প্রতিটি ধাপের পর
z আর তার parent
uncle
এই ধাপের কাজ
black-height(root)
  1. শুরু. 1 থেকে 7 সাজানো ক্রমে insert করার পর এই tree. ডান দিকে একটু ভারী, কিন্তু বৈধ: root 2 কালো, কোনো লাল–লাল নেই, আর প্রতিটি পথে কালো node ২টি (NIL-সহ, root বাদে)।
  2. 8 বসল. 8 সবার চেয়ে বড়, তাই একদম ডানে — 7-এর ডান সন্তান, লাল। parent 7-ও লাল: ভাঙা। grandparent 6, আর uncle 5 — লাল। এটি কেস ১.
  3. কেস ১: শুধু রং বদল. parent 7 আর uncle 5 কালো, grandparent 6 লাল। প্রতিটি পথে কালো সংখ্যা একই থাকল (6-এর নিচের পথে একটি কালো কমল, একটি বাড়ল)। কিন্তু এখন 6 লাল আর তার parent 4-ও লাল — সমস্যা দুই স্তর ওপরে উঠল। নতুন z = 6. 2-3-4 অর্থে: 4-node [5 6 7] ভেঙে 6 ওপরের node-এ গেল।
  4. কেস ৩: rotation, শেষ. z = 6-এর parent 4, grandparent 2, uncle 1 — কালো। 2 → ডান → ডান, সরলরেখা। 4 কালো, 2 লাল, তারপর 2-এ left rotation: 4 নতুন root. সব নিয়ম ঠিক, উচ্চতা এখনো 3, আর এই insert-এ মোট ১টি rotation.

কেন এটি ঠিক, আর কত সময় লাগে

Invariant. fix-up লুপের প্রতিটি ধাপের শুরুতে তিনটি কথা সত্য: (ক) z লাল; (খ) নিয়ম ৫ পুরো tree-তে ঠিক আছে; (গ) সর্বোচ্চ একটি নিয়ম ভাঙা থাকতে পারে — হয় z root আর লাল (নিয়ম ২), নয় z আর তার parent দুজনেই লাল (নিয়ম ৪)। কেস ১-এ g-এর নিচে কালো একটি কমে আর একটি বাড়ে — তাই (খ) টেকে, আর ভাঙা জায়গাটি g-তে সরে। কেস ২ আর ৩-এর rotation inorder ক্রম রাখে, আর রং এমনভাবে বদলায় যে g-এর জায়গায় বসা নতুন node কালো, তার দুই সন্তান লাল — প্রতিটি পথের কালো সংখ্যা আগের মতো, আর ওপরের দিকে আর কোনো লাল নেই, তাই লুপ শেষ। এটি পর্ব ০৩-এর loop invariant পদ্ধতি।

খরচ. BST insert নিজে O(log n) (উচ্চতা অংশ ৩ অনুযায়ী)। কেস ১ প্রতিবার z-কে দুই স্তর ওপরে তোলে, তাই সর্বোচ্চ O(log n) বার ঘটতে পারে, প্রতিবার O(1). কেস ২ বা ৩ একবার ঘটলেই লুপ শেষ — তাই একটি insert-এ সর্বোচ্চ ২টি rotation. মোট সময় O(log n), বাড়তি জায়গা O(1) (লুপ ওপরে ওঠে parent pointer ধরে, recursion ছাড়া)। আরও ভালো খবর: অনেকগুলো insert আর delete মিলিয়ে গড়লে রং বদলের সংখ্যাও প্রতি কাজে amortized O(1) — অর্থাৎ লম্বা রং-বদলের ঢেউ মাঝে মাঝে আসে, প্রতিবার নয়।

৬ · পুরো trace আর কোড — 10, 20, 30, 15, 25, 5

এবার শূন্য থেকে একটি tree বানাই, প্রতিটি insert-এর পর ছবি দেখে। একই ক্রম নিচের C++ আর Python প্রোগ্রামেও চলবে, তাই ডায়াগ্রামের প্রতিটি ধাপ আউটপুটের এক লাইনের সাথে মিলিয়ে নিতে পারবেন।

প্রতিটি insert আর fix-up-এর পরলাল লেখা = লাল node, দাগ = নতুন key

            
প্রতিটি ধাপের পর
insert
fix-up
মোট rotation
উচ্চতা (edge)
  1. insert 10. খালি tree-তে 10 লাল হয়ে root হলো — নিয়ম ২ ভাঙা। লুপ চলে না (parent নেই), শেষ লাইন root-কে কালো করে: 10B.
  2. insert 20. 20 > 10, ডানে লাল। parent 10 কালো, তাই কিছু ভাঙেনি — fix-up লাগল না।
  3. insert 30. 30 গেল 20-এর ডানে, লাল; parent 20-ও লাল। uncle (10-এর বাঁ) NIL — কালো, আর 10 → ডান → ডান সরলরেখা: কেস ৩-এর আয়না-রূপ। 20 কালো, 10 লাল, 10-এ left rotation. সাধারণ BST হলে এখানে উচ্চতা 2-এর একটি লাঠি হতো; এখন 20B-এর দুই পাশে 10R, 30R.
  4. insert 15. 15 < 20 → বাঁয়ে, 15 > 10 → 10-এর ডানে, লাল। parent 10 লাল, uncle 30-ও লাল — কেস ১. 10 আর 30 কালো, 20 লাল। z = 20 এখন root, তাই লুপ থামে, আর শেষ লাইন 20-কে আবার কালো করে। এই এক মুহূর্তে প্রতিটি পথে কালো একটি বাড়ল — black-height 1 থেকে 2. (2-3-4 অর্থে: 4-node [10 20 30] ভেঙে 20 নতুন root হলো, tree এক স্তর লম্বা হলো।)
  5. insert 25. 25 > 20 → ডানে, 25 < 30 → 30-এর বাঁয়ে, লাল। parent 30 কালো — কিছু করার নেই।
  6. insert 5. 5 < 20, 5 < 10 → 10-এর বাঁয়ে, লাল। parent 10 কালো — শেষ। অংশ ২-এর ছবির tree-টিই পেলাম, আর অংশ ৪-এর 2-3-4 রূপও এটিই।
  7. যাচাই. root 20 কালো। লাল node 5, 15, 25 — তিনজনেরই সন্তান NIL, কালো। প্রতিটি পথে কালো (NIL-সহ, root বাদে): 20→10→5→NIL = ২, 20→10→15→NIL = ২, 20→30→25→NIL = ২, 20→30→NIL = ২. ছয়টি insert-এ মোট ১টি rotation আর ১বার কেস ১.

এখন কোড। এটি CLRS-এর অ্যালগরিদম, দুটি ছোট কৌশলসহ: বাঁ/ডান সন্তান রাখা হয়েছে ch[0]/ch[1] অ্যারেতে, যাতে একটি rotate(x, d) ফাংশনই দুই দিকের কাজ করে, আর fix-up-এর “আয়না” কেসগুলো আলাদা করে লিখতে না হয়; আর NIL হলো nullptr, যাকে red() ফাংশন কালো হিসেবে ধরে। শেষে bh() ফাংশন পুরো tree ঘুরে নিয়ম ৪ আর ৫ যাচাই করে আর black-height ফেরত দেয়।

#include <iostream>
#include <string>
#include <initializer_list>
using namespace std;
struct Node { int key; bool red = true; Node* ch[2] = {nullptr, nullptr}; Node* p = nullptr; };
Node* root = nullptr;
bool red(Node* x) { return x && x->red; }                  // NIL (nullptr) is black
void rotate(Node* x, int d) {         // d=0: left rotation (x->ch[1] moves up), d=1: right
    Node* y = x->ch[!d];
    x->ch[!d] = y->ch[d]; if (y->ch[d]) y->ch[d]->p = x;
    y->p = x->p;
    if (!x->p) root = y; else x->p->ch[x == x->p->ch[1]] = y;
    y->ch[d] = x; x->p = y;
}
string insert(int k) {
    Node *z = new Node{k}, *y = nullptr, *x = root; string log;
    while (x) { y = x; x = x->ch[k > x->key]; }              // ordinary BST insert
    z->p = y; (y ? y->ch[k > y->key] : root) = z;
    while (red(z->p)) {                                      // fix-up: red parent = violation
        Node *p = z->p, *g = p->p; int d = (p == g->ch[1]);   // d = side of the parent
        if (Node* u = g->ch[!d]; red(u)) {                   // case 1: red uncle -> recolour
            p->red = u->red = false; g->red = true; z = g; log += " case1"; continue;
        }
        if (z == p->ch[!d]) { z = p; rotate(z, d); p = z->p; log += " case2"; }  // triangle
        p->red = false; g->red = true; rotate(g, !d); log += " case3";          // case 3
    }
    root->red = false;
    return log.empty() ? "no fix-up" : log.substr(1);
}
string show(Node* x) {
    if (!x) return "-";
    string s = to_string(x->key) + (x->red ? "R" : "B");
    return x->ch[0] || x->ch[1] ? s + "(" + show(x->ch[0]) + "," + show(x->ch[1]) + ")" : s;
}
int bh(Node* x, bool& ok) {      // black nodes below x down to NIL; ok = false on a violation
    if (!x) return 0;
    if (x->red && (red(x->ch[0]) || red(x->ch[1]))) ok = false;       // red node, red child
    int a = bh(x->ch[0], ok) + !red(x->ch[0]), b = bh(x->ch[1], ok) + !red(x->ch[1]);
    if (a != b) ok = false;                                            // unequal black counts
    return a;
}
int main() {
    for (int k : {10, 20, 30, 15, 25, 5}) {
        string c = insert(k);
        cout << "insert " << k << " [" << c << "]: " << show(root) << '\n';
    }
    bool ok = !red(root); int h = bh(root, ok);
    cout << "root black, no red-red, equal black-height: " << (ok ? "yes" : "no") << '\n';
    cout << "black-height(root) = " << h << '\n';
}

আউটপুট:

insert 10 [no fix-up]: 10B
insert 20 [no fix-up]: 10B(-,20R)
insert 30 [case3]: 20B(10R,30R)
insert 15 [case1]: 20B(10B(-,15R),30B)
insert 25 [no fix-up]: 20B(10B(-,15R),30B(25R,-))
insert 5 [no fix-up]: 20B(10B(5R,15R),30B(25R,-))
root black, no red-red, equal black-height: yes
black-height(root) = 2

প্রতিটি লাইন ডায়াগ্রামের একটি ধাপ: key-এর পরে R/B রং, বন্ধনীর ভেতরে (বাঁ, ডান) সন্তান, - মানে NIL. insert 30 [case3] — একটি rotation; insert 15 [case1] — শুধু রং বদল, তারপর root আবার কালো। শেষ দুই লাইন যাচাই: root কালো, কোনো লাল node-এর লাল সন্তান নেই, আর সব পথে কালো সংখ্যা সমান, black-height 2 (CLRS-এর সংজ্ঞায়: root বাদে, NIL-সহ)। লক্ষ করুন int d = (p == g->ch[1]) — parent কোন পাশে, তা একটি সংখ্যা; uncle হলো ch[!d], ত্রিভুজ মানে z আছে ch[!d]-এ। কেস ২-এর rotation হয় p-এ, দিক d; কেস ৩-এর rotation g-এ, উল্টো দিক !d. (সংক্ষেপের জন্য প্রোগ্রামটি মেমরি ফেরত দেয় না, আর সমান key এলে বাঁয়ে বসায় — বাস্তব std::set সমান key ফেলে দেয়।)

class Node:
    def __init__(self, key):
        self.key, self.red, self.ch, self.p = key, True, [None, None], None
root = None
def red(x): return x is not None and x.red              # NIL (None) is black
def rotate(x, d):          # d=0: left rotation (x.ch[1] moves up), d=1: right
    global root
    y = x.ch[1 - d]
    x.ch[1 - d] = y.ch[d]
    if y.ch[d]: y.ch[d].p = x
    y.p = x.p
    if not x.p: root = y
    else: x.p.ch[x is x.p.ch[1]] = y
    y.ch[d], x.p = x, y
def insert(k):
    global root
    z, y, x, log = Node(k), None, root, []
    while x: y, x = x, x.ch[k > x.key]                    # ordinary BST insert
    z.p = y
    if not y: root = z
    else: y.ch[k > y.key] = z
    while red(z.p):                                       # fix-up: red parent = violation
        p = z.p; g = p.p; d = int(p is g.ch[1])           # d = side of the parent
        u = g.ch[1 - d]                                   # uncle
        if red(u):                                        # case 1: red uncle -> recolour
            p.red = u.red = False; g.red = True; z = g; log.append("case1"); continue
        if z is p.ch[1 - d]:                              # case 2: triangle -> line
            z = p; rotate(z, d); p = z.p; log.append("case2")
        p.red = False; g.red = True; rotate(g, 1 - d); log.append("case3")   # case 3
    root.red = False
    return " ".join(log) or "no fix-up"
def show(x):
    if not x: return "-"
    s = f"{x.key}{'R' if x.red else 'B'}"
    return f"{s}({show(x.ch[0])},{show(x.ch[1])})" if x.ch[0] or x.ch[1] else s
def bh(x, ok):             # black nodes below x down to NIL; ok[0] = False on a violation
    if not x: return 0
    if x.red and (red(x.ch[0]) or red(x.ch[1])): ok[0] = False      # red node, red child
    a, b = (bh(c, ok) + (not red(c)) for c in x.ch)
    if a != b: ok[0] = False                                        # unequal black counts
    return a
for k in [10, 20, 30, 15, 25, 5]:
    c = insert(k)
    print(f"insert {k} [{c}]: {show(root)}")
ok = [not red(root)]
h = bh(root, ok)
print("root black, no red-red, equal black-height:", "yes" if ok[0] else "no")
print("black-height(root) =", h)

আউটপুট C++-এর সাথে হুবহু এক:

insert 10 [no fix-up]: 10B
insert 20 [no fix-up]: 10B(-,20R)
insert 30 [case3]: 20B(10R,30R)
insert 15 [case1]: 20B(10B(-,15R),30B)
insert 25 [no fix-up]: 20B(10B(-,15R),30B(25R,-))
insert 5 [no fix-up]: 20B(10B(5R,15R),30B(25R,-))
root black, no red-red, equal black-height: yes
black-height(root) = 2

Python-এ True আর False আসলে 1 আর 0, তাই x.ch[k > x.key] সরাসরি ডান বা বাঁ সন্তান বাছাই করে। bh()-এ লক্ষ করুন bh(c, ok) + (not red(c)): সন্তান কালো হলে (NIL-সহ) ১ যোগ — ঠিক black-height-এর সংজ্ঞা। দুই পাশের সংখ্যা না মিললে নিয়ম ৫ ভাঙা। এই যাচাই-ফাংশনটি কনটেস্টে বা ইন্টারভিউতে নিজের লেখা tree পরীক্ষা করার সেরা অস্ত্র: প্রতিটি insert-এর পর একবার চালান, ভুল সাথে সাথে ধরা পড়বে।

নিজে চালিয়ে দেখুন — red-black tree visualiser

নিচের টুলে “চালিয়ে দেখুন” চাপুন। একটি সংখ্যা লিখে insert চাপলে tree আঁকা হবে — লাল বৃত্ত লাল node, কালো বৃত্ত কালো node — আর নিচে লেখা থাকবে কোন কেস চলল, উচ্চতা, black-height আর সব নিয়ম ঠিক কি না। 1 থেকে 15 ক্রমে বোতামটি চেপে দেখুন সাজানো ইনপুটে সাধারণ BST-এর মতো লাঠি হয় কি না।

let T = null;                                     // root of the red-black tree
const $ = id => document.getElementById(id);
const isRed = x => !!x && x.red;                  // NIL (null) is black
function rot(x, d) {                              // d=0: left rotation, d=1: right rotation
  const y = x.c[1 - d]; x.c[1 - d] = y.c[d]; if (y.c[d]) y.c[d].p = x;
  y.p = x.p; if (!x.p) T = y; else x.p.c[x === x.p.c[1] ? 1 : 0] = y;
  y.c[d] = x; x.p = y;
}
function add(k) {
  let y = null, x = T, z = { k, red: true, c: [null, null], p: null }; const log = [];
  while (x) { if (k === x.k) return `insert ${k}: already present`; y = x; x = x.c[k > x.k ? 1 : 0]; }
  z.p = y; if (!y) T = z; else y.c[k > y.k ? 1 : 0] = z;
  while (isRed(z.p)) {
    let p = z.p; const g = p.p, d = p === g.c[1] ? 1 : 0, u = g.c[1 - d];
    if (isRed(u)) { p.red = u.red = false; g.red = true; z = g; log.push("case1"); continue; }
    if (z === p.c[1 - d]) { z = p; rot(z, d); p = z.p; log.push("case2"); }
    p.red = false; g.red = true; rot(g, 1 - d); log.push("case3");
  }
  T.red = false; return `insert ${k}: ${log.join(" ") || "no fix-up"}`;
}
function chk(x) {                                 // black-height below x, or -1 if a rule breaks
  if (!x) return 0;
  const [a, b] = x.c.map(c => { const h = chk(c); return h < 0 ? -1 : h + (isRed(c) ? 0 : 1); });
  return a < 0 || a !== b || (x.red && x.c.some(isRed)) ? -1 : a;
}
function draw(msg) {
  const ns = []; let H = 0;                       // in-order position = x, depth = y
  (function walk(x, dep) { if (!x) return; walk(x.c[0], dep + 1); x.i = ns.length; x.d = dep; H = Math.max(H, dep); ns.push(x); walk(x.c[1], dep + 1); })(T, 0);
  const X = x => x.i * 40 + 20, Y = x => x.d * 50 + 20, W = Math.max(ns.length, 1) * 40;
  let s = `<svg viewBox="0 0 ${W} ${H * 50 + 40}" width="${W}" style="max-width:100%;height:auto">`;
  for (const x of ns) if (x.p) s += `<line x1="${X(x.p)}" y1="${Y(x.p)}" x2="${X(x)}" y2="${Y(x)}" stroke="#94a3b8" stroke-width="2"/>`;
  for (const x of ns) s += `<circle cx="${X(x)}" cy="${Y(x)}" r="16" fill="${x.red ? "#dc2626" : "#111827"}" stroke="#94a3b8"/><text x="${X(x)}" y="${Y(x) + 5}" fill="#fff" text-anchor="middle" font-size="13">${x.k}</text>`;
  $("tv").innerHTML = s + "</svg>";
  const b = chk(T), out = `${msg} | n=${ns.length} height=${T ? H : -1} black-height=${b} ${b >= 0 && !isRed(T) ? "valid" : "INVALID"}`;
  $("st").textContent = out; console.log(out);
}
$("bi").onclick = () => draw(add(Number($("kv").value)));
$("bs").onclick = () => { T = null; for (let k = 1; k <= 15; k++) draw(add(k)); };
$("br").onclick = () => { T = null; for (let k = 0; k < 12; k++) draw(add(1 + Math.floor(Math.random() * 99))); };
for (const k of [10, 20, 30, 15, 25, 5]) draw(add(k));

শুরুতেই কোড নিজে ছয়টি insert করে; আউটপুটে প্রতিটির পরের অবস্থা:

insert 10: no fix-up | n=1 height=0 black-height=1 valid
insert 20: no fix-up | n=2 height=1 black-height=1 valid
insert 30: case3 | n=3 height=1 black-height=1 valid
insert 15: case1 | n=4 height=2 black-height=2 valid
insert 25: no fix-up | n=5 height=2 black-height=2 valid
insert 5: no fix-up | n=6 height=2 black-height=2 valid

সংখ্যাগুলো C++ আর Python-এর সাথে মেলে। এখানে উচ্চতা গোনা হয়েছে edge-এ (root একা থাকলে উচ্চতা শূন্য)। ছবিতে প্রতিটি node-এর আনুভূমিক অবস্থান তার inorder ক্রম, আর উল্লম্ব অবস্থান তার গভীরতা — তাই বাঁ থেকে ডানে পড়লে সবসময় সাজানো ক্রম পাবেন। পরীক্ষা: 1 থেকে 15 ক্রমে চাপুন — সাধারণ BST-এ উচ্চতা হতো 14, এখানে মাত্র 5, যেখানে সীমা 2 log₂ 16 = 8 পর্যন্ত। তারপর কয়েকটি এলোমেলো tree বানিয়ে দেখুন — শেষ লাইনে সবসময় valid, আর black-height কখনো log₂(n+1)-এর বেশি হয় না (দাবি ১ থেকে n ≥ 2bh − 1).

৭ · Delete — “double black”-এর ধারণা

Delete red-black tree-র সবচেয়ে কঠিন অংশ, আর পরীক্ষায় সাধারণত পুরো কোড নয়, ধারণাটি চাওয়া হয়। শুরুটা সাধারণ BST delete-এর মতো (পর্ব ২৭): যে node সরাতে হবে তার দুটি সন্তান থাকলে, তার inorder successor-কে (ডান subtree-র সবচেয়ে ছোটটি) তার জায়গায় আনুন — তাতে আসলে যে node tree থেকে বাদ পড়ে, তার সর্বোচ্চ একটি আসল সন্তান থাকে। এখন সমস্যা নির্ভর করে বাদ পড়া node-এর রঙের ওপর।

  • বাদ পড়া node লাল: কোনো সমস্যা নেই। লাল node সরালে কোনো পথের কালো সংখ্যা বদলায় না, আর লাল–লাল তৈরি হয় না (লালের parent ও সন্তান দুজনেই কালো ছিল)।
  • বাদ পড়া node কালো, আর তার জায়গায় আসা সন্তান লাল: সেই সন্তানকে কালো করে দিন — হারানো কালোটি ফিরে এল। শেষ।
  • বাদ পড়া node কালো, আর জায়গায় আসা x-ও কালো (প্রায়ই NIL): এবার x-এর পথ দিয়ে যাওয়া প্রতিটি পথে একটি কালো কম — নিয়ম ৫ ভাঙা।

শেষ অবস্থার জন্য CLRS একটি চতুর কল্পনা ব্যবহার করে: x-কে একটি বাড়তি কালো “ঋণ” দেওয়া হয় — বলা হয় x এখন double black. হিসাবের খাতায় নিয়ম ৫ তাহলে ঠিক থাকে, কিন্তু “একটি node-এ দুটি কালো” নিয়ম ১ ভাঙে। Fix-up-এর কাজ এই বাড়তি কালোটিকে কোথাও “খরচ” করা: হয় ওপরে ঠেলে দেওয়া, যতক্ষণ না কোনো লাল node পাওয়া যায় (তাকে কালো করলেই ঋণ শোধ) বা root-এ পৌঁছায় (root-এ বাড়তি কালো ফেলে দেওয়া যায়, কারণ তাতে সব পথে সমানভাবে একটি কমে); নয়তো x-এর ভাই (sibling) w-এর দিক থেকে একটি node rotation করে এনে কালো করা।

একটি পরিবারের কয়েক ভাই প্রত্যেকে মাসে সমান টাকা বাবা-মায়ের সংসারে পাঠান — এটিই নিয়ম ৫. হঠাৎ একজনের চাকরি গেল, তার ভাগে ঘাটতি। দুটি উপায়: পাশের ভাইয়ের কাছে বাড়তি থাকলে তিনি একটু ভাগ দেন (rotation — sibling থেকে ধার), নয়তো পুরো পরিবারের খরচ একটু কমিয়ে ঘাটতিটি এক প্রজন্ম ওপরে, বাবা-মায়ের হিসাবে তুলে দেওয়া হয় (রং বদল করে double black ওপরে ঠেলা)। কোনো এক জায়গায় সঞ্চয় (লাল node) পাওয়া গেলেই ঋণ শোধ।

খরচ insert-এর মতোই: double black সর্বোচ্চ উচ্চতা পর্যন্ত ওপরে উঠতে পারে, তাই O(log n) সময়; কিন্তু rotation হয় সর্বোচ্চ ৩টি, কারণ rotation-যুক্ত কেসগুলোর পরে লুপ এক-দুই ধাপেই থামে। AVL-এ delete-এর পর rotation O(log n) টি পর্যন্ত হতে পারে — এই পার্থক্যই বহু লাইব্রেরিকে red-black-এর দিকে টেনেছে। পুরো কেস-বিশ্লেষণ নিচের বাক্সে; প্রথম পড়ায় বাদ দিলেও কিছু হারাবেন না।

আরও গভীরে — delete fix-up-এর চারটি কেস: x double black, x হলো তার parent-এর বাঁ সন্তান, w = x-এর ভাই (ডান হলে আয়না)। কেস ১, w লাল: w কালো, parent লাল, parent-এ left rotation — এখন x-এর নতুন ভাই কালো, তাই কেস ২–৪-এর কোনো একটিতে যান। কেস ২, w কালো আর তার দুই সন্তান কালো: w লাল করুন — x আর w দুই পাশ থেকেই একটি কালো কমল, তাই বাড়তি কালোটি parent-এ ওঠে (parent লাল হলে তাকে কালো করে শেষ; কেস ১-এর পরে এলে parent লাল, তাই সাথে সাথে শেষ)। কেস ৩, w কালো, w-এর কাছের (বাঁ) সন্তান লাল আর দূরের (ডান) সন্তান কালো: w আর তার বাঁ সন্তানের রং অদলবদল, w-এ right rotation — এটি কেস ৪ তৈরি করে। কেস ৪, w কালো আর দূরের সন্তান লাল: w নেয় parent-এর রং, parent আর w-এর দূরের সন্তান কালো, parent-এ left rotation — বাড়তি কালো শোধ, লুপ শেষ। তাই সবচেয়ে খারাপ ক্রম কেস ১ → ৩ → ৪, মোট ৩টি rotation. 2-3-4 অর্থে কেস ২ হলো দুটি node জোড়া লাগানো (fusion), আর কেস ৩–৪ হলো ভাইয়ের কাছ থেকে একটি key ধার (transfer) — ঠিক B-tree-র delete-এর মতো (পর্ব ৩০)।

৮ · Left-leaning red-black tree — একই ধারণা, কম কেস

CLRS-ধাঁচের insert-এ তিনটি কেস আর তাদের আয়না-রূপ মিলিয়ে ছয়টি অবস্থা, delete-এ আটটি। ২০০৮ সালে Robert Sedgewick প্রস্তাব করেন left-leaning red-black tree (LLRB), যেখানে একটি বাড়তি নিয়ম যোগ করে কেসগুলো নাটকীয়ভাবে কমানো হয়: লাল link সবসময় বাঁ দিকে ঝুঁকে থাকে. অর্থাৎ কোনো node-এর ডান সন্তান কখনো লাল নয়। Sedgewick ও Wayne-এর Algorithms (৪র্থ সংস্করণ) বইয়ে যে রূপ শেখানো হয়, তাতে আবার কোনো node-এর দুই সন্তান একসাথে লাল থাকতে দেওয়া হয় না — ফলে এটি 4-node ছাড়া, 2-3 tree-র সাথে এক-এক সম্পর্কে মেলে: প্রতিটি 3-node-এর একটিই red-black রূপ (বড়টি কালো, ছোটটি তার বাঁ লাল সন্তান)।

Pseudocode — LLRB insert (Sedgewick 2008, 2-3 version; recursive)

put(h, key):                                   // returns the new root of this subtree
    if h == NIL: return new RED node(key)
    if key < h.key:   h.left  = put(h.left, key)
    elif key > h.key: h.right = put(h.right, key)
    if isRed(h.right) and not isRed(h.left):     h = rotateLeft(h)    // red leans right: fix
    if isRed(h.left) and isRed(h.left.left):     h = rotateRight(h)   // two reds in a row
    if isRed(h.left) and isRed(h.right):         flipColors(h)        // split a 4-node
    return h

insert(key):  root = put(root, key);  root.color = BLACK

rotateLeft(h):  x = h.right; h.right = x.left; x.left = h
                x.color = h.color; h.color = RED; return x      // rotateRight: mirror
flipColors(h):  h.color = RED; h.left.color = BLACK; h.right.color = BLACK

পুরো insert তিনটি if-এ! কারণ recursion থেকে ফেরার পথে প্রতিটি node-এ একই তিনটি স্থানীয় মেরামত চলে: ডানে ঝোঁকা লাল থাকলে বাঁয়ে ঘোরানো; পরপর দুটি বাঁ লাল থাকলে ডানে ঘোরানো; দুই সন্তান লাল হলে রং উল্টে মাঝের node-কে ওপরে পাঠানো — ঠিক 2-3 tree-তে একটি অস্থায়ী 4-node ভেঙে ফেলার মতো। CLRS-এর “uncle” ধারণাটিই লাগে না, parent pointer-ও লাগে না।

তবে LLRB সবকিছুতে জেতে না। বাড়তি নিয়মের কারণে এটি সাধারণ red-black tree-র চেয়ে বেশি rotation করে, আর delete সরল হলেও বোঝা সহজ নয়। কিছু প্রোগ্রামার মাপজোখ করে দেখিয়েছেন যে বাস্তবে এটি CLRS-ধাঁচের বাস্তবায়নের চেয়ে দ্রুত নয়। তাই বড় লাইব্রেরিগুলো এখনো ক্লাসিক রূপ ব্যবহার করে, আর LLRB টিকে আছে মূলত শেখানো আর ছোট, পরিষ্কার কোডের জন্য। একই ধরনের আরেকটি সরলীকরণ হলো Arne Andersson-এর ১৯৯৩ সালের AA tree, যেখানে লাল link শুধু ডান দিকে থাকতে পারে।

৯ · বাস্তবে কোথায়, আর কোন ভারসাম্যপূর্ণ tree কখন

যেখানে red-black tree প্রতিদিন কাজ করছে

  • C++ std::map, std::set, multimap, multiset: standard শুধু O(log n) জটিলতা আর সাজানো ক্রম দাবি করে, কিন্তু GCC-র libstdc++ (ভেতরের নাম _Rb_tree), Clang-এর libc++ আর MSVC — তিনটি প্রধান বাস্তবায়নই red-black tree ব্যবহার করে। এর একটি সুবিধা: insert বা erase-এ অন্য উপাদানের iterator নষ্ট হয় না, কারণ node মেমরিতে সরে না, শুধু pointer বদলায়। পর্ব ১৫-এ এই associative container-গুলো ব্যবহার করেছেন; এখন জানেন ভেতরে কী আছে।
  • Java TreeMap আর TreeSet: ডকুমেন্টেশনেই লেখা আছে এটি red-black tree, CLRS-এর অ্যালগরিদমের রূপান্তর। আর Java 8 থেকে HashMap-এর একটি bucket-এ অনেক key জমলে (৮টির বেশি, আর টেবিল যথেষ্ট বড় হলে) সেই bucket-এর linked list একটি ছোট red-black tree-তে বদলে যায় — hash table-এর worst case O(n) থেকে O(log n)-এ নামে (ভাবনার প্রশ্ন ৩)।
  • Linux kernel: lib/rbtree.c-এ একটি সাধারণ rbtree আছে, যা kernel-এর বহু অংশ ব্যবহার করে। ২০০৭ সালে (Linux 2.6.23) আসা Completely Fair Scheduler (CFS) চলার-অপেক্ষায়-থাকা task-গুলোকে একটি rbtree-তে রাখত, যার key হলো task এ পর্যন্ত কতটা CPU সময় পেয়েছে; সবচেয়ে বাঁয়ের node — সবচেয়ে কম সময় পাওয়া task — পরের পালা পায়। Linux 6.6 থেকে নতুন EEVDF scheduler এসেছে, কিন্তু সেটিও rbtree-এর ওপরেই গড়া। epoll, timer আর আরও অনেক জায়গায়ও rbtree আছে।
  • Python: standard library-তে কোনো balanced tree নেই। সাজানো map লাগলে মানুষ সাধারণত তৃতীয় পক্ষের sortedcontainers লাইব্রেরি ব্যবহার করেন — মজার ব্যাপার, সেটি tree নয়, ছোট ছোট সাজানো list-এর list, আর বাস্তবে বেশ দ্রুত।

ভারসাম্যপূর্ণ tree-এর তুলনা

একই কাজ — key সাজিয়ে রাখা, খোঁজা, insert, delete, ক্রমে ঘোরা — অনেকগুলো স্ট্রাকচার দিয়ে করা যায়। প্রত্যেকটির নিশ্চয়তা আর খরচ আলাদা। এই কোর্সে এদের বেশিরভাগ আলাদা পর্বে পাবেন:

স্ট্রাকচারsearch / insert / deleteভারসাম্যের ধরনপ্রতি update-এ পুনর্গঠনবাড়তি তথ্য প্রতি nodeকোথায় বেশি দেখা যায়
AVL tree (পর্ব ২৮)worst O(log n)কঠোর: উচ্চতা < 1.44 log₂ ninsert ≤ ১টি (single বা double) rotation; delete O(log n) পর্যন্তউচ্চতা বা balance factorখোঁজা বেশি, পরিবর্তন কম এমন কাজ
Red-black treeworst O(log n)ঢিলে: উচ্চতা ≤ 2 log₂(n+1)insert ≤ ২, delete ≤ ৩ rotation; রং বদল amortized O(1)১ bitstd::map, TreeMap, Linux kernel
Treap (পর্ব ৩৮)expected O(log n)এলোমেলো priority — heap আর BST একসাথেগড়ে ধ্রুবক সংখ্যক rotation; split/merge সহজএকটি এলোমেলো সংখ্যাকনটেস্ট (implicit treap, রেঞ্জ উল্টানো)
Skip list (পর্ব ৩৮)expected O(log n)এলোমেলো স্তর — tree নয়, বহুতল linked listrotation নেই, শুধু pointer জোড়াগড়ে ২টি pointerRedis sorted set, Java ConcurrentSkipListMap, LevelDB
B-tree (পর্ব ৩০)O(log n) তুলনা, O(logB n) node পড়াসব leaf এক স্তরে, প্রতি node-এ অনেক keynode ভাঙা ও জোড়া (split/merge)node-এ B টি পর্যন্ত keyডেটাবেস, ফাইল সিস্টেম, Rust-এর BTreeMap
Splay treeamortized O(log n); একটি কাজ worst O(n)কোনো নিয়ম নেই — যাকে ছোঁয়া হয় তাকে root-এ আনাপ্রতিটি কাজে, এমনকি search-এও, rotationকিছু নাবারবার একই key-তে প্রবেশ, link-cut tree

Splay tree নিয়ে দুটি কথা, কারণ এই কোর্সে এর আলাদা পর্ব নেই। Daniel Sleator আর Robert Tarjan ১৯৮৫ সালে এটি প্রকাশ করেন। এর কোনো ভারসাম্যের নিয়ম বা রং নেই: যে node খোঁজা হয়, তাকে কয়েকটি বিশেষ rotation (zig, zig-zig, zig-zag) দিয়ে root পর্যন্ত তুলে আনা হয়। একটি একক কাজ O(n) পর্যন্ত লাগতে পারে, কিন্তু m টি কাজের মোট খরচ O((m + n) log n) — অর্থাৎ m ≥ n হলে প্রতি কাজে amortized O(log n) (পর্ব ৬০-এর potential পদ্ধতিতে প্রমাণ)। আর যে key বারবার চাওয়া হয়, সে root-এর কাছে থাকে বলে খুব দ্রুত পাওয়া যায় — একটি ক্যাশের মতো।

কোনটি কখন — সিদ্ধান্তের সংক্ষিপ্ত নির্দেশিকা

  • সাধারণ কাজ, ভাষার লাইব্রেরি আছে: নিজে লিখবেন না — std::map/std::set, TreeMap ব্যবহার করুন। শুধু খোঁজা লাগলে, ক্রম লাগে না, তাহলে hash table (পর্ব ৩২) সাধারণত আরও দ্রুত।
  • খোঁজা অনেক বেশি, পরিবর্তন খুব কম: AVL-এর কম উচ্চতা কাজে লাগে; আর ডেটা একবার বানিয়ে আর না বদলালে সাধারণ সাজানো অ্যারে আর binary search (পর্ব ১৮) প্রায়ই সবচেয়ে দ্রুত।
  • insert আর delete অনেক, worst case-এর নিশ্চয়তা জরুরি: red-black tree — kernel আর real-time সিস্টেমের পছন্দ।
  • ডেটা ডিস্কে বা মেমরির চেয়ে বড়: B-tree বা B+ tree — প্রতিটি ডিস্ক-পড়ায় অনেক key.
  • কনটেস্টে split/merge, k-তম উপাদান, রেঞ্জ উল্টানো: treap, অথবা GNU-র policy-based tree দিয়ে বানানো ordered set কাঠামো।
  • অনেক thread একসাথে লেখে: skip list — rotation না থাকায় lock ছাড়া বা কম lock-এ বানানো সহজ।
  • কিছু key বারবার চাওয়া হয়: splay tree.

পরীক্ষা ও ইন্টারভিউতে:

  • n টি internal node-এর red-black tree-র সর্বোচ্চ উচ্চতা কত, আর কেন? — 2 log₂(n+1); subtree-তে ≥ 2bh − 1 node (induction), আর লাল নিয়মে bh ≥ h/2.
  • নতুন node লাল কেন? — কালো দিলে একটি পথে কালো বেড়ে নিয়ম ৫ ভাঙত; লাল দিলে শুধু লাল–লাল (নিয়ম ৪) বা root-লাল ভাঙতে পারে, যা স্থানীয়ভাবে সারানো যায়।
  • Insert-এ সর্বোচ্চ কয়টি rotation? Delete-এ? — insert-এ ২টি, delete-এ ৩টি; রং বদল O(log n) পর্যন্ত, কিন্তু amortized O(1).
  • AVL আর red-black — কোনটি কখন? — AVL নিচু, তাই খোঁজা একটু দ্রুত; red-black-এ update-এ পুনর্গঠন কম, তাই মিশ্র কাজে আর লাইব্রেরিতে।
  • ডেটাবেস index-এ red-black tree নয় কেন? — উচ্চতা log₂ n মানে প্রায় ততবার ডিস্ক পড়া; B-tree-তে প্রতি node-এ শত শত key, তাই উচ্চতা ৩–৪, ডিস্ক পড়াও ততবার।

১০ · AI-এর যুগে red-black tree — কেস ঠিক, আয়না ভুল

“C++-এ একটি red-black tree লিখে দাও” — AI কয়েক সেকেন্ডে দেড়শো লাইন দেবে, আর ছোট পরীক্ষায় সেটি ঠিকঠাক চলবেও। সমস্যা হলো, red-black tree-র ভুল সাধারণত দুর্লভ কেসে লুকিয়ে থাকে: আয়না-রূপের একটি “left” যেখানে “right” হওয়ার কথা, rotation-এর পর parent pointer হালনাগাদ বাদ, বা delete-এর চারটি কেসের একটিতে ভুল রং। সাজানো ছোট ইনপুটে এই শাখাগুলো হয়তো কখনো চলেই না। আর তত্ত্বের প্রশ্নে AI প্রায়ই আত্মবিশ্বাসের সাথে ভুল সংখ্যা বলে — যেমন উচ্চতা “≤ log₂ n”, বা “insert-এ O(log n) rotation”.

Red-black tree-র কোডে AI সাধারণত যেসব ভুল করে

AI-এর ভুলকীভাবে ধরবেন
আয়না কেসে (parent ডান সন্তান) left/right উল্টো বা অপরিবর্তিত কপিকমতে থাকা ক্রম (যেমন 10, 9, …, 1) আর বাড়তে থাকা ক্রম — দুটিই চালিয়ে প্রতিবার যাচাই-ফাংশন চালান
Rotation-এ y->p, মাঝের subtree-র parent, বা root হালনাগাদ বাদপ্রতিটি node-এর জন্য child->p == node পরীক্ষা করে এমন একটি assert যোগ করুন
NIL-কে কালো না ধরে nullptr-এর রং পড়তে যাওয়াuncle NIL এমন কেস হাতে বানান (10, 20, 30) — crash হয় কি না দেখুন
Fix-up শেষে root কালো করা বাদএকটি node দিয়ে চালিয়ে root-এর রং ছাপুন
Delete-এ double black-এর কেস ১-এর পর নতুন sibling না নেওয়াএলোমেলো insert আর delete মিশিয়ে হাজারবার চালান, প্রতিবার std::set-এর সাথে মিলিয়ে দেখুন
“উচ্চতা ≤ log₂ n”, “AVL আর red-black একই”, “splay tree worst case O(log n)” দাবিপ্রমাণ চান; সংখ্যা দিয়ে যাচাই করুন — 1 থেকে 15 ক্রমে insert-এ উচ্চতা 5, যেখানে ⌊log₂ 15⌋ = 3

AI-কে পুরো tree লিখে দিতে না বলে, আপনার কোড, trace আর প্রমাণ যাচাই করতে বলুন — আর প্রতিটি দাবির কারণ চান:

আমি DSA শিখছি, এখন red-black tree. নিচে আমার লেখা insert (CLRS-ধাঁচের fix-up) আর একটি ইনপুটে আমার হাতে করা trace দিলাম: [এখানে কোড আর trace পেস্ট করুন] পুরো কোড নতুন করে লিখে দিও না। শুধু এগুলো করো: ১. 41, 38, 31, 12, 19, 8 ইনপুটে প্রতিটি insert-এর পর tree-টি রংসহ দেখাও, কোন কেস চলল বলো, আর আমার trace-এর সাথে কোথায় অমিল তা দেখাও। ২. আমার আয়না কেসে (parent ডান সন্তান) left আর right ঠিক জায়গায় আছে কি না, লাইন ধরে বলো। ৩. rotation-এর পর প্রতিটি parent pointer ঠিক হালনাগাদ হচ্ছে কি না দেখাও। ৪. উচ্চতা ≤ 2 log₂(n+1)-এর প্রমাণে আমার যুক্তির কোন ধাপ দুর্বল, তা বলো — শুধু উত্তর নয়, কারণসহ।

Red-black tree-র কোড যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১Bayer-এর “symmetric binary B-tree” থেকে Guibas–Sedgewick-এর “red-black” — নামটি কোথা থেকে এল, আর ছয় বছরে আসলে কী বদলাল?

১৯৭০-এর দশকের শুরুতে ভারসাম্যপূর্ণ tree নিয়ে দুটি ধারা চলছিল। একদিকে ১৯৬২ সালের AVL tree — binary, কিন্তু কঠোর উচ্চতার নিয়মে। অন্যদিকে Rudolf Bayer আর Edward McCreight-এর B-tree (১৯৭০–৭২), যা ডিস্কের জন্য বানানো, প্রতিটি node-এ অনেক key, আর সব leaf এক স্তরে। ১৯৭২ সালে Bayer প্রশ্ন করলেন: B-tree-র সবচেয়ে ছোট রূপ — যেখানে প্রতি node-এ ১ থেকে ৩টি key — কি binary tree দিয়ে প্রকাশ করা যায়? তাঁর উত্তর “symmetric binary B-tree”: বড় node-কে কয়েকটি binary node-এ ভেঙে, তাদের মধ্যের link-কে “আনুভূমিক” বলে চিহ্নিত করা। ধারণাটি সঠিক ছিল, কিন্তু বর্ণনা আর অ্যালগরিদম ছিল জটিল।

১৯৭৮ সালে Leonidas Guibas আর Robert Sedgewick “A dichromatic framework for balanced trees” প্রবন্ধে একটি সরল কৌশল দেখালেন: link-এর ধরন না বলে প্রতিটি node-কে দুটি রঙের একটি দিন। আনুভূমিক link-এর নিচের node “লাল”, বাকিরা “কালো”. এই এক বদলে পুরো তত্ত্ব কয়েকটি সহজ, স্থানীয় নিয়মে নেমে এল, আর তাঁরা দেখালেন যে AVL-সহ আরও কয়েকটি ভারসাম্যপূর্ণ tree-ও একই রঙিন কাঠামোয় ব্যাখ্যা করা যায় — তাই “framework”.

কেন লাল আর কালো? Sedgewick পরে নানা সাক্ষাৎকার আর বক্তৃতায় এর গল্প বলেছেন। একটি গল্প অনুযায়ী, তাঁরা তখন Xerox PARC-এর সাথে যুক্ত ছিলেন, যেখানে সেই সময়ের নতুন রঙিন লেজার প্রিন্টার ছিল, আর তাতে লাল রংটিই ছাপায় সবচেয়ে ভালো দেখাত। আরেকটি গল্পে আছে, হাতে আঁকার জন্য তাঁদের কাছে লাল আর কালো কলমই ছিল। কোনটি পুরো সত্য বলা কঠিন — হয়তো দুটোই কিছুটা। তবে নামটি টিকে গেছে, কারণ এটি ছোট, মনে রাখা সহজ, আর ছবিতে দেখানো সহজ।

গল্পটির আসল শিক্ষা উপস্থাপনার শক্তি। Bayer-এর স্ট্রাকচার আর red-black tree গাণিতিকভাবে প্রায় একই জিনিস, কিন্তু রঙের ভাষা চিন্তাকে সহজ করেছে — নিয়মগুলো পরীক্ষা করা, প্রমাণ লেখা, কোড লেখা সবই। পরে CLRS বইয়ে এই রূপটিই পাঠ্য হয়ে যায়, আর সেখান থেকে Java-র TreeMap-এর মতো বাস্তব কোডে।

মূল উপলব্ধি: নতুন ডেটা স্ট্রাকচার সবসময় নতুন ধারণা নয় — কখনো পুরোনো ধারণার ভালো ভাষা। Red-black tree হলো binary পোশাকে 2-3-4 tree, আর “রং” সেই পোশাক।

প্র ০২AVL tree বেশি নিচু, তাই খোঁজা দ্রুত — তবু C++ আর Java-র লাইব্রেরি red-black tree কেন বেছে নিল?

কাগজে AVL-এর সুবিধা স্পষ্ট: উচ্চতা প্রায় 1.44 log₂ n, যেখানে red-black-এর সীমা 2 log₂ n. দশ লাখ key-তে worst case-এ ২৮ বনাম ৩৯ তুলনা। কিন্তু লাইব্রেরির ডিজাইনার জানেন না তাঁর map কীভাবে ব্যবহার হবে — কেউ শুধু খুঁজবেন, কেউ প্রতি সেকেন্ডে লাখবার insert আর erase করবেন। তাই প্রশ্ন “কোনটি সব ধরনের কাজে নিরাপদভাবে ভালো”, শুধু খোঁজায় নয়।

প্রথম কারণ পুনর্গঠনের খরচ। Red-black tree-তে insert-এ সর্বোচ্চ ২টি, erase-এ ৩টি rotation, আর রং বদল amortized O(1). AVL-এ insert-এ একটি rotation যথেষ্ট, কিন্তু erase-এর পর rotation-এর ঢেউ root পর্যন্ত চলতে পারে, আর প্রতিটি পরিবর্তনে পথ ধরে উচ্চতা হালনাগাদ করতে হয়। Rotation মানে একাধিক pointer লেখা, যা মেমরিতে ছড়ানো node-এ cache miss ঘটায়। দ্বিতীয় কারণ, red-black tree-র বাড়তি তথ্য মাত্র এক bit — অনেক বাস্তবায়নে এটি parent pointer-এর অব্যবহৃত সবচেয়ে নিচের bit-এ লুকিয়ে রাখা হয় (Linux kernel ঠিক এটিই করে), ফলে node-এর আকার বাড়ে না।

তৃতীয় কারণ ইতিহাস আর পরিচিতি। ১৯৯০-এর দশকে HP-র STL — যা থেকে C++ standard library-র container-গুলোর জন্ম — red-black tree ব্যবহার করে, আর পরের বাস্তবায়নগুলো সেই নকশা অনুসরণ করে। Java-র TreeMap সরাসরি CLRS-এর অ্যালগরিদম থেকে নেওয়া। একবার একটি নির্ভরযোগ্য, ভালোভাবে পরীক্ষিত বাস্তবায়ন তৈরি হলে বদলানোর কারণ লাগে, আর পার্থক্যটি সেই কারণ হওয়ার মতো বড় নয়।

বাস্তব মাপজোখও একটি পরিষ্কার বিজয়ী দেখায় না। বিভিন্ন গবেষণা আর benchmark-এ দেখা গেছে, খোঁজা-প্রধান কাজে AVL সামান্য এগিয়ে, পরিবর্তন-প্রধান কাজে red-black, আর কিছু ক্ষেত্রে (যেমন একই key বারবার চাওয়া) splay tree. আর এই সব পার্থক্যই প্রায়ই মেমরি-বিন্যাসের প্রভাবের চেয়ে ছোট — node একটি অ্যারেতে ঘন করে রাখলে যেকোনো tree দ্রুত হয়, এজন্যই Rust-এর standard library B-tree বেছে নিয়েছে।

মূল উপলব্ধি: লাইব্রেরির জন্য “সবচেয়ে দ্রুত” নয়, “কোথাও খারাপ নয়” বেশি জরুরি। Red-black tree প্রতিটি কাজে ছোট, নিশ্চিত খরচ দেয় — সেটিই তার শক্তি।

প্র ০৩Java 8-এ HashMap-এর একটি bucket ভরে গেলে তা red-black tree-তে বদলে যায় — একটি hash table-এর ভেতরে tree কেন দরকার হলো?

পর্ব ৩২-এ দেখবেন, hash table-এ গড়ে প্রতিটি কাজ O(1), কারণ key-গুলো অনেক bucket-এ ছড়িয়ে থাকে। কিন্তু সব key যদি একই bucket-এ পড়ে, সেই bucket-এর linked list লম্বা হয়ে খোঁজা O(n)-এ নামে। স্বাভাবিক ডেটায় এটি প্রায় ঘটে না। কিন্তু কেউ যদি ইচ্ছাকৃতভাবে এমন key বানায়?

২০১১ সালের শেষে জার্মানির Chaos Communication Congress-এ দুই গবেষক দেখান যে PHP, Java, Python, Ruby-সহ বহু ভাষার ওয়েব সার্ভার এই আক্রমণে কাবু হয়। একটি ওয়েব ফর্মের প্যারামিটারগুলো সার্ভার সাধারণত একটি hash table-এ রাখে। আক্রমণকারী এমন হাজার হাজার প্যারামিটার-নাম পাঠায়, যাদের hash একই — তখন প্রতিটি insert আগের সবার সাথে তুলনা করে, মোট O(n²) কাজ। একটি ছোট HTTP request সার্ভারের CPU মিনিটের পর মিনিট আটকে রাখতে পারে। এই hash flooding আক্রমণের জবাবে বিভিন্ন ভাষা বিভিন্ন পথ নিল: Python আর Ruby hash function-এ এলোমেলো “salt” যোগ করে, যাতে আক্রমণকারী আগে থেকে সংঘর্ষ হিসাব করতে না পারে।

Java একটি ভিন্ন, প্রকৌশলগত সমাধান নেয়। Java 8 থেকে HashMap-এর কোনো bucket-এ ৮টির বেশি entry জমলে (আর টেবিলের আকার অন্তত ৬৪ হলে; ছোট টেবিলে শুধু আকার বাড়ানো হয়) সেই bucket-এর list একটি red-black tree-তে বদলে যায়, আর ৬টিতে নামলে আবার list হয়। Tree-তে সাজানোর জন্য key-এর hash, তারপর key Comparable হলে তার তুলনা, আর শেষ উপায় হিসেবে একটি স্থির টাই-ব্রেকার ব্যবহার হয়। ফল: key Comparable হলে (যেমন String) সবচেয়ে খারাপ bucket-এও খোঁজা O(log n), আর আক্রমণের খরচ O(n²) থেকে O(n log n)-এ নামে।

কেন সব bucket শুরু থেকেই tree নয়? কারণ tree-এর node বড় (parent, বাঁ, ডান, রং), আর ছোট bucket-এ সরল list দ্রুত। ভালো hash-এ একটি bucket-এ ৮টি entry জমার সম্ভাবনা এতই কম যে tree প্রায় কখনো তৈরি হয় না — এটি শুধু খারাপ দিনের বীমা।

মূল উপলব্ধি: গড়ের নিশ্চয়তা আক্রমণকারীর সামনে টেকে না; worst case-এর নিশ্চয়তা টেকে। Red-black tree এখানে একটি নিরাপত্তা-জাল — দ্রুত পথটি সাধারণ, আর ধীর পথটিও সহনীয়।

প্র ০৪Linux-এর CFS scheduler কেন heap নয়, red-black tree ব্যবহার করত — যখন তার মূল কাজ তো শুধু “সবচেয়ে কম সময় পাওয়া task”টি বের করা?

প্রথম দেখায় এটি পর্ব ২৩-এর priority queue-র কাজ: প্রতিটি চলার-যোগ্য task-এর একটি key — সে এ পর্যন্ত কতটা (ওজন-সমন্বিত) CPU সময় পেয়েছে, যাকে বলে vruntime — আর scheduler সবসময় সবচেয়ে কম vruntime-এর task-কে CPU দেয়। Ingo Molnár ২০০৭ সালে Linux 2.6.23-এ CFS যোগ করেন, আর তিনি heap নয়, rbtree বেছে নেন। কেন?

প্রথম কারণ, task শুধু সবচেয়ে ছোটটি হয়ে বের হয় না। একটি task যেকোনো সময় ঘুমিয়ে পড়তে পারে (কিবোর্ডের অপেক্ষা, ডিস্কের অপেক্ষা), অন্য CPU-তে সরতে পারে, বা শেষ হয়ে যেতে পারে — তখন তাকে কাঠামোর মাঝখান থেকে সরাতে হয়। Binary heap-এ মাঝখান থেকে সরাতে প্রতিটি উপাদানের index জানা লাগে আর সেটি সবসময় হালনাগাদ রাখতে হয়; rbtree-তে node-এর pointer থাকলেই O(log n)-এ সরানো যায়। দ্বিতীয় কারণ, “সবচেয়ে বাঁয়ের node”-টি আলাদা করে মনে রাখা (cache) হয়, তাই পরের task বাছাই O(1).

তৃতীয় কারণ kernel-এর বিশেষ চাহিদা। Linux-এর rbtree একটি intrusive স্ট্রাকচার: tree-এর node (struct rb_node) আলাদা করে তৈরি হয় না, বরং task-এর নিজের struct-এর ভেতরে বসানো থাকে। ফলে insert করতে মেমরি বরাদ্দ লাগে না — scheduler-এর মতো জায়গায়, যেখানে মেমরি বরাদ্দ ব্যর্থ হওয়া বা দেরি করা অসম্ভব, এটি অপরিহার্য। Heap-এর জন্য একটি বাড়তে-কমতে পারে এমন অ্যারে লাগত, যা আকার বদলের সময় মেমরি চায়। আর rbtree-র worst case নিশ্চয়তা (amortized নয়) real-time আচরণের জন্য জরুরি।

Linux 6.6 (২০২৩) থেকে CFS-এর জায়গায় এসেছে EEVDF (Earliest Eligible Virtual Deadline First) — একটি পুরোনো গবেষণাপত্রের ধারণা, যেখানে প্রতিটি task-এর একটি “virtual deadline” থাকে। কিন্তু ভেতরের কাঠামো এখনো rbtree, এবার প্রতিটি node-এ subtree-র একটি বাড়তি সারাংশ রাখা “augmented” রূপে। rbtree kernel-এর অন্যত্রও সর্বত্র: epoll, high-resolution timer, আর বহু বছর ধরে প্রতিটি process-এর memory area-র তালিকা (Linux 6.1-এ যার জায়গা নিয়েছে “maple tree” নামে একটি B-tree-ধাঁচের স্ট্রাকচার)।

মূল উপলব্ধি: বাস্তব সিস্টেমে ডেটা স্ট্রাকচার বাছাই শুধু “কোন কাজ কত দ্রুত” নয় — মাঝখান থেকে সরানো, মেমরি বরাদ্দ, worst case-এর নিশ্চয়তা, সবকিছু মিলিয়ে।

প্র ০৫Splay tree-র কোনো ভারসাম্যের নিয়মই নেই, তবু amortized O(log n) — আর “dynamic optimality” কেন আজও একটি খোলা প্রশ্ন?

AVL আর red-black tree “আগাম সতর্কতা” নেয়: প্রতিটি পরিবর্তনের পর নিয়ম মেনে আকৃতি ঠিক রাখে, যাতে কোনো কাজ কখনো ধীর না হয়। ১৯৮৫ সালে Daniel Sleator আর Robert Tarjan একটি উল্টো দর্শন দেখান: কোনো নিয়ম রাখা নয়, শুধু যাকে ছোঁয়া হলো তাকে root-এ তুলে আনা — একটি বিশেষ ধরনের জোড়া-rotation (zig-zig, zig-zag) দিয়ে। একটি কাজ ধীর হতে পারে (লম্বা পথ), কিন্তু সেই কাজটিই পথের প্রায় সব node-এর গভীরতা অর্ধেক করে দেয়, তাই পরের কাজগুলো দ্রুত হয়। potential পদ্ধতিতে (পর্ব ৬০) প্রমাণ হয় যে m টি কাজের মোট খরচ O((m + n) log n).

আরও আশ্চর্য কিছু বৈশিষ্ট্য প্রমাণিত: splay tree “static optimality” পায় — কোন key কতবার চাওয়া হবে তা আগে জানলে যে সেরা স্থির BST বানানো যেত, তার সমান খরচ (ধ্রুবক গুণ পর্যন্ত), কিছু না জেনেই। সম্প্রতি চাওয়া key কাছে থাকে (“working set” বৈশিষ্ট্য), আর key-গুলো ক্রমে চাইলে মোট খরচ O(n) — একে বলে “sequential access” বৈশিষ্ট্য।

Sleator আর Tarjan আরও বড় একটি অনুমান করেন: যেকোনো প্রবেশ-ক্রমের জন্য splay tree সবচেয়ে ভালো সম্ভাব্য BST অ্যালগরিদমের — যে ভবিষ্যৎ জানে আর ইচ্ছামতো rotation করতে পারে — ধ্রুবক গুণের মধ্যে থাকে। এটিই dynamic optimality conjecture, আর চার দশক পরেও এটি প্রমাণ বা খণ্ডন হয়নি। ২০০৪ সালে Erik Demaine ও সহকর্মীরা “Tango tree” দেন, যা প্রমাণিতভাবে সেরার O(log log n) গুণের মধ্যে — এখনো জানা সেরা ফলের একটি, কিন্তু ধ্রুবক নয়।

বাস্তবে splay tree-র দুর্বলতাও আছে: খোঁজাও tree বদলায়, তাই একাধিক thread একসাথে শুধু পড়লেও lock লাগে, আর প্রতিটি খোঁজায় মেমরিতে লেখা হয়। একটি কাজের worst case O(n) হওয়ায় real-time সিস্টেমেও অচল। তবু link-cut tree-র মতো উন্নত স্ট্রাকচার (গ্রাফ অ্যালগরিদমে, network flow-এ) splay tree-র ওপর গড়া, আর কনটেস্টে এটি নিয়মিত দেখা যায়।

মূল উপলব্ধি: ভারসাম্য দুই উপায়ে আসে — প্রতিবার নিয়ম মেনে (red-black, AVL), বা ব্যবহারের ধরন থেকে নিজে নিজে শিখে, যেমন splay tree করে। প্রথমটি প্রতিটি কাজের নিশ্চয়তা দেয়, দ্বিতীয়টি দীর্ঘমেয়াদে আরও ভালো হতে পারে। কোনটি “সেরা”, তা এখনো গবেষণার প্রশ্ন।

অনুশীলন

খাতা-কলম নিন। প্রথম তিনটি হাতে করার, শেষটিতে কোড লিখতে হবে।

  1. হাতে trace করুন: একটি খালি red-black tree-তে (CLRS-ধাঁচের insert) পরপর insert করুন 41, 38, 31, 12, 19, 8. প্রতিটি insert-এর পর tree-টি রংসহ আঁকুন আর বলুন কোন কেস (১, ২, ৩) চলল।

    41 → root, কালো: 41B. 38 → 41-এর বাঁয়ে লাল, parent কালো: 41B(38R,-). 31 → 38-এর বাঁয়ে; parent 38 লাল, uncle NIL কালো, বাঁ-বাঁ সরলরেখা → কেস ৩: 38 কালো, 41 লাল, 41-এ right rotation: 38B(31R,41R). 12 → 31-এর বাঁয়ে; parent 31 লাল, uncle 41 লাল → কেস ১: 31, 41 কালো, 38 লাল → root, তাই আবার কালো: 38B(31B(12R,-),41B).

    19 → 31-এর বাঁ, 12-এর ডানে; parent 12 লাল, uncle (31-এর ডান) NIL কালো, আর 31 → বাঁ → ডান — ত্রিভুজ → কেস ২: 12-এ left rotation (এখন 19 parent, 12 তার বাঁ সন্তান), তারপর কেস ৩: 19 কালো, 31 লাল, 31-এ right rotation: 38B(19B(12R,31R),41B). 8 → 12-এর বাঁয়ে; parent 12 লাল, uncle 31 লাল → কেস ১: 12, 31 কালো, 19 লাল; 19-এর parent 38 কালো, থামা: 38B(19R(12B(8R,-),31B),41B). black-height 2, মোট ৩টি rotation. (এই পর্বের প্রোগ্রামে এই ক্রম চালিয়ে মিলিয়ে নিন।)

  2. ভেবে বলুন: একটি red-black tree-তে root-এর black-height 3 (CLRS-এর সংজ্ঞায়: root বাদে, NIL-সহ)। (ক) এতে সবচেয়ে কম কয়টি আর সবচেয়ে বেশি কয়টি internal node থাকতে পারে? (খ) root থেকে কোনো NIL পর্যন্ত পথের দৈর্ঘ্য (edge) সবচেয়ে কম আর সবচেয়ে বেশি কত হতে পারে?

    (ক) সবচেয়ে কম: সব node কালো, একটি perfect tree — root থেকে NIL পর্যন্ত ৩টি edge, অর্থাৎ ৩ স্তরের internal node: 23 − 1 = ৭. দাবি ১-এর সীমা 2bh − 1 ঠিক এখানে সমান হয়। সবচেয়ে বেশি: প্রতিটি কালো node-এর দুই সন্তান লাল (প্রতিটি 2-3-4 node একটি 4-node) — তখন কালো-লাল পালা করে, উচ্চতা ৬ স্তর, perfect tree: 26 − 1 = ৬৩.

    (খ) প্রতিটি পথে কালো (NIL-সহ, root বাদে) ঠিক ৩টি। সবচেয়ে ছোট পথে কোনো লাল নেই: ৩টি edge. সবচেয়ে লম্বা পথে root-এর পরে প্রতিটি কালোর আগে একটি লাল: root → R → B → R → B → R → NIL(B) = ৬টি edge. ঠিক দ্বিগুণ — অংশ ৩-এর “সর্বোচ্চ দ্বিগুণ”-এর সবচেয়ে চরম রূপ।

  3. 2-3-4 রূপান্তর: red-black tree 38B(19R(12B(8R,-),31B),41B)-কে 2-3-4 tree-তে রূপান্তর করুন। তারপর বলুন: এখন 5 insert করলে CLRS fix-up-এ কোন কেস চলবে, আর 2-3-4 tree-তে সেটি কী ঘটনা?

    প্রতিটি কালো node আর তার লাল সন্তান একটি 2-3-4 node: root-এ 38 কালো, তার লাল সন্তান 19 → 3-node [19 38]. তার নিচে তিনটি সন্তান: 12B আর লাল 8 → 3-node [8 12]; 31B একা → [31]; 41B একা → [41]. সব leaf এক স্তরে — ঠিক যেমন হওয়ার কথা।

    5 insert: 5 < 38, 5 < 19, 5 < 12, 5 < 8 → 8-এর বাঁয়ে লাল। parent 8 লাল, grandparent 12, uncle (12-এর ডান) NIL — কালো, আর বাঁ-বাঁ সরলরেখা → কেস ৩: 8 কালো, 12 লাল, 12-এ right rotation → 8B(5R,12R). 2-3-4 অর্থে 3-node [8 12]-এ 5 ঢুকে 4-node [5 8 12] হলো — কোনো node ভাঙেনি, tree-র উচ্চতাও বাড়েনি।

  4. কোড লিখুন: একটি ফাংশন লিখুন যা যেকোনো binary tree (প্রতিটি node-এ key, রং, বাঁ, ডান) পেয়ে বলে সেটি বৈধ red-black tree কি না — পাঁচটি নিয়ম আর BST ক্রম, দুটোই যাচাই করে। time আর space complexity বলুন।

    একটি recursion-এ তিনটি কাজ: key-এর অনুমোদিত সীমা (lo, hi) নিচে পাঠানো (BST ক্রম), লাল–লাল যাচাই, আর দুই পাশের black-height তুলনা। ভাঙলে −1 ফেরত দিন।

    struct N { long long key; bool red; N *l, *r; };
    bool isRed(N* x) { return x && x->red; }
    // black-height below x (NIL = 0), or -1 if x's subtree breaks a rule
    int check(N* x, long long lo, long long hi) {
        if (!x) return 0;
        if (x->key <= lo || x->key >= hi) return -1;              // BST order
        if (x->red && (isRed(x->l) || isRed(x->r))) return -1;      // rule 4
        int a = check(x->l, lo, x->key), b = check(x->r, x->key, hi);
        if (a < 0 || b < 0) return -1;
        a += !isRed(x->l); b += !isRed(x->r);                     // a black child adds 1
        return a == b ? a : -1;                                      // rule 5
    }
    bool isRedBlack(N* root) {
        return !isRed(root) && check(root, LLONG_MIN, LLONG_MAX) >= 0;   // rule 2
    }

    নিয়ম ১ আর ৩ গঠন থেকেই সত্য (bool রং, আর nullptr কালো ধরা)। প্রতিটি node একবার দেখা হয়, তাই সময় Θ(n); বাড়তি জায়গা recursion-এর গভীরতা, O(h) — বৈধ tree-তে O(log n), কিন্তু ভাঙা (লাঠির মতো) tree-তে O(n) পর্যন্ত। এখানে key আলাদা ধরা হয়েছে (সমান key এলে ভাঙা বলবে); LLONG_MIN-এর জন্য <climits> লাগবে।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমRed-black tree-র উচ্চতা ≤ 2 log₂(n+1) প্রমাণের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. black-height bh(x)-এর সংজ্ঞা: x থেকে যেকোনো NIL পর্যন্ত পথে কালো node-এর সংখ্যা (কালো নিয়মে সব পথে সমান)
  2. Induction: x-এর subtree-তে কমপক্ষে 2bh(x) − 1 টি internal node
  3. লাল নিয়ম থেকে: root থেকে যেকোনো পথে কমপক্ষে অর্ধেক node কালো, তাই bh(root) ≥ h/2
  4. দুটি মিলিয়ে: n ≥ 2h/2 − 1
  5. log নিয়ে: h ≤ 2 log₂(n + 1)

Red-black tree-তে insert করার সময় নতুন node-কে সাধারণত লাল রং দেওয়া হয় কেন?

  • লাল node খোঁজার সময় দ্রুত পাওয়া যায় বলে
  • লাল দিলে কোনো নিয়মই ভাঙতে পারে না
  • কালো দিলে একটি পথের black-height বেড়ে যেত, যা সারানো কঠিন; লাল দিলে শুধু স্থানীয় লাল–লাল সমস্যা হতে পারে
  • নতুন node সবসময় root হয়, আর root লাল হওয়ার কথা

n টি internal node-এর একটি red-black tree-র উচ্চতা h-এর জন্য কোন সীমাটি সবসময় সত্য?

  • h ≤ ⌊log₂ n⌋
  • h ≤ 2 log₂(n + 1)
  • h ≤ 1.44 log₂ n
  • h ≤ √n

Red-black tree-তে insert-এর পর নতুন লাল node z-এর parent লাল, আর z-এর uncle-ও লাল। CLRS fix-up কী করে?

  • parent আর uncle কালো, grandparent লাল করে, grandparent থেকে আবার যাচাই করে
  • grandparent-এ একটি rotation করে কাজ শেষ করে
  • z-কে কালো করে কাজ শেষ করে
  • পুরো tree আবার শুরু থেকে বানায়

CLRS-ধাঁচের red-black tree-তে একটি insert-এ সবচেয়ে বেশি কয়টি rotation হতে পারে?

  • O(log n) টি
  • ১টি
  • ৩টি
  • ২টি

Red-black tree আর 2-3-4 tree-র সম্পর্কে, একটি কালো node যার দুই সন্তানই লাল — এটি কোন 2-3-4 node-এর সমান?

  • 2-node
  • 3-node
  • 4-node
  • 5-node

একটি red-black tree-তে root থেকে সবচেয়ে ছোট NIL-পথে ৪টি edge আছে। root থেকে সবচেয়ে লম্বা NIL-পথে সর্বোচ্চ কয়টি edge থাকতে পারে?

  • ৪টি
  • ৮টি
  • ১২টি
  • কোনো সীমা নেই

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. পাঁচটি নিয়ম: লাল বা কালো; root কালো; NIL কালো; লালের সন্তান কালো; প্রতিটি পথে সমান কালো (black-height).
  2. প্রমাণ: subtree-তে ≥ 2bh − 1 node, আর bh ≥ h/2 — তাই h ≤ 2 log₂(n+1), সব কাজ worst case O(log n).
  3. Red-black tree = 2-3-4 tree: কালো node আর তার লাল সন্তানেরা মিলে একটি node; লাল link = জোড়া লাগানো।
  4. Insert: নতুন node লাল; uncle লাল → রং বদল, দুই স্তর ওপরে; uncle কালো → ত্রিভুজ হলে আগে একটি rotation, তারপর রং বদল আর একটি rotation. সর্বোচ্চ ২টি rotation; delete-এ double black, সর্বোচ্চ ৩টি।
  5. তুলনা: খোঁজা-প্রধান কাজে AVL, সাধারণ লাইব্রেরি ও kernel-এ red-black, ডিস্কে B-tree, কনটেস্টে treap, সমান্তরাল কাজে skip list, বারবার একই key-তে splay.

পরের পর্বে: ডেটা যখন মেমরিতে আঁটে না, তখন প্রতিটি tree-ধাপ মানে একটি ধীর ডিস্ক-পড়া। B-tree আর B+ tree কীভাবে প্রতিটি node-এ শত শত key রেখে উচ্চতা ৩–৪-এ নামায় — আর কেন প্রায় প্রতিটি ডেটাবেস আর ফাইল সিস্টেম এগুলোর ওপর দাঁড়িয়ে।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান