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

বাস্তবে সর্টিং — stable sort, comparator আর লাইব্রেরি

Sorting in Practice — Library Algorithms, Stability, Comparators, Multi-Key Sorting, Ranking & Coordinate Compression

Taught in Bangla, this lesson shows what library sorts really run (introsort, Timsort, dual-pivot quicksort), why stability matters, how strict weak ordering makes comparators safe, multi-key and struct sorting in C++ and Python, string and natural sort, argsort, dense rank, coordinate compression, top-k with partial sorting, and a comparison table of every sort in the module.

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

  • C++, Python, Java আর JavaScript-এর তৈরি sort ভেতরে কোন অ্যালগরিদম চালায়, আর তার ফল কী
  • Stable sort কেন দরকার, আর কয়েক ধাপে stable sort দিয়ে একাধিক key-তে সাজানোর কৌশল
  • সঠিক comparator লেখা — strict weak ordering, আর <= কেন crash ঘটাতে পারে
  • String-এর lexicographic, case-insensitive আর natural sort; argsort, dense rank আর coordinate compression
  • মডিউল ৩-এর সব sort-এর তুলনা টেবিল, আর পরিস্থিতি দেখে সঠিক sort বেছে নেওয়া

১ · লাইব্রেরির sort ভেতরে কী করে

পর্ব ২০ থেকে ২৪ পর্যন্ত আপনি নিজের হাতে insertion, merge, quick, heap আর counting sort লিখেছেন। কিন্তু কাজের কোডে বা কনটেস্টে প্রায় কেউই নিজে sort লেখেন না — এক লাইনে sort(v.begin(), v.end()) বা a.sort(). তাহলে এতগুলো অ্যালগরিদম শেখা কেন? কারণ লাইব্রেরির sort আসলে এই অ্যালগরিদমগুলোরই চতুর মিশ্রণ। কোনটি কখন কোনটিতে বদলায়, কোনটি stable, কোনটি বাড়তি মেমরি নেয় — এগুলো না জানলে আপনি ভুল ফাংশন বেছে নেবেন, বা ভুল comparator দিয়ে প্রোগ্রাম crash করাবেন।

ঢাকা থেকে চট্টগ্রাম যাওয়ার একজন অভিজ্ঞ বাসচালকের কথা ভাবুন। হাইওয়েতে তিনি দ্রুত চালান (quick sort-এর গড় গতি)। হঠাৎ জ্যামে আটকে গেলে বিকল্প রাস্তা ধরেন, যা হয়তো একটু লম্বা কিন্তু কখনো আটকায় না (heapsort-এর নিশ্চয়তা)। আর শহরের ভেতরের শেষ কয়েকটি গলিতে ধীরে, সাবধানে চালান — ছোট টুকরোয় insertion sort-এর মতো। লাইব্রেরির sort ঠিক এমন একজন চালক — একটি অ্যালগরিদম নয়, পরিস্থিতি বুঝে কয়েকটির মিশ্রণ।

C++: std::sort, std::stable_sort, std::partial_sort, std::nth_element

C++11 থেকে standard বলে দেয়, std::sort-কে worst case-এও O(n log n) তুলনায় শেষ করতে হবে। কিন্তু কোন অ্যালগরিদমে, তা বলে না। GCC-র libstdc++ (আর বেশিরভাগ বাস্তবায়ন) ব্যবহার করে introsort, যা David Musser ১৯৯৭ সালে প্রস্তাব করেন:

  1. শুরুতে quick sort — median-of-3 pivot দিয়ে partition (পর্ব ২২)। গড়ে এটিই সবচেয়ে দ্রুত, আর in-place.
  2. রিকার্শন বেশি গভীর হলে heapsort — গভীরতার সীমা প্রায় 2·log₂ n. সীমা পেরোনো মানে pivot বারবার খারাপ পড়ছে, quick sort O(n²)-এর দিকে যাচ্ছে। তখন সেই টুকরোটি heapsort দিয়ে শেষ হয় (পর্ব ২৩), যার worst case O(n log n).
  3. ছোট টুকরোয় insertion sort — libstdc++-এ ১৬টি বা তার কম উপাদানের টুকরো quick sort আর ভাগ করে না, ফেলে রাখে। শেষে পুরো array-তে একবার insertion sort চলে। তখন প্রতিটি উপাদান তার আসল জায়গার খুব কাছে, তাই insertion sort প্রায় O(n) (পর্ব ২০).

ফল: গড়ে quick sort-এর গতি, worst case-এ heapsort-এর নিশ্চয়তা, আর ছোট টুকরোয় insertion sort-এর কম overhead. বাড়তি জায়গা শুধু রিকার্শনের stack, O(log n). কিন্তু একটি দাম আছে — std::sort stable নয়. সমান দুটি উপাদানের আগের ক্রম বদলে যেতে পারে।

ফাংশনকী করেসাধারণ বাস্তবায়নসময়Stable?
sort(b, e)পুরো পরিসর সাজায়introsortO(n log n), worst case-এওনা
stable_sort(b, e)সমান উপাদানের ক্রম রেখে সাজায়merge sort (বাফারসহ)O(n log n); বাড়তি মেমরি না পেলে O(n log² n)হ্যাঁ
partial_sort(b, m, e)সবচেয়ে ছোট k = m − b টি সাজিয়ে সামনে রাখেheapO(n log k)না
nth_element(b, nth, e)k-তম উপাদানকে ঠিক জায়গায় বসায়; বাঁয়ে ছোট বা সমান, ডানে বড় বা সমানintroselect (quickselect + নিরাপত্তা)গড়ে O(n)না
is_sorted(b, e)সাজানো কি না যাচাইপাশাপাশি জোড়া দেখাO(n)—

Python: sorted আর list.sort — Timsort

Python-এর দুটি sort একই অ্যালগরিদম চালায়: Timsort, যা Tim Peters ২০০২ সালে Python-এর জন্য লেখেন। sorted(x) নতুন list দেয়, a.sort() জায়গাতেই সাজায় আর None ফেরত দেয়। Timsort-এর মূল ধারণা: বাস্তবের ডেটা খুব কম ক্ষেত্রেই পুরো এলোমেলো। প্রায়ই তাতে আগে থেকেই সাজানো লম্বা টুকরো থাকে — যেমন তারিখ ধরে সাজানো bKash লেনদেনের তালিকার শেষে কয়েকটি নতুন লেনদেন। এমন টুকরোকে বলে run. Timsort তিনটি কাজ করে:

  • Run খোঁজা: বাঁ থেকে ডানে গিয়ে বাড়তে থাকা (non-decreasing) বা কঠোরভাবে কমতে থাকা টুকরো চিনে নেয়। কমতে থাকা run উল্টে দেয়। run খুব ছোট হলে (minrun, সাধারণত ৩২ থেকে ৬৪-এর মধ্যে) binary insertion sort দিয়ে সেটিকে minrun মাপ পর্যন্ত বড় করে।
  • Run জোড়া দেওয়া: run-গুলো একটি stack-এ রাখে আর কিছু নিয়ম মেনে পাশাপাশি run-কে merge করে, যাতে মাপে কাছাকাছি run-গুলো জোড়া লাগে — merge sort-এর মতো ভারসাম্য থাকে।
  • Galloping: merge-এর সময় যদি একটি run থেকে পরপর অনেকগুলো (শুরুতে ৭টি) উপাদান জেতে, Timsort একটি একটি করে তুলনা ছেড়ে 1, 3, 7, 15 …-এর মতো লাফ দিয়ে খোঁজে (exponential search), তারপর binary search. ফলে একটি run-এর বড় অংশ একসাথে কপি হয়।

ফল: পুরো সাজানো বা পুরো উল্টো ইনপুটে O(n) — মাত্র n − 1 টি তুলনা। worst case O(n log n). বাড়তি জায়গা O(n) (merge-এর বাফার)। আর এটি stable — Python-এর documentation এর নিশ্চয়তা দেয়। এমন sort, যা ইনপুটে যত বেশি ক্রম আগে থেকে আছে তত দ্রুত চলে, তাকে বলে adaptive sort.

ইনপুট [2, 4, 6, 8, 9, 7, 3, 1, 5, 10] — বোঝার সুবিধায় minrun = 2 ধরা হলো
ধাপ
array
run-এর stack (দৈর্ঘ্য)
মোট তুলনা (আনুমানিক)
  1. শুরু. ইনপুটে চোখে দেখা যায় এমন তিনটি টুকরো আছে: একটি বাড়ছে, একটি কমছে, শেষে আরেকটি বাড়ছে। সাধারণ merge sort এগুলো খেয়ালই করত না — অন্ধভাবে মাঝখান থেকে ভাগ করত।
  2. Run ১. 2 ≤ 4 ≤ 6 ≤ 8 ≤ 9 — বাড়ছে। তারপর 7 এসে 9-এর চেয়ে ছোট, তাই run এখানে শেষ। দৈর্ঘ্য 5, stack-এ রাখা হলো।
  3. Run ২. 7 > 3 > 1 — কঠোরভাবে কমছে; 5 এসে 1-এর চেয়ে বড়, run শেষ। “কঠোরভাবে” কেন? সমান উপাদান থাকলে উল্টানোর সময় তাদের ক্রমও উল্টে যেত — stability ভাঙত। তাই কমতে থাকা run-এ সমান মান ঢুকতে দেওয়া হয় না।
  4. উল্টানো. কমতে থাকা run-কে জায়গাতেই উল্টে দেওয়া হলো: [1 3 7]. খরচ O(দৈর্ঘ্য), কোনো তুলনা নেই।
  5. Run ৩. 5 ≤ 10 — দৈর্ঘ্য 2. stack-এ এখন তিনটি run: 5, 3, 2. Timsort-এর মূল নিয়ম (Python ৩.১০ পর্যন্ত) — নিচ থেকে A, B, C হলে A > B + C আর B > C থাকতে হবে। এখানে 5 > 3 + 2 ভেঙেছে (5 = 5), তাই B-কে A আর C-এর মধ্যে যেটি ছোট তার সাথে জোড়া দেওয়া হয় — অর্থাৎ C-র সাথে।
  6. Run ২ + run ৩. [1 3 7] আর [5 10] merge হয়ে [1 3 5 7 10]. ছোট দুটি run আগে জোড়া লাগল — ঠিক merge sort-এর মতো ভারসাম্য।
  7. শেষ merge. [2 4 6 8 9] আর [1 3 5 7 10] জোড়া লেগে পুরো array সাজানো। আসল Timsort-এ n < 64 হলে minrun = n, তাই প্রথম run খুঁজে বাকিটা binary insertion sort দিয়ে তাতে জুড়ে দেয় — কোনো merge-ই লাগে না; এখানে minrun = 2 ধরা হয়েছে শুধু ধারণাটি দেখাতে।

Python ৩.১১-এর পরিবর্তন: run খোঁজা, binary insertion আর galloping আগের মতোই আছে। বদলেছে শুধু merge policy — কোন দুটি run কখন জোড়া লাগবে, সেই নিয়ম। Python ৩.১১ থেকে CPython ওপরের “A > B + C” নিয়মের বদলে J. Ian Munro আর Sebastian Wild-এর ২০১৮ সালের গবেষণার powersort নিয়ম ব্যবহার করে, যা run-গুলোর মাপ খুব অসমান হলেও প্রায় সর্বোত্তম ক্রমে merge করে। আপনার কোডে কিছু বদলাতে হবে না; ফল, stability আর O(n log n) নিশ্চয়তা একই।

Java আর JavaScript — সংক্ষেপে

