Skip to Content

Componentes fuertemente conexas y grafo de condensación

Definiciones

Sea G=(V,E)G=(V,E) un grafo dirigido con vértices VV y aristas EV×VE \subseteq V \times V. Denotamos con n=Vn=|V| el número de vértices y con m=Em=|E| el número de aristas en GG. Es fácil extender todas las definiciones de este artículo a multigrafos, pero no nos centraremos en eso.

Un subconjunto de vértices CVC \subseteq V se llama componente fuertemente conexa si se cumplen las siguientes condiciones:

  • para todos u,vCu,v\in C, si uvu \neq v existe un camino de uu a vv y un camino de vv a uu, y
  • CC es maximal, en el sentido de que no se puede agregar ningún vértice sin violar la condición anterior.

Denotamos con SCC(G)\text{SCC}(G) el conjunto de componentes fuertemente conexas de GG. Estas componentes fuertemente conexas no se intersectan entre sí y cubren todos los vértices del grafo. Así, el conjunto SCC(G)\text{SCC}(G) es una partición de VV.

Consideremos este grafo GexampleG_\text{example}, en el que se destacan las componentes fuertemente conexas:

drawing

Aquí tenemos SCC(Gexample)={{0,7},{1,2,3,5,6},{4,9},{8}}.\text{SCC}(G_\text{example})={{0,7},{1,2,3,5,6},{4,9},{8}}. Podemos confirmar que, dentro de cada componente fuertemente conexa, todos los vértices son alcanzables entre sí.

Definimos el grafo de condensación GSCC=(VSCC,ESCC)G^{\text{SCC}}=(V^{\text{SCC}}, E^{\text{SCC}}) de la siguiente forma:

  • los vértices de GSCCG^{\text{SCC}} son las componentes fuertemente conexas de GG; es decir, VSCC=SCC(G)V^{\text{SCC}} = \text{SCC}(G), y
  • para todos los vértices Ci,CjC_i,C_j del grafo de condensación, hay una arista de CiC_i a CjC_j si y solo si CiCjC_i \neq C_j y existen aCia\in C_i y bCjb\in C_j tales que hay una arista de aa a bb en GG.

El grafo de condensación de GexampleG_\text{example} se ve así:

drawing

La propiedad más importante del grafo de condensación es que es acíclico. En efecto, no hay ‘bucles’ en el grafo de condensación por definición, y si hubiera un ciclo que pasara por dos o más vértices (componentes fuertemente conexas) del grafo de condensación, entonces, por alcanzabilidad, la unión de estas componentes fuertemente conexas tendría que ser una sola componente fuertemente conexa: contradicción.

El algoritmo descrito en la sección siguiente encuentra todas las componentes fuertemente conexas en un grafo dado. Después de eso se puede construir el grafo de condensación.

Algoritmo de Kosaraju

Descripción del algoritmo

El algoritmo descrito fue propuesto de forma independiente por Kosaraju y Sharir alrededor de 1980. Se basa en dos series de búsqueda en profundidad, con un tiempo de ejecución de O(n+m)O(n + m).

En el primer paso del algoritmo, realizamos una secuencia de búsquedas en profundidad (dfs), visitando el grafo entero. Es decir, mientras queden vértices no visitados, tomamos uno de ellos e iniciamos una búsqueda en profundidad desde ese vértice. Para cada vértice, registramos el tiempo de salida tout[v]t_\text{out}[v]. Esta es la ‘marca de tiempo’ en la que termina la ejecución de dfs sobre el vértice vv, es decir, el momento en el que todos los vértices alcanzables desde vv han sido visitados y el algoritmo está de vuelta en vv. El contador de marcas de tiempo no debe reiniciarse entre llamadas consecutivas a dfs. Los tiempos de salida juegan un papel clave en el algoritmo, lo que quedará claro cuando discutamos el siguiente teorema.

