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

C++ STL আর Python-এর কালেকশন — তৈরি ডেটা স্ট্রাকচার

C++ STL & Python Collections — Ready-made Data Structures, Their Complexities and Pitfalls

Taught in Bangla, this lesson surveys ready-made data structures: C++ sequence containers, adapters, ordered and unordered containers, and Python's list, tuple, dict, set, collections, heapq and bisect. It covers built-in algorithms such as sort, lower_bound and next_permutation, gives a C++ versus Python comparison table, a method for choosing a container, and common pitfalls like iterator invalidation and hash worst cases.

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

  • C++-এর sequence container, adapter, ordered আর unordered container — কোনটির ভেতরে কী আছে আর প্রতিটি কাজে কত সময়
  • Python-এর list, tuple, dict, set আর collections, heapq, bisect মডিউলের সঠিক ব্যবহার
  • sort, lower_bound/upper_bound, bisect, next_permutation-এর মতো তৈরি অ্যালগরিদম আর iterator
  • একটি বড় C++ ↔ Python তুলনা টেবিল আর “কোন কাজে কোন container” বেছে নেওয়ার ধাপে ধাপে পদ্ধতি
  • Iterator invalidation, map[], hash-এর worst case আর Python-এর পরিচিত ফাঁদগুলো চেনা ও এড়ানো

১ · তৈরি কালেকশন কেন — চাকা নতুন করে বানাবেন না

আগের পর্বগুলোতে আমরা array, linked list, stack আর queue নিজের হাতে বানিয়েছি। সেটি জরুরি ছিল — ভেতরের কলকবজা না বুঝলে কোন কাজ কেন ধীর, তা বোঝা যায় না। কিন্তু বাস্তবে প্রতিটি প্রোগ্রামে নিজের linked list লেখা মানে প্রতিবার রান্নার আগে নিজের হাতে মসলা পেষা। বাজারে ভালো মানের গুঁড়া মসলা আছে; ভালো রাঁধুনি জানেন কোন মসলা কোন রান্নায়, কতটুকু দিতে হয়। প্রোগ্রামিংয়েও তাই।

C++-এ এই “তৈরি মসলার তাক” হলো STL (Standard Template Library), আর Python-এ ভাষার ভেতরেই থাকা list, dict, set এবং collections, heapq, bisect-এর মতো মডিউল। এগুলোর প্রতিটি একটি data structure-এর পরীক্ষিত বাস্তবায়ন — লাখো প্রোগ্রামার বহু বছর ধরে ব্যবহার করেছেন, বাগ খুঁজে ঠিক করেছেন, গতি বাড়িয়েছেন। কনটেস্টে বা ইন্টারভিউতে আধা ঘণ্টায় নিজের লেখা red-black tree-র চেয়ে std::map প্রায় সবসময় বেশি নির্ভরযোগ্য।

মূল ধারণা

তৈরি কালেকশন ব্যবহার করা মানে চিন্তা বাদ দেওয়া নয়। প্রতিটি container-এর পেছনে একটি নির্দিষ্ট স্ট্রাকচার আছে, আর সেই স্ট্রাকচারই ঠিক করে কোন কাজে কত সময় লাগবে। “এটি কোন স্ট্রাকচার, আর আমার দরকারি কাজগুলোর complexity কত?” — এই প্রশ্নের উত্তর না জেনে container বাছলে কোড ঠিক উত্তর দেবে, কিন্তু বড় ইনপুটে সময়সীমা পার হয়ে যাবে।

পর্ব ০১-এ দেখেছিলেন abstract data type (ADT) আর বাস্তবায়নের পার্থক্য: ADT বলে “কী করা যায়”, বাস্তবায়ন বলে “কীভাবে”। এই পর্বের পুরো মানচিত্রটি সেই ভাগ মেনে চলে:

📚 সারি (sequence)

উপাদান একটির পর একটি, আপনার দেওয়া ক্রমে। C++: vector, deque, list, array, string. Python: list, tuple, collections.deque.

index বা প্রান্ত ধরে কাজ

🚦 Adapter

অন্য container-এর ওপর একটি সীমিত নিয়ম চাপানো: stack, queue, priority_queue. Python-এ list, deque আর heapq দিয়ে একই কাজ।

শুধু নির্দিষ্ট প্রান্তে কাজ

🌳 সাজানো (ordered)

key সবসময় ছোট থেকে বড় ক্রমে: set, multiset, map, multimap — ভেতরে balanced binary search tree.

O(log n), ক্রম বজায়

🗂️ Hash (unordered)

key থেকে সরাসরি ঘরের ঠিকানা: unordered_set, unordered_map; Python-এর dict, set, Counter, defaultdict.

গড়ে O(1), ক্রম নেই

এর সাথে আছে তৈরি অ্যালগরিদম — sort, lower_bound, next_permutation, accumulate; Python-এ sorted, bisect, heapq.nlargest. STL-এ অ্যালগরিদম আর container-কে জোড়া দেয় iterator — একটি “আঙুল”, যা container-এর কোনো একটি ঘরের দিকে নির্দেশ করে আর পরের ঘরে সরতে পারে। iterator-এর কারণেই একই sort ফাংশন vector, deque বা সাধারণ array — সবকিছুতে চলে।

এই পর্ব একটি মানচিত্র, প্রতিটি স্ট্রাকচারের ভেতরের পূর্ণ বিশ্লেষণ নয়। ভেতরের গল্প আসবে নিজ নিজ পর্বে: heap আর priority queue পর্ব ২৩-এ, binary search tree পর্ব ২৭-এ, red-black tree পর্ব ২৯-এ, hash table পর্ব ৩২-এ। এখানে লক্ষ্য একটাই — পরের পর্বগুলোতে যখন বলব “এখানে একটি map নিন” বা “একটি min-heap লাগবে”, তখন যেন আপনি জানেন কোনটি নেবেন আর তার দাম কত।

২ · Sequence container — vector, deque আর list

std::vector হলো STL-এর সবচেয়ে বেশি ব্যবহৃত container — একটি dynamic array. উপাদানগুলো মেমরিতে পাশাপাশি থাকে (পর্ব ১০), তাই v[i] পড়া O(1), আর পরপর ঘোরা CPU cache-এর জন্য অত্যন্ত দ্রুত। শেষে যোগ (push_back) সাধারণত O(1); কিন্তু জায়গা ফুরিয়ে গেলে vector বড় একটি নতুন ব্লক নেয়, পুরোনো সব উপাদান সেখানে সরায়, আর পুরোনো ব্লক ছেড়ে দেয়। নিচে দেখুন GCC-র লাইব্রেরি (libstdc++) কীভাবে ধারণক্ষমতা (capacity) দ্বিগুণ করে:

vector v-এর অবস্থা
push করা মান
size()
capacity()
এই push-এ সরানো উপাদান
মোট সরানো এ পর্যন্ত
মেমরির ব্লক_ = খালি ঘর

                
  1. প্রথম push. খালি vector-এ 7 যোগ করতে ১ ঘরের একটি ব্লক নেওয়া হলো। size = 1, capacity = 1. কিছু সরাতে হয়নি।
  2. জায়গা ফুরাল। 3 যোগ করার জায়গা নেই। নতুন ব্লক নেওয়া হলো দ্বিগুণ — ২ ঘর; পুরোনো 7 সেখানে সরানো হলো (১টি সরানো), তারপর 3 বসল।
  3. আবার দ্বিগুণ। 9-এর জন্য ৪ ঘরের ব্লক, পুরোনো ২টি উপাদান সরানো হলো। এখন একটি ঘর খালি পড়ে আছে — পরের push-এর জন্য।
  4. সস্তা push. 1 সরাসরি খালি ঘরে বসল, কিছু সরাতে হলো না। ধরুন এই মুহূর্তে আমরা auto it = v.begin(); লিখে প্রথম ঘরের একটি iterator রেখে দিলাম।
  5. দামি push, আর একটি বিপদ। 5-এর জন্য ৮ ঘরের নতুন ব্লক, ৪টি উপাদান সরানো। পুরোনো ব্লক ফেরত দেওয়া হয়েছে — অথচ it এখনো সেখানেই নির্দেশ করছে। এখন *it পড়া undefined behavior. একে বলে iterator invalidation; অংশ ৯-এ আবার দেখব।
  6. হিসাব মেলান। 8 খালি ঘরে বসল। ৬টি push-এ মোট সরানো ৭টি; সাধারণভাবে n টি push-এ মোট সরানো ১ + ২ + ৪ + … < ২n. তাই প্রতি push-এর গড় খরচ O(1) — একে বলে amortized O(1). কোনো একটি push O(n) হতে পারে, কিন্তু পুরো ক্রমের গড় ধ্রুবক।

দ্বিগুণ করাই কেন? যদি প্রতিবার মাত্র ১০টি করে ঘর বাড়ানো হতো, তাহলে n টি push-এ মোট সরানো হতো প্রায় n²/20 — অর্থাৎ Θ(n²). গুণিতক হারে বাড়ালে (২ গুণ, বা Microsoft-এর লাইব্রেরির মতো ১.৫ গুণ) মোট খরচ Θ(n) থাকে। এর পূর্ণ প্রমাণ পর্ব ৬০-এ (amortized analysis). আগে থেকে আকার জানা থাকলে v.reserve(n) লিখুন — একবারেই জায়গা নেওয়া হবে, মাঝপথে কোনো সরানো নেই।

std::deque (double-ended queue, উচ্চারণ “ডেক”) দেখতে vector-এর মতো — d[i] O(1) — কিন্তু ভেতরে একটানা একটি ব্লক নয়, বরং অনেকগুলো ছোট নির্দিষ্ট-আকারের ব্লক আর সেগুলোর ঠিকানার একটি তালিকা। তাই সামনে আর পেছনে দুই দিকেই যোগ-বাদ O(1). ট্রেনের বগির মতো ভাবুন: দুই মাথাতেই নতুন বগি জোড়া যায়, পুরো ট্রেন সরাতে হয় না। দাম: প্রতিটি d[i]-এ একটি বাড়তি ঠিকানা-হিসাব, আর মেমরি পাশাপাশি না থাকায় vector-এর চেয়ে কিছুটা ধীর।

std::list হলো doubly linked list (পর্ব ১২). কোনো জায়গার iterator হাতে থাকলে সেখানে যোগ বা মুছে ফেলা O(1), আর splice দিয়ে এক list-এর অংশ আরেক list-এ O(1)-এ সরানো যায়। কিন্তু i-তম উপাদানে যেতে O(n) হাঁটতে হয়, প্রতিটি node আলাদা মেমরিতে থাকায় cache-এর সুবিধা নেই, আর প্রতি উপাদানে দুটি pointer-এর বাড়তি জায়গা লাগে। বাস্তবে তাই অনেক অভিজ্ঞ প্রোগ্রামার বলেন, “সন্দেহ হলে vector নিন” — মাঝখানে যোগ O(n) হলেও ছোট-মাঝারি n-এ পাশাপাশি মেমরির গতি প্রায়ই list-কে হারিয়ে দেয়।

কাজvectordequelist
i-তম উপাদান পড়াO(1)O(1)O(n)
শেষে যোগ / বাদamortized O(1) / O(1)O(1)O(1)
শুরুতে যোগ / বাদO(n)O(1)O(1)
মাঝখানে যোগ / বাদO(n)O(n)O(1), যদি iterator হাতে থাকে
মান খোঁজা (find)O(n)O(n)O(n)
মেমরিপাশাপাশি, সবচেয়ে কম বাড়তিছোট ব্লকে ভাগ করাপ্রতি node-এ ২টি pointer

