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

AVL tree — নিজে নিজে ভারসাম্য রাখা

AVL Trees — Self-Balancing BSTs: Balance Factor, Rotations, LL/RR/LR/RL Cases, Insert & Delete, and the Fibonacci Height Bound

Taught in Bangla, this lesson explains why plain BSTs degrade on sorted input and how AVL trees fix it: the balance factor rule, the Fibonacci-based O(log n) height bound via minimal AVL trees, single and double rotations, the LL, RR, LR and RL cases, rebalancing after insert and delete, storing height versus balance factor, and AVL versus red-black trees, with C++ and Python code.

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

  • কেন BST-কে নিজে নিজে ভারসাম্য রাখতে হয়, আর AVL-এর নিয়ম |bf| ≤ 1 ঠিক কী বলে
  • Minimal AVL tree আর Fibonacci সংখ্যা দিয়ে উচ্চতা O(log n)-এর প্রমাণ নিজে লেখা
  • Left আর right rotation-এ কোন pointer কখন বদলায়, আর কেন BST-র ক্রম ভাঙে না
  • LL, RR, LR, RL চিনে সঠিক rotation বাছাই করা, আর insert ও delete হাতে trace করা
  • C++ আর Python-এ AVL insert লিখে invariant যাচাই করা, আর AVL বনাম red-black-এর বাছাই বোঝা

১ · কেন ভারসাম্য — সাজানো ইনপুটে BST-এর বিপদ

পর্ব ২৭-এ binary search tree দেখেছেন: প্রতিটি node-এর বাঁ দিকের সব key তার চেয়ে ছোট, ডান দিকের সব key বড়। খুঁজতে গেলে প্রতিটি ধাপে একটি দিক বাদ পড়ে — ঠিক binary search-এর মতো। কিন্তু খরচ আসলে নির্ভর করে tree-র উচ্চতা-র ওপর: খোঁজা, insert, delete — সবই root থেকে একটি পথ ধরে নামে, তাই সময় O(h). Tree ঝাঁকড়া আর বেঁটে হলে h ≈ log₂ n; কিন্তু tree যদি এক দিকে হেলে লম্বা হয়ে যায়?

একটি বাস্তব দৃশ্য ভাবুন। একটি কলেজের ভর্তি অফিস প্রতিদিন নতুন শিক্ষার্থীর রোল নম্বর একটি BST-তে রাখে। রোল নম্বর তো বাড়তে থাকা ক্রমেই আসে: 1, 2, 3, 4, … প্রতিটি নতুন রোল আগের সবার চেয়ে বড়, তাই সে সবসময় সবচেয়ে ডানের node-এর ডান সন্তান হয়। দশজন ভর্তির পর tree দেখতে এরকম:

1
 \
  2
   \
    3
     \
      4            ... এভাবে 10 পর্যন্ত
       \
        5          উচ্চতা = n − 1 = 9
         \
          6        খুঁজতে 10টি তুলনা পর্যন্ত লাগে
সাজানো ক্রমে insert করলে BST একটি linked list হয়ে যায় — একে বলে degenerate tree.

এটি একটি degenerate tree — নামে tree, কাজে একটি linked list. দশ লাখ শিক্ষার্থী হলে একটি রোল খুঁজতে দশ লাখ ধাপ পর্যন্ত লাগতে পারে, যেখানে ভারসাম্যপূর্ণ tree-তে লাগত মাত্র ২০টির মতো। আর ইনপুট সাজানো হয়ে আসা মোটেই বিরল ঘটনা নয়: তারিখ, ক্রমিক নম্বর, লেনদেনের ID, আগে থেকে সাজানো ফাইল — বাস্তবের অনেক ডেটাই প্রায়-সাজানো। কনটেস্টে problem setter-রা ইচ্ছে করেই এমন test রাখেন, যাতে সাধারণ BST-এর কোড time limit ছাড়িয়ে যায়।

সমাধান কী? ইনপুট এলোমেলো করে নেওয়া সবসময় সম্ভব নয় — ডেটা একটার পর একটা আসে (online), আগে থেকে সব হাতে থাকে না। তাই দরকার এমন BST, যা প্রতিটি insert বা delete-এর পর নিজেই নিজের আকার ঠিক করে নেয়, যাতে উচ্চতা সবসময় O(log n) থাকে। একে বলে self-balancing BST. এর প্রথম উদাহরণ, আর আজও সবচেয়ে কড়া ভারসাম্যের জনপ্রিয় রূপটি হলো AVL tree.

বাঁকের দুই ঝুড়ি. গ্রামের হাটে কাঁধে বাঁক নিয়ে চলা ফেরিওয়ালাকে দেখেছেন — বাঁশের দুই মাথায় দুটি ঝুড়ি। এক ঝুড়িতে বেশি মাল উঠলে তিনি পুরো ভার নতুন করে সাজান না; শুধু কাঁধের জায়গাটা একটু সরিয়ে নেন, বা একটি-দুটি জিনিস এক ঝুড়ি থেকে অন্যটিতে নেন। দুই দিক “একেবারে সমান” হতে হবে না — একটু এদিক-ওদিক চলে, শুধু বেশি হেলে পড়লে চলবে না। AVL tree ঠিক তা-ই করে: প্রতিটি node-এ দুই দিকের উচ্চতার পার্থক্য সর্বোচ্চ ১ সহ্য করে, আর তার বেশি হলে স্থানীয়ভাবে, অল্প কয়েকটি pointer সরিয়ে ভারসাম্য ফেরায়।

১৯৬২ সালে মস্কোর দুই সোভিয়েত গণিতবিদ Georgy Adelson-Velsky আর Evgenii Landis সোভিয়েত বিজ্ঞান একাডেমির পত্রিকা Doklady Akademii Nauk SSSR-এ একটি ছোট প্রবন্ধ প্রকাশ করেন, যার শিরোনামের অর্থ “তথ্য সাজানোর একটি অ্যালগরিদম”. সেখানেই প্রথম এমন একটি binary search tree আসে, যা প্রতিটি insert-এর পর নিজের ভারসাম্য ঠিক রাখে, আর খোঁজা ও যোগ — দুটোই worst case-এ O(log n)-এ করে। তাঁদের নামের আদ্যক্ষর থেকেই নাম AVL. প্রবন্ধটির ইংরেজি অনুবাদ একই বছর Soviet Mathematics Doklady-তে আসে, আর পরে Donald Knuth-এর The Art of Computer Programming-এর তৃতীয় খণ্ডের মাধ্যমে এটি সারা বিশ্বের পাঠ্যবইয়ে ছড়িয়ে পড়ে। Adelson-Velsky পরে Kaissa নামের দাবা প্রোগ্রামের নির্মাতাদের একজন ছিলেন, যেটি ১৯৭৪ সালে প্রথম বিশ্ব কম্পিউটার দাবা চ্যাম্পিয়নশিপ জেতে।

২ · Balance factor আর AVL-এর একমাত্র নিয়ম

প্রথমে উচ্চতার সংজ্ঞা পাকা করি, কারণ বইভেদে এখানে ১-এর পার্থক্য থাকে। এই পর্বে (আর কোডে) কোনো node-এর উচ্চতা মানে সেই node থেকে তার নিচের সবচেয়ে গভীর leaf পর্যন্ত edge-এর সংখ্যা। তাই একটি leaf-এর উচ্চতা 0, আর খালি tree-র উচ্চতা −1 ধরা হয় — যাতে “উচ্চতা = 1 + দুই সন্তানের উচ্চতার বড়টি” সূত্রটি leaf-এর জন্যও খাটে: 1 + max(−1, −1) = 0. (অনেক বই আর কোড খালি tree-কে 0, leaf-কে 1 ধরে। তাতে সব উচ্চতা ১ করে বাড়ে, কিন্তু নিচের পার্থক্যগুলো একই থাকে।)

প্রতিটি node-এর একটি subtree বাঁয়ে, একটি ডানে। এদের উচ্চতার পার্থক্যই balance factor:

bf(v) = height(v.left) − height(v.right)

bf ধনাত্মক মানে বাঁ দিক উঁচু (বাঁয়ে ভারী), ঋণাত্মক মানে ডান দিক উঁচু। (কিছু বই উল্টো চিহ্ন ব্যবহার করে — ডান − বাঁ; পরীক্ষায় প্রশ্ন কোন সংজ্ঞা ধরেছে, আগে দেখে নিন।) এবার AVL-এর পুরো সংজ্ঞাটি এক লাইনে:

AVL invariant

একটি BST তখনই AVL tree, যখন এর প্রতিটি node-এর জন্য |bf(v)| ≤ 1 — অর্থাৎ bf শুধু −1, 0 বা +1. শুধু root-এ নয়, প্রতিটি node-এ। Insert বা delete এই নিয়ম সাময়িক ভাঙতে পারে (bf হয়ে যায় ±2), আর ঠিক তখনই মেরামত চলে।

“প্রতিটি node-এ” কথাটি গুরুত্বপূর্ণ। শুধু root-এর দুই দিক সমান উচ্চতার হলেই চলবে না — নিচের দুটি দিকই আলাদা আলাদা লম্বা শিকল হতে পারে, যেখানে root-এর bf 0 অথচ উচ্চতা প্রায় n/2. তিনটি ছোট উদাহরণ দেখুন (বন্ধনীতে bf):

   (ক) AVL              (খ) AVL নয়            (গ) AVL নয়
     8(+1)                8(+2)                 8(0)
    /    \               /                     /    \
  4(0)   9(0)          4(+1)                 4(+2)   12(-2)
  / \                  /                     /          \
 2   6               2(0)                  2(+1)        14(-1)
                                           /               \
                                          1                 15
(ক)-তে প্রতিটি bf −1, 0 বা +1. (খ)-তে root-এর bf +2. (গ)-তে root-এর bf 0, অথচ 4 আর 12-এ নিয়ম ভাঙা।

(গ)-তে প্রতিটি মান নিজে মিলিয়ে নিন। 4-এর বাঁয়ে 2 → 1 শিকল, উচ্চতা 1; ডানে কিছু নেই, উচ্চতা −1; তাই bf(4) = 1 − (−1) = +2. একই হিসাবে bf(12) = −1 − 1 = −2. Root-এর দুই দিকের উচ্চতা 2 আর 2 — bf 0, তবু tree-টি AVL নয়। চোখে দেখে “প্রায় সমান” মনে হলেও প্রতিটি node-এর bf নিচ থেকে ওপরে উচ্চতা গুনে বের করুন — এটিই নিরাপদ পদ্ধতি, আর কোডেও ঠিক তা-ই করা হবে।

bfঅর্থকী করতে হবে
0দুই দিক সমান উঁচুকিছু না
+1বাঁ দিক এক ধাপ উঁচুকিছু না — অনুমোদিত
−1ডান দিক এক ধাপ উঁচুকিছু না — অনুমোদিত
+2বাঁ দিক দুই ধাপ উঁচু — নিয়ম ভাঙাLL বা LR rotation (অংশ ৫)
−2ডান দিক দুই ধাপ উঁচু — নিয়ম ভাঙাRR বা RL rotation (অংশ ৫)

লক্ষ করুন, +3 বা −3 কখনো আসে না। একটি insert বা delete কোনো subtree-র উচ্চতা সর্বোচ্চ ১ বদলায়, আর মেরামত সঙ্গে সঙ্গে হয় — তাই নিয়ম ভাঙলেও bf ঠিক ±2-তেই থামে। কোডে এই তথ্যটি সাহায্য করে: শুধু দুটি অবস্থা (bf > 1, bf < −1) সামলালেই হয়।

৩ · উচ্চতা কেন O(log n) — minimal AVL tree আর Fibonacci

|bf| ≤ 1 নিয়মটি শিথিল — নিখুঁত ভারসাম্য চায় না। তাহলে উচ্চতা কতটা খারাপ হতে পারে? প্রশ্নটি উল্টো করে ধরলে সহজ হয়: উচ্চতা h-এর একটি AVL tree-তে সবচেয়ে কম কয়টি node থাকতে পারে? এই সংখ্যাকে বলি N(h). যদি দেখাতে পারি N(h) সূচকীয় হারে বাড়ে, তাহলে n টি node-এ উচ্চতা log-এর বেশি হতে পারবে না।