Primero, definimos el tiempo de salida tout[C]t_\text{out}[C] de una componente fuertemente conexa CC como el máximo de los valores tout[v]t_\text{out}[v] para todo vC.v \in C. Además, en la demostración del teorema, mencionaremos el tiempo de entrada tin[v]t_{\text{in}}[v] para cada vértice vGv\in G. El número tin[v]t_{\text{in}}[v] representa la ‘marca de tiempo’ en la que se llama a la función recursiva dfs sobre el vértice vv en el primer paso del algoritmo. Para una componente fuertemente conexa CC, definimos tin[C]t_{\text{in}}[C] como el mínimo de los valores tin[v]t_{\text{in}}[v] para todo vCv \in C.

Teorema

Sean CC y CC’ dos componentes fuertemente conexas distintas, y sea que hay una arista de CC a CC’ en el grafo de condensación. Entonces, tout[C]>tout[C]t_\text{out}[C] > t_\text{out}[C’].

Demostración

Hay dos casos distintos, según qué componente sea alcanzada primero por la búsqueda en profundidad:

  • Caso 1: la componente CC se alcanzó primero (es decir, tin[C]<tin[C]t_{\text{in}}[C] < t_{\text{in}}[C’]). En este caso, la búsqueda en profundidad visita algún vértice vCv \in C en un momento en el que todos los demás vértices de las componentes CC y CC’ aún no están visitados. Como hay una arista de CC a CC’ en el grafo de condensación, no solo todos los demás vértices de CC son alcanzables desde vv en GG, sino que también lo son todos los vértices de CC’. Esto significa que esta ejecución de dfs, que corre desde el vértice vv, también visitará en el futuro todos los demás vértices de las componentes CC y CC’, de modo que estos vértices serán descendientes de vv en el árbol de búsqueda en profundidad. Esto implica que para cada vértice u(CC){v},u \in (C \cup C’)\setminus {v}, se tiene tout[v]>tout[u]t_\text{out}[v] > t_\text{out}[u]. Por lo tanto, tout[C]>tout[C]t_\text{out}[C] > t_\text{out}[C’], lo que completa este caso de la demostración.

  • Caso 2: la componente CC’ se alcanzó primero (es decir, tin[C]>tin[C]t_{\text{in}}[C] > t_{\text{in}}[C’]). En este caso, la búsqueda en profundidad visita algún vértice vCv \in C’ en un momento en el que todos los demás vértices de las componentes CC y CC’ aún no están visitados. Como hay una arista de CC a CC’ en el grafo de condensación, CC no es alcanzable desde CC’, por la propiedad de aciclicidad. Por lo tanto, la ejecución de dfs que corre desde el vértice vv no alcanzará ningún vértice de CC, pero visitará todos los vértices de CC’. Los vértices de CC serán visitados por alguna ejecución de dfs más tarde durante este paso del algoritmo, así que efectivamente tenemos tout[C]>tout[C]t_\text{out}[C] > t_\text{out}[C’]. Esto completa la demostración.

El teorema demostrado es muy importante para encontrar componentes fuertemente conexas. Significa que cualquier arista en el grafo de condensación va de una componente con un valor mayor de toutt_\text{out} a una componente con un valor menor.

Si ordenamos todos los vértices vVv \in V en orden decreciente de su tiempo de salida tout[v]t_\text{out}[v], entonces el primer vértice uu pertenecerá a la componente fuertemente conexa “raíz”, que no tiene aristas entrantes en el grafo de condensación. Ahora queremos ejecutar algún tipo de búsqueda desde este vértice uu de modo que visite todos los vértices de su componente fuertemente conexa, pero no otros vértices. Haciéndolo de forma repetida, podemos ir encontrando todas las componentes fuertemente conexas: eliminamos todos los vértices que pertenecen a la primera componente encontrada, luego encontramos el siguiente vértice restante con el mayor valor de toutt_\text{out}, y ejecutamos esta búsqueda desde él, y así sucesivamente. Al final, habremos encontrado todas las componentes fuertemente conexas. Para encontrar un método de búsqueda que se comporte como queremos, consideramos el siguiente teorema:

Teorema

