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

Topological sort আর DAG

Topological Sort & DAGs — Kahn's Algorithm, DFS Finishing Order, Cycle Detection, Uniqueness, Lexicographic Order, Longest Paths & the Critical Path Method

Taught in Bangla, this lesson covers DAGs and topological sorting: the definition and existence proof, Kahn's algorithm with in-degree zero queues, levels for the fewest rounds of courses, DFS reverse finishing order, cycle detection, uniqueness and lexicographically smallest order, DP on DAGs for longest paths, path counting and the critical path method, and dependency cycles, with C++ and Python code.

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

  • কোর্সের prerequisite, রান্নার ধাপ, build system আর প্যাকেজ ইনস্টলকে একটি DAG হিসেবে দেখা
  • “topological order আছে ⇔ গ্রাফটি DAG” — দুই দিকের প্রমাণ লেখা
  • Kahn-এর অ্যালগরিদম আর DFS-ভিত্তিক পদ্ধতি হাতে trace করা, প্রমাণসহ, আর দুটি দিয়েই cycle ধরা
  • ক্রম কখন অনন্য, আর min-heap দিয়ে lexicographically সবচেয়ে ছোট ক্রম বের করা
  • DAG-এ দীর্ঘতম পথ, critical path, পথ গোনা আর ন্যূনতম কয় সেমিস্টার লাগে — C++ ও Python-এ

১ · ক্রমের সমস্যা — কোনটা আগে, কোনটা পরে

ধরুন ঢাকার একটি বিশ্ববিদ্যালয়ের CSE বিভাগ নতুন শিক্ষার্থীদের জন্য কোর্সের পরিকল্পনা বানাচ্ছে। কোর্সের তালিকায় লেখা আছে, কোন কোর্স নেওয়ার আগে কোনটি পাস করতে হবে। Data Structures নিতে হলে আগে Programming আর Discrete Math লাগবে; Algorithms-এর আগে Data Structures; Database-এর আগে Data Structures আর OOP. প্রশ্ন হলো: কোন ক্রমে কোর্সগুলো নিলে কোনো নিয়ম ভাঙবে না? আর সব শেষ করতে অন্তত কয়টি সেমিস্টার লাগবে?

কোর্স (সংক্ষেপ)আগে পাস করতে হবেএই কোর্স যাদের পূর্বশর্ত
Programming (P)—DS, OOP
Discrete Math (DM)—DS, AI
Data Structures (DS)P, DMALG, DB
OOPPDB
Algorithms (ALG)DSAI
Database (DB)DS, OOP—
Artificial Intelligence (AI)ALG, DM—

প্রতিটি কোর্স একটি vertex, আর “u পাস না করে v নেওয়া যাবে না” হলো একটি তীর u → v. তীরের দিক আছে, তাই এটি একটি directed graph: P → DS মানে P আগে, DS পরে — উল্টোটা নয়। এমন গ্রাফ, যেখানে তীর মানে “আগে লাগবে”, তাকে বলে dependency graph. শেষ কলামটিই প্রোগ্রামে রাখা হয় — প্রতিটি vertex-এর পরের vertex-গুলোর তালিকা, অর্থাৎ adjacency list (পর্ব ৩৯):

P   -> DS, OOP        DS  -> ALG, DB        ALG -> AI
DM  -> DS, AI         OOP -> DB             DB, AI -> (none)

একই ছাঁচ সবখানে। বিয়েবাড়ির কাচ্চি: বেরেস্তা ভাজা না হলে মাংস মেরিনেট হয় না, চাল না ভিজিয়ে আধা-সেদ্ধ হয় না, আর মাংস, চাল, আলু — তিনটিই তৈরি না হলে হাঁড়িতে স্তর সাজিয়ে দমে বসানো যায় না। Build system: একটি C++ প্রজেক্টে main.o বানাতে আগে main.cpp আর তার header লাগে, আর শেষ app.exe বানাতে সব .o. ১৯৭৬ সালে Bell Labs-এ Stuart Feldman-এর লেখা make ঠিক এই নির্ভরতা পড়ে ঠিক করে কোন ফাইল আগে compile হবে। প্যাকেজ ইনস্টল: apt বা pip দিয়ে একটি লাইব্রেরি বসাতে গেলে আগে তার নির্ভর লাইব্রেরিগুলো বসাতে হয়, আর সেগুলোর নির্ভরগুলো তারও আগে।

উপমা — বাসার রং করা। দেয়ালে আগে প্লাস্টার, তারপর পুটিন, তারপর রং; এদিকে ইলেকট্রিক মিস্ত্রির তারের কাজ প্লাস্টারের আগেই শেষ করতে হবে, কিন্তু কাঠমিস্ত্রির দরজা লাগানো যেকোনো সময়। ঠিকাদার যে কাজের তালিকা বানান, সেটিই topological order — আর তিনি জানেন, একই দিনে দুই মিস্ত্রি পাশাপাশি কাজ করতে পারেন, যদি একজনের কাজ অন্যজনের ওপর নির্ভর না করে।

২ · DAG আর topological order — সংজ্ঞা আর প্রমাণ

সংজ্ঞা: একটি directed গ্রাফ G = (V, E)-এর topological order হলো সব vertex-এর এমন একটি সারি v1, v2, …, vn, যাতে প্রতিটি edge u → v-তে u সারিতে v-এর আগে থাকে। এমন সারি বের করার কাজটির নাম topological sort. একে “sort” বলা হলেও এখানে সংখ্যার তুলনা নেই — আছে শুধু “আগে-পরে”-র নিয়ম। কোর্সের গ্রাফে P, DM, OOP, DS, ALG, DB, AI একটি বৈধ ক্রম; DM, P, DS, OOP, ALG, AI, DB আরেকটি। কিন্তু DS, P, … বৈধ নয়, কারণ P → DS ভেঙে যায়।

যদি তালিকায় ভুল করে লেখা থাকে “Data Structures-এর আগে Algorithms লাগবে”? তাহলে DS → ALG আর ALG → DS — কেউ কারও আগে বসতে পারে না। তীর ধরে হেঁটে শুরুর vertex-এ ফিরে আসার পথকে বলে directed cycle. যে directed গ্রাফে কোনো directed cycle নেই, তার নাম directed acyclic graph, সংক্ষেপে DAG (উচ্চারণ “ড্যাগ”).

Topological order আছে ⇔ গ্রাফটি DAG

(⇒) order থাকলে cycle নেই। বিরোধ দিয়ে প্রমাণ: ধরুন একটি topological order আছে, তবু একটি cycle c1 → c2 → … → ck → c1 আছে। সারিতে vertex-এর অবস্থান pos(·) লিখুন। প্রতিটি edge-এ pos বাড়ে, তাই pos(c1) < pos(c2) < … < pos(ck) < pos(c1) — একটি সংখ্যা নিজের চেয়ে ছোট, অসম্ভব। ∎

(⇐) cycle না থাকলে order আছে। প্রথমে একটি ছোট সত্য। লেমা: প্রতিটি অশূন্য DAG-এ অন্তত একটি vertex আছে, যাতে কোনো edge ঢোকে না। একটি vertex-এ কয়টি edge ঢোকে, তার নাম in-degree; in-degree ০-এর vertex-কে বলে source. লেমার প্রমাণ: ধরুন কোনো source নেই। যেকোনো vertex থেকে শুরু করে বারবার একটি ঢোকা edge ধরে পেছনে হাঁটুন — প্রতিটি vertex-এ অন্তত একটি ঢোকা edge আছে, তাই হাঁটা কখনো থামে না। কিন্তু vertex মাত্র n টি, তাই n + 1 ধাপের মধ্যে কোনো vertex দ্বিতীয়বার আসবে (pigeonhole নীতি) — সেই দুই সাক্ষাতের মাঝের অংশটি একটি cycle. বিরোধ। ∎

এবার n-এর ওপর induction. n = 1 হলে সারিটি সেই একটি vertex. n > 1 হলে লেমা থেকে একটি source s নিন, তাকে সারির প্রথমে বসান, আর গ্রাফ থেকে s ও তার edge-গুলো মুছে দিন। বাকি গ্রাফও DAG (edge মুছলে নতুন cycle তৈরি হয় না), তাই induction-এ তার একটি topological order আছে; তার আগে s বসালেই পুরো গ্রাফের order. কেন বৈধ? s-এ কোনো edge ঢোকে না, তাই s-কে প্রথমে বসানো কোনো নিয়ম ভাঙে না; বাকি সব edge বাকি গ্রাফের ভেতরে। ∎

মূল ধারণা

দ্বিতীয় প্রমাণটি আসলে একটি অ্যালগরিদম: “একটি source বের করো, তাকে সারিতে লেখো, মুছে দাও, আবার করো।” এটিই Kahn-এর অ্যালগরিদম। আর প্রমাণের লেমাটি বলে দেয়, এই প্রক্রিয়া কখন আটকে যায় — যখন বাকি গ্রাফে কোনো source নেই, অর্থাৎ একটি cycle আছে।

লক্ষ করুন, “আগে-পরে” সম্পর্কটি সব জোড়ার জন্য নির্ধারিত নয়: P আর DM-এর মধ্যে কেউ কারও আগে হওয়ার নিয়ম নেই। গণিতে এমন সম্পর্ককে বলে partial order, আর topological sort সেটিকে একটি পূর্ণ সারিতে বাড়িয়ে দেয়। তাই সাধারণত একাধিক বৈধ ক্রম থাকে — কখন ঠিক একটি, তা অংশ ৬-এ।

৩ · Kahn-এর অ্যালগরিদম — in-degree শূন্যের সারি

অংশ ২-এর প্রমাণকে দক্ষ করতে একটাই কৌশল লাগে: প্রতিবার পুরো গ্রাফ ঘুরে source খোঁজার বদলে প্রতিটি vertex-এর বাকি in-degree একটি অ্যারেতে রাখুন। একটি vertex u আউটপুট হলে u → v প্রতিটি edge “মুছে” যায়, অর্থাৎ indeg[v] এক কমে; শূন্য হলেই v নতুন source, তাকে একটি queue-তে রাখুন। ১৯৬২ সালে A. B. Kahn Communications of the ACM-এ “Topological sorting of large networks” প্রবন্ধে এই পদ্ধতি প্রকাশ করেন, তাই এর নাম Kahn-এর অ্যালগরিদম.

Pseudocode — Kahn's algorithm (0-based)

indeg[v] = number of edges into v            // one pass over all edges: O(V + E)
Q = queue of every v with indeg[v] == 0
order = empty list
while Q is not empty:
    u = Q.pop_front();  order.append(u)
    for each v in adj[u]:                    // "delete" the edge u -> v
        indeg[v] = indeg[v] - 1
        if indeg[v] == 0:  Q.push_back(v)    // v has no unmet prerequisite left
if length(order) < V:  report "cycle"       // the leftovers wait on each other
return order

কোর্সের গ্রাফে চালিয়ে দেখুন। প্রতিবার queue-র সামনে থেকে একটি কোর্স বের হয়। পাশে “স্তর” (level) সারিটিও দেখুন — এটি পরের অংশের সেমিস্টার-হিসাব, এখনই বুঝতে হবে না।

