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

হিপ ও প্রায়োরিটি কিউ — সবচেয়ে জরুরিটি সবার আগে

Heaps & Priority Queues — Binary Heap in an Array, Heapify & Heapsort

Taught in Bangla, this lesson builds a binary heap inside an array in C: parent and child index formulas, insert with sift-up, extracting the maximum with sift-down, heapify and heapsort, a struct-based priority queue, and real uses from Dijkstra to Huffman coding.

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

  • সাধারণ queue আর priority queue-এর পার্থক্য, আর সরল সমাধানগুলো কেন ধীর
  • একটি ট্রিকে pointer ছাড়া শুধু অ্যারেতে রাখার কৌশল — আর কেন সেটি সম্ভব
  • Sift-up, sift-down আর heapify — হাতে ধাপে ধাপে চালিয়ে দেখা
  • C-তে int-এর max-heap আর struct-এর জরুরি বিভাগের priority queue
  • Dijkstra, top-k, median, OS timer, Huffman — heap কোথায় কাজ করে, আর AI-এর heap কোড কীভাবে যাচাই করবেন

১ · সবকিছু সাজানোর দরকার নেই — শুধু “এখন সবচেয়ে জরুরি কে?”

রাত দুটো, ঢাকা মেডিকেল কলেজ হাসপাতালের জরুরি বিভাগ। একজন এসেছেন হাতে সামান্য কাটা নিয়ে, একজন জ্বর নিয়ে, তারপর অ্যাম্বুলেন্সে এলেন সড়ক দুর্ঘটনার একজন রোগী। ডাক্তার কাকে আগে দেখবেন? অবশ্যই শেষে আসা গুরুতর রোগীকে। এখানে “যে আগে এসেছে সে আগে” নিয়ম চলে না। চলে “যে সবচেয়ে জরুরি সে আগে” নিয়ম। হাসপাতালের ভাষায় একে বলে triage.

লক্ষ করুন, ডাক্তারের সবার একটি সাজানো তালিকা দরকার নেই। কে তৃতীয়, কে সপ্তম — এই মুহূর্তে সে প্রশ্ন অপ্রয়োজনীয়। দরকার শুধু একটি উত্তর: এখন সবচেয়ে জরুরি কে? একজনকে দেখা শেষ হলে আবার একই প্রশ্ন। মাঝে নতুন রোগী আসতেই থাকবেন। এই পর্বের পুরো বিষয়টি দাঁড়িয়ে আছে এই একটি পর্যবেক্ষণের ওপর।

একই প্রশ্ন কম্পিউটারের নানা জায়গায় দেখা যায়:

🖥️ CPU-র কাজ বাছাই

অপারেটিং সিস্টেমে অনেক প্রোগ্রাম CPU-র অপেক্ষায় থাকে (run queue). কোনটি এখন সবচেয়ে বেশি পাওনা, সেটি আগে চলবে।

“পরের কাজ কোনটি?”

🚗 রাইড-শেয়ারিং

Pathao বা Uber-এর মতো অ্যাপে যাত্রীর কাছের কয়েকজন চালক দরকার — শহরের সব চালককে দূরত্ব অনুযায়ী সাজানোর দরকার নেই।

“সবচেয়ে কাছের k জন”

🏆 পরীক্ষার মেধাতালিকা

লাখো পরীক্ষার্থীর মধ্যে প্রথম ১০০ জন বের করতে পুরো তালিকা সাজানো অপচয়।

“সেরা ১০০ জন”

⏰ ঘটনার সিমুলেশন

মেট্রো রেলের সিমুলেশনে হাজারো ঘটনা ভবিষ্যতের নানা সময়ে ঘটবে। প্রতিবার দরকার “এর পরে সবার আগে কোনটি ঘটবে?”

“সবচেয়ে কাছের সময়”

পর্ব ৩৪-এ (স্ট্যাক ও কিউ) আপনি abstract data type চিনেছেন — আগে ঠিক করা হয় কোন কাজগুলো করা যাবে, ভেতরে কীভাবে হবে তা পরের সিদ্ধান্ত। Priority queue-ও একটি ADT. এর তিনটি মূল কাজ:

কাজসাধারণ queue (FIFO)Priority queue
ঢোকানোenqueue — লাইনের শেষে দাঁড়ানোpush — মানের সাথে একটি গুরুত্ব (priority) দিয়ে রাখা
বের করাdequeue — যে আগে এসেছেpop — যার গুরুত্ব সবচেয়ে বেশি
শুধু দেখাfrontpeek / top — বের না করে সবচেয়ে জরুরিটি দেখা
বাস্তব উদাহরণব্যাংকের টোকেনের লাইনহাসপাতালের জরুরি বিভাগ

সরল সমাধানগুলো কোথায় আটকায়

Heap ছাড়াই কি কাজটি করা যায়? যায়, কিন্তু খরচ বেশি। দুটি সরল পথ ভাবুন:

  • এলোমেলো অ্যারে: নতুন রোগী এলে শুধু শেষে বসিয়ে দিন — O(1). কিন্তু সবচেয়ে জরুরিজনকে খুঁজতে প্রতিবার পুরো অ্যারে দেখতে হবে — O(n).
  • সবসময় সাজানো অ্যারে: সবচেয়ে জরুরিজন সবসময় শেষে, তাই বের করা O(1). কিন্তু নতুন রোগী ঢোকাতে তাঁর সঠিক জায়গা বের করে পেছনের সবাইকে এক ঘর সরাতে হবে — O(n).

দুটি পথেই কোনো একটি কাজ ধীর। লাখো ঘটনার সিমুলেশনে প্রতিটি কাজে লাখো ধাপ মানে মোট ধাপ লাখ × লাখ। Binary heap একটি মাঝামাঝি পথ নেয়: সে পুরো তালিকা সাজায় না, শুধু “একটু” সাজিয়ে রাখে — ঠিক ততটুকু, যাতে সবচেয়ে বড়টি সবসময় সামনে থাকে। ফলে দুটি কাজই হয় O(log n)-এ। পর্ব ৩৯-এ (Big-O) দেখবেন এই পার্থক্য কত বিশাল।

ভেতরের গঠনpushpop (সবচেয়ে বড়)peek
এলোমেলো অ্যারেO(1)O(n)O(n)
সাজানো অ্যারেO(n)O(1)O(1)
Binary heapO(log n)O(log n)O(1)

২ · Binary heap — দুটি নিয়ম মানা একটি ট্রি

পর্ব ৩৫-এ (ট্রি ও BST) আপনি binary tree দেখেছেন — প্রতিটি নোডের সর্বোচ্চ দুটি সন্তান। Binary heap-ও একটি binary tree, তবে সে দুটি নিয়ম মানে।

নিয়ম ১ · আকার

ট্রিটি একটি complete binary tree: শেষ স্তর ছাড়া প্রতিটি স্তর পুরো ভরা, আর শেষ স্তরে নোডগুলো বাঁ দিক থেকে ফাঁক ছাড়া বসে। সিনেমা হলের সিটের মতো — সামনের সারি পুরো না ভরলে পেছনের সারিতে কেউ বসেন না, আর প্রতিটি সারিতে বসা শুরু হয় বাঁ দিক থেকে।

নিয়ম ২ · ক্রম

Heap property: প্রতিটি parent তার প্রতিটি সন্তানের চেয়ে বড় বা সমান। একে বলে max-heap, যেখানে সবচেয়ে বড় মানটি সবসময় root-এ থাকে। উল্টো নিয়মে (parent ≤ সন্তান) হয় min-heap, যার root-এ সবচেয়ে ছোট মান।

ক্রমের নিয়মটি খুব দুর্বল — সেটিই এর শক্তি। দুই ভাইবোনের মধ্যে কে বড়, heap তা নিয়ে মাথা ঘামায় না। শুধু বলে, “বাবা-মা সন্তানদের চেয়ে বড়।” একটি অফিসের পদক্রমের কথা ভাবুন। প্রত্যেক বস তাঁর অধীনস্থদের চেয়ে বেশি বেতন পান। তাহলে সবচেয়ে বেশি বেতন কে পান? নিশ্চিতভাবে শীর্ষের এমডি। কিন্তু দুই বিভাগের দুই কর্মীর মধ্যে কার বেতন বেশি, পদক্রম দেখে বলা যায় না। এই “আধা-সাজানো” অবস্থা ধরে রাখা অনেক সস্তা, অথচ আমাদের একমাত্র প্রশ্নের উত্তর দিতে যথেষ্ট।

প্রশ্নBST (পর্ব ৩৫)Max-heap
ক্রমের নিয়মবাঁয়ে ছোট, ডানে বড়parent ≥ সন্তান; ভাইবোনের মধ্যে নিয়ম নেই
যেকোনো মান খোঁজাbalanced হলে O(log n)O(n) — heap খোঁজার জন্য নয়
সবচেয়ে বড় মানডানে ডানে নেমে O(log n)root-এই, O(1)
আকারঢোকানোর ক্রমে লাঠির মতো হয়ে যেতে পারেসবসময় complete — height ⌊log₂ n⌋
মেমরিতেনোড + দুটি pointerএকটি সাধারণ অ্যারে

