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

Set আর Map — HashMap, TreeMap আর দ্রুত খোঁজার কৌশল

Java Sets and Maps — HashSet, TreeSet, HashMap, LinkedHashMap, TreeMap and Fast Lookups

Taught in Bangla, this lesson explains Java sets and maps: HashSet, LinkedHashSet and TreeSet with set operations, Map basics and null rules, modern methods like merge and computeIfAbsent, HashMap internals with buckets, load factor, resizing and treeification, TreeMap and LinkedHashMap ordering, Map.of rules, and a decision table for picking the right one.

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

  • Set আর Map কোন প্রশ্নের উত্তর দেয়, আর List-এর তুলনায় এরা কেন এত দ্রুত
  • HashSet, LinkedHashSet, TreeSet-এর পার্থক্য, আর union, intersection, difference
  • Map-এর মৌলিক কাজ, null-এর নিয়ম, নিরাপদে ঘোরানো, আর merge / computeIfAbsent / compute
  • HashMap-এর ভেতরের কাজ — bucket, load factor, resize, treeification — আর key কেন immutable হতে হয়
  • TreeMap দিয়ে নম্বর থেকে গ্রেড, LinkedHashMap দিয়ে LRU cache, Map.of-এর নিয়ম, আর কোন কাজে কোনটি

১ · দুটি প্রশ্ন — “আছে কি?” আর “এর মান কী?”

পর্ব ৩০-এ List দেখেছেন: উপাদানগুলো সারিতে থাকে, প্রতিটির একটি index. কিন্তু বাস্তব প্রোগ্রামের অনেক প্রশ্নের সাথে index-এর কোনো সম্পর্ক নেই। “এই মোবাইল নম্বর থেকে আজ কি আগে কেউ লগইন করেছে?” “রোল ১৪২-এর ছাত্রের ফল কী?” “এই বাক্যে ‘ঢাকা’ শব্দটি কতবার এসেছে?” এমন প্রশ্নে List দিয়ে উত্তর দিতে হলে প্রতিটি উপাদান একে একে মেলাতে হয়। দশ লাখ উপাদানে, সবচেয়ে খারাপ ক্ষেত্রে, দশ লাখবার।

Java-র Collections Framework-এ এই দুই ধরনের প্রশ্নের জন্য দুটি আলাদা interface আছে:

Set — “এটা কি আছে?”

একই জিনিস দুবার থাকে না। কাজ তিনটি: যোগ করা, সরানো, আর আছে কি না জানা। Set-এ কোনো index নেই।

অনন্য ভিজিটর, ট্যাগ, ব্লক-লিস্ট

Map — “এর মান কী?”

প্রতিটি key-এর সাথে একটি value. একই key দুবার থাকে না, কিন্তু value একই হতে পারে। Map আসলে একটি অভিধান।

ফোনবুক, রোল → ফল, শব্দ → সংখ্যা

বিয়েবাড়ির গেটে দুজন লোক আছেন। প্রথমজনের হাতে দাওয়াতের তালিকা। কেউ এলে তিনি শুধু দেখেন “নাম আছে কি?” — হ্যাঁ বা না। এটা Set. দ্বিতীয়জন বসার ব্যবস্থা দেখেন। তাঁর খাতায় লেখা “করিম সাহেব → টেবিল ৭, রিনা আপা → টেবিল ৩”। নাম বললেই তিনি টেবিল নম্বর বলে দেন। এটা Map. দুজনের কেউই পুরো খাতা প্রথম পাতা থেকে পড়েন না — নামের আদ্যক্ষর দেখে সরাসরি সঠিক পাতায় চলে যান। Java-র Set আর Map-ও ঠিক এভাবে কাজ করে: hashing দিয়ে সরাসরি সঠিক ঘরে, অথবা সাজানো tree দিয়ে কয়েক ধাপে।

পার্থক্যটা কতটা বড়, একটি বাস্তব কাজে দেখুন। একটি অনলাইন দোকানের দুটি তালিকা: গত মাসের ১ লাখ ক্রেতার ফোন নম্বর, আর এই মাসের ১ লাখ। “কারা দুই মাসেই কিনেছেন?” — nested loop দিয়ে লিখলে প্রতিটি নম্বরকে অন্য তালিকার প্রতিটির সাথে মেলাতে হয়: ১ লাখ × ১ লাখ = ১,০০০ কোটি তুলনা। একটি তালিকাকে HashSet-এ রাখলে কাজ হয় প্রায় ২ লাখ ধাপ — প্রথম তালিকা set-এ ঢোকাতে ১ লাখ, দ্বিতীয় তালিকার প্রতিটির জন্য একটি contains. যে কাজে কয়েক মিনিট লাগত, তা নেমে আসে এক সেকেন্ডের নিচে। এই পর্বের মূল বার্তা এটাই: সঠিক কাঠামো বাছাই অনেক সময় সবচেয়ে বড় optimization.

ভেতরের কাঠামোSetMapক্রম
Hash tableHashSetHashMapকোনো নিশ্চয়তা নেই
Hash table + linked listLinkedHashSetLinkedHashMapঢোকানোর ক্রম (চাইলে ব্যবহারের ক্রম)
Red-black treeTreeSetTreeMapসবসময় সাজানো
enum-এর ordinal দিয়ে array/bitEnumSetEnumMapenum ঘোষণার ক্রম

সব সময় variable-এর টাইপ লিখুন interface দিয়ে — Set<String> s = new HashSet<>();, Map<Integer, String> m = new TreeMap<>();. পরে implementation বদলাতে হলে শুধু ডান পাশটি বদলান (পর্ব ২১-এর “program to an interface”). আর মনে রাখুন, collection-এ primitive রাখা যায় না: Map<int, String> লিখলে javac থামিয়ে দেয় —

Main.java:4: error: unexpected type
        Map<int, String> m = new HashMap<>();
            ^
  required: reference
  found:    int
1 error

লিখতে হবে Map<Integer, String>. তারপর m.put(142, "A+") লিখলে 142 নিজে থেকেই Integer হয়ে যায় — পর্ব ০৬-এর autoboxing.

২ · Set — HashSet, LinkedHashSet আর TreeSet

তিনটি Set-এর কাজ এক — ডুপ্লিকেট বাদ দেওয়া — কিন্তু উপাদানগুলো কোন ক্রমে দেখাবে, আর কত দ্রুত, সেখানে পার্থক্য। একই ইনপুট 5, 1, 9, 1, 3 তিনটিতে দিলে:

Setছাপলেadd / containsকখন নেবেন
HashSetঅনির্দিষ্ট ক্রম — নির্ভর করবেন নাগড়ে O(1)শুধু “আছে কি?” জানতে — সবচেয়ে প্রচলিত
LinkedHashSet[5, 1, 9, 3]গড়ে O(1)ডুপ্লিকেট বাদ, কিন্তু প্রথম আসার ক্রম রাখতে
TreeSet[1, 3, 5, 9]O(log n)সাজানো ফল, বা সবচেয়ে ছোট/বড়/কাছাকাছি খুঁজতে

এখানে O(1) আর O(log n) হলো Big-O notation: ডেটা বাড়লে কাজ কীভাবে বাড়ে। O(1) মানে উপাদান দশটি হোক বা দশ লাখ, খরচ মোটামুটি একই। O(log n) মানে দশ লাখ উপাদানেও মাত্র ২০টির মতো ধাপ (২-এর ২০ ঘাত প্রায় দশ লাখ)। দুটিই List-এর contains-এর O(n)-এর চেয়ে অনেক অনেক ভালো।

add একটি boolean ফেরত দেয়: নতুন হলে true, আগে থেকে থাকলে false — কোনো exception নয়। তাই “প্রথমবার দেখলে কিছু করো” লেখা যায় এক লাইনে: if (seen.add(phone)) sendWelcomeSms(phone);. HashSet ভেতরে আসলে একটি HashMap, যার key-গুলোই set-এর উপাদান আর value একটি খালি স্থানধারী object. তাই পর্ব ২৩-এ দেখা সব নিয়ম — hashCode আর equals একসাথে, একই field দিয়ে — Set-এর উপাদানের জন্যও খাটে।

TreeSet — সাজানো, আর “কাছাকাছি” খোঁজা

TreeSet শুধু সাজিয়ে রাখে না, সে একটি NavigableSet: সাজানো ক্রম ব্যবহার করে এমন প্রশ্নের উত্তর দেয়, যা HashSet পারে না। ধরুন একটি বাস কাউন্টারে আজকের ছাড়ার সময় (মিনিটে) রাখা আছে {10, 25, 40, 55, 70}:

Methodপ্রশ্নফল
first() / last()সবচেয়ে আগের / শেষের10 / 70
floor(50)৫০ বা তার আগের সবচেয়ে কাছেরটি40
ceiling(50)৫০ বা তার পরের সবচেয়ে কাছেরটি — “পরের বাস কখন?”55
higher(40)৪০-এর পরেরটি (৪০ নিজে নয়)55
floor(5)৫-এর আগে কিছু নেইnull
headSet(40) / tailSet(40)৪০-এর আগের সব / ৪০ থেকে পরের সব[10, 25] / [40, 55, 70]

সব মান OpenJDK 21-এ চালিয়ে যাচাই করা। খেয়াল করুন: headSet শেষের মানটি বাদ দেয়, tailSet শুরুর মানটি রাখে। আর কিছু না পেলে floor/ceiling null দেয় — সেটা int-এ রাখতে গেলে NullPointerException. TreeSet উপাদান তুলনা করে Comparable-এর compareTo দিয়ে (পর্ব ২৩), তাই যে class Comparable নয়, তার object TreeSet-এ ঢোকাতে গেলে ClassCastException আসে — অথবা আপনি একটি Comparator দিন (পর্ব ৩৩)।

Set-গণিত — union, intersection, difference

স্কুলের ভেন চিত্র মনে আছে? Java-তে তিনটি set operation-ই তৈরি আছে, কিন্তু একটি সতর্কতা: এই method-গুলো যে set-এর ওপর ডাকেন, সেটাকেই বদলে দেয়। তাই আগে একটি কপি বানান:

Set<String> rina  = new TreeSet<>(List.of("C", "Java", "Python"));
Set<String> karim = new TreeSet<>(List.of("Java", "Kotlin", "Python", "Go"));

