Foundationপ্রথম নীতি থেকে
LEVEL 6লেসন ১৬/২৪অ্যাডভান্সড৫৫ মিনিট

Topological Sort ও SCC — dependency-র গণিত

Topological Sort and Strongly Connected Components

Dependency-র গোলমাল দুনিয়াকে সোজা লাইনে সাজানোর দুই রাস্তা — Kahn-এর in-degree queue আর DFS finish-time reversal; আর যেখানে গোলকধাঁধা mutually reachable block-এ ভাগ হয়, সেখানে SCC — Kosaraju ও Tarjan-এর হাতে।

এই লেসন শেষে আপনি পারবেন

  • DAG-এর সংজ্ঞা দিয়ে ব্যাখ্যা করতে পারবেন topological order ঠিক তখনই থাকে যখন graph acyclic — উভয় দিকের যুক্তি সহ
  • Kahn's algorithm (in-degree queue) চালিয়ে dependency graph-এর বৈধ ordering বের করতে পারবেন এবং leftover vertex দেখে cycle-এর অস্তিত্ব শনাক্ত করতে পারবেন
  • DFS finish time-এর interval-nesting property থেকে Kosaraju-র দুই-pass SCC algorithm আর Tarjan-এর low-link intuition ব্যাখ্যা করতে পারবেন
  • Condensation graph কেন সবসময় DAG — এবং meta-topology ধারণাটা package manager বা compiler pass ordering-এ কীভাবে কাজে লাগে — বলতে পারবেন
  • এই সাইটের curriculum module dependency graph-এর ওপর Kahn's algorithm চালিয়ে নিজেই study order বের করে যাচাই করতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

আপনি যখন npm install চালান, npm-এর সামনে একটা প্রশ্ন আসে: শত শত package, প্রতিটার নিজের dependency — কোন ক্রমে install/configure করব? react-router-কে আগে বসাতে হবে না কি react-কে? একই প্রশ্ন Makefile-এ (make কোন target আগে বানাবে), spreadsheet-এ (কোন cell আগে recalculate হবে), university course planner-এ (কোন course আগে), আর এই সাইটেই — কোন লেসন আগে পড়বেন?

এই সবগুলোর গণিত এক: topological sort। Directed graph-এর vertex- দের এমন লাইনে দাঁড় করানো যেন প্রতিটা arrow বাম থেকে ডানে যায় — কোনো dependency কখনো পিছনের দিকে টানে না।

কিন্তু প্রথমে একটা খারাপ খবর: সব directed graph-এ এই লাইন অসম্ভব। A যদি B-এর ওপর depend করে আর B যদি A-এর ওপর — deadlock, circular dependency, চিরকাল অপেক্ষা। তাই আসল কাজ দুই ধাপে: (১) বলো cycle আছে কি না — থাকলে নাম ধরে রিপোর্ট করো; (২) না থাকলে ordering দাও।

আর একটা গভীর প্রশ্ন আছে: বড় directed graph-কে কীভাবে তার “mutually-dependent পাড়া”-তে ভাগ করা যায়? এই পাড়াগুলোই Strongly Connected Component (SCC) — আর সেগুলো বের করার দুইটা সুন্দরতম algorithm ইতিহাসে রেখে গেছে: Kosaraju-র দুই-pass আর Tarjan-এর এক-pass low-link। লেসনের শেষে আমরা এই পুরো curriculum-এর module dependency-র ওপর নিজের হাতে topo-sort চালাব।

মূল ধারণা

DAG — ordering-এর শর্ত

Directed Acyclic Graph (DAG): প্রতিটা edge-এর দিক আছে, আর কোনো directed cycle নেই। Dependency system-এর natural language — কারণ dependency যদি ফেরত ঘুরে আসে, সিস্টেম শুরুই করতে পারে না।

Theorem: Topological order থাকে ⇔ graph-এ directed cycle নেই।

⇐ (acyclic ⇒ order আছে): প্রমাণ induction-এ — acyclic graph-এ অবশ্যই একটা source (in-degree ০) vertex আছে; ওটাকে লিস্টের সামনে বসাও, বাদ দাও, বাকিটা এখনো DAG — repeat।

⇒ (cycle ⇒ order নেই): cycle v₁→v₂→…→v₁ থাকলে ordering-এ v₁ < v₂ < … < v₁ — নিজের চেয়ে ছোট, অসম্ভব।

দ্বিতীয় দিকটাই practical gold: ordering বানাতে গিয়ে cycle পাওয়া = bug report, আর ভালো implementation bug-এর ঠিকানাও দেয়।

Kahn’s algorithm — in-degree queue (1962)

চিন্তা সহজ, প্রায় নির্মমভাবে সহজ:

১. প্রতিটা vertex-এর in-degree (আসা arrow-র সংখ্যা) গুনে ফেলো। ২. In-degree ০ — যার কোনো বাকি dependency নেই — দের queue-তে ঢোকাও। ৩. Queue থেকে বের করে output লিস্টে বসাও; এর প্রতিটা সন্তানের in-degree ১ কমাও; কারো in-degree ০ হলে সে queue-তে। ৪. Queue খালি হলে শেষ। Output-এ সব vertex এলো? না এলে যারা বাদ পড়ল, তারা সব cycle-এ আটকে আছে।

from collections import deque

def kahn_topo_sort(adj, n):
    indeg = [0] * n
    for u in range(n):
        for v in adj[u]:
            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]:
            indeg[v] -= 1
            if indeg[v] == 0:
                q.append(v)
    if len(order) != n:
        leftover = [v for v in range(n) if v not in set(order)]
        return None, leftover          # cycle-এর সন্দেহভাজন সदস्यরা
    return order, None