শেষ দুটি সারি খেয়াল করুন। BST-এর বড় দুশ্চিন্তা ছিল balance — সাজানো ক্রমে মান এলে ট্রি লাঠি হয়ে যায়। Heap-এ সেই ভয় নেই, কারণ আকারের নিয়মই তাকে সবসময় সবচেয়ে চ্যাপ্টা রাখে। ১০ লাখ মানের heap-এর height মাত্র ১৯ — অর্থাৎ ২০টি স্তর।

নামের মিলে বিভ্রান্ত হবেন না। পর্ব ২০-এ (ডায়নামিক মেমরি) পড়া heap হলো মেমরির একটি এলাকা, যেখান থেকে malloc জায়গা দেয়। আজকের heap হলো ডেটা সাজানোর একটি নিয়ম। দুটির মধ্যে কোনো সম্পর্ক নেই — শুধু নামটি এক। আপনার বানানো heap ডেটা স্ট্রাকচারটি stack-এ, global এলাকায় বা মেমরির heap-এ — যেকোনো জায়গায় থাকতে পারে।

৩ · অ্যারেতেই ট্রি — pointer ছাড়া

পর্ব ৩৫-এ ট্রির প্রতিটি নোড malloc দিয়ে আলাদা আলাদা তৈরি হয়েছিল, আর নোডগুলো জোড়া ছিল left, right pointer দিয়ে। Heap-এর ক্ষেত্রে এসব কিছুই লাগে না। কারণটি আকারের নিয়মে লুকানো।

Complete tree-এর নোডগুলোকে ওপর থেকে নিচে, প্রতিটি স্তরে বাঁ থেকে ডানে নম্বর দিন — 0, 1, 2, 3… যেহেতু মাঝে কোনো ফাঁক নেই, এই নম্বরগুলো হুবহু একটি অ্যারের ঘরের index হয়ে যায়। পর্ব ১৬-এর (অ্যারে) পাশাপাশি সাজানো ঘরগুলোতেই পুরো ট্রি এঁটে যায়। কে কার সন্তান, তা আর pointer-এ লিখে রাখতে হয় না — একটি সহজ গুণ-ভাগেই বের হয়ে আসে:

0-ভিত্তিক অ্যারেতে নোড i-এর জন্য:
বাঁ সন্তান = 2i + 1 · ডান সন্তান = 2i + 2 · parent = (i − 1) / 2

এখানে / হলো C-এর পূর্ণসংখ্যা ভাগ — ভাগফলের দশমিক অংশ বাদ যায়, তাই (4 − 1) / 2 = 1 আর (3 − 1) / 2 = 1, দুই ভাইয়েরই parent এক। এমন ট্রিকে বলে implicit tree — ট্রির গঠন ডেটায় লেখা নেই, সূত্রে লেখা। নিচের ডায়াগ্রামে একটি ৭ নোডের max-heap দেখুন, ওপরে ট্রি আর নিচে মেমরির আসল অ্যারে:

90i = 0
70i = 1
60i = 2
40i = 3
50i = 4
20i = 5
10i = 6
মেমরিতে আসলে যা আছে — int h[7]
90h[0]
70h[1]
60h[2]
40h[3]
50h[4]
20h[5]
10h[6]
হিসাব
বেছে নেওয়া i
সূত্র
ফল
  1. একই রঙ, একই স্তর। ট্রির স্তরগুলো অ্যারেতে পরপর বসেছে: স্তর ০-এ ১টি ঘর, স্তর ১-এ ২টি, স্তর ২-এ ৪টি। প্রতিটি স্তর আগেরটির দ্বিগুণ — এজন্যই সূত্রে ২ দিয়ে গুণ। অ্যারেতে কোথাও pointer নেই, শুধু ৭টি int.
  2. i = 1 বেছে নিলাম। অ্যারের h[1]-এ আছে ৭০, আর ট্রিতে সেটি root-এর বাঁ সন্তান। এবার শুধু সূত্র দিয়ে এর আত্মীয়দের খুঁজব — একটি pointer-ও না দেখে।
  3. সন্তান: 2i+1 = 3, 2i+2 = 4. h[3] = ৪০ আর h[4] = ৫০ — ট্রিতে ঠিক ৭০-এর নিচের দুটি নোড। দুটিই ৭০-এর চেয়ে ছোট, তাই এখানে heap property ঠিক আছে।
  4. Parent: (i−1)/2 = 0. (1 − 1) / 2 = 0, অর্থাৎ root ৯০. ৯০ ≥ ৭০, নিয়ম ঠিক। একটি গুণ বা ভাগ — মাত্র এক-দুটি CPU নির্দেশ। Pointer ধরে মেমরির অন্য প্রান্তে লাফানোর চেয়ে এটি অনেক সস্তা।
  5. i = 5: leaf চেনার উপায়। Parent (5 − 1) / 2 = 2, অর্থাৎ ৬০. সন্তান হওয়ার কথা ১১ আর ১২ নম্বর ঘর, কিন্তু অ্যারেতে আছে মাত্র ৭টি ঘর (n = 7). সূত্রের ফল n বা তার বেশি হলে সেই সন্তান নেই। তাই কোডে প্রতিবার l < n যাচাই করতেই হবে।
  6. সবচেয়ে সাধারণ ভুল। অনেক বইয়ে heap শুরু হয় index 1 থেকে, সেখানে সূত্র 2i, 2i+1 আর i/2. সেই সূত্র 0-ভিত্তিক C অ্যারেতে বসালে i = 1-এর “সন্তান” হয় h[2] = ৬০ — যা আসলে তার ভাই! কোড crash করে না, শুধু চুপচাপ ভুল ক্রম দেয়। AI-এর লেখা heap কোডে এই মিশ্রণ প্রায়ই দেখা যায় (অংশ ৯)।

কেন শুধু complete tree অ্যারেতে এভাবে আঁটে? যদি ট্রির মাঝে কোনো নোড না থাকত, তবে সূত্র মানতে গিয়ে অ্যারেতে সেই ঘর খালি রাখতে হতো। পর্ব ৩৫-এর লাঠির মতো BST-তে ২০টি নোডের জন্য প্রায় দশ লাখ ঘর লাগত! Complete tree-তে কোনো ফাঁক নেই, তাই n নোডের জন্য ঠিক n ঘর — একটিও নষ্ট নয়। আর ঘরগুলো পাশাপাশি থাকায় spatial locality-র সুবিধাও মেলে: ওপরের স্তরগুলো ছোট, প্রায়ই CPU cache-এ থেকে যায়।

৪ · Insert — শেষে বসান, তারপর ওপরে ভাসিয়ে দিন

নতুন মান ঢোকানোর সময় দুটি নিয়মই রক্ষা করতে হবে। আকারের নিয়ম রক্ষা করা সহজ: নতুন মানটি অ্যারের শেষ ঘরে বসান, অর্থাৎ h[n]-এ, তারপর n এক বাড়ান। ট্রিতে এটি শেষ স্তরের প্রথম খালি জায়গা — ট্রি complete-ই থাকল।

কিন্তু ক্রমের নিয়ম ভেঙে যেতে পারে — নতুন মানটি তার parent-এর চেয়ে বড় হতে পারে। তখন দুজনের জায়গা বদল (swap) করুন। মানটি এক স্তর ওপরে উঠল, এবার নতুন parent-এর সাথে তুলনা। যতক্ষণ parent ছোট, ততক্ষণ ওপরে ওঠা — পানিতে বুদবুদ যেমন ভেসে ওঠে। একে বলে sift-up (bubble up). নিচে একটি খালি max-heap-এ ৪০, ৭০, ২০, ৯০, ৫০ — এই ক্রমে পাঁচটি মান ঢোকানো দেখুন:

