এই পর্বে যা শিখবেন
- Decision tree দিয়ে প্রমাণ করা যে তুলনা-ভিত্তিক কোনো সর্ট worst case-এ Ω(n log n)-এর কম তুলনায় পারে না
- Counting sort-এর count অ্যারে আর prefix sum দিয়ে stable বসানো হাতে trace করা, আর কখন এটি অচল তা বোঝা
- LSD radix sort কেন কাজ করে (stability-নির্ভর induction প্রমাণ), আর base বেছে নিয়ে Θ(d(n + b)) হিসাব করা
- MSD radix sort দিয়ে string সাজানো, bucket sort-এর প্রত্যাশিত Θ(n) প্রমাণ, আর pigeonhole sort চেনা
- বাস্তবে কখন এরা std::sort-কে হারায়, মেমরির খরচ, আর ঋণাত্মক সংখ্যা সামলানোর কৌশল
১ · তুলনা করে সাজানো — আর পরীক্ষার খাতার এক অন্য উপায়
পর্ব ২০ থেকে ২৩ পর্যন্ত আপনি যত সর্ট দেখেছেন — insertion, merge, quick, heap — সবার মধ্যে একটি মিল আছে। এরা উপাদান সম্পর্কে একটিই প্রশ্ন করে: “a কি b-এর চেয়ে ছোট বা সমান?” উত্তর হ্যাঁ বা না। এর বাইরে কিছু জানে না, জানতেও চায় না। এই ধরনের অ্যালগরিদমকে বলে comparison sort (তুলনা-ভিত্তিক সর্ট)। এদের সুবিধা বিশাল: যে জিনিসেরই তুলনা করা যায় — সংখ্যা, নাম, তারিখ, এমনকি আপনার নিজের বানানো struct — সবই একই কোডে সাজানো যায়, শুধু একটি comparator দিলেই।
কিন্তু এই সর্বজনীনতার একটি দাম আছে। পর্ব ২১-এ merge sort Θ(n log n) নিয়েছিল, পর্ব ২৩-এ heapsort-ও তাই। প্রশ্ন জাগে: আরও চালাক কোনো তুলনা-সর্ট কি O(n)-এ পারবে? এই পর্বের প্রথম অর্ধেক প্রমাণ করবে — না, কখনোই না। তারপর দ্বিতীয় অর্ধেক দেখাবে, তুলনা ছেড়ে দিলে কীভাবে সত্যিই রৈখিক সময়ে সাজানো যায়।
পরীক্ষা শেষে শিক্ষকের টেবিলে ৬০টি খাতা এলোমেলো পড়ে আছে, রোল ১ থেকে ৬০. দুইভাবে সাজানো যায়। প্রথম উপায়: দুটি খাতা তুলে রোল মিলিয়ে দেখা কোনটি আগে, তারপর আরেকটি — এভাবে তুলনার পর তুলনা। দ্বিতীয় উপায়: টেবিলে ৬০টি ঘর এঁকে নেওয়া, তারপর প্রতিটি খাতার রোল দেখে সরাসরি সেই নম্বরের ঘরে রেখে দেওয়া। দ্বিতীয় উপায়ে কোনো দুটি খাতার মধ্যে তুলনাই হয় না; প্রতিটি খাতা একবার ধরা হয়, একবার রাখা হয়। ৬০টি খাতা, ৬০টি কাজ। এটিই এই পর্বের মূল ভাবনা: key নিজেই বলে দেয় সে কোথায় যাবে।
দ্বিতীয় উপায়টি কাজ করেছে কারণ আমরা key সম্পর্কে একটি বাড়তি তথ্য জানতাম: রোল নম্বর ১ থেকে ৬০-এর মধ্যে একটি পূর্ণসংখ্যা। নাম দিয়ে সাজাতে হলে এভাবে ঘর আঁকা যেত না — অন্তত এত সহজে নয়। এই পর্বের প্রতিটি দ্রুত অ্যালগরিদম এমনই কোনো বাড়তি তথ্যের ওপর দাঁড়িয়ে: key-এর পরিসর ছোট (counting sort), key নির্দিষ্ট সংখ্যক অঙ্কে লেখা (radix sort), অথবা key একটি পরিসরে সমভাবে ছড়ানো (bucket sort).
| বিষয় | তুলনা-সর্ট | তুলনা-ছাড়া সর্ট |
|---|---|---|
| key সম্পর্কে কী জানে | শুধু “a ≤ b?” প্রশ্নের উত্তর | key-এর মান, অঙ্ক বা বণ্টন |
| উদাহরণ | insertion, merge, quick, heap, introsort, Timsort | counting, radix, bucket, pigeonhole |
| সেরা worst case | Θ(n log n) — এর নিচে অসম্ভব | Θ(n + k), Θ(d(n + b)) ইত্যাদি |
| কোন key-তে চলে | যেকোনো তুলনাযোগ্য জিনিস | নির্দিষ্ট ধরনের key (পূর্ণসংখ্যা, নির্দিষ্ট দৈর্ঘ্যের string…) |
২ · Decision tree — কেন তুলনা-সর্ট n log n-এর নিচে নামতে পারে না
একটি অ্যালগরিদমের পক্ষে “এর চেয়ে দ্রুত সম্ভব নয়” প্রমাণ করা সাধারণত কঠিন, কারণ বিশ্বের সব সম্ভাব্য অ্যালগরিদমের কথা একসাথে বলতে হয় — যেগুলো এখনো কেউ আবিষ্কারই করেনি সেগুলোরও। তুলনা-সর্টের জন্য একটি সুন্দর কৌশল আছে: যেকোনো তুলনা-সর্টকে একটি decision tree হিসেবে আঁকা যায়।
ধরুন n টি আলাদা উপাদান a₁, a₂, …, aₙ সাজাতে হবে। অ্যালগরিদম চলার সময় প্রথম যে তুলনাটি করে, সেটি tree-এর মূল (root). উত্তর “≤” হলে বাঁ দিকে, “>” হলে ডান দিকে যাই, আর সেখানে অ্যালগরিদমের পরের তুলনা। এভাবে চলতে চলতে অ্যালগরিদম যখন উত্তর জেনে যায়, তখন আমরা একটি পাতায় (leaf) পৌঁছাই — পাতায় লেখা থাকে চূড়ান্ত সাজানো ক্রমটি। অ্যালগরিদমের কোডে লুপ, ভ্যারিয়েবল, swap যা-ই থাকুক, n নির্দিষ্ট করে দিলে তুলনাগুলোর এই শাখা-প্রশাখাই তার পুরো আচরণ। নিচে n = 3-এর জন্য insertion sort-এর decision tree দেখুন, আর একটি নির্দিষ্ট ইনপুট কোন পথে যায় তা অনুসরণ করুন।
| এখনকার প্রশ্ন | |
| উত্তর | |
| এ পর্যন্ত তুলনা |
- পুরো tree. প্রতিটি ভেতরের node একটি তুলনা, প্রতিটি পাতা একটি সম্ভাব্য উত্তর। “123” মানে a₁ ≤ a₂ ≤ a₃, “312” মানে a₃ ≤ a₁ ≤ a₂. তিনটি আলাদা সংখ্যার মোট 3! = 6 রকম ক্রম হতে পারে, আর প্রতিটির জন্য একটি পাতা আছে।
- মূল: 1:2. insertion sort প্রথমে a₂-কে a₁-এর সাথে তুলনা করে। 5 ≤ 9 — হ্যাঁ, তাই বাঁ শাখা। এখন জানি a₁ আগে, a₂ পরে।
- 2:3. এবার a₃ ঢোকানোর পালা; প্রথমে তুলনা হয় শেষের উপাদান a₂-এর সাথে। 9 ≤ 2? না — ডান শাখা। a₃ অন্তত a₂-এর আগে বসবে।
- 1:3. এরপর a₃-কে a₁-এর সাথে তুলনা: 5 ≤ 2? না — আবার ডানে। অর্থাৎ a₃ সবার আগে।
- পাতা 312. উত্তর: a₃, a₁, a₂ = 2, 5, 9. এই ইনপুটে ৩টি তুলনা লাগল — ঠিক মূল থেকে এই পাতা পর্যন্ত পথের দৈর্ঘ্য। worst case-এ তুলনার সংখ্যা মানে tree-এর উচ্চতা (সবচেয়ে লম্বা পথ)।
- পাতা গোনা. ৬টি পাতা = 3!. উচ্চতা h-এর একটি binary tree-তে সর্বোচ্চ 2ʰ টি পাতা থাকে; 2² = 4 < 6, তাই উচ্চতা অন্তত ৩ হতেই হবে। ⌈log₂ 6⌉ = 3, আর insertion sort ঠিক ৩টিতেই পারে — n = 3-এ এটি সেরা।
প্রমাণ — চার লাইনে
- প্রতিটি ক্রমের জন্য অন্তত একটি পাতা। n টি আলাদা উপাদানের n! রকম বিন্যাস (permutation) সম্ভব। দুটি ভিন্ন বিন্যাস যদি একই পাতায় পৌঁছায়, তাহলে অ্যালগরিদম দুটির জন্য একই কাজ করবে — অথচ দুটির সঠিক উত্তর আলাদা, তাই অন্তত একটি ভুল হবে। সুতরাং সঠিক অ্যালগরিদমের tree-তে পাতা ≥ n!.
- উচ্চতা h হলে পাতা ≤ 2ʰ. প্রতিটি তুলনায় শাখা সর্বোচ্চ দুটি, তাই প্রতি স্তরে পাতার সম্ভাব্য সংখ্যা সর্বোচ্চ দ্বিগুণ হয়।
- তাই 2ʰ ≥ n!, অর্থাৎ h ≥ log₂ n!.
- log₂ n! = Θ(n log n). n! = 1 · 2 · … · n-এর ওপরের n/2 টি গুণনীয়কের প্রতিটি ≥ n/2, তাই n! ≥ (n/2)n/2, আর log₂ n! ≥ (n/2) log₂(n/2) = Ω(n log n). অন্যদিকে n! ≤ nⁿ, তাই log₂ n! ≤ n log₂ n. (পর্ব ০৬-এর Stirling-এর সূত্র আরও নিখুঁত: log₂ n! ≈ n log₂ n − 1.44 n.)
যেকোনো তুলনা-সর্ট worst case-এ অন্তত ⌈log₂ n!⌉ = Ω(n log n) টি তুলনা করে। এটি কোনো নির্দিষ্ট অ্যালগরিদমের দুর্বলতা নয় — পুরো সমস্যার lower bound. তাই merge sort আর heapsort, যারা worst case-এ O(n log n), তুলনা-সর্টের মধ্যে অসিম্পটোটিকভাবে সর্বোত্তম। আর quick sort গড়ে Θ(n log n) — সেটিও সেরা, কারণ একই যুক্তি গড়ের ক্ষেত্রেও খাটে।
সংখ্যায় দেখলে সীমাটি কতটা কড়া বোঝা যায়। n = 4-এ 4! = 24 টি পাতা, log₂ 24 ≈ 4.58, তাই অন্তত ৫টি তুলনা। n = 10-এ 10! = 36,28,800 টি পাতা, log₂ 10! ≈ 21.8 — অন্তত ২২টি তুলনা। n = 10⁶-এ log₂ n! ≈ 1.85 × 10⁷, যেখানে n log₂ n ≈ 1.99 × 10⁷ — অর্থাৎ merge sort সীমার খুব কাছেই আছে।
গড়ের ক্ষেত্রেও একই সীমা। সব n! ইনপুট সমান সম্ভাবনার হলে গড় তুলনা = পাতাগুলোর গড় গভীরতা। n! টি পাতার একটি binary tree-তে গড় গভীরতাও অন্তত log₂ n! (সমান ভাগ হলেই গড় সবচেয়ে কম হয়, আর তখনও প্রতিটি পাতা প্রায় log₂ n! গভীরে)। তাই এলোমেলো pivot বা কোনো চালাকি দিয়েও গড়ে n log n-এর নিচে নামা যায় না।
তাহলে counting sort কীভাবে পারে?
প্রমাণের প্রথম লাইনেই একটি লুকানো অনুমান আছে: অ্যালগরিদম উপাদান সম্পর্কে শুধু তুলনার মাধ্যমে তথ্য পায়, আর প্রতিটি তুলনা মাত্র দুটি উত্তর দেয় (১ bit তথ্য)। Counting sort এই অনুমান ভাঙে। সে cnt[x]++ লিখে key x-কে সরাসরি অ্যারের index বানায় — এক ধাপে k টি সম্ভাব্য ঘরের যেকোনোটিতে লাফ দেয়, যা আসলে k-মুখী একটি সিদ্ধান্ত, দুই-মুখী নয়। পর্ব ০২-এর RAM model-এ অ্যারে-index O(1) সময়ের কাজ, তাই এই “k-মুখী লাফ” সস্তা। Decision tree-এর যুক্তি তাই এখানে খাটেই না; এর মানে এই নয় যে প্রমাণটি ভুল, বরং অ্যালগরিদমটি অন্য খেলার নিয়মে খেলছে।
৩ · Counting sort — গুনে গুনে জায়গা ঠিক করা
ধরুন n টি উপাদানের key 0 থেকে k-এর মধ্যে পূর্ণসংখ্যা — যেমন ১০ নম্বরের কুইজে প্রাপ্ত নম্বর, বয়স, অথবা ASCII অক্ষর। Counting sort তিন ধাপে কাজ করে:
- গোনা: k + 1 ঘরের একটি
cntঅ্যারে, যারcnt[v]= key v কতবার এসেছে। এটি পর্ব ১১-এর frequency array. - Prefix sum:
cnt[v] += cnt[v-1]চালালেcnt[v]হয়ে যায় “v বা তার ছোট key কয়টি”। পর্ব ১৭-এর prefix sum এখানে একটি নতুন অর্থ পায়: key v-এর শেষ উপাদানটি আউটপুটে কোন ঘরে বসবে (cnt[v] − 1 নম্বর ঘরে, 0-based). - বসানো: ইনপুটের ডান থেকে বাঁয়ে হাঁটুন। প্রতিটি উপাদান x-এর জন্য
cnt[key(x)]এক কমান, তারপর x-কে সেই ঘরে বসান।
শুধু সংখ্যা সাজালে ধাপ ২–৩ লাগে না: গোনার পর “0 লিখুন cnt[0] বার, 1 লিখুন cnt[1] বার…” করলেই হয়। কিন্তু বাস্তবে key-এর সাথে থাকে বাড়তি তথ্য — নম্বরের সাথে রোল, নাম, ঠিকানা। একে বলে satellite data. তখন পুরো রেকর্ড সরাতে হয়, আর ধাপ ২–৩-ই সেটি ঠিকঠাক করে। নিচে n = 6, k = 3-এর একটি ইনপুটে দেখুন; সমান key দুটিকে আলাদা চেনার জন্য 2a, 2b লেখা হয়েছে:
| এই ধাপের কাজ | |
| cnt[0] cnt[1] cnt[2] cnt[3] | |
| out[0..5] |
- শুরু. key-এর পরিসর 0..3, তাই cnt-এ ৪টি ঘর, সব শূন্য। আউটপুটের জন্য n = 6 ঘরের আলাদা অ্যারে।
- গোনা. একবার পুরো ইনপুট পড়ে: 0 এসেছে ২ বার, 1 একবার, 2 দুবার, 3 একবার। cnt = 2 1 2 1. যোগফল 6 = n.
- Prefix sum. cnt = 2 3 5 6. মানে: key ≤ 0 এমন ২টি, ≤ 1 এমন ৩টি, ≤ 2 এমন ৫টি, ≤ 3 এমন ৬টি। তাই key 2-এর উপাদানগুলো বসবে out[3] আর out[4]-এ — শেষটি out[5 − 1]-এ।
- a[5] = 1. ডান থেকে শুরু। cnt[1] = 3 থেকে কমে 2, তাই 1 বসল out[2]-এ।
- a[4] = 0b. cnt[0] = 2 → 1, 0b বসল out[1]-এ — key 0-এর দুটি ঘরের শেষটিতে, কারণ ডান থেকে আসা প্রথম 0 মূল ক্রমে শেষের 0.
- a[3] = 2b. cnt[2] = 5 → 4, 2b বসল out[4]-এ, key 2-এর অংশের শেষ ঘরে।
- a[2] = 3. cnt[3] = 6 → 5, 3 বসল out[5]-এ।
- a[1] = 0a. cnt[0] = 1 → 0, 0a বসল out[0]-এ — 0b-এর আগে, যেমনটি মূল ইনপুটে ছিল।
- a[0] = 2a. cnt[2] = 4 → 3, 2a বসল out[3]-এ, 2b-এর আগে। ফল: 0a 0b 1 2a 2b 3. সমান key-এর উপাদানগুলো মূল ক্রম বজায় রেখেছে — অর্থাৎ সর্টটি stable.
কেন ডান থেকে বাঁয়ে? প্রতিটি key-এর জন্য cnt[v] শুরুতে সেই key-এর অংশের শেষ ঘরের পরের ঘর দেখায়, আর প্রতিবার এক কমে বাঁয়ে সরে। ডান থেকে হাঁটলে যে উপাদান ইনপুটে শেষে, সে সবার আগে আসে আর বসে অংশের শেষ ঘরে; ফলে সমান key-দের আপেক্ষিক ক্রম অক্ষত থাকে। বাঁ থেকে হাঁটলেও সর্টেড ফল আসবে, কিন্তু সমান key-দের ক্রম উল্টে যাবে — সর্ট আর stable থাকবে না। (বিকল্প: prefix sum-কে এক ঘর ডানে সরিয়ে প্রতিটি key-এর প্রথম ঘর রাখুন, তারপর বাঁ থেকে হেঁটে প্রতিবার এক বাড়ান — সেটিও stable.) পরের অংশে দেখবেন, radix sort-এর পুরো শুদ্ধতা এই একটি বৈশিষ্ট্যের ওপর দাঁড়িয়ে।
বিশ্লেষণ: গোনা Θ(n), prefix sum Θ(k), বসানো Θ(n) — মোট সময় Θ(n + k). জায়গা: cnt-এর Θ(k) আর আউটপুটের Θ(n), তাই auxiliary space Θ(n + k); এটি in-place নয়। k = O(n) হলে পুরো সর্ট Θ(n) — n log n-এর সীমার নিচে। এবার (নম্বর, রোল) রেকর্ড সাজিয়ে stability নিজের চোখে দেখুন:
#include <cstdio>
#include <vector>
using namespace std;
struct Rec { int marks, roll; };
vector<Rec> countingSort(const vector<Rec>& a, int k) {
vector<int> cnt(k + 1, 0);
for (const Rec& r : a) cnt[r.marks]++; // 1) count each key
printf("count :"); for (int c : cnt) printf(" %d", c); printf("\n");
for (int v = 1; v <= k; ++v) cnt[v] += cnt[v - 1]; // 2) prefix sums: keys <= v
printf("prefix:"); for (int c : cnt) printf(" %d", c); printf("\n");
vector<Rec> out(a.size());
for (int i = (int)a.size() - 1; i >= 0; --i) // 3) right to left = stable
out[--cnt[a[i].marks]] = a[i];
return out;
}
int main() {
// (marks, roll) records, already in roll order; marks are 0..10
vector<Rec> recs = {{7, 101}, {4, 102}, {9, 103}, {7, 104},
{4, 105}, {10, 106}, {9, 107}, {7, 108}};
for (const Rec& r : countingSort(recs, 10))
printf("marks %2d roll %d\n", r.marks, r.roll);
}আউটপুট:
count : 0 0 0 0 2 0 0 3 0 2 1
prefix: 0 0 0 0 2 2 2 5 5 7 8
marks 4 roll 102
marks 4 roll 105
marks 7 roll 101
marks 7 roll 104
marks 7 roll 108
marks 9 roll 103
marks 9 roll 107
marks 10 roll 106
count-এর লাইনে ১১টি ঘর (নম্বর 0..10): 4 পেয়েছে ২ জন, 7 পেয়েছে ৩ জন, 9 পেয়েছে ২ জন, 10 পেয়েছে ১ জন। prefix-এর লাইনে cnt[7] = 5 মানে 7 বা কম পেয়েছে ৫ জন — তাই 7 পাওয়া তিনজন বসবে ঘর 2, 3, 4-এ। এবার stability দেখুন: 7 পাওয়া তিনজন আউটপুটে রোল 101, 104, 108 ক্রমে — ইনপুটে যে ক্রমে ছিলেন, ঠিক সেভাবেই। ফলে “নম্বর অনুযায়ী, সমান হলে রোল অনুযায়ী” সাজানো তালিকা পেলাম আলাদা কোনো tie-break ছাড়াই। লাইন out[--cnt[...]] = a[i] আগে কমায়, তারপর ব্যবহার করে — ধাপ ৩-এর দুটি কাজ এক লাইনে।
# (marks, roll) records, already in roll order; marks are 0..10
recs = [(7, 101), (4, 102), (9, 103), (7, 104), (4, 105), (10, 106), (9, 107), (7, 108)]
K = 10
def counting_sort(a, key, k):
cnt = [0] * (k + 1)
for r in a: # 1) count each key
cnt[key(r)] += 1
print("count :", *cnt)
for v in range(1, k + 1): # 2) prefix sums: cnt[v] = number of keys <= v
cnt[v] += cnt[v - 1]
print("prefix:", *cnt)
out = [None] * len(a)
for r in reversed(a): # 3) right to left keeps equal keys in order
cnt[key(r)] -= 1
out[cnt[key(r)]] = r
return out
for marks, roll in counting_sort(recs, lambda r: r[0], K):
print(f"marks {marks:2d} roll {roll}")আউটপুট C++-এর সাথে হুবহু এক:
count : 0 0 0 0 2 0 0 3 0 2 1
prefix: 0 0 0 0 2 2 2 5 5 7 8
marks 4 roll 102
marks 4 roll 105
marks 7 roll 101
marks 7 roll 104
marks 7 roll 108
marks 9 roll 103
marks 9 roll 107
marks 10 roll 106
এখানে key একটি ফাংশন (lambda r: r[0]), তাই একই counting_sort দিয়ে চাইলে অন্য কোনো ছোট-পরিসরের key অনুযায়ীও সাজানো যায় — পরের অংশে radix sort ঠিক এই সুবিধাই নেবে। reversed(a) কোনো কপি বানায় না, শুধু উল্টো দিক থেকে হাঁটে। খেয়াল করুন, এখানে কোনো দুটি রেকর্ডের মধ্যে একবারও তুলনা হয়নি — শুধু index-এ লেখা আর পড়া।
k বড় হলে কী হয়?
Θ(n + k)-এর k-ই counting sort-এর দুর্বল জায়গা। ১০ লক্ষ মোবাইল রিচার্জের পরিমাণ (১০ থেকে ১০০০ টাকা) সাজাতে k ≈ 1000 — চমৎকার। কিন্তু একই ১০ লক্ষ লেনদেনের ১০ অঙ্কের ট্রানজ্যাকশন আইডি সাজাতে k ≈ 10¹⁰: cnt অ্যারেতেই লাগবে এক হাজার কোটি ঘর — 4 byte করে হলে ৪০ গিগাবাইট, যা সাধারণ কম্পিউটারে নেই। আর মেমরি থাকলেও prefix sum-এর Θ(k) লুপ n log n-এর চেয়ে বহুগুণ ধীর। মোটামুটি নিয়ম: k যদি n-এর কয়েক গুণের বেশি হয়, counting sort ছাড়ুন — radix sort নিন (অংশ ৪) অথবা সাধারণ std::sort.
ঋণাত্মক key-এর ফাঁদ। cnt[x]-এ x = −5 হলে C++-এ অ্যারের বাইরে লেখা — undefined behavior; Python-এ cnt[-5] চুপচাপ শেষ দিক থেকে পঞ্চম ঘর ধরে নেয়, ভুল উত্তর দেয় কোনো error ছাড়াই। সমাধান: আগে min আর max বের করুন, cnt-এর মাপ নিন max − min + 1, আর index হিসেবে ব্যবহার করুন x - min. তাপমাত্রা −10 থেকে 45 হলে মাত্র ৫৬টি ঘর।
৪ · Radix sort (LSD) — অঙ্ক ধরে ধরে, ডান থেকে বাঁয়ে
১০ লক্ষ শিক্ষার্থীর ছয় অঙ্কের রোল নম্বর সাজাতে চান। k = 10⁶ — counting sort চলে, কিন্তু টানাটানি করে। ৯ অঙ্কের সংখ্যা হলে আর চলে না। Radix sort-এর চালাকি: পুরো সংখ্যাকে একবারে key না বানিয়ে একটি একটি অঙ্ক ধরে সাজানো। প্রতিটি অঙ্কের পরিসর মাত্র 0..9, তাই প্রতিবার counting sort-এর cnt অ্যারেতে মাত্র ১০টি ঘর।
কিন্তু কোন অঙ্ক থেকে শুরু? মানুষ স্বাভাবিকভাবে বাঁয়ের (সবচেয়ে বড় মানের) অঙ্ক দিয়ে শুরু করতে চায়। LSD radix sort (Least Significant Digit) উল্টোটা করে: সবচেয়ে ডানের অঙ্ক দিয়ে শুরু, তারপর দশক, তারপর শতক… প্রতিটি pass-এ পুরো অ্যারেকে শুধু সেই অঙ্ক অনুযায়ী একটি stable counting sort দিয়ে সাজানো হয়। শুনে মনে হয় উল্টাপাল্টা — শেষ pass তো শুধু শতকের অঙ্ক দেখে, তাহলে বাকি অঙ্কের ক্রম ঠিক থাকে কীভাবে? নিচের তিন অঙ্কের রোল নম্বরগুলো দিয়ে দেখুন:
| অবস্থা | |
| অ্যারে | |
| কোন অংশ সাজানো |
- ইনপুট. ৭টি তিন অঙ্কের সংখ্যা, তাই d = 3 টি pass লাগবে। প্রতিটি pass-এ 0..9 পরিসরের একটি stable counting sort.
- Pass ১ — একক অঙ্ক. একক অঙ্ক 1, 2, 5, 6, 7, 8, 9 ক্রমে সাজল: 641 372 435 186 527 158 309. এখন শুধু শেষ অঙ্কটি দেখলে অ্যারে সাজানো।
- Pass ২ — দশক অঙ্ক. দশকের অঙ্ক 0, 2, 3, 4, 5, 7, 8: 309 527 435 641 158 372 186. লক্ষ করুন, এখন শেষ দুটি অঙ্ক (09, 27, 35, 41, 58, 72, 86) সাজানো। কোনো দুটির দশক সমান হলে stable সর্ট আগের pass-এর ক্রম — অর্থাৎ একক অঙ্কের ক্রম — রেখে দিত।
- Pass ৩ — শতক অঙ্ক. 158 আর 186 দুজনেরই শতক 1; stable সর্ট তাদের আগের ক্রম (158, তারপর 186 — কারণ 58 < 86) রাখল। একইভাবে 309 আর 372. ফল: 158 186 309 372 435 527 641 — পুরো সাজানো।
- Stability না থাকলে. ধরুন শতকের pass-টি এমন কোনো সর্ট দিয়ে করা হলো যা সমান key-এর ক্রম রাখে না। তাহলে শতক 1-এর দুটি সংখ্যা যেকোনো ক্রমে আসতে পারে — যেমন 186 158 — আর আগের দুই pass-এর সব পরিশ্রম নষ্ট। LSD radix sort-এ stability ঐচ্ছিক নয়, শুদ্ধতার শর্ত।
কেন ঠিক — stability-র ওপর induction
দাবি: i নম্বর pass-এর পর (ডান দিক থেকে i টি অঙ্ক দেখা হয়েছে) অ্যারেটি “শেষ i টি অঙ্ক দিয়ে গঠিত সংখ্যা” অনুযায়ী সাজানো।
- ভিত্তি (i = 1): প্রথম pass একক অঙ্ক দিয়ে সাজায় — দাবি সরাসরি সত্য।
- ধাপ (i − 1 থেকে i): দুটি সংখ্যা x আর y নিন। যদি তাদের i-তম অঙ্ক আলাদা হয়, তাহলে i-তম pass ছোট অঙ্কেরটিকে আগে রাখে — আর শেষ i অঙ্কের তুলনায় এই অঙ্কটিই নির্ধারক, তাই ঠিক। যদি i-তম অঙ্ক সমান হয়, তাহলে শেষ i অঙ্কের তুলনা নির্ভর করে শেষ i − 1 অঙ্কের ওপর; induction অনুযায়ী আগের pass-এর পর তারা সেই অনুযায়ী সাজানো ছিল, আর stable সর্ট সমান key-এর আগের ক্রম বদলায় না — তাই এখনো ঠিক।
- শেষে (i = d): সব d টি অঙ্ক দেখা হয়েছে, তাই অ্যারেটি পুরো সংখ্যা অনুযায়ী সাজানো। ∎
এটি পর্ব ০৩-এর mathematical induction-এর একটি পরিষ্কার প্রয়োগ, আর প্রমাণের ঠিক একটি জায়গায় stability লেগেছে — সেই জায়গাটিই পরীক্ষায় সবচেয়ে বেশি জিজ্ঞাসা করা হয়।
বিশ্লেষণ: d টি pass, প্রতিটি base b-এর একটি counting sort — Θ(n + b). মোট Θ(d(n + b)), জায়গা Θ(n + b). দশমিকে b = 10, তাই d অঙ্কের n টি সংখ্যায় Θ(d · n). d যদি ধ্রুবক হয় (যেমন সব রোল নম্বর ছয় অঙ্কের), radix sort রৈখিক। এবার আরেকটি ইনপুটে প্রতিটি pass-এর পরের অবস্থা চালিয়ে দেখুন — এখানে ডুপ্লিকেট আর দুই অঙ্কের সংখ্যাও আছে:
#include <cstdio>
#include <vector>
#include <algorithm>
using namespace std;
vector<int> countingPass(const vector<int>& a, int exp) { // stable sort on one digit
int cnt[10] = {0};
for (int x : a) cnt[x / exp % 10]++;
for (int d = 1; d < 10; ++d) cnt[d] += cnt[d - 1];
vector<int> out(a.size());
for (int i = (int)a.size() - 1; i >= 0; --i) // right to left = stable
out[--cnt[a[i] / exp % 10]] = a[i];
return out;
}
void show(const char* label, const vector<int>& a) {
printf("%-14s", label);
for (int x : a) printf(" %03d", x);
printf("\n");
}
vector<int> radixSort(vector<int> a) { // LSD: least significant digit first
show("input", a);
const char* names[] = {"after ones", "after tens", "after hundreds", "after thousands"};
int mx = a.empty() ? 0 : *max_element(a.begin(), a.end());
for (int exp = 1, p = 0; mx / exp > 0; exp *= 10, ++p) {
a = countingPass(a, exp);
show(names[p], a);
}
return a;
}
int main() { radixSort({503, 87, 612, 45, 230, 87, 908, 16}); }আউটপুট:
input 503 087 612 045 230 087 908 016
after ones 230 612 503 045 016 087 087 908
after tens 503 908 612 016 230 045 087 087
after hundreds 016 045 087 087 230 503 612 908
%03d দুই অঙ্কের সংখ্যার আগে 0 বসিয়ে দেখায়, যাতে প্রতিটি অঙ্কের কলাম চোখে পড়ে — 87-এর শতক অঙ্ক আসলে 0. প্রথম pass-এর পর একক অঙ্ক 0, 2, 3, 5, 6, 7, 7, 8 — সাজানো। দ্বিতীয় pass-এর পর শেষ দুই অঙ্ক 03, 08, 12, 16, 30, 45, 87, 87. তৃতীয় pass-এ 016, 045, 087, 087 চারজনেরই শতক 0; stable সর্ট তাদের আগের ক্রম রেখে দিল, তাই সেগুলো নিজেদের মধ্যে ঠিক ক্রমে। x / exp % 10 হলো exp-এর ঘরের অঙ্ক: 503 / 10 % 10 = 0. লুপ থামে যখন mx / exp শূন্য — অর্থাৎ সবচেয়ে বড় সংখ্যার আর কোনো অঙ্ক বাকি নেই।
def counting_pass(a, exp): # stable counting sort on one decimal digit
cnt = [0] * 10
for x in a:
cnt[x // exp % 10] += 1
for d in range(1, 10):
cnt[d] += cnt[d - 1]
out = [0] * len(a)
for x in reversed(a): # right to left = stable
d = x // exp % 10
cnt[d] -= 1
out[cnt[d]] = x
return out
def show(label, a):
print(f"{label:<14}", *(f"{x:03d}" for x in a))
def radix_sort(a): # LSD: least significant digit first
show("input", a)
names = ["after ones", "after tens", "after hundreds", "after thousands"]
mx = max(a, default=0)
exp, p = 1, 0
while mx // exp > 0:
a = counting_pass(a, exp)
show(names[p], a)
exp, p = exp * 10, p + 1
return a
radix_sort([503, 87, 612, 45, 230, 87, 908, 16])আউটপুট C++-এর সাথে হুবহু এক:
input 503 087 612 045 230 087 908 016
after ones 230 612 503 045 016 087 087 908
after tens 503 908 612 016 230 045 087 087
after hundreds 016 045 087 087 230 503 612 908
Python-এ ভাগের জন্য // (পূর্ণসংখ্যা ভাগ) লাগে; / লিখলে float আসত, আর float দিয়ে list-এর index করা যায় না — TypeError. max(a, default=0) খালি list-এ error না দিয়ে 0 দেয়, ফলে লুপ একবারও চলে না। এই কোড শুধু অ-ঋণাত্মক পূর্ণসংখ্যার জন্য — ঋণাত্মক সংখ্যায় Python-এর // আর % নিচের দিকে round করে, অঙ্কগুলো অদ্ভুত আসে (অংশ ৭-এ সমাধান)।
পাঞ্চ কার্ড থেকে radix sort. ১৮৯০ সালের মার্কিন আদমশুমারির তথ্য গোনার জন্য Herman Hollerith-এর বানানো বৈদ্যুতিক tabulating machine ব্যবহার করা হয়; প্রতিটি মানুষের তথ্য একটি কার্ডে নির্দিষ্ট জায়গায় ফুটো (punched card) করে রাখা হতো। সেই সময় কার্ড আলাদা আলাদা খোপে ভাগ করার কাজ অনেকটাই হাতে হতো; পরে Hollerith-এর কোম্পানি — যা পরে IBM-এর অংশ হয় — স্বয়ংক্রিয় card sorter বানায়। এমন একটি sorter প্রতিবার কার্ডের একটি কলাম পড়ে কার্ডগুলোকে ১০টি (বা কিছু বেশি) পকেটে ফেলত। অপারেটররা একটি বহু-অঙ্কের সংখ্যা সাজাতেন সবচেয়ে ডানের কলাম থেকে শুরু করে: পকেটগুলো ক্রম অনুযায়ী জড়ো করে পুরো গাদা আবার মেশিনে দিতেন, পরের কলামে — ঠিক LSD radix sort. যন্ত্রটি স্বভাবতই stable ছিল, কারণ একই পকেটে কার্ড পড়ত আসার ক্রমে। ইলেকট্রনিক কম্পিউটারের জন্য radix sort আর counting sort-এর প্রথম দিকের বর্ণনা পাওয়া যায় ১৯৫৪ সালে MIT-তে Harold H. Seward-এর একটি থিসিসে।
৫ · Radix বেছে নেওয়া, MSD radix আর string সাজানো
Base 10 কেন নয় — byte ধরে ধরে
“অঙ্ক” মানে দশমিক অঙ্কই হতে হবে, এমন কোনো কথা নেই। কম্পিউটারে সংখ্যা থাকে bit-এ, তাই যেকোনো r টি bit-কে একটি “অঙ্ক” ধরা যায় — তখন radix (base) b = 2ʳ. w-bit key-তে pass লাগে d = ⌈w / r⌉, আর প্রতিটি অঙ্ক বের করা হয় shift আর mask দিয়ে: (x >> (r * pass)) & (b - 1) — ভাগ বা ভাগশেষের চেয়ে অনেক সস্তা।
| প্রতি অঙ্কে bit (r) | base b = 2ʳ | pass d (32-bit) | cnt অ্যারে | মন্তব্য |
|---|---|---|---|---|
| 1 | 2 | 32 | 2 ঘর | অনেক বেশি pass — ধীর |
| 4 | 16 | 8 | 16 ঘর | ছোট n-এ চলে |
| 8 | 256 | 4 | 256 ঘর (1 KB) | সবচেয়ে প্রচলিত — প্রতি byte একটি অঙ্ক, cnt পুরোটাই cache-এ |
| 11 | 2048 | 3 | 2048 ঘর (8 KB) | pass কম, তবু cache-এ ধরে |
| 16 | 65536 | 2 | 65536 ঘর (256 KB) | মাত্র ২ pass, কিন্তু n ছোট হলে Θ(b) খরচ চোখে পড়ে, cache-ও চাপে পড়ে |
সেরা r কত? মোট খরচ Θ((w / r)(n + 2ʳ)). r বাড়ালে pass কমে, কিন্তু প্রতি pass-এ 2ʳ খরচ বাড়ে। 2ʳ ≈ n হলে, অর্থাৎ r ≈ log₂ n, দুটি পদ সমান হয়, আর খরচ দাঁড়ায় Θ(w · n / log n). n = 10⁶ (log₂ n ≈ 20) আর w = 32 হলে প্রায় ২টি pass যথেষ্ট — কিন্তু বাস্তবে 2²⁰ ঘরের cnt cache-এ ধরে না, তাই অনেক লাইব্রেরি 8 বা 11 bit বেছে নেয়। তত্ত্বের হিসাব আর হার্ডওয়্যারের বাস্তবতা এখানে একসাথে সিদ্ধান্ত নেয়। তুলনায় merge sort-এ n = 10⁶-এ প্রায় 2 × 10⁷ তুলনা; base 256-এর radix sort-এ ৪টি pass, প্রতিটিতে n-এর কয়েক গুণ সরল কাজ।
MSD radix sort — বাঁ দিক থেকে, ফোনবুকের মতো
MSD radix sort (Most Significant Digit) মানুষের স্বাভাবিক পথ ধরে: প্রথমে সবচেয়ে বাঁয়ের অঙ্ক বা অক্ষর দিয়ে উপাদানগুলোকে b টি দলে ভাগ করে, তারপর প্রতিটি দলকে আলাদাভাবে পরের অঙ্ক দিয়ে রিকার্সিভভাবে সাজায়। ফোনবুক বা অভিধান বানানোর কথা ভাবুন: আগে সব নাম A, B, C… অনুযায়ী ভাগ, তারপর “B”-এর দলের ভেতরে দ্বিতীয় অক্ষর অনুযায়ী — “Ba…”, “Be…”, “Bi…”.
- Stability লাগে না দলগুলোর মধ্যে: দলগুলো একবার আলাদা হলে আর মেশে না, তাই পরের pass আগের ক্রম ভাঙতে পারে না। (তবে একই দলে পুরোপুরি সমান key-এর রেকর্ডের ক্রম রাখতে চাইলে ভেতরের ভাগাভাগি stable হতে হয়।)
- তাড়াতাড়ি থামে: কোনো দলে একটি মাত্র উপাদান থাকলে আর দেখার কিছু নেই। ফলে MSD প্রায়ই সব অঙ্ক দেখে না — শুধু উপাদানগুলোকে আলাদা করতে যতটুকু prefix লাগে, ততটুকু। ছোট দলে (ধরুন ১৬টির কম) অনেক বাস্তবায়ন insertion sort-এ চলে যায়, কারণ সেখানে b ঘরের cnt অ্যারে বানানোই বেশি খরচ।
- বিভিন্ন দৈর্ঘ্যের string: “bat” আর “bath”-এর মতো ক্ষেত্রে যে string আগে শেষ হয়ে যায়, তাকে একটি বিশেষ “শেষ” দলে রাখা হয় যা সবার আগে আসে — ফলে ফল হয় অভিধানের ক্রম (lexicographic order).
MSD radix sort-এর খরচ মোটামুটি Θ(D + b · (দলের সংখ্যা)), যেখানে D = সব string মিলিয়ে আলাদা করতে যত অক্ষর আসলে দেখতে হয়েছে। অনেক string-এর মধ্যে দীর্ঘ মিল থাকলে (যেমন একই prefix-এর URL) D বড় হয়, কিন্তু তবুও তুলনা-সর্টের চেয়ে ভালো থাকে, কারণ তুলনা-সর্ট প্রতিটি তুলনায় শুরু থেকে একই prefix বারবার পড়ে।
নির্দিষ্ট দৈর্ঘ্যের string-এ LSD-ও চলে। সব key সমান দৈর্ঘ্যের হলে — যেমন বিমানবন্দরের তিন অক্ষরের কোড DAC, CGP, ZYL, CXB, JSR — শেষ অক্ষর থেকে শুরু করে প্রতিটি অবস্থানে base 26 (বা ASCII-র জন্য 256) counting sort চালালে L দৈর্ঘ্যের n টি string Θ(L(n + b))-এ সাজে। পর্ব ৫৯-এ suffix array বানানোর একটি পদ্ধতিতে ঠিক এমন radix pass-ই বারবার লাগবে।
৬ · Bucket sort আর pigeonhole sort
এবার key পূর্ণসংখ্যা নয়, বাস্তব সংখ্যা: ধরুন [0, 1) পরিসরে n টি সংখ্যা, যেমন একটি এলোমেলো-সংখ্যা জেনারেটরের আউটপুট, বা ০ থেকে ১-এর মধ্যে স্বাভাবিক করা (normalized) কোনো মাপ। Counting sort এখানে চলে না — 0.3141 আর 0.3142-এর মাঝে অসীম সংখ্যা। কিন্তু যদি জানি সংখ্যাগুলো পরিসরে সমভাবে ছড়ানো (uniform distribution), তাহলে bucket sort কাজে আসে:
- n টি খালি বালতি (bucket) নিন; বালতি i ধরবে [i/n, (i+1)/n) পরিসরের সংখ্যা।
- প্রতিটি সংখ্যা x-কে বালতি ⌊n · x⌋-এ ফেলুন।
- প্রতিটি বালতি আলাদাভাবে insertion sort দিয়ে সাজান (বালতিগুলো ছোট, তাই insertion sort-ই দ্রুত)।
- বালতি 0, 1, …, n−1 পরপর জুড়ে দিন।
| এই ধাপ | |
| B0 [0, .125) | |
| B1 [.125, .25) | |
| B2 [.25, .375) | |
| B3 [.375, .5) | |
| B4 [.5, .625) | |
| B5 [.625, .75) | |
| B6 [.75, .875) | |
| B7 [.875, 1) | |
| ফল |
- প্রস্তুতি. n = 8, তাই ৮টি বালতি, প্রতিটি ১/৮ = 0.125 চওড়া। বালতি নির্ণয়: ⌊8x⌋.
- 0.48, 0.07, 0.91, 0.55. ⌊3.84⌋ = 3, ⌊0.56⌋ = 0, ⌊7.28⌋ = 7, ⌊4.4⌋ = 4. প্রতিটি সংখ্যা একটি গুণ আর একটি floor-এ নিজের বালতিতে — কোনো তুলনা নেই।
- 0.13, 0.42, 0.86, 0.31. যথাক্রমে বালতি 1, 3, 6, 2. বালতি 3-এ এখন দুটি সংখ্যা (0.48, 0.42) আসার ক্রমে; বালতি 5 খালি। সমভাবে ছড়ানো ইনপুটে বেশিরভাগ বালতিতে ০, ১ বা ২টি সংখ্যা পড়ে।
- প্রতিটি বালতিতে insertion sort. শুধু B3-তে কাজ হলো: 0.42 আর 0.48-এর জায়গা বদল — একটি তুলনা। বাকি বালতিতে ০ বা ১টি উপাদান, কাজ নেই।
- জুড়ে দেওয়া. B0 থেকে B7 ক্রমে পড়লেই: 0.07 0.13 0.31 0.42 0.48 0.55 0.86 0.91. বালতি i-এর প্রতিটি সংখ্যা বালতি i+1-এর প্রতিটির চেয়ে ছোট, তাই ভেতরে সাজানো থাকলেই পুরোটা সাজানো।
- খারাপ ইনপুট. যদি ৮টি সংখ্যাই 0.125-এর নিচে হয় (বণ্টন সমান নয়), সবাই B0-তে পড়ে। তখন bucket sort আসলে একটি বড় insertion sort — worst case Θ(n²). প্রত্যাশিত Θ(n) শুধু বণ্টনের অনুমান সত্য হলেই।
প্রত্যাশিত Θ(n) — একটি ছোট সম্ভাবনার হিসাব
বালতি i-তে পড়া সংখ্যার পরিমাণ nᵢ একটি random variable. Insertion sort-এ বালতির খরচ O(nᵢ²), তাই মোট খরচ Θ(n) + Σ O(nᵢ²). প্রতিটি সংখ্যা স্বাধীনভাবে 1/n সম্ভাবনায় বালতি i-তে পড়ে, তাই nᵢ একটি binomial(n, 1/n): গড় E[nᵢ] = 1, variance = 1 − 1/n. তাহলে E[nᵢ²] = Var + (E[nᵢ])² = 2 − 1/n. n টি বালতিতে যোগ করলে প্রত্যাশিত মোট খরচ Θ(n) + n · O(2 − 1/n) = Θ(n). (এটি পর্ব ০৫-এর average-case বিশ্লেষণ — গড় নেওয়া হচ্ছে ইনপুটের বণ্টনের ওপর, অ্যালগরিদমের কোনো random পছন্দের ওপর নয়। Randomized অ্যালগরিদমের expected running time থেকে এটি আলাদা: সেখানে যেকোনো ইনপুটেই গড় খাটে।)
Worst case Θ(n²), যেমন ধাপ ৬-এ দেখলেন। বালতির ভেতরে insertion sort-এর বদলে merge sort দিলে worst case নামে Θ(n log n)-এ — কিন্তু ছোট বালতিতে বাড়তি খরচ বাড়ে। জায়গা Θ(n) (বালতিগুলোর তালিকা)। বালতির ভেতরের সর্ট stable হলে, আর বালতিতে আসার ক্রমে রাখলে, পুরো bucket sort-ও stable.
Pigeonhole sort — প্রতিটি খোপে একটি তালিকা
Pigeonhole sort হলো counting sort-এর সবচেয়ে সরল আত্মীয়: key-এর পরিসর m = max − min + 1 হলে m টি খোপ নিন, প্রতিটি খোপ একটি তালিকা। প্রতিটি রেকর্ডকে তার key-এর খোপে যোগ করুন, তারপর খোপগুলো ক্রমে পড়ুন। সময় ও জায়গা Θ(n + m), stable (যদি খোপে আসার ক্রমে রাখেন)। Counting sort যেখানে শুধু সংখ্যা গোনে আর prefix sum দিয়ে ঠিকানা বের করে, pigeonhole সরাসরি রেকর্ডগুলোই খোপে রাখে — বোঝা সহজ, কিন্তু অনেক ছোট তালিকার বাড়তি মেমরি খরচ আছে। পর্ব ০৬-এর pigeonhole principle-এর সাথে নামের মিল, কাজের মিল শুধু “খোপ” ভাবনায়।
বিশেষ ক্ষেত্র: যদি জানা থাকে ইনপুট 1..n-এর একটি permutation (প্রতিটি মান ঠিক একবার), তাহলে প্রতিটি x সরাসরি ঘর x − 1-এ বসে — Θ(n) সময়ে, কোনো গোনা ছাড়াই। অংশ ১-এর পরীক্ষার খাতা সাজানো ঠিক এটিই। কনটেস্টে “1..n-এর permutation দেওয়া আছে” লাইনটি প্রায়ই এই রকম একটি shortcut-এর ইঙ্গিত।
৭ · বাস্তবে — কখন এরা std::sort-কে হারায়
Θ(n) শুনলে মনে হয় সবসময় radix sort-ই চালানো উচিত। বাস্তবে C++-এর std::sort (introsort) আর Python-এর sorted (Timsort-ভিত্তিক) — দুটোই তুলনা-সর্ট, আর বেশিরভাগ কাজে এরাই ঠিক পছন্দ। কারণগুলো দেখুন:
| অ্যালগরিদম | সময় | বাড়তি জায়গা | Stable | কখন ভালো |
|---|---|---|---|---|
| Counting sort | Θ(n + k) | Θ(n + k) | হ্যাঁ | ছোট পরিসরের পূর্ণসংখ্যা: নম্বর 0–100, বয়স, অক্ষর |
| LSD radix sort | Θ(d(n + b)) | Θ(n + b) | হ্যাঁ | অনেক (লাখ-কোটি) নির্দিষ্ট মাপের পূর্ণসংখ্যা |
| MSD radix sort | ≈ Θ(D + দল × b) | Θ(n + b · গভীরতা) | বাস্তবায়নের ওপর | string, লম্বা key |
| Bucket sort | প্রত্যাশিত Θ(n), worst Θ(n²) | Θ(n) | হ্যাঁ (ঠিকভাবে লিখলে) | জানা, সমান বণ্টনের বাস্তব সংখ্যা |
| Pigeonhole sort | Θ(n + m) | Θ(n + m) | হ্যাঁ | m ≈ n |
std::sort | O(n log n) | O(log n) | না | যেকোনো comparator, ছোট-মাঝারি n, অজানা key |
- Radix জেতে যখন: লাখ বা কোটি সংখ্যক 32/64-bit পূর্ণসংখ্যা (বা float) সাজাতে হয়, key সরল, আর বাড়তি n ঘরের মেমরি আছে। ডেটাবেস, GPU-র লাইব্রেরি (যেমন NVIDIA-র CUB) আর গ্রাফিক্সে বহু বস্তুকে গভীরতা অনুযায়ী সাজানোর মতো কাজে radix sort প্রচলিত।
- std::sort জেতে যখন: n ছোট (কয়েক হাজারের নিচে radix-এর প্রতিটি pass-এর স্থির খরচ চোখে পড়ে); key জটিল (string, struct, বহু-স্তরের comparator); মেমরি কম (radix-এর জন্য দ্বিতীয় একটি n-ঘরের অ্যারে লাগে); অথবা key-এর পরিসর বিশাল কিন্তু n ছোট।
- মেমরির খরচ: counting আর LSD radix — দুটোই আউটপুটের জন্য আলাদা n ঘর নেয়। ১০ কোটি 8-byte সংখ্যায় সেটি বাড়তি প্রায় ৮০০ MB. In-place MSD রূপ আছে (যেমন ১৯৯৩ সালে McIlroy, Bostic ও McIlroy-এর “American flag sort”), কিন্তু সেগুলো stable নয়।
- Cache: প্রতিটি pass-এ উপাদানগুলো b টি ভিন্ন জায়গায় ছড়িয়ে লেখা হয়। b খুব বড় হলে প্রতিটি লেখা আলাদা cache line-এ পড়ে, ফলে তাত্ত্বিক সুবিধা বাস্তবে হারিয়ে যায়। এজন্যই base 2⁸–2¹¹ জনপ্রিয়।
ঋণাত্মক সংখ্যা আর float
Counting sort-এ সমাধান সোজা: min বিয়োগ করে offset (অংশ ৩-এর সতর্কবার্তা)। Radix sort-এ দুটি প্রচলিত কৌশল:
- Offset: সবকিছু থেকে min বিয়োগ করে অ-ঋণাত্মক বানান, সাজান, শেষে min যোগ করুন। খেয়াল রাখুন, x − min বড় হয়ে overflow করতে পারে (integer overflow) — 32-bit int-এ min = −2×10⁹ আর x = 2×10⁹ হলে পার্থক্য int-এ ধরে না; তখন unsigned বা 64-bit ব্যবহার করুন।
- Sign bit উল্টে দেওয়া: two's complement-এ ঋণাত্মক সংখ্যার সবচেয়ে বাঁয়ের bit 1, তাই unsigned হিসেবে দেখলে সেগুলো ধনাত্মকের পরে আসে।
key = (unsigned)x ^ 0x80000000uকরলে sign bit উল্টে যায়, আর unsigned ক্রম হুবহু signed ক্রমের সমান হয়। IEEE 754 float-এর জন্য একই রকম কৌশল: ধনাত্মক হলে শুধু sign bit উল্টান, ঋণাত্মক হলে সব bit.
আরও গভীরে: Ω(n log n) সীমাটি “শুধু তুলনা” মডেলের। যে মডেলে key হলো w-bit পূর্ণসংখ্যা আর একটি word-এর ওপর গুণ, shift, AND এক ধাপে হয় (word RAM), সেখানে তুলনা-সর্টের চেয়ে দ্রুত পূর্ণসংখ্যা সাজানো প্রমাণিত: ১৯৯৫ সালে Andersson, Hagerup, Nilsson ও Raman O(n log log n) দেখান, আর ২০০২ সালে Han ও Thorup প্রত্যাশিত O(n √(log log n)). সব w-এর জন্য O(n) সম্ভব কি না — এটি এখনো একটি খোলা প্রশ্ন। ব্যবহারিক দিকে Boost.Sort-এর spreadsort radix আর তুলনা-সর্ট মিশিয়ে চলে, আর Java-র Arrays.sort বড় byte/char/short অ্যারেতে counting sort ব্যবহার করে।
পরীক্ষা ও ইন্টারভিউতে:
- তুলনা-ভিত্তিক সর্টের worst case নিম্নসীমা কত, কীভাবে প্রমাণ? — Ω(n log n); decision tree-তে n! পাতা, উচ্চতা ≥ log₂ n! = Θ(n log n).
- Counting sort-এর time আর space? — দুটোই Θ(n + k); stable, in-place নয়।
- LSD radix sort-এ ভেতরের সর্ট stable হতে হয় কেন? — সমান অঙ্কের সংখ্যাগুলোর ক্রম আগের pass-গুলোর (নিচের অঙ্কের) ফল; stable না হলে সেটি নষ্ট হয়।
- n টি d অঙ্কের base-b সংখ্যায় radix sort-এর সময়? — Θ(d(n + b)).
- Bucket sort-এর worst case কখন? — সব উপাদান এক বালতিতে পড়লে; insertion sort-সহ Θ(n²).
৮ · AI-এর যুগে রৈখিক সর্ট — “O(n)” দাবিটি যাচাই করুন
“এটি O(n) radix sort” — AI এই কথাটি খুব আত্মবিশ্বাসের সাথে বলে, অথচ প্রায়ই d আর b-কে হিসাব থেকে বাদ দেয়, অথবা k-কে ধ্রুবক ধরে নেয় যেখানে k আসলে 10⁹. আরও বিপজ্জনক ভুলগুলো কোডে লুকিয়ে থাকে: বসানোর লুপ বাঁ থেকে ডানে (stability নষ্ট — কিন্তু শুধু সংখ্যা সাজালে ফল একই দেখায়, তাই ধরা পড়ে না), ঋণাত্মক সংখ্যায় index ভুল, বা prefix sum-এ এক ঘরের off-by-one ভুল।
রৈখিক সর্টের কোডে AI সাধারণত যেসব ভুল করে
| AI-এর ভুল | কীভাবে ধরবেন |
|---|---|
| বসানোর লুপ বাঁ থেকে ডানে — সমান key-এর ক্রম উল্টে যায় | শুধু সংখ্যা নয়, (key, id) জোড়া দিয়ে চালান; সমান key-এর id ক্রম মিলিয়ে দেখুন |
Radix-এর ভেতরে unstable সর্ট (যেমন প্রতিটি অঙ্কে std::sort) | অঙ্ক মিলে যায় এমন সংখ্যা দিন (158, 186); ফল ভুল আসবে |
ঋণাত্মক সংখ্যায় cnt[x] | [-3, 2, -1] দিয়ে চালান; C++-এ sanitizer, Python-এ ভুল উত্তর |
| “O(n)” বলে k বা d বাদ দেওয়া | জিজ্ঞাসা করুন: k = 10⁹ হলে cnt-এর মাপ কত? pass কয়টি? |
| Bucket sort-কে নিঃশর্ত O(n) বলা | সব মান এক বালতিতে পড়ে এমন ইনপুটে সময় মাপুন |
Python-এ x / exp % 10 (float ভাগ) | চালালেই float index-এর TypeError; int() দিয়ে ঢাকলে 253-এর বেশি মানে অঙ্ক ভুল আসতে পারে |
AI-কে সমাধান লিখতে না বলে আপনার trace আর যুক্তি যাচাই করতে বলুন। নিচের prompt-টি সেভাবেই সাজানো:
রৈখিক সর্টের কোড যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১⌈log₂ n!⌉ কি শুধু একটি তাত্ত্বিক সীমা, নাকি সত্যিই এত কম তুলনায় সাজানো যায়?
Decision tree-এর যুক্তি বলে কমপক্ষে ⌈log₂ n!⌉ টি তুলনা লাগবে। একে প্রায়ই information-theoretic সীমা বলা হয়: n! টি সম্ভাবনার মধ্যে একটিকে চিনতে log₂ n! bit তথ্য দরকার, আর প্রতিটি তুলনা সর্বোচ্চ ১ bit দেয়। প্রশ্ন হলো, এই সীমা কি আসলে ছোঁয়া যায়? ছোট n-এর জন্য এটি একটি সত্যিকারের গবেষণার প্রশ্ন ছিল, আর উত্তর চমকপ্রদ।
n = 5 নিন। 5! = 120, log₂ 120 ≈ 6.9, তাই অন্তত ৭টি তুলনা লাগবে। Merge sort এখানে worst case-এ ৮টি নেয়। কিন্তু একটি চতুর ক্রমে তুলনা করে ঠিক ৭টিতে পাঁচটি সংখ্যা সাজানো যায় — এটি একটি প্রচলিত ধাঁধা, নিজে চেষ্টা করে দেখুন (ইঙ্গিত: প্রথমে দুটি জোড়া তুলনা করুন, তারপর দুই বিজয়ীকে)। ১৯৫৯ সালে Lester Ford Jr. আর Selmer Johnson এই ভাবনাকে সাধারণ করে “merge insertion” নামের একটি পদ্ধতি দেন, যা অনেক n-এর জন্য তথ্য-তত্ত্বের সীমার খুব কাছে বা সমান সংখ্যক তুলনা করে।
কিন্তু সীমা সবসময় ছোঁয়া যায় না। n = 12-এ ⌈log₂ 12!⌉ = 29, অথচ ১৯৬০-এর দশকে কম্পিউটার-সহায়ক বিস্তারিত অনুসন্ধানে দেখানো হয় যে ১২টি উপাদানের জন্য worst case-এ ৩০টি তুলনা লাগবেই — অর্থাৎ decision tree-এর সীমা এখানে এক কম পড়ে। পরের কিছু n-এর জন্য সঠিক উত্তর বের করতে আরও কয়েক দশক আর অনেক বেশি কম্পিউটিং লেগেছে। Donald Knuth-এর The Art of Computer Programming-এর তৃতীয় খণ্ডে এই “minimum-comparison sorting” নিয়ে বিস্তারিত আলোচনা আছে।
তাহলে বাস্তবে কেউ Ford–Johnson ব্যবহার করে না কেন? কারণ এটি তুলনার সংখ্যা কমায়, কিন্তু উপাদান সরানো আর হিসাব-নিকাশ বাড়ায়; আধুনিক কম্পিউটারে একটি পূর্ণসংখ্যার তুলনা খুবই সস্তা, cache miss আর branch misprediction অনেক বেশি দামি। তুলনা যেখানে সত্যিই দামি — যেমন মানুষকে দিয়ে দুটি জিনিসের মধ্যে পছন্দ করানো, বা দুটি বড় ফাইল মেলানো — সেখানে এই চিন্তাগুলো এখনো কাজে লাগে।
মূল উপলব্ধি: নিম্নসীমা একটি প্রতিশ্রুতি, যে “এর চেয়ে ভালো অসম্ভব”; কিন্তু সেই সীমায় পৌঁছানো যাবে এমন প্রতিশ্রুতি নয়। Θ(n log n) অসিম্পটোটিকভাবে ঠিক, কিন্তু ধ্রুবক পর্যায়ে সমস্যাটি এখনো আংশিক রহস্য।
প্র ০২পাঞ্চ কার্ডের যুগের অপারেটররা কেন LSD পদ্ধতিই বেছে নিয়েছিলেন, যেখানে MSD মানুষের কাছে বেশি স্বাভাবিক?
Hollerith-এর tabulating machine ১৮৯০ সালের মার্কিন আদমশুমারিতে তথ্য গোনার কাজ বহুগুণ দ্রুত করে দেয়, আর তাঁর কোম্পানি — যা পরে আরও কয়েকটি কোম্পানির সাথে মিশে এবং ১৯২৪ সালে IBM নাম নেয় — বিশ শতকের প্রথমার্ধ জুড়ে অফিস, ব্যাংক আর সরকারি দপ্তরে punched card যন্ত্রের সাম্রাজ্য গড়ে তোলে। এই যন্ত্রগুলোর একটি ছিল card sorter: কার্ডের একটি নির্বাচিত কলাম পড়ে প্রতিটি কার্ডকে সেই কলামের ফুটো অনুযায়ী একটি পকেটে ফেলত। একবারে শুধু একটি কলাম — এটিই হার্ডওয়্যারের সীমা।
এখন ধরুন ছয় অঙ্কের কর্মচারী নম্বর অনুযায়ী দশ হাজার কার্ড সাজাতে হবে। MSD পথে প্রথম কলাম দিয়ে ১০টি গাদা হলো। এবার প্রতিটি গাদাকে আলাদা করে দ্বিতীয় কলামে চালাতে হবে — ১০টি নতুন গাদা প্রতিটি থেকে, মোট ১০০টি, তারপর ১০০০টি… প্রতিটি গাদার পরিচয় আলাদা করে মনে রাখা, টেবিলে সাজিয়ে রাখা, ভুল গাদা মিশে না যাওয়া নিশ্চিত করা — মানুষের জন্য এটি ভুলের কারখানা। LSD পথে সবসময় পুরো গাদা একসাথে: শেষ কলাম দিয়ে চালান, পকেট 0 থেকে 9 ক্রমে তুলে একটি গাদা বানান, পরের কলামে আবার চালান। ছয়টি একই রকম পুনরাবৃত্তি, কোনো হিসাব রাখার দরকার নেই। যন্ত্রটি নিজে থেকেই stable, কারণ কার্ড পকেটে পড়ে আসার ক্রমে।
অর্থাৎ LSD বেছে নেওয়ার কারণ ছিল প্রক্রিয়ার সরলতা, অসিম্পটোটিক দক্ষতা নয়। মজার ব্যাপার, কম্পিউটারের যুগে এসেও একই কারণে LSD জনপ্রিয়: লুপ সরল, কোনো রিকার্শন নেই, প্রতিটি pass একই রকম — যা CPU আর GPU দুটোর জন্যই আদর্শ। অন্যদিকে MSD-র সুবিধা (তাড়াতাড়ি থামা, string-এ দক্ষতা) কম্পিউটারে কাজে লাগানো যায়, কারণ রিকার্শন আর গাদার হিসাব রাখা সেখানে বিনা মূল্যে।
একটি সতর্কতা: “১৮৯০ সালের আদমশুমারিতে radix sort ব্যবহৃত হয়েছিল” — এভাবে বলা ঠিক নয়। সেখানে মূল কাজ ছিল tabulation (গোনা); স্বয়ংক্রিয় বহু-কলামের card sorting আর তা দিয়ে radix পদ্ধতিতে সাজানো এসেছে পরের দশকগুলোতে।
মূল উপলব্ধি: অ্যালগরিদম অনেক সময় যন্ত্রের সীমা থেকে জন্মায়। “একবারে একটি কলাম” সীমা থেকে LSD radix sort, আর তার শুদ্ধতার চাবি — stability — যন্ত্রের স্বাভাবিক আচরণ থেকেই বিনা চেষ্টায় এসেছিল।
প্র ০৩Ω(n log n) সীমাটি কোন অনুমানের ওপর দাঁড়িয়ে — আর সেই অনুমান ভাঙলে কতদূর যাওয়া যায়?
প্রতিটি নিম্নসীমা একটি গণনার মডেল-এর ভেতরে প্রমাণিত। Decision tree মডেলের অনুমান: অ্যালগরিদম ইনপুট সম্পর্কে তথ্য পায় শুধু দুটি উপাদানের তুলনা করে, আর বাকি সব কাজ (সরানো, কপি) বিনা মূল্যে, কিন্তু কোনো তথ্য দেয় না। এই মডেলে Ω(n log n) অলঙ্ঘনীয় — শুধু worst case-এ নয়, গড়েও, এমনকি randomized অ্যালগরিদমেও (প্রত্যাশিত তুলনার সংখ্যা)।
কিন্তু বাস্তব কম্পিউটার আরও বেশি কিছু করে। Key যদি w-bit পূর্ণসংখ্যা হয়, তাহলে আমরা তার bit দেখতে পারি, তাকে index বানাতে পারি, একটি word-এ কয়েকটি ছোট key প্যাক করে একসাথে প্রক্রিয়া করতে পারি। এই “word RAM” মডেলে radix sort-ই প্রথম উদাহরণ যে সীমা ভাঙা যায় — কিন্তু তার Θ(n · w / log n) খরচ w বড় হলে আবার n log n-এর কাছে চলে আসে। প্রশ্নটি দাঁড়াল: w যা-ই হোক, n টি পূর্ণসংখ্যা কত দ্রুত সাজানো যায়?
১৯৯০-এর দশকের শুরুতে Fredman আর Willard-এর “fusion tree” প্রথম দেখায় যে সব w-এর জন্য তুলনা-সর্টের চেয়ে অসিম্পটোটিকভাবে দ্রুত সাজানো সম্ভব। ১৯৯৫ সালে Andersson, Hagerup, Nilsson আর Raman-এর “Sorting in linear time?” প্রবন্ধ O(n log log n) দেয়, আর ২০০২ সালে Yijie Han ও Mikkel Thorup প্রত্যাশিত O(n √(log log n)) সময়ের randomized অ্যালগরিদম দেন। O(n) সম্ভব কি না, তা এখনো অজানা — তাত্ত্বিক কম্পিউটার বিজ্ঞানের একটি পরিচিত খোলা প্রশ্ন।
অন্য দিকে, কিছু সম্পর্কিত সমস্যার নিম্নসীমা আরও শক্তিশালী মডেলেও টেকে। যেমন “সব উপাদান আলাদা কি না” (element distinctness) সমস্যার জন্য algebraic decision tree মডেলে — যেখানে উপাদানগুলোর ওপর যোগ-গুণের বহুপদী রাশির চিহ্ন দেখা যায় — Michael Ben-Or ১৯৮৩ সালে Ω(n log n) প্রমাণ করেন। ফলে শুধু “তুলনা নিষেধ” তুলে দিলেই সব সমস্যা সহজ হয়ে যায় না; কোন ক্ষমতা যোগ হলো, সেটিই নির্ধারক।
মূল উপলব্ধি: “অসম্ভব” সবসময় “এই নিয়মে অসম্ভব”. নিম্নসীমা পড়ার সময় তার মডেলটি খুঁজে বের করুন — নতুন অ্যালগরিদম প্রায়ই জন্মায় সেই মডেলের কোনো অনুমান ভেঙে।
প্র ০৪Radix sort যদি রৈখিক হয়, তবে C++ আর Python-এর ডিফল্ট সর্ট কেন তুলনা-সর্ট?
লাইব্রেরির ডিফল্ট সর্টকে সবার জন্য কাজ করতে হয়। std::sort একটি template — এটি int, double, string, pair, আর আপনার নিজের struct, সবই সাজায়, শুধু একটি “কম কি না” বলার উপায় পেলেই। Radix sort-এর জন্য key-কে অঙ্কে ভাঙার উপায় জানতে হয়: int-এর জন্য byte, float-এর জন্য bit-এর কৌশল, string-এর জন্য অক্ষর — আর “আগে শহর, তারপর বয়স উল্টো ক্রমে, তারপর নাম বাংলা বর্ণমালায়” এমন comparator-কে অঙ্কে ভাঙা কঠিন বা অসম্ভব। সাধারণত্বের এই দামেই তুলনা-সর্ট ডিফল্ট।
দ্বিতীয় কারণ মেমরি আর ছোট ইনপুট। Introsort বাড়তি O(log n) জায়গায় চলে; LSD radix-এর জন্য আরেকটি n-ঘরের অ্যারে লাগে। আর বাস্তবে সাজানোর বেশিরভাগ ডাক আসে ছোট অ্যারেতে — কয়েক ডজন বা কয়েকশো উপাদান — যেখানে radix-এর প্রতিটি pass-এর cnt অ্যারে শূন্য করা আর prefix sum-এর স্থির খরচ পুরো insertion sort-এর চেয়ে বেশি। তৃতীয় কারণ ইনপুটের গঠন: Python-এর Timsort আংশিক সাজানো ডেটায় (যা বাস্তবে খুব সাধারণ) প্রায় রৈখিক সময়ে চলে, radix সেই সুবিধা পায় না।
তবু রৈখিক সর্ট লাইব্রেরিতে লুকিয়ে আছে। Java-র Arrays.sort বড় byte, char আর short অ্যারের জন্য counting sort ব্যবহার করে — key-এর পরিসর মাত্র 2⁸ বা 2¹⁶, তাই এটি স্বাভাবিক পছন্দ। Boost.Sort-এর spreadsort পূর্ণসংখ্যা, float আর string-এ radix-ধাঁচের ভাগাভাগি আর তুলনা-সর্ট মিশিয়ে চলে। GPU-তে হাজার হাজার thread একসাথে কাজ করে, সেখানে radix sort-এর সরল, পূর্বানুমেয় pass খুব ভালো মেলে, তাই NVIDIA-র CUB-এর মতো লাইব্রেরির সংখ্যা-সর্টের কেন্দ্রে radix sort. ডেটাবেস আর বড় ডেটা প্রক্রিয়াকরণ ব্যবস্থাও স্থির-দৈর্ঘ্যের key-এ প্রায়ই radix-ধাঁচের partitioning ব্যবহার করে।
কনটেস্টে কী করবেন? প্রায় সবসময় sort — n = 2×10⁵-এ এটি কয়েক মিলিসেকেন্ড। Counting sort তখনই, যখন মান ছোট (যেমন ≤ 10⁶) আর আপনার আসলে গোনাই দরকার, অথবা n এত বড় (10⁷) যে log n-এর গুণকও সময়সীমায় চাপ দেয়।
মূল উপলব্ধি: অসিম্পটোটিক জয় আর ব্যবহারিক জয় এক নয়। লাইব্রেরি ডিজাইনার সাধারণত্ব, মেমরি আর সাধারণ ইনপুটের গড় আচরণ বিবেচনা করেন; রৈখিক সর্ট সেখানে বিশেষজ্ঞের যন্ত্র, সবার জন্য ডিফল্ট নয়।
প্র ০৫বাস্তব ডেটা প্রায় কখনো সমভাবে ছড়ানো নয় — তাহলে bucket sort-এর ভাবনা বড় সিস্টেমে কীভাবে কাজে লাগে?
Bucket sort-এর প্রত্যাশিত Θ(n) প্রমাণে একটিই শক্ত অনুমান: প্রতিটি উপাদান সমান সম্ভাবনায় যেকোনো বালতিতে পড়ে। বাস্তব ডেটা এমন হয় না। দেশের মানুষের আয়, শহরের জনসংখ্যা, ওয়েবসাইটে ভিজিটর — এরা সাধারণত একদিকে ঘন, অন্যদিকে লম্বা লেজের মতো ছড়ানো। সমান চওড়ার বালতি দিলে কয়েকটি বালতি উপচে পড়বে, বাকিগুলো খালি থাকবে, আর সময় Θ(n²)-এর দিকে চলে যাবে।
সমাধান হলো বালতির সীমানা ডেটা দেখে ঠিক করা। ইনপুট থেকে কিছু উপাদান এলোমেলোভাবে নমুনা নিন, নমুনাটি সাজান, আর তার থেকে সমান দূরত্বের কয়েকটি মান (quantile) বেছে নিন — এগুলোই বালতির সীমানা। এখন প্রতিটি বালতিতে মোটামুটি সমান সংখ্যক উপাদান পড়বে, ডেটার বণ্টন যেমনই হোক। এই পদ্ধতির নাম sample sort, যা ১৯৭০ সালে W. D. Frazer আর A. C. McKellar-এর একটি প্রবন্ধে প্রস্তাবিত হয়। উপাদানকে বালতিতে পাঠাতে তখন ⌊n·x⌋-এর বদলে সীমানাগুলোর ওপর binary search লাগে (পর্ব ১৮)।
বড় পরিসরে এই ভাবনাই distributed sorting-এর মেরুদণ্ড। ধরুন কয়েকশো কম্পিউটারে ছড়ানো কয়েক terabyte ডেটা সাজাতে হবে। প্রতিটি কম্পিউটার একটি বালতি: নমুনা থেকে সীমানা ঠিক করে প্রতিটি রেকর্ড সঠিক কম্পিউটারে পাঠানো হয়, প্রতিটি কম্পিউটার নিজের অংশ আলাদাভাবে সাজায়, আর ফলগুলো ক্রমে জুড়লেই পুরোটা সাজানো — ঠিক bucket sort-এর ধাপ ৪. Hadoop-এর বহুল আলোচিত TeraSort benchmark এভাবেই নমুনা থেকে partition-এর সীমানা বেছে নেয়। ডিস্কে থাকা ডেটা সাজানোর external sorting-এও “আগে ভাগ, পরে প্রতিটি অংশ” কৌশল প্রচলিত।
আরেকটি শিক্ষা হলো অনুমানের ঝুঁকি। ইনপুট যদি কোনো প্রতিপক্ষ বেছে দেয় (যেমন একটি পাবলিক সার্ভারে আক্রমণকারী), সে ইচ্ছা করে সব মান একটি বালতিতে ফেলতে পারে — পর্ব ০৫-এর adversarial input. নমুনা এলোমেলোভাবে নিলে সেই আক্রমণ কঠিন হয়ে যায়, কারণ আক্রমণকারী আগে থেকে সীমানা জানে না।
মূল উপলব্ধি: Bucket sort-এর আসল শিক্ষা “সমান বণ্টন” নয়, “ভাগ করো যাতে প্রতিটি ভাগ ছোট আর স্বাধীন হয়”. বণ্টন জানা না থাকলে নমুনা থেকে শিখে নিন — একক কম্পিউটার থেকে হাজার কম্পিউটারের ক্লাস্টার পর্যন্ত একই নীতি।
অনুশীলন
খাতা-কলম নিন। প্রথম দুটি হাতে trace করার, তৃতীয়টি একটি ছোট প্রমাণ, শেষটিতে কোড লিখতে হবে।
-
হাতে trace করুন — counting sort: key 0..3-এর ইনপুট
[3a, 1a, 3b, 0, 2, 1b]. গোনার পর cnt, prefix sum-এর পর cnt, আর ডান থেকে বাঁয়ে বসানোর প্রতিটি ধাপে কোন উপাদান কোন ঘরে যায় লিখুন। শেষে 3a আর 3b-এর ক্রম কী?গোনা: cnt = 1 2 1 2 (0 একবার, 1 দুবার, 2 একবার, 3 দুবার)। Prefix: cnt = 1 3 4 6.
ডান থেকে: 1b → cnt[1] = 2, out[2]; 2 → cnt[2] = 3, out[3]; 0 → cnt[0] = 0, out[0]; 3b → cnt[3] = 5, out[5]; 1a → cnt[1] = 1, out[1]; 3a → cnt[3] = 4, out[4].
ফল: 0 1a 1b 2 3a 3b. 3a ইনপুটে 3b-এর আগে ছিল, আউটপুটেও আগে — stable. বাঁ থেকে বসালে পেতেন 0 1b 1a 2 3b 3a — সাজানো, কিন্তু সমান key-এর ক্রম উল্টো।
-
হাতে trace করুন — LSD radix sort:
[91, 406, 73, 250, 64, 138]-কে base 10-এ সাজান। প্রতিটি pass-এর পর অ্যারে লিখুন (তিন অঙ্কে, যেমন 091). কোন pass-এ stability প্রথমবার আসলে কাজে লাগে?একক অঙ্কের pass: 250 091 073 064 406 138 (অঙ্ক 0, 1, 3, 4, 6, 8).
দশক অঙ্কের pass: 406 138 250 064 073 091 (অঙ্ক 0, 3, 5, 6, 7, 9).
শতক অঙ্কের pass: 064 073 091 138 250 406.
প্রথম দুই pass-এ কোনো দুটি সংখ্যার অঙ্ক সমান হয়নি, তাই stability-র প্রশ্নই ওঠেনি। শতকের pass-এ 064, 073, 091 তিনজনেরই অঙ্ক 0 — এখানে stable সর্ট আগের pass-এর ক্রম (64 < 73 < 91) রেখে দিয়েছে বলেই ফল ঠিক।
-
ছোট প্রমাণ: দেখান যে কোনো তুলনা-সর্ট n! টি ইনপুটের অর্ধেকেও O(n) সংখ্যক তুলনায় শেষ করতে পারে না (যথেষ্ট বড় n-এর জন্য)। ইঙ্গিত: decision tree-তে গভীরতা ≤ cn-এর পাতা সর্বোচ্চ কয়টি?
ধরুন n!/2 টি ইনপুটে অ্যালগরিদম ≤ cn তুলনায় শেষ করে। প্রতিটি ইনপুট আলাদা পাতায় যায়, তাই গভীরতা ≤ cn-এ অন্তত n!/2 টি পাতা থাকতে হবে। কিন্তু একটি binary tree-তে গভীরতা ≤ h-এর পাতা সর্বোচ্চ 2ʰ টি। তাই 2cn ≥ n!/2, অর্থাৎ cn ≥ log₂ n! − 1 = Θ(n log n).
যেকোনো ধ্রুবক c-এর জন্য বড় n-এ ডানপাশ বাঁপাশকে ছাড়িয়ে যায় — বিরোধ। একই যুক্তিতে 1/n বা এমনকি 1/2ⁿ অংশ ইনপুটের জন্যও O(n) অসম্ভব, কারণ log₂(n!/2ⁿ) = log₂ n! − n এখনো Θ(n log n).
-
কোড লিখুন: একটি দোকানের দৈনিক লাভ-ক্ষতির হিসাব (টাকায়, −500 থেকে 500) দেওয়া আছে, যেমন
[120, -35, 0, -500, 77, -35, 500]. ঋণাত্মক মানসহ কাজ করে এমন একটি counting sort লিখুন যা Θ(n + (max − min)) সময়ে চলে।মূল কৌশল: min বিয়োগ করে index বানানো। শুধু সংখ্যা সাজাচ্ছি (satellite data নেই), তাই গুনে আবার লিখে দেওয়াই যথেষ্ট:
void countingSortSigned(vector<int>& a) { if (a.empty()) return; auto [lo, hi] = minmax_element(a.begin(), a.end()); int mn = *lo, k = *hi - *lo; // range size - 1 vector<int> cnt(k + 1, 0); for (int x : a) cnt[x - mn]++; // shift: smallest value -> index 0 int i = 0; for (int v = 0; v <= k; ++v) while (cnt[v]--) a[i++] = v + mn; // write each value cnt times } // [120, -35, 0, -500, 77, -35, 500] -> [-500, -35, -35, 0, 77, 120, 500]এখানে k = 500 − (−500) = 1000, তাই cnt-এ 1001 ঘর। সময় Θ(n + k), জায়গা Θ(k). Python-এ একই কাঠামো:
mn = min(a),cnt[x - mn] += 1, তারপরout.extend([v + mn] * c). রেকর্ড (যেমন তারিখসহ) সাজাতে হলে prefix sum আর ডান থেকে বসানোর stable রূপটি ব্যবহার করুন, শুধু index-এx - mn. খালি অ্যারের যাচাই না রাখলেminmax_elementend iterator দেয়, আর তাকে dereference করা undefined behavior.
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমLSD radix sort-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- সবচেয়ে বড় key দেখে অঙ্কের সংখ্যা d ঠিক করা
- সবচেয়ে ডানের (একক) অঙ্ক ধরে পুরো অ্যারেতে stable counting sort চালানো
- দশকের অঙ্ক ধরে আবার পুরো অ্যারেতে stable counting sort
- এভাবে বাঁ দিকের প্রতিটি পরের অঙ্কে একটি করে pass চালানো
- সবচেয়ে বাঁয়ের অঙ্কের pass শেষে সাজানো অ্যারে পাওয়া
শুধু দুটি উপাদানের তুলনা করে তথ্য পায় এমন যেকোনো সর্টিং অ্যালগরিদম n টি আলাদা উপাদান সাজাতে worst case-এ অন্তত কতগুলো তুলনা করে?
- Ω(n)
- Ω(n log n)
- Ω(n²)
- Ω(log n)
৪টি আলাদা সংখ্যা সাজাতে যেকোনো তুলনা-ভিত্তিক অ্যালগরিদমের worst case-এ ন্যূনতম কতটি তুলনা লাগে?
- 4
- 5
- 6
- 24
১০ লক্ষ (10⁶) টি পূর্ণসংখ্যা, প্রতিটি 0 থেকে 10⁹-এর মধ্যে। সরাসরি counting sort চালালে প্রধান সমস্যা কী?
- তুলনার সংখ্যা n log n ছাড়িয়ে যায়
- বড় key-এ counting sort আর stable থাকে না
- cnt অ্যারের মাপ ও সময় k = 10⁹-এর ওপর নির্ভর করে — মেমরি আর সময় দুটোই অসহনীয়
- ফল ভুল আসে
অ্যারে [25, 13, 31, 22]-এ LSD radix sort (base 10)-এর প্রথম pass, অর্থাৎ একক অঙ্ক অনুযায়ী stable সর্টের পর অ্যারে কেমন হবে?
[31, 22, 13, 25][13, 22, 25, 31][13, 25, 22, 31][25, 13, 22, 31]
LSD radix sort-এ প্রতিটি অঙ্কের জন্য ব্যবহৃত সর্টটি stable হওয়া কেন জরুরি?
- Stable না হলে প্রতিটি pass Θ(n log n) সময় নেয়
- Stable না হলে বাড়তি মেমরি লাগে
- Stable না হলে ঋণাত্মক সংখ্যা সামলানো যায় না
- বর্তমান অঙ্ক সমান এমন সংখ্যাগুলোর মধ্যে আগের pass-এর (নিচের অঙ্কের) ক্রম রক্ষা করতে
n টি 32-bit অ-ঋণাত্মক পূর্ণসংখ্যা base 2¹⁶ (প্রতিটি অঙ্ক 16 bit) ধরে LSD radix sort করলে সময় কত?
- Θ(32 · (n + 2))
- Θ(n log n)
- Θ(2 · (n + 65536))
- Θ(n + 2³²)
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- তুলনা-সর্ট = decision tree; n! পাতা ⇒ উচ্চতা ≥ log₂ n! = Ω(n log n). Merge sort আর heapsort তাই সর্বোত্তম।
- সীমা ভাঙতে key-এর গঠন লাগে: ছোট পরিসর, নির্দিষ্ট অঙ্ক, বা জানা বণ্টন — key-কে সরাসরি index বানানো হয়।
- Counting sort: গোনা → prefix sum → ডান থেকে বাঁয়ে বসানো; Θ(n + k), stable; k বড় বা ঋণাত্মক হলে সাবধান।
- LSD radix sort: ডানের অঙ্ক থেকে প্রতিটি অঙ্কে stable counting sort; Θ(d(n + b)); 32-bit-এ base 256 মানে ৪ pass.
- Bucket sort প্রত্যাশিত Θ(n) শুধু সমান বণ্টনে, worst Θ(n²); বাস্তবে ডিফল্ট
std::sort, রৈখিক সর্ট বিশেষ ক্ষেত্রের যন্ত্র।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ২৫ · বাস্তবে সর্টিং — stable sort, comparator আর লাইব্রেরিপরবর্তী পর্বলাইব্রেরি সর্ট, comparator-এর নিয়ম আর বহু-key সর্টিং।
- ডেটা স্ট্রাকচার ও অ্যালগরিদম · কোর্স হোম৭২টি পর্বপুরো কোর্সের রোডম্যাপ — নয়টি মডিউল।
- পর্ব ১৭ · Prefix sum আর difference arrayআগের ভিত্তিCounting sort-এর ঠিকানা বের করার prefix sum এখান থেকে।
- পর্ব ০৬ · DSA-র গণিত — log, যোগফল আর ধারাআগের ভিত্তিlog n! = Θ(n log n) আর Stirling-এর সূত্র।
- C++ প্রোগ্রামিং · STL algorithmপাশের ট্র্যাক
sort,stable_sortআর অন্যান্য তৈরি অ্যালগরিদম। - Python প্রোগ্রামিং · Listপাশের ট্র্যাক
sort,sortedআরkeyফাংশন।