সরাসরি পর্বে যান
পর্ব ৩৫ · ৫৬-এর মধ্যে · মডিউল ৫

ট্রি ও BST — ডেটাকে শাখা-প্রশাখায় সাজানো

Trees & Binary Search Trees — Nodes, Search, Traversal, Delete & Balance

Taught in Bangla, this lesson builds trees and binary search trees in C: node structure, the BST ordering rule, searching and inserting, traversal orders, the three cases of deletion, and why balance matters when a tree degenerates into a list.

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

  • ট্রির ভাষা — root, parent, child, leaf, edge, depth, height, subtree
  • C-তে নোড বানানো, মেমরিতে সেটি কেমন দেখায়, আর BST-তে insert ও search কীভাবে কাজ করে
  • চার রকম traversal — কোনটি কখন কাজে লাগে, আর কেন inorder সাজানো ক্রম দেয়
  • Delete-এর তিনটি অবস্থা, আর ট্রি কীভাবে “লাঠি” হয়ে গতি হারায়
  • File system, ডেটাবেস, ব্রাউজার আর compiler-এ ট্রি কোথায় লুকিয়ে আছে — এবং AI-এর BST কোড যাচাইয়ের চেকলিস্ট

১ · চারপাশে সবখানে ট্রি — ফোল্ডার থেকে উপজেলা পর্যন্ত

আগের দুটি পর্বে আমরা linked list, stack আর queue বানিয়েছি। তিনটিই এক সারির কাঠামো — একটির পরে আরেকটি। কিন্তু বাস্তবের অনেক তথ্য এক সারিতে সাজানো যায় না। আপনার কম্পিউটারের D: ড্রাইভ খুলুন। ভেতরে কয়েকটি ফোল্ডার, প্রতিটির ভেতরে আরও ফোল্ডার, তার ভেতরে ফাইল। এটি সারি নয় — এটি শাখা-প্রশাখা।

বাংলাদেশের প্রশাসনিক কাঠামোও ঠিক তাই। দেশ ভাগ হয়েছে ৮টি বিভাগে, বিভাগ ভাগ হয়েছে জেলায় (মোট ৬৪টি), জেলা ভাগ হয়েছে উপজেলায়, উপজেলা ইউনিয়নে। প্রতিটি জেলা ঠিক একটি বিভাগের অধীনে — কোনো জেলা একসাথে দুই বিভাগে থাকে না। এমন ওপর-নিচ সম্পর্ককে বলে hierarchy, আর কম্পিউটারে একে রাখার কাঠামোর নাম tree বা ট্রি।

একটি ট্রি — বাংলাদেশের প্রশাসনের ছোট একটি অংশ

A hierarchy drawn as a tree (only a few branches shown)

বাংলাদেশ                      ← root (সবার ওপরে, কোনো parent নেই)
├── ঢাকা বিভাগ
│   ├── ঢাকা জেলা
│   │   ├── সাভার উপজেলা       ← leaf (এখানে আর ভাগ দেখানো হয়নি)
│   │   └── ধামরাই উপজেলা      ← leaf
│   └── গাজীপুর জেলা           ← leaf
└── রাজশাহী বিভাগ
    └── বগুড়া জেলা
        └── শাজাহানপুর উপজেলা  ← leaf
ছবি ৩৫.১ — প্রতিটি অংশের ঠিক একজন “ওপরের” আছে; শুধু root-এর কেউ নেই।

কম্পিউটারের ট্রি অবশ্য উল্টো করে আঁকা হয় — root থাকে ওপরে, পাতা নিচে। প্রকৃতির গাছের উল্টো, কিন্তু পরিবারের বংশতালিকার মতো: দাদা ওপরে, নাতি-নাতনি নিচে। ট্রির প্রতিটি ঘরকে বলে node, আর দুটি নোডের সংযোগকে বলে edge. নিচের শব্দগুলো এই পর্ব জুড়ে বারবার আসবে:

শব্দমানেওপরের ছবিতে উদাহরণ
Rootসবার ওপরের নোড, যার parent নেইবাংলাদেশ
Parent / childসরাসরি ওপরের নোড / সরাসরি নিচের নোডঢাকা বিভাগ হলো ঢাকা জেলার parent; ঢাকা জেলা তার child
Leafযার কোনো child নেইসাভার, ধামরাই, গাজীপুর, শাজাহানপুর
Edgeparent থেকে child-এর সংযোগঢাকা বিভাগ ─ গাজীপুর জেলা
Depthroot থেকে নিচে নামতে কয়টি edge লাগেবাংলাদেশ = ০, বিভাগ = ১, জেলা = ২, উপজেলা = ৩
Heightroot থেকে সবচেয়ে দূরের leaf পর্যন্ত লম্বা পথ৪টি স্তর (বাংলাদেশ → বিভাগ → জেলা → উপজেলা)
Subtreeএকটি নোড আর তার নিচের সবকিছু“রাজশাহী বিভাগ” আর তার নিচের সব — নিজেই একটি ছোট ট্রি

Height মাপার দুই নিয়ম: কিছু বই height গোনে edge দিয়ে (তখন একটি নোডের ট্রির height শূন্য), কিছু বই গোনে নোড দিয়ে (তখন এক)। দুটোই চলে, শুধু একটি বেছে নিয়ে শেষ পর্যন্ত সেটিই মানতে হয়। এই পর্বে আমরা নোড গুনব — তাই কোডের height() ফাংশন খালি ট্রিতে 0 আর একটি নোডে 1 ফেরত দেবে।

মূল ধারণা

ট্রির সবচেয়ে সুন্দর গুণ হলো: প্রতিটি subtree নিজেই একটি পূর্ণ ট্রি। “রাজশাহী বিভাগ”-কে আলাদা করে তুলে নিলে সেটিও একটি ট্রি, যার root রাজশাহী বিভাগ। এজন্যই ট্রির প্রায় সব কাজ recursion দিয়ে লেখা যায় — “পুরো ট্রিতে কাজটি করো” মানে “root-এ করো, তারপর বাঁ subtree-তে আর ডান subtree-তে একই কাজ করো।” পর্ব ১৫-এ শেখা base case এখানে খালি ট্রি (NULL).

২ · C-তে একটি নোড — ডেটা আর দুটি pointer

সাধারণ ট্রিতে একটি নোডের যতখুশি child থাকতে পারে — ঢাকা বিভাগের নিচে ১৩টি জেলা। কিন্তু প্রোগ্রামিংয়ে সবচেয়ে বেশি ব্যবহৃত রূপটি হলো binary tree, যেখানে প্রতিটি নোডের সর্বোচ্চ দুটি child: একটি বাঁয়ে (left), একটি ডানে (right), এর বেশি নয়। পর্ব ৩৩-এর linked list-এর নোডে পরের নোডের ঠিকানা ছিল একটি; এখানে দুটি। ব্যস, এটুকুই পার্থক্য।

typedef struct Node {
    int key;               // নোডের ডেটা
    struct Node *left;     // বাঁ child-এর ঠিকানা (না থাকলে NULL)
    struct Node *right;    // ডান child-এর ঠিকানা (না থাকলে NULL)
} Node;

Node *root = NULL;         // খালি ট্রি = NULL pointer

এই কাঠামোটি struct — পর্ব ২২-এ শেখা। প্রতিটি নোড তৈরি হয় malloc দিয়ে, তাই নোডগুলো থাকে heap-এ, মেমরির নানা জায়গায় ছড়িয়ে। নোডগুলোকে একসাথে বেঁধে রাখে শুধু pointer. মেমরির ভেতরে ছোট একটি ট্রি দেখতে এমন (ঠিকানাগুলো কাল্পনিক):

কোথায়ঠিকানাkeyleftright
stack-এ ভ্যারিয়েবল root0x7ffc10ভেতরে শুধু একটি ঠিকানা: 0x5a40
heap-এ নোড (root)0x5a40500x5b800x5a20
heap-এ নোড (বাঁ child)0x5b8030NULLNULL
heap-এ নোড (ডান child)0x5a2070NULLNULL

খেয়াল করুন, ডান child-এর ঠিকানা (0x5a20) root-এর চেয়েও ছোট — heap-এ কোন নোড কোথায় বসবে তার কোনো ক্রম নেই। ট্রির “আকার” মেমরির অবস্থানে নেই, আছে শুধু pointer-গুলোর ভেতরে। আর দুটি NULL-ওয়ালা নোডগুলো leaf.