h[0]
h[1]
h[2]
h[3]
h[4]
মেমরিতে অ্যারে — int h[]
h[0]
h[1]
h[2]
h[3]
h[4]
এই ধাপের হিসাব
নতুন মান
ঘটনা
size (n)
মোট swap
  1. ৪০ — খালি heap-এ প্রথম মান। শেষ ঘর মানে h[0]. এর কোনো parent নেই, তাই তুলনারও কিছু নেই। একটি নোডের ট্রি নিজেই একটি heap.
  2. ৭০ — শেষে বসল। আকারের নিয়ম মেনে ৭০ গেল h[1]-এ, অর্থাৎ root-এর বাঁ সন্তান হয়ে। কিন্তু parent ৪০ তার চেয়ে ছোট — ক্রমের নিয়ম ভাঙল।
  3. Swap — ৭০ ভেসে উঠল। ৭০ আর ৪০ জায়গা বদল করল। ৭০ এখন root-এ, যার আর কোনো parent নেই। থামুন। দুটি নিয়মই আবার ঠিক।
  4. ২০ — কোনো swap লাগল না। ২০ বসল h[2]-এ। Parent (2 − 1) / 2 = 0, অর্থাৎ ৭০ — যে বড়। প্রথম তুলনাতেই থামা। Insert-এর খরচ বড়জোর O(log n), প্রায়ই আরও কম।
  5. ৯০ — শেষ স্তরে নতুন সারি শুরু। ৯০ বসল h[3]-এ, ৪০-এর বাঁ সন্তান হয়ে। Parent (3 − 1) / 2 = 1, সেখানে ৪০ < ৯০ — নিয়ম ভাঙল।
  6. প্রথম swap — এক স্তর ওপরে। ৯০ উঠল h[1]-এ, ৪০ নামল h[3]-এ। নতুন parent h[0] = ৭০, সেটিও ৯০-এর চেয়ে ছোট। আবার ওঠার পালা।
  7. দ্বিতীয় swap — root-এ পৌঁছানো। ৯০ এখন root, ৭০ নেমে এল। একটি মান ঢোকাতে দুটি swap লাগল — ঠিক ট্রির height-এর সমান। এর বেশি কখনো লাগবে না, কারণ ওঠার পথ শেষ স্তর থেকে root পর্যন্তই।
  8. ৫০ — পাঁচটি মানের heap তৈরি। ৫০ বসল h[4]-এ। Parent (4 − 1) / 2 = 1, সেখানে ৭০ — বড়, তাই থামা। চূড়ান্ত অ্যারে: 90 70 20 40 50. দেখুন, অ্যারেটি সাজানো নয় (২০-এর পরে ৪০) — কিন্তু সবচেয়ে বড়টি ঠিকই সামনে।

কেন sift-up কাজ করে? Swap-এর সময় যে parent নিচে নামল, সে আগে থেকেই তার পুরোনো সন্তানদের চেয়ে বড় ছিল (heap property). আর এখন তার জায়গায় যে উঠল, সে আরও বড়। তাই নিচের অংশে কোনো নিয়ম ভাঙে না — সমস্যাটা শুধু ওপরের দিকে সরে যায়, যতক্ষণ না মিটে যায়। প্রতিটি ধাপে একটি তুলনা আর বড়জোর একটি swap, আর ধাপের সংখ্যা height-এর বেশি নয়: মোট খরচ O(log n). দশ লাখ মানের heap-এ বড়জোর ১৯টি swap.

৫ · সবচেয়ে বড়টি বের করা — sift-down

সবচেয়ে বড় মানটি সবসময় h[0]-এ, তাই peek করা O(1) — শুধু একটি ঘর পড়া। কিন্তু সেটি বের করে নিলে (extract-max) root খালি হয়ে যায়। ট্রির মাথা কেটে ফেললে কী হবে?

কৌশলটি চমৎকার: অ্যারের শেষ মানটি তুলে root-এ বসান, আর n এক কমান। এতে আকারের নিয়ম রক্ষা পেল — শেষ স্তরের ডানদিকের নোডটিই শুধু সরল। কিন্তু সেই ছোট মানটি এখন root-এ বসে ক্রমের নিয়ম ভাঙছে। তাই তাকে নিচে নামাতে হবে: দুই সন্তানের মধ্যে বড়টির সাথে তুলনা করুন। সন্তান বড় হলে swap. যতক্ষণ কোনো সন্তান বড়, ততক্ষণ নামা। একে বলে sift-down. আগের heap 90 70 20 40 50 থেকে পরপর দুবার সবচেয়ে বড়টি বের করি:

h[0]
h[1]
h[2]
h[3]
h[4]
মেমরিতে অ্যারে — শুধু প্রথম n ঘর heap-এর অংশ
h[0]
h[1]
h[2]
h[3]
h[4]
এই ধাপের হিসাব
ঘটনা
size (n)
বের হয়েছে
  1. শুরুর heap. root-এ ৯০ — peek করলে এটিই পাবেন, কোনো খোঁজাখুঁজি ছাড়া। অ্যারের শেষ মান h[4] = ৫০, এই মানটিই একটু পরে root-এ যাবে।
  2. ৯০ বের হলো, ৫০ উঠে এল। ৯০ আলাদা করে রেখে (ফেরত দেওয়ার জন্য) শেষ ঘরের ৫০ বসানো হলো h[0]-এ, n হলো ৪. ট্রি এখনো complete, কিন্তু root-এ ৫০ বসেছে তার সন্তান ৭০-এর ওপরে — ক্রম ভাঙা।
  3. বড় সন্তানের সাথে swap. দুই সন্তান ৭০ আর ২০. কেন বড়টি? ৫০ যদি ২০-এর সাথে swap করত, তাহলে ২০ উঠত ৭০-এর মাথার ওপর — নতুন করে নিয়ম ভাঙত। বড় সন্তানটি ওপরে উঠলে সে স্বাভাবিকভাবেই তার ভাইয়ের চেয়ে বড়। ৭০ উঠল, ৫০ নামল h[1]-এ।
  4. থামার শর্ত। h[1]-এর সন্তান h[3] = ৪০ আর h[4]. কিন্তু n এখন ৪, তাই h[4] আর heap-এর অংশ নয় — সেখানে পুরোনো মান থাকলেও তা দেখা চলবে না। ৪০ < ৫০, তাই থামা। Heap: 70 50 20 40.
  5. দ্বিতীয় pop: ৭০ বের হলো। শেষ মান ৪০ গেল root-এ, n হলো ৩. এখন অ্যারের h[3] ঘরটিও heap-এর বাইরে।
  6. আবার নামা। সন্তান ৫০ আর ২০; বড়টি ৫০ > ৪০, তাই swap. ৪০ এখন h[1]-এ, তার সন্তান হওয়ার কথা ৩ আর ৪ নম্বর ঘর — দুটিই n = ৩-এর বাইরে। Leaf-এ পৌঁছে থামা।
  7. ফল: ৯০, তারপর ৭০. দুবার বের করায় বড় থেকে ছোট ক্রমে মান এল। বাকি heap 50 40 20 আবার দুটি নিয়মই মানছে। প্রতিটি pop-এ বড়জোর height-সংখ্যক swap — O(log n). এভাবে সব বের করতে থাকলে সাজানো ক্রম পাওয়া যায়; এটাই heapsort-এর বীজ (অংশ ৬)।

এবার পুরো ধারণাটি C-তে দেখুন। একটি global অ্যারে h আর তার size n — এই দুটিই পুরো heap. push হলো শেষে বসানো + sift-up, আর pop হলো root নেওয়া + শেষটি ওপরে আনা + sift-down:

#include <stdio.h>
int h[16], n = 0;                          /* heap = array + size */
void swap(int *a, int *b) { int t = *a; *a = *b; *b = t; }
void push(int x) {                         /* append, then sift-up */
    int i = n++;  h[i] = x;
    for (; i > 0 && h[(i - 1) / 2] < h[i]; i = (i - 1) / 2) swap(&h[i], &h[(i - 1) / 2]);
}
int pop(void) {                            /* root out, last to top, sift-down */
    int top = h[0], i = 0, c;  h[0] = h[--n];
    while ((c = 2 * i + 1) < n) {             /* c = left child */
        if (c + 1 < n && h[c + 1] > h[c]) c++;  /* pick the larger child */
        if (h[c] <= h[i]) break;
        swap(&h[i], &h[c]);  i = c;
    }
    return top;
}
int main(void) {
    push(40); push(70); push(20); push(90); push(50);
    printf("array:");  for (int k = 0; k < n; k++) printf(" %d", h[k]);
    printf("\npeek : %d\n", h[0]);
    while (n > 0) printf("pop  : %d\n", pop());
}

আউটপুটের প্রথম লাইন array: 90 70 20 40 50 — ডায়াগ্রামের অ্যারের সাথে হুবহু মিলে গেল। তারপর peek : 90, আর pop-গুলো আসে 90, 70, 50, 40, 20 ক্রমে — বড় থেকে ছোট। কয়েকটি জিনিস লক্ষ করুন: (১) h[0] = h[--n] একই লাইনে size কমায় আর শেষ মানটি ওপরে আনে; (২) c প্রথমে বাঁ সন্তান, ডান সন্তান থাকলে আর বড় হলে c++ — এভাবেই “বড় সন্তান” বাছা হয়; (৩) c < n আর c + 1 < n যাচাই ছাড়া অ্যারের বাইরের বা heap থেকে বাদ পড়া পুরোনো মান পড়া হতো। বাস্তব কোডে push-এ n < 16 আর pop-এ n > 0 যাচাইও রাখতে হবে; ছোট রাখার জন্য এখানে বাদ দেওয়া হয়েছে। (main-এর শেষে return 0; নেই; C99 থেকে main শেষ পর্যন্ত পৌঁছালে নিজে থেকেই 0 ফেরত দেয়।)

