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

String — অক্ষরের সারি আর প্রচলিত সমস্যা

Strings — Character Arrays, Immutability, UTF-8, Anagrams, Palindromes, Compression & Naive Pattern Matching

Taught in Bangla, this lesson treats strings as arrays and gives the cost of common operations. It covers mutable versus immutable strings and the quadratic concatenation trap, ASCII, Unicode and UTF-8 storage of Bangla text, frequency arrays and two pointers for anagrams, palindromes and compression, lexicographic comparison, string-number conversion, naive pattern matching and the idea of string hashing.

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

  • String কেন অ্যারে, আর কোন অপারেশনের খরচ O(1), কোনটির O(n) — তা চিনে নেওয়া
  • Immutable string-এ বারবার জোড়া দেওয়ার O(n²) ফাঁদ ধরা, আর join / reserve দিয়ে এড়ানো
  • ASCII, Unicode আর UTF-8 — বাংলা লেখা মেমরিতে কীভাবে থাকে, আর size() কেন অক্ষর গোনে না
  • Frequency array আর two pointer দিয়ে anagram, palindrome, উল্টানো আর compression সমাধান
  • Lexicographic তুলনা, string ↔ সংখ্যা রূপান্তর, সরল pattern matching আর string hashing-এর মূল ধারণা

১ · String কী — অক্ষরের একটি সারি

ধরুন বাসের টিকিট কাউন্টারে যাত্রীর নাম লেখা হচ্ছে একটি খাতায়, প্রতিটি ঘরে একটি করে অক্ষর: R, A, H, I, M. ঘরগুলো পাশাপাশি, প্রতিটির একটি নম্বর আছে — ০, ১, ২, ৩, ৪. তৃতীয় অক্ষর কী, জানতে চাইলে খাতাওয়ালাকে পুরো নাম পড়তে হয় না; সরাসরি ২ নম্বর ঘরে আঙুল রাখলেই হয়।

এটাই string: character (অক্ষর)-এর একটি ক্রমিক সারি। মেমরিতে string আসলে একটি array — পর্ব ১০-এ যে অ্যারে দেখেছেন, তারই একটি বিশেষ রূপ, যার প্রতিটি ঘরে একটি অক্ষরের কোড। তাই অ্যারের সব নিয়ম এখানেও খাটে:

কাজC++ (std::string s)Python (str s)খরচ
i-তম অক্ষর পড়াs[i]s[i]O(1)
দৈর্ঘ্য জানাs.size()len(s)O(1) (দৈর্ঘ্য আলাদা করে রাখা থাকে)
শেষে একটি অক্ষর যোগs += c / s.push_back(c)নতুন string তৈরি হয়C++-এ amortized O(1); Python-এ O(n)
দুটি string তুলনাs == t, s < ts == t, s < tO(min(n, m)) পর্যন্ত
k দৈর্ঘ্যের substrings.substr(i, k)s[i:i+k]O(k) — কপি হয়
একটি অক্ষর বা pattern খোঁজাs.find(p)s.find(p)অক্ষরে O(n); pattern-এ সবচেয়ে খারাপে অনেক বেশি (অংশ ৯)

দৈর্ঘ্য জানা O(1) — এটি কিন্তু স্বতঃসিদ্ধ নয়। C ভাষায় string হলো শুধু char-এর অ্যারে, যার শেষে একটি বিশেষ অক্ষর '\0' বসানো থাকে — একে বলে null terminator. দৈর্ঘ্য কোথাও লেখা থাকে না, তাই strlen প্রতিবার শুরু থেকে হেঁটে '\0' খোঁজে: O(n). লুপের শর্তে for (i = 0; i < strlen(s); i++) লিখলে প্রতিটি ঘুর্ণনে আবার পুরো string হাঁটা হয় — নিরীহ চেহারার একটি লুপ হয়ে যায় O(n²). C++-এর std::string আর Python-এর str দৈর্ঘ্য আলাদা করে মনে রাখে, তাই এই ফাঁদ সেখানে নেই।

লাইব্রেরির তাকে বই সাজানো আছে, প্রতিটি তাকে একটি করে। তাকের নম্বর জানলে বই নেওয়া এক মুহূর্তের কাজ (O(1)). কিন্তু “এই সারিতে কয়টি বই আছে?” — যদি কোথাও লেখা না থাকে, তাহলে শেষ পর্যন্ত গুনতে হবে (C-এর strlen). আধুনিক string যেন সারির মাথায় একটি ছোট বোর্ড ঝুলিয়ে রাখে: “এখানে ৫টি বই”। একটু বাড়তি জায়গা, কিন্তু প্রতিবার গোনার ঝামেলা শেষ।

এই পর্বের প্রায় সব সমস্যা একটি প্রশ্নের চারপাশে ঘোরে: কোন কাজে পুরো string হাঁটতে বা কপি করতে হয়, আর কোনটিতে হয় না? উত্তরটা পরিষ্কার থাকলে string-এর প্রায় সব complexity নিজেই বের করতে পারবেন।

২ · বদলানো যায়, না যায় না — mutable বনাম immutable

C++-এ string s = "cat"; s[0] = 'b'; লিখলে s হয়ে যায় "bat" — একই মেমরিতে, জায়গায় বসেই বদল। এমন বস্তুকে বলে mutable. Python-এ একই কাজ s[0] = 'b' করতে গেলে আসে TypeError: 'str' object does not support item assignment. Python-এর str (আর Java-র String) immutable: একবার তৈরি হলে আর বদলায় না। “বদলাতে” চাইলে নতুন একটি string বানাতে হয় — যেমন s = 'b' + s[1:].

C++ std::string — mutable

ভেতরে একটি dynamic array, ঠিক vector<char>-এর মতো। s += c শেষে জায়গা থাকলে সেখানেই বসে; জায়গা ফুরোলে ধারণক্ষমতা গুণিতক হারে বাড়ে — তাই গড়ে O(1), অর্থাৎ amortized O(1) (পর্ব ১০).

জায়গায় বসে বদল

Python str — immutable

কোনো অপারেশন পুরোনো string ছোঁয় না; s + t, s.upper(), s.replace(...) — সবাই নতুন string ফেরত দেয়। লাভ: নিরাপদে ভাগাভাগি করা যায়, dict-এর key হিসেবে ব্যবহার করা যায়।

প্রতিটি বদলে নতুন কপি

বারবার জোড়া দেওয়ার O(n²) ফাঁদ

দুটি string জোড়া দেওয়া — string concatenation — দেখতে এক অপারেশন, কিন্তু নতুন string বানাতে দুটির সব অক্ষর নতুন জায়গায় কপি করতে হয়। এখন ধরুন একটি লুপে একটি করে অক্ষর যোগ করে "Dhaka" বানাচ্ছেন, প্রতিবার s = s + c লিখে। immutable string-এ প্রতিটি ধাপে কী ঘটে, দেখুন:

প্রতিটি ধাপের হিসাব
এই ধাপের কাজ
নতুন string
এই ধাপে কপি হওয়া অক্ষর
মোট কপি, + পদ্ধতিতে
মোট কপি, join পদ্ধতিতে
মেমরিপ্রতিটি [ ] একটি আলাদা string

            
  1. প্রথম অক্ষর। খালি string-এর সাথে 'D' জুড়ে নতুন string "D". কপি হলো ১টি অক্ষর।
  2. দ্বিতীয় অক্ষর। পুরোনো "D" বদলানো যায় না, তাই ২ ঘরের নতুন জায়গা নিয়ে D আর h দুটোই কপি। পুরোনোটি আবর্জনা। মোট কপি ১ + ২ = ৩.
  3. তৃতীয় অক্ষর। আবার নতুন জায়গা, আবার পুরোনো সব অক্ষরের কপি — এবার ৩টি। মোট ৬.
  4. চতুর্থ অক্ষর। ৪টি কপি, মোট ১০. খেয়াল করুন: প্রতিটি ধাপের খরচ এখন পর্যন্ত বানানো string-এর দৈর্ঘ্যের সমান, আর সেটি প্রতি ধাপে বাড়ছে।
  5. পঞ্চম অক্ষর। ৫টি কপি, মোট ১ + ২ + ৩ + ৪ + ৫ = ১৫. সাধারণভাবে n অক্ষরে মোট কপি n(n+1)/2 — একটি arithmetic series, অর্থাৎ Θ(n²).
  6. join-এর পথ। টুকরোগুলো আগে একটি list-এ রাখা হলো (প্রতিটি append গড়ে O(1)), তারপর ''.join() প্রথমে মোট দৈর্ঘ্য (৫) গুনে একবারই জায়গা নেয় আর প্রতিটি অক্ষর ঠিক একবার কপি করে: মোট ৫ — Θ(n).

৫ অক্ষরে ১৫ বনাম ৫ — তুচ্ছ পার্থক্য। কিন্তু ১ লাখ (10⁵) অক্ষরের লগ-ফাইল বা আউটপুট এভাবে বানালে + পদ্ধতিতে কপি প্রায় 5 × 10⁹ — কয়েক সেকেন্ড থেকে মিনিট; join-এ মাত্র 10⁵. কনটেস্টে বড় আউটপুট ছাপার সময় এই ভুলেই অনেক সমাধান “Time Limit Exceeded” পায়। এই ধরনের quadratic খরচ কোডে চোখে পড়ে না, কারণ লাইনটি দেখতে মাত্র একটি +.

সমাধানের মূল কৌশলের নাম string builder: টুকরোগুলো আগে একটি বাড়তে-পারা জায়গায় জমান, শেষে একবারে string বানান। প্রতিটি ভাষায় এর নিজস্ব রূপ:

string out;
out.reserve(n);                  // optional: allocate once up front
for (int i = 0; i < n; ++i)
    out += piece(i);             // GOOD: appends in place, amortized O(1) per char

// out = out + piece(i);         // BAD: builds a temporary copy every time -> O(n^2)

ostringstream os;                // also fine for mixed numbers and text
os << "id=" << 42 << ", score=" << 7.5;
string line = os.str();
parts = []
for i in range(n):
    parts.append(piece(i))       # GOOD: list append, amortized O(1)
out = "".join(parts)             # one pass, total length computed first

# out = out + piece(i)           # risky: may copy everything each time -> O(n^2)

print("\n".join(map(str, answers)))   # fast way to print many lines

C++-এ += আর = s + এক নয়। s += t s-এর নিজের জায়গার শেষে t বসায় — খরচ শুধু t-এর দৈর্ঘ্য (amortized). কিন্তু s = s + t প্রথমে একটি নতুন অস্থায়ী string বানায়, যাতে s আর t দুটোই কপি হয় — খরচ s-এর পুরো দৈর্ঘ্য। লুপের ভেতরে দ্বিতীয়টি লিখলে C++-এও Θ(n²). আর Python-এ CPython কখনো কখনো s += t-কে চুপচাপ দ্রুত করে দেয় (যখন s-এর আর কোনো রেফারেন্স নেই), কিন্তু Python-এর নিজস্ব style guide PEP 8 স্পষ্ট বলে এর ওপর ভরসা না করে join ব্যবহার করতে — অন্য Python বাস্তবায়নে বা একটু ভিন্ন কোডে সেই সুবিধা থাকে না।