একটি কোম্পানির অফিসের কথা ভাবুন, যেখানে প্রত্যেক কর্মীর ডেস্কে একটি কার্ড আছে: “আমার নিচে কাজ করেন — বাঁয়ে রহিম (৩ তলা, ডেস্ক ১২), ডানে করিম (৭ তলা, ডেস্ক ৪)।” কর্মীরা পুরো ভবনে ছড়িয়ে বসেছেন, কিন্তু কার্ডগুলো ধরে ধরে MD থেকে যেকোনো কর্মীর কাছে পৌঁছানো যায়। কার্ডের ওই ঠিকানাই pointer, আর MD-র ডেস্কের ঠিকানা জানা থাকলেই পুরো অফিস আপনার হাতে — ঠিক যেমন root জানলেই পুরো ট্রি।

একটি নোডের দাম কত? সাধারণ ৬৪-বিট কম্পিউটারে উপরের Node-এর sizeof হয় ২৪ বাইট: int-এর ৪ বাইট, তারপর ৪ বাইট padding, আর দুটি ৮ বাইটের pointer. অর্থাৎ ৪ বাইটের একটি সংখ্যা রাখতে খরচ ২৪ বাইট — ঠিকানাগুলোর দামই বেশি। তার ওপর malloc নিজেও প্রতিটি ব্লকের সাথে কিছু হিসাবের তথ্য রাখে। ট্রির নমনীয়তার দাম এই বাড়তি মেমরি; অ্যারেতে এই খরচ নেই।

৩ · BST-এর একটিই নিয়ম — বাঁয়ে ছোট, ডানে বড়

যেকোনো binary tree-তে মান যেখানে খুশি রাখা যায়। কিন্তু একটি ছোট নিয়ম যোগ করলেই ট্রিটি হয়ে ওঠে অসাধারণ দ্রুত একটি খোঁজার যন্ত্র। নিয়মটির নাম Binary Search Tree বা BST:

প্রতিটি নোডের জন্য:
বাঁ subtree-র সব মান < নোডের মান < ডান subtree-র সব মান

লক্ষ করুন “সব” শব্দটি। শুধু সরাসরি child নয়, নিচের পুরো বংশই নিয়ম মানবে। নতুন মান ঢোকানোর (insert) পদ্ধতি তাই খুব সহজ: root থেকে শুরু করুন। নতুন মান ছোট হলে বাঁয়ে নামুন, বড় হলে ডানে। যেখানে গিয়ে NULL পাবেন, সেখানেই নতুন নোড বসবে। নিচের ডায়াগ্রামে ৫০, ৩০, ৭০, ২০, ৪০, ৬০, ৮০ — এই ক্রমে সাতটি মান ঢোকানো দেখুন। লাল দাগ দেখাবে নতুন মানটি কোন পথে নেমেছে।

50root
30
70
20
40
60
80
এই ধাপের হিসাব
নতুন মান
নামার পথ
মোট নোড
height (নোড গুনে)
  1. ৫০ — প্রথম মান, নিজেই root. ট্রি খালি ছিল (root == NULL), তাই কোনো তুলনা লাগেনি। malloc দিয়ে একটি নোড বানিয়ে root-এ তার ঠিকানা রাখা হলো। এর দুটি pointer-ই এখন NULL.
  2. ৩০ — বাঁয়ে। root-এর সাথে তুলনা: ৩০ ছোট, তাই বাঁয়ে নামতে হবে। ৫০-এর left এখন NULL — অর্থাৎ জায়গা খালি। ৩০ সেখানেই বসল।
  3. ৭০ — ডানে। ৭০ বড়, তাই ৫০-এর ডানে। লক্ষ করুন, ট্রিতে ৩টি নোড কিন্তু height মাত্র ২ — দুই দিকে সমানভাবে ছড়িয়েছে।
  4. ২০ — দুইবার বাঁয়ে। ২০ < ৫০, বাঁয়ে নামলাম, পৌঁছালাম ৩০-এ। ২০ < ৩০, আবার বাঁয়ে — সেখানে NULL, তাই ২০ বসল ৩০-এর বাঁ child হয়ে। দুটি তুলনা লাগল।
  5. ৪০ — বাঁয়ে, তারপর ডানে। ৪০ < ৫০ বলে বাঁয়ে, কিন্তু ৪০ > ৩০ বলে ৩০-এর ডানে। খেয়াল করুন: ৪০ বসেছে ৫০-এর বাঁ subtree-তে, তাই ৫০-এর চেয়ে ছোট হওয়ার নিয়মও মানছে। BST-এর “সব” শর্ত এভাবেই নিজে থেকে রক্ষা পায়।
  6. ৬০ — ডানে, তারপর বাঁয়ে। ৬০ > ৫০, ডানে ৭০-এ। ৬০ < ৭০, তাই ৭০-এর বাঁ child. এখন ৫০-এর ডান subtree-তে ৬০ আর ৭০ — দুটিই ৫০-এর চেয়ে বড়।
  7. ৮০ — দুইবার ডানে। ট্রি সম্পূর্ণ! ৭টি নোড, height মাত্র ৩. প্রতিটি নতুন মান বসাতে সর্বোচ্চ ৩টি নোড পার হতে হয়েছে। এটি একটি নিখুঁত balanced tree — প্রতিটি স্তর পুরো ভরা। এমন সুন্দর আকার এসেছে কারণ ঢোকানোর ক্রমটি ভালো ছিল; অংশ ৭-এ দেখব ক্রম খারাপ হলে কী হয়।

একই মান দুইবার এলে কী হবে? নিয়মে আছে “ছোট” আর “বড়” — “সমান”-এর কথা নেই। এটি একটি সিদ্ধান্ত, যা আপনাকে আগেই নিতে হবে: (ক) দ্বিতীয়বার এলে উপেক্ষা করা (set-এর মতো, প্রতিটি মান একবার), (খ) নোডের ভেতরে একটি count বাড়ানো, অথবা (গ) সবসময় এক দিকে (যেমন ডানে) পাঠানো। যেটিই বেছে নিন, insert, search আর delete — তিনটিতেই একই নিয়ম মানতে হবে। একটিতে < আর অন্যটিতে <= লিখলে ট্রিতে মান থেকেও খুঁজে পাওয়া যাবে না। এই পর্বের কোড পদ্ধতি (ক) মানে।

৪ · খোঁজা — প্রতিটি প্রশ্নে অর্ধেক বাদ

পর্ব ০২-এর সেই খেলাটি মনে আছে? বন্ধু ১ থেকে ১০০-এর মধ্যে একটি সংখ্যা ভেবেছিল, আর আপনি প্রতিবার মাঝখানের সংখ্যা বলে অর্ধেক সম্ভাবনা বাদ দিচ্ছিলেন — সাত প্রশ্নের মধ্যেই উত্তর। BST সেই খেলারই একটি স্থায়ী রূপ। প্রতিটি নোড একটি “মাঝখানের সংখ্যা”: খোঁজা মান ছোট হলে ডান দিকের পুরো অংশ এক তুলনাতেই বাদ, বড় হলে বাঁ দিকের পুরো অংশ বাদ। নিচে ৬০ খুঁজে দেখি, তারপর এমন একটি মান খুঁজব যা ট্রিতে নেই।

50root
30
70
20
40
60
80
খোঁজার হিসাব
যা খুঁজছি
এখনকার নোড
তুলনা ও সিদ্ধান্ত
মোট তুলনা
বাদ পড়া নোড
  1. শুরু root থেকে। ৬০ কি ৫০-এর চেয়ে বড়? হ্যাঁ। তাহলে ৬০ যদি ট্রিতে থাকে, থাকবে ডান দিকে — BST-এর নিয়মই সেটি নিশ্চিত করে। বাঁ দিকের পুরো subtree (৩০, ২০, ৪০) একবারও না দেখে বাদ দেওয়া গেল।
  2. ৭০-এ এলাম। ৬০ < ৭০, তাই বাঁয়ে। এবার বাদ পড়ল ৮০. একটি তুলনায় একটি করে নয় — একেকটি পুরো subtree বাদ যাচ্ছে।
  3. পাওয়া গেছে — ৩টি তুলনায়। ৬০ == ৬০. মোট ৭টি নোডের মধ্যে আমরা দেখেছি মাত্র ৩টি — ঠিক যতটা ট্রির height.
  4. যে মান নেই — ৬৫. একই পথে ৫০ → ৭০ → ৬০. ৬৫ > ৬০, তাই ৬০-এর ডানে যেতে হবে, কিন্তু সেখানে NULL. মানে ৬৫ এই ট্রিতে নেই — এবং এটুকু নিশ্চিত হতেও লাগল মাত্র ৩টি তুলনা। মজার কথা: ৬৫ insert করতে চাইলে ঠিক এই NULL-এর জায়গাতেই বসত। খোঁজা আর insert আসলে একই হাঁটা।
  5. হিসাবটা বড় করে ভাবুন। এলোমেলো তালিকায় একটি মান খুঁজতে খারাপ অবস্থায় সব ৭টিই দেখতে হয়। Balanced BST-তে সর্বোচ্চ ৩টি। বড় সংখ্যায় কী দাঁড়ায়, নিচের টেবিলে দেখুন।