প্রতিটি ধাপের পর (কলাম: P DM DS OOP ALG DB AI)
এই ধাপ
in-degree
queue (সামনে → পেছনে)
আউটপুট
স্তর (level)
  1. শুরু. সব edge একবার পড়ে in-degree গোনা হলো: DS-এ দুটি তীর ঢোকে (P, DM), DB-তে দুটি (DS, OOP), AI-তে দুটি (ALG, DM). যাদের in-degree ০ — P আর DM — তারা queue-তে।
  2. P বের হলো. P → DS আর P → OOP “মুছে” গেল: DS-এর in-degree ২ থেকে ১, OOP-এর ১ থেকে ০ — OOP queue-র পেছনে ঢুকল।
  3. DM বের হলো. DS ১ থেকে ০ — এখন DS-এর দুই পূর্বশর্তই শেষ, তাই সে queue-তে। AI ২ থেকে ১: এখনো ALG বাকি।
  4. OOP বের হলো. DB ২ থেকে ১. Queue-তে শুধু DS. খেয়াল করুন, OOP আর DS-এর মধ্যে কোনো নিয়ম নেই — queue-র ক্রমই ঠিক করেছে OOP আগে।
  5. DS বের হলো. ALG ১ থেকে ০, DB ১ থেকে ০ — দুজনই queue-তে। DB-এর স্তর ৩, কারণ তার সবচেয়ে দেরির পূর্বশর্ত DS ছিল স্তর ২-এ।
  6. ALG বের হলো. AI ১ থেকে ০, queue-তে ঢুকল। AI-এর স্তর হলো ALG-এর স্তর + ১ = ৪.
  7. DB বের হলো. DB থেকে কোনো তীর বের হয় না — সে একটি sink. কিছু বদলাল না।
  8. AI বের হলো. Queue খালি, আউটপুটে ৭টি কোর্স = V. তাই গ্রাফটি DAG, আর P DM OOP DS ALG DB AI একটি বৈধ topological order. প্রতিটি edge ঠিক একবার “মুছেছে”: in-degree মোট ৮ বার কমেছে = E.

কেন ঠিক — আর কীভাবে cycle ধরা পড়ে

বৈধতা: একটি vertex v queue-তে ঢোকে কেবল যখন indeg[v] শূন্য — অর্থাৎ v-তে ঢোকা প্রতিটি edge u → v-এর u ইতিমধ্যে আউটপুটে। তাই আউটপুটে প্রতিটি edge-এর লেজ মাথার আগে আসে। এটি একটি invariant: যেকোনো মুহূর্তে indeg[v] = v-এর এমন পূর্বশর্তের সংখ্যা, যারা এখনো আউটপুট হয়নি।

cycle ধরা: একটি cycle-এর কোনো vertex কখনো আউটপুট হয় না — cycle-এর যে vertex প্রথম আউটপুট হতো, তার cycle-এর আগের vertex তখনো আউটপুট হয়নি, তাই তার in-degree শূন্য হতে পারত না। উল্টো দিকে, শেষে কিছু vertex বাকি থাকলে, বাকিদের প্রত্যেকের in-degree ≥ ১, আর সেই edge-গুলো আসে বাকিদের মধ্য থেকেই (আউটপুট হওয়াদের edge আগেই বিয়োগ হয়েছে)। বাকিদের নিয়ে গঠিত গ্রাফে তাই কোনো source নেই, আর অংশ ২-এর লেমা বলে সেখানে একটি cycle আছে। সংক্ষেপে: আউটপুটে V টি vertex ⇔ গ্রাফটি DAG.

বাকি থাকা মানেই cycle-এর সদস্য নয়। যে vertex নিজে cycle-এ নেই কিন্তু cycle-এর কোনো vertex-এর ওপর নির্ভর করে, সেও আটকে থাকে — তার পূর্বশর্ত কখনো শেষ হবে না। পরের অংশের কোডে দেখবেন, DS ⇄ ALG চক্রের জন্য DB আর AI-ও আটকে গেছে। Cycle-টি নিজে ছাপতে হলে DFS ভালো (অংশ ৫ আর অনুশীলন ৪)।

জটিলতা: in-degree গোনায় সব edge একবার — O(V + E). প্রতিটি vertex queue-তে ঢোকে আর বের হয় ঠিক একবার — O(V). প্রতিটি vertex বের হওয়ার সময় তার adjacency list একবার পড়া হয়, তাই সব মিলিয়ে প্রতিটি edge একবার — O(E). মোট Θ(V + E) সময়, আর adjacency list, indeg ও queue মিলিয়ে Θ(V + E) জায়গা। Queue-র বদলে stack নিলেও অ্যালগরিদম ঠিক থাকে, শুধু অন্য একটি বৈধ ক্রম আসে — কোন source আগে, তাতে বৈধতা বদলায় না।

৪ · স্তরে স্তরে — ন্যূনতম কয় সেমিস্টারে সব কোর্স

এবার দ্বিতীয় প্রশ্ন: এক সেমিস্টারে যত খুশি কোর্স নেওয়া গেলে, সব শেষ করতে অন্তত কয়টি সেমিস্টার লাগবে? প্রতিটি কোর্সের স্তর সংজ্ঞায়িত করুন: source-এর স্তর ১, আর অন্যদের স্তর = তার পূর্বশর্তগুলোর সর্বোচ্চ স্তর + ১. Kahn চলার সময়ই এটি হিসাব হয়: u বের হলে প্রতিটি u → v-তে level[v] = max(level[v], level[u] + 1). v queue-তে ঢোকার আগে তার সব পূর্বশর্ত বের হয়ে গেছে, তাই তখন তার স্তর চূড়ান্ত।

দাবি: ন্যূনতম সেমিস্টার = সর্বোচ্চ স্তর = গ্রাফের সবচেয়ে লম্বা নির্ভরতার শিকলে কোর্সের সংখ্যা। নিচের সীমা: P → DS → ALG → AI শিকলের চারটি কোর্স চারটি আলাদা সেমিস্টারে, ক্রমানুসারে নিতেই হবে — কোনো সূচিতেই ৪-এর কম সম্ভব নয়। ওপরের সীমা: স্তর s-এর সব কোর্স সেমিস্টার s-এ নিন। প্রতিটি পূর্বশর্তের স্তর কঠোরভাবে ছোট, তাই সে আগের কোনো সেমিস্টারে শেষ — সূচিটি বৈধ। দুই সীমা মিলে গেল, তাই উত্তর ঠিক ৪. একই যুক্তিতে make -j একই স্তরের ফাইলগুলো একসাথে compile করতে পারে — স্তরের সংখ্যাই সময়ের নিচের সীমা।

নিচের কোডে kahn ফাংশনটি একটি ক্রম আর সেমিস্টারভিত্তিক ভাগ ছাপে, অথবা cycle পেলে কারা আটকে আছে তা বলে। দ্বিতীয় ডাকে তালিকায় একটি “ছাপার ভুল” যোগ করা হয়েছে: ALG → DS.

#include <algorithm>
#include <iostream>
#include <queue>
#include <string>
#include <utility>
#include <vector>
using namespace std;
void kahn(const vector<string>& name, const vector<pair<int, int>>& edges) {
    int n = name.size();
    vector<vector<int>> adj(n);
    vector<int> indeg(n, 0), level(n, 1), order;
    for (auto [u, v] : edges) { adj[u].push_back(v); ++indeg[v]; }   // u must come before v
    queue<int> q;
    for (int v = 0; v < n; ++v) if (indeg[v] == 0) q.push(v);
    while (!q.empty()) {
        int u = q.front(); q.pop(); order.push_back(u);
        for (int v : adj[u]) { level[v] = max(level[v], level[u] + 1); if (--indeg[v] == 0) q.push(v); }
    }
    if ((int)order.size() < n) {                                     // someone never reached in-degree 0
        cout << "cycle! only " << order.size() << " of " << n << " placed; stuck:";
        for (int v = 0; v < n; ++v) if (indeg[v] > 0) cout << ' ' << name[v];
        cout << '\n'; return;
    }
    cout << "order:"; for (int v : order) cout << ' ' << name[v];
    for (int s = 1; s <= *max_element(level.begin(), level.end()); ++s) {
        cout << "\nsemester " << s << ":";
        for (int v : order) if (level[v] == s) cout << ' ' << name[v];
    }
    cout << '\n';
}
int main() {
    vector<string> name = {"Programming", "Discrete", "DataStruct", "OOP", "Algorithms", "Database", "AI"};
    kahn(name, {{0, 2}, {1, 2}, {0, 3}, {2, 4}, {2, 5}, {3, 5}, {4, 6}, {1, 6}});
    kahn(name, {{0, 2}, {1, 2}, {0, 3}, {2, 4}, {2, 5}, {3, 5}, {4, 6}, {1, 6}, {4, 2}});   // typo: DataStruct needs Algorithms
}

আউটপুট:

order: Programming Discrete OOP DataStruct Algorithms Database AI
semester 1: Programming Discrete
semester 2: OOP DataStruct
semester 3: Algorithms Database
semester 4: AI
cycle! only 3 of 7 placed; stuck: DataStruct Algorithms Database AI

প্রথম ডাকের ক্রম ওপরের ডায়াগ্রামের সাথে হুবহু মেলে, আর সেমিস্টারের ভাগ স্তরের সারি থেকে: ৪টি সেমিস্টার, ঠিক যেমন প্রমাণ বলেছিল। দ্বিতীয় ডাকে ALG → DS যোগ হওয়ায় DS-এর in-degree ৩; P, DM আর OOP বের হওয়ার পরও DS-এর একটি পূর্বশর্ত (ALG) বাকি, আর ALG নিজে DS-এর অপেক্ষায় — queue খালি হয়ে গেল মাত্র ৩টি কোর্স বসিয়ে। আটকে থাকা চারজনের মধ্যে শুধু DS আর ALG cycle-এ; Database আর AI আটকেছে কারণ তারা এই চক্রের ওপর নির্ভর করে। for (auto [u, v] : edges) হলো C++17-এর structured binding. সময় Θ(V + E), জায়গা Θ(V + E).

from collections import deque

def kahn(names, edges):
    n = len(names)
    adj = [[] for _ in range(n)]
    indeg, level = [0] * n, [1] * n
    for u, v in edges:                 # u must be taken before v
        adj[u].append(v)
        indeg[v] += 1
    q = deque(v for v in range(n) if indeg[v] == 0)
    order = []
    while q:
        u = q.popleft()
        order.append(u)
        for v in adj[u]:
            level[v] = max(level[v], level[u] + 1)
            indeg[v] -= 1
            if indeg[v] == 0:
                q.append(v)
    if len(order) < n:                 # someone never reached in-degree 0
        stuck = [names[v] for v in range(n) if indeg[v] > 0]
        print(f"cycle! only {len(order)} of {n} placed; stuck:", *stuck)
        return
    print("order:", *(names[v] for v in order))
    for s in range(1, max(level) + 1):
        print(f"semester {s}:", *(names[v] for v in order if level[v] == s))

