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

Comparable আর Comparator — সাজানোর নিয়ম

Java Comparable and Comparator — Rules for Sorting, Ties, Stability and Bangla Text

Taught in Bangla, this lesson explains sorting rules in Java: Comparable for natural order versus Comparator for external rules, list.sort and Arrays.sort, building comparators with comparing, thenComparing and reversed, nullsLast, stable sorting with TimSort, sorting Bangla names with Collator, and how comparators decide equality in TreeSet, TreeMap and PriorityQueue.

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

  • Comparable আর Comparator-এর পার্থক্য, আর কখন কোনটি বেছে নেবেন
  • list.sort, Collections.sort, Arrays.sort — আর primitive array কেন Comparator নেয় না
  • anonymous class থেকে Comparator.comparing পর্যন্ত বিবর্তন, thenComparing, reversed()-এর ফাঁদ, nullsLast
  • stable sort কী, আর বাংলা নাম সাজাতে compareTo, Collator ও Unicode-এর আসল আচরণ
  • TreeSet, TreeMap আর PriorityQueue-তে Comparator দিলে কী বদলায় — আর কোথায় উপাদান হারায়

১ · সাজাতে হলে একটি নিয়ম লাগে — “কে আগে?”

SSC বা HSC-র ফল বের হলে একটি স্কুল তার মেধাতালিকা টাঙায়। সেই তালিকা বানাতে কম্পিউটারকে কী জানতে হয়? সাজানোর অ্যালগরিদম কম্পিউটার আগে থেকেই জানে — Java-র ভেতরে সেটা তৈরি আছে। যা সে জানে না, তা হলো আপনার নিয়ম: দুজন ছাত্রের মধ্যে কে আগে বসবে। GPA বেশি যার, সে আগে? GPA সমান হলে? তখন মোট নম্বর? সেটাও সমান হলে নামের ক্রম? যেকোনো sort অ্যালগরিদম আসলে এই একটি প্রশ্ন হাজারবার জিজ্ঞেস করে, প্রতিবার দুজনকে নিয়ে। প্রশ্নের উত্তর দেওয়ার দায়িত্ব আপনার।

Java এই উত্তর দেওয়ার দুটি জায়গা দেয়:

Comparable — ভেতরের নিয়ম

Class নিজেই বলে “আমাকে স্বাভাবিকভাবে এভাবে সাজাও” — compareTo method দিয়ে। একটি class-এর এমন নিয়ম একটিই। Student-এর জন্য সেটা রোল নম্বর, String-এর জন্য অক্ষরের ক্রম, Integer-এর জন্য ছোট থেকে বড়।

একটি · class-এর ভেতরে

Comparator — বাইরের নিয়ম

আলাদা একটি object, যার compare(a, b) method বলে কে আগে। GPA দিয়ে, নাম দিয়ে, “GPA তারপর নাম” দিয়ে — যত খুশি বানান, Student class একবারও না ছুঁয়ে। যে class আপনি লেখেননি (যেমন String), তারও নতুন ক্রম দেওয়া যায়।

অনেক · class-এর বাইরে

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

আগে যা দেখেছেন — compareTo-এর চুক্তি

পর্ব ২৩-এ (equals, hashCode, toString আর compareTo) Comparable আর compareTo দেখেছেন। সংক্ষেপে মনে করিয়ে দিই, কারণ Comparator-এর compare(a, b) হুবহু একই চুক্তি মানে:

  • শুধু চিহ্ন গুরুত্বপূর্ণ: ঋণাত্মক মানে a আগে, শূন্য মানে ক্রমে সমান, ধনাত্মক মানে a পরে। −১ আর −৫০০-এর মানে একই।
  • উল্টালে চিহ্ন উল্টাবে: compare(a, b) ঋণাত্মক হলে compare(b, a) ধনাত্মক হতেই হবে।
  • transitive: a আগে b, আর b আগে c হলে a আগে c.
  • বিয়োগ নয়, Integer.compare: a - b বড় বা বিপরীত চিহ্নের সংখ্যায় integer overflow করে চিহ্ন উল্টে দেয়। Comparator-এ এই ভুল আরও বেশি দেখা যায়, কারণ lambda-য় (a, b) -> a.score - b.score লেখা খুব সহজ।

Class-এর এই স্বাভাবিক ক্রমকে বলে natural ordering. কিন্তু বাস্তব সফটওয়্যারে একটি জিনিস প্রায় কখনো এক ক্রমে সাজাতে হয় না। একটি অনলাইন দোকানের পণ্যের তালিকায় ব্যবহারকারী চাপেন “দাম: কম থেকে বেশি”, “রেটিং: বেশি থেকে কম”, “নতুন আগে”। একটি প্রোগ্রামিং কনটেস্টের leaderboard সাজায় সমাধান করা সমস্যার সংখ্যায়, সমান হলে penalty-তে। প্রতিটি ক্রমের জন্য Product বা Team class বদলানো অসম্ভব — তাই Comparator.

মূল ধারণা

সাজানোর অ্যালগরিদম আর সাজানোর নিয়ম আলাদা জিনিস। অ্যালগরিদম (Java-তে TimSort) জানে কীভাবে কম তুলনায় সাজাতে হয়; নিয়ম (Comparable বা Comparator) জানে দুটি জিনিসের মধ্যে কে আগে। Java আপনাকে শুধু নিয়মটি লিখতে বলে — এটা strategy নকশার একটি পরিচিত উদাহরণ: একই কাজ, বদলানো যায় এমন নীতি।

২ · কোথায় সাজাবেন — list, array আর primitive

নিয়ম হাতে থাকলে সেটা কাকে দেবেন? Java-তে তিনটি প্রচলিত দরজা আছে:

লেখাকী সাজায়নিয়ম কোথা থেকে
list.sort(cmp)যেকোনো List, জায়গায় বসে (Java 8 থেকে)দেওয়া Comparator; null দিলে natural ordering
Collections.sort(list)List; ভেতরে আসলে list.sort ডাকেnatural ordering (উপাদান Comparable হতে হবে)
Collections.sort(list, cmp)Listদেওয়া Comparator
Arrays.sort(arr)object বা primitive arraynatural ordering
Arrays.sort(arr, cmp)শুধু object array (Integer[], Student[])দেওয়া Comparator

আধুনিক কোডে list.sort(cmp) সবচেয়ে প্রচলিত — পড়তে সহজ, আর ArrayList-এর ক্ষেত্রে সরাসরি ভেতরের array সাজায়। List.of(...) দিয়ে বানানো list বদলানো যায় না, তাই তার ওপর sort ডাকলে UnsupportedOperationException আসে — আগে new ArrayList<>(...) দিয়ে কপি করে নিন (পর্ব ৩০)।

primitive array — Comparator চলে না

int[] বড় থেকে ছোট সাজাতে স্বাভাবিকভাবে মনে হয় Arrays.sort(a, Comparator.reverseOrder()) লিখি। javac রাজি হয় না:

Main.java:6: error: no suitable method found for sort(int[],Comparator<T#1>)
        Arrays.sort(a, Comparator.reverseOrder());
              ^
(তারপর কয়েক লাইনে ব্যাখ্যা: inference variable T#2 has incompatible bounds … equality constraints: int, upper bounds: Object)

কারণ generics শুধু reference টাইপ নিয়ে কাজ করে (পর্ব ২৮) — Comparator<int> বলে কিছু হয় না। শেষ লাইনটাই আসল কথা বলছে: T-কে int হতে হবে, অথচ T Object-এর নিচের কিছু হতে হবে। তিনটি বাস্তব সমাধান:

  • ছোট থেকে বড় সাজিয়ে উল্টে নিন: Arrays.sort(a), তারপর একটি লুপে দুই প্রান্ত থেকে অদলবদল। বড় array-তে সবচেয়ে দ্রুত, কোনো বাড়তি object নেই।
  • শুরু থেকেই Integer[] রাখুন: Arrays.sort(b, Comparator.reverseOrder()) চলে — কিন্তু প্রতিটি সংখ্যা এখন একটি আলাদা object.
  • Stream দিয়ে box করে আবার unbox: Arrays.stream(a).boxed().sorted(Comparator.reverseOrder()).mapToInt(Integer::intValue).toArray() — এক লাইনে, তবে অনেক autoboxing; Stream পর্ব ৩৫-এ।

Primitive array-এর Arrays.sort ভেতরে একটি আলাদা অ্যালগরিদম চালায় — Dual-Pivot Quicksort — যা object-এর sort-এর মতো stable নয়। ভয়ের কিছু নেই: দুটি সমান int-কে আলাদা করে চেনার কোনো উপায়ই নেই, তাই কে আগে গেল তা কেউ টের পায় না (ভাবনার প্রশ্ন ৪)।

৩ · Comparator-এর বিবর্তন — anonymous class থেকে এক লাইন

Comparator একটি functional interface — এর একটিমাত্র abstract method, int compare(T a, T b). Java 1.2 (১৯৯৮) থেকে interface-টি আছে, কিন্তু লেখার ধরন সময়ের সাথে নাটকীয়ভাবে বদলেছে। একই নিয়ম — “GPA বেশি যার, সে আগে” — চার যুগে:

// Java 7 পর্যন্ত — ছয় লাইন, আসল কথা একটি
list.sort(new Comparator<Student>() {
    @Override
    public int compare(Student a, Student b) {
        return Double.compare(b.gpa(), a.gpa());   // b আগে লেখা = বড় থেকে ছোট
    }
});

পর্ব ২৭-এর anonymous class. কাজ করে, কিন্তু অনুষ্ঠান বেশি। আর “b আগে, a পরে” লিখে উল্টো ক্রম বানানোর কৌশলটি চোখে পড়ে না — রিভিউয়ে প্রায়ই ভুল ধরা পড়ে না।

// Java 8 থেকে — একই কাজ এক লাইনে
list.sort((a, b) -> Double.compare(b.gpa(), a.gpa()));

Lambda শুধু parameter আর ফলাফল রাখে, বাকি অনুষ্ঠান বাদ। কিন্তু তুলনার যুক্তি এখনো আপনি হাতে লিখছেন — আর হাতে লেখা মানেই বিয়োগের লোভ, a আর b উল্টে ফেলার ঝুঁকি। lambda-র পুরো গল্প পরের পর্বে (পর্ব ৩৪)।

// Java 8 থেকে — "কী দিয়ে" বলুন, "কীভাবে" নয়
list.sort(Comparator.comparing(Student::gpa).reversed());

এখানে আপনি তুলনা লেখেননি — শুধু বলেছেন কোন মান দিয়ে তুলনা হবে। Student::gpa একটি method reference, মানে “প্রতিটি ছাত্রের gpa() ডাকো”; এটা s -> s.gpa()-এর সংক্ষিপ্ত রূপ (পর্ব ৩৪-এ বিস্তারিত)। এমন function-কে বলে key extractor. তুলনার কাজটি — সঠিক, overflow-মুক্ত — Comparator.comparing নিজে করে। উল্টো দিক চাইলে .reversed(), যা নাম দেখেই বোঝা যায়।

// key primitive হলে — boxing ছাড়া
list.sort(Comparator.comparingDouble(Student::gpa).reversed());

comparing(Student::gpa) প্রতিটি তুলনায় double-কে Double object-এ box করে, তারপর তার compareTo ডাকে। ১০ লাখ ছাত্র সাজাতে প্রায় ২ কোটি তুলনা, প্রতিটিতে দুটি boxing — অকারণ কাজ। comparingInt, comparingLong, comparingDouble সরাসরি primitive মান তুলনা করে। key int/long/double হলে এগুলোই নিন।

লক্ষ করুন, Comparator.comparing নিজে কিছু সাজায় না। এটি একটি function (key extractor) নেয়, আর ফেরত দেয় আরেকটি function (Comparator) — function থেকে function বানানোর কারখানা। এমন method-কে বলে higher-order function. reversed(), thenComparing() — এরাও একটি Comparator নিয়ে নতুন Comparator ফেরত দেয়; আসলটি বদলায় না। এজন্যই এদের শিকলের মতো জোড়া যায়, আর একবার বানানো Comparator নিরাপদে বহু জায়গায় আবার ব্যবহার করা যায় (ভাবনার প্রশ্ন ৫)।

C/C++/Python জানলে

C-র qsort-এ একটি function pointer দেন, যা int ফেরত দেয় — Java-র compare-এর মতোই চিহ্নের চুক্তি (C-এর function pointer ও callback). C++-এর std::sort-এর comparator bool ফেরত দেয় (“a কি b-এর আগে?”), আর std::sort stable নয় — stable চাইলে std::stable_sort (C++-এর STL algorithm). Python ৩-এ তুলনার function নয়, শুধু key= দেওয়া হয় — ঠিক Java-র comparing-এর মতো চিন্তা — আর sort/sorted Java-র মতোই TimSort, তাই stable (Python-এর list ও sort).

৪ · নিয়মের শিকল — thenComparing, reversed আর মেধাতালিকা

বাস্তব নিয়ম প্রায় কখনো এক ধাপের নয়। উদাহরণ হিসেবে একটি স্কুলের মেধাতালিকার নিয়ম ধরি (নিয়মগুলো এই পর্বের উদাহরণ, কোনো বোর্ডের সরকারি নিয়ম নয়): GPA বেশি আগে; GPA সমান হলে মোট নম্বর বেশি আগে; সেটাও সমান হলে নামের বর্ণানুক্রমে। প্রতিটি পরের নিয়ম একটি tie-breaker — আগের নিয়মে সমান হলেই কেবল কাজে লাগে। Java-তে এটা হুবহু এক বাক্যে লেখা যায়। নিচের প্রোগ্রামে Student একটি record, যার natural ordering রোল নম্বর:

import java.util.*;

record Student(int roll, String name, double gpa, int total) implements Comparable<Student> {
    @Override public int compareTo(Student o) { return Integer.compare(roll, o.roll); }
}

class Main {
    public static void main(String[] args) {
        List<Student> list = new ArrayList<>(List.of(
            new Student(104, "Rafi", 5.00, 1180), new Student(101, "Mitu", 4.83, 1105),
            new Student(103, "Anika", 5.00, 1180), new Student(102, "Sakib", 5.00, 1215),
            new Student(105, "Joy", 4.83, 1120)));
        Collections.sort(list);                          // natural ordering: roll
        System.out.println("roll:  " + list.stream().map(Student::name).toList());

        Comparator<Student> merit = Comparator.comparingDouble(Student::gpa).reversed()
            .thenComparing(Comparator.comparingInt(Student::total).reversed())
            .thenComparing(Student::name);
        list.sort(merit);
        for (Student s : list) System.out.printf("%-6s %.2f %d%n", s.name(), s.gpa(), s.total());

        list.sort(Comparator.comparingDouble(Student::gpa).thenComparing(Student::name).reversed());
        System.out.println("wrong: " + list.stream().map(Student::name).toList());
    }
}

আউটপুট প্রথম লাইনে roll: [Mitu, Sakib, Anika, Rafi, Joy] — রোল ১০১ থেকে ১০৫, কারণ Collections.sort নিয়ম না পেয়ে record-এর নিজের compareTo ব্যবহার করেছে। তারপর মেধাতালিকা:

Sakib  5.00 1215
Anika  5.00 1180
Rafi   5.00 1180
Joy    4.83 1120
Mitu   4.83 1105

তিনজনের GPA 5.00 — তাদের মধ্যে Sakib প্রথম, কারণ মোট নম্বর বেশি। Anika আর Rafi-র GPA এবং মোট নম্বর দুটোই সমান, তাই তৃতীয় নিয়ম — নাম — সিদ্ধান্ত নিল। শেষ লাইন wrong: [Sakib, Rafi, Anika, Mitu, Joy] — GPA বড় থেকে ছোট ঠিকই আছে, কিন্তু নামগুলো Z থেকে A! কেন, নিচের দ্বিতীয় ডায়াগ্রামে। একই Student class, Comparable হিসেবে রোলের ক্রম রেখেও, বাইরে থেকে দুটি সম্পূর্ণ আলাদা ক্রম পেল। (list.stream().map(Student::name).toList() শুধু নামগুলো ছাপার জন্য; Stream পর্ব ৩৫-এ।)

Sort চলার সময় এই merit Comparator-কে বারবার দুজন ছাত্র দেওয়া হয়। একটি জোড়া কীভাবে নিষ্পত্তি হয়, ধাপে ধাপে দেখুন:

1merit.compare(a, b):2  comparingDouble(Student::gpa).reversed()3  .thenComparing(comparingInt(Student::total).reversed())4  .thenComparing(Student::name)
তুলনার অবস্থা
a, b
এখনকার নিয়ম
দুজনের মান
এই নিয়মের ফল
  1. একটি জোড়া। TimSort চলার সময় merit-কে জিজ্ঞেস করে: Rafi আর Anika-র মধ্যে কে আগে? merit আসলে তিনটি ছোট Comparator-এর একটি শিকল, যা ক্রমানুসারে চেষ্টা করা হয়।
  2. প্রথম নিয়ম: GPA. comparingDouble ভেতরে Double.compare(5.00, 5.00) করে, ফল শূন্য। reversed() শূন্যকে শূন্যই রাখে। শূন্য মানে “এই নিয়মে সমান” — তাই শিকল পরের নিয়মে যায়।
  3. দ্বিতীয় নিয়ম: মোট নম্বর। 1180 বনাম 1180 — আবার শূন্য। thenComparing-এর পুরো কাজ এটুকুই: আগের ফল শূন্য হলে পরেরটি ডাকো, নইলে আগের ফলই চূড়ান্ত।
  4. তৃতীয় নিয়ম: নাম। "Rafi".compareTo("Anika") ধনাত্মক (R আসে A-এর পরে), তাই Rafi পরে। এখানে কোনো reversed নেই, তাই নাম A থেকে Z.
  5. আরেকটি জোড়া — আগেই নিষ্পত্তি। Sakib আর Anika: GPA সমান, কিন্তু মোট নম্বরে Integer.compare(1215, 1180) ধনাত্মক; reversed দুই পক্ষ অদলবদল করে তুলনা করে, ফলে ঋণাত্মক → Sakib আগে। নামের নিয়ম ডাকাই হয়নি। শিকল যত লম্বাই হোক, বেশিরভাগ জোড়া প্রথম এক-দুই নিয়মেই মিটে যায়।

reversed()-এর ফাঁদ — কাকে উল্টায়?

প্রোগ্রামের শেষ লাইনটি এই পর্বের সবচেয়ে প্রচলিত bug. লেখক চেয়েছিলেন “GPA বড় থেকে ছোট, তারপর নাম A থেকে Z”. লিখলেন comparingDouble(gpa).thenComparing(name).reversed() — ভেবেছিলেন reversed শুধু GPA-র জন্য। কিন্তু Java-তে method chain বাঁ থেকে ডানে চলে: reversed() ডাকা হয় তার আগের পুরো শিকলের ফলাফলের ওপর।

ধাপ ১comparingDouble(Student::gpa)
Joy 4.83 · Mitu 4.83 · Anika 5.00 · Rafi 5.00 · Sakib 5.00
GPA ছোট থেকে বড়; সমান GPA-র ভেতরে ক্রম এখনো ঠিক হয়নি
ধাপ ২.thenComparing(Student::name)
[Joy, Mitu, Anika, Rafi, Sakib]
এখন একটি সম্পূর্ণ নিয়ম: GPA ↑, তারপর নাম ↑
ধাপ ৩.reversed() — পুরো শিকলের ওপর
[Sakib, Rafi, Anika, Mitu, Joy]
GPA ↓ ঠিক আছে, কিন্তু নামও ↓ — Z থেকে A
ধাপ ৪সঠিক: comparingDouble(gpa).reversed().thenComparing(name)
[Anika, Rafi, Sakib, Joy, Mitu]
reversed শুধু GPA-কে উল্টাল, তারপর নাম যোগ হলো ↑
  1. প্রথম নিয়ম একা। শুধু GPA দিয়ে সাজালে 4.83-রা আগে, 5.00-রা পরে। সমান GPA-র মধ্যে কে আগে, এই নিয়ম জানে না।
  2. শিকলে নাম যোগ হলো। thenComparing একটি নতুন Comparator ফেরত দেয়: “GPA ছোট থেকে বড়, সমান হলে নাম A থেকে Z”. এই মুহূর্ত থেকে এটা একটি একক নিয়ম — ভেতরে দুটি অংশ আছে, বাইরে থেকে বোঝার উপায় নেই।
  3. reversed পুরো নিয়মটাকে উল্টাল। reversed() জানে না ভেতরে কয়টি অংশ — সে পুরো ফলাফল উল্টায়। তাই সম্পূর্ণ তালিকা শেষ থেকে শুরুতে চলে গেল: GPA বড় থেকে ছোট হলো ঠিকই, কিন্তু নামও উল্টো। পর্বের প্রোগ্রামের wrong লাইনে ঠিক এটাই এসেছে।
  4. reversed-কে যার জন্য, ঠিক তার পরে বসান। comparingDouble(gpa).reversed() আগে একটি সম্পূর্ণ “GPA ↓” নিয়ম বানায়, তারপর thenComparing(name) যোগ হয় অক্ষত। আরেকটি পরিষ্কার উপায়: thenComparing(Student::name, Comparator.reverseOrder()) — যে অংশ উল্টো চাই, শুধু তাকেই উল্টো Comparator দিন। মেধাতালিকার মোট নম্বরের অংশে ঠিক এভাবেই আলাদা comparingInt(...).reversed() পাঠানো হয়েছে।

lambda দিয়ে লিখলে আরেকটি অবাক করা error

Student::gpa-র বদলে lambda লিখে তার ওপর reversed ডাকলে javac একটি বিভ্রান্তিকর error দেয়:

list.sort(Comparator.comparing(s -> s.gpa()).reversed());   // compile হয় না
Main.java:8: error: cannot find symbol
        list.sort(Comparator.comparing(s -> s.gpa()).reversed());
                                             ^
  symbol:   method gpa()
  location: variable s of type Object

s-কে Object ভাবল কেন? Java-র type inference সাধারণত বাইরের প্রসঙ্গ দেখে lambda-র টাইপ বোঝে — list.sort চায় Comparator<Student>, তাই s হলো Student. কিন্তু এখানে comparing-এর ফলাফলের ওপর সরাসরি .reversed() ডাকা হয়েছে; method chain-এর মাঝের ধাপে বাইরের প্রসঙ্গ পৌঁছায় না, তাই javac কোনো তথ্য না পেয়ে s-কে Object ধরে। সমাধান তিনটি: method reference (Student::gpa নিজেই টাইপ বলে দেয়), lambda-য় টাইপ লেখা ((Student s) -> s.gpa()), অথবা Comparator.<Student, Double>comparing(...). এই কারণেই Comparator-এর শিকলে method reference সবচেয়ে প্রচলিত।

৫ · stable sort — সমান যারা, তারা আগের ক্রমে

ধরুন একটি ওয়েবসাইটে ছাত্রদের তালিকা আগে নামের ক্রমে দেখানো হচ্ছিল। ব্যবহারকারী এবার “GPA” কলামের শিরোনামে চাপলেন। GPA সমান ছাত্ররা এখন কোন ক্রমে থাকবে? Stable sort কথা দেয়: নতুন নিয়মে যারা সমান, তারা sort-এর আগের ক্রমেই থাকবে। অর্থাৎ GPA সমান ছাত্ররা নামের ক্রমেই থাকবে — ব্যবহারকারী ঠিক যা আশা করেন। Java-র List.sort, Collections.sort আর object array-এর Arrays.sort — সবগুলো Javadoc-এ stable হওয়ার নিশ্চয়তা দেয়।

ধাপ ১শুরুর তালিকা (ভর্তির ক্রম)
Rafi 5.00 · Mitu 4.83 · Anika 5.00 · Sakib 5.00 · Joy 4.83
ধাপ ২list.sort(comparing(Student::name))
Anika 5.00 · Joy 4.83 · Mitu 4.83 · Rafi 5.00 · Sakib 5.00
ধাপ ৩এবার শুধু GPA ↓ দিয়ে — কে কার সমান?
5.00 দল: Anika → Rafi → Sakib   (এখনকার ক্রমে)
4.83 দল: Joy → Mitu
ধাপ ৪stable sort-এর ফল (যাচাই করা)
[Anika, Rafi, Sakib, Joy, Mitu]
প্রতিটি দলের ভেতরে নামের ক্রম অক্ষত
ধাপ ৫unstable sort হলে যা হতেই পারত (কাল্পনিক উদাহরণ)
[Sakib, Anika, Rafi, Mitu, Joy]
GPA নিয়ম মানা হয়েছে — তবু ক্রম "এলোমেলো"
  1. শুরু। তালিকা যে ক্রমে ঢুকেছিল, সেভাবেই আছে। এখানে কোনো নিয়ম নেই।
  2. প্রথমে নামের sort. ব্যবহারকারী আগে “নাম” কলামে চেপেছিলেন — তালিকা A থেকে Z.
  3. এবার শুধু GPA. নতুন Comparator কেবল GPA দেখে; তার চোখে Anika, Rafi, Sakib একেবারে সমান (compare শূন্য)। এদের মধ্যে কে আগে — এই প্রশ্নের উত্তর Comparator দেয় না, দেয় sort অ্যালগরিদমের stability.
  4. Stable: আগের ক্রম রক্ষা। TimSort সমান উপাদানের জায়গা অদলবদল করে না, তাই দুটি দলের ভেতরে নামের ক্রম যেমন ছিল তেমন রইল। ফলাফলটি অংশ ৪-এর “GPA ↓ তারপর নাম” Comparator-এর ফলের সাথে হুবহু এক — দুবার sort করে একটি শিকলের কাজ হয়ে গেল।
  5. Unstable হলে। Comparator-এর চুক্তি ভাঙেনি — GPA 5.00-রা আগে, 4.83-রা পরে। কিন্তু দলের ভেতরের ক্রম অ্যালগরিদমের মর্জি। প্রতিবার ডেটা একটু বদলালে ক্রম লাফাতে পারে — ব্যবহারকারী ভাবেন সাইট নষ্ট। Java-র object sort-এ এটা কখনো ঘটে না, কিন্তু অন্য কোথাও (যেমন C++-এর std::sort, কিছু ডেটাবেসের ORDER BY) ঘটতে পারে।

Stability-র ওপর ভরসা করবেন, কিন্তু ক্রম লুকিয়ে রাখবেন না। “আগে নামের sort, তারপর GPA-র sort” কাজ করে, কিন্তু কোড পড়ে কেউ বুঝবে না যে নামের ক্রম ইচ্ছাকৃত। চূড়ান্ত ক্রম যদি নিয়মের অংশ হয়, সেটা Comparator-এ লিখুন: comparingDouble(gpa).reversed().thenComparing(name). আর tie-breaker না দিলে সমান উপাদানের ক্রম নির্ভর করে ইনপুটের ক্রমের ওপর — ডেটাবেস বা HashMap থেকে আসা ডেটার ক্রম প্রতিবার আলাদা হতে পারে, তাই মেধাতালিকা প্রতিবার আলাদা দেখাতে পারে।

ভেতরে কী চলে — TimSort, হালকাভাবে

Java 7 থেকে object-এর sort চালায় TimSort — ২০০২ সালে Tim Peters-এর Python-এর জন্য বানানো অ্যালগরিদম, Java-তে আনেন Joshua Bloch. এর মূল বুদ্ধি: বাস্তবের ডেটা প্রায়ই আংশিক সাজানো থাকে (গতকালের মেধাতালিকায় আজ দুজন নতুন ছাত্র)। TimSort আগে ডেটার মধ্যে আগে থেকেই সাজানো টুকরো (run) খোঁজে, ছোট টুকরোকে insertion sort দিয়ে একটু বড় করে, তারপর merge sort-এর মতো টুকরোগুলো জোড়ে। ফলে:

  • সবচেয়ে খারাপ ক্ষেত্রে O(n log n) তুলনা — time complexity-র ভাষায়, ১০ লাখ উপাদানে মোটামুটি ২ কোটির কাছাকাছি তুলনা, ১০ লাখ × ১০ লাখ নয়।
  • আগে থেকে সাজানো ডেটায় প্রায় O(n) — একবার দেখেই বুঝে যায়, সাজানোর কিছু নেই।
  • Stable, কারণ merge করার সময় সমান হলে সবসময় বাঁ দিকেরটি আগে নেয়।
  • দাম: merge-এর জন্য বাড়তি মেমরি (সবচেয়ে খারাপ ক্ষেত্রে n/2 আকারের)। প্রতিটি তুলনা আপনার Comparator ডাকে — তাই Comparator-এর ভেতরে ভারী কাজ (ডেটাবেস ডাকা, String বানানো) রাখবেন না; সেটা কোটিবার চলবে।

৬ · null, উল্টো ক্রম আর বড়-ছোট হাতের অক্ষর

একটি ই-কমার্স সাইটের পণ্যের তালিকা ভাবুন: Product(name, price, rating). নতুন পণ্যের এখনো কোনো রেটিং নেই — rating হলো null (টাইপ Double, primitive নয়)। “রেটিং: বেশি থেকে কম” চাপলে কী হবে?

record Product(String name, int price, Double rating) {}
// Fan 3200 4.5 · Kettle 1450 null · Iron 1450 4.1 · Lamp 900 4.8

shop.sort(Comparator.comparingInt(Product::price));
// [Lamp, Kettle, Iron, Fan]      ← Kettle আর Iron-এর দাম সমান: আগের ক্রম রইল (stable)

shop.sort(Comparator.comparing(Product::rating));
// NullPointerException           ← null-এর compareTo ডাকা যায় না

shop.sort(Comparator.comparing(Product::rating, Comparator.nullsLast(Comparator.reverseOrder())));
// [Lamp, Fan, Iron, Kettle]      ← রেটিং বেশি আগে, রেটিং-হীন পণ্য সবার শেষে

মন্তব্যে লেখা ফলগুলো OpenJDK 21-এ চালিয়ে যাচাই করা। এখানে তিনটি নতুন হাতিয়ার:

হাতিয়ারকাজকখন
Comparator.naturalOrder()Comparable-এর নিজের ক্রম, Comparator হিসেবেযেখানে Comparator চাই কিন্তু স্বাভাবিক ক্রমই যথেষ্ট
Comparator.reverseOrder()স্বাভাবিক ক্রমের উল্টোInteger[] বড় থেকে ছোট, thenComparing(key, reverseOrder())
nullsFirst(cmp) / nullsLast(cmp)null-কে শুরুতে বা শেষে রাখে, বাকিদের cmp দিয়ে সাজায়ঐচ্ছিক field: রেটিং, ফোন নম্বর, শেষ লগইনের সময়
String.CASE_INSENSITIVE_ORDERবড়-ছোট হাতের অক্ষরের পার্থক্য না করে String তুলনাইংরেজি নাম, ফাইলের নাম, ট্যাগ

শেষেরটি কেন লাগে? String-এর compareTo প্রতিটি অক্ষরের UTF-16 code unit-এর সংখ্যা তুলনা করে, আর সব বড় হাতের ইংরেজি অক্ষরের সংখ্যা (A = 65 … Z = 90) সব ছোট হাতের অক্ষরের (a = 97 …) চেয়ে কম। ফলে সাধারণ sort-এ ["banana", "Cherry", "apple", "Apple"] হয়ে যায় [Apple, Cherry, apple, banana] — Cherry বসে apple-এর আগে! সেই সাজানো list-এ এবার String.CASE_INSENSITIVE_ORDER দিলে [Apple, apple, banana, Cherry]. আর এখানে stability আবার কাজে লাগল: Apple আর apple এই নিয়মে সমান, তাই আগের sort-এর ক্রম (Apple আগে) রইল। মূল ইনপুটে সরাসরি দিলে ইনপুটের ক্রম থাকত — [apple, Apple, banana, Cherry]. সব ফলই যাচাই করা।

leaderboard — একটি নিয়ম উল্টো, আরেকটি সোজা

প্রোগ্রামিং কনটেস্টের র‍্যাঙ্কিং-এর প্রচলিত নিয়ম: বেশি সমস্যা সমাধান করা দল আগে; সমান হলে কম penalty-র দল আগে। একটি অংশ উল্টো, পরেরটি সোজা — reversed-কে ঠিক জায়গায় বসানোর নিখুঁত অনুশীলন:

record Team(String name, int solved, int penalty) {}

board.sort(Comparator.comparingInt(Team::solved).reversed()
                     .thenComparingInt(Team::penalty));

thenComparingInt হলো thenComparing-এর boxing-মুক্ত রূপ। reversed এখানে শুধু solved-এর ঠিক পরে — তখন পর্যন্ত শিকলে ওই একটিই নিয়ম ছিল, তাই উল্টাল শুধু সেটাই।

৭ · বাংলা নাম সাজানো — compareTo, Collator আর Unicode

বর্ণানুক্রম ভাষার নিয়ম, কম্পিউটারের নয়। সুইডিশ ভাষায় ä বসে z-এর পরে, জার্মানে a-এর পাশে। একই তিনটি শব্দ — zebra, ärlig, apa — Java-র Collator দিয়ে সাজিয়ে দেখা ফল: সুইডিশ locale-এ [apa, zebra, ärlig], জার্মানে [apa, ärlig, zebra]. এই ভাষাভিত্তিক নিয়মের আন্তর্জাতিক মানদণ্ড হলো Unicode Collation Algorithm (ভাবনার প্রশ্ন ৩)। Collator হলো java.text-এর class, যা locale অনুযায়ী String তুলনা করে, আর নিজেই একটি Comparator — তাই সরাসরি list.sort(collator) লেখা যায়।

বাংলা নামের ক্ষেত্রে আসল প্রশ্ন দুটি: সাধারণ compareTo কী করে, আর Collator কি ভালো কিছু করে? দুটোই Wandbox-এর OpenJDK 21-এ চালিয়ে যাচাই করা হয়েছে:

  • সাধারণ compareTo বাংলাকে Unicode code unit-এর সংখ্যা দিয়ে সাজায়। বাংলা Unicode block-এ স্বরবর্ণ আর ব্যঞ্জনবর্ণ মোটামুটি বর্ণমালার ক্রমেই বসানো, তাই সাধারণ নামে ফল বেশ স্বাভাবিক দেখায়: [অনিক, আরিফ, ইমন, ঋতু, করিম, রহিম, শাওন].
  • ইংরেজি আর বাংলা মেশালে সব ইংরেজি আগে — কারণ ইংরেজি অক্ষরের সংখ্যা ছোট। আর compareTo-তে বড় হাতের ইংরেজিও ছোট হাতের আগে: [Anik, Rahim, rafi, আরিফ]; Collator-এ [Anik, rafi, Rahim, আরিফ].
  • Java-র নিজের Collator-এ বাংলার জন্য আলাদা কোনো নিয়ম নেই। Collator.getAvailableLocales()-এর তালিকায় bn নেই; Collator.getInstance(Locale.forLanguageTag("bn")) যে নিয়ম দেয়, তা হুবহু root locale-এর সাধারণ নিয়ম। যতগুলো বাংলা নামের তালিকা পরীক্ষা করা হয়েছে (ক্ষ, ঋ-কার, ং, চন্দ্রবিন্দু, য়-সহ), প্রতিটিতে Collator-এর ক্রম সাধারণ compareTo-র ক্রমের সাথে হুবহু মিলেছে।

তাহলে Collator কি বাংলায় অকেজো? না — একটি জায়গায় এটি সত্যিই বাঁচায়: একই দেখতে লেখার আলাদা Unicode রূপ। “বড়” শব্দের ড় দুইভাবে লেখা যায়: একটি আলাদা অক্ষর হিসেবে (U+09DC), অথবা ড + নুক্তা চিহ্ন (U+09A1 U+09BC). পর্দায় দুটি হুবহু এক, কিন্তু কিবোর্ড বা ফোন ভেদে আলাদা রূপ আসে। ধাপে ধাপে দেখুন:

ধাপ ১দুটি String, পর্দায় একই
a = "বড়"   → 09AC 09DC
b = "বড়"   → 09AC 09A1 09BC
ড় একটি অক্ষর বনাম ড + নুক্তা (দুটি code unit)
ধাপ ২a.equals(b), a.compareTo(b)
equals    → false
compareTo → 59   (0x09DC − 0x09A1)
new TreeSet<>(List.of(a, b)).size() → 2
ধাপ ৩Collator, ডিফল্ট সেটিং
coll.getDecomposition() → 0  (NO_DECOMPOSITION)
coll.compare(a, b)      → 1   (এখনো আলাদা)
ধাপ ৪coll.setDecomposition(Collator.CANONICAL_DECOMPOSITION)
coll.compare(a, b) → 0
TreeSet(coll)-এ দুটি যোগ করলে size → 1
ধাপ ৫সংরক্ষণের আগে normalize
Normalizer.normalize(a, NFC).equals(Normalizer.normalize(b, NFC)) → true
ডেটা ঢোকার সময়ই এক রূপে আনুন — তখন equals, HashSet, compareTo সব ঠিক
  1. দুটি লেখা, একই চেহারা। Unicode-এ ড় অক্ষরটির নিজস্ব code point আছে, আবার ড-এর সাথে নুক্তা চিহ্ন জুড়েও একই অক্ষর হয়। ব্যবহারকারী দেখেন একটি নাম; প্রোগ্রাম দেখে আলাদা সংখ্যার সারি।
  2. সাধারণ তুলনা: আলাদা। equals মেলায় code unit ধরে ধরে, তাই false. compareTo প্রথম অমিল জায়গার পার্থক্য দেয় — 59. ফলে একটি TreeSet বা HashSet-এ “একই” নাম দুবার থাকে, আর সার্চে “বড়” লিখে অন্য রূপের লেখা পাওয়া যায় না।
  3. Collator-ও ডিফল্টে আলাদা ধরে। Collator-এর decomposition ডিফল্টে বন্ধ (দ্রুততার জন্য), তাই দুটি রূপ এখনো সমান নয়।
  4. Canonical decomposition চালু করলে সমান। এবার Collator তুলনার আগে প্রতিটি অক্ষরকে তার মূল উপাদানে ভাঙে — দুটি রূপ একই হয়ে যায়, compare শূন্য, আর এই Collator দিয়ে বানানো TreeSet-এ একটিই থাকে।
  5. সবচেয়ে ভালো: ঢোকার সময় normalize. Unicode normalization (java.text.Normalizer) দিয়ে নাম সংরক্ষণের আগেই এক রূপে আনলে পরে কোনো তুলনায় আর সমস্যা হয় না। এই উদাহরণে NFC করলে দুটি একই code unit-এর সারি হয়ে যায়। বাংলা ফর্ম বা সার্চ বক্স থেকে আসা লেখার জন্য এটা একটি ভালো অভ্যাস।

বাংলা অভিধানের পূর্ণ, ভাষাভিত্তিক ক্রম দরকার হলে (যেমন যুক্তবর্ণের অবস্থান নিয়ে অভিধানের নিজস্ব নিয়ম), JDK-র Collator-এর ওপর নির্ভর করা যায় না। সেজন্য সাধারণত ICU4J library ব্যবহার করা হয় — Unicode Consortium-এর ICU প্রজেক্টের Java সংস্করণ, যা UCA আর CLDR-এর ভাষাভিত্তিক নিয়ম মেনে চলে। এটি JDK-র অংশ নয়, Maven দিয়ে যোগ করতে হয় (পর্ব ৪৬)। আপনার তালিকায় কোন ক্রম “সঠিক”, তা আগে ব্যবহারকারী বা প্রতিষ্ঠানের সাথে ঠিক করুন, তারপর আসল ডেটা দিয়ে পরীক্ষা করুন।

৮ · TreeSet, TreeMap, PriorityQueue — Comparator যখন সমতাও ঠিক করে

এ পর্যন্ত Comparator শুধু ক্রম ঠিক করেছে। কিন্তু পর্ব ৩১-এর TreeSet আর TreeMap আর পর্ব ৩২-এর PriorityQueue-এর constructor-এও Comparator দেওয়া যায় — আর সেখানে একটি সূক্ষ্ম পার্থক্য আছে। HashSet সমতা যাচাই করে equals দিয়ে; TreeSet আর TreeMap যাচাই করে compare দিয়ে. compare শূন্য দিলে তাদের চোখে দুটি উপাদান একই — equals যা-ই বলুক।

import java.util.*;

record Student(String name, double gpa) {}

class Main {
    public static void main(String[] args) {
        List<Student> all = List.of(new Student("Rafi", 5.00), new Student("Mitu", 4.83),
                                    new Student("Anika", 5.00), new Student("Joy", 4.83));
        Comparator<Student> byGpa = Comparator.comparingDouble(Student::gpa).reversed();

        Set<Student> bad = new TreeSet<>(byGpa);
        for (Student s : all) System.out.print(bad.add(s) + " ");
        System.out.println("-> size " + bad.size() + " " + bad.stream().map(Student::name).toList());
        System.out.println("contains Nobody(5.00)? " + bad.contains(new Student("Nobody", 5.00)));

        Set<Student> good = new TreeSet<>(byGpa.thenComparing(Student::name));
        good.addAll(all);
        System.out.println("fixed: size " + good.size() + " " + good.stream().map(Student::name).toList());
        System.out.println("HashSet size " + new HashSet<>(all).size());
    }
}

আউটপুট:

true true false false -> size 2 [Rafi, Mitu]
contains Nobody(5.00)? true
fixed: size 4 [Anika, Rafi, Joy, Mitu]
HashSet size 4

চারজন আলাদা ছাত্র, কিন্তু GPA-ভিত্তিক TreeSet-এ মাত্র দুজন! Anika আর Joy-এর add false ফেরত দিয়েছে — কোনো exception নেই, কোনো warning নেই। আরও অদ্ভুত: “Nobody” নামে কেউ set-এ নেই, তবু contains true, কারণ তার GPA 5.00, আর Rafi-র সাথে compare শূন্য। নাম দিয়ে tie-breaker যোগ করতেই চারজনই রইল। আর একই ডেটায় HashSet-এ ৪ — record-এর equals সব field দেখে। একই ডেটা, দুই set, দুই উত্তর: এটাই “Comparator equals-এর সাথে consistent নয়” কথাটির অর্থ।

1bad.add(Rafi 5.00)2bad.add(Mitu 4.83)3bad.add(Anika 5.00)4bad.add(Joy 4.83)5bad.contains(Nobody 5.00)
tree-র ভেতরে
কার সাথে তুলনা
compare-এর ফল
ফেরত মান
  1. প্রথম উপাদান। Tree খালি, তুলনার কেউ নেই — Rafi মূলে বসল। TreeSet ভেতরে একটি red-black tree (আসলে একটি TreeMap), যেখানে প্রতিটি নতুন উপাদান বারবার compare করে বাঁয়ে বা ডানে নামে।
  2. Mitu. byGpa.compare(Mitu, Rafi): GPA উল্টো ক্রমে 4.83 আসে 5.00-এর পরে, ফল ধনাত্মক — Mitu ডানে বসল। এখনো সব স্বাভাবিক।
  3. Anika — নিঃশব্দে বাদ। compare(Anika, Rafi) শূন্য। TreeMap-এর কাছে শূন্য মানে “এই key আগে থেকেই আছে”। Set-এর নিয়মে ডুপ্লিকেট রাখা হয় না, তাই add false দেয় আর Anika কোথাও যায় না। TreeSet কখনো equals ডাকেনি — ডাকলে দেখত দুজন আলাদা।
  4. Joy — একই পরিণতি। Rafi-র চেয়ে পরে, তারপর Mitu-র সাথে compare শূন্য — বাদ। ভাবুন এটা একটি বৃত্তির তালিকা: যাদের GPA কারও সাথে মিলে গেছে, তারা তালিকা থেকে উধাও।
  5. contains-ও compare দিয়ে। “Nobody” কখনো যোগ হয়নি, কিন্তু তার GPA 5.00 — Rafi-র সাথে compare শূন্য, তাই উত্তর “আছে”। TreeMap-এ এই Comparator দিয়ে key রাখলে আরও খারাপ: একই GPA-র দ্বিতীয় ছাত্রের put প্রথমজনের মান মুছে নিজেরটা বসায়। সমাধান সবসময় একই: Comparator-এ যথেষ্ট tie-breaker দিন, যাতে compare শূন্য হয় কেবল সত্যিই একই জিনিসে।

PriorityQueue-এর আচরণ এখানে আলাদা: সে ডুপ্লিকেট বাদ দেয় না, compare শুধু কে আগে বের হবে তা ঠিক করে। new PriorityQueue<>(merit) দিলে poll() প্রথমে Sakib, তারপর Anika দেয় — মেধাতালিকার শীর্ষ থেকে (যাচাই করা)। মনে রাখবেন, PriorityQueue-এর ভেতরের heap পুরোপুরি সাজানো থাকে না; লুপ দিয়ে ছাপলে বা toString-এ ক্রম আশা করবেন না — ক্রম পাওয়া যায় শুধু poll করে করে (পর্ব ৩২)। TreeMap-এ Comparator দিয়ে key-এর ক্রমও বদলানো যায়, যেমন new TreeMap<Double, String>(Comparator.reverseOrder()) — সবচেয়ে বেশি GPA প্রথমে।

যখন নিয়মই ভাঙা — “Comparison method violates its general contract!”

Comparator চুক্তি ভাঙলে (যেমন সমান দুটি উপাদানে compare(a, b) আর compare(b, a) দুটোই ধনাত্মক) TimSort মাঝে মাঝে সেটা টের পায় আর থেমে যায়। একটি পরিচিত ভুল Comparator দিয়ে — (x, y) -> x < y ? -1 : 1, যা সমান হলে কখনো শূন্য দেয় না — অনেক ডুপ্লিকেট-ভরা ২০০০টি সংখ্যা সাজাতে গিয়ে OpenJDK 21-এ পাওয়া গেল:

Exception in thread "main" java.lang.IllegalArgumentException: Comparison method violates its general contract!
	at java.base/java.util.TimSort.mergeLo(TimSort.java:781)
	at java.base/java.util.TimSort.mergeAt(TimSort.java:518)
	at java.base/java.util.TimSort.mergeCollapse(TimSort.java:448)
	at java.base/java.util.TimSort.sort(TimSort.java:245)
	at java.base/java.util.Arrays.sort(Arrays.java:1308)
	at java.base/java.util.ArrayList.sort(ArrayList.java:1804)
	at Main.main(Main.java:8)

Stack trace-টি নিচ থেকে পড়ুন: আপনার list.sort → ArrayList.sort → Arrays.sort → TimSort-এর merge ধাপ, যেখানে সে লক্ষ করল যে আগের তুলনার উত্তরের সাথে এখনকারটি মেলে না। সবচেয়ে বিপজ্জনক দিক: এই exception নিশ্চিত নয়। ছোট বা কম ডুপ্লিকেটের ডেটায় একই ভাঙা Comparator চুপচাপ একটি ভুল ক্রম দেয়; বড় ডেটা এলে হঠাৎ production-এ crash. কারণগুলো প্রায় সবসময় এর কোনো একটি: বিয়োগ দিয়ে লেখা compare, সমান অবস্থায় শূন্য না দেওয়া, তুলনার মাঝখানে বদলে যাওয়া মান, অথবা Math.random() দিয়ে “এলোমেলো” sort (এলোমেলো করতে Collections.shuffle লিখুন)।

পরীক্ষা ও ইন্টারভিউতে
  • Comparable আর Comparator-এর পার্থক্য? — Comparable: class-এর ভেতরে একটি natural ordering, compareTo(T), java.lang-এ। Comparator: বাইরে থেকে যত খুশি ক্রম, compare(T, T), java.util-এ।
  • Collections.sort কোন অ্যালগরিদম, stable কি? — Java 7 থেকে TimSort, O(n log n), stable. Primitive array-এর Arrays.sort Dual-Pivot Quicksort, stable নয়।
  • comparing(a).thenComparing(b).reversed() কী করে? — পুরো শিকল উল্টায়: a আর b দুটোই উল্টো ক্রমে। শুধু a উল্টাতে comparing(a).reversed().thenComparing(b).
  • TreeSet-এ দুটি আলাদা object রাখলাম, size 1 কেন? — TreeSet সমতা ঠিক করে compare দিয়ে; Comparator দুজনের জন্য শূন্য দিলে দ্বিতীয়টি ডুপ্লিকেট ধরে বাদ পড়ে।
  • “Comparison method violates its general contract!” কেন আসে? — Comparator চুক্তি ভেঙেছে (সাধারণত বিয়োগ, অসঙ্গত tie, বা বদলানো মান); TimSort অসঙ্গতি ধরলে IllegalArgumentException দেয়।

৯ · AI-এর যুগে — সাজানোর নিয়ম যাচাই করা আপনার কাজ

“ছাত্রদের GPA অনুযায়ী সাজাও” — AI এক সেকেন্ডে কোড দেয়, আর ছোট টেস্টে তা প্রায় সবসময় ঠিক দেখায়। সমস্যা হলো, Comparator-এর bug-গুলো লুকিয়ে থাকে সমান মানে, খুব বড় মানে, null-এ আর ভাষায় — ঠিক যে ডেটা টেস্টে কেউ রাখে না। AI-এর লেখা কোডে যে ভুলগুলো বারবার দেখা যায়:

AI যা করেকেন সমস্যাআপনি যা করবেন
(a, b) -> a.score - b.scoreবড় বা ঋণাত্মক মানে overflow; চুক্তি ভাঙে, ক্রম উল্টায় বা exceptioncomparingInt বা Integer.compare চান
.thenComparing(name).reversed() — reversed শেষেপুরো শিকল উল্টায়, নামও Z থেকে Areversed ঠিক যে নিয়মের জন্য, তার পরেই
শুধু একটি নিয়ম, কোনো tie-breaker নেইসমান মানের ক্রম ইনপুটের ওপর নির্ভর করে — প্রতিবার আলাদা তালিকা; TreeSet-এ উপাদান হারায়শেষে একটি অনন্য key (id, roll) দিয়ে tie-breaker
বাংলা বা মিশ্র নাম সাধারণ compareTo দিয়েcode unit-এর ক্রম; ইংরেজি আগে, বড় হাতের আগে; একই দেখতে নাম আলাদাপ্রয়োজন বলে দিন: normalize, Collator, না ICU4J
null-ওয়ালা field-এ সরাসরি comparingপ্রথম null-এই NullPointerExceptionnullsFirst/nullsLast
int key-এ comparing(Student::roll)প্রতিটি তুলনায় boxing; বড় ডেটায় ধীরcomparingInt/comparingDouble

AI-কে সাজানোর কোড লিখতে বা রিভিউ করতে বলার সময় এই কাঠামো ব্যবহার করুন:

আমি Java 21 শিখছি, Comparable আর Comparator-এর চুক্তি বুঝি। এই ডেটা সাজানোর Comparator লিখে দাও: [যেমন — record Student(int roll, String name, double gpa, int total)] নিয়ম (ক্রমানুসারে): [যেমন — ১. gpa বেশি আগে ২. total বেশি আগে ৩. name A থেকে Z ৪. roll ছোট আগে] শর্ত: ১. Comparator.comparing / comparingInt / comparingDouble আর thenComparing দিয়ে লেখো; কোথাও বিয়োগ দিয়ে তুলনা করবে না। ২. প্রতিটি reversed() কোন অংশকে উল্টাচ্ছে, মন্তব্যে লেখো; reversed যেন আগের পুরো শিকল না উল্টায়। ৩. শেষ tie-breaker একটি অনন্য field হবে, যাতে দুটি আলাদা object-এ compare কখনো শূন্য না হয়। ৪. কোন field null হতে পারে বলছি: [যেমন — কোনোটি না]; null হলে nullsLast ব্যবহার করো। ৫. নাম বাংলায় হলে আলাদা করে বলো: সাধারণ compareTo কী ক্রম দেবে, আর Normalizer/Collator লাগবে কি না। ৬. এই Comparator TreeSet/TreeMap-এ ব্যবহার করলে কোনো উপাদান হারাবে কি না, এক লাইনে বলো। ৭. শেষে একটি ছোট main দাও যেখানে অন্তত দুটি ছাত্রের gpa আর total সমান — সাজানো তালিকা ছাপবে।

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

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

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

প্র ০১Python-এর জন্য লেখা TimSort কীভাবে Java-র ডিফল্ট sort হলো — আর বহু বছর পরে formal verification দিয়ে এর ভেতরে কোন bug পাওয়া গেল?

জন্ম। ২০০২ সালে Tim Peters Python-এর list.sort-এর জন্য নতুন একটি অ্যালগরিদম লেখেন, যা Python 2.3 থেকে ব্যবহার হয়। তাঁর পর্যবেক্ষণ ছিল, পাঠ্যবইয়ের sort-গুলো ধরে নেয় ডেটা সম্পূর্ণ এলোমেলো, অথচ বাস্তবের ডেটায় প্রায়ই আগে থেকে সাজানো বা উল্টো সাজানো লম্বা টুকরো থাকে। TimSort সেই টুকরো (run) খুঁজে নেয়, ছোট টুকরোকে binary insertion sort দিয়ে একটি ন্যূনতম দৈর্ঘ্যে আনে, আর run-গুলোকে একটি stack-এ রেখে নির্দিষ্ট নিয়মে merge করে, যাতে merge-গুলো মোটামুটি সমান আকারের হয়। একটি run থেকে অনেক উপাদান পরপর জিতলে “galloping” মোডে লাফিয়ে লাফিয়ে খোঁজে।

Java-তে আগমন। Java 6 পর্যন্ত object sort ছিল একটি পরিবর্তিত merge sort. Joshua Bloch TimSort-কে Java-তে আনেন, আর Java 7-এ এটি object array ও list-এর ডিফল্ট হয় (Android-ও একই কোড নেয়)। একটি অপ্রত্যাশিত পার্শ্বফল: নতুন sort ভাঙা Comparator ধরে ফেলে “Comparison method violates its general contract!” দিতে শুরু করে। Java 7-এ আপগ্রেড করার পর বহু পুরোনো প্রোগ্রাম হঠাৎ এই exception দিতে থাকে — তাদের Comparator বহু বছর ধরেই ভাঙা ছিল, শুধু পুরোনো sort চুপ করে ছিল। সাময়িক রক্ষার জন্য java.util.Arrays.useLegacyMergeSort নামে একটি system property রাখা হয়।

২০১৫ সালের আবিষ্কার। নেদারল্যান্ডস আর জার্মানির একদল গবেষক — Stijn de Gouw, Jurriaan Rot, Frank de Boer, Richard Bubel আর Reiner Hähnle — KeY নামের একটি formal verification টুল দিয়ে OpenJDK-র TimSort-এর সঠিকতা গাণিতিকভাবে প্রমাণ করতে চাইছিলেন। প্রমাণ আটকে গেল: run-এর stack নিয়ে কোডের একটি অনুমান (invariant) — যার ওপর ভিত্তি করে stack-এর আকার ঠিক করা হয়েছিল — সব ক্ষেত্রে সত্য থাকে না। তাঁরা দেখান, বিশেষভাবে বানানো খুব বড় (কয়েক কোটি উপাদানের) ইনপুটে stack উপচে ArrayIndexOutOfBoundsException হতে পারে। একই নকশার কারণে Python-এর মূল সংস্করণেও সমস্যাটি ছিল। দুই প্রজেক্টেই সংশোধন আসে; তাঁদের প্রবন্ধের নাম ছিল প্রায় এমন — “OpenJDK’s java.utils.Collection.sort() is broken: The good, the bad and the worst case”.

শিক্ষা। লাখো ডিভাইসে বছরের পর বছর চলা, অসংখ্য টেস্ট পাস করা কোডেও bug থাকতে পারে, যা কোনো সাধারণ ডেটা কখনো ছোঁয় না। টেস্ট দেখায় bug আছে; শুধু প্রমাণই দেখাতে পারে bug নেই।

মূল উপলব্ধি: TimSort দেখায় বাস্তব ডেটার গঠন বুঝে বানানো অ্যালগরিদম কত শক্তিশালী — আর এর গল্প দেখায় যে সবচেয়ে বিশ্বস্ত library-ও গাণিতিক যাচাইয়ের সামনে নিখুঁত না-ও হতে পারে।

প্র ০২“Comparison method violates its general contract!” — sort অ্যালগরিদম কীভাবে বোঝে যে আপনার Comparator মিথ্যা বলছে? আর কেন এই exception সবসময় আসে না?

চুক্তি কী দাবি করে। Comparator-এর Javadoc তিনটি শর্ত দেয়: চিহ্নের প্রতিসাম্য (compare(a, b)-এর চিহ্ন compare(b, a)-এর উল্টো), transitivity (a > b আর b > c হলে a > c), আর সমান হলে সামঞ্জস্য (compare(a, b) শূন্য হলে a আর b অন্য যেকোনো c-এর সাথে একই রকম তুলনীয়)। গণিতের ভাষায় Comparator-কে একটি “total preorder” বর্ণনা করতে হয়। sort অ্যালগরিদম এই শর্তগুলোকে সত্য ধরে নিয়ে শর্টকাট নেয় — তাই এত দ্রুত।

TimSort কীভাবে ধরে। TimSort-এর merge ধাপ দুটি সাজানো run-কে জোড়ে। গতি বাড়াতে সে galloping করে: কোনো উপাদান অন্য run-এর কোথায় বসবে, তা binary search দিয়ে লাফিয়ে খুঁজে নেয়। এই হিসাব নির্ভর করে আগের তুলনার ফলের ওপর — যেমন “এই উপাদান ওই run-এর শেষটির চেয়ে বড়, তাই অন্তত একটি উপাদান অবশ্যই বাকি থাকবে।” Comparator অসঙ্গত হলে এমন অবস্থায় পৌঁছানো যায় যা সঠিক Comparator-এ অসম্ভব — যেমন একটি run অপ্রত্যাশিতভাবে খালি হয়ে যাওয়া। TimSort-এর কোড এই “অসম্ভব” অবস্থাগুলো পরীক্ষা করে, আর পেলে ভুল ফলাফল না দিয়ে IllegalArgumentException দেয়। এই পর্বের trace-এ দেখেছেন সেটা এসেছে mergeLo থেকে।

কেন সবসময় নয়। সব অসঙ্গতি যাচাই করতে হলে প্রতিটি জোড়া তুলনা করতে হতো — O(n²) কাজ, যা sort-এর পুরো উদ্দেশ্য নষ্ট করে। TimSort শুধু সেই অসঙ্গতি ধরে যা তার নিজের কাজ চালিয়ে যাওয়া অসম্ভব করে তোলে। ছোট তালিকায় (৩২টির কম উপাদানে Java-র TimSort শুধু binary insertion sort চালায়, galloping ছাড়া) এমন অবস্থা প্রায় আসেই না; ভাঙা Comparator সেখানে নীরবে একটি ক্রম দেয়, যা হয়তো ভুল। ফলে bug-টি দেখা দেয় শুধু বড়, ডুপ্লিকেট-ভরা ডেটায় — প্রায়ই production-এ, কোনো এক উৎসবের দিনে যখন অর্ডার অনেক বেশি।

সাধারণ অপরাধী। বিয়োগ দিয়ে লেখা compare (overflow transitivity ভাঙে), double-এ (int)(a - b) (0.3 আর 0.0-কে সমান বলে, কিন্তু অন্যদের সাথে নয়), সমান হলে শূন্য না দেওয়া, sort চলার সময় বদলে যাওয়া field (অন্য thread বা ক্যাশ), আর “এলোমেলো” করার জন্য random ফল দেওয়া Comparator.

মূল উপলব্ধি: exception-টি আসলে একটি উপহার — অ্যালগরিদম আপনাকে জানাচ্ছে যে আপনার “কে আগে” নিয়মের কোনো অর্থপূর্ণ উত্তর নেই। না আসাটা নিরাপত্তার প্রমাণ নয়; Comparator-এর সঠিকতা চুক্তি বুঝে নিজে নিশ্চিত করতে হয়।

প্র ০৩বর্ণানুক্রম কেন ভাষাভেদে আলাদা? Unicode Collation Algorithm সমস্যাটি কীভাবে সমাধান করে, আর বাংলার জন্য এর মানে কী?

সমস্যা। Unicode প্রতিটি অক্ষরকে একটি সংখ্যা দেয়, কিন্তু সেই সংখ্যার ক্রম বর্ণানুক্রম নয় — নতুন অক্ষর যোগ হয়েছে ইতিহাসের ক্রমে, ভাষার নয়। তার চেয়েও বড় কথা, “সঠিক” ক্রম ভাষার সিদ্ধান্ত। সুইডিশে å, ä, ö বর্ণমালার শেষে, z-এর পরে; জার্মানে ä সাধারণ অভিধানে a-এর সাথে, কিন্তু টেলিফোন ডিরেক্টরির ঐতিহ্যে “ae” হিসেবে। স্প্যানিশে একসময় “ch” আর “ll” আলাদা বর্ণ হিসেবে সাজানো হতো। এই পর্বে Java-র Collator দিয়ে সুইডিশ আর জার্মান locale-এ ä-এর আলাদা অবস্থান আপনি নিজে দেখেছেন।

UCA-র সমাধান। Unicode Technical Standard #10, Unicode Collation Algorithm, প্রতিটি অক্ষরকে কয়েক স্তরের ওজন দেয়। প্রথম স্তর মূল অক্ষর (a বনাম b), দ্বিতীয় স্তর accent বা চিহ্ন (e বনাম é), তৃতীয় স্তর বড়-ছোট হাতের অক্ষর (a বনাম A). তুলনা প্রথমে পুরো শব্দের প্রথম স্তর দেখে; পুরোপুরি সমান হলে তবেই দ্বিতীয় স্তর। তাই “resume” আর “résumé” পাশাপাশি বসে, অথচ আলাদাও থাকে। Java-র Collator-এর strength (PRIMARY, SECONDARY, TERTIARY) এই স্তরেরই প্রতিফলন — PRIMARY strength-এ ইংরেজি Collator “apple” আর “Apple”-কে, এমনকি “resume” আর “résumé”-কে সমান বলে (যাচাই করা)। UCA একটি ডিফল্ট ওজনের টেবিল দেয়, আর প্রতিটি ভাষা তার ওপর নিজের “tailoring” বসায়; সেই ভাষাভিত্তিক নিয়মগুলো সংগ্রহ করে Unicode-এর CLDR প্রজেক্ট, আর বাস্তবায়ন করে ICU library.

Java-র অবস্থান। java.text.Collator JDK 1.1-এ Taligent (পরে IBM)-এর আন্তর্জাতিকীকরণ কাজ থেকে এসেছিল, আর এর নিয়ম JDK-র ভেতরে নির্দিষ্ট কিছু ভাষার জন্য লেখা। এই পর্বে যাচাই করে দেখেছেন, বাংলা তার মধ্যে নেই — bn চাইলে root নিয়মই ফেরত আসে। পূর্ণ UCA আর CLDR-এর tailoring চাইলে ICU4J ব্যবহার করতে হয়।

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

মূল উপলব্ধি: “বর্ণানুক্রম” কোনো সর্বজনীন গাণিতিক সত্য নয়, একটি সাংস্কৃতিক চুক্তি। code unit-এর সংখ্যা দিয়ে সাজানো দ্রুত ও নির্দিষ্ট, কিন্তু মানুষের জন্য সাজাতে হলে ভাষার নিয়ম লাগে — আর সেই নিয়ম কোন library কতটা জানে, তা যাচাই করে নিতে হয়।

প্র ০৪Java object-এর জন্য stable TimSort আর primitive-এর জন্য unstable Dual-Pivot Quicksort — একই library-তে দুই অ্যালগরিদম কেন?

Stability কার কাজে লাগে। Stability-র অর্থ আছে শুধু তখনই, যখন দুটি “সমান” উপাদানকে আলাদা করে চেনা যায়। দুজন ছাত্রের GPA সমান হলেও তারা আলাদা মানুষ — নাম, রোল আলাদা — তাই কে আগে গেল তা চোখে পড়ে, আর আগের ক্রম রক্ষা করা একটি মূল্যবান প্রতিশ্রুতি। কিন্তু একটি int[]-এ দুটি 7-কে আলাদা করার কোনো উপায় নেই; তাদের কোনো পরিচয় নেই, শুধু মান। তাই primitive sort stable হলো কি না, কোনো প্রোগ্রাম কখনো টের পায় না। (double-এ একটু সূক্ষ্মতা আছে: −0.0 আর 0.0, বা NaN — Arrays.sort এদের জন্য Double.compare-এর ক্রম মানে: −0.0 আগে 0.0, NaN সবার শেষে।)

Primitive-এর জন্য গতি। Stability-র দায় না থাকায় primitive sort সবচেয়ে দ্রুত অ্যালগরিদম বেছে নিতে পারে। Java 7-এ আসে Vladimir Yaroslavskiy-র Dual-Pivot Quicksort, যার উন্নয়নে Jon Bentley আর Joshua Bloch যুক্ত ছিলেন। সাধারণ quicksort একটি pivot দিয়ে array দুই ভাগ করে; এটি দুটি pivot দিয়ে তিন ভাগ করে। তুলনার সংখ্যায় বড় পার্থক্য নেই, কিন্তু মেমরিতে কম যাতায়াত করে — আধুনিক CPU-তে cache-ই প্রায়ই আসল বাধা। এটি জায়গায় বসে কাজ করে, বাড়তি মেমরি প্রায় লাগে না; quicksort-এর পুরোনো দুর্বলতা (খারাপ ইনপুটে n²) এড়াতে Java-র বাস্তবায়নে পরে আরও সুরক্ষা যোগ হয়েছে।

Object-এর জন্য নিরাপত্তা। Object sort-এ তুলনা দামি — প্রতিটি তুলনা একটি method call, হয়তো একটি Comparator শিকল, String তুলনা। তাই মেমরিতে যাতায়াতের চেয়ে তুলনার সংখ্যা কমানো বেশি জরুরি, আর TimSort আংশিক সাজানো ডেটায় খুব কম তুলনায় কাজ সারে। সাথে stability-র নিশ্চয়তা, যা ব্যবহারকারীর ইন্টারফেসে (কলাম চেপে sort) আর বহু ধাপের sort-এ অপরিহার্য। দাম হলো বাড়তি মেমরি।

অন্যরা। C++ উল্টো পথ নিয়েছে: std::sort সব টাইপের জন্য unstable (সাধারণত introsort), stable চাইলে আলাদা করে std::stable_sort. Python-এ সবকিছু object, তাই একটিই sort — TimSort, সবসময় stable.

মূল উপলব্ধি: “সেরা sort” বলে কিছু নেই — আছে ডেটার প্রকৃতি অনুযায়ী সঠিক সমঝোতা। পরিচয়হীন মানের জন্য গতি, পরিচয়ওয়ালা object-এর জন্য stability আর কম তুলনা; Java দুটি দরজায় দুটি আলাদা উত্তর রেখেছে।

প্র ০৫Comparator.comparing একটি function নেয় আর function ফেরত দেয়। Java 8-এর এই functional নকশা কোথা থেকে এল, আর এতে আসলে কী লাভ?

Java 8-এর আগে। Comparator interface ছিল Java 1.2 থেকে, কিন্তু এতে শুধু compare আর equals. উল্টো ক্রম চাইলে Collections.reverseOrder(cmp), আর দুই ধাপের নিয়ম চাইলে হাতে লেখা if-else শিকল — প্রতিটি প্রজেক্টে একই কোড নতুন করে, প্রতিবার নতুন bug-এর সুযোগ। Google-এর Guava library-র Ordering class এই শূন্যতা পূরণ করছিল — Ordering.natural().reverse().nullsLast() ধরনের শিকল সেখানে আগে থেকেই ছিল।

Java 8 (২০১৪)। Project Lambda ভাষায় lambda আর method reference আনে, আর দুটি ছোট পরিবর্তন সব বদলে দেয়: interface-এ default আর static method (পর্ব ২১)। এর ফলে Comparator-এ পুরোনো কোড না ভেঙেই যোগ হয় comparing, thenComparing, reversed, nullsFirst, naturalOrder. এখন comparing একটি Function<T, U> নেয় আর একটি Comparator<T> ফেরত দেয় — function থেকে function. Functional programming-এর ভাষায় এটি higher-order function, আর thenComparing দুটি Comparator জুড়ে একটি বানায় — composition. Haskell-এর comparing আর sortOn বা Python-এর key= একই চিন্তার আত্মীয়।

লাভ কোথায়। প্রথমত, ঘোষণামূলক কোড: comparing(Student::gpa).reversed().thenComparing(Student::name) পড়লে নিয়মটাই পড়া হয়, তুলনার কৌশল নয়। দ্বিতীয়ত, সঠিকতা লাইব্রেরিতে: overflow-মুক্ত তুলনা, null-এর ব্যবস্থা একবার লেখা, সবাই ব্যবহার করে। তৃতীয়ত, পুনর্ব্যবহার: static final Comparator<Student> MERIT = ... একবার বানিয়ে sort, TreeSet, PriorityQueue — সব জায়গায়। Comparator immutable, তাই একাধিক thread-এও নিরাপদে ভাগ করা যায়।

দাম। শিকলের প্রতিটি ধাপ একটি ছোট object আর একটি বাড়তি method call; JIT সাধারণত বেশিরভাগ inline করে ফেলে, তবু খুব গরম কোডে হাতে লেখা Comparator একটু দ্রুত হতে পারে। boxing এড়াতে comparingInt/Double আছে। আর এই পর্বে দেখা type inference-এর সীমা — lambda-র পরে সরাসরি .reversed() — functional শৈলীর সাথে Java-র টাইপ ব্যবস্থার একটি বাস্তব ঘর্ষণ।

মূল উপলব্ধি: Comparator.comparing দেখায় কীভাবে একটি পুরোনো object-oriented interface, lambda আর default method পেয়ে, ছোট ছোট function জুড়ে বড় নিয়ম বানানোর ভাষা হয়ে উঠল। আপনি “কীভাবে তুলনা করব” না লিখে “কী দিয়ে সাজাব” লেখেন — আর সেটাই কম bug-এর রহস্য।

অনুশীলন

বেশিরভাগ কাজে নতুন কোড লিখতে হবে না — ওপরের কোড বদলে দেখা আর ভাবাই আসল কাজ।

  1. reversed সরিয়ে দেখুন: অংশ ৪-এর মেধাতালিকার প্রোগ্রামে merit-এর মাঝের লাইন থেকে .reversed() মুছে দিন, যাতে লেখা হয় .thenComparing(Comparator.comparingInt(Student::total)). না চালিয়ে বলুন প্রথম তিনজনের ক্রম কী হবে। তারপর চালিয়ে মিলিয়ে নিন।

    এখন GPA সমান হলে মোট নম্বর কম যার, সে আগে। GPA 5.00-র তিনজন: Anika 1180, Rafi 1180, Sakib 1215. Anika আর Rafi মোট নম্বরে সমান, তাই নাম দিয়ে Anika আগে, তারপর Rafi; Sakib-এর 1215 বেশি, তাই সে তৃতীয়: Anika, Rafi, Sakib. 4.83-র দুজনের মধ্যে Mitu (1105) আগে, Joy (1120) পরে। লক্ষ করুন, কোনো error নেই — একটি reversed-এর অনুপস্থিতি নিঃশব্দে মেধাতালিকার প্রথম স্থান বদলে দিল। এজন্যই এমন নিয়ম লেখার পর সমান মানের ডেটা দিয়ে নিজে পরীক্ষা করা জরুরি।

  2. Leaderboard (একমাত্র কোড লেখার কাজ): record Team(String name, int solved, int penalty)-এর চারটি দল — Padma (7, 812), Meghna (8, 1030), Jamuna (8, 945), Teesta (7, 640). নিয়ম: বেশি solved আগে, সমান হলে কম penalty আগে। Comparator লিখে board.sort করুন। একটি ভুল সংস্করণও লিখুন যেখানে reversed শেষে বসানো, আর বলুন তার ফল কী হবে।
    board.sort(Comparator.comparingInt(Team::solved).reversed()
                         .thenComparingInt(Team::penalty));
    // [Jamuna, Meghna, Teesta, Padma]

    ফলটি OpenJDK 21-এ যাচাই করা: ৮টি সমাধানের দুই দলের মধ্যে Jamuna-র penalty কম (945), তাই সে প্রথম; ৭টির দুই দলে Teesta (640) আগে। ভুল সংস্করণ comparingInt(Team::solved).thenComparingInt(Team::penalty).reversed() পুরো শিকল উল্টায়: solved বেশি আগে ঠিকই, কিন্তু penalty বেশি আগে — [Meghna, Jamuna, Padma, Teesta]. কনটেস্টে এটা মানে বেশি ভুল করা দল পুরস্কার পেল। Comparator boxing-মুক্ত রাখতে comparingInt আর thenComparingInt ব্যবহার করা হয়েছে।

  3. রেটিং-হীন পণ্য: অংশ ৬-এর Product-এর তালিকায় ব্যবহারকারী চান “রেটিং বেশি আগে, রেটিং না থাকলে সবার শেষে, আর রেটিং সমান হলে দাম কম আগে”. comparing(Product::rating) দিয়ে শুরু করলে কী হয়? সম্পূর্ণ Comparator-টি বলুন, আর ব্যাখ্যা করুন nullsLast কেন comparing-এর দ্বিতীয় argument হিসেবে বসে, পুরো Comparator-এর বাইরে নয়।

    সরাসরি comparing(Product::rating) দিলে Kettle-এর null rating-এ পৌঁছেই NullPointerException (যাচাই করা)। সঠিক নিয়ম:

    Comparator.comparing(Product::rating, Comparator.nullsLast(Comparator.reverseOrder()))
              .thenComparingInt(Product::price);

    null রয়েছে Product-এ নয়, তার rating-এ। Comparator.nullsLast(...) পুরো Comparator-এর বাইরে বসালে সেটা দেখত Product object নিজে null কি না — rating-এর null তখনো ভেতরের comparing-এ গিয়ে NPE দিত। তাই key-এর জন্য আলাদা Comparator দেওয়া হয় comparing(keyExtractor, keyComparator)-এর দ্বিতীয় argument-এ: সেখানে nullsLast null rating শেষে পাঠায়, আর বাকিদের reverseOrder দিয়ে বড় থেকে ছোট সাজায়। শেষে thenComparingInt সমান রেটিং-এ দাম দেখে।

  4. নম্বর হারানো TreeMap: একটি বৃত্তির অফিস লিখল Map<Student, Integer> scholarship = new TreeMap<>(Comparator.comparingDouble(Student::gpa).reversed()); আর প্রতিটি ছাত্রের বৃত্তির টাকা put করল। অংশ ৮-এর চারজন (Rafi, Mitu, Anika, Joy) ঢোকানো হলো এই ক্রমে, প্রত্যেকের টাকা আলাদা। শেষে map-এর size কত, কাদের নাম key হিসেবে আছে, আর কোন টাকাগুলো আছে? কীভাবে ঠিক করবেন?

    Size ২। TreeMap key মেলায় compare দিয়ে: Anika-র put-এ compare(Anika, Rafi) শূন্য, তাই TreeMap ধরে নেয় key আগেই আছে — key Rafi-ই থেকে যায়, কিন্তু মান বদলে Anika-র টাকা বসে। একইভাবে Joy-এর put Mitu-র key-তে Joy-এর টাকা বসায়। ফল: key হিসেবে Rafi আর Mitu, কিন্তু মান Anika আর Joy-এর — নাম আর টাকার জোড়া ভুল, দুজনের টাকা হারিয়ে গেছে, কোনো error ছাড়াই। (TreeMap-এর Javadoc অনুযায়ী put পুরোনো key রেখে শুধু মান বদলায়।) সমাধান: Comparator-এ অনন্য tie-breaker দিন — .thenComparing(Student::name), নাম একই হতে পারলে শেষে roll বা id. অথবা ক্রম না লাগলে HashMap নিন, যা record-এর equals/hashCode ব্যবহার করে; ক্রমে দেখানোর দরকার হলে দেখানোর সময় আলাদা করে sort করুন।

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

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

ক্রম“GPA বেশি আগে, তারপর মোট নম্বর বেশি আগে, তারপর নাম A–Z” — এই মেধাতালিকা বানানোর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 📏 Comparator.comparingDouble(Student::gpa) দিয়ে প্রথম নিয়ম বানানো
  2. 🔃 সাথে সাথে .reversed() — শুধু GPA-কে বড় থেকে ছোট করা
  3. ➕ .thenComparing(comparingInt(Student::total).reversed()) — GPA সমান হলে মোট নম্বর
  4. 🔤 .thenComparing(Student::name) — শেষ tie-breaker
  5. ✅ list.sort(merit) — stable TimSort চালানো

Java-তে Comparator.comparing(Student::gpa).thenComparing(Student::name).reversed() দিয়ে একটি List সাজালে ফল কী হয়?

  • GPA বড় থেকে ছোট, সমান হলে নাম A থেকে Z
  • GPA বড় থেকে ছোট, সমান হলে নাম Z থেকে A
  • GPA ছোট থেকে বড়, সমান হলে নাম Z থেকে A
  • compile error হয়

new TreeSet<>(Comparator.comparingDouble(Student::gpa)) তৈরি করে একই GPA কিন্তু আলাদা নামের দুজন ছাত্র add করলে কী হয়?

  • দ্বিতীয় add false দেয়, set-এ একজনই থাকে
  • দুজনই থাকে, কারণ equals false
  • IllegalArgumentException আসে
  • প্রথম ছাত্র মুছে দ্বিতীয়জন বসে

Java-র Collections.sort বা List.sort-এর stability সম্পর্কে কোনটি সঠিক?

  • সমান উপাদানের ক্রম প্রতিবার এলোমেলো হতে পারে
  • তুলনায় সমান উপাদানগুলোর একটি বাদ পড়ে
  • তুলনায় সমান উপাদানগুলো sort-এর আগের আপেক্ষিক ক্রমেই থাকে
  • শুধু Comparable উপাদানের ক্ষেত্রে stable, Comparator দিলে নয়

int[] a = {5, 2, 9}; তারপর Arrays.sort(a, Comparator.reverseOrder()); লিখলে কী হয়?

  • a বড় থেকে ছোট সাজানো হয়: [9, 5, 2]
  • Java নিজে int[]-কে Integer[] বানিয়ে সাজায়
  • চলার সময় ClassCastException
  • compile error: no suitable method found

Java-তে "banana", "Cherry", "apple" — এই তিনটি String সাধারণ natural ordering (String.compareTo) দিয়ে সাজালে ফল কী?

  • [apple, banana, Cherry]
  • [Cherry, apple, banana]
  • [apple, Cherry, banana]
  • ClassCastException আসে

int ধরনের field দিয়ে সাজাতে Java-তে Comparator.comparing(Student::roll)-এর বদলে Comparator.comparingInt(Student::roll) লেখার মূল সুবিধা কী?

  • comparing ব্যবহার করলে overflow-এ ক্রম ভুল হয়
  • শুধু comparingInt stable sort দেয়
  • প্রতিটি তুলনায় int-কে Integer-এ box করতে হয় না
  • comparing int field-এ compile হয় না

Java-তে sort করার সময় "Comparison method violates its general contract!" বার্তাসহ কোন exception আসে, আর সাধারণত কেন?

  • IllegalArgumentException — Comparator তার চুক্তি ভেঙেছে
  • NullPointerException — তালিকায় null আছে
  • ConcurrentModificationException — sort-এর সময় list বদলেছে
  • ClassCastException — উপাদান Comparable নয়

শব্দকোষ

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

মনে রাখুন

  1. Comparable = class-এর ভেতরের একটি স্বাভাবিক ক্রম (compareTo); Comparator = বাইরের, যত খুশি ক্রম (compare). দুটোরই চুক্তি এক: শুধু চিহ্ন, উল্টালে চিহ্ন উল্টায়, transitive, আর কখনো বিয়োগ নয়।
  2. লিখুন “কী দিয়ে”, “কীভাবে” নয়: comparing/comparingInt/comparingDouble, তারপর thenComparing. Primitive key-তে boxing-মুক্ত সংস্করণ নিন।
  3. reversed() তার আগের পুরো শিকল উল্টায় — যে নিয়মের জন্য, ঠিক তার পরে বসান, অথবা thenComparing(key, reverseOrder()). null-এর জন্য nullsFirst/nullsLast.
  4. Object sort (TimSort) stable, O(n log n); primitive array-এর sort Dual-Pivot Quicksort আর Comparator নেয় না। তবু চূড়ান্ত ক্রম যদি নিয়মের অংশ হয়, তা Comparator-এ লিখুন আর একটি অনন্য tie-breaker রাখুন।
  5. TreeSet/TreeMap সমতা ঠিক করে compare দিয়ে — compare শূন্য মানে “একই”, তাই অসম্পূর্ণ Comparator উপাদান হারায়। বাংলা লেখা normalize করুন; JDK-র Collator-এ বাংলার আলাদা নিয়ম নেই।

পরের পর্বে: এই পর্বে Student::gpa আর (a, b) -> ... বারবার এসেছে, ব্যাখ্যা পরে দেওয়ার কথা বলে। পর্ব ৩৪-এ সেই গল্প: lambda আসলে কী, functional interface কেন একটিমাত্র method-এর, method reference-এর চার ধরন, আর Function, Predicate, Supplier-এর মতো তৈরি interface দিয়ে আচরণকে মানের মতো হাতে হাতে পাঠানো।

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

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