৬ · Heapify আর heapsort — পুরো অ্যারেকে একবারে heap

ধরুন আপনার হাতে আগে থেকেই ১০ লাখ মানের একটি অ্যারে আছে। একটি একটি করে push করলে খরচ O(n log n). কিন্তু আরও চতুর একটি পথ আছে, যার নাম bottom-up heapify: অ্যারেটিকে যেমন আছে তেমনই একটি complete tree ধরুন। প্রতিটি leaf একা একাই একটি heap — তার তো কোনো সন্তান নেই। তাই শেষ যে নোডের সন্তান আছে (index n/2 − 1), সেখান থেকে শুরু করে উল্টো দিকে root পর্যন্ত প্রতিটি নোডে sift-down চালান। নিচ থেকে ওপরে, ছোট ছোট heap জুড়ে বড় heap. [3, 9, 2, 1, 4, 5] দিয়ে দেখি:

h[0]
h[1]
h[2]
h[3]
h[4]
h[5]
h[0]
h[1]
h[2]
h[3]
h[4]
h[5]
এই ধাপের হিসাব
কোন নোডে
ঘটনা
মোট swap
  1. অ্যারেটিকে ট্রি হিসেবে দেখুন। কিছু না সরিয়েই [3, 9, 2, 1, 4, 5] একটি complete tree. শেষ তিনটি ঘর (১, ৪, ৫) leaf — এরা ইতিমধ্যেই এক নোডের heap. কাজ শুরু হবে শেষ parent i = 6/2 − 1 = 2 থেকে।
  2. i = 2: ছোট একটি heap তৈরি। ২-এর একমাত্র সন্তান ৫, যে বড়। Swap — এখন h[2]-কে মাথা ধরে দুই নোডের ছোট ট্রিটি একটি ঠিকঠাক heap.
  3. i = 1: কিছুই করতে হলো না। ৯-এর সন্তান ১ আর ৪, দুজনই ছোট। এই subtree আগে থেকেই heap ছিল। একটি তুলনা, কোনো swap নয়।
  4. i = 0: root-এর পালা। ৩-এর সন্তান ৯ আর ৫; বড়টি ৯. Swap — ৯ উঠল root-এ। কিন্তু ৩ নেমে গেছে h[1]-এ, যেখানে নিচে ৪ আছে। Sift-down এখনো শেষ হয়নি।
  5. ৩ আরও এক ধাপ নামল। h[1]-এ ৩, সন্তান ১ আর ৪; বড়টি ৪ > ৩, swap. ৩ এখন leaf, থামা। লক্ষ করুন — sift-down একটি নোডকে প্রয়োজনে একেবারে নিচ পর্যন্ত নামিয়ে দেয়।
  6. Heap তৈরি — মোট ৩টি swap. 9 4 5 1 3 2: ৯ ≥ ৪, ৫; ৪ ≥ ১, ৩; ৫ ≥ ২. সবচেয়ে বেশি কাজ হয়েছে ওপরের অল্প কয়েকটি নোডে, আর নিচের অসংখ্য leaf-এ কোনো কাজই হয়নি। এই অসমতাই পুরো পদ্ধতিকে দ্রুত করে।
ফলাফলটি মনে রাখুন

Bottom-up heapify-এর মোট খরচ O(n) — একটি একটি করে push করার O(n log n) নয়। কারণটি সহজে ভাবা যায়: নোডের প্রায় অর্ধেক leaf, তাদের কোনো কাজ নেই। প্রায় এক-চতুর্থাংশ নোড বড়জোর এক ধাপ নামে, এক-অষ্টমাংশ বড়জোর দুই ধাপ… শুধু root-কে হয়তো পুরো height নামতে হয়। যোগফলটি একটি অভিসারী ধারা, যা n-এর কয়েক গুণের বেশি হয় না। প্রমাণ পাবেন অ্যালগরিদমের বইয়ে; এখানে শুধু ধারণাটুকু রাখুন — “বেশিরভাগ নোড নিচে, আর নিচের নোডের কাজ কম।”

Heapsort — heap থেকে সাজানো অ্যারে

Heap থেকে বারবার সবচেয়ে বড়টি বের করলে বড় থেকে ছোট ক্রম আসে — কোডে দেখেছেন। Heapsort এই চিন্তাকে একটি চমৎকার কৌশলে in-place করে: প্রতিবার pop করলে heap এক ঘর ছোট হয়, আর অ্যারের শেষে একটি ঘর খালি হয়। বের করা সবচেয়ে বড় মানটি ঠিক সেই খালি ঘরে রাখুন! প্রথম pop-এর সবচেয়ে বড় মান যায় শেষ ঘরে, দ্বিতীয়টি তার আগের ঘরে… শেষে একই অ্যারে ছোট থেকে বড় সাজানো। কোনো বাড়তি অ্যারে লাগে না।

বৈশিষ্ট্যHeapsortমন্তব্য
সময়O(n log n) — সবসময়, খারাপতম ক্ষেত্রেওQuicksort-এর মতো O(n²)-এ পড়ার ভয় নেই
বাড়তি মেমরিO(1)Merge sort-এর মতো দ্বিতীয় অ্যারে লাগে না
Stable?নালম্বা লাফের swap সমান মানের আগের ক্রম নষ্ট করে (stable sort দেখুন)
বাস্তবের গতিপ্রায়ই quicksort-এর চেয়ে ধীরi থেকে 2i+1-এ লাফ, মেমরিতে ছড়ানো পড়া — ভাবনার প্রশ্ন ০৩ দেখুন

পর্ব ৪০-এ (সর্টিং) সব sorting পদ্ধতি পাশাপাশি তুলনা করবেন। সেখানে দেখবেন, heapsort প্রায়ই প্রধান খেলোয়াড় নয়, কিন্তু একজন নির্ভরযোগ্য বদলি খেলোয়াড় — introsort quicksort দিয়ে শুরু করে, আর বিপদ দেখলে heapsort-এ চলে যায়।

৭ · Struct-এর priority queue — জরুরি বিভাগের কিউ

বাস্তবে আমরা শুধু সংখ্যা রাখি না। জরুরি বিভাগে প্রতিটি রোগীর নাম আছে, অবস্থার গুরুত্ব (severity) আছে, আর কখন এসেছেন সেই তথ্য আছে। পর্ব ২২-এর struct দিয়ে একজন রোগী বানাই, আর heap-এর তুলনাটি একটি আলাদা ফাংশনে সরিয়ে দিই — before(a, b) সত্য মানে “a-কে b-এর আগে সেবা দিতে হবে”।

একটি প্রশ্ন আগেই ঠিক করতে হবে: দুজনের severity সমান হলে কে আগে? Heap নিজে থেকে এর কোনো নিশ্চয়তা দেয় না — swap-গুলো এমনভাবে হয় যে সমান গুরুত্বের রোগীরা যেকোনো ক্রমে বের হতে পারেন। হাসপাতালে সেটি অন্যায় দেখায়। সমাধানের নাম tie-breaking: প্রত্যেক রোগীকে আসার সময় একটি ক্রমিক টিকিট নম্বর দিন, আর severity সমান হলে ছোট টিকিট আগে।

#include <stdio.h>
typedef struct { const char *name; int sev, arrival; } Patient;
Patient q[16]; int n = 0, ticket = 0;
int before(Patient a, Patient b) { return a.sev != b.sev ? a.sev > b.sev : a.arrival < b.arrival; }
void swap(Patient *a, Patient *b) { Patient t = *a; *a = *b; *b = t; }
void arrive(const char *name, int sev) {
    int i = n++;  q[i] = (Patient){name, sev, ++ticket};
    for (; i > 0 && before(q[i], q[(i - 1) / 2]); i = (i - 1) / 2) swap(&q[i], &q[(i - 1) / 2]);
}
Patient serve(void) {
    Patient top = q[0];  int i = 0, c;  q[0] = q[--n];
    while ((c = 2 * i + 1) < n) {
        if (c + 1 < n && before(q[c + 1], q[c])) c++;
        if (!before(q[c], q[i])) break;
        swap(&q[i], &q[c]);  i = c;
    }
    return top;
}
int main(void) {
    arrive("Rahim", 2); arrive("Karim", 5); arrive("Salma", 3); arrive("Nila", 5); arrive("Joy", 1);
    while (n > 0) { Patient p = serve(); printf("%-5s severity %d  ticket #%d\n", p.name, p.sev, p.arrival); }
}