আরও দুটি নাম জেনে রাখুন। std::array<int, N> হলো compile-এর সময় আকার জানা একটি সাধারণ array, যা vector-এর মতো আচরণ করে কিন্তু আকার বদলায় না। আর std::string আসলে অক্ষরের একটি vector-ই, বাড়তি কিছু ফাংশনসহ (পর্ব ১১).

৩ · Adapter — stack, queue, priority_queue আর pair/tuple

কখনো কখনো আমরা ইচ্ছা করেই ক্ষমতা কমাতে চাই। ট্রেনের টিকিটের লাইনে মাঝখান থেকে কাউকে ঢুকতে দেওয়া হয় না — নিয়মটাই লাইনের শক্তি। STL-এর container adapter ঠিক এই কাজ করে: একটি সাধারণ container (ডিফল্টে deque বা vector) নিয়ে তার ওপর শুধু কয়েকটি কাজের অনুমতি দেয়।

Adapterনিয়মমূল কাজভেতরে (ডিফল্ট)সময়
stack<T>শেষে ঢোকে, আগে বের হয় (LIFO)push, top, popdequeO(1)
queue<T>আগে ঢোকে, আগে বের হয় (FIFO)push, front, popdequeO(1)
priority_queue<T>সবসময় সবচেয়ে বড়টি ওপরেpush, top, popvector-এর ওপর binary heappush/pop O(log n), top O(1)

Stack আর queue আপনি পর্ব ১৩ ও ১৪-এ বানিয়েছেন। নতুন হলো priority queue: হাসপাতালের জরুরি বিভাগের কথা ভাবুন — যে আগে এসেছে সে নয়, যার অবস্থা সবচেয়ে গুরুতর, তাঁকে আগে দেখা হয়। ভেতরে আছে একটি heap (পর্ব ২৩): একটি array, যেখানে প্রতিটি ঘরের মান তার “সন্তান” ঘরগুলোর চেয়ে বড় বা সমান। তাই সবচেয়ে বড়টি সবসময় সামনে, আর নতুন যোগ বা সামনেরটি বাদ দিতে শুধু গাছের উচ্চতা বরাবর — O(log n) — ঠিকঠাক করতে হয়।

priority_queue<int> mx;                              // max-heap: top() = largest
priority_queue<int, vector<int>, greater<int>> mn;   // min-heap: top() = smallest
for (int x : {5, 1, 8, 3}) { mx.push(x); mn.push(x); }
// mx.top() == 8, mn.top() == 1

// pairs compare lexicographically: first, then second
priority_queue<pair<int,int>, vector<pair<int,int>>, greater<>> pq;  // (dist, node)
pq.push({4, 2}); pq.push({1, 7}); pq.push({1, 3});
auto [d, v] = pq.top();                              // d == 1, v == 3

দুটি পরিচিত ভুল। (১) C++-এর priority_queue ডিফল্টে max-heap, আর Python-এর heapq-এর প্রচলিত ফাংশনগুলো min-heap — ভাষা বদলালে উল্টে যায়। Python-এ max-heap চাইলে মানগুলো ঋণাত্মক করে রাখুন (heappush(h, -x)); Python 3.14 থেকে heappush_max-এর মতো আলাদা max-heap ফাংশনও আছে। (২) priority_queue-এ কোনো উপাদানের মান কমানো (decrease-key) বা মাঝখান থেকে মুছে ফেলার কোনো ফাংশন নেই। Dijkstra-তে (পর্ব ৪৩) তাই নতুন জোড়া আবার push করে পুরোনোটি বের হলে বাদ দেওয়া হয় — একে বলে lazy deletion.

pair আর tuple — ছোট দলবদ্ধ মান

pair<int, string> দুটি মান একসাথে রাখে (.first, .second), আর tuple<int, int, string> যেকোনো সংখ্যক। এদের সবচেয়ে কাজের বৈশিষ্ট্য: তুলনা হয় lexicographic ক্রমে — আগে প্রথম মান, সমান হলে দ্বিতীয়, এভাবে — অভিধানে শব্দ সাজানোর মতো। তাই vector<pair<int,int>>-এ সরাসরি sort করলে প্রথম মান ধরে, সমান হলে দ্বিতীয় মান ধরে সাজানো হয়। C++17-এর structured binding (auto [d, v] = p;) দিয়ে খুলে নেওয়াও সহজ। Python-এর tuple একইভাবে তুলনা হয়, আর এটি পরিবর্তন করা যায় না (immutable) বলে dict-এর key বা set-এর সদস্য হতে পারে — অংশ ৯-এ এর গুরুত্ব দেখবেন।

৪ · Ordered container — set, multiset, map, multimap

একটি লাইব্রেরির তাক ভাবুন, যেখানে বইগুলো সবসময় লেখকের নাম অনুযায়ী বর্ণানুক্রমে সাজানো। নতুন বই এলে ঠিক জায়গায় ঢোকানো হয়; কোনো বই খুঁজতে মাঝখান থেকে শুরু করে বাঁয়ে-ডানে যাওয়া যায়; আর “R অক্ষর থেকে শুরু হওয়া প্রথম লেখক কে?” — এই প্রশ্নের উত্তরও দ্রুত মেলে। C++-এর ordered associative container-গুলো ঠিক এই তাক।

  • set<T> — ভিন্ন ভিন্ন key, সাজানো। একই মান দ্বিতীয়বার যোগ করলে কিছু হয় না।
  • multiset<T> — একই key একাধিকবার থাকতে পারে (multiset).
  • map<K, V> — প্রতিটি ভিন্ন key-এর সাথে একটি মান; key অনুযায়ী সাজানো। ফোনবুকের মতো: নাম → নম্বর।
  • multimap<K, V> — একই key-এর একাধিক জোড়া।

ভেতরে এরা একটি balanced binary search tree: প্রতিটি node-এর বাঁ দিকের সব key তার চেয়ে ছোট, ডান দিকের সব বড়। C++ standard গাছের নাম বলে না, শুধু complexity বেঁধে দেয় — খোঁজা, যোগ, মুছে ফেলা O(log n). সেই নিশ্চয়তা পূরণ করতে GCC, Clang আর Microsoft — তিনটি প্রধান লাইব্রেরিই red-black tree ব্যবহার করে, যার উচ্চতা সবসময় 2·log₂(n + 1)-এর বেশি হয় না (প্রমাণ পর্ব ২৯-এ). উচ্চতা O(log n), তাই প্রতিটি কাজ O(log n) — ইনপুট যেমনই হোক, worst case-এও।

কাজকোডসময়
যোগs.insert(x), m[k] = v, m.insert({k, v})O(log n)
আছে কি নাs.count(x), s.contains(x) (C++20), m.find(k)O(log n)
মুছে ফেলাs.erase(x), s.erase(it)O(log n); iterator দিলে amortized O(1)
প্রথম ≥ x / প্রথম > xs.lower_bound(x), s.upper_bound(x)O(log n)
সবচেয়ে ছোট / বড়*s.begin(), *s.rbegin()O(1)
সাজানো ক্রমে সব ঘোরাfor (auto& x : s)মোট O(n)

শেষ দুই সারিই ordered container-এর আসল শক্তি — hash table এগুলো দিতে পারে না। “৫০০ টাকার সমান বা বেশি দামের সবচেয়ে সস্তা জিনিস কোনটি?”, “এই সময়ের ঠিক আগের ঘটনা কোনটি?” — এমন প্রশ্নের উত্তর lower_bound দিয়ে O(log n)-এ। এবার map দিয়ে শব্দ গোনার কাজটি ধাপে ধাপে দেখুন, আর খেয়াল রাখুন [] অপারেটর কী করে:

1map<string, int> cnt;2for (const string& w : words)3    ++cnt[w];             // missing key: insert 0, then ++4if (cnt["mango"] > 0) {}  // BUG: inserts mango = 05if (cnt.contains("mango")) {}  // C++20: no insert
এই ধাপে
শব্দ
key আগে ছিল?
cnt.size()
map-এর ভেতরে (সবসময় key-এর ক্রমে)

                    
  1. প্রথম শব্দ rice. cnt["rice"] key-টি পায়নি, তাই নিজেই rice = 0 বসাল, তারপর ++ করে 1. গোনার কাজে এই আচরণ সুবিধাজনক — আলাদা করে “আছে কি না” দেখতে হয় না।
  2. fish. নতুন key, বসল 1. খেয়াল করুন, map-এ fish আগে দেখাচ্ছে, কারণ বর্ণানুক্রমে f < r. ঢোকানোর ক্রম নয়, key-এর ক্রমই map-এর ক্রম।
  3. dal. বসল সবার আগে (d < f < r). ভেতরের গাছটি নিজেকে প্রয়োজনমতো ঘুরিয়ে (rotation) ভারসাম্য রাখে — আমাদের তা নিয়ে ভাবতে হয় না।
  4. rice আবার। key আছে, তাই শুধু মান বাড়ল: rice = 2. size বদলায়নি। খোঁজা আর বাড়ানো মিলিয়ে O(log n).
  5. egg. dal আর fish-এর মাঝে বসল। এখন ৪টি ভিন্ন শব্দ। পুরো ৫টি শব্দ গুনতে মোট O(5 · log 5) কাজ — সাধারণভাবে n টি শব্দে O(n log n) তুলনা (প্রতিটি string তুলনার খরচ আলাদা)।
  6. ফাঁদ! শুধু জানতে চেয়েছিলাম mango আছে কি না, লিখলাম cnt["mango"] > 0. কিন্তু [] না পেলে key বানিয়ে ফেলে — map-এ এখন mango = 0, size 5. পরে “কয়টি ভিন্ন শব্দ?” জিজ্ঞেস করলে ভুল উত্তর আসবে, আর লুপের ভেতরে এমন হাজার প্রশ্নে map অকারণে ফুলে উঠবে।
  7. সঠিক উপায়। শুধু খোঁজার জন্য contains (C++20), count বা find ব্যবহার করুন — এগুলো map বদলায় না। ধাপ ৫-এর map-এ contains("mango") মিথ্যা দেয়, size থাকে 4. আরেকটি কারণে [] সাবধানে: const map-এ এটি compile-ই হয় না, তখন at(k) লিখতে হয় (key না থাকলে exception দেয়)।

multiset-এর erase. ms.erase(5) লিখলে multiset থেকে সব 5 মুছে যায়, একটি নয়! একটি কপি মুছতে লিখুন ms.erase(ms.find(5)); (আগে নিশ্চিত হোন find end() দেয়নি)। কনটেস্টে sliding window-এর সবচেয়ে বড় বা ছোট মান রাখতে multiset খুব জনপ্রিয়, আর এই ভুলটিও তেমনি জনপ্রিয়।

৫ · Hash container — unordered_set, unordered_map আর anti-hash টেস্ট

লাইব্রেরির সাজানো তাক ভালো, কিন্তু যদি ক্রমের দরকার না থাকে? বড় অফিসের ডাকঘরের খোপগুলো ভাবুন: প্রত্যেক কর্মচারীর আইডির শেষ দুই অঙ্ক দেখে চিঠি সরাসরি সেই খোপে — খোঁজাখুঁজি নেই। এটিই hash table: একটি hash function key থেকে একটি সংখ্যা বানায়, আর সেই সংখ্যা ধরে একটি bucket (খোপ) বেছে নেওয়া হয়। C++-এর unordered_set আর unordered_map এভাবেই কাজ করে; একই bucket-এ একাধিক key পড়লে (collision) সেগুলো একটি ছোট শিকলে (chaining) রাখা হয়।