সবচেয়ে কম node-এ উচ্চতা h বানাতে হলে root-এর এক দিকে উচ্চতা h − 1-এর subtree লাগবেই (নইলে root-এর উচ্চতা h হবে না)। অন্য দিকটি যত ছোট সম্ভব রাখতে চাই — কিন্তু |bf| ≤ 1-এর জন্য সেটি h − 2-এর নিচে নামতে পারে না। আর দুই subtree নিজেরাও AVL, তাই তারাও নিজ নিজ উচ্চতার সবচেয়ে ছোট AVL tree হলেই node সবচেয়ে কম। এভাবে যে tree পাওয়া যায় তাকে বলে minimal AVL tree (বা Fibonacci tree). নিচের ডায়াগ্রামে @ হলো root, a চিহ্নের node-গুলো উচ্চতা h − 1-এর minimal tree, b চিহ্নেরগুলো উচ্চতা h − 2-এর:

সবচেয়ে কম node-এ উচ্চতা hসবুজ = root; a = T(h−1), b = T(h−2)

            
প্রতিটি ধাপে
উচ্চতা h
N(h) = 1 + N(h−1) + N(h−2)
N(h) + 1
  1. h = 0. একটি মাত্র node. উচ্চতা 0-র tree-তে অন্তত ১টি node লাগে — N(0) = 1.
  2. h = 1. root আর তার একটি সন্তান (a). বাঁ subtree-র উচ্চতা 0, ডান খালি (−1), তাই root-এর bf +1 — অনুমোদিত। N(1) = 2. (এখানে “h − 2”-এর দিকটি খালি tree.)
  3. h = 2. এক দিকে উচ্চতা 1-এর minimal tree (দুটি a), অন্য দিকে উচ্চতা 0-র (একটি b). ডান দিক খালি রাখলে root-এর bf হতো +2 — নিষেধ। N(2) = 1 + 2 + 1 = 4.
  4. h = 3. a অংশ = উচ্চতা 2-এর minimal tree (৪টি node), b অংশ = উচ্চতা 1-এর (২টি). N(3) = 7. লক্ষ করুন, প্রতিটি internal node-এর bf +1 — নিয়ম মেনে যতটা হেলে থাকা সম্ভব, ততটাই।
  5. h = 4. N(4) = 1 + 7 + 4 = 12. ধারাটি 1, 2, 4, 7, 12, 20, 33, … — প্রতিটির সাথে 1 যোগ করলে 2, 3, 5, 8, 13, 21, 34: Fibonacci সংখ্যা!

প্রমাণ — Fibonacci থেকে 1.44 log₂ n

ডায়াগ্রাম থেকে recurrence পাওয়া গেল (পর্ব ০৮-এর ভাষায় একটি recurrence):

N(0) = 1,  N(1) = 2,  N(h) = N(h−1) + N(h−2) + 1

দুই দিকে 1 যোগ করুন: N(h) + 1 = (N(h−1) + 1) + (N(h−2) + 1). অর্থাৎ M(h) = N(h) + 1 ঠিক Fibonacci-র নিয়ম মানে, আর M(0) = 2 = F(3), M(1) = 3 = F(4) (এখানে F(1) = F(2) = 1, F(3) = 2, …). Induction দিয়ে সরাসরি: যদি M(h−1) = F(h+2) আর M(h−2) = F(h+1), তাহলে M(h) = F(h+2) + F(h+1) = F(h+3). তাই

N(h) = F(h+3) − 1

পর্ব ০৮-এ দেখেছেন, Fibonacci সংখ্যা golden ratio φ = (1 + √5)/2 ≈ 1.618-এর সূচকের মতো বাড়ে: F(k) হলো φk/√5-এর নিকটতম পূর্ণসংখ্যা। এবার যেকোনো AVL tree নিন, যার n টি node আর উচ্চতা h. minimal tree-র সংজ্ঞা অনুযায়ী n ≥ N(h) = F(h+3) − 1, তাই n + 2 > F(h+3) + ½ > φh+3/√5. দুই দিকে logφ নিন:

h + 3 < logφ(n + 2) + logφ√5  ⇒  h < 1.4404 · log₂(n + 2) − 1.328

এখানে logφ x = log₂ x / log₂ φ ≈ 1.4404 · log₂ x, আর logφ √5 ≈ 1.672. মোদ্দা কথা: AVL tree-র উচ্চতা কখনো 1.44 log₂ n-এর চেয়ে উল্লেখযোগ্যভাবে বেশি হয় না — নিখুঁত ভারসাম্যের চেয়ে বড়জোর প্রায় ৪৪% বেশি। আর minimal tree-গুলো নিজেরাই বৈধ AVL tree, তাই এই সীমা আঁটসাঁট (tight): এর চেয়ে ভালো সীমা প্রমাণ করা যায় না।

node সংখ্যা nনিখুঁত ভারসাম্যে ⌊log₂ n⌋AVL-এ সর্বোচ্চ উচ্চতাসাধারণ BST-এ worst case
10339
1,000913999
1061927999,999
1092941প্রায় 109

AVL-এর কলামটি সূত্র থেকে নয়, সরাসরি N(h) থেকে: সবচেয়ে বড় h, যার জন্য N(h) ≤ n. যেমন N(27) = 832,039 ≤ 106 < N(28) = 1,346,268, তাই দশ লাখ node-এর AVL tree-র উচ্চতা কখনো 27 ছাড়ায় না। দশ লাখ রোল নম্বরের যেকোনোটি খুঁজতে সর্বোচ্চ ২৮টি তুলনা — degenerate BST-এর দশ লাখের জায়গায়।

৪ · Rotation — তিনটি pointer বদলে ভারসাম্য

ভারসাম্য ফেরানোর একমাত্র যন্ত্র rotation. ধারণাটি একটি অফিসের পদ বদলের মতো: বিভাগের প্রধান y অবসরে যাচ্ছেন না, শুধু তাঁর সহকারী x তাঁর জায়গায় উঠে আসছেন, আর y হয়ে যাচ্ছেন x-এর অধীন। কিন্তু সবার বাঁ-ডান ক্রম (কে কার চেয়ে ছোট) একই থাকে। নিচে right rotation (y-এ, যার বাঁ সন্তান x) ধাপে ধাপে দেখুন। A, B, C হলো তিনটি subtree — খালিও হতে পারে, বিশাল-ও হতে পারে; rotation-এ এদের ভেতরে কিছুই বদলায় না।

p = y-এর parent; A, B, C = subtreeসবুজ = এই ধাপে বদলানো

            
প্রতিটি ধাপের পর
y.left
x.right
p-এর সন্তান
উচ্চতা
in-order ক্রম
  1. শুরু. p-এর সন্তান y, y-এর বাঁ সন্তান x. ধরুন A-এর উচ্চতা h+1, B আর C-এর h. তাহলে x-এর উচ্চতা h+2, y-এর h+3, আর bf(y) = (h+2) − h = +2 — বাঁ দিক ভারী।
  2. y.left = x.right. B সরে y-এর বাঁ সন্তান হলো। এটি বৈধ, কারণ B-এর সব key x-এর চেয়ে বড় আর y-এর চেয়ে ছোট — y-এর বাঁয়ে বসার ঠিক যোগ্য। এই মুহূর্তে x.right-ও B-কে দেখায় (ছবিতে B দুবার), পরের লাইনেই বদলাবে।
  3. x.right = y. y তার পুরো subtree (B, C) নিয়ে x-এর ডান সন্তান হলো। কিন্তু p এখনও পুরোনো y-কেই দেখাচ্ছে — এটি ঠিক না করলে পুরো x-অংশটি tree থেকে হারিয়ে যাবে।
  4. উচ্চতা হালনাগাদ. আগে y (সে এখন নিচে): 1 + max(h, h) = h+1. তারপর x: 1 + max(h+1, h+1) = h+2. ক্রম উল্টালে x পুরোনো y-এর উচ্চতা ধরে ভুল মান পেত।
  5. p-এর সন্তান = x. কোডে ফাংশনটি x ফেরত দেয়, আর কলকারী লেখে t->l = rotRight(t->l) বা root = rotRight(root). মোট তিনটি pointer আর দুটি উচ্চতা — O(1) কাজ। subtree-র উচ্চতা h+3 থেকে h+2, দুই node-এর bf 0, আর in-order ক্রম A x B y C একটুও বদলায়নি।

কেন BST-র নিয়ম ভাঙে না. BST-র শর্তটি আসলে in-order ক্রমের শর্ত: tree-কে বাঁ থেকে ডানে পড়লে (in-order traversal, পর্ব ২৬) key-গুলো সাজানো ক্রমে আসে। Rotation-এর আগে ক্রম A, x, B, y, C; পরে x-এর বাঁয়ে A, ডানে y — আর y-এর বাঁয়ে B, ডানে C — অর্থাৎ আবারও A, x, B, y, C. যেহেতু ক্রম একই, BST-ও বৈধ। আর A, B, C-এর বাইরের কোনো node (p ও তার ওপরের সবাই) কোনো পরিবর্তন দেখে না, শুধু p-এর একটি pointer ছাড়া।

Left rotation হুবহু আয়নার প্রতিবিম্ব: x-এ, যার ডান সন্তান y — x.right = y.left, y.left = x, আগে x তারপর y-এর উচ্চতা, আর parent-এর pointer y-তে। Right rotation বাঁয়ে ভারী অবস্থা সারায় (বাঁ সন্তানকে ওপরে তোলে), left rotation ডানে ভারী অবস্থা।

Node* rotRight(Node* y) {          // y's left child x moves up
    Node* x = y->l;
    y->l = x->r;                    // subtree B changes parent: x -> y
    x->r = y;
    fix(y); fix(x);                 // heights: lower node first
    return x;                       // caller links the new subtree root
}
Node* rotLeft(Node* x) {           // mirror image: x's right child y moves up
    Node* y = x->r;
    x->r = y->l;
    y->l = x;
    fix(x); fix(y);
    return y;
}

৫ · চার ধরনের ভারসাম্যহীনতা — LL, RR, LR, RL

ধরুন কোনো node z-এর bf হয়ে গেল ±2. কোন rotation লাগবে, তা ঠিক করে কোন পথে ভারী: z থেকে উঁচু সন্তানের দিকে এক ধাপ, তারপর সেই সন্তানের উঁচু দিকে আরেক ধাপ। দুই ধাপের দিক মিলিয়ে চারটি নাম — L মানে বাঁয়ে, R মানে ডানে।

নামচেনার উপায়আকারসমাধান
LLbf(z) = +2, bf(z.left) ≥ 0সোজা লাইন, বাঁয়েz-এ একটি right rotation
RRbf(z) = −2, bf(z.right) ≤ 0সোজা লাইন, ডানেz-এ একটি left rotation
LRbf(z) = +2, bf(z.left) = −1আঁকাবাঁকা: বাঁয়ে, তারপর ডানেz.left-এ left, তারপর z-এ right rotation
RLbf(z) = −2, bf(z.right) = +1আঁকাবাঁকা: ডানে, তারপর বাঁয়েz.right-এ right, তারপর z-এ left rotation

“≥ 0” আর “≤ 0”-এর সমান চিহ্নটি এখনই খেয়াল রাখুন: insert-এর পর সন্তানের bf কখনো 0 হয় না (অংশ ৬), কিন্তু delete-এর পর হতে পারে — আর তখন single rotation-ই ঠিক উত্তর (অংশ ৭). সোজা লাইনের দুটি অবস্থা আগে দেখি — ছোট তিনটি key দিয়ে:

বন্ধনীতে balance factorসবুজ = এইমাত্র এসেছে / মেরামতের কেন্দ্র

            
প্রতিটি ধাপের পর
insert-এর ক্রম
ভারসাম্যহীন node
কাজ
  1. 30, তারপর 20. 20 < 30, তাই বাঁ সন্তান। 30-এর বাঁ উচ্চতা 0, ডান −1: bf +1 — অনুমোদিত।
  2. 10 এল. 10 যায় 20-এর বাঁয়ে। এখন 30-এর বাঁ উচ্চতা 1, ডান −1 — bf +2, নিয়ম ভাঙা। ভারী পথ: 30 → বাঁ → বাঁ. এটি LL.
  3. 30-এ right rotation. 20 উঠে এল root-এ, 30 তার ডান সন্তান। তিনজনেরই bf 0, উচ্চতা 2 থেকে নেমে 1.
  4. আয়নার ছবি: 10, 20, 30. এবার প্রতিটি নতুন key ডানে যায়। 10-এর bf −2, ডান সন্তান 20-এর bf −1 — ভারী পথ ডান → ডান: RR.
  5. 10-এ left rotation. 20 root, 10 বাঁয়ে, 30 ডানে — আবার নিখুঁত। দুই ক্ষেত্রেই ফল একই tree, কারণ তিনটি key-এর একমাত্র ভারসাম্যপূর্ণ BST এটাই।

এবার আঁকাবাঁকা অবস্থা। এখানে একটি rotation যথেষ্ট নয় — আর কেন নয়, তা নিজের চোখে দেখা দরকার:

বন্ধনীতে balance factorসবুজ = লক্ষ করার node

            
প্রতিটি ধাপের পর
কাজ
root, তার bf
আকার
  1. LR অবস্থা. 30-এর bf +2, কিন্তু তার বাঁ সন্তান 10-এর bf −1: ভার আছে 10-এর ডান দিকে, 20-এ। ভারী পথ বাঁ → ডান.
  2. ভুল চেষ্টা. LL-এর মতো শুধু 30-এ right rotation করলে 10 root হয়, আর 20 (10-এর ডান subtree, অর্থাৎ B) চলে যায় 30-এর বাঁয়ে। ফল: 10-এর bf −2 — সমস্যা শুধু আয়নায় উল্টে গেল, সারল না। কারণ rotation-এ মাঝের subtree B এক পাশ থেকে আরেক পাশে যায়, কিন্তু তার উচ্চতা কমে না।
  3. সঠিক ধাপ ১. আসল tree থেকে আগে সন্তান 10-এ left rotation: 20 উঠে 30-এর বাঁ সন্তান হলো, 10 নামল 20-এর বাঁয়ে। এখন আকার সোজা লাইন — LL.
  4. সঠিক ধাপ ২. এবার 30-এ right rotation, ঠিক LL-এর মতো। মাঝের key 20 root-এ, সবার bf 0. দুটি rotation মিলে একটি double rotation: “নাতি” (z-এর সন্তানের সন্তান) সরাসরি দুই ধাপ ওপরে ওঠে।

RL হুবহু আয়না। কিন্তু তিন node-এর ছোট উদাহরণে একটি জিনিস চোখে পড়ে না: নাতির নিজের দুটি subtree কোথায় যায়? সাধারণ রূপে দেখুন — z-এর ডান সন্তান y, y-এর বাঁ সন্তান x, আর A, B, C, D চারটি subtree:

[A] … [D] = subtreeসবুজ = এই ধাপের মূল node

            
প্রতিটি ধাপের পর
কাজ
উচ্চতা
in-order ক্রম
  1. শুরু. z-এর ডান দিক দুই ধাপ উঁচু (bf −2), আর ডান সন্তান y-এর বাঁ দিক উঁচু (bf +1) — আঁকাবাঁকা পথ ডান → বাঁ: RL. ভার আছে নাতি x-এর subtree-তে।
  2. y-এ right rotation. x উঠে z-এর ডান সন্তান হলো; x-এর ডান subtree C চলে গেল y-এর বাঁয়ে। এখন আকার সোজা লাইন ডানে — RR.
  3. z-এ left rotation. x root-এ; x-এর বাঁ subtree B চলে গেল z-এর ডানে। ফল: x-এর বাঁয়ে z (A, B), ডানে y (C, D). উচ্চতা h+2 — দুই node-এর bf 0 বা ±1. নাতির দুই subtree দুই দিকে ভাগ হয়ে গেল, তাই ভারও ভাগ হলো। in-order ক্রম অটুট।
মনে রাখার কৌশল

সোজা লাইন (LL, RR) → একবার, মাঝের node-কে ওপরে তুলুন। আঁকাবাঁকা (LR, RL) → দুবার: প্রথমে সন্তানে ঘুরিয়ে লাইন সোজা করুন, তারপর z-এ। চারটি ক্ষেত্রেই শেষে z, তার সন্তান আর (double হলে) নাতির মধ্যে যে key-টি মাঝের মান, সে-ই নতুন subtree-র root.

৬ · Insert — ফেরার পথে প্রথম ভারসাম্যহীন পূর্বপুরুষে মেরামত

AVL insert দুই ভাগে চলে। নামার পথ: সাধারণ BST-এর মতো root থেকে নেমে খালি জায়গায় নতুন leaf বসানো (পর্ব ২৭). ফেরার পথ: নতুন leaf থেকে root-এর দিকে উঠতে উঠতে প্রতিটি পূর্বপুরুষের উচ্চতা আর bf হালনাগাদ করা — একে বলে retracing. Recursive কোডে এটি আপনাআপনি হয়: recursive call থেকে ফেরার পর প্রতিটি স্তরে rebalance চলে। (পর্ব ০৭-এর recursion-এর call stack-ই এখানে “ফেরার পথ” মনে রাখে।)

Pseudocode — AVL insert (height of an empty tree = -1)

height(t):  if t is empty: return -1  else: return t.h
bf(t):      return height(t.left) - height(t.right)

rebalance(t):                          // both children are valid AVL trees
    t.h = 1 + max(height(t.left), height(t.right))
    if bf(t) = +2:                                        // left side too tall
        if bf(t.left) < 0: t.left = rotateLeft(t.left)     // LR: straighten first
        return rotateRight(t)                             // LL
    if bf(t) = -2:                                        // right side too tall
        if bf(t.right) > 0: t.right = rotateRight(t.right) // RL: straighten first
        return rotateLeft(t)                              // RR
    return t

insert(t, k):
    if t is empty: return new leaf(k)            // h = 0, bf = 0
    if k < t.key:      t.left  = insert(t.left, k)
    else if k > t.key: t.right = insert(t.right, k)
    return rebalance(t)                          // runs on the way back up

একটি বড় উদাহরণে retracing দেখুন। বৈধ AVL tree-তে 25 যোগ করা হচ্ছে:

বন্ধনীতে balance factorসবুজ = এই ধাপে দেখা / বদলানো

            
প্রতিটি ধাপের পর
কোথায় আছি
উচ্চতা বদলাল?
কাজ
  1. শুরু. ছয়টি node-এর একটি বৈধ AVL tree: প্রতিটি bf −1, 0 বা +1. 30-এর শুধু বাঁ সন্তান, তাই তার bf +1.
  2. নামার পথ. 25 < 40 → বাঁয়ে; 25 < 30 → বাঁয়ে; 25 > 20 → ডানে, জায়গা খালি — 25 সেখানে leaf হিসেবে বসল (উচ্চতা 0, bf 0).
  3. ফেরার পথ. 20: বাঁ −1, ডান 0 → উচ্চতা 1, bf −1. অনুমোদিত, কিন্তু উচ্চতা বেড়েছে, তাই ওপরে যেতে হবে। 30: বাঁ উচ্চতা 1, ডান −1 → bf +2. এটিই প্রথম ভারসাম্যহীন পূর্বপুরুষ. বাঁ সন্তান 20-এর bf −1 — LR.
  4. double rotation-এর প্রথম অর্ধেক. 20-এ left rotation: 25 উঠে এল, 20 তার বাঁ সন্তান। 30-এর নিচে এখন সোজা লাইন 30 → 25 → 20.
  5. দ্বিতীয় অর্ধেক, আর থামা. 30-এ right rotation: 25 এই subtree-র root, দুই পাশে 20 আর 30, সবার bf 0. subtree-র উচ্চতা 1 — insert-এর আগেও 30-এর subtree-র উচ্চতা 1 ছিল! তাই 40 কোনো পরিবর্তন দেখে না: bf 0-ই থেকে যায়, আর ওপরে কিছু করার নেই।

প্রমাণ — কেন insert-এ একটি মেরামতই যথেষ্ট

শেষ ধাপে যা ঘটল, তা কাকতালীয় নয়। দাবি: insert-এর পর প্রথম ভারসাম্যহীন পূর্বপুরুষ z-এ rotation করলে z-এর subtree-র উচ্চতা ঠিক insert-এর আগের উচ্চতায় ফিরে আসে. LL ক্ষেত্রে হিসাবটি দেখুন। insert-এর আগে z-এর উচ্চতা h+2 ধরি, বাঁ সন্তান y-এর h+1, ডান subtree C-এর h (z-এর bf তখন +1 ছিল — নইলে একটি insert-এ +2 হতো না)। y-এর বাঁ subtree A আর ডান B দুটোই উচ্চতা h (insert-এর আগে y-এর bf 0 ছিল — +1 হলে y নিজেই প্রথম ভারসাম্যহীন হতো, আর −1 হলে A বাড়লেও y-এর উচ্চতা বাড়ত না)। নতুন key A-তে গিয়ে A-কে h+1 করল; তখন y = h+2, z = h+3, bf(z) = +2. Right rotation-এর পর: z-এর সন্তান B (h) আর C (h), তাই z = h+1; y-এর সন্তান A (h+1) আর z (h+1), তাই y = h+2 — আগের মতোই h+2.

LR ক্ষেত্রে: আগে z = h+2, y = h+1 (A = h, নাতি x = h), D = h. insert x-এর কোনো subtree-কে বাড়িয়ে x-কে h+1 করে (x-এর দুই subtree তখন h আর h − 1, বা x নিজেই নতুন leaf). Double rotation-এর পর x root, তার বাঁয়ে y (A আর B — দুটোর উচ্চতা ≤ h, অন্তত একটি h), ডানে z (C আর D) — দুজনেরই উচ্চতা h+1, তাই x = h+2. আবারও আগের উচ্চতা। RR আর RL আয়না।

তাহলে z-এর ওপরের সব পূর্বপুরুষ দেখে, তাদের নিচের subtree-র উচ্চতা একটুও বদলায়নি — তাদের bf আগের মতোই বৈধ। তাই একটি insert-এ সর্বোচ্চ একটি single বা একটি double rotation (অর্থাৎ ≤ ২টি মৌলিক rotation). আরও একটি থামার শর্ত আছে: ফেরার পথে কোনো node-এর উচ্চতা যদি না বাড়ে (তার bf ±1 থেকে 0 হলো), তাহলেও ওপরে আর কিছু বদলাবে না। Iterative কোডে এই দুই জায়গায় loop ভেঙে বেরিয়ে আসা যায়; recursive কোডে বাকি স্তরগুলো শুধু উচ্চতা হিসাব করে, কিছু বদলায় না।

আর একটি সূক্ষ্ম কথা: insert-এর পর z-এর উঁচু সন্তানের bf কখনো 0 হয় না। সন্তানটির উচ্চতা এইমাত্র বেড়েছে, আর উচ্চতা বাড়ে কেবল একটি দিক বাড়লে — তাই তার bf ±1. এজন্যই insert-এ শুধু চারটি অবস্থা।

কোডে — কোন rotation কখন চলল, তা ছাপিয়ে দেখা

নিচের প্রোগ্রাম দশটি key একে একে insert করে, আর প্রতিটির পর ছাপায় কোন rotation চলল আর tree-টি কেমন দেখাচ্ছে। Tree ছাপানোর ছোট রূপ: (বাঁ key ডান), খালি subtree = ., leaf শুধু তার key. শেষে check পুরো tree ঘুরে তিনটি জিনিস assert করে: BST ক্রম, প্রতিটি node-এর |bf| ≤ 1, আর রাখা উচ্চতা আসল উচ্চতার সমান।