মোট মান (n)এলোমেলো তালিকায় খারাপ অবস্থায়Balanced BST-তে সর্বোচ্চ
৭৭টি তুলনা৩টি
১,০০০১,০০০টি১০টি
১০ লাখ১০ লাখ২০টি
১০০ কোটি১০০ কোটি৩০টি

প্রতিবার মান দ্বিগুণ হলে BST-তে মাত্র একটি তুলনা বাড়ে। একে বলে logarithmic সময়, লেখা হয় O(log n) — পর্ব ৩৯-এ time complexity নিয়ে বিস্তারিত দেখব। এটি সেই binary search-এর গতি, কিন্তু একটি বড় সুবিধাসহ: সাজানো অ্যারেতে নতুন মান ঢোকাতে পেছনের সব ঘর এক ঘর করে সরাতে হয়, আর BST-তে শুধু একটি pointer বসালেই চলে।

৫ · ট্রি ঘুরে দেখা — চার রকম traversal

অ্যারে বা linked list-এর সব মান ছাপানো সহজ — শুরু থেকে শেষ পর্যন্ত হাঁটলেই হলো। কিন্তু ট্রির তো “শেষ” একটি নয়, অনেকগুলো শাখা। প্রতিটি নোডে ঠিক একবার যাওয়ার নিয়মকে বলে traversal. প্রতিটি নোডে তিনটি কাজ আছে — বাঁ subtree ঘোরা, নিজেকে ছাপা, ডান subtree ঘোরা। এই তিনটির ক্রম বদলালেই ভিন্ন traversal:

Inorder · বাঁ → নিজে → ডান

BST-তে মানগুলো ছোট থেকে বড় ক্রমে বেরিয়ে আসে।

20 30 40 50 60 70 80

Preorder · নিজে → বাঁ → ডান

Parent আগে, সন্তান পরে। ট্রি হুবহু কপি করতে বা ফাইলে সংরক্ষণ করতে কাজে লাগে।

50 30 20 40 70 60 80

Postorder · বাঁ → ডান → নিজে

সন্তানদের কাজ শেষ হলে তবেই parent. ট্রি free করা বা ফোল্ডারের মোট আকার হিসাব করা।

20 40 30 60 80 70 50

Level-order · স্তরে স্তরে

ওপর থেকে নিচে, প্রতি স্তরে বাঁ থেকে ডানে। এর জন্য একটি queue লাগে।

50 30 70 20 40 60 80

প্রথম তিনটির নাম inorder, preorder আর postorder — “নিজে” কাজটি কখন হচ্ছে (মাঝে, আগে, পরে) সেটি দিয়েই নাম। তিনটিই তিন লাইনের recursive ফাংশন। সবচেয়ে চমকপ্রদটি হলো inorder: BST-তে এটি সবসময় সাজানো ক্রম দেয়। কেন? কারণ প্রতিটি নোডে আমরা আগে তার বাঁ দিকের সব (যারা ছোট) ছাপি, তারপর নিজেকে, তারপর ডানের সব (যারা বড়)। ধাপে ধাপে দেখুন:

50root
30
70
20
40
60
80
Call stack (কোন কোন inorder() ডাক এখনো চলছে)
চলমান ডাক
এখন ছাপা হলো
  1. প্রথমে সবচেয়ে বাঁয়ে। inorder(50) নিজেকে ছাপার আগে বাঁ subtree-তে যায় — inorder(30). সেটিও আগে বাঁয়ে যায় — inorder(20). ২০-এর বাঁয়ে NULL (base case, কিছু করার নেই), তাই ২০ ছাপা হলো। খেয়াল করুন, তিনটি ডাক একসাথে stack-এ অপেক্ষা করছে — পর্ব ১৫-এর call stack ঠিক এভাবেই কাজ করে।
  2. ৩০-এ ফিরে এলাম। ২০-এর ডানেও NULL, তাই inorder(20) শেষ। নিয়ন্ত্রণ ফিরল ৩০-এ, যার বাঁ দিক এখন শেষ। তাই এবার নিজে — ৩০ ছাপা হলো।
  3. ৩০-এর ডানে — ৪০. “বাঁ, নিজে, ডান” — ৩০-এর তৃতীয় কাজ বাকি। inorder(40) ডাকা হলো, তার দুই দিকেই NULL, তাই ৪০ ছাপা হলো। এখানে ৩০-এর পুরো subtree শেষ।
  4. এবার root — ৫০. Stack-এ শুধু inorder(50) বাকি, যার বাঁ subtree-র তিনটি মানই ছাপা হয়ে গেছে। এবার নিজে — ৫০. ৫০-এর চেয়ে ছোট সব মান আগেই বেরিয়ে গেছে, এটিই BST-এর নিয়মের ফল।
  5. ডান subtree-তে একই খেলা। inorder(70) আগে বাঁয়ে যায় — inorder(60), তার বাঁয়ে NULL, তাই ৬০ ছাপা হলো।
  6. ৭০. বাঁ দিক শেষ, তাই নিজে — ৭০.
  7. ৮০ — এবং সবকিছু সাজানো! শেষে ৭০-এর ডানে ৮০. আউটপুটটি দেখুন: 20 30 40 50 60 70 80. আমরা কোনো sorting করিনি — ট্রির আকারই সাজানো ক্রম ধরে রেখেছিল। n টি মান BST-তে ঢুকিয়ে inorder-এ পড়লে একটি sorting পদ্ধতিই পাওয়া যায় — এর নাম tree sort পদ্ধতি।

Level-order — যেখানে recursion নয়, queue লাগে

চতুর্থ traversal, level-order, একটু আলাদা। আগে root, তারপর তার দুই child, তারপর নাতি-নাতনিরা — স্তরে স্তরে। এটি recursion দিয়ে সহজে হয় না, কারণ recursion একটি শাখার গভীরে নেমে যায়। এখানে দরকার পর্ব ৩৪-এর queue: root-কে queue-তে রাখুন। তারপর বারবার — সামনের নোডটি বের করুন, ছাপুন, তার left আর right (যদি থাকে) queue-র পেছনে রাখুন। queue খালি হলে কাজ শেষ।

queue: [50]          → বের করি 50, ঢোকাই 30, 70
queue: [30, 70]      → বের করি 30, ঢোকাই 20, 40
queue: [70, 20, 40]  → বের করি 70, ঢোকাই 60, 80
queue: [20, 40, 60, 80] → চারটিই leaf, একে একে বের হয়
আউটপুট: 50 30 70 20 40 60 80

Queue “আগে এলে আগে যাবে” নিয়ম মানে বলে কাছের স্তরের নোড সবসময় দূরের স্তরের আগে বের হয়। গ্রাফে এই একই কৌশলের নাম BFS (Breadth-First Search) — পর্ব ৩৮-এ পুরোটা দেখব। বাস্তবে এটি কাজে লাগে যখন “সবচেয়ে কাছের” কিছু আগে চাই: যেমন একটি কোম্পানির org chart-এ “MD থেকে দুই ধাপ নিচে কারা আছেন?”

সব একসাথে — insert, inorder আর free

এবার পুরো ব্যাপারটি C-তে। নিচের প্রোগ্রামটি আটটি মান ঢোকায় (শেষের ৪০ ইচ্ছা করে দ্বিতীয়বার দেওয়া), inorder-এ ছাপে, তারপর পুরো ট্রি মুক্ত করে। চালিয়ে দেখুন:

#include <stdio.h>
#include <stdlib.h>
typedef struct Node { int key; struct Node *left, *right; } Node;
Node *insert(Node *t, int key) {
    if (t == NULL) {                       // খালি জায়গা: এখানেই নতুন নোড
        Node *n = malloc(sizeof *n);
        if (n == NULL) exit(1);
        n->key = key; n->left = n->right = NULL;
        return n;
    }
    if (key < t->key) t->left = insert(t->left, key);
    else if (key > t->key) t->right = insert(t->right, key);
    return t;                              // সমান হলে: উপেক্ষা
}
void inorder(const Node *t) {
    if (t) { inorder(t->left); printf("%d ", t->key); inorder(t->right); }
}
void free_tree(Node *t) {                  // postorder: আগে সন্তান, পরে নিজে
    if (t) { free_tree(t->left); free_tree(t->right); free(t); }
}
int main(void) {
    int keys[] = {50, 30, 70, 20, 40, 60, 80, 40}; Node *root = NULL;
    for (int i = 0; i < 8; i++) root = insert(root, keys[i]);
    inorder(root); printf("\n");
    free_tree(root); return 0;
}