পাঁচজন এলেন রহিম (২), করিম (৫), সালমা (৩), নীলা (৫), জয় (১) — এই ক্রমে। সেবা পেলেন Karim, Nila, Salma, Rahim, Joy ক্রমে। করিম আর নীলা দুজনেরই severity ৫, কিন্তু করিমের টিকিট #২, নীলার #৪ — তাই করিম আগে। লক্ষ করুন, heap-এর কোড প্রায় হুবহু আগের মতো; বদলেছে শুধু তুলনা >-এর জায়গায় before(). before-এর ভেতরের চিহ্ন উল্টে দিলেই (a.sev < b.sev) এটি min-heap হয়ে যাবে। সংখ্যা যত ছোট তত জরুরি — এমন নিয়মের হাসপাতালে সেটিই লাগত।

বাস্তব কোডে আরও তিনটি বিষয়। (১) আকার: এখানে ১৬ ঘরের স্থির অ্যারে। রোগী কত জন আসবেন জানা না থাকলে পর্ব ২০-এর realloc দিয়ে ঘর ফুরালে অ্যারে দ্বিগুণ করুন — dynamic array-এর মতো, গড়ে push এখনো O(log n). শেষে অবশ্যই free. (২) সাধারণ heap: পর্ব ২৮-এর function pointer দিয়ে before-কে প্যারামিটার বানালে একই heap কোড যেকোনো টাইপের জন্য চলে — qsort-এর comparator-এর মতো। (৩) Struct বড় হলে প্রতিটি swap-এ পুরো struct কপি হয়; তখন অ্যারেতে শুধু pointer বা index রাখা ভালো।

C-এর standard library-তে কোনো তৈরি priority queue নেই — তাই C প্রোগ্রামাররা heap নিজেরাই লেখেন, আর এজন্যই এটি ইন্টারভিউতে এত জনপ্রিয়। অন্য ভাষায় এটি তৈরি পাওয়া যায়: C++-এর std::priority_queue ডিফল্টভাবে max-heap, Python-এর heapq আর Java-র PriorityQueue min-heap. ভেতরে সবগুলোই এই পর্বের মতো অ্যারে-ভিত্তিক binary heap.

৮ · বাস্তবে heap — Dijkstra থেকে Huffman পর্যন্ত

Heap এমন একটি ডেটা স্ট্রাকচার, যাকে সরাসরি খুব কম দেখা যায়, কিন্তু অসংখ্য বিখ্যাত অ্যালগরিদমের ভেতরে সে ইঞ্জিনের মতো কাজ করে। কয়েকটি দেখি:

🗺️ Dijkstra — সবচেয়ে ছোট পথ

Google Maps-এর মতো রুট খোঁজায় প্রতিবার প্রশ্ন: “এখনো চূড়ান্ত হয়নি এমন জায়গাগুলোর মধ্যে কোনটি সবচেয়ে কাছে?” Min-heap এই প্রশ্নের উত্তর দেয়। Dijkstra পর্ব ৩৮-এর (গ্রাফ) BFS-এরই ওজনওয়ালা রূপ, আর পর্ব ৪৩-এ একে greedy অ্যালগরিদম হিসেবে দেখবেন।

min-heap · দূরত্ব

🏆 Top-k — সেরা k জন

Top-k বের করতে k ঘরের একটি min-heap রাখুন। নতুন মান heap-এর সবচেয়ে ছোটটির চেয়ে বড় হলে ছোটটিকে বের করে নতুনটি ঢোকান। মেমরি O(k), সময় O(n log k) — লাখো মানেও মাত্র k ঘর।

min-heap · k ঘর

🔀 k টি সাজানো তালিকা জোড়া

K-way merge: প্রতিটি তালিকার সামনের মানটি min-heap-এ রাখুন, সবচেয়ে ছোটটি বের করে তার তালিকার পরেরটি ঢোকান। ডিস্কে বিশাল ফাইল সাজানো আর ডেটাবেসে এটি রোজকার কাজ।

মোট N মান · O(N log k)

📊 চলমান median

Running median-এর জন্য দুটি heap: ছোট অর্ধেক একটি max-heap-এ, বড় অর্ধেক একটি min-heap-এ। দুটির আকার সমান রাখলে median সবসময় দুই root-এর কাছে।

দুটি heap

⏰ OS-এর timer ও event queue

“৩০ সেকেন্ড পর এই কাজ করো” — এমন হাজারো অনুরোধ সময় অনুযায়ী একটি event queue-তে থাকে; সবচেয়ে আগের সময়টি সামনে। সিমুলেশন, গেম ইঞ্জিন আর নেটওয়ার্কের timeout — সব এক ধারণা।

min-heap · সময়

🗜️ Huffman coding

ZIP-এর DEFLATE-এর মতো compression-এর একটি অংশ Huffman coding: বারবার সবচেয়ে কম আসা দুটি অক্ষর min-heap থেকে তুলে জুড়ে দেওয়া। পর্ব ৪৩-এ greedy-র উদাহরণ হিসেবে আবার দেখবেন।

min-heap · frequency

Top-k-এ কেন উল্টো (min) heap? বিশ্ববিদ্যালয়ের ভর্তি পরীক্ষায় ১০০টি আসনের কথা ভাবুন। ভর্তি হওয়া ১০০ জনের মধ্যে সবচেয়ে কম নম্বর পাওয়া জন হলেন “দরজার পাহারাদার” — নতুন কেউ তাঁর চেয়ে বেশি পেলে তিনি বাদ পড়বেন, নতুনজন ঢুকবেন। তাই আমাদের সবসময় জানতে হবে ভেতরের সবচেয়ে ছোট কে — আর ঠিক এটাই min-heap-এর root.

পরীক্ষা ও ইন্টারভিউতে — বিশ্ববিদ্যালয়ের পরীক্ষা, চাকরির লিখিত পরীক্ষা বা কোডিং ইন্টারভিউর জন্য এই ধরনের প্রশ্নের প্রস্তুতি রাখুন:

  • 0-ভিত্তিক অ্যারেতে i-এর সন্তান ও parent কোথায়? 2i+1, 2i+2 আর (i-1)/2; 1-ভিত্তিক হলে 2i, 2i+1, i/2.
  • একটি অ্যারে max-heap কি না, কীভাবে যাচাই করবেন? প্রতিটি i ≥ 1-এর জন্য h[(i-1)/2] ≥ h[i] — একবার পুরো অ্যারে ঘুরলেই, O(n).
  • n টি মান থেকে heap বানানোর খরচ কত? Bottom-up heapify-তে O(n); একটি একটি push করলে O(n log n).
  • অ্যারের k-তম বড় মান বের করুন। k ঘরের min-heap — শেষে root-ই উত্তর, O(n log k).
  • Heapsort কি stable? না। তবে in-place, আর খারাপতম ক্ষেত্রেও O(n log n).

৯ · AI-এর যুগে heap কোড — সূত্রে চোখ রাখুন

AI-কে “C-তে একটি priority queue লিখে দাও” বললে কয়েক সেকেন্ডে সুন্দর কোড আসবে। বেশিরভাগ সময় সেটি ঠিকও থাকবে। সমস্যা হলো, heap-এর ভুল সাধারণত crash করে না। প্রোগ্রাম চলে, সংখ্যা ছাপে — শুধু মাঝে মাঝে ভুল ক্রমে। পাঁচটি মান দিয়ে একবার চালিয়ে দেখলে প্রায়ই ধরা পড়ে না। AI-এর heap কোডে যে ভুলগুলো বারবার দেখা যায়:

ভুলকোডে যেমন দেখায়ফল
1-ভিত্তিক সূত্র, 0-ভিত্তিক অ্যারেleft = 2*i, parent = i/2, কিন্তু heap শুরু h[0] থেকেভাইকে সন্তান ভাবা; চুপচাপ ভুল ক্রম
Sift-down-এ ভুল সন্তানশুধু বাঁ সন্তানের সাথে তুলনা, বা যে প্রথম বড় তার সাথেই swapছোট মান বড় ভাইয়ের মাথায় ওঠে
Size কমাতে ভুলে যাওয়াh[0] = h[n-1]; কিন্তু n-- নেই, বা কমানোর আগেই sift-downবের করা মান আবার ফিরে আসে
সীমা যাচাই নেইr < n বাদ, বা <=অ্যারের বাইরে বা বাদ পড়া পুরোনো মান পড়া
Max আর min গুলিয়ে ফেলাsift-up-এ >, sift-down-এ < — দুই জায়গায় দুই নিয়মঅর্ধেক কাজ max-heap, অর্ধেক min-heap
সমান priority-র ক্রম ধরে নেওয়া“আগে এলে আগে” ধরে নেওয়া, কিন্তু tie-breaker নেইসমান গুরুত্বের কাজ এলোমেলো ক্রমে