#include <algorithm>
#include <cassert>
#include <cstdio>
#include <initializer_list>
#include <string>
using namespace std;
struct Node { int key, h = 0; Node *l = nullptr, *r = nullptr; };
int ht(Node* t) { return t ? t->h : -1; }                    // empty tree has height -1
void fix(Node* t) { t->h = 1 + max(ht(t->l), ht(t->r)); }
int bf(Node* t) { return ht(t->l) - ht(t->r); }              // balance factor
Node* rotRight(Node* y) { Node* x = y->l; y->l = x->r; x->r = y; fix(y); fix(x); return x; }
Node* rotLeft(Node* x) { Node* y = x->r; x->r = y->l; y->l = x; fix(x); fix(y); return y; }
string fired;                                                // which rotation this insert used
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);
    fix(t);                                                  // retracing: we are on the way back up
    if (bf(t) > 1) {                                         // left side too tall: LL or LR
        fired = string(bf(t->l) < 0 ? "LR" : "LL") + " at " + to_string(t->key);
        if (bf(t->l) < 0) t->l = rotLeft(t->l);
        t = rotRight(t);
    } else if (bf(t) < -1) {                                 // right side too tall: RR or RL
        fired = string(bf(t->r) > 0 ? "RL" : "RR") + " at " + to_string(t->key);
        if (bf(t->r) > 0) t->r = rotRight(t->r);
        t = rotLeft(t);
    }
    return t;
}
string show(Node* t) {                                       // "(left key right)", "." = empty
    if (!t) return ".";
    if (!t->l && !t->r) return to_string(t->key);
    return "(" + show(t->l) + " " + to_string(t->key) + " " + show(t->r) + ")";
}
int check(Node* t, long lo, long hi) {                       // returns height; asserts every rule
    if (!t) return -1;
    int a = check(t->l, lo, t->key), b = check(t->r, t->key, hi);
    assert(lo < t->key && t->key < hi && a - b <= 1 && b - a <= 1 && t->h == 1 + max(a, b));
    return t->h;
}
int main() {
    Node* root = nullptr;
    for (int k : {50, 40, 30, 60, 70, 20, 25, 80, 75, 10}) {
        fired = "none"; root = insert(root, k);
        printf("insert %2d  %-9s  %s\n", k, fired.c_str(), show(root).c_str());
    }
    printf("AVL invariant OK, height = %d\n", check(root, -1000000, 1000000));
}

আউটপুট:

insert 50  none       50
insert 40  none       (40 50 .)
insert 30  LL at 50   (30 40 50)
insert 60  none       (30 40 (. 50 60))
insert 70  RR at 50   (30 40 (50 60 70))
insert 20  none       ((20 30 .) 40 (50 60 70))
insert 25  LR at 30   ((20 25 30) 40 (50 60 70))
insert 80  none       ((20 25 30) 40 (50 60 (. 70 80)))
insert 75  RL at 70   ((20 25 30) 40 (50 60 (70 75 80)))
insert 10  none       (((10 20 .) 25 30) 40 (50 60 (70 75 80)))
AVL invariant OK, height = 3

চারটি অবস্থাই এক দৌড়ে এসেছে। 30-এ LL (30, 40, 50-এর সোজা লাইন), 70-এ RR, 25-এ LR — ঠিক ওপরের ডায়াগ্রামের ঘটনা — আর 75-এ RL (70 → 80 → 75 আঁকাবাঁকা)। প্রতিটি insert-এ সর্বোচ্চ একটি মেরামত। ছাপা রূপটি বাঁ থেকে ডানে পড়ুন: সংখ্যাগুলো সবসময় সাজানো — in-order ক্রম কোনো rotation-এ ভাঙেনি। দশটি node-এ উচ্চতা 3; একই ক্রমে সাধারণ BST-তে উচ্চতা হতো 4, আর সাজানো ক্রমে 9.

কোডের খুঁটিনাটি: ht(nullptr) = -1 বলে leaf-এর উচ্চতা আপনাআপনি 0. insert নতুন subtree-র root ফেরত দেয়, আর কলকারী সেটি নিজের pointer-এ বসায় (t->l = insert(...)) — rotation-এর পর parent-এর pointer এভাবেই ঠিক হয়। bf(t->l) < 0 হলে LR, নইলে LL: সমান চিহ্নটি (bf 0 → LL) insert-এ কখনো লাগে না, কিন্তু একই rebalance delete-এ ব্যবহার করলে এটিই সঠিক পছন্দ। সমান key এলে কিছুই যোগ হয় না (set-এর আচরণ)। ছোট প্রোগ্রাম বলে delete দিয়ে মেমরি ফেরত দেওয়া বাদ রাখা হয়েছে। আর check-এর assert শুধু পরীক্ষার জন্য — O(n), প্রতিটি insert-এ চালালে চলবে না।

class Node:
    def __init__(self, key): self.key, self.h, self.l, self.r = key, 0, None, None
def ht(t): return t.h if t else -1                 # empty tree has height -1
def fix(t): t.h = 1 + max(ht(t.l), ht(t.r))
def bf(t): return ht(t.l) - ht(t.r)                # balance factor
def rot_right(y): x = y.l; y.l, x.r = x.r, y; fix(y); fix(x); return x
def rot_left(x): y = x.r; x.r, y.l = y.l, x; fix(x); fix(y); return y
fired = "none"                                      # which rotation this insert used
def insert(t, k):
    global fired
    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)
    fix(t)                                          # retracing: on the way back up
    if bf(t) > 1:                                   # left side too tall: LL or LR
        fired = ("LR" if bf(t.l) < 0 else "LL") + f" at {t.key}"
        if bf(t.l) < 0: t.l = rot_left(t.l)
        t = rot_right(t)
    elif bf(t) < -1:                                # right side too tall: RR or RL
        fired = ("RL" if bf(t.r) > 0 else "RR") + f" at {t.key}"
        if bf(t.r) > 0: t.r = rot_right(t.r)
        t = rot_left(t)
    return t
def show(t):                                        # "(left key right)", "." = empty
    if t is None: return "."
    if t.l is None and t.r is None: return str(t.key)
    return f"({show(t.l)} {t.key} {show(t.r)})"
def check(t, lo, hi):                               # returns height; asserts every rule
    if t is None: return -1
    a, b = check(t.l, lo, t.key), check(t.r, t.key, hi)
    assert lo < t.key < hi and abs(a - b) <= 1 and t.h == 1 + max(a, b)
    return t.h
root = None
for k in [50, 40, 30, 60, 70, 20, 25, 80, 75, 10]:
    fired = "none"; root = insert(root, k)
    print(f"insert {k:2d}  {fired:<9}  {show(root)}")
print(f"AVL invariant OK, height = {check(root, -1000000, 1000000)}")

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

insert 50  none       50
insert 40  none       (40 50 .)
insert 30  LL at 50   (30 40 50)
insert 60  none       (30 40 (. 50 60))
insert 70  RR at 50   (30 40 (50 60 70))
insert 20  none       ((20 30 .) 40 (50 60 70))
insert 25  LR at 30   ((20 25 30) 40 (50 60 70))
insert 80  none       ((20 25 30) 40 (50 60 (. 70 80)))
insert 75  RL at 70   ((20 25 30) 40 (50 60 (70 75 80)))
insert 10  none       (((10 20 .) 25 30) 40 (50 60 (70 75 80)))
AVL invariant OK, height = 3

y.l, x.r = x.r, y এক লাইনে দুটি pointer বদল: ডান দিকের মানগুলো আগে হিসাব হয় (পুরোনো x.r আর y), তারপর বাঁ থেকে ডানে বসানো হয় — তাই C++-এর দুই লাইনের সমান। fired একটি global, তাই insert-এর ভেতরে global fired লিখতে হয়েছে। Python-এর recursion সীমা (সাধারণত 1000) এখানে সমস্যা নয়, কারণ recursion-এর গভীরতা tree-র উচ্চতার সমান — দশ লাখ key-তেও 30-এর কম। সাধারণ BST-তে সাজানো ইনপুটে এই একই কোড হাজার খানেক key-তেই RecursionError দিত।

নিজে চালিয়ে দেখুন — 1 থেকে 10, আর rotation-গুলো

অংশ ১-এর ভর্তি অফিসের সমস্যায় ফিরি: রোল নম্বর 1, 2, 3, … ক্রমে আসছে। নিচের টুলে “চালিয়ে দেখুন” চাপুন — কোডটি নিজেই 1 থেকে 10 insert করবে। তারপর পরের সংখ্যা চেপে 11, 12, … যোগ করুন, বা নিজের মান লিখে insert চাপুন। প্রতিটি node-এর ওপরে ডানে ছোট সংখ্যাটি তার bf; লাল node-গুলো নতুন যোগ হওয়া node আর যেখানে rotation হলো। নিচের লাইনে AVL-এর উচ্চতার পাশে দেখাবে, একই ইনপুটে সাধারণ BST-এর উচ্চতা কত হতো।

const mk = k => ({ key: k, h: 0, l: null, r: null });
const ht = t => (t ? t.h : -1), bal = t => ht(t.l) - ht(t.r);
const fix = t => { t.h = 1 + Math.max(ht(t.l), ht(t.r)); return t; };
const rotR = y => { const x = y.l; y.l = x.r; x.r = y; fix(y); return fix(x); };
const rotL = x => { const y = x.r; x.r = y.l; y.l = x; fix(x); return fix(y); };
const $ = id => document.getElementById(id);
let root = null, plain = null, nextKey = 1, fired = [], hot = new Set();
function ins(t, k) {                                   // AVL insert, remembers which rotation fired
  if (!t) { hot.add(k); return mk(k); }
  if (k < t.key) t.l = ins(t.l, k); else if (k > t.key) t.r = ins(t.r, k); else return t;
  const b = bal(fix(t));
  if (Math.abs(b) < 2) return t;                       // still balanced here: keep retracing
  const kind = b > 1 ? (bal(t.l) < 0 ? "LR" : "LL") : (bal(t.r) > 0 ? "RL" : "RR");
  fired.push(kind + " at " + t.key); hot.add(t.key);
  if (kind === "LR") t.l = rotL(t.l); else if (kind === "RL") t.r = rotR(t.r);   // straighten first
  return b > 1 ? rotR(t) : rotL(t);
}
const bstIns = (t, k) => { if (!t) return mk(k); if (k < t.key) t.l = bstIns(t.l, k); else if (k > t.key) t.r = bstIns(t.r, k); return fix(t); };
function draw(msg) {                                   // x = in-order rank, y = depth
  const pos = new Map(); let i = 0, lines = "", html = "";
  (function walk(t, d) { if (t) { walk(t.l, d + 1); pos.set(t, [i++ * 38 + 24, d * 52 + 24]); walk(t.r, d + 1); } })(root, 0);
  for (const [t, [x, y]] of pos) {
    for (const c of [t.l, t.r]) if (c) lines += `<line x1="${x}" y1="${y}" x2="${pos.get(c)[0]}" y2="${pos.get(c)[1]}"/>`;
    html += `<div class="nd${hot.has(t.key) ? " hl" : ""}" style="left:${x - 17}px;top:${y - 17}px">${t.key}<sub>${bal(t) > 0 ? "+" : ""}${bal(t)}</sub></div>`;
  }
  const H = (ht(root) + 1) * 52 + 10; $("sv").setAttribute("width", i * 38 + 30); $("sv").setAttribute("height", H); $("wrap").style.height = H + "px";
  $("sv").innerHTML = lines; $("nd").innerHTML = html; $("msg").textContent = msg; console.log(msg);
}
function add(k) {
  fired = []; hot = new Set(); root = ins(root, k); plain = bstIns(plain, k);
  draw(`insert ${String(k).padStart(2)}: ${(fired[0] || "no rotation").padEnd(11)} AVL height ${ht(root)}, plain BST height ${ht(plain)}`);
}
$("bn").onclick = () => add(nextKey++); $("bi").onclick = () => add(Number($("av").value));
$("br").onclick = () => { root = plain = null; nextKey = 1; draw("empty tree"); };
for (let k = 1; k <= 10; k++) add(nextKey++);

শুরুতেই কোড 1 থেকে 10 insert করে; প্রতিটির পর আউটপুটে এক লাইন:

insert  1: no rotation AVL height 0, plain BST height 0
insert  2: no rotation AVL height 1, plain BST height 1
insert  3: RR at 1     AVL height 1, plain BST height 2
insert  4: no rotation AVL height 2, plain BST height 3
insert  5: RR at 3     AVL height 2, plain BST height 4
insert  6: RR at 2     AVL height 2, plain BST height 5
insert  7: RR at 5     AVL height 2, plain BST height 6
insert  8: no rotation AVL height 3, plain BST height 7
insert  9: RR at 7     AVL height 3, plain BST height 8
insert 10: RR at 6     AVL height 3, plain BST height 9

দশটি insert-এ ছয়টি rotation, সবগুলো RR — কারণ বাড়তে থাকা ক্রমে ভার সবসময় ডান → ডান পথে জমে। AVL-এর উচ্চতা 3-এ আটকে আছে, আর সাধারণ BST-এর উচ্চতা প্রতিটি insert-এ একটি করে বেড়ে 9. শেষ tree ((1 2 3) 4 ((5 6 7) 8 (. 9 10))) — root 4, তার ডানে 8. লক্ষ করুন insert 6: rotation হয়েছে root 2-এ, কারণ নতুন node-এর ঠিক ওপরের node-গুলো (5, 4) ভারসাম্যে ছিল, প্রথম ভারসাম্যহীন পূর্বপুরুষ ছিল root. ছবিতে প্রতিটি node-এর আনুভূমিক অবস্থান তার in-order ক্রম — তাই key-গুলো বাঁ থেকে ডানে সবসময় সাজানো দেখায়। পরীক্ষা: আবার শুরু চেপে 30, 10, 20 insert করুন (LR), তারপর 10, 30, 20 (RL).

৭ · Delete — মেরামত ছড়াতে পারে root পর্যন্ত

Delete-ও দুই ভাগে। প্রথমে পর্ব ২৭-এর সাধারণ BST delete: leaf হলে সরিয়ে দিন; একটি সন্তান থাকলে সেই সন্তানকে তার জায়গায় তুলুন; দুটি সন্তান থাকলে key-টির জায়গায় তার in-order successor (ডান subtree-র সবচেয়ে ছোট key) বসিয়ে successor-এর পুরোনো node মুছুন। তারপর, যেখান থেকে node আসলে সরানো হলো, সেখান থেকে root পর্যন্ত retracing — insert-এর হুবহু একই rebalance.

পার্থক্যটা থামার নিয়মে। Insert-এর rotation subtree-র উচ্চতা আগের মানে ফিরিয়ে দেয়। কিন্তু delete এমনিতেই একটি দিক ছোট করেছে; rotation সেই subtree-র ভারসাম্য ফেরায়, অথচ প্রায়ই তার উচ্চতা এক কমে যায় — আর তাতে ওপরের পূর্বপুরুষের ভারসাম্য নতুন করে ভাঙতে পারে। সবচেয়ে খারাপ উদাহরণ minimal AVL tree, যেখানে প্রতিটি node আগে থেকেই এক দিকে হেলে আছে:

উচ্চতা 4-এর minimal AVL tree, 12টি nodeসবুজ = এই ধাপের node

            
প্রতিটি ধাপের পর
কোথায় আছি
bf
কাজ
মোট rotation
  1. শুরু. উচ্চতা 4-এর minimal AVL tree: ১২টি node, আর প্রতিটি internal node-এর bf +1 — সবাই বাঁয়ে যতটা সম্ভব হেলে। এখন সবচেয়ে ডানের leaf 12 মুছব।
  2. 12 সরানো হলো. ফেরার পথে প্রথম node 11: বাঁয়ে 10 (উচ্চতা 1), ডানে এখন কিছু নেই (−1) → bf +2. বাঁ সন্তান 10-এর bf +1 — LL.
  3. প্রথম rotation, কিন্তু থামা যাবে না. 11-এ right rotation: 10 উঠে এল, সন্তান 9 আর 11. ভারসাম্য ঠিক — কিন্তু এই subtree-র উচ্চতা 2 থেকে 1 হয়ে গেল। তাই ওপরে 8: বাঁয়ে 5 (উচ্চতা 3), ডানে 10 (উচ্চতা 1) → bf +2. 5-এর bf +1 — আবার LL.
  4. দ্বিতীয় rotation. 8-এ right rotation: 5 নতুন root; 5-এর পুরোনো ডান সন্তান 7 চলে গেল 8-এর বাঁয়ে। এখন সব bf বৈধ, পুরো tree-র উচ্চতা 3. একটি delete-এ ২টি rotation. উচ্চতা 6-এর minimal tree-তে একই কৌশলে ৩টি, উচ্চতা h-এ প্রায় h/2 টি।

তাই delete-এ rotation-এর সংখ্যা worst case-এ Θ(log n) — প্রায় প্রতি দুই স্তরে একটি। তবু মোট সময় O(log n), কারণ প্রতিটি rotation O(1) আর পথটির দৈর্ঘ্যই O(log n). থামার নিয়ম: ফেরার পথে কোনো node-এর উচ্চতা না বদলালে (rotation-সহ বা ছাড়া) থামা যায়।

Delete-এর একটি নতুন অবস্থাও আছে, যা insert-এ কখনো আসে না: z-এর bf ±2, কিন্তু উঁচু সন্তানের bf 0. যেমন z-এর ডান সন্তান y-এর দুই subtree-ই সমান উঁচু, আর z-এর বাঁ দিক থেকে একটি node মুছে গেল। তখন single rotation (RR) করুন — double নয়। Single rotation-এর পর z-এর bf −1 আর y-এর bf +1 হয়, আর subtree-র উচ্চতা বদলায় না — তাই retracing সেখানেই থামে। অংশ ৫-এর টেবিলের “≥ 0” আর “≤ 0” এই অবস্থার জন্যই। এখানে double rotation করলে উল্টো ফল: tree ভারসাম্যহীনই থেকে যেতে পারে। অনুশীলনের দ্বিতীয় প্রশ্নে এই অবস্থাটি নিজে হাতে দেখবেন।

৮ · উচ্চতা না bf, সব কাজের খরচ, আর AVL বনাম red-black

প্রতিটি node-এ কী রাখবেন — উচ্চতা, নাকি শুধু balance factor?

এই পর্বের কোড প্রতিটি node-এ পুরো উচ্চতা (একটি int) রাখে, আর bf দরকার হলে দুই সন্তানের উচ্চতা থেকে হিসাব করে। সুবিধা: কোড সরল আর ভুল হওয়ার সুযোগ কম — rotation-এর পর শুধু fix() ডাকলেই সব ঠিক। Adelson-Velsky আর Landis-এর মূল প্রবন্ধ আর Knuth-এর বই রাখত শুধু bf — তিনটি সম্ভাব্য মান, অর্থাৎ ২টি bit. মেমরি কম, কিন্তু উচ্চতা জানা নেই বলে প্রতিটি rotation-এর পর নতুন bf-গুলো কেস ধরে বের করতে হয়। যেমন double rotation (LR)-এর পর নতুন bf নির্ভর করে নাতি x-এর পুরোনো bf-এর ওপর:

LR-এর আগে bf(x)পরে bf(y) (বাঁয়ের node)পরে bf(z) (ডানের node)পরে bf(x)
+10−10
0 (x নিজেই নতুন leaf)000
−1+100

এই টেবিল ভুল মনে রাখা AVL কোডের সবচেয়ে পরিচিত বাগগুলোর একটি — ছোট test-এ ধরা পড়ে না, হাজার insert-এর পর tree চুপচাপ ভারসাম্য হারায়। কনটেস্ট আর শেখার জন্য উচ্চতা রাখাই ভালো; মেমরি খুব সীমিত হলে (কোটি কোটি node) bf রাখার কথা ভাবা যায় — তখন অনেক বাস্তবায়ন ২টি bit pointer-এর অব্যবহৃত নিচের bit-এ লুকিয়ে রাখে, যেভাবে Linux kernel-এর red-black tree node-এর রং parent pointer-এর ভেতরে রাখে।

সব কাজের খরচ

কাজসময় (worst case)কেন
খোঁজা (search), min, maxO(log n)root থেকে একটি পথ; h < 1.44 log₂(n + 2)
InsertO(log n)নামা O(h) + ফেরা O(h) + সর্বোচ্চ ২টি rotation, প্রতিটি O(1)
DeleteO(log n)নামা, successor খোঁজা, ফেরা — প্রতিটি O(h); rotation O(log n) টি পর্যন্ত, প্রতিটি O(1)
successor / predecessor, lower_boundO(log n)একটি পথ ধরে নামা (পর্ব ২৭-এর মতো)
পরিসরের সব key (k টি)O(log n + k)শুরুর জায়গা খুঁজে in-order হাঁটা
সব key সাজানো ক্রমেΘ(n)in-order traversal
সাজানো অ্যারে থেকে বানানোΘ(n)মাঝেরটি root করে দুই অর্ধেক recursive — rotation লাগে না
জায়গাΘ(n)প্রতি node-এ key, দুটি pointer আর একটি উচ্চতা (বা ২ bit); recursion-এর stack O(log n)

একটি তুলনা মনে রাখুন: n টি key একে একে insert করে AVL বানাতে O(n log n), আর সেই tree-র in-order পড়লেই সাজানো ক্রম — তাই AVL দিয়েও O(n log n)-এ sort করা যায় (tree sort). Ω(n log n) তুলনার নিম্নসীমা (পর্ব ২৪) দেখায়, শুধু তুলনা-ভিত্তিক কোনো BST-তে insert O(log n)-এর চেয়ে দ্রুত হতে পারে না — যদি পারত, sort-ও দ্রুত হতো।

AVL বনাম red-black tree

পরের পর্বে (পর্ব ২৯) red-black tree বিস্তারিত দেখবেন। সংক্ষেপে: সেখানে প্রতিটি node লাল বা কালো, আর রঙের কয়েকটি নিয়ম মানলে উচ্চতা 2 log₂(n + 1)-এর বেশি হয় না। নিয়মটি AVL-এর চেয়ে শিথিল — tree বেশি হেলে থাকতে পারে, কিন্তু বদলে মেরামত কম লাগে।

বিষয়AVL treeRed-black tree
উচ্চতার সীমা< 1.44 log₂(n + 2)≤ 2 log₂(n + 1)
দশ লাখ node-এ সর্বোচ্চ উচ্চতা (প্রায়)27প্রায় 40
Insert-এ rotation≤ ২টি (একটি single বা double)≤ ২টি (সাথে রং বদল)
Delete-এ rotationO(log n) টি পর্যন্ত≤ ৩টি
প্রতি node-এর বাড়তি তথ্যউচ্চতা বা ২ bit১ bit (রং)
কোথায় ভালোখোঁজা অনেক বেশি, পরিবর্তন কমinsert/delete ঘন ঘন
পরিচিত ব্যবহারপাঠ্যবই, lookup-ভারী কাঠামোC++ std::map/set (প্রধান লাইব্রেরিগুলোতে), Java TreeMap, Linux kernel

সারকথা: AVL কড়া ভারসাম্য রাখে, তাই গড়ে tree একটু বেঁটে আর খোঁজা একটু দ্রুত; কিন্তু সেই কড়াকড়ি বজায় রাখতে delete-এ বেশি rotation লাগতে পারে। দুটিই worst case O(log n) — পার্থক্য ধ্রুবকে আর কাজের ধরনে। C++-এ নিজে না লিখে std::set/std::map ব্যবহার করুন (পর্ব ১৫-এর associative container); Python-এর standard library-তে কোনো balanced BST নেই — সেখানে প্রচলিত বিকল্প তৃতীয় পক্ষের sortedcontainers লাইব্রেরি, বা সমস্যা বুঝে bisect, heap বা Fenwick tree (পর্ব ৩৬).