গড়ে প্রতিটি bucket-এ খুব কম key থাকে, তাই খোঁজা, যোগ আর মুছে ফেলা গড়ে O(1). ভরাট-অনুপাত বা load factor (উপাদান ÷ bucket) একটি সীমা (C++-এ ডিফল্ট 1.0) ছাড়ালে টেবিল বড় করে সব key নতুন bucket-এ বসানো হয় — rehashing, যার খরচ একবারে O(n) কিন্তু amortized O(1), ঠিক vector-এর দ্বিগুণ হওয়ার মতো। কিন্তু সব key যদি একই bucket-এ পড়ে? নিচে দেখুন:

ধরি bucket সংখ্যা 7, bucket = key mod 7
যোগ করা key
bucket
সবচেয়ে লম্বা শিকল
load factor
Bucket-এর অবস্থাপ্রতি সারি = একটি খোপ

                
  1. 10 যোগ। GCC-তে std::hash<int> সংখ্যাটিকেই ফেরত দেয়, আর bucket = hash mod bucket-সংখ্যা। 10 mod 7 = 3, তাই b3. খোঁজার সময়ও একই হিসাব — সরাসরি b3-তে যাওয়া হয়।
  2. 22 যোগ। 22 mod 7 = 1 → b1. দুটি key, দুটি আলাদা খোপ।
  3. প্রথম collision. 15 mod 7 = 1 — b1-এ আগেই 22 আছে। 15 সেই শিকলে যুক্ত হলো। এখন b1-এ কিছু খুঁজতে ২টি তুলনা লাগতে পারে।
  4. 3 যোগ। 3 mod 7 = 3, b3-তে 10-এর সাথে। শিকল এখনো ছোট — সর্বোচ্চ ২।
  5. 31 যোগ। 31 mod 7 = 3, b3-এর শিকল এখন ৩টির। load factor 0.71, এখনো 1.0-এর নিচে, তাই rehash হয়নি। এলোমেলো key-তে শিকল গড়ে ছোট থাকে — এটাই “গড়ে O(1)”-এর ভিত্তি।
  6. আক্রমণ। এবার একটি নতুন খালি টেবিলে কেউ ইচ্ছা করে 7-এর গুণিতক পাঠাল: 7, 14, 21, 28, 35. সবগুলো b0-তে! শিকলের দৈর্ঘ্য ৫, প্রতিটি খোঁজা-যোগে পুরো শিকল দেখতে হয়। n টি এমন key-তে প্রতিটি কাজ O(n), মোট O(n²). এটিই hash table-এর worst case.
  7. প্রতিরক্ষা। নিজের একটি hash function দিন, যা প্রতিবার প্রোগ্রাম চালানোর সময় এলোমেলো একটি seed মিশিয়ে সংখ্যাকে ভালোভাবে “গুলিয়ে” দেয়। আক্রমণকারী আগে থেকে জানে না কোন key কোথায় পড়বে, তাই key-গুলো ছড়িয়ে যায় (ছবির bucket-গুলো শুধু উদাহরণ — আসল অবস্থান seed-এর ওপর নির্ভর করে)। গড়ে O(1) আবার ফিরে এল।

বাস্তবে GCC bucket-সংখ্যা হিসেবে একটি নির্দিষ্ট মৌলিক সংখ্যার তালিকা ব্যবহার করে। Codeforces-এর মতো online judge-এ কনটেস্টের পরে অন্যের কোড “hack” করার সুযোগ থাকে, আর আক্রমণকারী জানেন টেবিল বড় হতে হতে কোন মৌলিক সংখ্যায় পৌঁছাবে — তিনি সেই সংখ্যার গুণিতক দিয়ে ইনপুট বানান। একে বলে anti-hash test. ফল: যে unordered_map সমাধান 0.1 সেকেন্ডে চলত, সেটি সময়সীমা পার হয়ে যায়। কনটেস্ট প্রোগ্রামারদের মধ্যে প্রচলিত সমাধানটি splitmix64 নামের একটি mixing function আর ঘড়ি থেকে নেওয়া seed:

struct SplitMixHash {                          // custom hash for integer keys
    static uint64_t mix(uint64_t x) {           // splitmix64 finalizer
        x += 0x9e3779b97f4a7c15;
        x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9;
        x = (x ^ (x >> 27)) * 0x94d049bb133111eb;
        return x ^ (x >> 31);
    }
    size_t operator()(uint64_t x) const {
        static const uint64_t SEED =
            chrono::steady_clock::now().time_since_epoch().count();
        return mix(x + SEED);                    // different layout every run
    }
};
unordered_map<long long, int, SplitMixHash> freq;  // use like a normal map

এই struct-টি custom hash-এর একটি উদাহরণ — এমন যেকোনো callable যা key থেকে size_t ফেরত দেয়। pair<int,int>-কে key বানাতে চাইলেও (যেমন গ্রিডের স্থানাঙ্ক) এমন একটি hash লিখতে হয়, কারণ STL-এ std::hash<pair> নেই — unordered_map<pair<int,int>, int> সরাসরি compile হয় না। অথচ map<pair<int,int>, int> কোনো ঝামেলা ছাড়াই চলে, কারণ pair-এর < আছে।

বিষয়map / setunordered_map / unordered_set
ভেতরেred-black treebucket-এর array + শিকল
খোঁজা / যোগ / মুছে ফেলাO(log n), worst case-এওগড়ে O(1), worst case O(n)
key-এর কাছে কী লাগে< তুলনাhash function আর ==
সাজানো ক্রম, lower_boundআছেনেই — ঘোরার ক্রম এলোমেলো
বাস্তবে গতিছোট n-এ বেশ দ্রুত, বড় n-এ log-এর দাম দেখা দেয়বড় n-এ সাধারণত দ্রুত, কিন্তু rehash আর বাড়তি মেমরি আছে
ঝুঁকিপ্রায় নেইanti-hash ইনপুট, খারাপ hash

৬ · Python-এর কালেকশন — list, tuple, dict, set আর collections

Python-এ মূল চারটি স্ট্রাকচার ভাষার ভেতরেই আছে, আর বাকিগুলো স্ট্যান্ডার্ড লাইব্রেরির কয়েকটি মডিউলে। নামগুলো C++-এর থেকে আলাদা — আর একটি জায়গায় বিপজ্জনকভাবে বিভ্রান্তিকর: Python-এর list কোনো linked list নয়, এটি C++-এর vector-এর মতো একটি dynamic array.

list — dynamic array

a[i], append, pop() O(1) (append amortized). কিন্তু pop(0), insert(0, x), x in a, a.remove(x) — সব O(n).

stack হিসেবে চমৎকার

tuple — অপরিবর্তনীয় সারি

একবার তৈরির পর বদলায় না; hashable (ভেতরের সব মান hashable হলে), তাই dict-এর key বা set-এর সদস্য হতে পারে। lexicographic তুলনা।

স্থানাঙ্ক, (dist, node) জোড়া

dict — hash table

key → মান; খোঁজা, যোগ, মুছে ফেলা গড়ে O(1), worst case O(n). Python 3.7 থেকে ভাষার নিয়মেই ঢোকানোর ক্রম মনে রাখে।

C++ unordered_map-এর মতো

set / frozenset

ভিন্ন ভিন্ন hashable মান; x in s গড়ে O(1). |, &, - দিয়ে সংযোগ, ছেদ, পার্থক্য। কোনো ক্রম নেই।

C++ unordered_set-এর মতো

এবার collections মডিউলের চারটি কাজের জিনিস — প্রতিটি একটি পরিচিত ঝামেলা দূর করে:

  • deque — দুই প্রান্তে append/appendleft/pop/popleft O(1). BFS-এর (পর্ব ৪০) queue হিসেবে এটিই ব্যবহার করুন, list.pop(0) নয়। ভেতরে ছোট ব্লকের একটি doubly linked list, তাই প্রান্তের কাছে index দ্রুত, কিন্তু একেবারে মাঝখানে d[i] O(n) — C++-এর deque-এর মতো O(1) নয়। deque(maxlen=k) দিলে শুধু শেষ k টি উপাদান থাকে।
  • Counter — dict-এরই একটি subclass, যা গোনার জন্য তৈরি: Counter("banana") → a: 3, n: 2, b: 1. না থাকা key পড়লে 0 দেয়, কিন্তু key তৈরি করে না। most_common(k) সবচেয়ে বেশি k টি দেয়; সমান গণনা হলে যেটি আগে দেখা গেছে সেটি আগে।
  • defaultdict — না থাকা key পড়লেই একটি ডিফল্ট মান বানিয়ে বসিয়ে দেয় — ঠিক C++-এর map[]-এর মতো। defaultdict(list) দিয়ে গ্রাফের adjacency list বানানো খুব সহজ: g[u].append(v).
  • OrderedDict — সাধারণ dict এখন ক্রম রাখে, তবু এর দুটি বাড়তি ক্ষমতা আছে: move_to_end(k) আর popitem(last=False), দুটিই O(1). LRU cache (সবচেয়ে পুরোনো ব্যবহারটি ফেলে দেওয়া) বানানোর ক্লাসিক উপায়।

heapq মডিউল একটি সাধারণ list-কে min-heap হিসেবে ব্যবহার করে: heappush(h, x) আর heappop(h) O(log n), h[0] সবচেয়ে ছোট (O(1)), আর heapify(a) একটি পুরো list-কে O(n)-এ heap বানায়। bisect মডিউল সাজানো list-এ binary search করে: bisect_left(a, x) হলো C++-এর lower_bound, bisect_right(a, x) হলো upper_bound — দুটিই O(log n). insort(a, x) সাজানো ক্রম রেখে ঢোকায়, কিন্তু জায়গা খুঁজতে O(log n) হলেও list-এ ঢোকাতে O(n).

Python-এ সাজানো set বা map নেই। C++-এর set/map-এর মতো “যোগ-মুছে ফেলা O(log n), আবার lower_bound-ও O(log n)” — এমন স্ট্রাকচার Python-এর স্ট্যান্ডার্ড লাইব্রেরিতে নেই। শুধু খোঁজা লাগলে একবার সাজানো list + bisect যথেষ্ট; বারবার যোগ-বাদ লাগলে বাইরের sortedcontainers প্যাকেজ (অনেক কনটেস্ট সাইটে থাকে না), heap + lazy deletion, অথবা পর্ব ৩৬-এর Fenwick tree দিয়ে কাজ চালাতে হয়। কনটেস্টে Python ব্যবহারকারীরা এই সীমাবদ্ধতায় প্রায়ই আটকে যান।

আর সাজানো: sorted(iterable, key=..., reverse=...) নতুন list দেয়, a.sort(...) জায়গাতেই সাজায়। দুটিই Timsort অ্যালগরিদম, O(n log n), আর stable — সমান key-এর উপাদানের আগের ক্রম বদলায় না। key হলো প্রতিটি উপাদান থেকে তুলনার মাপকাঠি বানানোর একটি ফাংশন: key=len দৈর্ঘ্য ধরে, key=lambda p: (-p[1], p[0]) দ্বিতীয় মান বড় থেকে ছোট আর সমান হলে প্রথম মান ছোট থেকে বড়। key প্রতিটি উপাদানের জন্য মাত্র একবার হিসাব হয়।

৭ · তৈরি অ্যালগরিদম আর iterator — sort, lower_bound, next_permutation

STL-এর <algorithm> আর <numeric> header-এ শতাধিক তৈরি অ্যালগরিদম আছে। সবগুলো কাজ করে iterator-এর একটি জোড়া [first, last)-এর ওপর — first প্রথম উপাদানে, last শেষ উপাদানের এক ঘর পরে। এই অর্ধ-খোলা পরিসরের সুবিধা: উপাদানের সংখ্যা ঠিক last − first, আর খালি পরিসর মানে first == last. কনটেস্ট আর পরের পর্বগুলোতে সবচেয়ে বেশি লাগবে এগুলো:

অ্যালগরিদমকাজসময়Python-এ
sort(b, e), sort(b, e, cmp)সাজানো; stable নয়O(n log n)a.sort(), sorted() (stable)
stable_sort(b, e)সমান উপাদানের ক্রম রেখে সাজানোO(n log n), বাড়তি মেমরি না পেলে O(n log² n)ডিফল্টেই stable
binary_search(b, e, x)সাজানো পরিসরে x আছে কি না (শুধু হ্যাঁ/না)O(log n)bisect_left + তুলনা
lower_bound(b, e, x)প্রথম ঘর যেখানে মান ≥ xO(log n)bisect_left(a, x)
upper_bound(b, e, x)প্রথম ঘর যেখানে মান > xO(log n)bisect_right(a, x)
next_permutation(b, e)অভিধান-ক্রমে পরের বিন্যাস; শেষটির পর falseপ্রতি কল O(n), amortized O(1)itertools.permutations
accumulate(b, e, init)যোগফল (বা অন্য কোনো ভাঁজ)O(n)sum(), itertools.accumulate
reverse, max_element, count, uniqueউল্টানো, সর্বোচ্চ, গোনা, পাশাপাশি সদৃশ বাদO(n)reversed, max, a.count

C++11 থেকে standard-এ std::sort-এর জন্য worst case-এও O(n log n) তুলনা বাধ্যতামূলক। এজন্য GCC আর অন্যরা introsort ব্যবহার করে: সাধারণত quick sort চালায়, রিকার্শন বেশি গভীর হয়ে গেলে heapsort-এ চলে যায়, আর ছোট টুকরোয় insertion sort (পর্ব ২০, ২২, ২৩; লাইব্রেরির sort-এর পুরো গল্প পর্ব ২৫-এ). sort-এ নিজের তুলনা দিতে একটি comparator লাগে — দুটি উপাদান নিয়ে “প্রথমটি কি আগে বসবে?” প্রশ্নের উত্তর দেয় এমন ফাংশন। এর একটি কঠোর শর্ত আছে: সমান উপাদানের জন্য অবশ্যই false দিতে হবে। <-এর বদলে <= লিখলে আচরণ undefined — কখনো ভুল ক্রম, কখনো crash.

vector<int> p = {1, 2, 3};
do { /* visits 123 132 213 231 312 321 */ } while (next_permutation(p.begin(), p.end()));

vector<long long> bills = {120, 450, 80};
long long total = accumulate(bills.begin(), bills.end(), 0LL);   // 650 -- note 0LL, not 0

accumulate-এর তৃতীয় মানটির type-ই যোগফলের type. 0 লিখলে যোগফল int-এ হবে, আর বড় মানে নিঃশব্দে overflow — তাই 0LL. next_permutation সব বিন্যাস দিতে চাইলে শুরুতে পরিসরটি সাজানো থাকতে হবে।

lower_bound ভেতরে কীভাবে কাজ করে

lower_bound আসলে পর্ব ১৮-এর binary search-এর একটি বিশেষ রূপ: “x কোথায় আছে” নয়, বরং “x-কে ক্রম না ভেঙে সবচেয়ে বাঁয়ে কোথায় ঢোকানো যায়”। এই প্রশ্নটির সুবিধা হলো, উত্তর সবসময় আছে (x না থাকলেও), আর সদৃশ মান থাকলে প্রথমটির অবস্থান পাওয়া যায়। Pseudocode (0-based, অর্ধ-খোলা পরিসর):

Pseudocode (0-based; a[0..n-1] sorted ascending)

LOWER-BOUND(a, n, x):                // first i with a[i] >= x, or n
    lo = 0, hi = n                   // answer is always in [lo, hi]
    while lo < hi:
        mid = lo + (hi - lo) / 2     // integer division
        if a[mid] < x: lo = mid + 1  // a[0..mid] are all < x
        else:          hi = mid      // a[mid] >= x, so answer <= mid
    return lo

UPPER-BOUND: same, but test  a[mid] <= x  instead of  a[mid] < x
চলক
খুঁজছি
lo, hi
mid, a[mid]
সিদ্ধান্ত
Array[ ] = বাকি পরিসর [lo, hi), ( ) = mid

                
  1. শুরু। lo = 0, hi = 6 (পুরো array). mid = 3, a[3] = 20. 20 < 20 মিথ্যা, তাই উত্তর 3 বা তার বাঁয়ে: hi = 3. লক্ষ করুন — 20 পেয়েও থামিনি, কারণ আরও বাঁয়ে আরেকটি 20 থাকতে পারে।
  2. বাঁ অর্ধেক। lo = 0, hi = 3, mid = 1, a[1] = 20 — আবার ≥ 20, তাই hi = 1.
  3. শেষ প্রার্থী। lo = 0, hi = 1, mid = 0, a[0] = 10 < 20. তাই 0 নম্বর ঘর উত্তর হতে পারে না: lo = 1.
  4. lower_bound = 1. lo = hi = 1, লুপ থামল। ঘর 1 হলো প্রথম 20. invariant ছিল: lo-এর বাঁয়ের সব মান < x, আর hi থেকে ডানের সব মান ≥ x — পরিসর শূন্য হলে lo-ই উত্তর। মোট ৩টি তুলনা ≈ log₂ 6.
  5. এবার upper_bound(20). একই শুরু: mid = 3, a[3] = 20. এবার প্রশ্ন “20 ≤ 20?” — সত্য, তাই উত্তর mid-এর ডানে: lo = 4.
  6. ডান অর্ধেক। lo = 4, hi = 6, mid = 5, a[5] = 40 > 20, তাই hi = 5.
  7. আরেক ধাপ। lo = 4, hi = 5, mid = 4, a[4] = 30 > 20, তাই hi = 4.
  8. upper_bound = 4. প্রথম যে ঘরের মান 20-এর বেশি। একটি দারুণ ফল: upper − lower = 4 − 1 = 3 = সাজানো array-তে 20 কতবার আছে, মাত্র O(log n) সময়ে। [lower, upper) হলো সব 20-এর পরিসর।

দুটি ভাষাতেই চালিয়ে দেখুন — এখানে চারটি ভিন্ন x নেওয়া হয়েছে: আছে, নেই কিন্তু মাঝে, সবার চেয়ে ছোট, আর সবার চেয়ে বড়। শেষ লাইনে একটি set-এ “x-এর সমান বা বড় প্রথম মান” আর “x-এর সমান বা ছোট শেষ মান” বের করা হয়েছে — কনটেস্টে খুবই প্রচলিত দুটি প্রশ্ন।

#include <algorithm>
#include <initializer_list>
#include <iostream>
#include <iterator>
#include <set>
#include <vector>
using namespace std;

int main() {
    vector<int> a = {10, 20, 20, 20, 30, 40};          // must be sorted
    cout << "a = 10 20 20 20 30 40\n";
    for (int x : {20, 25, 5, 45}) {
        auto lo = lower_bound(a.begin(), a.end(), x) - a.begin();  // first >= x
        auto hi = upper_bound(a.begin(), a.end(), x) - a.begin();  // first >  x
        bool found = binary_search(a.begin(), a.end(), x);
        cout << "x=" << x << ": lower=" << lo << " upper=" << hi
             << " count=" << hi - lo << " found=" << (found ? "yes" : "no") << '\n';
    }
    set<int> s(a.begin(), a.end());                     // {10, 20, 30, 40}
    auto ge = s.lower_bound(25);                        // member function: O(log n)
    auto le = prev(s.upper_bound(25));                  // last element <= 25
    cout << "set: first >= 25 is " << *ge << ", last <= 25 is " << *le << '\n';
}

আউটপুট:

a = 10 20 20 20 30 40
x=20: lower=1 upper=4 count=3 found=yes
x=25: lower=4 upper=4 count=0 found=no
x=5: lower=0 upper=0 count=0 found=no
x=45: lower=6 upper=6 count=0 found=no
set: first >= 25 is 30, last <= 25 is 20

x = 20-এর ফল ওপরের ডায়াগ্রামের সাথে মেলে। x = 25 নেই, তাই lower = upper = 4 — দুটিই “25 ঢোকানোর জায়গা” দেখায়। x = 45 সবার চেয়ে বড়, তাই উত্তর 6 = a.size(), অর্থাৎ end(); এমন ক্ষেত্রে *it পড়া undefined behavior, তাই আসল কোডে আগে it != a.end() যাচাই করুন। একইভাবে prev(s.upper_bound(x)) লেখার আগে নিশ্চিত হতে হবে upper_bound begin() নয় (x সবার চেয়ে ছোট হলে)। পার্থক্যটি লক্ষ করুন: set-এ s.lower_bound(25) (member) লিখেছি, lower_bound(s.begin(), s.end(), 25) নয় — কারণ অংশ ৯-এ বলছি।

from bisect import bisect_left, bisect_right

a = [10, 20, 20, 20, 30, 40]                 # must be sorted
print("a =", *a)
for x in [20, 25, 5, 45]:
    lo = bisect_left(a, x)                   # first index with a[i] >= x
    hi = bisect_right(a, x)                  # first index with a[i] >  x
    found = lo < len(a) and a[lo] == x
    yn = "yes" if found else "no"
    print(f"x={x}: lower={lo} upper={hi} count={hi - lo} found={yn}")

s = sorted(set(a))                           # no built-in sorted set in Python
ge = s[bisect_left(s, 25)]                   # first element >= 25
le = s[bisect_right(s, 25) - 1]              # last element <= 25
print(f"set: first >= 25 is {ge}, last <= 25 is {le}")

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

a = 10 20 20 20 30 40
x=20: lower=1 upper=4 count=3 found=yes
x=25: lower=4 upper=4 count=0 found=no
x=5: lower=0 upper=0 count=0 found=no
x=45: lower=6 upper=6 count=0 found=no
set: first >= 25 is 30, last <= 25 is 20

Python-এ binary_search-এর আলাদা ফাংশন নেই; bisect_left-এর পরে lo < len(a) যাচাই করে মান মেলাতে হয় — যাচাইটি বাদ দিলে x = 45-এ IndexError আসত। sorted(set(a)) একবার O(n log n)-এ সাজানো list বানায়; এরপর প্রতিটি প্রশ্ন O(log n). কিন্তু এর মধ্যে নতুন মান যোগ করতে হলে insort লাগবে, যা O(n) — C++-এর set::insert-এর O(log n) এখানে নেই। Python 3.10 থেকে bisect-এ key= parameter-ও দেওয়া যায়।

৮ · বড় তুলনা টেবিল আর কোনটি বেছে নেবেন

সবকিছু এক জায়গায়। পরের পর্বগুলো পড়ার সময় এই টেবিলটি হাতের কাছে রাখুন। “গড়ে” লেখা মানে hash-নির্ভর; worst case তার পাশে বন্ধনীতে।