ছোট string-এর গোপন কৌশল। প্রতিটি std::string-এর জন্য heap থেকে মেমরি নেওয়া ব্যয়বহুল। তাই GCC-র libstdc++ ১৫ char পর্যন্ত ছোট string-কে বস্তুর নিজের ভেতরেই রেখে দেয় — একে বলে small string optimization (SSO). নাম, রোল নম্বর, ছোট শব্দ — এমন লাখো string তাই প্রায় বিনা খরচে তৈরি হয়। complexity বদলায় না, কিন্তু বাস্তব গতি অনেক বাড়ে।

৩ · অক্ষর আসলে সংখ্যা — ASCII, Unicode আর UTF-8

কম্পিউটার অক্ষর চেনে না, চেনে শুধু সংখ্যা। প্রতিটি অক্ষরকে একটি নম্বর দেওয়ার চুক্তির নাম character encoding. সবচেয়ে পুরোনো জনপ্রিয় চুক্তি ASCII (১৯৬৩): ৭ bit, মোট ১২৮টি অক্ষর। কয়েকটি মান মুখস্থ রাখলে DSA-র অনেক কৌশল সহজ হয়:

অক্ষরASCII মানকাজে লাগে যেখানে
'0' … '9'48 … 57c - '0' দিয়ে অঙ্কের মান (অংশ ৭)
'A' … 'Z'65 … 90বড় হাতের অক্ষর আগে আসে — তুলনায় "Zebra" < "apple"
'a' … 'z'97 … 122c - 'a' দিয়ে ০–২৫ index (অংশ ৫)
ফাঁকা জায়গা ' '32শব্দ আলাদা করা (অংশ ৬)

বড় আর ছোট হাতের একই অক্ষরের পার্থক্য ঠিক ৩২ ('a' - 'A') — তাই case বদলানো একটি যোগ-বিয়োগ মাত্র। কিন্তু ১২৮টি ঘরে বাংলা, আরবি, চীনা লিপির জায়গা নেই। সেই সমস্যার সমাধান Unicode: পৃথিবীর প্রায় সব লিপির প্রতিটি অক্ষরকে একটি অনন্য নম্বর, যার নাম code point. যেমন A = U+0041, ক = U+0995, ই-কার ি = U+09BF. বাংলা লিপির পুরো ব্লক U+0980 থেকে U+09FF.

নম্বর তো হলো, কিন্তু সেটি বাইটে লেখা হবে কীভাবে? সবচেয়ে প্রচলিত উত্তর UTF-8: ASCII অক্ষর আগের মতোই ১ বাইটে, অন্যগুলো ২, ৩ বা ৪ বাইটে। প্রতিটি বাইটের শুরুর bit দেখেই বোঝা যায় সেটি কোন ভূমিকায়:

code point-এর সীমাবাইটনকশা (x = code point-এর bit)উদাহরণ
U+0000 – U+007F10xxxxxxxইংরেজি, অঙ্ক — হুবহু ASCII
U+0080 – U+07FF2110xxxxx 10xxxxxxআরবি, গ্রিক, রুশ
U+0800 – U+FFFF31110xxxx 10xxxxxx 10xxxxxxবাংলা, হিন্দি, চীনা
U+10000 – U+10FFFF411110xxx + তিনটি 10xxxxxxemoji, প্রাচীন লিপি

এবার দেখুন C++-এর একটি std::string-এ "Aকি" লেখাটি (ফাইল UTF-8-এ সেভ করা) মেমরিতে আসলে কী, আর একটি প্রোগ্রাম বাইটগুলো পড়ে কীভাবে অক্ষর ফিরে পায়:

বাইট ধরে ধরে পড়া (decode)
এখন পড়া বাইট (hex)
bit-রূপ
শুরুর bit বলে
পাওয়া code point
এ পর্যন্ত বাইট
পাওয়া অক্ষরের তালিকা[ ] = অসম্পূর্ণ

            
  1. প্রথম বাইট 0x41. শুরুর bit 0, তাই এটি নিজেই একটি সম্পূর্ণ অক্ষর: U+0041 = A. ইংরেজি লেখা UTF-8-এ হুবহু ASCII — এ কারণেই পুরোনো ইংরেজি ফাইল কোনো বদল ছাড়াই বৈধ UTF-8.
  2. দ্বিতীয় বাইট 0xE0 = 11100000. শুরুতে 1110, অর্থাৎ “আমি একটি ৩ বাইটের অক্ষরের প্রথম বাইট”। প্রোগ্রাম আরও দুটি বাইটের অপেক্ষায়। শেষ চারটি bit (0000) code point-এর প্রথম অংশ।
  3. 0xA6 = 10100110. শুরুতে 10 — ধারাবাহিক (continuation) বাইট; বাকি ৬ bit (100110) code point-এর মাঝের অংশ।
  4. 0x95 = 10010101. আরেকটি ধারাবাহিক বাইট, শেষ ৬ bit 010101. জোড়া দিলে 0000 100110 010101 = 0x0995 = ক. একটি বাংলা অক্ষর, তিনটি বাইট।
  5. আরও তিন বাইট: E0 A6 BF. একই নিয়মে 0000 100110 111111 = U+09BF, ই-কার ি. খেয়াল করুন: চোখে ই-কার ক-এর আগে বসে, কিন্তু মেমরিতে থাকে পরে — Unicode উচ্চারণের ক্রমে রাখে, ছাপার ক্রমে নয়।
  6. তিন রকম “দৈর্ঘ্য”। C++-এর s.size() = ৭ (বাইট), Python-এর len(s) = ৩ (code point), আর পাঠক দেখেন ২টি অক্ষর: A আর কি. চোখে দেখা এই এককের নাম grapheme cluster. তিনটি সংখ্যাই “ঠিক” — শুধু ভিন্ন প্রশ্নের উত্তর।

এর বাস্তব পরিণতি কয়েকটি, এবং বাংলায় কাজ করলে এগুলো রোজ সামনে আসবে:

  • C++-এ s[i] বাংলা অক্ষর দেয় না, দেয় একটি বাইট। s.substr(0, 2) একটি বাংলা অক্ষরকে মাঝখান থেকে কেটে অবৈধ বাইট-টুকরো বানায় — স্ক্রিনে আসে �. একইভাবে বাইট ধরে string উল্টালে বাংলা লেখা ভেঙে যায়।
  • code point গুনতে শুধু সেই বাইটগুলো গুনুন যেগুলো ধারাবাহিক নয়: (c & 0xC0) != 0x80. এটি O(n), কারণ প্রতিটি বাইট একবার দেখতে হয়।
  • Python-এ len("বাংলা") = ৫, কারণ ব, া, ং, ল, া — পাঁচটি code point; পাঠক দেখেন মোটে দু-তিনটি “অক্ষর”। যুক্তাক্ষর ক্ষ = ক + ্ (হসন্ত) + ষ — তিনটি code point, UTF-8-এ ৯ বাইট, চোখে একটি।
  • Python-এ s[i] O(1)-এই code point দেয়, কারণ CPython ভেতরে লেখাটিকে প্রতি অক্ষরে সমান মাপে (১, ২ বা ৪ বাইট) রাখে; UTF-8-এ রাখলে i-তম অক্ষর খুঁজতে শুরু থেকে হাঁটতে হতো।
মূল ধারণা

“String-এর দৈর্ঘ্য” বললে আগে জিজ্ঞেস করুন — বাইট, code point, না চোখে দেখা অক্ষর? DSA-র সমস্যায় (কনটেস্ট, ইন্টারভিউ) ইনপুট প্রায় সবসময় ASCII, তাই তিনটি এক। বাস্তব সফটওয়্যারে, বিশেষ করে বাংলা নিয়ে কাজ করলে, এরা আলাদা — আর সেখানেই সূক্ষ্ম bug লুকিয়ে থাকে। এনকোডিংয়ের বিস্তারিত দেখুন কম্পিউটার ফান্ডামেন্টালস · কোড আর এনকোডিং পর্বে।

৪ · তুলনা, অভিধানের ক্রম আর substring-এর খরচ

ফোনবুকে “Rahim” আগে না “Rahima”? “Karim” না “Kamal”? আমরা জানি — অভিধানের নিয়মে। এই নিয়মের নাম lexicographic order, আর C++ ও Python দুটোতেই s < t ঠিক এটাই করে:

  1. বাঁ দিক থেকে একটি একটি করে অক্ষর মেলান।
  2. প্রথম যে ঘরে অক্ষর আলাদা, সেই দুটি অক্ষরের কোড তুলনা করুন — যার কোড ছোট, সেই string আগে। "Kamal" < "Karim", কারণ তৃতীয় ঘরে 'm' (109) < 'r' (114).
  3. একটি string শেষ হয়ে গেলে আর অমিল না পেলে — ছোটটি আগে। "Rahim" < "Rahima".

খরচ: সবচেয়ে খারাপে min(n, m) টি তুলনা — অর্থাৎ O(min(n, m)). এটি মনে রাখা জরুরি, কারণ অনেকে string তুলনাকে O(1) ধরে complexity হিসাব করেন। n টি string sort করলে তুলনা হয় O(n log n) বার (পর্ব ২১), আর প্রতিটি তুলনা O(L) পর্যন্ত, যেখানে L গড় দৈর্ঘ্য — মোট O(L · n log n).

তুলনাফলকেন
"apple" < "apply"সত্যপঞ্চম ঘরে 'e' (101) < 'y' (121)
"Zebra" < "apple"সত্যসব বড় হাতের অক্ষর (65–90) ছোট হাতের (97–122) আগে
"10" < "9"সত্যপ্রথম ঘরেই '1' < '9' — string হিসেবে তুলনা, সংখ্যা হিসেবে নয়
"abc" < "abcd"সত্যprefix সবসময় আগে

তৃতীয় সারিটি বাস্তবে প্রায়ই ঝামেলা করে: ফাইলের নাম file10.txt চলে আসে file9.txt-এর আগে, রোল নম্বর string হিসেবে রাখলে “১০” বসে “৯”-এর আগে। সমাধান: সংখ্যা সংখ্যা হিসেবে রাখুন, অথবা সব রোল সমান দৈর্ঘ্যে লিখুন ("009", "010"). বাংলা শব্দের ক্রমে আরেকটি সমস্যা আছে — code point-এর ক্রম সবসময় বাংলা অভিধানের ক্রম নয়; ভাবনার প্রশ্ন ৩-এ সেটি দেখবেন।

Substring — দেখতে ছোট, খরচ লুকানো

একটি string-এর ভেতরের পাশাপাশি অক্ষরের টুকরো হলো substring. শুরু থেকে নেওয়া টুকরো prefix, শেষ পর্যন্ত নেওয়া টুকরো suffix. n দৈর্ঘ্যের string-এ খালি বাদে substring মোট n(n+1)/2 টি — তাই “সব substring পরীক্ষা করো” মানেই অন্তত O(n²), আর প্রতিটির ভেতরে কাজ থাকলে আরও বেশি।