আরও গভীরে: Knuth আরও নিখুঁত সীমা দেন: স্তর গুনে (leaf = 1) উচ্চতা < 1.4405 log₂(n + 2) − 0.3277, আর এলোমেলো insert-এ গড় উচ্চতা log₂ n-এর খুব কাছাকাছি থাকে বলে পরীক্ষায় দেখা যায়। AVL tree দুটি tree জোড়া (join) আর একটি key-এ ভাগ (split) — দুটোই O(log n)-এ পারে; এর ওপর ভিত্তি করে union, intersection-এর মতো set-অপারেশন, এমনকি parallel সংস্করণও লেখা হয়েছে। আর Bernhard Haeupler, Siddhartha Sen ও Robert Tarjan-এর “rank-balanced tree” গবেষণা থেকে আসা weak AVL (WAVL) tree শুধু insert হলে হুবহু AVL-এর মতো থাকে, কিন্তু delete-এ নিয়ম একটু শিথিল করে প্রতি delete-এ rotation সর্বোচ্চ দুটিতে নামায়।

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

  • উচ্চতা h-এর AVL tree-তে সর্বনিম্ন কয়টি node? — N(h) = N(h−1) + N(h−2) + 1, N(0) = 1, N(1) = 2; অর্থাৎ F(h+3) − 1 (উচ্চতা edge-এ গোনা হলে)।
  • AVL insert-এ সর্বোচ্চ কয়টি rotation? — একটি single বা একটি double (≤ ২টি মৌলিক rotation), কারণ মেরামতের পর subtree-র উচ্চতা আগের মানে ফেরে।
  • Delete-এ কি একটি rotation যথেষ্ট? — না; rotation-এর পর উচ্চতা এক কমতে পারে, তাই root পর্যন্ত O(log n) টি rotation লাগতে পারে।
  • 30, 10, 20 ক্রমে insert করলে কী হয়? — 30-এ LR: 10-এ left rotation, তারপর 30-এ right rotation; root 20.
  • AVL না red-black — কখন কোনটি? — খোঁজা বেশি হলে AVL (উচ্চতা ≤ 1.44 log n); পরিবর্তন বেশি হলে red-black (delete-এ ≤ ৩টি rotation).

৯ · AI-এর যুগে AVL tree — rotation ঠিক, হিসাব ভুল

“AVL tree-র insert আর delete লিখে দাও” — AI কয়েক সেকেন্ডে লিখবে, আর ছোট উদাহরণে চলবেও। AVL-এর বাগ সাধারণত দৃশ্যমান হয় না: tree ক্র্যাশ করে না, শুধু নীরবে ভারসাম্য হারায়, আর সব কিছু ধীর হয়ে যায় — অথচ উত্তর তখনো ঠিক আসে, কারণ BST-এর ক্রম অটুট থাকে। তাই আউটপুট মিলিয়ে নয়, invariant যাচাই করে AVL কোড পরীক্ষা করতে হয়।

AVL-এর কোডে AI সাধারণত যেসব ভুল করে

AI-এর ভুলকীভাবে ধরবেন
Rotation-এর পর উচ্চতা হালনাগাদ বাদ, বা আগে ওপরের node-এর উচ্চতা হিসাবপ্রতিটি কাজের পর check() চালিয়ে রাখা উচ্চতা আর আসল উচ্চতা মেলান
LR-এ ভুল node ঘোরানো (z-এর বদলে সন্তানে right rotation)30, 10, 20 আর 10, 30, 20 হাতে চালান — root 20 হতে হবে
Delete-এ সন্তানের bf 0 হলে double rotation (> 0 আর >= 0-এর গোলমাল)অনুশীলন ২-এর tree-তে 40 মুছে দেখুন — শুধু একটি single rotation হওয়ার কথা
Rotation-এর ফেরত দেওয়া নতুন root parent-এর pointer-এ না বসানোRotation-এর পর পুরো tree-র node সংখ্যা গুনুন — কমে গেলে অংশ হারিয়েছে
উচ্চতা না রেখে প্রতিবার recursive ভাবে পুরো subtree-র উচ্চতা গোনাএতে প্রতিটি insert O(n) হয়ে যায় — n = 105 সাজানো insert-এ সময় মাপুন
“AVL delete-এ একটি rotation-ই যথেষ্ট” বা “উচ্চতা ≤ log₂ n” দাবিউচ্চতা 4-এর minimal tree থেকে 12 মুছে দেখুন; N(h) দিয়ে সীমার যুক্তি চান

AI-কে পুরো কোড লিখে দিতে না বলে, আপনার কোড আর আপনার হাতে করা trace পরীক্ষা করতে বলুন — আর প্রতিটি দাবির পক্ষে যুক্তি চান:

আমি DSA শিখছি, এখন AVL tree. নিচে আমার লেখা insert, delete আর rotation-এর কোড, আর একটি ইনপুটে আমার হাতে করা trace দিলাম: [এখানে কোড আর trace পেস্ট করুন] পুরো সমাধান নতুন করে লিখে দিও না। শুধু এগুলো করো: ১. 40, 20, 10, 25, 30, 22 ক্রমে insert করলে প্রতিটি ধাপে কোন rotation চলে আর tree কেমন হয় দেখাও, আর আমার trace-এর সাথে কোথায় অমিল বলো। ২. আমার delete-এ উঁচু সন্তানের balance factor 0 হলে কী ঘটে? একটি ছোট উদাহরণ দিয়ে দেখাও single না double rotation হচ্ছে। ৩. প্রতিটি rotation-এর পর কোন node-এর উচ্চতা কোন ক্রমে হালনাগাদ হচ্ছে, আর কোথাও parent-এর pointer হারাচ্ছে কি না দেখো। ৪. উচ্চতা h-এর AVL tree-তে সর্বনিম্ন node-এর recurrence থেকে উচ্চতার সীমা ধাপে ধাপে বের করো — শুধু উত্তর নয়।

AVL কোড যাচাইয়ের চেকলিস্ট

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

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

প্র ০১১৯৬২ সালের মস্কো — Adelson-Velsky আর Landis ঠিক কোন সমস্যার সমাধান দিয়েছিলেন, আর প্রথম self-balancing BST কেন এত দেরিতে এল?

১৯৫০-এর দশকের শেষে binary search tree-র ধারণা পরিচিত হয়ে গিয়েছিল: সাজানো অ্যারের binary search দ্রুত, কিন্তু নতুন উপাদান ঢোকাতে বাকিদের সরাতে হয় (O(n)); linked list-এ ঢোকানো সহজ, কিন্তু খোঁজা ধীর। BST দুটিকে মেলায় — যতক্ষণ tree ভারসাম্যে থাকে। আর বিশ্লেষকেরা জানতেন, এলোমেলো ইনপুটে গড় উচ্চতা O(log n) হলেও সাজানো ইনপুটে tree শিকল হয়ে যায়। প্রশ্ন ছিল: এমন কোনো নিয়ম কি আছে, যা সস্তায় বজায় রাখা যায়, অথচ উচ্চতাকে সবসময় লগারিদমিক রাখে?

Georgy Adelson-Velsky আর Evgenii Landis-এর ১৯৬২ সালের প্রবন্ধটি মাত্র কয়েক পাতার, প্রকাশিত হয়েছিল সোভিয়েত বিজ্ঞান একাডেমির Doklady-তে — যেখানে ছোট, ঘন গাণিতিক ফল ছাপা হতো। শিরোনাম “তথ্য সাজানোর একটি অ্যালগরিদম” — উদ্দেশ্য ছিল এমন একটি কাঠামো, যেখানে খোঁজা আর নতুন তথ্য যোগ দুটোই দ্রুত। তাঁদের মূল অন্তর্দৃষ্টি দুটি। প্রথমত, নিখুঁত ভারসাম্য চাওয়ার দরকার নেই — প্রতিটি node-এ দুই দিকের উচ্চতার পার্থক্য সর্বোচ্চ ১ রাখলেই উচ্চতা log-এর ধ্রুবক গুণের মধ্যে থাকে (এই পর্বের Fibonacci-যুক্তি)। দ্বিতীয়ত, এই নিয়ম ভাঙলে স্থানীয় কয়েকটি pointer বদলেই (rotation) তা সারানো যায়, পুরো tree নতুন করে বানাতে হয় না।

দুজনের পরিচয়ও আকর্ষণীয়। Landis মূলত বিশুদ্ধ গণিতবিদ, partial differential equation নিয়ে কাজের জন্য পরিচিত। Adelson-Velsky ছিলেন সোভিয়েত কম্পিউটার বিজ্ঞানের একজন পথিকৃৎ: পরে দাবা প্রোগ্রাম Kaissa-র দলে কাজ করেন, যা ১৯৭৪ সালে প্রথম বিশ্ব কম্পিউটার দাবা চ্যাম্পিয়নশিপ জেতে। তাঁর অ্যালগরিদম ক্লাসের একটি অনুশীলন থেকেই Yefim Dinitz network flow-এর একটি বিখ্যাত অ্যালগরিদম বের করেন (পর্ব ৪৭).

“কেন দেরিতে” — প্রশ্নটির উত্তর আসলে, দেরি হয়নি। Computer-এ সাধারণ তথ্য রাখার কাঠামো নিয়ে গবেষণাই তখন সবে শুরু; BST নিজেই ১৯৬০ সালের আশপাশে কয়েকজন আলাদাভাবে প্রকাশ করেছিলেন। দুই বছরের মধ্যেই self-balancing সমাধান আসা বরং দ্রুত। আর পশ্চিমে এটি ব্যাপকভাবে ছড়ায় অনুবাদ আর পরে Knuth-এর বইয়ের মাধ্যমে, যেখানে AVL insert আর delete-এর বিস্তারিত বিশ্লেষণ আছে। পরের এক দশকে এরই উত্তরসূরি আসে: B-tree (১৯৭০-এর দশকের শুরু, পর্ব ৩০), আর তার থেকে red-black tree (পর্ব ২৯).

মূল উপলব্ধি: AVL-এর আসল উদ্ভাবন একটি শিথিল invariant — যা যথেষ্ট কড়া যাতে উচ্চতা O(log n) থাকে, আবার যথেষ্ট ঢিলা যাতে স্থানীয় O(1) মেরামতে বজায় রাখা যায়। পরের প্রায় সব balanced tree এই একই নকশা-চিন্তার নানা রূপ।

প্র ০২AVL-এ খোঁজা দ্রুত, তবু C++-এর std::map, Java-র TreeMap আর Linux kernel red-black tree কেন বেছে নিয়েছে?

কাগজে AVL-এর সুবিধা স্পষ্ট: উচ্চতার সীমা 1.44 log₂ n, red-black-এর 2 log₂ n. দশ লাখ key-তে worst case-এ ২৭ বনাম প্রায় ৪০টি তুলনা। তবু প্রধান সাধারণ-উদ্দেশ্যের লাইব্রেরিগুলো red-black নিয়েছে। কারণ খুঁজতে হলে দেখতে হয় একটি লাইব্রেরিকে কোন কাজের জন্য বানানো হয়।

প্রথমত, পরিবর্তনের খরচ. একটি সাধারণ-উদ্দেশ্যের map জানে না ব্যবহারকারী বেশি খুঁজবেন না বেশি বদলাবেন। Red-black tree-তে insert-এ সর্বোচ্চ ২টি, delete-এ সর্বোচ্চ ৩টি rotation, আর বাকি মেরামত শুধু রং বদল — এবং amortized হিসাবে প্রতিটি পরিবর্তনে মেরামতের কাজ গড়ে O(1). AVL-এ delete-এ rotation root পর্যন্ত ছড়াতে পারে। যে সিস্টেমে লাগাতার insert আর delete চলে — যেমন kernel-এর timer, process scheduler বা মেমরি এলাকার হিসাব — সেখানে পরিবর্তনের নিশ্চিত ছোট খরচ বেশি জরুরি। Concurrent বা persistent tree-তে কম node ছোঁয়া মানে কম lock বা কম কপি — এটিও red-black-এর পক্ষে যায়।

দ্বিতীয়ত, বাস্তবে পার্থক্য ছোট. এলোমেলো ডেটায় দুই tree-র গড় উচ্চতাই log₂ n-এর কাছাকাছি; worst case-এর ২৭ বনাম ৪০ সাধারণত দেখা যায় না। খোঁজার সময়ের বড় অংশ যায় cache miss-এ — প্রতিটি node মেমরির আলাদা জায়গায় — আর তাতে দুই tree প্রায় সমান। Ben Pfaff ২০০৪ সালে বাস্তব system software-এর ধাঁচের কাজের ভার দিয়ে কয়েক রকম BST তুলনা করেন; ফলাফল ছিল, কোনটি দ্রুত তা ইনপুটের ধরনের ওপর নির্ভর করে — কোনো একটি সবখানে সেরা নয়।