Java দুই রকম ডেটার জন্য দুই রকম অ্যালগরিদম রাখে। int[], double[]-এর মতো primitive array-তে Arrays.sort চালায় dual-pivot quicksort (Vladimir Yaroslavskiy, Jon Bentley আর Joshua Bloch, Java 7 থেকে) — দুটি pivot নিয়ে array-কে তিন ভাগ করে। আর object-এর array বা List-এ (Collections.sort, List.sort) চালায় Timsort. কেন এই ভাগ? দুটি সমান int-কে আলাদা করে চেনার কোনো উপায় নেই — তাই সেখানে stability-র কোনো মানে নেই, শুধু গতি দরকার। কিন্তু দুটি Student object-এর নম্বর সমান হলেও তারা আলাদা মানুষ — সেখানে stability জরুরি।

JavaScript-এ ECMAScript 2019 থেকে Array.prototype.sort-কে stable হতেই হবে; V8 (Chrome, Node.js) এর জন্য Timsort ব্যবহার করে। তবে একটি পুরোনো ফাঁদ এখনো আছে: comparator না দিলে JavaScript সব উপাদানকে string বানিয়ে তুলনা করে। তাই [10, 9, 1].sort() দেয় [1, 10, 9]. সংখ্যার জন্য সবসময় লিখুন a.sort((x, y) => x - y).

মূল ধারণা

লাইব্রেরির sort বেছে নেওয়ার আগে তিনটি প্রশ্ন: stable কি? (C++ sort না, stable_sort হ্যাঁ; Python, Java-র object, JavaScript হ্যাঁ), বাড়তি মেমরি নেয় কি? (introsort O(log n), Timsort আর stable_sort O(n)), আর worst case কত? (সবগুলোই O(n log n) — আধুনিক লাইব্রেরি O(n²)-এর ঝুঁকি রাখে না)।

২ · Stable sort — সমান হলে আগের ক্রম অটুট

পরীক্ষার হলে খাতা জমা পড়েছে রোল নম্বরের ক্রমে। এখন শিক্ষক খাতাগুলো প্রাপ্ত নম্বর অনুযায়ী সাজাবেন। দুজনের নম্বর সমান হলে কে আগে? যদি সাজানোর পদ্ধতি নিশ্চয়তা দেয় যে সমান নম্বরের খাতাগুলো আগের মতোই রোলের ক্রমে থাকবে, তাহলে সেটি stable sort. নিশ্চয়তা না দিলে — unstable — তারা যেকোনো ক্রমে আসতে পারে, আর এক রানে এক রকম, আরেক রানে আরেক রকম (pivot এলোমেলো হলে) আসাও সম্ভব।

আনুষ্ঠানিক সংজ্ঞা: যদি ইনপুটে i < j আর key(a[i]) = key(a[j]) হয়, তাহলে stable sort-এর আউটপুটে a[i] অবশ্যই a[j]-এর আগে থাকবে। খেয়াল করুন, শুধু সংখ্যা সাজালে stability চোখেই পড়ে না — দুটি 5 দেখতে এক। Stability-র প্রশ্ন ওঠে তখনই, যখন উপাদানে key ছাড়াও অন্য তথ্য থাকে: নাম, রোল, লেনদেনের সময়।

কোন sort stable, কোনটি নয় — আর কেন

  • Stable: insertion sort (শুধু কঠোরভাবে বড় হলে সরায়), merge sort (merge-এ সমান হলে বাঁ দিকেরটি আগে নেয় — <= দিয়ে), counting sort আর LSD radix sort (শেষ থেকে বসালে), Timsort.
  • Unstable: quick sort (partition-এ দূরের উপাদান অদলবদল হয়), heapsort (heap-এর গঠন ক্রম মনে রাখে না), selection sort (array-তে swap দূরের উপাদানকে লাফিয়ে সরায়), introsort.

যেকোনো sort-কে stable বানানোর একটি সাধারণ কৌশল আছে: প্রতিটি উপাদানের সাথে তার মূল index জুড়ে দিন, আর key সমান হলে index দিয়ে তুলনা করুন। তখন কোনো দুটি উপাদান আর “সমান” থাকে না, তাই stability-র প্রশ্নই ওঠে না। খরচ: প্রতি উপাদানে একটি বাড়তি সংখ্যা, Θ(n) জায়গা।

কয়েক ধাপে সাজানোর কৌশল (multi-pass stable sort)

Stability-র সবচেয়ে কাজের প্রয়োগ: একাধিক মাপকাঠিতে সাজানো, কোনো জটিল comparator ছাড়াই। লক্ষ্য — নম্বর বড় থেকে ছোট, নম্বর সমান হলে নাম বর্ণানুক্রমে। কৌশল হলো উল্টো দিক থেকে: আগে গৌণ key (নাম) দিয়ে সাজান, তারপর মুখ্য key (নম্বর) দিয়ে stable sort করুন। দ্বিতীয় ধাপ সমান নম্বরের ছাত্রদের ছোঁয় না — তাই প্রথম ধাপের নামের ক্রম তাদের মধ্যে টিকে থাকে।

লক্ষ্য: নম্বর বড় → ছোট, সমান হলে নাম A → Z
ধাপ
তালিকা
লক্ষ্য পূরণ?
  1. মূল তালিকা. ছয়জন ছাত্র, খাতা জমার ক্রমে। দুজনের 92, দুজনের 85, দুজনের 78 — তিনটি “টাই”, যা ভাঙতে হবে নাম দিয়ে।
  2. ধাপ ১ — গৌণ key আগে. শুধু নাম ধরে সাজালাম: Arif, Karim, Nusrat, Rahim, Sadia, Tanvir. নম্বরের দিকে এখনো কোনো খেয়াল নেই।
  3. ধাপ ২ — মুখ্য key শেষে, stable. নম্বর ধরে বড় থেকে ছোট সাজালাম। 92-এর দুজন (Karim, Nusrat) ধাপ ১-এ যে ক্রমে ছিল, সেই ক্রমেই রইল; 85-এ Arif তারপর Sadia; 78-এ Rahim তারপর Tanvir. লক্ষ্য পূরণ। মোট খরচ দুটি sort — O(n log n).
  4. ভুল ক্রমে করলে. আগে নম্বর, পরে নাম ধরে সাজালে শেষ sort-টিই জেতে — ফল শুধু নামের ক্রম, ওপরের ধাপ ১-এর তালিকার মতো; নম্বরের সব কাজ মুছে যায়। নিয়ম: যে key সবচেয়ে গুরুত্বপূর্ণ, সেটি দিয়ে সবার শেষে সাজান।
  5. ধাপ ২-এ unstable sort দিলে. std::sort-এর মতো unstable sort সমান নম্বরের ছাত্রদের নিজের ইচ্ছামতো সাজাতে পারে — যেমন Nusrat আগে, Karim পরে। ধাপ ১-এর পরিশ্রম নষ্ট। তাই এই কৌশলে শেষ ধাপগুলো অবশ্যই stable sort: C++-এ stable_sort, Python-এ যেকোনো sort.

এই কৌশলটি spreadsheet-এ আপনি না জেনেই ব্যবহার করেন: Excel বা Google Sheets-এ আগে “নাম” কলামে sort, তারপর “নম্বর” কলামে sort করলে ঠিক এই ফল পাবেন। Python-এর documentation-ও একাধিক key-তে সাজানোর একটি পথ হিসেবে এটি দেখায়, বিশেষ করে যখন একটি key বড় থেকে ছোট আর অন্যটি ছোট থেকে বড়, আর key-টি এমন যে তার আগে মাইনাস বসানো যায় না, যেমন string-এর ক্ষেত্রে।

৩ · Comparator আর strict weak ordering — <= কেন বিপদ

নিজের ক্রমে সাজাতে C++-এ comparator দিতে হয় — দুটি উপাদান নিয়ে একটি প্রশ্নের উত্তর দেওয়া ফাংশন: “a কি b-এর কঠোরভাবে আগে বসবে?” খেয়াল করুন “কঠোরভাবে” শব্দটি। এটি “a কি b-এর আগে বা সমান?” নয়। Standard অনুযায়ী comparator-কে একটি strict weak ordering হতে হবে। এর চারটি শর্ত, comp(a, b) মানে “a আগে”:

শর্তমানে< মানে কি?<= মানে কি?
Irreflexivecomp(a, a) সবসময় false — কেউ নিজের আগে বসে নাহ্যাঁ: 5 < 5 falseনা: 5 <= 5 true
Asymmetriccomp(a, b) true হলে comp(b, a) falseহ্যাঁনা: a = b হলে দুটোই true
Transitivecomp(a, b) আর comp(b, c) true হলে comp(a, c) trueহ্যাঁহ্যাঁ
“সমান”-ও transitivea ~ b আর b ~ c হলে a ~ c, যেখানে a ~ b মানে দুটো comp-ই falseহ্যাঁঅর্থহীন — কেউই “সমান” হয় না

শেষ শর্তটি সূক্ষ্ম। “weak” শব্দটি এখান থেকেই: দুটি আলাদা উপাদানও “সমান” (equivalent) হতে পারে — যেমন একই নম্বরের দুজন ছাত্র — কিন্তু এই সমতা নিজে একটি ঠিকঠাক সম্পর্ক হতে হবে। একটি ভাঙা উদাহরণ: “দুটি সংখ্যার পার্থক্য ১-এর কম হলে সমান ধরো”. তাহলে 1 ~ 1.6 আর 1.6 ~ 2.2, কিন্তু 1 আর 2.2 সমান নয় — sort তখন দিশেহারা।

কেন <= শুধু ভুল ক্রম নয়, crash ঘটাতে পারে

লাইব্রেরি comparator-এর এই শর্তের ওপর ভরসা করে গতি বাড়ায়। libstdc++-এর introsort-এর শেষ ধাপে insertion sort চলে, আর প্রথম ১৬টি ঘর সাজানোর পর ধরে নেওয়া যায় যে a[0]-এ সবচেয়ে ছোট উপাদানটি আছে। তাই বাকি অংশের insertion-এ ভেতরের লুপে “j > 0 কি না” যাচাই রাখা হয় না — প্রতি ধাপে একটি তুলনা বাঁচে। লুপ থামবে, কারণ comp(val, a[0]) কখনো true হবে না: কোনো উপাদান সবচেয়ে ছোটটির কঠোরভাবে আগে বসে না। এই কৌশলের নাম unguarded insertion — a[0] এখানে একটি প্রহরী (sentinel). এবার <= দিলে কী হয়, দেখুন:

a[0..3] = [3, 5, 8, 3], a[0] = 3 সবচেয়ে ছোট; শেষের 3-কে (val) ঠিক জায়গায় ঢোকানো হচ্ছে
comparator
j (যার সাথে তুলনা)
comp(val, a[j])
a[0] a[1] a[2] a[3]
  1. শুরু. a[0..2] সাজানো, আর a[0] = 3 পুরো array-র সবচেয়ে ছোট — প্রহরী। শেষ ঘরের 3-কে val হিসেবে তুলে নিলাম; তার জায়গা এখন খালি (_).
  2. j = 2. val কি 8-এর আগে বসবে? 3 < 8, হ্যাঁ। 8-কে এক ঘর ডানে সরালাম।
  3. j = 1. 3 < 5, হ্যাঁ। 5 ডানে সরল। লুপে কোনো “j > 0” যাচাই নেই — থামার ভার পুরোপুরি comparator-এর ওপর।
  4. j = 0, comparator <. 3 < 3 = false. লুপ থামল, val বসল a[1]-এ: [3, 3, 5, 8]. সঠিক, আর দুটি 3-এর আগের ক্রমও রইল। প্রহরী তার কাজ করেছে।
  5. j = 0, comparator <=. এবার একই জায়গায় 3 <= 3 = true! comparator বলছে “val প্রহরীরও আগে বসবে”. প্রহরীকে ডানে সরানো হলো, আর j হয়ে গেল −1.
  6. j = −1. এখন কোড a[−1] পড়ছে — array শুরু হওয়ার আগের মেমরি, যা আপনার নয়। সেখানে যা আছে তা আবর্জনা; সেটিও যদি comp-এ true দেয়, লুপ আরও পেছনে যায় আর অন্যের মেমরিতে লেখে. ফল হতে পারে segmentation fault, নীরবে নষ্ট হওয়া অন্য ভ্যারিয়েবল, বা কিছুই না — এটিই undefined behavior. বাস্তবে অনেক সমান মানের একটি বড় vector-এ <= দিয়ে std::sort চালালে crash প্রায়ই দেখা যায়।