ধারণাC++Pythonমূল কাজ ও সময়মনে রাখুন
Dynamic arrayvectorlistindex O(1); শেষে যোগ amortized O(1); শুরু/মাঝে O(n); খোঁজা O(n)Python list ≠ linked list
দুই-মুখী সারিdequecollections.dequeদুই প্রান্তে O(1)C++: index O(1); Python: মাঝে index O(n)
Linked listlist, forward_list— (deque দিয়ে চালান)iterator-এ যোগ/মুছে ফেলা O(1); index O(n)বাস্তবে কমই লাগে
Stackstacklist (append/pop)push/pop/top O(1)খালি stack-এ pop করবেন না
Queuequeuedeque (append/popleft)O(1)Python-এ list.pop(0) O(n)
Priority queuepriority_queue (max)heapq (min)push/pop O(log n); top O(1); heapify O(n)দুই ভাষায় দিক উল্টো
সাজানো setset, multisetনেই (সাজানো list + bisect)C++: সব O(log n); Python: খোঁজা O(log n), যোগ O(n)multiset-এ erase(x) সব মুছে দেয়
সাজানো mapmap, multimapনেইO(log n), key-এর ক্রমে ঘোরা[] নতুন key বানায়
Hash setunordered_setset, frozensetগড়ে O(1) (worst O(n))শুধু hashable মান
Hash mapunordered_mapdict, defaultdict, Counterগড়ে O(1) (worst O(n))Python dict ঢোকানোর ক্রম রাখে
জোড়া / দলpair, tupletuplelexicographic তুলনাPython tuple dict-এর key হতে পারে
সাজানোsort, stable_sortsorted, list.sortO(n log n)C++ sort stable নয়
Binary searchlower_bound, upper_boundbisect_left, bisect_rightO(log n), শুধু সাজানো ডেটায়সাজানো না থাকলে উত্তর অর্থহীন

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

#include <algorithm>
#include <iostream>
#include <map>
#include <sstream>
#include <string>
#include <utility>
#include <vector>
using namespace std;

int main() {
    string text = "rice fish dal rice tea fish rice egg tea dal rice";
    map<string, int> cnt;                         // ordered by word
    istringstream in(text);
    for (string w; in >> w; ) ++cnt[w];          // [] inserts 0, then ++

    cout << "alphabetical (map order):";
    for (const auto& [w, c] : cnt) cout << ' ' << w << '=' << c;
    cout << '\n';

    vector<pair<string, int>> v(cnt.begin(), cnt.end());
    sort(v.begin(), v.end(), [](const auto& a, const auto& b) {
        if (a.second != b.second) return a.second > b.second;   // count: high first
        return a.first < b.first;                                // then word: A to Z
    });
    cout << "by count, then word:\n";
    for (const auto& [w, c] : v) cout << c << ' ' << w << '\n';
    cout << "mango present? " << (cnt.contains("mango") ? "yes" : "no")
         << ", distinct words = " << cnt.size() << '\n';
}

আউটপুট:

alphabetical (map order): dal=2 egg=1 fish=2 rice=4 tea=2
by count, then word:
4 rice
2 dal
2 fish
2 tea
1 egg
mango present? no, distinct words = 5

প্রথম লাইনে map নিজেই বর্ণানুক্রমে ঘুরেছে — আলাদা করে সাজাতে হয়নি। তারপর map-এর জোড়াগুলো একটি vector-এ কপি করে comparator দিয়ে সাজানো হলো, কারণ map-এর ক্রম key-এর, মানের নয়। comparator-এ সমান অবস্থায় false আসে (> আর <, কখনো >= নয়)। মোট সময়: N টি শব্দ গুনতে O(N log k), যেখানে k ভিন্ন শব্দের সংখ্যা, আর সাজাতে O(k log k) — string তুলনার খরচ শব্দের দৈর্ঘ্যের সমানুপাতিক। contains map বদলায়নি, তাই ভিন্ন শব্দ ঠিক ৫টিই।

from collections import Counter

text = "rice fish dal rice tea fish rice egg tea dal rice"
cnt = Counter(text.split())                  # a dict: word -> count

print("alphabetical (map order):", " ".join(f"{w}={cnt[w]}" for w in sorted(cnt)))

print("by count, then word:")
for w, c in sorted(cnt.items(), key=lambda kv: (-kv[1], kv[0])):
    print(c, w)

yn = "yes" if "mango" in cnt else "no"
print(f"mango present? {yn}, distinct words = {len(cnt)}")

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

alphabetical (map order): dal=2 egg=1 fish=2 rice=4 tea=2
by count, then word:
4 rice
2 dal
2 fish
2 tea
1 egg
mango present? no, distinct words = 5

Counter একটি hash table, তাই নিজে বর্ণানুক্রম রাখে না — প্রথম লাইনে sorted(cnt) লাগল। key ফাংশন (-count, word) একটি tuple দেয়; tuple-এর lexicographic তুলনায় আগে ঋণাত্মক গণনা (মানে বড় গণনা আগে), তারপর শব্দ। গুনতে গড়ে O(N), সাজাতে O(k log k). সতর্কতা: এখানে cnt.most_common() লিখলে ক্রম হতো rice, fish, dal, tea, egg — সমান গণনায় Counter প্রথম দেখার ক্রম রাখে, বর্ণানুক্রম নয়। প্রশ্ন যদি টাই-ভাঙার নিয়ম বলে দেয়, নিজে key দিয়ে সাজান।

কোন container বেছে নেবেন — ধাপে ধাপে

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

  1. শুধু ক্রমে রাখা আর index ধরে পড়া?vector / listসবচেয়ে সাধারণ উত্তর
  2. দুই প্রান্তে যোগ-বাদ, বা শুধু এক প্রান্তে?deque / stack / queueলাইনের নিয়ম
  3. বারবার শুধু সবচেয়ে বড় বা ছোটটি চাই?priority_queue / heapqজরুরি বিভাগের নিয়ম
  4. key দিয়ে খোঁজা, ক্রমের দরকার নেই?unordered_map / dictডাকঘরের খোপ
  5. key দিয়ে খোঁজা, আর ক্রম বা “পরের বড়টি” চাই?set / mapসাজানো তাক
  6. ডেটা একবার আসে, পরে শুধু প্রশ্ন?sort + lower_bound / bisectসবচেয়ে সস্তা সাজানো সমাধান
উদাহরণক্লাসের রোল অনুযায়ী নম্বর
C++:    vector<int> marks(n);  marks[roll] = 78;
Python: marks = [0] * n;       marks[roll] = 78
সময়:   index O(1), শেষে যোগ amortized O(1)
উদাহরণBFS-এর queue, বন্ধনী মেলানোর stack
C++:    queue<int> q;  q.push(s);  q.front();  q.pop();
Python: q = deque([s]);  q.popleft()
সময়:   প্রতিটি কাজ O(1)
উদাহরণDijkstra, কাজের অগ্রাধিকার, k-তম বড়
C++:    priority_queue<T, vector<T>, greater<T>> pq;  // min
Python: heapq.heappush(h, (dist, node))
সময়:   push/pop O(log n), সবচেয়ে ছোট দেখা O(1)
উদাহরণbKash নম্বর → নাম, শব্দ গোনা, দেখা হয়েছে কি না
C++:    unordered_map<string, int> freq;  freq[w]++;
Python: freq = Counter(words)
সময়:   গড়ে O(1); কনটেস্টে custom hash ভাবুন
উদাহরণ“৫০০ টাকার সমান বা বেশি সবচেয়ে সস্তা পণ্য”
C++:    set<int> prices;  auto it = prices.lower_bound(500);
Python: স্ট্যান্ডার্ড লাইব্রেরিতে নেই — সাজানো list + bisect,
        বা heap / Fenwick tree দিয়ে বিকল্প
সময়:   C++-এ যোগ, মুছে ফেলা, খোঁজা — সব O(log n)
উদাহরণসব দাম আগে জানা, পরে লাখো প্রশ্ন
C++:    sort(a.begin(), a.end());  lower_bound(a.begin(), a.end(), x);
Python: a.sort();  bisect_left(a, x)
সময়:   একবার O(n log n), প্রতি প্রশ্ন O(log n);
        set-এর চেয়ে কম মেমরি আর cache-বান্ধব
  1. প্রশ্ন ১. শুধু উপাদানগুলো রাখা, ক্রমে ঘোরা আর index ধরে পড়া দরকার? তাহলে vector (Python-এ list). অধিকাংশ সমস্যার উত্তর এখানেই শেষ। মাঝখানে বারবার যোগ-বাদ না থাকলে আর কিছু ভাববেন না।
  2. প্রশ্ন ২. কাজ কি শুধু প্রান্তে? এক প্রান্তে ঢোকা-বের হওয়া হলে stack, এক দিক দিয়ে ঢুকে অন্য দিক দিয়ে বের হলে queue, দুই দিকেই হলে deque. Python-এ queue-এর জন্য সবসময় collections.deque.
  3. প্রশ্ন ৩. বারবার “এখন সবচেয়ে ছোট বা বড় কোনটি” জানতে আর সেটি বের করে নিতে হয়, কিন্তু পুরো ক্রম লাগে না? তাহলে heap — C++-এ priority_queue, Python-এ heapq. সাজানো set-ও পারত, কিন্তু heap সহজ আর দ্রুত।
  4. প্রশ্ন ৪. কোনো key (নাম, নম্বর, শব্দ) দিয়ে খুঁজতে হয়, আর ক্রমের কোনো দরকার নেই? hash — unordered_map/unordered_set, Python-এ dict/set/Counter. খোলা প্রতিযোগিতায় (hack আছে এমন) custom hash যোগ করুন, অথবা নিরাপদে map নিন।
  5. প্রশ্ন ৫. key-এর ক্রম লাগে — সাজানো ছাপানো, সবচেয়ে ছোট/বড় key, বা “x-এর পরের বড়টি কোনটি”, আর ডেটা বারবার বদলায়? তাহলে set/map. এখানে Python-এর হাতে তৈরি কিছু নেই — এটি জেনে রাখা দরকার।
  6. প্রশ্ন ৬. ডেটা যদি শুরুতে একবারে আসে আর পরে শুধু প্রশ্ন হয়, তাহলে set-এর চেয়েও সস্তা উপায় আছে: একবার সাজিয়ে lower_bound/bisect. মেমরি কম, cache-বান্ধব, আর দুই ভাষাতেই চলে। ভালো সমাধান প্রায়ই সবচেয়ে সরল স্ট্রাকচারটি।

৯ · সাধারণ ফাঁদ — যেখানে তৈরি স্ট্রাকচারও কামড় দেয়

তৈরি container ভুল করে না, কিন্তু আমরা ভুলভাবে ব্যবহার করি। নিচের ফাঁদগুলো ইন্টারভিউ, কনটেস্ট আর বাস্তব প্রজেক্টে বারবার দেখা যায়।

C++-এর ফাঁদ

  • map[] নতুন key বানায়। অংশ ৪-এর ডায়াগ্রামে দেখেছেন। শুধু খোঁজার জন্য find/count/contains.
  • Iterator invalidation. container বদলালে পুরোনো iterator, pointer বা reference আর বৈধ নাও থাকতে পারে। vector-এ reallocation হলে সব নষ্ট; erase হলে মুছে ফেলা ঘর আর তার পরের সব নষ্ট। map/set/list-এ শুধু মুছে ফেলা উপাদানটির iterator নষ্ট হয়, বাকিরা ঠিক থাকে। unordered_map-এ rehash হলে iterator নষ্ট হয় (reference টিকে থাকে)।
  • লুপের ভেতরে মুছে ফেলা। for (auto it = m.begin(); it != m.end(); ++it) if (...) m.erase(it); — মুছে ফেলার পর ++it একটি নষ্ট iterator-এ চলে। সঠিক রূপ: it = m.erase(it); (erase পরের iterator ফেরত দেয়), নইলে ++it. C++20-এ সহজ পথ: std::erase_if(m, pred).
  • set-এ সাধারণ lower_bound. lower_bound(s.begin(), s.end(), x) compile হয় আর ঠিক উত্তরও দেয়, কিন্তু set-এর iterator এক এক ঘর করে হাঁটে, তাই সময় O(n)! সবসময় member ফাংশন s.lower_bound(x) — O(log n). কনটেস্টে TLE-র একটি ক্লাসিক কারণ।
  • size() unsigned. v.size() - 1 খালি vector-এ 0 − 1 হয়ে একটি বিশাল ধনাত্মক সংখ্যা হয় (unsigned wrap-around). for (int i = 0; i < (int)v.size() - 1; ++i) লিখুন, বা C++20-এর ssize(v).
  • unordered_map-এ pair বা vector key. hash নেই, compile error. map নিন, বা custom hash লিখুন।