names = ["Programming", "Discrete", "DataStruct", "OOP", "Algorithms", "Database", "AI"]
edges = [(0, 2), (1, 2), (0, 3), (2, 4), (2, 5), (3, 5), (4, 6), (1, 6)]
kahn(names, edges)
kahn(names, edges + [(4, 2)])          # catalogue typo: DataStruct now needs Algorithms

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

order: Programming Discrete OOP DataStruct Algorithms Database AI
semester 1: Programming Discrete
semester 2: OOP DataStruct
semester 3: Algorithms Database
semester 4: AI
cycle! only 3 of 7 placed; stuck: DataStruct Algorithms Database AI

Python-এ queue হিসেবে collections.deque, কারণ list.pop(0) প্রতিবার O(n) সময় নেয় আর পুরো অ্যালগরিদমকে O(V²) বানিয়ে দিতে পারে। deque.popleft() O(1). এখানে recursion নেই, তাই লাখো vertex-এও Python-এর recursion-এর সীমা নিয়ে চিন্তা নেই — Kahn-এর একটি বাস্তব সুবিধা। Python ৩.৯ থেকে স্ট্যান্ডার্ড লাইব্রেরির graphlib.TopologicalSorter-ও একই কাজ করে, আর cycle পেলে CycleError দেয়।

সতর্কতা — সেমিস্টারে কোর্সের সীমা থাকলে: বাস্তবে এক সেমিস্টারে সর্বোচ্চ k টি কোর্স নেওয়া যায়। তখন সমস্যাটি হঠাৎ অনেক কঠিন: সাধারণ k-এর জন্য এটি NP-hard (Jeffrey Ullman, ১৯৭৫)। k = 2 হলে Coffman আর Graham-এর (১৯৭২) polynomial অ্যালগরিদম আছে, আর k = 3-এর জটিলতা আজও একটি বিখ্যাত খোলা প্রশ্ন। ছোট ইনপুটে (প্রায় ১৫টি কোর্স) কনটেস্টে bitmask DP দিয়ে করা হয় (পর্ব ৫৫)।

৫ · DFS দিয়ে topological sort — শেষ হওয়ার উল্টো ক্রম

দ্বিতীয় পদ্ধতিটি আসে DFS থেকে (পর্ব ৪১)। DFS একটি vertex u-তে ঢুকে তার প্রতিটি অদেখা প্রতিবেশীতে যতদূর যাওয়া যায় যায়, তারপর ফিরে আসে। যে মুহূর্তে u-এর সব প্রতিবেশী ঘোরা শেষ, সেটি u-এর finishing time f[u]. মূল পর্যবেক্ষণ: DAG-এ u → v থাকলে v সবসময় u-এর আগে শেষ হয় — কারণ u নিজের কাজ শেষ করার আগে v-কে ঘুরে আসে। তাই vertex-গুলোকে শেষ হওয়ার ক্রমে একটি তালিকায় লিখে তালিকাটি উল্টে দিলেই topological order.

প্রতিটি vertex-এর একটি রং রাখুন: সাদা (এখনো দেখা হয়নি), ধূসর (ঢুকেছি, কাজ চলছে — call stack-এ আছে), কালো (শেষ)। এই তিন-রঙের DFS একই সাথে cycle-ও ধরে।

Pseudocode — DFS-based topological sort (0-based)

color[v] = WHITE for every v;   L = empty list
for v = 0 to V-1:
    if color[v] == WHITE:  visit(v)
return reverse(L)                         // largest finishing time first

visit(u):
    color[u] = GRAY                       // u is on the recursion stack
    for each v in adj[u]:
        if color[v] == GRAY:  report "cycle"     // back edge u -> v
        if color[v] == WHITE: visit(v)
    color[u] = BLACK;  L.append(u)        // u finishes now

একই কোর্সের গ্রাফে, vertex-গুলো P, DM, DS, OOP, ALG, DB, AI ক্রমে চেষ্টা করে, adjacency list-এর ক্রমে প্রতিবেশী নিয়ে। d/f মানে প্রবেশ আর শেষের সময় (প্রতিটি ঘটনায় ঘড়ি এক বাড়ে)।

প্রতিটি ধাপের পর (W = সাদা, G = ধূসর, B = কালো)
এই ধাপ
রং (P DM DS OOP ALG DB AI)
call stack
নতুন d/f
শেষ হওয়ার তালিকা L
  1. শুরু. সব vertex সাদা, তালিকা L খালি। প্রথম সাদা vertex P থেকে visit শুরু।
  2. গভীরে নামা. P-এর প্রথম প্রতিবেশী DS, DS-এর প্রথম ALG, ALG-এর AI — প্রত্যেকে ধূসর হয়ে call stack-এ। এখনো কেউ শেষ হয়নি।
  3. প্রথম দুটি শেষ. AI থেকে কোনো তীর নেই, তাই সে প্রথম শেষ হয় (f = 5) — একটি sink সবসময় তালিকার শুরুর দিকে। তারপর ALG-এর আর কোনো প্রতিবেশী নেই, সেও শেষ (f = 6).
  4. DS-এর দ্বিতীয় শাখা. DS → DB: DB সাদা, ঢুকে দেখা গেল তার প্রতিবেশী নেই, শেষ (f = 8). এরপর DS-এর সব প্রতিবেশী কালো — DS শেষ (f = 9).
  5. P → OOP. OOP-এর প্রতিবেশী DB ইতিমধ্যে কালো — এটি cycle নয়, শুধু আগে ঘোরা একটি শাখা, তাই এড়িয়ে যাই। OOP শেষ (f = 11), তারপর P (f = 12).
  6. নতুন শুরু DM থেকে. P-এর DFS শেষ, কিন্তু DM এখনো সাদা। তার প্রতিবেশী DS আর AI দুটোই কালো, তাই DM সরাসরি শেষ (f = 14). এখন সবাই কালো।
  7. উল্টে দেওয়া. সবচেয়ে বড় f আগে: DM P OOP DS DB ALG AI. প্রতিটি edge মিলিয়ে দেখুন — যেমন OOP → DB-তে f[OOP] = 11 > f[DB] = 8. এই ক্রম Kahn-এর ক্রম থেকে আলাদা, কিন্তু সমান বৈধ।

প্রমাণ — কেন প্রতিটি edge-এ f[u] > f[v]

একটি DAG-এ DFS চলার সময় যে মুহূর্তে edge u → v পরীক্ষা হয়, তখন u ধূসর। v-এর রং তিনটির একটি হতে পারে। (১) v সাদা: DFS এখনই v-তে ঢুকবে, আর u-এর কাজ শেষ হওয়ার আগেই v-এর কাজ পুরো শেষ হবে — f[v] < f[u]. (২) v কালো: v আগেই শেষ, u এখনো শেষ হয়নি — f[v] < f[u]. (৩) v ধূসর: v তখনো call stack-এ, অর্থাৎ u আসলে v থেকে শুরু হওয়া পথের ভেতরে: v ⇝ u. তার সাথে u → v মিলে একটি cycle — DAG-এ অসম্ভব। তাই সব edge-এ f[u] > f[v], আর f-এর বড় থেকে ছোট ক্রমে সাজালে প্রতিটি edge-এর লেজ আগে আসে। ∎

(৩) নম্বর ক্ষেত্রটিই cycle ধরার নিয়ম: ধূসর vertex-এ যাওয়া edge-কে বলে back edge, আর directed গ্রাফে cycle আছে ⇔ DFS কোনো back edge পায়। উল্টো দিকটিও সত্য: cycle থাকলে cycle-এর যে vertex DFS প্রথম ছোঁয়, বাকি cycle তার নিচে ঘোরা হবে, আর cycle-এর শেষ vertex থেকে তার দিকে ফেরার edge-টি তখন ধূসর vertex-এ পড়বে। লক্ষ করুন, এখানে দুই রং যথেষ্ট নয়: শুধু “দেখা হয়েছে কি না” রাখলে ডায়াগ্রামের পঞ্চম ধাপের OOP → DB-কেও cycle ভেবে ভুল হবে। Undirected গ্রাফে DSU দিয়ে cycle ধরা গেলেও (পর্ব ৩৩) directed গ্রাফে সেটি ভুল উত্তর দেয় — এখানে লাগে ঠিক এই ধূসর-রং।

জটিলতা: প্রতিটি vertex একবার সাদা থেকে ধূসর আর একবার কালো হয়, প্রতিটি edge একবার পরীক্ষা হয় — Θ(V + E) সময়; রং, তালিকা আর call stack মিলে O(V) বাড়তি জায়গা। কিন্তু recursion-এর গভীরতা V পর্যন্ত যেতে পারে — একটি লম্বা শিকলে ১০৫ গভীর। C++-এ সাধারণত চলে, Python-এ recursion-এর সীমা (সাধারণত ১০০০) ছাড়িয়ে RecursionError. তখন নিজের stack দিয়ে iterative DFS লিখুন, অথবা সোজা Kahn ব্যবহার করুন।

দিকKahn (in-degree queue)DFS (finishing time)
সময় / জায়গাΘ(V + E) / Θ(V + E)Θ(V + E) / Θ(V + E)
cycle ধরাআউটপুটে V-এর কম vertexধূসর vertex-এ edge (back edge)
cycle-টি নিজে ছাপাসরাসরি নয়সহজ — call stack-এই আছে
recursionনেইগভীরতা V পর্যন্ত
স্তর বা lexicographic ক্রমস্বাভাবিকভাবে আসেসহজে আসে না

৬ · একটাই ক্রম, নাকি অনেক? — অনন্যতা আর lexicographic ক্রম

কোর্সের গ্রাফে আমরা অন্তত দুটি বৈধ ক্রম দেখেছি। কখন ক্রম ঠিক একটি? উপপাদ্য: একটি DAG-এর topological order অনন্য ⇔ সেই ক্রমের প্রতিটি পরপর জোড়া (vi, vi+1)-এর মধ্যে একটি edge আছে — অর্থাৎ গ্রাফে একটি Hamiltonian path আছে, যে পথ সব vertex একবার করে ছোঁয়।

প্রমাণ. (⇐) যদি v1 → v2 → … → vn পথটি থাকে, যেকোনো বৈধ ক্রমে v1 অবশ্যই v2-এর আগে, v2 অবশ্যই v3-এর আগে, ইত্যাদি — ক্রম পুরোপুরি বাঁধা, তাই অনন্য। (⇒) ধরুন কোনো একটি বৈধ ক্রমে vi আর vi+1-এর মধ্যে edge নেই। তাদের জায়গা অদলবদল করুন। অন্য কোনো vertex-এর সাপেক্ষে তাদের অবস্থান বদলায়নি, আর তাদের দুজনের মধ্যে কোনো নিয়ম নেই — তাই নতুন ক্রমটিও বৈধ, অর্থাৎ ক্রম অনন্য নয়। ∎