একই ব্যাপার partition-এর ভেতরের লুপেও ঘটে: “pivot-এর চেয়ে ছোট হলে এগোও” লুপটি pivot-এর সমান উপাদানে থামবে বলে ধরে নেওয়া হয়। <= দিলে সব উপাদান “ছোট” — pointer array-র শেষ পেরিয়ে চলে যায়। ছোট পরীক্ষায় (১৬টির কম উপাদান, বা কোনো সমান মান নেই) প্রোগ্রাম ঠিকঠাক চলে, তাই ভুলটি ধরা পড়ে অনেক পরে — online judge-এ runtime error হয়ে, বা কাজের সার্ভারে।

// WRONG: not irreflexive -- cmp(x, x) is true. Undefined behaviour in std::sort.
sort(v.begin(), v.end(), [](int a, int b) { return a <= b; });
// RIGHT: strict "comes before"
sort(v.begin(), v.end(), [](int a, int b) { return a < b; });
// descending: swap the roles, never use >=
sort(v.begin(), v.end(), [](int a, int b) { return a > b; });   // or greater<int>()
// quick self-check while debugging (cmp = your comparator): no element may come before itself
for (int x : v) assert(!cmp(x, x));

অন্য ভাষায় একই ভুলের চেহারা। Java-র Timsort অসঙ্গত comparator ধরতে পারলে IllegalArgumentException: Comparison method violates its general contract! দেয় — অনেক Java প্রোগ্রামার জীবনে অন্তত একবার এই বার্তা দেখেছেন। Python-এ key function থাকায় এই ভুল কম হয়, কিন্তু functools.cmp_to_key দিয়ে নিজের comparator দিলে সেটিকেও ঋণাত্মক, শূন্য বা ধনাত্মক — সঙ্গতভাবে — ফেরত দিতে হয়। আর float-এ NaN থাকলে <-ও strict weak ordering থাকে না (NaN-এর সাথে সব তুলনা false), তাই সাজানোর আগে NaN সরিয়ে ফেলুন। GCC-তে -D_GLIBCXX_DEBUG দিয়ে compile করলে debug mode অনেক ক্ষেত্রে এমন ভাঙা comparator ধরে ফেলে।

৪ · একাধিক key আর নিজের struct সাজানো

বাস্তবে প্রায় কখনোই শুধু একটি সংখ্যা সাজাতে হয় না। মেধাতালিকা: নম্বর বড় থেকে ছোট, নম্বর সমান হলে রোল ছোট থেকে বড়। Daraz-এর পণ্যতালিকা: দাম ছোট থেকে বড়, দাম সমান হলে রেটিং বড় থেকে ছোট। প্রতিটি ক্ষেত্রে একটি মুখ্য key আর এক বা একাধিক tie-breaking key. দুই ভাষা এটি দুইভাবে প্রকাশ করে।

Python: key function আর tuple-এর তুলনা

Python 3-এ sort-এ comparator দেওয়ার সরাসরি উপায় নেই; দিতে হয় একটি key function — প্রতিটি উপাদান থেকে একটি মান বানায়, আর sort শুধু সেই মানগুলো তুলনা করে। key প্রতিটি উপাদানের জন্য ঠিক একবার হিসাব হয় (n বার), তুলনার প্রতিবার নয় (n log n বার) — তাই দামি key-ও সস্তা। একাধিক key-এর জন্য key একটি tuple ফেরত দেয়, কারণ Python tuple-কে lexicographic ক্রমে তুলনা করে: আগে প্রথম ঘর, সমান হলে দ্বিতীয় ঘর, এভাবে।

students.sort(key=lambda s: (-s.marks, s.roll))     # marks desc, roll asc
students.sort(key=lambda s: (s.dept, -s.marks))      # dept A-Z, then marks desc
from operator import attrgetter
students.sort(key=attrgetter("dept", "roll"))        # same idea, a bit faster
# a string key cannot be negated: use two stable passes instead
students.sort(key=lambda s: s.name)                  # secondary key first
students.sort(key=lambda s: s.dept, reverse=True)    # primary key last; reverse keeps stability
from functools import cmp_to_key                     # when only a comparator makes sense
students.sort(key=cmp_to_key(lambda a, b: (a.marks < b.marks) - (a.marks > b.marks)))

মাইনাস চিহ্নের কৌশল (-s.marks) শুধু সংখ্যায় চলে। নাম উল্টো বর্ণানুক্রমে আর বাকিটা সোজা ক্রমে চাইলে অংশ ২-এর দুই ধাপের কৌশল ব্যবহার করুন। reverse=True stability নষ্ট করে না — documentation অনুযায়ী সমান উপাদানগুলো তখনো আগের ক্রমে থাকে।

C++: comparator lambda, std::tie আর C++20-এর projection

struct Student { int roll; string name; int marks; };

// 1) explicit lambda: compare the primary key, fall through to the tie-breaker
sort(v.begin(), v.end(), [](const Student& a, const Student& b) {
    if (a.marks != b.marks) return a.marks > b.marks;   // marks desc
    return a.roll < b.roll;                             // roll asc
});
// 2) std::tie builds tuples of references; put b first for a descending key
sort(v.begin(), v.end(), [](const Student& a, const Student& b) {
    return tie(b.marks, a.roll) < tie(a.marks, b.roll);
});
// 3) C++20 ranges: comparator + projection (sort by marks, high first)
ranges::sort(v, ranges::greater{}, &Student::marks);
// 4) C++20: a default ordering for the type itself (members compared in order)
//    inside the struct:  auto operator<=>(const Student&) const = default;

উপায় ১ সবচেয়ে পরিষ্কার আর নতুনদের জন্য সবচেয়ে নিরাপদ। উপায় ২-এ std::tie সদস্যদের reference নিয়ে একটি tuple বানায়; tuple-এর < lexicographic, তাই নিজে থেকেই strict weak ordering. বড় থেকে ছোট key-এর জন্য সেই ঘরে a আর b-এর জায়গা বদলে দিন। উপায় ৩-এ projection (&Student::marks) বলে দেয় কোন সদস্য তুলনা হবে — Python-এর key-এর কাছাকাছি ধারণা। উপায় ৪-এ operator<=> “default” করলে সদস্যগুলো ঘোষণার ক্রমে তুলনা হয়; তখন সরাসরি sort(v.begin(), v.end()) চলে। Python-এ একই কাজ করে @dataclass(order=True).

বিয়োগ দিয়ে comparator নয়। Java বা C-র qsort-এ অনেকে লেখেন return a - b;. a = 2,000,000,000 আর b = −2,000,000,000 হলে 32-bit int-এ বিয়োগ overflow করে ভুল চিহ্ন দেয় — sort নীরবে ভুল ফল দেয়। Java-য় Integer.compare(a, b), C-তে (a > b) - (a < b) লিখুন।

৫ · String সাজানো — অভিধান, বড়-ছোট হাতের অক্ষর আর “file10”

String-এর ডিফল্ট তুলনা lexicographic (পর্ব ১১): বাঁ থেকে প্রথম যে ঘরে দুটি string আলাদা, সেই দুই অক্ষরের কোড তুলনা করে ফল ঠিক হয়; একটি অন্যটির শুরুর অংশ হলে ছোটটি আগে। এর তিনটি ফল প্রায়ই মানুষকে অবাক করে:

  1. বড় হাতের অক্ষর আগে. ASCII-তে 'A'–'Z' হলো 65–90, আর 'a'–'z' হলো 97–122. তাই "Zebra" < "apple" — অভিধানের উল্টো।
  2. সংখ্যা অক্ষর হিসেবে তুলনা হয়. "file10" আর "file2"-এর প্রথম পার্থক্য পঞ্চম ঘরে: '1' বনাম '2'. '1' ছোট, তাই "file10" < "file2".
  3. অক্ষরের কোড মানেই ভাষার ক্রম নয়. Unicode-এর code point ক্রম ইংরেজির বাইরে অনেক ভাষার অভিধানের ক্রমের সাথে মেলে না — বাংলায় কার-চিহ্ন, হসন্ত আর যুক্তাক্ষর অভিধানে যেভাবে সাজানো থাকে, code point সেভাবে সাজায় না।

Case-insensitive sort

names = ["rahim", "Karim", "arif", "Sadia"]
sorted(names)                      # ['Karim', 'Sadia', 'arif', 'rahim']  (code order)
sorted(names, key=str.casefold)    # ['arif', 'Karim', 'rahim', 'Sadia']
sorted(names, key=lambda s: (s.casefold(), s))   # deterministic tie: "abc" vs "ABC"
auto lowerLess = [](const string& a, const string& b) {
    return lexicographical_compare(a.begin(), a.end(), b.begin(), b.end(),
        [](unsigned char x, unsigned char y) { return tolower(x) < tolower(y); });
};
sort(names.begin(), names.end(), lowerLess);   // ASCII letters only

casefold হলো lower-এর আরও আক্রমণাত্মক রূপ, যা কিছু ভাষার বিশেষ অক্ষরও (যেমন জার্মান ß → ss) সামলায়। C++-এর tolower শুধু ASCII-র জন্য ভরসাযোগ্য, আর char-কে আগে unsigned char-এ বদলাতে হয় — ঋণাত্মক মান দিলে undefined behavior. সত্যিকারের বহুভাষিক ক্রমের জন্য Unicode Collation Algorithm মেনে চলা লাইব্রেরি (যেমন ICU) লাগে, যাকে বলে collation.

Natural sort — মানুষ যেভাবে পড়ে

আপনার ফোনে ছবির নাম IMG2.jpg, IMG10.jpg, IMG1.jpg. মানুষ চায় 1, 2, 10. কৌশল: string-কে সংখ্যা আর অ-সংখ্যার টুকরোয় ভাঙুন, সংখ্যার টুকরোকে int বানান, তারপর টুকরোর list-কে lexicographic ক্রমে তুলনা করুন।

import re
def natural_key(s):
    # "file10.txt" -> ['file', 10, '.txt']; text and numbers always alternate
    return [int(t) if t.isdigit() else t.casefold() for t in re.split(r"(\d+)", s)]

files = ["file10.txt", "file2.txt", "File3.txt", "file1.txt"]
sorted(files)                     # ['File3.txt', 'file1.txt', 'file10.txt', 'file2.txt']
sorted(files, key=natural_key)    # ['file1.txt', 'file2.txt', 'File3.txt', 'file10.txt']

re.split-এ বন্ধনীর ভেতরের pattern থাকায় সংখ্যার টুকরোগুলোও ফলাফলে থাকে, আর ফলের list-এ জোড় index-এ সবসময় লেখা, বিজোড় index-এ সবসময় সংখ্যা — তাই দুটি key তুলনার সময় কখনো str আর int মুখোমুখি হয় না (Python 3-এ সেটি TypeError দিত)। খরচ: প্রতিটি string থেকে একবার key, মোট O(L) যেখানে L সব string-এর মোট দৈর্ঘ্য; তারপর O(n log n) তুলনা, প্রতিটি তুলনা key-এর দৈর্ঘ্যের সমানুপাতিক। বাকি থাকা প্রশ্ন — "file007" আর "file7" সমান কি না, দশমিক বিন্দু, ঋণাত্মক চিহ্ন — প্রতিটি প্রয়োগে নিজে ঠিক করতে হয়।

৬ · Index সাজানো আর rank — মেধাতালিকা বানানো

Argsort — মান নয়, index সাজানো

কখনো মূল array বদলানো যায় না, বা একই index দিয়ে কয়েকটি আলাদা array (নাম, নম্বর, রোল) একসাথে মেলাতে হয়। তখন মান নয়, index-গুলো সাজান: idx-কে এমনভাবে সাজান যাতে a[idx[0]] ≤ a[idx[1]] ≤ … হয়। NumPy-তে এই কাজের নাম argsort, আর নামটি এখন সাধারণ পরিভাষা।

