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

Hash table — hash function, collision আর load factor

Hash Tables — Hash Functions, Collisions, Separate Chaining, Open Addressing, Load Factor, Rehashing & Universal Hashing

Taught in Bangla, this lesson moves from direct-address tables to hash tables: division, multiplication and polynomial string hashes, why collisions are unavoidable, separate chaining, linear and quadratic probing and double hashing, tombstone deletion, load factor and amortized rehashing, universal hashing and hash flooding, and safe use of unordered_map, dict and HashMap, with C++ and Python code.

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

  • Direct-address table কেন বড় key-জগতে অচল, আর hash function কীভাবে সমস্যাটি মেটায়
  • Division, multiplication আর string-এর polynomial hash হাতে হিসাব করা, আর ভালো hash-এর গুণ চেনা
  • Chaining, linear probing, quadratic probing ও double hashing হাতে trace করা, আর তাদের প্রত্যাশিত খরচ প্রমাণসহ বলা
  • Tombstone দিয়ে delete, load factor ধরে rehash, আর তার amortized O(1) বিশ্লেষণ
  • Universal hashing আর hash flooding বোঝা, আর unordered_map, dict, HashMap নিরাপদে ব্যবহার করা

১ · রোল নম্বরের খাতা থেকে hash table — direct-address table আর তার সীমা

একটি স্কুলের ক্লাস টিচারের কথা ভাবুন। ক্লাসে ৬০ জন ছাত্রছাত্রী, রোল নম্বর 1 থেকে 60. তিনি একটি খাতায় ৬০টি পাতা রেখেছেন, আর রোল 37-এর সব তথ্য আছে ঠিক ৩৭ নম্বর পাতায়। কারও খবর জানতে খাতা উল্টে খুঁজতে হয় না — সরাসরি সেই পাতা খোলেন। নতুন তথ্য লেখা, পড়া বা মুছে ফেলা — সবকিছু এক ধাপে।

প্রোগ্রামের ভাষায় এটি একটি direct-address table: key-ই অ্যারের index. a[roll] পড়া O(1), কারণ পর্ব ১০-এ দেখেছেন, অ্যারের যেকোনো ঘরে সরাসরি যাওয়া যায় (random access বলে)। আমরা আসলে যে dictionary ADT চাই, তার তিনটি কাজ — insert(key, value), find(key), delete(key) — এখানে তিনটিই Θ(1).

সমস্যা শুরু হয় যখন key-গুলো আর ছোট পরিসরে থাকে না। একটি মুদি দোকান তার bKash গ্রাহকদের মোবাইল নম্বর দিয়ে বাকির হিসাব রাখতে চায়। নম্বর ১১ অঙ্কের, তাই সম্ভাব্য key প্রায় 1011 টি। প্রতিটি ঘরে ৮ byte রাখলেও অ্যারের আকার হবে 8 × 1011 byte ≈ ৮০০ GB — অথচ দোকানের গ্রাহক হয়তো ৫০০ জন! আর key যদি নাম বা email হয়, সম্ভাব্য key-এর জগৎ (universe, U) কার্যত অসীম। Direct-address table তখন অচল।

উপমা — বড় অফিসের চিঠির খোপ। অফিসে ২,০০০ কর্মী, কিন্তু রিসেপশনে চিঠির খোপ মাত্র ১০০টি। নিয়ম: কর্মীর আইডির শেষ দুই অঙ্ক যত, চিঠি যাবে সেই খোপে। খোপ খুঁজতে কোনো তালিকা লাগে না — একটি হিসাবেই ঠিকানা। কিন্তু আইডি 1037 আর 2437 — দুজনের চিঠিই খোপ 37-এ যাবে। তখন খোপে একটু খুঁজতে হবে। এই “হিসাব” হলো hash function, আর “একই খোপে দুজন” হলো collision.

এটিই hash table-এর মূল ধারণা। m টি ঘরের (bucket বা slot) একটি অ্যারে নিন, যেখানে m মোটামুটি আসল key-এর সংখ্যা n-এর কাছাকাছি। আর একটি hash function h নিন, যা প্রতিটি key-কে 0 থেকে m − 1-এর একটি সংখ্যায় পাঠায়: h : U → {0, 1, …, m − 1}. key k রাখা হবে ঘর h(k)-এ। জায়গা লাগে Θ(n + m), U-এর আকারের ওপর আর নির্ভর করে না।

যা দিয়ে বানানোfind / insert / deleteজায়গাক্রমের প্রশ্ন (ছোট থেকে বড়, পরের key)
Direct-address tableΘ(1) worst caseΘ(|U|)হ্যাঁ, কিন্তু U ছোট হতে হবে
অসাজানো অ্যারে বা listΘ(n)Θ(n)না
সাজানো অ্যারে (পর্ব ১৮)find O(log n), insert/delete O(n)Θ(n)হ্যাঁ
ভারসাম্যপূর্ণ BST (পর্ব ২৮–২৯)O(log n) worst caseΘ(n)হ্যাঁ
Hash tableগড়ে O(1), worst case O(n)Θ(n + m)না — ক্রম এলোমেলো

শেষ সারির দুটি শব্দই এই পর্বের পুরো গল্প: “গড়ে” আর “worst case”. কেন গড়ে O(1), কোন অনুমানের ওপর সেই গড় দাঁড়িয়ে, আর কেউ যদি ইচ্ছা করে worst case বানাতে চায় তখন কী হয় — একে একে দেখব।

২ · Hash function — key থেকে ঘরের নম্বর বানানো

প্রথমে ধরে নিই key একটি অঋণাত্মক পূর্ণসংখ্যা (নাম বা string-কে আগে সংখ্যায় বদলাতে হয়, নিচে দেখবেন)। সংখ্যাকে 0 থেকে m − 1-এ নামানোর দুটি ক্লাসিক উপায় আছে।

Division method — h(k) = k mod m

Division method-এ hash হলো ভাগশেষ: h(k) = k mod m (modulo অপারেশন)। একটি ভাগ, তাই খুব দ্রুত। কিন্তু m বাছাই করতে হয় সাবধানে। m যদি 2-এর ঘাত হয়, যেমন m = 16, তাহলে k mod 16 মানে শুধু k-এর শেষ ৪টি bit — বাকি সব bit উপেক্ষিত। বাস্তবের key প্রায়ই নিয়ম মেনে চলে: মেমরির ঠিকানা সাধারণত 8-এর গুণিতক, অনেক আইডি 10 বা 100-এর গুণিতক। দেখুন key 8, 16, 24, 32, 40-এ কী হয়:

key816243240ব্যবহৃত ঘর
k mod 1680808১৬টির মধ্যে মাত্র ২টি
k mod 13831161৫টি key, ৫টি আলাদা ঘর

সাধারণ নিয়ম: key-গুলোর মধ্যে যদি m-এর সাথে একটি সাধারণ উৎপাদক g থাকে, তাহলে তারা মাত্র m/g টি ঘর ব্যবহার করতে পারে। তাই division method-এ m হিসেবে একটি মৌলিক সংখ্যা নিন, যা 2-এর কোনো ঘাতের খুব কাছে নয়। C++-এর GCC লাইব্রেরি ঠিক এ কারণেই bucket-সংখ্যা হিসেবে মৌলিক সংখ্যার একটি তালিকা ব্যবহার করে।

Multiplication method — Knuth-এর সোনালি ধ্রুবক

Multiplication method-এ 0 আর 1-এর মাঝের একটি ধ্রুবক A নিয়ে h(k) = ⌊m · (k·A mod 1)⌋, যেখানে “mod 1” মানে ভগ্নাংশ অংশটুকু। এখানে m-এর মান নিয়ে চিন্তা কম — 2-এর ঘাত হলেও চলে, কারণ ভগ্নাংশ অংশে k-এর সব অঙ্কের প্রভাব থাকে। Donald Knuth তাঁর The Art of Computer Programming-এ A ≈ (√5 − 1)/2 ≈ 0.6180339887 নেওয়ার পরামর্শ দেন — সোনালি অনুপাতের (golden ratio) উল্টো। m = 16 নিয়ে পরপর চারটি key:

kk · Aভগ্নাংশ অংশ× 16, তারপর ⌊ ⌋
10061.8034…0.803412
10162.4214…0.42146
10263.0395…0.03950
10363.6575…0.657510

পাশাপাশি key-গুলো টেবিলের দূরে দূরে ছড়িয়ে গেল। বাস্তবে এটি পূর্ণসংখ্যায় করা হয়: 32-bit key-কে 2654435769 (= ⌊232/φ⌋, hex-এ 0x9E3779B9) দিয়ে গুণ করে overflow হতে দিন, তারপর ওপরের p টি bit নিন — m = 2p. একে প্রায়ই Fibonacci hashing বলে। পর্ব ১৫-এর splitmix64 hash-এর প্রথম ধ্রুবক 0x9e3779b97f4a7c15-ও একই সংখ্যার 64-bit রূপ।

String-এর hash — polynomial hash

নাম, শব্দ বা URL-কে আগে একটি সংখ্যায় বদলাতে হয়। সবচেয়ে সরল ভাবনা — অক্ষরগুলোর ASCII মান যোগ করা — খুব খারাপ: "ab" আর "ba" দুটোরই যোগফল 97 + 98 = 195, অর্থাৎ সব anagram একই ঘরে। অক্ষরের অবস্থানও hash-এ ঢোকাতে হবে। পর্ব ১১-এর polynomial hash ঠিক তা-ই করে: string s = s0s1…sL−1-কে একটি base p-এর সংখ্যার অঙ্ক হিসেবে পড়ুন:

h(s) = (s0·pL−1 + s1·pL−2 + … + sL−1) mod M

Horner-এর নিয়মে একটি লুপেই হয়: শুরুতে h = 0, প্রতিটি অক্ষর c-এর জন্য h = (h·p + c) mod M. Java-র String.hashCode() ঠিক এটি, p = 31 আর M = 232 (int overflow). তখন "ab" = 97·31 + 98 = 3105, কিন্তু "ba" = 98·31 + 97 = 3135 — আলাদা। এই hash-এর একটি বিশেষ ক্ষমতা আছে: একটি জানালা এক অক্ষর সরালে নতুন hash পুরোনোটি থেকে O(1)-এ বের করা যায় — rolling hash, যা দিয়ে পর্ব ৫৮-এ Rabin-Karp string matching হবে। খেয়াল রাখুন, L দৈর্ঘ্যের string-এর hash বের করতেই Θ(L) সময় লাগে — তাই string key-তে “O(1)” আসলে “O(L)”.

ভালো hash function-এর গুণ

  • নির্ধারিত (deterministic): একই key-এর জন্য প্রোগ্রাম চলার পুরো সময় একই মান। নইলে রাখা জিনিস আর খুঁজে পাওয়া যাবে না।
  • সমতার সাথে সামঞ্জস্য: a == b হলে h(a) == h(b) হতেই হবে। এ কারণে Python-এ শুধু hashable (অপরিবর্তনীয়) মান dict-এর key হতে পারে — list বদলে গেলে তার hash-ও বদলে যেত।
  • সমানভাবে ছড়ানো (uniform): বাস্তবের key-গুলো ঘরগুলোতে প্রায় সমানভাবে ভাগ হবে, key-এর নিয়মিত প্যাটার্ন থাকলেও।
  • key-এর প্রতিটি অংশ কাজে লাগানো: key-এর যেকোনো একটি bit বদলালে hash-এর প্রায় অর্ধেক bit বদলানো উচিত — একে বলে avalanche effect. শুধু প্রথম অক্ষর বা শেষ অঙ্ক দেখা hash এখানে ফেল করে।
  • দ্রুত: প্রতিটি কাজে hash বের করতে হয়, তাই SHA-256-এর মতো cryptographic hash সাধারণত টেবিলের জন্য অনেক ধীর। তবে অংশ ৮-এ দেখবেন, শত্রু থাকলে কিছুটা গোপনীয়তাও লাগে।

৩ · Collision এড়ানো যায় না — pigeonhole আর birthday paradox

দুটি ভিন্ন key একই ঘরে পড়লে তাকে বলে collision. সবচেয়ে ভালো hash function দিয়েও কি এটি পুরোপুরি এড়ানো যায়? পর্ব ০৬-এর pigeonhole principle সাথে সাথে বলে দেয়: না। |U| > m হলে (আর বাস্তবে |U| বিশাল, m ছোট) অন্তত দুটি key-এর একই ঘর থাকবেই। কিন্তু আরও অবাক করা কথা হলো, key-এর সংখ্যা n যখন m-এর তুলনায় অনেক ছোট, তখনও collision প্রায় নিশ্চিত।

এর বিখ্যাত রূপ birthday paradox: একটি ঘরে কতজন মানুষ থাকলে দুজনের জন্মদিন একই দিনে পড়ার সম্ভাবনা ৫০% ছাড়ায়? বেশিরভাগ মানুষ অনুমান করেন ১৮০-র কাছাকাছি। আসল উত্তর মাত্র ২৩. হিসাবটি নিজে করুন (ধরি বছর ৩৬৫ দিনের, প্রতিটি দিন সমান সম্ভাব্য)। “সবার জন্মদিন আলাদা” — এর সম্ভাবনা বের করা সহজ: প্রথমজন যেকোনো দিন, দ্বিতীয়জনকে বাকি ৩৬৪ দিনের একটিতে হতে হবে, তৃতীয়জনকে ৩৬৩ দিনের একটিতে…