Set<String> union = new TreeSet<>(rina);  union.addAll(karim);     // দুজনের যেকোনো একজন জানে
Set<String> both  = new TreeSet<>(rina);  both.retainAll(karim);    // দুজনেই জানে
Set<String> only  = new TreeSet<>(rina);  only.removeAll(karim);    // শুধু রিনা জানে
// union = [C, Go, Java, Kotlin, Python]   both = [Java, Python]   only = [C]

এখানে TreeSet নেওয়া হয়েছে যাতে ছাপা ফল প্রতিবার একই ক্রমে আসে। HashSet হলে ফলের উপাদান একই থাকত, কিন্তু ক্রম অনির্দিষ্ট। rina.containsAll(karim) জানায় karim কি rina-র subset. আর দুটি set-এর equals উপাদান মেলায়, ক্রম নয় — একটি HashSet আর একটি TreeSet-এ একই উপাদান থাকলে equals true.

৩ · Map-এর মৌলিক কাজ — put, get আর null-এর নিয়ম

একটি ফোনবুক দিয়ে শুরু করা যাক: নাম → নম্বর। Map-এর প্রতিটি জোড়াকে বলে একটি entry, আর Java-তে তার টাইপ Map.Entry.

Map<String, String> phone = new HashMap<>();
phone.put("Rina", "01711-000001");             // নতুন key → null ফেরত
phone.put("Karim", "01811-000002");
String old = phone.put("Rina", "01911-000003"); // key আছে → মান বদলায়, পুরোনোটি ফেরত
phone.get("Rina");                    // "01911-000003"
phone.get("Nabil");                   // null — key নেই
phone.getOrDefault("Nabil", "নেই");   // "নেই"
phone.containsKey("Karim");           // true
phone.remove("Karim");                // মুছে দেয়, মুছে যাওয়া মান ফেরত দেয়
phone.size();                         // 1

লক্ষ করুন, put একই key-তে দ্বিতীয়বার দিলে নতুন entry তৈরি হয় না — পুরোনো মানটি বদলে যায়, আর পুরোনোটি ফেরত আসে। Map-এ একটি key একবারই থাকে, ঠিক Set-এর মতো। আসলে Map-এর key-গুলোর দল নিজেই একটি Set (keySet())।

null — কোথায় চলে, কোথায় চলে না

Mapnull keynull value
HashMap, LinkedHashMapএকটি চলেচলে
TreeMap (স্বাভাবিক ক্রমে)NullPointerException — null-এর সাথে compareTo সম্ভব নয়চলে
Map.of, Map.copyOfNullPointerExceptionNullPointerException
Hashtable (পুরোনো), ConcurrentHashMapNullPointerExceptionNullPointerException

HashMap null মান রাখতে দেয় বলে একটি সূক্ষ্ম অস্পষ্টতা তৈরি হয়: get(k) null দিলে দুটি মানে হতে পারে — key নেই, অথবা key আছে আর তার মানই null. আরও সূক্ষ্ম: key আছে কিন্তু মান null হলে getOrDefault(k, 9)-ও null দেয়, 9 নয় — কারণ default দেওয়া হয় শুধু key না থাকলে (OpenJDK 21-এ যাচাই করা)। এই কারণে অভিজ্ঞ প্রোগ্রামাররা Map-এ null মান রাখেন না। “মান নেই” বোঝাতে key-টাই রাখবেন না।

Map ঘোরানো — entrySet, keySet, values

Map-এর তিনটি “জানালা” আছে: keySet() — সব key, values() — সব মান, আর entrySet() — সব জোড়া। key আর মান দুটিই দরকার হলে entrySet ঘোরান। keySet ঘুরিয়ে প্রতিবার get(k) ডাকলে প্রতিটি key-এর জন্য বাড়তি একটি খোঁজ হয়।

Map<Integer, String> result = new TreeMap<>(Map.of(142, "A+", 107, "B", 125, "A"));
for (var e : result.entrySet()) {
    System.out.println("রোল " + e.getKey() + " → " + e.getValue());
}
// রোল 107 → B
// রোল 125 → A
// রোল 142 → A+

HashMap বা HashSet ঘুরিয়ে কোনো নির্দিষ্ট ক্রম আশা করবেন না। Javadoc স্পষ্ট বলে: এর ক্রমের কোনো নিশ্চয়তা নেই, আর সময়ের সাথে ক্রম একই থাকবে, এমন নিশ্চয়তাও নেই — নতুন উপাদান ঢুকে table বড় হলে ক্রম বদলে যেতে পারে। ফাঁদটা হলো, ছোট Integer key-তে HashMap প্রায়ই সাজানো দেখায় (কারণ Integer-এর hashCode সংখ্যাটি নিজেই), তাই পরীক্ষায় সব ঠিক মনে হয়। String key দিলেই ক্রম এলোমেলো দেখায়। ছাপার বা রিপোর্টের ক্রম দরকার হলে TreeMap (সাজানো) বা LinkedHashMap (ঢোকানোর ক্রম) নিন, অথবা key-গুলো sort করে নিন। এই পর্বের প্রতিটি উদাহরণ এজন্যই TreeMap বা LinkedHashMap দিয়ে ছাপে।

ঘোরানোর সময় Map থেকে কিছু মুছবেন না। for-each লুপের ভেতরে map.remove(k) ডাকলে সাধারণত পরের ধাপে ConcurrentModificationException আসে — পর্ব ১১-এ List-এ যা দেখেছেন। শর্ত মেনে মুছতে লিখুন map.entrySet().removeIf(e -> e.getValue() < 33); — নিরাপদ, আর এক লাইনে।

৪ · আধুনিক Map method — merge, computeIfAbsent, putIfAbsent, compute

একটি বাক্যের প্রতিটি শব্দ কতবার এসেছে, গুনতে হবে। Java 8-এর আগে সবাই এভাবে লিখত — আর আজও AI প্রায়ই এভাবেই লেখে:

if (count.containsKey(w)) {
    count.put(w, count.get(w) + 1);   // তিনবার খোঁজা: containsKey, get, put
} else {
    count.put(w, 1);
}

কাজ করে, কিন্তু একই key তিনবার খোঁজা হয়, লেখা লম্বা, আর ভুলের জায়গা বেশি। Java 8 Map interface-এ কয়েকটি default method যোগ করেছে, যারা “খোঁজো, তারপর সিদ্ধান্ত নাও” কাজটি এক ডাকে সারে:

Methodkey না থাকলেkey থাকলেসাধারণ ব্যবহার
putIfAbsent(k, v)v বসায়, null ফেরতকিছু বদলায় না, পুরোনো মান ফেরতপ্রথম মানটিই রাখা
computeIfAbsent(k, f)f(k) চালিয়ে মান বানায়, বসায়f চালায়ই না, আগের মান ফেরতMap of List — দলে ভাগ করা
merge(k, v, f)v বসায়f(পুরোনো, v) বসায়গোনা, যোগফল
compute(k, f)f(k, null)f(k, পুরোনো)যেকোনো হালনাগাদ

একটি বাড়তি নিয়ম: merge বা compute-এর function যদি null ফেরত দেয়, key-টি Map থেকে মুছে যায় (চালিয়ে যাচাই করা)। এভাবে “গোনা শূন্যে নামলে key সরাও” লেখা যায়। শব্দ গোনার কাজটি merge দিয়ে কীভাবে চলে, ধাপে ধাপে দেখুন — শব্দগুলো “ট্রেন বাস ট্রেন লঞ্চ ট্রেন”:

1Map<String, Integer> c = new TreeMap<>();2for (String w : words)3    c.merge(w, 1, Integer::sum);4System.out.println(c);
এই ধাপে
w
আগের মান
merge যা করল
  1. প্রথম শব্দ, key নেই। merge দেখে “ট্রেন” Map-এ নেই, তাই function না ডেকেই দেওয়া মান 1 বসিয়ে দেয়। containsKey-এর আলাদা কোনো লাইন লাগেনি।
  2. নতুন শব্দ, আবার 1. “বাস”-ও নতুন, তাই একই ঘটনা। TreeMap বলে key-গুলো সবসময় বাংলা বর্ণের Unicode ক্রমে সাজানো থাকে।
  3. পুরোনো শব্দ — এবার function চলে। “ট্রেন” আছে, মান 1. merge এবার Integer::sum ডাকে পুরোনো মান আর নতুন মান দিয়ে: sum(1, 1) = 2. পুরো কাজ একটিমাত্র খোঁজে।
  4. আরেকটি নতুন শব্দ। “লঞ্চ” ঢুকল 1 নিয়ে। Integer::sum হলো পর্ব ৩৪-এর method reference — আপাতত পড়ুন “দুটি সংখ্যা যোগ করো”।
  5. তৃতীয়বার “ট্রেন”। sum(2, 1) = 3. ইচ্ছা করলে function বদলে অন্য কাজও করা যায় — যেমন Integer::max দিলে প্রতিটি key-এর সবচেয়ে বড় মান থাকবে।
  6. ছাপা — সাজানো, প্রতিবার একই। TreeMap হওয়ায় ফল সবসময় key-এর ক্রমে। HashMap নিলে সংখ্যাগুলো একই থাকত, কিন্তু ক্রম অনির্দিষ্ট — রিপোর্ট বা টেস্টের জন্য ভালো নয়।

এবার একটি পূর্ণ প্রোগ্রাম: একটি বাংলা বাক্যের শব্দ গোনা (merge), তারপর একই সংখ্যার শব্দগুলোকে দলে ভাগ করা (computeIfAbsent দিয়ে একটি Map of List), আর একটি ওয়েবসাইটের লগ থেকে অনন্য ভিজিটর বের করা (Set):

import java.util.*;

class Main {
    public static void main(String[] args) {
        String text = "ঢাকা থেকে চট্টগ্রাম ট্রেন ঢাকা থেকে সিলেট বাস ঢাকা থেকে খুলনা ট্রেন";
        Map<String, Integer> count = new TreeMap<>();          // key সাজানো থাকে
        for (String w : text.split(" ")) {
            count.merge(w, 1, Integer::sum);                   // নেই → 1, আছে → পুরোনো + 1
        }
        System.out.println(count);

        Map<Integer, List<String>> byCount = new TreeMap<>();  // কতবার → কোন শব্দগুলো
        for (Map.Entry<String, Integer> e : count.entrySet()) {
            byCount.computeIfAbsent(e.getValue(), k -> new ArrayList<>()).add(e.getKey());
        }
        System.out.println(byCount);

        List<String> log = List.of("rina", "karim", "rina", "sumi", "karim", "rina");
        Set<String> visitors = new LinkedHashSet<>(log);       // ডুপ্লিকেট বাদ, প্রথম আসার ক্রম
        System.out.println(visitors.size() + " visitors: " + visitors);
        System.out.println(visitors.contains("sumi") + " " + visitors.contains("nabil"));
    }
}