Sea GTG^T el grafo transpuesto de GG, obtenido al invertir las direcciones de las aristas en GG. Entonces, SCC(G)=SCC(GT)\text{SCC}(G)=\text{SCC}(G^T). Además, el grafo de condensación de GTG^T es el transpuesto del grafo de condensación de GG.

Se omite la demostración (pero es directa). Como consecuencia de este teorema, no habrá aristas desde la componente “raíz” hacia las demás componentes en el grafo de condensación de GTG^T. Así, para visitar toda la componente fuertemente conexa “raíz” que contiene al vértice vv, podemos simplemente ejecutar una búsqueda en profundidad desde el vértice vv en el grafo transpuesto GTG^T. Esto visitará precisamente todos los vértices de esta componente fuertemente conexa. Como se mencionó antes, luego podemos eliminar estos vértices del grafo. Después, encontramos el siguiente vértice con un valor maximal de tout[v]t_\text{out}[v], y ejecutamos la búsqueda en el grafo transpuesto empezando desde ese vértice para encontrar la siguiente componente fuertemente conexa. Repitiendo esto, encontramos todas las componentes fuertemente conexas.

Así, en resumen, discutimos el siguiente algoritmo para encontrar componentes fuertemente conexas:

  • Paso 1. Ejecutar una secuencia de búsquedas en profundidad sobre GG, que producirá alguna lista (p. ej. order) de vértices, ordenados por tiempo de salida creciente toutt_\text{out}.

  • Paso 2. Construir el grafo transpuesto GTG^T, y ejecutar una serie de búsquedas en profundidad sobre los vértices en orden inverso (es decir, en orden decreciente de tiempos de salida). Cada búsqueda en profundidad producirá una componente fuertemente conexa.

  • Paso 3 (opcional). Construir el grafo de condensación.

La complejidad temporal del algoritmo es O(n+m)O(n + m), porque la búsqueda en profundidad se realiza dos veces. Construir el grafo de condensación también es O(n+m).O(n+m).

Finalmente, es apropiado mencionar el orden topológico aquí. En el paso 1, encontramos los vértices en el orden de tiempo de salida creciente. Si GG es acíclico, esto corresponde a un orden topológico (invertido) de GG. En el paso 2, el algoritmo encuentra las componentes fuertemente conexas en orden decreciente de sus tiempos de salida. Así, encuentra componentes - vértices del grafo de condensación - en un orden que corresponde a un orden topológico del grafo de condensación.

Implementación

vector<bool> visited; // registra qué vértices ya están visitados // ejecuta búsqueda en profundidad empezando en el vértice v. // cada vértice visitado se agrega al vector de salida cuando dfs sale de él. void dfs(int v, vector<vector<int>> const& adj, vector<int> &output) { visited[v] = true; for (auto u : adj[v]) if (!visited[u]) dfs(u, adj, output); output.push_back(v); } // entrada: adj -- lista de adyacencia de G // salida: components -- las componentes fuertemente conexas en G // salida: adj_cond -- lista de adyacencia de G^SCC (por vértices raíz) void strongly_connected_components(vector<vector<int>> const& adj, vector<vector<int>> &components, vector<vector<int>> &adj_cond) { int n = adj.size(); components.clear(), adj_cond.clear(); vector<int> order; // lista de vértices de G ordenada por tiempo de salida visited.assign(n, false); // primera serie de búsquedas en profundidad for (int i = 0; i < n; i++) if (!visited[i]) dfs(i, adj, order); // crear lista de adyacencia de G^T vector<vector<int>> adj_rev(n); for (int v = 0; v < n; v++) for (int u : adj[v]) adj_rev[u].push_back(v); visited.assign(n, false); reverse(order.begin(), order.end()); vector<int> roots(n, 0); // da el vértice raíz de la SCC de un vértice // segunda serie de búsquedas en profundidad for (auto v : order) if (!visited[v]) { std::vector<int> component; dfs(v, adj_rev, component); components.push_back(component); int root = *component.begin(); for (auto u : component) roots[u] = root; } // agregar aristas al grafo de condensación adj_cond.assign(n, {}); for (int v = 0; v < n; v++) for (auto u : adj[v]) if (roots[v] != roots[u]) adj_cond[roots[v]].push_back(roots[u]); }

