এই পর্বে যা শিখবেন
- “চাবি দিয়ে খোঁজা” কেন vector-এ লুপ চালিয়ে খোঁজার চেয়ে আলাদা, আর C++-এ কোন কোন associative container আছে
std::mapভেতরে কীভাবে সাজানো থাকে (balanced tree), আর insert, find, contains, erase, try_emplace, insert_or_assign-এর সঠিক ব্যবহারoperator[]-এর নীরব ফাঁদ,set,multimap,multiset, আরlower_bound/upper_boundদিয়ে দামের range খোঁজাunordered_map-এর hash, bucket, load factor, rehash — আর কেন এর ক্রমের ওপর ভরসা করা যায় না- নিজের টাইপকে চাবি বানানো, map বনাম unordered_map বনাম সাজানো vector বাছাই, আর AI-এর লেখা কোডে এসব ভুল ধরা
১ · মূল ধারণা — অবস্থান নয়, চাবি দিয়ে খোঁজা
পর্ব ৩০-এ আপনি sequence container দেখেছেন — vector, deque, list. এদের সবার একটি মিল: উপাদানগুলো একটি সারিতে থাকে, আর প্রতিটির একটি অবস্থান আছে — প্রথম, দ্বিতীয়, তৃতীয়। কিন্তু বাস্তবের বহু প্রশ্ন অবস্থান দিয়ে করা হয় না। “রোল ১০৪২-এর ফল কী?” “Karim-এর ফোন নম্বর কত?” “এই পণ্যের দাম কত?” — এখানে আপনার হাতে আছে একটি চাবি (রোল, নাম, পণ্যের কোড), আর আপনি চান তার সাথে যুক্ত তথ্য।
ফোনের contact list ভাবুন। আপনি কখনো বলেন না “৩৭ নম্বর contact-কে ফোন দাও” — বলেন “আম্মু”, “রহিম ভাই”। ফোন নামগুলো বর্ণানুক্রমে সাজিয়ে রাখে, তাই হাজার contact থেকেও “R” পর্যন্ত স্ক্রল করে মুহূর্তে পেয়ে যান। বাংলা অভিধানও তাই — শব্দটি জানলে পাতা উল্টে উল্টে সরাসরি পৌঁছান, প্রথম পাতা থেকে একটি একটি করে পড়েন না। চাবি জানলে খোঁজার পথ ছোট হয়ে যায় — associative container-এর পুরো ধারণা এটুকুই।
vector দিয়েও এ কাজ করা যায়: std::vector<Student> রেখে প্রতিবার শুরু থেকে লুপ চালিয়ে রোল মেলানো। ১০০ জন ছাত্রছাত্রীতে এটি টের পাওয়া যায় না। কিন্তু একটি বোর্ড পরীক্ষার ফলাফলে কয়েক লাখ রোল থাকে, আর সার্ভারে প্রতি সেকেন্ডে শত শত অনুরোধ আসে। প্রতিটি অনুরোধে লাখ উপাদানে লুপ (O(n)) মানে সার্ভার বসে যাওয়া। associative container এই খোঁজাকে করে O(log n) — দশ লাখ উপাদানে মোটামুটি বিশটি তুলনা — বা গড়ে O(1), অর্থাৎ আকার যত বড়ই হোক, প্রায় একই সময়।
প্রতিটি উপাদান তাই একটি key-value pair: চাবি (রোল ১০৪২) আর মান (GPA 4.75). C++ standard library-তে এমন আটটি container আছে, দুটি পরিবারে ভাগ করা:
| container | কী রাখে | ভেতরে | ক্রম | খোঁজা |
|---|---|---|---|---|
map | চাবি → মান, চাবি একবার | balanced tree | সাজানো | O(log n) |
set | শুধু চাবি, একবার | balanced tree | সাজানো | O(log n) |
multimap | চাবি → মান, চাবি বারবার | balanced tree | সাজানো | O(log n) |
multiset | শুধু চাবি, বারবার | balanced tree | সাজানো | O(log n) |
unordered_map | চাবি → মান, চাবি একবার | hash table | কোনো ক্রম নেই | গড়ে O(1) |
unordered_set | শুধু চাবি, একবার | hash table | কোনো ক্রম নেই | গড়ে O(1) |
unordered_multimap আর unordered_multiset — একই, তবে চাবি বারবার থাকতে পারে | ||||
সব associative container একটি প্রশ্নের উত্তর দ্রুত দেয়: “এই চাবিটি আছে কি, আর থাকলে তার সাথে কী আছে?” বিনিময়ে আপনি হারান অবস্থান দিয়ে ধরার সুবিধা — m[3] মানে “তৃতীয় উপাদান” নয়, “চাবি 3-এর মান”। কোন পরিবার বেছে নেবেন, তা নির্ভর করে আপনার ক্রম দরকার কি না তার ওপর।
২ · std::map — ভেতরে একটি সাজানো tree
std::map চাবিগুলো সবসময় সাজানো রাখে। C++ standard শুধু বলে: খোঁজা, ঢোকানো আর মোছা হবে logarithmic সময়ে, আর ক্রমে হাঁটা যাবে। কোন data structure দিয়ে, তা বলে না। কিন্তু GCC-র libstdc++, Clang-এর libc++ আর Microsoft-এর STL — তিনটিই একই জিনিস ব্যবহার করে: red-black tree, এক ধরনের balanced tree.
ভিত্তিটা একটি binary search tree (BST): প্রতিটি node-এর বাম দিকে সব ছোট চাবি, ডান দিকে সব বড়। খুঁজতে মূল (root) থেকে শুরু করে প্রতিটি node-এ একটি প্রশ্ন — “ছোট না বড়?” — আর প্রতি উত্তরে প্রায় অর্ধেক tree বাদ। কিন্তু সাধারণ BST-এর একটি দুর্বলতা আছে: চাবিগুলো যদি আগে থেকেই সাজানো ক্রমে আসে (রোল ১, ২, ৩, ৪…), প্রতিটি নতুন node আগেরটির ডানে বসে, আর tree হয়ে যায় একটি লম্বা শিকল — খোঁজা আবার O(n). red-black tree প্রতিবার ঢোকানোর পর ছোট একটি পুনর্বিন্যাস (rotation) করে উচ্চতা নিয়ন্ত্রণে রাখে। ঘটনাটি ধাপে ধাপে দেখুন — প্রতিটি ধাপে আগে নিজে অনুমান করুন, নতুন চাবি কোথায় বসবে:
10(B) B = কালো node, R = লাল node নিয়ম: মূল সবসময় কালো
10(B)
\
20(R) ← নতুন node সবসময় লাল হয়ে ঢোকে
10(B)
\
20(R)
\
30(R) ← লাল বাবার লাল সন্তান: নিয়ম ভাঙল!
সাধারণ BST হলে এভাবেই থাকত — একটি শিকল,
খুঁজতে n ধাপ।
20(B)
/ \
10(R) 30(R)
বামে ছোট, ডানে বড় — ক্রম অটুট।
উচ্চতা ৩ থেকে কমে ২।
20(B)
/ \
10(B) 30(B)
/
25(R)
শুধু রং বদল (rotation লাগেনি)।
ক্রমে হাঁটলে (বাম → node → ডান): 10 20 25 30
- প্রথম চাবি। খালি tree-এ 10 হয়ে গেল মূল। red-black tree-এর নিয়মে মূল সবসময় কালো। map-এর প্রতিটি উপাদান এমন একটি node — heap-এ আলাদা করে নেওয়া এক টুকরো মেমরি, যাতে চাবি, মান আর তিনটি pointer (বাবা, বাম, ডান) থাকে।
- তুলনা করে জায়গা খোঁজা। 20 মূলের চেয়ে বড়, তাই ডানে। নতুন node লাল রঙে ঢোকে। খেয়াল করুন, map চাবিগুলোর মধ্যে একটিই প্রশ্ন করে —
a < b? এর বাইরে চাবি সম্পর্কে সে আর কিছু জানে না। - শিকলের বিপদ। 30 আবার ডানে, তারও ডানে। সাধারণ BST এভাবে সাজানো ইনপুটে শিকল হয়ে যেত, আর এক লাখ রোলে খুঁজতে লাগত এক লাখ ধাপ। red-black tree দেখে, লাল একটি node-এর সন্তানও লাল — নিয়ম ভাঙা, মানে tree একদিকে হেলে পড়ছে।
- Rotation. tree 10-কে নিচে নামিয়ে 20-কে ওপরে তুলল, তারপর রং ঠিক করল। এটি কয়েকটি pointer বদলানো মাত্র — কোনো উপাদান কপি বা সরানো হয়নি। তাই map-এ নতুন উপাদান ঢোকালে আগের কোনো iterator, pointer বা reference অচল হয় না — vector-এর সাথে বড় পার্থক্য।
- আবার ঢোকানো, আবার ক্রম। 25 দুটি তুলনায় জায়গা পেল। এবার শুধু রং বদলে নিয়ম রক্ষা হলো। red-black tree-এর নিয়মগুলো নিশ্চিত করে যে উচ্চতা কখনো 2·log₂(n+1)-এর বেশি হয় না — দশ লাখ চাবিতেও সর্বোচ্চ চল্লিশের মতো ধাপ। আর বাম → node → ডান ক্রমে হাঁটলে চাবিগুলো আপনিই সাজানো আসে।
এই ছবি থেকে map-এর তিনটি স্বভাব বেরিয়ে আসে, যা মনে রাখার মতো। এক, লুপ চালালে উপাদান আসে চাবির ক্রমে — আলাদা করে sort করতে হয় না। দুই, প্রতিটি উপাদান আলাদা node-এ, তাই map একটি node-based container: ঠিকানা স্থির থাকে, কিন্তু node-গুলো মেমরিতে ছড়িয়ে থাকে, আর vector-এর মতো cache-বান্ধব নয়। তিন, চাবির টাইপের জন্য শুধু “ছোট কি না” তুলনা লাগে — ডিফল্টভাবে operator<.
৩ · map-এর সাথে কাজ — ঢোকানো, খোঁজা, হাঁটা, মোছা
একটি শব্দ-গণনার প্রোগ্রাম দিয়ে শুরু করুন — অনুসন্ধান ইঞ্জিন থেকে শুরু করে ভাষা-মডেলের ডেটা প্রস্তুতিতে এই কাজটি আসল কাজের প্রথম ধাপ। বাক্যটি বাংলা, তবে রোমান হরফে লেখা, যাতে যেকোনো অনলাইন compiler-এ আউটপুট ঠিক দেখায়:
#include <map>
#include <print>
#include <sstream>
#include <string>
int main() {
std::istringstream in("ami cha khai ami bhat khai ami");
std::map<std::string, int> freq; // word -> count, sorted by word
for (std::string w; in >> w; ) ++freq[w]; // missing word starts at 0
for (const auto& [word, n] : freq) std::println("{:<5} {}", word, n);
std::println("size before: {}", freq.size());
if (freq["dim"] == 0) std::println("no dim"); // trap: this INSERTS "dim"
std::println("size after: {}", freq.size());
if (auto it = freq.find("cha"); it != freq.end())
std::println("cha -> {}", it->second); // one lookup, no insert
std::println("has rice? {}", freq.contains("rice"));
}আউটপুট: প্রথমে চারটি লাইন — ami 3, bhat 1, cha 1, khai 2 — শব্দগুলো বাক্যের ক্রমে নয়, বর্ণানুক্রমে, কারণ map চাবি সাজিয়ে রাখে। তারপর size before: 4, no dim, size after: 5, cha -> 1 আর has rice? false. লুপের ++freq[w] এখানে ইচ্ছাকৃত ও সঠিক: নতুন শব্দে [] একটি 0 বসায়, আর ++ সেটিকে 1 করে। কিন্তু freq["dim"] == 0 শুধু “আছে কি না” দেখার জন্য লেখা — তবু map-এ একটি নতুন উপাদান ঢুকে গেল, আকার 4 থেকে 5. পরের অংশে এই ফাঁদটি ধাপে ধাপে দেখব। find একবার খুঁজে iterator দেয়, আর contains শুধু হ্যাঁ/না — কোনোটিই কিছু ঢোকায় না।
লুপের auto& [word, n] হলো পর্ব ১২-এর structured binding. map-এর প্রতিটি উপাদান আসলে একটি std::pair<const std::string, int> — first চাবি, second মান। structured binding দুটিকে সুন্দর নাম দেয়। খেয়াল করুন চাবির টাইপে const: map-এর ভেতরে থাকা অবস্থায় চাবি বদলানো যায় না (কেন, তা ভাবনার প্রশ্ন ৫-এ)। আর auto& না লিখে auto লিখলে প্রতিটি pair কপি হতো — বড় string-এ অকারণ খরচ।
ঢোকানোর চারটি উপায় — কোনটি কখন
map-এ উপাদান ঢোকানোর একাধিক ফাংশন আছে, আর এদের পার্থক্য হলো একটি প্রশ্নে: চাবিটি আগে থেকেই থাকলে কী হবে?
std::map<int, std::string> result; // roll -> grade
result.insert({1042, "A+"}); // নতুন: ঢুকল
result.insert({1042, "B"}); // আছে: কিছুই বদলায় না! মান এখনো "A+"
auto [it, added] = result.insert({1043, "A"}); // added == true
result.try_emplace(1044, "A-"); // C++17: না থাকলে বানাও, থাকলে ছোঁবেও না
result.insert_or_assign(1042, "A"); // C++17: থাকলে বদলাও, না থাকলে ঢোকাও
result[1045] = "B+"; // কাজ করে, কিন্তু আগে ডিফল্ট "" বানিয়ে তারপর বদলায়
result.erase(1043); // চাবি দিয়ে মোছা; ফেরত দেয় কয়টি মুছল (0 বা 1)insert পুরোনো মান কখনো বদলায় না — এটি অনেককে অবাক করে। ফেরত আসা জোড়ার bool বলে দেয় ঢোকানো হলো কি না। try_emplace একই কাজ করে, কিন্তু আরও সাশ্রয়ী: চাবি থাকলে আর্গুমেন্ট থেকে কোনো object বানায়ও না, move-ও করে না। insert_or_assign হলো “থাকলে বদলাও” — আর m[k] = v-এর মতো হলেও এটি ডিফল্ট মান বানানোর বাড়তি ধাপ নেয় না, আর জানিয়ে দেয় নতুন ঢুকল কি না। এই দুটি C++17 থেকে আছে।
৪ · operator[]-এর ফাঁদ — পড়তে গিয়ে লিখে ফেলা
অন্য ভাষা থেকে আসা প্রোগ্রামারদের কাছে m[key] দেখতে নিছক “পড়া”। Python-এ d["x"] চাবি না থাকলে error দেয়। C++-এ নিয়মটা আলাদা: m[key] সবসময় একটি reference ফেরত দেয়, যাতে আপনি m[key] = 5 লিখতে পারেন। কিন্তু চাবিটি না থাকলে reference দেওয়ার মতো কিছু নেই — তাই C++ আগে একটি ডিফল্ট মান দিয়ে নতুন উপাদান ঢুকিয়ে দেয়, তারপর তার reference দেয়। int-এর ডিফল্ট 0, string-এর ফাঁকা "". কোনো error নেই, কোনো warning নেই। একটি দোকানের স্টক-হিসাবে ঘটনাটি দেখুন:
1std::map<std::string, int> stock{{"pen", 5}};2if (stock["pencil"] > 0) std::println("in stock");3std::println("items: {}", stock.size());4if (auto it = stock.find("eraser");5 it != stock.end() && it->second > 0) { ... }
| উপাদান | |
| size() | |
| যা ঘটল |
- শুরু। স্টকে শুধু একটি পণ্য — pen, ৫টি। map-এর আকার 1.
- “pencil আছে কি?” প্রোগ্রামার শুধু জানতে চাইলেন pencil স্টকে আছে কি না।
operator[]tree-তে "pencil" খুঁজল — পেল না। - নীরব ঢোকানো। না পেয়ে
[]একটি নতুন node বানাল: চাবি "pencil", মানint{}— অর্থাৎ 0. তারপর সেই 0-এর reference ফেরত দিল। শর্ত0 > 0মিথ্যা, তাই কিছু ছাপা হলো না। ফলাফল আপাতত “ঠিক”, কিন্তু map বদলে গেছে। - পরিণাম। এখন map-এ দুটি পণ্য। রিপোর্টে “pencil: 0” দেখাবে — যে পণ্য দোকান কখনো রাখেইনি। লাখ লাখ ভুল বানানের খোঁজে (যেমন একটি সার্চ বক্স থেকে) map অকারণে ফুলে উঠবে, মেমরি বাড়বে। আর যে map একাধিক thread থেকে শুধু “পড়া” হচ্ছিল, সেখানে এই গোপন লেখা data race ঘটাতে পারে (পর্ব ৪৫)।
- সঠিক পথ।
findখোঁজে, iterator দেয়, কিছু বদলায় না। না পেলেend(). শুধু হ্যাঁ/না লাগলে C++20-এরcontains. খেয়াল করুন, একবারfindকরে সেই iterator-ই ব্যবহার করা হয়েছে — আবারstock["eraser"]লিখে দ্বিতীয়বার খোঁজা হয়নি।
এই ফাঁদের দুটি পার্শ্ব-প্রমাণ আছে, যা compiler নিজেই দেয়। প্রথমত, একটি const std::map-এ operator[] ডাকাই যায় না — compile error — কারণ সেটি map বদলাতে পারে। তাই ফাংশনে const std::map<...>& নিলে আপনি বাধ্য হন find বা at ব্যবহার করতে। দ্বিতীয়ত, মানের টাইপের default constructor না থাকলে [] compile হয় না, কারণ ডিফল্ট মান বানানোর উপায় নেই।
| লিখলে | চাবি না থাকলে | কখন ব্যবহার করবেন |
|---|---|---|
m[k] | ডিফল্ট মান ঢুকিয়ে দেয় | যখন ঢোকানোই উদ্দেশ্য — যেমন ++count[w] |
m.at(k) | std::out_of_range exception | চাবি থাকা “নিশ্চিত”, না থাকলে সেটি বাগ |
m.find(k) | end() ফেরত দেয় | খুঁজে পেলে মানটিও লাগবে — একবার খোঁজা |
m.contains(k) | false | শুধু হ্যাঁ/না (C++20 থেকে; আগে count(k)) |
আরেকটি সাধারণ অপচয়: if (m.contains(k)) use(m[k]); বা if (m.find(k) != m.end()) return m[k];. এখানে একই চাবি দুবার খোঁজা হয় — প্রথমবার শর্তে, দ্বিতীয়বার []-এ। map-এ এটি দুটি O(log n) পথ, আর কোড পরে বদলালে দ্বিতীয় লাইনটি নীরবে ঢোকানোর কাজও শুরু করতে পারে। একবার find করে iterator রেখে দিন।
৫ · set, multimap আর multiset
অনেক সময় চাবির সাথে কোনো মান লাগে না — শুধু জানতে চান কোনো কিছু “তালিকায় আছে কি না”। একটি ওয়েবসাইটে আজ কতজন আলাদা দর্শক এসেছেন? একই দর্শক দশবার পাতা খুললেও তিনি একজন। এখানে std::set: প্রতিটি মান একবারই থাকে, সাজানো ক্রমে।
std::set<int> visitors;
for (int id : {7, 3, 7, 9, 3, 7}) visitors.insert(id); // একই id আবার এলে কিছুই হয় না
// visitors = {3, 7, 9}, visitors.size() == 3
auto [pos, isNew] = visitors.insert(5); // isNew == true
std::set<int> uniq(v.begin(), v.end()); // একটি vector থেকে আলাদা মানগুলো, সাজানোset-এর উপাদান চাবি নিজেই, তাই iterator দিয়ে সেটি বদলানো যায় না — *it = 10 compile error. বদলাতে চাইলে পুরোনোটি মুছে নতুনটি ঢোকাতে হয়।
std::multimap আর std::multiset একই চাবি একাধিকবার রাখতে দেয়। একই দামের দুটি পণ্য, একই নম্বর পাওয়া কয়েকজন ছাত্রছাত্রী — এমন ক্ষেত্রে। multimap-এ operator[] নেই (কোন মানটির reference দেবে?); একই চাবির সবগুলো পেতে equal_range(k). আর multiset-এ একটি সূক্ষ্ম ফাঁদ: ms.erase(5) সব 5 মুছে দেয়; শুধু একটি মুছতে চাইলে ms.erase(ms.find(5)) — আর তার আগে নিশ্চিত হোন find end() দেয়নি।
৬ · unordered_map — hash table: চাবি থেকে সরাসরি ঘরে
map প্রতিটি খোঁজায় tree বেয়ে নামে — log n ধাপ। এর চেয়ে দ্রুত হতে পারে কি? একটি সরকারি অফিসের নথি-ঘর ভাবুন, যেখানে ১০০টি তাক। নিয়ম: ফাইল নম্বরের শেষ দুই অঙ্ক দেখে সেই তাকে রাখা হবে। ফাইল ৪৫৬৭৩ যায় তাক ৭৩-এ। খুঁজতেও একই হিসাব — সরাসরি তাক ৭৩, সেখানে হয়তো তিন-চারটি ফাইল, একটু দেখলেই পাওয়া যায়। তাক যত বেশি, প্রতি তাকে ফাইল তত কম। এটাই hash table.
C++11 থেকে std::unordered_map আর std::unordered_set এভাবেই কাজ করে। তিনটি অংশ: একটি hash function (চাবি থেকে একটি বড় পূর্ণসংখ্যা — standard library-তে std::hash), একসারি bucket (তাক), আর একই bucket-এ পড়া চাবিদের তালিকা। নিচের সংখ্যাগুলো বোঝানোর জন্য বানানো; আসল hash অনেক বড় সংখ্যা, আর bucket-এর সংখ্যাও বাস্তবায়নভেদে আলাদা:
- চাবি
"rahim"যা খুঁজছেন - hash
9137std::hash - bucket
9137 % 8 = 1কোন তাক - তালিকা
== দিয়ে মেলানোcollision হলে - পাওয়া গেল
iteratorনা পেলে end()
std::hash<std::string>{}("rahim") → 9137 (উদাহরণের সংখ্যা)
একই চাবি থেকে সবসময় একই সংখ্যা আসবে —
এটাই hash function-এর একমাত্র বাধ্যতামূলক নিয়ম।
bucket = 9137 % 8 = 1 [0] — [1] → rahim:90 [2] — [3] — [4] — [5] — [6] — [7] —
hash("karim") = 6017, 6017 % 8 = 1 ← আবার bucket 1!
[1] → rahim:90 → karim:75 (একই bucket-এ একটি তালিকা)
[4] → nila:88 (hash 4100 % 8 = 4)
ভিন্ন চাবি, একই bucket — একে বলে collision.
hash("karim") = 6017 → bucket 1
rahim == karim ? না
karim == karim ? হ্যাঁ → iterator ফেরত
পুরো table নয়, শুধু একটি bucket-এর ছোট তালিকা দেখা হলো।
load factor = size / bucket_count = 9 / 8 = 1.125 max_load_factor() = 1.0 → সীমা পার! bucket বাড়ানো হলো (যেমন 8 → 17), প্রতিটি চাবির bucket নতুন করে হিসাব: 9137 % 17 = 8, 6017 % 17 = 16 ... → সব iterator অচল; উপাদানের pointer/reference ঠিক থাকে।
- চাবি থেকে সংখ্যা।
std::hashচাবি "rahim" থেকে একটি পূর্ণসংখ্যা বানায়। শর্ত একটাই: একই চাবি থেকে সবসময় একই সংখ্যা, আর যে দুটি চাবি==হিসেবে সমান, তাদের hash-ও সমান। ভালো hash ভিন্ন চাবিগুলোকে সব bucket-এ সমানভাবে ছড়ায়। - সংখ্যা থেকে bucket. বিশাল সংখ্যাটিকে bucket-এর সংখ্যা দিয়ে ভাগ করে ভাগশেষ নেওয়া হয় (বাস্তবায়ন অন্য কৌশলও নিতে পারে)। এখানে bucket 1. কোনো তুলনা নেই, কোনো tree নেই — সরাসরি ঠিকানা হিসাব। এজন্যই গড়ে O(1).
- Collision স্বাভাবিক। "karim"-এর hash ভিন্ন, কিন্তু ৮ দিয়ে ভাগশেষ সেই 1. দুটি চাবি এখন একই bucket-এ, একটি ছোট তালিকায় — এই কৌশলের নাম separate chaining. hash collision কোনো ভুল নয়; চাবি অসীম, bucket সীমিত, তাই এটি হবেই।
- খোঁজা = hash + ছোট তালিকা। find আবার hash হিসাব করে bucket 1-এ যায়, তারপর তালিকার প্রতিটি চাবির সাথে
==মেলায়। তালিকা ছোট থাকলে কাজ প্রায় তাৎক্ষণিক। তাই unordered_map-এর চাবির টাইপের জন্য দুটি জিনিস লাগে: একটি hash আরoperator==—<লাগে না। - Rehash. উপাদান বাড়লে তালিকাগুলো লম্বা হয়। গড়ে প্রতি bucket-এ কয়টি উপাদান — একে বলে load factor. এটি
max_load_factor()(ডিফল্ট 1.0) ছাড়ালে table আরও bucket নেয় আর সব উপাদানকে নতুন bucket-এ সাজায় — rehash. খরচ O(n), কিন্তু কদাচিৎ হয় বলে গড়ে ঢোকানো O(1) থাকে। rehash-এর পর আগের সব iterator অচল (iterator invalidation), যদিও node-গুলো সরে না বলে উপাদানের pointer আর reference ঠিক থাকে। আকার আগে জানা থাকলেreserve(n)দিয়ে rehash এড়ানো যায়।
ক্রমের ওপর কখনো ভরসা নয়
unordered_map-এ লুপ চালালে উপাদান আসে bucket-এর ক্রমে — যা নির্ভর করে hash-এর মান, bucket-এর সংখ্যা, ঢোকানোর ইতিহাস আর library-র বাস্তবায়নের ওপর। GCC-তে এক ক্রম, MSVC-তে আরেক; একই প্রোগ্রামে আরও কয়েকটি উপাদান ঢোকালে rehash হয়ে পুরো ক্রমই বদলে যেতে পারে। তাই unordered_map-এর লুপের আউটপুট কখনো পরীক্ষার “প্রত্যাশিত ফল” বানাবেন না, আর কাউকে সাজানো তালিকা দেখাতে চাইলে map ব্যবহার করুন, অথবা উপাদানগুলো একটি vector-এ নিয়ে sort করুন। এ কারণেই এই পর্বের কোনো চালানো কোড unordered_map-এর লুপ ছাপায় না।
খারাপ ক্ষেত্র আর অবিশ্বস্ত চাবি
“গড়ে O(1)” কথাটার “গড়ে” অংশটি গুরুত্বপূর্ণ। যদি অনেক চাবি একই bucket-এ পড়ে, সেই bucket-এর তালিকা লম্বা হয়, আর খোঁজা নেমে আসে O(n)-এ — সব চাবি এক bucket-এ পড়লে hash table একটি linked list ছাড়া কিছু নয়। সাধারণ ডেটায় এটি প্রায় কখনো হয় না। কিন্তু চাবিগুলো যদি বাইরের, অবিশ্বস্ত কারও কাছ থেকে আসে — একটি ওয়েব ফর্মের ফিল্ডের নাম, নেটওয়ার্ক থেকে আসা ID — তাহলে কেউ ইচ্ছা করে বেছে বেছে একই bucket-এর চাবি পাঠিয়ে সার্ভারকে ধীর করে দিতে পারে। এই ঝুঁকির নাম hash flooding. প্রতিরক্ষার সাধারণ উপায়: একটি অনুরোধে চাবির সংখ্যা সীমিত রাখা, প্রোগ্রাম চালুর সময় এলোমেলো বীজ (seed) মেশানো hash ব্যবহার করা, বা সংবেদনশীল জায়গায় map-এর মতো tree-ভিত্তিক container, যার খারাপ ক্ষেত্রও O(log n). C++ standard std::hash-কে এলোমেলো করার বাধ্যবাধকতা দেয় না, আর প্রচলিত বাস্তবায়নে এটি নির্দিষ্ট (deterministic) — যেমন GCC-র libstdc++-এ একটি int-এর hash সাধারণত সংখ্যাটি নিজেই।
৭ · নিজের টাইপকে চাবি বানানো
int বা string চাবি হিসেবে সরাসরি চলে, কারণ এদের তুলনা আর hash দুটোই তৈরি আছে। কিন্তু যদি চাবি হয় একটি struct — ধরুন একটি পরীক্ষার আসন: ভবন আর কক্ষ নম্বর? তখন container-কে বলে দিতে হয়, এই টাইপকে কীভাবে তুলনা বা hash করবে।
map আর set-এর জন্য — “ছোট কি না”
map ডিফল্টভাবে std::less<Key> ব্যবহার করে, যা a < b ডাকে। সবচেয়ে সহজ পথ পর্ব ২৩-এর <=> (three-way comparison) default করে দেওয়া — compiler সদস্যগুলো ঘোষণার ক্রমে তুলনা করে:
struct Seat {
int building, room;
auto operator<=>(const Seat&) const = default; // C++20: <, ==, > সব তৈরি
};
std::map<Seat, std::string> plan;
plan[{2, 305}] = "roll 1042"; // building আগে, তারপর room দিয়ে সাজানো
// নিজের নিয়ম চাইলে তুলনার object দিন, যেমন বড় থেকে ছোট:
std::map<int, std::string, std::greater<>> rank;নিজে তুলনা লিখলে একটি কঠিন নিয়ম মানতে হয়: strict weak ordering. সহজ ভাষায় — a < a কখনো সত্য নয়, a < b আর b < c হলে a < c, আর যে দুটি চাবির কোনোটিই অন্যটির চেয়ে ছোট নয়, map তাদের “একই চাবি” ধরে। সবচেয়ে সাধারণ ভুল <= দিয়ে তুলনা লেখা — তখন a <= a সত্য, আর map-এর আচরণ undefined: খোঁজা ব্যর্থ হতে পারে, একই চাবি দুবার ঢুকতে পারে। আরেকটি ফাঁদ: তুলনায় শুধু building দেখলে একই ভবনের সব কক্ষ map-এর চোখে “একই চাবি” — দ্বিতীয়টি আর ঢুকবে না।
unordered_map-এর জন্য — hash আর ==
unordered container-এর লাগে দুটি জিনিস: operator== আর একটি hash. hash দেওয়ার প্রচলিত উপায় std::hash-এর একটি template specialization লেখা — নিজের টাইপের জন্য namespace std-এ এটি লেখার অনুমতি standard দেয়:
struct Seat {
int building, room;
bool operator==(const Seat&) const = default;
};
template <> struct std::hash<Seat> {
std::size_t operator()(const Seat& s) const noexcept {
std::size_t h = std::hash<int>{}(s.building);
return h * 31 + std::hash<int>{}(s.room); // সরল মিশ্রণ; শেখার জন্য যথেষ্ট
}
};
std::unordered_map<Seat, std::string> seatOf; // এখন চলবেশুধু h1 ^ h2 (XOR) লেখা একটি পরিচিত দুর্বল অভ্যাস: {1, 2} আর {2, 1}-এর hash একই হয়, আর {5, 5}-এর মতো সমান জোড়া সবই 0 দেয় — collision বাড়ে। গুণ করে মেশানো একটু ভালো; গুরুত্বপূর্ণ কাজে পরীক্ষিত একটি hash-combine ব্যবহার করুন — যেমন Boost-এর hash_combine. আর নিয়মটি ভুলবেন না: যে দুটি চাবি ==, তাদের hash অবশ্যই সমান হতে হবে — hash-এ যে সদস্য নেই, ==-তে থাকতে পারে, কিন্তু উল্টোটা চলবে না।
৮ · Range query — lower_bound আর upper_bound
সাজানো থাকার সবচেয়ে বড় পুরস্কার এখানে। একটি অনলাইন দোকানে ক্রেতা ফিল্টার দিলেন: “দাম ৫০০ থেকে ১০০০ টাকা”। unordered_map-এ এর একমাত্র উপায় সব পণ্যে লুপ চালানো। কিন্তু দাম যদি সাজানো চাবি হয়, তাহলে ৫০০-এর জায়গায় সরাসরি নেমে গিয়ে ১০০০ পার হওয়া পর্যন্ত হাঁটলেই হয়। এই দুই জায়গা খুঁজে দেয় দুটি ফাংশন: lower_bound আর upper_bound. একই দামের দুটি পণ্য থাকতে পারে, তাই এখানে multimap:
#include <iterator>
#include <map>
#include <print>
#include <string>
int main() {
std::multimap<int, std::string> byPrice{ // price -> product, sorted
{450, "pen set"}, {1200, "headphone"}, {650, "mouse"},
{980, "keyboard"}, {500, "notebook"}, {650, "USB hub"},
{1000, "power bank"}};
auto from = byPrice.lower_bound(500); // first price >= 500
auto to = byPrice.upper_bound(1000); // first price > 1000
for (auto it = from; it != to; ++it)
std::println("{:>5} {}", it->first, it->second);
std::println("items in 500..1000: {}", std::distance(from, to));
}আউটপুট ছয় লাইন: 500 notebook, 650 mouse, 650 USB hub, 980 keyboard, 1000 power bank (দাম ডানদিকে সারিবদ্ধ), আর শেষে items in 500..1000: 5. পণ্যগুলো এলোমেলো ক্রমে দেওয়া হলেও multimap দাম অনুযায়ী সাজিয়ে রেখেছে; একই দামের দুটি (650) ঢোকানোর ক্রমে আছে — multimap এটি নিশ্চিত করে। 450 আর 1200 বাদ পড়েছে। দুটি খোঁজাই O(log n), তারপর হাঁটা শুধু ফলাফলের k-টি উপাদানে — মোট O(log n + k), দোকানে লাখ পণ্য থাকলেও।
দুটি ফাংশনের পার্থক্য একটি অক্ষরে: lower_bound দেয় প্রথম উপাদান যা >= চাবি, upper_bound দেয় প্রথম উপাদান যা > চাবি। জোড়াটি তাই একটি অর্ধ-খোলা পরিসর [from, to) বানায় — STL-এর সব পরিসরের মতো (পর্ব ৩২-এ iterator নিয়ে বিস্তারিত)। ধাপে ধাপে দেখুন:
1// সাজানো: 450 500 650 650 980 1000 12002auto from = byPrice.lower_bound(500);3auto to = byPrice.upper_bound(1000);4for (auto it = from; it != to; ++it) print5// lower_bound(1100) == upper_bound(1150)
| from | |
| to | |
| ফল |
- সাজানো সারি। tree-এর ভেতরে যা-ই থাকুক, iterator-এর চোখে map বা multimap একটি সাজানো সারি। এই সারির ওপর দুটি দাগ টানাই range query.
- নিচের দাগ।
lower_bound(500)tree বেয়ে নেমে খোঁজে প্রথম উপাদান, যার দাম 500-এর কম নয় — notebook, দাম 500. 500 দামের পণ্য না থাকলে এটি দিত 650-এ। 450 এর আগে, তাই বাদ। - ওপরের দাগ।
upper_bound(1000)খোঁজে প্রথম উপাদান, যার দাম 1000-এর বেশি — headphone, দাম 1200. এটি ফলাফলের অংশ নয়; এটি “এখানে থামুন” চিহ্ন। তাই 1000 দামের power bank ফলাফলে থাকে। - দাগের মাঝে হাঁটা। from থেকে শুরু করে to-তে পৌঁছানো পর্যন্ত
++it. পাঁচটি পণ্য ছাপা হলো, ক্রমানুসারে। পুরো দোকানের উপাদান একবারও ছোঁয়া হয়নি — শুধু দুটি log n খোঁজা আর ফলাফলের পাঁচটি ধাপ। - খালি পরিসর। 1100 থেকে 1150-এর মধ্যে কোনো দাম নেই। তখন দুটি ফাংশনই 1200-কে দেখায়,
from == to, আর লুপ একবারও চলে না। আলাদা কোনো if লাগে না — অর্ধ-খোলা পরিসরের এই সৌন্দর্যই STL-এর নকশার ভিত্তি।
map-এ সবসময় member m.lower_bound(x) ডাকুন, std::lower_bound(m.begin(), m.end(), x) নয়। দ্বিতীয়টি compile হয় (মানের টাইপ মিলিয়ে লিখলে), কিন্তু map-এর iterator এক লাফে মাঝখানে যেতে পারে না, এক ঘর এক ঘর করে হাঁটে — ফলে সময় O(n). member ফাংশনটি সরাসরি tree বেয়ে নামে: O(log n). find, count-এর ক্ষেত্রেও একই কথা।
C-র standard library-তে কোনো map বা hash table নেই। সাজানো array-তে bsearch চালানো যায়, নয়তো BST বা hash table নিজের হাতে লিখতে হয় — malloc, pointer আর free-সহ। C++-এ একই data structure তৈরি অবস্থায় আসে, যেকোনো টাইপের জন্য (template), আর মেমরি নিজেই সামলায়। তবে ভেতরের কাজ বোঝা কাজে দেয়: binary search আর অর্ধেক বাদ দেওয়ার যুক্তি দেখুন C কোর্সের সার্চিং ও Divide & Conquer পর্বে, আর হাতে-বানানো tree ও hash দেখুন ট্রি ও BST আর হ্যাশ টেবিল পর্বে।
৯ · কোনটি বেছে নেবেন — map, unordered_map, নাকি সাজানো vector
Big-O-র হিসাবে unordered_map-ই জয়ী মনে হয়: O(1) বনাম O(log n). কিন্তু বাস্তবের গতি নির্ধারণে বড় ভূমিকা রাখে মেমরি। map আর unordered_map দুটিই প্রতিটি উপাদানের জন্য আলাদা heap node নেয়। খোঁজা মানে pointer ধরে ধরে মেমরির দূর দূর জায়গায় লাফ — আর প্রতিটি লাফ cache-এ না পেলে CPU-কে অপেক্ষা করতে হয়। অন্যদিকে একটি সাজানো vector-এর সব উপাদান পাশাপাশি (cache locality), আর std::ranges::lower_bound দিয়ে তাতে binary search-ও O(log n). কয়েক হাজার উপাদান পর্যন্ত, যা একবার বানিয়ে বারবার পড়া হয়, সাজানো vector প্রায়ই দুটি map-কেই হারায়।
std::map / std::set
ক্রম লাগবে, range query লাগবে, বা সাজানো আউটপুট লাগবে। খারাপ ক্ষেত্রেও O(log n), তাই অবিশ্বস্ত চাবিতে নিরাপদ। ঢোকানো-মোছায় অন্য উপাদানের iterator অটুট।
sorted · predictablestd::unordered_map / set
শুধু “আছে কি, কত?” — ক্রম অপ্রয়োজনীয়, অনেক ঢোকানো-খোঁজা। চাবির ভালো hash থাকতে হবে। আকার জানা থাকলে reserve.
সাজানো vector / flat_map
ডেটা একবার বানিয়ে বারবার পড়া হয় (যেমন জেলা কোডের তালিকা)। মাঝখানে ঢোকানো O(n), কিন্তু খোঁজা আর হাঁটা খুব cache-বান্ধব। C++23 থেকে std::flat_map.
std::flat_map (আর flat_set) C++23-এ <flat_map> header-এ এসেছে। এটি map-এর মতো interface দেয়, কিন্তু ভেতরে চাবি আর মান দুটি আলাদা সাজানো vector-এ রাখে। ধারণাটি নতুন নয় — Boost.Container বহু বছর ধরে flat_map দিয়ে আসছে। তবে C++23 নতুন বলে সব compiler-এর library-তে এটি সমানভাবে তৈরি না-ও থাকতে পারে; ব্যবহারের আগে নিজের compiler-এ দেখে নিন। আর শেষ কথা: অনুমানে নয়, মেপে বেছে নিন (পর্ব ৪৯)। ১০০টি উপাদানে container-এর পার্থক্য প্রায় কখনো গুরুত্বপূর্ণ নয়; পরিষ্কার কোডই সেখানে বড় জয়।
map আর set এসেছে Alexander Stepanov-এর STL থেকে, যা ১৯৯৪ সালে C++ standard কমিটি গ্রহণ করে আর C++98-এ অন্তর্ভুক্ত হয়। hash table-এর প্রস্তাবও তখন উঠেছিল, কিন্তু standard চূড়ান্ত করার সময়সীমা পেরিয়ে যাওয়ায় বাদ পড়ে। ফলে পরের দশকে প্রায় প্রতিটি library বিক্রেতা নিজের hash_map বানায় — SGI-এর একটি, Microsoft-এর আরেকটি — একই নামে ভিন্ন আচরণ। এই বিশৃঙ্খলা এড়াতে standard-এ নতুন নাম রাখা হয় unordered_map; এটি প্রথমে আসে Technical Report 1 (TR1)-এ std::tr1 namespace-এ, তারপর C++11-এ পুরোপুরি std-এ। contains এল আরও পরে, C++20-এ।
- map আর unordered_map-এর পার্থক্য কী? — map সাজানো, balanced tree (সাধারণত red-black), সব কাজ O(log n); unordered_map ক্রমহীন hash table, গড়ে O(1), খারাপ ক্ষেত্রে O(n).
m[k]আরm.find(k)-এর পার্থক্য? —[]চাবি না থাকলে ডিফল্ট মান ঢুকিয়ে reference দেয় (const map-এ চলে না); find কিছু বদলায় না, না পেলেend().- একটি array-তে কয়টি আলাদা মান আছে? — সব মান একটি
std::setবাunordered_set-এ ঢুকিয়েsize(); set সাজানো ক্রমও দেয়। - map-এর চাবি কি বদলানো যায়? — সরাসরি না; চাবির টাইপ
const, কারণ বদলালে tree-এর ক্রম ভেঙে যেত। মুছে নতুন করে ঢোকাতে হয় — অথবা C++17-এরextractদিয়ে। - map-এ insert করলে পুরোনো iterator কি অচল হয়? — না; শুধু মুছে ফেলা উপাদানের iterator অচল হয়। unordered_map-এ rehash হলে সব iterator অচল হয়।
১০ · AI-এর যুগে — map-এর কোডে যে ভুলগুলো খুঁজবেন
AI টুল map আর unordered_map-এর কোড খুব সাবলীলভাবে লেখে, আর প্রায় সবসময় তা compile হয়। কিন্তু এই container-গুলোর ভুল বেশিরভাগই নীরব: ভুল container বাছাই, অকারণ দ্বিগুণ খোঁজা, আর [] দিয়ে “খুঁজতে গিয়ে” ঢুকিয়ে ফেলা। অনলাইনের প্রচুর পুরোনো কোডে if (m.count(k)) x = m[k]; ধরনের লাইন আছে, তাই AI-ও সেই রীতি শিখেছে। সাধারণ ভুলগুলো:
| AI যা করে | সমস্যা | যা হওয়া উচিত |
|---|---|---|
খোঁজার জন্য m[k] | না থাকলে নীরবে ডিফল্ট মান ঢোকে; const map-এ compile হয় না | find, contains বা at |
find/count-এর পরে আবার m[k] | একই চাবি দুবার খোঁজা | একবার find, iterator রেখে দেওয়া |
| unordered_map-এর লুপের ক্রম ধরে নিয়ে আউটপুট বা টেস্ট | compiler, আকার বা rehash বদলালেই ক্রম বদলায় | map, অথবা vector-এ নিয়ে sort |
সবখানে std::map, ক্রম লাগুক বা না লাগুক | অকারণে O(log n) আর ছড়ানো node | ক্রম না লাগলে unordered_map; পড়া-বেশি হলে সাজানো vector |
map-এ std::lower_bound(m.begin(), ...) | bidirectional iterator — O(n) | member m.lower_bound(x) |
নিজের তুলনায় <=, বা hash-এ শুধু XOR | strict weak ordering ভাঙে (UB); collision বাড়ে | default <=>; পরীক্ষিত hash-combine |
| লুপের ভেতরে ঢোকানো, বাইরে রাখা iterator ব্যবহার | unordered_map-এ rehash হলে iterator অচল | reserve, অথবা ঢোকানোর পর আবার খোঁজা |
AI-কে কোড লিখতে বলার সময় container-এর নিয়মগুলো আগেই বলে দিন, আর প্রতিটি বাছাইয়ের কারণ জানতে চান:
AI-এর map-কোড যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।
প্র ০১hash table কম্পিউটার বিজ্ঞানের একেবারে পুরোনো ধারণা। তবু C++-এ unordered_map এল C++11-এ, map-এর এক যুগেরও বেশি পরে। কেন এত দেরি?
hash table-এর ধারণা ১৯৫০-এর দশক থেকেই পরিচিত; C++-এর জন্মের অনেক আগে থেকে compiler-এর symbol table থেকে database পর্যন্ত সব জায়গায় এর ব্যবহার। তাই দেরির কারণ প্রযুক্তি নয়, standard তৈরির প্রক্রিয়া। Alexander Stepanov আর Meng Lee-র STL কমিটির সামনে আসে ১৯৯৩-৯৪ সালে, আর গৃহীত হয় অসাধারণ দ্রুততায় — যদিও তখন C++98-এর খসড়া প্রায় চূড়ান্ত। STL-এ সাজানো container (map, set) ছিল, কারণ তাদের নিয়ম পরিষ্কার: শুধু একটি তুলনা লাগে, আর জটিলতার নিশ্চয়তা (O(log n)) গাণিতিকভাবে দেওয়া যায়। hash container-এর প্রস্তাবও উঠেছিল, কিন্তু সেটি এল দেরিতে, আর তার নকশার অনেক প্রশ্ন তখনো খোলা: hash ফাংশনের interface কেমন হবে, bucket-এর সংখ্যা কে ঠিক করবে, rehash কখন, iterator-এর নিশ্চয়তা কী। সময়সীমার চাপে কমিটি সেটি বাদ দেয় — পরে এটি C++98-এর একটি বড় অভাব হিসেবে বহুবার উল্লেখ হয়েছে।
ফলাফল ছিল এক দশকের বিশৃঙ্খলা। SGI-এর STL বাস্তবায়নে hash_map ছিল, GCC সেটি __gnu_cxx namespace-এ রাখল, Microsoft-এর library রাখল stdext-এ, আর দুটির interface ও আচরণে পার্থক্য ছিল। একই নামের দুই ভিন্ন জিনিস — portable কোড লেখা কঠিন। তাই ২০০০-এর দশকের শুরুতে যখন Matt Austern-এর নেতৃত্বে hash container-এর নতুন প্রস্তাব এল, ইচ্ছা করেই নাম রাখা হলো unordered_map — যাতে পুরোনো hash_map-ওয়ালা কোডের সাথে সংঘর্ষ না হয়। নামটি একটি গুরুত্বপূর্ণ বৈশিষ্ট্যও বলে দেয়: কোনো ক্রমের নিশ্চয়তা নেই।
এর পথ ছিল Technical Report 1 (TR1) — standard library-র একগুচ্ছ পরীক্ষামূলক সংযোজন, যা ২০০০-এর দশকের মাঝামাঝি প্রকাশিত হয় আর std::tr1 namespace-এ থাকে। shared_ptr, tuple, regex-ও এই পথেই আসে। কয়েক বছর বাস্তব ব্যবহারের পর এরা C++11-এ পুরোপুরি std-এ উঠে আসে। কিন্তু তাড়াহুড়োর একটি দাম রয়ে গেছে — TR1-এর নকশা যে interface আর নিশ্চয়তা ঠিক করে দেয় (bucket interface, pointer stability), তা পরে আধুনিক, দ্রুততর hash table বানানোর পথ কঠিন করেছে (প্রশ্ন ৩ দেখুন)।
মূল উপলব্ধি: standard library-তে কী আছে আর কী নেই, তা অনেক সময় প্রযুক্তির নয়, ইতিহাস আর সময়সীমার ফল। আর একবার standard-এ ঢুকলে interface প্রায় চিরস্থায়ী — তাই প্রতিটি নিশ্চয়তা ভেবে-চিন্তে দিতে হয়।
প্র ০২map-এর red-black tree প্রতিটি node-এ একটি চাবি রাখে। database আর file system ব্যবহার করে B-tree, আর C++23 আনল flat_map. cache-এর চোখে এই তিনটির পার্থক্য কী?
আধুনিক CPU-তে মেমরি থেকে একটি তথ্য আনতে যত সময় লাগে, তার মধ্যে CPU শতাধিক সাধারণ হিসাব করে ফেলতে পারে। তাই CPU-র কাছে থাকে ছোট, খুব দ্রুত cache, আর মেমরি আসে ছোট ছোট খণ্ডে — সাধারণত ৬৪ বাইটের cache line. যে data structure একটি খণ্ড আনার পর তার পুরোটা কাজে লাগায়, সে দ্রুত; যে প্রতি ধাপে নতুন, দূরের খণ্ড চায়, সে ধীর — Big-O এক হলেও।
red-black tree-তে প্রতিটি node একটি চাবি, একটি মান আর তিনটি pointer. দশ লাখ চাবির tree-তে উচ্চতা বিশের বেশি, আর প্রতিটি ধাপ সম্ভবত একটি নতুন cache line — অনেক ধাপেই cache miss. node-গুলো আলাদা আলাদা সময়ে heap-এ বানানো, তাই মেমরিতে ছড়ানো। B-tree এর উত্তর: প্রতিটি node-এ অনেকগুলো চাবি, পাশাপাশি সাজানো। একটি node আনলে একসাথে বহু চাবির তুলনা হয়, আর tree-এর উচ্চতা হয় অনেক কম। Rudolf Bayer আর Edward McCreight ১৯৭০-এর দশকের শুরুতে B-tree তৈরি করেন ডিস্কের জন্য, যেখানে প্রতিটি পড়া খুব ব্যয়বহুল — আজও প্রায় সব database-এর index আর অনেক file system এর ওপর দাঁড়িয়ে। মজার যোগসূত্র: red-black tree-এর পূর্বসূরিও Bayer-এর (১৯৭২, “symmetric binary B-tree”), আর Guibas ও Sedgewick ১৯৭৮ সালে লাল-কালো রঙের বর্ণনা দেন। আসলে একটি red-black tree হলো ছোট একটি B-tree (প্রতি node-এ ১-৩টি চাবি)-কে binary আকারে লেখা।
flat_map চূড়ান্ত পথ নেয়: কোনো tree-ই নেই, শুধু সাজানো array. binary search-এর শেষ ধাপগুলো পাশাপাশি ঘরে হয়, আর পুরোটা ক্রমে হাঁটা vector-এর মতোই দ্রুত। দাম হলো মাঝখানে ঢোকানো বা মোছায় বাকি সব উপাদান সরাতে হয় — O(n). তাই যে ডেটা একবার বানিয়ে বারবার পড়া হয়, তাতে flat_map প্রায়ই জয়ী; যেখানে অবিরাম ঢোকানো-মোছা চলে, সেখানে tree. Google-এর Abseil library C++-এর জন্য absl::btree_map-ও দেয় — map-এর interface, কিন্তু ভেতরে B-tree, আর অনেক ক্ষেত্রে কম মেমরি নিয়ে দ্রুততর। তবে এটি std::map-এর একটি নিশ্চয়তা দেয় না: ঢোকানো-মোছায় অন্য উপাদানের iterator অটুট থাকা।
red-black tree-এর নিজস্ব শক্তিও আছে: ঢোকানো-মোছার খারাপ ক্ষেত্রেও খরচ সীমিত, আর উপাদান কখনো সরে না। Linux kernel-এর বহু জায়গায়, যেমন process scheduler-এ, red-black tree ব্যবহার হয় ঠিক এই নিশ্চয়তার জন্য।
মূল উপলব্ধি: Big-O শুধু তুলনার সংখ্যা গোনে, মেমরির দূরত্ব নয়। একই O(log n)-এর তিনটি data structure বাস্তবে কয়েক গুণ ভিন্ন গতিতে চলতে পারে — তাই কোন নিশ্চয়তা (স্থির ঠিকানা, দ্রুত ঢোকানো, দ্রুত পড়া) আসলে আপনার দরকার, সেটিই বাছাইয়ের আসল প্রশ্ন।
প্র ০৩অনেক অভিজ্ঞ C++ প্রোগ্রামার বলেন std::unordered_map “ধীর”, আর Abseil বা Boost-এর hash map ব্যবহার করেন। O(1)-এর একটি container ধীর হয় কীভাবে?
সমস্যাটা O(1)-এ নয়, সেই O(1)-এর ধ্রুবকে — আর সেটি standard-এর কয়েকটি নিশ্চয়তা থেকে আসে। প্রথমটি pointer stability: unordered_map প্রতিশ্রুতি দেয় যে একটি উপাদানের pointer বা reference rehash-এর পরেও ঠিক থাকবে। এটি রাখতে প্রতিটি উপাদানকে নিজের আলাদা heap node-এ থাকতে হয়, যাতে rehash-এ শুধু node-দের তালিকা বদলায়, উপাদান সরে না। দ্বিতীয়টি bucket interface: standard bucket_count(), bucket(key), আর এক bucket-এর উপাদানে হাঁটার local iterator দেয়। এর অর্থ, প্রতিটি bucket-কে একটি আলাদা তালিকা হতেই হবে — অর্থাৎ বাস্তবে separate chaining বাধ্যতামূলক।
এই দুই শর্তের ফল: প্রতিটি খোঁজায় অন্তত একটি bucket array পড়া, তারপর একটি node-এ pointer ধরে লাফ, collision থাকলে আরও লাফ — প্রতিটি সম্ভাব্য cache miss. প্রতিটি ঢোকানোয় একটি heap allocation. ছোট চাবি-মানের জন্য node-এর বাড়তি pointer আর allocation-এর খরচ ডেটার চেয়েও বেশি জায়গা নিতে পারে।
আধুনিক বিকল্পগুলো এই নিশ্চয়তা ছেড়ে দেয়, আর open addressing ব্যবহার করে: সব উপাদান একটিই পাশাপাশি সাজানো array-তে, collision হলে নিয়ম মেনে পাশের ঘর। Google-এর “Swiss Table” নকশা (Abseil-এর absl::flat_hash_map, CppCon 2017-এ উপস্থাপিত) প্রতিটি ঘরের জন্য এক বাইটের একটি ছোট “control” তথ্য রাখে, আর SIMD নির্দেশ দিয়ে একসাথে ১৬টি ঘর পরীক্ষা করে। Boost.Unordered ২০২২ সালে (Boost 1.81) boost::unordered_flat_map যোগ করে, একই ধরনের ধারণায়। অনেক benchmark-এ এগুলো std::unordered_map-এর চেয়ে কয়েক গুণ দ্রুত আর কম মেমরি নেয়।
দাম? rehash-এ উপাদান সত্যিই সরে যায়, তাই pointer আর reference অচল হয় — যে কোড উপাদানের ঠিকানা ধরে রাখে, তা ভাঙবে। আর বড় মানের টাইপে সরানোর খরচ বাড়ে। এজন্য Abseil node_hash_map-ও রাখে, যা stability দেয়। standard library নিজের নিশ্চয়তা বদলাতে পারে না, কারণ কোটি লাইনের পুরোনো কোড এর ওপর নির্ভর করে — ABI আর আচরণের সামঞ্জস্য C++-এর এক বড় অঙ্গীকার।
মূল উপলব্ধি: প্রতিটি নিশ্চয়তার একটি দাম আছে, এমনকি যারা সেটি ব্যবহার করে না তাদেরও। std::unordered_map একটি নিরাপদ, সঠিক ডিফল্ট; গতি সত্যিই সমস্যা হলে মেপে দেখে বিকল্পে যান, আর জেনে নিন কোন নিশ্চয়তা ছেড়ে দিচ্ছেন।
প্র ০৪hash flooding কী ধরনের ঝুঁকি, আর অনেক ভাষা কেন এলোমেলো (randomized) hash-এ চলে গেছে? C++ প্রোগ্রামার হিসেবে আপনার কী করণীয়?
hash table-এর গতি একটি অনুমানের ওপর দাঁড়িয়ে: চাবিগুলো bucket-এ মোটামুটি সমানভাবে ছড়াবে। সাধারণ ডেটায় অনুমানটি সত্য। কিন্তু hash ফাংশন যদি সবার জানা ও নির্দিষ্ট হয়, আর চাবিগুলো যদি বাইরের কেউ ঠিক করে দেয়, তাহলে সেই অনুমান আর নিশ্চিত নয়। কেউ এমন হাজার হাজার চাবি পাঠাতে পারে, যেগুলো একই bucket-এ পড়ে। তখন প্রতিটি নতুন চাবি ঢোকাতে পুরো তালিকা মেলাতে হয় — n চাবিতে মোট কাজ প্রায় n². একটি ছোট অনুরোধই একটি সার্ভারের CPU কয়েক সেকেন্ড বা মিনিট আটকে রাখতে পারে। এটি এক ধরনের denial of service — তথ্য চুরি নয়, সেবা বন্ধ করা।
ঝুঁকিটি তাত্ত্বিকভাবে আগেই জানা ছিল (২০০৩ সালে Crosby ও Wallach-এর একটি গবেষণাপত্রে ভালোভাবে বর্ণিত), আর Perl সেই সময়েই hash-এ এলোমেলো বীজ যোগ করে। ২০১১ সালের শেষে একটি নিরাপত্তা সম্মেলনে দেখানো হয় যে তখনকার অনেক জনপ্রিয় ওয়েব প্ল্যাটফর্ম এতে ঝুঁকিপূর্ণ, কারণ ওয়েব ফর্মের ফিল্ডগুলো সাধারণত সরাসরি একটি hash table-এ যায়। এরপর বহু ভাষা আর framework দ্রুত ব্যবস্থা নেয়: অনুরোধে ফিল্ডের সংখ্যা সীমিত করা, আর প্রোগ্রাম চালুর সময় একটি গোপন, এলোমেলো বীজ hash-এ মেশানো, যাতে বাইরে থেকে কেউ আগাম জানতে না পারে কোন চাবি কোন bucket-এ যাবে। ২০১২ সালে Jean-Philippe Aumasson আর Daniel J. Bernstein SipHash প্রকাশ করেন — এই কাজের জন্য তৈরি, গোপন চাবি-সহ একটি দ্রুত hash. Python (৩.৪ থেকে string-এর ডিফল্ট hash হিসেবে) আর Rust (HashMap-এর ডিফল্ট) এ ধরনের পথ নিয়েছে।
C++-এর std::hash এসব কিছুই বাধ্যতামূলক করে না; প্রচলিত বাস্তবায়নে এটি নির্দিষ্ট আর দ্রুত — integer-এর ক্ষেত্রে অনেক সময় সংখ্যাটি নিজেই। তাই অবিশ্বস্ত ইনপুট নিলে দায়িত্ব আপনার। প্রতিরক্ষার পথ: (১) একটি অনুরোধে বা একটি ফাইলে কয়টি চাবি নেবেন, তার সীমা; (২) চালুর সময় এলোমেলো বীজ নিয়ে নিজের hasher, বা এমন library, যা এটি দেয়; (৩) সংবেদনশীল জায়গায় std::map, যার খারাপ ক্ষেত্রও O(log n) — ধীর হলেও অনুমানযোগ্য। কনটেস্ট প্রোগ্রামিংয়েও (পর্ব ৫০) একই কারণে অনেকে unordered_map-এ এলোমেলো বীজের custom hash ব্যবহার করেন, কারণ বিচারক বা অন্য প্রতিযোগী কঠিন ইনপুট বানাতে পারেন।
মূল উপলব্ধি: “গড়ে O(1)” একটি পরিসংখ্যানের প্রতিশ্রুতি, নিশ্চয়তা নয়। যেখানে ইনপুট আপনার নিয়ন্ত্রণে নেই, সেখানে খারাপ ক্ষেত্রটি নিয়ে ভাবাই নিরাপদ প্রকৌশল।
প্র ০৫map-এর উপাদানের টাইপ pair<const Key, T> — চাবিটি const. set-এর উপাদানও বদলানো যায় না। এই কড়াকড়ি কেন, আর সত্যিই চাবি বদলাতে হলে কী করবেন?
map-এর পুরো দক্ষতা একটি invariant-এর ওপর দাঁড়িয়ে: tree-এর প্রতিটি node-এর বামে ছোট চাবি, ডানে বড়। tree এই নিয়ম রক্ষা করে ঢোকানোর মুহূর্তে — চাবি দেখে জায়গা ঠিক করে। কিন্তু ঢোকানোর পরে কেউ যদি চাবিটি নিজের জায়গায় বসে বদলে দেয়, tree টেরও পায় না। ধরুন রোল ১০-কে বদলে ১০০ করলেন, অথচ node-টি রয়ে গেল ২০-এর বামে। এখন find(100) মূল থেকে ডানে যাবে আর কখনো পাবে না; find(10)-ও পাবে না। ক্রমে হাঁটলে সারি আর সাজানো নেই। পরের ঢোকানো আর rotation সব একটি ভাঙা ভিত্তির ওপর — ফলাফল undefined. unordered_map-এ একই কথা ভিন্ন রূপে: চাবি বদলালে তার hash বদলায়, কিন্তু node পুরোনো bucket-এই থাকে — খোঁজার সময় container ভুল তাকে খুঁজবে।
তাই standard চাবিকে টাইপের স্তরেই const করে দিয়েছে: it->first = 100 লিখলে compile error. ভুলটি চলার সময় নয়, compile-এর সময়েই ধরা পড়ে — ঠিক যেমন পর্ব ২০-এ দেখেছিলেন, একটি invariant রক্ষার সবচেয়ে ভালো উপায় হলো ভুল কাজটি অসম্ভব করে দেওয়া। set-এ উপাদান আর চাবি একই জিনিস, তাই set-এর iterator দিয়ে উপাদান পুরোপুরি পড়া-যায়-শুধু।
একটি সূক্ষ্ম ফাঁক আছে। চাবির টাইপ একটি struct হলে, আর তার কোনো সদস্য mutable হলে, const object-এও সেটি বদলানো যায়। যদি সেই সদস্য তুলনায় অংশ না নেয় (যেমন একটি cache-করা তথ্য), কোনো ক্ষতি নেই। কিন্তু তুলনার অংশ বদলালে আবার সেই ভাঙা tree. const_cast দিয়ে জোর করে বদলানোও একই বিপদ।
সত্যিই চাবি বদলাতে হলে পুরোনো পথ ছিল মুছে নতুন করে ঢোকানো — যার মানে একটি node মুক্ত করা আর আরেকটি নতুন করে বানানো, মানটিরও কপি বা move. C++17 এনেছে আরও ভালো পথ: extract. auto nh = m.extract(10); node-টিকে tree থেকে বের করে একটি node handle দেয় — মেমরি মুক্ত হয় না। এখন nh.key() = 100; বৈধ, কারণ node আর tree-এর অংশ নয়। তারপর m.insert(std::move(nh)); সেটিকে ঠিক জায়গায় বসায়। কোনো নতুন allocation নেই, মান কপি হয় না। একই যন্ত্র দিয়ে merge এক map-এর node আরেক map-এ সরায়।
মূল উপলব্ধি: container-এর দক্ষতা আসে তার ভেতরের নিয়ম থেকে, আর নিয়মটি রক্ষার দায় সে টাইপ-ব্যবস্থার ওপর দেয়। চাবি const — এটি সীমাবদ্ধতা নয়, ভাঙা data structure থেকে compiler-এর দেওয়া সুরক্ষা।
অনুশীলন
বেশিরভাগ কাজে কোড লিখতে হবে না — আগে অনুমান করুন, তারপর উত্তর মিলিয়ে নিন।
-
আউটপুট অনুমান:
std::map<std::string, int> m;তারপরm["b"] = 2; m["a"]; m.insert({"b", 9}); if (m["c"] > 0) {}. শেষেm.size()কত, আর ক্রমে হাঁটলে কী কী চাবি-মান আসবে?m.size()= 3. ক্রমে:a=0,b=2,c=0.m["a"];একা একটি লাইন হলেও "a"-কে 0 দিয়ে ঢোকায়।insert({"b", 9})কিছু বদলায় না, কারণ "b" আগে থেকেই আছে — মান 2-ই থাকে। আরm["c"] > 0শুধু তুলনা করতে চাইলেও "c"-কে 0 দিয়ে ঢুকিয়ে দেয়। চাবিগুলো বর্ণানুক্রমে আসে, ঢোকানোর ক্রমে নয়। -
বাগ শিকার: একটি AI এই ফাংশন দিয়েছে —
int gpaOf(const std::map<int, int>& r, int roll) { if (r.count(roll)) return r[roll]; return -1; }. এটি compile হবে কি? না হলে কেন, আর কীভাবে ঠিক করবেন?compile হবে না।
rএকটি const reference, আর const map-এoperator[]নেই — কারণ সেটি উপাদান ঢোকাতে পারে। (const না হলে compile হতো, কিন্তু তখনো একই চাবি দুবার খোঁজা হতো।) সঠিক রূপ:if (auto it = r.find(roll); it != r.end()) return it->second; return -1;— একবার খোঁজা, কিছু ঢোকানো নয়। চাবি না থাকা যদি বাগ হয়, তাহলেr.at(roll), যাstd::out_of_rangeছোড়ে। -
নকশা ভাবুন: নিচের প্রতিটি কাজের জন্য map, unordered_map, set, নাকি সাজানো vector বেছে নেবেন, আর কেন? (ক) একটি চ্যাট সার্ভারে user ID থেকে অনলাইন অবস্থা, প্রতি সেকেন্ডে হাজারবার খোঁজা, ক্রমের দরকার নেই (খ) একটি লিডারবোর্ড, যা সবসময় স্কোর অনুযায়ী সাজানো দেখাতে হয় (গ) দেশের ৬৪টি জেলার কোড থেকে নাম, প্রোগ্রাম চালুর সময় একবার বানানো (ঘ) একটি ফাইলে কয়টি আলাদা শব্দ আছে, আর শব্দগুলো বর্ণানুক্রমে ছাপাতে হবে।
(ক) unordered_map — শুধু দ্রুত খোঁজা, ক্রম অপ্রয়োজনীয়; ID বাইরে থেকে এলে চাবির সংখ্যা সীমিত রাখা আর hash নিয়ে সতর্ক থাকা। (খ) স্কোর-ভিত্তিক সাজানো ক্রম লাগে আর একই স্কোর একাধিক জনের হতে পারে, তাই
std::multimap<int, std::string, std::greater<>>(বড় থেকে ছোট)। (গ) মাত্র ৬৪টি, একবার বানিয়ে বারবার পড়া — সাজানো vector (বা flat_map); এমনকি একটি সাধারণ array-ও যথেষ্ট। (ঘ)std::set<std::string>— আলাদা থাকা আর বর্ণানুক্রম দুটিই একসাথে;size()দেয় সংখ্যা। -
নিজে লিখুন (কোড): উপরের
price_range.cppবদলে ৬০০ থেকে ৯৮০ টাকার পণ্য দেখান। তারপর একটি প্রশ্ন: ৯৮০ দামের keyboard ফলাফলে রাখতে আপনি কোন ফাংশন কোন সংখ্যা দিয়ে ডাকবেন, আর আউটপুটের শেষ লাইন কী হবে? আগে অনুমান করুন।auto from = byPrice.lower_bound(600);আরauto to = byPrice.upper_bound(980);. upper_bound “৯৮০-এর বেশি প্রথমটি” দেয় (power bank, 1000), তাই 980 নিজে ফলাফলে থাকে। ফল:650 mouse,650 USB hub,980 keyboard, আর শেষ লাইনে সংখ্যা 3 (শেষ লাইনের লেখাটিও “600..980” করে নিন)। উল্টো দিকে,lower_bound(980)-কে শেষ দাগ বানালে keyboard বাদ পড়ত, কারণ সেটি ৯৮০-কেই দেখাত।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমC++-এর std::unordered_map-এ find(key) চলার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- hash ফাংশন চাবি থেকে একটি পূর্ণসংখ্যা বানায়
- সেই সংখ্যা থেকে bucket-এর নম্বর হিসাব হয়
- সেই bucket-এর উপাদানের তালিকায় যাওয়া হয়
- তালিকার প্রতিটি চাবির সাথে == দিয়ে মেলানো হয়
- মিললে সেই উপাদানের iterator, না মিললে end() ফেরত আসে
C++-এ std::map<std::string, int> m; খালি অবস্থায় if (m["x"] == 0) {} চালানোর পর m.size() কত?
- 0
- Compile error
- 1
- std::out_of_range exception
প্রচলিত C++ standard library বাস্তবায়নে std::map ভেতরে কোন data structure, আর খোঁজার জটিলতা কত?
- hash table, গড়ে O(1)
- red-black tree, O(log n)
- সাজানো vector, O(log n)
- linked list, O(n)
C++-এ একটি std::unordered_map-এ লুপ চালিয়ে উপাদানগুলো ছাপালে কোন ক্রমে আসবে?
- ঢোকানোর ক্রমে
- চাবির ছোট থেকে বড় ক্রমে
- সব compiler-এ সবসময় একই নির্দিষ্ট ক্রমে
- কোনো নির্দিষ্ট ক্রমের নিশ্চয়তা নেই
C++-এ std::map<int, std::string> m{{1, "a"}}; — তারপর m.insert({1, "z"}); করলে m[1] কী?
- "a"
- "z"
- দুটিই থাকে — "a" আর "z"
- exception ছোড়ে
C++-এ সাজানো std::set<int> s{10, 20, 30, 40}; — *s.lower_bound(20) আর *s.upper_bound(20) কত?
- 10 আর 20
- 20 আর 30
- 20 আর 20
- 20 আর 40
C++-এ একটি নিজের struct-কে std::unordered_map-এর চাবি বানাতে ন্যূনতম কী লাগে?
- শুধু operator<
- শুধু operator==
- একটি hash (যেমন std::hash-এর specialization) আর operator==
- operator<=> আর একটি default constructor
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- Associative container খোঁজে চাবি দিয়ে, অবস্থান দিয়ে নয় — map/set সাজানো tree (O(log n)), unordered_map/set hash table (গড়ে O(1), ক্রম নেই)।
m[k]চাবি না থাকলে ঢোকায়; শুধু খুঁজতেfind,containsবাat, আর একই চাবি দুবার খুঁজবেন না।insertপুরোনো মান বদলায় না;try_emplace“না থাকলে বানাও”,insert_or_assign“থাকলে বদলাও”।- সাজানো container-এ member
lower_bound/upper_boundদিয়ে range query, O(log n + k); unordered container-এর ক্রমের ওপর কখনো ভরসা নয়। - নিজের চাবিতে map-এর জন্য strict weak ordering (default
<=>), unordered-এর জন্য hash +==; বাছাই করুন প্রয়োজন দেখে, আর গতি মেপে।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৩২ · iterator — container-এর ভেতর দিয়ে হাঁটাপরবর্তী পর্বSTL-এর সব container আর algorithm-কে জোড়া দেওয়া সাধারণ ভাষা।
- C++ প্রোগ্রামিং · কোর্স হোম৫৫টি পর্বপুরো কোর্সের রোডম্যাপ — কোন পর্ব কখন।
- C প্রোগ্রামিং · সার্চিং ও Divide & Conquerগভীরেbinary search আর অর্ধেক বাদ দেওয়ার যুক্তি — map-এর O(log n)-এর ভিত্তি, নিজের হাতে।