Complexity O(V+E) — প্রতিটা vertex একবার dequeue, প্রতিটা edge একবার relax-style decrement। আর একটা সৌন্দর্য: queue-র discipline FIFO না LIFO — দুটোই বৈধ ordering দেয়, শুধু ভিন্ন flavor (FIFO প্রায় level-order, LIFO প্রায় DFS-like)। Tie ভাঙার নিয়ম বদলালে output বদলায় — topological order সাধারণত unique না।

DFS finish-time reversal — দ্বিতীয় রাস্তা

graphs-bfs-dfs লেসনের parenthesis property মনে আছে? DFS-এ প্রতিটা vertex-এর discovery (d) আর finish (f) সময় নিখুঁতভাবে nest করে। DAG-এ একটা theorem: edge u→v থাকলে সবসময় f(u) > f(v) — u তখনো খোলা থাকে যতক্ষণ v বন্ধ হয়। তাহলে finish time-এর উল্টো ক্রমে vertex-রা দাঁড় করালেই topological order!

def dfs_topo_sort(adj, n):
    visited = [False] * n
    finish = []
    def dfs(s):
        stack = [(s, iter(adj[s]))]
        visited[s] = True
        while stack:
            u, it = stack[-1]
            advanced = False
            for v in it:
                if not visited[v]:
                    visited[v] = True
                    stack.append((v, iter(adj[v])))
                    advanced = True
                    break
            if not advanced:
                finish.append(u)       # ← এই মুহূর্তে u finished
                stack.pop()
    for s in range(n):
        if not visited[s]:
            dfs(s)
    return finish[::-1]                # উল্টে দাও — ব্যস, topo order

SCC — mutually reachable পাড়া

Strongly connected component = vertex-দের সর্বোচ্চ সেট যেখানে যেকোনো u থেকে যেকোনো v-তে উভয় দিকে পথ আছে। প্রতিটা vertex ঠিক একটা SCC-তে — SCC-গুলো partition।

দুইটা observation সব দরজা খুলে দেয়:

১. Condensation: প্রতিটা SCC-কে একটা super-node ভাবলে যে graph পাওয়া যায়, সেটা অগত্যা DAG — দুই SCC যদি একে অপরের দিকে path রাখত, তারা একটাই SCC হতো। এই meta-graph-এ topological sort চালানো যায়! ২. Kosaraju’s insight: G-তে DFS চালিয়ে finish-time descending ক্রমে vertex নিয়ে reversed graph Gᵀ-তে DFS চালালে প্রতিটা run- এর reach হুবহু একটা SCC। Intuition: G-তে সবচেয়ে দেরিতে finish করা vertex তার SCC-র “source-পাড়া”-র বাসিন্দা; Gᵀ-তে সেই পাড়া থেকে বাইরে যাওয়ার রাস্তা বন্ধ হয়ে যায়, তাই DFS ঠিক পাড়াটাই ঝাড় করে ফেরে।

def kosaraju_scc(adj, n):
    # pass 1: G-তে DFS, finish order জমাও
    visited = [False] * n
    finish = []
    for s in range(n):
        if visited[s]:
            continue
        visited[s] = True
        stack = [(s, iter(adj[s]))]
        while stack:
            u, it = stack[-1]
            advanced = False
            for v in it:
                if not visited[v]:
                    visited[v] = True
                    stack.append((v, iter(adj[v])))
                    advanced = True
                    break
            if not advanced:
                finish.append(u)
                stack.pop()
    # reverse graph
    radj = [[] for _ in range(n)]
    for u in range(n):
        for v in adj[u]:
            radj[v].append(u)
    # pass 2: finish-descending ক্রমে Gᵀ-তে DFS
    seen = [False] * n
    comps = []
    for u in reversed(finish):
        if seen[u]:
            continue
        comp, st = [], [u]
        seen[u] = True
        while st:
            x = st.pop()
            comp.append(x)
            for y in radj[x]:
                if not seen[y]:
                    seen[y] = True
                    st.append(y)
        comps.append(comp)
    return comps

Tarjan (1972) একই কাজ করেন একটাই DFS-এ। প্রতিটা vertex-এর সাথে দুইটা সংখ্যা রাখা হয়:

  • disc[u] — discovery time (কত তারিখে আবিষ্কৃত)
  • low[u] — u বা u-র subtree থেকে পৌঁছানো যায় এমন সবচেয়ে পুরনো (ছোট disc) vertex-এর disc, যে এখনো DFS-এর stack-এ আছে

Update rule: low[u] = min(disc[u], min over children c of low[c], min over back/cross edges u→x of disc[x])। আর u-র DFS call শেষে (low ফেরত আসার পরে) যদি low[u] == disc[u] দাঁড়ায় — মানে u বা তার বংশধররা stack-এ থাকা কারো কাছে ফেরত টেনে আনতে পারেনি — তাহলে stack-এর উপর থেকে u পর্যন্ত সবাই মিলে একটা SCC: pop করে রিপোর্ট করো।

Stack-এর ভূমিকাটা honest বোঝা জরুরি: stack-এ আছে মানে “আবিষ্কৃত কিন্তু SCC-তে বরাদ্দ হয়নি”। Back/cross edge শুধু তাকেই টানতে পারে যে এখনো বরাদ্দ হয়নি — কারণ বরাদ্দ হওয়া SCC-র সদস্য আলাদা পাড়া, ওদিকে টানা মানে ভুল merge। এই সূক্ষ্মতাই Tarjan-কে এক-pass করে তোলে।

ভেতরে কী ঘটছে

কেন finish time-এর উল্টোটাই কাজ করে — interval প্রমাণের মূলকথা