এসব ভুল ধরার সবচেয়ে শক্তিশালী উপায় শিখবেন পর্ব ৩২-এ (টেস্টিং): একটি ধীর কিন্তু নিশ্চিতভাবে সঠিক সমাধানের সাথে মিলিয়ে দেখা। Heap-এর ক্ষেত্রে সেই নিশ্চিত সমাধান হলো sorting. হাজারটি এলোমেলো মান heap-এ push করুন, তারপর সব pop করুন — ফল হুবহু qsort দিয়ে বড় থেকে ছোট সাজানো অ্যারের সাথে মিলতে হবে। প্রতিটি push ও pop-এর পর পুরো অ্যারেতে heap property যাচাই করুন। একে বলে stress testing, আর একবার লিখে রাখলে AI-এর পরের যেকোনো পরিবর্তনও কয়েক সেকেন্ডে যাচাই হয়ে যায়।

AI-কে heap কোড চাইলে শুধু “লিখে দাও” নয় — সূত্র, পরীক্ষা আর সীমার শর্ত একসাথে চান:

C-তে struct-এর জন্য একটি array-ভিত্তিক binary max-heap priority queue লিখে দাও (push, pop, peek, size). শর্ত: ১. অ্যারে 0-ভিত্তিক; parent = (i-1)/2, সন্তান = 2i+1 ও 2i+2 — কোডের মন্তব্যে সূত্রগুলো লিখবে। ২. sift-down-এ দুই সন্তানের মধ্যে বড়টি বাছবে, আর প্রতিটি সন্তানের আগে index < size যাচাই করবে। ৩. সমান priority হলে যে আগে এসেছে সে আগে বের হবে (একটি ক্রমিক নম্বর দিয়ে)। ৪. খালি heap থেকে pop আর ভরা heap-এ push — দুটোই নিরাপদে সামলাবে; realloc দিয়ে বাড়লে free-ও দেখাবে। ৫. একটি আলাদা test ফাংশন দাও: ১০০০টি এলোমেলো সংখ্যা push-pop করে ফলাফল qsort-এর সাথে মেলাবে, আর প্রতিটি কাজের পর heap property যাচাই করবে। শেষে বলো, কোন লাইনে ভুল করলে কোন টেস্ট ব্যর্থ হবে।

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

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

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

প্র ০১Binary heap কে, কবে, কেন আবিষ্কার করলেন? আর একই বছরে কীভাবে সেটি আরও ভালো হলো?

১৯৬৪ সালে J. W. J. Williams Communications of the ACM পত্রিকায় “Algorithm 232: Heapsort” প্রকাশ করেন। মজার ব্যাপার হলো, তাঁর মূল লক্ষ্য ছিল একটি sorting পদ্ধতি — আর সেই কাজ করতে গিয়েই তিনি binary heap নামের ডেটা স্ট্রাকচারটি তৈরি করেন। অর্থাৎ heap আর heapsort একসাথে জন্ম নিয়েছে, আর priority queue হিসেবে heap-এর ব্যবহার এসেছে প্রায় উপজাত হিসেবে।

সে যুগে মেমরি ছিল অত্যন্ত দামি আর কম। Merge sort-এর জন্য দ্বিতীয় একটি অ্যারে রাখা অনেক কম্পিউটারে বিলাসিতা। Williams-এর পদ্ধতি একই অ্যারের ভেতরে কাজ করত, আর ট্রিটিকে pointer ছাড়া শুধু index-এর হিসাবে রাখত — দুটোই মেমরি বাঁচানোর জন্য অসাধারণ।

একই বছরে Robert W. Floyd “Algorithm 245: Treesort 3” নামে একটি উন্নত রূপ প্রকাশ করেন। Williams heap বানাতেন একটি একটি করে মান ঢুকিয়ে। Floyd দেখালেন, পুরো অ্যারেকে নিচ থেকে ওপরে sift-down করে heap বানানো যায় — এই পর্বের অংশ ৬-এর সেই bottom-up heapify, যার খরচ O(n). আজকের প্রায় সব heap লাইব্রেরি Floyd-এর পদ্ধতিতেই heap তৈরি করে। Floyd পরে আরও বহু অবদানের জন্য (যেমন shortest path-এর Floyd–Warshall অ্যালগরিদম, প্রোগ্রামের সঠিকতা প্রমাণের পদ্ধতি) ১৯৭৮ সালে Turing Award পান।

এখান থেকে একটি গভীর শিক্ষা: একই ডেটা স্ট্রাকচার, একই নিয়ম — কিন্তু কোন দিক থেকে কাজ করছেন, তাতে খরচ বদলে যায়। ওপর থেকে sift-up করলে নিচের অসংখ্য নোডকে লম্বা পথ পাড়ি দিতে হয়; নিচ থেকে sift-down করলে লম্বা পথ শুধু ওপরের অল্প কয়েকটি নোডের।

মূল উপলব্ধি: ভালো অ্যালগরিদম প্রায়ই সীমাবদ্ধতা থেকে জন্মায় — কম মেমরি Williams-কে অ্যারেতে ট্রি রাখতে বাধ্য করেছিল। আর প্রথম সমাধানই শেষ কথা নয়; Floyd-এর মতো কেউ একই জিনিস অন্য দিক থেকে দেখলে আরও ভালো পথ বেরিয়ে আসে।

প্র ০২Dijkstra-র অ্যালগরিদমের priority queue লাগে কেন? Fibonacci heap কী প্রতিশ্রুতি দেয়, আর বাস্তবে কেন সবাই সেটি ব্যবহার করে না?

Edsger Dijkstra ১৯৫৬ সালে এই অ্যালগরিদমটি ভেবেছিলেন — পরে বলেছেন, আমস্টারডামের একটি ক্যাফেতে কাগজ-কলম ছাড়াই প্রায় বিশ মিনিটে। প্রকাশ করেন ১৯৫৯ সালে। অ্যালগরিদমের প্রতিটি ধাপে একটিই প্রশ্ন: “যেসব জায়গার দূরত্ব এখনো চূড়ান্ত হয়নি, তাদের মধ্যে সবচেয়ে কাছেরটি কোনটি?” সেটিকে চূড়ান্ত করে তার প্রতিবেশীদের দূরত্ব কমানো হয়। এটি হুবহু priority queue-এর কাজ।

Dijkstra-র মূল সংস্করণে কোনো heap ছিল না — প্রতিবার সব vertex ঘুরে সবচেয়ে ছোটটি খোঁজা হতো, মোট O(V²). ঘন গ্রাফে (যেখানে প্রায় সব জোড়ার মধ্যে রাস্তা আছে) এটি আসলে ভালোই। কিন্তু সড়ক নেটওয়ার্কের মতো পাতলা গ্রাফে binary heap ব্যবহার করলে খরচ নামে O((V + E) log V)-এ — লাখো মোড়ের শহরে বিশাল পার্থক্য।

এখানে একটি সূক্ষ্ম কাজ আছে: কোনো vertex-এর দূরত্ব কমে গেলে heap-এর ভেতরে তার priority কমাতে হয় — decrease-key. ১৯৮৪ সালে Michael Fredman ও Robert Tarjan Fibonacci heap প্রস্তাব করেন, যেখানে decrease-key গড়ে (amortized) O(1). এতে Dijkstra-র তাত্ত্বিক খরচ নামে O(E + V log V)-এ — বহু বছর ধরে এটিই পাঠ্যবইয়ের সেরা সীমা।

তাহলে সবাই Fibonacci heap ব্যবহার করে না কেন? কারণ বাস্তবে এর ধ্রুবক খরচ বিশাল। প্রতিটি নোডে কয়েকটি pointer, নোডগুলো মেমরিতে ছড়ানো, প্রতিটি কাজে অনেক cache miss. অন্যদিকে অ্যারের binary heap ছোট আর cache-বান্ধব। তাই বাস্তবের অনেক প্রয়োগ decrease-key-ই করে না: দূরত্ব কমলে নতুন একটি entry push করে দেয়, আর pop-এর সময় পুরোনো (বাসি) entry দেখলে বাদ দেয়। তাত্ত্বিকভাবে একটু খারাপ, বাস্তবে প্রায়ই দ্রুত।

মূল উপলব্ধি: Big-O বলে বড় n-এ কী ঘটবে, কিন্তু ধ্রুবক আর মেমরির আচরণ বলে আপনার আসল ডেটায় কী ঘটবে। তত্ত্ব দিক দেখায়; সিদ্ধান্ত নিতে মাপতে হয়।

প্র ০৩Heapsort-এর খারাপতম খরচও O(n log n), বাড়তি মেমরিও লাগে না — তবু সে খুব কমই ডিফল্ট sort কেন?