Python-এর ফাঁদ

  • ঘোরার সময় dict বা set-এর আকার বদলানো। for k in d: if ...: del d[k] চালালে RuntimeError: dictionary changed size during iteration. list-এ এমন করলে error আসে না, কিন্তু উপাদান বাদ পড়ে যায় — আরও বিপজ্জনক। সমাধান: কপির ওপর ঘুরুন (for k in list(d):) বা নতুন কালেকশন বানান (d = {k: v for k, v in d.items() if keep(v)}).
  • Mutable default argument. def add(x, bag=[]): — ডিফল্ট list-টি ফাংশন সংজ্ঞায়িত হওয়ার সময় একবার তৈরি হয়, প্রতিটি কলে নতুন নয়। তাই add(1) তারপর add(2) দ্বিতীয়বার [1, 2] দেয়! এটি mutable default argument ফাঁদ। সমাধান: bag=None, তারপর ভেতরে if bag is None: bag = [].
  • list-এর set হয় না। {[1, 2], [3]} বা seen.add(path) (path একটি list) দিলে TypeError: unhashable type: 'list'. list বদলানো যায়, তাই তার hash স্থির থাকে না। tuple(path) বা frozenset ব্যবহার করুন।
  • [[0] * m] * n. এতে n টি আলাদা সারি নয়, একই সারির n টি reference তৈরি হয় — এক ঘর বদলালে সব সারিতে বদলায়। লিখুন [[0] * m for _ in range(n)].
  • লুকানো O(n). x in some_list, list.pop(0), list.insert(0, x), list.remove(x) — লুপের ভেতরে থাকলে পুরো অ্যালগরিদম O(n²) হয়ে যায়। খোঁজার জন্য set, সামনে থেকে বের করতে deque.

আরও গভীরে: ordered container-এর অনেক বাস্তবায়ন একটি “augmented” tree দেয়, যা প্রতিটি node-এ বাড়তি তথ্য রাখে। GCC-র নিজস্ব (standard-এর বাইরের) __gnu_pbds::tree দিয়ে find_by_order(k) (k-তম ছোট উপাদান) আর order_of_key(x) (x-এর চেয়ে ছোট কতগুলো) — দুটিই O(log n)-এ পাওয়া যায়, যা সাধারণ set-এ O(n). কনটেস্টে এটি “ordered set” নামে পরিচিত; কিন্তু শুধু GCC-তে চলে, তাই ইন্টারভিউ বা অন্য compiler-এ এর ওপর নির্ভর করবেন না। একই কাজ পর্ব ৩৬-এর Fenwick tree বা পর্ব ৩৮-এর treap দিয়ে নিজে করা যায়।

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

  • std::map আর std::unordered_map-এর পার্থক্য কী? — map: balanced BST (red-black tree), সাজানো, সব কাজ O(log n) worst case; unordered_map: hash table, ক্রমহীন, গড়ে O(1) কিন্তু worst case O(n).
  • vector::push_back কেন amortized O(1)? — capacity গুণিতক হারে (যেমন দ্বিগুণ) বাড়ে, তাই n টি push-এ মোট সরানো < 2n; গড়ে প্রতি push-এ ধ্রুবক।
  • Python-এ queue হিসেবে list কেন নয়? — list.pop(0) বাকি সব উপাদান এক ঘর সরায়, O(n); collections.deque.popleft() O(1).
  • সাজানো array-তে x কতবার আছে, O(log n)-এ? — upper_bound(x) − lower_bound(x); Python-এ bisect_right − bisect_left.
  • Python-এ list-কে dict-এর key বানানো যায় না কেন? — list mutable, তাই hashable নয়; বদলালে hash বদলে যেত আর key হারিয়ে যেত। tuple ব্যবহার করুন।

১০ · AI-এর যুগে কালেকশন — কোড চলে, কিন্তু complexity?

“এই সমস্যাটি Python-এ সমাধান করো” বললে AI প্রায়ই পরিষ্কার, ছোট কোড দেয় — আর ছোট উদাহরণে তা নির্ভুল চলে। কিন্তু তৈরি কালেকশনের লুকানো দাম AI প্রায়ই খেয়াল করে না: একটি x in list বা pop(0) লুপের ভেতরে পুরো সমাধানকে O(n²) বানিয়ে দেয়, অথচ ব্যাখ্যায় লেখা থাকে “O(n)”. complexity-র দাবি তাই প্রতিবার নিজে যাচাই করবেন।

কালেকশন ব্যবহারে AI সাধারণত যেসব ভুল করে

AI-এর ভুলকীভাবে ধরবেন
লুপের ভেতরে x in list, list.pop(0), list.remove রেখে “O(n)” দাবিপ্রতিটি লাইনের পাশে তার complexity লিখুন; n দ্বিগুণ করে সময় চারগুণ হলে সেটি O(n²)
unordered_map-কে “সবসময় O(1)” বলাগড় আর worst case আলাদা করুন; hack আছে এমন কনটেস্টে custom hash চান
set-এ std::lower_bound(s.begin(), s.end(), x)member s.lower_bound(x) কি না দেখুন
খোঁজার জন্য map[] বা defaultdict — নিঃশব্দে key তৈরিশেষে size()/len() ছাপিয়ে প্রত্যাশার সাথে মেলান
comparator-এ <=, বা টাই-ভাঙার নিয়ম ভুল (most_common দিয়ে বর্ণানুক্রম ধরে নেওয়া)সমান মান আছে এমন ছোট ইনপুটে হাতে ফল বের করে মেলান
Python-এ “sorted set”-এর জন্য এমন প্যাকেজ ব্যবহার, যা judge-এ নেইশুধু স্ট্যান্ডার্ড লাইব্রেরি দিয়ে লিখতে বলুন

AI-কে সমাধান লিখতে না বলে, আপনার নিজের লেখা সমাধানের container-পছন্দ যাচাই করাতে ব্যবহার করুন:

আমি DSA শিখছি। নিচে একটি সমস্যা আর আমার সমাধান দিলাম: [সমস্যা আর আপনার কোড এখানে পেস্ট করুন] পুরো নতুন সমাধান লিখে দিও না। শুধু এগুলো করো: ১. কোডের প্রতিটি container অপারেশনের পাশে তার সময় লেখো (গড় আর worst case আলাদা করে)। ২. কোনো লুপের ভেতরে লুকানো O(n) কাজ আছে কি না দেখাও (যেমন x in list, pop(0), set-এ সাধারণ lower_bound). ৩. মোট time আর space complexity কত, n = 200000 হলে 1 সেকেন্ডে চলবে কি না — কারণসহ। ৪. আমার container-এর চেয়ে ভালো কোনো পছন্দ থাকলে শুধু নাম আর কারণ বলো, কোড নয়।

AI-এর (বা নিজের) কালেকশন-নির্ভর কোড যাচাইয়ের চেকলিস্ট

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

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

প্র ০১STL কীভাবে জন্মাল, আর container ও অ্যালগরিদমকে আলাদা রাখার চিন্তাটি এত শক্তিশালী কেন?

STL-এর মূল নকশাকার Alexander Stepanov. ১৯৭০-৮০-র দশক থেকেই তিনি একটি প্রশ্ন নিয়ে কাজ করছিলেন: একটি অ্যালগরিদম — যেমন binary search — কি একবার লিখে যেকোনো ধরনের ডেটায় ব্যবহার করা যায়, গতি না হারিয়ে? এই চিন্তার নাম generic programming. Ada আর Scheme-এ চেষ্টার পর তিনি C++-এর template-এ উপযুক্ত মাধ্যম পান। Hewlett-Packard-এর গবেষণাগারে Meng Lee-এর সাথে তিনি লাইব্রেরিটি তৈরি করেন, আর ১৯৯৪ সালে C++ standard কমিটি এটিকে standard library-তে নেওয়ার সিদ্ধান্ত নেয়। ১৯৯৮ সালের প্রথম ISO C++ standard-এ (C++98) STL-এর ধারণাগুলো অন্তর্ভুক্ত হয়।

মূল কৌশলটি হলো iterator. ধরুন ১০ ধরনের container আর ৫০টি অ্যালগরিদম। প্রতিটি container-এর জন্য প্রতিটি অ্যালগরিদম আলাদা লিখলে লাগত ১০ × ৫০ = ৫০০টি বাস্তবায়ন। কিন্তু যদি প্রতিটি container শুধু জানায় “আমার ওপর কীভাবে হাঁটা যায়” (iterator), আর প্রতিটি অ্যালগরিদম শুধু iterator-এর ভাষায় লেখা হয়, তাহলে লাগে ১০ + ৫০ = ৬০টি অংশ। নতুন একটি container বানালে সব পুরোনো অ্যালগরিদম বিনা খরচে তার ওপর চলে।

Stepanov আরেকটি সাহসী সিদ্ধান্ত নেন: complexity-কে interface-এর অংশ করা। standard শুধু বলে না “map-এ খোঁজা যায়”, বলে “O(log n)-এ খোঁজা যায়”। তাই কোনো লাইব্রেরি নির্মাতা map-কে একটি সাধারণ অসাজানো list দিয়ে বানাতে পারেন না, যদিও তাতে সব ফাংশন “কাজ” করত। এজন্যই iterator-এর বিভিন্ন শ্রেণি আছে — random access (vector), bidirectional (list, set), forward — আর sort শুধু random access iterator চায়: list-এ std::sort compile হয় না, তার নিজের list::sort আছে।

এই চিন্তা পরে অন্য ভাষাতেও ছড়িয়েছে — Java-র Collections framework, C#-এর generic collections, Rust-এর iterator trait — প্রত্যেকে নিজের মতো করে “ডেটা স্ট্রাকচার আর অ্যালগরিদমকে একটি সাধারণ interface দিয়ে আলাদা রাখা”-র ধারণা নিয়েছে।

মূল উপলব্ধি: STL-এর শক্তি তার কোনো একক container-এ নয়, বরং নকশায় — iterator দিয়ে container আর অ্যালগরিদমের বিচ্ছেদ, আর complexity-কে চুক্তির অংশ বানানো। এই কারণেই একটি STL ফাংশনের complexity জেনে রাখা নিরাপদ: সেটি কোনো একটি compiler-এর খেয়াল নয়, standard-এর প্রতিশ্রুতি।

প্র ০২std::map কেন hash table নয়, red-black tree? আর C++98-এ hash table ছিলই না কেন?

১৯৯০-এর দশকের শুরুতে STL-এর নকশার সময় hash table-এর একটি প্রস্তাবও ছিল, কিন্তু standard-এর সময়সীমার মধ্যে সেটি চূড়ান্ত করা যায়নি। ফলে C++98-এ শুধু ordered set/map এল। তখন SGI-র STL-সহ কয়েকটি লাইব্রেরি নিজস্ব hash_map দিত, কিন্তু প্রত্যেকের interface আলাদা — একই কোড অন্য compiler-এ চলত না। শেষে ২০০০-এর দশকের মাঝামাঝি TR1 নামের সংযোজনে আর পরে C++11 standard-এ unordered_map আর unordered_set আসে। নামটি ইচ্ছা করে “hash_map” রাখা হয়নি, যাতে পুরোনো অসামঞ্জস্যপূর্ণ সংস্করণগুলোর সাথে নাম না মেলে।