DAG-এ u→v edge: v-এর DFS হয় হয় u-র ভেতরে শুরু (white পেলে) — তখন nesting বলে f(u) > f(v); নয়তো v already black — তখনও f(u) > f(v) (kারণ v আগে বন্ধ হয়ে গেছে; gray v দেখা মানেই back edge = cycle, DAG-এ অসম্ভব)। সুতরাং প্রতিটা edge “দেরিতে finisher → আগে finisher” — উল্টো ক্রম সরাসরি topo order। SCC-তে এই একই property reversed graph-এ প্রয়োগ করে পাড়া আলাদা করা হয় — Kosaraju-র পুরো প্রমাণ CLRS 22.5-এ এই দুই লাইনের প্রসারণ।

Kahn-এর leftover — cycle রিপোর্টের raw material

Cycle-এ থাকা প্রতিটা vertex-এর in-degree কখনো ০ হয় না — চক্রের সদস্যরা একে অপরকে টেনে ধরে রাখে। তাই Kahn শেষে যারা unprocessed, তারা হয় cycle-এ আছে, নয়তো cycle-এর downstream। এখান থেকে নির্দিষ্ট cycle-path বের করা সহজ: unprocessed subgraph-এ যেকোনো vertex থেকে successor walk করতে থাকো (সবার out-degree ≥ 1 এই subgraph-এ!) — walk অবশ্যই কোনো এক সময়ে একই vertex দ্বিতীয়বার ছোঁয়, আর প্রথম-থেকে- দ্বিতীয় visitation-এর মাঝের segment-টাই একটা পূর্ণ cycle। BuildIt-এ ঠিক এই wrapper বানাব — npm-এর বিখ্যাত “Circular dependency detected: a → b → c → a” message-এর ইঞ্জিন এটাই।

Condensation-এর ব্যবহারিক জাদু

Condensation DAG পাওয়ার পরে বড় directed সমস্যা প্রায়ই সহজ হয়ে যায়: reachability query SCC-id তুলনায় নেমে আসে, longest path (DAG-এ linear time!), আর “কোন পাড়া আগে process হবে” — meta-topo order। Compiler-এর call-graph analysis, garbage collector-এর object graph grouping, social network-এ community detection-এর coarse step — সবখানে condensation-first strategy।

Recursion depth আবারও — আর Python-এর sys.setrecursionlimit ধোঁকা

লেসন ১৩-এর শিক্ষা এখানেও সমানভাবে খাটে: 10⁵-vertex chain-এ recursive DFS মারা যায়। sys.setrecursionlimit(10**6) সেট করা “সমাধান” না — Python frame প্রতিটাই C stack জ্বালায়, limit তুললে Segmentation fault আসতে পারে interpreter-এর নিজের সীমায়। Explicit stack version কোনো অপরাধী না — এই লেসনের সব DFS iterative।

উদাহরণ

একটা digraph, দুই ধরনের উত্তর

Digraph (7 vertex):

    a ──▶ b ──▶ c ──▶ d          g
     ▲         │               ▲ │
     │         ▼               │ │
     └────── e ◀────────────── f

Edges: a→b, b→c, c→d, c→e, e→a, g→f, f→g।

Step ১ — SCC চেনা চোখে: {a, b, c, e} একে অপরে সবাই পৌঁছায় (a→b→c→e→a loop) — একটা SCC। {d} একা (বের হয় ঢুকতে পারে না)। {f, g} mutual loop — আরেকটা SCC। মোট তিনটা।

Step ২ — Condensation DAG:

   {a,b,c,e} ──▶ {d}
        ▲
        │
      {f,g}

Meta-edges: {a,b,c,e}→{d} (c→d থেকে), {f,g}→{a,b,c,e}? f→e? নেই… আসলে e→f ছিল না; আমাদের edges অনুযায়ী f→g, g→f ছাড়া বাইরে কোনো edge নেই — তাহলে condensation: {a,b,c,e}→{d} এবং {f,g} আলাদা island।

Step ৩ — Topo order (condensation-এ): sources হলো {f,g} আর {a,b,c,e} — দুইটা সমান্তরাল দ্বীপ, যেকোনো ক্রম বৈধ; {d} সবার পরে।

Kahn চলছে (vertex ক্রম a..g)

Initial in-degrees: a:1(e), b:1(a), c:1(b), d:1(c), e:2(c,f? না — e-তে ঢোকা edges: c→e একটাই) — সাবধানে: edges a→b, b→c, c→d, c→e, e→a, g→f, f→g।

vertexin-degree
a1 (e→a)
b1
c1
d1
e1 (c→e)
f1 (g→f)
g1 (f→g)

In-degree ০ কেউ নেই! Queue খালি দিয়েই শুরু শেষ — order খালি, leftover = সবাই। Graph-এ দুইটা cycle (a,b,c,e আর f,g) আছে বলে ordering অসম্ভব — Kahn এক নিঃশ্বাসে cycle detect করে ফেলল। এটাই build system-এর প্রথম লাইন অফ ডিফেন্স।

এবার d-কে মুছে দিয়ে (d ছিল নিরীহ শাখা) আরও এক ধাপ: cycle থাকায তবু কেউ ০ হয় না। এবার edge e→a মুছে দিন — a,b,c,e এখন chain; in-degree: a:0 ✓। Queue=[a]; pop a → b-র indeg 0 → pop b → c → e। Order [a,b,c,e], তারপর g? g-র indeg 1 (f→g), f-র 1 (g→f) — আটকে গেল! Leftover {f,g} — দ্বিতীয় cycle ধরা পড়ল নাম-ধরে রিপোর্টের জন্য ready।

Kosaraju বনাম Tarjan output মিলান

