🕸️ Алгоритми върху Графи: Основи и Оптимизации
Графите са фундаментална структура от данни за моделиране на връзки между обекти (градове, компютри, хора, социални мрежи). Разбирането на графовите алгоритми е критично за успеха в информатични олимпиади. В този материал разглеждаме най-важните алгоритми за обхождане, намиране на най-кратък път, минимални покриващи дървета и топологично сортиране.
Основни представяния на граф:
- Списък на съседство (vector<vector<int>> или vector<vector<pair<int,int>>>): Ефективен за разредени графи.
- Матрица на съседство (bool adj[MAXN][MAXN]): Бърза проверка за съществуване на ребро, но изисква \(O(V^2)\) памет.
1. Обхождане на Граф
1.1. DFS (Обхождане в дълбочина)
DFS е алгоритъм за обхождане на граф, който изследва колкото се може по-навътре по даден клон, преди да се върне назад. Използва стек (имплицитно чрез рекурсия или explicit чрез std::stack).
Как работи: 1. Започваме от даден връх и го маркираме като посетен 2. Рекурсивно посещаваме всички непосетени съседи 3. Връщаме се назад, когато няма повече непосетени съседи
Пълна имплементация:
vector<int> adj[MAXN]; // Списък на съседство
bool visited[MAXN]; // Маркиране на посетени върхове
vector<int> component; // Текуща компонента
void dfs(int u) {
visited[u] = true;
component.push_back(u); // Добавяме към текущата компонента
for (int v : adj[u]) {
if (!visited[v]) {
dfs(v);
}
}
}
// Намиране на всички свързани компоненти
void findComponents() {
memset(visited, false, sizeof(visited));
int numComponents = 0;
for (int i = 0; i < n; i++) {
if (!visited[i]) {
component.clear();
dfs(i);
numComponents++;
// component съдържа всички върхове в текущата компонента
}
}
}
Сложност: \(O(V + E)\) - всеки връх и ребро се обработва точно веднъж.
Приложения: - Намиране на свързани компоненти в ненасочен граф - Проверка за цикли (ако срещнем посетен връх, различен от бащата) - Топологично сортиране в DAG - Намиране на мостове и артикулационни точки - Решаване на лабиринти
1.2. BFS (Обхождане в ширина)
BFS обхожда графа "слой по слой", използвайки опашка (std::queue). Първо посещава всички съседи на текущия връх, след това техните съседи и т.н.
Как работи: 1. Започваме от даден връх, поставяме го в опашката и го маркираме 2. Докато опашката не е празна: - Извличаме връх от опашката - За всеки непосетен съсед: маркираме го и го добавяме в опашката
Пълна имплементация:
vector<int> adj[MAXN];
bool visited[MAXN];
int dist[MAXN]; // Разстояние от началния връх
void bfs(int start) {
memset(visited, false, sizeof(visited));
memset(dist, -1, sizeof(dist));
queue<int> q;
q.push(start);
visited[start] = true;
dist[start] = 0;
while (!q.empty()) {
int u = q.front();
q.pop();
for (int v : adj[u]) {
if (!visited[v]) {
visited[v] = true;
dist[v] = dist[u] + 1;
q.push(v);
}
}
}
}
// BFS с възстановяване на път
vector<int> reconstructPath(int start, int end) {
vector<int> parent(n, -1);
queue<int> q;
q.push(start);
parent[start] = start;
while (!q.empty()) {
int u = q.front();
q.pop();
if (u == end) break; // Намерихме целта
for (int v : adj[u]) {
if (parent[v] == -1) {
parent[v] = u;
q.push(v);
}
}
}
// Възстановяване на пътя
if (parent[end] == -1) return {}; // Няма път
vector<int> path;
for (int v = end; v != start; v = parent[v]) {
path.push_back(v);
}
path.push_back(start);
reverse(path.begin(), path.end());
return path;
}
Сложност: \(O(V + E)\).
Приложения: - Най-кратък път в непретеглен граф (BFS гарантира минималния брой ребра) - Проверка дали граф е двуделен (оцветяване в два цвята) - Намиране на всички върхове на разстояние \(k\) от даден връх - 0-1 BFS за графи с тегла 0 и 1
2. Най-кратък път (Shortest Path)
2.1. Алгоритъм на Дейкстра (Dijkstra)
Алгоритъмът на Дейкстра намира най-краткия път от начален връх до всички останали върхове в граф с неотрицателни тегла. Използва приоритетна опашка за винаги да обработва върха с най-малко разстояние.
Идея: Постепенно "релаксираме" разстоянията, винаги избирайки най-близкия необработен връх.
Пълна имплементация с възстановяване на път:
const long long INF = 1e18;
vector<pair<int, int>> adj[MAXN]; // {съсед, тегло}
long long dist[MAXN];
int parent[MAXN];
void dijkstra(int start) {
fill(dist, dist + n, INF);
fill(parent, parent + n, -1);
dist[start] = 0;
// Min-heap: {разстояние, връх}
priority_queue<pair<long long, int>,
vector<pair<long long, int>>,
greater<pair<long long, int>>> pq;
pq.push({0, start});
while (!pq.empty()) {
auto [d, u] = pq.top();
pq.pop();
// Пропускаме остарели записи
if (d > dist[u]) continue;
// Релаксация на съседите
for (auto [v, w] : adj[u]) {
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
parent[v] = u;
pq.push({dist[v], v});
}
}
}
}
// Възстановяване на най-кратък път
vector<int> getPath(int start, int end) {
if (dist[end] == INF) return {}; // Няма път
vector<int> path;
for (int v = end; v != -1; v = parent[v]) {
path.push_back(v);
}
reverse(path.begin(), path.end());
return path;
}
Сложност: \(O((V + E) \log V)\) с binary heap, \(O(V^2)\) с масив.
Важно: Дейкстра не работи коректно при отрицателни тегла!
Пример задача: Намиране на най-кратък път от град A до град B в мрежа от градове.
2.2. Алгоритъм на Белман-Форд (Bellman-Ford)
Bellman-Ford работи с отрицателни тегла и може да открива отрицателни цикли. Алгоритъмът релаксира всички ребра \(V-1\) пъти.
Идея: Ако има път с най-много \(k\) ребра, то след \(k\) итерации разстоянието ще бъде коректно.
Пълна имплементация:
struct Edge {
int from, to;
long long weight;
};
vector<Edge> edges;
long long dist[MAXN];
int parent[MAXN];
bool bellmanFord(int start, int n) {
fill(dist, dist + n, INF);
fill(parent, parent + n, -1);
dist[start] = 0;
// Релаксация V-1 пъти
for (int i = 0; i < n - 1; i++) {
for (auto& e : edges) {
if (dist[e.from] != INF && dist[e.from] + e.weight < dist[e.to]) {
dist[e.to] = dist[e.from] + e.weight;
parent[e.to] = e.from;
}
}
}
// Проверка за отрицателен цикъл
for (auto& e : edges) {
if (dist[e.from] != INF && dist[e.from] + e.weight < dist[e.to]) {
return false; // Има отрицателен цикъл
}
}
return true; // Няма отрицателен цикъл
}
// Намиране на всички върхове, достижими чрез отрицателен цикъл
void findNegativeCycleNodes(int n) {
bool relaxed[MAXN] = {};
// Още V итерации - разстоянията продължават да намаляват
// само за върхове в/достижими от отрицателен цикъл
for (int i = 0; i < n; i++) {
for (auto& e : edges) {
if (dist[e.from] != INF && dist[e.from] + e.weight < dist[e.to]) {
dist[e.to] = -INF; // Маркираме като "безкрайно малко"
relaxed[e.to] = true;
}
}
}
}
Сложност: \(O(V \cdot E)\) - по-бавен от Дейкстра, но по-универсален.
Оптимизация: Можем да спрем по-рано, ако в някоя итерация няма релаксация.
Приложение: Задачи с отрицателни тегла, арбитраж във финанси.
2.3. Алгоритъм на Флойд-Уоршал (Floyd-Warshall)
Floyd-Warshall намира най-краткия път между всички двойки върхове чрез динамично програмиране. Използва се за малки графи (до \(\approx\) 400-500 върха).
Идея: \(dp[k][i][j]\) = най-кратък път от \(i\) до \(j\), използвайки само върхове \(0...k\) като междинни. Може да се оптимизира до 2D масив.
Пълна имплементация:
const long long INF = 1e18;
long long dist[MAXN][MAXN];
int next[MAXN][MAXN]; // За възстановяване на път
void floydWarshall(int n) {
// Инициализация
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (i == j) {
dist[i][j] = 0;
} else {
dist[i][j] = INF;
}
next[i][j] = -1;
}
}
// Добавяме ребрата
for (auto [u, v, w] : edges) {
dist[u][v] = w;
next[u][v] = v;
}
// Основен алгоритъм
for (int k = 0; k < n; k++) {
for (int i = 0; i < n; i++) {
for (int j = 0; j < n; j++) {
if (dist[i][k] != INF && dist[k][j] != INF) {
if (dist[i][k] + dist[k][j] < dist[i][j]) {
dist[i][j] = dist[i][k] + dist[k][j];
next[i][j] = next[i][k];
}
}
}
}
}
// Проверка за отрицателни цикли
for (int i = 0; i < n; i++) {
if (dist[i][i] < 0) {
// Има отрицателен цикъл, достижим от i
}
}
}
// Възстановяване на път от u до v
vector<int> reconstructPath(int u, int v) {
if (next[u][v] == -1) return {}; // Няма път
vector<int> path = {u};
while (u != v) {
u = next[u][v];
path.push_back(u);
}
return path;
}
Сложност: \(O(V^3)\) - три вложени цикъла.
Приложения: - Намиране на транзитивно затваряне на граф - Откриване на отрицателни цикли - Минимакс/максимин пътища (с модификация)
3. Минимално Покриващо Дърво (MST)
Минималното покриващо дърво е подмножество от ребрата, което свързва всички върхове с минимална обща сума на теглата.
3.1. Алгоритъм на Крускал (Kruskal)
Крускал сортира всички ребра по тегло и ги добавя едно по едно, ако не образуват цикъл. Използва Disjoint Set Union (DSU) за ефективна проверка.
Пълна имплементация:
struct Edge {
int u, v;
long long weight;
bool operator<(const Edge& other) const {
return weight < other.weight;
}
};
// Disjoint Set Union (Union-Find)
int parent[MAXN], rankDSU[MAXN];
void initDSU(int n) {
for (int i = 0; i < n; i++) {
parent[i] = i;
rankDSU[i] = 0;
}
}
int find(int x) {
if (parent[x] != x) {
parent[x] = find(parent[x]); // Path compression
}
return parent[x];
}
bool unite(int x, int y) {
int px = find(x), py = find(y);
if (px == py) return false; // Вече в едно множество
// Union by rank
if (rankDSU[px] < rankDSU[py]) swap(px, py);
parent[py] = px;
if (rankDSU[px] == rankDSU[py]) rankDSU[px]++;
return true;
}
long long kruskal(vector<Edge>& edges, int n) {
sort(edges.begin(), edges.end()); // Сортиране по тегло
initDSU(n);
long long mstWeight = 0;
vector<Edge> mstEdges; // Ребрата в MST
for (auto& e : edges) {
if (unite(e.u, e.v)) { // Ако не образуват цикъл
mstWeight += e.weight;
mstEdges.push_back(e);
if (mstEdges.size() == n - 1) break; // MST е готово
}
}
// Проверка дали графът е свързан
if (mstEdges.size() != n - 1) {
return -1; // Няма MST (графът е несвързан)
}
return mstWeight;
}
Сложност: \(O(E \log E)\) заради сортирането.
3.2. Алгоритъм на Прим (Prim)
Прим изгражда MST инкрементално, започвайки от произволен връх и винаги добавяйки най-евтиното ребро, което свързва дървото с нов връх.
Пълна имплементация:
vector<pair<int, long long>> adj[MAXN]; // {съсед, тегло}
bool inMST[MAXN];
long long prim(int start, int n) {
fill(inMST, inMST + n, false);
// Min-heap: {тегло, {връх, от-къде}}
priority_queue<pair<long long, pair<int, int>>,
vector<pair<long long, pair<int, int>>>,
greater<pair<long long, pair<int, int>>>> pq;
long long mstWeight = 0;
vector<pair<int, int>> mstEdges;
// Започваме от start
pq.push({0, {start, -1}});
while (!pq.empty()) {
auto [w, vp] = pq.top();
auto [v, from] = vp;
pq.pop();
if (inMST[v]) continue; // Вече в MST
inMST[v] = true;
mstWeight += w;
if (from != -1) {
mstEdges.push_back({from, v});
}
// Добавяме съседите в опашката
for (auto [u, ew] : adj[v]) {
if (!inMST[u]) {
pq.push({ew, {u, v}});
}
}
}
// Проверка дали всички върхове са в MST
for (int i = 0; i < n; i++) {
if (!inMST[i]) return -1; // Графът е несвързан
}
return mstWeight;
}
Сложност: \(O(E \log V)\) с приоритетна опашка.
Кога да използваме кой: - Крускал: По-лесен за имплементация, добър за разредени графи - Прим: По-ефективен за плътни графи, по-добър за динамични модификации
4. Топологично Сортиране
Топологичното сортиране подрежда върховете на DAG (Directed Acyclic Graph) така, че за всяко ребро \(u \to v\), \(u\) се появява преди \(v\) в подредбата. Съществува само за ациклични графи.
4.1. DFS подход
Използваме DFS и добавяме върховете в резултата при излизане от рекурсията (postorder), след това обръщаме реда.
Имплементация:
vector<int> adj[MAXN];
bool visited[MAXN];
vector<int> topoOrder;
void topoDFS(int u) {
visited[u] = true;
for (int v : adj[u]) {
if (!visited[v]) {
topoDFS(v);
}
}
topoOrder.push_back(u); // Добавяме при излизане
}
vector<int> topologicalSort(int n) {
fill(visited, visited + n, false);
topoOrder.clear();
for (int i = 0; i < n; i++) {
if (!visited[i]) {
topoDFS(i);
}
}
reverse(topoOrder.begin(), topoOrder.end());
return topoOrder;
}
4.2. Алгоритъм на Кан (Kahn's Algorithm)
Използва входящата степен (indegree) на всеки връх. Започваме с върховете с indegree 0 и постепенно "премахваме" ребрата.
Имплементация:
vector<int> adj[MAXN];
int indegree[MAXN];
vector<int> kahnTopologicalSort(int n) {
fill(indegree, indegree + n, 0);
// Изчисляваме входяща степен
for (int u = 0; u < n; u++) {
for (int v : adj[u]) {
indegree[v]++;
}
}
queue<int> q;
for (int i = 0; i < n; i++) {
if (indegree[i] == 0) {
q.push(i);
}
}
vector<int> topoOrder;
while (!q.empty()) {
int u = q.front();
q.pop();
topoOrder.push_back(u);
// "Премахваме" ребрата от u
for (int v : adj[u]) {
indegree[v]--;
if (indegree[v] == 0) {
q.push(v);
}
}
}
// Ако не сме включили всички върхове, има цикъл
if (topoOrder.size() != n) {
return {}; // Графът има цикъл
}
return topoOrder;
}
Сложност: \(O(V + E)\) за двата подхода.
Приложения: - Планиране на задачи със зависимости - Разрешаване на dependencii в build системи - Определяне на реда на курсове с prerequisites
Пример: Даден е списък от курсове и prerequesite-и. В какъв ред трябва да вземете курсовете?
5. Практични Съвети и Често Срещани Грешки
5.1. Избор на подходящ алгоритъм
Таблица за избор на алгоритъм:
| Задача | Условия | Алгоритъм |
|---|---|---|
| Най-кратък път | Непретеглен граф | BFS |
| Най-кратък път | Положителни тегла | Dijkstra |
| Най-кратък път | Отрицателни тегла | Bellman-Ford |
| Всички двойки път | Малък граф (\(V \leq 500\)) | Floyd-Warshall |
| MST | Разреден граф | Kruskal |
| MST | Плътен граф | Prim |
| Проверка за цикли | DAG | Топологично сортиране |
5.2. Често срещани грешки
1. Използване на Dijkstra с отрицателни тегла
2. Забравяне на проверка за остарели записи в Dijkstra
3. Грешна инициализация на dist масив
// ГРЕШНО:
memset(dist, INF, sizeof(dist)); // Заявява байтове!
// ПРАВИЛНО:
fill(dist, dist + n, INF);
4. Integer overflow при пътища
5.3. Оптимизации
0-1 BFS: За графи с тегла само 0 и 1, използвайте deque вместо priority_queue:
void bfs01(int start) {
vector<long long> dist(n, INF);
deque<int> dq;
dist[start] = 0;
dq.push_back(start);
while (!dq.empty()) {
int u = dq.front();
dq.pop_front();
for (auto [v, w] : adj[u]) {
if (dist[u] + w < dist[v]) {
dist[v] = dist[u] + w;
if (w == 0) {
dq.push_front(v); // Тегло 0 - предимство
} else {
dq.push_back(v); // Тегло 1 - накрая
}
}
}
}
}
6. Задачи за упражнение
Леки задачи (за начинаещи):
- CSES - Building Roads: Намиране на компоненти с DFS/BFS
- CSES - Message Route: Най-кратък път с BFS
- CSES - Labyrinth: Обхождане на лабиринт с BFS
- Codeforces 277A: Свързани компоненти
Средни задачи:
- CSES - Shortest Routes I: Dijkstra
- CSES - Shortest Routes II: Floyd-Warshall
- CSES - Road Reparation: MST с Kruskal
- CSES - Course Schedule: Топологично сортиране
- Codeforces 20C: Dijkstra с възстановяване на път
- CSES - High Score: Bellman-Ford с отрицателни цикли
Трудни задачи:
- CSES - Flight Discount: Dijkstra с модификации
- UVa 558: Wormholes (Bellman-Ford)
- CSES - Investigation: Броене на най-кратки пътища
- Codeforces 295B: Floyd-Warshall с модификации
🏁 Заключение
Графовите алгоритми са фундаментална част от състезателното програмиране и информатичните олимпиади. Ключът към успеха е:
- Разбиране кога кой алгоритъм да използваме - вижте таблицата по-горе
- Внимание към детайлите - проверка за overflow, правилна инициализация, обработка на ъгловите случаи
- Практика - решавайте много задачи, за да затвърдите концепциите
- Анализ на сложността - винаги проверявайте дали избраният алгоритъм ще се справи със ограниченията
Овладяването на тези алгоритми ще ви даде силна основа за решаване на сложни графови задачи в олимпиади като IOI, CEOI и Balkan OI. Успех!