আউটপুট চার লাইন: {খুলনা=1, চট্টগ্রাম=1, ট্রেন=2, ঢাকা=3, থেকে=3, বাস=1, সিলেট=1}, তারপর {1=[খুলনা, চট্টগ্রাম, বাস, সিলেট], 2=[ট্রেন], 3=[ঢাকা, থেকে]}, তারপর 3 visitors: [rina, karim, sumi] আর true false. প্রথম লাইনে প্রতিটি শব্দ একবার করে, সাথে গোনা — TreeMap বাংলা শব্দগুলোকে Unicode ক্রমে সাজিয়েছে (ক, চ, ট, ঢ, থ, ব, স)। দ্বিতীয় অংশে computeIfAbsent প্রতিটি সংখ্যার জন্য প্রথমবার একটি নতুন ArrayList বানায়, পরের বার সেই list-টিই ফেরত দেয় — আর আমরা সরাসরি তাতে .add করি। “list আছে কি? না থাকলে বানাও, put করো, তারপর add করো” — চার ধাপ এক লাইনে। একটি সাধারণ ভুল হলো putIfAbsent(k, new ArrayList<>()) লেখা: সেটা প্রতিবার একটি নতুন list বানিয়ে ফেলে দেয়, আর ফেরত দেয় key নতুন হলে null — তাতে .add ডাকলে NullPointerException. শেষ অংশে LinkedHashSet ছয়টি লগ-এন্ট্রি থেকে তিনজন অনন্য ভিজিটর রেখেছে, প্রথম আসার ক্রমে। প্রোগ্রামটি কোথাও HashMap বা HashSet সরাসরি ছাপে না, তাই প্রতিবার হুবহু একই ফল।

এই “গোনা” আর “দলে ভাগ করা” দুটি প্যাটার্ন এত সাধারণ যে পর্ব ৩৫-এ Stream API এগুলোর জন্য তৈরি collector দেবে — Collectors.groupingBy আর Collectors.counting. ভেতরে তারা মূলত এই একই ধরনের কাজ করে (groupingBy নিজেও computeIfAbsent ব্যবহার করে)। লুপ দিয়ে একবার নিজে বুঝে নিলে Stream-এর কোড পড়া সহজ হবে।

৫ · HashMap-এর ভেতরে — bucket, load factor, resize আর treeification

পর্ব ২৩-এ দেখেছেন, HashSet/HashMap object-এর hashCode দিয়ে একটি bucket বাছে, আর সেই bucket-এর ভেতরে equals দিয়ে মিল নিশ্চিত করে। এবার আরও এক ধাপ ভেতরে যাই: টেবিলটি দেখতে কেমন, ভরে গেলে কী হয়, আর খারাপ ক্ষেত্রে HashMap নিজেকে কীভাবে রক্ষা করে।

  • টেবিল একটি array. ডিফল্টে প্রথম put-এর সময় ১৬টি ঘরের array তৈরি হয়। ঘরের সংখ্যা সবসময় ২-এর ঘাত (১৬, ৩২, ৬৪…)।
  • ঘর বাছাই। HashMap key-এর hashCode নেয়, উঁচু বিটগুলো নিচের বিটের সাথে মিশিয়ে দেয় (h ^ (h >>> 16)), তারপর ঘর = hash & (n - 1). n যেহেতু ২-এর ঘাত, এটা ভাগশেষ নেওয়ার একটি দ্রুত উপায় — শুধু নিচের কয়েকটি বিট রাখা।
  • Collision হলে তালিকা। একই ঘরে একাধিক key পড়লে তারা একটি ছোট linked list হয়ে থাকে।
  • Load factor ০.৭৫. উপাদানের সংখ্যা যখন ঘরের সংখ্যা × ০.৭৫ ছাড়ায় (১৬ ঘরে ১২টির বেশি), HashMap নতুন একটি দ্বিগুণ আকারের array বানায় আর সব entry নতুন ঘরে সরায় — একে বলে resize বা rehash.

ঘটনাগুলো ধাপে ধাপে দেখুন। বোঝার সুবিধার জন্য টেবিলটি ছোট — new HashMap<>(4) লিখলে আসলেই ৪টি ঘর হয়, আর সীমা ৪ × ০.৭৫ = ৩। hash-এর সংখ্যাগুলো বানানো, আসল মান নয়:

ধাপ ১put("rina", 80)
hash("rina") = 13     (ধরা যাক)
ঘর = 13 & (4 - 1) = 13 & 3 = 1

ঘর 0 │ ·
ঘর 1 │ rina=80
ঘর 2 │ ·
ঘর 3 │ ·          size = 1, সীমা = 3
ধাপ ২put("karim", 65)
hash("karim") = 6  →  6 & 3 = 2

ঘর 0 │ ·
ঘর 1 │ rina=80
ঘর 2 │ karim=65
ঘর 3 │ ·          size = 2
ধাপ ৩put("sumi", 72) — collision
hash("sumi") = 9  →  9 & 3 = 1   ← rina-র ঘর!

ঘর 1-এ: rina — hash 13 ≠ 9, তাই আলাদা key
        তালিকার শেষে জুড়ে দাও

ঘর 1 │ rina=80 → sumi=72
ঘর 2 │ karim=65    size = 3
ধাপ ৪put("rina", 85) — একই key
hash = 13 → ঘর 1
rina: hash মেলে, equals → true
→ শুধু মান বদলাও: 80 → 85, put ফেরত দেয় 80

ঘর 1 │ rina=85 → sumi=72
size বাড়েনি — নতুন entry নয়
ধাপ ৫put("nabil", 90) — সীমা পার
hash = 20 → 20 & 3 = 0 → ঘর 0-এ বসল
size = 4  >  সীমা 3   → resize!

নতুন array: ৮টি ঘর, নতুন সীমা 6
প্রতিটি entry নতুন ঘর পাবে: hash & 7
ধাপ ৬rehash-এর পর
sumi  9 & 7 = 1     ঘর 1 │ sumi=72
nabil 20 & 7 = 4    ঘর 4 │ nabil=90
rina  13 & 7 = 5    ঘর 5 │ rina=85
karim 6 & 7 = 6     ঘর 6 │ karim=65

rina আর sumi এখন আলাদা ঘরে — collision নেই।
ক্রমও বদলে গেছে: HashMap-এর ক্রমে ভরসা নয়!
  1. hash থেকে ঘর। key-এর hash-এর নিচের দুটি বিট (কারণ ঘর ৪টি) ঘরের নম্বর দেয়। 13-এর বাইনারি 1101, নিচের দুই বিট 01 — ঘর 1. খোঁজার সময়ও ঠিক একই হিসাব, তাই পুরো টেবিল ঘাঁটতে হয় না।
  2. আলাদা ঘর। karim গেল ঘর 2-এ। যতক্ষণ key-রা আলাদা ঘরে ছড়িয়ে থাকে, প্রতিটি put/get এক ধাপের কাজ — এটাই গড়ে O(1)-এর রহস্য।
  3. Collision — স্বাভাবিক ঘটনা। sumi-র hash আলাদা, কিন্তু নিচের দুই বিট মিলে গেছে। HashMap আগে ঘরের প্রতিটি entry-র সংরক্ষিত hash মেলায়; মেলেনি, তাই sumi নতুন key হিসেবে তালিকার শেষে বসে।
  4. একই key — মান বদলায়, size নয়। hash মিলেছে, তারপর equals true — তাই এটা নতুন entry নয়। এখানেই পর্ব ২৩-এর চুক্তি কাজে লাগে: equals আর hashCode না মিললে HashMap ভাবত rina নতুন কেউ, আর একই key দুবার রাখত।
  5. সীমা পার — resize. ৪টি ঘরে ৪টি entry: তালিকাগুলো লম্বা হওয়ার আগেই HashMap array দ্বিগুণ করে। এই কাজটি O(n) — সব entry সরাতে হয়। কিন্তু প্রতিবার দ্বিগুণ হয় বলে এটা কদাচিৎ ঘটে, আর গড়ে প্রতিটি put-এর খরচ O(1)-ই থাকে (amortized, পর্ব ৩০-এর ArrayList-এর মতো)।
  6. নতুন ঘর, নতুন ক্রম। এখন hash-এর নিচের তিন বিট দেখা হয়। সংঘর্ষ মিটে গেছে, কিন্তু entry-গুলোর ক্রমও বদলে গেছে। কত উপাদান আসবে আগে থেকে জানলে বারবার resize এড়াতে Java 19 থেকে লিখতে পারেন HashMap.newHashMap(1000) — সে load factor হিসাব করে যথেষ্ট বড় টেবিল বানায়।

Treeification — খারাপ দিনের বিমা

ভালো hashCode থাকলে প্রতিটি ঘরে গড়ে ১টিরও কম entry থাকে। কিন্তু hashCode খারাপ হলে (সব key একই hash দেয়), বা কেউ ইচ্ছা করে একই hash-এর হাজারো key পাঠায় (পর্ব ২৩-এর hash flooding), একটি ঘরের তালিকা লম্বা হয়ে যায় আর খোঁজা হয়ে যায় O(n). Java 8 থেকে HashMap এর একটি প্রতিকার রেখেছে, যাকে বলে treeification: কোনো ঘরে ৮টির বেশি entry হলে, আর টেবিলে অন্তত ৬৪টি ঘর থাকলে, সেই ঘরের তালিকাটি একটি red-black tree-তে বদলে যায়। তখন key-গুলো Comparable হলে (যেমন String) সবচেয়ে খারাপ খোঁজাও O(log n) — বিস্তারিত ভাবনার প্রশ্ন ৩-এ। (টেবিল ৬৪-এর ছোট হলে HashMap tree না বানিয়ে আগে resize করে।) সাধারণ প্রোগ্রামে এটা প্রায় কখনো ঘটে না — এটা দুর্ঘটনার বিমা, রোজকার পথ নয়।

key কেন immutable হতে হবে