vector<int> a = {30, 10, 20, 10};
vector<int> idx(a.size());
iota(idx.begin(), idx.end(), 0);                         // 0 1 2 3
stable_sort(idx.begin(), idx.end(), [&](int i, int j) { return a[i] < a[j]; });
// idx = 1 3 2 0   (a[1]=10, a[3]=10, a[2]=20, a[0]=30)
vector<int> pos(a.size());
for (int k = 0; k < (int)idx.size(); ++k) pos[idx[k]] = k;  // inverse: where did i go?
a = [30, 10, 20, 10]
idx = sorted(range(len(a)), key=a.__getitem__)   # [1, 3, 2, 0]
pos = [0] * len(a)
for k, i in enumerate(idx):
    pos[i] = k                                   # pos = [3, 0, 2, 1]

idx একটি permutation (০ থেকে n−1-এর প্রতিটি সংখ্যা ঠিক একবার)। তার উল্টো permutation pos বলে, মূল i-তম উপাদান সাজানোর পর কত নম্বর ঘরে গেল। সাজাতে O(n log n), উল্টো বানাতে O(n), জায়গা Θ(n).

সমান নম্বরে rank — তিন রকম নিয়ম

মেধাতালিকায় দুজনের নম্বর সমান হলে দুজনেরই একই অবস্থান হওয়া উচিত। কিন্তু তাদের পরের জন কত? তিনটি প্রচলিত নিয়ম আছে, আর SQL-এ তিনটির জন্যই আলাদা ফাংশন:

নম্বর (বড় → ছোট)929285857864
Ordinal (ROW_NUMBER): টাই ভাঙে অন্য key দিয়ে123456
Competition (RANK): টাইয়ের পর ফাঁক113356
Dense (DENSE_RANK): ফাঁক নেই112234

Competition rank-এর যুক্তি: “আমার চেয়ে বেশি নম্বর পেয়েছে কতজন, তার এক বেশি” — খেলাধুলা আর অনেক মেধাতালিকা এভাবে চলে। Dense rank-এর যুক্তি: “আমার চেয়ে বেশি কতগুলো আলাদা নম্বর আছে, তার এক বেশি” — গ্রেডের স্তর বা “কততম সর্বোচ্চ নম্বর” প্রশ্নে কাজের। দুটিই সাজানোর পর একবার হেঁটে পাওয়া যায়: আগের জনের নম্বর থেকে আলাদা হলেই নতুন rank শুরু। Dense rank আসলে পরের অংশের coordinate compression-এরই আরেক চেহারা।

এবার পুরো কাজটি চালিয়ে দেখুন: সাতজন ছাত্রকে (নম্বর বড় থেকে ছোট, নম্বর সমান হলে রোল ছোট থেকে বড়) সাজানো, তারপর dense আর competition rank.

#include <algorithm>
#include <cstdio>
#include <string>
#include <vector>
using namespace std;

struct Student { int roll; string name; int marks; };

int main() {
    vector<Student> v = {{1, "Rahim", 78}, {2, "Karim", 92}, {3, "Sadia", 85},
                         {4, "Nusrat", 92}, {5, "Tanvir", 78}, {6, "Mitu", 64},
                         {7, "Arif", 85}};
    sort(v.begin(), v.end(), [](const Student& a, const Student& b) {
        if (a.marks != b.marks) return a.marks > b.marks;  // marks high to low
        return a.roll < b.roll;                            // then roll low to high
    });
    printf("dense comp roll name    marks\n");
    int dense = 0, comp = 0;
    for (size_t i = 0; i < v.size(); ++i) {
        if (i == 0 || v[i].marks != v[i - 1].marks) {      // a new mark starts a new rank
            ++dense;
            comp = (int)i + 1;
        }
        printf("%5d %4d %4d %-7s %5d\n", dense, comp, v[i].roll, v[i].name.c_str(), v[i].marks);
    }
}

আউটপুট:

dense comp roll name    marks
    1    1    2 Karim      92
    1    1    4 Nusrat     92
    2    3    3 Sadia      85
    2    3    7 Arif       85
    3    5    1 Rahim      78
    3    5    5 Tanvir     78
    4    7    6 Mitu       64

92 পাওয়া দুজনের মধ্যে রোল 2 (Karim) আগে, রোল 4 (Nusrat) পরে — comparator-এর দ্বিতীয় লাইনের কাজ। এখানে std::sort unstable হলেও সমস্যা নেই, কারণ (marks, roll) জোড়া প্রতিটি ছাত্রের জন্য আলাদা — comparator নিজেই সব টাই ভেঙে দিয়েছে, “সমান” বলে কেউ নেই। Sadia-র dense rank 2, কিন্তু competition rank 3 — কারণ তার ওপরে দুজন আছেন। Mitu-র ক্ষেত্রে পার্থক্য আরও বড়: dense 4, competition 7. সময়: sort O(n log n), rank-এর জন্য একবার হাঁটা O(n); জায়গা O(log n) (introsort-এর রিকার্শন)। comparator-এ > আর < কঠোর — অংশ ৩-এর শর্ত মানা হয়েছে।

from collections import namedtuple

Student = namedtuple("Student", "roll name marks")
v = [Student(1, "Rahim", 78), Student(2, "Karim", 92), Student(3, "Sadia", 85),
     Student(4, "Nusrat", 92), Student(5, "Tanvir", 78), Student(6, "Mitu", 64),
     Student(7, "Arif", 85)]

v.sort(key=lambda s: (-s.marks, s.roll))   # marks high to low, then roll low to high

print("dense comp roll name    marks")
dense = comp = 0
for i, s in enumerate(v):
    if i == 0 or s.marks != v[i - 1].marks:  # a new mark starts a new rank
        dense += 1
        comp = i + 1
    print(f"{dense:5d} {comp:4d} {s.roll:4d} {s.name:<7s} {s.marks:5d}")

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

dense comp roll name    marks
    1    1    2 Karim      92
    1    1    4 Nusrat     92
    2    3    3 Sadia      85
    2    3    7 Arif       85
    3    5    1 Rahim      78
    3    5    5 Tanvir     78
    4    7    6 Mitu       64

key (-s.marks, s.roll) একটি tuple: আগে ঋণাত্মক নম্বর (তাই বড় নম্বর আগে), সমান হলে রোল। C++-এর দুই লাইনের comparator এখানে এক লাইনের key. namedtuple দিলে s.marks-এর মতো নাম ধরে পড়া যায়, আবার সাধারণ tuple-এর মতো হালকাও থাকে। f-string-এর :<7s নামকে ৭ ঘরে বাঁয়ে সাঁটে, :5d সংখ্যাকে ৫ ঘরে ডানে — C-র %-7s আর %5d-এর মতো, তাই দুই ভাষার আউটপুট অক্ষরে অক্ষরে মেলে।

৭ · Coordinate compression — বড় মানকে ছোট index-এ আনা

ধরুন n = 2 × 105 টি সংখ্যা, প্রতিটি −109 থেকে 109-এর মধ্যে। প্রতিটি মান কতবার এসেছে গুনতে চান, বা পরে পর্ব ৩৬-এর Fenwick tree-তে মানগুলোকে index হিসেবে বসাতে চান। মানের সমান মাপের array (২০০ কোটি ঘর) বানানো অসম্ভব। কিন্তু খেয়াল করুন: আলাদা মান আছে সর্বোচ্চ n টি। আর অনেক সমস্যায় মানের আসল পরিমাণ গুরুত্বপূর্ণ নয় — শুধু কে কার চেয়ে ছোট, সেটুকু। তাহলে প্রতিটি মানকে তার “আলাদা মানগুলোর মধ্যে অবস্থান” দিয়ে বদলে দিন: সবচেয়ে ছোটটি 0, তার পরেরটি 1, এভাবে k − 1 পর্যন্ত। এর নাম coordinate compression — কনটেস্টের সবচেয়ে বেশি ব্যবহৃত কৌশলগুলোর একটি।

ভর্তি পরীক্ষায় হাজার হাজার পরীক্ষার্থীর রোল নম্বর হয়তো ১০০২৩৪৫, ২০৮৮৭১৯ — বিশাল আর ফাঁকা ফাঁকা। কিন্তু হলে আসনের জন্য দরকার শুধু ১, ২, ৩ … — রোলগুলোকে ছোট থেকে বড় সাজিয়ে প্রথমজনকে আসন ১, পরেরজনকে আসন ২. ক্রম বদলায় না, শুধু সংখ্যাগুলো ঘন হয়ে যায়।

Pseudocode — coordinate compression (0-based)

vals = copy of a
sort(vals)
remove adjacent duplicates from vals       // k distinct values remain, sorted
for i = 0 .. n-1:
    c[i] = lower_bound(vals, a[i])         // index of a[i] inside vals, 0 .. k-1
// order is preserved:   a[i] < a[j]  <=>  c[i] < c[j]
// decode when needed:   a[i] == vals[c[i]]
প্রতিটি ধাপের পর
ধাপ
a
vals
c
  1. মূল array. সাতটি মান, পরিসর −7 থেকে 109. মান দিয়ে সরাসরি index করলে ১০০ কোটির বেশি ঘর লাগত — অথচ আলাদা মান মাত্র ৫টি।
  2. কপি করে sort. মূল a অক্ষত রেখে একটি কপি vals সাজালাম: −7, 5, 5, 42, 42, 999, 109. O(n log n).
  3. ডুপ্লিকেট বাদ. সাজানো থাকায় সমান মানগুলো পাশাপাশি — C++-এ unique + erase, O(n). রইল k = 5 টি আলাদা মান। এখন vals-এর index-ই প্রতিটি মানের নতুন নাম।
  4. জায়গা খোঁজা. প্রতিটি a[i]-এর জন্য vals-এ binary search (lower_bound, পর্ব ১৮): 109 আছে index 4-এ, 5 আছে index 1-এ, 42 আছে index 2-এ। প্রতিটি খোঁজা O(log k), মোট O(n log k).
  5. সংকুচিত array. c = [4, 1, 2, 1, 3, 2, 0]. সব মান 0..4-এর মধ্যে, আর ক্রম অটুট: a-তে যে ছোট, c-তেও সে ছোট; a-তে সমান, c-তেও সমান। এখন ৫ ঘরের একটি array দিয়েই গোনা যায়।
  6. উল্টো পথ. আসল মান দরকার হলে vals[c[i]] — O(1). তাই কিছুই হারায়নি: vals হলো অভিধান, c হলো সংক্ষিপ্ত সংকেত। খেয়াল করুন, c আসলে প্রতিটি মানের dense rank − 1 (ছোট থেকে বড় ক্রমে)।
#include <algorithm>
#include <cstdio>
#include <vector>
using namespace std;

int main() {
    vector<long long> a = {1000000000, 5, 42, 5, 999, 42, -7};
    vector<long long> vals = a;                               // copy, keep a intact
    sort(vals.begin(), vals.end());
    vals.erase(unique(vals.begin(), vals.end()), vals.end()); // sorted distinct values
    vector<int> c(a.size());
    for (size_t i = 0; i < a.size(); ++i)                     // value -> its index in vals
        c[i] = int(lower_bound(vals.begin(), vals.end(), a[i]) - vals.begin());

    printf("distinct:");   for (long long x : vals) printf(" %lld", x);
    printf("\ncompressed:"); for (int x : c) printf(" %d", x);
    vector<int> cnt(vals.size(), 0);                          // small array instead of size 10^9
    for (int x : c) ++cnt[x];
    printf("\ncounts:");
    for (size_t k = 0; k < vals.size(); ++k) printf(" %lld:%d", vals[k], cnt[k]);
    printf("\nrestored:"); for (int x : c) printf(" %lld", vals[x]);
    printf("\n");
}