তৃতীয়ত, ইতিহাস আর জড়তা. HP-র যে STL বাস্তবায়ন থেকে C++ standard library এসেছে, তাতে red-black tree ছিল; standard-এর কিছু জটিলতার শর্ত (যেমন iterator দিয়ে erase-এ amortized ধ্রুবক সময়) red-black-এ সহজে মেলে, আর বাকি লাইব্রেরিগুলোও সেই পথ ধরে। একবার একটি ভালো, পরীক্ষিত বাস্তবায়ন সবখানে ছড়িয়ে গেলে সামান্য গতির জন্য বদলানোর কারণ থাকে না।

তবে AVL হারিয়ে যায়নি: যেখানে tree একবার বানিয়ে বারবার খোঁজা হয় (যেমন বিরল-পরিবর্তনের lookup table), সেখানে এর বেঁটে উচ্চতা কাজে লাগে, আর পাঠ্যবই ও পরীক্ষায় balanced tree শেখানোর প্রথম উদাহরণ এটিই।

মূল উপলব্ধি: ডেটা স্ট্রাকচার বাছাই শুধু Big-O-র প্রতিযোগিতা নয় — কাজের ধরন (খোঁজা বনাম বদল), worst case-এর নিশ্চয়তা, হার্ডওয়্যার আর ইতিহাস মিলে সিদ্ধান্ত হয়।

প্র ০৩নিখুঁত ভারসাম্য (উচ্চতা ঠিক ⌊log₂ n⌋) রাখলে খোঁজা আরও দ্রুত হতো — তাহলে AVL ইচ্ছে করে “±1 ঢিলেমি” রাখে কেন?

ধরুন নিয়ম হলো, tree সবসময় complete binary tree-র মতো হবে — প্রতিটি স্তর ভরা, শুধু শেষটি ছাড়া। উচ্চতা তখন সবসময় ঠিক ⌊log₂ n⌋, আর খোঁজা সবচেয়ে কম ধাপে। সমস্যা হলো এই আকার বজায় রাখার খরচ। একটি উদাহরণ দেখুন: 1 থেকে 7 দিয়ে পুরো ভরা tree, root 4, তার নিচে 2 আর 6, তার নিচে 1, 3, 5, 7. এবার 8 যোগ করুন, তারপর 1 মুছুন। নতুন tree-তে key 2 থেকে 8, সাতটি — আর এর একমাত্র পুরো ভরা BST-র root 5, নিচে 3 আর 7, তারপর 2, 4, 6, 8. তুলনা করুন: প্রায় প্রতিটি node তার জায়গা বা সন্তান বদলেছে। সাধারণভাবে দেখানো যায়, নিখুঁত আকার রাখতে একটি insert-এ Θ(n) টি node সরাতে হতে পারে — কারণ in-order ক্রমে প্রতিটি অবস্থানের key নির্দিষ্ট, আর একটি নতুন key সবাইকে এক ঘর সরিয়ে দেয়।

অর্থাৎ “নিখুঁত” ভারসাম্য মানে প্রতিটি পরিবর্তনে প্রায় পুরো tree নতুন করে লেখা — সাজানো অ্যারের মতোই O(n). AVL-এর ঢিলেমি ঠিক এই সমস্যার সমাধান: প্রতিটি node-কে একটু হেলে থাকার অনুমতি দিলে মেরামত সবসময় স্থানীয় থাকে, আর বিনিময়ে উচ্চতা বড়জোর প্রায় ৪৪% বাড়ে। এটি একটি সচেতন trade-off: খোঁজায় একটি ধ্রুবক গুণের ক্ষতি, পরিবর্তনে O(n) থেকে O(log n)-এর লাভ।

মজার ব্যাপার, ঢিলেমির পরিমাণ নিজেই একটি নকশার সিদ্ধান্ত। আরও ঢিলা করলে (red-black: উচ্চতা 2 log n পর্যন্ত) মেরামত আরও কম। অন্য দিকে কিছু কাঠামো — যেমন scapegoat tree — অনেকক্ষণ কিছুই করে না, তারপর কোনো subtree অনুমোদিত সীমার চেয়ে বেশি হেলে গেলে সেটিকে পুরো নতুন করে নিখুঁত ভারসাম্যে বানায়; প্রতিবারের খরচ বড় হলেও amortized হিসাবে O(log n). আর treap (পর্ব ৩৮) ভারসাম্যের দায়িত্ব দেয় এলোমেলো সংখ্যাকে — expected উচ্চতা O(log n), কোনো নিশ্চয়তা নয়।

পরীক্ষায় এই প্রশ্ন এলে একটি সুন্দর উত্তর হলো: balanced BST-র প্রতিটি রূপ আসলে একটি প্রশ্নের আলাদা উত্তর — “উচ্চতার কতটা ছাড় দিলে মেরামতের খরচ কতটা কমে?”

মূল উপলব্ধি: নিখুঁত হওয়ার খরচ প্রায়ই বেশি। একটি invariant-কে ঠিক ততটুকু শিথিল করুন, যতটুকুতে প্রয়োজনীয় নিশ্চয়তা (এখানে O(log n) উচ্চতা) থাকে — এটি অ্যালগরিদম নকশার একটি গভীর শিক্ষা।

প্র ০৪Insert-এ একটি rotation-ই যথেষ্ট, অথচ delete-এ root পর্যন্ত লাগতে পারে — এই অসমতার গভীর কারণ কী, আর বাস্তবে এর কোনো মূল্য আছে কি?

দুটি কাজকে একটি প্রশ্ন দিয়ে দেখা যায়: “মেরামতের পর subtree-র উচ্চতা কোথায় দাঁড়াল?” Insert কোনো subtree-কে এক ধাপ উঁচু করে, আর প্রথম ভারসাম্যহীন node z-এ rotation সেই বাড়তি ধাপটি “খেয়ে ফেলে” — subtree-র উচ্চতা ঠিক আগের মানে ফেরে (অংশ ৬-এর প্রমাণ)। তাই ওপরের কেউ কিছু টের পায় না। Delete উল্টো: একটি দিক এক ধাপ ছোট হয়েছে। Rotation অন্য দিকের ভার এনে ভারসাম্য ফেরায়, কিন্তু সেই ভার আনতে গিয়ে পুরো subtree প্রায়ই এক ধাপ বেঁটে হয়ে যায় — আর সেই খাটো হওয়ার খবর ওপরে যায়। ওপরের node আগে থেকেই উল্টো দিকে হেলে থাকলে (minimal tree-তে যেমন সবাই হেলে আছে), তারও ভারসাম্য ভাঙে, আর ঢেউটি চলতে থাকে।

একটি উপমা: চায়ের দোকানে কাপের স্তূপ। ওপরে একটি কাপ রাখলে যদি একপাশে হেলে যায়, সেই জায়গায় একটু ঠিক করলেই হয় — নিচের কাপগুলো টের পায় না। কিন্তু নিচের দিক থেকে একটি কাপ সরালে পুরো স্তূপ এক ধাপ নামে, আর ওপরের প্রতিটি স্তরে আবার মানিয়ে নিতে হতে পারে।

বাস্তবে এর মূল্য কতটা? সময়ের হিসাবে খুব কম: retracing-এর পথ এমনিতেই O(log n), rotation সেই পথের প্রতিটি স্তরে O(1) যোগ করে মাত্র — মোট O(log n) বদলায় না। গবেষণা আর পরীক্ষায় দেখা যায়, এলোমেলো delete-এ গড়ে rotation অনেক কম লাগে। কিন্তু দুই জায়গায় এটি গুরুত্বপূর্ণ। এক, যেখানে প্রতিটি পরিবর্তিত node-এর আলাদা খরচ — persistent tree (পুরোনো সংস্করণ রেখে দেওয়া হয়, তাই বদলানো প্রতিটি node কপি করতে হয়), disk-এ রাখা tree, বা অনেক thread একসাথে কাজ করা tree-তে lock. দুই, amortized বিশ্লেষণে: শুধু insert হলে AVL-এ গড় মেরামতের কাজ O(1), কিন্তু insert আর delete মিশিয়ে চললে প্রতি কাজে Θ(log n) মেরামত বারবার ঘটানো যায়।

এই দুর্বলতাই Haeupler, Sen ও Tarjan-এর weak AVL (WAVL) tree-র প্রেরণা: delete-এর পর নিয়ম সামান্য শিথিল (যেমন কোনো node-এর দুই সন্তানের দিকেই উচ্চতার ফাঁক ২ রাখতে দেওয়া) — ফলে প্রতি delete-এ সর্বোচ্চ দুটি rotation, অথচ শুধু insert হলে tree হুবহু AVL. আর red-black tree এই সমস্যা শুরু থেকেই রং বদলের ভেতরে লুকিয়ে রাখে।

মূল উপলব্ধি: মেরামত কতদূর ছড়ায়, তা ঠিক করে মেরামতের পর উচ্চতা “আগের মতো” থাকে কি না। এই একটি প্রশ্ন দিয়েই balanced tree-র প্রতিটি কাজের rotation-সংখ্যা বিশ্লেষণ করা যায়।

প্র ০৫কনটেস্টে বা কাজের কোডে কি AVL tree নিজে লিখবেন? কখন লাইব্রেরি, আর কখন নিজের হাতে বাড়তি তথ্যসহ balanced tree দরকার?

বেশিরভাগ সময় উত্তর “না”. C++-এ std::set, std::map, std::multiset সব কাজ O(log n)-এ দেয়, পরীক্ষিত আর দ্রুত — ঝুঁকি ছাড়া। কনটেস্টে নিজে AVL লিখতে বসলে ২০–৩০ মিনিট আর বাগের ঝুঁকি, অথচ লাইব্রেরি এক লাইনে কাজ করে। কাজের কোডেও একই কথা: নিজের balanced tree রক্ষণাবেক্ষণের দায় অনেক।

তবে কিছু প্রশ্ন লাইব্রেরি সরাসরি পারে না। সবচেয়ে পরিচিত: “এই set-এ k-তম ছোট উপাদান কোনটি?” বা “x-এর চেয়ে ছোট কয়টি উপাদান?” (order statistic). std::set-এ এর জন্য O(n) হাঁটতে হয়। সমাধান হলো tree-কে augment করা: প্রতিটি node-এ তার subtree-র আকার (size) রাখা। Rotation-এ size-ও উচ্চতার মতোই নিচ থেকে হিসাব হয় — fix()-এ এক লাইন যোগ। তারপর k-তম উপাদান খুঁজতে root থেকে নামুন: বাঁ subtree-র size দেখে বাঁয়ে, এখানে বা ডানে যান — O(log n). একইভাবে subtree-র যোগফল, সর্বোচ্চ মান ইত্যাদি রাখা যায়। এখানে AVL-এর কোড সরল বলে কাজে লাগে, কারণ rotation-এ শুধু দুটি node-এর তথ্য নতুন করে হিসাব হয়।

তবু কনটেস্টে প্রচলিত শর্টকাট আছে। GCC-র __gnu_pbds লাইব্রেরির tree (policy-based data structure) red-black tree-র ওপর find_by_order আর order_of_key দেয়। আর নিজে লিখতে হলে অনেকে treap (পর্ব ৩৮) বেছে নেন — কোড ছোট, split/merge সহজ। আর মান যদি আগে থেকে জানা থাকে (offline), coordinate compression করে Fenwick tree (পর্ব ৩৬) প্রায়ই সবচেয়ে সহজ।

Python-এ চিত্রটি আলাদা: standard library-তে কোনো balanced BST নেই। bisect দিয়ে সাজানো list-এ খোঁজা O(log n), কিন্তু insert O(n) — ছোট n-এ এটি আসলে বেশ দ্রুত, কারণ list-এর ঘর সরানো C-তে লেখা। বড় n-এ তৃতীয় পক্ষের sortedcontainers (অনেক judge-এ পাওয়া যায় না), নয়তো heap বা Fenwick-ভিত্তিক বিকল্প। Python-এ বিশুদ্ধ AVL লিখলে ধ্রুবক এত বড় যে প্রায়ই time limit পেরোয়।