তিনটি জিনিস খেয়াল করুন। (১) insert নতুন বা পুরোনো subtree-র ঠিকানা ফেরত দেয়, আর ডাকার জায়গায় সেটি আবার বসানো হয় — root = insert(root, ...). প্রথম ডাকে root ছিল NULL; এই assignment না থাকলে নতুন নোড তৈরি হতো, কিন্তু root কখনো সেটির ঠিকানা পেত না — ট্রি খালি থাকত আর মেমরি হারিয়ে যেত। (২) দ্বিতীয় ৪০ কোনো শাখাতেই যায়নি, তাই আউটপুটে ৪০ একবারই এসেছে: 20 30 40 50 60 70 80. (৩) free_tree postorder-এ চলে। আগে নিজেকে free করলে t->left পড়াটাই হতো use-after-free — মুক্ত করা মেমরি থেকে ঠিকানা পড়া। আর ট্রি free না করলে প্রতিটি নোড memory leak.

৬ · মুছে ফেলা — তিনটি অবস্থা

Insert আর search সহজ, কারণ নতুন নোড সবসময় একটি খালি জায়গায় (leaf-এর নিচে) বসে। কিন্তু delete-এ মাঝখানের কোনো নোডও সরাতে হতে পারে, আর তখন তার সন্তানদের কোথাও না কোথাও জুড়ে দিতে হয় — BST-এর নিয়ম না ভেঙে। আগে delete করার নোডটি খুঁজে বের করুন (খোঁজার মতোই হাঁটা), তারপর দেখুন তার কয়টি child:

অবস্থা ১ · Leaf (কোনো child নেই)

সবচেয়ে সহজ। নোডটি free করুন, আর parent-এর যে pointer এটিকে দেখাত সেটি NULL করে দিন। যেমন ২০ মুছলে ৩০-এর left = NULL.

delete 20

অবস্থা ২ · একটি child

নোডটিকে সরিয়ে তার একমাত্র child-কে সরাসরি parent-এর সাথে জুড়ে দিন — যেন বাবা অবসরে গেলে ছেলে তার চেয়ারে বসল। ২০ মোছার পর ৩০-এর শুধু ৪০ আছে; এখন ৩০ মুছলে ৫০-এর left হবে ৪০.

delete 30 (child: 40)

অবস্থা ৩ · দুটি child

এটিই কঠিন। দুই সন্তানকে একসাথে parent-এর একটি pointer-এ জোড়া যায় না। তাই নোডটি না সরিয়ে তার মানটি বদলে দিন সাজানো ক্রমে ঠিক পরের মান দিয়ে, তারপর সেই মানের পুরোনো নোডটি মুছুন।

delete 50 (root!)

তৃতীয় অবস্থার “ঠিক পরের মান”-কে বলে inorder successor — inorder ক্রমে যে মান এর ঠিক পরে আসে। আমাদের মূল ট্রিতে ৫০-এর পরে ৬০. একে পাওয়ার নিয়ম: একবার ডানে, তারপর যতদূর যায় বাঁয়ে। ডান subtree-র সবাই ৫০-এর চেয়ে বড়, আর তাদের মধ্যে সবচেয়ে ছোটটি থাকে সবচেয়ে বাঁয়ে।

আগে:            ৫০-এর জায়গায় ৬০ বসানো:     পুরোনো ৬০ (leaf) মোছা:
      50                 60                         60
     /  \               /  \                       /  \
   30    70           30    70                   30    70
  / \   / \          / \   / \                  / \      \
 20 40 60  80       20 40 60  80               20 40      80
                          ↑ এখন দুইবার আছে

কেন এটি নিয়ম ভাঙে না? ৬০ ছিল ডান subtree-র সবচেয়ে ছোট মান, তাই ডানের বাকি সবার চেয়ে ছোট; আবার ৫০-এর চেয়ে বড় বলে বাঁ দিকের সবার চেয়ে বড়। Root-এর চেয়ারে বসার জন্য সে-ই একমাত্র যোগ্য প্রার্থী। আর একটি সুন্দর তথ্য: successor-এর কখনো বাঁ child থাকে না (থাকলে সে-ই হতো আরও ছোট)। তাই তাকে মোছা সবসময় অবস্থা ১ বা ২ — কখনো আবার অবস্থা ৩ নয়। চাইলে successor-এর বদলে predecessor (বাঁ subtree-র সবচেয়ে বড়, অর্থাৎ ৪০) দিয়েও একই কাজ করা যায়।

Delete-এর কোডে সবচেয়ে সাধারণ দুটি ভুল: (ক) successor-এর মান কপি করে তার পুরোনো নোডটি মুছতে ভুলে যাওয়া — ফলে একই মান দুইবার থাকে, ঠিক উপরের মাঝের ছবির মতো। (খ) দুই child-ওয়ালা নোডের জায়গায় সরাসরি ডান child-কে বসিয়ে দেওয়া — তখন বাঁ subtree-র কোনো ঠিকানা আর কোথাও থাকে না, পুরো অংশটি ট্রি থেকে হারিয়ে যায় আর মেমরি leak হয়। দুটি ভুলই ছোট ট্রিতে চোখে পড়ে না; ধরা পড়ে শুধু delete-এর পর inorder ছাপিয়ে মেলালে।

৭ · ট্রি যখন লাঠি হয়ে যায় — balance কেন জরুরি

এখন পর্যন্ত সব কিছু চমৎকার দেখাল, কারণ আমরা মানগুলো ঢুকিয়েছি একটি ভালো ক্রমে — আগে মাঝের ৫০, তারপর দুই পাশের ৩০ আর ৭০। কিন্তু ধরুন একটি স্কুলের রোল নম্বর আসছে ক্রমানুসারে: ১০, ২০, ৩০, ৪০, ৫০. ঢুকিয়ে দেখুন কী আকার দাঁড়ায়:

10
20
30
40
50
লাঠির হিসাব
নতুন মান
বসাতে তুলনা লাগল
height
একই সংখ্যক নোডের balanced ট্রিতে height
  1. ১০ — root. এখনো সব স্বাভাবিক। কিন্তু সমস্যার বীজ এখানেই: সবচেয়ে ছোট মানটি root হয়ে গেছে, তাই পরের সব মান তার ডানে যাবে।
  2. ২০ — ডানে। ২০ > ১০. ঠিকই আছে, দুটি নোডে আর কী আকার হবে!
  3. ৩০ — আবার ডানে, আবার ডানে। ৩০ দুটি নোডের চেয়েই বড়। লক্ষ করুন, কোনো নোডের বাঁ child নেই — বাঁ দিকটা পুরো ফাঁকা। ৩টি নোড, height ৩. Balanced হলে হতো ২.
  4. ৪০ — একটি সরু সিঁড়ি। প্রতিটি নতুন মানকে আগের সব নোড পার হতে হচ্ছে। “অর্ধেক বাদ”-এর জাদু কাজ করছে না, কারণ বাদ দেওয়ার মতো কোনো বাঁ দিক নেই।
  5. ৫০ — এটি আর ট্রি নয়, লাঠি। প্রতিটি নোডের ঠিক একটি child — এটি আসলে একটি linked list, শুধু pointer-এর নাম right. এমন ট্রিকে বলে degenerate tree. ৫০ খুঁজতে ৫টি তুলনা — n টি মানে n টি। দশ লাখ রোল নম্বর এভাবে ঢোকালে একটি খোঁজায় ২০টির বদলে দশ লাখ তুলনা!

এটি কোনো কাল্পনিক সমস্যা নয়। বাস্তবের ডেটা প্রায়ই সাজানো অবস্থায় আসে — রোল নম্বর, ক্রমিক অর্ডার নম্বর, সময় অনুযায়ী লেনদেন, ফাইলের নাম বর্ণানুক্রমে। সাধারণ BST ঠিক এই সাধারণ অবস্থাতেই সবচেয়ে খারাপ আচরণ করে। নিচের প্রোগ্রামটি ১ থেকে ১০০০ পর্যন্ত মান দুইভাবে ঢোকায় — একবার ক্রমানুসারে, একবার “আগে মাঝখান” কৌশলে — তারপর দুটি ট্রির height মাপে:

#include <stdio.h>
#include <stdlib.h>
typedef struct Node { int key; struct Node *left, *right; } Node;
void insert(Node **p, int key) {            // লুপ দিয়ে, recursion ছাড়া
    while (*p && (*p)->key != key) p = key < (*p)->key ? &(*p)->left : &(*p)->right;
    if (*p) return;                          // আগে থেকেই আছে
    if ((*p = malloc(sizeof **p)) == NULL) exit(1);
    (*p)->key = key; (*p)->left = (*p)->right = NULL;
}
int height(const Node *t) {                 // নোড গুনে: খালি = 0
    if (t == NULL) return 0;
    int l = height(t->left), r = height(t->right);
    return 1 + (l > r ? l : r);
}
void build(Node **t, int lo, int hi) {      // আগে মাঝখান, তারপর দুই পাশ
    if (lo > hi) return;
    int mid = lo + (hi - lo) / 2;
    insert(t, mid); build(t, lo, mid - 1); build(t, mid + 1, hi);
}
void free_tree(Node *t) { if (t) { free_tree(t->left); free_tree(t->right); free(t); } }
int main(void) {
    Node *stick = NULL, *bushy = NULL;
    for (int k = 1; k <= 1000; k++) insert(&stick, k);
    build(&bushy, 1, 1000);
    printf("sorted order : height = %d\n", height(stick));
    printf("middle first : height = %d\n", height(bushy));
    free_tree(stick); free_tree(bushy); return 0;
}

একই ১০০০টি মান, একই নিয়ম — শুধু ক্রম আলাদা। প্রথম ট্রির height 1000, দ্বিতীয়টির মাত্র 10. Height-ই হলো খারাপ অবস্থায় একটি খোঁজার তুলনার সংখ্যা, তাই পার্থক্য ১০০ গুণ — আর দশ লাখ মানে হতো প্রায় পঞ্চাশ হাজার গুণ। এখানে insert ইচ্ছা করে লুপ দিয়ে লেখা: pointer-to-pointer (Node **p) দিয়ে সরাসরি সেই left বা right ঘরটির ঠিকানা ধরে রাখা হয়, যেখানে নতুন নোড বসবে। Recursive insert লাঠির মতো ট্রিতে প্রতিটি নোডের জন্য একটি করে stack frame খরচ করত। ১০০০-এ সমস্যা নেই, কিন্তু কয়েক লাখ সাজানো মানে stack overflow হয়ে প্রোগ্রাম crash করতে পারে (একই কারণে এখানকার recursive height আর free_tree-ও খুব লম্বা লাঠিতে ঝুঁকিপূর্ণ)।

সমাধান: যে ট্রি নিজেই ভারসাম্য রাখে

সব সময় ডেটার ক্রম আমাদের হাতে থাকে না। তাই কম্পিউটার বিজ্ঞানীরা এমন BST বানিয়েছেন, যা প্রতিটি insert বা delete-এর পর নিজেই পরীক্ষা করে কোথাও বেশি হেলে পড়েছে কি না, আর পড়লে কয়েকটি pointer ঘুরিয়ে (একে বলে rotation) আবার সোজা করে। এদের বলে self-balancing tree. ফলে যে ক্রমেই ডেটা আসুক, height সবসময় log n-এর কাছাকাছি থাকে।

১৯৬২ সালে সোভিয়েত গণিতবিদ Georgy Adelson-Velsky ও Evgenii Landis প্রথম self-balancing BST প্রকাশ করেন। তাঁদের নামের আদ্যক্ষর থেকেই নাম AVL tree. নিয়মটি কঠোর: প্রতিটি নোডের বাঁ আর ডান subtree-র height-এর পার্থক্য এক-এর বেশি হতে পারবে না। পরে ১৯৭০-এর দশকে আসে red-black tree, যা একটু ঢিলেঢালা ভারসাম্য রাখে কিন্তু insert-delete-এ কম ঘোরাঘুরি করে। আজ C++-এর std::map-এর সাধারণ বাস্তবায়ন, Java-র TreeMap, এমনকি Linux kernel-এর ভেতরের অনেক অংশ red-black tree ব্যবহার করে। Rotation কীভাবে কাজ করে, তা DSA কোর্সে ধাপে ধাপে দেখানো আছে।

ট্রির ধরনখোঁজা (খারাপ অবস্থায়)বাড়তি খরচকখন ব্যবহার
সাধারণ BSTO(n) — লাঠি হলেনেই, কোড সবচেয়ে ছোটশেখা, ছোট বা এলোমেলো ডেটা
AVL treeO(log n)প্রতিটি নোডে height রাখা, বেশি rotationখোঁজা বেশি, বদলানো কম
Red-black treeO(log n)প্রতিটি নোডে একটি রঙের bit, কম rotationinsert-delete ঘন ঘন — library-র সাধারণ পছন্দ

৮ · বাস্তব জগতে ট্রি — আপনি প্রতিদিন যা ব্যবহার করছেন

ট্রি শুধু পরীক্ষার প্রশ্ন নয়। আপনি এই পাতাটি পড়তে পড়তেই অন্তত তিন-চারটি ট্রি কাজ করছে:

File system

ফোল্ডারের ভেতরে ফোল্ডার — ট্রির সবচেয়ে পরিচিত রূপ। C:\ বা / হলো root, ফাইলগুলো leaf. ফোল্ডারের মোট আকার হিসাব করা মানে postorder: আগে ভেতরের সব, তারপর নিজের যোগফল।

NTFS, ext4, APFS

ডেটাবেসের B-tree

ব্যাংকের কোটি অ্যাকাউন্ট থেকে একটি নম্বর মুহূর্তে খুঁজে দেয় index — আর প্রায় সব ডেটাবেসের index একটি B-tree: প্রতিটি নোডে শত শত key, তাই ট্রি খুব চওড়া কিন্তু মাত্র ৩–৪ স্তর উঁচু।

MySQL, PostgreSQL, SQLite

ওয়েব পাতার DOM

ব্রাউজার HTML পড়ে একটি ট্রি বানায় — DOM. <html> root, তার নিচে <head> আর <body>, তার নিচে প্রতিটি অনুচ্ছেদ। JavaScript এই ট্রির নোড বদলালেই পাতা বদলায়।

এই পাতাটিও একটি ট্রি

Compiler-এর syntax tree

পর্ব ০৩-এর compiler আপনার কোড পড়ে প্রথমে একটি syntax tree বানায়। a + b * c-তে গুণ নিচে, যোগ ওপরে — তাই আগে গুণ হয়। Postorder-এ হাঁটলেই হিসাবের সঠিক ক্রম মেলে।

GCC, Clang

আরও একটি জায়গা আছে, যেখানে ট্রি নিজেই সিদ্ধান্ত নেয়। Machine learning-এর decision tree মডেলে প্রতিটি নোড একটি প্রশ্ন: “মাসিক আয় ৩০ হাজারের বেশি?” হ্যাঁ হলে এক শাখা, না হলে আরেক। Leaf-এ পৌঁছালে উত্তর: “ঋণ দেওয়া যায়” বা “ঝুঁকিপূর্ণ”। সুবিধা হলো, একটি ছোট decision tree মানুষ নিজে পড়ে বুঝতে পারে কেন সিদ্ধান্তটি এলো।

B-tree কেন এত চওড়া? ধরুন আপনি ঢাকা থেকে রাজশাহীর একটি লাইব্রেরি থেকে বই আনাচ্ছেন। প্রতিবার আনাতে একদিন লাগে। প্রতিবার একটি বই আনানো বোকামি — একবারে একটি বড় বাক্স ভরে আনুন। হার্ড ডিস্ক বা SSD থেকে পড়াও তেমনি: একটি বাইট পড়তে যে সময়, একটি পুরো page (কয়েক KB) পড়তেও প্রায় সেই সময়, কিন্তু RAM-এর চেয়ে বহুগুণ ধীর। B-tree তাই প্রতিটি নোডকে একটি পুরো page-এর সমান বানায়, ভেতরে শত শত key ভরে দেয়। ফলে কোটি সারির মধ্যে খুঁজতে মাত্র তিন-চারবার ডিস্ক পড়লেই হয় — যেখানে binary BST-তে লাগত প্রায় পঁচিশবার।

B-tree-র ধারণা ১৯৭০ সালের দিকে Boeing-এর গবেষণাগারে কাজ করার সময় Rudolf Bayer ও Edward McCreight প্রকাশ করেন। “B” অক্ষরটির মানে কী — Boeing, Bayer, নাকি balanced — তাঁরা নিজেরাও কখনো নিশ্চিত করে বলেননি। পাঁচ দশক পরেও প্রায় সব বড় ডেটাবেস আর অনেক file system (যেমন NTFS-এর ফোল্ডার সূচি, Btrfs) এর কোনো না কোনো রূপ ব্যবহার করে।

