এই পর্বে যা শিখবেন
- Priority queue-কে ADT হিসেবে চেনা, আর কেন সাজানো বা অসাজানো অ্যারে এর জন্য ভালো নয় তা বোঝা
- Heap-কে tree আর অ্যারে — দুই রূপে একসাথে দেখা, index-এর সূত্র দিয়ে parent ও সন্তান খোঁজা
- Sift-up, sift-down, build-heap আর heapsort হাতে trace করা, আর build-heap-এর O(n) প্রমাণ করা
- নিজে min-heap লেখা, আর
std::priority_queue,heapqনিরাপদে ব্যবহার করা - Top-k, k-way merge, running median আর decrease-key-এর মতো সমস্যায় heap বসানো
১ · Priority queue — হাসপাতালের জরুরি বিভাগের নিয়ম
রাত দুটোয় একটি বড় সরকারি হাসপাতালের জরুরি বিভাগ। সামনে লম্বা ভিড়: কারও জ্বর, কারও হাত কেটেছে, আর এইমাত্র অ্যাম্বুলেন্সে এলেন সড়ক দুর্ঘটনার একজন রোগী, যাঁর রক্তক্ষরণ থামছে না। ডাক্তার কি টিকিটের লাইনের মতো “আগে এলে আগে” নিয়ম মানবেন? না। প্রথমে একজন নার্স প্রত্যেক রোগীকে দেখে ঠিক করেন কার অবস্থা কতটা গুরুতর — একে বলে triage. তারপর ডাক্তার সবসময় ডাকেন সবচেয়ে গুরুতর রোগীকে, তিনি যখনই আসুন না কেন। নতুন রোগী আসছেন, পুরোনোরা চিকিৎসা পেয়ে বেরোচ্ছেন — কিন্তু প্রতিবার প্রশ্ন একটাই: “এখন সবচেয়ে জরুরি কে?”
এই নিয়মে চলা ডেটা স্ট্রাকচারের নাম priority queue (প্রায়োরিটি কিউ). পর্ব ১৪-এর সাধারণ queue মানত FIFO — আগে এলে আগে যায়। Priority queue-তে প্রতিটি উপাদানের সাথে থাকে একটি অগ্রাধিকার (priority বা key), আর বের হয় সবচেয়ে বেশি (অথবা সবচেয়ে কম) অগ্রাধিকারেরটি। এটিও একটি abstract data type (ADT): শুধু কী কাজ করা যায় তা বলে, কীভাবে বানানো হবে তা নয়।
| অপারেশন | কাজ | জরুরি বিভাগের উপমায় |
|---|---|---|
| insert(x) | নতুন উপাদান তার অগ্রাধিকারসহ যোগ করা | নতুন রোগী এলেন, triage-এ তাঁর গুরুত্ব ঠিক হলো |
| peek() বা top() | সবচেয়ে অগ্রাধিকারেরটি দেখা, না সরিয়ে | “এখন সবচেয়ে জরুরি কে?” |
| extract() বা pop() | সবচেয়ে অগ্রাধিকারেরটি সরিয়ে ফেরত দেওয়া | সেই রোগীকে ডাক্তারের কাছে পাঠানো |
| size(), empty() | কয়টি উপাদান আছে | অপেক্ষার ঘরে কতজন |
| decrease-key (ঐচ্ছিক) | ভেতরে থাকা উপাদানের অগ্রাধিকার বদলানো | অপেক্ষার মধ্যেই কারও অবস্থা খারাপ হলো |
এখন প্রশ্ন, কীভাবে বানাবেন? আগের পর্বগুলোর জিনিস দিয়ে চেষ্টা করি। অসাজানো অ্যারে নিলে insert মানে শেষে বসানো, O(1); কিন্তু সবচেয়ে জরুরিটি খুঁজতে পুরো অ্যারে দেখতে হয়, O(n). সাজানো অ্যারে নিলে উল্টো: সবচেয়ে জরুরিটি এক প্রান্তে, O(1); কিন্তু নতুন রোগীকে সঠিক জায়গায় বসাতে বাকিদের সরাতে হয়, O(n) (insertion sort-এর একটি ধাপের মতো, পর্ব ২০). দুই উপায়েই n টি insert আর n টি extract মিলে O(n²).
| যা দিয়ে বানানো | insert | peek | extract | মন্তব্য |
|---|---|---|---|---|
| অসাজানো অ্যারে | O(1) | O(n) | O(n) | insert খুব সস্তা, খোঁজা ব্যয়বহুল |
| সাজানো অ্যারে | O(n) | O(1) | O(1) | উপাদান সরাতে হয় বলে insert ব্যয়বহুল |
| ভারসাম্যপূর্ণ BST (পর্ব ২৮–২৯) | O(log n) | O(log n) বা O(1) | O(log n) | কাজ চলে, কিন্তু pointer আর ভারসাম্যের বাড়তি খরচ |
| Binary heap | O(log n) | O(1) | O(log n) | একটি সাধারণ অ্যারে, কোনো pointer নেই; build O(n) |
Heap-এর কৌশলটি হলো একটি মাঝামাঝি পথ: পুরোপুরি সাজানোর দরকার নেই, শুধু এতটুকু ক্রম রাখতে হবে যাতে সবচেয়ে জরুরিটি সবসময় এক জায়গায় থাকে, আর প্রতিটি পরিবর্তনের পর সেই ক্রম সারাতে log n ধাপের বেশি না লাগে। “পুরো সাজানো” আর “একদম এলোমেলো”-র মাঝখানের এই আংশিক ক্রমই heap-এর শক্তি।
২ · Binary heap — অ্যারেতে লুকোনো একটি tree
পর্ব ২৬-এ tree বিস্তারিত পড়বেন; এখানে যতটুকু লাগে ততটুকুই। একটি binary tree-তে প্রতিটি node-এর সর্বোচ্চ দুটি সন্তান (বাঁ আর ডান)। সবার ওপরের node-কে বলে root, যার কোনো সন্তান নেই তাকে leaf. Root থেকে সবচেয়ে গভীর leaf পর্যন্ত edge-এর সংখ্যাই tree-র উচ্চতা (height).
Heap যে tree ব্যবহার করে, তা একটি বিশেষ ধরনের: complete binary tree. এর প্রতিটি স্তর (level) পুরো ভরা, শুধু শেষ স্তরটি ছাড়া — আর শেষ স্তরটি বাঁ থেকে ডানে ফাঁক ছাড়া ভরা হয়। সিনেমা হলের আসনের কথা ভাবুন: সামনের সারি পুরো না ভরা পর্যন্ত কেউ পেছনের সারিতে বসেন না, আর প্রতিটি সারিতে বসা শুরু হয় বাঁ দিক থেকে। এই নিয়মের দুটি বড় ফল আছে।
- উচ্চতা সবসময় ছোট: n টি node-এর complete binary tree-র উচ্চতা ঠিক ⌊log₂ n⌋. দশ লাখ উপাদানেও উচ্চতা মাত্র ১৯। তাই root থেকে leaf পর্যন্ত যেকোনো যাত্রা O(log n).
- Pointer লাগে না: node-গুলোকে স্তর ধরে বাঁ থেকে ডানে নম্বর দিলে (0, 1, 2, …) সেই ক্রমেই একটি অ্যারেতে রাখা যায়। কোনো ফাঁক নেই, তাই কে কার parent তা শুধু index-এর হিসাব থেকেই বের হয়।
a[0]
/ \
a[1] a[2]
/ \ / \
a[3] a[4] a[5] a[6]
/ \
a[7] a[8] (n = 9: শেষ স্তর বাঁ থেকে ভরা)
অ্যারেতে: a[0] a[1] a[2] a[3] a[4] a[5] a[6] a[7] a[8]
|__| |_______| |_________________| |_______|
স্তর 0 স্তর 1 স্তর 2 স্তর 3
| কী খুঁজছেন | 0-based (C++, Python) | 1-based (অনেক বই, CLRS) |
|---|---|---|
| parent(i) | (i − 1) / 2 (পূর্ণসংখ্যার ভাগ) | i / 2 |
| বাঁ সন্তান | 2i + 1 | 2i |
| ডান সন্তান | 2i + 2 | 2i + 1 |
| leaf কারা | index ⌊n/2⌋ থেকে n − 1 | ⌊n/2⌋ + 1 থেকে n |
| শেষ internal node | ⌊n/2⌋ − 1 | ⌊n/2⌋ |
যাচাই করুন: উপরের ছবিতে a[4]-এর parent (4 − 1)/2 = 1 — হ্যাঁ, a[1]. a[3]-এর সন্তান 2·3 + 1 = 7 আর 8 — হ্যাঁ, a[7], a[8]. আর n = 9 হলে leaf শুরু index ⌊9/2⌋ = 4 থেকে: a[4] থেকে a[8] — পাঁচটি leaf, অর্থাৎ প্রায় অর্ধেক node-ই leaf. এই তথ্যটি অংশ ৫-এ খুব কাজে লাগবে।
Heap property — একমাত্র নিয়ম
একটি complete binary tree heap হয়, যদি এটি heap property মানে:
- Min-heap: প্রতিটি node-এর মান তার সন্তানদের চেয়ে ছোট বা সমান — a[parent(i)] ≤ a[i]. ফলে সবচেয়ে ছোটটি root-এ, a[0]-এ।
- Max-heap: প্রতিটি node তার সন্তানদের চেয়ে বড় বা সমান. সবচেয়ে বড়টি root-এ।
লক্ষ করুন নিয়মটি কতটা দুর্বল। এটি শুধু parent আর সন্তানের সম্পর্ক বলে; দুই ভাইবোনের (sibling) মধ্যে কে বড়, বা বাঁ subtree-র সবাই ডানের চেয়ে ছোট কি না — কিছুই বলে না। তাই heap সাজানো অ্যারে নয়. [1, 5, 2, 9, 6, 3] একটি বৈধ min-heap, অথচ সাজানো নয়। এই দুর্বলতাই সুবিধা: নিয়ম কম মানে সারাতে কাজও কম। BST-তে (পর্ব ২৭) “বাঁয়ে সব ছোট, ডানে সব বড়” — কঠোর নিয়ম, তাই সেখানে যেকোনো মান খোঁজা যায়; heap-এ যায় না, এখানে শুধু সবচেয়ে ছোট বা সবচেয়ে বড়টি পাওয়া যায়।
নামের গোলমাল। এই heap আর পর্ব ১২-এ দেখা heap memory (যেখানে new বা malloc মেমরি দেয়) — দুটি সম্পূর্ণ আলাদা জিনিস, শুধু নাম এক। মেমরির heap কোনো heap ডেটা স্ট্রাকচার দিয়ে বানানো হয় না। ইন্টারভিউতে “heap” শুনলে প্রসঙ্গ দেখে বুঝুন।
৩ · Insert — নতুন উপাদান বুদবুদের মতো ওপরে ওঠে (sift-up)
Min-heap-এ নতুন মান x যোগ করতে হবে। Complete আকৃতি রাখতে x-কে বসানোর জায়গা একটাই — শেষ স্তরের প্রথম খালি ঘর, অর্থাৎ অ্যারের শেষে (index n). আকৃতি ঠিক থাকল, কিন্তু heap property ভাঙতে পারে: x যদি তার parent-এর চেয়ে ছোট হয়। সমাধান: x-কে parent-এর সাথে জায়গা বদল (swap) করুন, আর যতক্ষণ parent বড়, ততক্ষণ চালিয়ে যান। পানির নিচ থেকে বুদবুদ যেমন ওপরে ওঠে — এর নাম sift-up (bubble-up বা swim-ও বলে)। নিচে [2, 4, 3, 7, 9, 5, 8]-এ 1 যোগ করা দেখুন:
| 1-এর index i | |
| parent(i) = (i − 1)/2 | |
| এই ধাপের কাজ | |
| মোট swap |
- শুরু. সাতটি উপাদানের বৈধ min-heap: প্রতিটি parent তার সন্তানদের চেয়ে ছোট, root-এ সবচেয়ে ছোট 2.
- শেষে বসানো. 1 গেল index 7-এ — শেষ স্তরের বাঁয়ের প্রথম খালি ঘরে, 7-এর বাঁ সন্তান হিসেবে। আকৃতি complete রইল, কিন্তু parent 7 > 1 — নিয়ম ভাঙল।
- প্রথম swap. parent (7 − 1)/2 = 3-এর মান 7 বড়, তাই জায়গা বদল। 1 এখন index 3-এ। নিচে নামা 7 নিয়ে চিন্তা নেই: সে এখন index 7-এ, একটি leaf — তার নিচে কেউ নেই, তাই সেখানে নিয়ম ভাঙার প্রশ্নই ওঠে না।
- দ্বিতীয় swap. নতুন parent (3 − 1)/2 = 1, মান 4 > 1 — আবার swap. 1 এখন index 1-এ। নিচে নামা 4 তার নতুন সন্তান 7-এর চেয়ে ছোটই, কারণ সে আগে থেকেই 7-এর পূর্বপুরুষ ছিল।
- Root-এ পৌঁছানো. parent 0-এর মান 2 > 1 — শেষ swap. 1 এখন root-এ, তার ওপরে কেউ নেই, থামলাম। মোট ৩টি swap = tree-র উচ্চতা ⌊log₂ 8⌋ = 3. নতুন heap:
[1, 2, 3, 4, 9, 5, 8, 7].
কেন এটি ঠিক — invariant. Sift-up চলার পুরো সময় একটি কথা সত্য থাকে: “x যে ঘরে আছে, শুধু সেই ঘর আর তার parent-এর মধ্যে নিয়ম ভাঙা থাকতে পারে; বাকি সব parent–সন্তান জোড়া ঠিক।” শুরুতে এটি সত্য (আগের heap বৈধ ছিল, শুধু x নতুন)। Swap-এর সময় নিচে নামে parent-এর মান p. নিচের ঘরে p-এর নতুন সন্তান হয় x-এর পুরোনো ঘরের সন্তানেরা; p আগে থেকেই তাদের পূর্বপুরুষ ছিল, তাই heap property অনুযায়ী তাদের চেয়ে ছোট বা সমান — নিচের দিকে নিয়ম ঠিক থাকে। আর x ওপরে উঠে p-এর পুরোনো সন্তানের (x-এর ভাইবোনের) চেয়েও ছোট, কারণ x < p ≤ সেই ভাইবোন। ভাঙা জায়গাটি শুধু এক ধাপ ওপরে সরে। লুপ থামে যখন x root-এ পৌঁছায় বা parent ≤ x — তখন কোথাও কোনো ভাঙা নেই। এটি পর্ব ০৩-এর loop invariant পদ্ধতিরই প্রয়োগ।
বিশ্লেষণ: প্রতিটি ধাপে একটি তুলনা আর বড়জোর একটি swap, আর x প্রতি ধাপে এক স্তর ওপরে ওঠে। তাই worst case ⌊log₂ n⌋ ধাপ — সময় O(log n), বাড়তি জায়গা O(1). মজার কথা, এলোমেলো ক্রমে আসা ইনপুটে insert-এর গড় খরচ আসলে ধ্রুবকের কাছাকাছি, কারণ বেশিরভাগ নতুন উপাদান নিচের দিকেই থেকে যায় (অর্ধেক node-ই leaf). Worst case আসে যখন প্রতিটি নতুন মান আগের সবার চেয়ে ছোট — যেমন min-heap-এ কমতে থাকা ক্রমে insert.
৪ · Extract — root সরিয়ে শেষেরটিকে নিচে নামানো (sift-down)
সবচেয়ে ছোট মানটি সবসময় a[0]-এ, তাই peek O(1). কিন্তু সেটি সরিয়ে নিলে root খালি হয়, আর tree-র মাঝখানে একটি গর্ত রাখা যায় না। কৌশল: অ্যারের শেষ উপাদানটি তুলে root-এ বসান, আর অ্যারে এক ঘর ছোট করুন। আকৃতি আবার complete — কিন্তু শেষের উপাদানটি সাধারণত বড় একটি মান, root-এ বসার যোগ্য নয়। এবার উল্টো যাত্রা: তাকে তার ছোট সন্তানের সাথে swap করুন, যতক্ষণ সে কোনো সন্তানের চেয়ে বড়। এর নাম sift-down (bubble-down, sink বা heapify-ও বলে)।
| নামতে থাকা 7-এর index | |
| যে ঘরের সন্তান দেখা হলো | |
| এই ধাপের কাজ | |
| মোট swap |
- শুরু. আগের ডায়াগ্রামের heap
[1, 2, 3, 4, 9, 5, 8, 7]. root-এর 1-ই সবচেয়ে ছোট — এটিই ফেরত যাবে। আলাদা করে রেখে দিলাম। - শেষেরটি root-এ. শেষ উপাদান 7 উঠে এল a[0]-এ, অ্যারে এখন ৭ ঘরের। আকৃতি complete, কিন্তু root 7 তার সন্তান 2 আর 3-এর চেয়ে বড় — নিয়ম ভাঙা।
- ছোট সন্তানের সাথে swap. সন্তান index 1 (মান 2) আর 2 (মান 3); ছোটটি 2. কেন ছোটটি? কারণ যে ওপরে উঠবে, তাকে অন্য সন্তানেরও parent হতে হবে — 3 উঠলে 3 > 2 হয়ে আবার নিয়ম ভাঙত। swap-এর পর 7 আছে index 1-এ।
- আবার নামা. index 1-এর সন্তান 3 (মান 4) আর 4 (মান 9). ছোটটি 4 < 7, তাই swap. 7 এখন index 3-এ।
- থামা. index 3-এর সন্তান হতো 7 আর 8 — কিন্তু n = 7, তাই দুটোই অ্যারের বাইরে: 7 এখন leaf. থামলাম, ফেরত দিলাম 1. মোট ২টি swap, আর heap আবার
[2, 4, 3, 7, 9, 5, 8]— insert-এর আগের অবস্থা।
শুদ্ধতা ও খরচ. Invariant এবার আয়নার মতো: “শুধু নামতে থাকা মান আর তার সন্তানদের মধ্যে নিয়ম ভাঙা থাকতে পারে।” ছোট সন্তান c ওপরে উঠলে সে নিজের ভাইবোনের চেয়ে ছোট বা সমান (তাকে বাছাই করাই হয়েছে সেজন্য), আর নিজের পুরোনো subtree-র সবার চেয়েও ছোট বা সমান (heap property) — তাই তার ঘরে নিয়ম ঠিক। ভাঙা জায়গাটি এক স্তর নামে, আর leaf-এ পৌঁছালে বা দুই সন্তানের চেয়েই ছোট বা সমান হলে শেষ। প্রতি স্তরে দুটি তুলনা (দুই সন্তানের মধ্যে, তারপর নিজের সাথে) আর একটি swap, স্তর ⌊log₂ n⌋ টি — তাই extract O(log n), বাড়তি জায়গা O(1).
এবার দুটি কাজ একসাথে কোডে। লক্ষ করুন, sift-down-এ ডান সন্তানের অস্তিত্ব (r < n) আলাদা করে যাচাই করা হয়েছে — শেষ internal node-এর প্রায়ই শুধু বাঁ সন্তান থাকে।
#include <iostream>
#include <utility>
#include <vector>
#include <initializer_list>
using namespace std;
struct MinHeap {
vector<int> a;
int push(int x) { // append at the end, then sift-up
a.push_back(x); size_t i = a.size() - 1; int swaps = 0;
for (; i > 0 && a[(i - 1) / 2] > a[i]; i = (i - 1) / 2, ++swaps) swap(a[(i - 1) / 2], a[i]);
return swaps;
}
pair<int, int> pop() { // caller checks !a.empty()
int top = a[0], swaps = 0;
a[0] = a.back(); a.pop_back(); // last element jumps to the root
for (size_t i = 0, n = a.size();; ++swaps) { // sift-down
size_t l = 2 * i + 1, r = l + 1, m = i;
if (l < n && a[l] < a[m]) m = l;
if (r < n && a[r] < a[m]) m = r; // m = the smaller child, if smaller than a[i]
if (m == i) break;
swap(a[i], a[m]); i = m;
}
return {top, swaps};
}
};
int main() {
MinHeap h;
auto show = [&](const char* op, int v, int s) {
cout << op << v << ": [";
for (size_t i = 0; i < h.a.size(); ++i) cout << (i ? " " : "") << h.a[i];
cout << "] swaps=" << s << '\n';
};
for (int x : {4, 2, 3, 7, 9, 5, 8, 1}) show("push ", x, h.push(x));
while (!h.a.empty()) { auto [v, s] = h.pop(); show("pop ", v, s); }
}আউটপুট:
push 4: [4] swaps=0
push 2: [2 4] swaps=1
push 3: [2 4 3] swaps=0
push 7: [2 4 3 7] swaps=0
push 9: [2 4 3 7 9] swaps=0
push 5: [2 4 3 7 9 5] swaps=0
push 8: [2 4 3 7 9 5 8] swaps=0
push 1: [1 2 3 4 9 5 8 7] swaps=3
pop 1: [2 4 3 7 9 5 8] swaps=2
pop 2: [3 4 5 7 9 8] swaps=2
pop 3: [4 7 5 8 9] swaps=2
pop 4: [5 7 9 8] swaps=1
pop 5: [7 8 9] swaps=1
pop 7: [8 9] swaps=1
pop 8: [9] swaps=0
pop 9: [] swaps=0
প্রথম সাতটি push শেষে অ্যারে [2 4 3 7 9 5 8] — অংশ ৩-এর ডায়াগ্রামের শুরুর heap. তারপর push 1-এ ৩টি swap, আর প্রথম pop-এ ২টি — ঠিক দুই ডায়াগ্রামের মতো। কোনো ধাপেই swap ৩-এর বেশি হয়নি, কারণ ৮ উপাদানের heap-এর উচ্চতা ৩। সবচেয়ে সুন্দর জিনিসটি pop-এর বাঁ কলামে: 1 2 3 4 5 7 8 9 — বারবার সর্বনিম্ন বের করলে সাজানো ক্রম পাওয়া যায়। এটিই heapsort-এর বীজ (অংশ ৬)। pop()-এ খালি heap যাচাই কলকারীর দায়িত্ব, ঠিক std::priority_queue-এর মতো।
class MinHeap:
def __init__(self):
self.a = []
def push(self, x): # append at the end, then sift-up
a, swaps = self.a, 0
a.append(x)
i = len(a) - 1
while i > 0 and a[(i - 1) // 2] > a[i]:
p = (i - 1) // 2
a[p], a[i] = a[i], a[p]
i, swaps = p, swaps + 1
return swaps
def pop(self): # caller checks the heap is not empty
a, swaps = self.a, 0
top = a[0]
a[0] = a[-1] # last element jumps to the root
a.pop()
i, n = 0, len(a)
while True: # sift-down
l, r, m = 2 * i + 1, 2 * i + 2, i
if l < n and a[l] < a[m]: m = l
if r < n and a[r] < a[m]: m = r
if m == i: break
a[i], a[m] = a[m], a[i]
i, swaps = m, swaps + 1
return top, swaps
h = MinHeap()
for x in [4, 2, 3, 7, 9, 5, 8, 1]:
s = h.push(x)
print(f"push {x}: [{' '.join(map(str, h.a))}] swaps={s}")
while h.a:
v, s = h.pop()
print(f"pop {v}: [{' '.join(map(str, h.a))}] swaps={s}")আউটপুট C++-এর সাথে হুবহু এক:
push 4: [4] swaps=0
push 2: [2 4] swaps=1
push 3: [2 4 3] swaps=0
push 7: [2 4 3 7] swaps=0
push 9: [2 4 3 7 9] swaps=0
push 5: [2 4 3 7 9 5] swaps=0
push 8: [2 4 3 7 9 5 8] swaps=0
push 1: [1 2 3 4 9 5 8 7] swaps=3
pop 1: [2 4 3 7 9 5 8] swaps=2
pop 2: [3 4 5 7 9 8] swaps=2
pop 3: [4 7 5 8 9] swaps=2
pop 4: [5 7 9 8] swaps=1
pop 5: [7 8 9] swaps=1
pop 7: [8 9] swaps=1
pop 8: [9] swaps=0
pop 9: [] swaps=0
Python-এ // পূর্ণসংখ্যার ভাগ; (i - 1) // 2 তাই parent-এর সূত্র। i = 0-তে এটি −1 দিত (Python নিচের দিকে round করে), কিন্তু i > 0 শর্ত আগে থাকায় সেই হিসাব কখনো হয় না। a[p], a[i] = a[i], a[p] এক লাইনে swap. একটি উপাদানের heap-এ pop করলে a[0] = a[-1] নিজেকেই নিজের ওপর লেখে, তারপর a.pop() list খালি করে — আলাদা শাখা লাগে না। বাস্তবে নিজে না লিখে heapq ব্যবহার করবেন (অংশ ৭), কিন্তু ভেতরে ঠিক এই দুটি লুপই চলে।
নিজে চালিয়ে দেখুন — heap visualiser
নিচের টুলে “চালিয়ে দেখুন” চাপুন। একটি সংখ্যা লিখে insert চাপলে সেটি শেষে বসে ওপরে উঠবে; extract-min চাপলে root বের হয়ে শেষেরটি নিচে নামবে। যে ঘরগুলোর মধ্য দিয়ে মানটি গেছে, সেগুলো লাল হয়ে জ্বলবে — tree-তে আর নিচের অ্যারেতে একসাথে। ছোট নম্বরগুলো index. লক্ষ করুন, দশটি এলোমেলো মান দিলেও কোনো কাজে swap ৩-এর বেশি হয় না।
const hp = []; // the min-heap, drawn as a tree (top) and an array (bottom)
let mark = new Set();
const $ = id => document.getElementById(id);
const cell = i => `<div class="nd${mark.has(i) ? " hl" : ""}">${hp[i]}</div>`;
function draw(msg) {
let t = "";
for (let k = 0, s = 0; s < hp.length; k++, s = 2 ** k - 1) { // one row per level
t += '<div class="lv">';
for (let i = s; i < s + 2 ** k; i++) t += `<div class="sl">${i < hp.length ? cell(i) : ""}</div>`;
t += "</div>";
}
$("tr").innerHTML = t; $("msg").textContent = msg;
$("ar").innerHTML = hp.map((v, i) => `<span class="${mark.has(i) ? "hl" : ""}">${v}<sub>${i}</sub></span>`).join("");
console.log(msg.padEnd(26) + "[" + hp.join(" ") + "]");
}
function insert(x) {
hp.push(x); let i = hp.length - 1, sw = 0; mark = new Set([i]);
while (i > 0 && hp[(i - 1) >> 1] > hp[i]) { // sift-up
const p = (i - 1) >> 1; [hp[p], hp[i]] = [hp[i], hp[p]]; i = p; sw++; mark.add(i);
}
draw(`insert(${x}): ${sw} swaps`);
}
function extractMin() {
if (!hp.length) return draw("heap is empty");
const m = hp[0], last = hp.pop(); let i = 0, sw = 0; mark = new Set([0]); if (hp.length) hp[0] = last;
for (;;) { // sift-down
const l = 2 * i + 1, r = l + 1; let c = i;
if (l < hp.length && hp[l] < hp[c]) c = l;
if (r < hp.length && hp[r] < hp[c]) c = r;
if (c === i) break;
[hp[i], hp[c]] = [hp[c], hp[i]]; i = c; sw++; mark.add(i);
}
draw(`extractMin() = ${m}: ${sw} swaps`);
}
$("bi").onclick = () => insert(Number($("hv").value)); $("bx").onclick = extractMin;
$("br").onclick = () => { hp.length = 0; for (let k = 0; k < 10; k++) insert(10 + Math.floor(Math.random() * 90)); };
for (const x of [4, 2, 3, 7, 9, 5, 8, 1]) insert(x); extractMin();শুরুতেই কোড নিজে আটটি insert আর একটি extract করে; আউটপুটে প্রতিটির পরের অ্যারে:
insert(4): 0 swaps [4]
insert(2): 1 swaps [2 4]
insert(3): 0 swaps [2 4 3]
insert(7): 0 swaps [2 4 3 7]
insert(9): 0 swaps [2 4 3 7 9]
insert(5): 0 swaps [2 4 3 7 9 5]
insert(8): 0 swaps [2 4 3 7 9 5 8]
insert(1): 3 swaps [1 2 3 4 9 5 8 7]
extractMin() = 1: 2 swaps [2 4 3 7 9 5 8]
সংখ্যাগুলো C++ আর Python প্রোগ্রামের সাথে মেলে। ছবিতে প্রতিটি স্তর একটি সারি, আর স্তর k-তে 2k টি সমান চওড়া ঘর — তাই index i-এর node আপনাআপনি তার parent-এর ঠিক নিচে বাঁয়ে বা ডানে বসে। (i - 1) >> 1 মানে (i − 1)/2-এর পূর্ণসংখ্যা অংশ — bit সরিয়ে ২ দিয়ে ভাগ। পরীক্ষা: পরপর কয়েকবার extract-min চাপুন আর বেরোনো মানগুলো লিখে রাখুন — সেগুলো সবসময় ছোট থেকে বড় ক্রমে আসবে। তারপর একটি খুব ছোট মান (যেমন 0) insert করুন — লাল পথটি leaf থেকে root পর্যন্ত যাবে, আর swap হবে উচ্চতার সমান।
৫ · Build-heap O(n)-এ — Floyd-এর নিচ থেকে ওপরের কৌশল
ধরুন n টি সংখ্যা আগে থেকেই একটি অ্যারেতে আছে, আর এগুলো দিয়ে heap বানাতে হবে। সরল পথ: একে একে n বার insert. প্রতিটি insert O(log n), মোট O(n log n). আর এটি শুধু ওপরের সীমা নয় — কমতে থাকা ক্রমের ইনপুটে প্রতিটি নতুন উপাদান root পর্যন্ত ওঠে, মোট কাজ log 1 + log 2 + … + log n = Θ(n log n).
১৯৬৪ সালে Robert W. Floyd একটি চমৎকার বিকল্প দেখান, যাকে আজ build-heap বা bottom-up heapify বলে। পর্যবেক্ষণটি সরল: প্রতিটি leaf একা একাই একটি বৈধ heap — তার কোনো সন্তান নেই, নিয়ম ভাঙার সুযোগই নেই। আর অংশ ২-এ দেখেছেন, প্রায় অর্ধেক node-ই leaf. তাই কাজ শুরু করুন শেষ internal node (index ⌊n/2⌋ − 1) থেকে, আর পেছন দিকে root পর্যন্ত প্রতিটি node-এ sift-down চালান। যখন index i-তে sift-down চলে, তখন তার দুই subtree আগেই heap বানানো হয়ে গেছে — তাই i-কে নিচে নামিয়ে সঠিক জায়গায় বসালেই i-এর পুরো subtree heap হয়ে যায়।
Pseudocode — build-heap and heapsort (0-based, MAX-heap, ascending result)
siftDown(a, i, n): // heap occupies a[0..n-1]
while 2i+1 < n:
c = 2i+1 // left child
if c+1 < n and a[c+1] > a[c]: c = c+1 // pick the larger child
if a[i] >= a[c]: return
swap a[i], a[c]; i = c
buildHeap(a, n): // Floyd 1964: bottom-up
for i = n/2 - 1 down to 0: siftDown(a, i, n)
heapsort(a, n):
buildHeap(a, n)
for end = n-1 down to 1:
swap a[0], a[end] // current maximum goes to its final place
siftDown(a, 0, end) // the heap shrinks to a[0..end-1]এখানে max-heap নিয়েছি, কারণ পরের অংশের heapsort ছোট থেকে বড় ক্রমে সাজাতে max-heap ব্যবহার করে। Min-heap-এ শুধু তুলনার চিহ্ন উল্টে যায়। এবার [3, 1, 6, 5, 2, 4] থেকে max-heap বানানো দেখুন:
| sift-down শুরু যে index থেকে | |
| এই ধাপের কাজ | |
| মোট swap |
- শুরু. n = 6, শেষ internal node ⌊6/2⌋ − 1 = 2. index 3, 4, 5 (মান 5, 2, 4) leaf — এরা এমনিতেই এক-একটি heap, এদের ছুঁতে হবে না।
- i = 2. মান 6-এর একটিই সন্তান, index 5-এ 4. 6 ≥ 4 — নিয়ম ঠিক আছে, কিছু করতে হলো না। subtree {6, 4} এখন max-heap.
- i = 1. মান 1, সন্তান 5 আর 2; বড়টি 5. 1 < 5, তাই swap: অ্যারে
[3, 5, 6, 1, 2, 4]. 1 নেমে index 3-এ গেল, যেটি leaf — থামলাম। subtree {5, 1, 2} এখন heap. - i = 0. root 3, সন্তান 5 আর 6; বড়টি 6. swap:
[6, 5, 3, 1, 2, 4]. কিন্তু 3 এখন index 2-এ — সেখানে আবার দেখতে হবে। - নামা চলছে. index 2-এ 3, তার সন্তান index 5-এ 4. 3 < 4 — swap:
[6, 5, 4, 1, 2, 3]. 3 এখন leaf, শেষ। মোট ৩টি swap — n = 6-এর চেয়ে কম। ফল একটি বৈধ max-heap: 6 ≥ 5, 4; 5 ≥ 1, 2; 4 ≥ 3.
কেন O(n) — উচ্চতাগুলোর যোগফল
প্রথম দেখায় মনে হয়: n/2 টি node-এ sift-down, প্রতিটি O(log n), তাই O(n log n). এই হিসাব ভুল নয়, কিন্তু ঢিলেঢালা — কারণ বেশিরভাগ node নিচের দিকে, আর নিচের node-এর sift-down ছোট। Node v থেকে sift-down সর্বোচ্চ ততগুলো স্তর নামতে পারে, যতটা v-এর নিজের উচ্চতা h (v থেকে তার নিচের সবচেয়ে গভীর leaf পর্যন্ত দূরত্ব)। এবার গুনুন কোন উচ্চতায় কয়টি node:
- উচ্চতা 0 (leaf): প্রায় n/2 টি — কাজ 0
- উচ্চতা 1: প্রায় n/4 টি — প্রতিটি সর্বোচ্চ 1 swap
- উচ্চতা 2: প্রায় n/8 টি — প্রতিটি সর্বোচ্চ 2 swap
- সাধারণভাবে উচ্চতা h-এ সর্বোচ্চ ⌈n / 2h+1⌉ টি node, আর root একা উচ্চতা ⌊log₂ n⌋-এ।
তাই মোট swap-এর সংখ্যা সর্বোচ্চ
শেষ ধাপে লেগেছে একটি পরিচিত যোগফল: Σh≥0 h/2h = 1/2 + 2/4 + 3/8 + 4/16 + … = 2. (পর্ব ০৬-এর geometric series Σ xh = 1/(1 − x)-কে x দিয়ে differentiate করে x দিয়ে গুণ করলে Σ h·xh = x/(1 − x)²; x = 1/2 বসালে 2.) উচ্চতা h-এর node-এ sift-down সর্বোচ্চ 2h টি তুলনা করে (প্রতি স্তরে দুটি), তাই build-heap-এ তুলনাও 2n-এর বেশি নয় — সময় Θ(n), বাড়তি জায়গা O(1). (⌈⌉ থেকে আসা বাড়তি অংশ যোগ করলেও O(n) বদলায় না।)
একটি নিখুঁত হিসাবও আছে। পুরো ভরা (perfect) binary tree-তে, যার উচ্চতা H আর n = 2H+1 − 1, সব node-এর উচ্চতার যোগফল ঠিক n − H − 1. যেমন n = 7 (H = 2): root-এর উচ্চতা 2, দুটি node-এর 1, চারটি leaf-এর 0 — যোগফল 4 = 7 − 2 − 1. অর্থাৎ build-heap-এ swap কখনো n-এর সমানও হয় না।
Insert-ভিত্তিক বানানো আর Floyd-এর পদ্ধতির পার্থক্য হলো কাজ কোথায় বেশি. Sift-up-এ খরচ node-এর গভীরতা (depth, root থেকে দূরত্ব) — আর বেশিরভাগ node গভীরে, তাই মোট Θ(n log n). Sift-down-এ খরচ node-এর উচ্চতা — আর বেশিরভাগ node-এর উচ্চতা প্রায় শূন্য, তাই মোট Θ(n). একই tree, একই node, শুধু দিক বদলালেই এক log n গুণ সাশ্রয়।
৬ · Heapsort — জায়গায় বসে O(n log n)
কোডের আউটপুটে দেখেছেন, বারবার extract করলে সাজানো ক্রম আসে। কিন্তু বের হওয়া মানগুলো রাখতে আলাদা অ্যারে লাগবে? না — এখানেই heapsort-এর চাতুর্য। Max-heap-এ extract করলে অ্যারের শেষ ঘরটি খালি হয়; আর বের হওয়া সর্বোচ্চ মানটির সাজানো অ্যারেতে ঠিক জায়গা সেই শেষ ঘরই! তাই প্রতিবার a[0]-কে heap-এর শেষ ঘরের সাথে swap করুন, heap-এর আকার এক কমান, আর root থেকে sift-down করুন। অ্যারের বাঁ অংশ heap, ডান অংশ বাড়তে থাকা সাজানো অংশ — একই অ্যারের মধ্যে। ওপরের pseudocode-এর heapsort ঠিক এটিই করে। আগের ডায়াগ্রামের max-heap থেকে শুরু করুন:
| heap-এর আকার | |
| এই ধাপের কাজ | |
| সাজানো অংশ |
- শুরু. build-heap-এর পর max-heap
[6, 5, 4, 1, 2, 3]. সবচেয়ে বড় 6 আছে a[0]-এ — কিন্তু সাজানো অ্যারেতে তার জায়গা a[5]. - প্রথম extract. a[0] আর a[5] swap: 6 চূড়ান্ত জায়গায়। heap এখন ৫ ঘরের, root-এ 3 — sift-down: বড় সন্তান 5-এর সাথে swap, তারপর 3-এর সন্তান 1, 2 ছোট, থামা। heap
[5, 3, 4, 1, 2]. - দ্বিতীয়. 5 ↔ a[4]-এর 2. root 2-এর সন্তান 3, 4 — বড় 4-এর সাথে swap. heap
[4, 3, 2, 1], সাজানো অংশ 5 6. - তৃতীয়. 4 ↔ a[3]-এর 1. root 1-এর সন্তান 3, 2 — 3-এর সাথে swap. heap
[3, 1, 2], সাজানো অংশ 4 5 6. - চতুর্থ. 3 ↔ a[2]-এর 2. root 2-এর একমাত্র সন্তান 1 — 2 ≥ 1, নামতে হলো না। heap
[2, 1]. - শেষ. 2 ↔ a[1]-এর 1. heap-এ একটি মাত্র উপাদান, যা নিজেই সবচেয়ে ছোট — সেটিও নিজের জায়গায়। অ্যারে
[1, 2, 3, 4, 5, 6]— কোনো বাড়তি অ্যারে ছাড়াই সাজানো।
বিশ্লেষণ. Build-heap O(n), তারপর n − 1 বার swap আর sift-down, প্রতিটি O(log n) — মোট O(n log n), আর এটি worst case-এও সত্য; quicksort-এর মতো কোনো খারাপ ইনপুটে O(n²)-এ পড়ে না (পর্ব ২২). বেশিরভাগ ইনপুটে আসলে Θ(n log n), কারণ root-এ আনা মানটি অ্যারের শেষ থেকে আসে — সাধারণত ছোট মান — আর প্রায় নিচ পর্যন্ত নামে। Sift-down লুপ দিয়ে লিখলে বাড়তি জায়গা O(1): heapsort একটি in-place অ্যালগরিদম, merge sort-এর (পর্ব ২১) মতো Θ(n) বাড়তি অ্যারে লাগে না।
কিন্তু stable নয়. Stable sort মানে সমান key-এর উপাদানগুলোর আগের ক্রম বজায় থাকে। Heapsort-এ দূরের ঘরের মধ্যে swap হয়, তাই এই ক্রম ভেঙে যায়। ছোট উদাহরণ: [2a, 2b, 1] (দুটি 2, আলাদা চেনার জন্য a আর b). এটি আগেই max-heap. a[0] ↔ a[2]: [1, 2b ¦ 2a]; sift-down: [2b, 1 ¦ 2a]; a[0] ↔ a[1]: [1 ¦ 2b, 2a]. শেষে 2b চলে গেছে 2a-এর আগে। রোল নম্বরে আগে থেকে সাজানো ছাত্রদের নম্বর ধরে heapsort করলে একই নম্বরের ছাত্রদের রোল-ক্রম এভাবেই এলোমেলো হয়ে যাবে।
| বিষয় | Merge sort | Quick sort | Heapsort |
|---|---|---|---|
| Worst case | O(n log n) | O(n²) | O(n log n) |
| বাড়তি জায়গা | Θ(n) | O(log n) গড় (recursion) | O(1) |
| Stable? | হ্যাঁ | না | না |
| বাস্তবে গতি | ভালো | সাধারণত সবচেয়ে দ্রুত | ধীর — দূরের ঘরে লাফ, cache-এ খারাপ |
শেষ সারিটি গুরুত্বপূর্ণ। a[i] থেকে a[2i+1]-এ লাফ মানে বড় অ্যারেতে প্রায় প্রতিটি ধাপে মেমরির দূরের অংশ — পর্ব ১০-এর cache-এর সুবিধা হারায়। তাই লাইব্রেরিগুলো heapsort-কে সাধারণত নিরাপত্তা-জাল হিসেবে রাখে: C++-এর std::sort সাধারণত introsort — quicksort দিয়ে শুরু, recursion বেশি গভীর হলে heapsort-এ চলে যায়, যাতে worst case O(n log n) নিশ্চিত থাকে। std::partial_sort (প্রথম k টি সাজানো) সাধারণত heap দিয়েই বানানো হয়।
১৯৬৪ সালে Communications of the ACM-এ ব্রিটিশ কম্পিউটার বিজ্ঞানী J. W. J. Williams “Algorithm 232: Heapsort” প্রকাশ করেন। সেখানেই প্রথম অ্যারেতে রাখা heap ডেটা স্ট্রাকচার আর heapsort একসাথে আসে, আর “heap” নামটিও সেখান থেকে। Williams heap বানিয়েছিলেন একে একে insert করে। একই বছরের শেষে একই পত্রিকায় Robert W. Floyd “Algorithm 245: Treesort 3” প্রকাশ করেন — সেখানে নিচ থেকে ওপরে sift-down চালিয়ে জায়গায় বসেই heap বানানোর এই পদ্ধতি, যার খরচ O(n). Floyd পরে ১৯৭৮ সালে Turing Award পান (shortest path-এর Floyd-Warshall অ্যালগরিদমেও তাঁর নাম আছে, পর্ব ৪৪)।
৭ · লাইব্রেরির heap আর decrease-key
কনটেস্ট আর কাজের কোডে heap নিজে লেখা হয় কমই। তবে দুই ভাষার লাইব্রেরি দুই উল্টো দিক থেকে শুরু করে — এই একটি তথ্য ভুলে অসংখ্য wrong answer হয়।
#include <queue> // priority_queue
#include <functional> // greater
priority_queue<int> mx; // MAX-heap by default (compare = less)
priority_queue<int, vector<int>, greater<int>> mn; // MIN-heap
mx.push(5); mx.push(9);
int best = mx.top(); // 9 -- read first...
mx.pop(); // ...then remove; pop() returns void
// (priority, task id): pairs compare by .first, then .second
priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> jobs;
vector<int> v{3, 1, 6, 5, 2, 4};
make_heap(v.begin(), v.end()); // Floyd's O(n) build-heap, <algorithm>
// push_heap / pop_heap / sort_heap work on a plain vector toostd::priority_queue একটি container adaptor (পর্ব ১৫): ভেতরে একটি vector, আর ঠিক এই পর্বের sift-up/sift-down. নাম “less” হলেও ফল max-heap — comparator বলে “কে কম অগ্রাধিকারের”, আর top-এ থাকে যে কারও চেয়ে “কম” নয়। Min-heap চাইলে greater<int>; নিজের struct-এর জন্য লিখুন bool operator()(const T& a, const T& b) const { return a.prio > b.prio; } — এটিও min-heap দেয়। Comparator-এর এই উল্টো যুক্তিই C++-এ heap-এর সবচেয়ে সাধারণ ভুল। top() O(1), push/pop O(log n); খালি অবস্থায় top() undefined behavior.
import heapq
h = [] # heapq works on a plain list: MIN-heap
heapq.heappush(h, 5); heapq.heappush(h, 2)
h[0] # 2 -- the smallest, O(1)
heapq.heappop(h) # removes AND returns 2, O(log n)
data = [3, 1, 6, 5, 2, 4]
heapq.heapify(data) # O(n) in place, bottom-up
mx = [] # max-heap trick: store negatives
heapq.heappush(mx, -9); -mx[0] # 9
tasks = [] # (priority, counter, task): counter breaks ties
heapq.heappush(tasks, (2, 0, {"name": "backup"}))
heapq.heappush(tasks, (2, 1, {"name": "email"}))
heapq.nlargest(3, data); heapq.nsmallest(3, data)heapq কোনো class নয় — একটি সাধারণ list-এর ওপর কাজ করা কয়েকটি ফাংশন, আর এটি min-heap. Max-heap-এর প্রচলিত কৌশল হলো মান ঋণাত্মক করে রাখা (বের করার সময় আবার চিহ্ন বদলানো); Python 3.14 থেকে heapq.heappush_max, heappop_max, heapify_max নামে max-heap-এর ফাংশনও এসেছে, তবে অনেক judge-এ পুরোনো সংস্করণ থাকে। অগ্রাধিকারের সাথে কাজ রাখতে tuple ব্যবহার করুন — tuple বাঁ থেকে তুলনা হয়। মাঝের counter জরুরি: দুটি কাজের অগ্রাধিকার সমান হলে Python পরের উপাদান তুলনা করবে, আর দুটি dict তুলনা করা যায় না — TypeError. counter সমতা ভাঙে, আর আগে আসা কাজ আগে বের হয়। list-এর কোনো উপাদান সরাসরি বদলে দিলে heap property ভেঙে যায়; তখন আবার heapify লাগবে।
Decrease-key — কেন অবস্থান মনে রাখতে হয়
জরুরি বিভাগে অপেক্ষার মধ্যেই একজন রোগীর অবস্থা হঠাৎ খারাপ হলো — তাঁর অগ্রাধিকার বাড়াতে হবে। Min-heap-এর ভাষায় এটি decrease-key: ভেতরে থাকা একটি উপাদানের key কমানো। Key কমলে উপাদানটি শুধু ওপরের দিকে নিয়ম ভাঙতে পারে, তাই তার ঘর থেকে sift-up চালালেই হয় — O(log n). কিন্তু আসল প্রশ্ন: উপাদানটি অ্যারের কোন ঘরে আছে? Heap খোঁজার জন্য বানানো নয়; খুঁজতে গেলে O(n).
সমাধান হলো indexed priority queue: একটি বাড়তি অ্যারে pos[id], যা বলে কোন উপাদান heap-এর কোন ঘরে আছে। প্রতিটি swap-এর সাথে pos-ও হালনাগাদ করতে হয়:
// indexed min-heap: heap[] holds item ids, key[id] is the priority, pos[id] = index of id in heap[]
vector<int> heap, pos, key;
void swapNodes(int i, int j) {
swap(heap[i], heap[j]);
pos[heap[i]] = i; pos[heap[j]] = j; // keep the map in sync with EVERY swap
}
void siftUp(int i) {
for (; i > 0 && key[heap[(i - 1) / 2]] > key[heap[i]]; i = (i - 1) / 2) swapNodes(i, (i - 1) / 2);
}
void decreaseKey(int id, int k) { // requires k <= key[id]
key[id] = k;
siftUp(pos[id]); // O(1) to find, O(log n) to repair
}এর সবচেয়ে বিখ্যাত ব্যবহার Dijkstra-র অ্যালগরিদম (পর্ব ৪৩): প্রতিটি vertex-এর এখন পর্যন্ত জানা দূরত্বই তার key, আর ছোট পথ পাওয়া গেলে key কমে। মুশকিল হলো, std::priority_queue আর heapq — কোনোটিতেই decrease-key নেই। তাই কনটেস্টে প্রচলিত বিকল্প lazy deletion: পুরোনোটি না বদলে নতুন (দূরত্ব, vertex) জোড়া আরেকবার push করুন; পরে কোনো জোড়া pop হলে দেখুন সেটি পুরোনো কি না (d != dist[u] হলে বাদ)। Heap-এ তখন সর্বোচ্চ E টি জোড়া থাকে, প্রতিটি কাজ O(log E) = O(log V) (কারণ E ≤ V²), তাই মোট O((V + E) log V) — indexed heap-এর সমান, কোডে অনেক সহজ, শুধু জায়গা O(V) থেকে বেড়ে O(E). বিস্তারিত পর্ব ৪৩-এ।
৮ · প্রয়োগ — top-k, k টি list জোড়া, running median আর scheduling
সবচেয়ে বড় k টি — উল্টো heap-এর কৌশল
এক কোটি bKash লেনদেনের মধ্যে সবচেয়ে বড় ১০০টি দরকার। সব সাজালে O(n log n), আর সবকিছু মেমরিতে রাখতে হয়। ভালো উপায়টি প্রথমে উল্টো শোনায়: সবচেয়ে বড়গুলোর জন্য রাখুন একটি min-heap, যার আকার সর্বোচ্চ k. প্রতিটি নতুন মান push করুন; আকার k ছাড়ালে pop — অর্থাৎ k + 1 টির মধ্যে সবচেয়ে ছোটটি বাদ। Heap-এর root তখন “এখন পর্যন্ত k-তম বড়” — দরজার দারোয়ানের মতো: এর চেয়ে ছোট কেউ ভেতরে টিকতে পারে না। এটিই top-k সমস্যার মানক সমাধান।
import heapq
def k_largest(nums, k):
h = [] # min-heap of the k largest seen so far
for x in nums:
if len(h) < k:
heapq.heappush(h, x)
elif x > h[0]: # beats the current k-th largest
heapq.heapreplace(h, x) # pop the smallest and push x in one O(log k) step
return sorted(h, reverse=True) # h[0] alone is the k-th largest
print(k_largest([5, 1, 9, 3, 7, 8, 2], 3)) # [9, 8, 7]প্রতিটি উপাদানে O(log k) কাজ, তাই সময় O(n log k), জায়গা O(k). k = 100, n = 107 হলে log k ≈ 7, log n ≈ 23 — তিন গুণের বেশি সাশ্রয়, আর মেমরিতে মাত্র ১০০টি মান। ডেটা যদি স্রোতের মতো আসে (stream) আর পুরোটা একসাথে কখনো পাওয়া না যায়, তাহলে এটিই একমাত্র ব্যবহারযোগ্য পথ। পুরো অ্যারে হাতে থাকলে শুধু k-তম মানটির জন্য quickselect গড়ে O(n) (পর্ব ২২). Python-এর heapq.nlargest ভেতরে প্রায় এই কৌশলই ব্যবহার করে।
একই ছাঁচ top-k trending-এ: ফেসবুকে এই ঘণ্টায় কোন ১০টি hashtag সবচেয়ে বেশি এসেছে? প্রথমে একটি hash table-এ প্রতিটি hashtag গুনুন (পর্ব ৩২), তারপর (সংখ্যা, hashtag) জোড়াগুলোর ওপর আকার-১০ min-heap চালান — O(m log 10), যেখানে m আলাদা hashtag-এর সংখ্যা।
k টি সাজানো list জোড়া — k-way merge
পর্ব ২১-এর merge দুটি সাজানো list জোড়ে। k টি হলে? ধরুন দেশের আটটি বিভাগের বোর্ড পরীক্ষার ফল আলাদা আলাদা নম্বর ধরে সাজানো আছে, আর একটি জাতীয় মেধাতালিকা চাই। প্রতিটি list-এর প্রথম উপাদান একটি min-heap-এ রাখুন (মান, কোন list, কোন index). প্রতিবার সবচেয়ে ছোটটি pop করে output-এ দিন, আর সেই list-এর পরের উপাদান push করুন। Heap-এ সবসময় সর্বোচ্চ k টি উপাদান, তাই মোট N টি উপাদানে সময় O(N log k), জায়গা O(k) (output বাদে)। এই k-way merge বড় ডেটা সাজানোর (external sorting) মূল ধাপ — মেমরিতে না ধরা ফাইল টুকরো করে সাজিয়ে তারপর একসাথে জোড়া হয়, আর ডেটাবেস ও search engine-এর index জোড়ায়ও এটি চলে। Python-এ তৈরি আছে: heapq.merge(*lists).
Running median — দুটি heap মুখোমুখি
সংখ্যা একে একে আসছে, আর প্রতিটির পর জানতে হবে এখন পর্যন্ত সবগুলোর median (মাঝের মান) কত। প্রতিবার সাজালে O(n log n) প্রতি ধাপে। কৌশল: সংখ্যাগুলোকে দুই ভাগে রাখুন — ছোট অর্ধেক একটি max-heap low-এ, বড় অর্ধেক একটি min-heap high-এ। দুই heap-এর root দুটি তখন ঠিক মাঝখানে মুখোমুখি দাঁড়িয়ে। নিয়ম দুটি: (১) low-এর সবাই ≤ high-এর সবাই; (২) আকার সমান, অথবা low-এ একটি বেশি। তাহলে মোট বিজোড় হলে median = low-এর root, জোড় হলে দুই root-এর গড়।
| নতুন x | |
| কোথায় গেল, ভারসাম্য | |
| low (max-heap, root = সবচেয়ে বড়) | |
| high (min-heap, root = সবচেয়ে ছোট) | |
| median |
- x = 5. low খালি, তাই low-এ। মোট ১টি — median 5.
- x = 15. 15 > low-এর root 5, তাই high-এ। আকার 1 আর 1 — সমান, ঠিক আছে। median = (5 + 15)/2 = 10.
- x = 1. 1 ≤ 5, তাই low-এ: low = {1, 5}, root 5. আকার 2 আর 1 — low-এ একটি বেশি, চলবে। median = low-এর root = 5. মিলিয়ে দেখুন: 1 5 15-এর মাঝেরটি 5.
- x = 3. 3 ≤ 5, low-এ: low = {1, 3, 5}. এখন low-এ 3, high-এ 1 — পার্থক্য ২, নিয়ম ভাঙল। low-এর root 5 তুলে high-এ দিলাম: low = {1, 3}, high = {5, 15}. median = (3 + 5)/2 = 4. সাজালে 1 3 5 15 — মাঝের দুটি 3, 5.
- x = 8. 8 > 3, high-এ: high = {5, 8, 15}, আকার 3 > low-এর 2. high-এর root 5 ফেরত গেল low-এ: low = {1, 3, 5}, high = {8, 15}. median 5 (1 3 5 8 15).
- x = 7. 7 > 5, high-এ: high = {7, 8, 15}. আকার 3 আর 3 — ঠিক আছে। median = (5 + 7)/2 = 6.
- x = 9. 9 > 5, high-এ: আকার 4 > 3, তাই high-এর root 7 গেল low-এ। low = {1, 3, 5, 7}, high = {8, 9, 15}. median 7 — সাজালে 1 3 5 7 8 9 15, মাঝেরটি 7. প্রতিটি সংখ্যায় সর্বোচ্চ তিনটি heap-কাজ, প্রতিটি O(log n).
#include <cstdio>
#include <functional>
#include <queue>
#include <vector>
#include <initializer_list>
using namespace std;
int main() {
priority_queue<int> low; // max-heap: the smaller half
priority_queue<int, vector<int>, greater<int>> high; // min-heap: the larger half
for (int x : {5, 15, 1, 3, 8, 7, 9}) {
if (low.empty() || x <= low.top()) low.push(x); else high.push(x);
if (low.size() > high.size() + 1) { high.push(low.top()); low.pop(); } // rebalance
else if (high.size() > low.size()) { low.push(high.top()); high.pop(); }
double med = low.size() > high.size() ? low.top() : (low.top() + high.top()) / 2.0;
printf("add %2d: low size %zu, high size %zu, median %.1f\n", x, low.size(), high.size(), med);
}
}আউটপুট:
add 5: low size 1, high size 0, median 5.0
add 15: low size 1, high size 1, median 10.0
add 1: low size 2, high size 1, median 5.0
add 3: low size 2, high size 2, median 4.0
add 8: low size 3, high size 2, median 5.0
add 7: low size 3, high size 3, median 6.0
add 9: low size 4, high size 3, median 7.0
প্রতিটি লাইন ওপরের ডায়াগ্রামের এক-একটি ধাপ। low-এর আকার সবসময় high-এর সমান বা এক বেশি — তাই বিজোড় সংখ্যায় median শুধু low.top(). ভারসাম্যের শর্ত দুটি if/else if, কারণ একটি নতুন সংখ্যা একসাথে দুই দিকেই ভারসাম্য ভাঙতে পারে না। প্রতিটি সংখ্যায় সর্বোচ্চ তিনটি push/pop, তাই প্রতি ধাপ O(log n), median পড়া O(1), মোট জায়গা O(n). (low.top() + high.top()) / 2.0-তে 2.0 লেখায় ভাগটি double-এ হয়; খুব বড় int-এ যোগফল overflow এড়াতে আগে long long-এ নিন।
import heapq
low, high = [], [] # low: max-heap (stored negated), high: min-heap
for x in [5, 15, 1, 3, 8, 7, 9]:
if not low or x <= -low[0]:
heapq.heappush(low, -x)
else:
heapq.heappush(high, x)
if len(low) > len(high) + 1: # keep len(low) == len(high) or len(high) + 1
heapq.heappush(high, -heapq.heappop(low))
elif len(high) > len(low):
heapq.heappush(low, -heapq.heappop(high))
if len(low) > len(high):
med = float(-low[0])
else:
med = (-low[0] + high[0]) / 2
print(f"add {x:2d}: low size {len(low)}, high size {len(high)}, median {med:.1f}")এই সংস্করণটি এখানে চালানোর বোতাম ছাড়া দেওয়া হয়েছে; নিজের কম্পিউটারে বা যেকোনো অনলাইন Python-এ চালালে আউটপুট C++-এর সাথে হুবহু এক হয়। heapq শুধু min-heap দেয়, তাই low-এ মানগুলো ঋণাত্মক করে রাখা হয়েছে: -low[0] হলো ছোট অর্ধেকের সবচেয়ে বড় মান। ঋণাত্মক রাখার কথা ভুলে একবার low[0] লিখলেই উত্তর ভুল — এ ধরনের ভুল ছোট পরীক্ষায় অনেক সময় ধরা পড়ে না, তাই সাজিয়ে বের করা median-এর সাথে মিলিয়ে দেখুন।
Task scheduling আর simulation
Operating system বা server-এ অনেক কাজ অপেক্ষায় থাকে, প্রতিটির একটি অগ্রাধিকার বা “কখন চালাতে হবে” সময়। পরের কাজটি বাছাই মানেই priority queue. Timer-এর জন্য min-heap প্রচলিত: key = কখন বাজবে; root-এর timer-টিই সবার আগে। যেমন Go ভাষার runtime বহু বছর ধরে timer রাখতে একটি 4-ary heap ব্যবহার করেছে। একই কাঠামো discrete event simulation-এ — ব্যাংকের কাউন্টারে গ্রাহক আসা আর সেবা শেষ হওয়ার ঘটনাগুলো সময় ধরে heap-এ রাখা হয়, আর সিমুলেশন সবসময় সবচেয়ে কাছের ঘটনাটি নেয়। Huffman coding (পর্ব ৫১), Prim-এর MST (পর্ব ৪৫) আর A* search-ও প্রতিটি ধাপে “সবচেয়ে ভালো পরের প্রার্থী” চায় — সবখানেই heap.
আরও গভীরে: d-ary heap-এ প্রতিটি node-এর d টি সন্তান (index d·i + 1 … d·i + d, parent (i − 1)/d): উচ্চতা logd n-এ নামে, তাই insert আর decrease-key O(logd n), কিন্তু extract-এ প্রতি স্তরে d টি সন্তান দেখতে হয় — O(d logd n); বাস্তবে 4-ary heap প্রায়ই binary-র চেয়ে দ্রুত, কারণ চারটি সন্তান একই cache line-এ পড়ে। দুটি heap জোড়া (meld) লাগলে আছে binomial heap (Jean Vuillemin, ১৯৭৮) — meld O(log n). আর Fibonacci heap (Michael Fredman ও Robert Tarjan, ১৯৮৪) insert, meld আর decrease-key দেয় amortized O(1)-এ, extract-min amortized O(log n) — এতে Dijkstra-র তাত্ত্বিক সময় নামে O(E + V log V)-এ। তবে এর ধ্রুবক বড়, তাই বাস্তবে binary heap বা pairing heap সাধারণত জেতে।
পরীক্ষা ও ইন্টারভিউতে:
- n উপাদানের heap-এর উচ্চতা কত, আর insert/extract-এর সময়? — ⌊log₂ n⌋; দুটোই O(log n), peek O(1).
- Build-heap-এর time complexity O(n log n) নয় কেন? — sift-down-এর খরচ node-এর উচ্চতা; উচ্চতা h-এ সর্বোচ্চ ⌈n/2h+1⌉ টি node, আর Σ h/2h = 2 — তাই O(n).
- Min-heap-এ সবচেয়ে বড় উপাদান কোথায় থাকতে পারে? — শুধু কোনো leaf-এ (index ⌊n/2⌋ থেকে n − 1); খুঁজতে O(n).
- n টি সংখ্যার মধ্যে সবচেয়ে বড় k টি? — আকার-k min-heap: O(n log k) সময়, O(k) জায়গা।
- Heapsort কি stable আর in-place? — in-place (O(1) বাড়তি জায়গা), কিন্তু stable নয়; সব ক্ষেত্রে O(n log n).
৯ · AI-এর যুগে heap — সূত্র ঠিক, index ভুল
“একটি min-heap লিখে দাও” — AI কয়েক সেকেন্ডে লিখবে, আর প্রথম কয়েকটি পরীক্ষায় চলবেও। Heap-এর ভুল সাধারণত লুকিয়ে থাকে এক-অক্ষরের পার্থক্যে: 1-based বইয়ের i/2 সূত্র 0-based অ্যারেতে বসানো, শুধু বাঁ সন্তানের সাথে তুলনা, বা ডান সন্তান আছে কি না যাচাই না করা। ছোট ইনপুটে উত্তর ঠিক আসে, কারণ ভুল শাখা কাজেই লাগে না। আর complexity-র প্রশ্নে AI প্রায়ই build-heap-কে আত্মবিশ্বাসের সাথে O(n log n) বলে, বা heapsort-কে stable বলে দেয়।
Heap-এর কোডে AI সাধারণত যেসব ভুল করে
| AI-এর ভুল | কীভাবে ধরবেন |
|---|---|
0-based অ্যারেতে parent = i/2, সন্তান = 2i, 2i+1 | হাতে দেখুন: index 1-এর “সন্তান” 2 আর 3 হলে index 0-এর সন্তান কে? 0-এর সন্তান 0 হয়ে যায় — অসীম লুপ বা ভুল heap |
| Sift-down-এ শুধু বাঁ সন্তানের সাথে তুলনা, বা ছোট নয় বড় সন্তান বাছাই (min-heap-এ) | প্রতিটি pop-এর পর heap property যাচাইয়ের একটি ছোট ফাংশন লিখে assert করুন |
ডান সন্তানের সীমা যাচাই (r < n) বাদ | জোড় সংখ্যক উপাদান (শেষ internal node-এর একটিই সন্তান) দিয়ে চালান |
| Build-heap-এর লুপ 0 থেকে ওপরে, বা sift-up দিয়ে | কমতে থাকা ও বাড়তে থাকা ইনপুটে swap গুনুন; Floyd-এর পদ্ধতিতে কখনো n ছাড়াবে না |
C++-এ greater মানে max-heap ভাবা, বা custom comparator উল্টো | তিনটি সংখ্যা push করে top() ছাপুন |
| Python-এ সমান priority-র tuple-এ তুলনা-অযোগ্য উপাদান | সমান priority-র দুটি dict দিয়ে চালান — TypeError এলে counter যোগ করুন |
| “Heapsort stable” বা “build-heap O(n log n)” দাবি | [2a, 2b, 1] হাতে sort করুন; উচ্চতার যোগফলের যুক্তি চান |
AI-কে heap লিখে দিতে না বলে, আপনার কোড আর trace পরীক্ষা করতে বলুন — আর প্রতিটি দাবির প্রমাণ চান:
Heap-এর কোড যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১১৯৬৪ সালে একই পত্রিকায় কয়েক মাসের ব্যবধানে Williams আর Floyd — heap-এর জন্মের গল্পটি কী, আর Floyd ঠিক কী যোগ করেছিলেন?
১৯৬০-এর দশকের শুরুতে Communications of the ACM পত্রিকায় একটি নিয়মিত বিভাগ ছিল, যেখানে অ্যালগরিদম ছাপা হতো নম্বর দিয়ে, প্রায়ই ALGOL 60 ভাষার কোডসহ — আজকের ওপেন-সোর্স লাইব্রেরির একটি প্রাথমিক রূপ। সেই সময় সর্টিং ছিল কম্পিউটারের সবচেয়ে বেশি ব্যবহৃত কাজগুলোর একটি, আর মেমরি ছিল অত্যন্ত দামি: কয়েক হাজার word-এর মেমরিতে দ্বিতীয় একটি অ্যারে রাখা প্রায়ই অসম্ভব। তাই চাহিদা ছিল এমন সর্টের, যা O(n log n) সময় নেয় আর বাড়তি মেমরি প্রায় নেয় না।
১৯৬৪ সালের জুনে J. W. J. Williams “Algorithm 232: Heapsort” প্রকাশ করেন। এর মূল আবিষ্কার শুধু সর্ট নয় — একটি ডেটা স্ট্রাকচার: complete binary tree-কে pointer ছাড়া অ্যারেতে রাখা, যেখানে index-এর হিসাবেই parent আর সন্তান পাওয়া যায়, আর parent সবসময় সন্তানের চেয়ে বড়। “Heap” নামটিও তাঁর দেওয়া। তিনি নিজেই লক্ষ করেছিলেন যে এই স্ট্রাকচার শুধু সর্টের জন্য নয়, বরং একটি priority queue হিসেবেও কাজের — উপাদান যোগ আর সর্বোচ্চটি সরানো, দুটোই log n ধাপে। Williams heap বানাতেন উপাদান একে একে ঢুকিয়ে, অর্থাৎ sift-up দিয়ে — যার খরচ O(n log n).
একই বছরের ডিসেম্বরে Robert W. Floyd “Algorithm 245: Treesort 3” প্রকাশ করেন। নামের “3” বলে দেয় যে এটি tree-ভিত্তিক সর্টের ধারাবাহিক উন্নতির একটি ধাপ ছিল — Floyd আগে tournament-ধাঁচের tree sort নিয়ে কাজ করেছিলেন, যেখানে বাড়তি জায়গা লাগত। Treesort 3-এর বড় অবদান হলো এই পর্বের bottom-up build-heap: শেষ internal node থেকে root পর্যন্ত sift-down. এতে heap বানানো নামে O(n)-এ, আর পুরো সর্ট চলে মূল অ্যারের ভেতরেই। আজ পাঠ্যবইয়ে যাকে “heapsort” বলা হয়, সেটি আসলে Williams-এর ডেটা স্ট্রাকচার আর Floyd-এর build পদ্ধতির মিলন।
গল্পটির একটি শিক্ষা আছে: মূল ধারণা আর সেরা বাস্তবায়ন প্রায়ই আলাদা মানুষের কাছ থেকে আসে, আর উন্নতিটি অনেক সময় ছোট দেখায় — শুধু লুপের দিক উল্টানো। কিন্তু সেই ছোট পরিবর্তন জটিলতার শ্রেণিই বদলে দেয়। আরও লক্ষ করুন, Williams-এর “heap” নামটি পরে প্রোগ্রামিং ভাষার “heap memory”-র সাথে গুলিয়ে গেছে, যদিও দুটির কোনো সম্পর্ক নেই।
মূল উপলব্ধি: Heap জন্মেছিল মেমরির অভাব থেকে — জায়গায় বসে সাজানোর চাহিদা থেকে। আর build-heap-এর O(n) দেখায় যে একই কাজ কোন দিক থেকে করা হচ্ছে, তা বিশ্লেষণের চূড়ান্ত ফলে বড় পার্থক্য আনতে পারে।
প্র ০২Heapsort-এর worst case O(n log n) আর বাড়তি জায়গা O(1) — তবু লাইব্রেরিগুলো একে প্রধান সর্ট হিসেবে কেন ব্যবহার করে না?
কাগজে heapsort প্রায় নিখুঁত: worst case O(n log n), কোনো খারাপ ইনপুটের ভয় নেই, আর বাড়তি অ্যারে লাগে না। Quicksort-এর worst case O(n²), merge sort-এর Θ(n) বাড়তি জায়গা। তবু C++-এর std::sort, Java বা Python-এর প্রধান সর্ট heapsort নয়। কারণটি Big-O-র বাইরে — আধুনিক হার্ডওয়্যারে।
প্রথম কারণ cache. পর্ব ১০-এ দেখেছেন, CPU মেমরি থেকে একসাথে একটি পুরো cache line (সাধারণত 64 byte) আনে, তাই পাশাপাশি ঘর পড়া খুব দ্রুত। Quicksort-এর partition আর merge sort-এর merge দুটোই অ্যারের ওপর ক্রমানুসারে হাঁটে। কিন্তু heapsort-এর sift-down a[i] থেকে a[2i+1]-এ লাফ দেয় — বড় অ্যারেতে নিচের স্তরের দিকে প্রতিটি লাফ মেমরির একদম আলাদা অংশে, প্রায় প্রতিবার cache miss. দ্বিতীয় কারণ তুলনার সংখ্যা: সাধারণ heapsort-এ প্রতি স্তরে দুটি তুলনা, আর root-এ আনা মানটি সাধারণত ছোট বলে প্রায় নিচ পর্যন্ত নামে — মোট তুলনা প্রায় 2n log₂ n, যেখানে গড় quicksort-এ প্রায় 1.39 n log₂ n. তৃতীয় কারণ, কোন সন্তান বড় তা আগে থেকে অনুমান করা যায় না, তাই CPU-র branch prediction প্রায়ই ভুল হয়।
তাই heapsort লাইব্রেরিতে থাকে নিরাপত্তা-জাল হিসেবে। David Musser ১৯৯৭ সালে introsort প্রস্তাব করেন: quicksort দিয়ে শুরু, কিন্তু recursion-এর গভীরতা প্রায় 2 log₂ n ছাড়ালে বুঝতে হবে pivot বারবার খারাপ হচ্ছে — তখন সেই অংশ heapsort দিয়ে শেষ করা। ফল: গড়ে quicksort-এর গতি, worst case-এ heapsort-এর নিশ্চয়তা। GCC-র libstdc++ সহ প্রধান C++ লাইব্রেরিগুলো এই ধারণাই ব্যবহার করে।
তবে যেখানে নিশ্চয়তা আর ছোট মেমরি গতির চেয়ে জরুরি, সেখানে heapsort সরাসরি জেতে। Linux kernel-এর সাধারণ sort() ফাংশন (lib/sort.c) heapsort — kernel-এর stack ছোট, recursion বিপজ্জনক, আর কোনো ইনপুটে হঠাৎ O(n²) সহ্য করা যায় না। Embedded আর real-time সিস্টেমেও একই যুক্তি। আর “sift-down-এ আগে leaf পর্যন্ত বড় সন্তানের পথ ধরে নামো, তারপর ওপরে উঠে সঠিক জায়গা খোঁজো” — এই ধরনের রূপান্তর (bottom-up heapsort) তুলনার সংখ্যা প্রায় অর্ধেক করে।
মূল উপলব্ধি: একই Big-O-র দুটি অ্যালগরিদমের মধ্যে বাস্তব পার্থক্য ঠিক করে মেমরির ব্যবহার-ধরন, ধ্রুবক আর হার্ডওয়্যার। সেরা লাইব্রেরি একাধিক অ্যালগরিদম জুড়ে প্রত্যেকের শক্তি কাজে লাগায়।
প্র ০৩এমন কোনো priority queue কি সম্ভব, যার insert আর extract-min দুটোই O(log n)-এর চেয়ে দ্রুত?
উত্তর নির্ভর করে উপাদানগুলো সম্পর্কে আমরা কী জানি তার ওপর। যদি একমাত্র অনুমোদিত কাজ হয় দুটি key তুলনা করা — comparison sort-এর মডেল — তাহলে উত্তর একটি সুন্দর reduction-এ: না, সম্ভব নয়।
যুক্তিটি এরকম। ধরুন কোনো priority queue-তে insert আর extract-min দুটোই (amortized) f(n) সময় নেয়, আর এটি শুধু তুলনা দিয়ে কাজ করে। তাহলে যেকোনো n টি সংখ্যা সাজানো যায়: সবগুলো insert করুন, তারপর n বার extract-min — বের হওয়া ক্রমই সাজানো ক্রম (এই পর্বের কোডের আউটপুটে যা দেখেছেন)। মোট সময় 2n·f(n). কিন্তু পর্ব ২৪-এ দেখবেন, তুলনা-ভিত্তিক যেকোনো সর্টের worst case-এ Ω(n log n) তুলনা লাগে — কারণ n! টি সম্ভাব্য ক্রমের মধ্যে সঠিকটি আলাদা করতে log₂(n!) ≈ n log₂ n হ্যাঁ/না প্রশ্ন দরকার। তাই 2n·f(n) = Ω(n log n), অর্থাৎ f(n) = Ω(log n). দুটি কাজের অন্তত একটিকে log n খরচ দিতেই হবে। Fibonacci heap এই সীমা ভাঙে না: সেখানে insert O(1) হলেও extract-min amortized O(log n).
এই যুক্তি অ্যালগরিদম বিশ্লেষণের একটি শক্তিশালী কৌশল দেখায়: নতুন সমস্যার নিম্নসীমা প্রমাণ করতে দেখান যে এটি দ্রুত সমাধান হলে একটি পরিচিত কঠিন সমস্যাও দ্রুত সমাধান হতো। পর্ব ৬৪-এ NP-completeness-এ এই কৌশলই আরও বড় আকারে আসবে।
কিন্তু key যদি ছোট পূর্ণসংখ্যা হয়, তাহলে তুলনার মডেলের বাইরে যাওয়া যায় — ঠিক যেমন counting sort তুলনা না করে O(n + k)-এ সাজায় (পর্ব ২৪). Peter van Emde Boas ১৯৭৫ সালে এমন একটি স্ট্রাকচার দেন, যা 0 থেকে U − 1 পর্যন্ত পূর্ণসংখ্যার key-তে insert আর extract-min O(log log U)-এ করে। Dijkstra-তে যখন edge-এর ওজন ছোট পূর্ণসংখ্যা, তখন bucket-ভিত্তিক “radix heap” বা Dial-এর পদ্ধতি ব্যবহার হয়। আর Mikkel Thorup ২০০৭ সালে দেখান যে পূর্ণসংখ্যার key-এর জন্য priority queue আর sorting আসলে সমান কঠিন — একটির দ্রুত সমাধান অন্যটির দ্রুত সমাধান দেয়।
মূল উপলব্ধি: Priority queue আর sorting একই মুদ্রার দুই পিঠ। তুলনার জগতে log n একটি দেয়াল, যা কোনো চতুর heap ভাঙতে পারে না — ভাঙতে হলে key-এর গঠন (যেমন পূর্ণসংখ্যা) কাজে লাগাতে হয়।
প্র ০৪Fibonacci heap তাত্ত্বিকভাবে Dijkstra-কে দ্রুত করে — তাহলে বাস্তবের রাস্তার ম্যাপ বা কনটেস্টে সবাই সাধারণ binary heap কেন ব্যবহার করে?
Dijkstra ১৯৫৯ সালে তাঁর shortest path অ্যালগরিদম প্রকাশ করেন, আর সেই মূল রূপে প্রতিবার সবচেয়ে কাছের vertex খোঁজা হতো পুরো তালিকা দেখে — মোট O(V²). Binary heap দিয়ে এটি নামে O((V + E) log V)-এ: V বার extract-min আর সর্বোচ্চ E বার decrease-key, প্রতিটি O(log V). লক্ষ করুন, ঘন (dense) গ্রাফে E, V-এর চেয়ে অনেক বেশি হতে পারে — তখন খরচের বড় অংশ decrease-key-এ।
এখানেই Michael Fredman আর Robert Tarjan-এর Fibonacci heap (১৯৮৪ সালে উপস্থাপিত, ১৯৮৭ সালে পূর্ণ প্রবন্ধ) এর লক্ষ্য। এর decrease-key amortized O(1) — key কমলে node-টিকে কেটে আলাদা tree বানিয়ে রেখে দেয়, সারানোর কাজ পিছিয়ে দেয় পরের extract-min পর্যন্ত। ফলে Dijkstra হয় O(E + V log V), আর Prim-এর MST-ও তাই। নামটি এসেছে বিশ্লেষণ থেকে: প্রতিটি tree-র আকার তার degree-এর তুলনায় অন্তত একটি Fibonacci সংখ্যা, তাই degree O(log n) থাকে। এটি amortized analysis-এর (পর্ব ৬০) একটি ক্লাসিক উদাহরণ।
কিন্তু বাস্তবে binary heap প্রায় সবসময় জেতে, কয়েকটি কারণে। (১) Fibonacci heap-এর প্রতিটি node-এ চারটি pointer, একটি degree আর একটি চিহ্ন — মেমরি বেশি, আর node-গুলো মেমরিতে ছড়ানো, cache-এর জন্য খারাপ; binary heap একটি ঘন অ্যারে। (২) ধ্রুবক বড়: “amortized O(1)” মানে গড়ে কয়েক ডজন নির্দেশ হতে পারে। (৩) রাস্তার নেটওয়ার্কের মতো গ্রাফ sparse — E মাত্র V-এর কয়েক গুণ — তাই E log V আর E + V log V-এর পার্থক্য ছোট। (৪) বাস্তব ইনপুটে decrease-key-এর সময় উপাদান প্রায়ই অল্প কয়েক স্তর ওঠে, তাই binary heap-এর decrease-key-ও বাস্তবে সস্তা। কয়েকটি পরীক্ষামূলক গবেষণায় pairing heap (১৯৮৬) বা 4-ary heap ভালো ফল দেখিয়েছে। কনটেস্টে তো অনেকে decrease-key-ই ব্যবহার করেন না — অংশ ৭-এর lazy deletion সহজ, আর যথেষ্ট দ্রুত।
বাস্তব ম্যাপ সার্ভিসগুলো আবার আরেক ধাপ এগিয়ে: বিশাল রাস্তার গ্রাফ আগে থেকে প্রক্রিয়া করে রাখে (যেমন contraction hierarchies-এর মতো কৌশল), যাতে প্রতিটি প্রশ্নে খুব অল্প vertex দেখতে হয়। সেখানে heap-এর ধরনের চেয়ে প্রাক-প্রক্রিয়াকরণের ভূমিকা অনেক বড়।
মূল উপলব্ধি: তাত্ত্বিক সেরা আর বাস্তব সেরা আলাদা হতে পারে। Fibonacci heap প্রমাণ করে কী সম্ভব; binary heap দেখায় কোনটি ব্যবহারিক. দুটিই জানা থাকলে আপনি পরীক্ষায় সঠিক সীমা লিখবেন, আর কোডে সঠিক জিনিসটি বেছে নেবেন।
প্র ০৫কোটি কোটি পোস্ট থেকে “এখন ট্রেন্ডিং” বের করতে heap যথেষ্ট কি — মেমরি সীমিত হলে কী করা হয়?
অংশ ৮-এর top-k পদ্ধতিতে দুটি ধাপ: প্রথমে hash table-এ প্রতিটি আলাদা hashtag গোনা, তারপর আকার-k min-heap. Heap-এর অংশটি সস্তা — মাত্র k টি উপাদান। কিন্তু প্রথম ধাপে প্রতিটি আলাদা hashtag-এর জন্য একটি counter লাগে। একটি বড় সামাজিক মাধ্যমে এক ঘণ্টায় কোটি কোটি পোস্ট, আর আলাদা শব্দ বা hashtag-এর সংখ্যাও বিশাল — বেশিরভাগই একবার-দুবার আসা টাইপো বা ব্যক্তিগত শব্দ। সবকিছুর জন্য counter রাখা মেমরির অপচয়, আর একটি মেশিনে হয়তো ধরবেও না।
তাই এখানে আসে streaming algorithm: ডেটা একবার দেখে, খুব কম মেমরিতে, আনুমানিক উত্তর দেওয়া। ১৯৮২ সালে Jayadev Misra আর David Gries একটি সরল পদ্ধতি দেন, যা আজ Misra–Gries নামে পরিচিত: মাত্র k − 1 টি counter রাখুন। নতুন শব্দের counter থাকলে বাড়ান; খালি জায়গা থাকলে নতুন counter খুলুন; আর জায়গা না থাকলে সবগুলো counter এক করে কমান (শূন্য হলে সরিয়ে দিন)। প্রমাণ করা যায়, মোট m টি শব্দের মধ্যে যে শব্দ m/k বারের বেশি এসেছে, সে অবশ্যই শেষ তালিকায় থাকবে। পরে “Count-Min sketch”-এর মতো আরও স্ট্রাকচার আসে, যা কয়েকটি hash function দিয়ে ছোট একটি টেবিলে আনুমানিক গণনা রাখে — আর তার সাথে একটি ছোট heap রেখে সবচেয়ে বড়গুলো আলাদা করা হয়। অর্থাৎ heap এখানেও আছে, কিন্তু গণনার কাজটি আনুমানিক।
Running median-এর ক্ষেত্রেও একই সমস্যা: দুই-heap পদ্ধতিতে সব সংখ্যা মেমরিতে রাখতে হয় — Θ(n) জায়গা। সার্ভারের response time-এর median বা 99-তম percentile প্রতি সেকেন্ডে লাখো মাপে বের করতে হলে বাস্তব সিস্টেম আনুমানিক “quantile sketch” ব্যবহার করে, যা নির্দিষ্ট পরিমাণ মেমরিতে নির্দিষ্ট ভুলের সীমার মধ্যে উত্তর দেয়। আর জানালা-ভিত্তিক প্রশ্নে (“শেষ ১০০০টির median”) পুরোনো উপাদান সরাতে হয় — সাধারণ heap-এ মাঝখান থেকে সরানো কঠিন, তাই সেখানে lazy deletion বা দুটি multiset (পর্ব ১৫) ব্যবহার হয়।
Heap তবু কেন্দ্রেই থাকে: যেকোনো “সবচেয়ে বড় k টি রাখো” অংশ প্রায় সবসময় একটি ছোট min-heap. বড় সিস্টেম শুধু তার আগের ধাপটিকে মেমরি-সাশ্রয়ী আর আনুমানিক করে।
মূল উপলব্ধি: ডেটা যখন মেমরির চেয়ে বড়, তখন প্রশ্ন বদলে যায় — “নিখুঁত উত্তর কত দ্রুত” থেকে “কত কম মেমরিতে কতটা কাছাকাছি উত্তর”. Heap সেই নকশার একটি ছোট কিন্তু অপরিহার্য অংশ।
অনুশীলন
খাতা-কলম নিন। প্রথম তিনটি হাতে করার, শেষটিতে কোড লিখতে হবে।
-
হাতে trace করুন: একটি খালি min-heap-এ (0-based অ্যারে) পরপর insert করুন 10, 4, 15, 2, 8, 1. প্রতিটি insert-এর পর অ্যারেটি লিখুন আর বলুন কয়টি swap হলো।
10 →
[10], swap 0. 4 → index 1-এ, parent 10 > 4, swap →[4, 10], swap 1. 15 → index 2, parent 4 < 15 →[4, 10, 15], swap 0. 2 → index 3, parent index 1 (10) — swap; তারপর parent index 0 (4) — swap →[2, 4, 15, 10], swap 2. 8 → index 4, parent index 1 (4) < 8 →[2, 4, 15, 10, 8], swap 0. 1 → index 5, parent index 2 (15) — swap; parent index 0 (2) — swap →[1, 4, 2, 10, 8, 15], swap 2.মোট swap ৫টি। লক্ষ করুন, শেষ অ্যারেটি সাজানো নয় (2 আছে 4-এর পরে) — কিন্তু প্রতিটি parent তার সন্তানদের চেয়ে ছোট, তাই বৈধ heap.
-
Floyd-এর build-heap হাতে চালান:
[4, 10, 3, 5, 1, 8, 9]থেকে bottom-up পদ্ধতিতে max-heap বানান। কোন index থেকে শুরু করবেন, প্রতিটি sift-down-এর পর অ্যারে কী, আর মোট কয়টি swap?n = 7, শুরু index ⌊7/2⌋ − 1 = 2. i = 2: মান 3, সন্তান 8 আর 9 — বড় 9-এর সাথে swap →
[4, 10, 9, 5, 1, 8, 3]. i = 1: মান 10, সন্তান 5, 1 — 10 সবচেয়ে বড়, swap নেই। i = 0: মান 4, সন্তান 10, 9 — 10-এর সাথে swap →[10, 4, 9, 5, 1, 8, 3]; 4 এখন index 1-এ, সন্তান 5, 1 — 5-এর সাথে swap →[10, 5, 9, 4, 1, 8, 3].মোট swap ৩টি — n = 7-এর সাথে তুলনা করুন। perfect tree-তে (n = 7, H = 2) সর্বোচ্চ সম্ভব swap n − H − 1 = 4; এখানে লেগেছে তার চেয়েও কম।
-
ভেবে বলুন: n টি আলাদা মানের একটি min-heap-এ (ক) সবচেয়ে বড় মানটি কোন কোন index-এ থাকতে পারে? (খ) দ্বিতীয় সবচেয়ে ছোট মানটি কোথায় থাকতে পারে? (গ) তাই min-heap থেকে সবচেয়ে বড়টি বের করতে কত সময় লাগে?
(ক) সবচেয়ে বড় মানের কোনো সন্তান থাকতে পারে না — থাকলে সেই সন্তান তার চেয়ে বড় হতে হতো, যা অসম্ভব। তাই সে কোনো leaf-এ: index ⌊n/2⌋ থেকে n − 1. (খ) দ্বিতীয় সবচেয়ে ছোটটির parent অবশ্যই তার চেয়ে ছোট — অর্থাৎ সবচেয়ে ছোটটি, যে root-এ। তাই সে index 1 বা 2-এ।
(গ) সবচেয়ে বড়টি প্রায় n/2 টি leaf-এর যেকোনোটিতে হতে পারে, আর heap সেগুলোর মধ্যে কোনো ক্রম রাখে না — তাই সব leaf দেখতে হবে: Θ(n). Heap শুধু এক প্রান্তের জন্য দ্রুত; দুই প্রান্তই চাইলে দুটি heap, বা min-max heap-এর মতো বিশেষ স্ট্রাকচার লাগে।
-
কোড লিখুন: k টি সাজানো (ছোট থেকে বড়) list দেওয়া আছে, মোট N টি উপাদান। একটি min-heap ব্যবহার করে এগুলো জুড়ে একটি সাজানো list বানানোর ফাংশন লিখুন, আর time ও space complexity বলুন।
{1, 4, 7},{2, 5},{0, 6, 8}দিয়ে হাতে প্রথম চারটি pop দেখান।Heap-এ রাখুন (মান, list নম্বর, list-এর ভেতরের index). সবচেয়ে ছোটটি pop করে output-এ দিন, আর সেই list-এর পরের উপাদান থাকলে push করুন।
vector<int> mergeK(const vector<vector<int>>& lists) { using T = tuple<int, int, int>; // (value, list id, index in list) priority_queue<T, vector<T>, greater<T>> pq; for (int i = 0; i < (int)lists.size(); ++i) if (!lists[i].empty()) pq.push({lists[i][0], i, 0}); vector<int> out; while (!pq.empty()) { auto [v, i, j] = pq.top(); pq.pop(); out.push_back(v); if (j + 1 < (int)lists[i].size()) pq.push({lists[i][j + 1], i, j + 1}); } return out; }হাতে: শুরুতে heap-এ 1, 2, 0. pop 0 (তৃতীয় list), push 6. pop 1, push 4. pop 2, push 5. pop 4, push 7. প্রথম চারটি: 0 1 2 4; পুরো ফল 0 1 2 4 5 6 7 8. Heap-এ কখনো k-এর বেশি উপাদান থাকে না, তাই প্রতিটি push/pop O(log k) — মোট সময় O(N log k), বাড়তি জায়গা O(k) (output বাদে)। Python-এ একই কাজ:
list(heapq.merge(*lists)).
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমঅ্যারেতে রাখা min-heap থেকে সবচেয়ে ছোট উপাদান বের করার (extract-min) ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- a[0]-এর মানটি ফেরত দেওয়ার জন্য আলাদা করে রাখা
- অ্যারের শেষ উপাদানটি a[0]-এ এনে অ্যারের আকার এক কমানো
- বর্তমান ঘরের দুই সন্তানের মধ্যে ছোটটি বাছাই করা
- বর্তমান মান সেই ছোট সন্তানের চেয়ে বড় হলে দুটির swap করে নিচে নামা
- leaf-এ পৌঁছালে বা দুই সন্তানের চেয়ে ছোট বা সমান হলে থেমে আলাদা রাখা মানটি ফেরত দেওয়া
0-based অ্যারেতে রাখা একটি binary heap-এ index 5-এর node-এর parent আর দুই সন্তানের index কত?
- parent 2, সন্তান 10 আর 11
- parent 2, সন্তান 11 আর 12
- parent 4, সন্তান 11 আর 12
- parent 3, সন্তান 6 আর 7
নিচের কোন অ্যারেটি (0-based) একটি বৈধ min-heap?
[1, 5, 3, 4, 6][2, 3, 1, 4, 5][1, 3, 2, 7, 4, 5][9, 7, 8, 3, 5]
min-heap [1, 3, 2, 7, 4, 5] থেকে একবার extract-min করা হলো (শেষ উপাদান root-এ এনে sift-down). এরপর অ্যারেটি কী?
[2, 3, 5, 7, 4][3, 5, 2, 7, 4][2, 3, 4, 5, 7][3, 2, 7, 4, 5]
n টি সংখ্যার একটি অ্যারে থেকে শেষ internal node থেকে root পর্যন্ত প্রতিটি node-এ sift-down চালিয়ে (bottom-up) heap বানালে worst case time complexity কত?
- O(n log n)
- O(log n)
- O(n²)
- O(n)
Heapsort সম্পর্কে কোন বক্তব্যটি সঠিক?
- এটি stable আর in-place
- worst case O(n log n), বাড়তি জায়গা O(1), কিন্তু stable নয়
- খারাপ ইনপুটে এর worst case O(n²)
- সাজানো অংশ রাখতে Θ(n) বাড়তি অ্যারে লাগে
স্রোতের মতো আসা n টি সংখ্যা থেকে সবচেয়ে বড় k টি (k অনেক ছোট) রাখার সবচেয়ে দক্ষ heap-কৌশল কোনটি?
- সব সংখ্যা দিয়ে একটি max-heap বানিয়ে k বার pop
- আকার-k একটি max-heap
- আকার-k একটি min-heap, যার root-এর চেয়ে বড় নতুন মান এলে root বদলানো
- প্রতিটি নতুন সংখ্যার পর k টির তালিকা আবার সাজানো
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- Priority queue = সবচেয়ে অগ্রাধিকারেরটি আগে; binary heap-এ insert আর extract O(log n), peek O(1).
- Heap = অ্যারেতে complete binary tree; 0-based-এ parent (i − 1)/2, সন্তান 2i + 1, 2i + 2. Heap সাজানো অ্যারে নয়।
- Insert: শেষে বসিয়ে sift-up. Extract: শেষেরটি root-এ এনে ছোট (min-heap) সন্তানের সাথে sift-down.
- Floyd-এর bottom-up build-heap O(n) — খরচ উচ্চতার যোগফল। Heapsort O(n log n), in-place, কিন্তু stable নয়।
std::priority_queueডিফল্ট max,heapqmin. Top-k: আকার-k min-heap, O(n log k); median: দুটি heap; decrease-key-এpos[]বা lazy deletion.
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ২৪ · Counting, radix আর bucket sort — আর তুলনা-সর্টের নিম্নসীমাপরবর্তী পর্বকেন তুলনা করে O(n log n)-এর নিচে নামা যায় না, আর না করে কীভাবে যায়।
- ডেটা স্ট্রাকচার ও অ্যালগরিদম · কোর্স হোম৭২টি পর্বপুরো কোর্সের রোডম্যাপ — নয়টি মডিউল।
- পর্ব ১৪ · Queue আর deque — সারির নিয়মআগের ভিত্তিFIFO queue — priority queue-এর সরল পূর্বসূরি।
- C++ প্রোগ্রামিং · Sequence containerভাষার দিক
vector,dequeআর container adaptor —std::priority_queue-এর ভেতরের গল্প। - C++ প্রোগ্রামিং · STL algorithmপাশের ট্র্যাক
sort,partial_sort,make_heapআর তৈরি অ্যালগরিদম। - Python প্রোগ্রামিং · অ্যালগরিদম ও কনটেস্টে Pythonপাশের ট্র্যাক
heapq,bisectআর কনটেস্টে Python-এর কৌশল।