তাহলে AVL শিখে লাভ কী? তিনটি: (১) পরীক্ষা আর ইন্টারভিউতে rotation, LL/LR আর উচ্চতার প্রমাণ নিয়মিত আসে; (২) augmented tree, interval tree বা নিজস্ব index বানাতে এই কাঠামোই ভিত্তি; (৩) invariant রক্ষা করে মেরামতের চিন্তা — segment tree, B-tree, সবখানে কাজে লাগে।

মূল উপলব্ধি: লাইব্রেরি যা পারে তার জন্য লাইব্রেরি; লাইব্রেরি যা পারে না (বাড়তি তথ্য, বিশেষ প্রশ্ন), তার জন্য নিজের balanced tree — আর তখন AVL-এর সরলতা একটি বড় সুবিধা।

অনুশীলন

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

  1. হাতে trace করুন: খালি AVL tree-তে পরপর insert করুন 40, 20, 10, 25, 30, 22. প্রতিটি insert-এর পর কোন rotation (যদি হয়) কোন node-এ চলল আর tree কেমন হলো, লিখুন।

    40 → 40. 20 → (20 40 .). 10 → 40-এর bf +2, বাঁ সন্তান 20-এর bf +1: LL at 40 → (10 20 40). 25 → 40-এর বাঁ সন্তান: (10 20 (25 40 .)), কোনো rotation নেই (20-এর bf −1). 30 → 25-এর ডান সন্তান; 40-এর bf +2, বাঁ সন্তান 25-এর bf −1: LR at 40 → (10 20 (25 30 40)).

    22 → 20 → 30 → 25 → 25-এর বাঁ সন্তান। 25-এর উচ্চতা 1, 30-এর 2; root 20-এর বাঁ উচ্চতা 0, ডান 2 → bf −2. ডান সন্তান 30-এর bf +1: RL at 20 — আগে 30-এ right rotation, তারপর 20-এ left rotation. শেষ tree ((10 20 22) 25 (. 30 40)), উচ্চতা 2. মোট তিনটি মেরামত: LL, LR, RL — প্রতিটি insert-এ সর্বোচ্চ একটি।

  2. Delete হাতে চালান: AVL tree (((10 20 .) 25 30) 40 (50 60 (70 75 80))) থেকে 40 মুছুন। কোন key root-এ আসে, কোথায় ভারসাম্য ভাঙে, আর কোন rotation লাগে? Single না double — কেন?

    40-এর দুটি সন্তান, তাই তার in-order successor — ডান subtree-র সবচেয়ে ছোট key 50 — root-এ বসে, আর 50-এর পুরোনো node (একটি leaf) মোছা হয়। ফেরার পথে 60: বাঁ এখন খালি (−1), ডানে 75 (উচ্চতা 1) → bf −2. ডান সন্তান 75-এর দুই সন্তান 70 আর 80 দুটোই leaf, তাই bf(75) = 0.

    এটি delete-এর বিশেষ অবস্থা: উঁচু সন্তানের bf 0 → single left rotation 60-এ (RR-এর মতো)। 75 উঠে আসে, 60 তার বাঁ সন্তান আর 70 যায় 60-এর ডানে: ((. 60 70) 75 80). এই subtree-র উচ্চতা 2 — মোছার আগের মতোই, তাই retracing থামে। শেষ tree (((10 20 .) 25 30) 50 ((. 60 70) 75 80)); bf(60) = −1, bf(75) = +1, root-এর 0. Double rotation (RL) করলে 70 উঠে আসত, আর 75-এর বাঁ দিক খালি হয়ে ডানে 80 থাকত — tree বৈধ থাকলেও অকারণে বেশি কাজ; বড় উদাহরণে এতে ভারসাম্যও ভাঙতে পারে।

  3. হিসাব করুন: (ক) N(5) আর N(6) বের করুন (উচ্চতা edge-এ গোনা)। (খ) ৫০টি node-এর AVL tree-র উচ্চতা সর্বোচ্চ কত হতে পারে? (গ) একই ৫০টি key সাধারণ BST-তে রাখলে worst case উচ্চতা কত, আর নিখুঁত ভারসাম্যে সর্বনিম্ন কত?

    (ক) N(5) = N(4) + N(3) + 1 = 12 + 7 + 1 = 20; N(6) = 20 + 12 + 1 = 33. যাচাই: F(8) − 1 = 21 − 1 = 20, F(9) − 1 = 34 − 1 = 33.

    (খ) N(7) = 33 + 20 + 1 = 54 > 50, তাই উচ্চতা 7-এর AVL tree বানাতে অন্তত ৫৪টি node লাগে — ৫০টিতে অসম্ভব। N(6) = 33 ≤ 50, তাই উচ্চতা 6 সম্ভব। উত্তর 6. সূত্র দিয়েও: 1.4404 · log₂ 52 − 1.328 ≈ 6.9, অর্থাৎ h ≤ 6. (গ) সাধারণ BST-তে সাজানো ক্রমে 49; নিখুঁত ভারসাম্যে ⌊log₂ 50⌋ = 5. AVL-এর worst case (6) নিখুঁতের মাত্র এক বেশি।

  4. কোড লিখুন: এই পর্বের C++ Node, fix, bf, rotLeft, rotRight ব্যবহার করে একটি rebalance(Node*) আর AVL erase(Node*, int) লিখুন। দুই সন্তানের ক্ষেত্রে in-order successor ব্যবহার করুন। সময় আর rotation-এর সংখ্যার complexity বলুন।
    Node* rebalance(Node* t) {                    // shared by insert and erase
        fix(t);
        if (bf(t) > 1)  { if (bf(t->l) < 0) t->l = rotLeft(t->l);  return rotRight(t); }
        if (bf(t) < -1) { if (bf(t->r) > 0) t->r = rotRight(t->r); return rotLeft(t); }
        return t;
    }
    Node* erase(Node* t, int k) {
        if (!t) return nullptr;                       // key not present
        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) {                     // zero or one child
                Node* c = t->l ? t->l : t->r;
                delete t;
                return c;                             // c is already a valid AVL tree
            }
            Node* s = t->r;                           // two children: in-order successor
            while (s->l) s = s->l;
            t->key = s->key;
            t->r = erase(t->r, s->key);
        }
        return rebalance(t);                          // retracing on the way back up
    }

    লক্ষ করুন bf(t->l) < 0 (কঠোর চিহ্ন): সন্তানের bf 0 হলে single rotation — অনুশীলন ২-এর অবস্থা। Recursive গঠনের কারণে ফেরার পথে প্রতিটি পূর্বপুরুষে rebalance চলে, তাই একাধিক rotation আপনাআপনি সামলানো হয়। সময় O(log n): নামা, successor খোঁজা আর ফেরা — প্রতিটি উচ্চতার সমান। Rotation worst case-এ Θ(log n) টি, প্রতিটি O(1). পরীক্ষা: হাজারটি এলোমেলো insert/erase-এর পর প্রতিবার অংশ ৬-এর check() চালান, আর std::set-এর সাথে উপাদান মেলান।

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

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

ক্রমAVL tree-তে একটি নতুন key insert করার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. BST-এর নিয়মে root থেকে নেমে নতুন key-এর জন্য খালি জায়গা খোঁজা
  2. সেই জায়গায় নতুন leaf বসানো (উচ্চতা 0, balance factor 0)
  3. ফেরার পথে প্রতিটি পূর্বপুরুষের উচ্চতা আর balance factor হালনাগাদ করা
  4. প্রথম যে পূর্বপুরুষের balance factor ±2, সেখানে LL, RR, LR বা RL চিনে rotation করা
  5. মেরামত করা subtree-র উচ্চতা আগের মানে ফিরেছে বলে ওপরের পূর্বপুরুষদের আর কিছু না বদলানো

balance factor = বাঁ subtree-র উচ্চতা − ডান subtree-র উচ্চতা, আর খালি subtree-র উচ্চতা −1. নিচের কোন node AVL নিয়ম ভাঙে?

  • বাঁ subtree-র উচ্চতা 3, ডান subtree-র উচ্চতা 2
  • বাঁ subtree-র উচ্চতা 1, ডান subtree খালি
  • বাঁ subtree খালি, ডান subtree একটি leaf (উচ্চতা 0)
  • দুই subtree-রই উচ্চতা 5

উচ্চতা (root থেকে সবচেয়ে গভীর leaf পর্যন্ত edge-এর সংখ্যা; এক node-এর tree-র উচ্চতা 0) 4 এমন একটি AVL tree-তে সর্বনিম্ন কয়টি node থাকতে পারে?

  • 5
  • 9
  • 12
  • 16

একটি খালি AVL tree-তে 30, 10, 20 ক্রমে insert করা হলো। তৃতীয় insert-এর পর কী ঘটে?

  • 10-এ left rotation, তারপর 30-এ right rotation; root হয় 20
  • শুধু 30-এ right rotation; root হয় 10
  • কোনো rotation লাগে না
  • শুধু 10-এ left rotation; root থাকে 30

n node-এর একটি AVL tree-তে একটি insert-এর worst case-এ সর্বোচ্চ কতগুলো মেরামত (rotation) লাগে?

  • Θ(log n) টি
  • কখনোই লাগে না
  • n টি পর্যন্ত
  • সর্বোচ্চ একটি single বা একটি double rotation (মোট ≤ ২টি মৌলিক rotation)

AVL tree আর red-black tree-র তুলনায় কোন বক্তব্যটি সঠিক?

  • Red-black tree-র উচ্চতার সীমা AVL-এর চেয়ে ছোট
  • AVL বেশি কড়া ভারসাম্যে থাকে, তাই খোঁজা সাধারণত সামান্য দ্রুত, কিন্তু delete-এ rotation বেশি লাগতে পারে
  • AVL-এ delete-এ সর্বোচ্চ একটি rotation লাগে
  • Red-black tree-তে খোঁজা worst case-এ O(n)

একটি BST-র node y আর তার বাঁ সন্তান x-এ right rotation করা হলো (x ওপরে ওঠে)। কোনটি সত্য?

  • tree-র in-order ক্রম উল্টে যায়
  • tree-র সব node-এর উচ্চতা নতুন করে হিসাব করতে হয়, তাই O(n)
  • in-order ক্রম একই থাকে, শুধু y আর x-এর উচ্চতা বদলায়, আর কাজটি O(1)
  • x-এর বাঁ subtree y-এর নতুন বাঁ সন্তান হয়

শব্দকোষ

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

মনে রাখুন

  1. সাধারণ BST সাজানো ইনপুটে শিকল হয়ে O(n); AVL tree নিজে ভারসাম্য রেখে সব কাজ worst case-এ O(log n)-এ করে (Adelson-Velsky ও Landis, ১৯৬২).
  2. নিয়ম: প্রতিটি node-এ bf = h(বাঁ) − h(ডান) ∈ {−1, 0, +1}; খালি tree-র উচ্চতা −1.
  3. Minimal AVL tree: N(h) = N(h−1) + N(h−2) + 1 = F(h+3) − 1, তাই উচ্চতা < 1.44 log₂(n + 2).
  4. Rotation O(1), in-order ক্রম অটুট। LL/RR → একটি rotation, LR/RL → দুটি (আগে সন্তানে, তারপর z-এ)।
  5. Insert-এ প্রথম ভারসাম্যহীন পূর্বপুরুষে একটি মেরামতই যথেষ্ট; delete-এ Θ(log n) rotation পর্যন্ত, আর সন্তানের bf 0 হলে single rotation. Red-black (পর্ব ২৯) কম কড়া, পরিবর্তনে সস্তা।

পরের পর্বে: লাল আর কালো রঙের কয়েকটি নিয়ম দিয়ে ভারসাম্য — red-black tree, কেন এর delete-এ সর্বোচ্চ তিনটি rotation, আর AVL, red-black, B-tree, treap ও skip list-এর তুলনা।

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

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