এই পর্বে যা শিখবেন
- Memory hierarchy থেকে বোঝা, কেন ডিস্কে binary tree ধীর আর কেন block গুনে বিশ্লেষণ করতে হয়
- B-tree-র সংজ্ঞা (CLRS-এর minimum degree t আর Knuth-এর order m), আর উচ্চতার সীমার প্রমাণ
- B-tree-তে search, node split দিয়ে insert আর borrow/merge দিয়ে delete হাতে trace করা
- B+ tree-র গঠন, leaf-এর শিকল দিয়ে range query, আর ডেটাবেস ও file system-এ এর ব্যবহার
- Fan-out আর উচ্চতার হিসাব, 2-3 tree, red-black tree-র সাথে সম্পর্ক আর B* tree চেনা
১ · ডিস্কে binary tree কেন ধীর — memory hierarchy আর block
পর্ব ২৭–২৯-এ আপনি binary search tree আর তাকে ভারসাম্যে রাখার উপায় দেখেছেন: n টি key-তে উচ্চতা প্রায় log₂ n, তাই খোঁজা O(log n). দশ লাখ key-তে মাত্র ২০টির মতো ধাপ। তাহলে সমস্যা কোথায়? সমস্যা হলো, এই হিসাব ধরে নেয় যে প্রতিটি node দেখতে সমান সময় লাগে — অর্থাৎ পুরো tree RAM-এ আছে। কিন্তু একটি ব্যাংকের লেনদেনের টেবিলে শত কোটি row থাকতে পারে, যা RAM-এ ধরে না। ডেটা থাকে SSD বা hard disk-এ, আর তখন খেলার নিয়ম পুরো বদলে যায়।
ধরুন আপনি একটি বিশাল সরকারি মহাফেজখানায় (archive) পুরোনো জমির দলিল খুঁজছেন। আপনার টেবিলের পাশের তাক থেকে একটি ফাইল নিতে লাগে ১ সেকেন্ড। কিন্তু বেশিরভাগ ফাইল আছে শহরের বাইরের গুদামে — সেখান থেকে আনতে ঢাকার জ্যাম পেরিয়ে যাওয়া-আসায় পুরো একটা দিন। এখন যদি গুদামের প্রতিটি ট্রিপে আপনি মাত্র একটি পাতা নিয়ে ফেরেন, আর সেই পাতা দেখে ঠিক করেন পরের দিন কোন পাতা আনবেন — ৩০টি পাতার জন্য ৩০ দিন! বুদ্ধিমান কর্মচারী কী করবেন? একবার গেলে পুরো একটি বস্তা নিয়ে আসবেন, যাতে পরের সিদ্ধান্তের জন্য দরকারি অনেক তথ্য একসাথে থাকে। B-tree ঠিক এই বুদ্ধি।
কম্পিউটারের স্টোরেজও এমন স্তরে সাজানো, যাকে বলে memory hierarchy (বিস্তারিত দেখুন কম্পিউটারের মৌলিক ধারণা কোর্সের মেমরি হায়ারার্কির পর্বে). ওপরের স্তর দ্রুত কিন্তু ছোট আর দামি, নিচের স্তর ধীর কিন্তু বিশাল আর সস্তা। নিচের সংখ্যাগুলো মোটামুটি মাপ — যন্ত্রভেদে কম-বেশি হয়, কিন্তু অনুপাতটাই আসল কথা:
| স্তর | একবার পড়তে মোটামুটি অপেক্ষা | একবারে যতটুকু আসে | মহাফেজখানার উপমায় |
|---|---|---|---|
| CPU cache (L1) | ~১ ন্যানোসেকেন্ড | ৬৪ byte-এর cache line | হাতে ধরা কাগজ |
| RAM | ~১০০ ন্যানোসেকেন্ড | ৬৪ byte-এর cache line | পাশের তাক — ১ সেকেন্ড |
| NVMe SSD | ~১০–১০০ মাইক্রোসেকেন্ড | সাধারণত ৪ KB-র page | অন্য তলার ঘর — কয়েক মিনিট |
| Hard disk (HDD) | ~৫–১০ মিলিসেকেন্ড | block (প্রায়ই ৪ KB বা বেশি) | শহরের বাইরের গুদাম — প্রায় এক দিন |
Hard disk-এর এত দেরি কেন? ভেতরে একটি চাকতি ঘোরে, আর একটি যান্ত্রিক হাত (head) সঠিক track-এ সরে গিয়ে অপেক্ষা করে কখন দরকারি অংশটি তার নিচে ঘুরে আসবে। এই নড়াচড়াই মিলিসেকেন্ড খায়। কিন্তু একবার পৌঁছে গেলে পাশাপাশি অনেক byte পড়া প্রায় বিনা খরচে হয়। SSD-তে নড়াচড়ার অংশ নেই, তবু সে ডেটা দেয় পুরো page ধরে। তাই ডিস্ক বা SSD থেকে কখনো একটি byte আসে না — আসে একটি পুরো disk block বা page, সাধারণত কয়েক KB. এক byte লাগলেও খরচ পুরো block-এর, আর block-এর বাকি byte-গুলো তখন প্রায় বিনা মূল্যে পাওয়া। এই অপেক্ষার সময়কে বলে latency.
এবার হিসাব করুন। একশো কোটি (10⁹) key-এর একটি পুরো ভারসাম্যপূর্ণ binary tree-র উচ্চতা প্রায় log₂ 10⁹ ≈ ৩০। প্রতিটি node ডিস্কের কোনো এলোমেলো জায়গায় — তাই একটি খোঁজায় ৩০টি এলোমেলো disk read, hard disk-এ প্রায় ৩০ × ১০ ms = ০.৩ সেকেন্ড। একটি ওয়েবসাইটে প্রতি সেকেন্ডে হাজার জন খুঁজলে এটি অচল। আর সবচেয়ে বড় অপচয়টি দেখুন: প্রতিটি read-এ ৪ KB-র block আনা হলো, অথচ কাজে লাগল শুধু একটি key আর দুটি pointer — হয়তো ২০ byte. বাকি ৯৯% ফেলে দেওয়া।
External-memory model — block গুনে বিশ্লেষণ
এই বাস্তবতা ধরতে ১৯৮৮ সালে Alok Aggarwal আর Jeffrey Vitter একটি বিশ্লেষণ পদ্ধতি দাঁড় করান, যাকে বলে external-memory model বা I/O model. পর্ব ০২-এর RAM model-এ প্রতিটি সরল ধাপের খরচ ১ ধরা হতো। এখানে ছবিটা অন্য রকম:
- একটি দ্রুত মেমরি, যার মাপ M, আর একটি অসীম বড় কিন্তু ধীর ডিস্ক।
- ডিস্ক থেকে ডেটা আসে-যায় শুধু B মাপের block ধরে (B = একটি block-এ কয়টি key ধরে)।
- খরচ = কয়টি block আনা-নেওয়া (I/O) হলো. মেমরির ভেতরের CPU-র কাজ বিনা খরচে ধরা হয়, কারণ সেটি তুলনায় নগণ্য।
এই মডেলে কয়েকটি পরিচিত কাজের খরচ: N টি key একবার পড়ে যাওয়া (scan) Θ(N/B) I/O — প্রতিটি block একবার। ডিস্কে রাখা সাজানো অ্যারেতে binary search প্রায় log₂(N/B) I/O — শেষের দিকে খোঁজা একটি block-এর ভেতরে চলে আসে, তার আগে প্রতিটি ধাপ নতুন block. Binary search tree-তে log₂ N I/O. আর এই পর্বের B-tree-তে মাত্র Θ(logB N) I/O. B = ১০০০ হলে log1000 10⁹ = ৩ — ৩০ নয়, ৩! ভিত্তি ২ থেকে ১০০০-এ গেলে log-এর মান প্রায় দশ ভাগের এক ভাগ হয়ে যায় (log₂ 1000 ≈ ১০)। পর্ব ২১-এর external sorting-ও এই মডেলেরই বিষয়।
ডিস্কে ডেটা স্ট্রাকচারের লক্ষ্য তুলনা কমানো নয় — block read কমানো. তাই প্রতিটি node-কে একটি পুরো block-এর মাপে বানান, তাতে যত বেশি সম্ভব key ঠাসুন। প্রতিটি node-এর সন্তানসংখ্যা — fan-out — দুই থেকে বেড়ে কয়েকশো হলে tree-র উচ্চতা log₂ n থেকে নেমে আসে logfan-out n-এ। এই “মোটা আর বেঁটে” tree-ই B-tree.
২ · B-tree-র সংজ্ঞা — minimum degree t আর উচ্চতার সীমা
B-tree একটি search tree, যার প্রতিটি node-এ একাধিক সাজানো key থাকে। k টি key-ওয়ালা একটি internal node-এর ঠিক k + 1 টি সন্তান, আর key-গুলোই সন্তানদের মাঝে সীমারেখা টানে — এদের বলে separator key. যেমন node [20 50]-এর তিন সন্তান: প্রথমটিতে সব key 20-এর ছোট, দ্বিতীয়টিতে 20 থেকে 50-এর মাঝে, তৃতীয়টিতে 50-এর বড়। পর্ব ২৭-এর BST-র “বাঁয়ে ছোট, ডানে বড়” নিয়মই, শুধু দুই দিকের বদলে অনেক দিকে। লাইব্রেরির তাকের গায়ে লেখা থাকে “০০০–২৯৯ | ৩০০–৫৯৯ | ৬০০–৯৯৯” — সেই লেবেল দেখেই ঠিক করেন কোন তাকে যাবেন; separator key ঠিক সেই লেবেল।
রীতি পরিষ্কার করে নিই। B-tree-র মাপ বলার দুটি প্রচলিত রীতি আছে, আর বই ও প্রশ্নভেদে আলাদা। এই পর্বে আমরা CLRS (Cormen, Leiserson, Rivest, Stein-এর Introduction to Algorithms) বইয়ের minimum degree t (t ≥ 2) ব্যবহার করব। অন্য রীতি Knuth-এর order m: একটি node-এর সর্বোচ্চ সন্তানসংখ্যা m. দুটির সম্পর্ক: CLRS-এর t মানে order m = 2t. যেমন t = 2 মানে order 4 — প্রতিটি node-এ ১ থেকে ৩টি key, ২ থেকে ৪টি সন্তান, যাকে বলে 2-3-4 tree. আবার order 3-এর tree (2-3 tree) CLRS-এর t দিয়ে লেখা যায় না, কারণ সর্বোচ্চ সন্তানসংখ্যা বিজোড়। পরীক্ষায় “order 5-এর B-tree” দেখলে ধরে নিন সর্বোচ্চ ৫টি সন্তান, ৪টি key — আর প্রশ্নে সংজ্ঞা দেওয়া থাকলে সেটিই মানুন।
Minimum degree t-এর একটি B-tree এই পাঁচটি নিয়ম মানে:
- প্রতিটি node-এর key-গুলো ছোট থেকে বড় ক্রমে সাজানো: k₁ < k₂ < … < km.
- m টি key-ওয়ালা internal node-এর m + 1 টি সন্তান; i-তম সন্তানের subtree-র সব key ki−1 আর ki-এর মাঝে।
- সব leaf একই গভীরতায় — tree পুরোপুরি ভারসাম্যপূর্ণ, কোনো শাখা অন্যটির চেয়ে লম্বা নয়।
- সর্বোচ্চ: প্রতিটি node-এ সর্বোচ্চ 2t − 1 টি key (তাই সর্বোচ্চ 2t টি সন্তান)। 2t − 1 টি key থাকলে node-টি “ভরা” (full).
- সর্বনিম্ন: root ছাড়া প্রতিটি node-এ অন্তত t − 1 টি key (internal হলে অন্তত t টি সন্তান)। Tree খালি না হলে root-এ অন্তত ১টি key.
নিয়ম ৪ আর ৫ মিলে প্রতিটি node অন্তত প্রায় অর্ধেক ভরা থাকে — জায়গার অপচয় সীমিত। আর নিয়ম ৩ নিশ্চিত করে যে কোনো খোঁজা কখনো অপ্রত্যাশিত লম্বা পথ ধরবে না। বাস্তবে t অনেক বড়: একটি 16 KB-র page-এ প্রতিটি entry মোটামুটি ১৬ byte ধরলে প্রায় এক হাজার entry ধরে, অর্থাৎ t কয়েকশো। কাগজে-কলমে বোঝার জন্য আমরা ছোট t (২ বা ৩) নেব।
উচ্চতার সীমা — কেন logt n
উপপাদ্য (CLRS): n ≥ 1 টি key-এর, minimum degree t-এর যেকোনো B-tree-র উচ্চতা h (root থেকে leaf পর্যন্ত edge-এর সংখ্যা) মানে
প্রমাণ. উচ্চতা h-এর একটি B-tree-তে সবচেয়ে কম কয়টি key থাকতে পারে, তা গুনি। Root-এ অন্তত ১টি key, তাই অন্তত ২টি সন্তান — গভীরতা ১-এ অন্তত ২টি node. এদের প্রত্যেকের অন্তত t টি সন্তান, তাই গভীরতা ২-এ অন্তত 2t টি node, গভীরতা ৩-এ অন্তত 2t² টি; সাধারণভাবে গভীরতা i-তে (1 ≤ i ≤ h) অন্তত 2ti−1 টি node, প্রত্যেকটিতে অন্তত t − 1 টি key. তাই
মাঝের ধাপে পর্ব ০৬-এর geometric series: 1 + t + … + th−1 = (th − 1)/(t − 1). তাহলে th ≤ (n + 1)/2, আর দুই দিকে logt নিলেই উপপাদ্য। ∎ উল্টো দিকে, প্রতিটি node পুরো ভরা হলে (2t − 1 key, 2t সন্তান) উচ্চতা h-এ সর্বোচ্চ (2t)h+1 − 1 টি key ধরে, তাই h ≥ log2t(n + 1) − 1. অর্থাৎ উচ্চতা সবসময় Θ(logt n).
সংখ্যায় দেখুন: t = ৫০০ আর n = 10⁹ হলে (n + 1)/2 ≈ 5 × 10⁸, আর log500(5 × 10⁸) ≈ ৩.২২ — তাই h ≤ ৩, অর্থাৎ root থেকে leaf পর্যন্ত বড়জোর ৪টি node. একই n-এ binary tree-তে প্রায় ৩০টি। আর root ও তার ঠিক নিচের স্তর সাধারণত সবসময় RAM-এ থাকে (সেগুলো বারবার লাগে, আর মাপে ছোট) — তাই বাস্তবে একটি খোঁজায় মাত্র ১–২টি disk read.
৩ · Search — প্রতিটি node মানে একটি disk read
খোঁজা BST-র মতোই, শুধু প্রতিটি node-এ দুই দিকের বদলে অনেক দিকের সিদ্ধান্ত। Root থেকে শুরু করুন। Node-টি ডিস্ক থেকে পড়ুন (একটি I/O), তার key-গুলোর মধ্যে খুঁজুন x-এর জায়গা কোথায়। x পেয়ে গেলে শেষ। না পেলে, x যে দুটি key-এর মাঝে পড়ে, সেই ফাঁকের সন্তানে নামুন। Leaf-এ পৌঁছেও না পেলে, x tree-তে নেই।
Pseudocode — B-tree search (keys of a node are 0-based: v.key[0 .. v.n-1])
search(v, x): // v has just been read from disk
i = first index with v.key[i] >= x // linear scan, or binary search inside the node
if i < v.n and v.key[i] == x: return (v, i)
if v is a leaf: return NOT_FOUND
DISK-READ(v.child[i]) // one I/O per level
return search(v.child[i], x)নিচে t = 3-এর একটি tree (প্রতিটি non-root node-এ ২ থেকে ৫টি key). এতে ২৪টি key, উচ্চতা ২। এখানে 66 খুঁজছি:
| এখন পড়া node | |
| node-এর ভেতরে সিদ্ধান্ত | |
| disk read (মোট) |
- শুরু. পুরো tree ডিস্কে। প্রতিটি node একটি block — তাই প্রতিটি node দেখা মানে একটি disk read. সব leaf গভীরতা ২-এ।
- Root পড়া. প্রথম read:
[40]. 66 > 40, তাই x আছে (যদি থাকে) ডান সন্তান c[1]-এর subtree-তে। বাঁ দিকের ১০টি key-এর দিকে আর তাকাতে হবে না। - দ্বিতীয় স্তর. দ্বিতীয় read:
[55 70 82]. 55 < 66 < 70, তাই ফাঁক নম্বর ১ — সন্তান c[1]. একটি read-এই চারটি দিকের মধ্যে একটি বেছে নেওয়া হলো; binary tree-তে এর জন্য দুটি স্তর লাগত। - Leaf-এ পাওয়া. তৃতীয় read:
[58 63 66]. node-এর ভেতরে খুঁজে key[2] = 66 — পাওয়া গেছে। মোট ৩টি disk read = উচ্চতা + ১। 36-এর মতো যে key নেই, তার খোঁজাও leaf-এ গিয়ে থামত — সেটিও ৩টি read.
বিশ্লেষণ. প্রতিটি স্তরে একটি node, তাই disk read O(h) = O(logt n). মেমরিতে CPU-র কাজ: প্রতিটি node-এ সর্বোচ্চ 2t − 1 টি key, সরলভাবে একে একে দেখলে O(t), node-এর ভেতরে binary search (পর্ব ১৮) করলে O(log t). মোট CPU সময় O(t logt n), বা binary search দিয়ে O(log t · logt n) = O(log n). লক্ষ করুন শেষের হিসাবটি: মোট তুলনা BST-র মতোই প্রায় log₂ n. B-tree তুলনা কমায় না — সে তুলনাগুলোকে কম কয়টি block-এ গুছিয়ে আনে। জায়গা: recursion না করে লুপে লিখলে বাড়তি O(1) node-এর জায়গা (মেমরিতে একটি block-এর বাফার)।
৪ · Insert — ভরা node ভাগ করা (node split)
নতুন key x সবসময় একটি leaf-এ ঢোকে — খোঁজার পথ ধরে নেমে যে leaf-এ x থাকার কথা, সেখানে সাজানো জায়গায়। কিন্তু leaf যদি ভরা থাকে (2t − 1 key)? তখন node split: ভরা node-এর মাঝের key (median, index t − 1) উঠে যায় parent-এ, বাঁয়ের t − 1 টি key থেকে যায় পুরোনো node-এ, আর ডানের t − 1 টি key যায় একটি নতুন node-এ। Parent-এ একটি key আর একটি সন্তান বাড়ে। মাঝের key-টি দুই নতুন node-এর মাঝে নিখুঁত separator, তাই search tree-র নিয়ম ঠিক থাকে।
ঝামেলা হলো, parent-ও যদি ভরা থাকে, তাহলে median ঢুকবে কোথায়? তাকেও ভাগ করতে হবে, তার parent-কেও হয়তো — ভাঙন ওপরের দিকে চেইনের মতো ছড়াতে পারে। দুটি সমাধান:
- নিচ থেকে ওপরে (bottom-up): আগে leaf-এ ঢোকান, উপচে পড়লে split করে median ওপরে পাঠান, দরকার হলে বারবার। এতে নামার পথ মনে রাখতে হয় (stack বা parent pointer).
- ওপর থেকে নিচে (proactive বা top-down splitting, CLRS): নামার পথে যে ভরা node-ই পান, ঢোকার আগেই সেটি ভাগ করে ফেলুন — আপনার এই key সেখানে যাক বা না যাক। তাহলে যখনই কোনো node ভাগ করবেন, তার parent নিশ্চিতভাবে ভরা নয় (তাকে আগের ধাপেই ভাগ করা হয়েছে), তাই median-এর জায়গা সবসময় আছে। এক যাত্রায় নিচে নামা, আর কখনো ওপরে ফিরতে হয় না।
Pseudocode — B-tree insert with proactive splitting (CLRS style, 0-based keys)
splitChild(p, i): // p is NOT full; y = p.child[i] has 2t-1 keys
z = new node with y.key[t .. 2t-2] and, if y is internal, y.child[t .. 2t-1]
insert y.key[t-1] (the median) into p at position i
p.child[i+1] = z
y keeps only y.key[0 .. t-2] (and y.child[0 .. t-1])
DISK-WRITE(y); DISK-WRITE(z); DISK-WRITE(p)
insert(x):
if root has 2t-1 keys: // the ONLY way the tree gets taller
s = new node with child[0] = root; root = s; splitChild(s, 0)
v = root
while v is not a leaf:
i = number of keys in v that are <= x
if v.child[i] has 2t-1 keys:
splitChild(v, i)
if x > v.key[i]: i = i + 1 // x belongs to the new right half
v = v.child[i]
put x into leaf v at its sorted position; DISK-WRITE(v)এবার একটি খালি t = 2-এর tree-তে (প্রতিটি node-এ ১ থেকে ৩টি key) পরপর ঢোকাই 50, 20, 80, 10, 30, 60, 90, 40, 70, 25. প্রতিটি ধাপ নিচের প্রোগ্রামের আউটপুটের সাথে মেলে:
| ঢোকানো key | |
| এই ধাপের split | |
| উচ্চতা h | |
| মোট key |
- প্রথম তিনটি. 50, 20, 80 একটিমাত্র node-এ — root-ই leaf. সাজানো ক্রমে
[20 50 80]. এখন এটি ভরা: 2t − 1 = 3টি key. - Insert 10 — root ভরা, আগে split. Root ভরা, তাই কিছু ঢোকানোর আগেই একটি নতুন খালি root বানিয়ে পুরোনোটিকে ভাগ করা হলো: median 50 উঠল নতুন root-এ,
[20]আর[80]হলো তার দুই সন্তান। উচ্চতা ০ থেকে ১ — tree লম্বা হলো ওপরের দিকে, তাই দুই leaf এখনো একই গভীরতায়। - 10 ঢুকল. Root
[50]-এ 10 < 50 → সন্তান c[0] =[20], যা ভরা নয়, আর এটি leaf. সাজানো জায়গায়:[10 20]. - 30, 60, 90 — কোনো split নেই. 30 যায় বাঁ leaf-এ →
[10 20 30]; 60 আর 90 যায় ডানে →[60 80 90]. পথে কোনো ভরা node পড়েনি। এখন দুটি leaf-ই ভরা। - Insert 40 — নামার পথে split. Root
[50]-এ 40 < 50 → সন্তান[10 20 30], যা ভরা — তাই ঢোকার আগেই split: median 20 উঠল root-এ →[20 50], সন্তান[10]আর[30]. এখন 40 > 20, তাই ডানের নতুন অর্ধেক[30]-এ →[30 40]. - Insert 70 — আরেকটি split. Root
[20 50]ভরা নয়। 70 > 50 → সন্তান[60 80 90], ভরা — split: median 80 উঠল → root[20 50 80], সন্তান[60]আর[90]. 70 < 80, তাই[60]-এ →[60 70]. Root এখন আবার ভরা। - Insert 25 — আগেভাগে root split. Root
[20 50 80]ভরা, তাই ভাগ: নতুন root[50], সন্তান[20]আর[80], নাতি-নাতনিরা ভাগ হয়ে গেল। লক্ষ করুন, 25 যে leaf-এ যাবে সেই[30 40]-এ জায়গা ছিল — তবু split হলো। এটিই proactive splitting-এর দাম: কখনো কখনো “অদরকারি” split. - 25 ঢুকল.
[50]: 25 < 50 →[20]: 25 > 20 → leaf[30 40]→[25 30 40]. চূড়ান্ত tree-র উচ্চতা ২, ১০টি key, আর সব leaf গভীরতা ২-এ।
কেন সব leaf সবসময় একই গভীরতায় থাকে — invariant. একটি split একটি node-কে একই স্তরের দুটি node বানায়; কোনো leaf-এর গভীরতা বদলায় না। গভীরতা বদলায় একমাত্র root split-এ: তখন নতুন root সবার ওপরে বসে, আর প্রতিটি leaf-এর গভীরতা একসাথে ১ বাড়ে। BST নিচে leaf ঝুলিয়ে বাড়ে, তাই একদিকে লম্বা হয়ে যেতে পারে; B-tree বাড়ে মাথার দিক থেকে, তাই কখনো হেলে পড়ে না। আর key-সংখ্যার নিয়ম: split-এ 2t − 1 টি key থেকে দুটি node পায় ঠিক t − 1 টি করে — সর্বনিম্ন সীমার মধ্যে; parent-এ একটি key বাড়ে, কিন্তু proactive নিয়মে parent আগে থেকেই ভরা ছিল না, তাই সর্বোচ্চ সীমাও ঠিক থাকে। এটি পর্ব ০৩-এর loop invariant-এর মতো যুক্তি: “বর্তমান node v কখনো ভরা নয়” — শুরুতে root-এর বিশেষ split এটি সত্য করে, আর প্রতিটি নামার আগে সন্তানের split এটি সত্য রাখে।
খরচ. প্রতি স্তরে বড়জোর একটি split, আর প্রতিটি split-এ ৩টি node লেখা — তাই disk I/O O(h) = O(logt n). CPU: প্রতিটি split-এ প্রায় t টি key সরানো, প্রতিটি node-এ সঠিক সন্তান খোঁজা O(t) — মোট O(t logt n). বাড়তি মেমরি: একই সময়ে মাত্র কয়েকটি node, O(t).
#include <algorithm>
#include <deque>
#include <initializer_list>
#include <iostream>
#include <string>
#include <vector>
using namespace std;
const int T = 2; // minimum degree: every node holds 1..3 keys
struct Node { vector<int> k; vector<Node*> c; }; // keys, children (empty = leaf)
deque<Node> pool; // owns all nodes; pointers stay valid
Node* root = &pool.emplace_back();
void split(Node* p, int i) { // p->c[i] is full (2T-1 keys)
Node* y = p->c[i]; Node* z = &pool.emplace_back();
z->k.assign(y->k.begin() + T, y->k.end()); // right half moves to a new node
if (!y->c.empty()) { z->c.assign(y->c.begin() + T, y->c.end()); y->c.resize(T); }
p->k.insert(p->k.begin() + i, y->k[T - 1]); // the median goes up into the parent
p->c.insert(p->c.begin() + i + 1, z);
y->k.resize(T - 1);
}
void insert(int x) {
if ((int)root->k.size() == 2 * T - 1) { // full root: the tree grows by one level
Node* s = &pool.emplace_back(); s->c.push_back(root); root = s; split(s, 0);
}
Node* v = root;
while (!v->c.empty()) { // go down, splitting a full child first
int i = upper_bound(v->k.begin(), v->k.end(), x) - v->k.begin();
if ((int)v->c[i]->k.size() == 2 * T - 1) { split(v, i); if (x > v->k[i]) ++i; }
v = v->c[i];
}
v->k.insert(upper_bound(v->k.begin(), v->k.end(), x), x);
}
pair<bool, int> search(int x) { // (found, nodes visited)
Node* v = root;
for (int visits = 1; ; ++visits) {
auto it = lower_bound(v->k.begin(), v->k.end(), x);
if (it != v->k.end() && *it == x) return {true, visits};
if (v->c.empty()) return {false, visits};
v = v->c[it - v->k.begin()];
}
}
string show() { // level by level, levels separated by |
string out; vector<Node*> lvl{root};
while (!lvl.empty()) {
vector<Node*> nxt;
for (size_t n = 0; n < lvl.size(); ++n) {
out += n ? " [" : "[";
for (size_t j = 0; j < lvl[n]->k.size(); ++j) out += (j ? " " : "") + to_string(lvl[n]->k[j]);
out += "]";
nxt.insert(nxt.end(), lvl[n]->c.begin(), lvl[n]->c.end());
}
lvl = nxt;
if (!lvl.empty()) out += " | ";
}
return out;
}
int main() {
for (int x : {50, 20, 80, 10, 30, 60, 90, 40, 70, 25}) { insert(x); cout << "insert " << x << ": " << show() << '\n'; }
for (int x : {70, 35}) {
auto [found, visits] = search(x);
cout << "search " << x << ": " << (found ? "found" : "not found") << ", nodes visited = " << visits << '\n';
}
}আউটপুট (প্রতিটি স্তর | দিয়ে আলাদা, প্রতিটি স্তরে বাঁ থেকে ডানে node):
insert 50: [50]
insert 20: [20 50]
insert 80: [20 50 80]
insert 10: [50] | [10 20] [80]
insert 30: [50] | [10 20 30] [80]
insert 60: [50] | [10 20 30] [60 80]
insert 90: [50] | [10 20 30] [60 80 90]
insert 40: [20 50] | [10] [30 40] [60 80 90]
insert 70: [20 50 80] | [10] [30 40] [60 70] [90]
insert 25: [50] | [20] [80] | [10] [25 30 40] [60 70] [90]
search 70: found, nodes visited = 3
search 35: not found, nodes visited = 3
প্রতিটি লাইন ডায়াগ্রামের একটি ধাপ। split()-এ তিনটি কাজ লক্ষ করুন: ডানের অর্ধেক (key আর, internal হলে, সন্তান) নতুন node z-এ; median y->k[T - 1] parent-এ index i-তে; আর z parent-এর সন্তান i + 1. Node-গুলো একটি deque-এ রাখা, কারণ deque-এর শেষে যোগ করলে আগের উপাদানের ঠিকানা বদলায় না — তাই pointer নিরাপদ, আর প্রোগ্রাম শেষে সব মেমরি নিজেই ফেরত যায়। upper_bound দেয় “x-এর চেয়ে বড় প্রথম key”-এর জায়গা, অর্থাৎ ≤ x key-এর সংখ্যা — ঠিক যে সন্তানে নামতে হবে। শেষ দুই লাইন: ১০টি key-এর tree-তে খুঁজতে ৩টি node — উচ্চতা ২ + ১; পাওয়া যাক বা না যাক, পথ root থেকে leaf.
from bisect import bisect_left, bisect_right
T = 2 # minimum degree: every node holds 1..3 keys
class Node:
def __init__(self, keys=None, kids=None):
self.k, self.c = keys or [], kids or [] # keys, children (empty = leaf)
def split(p, i): # p.c[i] is full (2T-1 keys)
y = p.c[i]
z = Node(y.k[T:], y.c[T:]) # right half moves to a new node
p.k.insert(i, y.k[T - 1]) # the median goes up into the parent
p.c.insert(i + 1, z)
y.k, y.c = y.k[:T - 1], y.c[:T]
def insert(x):
global root
if len(root.k) == 2 * T - 1: # full root: the tree grows by one level
root = Node([], [root])
split(root, 0)
v = root
while v.c: # go down, splitting a full child first
i = bisect_right(v.k, x)
if len(v.c[i].k) == 2 * T - 1:
split(v, i)
if x > v.k[i]: i += 1
v = v.c[i]
v.k.insert(bisect_right(v.k, x), x)
def search(x): # (found, nodes visited)
v, visits = root, 1
while True:
i = bisect_left(v.k, x)
if i < len(v.k) and v.k[i] == x: return True, visits
if not v.c: return False, visits
v, visits = v.c[i], visits + 1
def show(): # level by level, levels separated by |
lvl, out = [root], []
while lvl:
out.append(" ".join("[" + " ".join(map(str, v.k)) + "]" for v in lvl))
lvl = [c for v in lvl for c in v.c]
return " | ".join(out)
root = Node()
for x in [50, 20, 80, 10, 30, 60, 90, 40, 70, 25]:
insert(x)
print(f"insert {x}: {show()}")
for x in [70, 35]:
found, visits = search(x)
print(f"search {x}: {'found' if found else 'not found'}, nodes visited = {visits}")আউটপুট C++-এর সাথে হুবহু এক:
insert 50: [50]
insert 20: [20 50]
insert 80: [20 50 80]
insert 10: [50] | [10 20] [80]
insert 30: [50] | [10 20 30] [80]
insert 60: [50] | [10 20 30] [60 80]
insert 90: [50] | [10 20 30] [60 80 90]
insert 40: [20 50] | [10] [30 40] [60 80 90]
insert 70: [20 50 80] | [10] [30 40] [60 70] [90]
insert 25: [50] | [20] [80] | [10] [25 30 40] [60 70] [90]
search 70: found, nodes visited = 3
search 35: not found, nodes visited = 3
Python-এ bisect_right(v.k, x) হলো C++-এর upper_bound, আর bisect_left হলো lower_bound — দুটোই node-এর ভেতরে binary search. split()-এ slicing কাজটি খুব পরিষ্কার করে: y.k[T:] ডানের অর্ধেক, y.k[T - 1] median, y.k[:T - 1] বাঁয়ের অর্ধেক। Leaf-এর c খালি list, তাই y.c[T:] আর y.c[:T] leaf-এর জন্যও নিরাপদে খালি list দেয় — আলাদা শাখা লাগে না। show() পর্ব ৪০-এর BFS-এর মতো স্তর ধরে tree ছাপায়: প্রতিটি স্তরের সব সন্তান মিলে পরের স্তর।
৫ · Delete — ধার নেওয়া (borrow) আর জোড়া লাগানো (merge)
Insert-এর বিপদ ছিল node উপচে পড়া; delete-এর বিপদ উল্টো — কোনো node-এ key t − 1-এর নিচে নেমে যাওয়া (underflow). সমাধানের দুটি হাতিয়ার:
- Borrow (ধার, rotation বলেও): কম key-ওয়ালা node-এর পাশের ভাইয়ের অন্তত t টি key থাকলে, parent-এর মাঝের key নামিয়ে আনুন এই node-এ, আর ভাইয়ের কিনারার key-টি তুলে দিন parent-এ। পাশের বাসার কাছ থেকে চিনি ধার নেওয়ার মতো — কিন্তু সরাসরি নয়, বাড়িওয়ালার (parent) মাধ্যমে, যাতে সাজানো ক্রম না ভাঙে।
- Merge (জোড়া): দুই ভাইয়েরই মাত্র t − 1 টি key থাকলে, দুজনকে আর parent-এর মাঝের key-টিকে মিলিয়ে একটি node বানান: (t − 1) + 1 + (t − 1) = 2t − 1 টি key — ঠিক একটি ভরা node. এটি split-এর ঠিক উল্টো কাজ। Parent-এ একটি key কমে।
Insert-এর মতোই এখানেও proactive কৌশল আছে (CLRS): নামার পথে কোনো সন্তানে ঢোকার আগে নিশ্চিত করুন তার অন্তত t টি key আছে — না থাকলে আগেই borrow বা merge. তাহলে শেষে কোনো leaf থেকে একটি key মুছলেও সে t − 1-এর নিচে নামবে না। Merge-এ root-এর শেষ key নেমে গেলে root খালি হয়, আর তার একমাত্র সন্তান নতুন root হয় — tree বেঁটে হয় শুধু ওপর থেকে, ঠিক যেমন লম্বা হয় শুধু ওপর থেকে। নিচে আগের অংশের চূড়ান্ত tree থেকে প্রথমে 90, তারপর 50 মোছা দেখুন:
| কাজ | |
| কারণ | |
| উচ্চতা h |
- শুরু: delete(90). Root
[50]-এ 90 নেই; 90 > 50, তাই নামতে হবে[80]-এ। কিন্তু[80]-এ মাত্র t − 1 = ১টি key — সেখানে নেমে নিচে থেকে কিছু মুছলে ঝুঁকি। তাই নামার আগেই একে মোটা করতে হবে। - Merge, আর tree বেঁটে হলো. একমাত্র ভাই
[20]-এরও ১টি key, ধার দেওয়ার মতো নেই। তাই[20]+ parent-এর 50 +[80]জুড়ে[20 50 80], চারটি নাতি-নাতনি এখন তার সন্তান। Root-এর একমাত্র key নেমে গেল, root খালি — তাই নতুন root এই জোড়া node. উচ্চতা ২ থেকে ১। - Borrow. এখন
[20 50 80]-এ 90 নেই; 90 > 80 → সন্তান[90], যাতে ১টি key. বাঁ ভাই[60 70]-এ ২ = t টি key — ধার সম্ভব: parent-এর 80 নামল[90]-এ →[80 90], ভাইয়ের সবচেয়ে বড় 70 উঠল parent-এ →[20 50 70], ভাই হলো[60]. ক্রম ঠিক আছে: 60 < 70 < 80 < 90. - Leaf থেকে মোছা.
[80 90]একটি leaf, তাতে 90 আছে — শুধু সরিয়ে দিন:[80]. নিয়ম ভাঙেনি, কারণ আগের ধাপেই এখানে ২টি key নিশ্চিত করা হয়েছিল। delete(90) শেষ। - delete(50) — internal node থেকে. 50 আছে root-এ, internal node-এ — সরাসরি তোলা যায় না, তাহলে দুই সন্তানের মাঝে separator থাকবে না। তার বাঁ সন্তান
[25 30 40]-এ ৩ ≥ t টি key, তাই 50-এর জায়গায় বসান তার in-order predecessor 40 (বাঁ subtree-র সবচেয়ে বড় key), আর 40-কে মুছুন সেই সন্তান থেকে:[20 40 70], সন্তান[25 30]. সব নিয়ম ঠিক।
খরচ. প্রতি স্তরে বড়জোর একটি borrow বা merge, প্রতিটিতে ধ্রুবক সংখ্যক node পড়া-লেখা, তাই delete-ও O(logt n) disk I/O, CPU O(t logt n). Delete-এর কোড insert-এর প্রায় দ্বিগুণ লম্বা, আর ভুলের সুযোগ অনেক — তাই সব কেসের বিস্তারিত নিচের বাক্সে রাখলাম; প্রথম পড়ায় borrow আর merge-এর ধারণা বুঝলেই যথেষ্ট।
আরও গভীরে — CLRS-এর delete-এর সব কেস: delete(v, x) ডাকা হয় শুধু তখন, যখন v-এ অন্তত t টি key (root ছাড়া) — এটিই invariant.
- কেস ১: x আছে v-এ আর v leaf — সরাসরি মুছুন।
- কেস ২: x আছে internal node v-এ। (ক) x-এর আগের সন্তান y-তে অন্তত t টি key থাকলে x-এর বদলে বসান y-এর subtree-র predecessor x′, আর recursion-এ y থেকে x′ মুছুন। (খ) না হলে, পরের সন্তান z-এ অন্তত t টি key থাকলে একইভাবে successor দিয়ে। (গ) দুজনেরই t − 1 টি হলে y + x + z merge করে 2t − 1 key-এর একটি node বানান, তারপর সেখান থেকে x মুছুন।
- কেস ৩: x v-এ নেই; যে সন্তান c-তে নামতে হবে তার মাত্র t − 1 টি key. (ক) পাশের কোনো ভাইয়ের অন্তত t টি key থাকলে borrow — parent-এর key c-তে নামে, ভাইয়ের কিনারার key parent-এ ওঠে, আর ভাইয়ের কিনারার সন্তান pointer-টিও c-তে চলে আসে। (খ) দুই ভাইয়েরই t − 1 টি হলে c-কে একটি ভাই আর parent-এর মাঝের key-সহ merge করুন।
- Merge-এর পর root-এ ০টি key থাকলে root মুছে তার একমাত্র সন্তানকে root করুন — tree-র উচ্চতা ১ কমে।
বাস্তবের অনেক ডেটাবেস আরও সহজ পথ নেয়: underflow হলেও সাথে সাথে merge করে না, বরং page খালি হলে তবে সরায়, বা পরে একসাথে পুনর্গঠন করে — কারণ মোছার পর প্রায়ই আবার insert আসে, আর ঘনঘন merge-split ডিস্কে অপচয়।
৬ · B+ tree — সব record leaf-এ, leaf-গুলো শিকলে বাঁধা
সাধারণ B-tree-তে প্রতিটি key-এর সাথে তার পুরো record (বা record-এর ঠিকানা) থাকে, যেকোনো node-এ। ডেটাবেস এর একটি রূপ বেশি পছন্দ করে — B+ tree. পার্থক্য তিনটি:
- সব record শুধু leaf-এ. Internal node-এ থাকে শুধু separator key আর সন্তানের pointer — যেন একটি বইয়ের সূচিপত্র, যেখানে পুরো অধ্যায় নেই, শুধু পাতার নম্বর।
- Internal node-এর key-গুলো leaf-এর key-এর কপি. একটি key leaf-এ থাকে, আর হয়তো কোনো internal node-এ separator হিসেবে আবারও। এই পর্বের রীতি: separator s হলে বাঁ subtree-তে সব key < s, ডানে সব key ≥ s.
- Leaf-গুলো পাশাপাশি pointer দিয়ে জোড়া (প্রায়ই দুই দিকেই) — সাজানো ক্রমে একটি লম্বা শিকল, ঠিক পর্ব ১২-এর linked list-এর মতো।
এর লাভ কী? Internal node-এ record না থাকায় একটি page-এ অনেক বেশি key ধরে — fan-out বাড়ে, উচ্চতা কমে। প্রতিটি খোঁজা leaf পর্যন্ত যায়, তাই খরচ সবসময় সমান আর অনুমানযোগ্য। আর সবচেয়ে বড় লাভ range query-তে: “২০২৪ সালের জানুয়ারির সব লেনদেন” বা WHERE price BETWEEN 22 AND 62. প্রথম key-এর leaf খুঁজে নিন, তারপর শুধু শিকল ধরে ডানে হাঁটুন — ওপরে ওঠানামা নেই।
| এখন পড়া page | |
| সিদ্ধান্ত | |
| ফল | |
| page read (মোট) |
- প্রশ্ন. 22 ≤ key ≤ 62 — এমন সব key সাজানো ক্রমে চাই। ১৫টি key-এর B+ tree, সব key leaf-এ; ওপরের দুই স্তর শুধু পথ দেখায়।
- Root. প্রথম read:
[45]. 22 < 45, তাই বাঁ সন্তান। (45 নিজে আছে ডান দিকের leaf-এ — রীতি “ডানে ≥ s”.) - Internal page. দ্বিতীয় read:
[20 30]. 20 ≤ 22 < 30, তাই মাঝের সন্তান — যে leaf-এ 22 থাকার কথা। এখান থেকে আর কোনো internal page লাগবে না। - প্রথম leaf. তৃতীয় read:
(20 25). 20 < 22 বাদ, 25 নিলাম। leaf শেষ — ওপরে না উঠেnextpointer ধরে পাশের leaf-এ। - শিকল ধরে. চতুর্থ read:
(30 35 40)— তিনটিই রেঞ্জের ভেতরে, সব নিলাম। লক্ষ করুন, এই leaf-এ আসতে parent [20 30]-এ ফিরে যেতে হয়নি; সাধারণ B-tree হলে পরের key খুঁজতে আবার ওপরে উঠতে হতো। - Subtree পেরিয়ে. পঞ্চম read:
(45 50)— এটি root-এর ডান subtree-র leaf, তবু শিকল সরাসরি এখানে এনেছে। দুটিই নিলাম। - থামা. ষষ্ঠ read:
(60 65 70). 60 নিলাম; 65 > 62 — leaf সাজানো, তাই এর পরে আর কিছু রেঞ্জে নেই, থামলাম। ফল ৭টি key: 25 30 35 40 45 50 60. মোট read = ২টি internal + ৪টি leaf.
খরচ. K টি ফলাফলের range query-তে প্রথম leaf পর্যন্ত O(logB N) read, তারপর প্রতিটি leaf-এ প্রায় B টি key, তাই আরও O(K/B + 1) read — মোট O(logB N + K/B) I/O. একটি key-এর জন্য একটি read নয়, এক page ভর্তি key-এর জন্য একটি read. আর leaf-গুলো যদি ডিস্কে পাশাপাশি রাখা থাকে, শিকল ধরে হাঁটা প্রায় sequential read — hard disk-এ এটি এলোমেলো read-এর চেয়ে বহু গুণ দ্রুত।
| বিষয় | B-tree | B+ tree |
|---|---|---|
| record কোথায় | যেকোনো node-এ, key-এর সাথে | শুধু leaf-এ |
| internal node-এ | key + record (বা তার ঠিকানা) | শুধু separator key আর pointer |
| fan-out | কম — record জায়গা নেয় | বেশি, তাই উচ্চতা কম |
| একটি key খোঁজা | কখনো internal node-এই থামে | সবসময় leaf পর্যন্ত — খরচ সমান |
| রেঞ্জ বা পুরো scan | in-order traversal, ওপর-নিচ ওঠানামা | leaf-এর শিকল ধরে সোজা হাঁটা |
| key-এর পুনরাবৃত্তি | প্রতিটি key একবারই | separator key leaf-এও থাকে |
৭ · Fan-out আর উচ্চতার হিসাব — ডেটাবেস কেন ৩–৪ ধাপে খোঁজে
B+ tree-র আসল শক্তি বোঝা যায় সংখ্যায়। ধরুন প্রতিটি node (একটি page) f টি entry ধরে — internal node-এ f টি সন্তান, leaf-এ f টি record. তাহলে ১ স্তরে f টি, ২ স্তরে f² টি, L স্তরে fL টি record. f = ৫০০ হলে ৩ স্তরে 500³ = 1.25 × 10⁸ — প্রায় সাড়ে বারো কোটি record! নিচের ছোট প্রোগ্রামটি কয়েকটি fan-out-এর জন্য দেখায়, n টি record-এর জন্য কয় স্তর লাগে (সবচেয়ে ছোট L, যাতে fL ≥ n):
#include <cstdio>
#include <initializer_list>
// Levels L needed so that f^L >= n: each node (one disk page) holds f entries.
int levels(unsigned long long f, unsigned long long n) {
int L = 0;
for (unsigned long long cap = 1; cap < n; cap *= f) ++L;
return L;
}
int main() {
std::printf("fan-out | n=10^6 | n=10^9 | n=10^12 | entries in 3 levels\n");
for (unsigned long long f : {2ULL, 16ULL, 100ULL, 500ULL, 1000ULL}) {
std::printf("%7llu | %6d | %6d | %7d | %llu\n", f, levels(f, 1000000ULL),
levels(f, 1000000000ULL), levels(f, 1000000000000ULL), f * f * f);
}
// a lookup reads one page per level; with the top 2 levels cached in RAM:
int L = levels(500, 1000000000ULL);
std::printf("fan-out 500, n=10^9: %d levels, %d disk reads if top 2 levels are cached\n", L, L - 2);
}আউটপুট:
fan-out | n=10^6 | n=10^9 | n=10^12 | entries in 3 levels
2 | 20 | 30 | 40 | 8
16 | 5 | 8 | 10 | 4096
100 | 3 | 5 | 6 | 1000000
500 | 3 | 4 | 5 | 125000000
1000 | 2 | 3 | 4 | 1000000000
fan-out 500, n=10^9: 4 levels, 2 disk reads if top 2 levels are cached
প্রথম সারি binary tree: একশো কোটিতে ৩০ স্তর। fan-out ৫০০-তে একই ডেটা ৪ স্তরে, আর ১০০০-এ মাত্র ৩-এ। লক্ষ করুন, n এক হাজার গুণ বাড়লে (10⁶ → 10⁹) binary tree-তে স্তর বাড়ে ১০টি, কিন্তু fan-out ১০০০-এ মাত্র ১টি — log-এর ভিত্তি বড় হওয়ার জাদু। levels()-এ ভাসমান বিন্দুর log() ব্যবহার করিনি, কারণ 10⁶-এর মতো ঠিক ঘাতের কাছে rounding ভুল দিতে পারে; পূর্ণসংখ্যায় গুণ করে গোনা নিরাপদ। শেষ লাইনটি বাস্তবের হিসাব: ওপরের দুই স্তর মাত্র 1 + 500 টি page — RAM-এ সহজেই থাকে — তাই একশো কোটি record-এর মধ্যে যেকোনোটি খুঁজতে মাত্র ২টি disk read.
ডেটাবেস আর file system-এ B-tree পরিবার
এই কারণেই প্রায় প্রতিটি relational ডেটাবেসের ডিফল্ট index B-tree পরিবারের। নামগুলো সাবধানে বলি — প্রতিটি সিস্টেম নিজের মতো করে রূপ বদলেছে, আর documentation-এ প্রায়ই সবকিছুকেই সাধারণভাবে “B-tree” বলা হয়:
MySQL InnoDB
টেবিল নিজেই primary key-এর ক্রমে সাজানো একটি B+ tree — leaf-এ পুরো row থাকে। একে বলে clustered index. Secondary index আলাদা B+ tree, যার leaf-এ থাকে primary key-এর মান। ডিফল্ট page 16 KB.
primary key দিয়ে রেঞ্জ খোঁজা খুব দ্রুতPostgreSQL
CREATE INDEX-এর ডিফল্ট ধরন btree, যা Lehman ও Yao-র একসাথে অনেক ব্যবহারকারীর জন্য উপযোগী B+ tree-র রূপের ওপর ভিত্তি করে বানানো। টেবিলের row আলাদা “heap” ফাইলে, index-এর leaf-এ তাদের ঠিকানা। ডিফল্ট page 8 KB.
SQLite
পুরো ডেটাবেস ফাইলটিই কয়েকটি B-tree-র সমষ্টি: প্রতিটি টেবিল একটি “table b-tree” (B+ tree-ধাঁচের, ডেটা leaf-এ), আর প্রতিটি index একটি “index b-tree”. ডিফল্ট page 4096 byte. ফোনের বহু অ্যাপের ভেতরে এটিই চলে।
একটি ফাইল, ভেতরে অনেক treeFile system
বড় file system-এ হাজার হাজার ফাইলের ফোল্ডারে নাম খুঁজতেও একই সমস্যা। NTFS ফোল্ডারের index রাখে B-tree পরিবারের কাঠামোয়; ext4 বড় ফোল্ডারে “htree” নামে নামের hash-ভিত্তিক, অল্প গভীরতার একটি B-tree-সদৃশ index ব্যবহার করে; Btrfs আর Apple-এর APFS-এর metadata-ও B-tree-তে সাজানো।
Btrfs নামটিই “B-tree file system” থেকেপেছনের কারণ একটাই: এই সিস্টেমগুলোকে কোটি কোটি সাজানো key রাখতে হয়, যার বেশিরভাগ ডিস্কে; খোঁজা, insert, delete আর রেঞ্জ scan সবই দ্রুত চাই; আর একটি ভাঙা লেখা (যেমন লেখার মাঝপথে বিদ্যুৎ চলে যাওয়া) যেন পুরো কাঠামো নষ্ট না করে। Hash table (পর্ব ৩২) একটি key খুঁজতে আরও দ্রুত হতে পারে, কিন্তু ক্রম রাখে না — রেঞ্জ, ORDER BY বা “এই prefix দিয়ে শুরু” প্রশ্নে অচল। বাংলাদেশের কোনো ব্যাংক যখন আপনার গত তিন মাসের statement দেখায়, পেছনে খুব সম্ভবত ঠিক এই range scan-টিই চলে।
৮ · পরিবারের অন্যরা — 2-3 tree, red-black আর B* tree
2-3 tree হলো সবচেয়ে ছোট B-tree: প্রতিটি internal node-এ ১টি key আর ২টি সন্তান (“2-node”), অথবা ২টি key আর ৩টি সন্তান (“3-node”), আর সব leaf একই গভীরতায়। Knuth-এর রীতিতে order 3. John Hopcroft ১৯৭০ সালে এই ধারণা দেন — B-tree-র প্রকাশের প্রায় একই সময়ে, কিন্তু আলাদাভাবে এবং মেমরির ভেতরের ভারসাম্যপূর্ণ tree হিসেবে। Insert-এ কোনো 3-node-এ তৃতীয় key এলে সে মাঝেরটিকে ওপরে পাঠিয়ে ভাগ হয় — ঠিক B-tree-র split.
এক ধাপ বড় হলো t = 2-এর B-tree, অর্থাৎ 2-3-4 tree — যার ওপর অংশ ৪-এ insert চালালেন। এর সাথে পর্ব ২৯-এর red-black tree-এর সম্পর্ক অবাক করার মতো: প্রতিটি red-black tree আসলে একটি 2-3-4 tree-র binary ছদ্মবেশ. 2-3-4 tree-র প্রতিটি node-কে ১–৩টি binary node-এ ভাঙুন: মাঝের key কালো, বাকিরা তার লাল সন্তান।
2-3-4 node red-black রূপ (R = লাল, B = কালো)
[50] → 50B
[20 50] → 50B (অথবা 20B, ডানে 50R)
/
20R
[20 50 80] → 50B
/ \
20R 80R
এই চোখে দেখলে red-black-এর নিয়মগুলো আর মুখস্থ করতে হয় না। “লাল node-এর সন্তান লাল নয়” মানে একটি B-tree node-এ ৩টির বেশি key নেই। “root থেকে প্রতিটি leaf-এ কালো node-এর সংখ্যা সমান” মানে B-tree-র সব leaf একই গভীরতায়. Red-black-এ color flip হলো 2-3-4 tree-র 4-node split, আর rotation হলো একটি B-tree node-এর ভেতরে key-গুলোর সাজ বদলানো। আর উচ্চতা: B-tree-র উচ্চতা log n-এর কাছাকাছি, প্রতিটি স্তরে বড়জোর একটি লাল node যোগ হয় — তাই red-black-এর উচ্চতা ≤ 2 log₂(n + 1). ২০০৮ সালে Robert Sedgewick 2-3 tree-র সাথে ঠিক এক-এক মিলের একটি সরল রূপ দেন — left-leaning red-black tree.
B* tree (Knuth ১৯৭৩ সালে বর্ণনা করেন) জায়গা আরও ভালোভাবে ভরতে চায়। সাধারণ B-tree-তে node উপচালেই ভাগ হয়, ফলে দুটি প্রায়-অর্ধেক node. B*-তে উপচে পড়লে আগে পাশের ভাইয়ের সাথে key ভাগাভাগি করে নেয়; ভাইও ভরা হলে দুটি ভরা node মিলে তিনটি node হয় (২ থেকে ৩ split). ফল: root ছাড়া প্রতিটি node অন্তত প্রায় দুই-তৃতীয়াংশ ভরা, তাই একই ডেটায় node কম, fan-out বেশি, উচ্চতা কখনো কখনো এক স্তর কম। দাম: কোড আরও জটিল, প্রতিটি insert-এ ভাইয়ের page-ও পড়তে-লিখতে হতে পারে।
১৯৭০ সালে Boeing Scientific Research Laboratories-এ কর্মরত Rudolf Bayer আর Edward M. McCreight একটি সমস্যা নিয়ে কাজ করছিলেন: ধীর ডিস্কে রাখা বিশাল index কীভাবে দ্রুত খোঁজা আর হালনাগাদ রাখা যায়। তাঁদের প্রবন্ধ “Organization and Maintenance of Large Ordered Indices” ১৯৭০ সালে একটি ACM workshop-এ উপস্থাপিত হয় আর ১৯৭২ সালে Acta Informatica পত্রিকায় ছাপা হয়। “B” অক্ষরের মানে লেখকেরা কখনো নিশ্চিত করে বলেননি — Boeing, balanced, broad, bushy, এমনকি Bayer — সবই প্রস্তাব করা হয়েছে। ১৯৭৯ সালে Douglas Comer তাঁর বিখ্যাত সমীক্ষা “The Ubiquitous B-Tree”-তে (“সর্বব্যাপী B-tree”) লেখেন যে ততদিনে B-tree ডেটাবেসের index-এর কার্যত মানক হয়ে উঠেছে; সেখানে B+ tree-র রূপটিও বিস্তারিত আলোচিত হয়।
পরীক্ষা ও ইন্টারভিউতে:
- minimum degree t-এর B-tree-র একটি node-এ কয়টি key থাকে? — root ছাড়া t − 1 থেকে 2t − 1; root-এ ১ থেকে 2t − 1; internal node-এর সন্তান key-সংখ্যার চেয়ে ১ বেশি।
- n টি key-এর B-tree-র উচ্চতা কত হতে পারে? — h ≤ logt((n + 1)/2); প্রমাণ: গভীরতা i-তে অন্তত 2ti−1 টি node, প্রতিটিতে অন্তত t − 1 টি key, যোগ করলে n ≥ 2th − 1.
- ভরা node-এ insert করলে কী হয়, আর tree কখন লম্বা হয়? — median parent-এ ওঠে, দুই পাশে t − 1 টি করে key; tree লম্বা হয় শুধু root split-এ, তাই সব leaf একই গভীরতায় থাকে।
- ডেটাবেস index-এ BST বা hash table নয়, B+ tree কেন? — উচ্চতা logB n বলে disk read খুব কম; ক্রম রাখে বলে রেঞ্জ আর
ORDER BYচলে; leaf-এর শিকলে range scan O(logB N + K/B) I/O. - B-tree আর B+ tree-র পার্থক্য? — B+-এ record শুধু leaf-এ, internal node শুধু index, leaf-গুলো জোড়া; তাই fan-out বেশি আর রেঞ্জ scan সহজ।
৯ · AI-এর যুগে B-tree — সংজ্ঞা গুলিয়ে ফেলা আর split-এর off-by-one
“একটি B-tree insert লিখে দাও” — AI দ্রুতই লিখবে, আর ছোট পরীক্ষায় চলবেও। B-tree-র ভুল লুকিয়ে থাকে তিন জায়গায়: সংজ্ঞায় (t নাকি order m), split-এর index-এ (median কোনটি, কোন সন্তান কোথায় যায়), আর delete-এর বিরল কেসে (internal node থেকে মোছা, root খালি হওয়া)। ছোট ইনপুটে এই শাখাগুলো প্রায়ই চলেই না, তাই ভুল ধরা পড়ে না। আর ডেটাবেস বা file system সম্পর্কে AI প্রায়ই আত্মবিশ্বাসী কিন্তু ভুল দাবি করে — নির্দিষ্ট সিস্টেমের কথা সবসময় তার অফিসিয়াল documentation-এ মিলিয়ে নিন।
B-tree-র কোড আর উত্তরে AI সাধারণত যেসব ভুল করে
| AI-এর ভুল | কীভাবে ধরবেন |
|---|---|
| t আর order m গুলিয়ে ফেলা — যেমন “order 3-এর node-এ 2t − 1 = 5 key” | প্রশ্নের সংজ্ঞা লিখে নিন; ছোট উদাহরণে সর্বোচ্চ key আর সন্তানসংখ্যা হাতে গুনুন |
| Split-এ internal node-এর সন্তানদের নতুন node-এ সরাতে ভুলে যাওয়া, বা median দুই জায়গায় রেখে দেওয়া | উচ্চতা ২ হয় এমন ইনপুট দিন (t = 2-এ অন্তত ১০টি key); প্রতিটি insert-এর পর invariant যাচাই করুন |
| Bottom-up split, কিন্তু parent-ও ভরা হলে কী হবে তা সামলায় না | বাড়তে থাকা ক্রমে (1, 2, 3, …) অনেক key দিন — এতে বারবার ডানের পথ ভরে যায় |
| “B-tree search O(log₂ n) disk read” বা “B-tree তুলনা কমায়” | disk read গুনুন উচ্চতা দিয়ে (logt n), আর তুলনা গুনুন আলাদা — মোট তুলনা প্রায় log₂ n-ই থাকে |
| Delete-এ borrow-এর সময় ভাইয়ের কিনারার সন্তান pointer না সরানো, বা root খালি হলে না সরানো | এলোমেলো insert-delete মিশিয়ে হাজারবার চালান, প্রতিবার সব leaf-এর গভীরতা মেলান |
| B+ tree বলে কোড দিল, কিন্তু record internal node-এ রাখে বা leaf-এর শিকল নেই | রেঞ্জ query-তে কয়টি internal node পড়া হলো গুনুন — প্রথম leaf-এর পর শূন্য হওয়ার কথা |
AI-কে পুরো কোড লিখে দিতে না বলে, আপনার কোড আর হাতে করা trace পরীক্ষা করতে বলুন — আর প্রতিটি দাবির যুক্তি চান:
B-tree-র কোড যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১একটি বিমান কোম্পানির গবেষণাগারে জন্ম নেওয়া B-tree কেন ৫০ বছর পরেও প্রায় প্রতিটি ডেটাবেসের ভেতরে — আর “B” মানে আসলে কী?
১৯৬০-এর দশকের শেষে কম্পিউটারের মূল মেমরি ছিল খুবই ছোট আর দামি, আর বড় ডেটা থাকত ঘূর্ণায়মান ডিস্ক বা টেপে। বিমান বানানোর মতো প্রকৌশল কাজে বিশাল পরিমাণ যন্ত্রাংশ, নকশা আর পরীক্ষার তথ্য রাখতে হতো, আর সেগুলো key দিয়ে দ্রুত খুঁজে বের করতে হতো। সেই সময়ের প্রচলিত উপায় ছিল সাজানো ফাইল আর তার ওপর স্থির index — খোঁজা দ্রুত, কিন্তু নতুন record ঢোকাতে গেলে ফাইলের বড় অংশ নতুন করে লিখতে হতো, বা “overflow” এলাকায় রেখে ধীরে ধীরে কাঠামো এলোমেলো হয়ে যেত। আবার binary tree নমনীয়, কিন্তু ডিস্কে প্রতিটি node-এ একটি ধীর read.
Boeing Scientific Research Laboratories-এ Rudolf Bayer আর Edward McCreight-এর সমাধান ছিল দুই জগতের মিলন: node-কে ডিস্কের page-এর মাপে বানানো, যাতে একটি read-এ অনেক key আসে; আর split ও merge দিয়ে কাঠামোকে স্থানীয়ভাবে মেরামত করা, যাতে কোনো insert বা delete-এ পুরো ফাইল নতুন করে লিখতে না হয়। সবচেয়ে বড় কথা, তাঁরা এর খরচ গাণিতিকভাবে বিশ্লেষণ করেছিলেন — প্রতিটি অপারেশনে page access-এর সংখ্যা logarithmic, আর প্রতিটি page অন্তত অর্ধেক ভরা। ১৯৭০ সালে উপস্থাপিত আর ১৯৭২ সালে ছাপা সেই প্রবন্ধের পর এক দশকের মধ্যে, Douglas Comer-এর ১৯৭৯ সালের সমীক্ষার শিরোনামেই বলা হলো B-tree “ubiquitous” — সর্বব্যাপী।
“B”-এর মানে নিয়ে লেখকেরা কখনো নিশ্চিত উত্তর দেননি। Boeing (যেখানে কাজটি হয়েছিল), balanced (ভারসাম্যপূর্ণ), broad বা bushy (চওড়া, ঝোপালো tree), আর Bayer (প্রথম লেখকের নাম) — সবই প্রস্তাব করা হয়েছে। McCreight পরে এক বক্তৃতায় এই প্রশ্নের জবাবে মজা করে বলেছিলেন বলে প্রচলিত যে B-এর মানে নিয়ে যত বেশি ভাববেন, B-tree তত ভালো বুঝবেন। কথাটি আসলে গভীর: প্রতিটি প্রস্তাবিত মানেই কাঠামোর একটি সত্যিকারের বৈশিষ্ট্য।
৫০ বছর টিকে থাকার কারণ হলো, এর মূল অনুমানটি — “ধীর স্টোরেজ থেকে ডেটা বড় টুকরোয় আসে, আর টুকরো আনার খরচই প্রধান” — প্রযুক্তি বদলালেও সত্য থেকে গেছে। টেপ থেকে hard disk, hard disk থেকে SSD, এমনকি RAM থেকে CPU cache — প্রতিটি স্তরেই ব্যবধান আছে, শুধু মাপ বদলায়।
মূল উপলব্ধি: B-tree কোনো একটি যন্ত্রের জন্য বানানো কৌশল নয়, বরং “block-এ ডেটা আসে” — এই সাধারণ সত্যের উত্তর। তাই যন্ত্র বদলালেও এটি টিকে আছে, আর নামের রহস্যও তার বৈশিষ্ট্যগুলোরই প্রতিধ্বনি।
প্র ০২SSD-তে random read hard disk-এর চেয়ে শতগুণ দ্রুত, আর অনেক ডেটা এখন পুরোটাই RAM-এ থাকে — তাহলে কি B-tree-র দিন শেষ?
প্রথম দেখায় মনে হয় হ্যাঁ। B-tree-র যুক্তি ছিল hard disk-এর মিলিসেকেন্ডের seek. SSD-তে সেটি নেমে এসেছে দশ থেকে একশো মাইক্রোসেকেন্ডে, আর RAM-এ একশো ন্যানোসেকেন্ডে। কিন্তু খেয়াল করুন, B-tree-র আসল অনুমান “ডিস্ক ধীর” নয় — “ডেটা আসে block-এ, আর block-এর ভেতরের byte প্রায় বিনা খরচে”. এই অনুমান প্রতিটি স্তরে সত্য।
SSD-তে: SSD-ও ডেটা পড়ে page ধরে (সাধারণত কয়েক KB). একটি ৮ byte-এর key-এর জন্য একটি পুরো page পড়ার খরচ, আর সেই page-এর শত key-ও একই খরচে। তাই এখানেও fan-out বড় রাখলে read কমে। তার ওপর SSD-তে লেখা ধীর আর প্রতিটি cell বারবার লেখায় ক্ষয় হয়, তাই লেখা কমানোর চাপ আরও বেশি — যা থেকে LSM-tree-এর মতো বিকল্পের উত্থান (পরের প্রশ্ন)।
RAM-এ: পর্ব ১০-এ দেখেছেন, CPU RAM থেকে ডেটা আনে ৬৪ byte-এর cache line ধরে, আর cache-এ না পেলে (cache miss) শত শত CPU cycle অপেক্ষা। এটি ঠিক ডিস্কের ছোট সংস্করণ! একটি red-black tree-র প্রতিটি node মেমরির এলোমেলো জায়গায়, তাই দশ লাখ key-এ প্রায় ২০টির বেশি cache miss. একটি ছোট-node-ওয়ালা B-tree, যার node কয়েকটি cache line-এর মাপে, অনেক কম miss করে। এ কারণেই Rust ভাষার standard library-র BTreeMap মেমরির ভেতরেই B-tree ব্যবহার করে (প্রতিটি node-এ সর্বোচ্চ ১১টি key), আর Google-এর Abseil লাইব্রেরিতে btree_map আছে, যা অনেক ক্ষেত্রে std::map-এর চেয়ে কম মেমরি নেয় আর দ্রুত চলে। Red-black tree-র প্রতিটি key-এর সাথে তিনটি pointer আর একটি রঙ; B-tree node-এ অনেক key পাশাপাশি, pointer কম।
তবে সব জায়গায় B-tree জেতে না। একটি key-এর হুবহু মিল খুঁজতে hash table সাধারণত দ্রুত, আর খুব ছোট ডেটায় সাজানো অ্যারে আর binary search-ই যথেষ্ট। কিন্তু “সাজানো ক্রম রাখো, রেঞ্জ দাও, আর ডেটা বড়” — এই তিন চাহিদা একসাথে এলে প্রায় সবসময় B-tree পরিবারের কিছু একটা পাবেন।
মূল উপলব্ধি: B-tree-র যুক্তি একটি নির্দিষ্ট যন্ত্রের গতির ওপর নয়, “স্তরে স্তরে memory hierarchy আর block-এ ডেটা আসা”-র ওপর দাঁড়ানো। স্তরের মাপ বদলায়, যুক্তি বদলায় না — তাই node-এর মাপ বদলে একই ধারণা ডিস্ক থেকে cache পর্যন্ত কাজ করে।
প্র ০৩RocksDB বা Cassandra-র মতো অনেক নতুন storage সিস্টেম B+ tree নয়, LSM-tree ব্যবহার করে কেন? দুটির মধ্যে আসল দর-কষাকষি কী?
B+ tree-তে প্রতিটি insert বা update ঠিক সেই leaf page-এ যায়, যেখানে key-টি থাকার কথা। ধরুন একটি page 16 KB, আর আপনি বদলালেন ১০০ byte-এর একটি row. ডিস্কে কিন্তু পুরো page আবার লিখতে হয় (আর নিরাপত্তার জন্য প্রায়ই একটি log-এও)। ব্যবহারকারী লিখলেন ১০০ byte, ডিস্কে গেল হাজার গুণেরও বেশি — একে বলে write amplification. তার ওপর এলোমেলো key-তে insert মানে ডিস্কের এলোমেলো জায়গায় লেখা, যা hard disk-এ খুব ধীর আর SSD-কেও ক্ষয় করে।
১৯৯৬ সালে Patrick O’Neil ও সহকর্মীরা LSM-tree (log-structured merge-tree) প্রস্তাব করেন। এর ধারণা: নতুন লেখা প্রথমে মেমরিতে একটি সাজানো কাঠামোয় জমান (সাথে একটি sequential log, যাতে বিদ্যুৎ গেলে হারায় না)। মেমরি ভরে গেলে পুরোটা একসাথে একটি সাজানো ফাইল হিসেবে ডিস্কে লিখে ফেলুন — একটানা, sequential লেখা, যা ডিস্কের সবচেয়ে দ্রুত কাজ। সময়ের সাথে অনেক ফাইল জমে, তখন পটভূমিতে সেগুলোকে merge sort-এর মতো জুড়ে বড় ফাইল বানান (compaction). পর্ব ২১-এর k-way merge এখানে আসল কাজটি করে।
দাম কোথায়? পড়ায়. একটি key খুঁজতে মেমরির অংশ, তারপর একাধিক ফাইল দেখতে হতে পারে — কোনটিতে সবচেয়ে নতুন মান আছে। এটি কমাতে প্রতিটি ফাইলের সাথে Bloom filter রাখা হয় (“এই key এই ফাইলে নিশ্চয়ই নেই” দ্রুত বলার জন্য)। আর compaction-ও একই ডেটা বারবার লেখে, তাই LSM-এর নিজস্ব write amplification আছে, শুধু সেটি sequential. মোটামুটি নিয়ম: লেখা-প্রধান কাজে (log, মেসেজ, সেন্সরের ডেটা, টাইমলাইন) LSM ভালো; পড়া-প্রধান কাজে আর ছোট লেনদেনে, যেখানে অনুমানযোগ্য দ্রুত খোঁজা চাই, B+ tree এখনো প্রধান।
Google-এর LevelDB, তা থেকে Facebook-এর RocksDB, আর Apache Cassandra LSM-ধাঁচের। অন্যদিকে MySQL InnoDB, PostgreSQL আর SQLite B-tree পরিবারের। তাত্ত্বিক জগতে এই দুইয়ের মাঝামাঝিও আছে — যেমন Bε-tree, যেখানে internal node-এ বার্তা জমিয়ে রেখে ধীরে ধীরে নিচে পাঠানো হয়, ফলে লেখা সস্তা হয় অথচ খোঁজা প্রায় B-tree-র মতো থাকে।
মূল উপলব্ধি: কোনো ডেটা স্ট্রাকচার পড়া, লেখা আর জায়গা — তিনটিতে একসাথে সেরা হতে পারে না। B+ tree পড়াকে অগ্রাধিকার দেয়, LSM লেখাকে; কাজের ধরন দেখে বাছাই করাই প্রকৌশল।
প্র ০৪Red-black tree কি আসলে একটি B-tree? এই সম্পর্ক জানলে কী সুবিধা হয়?
ইতিহাস দিয়ে শুরু করি। B-tree-র প্রবন্ধ প্রকাশের একই বছর, ১৯৭২ সালে, Rudolf Bayer নিজেই আরেকটি কাঠামো প্রকাশ করেন: “symmetric binary B-tree”. তাঁর লক্ষ্য ছিল মেমরির ভেতরে B-tree-র ধারণা — বিশেষত 2-3-4 tree-র মতো ছোট B-tree — binary node দিয়ে প্রকাশ করা, যাতে প্রতিটি node-এ একটিই key থাকে। ১৯৭৮ সালে Leonidas Guibas আর Robert Sedgewick একই কাঠামোকে রঙ দিয়ে বর্ণনা করেন — লাল আর কালো edge বা node — আর সেখান থেকেই “red-black tree” নামটি আসে। অর্থাৎ ঐতিহাসিকভাবেই red-black tree হলো B-tree-র বংশধর।
গঠনের দিক থেকে মিলটি প্রায় এক-এক: প্রতিটি red-black tree থেকে ঠিক একটি 2-3-4 tree পাওয়া যায়, আর প্রতিটি 2-3-4 tree-কে red-black রূপে লেখা যায় (৩টি key-এর node-এ মাঝেরটি কালো, দুই পাশের দুটি লাল; ২টি key-এর node-এ কোনটি কালো তা নিয়ে দুটি বিকল্প, তাই একটি 2-3-4 tree-র কয়েকটি red-black রূপ থাকতে পারে)। তাই red-black-এর প্রতিটি নিয়ম B-tree-র একটি নিয়মের অনুবাদ। “পরপর দুটি লাল নয়” মানে কোনো B-tree node-এ ৩টির বেশি key নেই। “প্রতিটি পথে কালো node সমান” মানে সব leaf একই গভীরতায়। Insert-এ যখন একটি লাল node-এর লাল সন্তান হয়, সেটি আসলে একটি B-tree node উপচে পড়া; color flip হলো split — মাঝের key কালো থেকে লাল হয়ে “ওপরে ওঠে”। Rotation হলো একই B-tree node-এর ভেতরে কোন key কালো থাকবে তা বদলানো।
সুবিধা তিনটি। এক, মুখস্থ কমে: red-black-এর insert আর delete-এর কেসগুলো (চাচা লাল না কালো, বাঁকা না সোজা) মুখস্থ করা কঠিন, কিন্তু 2-3-4 tree-তে সেগুলো শুধু “split”, “borrow” আর “merge”. দুই, প্রমাণ সহজ হয়: B-tree-র উচ্চতা প্রায় log n, আর red-black-এ প্রতিটি B-tree স্তর বড়জোর দুটি binary স্তর, তাই উচ্চতা ≤ 2 log₂(n + 1). তিন, সরল রূপ তৈরি করা যায়: ২০০৮ সালে Sedgewick 2-3 tree-র সাথে এক-এক মিলের “left-leaning red-black tree” দেন, যার insert কয়েক লাইনে লেখা যায়।
তবে একটি পার্থক্য মনে রাখুন: red-black tree মেমরির জন্য, প্রতিটি node-এ একটি key — তাই এর উচ্চতা log₂-ভিত্তিক, ডিস্কে অচল। B-tree-র শক্তি বড় fan-out-এ; red-black শুধু t = 2-এর B-tree-র যুক্তিটা ধার করে।
মূল উপলব্ধি: দুটি আলাদা দেখতে ডেটা স্ট্রাকচার প্রায়ই একই ধারণার দুটি উপস্থাপন। Red-black tree-কে 2-3-4 tree হিসেবে দেখলে তার জটিল নিয়ম সরল যুক্তিতে পরিণত হয়।
প্র ০৫একটি ডেটাবেসে একসাথে হাজার জন ব্যবহারকারী একই B+ tree-তে খুঁজছেন আর ঢোকাচ্ছেন। পুরো tree lock না করে কীভাবে এটি নিরাপদে চলে?
ধরুন ঈদের আগে একটি মোবাইল ব্যাংকিং সার্ভারে প্রতি সেকেন্ডে হাজার হাজার লেনদেন আসছে, প্রত্যেকটি একই index-এ নতুন row ঢোকায়। সবচেয়ে সরল নিরাপদ উপায় — পুরো tree-তে একটি তালা, একজন কাজ শেষ করলে পরের জন। কিন্তু তাহলে হাজার CPU core থাকলেও কাজ চলবে একজন একজন করে। আবার কোনো তালা না দিলে বিপদ: একজন পাঠক একটি leaf-এ নামার মুহূর্তে আরেকজন লেখক সেই leaf split করে ফেললেন — পাঠক ভুল জায়গায় খুঁজে বলবেন “key নেই”, অথচ আছে।
প্রথম কৌশলের নাম “latch crabbing” (কাঁকড়ার মতো হাঁটা): parent-এর তালা ধরে রেখে সন্তানের তালা নিন, তারপর যদি নিশ্চিত হন যে সন্তানে কোনো split বা merge ওপরে ছড়াবে না (“নিরাপদ” node — insert-এ ভরা নয়, delete-এ প্রায় খালি নয়), তাহলে ওপরের সব তালা ছেড়ে দিন। এখানেই এই পর্বের proactive split-এর একটি বাস্তব সুবিধা দেখা যায়: যেহেতু নামার পথে ভরা node আগেই ভাগ হয়, কোনো পরিবর্তন ওপরে ফিরে আসে না — তাই ওপরের তালা তাড়াতাড়ি ছেড়ে দেওয়া যায়। Root-এর কাছের node-গুলো সবার পথে পড়ে, তাই সেগুলোর তালা যত কম সময় ধরে রাখা যায়, তত বেশি মানুষ একসাথে কাজ করতে পারেন।
দ্বিতীয় আর আরও চতুর কৌশল ১৯৮১ সালে Philip Lehman আর S. Bing Yao-র B-link tree: প্রতিটি node-এ তার ডান ভাইয়ের দিকে একটি বাড়তি pointer আর “এই node-এর সর্বোচ্চ key” (high key). Split হলে আগে নতুন ডান node বানিয়ে link জুড়ে দেওয়া হয়, তারপর parent-এ separator বসানো হয়। এর মাঝখানে কোনো পাঠক পুরোনো node-এ এসে দেখলে, তাঁর key high key-এর চেয়ে বড় হলে শুধু ডানের link ধরে এগিয়ে যান — ভুল হয় না। ফলে পাঠকদের প্রায় কোনো তালাই লাগে না। PostgreSQL-এর btree index এই Lehman–Yao পদ্ধতির ওপরই বানানো।
ডিস্কে আরেকটি সমস্যা হলো লেখার মাঝপথে বিদ্যুৎ চলে যাওয়া — বাংলাদেশে load-shedding-এর কথা ভাবুন। Split-এ তিনটি page বদলায়; একটি লেখা হয়ে বাকিগুলো না হলে tree ভেঙে যায়। তাই ডেটাবেস আগে একটি write-ahead log-এ পরিবর্তন লেখে, আর Btrfs বা LMDB-র মতো সিস্টেম copy-on-write নেয়: পুরোনো page না বদলে নতুন কপি লেখে, শেষে root pointer এক ধাক্কায় বদলায়।
মূল উপলব্ধি: বাস্তবের B-tree শুধু একটি অ্যালগরিদম নয় — concurrency, ক্র্যাশ থেকে উদ্ধার আর ডিস্কের আচরণ মিলিয়ে একটি পুরো প্রকৌশল। কিন্তু সবকিছুর ভিত্তি এই পর্বের সরল split আর merge.
অনুশীলন
খাতা-কলম নিন। প্রথম তিনটি হাতে করার, শেষটিতে কোড লিখতে হবে।
-
হাতে trace করুন: একটি খালি t = 2-এর B-tree-তে (proactive split) পরপর insert করুন 5, 15, 25, 35, 45, 55, 65, 75, 85. প্রতিটি insert-এর পর tree স্তর ধরে লিখুন, আর বলুন শেষ insert-এ কী “অদরকারি” মনে হওয়া ঘটনা ঘটল।
5, 15, 25 →
[5 15 25](ভরা)। 35: root ভরা → split,[15] | [5] [25 35]. 45 →[15] | [5] [25 35 45]. 55: সন্তান[25 35 45]ভরা → split, 35 ওঠে →[15 35] | [5] [25] [45 55]. 65 →[15 35] | [5] [25] [45 55 65]. 75: সন্তান ভরা → split, 55 ওঠে →[15 35 55] | [5] [25] [45] [65 75].85: root
[15 35 55]ভরা → আগেই split →[35] | [15] [55] | …; তারপর 85 যায়[35] → [55] → [65 75], ফল[35] | [15] [55] | [5] [25] [45] [65 75 85]. “অদরকারি” ঘটনা: 85-এর leaf[65 75]-এ জায়গা ছিল, তবু root ভাগ হয়ে tree-র উচ্চতা ২ হলো — proactive split নামার পথের প্রতিটি ভরা node ভাগ করে। লক্ষ করুন, বাড়তে থাকা ক্রমে বাঁয়ের leaf-গুলো একটি করে key নিয়ে পড়ে থাকে — জায়গার ব্যবহার কম। এজন্য বাস্তব ডেটাবেস ক্রমিক key-এর জন্য প্রায়ই অসমান split করে (ডানের দিকে প্রায় খালি নতুন page). -
উচ্চতার হিসাব: t = 100-এর একটি B-tree-তে n = 10⁸ টি key আছে। (ক) উপপাদ্য দিয়ে উচ্চতার সর্বোচ্চ সীমা বের করুন। (খ) প্রতিটি node পুরো ভরা হলে উচ্চতা সবচেয়ে কম কত হতে পারে? (গ) root সবসময় RAM-এ থাকলে একটি search-এ worst case কয়টি disk read?
(ক) h ≤ log100((10⁸ + 1)/2) ≈ log100(5 × 10⁷). 100³ = 10⁶ ≤ 5 × 10⁷ < 10⁸ = 100⁴, তাই মানটি ৩ আর ৪-এর মাঝে (প্রায় ৩.৮৫); h পূর্ণসংখ্যা বলে h ≤ ৩. (খ) সব node ভরা হলে উচ্চতা h-এ সর্বোচ্চ (2t)h+1 − 1 = 200h+1 − 1 টি key. 200³ − 1 ≈ 8 × 10⁶ < 10⁸, কিন্তু 200⁴ − 1 ≈ 1.6 × 10⁹ ≥ 10⁸ — তাই h + 1 ≥ ৪, অর্থাৎ h ≥ ৩.
দুই সীমা মিলে উচ্চতা ঠিক ৩ — অর্থাৎ ১০ কোটি key-এর যেকোনো t = 100 B-tree-তে ঠিক ৪টি স্তর, যেভাবেই insert করা হোক। (গ) পথে ৪টি node, root RAM-এ, তাই worst case ৩টি disk read. Binary tree-তে একই n-এ প্রায় ২৭টি।
-
ভেবে বলুন: একটি ডেটাবেসের page 16 KB, আর B+ tree-র প্রতিটি entry (key + pointer + সামান্য বাড়তি) ১৬ byte ধরুন। (ক) একটি page-এ মোটামুটি কয়টি entry? (খ) ১০০ কোটি row-এর জন্য কয় স্তর লাগবে? (গ) ওপরের দুই স্তর RAM-এ রাখতে কত মেমরি লাগবে, আর তাহলে একটি খোঁজায় কয়টি disk read?
(ক) 16384 / 16 = ১০২৪টি entry (বাস্তবে page header আর অর্ধেক-ভরা page-এর কারণে কিছু কম)। (খ) 1024² ≈ 1.05 × 10⁶ < 10⁹, কিন্তু 1024³ ≈ 1.07 × 10⁹ ≥ 10⁹ — তাই ৩ স্তর যথেষ্ট (leaf-এও ধরুন ১০২৪টি row-এর entry).
(গ) ওপরের দুই স্তর = ১টি root + ১০২৪টি page = ১০২৫ × 16 KB ≈ ১৬ MB — আজকের যেকোনো সার্ভারের RAM-এর তুলনায় নগণ্য। তাহলে প্রতিটি খোঁজায় শুধু leaf page-টি ডিস্ক থেকে আনতে হয়: ১টি disk read. এ কারণেই ডেটাবেস “buffer pool”-এ ঘন ঘন লাগা page রেখে দেয়, আর সাধারণত tree-র ওপরের অংশ স্বাভাবিকভাবেই সেখানে থেকে যায়।
-
কোড লিখুন: অংশ ৪-এর C++
Node(key-এর vectork, সন্তানের vectorc) ব্যবহার করে একটি ফাংশন লিখুন, যা সাধারণ B-tree থেকে lo ≤ key ≤ hi এমন সব key সাজানো ক্রমে বের করে। যে subtree-তে রেঞ্জের কোনো key থাকতে পারে না, সেখানে নামবেন না। Time complexity বলুন, আর অংশ ৪-এর চূড়ান্ত tree-তে [22, 62] দিয়ে হাতে চালান।প্রতিটি node-এ প্রথমে lo-র চেয়ে ছোট নয় এমন প্রথম key-এর জায়গা i খুঁজুন; তার আগের সন্তানগুলোতে সব key lo-র ছোট, তাই বাদ। তারপর in-order: সন্তান c[i], key k[i], সন্তান c[i+1], … — যতক্ষণ key ≤ hi.
void range(Node* v, int lo, int hi, vector<int>& out) { size_t i = lower_bound(v->k.begin(), v->k.end(), lo) - v->k.begin(); for (;; ++i) { if (!v->c.empty()) range(v->c[i], lo, hi, out); // keys between k[i-1] and k[i] if (i == v->k.size() || v->k[i] > hi) return; // nothing further right is in range out.push_back(v->k[i]); } }হাতে: root
[50], i = 0 → সন্তান[20]: i = 1 (20 < 22) → সন্তান[25 30 40]: 25, 30, 40 নেওয়া। ফিরে root-এ 50 ≤ 62 → নেওয়া; সন্তান[80]: i = 0 → সন্তান[60 70]: 60 নেওয়া, 70 > 62 থামা; ফিরে 80 > 62 থামা। ফল: 25 30 40 50 60.[10]আর[90]একবারও পড়া হয়নি।খরচ: রেঞ্জের দুই কিনারার দুটি পথ (প্রতিটি h + 1 node) আর মাঝের যে node-গুলোর সব key ফলাফলে যায়। তাই node পড়া O(logt n + K/t), যেখানে K = ফলাফলের সংখ্যা; CPU সময় O(t logt n + K). তুলনা করুন B+ tree-র সাথে: সেখানে প্রথম leaf-এর পর আর কোনো internal node লাগে না।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমCLRS-এর proactive (top-down) পদ্ধতিতে B-tree-তে key x ঢোকানোর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- Root ভরা (2t − 1 key) হলে নতুন একটি root বানিয়ে পুরোনো root-কে split করা
- বর্তমান node-এর key দেখে x-এর জন্য সঠিক সন্তান বাছাই করা
- সেই সন্তান ভরা হলে নামার আগেই split করে তার median বর্তমান node-এ তোলা
- split-এর পর x median-এর চেয়ে বড় হলে ডানের নতুন node বেছে নিয়ে নিচে নামা
- Leaf-এ পৌঁছে x-কে সাজানো জায়গায় বসানো
Minimum degree t = 3-এর একটি B-tree-তে root ছাড়া যেকোনো node-এ key-এর সংখ্যা কত থেকে কত হতে পারে?
- ১ থেকে ৩
- ২ থেকে ৫
- ৩ থেকে ৬
- ২ থেকে ৬
১০০ কোটি key ডিস্কে রেখে খোঁজার জন্য ভারসাম্যপূর্ণ binary search tree-র বদলে B-tree ব্যবহারের মূল কারণ কোনটি?
- B-tree-তে মোট তুলনার সংখ্যা অনেক কম
- B-tree key-গুলো hash করে সরাসরি জায়গা বের করে
- প্রতিটি node একটি পুরো disk block, fan-out বড় বলে উচ্চতা আর disk read-এর সংখ্যা অনেক কম
- Binary search tree ডিস্কে রাখাই যায় না
t = 2-এর একটি B-tree-তে একটিমাত্র node [20 50 80] আছে। CLRS-এর proactive পদ্ধতিতে 10 insert করার পর tree কেমন হবে? (স্তরগুলো | দিয়ে আলাদা)
- [50] | [10 20] [80]
- [20 50] | [10] [80]
- [10 20 50 80]
- [20] | [10] [50 80]
n ≥ 1 টি key আর minimum degree t-এর যেকোনো B-tree-র উচ্চতা h (root থেকে leaf পর্যন্ত edge) সম্পর্কে কোনটি সবসময় সত্য?
- h ≤ log₂ n, কিন্তু এর চেয়ে ভালো সীমা নেই
- h ≤ (n + 1)/(2t)
- h ≤ log2t(n + 1) − 1
- h ≤ logt((n + 1)/2)
B+ tree-তে BETWEEN 100 AND 500-এর মতো range query দ্রুত হওয়ার মূল কারণ কোনটি?
- Internal node-গুলোতে সব record-এর কপি থাকে
- সব record leaf-এ সাজানো আর leaf-গুলো পরস্পর pointer দিয়ে জোড়া, তাই প্রথম leaf খুঁজে শুধু শিকল ধরে এগোতে হয়
- প্রতিটি key-এর hash থেকে সরাসরি leaf-এর ঠিকানা পাওয়া যায়
- পুরো tree-র উচ্চতা ১, তাই কোনো খোঁজাই লাগে না
Red-black tree আর B-tree-র সম্পর্ক সম্পর্কে কোন বক্তব্যটি সঠিক?
- দুটির মধ্যে কোনো সম্পর্ক নেই
- Red-black tree ডিস্কে B-tree-র মতোই কম disk read দেয়
- Red-black tree হলো t = 2-এর B-tree (2-3-4 tree)-র binary রূপ, যেখানে প্রতিটি B-tree node একটি কালো node আর তার লাল সন্তানেরা
- Red-black tree হলো B+ tree-র binary রূপ
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- ডিস্ক বা SSD-তে খরচের একক block read; external-memory model তুলনা নয়, block আনা-নেওয়া গোনে। Binary tree-তে log₂ n read, B-tree-তে logB n.
- Minimum degree t: root ছাড়া প্রতিটি node-এ t − 1 থেকে 2t − 1 key, সব leaf একই গভীরতায়, উচ্চতা h ≤ logt((n + 1)/2). Knuth-এর order m = সর্বোচ্চ সন্তান (m = 2t).
- Insert: নামার পথে ভরা node split — median parent-এ ওঠে; tree লম্বা হয় শুধু root split-এ। Delete: borrow বা merge; tree বেঁটে হয় শুধু root খালি হলে।
- Search, insert, delete — তিনটিই O(logt n) disk I/O আর O(t logt n) CPU; node-এর ভেতরে binary search দিলে মোট তুলনা প্রায় log₂ n.
- B+ tree: record শুধু leaf-এ, leaf-গুলো শিকলে জোড়া, range query O(logB N + K/B). Fan-out ৫০০-তে ৩ স্তরে ১.২৫ × 10⁸ record — তাই ডেটাবেস আর file system-এর প্রিয় কাঠামো। 2-3-4 tree-র binary রূপই red-black tree.
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৩১ · Trie — শব্দ আর prefix খোঁজার treeপরবর্তী পর্বঅক্ষর ধরে শাখা — autocomplete আর prefix প্রশ্নের tree.
- ডেটা স্ট্রাকচার ও অ্যালগরিদম · কোর্স হোম৭২টি পর্বপুরো কোর্সের রোডম্যাপ — নয়টি মডিউল।
- পর্ব ২৯ · Red-black tree আর ভারসাম্যপূর্ণ tree-এর তুলনাআগের ভিত্তি2-3-4 tree-র binary রূপ — মেমরির ভেতরের ভারসাম্যপূর্ণ tree.
- কম্পিউটারের মৌলিক ধারণা · মেমরি হায়ারার্কি, cache আর virtual memoryপাশের ট্র্যাককেন RAM, SSD আর disk-এর গতিতে এত ফারাক।
- Operating Systems · SSD বনাম HDDপাশের ট্র্যাকডিস্কের ভেতরের কাজ — seek, page আর লেখার খরচ।
- Database Management Systems কোর্সপ্রয়োগindex, query আর লেনদেন — B+ tree যেখানে প্রতিদিন কাজ করে।