কাগজে-কলমে heapsort প্রায় নিখুঁত: quicksort-এর মতো O(n²)-এ পড়ার ভয় নেই, merge sort-এর মতো দ্বিতীয় অ্যারেও লাগে না। তবু C++, Java, Python-এর ডিফল্ট sort-এর কোনোটিই খাঁটি heapsort নয়। কারণ তিনটি।

  • মেমরির আচরণ: Sift-down প্রতি ধাপে i থেকে 2i+1-এ লাফায়। অ্যারে বড় হলে নিচের স্তরের লাফগুলো মেমরির দূর দূর অংশে পড়ে, আর প্রায় প্রতিটি পড়াই cache miss. Quicksort-এর partition অ্যারেকে বাঁ থেকে ডানে টানা পড়ে — CPU cache এমন পড়াই সবচেয়ে ভালোবাসে।
  • বেশি তুলনা ও অনুমান-অযোগ্য শাখা: Heapsort গড়ে quicksort-এর চেয়ে বেশি তুলনা করে, আর “কোন সন্তান বড়” প্রশ্নের উত্তর প্রায় এলোমেলো — CPU-র branch prediction এখানে বারবার ভুল করে।
  • Stable নয়: একই নম্বরের দুই ছাত্রের আগের ক্রম রাখা জরুরি হলে heapsort চলে না। Python ও Java (object-এর জন্য) তাই Timsort ব্যবহার করে, যা stable.

কিন্তু heapsort হারিয়ে যায়নি — সে নিরাপত্তা-জাল হিসেবে টিকে আছে। ১৯৯৭ সালে David Musser প্রস্তাব করেন introsort: quicksort দিয়ে শুরু, কিন্তু recursion যদি প্রায় 2 log n-এর বেশি গভীরে যায় (মানে pivot বারবার খারাপ পড়ছে), তখন সেই অংশ heapsort দিয়ে সাজানো; আর খুব ছোট টুকরোয় insertion sort. ফলে গড়ে quicksort-এর গতি, খারাপতম ক্ষেত্রেও O(n log n)-এর নিশ্চয়তা। GCC-র C++ লাইব্রেরির std::sort এভাবেই কাজ করে। পর্ব ৪০-এ এই hybrid চিন্তাটি বিস্তারিত দেখবেন।

মূল উপলব্ধি: “সবচেয়ে ভালো Big-O” আর “সবচেয়ে দ্রুত” এক জিনিস নয়। আধুনিক CPU-তে মেমরি কীভাবে পড়া হচ্ছে, সেটি প্রায়ই তুলনার সংখ্যার চেয়েও বেশি গুরুত্বপূর্ণ — আর সেরা প্রকৌশল প্রায়ই কয়েকটি পদ্ধতির বুদ্ধিমান মিশ্রণ।

প্র ০৪OS-এর scheduler তো এক রকম priority queue — তাহলে Linux কেন সেখানে heap না নিয়ে red-black tree নিল? আর হাজারো timer সামলাতে “timer wheel” কী?

২০০৭ সালে Linux kernel 2.6.23-এ Ingo Molnár-এর CFS (Completely Fair Scheduler) আসে। প্রতিটি কাজ এ পর্যন্ত কতটুকু CPU পেয়েছে (virtual runtime), তার হিসাব রাখা হয়, আর সবচেয়ে কম পাওয়া কাজটি পরের সুযোগ পায়। “সবচেয়ে কম” — শুনতেই min-heap মনে হয়। কিন্তু CFS কাজগুলো রাখে একটি red-black tree-তে, আর সবচেয়ে বাঁয়ের নোডটি আলাদা করে মনে রাখে, যাতে “সবচেয়ে কম” পেতে O(1) লাগে।

কেন heap নয়? কারণ scheduler-এর কাজ শুধু “সবচেয়ে ছোটটি বের করো” নয়। কোনো প্রোগ্রাম ডিস্কের অপেক্ষায় ঘুমিয়ে পড়লে তাকে তালিকার মাঝখান থেকে সরাতে হয়; প্রোগ্রাম বন্ধ হলেও তাই। Binary heap-এ মাঝখান থেকে মুছতে হলে আগে জানতে হবে সেটি কোন index-এ আছে, আর প্রতিটি swap-এ সেই index হালনাগাদ রাখতে হয়। Balanced tree-তে যেকোনো নোড মোছা স্বাভাবিকভাবেই O(log n), আর ক্রম অনুযায়ী হাঁটাও যায়। (২০২৩ সালে Linux 6.6-এ CFS-এর জায়গায় EEVDF নামের নতুন scheduler আসে — সেটিও tree-ভিত্তিক।)

Timer-এর গল্পটি আরও মজার। একটি সার্ভারে লাখো network connection, প্রতিটির একটি timeout — “৩০ সেকেন্ডে উত্তর না এলে বন্ধ করো।” কিন্তু প্রায় সব timer সময় হওয়ার আগেই বাতিল হয়ে যায়, কারণ উত্তর চলে আসে! তাই এখানে সবচেয়ে জরুরি হলো দ্রুত ঢোকানো আর দ্রুত বাতিল করা। ১৯৮৭ সালে George Varghese ও Tony Lauck timer wheel প্রস্তাব করেন: ঘড়ির কাঁটার মতো একটি গোল অ্যারে, যার প্রতিটি ঘর একটি সময়-খণ্ড; timer সরাসরি তার ঘরের তালিকায় যায় — O(1). Linux-এর সাধারণ timer এই ধারণায় চলে, আর সূক্ষ্ম সময়ের high-resolution timer-গুলো আবার একটি red-black tree-তে থাকে।

মূল উপলব্ধি: “Priority queue” একটি interface; heap তার একটি বাস্তবায়ন মাত্র। কোন কাজ সবচেয়ে বেশি হয় — বের করা, মাঝখান থেকে মোছা, না বাতিল করা — সেটি দেখেই ভেতরের গঠন বেছে নিতে হয়।

প্র ০৫কোটি কোটি পোস্টের মধ্যে “এই মুহূর্তের সেরা ১০টি ট্রেন্ডিং বিষয়” — সব ডেটা মেমরিতে রাখা না গেলে top-k কীভাবে বের হয়?

প্রথম ধাপ এই পর্বেরই কৌশল: k ঘরের একটি min-heap. ডেটা একটি একটি করে স্রোতের (stream) মতো আসে, প্রতিটি মান একবার দেখা হয়, আর মেমরিতে থাকে শুধু k টি — সময় O(n log k). ১০টি সেরা খুঁজতে কোটি মানের পুরো অ্যারে সাজানোর দরকারই নেই। সার্ভারের লগ থেকে সবচেয়ে ধীর ১০টি request, বা সেন্সরের সবচেয়ে বড় ১০টি মাপ — এগুলো এভাবেই হয়।

কিন্তু “ট্রেন্ডিং বিষয়” আরও কঠিন, কারণ এখানে প্রতিটি মানের আলাদা আকার নেই — দরকার কোন বিষয় কতবার এসেছে। সব বিষয়ের গণনা রাখতে hash table লাগবে (পরের পর্বের বিষয়), আর কোটি ভিন্ন বিষয় হলে সেটিও বিশাল। তাই বড় সিস্টেম আনুমানিক অ্যালগরিদম ব্যবহার করে। ১৯৮২ সালে Jayadev Misra ও David Gries একটি পদ্ধতি দেখান, যা মাত্র k টি counter দিয়ে স্রোতের সবচেয়ে ঘন ঘন আসা মানগুলো খুঁজে দেয় — যারা মোটের একটি নির্দিষ্ট অংশের বেশিবার এসেছে তারা বাদ পড়ে না। পরে Count-Min Sketch-এর মতো কাঠামো খুব কম মেমরিতে কাছাকাছি গণনা দেয়, আর সেগুলোর সাথে একটি ছোট heap রেখে সেরা প্রার্থীদের তালিকা ধরে রাখা হয়।

ডেটা যখন অনেক মেশিনে ভাগ করা, তখন একটি সূক্ষ্ম ফাঁদ আছে। প্রতিটি মেশিন নিজের top-10 পাঠাল, কেন্দ্র সেগুলো মিলিয়ে চূড়ান্ত top-10 বানাল — শুনতে ঠিক। আলাদা আলাদা মান-এর (যেমন সবচেয়ে বড় ১০টি সংখ্যা) ক্ষেত্রে এটি সত্যিই ঠিক। কিন্তু গণনার যোগফলের ক্ষেত্রে ভুল হতে পারে: একটি বিষয় প্রতিটি মেশিনে ১১তম, কাজেই কোথাও তালিকায় নেই, অথচ সব মিলিয়ে সে প্রথম! এজন্য বাস্তব সিস্টেম প্রতিটি মেশিন থেকে k-এর চেয়ে বেশি প্রার্থী নেয়, বা আনুমানিক sketch মিলিয়ে নেয়।

মূল উপলব্ধি: বড় ডেটায় প্রশ্ন বদলে যায় — “কত দ্রুত?” থেকে “কত কম মেমরিতে, কত কাছাকাছি নির্ভুল?” ছোট একটি heap প্রায়ই সেই বড় সমাধানের কেন্দ্রে থাকে, কিন্তু কখন একটি সরল কৌশল ভুল উত্তর দেবে, সেটি চেনাই আসল দক্ষতা।

অনুশীলন