আউটপুট:

distinct: -7 5 42 999 1000000000
compressed: 4 1 2 1 3 2 0
counts: -7:1 5:2 42:2 999:1 1000000000:1
restored: 1000000000 5 42 5 999 42 -7

ওপরের stepper-এর প্রতিটি ধাপ এখানে এক বা দুই লাইন। unique নিজে কিছু মোছে না — পাশাপাশি ডুপ্লিকেট সরিয়ে আলাদা মানগুলো সামনে আনে আর নতুন শেষের iterator দেয়; erase বাকিটা কেটে ফেলে। এই জোড়াটি না সাজিয়ে চালালে শুধু পাশাপাশি ডুপ্লিকেট সরে, দূরেরগুলো নয় — তাই sort আগে। মোট সময় O(n log n), জায়গা O(n). গোনার array মাত্র ৫ ঘরের — 109 নয়। শেষ লাইন প্রমাণ করে যে vals থেকে মূল মানগুলো হুবহু ফেরত আসে।

from bisect import bisect_left

a = [1000000000, 5, 42, 5, 999, 42, -7]
vals = sorted(set(a))                       # sorted distinct values
c = [bisect_left(vals, x) for x in a]       # value -> its index in vals
print("distinct:", *vals)
print("compressed:", *c)
cnt = [0] * len(vals)                       # small array instead of size 10^9
for x in c:
    cnt[x] += 1
print("counts:", *(f"{vals[k]}:{cnt[k]}" for k in range(len(vals))))
print("restored:", *(vals[x] for x in c))

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

distinct: -7 5 42 999 1000000000
compressed: 4 1 2 1 3 2 0
counts: -7:1 5:2 42:2 999:1 1000000000:1
restored: 1000000000 5 42 5 999 42 -7

sorted(set(a)) এক লাইনে ডুপ্লিকেট বাদ আর সাজানো — set গড়ে O(n), sort O(k log k). bisect_left হলো C++-এর lower_bound. আরেকটি জনপ্রিয় রূপ: idx = {v: i for i, v in enumerate(vals)} বানিয়ে c = [idx[x] for x in a] — প্রতিটি খোঁজা গড়ে O(1), তবে dict-এর বাড়তি মেমরি লাগে। কনটেস্টে দুটিই চলে।

কোথায় কাজে লাগে: Fenwick tree বা segment tree দিয়ে inversion গোনা (পর্ব ৩৪, ৩৬), বড় স্থানাঙ্কের আয়তক্ষেত্রের মোট ক্ষেত্রফল বের করা (sweep line), বা যেকোনো সমস্যা যেখানে মানের পরিসর বিশাল কিন্তু সংখ্যা কম। কনটেস্টে সমস্যার বিবরণে “ai ≤ 109” আর “n ≤ 2 × 105” একসাথে দেখলেই এই কৌশলটির কথা মনে আনুন। একটি সতর্কতা: অনেক সময় query-তে আসা মানগুলোও (যা মূল array-তে নেই) আগে থেকেই vals-এ যোগ করতে হয় — সব প্রশ্ন আগে পড়ে নিয়ে তারপর compress করা (offline processing).

৮ · পুরোটা না সাজিয়ে — top-k, k-তম উপাদান আর সাজানো কি না যাচাই

১০ লাখ পরীক্ষার্থীর মধ্যে সেরা ১০০ জনের তালিকা চাই। পুরোটা সাজালে O(n log n) — প্রায় ২ কোটি তুলনা। কিন্তু বাকি ৯ লাখ ৯৯ হাজার ৯০০ জনের ক্রম আমাদের দরকারই নেই। এমন top-k প্রশ্নের জন্য তিনটি তৈরি হাতিয়ার আছে।

প্রশ্নC++Pythonসময়
সবচেয়ে ছোট/বড় k টি, সাজানো অবস্থায়partial_sortheapq.nsmallest(k, a), nlargestO(n log k)
শুধু k-তম উপাদান (যেমন median)nth_elementনেই; sorted(a)[k] বা নিজের quickselectগড়ে O(n)
সেরা k টি, ক্রম লাগবে নাnth_element, তারপর প্রথম k টিheapq.nlargest (O(n log k))C++-এ গড়ে O(n)
ডেটা আসছে স্রোতের মতো, সবসময় সেরা k টি চাইk মাপের min-heap (priority_queue)heapq-এ k মাপের heapপ্রতি উপাদানে O(log k)
array কি আগে থেকেই সাজানো?is_sorted, is_sorted_untilall(a[i] <= a[i+1] …)O(n)

O(n log k) কেন: partial_sort প্রথম k টি উপাদান দিয়ে একটি max-heap বানায় (পর্ব ২৩)। তারপর বাকি প্রতিটি উপাদান heap-এর শিকড়ের (এ পর্যন্ত পাওয়া k টির মধ্যে সবচেয়ে বড়) সাথে তুলনা হয়; ছোট হলে শিকড় বাদ দিয়ে এটি ঢোকে — O(log k). n টি উপাদানে O(n log k), শেষে heap-টি সাজাতে O(k log k). k = 100, n = 106 হলে log k ≈ 7, log n ≈ 20 — প্রায় তিন গুণ কম কাজ, আর জায়গা মাত্র O(k). স্রোতের মতো আসা ডেটায় (যেমন সার্ভারের লগ) এটিই একমাত্র উপায়, কারণ সব ডেটা একসাথে মেমরিতে রাখাই সম্ভব নয়।

nth_element: পর্ব ২২-এর quickselect — partition-এর পর শুধু যে দিকে k-তম উপাদান আছে সেই দিকে নামা। প্রতিবার গড়ে অর্ধেক বাদ পড়ে, তাই n + n/2 + n/4 + … ≈ 2n, গড়ে O(n). libstdc++ এখানেও introsort-এর মতো নিরাপত্তা রাখে (রিকার্শন বেশি গভীর হলে অন্য পদ্ধতিতে যায়)। কাজ শেষে k-তম ঘরে ঠিক সেই উপাদান থাকে যা পুরো সাজালে থাকত, আর তার বাঁয়ের সব উপাদান তার চেয়ে ছোট বা সমান, ডানের সব বড় বা সমান — কিন্তু দুই পাশের ভেতরে কোনো ক্রমের নিশ্চয়তা নেই।

vector<int> m = marks;                                   // keep the original
nth_element(m.begin(), m.begin() + m.size() / 2, m.end());
int median = m[m.size() / 2];                            // average O(n), not O(n log n)

partial_sort(m.begin(), m.begin() + 3, m.end(), greater<int>());   // top 3, high first

bool ok = is_sorted(v.begin(), v.end(), cmp);            // O(n): verify any sort result

is_sorted-কে তুচ্ছ মনে হলেও এটি যাচাইয়ের সবচেয়ে সস্তা হাতিয়ার: নিজের লেখা (বা AI-এর লেখা) যেকোনো sort-এর পরে একটি assert(is_sorted(...)) বসালে O(n) খরচে বড় ভুল ধরা পড়ে। Python-এ all(x <= y for x, y in zip(a, a[1:])), বা Python 3.10+-এ itertools.pairwise.

৯ · মডিউল ৩-এর সব sort এক টেবিলে — আর কোনটি কখন

পর্ব ২০ থেকে ২৪-এর সব sort, সাথে দুটি লাইব্রেরি sort. “Adaptive” মানে ইনপুট আংশিক সাজানো থাকলে উল্লেখযোগ্যভাবে দ্রুত চলে। n = উপাদানের সংখ্যা, k = key-এর পরিসর, d = অঙ্কের সংখ্যা, b = ভিত্তি (base).

SortBestAverageWorstবাড়তি জায়গাStableIn-placeAdaptive
Bubble (আগাম থামাসহ)O(n)O(n²)O(n²)O(1)হ্যাঁহ্যাঁহ্যাঁ
SelectionΘ(n²)Θ(n²)Θ(n²)O(1)নাহ্যাঁনা
InsertionO(n)O(n²)O(n²)O(1)হ্যাঁহ্যাঁহ্যাঁ — O(n + inversion)
MergeΘ(n log n)Θ(n log n)Θ(n log n)O(n)হ্যাঁনানা (মূল রূপে)
Quick (এলোমেলো pivot)O(n log n)O(n log n)O(n²)O(log n) stack*নাহ্যাঁনা
HeapsortO(n log n)O(n log n)O(n log n)O(1)নাহ্যাঁনা
CountingΘ(n + k)Θ(n + k)Θ(n + k)O(n + k)হ্যাঁনানা
Radix (LSD)Θ(d(n + b))Θ(d(n + b))Θ(d(n + b))O(n + b)হ্যাঁনানা
Bucket (সমভাবে ছড়ানো ইনপুট)O(n)O(n)O(n²)O(n)হ্যাঁ**নানা
Introsort (std::sort)O(n log n)O(n log n)O(n log n)O(log n)নাহ্যাঁনা
Timsort (Python, Java object)O(n)O(n log n)O(n log n)O(n)হ্যাঁনাহ্যাঁ — run অনুযায়ী

* ছোট ভাগে আগে রিকার্শন আর বড় ভাগে লুপ দিলে stack O(log n) থাকে; সরলভাবে লিখলে worst case-এ O(n). ** ভেতরের প্রতিটি bucket stable sort দিয়ে সাজালে। আরেকটি মনে রাখার মতো তথ্য: তুলনা-ভিত্তিক যেকোনো sort-এর worst case Ω(n log n) — পর্ব ২৪-এর decision tree যুক্তি। Counting আর radix এই সীমা ভাঙে, কারণ তারা তুলনা করে না, key-কে সরাসরি index হিসেবে ব্যবহার করে — তাই key-কে ছোট পরিসরের পূর্ণসংখ্যা হতে হয়। আর Timsort-এর best case O(n) সীমাটি ভাঙে না: সীমাটি worst case-এর, আর পুরো সাজানো ইনপুট worst case নয়।

কোনটি বেছে নেবেন — প্রশ্নের পর প্রশ্ন

নতুন সমস্যায় নিচের প্রশ্নগুলো ক্রমানুসারে করুন। প্রথম যে প্রশ্নের উত্তর “হ্যাঁ”, সাধারণত সেখানেই আপনার উত্তর।

  1. পুরোটা সাজানো লাগবে কি?nth_element / partial_sortশুধু k টি বা median
  2. সমান key-এর ক্রম রাখতে হবে?stable_sort / sortedstability
  3. key কি ছোট পরিসরের পূর্ণসংখ্যা?counting / radixΩ(n log n)-এর নিচে
  4. ডেটা প্রায় সাজানো?Timsort / insertionadaptive
  5. মেমরি খুব কম, নিশ্চয়তা জরুরি?heapsortO(1) জায়গা
  6. বাকি সব ক্ষেত্রেstd::sort / sortedলাইব্রেরিকে বিশ্বাস করুন