কিন্তু শুধু সময়ের অভাব নয় — ordered tree-র নিজস্ব গুণও আছে। প্রথমত, worst-case নিশ্চয়তা: red-black tree-তে প্রতিটি কাজ O(log n), ইনপুট যেমনই হোক; hash table-এ খারাপ hash বা আক্রমণে O(n). দ্বিতীয়ত, ক্রম: সাজানো ঘোরা, lower_bound, পরিসরের প্রশ্ন — hash table এগুলো পারে না। তৃতীয়ত, key-এর জন্য শুধু < লাগে, যা প্রায় সব type-এ সহজে সংজ্ঞায়িত; ভালো hash function লেখা তুলনায় কঠিন।

Red-black tree-র ইতিহাসও আকর্ষণীয়। ১৯৭২ সালে Rudolf Bayer “symmetric binary B-tree” নামে একটি স্ট্রাকচার দেন। ১৯৭৮ সালে Leonidas Guibas আর Robert Sedgewick একে লাল-কালো রঙের নিয়মে নতুন করে বর্ণনা করেন — “red-black” নামটি সেখান থেকেই। AVL tree (১৯৬২) আরও কঠোরভাবে ভারসাম্য রাখে, তাই খোঁজা সামান্য দ্রুত; কিন্তু red-black tree-তে প্রতিটি যোগ বা মুছে ফেলায় সর্বোচ্চ ধ্রুবক সংখ্যক rotation লাগে, তাই ঘন ঘন বদলানো ডেটায় এটি সুবিধাজনক। পর্ব ২৮ আর ২৯-এ এই তুলনা বিস্তারিত।

এর প্রভাব বাস্তব সফটওয়্যারে ছড়ানো: Java-র TreeMap red-black tree, আর Linux kernel-এ একটি সাধারণ red-black tree বাস্তবায়ন আছে, যা দীর্ঘদিন CPU scheduler (CFS) সহ বহু অংশে ব্যবহৃত হয়েছে। অন্য দিকে Java 8-এর HashMap একটি মজার মিশ্রণ করে: কোনো bucket-এর শিকল খুব লম্বা হয়ে গেলে সেটিকে একটি ছোট balanced tree-তে বদলে দেয়, যাতে worst case O(n) থেকে O(log n)-এ নামে।

মূল উপলব্ধি: “hash সবসময় দ্রুত” — এই ধারণা অসম্পূর্ণ। tree দেয় নিশ্চয়তা আর ক্রম, hash দেয় গড় গতি। কোনটি বেছে নেবেন, তা নির্ভর করে আপনার কোন গুণটি বেশি দরকার তার ওপর।

প্র ০৩Hash flooding কী? এটি কীভাবে ওয়েব সার্ভার থেকে কনটেস্টের anti-hash টেস্ট পর্যন্ত পৌঁছাল?

Hash table-এর worst case O(n) — এটি বহু বছর পাঠ্যবইয়ের একটি তাত্ত্বিক সতর্কবার্তা ছিল। ২০০৩ সালে Scott Crosby আর Dan Wallach একটি গবেষণাপত্রে দেখান যে আক্রমণকারী ইচ্ছা করে একই bucket-এ পড়ে এমন key পাঠিয়ে সার্ভারকে অচল করতে পারে — তাঁরা একে “algorithmic complexity attack” বলেন।

বিষয়টি ব্যাপক আলোচনায় আসে ২০১১ সালের ডিসেম্বরে, জার্মানির Chaos Communication Congress (28C3)-এ। Alexander Klink আর Julian Wälde দেখান যে PHP, Java, Python, Ruby, ASP.NET-সহ অনেক ওয়েব প্ল্যাটফর্ম HTTP request-এর প্যারামিটারগুলো একটি hash table-এ রাখে, আর সেই hash function আগে থেকে অনুমানযোগ্য। তাই কয়েকশো কিলোবাইটের একটি সাবধানে বানানো request পাঠিয়েই একটি সার্ভারের CPU দীর্ঘ সময় ব্যস্ত রাখা যায় — কারণ n টি key ঢোকাতে O(n²) কাজ। এর নাম হয় hash flooding.

প্রতিক্রিয়ায় ভাষাগুলো hash-এ এলোমেলোতা আনে। Python 3.3 থেকে string আর bytes-এর hash প্রতিবার interpreter চালু হওয়ার সময় একটি এলোমেলো seed দিয়ে হিসাব হয় (PYTHONHASHSEED), আর Python 3.4 থেকে string hash-এর জন্য SipHash নামের একটি keyed hash ব্যবহৃত হয় (PEP 456). এজন্যই Python-এ একই set একেক রানে একেক ক্রমে ছাপতে পারে। তবে লক্ষ করুন — Python-এ ছোট int-এর hash হলো সংখ্যাটি নিজেই, এলোমেলো নয়; তাই int key-এর dict বা set-এর বিরুদ্ধেও কনটেস্টে বিশেষভাবে বানানো ইনপুট দেখা যায়।

কনটেস্টের জগতে Codeforces-এর hacking পর্ব এই আক্রমণকে জনপ্রিয় করে। সেখানে অন্যের সমাধান পড়ে তাকে ভাঙে এমন ইনপুট দেওয়া যায়। GCC-র unordered_map int-এর hash হিসেবে সংখ্যাটিকেই নেয়, আর bucket-সংখ্যা একটি জানা মৌলিক তালিকা থেকে আসে — তাই সেই মৌলিক সংখ্যার গুণিতক দিয়ে ইনপুট বানালেই চলে। ২০১৮ সালের দিকে Codeforces-এ একটি বহুল পঠিত ব্লগ লেখায় এই আক্রমণ আর splitmix64-ভিত্তিক custom hash দিয়ে প্রতিরক্ষার কৌশল বিস্তারিত দেখানো হয়, যা এখন কনটেস্ট প্রোগ্রামারদের প্রায় মানক অভ্যাস।

মূল উপলব্ধি: “গড়ে O(1)” কথাটির পেছনে একটি অনুমান লুকিয়ে আছে — ইনপুট এলোমেলো। প্রতিপক্ষ (adversary) যখন ইনপুট বেছে নিতে পারে, তখন গড় নয়, worst case-ই বাস্তব। নিরাপত্তা আর কনটেস্ট — দুই জায়গাতেই এই শিক্ষা একই।

প্র ০৪Python-এর dict হঠাৎ ঢোকানোর ক্রম মনে রাখতে শুরু করল কেন? এতে OrderedDict কি অপ্রয়োজনীয় হয়ে গেল?

পুরোনো Python-এ dict ছিল একটি সাধারণ hash table: একটি বড় array, যার প্রতিটি ঘরে (hash, key, value) — অনেক ঘর খালি, কারণ load factor কম রাখতে হয়। ঘোরার ক্রম ছিল ঘরের ক্রম, অর্থাৎ hash-নির্ভর ও এলোমেলো। ২০১২ সালে Python-এর অন্যতম core developer Raymond Hettinger python-dev mailing list-এ একটি নতুন নকশা প্রস্তাব করেন: আসল (hash, key, value) এন্ট্রিগুলো একটি ঘন array-তে ঢোকানোর ক্রমে রাখা হবে, আর hash table-এর ঘরে থাকবে শুধু সেই array-র ছোট একটি index.

লাভ দুটি। প্রথমত মেমরি: খালি ঘরগুলো এখন বড় এন্ট্রি নয়, ছোট index মাত্র — তাই dict উল্লেখযোগ্যভাবে কম মেমরি নেয়, আর ঘোরা দ্রুত হয় কারণ এন্ট্রিগুলো পাশাপাশি। দ্বিতীয়ত, প্রায় বিনা খরচে একটি বাড়তি গুণ: এন্ট্রি array ঢোকানোর ক্রমে, তাই ঘোরাও ঢোকানোর ক্রমে। PyPy আগে এটি বাস্তবায়ন করে; CPython 3.6-এ এই “compact dict” আসে, তবে তখন ক্রম রাখাকে শুধু একটি বাস্তবায়নের খুঁটিনাটি বলা হয়েছিল। Python 3.7-এ Guido van Rossum এটিকে ভাষার নিয়মের অংশ ঘোষণা করেন — এখন যেকোনো Python বাস্তবায়নে dict ক্রম রাখতে বাধ্য।

তাহলে OrderedDict কি অচল? না, তিনটি পার্থক্য রয়ে গেছে। (১) move_to_end(key) আর popitem(last=False) O(1)-এ — সাধারণ dict-এ সবচেয়ে পুরোনো এন্ট্রি সরানোর বা কোনো key-কে শেষে নেওয়ার এমন সরাসরি উপায় নেই। এজন্য LRU cache-এ এটি এখনো উপযোগী। (২) দুটি OrderedDict-এর সমতা যাচাইয়ে ক্রমও মেলানো হয়; দুটি সাধারণ dict একই জোড়া ভিন্ন ক্রমে রাখলেও সমান। (৩) পুরোনো Python সংস্করণের সাথে সামঞ্জস্য।

একটি সতর্কতা: “ক্রম রাখে” মানে ঢোকানোর ক্রম, সাজানো ক্রম নয়। একটি key-এর মান বদলালে তার অবস্থান বদলায় না; মুছে আবার ঢোকালে সেটি শেষে যায়। আর set-এর ক্ষেত্রে এমন কোনো নিশ্চয়তা নেই — set এখনো এলোমেলো ক্রমে ঘোরে।

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

প্র ০৫Python-এর Timsort আর C++-এর introsort — দুই ভাষা দুই রকম sort বেছে নিল কেন?

২০০২ সালে Tim Peters Python-এর জন্য একটি নতুন sort লেখেন, যা পরে তাঁর নামেই Timsort পরিচিত হয়। এর মূল পর্যবেক্ষণ: বাস্তবের ডেটা প্রায়ই আংশিক সাজানো — তারিখ অনুযায়ী আগেই সাজানো লেনদেনের তালিকায় নতুন কয়েকটি যোগ হয়েছে, বা দুটি সাজানো তালিকা জোড়া লাগানো হয়েছে। Timsort ডেটার ভেতরে আগে থেকেই সাজানো টুকরো (run) খুঁজে নেয়, ছোট টুকরোকে insertion sort দিয়ে বড় করে, তারপর merge sort-এর মতো জোড়া দেয়। ফলে worst case O(n log n), কিন্তু প্রায়-সাজানো ডেটায় প্রায় O(n). আর এটি stable — Python-এর মতো ভাষায়, যেখানে মানুষ প্রায়ই একাধিক মাপকাঠিতে পরপর সাজায়, এটি খুব কাজের।

Timsort এত সফল যে Java 7 থেকে object-এর array সাজাতে এটি ব্যবহৃত হয়, Android আর অন্য কয়েকটি প্ল্যাটফর্মেও। তবে ২০১৫ সালে একটি গবেষক দল formal verification টুল (KeY) দিয়ে Timsort-এর প্রমাণ লিখতে গিয়ে আবিষ্কার করেন যে বাস্তবায়নের একটি invariant কিছু বিরল ইনপুটে ভেঙে যায় — Java-র সংস্করণে বিশেষভাবে বানানো বড় ইনপুটে এটি exception ঘটাতে পারত। Python আর Java দুটিই পরে সংশোধন করে। পর্ব ০৩-এর loop invariant আর প্রমাণের গুরুত্বের এটি একটি বাস্তব উদাহরণ।