খাতা-কলম নিয়ে বসুন — প্রথম তিনটিতে কোড লিখতে হবে না, শুধু ট্রি আঁকুন আর index গুনুন।

  1. এটি কি max-heap? অ্যারে [90, 70, 80, 75, 60, 85, 20]. ট্রি এঁকে বা সূত্র দিয়ে যাচাই করুন। না হলে কোন জোড়া নিয়ম ভাঙছে?

    না। প্রতিটি parent দেখি: h[0] = ৯০ ≥ ৭০, ৮০ — ঠিক। h[1] = ৭০-এর সন্তান h[3] = ৭৫ আর h[4] = ৬০ — এখানে ৭৫ > ৭০, নিয়ম ভাঙা। h[2] = ৮০-এর সন্তান h[5] = ৮৫ আর h[6] = ২০ — ৮৫ > ৮০, এটিও ভাঙা। অর্থাৎ দুটি জোড়া: (৭০, ৭৫) আর (৮০, ৮৫)। খেয়াল করুন, প্রথম সারি দেখে সব ঠিক মনে হচ্ছিল — প্রতিটি parent-কে আলাদা করে দেখতে হয়।

  2. হাতে sift-up: কোডের heap [90, 70, 20, 40, 50]-এ ৯৫ push করুন। প্রতিটি swap লিখুন। শেষ অ্যারে কী হবে?

    ৯৫ বসে h[5]-এ। Parent (5 − 1) / 2 = 2, সেখানে ২০ < ৯৫ — swap: [90, 70, 95, 40, 50, 20]. এবার parent (2 − 1) / 2 = 0, সেখানে ৯০ < ৯৫ — swap: [95, 70, 90, 40, 50, 20]. Root-এ পৌঁছে থামা। মোট ২টি swap — নতুন height-এর সমান। লক্ষ করুন, ৭০ আর ৪০, ৫০-এর অংশটি একবারও ছোঁয়া হয়নি; sift-up শুধু একটি পথ ধরে ওঠে।

  3. স্রোত থেকে top-3: মান আসছে এই ক্রমে — ৫, ১, ৯, ৩, ৭, ৮, ২. ৩ ঘরের min-heap দিয়ে সবচেয়ে বড় ৩টি বের করুন। প্রতিটি মান আসার পর heap-এ কী আছে লিখুন। Max-heap নিলে কেন চলত না?

    প্রথম তিনটি সরাসরি ঢোকে: {৫}, {১, ৫}, {১, ৫, ৯} — root (সবচেয়ে ছোট) ১. এবার ৩ > ১, তাই ১ বের, ৩ ঢুকল: {৩, ৫, ৯}. ৭ > ৩: {৫, ৭, ৯}. ৮ > ৫: {৭, ৮, ৯}. ২ < ৭, বাদ। উত্তর ৯, ৮, ৭.

    Max-heap নিলে root-এ থাকত ভেতরের সবচেয়ে বড় মান — অথচ নতুন মান এলে আমাদের জানা দরকার ভেতরের সবচেয়ে দুর্বল কে, যাকে বাদ দেওয়া যায়। Max-heap-এ সেটি খুঁজতে পুরো heap ঘাঁটতে হতো।

  4. Sort-এর সাথে মিলিয়ে টেস্ট (কোড লিখুন): প্রথম কোডটিতে একটি টেস্ট যোগ করুন — srand(1) দিয়ে ১৫টি এলোমেলো সংখ্যা push করুন, একই সংখ্যাগুলো আরেকটি অ্যারেতে রেখে qsort দিয়ে বড় থেকে ছোট সাজান, তারপর সব pop করে প্রতিটি মান মিলিয়ে দেখুন। এরপর ইচ্ছা করে pop-এ c + 1 < n && অংশটি মুছে দিন। টেস্ট কি সবসময় ধরতে পারে?

    টেস্টের মূল অংশ এমন হতে পারে (অ্যারের আকার ১৬, তাই ১৫টির বেশি নয়):

    int cmp_desc(const void *a, const void *b) { return *(const int *)b - *(const int *)a; }
    /* main-এর ভেতরে: */
    int ref[15];
    srand(1);
    for (int k = 0; k < 15; k++) { ref[k] = rand() % 100; push(ref[k]); }
    qsort(ref, 15, sizeof ref[0], cmp_desc);
    for (int k = 0; k < 15; k++)
        if (pop() != ref[k]) { printf("MISMATCH at %d\n", k); return 1; }
    printf("OK\n");

    (<stdlib.h> include করতে হবে। এখানে মানগুলো ০–৯৯, তাই বিয়োগে overflow-এর ভয় নেই।) সীমার যাচাই মুছলে ভুলটি সবসময় ধরা পড়ে না। শেষ parent-এর ডান সন্তান যখন ঠিক heap-এর সীমার বাইরে, তখন সেখানে পড়ে থাকা পুরোনো (আগে বের করা) মান তুলনায় ঢুকে পড়ে — কখনো ফল বদলায়, কখনো বদলায় না। এজন্যই একটি নয়, নানা srand seed দিয়ে বহুবার চালাতে হয়, আর প্রতিটি pop-এর পর heap property যাচাই করাও ভালো। এখানে ভুল পড়াটি h[16]-এর ভেতরেই থাকে, তাই AddressSanitizer-ও এটি ধরবে না; sanitizer ধরে কেবল অ্যারের সীমা পেরোনো পড়া।

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

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

ক্রমMax-heap থেকে সবচেয়ে বড় মান বের করার (pop) ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Root h[0]-এর মান আলাদা করে রাখা
  2. শেষ ঘরের মান h[0]-এ এনে size এক কমানো
  3. দুই সন্তানের মধ্যে বড়টি বেছে নেওয়া
  4. সেই সন্তান বড় হলে swap করে এক স্তর নিচে নামা
  5. কোনো সন্তান বড় না থাকলে থেমে, রাখা মানটি ফেরত দেওয়া

0-ভিত্তিক অ্যারের heap-এ index ৪-এর parent কোন index-এ?

  • 1
  • 2
  • 3
  • 0

n টি মানের max-heap-এ সবচেয়ে বড় মানটি শুধু দেখতে (peek) কত খরচ?

  • O(log n)
  • O(1)
  • O(n)
  • O(n log n)

নিচের কোন অ্যারেটি বৈধ max-heap?

  • [50, 30, 20, 40, 10]
  • [10, 50, 40, 30, 20]
  • [50, 30, 40, 10, 20]
  • [50, 40, 20, 10, 30, 45]

আগে থেকে থাকা n টি মানের অ্যারেকে bottom-up heapify দিয়ে max-heap বানাতে মোট খরচ কত?

  • O(log n)
  • O(n log n)
  • O(n²)
  • O(n)

কোটি সংখ্যার একটি স্রোত থেকে সবচেয়ে বড় ১০০টি রাখতে চান, মেমরি খুব কম। কোনটি সবচেয়ে উপযুক্ত?

  • ১০০ ঘরের min-heap
  • ১০০ ঘরের max-heap
  • সব সংখ্যা অ্যারেতে রেখে qsort
  • ১০০ ঘরের সাজানো অ্যারে, প্রতিবার insertion

Heapsort সম্পর্কে কোন কথাটি সত্য?

  • Stable, কিন্তু দ্বিতীয় অ্যারে লাগে
  • In-place, খারাপতম ক্ষেত্রেও O(n log n), কিন্তু stable নয়
  • খারাপতম ক্ষেত্রে O(n²)
  • এটি malloc-এর heap মেমরিতে কাজ করে বলে এমন নাম

শব্দকোষ

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

মনে রাখুন

  1. Priority queue একটি ADT — push, pop, peek; সবচেয়ে জরুরিটি আগে। Binary heap তার সবচেয়ে প্রচলিত বাস্তবায়ন।
  2. Heap = complete tree + parent ≥ সন্তান (max) বা parent ≤ সন্তান (min). ভাইবোনের মধ্যে কোনো ক্রম নেই।
  3. অ্যারেতে (0-ভিত্তিক): সন্তান 2i+1, 2i+2; parent (i-1)/2. Pointer লাগে না।
  4. Push = শেষে বসানো + sift-up; pop = শেষটি root-এ + sift-down (বড় সন্তানের সাথে) — দুটিই O(log n); heapify O(n); heapsort O(n log n), in-place, stable নয়।
  5. AI-এর heap কোডে আগে দেখুন সূত্র, সীমা, size — তারপর হাজারো এলোমেলো মানে sort-এর সাথে মিলিয়ে টেস্ট।

পরের পর্বে: Heap জানে কে সবচেয়ে বড়, কিন্তু “রহিম কোথায়?” জিজ্ঞেস করলে পুরো অ্যারে ঘাঁটতে হয়। পরের পর্বে এমন একটি কাঠামো বানাব, যা নাম শুনেই প্রায় এক ধাপে সঠিক ঘরে পৌঁছে যায় — hash table.

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

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