সবচেয়ে সাধারণ ফাঁদ: s.substr(i, k) (C++) বা s[i:i+k] (Python) একটি নতুন কপি বানায় — খরচ O(k), O(1) নয়। লুপের ভেতরে বারবার slice নিলে লুকানো একটি বাড়তি গুণক যোগ হয়। পর্ব ০৭-এ দেখেছেন, রিকার্শনে s[1:] পাঠালে কীভাবে O(n) কাজ O(n²) হয়ে যায়।

string s = "dhanmondi";
string a = s.substr(0, 4);         // copies 4 chars: "dhan"       O(k)
string_view v(s);                  // no copy: pointer + length     O(1)
string_view b = v.substr(0, 4);    // still no copy: "dhan"         O(1)
// careful: b is only valid while s is alive and unchanged

C++17-এর std::string_view কপি না করে মূল string-এর একটি অংশের দিকে শুধু “তাকিয়ে” থাকে — শুরুর ঠিকানা আর দৈর্ঘ্য। তাই এর substr O(1). বিনিময়ে একটি দায়িত্ব: মূল string মুছে গেলে বা বদলালে view-টি অবৈধ (dangling) হয়ে যায়। Python-এ সরাসরি এর সমতুল্য নেই; সেখানে প্রচলিত কৌশল হলো slice না নিয়ে শুরু আর শেষের index পাঠানো।

৫ · অক্ষর গোনা — ২৬ ঘরের অ্যারে আর map

পরীক্ষার হলে খাতা জমা নেওয়ার সময় শিক্ষক প্রতিটি রোল নম্বরের পাশে টিক দেন — কেউ বাদ পড়ল কি না, কেউ দুবার জমা দিল কি না। string-এর বহু সমস্যার সমাধান ঠিক এভাবে: প্রতিটি অক্ষর কতবার এসেছে, গুনে ফেলুন।

ইনপুট যদি শুধু ইংরেজি ছোট হাতের অক্ষর হয় ('a' থেকে 'z'), তাহলে ২৬ ঘরের একটি অ্যারেই যথেষ্ট — অক্ষর c-এর ঘর হলো c - 'a' ('a' → 0, 'c' → 2, 'r' → 17). একে বলে frequency array. অক্ষরের জগৎ বড় বা অজানা হলে (বাংলা, Unicode, পুরো শব্দ) অ্যারের বদলে hash table — C++-এ unordered_map, Python-এ dict বা collections.Counter (পর্ব ১৫, ৩২).

Anagram — একই অক্ষর, ভিন্ন ক্রম

listen আর silent — একই ছয়টি অক্ষর, শুধু ক্রম আলাদা। এমন জোড়াকে বলে anagram. সরল উপায়: দুটিকেই sort করে মেলানো — O(n log n). গোনার উপায় আরও দ্রুত: প্রথম string-এর প্রতিটি অক্ষরে ঘর +১, দ্বিতীয়টির প্রতিটিতে −১. শেষে সব ঘর ০ হলেই anagram. নিচে "arc" আর "car":