পুরো ব্যবস্থাটা একটি অনুমানের ওপর দাঁড়িয়ে: কোনো key একবার একটি ঘরে বসার পর তার hash বদলাবে না। পর্ব ২৩-এর অংশ ৬-এ দেখেছেন, Set-এ রাখার পর object-এর নাম বদলালে সে পুরোনো ঘরে আটকে থাকে — contains false দেয়, আর আবার add করলে একই object দুবার থাকে। Map-এর key-এর বেলায় আরও খারাপ: put করা মান get দিয়ে আর পাওয়াই যায় না। তাই key হিসেবে নিন immutable টাইপ — String, Integer, enum, record (সব field নিজেও immutable হলে)। String-এর একটি বাড়তি সুবিধা: সে নিজের hashCode প্রথমবার হিসাব করে মনে রাখে, তাই একই String key বারবার খুঁজলে hash আবার হিসাব করতে হয় না।

৬ · TreeMap আর LinkedHashMap — সাজানো ক্রম আর ঢোকানোর ক্রম

TreeMap — সাজানো key, আর floorKey

TreeMap ভেতরে একটি red-black tree — নিজে নিজে ভারসাম্য রাখা একটি binary search tree. প্রতিটি node-এর বাঁয়ে ছোট key, ডানে বড়। ভারসাম্য থাকায় tree-এর উচ্চতা সবসময় O(log n), তাই put, get, remove — সব O(log n). HashMap-এর চেয়ে একটু ধীর, কিন্তু বিনিময়ে key-গুলো সবসময় সাজানো, আর সে একটি NavigableMap: firstKey, lastKey, floorKey, ceilingKey, headMap, tailMap, descendingMap.

এর সবচেয়ে কাজের ব্যবহার হলো “পরিসর থেকে মান”। SSC/HSC-র গ্রেড: ৮০ বা বেশি A+, ৭০–৭৯ A, ৬০–৬৯ A-, ৫০–৫৯ B, ৪০–৪৯ C, ৩৩–৩৯ D, ৩৩-এর নিচে F. if-else-এর লম্বা সিঁড়ি না লিখে প্রতিটি সীমার শুরুটা key হিসেবে রাখুন। তারপর নম্বর দিলে floorKey(marks) বলে দেয় “এই নম্বর বা তার নিচের সবচেয়ে বড় সীমা কোনটি”। আয়কর বা বিদ্যুৎ বিলের ধাপ, কুরিয়ারের ওজন অনুযায়ী চার্জ, বয়স অনুযায়ী টিকিটের দাম — সব একই ছাঁচ। ৬৭ নম্বর দিয়ে খোঁজাটা ধাপে ধাপে দেখুন (tree-এর আকার সরল করে দেখানো):

treekey = সীমার শুরু, value = গ্রেড
                50:B
            /          \
        33:D            70:A
       /    \          /    \
     0:F   40:C     60:A-   80:A+

floorKey(67) = ?   উত্তর এখনো নেই
ধাপ ১root 50
67 ≥ 50  →  50 একটি সম্ভাব্য উত্তর
           আরও বড় সম্ভাব্য উত্তর থাকতে পারে → ডানে যাও

সেরা এখন পর্যন্ত: 50
বাঁয়ের পুরো অংশ (0, 33, 40) আর দেখার দরকার নেই
ধাপ ২node 70
67 < 70  →  70 উত্তর হতে পারে না (67-এর বেশি)
           বাঁয়ে যাও

সেরা এখন পর্যন্ত: 50
80 বাদ পড়ল
ধাপ ৩node 60
67 ≥ 60  →  60, 50-এর চেয়ে ভালো সম্ভাব্য উত্তর
           ডানে যাও … ডানে কিছু নেই

সেরা: 60
ফল৩টি তুলনা, ৭টি key-এর মধ্যে
floorKey(67)   → 60
get(60)        → "A-"
floorEntry(67) → 60=A-   (key আর value একসাথে)

ceilingKey(67) → 70
floorKey(-5)   → null  ← সাবধান!
  1. সীমার শুরুগুলোই key. ৭টি গ্রেড মানে ৭টি key. ৩৩ থেকে ৩৯-এর প্রতিটি নম্বর আলাদা key হিসেবে রাখার দরকার নেই — শুধু সীমার শুরু ৩৩. TreeMap এগুলো সাজানো tree-তে রাখে।
  2. প্রতিটি তুলনা অর্ধেক বাদ দেয়। ৬৭ ≥ ৫০, তাই ৫০ একটি উত্তর হতে পারে — মনে রাখা হলো। ডানের key-গুলো ৫০-এর চেয়ে বড়; তাদের কোনোটি ৬৭-এর নিচে হলে সেটা আরও ভালো উত্তর, তাই ডানে যাওয়া। পর্ব ১৩-এর binary search-এর মতো, শুধু array-এর বদলে tree-তে।
  3. বেশি বড় — বাঁয়ে। ৭০ তো ৬৭-এর চেয়ে বড়, সেটা floor হতে পারে না। ৭০-এর বাঁয়ের অংশে ৬৭-এর কাছাকাছি কিছু থাকতে পারে।
  4. আরও ভালো উত্তর। ৬০ ≤ ৬৭, আর ৫০-এর চেয়ে বড় — নতুন সেরা। ডানে আর কোনো node নেই, তাই খোঁজা শেষ। দশ লাখ key থাকলেও এভাবে মাত্র ২০টির মতো তুলনা লাগত।
  5. উত্তর, আর একটি ফাঁদ। floorEntry key আর value একসাথে দেয়, তাই আলাদা get লাগে না। কিন্তু সবচেয়ে ছোট key-এর চেয়েও ছোট মান (−৫) দিলে কিছু পাওয়া যায় না — null. তখন floorEntry(-5).getValue() NullPointerException দেয়। তাই সবসময় 0-এর মতো একটি “তলা” key রাখুন, অথবা ইনপুট আগে যাচাই করুন।

LinkedHashMap — ক্রম মনে রাখা HashMap, আর LRU cache

LinkedHashMap হলো HashMap, যার সব entry পাশাপাশি একটি দুই-দিকের linked list দিয়েও জোড়া। খোঁজা HashMap-এর মতোই O(1), কিন্তু ঘোরালে আসে ঢোকানোর ক্রমে। খরচ: প্রতিটি entry-তে দুটি বাড়তি reference. একটি মজার বিকল্প আছে — constructor-এর তৃতীয় argument true দিলে ক্রম হয় ব্যবহারের ক্রম (access order): get বা put করা entry তালিকার শেষে চলে যায়। আর removeEldestEntry override করলে প্রতিটি put-এর পর সে জিজ্ঞেস করে “সবচেয়ে পুরোনোটি সরাব?”। এই দুটি মিলিয়ে কয়েক লাইনে একটি LRU cache হয় — যে cache জায়গা ফুরালে “সবচেয়ে বেশি দিন ব্যবহার হয়নি” এমন জিনিস ফেলে দেয়।

import java.util.*;

class Main {
    public static void main(String[] args) {
        TreeMap<Integer, String> grade = new TreeMap<>(Map.of(
            80, "A+", 70, "A", 60, "A-", 50, "B", 40, "C", 33, "D", 0, "F"));
        for (int marks : new int[] {95, 80, 67, 33, 12}) {
            System.out.println(marks + " -> " + grade.floorEntry(marks).getValue());
        }

        Map<String, String> cache = new LinkedHashMap<>(16, 0.75f, true) {  // true = access order
            @Override
            protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
                return size() > 3;                       // ৩টির বেশি হলে সবচেয়ে পুরোনোটি বাদ
            }
        };
        cache.put("/home", "Home"); cache.put("/about", "About"); cache.put("/blog", "Blog");
        cache.get("/home");                              // /home এখন সবচেয়ে সাম্প্রতিক
        cache.put("/shop", "Shop");                      // ৪টি হলো → সবচেয়ে পুরোনো বাদ
        System.out.println(cache.keySet());
    }
}

আউটপুট: 95 -> A+, 80 -> A+, 67 -> A-, 33 -> D, 12 -> F, আর শেষে [/blog, /home, /shop]. লক্ষ করুন ৮০ আর ৩৩ — সীমার ঠিক ওপর পড়া নম্বর — সঠিক গ্রেড পেয়েছে, কারণ floor মানে “সমান বা নিচের”। (“শুধু নিচের” চাইলে lowerKey.) Map.of-এর ক্রম এলোমেলো হলেও TreeMap-এ ঢোকানোর পর সব সাজানো। দ্বিতীয় অংশে new LinkedHashMap<>(16, 0.75f, true) { … } একটি anonymous subclass (পর্ব ২৭)। /about-কে সরানো হয়েছে, যদিও সে /home-এর পরে ঢুকেছিল — কারণ get-এর পর /home তালিকার শেষে চলে গেছে, আর সবচেয়ে কম সাম্প্রতিক থেকে গেছে /about. নিচের stepper-এ ঘটনাটি ধাপে ধাপে।

1put /home, /about, /blog2cache.get("/home");3cache.put("/shop", "Shop");4System.out.println(cache.keySet());
cache-এর ভেতরে
size
সবচেয়ে পুরোনো (eldest)
removeEldestEntry
  1. তিনটি পাতা cache-এ। ঢোকানোর ক্রমে তালিকা: /home সবচেয়ে পুরোনো। প্রতিটি put-এর পর removeEldestEntry ডাকা হয়েছে, কিন্তু size 3 সীমা ছাড়ায়নি, তাই কিছু সরেনি।
  2. get — ব্যবহারের ক্রম বদলায়। access order চালু থাকায় /home-কে পড়তেই সে তালিকার শেষে (সবচেয়ে সাম্প্রতিক) চলে গেল। এখন সবচেয়ে পুরোনো /about. সাধারণ (insertion order) LinkedHashMap-এ get ক্রম বদলায় না।
  3. চতুর্থ পাতা ঢুকল। /shop শেষে বসল, size 4. put শেষ হওয়ার মুখে LinkedHashMap ডাকে removeEldestEntry(eldest), যেখানে eldest = /about. আমাদের শর্ত size() > 3 সত্য।
  4. সবচেয়ে কম সাম্প্রতিকটি বাদ। true পেয়ে LinkedHashMap /about মুছে দেয়। কোনো লুপ নেই, কোনো টাইমস্ট্যাম্প নেই — পুরো LRU নীতি দুটি সেটিং-এ।
  5. ফল। [/blog, /home, /shop] — পুরোনো থেকে নতুন। বাস্তবে ছবি, ডেটাবেসের ফল বা API-এর উত্তর এভাবে মেমরিতে রাখা হয়। একাধিক thread একসাথে ব্যবহার করলে এই cache নিরাপদ নয় — তখন Caffeine-এর মতো তৈরি library বা synchronization লাগে (পর্ব ৪০)।

