এই পর্বে যা শিখবেন
- Two pointer দিয়ে দুটি সাজানো অংশ merge করা, আর loop invariant দিয়ে তার শুদ্ধতা ও stability প্রমাণ করা
- পুরো রিকার্সিভ অ্যালগরিদম হাতে trace করা, আর recursion tree থেকে Θ(n log n) ও তুলনার নিখুঁত সংখ্যা বের করা
- Θ(n) buffer আর Θ(log n) গভীরতা — জায়গার হিসাব, আর কোড ধীর হওয়ার সাধারণ কারণগুলো চেনা
- Bottom-up merge sort, linked list-এর merge sort আর RAM-এর চেয়ে বড় ডেটার external merge sort-এর নকশা বোঝা
- Merge sort-এর ভেতরে inversion গুনে দুটি র্যাঙ্কিং তুলনা করা, আর brute force দিয়ে উত্তর যাচাই করা
১ · আবার divide and conquer — পরীক্ষার খাতা সাজানোর গল্প
একটি কলেজের পরীক্ষা শেষ। ৪০০টি খাতা রোল নম্বর অনুযায়ী সাজাতে হবে, আর হাতে আছে আটজন সহকারী। একজনকে পুরো স্তূপ দিলে তিনি পর্ব ২০-এর insertion sort-এর মতো একটি একটি খাতা নিয়ে ঠিক জায়গায় ঢোকাবেন — শেষের দিকে প্রতিটি খাতার জন্য শত শত খাতা পেরোতে হবে। চালাক পরীক্ষা-নিয়ন্ত্রক অন্যভাবে ভাবেন: স্তূপটি আট ভাগ করে আটজনকে দিন। প্রত্যেকে নিজের ৫০টি খাতা সাজাবেন। তারপর দুজন দুজন করে দুটি সাজানো স্তূপ জোড়া দেবেন — দুটি স্তূপের ওপরের খাতা দেখে ছোট রোলেরটি নতুন স্তূপে রাখা, বারবার। চারটি স্তূপ হবে, তারপর দুটি, তারপর একটি।
এই চিন্তার নাম divide and conquer, যা পর্ব ০৮ আর ০৯-এ recurrence হিসেবে দেখেছেন। তিনটি ধাপ:
- ভাগ (divide): সমস্যাকে ছোট উপ-সমস্যায় ভাঙা — অ্যারের বাঁ অর্ধেক আর ডান অর্ধেক।
- জয় (conquer): উপ-সমস্যাগুলো একই পদ্ধতিতে, রিকার্শন দিয়ে সমাধান করা। খুব ছোট হলে (০ বা ১টি উপাদান) সরাসরি উত্তর — এটিই base case.
- জোড়া (combine): ছোট উত্তরগুলো মিলিয়ে বড় উত্তর বানানো — দুটি সাজানো অর্ধেক merge করা।
Merge sort-এর বিশেষত্ব হলো, ভাগ করা প্রায় বিনা খরচে হয় (শুধু মাঝখানের index বের করা), আর সব বুদ্ধি থাকে জোড়া দেওয়ার ধাপে। পর্ব ২২-এর quick sort ঠিক উল্টো: সেখানে ভাগ করাতেই সব কাজ, জোড়া দিতে কিছুই লাগে না। এই দুটি অ্যালগরিদম পাশাপাশি রাখলে divide and conquer-এর দুটি মুখ পরিষ্কার দেখা যায়।
| বিষয় | Insertion sort (পর্ব ২০) | Merge sort (এই পর্ব) |
|---|---|---|
| Worst case সময় | Θ(n²) | Θ(n log n) |
| Best case সময় | Θ(n) (আগে থেকে সাজানো) | Θ(n log n) (সাধারণ রূপে) |
| বাড়তি জায়গা | Θ(1) — in-place | Θ(n) buffer + Θ(log n) stack |
| Stable? | হ্যাঁ | হ্যাঁ (সমান হলে বাঁ দিক থেকে নিলে) |
| n = 10৬ হলে মোটামুটি তুলনা | worst case-এ প্রায় ৫ × ১০১১ | প্রায় ২ × ১০৭ |
শেষ সারির পার্থক্যটি দেখুন: দশ লাখ সংখ্যায় প্রায় পঁচিশ হাজার গুণ কম কাজ। এক সেকেন্ডে কয়েক কোটি সরল কাজ করতে পারে এমন কম্পিউটারে insertion sort-এর worst case লাগবে কয়েক ঘণ্টা, merge sort-এর এক সেকেন্ডেরও কম। Big-O-র ভাষা (পর্ব ০৪) এখানে শুধু তত্ত্ব নয় — সময়সীমার ভেতরে উত্তর আসবে কি না, তার পার্থক্য।
ইতিহাস: Merge sort কম্পিউটারের চেয়েও পুরোনো চিন্তা — punched card যুগের “collator” যন্ত্র দুটি সাজানো কার্ডের গাদা জোড়া দিত। আর stored-program কম্পিউটারের জন্য লেখা প্রথম দিকের প্রোগ্রামগুলোর একটি ছিল merge-ভিত্তিক সর্টিং: John von Neumann ১৯৪৫ সালে EDVAC কম্পিউটারের জন্য এটি হাতে লিখেছিলেন, যন্ত্রটি তৈরি হওয়ার আগেই। পরে Donald Knuth ১৯৭০ সালে “Von Neumann’s First Computer Program” নামের প্রবন্ধে সেই পাণ্ডুলিপি বিশ্লেষণ করেন। এই গল্পের বাকিটা ভাবনার প্রশ্ন ১-এ।
২ · Merge — দুটি সাজানো সারি এক সারিতে
ধরুন ব্যাংকের দুটি কাউন্টারের সামনে দুটি সারি, প্রতিটি সারি টোকেন নম্বর অনুযায়ী আগে থেকেই সাজানো। এখন দুটি কাউন্টার এক করে একটিই সারি বানাতে হবে, নম্বরের ক্রম ঠিক রেখে। উপায় সহজ: দুই সারির সামনের দুজনের টোকেন দেখুন, ছোট নম্বরের জনকে নতুন সারিতে পাঠান। তাঁর সারিতে পরের জন সামনে এলেন। আবার তুলনা। একটি সারি ফুরিয়ে গেলে অন্য সারির বাকি সবাই একসাথে চলে যাবেন — তাঁরা তো আগে থেকেই সাজানো।
কোডে দুই সারির সামনের জনকে চিহ্নিত করে দুটি index: বাঁ অংশে i, ডান অংশে j — পর্ব ১৬-এর two pointer কৌশল। আমরা half-open রেঞ্জ ব্যবহার করব: বাঁ অংশ a[lo..mid), ডান অংশ a[mid..hi) — শুরু অন্তর্ভুক্ত, শেষ নয়।
Pseudocode — merge a[lo..mid) and a[mid..hi), both already sorted (0-based)
i = lo; j = mid; k = lo
while i < mid and j < hi:
if a[i] <= a[j]: // "<=" : on a tie take the LEFT one (stability)
buf[k] = a[i]; i = i + 1
else:
buf[k] = a[j]; j = j + 1
k = k + 1
copy the rest of a[i..mid) to buf[k..] // at most one of these two is non-empty
copy the rest of a[j..hi) to buf[k..]
copy buf[lo..hi) back to a[lo..hi)এবার বাঁ অংশ L = [2, 4, 5, 7] আর ডান অংশ R = [1, 2, 3, 6] merge করে দেখুন। দুটি 2 আছে — কোনটি কোন দিক থেকে এল, তা চিনতে লিখছি 2L আর 2R.
| তুলনা | |
| নেওয়া হলো | |
| buf (নতুন সারি) | |
| মোট তুলনা |
- শুরু. i আর j দুই অংশের প্রথম ঘরে। প্রতিটি অংশ নিজের ভেতরে সাজানো, তাই সবচেয়ে ছোট উপাদানটি অবশ্যই এই দুই প্রার্থীর একজন।
- 2 বনাম 1. ডানেরটি ছোট — 1 গেল buf-এ, j এক ঘর এগোল। একটি তুলনায় একটি উপাদান জায়গা পেল।
- 2 বনাম 2 — সমান! এখানেই stability-র সিদ্ধান্ত। শর্ত
a[i] <= a[j]সত্য, তাই নেওয়া হলো বাঁ দিকের 2 (2L). মূল অ্যারেতে 2L ছিল 2R-এর আগে, buf-এও আগে থাকল। - 4 বনাম 2. 2R ছোট, গেল buf-এ। এখন buf: 1 2L 2R — দুটি 2-এর আপেক্ষিক ক্রম অটুট।
- 4 বনাম 3. 3 নেওয়া হলো। ডান অংশ থেকে পরপর তিনটি উপাদান গেল — কোন দিক থেকে কতগুলো যাবে, তার কোনো বাঁধা নিয়ম নেই।
- 4 বনাম 6. এবার বাঁ দিকের 4 ছোট। i এগোল।
- 5 বনাম 6. 5 নেওয়া হলো। বাঁ অংশে বাকি শুধু 7.
- 7 বনাম 6. 6 নেওয়া হলো, আর ডান অংশ ফুরিয়ে গেল (j = শেষ)। মোট তুলনা ৭টি।
- বাকিটা কপি. বাঁ অংশের বাকি 7 তুলনা ছাড়াই buf-এ গেল — সে তো buf-এর সবার চেয়ে বড় বা সমান। ফল: 1 2 2 3 4 5 6 7, ৮টি উপাদানে ৭টি তুলনা — এটিই সর্বোচ্চ, m + n − 1.
কেন merge সঠিক — loop invariant
পর্ব ০৩-এর loop invariant পদ্ধতিতে প্রমাণ করি। দাবি: while লুপের প্রতিটি ঘূর্ণনের শুরুতে
- (ক)
buf[lo..k)-এ আছে L আর R মিলিয়ে সবচেয়ে ছোট k − lo টি উপাদান, সাজানো ক্রমে; - (খ)
a[i]হলো L-এর বাকি উপাদানগুলোর মধ্যে সবচেয়ে ছোট, আরa[j]R-এর বাকিগুলোর মধ্যে সবচেয়ে ছোট (যদি বাকি থাকে)।
শুরুতে (initialization): k = lo, buf-এর অংশটি খালি — (ক) ফাঁকা অর্থে সত্য। i = lo আর j = mid দুই সাজানো অংশের প্রথম ঘর, তাই (খ) সত্য।
প্রতি ধাপে বজায় থাকে (maintenance): ধরুন a[i] <= a[j]. বাকি সব উপাদান হয় L-এর বাকি অংশে (সবাই ≥ a[i], কারণ L সাজানো), নয়তো R-এর বাকি অংশে (সবাই ≥ a[j] ≥ a[i]). তাই a[i]-ই বাকিদের মধ্যে সবচেয়ে ছোট। আর (ক) অনুযায়ী buf-এ আগে থেকে থাকা প্রতিটি উপাদান বাকিদের যেকোনোটির চেয়ে বড় নয় — তাই a[i]-কে buf-এর শেষে বসালে buf সাজানোই থাকে। i আর k এক বাড়লে (ক) আর (খ) আবার সত্য। অন্য ক্ষেত্রটি (a[j] ছোট) একইভাবে।
শেষে (termination): প্রতি ঘূর্ণনে i বা j এক বাড়ে, তাই সর্বোচ্চ (mid − lo) + (hi − mid) ঘূর্ণনে লুপ থামে। থামার সময় একটি অংশ খালি; অন্য অংশের বাকি উপাদানগুলো নিজেরা সাজানো আর (ক) অনুযায়ী buf-এর সবার চেয়ে বড় বা সমান — তাই সরাসরি কপি করলে পুরো buf সাজানো। প্রমাণ শেষ।
খরচ: প্রতিটি উপাদান ঠিক একবার buf-এ যায় আর একবার ফেরত আসে, তাই m + n উপাদানের merge-এ সময় Θ(m + n). তুলনার সংখ্যা সবচেয়ে কম min(m, n) (যখন ছোট অংশের সবাই অন্যটির প্রথম উপাদানের চেয়ে ছোট, যেমন [1 2] আর [8 9]: তুলনা ২টি, তারপর বাকিটা কপি), আর সবচেয়ে বেশি m + n − 1 (যখন দুই অংশ একে অপরের ফাঁকে ফাঁকে বসে, যেমন ওপরের উদাহরণ — শেষ উপাদানটি ছাড়া প্রতিটির জন্য একটি তুলনা)।
Stability — কেন “<=”, “<” নয়
Stable sort মানে সমান key-এর উপাদানগুলোর আগের আপেক্ষিক ক্রম বদলায় না। কেন দরকার? ধরুন bKash-এর লেনদেনের তালিকা আগে সময় অনুযায়ী সাজানো, এখন আপনি সেটি টাকার অঙ্ক অনুযায়ী সাজাবেন। Stable sort হলে একই অঙ্কের লেনদেনগুলো সময়ের ক্রমেই থাকবে — দুই ধাপে দুই key-তে সাজানো সম্ভব হয় (পর্ব ২৫-এ বিস্তারিত)।
প্রমাণ (induction): দাবি — merge sort যেকোনো অংশ সাজানোর পর সমান key-এর উপাদানগুলো মূল ক্রমেই থাকে। ০ বা ১টি উপাদানে এটি স্পষ্ট। বড় অংশে, রিকার্শনের পর (inductive hypothesis) প্রতিটি অর্ধেকের ভেতরে সমান উপাদানগুলো মূল ক্রমে আছে। এখন merge দেখুন। একই অর্ধেকের দুটি সমান উপাদান সেই অর্ধেকের ক্রমেই বের হয়, কারণ pointer শুধু সামনে এগোয়। আর দুই অর্ধেকের দুটি সমান উপাদান x (বাঁ থেকে) আর y (ডান থেকে) — মূল অ্যারেতে x ছিল y-এর আগে, কারণ পুরো বাঁ অর্ধেক ডান অর্ধেকের আগে। ধরুন উল্টোটা ঘটল, y আগে বের হলো। সেই মুহূর্তে বাঁ দিকের প্রার্থী a[i] হয় x নিজে, নয়তো x-এর আগের কোনো উপাদান — বাঁ অংশ সাজানো বলে দুই ক্ষেত্রেই a[i] ≤ x = y. কিন্তু ডান থেকে y নেওয়া হয় কেবল তখন, যখন a[i] <= y মিথ্যা, অর্থাৎ a[i] > y — স্ববিরোধ। তাই x সবসময় y-এর আগে বের হয়।
এক অক্ষরের ফাঁদ। শর্তটি a[i] < a[j] লিখলে সমান হলে ডান দিকেরটি আগে যায় — অ্যারে তবুও সাজানো হয়, কিন্তু stable থাকে না। শুধু সংখ্যা সাজালে চোখেই পড়বে না; কিন্তু (নাম, নম্বর) জোড়া নম্বর দিয়ে সাজালে একই নম্বরের নামগুলো এলোমেলো হয়ে যাবে। Inversion গোনার সময় (অংশ ৯) এই একই ভুল সমান জোড়াকে inversion হিসেবে গুনে ফেলে — উত্তর ভুল আসে।
৩ · পুরো অ্যালগরিদম — ভাগ, রিকার্শন, জোড়া
Merge হাতে থাকলে বাকি অ্যালগরিদম তিন লাইনের:
Pseudocode — merge sort on a[lo..hi) (0-based, half-open)
mergeSort(a, lo, hi):
if hi - lo <= 1: return // 0 or 1 element: already sorted
mid = lo + (hi - lo) / 2 // integer division
mergeSort(a, lo, mid) // sort the left half
mergeSort(a, mid, hi) // sort the right half
merge(a, lo, mid, hi) // join the two sorted halves (section 2)এর শুদ্ধতা strong induction দিয়ে এক অনুচ্ছেদে: n ≤ 1 হলে অংশটি এমনিতেই সাজানো। n ≥ 2 হলে দুই অর্ধেকের আকার n-এর চেয়ে কঠোরভাবে ছোট (⌊n/2⌋ আর ⌈n/2⌉, দুটোই ≥ ১), তাই inductive hypothesis অনুযায়ী রিকার্সিভ ডাক দুটি সেগুলো সঠিকভাবে সাজায়; তারপর অংশ ২-এ প্রমাণিত merge দুটি সাজানো অংশ থেকে একটি সাজানো অংশ বানায়। আকার প্রতিবার কমে বলে রিকার্শন থামেও।
মাঝখান বের করার দুটি ফাঁদ। (১) closed রেঞ্জ [lo, hi] নিয়ে mergeSort(lo, mid) আর mergeSort(mid, hi) লিখলে দুই উপাদানের অংশে mid = lo হয়ে একই রেঞ্জ বারবার ডাকা হয় — অসীম রিকার্শন আর stack overflow. Half-open রেঞ্জে hi - lo <= 1 base case থাকলে এটি ঘটে না। (২) খুব বড় অ্যারেতে (lo + hi) / 2 int-এর সীমা পেরোতে পারে (integer overflow); lo + (hi - lo) / 2 নিরাপদ। Java-র লাইব্রেরির binary search-এ ঠিক এই ভুলটি বহু বছর লুকিয়ে ছিল (পর্ব ১৮)।
এবার ৮টি উপাদানের [5, 2, 4, 7, 1, 3, 2, 6]-এ পুরো recursion tree দেখুন। আসলে রিকার্শন গভীরতা-আগে (depth-first) চলে — বাঁ দিকের পুরো শাখা শেষ করে তবে ডান দিকে যায় — কিন্তু বিশ্লেষণের জন্য স্তর ধরে দেখাই সুবিধাজনক। d = গভীরতা (depth).
| পর্যায় | |
| এই স্তরের merge-এ তুলনা | |
| এই স্তরে কপি হওয়া উপাদান | |
| মোট তুলনা |
- শুরু. ৮টি উপাদান, এলোমেলো। একটিই ডাক: mergeSort(0, 8).
- প্রথম ভাগ. mid = 4. দুটি ডাক, প্রতিটি ৪টি উপাদানের। ভাগ করতে কোনো তুলনা লাগেনি — শুধু একটি index হিসাব।
- দ্বিতীয় ভাগ. চারটি অংশ, প্রতিটি ২টির।
- তৃতীয় ভাগ — তলায় পৌঁছানো. ৮টি অংশ, প্রতিটিতে ১টি উপাদান — base case, এরা “এমনিতেই সাজানো”. lg 8 = 3 বার ভাগ করে তলায় এলাম।
- প্রথম স্তরের merge. চারটি জোড়া: [5]+[2] → [2 5], [4]+[7] → [4 7], [1]+[3] → [1 3], [2]+[6] → [2 6]. প্রতিটিতে ১টি তুলনা, মোট ৪। কিন্তু উপাদান নড়েছে ৮টিই।
- দ্বিতীয় স্তরের merge. [2 5]+[4 7] → [2 4 5 7] (৩টি তুলনা), [1 3]+[2 6] → [1 2 3 6] (৩টি)। এই স্তরে ৬টি তুলনা, মোট ১০।
- শেষ merge. অংশ ২-এর ডায়াগ্রামটিই: ৭টি তুলনা। ফল [1 2 2 3 4 5 6 7], মোট তুলনা ১৭. প্রতিটি merge-স্তরে ঠিক ৮টি উপাদান কপি হয়েছে, স্তর ৩টি — মোট কাজ মোটামুটি 8 × 3. এটিই n lg n-এর চেহারা।
এবার কোডে চালিয়ে দেখুন। প্রোগ্রামটি প্রতিটি merge ছাপায়, গভীরতা অনুযায়ী ডানে সরিয়ে — ফলে আসল (depth-first) ক্রমটিও দেখা যাবে। একটি মাত্র buf অ্যারে শুরুতে একবার বানানো হয়েছে, সব ডাক সেটিই ভাগ করে ব্যবহার করে:
#include <iostream>
#include <string>
#include <vector>
using namespace std;
long long comps = 0;
string show(const vector<int>& a, int lo, int hi) {
string s = "[";
for (int i = lo; i < hi; ++i) s += (i > lo ? " " : "") + to_string(a[i]);
return s + "]";
}
void mergeSort(vector<int>& a, vector<int>& buf, int lo, int hi, int depth, bool verbose) {
if (hi - lo <= 1) return; // 0 or 1 element: already sorted
int mid = lo + (hi - lo) / 2;
mergeSort(a, buf, lo, mid, depth + 1, verbose);
mergeSort(a, buf, mid, hi, depth + 1, verbose);
string before = string(2 * depth, ' ') + "merge " + show(a, lo, mid) + " + " + show(a, mid, hi);
int i = lo, j = mid, k = lo;
while (i < mid && j < hi) {
++comps;
if (a[i] <= a[j]) buf[k++] = a[i++]; // <= : take from the left on ties (stable)
else buf[k++] = a[j++];
}
while (i < mid) buf[k++] = a[i++]; // copy whatever is left (one part is empty)
while (j < hi) buf[k++] = a[j++];
for (k = lo; k < hi; ++k) a[k] = buf[k];
if (verbose) cout << before << " -> " << show(a, lo, hi) << '\n';
}
long long sortAndCount(vector<int>& a, bool verbose = false) {
comps = 0;
vector<int> buf(a.size()); // one buffer, allocated once
mergeSort(a, buf, 0, (int)a.size(), 0, verbose);
return comps;
}
int main() {
vector<int> a{5, 2, 4, 7, 1, 3, 2, 6}, s{1, 2, 3, 4, 5, 6, 7, 8}, r{8, 7, 6, 5, 4, 3, 2, 1};
long long c = sortAndCount(a, true);
cout << "sorted: " << show(a, 0, (int)a.size()) << ", comparisons: " << c << '\n';
cout << "already sorted: comparisons = " << sortAndCount(s) << '\n';
cout << "reversed: comparisons = " << sortAndCount(r) << '\n';
}আউটপুট:
merge [5] + [2] -> [2 5]
merge [4] + [7] -> [4 7]
merge [2 5] + [4 7] -> [2 4 5 7]
merge [1] + [3] -> [1 3]
merge [2] + [6] -> [2 6]
merge [1 3] + [2 6] -> [1 2 3 6]
merge [2 4 5 7] + [1 2 3 6] -> [1 2 2 3 4 5 6 7]
sorted: [1 2 2 3 4 5 6 7], comparisons: 17
already sorted: comparisons = 12
reversed: comparisons = 12
ছাপার ক্রমটি লক্ষ করুন: প্রথমে বাঁ দিকের পুরো শাখা ([5]+[2], [4]+[7], তারপর তাদের জোড়া), তারপর ডান শাখা, সবশেষে মূল merge — রিকার্শনের depth-first চলন, ডায়াগ্রামের স্তর-ভিত্তিক ছবির সাথে ফল এক। মোট ১৭টি তুলনা — n = 8-এর জন্য এটিই সর্বোচ্চ সম্ভব (অংশ ৪)। আগে থেকে সাজানো আর উল্টো — দুটো অ্যারেতেই ১২টি: প্রতিটি merge-এ এক অংশের সবাই অন্য অংশের সবার চেয়ে ছোট, তাই ছোট অংশটি ফুরোতেই merge শেষ। Insertion sort-এর মতো “সাজানো ইনপুটে খুব দ্রুত” সুবিধা এখানে নেই — merge sort ইনপুট দেখে কম-বেশি খাটে না। show ফাংশনটি শুধু ছাপার জন্য; string(2 * depth, ' ') গভীরতা অনুযায়ী ফাঁকা জায়গা বানায়।
comps = 0
def show(a, lo, hi):
return "[" + " ".join(map(str, a[lo:hi])) + "]"
def merge_sort(a, buf, lo, hi, depth, verbose):
global comps
if hi - lo <= 1: # 0 or 1 element: already sorted
return
mid = (lo + hi) // 2
merge_sort(a, buf, lo, mid, depth + 1, verbose)
merge_sort(a, buf, mid, hi, depth + 1, verbose)
before = " " * depth + "merge " + show(a, lo, mid) + " + " + show(a, mid, hi)
i, j, k = lo, mid, lo
while i < mid and j < hi:
comps += 1
if a[i] <= a[j]: # <= : take from the left on ties (stable)
buf[k] = a[i]; i += 1
else:
buf[k] = a[j]; j += 1
k += 1
buf[k:hi] = a[i:mid] + a[j:hi] # copy whatever is left (one part is empty)
a[lo:hi] = buf[lo:hi]
if verbose:
print(before, "->", show(a, lo, hi))
def sort_and_count(a, verbose=False):
global comps
comps = 0
merge_sort(a, [0] * len(a), 0, len(a), 0, verbose)
return comps
a = [5, 2, 4, 7, 1, 3, 2, 6]
c = sort_and_count(a, True)
print(f"sorted: {show(a, 0, len(a))}, comparisons: {c}")
for name, b in [("already sorted", [1, 2, 3, 4, 5, 6, 7, 8]),
("reversed", [8, 7, 6, 5, 4, 3, 2, 1])]:
print(f"{name}: comparisons = {sort_and_count(b)}")আউটপুট C++-এর সাথে হুবহু এক:
merge [5] + [2] -> [2 5]
merge [4] + [7] -> [4 7]
merge [2 5] + [4 7] -> [2 4 5 7]
merge [1] + [3] -> [1 3]
merge [2] + [6] -> [2 6]
merge [1 3] + [2 6] -> [1 2 3 6]
merge [2 4 5 7] + [1 2 3 6] -> [1 2 2 3 4 5 6 7]
sorted: [1 2 2 3 4 5 6 7], comparisons: 17
already sorted: comparisons = 12
reversed: comparisons = 12
Python-এ index শূন্য বা ধনাত্মক বলে (lo + hi) // 2 নিরাপদ — এখানে int-এর সীমা নেই। buf[k:hi] = a[i:mid] + a[j:hi] এক লাইনে বাকি অংশ কপি করে: দুটি slice-এর একটি সবসময় খালি। শেখার জন্য এই কোড ভালো, কিন্তু বাস্তব কাজে Python-এ নিজে merge sort লিখবেন না — sorted() আর list.sort() C-তে লেখা Timsort, যা নিজেও merge-ভিত্তিক আর stable, এবং কয়েক গুণ দ্রুত।
৪ · বিশ্লেষণ — কেন Θ(n log n), আর তুলনা ঠিক কতটি
n টি উপাদান সাজাতে সময় T(n) ধরি। মাঝখান বের করা Θ(1), দুটি রিকার্সিভ ডাক T(⌊n/2⌋) + T(⌈n/2⌉), আর merge Θ(n). পর্ব ০৮-এর মতো floor-ceiling সরিয়ে পাই পরিচিত recurrence:
তিনভাবে সমাধান:
- Recursion tree (পর্ব ০৮): merge-এর খরচ cn ধরলে স্তর d-তে 2d টি অংশ, প্রতিটির আকার n/2d, তাই স্তরের মোট খরচ 2d · c(n/2d) = cn — প্রতিটি স্তরে সমান! স্তর আছে lg n টি (0 থেকে lg n − 1), সাথে তলায় n টি base case. মোট cn lg n + Θ(n) = Θ(n log n). ওপরের ডায়াগ্রামে প্রতিটি merge-স্তরে ঠিক ৮টি উপাদান কপি হয়েছিল — এটিই সেই “প্রতি স্তরে n”.
- Master theorem (পর্ব ০৯): a = 2, b = 2, তাই watershed nlog₂2 = n. f(n) = Θ(n) তার সমান — case 2, উত্তর Θ(n log n).
- Substitution: n = 2k আর T(n) = 2T(n/2) + n, T(1) = 0 হলে induction-এ T(n) = n lg n ঠিক। যাচাই: 2 · (n/2) lg(n/2) + n = n(lg n − 1) + n = n lg n.
লক্ষ করুন, recurrence-টি ইনপুটের মান দেখে না — ভাগ সবসময় মাঝখানে, merge সবসময় Θ(n). তাই merge sort-এর best case, average আর worst case তিনটিই Θ(n log n). Quick sort-এর মতো খারাপ ইনপুটে Θ(n²) হয়ে যাওয়ার ভয় নেই — এই নিশ্চয়তাই merge sort-এর বড় শক্তি।
তুলনার নিখুঁত হিসাব
বিশ্ববিদ্যালয়ের পরীক্ষায় প্রায়ই Θ ছাড়িয়ে নিখুঁত সংখ্যা চাওয়া হয়। n = 2k হলে:
- Worst case: প্রতিটি merge-এ (m + m) উপাদানে সর্বোচ্চ 2m − 1 তুলনা। স্তর d-তে 2d টি merge, প্রতিটিতে n/2d − 1 টি — স্তরের মোট n − 2d. d = 0 থেকে k − 1 যোগ করলে: kn − (2k − 1) = n lg n − n + 1. n = 8: 24 − 8 + 1 = 17 — ঠিক আমাদের অ্যারের সংখ্যা।
- Best case: প্রতিটি merge-এ সর্বনিম্ন m তুলনা, স্তরের মোট n/2, তাই (n/2) lg n. n = 8: 12 — সাজানো আর উল্টো অ্যারের সংখ্যা।
- যেকোনো n-এর worst case: W(n) = n⌈lg n⌉ − 2⌈lg n⌉ + 1 (Knuth-এর বইয়ে প্রমাণ আছে)। n = 2k হলে এটি ওপরের সূত্রে নেমে আসে।
ছোট n-এর জন্য সব permutation চালিয়ে (brute force) পাওয়া আসল সংখ্যা, আর তুলনা-ভিত্তিক যেকোনো সর্টের তাত্ত্বিক নিম্নসীমা ⌈lg n!⌉ (পর্ব ২৪-এ প্রমাণ হবে):
| n | সবচেয়ে কম তুলনা | সবচেয়ে বেশি তুলনা (W(n)) | যেকোনো তুলনা-সর্টের নিম্নসীমা ⌈lg n!⌉ |
|---|---|---|---|
| 4 | 4 | 5 | 5 |
| 5 | 5 | 8 | 7 |
| 6 | 7 | 11 | 10 |
| 7 | 9 | 14 | 13 |
| 8 | 12 | 17 | 16 |
অর্থাৎ worst case-এ merge sort তাত্ত্বিক সেরার খুব কাছে — n = 4-এ ঠিক সমান, n = 5…8-এ মাত্র ১টি বেশি। বড় n-এ lg n! ≈ n lg n − 1.44n, আর merge sort-এর worst case n lg n − n-এর কাছাকাছি — পার্থক্য প্রতি উপাদানে অর্ধেকেরও কম তুলনা। এজন্যই তুলনা যখন খুব ব্যয়বহুল (যেমন লম্বা string বা জটিল comparator), তখন merge-ভিত্তিক সর্ট জনপ্রিয় — ভাবনার প্রশ্ন ২ আর ৪ দেখুন।
Merge sort-এর Θ(n log n) আসে দুটি সংখ্যার গুণফল থেকে: প্রতিটি স্তরে মোট Θ(n) কাজ (প্রতিটি উপাদান একবার merge-এ অংশ নেয়), আর lg n টি স্তর (প্রতিবার অর্ধেক হলে lg n বার পরেই ১-এ পৌঁছায়)। যেকোনো divide and conquer-এর বিশ্লেষণে এই দুটি প্রশ্ন করুন: প্রতি স্তরে কাজ কত, স্তর কতটি।
৫ · জায়গার হিসাব — Θ(n) buffer আর Θ(log n) গভীরতা
Merge-এর সময় ফল লেখার জন্য আলাদা জায়গা লাগে — মূল অ্যারেতেই লিখতে গেলে যে উপাদান এখনো পড়া হয়নি, তার ওপর লেখা পড়ে যাবে। তাই অ্যারের merge sort-এর auxiliary space দুই অংশে:
- Buffer: n আকারের একটি অ্যারে — Θ(n). একবার বানিয়ে সব ডাকে ভাগ করে ব্যবহার করলে মোট বরাদ্দও n.
- Recursion stack: একসাথে সর্বোচ্চ যতগুলো ডাক অপেক্ষায় থাকে, অর্থাৎ recursion depth — প্রতিবার আকার অর্ধেক হয় বলে ⌈lg n⌉ + 1, অর্থাৎ Θ(log n). n = 10৯ হলেও মাত্র ৩১টির মতো frame, তাই এখানে stack overflow-এর ভয় নেই (পর্ব ০৭-এর গভীর রিকার্শনের সাথে তুলনা করুন)।
মোট বাড়তি জায়গা Θ(n) + Θ(log n) = Θ(n). তাই সাধারণ অ্যারের merge sort in-place নয় — insertion sort বা heapsort-এর (পর্ব ২৩) সাথে এটিই বড় পার্থক্য।
ধীর কোডের সাধারণ কারণ। অনেকে (আর অনেক AI-লেখা কোড) প্রতিটি ডাকে নতুন vector বানায় বা Python-এ left = a[:mid]; right = a[mid:] slice করে নতুন list ফেরত দেয়। Big-O বদলায় না — একসাথে বেঁচে থাকা জায়গা তখনো সর্বোচ্চ n + n/2 + n/4 + … ≤ 2n, আর সময় Θ(n log n) — কিন্তু সব ডাক মিলিয়ে মোট বরাদ্দ করা মেমরি দাঁড়ায় Θ(n log n), যা বাস্তবে কয়েক গুণ ধীর। কনটেস্টে n = 10৬-এ এই পার্থক্য TLE আর AC-র পার্থক্য হতে পারে।
বাস্তবের তিনটি উন্নতি
- ছোট অংশে insertion sort: ১৬–৩২টির মতো ছোট অংশে insertion sort-এর কম overhead আর ভালো cache locality merge sort-এর রিকার্শনের চেয়ে দ্রুত। আকার n ≥ কোনো সীমা হলে ভাগ, নইলে insertion sort — Θ(n log n) অটুট থাকে, ধ্রুবক কমে।
- আগে থেকে সাজানো হলে merge বাদ: দুই অর্ধেক সাজানোর পর যদি
a[mid-1] <= a[mid]হয়, তাহলে পুরো অংশ আগে থেকেই সাজানো — merge করার দরকার নেই। একটি লাইনেই সাজানো ইনপুটে মোট তুলনা n − 1, সময় Θ(n). - কপি অর্ধেক করা: প্রতিবার buf থেকে a-তে ফেরত না এনে, স্তরে স্তরে a আর buf-এর ভূমিকা অদলবদল করা যায় — একই কাজ, অর্ধেক কপি।
৬ · Bottom-up merge sort — রিকার্শন ছাড়া, লুপ দিয়ে
Recursion tree-র merge-অংশটি তলা থেকে ওপরে উঠেছিল: আগে ১-১ জোড়া, তারপর ২-২, তারপর ৪-৪. তাহলে ভাগ করার রিকার্শন বাদ দিয়ে সরাসরি তলা থেকেই শুরু করা যায়। প্রথম পাসে পাশাপাশি প্রতিটি ১ উপাদানের অংশ জোড়া দিন, দ্বিতীয় পাসে ২-এর অংশ, তারপর ৪-এর… যতক্ষণ না অংশের মাপ (width) n ছাড়ায়। এর নাম bottom-up merge sort. শেষে একটি অংশ জোড়াহীন থাকতে পারে, বা ছোট হতে পারে — min দিয়ে সীমা কেটে নিলেই সমস্যা নেই।
Pseudocode — bottom-up merge sort (0-based, half-open)
width = 1
while width < n:
for lo = 0, 2*width, 4*width, ... while lo < n:
mid = min(lo + width, n)
hi = min(lo + 2*width, n)
merge(a, lo, mid, hi) // if mid == hi the right part is empty: nothing to do
width = 2 * width| width | |
| সাজানো অংশগুলো | |
| অ্যারে a | |
| এই পাসে তুলনা | |
| মোট তুলনা |
- শুরু. ৭টি উপাদান। প্রতিটি একা একটি “সাজানো অংশ”. কোনো রিকার্শন নেই, কোনো ভাগও নেই।
- width = 1. জোড়া: [6]+[3], [8]+[1], [5]+[2] — প্রতিটিতে একটি তুলনা। শেষের [7]-এর জোড়া নেই: lo = 6, mid = min(7, 7) = 7 = hi, ডান অংশ খালি, তাই কিছু করার নেই।
- width = 2. [3 6]+[1 8] → [1 3 6 8] (৩টি তুলনা); [2 5]+[7] → [2 5 7] (২টি) — এখানে ডান অংশ ছোট, hi = min(8, 7) = 7 দিয়ে কাটা।
- width = 4. [1 3 6 8]+[2 5 7] → পুরো অ্যারে সাজানো, ৬টি তুলনা (৭ উপাদানে সর্বোচ্চ ৬)। মোট ১৪ — n = 7-এর worst case W(7) = 14-এর সমান।
- width = 8 ≥ n. লুপ থামে। পাস হয়েছে ৩টি = ⌈lg 7⌉. প্রতিটি পাসে Θ(n) কাজ, তাই মোট Θ(n log n) — রিকার্সিভ রূপের সমান, কিন্তু stack-এর গভীরতা শূন্য।
কখন bottom-up? রিকার্শন নিষেধ বা ব্যয়বহুল এমন পরিবেশে (কিছু embedded সিস্টেম, GPU), আর linked list-এ (পরের অংশ) — যেখানে এটি বাড়তি জায়গা O(1)-এ নামিয়ে আনে। মনে রাখবেন, n দুইয়ের ঘাত না হলে bottom-up আর top-down-এর ভাগ আলাদা হয় (top-down ৭-কে ৩ + ৪-এ ভাঙে, bottom-up ৪ + ৩-এ), তাই তুলনার সংখ্যা সবসময় হুবহু এক হয় না — কিন্তু দুটোই Θ(n log n) আর দুটোই stable.
৭ · Linked list-এর merge sort — যেখানে buffer লাগেই না
অ্যারেতে merge-এর জন্য buffer লাগে, কারণ উপাদান সত্যিই সরাতে হয়। কিন্তু linked list-এ (পর্ব ১২) “সরানো” মানে শুধু next pointer বদলানো। দুটি সাজানো list merge করতে কোনো নতুন node লাগে না — দুই list-এর সামনের node দুটির ছোটটিকে ফলের লেজে জুড়ে দিন, এটুকুই। শেষে যে list বাকি থাকে, তাকে একটি মাত্র pointer দিয়ে পুরোটা জুড়ে দিন — কপি নেই।
struct Node { int val; Node* next; };
Node* mergeLists(Node* a, Node* b) {
Node dummy{0, nullptr}; // sentinel: no special case for the first node
Node* tail = &dummy;
while (a && b) {
if (a->val <= b->val) { tail->next = a; a = a->next; } // ties: left list first (stable)
else { tail->next = b; b = b->next; }
tail = tail->next;
}
tail->next = a ? a : b; // append the rest with ONE link -- no copying
return dummy.next;
}
Node* sortList(Node* head) {
if (!head || !head->next) return head; // 0 or 1 node
Node* slow = head; Node* fast = head->next;
while (fast && fast->next) { slow = slow->next; fast = fast->next->next; } // find the middle
Node* right = slow->next;
slow->next = nullptr; // cut into two lists
return mergeLists(sortList(head), sortList(right));
}মাঝখান খুঁজতে পর্ব ১২ ও ১৬-এর fast/slow pointer: fast দুই ঘর এগোয়, slow এক ঘর — fast শেষে পৌঁছালে slow মাঝখানে। dummy হলো একটি sentinel node, যাতে “ফলের প্রথম node কোনটি” আলাদা করে ভাবতে না হয়।
বিশ্লেষণ: প্রতিটি স্তরে মাঝখান খোঁজা আর merge — দুটোই মোট Θ(n), স্তর lg n টি, তাই সময় Θ(n log n). বাড়তি জায়গা: কোনো buffer নেই, শুধু রিকার্শনের Θ(log n) stack. আর bottom-up রূপে (প্রথমে ১-node-এর টুকরো জোড়া, তারপর ২-এর…) সেটুকুও লাগে না — বাড়তি জায়গা O(1).
কেন linked list-এ merge sort-ই স্বাভাবিক পছন্দ?
- শুধু ক্রমানুসারে পড়া: merge দুই list-কে সামনে থেকে পেছনে একবার হাঁটে — linked list ঠিক এটাই ভালো পারে। Heapsort-এর দরকার index দিয়ে লাফানো (
2i + 1-তম উপাদান), যা linked list-এ Θ(n) খরচের random access. Binary insertion বা median-of-three pivot বাছাইয়ের মতো কৌশলেও একই সমস্যা। - Merge-এর একমাত্র দুর্বলতা উধাও: অ্যারেতে merge sort-এর বিরুদ্ধে প্রধান অভিযোগ Θ(n) buffer — linked list-এ সেটি লাগেই না।
- নিশ্চয়তা আর stability: worst case Θ(n log n), আর node সরানো হয় না বলে stable রাখা সহজ।
এজন্যই C++-এর std::list::sort (যা standard অনুযায়ী stable আর মোটামুটি n log n তুলনা করে) সাধারণত merge sort হিসেবে লেখা হয়, আর Linux kernel-এর linked list সাজানোর ফাংশন list_sort-ও একটি merge sort. মনে রাখবেন, std::sort linked list-এ চলেই না — তার random access iterator লাগে।
৮ · External merge sort — RAM-এর চেয়ে বড় ডেটা
একটি টেলিকম কোম্পানির এক মাসের কল-রেকর্ড ২০০ GB, আর সার্ভারের RAM ১৬ GB. পুরো ডেটা একসাথে মেমরিতে আনা অসম্ভব। এখানেও merge sort, কারণ merge ডেটাকে শুধু সামনে থেকে পেছনে ক্রমানুসারে পড়ে — ডিস্ক বা SSD ঠিক এভাবে পড়াতেই সবচেয়ে দ্রুত। এর নাম external sorting, আর ধাপ দুটি:
- Run বানানো: RAM-এ যতটুকু ধরে (ধরুন ১৬ GB-র মধ্যে নিরাপদে ১০ GB) ততটুকু পড়ুন, মেমরিতেই সাজান (যেকোনো দ্রুত সর্টে), আর ডিস্কে একটি সাজানো ফাইল হিসেবে লিখুন। এমন প্রতিটি সাজানো টুকরোকে বলে run. ২০০ GB থেকে হবে ২০টি run.
- k-way merge: ২০টি run একসাথে জোড়া দিন। প্রতিটি run-এর সামনের ছোট একটি অংশ মেমরিতে রাখুন। প্রতিবার ২০টি প্রার্থীর মধ্যে সবচেয়ে ছোটটি output ফাইলে লিখুন, আর সেই run থেকে পরের উপাদান আনুন।
দ্বিতীয় ধাপে “k টি প্রার্থীর মধ্যে সবচেয়ে ছোট” প্রতিবার খুঁজতে সরলভাবে k − 1 তুলনা লাগে। দ্রুত উপায় একটি min-heap — পর্ব ২৩-এর priority queue. Heap-এ k টি (মান, run) জোড়া থাকে; সবচেয়ে ছোটটি বের করা আর নতুন একটি ঢোকানো — দুটোই O(log k). এই k-way merge তিনটি ছোট run দিয়ে দেখুন:
| এই ধাপে | |
| heap | |
| run-এ বাকি | |
| output |
- শুরু. প্রতিটি run-এর প্রথম উপাদান heap-এ: 1 (A থেকে), 2 (B), 4 (C). উপাদানের সাথে কোন run থেকে এল তা-ও রাখতে হয় — নইলে পরের উপাদান কোথা থেকে আনবেন, জানবেন না।
- pop 1. সবচেয়ে ছোট 1, output-এ। সে A-র, তাই A-র পরেরটি (5) heap-এ।
- pop 2. B থেকে, তাই B-র পরের 3 ঢুকল। heap-এ সবসময় সর্বোচ্চ k = 3টি উপাদান।
- pop 3. আবার B — পরপর একই run থেকে আসতেই পারে। B-র শেষ উপাদান 10 ঢুকল।
- pop 4. C থেকে, পরের 6 ঢুকল।
- pop 5. A থেকে, A-র শেষ উপাদান 9 ঢুকল।
- pop 6. C থেকে, 7 ঢুকল। এখন সব run-এর সব উপাদান হয় output-এ, নয়তো heap-এ।
- pop 7. C ফুরিয়েছে — নতুন কিছু ঢোকে না, heap ছোট হয়।
- pop 9. A-ও ফুরোল।
- pop 10. heap খালি, merge শেষ: 1 2 3 4 5 6 7 9 10. N = 9টি উপাদানের প্রতিটির জন্য একটি pop (আর প্রায় একটি push), প্রতিটি O(log k) — মোট O(N log k).
মোট খরচ: M = মেমরিতে ধরে এমন উপাদান, N = মোট উপাদান। Run বানাতে O(N log M), k = N/M টি run-এর merge-এ O(N log k) — একসাথে O(N log N) তুলনা। কিন্তু external sort-এ আসল খরচ ডিস্কের পড়া-লেখা: এখানে পুরো ডেটা দুবার পড়া আর দুবার লেখা হয় (প্রতি ধাপে একবার)। Run এত বেশি হলে যে সবার জন্য মেমরিতে জায়গা হয় না, তখন কয়েক পাসে merge হয় — পাসের সংখ্যা মোটামুটি ⌈logk(N/M)⌉, তাই বড় k মানে কম পাস।
আপনি না জেনেই এটি ব্যবহার করেন: Linux-এর sort কমান্ড বড় ফাইলে ঠিক এভাবে সাময়িক ফাইলে run লিখে পরে merge করে। PostgreSQL-এ বড় ORDER BY-এর query plan দেখলে প্রায়ই লেখা থাকে “Sort Method: external merge”. আর বড় ডেটা-প্রক্রিয়াকরণের সিস্টেমগুলো (যেমন MapReduce-ধাঁচের কাঠামো) মেশিনে মেশিনে সাজানো টুকরো বানিয়ে শেষে merge করে।
আরও গভীরে: Run বানানোর একটি চতুর কৌশল replacement selection — মেমরিতে একটি heap রেখে উপাদান বের করতে করতে নতুন উপাদান ঢোকানো; এলোমেলো ইনপুটে গড় run-এর দৈর্ঘ্য হয় মেমরির প্রায় দ্বিগুণ (2M), ফলে run অর্ধেক, merge সহজ। অন্যদিকে Timsort (Python, Java-র object সর্ট, Android) ইনপুটে আগে থেকেই থাকা সাজানো টুকরো (“natural run”) খুঁজে নিয়ে merge করে, তাই প্রায়-সাজানো ডেটায় প্রায় O(n) সময় নেয়। তাত্ত্বিক দিক থেকে, k-way merge-এ প্রতি উপাদানে ⌈lg k⌉-এর কাছাকাছি তুলনা লাগে — যা “k টি সাজানো তালিকা জোড়া দেওয়ার” তুলনা-নিম্নসীমার প্রায় সমান।
৯ · Inversion গোনা — দুটি র্যাঙ্কিং কতটা আলাদা
একটি রান্নার প্রতিযোগিতায় দুজন বিচারক পাঁচটি পদ (A, B, C, D, E) ১ থেকে ৫ র্যাঙ্ক দিলেন। বিচারক ১: A B C D E. বিচারক ২: C A B E D. দুজনের মত কতটা মেলে? একটি স্বাভাবিক মাপ: কতগুলো জোড়া নিয়ে তাঁরা একমত নন। বিচারক ১-এর ক্রমে প্রতিটি পদের অবস্থান (A = 0, B = 1, …) দিয়ে বিচারক ২-এর তালিকা লিখলে পাই [2, 0, 1, 4, 3]. এখন যেসব জোড়া এখানে “উল্টো” — আগের সংখ্যা বড় — সেগুলোই মতভেদ: (2, 0), (2, 1), (4, 3) — ৩টি।
অ্যারেতে i < j অথচ a[i] > a[j] — এমন জোড়াকে বলে inversion (পর্ব ০৫-এ দেখেছেন)। সাজানো অ্যারেতে ০টি, উল্টো অ্যারেতে সর্বোচ্চ n(n − 1)/2 টি। দুটি র্যাঙ্কিংয়ের মধ্যে inversion-এর সংখ্যাকে বলে Kendall tau distance — পরিসংখ্যানবিদ Maurice Kendall ১৯৩৮ সালে এই ভিত্তিতে একটি rank correlation সংজ্ঞায়িত করেন। আমাদের উদাহরণে ১০টি জোড়ার ৩টিতে মতভেদ, ৭টিতে মিল; Kendall-এর τ = (৭ − ৩)/১০ = ০.৪. সংগীত বা সিনেমার অ্যাপ এমন মাপ দিয়ে দেখতে পারে আপনার পছন্দের ক্রম কার সাথে মেলে; সার্চ ইঞ্জিনের দুটি ফলাফল-তালিকাও এভাবে তুলনা করা যায়।
Brute force প্রতিটি জোড়া দেখে — Θ(n²). n = 10৫ হলে প্রায় ৫০০ কোটি জোড়া। আরেকটি তথ্য: insertion sort ঠিক inversion-এর সংখ্যার সমান বার উপাদান সরায় (পর্ব ২০), তাই সেটিও Θ(n²) পর্যন্ত।
Merge sort দিয়ে O(n log n): প্রতিটি inversion তিন ধরনের — দুটিই বাঁ অর্ধেকে, দুটিই ডান অর্ধেকে, অথবা একটি বাঁয়ে আর একটি ডানে (cross inversion). প্রথম দুটি রিকার্শন গুনবে। আর cross inversion গোনা যায় merge করার সময়ই, বিনা খরচে: যখন ডান অংশ থেকে a[j] নেওয়া হয়, তখন বাঁ অংশে অপেক্ষায় থাকা সব উপাদান (mid − i টি) a[j]-এর চেয়ে বড় — কারণ বাঁ অংশ সাজানো আর তাদের সবচেয়ে ছোটটিই a[j]-এর চেয়ে বড় ছিল। আর এরা সবাই মূল অ্যারেতে a[j]-এর আগে ছিল (বাঁ অর্ধেক বলে)। তাই এক ধাক্কায় inv += mid - i. সমান হলে বাঁ দিক থেকে নেওয়া (<=) জরুরি — সমান জোড়া inversion নয়।
কেন সাজানোর পরও গোনা ঠিক থাকে? কারণ একটি অর্ধেকের ভেতরে উপাদানের ক্রম বদলালেও, বাঁ অর্ধেকের প্রতিটি উপাদান ডান অর্ধেকের প্রতিটির আগে — এই সত্যটি বদলায় না। cross inversion শুধু “কে কোন অর্ধেকে” আর মান দেখে, অর্ধেকের ভেতরের ক্রম দেখে না।
| merge | |
| নতুন cross inversion | |
| কোন জোড়া | |
| মোট inversion |
- [5] + [2]. ডান থেকে 2 নেওয়ার সময় বাঁয়ে অপেক্ষায় 5 — mid − i = 1. জোড়া (5, 2).
- [4] + [7]. বাঁয়ের 4 আগে যায়; ডান থেকে 7 নেওয়ার সময় বাঁ অংশ খালি — ০টি।
- [2 5] + [4 7]. 2 (বাঁ) যায়; তারপর 4 (ডান) নেওয়ার সময় বাঁয়ে অপেক্ষায় শুধু 5 — +1, জোড়া (5, 4). বাঁ অর্ধেকের মোট: ভেতরের ১ + এই ১ = ২।
- [1] + [3]. আগে থেকেই ঠিক ক্রমে — ০।
- [2] + [6]. ০।
- [1 3] + [2 6]. 1 যায়; 2 (ডান) নেওয়ার সময় বাঁয়ে 3 অপেক্ষায় — +1, জোড়া (3, 2). মোট ৩।
- শেষ merge. অংশ ২-এর ডায়াগ্রামের মতোই চলে: 1 নেওয়ার সময় বাঁয়ে চারজন (2 4 5 7) → +4; 2L যায়; 2R নেওয়ার সময় বাঁয়ে 4 5 7 → +3 (সমান 2L আগেই চলে গেছে বলে (2, 2) গোনা হয়নি — ঠিক যা চাই); 3 → +3; 4, 5 যায়; 6 → বাঁয়ে শুধু 7, +1. মোট cross ১১, সব মিলিয়ে ১৪.
সময়: merge sort-এর ওপর প্রতিটি merge-ধাপে শুধু একটি যোগ — Θ(n log n), জায়গা Θ(n). এবার কোডে চালিয়ে দেখুন, সাথে O(n²) brute force দিয়ে ৫০০টি এলোমেলো অ্যারেতে যাচাই। দুই ভাষায় একই এলোমেলো সংখ্যা পেতে একটি সরল linear congruential generator (LCG) নিজে লেখা হয়েছে:
#include <iostream>
#include <vector>
using namespace std;
long long sortCount(vector<int>& a, vector<int>& buf, int lo, int hi) {
if (hi - lo <= 1) return 0;
int mid = lo + (hi - lo) / 2;
long long inv = sortCount(a, buf, lo, mid) + sortCount(a, buf, mid, hi);
int i = lo, j = mid, k = lo;
while (i < mid && j < hi) {
if (a[i] <= a[j]) buf[k++] = a[i++];
else { inv += mid - i; buf[k++] = a[j++]; } // a[j] is smaller than every left item still waiting
}
while (i < mid) buf[k++] = a[i++];
while (j < hi) buf[k++] = a[j++];
for (k = lo; k < hi; ++k) a[k] = buf[k];
return inv;
}
long long countInv(vector<int> a) { // by value: works on a copy
vector<int> buf(a.size());
return sortCount(a, buf, 0, (int)a.size());
}
long long brute(const vector<int>& a) { // O(n^2): check every pair
long long c = 0;
for (size_t i = 0; i < a.size(); ++i)
for (size_t j = i + 1; j < a.size(); ++j) c += a[i] > a[j];
return c;
}
int main() {
vector<int> a{5, 2, 4, 7, 1, 3, 2, 6};
cout << "inversions = " << countInv(a) << ", brute = " << brute(a) << '\n';
vector<int> b{2, 0, 1, 4, 3}; // judge B's order, written as positions in judge A's order
cout << "kendall tau distance = " << countInv(b) << " of " << b.size() * (b.size() - 1) / 2 << " pairs\n";
unsigned x = 2026; int bad = 0;
for (int t = 0; t < 500; ++t) {
vector<int> r(t % 30);
for (int& v : r) { x = x * 1103515245u + 12345u; v = (x >> 16) % 10; }
if (countInv(r) != brute(r)) ++bad;
}
cout << "random tests: 500, mismatches: " << bad << '\n';
}আউটপুট:
inversions = 14, brute = 14
kendall tau distance = 3 of 10 pairs
random tests: 500, mismatches: 0
১৪ — ডায়াগ্রামের সাথে মিলল, brute force-এর সাথেও। বিচারকদের মতভেদ ১০টি জোড়ার ৩টিতে। শেষ লাইনটিই আসল আত্মবিশ্বাস: ০ থেকে ২৯ দৈর্ঘ্যের ৫০০টি এলোমেলো অ্যারে (মান ০–৯, তাই প্রচুর সমান মান — stability-র ফাঁদ ধরার জন্য ঠিক যা দরকার), খালি অ্যারেসহ, প্রতিটিতে দ্রুত আর ধীর উত্তর এক। গণনার ধরন long long — n = 10৫-এ উত্তর প্রায় ৫ × ১০৯ হতে পারে, যা int-এর সীমা (প্রায় ২.১ × ১০৯) ছাড়িয়ে যায়। unsigned-এর গুণ 232-এ আপনাআপনি ঘুরে আসে, যা LCG-র জন্য ঠিক যা চাই।
def sort_count(a, buf, lo, hi):
if hi - lo <= 1:
return 0
mid = (lo + hi) // 2
inv = sort_count(a, buf, lo, mid) + sort_count(a, buf, mid, hi)
i, j, k = lo, mid, lo
while i < mid and j < hi:
if a[i] <= a[j]:
buf[k] = a[i]; i += 1
else:
inv += mid - i # a[j] is smaller than every left item still waiting
buf[k] = a[j]; j += 1
k += 1
buf[k:hi] = a[i:mid] + a[j:hi]
a[lo:hi] = buf[lo:hi]
return inv
def count_inv(a):
a = list(a) # work on a copy
return sort_count(a, [0] * len(a), 0, len(a))
def brute(a): # O(n^2): check every pair
return sum(a[i] > a[j] for i in range(len(a)) for j in range(i + 1, len(a)))
a = [5, 2, 4, 7, 1, 3, 2, 6]
print(f"inversions = {count_inv(a)}, brute = {brute(a)}")
b = [2, 0, 1, 4, 3] # judge B's order, written as positions in judge A's order
print(f"kendall tau distance = {count_inv(b)} of {len(b) * (len(b) - 1) // 2} pairs")
x, bad = 2026, 0
for t in range(500):
r = []
for _ in range(t % 30):
x = (x * 1103515245 + 12345) & 0xFFFFFFFF
r.append((x >> 16) % 10)
if count_inv(r) != brute(r):
bad += 1
print(f"random tests: 500, mismatches: {bad}")আউটপুট C++-এর সাথে হুবহু এক:
inversions = 14, brute = 14
kendall tau distance = 3 of 10 pairs
random tests: 500, mismatches: 0
Python-এর int-এর কোনো সীমা নেই, তাই 32-bit unsigned-এর মতো আচরণ পেতে & 0xFFFFFFFF দিয়ে নিচের ৩২ bit রাখা হয়েছে — এতেই দুই ভাষার এলোমেলো অ্যারে হুবহু এক। sum(a[i] > a[j] ...)-এ True = 1 আর False = 0 যোগ হয়। এই “দ্রুত সমাধান + ধীর কিন্তু নিশ্চিত সমাধান + অনেক এলোমেলো ছোট ইনপুট” কাঠামোটিকেই বলে stress testing — কনটেস্ট আর ইন্টারভিউর প্রস্তুতিতে এটি অভ্যাস করুন।
পরীক্ষা ও ইন্টারভিউতে:
- Merge sort-এর time complexity best, average আর worst case-এ কত? — তিনটিই Θ(n log n), কারণ ভাগ সবসময় মাঝখানে আর merge সবসময় Θ(n).
- ৫ আর ৭ উপাদানের দুটি সাজানো অ্যারে merge করতে সর্বোচ্চ আর সর্বনিম্ন কয়টি তুলনা? — সর্বোচ্চ ১১ (m + n − 1), সর্বনিম্ন ৫ (min(m, n)).
- Merge sort কি stable? কীভাবে নিশ্চিত করবেন? — হ্যাঁ, যদি সমান হলে বাঁ অংশ থেকে নেওয়া হয় (
<=). - Linked list সাজাতে কোন অ্যালগরিদম, আর কেন? — merge sort: শুধু ক্রমানুসারে পড়ে, buffer লাগে না, worst case Θ(n log n).
- একটি অ্যারের inversion O(n log n)-এ কীভাবে গুনবেন? — merge sort-এর merge-এ ডান থেকে নেওয়ার সময়
mid − iযোগ করে।
১০ · AI-এর যুগে merge sort — সাজানো ফল মানেই সঠিক কোড নয়
“Merge sort লিখে দাও” — AI কয়েক সেকেন্ডে চলনসই কোড দেবে, আর ছোট উদাহরণে সাজানো ফলও আসবে। বিপদ সেখানেই: সাজানো ফল দেখলে মনে হয় সব ঠিক, অথচ stability ভাঙা, প্রতিটি ডাকে নতুন মেমরি বরাদ্দ, বা inversion-এর সংখ্যা int-এ উপচে পড়া — কোনোটিই ছোট উদাহরণে ধরা পড়ে না। আর complexity নিয়ে AI কখনো লেখে “merge sort O(1) জায়গায় চলে” বা “সাজানো ইনপুটে O(n)” — দুটিই সাধারণ রূপের জন্য ভুল।
Merge sort-এর কোডে AI সাধারণত যেসব ভুল করে
| AI-এর ভুল | কীভাবে ধরবেন |
|---|---|
merge-এ < — sort আর stable থাকে না | (মান, মূল index) জোড়া সাজিয়ে দেখুন, সমান মানের index বাড়তি ক্রমে আছে কি না |
| লুপ শেষে বাকি অংশ কপি করতে ভুলে যাওয়া | [1 2] আর [8 9]-এর মতো এক দিক আগে ফুরোয় এমন ইনপুট দিন |
| closed রেঞ্জে mid-এর ভুল — অসীম রিকার্শন | ২ আর ৩ উপাদানের অ্যারে, আর খালি অ্যারে দিয়ে চালান |
| প্রতিটি ডাকে নতুন vector/slice | n = 10৬-এ সময় মাপুন; একটি buffer একবার বানাতে বলুন |
inversion-এর গণনা int-এ | উল্টো সাজানো n = 10৫ দিন — উত্তর হতে হবে 4999950000 |
inversion গুনতে += 1 (বা mid - i-এর জায়গায় ভুল সূচক) | brute force-এর সাথে হাজারটি এলোমেলো ছোট অ্যারেতে মেলান |
| “জায়গা O(1)” বা “সাজানো ইনপুটে O(n)” দাবি | buffer-এর আকার আর তুলনা নিজে গুনুন; অংশ ৪–৫-এর হিসাব মেলান |
AI-কে কোড লিখতে না বলে আপনার কোড আর trace পরীক্ষা করাতে ব্যবহার করুন। নিচের prompt-টি সেভাবে সাজানো:
Merge sort-এর কোড যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১প্রথম দিকের stored-program কম্পিউটারের জন্য von Neumann কেন একটি সর্টিং প্রোগ্রাম লিখলেন — অঙ্কের হিসাব নয় কেন?
১৯৪৫ সালে কম্পিউটার মানে ছিল মূলত অঙ্ক কষার যন্ত্র — কামানের গোলার গতিপথ, পারমাণবিক গবেষণার সমীকরণ। ENIAC তখন তৈরি হচ্ছে, আর তার উত্তরসূরি EDVAC-এর নকশা চলছে, যেখানে প্রোগ্রাম আর ডেটা একই মেমরিতে থাকবে — আজ যাকে বলি stored-program নকশা। John von Neumann সেই বছর EDVAC নিয়ে বিখ্যাত “First Draft of a Report on the EDVAC” লেখেন। একই সময়ে তিনি হাতে একটি প্রোগ্রাম লেখেন, যা দুটি সাজানো তালিকা merge করে, আর সেই merge বারবার ব্যবহার করে পুরো তালিকা সাজায়। যন্ত্রটি তখনো তৈরিই হয়নি — প্রোগ্রামটি কাগজে, EDVAC-এর প্রস্তাবিত নির্দেশ-ভাষায়।
কেন সর্টিং? প্রচলিত ব্যাখ্যা হলো, von Neumann দেখাতে চেয়েছিলেন যে একটি সাধারণ-উদ্দেশ্যের কম্পিউটার শুধু অঙ্ক নয়, “অ-গাণিতিক” তথ্য-প্রক্রিয়াকরণেও কাজের — এমনকি তখনকার IBM-এর punched card sorter আর collator-এর মতো বিশেষায়িত যন্ত্রের সাথে পাল্লা দিতে পারে। আদমশুমারি, ব্যবসার হিসাব, বিমার রেকর্ড — এসবের বড় অংশই ছিল কার্ড সাজানো আর জোড়া দেওয়া। Merge-এর ধারণা তাই নতুন ছিল না; collator যন্ত্র ঠিক এটাই করত। নতুন ছিল একই যন্ত্রে, একই মেমরিতে, প্রোগ্রাম দিয়ে পুরো প্রক্রিয়াটি চালানো।
১৯৭০ সালে Donald Knuth “Von Neumann’s First Computer Program” প্রবন্ধে পাণ্ডুলিপিটি খুঁটিয়ে বিশ্লেষণ করেন। সেখানে তিনি দেখান, যন্ত্র তৈরির আগেই লেখা এই প্রোগ্রামে প্রোগ্রামিংয়ের অনেক মৌলিক সমস্যা — লুপ, শর্তসাপেক্ষ লাফ, মেমরির ঠিকানা সামলানো — নিয়ে von Neumann-কে ভাবতে হয়েছিল। পরে Goldstine ও von Neumann-এর প্রোগ্রামিং-বিষয়ক রিপোর্টগুলোতেও সর্টিং আর merge-এর আলোচনা আসে। Knuth তাঁর “The Art of Computer Programming”-এর সর্টিং খণ্ডে merge sort-এর এই ইতিহাস উল্লেখ করেছেন।
এই গল্পের আরেকটি দিক: সর্টিং শুরু থেকেই কম্পিউটারের “benchmark” কাজ। ১৯৬০-৭০-এর দশকে অনেক হিসাবে বলা হতো, বাণিজ্যিক কম্পিউটারের সময়ের একটি বড় অংশ যায় সাজানোয়। তখন ডেটা থাকত ম্যাগনেটিক টেপে, যা শুধু ক্রমানুসারে পড়া যায় — আর merge-ই একমাত্র পদ্ধতি যা টেপে স্বাভাবিকভাবে চলে। অংশ ৮-এর external sort সেই টেপ-যুগেরই উত্তরসূরি।
মূল উপলব্ধি: Merge sort কোনো একাডেমিক কৌশল হিসেবে জন্মায়নি — জন্মেছিল একটি ব্যবহারিক প্রশ্নের উত্তরে: সাধারণ কম্পিউটার কি বাস্তব ডেটা সামলাতে পারবে? ক্রমানুসারে পড়ার স্বভাবই একে টেপ, ডিস্ক আর linked list-এর স্থায়ী সঙ্গী বানিয়েছে।
প্র ০২Python আর Java-র object সাজানোয় merge-ভিত্তিক Timsort, অথচ C++-এর std::sort introsort — কেন এই ভিন্ন সিদ্ধান্ত?
লাইব্রেরি নকশাকারীদের সামনে প্রশ্নটি শুধু “কোনটি দ্রুত” নয় — কী কথা দেওয়া হবে (stable কি না), তুলনা কতটা ব্যয়বহুল, আর মেমরি কতটা পাওয়া যাবে। ২০০২ সালে Tim Peters Python-এর জন্য Timsort লেখেন। Python-এ প্রতিটি তুলনা একটি ফাংশন-ডাক — দুটি object-এর __lt__ খোঁজা, টাইপ যাচাই — যা একটি মেমরি-কপির চেয়ে অনেক ব্যয়বহুল। তাই লক্ষ্য ছিল যত সম্ভব কম তুলনা, আর merge sort-এর worst case তাত্ত্বিক সেরার খুব কাছে (অংশ ৪)। সাথে বাস্তব ডেটা প্রায়ই আংশিক সাজানো (লগ ফাইল, আগে সাজানো তালিকায় কিছু নতুন যোগ), তাই Timsort আগে থেকে থাকা সাজানো টুকরো খুঁজে নেয় আর ছোট টুকরোকে binary insertion sort দিয়ে লম্বা করে। Python ডকুমেন্টেশন sort()-কে stable বলে নিশ্চয়তা দেয়, তাই একাধিক key-তে ধাপে ধাপে সাজানো যায়।
Java ৭ থেকে object-এর অ্যারে (Arrays.sort(Object[]), Collections.sort) সাজাতে Timsort ব্যবহার করে, কারণ Java-র documentation সেখানে stability-র কথা দেয়। কিন্তু int[]-এর মতো primitive অ্যারেতে Java dual-pivot quicksort ব্যবহার করে — সেখানে stability-র কোনো অর্থ নেই (দুটি সমান int আলাদা করে চেনাই যায় না), আর বাড়তি buffer না লাগা আর cache-বান্ধব partition বেশি মূল্যবান।
C++-এর std::sort stable হওয়ার কথা দেয় না; standard শুধু O(n log n) তুলনার কথা বলে। বেশিরভাগ বাস্তবায়ন introsort — quick sort দিয়ে শুরু, গভীরতা বেশি হলে heapsort, ছোট অংশে insertion sort. কোনো বাড়তি buffer নেই, আর C++-এ তুলনা সাধারণত inline হয়ে যায় বলে সস্তা। Stability দরকার হলে আলাদা ফাংশন আছে: std::stable_sort, যা সাধারণত merge sort — buffer পেলে O(n log n), মেমরি না পেলে in-place merge-এ নেমে O(n log² n) পর্যন্ত। আর std::list::sort merge sort, অংশ ৭-এর কারণে।
এখানে একটি শিক্ষণীয় ঘটনাও আছে: ২০১৫ সালে একদল গবেষক formal verification টুল দিয়ে দেখান যে Java আর Python-এর Timsort বাস্তবায়নে run-এর stack-এর একটি invariant কিছু বিরল, খুব বড় ইনপুটে ভেঙে যেতে পারত; পরে দুটিই সংশোধন করা হয়। বহু বছর ধরে কোটি মানুষের ব্যবহৃত কোডেও প্রমাণ ছাড়া invariant-এ আস্থা রাখা নিরাপদ নয়।
মূল উপলব্ধি: “সেরা সর্ট” বলে কিছু নেই। তুলনা ব্যয়বহুল আর stability জরুরি হলে merge-এর দিকে, তুলনা সস্তা আর মেমরি মূল্যবান হলে quick sort-এর দিকে ঝোঁক — প্রতিটি লাইব্রেরির সিদ্ধান্ত তার ভাষার খরচের কাঠামোর প্রতিফলন।
প্র ০৩Merge sort কি in-place করা যায়? Θ(n) buffer এড়ানোর চেষ্টা কতদূর গেছে?
সরল প্রশ্ন, কিন্তু উত্তরটি আশ্চর্য রকম জটিল। সমস্যাটি পুরোটাই merge-এ: দুটি পাশাপাশি সাজানো অংশ a[lo..mid) আর a[mid..hi)-কে কোনো বাড়তি অ্যারে ছাড়া জোড়া দেওয়া। সবচেয়ে সরল চেষ্টা — ডান অংশের উপাদান বাঁ অংশে ঠিক জায়গায় ঢুকিয়ে বাকিদের সরানো — insertion sort-এর মতোই worst case Θ(n²) সময় নেয়।
একটি ভালো মাঝামাঝি সমাধান হলো rotation-ভিত্তিক merge: বড় অংশের মাঝের উপাদান নিয়ে অন্য অংশে binary search করে তার জায়গা বের করা, মাঝের দুটি টুকরো ঘুরিয়ে (rotate, পর্ব ১০-এর reversal কৌশলে) জায়গা বদল, তারপর দুই পাশে রিকার্শন। এতে merge-এ লাগে O(n log n), আর পুরো sort O(n log² n) — বাড়তি জায়গা শুধু রিকার্শনের stack. C++-এর std::stable_sort আর std::inplace_merge মেমরি না পেলে এমন কিছুতেই নেমে আসে; standard-ও তাই সেখানে দুটি আলাদা সীমা দেয়।
তাত্ত্বিকভাবে আরও ভালো সম্ভব। ১৯৬৯ সালে সোভিয়েত গণিতবিদ M. A. Kronrod দেখান যে দুটি সাজানো অংশ O(1) বাড়তি জায়গায় আর linear সময়ে merge করা যায় — মূল কৌশল অ্যারের একটি অংশকে √n আকারের ব্লকে ভাগ করে সেটিকেই সাময়িক buffer হিসেবে ব্যবহার করা। পরে অনেকে (যেমন ১৯৮০-র দশকে Huang ও Langston) পদ্ধতিটিকে বাস্তবসম্মত আর stable করেন। এই ধারার আধুনিক বাস্তবায়নগুলো “block merge sort” নামে পরিচিত।
তাহলে লাইব্রেরিগুলো এগুলো ব্যবহার করে না কেন? কারণ ধ্রুবক বড়। O(n) buffer-এর সরল merge প্রতি উপাদানে একটি তুলনা আর একটি কপি করে, ক্রমানুসারে — cache আর CPU-র জন্য আদর্শ। In-place পদ্ধতিগুলো উপাদান কয়েকবার সরায়, ব্লক ঘোরায়, আর কোড অনেক জটিল — জটিল কোডে ভুলের সম্ভাবনাও বেশি। আজকের কম্পিউটারে n টি সংখ্যার জন্য আরও n টি জায়গা সাধারণত সস্তা, তাই বেশিরভাগ ক্ষেত্রে সরল পদ্ধতিই জেতে। In-place দরকার হলে মানুষ সাধারণত সরাসরি heapsort বা introsort বেছে নেয়, stability ছেড়ে দিয়ে।
মূল উপলব্ধি: Big-O-তে সম্ভব আর বাস্তবে লাভজনক — দুটি আলাদা প্রশ্ন। In-place stable merge তত্ত্বে linear সময়ে হয়, কিন্তু সরল Θ(n) buffer-এর সাথে বাস্তবে পাল্লা দিতে পারে না; প্রকৌশল মানে এই দুইয়ের মধ্যে সচেতন বাছাই।
প্র ০৪Merge sort-এর worst case তুলনা n lg n − n + 1, আর নিম্নসীমা lg n! — এর চেয়ে কম তুলনায় সাজানো কি সম্ভব?
পর্ব ২৪-এ প্রমাণ হবে: শুধু তুলনা করে সাজায় এমন যেকোনো অ্যালগরিদমের worst case-এ অন্তত ⌈lg n!⌉ তুলনা লাগে, কারণ n! টি সম্ভাব্য ক্রমের মধ্যে থেকে একটি বেছে নিতে হয়, আর প্রতিটি তুলনা সর্বোচ্চ দুই ভাগে ভাগ করে। Stirling-এর সূত্রে lg n! ≈ n lg n − 1.443n. Merge sort-এর worst case n lg n − n + 1 (n দুইয়ের ঘাত হলে)। পার্থক্য প্রতি উপাদানে প্রায় ০.৪৪টি তুলনা — ছোট, কিন্তু শূন্য নয়। অংশ ৪-এর টেবিলে n = 5 থেকে ১টি করে বেশি দেখেছেন।
এই ফাঁক কমানোর বিখ্যাত চেষ্টা merge insertion, যা Lester Ford Jr. আর Selmer Johnson ১৯৫৯ সালে প্রকাশ করেন (তাই একে Ford–Johnson algorithm-ও বলে)। কৌশলটি: উপাদানগুলো জোড়ায় জোড়ায় তুলনা করুন, বড়গুলোকে রিকার্সিভভাবে সাজান, তারপর ছোটগুলোকে binary insertion দিয়ে এমন একটি বিশেষ ক্রমে ঢোকান যাতে প্রতিটি binary search ঠিক দুইয়ের ঘাত-আকারের রেঞ্জে হয় — কোনো তুলনা “নষ্ট” না হয়। ফলে ছোট n-এ এটি নিম্নসীমার সমান বা প্রায় সমান তুলনা করে; যেমন n = 5-এ ৭টি, যেখানে merge sort-এর ৮টি লাগে।
বহু বছর ধরে মনে করা হতো Ford–Johnson হয়তো সব n-এ সর্বোত্তম। পরে দেখা গেছে কিছু নির্দিষ্ট n-এ এর চেয়েও কম তুলনায় সাজানো যায়; আর কোন n-এ ঠিক কত তুলনা সর্বনিম্ন, সেটি শুধু ছোট n-এর জন্য কম্পিউটার দিয়ে সব সম্ভাবনা খুঁজে বের করা হয়েছে। অর্থাৎ “n টি জিনিস সাজাতে সর্বনিম্ন কতটি তুলনা” — এই সরল প্রশ্নের সাধারণ উত্তর আজও জানা নেই।
তাহলে বাস্তবে Ford–Johnson কেউ ব্যবহার করে না কেন? কারণ তুলনা কমলেও অন্য কাজ বেড়ে যায় — জটিল সূচক-হিসাব, ডেটা বারবার সরানো, binary insertion-এর জন্য বড় অংশ সরানো। মোট সময়ে এটি merge sort বা quick sort-এর চেয়ে ধীর। ব্যতিক্রম শুধু সেখানে, যেখানে একটি তুলনা অবিশ্বাস্য রকম ব্যয়বহুল — যেমন মানুষকে দিয়ে দুটি জিনিস তুলনা করানো (টেস্টারদের দিয়ে দুটি ছবির মান যাচাই) বা একটি পরীক্ষাগার-পরীক্ষা।
মূল উপলব্ধি: Merge sort তুলনার হিসাবে প্রায় সর্বোত্তম — নিম্নসীমা থেকে প্রতি উপাদানে অর্ধেকেরও কম তুলনা দূরে। শেষ এই ফাঁকটুকু বন্ধ করার চেষ্টা গণিতকে সমৃদ্ধ করেছে, কিন্তু বাস্তবের গতি ঠিক করে মোট কাজ, শুধু তুলনা নয়।
প্র ০৫দুটি র্যাঙ্কিং কতটা আলাদা — Kendall tau ছাড়া আর কোন মাপ আছে, আর inversion গোনা কোথায় কাজে লাগে?
পরিসংখ্যানবিদ Maurice Kendall ১৯৩৮ সালে “A New Measure of Rank Correlation” প্রবন্ধে τ সংজ্ঞায়িত করেন: দুটি র্যাঙ্কিংয়ে কতগুলো জোড়া একমত (concordant) আর কতগুলো দ্বিমত (discordant), তার পার্থক্যকে মোট জোড়া n(n − 1)/2 দিয়ে ভাগ। দ্বিমত জোড়ার সংখ্যাই inversion — Kendall tau distance. পুরোপুরি এক ক্রম হলে τ = 1, পুরোপুরি উল্টো হলে −1. বহু বছর ধরে এর হিসাব Θ(n²) ধরে করা হতো; ১৯৬৬ সালে William Knight দেখান যে merge sort-এর ভেতরে গুনে O(n log n)-এ করা যায় — ঠিক অংশ ৯-এর কৌশল।
আরেকটি জনপ্রিয় মাপ Spearman-এর footrule: প্রতিটি জিনিসের দুই র্যাঙ্কিংয়ের অবস্থানের পার্থক্যের পরম মান যোগ করা। এটি O(n)-এ হিসাব হয়। ১৯৭৭ সালে Persi Diaconis আর Ronald Graham প্রমাণ করেন যে দুটি মাপ একে অপরের কাছাকাছি: Kendall distance ≤ footrule ≤ ২ × Kendall distance. তাই দ্রুত আনুমানিক তুলনার জন্য footrule, আর জোড়া-ভিত্তিক নিখুঁত মাপের জন্য Kendall. Spearman-এর আরেকটি মাপ ρ অবস্থানের পার্থক্যের বর্গ ব্যবহার করে।
প্রয়োগ অনেক। Recommendation system — দুজন ব্যবহারকারী একই সিনেমাগুলোকে কোন ক্রমে পছন্দ করেন, তা মিলিয়ে “আপনার মতো রুচির মানুষ” খোঁজা। সার্চ ইঞ্জিনের দুটি ranking-অ্যালগরিদমের ফল তুলনা। একাধিক বিচারকের র্যাঙ্কিং থেকে একটি সম্মিলিত র্যাঙ্কিং বানানো (rank aggregation) — যে র্যাঙ্কিং সবার থেকে মোট Kendall দূরত্ব সবচেয়ে কম, তাকে বলে Kemeny ranking; এটি খুঁজে বের করা NP-hard (পর্ব ৬৪-৬৫-এর ভাষায়), তাই বাস্তবে আনুমানিক পদ্ধতি লাগে। জীববিজ্ঞানে দুটি genome-এ জিনের ক্রম কতটা বদলেছে, তা মাপতেও inversion-জাতীয় ধারণা ব্যবহৃত হয়।
কনটেস্টেও inversion গোনা একটি মৌলিক ইট: “পাশাপাশি দুটি উপাদান অদলবদল করে সাজাতে সর্বনিম্ন কয়টি swap?” — উত্তর ঠিক inversion-এর সংখ্যা, কারণ প্রতিটি পাশাপাশি swap ঠিক একটি inversion কমায় বা বাড়ায়। একই কাজ পর্ব ৩৬-এর Fenwick tree দিয়েও O(n log n)-এ করা যায়।
মূল উপলব্ধি: Inversion গোনা দেখায়, একটি অ্যালগরিদমের কাঠামো শুধু তার মূল কাজের জন্য নয় — merge sort-এর “কে কোন অর্ধেকে” তথ্যটি বিনা খরচে একটি সম্পূর্ণ ভিন্ন প্রশ্নের উত্তর দেয়। ভালো অ্যালগরিদম-ডিজাইনার চেনা অ্যালগরিদমের ভেতরে এমন বাড়তি তথ্য খোঁজেন।
অনুশীলন
খাতা-কলম নিন। প্রথম তিনটি হাতে করার, শেষটিতে কোড লিখতে হবে।
-
হাতে trace করুন:
L = [1, 4, 9]আরR = [2, 3, 10, 11]merge করুন। প্রতিটি তুলনা লিখুন। মোট কয়টি তুলনা হলো? এই দুই আকারের জন্য সম্ভাব্য সর্বনিম্ন আর সর্বোচ্চ কত?1 বনাম 2 → 1; 4 বনাম 2 → 2; 4 বনাম 3 → 3; 4 বনাম 10 → 4; 9 বনাম 10 → 9. এখন L ফুরিয়েছে — 10, 11 তুলনা ছাড়াই কপি। ফল: 1 2 3 4 9 10 11, তুলনা ৫টি.
m = 3, n = 4-এর জন্য সর্বনিম্ন min(3, 4) = ৩ (যেমন L-এর সবাই R[0]-এর চেয়ে ছোট হলে), সর্বোচ্চ m + n − 1 = ৬ (দুই অংশ একেবারে শেষ পর্যন্ত ফাঁকে ফাঁকে বসলে)। এখানে R-এর শেষ দুটি উপাদান L-এর সবার চেয়ে বড় ছিল, তাই একটি তুলনা কম লেগেছে।
-
Inversion হাতে গুনুন:
[4, 1, 3, 2]-এর inversion merge sort-এর পদ্ধতিতে গুনুন — প্রতিটি merge-এ কয়টি যোগ হলো লিখুন। তারপর সব জোড়া লিখে brute force-এ মিলিয়ে নিন।[4] + [1]: 1 নেওয়ার সময় বাঁয়ে 4 অপেক্ষায় → +1. [3] + [2]: +1. এখন [1 4] + [2 3]: 1 (বাঁ) যায়; 2 (ডান) নেওয়ার সময় বাঁয়ে 4 → +1; 3 (ডান) নেওয়ার সময় বাঁয়ে 4 → +1; তারপর 4. cross = ২. মোট ১ + ১ + ২ = ৪.
Brute force: (4, 1), (4, 3), (4, 2), (3, 2) — ৪টি। মিলেছে। ৪টি উপাদানে সর্বোচ্চ ৬টি inversion সম্ভব, তাই অ্যারেটি বেশ এলোমেলো।
-
বিশ্লেষণ করুন: একজন বললেন, “মাঝখানে না ভেঙে প্রতিবার শেষ উপাদানটিকে আলাদা করি — n − 1 আর 1-এ ভাগ — merge তো একই থাকছে।” এই রূপের recurrence আর time complexity কত? আর ৩ ভাগে ভাঙলে (T(n) = 3T(n/3) + Θ(n)) কী হয়?
n − 1 আর 1-এ ভাগ করলে T(n) = T(n − 1) + T(1) + Θ(n) = T(n − 1) + Θ(n), যা খুলে লিখলে n + (n − 1) + … + 1 = Θ(n²). আসলে এটি insertion sort-ই — প্রতিবার একটি উপাদানকে সাজানো অংশে “merge” করে ঢোকানো। Recursion tree-র উচ্চতা এখন n, lg n নয়; সুষম ভাগই merge sort-এর গতির উৎস। গভীরতা n হওয়ায় বড় n-এ stack overflow-ও হবে।
৩ ভাগে: a = 3, b = 3, watershed n1 = f(n) — master theorem case 2, Θ(n log n). স্তর কমে log₃ n হয়, কিন্তু তিনটি অংশের merge-এ প্রতিটি উপাদানের জন্য দুটি তুলনা লাগে (সরলভাবে), তাই মোট তুলনা প্রায় 2n log₃ n ≈ 1.26 n lg n — দুই ভাগের চেয়ে বেশি। ভাগ বাড়ালেই লাভ নয়।
-
কোড লিখুন: রিকার্শন ছাড়া একটি bottom-up merge sort লিখুন (C++ বা Python), যেকোনো n-এর জন্য (দুইয়ের ঘাত না হলেও)। তারপর
[6, 3, 8, 1, 5, 2, 7]-এ চালিয়ে প্রতিটি পাসের পর অ্যারে ছাপান।মূল চিন্তা: width = 1, 2, 4, …; প্রতিটি পাসে lo = 0, 2w, 4w, …; mid আর hi-কে
min(…, n)দিয়ে কাটুন। একটি buffer একবার বানান।void bottomUpMergeSort(vector<int>& a) { int n = (int)a.size(); vector<int> buf(n); for (int w = 1; w < n; w *= 2) { for (int lo = 0; lo < n; lo += 2 * w) { int mid = min(lo + w, n), hi = min(lo + 2 * w, n); int i = lo, j = mid, k = lo; while (i < mid && j < hi) buf[k++] = (a[i] <= a[j]) ? a[i++] : a[j++]; while (i < mid) buf[k++] = a[i++]; while (j < hi) buf[k++] = a[j++]; } a = buf; // whole pass done: copy back (or swap the roles) // print a here to see each pass } }পাসগুলো:
3 6 1 8 2 5 7→1 3 6 8 2 5 7→1 2 3 5 6 7 8— অংশ ৬-এর ডায়াগ্রামের সাথে মেলে। খেয়াল করুন: জোড়াহীন শেষ টুকরোর ক্ষেত্রে (mid = hi) ভেতরের তিনটি লুপের প্রথম আর তৃতীয়টি চলেই না, দ্বিতীয়টি বাঁ অংশটি হুবহু buf-এ কপি করে — তাইa = bufকরলে কিছু হারায় না।a = buf-এর বদলেswap(a, buf)করলে প্রতি পাসের কপি বাঁচে (শেষে দরকার হলে একবার কপি)। সময় Θ(n log n), বাড়তি জায়গা Θ(n), stack Θ(1).
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমঅ্যারের অংশ a[lo..hi)-এ রিকার্সিভ merge sort-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- অংশে ১টির বেশি উপাদান আছে কি না দেখা; না থাকলে ফিরে যাওয়া
- mid = lo + (hi − lo) / 2 বের করা
- বাঁ অর্ধেক a[lo..mid) রিকার্শন দিয়ে সাজানো
- ডান অর্ধেক a[mid..hi) রিকার্শন দিয়ে সাজানো
- দুই pointer দিয়ে দুই অর্ধেক buffer-এ merge করা (সমান হলে বাঁ দিক থেকে)
- buffer থেকে ফলাফল a[lo..hi)-এ ফেরত কপি করা
৫টি আর ৭টি উপাদানের দুটি সাজানো অ্যারে two pointer পদ্ধতিতে merge করতে worst case-এ সর্বোচ্চ কয়টি তুলনা লাগে?
- ৫
- ৭
- ১১
- ১২
সাধারণ (top-down, অপরিবর্তিত) merge sort-এ n টি উপাদানের আগে থেকেই সাজানো অ্যারে দিলে time complexity কত?
- Θ(n)
- Θ(n log n)
- Θ(n²)
- Θ(log n)
Merge ধাপে বাঁ অংশের a[i] আর ডান অংশের a[j] সমান হলে কোনটি আগে নিলে merge sort stable হয়?
- বাঁ অংশের a[i] (শর্ত
a[i] <= a[j]) - ডান অংশের a[j] (শর্ত
a[i] < a[j]) - যেকোনো একটি এলোমেলোভাবে
- দুটোকেই বাদ দিয়ে পরের উপাদানে যাওয়া
n উপাদানের অ্যারেতে top-down merge sort-এর মোট বাড়তি জায়গা (auxiliary space) কত, একটি buffer একবার বানালে?
- Θ(1)
- Θ(log n)
- Θ(n log n)
- Θ(n)
অ্যারে [3, 1, 2]-এ কয়টি inversion আছে (i < j অথচ a[i] > a[j])?
- ১
- ২
- ৩
- ০
External sorting-এ k টি সাজানো run (মোট N টি উপাদান) একটি min-heap দিয়ে একসাথে merge করলে মোট সময় কত?
- O(N)
- O(N·k)
- O(N log k)
- O(N log N), শুধু merge-ধাপের জন্য
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- Merge: দুই pointer, ছোটটি নেওয়া, শেষে বাকিটা কপি — Θ(m + n), তুলনা min(m, n) থেকে m + n − 1. সমান হলে বাঁ দিক থেকে, তাই stable.
- Merge sort-এর recurrence T(n) = 2T(n/2) + Θ(n) = Θ(n log n) — best, average, worst তিনটিতেই। n = 2k-এ worst case তুলনা n lg n − n + 1.
- অ্যারেতে বাড়তি জায়গা Θ(n) buffer + Θ(log n) stack; buffer একবার বানান, ছোট অংশে insertion sort.
- Bottom-up = রিকার্শন ছাড়া width 1, 2, 4…; linked list-এ buffer লাগে না; external sort = run বানানো, তারপর heap দিয়ে k-way merge, O(N log k).
- Inversion গোনা: ডান থেকে নেওয়ার সময়
inv += mid − i— O(n log n), উত্তর রাখুনlong long-এ।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ২২ · Quick sort, pivot আর quickselectপরবর্তী পর্বভাগেই সব কাজ — partition, গড় আর worst case.
- ডেটা স্ট্রাকচার ও অ্যালগরিদম · কোর্স হোম৭২টি পর্বপুরো কোর্সের রোডম্যাপ — নয়টি মডিউল।
- পর্ব ০৯ · Master theoremআগের ভিত্তিT(n) = aT(n/b) + f(n)-এর তিনটি case, merge sort-সহ।
- পর্ব ০৮ · Recurrence সমাধানআগের ভিত্তিSubstitution আর recursion tree — Θ(n log n)-এর প্রমাণ।
- C প্রোগ্রামিং · সর্টিংভাষার দিকC-তে অ্যারে সাজানোর অ্যালগরিদম আর
qsort. - C++ প্রোগ্রামিং · STL algorithmপাশের ট্র্যাক
std::sort,std::stable_sort,std::mergeআরinplace_merge. - Python প্রোগ্রামিং · Python-এ অ্যালগরিদমপাশের ট্র্যাক
sorted, key ফাংশন আরheapq.merge.