P(সবাই আলাদা) = (365/365) · (364/365) · (363/365) · … · (343/365) ≈ 0.4927

শেষ ভগ্নাংশটি 343/365, কারণ ২৩তম মানুষের জন্য বাকি থাকে 365 − 22 = 343 দিন। তাই অন্তত একটি মিলের সম্ভাবনা 1 − 0.4927 ≈ 0.5073 — ৫০%-এর একটু বেশি। কেন এত কম মানুষেই? কারণ মিল খোঁজা হচ্ছে যেকোনো দুজনের মধ্যে, আর ২৩ জনে জোড়া আছে 23·22/2 = 253 টি। প্রতিটি জোড়ার মিলের সম্ভাবনা 1/365, তাই মোটামুটি হিসাব P(কোনো মিল নেই) ≈ e−253/365 = e−0.693 ≈ 0.50 (এখানে 1 − x ≈ e−x ব্যবহার করেছি)। সাধারণভাবে n টি key আর m টি ঘরে

P(অন্তত একটি collision) ≈ 1 − e−n(n−1)/(2m),   ৫০% হয় যখন n ≈ √(2m ln 2) ≈ 1.18√m

অর্থাৎ ঘর যত বেশি হোক, collision শুরু হয় ঘরের সংখ্যার বর্গমূলের কাছাকাছি key-তেই। নিচের প্রোগ্রাম ঠিক হিসাব আর এই আনুমানিক সূত্র পাশাপাশি দেখায়, আর তিনটি টেবিল-আকারের জন্য ৫০%-এর সীমা বের করে।

#include <cmath>
#include <cstdio>
#include <initializer_list>
int main() {
    const double m = 365;                               // 365 "buckets" = days of the year
    std::printf(" n   exact    1-exp(-n(n-1)/2m)\n");
    for (int n : {5, 10, 20, 23, 30, 40, 50, 57, 70}) {
        double q = 1;                                   // q = P(all n keys land in different buckets)
        for (int i = 0; i < n; ++i) q *= (m - i) / m;
        std::printf("%2d   %.4f   %.4f\n", n, 1 - q, 1 - std::exp(-n * (n - 1) / (2 * m)));
    }
    for (double M : {365.0, 1e6, 4294967296.0}) {      // smallest n with P(collision) >= 1/2
        double q = 1;
        int n = 0;
        while (1 - q < 0.5) { q *= (M - n) / M; ++n; }
        std::printf("m = %.0f: n = %d   sqrt(2 m ln 2) = %.1f\n", M, n, std::sqrt(2 * M * std::log(2.0)));
    }
}

আউটপুট:

 n   exact    1-exp(-n(n-1)/2m)
 5   0.0271   0.0270
10   0.1169   0.1160
20   0.4114   0.4058
23   0.5073   0.5000
30   0.7063   0.6963
40   0.8912   0.8820
50   0.9704   0.9651
57   0.9901   0.9874
70   0.9992   0.9987
m = 365: n = 23   sqrt(2 m ln 2) = 22.5
m = 1000000: n = 1178   sqrt(2 m ln 2) = 1177.4
m = 4294967296: n = 77164   sqrt(2 m ln 2) = 77162.7

n = 23-এর সারিতে ঠিক মান 0.5073 — অংশের শুরুর হাতের হিসাবের সাথে মেলে। ৫৭ জনেই সম্ভাবনা ৯৯% ছাড়ায়, অথচ ৯৯.৯% নিশ্চয়তার জন্যও ৩৬৫ জন লাগে না। শেষ তিন লাইন hash table-এর জন্য আসল শিক্ষা: দশ লাখ bucket-এর টেবিলে মাত্র 1178 টি এলোমেলো key দিলেই collision-এর সম্ভাবনা ৫০%, আর 32-bit hash মানের (প্রায় ৪৩০ কোটি সম্ভাব্য মান) মধ্যেও প্রায় 77 হাজার key-তেই দুটি key-এর hash হুবহু এক হওয়ার সম্ভাবনা ৫০%. প্রতিটি লাইনে √(2m ln 2) সূত্রটি আসল উত্তরের প্রায় সমান। ভেতরের লুপ n টি গুণ করে, তাই প্রতিটি সারি O(n) সময় নেয়। তাই hash table-এর নকশায় প্রশ্ন কখনো “collision হবে কি না” নয় — প্রশ্ন হলো “collision হলে কী করব”.

৪ · Separate chaining — প্রতিটি ঘরে একটি তালিকা

প্রথম সমাধানটি চিঠির খোপের মতোই সরল: প্রতিটি ঘরে একটি linked list (পর্ব ১২) রাখুন, আর ঘর h(k)-এ যতগুলো key পড়ে সবাইকে সেই তালিকায় রাখুন। একে বলে separate chaining. Insert মানে তালিকার মাথায় যোগ করা (key আগে থেকে আছে কি না, map-এ সেটি আগে দেখতে হয়), find মানে সেই এক তালিকা ধরে হাঁটা, delete মানে তালিকা থেকে node সরানো। নিচে m = 7, h(k) = k mod 7:

Bucket অ্যারে, প্রতিটিতে একটি তালিকাসবুজ = এইমাত্র যোগ হলো

            
প্রতিটি ধাপের পর
কাজ
h(k) = k mod 7
সেই bucket-এর তালিকার দৈর্ঘ্য
load factor α = n/m
  1. শুরু. সাতটি bucket, সবগুলো খালি তালিকা (·). কোনো key নেই, তাই α = 0.
  2. insert 15. 15 mod 7 = 1, তাই bucket 1-এর তালিকায় 15. এক ধাপের হিসাব, কোনো খোঁজাখুঁজি নেই।
  3. insert 8 — প্রথম collision. 8 mod 7 = 1 — bucket 1-এ আগেই 15. কোনো সমস্যা নেই: 8 তালিকার মাথায় বসল, তালিকা এখন 8 → 15. মাথায় বসানো O(1).
  4. insert 22. 22 mod 7 = 1 — আবার bucket 1! তালিকা 22 → 8 → 15, দৈর্ঘ্য ৩. বাকি ছয়টি bucket খালি, অথচ তিনটি key এক জায়গায় — birthday paradox-এর মতো, অল্প key-তেই ভিড়।
  5. insert 4. 4 mod 7 = 4, নিজের খালি bucket পেল।
  6. insert 11. 11 mod 7 = 4, তালিকা 11 → 4. পাঁচটি key, α = 5/7 ≈ 0.71. গড়ে প্রতি bucket-এ একটিরও কম key.
  7. find 29 — ব্যর্থ খোঁজা. 29 mod 7 = 1. শুধু bucket 1-এর তালিকা দেখতে হলো: 22, 8, 15 — কেউ 29 নয়, তালিকা শেষ, তাই “নেই”. ৩টি তুলনা লাগল — এই bucket-এর তালিকার দৈর্ঘ্যের সমান। বাকি ছয়টি bucket ছুঁতেই হয়নি।

বিশ্লেষণ — কেন গড়ে O(1 + α)

Worst case সহজ: সব n টি key এক bucket-এ পড়লে hash table একটি লম্বা linked list, আর find Θ(n). গড় হিসাবের জন্য একটি অনুমান লাগে — simple uniform hashing: প্রতিটি key স্বাধীনভাবে m টি bucket-এর যেকোনোটিতে সমান সম্ভাবনায় (1/m) পড়ে। এখন load factor α = n/m সংজ্ঞায়িত করুন — প্রতি bucket-এ গড় key-এর সংখ্যা।

  • ব্যর্থ খোঁজা (key নেই): k-এর bucket-এর পুরো তালিকা দেখতে হয়। n টি key-এর প্রত্যেকটি সেই নির্দিষ্ট bucket-এ পড়ে 1/m সম্ভাবনায়, তাই expected value-এর linearity অনুযায়ী তালিকার প্রত্যাশিত দৈর্ঘ্য n · (1/m) = α. hash বের করার এক ধাপসহ মোট খরচ Θ(1 + α).
  • সফল খোঁজা (key আছে): key x খুঁজতে x-এর আগে তালিকায় থাকা key-গুলো দেখতে হয় — অর্থাৎ x-এর পরে insert হওয়া আর একই bucket-এ পড়া key-গুলো (মাথায় বসানো হয় বলে)। i-তম insert হওয়া key-এর পরে আসে n − i টি key, প্রত্যেকটি 1/m সম্ভাবনায় একই bucket-এ। সব i-এর গড় নিলে দেখা key-এর প্রত্যাশিত সংখ্যা 1 + (1/n) Σi=1n (n − i)/m = 1 + (n − 1)/(2m) ≈ 1 + α/2. এটিও Θ(1 + α).

তাই যদি bucket-এর সংখ্যা m সবসময় n-এর সমানুপাতিক রাখা যায় (m = Θ(n), অর্থাৎ α একটি ধ্রুবক), তাহলে প্রতিটি কাজ গড়ে O(1). Insert (মাথায় বসানো) আর delete (doubly linked list হলে, node হাতে থাকলে) O(1); key আছে কি না আগে দেখতে হলে খোঁজার খরচ যোগ হয়। জায়গা Θ(n + m). লক্ষ করুন, এই “গড়” একটি অনুমানের ওপর দাঁড়িয়ে — key-গুলো যেন এলোমেলোভাবে bucket-এ ছড়ায়। কিন্তু h নির্দিষ্ট হলে আসলে কোথাও কোনো এলোমেলোতা নেই: একই key-গুলো বারবার দিলে খরচ হুবহু একই থাকে, আর কেউ ইচ্ছা করে খারাপ key বাছলে এই গড়ের হিসাব খাটে না। এই সূক্ষ্ম পার্থক্যই অংশ ৮-এর আক্রমণের দরজা।

Chaining-এর সুবিধা-অসুবিধা. খুব সরল, delete সহজ, α 1-এর বেশি হলেও চলে (শুধু ধীর হয়), আর খারাপ hash-এও ধীরে ধীরে খারাপ হয়, হঠাৎ ভেঙে পড়ে না। কিন্তু প্রতিটি key-এর জন্য একটি আলাদা node আর pointer — বাড়তি মেমরি, আর node-গুলো মেমরিতে ছড়ানো বলে তালিকা ধরে হাঁটা cache-এর জন্য খারাপ (পর্ব ১২-এ linked list-এর এই দুর্বলতা দেখেছেন)।

৫ · Open addressing — টেবিলের ভেতরেই জায়গা খোঁজা

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

প্রতিটি key k-এর জন্য ঘরের একটি ক্রম থাকে — probe sequence h(k, 0), h(k, 1), h(k, 2), … — যা 0 থেকে m − 1-এর সব ঘরকে কোনো এক ক্রমে ছুঁয়ে যায়। Insert সেই ক্রমে প্রথম খালি ঘরে বসায়; find একই ক্রমে এগোয়, key পেলে থামে, আর একটি খালি ঘর পেলে বলে “নেই” — কারণ key থাকলে insert-এর সময় এই খালি ঘরেই বসত। যেহেতু প্রতিটি ঘরে একটিই key, এখানে সবসময় n ≤ m, অর্থাৎ α ≤ 1.

Linear probing আর primary clustering

সবচেয়ে সরল নিয়ম linear probing: h(k, i) = (h(k) + i) mod m — নিজের ঘর, তারপর ঠিক পরেরটি, তারপর তার পরেরটি, শেষে পৌঁছালে আবার শুরু থেকে। নিচে m = 11, h(k) = k mod 11:

টেবিল (· = খালি)সবুজ = এইমাত্র বসানো key

            
প্রতিটি insert-এর পর
key k
home slot h(k)
যে slot দেখা হলো (probe)
probe-এর সংখ্যা
সবচেয়ে লম্বা cluster
  1. শুরু. এগারোটি খালি slot. প্রতিটি key-এর home slot = k mod 11.
  2. insert 12. 12 mod 11 = 1, slot 1 খালি — এক probe-এ বসল।
  3. insert 23. 23 mod 11 = 1 — slot 1-এ 12. পরের slot 2 খালি, সেখানে বসল: ২ probe. এখন slot 1–2 একটানা ভরা — একটি cluster.
  4. insert 2. 2-এর home slot 2 — কিন্তু সেটি দখল করেছে 23, যার নিজের home ছিল 1! 2 গেল slot 3-এ। যে key-এর সাথে কোনো collision হওয়ার কথা ছিল না, সেও cluster-এ আটকে গেল, আর cluster বড় হলো: slot 1–3.
  5. insert 34. 34 mod 11 = 1. slot 1, 2, 3 ভরা — পুরো cluster পার হয়ে slot 4: ৪ probe. Cluster এখন 1–4.
  6. insert 5. home 5 খালি, এক probe. কিন্তু slot 5 ঠিক cluster-এর পাশে — দুটি জোড়া লেগে cluster এখন 1–5, দৈর্ঘ্য ৫.
  7. insert 45. 45 mod 11 = 1 → slot 1 থেকে 5 সব ভরা, slot 6-এ বসল: ৬ probe. ছয়টি key, অথচ শেষটির জন্যই ছয়টি probe. পরের key-এর home যদি slot 0 থেকে 7-এর যেকোনোটি হয় (১১-র মধ্যে ৮টি ঘর), সে এই cluster-এই যোগ হবে — বড় cluster আরও দ্রুত বড় হয়।