Experiment section-এর script এই graph-এ দুই algorithm চালিয়ে দেখাবে — component-এর সেট হুবহু এক (member-দের ভেতরের ক্রম আলাদা হতে পারে, কারণ DFS order আলাদা), আর condensation-এ topo order-ও মিলবে।

ছবিতে

SCC আর condensation — পাড়া আর শহরের মানচিত্র

মূল digraph:                     condensation:

   ┌────────────┐                 ┌──────────┐
   │  a → b     │                 │          │
   │  ↑   ↓     │  SCC-১          │ SCC-A    │──▶ SCC-D
   │  e ← c → d │                 │{a,b,c,e} │    {d}
   └────────────┘                 └──────────┘
                                      ▲
   ┌────────────┐                     │
   │  g ⇄ f     │  SCC-২              │
   └────────────┘                 ┌──────────┐
                                  │ SCC-FG   │
                                  │ {f,g}    │
                                  └──────────┘
বাম: মূল digraph, তিনটা SCC রঙিন blob। ডান: condensation — প্রতিটা blob একটা super-node, আর সব arrow এক দিকে — DAG!
DFS: a(1) → b(2) → c(3) → e(4) --back edge--> a

disc:  a=1  b=2  c=3  e=4
low:   a=1  b=1  c=1  e=1     ← সবাই a-কে টেনে আনে
stack (Tarjan-এর): [a, b, c, e]

call-stack unwind: e done? low[e](1) != disc[e](4) → pop না, অপেক্ষা
                   c done? low[c](1) != disc[c](3) → অপেক্ষা
                   b done? low[b](1) != disc[b](2) → অপেক্ষা
                   a done? low[a](1) == disc[a](1) → POP [a,b,c,e] = SCC!
disc[] বাড়ে DFS-এর গভীরে; back edge (e→a) উপরে টেনে low[] কমিয়ে দেয়। a-র call শেষ হওয়ার সময় low[a]==disc[a] — স্ট্যাক থেকে a পর্যন্ত সবাই এক SCC।
dependency সমস্যার চিকিৎসা সিঁড়ি
  1. Cycle আছে?Kahn leftover / DFS gray-hit — নাম-ধরে রিপোর্ট
  2. Topological orderKahn queue বা finish-time reverse — O(V+E)
  3. SCC partitionKosaraju (২ pass) বা Tarjan (১ pass) — পাড়া চেনা
  4. Condensation DAGmeta-topology — reachability, meta-order, longest path

নিজে চালিয়ে দেখুন

EXPERIMENT

Circular dependency ধরা, SCC দুইভাবে মেলানো, আর এই সাইটের module order নিজে বের করা

macOS / Linux (Python 3.10+)· ۱۵ মিনিট
from collections import deque


# ---------- অংশ ১: intentionally circular spec ----------
bad_deps = {
    'auth':    ['session'],
    'session': ['db'],
    'db':      ['cache'],
    'cache':   ['auth'],           # ← এখানেই লুপ: auth → session → db → cache → auth
}

names = sorted(bad_deps)
idx = {nm: i for i, nm in enumerate(names)}
n = len(names)
adj = [[] for _ in range(n)]
for pkg, deps in bad_deps.items():
    for d in deps:
        adj[idx[pkg]].append(idx[d])

def kahn_with_cycle_report(adj, n):
    indeg = [0] * n
    for u in range(n):
        for v in adj[u]:
            indeg[v] += 1
    q = deque(i for i in range(n) if indeg[i] == 0)
    order = []
    while q:
        u = q.popleft()
        order.append(u)
        for v in adj[u]:
            indeg[v] -= 1
            if indeg[v] == 0:
                q.append(v)
    if len(order) == n:
        return [names[i] for i in order], None
    alive = set(range(n)) - set(order)
    # unprocessed subgraph-এ সবার out-degree ≥ 1 — walk করলেই লুপ ধরা পড়ে
    start = next(iter(alive))
    pos, step = {}, 0
    u = start
    while u not in pos:
        pos[u] = step
        step += 1
        u = next(v for v in adj[u] if v in alive)
    cyc = [names[x] for x, s in pos.items() if s >= pos[u]]
    return None, cyc

order, cyc = kahn_with_cycle_report(adj, n)
if order is None:
    print('CIRCULAR DEPENDENCY DETECTED:')
    print('  ' + ' → '.join(cyc + [cyc[0]]))


# ---------- অংশ ২: SCC — এক digraph-এ দুই algorithm ----------
# a b c d e f g  →  0 1 2 3 4 5 6
edges = [('a','b'),('b','c'),('c','d'),('c','e'),
         ('e','a'),('g','f'),('f','g')]
lab = 'abcdefg'
N = 7
A = [[] for _ in range(N)]
for u, v in edges:
    A[lab.index(u)].append(lab.index(v))

def kosaraju(A, N):
    vis = [False]*N; fin = []
    for s in range(N):
        if vis[s]: continue
        vis[s] = True
        st = [(s, iter(A[s]))]
        while st:
            u, it = st[-1]
            adv = False
            for v in it:
                if not vis[v]:
                    vis[v] = True
                    st.append((v, iter(A[v])))
                    adv = True; break
            if not adv:
                fin.append(u); st.pop()
    R = [[] for _ in range(N)]
    for u in range(N):
        for v in A[u]:
            R[v].append(u)
    seen = [False]*N; comps = []
    for u in reversed(fin):
        if seen[u]: continue
        c, st = [], [u]
        seen[u] = True
        while st:
            x = st.pop(); c.append(x)
            for y in R[x]:
                if not seen[y]:
                    seen[y] = True; st.append(y)
        comps.append(sorted(lab[i] for i in c))
    return comps

