এই পর্বে যা শিখবেন
- DFS-এর ধারণা — গোলকধাঁধায় সুতো হাতে চলা থেকে recursive কোড পর্যন্ত — আর Θ(V + E) কেন
- Explicit stack দিয়ে iterative DFS লেখা, আর কোন সংস্করণে ভ্রমণের ক্রম recursive-এর থেকে আলাদা হয় তা বোঝা
- Discovery/finish time হাতে বের করা, parenthesis theorem বলা ও প্রমাণ করা
- Directed গ্রাফে tree, back, forward, cross edge আর undirected গ্রাফে tree, back edge চেনা
- তিন রং দিয়ে directed cycle আর parent দিয়ে undirected cycle ধরা, flood fill, component গোনা আর পথ খোঁজা — C++ ও Python-এ
১ · ধারণা — সুতো হাতে গোলকধাঁধায়
গ্রিক পুরাণে Theseus যখন Minotaur-কে মারতে গোলকধাঁধায় ঢোকেন, Ariadne তাঁর হাতে একটি সুতোর বল দেন। দরজায় এক মাথা বেঁধে সুতো খুলতে খুলতে এগোলে আর কখনো পথ হারানোর ভয় নেই — যেকোনো সময় সুতো গুটিয়ে ঠিক যে পথে এসেছেন সেই পথে ফেরা যায়। এই সরল বুদ্ধিই একটি পুরো অ্যালগরিদমের বীজ।
উনিশ শতকের ফরাসি প্রকৌশলী Charles Pierre Trémaux গোলকধাঁধা সমাধানের একটি নিয়ম দেন, যা Édouard Lucas ১৮৮২ সালে তাঁর Récréations mathématiques বইয়ে প্রকাশ করেন। Trémaux-এর পদ্ধতি মোটামুটি এরকম: প্রতিটি মোড়ে যে গলিতে ঢুকছেন তার মুখে চক দিয়ে দাগ দিন। নতুন মোড়ে পৌঁছালে একটি অদেখা গলি ধরুন। নতুন গলি দিয়ে যদি এমন মোড়ে পৌঁছান যেখানে আগেই এসেছিলেন, যে গলি দিয়ে এইমাত্র এলেন সেটি ধরেই ফিরে যান। আর কোনো মোড়ে অদেখা গলি না থাকলে, যে গলি দিয়ে এই মোড়ে প্রথম এসেছিলেন, সেটি ধরে ফিরুন। এভাবে প্রতিটি গলি সর্বোচ্চ দুবার হাঁটা হয় — একবার যেতে, একবার ফিরতে — আর গোলকধাঁধার প্রতিটি জায়গা দেখা হয়ে যায়।
উপমা — পুরান ঢাকার গলিতে বন্ধুর বাসা খোঁজা। ঠিকানা নেই, শুধু জানেন বাসাটি এই মহল্লার কোথাও। আপনি একটি গলি ধরে হাঁটতে থাকেন; প্রতিটি মোড়ে একটি নতুন গলিতে ঢোকেন, যতক্ষণ না কানা গলিতে আটকান। তখন ঠিক আগের মোড়ে ফিরে সেখানকার পরের অদেখা গলিটি ধরেন। পুরো মহল্লা না দেখা পর্যন্ত বা বাসা না পাওয়া পর্যন্ত চলে। আপনার মাথায় “কোন মোড় থেকে কোন গলিতে ঢুকেছিলাম” তালিকাটিই stack: সবার শেষে যে মোড়ে ঢুকেছেন, ফেরার সময় সেখানেই আগে ফিরবেন।
কম্পিউটারের ভাষায় গোলকধাঁধা একটি graph: মোড়গুলো vertex, গলিগুলো edge (পর্ব ৩৯). আর এই পদ্ধতির নাম depth-first search বা DFS — “গভীরতা আগে” খোঁজা: পাশের সব অপশন দেখার আগে একটি পথ ধরে যতদূর সম্ভব গভীরে যাওয়া। আগের পর্বের BFS ঠিক উল্টো: সে শুরুর বিন্দুর চারপাশে স্তরে স্তরে ছড়ায়, যেন পুকুরে ঢিল ছুড়লে ঢেউ ছড়ায়। BFS একটি queue ব্যবহার করে, DFS একটি stack — এই একটি পার্থক্য থেকেই দুটির সব বৈশিষ্ট্য আলাদা হয়ে যায়।
DFS-এর আসল শক্তি শুধু “সবাইকে দেখে ফেলা” নয় — সেটি BFS-ও পারে। DFS ভ্রমণের সময় এমন একটি কাঠামো রেখে যায় (কে কার মধ্য দিয়ে আবিষ্কৃত হলো, কে কখন শুরু আর কখন শেষ হলো), যা থেকে cycle, ক্রম, দুর্বল সংযোগ — গ্রাফের অনেক গভীর প্রশ্নের উত্তর রৈখিক সময়ে বেরিয়ে আসে। এই পর্বের বেশিরভাগ অংশ সেই কাঠামো নিয়েই।
২ · Recursive DFS — যতদূর পারেন গভীরে, তারপর ফেরত
সুতোর কাজটি recursion বিনামূল্যে করে দেয় (পর্ব ৭): একটি vertex থেকে প্রতিটি অদেখা প্রতিবেশীর জন্য নিজেকে ডাকুন। ডাকা ফাংশন শেষ হলে নিয়ন্ত্রণ আপনাআপনি ঠিক আগের vertex-এ ফিরে আসে — call stack-ই সুতো। একটি visited অ্যারে চকের দাগের কাজ করে, যাতে কোনো vertex দুবার শুরু না হয়।
Pseudocode — recursive DFS (0-based, adjacency lists)
DFS(u):
visited[u] = true // chalk mark: never start u again
for each v in adj[u]: // neighbours in list order
if not visited[v]:
parent[v] = u // tree edge u -> v
DFS(v) // go deeper; returns when v is done
// all neighbours of u handled: step back to parent[u]
DFS-ALL(G):
for each vertex s: if not visited[s]: DFS(s) // one tree per startএকটি ইউনিয়নের ছয়টি গ্রাম, দুই দিকেই চলা যায় এমন রাস্তা দিয়ে যুক্ত — একটি undirected graph. প্রতিটি গ্রামের প্রতিবেশী ছোট নম্বর আগে, এই ক্রমে রাখা (adjacency list):
0 ----- 1 adj[0] = 1 2
| | adj[1] = 0 3
2 ----- 3 ----- 4 adj[2] = 0 3 5
| adj[3] = 1 2 4
5 adj[4] = 3
adj[5] = 2
| এই ধাপে | |
| call stack (নিচ → ওপর) | |
| দেখার ক্রম | |
| DFS tree-র নতুন edge |
- শুরু. DFS(0): 0-তে দাগ, call stack-এ শুধু 0.
- গভীরে. 0-এর প্রথম প্রতিবেশী 1 অদেখা — সাথে সাথে DFS(1). 0-এর বাকি প্রতিবেশী (2) অপেক্ষায় থাকল; এটিই BFS-এর সাথে পার্থক্য।
- আরও গভীরে. 1-এর প্রতিবেশী 0 আগেই দেখা, বাদ। 3 অদেখা — DFS(3). Stack এখন 0 1 3.
- 2-এ পৌঁছানো. 3 → 1 দেখা, 3 → 2 অদেখা — DFS(2). লক্ষ করুন, 2 সরাসরি 0-এর প্রতিবেশী হলেও DFS তাকে পেল 0 → 1 → 3 → 2 লম্বা পথে।
- Back edge. 2 → 0: 0 দেখা, আর 0 এখনো stack-এ — অর্থাৎ 2-এর পূর্বপুরুষ (ancestor). এই edge tree-তে ঢোকে না; এটি একটি cycle বন্ধ করে (0–1–3–2–0). তারপর 2 → 5 অদেখা — DFS(5).
- ফিরে আসা. 5-এর একমাত্র প্রতিবেশী 2, দেখা — DFS(5) শেষ। 2-এরও আর কেউ বাকি নেই, শেষ। নিয়ন্ত্রণ ফিরল 3-এ, যার পরের প্রতিবেশী 4 অদেখা — DFS(4). Stack 0 1 3 4: 2 আর 5 বেরিয়ে গেছে।
- শেষ. 4, তারপর 3, তারপর 1 শেষ। 0-তে ফিরে দেখা গেল তার দ্বিতীয় প্রতিবেশী 2 অনেক আগেই দেখা। দেখার ক্রম 0 1 3 2 5 4; tree-তে ৫টি edge, আর বাকি একটি edge (0–2) back edge.
যে edge দিয়ে কোনো vertex প্রথম আবিষ্কৃত হয় (parent[v] = u), সেগুলো মিলে একটি tree — DFS tree, যার root শুরুর vertex. গ্রাফ connected না হলে DFS-ALL প্রতিটি নতুন শুরু থেকে আলাদা tree বানায়; সব মিলে একটি forest. ওপরের উদাহরণে tree-টি 0–1–3, যার নিচে 3 থেকে দুই শাখা: 3–2–5 আর 3–4. একই গ্রাফে প্রতিবেশীর ক্রম বদলালে tree-ও বদলায় — DFS-এর ফল ইনপুটের ক্রমের ওপর নির্ভর করে, তাই পরীক্ষার প্রশ্নে ক্রম সবসময় বলে দেওয়া থাকে (না থাকলে ছোট নম্বর আগে ধরুন, আর সেটি লিখে দিন)।
কেন Θ(V + E)
দুটি গণনা আলাদা করে করুন। (১) DFS(u) প্রতিটি vertex-এর জন্য ঠিক একবার ডাকা হয়, কারণ ডাকার আগেই visited দেখা হয় আর ঢুকেই দাগ দেওয়া হয় — মোট Θ(V). (২) DFS(u)-এর ভেতরের লুপ u-এর adjacency list একবার পড়ে। সব vertex মিলে লুপ ঘোরে Σ deg(u) বার, যা directed গ্রাফে E আর undirected গ্রাফে 2E (প্রতিটি edge দুই প্রান্তের তালিকায় থাকে)। যোগ করলে Θ(V + E) — গ্রাফটি একবার পড়তেই যত সময় লাগে, তার ধ্রুবক গুণ। এর চেয়ে কম সম্ভব নয়, কারণ যেকোনো edge না দেখলে সেটি হয়তো একটি অদেখা অংশে যাওয়ার একমাত্র রাস্তা।
Adjacency matrix-এ “u-এর প্রতিবেশী কারা” জানতে পুরো একটি সারি (V টি ঘর) পড়তে হয়, তাই সময় Θ(V²), E যত কমই হোক। বাড়তি জায়গা: visited, parent আর recursion-এর গভীরতা — সব O(V). Recursion-এর গভীরতা worst case-এ V − 1 (একটি লম্বা শিকলের মতো গ্রাফে), আর এই সংখ্যাটিই পরের অংশের একটি বাস্তব সমস্যার মূল।
৩ · Explicit stack দিয়ে iterative DFS — আর কেন ক্রম বদলে যায়
Recursion-এর বদলে নিজেই একটি stack রাখা যায়। সবচেয়ে প্রচলিত সংস্করণ দেখতে BFS-এর মতোই, শুধু queue-এর জায়গায় stack: একটি vertex তুলুন (pop), অদেখা হলে দাগ দিন, তারপর তার সব অদেখা প্রতিবেশীকে stack-এ ঢোকান (push). একই গ্রাফে, একই adjacency list নিয়ে চালান:
| এই ধাপে | |
| stack (নিচ → ওপর) | |
| দেখার ক্রম | |
| recursive-এর ক্রম (তুলনা) |
- শুরু. Stack-এ শুধু 0.
- pop 0. 0-তে দাগ, আর তার প্রতিবেশী 1, 2 তালিকার ক্রমে push. Stack-এর ওপরে এখন 2 — শেষে যে ঢুকল, সে আগে বের হবে — LIFO নিয়ম।
- pop 2. Recursive DFS প্রথমে 1-এ যেত; এখানে 2. প্রথম পার্থক্য! 2-এর অদেখা প্রতিবেশী 3, 5 push.
- pop 5. দাগ; 5-এর একমাত্র প্রতিবেশী 2 দেখা, কিছু push হলো না।
- pop 3. দাগ; অদেখা প্রতিবেশী 1 আর 4 push. 1 এখন stack-এ দুবার — একবার 0 ঢুকিয়েছিল, একবার 3. এতে ভুল নেই, শুধু বাড়তি জায়গা — worst case-এ stack-এ O(E) টি entry জমতে পারে।
- pop 4. দাগ; প্রতিবেশী 3 দেখা।
- pop 1. দাগ; প্রতিবেশী 0, 3 দেখা।
- pop 1 আবার. আগেই দাগ — বাদ। Stack খালি। ক্রম 0 2 5 3 4 1, recursive-এর 0 1 3 2 5 4 থেকে পুরো আলাদা। নতুন ক্রমটিও কি বৈধ DFS ক্রম? হ্যাঁ — এটি “প্রতিবেশী উল্টো ক্রমে দেখা” একটি DFS-এর ক্রম। কিন্তু কিছু সংস্করণ তা-ও নয় (নিচে দেখুন)।
ক্রম কেন বদলাল? Stack শেষ ঢোকানো প্রতিবেশীকে আগে বের করে, তাই adjacency list উল্টো দিক থেকে পড়া হয়। দুটি সহজ সমাধান: প্রতিবেশীদের উল্টো ক্রমে push করুন (তখন এই সংস্করণ recursive-এর মতো একই ক্রমে vertex দেখে), অথবা ক্রম নিয়ে মাথা না ঘামান — শুধু “সবাইকে দেখা” বা component গোনার জন্য যেকোনো ক্রমই ঠিক।
একটি সূক্ষ্ম ফাঁদ — push করার সময় দাগ দেওয়া। কেউ কেউ BFS-এর অভ্যাসে push করার সময়েই visited দাগ দেন। তাতে প্রতিটি vertex stack-এ একবারই ঢোকে, কিন্তু ফলাফল আর সত্যিকারের DFS থাকে না: ওপরের গ্রাফে 0 একসাথে 1 আর 2 দুজনকেই দাগ দেয়, ফলে 3 পরে 1-কে আর “আবিষ্কার” করতে পারে না, আর 1 হয়ে যায় 0-এর সন্তান। যে tree তৈরি হয় (0–1, 0–2, 2–3, 2–5, 3–4), তাতে edge 1–3 দুটি আলাদা শাখাকে জোড়ে — সত্যিকারের DFS tree-তে undirected গ্রাফে এমন edge কখনো থাকে না (অংশ ৫). সবাইকে দেখা যায় ঠিকই, কিন্তু back edge, finish time বা DFS-এর কোনো উপপাদ্যের ওপর ভরসা করা যায় না।
Recursive-এর হুবহু নকল — প্রতিবেশীর সূচক মনে রাখা
Finish time বা edge-এর শ্রেণি লাগলে stack-এ শুধু vertex নয়, “এই vertex-এর কততম প্রতিবেশী পর্যন্ত দেখা হয়েছে” তা-ও রাখতে হয় — recursive ফাংশনের লুপ-ভেরিয়েবল ঠিক এটিই মনে রাখত। নিচের কোড প্রতিটি vertex-এর জন্য একটি সূচক it[u] রাখে; stack-এর ওপরের vertex-এর পরের প্রতিবেশী দেখে, সাদা হলে push, আর প্রতিবেশী ফুরোলে তবেই pop — ঠিক তখনই তার finish time:
// iterative DFS with the exact recursive order and d/f times
vector<size_t> it(n, 0); // it[u] = next neighbour index of u
vector<int> st;
for (int s = 0; s < n; ++s) {
if (color[s] != 0) continue;
color[s] = 1; d[s] = ++timer; st.push_back(s);
while (!st.empty()) {
int u = st.back();
if (it[u] < adj[u].size()) {
int v = adj[u][it[u]++]; // look at the next neighbour of u
if (color[v] == 0) { color[v] = 1; d[v] = ++timer; par[v] = u; st.push_back(v); }
} else { // no neighbours left: u is finished
color[u] = 2; f[u] = ++timer; st.pop_back();
}
}
}Recursion-এর গভীরতা — কখন iterative-এ যাবেন
Recursive DFS-এর recursion depth হলো DFS tree-র সবচেয়ে লম্বা root থেকে পাতা পথ — একটি শিকলের মতো গ্রাফে V − 1. প্রতিটি ডাক call stack-এ একটি frame নেয়, আর call stack-এর জায়গা সীমিত:
- Python: ডিফল্ট recursion limit সাধারণত ১০০০; ১০৫ vertex-এর শিকলে recursive DFS
RecursionErrorদেবে।sys.setrecursionlimitবাড়ালে আসল C stack ফুরিয়ে প্রোগ্রাম কোনো বার্তা ছাড়াই ভেঙে পড়তে পারে; কনটেস্টে অনেকেthreading.stack_sizeদিয়ে বড় stack-এর একটি thread-এ চালান, কিন্তু সবচেয়ে নিরাপদ হলো iterative সংস্করণ। - C++: main thread-এর stack সাধারণত কয়েক MB — Linux-এ প্রায়ই ৮ MB, Windows-এ ডিফল্ট ১ MB. ছোট frame-এ কয়েক লাখ গভীরতা চলে যায়, কিন্তু ১০৬ গভীরতায় stack overflow (segmentation fault) খুব সম্ভব। Judge কত বড় stack দেয় তা প্ল্যাটফর্মভেদে আলাদা — সন্দেহ হলে iterative লিখুন।
নিয়মটি সহজ: V ≤ ১০৪-এর মতো ছোট গ্রাফ বা tree-র উচ্চতা ছোট জানা থাকলে recursive (পড়তে সহজ, ভুল কম); V লাখের ঘরে আর গ্রাফ শিকলের মতো হতে পারলে — বিশেষ করে Python-এ — iterative.
৪ · Discovery আর finish time — parenthesis theorem
এবার DFS-কে আরও একটু তথ্য রাখতে দিই। একটি ঘড়ি time রাখুন, শুরুতে 0. কোনো vertex u প্রথম দেখলে d[u] = ++time — তার discovery time; u-এর সব প্রতিবেশী শেষ হলে f[u] = ++time — তার finish time. সাথে প্রতিটি vertex-এর তিনটি রং (CLRS-এর প্রচলিত নিয়ম), যাকে তিন রঙের DFS বলে:
- সাদা (W): এখনো দেখা হয়নি (d নেই)।
- ধূসর (G): দেখা হয়েছে কিন্তু শেষ হয়নি — recursion stack-এ আছে, d আছে কিন্তু f নেই।
- কালো (B): শেষ — নিজের আর নিজের নিচের সবার কাজ সারা, d আর f দুটোই আছে।
যেকোনো মুহূর্তে ধূসর vertex-গুলো মিলে ঠিক একটি পথ: root থেকে বর্তমান vertex পর্যন্ত DFS tree-র শাখা — সুতোর যে অংশ এখনো খোলা। প্রতিটি vertex দুটি করে ছাপ পায়, তাই সময় চলে 1 থেকে 2V পর্যন্ত। এবার একটি directed graph নিন — পুরান ঢাকার ছয়টি মোড়, মাঝের গলিগুলো একমুখী:
0 → 1, 3 1 → 2 2 → 0, 3 3 → (কিছু নেই) 4 → 3, 5 5 → 4
| এই ধাপে | |
| ধূসর পথ (recursion stack) | |
| বন্ধনী এ পর্যন্ত |
- শুরু. DFS-ALL প্রথমে 0 থেকে: 0 ধূসর,
d[0] = 1. একটি বন্ধনী খুলল: “(0”. - 0 → 1. 1 সাদা — tree edge; 1 ধূসর,
d[1] = 2. - 1 → 2. আবার tree edge;
d[2] = 3. ধূসর পথ 0 1 2. - 2 → 0. 0 ধূসর — অর্থাৎ এখনো পথের ওপর, 2-এর পূর্বপুরুষ। এটি back edge, আর এটি একটি cycle বন্ধ করে: 0 → 1 → 2 → 0. কোনো রং বা সময় বদলায়নি।
- 2 → 3. 3 সাদা — tree edge,
d[3] = 4. ধূসর পথ এখন চারজনের। - পরপর শেষ. 3-এর বাইরে যাওয়ার কোনো edge নেই:
f[3] = 5, কালো। 2-এর তালিকা শেষ:f[2] = 6. 1-এরও:f[1] = 7. যে ক্রমে খুলেছিল, ঠিক তার উল্টো ক্রমে বন্ধ হলো — stack-এর ধর্ম। - 0 → 3. 0-এর দ্বিতীয় প্রতিবেশী 3 এখন কালো, আর d[0] = 1 ছোট d[3] = 4-এর চেয়ে: 3 হলো 0-এর উত্তরসূরি, শুধু অন্য পথে আগেই দেখা। এটি forward edge. তারপর
f[0] = 8; প্রথম tree শেষ। - নতুন tree. 1, 2, 3 কালো; DFS-ALL পরের সাদা vertex 4 থেকে শুরু করে:
d[4] = 9. 4 → 3: 3 কালো, কিন্তু d[4] = 9 বড় d[3] = 4-এর চেয়ে — 3 অন্য tree-তে, আগেই শেষ। এটি cross edge. - 4 → 5, 5 → 4. 5 সাদা, tree edge,
d[5] = 10. 5 → 4: 4 ধূসর — back edge, cycle 4 → 5 → 4. - শেষ.
f[5] = 11,f[4] = 12= 2V. পুরো বন্ধনী: (0 (1 (2 (3 3) 2) 1) 0) (4 (5 5) 4) — সঠিকভাবে বসানো একটি বন্ধনীর মালা। এটি কাকতালীয় নয়।
Parenthesis theorem
উপপাদ্য (parenthesis theorem): যেকোনো গ্রাফে (directed বা undirected) DFS চালালে যেকোনো দুটি vertex u আর v-এর জন্য ঠিক একটি সত্য: (ক) [d[u], f[u]] আর [d[v], f[v]] সম্পূর্ণ আলাদা, আর কেউ কারও উত্তরসূরি নয়; (খ) [d[v], f[v]] পুরোটা [d[u], f[u]]-এর ভেতরে, আর v হলো u-এর descendant (উত্তরসূরি); (গ) উল্টোটা — u-এর ব্যবধান v-এর ভেতরে, u হলো v-এর উত্তরসূরি। আংশিক জোড়া — যেমন d[u] < d[v] < f[u] < f[v] — কখনো হয় না।
প্রমাণ: ধরুন d[u] < d[v] (উল্টোটা প্রতিসম)। দুটি ক্ষেত্র। প্রথম: d[v] < f[u] — অর্থাৎ v আবিষ্কৃত হলো যখন u ধূসর, তাই u তখন recursion stack-এ, আর v আবিষ্কৃত হয়েছে u-এর ডাকের ভেতরের কোনো ডাকে — v হলো u-এর উত্তরসূরি। Recursion-এ ভেতরের ডাক বাইরেরটির আগে শেষ হয়, তাই f[v] < f[u]: v-এর ব্যবধান পুরোটা u-এর ভেতরে — (খ)। দ্বিতীয়: f[u] < d[v] — u শেষ হয়ে গেছে v দেখার আগেই। তাহলে d[u] < f[u] < d[v] < f[v]: ব্যবধান দুটি আলাদা। কেউ কারও উত্তরসূরি নয়, কারণ উত্তরসূরি হলে তো প্রথম ক্ষেত্রের মতো তার ডাকের ভেতরেই আবিষ্কৃত হতো — (ক)। ∎
ফলাফল (corollary): v হলো u-এর প্রকৃত উত্তরসূরি ⇔ d[u] < d[v] < f[v] < f[u]. চারটি সংখ্যা তুলনা করেই বলা যায় DFS tree-তে কে কার নিচে — tree-টি হাঁটতে হয় না। আরেকটি গুরুত্বপূর্ণ সত্য, white-path theorem: v হবে u-এর উত্তরসূরি ঠিক তখন, যখন d[u] মুহূর্তে u থেকে v পর্যন্ত এমন একটি পথ আছে যার সব vertex সাদা। পরের অংশের প্রমাণগুলো এই দুটির ওপর দাঁড়িয়ে।
৫ · Edge-এর শ্রেণিবিভাগ — tree, back, forward, cross
DFS প্রতিটি edge (u, v) প্রথমবার পরীক্ষা করার মুহূর্তে v-এর রং দেখেই edge-টির শ্রেণি বলে দেওয়া যায় — একে বলে edge classification:
| শ্রেণি | u → v দেখার সময় v-এর রং | অর্থ | সময়ের সম্পর্ক |
|---|---|---|---|
| Tree edge | সাদা | v এই edge দিয়েই আবিষ্কৃত; DFS tree-র অংশ | d[u] < d[v] < f[v] < f[u] |
| Back edge | ধূসর | v হলো u-এর পূর্বপুরুষ (self-loop-ও এতে পড়ে) | d[v] ≤ d[u] < f[u] ≤ f[v] |
| Forward edge | কালো, আর d[u] < d[v] | v হলো u-এর উত্তরসূরি, কিন্তু tree edge নয় | d[u] < d[v] < f[v] < f[u] |
| Cross edge | কালো, আর d[u] > d[v] | বাকি সব: অন্য শাখা বা অন্য tree-তে, আগেই শেষ হওয়া vertex-এ | d[v] < f[v] < d[u] < f[u] |
লক্ষ করুন, cross edge সবসময় “পরে” আবিষ্কৃত vertex থেকে “আগে” শেষ হওয়া vertex-এ যায় — কখনো উল্টো নয়। কেন? যদি u আগে আবিষ্কৃত হতো আর u → v edge থাকত, তবে u ধূসর থাকতেই DFS u-এর তালিকা থেকে v দেখত, তখন v সাদা হলে v u-এর উত্তরসূরি হয়ে যেত। আর একটি ব্যবহারিক তথ্য: back edge ছাড়া বাকি তিন শ্রেণিতে f[v] < f[u]; শুধু back edge-এ f[v] ≥ f[u]. পর্ব ৪২-এর topological sort এই সত্যের ওপর দাঁড়িয়ে।
Undirected গ্রাফে শুধু tree আর back
উপপাদ্য: undirected গ্রাফের DFS-এ প্রতিটি edge হয় tree edge, নয়তো back edge — forward বা cross edge থাকে না। (Undirected গ্রাফে প্রতিটি edge দুবার দেখা যায়, তাই শ্রেণি ধরা হয় যে দিক থেকে প্রথম দেখা হলো সেই অনুযায়ী।) প্রমাণ: edge {u, v}, আর ধরুন d[u] < d[v]. v হলো u-এর প্রতিবেশী, তাই u শেষ হওয়ার আগে DFS অবশ্যই v-কে দেখবে (u-এর তালিকায় v আছে) — অর্থাৎ v আবিষ্কৃত আর শেষ হয় u ধূসর থাকতেই। এখন দুটি সম্ভাবনা। যদি edge-টি প্রথম দেখা হয় u-এর দিক থেকে, তখনো v সাদা (নইলে v-এর দিক থেকে আগেই দেখা হতো, কারণ v আবিষ্কৃত হলেই নিজের তালিকা দেখে) — তাই tree edge. যদি প্রথম দেখা হয় v-এর দিক থেকে, তখন u ধূসর — back edge. ∎ অংশ ২-এর ডায়াগ্রামে ঠিক এটিই দেখেছেন: ছয়টি edge-এর পাঁচটি tree, একটি (2 → 0) back.
পুরো কোড — সময়, শ্রেণি আর প্রতিটি back edge-এর cycle
এবার অংশ ৪-এর একমুখী গলির গ্রাফে পুরো DFS-ALL. প্রতিটি edge পরীক্ষার সময় তার শ্রেণি ছাপা হয়; back edge u → v পেলে par ধরে u থেকে v পর্যন্ত ওপরে উঠে cycle-টিও দেখানো হয় (v ধূসর, তাই v নিশ্চিতভাবে u-এর পূর্বপুরুষ — উঠলে পৌঁছাবেই)। শেষে প্রতিটি vertex-এর d, f, parent আর বন্ধনীর মালা।
#include <iostream>
#include <string>
#include <vector>
using namespace std;
const vector<vector<int>> adj = {{1, 3}, {2}, {0, 3}, {}, {3, 5}, {4}}; // one-way lanes
const int n = adj.size();
vector<int> color(n, 0), d(n, 0), f(n, 0), par(n, -1); // 0 white, 1 grey, 2 black
int timer = 0;
string paren;
void dfs(int u) {
color[u] = 1; d[u] = ++timer; paren += "(" + to_string(u) + " ";
for (int v : adj[u]) {
string kind;
if (color[v] == 0) kind = "tree";
else if (color[v] == 1) kind = "back"; // v is still on the path
else kind = d[u] < d[v] ? "forward" : "cross"; // v is already finished
cout << kind << string(8 - kind.size(), ' ') << u << " -> " << v;
if (kind == "back") { // walk up from u to v
string cyc = to_string(u);
for (int x = u; x != v; x = par[x]) cyc = to_string(par[x]) + " -> " + cyc;
cout << " cycle: " << cyc << " -> " << v;
}
cout << '\n';
if (color[v] == 0) { par[v] = u; dfs(v); }
}
color[u] = 2; f[u] = ++timer; paren += to_string(u) + ") ";
}
int main() {
for (int s = 0; s < n; ++s)
if (color[s] == 0) dfs(s);
for (int v = 0; v < n; ++v)
cout << v << ": d=" << d[v] << " f=" << f[v] << " parent=" << par[v] << '\n';
paren.pop_back();
cout << paren << '\n';
}আউটপুট:
tree 0 -> 1
tree 1 -> 2
back 2 -> 0 cycle: 0 -> 1 -> 2 -> 0
tree 2 -> 3
forward 0 -> 3
cross 4 -> 3
tree 4 -> 5
back 5 -> 4 cycle: 4 -> 5 -> 4
0: d=1 f=8 parent=-1
1: d=2 f=7 parent=0
2: d=3 f=6 parent=1
3: d=4 f=5 parent=2
4: d=9 f=12 parent=-1
5: d=10 f=11 parent=4
(0 (1 (2 (3 3) 2) 1) 0) (4 (5 5) 4)
প্রতিটি লাইন ওপরের ডায়াগ্রামের এক-একটি ঘটনা। শ্রেণির সিদ্ধান্ত মাত্র তিনটি শর্তে: color[v] == 0 → tree, == 1 → back, নইলে d তুলনা করে forward বা cross. শ্রেণি ছাপা হয় recursion-এ ঢোকার আগে, তাই “tree 2 -> 3”-এর পরেই 3-এর কাজ শুরু। Back edge 2 → 0-এ par ধরে ওঠা: 2 → par 1 → par 0 = v, তাই cycle 0 → 1 → 2 → 0. parent = -1 মানে vertex-টি একটি tree-র root — এখানে দুটি tree, 0 আর 4 থেকে। paren-এর শেষের বাড়তি space pop_back দিয়ে বাদ। সময় Θ(V + E) — শুধু cycle ছাপার অংশ প্রতি back edge-এ cycle-এর দৈর্ঘ্যের সমান বাড়তি কাজ করে; জায়গা O(V).
adj = [[1, 3], [2], [0, 3], [], [3, 5], [4]] # one-way lanes
n = len(adj)
color, d, f, par = [0] * n, [0] * n, [0] * n, [-1] * n # 0 white, 1 grey, 2 black
timer = 0
paren = []
def dfs(u):
global timer
color[u] = 1
timer = d[u] = timer + 1 # discovery time
paren.append(f"({u}")
for v in adj[u]:
if color[v] == 0:
kind = "tree"
elif color[v] == 1:
kind = "back" # v is still on the path
else:
kind = "forward" if d[u] < d[v] else "cross" # v is already finished
line = f"{kind:<8}{u} -> {v}"
if kind == "back": # walk up from u to v
cyc, x = [u], u
while x != v:
x = par[x]
cyc.append(x)
line += " cycle: " + " -> ".join(map(str, reversed(cyc))) + f" -> {v}"
print(line)
if color[v] == 0:
par[v] = u
dfs(v)
color[u] = 2
timer = f[u] = timer + 1 # finish time
paren.append(f"{u})")
for s in range(n):
if color[s] == 0:
dfs(s)
for v in range(n):
print(f"{v}: d={d[v]} f={f[v]} parent={par[v]}")
print(" ".join(paren))আউটপুট C++-এর সাথে হুবহু এক:
tree 0 -> 1
tree 1 -> 2
back 2 -> 0 cycle: 0 -> 1 -> 2 -> 0
tree 2 -> 3
forward 0 -> 3
cross 4 -> 3
tree 4 -> 5
back 5 -> 4 cycle: 4 -> 5 -> 4
0: d=1 f=8 parent=-1
1: d=2 f=7 parent=0
2: d=3 f=6 parent=1
3: d=4 f=5 parent=2
4: d=9 f=12 parent=-1
5: d=10 f=11 parent=4
(0 (1 (2 (3 3) 2) 1) 0) (4 (5 5) 4)
f"{kind:<8}" শব্দটিকে ৮ ঘর চওড়া বাঁ-দিক-ঘেঁষা করে, ঠিক C++-এর string(8 - kind.size(), ' ')-এর মতো। timer একটি global, তাই ফাংশনের ভেতরে global timer লিখতে হয়েছে, আর timer = d[u] = timer + 1 এক লাইনে ঘড়ি বাড়িয়ে নতুন মানটি দুই জায়গায় রাখে (C++-এর d[u] = ++timer); তালিকাগুলো (d, paren) বদলানো যায় global ঘোষণা ছাড়াই, কারণ নাম নতুন করে বাঁধা হচ্ছে না, শুধু ভেতরের মান বদলাচ্ছে। Cycle-এর তালিকা u থেকে ওপরে উঠে তৈরি, তাই reversed দিয়ে উল্টে v থেকে শুরু। এই গ্রাফ ছোট; লাখো vertex-এর শিকলে এই recursive কোড RecursionError দেবে — তখন অংশ ৩-এর সূচক-রাখা iterative সংস্করণ।
নিজের গ্রাফে চালিয়ে দেখুন
নিচের ভিজ্যুয়ালাইজারে u>v আকারে edge লিখে (vertex 0–11) “নতুন গ্রাফ” চাপুন, তারপর ধাপে ধাপে এগোন। প্রতিটি বৃত্তে vertex-এর নম্বর আর d/f; সাদা, ধূসর, কালো রং; নিচে প্রতিটি edge-এর শ্রেণি (সবুজ tree, লাল back, নীল forward, বেগুনি cross রঙে)। শুরুতে ওপরের গ্রাফটিই দেওয়া।
const $ = id => document.getElementById(id);
let snaps = [], at = 0; // one snapshot per DFS event
function build() {
const pairs = $("ed").value.trim().split(/[\s,]+/).filter(Boolean).map(s => s.split(">").map(Number));
if (!pairs.length || pairs.some(p => p.length !== 2 || p.some(x => !Number.isInteger(x) || x < 0 || x > 11)))
return void ($("msg").textContent = "format: u>v pairs, vertices 0..11");
const n = 1 + Math.max(...pairs.flat()), adj = Array.from({ length: n }, () => []);
for (const [u, v] of pairs) adj[u].push(v);
adj.forEach(a => a.sort((x, y) => x - y));
const col = Array(n).fill(0), d = Array(n).fill(0), f = Array(n).fill(0), path = [], kinds = [];
let t = 0;
snaps = [];
const snap = msg => snaps.push({ msg, col: [...col], d: [...d], f: [...f], path: [...path], kinds: [...kinds] });
function visit(u) { // recursive DFS: white 0, grey 1, black 2
col[u] = 1; d[u] = ++t; path.push(u); snap(`t=${t}: discover ${u}`);
for (const v of adj[u]) {
const k = col[v] === 0 ? "tree" : col[v] === 1 ? "back" : d[u] < d[v] ? "forward" : "cross";
kinds.push(`${u}>${v} ${k}`); snap(`edge ${u}->${v}: ${k}`);
if (col[v] === 0) visit(v);
}
col[u] = 2; f[u] = ++t; path.pop(); snap(`t=${t}: finish ${u}`);
}
for (let s = 0; s < n; s++) if (col[s] === 0) visit(s);
at = 0; show();
}
function show() {
const s = snaps[at];
$("vs").innerHTML = s.col.map((c, v) => `<span class="v c${c}">${v}<small>${s.d[v] || "."}/${s.f[v] || "."}</small></span>`).join("");
$("ek").innerHTML = s.kinds.map(k => `<span class="${k.split(" ")[1]}">${k}</span>`).join(" ");
$("msg").textContent = `step ${at + 1}/${snaps.length}: ${s.msg} | grey path: ${s.path.join(" ") || "-"}`;
}
$("nx").onclick = () => { if (at < snaps.length - 1) { at++; show(); } };
$("pv").onclick = () => { if (at > 0) { at--; show(); } };
$("go").onclick = build;
build();
snaps.forEach((s, i) => console.log(`${String(i + 1).padStart(2)}. ${s.msg}`));চালানোর সাথে সাথে কোড পুরো DFS একবার হিসাব করে প্রতিটি ঘটনার একটি ছবি (snapshot) জমা রাখে, আর console-এ সব ঘটনা ছাপে:
1. t=1: discover 0
2. edge 0->1: tree
3. t=2: discover 1
4. edge 1->2: tree
5. t=3: discover 2
6. edge 2->0: back
7. edge 2->3: tree
8. t=4: discover 3
9. t=5: finish 3
10. t=6: finish 2
11. t=7: finish 1
12. edge 0->3: forward
13. t=8: finish 0
14. t=9: discover 4
15. edge 4->3: cross
16. edge 4->5: tree
17. t=10: discover 5
18. edge 5->4: back
19. t=11: finish 5
20. t=12: finish 4
২০টি ঘটনা = V টি discover + V টি finish + E টি edge (৬ + ৬ + ৮) — Θ(V + E)-এর হিসাব চোখের সামনে। “পরের ধাপ” শুধু জমানো ছবিগুলোর মধ্যে এগোয়, তাই “আগের ধাপ”-এ পিছিয়েও যাওয়া যায়। চেষ্টা করুন: 0>1 1>2 0>2 লিখুন — 0 → 2 forward হবে; 0>2 0>1 1>2-তে তালিকা সাজানো থাকায় ফল একই। আর 1>0 2>0 দিন — 2 → 0 হবে cross, কারণ 0 তখন অন্য tree-তে আগেই শেষ। 3>3 একটি self-loop: back edge.
৬ · Cycle detection — undirected-এ parent, directed-এ তিন রং
Undirected গ্রাফ: “দেখা প্রতিবেশী, কিন্তু parent নয়”
Undirected গ্রাফে back edge মানেই cycle, আর অংশ ৫ বলছে tree আর back ছাড়া কিছু নেই। কিন্তু একটি ফাঁদ আছে: v থেকে তার parent u-তে ফেরার edge-টিও “দেখা প্রতিবেশী” — অথচ সেটি একই tree edge, উল্টো দিক থেকে। তাই নিয়ম: দেখা প্রতিবেশী পেলে, সে parent না হলে তবেই cycle.
Pseudocode — cycle in an undirected graph (0-based)
hasCycle(u, p): // p = the vertex we came from (-1 at a root)
seen[u] = true
for each v in adj[u]:
if not seen[v]:
if hasCycle(v, u): return true
else if v != p: // seen, and not the edge we arrived by
return true // back edge u - v closes a cycle
return false
// run hasCycle(s, -1) for every unseen s; time O(V + E)সতর্কতা: u আর p-এর মধ্যে দুটি আলাদা রাস্তা থাকলে (multigraph — যেমন দুই গ্রামের মধ্যে একটি পাকা রাস্তা আর একটি সাঁকো), সেটি দৈর্ঘ্য ২-এর একটি cycle, কিন্তু v != p শর্ত দুটিকেই বাদ দেয়। সমাধান: parent vertex নয়, যে edge দিয়ে এসেছেন তার নম্বর মনে রাখুন, আর শুধু সেই edge-টি বাদ দিন। এই কৌশল পর্ব ৪৬-এর bridge খোঁজাতেও লাগবে। আরেকটি বিকল্প: পর্ব ৩৩-এর DSU, যা edge একে একে নিয়ে একই প্রশ্নের উত্তর দেয়।
Directed গ্রাফ: back edge ⇔ cycle
Directed গ্রাফে “দেখা vertex পেলেই cycle” ভুল — forward আর cross edge-ও দেখা (কালো) vertex-এ যায়, অথচ কোনো cycle বানায় না। সঠিক নিয়মটি রঙে লুকিয়ে আছে:
প্রমাণ (⇐): back edge u → v মানে v ধূসর, অর্থাৎ u-এর পূর্বপুরুষ: DFS tree-তে v থেকে u পর্যন্ত একটি পথ আছে। সেই পথ আর u → v মিলে একটি cycle. প্রমাণ (⇒): ধরুন গ্রাফে একটি cycle c আছে। c-এর vertex-গুলোর মধ্যে যে সবার আগে আবিষ্কৃত হলো, তাকে v বলুন, আর c-তে v-এর ঠিক আগের vertex-কে u (অর্থাৎ u → v একটি edge আছে)। d[v] মুহূর্তে c-এর বাকি সব vertex সাদা, তাই v থেকে cycle ধরে u পর্যন্ত একটি সব-সাদা পথ আছে। White-path theorem অনুযায়ী u হবে v-এর উত্তরসূরি — u আবিষ্কৃত আর শেষ হবে v ধূসর থাকতেই। তাই DFS যখন u → v edge পরীক্ষা করবে, v তখনো ধূসর: back edge. ∎ খেয়াল করুন, DFS কোথা থেকে শুরু করল বা প্রতিবেশী কোন ক্রমে, তাতে কিছু যায় আসে না — cycle থাকলে back edge আসবেই।
Pseudocode — cycle in a directed graph, stop at the first back edge (0-based)
visit(u): // colour: 0 white, 1 grey, 2 black
colour[u] = 1
for each v in adj[u]:
if colour[v] == 1: report cycle v -> ... -> u -> v (walk parent from u up to v); return true
if colour[v] == 0:
parent[v] = u
if visit(v): return true
colour[u] = 2 // finished: no cycle through u can be found later
return falseনিচের গ্রাফে একটি “হীরা” (0 → 1 → 3 আর 0 → 2 → 3) আছে, যেখানে দুটি পথ একই vertex-এ মেলে কিন্তু কোনো cycle নেই; আর আলাদা একটি সত্যিকারের cycle. Edge: 0 → 1, 2; 1 → 3; 2 → 3, 4; 4 → 2.
| এই ধাপে | |
| রং (0 1 2 3 4) | |
| ধূসর পথ | |
| সিদ্ধান্ত |
- শুরু. visit(0): 0 ধূসর। ধূসর পথ = recursion stack = শুধু 0.
- গভীরে. 0 → 1, তারপর 1 → 3: দুজনেই সাদা ছিল, tree edge. ধূসর পথ 0 1 3.
- শেষ হওয়া. 3-এর বাইরে যাওয়ার edge নেই — কালো। 1-এর তালিকাও শেষ — কালো। কালো মানে: এদের মধ্য দিয়ে আর কোনো cycle পাওয়া সম্ভব নয়, সব দেখা হয়ে গেছে।
- 0 → 2. 2 সাদা, tree edge; ধূসর পথ 0 2.
- হীরার ফাঁদ. 2 → 3: 3 দেখা, কিন্তু কালো. এটি cross edge — 3-এ পৌঁছানোর দুটি পথ আছে, কিন্তু 3 থেকে 2-এ ফেরার পথ নেই, তাই কোনো cycle নেই। শুধু
visitedদেখে সিদ্ধান্ত নিলে এখানেই ভুল করে “cycle” বলা হতো। - 2 → 4. 4 সাদা, tree edge; ধূসর পথ 0 2 4.
- 4 → 2. 2 ধূসর — এখনো পথের ওপর, 4-এর পূর্বপুরুষ। Back edge, তাই cycle. parent ধরে 4 থেকে 2-এ উঠলে পথ 2 → 4, আর back edge জুড়ে cycle 2 → 4 → 2. প্রথম back edge পেয়েই থামা যায়।
বাস্তবে এই প্রশ্ন প্রায় সবখানে: কোর্সের prerequisite-এ চক্র আছে কি না (A-র আগে B, B-র আগে A হলে কেউ ভর্তি হতে পারবে না), Excel-এর সূত্রে circular reference, software package-এর dependency-তে চক্র, অপারেটিং সিস্টেমে deadlock (কে কার জন্য অপেক্ষা করছে, তার গ্রাফে চক্র)। সব ক্ষেত্রে একই তিন রঙের DFS, Θ(V + E).
৭ · Component, flood fill আর পথ খোঁজা
Connected component গোনা
Undirected গ্রাফে DFS-ALL যতবার নতুন শুরু করে, ঠিক ততটি connected component — প্রতিটি DFS tree একটি component-এর সব vertex ধরে, কারণ শুরুর vertex থেকে যাওয়া যায় এমন সবাইকে DFS দেখবেই। প্রতিটি vertex-এ component-এর নম্বর লিখে রাখলে পরে “u আর v কি যুক্ত?” প্রশ্নের উত্তর O(1)-এ। গ্রাফ না বদলালে এটি DSU-এর চেয়েও সহজ; বদলাতে থাকলে (edge যোগ হতে থাকলে) DSU (পর্ব ৩৩).
Flood fill — গ্রামের মানচিত্রে পুকুর গোনা
একটি ইউনিয়ন পরিষদ জানতে চায় এলাকায় কয়টি আলাদা পুকুর আছে, মাছ চাষের জরিপের জন্য। স্যাটেলাইট মানচিত্রটি একটি গ্রিড: W = পানি, . = মাটি। পাশাপাশি (ওপর, নিচ, বাঁ, ডান) পানির ঘরগুলো মিলে একটি পুকুর। এখানে গ্রাফটি আলাদা করে বানাতে হয় না — প্রতিটি ঘর একটি vertex, পাশের ঘরগুলো তার প্রতিবেশী (grid graph). প্রতিটি অদেখা পানির ঘর পেলে গণনা এক বাড়ান আর সেখান থেকে DFS চালিয়ে পুরো পুকুরটিতে দাগ দিন — যেন Paint-এর বালতি দিয়ে একটি অংশ রং করা। এটিই flood fill.
// count ponds: 'W' = water, '.' = land; 4 directions (up, down, left, right)
int fill(vector<string>& g, int r, int c) { // returns the pond size
if (r < 0 || r >= (int)g.size() || c < 0 || c >= (int)g[0].size() || g[r][c] != 'W') return 0;
g[r][c] = '#'; // mark BEFORE recursing
return 1 + fill(g, r - 1, c) + fill(g, r + 1, c) + fill(g, r, c - 1) + fill(g, r, c + 1);
}
// for every cell (r, c) in row-major order: if g[r][c] == 'W' -> ++ponds; size = fill(g, r, c)| এই ধাপে | |
| পুকুর গোনা | |
| আকার |
- শুরু. ৪ × ৫ মানচিত্র। সারি ধরে বাঁ থেকে ডানে প্রতিটি ঘর দেখা হবে; অদেখা পানি পেলেই নতুন পুকুর।
- পুকুর A. (0,0) পানি, গণনা ১. DFS প্রতিবেশী দেখে ওপর, নিচ, বাঁ, ডান ক্রমে: ওপরে কিছু নেই, নিচে (1,0) পানি — সেখানে চলে গেল, দাগ A.
- ফিরে আসা. (1,0)-এর নিচে (2,0) মাটি, ডানে (1,1) মাটি — শেষ। ফিরে (0,0)-এর পরের প্রতিবেশী: ডানে (0,1) পানি, দাগ A. A-এর আকার ৩.
- পুকুর B. খোঁজা এগিয়ে (0,1) আর (1,0)-এর দাগ দেখে এড়িয়ে যায়; (1,3)-এ নতুন পানি, গণনা ২. DFS নিচে (2,3), সেখান থেকে আর কোথাও নয়; ফিরে (1,3)-এর ডানে (1,4). B-এর আকার ৩.
- পুকুর C. (3,0) নতুন পানি, গণনা ৩; DFS ডানে (3,1). C-এর আকার ২.
- শেষ. বাকি সব ঘর মাটি বা দাগ দেওয়া। মোট ৩টি পুকুর। প্রতিটি ঘর একবার দাগ পায়, আর প্রতিটি ঘরের ৪টি প্রতিবেশী দেখা হয় — সময় Θ(R · C), যেখানে R সারি আর C কলাম।
তিনটি সতর্কতা। (১) Recursion-এর আগে দাগ দিন — পরে দিলে দুটি পাশাপাশি পানির ঘর একে অন্যকে অনন্তকাল ডাকতে থাকবে। (২) ৪ না ৮ দিক — কোণাকুণি ছোঁয়া ঘরকে একই পুকুর ধরবেন কি না, তা সমস্যা বলে দেয়; উত্তর পুরো বদলে যেতে পারে (অনুশীলন ৩)। (৩) গভীরতা — ১০০০ × ১০০০ গ্রিডে একটি সাপের মতো আঁকাবাঁকা পুকুর থাকলে recursion ১০৬ গভীর হতে পারে; তখন explicit stack বা BFS. জায়গা worst case O(R · C).
পথ খোঁজা — কিন্তু সবচেয়ে ছোট নয়!
s থেকে t-তে যাওয়া যায় কি না, আর গেলে কোন পথে — DFS এটি সহজেই বলে: s থেকে DFS চালান, parent রাখুন; t দেখা হলে t থেকে parent ধরে s পর্যন্ত উঠে উল্টে দিন। কিন্তু অংশ ২-এর গ্রাফে 0 থেকে 2-এর পথ চাইলে DFS দেবে 0 → 1 → 3 → 2 (৩টি রাস্তা), অথচ সরাসরি রাস্তা 0 – 2 আছে (১টি)। DFS প্রথম যে পথে পৌঁছায় সেটিই দেয়, আর সেটি তার গভীরে যাওয়ার অভ্যাসের কারণে প্রায়ই লম্বা। ওজনহীন গ্রাফে সবচেয়ে ছোট পথ চাইলে BFS (পর্ব ৪০); ওজনসহ হলে Dijkstra (পর্ব ৪৩).
৮ · BFS বনাম DFS, আর সামনের প্রয়োগ
| বিষয় | BFS | DFS |
|---|---|---|
| ডেটা স্ট্রাকচার | queue | stack (বা recursion-এর call stack) |
| ভ্রমণের ধরন | স্তরে স্তরে, শুরুর কাছ থেকে দূরে | একটি পথে যতদূর সম্ভব, তারপর ফেরত |
| সময় (adjacency list) | Θ(V + E) | Θ(V + E) |
| বাড়তি জায়গা | O(V) — queue-তে পুরো একটি স্তর (চওড়া গ্রাফে বড়) | O(V) — বর্তমান পথের দৈর্ঘ্য (গভীর গ্রাফে বড়) |
| ওজনহীন গ্রাফে সবচেয়ে ছোট পথ | হ্যাঁ | না |
| d/f সময়, edge-এর শ্রেণি | না (স্বাভাবিকভাবে) | হ্যাঁ — বেশিরভাগ গভীর প্রয়োগের ভিত্তি |
| Directed cycle detection | সরাসরি নয় (Kahn-এর in-degree পদ্ধতিতে পরোক্ষে) | হ্যাঁ — back edge |
| সাধারণ প্রয়োগ | সবচেয়ে কম ধাপ, bipartite যাচাই, multi-source দূরত্ব | topological sort, SCC, bridge, articulation point, backtracking, maze |
| বিশাল বা অসীম গ্রাফ | কাছের সমাধান আগে পায় | একটি অসীম শাখায় হারিয়ে যেতে পারে — সীমা দিয়ে গভীরতা বাড়াতে হয় |
সংক্ষেপে: “কত কাছে?” প্রশ্নে BFS, “কাঠামোটা কেমন?” প্রশ্নে DFS. শুধু সবাইকে দেখা বা component গোনায় যেকোনোটি; তখন যেটি লিখতে আর ডিবাগ করতে স্বচ্ছন্দ, সেটিই।
সামনের পর্বগুলোতে DFS
Topological sort — পর্ব ৪২
Topological sort: Cycle-হীন directed গ্রাফে (DAG) finish time-এর উল্টো ক্রমে vertex সাজালেই প্রতিটি edge বাঁ থেকে ডানে যায় — কাজের সঠিক ক্রম।
SCC, bridge, articulation point — পর্ব ৪৬
Back edge কতটা ওপরে পৌঁছায় (low-link) তা মেপে Tarjan-এর পদ্ধতি এক DFS-এই দুর্বল সংযোগ আর পরস্পর-পৌঁছানো দল (strongly connected component) খুঁজে দেয়।
Backtracking — পর্ব ৫৬
Backtracking: N-queens বা subset sum — সম্ভাব্য সমাধানের একটি অদৃশ্য tree-তে DFS, যেখানে অচল শাখা দেখলেই ফিরে আসা হয়।
Tree-এর কৌশল — পর্ব ৪৮
Euler tour, subtree-এর আকার, LCA — সবই একটি DFS-এর d/f সময় দিয়ে; subtree মানে একটি সংলগ্ন ব্যবধান।
আরও গভীরে — DFS কেন সমান্তরাল করা কঠিন: BFS-এর একটি পুরো স্তর একসাথে অনেক processor-এ ভাগ করে দেখা যায়, কিন্তু DFS-এর প্রতিটি সিদ্ধান্ত আগের সিদ্ধান্তের ওপর নির্ভর করে। John Reif ১৯৮৫ সালে দেখান যে নির্দিষ্ট ক্রমের (lexicographic) DFS ক্রম বের করা P-complete — অর্থাৎ এর জন্য দক্ষ সমান্তরাল অ্যালগরিদম থাকলে আরও অনেক “স্বভাবত ক্রমিক” সমস্যারও থাকত, যা বেশিরভাগ গবেষক অসম্ভব মনে করেন। বিশাল গ্রাফের সমান্তরাল benchmark-ও (যেমন Graph500) BFS দিয়েই মাপা হয়। আরেকটি সমাধান: অসীম বা বিশাল সার্চ-গ্রাফে iterative deepening — গভীরতার সীমা ১, ২, ৩, … করে বারবার DFS চালানো, যা DFS-এর অল্প জায়গা আর BFS-এর “কাছেরটা আগে” দুটোই দেয়।
পরীক্ষা ও ইন্টারভিউতে:
- Adjacency list আর matrix-এ DFS-এর সময়? — list-এ Θ(V + E), matrix-এ Θ(V²); কারণ matrix-এ প্রতিটি vertex-এর প্রতিবেশী খুঁজতে পুরো সারি পড়তে হয়।
- Directed গ্রাফে cycle আছে কি না কীভাবে বুঝবেন? — তিন রঙের DFS; ধূসর vertex-এ যাওয়া edge (back edge) পেলেই cycle, কালোতে গেলে নয়।
- d[u] = 2, f[u] = 9, d[v] = 4, f[v] = 7 — সম্পর্ক কী? — v-এর ব্যবধান u-এর ভেতরে, তাই parenthesis theorem অনুযায়ী v হলো u-এর উত্তরসূরি।
- Undirected গ্রাফের DFS-এ কোন edge-শ্রেণি থাকে? — শুধু tree আর back; forward বা cross অসম্ভব।
- ১০৬ vertex-এর শিকলে recursive DFS কেন ভেঙে পড়ে, সমাধান কী? — recursion depth ১০৬, call stack ফুরিয়ে যায় (Python-এ RecursionError); explicit stack দিয়ে iterative DFS.
৯ · AI-এর যুগে DFS — ছোট কোড, সূক্ষ্ম ভুল
“DFS লিখে দাও” বললে যেকোনো AI সহকারী দশ লাইনের পরিষ্কার কোড দেবে, আর ছোট উদাহরণে সেটি ঠিকই চলবে। DFS-এর ভুল সাধারণত লুকিয়ে থাকে বিশেষ গঠনের গ্রাফে — হীরা, multigraph, লম্বা শিকল, একাধিক component — যেগুলো AI-এর নিজের পরীক্ষায় থাকে না। আর ব্যাখ্যায় AI প্রায়ই নিশ্চিত সুরে বলে “DFS shortest path দেয়” বা “iterative আর recursive DFS একই ক্রমে চলে”, যা সাধারণভাবে সত্য নয়।
DFS-এর কোডে AI সাধারণত যেসব ভুল করে
| AI-এর ভুল | কীভাবে ধরবেন |
|---|---|
| Directed গ্রাফে “visited হলেই cycle” | হীরা দিন: 0→1, 0→2, 1→3, 2→3 — cycle নেই, কিন্তু কোড “cycle” বলবে |
Undirected-এ শুধু v != parent দিয়ে cycle | দুই vertex-এর মধ্যে দুটি edge দিন — দৈর্ঘ্য ২-এর cycle ধরা পড়ে না |
| DFS-ALL-এর বদলে শুধু DFS(0) | দুটি component-এর গ্রাফ দিন; দ্বিতীয় অংশের cycle বা vertex হারিয়ে যায় |
| Python-এ গভীর recursion | ১০৫ vertex-এর শিকল (0→1→2→…) চালান — RecursionError |
| Push-এর সময় দাগ দিয়ে “DFS” বলা, তারপর finish time ব্যবহার | ছোট গ্রাফে recursive সংস্করণের d/f-এর সাথে মিলিয়ে দেখুন |
| Flood fill-এ recursion-এর পরে দাগ, বা সীমা পরীক্ষা পরে | ২ × ২ পুরো পানির গ্রিড দিন — অসীম লুপ বা out-of-bounds |
| “DFS-এর সময় O(V · E)” বা matrix-এও “O(V + E)” | প্রতিটি vertex আর প্রতিটি adjacency list কতবার পড়া হয়, গুনে প্রমাণ চান |
একাধিক টেস্ট কেসে visited আবার শূন্য না করা | পরপর দুটি গ্রাফ দিয়ে চালান; দ্বিতীয়টির ফল ভুল আসবে |
AI-কে কোড লিখতে না বলে আপনার trace আর যুক্তি যাচাই করতে বলুন — আর প্রতিটি দাবির প্রতি-উদাহরণ চান:
DFS-এর কোড যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১Ariadne-র সুতো থেকে Trémaux-এর চক পর্যন্ত — গোলকধাঁধা সমাধানের পুরোনো কৌশলগুলো কীভাবে DFS-এর আগাম রূপ?
গোলকধাঁধা মানুষের পুরোনো আকর্ষণ। গ্রিক পুরাণের Crete-এর গোলকধাঁধার গল্পে Ariadne-র সুতো আসলে একটি চমৎকার ডেটা স্ট্রাকচার: সুতো দরজা থেকে বর্তমান অবস্থান পর্যন্ত পুরো পথটি ধরে রাখে, আর গুটিয়ে আনলে ঠিক উল্টো ক্রমে ফেরা যায়। সুতোর শেষ অংশই আগে গোটানো হয় — LIFO. DFS-এর recursion stack-এ ঠিক এই তথ্যটিই থাকে: root থেকে বর্তমান vertex পর্যন্ত ধূসর পথ। কিন্তু শুধু সুতো যথেষ্ট নয়। কোন গলিতে আগে গিয়েছেন তা না জানলে একই কানা গলিতে বারবার ঢুকতে পারেন, বা একটি চক্রে ঘুরতে থাকতে পারেন — তাই দরকার “কোথায় গিয়েছি” তার একটি চিহ্ন।
উনিশ শতকে Charles Pierre Trémaux সেই চিহ্নের নিয়ম দেন: গলির মুখে চকের দাগ। Édouard Lucas ১৮৮২ সালে Récréations mathématiques-এ পদ্ধতিটি প্রকাশ করে দেখান যে এটি যেকোনো গোলকধাঁধায় কাজ করে, যত জটিলই হোক। এর কিছু পরে, ১৮৯৫ সালে, আরেক ফরাসি Gaston Tarry একটি সম্পর্কিত নিয়ম দেন, যাতে প্রতিটি গলি দুই দিকে ঠিক একবার করে হাঁটা হয়। চকের দাগ আজকের visited অ্যারে, আর “নতুন মোড়ে অদেখা গলি ধরুন, নইলে যে গলিতে প্রথম এসেছিলেন সেটি ধরে ফিরুন” — হুবহু recursive DFS. Trémaux-এর হিসাবে প্রতিটি গলি সর্বোচ্চ দুবার হাঁটা হয়, যা আধুনিক ভাষায় Θ(V + E) সময়েরই একটি রূপ।
এই প্রাচীন কৌশল আজও বাস্তবে কাজ করে। একটি রোবট যখন অচেনা ঘর বা খনির সুড়ঙ্গ ঘুরে মানচিত্র বানায়, তার হাতে পুরো মানচিত্র থাকে না — সে শুধু বর্তমান মোড়ের গলিগুলো দেখে। এমন “অনলাইন” অবস্থায় DFS স্বাভাবিক পছন্দ, কারণ প্রতিটি পদক্ষেপে রোবট পাশের একটি জায়গায় যায় বা এক ধাপ পিছিয়ে আসে — BFS-এর মতো দূরের একটি স্তরের পরের vertex-এ লাফ দিতে হয় না। শারীরিকভাবে চলাফেরা করা কারও জন্য এই পার্থক্য বিশাল: BFS-এ এক vertex থেকে পরের vertex-এ যেতে অনেক পথ হাঁটতে হতে পারে।
মূল উপলব্ধি: DFS-এর দুটি উপাদান — ফেরার পথ (stack) আর দেখা জায়গার চিহ্ন (visited) — মানুষ কম্পিউটারের অনেক আগেই আবিষ্কার করেছিল; অ্যালগরিদম প্রায়ই পুরোনো বাস্তব বুদ্ধির নিখুঁত রূপ।
প্র ০২১৯৭০-এর দশকে Hopcroft আর Tarjan DFS দিয়ে কী এমন করেছিলেন যে তাঁরা Turing Award পেলেন?
DFS-এর ধারণা পুরোনো, কিন্তু এটি যে গ্রাফ-অ্যালগরিদমের একটি শক্তিশালী হাতিয়ার, তা পরিষ্কার হয় ১৯৭০-এর দশকের শুরুতে। Robert Tarjan ১৯৭২ সালে SIAM Journal on Computing-এ “Depth-first search and linear graph algorithms” প্রকাশ করেন। সেখানে তিনি দেখান যে DFS-এর কাঠামো — tree edge আর back edge, আর discovery time — ব্যবহার করে strongly connected component আর biconnected component (যেখানে কোনো একটি vertex সরালে গ্রাফ ভাঙে না) রৈখিক, অর্থাৎ O(V + E) সময়ে বের করা যায়। আগের পদ্ধতিগুলো এর চেয়ে ধীর ছিল।
John Hopcroft আর Tarjan একসাথে আরও কয়েকটি ফল দেন, যার সবচেয়ে বিখ্যাত ১৯৭৪ সালের planarity testing: একটি গ্রাফ কাগজে এমনভাবে আঁকা যায় কি না যাতে কোনো দুটি edge পরস্পরকে না কাটে — যা সার্কিট বোর্ডের নকশায় গুরুত্বপূর্ণ। তাঁরা এটিও রৈখিক সময়ে করেন, DFS-এর ওপর ভিত্তি করে। ১৯৮৬ সালে দুজনে যৌথভাবে ACM Turing Award পান, “অ্যালগরিদম আর ডেটা স্ট্রাকচারের নকশা ও বিশ্লেষণে মৌলিক অবদানের” জন্য।
কেন DFS-ই এত কাজের? কারণ এই পর্বের উপপাদ্যগুলো। Undirected গ্রাফে শুধু tree আর back edge থাকে — অর্থাৎ পুরো গ্রাফ একটি tree আর কিছু “ওপরে ফেরার” edge. তাহলে একটি subtree বাকি গ্রাফের সাথে কীভাবে যুক্ত, তা জানতে শুধু দেখতে হয় তার back edge-গুলো কতটা ওপরে পৌঁছায়। প্রতিটি vertex-এ “আমার subtree থেকে সবচেয়ে ওপরে কোথায় পৌঁছানো যায়” (low value) একটি সংখ্যা রেখে, DFS ফেরার পথে সেটি হিসাব করা যায়। এই একটি সংখ্যা থেকে bridge, articulation point, SCC — সব বেরিয়ে আসে (পর্ব ৪৬). BFS-এর tree-তে cross edge থাকে, তাই এমন পরিষ্কার কাঠামো সেখানে নেই।
এটি একটি বড় নকশা-শিক্ষাও। Tarjan দেখিয়েছিলেন যে একটি সাধারণ ভ্রমণ-পদ্ধতিকে শুধু “সবাইকে দেখার” কাজে না লাগিয়ে, তার রেখে যাওয়া কাঠামোর গাণিতিক বৈশিষ্ট্য প্রমাণ করলে, অনেক আপাত-কঠিন সমস্যা হঠাৎ সহজ হয়ে যায়।
মূল উপলব্ধি: DFS-এর আসল উপহার তার ভ্রমণ নয়, তার রেখে যাওয়া কাঠামো — tree, back edge আর সময়ের ছাপ — যার ওপর দাঁড়িয়ে অনেক রৈখিক-সময়ের গ্রাফ-অ্যালগরিদম।
প্র ০৩কনটেস্টে recursive DFS কেন হঠাৎ “Runtime Error” দেয়, আর recursive থেকে iterative-এ রূপান্তর কেন সবসময় সরল নয়?
অনেক প্রতিযোগী প্রথমবার এই অভিজ্ঞতা পান: ছোট পরীক্ষায় সব ঠিক, কিন্তু judge-এ বড় টেস্টে “Runtime Error” বা “Segmentation fault”, অথচ কোডে কোনো অ্যারে সীমার বাইরে যায়নি। কারণ প্রায়ই recursion-এর গভীরতা। একটি tree যদি একটি লম্বা শিকল হয় (১ → ২ → ৩ → … → ২·১০৫), DFS-এর recursion তত গভীর হয়, আর প্রতিটি ডাক call stack-এ একটি frame নেয় — return address, parameter, local variable. Operating system প্রতিটি প্রোগ্রামের main thread-কে নির্দিষ্ট মাপের stack দেয়; ফুরিয়ে গেলে প্রোগ্রাম ভেঙে পড়ে। কিছু judge stack-এর সীমা বড় রাখে, কিছু রাখে না — তাই একই কোড এক প্ল্যাটফর্মে চলে, অন্যটিতে ভাঙে।
Python-এ সমস্যা আরও আগে আসে। CPython ইচ্ছে করেই একটি recursion limit রাখে (সাধারণত ১০০০), যাতে অসীম recursion C stack ভেঙে interpreter-কে নষ্ট না করে। sys.setrecursionlimit(10**6) লিখলে সীমা ওঠে, কিন্তু আসল stack বাড়ে না — তখন বার্তা ছাড়াই প্রোগ্রাম মরে যেতে পারে। কনটেস্টে প্রচলিত কৌশল হলো threading.stack_size দিয়ে বড় stack-এর একটি নতুন thread খুলে সেখানে চালানো; এটি কাজ করে, কিন্তু প্ল্যাটফর্মভেদে নির্ভরযোগ্যতা আলাদা।
সমাধান iterative DFS, কিন্তু রূপান্তরটি নির্ভর করে আপনি recursion থেকে কী চাইছেন তার ওপর। শুধু “সবাইকে দেখা” হলে সরল stack যথেষ্ট। কিন্তু অনেক DFS-এর আসল কাজ হয় ফেরার পথে — finish time, subtree-এর আকার, low value, বা সন্তানদের ফল জুড়ে মা-এর ফল বের করা (tree DP-তে যেমন লাগে)। Recursive কোডে এগুলো স্বাভাবিকভাবে “ডাকের পরের লাইনে” থাকে। Iterative-এ এর জন্য stack-এ প্রতিটি vertex-এর সাথে “কততম প্রতিবেশী পর্যন্ত দেখা হয়েছে” রাখতে হয় (অংশ ৩-এর it[u]), আর ফেরার কাজটি করতে হয় ঠিক pop-এর মুহূর্তে। আরেকটি প্রচলিত কৌশল: প্রথমে iterative-ভাবে DFS ক্রম (preorder) বের করে রাখুন, তারপর সেই ক্রমের উল্টো দিকে একটি সাধারণ লুপ চালিয়ে সন্তান থেকে মা-এর হিসাব করুন — কারণ preorder-এর উল্টো ক্রমে প্রতিটি সন্তান তার মা-এর আগে আসে।
মূল উপলব্ধি: recursion হলো লুকোনো stack, যার মাপ আপনার হাতে নেই; গভীর গ্রাফে সেই stack নিজে হাতে রাখাই নিরাপদ, আর ফেরার পথের কাজ কোথায় বসবে তা আগে থেকে ভেবে রাখতে হয়।
প্র ০৪Deadlock, circular reference আর build system — বাস্তব সফটওয়্যারে directed cycle detection কোথায় কোথায় চলে?
Directed গ্রাফে cycle খোঁজা হয়তো বইয়ের একটি অনুশীলন মনে হয়, কিন্তু আমাদের প্রতিদিনের সফটওয়্যারের ভেতরে এটি অসংখ্যবার চলে। সবচেয়ে নাটকীয় উদাহরণ deadlock: দুটি প্রোগ্রাম, প্রথমটি ফাইল A ধরে রেখে B চাইছে, দ্বিতীয়টি B ধরে রেখে A চাইছে — কেউ কখনো এগোবে না। Operating system আর database-এ এটি ধরা হয় একটি “wait-for graph” দিয়ে: প্রতিটি কাজ একটি vertex, আর “T₁ অপেক্ষা করছে T₂-এর জন্য” একটি edge. এই গ্রাফে cycle মানেই deadlock. অনেক database নিয়মিত এই গ্রাফে cycle খোঁজে, আর পেলে একটি কাজকে বাতিল করে (rollback) চক্র ভাঙে।
Spreadsheet: Excel বা Google Sheets-এ একটি ঘরের সূত্র অন্য ঘরের ওপর নির্ভর করে — এটি একটি directed গ্রাফ। A1 = B1 + 1 আর B1 = A1 × 2 লিখলে চক্র, আর প্রোগ্রাম “circular reference” সতর্কতা দেয়। কোন ঘর আগে হিসাব হবে, তা ঠিক করা হয় topological sort দিয়ে (পর্ব ৪২), আর সেটি সম্ভব কেবল cycle না থাকলে।
Build system আর package manager: একটি বড় সফটওয়্যারে হাজারো ফাইল বা package একে অন্যের ওপর নির্ভর করে। make-এর মতো build tool আর বিভিন্ন ভাষার package manager নির্ভরতার গ্রাফে চক্র পেলে ত্রুটি দেখায়, কারণ চক্র থাকলে “কোনটি আগে তৈরি করব” প্রশ্নের উত্তর নেই। কোর্সের prerequisite-ও একই: বিশ্ববিদ্যালয়ের কোর্স-তালিকায় ভুল করে চক্র থাকলে কোনো শিক্ষার্থী সেই কোর্সগুলো নিতেই পারবে না।
Garbage collection: Java বা JavaScript-এর মতো ভাষায় mark-and-sweep garbage collector প্রোগ্রামের “root” (global আর stack-এর ভেরিয়েবল) থেকে শুরু করে object-এর reference ধরে গ্রাফ ঘোরে — প্রায়ই একটি explicit stack দিয়ে, DFS-এর মতো। যা পৌঁছানো যায় না, তা মুছে ফেলা হয়। এখানে cycle সমস্যা নয়, বরং reference counting-এর দুর্বলতা দূর করার উপায়: দুটি অদরকারি object যদি একে অন্যকে নির্দেশ করে, reference count কখনো শূন্য হয় না, কিন্তু root থেকে ভ্রমণে তারা ধরা পড়ে না।
মূল উপলব্ধি: “কে কার ওপর নির্ভর করে” — এই সম্পর্ক যেখানেই আছে, সেখানেই একটি directed গ্রাফ, আর সেখানে চক্র মানেই আটকে যাওয়া। তিন রঙের DFS এই প্রশ্নের সবচেয়ে সরল রৈখিক-সময়ের উত্তর।
প্র ০৫Paint-এর বালতি, Minesweeper আর Go — flood fill-এর পেছনে কোন DFS, আর বড় ছবিতে সেটি কেন বদলাতে হয়?
ছবি আঁকার প্রোগ্রামের “বালতি” (paint bucket) টুল সম্ভবত flood fill-এর সবচেয়ে পরিচিত রূপ: একটি pixel-এ চাপলে তার সাথে লাগানো একই রঙের সব pixel নতুন রঙে বদলে যায়। সরলতম বাস্তবায়ন এই পর্বের fill ফাংশনের মতো — প্রতিটি pixel থেকে চার দিকে recursion. কিন্তু একটি ৪০০০ × ৩০০০ ছবির বড় একটি সাদা অংশে এই recursion কোটি গভীর হতে পারে, যা কোনো stack সামলাতে পারে না। তাই বাস্তব প্রোগ্রাম explicit stack বা queue ব্যবহার করে, আর আরও দ্রুত করতে “scanline” কৌশল: একটি সারিতে বাঁ-ডানে যতদূর একই রং ততদূর একসাথে রং করে, শুধু ওপর-নিচের সারির নতুন অংশগুলো stack-এ রাখে। এতে stack-এর আকার আর মেমরি-চলাচল অনেক কমে।
Minesweeper: খেলায় একটি ফাঁকা ঘরে (যার চারপাশে কোনো মাইন নেই) চাপলে পুরো ফাঁকা অঞ্চল একসাথে খুলে যায়, সীমানার সংখ্যাওয়ালা ঘরগুলো পর্যন্ত। এটিও flood fill — এবার ৮ দিকে, আর “সংখ্যা” ঘরে থামার একটি শর্তসহ। Go: এই প্রাচীন বোর্ড-খেলায় একই রঙের পাশাপাশি পাথরগুলো মিলে একটি দল, আর দলটির চারপাশে কোনো খালি ঘর (liberty) না থাকলে পুরো দল বোর্ড থেকে সরে যায়। Go প্রোগ্রাম প্রতিটি চালের পর flood fill দিয়ে দল আর তার খালি ঘর গোনে।
বিজ্ঞানেও একই ধারণা: স্যাটেলাইট ছবিতে বন্যার পানির এলাকা মাপা, মেডিক্যাল ছবিতে একটি টিউমারের আয়তন বের করা, বা মানচিত্রে একটি দ্বীপের তটরেখা চিহ্নিত করা — সব ক্ষেত্রে “পাশাপাশি একই ধরনের ঘরগুলো মিলে একটি অঞ্চল”. যখন সব অঞ্চল একসাথে গুনতে হয়, তখন পর্ব ৩৩-এর DSU-ভিত্তিক দুই-পাস পদ্ধতিও প্রচলিত; একটি নির্দিষ্ট বিন্দু থেকে একটি অঞ্চল চাইলে flood fill.
এখানে একটি নকশার শিক্ষাও আছে: একই সমস্যার সমাধান DFS, BFS বা DSU — তিনভাবেই হয়, সব Θ(R · C). পার্থক্য আসে মেমরিতে, stack-এর গভীরতায়, আর cache-এর ব্যবহারে। বড় ছবিতে recursion-এর সৌন্দর্য ত্যাগ করে নিয়ন্ত্রিত stack নেওয়াই প্রকৌশলের সিদ্ধান্ত।
মূল উপলব্ধি: flood fill হলো একটি অন্তর্নিহিত গ্রিড-গ্রাফে DFS; অ্যালগরিদম একই থাকে, কিন্তু ইনপুট যত বড় হয়, বাস্তবায়নের খুঁটিনাটি — recursion না stack, ৪ না ৮ দিক — তত গুরুত্বপূর্ণ হয়ে ওঠে।
অনুশীলন
খাতা-কলম নিন। প্রথম তিনটি হাতে করার, শেষটিতে কোড লিখতে হবে।
-
হাতে trace করুন: directed গ্রাফ
0 → 1, 2;1 → 3;2 → 1, 3;3 → 0. প্রতিবেশী ছোট নম্বর আগে, DFS-ALL 0 থেকে। প্রতিটি vertex-এর d আর f, প্রতিটি edge-এর শ্রেণি আর বন্ধনীর মালা লিখুন। গ্রাফে cycle আছে কি?d[0] = 1; 0 → 1 tree, d[1] = 2; 1 → 3 tree, d[3] = 3; 3 → 0: 0 ধূসর → back; f[3] = 4, f[1] = 5. 0 → 2 tree, d[2] = 6; 2 → 1: 1 কালো, d[2] = 6 বড় d[1] = 2-এর চেয়ে → cross; 2 → 3: একইভাবে cross; f[2] = 7, f[0] = 8.
ফল: 0: 1/8, 1: 2/5, 2: 6/7, 3: 3/4. বন্ধনী (0 (1 (3 3) 1) (2 2) 0). Edge: tree 0→1, 1→3, 0→2; back 3→0; cross 2→1, 2→3; forward একটিও নেই। Back edge আছে, তাই cycle আছে: 0 → 1 → 3 → 0. লক্ষ করুন, 2 আর 1-এর ব্যবধান [6, 7] আর [2, 5] আলাদা — কেউ কারও উত্তরসূরি নয়, তাই 2 → 1 cross.
-
ভেবে বলুন: (ক) একটি DFS-এ পাওয়া গেল d[a] = 3, f[a] = 10, d[b] = 5, f[b] = 6, d[c] = 11, f[c] = 14. কে কার উত্তরসূরি? (খ) একটি directed গ্রাফে edge u → v আছে আর DFS-এর পর দেখা গেল f[u] < f[v]. Edge-টি কোন শ্রেণির, আর এ থেকে গ্রাফ সম্পর্কে কী বলা যায়?
(ক) [5, 6] পুরোটা [3, 10]-এর ভেতরে, তাই b হলো a-এর উত্তরসূরি। [11, 14] বাকি দুটি থেকে সম্পূর্ণ আলাদা, তাই c-এর সাথে a বা b-এর পূর্বপুরুষ-উত্তরসূরি সম্পর্ক নেই — c হয় অন্য শাখায় নয়তো অন্য tree-তে।
(খ) Tree, forward আর cross — তিনটিতেই f[v] < f[u] (অংশ ৫). তাই f[u] < f[v] হলে edge-টি অবশ্যই back edge, আর back edge মানে গ্রাফে cycle আছে. উল্টো দিক থেকে: cycle-হীন গ্রাফে প্রতিটি edge u → v-এ f[u] > f[v] — finish time-এর বড় থেকে ছোট ক্রমে সাজালেই সব edge একদিকে যায়। এটিই পর্ব ৪২-এর topological sort.
-
Flood fill হাতে চালান: ৩ × ৩ মানচিত্র — প্রথম সারি
W . W, দ্বিতীয়. W ., তৃতীয়W . W. ৪ দিক ধরলে কয়টি পুকুর? ৮ দিক (কোণাকুণিসহ) ধরলে? দুটির flood fill-এ মোট কয়টি ঘর দাগ পায়?৪ দিকে কোনো দুটি পানির ঘর পাশাপাশি নেই — প্রতিটি পানির ঘর আলাদা পুকুর: ৫টি পুকুর, প্রতিটির আকার ১. ৮ দিকে মাঝের (1,1) চারটি কোণার সাথে কোণাকুণি যুক্ত — ১টি পুকুর, আকার ৫. দুই ক্ষেত্রেই ঠিক ৫টি ঘর দাগ পায়: প্রতিটি পানির ঘর একবার। পার্থক্য শুধু কতবার নতুন DFS শুরু হলো (৫ বনাম ১)। শিক্ষা: সমস্যার বিবরণে “পাশাপাশি” মানে কী, সেটি প্রথমেই নিশ্চিত হোন।
-
কোড লিখুন: recursion ছাড়া, একটি explicit stack দিয়ে flood fill লিখুন, যা একটি গ্রিডে পুকুরের সংখ্যা আর সবচেয়ে বড় পুকুরের আকার ফেরত দেয় (৪ দিক)। অংশ ৭-এর মানচিত্রে উত্তর মেলান। সময় আর জায়গার জটিলতা কত?
মূল কৌশল: ঘর stack-এ ঢোকানোর সময়েই দাগ দিন, যাতে একটি ঘর দুবার না ঢোকে। এখানে শুধু গোনা দরকার, DFS-এর ক্রম বা finish time নয় — তাই “push-এর সময় দাগ” নিরাপদ।
pair<int, int> ponds(vector<string> g) { // returns {count, largest} int R = g.size(), C = g[0].size(), cnt = 0, best = 0; const int dr[] = {-1, 1, 0, 0}, dc[] = {0, 0, -1, 1}; for (int r = 0; r < R; ++r) for (int c = 0; c < C; ++c) { if (g[r][c] != 'W') continue; ++cnt; int size = 0; vector<pair<int, int>> st = {{r, c}}; g[r][c] = '#'; while (!st.empty()) { auto [x, y] = st.back(); st.pop_back(); ++size; for (int k = 0; k < 4; ++k) { int nx = x + dr[k], ny = y + dc[k]; if (nx >= 0 && nx < R && ny >= 0 && ny < C && g[nx][ny] == 'W') { g[nx][ny] = '#'; st.push_back({nx, ny}); } } } best = max(best, size); } return {cnt, best}; }অংশ ৭-এর মানচিত্রে ফল {3, 3}: ৩টি পুকুর, সবচেয়ে বড়টির আকার ৩ (A আর B দুটিই)। প্রতিটি ঘর সর্বোচ্চ একবার stack-এ ঢোকে আর ৪টি প্রতিবেশী দেখে — সময় Θ(R · C); stack-এ একসাথে সর্বোচ্চ O(R · C) ঘর, কিন্তু এবার heap মেমরিতে, call stack-এ নয় — তাই ১০৬ ঘরেও নিরাপদ।
gমান হিসেবে (copy) নেওয়া হয়েছে, যাতে ডাকার জায়গার মানচিত্র না বদলায়।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমdiscovery/finish time-সহ DFS-ALL-এর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- সব vertex সাদা আর ঘড়ি time = 0 করা
- একটি সাদা vertex u পেয়ে তাকে ধূসর করা আর d[u] = ++time লেখা
- u-এর adjacency list থেকে পরের প্রতিবেশী v নেওয়া, আর v সাদা হলে parent[v] = u
- v থেকে একই কাজ recursively চালিয়ে ফেরার অপেক্ষা করা
- u-এর সব প্রতিবেশী শেষ হলে u-কে কালো করা আর f[u] = ++time লেখা
V টি vertex আর E টি edge-এর একটি গ্রাফ adjacency list-এ রাখা। পুরো গ্রাফে DFS-ALL-এর সময় কত?
- Θ(V · E)
- Θ(V + E)
- Θ(V²)
- Θ((V + E) log V)
একটি directed গ্রাফে তিন রঙের DFS চলার সময় edge u → v পরীক্ষা করা হলো, আর v তখন ধূসর। Edge-টি কোন শ্রেণির?
- Tree edge
- Forward edge
- Back edge
- Cross edge
একটি DFS-এ পাওয়া গেল d[u] = 2, f[u] = 9 আর d[v] = 4, f[v] = 7. Parenthesis theorem অনুযায়ী কোনটি নিশ্চিত?
- v হলো DFS tree-তে u-এর উত্তরসূরি
- u হলো v-এর উত্তরসূরি
- u আর v আলাদা DFS tree-তে
- u → v অবশ্যই একটি tree edge
একটি undirected গ্রাফে DFS চালালে edge-গুলো কোন কোন শ্রেণিতে পড়তে পারে?
- tree, back, forward আর cross — চারটিই
- শুধু tree edge
- tree আর cross
- শুধু tree আর back
একটি directed গ্রাফ: 0 → 1, 0 → 2, 1 → 3, 2 → 3. কেউ “DFS-এ আগে দেখা (visited) vertex-এ edge গেলেই cycle” নিয়মে কোড লিখলেন। ফল কী হবে?
- “cycle আছে” বলবে, আর সেটিই সত্য
- ভুল করে “cycle আছে” বলবে, যদিও কোনো cycle নেই
- সঠিকভাবে “cycle নেই” বলবে
- অসীম লুপে আটকে যাবে
একটি ওজনহীন undirected গ্রাফে s থেকে t-তে সবচেয়ে কম edge-এর পথ চাই। কোন বক্তব্যটি সঠিক?
- DFS-এর parent ধরে পাওয়া পথই সবসময় সবচেয়ে ছোট
- প্রতিবেশী সাজিয়ে DFS চালালে সবচেয়ে ছোট পথ পাওয়া যায়
- BFS লাগবে; DFS যেকোনো একটি পথ দেয়, ছোটটি নয়
- কোনো Θ(V + E) পদ্ধতিতে এটি সম্ভব নয়
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- DFS = একটি পথে যতদূর সম্ভব, তারপর ঠিক আগের মোড়ে ফেরত; ফেরার পথ রাখে stack (recursion-এ call stack); সময় Θ(V + E) list-এ, Θ(V²) matrix-এ।
- Iterative-এ “pop করে দাগ, প্রতিবেশী push” ক্রম বদলায়; recursive-এর হুবহু ক্রম আর finish time চাইলে প্রতিটি vertex-এর প্রতিবেশী-সূচক রাখুন। গভীর গ্রাফে (বিশেষ করে Python-এ) iterative.
- Parenthesis theorem: [d, f] ব্যবধান আলাদা বা একটি অন্যটির ভেতরে; ভেতরে মানে উত্তরসূরি।
- সাদা → tree, ধূসর → back, কালো → forward (d[u] < d[v]) বা cross; undirected-এ শুধু tree আর back.
- Directed cycle ⇔ back edge (কালো vertex-এ যাওয়া নিরাপদ); undirected cycle = parent নয় এমন দেখা প্রতিবেশী; flood fill = গ্রিডে DFS; DFS-এর পথ সবচেয়ে ছোট নয়.
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৪২ · Topological sort আর DAGপরবর্তী পর্বDFS-এর finish time দিয়ে কাজের ক্রম, আর Kahn-এর পদ্ধতি।
- ডেটা স্ট্রাকচার ও অ্যালগরিদম · কোর্স হোম৭২টি পর্বপুরো কোর্সের রোডম্যাপ — নয়টি মডিউল।
- পর্ব ৩৯ · গ্রাফ — উপস্থাপন আর পরিভাষাআগের ভিত্তিAdjacency list, matrix, directed আর undirected গ্রাফ।
- পর্ব ৪০ · Breadth-first search (BFS)তুলনাQueue দিয়ে স্তরে স্তরে ভ্রমণ আর সবচেয়ে ছোট পথ।
- পর্ব ০৭ · রিকার্শন — নিজেকে ডাকা ফাংশন আর call stackআগের ভিত্তিCall stack, recursion depth আর stack overflow.
- Python প্রোগ্রামিং · অ্যালগরিদম ও কনটেস্টে Pythonপাশের ট্র্যাকRecursion-এর সীমা আর কনটেস্টে Python-এর কৌশল।