এই ঘটনার নাম primary clustering: ভরা ঘরগুলো লম্বা একটানা দল বাঁধে, আর একটি দল যত লম্বা, নতুন key তাতে পড়ার সম্ভাবনা তত বেশি (দৈর্ঘ্য L-এর cluster-এ পড়ার সম্ভাবনা প্রায় (L + 1)/m) — “ধনী আরও ধনী হয়”. ঢাকার রাস্তার জ্যামের মতো: একটি মোড়ে গাড়ি আটকালে পেছনের গাড়ি জমে, আর জমা লাইন পাশের মোড়ের লাইনের সাথে মিশে আরও লম্বা হয়।

১৯৬০-এর দশকের শুরুতে Donald Knuth linear probing-এর গড় খরচ ঠিকঠাক বের করেন (একটি ভালো, এলোমেলো hash ধরে নিয়ে, আর বড় m-এর জন্য): সফল খোঁজায় প্রায় ½(1 + 1/(1 − α)) probe, ব্যর্থ খোঁজা বা insert-এ প্রায় ½(1 + 1/(1 − α)²). তুলনার জন্য, যদি প্রতিটি key-এর probe sequence সম্পূর্ণ এলোমেলো একটি ক্রম হতো (uniform hashing), তাহলে ব্যর্থ খোঁজায় সর্বোচ্চ 1/(1 − α) probe লাগত — কারণ প্রথম probe ভরা ঘরে পড়ে α সম্ভাবনায়, প্রথম দুটিই ভরা ঘরে পড়ে বড়জোর α² সম্ভাবনায়, এভাবে প্রত্যাশিত probe ≤ 1 + α + α² + … = 1/(1 − α), পর্ব ০৬-এর geometric series অনুযায়ী।

αChaining: সফল ≈ 1 + α/2Linear probing: সফলLinear probing: ব্যর্থUniform hashing: ব্যর্থ ≤ 1/(1 − α)
0.51.251.52.52
0.751.3752.58.54
0.91.455.550.510

টেবিলের শিক্ষা পরিষ্কার: α ছোট থাকলে linear probing চমৎকার, কিন্তু α 1-এর কাছে গেলে ব্যর্থ খোঁজার খরচ বিস্ফোরিত হয় — 0.9-এ গড়ে ৫০টিরও বেশি probe. তাই open addressing-এ α সাধারণত 0.5 থেকে 0.75-এর মধ্যে রাখা হয়। তবু বাস্তবে linear probing প্রায়ই সবচেয়ে দ্রুত, কারণ পাশাপাশি ঘর দেখা মানে একই cache line — পর্ব ১০-এর spatial locality.

Quadratic probing আর double hashing

Cluster ভাঙতে probe-এর লাফ বদলানো যায়। Quadratic probing-এ h(k, i) = (h(k) + i²) mod m — লাফ 1, 4, 9, 16, … ক্রমে বাড়ে, তাই পাশাপাশি cluster জমে না। কিন্তু সব probe-এর ক্রম আর সব ঘর ছোঁয় না; প্রমাণ করা যায় m মৌলিক হলে প্রথম ⌈m/2⌉ টি probe আলাদা ঘরে পড়ে, তাই α ≤ ½ রাখলে insert সবসময় খালি ঘর পায়। আরেকটি দুর্বলতা থেকে যায়: একই home-এর দুটি key হুবহু একই ক্রমে হাঁটে — একে বলে secondary clustering.

Double hashing এটিও সারায়: দ্বিতীয় একটি hash function h2 লাফের দৈর্ঘ্য ঠিক করে — h(k, i) = (h1(k) + i · h2(k)) mod m. দুটি শর্ত: h2(k) কখনো 0 হবে না (নইলে একই ঘরে ঘুরপাক), আর h2(k) ও m-এর গসাগু 1 হবে, যাতে সব ঘর ছোঁয়া যায় — m মৌলিক নিলে দ্বিতীয়টি আপনাআপনি সত্য। একটি প্রচলিত পছন্দ h1(k) = k mod m, h2(k) = 1 + (k mod (m − 1)). m = 11-এ home slot 1-এর দুটি key-এর প্রথম পাঁচটি probe দেখুন:

নিয়ম (m = 11)key 45 (home 1)key 12 (home 1)সমস্যা
Linear: 1 + i1, 2, 3, 4, 51, 2, 3, 4, 5primary clustering
Quadratic: 1 + i²1, 2, 5, 10, 61, 2, 5, 10, 6একই home = একই পথ (secondary)
Double: 1 + i·h2h2 = 1 + 5 = 6: 1, 7, 2, 8, 3h2 = 1 + 2 = 3: 1, 4, 7, 10, 2দুটি hash লাগে, cache-এ কম বন্ধুত্বপূর্ণ

Double hashing-এ প্রতিটি key-এর নিজস্ব পথ, তাই এর আচরণ uniform hashing-এর খুব কাছাকাছি — বিশ্লেষণে প্রায় 1/(1 − α). বিনিময়ে লাফগুলো দূরে দূরে, তাই cache-এর সুবিধা কম। বাস্তবের লাইব্রেরি এই দুইয়ের মধ্যে আপস করে, যেমন Python-এর dict (অংশ ৯)।

৬ · Delete-এর ফাঁদ — tombstone

Chaining-এ delete সহজ — তালিকা থেকে node সরিয়ে দিন। Open addressing-এ ভাবুন: আগের ডায়াগ্রামের টেবিল থেকে 23 মুছতে ঘরটি খালি করে দিলেন। এখন 34 খুঁজুন: home slot 1-এ 12, slot 2 খালি — “খালি ঘর মানে key নেই” নিয়মে খোঁজা থামবে, অথচ 34 বসে আছে slot 4-এ! খালি ঘরটি probe sequence-এর শিকল ভেঙে দিয়েছে. সমাধান হলো মোছা ঘরে একটি বিশেষ চিহ্ন রাখা — tombstone (কবরের ফলক), নিচে #: “এখানে কেউ ছিল”. খোঁজার সময় tombstone পার হয়ে যেতে হয়, আর insert-এর সময় সেই ঘর আবার ব্যবহার করা যায়।