উদাহরণ১০ লাখ নম্বরের median, সেরা ১০ জন
C++:    nth_element(b, b + n/2, e);   partial_sort(b, b + 10, e, greater<>());
Python: heapq.nlargest(10, marks)
সময়:   গড়ে O(n);  O(n log k)
উদাহরণসময় ধরে সাজানো লেনদেন, এখন পরিমাণ ধরে
C++:    stable_sort(v.begin(), v.end(), byAmount);
Python: tx.sort(key=lambda t: t.amount)     # always stable
সময়:   O(n log n), বাড়তি O(n)
উদাহরণ৫০ লাখ পরীক্ষার্থীর নম্বর, 0..100
counting sort: 101টি ঘরের count array
সময়:   Θ(n + k) = Θ(n),  k = 101
শর্ত:   key পূর্ণসংখ্যা, পরিসর ছোট (পর্ব ২৪)
উদাহরণসাজানো তালিকার শেষে কয়েকটি নতুন উপাদান
Python/Java: Timsort নিজেই run চিনে নেয়
ছোট n (≤ 20 বা কাছাকাছি): insertion sort
সময়:   প্রায় O(n) — যত বেশি ক্রম, তত দ্রুত
উদাহরণembedded যন্ত্র, kernel-এর ভেতরের কোড
C++:    make_heap(b, e);  sort_heap(b, e);
সময়:   সবসময় O(n log n), বাড়তি জায়গা O(1)
দাম:    cache-বান্ধব নয়, stable নয়
উদাহরণসাধারণ কনটেস্ট, সাধারণ কাজের কোড
C++:    sort(v.begin(), v.end(), cmp);    // cmp: strict, never <=
Python: v.sort(key=...)
সময়:   O(n log n);  n = 10^6 হলে এক সেকেন্ডের অনেক কম
  1. পুরোটা লাগবে কি? শুধু median, k-তম বড় বা সেরা k জন চাইলে পুরো sort অপচয়। nth_element গড়ে O(n), partial_sort/nlargest O(n log k).
  2. Stability দরকার? আগের কোনো ক্রম (সময়, রোল, আগের sort-এর ফল) সমান key-এর মধ্যে টিকিয়ে রাখতে হলে C++-এ stable_sort; Python-এ সব sort-ই stable. অথবা comparator-এ সব টাই নিজে ভেঙে দিন।
  3. ছোট পরিসরের পূর্ণসংখ্যা? নম্বর 0..100, বয়স 0..150, বা অঙ্ক-ভিত্তিক বড় সংখ্যা — counting বা radix sort তুলনা ছাড়াই Θ(n)-এর কাছাকাছি সময়ে সাজায়। তবে বাস্তবে n বড় না হলে লাইব্রেরির sort-ই প্রায়ই যথেষ্ট দ্রুত।
  4. প্রায় সাজানো? Python বা Java-য় কিছু করতে হবে না — Timsort নিজেই run খুঁজে নেয়। নিজের কোডে ছোট, প্রায় সাজানো অংশে insertion sort.
  5. মেমরি খুব কম, worst case-এর নিশ্চয়তা চাই? heapsort: O(1) বাড়তি জায়গা, সবসময় O(n log n). দাম — cache-এ ধীর, stable নয়। std::sort-ও worst case O(n log n) দেয়, শুধু O(log n) stack নেয়।
  6. বাকি সব ক্ষেত্রে. লাইব্রেরির sort ব্যবহার করুন — এটি বহু বছরের পরীক্ষিত, আপনার হাতে লেখা quick sort-এর চেয়ে প্রায় নিশ্চিতভাবেই দ্রুত আর নিরাপদ। শুধু comparator-টি নির্ভুল লিখুন।

আরও গভীরে: তুলনা-ভিত্তিক sort-এর Ω(n log n) নিম্নসীমাকে আরও সূক্ষ্মভাবে বলা যায়: ইনপুটে আগে থেকে যত ক্রম আছে, তা মাপার একটি পরিমাপ ধরলে নিম্নসীমা নামে। যেমন r টি run থাকলে সাজাতে Ω(n log r)-এর মতো তুলনা যথেষ্ট আর দরকারি — আরও নিখুঁতভাবে, run-গুলোর দৈর্ঘ্যের entropy-র সমানুপাতিক। Timsort-এর ভারসাম্যপূর্ণ merge আর powersort ঠিক এই সীমার কাছাকাছি পৌঁছায়; তাই এরা “adaptive-optimal” হিসেবে আলোচিত। অন্য দিকে quick sort-এর আধুনিক উত্তরসূরি pattern-defeating quicksort (pdqsort, Orson Peters) ইনপুটের কিছু ধাঁচ (সাজানো, উল্টো, অনেক সমান মান) চিনে দ্রুত করে; Go ১.১৯ থেকে এর sort প্যাকেজ pdqsort ব্যবহার করে।

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

  • C++-এর std::sort কোন অ্যালগরিদম, stable কি? — সাধারণত introsort (quick + heap + insertion), worst case O(n log n), stable নয়; stable দরকার হলে std::stable_sort.
  • Timsort-এর best case কখন, কত? — ইনপুট আগে থেকেই সাজানো বা কঠোরভাবে উল্টো হলে, n − 1 টি তুলনায় O(n).
  • n টি সংখ্যার মধ্যে সবচেয়ে বড় k টি বের করার দক্ষ উপায়? — k মাপের min-heap, O(n log k) সময়, O(k) জায়গা; অথবা quickselect দিয়ে গড়ে O(n).
  • Comparator-এ a <= b লিখলে কী সমস্যা? — irreflexivity ভাঙে (comp(a, a) true), strict weak ordering থাকে না; C++-এ undefined behavior, crash পর্যন্ত হতে পারে।
  • Counting sort কীভাবে Ω(n log n)-এর চেয়ে দ্রুত হয়? — নিম্নসীমাটি শুধু তুলনা-ভিত্তিক sort-এর জন্য; counting sort তুলনা না করে key-কে index বানায়, Θ(n + k).

১০ · AI-এর যুগে সর্টিং — এক লাইনের কোডে লুকোনো ভুল

সর্টিংয়ের কোড প্রায়ই এক লাইন — sort(..., cmp). তাই AI-এর দেওয়া সমাধানে ভুলটিও লুকোয় ঠিক ওই এক লাইনের comparator বা key-এর ভেতরে: একটি <=, ভুল দিকে মাইনাস, টাই ভাঙার নিয়ম বাদ পড়া, বা stability ধরে নেওয়া যেখানে তা নেই। ছোট উদাহরণে উত্তর মিলে যায়, কারণ সেখানে টাই নেই বা উপাদান ১৬টির কম। আর complexity নিয়ে AI কখনো বলে “sort আছে, তাই O(n log n)” — অথচ comparator-এর ভেতরে string কপি বা O(n) কাজ থাকলে আসল খরচ আরও বেশি।

সর্টিংয়ের কোডে AI সাধারণত যেসব ভুল করে

AI-এর ভুলকীভাবে ধরবেন
Comparator-এ <= বা >=assert(!cmp(x, x)); ১০০০টি সমান মানে চালান; -D_GLIBCXX_DEBUG
std::sort-এ stability ধরে নেওয়াসমান key, আলাদা পরিচয়ের উপাদান দিয়ে চালান; stable_sort বা পূর্ণ টাই-ভাঙা
Multi-pass sort-এ ধাপের ক্রম উল্টো (মুখ্য key আগে)ছোট টাই-সহ উদাহরণ হাতে trace করুন
Comparator-এ বিয়োগ (a - b) — overflow±2·109-এর কাছের মান দিয়ে পরীক্ষা
Rank-এ dense আর competition গুলিয়ে ফেলাপ্রশ্নে টাইয়ের পরের rank কত হওয়ার কথা, উদাহরণ দেখে মেলান
Coordinate compression-এ unique-এর আগে sort না করা, বা query-র মান বাদ পড়াঅসাজানো ডুপ্লিকেট-সহ ইনপুট দিন; vals-এর আকার গুনুন
Top-k-তে পুরো sort, বা heap-এর দিক ভুল (বড় k-র জন্য max-heap)brute force sorted(a)[-k:]-এর সাথে stress testing

AI-কে কোড লিখতে না বলে বরং আপনার comparator আর trace যাচাই করতে বলুন — আর প্রতিটি দাবির পক্ষে উদাহরণ চান:

আমি DSA শিখছি, এখন বাস্তব সর্টিং: comparator, stable sort আর rank. নিচে আমার কোড আর আমার হাতে করা trace দিলাম: [এখানে কোড আর trace পেস্ট করুন] পুরো সমাধান লিখে দিও না। শুধু এগুলো করো: ১. আমার comparator কি strict weak ordering মানে? comp(x, x) কখনো true হয় কি না, উদাহরণসহ দেখাও। ২. সমান key-এর দুটি উপাদান থাকলে আউটপুটের ক্রম কি নিশ্চিত? না হলে কোন লাইন বদলালে হবে? ৩. [92, 92, 85, 78] নম্বরে আমার কোড dense rank দেয় নাকি competition rank? প্রতিটি ধাপ দেখাও। ৪. time আর space complexity কত — comparator-এর নিজের খরচসহ হিসাব করো। ৫. কোন ৫টি edge case দিয়ে আমার কোড পরীক্ষা করা উচিত?

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

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

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

প্র ০১Java কেন primitive আর object-এর জন্য আলাদা sort ব্যবহার করে — আর dual-pivot quicksort আসলে কেন দ্রুত?

Java 7-এর আগে Arrays.sort(int[]) চালাত Jon Bentley আর Douglas McIlroy-র ১৯৯৩ সালের বিখ্যাত প্রবন্ধ “Engineering a Sort Function”-এর ভিত্তিতে লেখা একটি সুনিপুণ এক-pivot quicksort, আর object-এর জন্য একটি merge sort. ২০০৯ সালে রুশ প্রোগ্রামার Vladimir Yaroslavskiy একটি dual-pivot সংস্করণ প্রস্তাব করেন: দুটি pivot p ≤ q নিয়ে array-কে তিন ভাগে ভাগ করা — p-এর চেয়ে ছোট, p আর q-এর মাঝে, q-এর চেয়ে বড়। Bentley আর Joshua Bloch-এর সাথে পর্যালোচনার পর এটি Java 7-এ primitive array-র ডিফল্ট হয়। একই সময়ে object-এর sort-এ আসে Tim Peters-এর Timsort.

ভাগটি যুক্তিসঙ্গত। দুটি সমান int-কে আলাদা করার কোনো উপায় নেই, তাই primitive-এ stability অর্থহীন — যেকোনো দ্রুত in-place অ্যালগরিদম চলে। Object-এর ক্ষেত্রে দুটি “সমান” object আলাদা পরিচয় বহন করে, আর Java-র Collections.sort-এর documentation বহু আগে থেকেই stability-র কথা দেয়। তা ছাড়া object তুলনা মানে compareTo method call — দামি — তাই তুলনার সংখ্যা কমানো Timsort এখানে লাভজনক।

কিন্তু dual-pivot কেন দ্রুত? প্রথমে ধারণা ছিল এটি কম তুলনা করে। ২০১২ সালে Sebastian Wild আর Markus Nebel গড় ক্ষেত্রের নিখুঁত বিশ্লেষণে দেখান, Yaroslavskiy-র পদ্ধতিতে গড়ে প্রায় 1.9·n ln n তুলনা লাগে, যেখানে সাধারণ quicksort-এ প্রায় 2·n ln n — পার্থক্য সামান্য, আর swap আসলে বেশি হয়। তাহলে গতির রহস্য কী? পরের কয়েক বছরের গবেষণা (Kushagra, López-Ortiz, Munro, Qiao প্রমুখ) একটি ভিন্ন মাপকাঠির দিকে নির্দেশ করে: মেমরি থেকে কতবার ডেটা আনতে হয় — cache miss. তিন ভাগে ভাগ করায় রিকার্শনের গভীরতা কমে (log₃ n-এর কাছাকাছি), ফলে প্রতিটি উপাদান মোট কম বার মেমরি থেকে আসে। আধুনিক CPU-তে একটি cache miss অনেকগুলো তুলনার চেয়ে দামি।

এই গল্পটি পর্ব ০২-এর RAM model-এর সীমাবদ্ধতা দেখায়: সেখানে প্রতিটি মেমরি পড়ার খরচ সমান ধরা হয়, কিন্তু বাস্তব মেশিনে নয়। তুলনা গুনে দুটি অ্যালগরিদম প্রায় সমান, অথচ ঘড়ি ধরে মাপলে একটি স্পষ্টভাবে দ্রুত।

মূল উপলব্ধি: অ্যালগরিদমের “ভালো” নির্ভর করে কোন খরচ আপনি গুনছেন তার ওপর। ডেটার ধরন (primitive না object) ঠিক করে stability দরকার কি না, আর মেশিনের গঠন (cache) ঠিক করে কোন অ্যালগরিদম আসলে দ্রুত।