Java 21 LinkedHashMap আর TreeMap-কে একটি নতুন interface দিয়েছে — SequencedMap (JEP 431). এখন যেকোনো ক্রম-ওয়ালা Map-এ একই নামে লেখা যায় firstEntry(), lastEntry(), pollFirstEntry(), আর reversed(). HashMap এই interface implement করে না — কারণ তার কোনো “প্রথম” নেই।

EnumMap — key যখন enum

পর্ব ২৪-এ দেখেছেন, key যদি একটি enum হয় — সপ্তাহের দিন, অর্ডারের অবস্থা — তাহলে EnumMap নিন। ভেতরে সে একটি সাধারণ array, যার index হলো enum-এর ordinal: hash নেই, collision নেই, ঘোরালে enum ঘোষণার ক্রম। HashMap-এর চেয়ে দ্রুত আর কম মেমরির। Set-এর জন্য একই রকম EnumSet, যা ভেতরে বিট দিয়ে কাজ করে।

৭ · অপরিবর্তনীয় Map — Map.of, Map.entry আর Map.copyOf

অনেক Map একবার বানিয়ে আর কখনো বদলানো হয় না — জেলার কোড, ভ্যাটের হার, মেনুর দাম। এদের জন্য Java 9 এনেছে Map.of, আর Java 10 এনেছে Map.copyOf. এগুলো unmodifiable: put বা remove ডাকলে UnsupportedOperationException.

Map<String, Integer> code = Map.of("Dhaka", 1, "Chattogram", 2, "Khulna", 3);
Map<String, Integer> big  = Map.ofEntries(              // ১০ জোড়ার বেশি হলে
        Map.entry("Rajshahi", 4), Map.entry("Sylhet", 5));
Map<String, Integer> snap = Map.copyOf(someHashMap);    // অপরিবর্তনীয় কপি

Map.of সর্বোচ্চ ১০ জোড়া নেয়; বেশি হলে Map.ofEntries আর Map.entry. তিনটি কঠোর নিয়ম আছে, আর প্রতিটি OpenJDK 21-এ চালিয়ে যাচাই করা:

  • একই key দুবার — সাথে সাথে exception. HashMap-এ একই key দুবার put করলে নীরবে পুরোনো মান মুছে যায়। Map.of একে ভুল হিসেবে ধরে: Map.of("a", 1, "a", 2) দেয় java.lang.IllegalArgumentException: duplicate key: a. (Set.of-এ একই উপাদান দুবার দিলে: duplicate element: a.) কপি-পেস্ট করা কনফিগারেশনে ভুলটা চলার শুরুতেই ধরা পড়ে।
  • null কোথাও নয়। key বা value null দিলে NullPointerException. যে HashMap-এ null key আছে, তার Map.copyOf-ও একই কারণে ব্যর্থ হয়।
  • ঘোরানোর ক্রম ইচ্ছা করে এলোমেলো। OpenJDK-র বর্তমান implementation প্রতিবার JVM চালু হওয়ার সময় একটি এলোমেলো “salt” বেছে নেয়, আর Map.of/Set.of-এর ঘোরানোর ক্রম তার ওপর নির্ভর করে। তাই একই প্রোগ্রাম দুবার চালালে ক্রম আলাদা হতে পারে (ভাবনার প্রশ্ন ৪)।

Map.copyOf আর Collections.unmodifiableMap(m)-এর পার্থক্য মনে রাখুন। প্রথমটি একটি স্বাধীন কপি; মূল Map পরে বদলালে কপি বদলায় না। দ্বিতীয়টি শুধু একটি “পড়ার জানালা” — নিজে বদলানো যায় না, কিন্তু মূল Map বদলালে জানালা দিয়ে সেই বদল দেখা যায়। আর মনে রাখুন, unmodifiable মানে শুধু Map-এর কাঠামো স্থির; ভেতরের value যদি একটি mutable list হয়, সেই list বদলানো যায় (পর্ব ২৫-এর shallow immutability).

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

C-তে তৈরি কোনো hash table নেই — array, linked list আর hash function দিয়ে নিজে বানাতে হয় (C-এর হ্যাশ টেবিল). C++-এর std::map সাজানো (সাধারণত red-black tree) — Java-র TreeMap-এর মতো; std::unordered_map হলো HashMap. একটি পার্থক্য: C++-এ m[k] key না থাকলে নিজেই একটি entry বানিয়ে ফেলে, Java-র get তা করে না (C++-এর map ও set). Python-এর dict Java-র HashMap-এর কাজ করে, কিন্তু Python 3.7 থেকে ঢোকানোর ক্রম রাখার নিশ্চয়তা দেয় — অর্থাৎ আচরণে LinkedHashMap-এর কাছাকাছি; d.get(k, 0) হলো getOrDefault, আর collections.Counter হলো merge দিয়ে গোনা (Python-এর dictionary).

৮ · কোনটি কখন — খরচের টেবিল আর সিদ্ধান্তের নিয়ম

কাঠামোযোগ / খোঁজা / মোছাক্রমnull key
ArrayList (তুলনার জন্য)contains O(n)index-এর ক্রম—
HashSet / HashMapগড়ে O(1); খুব খারাপ hash-এ O(log n) পর্যন্ত (Java 8 থেকে)নেইএকটি চলে
LinkedHashSet / LinkedHashMapগড়ে O(1), একটু বেশি মেমরিঢোকানোর বা ব্যবহারেরএকটি চলে
TreeSet / TreeMapO(log n)সাজানোনা
EnumSet / EnumMapO(1), array/bitenum-এরনা
Map.of / Set.ofখোঁজা গড়ে O(1); বদলানো যায় নাএলোমেলোনা
সিদ্ধান্তের নিয়ম

১. প্রশ্নটা কি “আছে কি?” → Set. “এর মান কী?” → Map. “কততম?” বা ক্রমের সারি → List.
২. ক্রমের দরকার নেই → HashSet / HashMap (ডিফল্ট পছন্দ)।
৩. ঢোকানোর ক্রম রাখতে হবে, বা LRU cache → LinkedHashSet / LinkedHashMap.
৪. সাজানো ফল, সবচেয়ে ছোট/বড়, বা পরিসরের খোঁজ (floor, ceiling, headMap) → TreeSet / TreeMap.
৫. key একটি enum → EnumSet / EnumMap. কখনো বদলাবে না → Set.of / Map.of.

তিনটি প্যাটার্ন, যা বারবার লাগবে

  • গোনা (frequency count): count.merge(x, 1, Integer::sum) — শব্দ, ভোট, প্রতিটি পণ্যের বিক্রি, প্রতিটি IP-র request.
  • দলে ভাগ করা (grouping): groups.computeIfAbsent(key, k -> new ArrayList<>()).add(x) — বিভাগ অনুযায়ী ছাত্র, জেলা অনুযায়ী অর্ডার, একই অক্ষরের শব্দ। পর্ব ৩৫-এ Collectors.groupingBy একই কাজ এক লাইনে করবে।
  • দেখা হয়েছে কি (seen set): if (!seen.add(x)) { /* ডুপ্লিকেট! */ } — ডুপ্লিকেট ফর্ম, একই OTP দুবার, গ্রাফে একই node দুবার না ঘোরা। দুই তালিকার মিল খুঁজতে একটিকে Set বানান, অন্যটি ঘুরে contains — nested loop-এর O(n × m) নেমে আসে O(n + m)-এ।
পরীক্ষা ও ইন্টারভিউতে
  • HashMap আর TreeMap-এর পার্থক্য? — HashMap: hash table, গড়ে O(1), ক্রম নেই, একটি null key চলে। TreeMap: red-black tree, O(log n), key সাজানো, null key চলে না (স্বাভাবিক ক্রমে)।
  • HashMap-এর ডিফল্ট capacity আর load factor? — ১৬ আর ০.৭৫; উপাদান ১২-এর বেশি হলে টেবিল দ্বিগুণ (৩২) হয় আর সব entry rehash হয়।
  • HashMap আর Hashtable-এর পার্থক্য? — Hashtable Java 1.0-এর পুরোনো class, প্রতিটি method synchronized, null key/value চলে না। নতুন কোডে একা ব্যবহারে HashMap, একাধিক thread-এ ConcurrentHashMap.
  • Java 8-এ HashMap-এ কী বদলাল? — খুব লম্বা bucket (৮টির বেশি, টেবিল ≥ ৬৪) red-black tree হয়, তাই সবচেয়ে খারাপ খোঁজা O(n) থেকে O(log n); আর merge, computeIfAbsent, getOrDefault-এর মতো method এল।
  • HashSet কীভাবে ডুপ্লিকেট ঠেকায়? — ভেতরে একটি HashMap; উপাদান key হিসেবে রাখা হয়, আর hashCode + equals দিয়ে একই key চেনা হয়।

৯ · AI-এর যুগে — Map-এর কোড যাচাই করা

“একটি লগ ফাইল থেকে প্রতিটি ব্যবহারকারীর লগইন সংখ্যা বের করো” — AI এমন কাজে কয়েক সেকেন্ডে কোড দেয়, আর বেশিরভাগ সময় সেটা চলে। কিন্তু Set আর Map-এর সবচেয়ে বিপজ্জনক ভুলগুলো compile error দেয় না: ক্রম আজ ঠিক আসে কাল আসে না, ছোট ডেটায় দ্রুত চলে বড় ডেটায় ঘণ্টা লাগে। এগুলো আপনাকেই ধরতে হবে।

AI যা করেকেন সমস্যাআপনি যা করবেন
HashMap ঘুরিয়ে ছাপে, আর টেস্টে নির্দিষ্ট ক্রম আশা করেক্রম অনির্দিষ্ট; Java version, ডেটার আকার বা resize-এ বদলায় — টেস্ট মাঝে মাঝে ফেল করেছাপার ক্রম দরকার হলে TreeMap/LinkedHashMap, বা key sort
containsKey + get + put-এর তিন লাইনএকই key তিনবার খোঁজা, লম্বা কোড, ভুলের সুযোগmerge, computeIfAbsent, getOrDefault চান
দুই তালিকার মিল বা ডুপ্লিকেট খুঁজতে nested loop বা list.containsO(n²) — ১০০ উপাদানে ঠিক, ১০ লাখে অচলএকটিকে HashSet বানাতে বলুন
mutable class (setter-সহ) key হিসেবে, বা hashCode ছাড়া equalsput করা মান get দিয়ে পাওয়া যায় না, ডুপ্লিকেট keykey-এর জন্য String, enum বা record
putIfAbsent(k, new ArrayList<>()).add(x)key নতুন হলে putIfAbsent null ফেরত দেয় — NullPointerExceptioncomputeIfAbsent
for-each লুপের ভেতরে map.removeConcurrentModificationExceptionentrySet().removeIf(...)
TreeMap-এর floorKey/floorEntry-র ফল null-যাচাই ছাড়াই ব্যবহারসবচেয়ে ছোট key-এর নিচের ইনপুটে NullPointerException“তলা” key রাখা, বা null যাচাই

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