ব্যবহারিক রূপ: Kahn চালানোর সময় queue-তে কখনো দুই বা ততোধিক vertex একসাথে থাকলে ক্রম অনন্য নয় (যেকোনোটি আগে নেওয়া যেত); সবসময় ঠিক একটি থাকলে প্রতিটি পদক্ষেপ বাধ্যতামূলক, তাই অনন্য। এতে O(V + E)-এ সাধারণ গ্রাফের একটি কঠিন প্রশ্ন — Hamiltonian path আছে কি না — DAG-এর জন্য সহজ হয়ে যায়।

Lexicographically সবচেয়ে ছোট ক্রম — queue-র বদলে min-heap

অনেক সমস্যায় শর্ত থাকে: “একাধিক বৈধ উত্তর থাকলে lexicographically সবচেয়ে ছোটটি ছাপুন” — অর্থাৎ প্রতিটি অবস্থানে সম্ভাব্য সবচেয়ে ছোট নম্বর। সমাধান সরল: Kahn-এর queue-র জায়গায় একটি min-heap (পর্ব ২৩) রাখুন, আর প্রতিবার প্রস্তুতদের মধ্যে সবচেয়ে ছোটটি নিন। প্রতিটি vertex একবার heap-এ ঢোকে আর বের হয় (O(log V) করে), প্রতিটি edge O(1) — মোট O(V log V + E). নিচের ছোট গ্রাফে (ধরুন ৬টি প্যাকেজ, নম্বর 0–5) edge-গুলো 3 → 0, 5 → 1, 3 → 2, 2 → 1, 4 → 2:

প্রতিটি ধাপের পর (in-degree কলাম: 0 1 2 3 4 5)
এই ধাপ
in-degree
min-heap-এ প্রস্তুত
আউটপুট
  1. শুরু. in-degree ০: 3, 4, 5. সাধারণ FIFO queue-তে এরা 3, 4, 5 ক্রমে বের হতো।
  2. 3 বের হলো. সবচেয়ে ছোট প্রস্তুত 3. তার তীর 3 → 0 আর 3 → 2: 0-এর in-degree শূন্য, heap-এ ঢুকল; 2-এর in-degree ২ থেকে ১।
  3. 0 বের হলো. Heap-এ {0, 4, 5}; সবচেয়ে ছোট 0. FIFO queue হলে এখানে 4 বের হতো, কারণ 4 আগে থেকে সারিতে ছিল — এখানেই দুই পদ্ধতি আলাদা হয়ে যায়।
  4. 4 বের হলো. 4 → 2: 2-এর in-degree শূন্য, heap-এ {2, 5}.
  5. 2 বের হলো. 2 < 5, তাই 2. তার তীর 2 → 1: 1-এর in-degree ২ থেকে ১ — এখনো 5 বাকি।
  6. 5 বের হলো. 5 → 1: 1 প্রস্তুত।
  7. 1 বের হলো. উত্তর 3 0 4 2 5 1. FIFO queue দিত 3 4 5 0 2 1 — বৈধ, কিন্তু দ্বিতীয় অবস্থানেই 4 > 0, তাই lexicographically বড়। Greedy-টি ঠিক, কারণ প্রতিটি অবস্থানে যে ছোট vertex প্রস্তুত, তাকে এখন না বসালে পরে বসানোর কোনো লাভ নেই — বাকি সব বৈধ সমাপ্তি তখনো সম্ভব।

একটি সূক্ষ্ম ফাঁদ: “0 নম্বর যত আগে সম্ভব, তারপর 1 যত আগে সম্ভব…” — এটি lexicographically ছোট ক্রম নয়. যেমন তিনটি vertex 0, 1, 2 আর একটি edge 2 → 0. Lexicographically ছোট ক্রম 1 2 0, কিন্তু “0 যত আগে সম্ভব” চাইলে উত্তর 2 0 1. দ্বিতীয়টির প্রচলিত কৌশল: সব edge উল্টে দিন, max-heap দিয়ে Kahn চালান, আর পাওয়া ক্রমটি উল্টে দিন।

৭ · DAG-এ DP — দীর্ঘতম পথ, critical path আর পথ গোনা

Topological order-এর সবচেয়ে বড় উপহার: প্রতিটি vertex-এ পৌঁছানোর আগে তার সব পূর্বসূরি প্রস্তুত. ঠিক এটাই dynamic programming-এর দরকার (পর্ব ৫২) — প্রতিটি উপ-সমস্যা তার ওপর নির্ভরশীল উপ-সমস্যাগুলোর পরে। আসলে যেকোনো DP-র উপ-সমস্যাগুলো একটি DAG, আর tabulation-এর লুপের ক্রম সেই DAG-এর একটি topological order.

দীর্ঘতম আর সংক্ষিপ্ততম পথ, O(V + E)-এ

Edge-এ ওজন w(u, v) থাকলে, s থেকে প্রতিটি v পর্যন্ত দীর্ঘতম পথ: dist[s] = 0, অন্য সবার −∞; তারপর topological order-এ প্রতিটি u নিয়ে প্রতিটি u → v-তে dist[v] = max(dist[v], dist[u] + w(u, v)). প্রমাণ (order-এর অবস্থানের ওপর induction): v-এ পৌঁছানো যেকোনো পথের শেষ edge কোনো u → v, আর u order-এ v-এর আগে; তাই v-এর পালা আসার আগে প্রতিটি পূর্বসূরি u-এর dist চূড়ান্ত (induction), আর তাদের প্রত্যেকটি থেকে v-কে একবার হালনাগাদ করা হয়েছে। প্রতিটি edge একবার — Θ(V + E). max-কে min আর −∞-কে +∞ করলে সংক্ষিপ্ততম পথ, এমনকি ঋণাত্মক ওজনেও — cycle নেই, তাই Dijkstra-র সমস্যা (পর্ব ৪৩) বা Bellman-Ford-এর V − 1 বার ঘোরা (পর্ব ৪৪) কিছুই লাগে না।

সাধারণ গ্রাফে দীর্ঘতম পথ কঠিন। Cycle-যুক্ত গ্রাফে simple দীর্ঘতম পথ খোঁজা NP-hard — কারণ “n − 1 টি edge-এর পথ আছে কি?” প্রশ্নটিই Hamiltonian path (পর্ব ৫৬, ৬৪)। DAG-এ সহজ, কারণ সেখানে প্রতিটি পথই নিজে থেকে simple, আর একটি ক্রম আছে যা মেনে হিসাব চলে। AI প্রায়ই “longest path O(V + E)” বলে শর্তটি — DAG — বাদ দেয়।

Critical path method — বিয়েবাড়ির কাচ্চি কখন নামবে?

প্রজেক্ট পরিকল্পনায় প্রতিটি কাজের একটি সময় (duration) থাকে, আর কাজগুলোর মধ্যে নির্ভরতা। প্রশ্ন: সব কাজ শেষ হতে সবচেয়ে কম কত সময় লাগবে, যদি পরস্পর-নির্ভর নয় এমন কাজ একসাথে চলতে পারে? প্রতিটি কাজ v-এর সবচেয়ে আগে শুরুর সময় start[v] = তার পূর্বশর্তগুলোর সর্বোচ্চ (start[u] + dur[u]) — একটি দীর্ঘতম পথ, যেখানে edge u → v-এর ওজন dur[u]. পুরো প্রজেক্টের সময় = সবচেয়ে দেরিতে শেষ হওয়া কাজের শেষ সময়, আর যে পথ এই সময় ঠিক করে, তার নাম critical path: তার যেকোনো কাজ দেরি করলে পুরো প্রজেক্ট দেরি করে। এই পদ্ধতির নাম critical path method (CPM); ১৯৫০-এর দশকের শেষে DuPont কোম্পানিতে CPM আর মার্কিন নৌবাহিনীর Polaris প্রকল্পে প্রায় একই ধাঁচের PERT তৈরি হয়। আজও বড় সেতু বা ভবন নির্মাণ থেকে সফটওয়্যার রিলিজ পর্যন্ত Gantt chart আর project-management সফটওয়্যার এই হিসাবের ওপর দাঁড়িয়ে।

কাজ (কোডে নাম)সময় (মিনিট)আগে যা লাগবে
পেঁয়াজ বেরেস্তা (FryOnion)২৫—
মাংস মেরিনেট (Marinate)৬০বেরেস্তা
চাল ভেজানো (SoakRice)৩০—
চাল আধা-সেদ্ধ (BoilRice)১৫চাল ভেজানো
আলু ভাজা (FryPotato)২০—
হাঁড়িতে স্তর সাজানো (Layer)১০মেরিনেট, আধা-সেদ্ধ চাল, আলু
দমে রান্না (Dum)৬০স্তর সাজানো
start[ ] প্রতিটি ধাপের পর (কলাম: FryOnion SoakRice FryPotato Marinate BoilRice Layer Dum)
এখন যে কাজ (u)
এই ধাপের হিসাব
start
Layer-এর critical পূর্বসূরি
  1. শুরু. Kahn দিয়ে পাওয়া ক্রম: FryOnion, SoakRice, FryPotato, Marinate, BoilRice, Layer, Dum. সবার start আপাতত ০।
  2. FryOnion. বেরেস্তা ০ মিনিটে শুরু, ২৫-এ শেষ; তাই মেরিনেট সবচেয়ে আগে ২৫ মিনিটে শুরু হতে পারে।
  3. SoakRice. চাল ভেজানো শেষ ৩০-এ — আধা-সেদ্ধ শুরু ৩০.
  4. FryPotato. আলু ২০-এ তৈরি, তাই এ পর্যন্ত জানা তথ্যে স্তর সাজানো ২০-এ শুরু হতে পারত। কিন্তু Layer-এর আরও দুই পূর্বশর্ত বাকি।
  5. Marinate. ২৫ + ৬০ = ৮৫ — Layer-এর start ২০ থেকে ৮৫, আর তার critical পূর্বসূরি এখন মেরিনেট।
  6. BoilRice. চাল তৈরি ৪৫-এ, কিন্তু মাংস তখনো হয়নি; ৪৫ < ৮৫, কিছু বদলায় না। চালের দিকে ৪০ মিনিট হাতে আছে — এটিই এর slack.
  7. Layer. ৮৫-এ শুরু, ৯৫-এ শেষ — দম শুরু ৯৫-এ।
  8. Dum. শেষ ৯৫ + ৬০ = ১৫৫ মিনিট। পেছনের দিকে critical পূর্বসূরি ধরে হাঁটলে critical path: বেরেস্তা → মেরিনেট → স্তর → দম (২৫ + ৬০ + ১০ + ৬০ = ১৫৫)। বাবুর্চি জানেন, বেরেস্তায় ৫ মিনিট দেরি মানে খাবার ৫ মিনিট দেরি; কিন্তু আলুর হাতে ৬৫ মিনিট slack.