প্র ০২Python ৩.১১ কেন Timsort-এর merge-এর নিয়ম বদলে powersort নিল — আর এর পেছনে একটি ভাঙা invariant-এর গল্প কী?

Timsort-এর হৃদয় হলো run-এর stack আর দুটি invariant: নিচ থেকে তিনটি run A, B, C হলে A > B + C আর B > C. এই নিয়ম মানলে stack-এ run-এর দৈর্ঘ্য Fibonacci সংখ্যার মতো বাড়ে, তাই stack-এর উচ্চতা O(log n) থাকে, আর merge-গুলো মোটামুটি ভারসাম্যপূর্ণ হয়। Tim Peters তাঁর বিস্তারিত নোট listsort.txt-এ এই নকশার যুক্তি লিখে রেখেছিলেন, আর বহু বছর ধরে এটি Python, Java, Android আর V8-এ চলেছে।

২০১৫ সালে Stijn de Gouw-সহ একটি গবেষক দল KeY নামের formal verification টুল দিয়ে Java-র Timsort-এর শুদ্ধতা প্রমাণ করতে গিয়ে দেখেন, invariant আসলে সবসময় বজায় থাকে না: কোড শুধু stack-এর ওপরের তিনটি run যাচাই করত, অথচ merge-এর পর নিচের দিকে নিয়ম ভেঙে যেতে পারত। তাঁরা বিশেষভাবে বানানো বড় ইনপুট দিয়ে দেখান যে Java-র সংস্করণে run-এর stack-এর জন্য রাখা নির্দিষ্ট মাপের array উপচে পড়ে exception হয়। Python আর Java দুটিই সংশোধন করে — পর্ব ০৩-এর loop invariant-এর গুরুত্বের এর চেয়ে ভালো বাস্তব উদাহরণ কম আছে।

এর পাশাপাশি তাত্ত্বিক প্রশ্নও ছিল: Timsort-এর merge নীতি কি সর্বোত্তম? ২০১৮ সালে J. Ian Munro আর Sebastian Wild দুটি নতুন নীতি প্রস্তাব করেন — peeksort আর powersort. Powersort প্রতিটি দুই run-এর সীমানাকে একটি “power” দেয় — সীমানাটি পুরো array-র মাপে কোন গভীরতার মাঝখানে পড়ে, তার একটি সহজ হিসাব — আর সেই অনুযায়ী ঠিক করে কে আগে merge হবে। ফল: run-গুলোর দৈর্ঘ্য যত অসমানই হোক, মোট merge খরচ তাত্ত্বিক সর্বনিম্নের খুব কাছে থাকে, অথচ হিসাবটি সস্তা আর stack-এর আচরণ সহজে প্রমাণযোগ্য।

Tim Peters নিজেই CPython-এ পরিবর্তনটি আনেন, আর Python ৩.১১ থেকে list.sort powersort-এর merge নীতি ব্যবহার করে। বাকি সব — run খোঁজা, minrun, binary insertion, galloping — আগের মতো। ব্যবহারকারীর জন্য কোনো interface বদলায়নি; শুধু কিছু ইনপুটে merge-এর খরচ কমেছে।

মূল উপলব্ধি: বহু বছর ধরে কোটি কোটি যন্ত্রে চলা কোডেও invariant নিঃশব্দে ভাঙতে পারে। প্রমাণ আর তত্ত্ব শুধু পরীক্ষার বিষয় নয় — এরা বাস্তব লাইব্রেরিকে আরও নির্ভুল আর আরও দ্রুত করে।

প্র ০৩Floating-point সংখ্যার NaN কীভাবে sort ভেঙে দেয়, আর “total order” কেন দরকার?

IEEE 754 standard (১৯৮৫ সালে প্রথম প্রকাশ) অনুযায়ী NaN (“Not a Number”, যেমন 0.0/0.0-এর ফল) কোনো কিছুর সমান, ছোট বা বড় নয় — এমনকি নিজের সমানও নয়। NaN < x, x < NaN, NaN == NaN — সব false. প্রথম দেখায় শুধু একটি অদ্ভুত নিয়ম মনে হয়, কিন্তু sort-এর জন্য এটি মারাত্মক।

অংশ ৩-এর চতুর্থ শর্তটি মনে করুন: “সমান”-কেও transitive হতে হবে। < দিয়ে NaN আর 1 তুলনা করলে দুটো দিকই false — তাই sort-এর চোখে তারা “সমান”. একইভাবে NaN আর 2-ও “সমান”. তাহলে transitivity অনুযায়ী 1 আর 2-ও সমান হওয়ার কথা — অথচ 1 < 2. Strict weak ordering ভেঙে গেল। C++-এ NaN-সহ vector<double>-এ std::sort তাই আনুষ্ঠানিকভাবে undefined behavior. বাস্তবে প্রায়ই crash হয় না, কিন্তু ফল আংশিক এলোমেলো — NaN একটি দেয়ালের মতো আচরণ করে, যার দুই পাশ আলাদাভাবে সাজানো থাকে, কিন্তু একসাথে নয়। Python-এ sorted([3.0, float("nan"), 1.0, 2.0])-ও একইভাবে অদ্ভুত ক্রম দিতে পারে, কোনো error ছাড়া।

সমাধান কয়েক রকম। সবচেয়ে সরল: সাজানোর আগে NaN আলাদা করে ফেলা (C++-এ partition দিয়ে, Python-এ math.isnan দিয়ে ছেঁকে)। দ্বিতীয়: comparator-এ NaN-কে স্পষ্টভাবে সবার শেষে রাখা। তৃতীয়: IEEE 754-এর ২০০৮ সালের সংস্করণে সংজ্ঞায়িত totalOrder সম্পর্ক, যা −NaN, −∞, ঋণাত্মক সংখ্যা, −0, +0, ধনাত্মক সংখ্যা, +∞, +NaN — সবকিছুকে একটি পূর্ণ ক্রমে বসায়। C++20-এর std::strong_order floating-point-এ ঠিক এই ক্রম দেয়। Rust ভাষা আরও কঠোর: সেখানে f64-এ শুধু “partial” তুলনা আছে, তাই সরাসরি sort() compile-ই হয় না — প্রোগ্রামারকে total_cmp বা নিজের নিয়ম বেছে নিতে হয়।

ডেটা বিজ্ঞানেও এর প্রভাব আছে: pandas-এর মতো লাইব্রেরি সাজানোর সময় অনুপস্থিত মান (NaN) কোথায় যাবে তার জন্য আলাদা parameter রাখে (na_position), কারণ চুপচাপ ভুল ক্রম দেওয়া ক্রমের চেয়ে স্পষ্ট নিয়ম ভালো।

মূল উপলব্ধি: “ছোট কি না” প্রশ্নটি সব ডেটায় স্বাভাবিকভাবে সংজ্ঞায়িত নয়। Sort-এ দেওয়ার আগে নিশ্চিত হোন যে আপনার তুলনা সত্যিই একটি সঙ্গত ক্রম — না হলে ভুলটি নিঃশব্দে থাকবে।

প্র ০৪ফাইল ম্যানেজার “file2”-কে “file10”-এর আগে কীভাবে রাখে, আর বাংলা নাম সঠিকভাবে সাজানো এত কঠিন কেন?

প্রথম দিকের কম্পিউটারে ফাইলের তালিকা সাজানো হতো সরল অক্ষর-কোড ধরে — তাই “file10” বসত “file2”-এর আগে। ব্যবহারকারীরা এর সমাধান হিসেবে শূন্য বসিয়ে নাম দিতেন: “file02”, “file10”. পরে অপারেটিং সিস্টেমগুলো natural sort গ্রহণ করে। Windows-এ Windows XP-এর সময় থেকে File Explorer নামের ভেতরের সংখ্যাকে সংখ্যা হিসেবে তুলনা করে (এর জন্য StrCmpLogicalW নামের একটি system ফাংশন আছে); macOS-এর Finder-ও এমন আচরণ করে। ডেভেলপারদের মধ্যে Martin Pool-এর “strnatcmp” আর Dave Koelle-এর “Alphanum algorithm”-এর মতো খোলা বাস্তবায়ন জনপ্রিয় হয়।

কিন্তু “মানুষের মতো” সাজানোর সংজ্ঞা নিজেই অস্পষ্ট। “file1.5” আর “file1.10” — দশমিক না সংস্করণ নম্বর? “file007” আর “file7” কি সমান? ঋণাত্মক চিহ্ন আর তারিখের ভেতরের হাইফেন কীভাবে আলাদা করবেন? প্রতিটি বাস্তবায়ন কিছু নিয়ম বেছে নেয়, আর ভিন্ন সিস্টেমে একই তালিকা একটু ভিন্নভাবে সাজানো দেখা যায়। তাই কোডে natural sort লিখলে আপনার নিয়মগুলো স্পষ্ট করে লিখে রাখুন।

ভাষার ক্ষেত্রে সমস্যা আরও গভীর। Unicode প্রতিটি অক্ষরকে একটি code point দেয়, কিন্তু code point-এর ক্রম কোনো ভাষার অভিধানের ক্রমের জন্য নকশা করা হয়নি। একই দেখতে লেখা একাধিকভাবেও লেখা যায় — যেমন বাংলায় “য়” একটি আলাদা code point হিসেবে, আবার “য” আর নুক্তা মিলিয়েও লেখা যায় (Unicode normalization, পর্ব ১১)। দুটি রূপকে সমান না ধরলে একই শব্দ তালিকার দুই জায়গায় চলে যায়। তা ছাড়া যুক্তাক্ষর ভেতরে হসন্ত দিয়ে গঠিত হয়, আর কার-চিহ্ন ব্যঞ্জনের পরে বসে — অভিধানের প্রথাগত ক্রম এই গঠনগুলোকে যেভাবে সাজায়, code point-এর সরল তুলনা সবসময় সেভাবে সাজায় না।