La función dfs implementa la búsqueda en profundidad. Recibe como entrada una lista de adyacencia y un vértice de inicio. También recibe una referencia al vector output: cada vértice visitado se agregará a output cuando dfs salga de ese vértice.

Nótese que usamos la función dfs tanto en el primer como en el segundo paso del algoritmo. En el primer paso, pasamos la lista de adyacencia de GG y, durante llamadas consecutivas a dfs, seguimos pasando el mismo ‘vector de salida’ order, de modo que al final obtenemos una lista de vértices en orden creciente de tiempos de salida. En el segundo paso, pasamos la lista de adyacencia de GTG^T y, en cada llamada, pasamos un ‘vector de salida’ vacío component, que nos dará una componente fuertemente conexa a la vez.

Algoritmo de componentes fuertemente conexas de Tarjan

Descripción del algoritmo

El algoritmo descrito fue propuesto por primera vez por Tarjan en 1972. Se basa en realizar una secuencia de llamadas a DFS, usando información inherente a su estructura para determinar las componentes fuertemente conexas (SCC), con un tiempo de ejecución de O(n+m)O(n+m).

Al aplicar el DFS sobre un vértice, recorremos su lista de adyacencia, y si encontramos un vértice que no ha sido visitado, aplicamos el DFS de forma recursiva sobre él.

Consideremos el árbol inducido por la secuencia de llamadas a DFS, al que llamaremos árbol DFS. Una vez que llamamos por primera vez un DFS sobre un vértice de una SCC, todos los vértices de su SCC serán visitados antes de que esta llamada termine, ya que todos son alcanzables entre sí. En el árbol DFS, este primer vértice será un ancestro común de todos los demás vértices de la SCC; definimos este vértice como la raíz de la SCC.

Teorema

Todos los vértices de una SCC inducen un subgrafo conexo del árbol DFS.

Demostración

Hemos determinado que todos los vértices de una SCC tienen un ancestro común, el primer vértice visitado por una llamada a DFS. Consideremos un vértice vv y su raíz, el vértice rr. Todos los vértices en el camino de rr a vv pertenecen a la misma SCC. Todos estos vértices son alcanzables desde rr, y todos ellos alcanzan vv, y como por definición vv alcanza rr, todos estos vértices se alcanzan entre sí. Como todos los caminos desde una raíz hasta cualquier otro vértice de la SCC pertenecen a la misma SCC, el subgrafo formado es conexo.

Nótese que las SCC parten de forma perfecta el árbol DFS en subgrafos conexos.

La idea del algoritmo es entonces la siguiente:

  • Realizamos una secuencia de llamadas a DFS, aplicándolas de forma recursiva a los vértices de las listas de adyacencia.

  • Una vez que terminamos de recorrer la lista de adyacencia de un vértice, de alguna forma podemos determinar si es una raíz o no. Este método se explicará más adelante.

  • Si el vértice es una raíz, entonces inmediatamente encontraremos y reclamaremos todos los vértices de su SCC.

Cuando todas las llamadas terminen, se habrán detectado todas las raíces y todos los vértices habrán sido reclamados como parte de alguna SCC.

Analicemos ahora las propiedades del DFS cuando se introduce este proceso de reclamar.

Teorema

Consideremos el vértice vv y supongamos que acabamos de terminar de recorrer su lista de adyacencia. Todos los vértices no reclamados en su subárbol pertenecen a la misma SCC.

Demostración

El algoritmo reclamará los vértices de una SCC cuando se encuentre su raíz. Como se recorrió la lista de adyacencia de vv, todas las llamadas a DFS en su subárbol han terminado, se detectaron las raíces y se reclamaron los vértices que pertenecen a sus SCC. La raíz de los vértices no reclamados restantes será un ancestro cuyo proceso de reclamar aún no se ejecutó, así que es vv o un ancestro de vv. Como vv está en el camino de todos los vértices a su raíz y las SCC deben inducir un subgrafo conexo del árbol, tanto vv como todos los vértices restantes pertenecen a la misma SCC.

