এই পর্বে যা শিখবেন
- BST-এর নিয়ম ঠিকভাবে বলা, ডুপ্লিকেট key কীভাবে সামলাবেন তা ঠিক করা, আর বৈধ-অবৈধ tree চেনা
- Search, insert, min/max, successor/predecessor আর তিন ধরনের delete হাতে trace করা ও কোডে লেখা
- Inorder traversal কেন সাজানো ক্রম দেয় তা induction দিয়ে প্রমাণ করা
- খরচ O(h) কেন, কখন h = log n আর কখন h = n — আর এলোমেলো insert-এ গড় উচ্চতা কত
- BST সঠিকভাবে যাচাই করা, আর k-তম ছোট, range query, floor/ceil-এর মতো প্রশ্নে BST কাজে লাগানো
১ · BST-এর নিয়ম — বাঁয়ে ছোট, ডানে বড়
পর্ব ১৮-এ binary search দেখেছেন: সাজানো অ্যারের মাঝখানে তাকান, খোঁজা মানটি ছোট হলে বাঁ অর্ধেক, বড় হলে ডান অর্ধেক — প্রতি ধাপে অর্ধেক বাদ, তাই O(log n). কিন্তু সমস্যা হলো পরিবর্তন. সাজানো অ্যারের মাঝখানে নতুন একটি মান ঢোকাতে বা সরাতে পেছনের সবাইকে এক ঘর সরাতে হয় — O(n). একটি মোবাইল ব্যাংকিং অ্যাপের কথা ভাবুন, যেখানে প্রতি সেকেন্ডে নতুন গ্রাহক যোগ হচ্ছেন, কেউ অ্যাকাউন্ট বন্ধ করছেন, আর একই সাথে নম্বর ধরে খোঁজাও চলছে। সাজানো অ্যারেতে খোঁজা দ্রুত, বদলানো ধীর; অসাজানো অ্যারেতে উল্টো। দুটোই দ্রুত চাই।
পুরোনো দিনের একটি বড় লাইব্রেরির কথা ভাবুন, যেখানে বইয়ের তালিকা রাখা হয় কার্ডে, আর কার্ডগুলো সাজানো একটি “সিদ্ধান্তের গাছে”. প্রথম ডেস্কে লেখা আছে “M” — আপনার বইয়ের নাম M-এর আগে হলে বাঁ দিকের করিডোরে যান, পরে হলে ডানে। পরের ডেস্কে আবার একই প্রশ্ন, অন্য একটি অক্ষর দিয়ে। কয়েকটি ডেস্ক পেরোলেই বই পেয়ে যান। নতুন বই এলে? একই পথে হেঁটে যেখানে করিডোর শেষ, সেখানে একটি নতুন ডেস্ক বসিয়ে দিন — আর কাউকে সরাতে হয় না। এটিই binary search tree.
পর্ব ২৬-এ tree-র পরিভাষা দেখেছেন: node, root, leaf, আর প্রতিটি node-এর সর্বোচ্চ দুটি সন্তানের binary tree. কোনো node আর তার নিচের সব বংশধর মিলে হয় সেই node-এর subtree. এখন একটি binary tree-কে binary search tree (BST) বলা হয়, যদি প্রতিটি node x-এর জন্য এই BST property সত্য হয়:
সাবধান — নিয়মটি পুরো subtree নিয়ে, শুধু সরাসরি সন্তান নিয়ে নয়। নিচের দুটি tree দেখুন। দুটিতেই প্রতিটি parent তার বাঁ সন্তানের চেয়ে বড় আর ডান সন্তানের চেয়ে ছোট। কিন্তু ডানেরটি BST নয়: 6 আছে 10-এর ডান subtree-তে, অথচ 6 < 10. কেউ 6 খুঁজলে root-এ 6 < 10 দেখে বাঁয়ে যাবে, আর কখনো পাবে না। অংশ ৭-এ এই ফাঁদটিই BST যাচাইয়ের সবচেয়ে প্রচলিত ভুল হিসেবে ফিরে আসবে।
বৈধ BST BST নয়
10 10
/ \ / \
5 15 5 15
/ \ / \
12 20 6 20
^
6 < 15 ঠিক আছে, কিন্তু 6 < 10 — অথচ সে 10-এর ডান দিকে
প্রতিটি node-এ সাধারণত থাকে একটি key (যার ওপর তুলনা হয়), দরকার হলে সাথের তথ্য (value — যেমন ফোনবুকে নামের সাথে নম্বর), আর দুটি pointer: left ও right. সন্তান না থাকলে pointer-টি null. কোনো কোনো বাস্তবায়নে parent-এর দিকেও একটি pointer থাকে (অংশ ৪), আর কখনো subtree-র আকার (অংশ ৮)।
Key-এর ক্রম আর ডুপ্লিকেটের নীতি
BST কাজ করে যেকোনো key-তে, যদি দুটি key-এর মধ্যে “কে আগে” তা সবসময় সুসংগতভাবে বলা যায় — সংখ্যা, string-এর অভিধান-ক্রম, বা তারিখ। পর্ব ২৫-এ দেখা comparator আর strict weak ordering-এর শর্তগুলো এখানেও লাগে; comparator অসংগত হলে tree-র ভেতরের ক্রম নষ্ট হয়, আর খোঁজা নিঃশব্দে ভুল ফল দেয়।
সমান key এলে কী হবে, তা আগেই ঠিক করে নিতে হয়। তিনটি প্রচলিত নীতি:
- প্রত্যাখ্যান বা উপেক্ষা — key আগে থেকে থাকলে নতুন কিছু যোগ হয় না (বড়জোর value হালনাগাদ হয়)।
std::set,std::mapআর এই পর্বের কোড এভাবে চলে। এখানে নিয়মটি কঠোর: বাঁয়ে <, ডানে >. - প্রতিটি node-এ একটি count — একই key আবার এলে শুধু সংখ্যা বাড়ে। tree ছোট থাকে, আর delete মানে count কমানো।
- সমানকে সবসময় একই দিকে পাঠানো — যেমন “সমান হলে ডানে”. তখন নিয়ম হয় বাঁ < x ≤ ডান।
std::multisetএভাবে চলে: নতুন সমান key বসে আগের সমানগুলোর পরে। তবে এতে একই key-এর অনেক কপি একটি লম্বা শিকল বানাতে পারে।
কোন নীতি নিচ্ছেন, তা কোডের প্রতিটি অংশে — insert, search, delete আর validate — একই থাকতে হবে। একটিতে < আর অন্যটিতে ≤ লিখলেই খুঁজে না পাওয়ার মতো সূক্ষ্ম bug হয়।
২ · Search আর min/max — প্রতিটি তুলনায় একটি দিক বাদ
খোঁজার নিয়ম সরাসরি BST property থেকে আসে। root থেকে শুরু করুন। খোঁজা key k যদি বর্তমান node-এর key-এর সমান হয়, পাওয়া গেল। ছোট হলে k থাকতে পারে শুধু বাঁ subtree-তে — ডানের সবাই আরও বড় — তাই বাঁয়ে যান; বড় হলে ডানে। null-এ পৌঁছালে k tree-তে নেই। প্রতিটি তুলনায় একটি পুরো subtree বাদ পড়ে, ঠিক binary search-এর মতো।
Pseudocode — BST search, min, max (iterative; null = no node)
search(t, k):
while t != null and k != t.key:
if k < t.key: t = t.left
else: t = t.right
return t // the node with key k, or null
minimum(t): while t.left != null: t = t.left; return t // t != null
maximum(t): while t.right != null: t = t.right; return tএই পর্বের প্রধান উদাহরণ tree বানানো হয়েছে 50, 30, 70, 20, 40, 60, 80, 35, 45 ক্রমে insert করে। এতে 45 খুঁজে দেখুন, তারপর 65 — যা tree-তে নেই:
| খোঁজা key | |
| বর্তমান node | |
| তুলনা ও সিদ্ধান্ত | |
| মোট তুলনা |
- Root থেকে শুরু. 45 < 50. 50-এর ডান subtree-র সবাই 50-এর চেয়ে বড়, তাই 45 সেখানে থাকতেই পারে না — ডানের চারটি node একসাথে বাদ। বাঁয়ে যাই।
- 30-এ. 45 > 30, তাই ডানে। 30-এর বাঁ subtree (শুধু 20) বাদ পড়ল।
- 40-এ. 45 > 40 — আবার ডানে। 35 বাদ।
- পাওয়া গেল. 45 = 45. মোট ৪টি তুলনা — 45-এর depth (root থেকে দূরত্ব) ৩, তার সাথে ১. ৯টি node-এর মধ্যে মাত্র ৪টি দেখতে হলো।
- এবার 65. 65 > 50 — ডানে, 70-এ।
- 70-এ. 65 < 70 — বাঁয়ে, 60-এ।
- শেষ. 65 > 60, তাই ডানে যেতে হতো — কিন্তু 60-এর ডান সন্তান null. অর্থাৎ 65 tree-তে নেই। এই null ঘরটি মনে রাখুন: 65 insert করলে ঠিক এখানেই বসবে (অংশ ৩)।
কেন ঠিক — invariant. লুপের পুরো সময় একটি কথা সত্য: “k যদি tree-তে থাকে, তাহলে সেটি বর্তমান node t-এর subtree-তে আছে।” শুরুতে t = root, তাই সত্য। k < t.key হলে BST property অনুযায়ী ডান subtree আর t নিজে বাদ, তাই k থাকলে বাঁ subtree-তে — t = t.left করার পরও কথাটি সত্য। লুপ থামে হয় k পেয়ে, নয়তো t = null-এ — খালি subtree-তে k নেই, তাই tree-তেই নেই। এটি পর্ব ০৩-এর loop invariant-এর সরাসরি প্রয়োগ।
খরচ. প্রতিটি ধাপে এক স্তর নিচে নামি, আর একটি পথে সর্বোচ্চ h + 1 টি node (h = tree-র উচ্চতা, root থেকে সবচেয়ে গভীর leaf পর্যন্ত edge-এর সংখ্যা)। তাই সময় O(h). লুপ দিয়ে লিখলে বাড়তি জায়গা O(1); recursion দিয়ে লিখলে call stack-এ O(h). লক্ষ করুন, খরচ n দিয়ে নয়, h দিয়ে লেখা — h কত বড় হতে পারে, সেটিই এই পর্বের কেন্দ্রীয় প্রশ্ন (অংশ ৬)।
Min আর max আরও সহজ: সবচেয়ে ছোট key সবসময় root থেকে শুধু বাঁয়ে বাঁয়ে গিয়ে শেষ node-এ (এখানে 20), সবচেয়ে বড়টি শুধু ডানে ডানে (80). কারণ কোনো node-এর বাঁ সন্তান থাকলে তার চেয়ে ছোট কেউ আছে; যার বাঁ সন্তান নেই আর যে পথটি সবসময় বাঁয়ে গেছে, তার চেয়ে ছোট কেউ থাকতে পারে না। খরচও O(h). লক্ষ করুন, সবচেয়ে ছোট node-টি leaf না-ও হতে পারে — তার ডান সন্তান থাকতে পারে, শুধু বাঁ সন্তান থাকে না। Delete-এ এই তথ্যটি কাজে লাগবে।
৩ · Insert — ব্যর্থ খোঁজার শেষে নতুন leaf
নতুন key কোথায় বসবে? যেখানে থাকলে খোঁজা তাকে খুঁজে পেত। তাই insert মানে প্রথমে key-টি খোঁজা; খোঁজা ব্যর্থ হয়ে যে null ঘরে থামে, সেখানে একটি নতুন node বসানো। নতুন node সবসময় একটি leaf হয়ে যোগ হয় — পুরোনো কোনো node সরাতে হয় না, কোনো pointer-এর জট খুলতে হয় না।
| বর্তমান node | |
| সিদ্ধান্ত | |
| node সংখ্যা n, উচ্চতা h |
- শুরু. ৯টি key-এর BST, উচ্চতা ৩ (পথ 50 → 30 → 40 → 35). 65 যোগ করতে হবে।
- 50-এ. 65 > 50 — ডানে। যদি এখানে সমান পেতাম, ডুপ্লিকেটের নীতি অনুযায়ী থামতাম।
- 70-এ. 65 < 70 — বাঁয়ে।
- 60-এ. 65 > 60 — ডানে, কিন্তু 60-এর ডান সন্তান নেই। Search(65) ঠিক এখানেই ব্যর্থ হয়েছিল।
- বসানো. নতুন node 65 হলো 60-এর ডান সন্তান — একটি leaf. শুধু একটি pointer বদলাল। উচ্চতা ৩-ই রইল, কারণ 65-এর depth ৩. এখন Search(65) এই পথেই তাকে পাবে।
খরচ: খোঁজার মতোই O(h) সময়; লুপে লিখলে O(1) বাড়তি জায়গা, recursion-এ O(h). Recursive রূপটি খুব ছোট, আর কোডে আমরা সেটিই ব্যবহার করব: insert(t, k) নতুন subtree-র root ফেরত দেয় — t null হলে নতুন node, নইলে t নিজেই, আর পথে t.left = insert(t.left, k) লিখে সংযোগ বসানো হয়।
Insert-এর ক্রমই আকার ঠিক করে. একই key-গুলো ভিন্ন ক্রমে insert করলে ভিন্ন আকারের tree হয়। প্রথম key সবসময় root হয়ে থাকে (delete না করা পর্যন্ত)। 2, 1, 3 ক্রমে দিলে সুন্দর একটি তিন-node-এর tree; 1, 2, 3 দিলে ডান দিকে হেলে থাকা একটি শিকল। ৩টি key দিয়ে মোট ৫টি আলাদা আকারের BST হয় — insert-এর ৬টি ক্রমের মধ্যে 2, 1, 3 আর 2, 3, 1 একই tree দেয়। (n টি key-এর BST আকারের সংখ্যা একটি Catalan number — ভাবনার প্রশ্ন ৫ দেখুন।)
৪ · Inorder traversal সাজানো ক্রম দেয় — আর successor/predecessor
পর্ব ২৬-এ inorder traversal দেখেছেন: আগে বাঁ subtree, তারপর node নিজে, তারপর ডান subtree. এক অদ্ভুত সুন্দর তথ্য: BST-তে inorder ক্রম ঠিক ছোট থেকে বড় সাজানো ক্রম. উদাহরণের tree-তে inorder: 20 30 35 40 45 50 60 65 70 80. কোনো সর্ট চালানো হয়নি — ক্রমটি tree-র আকারের মধ্যেই লুকিয়ে ছিল।
প্রমাণ — node সংখ্যার ওপর induction
দাবি: n টি node-এর যেকোনো BST-এর inorder traversal তার key-গুলোকে ছোট থেকে বড় ক্রমে দেয়। প্রমাণ strong induction দিয়ে, n-এর ওপর (পর্ব ০৩)।
- ভিত্তি (n = 0): খালি tree-র inorder খালি ক্রম, যা সাজানো। (n = 1 হলে একটি key, তাও সাজানো।)
- Inductive hypothesis: ধরে নিই, n-এর চেয়ে কম node-এর প্রতিটি BST-এর জন্য দাবিটি সত্য।
- Inductive step: n টি node-এর BST নিন, root r, বাঁ subtree L, ডান subtree R. L আর R নিজেরাও BST (BST property প্রতিটি node-এর জন্য, তাই subtree-তেও সত্য), আর দুটিতেই n-এর চেয়ে কম node. Hypothesis অনুযায়ী inorder(L) সাজানো, inorder(R) সাজানো। Inorder(T) = inorder(L), তারপর r.key, তারপর inorder(R). BST property বলে L-এর সব key < r.key < R-এর সব key. তিনটি সাজানো টুকরো, আর প্রতিটি টুকরোর সব মান পরেরটির সব মানের চেয়ে ছোট — জোড়া দিলেও সাজানো। ∎
এর উল্টোটাও সত্য (আলাদা key ধরে): কোনো binary tree-র inorder ক্রম কঠোরভাবে বাড়তে থাকলে tree-টি BST. তাই “BST কি না” যাচাইয়ের একটি সঠিক উপায় হলো inorder-এ হেঁটে দেখা প্রতিটি মান আগেরটির চেয়ে বড় কি না (অংশ ৭)। আরেকটি ফল: n টি সংখ্যা একে একে BST-তে insert করে inorder পড়লেই সর্ট হয়ে যায় — একে বলে tree sort. এর খরচ insert-গুলোর খরচ, যা tree-র আকারের ওপর নির্ভর করে: ভারসাম্য থাকলে O(n log n), শিকল হলে O(n²) — অংশ ৬-এ দেখবেন কেন এটি quicksort-এর সাথে অদ্ভুতভাবে মেলে।
পুরো inorder traversal-এর খরচ Θ(n): প্রতিটি node একবার ছাপা হয়, আর প্রতিটি edge ঠিক দুবার পার হয় (একবার নামতে, একবার উঠতে)। Recursion-এ বাড়তি জায়গা O(h).
Successor আর predecessor — দুটি অবস্থা
কোনো node x-এর inorder successor হলো সাজানো ক্রমে x-এর ঠিক পরের key — অর্থাৎ x-এর চেয়ে বড় key-গুলোর মধ্যে সবচেয়ে ছোট। Predecessor ঠিক আগেরটি। পুরো inorder না চালিয়েই এটি O(h)-এ পাওয়া যায়, দুটি অবস্থা আলাদা করে:
- অবস্থা ১ — x-এর ডান subtree আছে: successor = ডান subtree-র minimum. একবার ডানে, তারপর যতক্ষণ পারেন বাঁয়ে। কারণ x-এর চেয়ে বড় কিন্তু কাছের সবাই ওই ডান subtree-তেই আছে।
- অবস্থা ২ — ডান subtree নেই: তখন x যে subtree-র “সবচেয়ে বড়” সদস্য, সেই subtree-র ওপরের প্রথম পূর্বপুরুষটিই উত্তর। নিয়ম: ওপরে উঠতে থাকুন, যতক্ষণ আপনি ডান সন্তান হিসেবে উঠছেন; প্রথমবার যখন কোনো node-এর বাঁ সন্তান হিসেবে তার কাছে পৌঁছান, সেই node-ই successor. এমন কেউ না থাকলে x-ই সবচেয়ে বড় — successor নেই।
অবস্থা ২-এ ওপরে উঠতে হয়, কিন্তু সাধারণ BST node-এ শুধু নিচের দিকের pointer আছে। দুটি সমাধান: (ক) প্রতিটি node-এ একটি parent pointer রাখা — ওঠা সহজ, কিন্তু প্রতিটি insert, delete-এ সেটিও ঠিক রাখতে হয়, আর node-প্রতি একটি বাড়তি pointer-এর জায়গা লাগে; (খ) root থেকে আবার x খোঁজা, আর পথে যেখানেই বাঁয়ে গেছেন সেই node-টি মনে রাখা — শেষ যে node-এ বাঁয়ে গেছেন, সেটিই successor. দুটিই O(h).
| প্রশ্ন | |
| অবস্থা / পদ্ধতি | |
| উত্তর |
- succ(30). 30-এর ডান সন্তান 40 আছে, তাই অবস্থা ১: উত্তর 30-এর ডান subtree {40, 35, 45}-এর মধ্যে সবচেয়ে ছোটটি।
- ডানে একবার, বাঁয়ে বারবার. 40-এ গেলাম, তারপর বাঁয়ে 35; 35-এর বাঁ সন্তান নেই — successor 35. মিলিয়ে দেখুন: inorder-এ 30-এর পরেই 35.
- succ(45). 45 একটি leaf, ডান subtree নেই — অবস্থা ২. parent 40-এ উঠলাম; কিন্তু 45 ছিল 40-এর ডান সন্তান, তাই 40 < 45 — উত্তর নয়, আরও উঠুন।
- প্রথম বাঁ-সন্তান-সম্পর্ক. 40 হলো 30-এর ডান সন্তান — আরও উঠুন। 30 হলো 50-এর বাঁ সন্তান — থামুন। successor 50. যুক্তি: 30-এর পুরো subtree 50-এর বাঁয়ে, আর 45 সেই subtree-র সবচেয়ে বড়; তার পরের key তাই 50.
- Parent pointer ছাড়া. root থেকে 45 খুঁজি: 50-এ বাঁয়ে গেলাম (প্রার্থী = 50), 30-এ ডানে, 40-এ ডানে, 45 পাওয়া গেল। শেষ বাঁ-বাঁকের node 50 — একই উত্তর, parent pointer লাগল না।
Predecessor হুবহু আয়নার মতো: বাঁ subtree থাকলে তার maximum; না থাকলে ওপরে উঠুন যতক্ষণ বাঁ সন্তান হিসেবে উঠছেন, প্রথম যেখানে ডান সন্তান হিসেবে পৌঁছান সেটিই উত্তর। যেমন pred(60) = 50 (60-এর বাঁ subtree নেই; 60 হলো 70-এর বাঁ সন্তান, 70 হলো 50-এর ডান সন্তান → 50).
Minimum থেকে শুরু করে বারবার successor নিলে পুরো tree সাজানো ক্রমে ঘোরা হয়। একটি successor-এ O(h) লাগতে পারে, কিন্তু n টি successor মিলে মোট Θ(n) — কারণ পুরো যাত্রায় প্রতিটি edge সর্বোচ্চ দুবার পার হয়। std::set-এর iterator-এর ++ ঠিক এই কাজটি করে: একবারে worst case O(log n), কিন্তু পুরো সেট ঘুরলে গড়ে প্রতি ধাপে O(1) — একে বলে amortized O(1).
৫ · Delete — তিনটি অবস্থা
Insert সবসময় leaf যোগ করে, তাই সহজ। Delete কঠিন, কারণ মুছতে হতে পারে tree-র মাঝখানের কোনো node, যার নিচে পুরো একটি পরিবার ঝুলে আছে। প্রথমে key-টি খুঁজে node z বের করুন (O(h)), তারপর z-এর সন্তান-সংখ্যা দেখে তিনটি অবস্থা।
অবস্থা ১ — z একটি leaf
সবচেয়ে সহজ: parent-এর যে pointer z-কে দেখাচ্ছিল, সেটিকে null করুন, আর z-এর মেমরি ছেড়ে দিন। বাকি কারও সম্পর্ক বদলায় না, তাই BST property অটুট।
| z | |
| কাজ | |
| n |
- খোঁজা. 20 < 50 বাঁয়ে, 20 < 30 বাঁয়ে — 20 পাওয়া গেল। তার বাঁ-ডান দুটোই null: অবস্থা ১.
- কেটে ফেলা. parent 30-এর বাঁ pointer এখন null. 30-এর এখন শুধু ডান সন্তান 40.
- যাচাই. inorder 30 35 40 45 50 60 65 70 80 — আগের ক্রম থেকে শুধু 20 বাদ, বাকি ক্রম অটুট।
অবস্থা ২ — z-এর একটিমাত্র সন্তান
z-এর একমাত্র সন্তানকে (তার পুরো subtree-সহ) z-এর জায়গায় তুলে দিন: parent-এর pointer এখন সরাসরি সেই সন্তানকে দেখাবে। পর্ব ১২-এর linked list থেকে একটি node বাদ দেওয়ার মতো — আগেরটিকে পরেরটির সাথে জুড়ে দেওয়া। কেন BST property ভাঙে না? z-এর subtree-টি তার parent-এর যে দিকে ছিল, ওপরে ওঠা subtree-টিও সেই একই দিকে থাকে, আর তার সব key আগেও z-এর subtree-র অংশ ছিল — তাই parent ও সব পূর্বপুরুষের সাথে সম্পর্ক বদলায় না।
| z | |
| কাজ | |
| উচ্চতা h |
- খোঁজা. 30 < 50 বাঁয়ে — 30 পাওয়া গেল। আগের delete-এর পর 30-এর বাঁ সন্তান নেই, ডানে 40: অবস্থা ২.
- পাশ কাটানো. 50-এর বাঁ pointer এখন সরাসরি 40-কে দেখায়; 30-এর node মুক্ত। 40-এর subtree আগেও 50-এর বাঁ দিকে ছিল, এখনো আছে — তাই 35, 40, 45 সবাই 50-এর চেয়ে ছোট, নিয়ম ঠিক।
- ফল. 40 এখন 50-এর বাঁ সন্তান, 35 ও 45 এক স্তর ওপরে উঠল। তবু tree-র উচ্চতা ৩-ই রইল, কারণ সবচেয়ে গভীর পথ এখন ডান দিকে: 50 → 70 → 60 → 65. inorder: 35 40 45 50 60 65 70 80.
অবস্থা ৩ — z-এর দুটি সন্তান
এখানে z-কে সরিয়ে দিলে দুটি subtree ঝুলে থাকে, আর parent-এর কাছে জায়গা একটিই। কৌশলটি ১৯৬২ সালে Thomas Hibbard প্রকাশ করেন, তাই একে কখনো Hibbard deletion বলে: z-এর জায়গায় বসান এমন একটি key, যা বাঁ subtree-র সবার চেয়ে বড় আর ডান subtree-র সবার চেয়ে ছোট। এমন key আছে দুটি — z-এর inorder successor (ডান subtree-র minimum) আর predecessor (বাঁ subtree-র maximum). প্রচলিতভাবে successor s নেওয়া হয়:
- s = z-এর ডান subtree-র minimum (একবার ডানে, তারপর বাঁয়ে বাঁয়ে)।
- s-এর key (আর value) z-এ কপি করুন।
- এবার ডান subtree থেকে s-এর পুরোনো node-টি মুছুন। s-এর কখনো বাঁ সন্তান থাকে না — থাকলে সে আরও ছোট হতো, minimum হতো না। তাই এই দ্বিতীয় delete সবসময় অবস্থা ১ বা ২ — সহজ।
| z | |
| কাজ | |
| n, h |
- z = root 50. বাঁয়ে 40, ডানে 70 — দুটি সন্তান, অবস্থা ৩. 50-কে সরিয়ে দিলে দুটি subtree-কে একসাথে কোথায় ঝোলাব, তার জায়গা নেই।
- Successor খোঁজা. ডানে 70, তারপর বাঁয়ে 60; 60-এর বাঁ সন্তান নেই — s = 60. ডান subtree-তে 60-এর চেয়ে ছোট কেউ নেই, আর বাঁ subtree-র সবাই (35, 40, 45) 50-এর চেয়েও ছোট।
- Key কপি. root-এ এখন 60. মুহূর্তের জন্য 60 দুবার আছে — root-এ আর নিচে পুরোনো ঘরে। root-এর জন্য নিয়ম ঠিক: বাঁয়ের সবাই < 60, ডানের বাকিরা (65, 70, 80) > 60.
- পুরোনো 60 মোছা. তার শুধু ডান সন্তান 65 — অবস্থা ২: 70-এর বাঁ pointer এখন 65. মোট খরচ: একবার নামা z পর্যন্ত, আরেকবার s পর্যন্ত — দুটো মিলে O(h).
- ফল. inorder 35 40 45 60 65 70 80 — সাজানো, শুধু 50 নেই। উচ্চতা ২.
Key কপি করা বনাম node সরানো। ওপরের পদ্ধতি s-এর key z-এ কপি করে। সহজ, কিন্তু বাইরের কোনো কোড যদি s-এর node-এর দিকে pointer বা iterator ধরে রাখে, সেটি এখন ভুল জিনিস দেখায় বা মুক্ত মেমরির দিকে — dangling pointer. তাই std::set-এর মতো লাইব্রেরি key কপি করে না, বরং s-এর node-কেই pointer বদলে z-এর জায়গায় বসায়; এতে মুছে ফেলা উপাদান ছাড়া অন্য কারও iterator অকেজো হয় না। আর C++-এ সরানো node-এর মেমরি delete না করলে memory leak.
এবার পুরো BST কোডে — insert, search, delete, প্রতিটি কাজের পর inorder ছাপা, আর শেষে অংশ ৭-এর যাচাই (এখনই না বুঝলে সমস্যা নেই, অংশ ৭ পড়ে ফিরে আসুন)। Delete-এর তিনটি অবস্থা কোডে মাত্র দুটি শাখা: “কোনো একটি সন্তান null” শাখাটি leaf আর এক-সন্তান দুটোই সামলায় — leaf হলে অন্য সন্তানটিও null, তাই null-ই ফেরত যায়।
#include <algorithm>
#include <climits>
#include <iostream>
#include <string>
#include <utility>
#include <vector>
using namespace std;
struct Node { int key; Node *l = nullptr, *r = nullptr; };
Node* insert(Node* t, int k) {
if (!t) return new Node{k};
if (k < t->key) t->l = insert(t->l, k);
else if (k > t->key) t->r = insert(t->r, k); // duplicate keys are ignored
return t;
}
bool search(Node* t, int k, string& path) {
for (; t; t = k < t->key ? t->l : t->r) {
path += " " + to_string(t->key);
if (t->key == k) return true;
}
return false;
}
Node* erase(Node* t, int k) {
if (!t) return nullptr;
if (k < t->key) t->l = erase(t->l, k);
else if (k > t->key) t->r = erase(t->r, k);
else if (!t->l || !t->r) { Node* c = t->l ? t->l : t->r; delete t; return c; } // leaf or one child
else {
Node* s = t->r;
while (s->l) s = s->l; // inorder successor = min of right subtree
t->key = s->key; t->r = erase(t->r, s->key);
}
return t;
}
void inorder(Node* t, string& out) { if (t) { inorder(t->l, out); out += " " + to_string(t->key); inorder(t->r, out); } }
int height(Node* t) { return t ? 1 + max(height(t->l), height(t->r)) : -1; }
bool naiveOk(Node* t) { // WRONG: compares a node only with its children
if (!t) return true;
if ((t->l && t->l->key >= t->key) || (t->r && t->r->key <= t->key)) return false;
return naiveOk(t->l) && naiveOk(t->r);
}
bool valid(Node* t, long long lo = LLONG_MIN, long long hi = LLONG_MAX) { // right: key in (lo, hi)
return !t || (lo < t->key && t->key < hi && valid(t->l, lo, t->key) && valid(t->r, t->key, hi));
}
void show(const string& label, Node* t) { string s; inorder(t, s); cout << label << string(22 - label.size(), ' ') << "->" << s << '\n'; }
int main() {
Node* root = nullptr;
for (int k : vector{50, 30, 70, 20, 40, 60, 80, 35, 45}) root = insert(root, k);
show("insert 9 keys", root); cout << "height " << height(root) << '\n';
for (int k : vector{45, 65}) {
string path; bool ok = search(root, k, path);
cout << "search " << k << ": " << (ok ? "found" : "not found") << ", path" << path << '\n';
}
root = insert(root, 65); show("insert 65", root);
for (auto [k, why] : vector<pair<int, string>>{{20, "leaf"}, {30, "one child"}, {50, "two kids"}}) {
root = erase(root, k); show("delete " + to_string(k) + " (" + why + ")", root);
}
cout << "root " << root->key << " height " << height(root) << '\n';
Node a{6}, b{20}, c{5}, d{15, &a, &b}, bad{10, &c, &d}; // the tricky tree of section 7
for (auto [name, t] : vector<pair<string, Node*>>{{"final tree", root}, {"tricky tree", &bad}})
cout << name << ": naive says " << (naiveOk(t) ? "BST" : "not BST") << ", bounds say " << (valid(t) ? "BST" : "not BST") << '\n';
}আউটপুট:
insert 9 keys -> 20 30 35 40 45 50 60 70 80
height 3
search 45: found, path 50 30 40 45
search 65: not found, path 50 70 60
insert 65 -> 20 30 35 40 45 50 60 65 70 80
delete 20 (leaf) -> 30 35 40 45 50 60 65 70 80
delete 30 (one child) -> 35 40 45 50 60 65 70 80
delete 50 (two kids) -> 35 40 45 60 65 70 80
root 60 height 2
final tree: naive says BST, bounds say BST
tricky tree: naive says BST, bounds say not BST
প্রতিটি লাইন একটি ডায়াগ্রামের সাথে মেলে: search-এর পথ 50 30 40 45 আর 50 70 60 (অংশ ২), 65 বসে 60 আর 70-এর মাঝে (অংশ ৩), তারপর leaf 20, এক-সন্তানের 30 আর দুই-সন্তানের 50 মোছা (অংশ ৫)। প্রতিটি কাজের পর inorder সাজানোই থাকে — BST property রক্ষা পাওয়ার সবচেয়ে সহজ পরীক্ষা। শেষে root 60, উচ্চতা ২. erase-এ দুই সন্তানের শাখায় t->r = erase(t->r, s->key) successor-কে ডান subtree থেকে মোছে, আর সেই ডাক কখনো আবার তৃতীয় অবস্থায় পড়ে না। শেষ দুই লাইন অংশ ৭-এর বিষয়: “tricky tree”-তে শুধু সন্তান দেখা পরীক্ষা (naiveOk) বোকা বনে যায়, সীমা-ভিত্তিক valid ধরে ফেলে। valid-এ সীমা long long, যাতে INT_MIN বা INT_MAX key-ও ঠিকভাবে যাচাই হয়। সহজ রাখতে মূল tree-র মেমরি প্রোগ্রামের শেষে ছাড়া হয়নি; বাস্তবে একটি destructor বা unique_ptr দিয়ে তা করুন।
class Node:
def __init__(self, key, l=None, r=None): self.key, self.l, self.r = key, l, r
def insert(t, k):
if t is None: return Node(k)
if k < t.key: t.l = insert(t.l, k)
elif k > t.key: t.r = insert(t.r, k) # duplicate keys are ignored
return t
def search(t, k):
path = []
while t and t.key != k:
path.append(t.key); t = t.l if k < t.key else t.r
return t is not None, path + ([k] if t else [])
def erase(t, k):
if t is None: return None
if k < t.key: t.l = erase(t.l, k)
elif k > t.key: t.r = erase(t.r, k)
elif t.l is None or t.r is None: return t.l or t.r # leaf or one child
else:
s = t.r
while s.l: s = s.l # inorder successor = min of right subtree
t.key = s.key; t.r = erase(t.r, s.key)
return t
inorder = lambda t: inorder(t.l) + [t.key] + inorder(t.r) if t else []
height = lambda t: 1 + max(height(t.l), height(t.r)) if t else -1
def naive_ok(t): # WRONG: compares a node only with its children
if t is None: return True
if (t.l and t.l.key >= t.key) or (t.r and t.r.key <= t.key): return False
return naive_ok(t.l) and naive_ok(t.r)
def valid(t, lo=float("-inf"), hi=float("inf")): # right: every key must lie in (lo, hi)
return t is None or (lo < t.key < hi and valid(t.l, lo, t.key) and valid(t.r, t.key, hi))
show = lambda label, t: print(f"{label:<22}-> {' '.join(map(str, inorder(t)))}")
root = None
for k in [50, 30, 70, 20, 40, 60, 80, 35, 45]: root = insert(root, k)
show("insert 9 keys", root); print("height", height(root))
for k in [45, 65]:
ok, path = search(root, k)
print(f"search {k}: {'found' if ok else 'not found'}, path {' '.join(map(str, path))}")
root = insert(root, 65); show("insert 65", root)
for k, why in [(20, "leaf"), (30, "one child"), (50, "two kids")]:
root = erase(root, k); show(f"delete {k} ({why})", root)
print("root", root.key, "height", height(root))
bad = Node(10, Node(5), Node(15, Node(6), Node(20)))
for name, t in [("final tree", root), ("tricky tree", bad)]:
print(f"{name}: naive says {'BST' if naive_ok(t) else 'not BST'}, bounds say {'BST' if valid(t) else 'not BST'}")আউটপুট C++-এর সাথে হুবহু এক:
insert 9 keys -> 20 30 35 40 45 50 60 70 80
height 3
search 45: found, path 50 30 40 45
search 65: not found, path 50 70 60
insert 65 -> 20 30 35 40 45 50 60 65 70 80
delete 20 (leaf) -> 30 35 40 45 50 60 65 70 80
delete 30 (one child) -> 35 40 45 50 60 65 70 80
delete 50 (two kids) -> 35 40 45 60 65 70 80
root 60 height 2
final tree: naive says BST, bounds say BST
tricky tree: naive says BST, bounds say not BST
Python-এ None হলো null. return t.l or t.r এক লাইনে leaf (দুটোই None, ফল None) আর এক-সন্তান দুটো অবস্থা সামলায়। সীমার জন্য float("-inf") আর float("inf") — Python-এ int-এর কোনো সীমা নেই, তাই overflow-এর ভয়ও নেই। lo < t.key < hi হলো Python-এর chained comparison. মুছে ফেলা node-এর মেমরি garbage collector নিজেই ফেরত নেয়। সাবধান: recursion-এর গভীরতা = tree-র উচ্চতা, আর Python-এর ডিফল্ট সীমা প্রায় ১০০০ — অংশ ৬-এর শিকলের মতো tree-তে এই কোড RecursionError দেবে; সেখানে লুপ-ভিত্তিক রূপ লিখুন।
৬ · খরচ O(h) — ভারসাম্যে log n, সাজানো ইনপুটে n
এ পর্যন্ত প্রতিটি কাজ O(h). এবার h-এর সীমা বের করি। n টি node-এর binary tree-তে depth i-এ সর্বোচ্চ 2i টি node থাকতে পারে, তাই উচ্চতা h হলে n ≤ 1 + 2 + 4 + … + 2h = 2h+1 − 1, অর্থাৎ h ≥ ⌈log₂(n + 1)⌉ − 1 = ⌊log₂ n⌋. আর ওপরের সীমা স্পষ্ট: প্রতিটি স্তরে অন্তত একটি node, তাই h ≤ n − 1.
১০ লাখ key-তে ভারসাম্যপূর্ণ tree-র উচ্চতা মাত্র ১৯ — প্রতিটি খোঁজায় সর্বোচ্চ ২০টি তুলনা। কিন্তু খারাপতম ক্ষেত্রে উচ্চতা ৯,৯৯,৯৯৯ — তখন BST আসলে একটি linked list. আর এই খারাপতম ক্ষেত্রটি দুর্লভ নয়; সবচেয়ে সাধারণ ইনপুটটিই এটি তৈরি করে — আগে থেকে সাজানো ডেটা. রোল নম্বর, তারিখ, ক্রমিক লেনদেন নম্বর — বাস্তবের অনেক ডেটা এমনিতেই বাড়তে থাকা ক্রমে আসে।
| insert ক্রম | |
| এই insert-এ তুলনা | |
| মোট তুলনা | |
| উচ্চতা h |
- 10. খালি tree-তে 10 root হলো।
- 20. 20 > 10 → ডানে। ১টি তুলনা।
- 30. 10-এর ডানে, 20-এর ডানে — ২টি তুলনা। tree ডান দিকে হেলছে, কোনো node-এর বাঁ সন্তান নেই।
- 40. ৩টি তুলনা, উচ্চতা ৩. প্রতিটি নতুন key আগের সবার চেয়ে বড়, তাই প্রতিবার পুরো শিকল পার হয়ে একদম শেষে বসে।
- 50. ৪টি তুলনা; মোট 0 + 1 + 2 + 3 + 4 = 10 = n(n − 1)/2. এটি একটি degenerate tree — আসলে linked list. এখন 50 খুঁজতে ৫টি তুলনা, n টি key-তে n টি; পুরো tree বানাতে Θ(n²).
- একই key, অন্য ক্রম. 30, 20, 40, 10, 50 ক্রমে দিলে উচ্চতা মাত্র ২, মোট তুলনা ৬. key এক, ক্রম আলাদা — আর খরচে বিশাল পার্থক্য। n = 105 সাজানো key-তে শিকল বানাতে প্রায় 5 × 109 তুলনা লাগে — কনটেস্টের সময়সীমায় নিশ্চিত TLE.
এলোমেলো ক্রমে insert করলে — গড় উচ্চতা O(log n)
যদি n টি আলাদা key-এর সব n! ক্রম সমান সম্ভাব্য হয় (একে বলে random BST), তাহলে ফলাফল অনেক ভালো। দুটি বিখ্যাত ফল (প্রমাণ এখানে নয়, শুধু বক্তব্য):
- গড় depth: একটি node-এর প্রত্যাশিত depth প্রায় 2 ln n ≈ 1.39 log₂ n. অর্থাৎ সফল খোঁজায় গড়ে ভারসাম্যপূর্ণ tree-র চেয়ে মাত্র প্রায় ৩৯% বেশি তুলনা।
- প্রত্যাশিত উচ্চতা: Θ(log n); আরও সূক্ষ্মভাবে প্রায় 4.311 ln n ≈ 2.99 log₂ n (Luc Devroye, ১৯৮৬)। সবচেয়ে গভীর পথ গড় পথের চেয়ে লম্বা, কিন্তু তবু logarithmic.
গড় depth-এর ফলটির পেছনে একটি সুন্দর মিল আছে: BST-তে insert করার সময় যে তুলনাগুলো হয়, ঠিক সেগুলোই হয় quicksort-এ, যদি প্রতিবার প্রথম উপাদানকে pivot নেওয়া হয় (পর্ব ২২) — root-ই প্রথম pivot, তার বাঁ আর ডান subtree দুটি partition. তাই BST বানানোর মোট খরচ আর quicksort-এর তুলনা-সংখ্যা একই, গড়ে প্রায় 2n ln n (ভাবনার প্রশ্ন ২)।
কিন্তু এই “গড়” নির্ভর করে ইনপুটের এলোমেলোতার ওপর, অ্যালগরিদমের নিজের ওপর নয়। ইনপুট কে দেবে, আপনি জানেন না — সাজানো ডেটা, বা ইচ্ছে করে বানানো খারাপ ইনপুট (anti-test) এলে O(n). দুটি পথ আছে: tree-কে নিজেই ভারসাম্যে রাখতে বাধ্য করা (AVL, red-black — পর্ব ২৮–২৯), অথবা এলোমেলোতা নিজে তৈরি করা (treap — পর্ব ৩৮), যাতে গড়টি ইনপুটের ওপর নয়, নিজের মুদ্রা-নিক্ষেপের ওপর নির্ভর করে।
নিজে চালিয়ে দেখুন — BST visualiser
নিচের টুলে “চালিয়ে দেখুন” চাপুন। একটি key লিখে insert চাপলে সেটি যে পথে নামল তা লাল হয়ে জ্বলবে; delete চাপলে কোন অবস্থা (leaf, one child, two children) ঘটল তা নিচে লেখা আসবে। প্রতিটি node-এর আনুভূমিক অবস্থান তার inorder ক্রম — তাই বাঁ থেকে ডানে পড়লেই সাজানো ক্রম। sorted বোতামে 10, 20, …, 80 সাজানো ক্রমে ঢুকিয়ে শিকলটি দেখুন, তারপর কয়েকবার random চেপে তুলনা করুন উচ্চতা কত হয়।
let bst = null; // node = {k, l, r}
const $ = id => document.getElementById(id);
function ins(t, k, path) {
if (!t) return { k, l: null, r: null };
path.push(t.k);
if (k < t.k) t.l = ins(t.l, k, path); else if (k > t.k) t.r = ins(t.r, k, path);
return t; // equal key: ignored
}
function del(t, k, info) {
if (!t) return null;
if (k < t.k) t.l = del(t.l, k, info); else if (k > t.k) t.r = del(t.r, k, info);
else if (!t.l || !t.r) { info.c = t.l || t.r ? "one child" : "leaf"; return t.l || t.r; }
else {
let s = t.r; while (s.l) s = s.l; // inorder successor
info.c = "two children, successor " + s.k; t.k = s.k; t.r = del(t.r, s.k, {});
}
return t;
}
const inord = (t, a = []) => (t && (inord(t.l, a), a.push(t.k), inord(t.r, a)), a);
const ht = t => t ? 1 + Math.max(ht(t.l), ht(t.r)) : -1;
function draw(msg, hot = []) {
const pos = new Map(); let x = 0; // x = inorder rank, y = depth
(function walk(t, d) { if (t) { walk(t.l, d + 1); pos.set(t, [x++, d]); walk(t.r, d + 1); } })(bst, 0);
let svg = "", nodes = "";
for (const [t, [px, py]] of pos) {
for (const c of [t.l, t.r]) if (c) { const [cx, cy] = pos.get(c);
svg += `<line x1="${px * 34 + 16}" y1="${py * 46 + 16}" x2="${cx * 34 + 16}" y2="${cy * 46 + 16}"/>`; }
nodes += `<div class="nd${hot.includes(t.k) ? " hl" : ""}" style="left:${px * 34}px;top:${py * 46}px">${t.k}</div>`;
}
const h = ht(bst), box = $("bd");
box.style.width = Math.max(1, x) * 34 + "px"; box.style.height = (h + 1) * 46 + "px";
box.innerHTML = `<svg>${svg}</svg>${nodes}`;
const line = `${msg} | inorder: ${inord(bst).join(" ") || "(empty)"} | height ${h}`;
$("msg").textContent = line; console.log(line);
}
function add(k) { const path = []; bst = ins(bst, k, path); draw(`insert ${k}, path ${path.join(">") || "-"}`, [...path, k]); }
function remove(k) { const info = { c: "not found" }; bst = del(bst, k, info); draw(`delete ${k} (${info.c})`); }
$("bi").onclick = () => add(Number($("bv").value));
$("bx").onclick = () => remove(Number($("bv").value));
$("bs").onclick = () => { bst = null; for (let k = 1; k <= 8; k++) add(k * 10); };
$("br").onclick = () => { bst = null; for (let i = 0; i < 12; i++) add(1 + Math.floor(Math.random() * 99)); };
for (const k of [50, 30, 70, 20, 40, 60, 80, 35, 45]) add(k);
remove(30);শুরুতে কোড নিজে প্রধান উদাহরণের ৯টি key insert করে, তারপর 30 মোছে:
insert 50, path - | inorder: 50 | height 0
insert 30, path 50 | inorder: 30 50 | height 1
insert 70, path 50 | inorder: 30 50 70 | height 1
insert 20, path 50>30 | inorder: 20 30 50 70 | height 2
insert 40, path 50>30 | inorder: 20 30 40 50 70 | height 2
insert 60, path 50>70 | inorder: 20 30 40 50 60 70 | height 2
insert 80, path 50>70 | inorder: 20 30 40 50 60 70 80 | height 2
insert 35, path 50>30>40 | inorder: 20 30 35 40 50 60 70 80 | height 3
insert 45, path 50>30>40 | inorder: 20 30 35 40 45 50 60 70 80 | height 3
delete 30 (two children, successor 35) | inorder: 20 35 40 45 50 60 70 80 | height 3
পথগুলো (যেমন 35-এর জন্য 50>30>40) অংশ ২–৩-এর ডায়াগ্রামের সাথে মেলে। শেষ লাইনে 30-এর দুই সন্তান (20 আর 40) থাকায় অবস্থা ৩: successor 35 উঠে এসেছে, আর 35 ছিল leaf বলে দ্বিতীয় মোছাটি অবস্থা ১. ছবি আঁকার কৌশলটি সহজ — x স্থানাঙ্ক = inorder-এ কততম, y = depth — তাই কোনো দুটি node একই কলামে পড়ে না, আর প্রতিটি node তার বাঁ subtree-র সবার ডানে ও ডান subtree-র সবার বাঁয়ে বসে: BST property চোখে দেখা যায়। পরীক্ষা: random-এ ১২টি key-তে উচ্চতা সাধারণত ৪ থেকে ৭-এর মধ্যে থাকে, ⌊log₂ 12⌋ = 3-এর কাছাকাছি; sorted-এ ৮টি key-তে উচ্চতা ৭ = n − 1.
৭ · এটি কি BST? — যাচাইয়ের ভুল আর সঠিক উপায়
“একটি binary tree দেওয়া আছে, এটি BST কি না বলুন” — ইন্টারভিউ আর পরীক্ষার খুব প্রচলিত প্রশ্ন, আর এর প্রথম সমাধানটি প্রায় সবাই ভুল লেখেন। ভুলটি হলো প্রতিটি node-কে শুধু তার সরাসরি সন্তানের সাথে মেলানো: left.key < node.key < right.key, তারপর recursion. এটি দেখতে ঠিক, ছোট উদাহরণে উত্তরও ঠিক দেয়। কিন্তু অংশ ১-এর tree-টি দেখুন:
10 node 10: 5 < 10 < 15 ঠিক
/ \ node 15: 6 < 15 < 20 ঠিক
5 15
/ \ কিন্তু 6 আছে 10-এর ডান subtree-তে,
6 20 তাই 6-কে অবশ্যই 10-এর চেয়ে বড় হতে হতো।
সঠিক উপায় ১ — সীমা নিয়ে নামা. প্রতিটি node-এর key একটি খোলা ব্যবধি (lo, hi)-এর মধ্যে থাকতে হবে, যেটি তার সব পূর্বপুরুষ মিলে ঠিক করে। root-এর জন্য (−∞, +∞). বাঁয়ে নামলে ওপরের সীমা হয় node-এর key (বাঁ দিকের সবাই এর চেয়ে ছোট), ডানে নামলে নিচের সীমা। Tricky tree-তে: 10-এর ব্যবধি (−∞, ∞) ✓; 15-এর (10, ∞) ✓; 6-এর (10, 15) — 6 < 10, ✗. ধরা পড়ল।
Pseudocode — validate a BST (distinct keys, strict order)
valid(t, lo, hi): // call valid(root, -inf, +inf)
if t == null: return true
if not (lo < t.key < hi): return false
return valid(t.left, lo, t.key) and valid(t.right, t.key, hi)
// alternative: inorder walk, every key must be greater than the previous one
validInorder(t): prev = -inf; for each key k in inorder(t): if k <= prev: return false; prev = k
return trueসঠিক উপায় ২ — inorder-এ হাঁটা. অংশ ৪-এর প্রমাণ অনুযায়ী (আলাদা key ধরে) tree-টি BST ঠিক তখনই, যখন inorder ক্রম কঠোরভাবে বাড়ে। শুধু আগের মানটি মনে রেখে হাঁটলেই চলে, পুরো তালিকা রাখার দরকার নেই। দুই পদ্ধতিতেই প্রতিটি node একবার: সময় Θ(n) (খারাপ tree আগেই ধরা পড়লে কম), বাড়তি জায়গা recursion-এর জন্য O(h).
- সীমার ফাঁদ: −∞/+∞ হিসেবে
INT_MIN/INT_MAXনিলে, key নিজেইINT_MINহলে কঠোর তুলনাlo < keyমিথ্যা হয়ে বৈধ tree-কে অবৈধ বলবে। সমাধান:long longসীমা, অথবা “সীমা নেই” বোঝাতে null pointer বাstd::optional. - ডুপ্লিকেটের নীতি: অংশ ১-এর “সমান হলে ডানে” নীতি নিলে যাচাইও বদলায়: বাঁয়ে
key < hi, ডানেkey ≥ lo. যাচাই আর insert-এর নীতি এক না হলে যাচাই নিজেই ভুল। - Subtree-র min/max ফেরত দেওয়া আরেকটি সঠিক পদ্ধতি (post-order-এ প্রতিটি subtree নিজের min, max আর বৈধতা ফেরত দেয়) — একই O(n).
৮ · BST-কে আরও কাজে লাগানো — floor/ceil, range query আর k-তম ছোট
Hash table (পর্ব ৩২) “k আছে কি?” প্রশ্নে BST-কে হারিয়ে দেয়। কিন্তু BST-র আসল শক্তি ক্রমের প্রশ্নে — যেগুলো hash table মোটেই পারে না।
Floor আর ceil
x-এর floor হলো tree-র x-এর সমান বা ছোট key-গুলোর মধ্যে সবচেয়ে বড়; ceil হলো x-এর সমান বা বড়গুলোর মধ্যে সবচেয়ে ছোট। যেমন বাসের ভাড়ার স্তর 20, 35, 50, 80 টাকার কোনটি একজন যাত্রীর কাছে থাকা 60 টাকার মধ্যে সর্বোচ্চ — floor(60) = 50. খোঁজার মতো নামুন, আর পথে প্রার্থী মনে রাখুন: floor-এর জন্য, t.key ≤ x হলে t একজন প্রার্থী (এবং আরও ভালো প্রার্থী থাকলে ডানে), নইলে বাঁয়ে যান। শেষ যে প্রার্থী মনে রেখেছেন, সেটিই উত্তর। এটি সাজানো অ্যারের lower_bound/upper_bound-এর (পর্ব ১৮) BST রূপ; std::set-এ এগুলো তৈরি আছে, সময় O(h).
উদাহরণ — অংশ ৫-এর শেষ tree (root 60; বাঁয়ে 40, তার সন্তান 35, 45; ডানে 70, তার সন্তান 65, 80)-এ floor(50): 60 > 50 → বাঁয়ে; 40 ≤ 50 → প্রার্থী 40, ডানে; 45 ≤ 50 → প্রার্থী 45, ডানে; null → উত্তর 45. ceil(50): 60 ≥ 50 → প্রার্থী 60, বাঁয়ে; 40 < 50 → ডানে; 45 < 50 → ডানে; null → উত্তর 60.
Range query — [l, r]-এর সব key
“ঢাকার যেসব গ্রাহকের বয়স ২০ থেকে ৩০” — অর্থাৎ l ≤ key ≤ r এমন সব key ছাপাতে হবে। Inorder traversal চালান, কিন্তু অপ্রয়োজনীয় দিক ছেঁটে দিন: t.key > l হলে বাঁ subtree-তে যান (সেখানে l-এর চেয়ে বড় কেউ থাকতে পারে), l ≤ t.key ≤ r হলে ছাপান, t.key < r হলে ডানে যান। খরচ O(h + k), যেখানে k হলো উত্তরে কয়টি key — সীমানার দুটি পথ ধরে O(h), আর বাকি সময় শুধু উত্তরের node-গুলোতে। Range query-র এই ধাঁচ ডেটাবেসের index-এ সবসময় চলে (সেখানে B+ tree, পর্ব ৩০)।
Subtree-র আকার রাখা — order-statistic tree
“k-তম ছোট key কোনটি?” বা “x-এর চেয়ে ছোট কয়টি key?” — প্রথমটি order statistic, দ্বিতীয়টি rank. সাধারণ BST-তে inorder-এ k টি পর্যন্ত গুনে O(h + k). কিন্তু প্রতিটি node-এ একটি বাড়তি সংখ্যা রাখুন — size = তার subtree-তে কয়টি node — তাহলে দুটোই O(h). এমন বাড়তি তথ্যসহ BST-কে বলে order-statistic tree, আর এটি augmented data structure-এর সবচেয়ে পরিচিত উদাহরণ।
Pseudocode — order statistics with subtree sizes (k is 1-based; size(null) = 0)
kth(t, k): // requires 1 <= k <= size(t)
s = size(t.left)
if k <= s: return kth(t.left, k)
if k == s + 1: return t.key
return kth(t.right, k - s - 1) // skip the left subtree and t itself
rank(t, x): // how many keys are < x
if t == null: return 0
if x <= t.key: return rank(t.left, x)
return size(t.left) + 1 + rank(t.right, x)
countInRange(l, r) = rank(root, r + 1) - rank(root, l) // integer keys in [l, r]উদাহরণ — একই শেষ tree-তে size: 35, 45, 65, 80 প্রতিটি 1; 40 আর 70 প্রতিটি 3; root 60-এর 7. kth(root, 5): বাঁয়ের size 3, 5 > 3 + 1 = 4, তাই ডানে k = 5 − 4 = 1; 70-এর বাঁয়ের size 1, k = 1 ≤ 1 → বাঁয়ে; 65-এর বাঁয়ে size 0, k = 1 = 0 + 1 → উত্তর 65. মিলিয়ে দেখুন: 35 40 45 60 65 — পঞ্চম। size ঠিক রাখার খরচ সামান্য: insert আর delete-এর পথে প্রতিটি node-এর size এক বাড়ে বা কমে, তাই খরচ O(h)-ই থাকে। আর countInRange দুটি rank-এর বিয়োগে [l, r]-এ কয়টি key, তাও O(h) — উত্তর যত বড়ই হোক।
৯ · কোনটি কখন — BST, সাজানো অ্যারে না hash table; আর ভারসাম্য কেন জরুরি
| কাজ | সাজানো অ্যারে | সাধারণ BST | ভারসাম্যপূর্ণ BST | Hash table |
|---|---|---|---|---|
| খোঁজা | O(log n) | O(h): গড়ে O(log n), worst O(n) | O(log n) | গড়ে O(1), worst O(n) |
| insert / delete | O(n) (সরানো) | O(h) | O(log n) | গড়ে O(1) |
| min/max, successor | O(1) / O(log n) | O(h) | O(log n) | O(n) |
| সাজানো ক্রমে সব | O(n) | O(n) | O(n) | O(n log n) (আগে সর্ট) |
| floor/ceil, range [l, r] | O(log n) / O(log n + k) | O(h) / O(h + k) | O(log n) / O(log n + k) | সম্ভব নয় (পুরোটা দেখতে হয়) |
| k-তম ছোট | O(1) | size-সহ O(h) | size-সহ O(log n) | O(n) (quickselect) |
| মেমরি | শুধু ডেটা, ঘন | node-প্রতি ২–৩টি pointer | pointer + ভারসাম্যের তথ্য | ফাঁকা ঘর + pointer |
সিদ্ধান্তের সরল নিয়ম: ডেটা একবার বানিয়ে শুধু প্রশ্ন করবেন (বদলাবে না)? সাজানো অ্যারে + binary search — সবচেয়ে কম মেমরি আর cache-বান্ধব। শুধু “আছে কি / মান কত” প্রশ্ন, ক্রমের দরকার নেই? Hash table. বারবার বদলাবে আর ক্রমের প্রশ্নও লাগবে (successor, range, floor, k-তম)? ভারসাম্যপূর্ণ BST. আর সাধারণ, ভারসাম্যহীন BST? শেখা, বোঝা আর ছোট পরীক্ষার জন্য চমৎকার — কিন্তু অজানা ইনপুটের production বা কনটেস্টের কোডে বিপজ্জনক, কারণ অংশ ৬-এর শিকল।
ভারসাম্য — পরের দুই পর্বের প্রতিশ্রুতি
সমাধান হলো এমন BST, যা প্রতিটি insert আর delete-এর পর কয়েকটি ছোট পুনর্বিন্যাস (rotation) করে উচ্চতা O(log n)-এ বেঁধে রাখে — self-balancing BST. প্রথমটি AVL tree (১৯৬২, Georgy Adelson-Velsky আর Evgenii Landis): প্রতিটি node-এর দুই subtree-র উচ্চতার পার্থক্য সর্বোচ্চ ১ — পর্ব ২৮. পরে আসে red-black tree (১৯৭৮ সালে Leonidas Guibas আর Robert Sedgewick-এর রূপ, Rudolf Bayer-এর ১৯৭২ সালের কাজের ওপর ভিত্তি করে): ভারসাম্যের নিয়ম একটু ঢিলে, কিন্তু insert/delete-এ rotation কম — পর্ব ২৯. Search, successor, floor, range, k-তম — এই পর্বের প্রতিটি অ্যালগরিদম সেগুলোতে হুবহু একই থাকে; শুধু insert আর delete-এর শেষে সারানোর ধাপ যোগ হয়।
আপনি হয়তো ইতিমধ্যে ব্যবহার করছেন। পর্ব ১৫-এর std::set, std::map, std::multiset হলো ভারসাম্যপূর্ণ BST — C++ standard শুধু O(log n) চায়, আর প্রধান বাস্তবায়নগুলো (GCC-র libstdc++, LLVM-এর libc++, Microsoft-এর STL) red-black tree ব্যবহার করে। এই পর্বের কাজগুলোর নাম সেখানে: insert, find, erase, begin() (min), prev(end()) (max), iterator ++/-- (successor/predecessor), lower_bound(x) (ceil), upper_bound(x) আর এক ধাপ পেছনে (floor). Java-র TreeMap/TreeSet-ও red-black tree. Python-এর standard library-তে কোনো ভারসাম্যপূর্ণ BST নেই; প্রচলিত বিকল্প হলো bisect দিয়ে সাজানো list (insert O(n), কিন্তু ছোট n-এ দ্রুত) বা তৃতীয় পক্ষের sortedcontainers প্যাকেজ — কনটেস্টের judge-এ সাধারণত শুধু standard library থাকে।
#include <set>
std::set<int> s{35, 40, 45, 60, 65, 70, 80}; // a red-black tree inside
auto it = s.lower_bound(50); // ceil(50): first key >= 50 -> 60
auto fl = s.upper_bound(50); // first key > 50 ...
if (fl != s.begin()) --fl; // ... one step back: floor(50) -> 45
int lo = *s.begin(), hi = *s.rbegin(); // min 35, max 80 (set must be non-empty)
for (auto p = s.lower_bound(40); p != s.end() && *p <= 65; ++p) { /* range [40, 65] */ }
// std::distance(s.begin(), it) would give the rank, but it walks: O(n), not O(log n)শেষ মন্তব্যটি গুরুত্বপূর্ণ: std::set-এ subtree-র size রাখা হয় না, তাই k-তম ছোট বা rank O(log n)-এ পাওয়া যায় না — std::distance বা std::next(begin, k) এক এক করে হাঁটে। কনটেস্টে এর জন্য GCC-র একটি এক্সটেনশন ব্যবহার করা হয় (নিচে “আরও গভীরে”), অথবা key-এর পরিসর ছোট হলে Fenwick tree বা segment tree (পর্ব ৩৪–৩৬)।
আরও গভীরে: GCC-র policy-based data structures (__gnu_pbds::tree, tree_order_statistics_node_update সহ) ঠিক এই পর্বের order-statistic tree — একটি red-black tree যার প্রতিটি node-এ subtree-র আকার রাখা; find_by_order(k) (0-based k-তম) আর order_of_key(x) (x-এর চেয়ে ছোট কয়টি) দুটোই O(log n). এটি standard C++ নয়, শুধু GCC-তে চলে, তবে কনটেস্টে খুব জনপ্রিয়। আরেকটি দিক: Sleator আর Tarjan-এর splay tree (১৯৮৫) কোনো ভারসাম্যের তথ্য রাখে না, প্রতিটি খোঁজার পর পাওয়া node-কে rotation দিয়ে root-এ তোলে, আর তবু amortized O(log n) দেয় — ঘনঘন খোঁজা key-গুলো ওপরে থাকে বলে বাস্তবের অসম চাহিদায় তা আরও দ্রুত হতে পারে।
পরীক্ষা ও ইন্টারভিউতে:
- BST-তে search, insert, delete-এর time complexity? — O(h); ভারসাম্যে O(log n), worst case (শিকল) O(n).
- দুই সন্তানের node delete করবেন কীভাবে? — inorder successor (ডান subtree-র minimum) দিয়ে বদলান; successor-এর বাঁ সন্তান নেই, তাই তাকে মোছা সহজ অবস্থা।
- BST-এর inorder traversal কী দেয়? — key-গুলো ছোট থেকে বড় সাজানো ক্রমে; প্রমাণ subtree-র ওপর induction.
- একটি tree BST কি না কীভাবে যাচাই করবেন? — প্রতিটি node-কে পূর্বপুরুষদের দেওয়া (lo, hi) সীমায় মেলান, বা inorder কঠোরভাবে বাড়ে কি না দেখুন; শুধু সন্তানের সাথে মেলানো ভুল। O(n).
- BST-তে k-তম ছোট key? — inorder-এ গুনে O(h + k); প্রতিটি node-এ subtree size রাখলে O(h).
১০ · AI-এর যুগে BST — কোড চলে, নিয়ম ভাঙে
“একটি BST-র delete লিখে দাও” — AI প্রায় সবসময় পরিপাটি কোড দেবে, আর leaf মোছার ছোট পরীক্ষায় সেটি চলবেও। ভুলগুলো লুকিয়ে থাকে কম-ঘটা শাখায়: দুই-সন্তানের delete-এ successor-কে ডান subtree থেকে না মুছে আবার root থেকে মোছা (খোঁজা তখন কপি-করা key নিয়ে z-এর ঘরেই থামে — ভুল node মোছে বা recursion থামে না), root মুছলে নতুন root ফেরত না দেওয়া, বা BST যাচাইয়ে শুধু সন্তানের সাথে মেলানো। আর complexity জিজ্ঞেস করলে AI প্রায়ই নিশ্চিন্তে বলে “BST-তে search O(log n)” — শর্তটি (ভারসাম্য) বাদ দিয়ে।
BST-এর কোডে AI সাধারণত যেসব ভুল করে
| AI-এর ভুল | কীভাবে ধরবেন |
|---|---|
Validate-এ শুধু left < node < right | অংশ ৭-এর 10 / 5, 15 / 6, 20 tree দিন — “BST” বললে ভুল |
| “BST search সবসময় O(log n)” | সাজানো ক্রমে 105 key insert করে সময় মাপুন; উচ্চতা ছাপুন |
| Delete-এর পর নতুন root ফেরত দেয় না (root মুছলে tree হারায়) | শুধু root-কে বারবার মুছে inorder ছাপুন, যতক্ষণ না tree খালি হয় |
| দুই-সন্তানের delete-এ successor-এর ডান সন্তান হারিয়ে যায় | যে successor-এর ডান সন্তান আছে এমন tree নিন (যেমন অংশ ৫-এর 60 → 65) |
| Insert আর search-এ ডুপ্লিকেটের নীতি আলাদা (একটিতে <, অন্যটিতে ≤) | একই key দুবার insert করে find আর count পরীক্ষা করুন |
Validate-এ INT_MIN/INT_MAX সীমা | key = INT_MIN বা INT_MAX সহ একটি বৈধ tree দিন |
| Python-এ গভীর recursion | ১০,০০০ সাজানো key দিয়ে চালান — RecursionError এলে লুপে লিখুন |
AI-কে পুরো BST লিখে দিতে না বলে, আপনার কোড ভাঙার চেষ্টা করতে বলুন — আর প্রতিটি দাবির পেছনে একটি ছোট উদাহরণ চান:
BST-এর কোড যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১BST কে আবিষ্কার করেছিলেন — আর Hibbard-এর delete-এর ছোট একটি অসমতা কেন কয়েক দশকের গবেষণার প্রশ্ন হয়ে উঠল?
Binary search tree-র কোনো একক আবিষ্কারক নেই। Donald Knuth তাঁর The Art of Computer Programming-এর তৃতীয় খণ্ডে লিখেছেন যে ধারণাটি ১৯৫০-এর দশকে কয়েকজন আলাদাভাবে পেয়েছিলেন, আর ছাপা আকারে প্রথম দিকের বর্ণনাগুলো আসে ১৯৬০ সালের আশপাশে — P. F. Windley-এর, আর A. D. Booth ও A. J. T. Colin-এর লেখায়। সেই সময় সমস্যাটি ছিল খুব বাস্তব: কম্পিউটারে একটি “symbol table” রাখা — যেমন compiler-এ চলকের নাম আর তার ঠিকানা — যেখানে নতুন নাম বারবার আসে আর খোঁজাও চলে। সাজানো তালিকায় যোগ করা ধীর, অসাজানোতে খোঁজা ধীর; tree দুটোকেই মেলায়।
১৯৬২ সালে Thomas N. Hibbard একটি প্রবন্ধে BST-এর গাণিতিক বিশ্লেষণ করেন, আর সেখানেই এই পর্বের delete পদ্ধতি আসে: দুই সন্তান হলে successor দিয়ে বদলানো। Hibbard একটি সুন্দর ফলও প্রমাণ করেছিলেন — একটি এলোমেলো BST থেকে একটি এলোমেলো key মুছলে বাকি tree-টিও “এলোমেলো” থাকে, অর্থাৎ গড় আকারের বিশ্লেষণ বজায় থাকে। অনেকে ধরে নিয়েছিলেন, তাহলে বারবার insert আর delete চালালেও tree গড়ে ভালো থাকবে।
কিন্তু পরে দেখা গেল ব্যাপারটি এত সরল নয়। ১৯৭০-এর দশকে Gary Knott দেখান যে insert আর delete মিশিয়ে চালালে এলোমেলোতা আর আগের মতো থাকে না। ১৯৮৩ সালে Jeffrey Eppinger কম্পিউটারে বড় পরীক্ষা চালিয়ে দেখান: খুব লম্বা সময় ধরে এলোমেলো insert–delete জোড়া চালালে, সবসময় successor নেওয়ার কারণে tree ধীরে ধীরে একদিকে হেলে যায়, আর গড় পথ লম্বা হতে থাকে। কারণটি অসমতা — প্রতিবার ডান subtree থেকে একটি node সরে বাঁয়ের দিকে আসে। পরবর্তী গবেষণায় কিছু মডেলে গড় খোঁজার খরচ √n-এর মতো বাড়ার প্রমাণও এসেছে। একটি সহজ প্রতিকার প্রস্তাব করা হয়েছে: successor আর predecessor-এর মধ্যে পালা করে বা এলোমেলোভাবে বাছাই করা।
বাস্তবে এই প্রশ্নের গুরুত্ব কমেছে, কারণ গুরুত্বপূর্ণ জায়গায় ভারসাম্যপূর্ণ tree ব্যবহার হয়, যেখানে উচ্চতা প্রতিটি কাজের পর নিশ্চিতভাবে O(log n) থাকে। কিন্তু গল্পটি অ্যালগরিদম বিশ্লেষণের একটি বড় শিক্ষা দেয়: একটি কাজের গড় বিশ্লেষণ ঠিক হলেও, কাজগুলোর ক্রম মিলে ফল বদলে যেতে পারে। আর ছোট একটি নকশার সিদ্ধান্ত — বাঁ না ডান — দীর্ঘমেয়াদে বড় প্রভাব ফেলতে পারে।
মূল উপলব্ধি: BST জন্মেছিল symbol table-এর বাস্তব চাহিদা থেকে, একাধিক মানুষের হাতে। আর Hibbard deletion-এর ইতিহাস দেখায় যে “গড়ে ভালো” দাবি করার আগে কোন ধরনের কাজের ক্রমের ওপর সেই গড়, তা স্পষ্ট করতে হয়।
প্র ০২এলোমেলো ক্রমে BST বানানো আর quicksort আসলে একই তুলনা করে — কেন, আর এ থেকে BST-এর গড় খরচ সম্পর্কে কী জানা যায়?
ধরুন key-গুলো একটি ক্রমে আসে: 50, 30, 70, 20, 40, 60, 80. BST বানানোর সময় প্রথম key 50 root হয়, আর পরের প্রতিটি key 50-এর সাথে একবার তুলনা হয় — ছোটরা বাঁয়ে, বড়রা ডানে যায়। এবার quicksort ভাবুন, যেখানে প্রতিবার প্রথম উপাদানকে pivot নেওয়া হয়: প্রথম pivot 50, বাকি সবাই 50-এর সাথে একবার তুলনা হয়ে দুই ভাগে যায়। বাঁ ভাগে প্রথম আসে 30 — BST-তে সে বাঁ subtree-র root, quicksort-এ বাঁ ভাগের pivot. এভাবে প্রতিটি স্তরে দুটো প্রক্রিয়া একই জোড়া key তুলনা করে, শুধু ভিন্ন সময়ে। ফলে BST বানানোর মোট তুলনা = quicksort-এর মোট তুলনা, আর BST-র আকারটিই quicksort-এর recursion tree.
এই মিল থেকে গড় খরচ সরাসরি পাওয়া যায়। পর্ব ২২-এ দেখেছেন, এলোমেলো ক্রমে quicksort-এর প্রত্যাশিত তুলনা প্রায় 2n ln n ≈ 1.39 n log₂ n. সুন্দর যুক্তিটি এরকম: সাজানো ক্রমে i-তম আর j-তম key তুলনা হয় ঠিক তখনই, যখন তাদের মাঝের (i থেকে j পর্যন্ত) j − i + 1 টি key-এর মধ্যে এই দুজনের একজন প্রথম আসে — সম্ভাবনা 2/(j − i + 1). সব জোড়ায় যোগ করলে প্রায় 2n ln n. BST-র দিক থেকে, কোনো node-এর depth হলো সে কয়টি পূর্বপুরুষের সাথে তুলনা হয়েছে, তাই সব node-এর depth-এর যোগফল (internal path length) প্রায় 2n ln n, আর গড় depth প্রায় 2 ln n. অর্থাৎ এলোমেলো BST-তে সফল খোঁজা গড়ে প্রায় 1.39 log₂ n তুলনা নেয় — নিখুঁত ভারসাম্যের চেয়ে মাত্র প্রায় ৩৯% বেশি।
উচ্চতা অবশ্য গড় depth-এর চেয়ে বেশি। ১৯৭৯ সালে J. M. Robson বিভিন্ন সীমা দেন, আর ১৯৮৬ সালে Luc Devroye প্রমাণ করেন যে এলোমেলো BST-এর উচ্চতা প্রায় c ln n, যেখানে c ≈ 4.311; পরে Bruce Reed (২০০৩) এর আরও সূক্ষ্ম রূপ দেন। log₂-এ লিখলে উচ্চতা প্রায় 2.99 log₂ n — ভারসাম্যপূর্ণ tree-র প্রায় তিন গুণ, কিন্তু তবু logarithmic.
এই মিলের একটি ব্যবহারিক শিক্ষা আছে। Quicksort-এ আমরা খারাপ ইনপুটের ভয় কাটাই pivot এলোমেলোভাবে বেছে — randomized quicksort. BST-তে ইনপুটের ক্রম আমাদের হাতে নেই (key যেমন আসে তেমন insert করতে হয়), তাই এলোমেলোতা আনতে হয় অন্যভাবে: প্রতিটি node-কে একটি এলোমেলো “priority” দিয়ে, আর heap-এর নিয়মে সেই priority মানিয়ে tree সাজিয়ে — এটিই treap (Raimund Seidel ও Cecilia Aragon, ১৯৮৯; পর্ব ৩৮)। তখন tree-র আকার ইনপুটের ক্রম নয়, নিজের মুদ্রা-নিক্ষেপের ওপর নির্ভর করে।
মূল উপলব্ধি: BST আর quicksort একই divide-and-conquer-এর দুই রূপ — একটি স্থির ডেটা স্ট্রাকচার, অন্যটি একবারের অ্যালগরিদম। একটির বিশ্লেষণ অন্যটিকেও ব্যাখ্যা করে।
প্র ০৩C++-এর std::map কেন hash table নয়, red-black tree — আর Java-র HashMap কেন বিপদে পড়লে নিজেই tree হয়ে যায়?
১৯৯০-এর দশকে Alexander Stepanov আর সহকর্মীরা যখন STL নকশা করেন, তখন map আর set-এর জন্য তাঁরা দিয়েছিলেন ক্রমের নিশ্চয়তা: iterator দিয়ে ঘুরলে key-গুলো সাজানো ক্রমে আসবে, lower_bound কাজ করবে, আর প্রতিটি কাজ worst case-এও O(log n). Hash table এর কোনোটিই দিতে পারে না — তার ভেতরে কোনো ক্রম নেই, আর খারাপ hash-এ worst case O(n). তাই standard একটি ভারসাম্যপূর্ণ BST-র মতো আচরণ চায়, আর প্রায় সব বাস্তবায়ন বেছে নেয় red-black tree. Hash-ভিত্তিক unordered_map standard-এ আসে অনেক পরে, C++11-এ।
কেন red-black, AVL নয়? AVL-এর ভারসাম্য বেশি কঠোর, তাই খোঁজা সামান্য দ্রুত; কিন্তু insert আর delete-এর পর rotation বেশি হতে পারে, বিশেষ করে delete-এ পথ ধরে অনেকগুলো। Red-black tree প্রতিটি insert-এ সর্বোচ্চ দুটি আর delete-এ সর্বোচ্চ তিনটি rotation করে, যা একটি সাধারণ-উদ্দেশ্যের লাইব্রেরিতে যেখানে পরিবর্তন আর খোঁজা দুটোই চলে, একটি ভালো আপস। (পর্ব ২৯-এ বিস্তারিত।)
এবার উল্টো দিকের গল্প। Hash table গড়ে O(1), কিন্তু কেউ যদি ইচ্ছে করে এমন অনেক key পাঠায় যাদের hash একই — তাহলে সব একটি bucket-এ জমে, আর প্রতিটি খোঁজা O(n). ২০১১ সালের শেষে নিরাপত্তা গবেষকেরা জনসমক্ষে দেখান যে বহু ওয়েব প্ল্যাটফর্মে একটি মাত্র HTTP request-এর form field দিয়ে এভাবে সার্ভারকে মিনিটের পর মিনিট ব্যস্ত রাখা যায় — “hash flooding” আক্রমণ। এর একটি প্রতিকার হিসেবে Java 8 (২০১৪)-এর HashMap-এ একটি কৌশল আসে: কোনো একটি bucket-এ উপাদান ৮টির বেশি হলে (আর table যথেষ্ট বড় হলে), সেই bucket-এর linked list-কে একটি red-black tree-তে বদলে ফেলা হয়। তখন সবচেয়ে খারাপ bucket-এও খোঁজা O(log n). অর্থাৎ hash table-এর ভেতরেই BST বসে থাকে নিরাপত্তা-জাল হিসেবে।
কনটেস্টেও একই শিক্ষা। Codeforces-এর মতো প্ল্যাটফর্মে অংশগ্রহণকারীরা মাঝে মাঝে অন্যের unordered_map ভাঙতে বিশেষ anti-hash test বানান (পর্ব ১৫), আর তখন std::map-এর নিশ্চিত O(log n) — যদিও ধ্রুবক বড় — অনেক সময় নিরাপদ পছন্দ।
মূল উপলব্ধি: ডেটা স্ট্রাকচার বাছাই শুধু গড় গতির প্রশ্ন নয় — ক্রমের দরকার আছে কি না, আর worst case-এর নিশ্চয়তা কতটা জরুরি, সেটিও। ভারসাম্যপূর্ণ BST ঠিক এই দুটি দেয়।
প্র ০৪ডেটাবেস কেন binary search tree নয়, B-tree ব্যবহার করে — যেখানে দুটোরই খরচ O(log n)?
Big-O-তে দুটো এক, কিন্তু log-এর ভিত্তি আর প্রতিটি ধাপের খরচ আলাদা — আর ডেটাবেসে সেটিই নির্ধারক। ধরুন একটি ব্যাংকের ১০ কোটি গ্রাহকের রেকর্ড, account number ধরে index. ভারসাম্যপূর্ণ BST-তে উচ্চতা প্রায় log₂(108) ≈ ২৭. মেমরিতে এটি কোনো সমস্যা নয় — ২৭টি pointer অনুসরণ, কয়েকশো ন্যানোসেকেন্ড। কিন্তু এত বড় index সাধারণত পুরোটা মেমরিতে ধরে না; তা থাকে disk-এ।
Disk থেকে পড়ার একক হলো একটি page বা block — সাধারণত কয়েক কিলোবাইট — আর প্রতিটি আলাদা জায়গা থেকে পড়া মেমরির তুলনায় হাজার থেকে লক্ষ গুণ ধীর (পুরোনো ঘূর্ণায়মান hard disk-এ কয়েক মিলিসেকেন্ড, SSD-তে কম, কিন্তু তবু অনেক)। BST-এর প্রতিটি node একটি key আর দুটি pointer — কয়েক ডজন byte — অথচ সেটি পড়তে পুরো একটি page আনতে হয়। ২৭টি স্তর মানে প্রায় ২৭টি আলাদা disk পড়া। অপচয়টি স্পষ্ট: প্রতিবার একটি page-এর প্রায় পুরোটা কাজে লাগে না।
Rudolf Bayer আর Edward McCreight ১৯৭০-এর দশকের শুরুতে এর সমাধান দেন B-tree দিয়ে: প্রতিটি node একটি পুরো page, যেখানে শত শত key আর তাদের মাঝের শত শত সন্তানের pointer. ধরুন প্রতি node-এ ৫০০টি সন্তান — তাহলে উচ্চতা log₅₀₀(108) ≈ ৩. আর ওপরের এক-দুটি স্তর সাধারণত মেমরিতেই থাকে, তাই একটি খোঁজায় হয়তো এক-দুটি disk পড়া। Node-এর ভেতরে key-গুলো সাজানো, সেখানে binary search — মেমরিতে, সস্তা। অর্থাৎ B-tree একটি “মোটা” BST: প্রতিটি node-এ অনেক key, যাতে tree খুব চওড়া আর খুব নিচু হয়।
B+ tree রূপে (পর্ব ৩০) সব আসল রেকর্ড থাকে leaf-এ, আর leaf-গুলো পাশাপাশি জোড়া থাকে — তাই এই পর্বের range query (“২০ থেকে ৩০ বছর বয়সী সবাই”) শুরুর leaf খুঁজে পেয়ে তারপর শুধু পাশে হাঁটে, প্রায় ক্রমানুসারে disk পড়ে। আজকের প্রধান relational ডেটাবেসের index (যেমন MySQL-এর InnoDB, PostgreSQL-এর ডিফল্ট index) এই পরিবারের। আর একই যুক্তি মেমরিতেও ছোট আকারে খাটে: CPU cache line-এর কারণে মেমরিতেও এক node-এ কয়েকটি key রাখা tree অনেক সময় সাধারণ BST-র চেয়ে দ্রুত।
মূল উপলব্ধি: O(log n)-এর log-এর ভিত্তি আর প্রতিটি ধাপের বাস্তব খরচ — হার্ডওয়্যারের সাথে মিলিয়ে — ঠিক করে কোন tree জিতবে। BST শেখায় নীতিটি; B-tree সেই নীতিকে disk-এর মাপে বানায়।
প্র ০৫n টি key দিয়ে কয়টি আলাদা BST বানানো যায় — আর খোঁজার সম্ভাবনা জানা থাকলে “সেরা” BST কোনটি?
প্রথম প্রশ্নটি গোনার। n টি আলাদা key দিয়ে BST-এর সংখ্যা b(n) বের করতে root বাছুন: সাজানো ক্রমে i-তম key root হলে বাঁয়ে বাকি i − 1 টি ছোট key দিয়ে যেকোনো BST, ডানে n − i টি বড় key দিয়ে যেকোনো BST. তাই b(n) = Σi=1..n b(i − 1)·b(n − i), b(0) = 1. এটি বিখ্যাত Catalan সংখ্যার recurrence: 1, 1, 2, 5, 14, 42, … আর বন্ধ রূপ b(n) = C(2n, n)/(n + 1). সংখ্যাটির নাম বেলজিয়ান গণিতবিদ Eugène Catalan-এর নামে, যদিও এই সংখ্যাগুলো আগেও অন্যদের লেখায় এসেছিল। বৃদ্ধি প্রায় 4n/(n3/2√π) — দ্রুত বাড়ে, তাই সব আকার পরীক্ষা করে সেরাটি বাছা অসম্ভব। লক্ষ করুন, n! টি insert-ক্রম কিন্তু মাত্র Catalan সংখ্যক আকার — অনেক ক্রম একই tree দেয়, আর ভারসাম্যপূর্ণ আকারগুলো বেশি ক্রম থেকে আসে; এজন্যই এলোমেলো ক্রমে tree গড়ে ভালো হয়।
দ্বিতীয় প্রশ্নটি নকশার। ধরুন একটি অভিধান অ্যাপে কিছু শব্দ খুব বেশি খোঁজা হয় (“the”, “and”), কিছু কালেভদ্রে। সবার জন্য সমান উচ্চতা ভালো, নাকি ঘনঘন খোঁজা শব্দগুলোকে root-এর কাছে রাখা? প্রতিটি key-এর খোঁজার সম্ভাবনা জানা থাকলে, প্রত্যাশিত তুলনা-সংখ্যা সবচেয়ে কম এমন tree-কে বলে optimal BST. একে dynamic programming দিয়ে বের করা যায় (পর্ব ৫২-এর পরের DP পর্বগুলোতে এই ধাঁচ): key i থেকে j-এর সেরা tree-র খরচ = সেরা root বাছাই, যার বাঁ আর ডান অংশ নিজেরাও সেরা। সরল DP-তে O(n³) সময়। ১৯৭১ সালে Donald Knuth দেখান যে সেরা root-এর অবস্থান একটি একমুখী (monotone) নিয়ম মানে, যা দিয়ে খোঁজার পরিসর ছোট করে সময় নামে O(n²)-এ — পরে এই ধরনের কৌশল “Knuth optimization” নামে কনটেস্টেও পরিচিত হয়।
কিন্তু সম্ভাবনা আগে থেকে জানা না থাকলে? ১৯৮৫ সালে Daniel Sleator আর Robert Tarjan-এর splay tree প্রতিটি খোঁজার পর পাওয়া node-কে root-এ তুলে নিজেকে চাহিদার সাথে মানিয়ে নেয়, আর তাঁরা প্রমাণ করেন যে লম্বা সময়ে এটি স্থির optimal BST-র চেয়ে একটি ধ্রুবক গুণের বেশি খারাপ নয়। তাঁরা আরও এক ধাপ এগিয়ে একটি অনুমান (conjecture) দেন — “dynamic optimality”: splay tree যেকোনো BST অ্যালগরিদমের সমান ভালো, ধ্রুবক গুণ পর্যন্ত। প্রায় চার দশক পরেও এটি প্রমাণ বা খণ্ডন হয়নি — ডেটা স্ট্রাকচারের সবচেয়ে বিখ্যাত খোলা প্রশ্নগুলোর একটি।
মূল উপলব্ধি: “সেরা BST” প্রশ্নটি গোনা (Catalan), অপ্টিমাইজেশন (DP) আর অনলাইন অভিযোজন (splay tree) — তিনটি ভিন্ন শাখাকে এক জায়গায় আনে, আর তার একটি অংশ আজও অমীমাংসিত।
অনুশীলন
খাতা-কলম নিন। প্রথম তিনটি হাতে করার, শেষটিতে কোড লিখতে হবে।
-
হাতে trace করুন: খালি BST-তে পরপর insert করুন 8, 3, 10, 1, 6, 14, 4, 7, 13. tree-টি আঁকুন, inorder লিখুন, উচ্চতা বলুন। তারপর 3 মুছুন (কোন অবস্থা?) আর নতুন tree আঁকুন।
8 root; 3 বাঁয়ে, 10 ডানে; 1 হলো 3-এর বাঁ, 6 হলো 3-এর ডান; 14 হলো 10-এর ডান; 4 হলো 6-এর বাঁ, 7 হলো 6-এর ডান; 13 হলো 14-এর বাঁ। inorder: 1 3 4 6 7 8 10 13 14. উচ্চতা ৩ (দুটি পথ: 8 → 3 → 6 → 4 বা 7, আর 8 → 10 → 14 → 13).
8 8 / \ / \ 3 10 3 মোছার 4 10 / \ \ পর / \ \ 1 6 14 1 6 14 / \ / \ / 4 7 13 7 133-এর দুটি সন্তান — অবস্থা ৩. successor = 3-এর ডান subtree-র minimum: 6-এ গিয়ে বাঁয়ে 4, 4-এর বাঁ নেই → 4. 4 কপি হলো 3-এর ঘরে, আর পুরোনো 4 (একটি leaf) মোছা হলো। নতুন inorder: 1 4 6 7 8 10 13 14.
-
Successor আর predecessor: প্রশ্ন ১-এর মূল tree-তে (3 মোছার আগে) succ(7), pred(10), pred(4) আর succ(14) বের করুন, আর প্রতিটির ক্ষেত্রে বলুন কোন অবস্থা (subtree আছে / নেই) কাজে লাগল।
succ(7): 7-এর ডান subtree নেই। ওপরে উঠুন: 7 হলো 6-এর ডান সন্তান → উঠুন; 6 হলো 3-এর ডান সন্তান → উঠুন; 3 হলো 8-এর বাঁ সন্তান → থামুন। উত্তর 8. pred(10): 10-এর বাঁ subtree নেই; 10 হলো 8-এর ডান সন্তান — প্রথম ডান-সন্তান-সম্পর্কেই থামা, উত্তর 8.
pred(4): বাঁ subtree নেই; 4 হলো 6-এর বাঁ সন্তান → উঠুন; 6 হলো 3-এর ডান সন্তান → থামুন, উত্তর 3. succ(14): ডান subtree নেই; 14 হলো 10-এর ডান, 10 হলো 8-এর ডান, 8 root — কোথাও বাঁ-সন্তান-সম্পর্ক পাওয়া গেল না: 14 সবচেয়ে বড়, successor নেই. inorder 1 3 4 6 7 8 10 13 14-এর সাথে চারটিই মিলিয়ে দেখুন।
-
ভেবে বলুন: একটি BST-তে 50 খোঁজার সময় দেখা key-গুলোর ক্রম নিচের কোনটি হতে পারে, আর কোনটি অসম্ভব? (ক) 90, 20, 80, 30, 60, 40, 50 (খ) 90, 20, 80, 30, 70, 85, 50
কৌশল: অংশ ৭-এর সীমা ধরে হাঁটুন। প্রতিটি দেখা key-এর পর 50 কোন দিকে যায়, তা সম্ভাব্য পরিসর (lo, hi) ছোট করে; পরের প্রতিটি key-কে এই পরিসরের ভেতরে থাকতেই হবে।
(ক) 90 → 50 ছোট, পরিসর (−∞, 90); 20 → (20, 90); 80 → (20, 80); 30 → (30, 80); 60 → (30, 60); 40 → (40, 60); 50 পাওয়া গেল। প্রতিটি key আগের পরিসরের ভেতরে — সম্ভব. (খ) 70-এর পর পরিসর (30, 70); পরের key 85 এই পরিসরের বাইরে — 85 কখনো 70-এর বাঁ subtree-তে থাকতে পারে না। অসম্ভব. এই একই যুক্তিই BST যাচাইয়ের সীমা-পদ্ধতি।
-
কোড লিখুন: একটি BST-র root আর একটি সংখ্যা x দেওয়া আছে।
floorKey(root, x)(x-এর সমান বা ছোট সবচেয়ে বড় key) আরceilKey(root, x)লিখুন, recursion ছাড়া; উত্তর না থাকলে একটি বিশেষ মান ফেরত দিন। time আর space complexity বলুন, আর অংশ ৫-এর শেষ tree-তে floor(64) ও ceil(81) হাতে বের করুন।পথে প্রার্থী মনে রাখুন। floor-এ: t.key ≤ x হলে t একজন প্রার্থী, আরও বড় প্রার্থী খুঁজতে ডানে যান; নইলে বাঁয়ে। ceil হুবহু আয়না।
#include <optional> struct Node { int key; Node *l = nullptr, *r = nullptr; }; std::optional<int> floorKey(Node* t, int x) { std::optional<int> best; // empty = no key <= x while (t) { if (t->key == x) return x; if (t->key < x) { best = t->key; t = t->r; } // candidate; look for a larger one else t = t->l; } return best; } std::optional<int> ceilKey(Node* t, int x) { std::optional<int> best; while (t) { if (t->key == x) return x; if (t->key > x) { best = t->key; t = t->l; } else t = t->r; } return best; }প্রতিটি লুপ root থেকে একটি পথ ধরে নামে: সময় O(h), বাড়তি জায়গা O(1). শেষ tree (60; 40 → 35, 45; 70 → 65, 80)-এ floor(64): 60 < 64 → প্রার্থী 60, ডানে; 70 > 64 → বাঁয়ে; 65 > 64 → বাঁয়ে; null → 60. ceil(81): 60 < 81 → ডানে; 70 < 81 → ডানে; 80 < 81 → ডানে; null, কোনো প্রার্থী নেই → উত্তর নেই (81-এর সমান বা বড় key নেই)।
std::optional“উত্তর নেই” অবস্থাকে নিরাপদে প্রকাশ করে, −1-এর মতো জাদুকরী মানের ঝুঁকি ছাড়া।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমBST থেকে দুই সন্তানওয়ালা একটি node z মোছার (successor পদ্ধতি) ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- root থেকে খুঁজে key-টির node z বের করা
- z-এর ডান সন্তানে এক ধাপ যাওয়া
- সেখান থেকে যতক্ষণ পারা যায় বাঁয়ে গিয়ে successor s বের করা
- s-এর key z-এ কপি করা
- z-এর ডান subtree থেকে s-এর পুরোনো node মুছে ফেলা (তার বাঁ সন্তান নেই)
একটি binary tree-তে root 10, তার বাঁ সন্তান 5, ডান সন্তান 15; আর 15-এর বাঁ সন্তান 6, ডান সন্তান 20. এটি কি binary search tree?
- হ্যাঁ, কারণ প্রতিটি node তার বাঁ সন্তানের চেয়ে বড় আর ডান সন্তানের চেয়ে ছোট
- না, কারণ 6 আছে 10-এর ডান subtree-তে অথচ 6 < 10
- না, কারণ 5-এর কোনো সন্তান নেই
- না, কারণ tree-টি ভারসাম্যপূর্ণ নয়
একটি খালি BST-তে নিচের কোন ক্রমে key insert করলে tree-র উচ্চতা সবচেয়ে বেশি হবে?
- 4, 2, 6, 1, 3, 5, 7
- 3, 1, 5, 2, 4, 6, 7
- 7, 6, 5, 4, 3, 2, 1
- 4, 1, 7, 2, 6, 3, 5
BST-তে x নামের একটি node-এর ডান subtree নেই। x-এর inorder successor কোথায় পাওয়া যাবে (যদি থাকে)?
- সবচেয়ে কাছের সেই পূর্বপুরুষ, যার বাঁ subtree-তে x আছে
- x-এর বাঁ subtree-র maximum
- সবসময় x-এর parent
- root-এর ডান subtree-র minimum
n টি আলাদা key আগে থেকে ছোট থেকে বড় সাজানো ক্রমে একে একে একটি সাধারণ (ভারসাম্যহীন) BST-তে insert করলে মোট সময় কত?
- Θ(n log n)
- Θ(n)
- Θ(log n)
- Θ(n²)
প্রতিটি node-এ subtree-র আকার (size) রাখা একটি BST-তে root-এর বাঁ subtree-র size 4. সবচেয়ে ছোট key-কে ১ম ধরলে, 6-তম ছোট key কোথায় খুঁজতে হবে?
- বাঁ subtree-তে 6-তম ছোট হিসেবে
- ডান subtree-তে 1-ম ছোট (ডান subtree-র minimum) হিসেবে
- root নিজেই উত্তর
- ডান subtree-তে 2-য় ছোট হিসেবে
একটি binary tree BST কি না যাচাইয়ের কোন পদ্ধতিটি সব ক্ষেত্রে সঠিক (key-গুলো আলাদা ধরে)?
- প্রতিটি node-কে শুধু তার বাঁ ও ডান সন্তানের সাথে তুলনা করা
- প্রতিটি node-কে শুধু root-এর সাথে তুলনা করা
- ওপর থেকে (lo, hi) সীমা নামিয়ে প্রতিটি key সেই সীমার মধ্যে আছে কি না দেখা
- preorder traversal সাজানো ক্রমে আছে কি না দেখা
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- BST property: বাঁ subtree-র সব key < node < ডান subtree-র সব key. ডুপ্লিকেটের নীতি একবার ঠিক করে সব জায়গায় মানুন।
- Search, insert, min/max, successor, delete, floor/ceil — সবই একটি পথ ধরে, খরচ O(h). Insert সবসময় নতুন leaf যোগ করে।
- Inorder = সাজানো ক্রম (induction). Successor: ডান subtree থাকলে তার minimum, না থাকলে প্রথম যে পূর্বপুরুষের বাঁ দিকে আছেন।
- Delete: leaf → কেটে ফেলুন; এক সন্তান → সন্তানকে তুলুন; দুই সন্তান → inorder successor দিয়ে বদলে তাকে মুছুন।
- ⌊log₂ n⌋ ≤ h ≤ n − 1; সাজানো ইনপুটে শিকল, Θ(n²) বানাতে। যাচাই সীমা দিয়ে, শুধু সন্তান দেখে নয়। ভারসাম্যের জন্য AVL/red-black —
std::set/map.
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ২৮ · AVL tree — নিজে নিজে ভারসাম্য রাখাপরবর্তী পর্বrotation দিয়ে উচ্চতা O(log n)-এ বেঁধে রাখা।
- ডেটা স্ট্রাকচার ও অ্যালগরিদম · কোর্স হোম৭২টি পর্বপুরো কোর্সের রোডম্যাপ — নয়টি মডিউল।
- পর্ব ২৬ · Tree — পরিভাষা, উপস্থাপন আর traversalআগের ভিত্তিnode, depth, height আর inorder/preorder/postorder.
- পর্ব ১৮ · Linear আর binary searchআগের ভিত্তিসাজানো অ্যারেতে অর্ধেক বাদ দেওয়ার কৌশল — BST-এর পূর্বসূরি।
- C++ প্রোগ্রামিং · Associative containerভাষার দিক
set,map,multisetআরlower_bound— ভারসাম্যপূর্ণ BST হাতে-কলমে। - Python প্রোগ্রামিং · অ্যালগরিদম ও কনটেস্টে Pythonপাশের ট্র্যাক
bisectদিয়ে সাজানো list আর Python-এ ক্রমের প্রশ্ন।