এর জন্য Unicode Consortium Unicode Collation Algorithm (UTS #10) প্রকাশ করেছে, আর CLDR প্রকল্প বহু ভাষার জন্য ভাষাভিত্তিক collation নিয়ম রাখে। ICU লাইব্রেরি (C/C++ ও Java), আর তার ওপর ভিত্তি করে Python-এর PyICU বা JavaScript-এর Intl.Collator এই নিয়ম প্রয়োগ করে। Intl.Collator("bn") বা ICU-র বাংলা locale ব্যবহার করলে নাম সাজানো অনেক বেশি স্বাভাবিক হয়। এগুলো আসলে key function-এরই উন্নত রূপ: প্রতিটি string থেকে একটি “sort key” বানায়, যার সরল byte-তুলনাই সঠিক ভাষাগত ক্রম দেয়।

মূল উপলব্ধি: String সাজানো মানে আসলে “এই প্রয়োগে কোন ক্রমটি সঠিক” — সেই প্রশ্নের উত্তর দেওয়া। অক্ষর-কোডের তুলনা দ্রুত আর নির্ধারিত, কিন্তু মানুষের প্রত্যাশা মেটাতে key function বা collation লাইব্রেরি লাগে।

প্র ০৫নতুন ভাষাগুলো কেন ভাঙা comparator পেলে ইচ্ছা করে প্রোগ্রাম থামিয়ে দেয় — এটি কি ভালো নকশা?

C++-এর নকশা-দর্শন হলো “যা ব্যবহার করেন না, তার দাম দেবেন না”. Comparator সঠিক কি না, তা প্রতিটি sort-এ যাচাই করতে বাড়তি তুলনা লাগত, তাই standard দায়িত্বটি পুরোপুরি প্রোগ্রামারের ওপর ছেড়ে দেয়: ভাঙা comparator মানে undefined behavior. ফলে অংশ ৩-এর মতো ভুল বছরের পর বছর লুকিয়ে থাকতে পারে, আর হঠাৎ একদিন অন্য কোনো জায়গার মেমরি নষ্ট করে — নিরাপত্তার দিক থেকে এটি বিপজ্জনক, কারণ সীমার বাইরে লেখা অনেক আক্রমণের প্রবেশপথ।

অন্য ভাষাগুলো ভিন্ন আপস বেছে নিয়েছে। Java-র Timsort merge-এর সময় অসঙ্গতি টের পেলে (যেমন একটি run-এর ক্রম comparator-এর কথার সাথে মিলছে না) IllegalArgumentException দেয় — মেমরি নষ্ট হয় না, তবে প্রতিটি ভাঙা comparator ধরা পড়বে এমন নিশ্চয়তাও নেই। Rust আরও এক ধাপ এগিয়েছে: ২০২৪ সালের Rust ১.৮১-এ নতুন sort বাস্তবায়ন (stable sort-এর জন্য driftsort, unstable-এর জন্য ipnsort) আসে, আর release notes-এ জানানো হয় যে comparator একটি total order না হলে নতুন sort panic করতে পারে — অর্থাৎ ইচ্ছা করে প্রোগ্রাম থামাতে পারে। আগে এমন ক্ষেত্রে ফল শুধু ভুল ক্রম হতো। কিছু পুরোনো প্রোগ্রাম এতে হঠাৎ থেমে যায়, যা নিয়ে আলোচনা হয়েছিল; কিন্তু সব ক্ষেত্রেই নিয়ম একটি — মেমরির নিরাপত্তা কখনো ভাঙবে না।

LLVM-এর libc++ আর GCC-র libstdc++-ও “hardening” বা debug mode-এ এমন কিছু যাচাই যোগ করেছে, যা চাইলে চালু করা যায় — উন্নয়নের সময় ভুল ধরতে, আর ছাড়ার সময় বন্ধ করে গতি পেতে। এটিই সম্ভবত সবচেয়ে বাস্তবসম্মত মধ্যপথ।

ভালো নকশা কোনটি? দ্রুত ব্যর্থ হওয়া (fail fast) নীতির পক্ষে যুক্তি শক্ত: ভুলটি যেখানে ঘটে, সেখানেই ধরা পড়ে, আর ভুল উত্তর নিয়ে প্রোগ্রাম চলতে থাকে না — ব্যাংকের লেনদেন বা পরীক্ষার ফলের তালিকায় নীরব ভুল ক্রম একটি crash-এর চেয়ে অনেক বেশি ক্ষতিকর হতে পারে। বিপক্ষের যুক্তি: কনটেস্টে বা সময়-সংবেদনশীল সিস্টেমে প্রতিটি বাড়তি যাচাই খরচ। তাই বিভিন্ন ভাষা বিভিন্ন জায়গায় রেখা টেনেছে।

মূল উপলব্ধি: Comparator লাইব্রেরির সাথে একটি চুক্তি। চুক্তি ভাঙলে কী হবে — UB, exception, না panic — তা ভাষা ঠিক করে; কিন্তু চুক্তি মেনে চলা সবসময় প্রোগ্রামারের দায়িত্ব।

অনুশীলন

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

  1. হাতে trace করুন: পণ্যের তালিকা (নাম, দাম, রেটিং): (Pen, 20, 4), (Bag, 500, 5), (Cap, 150, 4), (Mug, 150, 5), (Ink, 20, 3). লক্ষ্য — দাম ছোট থেকে বড়, দাম সমান হলে রেটিং বড় থেকে ছোট। দুই ধাপের stable sort দিয়ে করুন: প্রথম ধাপের পর আর দ্বিতীয় ধাপের পর তালিকা লিখুন। কোন ধাপে কোন key?

    গৌণ key (রেটিং, বড় থেকে ছোট) আগে। ধাপ ১, রেটিং ধরে stable sort (বড় আগে): Bag 5, Mug 5, Pen 4, Cap 4, Ink 3 — সমান রেটিংয়ে মূল ক্রম (Bag মূল তালিকায় Mug-এর আগে, Pen Cap-এর আগে) টিকে আছে।

    ধাপ ২, দাম ধরে stable sort (ছোট আগে): দাম 20 → Pen (4), Ink (3) — ধাপ ১-এর ক্রমে Pen আগে; দাম 150 → Mug (5), Cap (4); দাম 500 → Bag. ফল: Pen, Ink, Mug, Cap, Bag. যাচাই: দাম 20-এ রেটিং 4 > 3, দাম 150-এ 5 > 4 — লক্ষ্য পূরণ। এক ধাপে করতে চাইলে key (price, -rating).

  2. Rank হাতে বের করুন: নম্বর [70, 95, 82, 95, 70, 60, 82, 95]. বড় থেকে ছোট ক্রমে প্রতিটি নম্বরের dense rank আর competition rank লিখুন। তারপর মূল array-র ক্রমে প্রতিটি ছাত্রের dense rank কত?

    সাজানো (বড় → ছোট): 95, 95, 95, 82, 82, 70, 70, 60. Dense: 1, 1, 1, 2, 2, 3, 3, 4. Competition: 1, 1, 1, 4, 4, 6, 6, 8 — কারণ 82-এর ওপরে তিনজন, 70-এর ওপরে পাঁচজন, 60-এর ওপরে সাতজন।

    মূল ক্রমে dense rank: 70 → 3, 95 → 1, 82 → 2, 95 → 1, 70 → 3, 60 → 4, 82 → 2, 95 → 1, অর্থাৎ [3, 1, 2, 1, 3, 4, 2, 1]. লক্ষ করুন, এটি আসলে বড় থেকে ছোট ক্রমে coordinate compression + 1: আলাদা মান 95, 82, 70, 60 → 1, 2, 3, 4.

  3. Comparator যাচাই করুন: নিচের প্রতিটি C++ comparator কি strict weak ordering? না হলে কোন শর্ত ভাঙে? (ক) return a.marks >= b.marks; (খ) return abs(a) < abs(b); (গ) return a % 3 < b % 3; (ঘ) return a.name.size() < b.name.size() || a.name < b.name;

    (ক) না — comp(a, a) true, irreflexivity ভাঙে; লিখুন a.marks > b.marks. (খ) হ্যাঁ — −3 আর 3 “সমান” (equivalent) হয়, আর এই সমতা transitive; শুধু তাদের পারস্পরিক ক্রম নিশ্চিত নয়। (গ) হ্যাঁ — ভাগশেষ ধরে তিনটি দল, প্রতিটি দলের সবাই সমান; এটিও বৈধ weak ordering.

    (ঘ) না — দেখতে ঠিক, কিন্তু asymmetry ভাঙে। a.name = "zz" (দৈর্ঘ্য 2), b.name = "abc" (দৈর্ঘ্য 3) নিন: comp(a, b) = (2 < 3) = true; comp(b, a) = (3 < 2) || ("abc" < "zz") = true. দুই দিকেই true! সঠিক রূপ: if (a.name.size() != b.name.size()) return a.name.size() < b.name.size(); return a.name < b.name; — বা tie দিয়ে tuple তুলনা।

  4. কোড লিখুন: একটি ফাংশন লিখুন যা একটি পূর্ণসংখ্যার array নিয়ে প্রতিটি উপাদানের জায়গায় তার “ছোট থেকে বড় ক্রমে dense rank” (সবচেয়ে ছোট মান = 1) বসিয়ে নতুন array দেয়। [40, 10, 40, 30] → [3, 1, 3, 2]. সময় O(n log n) হতে হবে। একটি brute force (O(n²)) সংস্করণও লিখুন যা দিয়ে আপনার উত্তর মেলাবেন।

    মূল চিন্তা: dense rank = coordinate compression-এর index + 1.

    from bisect import bisect_left
    
    def dense_rank(a):                      # O(n log n)
        vals = sorted(set(a))
        return [bisect_left(vals, x) + 1 for x in a]
    
    def dense_rank_brute(a):                # O(n^2): count distinct values smaller than x
        return [len({y for y in a if y < x}) + 1 for x in a]
    
    print(dense_rank([40, 10, 40, 30]))     # [3, 1, 3, 2]

    C++-এ একই কাজ: কপি, sort, unique + erase, তারপর lower_bound(...) - vals.begin() + 1. যাচাইয়ের জন্য ১০০০টি এলোমেলো ছোট array (মান 0..5, যাতে টাই প্রচুর থাকে) দিয়ে দুটি ফাংশনের উত্তর মেলান। Brute force-এর যুক্তিটিই dense rank-এর সংজ্ঞা: “আমার চেয়ে ছোট আলাদা মান কতগুলো, তার এক বেশি”.

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

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

ক্রমCoordinate compression-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. মূল array-র একটি কপি নেওয়া
  2. কপিটি ছোট থেকে বড় ক্রমে sort করা
  3. পাশাপাশি থাকা ডুপ্লিকেট মান বাদ দেওয়া
  4. প্রতিটি মূল মানের জন্য binary search করে আলাদা মানগুলোর মধ্যে তার index বের করা
  5. দরকার হলে সেই index দিয়ে আলাদা মানের তালিকা থেকে আসল মান ফেরত আনা

C++-এ নিচের কোন ফাংশন নিশ্চয়তা দেয় যে সমান key-এর উপাদানগুলোর আগের আপেক্ষিক ক্রম বদলাবে না?

  • std::sort
  • std::partial_sort
  • std::stable_sort
  • std::nth_element

C++-এ sort(v.begin(), v.end(), [](int a, int b) { return a <= b; }); লিখলে মূল সমস্যা কী?

  • এটি compile হয় না
  • comparator strict weak ordering মানে না, তাই আচরণ undefined — crash পর্যন্ত হতে পারে
  • array বড় থেকে ছোট ক্রমে সাজানো হয়
  • sort-টি stable হয়ে যায়, তাই একটু ধীর হয়

নম্বর বড় থেকে ছোট ক্রমে 90, 85, 85, 70. Dense rank অনুযায়ী 70 পাওয়া ছাত্রের rank কত?

  • 4
  • 2
  • 1
  • 3

Array [50, 10, 50, 7]-কে coordinate compression (সবচেয়ে ছোট মান = 0) করলে ফল কী?

  • [2, 1, 2, 0]
  • [3, 1, 0, 2]
  • [2, 1, 3, 0]
  • [0, 1, 0, 2]

শুধু stable sort ব্যবহার করে ছাত্রদের “নম্বর বড় থেকে ছোট, নম্বর সমান হলে নাম বর্ণানুক্রমে” সাজাতে চান। কোন ক্রমে দুটি sort চালাবেন?

  • আগে নম্বর ধরে, তারপর নাম ধরে
  • আগে নাম ধরে, তারপর নম্বর ধরে
  • যেকোনো ক্রমে — ফল একই
  • দুই ধাপে সম্ভব নয়, comparator লাগবেই

সাধারণ lexicographic তুলনায় ["file2", "file10", "file1"] সাজালে ফল কী?

  • file1, file2, file10
  • file2, file10, file1
  • file1, file10, file2
  • file10, file1, file2

শব্দকোষ

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

মনে রাখুন

  1. std::sort = introsort, O(n log n), stable নয়; stable_sort merge-ভিত্তিক; Python-এর sort Timsort — stable, adaptive.
  2. Comparator মানে “কঠোরভাবে আগে” — <, কখনো <= নয়; ভাঙলে C++-এ undefined behavior.
  3. একাধিক key: Python-এ tuple key (-marks, roll), C++-এ lambda বা tie; অথবা stable sort-এ গৌণ key আগে, মুখ্য key শেষে.
  4. Argsort, dense rank আর coordinate compression (sort + unique + lower_bound) — সবই O(n log n).
  5. পুরোটা না লাগলে nth_element গড়ে O(n), top-k O(n log k); ফল যাচাইয়ে is_sorted O(n).

পরের পর্বে: মডিউল ৪ শুরু — tree. সারি থেকে শাখায়: root, parent, child, leaf, height-এর পরিভাষা, tree-কে মেমরিতে রাখার উপায়, আর preorder, inorder, postorder ও level-order traversal.

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

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