৯ · AI-এর যুগে — BST কোড যাচাই

“C-তে একটি BST লিখে দাও” — যেকোনো AI কয়েক সেকেন্ডে লিখে দেবে, আর সাধারণ পরীক্ষায় সেটি ঠিকঠাকই চলবে। সমস্যা লুকিয়ে থাকে কিনারায়: সাজানো ইনপুট, একই মান দুইবার, দুই child-ওয়ালা নোড মোছা, আর প্রোগ্রাম শেষে মেমরি ফেরত দেওয়া। AI-এর শেখার উৎসে পাঠ্যবইয়ের ছোট উদাহরণই বেশি, আর সেখানে এই কিনারাগুলো প্রায়ই দেখানো হয় না।

AI-এর সাধারণ ভুলকী ঘটেকীভাবে ধরবেন
শেষে free_tree নেই, বা delete-এ নোড free করেনিMemory leak — লম্বা সময় চলা সার্ভারে মেমরি ধীরে ধীরে ফুরায়Valgrind বা -fsanitize=address দিয়ে চালানো
Insert-এ <=, search-এ < — সমান মানের নিয়ম আলাদামান আছে, তবু “পাওয়া যায়নি”; duplicate জমেএকই মান দুইবার ঢুকিয়ে খোঁজা ও গোনা
সব ফাংশন recursive, balance নেইসাজানো লাখখানেক মানে stack overflow, বা খুব ধীর১ থেকে ১০ লাখ ক্রমানুসারে ঢুকিয়ে পরীক্ষা
দুই child-ওয়ালা নোড মুছতে ভুল — successor না মোছা, বা বাঁ subtree হারানোDuplicate মান, বা পুরো একটি শাখা উধাওRoot মুছে inorder ছাপানো — সাজানো আর সংখ্যায় এক কম কি না
insert(root, x); — ফেরত ঠিকানা root-এ বসায়নিট্রি সবসময় খালি, প্রতিটি নোড leakInsert-এর পর নোড গোনা

AI-কে BST লিখতে বললে শুধু “BST লিখে দাও” নয় — কিনারার সিদ্ধান্তগুলো আপনি আগেই বলে দিন, আর পরীক্ষাগুলোও চেয়ে নিন। তখন AI-কে নিজের কোডের দুর্বলতা নিয়ে ভাবতে হয়:

C-তে int key-র একটি Binary Search Tree লিখে দাও, gcc -Wall -Wextra দিয়ে কোনো warning ছাড়া compile হতে হবে। শর্তগুলো: ১. একই key দ্বিতীয়বার এলে উপেক্ষা করবে — insert, search, delete তিনটিতেই একই নিয়ম। ২. delete-এ তিনটি অবস্থা (leaf, এক child, দুই child) আলাদা করে সামলাবে; দুই child হলে inorder successor ব্যবহার করবে এবং successor-এর পুরোনো নোড free করবে। ৩. insert আর search লুপ দিয়ে লিখবে, যাতে সাজানো ইনপুটে stack overflow না হয়। ৪. পুরো ট্রি free করার ফাংশন দেবে। ৫. main-এ এই পরীক্ষাগুলো রাখবে: ১ থেকে ১০০০০০ ক্রমানুসারে insert; একই মান দুইবার insert; root delete করে inorder ছাপানো। কোডের পর সহজ বাংলায় বলো, কোন ইনপুটে এই ট্রি সবচেয়ে ধীর হবে আর কেন।

AI-এর লেখা BST হাতে পেলে — যাচাইয়ের চেকলিস্ট

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

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১ডেটাবেস কেন binary BST নয়, বরং B-tree ব্যবহার করে? দুটিই তো O(log n)!

দুটিই O(log n) — কিন্তু log-এর ভিত্তি আলাদা, আর আসল খরচ কোথায় সেটিও আলাদা।

  • আসল খরচ তুলনা নয়, ডিস্ক পড়া: RAM থেকে একটি মান পড়তে লাগে কয়েক ন্যানোসেকেন্ড। SSD থেকে পড়তে লাগে তার শতগুণ থেকে হাজারগুণ বেশি সময়, আর পুরোনো ঘূর্ণায়মান হার্ড ডিস্কে আরও অনেক বেশি। তার ওপর ডিস্ক একটি বাইট আলাদা করে দেয় না — দেয় পুরো একটি page (সাধারণত ৪ থেকে ১৬ KB আকারের একটি টুকরো)। কোটি সারির টেবিলের index পুরোটা RAM-এ রাখা সবসময় সম্ভব নয়।
  • Binary BST-তে কী হতো: কোটি key-র balanced BST-র height প্রায় ২৪. প্রতিটি নোড ডিস্কের ভিন্ন জায়গায় থাকলে একটি খোঁজায় প্রায় ২৪ বার ডিস্ক পড়া। প্রতিবার একটি পুরো page আনা হলো, অথচ ব্যবহার হলো তার ভেতরের মাত্র ২৪ বাইটের একটি নোড — বাকিটা অপচয়।
  • B-tree-র কৌশল: একটি নোড = একটি page, ভেতরে ধরুন ৫০০টি key আর ৫০১টি child pointer. প্রতি স্তরে ৫০০ ভাগে ভাগ হয়, তাই কোটি key-র জন্য height মাত্র ৩. ওপরের এক-দুই স্তর সাধারণত RAM-এই থেকে যায়, তাই বাস্তবে এক-দুইবার ডিস্ক পড়লেই উত্তর। নোডের ভেতরে ৫০০ key-র মধ্যে খোঁজা RAM-এ হয় — সেটি প্রায় বিনা খরচে।
  • B+ tree আর range query: বেশিরভাগ ডেটাবেস আসলে B+ tree ব্যবহার করে, যেখানে সব ডেটা leaf-এ থাকে আর leaf-গুলো পাশাপাশি জোড়া। “জানুয়ারি থেকে মার্চের সব লেনদেন” খুঁজতে প্রথম leaf-এ গিয়ে পাশাপাশি হাঁটলেই হয়।

এখানে একটি বড় শিক্ষা আছে: Big-O শুধু তুলনার সংখ্যা গোনে, কিন্তু সব কাজের দাম সমান নয়। যে কাজটি সবচেয়ে ধীর (এখানে ডিস্ক পড়া), সেটিকে কমানোর জন্যই ডিজাইন করতে হয়। DBMS কোর্সে index-এর ভেতরের এই কাঠামো বিস্তারিত দেখানো আছে।

মূল উপলব্ধি: ডেটা স্ট্রাকচার বেছে নিতে হয় হার্ডওয়্যার দেখে। RAM-এ binary tree চমৎকার, ডিস্কে চওড়া আর কম উঁচু ট্রি — একই অ্যালগরিদমের ভিন্ন রূপ, ভিন্ন খরচের জগতের জন্য।

প্র ০২Balance রাখার দাম কী? তাহলে কি সবসময় AVL বা red-black tree-ই ব্যবহার করা উচিত?

Balance বিনা খরচে আসে না। তিনটি দাম দিতে হয়:

  • প্রতিটি নোডে বাড়তি তথ্য: AVL-এ প্রতিটি নোডে তার height বা balance factor, red-black-এ একটি রঙের bit. ছোট মনে হলেও কোটি নোডে মেমরি বাড়ে।
  • প্রতিটি insert/delete-এ বাড়তি কাজ: নিচ থেকে ওপরে উঠে ভারসাম্য পরীক্ষা, দরকার হলে rotation. AVL কঠোর বলে একটু বেশি ঘোরায়; red-black ঢিলেঢালা বলে তার height একটু বেশি হতে পারে (সর্বোচ্চ প্রায় দ্বিগুণ log n), কিন্তু পরিবর্তন সস্তা।
  • কোডের জটিলতা: সাধারণ BST-এর delete ত্রিশ লাইনে হয়; red-black tree-র delete-এ একাধিক আলাদা অবস্থা সামলাতে হয়, যেখানে ভুল করা খুব সহজ। এজন্যই পেশাদাররা নিজে না লিখে পরীক্ষিত library ব্যবহার করেন।

কখন balance লাগে না? যদি ডেটা সত্যিই এলোমেলো ক্রমে আসে, সাধারণ BST-এর গড় height-ও log n-এর একটি ছোট গুণিতক থাকে। যদি সব ডেটা একবারে আগেই জানা থাকে, তাহলে সাজিয়ে “আগে মাঝখান” কৌশলে বানানো যায় (আমাদের দ্বিতীয় কোডের মতো) — অথবা ট্রিই লাগে না, সাজানো অ্যারেতে binary search যথেষ্ট, যা মেমরিতে অনেক কম জায়গা নেয় আর CPU cache-এও ভালো চলে। আর ডেটা যদি মাত্র কয়েকশো হয়, সাধারণ লুপই হয়তো সবচেয়ে দ্রুত।