#include <cstdio>
#include <queue>
#include <string>
#include <utility>
#include <vector>
using namespace std;
int main() {
    vector<string> name = {"FryOnion", "Marinate", "SoakRice", "BoilRice", "FryPotato", "Layer", "Dum"};
    vector<int> dur = {25, 60, 30, 15, 20, 10, 60};                   // minutes
    vector<pair<int, int>> edges = {{0, 1}, {2, 3}, {1, 5}, {3, 5}, {4, 5}, {5, 6}};
    int n = name.size();
    vector<vector<int>> adj(n);
    vector<int> indeg(n, 0), order, start(n, 0), best(n, -1);        // earliest start, critical predecessor
    for (auto [u, v] : edges) { adj[u].push_back(v); ++indeg[v]; }
    queue<int> q;
    for (int v = 0; v < n; ++v) if (indeg[v] == 0) q.push(v);
    while (!q.empty()) {                                              // Kahn: a topological order
        int u = q.front(); q.pop(); order.push_back(u);
        for (int v : adj[u]) if (--indeg[v] == 0) q.push(v);
    }
    for (int u : order)                                               // longest-path DP, edge weight = dur[u]
        for (int v : adj[u])
            if (start[u] + dur[u] > start[v]) { start[v] = start[u] + dur[u]; best[v] = u; }
    printf("order:"); for (int v : order) printf(" %s", name[v].c_str()); printf("\n");
    int end = 0;
    for (int v : order) {
        printf("%-10s start %3d  finish %3d\n", name[v].c_str(), start[v], start[v] + dur[v]);
        if (start[v] + dur[v] > start[end] + dur[end]) end = v;
    }
    vector<string> path;
    for (int v = end; v != -1; v = best[v]) path.push_back(name[v]);
    printf("total %d min; critical path:", start[end] + dur[end]);
    for (int i = path.size() - 1; i >= 0; --i) printf(i ? " %s ->" : " %s", path[i].c_str());
    printf("\n");
}

আউটপুট:

order: FryOnion SoakRice FryPotato Marinate BoilRice Layer Dum
FryOnion   start   0  finish  25
SoakRice   start   0  finish  30
FryPotato  start   0  finish  20
Marinate   start  25  finish  85
BoilRice   start  30  finish  45
Layer      start  85  finish  95
Dum        start  95  finish 155
total 155 min; critical path: FryOnion -> Marinate -> Layer -> Dum

প্রতিটি সারি ওপরের ডায়াগ্রামের শেষ অবস্থা। best[v] মনে রাখে কোন পূর্বসূরি v-এর start ঠিক করেছে; সবচেয়ে দেরিতে শেষ হওয়া কাজ (Dum) থেকে best ধরে পেছনে হাঁটলেই critical path, যা শেষে উল্টো করে ছাপা হয়েছে। এখানে ওজন কাজের (vertex-এর) ওপর; edge-ওজনের সাধারণ রূপে শুধু start[u] + dur[u]-এর জায়গায় dist[u] + w বসবে। Kahn আর DP দুটোই Θ(V + E), জায়গা Θ(V + E).

from collections import deque

names = ["FryOnion", "Marinate", "SoakRice", "BoilRice", "FryPotato", "Layer", "Dum"]
dur = [25, 60, 30, 15, 20, 10, 60]                     # minutes
edges = [(0, 1), (2, 3), (1, 5), (3, 5), (4, 5), (5, 6)]
n = len(names)
adj, indeg = [[] for _ in range(n)], [0] * n
for u, v in edges:
    adj[u].append(v)
    indeg[v] += 1
q = deque(v for v in range(n) if indeg[v] == 0)
order = []
while q:                                               # Kahn: a topological order
    u = q.popleft()
    order.append(u)
    for v in adj[u]:
        indeg[v] -= 1
        if indeg[v] == 0:
            q.append(v)
start, best = [0] * n, [-1] * n                        # earliest start, critical predecessor
for u in order:                                        # longest-path DP, edge weight = dur[u]
    for v in adj[u]:
        if start[u] + dur[u] > start[v]:
            start[v], best[v] = start[u] + dur[u], u
print("order:", *(names[v] for v in order))
for v in order:
    print(f"{names[v]:<10} start {start[v]:>3}  finish {start[v] + dur[v]:>3}")
end = max(range(n), key=lambda v: start[v] + dur[v])
path = []
while end != -1:
    path.append(names[end])
    end = best[end]
print("total", max(s + d for s, d in zip(start, dur)), "min; critical path:", " -> ".join(reversed(path)))

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

order: FryOnion SoakRice FryPotato Marinate BoilRice Layer Dum
FryOnion   start   0  finish  25
SoakRice   start   0  finish  30
FryPotato  start   0  finish  20
Marinate   start  25  finish  85
BoilRice   start  30  finish  45
Layer      start  85  finish  95
Dum        start  95  finish 155
total 155 min; critical path: FryOnion -> Marinate -> Layer -> Dum

f"{names[v]:<10}" নামটিকে ১০ ঘরে বাঁ-দিকে সাজায়, :>3 সংখ্যাকে ৩ ঘরে ডানে — C++-এর %-10s আর %3d-এর সমান। start[v], best[v] = … এক লাইনে দুটি হালনাগাদ। Slack বের করতে চাইলে একই কাজ উল্টো topological order-এ করুন: প্রতিটি কাজের সবচেয়ে দেরিতে শেষের সময় = তার পরের কাজগুলোর সবচেয়ে দেরিতে শুরুর সময়ের সর্বনিম্ন; slack = দেরিতে শুরু − আগে শুরু। যেমন BoilRice-এর slack ৮৫ − ১৫ − ৩০ = ৪০ মিনিট।

পথ গোনা

একই ছাঁচে, “কত ভাবে” প্রশ্নও। ways[v] = v-এ শেষ হওয়া পথের সংখ্যা: source-এ ১, অন্যদের ways[v] = সব পূর্বসূরি u-র ways[u]-এর যোগফল, topological order-এ হিসাব। কোর্সের গ্রাফে P = 1, DM = 1, OOP = 1, DS = 1 + 1 = 2, ALG = 2, DB = 2 + 1 = 3, AI = ALG + DM = 2 + 1 = 3 — AI-তে শেষ হওয়া তিনটি পূর্বশর্ত-শিকল: P → DS → ALG → AI, DM → DS → ALG → AI, আর DM → AI. পথের সংখ্যা ঘাতের হারে বাড়তে পারে (প্রতিটি “হীরা” আকৃতির অংশ সংখ্যাকে দ্বিগুণ করে), তাই কনটেস্টে উত্তর সাধারণত 109 + 7 দিয়ে ভাগশেষ চাওয়া হয় (modular arithmetic). গ্রিডে শুধু ডানে আর নিচে চলার পথ গোনা, বা সিঁড়ির ধাপ গোনা — সবই আসলে DAG-এ পথ গোনা।

৮ · নির্ভরতার চক্র — circular import থেকে spreadsheet পর্যন্ত

বাস্তব সফটওয়্যারে topological sort-এর অর্ধেক কাজ ক্রম বানানো, বাকি অর্ধেক circular dependency ধরা আর পরিষ্কার ভাষায় জানানো। কয়েকটি পরিচিত রূপ:

  • Python-এর circular import: a.py লেখে from b import f, আর b.py লেখে from a import g. প্রথম import চলার মাঝপথে দ্বিতীয়টি অর্ধেক-তৈরি module খুঁজে পায় — আর ImportError আসে, যেখানে “partially initialized module” আর “circular import” কথাগুলো থাকে। Go ভাষা আরও কড়া: compile-এর সময়ই “import cycle not allowed”.
  • Build system: make চক্র পেলে সতর্ক করে আর একটি নির্ভরতা বাদ দেয়; আধুনিক build system (Ninja, Bazel) সাধারণত পুরো চক্রটি ছেপে থেমে যায়।
  • Spreadsheet: A1 = B1 + 1 আর B1 = A1 × 2 লিখলে Excel “circular reference” সতর্কবার্তা দেয়। একটি ঘর বদলালে কোন ঘরগুলো কোন ক্রমে নতুন করে হিসাব হবে, তা-ও ঘরগুলোর নির্ভরতার DAG থেকে আসে।
  • প্যাকেজ আর ডেটাবেস: প্যাকেজ ম্যানেজার নির্ভরতার ক্রমে ইনস্টল করে; ডেটাবেসে foreign key-যুক্ত টেবিল তৈরি বা মোছার ক্রমও topological order.

সব ক্ষেত্রে ভালো ত্রুটি-বার্তার জন্য শুধু “cycle আছে” যথেষ্ট নয় — ব্যবহারকারী জানতে চান কোন চক্র। DFS-এর call stack এখানে কাজে আসে: back edge u → v পেলে v থেকে u পর্যন্ত stack-এর অংশ আর সেই edge মিলে চক্রটি (অনুশীলন ৪)। একাধিক চক্র জড়িয়ে থাকলে strongly connected component (পর্ব ৪৬) দিয়ে প্রতিটি “জট” আলাদা করা যায় — আর সেই জটগুলোকে একটি করে বিন্দু ধরলে বাকি গ্রাফটি আবার একটি DAG.

১৯৬২ সালে A. B. Kahn বড় নেটওয়ার্কের — যেমন PERT-ধাঁচের প্রজেক্টের হাজারো কাজের — ক্রম বের করার জন্য in-degree-ভিত্তিক পদ্ধতিটি প্রকাশ করেন। ১৯৬৮ সালে Donald Knuth তাঁর The Art of Computer Programming-এর প্রথম খণ্ডে topological sort-কে linked list-এর একটি প্রধান উদাহরণ হিসেবে শেখান। DFS-এর finishing time দিয়ে topological sort-এর প্রথম ছাপা বর্ণনা সাধারণত ধরা হয় Robert Tarjan-এর ১৯৭৬ সালের একটি প্রবন্ধকে — DFS-এর শক্তি নিয়ে তাঁর ধারাবাহিক কাজের অংশ হিসেবে (একই ধারায় SCC-র অ্যালগরিদম, পর্ব ৪৬)। আর ১৯৭৬ সালেই Stuart Feldman-এর make এই ধারণাকে প্রতিটি প্রোগ্রামারের দৈনন্দিন হাতিয়ারে পরিণত করে।

আরও গভীরে — কয়টি topological order? একটি DAG-এর মোট বৈধ ক্রমের সংখ্যা গোনা #P-complete (Brightwell ও Winkler, ১৯৯১) — বাস্তবে polynomial সময়ে সম্ভব নয় বলে ধরা হয়, যদিও একটি ক্রম বের করা O(V + E). ছোট গ্রাফে (V ≤ ২০) bitmask DP দিয়ে গোনা যায়: dp[S] = S সেটের vertex-গুলো দিয়ে কয়ভাবে সারির শুরুটা সাজানো যায়, যেখানে পরের vertex-টির সব পূর্বশর্ত S-এ থাকতে হবে — O(2V · V). আর অনেক vertex-এ dynamic topological order (edge একে একে যোগ হতে থাকলে ক্রম হালনাগাদ রাখা) গবেষণার একটি চলমান বিষয়, যা incremental compiler আর spreadsheet-এ কাজে লাগে।

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

  • কোন গ্রাফের topological order আছে? — ঠিক DAG-গুলোর; cycle থাকলে pos-এর অসমতা নিজের সাথে বিরোধে যায়, আর DAG-এ সবসময় একটি source থাকে।
  • Kahn-এর অ্যালগরিদমে cycle কীভাবে ধরা পড়ে? — queue খালি হলো অথচ আউটপুটে V-এর কম vertex; বাকিদের কেউ in-degree ০-তে পৌঁছায়নি।
  • DFS-এ topological order কেন finishing time-এর উল্টো ক্রম? — DAG-এ প্রতিটি edge u → v-তে f[u] > f[v]; ধূসর v পাওয়া মানে back edge, অর্থাৎ cycle.
  • ক্রম অনন্য কি না কীভাবে বুঝবেন? — Kahn-এ queue-তে কখনো দুটি vertex একসাথে থাকলে অনন্য নয়; অনন্য ⇔ ক্রমের পরপর প্রতিটি জোড়ায় edge (Hamiltonian path).
  • DAG-এ দীর্ঘতম পথের সময়? — topological order ধরে DP, Θ(V + E); সাধারণ গ্রাফে একই প্রশ্ন NP-hard.