def tarjan(A, N):
    idx = [-1]*N; low = [0]*N; onstk = [False]*N
    stk, comps, clock = [], [], 0
    for root in range(N):
        if idx[root] != -1: continue
        work = [(root, iter(A[root]))]
        idx[root] = low[root] = clock; clock += 1
        stk.append(root); onstk[root] = True
        while work:
            u, it = work[-1]
            adv = False
            for v in it:
                if idx[v] == -1:
                    idx[v] = low[v] = clock; clock += 1
                    stk.append(v); onstk[v] = True
                    work.append((v, iter(A[v])))
                    adv = True; break
                elif onstk[v]:
                    low[u] = min(low[u], idx[v])
            if not adv:
                work.pop()
                if work:
                    pu = work[-1][0]
                    low[pu] = min(low[pu], low[u])
                if low[u] == idx[u]:
                    c = []
                    while True:
                        x = stk.pop(); onstk[x] = False
                        c.append(x)
                        if x == u: break
                    comps.append(sorted(lab[i] for i in c))
    return comps

k = sorted(map(tuple, kosaraju(A, N)))
t = sorted(map(tuple, tarjan(A, N)))
print(f'\nKosaraju SCCs: {k}')
print(f'Tarjan   SCCs: {t}')
print(f'partition identical? {k == t}')


# ---------- অংশ ৩: এই সাইটের curriculum module graph ----------
curriculum_requires = {
    'mathematics': [],
    'computer-representation': ['mathematics'],
    'digital-logic': ['mathematics', 'computer-representation'],
    'computer-architecture': ['digital-logic'],
    'assembly': ['computer-architecture'],
    'operating-systems': ['computer-architecture', 'assembly'],
    'algorithms': ['mathematics', 'computer-representation'],
    'programming-languages': ['operating-systems', 'algorithms'],
    'compilers': ['programming-languages', 'assembly'],
    'networking': ['operating-systems'],
    'databases': ['operating-systems', 'algorithms'],
    'distributed-systems': ['networking', 'databases'],
    'security': ['operating-systems', 'networking'],
    'advanced-architecture': ['computer-architecture', 'operating-systems'],
    'virtualization-cloud': ['operating-systems', 'networking'],
    'cs-theory': ['algorithms', 'compilers'],
}

mods = sorted(curriculum_requires)
mid = {m: i for i, m in enumerate(mods)}
M = len(mods)
ADJ = [[] for _ in range(M)]
for mod, reqs in curriculum_requires.items():
    for r in reqs:
        ADJ[mid[mod]].append(mid[r])     # require করলে সেখান থেকে এদিকে arrow

indeg = [0]*M
for u in range(M):
    for v in ADJ[u]:
        indeg[v] += 1
q = deque(i for i in range(M) if indeg[i] == 0)
study = []
while q:
    u = q.popleft()
    study.append(mods[u])
    for v in ADJ[u]:
        indeg[v] -= 1
        if indeg[v] == 0:
            q.append(v)

print(f'\ncurriculum: {M} modules')
if len(study) == M:
    print(f'DAG ✓ — একটা বৈধ study order:')
    for i, m in enumerate(study, 1):
        reqs = curriculum_requires[m]
        tail = f'   (after {", ".join(reqs)})' if reqs else ''
        print(f'  {i:>2}. {m}{tail}')
else:
    stuck = [mods[i] for i in range(M) if mods[i] not in study]
    print(f'CYCLE! stuck: {stuck}')

# validity check: প্রতিটা module তার সব require-এর পরে আসছে?
pos = {m: i for i, m in enumerate(study)}
okall = all(all(pos[r] < pos[m] for r in rs) for m, rs in curriculum_requires.items())
print(f'order validity: {okall}')

সাধারণ ফলাফল:

CIRCULAR DEPENDENCY DETECTED:
  auth → session → db → cache → auth

Kosaraju SCCs: [('a', 'b', 'c', 'e'), ('d',), ('f', 'g')]
Tarjan   SCCs: [('a', 'b', 'c', 'e'), ('d',), ('f', 'g')]
partition identical? True

curriculum: 16 modules
DAG ✓ — একটা বৈধ study order:
   1. mathematics
   2. computer-representation   (after mathematics)
   3. digital-logic   (after mathematics, computer-representation)
   ...
  16. cs-theory   (after algorithms, compilers)
order validity: True

পড়ার নিয়ম:

১. Cycle-report নাম-ধরে বলে দেয় — ঠিক npm/make-এর error message-এর মতো, কারণ ইঞ্জিনটাই এটা।

২. দুই algorithm, এক partition — member-দের ক্রম ভিন্ন হলেও component-সেট bit-for-bit মেলে; এটাই SCC-র definition-level uniqueness (partition unique, representation না)।

৩. Curriculum নিজেই একটা live DAG — README-তে বলা studyOrder() ঠিক এই গণিত চালায় production-এ। আপনার লেখা ৫ লাইনের Kahn আজ এই সাইটের navigation ঠিক করে যে ভাবে কাজ করে।

এটা কী প্রমাণ করে

Kahn-এর leftover সরাসরি cycle শনাক্ত করে, Kosaraju আর Tarjan সম্পূর্ণ ভিন্ন কৌশলেও হুবহু এক SCC partition দেয়, আর এই প্রজেক্টের curriculum dependency graph আসলেই একটা DAG যার topo order সাইটের studyOrder।

নিজে বানান

BUILD IT

kahn_topo_sort + kosaraju_scc + tarjan_scc + cycle-reporter — এক ফাইলে dependency engine

Python · ●●●○○
  1. kahn_topo_sort — order বা (None, leftover) ফেরত দেয়
  2. find_cycle — leftover থেকে successor-walk করে নির্দিষ্ট cycle path বের করে
  3. kosaraju_scc — দুই-pass, finish order + reversed graph
  4. tarjan_scc — এক-pass iterative, disc/low/on-stack
  5. cross-check: দুই SCC output partition হিসেবে মিলবেই — assert দিয়ে প্রমাণ করুন