আমি Java 21 শিখছি; HashMap, TreeMap, LinkedHashMap আর HashSet-এর পার্থক্য জানি। এই কাজের জন্য কোড লিখে দাও: [যেমন — একটি লগ থেকে প্রতিটি ব্যবহারকারীর লগইন সংখ্যা, আর যারা ৫ বারের বেশি ঢুকেছে তাদের তালিকা] শর্ত: ১. প্রতিটি collection-এর জন্য কেন সেই implementation বেছে নিলে, এক লাইনে বলো (Hash, Linked, না Tree, আর কেন)। ২. আউটপুট বা টেস্টে ক্রম গুরুত্বপূর্ণ হলে HashMap/HashSet ঘুরিয়ে ছাপবে না — TreeMap, LinkedHashMap, বা sort করা key ব্যবহার করো। ৩. গোনার জন্য merge, দলে ভাগ করার জন্য computeIfAbsent, ডিফল্ট মানের জন্য getOrDefault — containsKey + get + put নয়। ৪. কোথাও nested loop দিয়ে খোঁজা বা list.contains থাকলে Set দিয়ে বদলাও, আর আগে-পরের Big-O লেখো। ৫. key হিসেবে শুধু immutable টাইপ (String, Integer, enum, record); নিজের class হলে equals আর hashCode একই field দিয়ে। ৬. ঘোরানোর সময় map থেকে মুছতে হলে removeIf বা Iterator.remove; floorKey/ceilingKey-এর null ফলের ব্যবস্থা রাখো। ৭. শেষে একটি ছোট main দাও যার আউটপুট প্রতিবার হুবহু একই।

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

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

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

প্র ০১Hash table-এর ধারণা কোথা থেকে এল? আর Java-র HashMap কেন ঘরের সংখ্যা সবসময় ২-এর ঘাত রাখে, যেখানে পাঠ্যবই প্রায়ই মৌলিক সংখ্যা বলে?

জন্ম IBM-এ। Donald Knuth তাঁর The Art of Computer Programming-এর তৃতীয় খণ্ডে hashing-এর ইতিহাস খুঁজে দেখিয়েছেন: ১৯৫৩ সালের জানুয়ারিতে IBM-এর Hans Peter Luhn একটি অভ্যন্তরীণ memo-তে key থেকে একটি সংখ্যা হিসাব করে key-গুলোকে আলাদা আলাদা ঘরে ভাগ করে রাখার কথা লেখেন, আর একই ঘরে পড়া key-গুলো তালিকা হিসেবে জুড়ে রাখার প্রস্তাব দেন — আজকের HashMap-এর chaining ঠিক এটাই। প্রায় একই সময়ে IBM-এর আরেকটি দল (Gene Amdahl, Elaine McGraw, Arthur Samuel প্রমুখ) IBM 701 কম্পিউটারের জন্য অন্য একটি কৌশল ব্যবহার করে — collision হলে পাশের খালি ঘরে রাখা, যাকে আজ বলে open addressing বা linear probing. “hash” শব্দটি ছাপা অক্ষরে নিয়মিত আসে আরও পরে, ১৯৬০-এর দশকে। (Luhn-এর নাম আরেকটি কারণেও পরিচিত — ক্রেডিট কার্ড নম্বর যাচাইয়ের Luhn algorithm.)

Java-র পথ। JDK 1.0-এ ছিল Hashtable. ১৯৯৮ সালের Java 1.2-তে Joshua Bloch-এর নকশা করা Collections Framework আসে, সাথে HashMap. পাঠ্যবইয়ে প্রায়ই বলা হয় টেবিলের আকার মৌলিক সংখ্যা রাখুন — কারণ hash % m-এ m মৌলিক হলে hash-এর সব বিট ফলাফলে প্রভাব ফেলে, খারাপ hashCode-ও মোটামুটি ছড়িয়ে যায়। কিন্তু ভাগ (%) CPU-তে তুলনামূলক ধীর কাজ। HashMap তাই আকার রাখে ২-এর ঘাত, আর ঘর বের করে hash & (n - 1) দিয়ে — একটি AND, প্রায় বিনা খরচে।

দাম আর তার প্রতিকার। ২-এর ঘাতে শুধু নিচের কয়েকটি বিট দেখা হয়। কোনো hashCode যদি শুধু উঁচু বিটে আলাদা হয় (যেমন কিছু Float মান, বা ১৬-এর গুণিতক সংখ্যা), সবাই একই ঘরে পড়ত। প্রতিকার হিসেবে HashMap ঘর বাছার আগে hash-এর উঁচু ১৬ বিট নিচের ১৬ বিটের সাথে XOR করে মেশায় (h ^ (h >>> 16)). Java 8-এর আগে এই মেশানো আরও জটিল ছিল; Java 8 একে সরল করে, কারণ খারাপ ক্ষেত্রের জন্য treeification-এর বিমা তখন ছিল। আরেকটি সুবিধা: দ্বিগুণ করার সময় প্রতিটি entry হয় একই index-এ থাকে, নয়তো ঠিক পুরোনো আকার যোগ করা index-এ যায় — একটিমাত্র বিট দেখে সিদ্ধান্ত, তাই resize দ্রুত।

মূল উপলব্ধি: hash table-এর মূল ধারণা প্রায় সত্তর বছরের পুরোনো, কিন্তু প্রতিটি যুগ নিজের hardware-এর মাপে খুঁটিনাটি বদলেছে। HashMap-এর ২-এর ঘাত, বিট মেশানো আর treeification একসাথে একটি নকশা: দ্রুত সাধারণ পথ, আর খারাপ ক্ষেত্রের জন্য আলাদা সুরক্ষা।

প্র ০২TreeMap কেন red-black tree, AVL tree নয়? দুটির পার্থক্য কী, আর “লাল-কালো” নামটাই বা কোথা থেকে?

সমস্যা। সাধারণ binary search tree-তে সাজানো ক্রমে key ঢোকালে (১, ২, ৩, …) প্রতিটি নতুন key আগেরটির ডানে বসে, আর tree আসলে একটি লম্বা তালিকা হয়ে যায় — খোঁজা O(n). সমাধান হলো এমন tree, যা প্রতিটি ঢোকানো ও মোছার পর নিজের আকার সামান্য ঘুরিয়ে (rotation) ভারসাম্য রাখে।

AVL — প্রথম সমাধান। ১৯৬২ সালে সোভিয়েত গণিতবিদ Georgy Adelson-Velsky আর Evgenii Landis প্রথম এমন tree প্রকাশ করেন, তাঁদের নামের আদ্যক্ষরে AVL. শর্ত কঠোর: প্রতিটি node-এর বাঁ আর ডান অংশের উচ্চতার পার্থক্য সর্বোচ্চ ১. তাই tree খুব ভারসাম্যপূর্ণ — উচ্চতা মোটামুটি ১.৪৪ log₂ n-এর বেশি হয় না — আর খোঁজা খুব দ্রুত।

Red-black — নরম শর্ত। ১৯৭২ সালে Rudolf Bayer “symmetric binary B-tree” নামে একটি কাঠামো দেন। ১৯৭৮ সালে Leonidas Guibas আর Robert Sedgewick তাঁদের প্রবন্ধ “A Dichromatic Framework for Balanced Trees”-এ একে প্রতিটি node-এ একটি রং (লাল বা কালো) দিয়ে প্রকাশ করেন — সেখান থেকেই নাম। রং দুটি কেন লাল আর কালো? Sedgewick-এর নিজের পরের বর্ণনা অনুযায়ী, Xerox PARC-এ তখন যে রঙিন লেজার প্রিন্টার ছিল, তাতে লাল রংটা সবচেয়ে ভালো দেখাত। আবার কোনো কোনো বর্ণনায় তাঁদের হাতের লাল-কালো কলমের কথাও আসে — তাই গল্পটিকে মজার কাহিনি হিসেবেই নিন। শর্তগুলো: লাল node-এর সন্তান লাল নয়, আর root থেকে প্রতিটি পাতা পর্যন্ত কালো node-এর সংখ্যা সমান। ফলে উচ্চতা সর্বোচ্চ প্রায় ২ log₂ n — AVL-এর চেয়ে একটু লম্বা হতে পারে।

কেন red-black জেতে। শিথিল শর্তের লাভ হলো বদলানোয়: একটি ঢোকানোয় সর্বোচ্চ ২টি rotation, মোছায় সর্বোচ্চ ৩টি; বাকি কাজ শুধু রং বদলানো। AVL-এ মোছার পর গোড়া পর্যন্ত অনেক rotation লাগতে পারে। সাধারণ-উদ্দেশ্যের library-তে খোঁজা আর বদলানো দুটিই ঘন ঘন হয়, তাই সামান্য বেশি উচ্চতার বদলে কম পুনর্গঠন ভালো সমঝোতা। TreeMap-এর Javadoc নিজেই বলে, তার algorithm Cormen, Leiserson আর Rivest-এর Introduction to Algorithms বই থেকে নেওয়া। C++-এর প্রচলিত std::map implementation আর Linux kernel-ও red-black tree ব্যবহার করে। যেখানে খোঁজাই প্রধান আর বদল কম, সেখানে AVL-এর কম উচ্চতা এখনো সুবিধা দিতে পারে।

মূল উপলব্ধি: “সবচেয়ে ভারসাম্যপূর্ণ” মানেই “সবচেয়ে ভালো” নয়। Red-black tree একটু বেশি উচ্চতা মেনে নিয়ে বদলানোর খরচ কমায় — আর library-নকশায় এমন সমঝোতাই সাধারণত জেতে।

প্র ০৩Java 8-এর treeification ঠিক কীভাবে কাজ করে? সীমা ৮ কেন, আর key Comparable না হলে কি সুরক্ষা পুরোপুরি কাজ করে?