৯ · AI-এর যুগে topological sort — ঠিক দেখতে, ভুল চলতে

“Course schedule সমস্যার কোড দাও” — এটি AI-কে দেওয়া সবচেয়ে প্রচলিত গ্রাফ-অনুরোধগুলোর একটি, আর উত্তর প্রায় সবসময় ঝকঝকে। কিন্তু topological sort-এর ভুলগুলো চুপচাপ থাকে: cycle-যুক্ত ইনপুটে কোড একটি আংশিক ক্রম ফেরত দিয়ে খুশি থাকে, বা DFS-এ finishing তালিকা উল্টাতে ভুলে যায়, আর ছোট উদাহরণে অনেক সময় উত্তর তবুও “ঠিক” দেখায়।

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

AI-এর ভুলকীভাবে ধরবেন
Kahn-এর শেষে len(order) == V যাচাই নেই0 → 1, 1 → 0 আর একটি আলাদা vertex 2 দিন — কোড “2” ছেপে সফল দাবি করবে
DFS-এ finishing তালিকা উল্টানো হয়নিশিকল 0 → 1 → 2-তে উত্তর 2 1 0 আসবে
directed cycle ধরতে শুধু visited (দুই রং) বা DSU0 → 1, 0 → 2, 2 → 1 — cycle নেই, কিন্তু কোড “cycle” বলবে
edge-এর দিক উল্টো (“a-র জন্য b লাগে” মানে b → a)একটি পূর্বশর্ত হাতে মিলিয়ে দেখুন — আগে আসা কোর্সটি কি সত্যিই আগে?
Python-এ list.pop(0) বা recursive DFS১০৫ vertex-এর শিকলে সময় মাপুন — O(V²) বা RecursionError
“lexicographically ছোট” চাইলেও সাধারণ queuevertex 0, 3, 4, 5 আর একটি edge 3 → 0 — উত্তর 3 0 4 5 না এলে ভুল
“longest path O(V + E)” — DAG শর্ত ছাড়াজিজ্ঞেস করুন: cycle থাকলে? সঠিক উত্তর: সাধারণ গ্রাফে NP-hard

AI-কে সমাধান লিখতে না বলে আপনার কোড, trace আর প্রমাণ যাচাই করতে বলুন:

আমি DSA শিখছি, এখন topological sort. নিচে আমার লেখা Kahn-এর অ্যালগরিদম আর একটি ইনপুটে আমার হাতে করা trace দিলাম: [এখানে কোড আর trace পেস্ট করুন] পুরো সমাধান নতুন করে লিখে দিও না। শুধু এগুলো করো: ১. edges 5→2, 5→0, 4→0, 4→1, 2→3, 3→1-এ প্রতিটি ধাপের পর in-degree অ্যারে আর queue দেখাও, আর আমার trace-এর সাথে কোথায় অমিল বলো। ২. আমার কোড cycle-যুক্ত গ্রাফে কী করে? একটি ছোট প্রতি-উদাহরণ দাও যেখানে এটি ভুল উত্তর দিতে পারে। ৩. "আউটপুটে V টি vertex থাকলে গ্রাফটি DAG, না থাকলে cycle আছে" — দুই দিকের প্রমাণ ধাপে ধাপে লেখো। ৪. আমার কোডের time আর space complexity কত, V আর E দিয়ে? কোন লাইন কোন অংশের জন্য দায়ী, বলো।

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

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

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

প্র ০১Topological sort-এর জন্ম কম্পিউটার বিজ্ঞানের ক্লাসরুমে নয়, ১৯৫০-৬০-এর দশকের প্রজেক্ট ব্যবস্থাপনায় — PERT, CPM আর Kahn-এর ১৯৬২ সালের প্রবন্ধের সম্পর্ক কী?

১৯৫০-এর দশকের শেষে দুটি বিশাল প্রকৌশল-প্রকল্প একই সমস্যায় পড়ে। মার্কিন নৌবাহিনীর Polaris ক্ষেপণাস্ত্র প্রকল্পে হাজার হাজার ঠিকাদার আর কাজ — কোনটি কোনটির ওপর নির্ভর করে, আর পুরো প্রকল্প কখন শেষ হবে? তার জবাবে তৈরি হয় PERT (Program Evaluation and Review Technique), যেখানে প্রতিটি কাজের সময়কে অনিশ্চিত ধরে আশাবাদী, সম্ভাব্য আর হতাশাবাদী — তিনটি অনুমান নেওয়া হয়। প্রায় একই সময়ে রাসায়নিক কোম্পানি DuPont-এর কারখানার কাজের সময় কমাতে DuPont-এর Morgan Walker আর Remington Rand-এর James Kelley তৈরি করেন CPM, যেখানে সময় নির্দিষ্ট ধরা হয় আর মূল প্রশ্ন critical path. দুটির ভেতরেই একই গাণিতিক বস্তু: কাজের নির্ভরতার একটি DAG, আর তার ওপর দীর্ঘতম পথ।

কিন্তু হিসাব করতে হলে প্রথমে কাজগুলোকে এমন ক্রমে সাজাতে হয় যাতে প্রতিটি কাজের আগে তার পূর্বশর্তগুলো আসে — ঠিক topological sort. তখনকার কম্পিউটারে মেমরি অল্প, ইনপুট আসে punched card-এ, আর নেটওয়ার্কে হাজারো কাজ। ১৯৬২ সালে A. B. Kahn তাঁর “Topological sorting of large networks” প্রবন্ধে দেখান কীভাবে প্রতিটি কাজের বাকি পূর্বশর্তের সংখ্যা গুনে, শূন্য হওয়া কাজগুলো একটি তালিকায় রেখে, রৈখিক সময়ে পুরো ক্রম বের করা যায় — আর ক্রম শেষ না হলে নেটওয়ার্কে একটি “loop” আছে, অর্থাৎ পরিকল্পনাতেই ভুল। প্রজেক্ট পরিকল্পনায় এই ভুল ধরা খুব জরুরি ছিল, কারণ হাজারো কাজের তালিকায় মানুষের হাতে একটি চক্র ঢুকে পড়া সহজ।

পরে Knuth যখন ১৯৬৮ সালে The Art of Computer Programming-এর প্রথম খণ্ড লেখেন, তিনি topological sort-কে linked list-এর ব্যবহারের প্রধান উদাহরণ বানান — in-degree গোনা আর শূন্যদের সারি, Kahn-এর ধারণার একটি যত্নশীল বাস্তবায়ন। এভাবে একটি ব্যবস্থাপনার কৌশল ডেটা স্ট্রাকচারের ক্লাসিক পাঠ্যে জায়গা পায়। আজ Microsoft Project-এর মতো সফটওয়্যারের Gantt chart, বাংলাদেশের মেট্রো রেল বা সেতু-প্রকল্পের সময়সূচি — সবকিছুর পেছনে এখনো সেই একই হিসাব।

মূল উপলব্ধি: অনেক মৌলিক অ্যালগরিদম জন্মেছে খুব বাস্তব প্রয়োজন থেকে। Topological sort-এর ক্ষেত্রে প্রয়োজনটি ছিল “কোন কাজ আগে, আর সব কখন শেষ” — এক প্রশ্নে ক্রম, cycle ধরা আর দীর্ঘতম পথ, তিনটি একসাথে।

প্র ০২make থেকে Bazel পর্যন্ত — build system কীভাবে DAG দিয়ে ঠিক করে কোন ফাইল আবার compile করতে হবে, আর কতটা একসাথে চালানো যায়?

১৯৭৬ সালে Bell Labs-এ Stuart Feldman একটি পরিচিত বিরক্তি থেকে make লেখেন: প্রোগ্রামাররা কোনো ফাইল বদলে কোন কোন অংশ আবার compile করতে হবে তা ভুলে যেতেন, আর ভুল সংস্করণের প্রোগ্রাম নিয়ে ঘণ্টার পর ঘণ্টা debug করতেন। Makefile-এ প্রতিটি “target”-এর পাশে লেখা থাকে সে কোন ফাইলের ওপর নির্ভর করে, আর কোন কমান্ডে তাকে বানাতে হয়। এই নির্ভরতাগুলোই একটি DAG: app নির্ভর করে main.o আর util.o-এর ওপর, main.o নির্ভর করে main.c আর util.h-এর ওপর। make DFS-এর মতো target থেকে নিচে নামে, প্রথমে নির্ভরগুলো হালনাগাদ করে, তারপর target — অর্থাৎ একটি topological order-এ কাজ করে।

দ্বিতীয় বুদ্ধিটি incremental build: একটি target আবার বানাতে হবে কেবল যদি তার কোনো নির্ভর ফাইল তার চেয়ে নতুন হয় (make ফাইলের পরিবর্তনের সময় তুলনা করে)। util.h বদলালে শুধু DAG-এ তার থেকে পৌঁছানো যায় এমন target-গুলো আবার বানাতে হয় — বাকিগুলো ছোঁয়াই হয় না। লাখো ফাইলের প্রজেক্টে এটিই ঘণ্টা আর সেকেন্ডের পার্থক্য।

তৃতীয়টি সমান্তরালতা। make -j8 একসাথে আটটি কাজ চালায় — কিন্তু কোনগুলো? যাদের সব নির্ভরতা শেষ, অর্থাৎ Kahn-এর queue-তে যারা আছে। এই পর্বের “স্তর” ধারণা এখানে নিচের সীমা দেয়: যত core-ই থাকুক, দীর্ঘতম নির্ভরতার শিকলের (critical path) চেয়ে দ্রুত build শেষ হবে না। বড় C++ প্রজেক্টে তাই একটি বিশাল header সবাই include করলে শিকল লম্বা হয় আর সমান্তরালতা নষ্ট হয়।

পরের প্রজন্মের টুল এই ধারণাগুলোকে আরও কড়া করেছে। Google Chrome-এর বিশাল কোডবেসের জন্য তৈরি Ninja দ্রুততার জন্য প্রায় সব সিদ্ধান্ত আগেই নিয়ে রাখে, আর Bazel-এর মতো টুল প্রতিটি কাজের ইনপুটের hash রেখে ফল cache করে, এমনকি অন্য মেশিনেও — যাতে একই ইনপুটে কাজ দ্বিতীয়বার চালাতে না হয়। আর সবাই চক্র পেলে থেমে যায়, কারণ চক্রযুক্ত নির্ভরতায় “কোনটি আগে” প্রশ্নের উত্তরই নেই।