from collections import deque


def kahn_topo_sort(adj, n):
    """বৈধ topo order ফেরত, নইলে (None, leftover_vertices)"""
    indeg = [0] * n
    for u in range(n):
        for v in adj[u]:
            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]:
            indeg[v] -= 1
            if indeg[v] == 0:
                q.append(v)
    if len(order) == n:
        return order, None
    return None, [v for v in range(n) if indeg[v] > 0]


def find_cycle(adj, leftover):
    """leftover subgraph-এ একটা concrete cycle বের করে"""
    alive = set(leftover)
    u = leftover[0]
    seen_at = {}
    path = []
    while u not in seen_at:
        seen_at[u] = len(path)
        path.append(u)
        u = next(v for v in adj[u] if v in alive)
    return path[seen_at[u]:]


def kosaraju_scc(adj, n):
    vis = [False] * n
    fin = []
    for s in range(n):
        if vis[s]:
            continue
        vis[s] = True
        st = [(s, iter(adj[s]))]
        while st:
            u, it = st[-1]
            adv = False
            for v in it:
                if not vis[v]:
                    vis[v] = True
                    st.append((v, iter(adj[v])))
                    adv = True
                    break
            if not adv:
                fin.append(u)
                st.pop()
    radj = [[] for _ in range(n)]
    for u in range(n):
        for v in adj[u]:
            radj[v].append(u)
    seen = [False] * n
    comps = []
    for u in reversed(fin):
        if seen[u]:
            continue
        comp, st = [], [u]
        seen[u] = True
        while st:
            x = st.pop()
            comp.append(x)
            for y in radj[x]:
                if not seen[y]:
                    seen[y] = True
                    st.append(y)
        comps.append(comp)
    return comps


def tarjan_scc(adj, n):
    index = [-1] * n
    low = [0] * n
    on_stack = [False] * n
    stack, comps = [], []
    counter = 0
    for root in range(n):
        if index[root] != -1:
            continue
        index[root] = low[root] = counter
        counter += 1
        stack.append(root)
        on_stack[root] = True
        work = [(root, iter(adj[root]))]
        while work:
            u, it = work[-1]
            advanced = False
            for v in it:
                if index[v] == -1:
                    index[v] = low[v] = counter
                    counter += 1
                    stack.append(v)
                    on_stack[v] = True
                    work.append((v, iter(adj[v])))
                    advanced = True
                    break
                elif on_stack[v]:
                    if index[v] < low[u]:
                        low[u] = index[v]
            if not advanced:
                work.pop()
                if work:
                    parent = work[-1][0]
                    if low[u] < low[parent]:
                        low[parent] = low[u]
                if low[u] == index[u]:          # u = SCC-র root
                    comp = []
                    while True:
                        x = stack.pop()
                        on_stack[x] = False
                        comp.append(x)
                        if x == u:
                            break
                    comps.append(comp)
    return comps


def as_partition(comps, n):
    """component list → frozenset-of-frozensets (comparison-এর জন্য)"""
    return frozenset(frozenset(c) for c in comps)


# ================= unit cases =================
if __name__ == '__main__':
    # ১. সাধারণ DAG — diamond + দুই island
    dag = [[1, 2], [3], [3], []]           # 0→1, 0→2, 1→3, 2→3 ; 4 আলাদা
    order, cyc = kahn_topo_sort(dag, 5)
    assert cyc is None
    pos = {v: i for i, v in enumerate(order)}
    for u in range(5):
        for v in dag[u]:
            assert pos[u] < pos[v]
    assert len(set(order)) == 5
    print('DAG topo order valid ✓ :', order)

    # ২. self-loop — ক্ষুদ্রতম cycle
    selfy = [[0]]
    order, cyc = kahn_topo_sort(selfy, 1)
    assert order is None and find_cycle(selfy, cyc) == [0]
    print('self-loop cycle reported ✓')

    # ৩. লেসনের example digraph — SCC দুইভাবে, মিলিয়ে
    ex = [
        [1],        # a → b
        [2],        # b → c
        [3, 4],     # c → d, c → e
        [],         # d
        [0],        # e → a
        [6],        # f → g
        [5],        # g → f
    ]
    kc = as_partition(kosaraju_scc(ex, 7), 7)
    tc = as_partition(tarjan_scc(ex, 7), 7)
    assert kc == tc
    expected = frozenset({frozenset({0,1,2,4}), frozenset({3}), frozenset({5,6})})
    assert kc == expected
    print('Kosaraju == Tarjan == expected ✓:', sorted(map(sorted, tarjan_scc(ex, 7))))

    # ৪. বড় cycle + tail — reporter নাম-ধরে দেखায়
    # 0→1→2→3→1  (cycle 1-2-3) plus 0 ঢুকে-পড়া tail
    cyc_g = [[1], [2], [3], [1]]
    order, left = kahn_topo_sort(cyc_g, 4)
    assert order is None
    cycle_path = find_cycle(cyc_g, left)
    print('reported cycle ✓:', ' → '.join(map(str, cycle_path + [cycle_path[0]])))

    # ৫. সব vertex আলাদা SCC (chain) আর single node
    chain = [[1], [2], []]
    assert as_partition(kosaraju_scc(chain, 3), 3) == \
           as_partition(tarjan_scc(chain, 3), 3) == \
           frozenset({frozenset({0}), frozenset({1}), frozenset({2})})
    assert as_partition(tarjan_scc([[]], 1), 1) == frozenset({frozenset({0})})
    print('trivial partitions ✓')

    # ৬. এক SCC ভেতরে topo order নেই — কিন্তু condensation-এ আছে
    big = [[1], [2], [0]]                  # pure 3-cycle
    order, _ = kahn_topo_sort(big, 3)
    assert order is None
    comps = tarjan_scc(big, 3)
    assert len(comps) == 1 and len(comps[0]) == 3
    print('whole-cycle single SCC ✓')

