Data Structures & Algorithms · ساختمانداده و الگوریتم سنیورSenior ~43 دقیقه مطالعه~36 min read
گرافها و الگوریتمهای گرافGraphs & Graph Algorithms
از صفر یاد میگیری که هر مسئلهٔ سخت مصاحبه در باطن یک گراف است و چطور آن را مدل کنی، نمایش بدهی و با BFS، DFS، مرتبسازی توپولوژیک، دایکسترا، بلمن-فورد، Union-Find و درخت پوشای کمینه حل کنی — با کد جاوای قابل کامپایل و پانزده سؤال مصاحبهٔ کامل.Learn from scratch why almost every hard interview problem is secretly a graph, and how to model, represent, and solve it with BFS, DFS, topological sort, Dijkstra, Bellman-Ford, Union-Find, and minimum spanning trees — with compilable Java and fifteen fully answered interview questions.
پیشنیاز:Prerequisites: آرایه، لیست، پشته، صف و هشArrays, Lists, Stacks, Queues & Hashing
بگذار با یک راز شروع کنیم که سطح ارشد را از تازهکار جدا میکند: بیشتر مسئلههای سختِ مصاحبه در باطن مسئلهٔ گرافاند، فقط لباس مبدل پوشیدهاند. یک نقشهٔ شهر، یک صفحهٔ شطرنجی از خانهها، فهرست پیشنیاز درسها، شبکهٔ دوستان یک اپلیکیشن، حتی «آیا میتوانم با این قوانین از حالت A به حالت B برسم» — همه گرافاند. اگر یاد بگیری گراف را در دل مسئله ببینی، نصف راه را رفتهای. این فصل همان دیدن را از پایه به تو یاد میدهد و بعد جعبهابزار الگوریتمیاش را دانهدانه باز میکند.
اول واژههای پایه (رأس، یال، وزن، جهت، درجه، چگالی) را با تشبیه میسازیم. بعد سه راه نمایش گراف در حافظه را میبینیم. سپس دو پیمایش بنیادین BFS و DFS، و از دل آنها: شمارش مؤلفههای همبند، تشخیص دور (جهتدار و بدونجهت)، و مرتبسازی توپولوژیک. بعد به سراغ کوتاهترین مسیرها میرویم (BFS، دایکسترا، بلمن-فورد، DAG). در پایان Union-Find و درخت پوشای کمینه (کروسکال و پریم)، یک فهرست دامهای رایج، و پانزده سؤال مصاحبهٔ کامل. همهچیز با کد جاوای قابل کامپایل.
بخش صفر — واژههایی که باید بلد باشی
قبل از هر چیز، بیا این کلمهها را با یک تصویر ذهنی بسازیم تا بعداً هیچکدام غریبه نباشند.
تصور کن یک نقشهٔ شهر جلوی توست. شهرها نقطههایی روی نقشهاند و جادهها آنها را به هم وصل میکنند. در زبان گراف، هر شهر یک رأس (vertex) است و هر جاده یک یال (edge). اگر روی جاده بنویسند «۱۲ کیلومتر»، آن عدد وزن (weight) یال است. اگر جاده یکطرفه باشد، یال جهتدار (directed) است؛ اگر دوطرفه باشد، بدونجهت (undirected). تعداد جادههایی که به یک شهر وصلاند، درجهٔ (degree) آن شهر است.
با همین تصویر، تعریف رسمی ساده میشود: گراف عبارت است از G = (V, E)؛ یعنی یک مجموعه رأس V و یک مجموعه یال E ⊆ V × V (هر یال زوجی از رأسهاست). دو نماد که تا آخر فصل مدام میبینی:
V = |V|یعنی تعداد رأسها.E = |E|یعنی تعداد یالها.
همهٔ پیچیدگیهای زمانی و حافظهای که در ادامه میآید بر حسب همین دو عدد نوشته میشوند.
دو صفت دیگر هم لازم داریم:
یک گراف تنک (sparse) یعنی جادهها کماند — تقریباً به تعداد شهرها (E ≈ V). مثل نقشهٔ واقعی یک کشور که هر شهر فقط به چند شهر همسایه وصل است. یک گراف چگال (dense) یعنی تقریباً همه به همه وصلاند (E ≈ V²). مثل یک مهمانی که هر کس با هر کس دست داده. این تفاوت تعیین میکند گراف را چطور در حافظه بریزی و کدام الگوریتم را انتخاب کنی.
و در نهایت DAG: مخفف Directed Acyclic Graph، یعنی گرافِ جهتدارِ بدوندور. «بدون دور» یعنی نمیتوانی از یک رأس راه بیفتی و در جهت یالها دوباره به خودش برگردی. این خانواده آنقدر مهم است که چند الگوریتم مخصوص خودش دارد.
مدل ذهنی: اول چهار سؤال، بعد الگوریتم
مهارت واقعی، مدلسازی (modeling) است: در متن یک مسئلهٔ کلامی، رأسها، یالها و وزنها را تشخیص بدهی و بعد پیمایش درست را برداری. یک شبکهٔ دوبعدی گراف است (هر خانه یک رأس، هر همسایگی یک یال)، یک زنجیرهٔ وابستگی گراف است، یک ماشین حالت (state machine) گراف است.
قبل از نوشتن هر خط کد، این چهار سؤال را از مسئله بپرس. جوابشان تقریباً همیشه الگوریتم را برایت انتخاب میکند.
| محور | گزینهها | نتیجه |
|---|---|---|
| جهت | جهتدار (directed) / بدونجهت (undirected) | بر تشخیص دور، همبندی و امکان مرتبسازی توپولوژیک اثر میگذارد |
| وزن | بیوزن / وزندار / وزن منفی | BFS در برابر Dijkstra در برابر Bellman-Ford |
| چگالی | تنک (E ≈ V) / چگال (E ≈ V²) |
لیست در برابر ماتریس؛ بر انتخاب heap دایکسترا اثر دارد |
| وجود دور | DAG (بدون دور) / عمومی | مرتبسازی توپولوژیک، برنامهریزی پویا روی DAG |
اگر همین چهار واقعیت (جهت، وزن، چگالی، دوردار بودن) را قبل از کدنویسی روی کاغذ بنویسی، در نیمی از مصاحبهها الگوریتم خودش بیرون میافتد.
نمایشها: گراف را چطور در حافظه بریزیم؟
قبل از الگوریتم، باید گراف را در حافظه ذخیره کنیم. سه راه رایج داریم و هر کدام برای کاری ساخته شده.
لیست مجاورت (adjacency list)
تصور کن هر شهر یک دفترچه دارد و در آن فقط شمارهی شهرهایی را نوشته که مستقیم به آنها جاده دارد. برای پیدا کردن همسایههای یک شهر، فقط دفترچهٔ خودش را باز میکنی؛ لازم نیست کل کشور را ورق بزنی. این دقیقاً «لیست مجاورت» است.
هر رأس فهرست همسایههایش را نگه میدارد. فضای مصرفی O(V + E) است (بهاندازهٔ رأسها بهعلاوهٔ یالها، نه بیشتر). پیمایش همسایههای یک رأس O(deg(v)) است — یعنی بهاندازهٔ تعداد جادههای همان شهر. بررسی اینکه یک یال مشخص (u, v) وجود دارد یا نه O(deg(u)) است، مگر همسایهها را در یک set نگه داری.
// V رأس با برچسب 0..V-1. یالهای جهتدار وزندار.
class Graph {
record Edge(int to, int weight) {}
final List<List<Edge>> adj;
Graph(int v) {
adj = new ArrayList<>(v);
for (int i = 0; i < v; i++) adj.add(new ArrayList<>());
}
void addDirected(int u, int v, int w) { adj.get(u).add(new Edge(v, w)); }
void addUndirected(int u, int v, int w) {
adj.get(u).add(new Edge(v, w));
adj.get(v).add(new Edge(u, w)); // هر دو جهت
}
}
دقت کن addUndirected یال را در هر دو جهت اضافه میکند — چون جادهٔ دوطرفه از دید هر دو شهر دیده میشود. برای گراف کاملاً بیوزن، int[][] adj که در آن adj[u] یک آرایهٔ سادهٔ همسایههاست، سبکتر و از نظر cache بهینهتر است (دادهها پشتسرهم در حافظهاند و پردازنده سریعتر میخواندشان).
ماتریس مجاورت (adjacency matrix)
حالا تصور کن یک جدول بزرگ V در V کشیدهای؛ سطر و ستونش شهرها هستند و در خانهٔ تقاطع شهر u و شهر v نوشتهای «آیا جاده هست؟» (یا وزنش چند است). برای پرسیدن «آیا این دو شهر مستقیم وصلاند؟» فقط به یک خانه نگاه میکنی — آنی. ولی این جدول برای کشوری با میلیونها شهرِ کمجاده، یک اتلاف عظیم کاغذ است.
این ساختار boolean[V][V] است (یا int[V][V] برای وزن). فضا O(V²) است. بررسی وجود یال O(1) (زمان ثابت) است — همان نگاه به یک خانه. اما پیمایش همسایهها فارغ از درجه O(V) میشود (باید کل یک سطر را بخوانی)، و مصرف حافظه برای گرافهای تنکِ بزرگ غیرقابلقبول است. فقط وقتی سراغش برو که V کوچک باشد (مثلاً ≤ ~۱۰۰۰)، گراف چگال باشد، یا واقعاً به پرسوجوی یال در زمان ثابت نیاز داشته باشی (مثلاً در الگوریتم Floyd–Warshall).
int[][] w = new int[V][V];
for (int[] row : w) Arrays.fill(row, INF);
for (int i = 0; i < V; i++) w[i][i] = 0;
// w[u][v] وزن یال u->v است؛ INF اگر یالی نباشد
لیست یال (edge list)
صرفاً یک List<int[]> از سهتاییهای {u, v, w} — یعنی «از u به v با وزن w». فشرده و ساده، و دقیقاً همان چیزی که دو الگوریتم Kruskal (برای MST) و Bellman-Ford مصرف میکنند. عیبش این است که برای پرسیدن «همسایههای u کیاند؟» باید کل فهرست را بگردی، پس برای پیمایش بد است.
پیشفرض همیشه لیست مجاورت است، مگر آنکه یک الگوریتم خاص (DP ماتریسی، Floyd–Warshall) یا یک گراف چگال چیز دیگری بطلبد. اگر شک داری، لیست مجاورت را بردار؛ در ۹۰٪ مسائل جواب درست است.
BFS — جستوجوی سطحاول (breadth-first search)
یک سنگ در استخر بینداز. موج بهصورت حلقههای هممرکز پخش میشود: اول همهٔ نقاطی که یک قدم دورند، بعد همه که دو قدم دورند، و همینطور. BFS دقیقاً همین است — از مبدأ شروع میکند و گراف را در حلقههایی با فاصلهٔ فزاینده میکاود. چون همهٔ نقاط فاصلهٔ ۱ را قبل از فاصلهٔ ۲ میبیند، اولین باری که به یک رأس میرسد از کوتاهترین مسیر رسیده است.
BFS برای مرتب نگهداشتن این حلقهها از یک صف FIFO استفاده میکند (First-In-First-Out؛ هر که زودتر وارد شد زودتر خارج میشود — مثل صف نانوایی). روی گراف بیوزن کوتاهترین مسیر یعنی کمترین تعداد یال را در O(V + E) پیدا میکند. این پرکاربردترین تکنیک گراف در مصاحبههاست: هر مسئلهٔ «کمترین تعداد گام / حرکت / تبدیل» در باطن یک BFS است.
int[] bfsDistances(List<List<Integer>> adj, int src) {
int n = adj.size();
int[] dist = new int[n];
Arrays.fill(dist, -1); // -1 یعنی بازدیدنشده
Deque<Integer> q = new ArrayDeque<>();
dist[src] = 0;
q.add(src);
while (!q.isEmpty()) {
int u = q.poll();
for (int v : adj.get(u)) {
if (dist[v] == -1) { // علامتگذاری هنگام ENQUEUE، نه هنگام dequeue
dist[v] = dist[u] + 1;
q.add(v);
}
}
}
return dist;
}
پرتکرارترین اشتباه: علامتگذاری «بازدیدشده» هنگام خارج کردن از صف (dequeue) بهجای وارد کردن (enqueue). اگر هنگام خروج علامت بزنی، یک رأس میتواند چندین بار وارد صف شود (چون تا وقتی از صف بیرون نیامده هنوز «بازدیدنشده» است) و کار بهطور نمایی منفجر میشود و گاهی فاصلهها هم غلط درمیآید. همیشه dist[v] یا visited[v] را در همان لحظهای که به صف اضافه میکنی تنظیم کن.
دو گونهٔ مهم BFS که در مصاحبه خیلی به کار میآیند:
- BFS چندمبدأ (multi-source): صف را با تمام مبدأها در فاصلهٔ صفر مقداردهی کن (مثل مسئلهٔ «پرتقالهای فاسدشونده» یا «نزدیکترین خروجی»). این کار برای هر سلول، فاصله تا نزدیکترین مبدأ را در یک پیمایش حساب میکند — انگار چند سنگ را همزمان در استخر انداختی.
- BFS صفر-یک (0-1 BFS): وقتی وزن یالها فقط ۰ یا ۱ است، بهجای صف از یک deque (صف دوسر) استفاده کن: یالهای وزن ۰ را به جلو و یالهای وزن ۱ را به عقب هل بده. نتیجهای در حد دایکسترا اما با هزینهٔ
O(V + E).
DFS — جستوجوی عمقاول (depth-first search)
وارد یک هزارتو شدهای و یک ریسمان از ورودی به دنبالت میکشی. هر بار یک راهرو را تا آخر میروی؛ به بنبست که رسیدی، در امتداد ریسمان عقبگرد (backtrack) میکنی تا به آخرین دوراهیِ نرفته برسی و از آنجا ادامه میدهی. DFS همین است: تا جای ممکن عمیق برو، بعد برگرد.
نسخهٔ بازگشتی (recursive) طبیعیترین شکل نوشتنش است. نسخهٔ تکراری با یک پشتهٔ صریح، از سرریز پشته روی گرافهای خیلی عمیق جلوگیری میکند (پشتهٔ پیشفرض JVM حدود ۱۰ تا ۲۰ هزار فریم فراخوانی را تحمل میکند — گرافی که یک مسیر بلندِ ۱۰۰هزارتایی دارد StackOverflowError میدهد).
void dfs(int u, List<List<Integer>> adj, boolean[] visited) {
visited[u] = true;
// کار preorder اینجا
for (int v : adj.get(u)) {
if (!visited[v]) dfs(v, adj, visited);
}
// کار postorder اینجا (زمان پایان / finish time)
}
دو کلمه اینجا مهماند: کار preorder یعنی کاری که پیش از رفتن به سراغ فرزندان انجام میدهی، و کار postorder یعنی کاری که بعد از تمامشدن همهٔ فرزندان انجام میدهی. لحظهای که همهٔ نوادگان یک رأس تمام شدهاند، زمان پایان (finish time) آن رأس است.
DFS ستون فقرات اینهاست: مؤلفههای همبند، تشخیص دور، مرتبسازی توپولوژیک، پلها و نقاط برشی (الگوریتم Tarjan)، و مؤلفههای قویاً همبند (SCC، با Tarjan یا Kosaraju). ایدهٔ کلیدی که موتور مرتبسازی توپولوژیک و SCC را میراند، همان زمان پایان در postorder است.
وقتی روی یک گراف جهتدار DFS میزنی، هر یال یکی از این چهار نوع میشود: درختی (tree) یعنی یالی که با آن به یک رأس تازه رسیدی؛ پسرو (back) یعنی یالی به یکی از اجدادی که هنوز روی پشتهٔ بازگشت است؛ پیشرو (forward) و عرضی (cross) یعنی یال به رأسی که کارش تمام شده. نکتهٔ طلایی: یک یال پسرو دقیقاً همان چیزی است که وجود دور در گراف جهتدار را نشان میدهد. این را نگه دار؛ در بخش تشخیص دور دوباره لازمش داریم.
مؤلفههای همبند (بدونجهت)
مؤلفهٔ همبند یک تکهٔ جدا از گراف است که همهٔ رأسهایش از راهی به هم وصلاند اما به بقیه وصل نیستند — مثل چند جزیرهٔ مجزا که جادههای داخلی دارند ولی پل بینشان نیست. میخواهیم این جزیرهها را بشماریم یا برچسب بزنیم.
راهش ساده است: از هر رأسِ هنوز بازدیدنشده یک BFS (یا DFS) راه بینداز؛ هر بار که مجبور میشوی از نو شروع کنی، یعنی به یک جزیرهٔ جدید رسیدهای.
int countComponents(int n, List<List<Integer>> adj) {
boolean[] seen = new boolean[n];
int components = 0;
for (int i = 0; i < n; i++) {
if (!seen[i]) {
components++;
Deque<Integer> q = new ArrayDeque<>();
seen[i] = true; q.add(i);
while (!q.isEmpty()) {
int u = q.poll();
for (int v : adj.get(u))
if (!seen[v]) { seen[v] = true; q.add(v); }
}
}
}
return components;
}
کل هزینه O(V + E) است. اما توجه: این روش برای وقتی خوب است که کل گراف را یکجا داری. برای همبندی پویا — یعنی وقتی یالها در طول زمان یکییکی اضافه میشوند و مدام میپرسند «آیا این دو الان همبندند؟» — ابزار درست چیز دیگری است به نام Union-Find که کمی جلوتر میبینیم.
تشخیص دور (cycle detection)
«دور (cycle)» یعنی مسیری که از یک رأس شروع میشود و به خودش برمیگردد. تشخیص دور در دو حالت جهتدار و بدونجهت کاملاً متفاوت است، و همین جای اشتباه رایج است.
حالت بدونجهت
دور بدونجهت وجود دارد اگر در حین DFS به همسایهای بازدیدشده برسی که والد (parent) تو نباشد — یعنی همان رأسی که همین الان از آن آمدی.
در گراف بدونجهت، جاده دوطرفه است. وقتی از A به B رفتی، از دید B یک یال به A وجود دارد که «بازدیدشده» است. اگر سادهلوحانه بگویی «رسیدم به رأس بازدیدشده، پس دور است!» هر یال درختی را اشتباهی دور میبینی. راهحل: رأسی که همین الان از آن آمدی (والد) را نادیده بگیر؛ فقط اگر به یک رأس بازدیدشدهٔ دیگر رسیدی، دور واقعی است.
boolean hasCycleUndirected(int n, List<List<Integer>> adj) {
boolean[] visited = new boolean[n];
for (int i = 0; i < n; i++) {
if (!visited[i] && dfsUndir(i, -1, adj, visited)) return true;
}
return false;
}
boolean dfsUndir(int u, int parent, List<List<Integer>> adj, boolean[] visited) {
visited[u] = true;
for (int v : adj.get(u)) {
if (!visited[v]) {
if (dfsUndir(v, u, adj, visited)) return true;
} else if (v != parent) { // بازدیدشده و جایی که از آن نیامدهایم => دور
return true;
}
}
return false;
}
یک تلهٔ ظریف: با یالهای موازی (parallel edges) — یعنی دو یالِ جدا بین همان جفت رأس — شرط v != parent کافی نیست، چون دو یال به والد در واقع یک دور واقعی به طول ۲ است. آنجا باید از Union-Find یا ردیابی هویت خودِ یالها استفاده کنی.
حالت جهتدار
اینجا رسیدن به یک همسایهٔ «بازدیدشده» بهتنهایی کافی نیست. باید آن رأس روی پشتهٔ بازگشتِ جاری باشد — یعنی یکی از اجداد تو در مسیر فعلی، نه رأسی که کارش قبلاً تمام شده. برای این تفکیک از سه رنگ استفاده میکنیم.
دوباره در هزارتو هستی. هر اتاق سه حالت دارد: سفید یعنی هنوز پایش را نگذاشتهای؛ خاکستری یعنی الان داخلش هستی و ریسمانت هنوز از آن رد میشود (روی مسیر جاریات است)؛ سیاه یعنی کاملاً کاویدیاش و بیرون آمدی. اگر در حال حرکت به اتاقی برسی که خاکستری است، یعنی داری به جایی برمیگردی که هنوز در آن هستی — این یک دور است. رسیدن به اتاق سیاه فقط یعنی از راه دیگری به جای تمامشده رسیدی؛ دور نیست.
boolean hasCycleDirected(int n, List<List<Integer>> adj) {
int[] color = new int[n]; // 0=سفید، 1=خاکستری، 2=سیاه
for (int i = 0; i < n; i++)
if (color[i] == 0 && dfsDir(i, adj, color)) return true;
return false;
}
boolean dfsDir(int u, List<List<Integer>> adj, int[] color) {
color[u] = 1; // خاکستری: روی پشته
for (int v : adj.get(u)) {
if (color[v] == 1) return true; // یال پسرو -> دور
if (color[v] == 0 && dfsDir(v, adj, color)) return true;
}
color[u] = 2; // سیاه: کاملاً کاویده شد
return false;
}
فقط رسیدن به رأس خاکستری دور است؛ رسیدن به رأس سیاه یک زیردرخت تمامشده (یال عرضی یا پیشرو) است که کاملاً بیخطر است. خلط کردن «بازدیدشده» با «روی پشته» یکی از سه خطای رایج مصاحبهٔ ارشد است — همین را جدی بگیر.
مرتبسازی توپولوژیک (روی DAG)
صبح نمیتوانی کفش را قبل از جوراب بپوشی، یا کت را قبل از پیراهن. یکسری «این قبل از آن» داری. مرتبسازی توپولوژیک یعنی چیدن همهٔ کارها در یک ردیف طوریکه هر وابستگی رعایت شود: اگر A پیشنیاز B است، A زودتر بیاید. تنها شرطش این است که وابستگیها حلقه نزنند (نمیشود A پیشنیاز B و B پیشنیاز A باشد) — یعنی گراف باید DAG باشد.
بهطور رسمی: یک ترتیب خطی از رأسهای یک DAG که در آن هر یال u → v، رأس u را پیش از v قرار میدهد. این ترتیب اگر و تنها اگر گراف بدون دور باشد وجود دارد. کاربردها: سیستمهای ساخت (build)، زمانبندی وظایف، پیشنیاز درسها، حل وابستگی پکیجها، و DP روی DAG. دو راه رایج داریم.
الگوریتم Kahn (مبتنی بر BFS و درجهٔ ورودی)
درجهٔ ورودی (in-degree) یک رأس یعنی تعداد یالهایی که به آن وارد میشوند — یعنی چند پیشنیاز دارد. ایده: هر رأسی که درجهٔ ورودیاش صفر شد (هیچ پیشنیاز باقیماندهای ندارد) را خروجی بده و از یالهای خروجیاش، درجهٔ ورودی همسایهها را یکی کم کن. تکرار کن.
int[] topoKahn(int n, List<List<Integer>> adj) {
int[] indeg = new int[n];
for (int u = 0; u < n; u++)
for (int v : adj.get(u)) indeg[v]++;
Deque<Integer> q = new ArrayDeque<>();
for (int i = 0; i < n; i++) if (indeg[i] == 0) q.add(i);
int[] order = new int[n];
int idx = 0;
while (!q.isEmpty()) {
int u = q.poll();
order[idx++] = u;
for (int v : adj.get(u))
if (--indeg[v] == 0) q.add(v);
}
if (idx != n) throw new IllegalStateException("graph has a cycle");
return order;
}
اگر در پایان کمتر از V رأس خروجی داده باشی (idx != n)، حتماً دور وجود دارد. چرا؟ چون رأسهای باقیمانده هرگز به درجهٔ ورودی صفر نرسیدند — یعنی درون یک حلقهٔ وابستگی گیر کردهاند. پس Kahn هم مرتبسازی میکند و هم دور را مجانی تشخیص میدهد.
یک نکتهٔ ظریف که مصاحبهگر ممکن است بپرسد: اگر بهجای صف ساده از PriorityQueue استفاده کنی، ترتیب توپولوژیکِ از نظر لغوی کمینه (کوچکترین ترتیب الفبایی) را میگیری.
مرتبسازی توپولوژیک مبتنی بر DFS
راه دوم: هر رأس را در postorder (بعد از تمامشدن همهٔ نوادگانش) روی یک پشته هل بده، سپس پشته را برعکس کن. شهودش: یک رأس فقط زمانی «تمام» میشود که هر چیزی که از آن قابلدسترسی است تمام شده باشد؛ پس معکوسِ ترتیبِ پایان، به همهٔ یالها احترام میگذارد.
List<Integer> topoDfs(int n, List<List<Integer>> adj) {
int[] color = new int[n]; // همان طرح سهرنگ برای تشخیص دور هم به کار میرود
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i < n; i++)
if (color[i] == 0) topoVisit(i, adj, color, stack);
List<Integer> order = new ArrayList<>(n);
while (!stack.isEmpty()) order.add(stack.pop()); // معکوس ترتیب پایان
return order;
}
void topoVisit(int u, List<List<Integer>> adj, int[] color, Deque<Integer> stack) {
color[u] = 1;
for (int v : adj.get(u)) {
if (color[v] == 1) throw new IllegalStateException("cycle");
if (color[v] == 0) topoVisit(v, adj, color, stack);
}
color[u] = 2;
stack.push(u); // هل دادن در postorder
}
هر دو روش O(V + E) هستند. Kahn استدلالش سادهتر است و دور را تمیز تشخیص میدهد؛ DFS ظریفتر است و از همان ماشین سهرنگ بخش قبل دوباره استفاده میکند.
کوتاهترین مسیرها
اینجا یک قانون سخت داریم: الگوریتم را با وزن یالها تطبیق بده. انتخاب اشتباه، برای مصاحبهگر یک پرچم قرمز است. این جدول را حفظ کن؛ عملاً کل تصمیمگیری همین است:
| وضعیت | الگوریتم | پیچیدگی |
|---|---|---|
| بیوزن (یا همه برابر) | BFS | O(V + E) |
| وزن ∈ {0,1} | 0-1 BFS (deque) | O(V + E) |
| وزن نامنفی | Dijkstra (heap دودویی) | O((V + E) log V) |
| هر وزنی، تشخیص دور منفی | Bellman–Ford | O(V · E) |
| همهٔ جفتها، V کوچک | Floyd–Warshall | O(V³) |
| DAG، هر وزنی | ترتیب توپولوژیک + relax | O(V + E) |
دایکسترا (Dijkstra)
یک قطره جوهر روی دستمال بریز. جوهر اول به نزدیکترین نقاط میرسد و بعد آرامآرام به دورترها. دایکسترا هم همینطور حریصانه (greedy) جلو میرود: در هر گام، از میان رأسهایی که هنوز نهایی نشدهاند، آنکه کمترین فاصلهٔ موقت را دارد قطعی میکند و از آنجا مسیرها را بهروزرسانی میکند. عمل بهروزرسانی فاصلهٔ یک همسایه را relax کردن میگویند: «اگر از این راه ارزانتر میرسم، فاصلهاش را کم کن.»
نکتهٔ حیاتی: دایکسترا فقط با وزن نامنفی درست کار میکند. چرا؟ چون فرض حریصانهاش این است که وقتی یک رأس را نهایی کرد، دیگر مسیر ارزانتری پیدا نمیشود. یک یال با وزن منفی میتواند این فرض را بشکند و رأسی را که قبلاً «قطعی» کردهای ارزانتر کند.
int[] dijkstra(List<List<int[]>> adj, int src) { // adj: int[]{to, weight}
int n = adj.size();
int[] dist = new int[n];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[src] = 0;
// {فاصله، رأس}، مرتب بر اساس فاصله
PriorityQueue<int[]> pq = new PriorityQueue<>(Comparator.comparingInt(a -> a[0]));
pq.add(new int[]{0, src});
while (!pq.isEmpty()) {
int[] top = pq.poll();
int d = top[0], u = top[1];
if (d > dist[u]) continue; // ورودی کهنه (stale) — رد کن. حیاتی است.
for (int[] e : adj.get(u)) {
int v = e[0], w = e[1];
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
pq.add(new int[]{dist[v], v}); // حذف تنبل: یک ورودی تازه هل بده
}
}
}
return dist;
}
دو چیز پاسخ درست را از پاسخ باگدار جدا میکند:
PriorityQueue جاوا عمل decrease-key ندارد؛ یعنی نمیتوانی مستقیم بگویی «فاصلهٔ این رأس در heap را کم کن». پس ترفند این است: هر بار که فاصلهٔ بهتری پیدا شد یک {dist, v} تازه هل میدهی و ورودی قدیمی را همانجا رها میکنی. حالا heap ممکن است چند نسخهٔ کهنه از یک رأس داشته باشد. نگهبان if (d > dist[u]) continue; این ورودیهای قدیمی را هنگام بیرونآمدن دور میریزد. بدون این نگهبان هم جواب درست میگیری، اما با کلی کار هدررفته — پس نگهش دار.
dist[u] + w وقتی dist[u] برابر Integer.MAX_VALUE باشد (رأس هنوز نادیده)، سرریز میکند و به یک عدد منفی میپیچد — و ناگهان همهچیز خراب میشود. یا کل فاصلهها را long بگیر، یا فقط وقتی relax کن که dist[u] != Integer.MAX_VALUE.
برای بازیابی مسیر واقعی (نه فقط طولش)، یک آرایهٔ int[] prev نگه دار و در هر relax موفق prev[v] = u را تنظیم کن؛ بعد از مقصد رو به عقب راه برو تا به مبدأ برسی.
بلمن-فورد (Bellman–Ford)
وقتی وزنها میتوانند منفی باشند، یا وقتی باید وجود دور منفی را تشخیص بدهی، دایکسترا بیفایده است و باید سراغ بلمن-فورد بروی.
تصور کن یک شایعه در شهری با V نفر پخش میشود و هر بار فقط از یک نفر به نفر کناریاش میرسد. طولانیترین زنجیرهٔ ممکن V − 1 نفر است. پس اگر V − 1 دور کامل، همهٔ یالها را relax کنی، مطمئنی هر کوتاهترین مسیر (که حداکثر V − 1 یال دارد) نهایی شده. حالا یک دور اضافه بزن: اگر باز هم چیزی بهبود یافت، یعنی مسیری هست که هرچه بیشتر دورش بزنی کوتاهتر میشود — این نشانهٔ قطعی یک دور منفی (negative cycle) است.
long[] bellmanFord(int n, List<int[]> edges, int src) { // edges: {u, v, w}
long[] dist = new long[n];
Arrays.fill(dist, Long.MAX_VALUE);
dist[src] = 0;
for (int i = 0; i < n - 1; i++) {
for (int[] e : edges) {
int u = e[0], v = e[1], w = e[2];
if (dist[u] != Long.MAX_VALUE && dist[u] + w < dist[v])
dist[v] = dist[u] + w;
}
}
for (int[] e : edges) { // یک پیمایش دیگر -> اگر relax شد، دور منفی است
int u = e[0], v = e[1], w = e[2];
if (dist[u] != Long.MAX_VALUE && dist[u] + w < dist[v])
throw new IllegalStateException("negative cycle reachable from src");
}
return dist;
}
این همان الگوریتمی است که در مسائل واقعی مثل تشخیص فرصت آربیتراژ (سود از حلقهٔ تبدیل ارز) به کار میرود — چون آربیتراژ دقیقاً یک دور منفی در گراف نرخهاست.
کوتاهترین مسیر روی DAG
اگر گراف DAG باشد (بدون دور)، حتی با وزنهای منفی، راهحل سادهتر و سریعتری داری: رأسها را به ترتیب توپولوژیک پردازش کن و یالهای خروجی هرکدام را relax کن. چون هر رأس پیش از همهٔ جانشینانش نهایی میشود، فقط یک پیمایش کافی است — وزن منفی هم مشکلی نیست — و در O(V + E) تمام میشود. همین چارچوب، اسکلت مسائل طولانیترین مسیر / مسیر بحرانی (CPM) هم هست: کافی است وزنها را منفی کنی یا بهجای min با max relax کنی.
Union-Find (مجموعههای مجزا / Disjoint Set Union)
هر نفر عضو یک باشگاه است. دو عمل داری: «این نفر عضو کدام باشگاه است؟» و «این دو باشگاه را یکی کن». برای اینکه سریع بفهمی هر کس عضو کجاست، هر باشگاه یک رئیس (ریشه) دارد؛ برای پرسیدن عضویت، از هر نفر میپرسی رئیست کیست تا به رأس هرم برسی. Union-Find همین ساختار است و هر دو عمل را تقریباً در زمان ثابت انجام میدهد.
این ساختار به همبندی پویا پاسخ میدهد («آیا x و y الان در یک مجموعهاند؟»)، موتور MST کروسکال است، و هنگام افزودن یالهای بدونجهت دور را تشخیص میدهد. دو بهینهسازی آن را برقآسا میکند:
- اجتماع بر اساس رتبه/اندازه (union by rank/size): هنگام ادغام، درخت کوچکتر را زیر بزرگتر بچسبان تا درختها کمعمق بمانند (باشگاه کوچک زیر پرچم باشگاه بزرگ میرود، نه برعکس).
- فشردهسازی مسیر (path compression): در حین
find، هر رأسی که سر راهت است را مستقیماً به ریشه وصل کن تا دفعهٔ بعد کوتاهتر بپرسی.
با هر دو بهینهسازی، هزینهٔ سرشکنِ (amortized، یعنی میانگین روی دنبالهای از عملها) هر عمل O(α(n)) میشود. اینجا α وارون تابع آکرمان (inverse Ackermann) است — تابعی که آنقدر آهسته رشد میکند که برای هر n واقعی در جهان، مقدارش ≤ ۴ است. عملاً یعنی «تقریباً زمان ثابت». اگر فقط یکی از دو بهینهسازی را داشته باشی، به O(log n) میرسی؛ با هر دو، تقریباً ثابت.
class DSU {
private final int[] parent, rank;
private int count; // تعداد مجموعههای مجزا
DSU(int n) {
parent = new int[n];
rank = new int[n];
count = n;
for (int i = 0; i < n; i++) parent[i] = i; // هر رأس ریشهٔ خودش
}
int find(int x) { // فشردهسازی مسیر (تکراری، بدون سرریز پشته)
int root = x;
while (root != parent[root]) root = parent[root];
while (x != root) { int next = parent[x]; parent[x] = root; x = next; }
return root;
}
boolean union(int a, int b) { // اگر از قبل در یک مجموعه باشند false برمیگرداند
int ra = find(a), rb = find(b);
if (ra == rb) return false; // اگر این یک یال بود، یک دور ایجاد میشد
if (rank[ra] < rank[rb]) { int t = ra; ra = rb; rb = t; }
parent[rb] = ra; // رتبهٔ کوچکتر زیر بزرگتر
if (rank[ra] == rank[rb]) rank[ra]++;
count--;
return true;
}
boolean connected(int a, int b) { return find(a) == find(b); }
int components() { return count; }
}
توجه کن find را عمداً تکراری نوشتهایم نه بازگشتی — تا روی زنجیرههای خیلی بلند دچار StackOverflowError نشویم.
تشخیص دور با DSU (بدونجهت): روی یالها پیمایش کن؛ اگر union(u, v) مقدار false برگرداند، یعنی دو سرِ یال از قبل در یک مجموعه بودند، پس این یال یک دور میبندد. این تمیزترین راه تشخیص دور در همان حین ساختن گراف است و یالهای موازی را هم طبیعتاً درست مدیریت میکند.
boolean hasCycle(int n, List<int[]> edges) {
DSU dsu = new DSU(n);
for (int[] e : edges)
if (!dsu.union(e[0], e[1])) return true; // یال درون یک درخت موجود
return false;
}
عمل union متقارن است (a با b همان b با a است)، پس جهت را نمیفهمد. برای تشخیص دور در گراف جهتدار حتماً باید سراغ همان DFS سهرنگ (سفید/خاکستری/سیاه) بروی، نه DSU.
درخت پوشای کمینه (MST)
میخواهی همهٔ شهرها را با فیبر نوری به هم وصل کنی طوریکه هر شهر به شبکه برسد، و کمترین طول کابل خرج شود. لازم نیست بین هر دو شهر مستقیم کابل بکشی — فقط باید همه بهنوعی به هم متصل باشند و حلقهٔ زائد (کابل اضافهای که فقط دور میزند) نداشته باشی. جواب بهینه یک درخت پوشای کمینه (minimum spanning tree) است.
بهطور رسمی: با یک گراف همبندِ وزندارِ بدونجهت، MST زیرمجموعهای از یالهاست که همهٔ رأسها را با کمترین وزن کل به هم وصل میکند — دقیقاً V − 1 یال و بدون هیچ دور. هر دو الگوریتم زیر بر یک اصل واحد تکیه دارند:
اگر رأسها را به هر شکل به دو دسته بشکنی (یک «برش»)، آنگاه کموزنترین یالی که این دو دسته را به هم وصل میکند حتماً در یکی از MSTها هست. این جملهٔ ساده، دلیل درستی هر دو الگوریتم کروسکال و پریم است — هر دو در واقع دارند بارها همین قانون را اعمال میکنند.
کروسکال (Kruskal) — یالها را مرتب کن، حریصانه اضافه کن، DSU برای رد دور
int kruskalMST(int n, List<int[]> edges) { // edges: {u, v, w}
edges.sort(Comparator.comparingInt(e -> e[2])); // سبکترین اول
DSU dsu = new DSU(n);
int total = 0, used = 0;
for (int[] e : edges) {
if (dsu.union(e[0], e[1])) { // افزودنش دور ایجاد نمیکند
total += e[2];
if (++used == n - 1) break; // MST کامل شد
}
}
if (used != n - 1) throw new IllegalStateException("graph is not connected");
return total;
}
منطقش شهودی است: یالها را از سبک به سنگین مرتب کن، هر یال را بهترتیب امتحان کن؛ اگر دو سرش قبلاً وصل نبودند (یعنی union مقدار true داد) برش دار، وگرنه ردش کن چون فقط دور میسازد. هزینه O(E log E) است که مرتبسازی بر آن غالب است. بهترین حالت وقتی گراف تنک است یا یالها از قبل مرتباند. کروسکال، کاربرد شاخصِ Union-Find است.
پریم (Prim) — یک درخت را رشد بده، همیشه ارزانترین یال خروجی را بردار
int primMST(List<List<int[]>> adj, int n) { // adj: {to, weight}
boolean[] inTree = new boolean[n];
PriorityQueue<int[]> pq = new PriorityQueue<>(Comparator.comparingInt(a -> a[0]));
pq.add(new int[]{0, 0}); // {وزن، رأس}، شروع از ۰
int total = 0, picked = 0;
while (!pq.isEmpty() && picked < n) {
int[] top = pq.poll();
int w = top[0], u = top[1];
if (inTree[u]) continue; // قبلاً جذب شده — ورودی کهنه را رد کن
inTree[u] = true;
total += w;
picked++;
for (int[] e : adj.get(u))
if (!inTree[e[0]]) pq.add(new int[]{e[1], e[0]});
}
if (picked != n) throw new IllegalStateException("graph is not connected");
return total;
}
پریم بهجای مرتبکردن همهٔ یالها، یک درخت را از یک رأس شروع میکند و در هر گام ارزانترین یالی که از درخت فعلی بیرون میزند را میقاپد. هزینه O(E log V) با heap دودویی است. پریم روی گرافهای چگال میدرخشد (بهویژه با Fibonacci heap یا نسخهٔ ماتریسی O(V²)).
از نظر ساختاری پریم و دایکسترا یک اسکلت دارند: هر دو یک درخت را با heap حریصانه رشد میدهند. تنها تفاوت، کلید heap است. در دایکسترا کلید «فاصلهٔ انباشته از مبدأ» (dist[u] + w) است؛ در پریم کلید «وزن تکیالِ» عبورکننده از برش (w) است. توجه کن در کد پریم، heap فقط w را نگه میدارد نه مسیر انباشته را. کلید relax را عوض کن، یکی به دیگری تبدیل میشود.
انتخاب بین این دو: ورودیِ تنک یا لیستِ یال ← کروسکال؛ گراف چگال یا وقتی از قبل لیست مجاورت داری ← پریم. هر دو کمترین وزن کل را میدهند (اگر همهٔ وزنها متمایز باشند مجموع یکتاست؛ ولی خودِ درخت ممکن است یکتا نباشد).
دامهای رایج
این فهرست را قبل از هر مصاحبه یک بار مرور کن — هر کدام یک اشتباه واقعی است که سطح ارشد را میسوزاند.
- علامتگذاری BFS هنگام dequeue بهجای enqueue ← رأسها بارها وارد صف، فاصلهها غلط.
- دایکسترا با یال منفی ← پاسخ بیسروصدا غلط. سراغ Bellman-Ford برو.
- فراموشکردن نگهبان ورودی کهنه در دایکسترا/پریمِ heap-محور ← کندتر، و در صورت مدیریت نادرست، غلط.
- سرریز صحیح روی
dist + wنزدیکInteger.MAX_VALUE← فاصلههای منفی. ازlongیا نگهبان استفاده کن. - تشخیص دور جهتدار با
visitedساده ← مثبت کاذب از یالهای عرضی. به حالت رویپشته (خاکستری) نیاز داری. - تشخیص دور بدونجهت با نادیدهگرفتن والد ← هر یال درختی شبیه دور. والد بلافصل را رد کن (اما مراقب یالهای موازی باش).
- بازگشت عمیق روی گرافهای با مسیر بلند ←
StackOverflowError. به تکراری تبدیل کن یا-Xssرا بالا ببر. - گرافهای ناهمبند ← یک اجرای BFS/DFS مؤلفهها را از دست میدهد؛ روی همهٔ رأسهای شروع حلقه بزن. MST روی گراف ناهمبند اصلاً وجود ندارد (یک جنگل میگیری).
- استفاده از ماتریس مجاورت برای گراف تنک بزرگ ← انفجار حافظهٔ
O(V²).
بهترین شیوهها
- در مسیرهای داغ (hot path)، پیشفرض را لیست مجاورت با ساختارهای دوستدار primitive بگذار.
- DSU را یک بار کپسوله کن و همهجا بازاستفاده کن — دائماً ظاهر میشود (MST، همبندی، «ادغام حسابها»، «تعداد جزیرهها II»، «اتصال زائد»).
- جایی که عمق بازگشت با اندازهٔ ورودی نامحدود است، BFS/DSU تکراری را ترجیح بده.
- پیچیدگی و دلیل درستی الگوریتم برای پروفایل وزنِ دادهشده را بیان کن؛ مصاحبهگر توجیه را نمره میدهد، نه فقط کد را.
- هنگام مدلسازی، ابتدا
V،E، جهت و بازهٔ وزن را بنویس — الگوریتم از این چهار واقعیت بیرون میآید.
سؤالات مصاحبه
BFS روی گراف بیوزن کوتاهترین مسیر میدهد، چون بر اساس حلقههای همفاصله (مثل موج) پیش میرود و اولین باری که به رأسی میرسد از کوتاهترین راه است. DFS بهطور کلی کوتاهترین مسیر را نمییابد — چون به عمق متعهد میشود و ممکن است اول از یک مسیر بلند به رأس برسد. برای گرافهای وزندار نامنفی، هیچکدام؛ از دایکسترا استفاده کن.
دایکسترا یک رأس را در اولین pop نهایی میکند، با این فرض که دیگر مسیر ارزانتری وجود ندارد. اما یک یال منفی از رأسی که بعداً پردازش میشود میتواند مسیر ارزانتری به آن رأسِ از قبل نهاییشده بدهد و این ناوردا را نقض کند. یعنی فرض حریصانهٔ «نزدیکترینِ بازدیدنشده قطعاً تمام است» شکست میخورد. Bellman-Ford چنین فرضی ندارد چون همهٔ یالها را V−1 بار relax میکند.
DFS سهرنگ: دور فقط وقتی وجود دارد که به رأس خاکستری (رویپشته / جد) برسی. یک visited ساده رأسهای سیاه را هم علامت میزند — اما رسیدن به رأسی که کاملاً تمام شده از راه یال عرضی یا پیشرو، دور نیست. خلط این دو حالت، مثبت کاذب تولید میکند.
بدونجهت: DFS، و دور اگر به رأس بازدیدشدهای برسی که والدت نیست — یا از DSU استفاده کن و وقتی union دو سرِ از قبل همبند را یافت پرچم بزن. جهتدار: DSU کار نمیکند (اجتماع متقارن است)؛ باید از DFS سهرنگ با تشخیص رویپشته استفاده کنی. ابزار متفاوت برای ساختار متفاوت.
یعنی رأسهای باقیمانده همه برای همیشه درجهٔ ورودیِ مثبت دارند، پس درون یا بعد از یک دور گیر کردهاند و هیچ ترتیب توپولوژیکی وجود ندارد. Kahn دور را رایگان تشخیص میدهد: emitted != V ⇐ گراف دوردار است.
اجتماع بر اساس رتبه، درخت کوتاهتر را زیر بلندتر میچسباند تا ارتفاع کند رشد کند. فشردهسازی مسیر، مسیر تا ریشه را در حین find مسطح میکند. با هم، سرشکن O(α(n)) به ازای هر عمل میدهند — وارون آکرمان، که برای هر n عملی ≤ ۴ است. هر یک بهتنهایی O(log n) میدهد؛ هر دو با هم، تقریباً ثابت.
کروسکال یالها را مرتب میکند و از DSU برای رد دور استفاده میکند — عالی برای گراف تنک یا لیست یال، O(E log E). پریم یک درخت را از طریق heap یالهای مرزی رشد میدهد — بهتر روی گراف چگال، O(E log V) یا O(V²) با ماتریس. هر دو بر خاصیت برش تکیه دارند؛ هر دو همان مجموع کمینه را میدهند.
هر دو رشد درختِ حریصانهٔ heap-محورند. در دایکسترا کلید فاصلهٔ انباشته از مبدأ (dist[u] + w) است؛ در پریم وزن تکیال (w) است که برش جاری را قطع میکند. کلید relax را عوض کن، یکی به دیگری تبدیل میشود.
Deque<Integer> q = new ArrayDeque<>();
q.add(src);
while (!q.isEmpty()) {
int u = q.poll();
visited[u] = true; // <-- اینجا
for (int v : adj.get(u))
if (!visited[v]) q.add(v);
}
علامتگذاری visited هنگام dequeue است. یک رأس میتواند بارها پیش از poll شدن وارد صف شود، پس بارها پردازش میشود — انفجار درجهدو/نمایی، و در نسخههای فاصلهٔ وزندار فاصلههای غلط. اصلاح: در لحظهٔ q.add(v) علامت بزن.
// یالهای جهتدار: 0->1, 1->2, 2->0, 3->2. فراخوانی topoKahn(4, adj).
یک IllegalStateException("graph has a cycle") پرتاب میکند. رأسهای ۰، ۱، ۲ یک دور جهتدار میسازند، پس هیچکدام هرگز به درجهٔ ورودی صفر نمیرسند؛ فقط رأس ۳ خروجی میشود (idx = 1 != 4) و بررسی دور فعال میشود. کسی که بگوید «۳،۰،۱،۲ چاپ میشود» ندیده که دور، Kahn را قحطیزده میکند.
با 0-1 BFS و یک deque: یال وزن ۰ را با هلدادن همسایه به جلو و یال وزن ۱ را به عقب relax کن. deque از نظر فاصله یکنوا میماند، پس O(V + E) میشود نه O((V+E) log V).
relax یالهای Bellman-Ford را V−1 بار اجرا کن — کافی است چون هر کوتاهترین مسیر ≤ V−1 یال دارد. اگر پیمایش V-ام هنوز یالی را relax کند، آن یال روی (یا پاییندستِ) یک دور منفی قرار دارد، چون در نبود چنین دوری مسیرهای متناهی باید تا آن مرحله پایدار شده باشند. آن پیمایش اضافی، همان آشکارساز است.
حذف تنبل (lazy deletion): در هر بهبود یک {newDist, v} تازه هل بده و ورودیهای کهنه را در heap رها کن. هنگام pop، هر ورودیای که فاصلهٔ ذخیرهشدهاش از dist[u] بیشتر است را رد کن (if (d > dist[u]) continue;). درستی برقرار است چون کوچکترین ورودیِ زندهٔ هر رأس همیشه اول پردازش میشود.
رأسها = درسها، یال جهتدار پیشنیاز → درس. Kahn را اجرا کن: اگر همهٔ V را خروجی داد، ترتیب معتبر است؛ اگر کمتر، یک دور پیشنیازی آن را ناممکن میکند. O(V + E). این همان «Course Schedule I/II» در LeetCode است — نشانهٔ کلاسیک مرتبسازی توپولوژیک.
Union-Find. هر union و connected سرشکن O(α(n)) است و ماهیت افزایشی (incremental) مسئله را طبیعتاً مدیریت میکند. BFS/DFS بهازای هر پرسوجو O(V+E) میشد — بسیار بدتر. (اگر یالها حذف هم میشدند، DSU بهتنهایی شکست میخورد؛ آن حالت نیازمند link-cut tree یا تکنیکهای آفلاین است.)
گراف = (V, E)، و مهارت اصلی این است که در هر مسئله رأسها/یالها/وزنها را ببینی و قبل از کدنویسی چهار سؤال بپرسی: جهتدار است؟ وزندار (یا منفی)؟ تنک یا چگال؟ DAG یا عمومی؟ جواب این چهار، الگوریتم را انتخاب میکند. لیست مجاورت پیشفرض ذخیرهسازی است. BFS = کوتاهترین مسیرِ بیوزن و موتور علامتگذاری هنگام enqueue؛ DFS = عمق و postorder که مرتبسازی توپولوژیک و SCC را میراند. دور بدونجهت با «رأس بازدیدشدهٔ غیرِوالد» یا DSU، و دور جهتدار فقط با سهرنگ (خاکستری = روی پشته). برای کوتاهترین مسیر جدول وزن را دنبال کن: BFS، 0-1 BFS، دایکسترا (نامنفی، با نگهبان کهنگی و مراقب سرریز)، Bellman-Ford (منفی/دور منفی، تکرار V-ام آشکارساز است)، یا DAG. Union-Find همبندی پویا و کروسکال را میراند و در O(α(n)) تقریباً رایگان است. MST را با کروسکال (تنک) یا پریم (چگال) بگیر و بدان پریم همان دایکستراست با کلیدِ متفاوت. و همیشه یادت باشد: مصاحبهگر توجیه درستی را نمره میدهد، نه فقط کدِ درست را.
Let's start with the secret that separates seniors from juniors: most hard interview problems are secretly graph problems wearing a disguise. A city map, a grid of cells, a list of course prerequisites, an app's friend network, even "can I get from state A to state B under these rules" — they are all graphs. Once you learn to see the graph hidden inside a problem, you are halfway to the solution. This chapter teaches you that seeing from the ground up, then unpacks the algorithmic toolbox one piece at a time.
First we build the vocabulary (vertex, edge, weight, direction, degree, density) with analogies. Then the three ways to represent a graph in memory. Then the two fundamental traversals, BFS and DFS, and everything that grows out of them: counting connected components, cycle detection (directed and undirected), and topological sort. Next, shortest paths (BFS, Dijkstra, Bellman-Ford, DAG). Finally Union-Find and minimum spanning trees (Kruskal and Prim), a list of common pitfalls, and fifteen fully answered interview questions. All with compilable Java.
Part 0 — words you must know
Before anything, let's attach a mental picture to each word so none of them feel foreign later.
Picture a map of a country in front of you. Cities are dots on the map and roads connect them. In graph language, each city is a vertex and each road is an edge. If a road is labeled "12 km," that number is the edge's weight. If the road is one-way, the edge is directed; if two-way, undirected. The number of roads touching a city is that city's degree.
With that picture the formal definition becomes easy: a graph is G = (V, E) — a set of vertices V and a set of edges E ⊆ V × V (each edge is a pair of vertices). Two symbols you'll see all chapter:
V = |V|means the number of vertices.E = |E|means the number of edges.
Every time and space complexity below is written in terms of these two numbers.
Two more adjectives we'll need:
A sparse graph has few roads — roughly as many as cities (E ≈ V). Like a real country map where each city connects to only a handful of neighbors. A dense graph has almost everyone connected to everyone (E ≈ V²). Like a party where every person has shaken every other person's hand. This distinction decides how you store the graph and which algorithm you pick.
Finally, DAG: short for Directed Acyclic Graph, a directed graph with no cycles. "Acyclic" means you can't start at a vertex, follow the edges' direction, and end up back at it. This family is important enough to have several algorithms of its own.
Mental model: four questions before any algorithm
The real skill is modeling: reading a word problem and spotting the vertices, edges, and weights, then reaching for the right traversal. A 2D grid is a graph (each cell a vertex, each adjacency an edge), a dependency chain is a graph, a state machine is a graph.
Before writing a line of code, ask the problem these four questions. Their answers almost always choose the algorithm for you.
| Axis | Options | Consequence |
|---|---|---|
| Direction | directed / undirected | changes cycle detection, connectivity, topo sort applicability |
| Weights | unweighted / weighted / negative weights | BFS vs Dijkstra vs Bellman-Ford |
| Density | sparse (E ≈ V) / dense (E ≈ V²) |
list vs matrix; affects Dijkstra heap choice |
| Cyclicity | DAG / general | topo sort, DP on a DAG |
If you write those four facts (direction, weights, density, cyclicity) on paper before coding, in half your interviews the algorithm falls right out.
Representations: how to store a graph in memory
Before any algorithm, we must store the graph. There are three common ways, each built for a different job.
Adjacency list
Imagine every city keeps a booklet listing only the cities it has a direct road to. To find a city's neighbors you just open its own booklet; you never flip through the whole country. That is exactly an adjacency list.
Each vertex stores its list of neighbors. Space is O(V + E) (as much as vertices plus edges, no more). Iterating a vertex's neighbors is O(deg(v)) — as many as that city's roads. Checking whether a specific edge (u, v) exists is O(deg(u)) unless you store neighbors in a set.
// V vertices labeled 0..V-1. Weighted directed edges.
class Graph {
record Edge(int to, int weight) {}
final List<List<Edge>> adj;
Graph(int v) {
adj = new ArrayList<>(v);
for (int i = 0; i < v; i++) adj.add(new ArrayList<>());
}
void addDirected(int u, int v, int w) { adj.get(u).add(new Edge(v, w)); }
void addUndirected(int u, int v, int w) {
adj.get(u).add(new Edge(v, w));
adj.get(v).add(new Edge(u, w)); // both directions
}
}
Notice addUndirected adds the edge in both directions — a two-way road is seen from both cities. For a purely unweighted graph, int[][] adj where adj[u] is a plain neighbor array is leaner and more cache-friendly (data sits contiguously, so the CPU reads it faster).
Adjacency matrix
Now imagine a big V by V table, rows and columns labeled by cities, and in the cell where city u meets city v you write "is there a road?" (or its weight). To ask "are these two directly connected?" you glance at one cell — instant. But for a country of millions of barely-connected cities, this table is a colossal waste of paper.
This is boolean[V][V] (or int[V][V] for weights). Space is O(V²). An edge-existence check is O(1) (constant time) — one cell lookup. But iterating neighbors is O(V) regardless of degree (you must scan a whole row), and the memory is prohibitive for large sparse graphs. Reach for it only when V is small (say ≤ ~1000), the graph is dense, or you truly need constant-time edge queries (e.g. Floyd–Warshall).
int[][] w = new int[V][V];
for (int[] row : w) Arrays.fill(row, INF);
for (int i = 0; i < V; i++) w[i][i] = 0;
// w[u][v] = weight of edge u->v, INF if none
Edge list
Just a List<int[]> of triples {u, v, w} — "from u to v with weight w." Compact and simple, and exactly what Kruskal's MST and Bellman-Ford consume. Its downside: to ask "who are u's neighbors?" you'd scan the whole list, so it's bad for traversal.
The default is always the adjacency list, unless a specific algorithm (matrix DP, Floyd–Warshall) or a dense graph demands otherwise. When in doubt, pick the adjacency list; it's right for about 90% of problems.
BFS — breadth-first search
Drop a stone in a pond. The ripple spreads in concentric rings: first everything one step away, then everything two steps away, and so on. BFS is exactly this — it starts at a source and explores the graph in rings of increasing distance. Because it sees all the distance-1 points before any distance-2 point, the first time it reaches a vertex it reached it by the shortest path.
To keep these rings in order, BFS uses a FIFO queue (First-In-First-Out; whoever entered first leaves first — like a bakery line). On an unweighted graph it computes shortest paths (fewest edges) in O(V + E). This is the single most reused graph technique in interviews: every "fewest steps / moves / transformations" problem is secretly BFS.
int[] bfsDistances(List<List<Integer>> adj, int src) {
int n = adj.size();
int[] dist = new int[n];
Arrays.fill(dist, -1); // -1 = unvisited
Deque<Integer> q = new ArrayDeque<>();
dist[src] = 0;
q.add(src);
while (!q.isEmpty()) {
int u = q.poll();
for (int v : adj.get(u)) {
if (dist[v] == -1) { // mark on ENQUEUE, not on dequeue
dist[v] = dist[u] + 1;
q.add(v);
}
}
}
return dist;
}
The most common mistake: marking a node visited when you dequeue it instead of when you enqueue it. If you mark on exit, a node can be pushed onto the queue many times (it's still "unvisited" until it's actually polled), blowing work up exponentially and sometimes producing wrong distances. Always set dist[v] or visited[v] at the very moment you add it to the queue.
Two important BFS variants that come up constantly:
- Multi-source BFS: seed the queue with all sources at distance 0 (think "rotting oranges" or "nearest exit"). In one pass it computes, for every cell, the distance to the nearest source — like dropping several stones in the pond at once.
- 0-1 BFS: when edge weights are only 0 or 1, use a deque (double-ended queue) instead of a plain queue: push 0-weight edges to the front, 1-weight edges to the back. Gives Dijkstra-quality results at
O(V + E)cost.
DFS — depth-first search
You enter a maze trailing a string from the entrance. You walk one corridor all the way to its end; when you hit a dead end you backtrack along the string to the last unexplored fork and continue from there. DFS is just this: go as deep as possible, then back up.
The recursive version is the most natural way to write it. An iterative version with an explicit stack avoids stack overflow on very deep graphs (the JVM's default stack handles roughly 10k–20k call frames — a graph with one long path of 100k nodes will throw StackOverflowError).
void dfs(int u, List<List<Integer>> adj, boolean[] visited) {
visited[u] = true;
// preorder work here
for (int v : adj.get(u)) {
if (!visited[v]) dfs(v, adj, visited);
}
// postorder work here (finish time)
}
Two words matter here: preorder work is what you do before descending into children, and postorder work is what you do after all children finish. The moment all of a vertex's descendants are done is that vertex's finish time.
DFS is the backbone of: connected components, cycle detection, topological sort, bridges/articulation points (Tarjan's algorithm), and strongly-connected components (SCC, via Tarjan or Kosaraju). The key idea powering topo sort and SCC is that postorder finish time.
When you run DFS on a directed graph, every edge becomes one of four types: a tree edge reaches a fresh vertex; a back edge points to an ancestor still on the recursion stack; forward and cross edges point to already-finished vertices. The golden fact: a back edge is exactly what signals a cycle in a directed graph. Hold onto this; we need it again in cycle detection.
Connected components (undirected)
A connected component is an isolated piece of the graph whose vertices are all reachable from each other but not from the rest — like several separate islands with internal roads but no bridges between them. We want to count or label these islands.
The method is simple: launch a BFS (or DFS) from every not-yet-visited vertex; every time you're forced to start fresh, you've found a new island.
int countComponents(int n, List<List<Integer>> adj) {
boolean[] seen = new boolean[n];
int components = 0;
for (int i = 0; i < n; i++) {
if (!seen[i]) {
components++;
Deque<Integer> q = new ArrayDeque<>();
seen[i] = true; q.add(i);
while (!q.isEmpty()) {
int u = q.poll();
for (int v : adj.get(u))
if (!seen[v]) { seen[v] = true; q.add(v); }
}
}
}
return components;
}
Total cost is O(V + E). But note: this works when you have the whole graph at once. For dynamic connectivity — edges arriving one at a time while you keep asking "are these two connected now?" — the right tool is something else called Union-Find, which we'll meet shortly.
Cycle detection
A "cycle" is a path that starts at a vertex and returns to it. Cycle detection is completely different for directed vs undirected graphs, and that's where the common mistakes live.
Undirected case
An undirected cycle exists if, during DFS, you reach an already-visited neighbor that is not the parent you just came from.
In an undirected graph the road is two-way. When you go from A to B, from B's view there's an edge back to A that is "visited." If you naively say "I reached a visited node, so it's a cycle!" you'll mistake every tree edge for a cycle. The fix: ignore the vertex you just came from (the parent); only if you reach a different visited vertex is it a real cycle.
boolean hasCycleUndirected(int n, List<List<Integer>> adj) {
boolean[] visited = new boolean[n];
for (int i = 0; i < n; i++) {
if (!visited[i] && dfsUndir(i, -1, adj, visited)) return true;
}
return false;
}
boolean dfsUndir(int u, int parent, List<List<Integer>> adj, boolean[] visited) {
visited[u] = true;
for (int v : adj.get(u)) {
if (!visited[v]) {
if (dfsUndir(v, u, adj, visited)) return true;
} else if (v != parent) { // visited and not where we came from => cycle
return true;
}
}
return false;
}
One subtle trap: with parallel edges — two separate edges between the same pair of vertices — the v != parent test is insufficient, because two edges to the parent really are a genuine 2-cycle. There you'd use Union-Find or track edge identity.
Directed case
Here, reaching a "visited" neighbor is not enough. The vertex must be on the current recursion stack — one of your ancestors on the present path, not a vertex whose work already finished. To distinguish, we use three colors.
Back in the maze. Each room has three states: white means you haven't set foot in it; gray means you're inside it right now and your string still runs through it (it's on your current path); black means you fully explored it and left. If, while moving, you reach a room that is gray, you're returning to somewhere you're still inside — that's a cycle. Reaching a black room just means you got to a finished place by another route; not a cycle.
boolean hasCycleDirected(int n, List<List<Integer>> adj) {
int[] color = new int[n]; // 0=white, 1=gray, 2=black
for (int i = 0; i < n; i++)
if (color[i] == 0 && dfsDir(i, adj, color)) return true;
return false;
}
boolean dfsDir(int u, List<List<Integer>> adj, int[] color) {
color[u] = 1; // gray: on stack
for (int v : adj.get(u)) {
if (color[v] == 1) return true; // back edge -> cycle
if (color[v] == 0 && dfsDir(v, adj, color)) return true;
}
color[u] = 2; // black: fully explored
return false;
}
Only reaching a gray node is a cycle; reaching a black node is a finished subtree (a cross or forward edge), which is perfectly fine. Confusing "visited" with "on the stack" is a top-three senior interview mistake — take it seriously.
Topological sort (on DAGs)
You can't put shoes on before socks, or your jacket before your shirt. You have a bunch of "this before that" constraints. A topological sort lines up all the tasks so every dependency is respected: if A is a prerequisite of B, A comes first. The only requirement is that dependencies don't loop (you can't have A require B and B require A) — i.e. the graph must be a DAG.
Formally: a linear ordering of a DAG's vertices such that every edge u → v places u before v. This ordering exists if and only if the graph has no cycle. Uses: build systems, task scheduling, course prerequisites, package dependency resolution, and DP on a DAG. There are two common methods.
Kahn's algorithm (BFS / in-degree)
A vertex's in-degree is the number of edges pointing into it — how many prerequisites it has. Idea: repeatedly emit any vertex whose in-degree has dropped to 0 (no remaining prerequisites), and decrement its neighbors' in-degrees. Repeat.
int[] topoKahn(int n, List<List<Integer>> adj) {
int[] indeg = new int[n];
for (int u = 0; u < n; u++)
for (int v : adj.get(u)) indeg[v]++;
Deque<Integer> q = new ArrayDeque<>();
for (int i = 0; i < n; i++) if (indeg[i] == 0) q.add(i);
int[] order = new int[n];
int idx = 0;
while (!q.isEmpty()) {
int u = q.poll();
order[idx++] = u;
for (int v : adj.get(u))
if (--indeg[v] == 0) q.add(v);
}
if (idx != n) throw new IllegalStateException("graph has a cycle");
return order;
}
If you emit fewer than V vertices (idx != n), a cycle must exist. Why? Because the remaining vertices never reached in-degree 0 — meaning they're stuck inside a loop of dependencies. So Kahn both sorts and detects cycles for free.
A subtle follow-up an interviewer might ask: if you use a PriorityQueue instead of a plain queue, you get the lexicographically smallest topological order.
DFS-based topological sort
The second method: push each vertex onto a stack in postorder (after all its descendants finish), then reverse the stack. Intuition: a vertex only "finishes" after everything reachable from it has finished; so the reverse of finish order respects every edge.
List<Integer> topoDfs(int n, List<List<Integer>> adj) {
int[] color = new int[n]; // reuse the 3-color scheme to also detect cycles
Deque<Integer> stack = new ArrayDeque<>();
for (int i = 0; i < n; i++)
if (color[i] == 0) topoVisit(i, adj, color, stack);
List<Integer> order = new ArrayList<>(n);
while (!stack.isEmpty()) order.add(stack.pop()); // reverse of finish order
return order;
}
void topoVisit(int u, List<List<Integer>> adj, int[] color, Deque<Integer> stack) {
color[u] = 1;
for (int v : adj.get(u)) {
if (color[v] == 1) throw new IllegalStateException("cycle");
if (color[v] == 0) topoVisit(v, adj, color, stack);
}
color[u] = 2;
stack.push(u); // postorder push
}
Both methods are O(V + E). Kahn is easier to reason about and detects cycles cleanly; DFS is more elegant and reuses the three-color machinery from the previous section.
Shortest paths
Here's a hard rule: match the algorithm to the edge weights. Picking the wrong one is a red flag to interviewers. Memorize this table; it is essentially the whole decision:
| Situation | Algorithm | Complexity |
|---|---|---|
| Unweighted (or all equal) | BFS | O(V + E) |
| Weights ∈ {0,1} | 0-1 BFS (deque) | O(V + E) |
| Non-negative weights | Dijkstra (binary heap) | O((V + E) log V) |
| Any weights, detect negative cycle | Bellman–Ford | O(V · E) |
| All-pairs, small V | Floyd–Warshall | O(V³) |
| DAG, any weights | topo order + relax | O(V + E) |
Dijkstra
Drop a bead of ink on a napkin. It reaches the nearest points first, then slowly the farther ones. Dijkstra advances the same greedy way: at each step, among the not-yet-finalized vertices, it finalizes the one with the smallest tentative distance and updates paths from there. Updating a neighbor's distance is called relaxing it: "if I can reach it cheaper this way, lower its distance."
Crucial: Dijkstra is correct only with non-negative weights. Why? Its greedy assumption is that once it finalizes a vertex, no cheaper path exists. A negative-weight edge can break that assumption and make an already-"finalized" vertex cheaper.
int[] dijkstra(List<List<int[]>> adj, int src) { // adj: int[]{to, weight}
int n = adj.size();
int[] dist = new int[n];
Arrays.fill(dist, Integer.MAX_VALUE);
dist[src] = 0;
// {distance, vertex}, ordered by distance
PriorityQueue<int[]> pq = new PriorityQueue<>(Comparator.comparingInt(a -> a[0]));
pq.add(new int[]{0, src});
while (!pq.isEmpty()) {
int[] top = pq.poll();
int d = top[0], u = top[1];
if (d > dist[u]) continue; // STALE entry — skip. Critical.
for (int[] e : adj.get(u)) {
int v = e[0], w = e[1];
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
pq.add(new int[]{dist[v], v}); // lazy deletion: push a fresh entry
}
}
}
return dist;
}
Two things separate a correct answer from a buggy one:
Java's PriorityQueue has no decrease-key operation; you can't directly say "lower this vertex's distance in the heap." So the trick is: every time you find a better distance, push a fresh {dist, v} and leave the old one behind. Now the heap may hold several stale copies of a vertex. The guard if (d > dist[u]) continue; discards those outdated entries as they come out. Without it you still get the right answer, but with lots of wasted work — so keep it.
dist[u] + w when dist[u] equals Integer.MAX_VALUE (vertex not yet reached) overflows and wraps around to a negative number — and suddenly everything breaks. Either make distances long, or only relax when dist[u] != Integer.MAX_VALUE.
To recover the actual path (not just its length), keep an int[] prev array and set prev[v] = u on each successful relaxation; then walk backward from the target to the source.
Bellman–Ford
When weights can be negative, or when you must detect a negative cycle, Dijkstra is useless and you reach for Bellman-Ford.
Imagine a rumor spreading through a town of V people, passed one neighbor at a time. The longest possible chain is V − 1 people. So if you run V − 1 full passes relaxing all edges, you're sure every shortest path (which has at most V − 1 edges) is settled. Now do one extra pass: if something still improves, there must be a path that keeps getting shorter the more you loop it — the sure sign of a negative cycle.
long[] bellmanFord(int n, List<int[]> edges, int src) { // edges: {u, v, w}
long[] dist = new long[n];
Arrays.fill(dist, Long.MAX_VALUE);
dist[src] = 0;
for (int i = 0; i < n - 1; i++) {
for (int[] e : edges) {
int u = e[0], v = e[1], w = e[2];
if (dist[u] != Long.MAX_VALUE && dist[u] + w < dist[v])
dist[v] = dist[u] + w;
}
}
for (int[] e : edges) { // one more pass -> negative cycle if it relaxes
int u = e[0], v = e[1], w = e[2];
if (dist[u] != Long.MAX_VALUE && dist[u] + w < dist[v])
throw new IllegalStateException("negative cycle reachable from src");
}
return dist;
}
This is the algorithm behind real-world problems like arbitrage detection (profit from a loop of currency conversions) — because arbitrage is exactly a negative cycle in the graph of exchange rates.
Shortest path on a DAG
If the graph is a DAG (no cycle), then even with negative weights you have a simpler, faster solution: process vertices in topological order and relax each one's outgoing edges. Because a vertex is finalized before any of its successors, a single pass suffices — negative weights are fine — and it runs in O(V + E). The very same frame is the skeleton for longest-path / critical-path (CPM) problems: just negate the weights or relax with max instead of min.
Union-Find (Disjoint Set Union)
Every person belongs to a club. You have two operations: "which club is this person in?" and "merge these two clubs." To quickly know who belongs where, each club has a president (root); to answer membership, you keep asking each person "who's your president?" until you reach the top of the pyramid. Union-Find is exactly this structure, and it does both operations in near-constant time.
This structure answers dynamic connectivity ("are x and y in the same set now?"), powers Kruskal's MST, and detects cycles as you add undirected edges. Two optimizations make it lightning fast:
- Union by rank/size: when merging, attach the smaller tree under the larger to keep trees shallow (the small club moves under the big club's banner, not the reverse).
- Path compression: during
find, repoint every node you pass directly to the root, so next time the question is shorter.
With both optimizations, the amortized cost (the average over a sequence of operations) of each operation becomes O(α(n)). Here α is the inverse Ackermann function — a function that grows so unbelievably slowly that for any n in the real universe its value is ≤ 4. In practice, that means "essentially constant time." With only one of the two optimizations you get O(log n); with both, near-constant.
class DSU {
private final int[] parent, rank;
private int count; // number of disjoint sets
DSU(int n) {
parent = new int[n];
rank = new int[n];
count = n;
for (int i = 0; i < n; i++) parent[i] = i; // each node its own root
}
int find(int x) { // path compression (iterative, no stack overflow)
int root = x;
while (root != parent[root]) root = parent[root];
while (x != root) { int next = parent[x]; parent[x] = root; x = next; }
return root;
}
boolean union(int a, int b) { // returns false if already in same set
int ra = find(a), rb = find(b);
if (ra == rb) return false; // a cycle would be created if this were an edge
if (rank[ra] < rank[rb]) { int t = ra; ra = rb; rb = t; }
parent[rb] = ra; // attach smaller rank under larger
if (rank[ra] == rank[rb]) rank[ra]++;
count--;
return true;
}
boolean connected(int a, int b) { return find(a) == find(b); }
int components() { return count; }
}
Notice find is written iteratively on purpose, not recursively — to avoid StackOverflowError on very long chains.
Cycle detection with DSU (undirected): iterate the edges; if union(u, v) returns false, the two endpoints were already in the same set, so this edge closes a cycle. This is the cleanest way to detect a cycle while you're still building the graph, and it naturally handles parallel edges too.
boolean hasCycle(int n, List<int[]> edges) {
DSU dsu = new DSU(n);
for (int[] e : edges)
if (!dsu.union(e[0], e[1])) return true; // edge inside an existing tree
return false;
}
The union operation is symmetric (a-with-b is the same as b-with-a), so it can't understand direction. For cycle detection in a directed graph you must use the three-color DFS (white/gray/black), not DSU.
Minimum spanning tree (MST)
You want to connect all cities with fiber so every city reaches the network, using the least total length of cable. You don't need direct cable between every pair — you just need everyone connected somehow, with no redundant loop (an extra cable that merely goes around). The optimal answer is a minimum spanning tree.
Formally: given a connected, weighted, undirected graph, an MST is a subset of edges connecting all vertices with minimum total weight — exactly V − 1 edges and no cycle. Both algorithms below rest on a single principle:
If you split the vertices any way you like into two groups (a "cut"), then the minimum-weight edge crossing between the two groups is in some MST. This simple statement is why both Kruskal and Prim are correct — each is really just applying this rule over and over.
Kruskal — sort edges, add greedily, DSU to reject cycles
int kruskalMST(int n, List<int[]> edges) { // edges: {u, v, w}
edges.sort(Comparator.comparingInt(e -> e[2])); // lightest first
DSU dsu = new DSU(n);
int total = 0, used = 0;
for (int[] e : edges) {
if (dsu.union(e[0], e[1])) { // adding it doesn't create a cycle
total += e[2];
if (++used == n - 1) break; // MST complete
}
}
if (used != n - 1) throw new IllegalStateException("graph is not connected");
return total;
}
The logic is intuitive: sort edges from light to heavy, try each in turn; if its endpoints weren't already connected (union returned true) take it, otherwise skip it since it would only form a cycle. Cost is O(E log E), dominated by the sort. Best when the graph is sparse or edges are already sorted. Kruskal is the canonical use case for Union-Find.
Prim — grow one tree, always take the cheapest edge leaving it
int primMST(List<List<int[]>> adj, int n) { // adj: {to, weight}
boolean[] inTree = new boolean[n];
PriorityQueue<int[]> pq = new PriorityQueue<>(Comparator.comparingInt(a -> a[0]));
pq.add(new int[]{0, 0}); // {weight, vertex}, start at 0
int total = 0, picked = 0;
while (!pq.isEmpty() && picked < n) {
int[] top = pq.poll();
int w = top[0], u = top[1];
if (inTree[u]) continue; // already absorbed — skip stale entry
inTree[u] = true;
total += w;
picked++;
for (int[] e : adj.get(u))
if (!inTree[e[0]]) pq.add(new int[]{e[1], e[0]});
}
if (picked != n) throw new IllegalStateException("graph is not connected");
return total;
}
Instead of sorting all edges, Prim starts a tree from one vertex and, at each step, grabs the cheapest edge leaving the current tree. Cost is O(E log V) with a binary heap. Prim shines on dense graphs (especially with a Fibonacci heap or an adjacency-matrix O(V²) variant).
Structurally Prim and Dijkstra share one skeleton: both grow a tree greedily via a heap. The only difference is the key in the heap. In Dijkstra the key is "cumulative distance from the source" (dist[u] + w); in Prim it's the "single edge weight" crossing the cut (w). Notice in the Prim code the heap stores w alone, not the accumulated path. Change the relaxation key and one becomes the other.
Choosing between them: sparse input or an edge list → Kruskal; a dense graph or when you already have adjacency lists → Prim. Both give a minimum total weight (the total is unique if all weights are distinct; the tree itself may not be unique).
Common pitfalls
Skim this list once before any interview — each is a real mistake that has burned seniors.
- BFS marking on dequeue instead of enqueue → nodes enqueued repeatedly, distances wrong.
- Dijkstra with negative edges → silently wrong answers. Reach for Bellman-Ford.
- Forgetting the stale-entry guard in heap-based Dijkstra/Prim → slower, and if mishandled, wrong.
- Integer overflow on
dist + wnearInteger.MAX_VALUE→ negative distances. Uselongor a guard. - Directed cycle detection with plain
visited→ false positives from cross edges. You need on-stack (gray) state. - Undirected cycle detection ignoring the parent → every tree edge looks like a cycle. Skip the immediate parent (but beware parallel edges).
- Deep recursion on long-path graphs →
StackOverflowError. Convert to iterative or raise-Xss. - Disconnected graphs → a single BFS/DFS launch misses components; loop over all start vertices. MST on a disconnected graph doesn't exist (you get a forest).
- Using an adjacency matrix for a large sparse graph →
O(V²)memory blowup.
Best practices
- On hot paths, default to adjacency lists with primitive-friendly structures.
- Encapsulate DSU once and reuse it everywhere — it appears constantly (MST, connectivity, "accounts merge", "number of islands II", "redundant connection").
- Where recursion depth is unbounded by input size, prefer iterative BFS/DSU.
- State the complexity and why the algorithm is correct for the given weight profile; interviewers grade the justification, not just the code.
- When modeling, write down
V,E, direction, and weight range first — the algorithm falls out of those four facts.
Interview Questions
BFS gives shortest paths on unweighted graphs because it explores in rings of equal distance (like ripples), so the first time it reaches a vertex it's via the shortest route. DFS does not find shortest paths in general — it commits to depth and may reach a vertex via a long route first. For weighted non-negative graphs, neither; use Dijkstra.
Dijkstra finalizes a vertex the first time it's popped, assuming no cheaper path exists. A negative edge from a later-processed vertex can offer a cheaper route to that already-finalized vertex, violating the invariant. The greedy "closest unvisited is done" assumption fails. Bellman-Ford makes no such assumption because it relaxes all edges V−1 times.
Use three-color DFS: a cycle exists only when you reach a gray (on-stack / ancestor) node. A plain visited set flags black nodes too — but reaching a fully-finished node via a cross or forward edge is not a cycle. Confusing the two produces false positives.
Undirected: DFS, cycle if you hit a visited node that isn't your parent — or use DSU and flag when union finds two endpoints already connected. Directed: DSU does not work (union is symmetric); use three-color DFS with on-stack detection. Different tools for different structures.
It means the remaining nodes all keep positive in-degree forever, i.e. they sit in or after a cycle, so no topological order exists. Kahn detects cycles for free: emitted != V ⇒ the graph is cyclic.
Union by rank attaches the shorter tree under the taller so height grows slowly. Path compression flattens the path to the root during find. Together they yield amortized O(α(n)) per op — inverse Ackermann, ≤ 4 for all practical n. Either one alone gives O(log n); both together give near-constant.
Kruskal sorts edges and uses DSU to skip cycles — great for sparse graphs / edge lists, O(E log E). Prim grows one tree via a heap of boundary edges — better on dense graphs, O(E log V) or O(V²) with a matrix. Both rely on the cut property; both return the same minimum total weight.
Both are greedy heap-driven tree growth. In Dijkstra the key is cumulative distance from source (dist[u] + w); in Prim it's the single edge weight (w) crossing the current cut. Change the relaxation key and one becomes the other.
Deque<Integer> q = new ArrayDeque<>();
q.add(src);
while (!q.isEmpty()) {
int u = q.poll();
visited[u] = true; // <-- here
for (int v : adj.get(u))
if (!visited[v]) q.add(v);
}
It marks visited on dequeue. A node can be enqueued many times before it's ever polled, so it's processed repeatedly — quadratic/exponential blowup and, in weighted-distance variants, wrong distances. Fix: mark visited at the moment you q.add(v).
// Directed edges: 0->1, 1->2, 2->0, 3->2. topoKahn(4, adj) called.
It throws IllegalStateException("graph has a cycle"). Nodes 0, 1, 2 form a directed cycle, so none of them ever reaches in-degree 0; only node 3 is emitted (idx = 1 != 4), triggering the cycle check. A candidate who says "prints 3,0,1,2" missed that the cycle starves Kahn.
0-1 BFS with a deque: relax a 0-weight edge by pushing the neighbor to the front, a 1-weight edge to the back. The deque stays monotonic by distance, so it's O(V + E) instead of O((V+E) log V).
Run Bellman-Ford's edge relaxation V−1 times — enough because any shortest path has ≤ V−1 edges. If a V-th pass still relaxes some edge, that edge lies on (or downstream of) a negative cycle, since without one the finite shortest paths would already be stable by then. That extra pass is the detector.
Lazy deletion: push a fresh {newDist, v} on every improvement and leave stale entries in the heap. When you pop, skip any entry whose stored distance exceeds dist[u] (if (d > dist[u]) continue;). Correctness holds because the smallest live entry for a vertex is always processed first.
Vertices = courses, directed edge prereq → course. Run Kahn: if it emits all V, that's a valid order; if fewer, a prerequisite cycle makes it impossible. O(V + E). This is LeetCode "Course Schedule I/II" — a classic topo-sort tell.
Union-Find. Each union and connected is amortized O(α(n)), and it handles the incremental nature of the problem naturally. BFS/DFS per query would be O(V+E) each — far worse. (If edges were also removed, DSU alone fails; that case needs link-cut trees or offline techniques.)
A graph is (V, E), and the core skill is seeing the vertices/edges/weights in any problem and asking four questions before coding: directed? weighted (or negative)? sparse or dense? DAG or general? Those four answers pick the algorithm. The adjacency list is your default storage. BFS = unweighted shortest path and the mark-on-enqueue engine; DFS = depth and the postorder finish times that power topo sort and SCC. Undirected cycles via "visited non-parent node" or DSU; directed cycles only via three colors (gray = on stack). For shortest paths, follow the weight table: BFS, 0-1 BFS, Dijkstra (non-negative, with the stale guard and overflow care), Bellman-Ford (negative / negative-cycle, where the V-th pass is the detector), or the DAG relaxation. Union-Find drives dynamic connectivity and Kruskal, near-free at O(α(n)). Get an MST with Kruskal (sparse) or Prim (dense), and remember Prim is just Dijkstra with a different key. And always: interviewers grade the justification of correctness, not merely correct code.