মূল উপলব্ধি: একটি build system আসলে তিনটি গ্রাফ-অ্যালগরিদম: topological order (কোন ক্রমে), reachability (কী আবার বানাতে হবে) আর critical path (কতটা সমান্তরাল হতে পারে)।

প্র ০৩Circular import বা প্যাকেজের চক্র কেন সফটওয়্যার নকশার “গন্ধ” ধরা হয়, আর ভার্সনের শর্তসহ প্যাকেজ বসানো কেন topological sort-এর চেয়ে অনেক কঠিন?

যখন module A module B ব্যবহার করে আর B আবার A-কে, তখন দুটিকে আলাদা করে বোঝা, পরীক্ষা করা বা বদলানো যায় না — এক অর্থে তারা একটিই module, শুধু দুই ফাইলে ভাগ করা। এজন্য সফটওয়্যার নকশায় একটি পুরোনো নীতি আছে: module-গুলোর নির্ভরতার গ্রাফ একটি DAG হওয়া উচিত (Robert Martin একে “acyclic dependencies principle” নাম দেন)। বাস্তবে চক্র ঢোকে ধীরে ধীরে — একটি ছোট সুবিধার জন্য কেউ “নিচের” module থেকে “ওপরের” একটি ফাংশন import করেন, আর কয়েক মাস পর পুরো কোডবেস একটি জটে আটকে যায়।

ভাষাগুলো এর সাথে আলাদাভাবে আচরণ করে। Python import চলার সময় module-এর কোড ওপর থেকে নিচে চালায়; চক্রে দ্বিতীয় module প্রথমটিকে অর্ধেক-চালানো অবস্থায় পায়, আর তখনো সংজ্ঞায়িত হয়নি এমন নাম চাইলে ImportError. Go ভাষা এই ঝুঁকি নেয় না — import-এর চক্র সরাসরি compile error. C আর C++-এ header-এর চক্র include guard দিয়ে আটকানো হয়, কিন্তু দুই class একে অন্যকে পুরোপুরি ধারণ করতে চাইলে forward declaration আর pointer ছাড়া উপায় নেই। প্রতিকারও প্রায় একই: সাধারণ অংশটি তৃতীয় একটি module-এ সরিয়ে চক্রটি ভাঙা, যাতে গ্রাফটি আবার DAG হয়।

প্যাকেজ ম্যানেজারের সমস্যা আরও গভীর। শুধু “কে কার আগে” হলে topological sort যথেষ্ট। কিন্তু বাস্তবে প্রতিটি নির্ভরতার সাথে ভার্সনের শর্ত থাকে (“numpy ১.২০ বা তার পরের, কিন্তু ২.০-এর আগের”), আর কিছু প্যাকেজ একসাথে থাকতে পারে না (conflict). প্রশ্ন তখন দাঁড়ায়: প্রতিটি প্যাকেজের এমন একটি ভার্সন কি বাছা যায়, যাতে সব শর্ত একসাথে মানা হয়? ২০০৬ সালের দিকে Debian-ধাঁচের প্যাকেজ নিয়ে গবেষণায় (Mancinelli, Di Cosmo ও সহকর্মীরা) দেখানো হয় যে এই “installability” প্রশ্নটি NP-complete — একটি SAT সমস্যার সমতুল্য (পর্ব ৬৪)। তাই আধুনিক অনেক dependency resolver ভেতরে SAT solver বা backtracking-ভিত্তিক অনুসন্ধান ব্যবহার করে, আর কখনো কখনো “resolving dependencies…” লিখে অনেকক্ষণ আটকে থাকে।

মূল উপলব্ধি: ক্রম ঠিক করা সহজ (O(V + E)), কিন্তু কী বসাবেন তা বাছাই কঠিন হতে পারে। আর নির্ভরতার গ্রাফকে DAG রাখা শুধু অ্যালগরিদমের সুবিধা নয় — ভালো নকশারও একটি লক্ষণ।

প্র ০৪Spreadsheet-এর হিসাব আর deep learning-এর backpropagation — দুই ভিন্ন জগতে একই topological order কীভাবে কাজ করে?

একটি spreadsheet-এ প্রতিটি ঘর একটি vertex, আর C1 = A1 + B1 মানে A1 → C1 আর B1 → C1 দুটি edge. আপনি A1 বদলালে C1 আবার হিসাব করতে হবে, আর C1-এর ওপর নির্ভর করা প্রতিটি ঘরও — কিন্তু অবশ্যই সঠিক ক্রমে, নইলে কোনো ঘর পুরোনো মান দিয়ে হিসাব হবে। ১৯৭৯ সালের VisiCalc-এর মতো প্রথম দিকের spreadsheet সারি বা কলাম ধরে পুরো শিট হিসাব করত, ফলে কখনো কখনো একাধিকবার হিসাব চালাতে হতো; পরের spreadsheet-গুলো (Excel সহ) ঘরগুলোর নির্ভরতার একটি গ্রাফ রাখে আর বদলানো ঘর থেকে পৌঁছানো যায় এমন ঘরগুলো একটি topological ক্রমে নতুন করে হিসাব করে। চক্র পেলে সেই পরিচিত “circular reference” সতর্কবার্তা।

Deep learning-এ একই কাঠামোর নাম computation graph. একটি neural network-এর প্রতিটি গাণিতিক কাজ (গুণ, যোগ, activation) একটি vertex, আর একটির ফল অন্যটির ইনপুট হলে একটি edge. Forward pass মানে এই DAG-কে topological order-এ হিসাব করা — ঠিক spreadsheet-এর মতো। তারপর backpropagation: loss থেকে প্রতিটি parameter-এর gradient বের করতে chain rule প্রয়োগ হয় উল্টো topological order-এ, কারণ একটি vertex-এর gradient জানতে আগে তার সব ব্যবহারকারীর gradient লাগে। PyTorch-এর autograd ইঞ্জিন ঠিক এভাবে backward pass চালায়, আর reverse-mode automatic differentiation-এর পুরো তত্ত্বই এই উল্টো ক্রমের ওপর দাঁড়িয়ে।

দুই ক্ষেত্রেই আরেকটি বাস্তব বিষয় আছে: সমান্তরালতা। একই “স্তরের” ঘর বা কাজ একে অন্যের ওপর নির্ভর করে না, তাই একসাথে হিসাব করা যায় — GPU-তে বা কয়েকটি CPU core-এ। Compiler-এর instruction scheduling-ও একই ধারণা ব্যবহার করে: একটি basic block-এর instruction-গুলোর নির্ভরতার DAG থেকে এমন ক্রম বাছা, যাতে processor-এর কোনো অংশ অপেক্ষায় বসে না থাকে।

Spreadsheet-এর চক্র নিয়ে একটি মজার ব্যতিক্রমও আছে: Excel-এ “iterative calculation” চালু করলে চক্রযুক্ত সূত্রও বারবার হিসাব হয়, যতক্ষণ না মান স্থির হয় — এটি আর topological sort নয়, বরং একটি সংখ্যাগত fixed-point পদ্ধতি।

মূল উপলব্ধি: “আগে কারণ, পরে ফল” — যেখানেই হিসাব এক ধাপ থেকে আরেক ধাপে বয়ে যায়, সেখানে একটি DAG আর তার topological order আছে; সামনে যেতে এক ক্রম, পেছনে ফিরতে তার উল্টো।

প্র ০৫সেমিস্টারে কোর্সের সংখ্যায় সীমা না থাকলে উত্তর এক লাইনে, কিন্তু “প্রতি সেমিস্টারে সর্বোচ্চ k টি” যোগ করলেই সমস্যা NP-hard — কেন এমন হয়, আর এর সীমানা কোথায়?

সীমাহীন ক্ষেত্রে উত্তর দীর্ঘতম শিকলের দৈর্ঘ্য, কারণ নিচের সীমা (শিকল) আর ওপরের সীমা (স্তর অনুযায়ী সূচি) মিলে যায়। k-এর সীমা আসলে এই মিলটিকে ভেঙে দেয়: একটি স্তরে k-এর বেশি কোর্স থাকলে কিছু পিছিয়ে দিতে হবে, আর কোনগুলো পিছাবেন তার ওপর নির্ভর করে পরের স্তরগুলো কতটা ভরবে। এক জায়গার ছোট সিদ্ধান্ত অনেক দূরে প্রভাব ফেলে — ঠিক এমন কাঠামোই সাধারণত NP-hard সমস্যার চিহ্ন।

Scheduling তত্ত্বে এর নাম “precedence-constrained scheduling”: একক সময়ের কাজ, নির্ভরতার একটি DAG, আর m টি একই রকম মেশিন। ১৯৭৫ সালে Jeffrey Ullman প্রমাণ করেন, m যখন ইনপুটের অংশ, তখন সমস্যাটি NP-complete. অন্যদিকে m = 2 হলে polynomial সমাধান আছে — ১৯৬৯ সালে Fujii, Kasami ও Ninomiya matching দিয়ে, আর ১৯৭২ সালে Coffman ও Graham একটি চতুর লেবেলিং-ভিত্তিক greedy দিয়ে। মাঝখানের m = 3 আজও খোলা প্রশ্ন: Garey ও Johnson-এর ১৯৭৯ সালের বিখ্যাত বইয়ের অমীমাংসিত সমস্যার তালিকায় এটি ছিল, আর অর্ধ শতাব্দী পরেও কেউ polynomial অ্যালগরিদম বা NP-hardness প্রমাণ দিতে পারেননি। একটি সংখ্যা ২ থেকে ৩ হলেই জ্ঞানের সীমানায় পৌঁছে যাওয়া — অ্যালগরিদম তত্ত্বের এক বিস্ময়কর দিক।

বাস্তবে তাহলে কী করা হয়? ছোট ইনপুটে (প্রায় ১৫টি কোর্স) bitmask DP: অবস্থা = এ পর্যন্ত শেষ হওয়া কোর্সের সেট, আর প্রতি সেমিস্টারে প্রস্তুত কোর্সগুলো থেকে সর্বোচ্চ k টির সব উপসেট চেষ্টা। বড় ইনপুটে heuristic — যেমন critical path-এ থাকা, বা সবচেয়ে বেশি কোর্সের পূর্বশর্ত এমন কোর্সকে অগ্রাধিকার (list scheduling). Graham ১৯৬৬ সালে দেখান যে এ ধরনের যেকোনো “অলস নয়” এমন greedy সূচি সর্বোত্তমের সর্বোচ্চ (2 − 1/m) গুণ লম্বা হয় — approximation অ্যালগরিদমের (পর্ব ৬৬) প্রথম দিকের একটি ফল।

মূল উপলব্ধি: একই DAG-এ “ক্রম” আর “স্তর” সহজ, কিন্তু সম্পদের সীমা যোগ হলেই সমস্যার চরিত্র বদলে যায়। পরীক্ষা বা ইন্টারভিউতে প্রশ্নে “প্রতি সেমিস্টারে সর্বোচ্চ k” আছে কি না — এক লাইনের এই পার্থক্যটি আগে খুঁজুন।