Linear probing, m = 11 (· = খালি, # = tombstone)সবুজ = এই ধাপে দেখা বা বদলানো slot

            
প্রতিটি ধাপে
কাজ
যে slot দেখা হলো
ফল
  1. শুরু. অংশ ৫-এর শেষ টেবিল: slot 1–6-এ 12, 23, 2, 34, 5, 45. এদের মধ্যে 23, 34 আর 45-এর home slot 1 — তারা cluster ধরে এগিয়ে বসেছে।
  2. ভুল উপায় — খালি করা. 23 খুঁজে (slot 1-এ 12, slot 2-এ 23) ঘরটি খালি করে দিলাম। দেখতে নিরীহ, কিন্তু cluster-এর মাঝে একটি ফাঁক তৈরি হলো।
  3. ভুলের ফল. এবার find 34: home 1-এ 12 — মিলল না; slot 2 খালি — নিয়ম অনুযায়ী থামা, উত্তর “নেই”. অথচ 34 আছে slot 4-এ। একইভাবে 45-ও আর খুঁজে পাওয়া যাবে না। টেবিল নিঃশব্দে ডেটা “হারিয়ে” ফেলেছে — এমন bug ছোট পরীক্ষায় প্রায়ই ধরা পড়ে না।
  4. সঠিক উপায় — tombstone. মূল টেবিলে ফিরে আবার delete 23: এবার slot 2-এ রাখলাম # চিহ্ন। Key হিসেবে এটি “নেই”, কিন্তু probe-এর শিকল হিসেবে এটি “ভরা”.
  5. find 34. slot 1-এ 12 — না; slot 2-এ # — key নয়, কিন্তু থামার কারণও নয়, এগোলাম; slot 3-এ 2 — না; slot 4-এ 34 — পাওয়া গেল, ৪ probe. সঠিক উত্তর।
  6. insert 56 — tombstone-এর পুনর্ব্যবহার. 56 mod 11 = 1. আগে নিশ্চিত হতে হবে 56 আগে থেকেই নেই: slot 1 থেকে 7 পর্যন্ত হাঁটলাম (slot 7 প্রথম সত্যিকারের খালি ঘর), ৭ probe-এ নিশ্চিত। তারপর পথের প্রথম tombstone, slot 2-এ 56 বসালাম। টেবিল আবার পরিচ্ছন্ন।

Tombstone-এর একটি দাম আছে: এগুলো key নয়, কিন্তু খোঁজার পথ লম্বা করে। অনেক insert-delete-এর পর টেবিল tombstone-এ ভরে গেলে খালি ঘর প্রায় থাকে না, আর ব্যর্থ খোঁজা পুরো টেবিল ঘুরে আসে। তাই বাস্তবায়নে “ভরা” গোনার সময় key আর tombstone দুটোই গোনা হয়, আর সীমা ছাড়ালে rehash করে (অংশ ৭) সব tombstone একসাথে মুছে ফেলা হয়। Linear probing-এ আরেকটি উপায়ও আছে — delete-এর পর পেছনের key-গুলোকে সরিয়ে ফাঁক ভরাট করা (backward-shift deletion), যাতে tombstone-ই লাগে না।

এবার পুরো কৌশলটি কোডে: linear probing, tombstone-সহ delete, আর প্রতিটি কাজের পর টেবিল ছাপা। . খালি, # tombstone.

#include <bits/stdc++.h>
#include <initializer_list>
using namespace std;
const int M = 11, EMPTY = -1, TOMB = -2;      // keys are >= 0; TOMB marks a deleted slot
vector<int> t(M, EMPTY);
void show(const string& what) {
    cout << what << "\n   [";
    for (int i = 0; i < M; ++i) cout << (i ? " " : "") << (t[i] == EMPTY ? "." : t[i] == TOMB ? "#" : to_string(t[i]));
    cout << "]\n";
}
pair<int, int> locate(int k) {                // -> {slot or -1, number of probes}
    for (int p = 1, s = k % M; p <= M; ++p, s = (s + 1) % M) {
        if (t[s] == EMPTY) return {-1, p};    // a truly empty slot ends the search
        if (t[s] == k) return {s, p};         // tombstones are simply skipped
    }
    return {-1, M};
}
void insert(int k) {                          // assumes the table never becomes full
    if (locate(k).first >= 0) return show("insert " + to_string(k) + ": already present");
    int s = k % M; while (t[s] >= 0) s = (s + 1) % M;   // first empty OR tombstone slot
    t[s] = k; show("insert " + to_string(k) + " -> slot " + to_string(s));
}
void erase(int k) {
    int s = locate(k).first;
    if (s >= 0) t[s] = TOMB;                  // mark, never empty: later keys stay reachable
    show("erase " + to_string(k) + " -> " + (s >= 0 ? "tombstone at slot " + to_string(s) : string("not found")));
}
void lookup(int k) {
    auto [s, p] = locate(k);
    cout << "find " << k << ": " << (s >= 0 ? "slot " + to_string(s) : string("absent")) << " after " << p << " probes\n";
}
int main() {
    for (int k : {22, 33, 44, 5, 16, 27}) insert(k);
    erase(33); lookup(44); lookup(66); insert(66); erase(5); lookup(27);
}

আউটপুট:

insert 22 -> slot 0
   [22 . . . . . . . . . .]
insert 33 -> slot 1
   [22 33 . . . . . . . . .]
insert 44 -> slot 2
   [22 33 44 . . . . . . . .]
insert 5 -> slot 5
   [22 33 44 . . 5 . . . . .]
insert 16 -> slot 6
   [22 33 44 . . 5 16 . . . .]
insert 27 -> slot 7
   [22 33 44 . . 5 16 27 . . .]
erase 33 -> tombstone at slot 1
   [22 # 44 . . 5 16 27 . . .]
find 44: slot 2 after 3 probes
find 66: absent after 4 probes
insert 66 -> slot 1
   [22 66 44 . . 5 16 27 . . .]
erase 5 -> tombstone at slot 5
   [22 66 44 . . # 16 27 . . .]
find 27: slot 7 after 3 probes

প্রথম তিনটি key (22, 33, 44)-এর home slot 0, তাই তারা slot 0, 1, 2-এ একটি cluster বানায়; 5, 16, 27-এর home 5, তারা বসে 5, 6, 7-এ। erase 33 slot 1-এ tombstone রাখে। তারপর find 44 slot 0 (22), slot 1 (#, পার হওয়া), slot 2 (44) — ৩ probe-এ পায়। Tombstone না রেখে slot 1 খালি করলে এখানেই ভুল “absent” আসত। find 66 (home 0) slot 0, 1, 2 পার হয়ে slot 3-এর সত্যিকারের খালি ঘরে থামে — ৪ probe. পরের insert 66 পথের প্রথম tombstone, slot 1-এ বসে। শেষে 5 মোছার পর find 27 slot 5-এর # পার হয়ে slot 7-এ পৌঁছায়। locate() সর্বোচ্চ m টি ঘর দেখে, তাই worst case O(m); বাড়তি জায়গা O(1). এখানে resize নেই; বাস্তব কোডে load factor সীমা ছাড়ালে আগেই rehash করতে হয়। লক্ষ করুন, while (t[s] >= 0) খালি (−1) আর tombstone (−2) — দুটোতেই থামে, তাই insert tombstone পুনর্ব্যবহার করে।

M = 11
EMPTY, TOMB = None, "#"                 # TOMB marks a deleted slot
t = [EMPTY] * M
def show(what):
    print(what)
    print("   [" + " ".join("." if x is EMPTY else str(x) for x in t) + "]")
def locate(k):                          # -> (slot or -1, number of probes)
    s = k % M
    for p in range(1, M + 1):
        if t[s] is EMPTY: return -1, p  # a truly empty slot ends the search
        if t[s] == k: return s, p       # tombstones are simply skipped
        s = (s + 1) % M
    return -1, M
def insert(k):                          # assumes the table never becomes full
    if locate(k)[0] >= 0: return show(f"insert {k}: already present")
    s = k % M
    while t[s] is not EMPTY and t[s] != TOMB:
        s = (s + 1) % M                 # stop at the first empty OR tombstone slot
    t[s] = k
    show(f"insert {k} -> slot {s}")
def erase(k):
    s = locate(k)[0]
    if s >= 0: t[s] = TOMB              # mark, never empty: later keys stay reachable
    show(f"erase {k} -> " + (f"tombstone at slot {s}" if s >= 0 else "not found"))
def lookup(k):
    s, p = locate(k)
    print(f"find {k}: " + (f"slot {s}" if s >= 0 else "absent") + f" after {p} probes")
for k in [22, 33, 44, 5, 16, 27]: insert(k)
erase(33); lookup(44); lookup(66); insert(66); erase(5); lookup(27)

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

insert 22 -> slot 0
   [22 . . . . . . . . . .]
insert 33 -> slot 1
   [22 33 . . . . . . . . .]
insert 44 -> slot 2
   [22 33 44 . . . . . . . .]
insert 5 -> slot 5
   [22 33 44 . . 5 . . . . .]
insert 16 -> slot 6
   [22 33 44 . . 5 16 . . . .]
insert 27 -> slot 7
   [22 33 44 . . 5 16 27 . . .]
erase 33 -> tombstone at slot 1
   [22 # 44 . . 5 16 27 . . .]
find 44: slot 2 after 3 probes
find 66: absent after 4 probes
insert 66 -> slot 1
   [22 66 44 . . 5 16 27 . . .]
erase 5 -> tombstone at slot 5
   [22 66 44 . . # 16 27 . . .]
find 27: slot 7 after 3 probes

Python-এ খালি ঘর None আর tombstone একটি আলাদা চিহ্ন "#" — দুটিকে গুলিয়ে ফেলা চলবে না, কারণ খোঁজা থামে শুধু None-এ। insert-এর while শর্ত পড়ুন: “ঘরটি খালিও নয়, tombstone-ও নয়” — অর্থাৎ সত্যিকারের key থাকলে এগোও। locate একটি tuple ফেরত দেয়: (slot, probe সংখ্যা)।

নিজে চালিয়ে দেখুন — chaining বনাম linear probing

নিচের টুলে “চালিয়ে দেখুন” চাপুন। একই key দুটি টেবিলে একসাথে ঢোকে, দুটিরই m = 11 আর h(k) = k mod 11. বাঁয়ে chaining-এর তালিকা (নতুন key মাথায়, লাল), ডানে linear probing-এর slot (এই insert-এ যে slot-গুলো দেখা হলো সেগুলো লাল)। শুরুতে কোড নিজে নয়টি key দেয় — প্রথম ছয়টি ওপরের প্রোগ্রামের মতোই। এরপর নিজে key দিন, বা “এলোমেলো ৭টি” চাপুন আর দেখুন cluster কত দ্রুত লম্বা হয়।

const M = 11;                                   // both tables: m = 11, h(k) = k mod 11
const ch = Array.from({ length: M }, () => []); // separate chaining: one list per bucket
const lp = new Array(M).fill(null);             // linear probing: one key per slot
let path = [], hotB = -1;
const $ = id => document.getElementById(id);
const h = k => ((k % M) + M) % M;
function longestCluster() {                     // longest run of occupied slots (wraps around)
  let best = 0, run = 0;
  for (let i = 0; i < 2 * M; i++) { run = lp[i % M] === null ? 0 : run + 1; best = Math.max(best, Math.min(run, M)); }
  return best;
}
function draw(msg) {
  $("ch").innerHTML = ch.map((b, i) => `<div class="r"><b>${i}</b>` +
    b.map((k, j) => `<span class="${i === hotB && j === 0 ? "hl" : ""}">${k}</span>`).join("") + "</div>").join("");
  $("lp").innerHTML = lp.map((k, i) => `<div class="c${path.includes(i) ? " hl" : ""}"><small>${i}</small>${k ?? "·"}</div>`).join("");
  $("msg").textContent = msg; console.log(msg);
}
function insert(k) {
  if (!Number.isInteger(k)) return draw("please type a whole number");
  if (lp.includes(k)) return draw(`${k} is already in both tables`);
  if (!lp.includes(null)) return draw("probing table is full: it must grow (rehash) first");
  const b = h(k); ch[b].unshift(k); hotB = b;   // chaining: put the key at the head of its chain
  let s = b; path = [s];
  while (lp[s] !== null) { s = (s + 1) % M; path.push(s); }
  lp[s] = k;
  draw(`insert ${k}: h=${b} | chain[${b}] length ${ch[b].length} | probing: slot ${s}, ${path.length} probes, longest cluster ${longestCluster()}`);
}
function clearAll() { ch.forEach(b => (b.length = 0)); lp.fill(null); path = []; hotB = -1; }
$("bi").onclick = () => insert(Number($("hk").value));
$("br").onclick = () => { clearAll(); for (let i = 0; i < 7; i++) { let k; do k = Math.floor(Math.random() * 100); while (lp.includes(k)); insert(k); } };
$("bz").onclick = () => { clearAll(); draw("empty tables"); };
for (const k of [22, 33, 44, 5, 16, 27, 3, 4, 55]) insert(k);

শুরুর নয়টি insert-এর আউটপুট:

insert 22: h=0 | chain[0] length 1 | probing: slot 0, 1 probes, longest cluster 1
insert 33: h=0 | chain[0] length 2 | probing: slot 1, 2 probes, longest cluster 2
insert 44: h=0 | chain[0] length 3 | probing: slot 2, 3 probes, longest cluster 3
insert 5: h=5 | chain[5] length 1 | probing: slot 5, 1 probes, longest cluster 3
insert 16: h=5 | chain[5] length 2 | probing: slot 6, 2 probes, longest cluster 3
insert 27: h=5 | chain[5] length 3 | probing: slot 7, 3 probes, longest cluster 3
insert 3: h=3 | chain[3] length 1 | probing: slot 3, 1 probes, longest cluster 4
insert 4: h=4 | chain[4] length 1 | probing: slot 4, 1 probes, longest cluster 8
insert 55: h=0 | chain[0] length 4 | probing: slot 8, 9 probes, longest cluster 9

দুই টেবিলের পার্থক্য শেষ তিন লাইনে। 3 আর 4 নিজেদের খালি home slot-এ বসেছে (১ probe করে), কিন্তু 4 বসতেই slot 0–2 আর 3–7 জুড়ে গিয়ে একটি ৮ ঘরের cluster হয়েছে। তারপর 55 (home 0) পুরো cluster পার হয়ে slot 8-এ — ৯ probe. Chaining-এ একই 55 শুধু bucket 0-এর তালিকায় যোগ হয়েছে, যার দৈর্ঘ্য এখন ৪ — অন্য bucket-এর ভিড় তাকে ছোঁয়নি। α এখন 9/11 ≈ 0.82; অংশ ৫-এর টেবিল এই কারণেই open addressing-এ α 0.75-এর নিচে রাখতে বলে। ((k % M) + M) % M লেখা হয়েছে, কারণ JavaScript-এ ঋণাত্মক সংখ্যার % ঋণাত্মক ফল দেয়।

৭ · Load factor আর rehashing — টেবিল বড় করার হিসাব

অংশ ৪ আর ৫-এর সব “গড়ে O(1)” দাঁড়িয়ে আছে একটি শর্তে: α ধ্রুবক থাকবে। কিন্তু key আসতেই থাকে, n বাড়ে, আর m স্থির থাকলে α = n/m-ও বাড়ে। সমাধান পর্ব ১০-এর dynamic array-এর মতোই: α একটি সীমা ছাড়ালে প্রায় দ্বিগুণ আকারের নতুন টেবিল বানান, আর পুরোনো সব key সেখানে আবার ঢোকান। একে বলে rehashing. শুধু কপি করলে হয় না — h(k) = k mod m-এ m বদলালে প্রায় প্রতিটি key-এর ঘরই বদলে যায়, তাই প্রতিটি key-কে আবার hash করতে হয়।

Linear probing, h(k) = k mod mসবুজ = নতুন জায়গায় বসা key

            
প্রতিটি ধাপে
m
n (key-এর সংখ্যা)
α
কাজ
  1. ছোট টেবিল. m = 5-এ 7 বসেছে slot 2-এ (7 mod 5 = 2); 12-এর home-ও 2, তাই slot 3; 3-এর home 3 ভরা, তাই slot 4. n = 3, α = 0.6 — সীমা 0.75-এর নিচে।
  2. সীমা ছাড়ানোর আগেই থামা. 17 ঢোকালে α হবে 4/5 = 0.8 > 0.75. তাই আগে বড় টেবিল: আকার প্রায় দ্বিগুণ, আর division method-এর জন্য মৌলিক — 2 × 5 = 10-এর পরের মৌলিক সংখ্যা 11. নতুন টেবিল পুরো খালি।
  3. Rehash. পুরোনো টেবিলের প্রতিটি key নতুন m দিয়ে আবার hash: 7 mod 11 = 7, 12 mod 11 = 1, 3 mod 11 = 3. পুরোনো টেবিলে পাশাপাশি থাকা 7 আর 12 এখন অনেক দূরে, আর কারও collision নেই — বড় টেবিলে cluster-ও ভেঙে যায়। এই ধাপে কাজ n-এর সমানুপাতিক। (tombstone থাকলে সেগুলো এখানে বাদ পড়ে।)
  4. এবার insert 17. 17 mod 11 = 6, খালি slot, এক probe. α = 4/11 ≈ 0.36 — সামনের অনেক insert-এর জন্য জায়গা তৈরি।

কেন তবু amortized O(1)

একটি rehash Θ(n) — তাহলে কি insert ধীর? না, কারণ rehash খুব কম ঘটে। ধরুন টেবিল প্রতিবার দ্বিগুণ হয়, আর শেষে n টি key আছে। শেষ rehash-এ সর্বোচ্চ n টি key সরেছে, তার আগেরটিতে n/2, তার আগে n/4… মোট সরানো সর্বোচ্চ n + n/2 + n/4 + … < 2n. সাথে n টি সাধারণ insert. তাই n টি insert-এর মোট খরচ O(n), অর্থাৎ প্রতি insert amortized O(1) — পর্ব ১০-এর vector-এর হুবহু একই যুক্তি, আর পর্ব ৬০-এ potential method দিয়ে এর আনুষ্ঠানিক প্রমাণ দেখবেন। শর্ত একটাই: আকার গুণ করে বাড়াতে হবে, যেমন ×2 বা ×1.5 হারে। প্রতিবার শুধু ১০টি ঘর যোগ করলে মোট খরচ Θ(n²/10) — O(n²).

  • লাইব্রেরির সীমা: C++ unordered_map-এর max_load_factor() ডিফল্ট 1.0, Java HashMap-এর 0.75, আর CPython-এর dict ভরতে দেয় মোটামুটি 2/3 পর্যন্ত। Chaining-এ α 1 হলেও চলে; open addressing-এ সীমা কম রাখতে হয়।
  • আগেই জায়গা নিন: কতগুলো key আসবে জানা থাকলে C++-এ m.reserve(n) বা Java-তে new HashMap<>(capacity) দিন — বারবার rehash এড়ায়। কনটেস্টে বড় unordered_map-এ এটি একটি সহজ গতিবৃদ্ধি।
  • Latency-র দিক: amortized O(1) মানে গড়, প্রতিটি নয় — যে insert rehash ঘটায় সে একাই Θ(n). বিশাল টেবিলে এক মুহূর্তের এই থেমে যাওয়া কিছু সার্ভারে সমস্যা, তাই Redis-এর মতো সিস্টেম rehash একবারে না করে প্রতিটি কাজের সাথে অল্প অল্প করে — একে বলে incremental rehashing.
  • আকার মৌলিক না 2-এর ঘাত: mod m-এর জন্য মৌলিক m ভালো (GCC তা-ই করে)। Java আর Python 2-এর ঘাত নেয়, কারণ তখন mod মানে শুধু একটি bit-mask (hash & (m − 1)) — দ্রুত। তবে তখন hash-এর নিচের bit-গুলো ভালো হতে হয়, তাই Java hash-এর ওপরের ১৬টি bit নিচের সাথে XOR করে মিশিয়ে নেয়: h ^ (h >>> 16).

৮ · শত্রুর ইনপুট — universal hashing, hash flooding আর SipHash

অংশ ৪-এর বিশ্লেষণ ধরে নিয়েছিল key-গুলো “এলোমেলোভাবে” ছড়ায়। কিন্তু ইনপুট যদি দেয় এমন কেউ, যে আপনার hash function জানে আর আপনার প্রোগ্রাম ধীর করতে চায়? দুঃসংবাদটি প্রমাণ করা সহজ: যেকোনো নির্দিষ্ট hash function-এর একটি খারাপ ইনপুট আছে। |U| ≥ n·m হলে pigeonhole অনুযায়ী অন্তত একটি bucket-এ U-এর অন্তত n টি key পড়ে; আক্রমণকারী শুধু সেই n টি key পাঠায়, আর সব এক bucket-এ — প্রতিটি কাজ Θ(n), n টি insert মিলে Θ(n²). পর্ব ১৫-এ দেখেছেন, কনটেস্টে এই adversarial input-এর নাম anti-hash test.

Universal hashing — এলোমেলোতা আমাদের হাতে

সমাধানটি quick sort-এর এলোমেলো pivot-এর মতো (পর্ব ২২): এলোমেলোতা ইনপুটের ওপর ছেড়ে না দিয়ে নিজে নিন. ১৯৭৯ সালে J. Lawrence Carter আর Mark Wegman (প্রথম সংস্করণ ১৯৭৭ সালের একটি সম্মেলনে) universal hashing-এর ধারণা দেন: hash function-এর একটি পরিবার H, আর প্রোগ্রাম শুরুতে H থেকে এলোমেলোভাবে একটি h বেছে নেয়। পরিবারটি universal, যদি যেকোনো দুটি আলাদা key x ≠ y-এর জন্য

Prh ∈ H [ h(x) = h(y) ] ≤ 1/m

অর্থাৎ যেকোনো নির্দিষ্ট জোড়া key-এর collision-এর সম্ভাবনা ঠিক ততটুকু, যতটা সম্পূর্ণ এলোমেলো hash-এ। ফল: chaining-এ যেকোনো key x-এর bucket-এ অন্য key-এর প্রত্যাশিত সংখ্যা = Σy ≠ x Pr[h(y) = h(x)] ≤ (n − 1)/m < α. তাই প্রত্যাশিত খরচ O(1 + α) — যেকোনো ইনপুটের জন্য, কারণ সম্ভাবনা এখন h বাছাইয়ের ওপর, ইনপুটের ওপর নয়। আক্রমণকারী ইনপুট বানানোর সময় জানে না কোন h বেছে নেওয়া হবে, তাই তার পক্ষে কোনো নির্দিষ্ট ইনপুটকে খারাপ বানানো সম্ভব নয়। এটি পর্ব ৬১-এর randomized algorithm-এর একটি সুন্দর উদাহরণ।

একটি ক্লাসিক universal পরিবার: সব key-এর চেয়ে বড় একটি মৌলিক সংখ্যা p নিন, আর এলোমেলোভাবে a ∈ {1, …, p − 1}, b ∈ {0, …, p − 1} বেছে নিন।

ha,b(k) = ((a·k + b) mod p) mod m

প্রমাণের মূল ভাবনা: x ≠ y হলে (a, b) থেকে (ax + b mod p, ay + b mod p) জোড়ায় যাওয়ার নিয়মটি এক-এক (bijection), কারণ p মৌলিক বলে x − y-এর mod p-তে উল্টো আছে। তাই দুটি মান p-এর মধ্যে এলোমেলো দুটি আলাদা সংখ্যার মতো আচরণ করে, আর শেষ “mod m”-এর পর তারা একই ঘরে পড়ে বড়জোর 1/m সম্ভাবনায়।

Hash flooding — যখন আক্রমণ বাস্তব

২০০৩ সালে Scott Crosby আর Dan Wallach একটি গবেষণায় দেখান যে বাস্তব সফটওয়্যারের hash table (যেমন Perl-এর) ইচ্ছা করে বানানো ইনপুটে quadratic সময়ে নেমে আসে — এই “algorithmic complexity attack” দিয়ে অল্প ডেটা পাঠিয়েই সার্ভার অচল করা যায়। ২০১১ সালের ডিসেম্বরে জার্মানির একটি নিরাপত্তা সম্মেলনে (28C3) Alexander Klink আর Julian Wälde দেখান যে PHP, Java, Python, Ruby-সহ বহু জনপ্রিয় ওয়েব প্ল্যাটফর্ম একই আক্রমণে দুর্বল: ওয়েব সার্ভার একটি POST অনুরোধের সব প্যারামিটার একটি hash table-এ রাখে, তাই হাজার হাজার collision-ওয়ালা প্যারামিটার-নাম পাঠালেই একটি অনুরোধে CPU দীর্ঘক্ষণ ব্যস্ত। একে বলে hash flooding, এক ধরনের denial-of-service আক্রমণ।

Java-র উদাহরণটি দেখার মতো। অংশ ২-এর সূত্রে "Aa" = 65·31 + 97 = 2112, আর "BB" = 66·31 + 66 = 2112 — একই hash! আর দুটিই দুই অক্ষরের, তাই এগুলো জোড়া দিয়ে বানানো যেকোনো string — "AaAa", "AaBB", "BBAa", "BBBB" — সবার hash এক। k টি টুকরো জুড়লে 2k টি আলাদা string, সবাই এক bucket-এ। Hash function সবার জানা আর নির্দিষ্ট — এটাই দুর্বলতা।

প্রতিকার হিসেবে এসেছে গোপন key-ওয়ালা hash. ২০১২ সালে Jean-Philippe Aumasson আর Daniel J. Bernstein SipHash প্রকাশ করেন: একটি দ্রুত hash function, যা একটি 128-bit গোপন key নেয়। প্রোগ্রাম চালু হওয়ার সময় key এলোমেলোভাবে বাছাই হয়, তাই বাইরের কেউ জানে না কোন string কোন bucket-এ যাবে — universal hashing-এর ভাবনার বাস্তব, দ্রুত রূপ। Python ৩.৩ থেকে string-এর hash প্রতিবার চালানোয় আলাদা seed পায় (PYTHONHASHSEED), আর ৩.৪ থেকে str ও bytes-এর hash SipHash-ভিত্তিক; Rust-এর HashMap-এর ডিফল্ট hash-ও SipHash-এর একটি রূপ। Java অন্য পথ নিয়েছে: Java 8 থেকে HashMap-এর কোনো bucket-এ বেশি key জমলে তালিকাটি একটি red-black tree-তে বদলে যায় (অংশ ৯), তাই ওই bucket-এ worst case O(n) থেকে নেমে আসে O(log n)-এ (key Comparable হলে, যেমন String-এর ক্ষেত্রে)।

কনটেস্টে: GCC-র std::hash<int> সংখ্যাটিকেই hash হিসেবে ফেরত দেয়, আর bucket-সংখ্যার তালিকা সবার জানা — তাই Codeforces-এর মতো যেখানে অন্যের কোড hack করা যায়, সেখানে সাধারণ unordered_map<int,int> একটি anti-hash test-এ TLE খেতে পারে। পর্ব ১৫-এর splitmix64 custom hash, সময় থেকে নেওয়া এলোমেলো seed-সহ, এই সমস্যা মেটায়। নিশ্চিত থাকতে না পারলে map (O(log n) worst case) নিরাপদ বিকল্প।

আরও গভীরে: Cuckoo hashing (Rasmus Pagh ও Flemming Friche Rodler, ২০০১) প্রতিটি key-কে দুটি সম্ভাব্য ঘর দেয় দুটি hash function দিয়ে; দুটিই ভরা থাকলে পুরোনো key-কে লাথি মেরে তার অন্য ঘরে পাঠায়, কোকিলছানা যেমন অন্য পাখির ডিম বাসা থেকে ফেলে দেয় — ফলে find worst case-এও মাত্র দুটি ঘর দেখে, insert প্রত্যাশিত (amortized) O(1). Robin Hood hashing (Pedro Celis, ১৯৮৫–৮৬) linear probing-এ insert-এর সময় যে key নিজের home থেকে বেশি দূরে, তাকে জায়গা ছেড়ে দেয় — “ধনীর কাছ থেকে নিয়ে গরিবকে” — ফলে probe-দূরত্বের বৈষম্য কমে আর খোঁজার worst case-ও ছোট থাকে; Rust-এর HashMap ২০১৯ পর্যন্ত এটিই ব্যবহার করত। আধুনিক টেবিল, যেমন Google-এর SwissTable (Abseil-এর flat_hash_map), open addressing-এর সাথে প্রতি ঘরে এক byte-এর metadata রাখে, যাতে SIMD নির্দেশ দিয়ে একসাথে ১৬টি ঘর পরীক্ষা করা যায়।

৯ · লাইব্রেরি আর প্রয়োগ — set, map, two-sum থেকে Bloom filter

দুই ধরনের hash container আছে। Hash set শুধু key রাখে আর উত্তর দেয় “আছে কি না” — C++-এ unordered_set, Python-এ set, Java-তে HashSet. Hash map প্রতিটি key-এর সাথে একটি value রাখে (key → value জোড়া) — C++-এ unordered_map, Python-এ dict, Java-তে HashMap. ভেতরের কৌশল একই; Java-র HashSet তো আসলে ভেতরে একটি HashMap, যার প্রতিটি value একটি নকল বস্তু। তিন ভাষার লাইব্রেরি কিন্তু তিনটি আলাদা নকশা বেছে নিয়েছে:

বিষয়C++ unordered_map (GCC libstdc++)Python dict (CPython)Java HashMap
Collision-এর সমাধানSeparate chaining — প্রতিটি key একটি আলাদা node-এOpen addressing, “perturbation” দিয়ে লাফানো probeSeparate chaining; লম্বা bucket red-black tree হয়ে যায়
টেবিলের আকারমৌলিক সংখ্যা2-এর ঘাত2-এর ঘাত (ডিফল্ট শুরু 16)
Resize-এর সীমাα > 1.0 (max_load_factor)প্রায় 2/3 ভরাα > 0.75
ঘোরার (iteration) ক্রমএলোমেলো, rehash-এ বদলায়insert-এর ক্রম (৩.৭ থেকে ভাষার নিশ্চয়তা)এলোমেলো
বিশেষ দিকrehash-এর পরও উপাদানের pointer/reference বৈধ থাকে — node-ভিত্তিক বলে৩.৬ থেকে “compact”: ছোট index টেবিল + insert-ক্রমে ঘন entry অ্যারে, delete-এ dummy (tombstone)Java 8 থেকে একটি bucket-এ ৮টির বেশি key জমলে (আর টেবিল অন্তত ৬৪ ঘরের হলে) tree-তে বদল

Python dict-এর probe সাধারণ linear নয়: পরের ঘর হিসাব হয় j = (5j + 1 + perturb) mod 2k, যেখানে perturb শুরুতে পুরো hash আর প্রতি ধাপে ৫ bit ডানে সরে। ফলে hash-এর ওপরের bit-গুলোও পথ ঠিক করতে কাজে লাগে, আর একই home-এর key-রা আলাদা পথে ছড়ায় — double hashing-এর মতো সুবিধা, কম খরচে।

প্রচলিত প্রয়োগ

  • Two-sum: অ্যারেতে এমন দুটি index খুঁজুন যাদের যোগফল target. প্রতিটি a[j]-এর জন্য দেখুন target − a[j] আগে দেখা হয়েছে কি না — hash map-এ (মান → index) রাখলে গড়ে O(n) সময়, O(n) জায়গা। পর্ব ১৬-এর two pointer সমাধান সাজানো অ্যারেতে O(1) জায়গায় কাজ করে, কিন্তু সাজাতে লাগে O(n log n) আর মূল index হারায়। (two-sum)
  • Frequency counting: কোন শব্দ কতবার এসেছে, কোন bKash নম্বরে আজ কতটি লেনদেন — cnt[key]++. মান ছোট পূর্ণসংখ্যা হলে সাধারণ অ্যারে (frequency array) আরও দ্রুত — ছোট U-তে direct-address table-এ ফিরে যাওয়া।
  • Deduplication: একটি hash set-এ রেখে দিন; ইতিমধ্যে থাকলে বাদ। ডুপ্লিকেট বাদ দেওয়া গড়ে O(n), সাজিয়ে করলে O(n log n) — তবে hash-এ মূল ক্রম রাখতে আলাদা যত্ন লাগে।
  • Caching আর memoization: একবার হিসাব করা উত্তর (ইনপুট → ফল) hash map-এ রেখে দিন, পরের বার সরাসরি নিন। পর্ব ৫২-এর memoization-এর ভিত্তি এটিই; Python-এর functools.cache ভেতরে একটি dict. Doubly linked list-এর সাথে জুড়লে LRU cache (পর্ব ১২)।

Bloom filter — “হয়তো আছে” বা “নিশ্চিত নেই”

কখনো কখনো শুধু জানতে চাই একটি key সেটে আছে কি না, আর মেমরি খুব কম। ১৯৭০ সালে Burton H. Bloom একটি চতুর কাঠামো দেন — Bloom filter: m টি bit-এর একটি অ্যারে (শুরুতে সব 0) আর k টি আলাদা hash function. Insert(x): k টি hash-এর প্রতিটি ঘরে bit 1 করুন। Query(x): k টি ঘরই 1 হলে উত্তর “হয়তো আছে”, কোনো একটি 0 হলে “নিশ্চিত নেই”. অন্য key-রা মিলে ঠিক x-এর k টি ঘরই 1 করে ফেলতে পারে, তাই false positive সম্ভব; কিন্তু x ঢোকানো হলে তার ঘরগুলো কখনো 0 হয় না, তাই false negative অসম্ভব। key-গুলো নিজে কোথাও রাখা হয় না — শুধু bit.

n টি key ঢোকানোর পর একটি নির্দিষ্ট bit তখনো 0 থাকার সম্ভাবনা (1 − 1/m)kn ≈ e−kn/m. একটি নতুন key-এর k টি ঘরই 1 হওয়ার সম্ভাবনা, অর্থাৎ false positive-এর হার প্রায়

p ≈ (1 − e−kn/m)k,   সবচেয়ে ভালো k = (m/n) ln 2

প্রতি key-এ ১০ bit (m/n = 10) আর k = 7 নিলে p ≈ (1 − e−0.7)7 ≈ 0.0082 — ১%-এরও কম, অথচ key-গুলো নিজে রাখলে লাগত অনেক গুণ বেশি জায়গা। ডেটাবেস (যেমন LevelDB, RocksDB, Cassandra-র মতো LSM-tree ভিত্তিক) ডিস্কের একটি ফাইল পড়ার আগে Bloom filter দেখে নেয়: “নিশ্চিত নেই” হলে ধীর ডিস্ক-পড়া বাদ। সাধারণ Bloom filter থেকে delete করা যায় না (একটি bit অন্য key-এরও হতে পারে); তার জন্য প্রতি ঘরে ছোট counter রাখা “counting Bloom filter” আছে।

Consistent hashing — সার্ভার বদলালেও key কম সরে

একটি বড় ওয়েবসাইটের ডেটা ১০টি cache সার্ভারে ভাগ করা আছে, সার্ভার = h(key) mod 10. একটি সার্ভার যোগ হলে mod 11 — প্রায় সব key-এর সার্ভার বদলে যায়, পুরো cache একসাথে অকেজো। Consistent hashing (David Karger ও সহকর্মীরা, ১৯৯৭) সার্ভার আর key দুটোকেই একটি বৃত্তের ওপর hash করে, আর প্রতিটি key যায় বৃত্তে ঘড়ির কাঁটার দিকে তার পরের সার্ভারে। নতুন সার্ভার শুধু তার ঠিক আগের অংশের key নেয় — গড়ে মোট key-এর মাত্র প্রায় 1/(সার্ভারের সংখ্যা) অংশ সরে। এই ধারণা থেকে Akamai-র content delivery network গড়ে ওঠে, আর Amazon-এর Dynamo (২০০৭) ও Cassandra-র মতো ডেটাবেস এটি ব্যবহার করে।

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

  • Chaining-এ simple uniform hashing ধরে খোঁজার প্রত্যাশিত সময়? — Θ(1 + α), α = n/m; m = Θ(n) হলে O(1). Worst case Θ(n).
  • Open addressing-এ delete করতে ঘর খালি না করে tombstone কেন? — খালি ঘর probe-এর শিকল ভেঙে দেয়, তখন পরে বসা key “নেই” দেখায়।
  • Division method-এ m কেন মৌলিক? — 2-এর ঘাত হলে শুধু নিচের bit কাজে লাগে, আর key-এর সাথে সাধারণ উৎপাদক থাকলে অল্প কয়টি ঘর ব্যবহৃত হয়।
  • Rehash-এ O(n) লাগলেও insert কেন amortized O(1)? — আকার দ্বিগুণ হলে মোট সরানো n + n/2 + … < 2n.
  • Bloom filter কোন ভুল করতে পারে? — শুধু false positive; false negative কখনো নয়। হার ≈ (1 − e−kn/m)k.

১০ · AI-এর যুগে hash table — “গড়ে O(1)” থেকে “গড়” শব্দটি হারিয়ে যায়

“Linear probing-ওয়ালা একটি hash table লিখে দাও” — AI কয়েক সেকেন্ডে লিখবে, আর insert-find-এর ছোট পরীক্ষায় চলবেও। Hash table-এর ভুল সাধারণত লুকিয়ে থাকে delete-এ আর ভরা টেবিলে, যা ছোট পরীক্ষায় আসেই না। আর বিশ্লেষণের প্রশ্নে AI প্রায়ই “hash table-এ সব কাজ O(1)” বলে দেয় — “গড়ে”, “কোন অনুমানে” আর “worst case” বাদ দিয়ে।

Hash table-এ AI সাধারণত যেসব ভুল করে

AI-এর ভুলকীভাবে ধরবেন
Open addressing-এ delete মানে ঘর খালি করাএকই home-এর তিনটি key ঢুকিয়ে মাঝেরটি মুছুন, তারপর শেষটি খুঁজুন
টেবিল পুরো ভরলে find বা insert অসীম লুপেm টি key ঢুকিয়ে একটি অনুপস্থিত key খুঁজুন; probe-এর সংখ্যায় সীমা আছে কি না দেখুন
Rehash-এ পুরোনো অ্যারে শুধু কপি করা, নতুন m দিয়ে আবার hash না করাresize-এর পর আগের সব key খুঁজে দেখুন
Tombstone-কে load factor-এ না গোনাহাজারবার insert-delete চক্র চালিয়ে probe-এর সংখ্যা মাপুন
ঋণাত্মক key-তে k % m (C++, Java, JS-এ ঋণাত্মক ফল)−3 key দিয়ে চালান — index অ্যারের বাইরে যায় কি?
“Hash table-এ worst case-ও O(1)” বা “quadratic probing সব ঘর ছোঁয়”সব key এক bucket-এ পড়ার উদাহরণ চান; m = 8-এ i² mod 8 হাতে লিখে দেখুন (শুধু 0, 1, 4)
কনটেস্টে সাধারণ unordered_map নিরাপদ বলাanti-hash test-এর কথা জিজ্ঞেস করুন; custom hash বা map বিবেচনা করুন

AI-কে hash table লিখে দিতে না বলে, আপনার কোড আর trace পরীক্ষা করতে বলুন — আর প্রতিটি দাবির শর্ত চান:

আমি DSA শিখছি, এখন hash table. নিচে আমার লেখা linear probing hash table (insert, find, delete — tombstone-সহ) আর একটি ইনপুটে আমার হাতে করা trace দিলাম: [এখানে কোড আর trace পেস্ট করুন] পুরো সমাধান নতুন করে লিখে দিও না। শুধু এগুলো করো: ১. m = 11, h(k) = k mod 11 নিয়ে insert 12, 23, 34, তারপর delete 23, তারপর find 34 — প্রতিটি ধাপের পর টেবিলটি দেখাও, আর আমার trace-এর সাথে কোথায় অমিল বলো। ২. টেবিল পুরো ভরা থাকলে বা সব ঘর tombstone হলে আমার find থামে কি না, যাচাই করে দেখাও। ৩. আমার rehash সব key নতুন m দিয়ে আবার hash করে কি না, আর tombstone বাদ দেয় কি না বলো। ৪. আমার insert-এর time complexity কত — average আর worst case আলাদা করে, কোন অনুমানে, আর rehash-এর amortized যুক্তিসহ। শুধু “O(1)” লিখো না।

Hash table-এর কোড যাচাইয়ের চেকলিস্ট

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

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

প্র ০১Hash table কে আবিষ্কার করেছিলেন — আর linear probing-এর বিশ্লেষণ কেন Donald Knuth-এর জীবনে একটি মোড় ঘোরানো ঘটনা?

Hash table-এর কোনো একক আবিষ্কারক নেই; ধারণাটি ১৯৫০-এর দশকে IBM-এ কয়েকজনের হাতে প্রায় একসাথে আসে। সবচেয়ে পুরোনো লিখিত প্রমাণ হিসেবে ধরা হয় ১৯৫৩ সালে Hans Peter Luhn-এর একটি অভ্যন্তরীণ IBM নোট, যেখানে key থেকে হিসাব করে “bucket”-এ রাখা আর একই bucket-এর জিনিসগুলো শিকলে জোড়ার কথা ছিল — অর্থাৎ chaining. প্রায় একই সময়ে Gene Amdahl, Elaine McGraw, Nathaniel Rochester আর Arthur Samuel IBM 701 কম্পিউটারের assembler-এর জন্য সাজানো ছাড়া দ্রুত নাম খোঁজার একটি উপায় বানান, যেখানে ঘর ভরা থাকলে পরের ঘরে যাওয়া হয় — linear probing. প্রথম প্রকাশিত বিবরণ আসে ১৯৫৬ সালে Arnold Dumey-র একটি প্রবন্ধে, যেখানে মৌলিক সংখ্যা দিয়ে ভাগশেষ নেওয়ার পরামর্শও ছিল। ১৯৫৭ সালে W. Wesley Peterson “open addressing” নামটি দেন, আর “hashing” শব্দটি বই-প্রবন্ধে প্রতিষ্ঠিত হয় ১৯৬০-এর দশকের শেষে (যেমন Robert Morris-এর ১৯৬৮ সালের প্রবন্ধে) — ইংরেজি “hash” মানে কুচি কুচি করে কেটে মিশিয়ে ফেলা, ঠিক যা একটি hash function key-এর অঙ্কগুলোর সাথে করে।

১৯৬২ সালে, তখন ছাত্র, Donald Knuth linear probing-এর গড় খরচ নিখুঁতভাবে বের করেন — অংশ ৫-এর ½(1 + 1/(1 − α)²) সূত্রটি। এই হিসাব সহজ নয়, কারণ linear probing-এ ঘরগুলো স্বাধীন নয়: cluster-গুলো একে অপরের সাথে জোড়া লাগে। Knuth পরে বহুবার বলেছেন, এই বিশ্লেষণটি করতে গিয়েই তিনি বুঝতে পারেন যে অ্যালগরিদমের আচরণ গণিত দিয়ে নিখুঁতভাবে বিশ্লেষণ করা একটি পুরো বিষয় হতে পারে — যার নাম তিনি দেন “analysis of algorithms”. সেখান থেকেই তাঁর বিশাল বই-সিরিজ The Art of Computer Programming-এর বিশ্লেষণধর্মী ধারা; hash table আছে তার তৃতীয় খণ্ড “Sorting and Searching”-এ।

গল্পটির একটি আধুনিক মোড়ও আছে। বহু বছর linear probing-কে clustering-এর জন্য দুর্বল মনে করা হতো, আর double hashing-কে তাত্ত্বিকভাবে ভালো। কিন্তু আধুনিক CPU-তে cache-এর গুরুত্ব বাড়ায় পাশাপাশি ঘর দেখার সুবিধা বড় হয়ে দাঁড়ায়, আর আজকের অনেক দ্রুততম hash table আসলে linear probing-এর কোনো রূপ।

মূল উপলব্ধি: Hash table জন্মেছিল একটি বাস্তব প্রকৌশল সমস্যা থেকে — assembler-এ দ্রুত নাম খোঁজা। তার বিশ্লেষণ থেকে জন্ম নেয় অ্যালগরিদম বিশ্লেষণের পুরো শাখা। আর কোন কৌশল “ভালো” তা তত্ত্ব ও হার্ডওয়্যার দুটোর ওপর নির্ভর করে।

প্র ০২“Hash table-এ সব কাজ O(1)” — কথাটি কতটা সত্য? কখন একটি ভারসাম্যপূর্ণ BST বা সাজানো অ্যারে ভালো পছন্দ?

কথাটি একটি বড় সত্যের সংক্ষিপ্ত, আর তাই কিছুটা ভুল রূপ। সঠিক বাক্যটি হলো: “ভালোভাবে ছড়ানো hash function আর ধ্রুবক load factor-এ, প্রতিটি কাজ প্রত্যাশিত O(1), resize-সহ amortized”. এখানে চারটি শর্ত লুকিয়ে আছে। প্রথম, গড়টি hash-এর এলোমেলোতার ওপর — খারাপ বা আক্রান্ত hash-এ worst case Θ(n). দ্বিতীয়, rehash-এর মুহূর্তে একটি insert একাই Θ(n), তাই real-time সিস্টেমে, যেখানে প্রতিটি কাজের সর্বোচ্চ সময়ের নিশ্চয়তা চাই, এটি সমস্যা। তৃতীয়, key-এর hash বের করার খরচ — L দৈর্ঘ্যের string-এ Θ(L), যা O(1) নয়; অনেক কাজে এটিই মূল খরচ। চতুর্থ, “O(1)”-এর ভেতরে লুকোনো ধ্রুবক: একটি cache miss কয়েকশো CPU cycle খায়, আর বড় টেবিলের প্রায় প্রতিটি খোঁজাই একটি cache miss.

অন্যদিকে ভারসাম্যপূর্ণ BST (পর্ব ২৮–২৯, C++-এর map, Java-র TreeMap) O(log n) দেয় worst case-এ, কোনো এলোমেলোতা বা hash ছাড়াই, শুধু তুলনা দিয়ে। আর এটি এমন প্রশ্নের উত্তর দেয়, যা hash table পারে না: সবচেয়ে ছোট key, x-এর পরের বড় key (lower_bound), একটি রেঞ্জের সব key ক্রমে, k-তম ছোট key. Hash table ক্রম ভেঙে ফেলে — এটাই তার গতির দাম। ডেটা একবার বানিয়ে শুধু পড়া হলে সাজানো অ্যারে আর binary search (পর্ব ১৮) প্রায়ই সবচেয়ে কম মেমরি আর ভালো cache আচরণ দেয়।

তাই বাছাইয়ের প্রশ্নগুলো এমন: ক্রমের প্রশ্ন আছে? তাহলে tree. Worst case-এর নিশ্চয়তা জরুরি, বা ইনপুট শত্রুর কাছ থেকে আসতে পারে আর ভালো randomized hash নেই? তাহলে tree বা SipHash-এর মতো সুরক্ষা। শুধু “আছে কি না” আর “এই key-এর মান” — অনেক ডেটা, গতি জরুরি? তাহলে hash table. বাস্তব সিস্টেম প্রায়ই দুটিই রাখে: ডেটাবেস প্রাথমিক key-এ hash index আর রেঞ্জ-প্রশ্নের জন্য B+ tree index (পর্ব ৩০)। Redis-এর মতো সিস্টেম rehash-এর থেমে যাওয়া এড়াতে দুটি টেবিল রেখে ধাপে ধাপে key সরায়।

মূল উপলব্ধি: Big-O-র পাশে লেখা শব্দগুলো — expected, amortized, worst case — বাদ দিলে তথ্যটিই বদলে যায়। ভালো প্রকৌশলী কাজের ধরন (ক্রম, নিশ্চয়তা, শত্রু, মেমরি) দেখে স্ট্রাকচার বাছেন, শুধু সবচেয়ে ছোট Big-O দেখে নয়।

প্র ০৩২০১১ সালের hash flooding আক্রমণের পর প্রোগ্রামিং ভাষাগুলো কী বদলাল — আর এলোমেলো hash-এর কোনো দাম আছে কি?

২০০৩ সালে Crosby আর Wallach যখন “algorithmic complexity attack” দেখান, তখন কিছু প্রকল্প (যেমন Perl) hash-এ এলোমেলো seed যোগ করে। কিন্তু বেশিরভাগ ভাষা এটিকে তাত্ত্বিক ঝুঁকি ভেবে উপেক্ষা করে। ২০১১ সালের ডিসেম্বরে 28C3 সম্মেলনে Klink আর Wälde দেখান যে সমস্যাটি অত্যন্ত বাস্তব: প্রায় প্রতিটি ওয়েব ফ্রেমওয়ার্ক একটি HTTP অনুরোধের প্যারামিটার স্বয়ংক্রিয়ভাবে একটি hash table-এ রাখে। কয়েকশো KB-এর একটি অনুরোধে collision-ওয়ালা নাম ভরে দিলে একটি CPU core দীর্ঘ সময় আটকে থাকে, আর একটি সাধারণ ইন্টারনেট সংযোগ থেকেই একটি সার্ভার অচল করা যায়। Java-র "Aa"/"BB"-এর মতো সমান-hash টুকরো জুড়ে কোটি কোটি colliding string বানানো খুব সহজ।

প্রতিক্রিয়া এসেছিল কয়েক স্তরে। দ্রুত সমাধান হিসেবে PHP একটি অনুরোধে প্যারামিটারের সংখ্যায় একটি সীমা বসায়, যার নাম max_input_vars. Python ৩.৩ থেকে str আর bytes-এর hash-এ প্রতিবার চালানোয় এলোমেলো seed ডিফল্টভাবে চালু হয়। কিন্তু seed দেওয়া পুরোনো দুর্বল hash-ও যথেষ্ট ছিল না — গবেষকেরা দেখান কিছু ক্ষেত্রে seed নির্বিশেষে collision বানানো যায়। তাই ২০১২ সালে Aumasson আর Bernstein SipHash বানান: একটি “keyed pseudorandom function”, যা ছোট ইনপুটে (যেমন প্যারামিটার-নাম) খুব দ্রুত, আর গোপন key না জেনে collision খুঁজে বের করা কার্যত অসম্ভব। Python ৩.৪ (PEP 456) থেকে এটি ডিফল্ট; Rust, Ruby, Perl-সহ অনেক ভাষাও এটি বা এর রূপ নেয়। Java ভিন্ন পথে যায়: String-এর hash একই রাখে (অনেক পুরোনো কোড এর ওপর নির্ভর করে), কিন্তু Java 8 থেকে লম্বা bucket-কে red-black tree বানিয়ে worst case O(log n)-এ সীমিত করে।

দাম কী? প্রথম, এলোমেলো seed মানে প্রতিবার চালানোয় hash আলাদা — Python-এ string-এর একটি set-এর ঘোরার ক্রম দুই চালানোয় আলাদা হতে পারে, তাই যে টেস্ট সেই ক্রমের ওপর নির্ভর করে, সে ভেঙে যায়। দ্বিতীয়, SipHash একটি সরল গুণ-যোগ hash-এর চেয়ে ধীর; তাই অনেক ভাষা int-এর মতো সরল key-তে সরল hash রাখে। তৃতীয়, debug কঠিন হয়, কারণ সমস্যা এক চালানোয় আসে, অন্যটিতে আসে না — তখন seed স্থির করে (যেমন PYTHONHASHSEED=0) পুনরুৎপাদন করতে হয়।

মূল উপলব্ধি: যে ডেটা স্ট্রাকচার বাইরের ইনপুট নেয়, তার worst case একটি নিরাপত্তার প্রশ্ন। Universal hashing-এর তত্ত্ব (১৯৭৯) বাস্তবে পুরোপুরি গৃহীত হতে লেগেছিল তিন দশক আর একটি বড় আক্রমণ।

প্র ০৪C++-এর unordered_map chaining, অথচ Python-এর dict আর Google-এর SwissTable open addressing — একই সমস্যায় এত ভিন্ন নকশা কেন?

উত্তরের বড় অংশ লুকিয়ে আছে C++ standard-এর প্রতিশ্রুতিতে। ১৯৯০-এর দশকের শেষে আর ২০০০-এর শুরুতে যখন unordered container-গুলো standard-এ আনার প্রস্তাব লেখা হয় (এরা আসে C++11-এ), তখন কয়েকটি নিশ্চয়তা চাওয়া হয়েছিল: rehash হলেও একটি উপাদানের pointer বা reference বৈধ থাকবে, প্রতিটি bucket আলাদাভাবে দেখার interface থাকবে (bucket(), bucket_size(), local iterator), আর insert কখনো অন্য উপাদানকে মেমরিতে সরাবে না। Open addressing-এ rehash মানে key-গুলোকে নতুন অ্যারেতে সরানো — তখন পুরোনো pointer অকেজো। তাই কার্যত প্রতিটি উপাদান একটি আলাদা heap node-এ রাখতে হয়, অর্থাৎ chaining. এর দাম: প্রতিটি key-এর জন্য আলাদা মেমরি বরাদ্দ, pointer-এর বাড়তি জায়গা, আর খোঁজায় একাধিক cache miss.

Python-এর অগ্রাধিকার ছিল ভিন্ন। ভাষার ভেতরের সবকিছু — global ভেরিয়েবল, বস্তুর attribute, keyword argument — dict, তাই dict-এর গতি আর মেমরি পুরো ভাষার গতি। CPython শুরু থেকেই open addressing নেয়, perturbation দিয়ে probe ছড়িয়ে। ২০১২ সালে Raymond Hettinger python-dev-এ একটি “compact dict” প্রস্তাব দেন, আর INADA Naoki সেটি CPython ৩.৬-এ বাস্তবায়ন করেন: hash টেবিলে শুধু ছোট index (টেবিল ছোট হলে প্রতিটি মাত্র ১ byte), আর আসল entry-গুলো একটি ঘন অ্যারেতে insert-এর ক্রমে। এতে মেমরি উল্লেখযোগ্যভাবে কমে, ঘোরা দ্রুত হয়, আর বোনাস হিসেবে insert-এর ক্রম রক্ষা পায় — যা ৩.৭-এ ভাষার আনুষ্ঠানিক নিয়ম হয়ে যায়। Python-এ pointer-এর বৈধতার কোনো প্রতিশ্রুতি নেই, তাই key সরানোতে বাধা নেই।

Google ২০১৭ সালে (CppCon-এ Matt Kulukundis-এর বক্তৃতায়) SwissTable উপস্থাপন করে, পরে Abseil লাইব্রেরিতে absl::flat_hash_map নামে প্রকাশ করে। এটি standard-এর pointer-প্রতিশ্রুতি ইচ্ছা করে ছেড়ে দেয়, আর open addressing-এর সাথে প্রতিটি ঘরের জন্য এক byte-এর “control” তথ্য রাখে — ঘরটি খালি, মোছা (tombstone) না ভরা, আর ভরা হলে hash-এর ৭টি bit. SIMD নির্দেশ দিয়ে ১৬টি control byte একসাথে তুলনা করে প্রায় এক ধাপে ঠিক করা যায় কোন ঘরে key থাকতে পারে। Rust-এর standard HashMap ২০১৯ সালে Robin Hood থেকে এই নকশার একটি Rust রূপে (hashbrown) চলে যায়। Java-র HashMap chaining রাখে, কিন্তু null key, iterator-এর আচরণ আর পুরোনো কোডের সামঞ্জস্যের সীমার মধ্যে treeify যোগ করে।

মূল উপলব্ধি: একই অ্যালগরিদমিক ধারণার বাস্তবায়ন ঠিক করে interface-এর প্রতিশ্রুতি, সামঞ্জস্যের দায় আর হার্ডওয়্যার। পরীক্ষায় “unordered_map কীভাবে কাজ করে” প্রশ্নে chaining বলা সঠিক — কিন্তু জানুন যে সেটি একটি নকশাগত বাছাই, একমাত্র উপায় নয়।

প্র ০৫Bloom filter ভুল উত্তর দিতে পারে — তবু কেন বড় ডেটাবেস আর নেটওয়ার্ক সিস্টেম একে এত ভালোবাসে?

১৯৭০ সালে Burton H. Bloom Communications of the ACM-এ “Space/Time Trade-offs in Hash Coding with Allowable Errors” প্রবন্ধে প্রশ্নটি তোলেন: যদি মাঝেমধ্যে ভুল “হ্যাঁ” মেনে নেওয়া যায়, তাহলে কত কম মেমরিতে সদস্যপদ যাচাই করা যায়? তাঁর উদাহরণ ছিল শব্দ ভাঙার (hyphenation) নিয়মের একটি বড় ব্যতিক্রম-তালিকা: বেশিরভাগ শব্দ সাধারণ নিয়মে ভাঙা যায়, অল্প কিছু শব্দের জন্য ধীর ডিস্কে রাখা অভিধান দেখতে হয়। ছোট একটি bit-অ্যারে যদি বলে দেয় “এই শব্দ ব্যতিক্রম-তালিকায় নিশ্চিত নেই”, তাহলে বেশিরভাগ ক্ষেত্রে ডিস্কে যেতেই হয় না; মাঝেমধ্যে ভুল “হয়তো আছে” এলে শুধু একটি অপ্রয়োজনীয় ডিস্ক-পড়া — ক্ষতি সামান্য।

এই ছাঁচটি — “দ্রুত, ছোট একটি ফিল্টার ধীর ও দামি কাজটি এড়াতে সাহায্য করে, আর তার ভুল শুধু বাড়তি কাজ ঘটায়, ভুল ফল নয়” — আধুনিক সিস্টেমে সবখানে। LSM-tree ভিত্তিক ডেটাবেস (LevelDB, RocksDB, Cassandra, HBase) ডেটা অনেক ফাইলে রাখে; একটি key খুঁজতে প্রতিটি ফাইল পড়লে ধীর হতো, তাই প্রতিটি ফাইলের সাথে একটি Bloom filter থাকে, আর “নিশ্চিত নেই” ফাইলগুলো বাদ যায়। নেটওয়ার্ক router, web cache আর ব্লকচেইনের হালকা client-ও এ ধরনের ফিল্টার ব্যবহার করে।

গণিতটি সুন্দর। n টি key-তে k টি hash মিলে kn বার bit 1 করা হয়, তাই একটি নির্দিষ্ট bit 0 থাকে প্রায় e−kn/m সম্ভাবনায়। k বাড়ালে প্রতিটি query বেশি bit দেখে (ভালো), কিন্তু অ্যারেও দ্রুত ভরে (খারাপ); ভারসাম্য আসে k = (m/n) ln 2-তে, যখন প্রায় অর্ধেক bit 1. তখন false positive ≈ (1/2)k ≈ 0.6185m/n. অর্থাৎ প্রতি key-এ মাত্র ১০ bit-এ ১%-এর কম ভুল, ১৪–১৫ bit-এ প্রায় ০.১% — key-এর আকার যত বড়ই হোক (একটি ৫০ অক্ষরের URL হলেও!)। একটি পূর্ণ hash set-এ key নিজে রাখতে হয়, তাই URL-এর ক্ষেত্রে কয়েকশো bit.

সীমাবদ্ধতাও জানা দরকার: সাধারণ Bloom filter থেকে delete করা যায় না, বেশি key ঢোকালে ভুলের হার দ্রুত বাড়ে (তাই n আগে আন্দাজ করতে হয়), আর ভালো, স্বাধীন hash লাগে — বাস্তবে দুটি hash থেকে h1 + i·h2 সূত্রে k টি বানানো হয়, double hashing-এর মতোই। পরে counting Bloom filter, cuckoo filter-এর মতো রূপ এসেছে, যারা delete-ও সমর্থন করে।

মূল উপলব্ধি: কখনো কখনো নিখুঁত উত্তরের চেয়ে “দ্রুত, ছোট আর একদিকে নিরাপদ ভুল” বেশি মূল্যবান। Hash-এর ধারণা শুধু টেবিল নয় — সম্ভাবনার হিসাব দিয়ে মেমরি আর নির্ভুলতার মধ্যে সচেতন আপসের একটি হাতিয়ার।

অনুশীলন

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

  1. হাতে trace করুন: m = 7, h1(k) = k mod 7. খালি টেবিলে পরপর insert করুন 10, 17, 24, 3, 31 — (ক) separate chaining (নতুন key তালিকার মাথায়), (খ) linear probing, (গ) double hashing, h2(k) = 1 + (k mod 6). (খ) আর (গ)-তে প্রতিটি key কোন slot-এ বসল আর মোট কয়টি probe লাগল?

    সবগুলো key-এর home slot 3 (10, 17, 24, 3, 31 — প্রত্যেকটি mod 7 = 3)! (ক) bucket 3: 31 → 3 → 24 → 17 → 10, বাকি সব খালি। (খ) Linear: 10 → 3 (১ probe), 17 → 3, 4 (২), 24 → 3, 4, 5 (৩), 3 → 3–6 (৪), 31 → 3, 4, 5, 6, 0 (৫, শেষ থেকে শুরুতে ঘুরে)। টেবিল [31, ., ., 10, 17, 24, 3], মোট ১৫ probe.

    (গ) Double: 10 → 3 (১). 17: h2 = 1 + 5 = 6, পথ 3, 9 mod 7 = 2 → slot 2 (২). 24: h2 = 1 + 0 = 1, পথ 3, 4 → slot 4 (২). 3: h2 = 1 + 3 = 4, পথ 3, 7 mod 7 = 0 → slot 0 (২). 31: h2 = 1 + 1 = 2, পথ 3, 5 → slot 5 (২). টেবিল [3, ., 17, 10, 24, 31, .], মোট ৯ probe. একই home হলেও আলাদা লাফ — তাই secondary clustering নেই।

  2. ভেবে বলুন: অনুশীলন ১(খ)-এর linear probing টেবিল [31, ., ., 10, 17, 24, 3] থেকে 17 মোছা হলো। (ক) tombstone রাখলে find 31-এ কোন কোন slot দেখা হবে? (খ) tombstone না রেখে slot 4 খালি করলে কোন কোন key আর খুঁজে পাওয়া যাবে না? (গ) এরপর insert 38 করলে (tombstone-সহ) সে কোথায় বসবে?

    (ক) 31-এর home 3: slot 3 (10), 4 (#, পার), 5 (24), 6 (3), 0 (31) — ৫ probe-এ পাওয়া। (খ) slot 4 খালি হলে home 3-এর যে key-রা slot 4-এর পরে বসেছে — 24, 3 আর 31 — সবার খোঁজা slot 4-এ থেমে “নেই” বলবে। শুধু 10 পাওয়া যাবে।

    (গ) 38 mod 7 = 3. আগে নিশ্চিত হতে হবে 38 নেই: slot 3, 4 (#), 5, 6, 0, তারপর slot 1 খালি — নেই, ৬ probe. তারপর পথের প্রথম tombstone slot 4-এ বসবে: [31, ., ., 10, 38, 24, 3].

  3. হিসাব করুন: (ক) একটি প্রোগ্রাম ১,০০,০০০টি আলাদা key-এর 32-bit hash রাখে, ধরে নিন hash মানগুলো এলোমেলো। গড়ে কয়টি জোড়া key-এর hash হুবহু এক হবে? (খ) ১০ লাখ URL-এর জন্য ১% false positive-এর Bloom filter-এ কত bit আর কয়টি hash function লাগবে? (সূত্র: m = −n ln p / (ln 2)², k = (m/n) ln 2.)

    (ক) জোড়ার সংখ্যা n(n − 1)/2 = 100000 × 99999 / 2 ≈ 5 × 109, প্রতিটি জোড়ার মিলের সম্ভাবনা 1/232 ≈ 1/(4.29 × 109). expected value-এর linearity অনুযায়ী প্রত্যাশিত মিল ≈ 4,999,950,000 / 4,294,967,296 ≈ 1.16 টি জোড়া. অর্থাৎ ৩২-bit hash-কে key-এর “পরিচয়” ধরা যায় না — birthday paradox.

    (খ) ln(0.01) ≈ −4.605, (ln 2)² ≈ 0.4805. m ≈ 106 × 4.605 / 0.4805 ≈ 9.59 × 106 bit ≈ ১.২ MB. k = 9.59 × 0.693 ≈ 6.6, তাই ৭টি hash function. URL-গুলো নিজে রাখলে লাগত কয়েক দশক MB.

  4. কোড লিখুন: একটি পূর্ণসংখ্যার অ্যারে আর একটি target দেওয়া আছে। এমন দুটি index i < j বের করুন যাতে a[i] + a[j] = target (না থাকলে {−1, −1})। গড়ে O(n) সময়ে করতে hash map ব্যবহার করুন, আর time ও space complexity বলুন। [3, 8, 11, 5, 2], target 13 দিয়ে হাতে চালান।

    বাঁ থেকে ডানে হাঁটুন; প্রতিটি a[j]-এর জন্য দেখুন target − a[j] আগে দেখা হয়েছে কি না। Map-এ রাখুন “মান → index”. আগে খুঁজে তারপর যোগ করলে একই উপাদান দুবার ব্যবহার হয় না।

    pair<int, int> twoSum(const vector<int>& a, int target) {
        unordered_map<int, int> seen;                 // value -> index where we saw it
        seen.reserve(a.size());                       // avoid rehashing while we insert
        for (int j = 0; j < (int)a.size(); ++j) {
            auto it = seen.find(target - a[j]);       // look up the partner first...
            if (it != seen.end()) return {it->second, j};
            seen[a[j]] = j;                           // ...then record a[j]
        }
        return {-1, -1};
    }

    হাতে: j = 0, a = 3, খুঁজি 10 — নেই, রাখি {3: 0}. j = 1, a = 8, খুঁজি 5 — নেই, রাখি {8: 1}. j = 2, a = 11, খুঁজি 2 — নেই, রাখি {11: 2}. j = 3, a = 5, খুঁজি 8 — আছে index 1-এ! উত্তর (1, 3): 8 + 5 = 13. প্রতিটি উপাদানে একটি find আর একটি insert, প্রত্যাশিত O(1) করে — মোট প্রত্যাশিত O(n) সময়, O(n) জায়গা; anti-hash ইনপুটে worst case O(n²), তাই কনটেস্টে custom hash দিন। a[j] + a[i]-তে বড় মানে overflow-এর ভয় থাকলে target - a[j] হিসাবটি long long-এ করুন।

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

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

ক্রমLinear probing আর tombstone ব্যবহার করা একটি hash table-এ নতুন key insert করার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. key-এর hash থেকে home slot h(k) বের করা
  2. probe sequence ধরে খুঁজে দেখা key আগে থেকেই আছে কি না — সত্যিকারের খালি ঘর পেলে থামা
  3. key না থাকলে home থেকে আবার হেঁটে প্রথম খালি বা tombstone ঘরটি বেছে নেওয়া
  4. সেই ঘরে key বসিয়ে key-এর সংখ্যা n এক বাড়ানো
  5. load factor সীমা ছাড়ালে বড় টেবিল বানিয়ে সব key আবার hash করে বসানো

m = 11, h(k) = k mod 11, linear probing. খালি টেবিলে পরপর 12, 23, 34 insert করলে 34 কোন slot-এ বসবে?

  • slot 1
  • slot 2
  • slot 3
  • slot 4

Separate chaining-এ simple uniform hashing ধরে নিলে (n টি key, m টি bucket, α = n/m) একটি ব্যর্থ খোঁজার প্রত্যাশিত সময় কত?

  • সবসময় Θ(1), α যা-ই হোক
  • Θ(1 + α)
  • Θ(n)
  • Θ(log n)

Open addressing-এর hash table-এ একটি key মোছার সময় ঘরটি খালি না করে tombstone চিহ্ন রাখা হয় কেন?

  • মেমরি বাঁচাতে
  • যাতে মোছা key পরে ফিরিয়ে আনা যায়
  • যাতে insert সবসময় এক probe-এ হয়
  • যাতে ওই ঘরের পরে probe করে বসা key-গুলো খোঁজায় হারিয়ে না যায়

106 টি bucket-এর একটি hash table-এ এলোমেলোভাবে ছড়ানো key ঢোকানো হচ্ছে। মোটামুটি কয়টি key-তে অন্তত একটি collision-এর সম্ভাবনা ৫০% ছাড়ায়?

  • প্রায় ৫,০০,০০০টি
  • প্রায় ১,২০০টি
  • ঠিক 106 + 1 টি
  • প্রায় ১০০টি

Universal hashing সম্পর্কে কোন বক্তব্যটি সঠিক?

  • একটি পরিবার থেকে এলোমেলোভাবে hash বাছাই করা হয়, ফলে যেকোনো ইনপুটে প্রত্যাশিত খরচ O(1 + α) থাকে
  • এটি collision পুরোপুরি অসম্ভব করে দেয়
  • একটি নির্দিষ্ট, সবার জানা hash function ব্যবহার করে
  • এটি worst case-এও প্রতিটি খোঁজা O(1) নিশ্চিত করে

একটি Bloom filter-কে একটি key সম্পর্কে জিজ্ঞেস করা হলো। কোন বক্তব্যটি সবসময় সত্য?

  • “হয়তো আছে” বললে key অবশ্যই ঢোকানো হয়েছিল
  • এটি ঢোকানো সব key-এর তালিকা ফেরত দিতে পারে
  • “নেই” বললে key নিশ্চিতভাবেই কখনো ঢোকানো হয়নি
  • bit-এর সংখ্যা যথেষ্ট হলে false positive-এর হার ঠিক শূন্য

শব্দকোষ

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

মনে রাখুন

  1. Hash table key-কে hash function দিয়ে m টি ঘরের একটিতে পাঠায়: insert/find/delete গড়ে O(1), worst case O(n), কোনো ক্রম নেই।
  2. Division method-এ মৌলিক m; multiplication method-এ A ≈ 0.618; string-এ polynomial hash — অক্ষরের অবস্থানও গোনে।
  3. Collision অনিবার্য — birthday paradox: প্রায় 1.18√m key-তেই ৫০%. Chaining: খরচ Θ(1 + α); open addressing: linear (primary clustering), quadratic, double hashing.
  4. Open addressing-এ delete = tombstone; α সীমা ছাড়ালে দ্বিগুণ টেবিলে rehash — একবারে O(n), amortized O(1).
  5. নির্দিষ্ট hash-এর বিরুদ্ধে আক্রমণ সম্ভব; universal hashing, SipHash বা custom hash রক্ষা করে। Bloom filter: শুধু false positive.

পরের পর্বে: কয়েকটি শহরের মধ্যে রাস্তা তৈরি হচ্ছে — “এই দুটি শহর কি এখন যুক্ত?” প্রশ্নের উত্তর প্রায় ধ্রুবক সময়ে দেওয়ার স্ট্রাকচার: disjoint set union, union by rank আর path compression.

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

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