নিজে বাড়ান:

১. condensation(adj, n) লিখুন — SCC id-তে নামিয়ে meta-edge সেট ফেরত দিন, তারপর meta-graph-এ নিজেরই kahn_topo_sort চালিয়ে meta-order দেখান। ২. Longest path in DAG (critical path scheduling): topo order-এ এক pass-এ dp করে সবচেয়ে লম্বা পথ — project management-এর PERT chart-এর ইঞ্জিন। ৩. Kahn-এ tie-break priority queue দিয়ে (lexicographically smallest order) — CF problems-এর ক্লাসিক variant; heapq import ছাড়া কিছুই বদলায় না।

বাস্তব সিস্টেমে

Dependency-র গণিত বাস্তবে কোথায়

Package managers। npm-এর arborist, Debian-এর apt resolver, Cargo, Maven — সবাই dependency graph বানিয়ে topo-sort চালায়; circular dependency ধরলে exact cycle path রিপোর্ট করে — আজকের find_cycle wrapper-এর বাণিজ্যিক রূপ। Python packaging-এর pip resolver-ও একই।

Build systems। Makefile target, Bazel/Buck action graph, LLVM-এর pass manager, Linux kernel-এর Kbuild — সবাই DAG schedule। Parallel build মানে topo-order-এর ওপর topological levels ধরে thread বসানো — level-এর ভেতরে independent কাজ সমান্তরালে।

Spreadsheet engines। Excel/Google Sheets প্রতিটা cell-কে formula dependency-র node বানায়; edit হলে dirty-cells সেট topo order-এ recalculate — এই জন্যই circular reference error দেয় নাম-ধরে।

Course curriculum — এই সাইট সহ। এই প্রজেক্টের src/data/curriculum.ts-এ module-দের requires[] edge; studyOrder() ওই graph-এর topological sort — সাইটের প্রতিটা ordering সেখান থেকে derived, hand-maintained তালিকা নেই। University degree audit tools-ও একই গণিত।

Garbage collection আর memory ordering। Reference-counting-এর cycle problem ঠিক SCC সমস্যা — Python-এর gc module cyclic trash detect করে SCC-জাতীয় group-এ। আর distributed snapshot-এর আগে event ordering (Lamport clocks) partial order — এর topo extension।

Data pipeline orchestration। Airflow DAG, dbt model lineage, GitHub Actions needs — scheduler প্রতिदিন লাখ লাখ node-এর topo-sort চালায়; failure-এর পরে resume করতে completed-subtree prune করে আবার Kahn।

যে ভুলগুলো সবাই করে

“একটা DAG-এর topological order unique হয়।”

সাধারণত না — যতখনই দুইটা vertex একসাথে unordered (কোনো দিকে path নেই), তাদের ক্রম স্বাধীন। Unique হয় ঠিক তখনই যখন graph-এ পরপর প্রতিটা vertex-এর মাঝে Hamiltonian path থাকে — তখন একটাই ordering। Queue discipline (FIFO/LIFO/priority) বদলালেই বৈধ ভিন্ন order আসে।

“Topological sort মানে alphabetically বা numerically সাজানো।”

না — শুধু edge direction মানা ছাড়া কোনো বাধ্যবাধকতা নেই। [b, a] আর [a, b] দুটোই বৈধ হতে পারে যদি a,b-র মধ্যে কোনো path না থাকে। Lexicographic tie-breaking একটা বাড়তি নিয়ম যা আপনি চাইলে যোগ করতে পারেন, algorithm-এর requirement না।

“Connected component আর strongly connected component একই ব্যাপার — শুধু directed graph-এ।”

Undirected-তে connectivity symmetric by definition — একটাই concept। Directed-তে দুইটা আলাদা ধারণা: weakly connected (direction ignore করলে connected) আর strongly connected (উভয় দিকে path)। Path digraph a→b→c weakly connected কিন্তু SCC তিনটা। SCC-র প্রতিটা সদস্য-জোড়ার দুই দিকের path বাধ্যতামূলক — এটাই ব্যাপারটা কঠিন ও আকর্ষণীয় করে।

“Kosaraju-তে reversed graph শুধু দক্ষতার জন্য — G-তেই আসলে SCC গোছানো থাকে।”

না, reversal অপরিহার্য। G-তে এক DFS-run একাধik SCC গিলে ফেলতে পারে — কারণ এক SCC থেকে অন্য SCC-তে যাওয়ার outgoing edge G-তে open। Reversed graph-এ সেই দিকগুলো উল্টে যায়: finish-time-descending ক্রমে শুরু করলে source-SCC থেকে বাইরে যাওয়ার রাস্তাগুলোই বন্ধ হয়ে যায়, তাই প্রতিটা run ঠিক এক SCC-তেই আটকে পড়ে। Reversal ছাড়া correctness-এর প্রমাণই দাঁড়াঁয় না।

“Tarjan-এর low[u] মানে u থেকে পৌঁছানো যায় এমন সবচেয়ে পুরনো vertex — যেকোনোভাবে।”

প্রায়, কিন্তু সেই “যেকোনোভাবে”-তেই ফাঁদ: শুধু on-stack vertex-এর disc দিয়েই update হয়। বরাদ্দ-হয়ে-যাওয়া (popped) SCC-র দিকে cross edge টানলে ভুল merge হতো — ওই SCC ইতিমধ্যে আলাদা পাড়া, তার সাথে এখনকার vertex-এর কোনো mutual-reachability জন্মায় না। On-stack শর্তটা Tarjan-এর সবচেয়ে সূক্ষ্ম আর সবচেয়ে ভুল-বোঝানো অংশ।