C++ ভিন্ন পথ নেয়। std::sort-কে stable হতে হয় না, আর এর প্রধান লক্ষ্য কাঁচা গতি, বাড়তি মেমরি ছাড়া। quick sort গড়ে দ্রুততম ও in-place, কিন্তু worst case O(n²). ১৯৯৭ সালে David Musser introsort প্রস্তাব করেন: quick sort চালানো হয়, কিন্তু রিকার্শনের গভীরতা প্রায় 2·log₂ n ছাড়ালে বাকি অংশ heapsort দিয়ে শেষ করা হয় — তাতে worst case O(n log n) থাকে। C++11 standard-এ std::sort-এর worst case O(n log n) বাধ্যতামূলক হয়। stable দরকার হলে C++-এ আলাদা std::stable_sort আছে, যা merge sort-ভিত্তিক।

পার্থক্যের পেছনে আছে দুই ভাষার মূল্যবোধ: Python-এ তুলনা দামি (প্রতিটি তুলনা একটি Python method call), তাই তুলনার সংখ্যা কমানো আর আংশিক ক্রম কাজে লাগানো লাভজনক; C++-এ তুলনা প্রায়ই একটি CPU নির্দেশ, তাই cache-বান্ধব in-place quick sort জেতে।

মূল উপলব্ধি: “সবচেয়ে ভালো sort” বলে কিছু নেই — আছে নির্দিষ্ট পরিবেশের জন্য ভালো sort. লাইব্রেরির ফাংশন ব্যবহার করার সময় জানুন সেটি stable কি না, worst case কত, আর বাড়তি মেমরি নেয় কি না — বাকিটা লাইব্রেরি নির্মাতারা দশকের অভিজ্ঞতায় সামলে দিয়েছেন।

অনুশীলন

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

  1. হাতে trace করুন: একটি খালি vector<int>-এ (capacity দ্বিগুণ হয়, শুরু 1) পরপর ৯টি push_back করা হলো। প্রতিটি push-এর পর capacity কত হয়? মোট কতগুলো উপাদান সরাতে (কপি করতে) হলো?

    capacity: push ১ → 1, ২ → 2, ৩ → 4, ৪ → 4, ৫ → 8, ৬–৮ → 8, ৯ → 16.

    সরানো হয় শুধু যখন জায়গা ফুরায়: push ২-এ 1টি, push ৩-এ 2টি, push ৫-এ 4টি, push ৯-এ 8টি। মোট 1 + 2 + 4 + 8 = ১৫টি, যা 2 × 9 = 18-এর কম। সাধারণভাবে n টি push-এ মোট সরানো 2n-এর কম, তাই প্রতি push amortized O(1). শুরুতে v.reserve(9) লিখলে সরানো হতো ০টি।

  2. Complexity বের করুন: নিচের Python কোডটি n টি সংখ্যার একটি list a থেকে সদৃশ বাদ দেয় (প্রথম দেখার ক্রম রেখে)। এর সময় কত? কীভাবে O(n) করবেন?
    out = []
    for x in a:
        if x not in out:
            out.append(x)

    x not in out একটি list-এ linear search — O(len(out)). সব মান ভিন্ন হলে out বাড়তে থাকে 0, 1, 2, …, n − 1, তাই মোট 0 + 1 + … + (n − 1) = n(n − 1)/2 তুলনা: Θ(n²) worst case.

    সমাধান: খোঁজার জন্য একটি set রাখুন, ক্রমের জন্য list — seen = set(), তারপর if x not in seen: seen.add(x); out.append(x). এখন প্রতিটি খোঁজা গড়ে O(1), মোট গড়ে O(n), বাড়তি জায়গা O(n). এক লাইনে: list(dict.fromkeys(a)) — dict ঢোকানোর ক্রম রাখে বলে এটি কাজ করে (Python 3.7+).

  3. সঠিক container বেছে নিন: প্রতিটির জন্য C++ আর Python-এ একটি container আর মূল কাজের সময় বলুন। (ক) একটি ব্যাংকের সারিতে গ্রাহক আসেন আর আগে-আসা-আগে-সেবা পান। (খ) প্রতিদিনের লেনদেনের পরিমাণ আসছে; যেকোনো মুহূর্তে “x টাকার সমান বা বেশি সবচেয়ে ছোট লেনদেন” জানতে হবে, আর ভুল লেনদেন মুছেও ফেলতে হবে। (গ) একটি বড় লেখায় প্রতিটি অক্ষর কতবার এসেছে।

    (ক) FIFO queue: C++-এ queue, Python-এ collections.deque (append/popleft) — প্রতিটি O(1). Python-এ list-এর pop(0) নয়।

    (খ) সাজানো, বদলায় এমন ডেটায় “প্রথম ≥ x”: C++-এ multiset<long long> (একই পরিমাণের লেনদেন একাধিক হতে পারে), insert, lower_bound(x), erase(find(v)) — সব O(log n). Python-এর স্ট্যান্ডার্ড লাইব্রেরিতে এর সরাসরি সমতুল্য নেই; সাজানো list + bisect দিলে খোঁজা O(log n) কিন্তু যোগ-মুছে ফেলা O(n), অথবা মানগুলো আগে জানা থাকলে Fenwick tree (পর্ব ৩৬).

    (গ) গোনা: C++-এ unordered_map<char, int> বা আরও সহজে array<int, 256> (অক্ষর মাত্র ২৫৬ রকম, তাই সরাসরি index — O(1) worst case-এও), Python-এ Counter(text) — মোট O(N).

  4. কোড লিখুন: একটি ফাংশন লিখুন যা n টি পূর্ণসংখ্যার একটি array আর একটি মান k নেয়, এবং এমন জোড়ার (i < j) সংখ্যা ফেরত দেয় যাদের যোগফল ঠিক k. সময় গড়ে O(n) হতে হবে। উদাহরণ: [1, 5, 7, -1, 5], k = 6 → 3 (জোড়া: 1+5, 1+5, 7+(−1)).

    মূল চিন্তা: বাঁ থেকে ডানে যান। প্রতিটি x-এর জন্য প্রশ্ন — “আগে কতবার k − x দেখেছি?” সেটিই x-কে ডান সদস্য ধরে জোড়ার সংখ্যা। একটি hash map-এ এ পর্যন্ত দেখা মানগুলোর গণনা রাখুন।

    long long countPairs(const vector<int>& a, int k) {
        unordered_map<long long, long long> seen;    // value -> how many so far
        long long pairs = 0;
        for (int x : a) {
            auto it = seen.find((long long)k - x);   // find: does NOT insert
            if (it != seen.end()) pairs += it->second;
            ++seen[x];                               // here inserting is intended
        }
        return pairs;
    }

    Trace: 1 → seen-এ 5 নেই, seen = {1:1}; 5 → 1 আছে একবার, pairs = 1; 7 → −1 নেই; −1 → 7 আছে, pairs = 2; 5 → 1 আছে একবার, pairs = 3. উত্তর 3 ✔. খোঁজায় find লেখা হয়েছে, seen[k - x] নয় — নইলে অকারণ key তৈরি হতো। k − x long long-এ হিসাব করায় overflow-এর ভয় নেই। Python-এ একই চিন্তা: seen = Counter(), pairs += seen[k - x] (Counter key বানায় না), তারপর seen[x] += 1. সময় গড়ে O(n), জায়গা O(n); hack-এর ঝুঁকিতে custom hash বা map (তখন O(n log n)).

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

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

ক্রমশব্দ গুনে “বেশি গণনা আগে, সমান হলে বর্ণানুক্রমে” ছাপার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. লেখাটিকে শব্দে ভাগ করা
  2. একটি map বা Counter-এ প্রতিটি শব্দের গণনা বাড়ানো
  3. (শব্দ, গণনা) জোড়াগুলো একটি তালিকায় নেওয়া
  4. গণনা বড় থেকে ছোট, সমান হলে শব্দ ছোট থেকে বড় — এই নিয়মে সাজানো
  5. সাজানো তালিকা ছাপানো

C++-এর std::map-এ খোঁজা, যোগ আর মুছে ফেলার worst-case time complexity কত, আর কেন?

  • O(1), কারণ এটি hash table
  • O(log n), কারণ এটি balanced binary search tree
  • O(n), কারণ সবচেয়ে খারাপে সব key দেখতে হয়
  • O(n log n)

map<string,int> m; খালি। এরপর if (m["x"] == 0) cout << m.size(); চালালে কী ছাপা হয়?

  • 0
  • কিছু না — exception ঘটে
  • 1
  • compile error হয়

Python-এ n টি কাজ দিয়ে একটি FIFO queue চালাতে হবে (সামনে থেকে বের, পেছনে যোগ)। কোনটি সবচেয়ে উপযুক্ত, আর মোট সময় কত?

  • collections.deque-এর append ও popleft — মোট O(n)
  • list-এর append ও pop(0) — মোট O(n)
  • heapq-এর heappush ও heappop — মোট O(n)
  • set-এর add ও pop — মোট O(n)

সাজানো array a = [2, 4, 4, 4, 7, 9]-এ (0-based index) lower_bound(4) আর upper_bound(4) কোন index দেয়?

  • 1 আর 3
  • 0 আর 4
  • 2 আর 4
  • 1 আর 4

Python-এ seen = set(); seen.add([1, 2]) চালালে কী ঘটে?

  • [1, 2] একটি উপাদান হিসেবে যোগ হয়
  • TypeError — list hashable নয়
  • 1 আর 2 আলাদা দুটি উপাদান হিসেবে যোগ হয়
  • কিছুই হয় না, set খালি থাকে

একটি std::set<int> s-এ (n টি উপাদান) std::lower_bound(s.begin(), s.end(), x) আর s.lower_bound(x) — দুটির সময়ের পার্থক্য কী?

  • দুটিই O(log n), কোনো পার্থক্য নেই
  • সাধারণটি O(log n), member-টি O(n)
  • সাধারণটি O(n), member-টি O(log n)
  • সাধারণটি set-এ compile-ই হয় না

C++-এর unordered_map-এ n টি key ঢোকানো হলো, আর প্রতিপক্ষ ইচ্ছা করে এমন key বেছেছে যেগুলো সব একই bucket-এ পড়ে। মোট সময় কত হতে পারে?

  • Θ(n²)
  • Θ(n)
  • Θ(n log n)
  • Θ(2ⁿ)

শব্দকোষ

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

মনে রাখুন

  1. vector / Python list = dynamic array: index O(1), শেষে যোগ amortized O(1), শুরু-মাঝে আর খোঁজা O(n).
  2. deque দুই প্রান্তে O(1); priority_queue (max) আর heapq (min) push/pop O(log n).
  3. set / map = red-black tree: সব O(log n) worst case, সাজানো ক্রম আর lower_bound; Python-এ এর তৈরি সমতুল্য নেই।
  4. unordered_map / dict / set = hash: গড়ে O(1), worst case O(n); কনটেস্টে custom hash.
  5. ফাঁদ: map[] key বানায়, iterator invalidation, ঘোরার সময় dict বদলানো, mutable default argument, list-এর set হয় না।

পরের পর্বে: হাতে এখন তৈরি স্ট্রাকচারের পুরো বাক্স। এবার প্রথম বড় কৌশল — two pointer আর sliding window: দুটি index একসাথে সরিয়ে O(n²)-এর অনেক সমস্যা O(n)-এ, যেখানে গণনার জন্য array আর dict-এর মতো তৈরি স্ট্রাকচার কাজে লাগবে।

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

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