1bool isAnagram(const string& s, const string& t) {2    if (s.size() != t.size()) return false;3    array<int, 26> cnt{};             // all zero4    for (char c : s) cnt[c - 'a']++;5    for (char c : t)6        if (--cnt[c - 'a'] < 0) return false;7    return true;8}
গণনার ঘর (বাকি ২৩টি ঘর সবসময় 0)
এখন পড়া অক্ষর
cnt[0] ('a')
cnt[2] ('c')
cnt[17] ('r')
শূন্য নয় এমন ঘরঅক্ষর : গণনা

                    
  1. দৈর্ঘ্য মিলেছে (৩ = ৩), গোনা শুরু। s-এর প্রথম অক্ষর 'a', তার ঘর 'a' - 'a' = 0. cnt[0] হলো 1.
  2. 'r'. ঘর 'r' - 'a' = 114 − 97 = 17. cnt[17] = 1. অক্ষরের ASCII মান থেকে সরাসরি ঘরের নম্বর — কোনো খোঁজাখুঁজি নেই, O(1).
  3. 'c'. cnt[2] = 1. s শেষ: এখন জানি s-এ a, c, r — প্রত্যেকটি একবার করে।
  4. t শুরু: 'c'. এবার বিয়োগ: cnt[2] হলো 0. ঘরটি 0-এর নিচে যায়নি, তাই এ পর্যন্ত t-এ s-এর চেয়ে বেশি 'c' নেই।
  5. 'a'. cnt[0] = 0. কোনো ঘর ঋণাত্মক হলে সঙ্গে সঙ্গে false ফেরত দেওয়া হতো — t-এ এমন অক্ষর আছে যা s-এ কম।
  6. 'r' — শেষ। cnt[17] = 0. দৈর্ঘ্য সমান আর কোনো ঘর ঋণাত্মক হয়নি, তাই সব ঘর ঠিক 0 — anagram. মোট কাজ ৩ + ৩ = ৬টি ধাপ: O(n) সময়, আর ২৬টি ঘর মানে বাড়তি জায়গা O(1) (অক্ষরের জগৎ σ হলে O(σ)).

কেন ঠিক কাজ করে? দৈর্ঘ্য সমান হলে দুটির মোট গণনাও সমান। দ্বিতীয় লুপ শেষে যদি কোনো ঘর ধনাত্মক থাকত, তাহলে মোট সমান রাখতে অন্য কোনো ঘর অবশ্যই ঋণাত্মক হতো — আর সেটি আমরা লুপের ভেতরেই ধরে ফেলতাম। তাই “কোনো ঘর ঋণাত্মক হয়নি” মানেই সব ঘর 0. এক লাইনের এই যুক্তিটিই শেষে আলাদা করে সব ঘর পরীক্ষা করার দরকার মিটিয়ে দেয়।

প্রথম অনন্য অক্ষর

প্রশ্ন: string-এ প্রথম যে অক্ষরটি মাত্র একবার এসেছে, তার index কত? ("swiss" → 1, কারণ 'w'; কোনোটি না থাকলে −1.) প্রতিটি অক্ষরের জন্য বাকি string খুঁজলে O(n²). গোনার কৌশলে দুটি পাস যথেষ্ট:

def first_unique(s):
    cnt = [0] * 26
    for c in s:                          # pass 1: count everything
        cnt[ord(c) - ord('a')] += 1
    for i, c in enumerate(s):            # pass 2: first char with count 1
        if cnt[ord(c) - ord('a')] == 1:
            return i
    return -1                            # first_unique("swiss") == 1

সময় O(n) (দুটি পাস), জায়গা O(1). প্রথম পাসে আগে পুরো তথ্য জমান, দ্বিতীয় পাসে সিদ্ধান্ত নিন — “আগে গোনো, পরে সিদ্ধান্ত” ধাঁচটি পর্ব ২৪-এর counting sort-এরও ভিত্তি।

৬ · Palindrome আর উল্টানো — দুই প্রান্ত থেকে দুই পয়েন্টার

level, racecar, বাংলায় “রমাকান্তকামার” — সামনে থেকে আর পেছন থেকে পড়লে একই। (বাংলা উদাহরণটি অবশ্য চোখে দেখা অক্ষর ধরে palindrome: র-মা-কা-ন্ত-কা-মা-র; code point ধরে উল্টালে মেলে না, কারণ কার-চিহ্ন আর হসন্ত আলাদা code point — অংশ ৩ মনে করুন।) এমন লেখাকে বলে palindrome. একটি উপায়: string উল্টে মূলটির সাথে মেলানো — O(n) সময়, কিন্তু উল্টানো কপির জন্য O(n) বাড়তি জায়গা। ভালো উপায় two pointers: একটি index i শুরুতে, আরেকটি j শেষে; দুটি অক্ষর মিললে দুজনই এক ঘর ভেতরে আসে, না মিললেই উত্তর “না”।

ইন্টারভিউর সংস্করণে সাধারণত একটি শর্ত থাকে: শুধু অক্ষর আর অঙ্ক গণ্য, ফাঁকা জায়গা আর চিহ্ন বাদ, বড়-ছোট হাতের পার্থক্য নেই। নিচে "Top spot" (_ = ফাঁকা জায়গা):

পয়েন্টারের অবস্থান_ = ফাঁকা জায়গা

            
অবস্থা
i, j
s[i] / s[j]
সিদ্ধান্ত
মোট তুলনা
  1. শুরু: i = 0, j = 7. 'T' আর 't' — ছোট হাতে বদলালে দুটোই t, মিলেছে। i এক ঘর ডানে, j এক ঘর বাঁয়ে।
  2. i = 1, j = 6. 'o' = 'o'. আবার দুজনই ভেতরে।
  3. i = 2, j = 5. 'p' = 'p'. তিনটি তুলনা হলো, প্রতিটিতে দুটি করে ঘর বাদ পড়ল।
  4. i = 3: ফাঁকা জায়গা। এটি অক্ষর বা অঙ্ক নয়, তাই তুলনা ছাড়াই শুধু i এক ঘর এগোল — এখন i = 4. j যেখানে ছিল সেখানেই।
  5. i = j = 4. শর্ত i < j আর সত্য নয় — মাঝের একটি অক্ষর (s) নিজের সাথেই মেলে। কোনো অমিল পাওয়া যায়নি, তাই palindrome. প্রতিটি ধাপে i বা j অন্তত একটি এক ঘর সরে, তাই মোট ধাপ ≤ n: সময় O(n), বাড়তি জায়গা O(1).

শুদ্ধতার যুক্তি (loop invariant, পর্ব ০৩): প্রতিটি ঘুর্ণনের শুরুতে সত্য — “i-এর বাঁয়ের আর j-এর ডানের অংশ (শুধু অক্ষর-অঙ্ক ধরে) একে অপরের আয়না”। শুরুতে দুটি অংশই খালি, তাই সত্য। মিলের পর দুজন ভেতরে এলে সত্য থাকে; চিহ্ন বাদ দিলে আয়নায় কিছু যোগ হয় না। লুপ থামলে বাকি থাকে বড়জোর একটি অক্ষর, যা নিজের আয়না — তাই পুরো লেখা palindrome. এই দুই-প্রান্ত কৌশলের আরও বহু প্রয়োগ (sorted অ্যারেতে জোড়া খোঁজা, sliding window) আসবে পর্ব ১৬-এ।

শব্দগুলো উল্টানো

প্রশ্ন: "the sky is blue" → "blue is sky the". Python-এ এক লাইন: " ".join(reversed(s.split())) — split() বাড়তি ফাঁকা জায়গাও বাদ দেয়, সময় O(n), জায়গা O(n). C++-এ mutable string থাকায় একটি সুন্দর in-place কৌশল আছে: আগে পুরো string উল্টান, তারপর প্রতিটি শব্দ আলাদা করে উল্টান।

// "the sky is blue" -> "eulb si yks eht" -> "blue is sky the"
void reverseWords(string& s) {          // assumes single spaces between words
    reverse(s.begin(), s.end());         // step 1: whole string
    size_t start = 0;
    for (size_t i = 0; i <= s.size(); ++i)
        if (i == s.size() || s[i] == ' ') {
            reverse(s.begin() + start, s.begin() + i);   // step 2: each word
            start = i + 1;
        }
}

প্রতিটি অক্ষর মোট দুবার সরে (একবার পুরো উল্টানোয়, একবার শব্দ উল্টানোয়), তাই সময় O(n), বাড়তি জায়গা O(1). এই “দুবার উল্টাও” কৌশল অ্যারে k ঘর ঘোরাতেও (rotate) কাজে লাগে।

এবার anagram আর palindrome দুটো যাচাই একসাথে চালিয়ে দেখুন। দুটি ভাষাতেই একই ইনপুট, একই আউটপুট:

#include <array>
#include <cctype>
#include <initializer_list>
#include <iostream>
#include <string>
#include <utility>
#include <vector>
using namespace std;
bool isAnagram(const string& s, const string& t) {
    if (s.size() != t.size()) return false;        // counts of 'a'..'z' must match
    array<int, 26> cnt{};
    for (char c : s) cnt[c - 'a']++;
    for (char c : t) if (--cnt[c - 'a'] < 0) return false;   // t has an extra c
    return true;
}

bool isPalindrome(const string& s) {               // two pointers, ignore case
    int i = 0, j = (int)s.size() - 1;
    while (i < j) {
        unsigned char a = s[i], b = s[j];
        if (!isalnum(a)) ++i;                      // skip space, comma, colon
        else if (!isalnum(b)) --j;
        else if (tolower(a) != tolower(b)) return false;
        else { ++i; --j; }
    }
    return true;
}

int main() {
    vector<pair<string, string>> tests = {{"listen", "silent"}, {"arc", "car"}, {"aab", "abb"}, {"hello", "world"}};
    for (auto& [a, b] : tests)
        cout << a << " / " << b << " -> " << (isAnagram(a, b) ? "anagram" : "not anagram") << '\n';
    for (string s : {"Top spot", "A man, a plan, a canal: Panama", "abca", ""})
        cout << '"' << s << "\" -> " << (isPalindrome(s) ? "palindrome" : "not palindrome") << '\n';
}

আউটপুট:

listen / silent -> anagram
arc / car -> anagram
aab / abb -> not anagram
hello / world -> not anagram
"Top spot" -> palindrome
"A man, a plan, a canal: Panama" -> palindrome
"abca" -> not palindrome
"" -> palindrome

aab আর abb-এর দৈর্ঘ্য সমান, কিন্তু t-এর দ্বিতীয় 'b'-তে cnt[1] হয় −1 — সেখানেই false. খালি string-এ j = -1, লুপ একবারও চলে না — খালি লেখা সংজ্ঞা অনুযায়ী palindrome; এই edge case আলাদা করে ভাবতে হয়নি। isalnum আর tolower-এ unsigned char পাঠানো জরুরি: UTF-8-এর বাংলা বাইট (যেমন 0xE0) সাধারণ char-এ ঋণাত্মক সংখ্যা, আর ঋণাত্মক মান পাঠালে এই ফাংশনগুলোর আচরণ undefined. লক্ষ করুন, cnt[c - 'a'] ধরে নেয় ইনপুটে শুধু ছোট হাতের ইংরেজি অক্ষর — বড় হাতের বা অন্য অক্ষর এলে index সীমার বাইরে যাবে।

def is_anagram(s, t):
    if len(s) != len(t):                 # counts of 'a'..'z' must match
        return False
    cnt = [0] * 26
    for c in s:
        cnt[ord(c) - ord('a')] += 1
    for c in t:
        k = ord(c) - ord('a')
        cnt[k] -= 1
        if cnt[k] < 0:                   # t has an extra c
            return False
    return True

def is_palindrome(s):                    # two pointers, ignore case
    i, j = 0, len(s) - 1
    while i < j:
        if not s[i].isalnum():           # skip space, comma, colon
            i += 1
        elif not s[j].isalnum():
            j -= 1
        elif s[i].lower() != s[j].lower():
            return False
        else:
            i, j = i + 1, j - 1
    return True

for a, b in [("listen", "silent"), ("arc", "car"), ("aab", "abb"), ("hello", "world")]:
    print(f"{a} / {b} -> {'anagram' if is_anagram(a, b) else 'not anagram'}")
for s in ["Top spot", "A man, a plan, a canal: Panama", "abca", ""]:
    print(f'"{s}" -> {"palindrome" if is_palindrome(s) else "not palindrome"}')

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

listen / silent -> anagram
arc / car -> anagram
aab / abb -> not anagram
hello / world -> not anagram
"Top spot" -> palindrome
"A man, a plan, a canal: Panama" -> palindrome
"abca" -> not palindrome
"" -> palindrome

ord(c) অক্ষরের code point দেয় — C++-এর c - 'a'-এর সমান কাজ। বাস্তবে Python-এ anagram প্রায়ই এক লাইনে লেখা হয়: Counter(s) == Counter(t) — একই O(n) ধারণা, যেকোনো অক্ষরে চলে। আরেকটি পার্থক্য: Python-এর isalnum() Unicode বোঝে, তাই বাংলা ব্যঞ্জনবর্ণ আর বাংলা অঙ্কও (০–৯) “অক্ষর-অঙ্ক” গণ্য হয় (কার-চিহ্ন নয়); C++-এর isalnum (ডিফল্ট locale-এ) শুধু ASCII চেনে। ASCII ইনপুটে দুটির ফল এক।

৭ · String থেকে সংখ্যা, সংখ্যা থেকে string

ইনপুট ফাইল, ওয়েব ফর্ম, bKash-এর SMS — সব জায়গায় সংখ্যা আসে লেখা হিসেবে: "4096". হিসাব করতে হলে আগে সেটিকে আসল সংখ্যায় আনতে হয়। লেখা পড়ে মান বের করার এই কাজের নাম parsing. ভেতরের কৌশলটি খুব সরল — বাঁ থেকে প্রতিটি অঙ্ক পড়ে, এ পর্যন্ত পাওয়া মানকে ১০ দিয়ে গুণ করে নতুন অঙ্ক যোগ করুন:

long long parseNumber(const string& s) {        // assumes only digits '0'..'9'
    long long num = 0;
    for (char c : s)
        num = num * 10 + (c - '0');             // "4096": 4 -> 40 -> 409 -> 4096
    return num;
}
string toText(long long x) {                    // the reverse: peel digits off the end
    if (x == 0) return "0";
    string r;
    for (; x > 0; x /= 10) r += char('0' + x % 10);   // "6904"
    reverse(r.begin(), r.end());                // -> "4096"
    return r;
}

c - '0' কেন অঙ্কের মান দেয়? কারণ ASCII-তে '0' থেকে '9' পরপর 48 থেকে 57 — '7' - '0' = 55 − 48 = 7. দুটি ফাংশনই প্রতি অঙ্কে O(1) কাজ করে, তাই d অঙ্কের সংখ্যায় O(d) = O(log x). উল্টো দিকে লক্ষ করুন: অঙ্কগুলো শেষ থেকে আসে (x % 10), তাই শেষে একবার উল্টাতে হয় — প্রতিবার সামনে যোগ করলে (r = c + r) আবার সেই O(d²) জোড়ার ফাঁদ।

বাস্তবে নিজে না লিখে লাইব্রেরি ব্যবহার করবেন। কিন্তু লাইব্রেরির আচরণ ভাষাভেদে আলাদা, আর পরীক্ষায় ঠিক এগুলোই জিজ্ঞেস করা হয়:

ইনপুটC++ stoi / stollPython int()
"42", "-7"42, −742, −7
"12abc"12 — শুরুর অঙ্কগুলো পড়েই থামে, কোনো error নেইValueError
"abc"std::invalid_argument exceptionValueError
" 42 "42 (শুরুর ফাঁকা বাদ দেয়)42 (দুই পাশের ফাঁকা বাদ)
"99999999999"stoi-তে std::out_of_range; stoll ঠিক আছেঠিক আছে — Python-এর int-এর সীমা নেই
বাংলা অঙ্ক "৪২"std::invalid_argument42 — Unicode-এর যেকোনো দশমিক অঙ্ক চেনে

নিজের parser-এ overflow. ওপরের parseNumber-এ ২০ অঙ্কের ইনপুট দিলে num * 10 long long-এর সীমা (প্রায় 9.2 × 10¹⁸) ছাড়িয়ে যায় — এটি integer overflow, আর signed সংখ্যায় C++-এ undefined behavior: ভুল উত্তর, কোনো সতর্কবার্তা ছাড়াই। ইন্টারভিউর “atoi লিখুন” প্রশ্নে তাই গুণের আগে পরীক্ষা করতে হয়: num > (LIMIT - d) / 10 হলে থামুন। উল্টো দিকে, to_string(3.5) দেয় "3.500000" — দশমিক সংখ্যার জন্য ostringstream বা std::format ব্যবহার করুন।

৮ · ইন্টারভিউর দুই ক্লাসিক — common prefix আর compression

Longest common prefix

ঢাকার কয়েকটি এলাকার নাম: dhaka, dhanmondi, dhamrai. সবার শুরুতে যে সবচেয়ে লম্বা অংশ হুবহু এক, তা হলো dha — একে বলে longest common prefix (LCP). ফোনের কন্ট্যাক্টে বা সার্চ বক্সে টাইপ করার সময় যে “স্বয়ংক্রিয় পূরণ” দেখেন, তার পেছনে এই ধারণা (পর্ব ৩১-এর trie এটিকে আরও দক্ষ করে)।

সহজ পদ্ধতি — কলাম ধরে মেলানো: সব শব্দের ঘর 0 মেলে? তাহলে ঘর 1 দেখুন… যে কলামে প্রথম অমিল বা কোনো শব্দ শেষ, সেখানেই থামুন। k শব্দ আর উত্তরের দৈর্ঘ্য p হলে কাজ O(k·(p + 1)). প্রতিটি শব্দ অন্তত p অক্ষরের, তাই k·p ≤ S (সব অক্ষরের মোট সংখ্যা) — অর্থাৎ কাজ O(S + k). মৌলিকভাবে এর চেয়ে ভালো সম্ভব নয়, কারণ উত্তর নিশ্চিত হতে হলে প্রতিটি শব্দের প্রথম p টি অক্ষর দেখতেই হয় — Ω(k·p).

Run-length compression — “aaabb” → “a3b2”

বাসের কন্ডাক্টর যাত্রী গোনেন না একজন একজন করে নাম ধরে — বলেন “গুলিস্তানের ৩ জন, ফার্মগেটের ২ জন”। একই অক্ষরের টানা সারিকে (run) “অক্ষর + কতবার” দিয়ে লেখার এই কৌশলের নাম run-length encoding (RLE). অ্যালগরিদম এক পাসের: i থেকে শুরু করে j-কে ডানে সরান যতক্ষণ একই অক্ষর; তারপর লিখুন s[i] আর j − i, আর i-কে j-তে নিয়ে যান।

Pseudocode (0-based index)

COMPRESS(s):                       // n = length of s
    out = empty builder
    i = 0
    while i < n:
        j = i
        while j < n and s[j] == s[i]:
            j = j + 1              // s[i..j-1] is one run
        append s[i] and (j - i) to out
        i = j                      // jump to the next run
    return out as a string

ভেতরে দুটি নেস্টেড লুপ দেখে অনেকে বলেন O(n²) — ভুল। j কখনো পেছনে যায় না, আর i সরাসরি j-তে লাফ দেয়; তাই j মোট n বার বাড়ে, পুরো কাজ O(n). নেস্টেড লুপ মানেই O(n²) নয় — ভেতরের পয়েন্টার মোট কতবার সরে, সেটি গুনুন। এখানে লেখার জন্য builder ব্যবহার না করে out = out + ... লিখলে আবার অংশ ২-এর O(n²) ফাঁদে পড়তেন।

#include <initializer_list>
#include <iostream>
#include <string>
#include <vector>
using namespace std;

string compress(const string& s) {             // "aaabb" -> "a3b2"
    string out;
    out.reserve(2 * s.size());                  // worst case "abc" -> "a1b1c1"
    for (size_t i = 0; i < s.size(); ) {
        size_t j = i;
        while (j < s.size() && s[j] == s[i]) ++j;   // s[i..j-1] is one run
        out += s[i];
        out += to_string(j - i);                // += appends in place
        i = j;
    }
    return out;
}

string lcp(const vector<string>& words) {      // check column k of every word
    if (words.empty()) return "";
    for (size_t k = 0; ; ++k)
        for (const string& w : words)
            if (k == w.size() || w[k] != words[0][k]) return words[0].substr(0, k);
}

int main() {
    for (string s : {"aaabb", "abc", "aaaaaaaaaaaab", "zzzzzz"})
        cout << s << " -> " << compress(s) << '\n';
    for (const vector<string>& ws : {vector<string>{"flower", "flow", "flight"},
                                    vector<string>{"dhaka", "dhanmondi", "dhamrai"},
                                    vector<string>{"bus", "train"}})
        cout << "lcp = \"" << lcp(ws) << "\"\n";
}

আউটপুট:

aaabb -> a3b2
abc -> a1b1c1
aaaaaaaaaaaab -> a12b1
zzzzzz -> z6
lcp = "fl"
lcp = "dha"
lcp = ""

a12b1 দেখুন: ১২টি a-এর গণনা দুই অঙ্কের — to_string সেটি ঠিকমতো লেখে; যাঁরা char('0' + count) লেখেন, তাঁদের কোড ১০ বা তার বেশি পুনরাবৃত্তিতে ভেঙে পড়ে (একটি পরিচিত off-by-one-ধাঁচের কিনারার ভুল)। আর abc → a1b1c1: পুনরাবৃত্তি না থাকলে “compression” লেখাকে দ্বিগুণ করে দেয়! তাই ইন্টারভিউর সংস্করণে প্রায়ই বলা থাকে — ফল মূলের চেয়ে ছোট না হলে মূলটিই ফেরত দিন। lcp-এ for (size_t k = 0; ; ++k) লুপের কোনো শর্ত নেই; প্রথম শব্দ শেষ হলে k == w.size() সত্য হবেই, তাই লুপ নিশ্চিতভাবে থামে। bus আর train-এর প্রথম অক্ষরেই অমিল, তাই উত্তর খালি string.

def compress(s):                         # "aaabb" -> "a3b2"
    out = []
    i = 0
    while i < len(s):
        j = i
        while j < len(s) and s[j] == s[i]:
            j += 1                       # s[i:j] is one run
        out.append(s[i] + str(j - i))
        i = j
    return "".join(out)                  # one final copy, not n copies

def lcp(words):                          # check column k of every word
    if not words:
        return ""
    k = 0
    while True:
        for w in words:
            if k == len(w) or w[k] != words[0][k]:
                return words[0][:k]
        k += 1

for s in ["aaabb", "abc", "aaaaaaaaaaaab", "zzzzzz"]:
    print(f"{s} -> {compress(s)}")
for ws in [["flower", "flow", "flight"], ["dhaka", "dhanmondi", "dhamrai"], ["bus", "train"]]:
    print(f'lcp = "{lcp(ws)}"')

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

aaabb -> a3b2
abc -> a1b1c1
aaaaaaaaaaaab -> a12b1
zzzzzz -> z6
lcp = "fl"
lcp = "dha"
lcp = ""

এখানে টুকরোগুলো একটি list-এ জমে, শেষে "".join(out) একবারে string বানায় — অংশ ২-এর নিয়ম মেনে। Python-এ itertools.groupby(s) দিয়েও run আলাদা করা যায়: "".join(c + str(len(list(g))) for c, g in groupby(s)). LCP-র জন্য তৈরি ফাংশনও আছে — os.path.commonprefix(words), যা নাম সত্ত্বেও অক্ষর ধরে কাজ করে।

৯ · লেখার ভেতরে খোঁজা — সরল pattern matching আর hashing-এর আভাস

ব্রাউজারে Ctrl+F চাপলেন, লিখলেন “bKash” — পুরো পাতায় শব্দটি কোথায় কোথায় আছে, মুহূর্তে হলুদ হয়ে যায়। এই কাজের নাম pattern matching: n দৈর্ঘ্যের একটি লেখা (text) T-এর ভেতরে m দৈর্ঘ্যের pattern P কোথায় কোথায় হুবহু আছে, খুঁজে বের করা। সবচেয়ে সরল উপায় — naive string matching: P-কে T-এর প্রতিটি সম্ভাব্য শুরুর ঘরে (shift) বসিয়ে বাঁ থেকে অক্ষর মেলান; অমিল পেলেই পরের shift.

Pattern-এর অবস্থান= মিলেছে, X মেলেনি

            
গণনা
shift
এই shift-এ তুলনা
মোট তুলনা
পাওয়া মিল
  1. shift 0. T[0] = a = P[0], T[1] = b = P[1], কিন্তু T[2] = a ≠ c. তিনটি তুলনার পর অমিল — এই shift বাতিল।
  2. shift 1. P এক ঘর ডানে। T[1] = b ≠ a — প্রথম তুলনাতেই অমিল। লক্ষ করুন, shift 0-এ আমরা ইতিমধ্যে জেনেছিলাম T[1] = b; সরল পদ্ধতি সেই জ্ঞান কাজে লাগায় না।
  3. shift 2. T[2..4] = a b c — তিনটিই মিলেছে। মিল পাওয়া গেল index 2-এ। সব মিল চাইলে আমরা থামি না, পরের shift-এ যাই।
  4. shift 3. T[3] = b ≠ a. মোট তুলনা ৮.
  5. shift 4 — শেষ সম্ভাব্য shift. T[4] = c ≠ a. shift 5 সম্ভব নয়, কারণ তখন P T-এর বাইরে চলে যাবে: সম্ভাব্য shift মোট n − m + 1 = 7 − 3 + 1 = 5টি।
  6. সারাংশ। ৫টি shift, ৯টি তুলনা, একটি মিল (index 2). প্রতিটি shift-এ সর্বোচ্চ m টি তুলনা, তাই সবচেয়ে খারাপে (n − m + 1) · m — অর্থাৎ O(n·m).

এখানে বেশিরভাগ shift প্রথম অক্ষরেই বাতিল হয়েছে, তাই কাজ কম। কিন্তু সবচেয়ে খারাপ ইনপুটটি ভাবুন: T = "aaaa…a" (n টি a), P = "aa…ab" (m − 1টি a, শেষে b). প্রতিটি shift-এ প্রথম m − 1টি তুলনা মেলে, শেষটিতে অমিল — মোট প্রায় (n − m + 1)·m তুলনা। n = 10⁶ আর m = 10⁵ হলে প্রায় 9 × 10¹⁰ — কনটেস্টের সময়সীমায় অসম্ভব।

Pseudocode (0-based index)

NAIVE-MATCH(T, P):                 // n = |T|, m = |P|
    for s = 0 to n - m:            // every possible shift
        k = 0
        while k < m and T[s + k] == P[k]:
            k = k + 1
        if k == m:
            report a match at s    // O((n - m + 1) * m) in the worst case

অপচয়টি কোথায়, stepper-এর ধাপ ২-এ দেখেছেন: প্রতিটি অমিলের পর আমরা আগে মেলানো সব তথ্য ফেলে দিয়ে আবার শূন্য থেকে শুরু করি। দ্রুত অ্যালগরিদমগুলো ঠিক এই অপচয় বন্ধ করে। KMP (Knuth–Morris–Pratt, ১৯৭৭ সালে প্রকাশিত) আর Z algorithm pattern-এর নিজের গঠন আগে থেকে বিশ্লেষণ করে রাখে, যাতে অমিলের পর কতদূর লাফ দেওয়া নিরাপদ তা জানা থাকে — সবচেয়ে খারাপেও O(n + m). সেগুলোর পুরো আলোচনা পর্ব ৫৮-এ।

String hashing — লেখাকে একটি সংখ্যায় বদলানো

আরেকটি ভিন্ন চিন্তা: দুটি m দৈর্ঘ্যের string অক্ষর ধরে মেলাতে O(m) লাগে। কিন্তু যদি প্রতিটি string-কে একটি সংখ্যা দিয়ে প্রকাশ করা যেত, তাহলে তুলনা হতো O(1). এটিই string hashing-এর মূল ধারণা। প্রচলিত polynomial hash অক্ষরগুলোকে একটি base B-এর অঙ্ক হিসেবে ধরে, বড় একটি মৌলিক সংখ্যা M-এর mod-এ (পর্ব ৬২):

h(s) = (s₀·Bm−1 + s₁·Bm−2 + … + sm−1) mod M

অংশ ৭-এর num = num * 10 + d-এর সাথে মিল দেখছেন? এটি হুবহু সেই সূত্র, শুধু ১০-এর বদলে B আর প্রতি ধাপে mod. আসল জাদু rolling hash-এ: text-এর ওপর m দৈর্ঘ্যের জানালা এক ঘর সরালে বাঁয়ের অক্ষরের অবদান বাদ দিয়ে, B দিয়ে গুণ করে, ডানের অক্ষর যোগ করলেই নতুন hash — O(1)-এ। ফলে প্রতিটি shift-এর hash মোট O(n)-এ পাওয়া যায়; এই ধারণার ওপর দাঁড়িয়ে Rabin–Karp অ্যালগরিদম। সতর্কতা একটিই: দুটি ভিন্ন string-এর hash মিলে যেতে পারে — একে বলে collision — তাই hash মিললে হয় অক্ষর ধরে যাচাই করুন, নয়তো বড় বা দুটি আলাদা M ব্যবহার করে ভুলের সম্ভাবনা নগণ্য করুন। বিস্তারিত পর্ব ৫৮-এ; hash table-এর দিক থেকে hash function-এর আলোচনা পর্ব ৩২-এ।

আরও গভীরে: naive matching-এর O(n·m) শুধু সবচেয়ে খারাপ ক্ষেত্রের গল্প। σ ≥ 2টি অক্ষরের বর্ণমালা থেকে সমসম্ভাব্যভাবে এলোমেলো text আর pattern-এ প্রতিটি shift-এ প্রত্যাশিত তুলনা 1 + 1/σ + 1/σ² + … < σ/(σ − 1) ≤ 2 — তাই গড়ে কাজ O(n), আর এ কারণেই বাস্তবের অনেক লাইব্রেরি ছোট pattern-এ সরল পদ্ধতির উন্নত রূপই ব্যবহার করে। সমস্যা হলো বাস্তব ডেটা এলোমেলো নয় (DNA-তে চারটি অক্ষর, লগ-ফাইলে একই লাইন বারবার), আর কনটেস্টের test ইচ্ছা করেই সবচেয়ে খারাপ ক্ষেত্র বানায়। তাই worst-case গ্যারান্টি চাইলে KMP বা Z.

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

  • দুটি string anagram কি না, সবচেয়ে দক্ষভাবে কীভাবে যাচাই করবেন? — দৈর্ঘ্য মিলিয়ে ২৬ ঘরের গণনা-অ্যারে বা hash map: O(n) সময়, O(1) বা O(σ) জায়গা; sort করলে O(n log n).
  • Python-এ লুপে s += c দিয়ে n অক্ষরের string বানানোর সমস্যা কী? — str immutable, প্রতিবার নতুন কপি হতে পারে: মোট Θ(n²); list-এ জমিয়ে "".join() করলে Θ(n).
  • Palindrome যাচাইয়ের সময় ও বাড়তি জায়গা? — দুই পয়েন্টারে O(n) সময়, O(1) জায়গা; উল্টানো কপি বানালে O(n) জায়গা।
  • Naive pattern matching-এর worst case কোন ইনপুটে, আর কত? — T = aaa…a, P = aa…ab; (n − m + 1)·m তুলনা, O(n·m).
  • UTF-8-এ “বাংলা” লেখাটির C++ size() কত? — ১৫: পাঁচটি code point, প্রতিটি ৩ বাইট।

১০ · AI-এর যুগে string — ছোট কোড, লুকানো খরচ

string-এর সমস্যায় AI-এর লেখা কোড সাধারণত ছোট, পরিষ্কার আর উদাহরণে নির্ভুল। ভুল লুকিয়ে থাকে তিন জায়গায়: লুকানো কপি (slice, +, substr) যা complexity বাড়িয়ে দেয়, কিনারার ইনপুট (খালি string, এক অক্ষর, ১০+ পুনরাবৃত্তি, বড় হাতের অক্ষর), আর এনকোডিং — বিশেষ করে বাংলা বা emoji এলে।

String-এর সমস্যায় AI সাধারণত যেসব ভুল করে

AI-এর ভুলকীভাবে ধরবেন
লুপে s = s + x বা প্রতি ধাপে s[i:] নিয়ে “O(n)” দাবিপ্রতিটি লাইনে কোথায় কপি হয় চিহ্নিত করুন; n দ্বিগুণ করে সময় মাপুন — চারগুণ হলে O(n²)
Compression-এ char('0' + count) — ১০ বা বেশি পুনরাবৃত্তিতে ভুল"a" * 12 দিয়ে চালান; উত্তর a12 হওয়া উচিত
c - 'a' ধরে নেওয়া অথচ ইনপুটে বড় হাতের অক্ষর বা ফাঁকা জায়গাসমস্যার শর্ত আবার পড়ুন; "Ab c" জাতীয় ইনপুট দিন
C++-এ s.size()-কে বাংলা অক্ষরের সংখ্যা ধরা, বা বাইট ধরে উল্টানো"কলম" দিয়ে চালান — size() ৯ আসবে, উল্টালে হিজিবিজি
Naive matching বা find-কে সবসময় O(n) বলাT = aaa…a, P = aa…ab দিয়ে সবচেয়ে খারাপ ক্ষেত্র তৈরি করে সময় মাপুন
Hash মিললেই “string সমান” ধরে নেওয়াcollision-এর সম্ভাবনা নিয়ে প্রশ্ন করুন; ছোট ইনপুটে brute force-এর সাথে মেলান

AI-কে সমাধান লিখতে না বলে বরং আপনার সমাধানের দুর্বলতা খুঁজতে বলুন। নিচের prompt-টি আপনাকে নিজে ভাবতে বাধ্য করে:

আমি DSA শিখছি, এখন string. নিচে আমার লেখা একটি ফাংশন দিলাম, যা [সমস্যার নাম, যেমন run-length compression] করে: [এখানে আপনার কোড পেস্ট করুন] পুরো সমাধান লিখে দিও না। শুধু এগুলো করো: ১. প্রতিটি লাইনে কোথায় নতুন string কপি হচ্ছে (concatenation, slice, substr) চিহ্নিত করে মোট সময় আর জায়গার complexity বলো, যুক্তিসহ। ২. পাঁচটি কিনারার ইনপুট দাও যেগুলোতে আমার কোড ভাঙতে পারে (খালি, এক অক্ষর, ১০+ পুনরাবৃত্তি, বড় হাতের অক্ষর, non-ASCII). ৩. ইনপুটে বাংলা লেখা (UTF-8) এলে C++ আর Python-এ কোডটির আচরণ কোথায় বদলাবে? ৪. একটি brute force সংস্করণ কীভাবে লিখে random ইনপুটে মেলাব — শুধু ইঙ্গিত দাও।

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

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

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

প্র ০১C ভাষা কেন string-এর দৈর্ঘ্য না রেখে শেষে '\0' বসাল — আর এই সিদ্ধান্তের দাম কত?

১৯৭০-এর দশকের শুরুতে Dennis Ritchie যখন C তৈরি করছিলেন, তখন মেমরি ছিল অত্যন্ত দামি — PDP-11-এর মতো কম্পিউটারে কয়েক দশ কিলোবাইট। string-এর শেষে একটিমাত্র শূন্য বাইট রাখা ছিল সবচেয়ে সাশ্রয়ী নকশা: দৈর্ঘ্যের জন্য আলাদা ঘর লাগে না, আর দৈর্ঘ্যের কোনো ঊর্ধ্বসীমাও থাকে না। তুলনায় সেই যুগের Pascal-এর অনেক বাস্তবায়ন string-এর শুরুতে এক বাইটে দৈর্ঘ্য রাখত — ফলে কোনো string ২৫৫ অক্ষরের বেশি হতে পারত না।

কিন্তু এই সাশ্রয়ের দাম দিতে হয়েছে দুভাবে। প্রথমত গতি: দৈর্ঘ্য জানতে প্রতিবার পুরো string হাঁটতে হয়, তাই strlen O(n). সফটওয়্যার লেখক Joel Spolsky ২০০১ সালে “Back to Basics” নামের একটি লেখায় এর একটি মজার উপমা দেন — “Schlemiel the painter”: রাস্তায় দাগ টানার এক রংমিস্ত্রি রঙের কৌটা শুরুর জায়গায় রেখে দেন, আর প্রতিটি দাগের জন্য কৌটার কাছে হেঁটে ফেরেন; প্রতিদিন তাঁর কাজ কমতে থাকে, কারণ হাঁটার পথ লম্বা হয়। লুপে বারবার strcat করলে ঠিক তাই হয় — প্রতিবার শেষ খুঁজতে শুরু থেকে হাঁটা, মোট O(n²).

দ্বিতীয় এবং অনেক গুরুতর দাম নিরাপত্তা। দৈর্ঘ্য যেহেতু string-এর সাথে থাকে না, strcpy বা gets-এর মতো ফাংশন জানেই না গন্তব্যে কতটুকু জায়গা আছে। বেশি লম্বা ইনপুট এলে লেখা পাশের মেমরিতে গিয়ে পড়ে — buffer overflow. কয়েক দশক ধরে এটি সবচেয়ে পরিচিত নিরাপত্তা-দুর্বলতাগুলোর একটি। ১৯৮৮ সালের বিখ্যাত Morris worm-এর ছড়ানোর একটি পথ ছিল fingerd প্রোগ্রামের gets-জনিত overflow. শেষ পর্যন্ত C11 standard থেকে gets পুরোপুরি বাদ দেওয়া হয়।

আধুনিক ভাষাগুলো শিক্ষা নিয়েছে: C++-এর std::string, Python, Java, Rust, Go — সবাই দৈর্ঘ্য আলাদা করে রাখে, তাই size() O(1) আর সীমার বাইরে লেখা সহজে ধরা পড়ে। মজার ব্যাপার, C-এর সাথে যোগাযোগ রাখতে std::string ভেতরে শেষে একটি '\0'-ও রাখে (c_str()-এর জন্য) — দুই দুনিয়ার সুবিধাই।

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

প্র ০২UTF-8 কীভাবে জন্ম নিল, আর বাংলার জন্য এটি কি সবচেয়ে ভালো এনকোডিং?

১৯৯০-এর দশকের শুরুতে Unicode এসে গেছে, কিন্তু সমস্যা ছিল বাইটে লেখার নিয়ম নিয়ে। প্রথম দিকের প্রস্তাবগুলোতে প্রতিটি অক্ষর ২ বাইটে রাখা হতো, ফলে পুরোনো সব ASCII ফাইল আর প্রোগ্রাম অচল হয়ে যেত, আর লেখার মাঝে শূন্য বাইট এসে C-এর string ভেঙে দিত। ১৯৯২ সালের সেপ্টেম্বরে Bell Labs-এর Ken Thompson আর Rob Pike — যাঁরা তখন Plan 9 অপারেটিং সিস্টেমে কাজ করছিলেন — একটি নতুন নকশা দাঁড় করান। গল্পটি প্রায় কিংবদন্তি: নিউ জার্সির একটি রেস্তোরাঁয় রাতের খাবারের সময় Thompson খাবারের টেবিলের কাগজের ম্যাটে নকশাটি এঁকে ফেলেন, আর কয়েক দিনের মধ্যে Plan 9 পুরোপুরি UTF-8-এ চলে যায়।

নকশাটির সৌন্দর্য কয়েকটি বৈশিষ্ট্যে। (১) ASCII হুবহু ASCII — পুরোনো ইংরেজি ফাইল বিনা বদলে বৈধ। (২) কোনো অ-ASCII অক্ষরের ভেতরে 0x00 থেকে 0x7F মানের বাইট আসে না, তাই C-এর '\0' বা '/'-নির্ভর কোড ভাঙে না। (৩) Self-synchronizing: যেকোনো বাইট দেখেই বোঝা যায় এটি অক্ষরের শুরু (0… বা 11…) নাকি মাঝখান (10…) — ফাইলের মাঝখান থেকে পড়া শুরু করলেও পরের অক্ষর থেকে ঠিকঠাক পড়া যায়, একটি নষ্ট বাইট বড়জোর একটি অক্ষর নষ্ট করে। আজ ওয়েবের প্রায় সব পাতা UTF-8-এ লেখা।

বাংলার জন্য হিসাবটা একটু জটিল। প্রতিটি বাংলা code point UTF-8-এ ৩ বাইট, অথচ UTF-16-এ ২ বাইট — তাই খাঁটি বাংলা লেখা UTF-16-এ প্রায় এক-তৃতীয়াংশ কম জায়গা নেয়। তবু বাস্তবে UTF-8-ই জিতেছে: ওয়েব পাতায় HTML ট্যাগ, লিংক আর ইংরেজি শব্দ মিশে থাকে (সেগুলো ১ বাইট), compression এই পার্থক্য অনেকটাই মুছে দেয়, আর সব টুল একটি এনকোডিংয়ে একমত হওয়ার লাভ কয়েক শতাংশ জায়গার চেয়ে অনেক বড়।

বাংলাদেশের প্রেক্ষাপটে একটি পুরোনো সমস্যাও মনে রাখার মতো। Unicode ছড়িয়ে পড়ার আগে অনেক বাংলা লেখা তৈরি হতো বিশেষ ফন্টের ওপর নির্ভর করে, যেখানে ইংরেজি অক্ষরের কোডের জায়গায় বাংলা অক্ষরের আকৃতি আঁকা থাকত (বিজয়-যুগের অনেক নথি এভাবে তৈরি)। সেই ফন্ট না থাকলে লেখা দেখায় ইংরেজি হিজিবিজি, আর কম্পিউটারের কাছে সেটি বাংলাই নয় — খোঁজা, sort বা বানান-যাচাই কিছুই ঠিকমতো চলে না। সরকারি নথি আর পুরোনো আর্কাইভ Unicode-এ রূপান্তর আজও একটি বাস্তব প্রকৌশল-কাজ।

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

প্র ০৩দুটি বাংলা শব্দ দেখতে হুবহু এক, তবু == বলছে আলাদা — আর বাংলা শব্দ sort করলে অভিধানের ক্রম আসে না কেন?

Unicode-এ একই দেখতে লেখা একাধিক উপায়ে এনকোড করা যায়। বাংলার একটি পরিচিত উদাহরণ ড়: এটি একটি আলাদা code point (U+09DC) হিসেবে লেখা যায়, আবার ড (U+09A1) + নুক্তা (U+09BC) — দুটি code point হিসেবেও। পর্দায় দুটো হুবহু এক, কিন্তু বাইট আলাদা; তাই সরল == বা find বলে “মেলেনি”। একজন ব্যবহারকারী এক কীবোর্ডে “বাড়ি” লিখে সেভ করলেন, আরেকজন অন্য কীবোর্ডে লিখে খুঁজলেন — ফল পাওয়া গেল না, অথচ কেউ কোনো ভুল করেননি।

সমাধানের নাম Unicode normalization: তুলনা বা সংরক্ষণের আগে লেখাকে একটি মানক রূপে আনা। সবচেয়ে প্রচলিত রূপ NFC. মজার ব্যাপার, U+09DC-কে Unicode “composition exclusion” তালিকায় রেখেছে, তাই NFC-তে ড় দুই code point-এর রূপেই (ড + ়) থাকে — Python-এ unicodedata.normalize("NFC", "ড়") চালালে ঠিক তাই পাবেন। নিয়মটা জটিল, তাই নিজে না লিখে লাইব্রেরি ব্যবহার করাই নিরাপদ।

দ্বিতীয় সমস্যা ক্রম। ভাষা যে < দিয়ে তুলনা করে, সেটি code point-এর ক্রম। বাংলা ব্লকে বর্ণগুলো মোটামুটি বর্ণমালার ক্রমে সাজানো হলেও সব জায়গায় নয় — একক code point ড় (U+09DC), ঢ় (U+09DD) আর য় (U+09DF) বসানো আছে হ (U+09B9)-এরও পরে। ফলে code point-এর ক্রমে sort করলে ‘ড়’ দিয়ে শুরু শব্দ হ-এর শব্দেরও পরে চলে যায়। তার ওপর যুক্তাক্ষর, কার-চিহ্ন আর হসন্তের নিয়ম আছে, যা অভিধানে আলাদাভাবে বিবেচিত হয়। ভাষাভিত্তিক সঠিক ক্রমের জন্য আছে Unicode Collation Algorithm আর ICU-এর মতো লাইব্রেরি, যা প্রতিটি ভাষার নিয়ম আলাদা করে জানে।

ইংরেজিতেও এমন ফাঁদ আছে, শুধু আমরা কম লক্ষ করি: case-insensitive তুলনায় তুর্কি ভাষার ডট-ওয়ালা আর ডট-ছাড়া i-এর জোড়া বহু সফটওয়্যারে bug ঘটিয়েছে; জার্মান ß বড় হাতে হয় “SS” — দৈর্ঘ্যই বদলে যায়। DSA-র সমস্যায় এসব থাকে না বলে অনেকে বাস্তবেও ধরে নেন “string তুলনা মানে বাইট তুলনা”।

মূল উপলব্ধি: মানুষের ভাষায় “একই লেখা” আর “আগে-পরে” মানুষের নিয়মে চলে, বাইটের নিয়মে নয়। অ্যালগরিদম সঠিক হলেও ভুল তুলনা-নিয়ম দিলে ফল ভুল — তাই বাস্তব সফটওয়্যারে আগে normalize, তারপর ভাষা-সচেতন তুলনা।

প্র ০৪Python আর Java কেন string-কে immutable বানাল, যখন এতে জোড়া দেওয়া এত ব্যয়বহুল?

immutable string-এর পক্ষে যুক্তিগুলো শক্তিশালী, আর সেগুলো মূলত ডেটা স্ট্রাকচারের প্রশ্ন। প্রথম ও সবচেয়ে বড় কারণ hashing: Python-এর dict বা Java-র HashMap-এ string সবচেয়ে প্রচলিত key. hash table (পর্ব ৩২) key-এর hash দেখে তাকে রাখে। key যদি পরে বদলে যেত, তার hash বদলে যেত, আর সেটি ভুল তাকে পড়ে থাকত — আর কখনো খুঁজে পাওয়া যেত না। immutable হলে hash একবার হিসাব করে মনে রাখাও যায়; CPython আর Java দুটোই তা করে।

দ্বিতীয় কারণ নিরাপদ ভাগাভাগি। immutable বস্তু কপি না করেই একাধিক জায়গায়, একাধিক thread-এ ব্যবহার করা যায় — কেউ বদলাতে পারবে না বলে lock লাগে না। Java একই লেখার string literal-গুলোকে একটি “string pool”-এ একবারই রাখে; Python-ও ছোট, নামের মতো দেখতে string-কে প্রায়ই এভাবে ভাগাভাগি (interning) করে। তৃতীয় কারণ নিরাপত্তা: একটি ফাংশন ফাইলের নাম বা পাসওয়ার্ড যাচাই করার পর অন্য কোনো কোড সেই string বদলে দিতে পারবে না।

দাম হলো অংশ ২-এর জোড়া দেওয়ার খরচ — আর দুটি ভাষাই সেটি কমানোর পথ বানিয়েছে। Java-তে আছে mutable StringBuilder, আর javac compiler বহু বছর ধরে a + b + c-এর মতো এক লাইনের জোড়াকে নিজেই StringBuilder-এ রূপান্তর করে দিত; Java 9 থেকে এর জায়গায় এসেছে আরও নমনীয় একটি ব্যবস্থা (JEP 280, invokedynamic-ভিত্তিক)। তবে লুপের ভেতরের s = s + x এই রূপান্তরে বাঁচে না — প্রতিটি ঘুর্ণনে নতুন string তৈরি হয়। Python-এ সমাধান join, আর CPython-এর +=-এর গোপন optimization নিয়ে PEP 8-এর সতর্কতা আগেই দেখেছেন।

অন্য দিকে C++ বেছে নিয়েছে mutable string, কারণ ভাষাটির দর্শন হলো “যা ব্যবহার করবেন না, তার দাম দেবেন না”। তবে C++-এও immutability-র সুবিধা পেতে const std::string& আর C++17-এর std::string_view আছে — বদলানোর অধিকার ছাড়াই পড়ার সুবিধা। Rust আরও এক ধাপ এগিয়ে দুটি আলাদা টাইপ রেখেছে: মালিকানাসহ বদলানো যায় এমন String আর শুধু-দেখার &str.

মূল উপলব্ধি: immutable বনাম mutable কোনো সঠিক-ভুলের প্রশ্ন নয়, একটি trade-off: নিরাপদ ভাগাভাগি আর hashing বনাম জায়গায় বসে দ্রুত বদল। প্রতিটি ভাষার পছন্দ জানলে আপনি জানবেন কোন অপারেশন সস্তা, আর কোনটি লুকিয়ে O(n).

প্র ০৫String hashing এত দ্রুত হলে সবাই কেন KMP শেখে? hash-এর দুর্বলতা বাস্তবে কোথায় ধরা পড়েছে?

Rabin–Karp অ্যালগরিদম Richard Karp আর Michael Rabin ১৯৮৭ সালে প্রকাশ করেন। এর শক্তি নমনীয়তায়: একসাথে অনেক pattern খোঁজা, দুটি লেখায় সাধারণ টুকরো খোঁজা, বা যেকোনো দুটি substring-এর তুলনা O(1)-এ — এসব hashing দিয়ে সহজ। প্রোগ্রামিং কোডে নকল (plagiarism) ধরার জনপ্রিয় ব্যবস্থা MOSS-এর পেছনের “winnowing” পদ্ধতিও (Schleimer, Wilkerson আর Aiken, ২০০৩) লেখার টুকরোগুলোর hash-এর ওপর দাঁড়িয়ে।

কিন্তু hashing একটি সম্ভাবনার খেলা। দুটি ভিন্ন string-এর hash মিলে গেলে (collision) অ্যালগরিদম ভুল উত্তর দিতে পারে। এলোমেলো ইনপুটে বড় মৌলিক mod নিলে এর সম্ভাবনা নগণ্য — কিন্তু ইনপুট যদি কেউ ইচ্ছা করে বানায়? কনটেস্ট প্রোগ্রামিংয়ে এটি একটি বিখ্যাত শিক্ষা। অনেকে mod হিসেবে 2⁶⁴ ব্যবহার করতেন — unsigned long long-এর স্বাভাবিক overflow-ই mod, আলাদা হিসাব লাগে না। দেখা গেল, Thue–Morse ক্রম (0 আর 1-এর একটি বিশেষ ক্রম: প্রতিটি ধাপে এ পর্যন্ত লেখা অংশের প্রতিটি 0-কে 1 আর 1-কে 0 করে পেছনে জুড়ে দেওয়া হয় — 0, 01, 0110, 01101001, …) থেকে বানানো দুটি string যেকোনো বিজোড় base-এ 2⁶⁴ mod-এ একই hash দেয়। Codeforces-এর মতো প্ল্যাটফর্মে hack-পর্বে প্রতিযোগীরা ঠিক এমন test দিয়ে অন্যের সমাধান ভেঙে দেন। তাই প্রচলিত পরামর্শ: বড় মৌলিক mod, আর base প্রোগ্রাম চলার সময় এলোমেলোভাবে বেছে নেওয়া — যাতে আগে থেকে খারাপ test বানানো না যায়।

বাস্তব সার্ভারেও একই সমস্যা দেখা দিয়েছে। ২০১১ সালের শেষে নিরাপত্তা গবেষকেরা দেখান, অনেক ওয়েব প্ল্যাটফর্মের hash table-এর hash function অনুমানযোগ্য হওয়ায় একটি ওয়েব অনুরোধে হাজার হাজার collide-করা key পাঠিয়ে সার্ভারের CPU আটকে দেওয়া যায় (hash flooding). এর পর Python ৩.৩ থেকে string hash ডিফল্টভাবে এলোমেলো seed নিয়ে হিসাব হয়, আর ৩.৪ থেকে ব্যবহৃত হয় SipHash নামের একটি শক্ত hash function (PEP 456).

তাহলে KMP কেন? কারণ KMP আর Z deterministic: কোনো ভাগ্য নেই, কোনো খারাপ ইনপুট নেই, সবসময় O(n + m). পরীক্ষা ও প্রমাণে এগুলোই প্রত্যাশিত, আর failure function / Z-array নিজেই অনেক সমস্যার (পর্যায়কাল, সীমানা) উত্তর দেয়। পর্ব ৫৮-এ দুটি পথই পাশাপাশি দেখবেন, পর্ব ৬১-এ এলোমেলোতার ভালো-মন্দ।

মূল উপলব্ধি: hashing গড় ক্ষেত্রে অসাধারণ, কিন্তু প্রতিপক্ষ যখন ইনপুট বানায়, “গড়” আর আপনার পক্ষে থাকে না। তাই hash ব্যবহার করলে এলোমেলোতা নিজের হাতে রাখুন, আর যেখানে নিশ্চয়তা দরকার, সেখানে deterministic অ্যালগরিদম।

অনুশীলন

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

  1. হাতে trace করুন: compress("aabcccccaaa") কী ফেরত দেবে? বাইরের লুপ কতবার ঘুরবে, আর ভেতরের পয়েন্টার j মোট কতবার বাড়বে?

    run-গুলো: aa, b, ccccc, aaa → ফল "a2b1c5a3" (৮ অক্ষর, মূল ১১ অক্ষরের চেয়ে ছোট)। খেয়াল করুন, শেষের aaa আলাদা run — RLE শুধু টানা পুনরাবৃত্তি গোনে, মোট সংখ্যা নয়।

    বাইরের লুপ ঘোরে run-এর সংখ্যার সমান — ৪ বার। j প্রতিটি অক্ষরে ঠিক একবার বাড়ে — মোট ১১ বার = n. তাই নেস্টেড লুপ সত্ত্বেও সময় O(n).

  2. তুলনা গুনুন: naive matching-এ T = "aaaaa" আর P = "aab" হলে মোট কতটি অক্ষর-তুলনা হবে? সাধারণভাবে T-এ n টি a আর P = (m − 1)টি a + b হলে সূত্র কী?

    সম্ভাব্য shift n − m + 1 = 5 − 3 + 1 = ৩টি (0, 1, 2). প্রতিটিতে প্রথম দুটি অক্ষর (a = a, a = a) মেলে, তৃতীয়টিতে a ≠ b — তাই প্রতি shift-এ ৩টি তুলনা, মোট ৯টি। কোনো মিল নেই।

    সাধারণ সূত্র: প্রতিটি shift-এ ঠিক m টি তুলনা, তাই মোট (n − m + 1)·m. m = n/2 হলে এটি প্রায় n²/4 — Θ(n²). এটিই naive পদ্ধতির সবচেয়ে খারাপ ক্ষেত্র, আর KMP-তে একই ইনপুটে তুলনা O(n + m).

  3. বাইট আর অক্ষর: একটি UTF-8 ফাইল থেকে "ঢাকা 2026" লেখাটি C++-এর std::string-এ পড়া হলো। s.size() কত? Python-এ len() কত? std::reverse দিয়ে বাইট উল্টালে কী হবে?

    “ঢাকা” = ঢ + া + ক + া — চারটি code point, প্রতিটি ৩ বাইট = ১২ বাইট। ফাঁকা জায়গা ১ বাইট, “2026” চারটি ASCII অঙ্ক = ৪ বাইট। মোট s.size() = ১৭। Python-এ len() = ৪ + ১ + ৪ = ৯ (code point). পাঠক দেখেন “ঢা”, “কা”, ফাঁকা আর চারটি অঙ্ক — ৭টি একক।

    বাইট উল্টালে “6202” অংশটি ঠিক থাকে, কিন্তু প্রতিটি বাংলা অক্ষরের তিনটি বাইটের ক্রমও উল্টে যায় — ধারাবাহিক বাইট (10xxxxxx) আগে, শুরুর বাইট (1110xxxx) পরে — যা অবৈধ UTF-8. স্ক্রিনে আসবে � চিহ্ন। ঠিকভাবে উল্টাতে হলে অন্তত code point ধরে (আর আদর্শভাবে grapheme cluster ধরে, যাতে “কা” ভেঙে “াক” না হয়) উল্টাতে হবে।

  4. কোড লিখুন: দুটি string s আর t দেওয়া আছে। t কি s-এর একটি rotation (s-এর শুরু থেকে কয়েকটি অক্ষর কেটে শেষে জুড়ে দিলে যা হয়)? যেমন "waterbottle" আর "erbottlewat" → হ্যাঁ। একটি ছোট ফাংশন লিখুন আর complexity বলুন।

    মূল পর্যবেক্ষণ: s-এর প্রতিটি rotation s + s-এর ভেতরে একটি substring হিসেবে থাকে। "waterbottlewaterbottle"-এর ভেতরে "erbottlewat" আছে (index 3 থেকে)। দৈর্ঘ্য সমান হওয়ার শর্তটি ভুলবেন না — না হলে "wat"-কেও rotation বলে ফেলবেন।

    bool isRotation(const string& s, const string& t) {
        return s.size() == t.size() && (s + s).find(t) != string::npos;
    }

    Python-এ: return len(s) == len(t) and t in s + s. s + s বানাতে O(n) সময় আর O(n) জায়গা। find সাধারণ ইনপুটে দ্রুত, কিন্তু সবচেয়ে খারাপ ক্ষেত্রে সরল খোঁজা O(n²) পর্যন্ত যেতে পারে (লাইব্রেরির বাস্তবায়নের ওপর নির্ভর করে); KMP বা Z দিয়ে খুঁজলে নিশ্চিতভাবে O(n) — পর্ব ৫৮.

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

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

ক্রমRun-length compression (“aaabb” → “a3b2”)-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. একটি ফাঁকা builder (list বা reserve করা string) তৈরি করা, i = 0
  2. j-কে i থেকে ডানে সরানো, যতক্ষণ s[j] আর s[i] একই অক্ষর
  3. builder-এ s[i] আর run-এর দৈর্ঘ্য j − i যোগ করা
  4. i-কে j-তে নিয়ে পরের run শুরু করা, যতক্ষণ i < n
  5. শেষে builder-এর টুকরোগুলো একবারে জোড়া দিয়ে string ফেরত দেওয়া

একটি immutable string-এ লুপের ভেতরে n বার s = s + "x" লেখা হলো, আর প্রতিবার পুরো নতুন string তৈরি হয়। মোট কতগুলো অক্ষর কপি হয়?

  • Θ(n), প্রতি ধাপে একটি অক্ষর
  • Θ(n log n)
  • Θ(n²), কারণ 1 + 2 + … + n
  • Θ(2ⁿ)

UTF-8-এ সেভ করা “বাংলা” শব্দটি (ব, া, ং, ল, া — পাঁচটি code point) C++-এর std::string-এ রাখলে size() কত দেখাবে?

  • 3
  • 5
  • 10
  • 15

ASCII-ভিত্তিক স্বাভাবিক string তুলনায় (C++-এর std::string বা Python-এর str) নিচের কোনটি সত্য?

  • "9" < "10"
  • "apple" > "apply"
  • "Zebra" < "apple"
  • "abc" > "abcd"

শুধু ছোট হাতের ইংরেজি অক্ষরের দুটি n দৈর্ঘ্যের string anagram কি না, ২৬ ঘরের গণনা-অ্যারে দিয়ে যাচাই করলে সময় আর বাড়তি জায়গা কত?

  • O(n log n) সময়, O(n) জায়গা
  • O(n) সময়, O(1) জায়গা
  • O(n²) সময়, O(1) জায়গা
  • O(n) সময়, O(n) জায়গা, কারণ প্রতিটি অক্ষরের জন্য একটি ঘর

n দৈর্ঘ্যের text-এ m দৈর্ঘ্যের pattern সরল (naive) পদ্ধতিতে খুঁজলে সবচেয়ে খারাপ ক্ষেত্রে অক্ষর-তুলনার সংখ্যা কত?

  • O(n + m)
  • O(n·m)
  • O(n log m)
  • O(m²)

Run-length encoding-এ (প্রতিটি টানা run-কে অক্ষর + দৈর্ঘ্য দিয়ে লেখা) "aaabccc"-এর ফল কী?

  • a3b1c3
  • a3bc3
  • a3c3b1
  • 3a1b3c

শব্দকোষ

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

মনে রাখুন

  1. String = অক্ষরের অ্যারে: পড়া O(1), কিন্তু তুলনা, কপি আর substring O(দৈর্ঘ্য).
  2. Immutable string-এ লুপে s = s + t = Θ(n²); builder ব্যবহার করুন — Python-এ join, C++-এ += / reserve.
  3. UTF-8-এ বাংলা ৩ বাইট: C++ size() = বাইট, Python len() = code point, চোখ দেখে grapheme.
  4. গণনা-অ্যারে (anagram, প্রথম অনন্য) আর দুই পয়েন্টার (palindrome, উল্টানো) — দুটিই O(n) সময়, O(1) জায়গা।
  5. Naive matching O(n·m); KMP/Z O(n + m), hashing গড়ে দ্রুত কিন্তু collision-এর ঝুঁকি — পর্ব ৫৮.

পরের পর্বে: অ্যারে আর string-এ মাঝখানে কিছু ঢোকাতে বা মুছতে O(n) লাগে, কারণ সবাইকে সরাতে হয়। যদি উপাদানগুলো পাশাপাশি না থেকে একে অপরের ঠিকানা ধরে শিকলের মতো জুড়ে থাকে? Linked list — singly, doubly আর circular.

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

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