প্রেক্ষাপট। পর্ব ২৩-এর ভাবনার প্রশ্ন ৪-এ দেখেছেন, ২০১১ সালে hash flooding আক্রমণ দেখানো হয় — একই hash-এর হাজারো key পাঠিয়ে সার্ভারের HashMap-কে একটি লম্বা তালিকা বানিয়ে দেওয়া। Java 8-এ JEP 180 (“Handle Frequent HashMap Collisions with Balanced Trees”) এর স্থায়ী উত্তর দেয়। এখানে দেখি নকশার খুঁটিনাটি।

তিনটি সংখ্যা। OpenJDK-র HashMap-এর কোডে তিনটি ধ্রুবক আছে: TREEIFY_THRESHOLD = 8 — একটি bucket-এ এর বেশি entry হলে tree বানানোর চেষ্টা; UNTREEIFY_THRESHOLD = 6 — resize-এর পর tree ছোট হয়ে ৬ বা কম হলে আবার তালিকা; আর MIN_TREEIFY_CAPACITY = 64 — টেবিল এর চেয়ে ছোট হলে tree নয়, resize. ৮ আর ৬-এর মধ্যে ফাঁক রাখা হয়েছে যাতে সীমার কাছে একটি key যোগ-বিয়োগে বারবার tree-তালিকা রূপান্তর না হয় (hysteresis).

৮ কেন। HashMap-এর source-এর একটি মন্তব্যে হিসাব আছে: ভালো hashCode আর ০.৭৫ load factor-এ একটি bucket-এ k-টি entry থাকার সম্ভাবনা প্রায় Poisson বণ্টন মানে, আর ৮টি পড়ার সম্ভাবনা এক কোটিতে একবারেরও কম। অর্থাৎ স্বাভাবিক অবস্থায় tree প্রায় কখনো তৈরি হয় না। এটা জরুরি, কারণ tree-এর একটি node (TreeNode) সাধারণ node-এর প্রায় দ্বিগুণ জায়গা নেয়। সুরক্ষা দরকার শুধু খারাপ hashCode বা আক্রমণের সময় — আর তখনই সে চালু হয়।

Comparable না হলে। একই bucket-এর key-দের hash প্রায়ই হুবহু এক (আক্রমণে তো বটেই)। তাহলে tree-তে বাঁয়ে-ডানে সাজানো হবে কী দিয়ে? HashMap আগে hash দেখে; সমান হলে, key যদি Comparable হয় আর একই class-এর, তাহলে compareTo দিয়ে। String Comparable, তাই ওয়েব parameter-এর মতো String key-তে খোঁজা সত্যিই O(log n). কিন্তু key Comparable না হলে ঢোকানোর জন্য একটি “টাই-ভাঙার” ক্রম (class-এর নাম, তারপর identity hash) ব্যবহার হয়, যা খোঁজার সময় কাজে লাগে না — তখন খোঁজাকে tree-এর দুই দিকই দেখতে হতে পারে, আর সবচেয়ে খারাপ ক্ষেত্রে সেটা আবার O(n)-এর কাছে চলে যায়।

মূল উপলব্ধি: treeification একটি বিমা, যার প্রিমিয়াম স্বাভাবিক দিনে প্রায় শূন্য। আর সেরা সুরক্ষা পেতে key-কে দুটি কাজই ঠিকভাবে করতে হয়: ভালো hashCode, আর সম্ভব হলে Comparable.

প্র ০৪HashMap-এর ঘোরানোর ক্রম কেন “অনির্দিষ্ট” রাখা হয়েছে? আর Java 9-এর Map.of কেন ইচ্ছা করে প্রতিবার ক্রম এলোমেলো করে?

অনির্দিষ্ট মানে স্বাধীনতা। HashMap-এর ক্রম আসে hash, টেবিলের আকার আর collision-এর ইতিহাস থেকে। Javadoc যদি কোনো ক্রমের প্রতিশ্রুতি দিত, JDK-র প্রকৌশলীরা কখনো hash মেশানোর সূত্র, resize-এর নিয়ম বা ভেতরের কাঠামো বদলাতে পারতেন না। “কোনো নিশ্চয়তা নেই” লিখে তাঁরা নিজেদের উন্নতির পথ খোলা রেখেছেন।

কিন্তু মানুষ নির্ভর করে ফেলে। Google-এর প্রকৌশলী Hyrum Wright-এর নামে একটি পর্যবেক্ষণ প্রচলিত — “Hyrum’s Law”: একটি API-র যথেষ্ট ব্যবহারকারী থাকলে, আপনি কী প্রতিশ্রুতি দিয়েছেন তা গুরুত্বহীন; আপনার সিস্টেমের প্রতিটি দেখা যায় এমন আচরণের ওপর কেউ না কেউ নির্ভর করবে। HashMap ঠিক এর উদাহরণ। Java 7 থেকে 8-এ ওঠার সময় HashMap-এর ভেতরের বদলে অনেক প্রকল্পের টেস্ট ভেঙেছিল, যারা না জেনেই একটি নির্দিষ্ট ক্রম আশা করত — JSON-এর field-এর ক্রম, রিপোর্টের লাইনের ক্রম। কোড ভুল ছিল আগেই; Java শুধু তা প্রকাশ করল।

Java 9-এর সমাধান — ইচ্ছাকৃত এলোমেলো। Set.of আর Map.of (Java 9) নতুন API, তাই শুরু থেকেই এই ফাঁদ বন্ধ করার সুযোগ ছিল। OpenJDK-র implementation (ImmutableCollections class) JVM চালু হওয়ার সময় একটি “SALT” মান হিসাব করে, আর এই collection-গুলোর ঘোরানোর ক্রম সেই salt-এর ওপর নির্ভর করে। ফলে একই প্রোগ্রাম আজ একবার চালালে এক ক্রম, আবার চালালে আরেক ক্রম আসতে পারে। নির্ভর করা কোড প্রথম দিনেই ধরা পড়ে, বহু বছর পরে নয়। সাবধানে বলা দরকার: এটা implementation-এর আচরণ, Javadoc শুধু বলে ক্রম অনির্দিষ্ট আর বদলাতে পারে। একই দর্শন Go ভাষাতেও দেখা যায় — Go-র runtime map ঘোরানোর ক্রম ইচ্ছা করে এলোমেলো করে।

আপনার জন্য নিয়ম। ক্রম দরকার হলে সেটা টাইপে লিখুন: TreeMap (সাজানো), LinkedHashMap (ঢোকানোর ক্রম), অথবা ছাপার আগে sort. ক্রম দরকার না হলে HashMap — আর তখন ক্রম নিয়ে কোনো অনুমানই করবেন না, টেস্টেও না।

মূল উপলব্ধি: “অনির্দিষ্ট” আচরণ একদিন বদলাবেই, আর তার ওপর দাঁড়ানো কোড তখন ভাঙবে। ভালো API নকশা কখনো কখনো ভুল ব্যবহার আগেভাগে প্রকাশ করতে ইচ্ছা করেই অনিশ্চয়তা দেখায়।

প্র ০৫Python-এর dict নিজেই ঢোকানোর ক্রম রাখে। Java কেন HashMap-কে ক্রমহীন রেখে আলাদা LinkedHashMap রাখল?

Python-এর গল্প। ২০১২ সালে Python-এর core developer Raymond Hettinger python-dev mailing list-এ dict-এর একটি নতুন, “compact” নকশা প্রস্তাব করেন। পুরোনো নকশায় একটি বড় sparse টেবিলের প্রতিটি ঘরে পুরো entry (hash, key, value) থাকত — অনেক ঘর খালি, অনেক জায়গা নষ্ট। নতুন নকশায় দুটি অংশ: entry-গুলো একটি ঘন array-তে ঢোকানোর ক্রমে পরপর বসে, আর hash টেবিলে থাকে শুধু সেই array-এর ছোট ছোট index. ফলে মেমরি অনেকটা কমে, আর ঘোরালে ঢোকানোর ক্রম পাওয়া যায় বিনা খরচে — বাড়তি সুবিধা হিসেবে। PyPy আগে এটি গ্রহণ করে; CPython 3.6-এ (২০১৬) এটা আসে “implementation detail” হিসেবে, আর Guido van Rossum-এর সিদ্ধান্তে Python 3.7 (২০১৮) থেকে ঢোকানোর ক্রম ভাষার নিশ্চয়তা।

Java কেন এমন করেনি। প্রথমত, ইতিহাস: Java-র HashMap ১৯৯৮ সালের, আর LinkedHashMap Java 1.4-এ (২০০২) আলাদা class হিসেবে আসে — যাদের ক্রম দরকার তারা বেছে নেবে, বাকিরা দাম দেবে না। দ্বিতীয়ত, Java-র HashMap-এর কাঠামো আলাদা: প্রতিটি entry একটি আলাদা Node object, collision-এ linked list বা tree. Python-এর compact নকশা মূলত open addressing-এর জন্য বানানো; HashMap-এ ক্রম আনতে হলে LinkedHashMap-এর মতো প্রতিটি entry-তে দুটি বাড়তি reference লাগত — সব প্রোগ্রামের মেমরি বাড়ত। তৃতীয়ত, Java-তে মুছে ফেলা আর treeification-এর মতো কাজ ক্রম রক্ষাকে আরও জটিল করত। আর একবার নিশ্চয়তা দিলে আর ফেরানো যায় না।

Java-র আধুনিক উত্তর। Java ক্রমকে আলাদা টাইপ হিসেবে প্রকাশ করার পথ নিয়েছে। Java 21-এর JEP 431 (Sequenced Collections) SequencedMap interface আনে, যা LinkedHashMap আর TreeMap implement করে: firstEntry(), lastEntry(), putFirst, reversed(). ফলে method-এর signature-এই লেখা যায় “আমার ক্রম-ওয়ালা Map চাই” — টাইপ-চেকার তা যাচাই করে। Python-এ সব dict ক্রম-ওয়ালা, তাই এই পার্থক্য টাইপে দেখা যায় না।

মূল উপলব্ধি: Python সুবিধা আর সরলতা বেছেছে: একটি dict, সব কাজে। Java বেছেছে স্পষ্টতা আর “যা ব্যবহার করবেন না তার দাম দেবেন না” নীতি: ক্রম চাইলে টাইপে বলুন। দুটিই যুক্তিসঙ্গত, আর পার্থক্যটা বুঝলে দুই ভাষাতেই ভুল কম হবে।