অনুশীলন

খাতা-কলম নিন। প্রথম তিনটি হাতে করার, শেষটিতে কোড লিখতে হবে। প্রথম দুটিতে গ্রাফ একই: vertex 0–5, edges (এই ক্রমে adjacency list-এ) 5 → 2, 5 → 0, 4 → 0, 4 → 1, 2 → 3, 3 → 1.

  1. হাতে trace করুন — Kahn: FIFO queue দিয়ে Kahn চালান (শুরুর queue-তে ছোট নম্বর আগে)। প্রতিটি ধাপের পর queue আর আউটপুট লিখুন, আর প্রতিটি vertex-এর স্তর বের করে বলুন সব কাজ ন্যূনতম কয় ধাপে শেষ হয়।

    in-degree: 0 → ২, 1 → ২, 2 → ১, 3 → ১, 4 → ০, 5 → ০. Queue [4, 5]. 4 বের: 0 → ১, 1 → ১; queue [5]. 5 বের: 2 → ০ (ঢুকল), 0 → ০ (ঢুকল); queue [2, 0]. 2 বের: 3 → ০; queue [0, 3]. 0 বের: কোনো তীর নেই; queue [3]. 3 বের: 1 → ০; queue [1]. 1 বের। ক্রম: 4 5 2 0 3 1, ছয়টিই আউটপুট — DAG.

    স্তর: 4 = ১, 5 = ১, 0 = max(4, 5) + ১ = ২, 2 = ২, 3 = ৩, 1 = max(4-এর ১, 3-এর ৩) + ১ = ৪. ন্যূনতম ৪ ধাপ — দীর্ঘতম শিকল 5 → 2 → 3 → 1.

  2. হাতে trace করুন — DFS: একই গ্রাফে vertex 0, 1, …, 5 ক্রমে সাদা vertex থেকে DFS চালান। শেষ হওয়ার তালিকা আর উল্টো ক্রমটি লিখুন, আর দেখান যে প্রতিটি edge-এ f[u] > f[v].

    visit(0): প্রতিবেশী নেই — 0 শেষ। visit(1): 1 শেষ। visit(2) → 3 → 1 (কালো, এড়িয়ে) — 3 শেষ, 2 শেষ। 3 আগেই কালো। visit(4): 0, 1 কালো — 4 শেষ। visit(5): 2, 0 কালো — 5 শেষ। শেষ হওয়ার তালিকা 0 1 3 2 4 5; উল্টো: 5 4 2 3 1 0.

    সময় ধরে (d/f): 0 = 1/2, 1 = 3/4, 2 = 5/8, 3 = 6/7, 4 = 9/10, 5 = 11/12. Edge 5 → 2: 12 > 8; 5 → 0: 12 > 2; 4 → 0: 10 > 2; 4 → 1: 10 > 4; 2 → 3: 8 > 7; 3 → 1: 7 > 4 — সব ঠিক। Kahn-এর 4 5 2 0 3 1 থেকে আলাদা, তবু বৈধ; খেয়াল করুন, এখানে প্রথম DFS শুরু হয়েছে একটি sink (0) থেকে, তবু ক্রম ঠিক এসেছে।

  3. ভেবে বলুন: (ক) edges a → b, b → c, a → c-এর topological order কি অনন্য? এবার a → d যোগ করলে? (খ) একটি DAG-এ কিছু edge-এর ওজন ঋণাত্মক। s থেকে সংক্ষিপ্ততম পথে topological order-এর DP কেন তবুও ঠিক, অথচ Dijkstra ভুল করতে পারে?

    (ক) হ্যাঁ, অনন্য: a b c, কারণ a → b → c একটি Hamiltonian path — প্রতিটি পরপর জোড়ায় edge আছে। Kahn-এ queue-তে প্রতিবার একটিই vertex থাকে। a → d যোগ করলে অনন্য নয়: a বের হওয়ার পর b আর d দুজনই প্রস্তুত, তাই a b c d, a b d c, a d b c — তিনটিই বৈধ। d-কে কোনো শিকলে বাঁধার edge নেই।

    (খ) DAG-এর DP-র প্রমাণ কোথাও ওজনের চিহ্ন ব্যবহার করে না: v-এর পালা আসার আগে তার প্রতিটি পূর্বসূরির মান চূড়ান্ত — শুধু ক্রমের কারণে। Dijkstra-র প্রমাণ নির্ভর করে “একবার বের করা vertex-এর দূরত্ব আর কমবে না”-র ওপর, যা ঋণাত্মক edge থাকলে ভাঙে: পরে বের হওয়া vertex থেকে ঋণাত্মক edge দিয়ে আগের কারও দূরত্ব কমতে পারে (পর্ব ৪৩)। আর ঋণাত্মক cycle-এর ভয় DAG-এ নেই, কারণ কোনো cycle-ই নেই।

  4. কোড লিখুন: একটি directed গ্রাফে DFS-ভিত্তিক topological sort লিখুন, যা cycle পেলে শুধু “cycle” না বলে চক্রটি ছাপে (যেমন 0 -> 1 -> 2 -> 0). edges 0 → 1, 1 → 2, 2 → 0, 2 → 3 দিয়ে হাতে মেলান।

    ধূসর vertex-এ edge পেলেই back edge. কোন পথে এসেছি তা জানতে প্রতিটি vertex-এর DFS-parent রাখুন, আর back edge u → v পেলে u থেকে parent ধরে v পর্যন্ত উঠুন।

    vector<vector<int>> adj;
    vector<int> color, par, order;                  // 0 white, 1 gray, 2 black
    bool dfs(int u) {                               // true = a cycle was found and printed
        color[u] = 1;
        for (int v : adj[u]) {
            if (color[v] == 1) {                    // back edge u -> v closes v ~> u -> v
                vector<int> cyc;
                for (int x = u; x != v; x = par[x]) cyc.push_back(x);
                cout << v;
                for (int i = cyc.size() - 1; i >= 0; --i) cout << " -> " << cyc[i];
                cout << " -> " << v << '\n';
                return true;
            }
            if (color[v] == 0) { par[v] = u; if (dfs(v)) return true; }
        }
        color[u] = 2; order.push_back(u);           // u finishes
        return false;
    }
    // main: for every white v, if (dfs(v)) stop;
    // if no cycle: reverse(order.begin(), order.end()) is a topological order

    হাতে: dfs(0) → 1 → 2; 2-এর প্রথম প্রতিবেশী 0 ধূসর — back edge 2 → 0. u = 2 থেকে parent ধরে: cyc = [2, 1] (0-তে পৌঁছে থামে)। ছাপা: 0, তারপর cyc উল্টো (1, 2), শেষে আবার 0 — 0 -> 1 -> 2 -> 0. লক্ষ করুন, 3 কখনো ঘোরা হয়নি — চক্র পেয়েই থেমেছি। সময় O(V + E), জায়গা O(V); বড় V-তে recursion-এর গভীরতা নিয়ে সতর্ক থাকুন।

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

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

ক্রমKahn-এর অ্যালগরিদমের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. সব edge পড়ে প্রতিটি vertex-এর in-degree গোনা
  2. in-degree শূন্য এমন সব vertex queue-তে রাখা
  3. queue-র সামনে থেকে একটি vertex u বের করে আউটপুটে লেখা
  4. u থেকে যাওয়া প্রতিটি edge u → v-তে v-এর in-degree এক কমানো, শূন্য হলে v-কে queue-তে রাখা
  5. queue খালি হলে দেখা: আউটপুটে V-এর কম vertex থাকলে গ্রাফে cycle আছে

একটি directed গ্রাফে চারটি vertex A, B, C, D আর edge A → B, A → C, B → D, C → D. মোট কয়টি বৈধ topological order আছে?

  • ১
  • ২
  • ৩
  • ৪

৮টি vertex-এর একটি directed গ্রাফে Kahn-এর অ্যালগরিদম শেষ হলো, আর আউটপুটে মাত্র ৫টি vertex. নিচের কোনটি নিশ্চিতভাবে সত্য?

  • বাকি ৩টি vertex মিলে ঠিক একটি cycle
  • গ্রাফটি connected নয়
  • গ্রাফে অন্তত একটি directed cycle আছে
  • অ্যালগরিদমে queue-র বদলে stack ব্যবহার করা উচিত ছিল

একটি DAG-এ DFS চালিয়ে প্রতিটি vertex-এর finishing time f[ ] রাখা হলো। যেকোনো edge u → v সম্পর্কে কোনটি সবসময় সত্য?

  • f[u] > f[v]
  • f[u] < f[v]
  • d[u] < d[v], অর্থাৎ u আগে আবিষ্কৃত
  • f[u] = f[v] হতে পারে

V টি vertex আর E টি ওজনযুক্ত edge-এর একটি DAG-এ (কিছু ওজন ঋণাত্মক) একটি উৎস থেকে সব vertex-এর দীর্ঘতম পথ বের করার সবচেয়ে ভালো সময় কত?

  • O(V · E)
  • Θ(V + E)
  • O((V + E) log V)
  • NP-hard, তাই exponential

১২টি কোর্সের পূর্বশর্তের গ্রাফ একটি DAG, আর তার সবচেয়ে লম্বা নির্ভরতার শিকলে ৫টি কোর্স। প্রতি সেমিস্টারে যত খুশি কোর্স নেওয়া গেলে সব শেষ করতে ন্যূনতম কয়টি সেমিস্টার লাগবে?

  • ১২
  • source-এর সংখ্যার সমান
  • ৪
  • ৫

vertex 0–4 আর edge 3 → 0, 3 → 1, 4 → 1. Lexicographically সবচেয়ে ছোট topological order কোনটি?

  • 0 1 2 3 4
  • 3 0 2 4 1
  • 2 3 0 4 1
  • 2 3 4 0 1

শব্দকোষ

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

মনে রাখুন

  1. Topological order = প্রতিটি edge u → v-তে u আগে; এমন ক্রম আছে ⇔ গ্রাফটি DAG (প্রতিটি DAG-এ একটি source থাকে)।
  2. Kahn: in-degree ০-দের queue, বের করা আর in-degree কমানো; আউটপুটে V-এর কম vertex ⇒ cycle. Θ(V + E).
  3. DFS: finishing time-এর উল্টো ক্রম; ধূসর vertex-এ edge = back edge = cycle. তিন রং লাগে, দুই নয়।
  4. ক্রম অনন্য ⇔ Hamiltonian path (queue-তে কখনো দুজন নয়); lexicographically ছোট ক্রম = min-heap, O(V log V + E).
  5. Topological order ধরে DP: দীর্ঘতম/সংক্ষিপ্ততম পথ, critical path, পথ গোনা, ন্যূনতম স্তর — সব Θ(V + E); সাধারণ গ্রাফে দীর্ঘতম পথ NP-hard.

পরের পর্বে: ওজনযুক্ত গ্রাফে, cycle থাকলেও, এক জায়গা থেকে সব জায়গায় সবচেয়ে ছোট পথ — ঢাকার রাস্তার মানচিত্রে priority queue দিয়ে। Dijkstra-র অ্যালগরিদম, প্রমাণসহ।

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

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