Teorema

Consideremos el vértice vv y supongamos que estamos recorriendo su lista de adyacencia, procesando actualmente la arista (v,u)(v, u). Si uu ya fue visitado por alguna llamada a DFS y permanece no reclamado, vv y uu pertenecen a la misma SCC.

Demostración

Hay distintos casos según el tipo de arista:

  • Arista de árbol: si esta es una arista de árbol, es la primera vez que encontramos el vértice uu. Esto significa que primero debemos aplicar de forma recursiva la llamada a DFS sobre uu y considerarlo después de que su llamada a DFS haya terminado. Si el vértice uu permanece no reclamado, su raíz es vv o un ancestro de vv, así que deben pertenecer a la misma SCC.

  • Arista de retroceso: este es el caso más simple; si uu es un ancestro de vv, son alcanzables entre sí y por definición pertenecen a la misma SCC.

  • Arista hacia adelante: antes de procesar esta arista, hubo una secuencia de llamadas a DFS que terminaron sin encontrar la raíz de uu, habiendo vuelto a vv cuya llamada a DFS continuó. La raíz de uu será entonces un ancestro cuyo proceso de reclamar aún no se ejecutó, así que es vv o un ancestro de vv, de modo que deben pertenecer a la misma SCC.

  • Arista cruzada: de forma similar, antes de procesar esta arista, hubo una secuencia de llamadas a DFS que terminaron sin encontrar la raíz de uu, habiendo vuelto a un ancestro común de uu y vv cuya llamada a DFS continuó e inició una nueva secuencia de llamadas a DFS que llevó a una llamada sobre vv. La raíz de uu será entonces un ancestro cuyo proceso de reclamar aún no se ejecutó, y todos los candidatos posibles son ancestros comunes con vv. Como la raíz de uu es un ancestro de vv, alcanza vv, y como ahora vv alcanza uu, deben pertenecer a la misma SCC.

Nótese que, cuando dos vértices pertenecen a la misma componente, su raíz debe ser un ancestro común de ambos vértices.

Teorema

Sea vv un vértice. Las siguientes afirmaciones son equivalentes:

  1. Algún vértice en el subárbol de vv alcanza un vértice no reclamado fuera del subárbol.
  2. vv no es la raíz de una SCC.

Demostración

  • 1.    2.1. \implies 2.: Supongamos que algún vértice uu en el subárbol de vv alcanza un vértice no reclamado ww fuera del subárbol. Hemos establecido que uu y ww pertenecen a la misma SCC y que su raíz debe ser un ancestro común de ambos. Este ancestro común está necesariamente fuera del subárbol, y también será un ancestro de vv. Como vv está en el camino de la raíz a uu, debe pertenecer a la misma SCC, cuya raíz no es vv.

  • ¬1.    ¬2.\neg 1. \implies \neg 2.: Supongamos que ningún vértice en el subárbol de vv alcanza un vértice no reclamado fuera del subárbol. Esto debe significar que ningún vértice en el subárbol de vv alcanza un ancestro de vv. Las únicas aristas posibles hacia vértices fuera del subárbol son aristas cruzadas hacia vértices que ya fueron reclamados; estos vértices no pueden alcanzar un ancestro de vv, ya que si lo hicieran, pertenecerían a la misma SCC que vv, lo cual es imposible porque su SCC ya fue determinada. Como ningún ancestro de vv es alcanzable desde su subárbol, la raíz de vv debe ser vv mismo.

Ahora debemos encontrar el método que nos permite determinar si un vértice es una raíz o no, y las propiedades del proceso de reclamar son necesarias para su corrección. Para ello, definimos el tiempo de entrada tin[v]t_{in}[v] para cada vértice vGv \in G, que corresponde a la ‘marca de tiempo’ en la que se llamó al DFS sobre vv. Por definición, la raíz es el primer vértice de una SCC visitado por el DFS, así que tendrá el valor mínimo de tint_{in} de su SCC.