বুঝেছেন কি না দেখুন

1

Topological order থাকার শর্ত কী, আর দুই দিকের যুক্তি এক বাক্যে কী?

স্মরণ

শর্ত: graph directed acyclic হতে হবে। Cycle ⇒ order impossible (order-এ চক্রের সদস্যরা নিজেদের চেয়ে ছোট হতো)। Acyclic ⇒ order exists (in-degree-০ vertex সবসময় আছে; ওটা বসিয়ে বাদ দিতে থাকলে induction)।

2

Edges: a→b, b→c, c→a, c→d। Kahn চালালে order আর leftover কী হবে? find_cycle কোন path দেখাবে?

প্রয়োগ

In-degrees: a:1(c), b:1(a), c:1(b), d:1(c) — শূন্য কেউ নেই, তাই order = [], leftover = {a,b,c,d}। Walk d থেকে: d-র outgoing নেই! সাবধান — leftover subgraph-এ d-র out-degree 0, তাই walk d দিয়ে শুরু করলে আটকায়। সঠিক walk শুরু হবে এমন vertex থেকে যার outgoing edge আছে (যেমন a): a→b→c→a — cycle path a, b, c (d চক্রের downstream, চক্রের অংশ না)। শিক্ষা: reporter-এ walk-start বাছাই গুরুত্বপূর্ণ — out-degree ≥ 1 এমন leftover vertex ধরতে হবে।

3

Kosaraju-র pass 1 কেন পুরো G-তে (সব unvisited root থেকে) চালাতে হয়? এক root থেকে চালালে কী মিস হয়?

যুক্তি

Graph disconnected বা কিছু vertex প্রথম root-এর reach-এর বাইরে থাকতে পারে — তাদের finish time রেকর্ডই হবে না, pass 2-এর ক্রমে ওরা অনুপস্থিত। প্রতিটা unvisited root-এ নতুন DFS মানে প্রতিটা vertex ঠিক একবার finished — full coverage। এক root-এর reach সব SCC cover করলেও (strongly-connected না হলেও reachable হলে) সব graph-এ guarantee নেই — তাই loop সব vertex-এ।

4

Excel-জাতীয় engine-এ user ৫ লাখ cell-এর sheet-এ এক cell edit করল। Recalculation schedule কীভাবে বানাবেন যাতে সব dirty cell ঠিক একবার, সঠিক ক্রমে হয় — আর circular formula থাকলে user-friendly error যায়?

ডিজাইন

Dirty cells + তাদের transitive dependents-এর subgraph বানান (reverse edges follow করে BFS — শুধু প্রভাবিত অংশ, পুরো sheet না)। ওই subgraph-এ in-degree গুনে Kahn চালান: order পেলে সেই ক্রমেই recalc (level ধরে parallelize করা যায়)। Kahn-এর leftover পেলে — circular formula — leftover-এ find_cycle চালিয়ে “Cell B7 → C3 → B7” জাতীয় named-path error দিন। খরচ O(dirty subgraph)-only — বাকি sheet untouched। ঠিক npm-এর মতোই, শুধু domain আলাদা।

5

Tarjan-এ low[u] == disc[u] দেখামাত্র কী ঘটে এবং কেন সেটাই SCC-র signal? On-stack condition বাদ দিলে কী ভুল হতো?

স্মরণ

low[u] == disc[u] মানে u নিজে বা তার active-subtree-র কেউ stack-এ থাকা কোনো পুরনো vertex-এ ফেরত টানতে পারেনি — u তার SCC-র সবচেये পुরনো (root) member, আর stack-এর top থেকে u পর্যন্ত যারা জমে আছে তারাই এক SCC — pop করে রিপোর্ট। On-stack চেক বাদ দিলে already-popped SCC-র সদস্যের দিকে cross-edge low[] কমিয়ে ফেলত — দুই আলাদা পাড়ा ভুল করে এক হয়ে যেত (correctness ভাঙে, complexity না)।

এরপর কী

এরপর কी

এ till now আমরা graph-কে ঘুরেছি, জুড়েছি, সাজিয়েছি। এবার সবচেয়ে পুরনো ও familiar সমস্যায় ফেরা: sorting — কিন্তু এবার গভীরে। Comparison sort-এর Ω(n log n) নিচের সীমা কোথা থেকে এল (decision-tree counting), quicksort বাস্তবে কেন সবাইকে হারায (inner loop + cache), counting/radix কীভাবে সীমাটা বৈধভাবে ভাঙে, stability কেন ব্যাপার, আর Python/Java-র default timsort কেন nearly-sorted input-এ প্রায় linear। সাথে পুরো benchmark suite — ছয় algorithm, পাঁচ distribution।

আরও পড়ুন

  • Introduction to Algorithms (CLRS), Sections 22.4–22.5 — Topological Sort and Strongly Connected Components — Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, Clifford Stein · Finish-time parenthesis structure আর Kosaraju-র পূর্ণ প্রমাণ — এই লেসনের মেরুদণ্ড
  • "Depth-First Search and Linear Graph Algorithms" (1972) — Robert Endre Tarjan · SCC-সহ একঝাঁক DFS application-এর মূল paper — low-link-এর জন্মস্থান, Turing Award-জয়ী কাজের অংশ
  • "Topological Sorting of Large Networks" (1962) — Arthur B. Kahn · In-degree queue technique-এর মূল প্রবন্ধ — আজও প্রতিটা build system-এ চলছে