এই পর্বে যা শিখবেন
- সাধারণ segment tree-তে রেঞ্জ আপডেট কেন ধীর, আর lazy tag দিয়ে কীভাবে কাজ “পরে করার জন্য” জমিয়ে রাখা যায়
- Update আর query-র সময় push-down আর pull-up হাতে trace করা, আর node-এর মানের invariant দিয়ে সঠিকতা প্রমাণ করা
- প্রতিটি কাজ O(log n) — level ধরে গোনার যুক্তিটি নিজে লিখতে পারা
- Range add + sum, range assign + sum, add আর assign একসাথে (composition-এর ক্রম), range min/max আর XOR flip-এর tag ডিজাইন করা
- কখন lazy segment tree লাগবেই না — difference array, দুটি Fenwick tree বা sqrt decomposition যথেষ্ট
১ · সমস্যা — পুরো একটি রেঞ্জ বদলাতে গেলে segment tree হাঁপিয়ে ওঠে
একটি ব্যাংকের কথা ভাবুন। গ্রাহকদের অ্যাকাউন্ট নম্বর ০ থেকে n − 1, আর প্রতিটি অ্যাকাউন্টে কিছু টাকা আছে। দুই ধরনের কাজ বারবার আসে। প্রথমটি একসাথে জমা: “একটি গার্মেন্টস কারখানার ঈদ বোনাস — অ্যাকাউন্ট ২০০ থেকে ৪৫০ পর্যন্ত সবার অ্যাকাউন্টে ৫০০ টাকা”. দ্বিতীয়টি হিসাবের প্রশ্ন: “অ্যাকাউন্ট ৩০০ থেকে ৯০০ পর্যন্ত মোট কত টাকা আছে?” — হয়তো একটি শাখার মোট আমানত জানতে। দুটি কাজ মিশে আসে, লাখ লাখ বার।
দ্বিতীয় কাজটি একটি range query, আর প্রথমটি একটি range update. পর্ব ৩৪-এ আপনি segment tree দেখেছেন: অ্যারের ওপর একটি binary tree, যার প্রতিটি node একটি রেঞ্জের যোগফল রাখে, আর রেঞ্জের যোগফল আর point update দুটোই O(log n). এই পর্বে আমরা ধরে নিচ্ছি সেই কাঠামো আপনার চেনা — build, query আর point update কীভাবে হয়, তা আবার বলছি না। প্রশ্ন একটাই: পুরো রেঞ্জ একসাথে বদলাতে চাইলে কী হয়?
সরল উপায়গুলো কোথায় আটকায়
উপায় ১ — প্রতিটি ঘরে আলাদা point update. [l, r]-এর k = r − l + 1 টি ঘরের প্রতিটিতে O(log n), মোট O(k log n). পুরো অ্যারেতে বোনাস দিলে O(n log n) — একটি মাত্র কাজে! দশ লাখ অ্যাকাউন্ট আর এক লাখ এমন কাজে প্রায় ২ × 1012 ধাপ।
উপায় ২ — tree-টি ওপর থেকে নেমে রেঞ্জের সব leaf বদলানো, ফেরার পথে যোগফল ঠিক করা. এতে প্রতিটি node একবার করে ছোঁয়া হয়, কিন্তু [l, r]-এর ভেতরের প্রতিটি leaf আর তাদের সব পূর্বপুরুষ — কমপক্ষে k টি node, পুরো অ্যারেতে প্রায় 2n. এটি Θ(k + log n), worst case Θ(n). একটি সাধারণ অ্যারেতে লুপ চালানোর চেয়ে ভালো কিছু নয়।
উপায় ৩ — prefix sum বা difference array (পর্ব ১৭). Prefix sum-এ প্রশ্ন O(1), কিন্তু একটি আপডেটের পর পুরো prefix অ্যারে নতুন করে বানাতে হয় — O(n). Difference array-তে রেঞ্জ আপডেট O(1), কিন্তু যোগফল জানতে আগে পুরোটা জমাতে হয় — O(n). যতক্ষণ প্রশ্ন আর আপডেট মিশে আসে (online), এদের কেউ একা যথেষ্ট নয়।
| উপায় | রেঞ্জ আপডেট | রেঞ্জ যোগফল |
|---|---|---|
| সাধারণ অ্যারে | O(k) | O(k) |
| Prefix sum অ্যারে | O(n) | O(1) |
| Difference array | O(1) | O(n) |
| Segment tree, প্রতিটি ঘরে point update | O(k log n) | O(log n) |
| Segment tree + lazy propagation | O(log n) | O(log n) |
শেষ সারির কৌশলটির নাম lazy propagation — “অলস প্রচার”. এর পুরো রহস্য একটি বাক্যে: যে কাজ এখনই কারও দরকার নেই, তা এখনই করবেন না.
২ · অলস হওয়ার বুদ্ধি — অফিসের ড্রয়ারে একটি নোট
উপমা — সরকারি অফিসের ফাইল-ক্যাবিনেট। একটি জেলা অফিসে কর্মচারীদের ফাইল রাখা আছে বড় ক্যাবিনেটে; প্রতিটি ক্যাবিনেটে কয়েকটি ড্রয়ার, প্রতিটি ড্রয়ারে কয়েকটি ফোল্ডার, প্রতিটি ফোল্ডারে একজনের ফাইল। প্রতিটি ড্রয়ারের ওপর একটি কার্ডে লেখা থাকে ভেতরের সবার মোট বেতন। হঠাৎ আদেশ এল: “এই পুরো ক্যাবিনেটের সবার বেতন ৫০০ টাকা বাড়বে”. পরিশ্রমী কেরানি প্রতিটি ফাইল খুলে বেতন বদলাবেন — সারাদিন লাগবে। চতুর কেরানি শুধু ক্যাবিনেটের কার্ডে মোট বেতন ঠিক করেন (জনসংখ্যা × ৫০০ যোগ), আর ক্যাবিনেটের গায়ে একটি নোট সেঁটে দেন: “ভেতরের সবার +৫০০, এখনো ফাইলে তোলা হয়নি”. পরে কেউ একটি নির্দিষ্ট ড্রয়ার খুলতে এলে, খোলার আগে কেরানি নোটটি ড্রয়ারগুলোর কার্ডে তুলে দেন আর নোট সরিয়ে নেন। যে ড্রয়ার কেউ কখনো খোলে না, সেখানকার ফাইলে বদলটি কখনো তুলতেই হয় না — অথচ মোট হিসাব সবসময় ঠিক।
Segment tree-তে ঠিক এটিই। প্রতিটি node v দুটি জিনিস রাখে:
sum[v]— v-এর রেঞ্জের যোগফল, v-এর নিজের নোটসহ ঠিক।tag[v]— একটি lazy tag: “v-এর রেঞ্জের প্রতিটি ঘরে এখনো tag[v] যোগ করা বাকি, কিন্তু শুধু v-এর সন্তানদের কাছে” — v নিজে ইতিমধ্যে হিসাব করে ফেলেছে।
দুটি ছোট কাজ সবকিছু চালায়। apply(v, x): node v-এর পুরো রেঞ্জে x যোগ করা — অর্থাৎ sum[v] += x × (রেঞ্জের দৈর্ঘ্য) আর tag[v] += x. এটি O(1), সন্তানদের না ছুঁয়ে। আর push-down push(v): v-এর নোট দুই সন্তানের হাতে দেওয়া — দুই সন্তানে apply(child, tag[v]), তারপর tag[v] = 0. সন্তানদের মান ঠিক হয়ে গেল, আর তারাও এখন নিজেদের নোট রাখে। নিচ থেকে ওপরের দিকে উল্টো কাজটির নাম pull-up: sum[v] = sum[left] + sum[right].
Pseudocode — range add on [ql, qr] (0-based, node v covers [l, r])
update(v, l, r, ql, qr, x):
if qr < l or r < ql: return // disjoint: nothing to do
if ql <= l and r <= qr: // fully covered:
apply(v, l, r, x); return // fix sum[v], write the note, STOP
m = (l + r) / 2
push(v, l, m, r) // partial: hand old note down first
update(2v, l, m, ql, qr, x)
update(2v+1, m+1, r, ql, qr, x)
sum[v] = sum[2v] + sum[2v+1] // pull upএই পর্বে node-এর নম্বর পর্ব ৩৪-এর প্রচলিত নিয়মে: root = 1, node v-এর বাঁ সন্তান 2v, ডান সন্তান 2v + 1, অ্যারে 4n আকারের। মূল কথা দ্বিতীয় শর্তে — পুরোপুরি ঢাকা node-এ পৌঁছালেই থামুন, নিচে নামবেন না। এবার একটি সত্যিকারের অ্যারেতে দেখুন: a = [5, 3, 7, 2, 6, 1, 4, 8], আর কাজ “[2, 5]-এর প্রতিটি ঘরে 10 যোগ”.
| এই ধাপের node | |
| সিদ্ধান্ত | |
| এ পর্যন্ত দেখা node | |
| বাসি (stale) leaf |
- শুরু. a = [5, 3, 7, 2, 6, 1, 4, 8] থেকে বানানো tree. প্রতিটি node তার রেঞ্জের যোগফল রাখে, root-এ 36. কোনো tag নেই।
- Root [0-7]. আপডেটের রেঞ্জ [2, 5] root-এর রেঞ্জের অংশমাত্র — আংশিক মিল। তাই আগে push (এখনো কোনো নোট নেই, কিছু হয় না), তারপর দুই সন্তানেই নামা।
- বাঁ দিক. [0-3] আংশিক, তাই আরও নামা। [0-1] পুরোপুরি বাইরে — ফেরত। [2-3] পুরোপুরি [2, 5]-এর ভেতরে: apply — যোগফল 9 + 10 × 2 = 29, গায়ে নোট “tag +10”. এখানেই থামা; leaf [2] আর [3] এখনো 7 আর 2 দেখায় — তারা বাসি, কিন্তু কেউ তাদের জিজ্ঞেস করেনি।
- Pull-up [0-3]. সন্তানদের নতুন মান থেকে: 8 + 29 = 37. আগে ছিল 17 — ঠিক 20 বেড়েছে, কারণ এই অংশে আপডেটের দুটি ঘর।
- ডান দিক. [4-7] আংশিক। [4-5] পুরো ঢাকা: 7 + 20 = 27, tag +10, থামা। [6-7] বাইরে।
- Pull-up শেষ. [4-7] = 27 + 12 = 39, root = 37 + 39 = 76 = 36 + 4 × 10. মোট ৭টি node দেখা হলো, আর নোট বসল মাত্র ২টিতে। চারটি leaf অছোঁয়া রয়ে গেল — তবু প্রতিটি “ওপরের” প্রশ্নের উত্তর ঠিক।
৩ · Push-down — নোট নিচে নামে ঠিক যখন দরকার
বাসি leaf-গুলো কি কখনো সমস্যা করবে? করবে — যদি কেউ নোট না নামিয়েই তাদের পড়ে। নিয়মটি তাই সরল আর কঠোর: কোনো node-এর সন্তানের কাছে যাওয়ার আগে, সবসময় সেই node-এর নোট push করুন — update-এ আর query-তে, দুই জায়গাতেই। Query-র কোড পর্ব ৩৪-এর মতোই, শুধু একটি লাইন বেশি:
Pseudocode — range sum on [ql, qr] (0-based)
query(v, l, r, ql, qr):
if qr < l or r < ql: return 0
if ql <= l and r <= qr: return sum[v] // already correct, tag included
m = (l + r) / 2
push(v, l, m, r) // the one new line
return query(2v, l, m, ql, qr) + query(2v+1, m+1, r, ql, qr)আগের আপডেটের পর এবার জিজ্ঞেস করুন: [3, 4]-এর যোগফল কত? সঠিক উত্তর a[3] + a[4] = (2 + 10) + (6 + 10) = 28. দেখুন নোটগুলো কীভাবে ঠিক যেখানে দরকার, সেখানে নামে:
| এই ধাপের কাজ | |
| নোট কোথায় | |
| জমা যোগফল |
- শুরু. আগের আপডেটের শেষ অবস্থা: নোট আছে [2-3] আর [4-5]-এ, leaf [2]…[5] বাসি।
- ওপরের দুই level. Root আর [0-3] — দুজনেই [3, 4]-এর সাথে আংশিক মেলে। দুজনেরই নোট নেই, push কিছু করে না। [0-1] পুরোপুরি বাইরে, ফেরত 0.
- [2-3]-এ push. [2-3] আংশিক (শুধু 3 দরকার), তাই সন্তানে নামার আগে নোট নামে: [2] = 7 + 10 = 17, [3] = 2 + 10 = 12; [2-3]-এর tag মুছে গেল। খেয়াল করুন, [2-3]-এর নিজের মান 29 বদলায়নি — সে আগেই ঠিক ছিল।
- [3] পুরো ঢাকা. মান 12 ফেরত — এখন সেটি সঠিক, কারণ নোট ঠিক আগেই নেমেছে। [2] বাইরে।
- ডান দিকে একই কাজ. [4-7]-এ নোট নেই। [4-5] আংশিক, তাই push: [4] = 16, [5] = 11, tag মুছে গেল।
- শেষ. [4] পুরো ঢাকা, ফেরত 16. মোট 12 + 16 = 28 — সঠিক। প্রশ্নটি যেসব নোটের “নিচে” যেতে চেয়েছিল, শুধু সেগুলোই নেমেছে; Tree-র বাকি অংশ যেমন ছিল, তেমন।
ভুল কোথায় হতে পারে, এখান থেকেই বোঝা যায়। Query-তে push বাদ দিলে ধাপ ৪-এ [3] থেকে বাসি মান 2 ফেরত আসত, আর উত্তর হতো 2 + 6 = 8. আবার update-এ নামার আগে push বাদ দিলে আরেক রকম ভুল: পুরোনো নোট থেকে যাবে ওপরে, আর pull-up সন্তানদের বাসি মান যোগ করে ওপরের ঠিক মানটিকে নষ্ট করবে। তাই নিয়ম: নামার আগে push, ফেরার পরে pull.
প্রতিটি node v-এর জন্য: sum[v] = v-এর রেঞ্জের সত্যিকারের যোগফল, v-এর পূর্বপুরুষদের গায়ে এখনো ঝুলে থাকা নোটগুলো বাদে. অন্যভাবে বললে — node-এর মান তার নিজের tag-সহ ঠিক; শুধু তার সন্তানরা (আর তাদের নিচের সবাই) বাসি থাকতে পারে। Root-এর কোনো পূর্বপুরুষ নেই, তাই root সবসময় পুরোপুরি ঠিক। আর ওপর থেকে নামার পথে প্রতিটি node-এ push করলে, যে node-এ পৌঁছান তার পূর্বপুরুষদের সব নোট ইতিমধ্যে নেমে গেছে — তাই তার মানও পুরোপুরি ঠিক।
৪ · কোড — range add আর range sum, brute force-এর সাথে মিলিয়ে
এবার পুরো কোড। ব্যাংকের গল্পের ছোট রূপ: আটটি অ্যাকাউন্ট, একটি নির্দিষ্ট ক্রমের কাজ — '+' মানে রেঞ্জে জমা (ঋণাত্মক হলে উত্তোলন), '?' মানে রেঞ্জের মোট। প্রতিটি প্রশ্নের উত্তর একটি সাধারণ অ্যারের brute force লুপের সাথে মেলানো হচ্ছে — নতুন ডেটা স্ট্রাকচার লিখলে এটিই প্রথম পরীক্ষা। টাকার যোগফল বড় হতে পারে, তাই long long; x × দৈর্ঘ্য গুণটিতে integer overflow সবচেয়ে সহজে ঢোকে।
#include <iostream>
#include <vector>
using namespace std;
using ll = long long;
vector<ll> a = {5, 3, 7, 2, 6, 1, 4, 8};
int n = a.size();
vector<ll> s(4 * n), tag(4 * n); // s = segment sum, tag = pending add
void build(int v, int l, int r) {
if (l == r) { s[v] = a[l]; return; }
int m = (l + r) / 2;
build(2 * v, l, m); build(2 * v + 1, m + 1, r); s[v] = s[2 * v] + s[2 * v + 1];
}
void apply(int v, int l, int r, ll x) { s[v] += x * (r - l + 1); tag[v] += x; }
void push(int v, int l, int m, int r) { // hand the note down to both children
if (tag[v]) { apply(2 * v, l, m, tag[v]); apply(2 * v + 1, m + 1, r, tag[v]); tag[v] = 0; }
}
void update(int v, int l, int r, int ql, int qr, ll x) {
if (qr < l || r < ql) return;
if (ql <= l && r <= qr) { apply(v, l, r, x); return; } // fully covered: write the note, stop
int m = (l + r) / 2; push(v, l, m, r);
update(2 * v, l, m, ql, qr, x); update(2 * v + 1, m + 1, r, ql, qr, x);
s[v] = s[2 * v] + s[2 * v + 1]; // pull up
}
ll query(int v, int l, int r, int ql, int qr) {
if (qr < l || r < ql) return 0;
if (ql <= l && r <= qr) return s[v];
int m = (l + r) / 2; push(v, l, m, r);
return query(2 * v, l, m, ql, qr) + query(2 * v + 1, m + 1, r, ql, qr);
}
int main() {
build(1, 0, n - 1);
struct Op { char t; int l, r; ll x; };
vector<Op> ops = {{'+', 2, 5, 10}, {'?', 0, 7, 0}, {'?', 3, 4, 0}, {'+', 0, 3, -2},
{'?', 1, 2, 0}, {'+', 4, 7, 5}, {'?', 2, 6, 0}, {'?', 5, 5, 0}};
for (auto [t, l, r, x] : ops) {
ll got = 0, want = 0;
if (t == '+') update(1, 0, n - 1, l, r, x); else got = query(1, 0, n - 1, l, r);
for (int i = l; i <= r; ++i) { if (t == '+') a[i] += x; else want += a[i]; } // brute force copy
if (t == '+') cout << "add " << x << " to [" << l << ',' << r << "]\n";
else cout << "sum[" << l << ',' << r << "] = " << got << " brute = " << want
<< " " << (got == want ? "ok" : "MISMATCH") << '\n';
}
}আউটপুট:
add 10 to [2,5]
sum[0,7] = 76 brute = 76 ok
sum[3,4] = 28 brute = 28 ok
add -2 to [0,3]
sum[1,2] = 16 brute = 16 ok
add 5 to [4,7]
sum[2,6] = 71 brute = 71 ok
sum[5,5] = 16 brute = 16 ok
প্রথম দুটি প্রশ্ন ঠিক ওপরের দুই ডায়াগ্রাম: [2, 5]-এ +10-এর পর মোট 76, আর [3, 4]-এর যোগফল 28. তারপর [0, 3] থেকে 2 করে তোলা হলো, আর sum[1,2] = 16: a[1] = 3 − 2 = 1, a[2] = 7 + 10 − 2 = 15. এখানে [2-3]-এর পুরোনো নোট আগের প্রশ্নেই নেমে গিয়েছিল; নতুন −2 আবার [0-3]-এ একটি নোট হয়ে বসে, আর প্রশ্ন [1, 2] সেটিকে আবার নিচে নামায়। প্রতিটি লাইনে ok — আটটি কাজের প্রতিটিতে tree আর brute force এক উত্তর দিয়েছে। apply ফাংশন leaf-এও tag লেখে; leaf-এর কোনো সন্তান নেই, তাই সেই tag কখনো পড়া হয় না — ক্ষতি নেই, কোড সরল থাকে। প্রতিটি কাজ O(log n), build O(n), জায়গা দুটি 4n অ্যারে — O(n).
a = [5, 3, 7, 2, 6, 1, 4, 8]
n = len(a)
s, tag = [0] * (4 * n), [0] * (4 * n) # s = segment sum, tag = pending add
def build(v, l, r):
if l == r:
s[v] = a[l]; return
m = (l + r) // 2
build(2 * v, l, m); build(2 * v + 1, m + 1, r)
s[v] = s[2 * v] + s[2 * v + 1]
def apply(v, l, r, x): # add x to every cell of node v
s[v] += x * (r - l + 1); tag[v] += x
def push(v, l, m, r): # hand the note down to both children
if tag[v]:
apply(2 * v, l, m, tag[v]); apply(2 * v + 1, m + 1, r, tag[v]); tag[v] = 0
def update(v, l, r, ql, qr, x):
if qr < l or r < ql: return
if ql <= l and r <= qr:
apply(v, l, r, x); return # fully covered: write the note and stop
m = (l + r) // 2; push(v, l, m, r)
update(2 * v, l, m, ql, qr, x); update(2 * v + 1, m + 1, r, ql, qr, x)
s[v] = s[2 * v] + s[2 * v + 1] # pull up
def query(v, l, r, ql, qr):
if qr < l or r < ql: return 0
if ql <= l and r <= qr: return s[v]
m = (l + r) // 2; push(v, l, m, r)
return query(2 * v, l, m, ql, qr) + query(2 * v + 1, m + 1, r, ql, qr)
build(1, 0, n - 1)
ops = [("+", 2, 5, 10), ("?", 0, 7), ("?", 3, 4), ("+", 0, 3, -2),
("?", 1, 2), ("+", 4, 7, 5), ("?", 2, 6), ("?", 5, 5)]
for t, l, r, *x in ops:
if t == "+":
update(1, 0, n - 1, l, r, x[0])
for i in range(l, r + 1): a[i] += x[0] # brute force copy
print(f"add {x[0]} to [{l},{r}]")
else:
got, want = query(1, 0, n - 1, l, r), sum(a[l:r + 1])
print(f"sum[{l},{r}] = {got} brute = {want} {'ok' if got == want else 'MISMATCH'}")আউটপুট C++-এর সাথে হুবহু এক:
add 10 to [2,5]
sum[0,7] = 76 brute = 76 ok
sum[3,4] = 28 brute = 28 ok
add -2 to [0,3]
sum[1,2] = 16 brute = 16 ok
add 5 to [4,7]
sum[2,6] = 71 brute = 71 ok
sum[5,5] = 16 brute = 16 ok
কাঠামো একই — চারটি ফাংশন, একই node নম্বর। for t, l, r, *x in ops লাইনে প্রশ্নের tuple-এ তিনটি মান, আপডেটের tuple-এ চারটি; বাড়তি মানটি x তালিকায় আসে। Recursion-এর গভীরতা মাত্র ⌈log₂ n⌉ + 1, তাই Python-এর recursion সীমা নিয়ে চিন্তা নেই। তবে Python-এ প্রতিটি ফাংশন কল ধীর; কনটেস্টে n = 2 × 105 আর ততগুলো কাজে এই কোড সময়সীমা ছাড়াতে পারে — তখন C++ বা নিচ থেকে ওপরে (iterative) লেখা lazy tree ভালো।
৫ · কেন ঠিক, আর কেন O(log n) — প্রমাণ
সঠিকতা
দাবি: প্রতিটি কাজের পর ওপরের invariant সত্য থাকে, আর query সঠিক উত্তর দেয়। শুরুতে (build-এর পর) কোনো নোট নেই, আর প্রতিটি sum[v] নিজের রেঞ্জের যোগফল — invariant সত্য। এবার দেখুন প্রতিটি মৌলিক কাজ invariant রাখে কি না।
- apply(v, x): v-এর রেঞ্জের প্রতিটি ঘরে সত্যিই x যোগ হয়েছে ধরে নিলে নতুন যোগফল sum[v] + x·len — ঠিক সেটিই লেখা হয়। v-এর সন্তানরা এই x জানে না, কিন্তু সেটি এখন v-এর tag-এ — “পূর্বপুরুষের ঝুলে থাকা নোট”, invariant যা অনুমতি দেয়।
- push(v): v-এর নোট দুই সন্তানে apply হলো আর v থেকে মুছল। সন্তানদের জন্য ঝুলে থাকা নোট থেকে এটি সরে তাদের নিজের মানে ঢুকল — invariant সত্য। নাতিদের জন্য নোটটি এখন সন্তানের গায়ে, তাই তাদের দিক থেকেও কিছু বদলায়নি।
- pull-up: push-এর পর v-এর নিজের tag ০, তাই v-এর রেঞ্জের যোগফল = দুই সন্তানের যোগফলের যোগ — ঠিক।
Query যে node-গুলোর মান ফেরত দেয়, সেগুলোতে পৌঁছানোর পথে সব পূর্বপুরুষ push করেছে — তাই তাদের মান পুরোপুরি সঠিক। আর ফেরত দেওয়া node-গুলোর রেঞ্জ ঠিক [ql, qr]-কে ভাগ করে (পর্ব ৩৪-এর যুক্তি), তাই যোগফল সঠিক। ∎ Induction-টি কাজের সংখ্যার ওপর: প্রতিটি কাজ invariant মেনে শুরু করে, invariant মেনে শেষ করে।
জটিলতা — প্রতিটি level-এ সর্বোচ্চ চারটি node
Update বা query-তে একটি node দেখা হলে তিন অবস্থার একটি: (ক) পুরোপুরি বাইরে, (খ) পুরোপুরি ঢাকা — দুই ক্ষেত্রেই O(1) কাজ করে থামে; (গ) আংশিক — শুধু এই node-ই নিচে নামে। দাবি: কোনো একটি level-এ সর্বোচ্চ দুটি node আংশিক. কারণ একই level-এর node-গুলোর রেঞ্জ পাশাপাশি, একে অন্যকে ছেদ না করে বসানো; একটি node আংশিক হতে হলে তার রেঞ্জের ভেতরে [ql, qr]-এর একটি প্রান্ত (ql বা qr-এর কিনারা) থাকতেই হবে — নইলে সে হয় পুরো ঢাকা, নয় পুরো বাইরে। প্রান্ত মাত্র দুটি, তাই আংশিক node সর্বোচ্চ দুটি। প্রতিটি আংশিক node পরের level-এ দুটি সন্তান দেখে, তাই প্রতিটি level-এ দেখা node সর্বোচ্চ ২ × ২ = ৪. Tree-র উচ্চতা ⌈log₂ n⌉, তাই একটি কাজে মোট দেখা node ≤ 4⌈log₂ n⌉ + 1.
প্রতিটি দেখা node-এ কাজ O(1): একটি push (দুটি apply), দুটি তুলনা, একটি pull-up. তাই প্রতিটি range update আর range query worst case-এ O(log n) — amortized নয়, প্রতিটি কাজেই। এখানে একটি শর্ত লুকিয়ে আছে, পরের অংশে যা বারবার আসবে: apply আর push প্রতিটি O(1) হতে হবে — অর্থাৎ tag থেকে node-এর নতুন মান সন্তানদের না দেখে বের করা যায়, আর দুটি tag জুড়ে একটি tag বানানো যায়। Build O(n), জায়গা sum আর tag মিলে 8n টি ঘর — O(n). ওপরের ডায়াগ্রামে [2, 5]-এর আপডেট ৭টি node দেখেছে, সীমা 4 × 3 + 1 = 13-এর অনেক নিচে।
4n কেন, 2n কেন নয়? n যখন ২-এর ঘাত নয় (যেমন n = 5 বা 6), tree পুরোপুরি ভরা থাকে না, আর 2v, 2v + 1 নিয়মে সবচেয়ে বড় node নম্বর 2n ছাড়িয়ে যেতে পারে। 4n সবসময় নিরাপদ সীমা (সূক্ষ্মভাবে 2⌈log₂ n⌉+1 যথেষ্ট)। Lazy tree-তে দুটি অ্যারে লাগে, আর দুটিরই একই আকার — tag অ্যারে ছোট রাখলে push-এ out-of-bounds লেখা হয়, যা অনেক সময় কোনো error ছাড়াই অন্য ডেটা নষ্ট করে।
৬ · Range assign আর tag-এর গঠন — সবচেয়ে প্রচলিত বাগ
যোগ ছাড়াও আরেকটি স্বাভাবিক আপডেট: range assign — “[l, r]-এর প্রতিটি ঘরের মান এখন থেকে c”. ব্যাংকের ভাষায়: একটি প্রকল্প শেষে কিছু অ্যাকাউন্টের ব্যালান্স নির্দিষ্ট একটি অঙ্কে বসিয়ে দেওয়া। একা assign-এর tag সহজ: node-এর নোট “সবাই c”, apply করলে sum[v] = c × len. কিন্তু খেয়াল করুন, এখানে “নোট নেই” আর “c = 0” আলাদা — 0-তে বসানোও একটি বৈধ আপডেট। তাই একটি আলাদা চিহ্ন (bool বা sentinel) লাগে।
আসল পরীক্ষা আসে যখন একই tree-তে add আর assign দুটোই চলে। একটি node-এ ধারাবাহিকভাবে কয়েকটি নোট জমতে পারে, আর তাদের ক্রম গুরুত্বপূর্ণ: “+3, তারপর 5-এ বসাও” মানে শেষে সবাই 5; কিন্তু “5-এ বসাও, তারপর +3” মানে সবাই 8. যেকোনো ক্রমের নোটকে একটি ছোট tag-এ গুটিয়ে রাখতে হয় — এর নাম tag composition. সমাধানটি হলো tag-কে একটি নির্দিষ্ট রূপে রাখা: (set, add) মানে “আগে set থাকলে সবাইকে set-এ বসাও, তারপর add যোগ করো”. এখন যেকোনো নতুন নোট এই রূপে মেশানো যায়:
| পুরোনো tag | নতুন নোট | মেশানো tag | কেন |
|---|---|---|---|
| (set s বা —, add a) | +x | (একই set, add a + x) | শেষে আরও x যোগ |
| (যা-ই থাকুক) | = c | (set c, add 0) | বসানো আগের সব ইতিহাস মুছে দেয় |
আর push-এ সন্তানের হাতে নোট দিতে হয় একই ক্রমে: আগে set, তারপর add. উল্টো ক্রমে দিলে — আগে add, তারপর set — সন্তানের set তার সদ্য-পাওয়া add মুছে দেয়। কোডটি compile হবে, অনেক পরীক্ষায় উত্তরও ঠিক আসবে, কারণ ভুলটি শুধু তখনই দেখা দেয় যখন একটি node-এ set আর add দুটোই একসাথে ঝুলে থাকে। একটি node-এ ধাপে ধাপে দেখুন:
| এই ধাপের কাজ | |
| ঘরগুলোর আসল মান | |
| [0-3]: sum / tag | |
| [0-1]: sum / tag | |
| [0-1]-এর হওয়া উচিত |
- শুরু. Node [0-3]-এর ঘরে 1, 3, 2, 4 — যোগফল 10. বাঁ সন্তান [0-1] = 1 + 3 = 4. কোনো নোট নেই; “—” মানে set নেই।
- +3. পুরো node ঢাকা, apply: 10 + 3 × 4 = 22, tag (—, +3). সন্তান বাসি — তার আসল মান 10, কিন্তু সে এখনো 4 দেখায়; invariant অনুযায়ী এটি ঠিক আছে।
- = 5. Assign সবকিছু মুছে দেয়: sum = 5 × 4 = 20, tag (5, 0). আগের +3 কোথায় গেল? কোথাও না — 5-এ বসানোর পর আগের যোগের কোনো প্রভাব থাকে না। এখানে add-কে ০ না করলে পরে ভুল হতো।
- +2. Set-এর ওপর add: tag (5, +2), মানে “আগে 5-এ বসাও, তারপর 2 যোগ”. sum = 20 + 2 × 4 = 28. প্রতিটি ঘর আসলে 7.
- Push, সঠিক ক্রম. সন্তান [0-1] আগে set পায়: sum = 5 × 2 = 10, tag (5, 0); তারপর add: sum = 10 + 2 × 2 = 14, tag (5, +2). ঠিক 7 + 7 = 14 ✓. Parent-এর tag মুছে গেল।
- Push, ভুল ক্রম. একই অবস্থা থেকে, সন্তান আগে add পেলে: sum = 4 + 2 × 2 = 8, tag (—, +2); তারপর set: sum = 10, tag (5, 0) — set সদ্য-পাওয়া +2 মুছে দিল! সন্তান এখন 10 বলে, আসল 14. Parent 28 ঠিক দেখায়, তাই ভুলটি ধরা পড়ে শুধু কেউ [0-1]-এর ভেতরে জিজ্ঞেস করলে।
এবার দুটি tree পাশাপাশি — একটিতে সঠিক push, অন্যটিতে “আগে add” — আর দুটিকেই brute force-এর সাথে মেলানো। কোড ছোট রাখতে build-এর বদলে প্রতিটি ঘরে আলাদা assign (O(n log n), এখানে সমস্যা নয়)।
#include <iostream>
#include <string>
#include <vector>
using namespace std; using ll = long long;
struct Seg { // range assign + range add, range sum
int n; bool addFirst; vector<ll> s, st, ad; vector<bool> has; // has[v]: assign pending
Seg(const vector<ll>& a, bool bug) : n(a.size()), addFirst(bug), s(4 * n), st(4 * n), ad(4 * n), has(4 * n)
{ for (int i = 0; i < n; ++i) update(1, 0, n - 1, i, i, '=', a[i]); } // build by point assigns
void apply(int v, int len, char k, ll x) {
if (k == '=') { s[v] = x * len; st[v] = x; has[v] = true; ad[v] = 0; } // assign wipes old adds
else { s[v] += x * len; ad[v] += x; } // add goes after any assign
}
void push(int v, int l, int m, int r) {
for (char k : string(addFirst ? "+=" : "=+")) { // correct order: '=' first
ll x = (k == '=') ? st[v] : ad[v];
if (k == '=' ? bool(has[v]) : x != 0) { apply(2 * v, m - l + 1, k, x); apply(2 * v + 1, r - m, k, x); }
}
has[v] = false; ad[v] = 0;
}
void update(int v, int l, int r, int ql, int qr, char k, ll x) {
if (qr < l || r < ql) return;
if (ql <= l && r <= qr) { apply(v, r - l + 1, k, x); return; }
int m = (l + r) / 2; push(v, l, m, r);
update(2 * v, l, m, ql, qr, k, x); update(2 * v + 1, m + 1, r, ql, qr, k, x);
s[v] = s[2 * v] + s[2 * v + 1];
}
ll query(int v, int l, int r, int ql, int qr) {
if (qr < l || r < ql) return 0;
if (ql <= l && r <= qr) return s[v];
int m = (l + r) / 2; push(v, l, m, r);
return query(2 * v, l, m, ql, qr) + query(2 * v + 1, m + 1, r, ql, qr);
}
};
int main() {
vector<ll> a = {5, 3, 7, 2, 6, 1, 4, 8}; Seg good(a, false), bad(a, true);
struct Op { char t; int l, r; ll x; };
vector<Op> ops = {{'=', 0, 7, 5}, {'+', 0, 7, 2}, {'?', 2, 3, 0}, {'=', 2, 5, 1}, {'+', 4, 7, 3}, {'?', 0, 7, 0}, {'?', 4, 5, 0}};
for (auto [t, l, r, x] : ops) {
ll brute = 0; for (int i = l; i <= r; ++i) { if (t == '=') a[i] = x; else if (t == '+') a[i] += x; else brute += a[i]; }
if (t == '?') { cout << "sum[" << l << ',' << r << "]: correct = " << good.query(1, 0, 7, l, r) << ", brute = "
<< brute << ", add-first bug = " << bad.query(1, 0, 7, l, r) << '\n'; continue; }
good.update(1, 0, 7, l, r, t, x); bad.update(1, 0, 7, l, r, t, x);
cout << (t == '=' ? "set " : "add ") << x << " on [" << l << ',' << r << "]\n";
}
}আউটপুট:
set 5 on [0,7]
add 2 on [0,7]
sum[2,3]: correct = 14, brute = 14, add-first bug = 10
set 1 on [2,5]
add 3 on [4,7]
sum[0,7]: correct = 44, brute = 44, add-first bug = 36
sum[4,5]: correct = 8, brute = 8, add-first bug = 8
প্রথম দুই কাজে root-এ জমল tag (5, +2) — ঠিক ডায়াগ্রামের অবস্থা। প্রশ্ন [2, 3]-এ root থেকে নোট নামে: সঠিক tree-তে উত্তর 7 + 7 = 14, brute force-ও 14; ভুল tree-তে set সদ্য-নামা +2 মুছে দেয়, তাই 5 + 5 = 10. পরের প্রশ্নেও ভুলটি থেকে যায় (44-এর বদলে 36), কারণ বাসি ভুল মান pull-up হয়ে ওপরে ছড়িয়েছে। কিন্তু শেষ প্রশ্ন [4, 5]-এ দুটি tree একই উত্তর দেয় (8) — সেখানে নতুন set 1 আগের ভুল মান মুছে দিয়েছে। ঠিক এই কারণেই এমন বাগ বিপজ্জনক: কিছু পরীক্ষায় পাস করে। apply-এ assign হলে ad[v] = 0 আর push-এ "=+" ক্রম — এই দুটি লাইনই পুরো composition-এর নিয়ম। প্রতিটি কাজ O(log n).
class Seg: # range assign + range add, range sum
def __init__(self, a, add_first=False):
self.n, self.add_first = len(a), add_first
self.s, self.st, self.ad = [0] * (4 * self.n), [None] * (4 * self.n), [0] * (4 * self.n)
for i, x in enumerate(a): self.update(1, 0, self.n - 1, i, i, "=", x)
def apply(self, v, ln, kind, x):
if kind == "=": self.s[v], self.st[v], self.ad[v] = x * ln, x, 0 # assign wipes old adds
else: self.s[v] += x * ln; self.ad[v] += x # add goes after any assign
def push(self, v, l, m, r):
order = ["+", "="] if self.add_first else ["=", "+"] # correct order: "=" first
for kind in order:
x = self.st[v] if kind == "=" else self.ad[v]
if x is None or (kind == "+" and x == 0): continue
self.apply(2 * v, m - l + 1, kind, x); self.apply(2 * v + 1, r - m, kind, x)
self.st[v], self.ad[v] = None, 0
def update(self, v, l, r, ql, qr, kind, x):
if qr < l or r < ql: return
if ql <= l and r <= qr: return self.apply(v, r - l + 1, kind, x)
m = (l + r) // 2; self.push(v, l, m, r)
self.update(2 * v, l, m, ql, qr, kind, x); self.update(2 * v + 1, m + 1, r, ql, qr, kind, x)
self.s[v] = self.s[2 * v] + self.s[2 * v + 1]
def query(self, v, l, r, ql, qr):
if qr < l or r < ql: return 0
if ql <= l and r <= qr: return self.s[v]
m = (l + r) // 2; self.push(v, l, m, r)
return self.query(2 * v, l, m, ql, qr) + self.query(2 * v + 1, m + 1, r, ql, qr)
a = [5, 3, 7, 2, 6, 1, 4, 8]
good, bad = Seg(a), Seg(a, add_first=True)
ops = [("=", 0, 7, 5), ("+", 0, 7, 2), ("?", 2, 3), ("=", 2, 5, 1), ("+", 4, 7, 3), ("?", 0, 7), ("?", 4, 5)]
for t, l, r, *x in ops:
if t == "?":
g, b = good.query(1, 0, 7, l, r), bad.query(1, 0, 7, l, r)
print(f"sum[{l},{r}]: correct = {g}, brute = {sum(a[l:r + 1])}, add-first bug = {b}")
continue
good.update(1, 0, 7, l, r, t, x[0]); bad.update(1, 0, 7, l, r, t, x[0])
for i in range(l, r + 1): a[i] = x[0] if t == "=" else a[i] + x[0]
print(f"{'set' if t == '=' else 'add'} {x[0]} on [{l},{r}]")আউটপুট C++-এর সাথে হুবহু এক:
set 5 on [0,7]
add 2 on [0,7]
sum[2,3]: correct = 14, brute = 14, add-first bug = 10
set 1 on [2,5]
add 3 on [4,7]
sum[0,7]: correct = 44, brute = 44, add-first bug = 36
sum[4,5]: correct = 8, brute = 8, add-first bug = 8
Python-এ “set নেই” বোঝাতে None — 0-তে বসানো থেকে আলাদা রাখার সবচেয়ে পরিষ্কার উপায়। push-এর লুপে দুই ধরনের নোট একটি ক্রম-তালিকা ধরে নামে; add_first=True দিলে ক্রমটি উল্টে যায় — বাকি কোড হুবহু এক, তাই পার্থক্যটি পুরোপুরি এই একটি সিদ্ধান্তের ফল। শেষে self.st[v], self.ad[v] = None, 0 parent-এর নোট মুছে দেয়।
সাধারণ নিয়মটি এভাবে মনে রাখুন। প্রতিটি tag আসলে একটি ফাংশন f: “প্রতিটি ঘরের মান x-কে f(x) করো”. দুটি নোট পরপর এলে — প্রথমে g, তারপর f — মিলিত নোট f∘g, অর্থাৎ x → f(g(x)). ফাংশন জোড়ার ক্রম সাধারণত বদলানো যায় না (set আর add তার প্রমাণ), তাই নতুন নোট সবসময় পুরোনোটির “পরে” বসে। Push-এ parent-এর নোট সন্তানের পুরোনো নোটের পরে বসে, কারণ parent-এর নোট সময়ের দিক থেকে নতুন — সন্তানের নোট parent-এর নোটের আগে জমা হয়েছিল (নইলে আগেই push হয়ে যেত)।
৭ · Range min/max, XOR flip আর অন্য জুটি — কোন tag কোথায় চলে
Range add-এর সাথে প্রশ্ন যদি যোগফল না হয়ে সর্বনিম্ন (min) বা সর্বোচ্চ (max) হয়? কোড প্রায় একই — শুধু apply বদলায়। রেঞ্জের প্রতিটি ঘরে x যোগ করলে সবচেয়ে ছোট ঘরটিও x বাড়ে, তাই mn[v] += x — দৈর্ঘ্য দিয়ে গুণ নেই. যোগফলের কোড কপি করে min বানাতে গিয়ে × len রেখে দেওয়া একটি পরিচিত ভুল। Range assign-এ mn[v] = c. Pull-up হয় min(mn[2v], mn[2v+1]).
প্রতিটি নতুন সমস্যায় দুটি প্রশ্ন করুন। (১) Tag থেকে node-এর নতুন মান কি শুধু node-এর পুরোনো মান আর দৈর্ঘ্য দিয়েই বের হয়? (২) দুটি tag কি একটি tag-এ গুটানো যায়? দুটির উত্তর হ্যাঁ হলে lazy propagation চলবে, প্রতিটি কাজ O(log n).
| আপডেট | প্রশ্ন | node-এর নতুন মান | দুই tag মেশানো |
|---|---|---|---|
| +x | যোগফল | sum + x·len | x₁ + x₂ |
| +x | min / max | mn + x | x₁ + x₂ |
| = c | যোগফল / min | c·len / c | নতুনটি জেতে |
| ×b তারপর +c | যোগফল | b·sum + c·len | (b₂b₁, b₂c₁ + c₂) |
| flip (0 ↔ 1) | কয়টি 1 | len − cnt | t₁ XOR t₂ |
| a[i] = min(a[i], x) | যোগফল | শুধু sum থেকে বের হয় না ✗ | — |
শেষ সারিটি শিক্ষণীয়: “প্রতিটি ঘরকে x-এ ছেঁটে দাও” আপডেটের পর নতুন যোগফল নির্ভর করে কোন ঘরগুলো x-এর চেয়ে বড় ছিল তার ওপর — শুধু পুরোনো যোগফল দিয়ে তা জানা যায় না। শর্ত (১) ভাঙল, তাই সাধারণ lazy চলে না (অংশ ৮-এর “আরও গভীরে” দেখুন)। গুণ-যোগের সারিটিও লক্ষ করুন: ×b তারপর +c একটি affine ফাংশন x → bx + c, আর দুটি affine ফাংশন জুড়লে আবার affine — তাই tag দুটি সংখ্যাতেই থাকে।
XOR tag — রাস্তার বাতির সারি
সিটি করপোরেশনের একটি রাস্তায় n টি বাতি, প্রতিটি জ্বলছে (1) বা নিভে আছে (0). নিয়ন্ত্রণকক্ষ থেকে আদেশ আসে “[l, r]-এর প্রতিটি বাতির সুইচ উল্টে দাও” — জ্বলা নেভে, নেভা জ্বলে; আর প্রশ্ন আসে “[l, r]-এ এখন কয়টি জ্বলছে?”. Node রাখে জ্বলা বাতির সংখ্যা cnt; উল্টালে জ্বলা হয় নেভা, তাই নতুন cnt = len − cnt. Tag একটি bit: “নিচে একবার উল্টানো বাকি”. দুবার উল্টালে আগের অবস্থা — তাই tag মেশানো মানে XOR: t ^= 1. চারটি বাতি 1 0 1 1-এ দেখুন:
| এই ধাপের কাজ | |
| বাতির আসল অবস্থা | |
| root-এর মান |
- শুরু. বাতি 1 0 1 1: root-এ ৩টি জ্বলছে, [0-1]-এ ১টি, [2-3]-এ ২টি।
- flip [0, 3]. পুরো রাস্তা — root পুরো ঢাকা: cnt = 4 − 3 = 1, গায়ে flip-নোট। নিচের সবাই বাসি; আসলে এখন বাতি 0 1 0 0.
- flip [1, 2] শুরু. Root আংশিক, তাই আগে push: [0-1] = 2 − 1 = 1, [2-3] = 2 − 2 = 0, দুজনের গায়ে flip-নোট, root-এর নোট মুছল।
- বাঁ দিক. [0-1] আংশিক, push: [0] 1 → 0, [1] 0 → 1; নোট মুছল। তারপর [1] পুরো ঢাকা, নিজেই flip: 1 → 0. Pull-up: [0-1] = 0.
- ডান দিক আর শেষ. [2-3] আংশিক, push: [2] 1 → 0, [3] 1 → 0; তারপর [2] flip: 0 → 1. [2-3] = 1, root = 0 + 1 = 1. বাতি 0 0 1 0 — মোট ১টি জ্বলছে, ঠিক। কোনো node-এ দুটি flip-নোট জমলে XOR তাদের কাটাকাটি করে দিত: 1 ^ 1 = 0.
৮ · প্রয়োগ, আর কখন lazy tree লাগেই না
তিনটি চেনা প্রয়োগ
- দেয়াল রং করা (interval painting). একটি লম্বা দেয়াল n ভাগে; বিভিন্ন দল [l, r] অংশটি রং c-তে রং করে, পরের রং আগেরটি ঢেকে দেয়। প্রশ্ন: “ঘর i-এর রং কী?”, বা “[l, r]-এ কতগুলো ঘর রং c?”. এটি range assign — নতুন নোট পুরোনোটিকে পুরোপুরি মুছে দেয়। একটি node-এ “পুরো রেঞ্জ এক রঙের কি না, হলে কোন রং” রাখলে প্রশ্নের উত্তরও সহজ।
- বাতি বা সুইচ (flip). ওপরের XOR tag. একই ছাঁচে একটি bit-স্ট্রিংয়ের রেঞ্জ উল্টানো আর “কয়টি 1” গোনা — কনটেস্টে খুব পরিচিত।
- ব্যাংকের অ্যাকাউন্ট. একসাথে জমা বা উত্তোলন (range add), সুদ যোগ (range multiply — affine tag), নির্দিষ্ট অঙ্কে বসানো (range assign), আর মোট বা সর্বনিম্ন ব্যালান্সের প্রশ্ন (“কোনো অ্যাকাউন্ট কি ঋণাত্মক হলো?” — এর জন্য range min লাগে)। বাস্তবের ব্যাংক অবশ্যই প্রতিটি লেনদেন আলাদা করে লেখে; কিন্তু হিসাব-বিশ্লেষণ বা সিমুলেশনে এই ধাঁচ কাজে আসে।
বিকল্প — সবসময় সবচেয়ে সরল যন্ত্রটি বেছে নিন
সব আপডেট আগে, সব প্রশ্ন পরে? তাহলে lazy tree অপচয়। পর্ব ১৭-এর difference array-তে প্রতিটি রেঞ্জ আপডেট O(1) (d[l] += x; d[r+1] -= x), শেষে একবার prefix sum নিয়ে অ্যারে বানান, আরেকবার prefix sum নিয়ে প্রতিটি রেঞ্জের যোগফল O(1) — মোট O(n + q). এটি offline পরিস্থিতির সেরা উত্তর।
শুধু range add আর range sum, মিশ্র ক্রমে? দুটি Fenwick tree (BIT) দিয়েই হয়, যা পর্ব ৩৬-এ বিস্তারিত দেখবেন। ধারণাটির একটি ঝলক: 1-based-এ [l, r]-এ x যোগ করতে B1-এ l-এ +x, r + 1-এ −x, আর B2-এ l-এ +x·(l − 1), r + 1-এ −x·r; তখন প্রথম i টি ঘরের যোগফল = B1-এর prefix(i) × i − B2-এর prefix(i). কোড অনেক ছোট আর দ্রুত — কিন্তু শুধু যোগের জন্য। Assign, min বা flip এলে segment tree-তেই ফিরতে হয়।
আরও সরল কিছু চাই? Sqrt decomposition (পর্ব ৩৭) অ্যারেকে √n আকারের ব্লকে ভাগ করে, আর পুরো ব্লকের জন্য একটি “ব্লক-নোট” রাখে — ঠিক lazy tag-এর এক-স্তরের রূপ। প্রতিটি কাজ O(√n), কিন্তু কোড সহজ আর অদ্ভুত ধরনের আপডেটেও চলে।
আরও গভীরে — segment tree beats আর persistent segment tree: “a[i] = min(a[i], x)” ধরনের আপডেটের জন্য চীনের প্রোগ্রামার Ji Ruyi প্রায় ২০১৬ সালে segment tree beats নামের কৌশল দেন: node-এ সর্বোচ্চ মান, দ্বিতীয় সর্বোচ্চ মান আর সর্বোচ্চ মানটি কয়বার আছে — এই তিনটি রাখলে, x যদি দ্বিতীয় সর্বোচ্চের চেয়ে বড় হয়, শুধু সর্বোচ্চ ঘরগুলো বদলায় আর যোগফল O(1)-এ ঠিক করা যায়; নইলে আরও নিচে নামতে হয়, আর একটি potential-ভিত্তিক amortized যুক্তি দেখায় মোট খরচ সীমিত থাকে। অন্যদিকে persistent segment tree প্রতিটি আপডেটে নতুন node বানিয়ে পুরোনো সব সংস্করণ রেখে দেয় — “গত মঙ্গলবারের ব্যালান্স কত ছিল?”. সেখানে push নিজেও নতুন node বানায়, তাই অনেকে নোট না নামিয়ে স্থায়ীভাবে রেখে দেওয়ার (permanent tag) কৌশল নেন: প্রশ্নের সময় পথের নোটগুলো জমিয়ে যোগ করা হয়।
পরীক্ষা ও ইন্টারভিউতে:
- Lazy propagation ছাড়া segment tree-তে রেঞ্জ আপডেটের খরচ কত? — k টি point update-এ O(k log n), worst case O(n log n); lazy দিয়ে O(log n).
- Lazy segment tree-র একটি কাজ কেন O(log n)? — প্রতিটি level-এ সর্বোচ্চ দুটি node আংশিক মেলে, তাই সর্বোচ্চ ৪টি node দেখা হয়; উচ্চতা ⌈log₂ n⌉.
- Push-down কখন করতে হয়? — কোনো node-এর সন্তানে নামার ঠিক আগে, update আর query দুটোতেই; ফেরার পরে pull-up.
- Add আর assign একসাথে থাকলে push-এ কোনটি আগে? — Assign আগে, তারপর add; আর নতুন assign এলে জমা add ০ করতে হয়।
- সব আপডেট শেষে সব প্রশ্ন এলে কী ব্যবহার করবেন? — Difference array আর prefix sum, মোট O(n + q).
৯ · AI-এর যুগে lazy segment tree — চল্লিশ লাইনের কোড, এক লাইনের ভুল
“Lazy segment tree লিখে দাও” — AI সহকারী কয়েক সেকেন্ডে সুন্দর কোড দেবে, আর প্রায়ই সেটি নমুনা ইনপুটে ঠিক উত্তরও দেবে। সমস্যা হলো, lazy tree-র প্রায় সব ভুল নীরব: compile হয়, চলে, অনেক ইনপুটে ঠিক উত্তর দেয়, আর হঠাৎ একটি নির্দিষ্ট ক্রমের কাজে ভুল সংখ্যা ফেরত দেয়। অংশ ৬-এর ভুল push ক্রম তিনটির দুটি প্রশ্নে ধরা পড়েছিল, একটিতে পড়েনি। আর complexity জিজ্ঞেস করলে AI কখনো “O(log n) amortized” বলে — অথচ এখানে প্রতিটি কাজ worst case-এই O(log n); amortized শব্দটি segment tree beats-এর জন্য।
Lazy tree-র কোডে AI সাধারণত যেসব ভুল করে
| AI-এর ভুল | কীভাবে ধরবেন |
|---|---|
| Query-তে push বাদ | একটি রেঞ্জ আপডেটের পর তার ভেতরের একটি একক ঘর জিজ্ঞেস করুন |
| Update-এ নামার আগে push বাদ, শুধু pull-up | একই রেঞ্জে দুটি ভিন্ন আপডেট, তারপর আংশিক ঢাকা রেঞ্জে আরেকটি — root-এর মান brute force-এর সাথে মেলান |
Min/max tree-তে tag × len | দৈর্ঘ্য ২-এর বেশি এমন রেঞ্জে +1 দিয়ে min জিজ্ঞেস করুন |
| Assign-এ পুরোনো add মোছে না, বা push-এ add আগে | = c তারপর +x, তারপর ভেতরের একটি ছোট রেঞ্জ — অংশ ৬-এর ক্রম |
| “set নেই” বোঝাতে 0 ব্যবহার | একটি রেঞ্জ 0-তে বসিয়ে যোগফল জিজ্ঞেস করুন |
tag অ্যারে 2n বা n আকারের, বা int-এ গুণফল | n = 5, 6, 7 আর বড় মান (109) দিয়ে চালান |
AI-কে কোড লিখে দিতে না বলে, আপনার কোড আর trace যাচাই করতে বলুন — আর একটি stress test বানাতে সাহায্য নিন:
Lazy tree যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১“দরকার না হওয়া পর্যন্ত কাজ করো না” — lazy propagation-এর এই নীতি কম্পিউটার বিজ্ঞানের আর কোথায় কোথায় দেখা যায়?
অলসতা কম্পিউটার বিজ্ঞানের সবচেয়ে ফলপ্রসূ ধারণাগুলোর একটি। প্রোগ্রামিং ভাষার জগতে এর নাম lazy evaluation: একটি expression-এর মান হিসাব করা হয় তখনই, যখন কেউ সেটি সত্যিই চায় — আর একবার হিসাব হলে মনে রাখা হয়। এই “call-by-need” ধারণা ১৯৭১ সালে Christopher Wadsworth তাঁর গবেষণায় বর্ণনা করেন, আর পরে Haskell ভাষা (প্রথম সংস্করণ ১৯৯০) এটিকে ডিফল্ট আচরণ বানায়। Haskell-এ আপনি “সব মৌলিক সংখ্যার অসীম তালিকা” লিখতে পারেন, কারণ তালিকাটির শুধু ততটুকুই হিসাব হয়, যতটুকু পড়া হয় — ঠিক যেমন lazy tree-তে শুধু ততগুলো নোট নামে, যতগুলো কোনো প্রশ্ন ছোঁয়।
অপারেটিং সিস্টেমে একই নীতির নাম copy-on-write. Unix-এ একটি প্রক্রিয়া fork করলে নতুন প্রক্রিয়ার জন্য পুরো মেমরি সাথে সাথে কপি হয় না; দুজনে একই পাতা (page) ভাগ করে, আর কেউ একটি পাতায় লিখতে গেলে তখনই শুধু সেই পাতাটি কপি হয়। বেশিরভাগ ক্ষেত্রে নতুন প্রক্রিয়া সাথে সাথে অন্য একটি প্রোগ্রাম চালু করে, তাই সেই কপির বেশিরভাগ কখনো দরকারই হয় না। Lazy tag-এর মতোই: কাজটি “বাকি” বলে চিহ্নিত, কিন্তু কেউ না ছোঁয়া পর্যন্ত করা হয় না।
এই কোর্সেই আপনি এর আরেক রূপ দেখেছেন — heap-এ lazy deletion (পর্ব ২৩): পুরোনো উপাদান সাথে সাথে না সরিয়ে রেখে দেওয়া, আর পরে বের হলে বাদ দেওয়া। ডেটাবেসে মুছে ফেলা সারি প্রথমে শুধু “মৃত” চিহ্নিত হয় আর পরে একসাথে পরিষ্কার হয়; ওয়েব পেজে ছবি লোড হয় শুধু স্ক্রল করে তার কাছে গেলে।
তবে অলসতার দাম আছে। জমে থাকা কাজ একসাথে এলে হঠাৎ দেরি হতে পারে, আর “কী বাকি আছে” তার হিসাব রাখতে বাড়তি জায়গা আর জটিলতা লাগে — Haskell-এ জমে থাকা অহিসাবি expression মেমরি ভরিয়ে ফেলার সমস্যা (space leak) সুপরিচিত। Lazy segment tree এখানে ভাগ্যবান: প্রতিটি node-এ নোট সবসময় একটি ছোট, নির্দিষ্ট আকারের tag-এ গুটিয়ে যায় (composition), তাই জমে থাকা কাজ কখনো ফুলে ওঠে না, আর প্রতিটি push O(1).
মূল উপলব্ধি: অলসতা তখনই কাজে আসে, যখন বাকি কাজটি ছোট আকারে লিখে রাখা যায় আর পরে সস্তায় করা যায়। Lazy propagation-এর দুটি শর্ত — tag থেকে মান বের করা আর tag মেশানো — আসলে এই সাধারণ নীতিরই নির্দিষ্ট রূপ।
প্র ০২Tag-এর গঠন কি শুধু কোডিংয়ের কৌশল, নাকি এর পেছনে কোনো বীজগণিত আছে? একটি “সাধারণ” lazy segment tree কীভাবে লেখা হয়?
পেছনে সুন্দর একটি বীজগণিত আছে। প্রথমে node-এর মানগুলো দেখুন। দুই সন্তানের মান জুড়ে parent-এর মান বানানো হয় একটি ক্রিয়া ⊕ দিয়ে (যোগ, min, max, gcd), যা associative — (a ⊕ b) ⊕ c = a ⊕ (b ⊕ c) — আর যার একটি “নিরপেক্ষ” মান আছে (যোগে 0, min-এ +∞). এমন কাঠামোর নাম monoid. সাধারণ segment tree যেকোনো monoid-এর ওপর চলে।
এবার tag-গুলো। প্রতিটি tag একটি ফাংশন f, যা node-এর মান বদলায়। Lazy tree চলার জন্য তিনটি শর্ত লাগে। (১) tag-গুলোর নিজেদের মধ্যে জোড়া (composition) আবার একটি tag — আর এরও একটি নিরপেক্ষ রূপ আছে (“কিছু করো না”); অর্থাৎ tag-গুলোও একটি monoid. (২) tag-কে পুরো node-এ একবারে প্রয়োগ করা যায় — f(a ⊕ b) = f(a) ⊕ f(b), যাতে সন্তানদের না দেখেই parent-এর নতুন মান বের হয়। (৩) এই সব কাজ O(1). যোগফলের ক্ষেত্রে “+x” সরাসরি এই শর্ত মানে না (দুই সন্তানে x যোগ করলে যোগফলে 2x বাড়ে, x নয়) — তাই node-এর মানে দৈর্ঘ্যটিও রাখা হয়: মান = (sum, len), আর f(sum, len) = (sum + x·len, len). এই সামান্য কৌশলই যোগফলকে নিয়মের ভেতরে আনে।
এই দৃষ্টিভঙ্গির সবচেয়ে পরিচিত বাস্তব রূপ জাপানের AtCoder-এর প্রকাশিত AtCoder Library (ACL, ২০২০). এর lazy_segtree একটিই template: আপনি দেন মানের type, তার ক্রিয়া op আর নিরপেক্ষ মান e, tag-এর type, tag প্রয়োগ mapping, দুই tag জোড়া composition আর নিরপেক্ষ tag id. বাকি tree-র কোড একবার লেখা, বারবার ব্যবহার। Add + sum, affine + sum, flip + count — সব একই template-এ, শুধু ছয়টি ছোট টুকরো বদলায়। এখানেও composition-এর ক্রম নিয়ে সাবধান থাকতে হয়: composition(f, g) মানে “আগে g, তারপর f” — ঠিক অংশ ৬-এর f∘g.
বিশ্ববিদ্যালয়ের বীজগণিতে এই কাঠামোর নাম “monoid action”: একটি monoid (tag) আরেকটি monoid-এর (মান) ওপর কাজ করে, আর সেই কাজ ⊕-কে সম্মান করে। নামটি ভারী শোনালেও ধারণাটি অংশ ৭-এর দুটি প্রশ্নই। কোনো নতুন সমস্যায় আপনি যদি এই তিনটি শর্ত দেখাতে পারেন, তাহলে lazy segment tree কাজ করবে — আর না পারলে (যেমন chmin + sum) আপনি আগেই জানেন সাধারণ উপায়ে হবে না।
মূল উপলব্ধি: Lazy segment tree একটি নির্দিষ্ট কোড নয়, একটি ছাঁচ — “মানের monoid, tag-এর monoid, আর তাদের সংযোগ”. ছাঁচটি চিনলে নতুন সমস্যার জন্য শুধু ছয়টি ছোট টুকরো ভাবতে হয়।
প্র ০৩Segment tree-র জন্ম হয়েছিল আয়তক্ষেত্রের ক্ষেত্রফলের একটি জ্যামিতি সমস্যা থেকে — সেখানে “নোট কখনো না নামানো”-র কৌশল কেন কাজ করে?
১৯৭৭ সালে গণিতবিদ Victor Klee একটি প্রশ্ন তোলেন: সংখ্যারেখায় n টি interval দেওয়া থাকলে তাদের মিলনের মোট দৈর্ঘ্য কি O(n log n)-এর চেয়ে দ্রুত বের করা যায়? এর দুই-মাত্রার রূপ — n টি আয়তক্ষেত্র পরস্পরকে ঢেকে দিলে মোট কত ক্ষেত্রফল ঢাকা পড়ে — আজ Klee's measure problem নামে পরিচিত। একই বছর Jon Bentley একটি পদ্ধতি দেন, যা O(n log n)-এ দুই-মাত্রার উত্তর বের করে; তার কেন্দ্রে ছিল যে কাঠামো, তা-ই আজকের segment tree. বাস্তবে এর প্রয়োগ আছে VLSI চিপের নকশায় (কত জায়গা ধাতুর স্তরে ঢাকা) আর মানচিত্রের হিসাবে।
পদ্ধতিটি একটি sweep line: একটি উল্লম্ব রেখা বাঁ থেকে ডানে সরে, আর প্রতিটি আয়তক্ষেত্রের বাঁ প্রান্তে y-রেঞ্জ [y1, y2]-এ “+1 ঢাকনা”, ডান প্রান্তে “−1”. প্রতিটি থামার জায়গায় জানতে হয় y-অক্ষের কতটুকু অন্তত একটি ঢাকনায় ঢাকা। এটি range add আর “কতগুলো ঘর শূন্য নয়” প্রশ্ন — যা সরাসরি lazy-র শর্ত মানে না (−1-এর পর কোন ঘর শূন্যে নামল, তা শুধু যোগফল থেকে জানা যায় না)।
সমাধানটি চতুর: node-এ একটি cover-গণনা রাখা হয় — “আমার পুরো রেঞ্জ কতবার সরাসরি ঢাকা” — আর নোটটি কখনো নামানো হয় না. Node-এর “ঢাকা দৈর্ঘ্য” হিসাব হয় এভাবে: cover > 0 হলে পুরো দৈর্ঘ্য, নইলে দুই সন্তানের ঢাকা দৈর্ঘ্যের যোগ। কৌশলটি কাজ করে একটি বিশেষ কারণে: প্রতিটি −1 ঠিক তার আগের +1-এর একই রেঞ্জে আসে, তাই একই node-গুলোতেই পড়ে — নোট নামানোর দরকারই হয় না, আর প্রশ্ন সবসময় শুধু root-এর। এটিই permanent tag-এর প্রাচীনতম উদাহরণগুলোর একটি।
Klee-র মূল প্রশ্নের উত্তর পরের বছরই নেতিবাচক আসে: Michael Fredman আর Bruce Weide (১৯৭৮) দেখান যে decision tree ধাঁচের মডেলে এক-মাত্রাতেও Ω(n log n) সময় লাগবেই (পর্ব ২৪-এর তুলনা-সর্টের নিম্নসীমার কাছাকাছি যুক্তি)। আর উচ্চতর মাত্রায় Klee's measure problem আজও গবেষণার বিষয় — d-মাত্রায় কত দ্রুত সম্ভব, তা নিয়ে কয়েক দশক ধরে ধীরে ধীরে উন্নতি হয়েছে।
মূল উপলব্ধি: Push-down কোনো পবিত্র নিয়ম নয় — এটি দরকার কারণ সাধারণত নোট আর প্রশ্ন আলাদা জায়গায় পড়ে। সমস্যার কাঠামো যদি নিশ্চয়তা দেয় যে নোট আর প্রশ্ন একই node-এ মিলবে, তাহলে নোট স্থায়ীভাবে রেখে দেওয়াই সহজ আর দ্রুত।
প্র ০৪“প্রতিটি ঘরকে x-এ ছেঁটে দাও” (chmin) আপডেটে সাধারণ lazy কেন ব্যর্থ, আর segment tree beats কীভাবে সেই দেয়াল ভাঙে?
ধরুন একটি সরকারি প্রকল্পে নিয়ম হলো: “[l, r]-এর কোনো অ্যাকাউন্টে x-এর বেশি ভর্তুকি থাকবে না — বেশি থাকলে x-এ নামিয়ে আনো”. এটি a[i] = min(a[i], x). প্রশ্ন: রেঞ্জের মোট কত? অংশ ৭-এর প্রথম শর্তে এটি আটকে যায়। একটি node-এর যোগফল 100 জানলেও আপনি বলতে পারবেন না chmin(30)-এর পর নতুন যোগফল কত — সেটি নির্ভর করে কয়টি ঘর 30-এর বেশি আর তারা কত বড়। Node-কে তার সব ঘরের মান জানতে হবে, যা O(1)-এ সম্ভব নয়।
চীনের জাতীয় দলের প্রশিক্ষণ-জগতে Ji Ruyi প্রায় ২০১৬ সালে একটি উপায় দেখান, পরে ইংরেজি-ভাষী কনটেস্ট সমাজে যা “segment tree beats” নামে ছড়ায়। প্রতিটি node রাখে: সর্বোচ্চ মান mx, কঠোরভাবে দ্বিতীয় সর্বোচ্চ মান se, mx কয়টি ঘরে আছে (cnt), আর যোগফল। chmin(x) একটি node-এ এলে তিন অবস্থা: x ≥ mx — কিছুই বদলায় না, থামুন। se < x < mx — শুধু mx-ওয়ালা ঘরগুলো x-এ নামবে, আর তারা কয়টি তা জানা: sum −= (mx − x)·cnt, mx = x, আর একটি নোট রাখুন; থামুন। x ≤ se — একাধিক ভিন্ন মান বদলাবে, তাই সাধারণভাবে নিচে নামুন।
তৃতীয় অবস্থাটি ভয়ের মনে হয় — কতবার নিচে নামতে হবে? এখানে আসে amortized analysis (পর্ব ৬০). প্রতিটি node-এ “কতগুলো ভিন্ন মান আছে” এমন একটি potential ধরুন। তৃতীয় অবস্থায় নামলে node-এর অন্তত দুটি ভিন্ন মান (mx আর se) x-এ মিশে এক হয়ে যায়, ফলে potential কমে। Potential-এর মোট পরিমাণ শুরুতে O(n log n)-এর মতো, আর প্রতিটি সাধারণ আপডেট তাতে শুধু O(log n) যোগ করে। তাই মোট বাড়তি নামা সীমিত — শুধু chmin আর যোগফলের প্রশ্নে মোট O((n + q) log n). Range add মেশালে বিশ্লেষণ জটিল হয় আর সীমাও কিছুটা বড়।
এখান থেকে একটি গভীর শিক্ষা: সাধারণ lazy-র “প্রতিটি কাজ worst case O(log n)” নিশ্চয়তা beats-এ নেই; একটি নির্দিষ্ট আপডেট অনেক node-এ নামতে পারে, কিন্তু সব মিলিয়ে গড় খরচ ছোট। Union-find-এর path compression-এর মতোই (পর্ব ৩৩) — আজকের দামি কাজ কালকের কাজগুলোকে সস্তা করে।
মূল উপলব্ধি: কোনো আপডেট “tag দিয়ে হয় না” মনে হলে node-এ আরও একটু তথ্য রাখার কথা ভাবুন — যাতে অন্তত সহজ ক্ষেত্রগুলোয় tag চলে — আর কঠিন ক্ষেত্রগুলোর খরচ amortized যুক্তিতে বাঁধুন।
প্র ০৫ব্যাংক যদি জানতে চায় “গত মাসের ১০ তারিখে এই শাখার মোট আমানত কত ছিল?” — persistent segment tree কীভাবে অতীতের সব সংস্করণ রাখে, আর lazy নোট সেখানে কী ঝামেলা বাধায়?
সাধারণ ডেটা স্ট্রাকচার আপডেটের পর পুরোনো অবস্থা ভুলে যায়। কিন্তু হিসাব নিরীক্ষা (audit), version control বা “undo”-র জন্য অতীতের প্রতিটি অবস্থা জিজ্ঞেস করতে পারা দরকার। ১৯৮৬ সালে (জার্নালে ১৯৮৯) James Driscoll, Neil Sarnak, Daniel Sleator আর Robert Tarjan “Making Data Structures Persistent” প্রবন্ধে দেখান কীভাবে pointer-ভিত্তিক স্ট্রাকচারকে persistent করা যায় — প্রতিটি সংস্করণ আলাদাভাবে জিজ্ঞেস করা যায়, অথচ প্রতিবার পুরোটা কপি করতে হয় না।
Segment tree-তে সবচেয়ে সরল উপায় path copying: একটি point update root থেকে একটি leaf পর্যন্ত মাত্র O(log n) টি node বদলায়। সেই node-গুলোর নতুন কপি বানান, আর বাকি সব node পুরোনো সংস্করণের সাথে ভাগ করে নিন। প্রতিটি সংস্করণের নিজের root থাকে; root-এর তালিকা রাখলেই যেকোনো দিনের অবস্থা পাওয়া যায়। প্রতিটি আপডেটে O(log n) বাড়তি মেমরি। কনটেস্টে এর প্রিয় প্রয়োগ “রেঞ্জ [l, r]-এ k-তম ছোট মান”: প্রতিটি prefix-এর জন্য একটি সংস্করণ রেখে দুই সংস্করণের পার্থক্য দিয়ে উত্তর।
এবার lazy নোট যোগ করুন। সমস্যা হলো push নিজেই একটি পরিবর্তন: সে সন্তানদের মান আর tag বদলায়। কিন্তু সেই সন্তানরা হয়তো অনেক পুরোনো সংস্করণের সাথে ভাগ করা — সরাসরি বদলালে অতীত নষ্ট হবে! তাই persistent lazy tree-তে push-এর সময়ও সন্তানদের নতুন কপি বানাতে হয়; এমনকি একটি query-ও নতুন node তৈরি করতে পারে, মেমরি দ্রুত বাড়ে। প্রচলিত বিকল্প হলো permanent tag (প্রশ্ন ০৩-এর কৌশল): নোট কখনো নামানো হয় না, update শুধু পথের node-গুলো কপি করে নোট বসায়, আর query নামার পথে পাওয়া নোটগুলোর প্রভাব (যেমন নোট × ছেদের দৈর্ঘ্য) জমিয়ে যোগ করে। Range add + sum-এ এটি সুন্দরভাবে চলে, কারণ যোগ ক্রম-নিরপেক্ষ; কিন্তু assign-এর মতো ক্রম-নির্ভর নোটে permanent tag অনেক কঠিন।
বাস্তবের ব্যাংক বা ডেটাবেস অবশ্য সাধারণত লেনদেনের লগ রেখে অতীত পুনর্গঠন করে, বা নির্দিষ্ট সময়ের snapshot রাখে; persistent স্ট্রাকচারের ধারণা সেখানে ভিন্ন রূপে আছে — যেমন অনেক ডেটাবেস একটি সারির পুরোনো সংস্করণ রেখে দেয়, যাতে চলমান পাঠকরা পুরোনো অবস্থাই দেখে।
মূল উপলব্ধি: Lazy নোট মানে “ভবিষ্যতে বদলানোর প্রতিশ্রুতি”, আর persistence মানে “অতীত কখনো বদলাবে না” — দুটি একসাথে রাখতে হলে push-কে নতুন সংস্করণ বানাতে হয়, অথবা নোট নামানোই বন্ধ করতে হয়।
অনুশীলন
খাতা-কলম নিন। প্রথম তিনটি হাতে করার, শেষটিতে কোড লিখতে হবে।
-
হাতে trace করুন: a = [2, 4, 1, 3] (0-based) দিয়ে segment tree বানান। তারপর চালান: (ক) [1, 3]-এ +5, (খ) [0, 1]-এর যোগফল, (গ) [3, 3]-এর যোগফল। প্রতিটি কাজের পর কোন node-এ কী যোগফল আর কী tag, লিখুন।
শুরু: [0-3] = 10, [0-1] = 6, [2-3] = 4, leaf 2, 4, 1, 3. (ক) Root আংশিক, push (কিছু নেই)। [0-1] আংশিক: [0] বাইরে, [1] পুরো ঢাকা → 9; pull-up [0-1] = 2 + 9 = 11. [2-3] পুরো ঢাকা → 4 + 5 × 2 = 14, tag +5, থামা; leaf [2], [3] বাসি (1, 3). Root = 11 + 14 = 25 = 10 + 3 × 5 ✓.
(খ) Root আংশিক; [0-1] পুরো ঢাকা → 11 (2 + 9). কোনো push দরকার হয়নি। (গ) Root আংশিক; [2-3] আংশিক → push: [2] = 6, [3] = 8, tag মুছল; [3] পুরো ঢাকা → 8 (3 + 5 ✓)। খেয়াল করুন, (খ)-তে নোট নামেনি কারণ প্রশ্নটি নোটের নিচে যায়নি।
-
Tag মেশান: একটি node-এর tag (set 4, add 3), অর্থাৎ “আগে 4-এ বসাও, তারপর +3”. এর ওপর পরপর আসে +2, তারপর = 7, তারপর +1. (ক) প্রতিটির পর tag কী? (খ) এবার আপডেট “×b তারপর +c” ধরনের (affine). পুরোনো tag (b₁, c₁)-এর পর নতুন (b₂, c₂) এলে মেশানো tag কী, আর node-এর যোগফল কীভাবে বদলায়?
(ক) +2: (set 4, add 5). = 7: (set 7, add 0) — assign সব মুছে দেয়। +1: (set 7, add 1). প্রতিটি ঘরের শেষ মান 8.
(খ) একটি ঘর x প্রথমে b₁x + c₁, তারপর b₂(b₁x + c₁) + c₂ = (b₂b₁)x + (b₂c₁ + c₂). তাই মেশানো tag (b₂b₁, b₂c₁ + c₂) — ক্রম গুরুত্বপূর্ণ: নতুনটির b₂ পুরোনো c₁-কেও গুণ করে। দৈর্ঘ্য len-এর node-এ নতুন যোগফল = b·sum + c·len. আর “নোট নেই” মানে (1, 0). বড় সংখ্যায় এগুলো সাধারণত কোনো মৌলিক সংখ্যা দিয়ে mod নিয়ে করা হয়, নইলে overflow.
-
ভেবে বলুন: (ক) একটি lazy tree-তে query-র push লাইনটি ভুলে বাদ পড়ল, কিন্তু update ঠিক আছে। এমন একটি ছোট ক্রম দিন যাতে ভুল উত্তর আসে। (খ) q টি আপডেট আর q টি প্রশ্ন, কিন্তু সব আপডেট আগে আসে। Lazy tree আর difference array-র মোট সময় তুলনা করুন।
(ক) n = 2, a = [0, 0]. [0, 1]-এ +5: root পুরো ঢাকা, root = 10, tag +5; leaf দুটি বাসি (0, 0). এখন [0, 0]-এর যোগফল: root আংশিক, push বাদ, সরাসরি leaf [0] → 0, অথচ সঠিক 5. লক্ষ করুন, [0, 1]-এর প্রশ্ন ঠিক উত্তর (10) দিত — তাই শুধু পুরো-রেঞ্জের পরীক্ষায় এই ভুল ধরা পড়ে না।
(খ) Lazy tree: build O(n), প্রতিটি কাজ O(log n), মোট O(n + q log n). Difference array: প্রতিটি আপডেট O(1), তারপর দুবার prefix sum O(n), প্রতিটি প্রশ্ন O(1) — মোট O(n + q). Offline হলে difference array শুধু দ্রুতই নয়, কোডও পাঁচ লাইনে শেষ — ভুলের সুযোগ কম।
-
কোড লিখুন: range add আর range max-এর একটি lazy segment tree লিখুন। a = [3, 1, 4, 1, 5] দিয়ে চালান: [1, 3]-এ +2, max[0, 2], [0, 4]-এ −1, max[3, 4], max[1, 3]. হাতে উত্তর মিলিয়ে নিন।
যোগফলের কোড থেকে শুধু তিনটি জায়গা বদলায়: apply-তে
mx[v] += x(দৈর্ঘ্য দিয়ে গুণ নেই), pull-up-এmax, আর বাইরের রেঞ্জে query ফেরত দেয় খুব ছোট একটি মান।vector<long long> mx, tg; // size 4n each void apply(int v, long long x) { mx[v] += x; tg[v] += x; } // no "* len" for max void push(int v) { if (tg[v]) { apply(2 * v, tg[v]); apply(2 * v + 1, tg[v]); tg[v] = 0; } } void update(int v, int l, int r, int ql, int qr, long long x) { if (qr < l || r < ql) return; if (ql <= l && r <= qr) { apply(v, x); return; } push(v); int m = (l + r) / 2; update(2 * v, l, m, ql, qr, x); update(2 * v + 1, m + 1, r, ql, qr, x); mx[v] = max(mx[2 * v], mx[2 * v + 1]); } long long query(int v, int l, int r, int ql, int qr) { if (qr < l || r < ql) return LLONG_MIN; // identity for max (needs <climits>) if (ql <= l && r <= qr) return mx[v]; push(v); int m = (l + r) / 2; return max(query(2 * v, l, m, ql, qr), query(2 * v + 1, m + 1, r, ql, qr)); }হাতে: +2-এর পর [3, 3, 6, 3, 5]; max[0, 2] = 6. −1-এর পর [2, 2, 5, 2, 4]; max[3, 4] = 4, max[1, 3] = 5. Build-এ leaf-এ a[i], pull-up-এ max. প্রতিটি কাজ O(log n), জায়গা O(n). Brute force-এর সাথে এলোমেলো কাজে মিলিয়ে নিলে নিশ্চিত হবেন।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমlazy segment tree-তে একটি node v-এ range update-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- v-এর রেঞ্জ আপডেটের রেঞ্জের পুরোপুরি বাইরে হলে কিছু না করে ফেরা
- v পুরোপুরি ঢাকা হলে v-এর মান ঠিক করে tag লিখে থামা
- আংশিক মিল হলে v-এর পুরোনো tag দুই সন্তানে push করা
- দুই সন্তানে recursive update চালানো
- দুই সন্তানের মান থেকে v-এর মান আবার হিসাব করা (pull-up)
n = 1024 উপাদানের একটি lazy segment tree-তে পুরো অ্যারে [0, 1023]-এ +7 আপডেট দিলে কয়টি node-এর মান বদলায়?
- ১টি — শুধু root
- প্রায় 4 log₂ n = ৪০টি
- ১০২৪টি — প্রতিটি leaf
- ২০৪৭টি — পুরো tree
Range add আর range minimum-এর lazy segment tree-তে একটি node-এর রেঞ্জে ৮টি ঘর, বর্তমান minimum 12. পুরো node-এ +5 apply করলে node-এর নতুন minimum কত?
- 52
- 12
- 17
- সন্তানদের না দেখে বলা যায় না
একটি lazy segment tree-তে add আর assign দুটোই আছে, আর tag (set, add) মানে “আগে set, তারপর add”. একটি node-এর tag (set 3, add 4); এখন পুরো node-এ “= 9” আসে। নতুন tag কী?
- (set 9, add 4)
- (set 9, add 0)
- (set 12, add 4)
- (set 3, add 4) — পুরোনোটাই থাকে
n উপাদানের lazy segment tree (range add, range sum)-এ একটি range update আর একটি range query-র worst-case সময় কত?
- update O(k log n), query O(log n)
- দুটোই O(1)
- দুটোই O(log n), কিন্তু শুধু amortized
- দুটোই O(log n), worst case-এ
একটি সারির বাতিগুলোর জন্য segment tree-র প্রতিটি node তার রেঞ্জে জ্বলা বাতির সংখ্যা cnt রাখে, আর tag একটি bit (“একবার উল্টানো বাকি”). দৈর্ঘ্য 6-এর একটি node-এ cnt = 4, tag = 1. পুরো node-এ আরেকবার flip হলে নতুন cnt আর tag কী?
- cnt = 2, tag = 0
- cnt = 2, tag = 2
- cnt = 4, tag = 0
- cnt = 4, tag = 1
একটি অ্যারেতে 105 টি range add আপডেট আসে, আর সব আপডেট শেষ হওয়ার পর 105 টি range sum প্রশ্ন। সবচেয়ে সরল ও দ্রুত সমাধান কোনটি?
- Lazy segment tree
- প্রতিটি আপডেটে সরাসরি লুপ, প্রতিটি প্রশ্নে লুপ
- Difference array, তারপর prefix sum
- Sparse table
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- পুরো ঢাকা node-এ থামুন: মান ঠিক করুন, আর lazy tag-এ “নিচে এখনো বাকি” লিখে রাখুন।
- নামার আগে push, ফেরার পরে pull — update আর query দুটোতেই।
- Invariant: node-এর মান নিজের tag-সহ ঠিক; শুধু সন্তানরা বাসি থাকতে পারে। প্রতিটি level-এ ≤ ৪টি node, তাই প্রতিটি কাজ O(log n) worst case, জায়গা O(n).
- Add + assign: tag = (set, add), “আগে set, তারপর add”; নতুন assign add মুছে দেয়; push-এ assign আগে. Min-এ add মানে
+x, যোগফলে+x·len; flip-এ XOR. - সব আপডেট আগে হলে difference array; শুধু add + sum হলে দুটি Fenwick tree; tag-এর দুই শর্ত না মানলে beats বা অন্য কৌশল।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৩৬ · Fenwick tree (BIT)পরবর্তী পর্বPrefix sum আর point update কয়েক লাইনে, আর দুটি BIT দিয়ে range update.
- পর্ব ৩৪ · Segment tree — রেঞ্জের প্রশ্ন দ্রুতআগের ভিত্তিBuild, query আর point update — এই পর্বের ভিত।
- পর্ব ১৭ · Prefix sum আর difference arrayসরল বিকল্পসব আপডেট আগে এলে O(1) রেঞ্জ আপডেট।
- ডেটা স্ট্রাকচার ও অ্যালগরিদম · কোর্স হোম৭২টি পর্বপুরো কোর্সের রোডম্যাপ — নয়টি মডিউল।
- C++ প্রোগ্রামিং · STL algorithmপাশের ট্র্যাক
max,minআর কনটেস্টে দরকারি তৈরি অ্যালগরিদম। - Python প্রোগ্রামিং · অ্যালগরিদম ও কনটেস্টে Pythonপাশের ট্র্যাকRecursion-এর সীমা আর Python-এ দ্রুত কোড লেখার কৌশল।