Sea vv un vértice y consideremos su subárbol. En el momento en que terminamos de recorrer su lista de adyacencia, cualquier vértice ya visitado por un DFS fuera del subárbol tendrá un valor menor de tint_{in}, ya que el DFS se llamó primero sobre ellos antes de empezar en vv.

Al considerar el proceso de reclamar, el valor de tint_{in} de todos los vértices no reclamados fuera del subárbol de vv es menor que tin[v]t_{in}[v]. Ahora podemos ver cómo usar tint_{in} para determinar las raíces. Consideramos el valor mínimo de tint_{in} de los vértices no reclamados que podemos alcanzar y propagamos esta información a los ancestros a través de aristas de árbol. Llamaremos al valor propagado tlowt_{low}.

Más formalmente, definimos tlow[v]t_{low}[v] como el menor valor de tint_{in} que un vértice en el subárbol de vv puede alcanzar a través de una arista directa. Por lo tanto, podemos detectar si un vértice vv es una raíz o no comprobando si tlow[v]<tin[v]t_{low}[v] < t_{in}[v].

Por último, para reclamar los vértices hay muchas formas de hacerlo, como otro algoritmo de recorrido de grafos, pero también es posible usar una estructura de datos simple para llevar la cuenta de los vértices no reclamados. Para determinar la estructura de datos desde primeros principios, recorramos los métodos que debe implementar, que son solo dos:

  • Cuando visitamos un vértice por primera vez, simplemente debemos insertarlo en la estructura de datos, ya que este vértice está no reclamado.

  • Cuando encontramos una raíz, debemos encontrar todos los vértices no reclamados restantes en su subárbol y eliminarlos de la estructura de datos.

Podemos encontrar una forma alternativa de describir la operación de eliminación al notar que, inmediatamente después de recorrer la lista de adyacencia de un vértice vv, todos los vértices colocados en la estructura de datos después de vv pertenecen a su subárbol. Si vv es una raíz, todos los vértices restantes que se insertaron después de vv deben eliminarse. Así, la operación de eliminación se puede describir en cambio como:

  • Cuando encontramos una raíz, debemos encontrar y eliminar todos los vértices restantes que se insertaron después de ella.

Ahora podemos ver que esto se puede implementar con una pila:

  • Cuando visitamos un vértice por primera vez, lo apilamos.

  • Cuando encontramos una raíz, desapilamos todos los elementos hasta desapilar la raíz misma.

Esto por fin nos permite implementar el algoritmo.

La complejidad temporal de la secuencia de llamadas a DFS es O(n+m)O(n + m). Considerando la pila, su complejidad se amortiza a O(n)O(n) ya que cada nodo se apila y se desapila solo una vez. La complejidad temporal total es por lo tanto O(n+m)O(n + m).

Como observación adicional, las raíces se encuentran en orden topológico inverso. En el algoritmo, el vértice es una raíz si no hay aristas hacia vértices no reclamados fuera de su subárbol, lo que significa que todas las demás componentes alcanzables están o bien en su subárbol (y por lo tanto sus raíces ya se encontraron) o bien se conectan con vértices ya reclamados fuera del subárbol (cuyas raíces también ya se encontraron). Así, todas las componentes alcanzables ya se encontraron, lo que significa que se introducen en un orden topológico inverso válido del grafo de condensación.

Implementación