অনুশীলন

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

  1. তিন Set, এক ইনপুট: Arrays.asList(5, 1, 9, 1, 3) থেকে একটি HashSet, একটি LinkedHashSet আর একটি TreeSet বানানো হলো। প্রতিটির size কত? কোন দুটির ছাপা ফল আপনি নিশ্চিতভাবে বলতে পারেন, আর কোনটির পারেন না? HashSet-এর ফল যদি চালিয়ে সাজানো দেখায়, তাতে কি প্রমাণ হয় যে HashSet সাজায়?

    তিনটিরই size 4 — দ্বিতীয় 1 বাদ। LinkedHashSet: [5, 1, 9, 3] (প্রথম আসার ক্রম), TreeSet: [1, 3, 5, 9] (সাজানো) — দুটিই নিশ্চিত, OpenJDK 21-এ চালিয়ে যাচাই করা। HashSet-এর ক্রম নিশ্চিত নয়। ছোট Integer-এর hashCode সংখ্যাটি নিজেই, তাই ১৬ ঘরের টেবিলে 1, 3, 5, 9 নিজ নিজ নম্বরের ঘরে বসে আর ছাপলে সাজানো দেখায় — কিন্তু এটা কাকতাল। 17 যোগ করলে সে ঘর 1-এ যায় (17 & 15 = 1), আর ক্রম আর সাজানো থাকে না। একটি উদাহরণে সাজানো দেখা মানে নিয়ম নয়; Javadoc-এর প্রতিশ্রুতিই নিয়ম।

  2. Nested loop থেকে Set: একটি মোবাইল ব্যাংকিং অ্যাপে দুটি তালিকা: ৫ লাখ সন্দেহজনক নম্বর আর আজকের ২০ লাখ লেনদেনের প্রেরকের নম্বর। একজন লিখেছেন দুটি nested for লুপ দিয়ে মিল খোঁজা। মোটামুটি কতটি তুলনা হবে? Set দিয়ে কীভাবে লিখবেন, কোন তালিকাটিকে Set বানাবেন, আর তখন কাজ মোটামুটি কত?

    Nested loop: ৫ লাখ × ২০ লাখ = ১ লক্ষ কোটি (১০¹²) তুলনা — দ্রুত কম্পিউটারেও অনেকক্ষণ, প্রায় ঘণ্টার হিসাবে। Set দিয়ে: সন্দেহজনক ৫ লাখ নম্বর একটি HashSet<String>-এ রাখুন (ছোট তালিকাটি — কম মেমরি), তারপর ২০ লাখ লেনদেন একবার ঘুরে প্রতিটির জন্য suspicious.contains(sender). মোট মোটামুটি ৫ লাখ + ২০ লাখ = ২৫ লাখ ধাপ, প্রতিটি গড়ে O(1) — কয়েক সেকেন্ডের কাজ। কোন লেনদেন মিলেছে সেটা না জেনে শুধু মিলে যাওয়া নম্বরগুলো চাইলে retainAll-ও চলে, তবে সেটা যে set-এ ডাকেন তাকে বদলে দেয়, তাই আগে কপি বানান।

  3. কুরিয়ারের চার্জ: একটি কুরিয়ার সার্ভিসের চার্জ: ১ কেজির কম ৬০ টাকা, ১ থেকে ৩ কেজির কম ৮০, ৩ থেকে ৫ কেজির কম ১২০, ৫ কেজি বা বেশি ২০০। অংশ ৬-এর গ্রেডের প্রোগ্রামটি বদলে এই টেবিল TreeMap দিয়ে লিখুন। ০.৫, ১.০, ২.৭ আর ৯.০ কেজিতে কী ফল আসবে? ঋণাত্মক ওজন দিলে কী হবে, আর কীভাবে সামলাবেন?

    TreeMap<Double, Integer> fee = new TreeMap<>(Map.of(0.0, 60, 1.0, 80, 3.0, 120, 5.0, 200)); তারপর fee.floorEntry(kg).getValue(). ফল (চালিয়ে যাচাই করা): ০.৫ → 60, ১.০ → 80 (সীমার ঠিক ওপর — floor “সমান”-ও ধরে), ২.৭ → 80, ৯.০ → 200. ঋণাত্মক ওজনে floorEntry null দেয়, আর .getValue() NullPointerException. সামলানোর উপায়: আগে যাচাই — if (kg < 0) throw new IllegalArgumentException("weight: " + kg); — কারণ ঋণাত্মক ওজন আসলে একটি ভুল ইনপুট, তাকে চুপচাপ ৬০ টাকা ধরা ঠিক নয় (পর্ব ২৯-এর fail-fast). খেয়াল করুন, নতুন ধাপ যোগ করতে শুধু একটি entry যোগ করলেই হয় — if-else-এর সিঁড়ি বদলাতে হয় না।

  4. নিজে লিখুন (একমাত্র কোড লেখার কাজ): একই অক্ষর দিয়ে তৈরি শব্দগুলোকে (anagram) দলে ভাগ করুন: listen, silent, cat, enlist, act, tea, eat. ইঙ্গিত: প্রতিটি শব্দের অক্ষর sort করলে একটি “চাবি” পাওয়া যায় (listen → eilnst). কোন Map আর কোন method নেবেন, যাতে ফল প্রতিবার একই ক্রমে ছাপে?

    একটি সমাধান:

    Map<String, List<String>> groups = new TreeMap<>();
    for (String w : List.of("listen", "silent", "cat", "enlist", "act", "tea", "eat")) {
        char[] cs = w.toCharArray();
        Arrays.sort(cs);
        groups.computeIfAbsent(new String(cs), k -> new ArrayList<>()).add(w);
    }
    System.out.println(groups);
    // {act=[cat, act], aet=[tea, eat], eilnst=[listen, silent, enlist]}

    আউটপুটটি OpenJDK 21-এ চালিয়ে যাচাই করা। TreeMap নেওয়া হয়েছে যাতে চাবিগুলো সাজানো ক্রমে ছাপে; প্রতিটি দলের ভেতরের ক্রম ঢোকানোর ক্রম, কারণ list-এ add হয় শব্দ আসার ক্রমে। computeIfAbsent প্রথমবার list বানায়, পরে একই list ফেরত দেয়। চাবি হিসেবে String নিরাপদ — immutable; char[] সরাসরি key হিসেবে দিলে চলত না, কারণ array-এর equals আর hashCode মান দেখে না, শুধু identity (পর্ব ২৩)। এটা একটি প্রচলিত ইন্টারভিউ প্রশ্ন; n শব্দ, প্রতিটির দৈর্ঘ্য k হলে খরচ মোটামুটি O(n · k log k).

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

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

ক্রমJava-র HashMap-এ map.put(key, value) ডাকলে ঘটনাগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 🔢 key.hashCode() ডেকে একটি int পাওয়া যায়, আর তার উঁচু বিট নিচের বিটের সাথে মেশানো হয়
  2. 🗂️ hash & (n - 1) দিয়ে টেবিলের একটি ঘর বাছা হয়
  3. 🔍 সেই ঘরের প্রতিটি entry-র hash আর equals মেলানো হয়
  4. ✏️ মিললে মান বদলায়; না মিললে নতুন entry যোগ হয়
  5. 📏 size যদি capacity × load factor ছাড়ায়, টেবিল দ্বিগুণ করে সব entry rehash হয়

Java-তে কোন Map key-গুলো সবসময় সাজানো ক্রমে রাখে আর floorKey, ceilingKey-এর মতো method দেয়?

  • HashMap
  • LinkedHashMap
  • TreeMap
  • Map.of দিয়ে বানানো Map

Java 21-এ Map.of("a", 1, "a", 2) চালালে কী ঘটে?

  • পরের মানটি থাকে: {a=2}
  • IllegalArgumentException: duplicate key: a
  • compile error হয়
  • প্রথম মানটি থাকে: {a=1}

Map<String, Integer> count-এ "java" key নেই। count.merge("java", 1, Integer::sum) চালালে কী হয়?

  • "java"=1 যোগ হয়
  • NullPointerException আসে
  • কিছুই হয় না, কারণ key নেই
  • "java"=0 যোগ হয়

ডিফল্ট সেটিং-এ (capacity 16, load factor 0.75) একটি Java HashMap প্রথমবার কখন resize হয়?

  • ১৬টি ঘরই ভরে গেলে
  • প্রথম collision হলে
  • কোনো bucket-এ ৮টির বেশি entry হলে
  • ১২টির বেশি entry হলে (১৩তম put-এর পর)

একটি TreeMap<Integer, String>-এ key আছে 0, 33, 40, 50, 60, 70, 80. floorKey(67) কী ফেরত দেবে?

  • 70
  • 60
  • 50
  • null

Java-র কোন Map-এ put(null, "x") চালালে exception আসে না?

  • HashMap
  • TreeMap (স্বাভাবিক ক্রম)
  • ConcurrentHashMap
  • Hashtable

শব্দকোষ

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

মনে রাখুন

  1. Set = “আছে কি?”, Map = “এর মান কী?” — দুটিই প্রায় এক ধাপে উত্তর দেয়। দুই তালিকার মিল বা ডুপ্লিকেট খুঁজতে nested loop নয়, একটি HashSet.
  2. Hash, Linked, Tree: HashSet/HashMap দ্রুততম, ক্রম নেই; LinkedHash… ঢোকানোর (বা ব্যবহারের) ক্রম; Tree… সাজানো, O(log n), floor/ceiling দিয়ে পরিসরের খোঁজ। key enum হলে EnumMap.
  3. merge দিয়ে গোনা, computeIfAbsent দিয়ে দলে ভাগ, getOrDefault দিয়ে ডিফল্ট — containsKey + get + put নয়। ঘোরানোর সময় মুছতে removeIf.
  4. HashMap: ২-এর ঘাতের bucket array, load factor ০.৭৫ ছাড়ালে দ্বিগুণ আর rehash, Java 8 থেকে লম্বা bucket tree. key immutable রাখুন, আর HashMap-এর ক্রমে কখনো ভরসা নয়।
  5. Map.of অপরিবর্তনীয়: ডুপ্লিকেট key-তে IllegalArgumentException, null-এ NullPointerException, ক্রম এলোমেলো। HashMap ও LinkedHashMap null key নেয়, TreeMap নেয় না।

পরের পর্বে: হাসপাতালের জরুরি বিভাগে কে আগে ডাক্তার দেখাবেন — যিনি আগে এসেছেন, না যাঁর অবস্থা বেশি গুরুতর? Queue, Deque আর PriorityQueue: লাইনে দাঁড়ানো, দুই মাথা থেকে নেওয়া, আর সবসময় “সবচেয়ে জরুরি”টিকে আগে পাওয়া।

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

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