কখন অবশ্যই লাগে? যখন ইনপুট আপনার নিয়ন্ত্রণে নেই — বিশেষ করে ইন্টারনেট থেকে আসা ইনপুট। কেউ ইচ্ছা করে সাজানো ডেটা পাঠিয়ে আপনার সার্ভারের BST-কে লাঠি বানিয়ে ধীর করে দিতে পারে। এ ধরনের আক্রমণকে বলে algorithmic complexity attack. Balanced tree খারাপ অবস্থাতেও O(log n) নিশ্চিত করে — গড় নয়, প্রতিশ্রুতি।

মূল উপলব্ধি: প্রকৌশলে গড় অবস্থা আর খারাপ অবস্থা আলাদা প্রশ্ন। কোথাও গড় গতি যথেষ্ট; কোথাও — বিশেষত শত্রুভাবাপন্ন ইনপুটে — খারাপ অবস্থার নিশ্চয়তাই আসল মূল্য।

প্র ০৩পর্ব ৩৭-এর hash table গড়ে আরও দ্রুত। তাহলে ট্রির দরকার কী?

সত্যি, hash table-এ একটি নির্দিষ্ট key খোঁজা গড়ে প্রায় ধ্রুব সময়ে হয় — ট্রির log n-এর চেয়েও দ্রুত। কিন্তু hash table একটি জিনিস হারায়: ক্রম। hash function মানগুলোকে ইচ্ছা করেই এলোমেলো ঘরে ছড়িয়ে দেয়, যাতে ভিড় না হয়। ফলে কিছু প্রশ্নের উত্তর hash table দিতে পারে না, ট্রি পারে সহজে:

  • Range query: “৬০ থেকে ৮০ নম্বর পাওয়া সব ছাত্র” — BST-তে ৬০-এর জায়গায় গিয়ে inorder-এ হাঁটুন, ৮০ পেরোলে থামুন। Hash table-এ পুরো টেবিল ঘুরতে হবে।
  • সাজানো ক্রমে সব: BST-তে inorder দিলেই হলো; hash table-এ আগে সব বের করে আলাদা করে sort করতে হয়।
  • সবচেয়ে কাছের মান: “৬৫-এর ঠিক আগের বা পরের মান কোনটি?” — যেমন বাস সময়সূচিতে “৫:৪০-এর পরের বাস কখন?” ট্রিতে এটি log n-এ হয়; hash table-এ উপায় নেই, কারণ ৬৫ আর ৬৬ সম্পূর্ণ ভিন্ন ঘরে থাকে।
  • সবচেয়ে ছোট বা বড়: ট্রিতে সবচেয়ে বাঁয়ে বা ডানে হাঁটা।

আরও একটি পার্থক্য: hash table-এর গতি নির্ভর করে hash function ভালো হওয়ার ওপর। খারাপ অবস্থায় (অনেক key একই ঘরে পড়লে) সেটিও ধীর হয়ে যায়, আর টেবিল ভরে গেলে মাঝে মাঝে পুরোটা নতুন করে সাজাতে হয় — তখন একটি insert হঠাৎ অনেক সময় নেয়। Balanced tree-র প্রতিটি কাজ নিশ্চিতভাবে log n.

এজন্যই বাস্তব সিস্টেমে দুটিই পাশাপাশি থাকে। C++-এ std::map (ট্রি) আর std::unordered_map (hash) — নামই বলে দিচ্ছে পার্থক্য। ডেটাবেসেও সাধারণ index B-tree, কিন্তু কিছু ডেটাবেস শুধু সমান-কি-না খোঁজার জন্য hash index-এর সুবিধাও দেয়।

মূল উপলব্ধি: “কোনটি দ্রুত” প্রশ্নের আগে জিজ্ঞেস করুন “আমি কী ধরনের প্রশ্ন করব?” শুধু “আছে কি না” হলে hash; “কোনটির পরে কোনটি” বা “এর মধ্যে কারা” হলে ট্রি।

প্র ০৪ব্রাউজারের DOM একটি ট্রি — এই ট্রি-কাঠামো ওয়েব পাতাকে কীভাবে কাজ করায়?

HTML ফাইলটি আসলে শুধু লেখা। ব্রাউজার সেটি পড়ে (parse করে) মেমরিতে একটি ট্রি বানায়: <html> root, তার দুই child <head> আর <body>, <body>-এর নিচে প্রতিটি <div>, অনুচ্ছেদ, বোতাম। ট্যাগ খোলা আর বন্ধের নিয়মই ট্রির আকার ঠিক করে — একটি ট্যাগের ভেতরে যা আছে, সব তার subtree. এজন্যই ভুল জায়গায় ট্যাগ বন্ধ করলে পাতার আকার বিগড়ে যায়।

  • স্টাইল নিচে নামে: <body>-এ font ঠিক করলে নিচের সবাই সেটি পায় — subtree-র সবাইকে একসাথে প্রভাবিত করা, ট্রির স্বাভাবিক গুণ।
  • পাতা আঁকা: প্রতিটি বাক্সের আকার জানতে আগে তার ভেতরের বাক্সগুলোর আকার জানা চাই — অনেকটা postorder-এর মতো চিন্তা। আর আঁকা হয় ওপর থেকে নিচে, parent আগে, child পরে।
  • ক্লিক কোথায় গেল: একটি বোতামে ক্লিক করলে event সেই নোড থেকে parent ধরে root পর্যন্ত ওপরে ওঠে (event bubbling), তাই একটি বাইরের <div>-ও ভেতরের সব ক্লিক শুনতে পারে।
  • JavaScript পাতা বদলায় ট্রি বদলে: নতুন নোড যোগ করা, পুরোনোটি সরানো — linked structure বলে শুধু কয়েকটি pointer বদলায়।

খরচের দিকটাও আছে। প্রতিবার ট্রি বদলালে ব্রাউজারকে আকার আর অবস্থান নতুন করে হিসাব করতে হতে পারে, যা ধীর। React-এর মতো library এই কারণেই আগে মেমরিতে একটি হালকা নকল ট্রি বানিয়ে পুরোনোটির সাথে মিলিয়ে দেখে কী কী বদলেছে, তারপর শুধু সেই নোডগুলো আসল DOM-এ বদলায়। দুটি ট্রি তুলনা করার এই কাজও একটি ট্রি-অ্যালগরিদম।

মূল উপলব্ধি: নেস্টেড বা “ভেতরে ভেতরে” সাজানো যেকোনো কিছু — HTML, JSON, ফোল্ডার, কোড — আসলে একটি ট্রি। ট্রি বুঝলে এগুলোর সবগুলোতে একই কৌশল খাটে।

প্র ০৫খুব বড় বা লম্বা ট্রিতে recursion কেন বিপজ্জনক, আর পেশাদাররা কীভাবে এড়ান?

পর্ব ১৫-এ দেখেছি, প্রতিটি ফাংশন ডাক stack-এ একটি frame নেয়, আর ফাংশন শেষ না হওয়া পর্যন্ত সেটি থাকে। Recursive traversal-এ একসাথে যতগুলো ডাক চলে, তা ট্রির height-এর সমান। Balanced ট্রিতে কোটি নোড হলেও height মাত্র ত্রিশের কাছাকাছি — কোনো সমস্যা নেই। কিন্তু লাঠির মতো ট্রিতে height = n.

  • Stack কত বড়? সাধারণত Linux-এ মূল thread-এর stack ৮ MB, Windows-এ ডিফল্ট ১ MB. প্রতিটি frame কয়েক ডজন বাইট থেকে শুরু করে বেশি — debug build-এ আরও বড়। ফলে কয়েক লাখ গভীর recursion-এই সাধারণত stack ফুরিয়ে যায়, আর প্রোগ্রাম segmentation fault দিয়ে মারা যায় — কোনো সুন্দর error message ছাড়াই।
  • এটি শুধু তাত্ত্বিক নয়: খুব গভীর নেস্টেড JSON বা XML পাঠিয়ে recursive parser-কে crash করানো একটি পরিচিত আক্রমণ। এজন্য অনেক parser নেস্টিংয়ের একটি সর্বোচ্চ সীমা বেঁধে দেয়।

