এই পর্বে যা শিখবেন
- স্থির (static) রেঞ্জ-প্রশ্নের সমস্যা চেনা, আর prefix sum কেন min-এর জন্য যথেষ্ট নয় তা ব্যাখ্যা করা
- Sparse table হাতে বানানো, O(1) প্রশ্নের দুই-ব্লক কৌশলের প্রমাণ দেওয়া, আর idempotent অপারেশনের শর্তটি বোঝা
- Sqrt decomposition-এ প্রশ্ন, point update আর lazy রেঞ্জ আপডেট হাতে trace করা, আর ব্লকের মাপ হিসাব করে বেছে নেওয়া
- Mo’s algorithm-এর সাজানোর নিয়ম আর O((n + q)√n) সীমার যুক্তি লিখতে পারা
- Prefix sum, sparse table, sqrt, Fenwick আর segment tree-র মধ্যে সমস্যা দেখে সঠিকটি বেছে নেওয়া, C++ ও Python-এ brute force দিয়ে যাচাই করে
১ · স্থির অ্যারে, হাজারো প্রশ্ন
কারওয়ান বাজারের একজন চালের আড়তদার গত এক বছরের প্রতিদিনের মোটা চালের কেজিপ্রতি দাম একটি খাতায় লিখে রেখেছেন — ৩৬৫টি সংখ্যা। খাতাটি আর বদলাবে না; পুরোনো দিনের দাম তো আর পাল্টায় না। কিন্তু প্রশ্ন আসতেই থাকে: “ফেব্রুয়ারির ১০ থেকে মার্চের ২০ পর্যন্ত সবচেয়ে কম দাম কত ছিল?”, “রমজানের মাসে সর্বোচ্চ দাম কত?”, “বর্ষার তিন মাসে?” একটি গবেষণা প্রতিষ্ঠান হয়তো এমন লাখো প্রশ্ন করবে, বিভিন্ন জেলার, বিভিন্ন পণ্যের।
এটিই range query-এর সমস্যা, আর এই পর্বের বিশেষ শর্তটি হলো: অ্যারেটি স্থির (static) — কোনো আপডেট নেই। প্রতিটি প্রশ্নে l থেকে r পর্যন্ত হেঁটে উত্তর বের করা যায়, কিন্তু সেটি প্রতি প্রশ্নে O(n). n = 106 দিনের ডেটা আর q = 106 প্রশ্নে প্রায় 1012 ধাপ — অচল। উল্টো দিকে, সব সম্ভাব্য (l, r) জোড়ার উত্তর আগে থেকে লিখে রাখলে প্রশ্ন O(1), কিন্তু জোড়া প্রায় n²/2 টি — 106 দিনে ৫০,০০০ কোটি ঘর, কোনো মেমরিতে ধরবে না। আমাদের দরকার মাঝামাঝি কিছু: অল্প প্রস্তুতি, দ্রুত উত্তর।
যোগফলের জন্য prefix sum — কিন্তু min-এর জন্য নয়
পর্ব ১৭-এ আপনি prefix sum দেখেছেন: P[i] = প্রথম i টি মানের যোগফল, আর sum(l, r) = P[r + 1] − P[l]. প্রস্তুতি O(n), প্রতিটি প্রশ্ন O(1) — নিখুঁত। কিন্তু এই কৌশলের পেছনে একটি লুকানো শর্ত আছে: যোগের একটি উল্টো অপারেশন (বিয়োগ) আছে। বড় অংশের যোগফল থেকে বাড়তি অংশের যোগফল বাদ দেওয়া যায়।
সর্বনিম্নের (min) ক্ষেত্রে এমন কোনো উল্টো নেই। ধরুন জানেন প্রথম ১০০ দিনের সর্বনিম্ন দাম ৪২ টাকা, আর প্রথম ৫০ দিনের সর্বনিম্নও ৪২ টাকা। তাহলে ৫১ থেকে ১০০ দিনের সর্বনিম্ন কত? জানা সম্ভব নয় — ৪২-ও হতে পারে, ৫৫-ও হতে পারে। “বাদ দেওয়া” যায় না। তাই min আর max-এর জন্য prefix কৌশল ভেঙে পড়ে, আর আমাদের একটি নতুন ধারণা দরকার। এই পর্বে তিনটি কৌশল দেখব, তিনটি ভিন্ন দর্শন নিয়ে:
Sparse table
দুইয়ের ঘাতের (1, 2, 4, 8, …) দৈর্ঘ্যের সব অংশের উত্তর আগে বানিয়ে রাখা। অ্যারে স্থির হলে min/max প্রশ্ন O(1).
Sqrt decomposition
অ্যারেকে √n আকারের ব্লকে ভাগ করে ব্লকের সারাংশ রাখা। সরল, নমনীয়, আপডেটও সামলায় — প্রতিটি কাজ O(√n).
Mo’s algorithm
প্রশ্নগুলোকেই চতুরভাবে সাজিয়ে নেওয়া, যাতে একটি চলমান জানালা কম হাঁটে। সব প্রশ্ন আগে জানা থাকলে কাজে লাগে।
আগের তিনটি পর্বে (৩৪–৩৬) আপনি segment tree আর Fenwick tree দেখেছেন — সেগুলো আপডেটসহ প্রতিটি কাজ O(log n)-এ করে। তাহলে এই পর্বের কৌশলগুলো কেন? কারণ প্রতিটি কোনো একটি দিক থেকে তাদের হারায়: sparse table প্রশ্নে দ্রুততর (O(1) বনাম O(log n)), sqrt অনেক বেশি নমনীয় আর লিখতে সহজ, আর Mo এমন প্রশ্নের উত্তর দেয় যা segment tree-তে সহজে জোড়া যায় না। পর্বের শেষে একটি তুলনার টেবিলে সব একসাথে রাখব।
২ · Sparse table — দুইয়ের ঘাতের দৈর্ঘ্যে উত্তর জমিয়ে রাখা
মূল ধারণাটি সহজ: সব (l, r) জোড়ার উত্তর রাখা অসম্ভব, কিন্তু শুধু সেই অংশগুলোর উত্তর রাখুন যাদের দৈর্ঘ্য 1, 2, 4, 8, 16, … — দুইয়ের ঘাত। প্রতিটি শুরুর অবস্থান i-র জন্য এমন দৈর্ঘ্য মাত্র ⌊log₂ n⌋ + 1 টি। তাই মোট ঘর প্রায় n log n — 106 উপাদানে প্রায় দুই কোটি, যা মেমরিতে ধরে।
Sparse table-এ st[k][i] = a[i], a[i+1], …, a[i + 2^k − 1]-এর সর্বনিম্ন, অর্থাৎ half-open interval [i, i + 2^k)-এর উত্তর। সারি k = 0-তে শুধু অ্যারেটি নিজে: st[0][i] = a[i].
পরের সারিগুলো বানাতে আবার পুরো অংশ ঘুরতে হয় না। 2k দৈর্ঘ্যের একটি অংশকে ঠিক মাঝখানে কাটলে দুটি 2k−1 দৈর্ঘ্যের অংশ পাওয়া যায়, আর তাদের উত্তর আগের সারিতে আগেই আছে:
এটি আসলে একটি ছোট dynamic programming: বড় সমস্যার উত্তর দুটি ছোট, আগে সমাধান করা সমস্যা থেকে। প্রতিটি ঘর O(1)-এ ভরে। নিচের ডায়াগ্রামে আটটি দিনের দাম (a = 5, 2, 4, 7, 1, 3, 6, 8) দিয়ে পুরো টেবিলটি বানানো দেখুন:
| এই ধাপের সারি | |
| উদাহরণ ঘর | |
| ঘরের মান | |
| সারিতে ঘর (n − 2^k + 1) |
- সারি k = 0. দৈর্ঘ্য ১-এর অংশের সর্বনিম্ন মানে উপাদানটি নিজেই:
st[0] = a. আটটি ঘর। - সারি k = 1. দৈর্ঘ্য ২-এর প্রতিটি অংশ = দুটি পাশাপাশি দৈর্ঘ্য-১ অংশ। যেমন
st[1][3]= a[3..4]-এর min = min(7, 1) = 1. শেষ শুরুর অবস্থান i = 6, কারণ i + 2 ≤ 8 হতে হবে — তাই সাতটি ঘর। - সারি k = 2. দৈর্ঘ্য ৪ = দুটি দৈর্ঘ্য-২ অংশ, যাদের শুরু i আর i + 2.
st[2][1]= min(st[1][1], st[1][3]) = min(2, 1) = 1 — অর্থাৎ a[1..4] = 2, 4, 7, 1-এর min. মূল অ্যারে আর দেখতে হয়নি, শুধু আগের সারির দুটি ঘর। - সারি k = 3. দৈর্ঘ্য ৮ — পুরো অ্যারে।
st[3][0]= min(st[2][0], st[2][4]) = min(2, 1) = 1. একটিই ঘর, কারণ 23 = n. - সম্পূর্ণ. সারি ⌊log₂ 8⌋ + 1 = ৪টি, ঘর 8 + 7 + 5 + 1 = ২১টি — n(⌊log₂ n⌋ + 1) = ৩২-এর কম। প্রতিটি ঘর O(1)-এ, তাই বানাতে Θ(n log n) সময় আর জায়গা। এখন প্রশ্নের পালা।
Pseudocode — sparse table build (0-based, rows k = 0 .. LOG)
LOG = floor(log2(n))
for i = 0 to n-1: st[0][i] = a[i]
for k = 1 to LOG:
for i = 0 to n - 2^k: st[k][i] = min(st[k-1][i], st[k-1][i + 2^(k-1)])জটিলতার হিসাব নিখুঁতভাবে: সারি k-তে n − 2k + 1 টি ঘর, k = 0 থেকে ⌊log₂ n⌋ পর্যন্ত। যোগফল ≤ n(⌊log₂ n⌋ + 1), আর k ≤ ⌊log₂ n⌋ − 1 সারিগুলোর প্রতিটিতে অন্তত n/2 টি ঘর — তাই Θ(n log n) সময়, আর একই পরিমাণ জায়গা। লুপের ক্রম লক্ষ করুন: বাইরের লুপ k, ভেতরের লুপ i. উল্টো করলে st[k−1][i + 2^(k−1)] তখনো তৈরি না-ও হতে পারে।
৩ · O(1)-এ সর্বনিম্ন — দুটি ওভারল্যাপ করা ব্লক
এখন প্রশ্ন [l, r] (দুই প্রান্তসহ)। এর দৈর্ঘ্য len = r − l + 1 সাধারণত দুইয়ের ঘাত নয় — যেমন ৫. কৌশলটি: সবচেয়ে বড় দুইয়ের ঘাত 2k নিন, যা len-এর চেয়ে বড় নয় (k = ⌊log₂ len⌋). এরপর দুটি ব্লক:
- ব্লক A: l থেকে শুরু, দৈর্ঘ্য 2k —
st[k][l] - ব্লক B: r-এ শেষ, দৈর্ঘ্য 2k — শুরু r − 2k + 1, তাই
st[k][r − 2^k + 1]
উত্তর = min(A, B). মাত্র দুটি ঘর পড়া, একটি তুলনা — O(1), len যত বড়ই হোক।
উপমা — দুটি ত্রিপল দিয়ে উঠান ঢাকা। বৃষ্টির আগে একটি ৫ হাত লম্বা উঠান ঢাকতে হবে, কিন্তু দোকানে শুধু ১, ২, ৪, ৮ হাতের ত্রিপল। দুটি ৪ হাতের ত্রিপল নিন — একটি বাঁ প্রান্ত থেকে, একটি ডান প্রান্ত থেকে। মাঝখানে ৩ হাত দুটিই ঢেকে রাখবে, কিন্তু তাতে কোনো ক্ষতি নেই: উঠানের কোনো অংশ ভিজবে না, আর বাইরেও কিছু বেরিয়ে থাকবে না। “উঠানের কোথাও কি পানি জমেছে?” — এই প্রশ্নের উত্তরে দুবার দেখা জায়গা কোনো ভুল ঘটায় না। কিন্তু “উঠানে মোট কত ইট” গুনলে মাঝের ইটগুলো দুবার গোনা হবে।
| প্রশ্ন | |
| ব্লক A আর B | |
| ওভারল্যাপ | |
| উত্তর |
- min[0, 2]. len = 3, k = 1 (2 ≤ 3 < 4). ব্লক A = [0, 1], B = [1, 2]; ঘর 1 দুটিতেই। min(st[1][0], st[1][1]) = min(2, 2) = 2. ঠিক: 5, 2, 4-এর সর্বনিম্ন ২.
- min[2, 6]. len = 5, k = 2. A = [2, 5], B = [3, 6] — তিনটি ঘর দুবার ঢাকা। min(1, 1) = 1. ঠিক: 4, 7, 1, 3, 6-এর সর্বনিম্ন ১.
- min[5, 7]. len = 3, k = 1. A = [5, 6] থেকে 3, B = [6, 7] থেকে 6. উত্তর 3 — সবচেয়ে ছোটটি A-তে ছিল, B-তে নয়; দুটি ব্লক মিলে পুরো রেঞ্জ ঢাকে বলেই এটি ধরা পড়ে।
- min[3, 3]. len = 1, k = 0. A আর B একই ঘর — st[0][3] = 7. এক-ঘরের প্রশ্নও একই সূত্রে চলে, আলাদা শর্ত লাগে না।
- যোগফলে একই কৌশল. ধরুন st-তে min-এর বদলে যোগফল রাখলাম। sum[2, 6]-এ A = 4 + 7 + 1 + 3 = 15, B = 7 + 1 + 3 + 6 = 17, যোগ 32. কিন্তু আসল যোগফল ২১ — ওভারল্যাপের 7, 1, 3 দুবার গোনা হয়েছে। min(x, x) = x, কিন্তু x + x ≠ x.
- যোগফলের সঠিক উপায়. len = 5-কে বাইনারিতে লিখুন: 101 = 4 + 1. বাঁ থেকে শুরু করে প্রথমে দৈর্ঘ্য ৪-এর ব্লক [2, 5] = 15, তারপর দৈর্ঘ্য ১-এর ব্লক [6, 6] = 6. ব্লকগুলো আলাদা (disjoint), তাই 15 + 6 = 21 ঠিক। ব্লকের সংখ্যা = len-এর বাইনারিতে 1-এর সংখ্যা ≤ ⌊log₂ n⌋ + 1, তাই O(log n).
প্রমাণ — দুটি ব্লক কেন ঠিক পুরো রেঞ্জটি ঢাকে
দাবি: k = ⌊log₂ len⌋ হলে ব্লক A = [l, l + 2k − 1] আর B = [r − 2k + 1, r]-এর মিলন ঠিক [l, r].
যুক্তি: k-এর সংজ্ঞায় 2k ≤ len < 2k+1. (ক) দুটি ব্লকই রেঞ্জের ভেতরে: A শেষ হয় l + 2k − 1 ≤ l + len − 1 = r-এ, আর B শুরু হয় r − 2k + 1 ≥ r − len + 1 = l-এ। (খ) মাঝে কোনো ফাঁক নেই: A-র পরের ঘর l + 2k, আর B শুরু r − 2k + 1 = l + len − 2k. যেহেতু len < 2 · 2k, তাই l + len − 2k < l + 2k — B শুরু হয় A শেষ হওয়ার আগেই বা ঠিক পরে। তাই [l, r]-এর প্রতিটি ঘর অন্তত একটি ব্লকে, আর বাইরের কোনো ঘর কোনো ব্লকে নেই। min-এর জন্য min(A ∪ B) = min(min A, min B), ওভারল্যাপ থাকলেও। ∎
Idempotent অপারেশন — কোন প্রশ্নে O(1) চলে
শেষ বাক্যটিতেই সব: একটি ঘর দুবার নিলেও উত্তর বদলায় না। যে অপারেশনে f(x, x) = x, তাকে বলে idempotent operation. সাথে লাগে associativity (কোন ক্রমে জোড়া হলো তাতে কিছু যায় আসে না)। এই দুই শর্ত মানে এমন অপারেশন:
| অপারেশন | f(x, x) = x? | Sparse table-এ প্রশ্ন |
|---|---|---|
| min, max | হ্যাঁ | O(1) |
| gcd, lcm | হ্যাঁ (gcd(x, x) = x) | O(1) টেবিল-পড়া (gcd-এর নিজের খরচ O(log V) আলাদা) |
| bitwise AND, OR | হ্যাঁ | O(1) |
| যোগ, গুণ, XOR | না (x + x = 2x, x ⊕ x = 0) | O(log n), আলাদা ব্লকে ভেঙে |
যোগ আর XOR-এর জন্য অবশ্য prefix কৌশলই ভালো (O(1)), কারণ তাদের উল্টো আছে। Sparse table-এর আসল জায়গা তাই ঠিক যেখানে prefix ব্যর্থ: min, max, gcd — উল্টো নেই, কিন্তু idempotent.
log আগে থেকে হিসাব করে রাখা
প্রতিটি প্রশ্নে k = ⌊log₂ len⌋ লাগে। floating-point-এর log2() ফাংশন ধীর, আর 2k-এর ঠিক কাছে rounding-এর ভুলে এক কম বা বেশি দিতে পারে — off-by-one-এর ক্লাসিক উৎস। নিরাপদ উপায়: একটি ছোট অ্যারে আগে থেকে বানান, lg[1] = 0 আর lg[x] = lg[x / 2] + 1, O(n)-এ। C++20-এ বিকল্প std::bit_width(len) − 1 (বা GCC-র __lg(len)), Python-এ len.bit_length() − 1 — দুটিই পূর্ণসংখ্যার হিসাব, তাই নির্ভুল।
পুরো কোড — sparse table, brute force-এর সাথে মিলিয়ে
নিচের প্রোগ্রাম ওপরের অ্যারের টেবিল ছাপে, চারটি প্রশ্নের উত্তর দেয়, তারপর ২০০টি এলোমেলো অ্যারেতে মোট ৪,০০০টি এলোমেলো প্রশ্নে sparse table-এর উত্তর সরাসরি হেঁটে বের করা উত্তরের (brute force) সাথে মেলায় — এটিই stress testing. এলোমেলো সংখ্যা আসে একটি ছোট নিজস্ব জেনারেটর থেকে (linear congruential), যাতে C++ আর Python ঠিক একই ইনপুট পায়।
#include <algorithm>
#include <cstdio>
#include <utility>
#include <vector>
using namespace std;
struct SparseMin {
vector<int> lg; // lg[x] = floor(log2 x)
vector<vector<int>> st; // st[k][i] = min of a[i .. i + 2^k - 1]
SparseMin(const vector<int>& a) {
int n = a.size(); lg.assign(n + 1, 0);
for (int x = 2; x <= n; ++x) lg[x] = lg[x / 2] + 1;
st.assign(lg[n] + 1, {}); st[0] = a;
for (int k = 1; k <= lg[n]; ++k)
for (int i = 0; i + (1 << k) <= n; ++i)
st[k].push_back(min(st[k - 1][i], st[k - 1][i + (1 << (k - 1))]));
}
int query(int l, int r) const { // inclusive [l, r], O(1)
int k = lg[r - l + 1]; return min(st[k][l], st[k][r - (1 << k) + 1]);
}
};
unsigned long long seed = 12345;
int rnd(int m) { seed = (seed * 1103515245 + 12345) % 2147483648ULL; return int(seed % m); }
int main() {
vector<int> a = {5, 2, 4, 7, 1, 3, 6, 8};
SparseMin s(a);
for (size_t k = 0; k < s.st.size(); ++k) {
printf("k=%zu:", k); for (int x : s.st[k]) printf(" %d", x); printf("\n");
}
for (auto [l, r] : vector<pair<int, int>>{{0, 2}, {2, 6}, {5, 7}, {3, 3}}) printf("min[%d,%d] = %d\n", l, r, s.query(l, r));
int bad = 0, tests = 0;
for (int t = 0; t < 200; ++t) { // stress test against brute force
vector<int> b(1 + rnd(50));
for (int& x : b) x = rnd(100);
SparseMin sb(b);
for (int q = 0; q < 20; ++q, ++tests) {
int l = rnd(b.size()), r = rnd(b.size()); if (l > r) swap(l, r);
bad += sb.query(l, r) != *min_element(b.begin() + l, b.begin() + r + 1);
}
}
printf("random queries: %d, mismatches: %d\n", tests, bad);
}আউটপুট:
k=0: 5 2 4 7 1 3 6 8
k=1: 2 2 4 1 1 3 6
k=2: 2 1 1 1 1
k=3: 1
min[0,2] = 2
min[2,6] = 1
min[5,7] = 3
min[3,3] = 7
random queries: 4000, mismatches: 0
প্রথম চার লাইন ডায়াগ্রামের চারটি সারি — শুধু বৈধ ঘরগুলো (সারি k-তে n − 2k + 1 টি), কারণ প্রতিটি সারি push_back দিয়ে ঠিক ততটুকু বানানো। চারটি প্রশ্নের উত্তর 2, 1, 3, 7 — দ্বিতীয় ডায়াগ্রামের প্রথম চার ধাপ। শেষ লাইন বলছে ৪,০০০টি এলোমেলো প্রশ্নে একটিও অমিল নেই; অ্যারের আকার ১ থেকে ৫০, তাই n = 1 (একটিই সারি) আর এক-ঘরের প্রশ্নের মতো edge case-ও পরীক্ষায় এসেছে। query-তে r - (1 << k) + 1-এর “+ 1” বাদ দিলে এই পরীক্ষাই সাথে সাথে অমিল দেখাবে — চেষ্টা করে দেখুন। বানাতে Θ(n log n), প্রতিটি প্রশ্ন O(1).
class SparseMin:
def __init__(self, a):
n = len(a)
self.lg = [0] * (n + 1) # lg[x] = floor(log2 x)
for x in range(2, n + 1):
self.lg[x] = self.lg[x // 2] + 1
self.st = [a[:]] # st[k][i] = min of a[i .. i + 2^k - 1]
for k in range(1, self.lg[n] + 1):
prev, h = self.st[k - 1], 1 << (k - 1)
self.st.append([min(prev[i], prev[i + h]) for i in range(n - (1 << k) + 1)])
def query(self, l, r): # inclusive [l, r], O(1)
k = self.lg[r - l + 1]
return min(self.st[k][l], self.st[k][r - (1 << k) + 1])
a = [5, 2, 4, 7, 1, 3, 6, 8]
s = SparseMin(a)
for k, row in enumerate(s.st):
print(f"k={k}:", *row)
for l, r in [(0, 2), (2, 6), (5, 7), (3, 3)]:
print(f"min[{l},{r}] = {s.query(l, r)}")
seed = 12345
def rnd(m): # same LCG as the C++ version
global seed
seed = (seed * 1103515245 + 12345) % 2147483648
return seed % m
bad = tests = 0
for t in range(200): # stress test against brute force
b = [rnd(100) for _ in range(1 + rnd(50))]
sb = SparseMin(b)
for q in range(20):
l, r = sorted((rnd(len(b)), rnd(len(b))))
bad += sb.query(l, r) != min(b[l:r + 1])
tests += 1
print(f"random queries: {tests}, mismatches: {bad}")আউটপুট C++-এর সাথে হুবহু এক:
k=0: 5 2 4 7 1 3 6 8
k=1: 2 2 4 1 1 3 6
k=2: 2 1 1 1 1
k=3: 1
min[0,2] = 2
min[2,6] = 1
min[5,7] = 3
min[3,3] = 7
random queries: 4000, mismatches: 0
Python-এ প্রতিটি সারি একটি list comprehension: range(n - (1 << k) + 1) ঠিক বৈধ শুরুর অবস্থানগুলো দেয়। Brute force এখানে এক লাইনের min(b[l:r + 1]) — slicing-এর শেষ সীমা বাদ যায় বলে r + 1. এলোমেলো সংখ্যা নেওয়ার ক্রম (আগে অ্যারের দৈর্ঘ্য, তারপর মানগুলো, তারপর l আর r) C++-এর সাথে এক, তাই দুই ভাষা ঠিক একই ৪,০০০টি প্রশ্ন পরীক্ষা করে। বড় n-এ Python-এর প্রতিটি সারি একটি আলাদা list হওয়ায় জায়গা লাগে বেশি; প্রতিযোগিতায় n = 106 হলে C++ বা array মডিউল বিবেচনা করুন।
৪ · RMQ-এর প্রয়োগ — LCA থেকে suffix array পর্যন্ত
“একটি রেঞ্জের সর্বনিম্ন” — প্রশ্নটি নিরীহ শোনায়, কিন্তু range minimum query (RMQ) অ্যালগরিদমের জগতে একটি কেন্দ্রীয় হাতিয়ার, কারণ অনেক আপাত-ভিন্ন সমস্যা এতে রূপান্তরিত হয়। প্রায়ই সর্বনিম্ন মানের বদলে তার অবস্থান (index) দরকার হয় — তখন টেবিলে index রাখুন আর তুলনা করুন a[x] < a[y] দিয়ে।
LCA — tree-র প্রশ্নকে অ্যারের প্রশ্নে বদলানো
একটি tree-তে দুটি node u আর v-এর lowest common ancestor (LCA) হলো সবচেয়ে গভীর node, যে দুজনেরই পূর্বপুরুষ। একটি পরিবারের বংশতালিকায় “দুই চাচাতো ভাইয়ের সবচেয়ে কাছের সাধারণ দাদা কে?” — এই প্রশ্ন। পর্ব ৪৮-এ binary lifting দিয়ে এর O(log n) সমাধান দেখবেন; এখানে একটি ঝলক, কীভাবে sparse table একে O(1) করে।
Tree-তে root থেকে DFS চালান, আর প্রতিটি node-এ ঢোকার সময় এবং প্রতিটি সন্তান থেকে ফিরে আসার সময় সেই node-টি লিখে রাখুন। এই ক্রমটির নাম Euler tour; n টি node-এ এর দৈর্ঘ্য 2n − 1. প্রতিটি অবস্থানে node-এর গভীরতাও লিখুন। ধরুন root 0-এর দুই সন্তান 1 আর 2, আর 1-এর দুই সন্তান 3 আর 4:
pos : 0 1 2 3 4 5 6 7 8
node : 0 1 3 1 4 1 0 2 0
depth : 0 1 2 1 2 1 0 1 0
first[3] = 2, first[4] = 4 -> min depth on [2, 4] at pos 3 -> LCA(3, 4) = 1
first[3] = 2, first[2] = 7 -> min depth on [2, 7] at pos 6 -> LCA(3, 2) = 0
কেন কাজ করে? u থেকে v-তে DFS যে পথে হাঁটে, তা u আর v-এর LCA-র ওপর দিয়ে যায়, কিন্তু LCA-র ওপরে কখনো ওঠে না — কারণ LCA-র subtree থেকে বেরোনোর আগেই v-তে পৌঁছানো যায়। তাই first[u] থেকে first[v]-এর মধ্যে সবচেয়ে কম গভীরতার node-টিই LCA. Euler tour বানাতে O(n), গভীরতার অ্যারেতে sparse table O(n log n), তারপর প্রতিটি LCA প্রশ্ন O(1). হাজার হাজার LCA প্রশ্নের সমস্যায় (যেমন tree-তে দুই node-এর দূরত্ব = depth[u] + depth[v] − 2·depth[LCA]) এটি খুব কাজের।
আরও কিছু RMQ
- Suffix array-তে LCP: পর্ব ৫৯-এ দেখবেন, দুটি suffix-এর longest common prefix = LCP অ্যারের একটি রেঞ্জের সর্বনিম্ন। String-এর বহু প্রশ্ন এভাবে O(1)-এ।
- Sliding window-এর সর্বনিম্ন: সব জানালা একই দৈর্ঘ্যের হলে monotonic deque (পর্ব ১৬) O(n)-এ যথেষ্ট; কিন্তু জানালার দৈর্ঘ্য প্রশ্নভেদে আলাদা হলে sparse table.
- উত্তরের ওপর binary search: “l থেকে শুরু করে সবচেয়ে লম্বা কোন অংশের gcd এখনো ১-এর বেশি?” — gcd যত ডানে যাবেন ততই কমে বা একই থাকে (monotonic), তাই r-এর ওপর binary search (পর্ব ১৯), প্রতিটি পরীক্ষা sparse table-এ O(1).
৫ · Sqrt decomposition — √n আকারের ব্লক
Sparse table দ্রুত, কিন্তু একটি দুর্বলতা মারাত্মক: একটি মান বদলালেই অনেক ঘর বদলাতে হয়। a[p] যে যে অংশে আছে, তাদের সবাই — সারি k-তে 2k টি পর্যন্ত, সব সারি মিলে প্রায় 2n টি। একটি আপডেট O(n). তাই আড়তদার যদি প্রতিদিন নতুন দাম যোগ করেন বা পুরোনো ভুল সংশোধন করেন, আমাদের অন্য কিছু লাগবে।
উপমা — স্কুলের হাজিরা খাতা। একটি স্কুলে ৯০০ জন ছাত্রছাত্রী, ৩০টি সেকশনে ৩০ জন করে। প্রধান শিক্ষক জানতে চান “রোল ১৭ থেকে ৮২০ পর্যন্ত আজ কতজন উপস্থিত?” প্রত্যেকের নাম ধরে গোনার দরকার নেই: প্রতিটি সেকশনের শিক্ষক তাঁর সেকশনের মোট উপস্থিতি আগেই লিখে রেখেছেন। শুধু দুই প্রান্তের দুটি অসম্পূর্ণ সেকশনে আলাদা করে গুনুন, আর মাঝের পূর্ণ সেকশনগুলোর মোট যোগ করুন। একজন দেরিতে এলে শুধু তাঁর সেকশনের মোট এক বাড়ে।
এটিই sqrt decomposition. অ্যারেকে B আকারের ব্লকে ভাগ করুন (শেষ ব্লক ছোট হতে পারে); উপাদান i আছে ব্লক ⌊i / B⌋-তে। প্রতিটি ব্লকের একটি সারাংশ রাখুন — যোগফল, সর্বনিম্ন, বা যা দরকার। B = √n হলে ব্লক প্রায় √n টি, প্রতিটিতে প্রায় √n টি উপাদান।
Pseudocode — sqrt decomposition, range sum (0-based, inclusive [l, r])
build: for i = 0 to n-1: sum[i / B] += a[i] // O(n)
query(l, r):
s = 0
while l <= r and l % B != 0: s += a[l]; l += 1 // left partial block
while l + B - 1 <= r: s += sum[l / B]; l += B // full blocks
while l <= r: s += a[l]; l += 1 // right partial block
return s
update(i, v): sum[i / B] += v - a[i]; a[i] = v // O(1)| এই ধাপের কাজ | |
| চলমান যোগফল | |
| এই প্রশ্নে ছোঁয়া ঘর |
- Build. ৯টি উপাদান, B = √9 = 3, তিনটি ব্লক। যোগফল 3 + 1 + 4 = 8, 1 + 5 + 9 = 15, 2 + 6 + 5 = 13; সর্বনিম্ন 1, 1, 2. একবার পুরো অ্যারে ঘুরে — O(n).
- বাঁ প্রান্ত. প্রশ্ন sum[1, 7]. ঘর 1 ব্লক 0-এর মাঝখানে, তাই ব্লক 0 পুরো নেওয়া যাবে না — ঘর 1 আর 2 আলাদা করে: 1 + 4 = 5. ঘর 3-এ পৌঁছে আমরা একটি ব্লকের শুরুতে।
- পূর্ণ ব্লক. ব্লক 1 (ঘর 3–5) পুরোটাই রেঞ্জের ভেতরে, তাই তিনটি ঘর না পড়ে শুধু সারাংশ 15 নিন: 5 + 15 = 20. একটি ঘর পড়েই তিনটি উপাদানের কাজ।
- ডান প্রান্ত. ব্লক 2 পুরো ভেতরে নেই (ঘর 8 বাইরে), তাই ঘর 6 আর 7 আলাদা: 2 + 6 = 8, মোট 28. যাচাই: 1 + 4 + 1 + 5 + 9 + 2 + 6 = 28 ✓. ছোঁয়া ঘর ৫টি; দুই প্রান্তে সর্বোচ্চ 2(B − 1), মাঝে সর্বোচ্চ n/B টি সারাংশ।
- Point update. a[4] = 5 থেকে 0. যোগফলের সারাংশে শুধু পার্থক্যটি: sum[1] = 15 − 5 + 0 = 10 — O(1). কিন্তু সর্বনিম্নের এমন সহজ সংশোধন নেই (min থেকে পুরোনো মান “বাদ” দেওয়া যায় না, অংশ ১ মনে করুন), তাই ব্লক 1 পুরোটা আবার দেখে min = min(1, 0, 9) = 0 — O(B) = O(√n).
- আবার প্রশ্ন. sum[1, 7] = 5 + 10 + 8 = 23. বদলে যাওয়া উপাদানটি মাঝের পূর্ণ ব্লকে ছিল, কিন্তু আমরা ঘরটি পড়িইনি — হালনাগাদ সারাংশই ঠিক উত্তর দিল। প্রস্তুতি আবার করতে হয়নি, যা sparse table-এ লাগত।
জটিলতা: প্রশ্নে দুই প্রান্তে মোট সর্বোচ্চ 2(B − 1) টি ঘর আর মাঝে সর্বোচ্চ ⌈n / B⌉ টি ব্লক — O(B + n/B). B = √n হলে O(√n). যোগফলের point update O(1), min/max-এর point update O(B) = O(√n). জায়গা: অ্যারে ছাড়া মাত্র O(n/B) = O(√n) বাড়তি। n = 105-এ √n ≈ 316, তাই একটি প্রশ্নে প্রায় ৯০০টি ধাপ — log₂ n ≈ 17-এর চেয়ে অনেক বেশি, কিন্তু 105 প্রশ্নে মোট প্রায় 108 সরল ধাপ, যা সাধারণত এক সেকেন্ডের কাছাকাছি সময়ে চলে।
৬ · রেঞ্জ আপডেট, ব্লকের মাপ আর কখন sqrt জেতে
রেঞ্জ আপডেট — ব্লকের গায়ে “বাকি কাজ” লিখে রাখা
এখন আপডেটও একটি রেঞ্জে: “ঘর l থেকে r পর্যন্ত সবার সাথে v যোগ করুন” (range update) — যেমন একটি এলাকার সব দোকানে ভ্যাট বাড়লে সব দামে একই পরিমাণ যোগ। প্রতিটি ঘর বদলালে O(n). প্রশ্নের মতোই ভাগ করুন: দুই প্রান্তের অসম্পূর্ণ ব্লকে ঘর ধরে ধরে যোগ করুন (আর সেই ব্লকের সারাংশও হালনাগাদ), কিন্তু মাঝের প্রতিটি পূর্ণ ব্লকে শুধু একটি সংখ্যা লিখে রাখুন: lazy[b] += v — “এই ব্লকের সবার সাথে v যোগ হওয়ার কথা, এখনো করা হয়নি”. এটিই lazy propagation-এর সরলতম রূপ (segment tree-তে এর পূর্ণ রূপ পর্ব ৩৫-এ)।
Pseudocode — range add, range sum with block lazy values (0-based)
add(l, r, v):
for each i in a partial block: a[i] += v; sum[i / B] += v
for each full block b: lazy[b] += v
query(l, r):
partial cell i contributes a[i] + lazy[i / B]
full block b contributes sum[b] + lazy[b] * (size of block b)দুটি কাজই O(B + n/B) = O(√n). সর্বনিম্নের রেঞ্জ-যোগেও একই কৌশল চলে, কারণ সবার সাথে v যোগ করলে min-ও ঠিক v বাড়ে: পূর্ণ ব্লকের উত্তর mn[b] + lazy[b]. কিন্তু অসম্পূর্ণ ব্লকে কিছু ঘর বদলালে সেই ব্লকের mn আবার হিসাব করতে হয় (O(B)) — আর তার আগে সেই ব্লকের lazy মান ঘরগুলোতে নামিয়ে দেওয়া সহজ নিয়ম। “রেঞ্জের সব মান x-এ বসাও” (assign) ধরনের আপডেটেও একই ধারণা, শুধু lazy-তে যোগের বদলে একটি “বসানো মান” রাখুন।
ব্লকের মাপ বেছে নেওয়া — দুই খরচের ভারসাম্য
B = √n কেন? একটি প্রশ্নের খরচ প্রায় f(B) = B + n/B (প্রান্তে B পর্যন্ত, মাঝে n/B পর্যন্ত)। B ছোট হলে ব্লক অনেক — মাঝের অংশ দামি; B বড় হলে প্রান্ত দামি। AM–GM অসমতা বলে B + n/B ≥ 2√n, আর সমতা ঠিক B = √n-এ। (ক্যালকুলাসে: f′(B) = 1 − n/B² = 0.)
কিন্তু খরচ সবসময় এমন প্রতিসম নয়। ধরুন u টি min-এর point update (প্রতিটি O(B), ব্লক নতুন করে হিসাব) আর q টি প্রশ্ন (প্রতিটি O(B + n/B)). মোট ≈ (u + q)B + qn/B; ভারসাম্যে B = √(qn / (u + q)). প্রায় সবই আপডেট হলে B ছোট রাখুন; প্রায় সবই প্রশ্ন হলে B = √n-এর কাছে। বাস্তবে আরও দুটি কথা: (১) ধ্রুবক গুণক — প্রান্তের ঘর-পড়া খুব সস্তা আর cache-বান্ধব, ব্লকের সারাংশ পড়া একটু কম, তাই কখনো √n-এর চেয়ে কিছুটা বড় B দ্রুততর হয়; (২) B-কে দুইয়ের ঘাত (যেমন 256 বা 512) নিলে i / B একটি shift-এ হয়। প্রতিযোগিতায় সাধারণ অভ্যাস: n = 105-এ B ≈ 300–400, তারপর মেপে দেখা।
কখন sqrt log-এর স্ট্রাকচারকে হারায়
O(√n) কাগজে O(log n)-এর চেয়ে খারাপ, তবু sqrt decomposition কনটেস্ট আর বাস্তবে টিকে আছে তিনটি কারণে:
- সরলতা: দশ-পনেরো লাইনে লেখা যায়, ভুলের জায়গা কম। সময়ের চাপে segment tree-র lazy-র ভুলের চেয়ে sqrt-এর একটি পরিষ্কার সমাধান প্রায়ই নিরাপদ।
- নমনীয় অপারেশন: segment tree-তে দুটি সন্তানের উত্তর জুড়ে বাবার উত্তর বানাতে পারতে হয়। কিন্তু “রেঞ্জে x-এর চেয়ে ছোট কয়টি মান, মাঝে মাঝে মান বদলায়” — প্রতিটি ব্লক সাজিয়ে রাখুন; পূর্ণ ব্লকে binary search (O(log B)), প্রান্তে সরাসরি গোনা; প্রশ্ন O((n/B) log B + B), আপডেট মানে একটি ব্লক আবার সাজানো। একইভাবে ব্লকের মধ্যে অন্য কোনো ছোট স্ট্রাকচার রাখা যায় — জোড়া লাগানোর নিয়ম না জানলেও চলে।
- মাঝারি n আর cache: ব্লকগুলো মেমরিতে পাশাপাশি, প্রান্তের হাঁটা ক্রমিক — আধুনিক CPU-তে এটি খুব দ্রুত, তাই n = 105-এ পার্থক্য প্রায়ই তত্ত্বের চেয়ে কম।
Sqrt-এর ধারণা শুধু অ্যারের ব্লকে সীমিত নয়। প্রশ্নের ওপর sqrt: আপডেটগুলো একটি ছোট তালিকায় জমা রাখুন, প্রতিটি প্রশ্নে স্থির স্ট্রাকচারের উত্তরের সাথে তালিকার আপডেটগুলোর প্রভাব হাতে মিলিয়ে নিন, আর তালিকা √q-এর বেশি লম্বা হলে পুরো স্ট্রাকচার নতুন করে বানান। Heavy/light ভাগ: গ্রাফে √m-এর বেশি degree-র vertex অল্প (সর্বোচ্চ 2√m টি), তাই তাদের আলাদাভাবে সামলান — ত্রিভুজ গোনার মতো সমস্যায় এটি O(m√m) দেয়। মূল দর্শন একই: দুই ধরনের খরচকে √ সীমায় ভারসাম্যে আনা।
৭ · Mo’s algorithm — প্রশ্নগুলো সাজিয়ে নিলে
এবার একটি কঠিন প্রশ্ন: “a[l..r]-এ কয়টি আলাদা মান আছে?” — যেমন একটি মোবাইল ব্যাংকিং অ্যাপের লেনদেনের লগে “সকাল ৯টা থেকে দুপুর ২টা পর্যন্ত কতজন আলাদা গ্রাহক লেনদেন করেছেন?” এটি segment tree-তে সহজে জোড়া যায় না: বাঁ অর্ধেকে ৩টি আলাদা, ডান অর্ধেকে ৪টি — মোট কত? জানা নেই, কারণ দুই পাশে একই গ্রাহক থাকতে পারেন। Sparse table-ও নয় — অপারেশনটি idempotent নয়।
কিন্তু একটি জিনিস সহজ: একটি জানালা [L, R]-এর উত্তর জানা থাকলে, জানালা এক ঘর বাড়ানো বা কমানোর পর নতুন উত্তর O(1)-এ। একটি frequency array cnt[x] রাখুন: একটি মান যোগ হলে cnt[x] ০ থেকে ১ হলে আলাদা মান এক বাড়ে; সরালে ১ থেকে ০ হলে এক কমে। তাহলে প্রতিটি প্রশ্নে জানালার দুই প্রান্ত (L আর R pointer) টেনে নতুন রেঞ্জে নিয়ে যান। সমস্যা: প্রশ্নগুলো এলোমেলো ক্রমে এলে pointer প্রতিবার পুরো অ্যারে জুড়ে লাফায় — প্রতি প্রশ্নে O(n), মোট O(nq).
উপমা — ডেলিভারি রাইডারের রুট। একজন রাইডারের হাতে ঢাকার ৩০টি ঠিকানা। অর্ডার যে ক্রমে এসেছে সেই ক্রমে গেলে তিনি মিরপুর-মতিঝিল-উত্তরা-ধানমন্ডি করে সারা দিন জ্যামে কাটাবেন। বুদ্ধিমান রাইডার আগে এলাকা অনুযায়ী ঠিকানাগুলো ভাগ করেন, প্রতিটি এলাকার ভেতরে একটি দিকে এগিয়ে যান — মোট পথ অনেক কমে যায়। শর্ত একটাই: সব ঠিকানা আগে থেকে হাতে থাকতে হবে।
Mo’s algorithm ঠিক এটিই করে। সব q টি প্রশ্ন আগে পড়ে নিন — অর্থাৎ এটি একটি offline algorithm, যা প্রশ্নের উত্তর তাদের নিজের ক্রমে না দিয়ে সুবিধামতো ক্রমে দেয় (উল্টোটি online, যেখানে আগের উত্তর না দিলে পরের প্রশ্ন আসে না)। তারপর:
- ব্লকের মাপ B ≈ √n নিন। প্রশ্ন (l, r)-এর চাবি = (⌊l / B⌋, r).
- প্রশ্নগুলো এই চাবি অনুযায়ী সাজান: আগে l-এর ব্লক, একই ব্লকে r বাড়ার ক্রমে।
- খালি জানালা (L = 0, R = −1) থেকে শুরু করে প্রতিটি প্রশ্নে আগে জানালা বাড়ান, তারপর কমান: R ডানে, L বাঁয়ে, R বাঁয়ে, L ডানে — প্রতিটি সরানোয় add বা remove.
- উত্তর জমা রাখুন প্রশ্নের মূল index-এ, যাতে শেষে মূল ক্রমে ছাপা যায়।
| এই ধাপের প্রশ্ন | |
| জানালা [L, R] | |
| আলাদা মান | |
| pointer সরানো: এই ধাপে / মোট | |
| উত্তর (মূল ক্রমে q0..q3) |
- সাজানো. n = 8, এখানে B = ⌈√8⌉ = 3. প্রশ্ন q0 = [0, 4], q1 = [1, 3], q2 = [5, 6], q3 = [2, 6]. চাবি (⌊l/3⌋, r): q0 → (0, 4), q1 → (0, 3), q2 → (1, 6), q3 → (0, 6). সাজানোর পর ক্রম q1, q0, q3, q2. জানালা খালি, সব cnt শূন্য।
- q1 = [1, 3]. R −1 থেকে 3: ঘর 0, 1, 2, 3 যোগ (মান 1, 2, 1, 3). তারপর L 0 থেকে 1: ঘর 0 বাদ — cnt[1] 2 থেকে 1, এখনো শূন্য নয়, তাই আলাদা মান কমেনি। জানালা 2, 1, 3 — আলাদা মান ৩. সরানো ৫টি।
- q0 = [0, 4]. R 3 থেকে 4: ঘর 4 (মান 2) যোগ, cnt[2] = 2. L 1 থেকে 0: ঘর 0 (মান 1) আবার যোগ, cnt[1] = 2. কোনো cnt ০ থেকে ১ হয়নি, তাই আলাদা মান ৩-ই। সরানো মাত্র ২টি — একই ব্লকের প্রশ্ন পাশাপাশি থাকার লাভ।
- q3 = [2, 6]. R 4 থেকে 6: ঘর 5 (মান 2) আর ঘর 6 (মান 4) যোগ — cnt[4] ০ থেকে ১, আলাদা মান ৪. L 0 থেকে 2: ঘর 0 আর 1 বাদ, cnt[1] = 1, cnt[2] = 2 — কেউ শূন্য হয়নি। জানালা 1, 3, 2, 2, 4: আলাদা মান ৪.
- q2 = [5, 6]. নতুন ব্লক (l = 5 → ব্লক 1). R ঠিক জায়গায়। L 2 থেকে 5: ঘর 2 (মান 1, cnt[1] শূন্য → ৩), ঘর 3 (মান 3, শূন্য → ২), ঘর 4 (মান 2, cnt[2] = 1). জানালা 2, 4 — আলাদা মান ২.
- মূল ক্রমে উত্তর. q0 = 3, q1 = 3, q2 = 2, q3 = 4. মোট pointer সরানো ১৪, আর প্রতিটি প্রশ্ন আলাদা করে হাঁটলে লাগত 5 + 3 + 2 + 5 = ১৫ ঘর। এত ছোট উদাহরণে লাভ সামান্য; বড় n আর q-তে পার্থক্য বিশাল — নিচের কোডে ৪০০টি প্রশ্নে প্রায় সাড়ে পাঁচ গুণ।
কেন O((n + q)√n) — pointer কতটা হাঁটে
প্রতিটি add বা remove O(1) ধরে নিন; তাহলে মোট সময় = সাজানো O(q log q) + pointer-এর মোট সরানো। দুটি pointer আলাদা করে গুনুন, B = √n ধরে:
- R pointer: l-এর একই ব্লকের প্রশ্নগুলোতে r বাড়ার ক্রমে সাজানো, তাই সেই ব্লকের ভেতরে R শুধু ডানে যায় — মোট সর্বোচ্চ n ঘর। নতুন ব্লকে ঢোকার সময় R একবার বাঁয়ে ফিরতে পারে, সেটিও সর্বোচ্চ n. ব্লক আছে ⌈n/B⌉ টি, তাই মোট O(n · n/B) = O(n√n).
- L pointer: একই ব্লকের দুটি পরপর প্রশ্নের l দুটিই একই ব্লকে, তাই L সর্বোচ্চ B ঘর সরে — q টি প্রশ্নে O(qB). ব্লক বদলানোর সময় L সর্বোচ্চ 2B সরে (পরের ব্লকগুলো ক্রমে আসে বলে সব মিলে O(n)). মোট O(q√n + n).
যোগ করে O((n + q)√n), জায়গা O(n + q) (অ্যারে, cnt আর উত্তর)। আরও নিখুঁতভাবে খরচ O(n²/B + qB); B = n/√q নিলে O(n√q) — q যখন n-এর চেয়ে অনেক ছোট, তখন এটি ভালো। মনে রাখুন, add/remove যদি O(1) না হয়ে O(log n) হয় (যেমন একটি set-এ রাখতে হলে), মোট খরচও log n গুণ বাড়ে।
pointer সরানোর ক্রম গুরুত্বপূর্ণ। আগে বাড়ান (R ডানে, L বাঁয়ে), তারপর কমান। উল্টো করলে মাঝপথে L > R + 1 হয়ে জানালা “ঋণাত্মক” হতে পারে — একটি ঘর যোগ হওয়ার আগেই বাদ পড়ে, cnt ঋণাত্মক হয়, আর আলাদা মানের হিসাব ভুল হয়। আরেকটি সাধারণ ভুল: উত্তর সাজানো ক্রমে ছাপা, মূল ক্রমে নয়।
একটি জনপ্রিয় ছোট উন্নতি: বিজোড় নম্বরের ব্লকে r কমার ক্রমে সাজান। তাহলে এক ব্লক শেষ করে R পুরো অ্যারে পেরিয়ে বাঁয়ে ফেরার বদলে ফেরার পথেই পরের ব্লকের প্রশ্নগুলো ধরে — বাস্তবে প্রায়ই অনেকটা দ্রুত, যদিও Big-O একই।
পুরো কোড — Mo দিয়ে আলাদা মান গোনা, brute force-এর সাথে মিলিয়ে
প্রোগ্রামটি প্রথমে ওপরের উদাহরণ চালায়, তারপর ৪০০ উপাদানের একটি এলোমেলো অ্যারেতে (মান ০ থেকে ২৯) ৪০০টি এলোমেলো প্রশ্নের উত্তর প্রতিটি রেঞ্জের set-এর আকারের সাথে মেলায়। সাথে গোনে Mo-র pointer কত ঘর সরল, আর brute force মোট কত ঘর পড়ত।
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <set>
#include <tuple>
#include <utility>
#include <vector>
using namespace std;
pair<vector<int>, long long> mo_distinct(const vector<int>& a, const vector<pair<int, int>>& qs, int maxv) {
int B = 1; while (B * B < (int)a.size()) ++B; // B = ceil(sqrt(n))
vector<int> order(qs.size()), cnt(maxv + 1, 0), ans(qs.size());
for (int i = 0; i < (int)qs.size(); ++i) order[i] = i;
sort(order.begin(), order.end(), [&](int x, int y) { // key: (block of l, r, index)
return tuple(qs[x].first / B, qs[x].second, x) < tuple(qs[y].first / B, qs[y].second, y); });
int L = 0, R = -1, distinct = 0; long long moves = 0;
auto add = [&](int i) { if (cnt[a[i]]++ == 0) ++distinct; };
auto drop = [&](int i) { if (--cnt[a[i]] == 0) --distinct; };
for (int i : order) {
auto [l, r] = qs[i];
moves += abs(l - L) + abs(r - R);
while (R < r) add(++R); // grow right, grow left, then shrink
while (L > l) add(--L);
while (R > r) drop(R--);
while (L < l) drop(L++);
ans[i] = distinct;
}
return {ans, moves};
}
unsigned long long seed = 2024;
int rnd(int m) { seed = (seed * 1103515245 + 12345) % 2147483648ULL; return int(seed % m); }
int main() {
auto [ans, moves] = mo_distinct({1, 2, 1, 3, 2, 2, 4, 1}, {{0, 4}, {1, 3}, {5, 6}, {2, 6}}, 4);
printf("answers:"); for (int x : ans) printf(" %d", x); printf(" | pointer moves: %lld\n", moves);
int n = 400; vector<int> b(n); vector<pair<int, int>> qs; for (int& x : b) x = rnd(30);
for (int i = 0; i < 400; ++i) { int l = rnd(n), r = rnd(n); qs.push_back({min(l, r), max(l, r)}); }
auto [ans2, moves2] = mo_distinct(b, qs, 29);
int bad = 0; long long cells = 0;
for (int i = 0; i < 400; ++i) {
auto [l, r] = qs[i]; cells += r - l + 1;
bad += ans2[i] != (int)set<int>(b.begin() + l, b.begin() + r + 1).size(); }
printf("random queries: %d, mismatches: %d\nMo pointer moves: %lld, brute force cells: %lld\n",
(int)qs.size(), bad, moves2, cells);
}আউটপুট:
answers: 3 3 2 4 | pointer moves: 14
random queries: 400, mismatches: 0
Mo pointer moves: 9668, brute force cells: 53654
প্রথম লাইন ডায়াগ্রামের ফল: মূল ক্রমে 3 3 2 4, pointer সরানো ১৪. সাজানোর চাবিতে তৃতীয় উপাদান হিসেবে প্রশ্নের index রাখা হয়েছে, যাতে সমান (ব্লক, r)-এর প্রশ্নের ক্রমও নির্দিষ্ট থাকে — C++ আর Python তাই ঠিক একই ক্রমে চলে, আর moves-এর সংখ্যাও মেলে। এলোমেলো পরীক্ষায় ৪০০টির মধ্যে অমিল ০, আর Mo-র pointer সরেছে ৯,৬৬৮ ঘর, যেখানে প্রতিটি রেঞ্জ আলাদা করে পড়লে লাগত ৫৩,৬৫৪ ঘর। B = 20 এখানে, তাই সীমাটি মোটামুটি (n + q)√n = 800 × 20 = 16,000-এর ঘরে — বাস্তব সংখ্যাটি তার নিচে। add-এ cnt[a[i]]++ == 0 পুরোনো মান পরীক্ষা করে তারপর বাড়ায়; drop-এ --cnt[a[i]] == 0 আগে কমায় তারপর পরীক্ষা করে — ক্রমটি উল্টালে উত্তর ভুল।
def mo_distinct(a, qs, maxv): # offline: distinct values in a[l..r]
B = 1
while B * B < len(a): # B = ceil(sqrt(n))
B += 1
order = sorted(range(len(qs)), key=lambda i: (qs[i][0] // B, qs[i][1], i))
cnt, ans = [0] * (maxv + 1), [0] * len(qs)
L, R, distinct, moves = 0, -1, 0, 0
for i in order:
l, r = qs[i]
moves += abs(l - L) + abs(r - R)
while R < r: # grow right, grow left, then shrink
R += 1; cnt[a[R]] += 1; distinct += cnt[a[R]] == 1
while L > l:
L -= 1; cnt[a[L]] += 1; distinct += cnt[a[L]] == 1
while R > r:
cnt[a[R]] -= 1; distinct -= cnt[a[R]] == 0; R -= 1
while L < l:
cnt[a[L]] -= 1; distinct -= cnt[a[L]] == 0; L += 1
ans[i] = distinct
return ans, moves
a = [1, 2, 1, 3, 2, 2, 4, 1]
qs = [(0, 4), (1, 3), (5, 6), (2, 6)]
ans, moves = mo_distinct(a, qs, 4)
print("answers:", *ans, "| pointer moves:", moves)
seed = 2024
def rnd(m): # same LCG as the C++ version
global seed
seed = (seed * 1103515245 + 12345) % 2147483648
return seed % m
n = 400
b = [rnd(30) for _ in range(n)]
qs = [tuple(sorted((rnd(n), rnd(n)))) for _ in range(400)]
ans, moves = mo_distinct(b, qs, 29)
bad = sum(ans[i] != len(set(b[l:r + 1])) for i, (l, r) in enumerate(qs))
cells = sum(r - l + 1 for l, r in qs)
print(f"random queries: {len(qs)}, mismatches: {bad}")
print(f"Mo pointer moves: {moves}, brute force cells: {cells}")আউটপুট C++-এর সাথে হুবহু এক:
answers: 3 3 2 4 | pointer moves: 14
random queries: 400, mismatches: 0
Mo pointer moves: 9668, brute force cells: 53654
sorted(range(len(qs)), key=...) প্রশ্নের index-গুলোকেই সাজায়, চাবি একই (ব্লক, r, index). লুপের ভেতরে distinct += cnt[a[R]] == 1 লাইনে Python-এর True সংখ্যা হিসেবে 1 — যোগ হলে কোনো মানের গোনা ঠিক ১ হলে আলাদা মান এক বাড়ে, আর বাদ দেওয়ার পর ০ হলে এক কমে। Brute force এক লাইনে len(set(b[l:r + 1])). Python-এ Mo-র ভেতরের লুপ C++-এর চেয়ে কয়েক দশ গুণ ধীর, তাই n, q = 105-এর প্রতিযোগিতার সমস্যায় সাধারণত C++ বা PyPy লাগে; ধারণা আর যাচাইয়ের জন্য এই কোডই যথেষ্ট।
Mo কোথায় চলে, কোথায় চলে না
চলে যখন: (১) সব প্রশ্ন আগে জানা, (২) কোনো আপডেট নেই (বা নিচের “আরও গভীরে” রূপ), (৩) জানালায় এক ঘর যোগ আর বাদ দুটোই সস্তা। উদাহরণ: রেঞ্জে আলাদা মান, রেঞ্জে কোনো মানের সর্বোচ্চ পুনরাবৃত্তি (একটু কৌশলে), রেঞ্জে “সমান মানের জোড়া কয়টি” (যোগে cnt[x] জোড়া বাড়ে), রেঞ্জে কয়টি জোড়ার XOR ঠিক k. চলে না: online প্রশ্নে (যেমন প্রতিটি প্রশ্ন আগের উত্তর দিয়ে এনক্রিপ্ট করা — প্রতিযোগিতায় ঠিক Mo আটকাতে এই কৌশল ব্যবহার হয়), বা যেখানে remove কঠিন (যেমন সর্বোচ্চ মান — বাদ দিলে পরের সর্বোচ্চ কে, জানা নেই; তখন শুধু-যোগের “rollback Mo” রূপ লাগে)।
৮ · তুলনা — কোন রেঞ্জ-প্রশ্নে কোন স্ট্রাকচার
এই মডিউলের সব রেঞ্জ-স্ট্রাকচার একসাথে। n = অ্যারের আকার; “অপারেশন” কলামে লেখা কোন ধরনের হিসাব সেটি স্বাভাবিকভাবে সামলায়।
| স্ট্রাকচার | বানানো | প্রশ্ন | আপডেট | জায়গা | অপারেশন |
|---|---|---|---|---|---|
| Prefix sum (পর্ব ১৭) | O(n) | O(1) | O(n) — পুনর্নির্মাণ | O(n) | যার উল্টো আছে: যোগ, XOR |
| Sparse table | O(n log n) | O(1) idempotent; O(log n) অন্য associative | সমর্থন নেই (O(n) বা নতুন করে বানানো) | O(n log n) | min, max, gcd, AND, OR |
| Sqrt decomposition | O(n) | O(√n) | point O(1) বা O(√n); রেঞ্জ O(√n) lazy-তে | O(n) + O(√n) | প্রায় যেকোনো — ব্লকে যা রাখা যায় |
| Fenwick tree (পর্ব ৩৬) | O(n) | O(log n) | point O(log n) | O(n) | prefix-এ উল্টো-যোগ্য: যোগ, XOR |
| Segment tree (পর্ব ৩৪–৩৫) | O(n) | O(log n) | point O(log n); রেঞ্জ O(log n) lazy-তে | O(n) (প্রায় 2n–4n ঘর) | যেকোনো associative: যোগ, min, gcd, ম্যাট্রিক্স গুণ… |
| Mo’s algorithm (offline) | সাজানো O(q log q) | সব মিলে O((n + q)√n) | সাধারণ রূপে নেই | O(n + q) | যেখানে add/remove O(1): আলাদা মান, জোড়া গোনা |
সিদ্ধান্তের একটি সহজ ক্রম: আপডেট আছে কি? না থাকলে আর অপারেশনের উল্টো থাকলে prefix sum; উল্টো না থাকলে কিন্তু idempotent হলে sparse table. আপডেট থাকলে segment tree (সবচেয়ে সাধারণ) বা Fenwick (যোগফলে ছোট আর দ্রুত)। অপারেশন জোড়া লাগে না কিন্তু জানালায় add/remove সহজ, আর সব প্রশ্ন আগে জানা → Mo. আর সময়ের চাপে বা অদ্ভুত শর্তে sqrt — প্রায় সবসময় একটি নিরাপদ বিকল্প।
আরও গভীরে — hybrid আর আপডেটসহ Mo: RMQ-তে O(n) প্রস্তুতি আর O(1) প্রশ্ন একসাথেও সম্ভব: অ্যারেকে log n আকারের ব্লকে ভাগ করে শুধু ব্লকের min-গুলোর ওপর sparse table (মাত্র (n / log n) · log n = O(n) ঘর), আর ব্লকের ভেতরের প্রশ্নের জন্য আলাদা ছোট কৌশল — Bender ও Farach-Colton-এর বিখ্যাত পদ্ধতির মূল ভাবনা এটিই। আবার disjoint sparse table যোগফল বা ম্যাট্রিক্স গুণের মতো যেকোনো associative অপারেশনে O(1) প্রশ্ন দেয়। আপডেটও চাইলে Mo with updates: প্রতিটি প্রশ্নে একটি তৃতীয় মাত্রা “সময়” (এ পর্যন্ত কয়টি আপডেট) যোগ করুন, চাবি (⌊l/B⌋, ⌊r/B⌋, t), আর B ≈ n2/3 নিলে মোট প্রায় O(n5/3) (n আর q একই মাপের হলে)। Tree-র পথে প্রশ্নের জন্য Euler tour-এর ওপর Mo চালানো যায়, আর Hilbert curve-এর ক্রমে প্রশ্ন সাজালে বাস্তবে pointer আরও কম হাঁটে।
পরীক্ষা ও ইন্টারভিউতে:
- Sparse table-এর বানানো আর প্রশ্নের জটিলতা কত? — বানানো Θ(n log n) সময় ও জায়গা; min/max প্রশ্ন O(1), দুটি ওভারল্যাপ করা 2k ব্লক দিয়ে।
- Sparse table দিয়ে রেঞ্জ যোগফল O(1)-এ হয় না কেন? — যোগ idempotent নয়; ওভারল্যাপের ঘর দুবার গোনা হয়। আলাদা ব্লকে ভাঙলে O(log n), আর যোগফলের জন্য prefix sum-ই O(1).
- Sqrt decomposition-এ B = √n কেন? — প্রশ্নের খরচ B + n/B, যা সবচেয়ে কম হয় B = √n-এ, তখন 2√n.
- Mo’s algorithm-এর জটিলতা আর শর্ত? — O((n + q)√n), যদি add/remove O(1); প্রশ্ন offline হতে হবে, সাধারণ রূপে আপডেট নেই।
- LCA-কে RMQ-তে কীভাবে আনা যায়? — Euler tour-এর গভীরতার অ্যারেতে first[u] থেকে first[v]-এর সর্বনিম্ন গভীরতার node-ই LCA.
৯ · AI-এর যুগে রেঞ্জ-প্রশ্ন — দ্রুত কোড, ধীর বাগ
“Sparse table লিখে দাও” বা “Mo’s algorithm দিয়ে distinct count” — AI সহকারী কয়েক সেকেন্ডে পরিষ্কার দেখতে কোড দেবে। কিন্তু এই কৌশলগুলোর বাগ প্রায় সবই সীমানায়: একটি “+ 1” বা “− 1”, inclusive আর exclusive রেঞ্জের গোলমাল, ভুল log. ছোট উদাহরণে প্রায়ই উত্তর ঠিক আসে, কারণ ভুল ব্লকটির min কাকতালীয়ভাবে একই হতে পারে। আর complexity-র প্রশ্নে AI কখনো কখনো sparse table-কে “আপডেটসহ O(1)” বা Mo-কে “online-এ চলে” বলে ফেলে।
এই পর্বের কোডে AI সাধারণত যেসব ভুল করে
| AI-এর ভুল | কীভাবে ধরবেন |
|---|---|
দ্বিতীয় ব্লক st[k][r - (1 << k)] — “+ 1” নেই | এলোমেলো প্রশ্নে brute force-এর সাথে মেলান; l = r বা len = 2k হলে প্রায়ই সীমার বাইরে পড়ে |
(int)log2(len) — floating-point log | 1 থেকে n পর্যন্ত প্রতিটি len (বিশেষ করে 2k-এর মান) দিয়ে lg অ্যারের সাথে মিলিয়ে দেখুন |
| Sparse table-এ যোগফল দুই-ব্লকে | [0, 2]-এর মতো ওভারল্যাপসহ একটি প্রশ্ন হাতে হিসাব করুন — ঘর দুবার গোনা ধরা পড়বে |
| বানানোর লুপে ভেতরে k, বাইরে i | আগের সারির ঘর তখনো তৈরি হয়নি — ছোট অ্যারেতে পুরো টেবিল ছাপিয়ে দেখুন |
| Mo-তে আগে কমানো, পরে বাড়ানো | cnt-এ assert করুন যে কখনো ঋণাত্মক হয় না; দূরের দুটি প্রশ্ন পরপর দিন |
| Mo-র উত্তর সাজানো ক্রমে ছাপা | প্রশ্নগুলো ইচ্ছা করে উল্টো ক্রমে দিন; উত্তরের ক্রম মেলে কি না দেখুন |
| “Mo’s algorithm O(n log n)” বা “sqrt সবসময় segment tree-র চেয়ে ধীর” | সঠিক বাক্য চান: O((n + q)√n), add/remove O(1) ধরে; আর বাস্তব সময় মেপে দেখুন |
AI-কে সমাধান লিখতে না বলে, আপনার কোড, trace আর complexity-র যুক্তি পরীক্ষা করতে বলুন:
রেঞ্জ-প্রশ্নের কোড যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১RMQ আর LCA আসলে একই সমস্যার দুই রূপ — কয়েক দশকের গবেষণায় এই সম্পর্ক কীভাবে বেরিয়ে এল?
অংশ ৪-এ দেখেছেন, Euler tour দিয়ে LCA-কে RMQ-তে আনা যায়। মজার ব্যাপার হলো উল্টো দিকেও যাওয়া যায়: যেকোনো অ্যারের RMQ-কে একটি tree-র LCA-তে রূপান্তর করা যায়। অ্যারের সবচেয়ে ছোট মানটিকে root বানান, তার বাঁ অংশ থেকে একইভাবে বাঁ subtree, ডান অংশ থেকে ডান subtree — এই tree-র নাম Cartesian tree (Jean Vuillemin, ১৯৮০ সালে প্রকাশিত). তখন min[l, r] = node l আর node r-এর LCA. Harold Gabow, Jon Bentley আর Robert Tarjan ১৯৮৪ সালের একটি প্রবন্ধে এই রূপান্তর ব্যবহার করেন। অর্থাৎ দুটি সমস্যা সমান কঠিন — একটির ভালো সমাধান মানে অন্যটিরও।
LCA-র দিক থেকে বড় অগ্রগতি আসে ১৯৮৪ সালেই: Dov Harel আর Robert Tarjan দেখান যে O(n) প্রস্তুতির পর প্রতিটি LCA প্রশ্ন O(1)-এ সম্ভব। কিন্তু তাঁদের পদ্ধতি এত জটিল ছিল যে বাস্তবে প্রায় কেউ ব্যবহার করতেন না। ১৯৮৮ সালে Baruch Schieber আর Uzi Vishkin একটি সরলতর পদ্ধতি দেন, তবু শেখানোর মতো সহজ ছিল না।
২০০০ সালে Michael Bender আর Martín Farach-Colton “The LCA Problem Revisited” নামে একটি ছোট প্রবন্ধে পুরো ব্যাপারটিকে প্রায় পাঠ্যবইয়ের মতো সহজ করে দেন। তাঁদের পথ: LCA → Euler tour-এ RMQ, যেখানে পাশাপাশি দুটি গভীরতার পার্থক্য সবসময় +1 বা −1 (“±1 RMQ”). তারপর অ্যারেকে ছোট ব্লকে ভাগ করে ব্লকের min-গুলোর ওপর sparse table (O(n) জায়গায়), আর ব্লকের ভেতরে ±1 ধাঁচ থাকায় সম্ভাব্য ব্লক-আকৃতি খুব অল্প — সব আগে থেকে হিসাব করে রাখা যায়। ফল: O(n) প্রস্তুতি, O(1) প্রশ্ন, আর এবার বোঝার মতো সহজ। এই পর্বের sparse table সেখানে একটি মূল ইট।
পরে Johannes Fischer আর Volker Heun দেখান যে RMQ-র উত্তর দিতে মূল অ্যারে ছাড়াই মাত্র প্রায় 2n bit যথেষ্ট — বিশাল জিনোম ডেটায় suffix array-ভিত্তিক সূচিতে জায়গা বাঁচানোর জন্য এটি গুরুত্বপূর্ণ। এক প্রশ্নের এত দীর্ঘ যাত্রা দেখায় যে “O(1) প্রশ্ন” প্রমাণ করা আর “O(1) প্রশ্ন সহজে লেখা যায়” — এ দুটি আলাদা অর্জন।
মূল উপলব্ধি: RMQ আর LCA পরস্পরে রূপান্তরযোগ্য, আর sparse table হলো সেই সেতুর সবচেয়ে সহজ অংশ। নতুন একটি সমস্যা দেখলে জিজ্ঞেস করুন — এটি কি ছদ্মবেশে একটি RMQ?
প্র ০২“√” কৌশল শুধু অ্যারেতে নয় — baby-step giant-step থেকে কনটেস্টের heavy-light ভাগ পর্যন্ত এই দর্শনটি কোথায় কোথায় দেখা যায়?
Sqrt decomposition-এর আসল শিক্ষা কোনো নির্দিষ্ট স্ট্রাকচার নয়, একটি চিন্তার ধরন: দুই ধরনের খরচ আছে, একটি B-এর সাথে বাড়ে, অন্যটি B-এর সাথে কমে — তাহলে B-কে মাঝামাঝি এমন জায়গায় বসান যেখানে দুটি সমান। খরচ B + n/B হলে সেই জায়গা √n. এই দর্শন কম্পিউটার বিজ্ঞানের অনেক কোণে আছে।
সবচেয়ে বিখ্যাত উদাহরণ সংখ্যাতত্ত্বে: discrete logarithm — gx ≡ h (mod p) হলে x কত? সব x চেষ্টা করলে O(p). Daniel Shanks-এর baby-step giant-step পদ্ধতি (১৯৭১ সালে প্রকাশিত) x-কে লেখে x = i·m + j, যেখানে m ≈ √p. প্রথমে সব “ছোট পদক্ষেপ” gj (j < m) একটি hash table-এ রাখুন, তারপর “বড় পদক্ষেপে” h·g−im হিসাব করে টেবিলে খুঁজুন। সময় আর জায়গা দুটিই O(√p) — ঠিক sqrt-এর ভারসাম্য। Cryptography-তে এই কারণেই discrete log-এর নিরাপত্তার জন্য p-কে এত বড় নিতে হয় যে √p-ও অসম্ভব রকম বড় থাকে। কাছাকাছি ধারণা meet-in-the-middle: 2n টি সম্ভাবনাকে দুই অর্ধেকে ভাগ করে 2n/2-এর দুটি তালিকা মেলানো।
গ্রাফে: m টি edge-এর গ্রাফে ত্রিভুজ গুনতে হলে প্রতিটি vertex-কে “ভারী” (degree > √m) বা “হালকা” ধরুন। ভারী vertex সর্বোচ্চ 2√m টি, তাই তাদের সব জোড়া দেখা সস্তা; হালকা vertex-এর প্রতিবেশী কম, তাই তাদের প্রতিবেশীর জোড়া দেখা সস্তা। মোট O(m√m) — কনটেস্টে খুব পরিচিত কৌশল। স্ট্রিং-এ: অনেকগুলো pattern-এর মোট দৈর্ঘ্য L হলে আলাদা দৈর্ঘ্য সর্বোচ্চ প্রায় √(2L) টি — এই ছোট পর্যবেক্ষণও অনেক সমস্যাকে sqrt সময়ে নামায়।
আর “প্রশ্নের ওপর sqrt” (অংশ ৬) বাস্তবের ব্যাচ-প্রক্রিয়ার সাথে মেলে: নতুন পরিবর্তনগুলো একটি ছোট লগে জমা রাখা, প্রশ্নে মূল ডেটা আর লগ দুটিই দেখা, আর লগ বড় হলে মূল ডেটায় মিশিয়ে দেওয়া। পর্ব ৩০-এর LSM-tree এই ধারণার একটি বহুস্তরের রূপ।
মূল উপলব্ধি: যখনই দেখবেন একটি প্যারামিটার বাড়ালে এক খরচ কমে আর অন্যটি বাড়ে, তখন ভারসাম্যের বিন্দুটি হিসাব করুন — প্রায়ই সেটি একটি বর্গমূল।
প্র ০৩Mo’s algorithm-এর প্রশ্ন সাজানো আসলে একটি “সবচেয়ে ছোট পথ খোঁজা”র সমস্যা — এটি কেন, আর Hilbert curve এখানে কীভাবে ঢোকে?
প্রতিটি প্রশ্ন (l, r)-কে একটি সমতলে একটি বিন্দু ভাবুন। জানালা (L, R) থেকে (l, r)-এ যেতে pointer সরে |L − l| + |R − r| ঘর — এটি দুই বিন্দুর Manhattan দূরত্ব, ঢাকার রাস্তায় শুধু উত্তর-দক্ষিণ আর পূর্ব-পশ্চিমে চলার মতো। তাহলে মোট pointer সরানো = সব বিন্দু একবার করে ছুঁয়ে যাওয়া একটি পথের দৈর্ঘ্য। সবচেয়ে ছোট এমন পথ খোঁজা হলো traveling salesman ধাঁচের সমস্যা (পর্ব ৫৭), যা NP-hard — নিখুঁত সমাধান আশা করা যায় না। Mo-র সাজানো তাই একটি সরল, প্রমাণযোগ্য আনুমানিক (approximate) সমাধান: সমতলকে B চওড়া উল্লম্ব ফালিতে ভাগ করে প্রতিটি ফালিতে ওপর-নিচে হাঁটা। এর মোট দৈর্ঘ্য O((n + q)√n), আর এমন উদাহরণ আছে যেখানে যেকোনো ক্রমেই প্রায় n√q ধাঁচের সরানো লাগে — তাই শুধু ক্রম বদলে বড় উন্নতি সম্ভব নয়।
Hilbert curve এখানে আরেকটি পুরোনো ধারণা নিয়ে আসে। ১৮৯১ সালে গণিতবিদ David Hilbert একটি বক্ররেখা বর্ণনা করেন, যা একটি বর্গের প্রতিটি বিন্দু দিয়ে যায় (space-filling curve), আর এর বিশেষ গুণ হলো বক্ররেখায় কাছাকাছি বিন্দু সমতলেও কাছাকাছি। প্রতিটি প্রশ্নকে Hilbert ক্রমে তার অবস্থানের নম্বর দিয়ে সাজালে ফালির সীমানায় বড় লাফ কমে যায়। প্রতিযোগিতার অনেকে মেপে দেখেছেন, বড় ইনপুটে এটি প্রায়ই সাধারণ Mo-র চেয়ে চোখে পড়ার মতো দ্রুত — যদিও Big-O একই থাকে। একই Hilbert ক্রম ডেটাবেসে ভৌগোলিক ডেটা সাজাতে আর ছবির pixel cache-বান্ধবভাবে পড়তেও ব্যবহৃত হয়।
নামটি নিয়ে একটি কথা: “Mo’s algorithm” নামটি চীনা প্রতিযোগী প্রোগ্রামিং মহল থেকে আসা, যেখানে একজন প্রতিযোগী Mo Tao (莫涛)-এর নামে কৌশলটিকে ডাকা হয় বলে প্রচলিত; প্রশ্নগুলো সাজিয়ে offline উত্তর দেওয়ার ধারণা অবশ্য তার আগেও নানা রূপে ছিল। নামের ইতিহাসে যা-ই থাকুক, ভাবনাটি একটি বৃহত্তর নীতির উদাহরণ — offline সুবিধা: প্রশ্ন আগে জানলে ক্রম আমাদের হাতে।
মূল উপলব্ধি: Mo-কে “প্রশ্ন-বিন্দুর ওপর একটি সস্তা ভ্রমণপথ” হিসেবে দেখলে তার সীমা, তার উন্নতি (বিজোড় ব্লকে উল্টো, Hilbert) আর তার সীমাবদ্ধতা — সব এক ছবিতে বোঝা যায়।
প্র ০৪Sparse table-এর O(n log n) জায়গা কাগজে নিরীহ, কিন্তু বাস্তবে কেমন দাঁড়ায় — আর মেমরির সাজানো, cache আর ভাষাভেদে কী বদলায়?
হিসাবটি করে দেখুন। n = 106 হলে সারি ⌊log₂ 106⌋ + 1 = ২০টি। প্রতিটি ঘর একটি ৪-বাইটের int হলে প্রায় 20 × 106 × 4 = ৮০ মেগাবাইট। অনেক প্রতিযোগিতার মেমরি-সীমা ২৫৬ মেগাবাইট, তাই এটি চলে — কিন্তু ৬৪-বিটের long long হলে ১৬০ মেগাবাইট, আর দুটি টেবিল (min আর max) লাগলে সীমা ছাড়িয়ে যেতে পারে। Python-এ প্রতিটি list-এর প্রতিটি ঘর একটি pointer (৮ বাইট), আর ছোট পূর্ণসংখ্যা ছাড়া প্রতিটি সংখ্যা আলাদা object — একই টেবিল কয়েকশো মেগাবাইটে পৌঁছায়। তখন index রাখা (অ্যারের অবস্থান), ছোট টাইপ ব্যবহার, বা অংশ ৮-এর hybrid (O(n) জায়গা) কাজে আসে। Segment tree মাত্র প্রায় 2n–4n ঘর নেয় — জায়গার হিসাবে সে-ই জেতে।
দ্বিতীয় বিষয় মেমরির সাজানো। st[k][i] (আগে স্তর, তারপর অবস্থান) রাখলে বানানোর সময় প্রতিটি সারি একটি টানা অ্যারে, আর ভেতরের লুপ i ক্রমিকভাবে পড়ে — cache-বান্ধব। st[i][k] রাখলে একই সারির ঘরগুলো মেমরিতে ছড়িয়ে থাকে, তাই বানানোর লুপ প্রতি ধাপে লাফ দিয়ে পড়ে। পর্ব ১০-এ দেখেছেন, cache line-এ পরপর ঘর একসাথে আসে; তাই এই ছোট পছন্দ বাস্তব সময়ে কয়েক গুণ পার্থক্য করতে পারে। O(1) প্রশ্নও আসলে দুটি এলোমেলো মেমরি-পড়া — বড় টেবিলে সেগুলো প্রায়ই cache-এর বাইরে, তাই “O(1)” মানে ন্যানোসেকেন্ড নয়, কয়েকশো ন্যানোসেকেন্ডও হতে পারে।
তৃতীয়ত, বাস্তব সিস্টেমে ঠিক এই ধাঁচের পূর্বগণনা দেখা যায়। বড় ডেটা-বিশ্লেষণের কলাম-ভিত্তিক ফাইল ফরম্যাট, যেমন Apache Parquet, প্রতিটি অংশের (row group বা page) জন্য কলামের min আর max আলাদা করে লিখে রাখে, যাতে “দাম ১০০-এর কম এমন সারি” খোঁজার সময় পুরো অংশ পড়া ছাড়াই বাদ দেওয়া যায় — এটি আসলে sqrt decomposition-এর ব্লক-সারাংশের ধারণা, ডিস্কের মাপে। এক স্তরের ব্লক-সারাংশ আর বহু স্তরের দুইয়ের-ঘাত টেবিলের মধ্যে কোনটি নেবেন, তা নির্ভর করে প্রশ্ন কত বেশি আর জায়গা কত কম তার ওপর।
মূল উপলব্ধি: Big-O-র পাশে ধ্রুবক, টাইপের আকার আর মেমরির সাজানোও হিসাব করুন। Sparse table প্রশ্নে অপরাজেয়, কিন্তু জায়গায় দামি — সমস্যার সীমা দেখে সিদ্ধান্ত নিন।
প্র ০৫Offline আর online — “সব প্রশ্ন আগে জানা” থাকলে অ্যালগরিদম কেন এত শক্তিশালী হয়, আর প্রতিযোগিতা ও বাস্তবে এর সীমা কোথায়?
Online অ্যালগরিদমকে প্রতিটি প্রশ্নের উত্তর সাথে সাথে দিতে হয়, পরের প্রশ্ন না দেখে — যেমন একটি ব্যাংকের ATM. Offline অ্যালগরিদম সব প্রশ্ন আগে দেখে নিতে পারে, তারপর নিজের সুবিধামতো ক্রমে উত্তর দিয়ে শেষে মূল ক্রমে সাজায় — যেমন বছরের শেষে হিসাব-নিকাশ। এই স্বাধীনতা অনেক বড়: Mo প্রশ্ন সাজিয়ে pointer-এর পথ ছোট করে; পর্ব ৩৩-এ দেখেছেন, DSU সময়কে উল্টো চালিয়ে “ভাঙা”-কে “জোড়া” বানায়; sweep line প্রশ্ন আর ঘটনাগুলো একটি অক্ষ বরাবর সাজিয়ে একবারে সামলায়; আর divide and conquer-এর কিছু কৌশল (যেমন চীনা কনটেস্ট-সমাজে CDQ নামে পরিচিত পদ্ধতি) আপডেট আর প্রশ্নকে সময় অনুযায়ী অর্ধেক অর্ধেক ভাগ করে সমাধান করে।
তত্ত্বের দিক থেকে online-এর সীমা মাপার একটি ভাষা আছে — competitive analysis: online অ্যালগরিদম সবচেয়ে খারাপ ক্ষেত্রে সর্বোত্তম offline সমাধানের কত গুণ খারাপ করে। Daniel Sleator আর Robert Tarjan ১৯৮৫ সালে paging আর list update সমস্যায় এই বিশ্লেষণ জনপ্রিয় করেন। যেমন cache-এর জন্য “ভবিষ্যতে সবচেয়ে দেরিতে লাগবে এমন পাতা বাদ দাও” (Bélády-র নিয়ম) সর্বোত্তম, কিন্তু তা offline — ভবিষ্যৎ জানতে হয়। বাস্তব cache তাই LRU-র মতো online নিয়মে চলে।
প্রতিযোগিতায় প্রশ্নকর্তারা এটি ভালো জানেন। কোনো সমস্যায় offline কৌশল আটকাতে চাইলে তাঁরা প্রশ্নকে “এনক্রিপ্ট” করেন: আসল l = দেওয়া l XOR আগের উত্তর। তখন আগের উত্তর না দিয়ে পরের প্রশ্ন জানাই যায় না, আর Mo বা উল্টো-সময়ের DSU অচল — persistent segment tree-র মতো online স্ট্রাকচার লাগে। উল্টো দিকে, প্রশ্ন সাধারণ পূর্ণসংখ্যায় দেওয়া থাকলে offline পথ প্রায়ই সহজতম সমাধান।
বাস্তব সফটওয়্যারে একই ভাগাভাগি: রাতের বেলা চলা রিপোর্ট বা ডেটা গুদামের ব্যাচ-বিশ্লেষণ offline — সব প্রশ্ন জানা, ক্রম বদলানো চলে। কিন্তু একটি অনলাইন দোকানের দামের পাতা বা মোবাইল ব্যাংকিং-এর ব্যালান্স দেখানো online — প্রতিটি অনুরোধ এখনই।
মূল উপলব্ধি: সমস্যা পড়ার সময় জিজ্ঞেস করুন — সব প্রশ্ন কি আগে জানা যায়? যদি যায়, তবে প্রশ্নের ক্রমও আপনার নকশার একটি স্বাধীন চলক।
অনুশীলন
খাতা-কলম নিন। প্রথম তিনটি হাতে করার, শেষটিতে কোড লিখতে হবে।
-
হাতে trace করুন: a = [3, 8, 6, 1, 9, 2] (0-based)-এর জন্য max-এর sparse table-এর সব সারি লিখুন। তারপর max[0, 3], max[2, 4] আর max[5, 5]-এর জন্য k আর দুটি ব্লক কী, আর উত্তর কত?
সারি k = 0:
3 8 6 1 9 2. k = 1 (দৈর্ঘ্য ২): max(3,8) = 8, max(8,6) = 8, max(6,1) = 6, max(1,9) = 9, max(9,2) = 9 →8 8 6 9 9. k = 2 (দৈর্ঘ্য ৪, st[1][i] আর st[1][i+2]): max(8,6) = 8, max(8,9) = 9, max(6,9) = 9 →8 9 9. ⌊log₂ 6⌋ = 2, তাই আর সারি নেই (দৈর্ঘ্য ৮ > ৬)।max[0, 3]: len = 4, k = 2, দুটি ব্লকই [0, 3] → st[2][0] = 8. max[2, 4]: len = 3, k = 1, ব্লক [2, 3] আর [3, 4] → max(6, 9) = 9. max[5, 5]: len = 1, k = 0 → 2. প্রতিটি উত্তর সরাসরি অ্যারে দেখে যাচাই করুন: 3, 8, 6, 1-এর সর্বোচ্চ ৮; 6, 1, 9-এর ৯.
-
হাতে trace করুন — lazy ব্লক: a = [2, 0, 1, 4, 3, 5, 1, 1, 2], B = 3, ব্লকের যোগফল আর
lazyশুরুতে শূন্য। কাজ ১: ঘর 1 থেকে 7-এ সবার সাথে 2 যোগ। কাজ ২: sum[3, 8]. কাজ ৩: sum[4, 6]. প্রতিটি কাজের পর a, sum আর lazy লিখুন।শুরু: sum = [3, 12, 4], lazy = [0, 0, 0]. কাজ ১: ব্লক 0 অসম্পূর্ণ (ঘর 1, 2) → a[1] = 2, a[2] = 3, sum[0] = 7. ব্লক 1 পূর্ণ →
lazy[1] = 2(a আর sum[1] অপরিবর্তিত)। ব্লক 2 অসম্পূর্ণ (ঘর 6, 7) → a[6] = 3, a[7] = 3, sum[2] = 8. এখন a = [2, 2, 3, 4, 3, 5, 3, 3, 2], sum = [7, 12, 8], lazy = [0, 2, 0].কাজ ২: sum[3, 8] = ব্লক 1 পূর্ণ: 12 + 2 × 3 = 18, ব্লক 2 পূর্ণ: 8 + 0 = 8 → 26. কাজ ৩: sum[4, 6] — ব্লক 1-এর ঘর 4, 5 অসম্পূর্ণ: (3 + 2) + (5 + 2) = 12; ব্লক 2-এর ঘর 6: 3 → 15. যাচাই: আসল অ্যারে [2, 2, 3, 6, 5, 7, 3, 3, 2]; ঘর 3–8-এর যোগ 26, ঘর 4–6-এর যোগ 15 ✓. অসম্পূর্ণ ঘরে
lazyযোগ করতে ভুলে গেলে কাজ ৩-এর উত্তর ১১ আসত। -
ভেবে বলুন — Mo-র ব্লক: n = 106 আর মাত্র q = 104 টি প্রশ্ন, add/remove O(1). খরচের মডেল n²/B + qB ধরে B = √n আর B = n/√q — দুটির মোট pointer-সরানো আনুমানিক কত? কোনটি নেবেন? আর q = 106 হলে?
B = √n = 1000: n²/B = 1012/103 = 109, qB = 107 — মোট প্রায় 109, এক সেকেন্ডে কঠিন। B = n/√q = 106/100 = 104: n²/B = 108, qB = 108 — মোট প্রায় 2 × 108, পাঁচ গুণ কম। তাই B = n/√q; তখন মোট O(n√q).
q = 106 = n হলে n/√q = 106/103 = 1000 = √n — দুই সূত্র একই উত্তর দেয়, মোট প্রায় 2 × 109. শিক্ষা: “B = √n” মুখস্থ না করে খরচের দুই অংশ লিখে ভারসাম্যের বিন্দু বের করুন — n²/B = qB থেকে B = n/√q.
-
কোড লিখুন: একটি অ্যারেতে দুই ধরনের কাজ:
set(i, v)(a[i] = v) আরmin(l, r)(inclusive). Sqrt decomposition দিয়ে একটি struct লিখুন, যেখানে set O(√n) আর min O(√n). a = [4, 6, 2, 8, 5, 7, 3] দিয়ে min(1, 5), তারপর set(2, 9), তারপর আবার min(1, 5) হাতে মেলান।প্রতিটি ব্লকের min রাখুন; set-এ ঘর বদলে সেই ব্লকের min নতুন করে হিসাব করুন (O(B)); প্রশ্নে প্রান্তে ঘর, মাঝে ব্লকের min.
struct SqrtMin { int n, B; vector<int> a, mn; // mn[b] = min of block b SqrtMin(const vector<int>& v) : n(v.size()), B(max(1, (int)sqrt(v.size()))), a(v) { mn.assign((n + B - 1) / B, INT_MAX); for (int i = 0; i < n; ++i) mn[i / B] = min(mn[i / B], a[i]); } void set(int i, int v) { // O(B): rebuild one block a[i] = v; int b = i / B; mn[b] = INT_MAX; for (int j = b * B; j < min(n, (b + 1) * B); ++j) mn[b] = min(mn[b], a[j]); } int query(int l, int r) const { // inclusive, O(B + n/B) int res = INT_MAX; while (l <= r && l % B != 0) res = min(res, a[l++]); while (l + B - 1 <= r) { res = min(res, mn[l / B]); l += B; } while (l <= r) res = min(res, a[l++]); return res; } };হাতে: n = 7, B = ⌊√7⌋ = 2, ব্লক [4, 6], [2, 8], [5, 7], [3] → mn = [4, 2, 5, 3]. min(1, 5): ঘর 1 (6) প্রান্তে, তারপর ব্লক 1 (2) আর ব্লক 2 (5) পূর্ণ → 2. set(2, 9): ব্লক 1 = [9, 8], mn[1] = 8. আবার min(1, 5): 6, 8, 5 → 5. যাচাই: [6, 9, 8, 5, 7]-এর সর্বনিম্ন ৫ ✓. দরকারি header:
<vector>,<algorithm>,<cmath>,<climits>. Stress test-এ brute force-এর সাথে মিলিয়ে নিন — বিশেষ করে শেষ ছোট ব্লকে set.
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমMo’s algorithm দিয়ে offline রেঞ্জ-প্রশ্নের উত্তর দেওয়ার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- সব প্রশ্ন পড়ে নেওয়া আর ব্লকের মাপ B (প্রায় √n) ঠিক করা
- প্রশ্নগুলোকে (l-এর ব্লক, r) অনুযায়ী সাজানো
- খালি জানালা আর শূন্য গণনা দিয়ে শুরু করা
- প্রতিটি প্রশ্নে আগে জানালা বাড়ানো, তারপর কমানো — প্রতিটি সরানোয় add বা remove
- উত্তরটি প্রশ্নের মূল index-এ রেখে শেষে মূল ক্রমে ছাপা
min-এর sparse table-এ প্রশ্ন [l, r]-এর দৈর্ঘ্য ১৩ (l = 10, r = 22). কোন k আর কোন দুটি ব্লক নেওয়া হয়?
- k = 4; ব্লক [10, 25] আর [7, 22]
- k = 3; ব্লক [10, 17] আর [15, 22]
- k = 3; ব্লক [10, 17] আর [14, 22]
- k = 2; ব্লক [10, 13] আর [19, 22]
Sparse table-এ দুটি ওভারল্যাপ করা ব্লক দিয়ে O(1)-এ রেঞ্জের উত্তর দেওয়ার কৌশল নিচের কোন অপারেশনে ভুল উত্তর দেয়?
- সর্বোচ্চ (max)
- গরিষ্ঠ সাধারণ গুণনীয়ক (gcd)
- bitwise OR
- যোগফল (sum)
n = 10,000 উপাদানের একটি অ্যারেতে B = 100 আকারের ব্লকে sqrt decomposition (ব্লকের যোগফল রাখা)। একটি রেঞ্জ-যোগফলের প্রশ্নে worst case-এ মোটামুটি কয়টি ঘর বা সারাংশ পড়তে হয়?
- প্রায় ১০০
- প্রায় ৩০০
- প্রায় ১০,০০০
- প্রায় ১৪
নিচের কোন পরিস্থিতিতে সাধারণ Mo’s algorithm ব্যবহার করা যায় না?
- স্থির অ্যারেতে ১ লাখ প্রশ্ন, প্রতিটিতে রেঞ্জে কয়টি আলাদা মান
- স্থির অ্যারেতে অনেক প্রশ্ন, প্রতিটিতে রেঞ্জে সমান মানের জোড়া কয়টি
- প্রতিটি প্রশ্নের l আর r আগের প্রশ্নের উত্তরের সাথে XOR করে দেওয়া (online)
- n = 10^5, কিন্তু প্রশ্ন মাত্র ১,০০০টি, সব আগে জানা
n উপাদান আর q টি প্রশ্নে, add/remove O(1) আর B = √n হলে Mo’s algorithm-এর মোট সময় (সাজানো বাদে) কত?
- O((n + q)√n)
- O(nq)
- O((n + q) log n)
- O(n + q)
n উপাদানের min-এর sparse table বানানো আছে। একটি উপাদান a[p]-এর মান বদলালে টেবিলের worst case-এ মোটামুটি কয়টি ঘর বদলাতে হতে পারে?
- O(log n) — প্রতিটি সারিতে একটি
- O(1) — শুধু st[0][p]
- O(n) — সব সারি মিলে প্রায় 2n
- Θ(n log n) — সব ঘর
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- স্থির অ্যারেতে min/max-এর জন্য prefix sum চলে না, কারণ min-এর উল্টো নেই; তখন আগে থেকে বানানো টেবিল।
- Sparse table:
st[k][i] = min(st[k−1][i], st[k−1][i + 2^(k−1)]), বানানো Θ(n log n); প্রশ্নে k = lg[len], দুটি ওভারল্যাপ করা ব্লক → O(1), শুধু idempotent অপারেশনে। - Sqrt decomposition: √n আকারের ব্লক আর ব্লকের সারাংশ; প্রশ্ন O(√n), point update O(1)/O(√n), ব্লকে lazy দিয়ে রেঞ্জ আপডেট O(√n); B বেছে নিন দুই খরচ সমান করে।
- Mo’s algorithm: offline প্রশ্ন (l-এর ব্লক, r) অনুযায়ী সাজিয়ে জানালায় add/remove → O((n + q)√n); আগে বাড়ান, পরে কমান; উত্তর মূল ক্রমে।
- বাছাই: আপডেট নেই + উল্টো আছে → prefix; আপডেট নেই + idempotent → sparse table; আপডেট আছে → segment tree/Fenwick; জোড়া লাগে না কিন্তু add/remove সহজ → Mo; নমনীয়তা দরকার → sqrt.
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৩৮ · Treap আর skip list — এলোমেলোতার সাহায্যে ভারসাম্যপরবর্তী পর্বRandomized ভারসাম্যের দুটি সুন্দর স্ট্রাকচার।
- ডেটা স্ট্রাকচার ও অ্যালগরিদম · কোর্স হোম৭২টি পর্বপুরো কোর্সের রোডম্যাপ — নয়টি মডিউল।
- পর্ব ১৭ · Prefix sum আর difference arrayআগের ভিত্তিযোগফলের O(1) রেঞ্জ-প্রশ্ন আর রেঞ্জ আপডেটের অফলাইন কৌশল।
- পর্ব ৩৪ · Segment tree — রেঞ্জের প্রশ্ন দ্রুতএকই মডিউলআপডেটসহ যেকোনো associative রেঞ্জ-প্রশ্ন O(log n)-এ।
- C++ প্রোগ্রামিং · STL algorithmপাশের ট্র্যাক
sort,min_elementআর comparator — Mo-র সাজানো আর brute force-এর জন্য। - Python প্রোগ্রামিং · অ্যালগরিদম ও কনটেস্টে Pythonপাশের ট্র্যাকKey দিয়ে সাজানো, দ্রুত ইনপুট আর Python-এর গতির সীমা।