vector<int> st; // - pila que guarda los vértices no reclamados vector<int> roots; // - registra las raíces de SCC de los vértices int timer; // - contador de marcas de tiempo del dfs vector<int> t_in; // - registra la marca de tiempo dfs de los vértices vector<int> t_low; // - registra el menor t_in de vértices no reclamados // alcanzables en el subárbol // implementa el algoritmo de Tarjan para componentes fuertemente conexas void dfs(int v, vector<vector<int>> const &adj, vector<vector<int>> &components) { t_low[v] = t_in[v] = timer++; st.push_back(v); for (auto u : adj[v]) { if (t_in[u] == -1) { // arista de árbol dfs(u, adj, components); t_low[v] = min(t_low[v], t_low[u]); } else if (roots[u] == -1) { // arista de retroceso, cruzada o hacia adelante a un vértice no reclamado t_low[v] = min(t_low[v], t_in[u]); } } if (t_low[v] == t_in[v]) { // el vértice es una raíz components.push_back({v}); // inicializa una nueva componente con raíz v while (true) { int u = st.back(); st.pop_back(); roots[u] = v; // reclama el vértice if (u == v) break; components.back().push_back(u); // agrega el vértice u a la componente de v } } } // entrada: adj -- lista de adyacencia de G // salida: components -- las componentes fuertemente conexas en G // salida: adj_cond -- lista de adyacencia de G^SCC (por vértices raíz) void strongly_connected_components(vector<vector<int>> const &adj, vector<vector<int>> &components, vector<vector<int>> &adj_cond) { components.clear(); adj_cond.clear(); int n = adj.size(); st.clear(); roots.assign(n, -1); timer = 0; t_in.assign(n, -1); t_low.assign(n, -1); // aplica el algoritmo de Tarjan a todos los vértices // agrega vértices a las componentes en orden topológico inverso for (int v = 0; v < n; v++) { if (t_in[v] == -1) { dfs(v, adj, components); } } // agrega aristas al grafo de condensación adj_cond.assign(n, {}); for (int v = 0; v < n; v++) { for (auto u : adj[v]) if (roots[v] != roots[u]) adj_cond[roots[v]].push_back(roots[u]); } }

Tenemos un envío aceptado  con este código en Library Checker.

Como última observación, hay una forma alternativa de iterar por la lista de adyacencia. Actualmente hacemos lo siguiente:

for (auto u : adj[v]) { if (t_in[u] == -1) { // arista de árbol dfs(u, adj); t_low[v] = min(t_low[v], t_low[u]); } else if (roots[u] == -1) { // arista de retroceso, cruzada o hacia adelante a un vértice no reclamado t_low[v] = min(t_low[v], t_in[u]); } }

De forma alternativa, podríamos hacer:

for (auto u : adj[v]) { if (t_in[u] == -1) // el vértice no está visitado dfs(u, adj); if (roots[u] == -1) // el vértice no ha sido reclamado t_low[v] = min(t_low[v], t_low[u]); }

tlowt_{low} se usa para propagar la información hasta la raíz, y cuando hacemos t_low[v] = min(t_low[v], t_in[u]), sabemos que uu y vv pertenecen a la misma SCC. Si tlow[u]t_{low}[u] se propaga hasta la raíz de uu, también se puede propagar a través de vv ya que la raíz es la misma. Como tlow[u]tin[u]t_{low}[u] \leq t_{in}[u], esto no introduce conflictos; solo mejora la cota sobre la raíz de vv.

Construcción del grafo de condensación

Al construir la lista de adyacencia del grafo de condensación, seleccionamos la raíz de cada componente como el primer vértice de su lista de vértices (esta es una elección arbitraria). Este vértice raíz representa toda su SCC. Para cada vértice v, el valor roots[v] indica el vértice raíz de la SCC a la que pertenece v.

Nuestro grafo de condensación queda dado por los vértices components (una componente fuertemente conexa corresponde a un vértice en el grafo de condensación), y la lista de adyacencia queda dada por adj_cond, usando solo los vértices raíz de las componentes fuertemente conexas. Nótese que generamos una arista de CC a CC’ en GSCCG^\text{SCC} por cada arista de algún aCa\in C a algún bCb\in C’ en GG (si CCC\neq C’). Esto implica que, en nuestra implementación, puede haber aristas múltiples entre dos componentes en el grafo de condensación.

Literatura

  • Thomas Cormen, Charles Leiserson, Ronald Rivest, Clifford Stein. Introduction to Algorithms [2005].
  • M. Sharir. A strong-connectivity algorithm and its applications in data-flow analysis [1979].
  • Robert Tarjan. Depth-first search and linear graph algorithms [1972].

Problemas de práctica