সমাধানের উপায়:

  • Balance রাখা: height log n থাকলে recursion depth-ও log n — সবচেয়ে পরিষ্কার সমাধান।
  • লুপ ব্যবহার: search আর insert স্বাভাবিকভাবেই একটি পথ ধরে নামে, তাই লুপে লেখা সহজ (আমাদের দ্বিতীয় কোডের মতো)।
  • নিজের stack: Traversal-এর জন্য heap-এ একটি stack (পর্ব ৩৪) বানিয়ে নিজেই নোড push-pop করা যায়। Heap অনেক বড়, আর জায়গা ফুরালে malloc NULL দেয় — যা সামলানো যায়, crash-এর মতো হঠাৎ মৃত্যু নয়।

মূল উপলব্ধি: Recursion কোড ছোট ও পরিষ্কার করে, কিন্তু এর গভীরতা আপনার ইনপুটের হাতে। ইনপুট যখন আপনার নিয়ন্ত্রণে নেই, তখন হয় গভীরতার নিশ্চয়তা দিন (balance), নয়তো recursion এড়ান।

অনুশীলন

খাতা-কলম নিন। ট্রি হাতে আঁকতে পারাটাই এই পর্বের সবচেয়ে বড় দক্ষতা।

  1. হাতে BST আঁকুন: খালি ট্রিতে এই ক্রমে মান ঢোকান — ৪৫, ২৫, ৬৫, ১৫, ৩৫, ৫৫, ৭৫, ৩০. ট্রিটি আঁকুন, height বলুন, আর inorder ক্রম লিখুন।
            45
          /    \
        25      65
       /  \    /  \
      15  35  55  75
         /
       30

    ৩০ প্রথমে ৪৫-এর বাঁয়ে, তারপর ২৫-এর ডানে (৩০ > ২৫), তারপর ৩৫-এর বাঁয়ে (৩০ < ৩৫)। Height (নোড গুনে) ৪: ৪৫ → ২৫ → ৩৫ → ৩০. Inorder: ১৫, ২৫, ৩০, ৩৫, ৪৫, ৫৫, ৬৫, ৭৫ — সাজানো, যেমন হওয়ার কথা। না মিললে আঁকায় কোথাও ভুল আছে।

  2. তিন traversal: উপরের অনুশীলনের ট্রিটির preorder, postorder আর level-order লিখুন। কোনটিতে root সবার আগে, কোনটিতে সবার শেষে?

    Preorder (নিজে, বাঁ, ডান): ৪৫, ২৫, ১৫, ৩৫, ৩০, ৬৫, ৫৫, ৭৫.

    Postorder (বাঁ, ডান, নিজে): ১৫, ৩০, ৩৫, ২৫, ৫৫, ৭৫, ৬৫, ৪৫.

    Level-order: ৪৫, ২৫, ৬৫, ১৫, ৩৫, ৫৫, ৭৫, ৩০.

    Preorder আর level-order-এ root সবার আগে; postorder-এ সবার শেষে — এজন্যই free করতে postorder, কারণ root-কে সবার শেষে মুক্ত করতে হয়।

  3. Delete অনুশীলন: প্রথম অনুশীলনের ট্রি থেকে ২৫ মুছুন (দুটি child আছে)। কোন মান তার জায়গায় বসবে, আর মোছার পর ট্রিটি কেমন দেখাবে?

    ২৫-এর inorder successor খুঁজতে একবার ডানে (৩৫), তারপর যতদূর যায় বাঁয়ে (৩০)। তাই ৩০ বসবে ২৫-এর জায়গায়। এরপর পুরোনো ৩০ নোডটি মুছতে হবে — সেটি একটি leaf (অবস্থা ১), তাই ৩৫-এর left = NULL.

            45
          /    \
        30      65
       /  \    /  \
      15  35  55  75

    যাচাই: inorder এখন ১৫, ৩০, ৩৫, ৪৫, ৫৫, ৬৫, ৭৫ — সাজানো, আর একটি মান কম। Height-ও কমে ৩ হলো।

  4. কোড পরীক্ষা (ঐচ্ছিক): অংশ ৭-এর height.c-তে একটি ফাংশন যোগ করুন, যা একটি key খুঁজতে কয়টি নোড দেখতে হলো তা গুনে ফেরত দেয়। দুটি ট্রিতেই 1000 খুঁজে দেখুন।
    int search_steps(const Node *t, int key) {
        int steps = 0;
        while (t) {
            steps++;
            if (key == t->key) break;
            t = key < t->key ? t->left : t->right;
        }
        return steps;
    }
    // main-এ: printf("%d %d\n", search_steps(stick, 1000), search_steps(bushy, 1000));

    লাঠিতে 1000 পেতে 1000টি নোড দেখতে হয়। “আগে মাঝখান” ট্রিতে পথটি হলো ৫০০ → ৭৫০ → ৮৭৫ → ৯৩৮ → ৯৬৯ → ৯৮৫ → ৯৯৩ → ৯৯৭ → ৯৯৯ → ১০০০, অর্থাৎ 10টি। প্রতিটি ধাপে বাকি পরিসর প্রায় অর্ধেক — পর্ব ০২-এর খেলার মতোই।

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

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

ক্রমদুটি child আছে এমন নোড মোছার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 🔎 Root থেকে খুঁজে মুছতে চাওয়া নোডটিতে পৌঁছানো
  2. ➡️ সেই নোডের ডান child-এ একবার নামা
  3. ⬅️ সেখান থেকে যতদূর যায় বাঁয়ে নামা — এটিই inorder successor
  4. 📋 Successor-এর মান মুছতে চাওয়া নোডে কপি করা
  5. 🗑️ ডান subtree থেকে successor-এর পুরোনো নোডটি মুছে free করা

একটি ট্রিতে কোন নোডকে leaf বলে?

  • সবার ওপরের নোড
  • যে নোডের কোনো child নেই
  • যে নোডের ঠিক একটি child আছে
  • BST-এর সবচেয়ে ছোট মানের নোড

৫০, ৩০, ৭০, ২০, ৪০, ৬০, ৮০ দিয়ে বানানো BST-তে ৪০ খুঁজতে কোন পথে নামতে হবে?

  • ৫০ → ৭০ → ৬০ → ৪০
  • ৫০ → ৩০ → ২০ → ৪০
  • ৫০ → ৩০ → ৪০
  • সব নোড একে একে দেখতে হবে

BST-এর কোন traversal মানগুলো ছোট থেকে বড় ক্রমে দেয়?

  • Inorder
  • Preorder
  • Postorder
  • Level-order

খালি BST-তে ১, ২, ৩, … ১০০ ক্রমানুসারে ঢোকালে ট্রির height কত হবে (নোড গুনে)?

  • ৭
  • ৫০
  • ১০
  • ১০০

পুরো ট্রি free করতে কোন traversal ব্যবহার করা উচিত, আর কেন?

  • Preorder — root আগে মুক্ত হয়, তাই দ্রুত
  • Postorder — সন্তানরা আগে, নিজে সবার শেষে
  • Inorder — সাজানো ক্রমে মুক্ত হয়
  • দরকার নেই, প্রোগ্রাম শেষে সব মুক্ত হয়ে যায়

ডেটাবেসের index সাধারণত B-tree কেন, binary BST কেন নয়?

  • B-tree-তে কোনো তুলনা লাগে না
  • Binary BST-তে মান রাখাই যায় না
  • একটি নোডে অনেক key রেখে ট্রি কম উঁচু হয়, ফলে ডিস্ক থেকে কম বার পড়তে হয়
  • B-tree-র কোড সহজ

শব্দকোষ

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

মনে রাখুন

  1. ট্রি = স্তরে সাজানো নোড: root ওপরে, leaf নিচে, প্রতিটি subtree নিজেই একটি ট্রি — তাই recursion স্বাভাবিক।
  2. C-তে নোড = key + left + right, malloc দিয়ে heap-এ; খালি ট্রি = NULL.
  3. BST: বাঁয়ে ছোট, ডানে বড়। Search আর insert একই পথে হাঁটে, খরচ = ট্রির height.
  4. Inorder সাজানো ক্রম দেয়, postorder দিয়ে free, level-order-এ queue লাগে; দুই child-ওয়ালা নোড মুছতে inorder successor.
  5. সাজানো ইনপুটে BST লাঠি হয়ে O(n); সমাধান AVL/red-black, ডিস্কে B-tree. AI-এর কোড সাজানো ইনপুট, duplicate আর delete দিয়ে যাচাই করুন।

পরের পর্বে: BST-তে সব কিছু সাজানো থাকে। কিন্তু অনেক সময় দরকার শুধু একটি জিনিস — এই মুহূর্তের সবচেয়ে জরুরিটা কোনটি? হাসপাতালের জরুরি বিভাগ বা CPU-র কাজের তালিকা ঠিক এই প্রশ্ন করে। পরের পর্বে সেই প্রশ্নের দ্রুততম উত্তর — heap আর priority queue.

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

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