Skip to main content

Articulation Points (Cut Vertices)

Description

You are given an undirected graph with V vertices (numbered 0 to V - 1) and a list of edges. Your task is to find all articulation points (also called cut vertices) in the graph.

An articulation point is a vertex whose removal — along with all edges connected to it — increases the number of connected components in the graph. In other words, removing an articulation point splits a previously connected part of the graph into two or more separate pieces.

Return all articulation points in sorted order. If no articulation point exists, return [-1].

Note: The graph may contain more than one connected component, and there might be self-loops present.

Graph with 5 nodes showing articulation points at nodes 1 and 4 — removing either disconnects the graph
Graph with 5 nodes showing articulation points at nodes 1 and 4 — removing either disconnects the graph

Examples

Example 1

Input: V = 5, edges = [[0,1],[1,4],[4,3],[4,2],[2,3]]

Output: [1, 4]

Explanation: Consider what happens when each node is removed:

  • Remove node 0: The remaining nodes {1, 2, 3, 4} are still connected through edges 1-4, 4-3, 4-2, 2-3. No disconnection.
  • Remove node 1: Node 0 becomes isolated (its only connection was to node 1). Nodes {2, 3, 4} remain connected. The graph splits into {0} and {2, 3, 4} — two components. Node 1 is an articulation point.
  • Remove node 2: Nodes {0, 1, 3, 4} are still connected via 0-1, 1-4, 4-3. No disconnection.
  • Remove node 3: Nodes {0, 1, 2, 4} are still connected via 0-1, 1-4, 4-2. No disconnection.
  • Remove node 4: Node 0 connects to node 1, but nodes 2 and 3 only connected to node 4 (and each other). The graph splits into {0, 1} and {2, 3} — two components. Node 4 is an articulation point.

Articulation points: [1, 4].

Example 2

Input: V = 4, edges = [[0,1],[0,2]]

Output: [0]

Explanation: Node 0 is connected to both node 1 and node 2, but nodes 1 and 2 have no edge between them. Node 3 is already isolated. Removing node 0 disconnects node 1 from node 2, increasing the number of components from 2 (the component {0,1,2} and the isolated {3}) to 3 ({1}, {2}, {3}). Node 0 is the only articulation point.

Example 3

Input: V = 3, edges = [[0,1],[1,2],[2,0]]

Output: [-1]

Explanation: All three nodes form a cycle (triangle). Removing any single node leaves the other two still connected by a direct edge. No articulation points exist.

Constraints

  • 1 ≤ V ≤ 10^5
  • 0 ≤ E ≤ 10^5
  • 0 ≤ edges[i][0], edges[i][1] ≤ V - 1
  • The graph is undirected.
  • There might be self-loops present in the graph.
  • The graph may contain more than one connected component.

Editorial

Brute Force

Intuition

The most direct way to find articulation points is to test each vertex individually. For every vertex in the graph, pretend to remove it (along with all its edges), then check if the remaining graph has more connected components than before.

Imagine a group of friends connected by phone calls. To find out who the critical "connectors" are, you could silence each person one at a time and see if any pair of remaining friends can no longer reach each other through the phone chain. If silencing person X breaks the chain, then X is a critical connector — an articulation point.

To check connectivity after removing a vertex, we mark the removed vertex as already visited (so DFS skips it), then count how many separate DFS traversals are needed to visit all of its neighbors. If we need more than one DFS call, that vertex was holding together multiple groups.

Step-by-Step Explanation

Let's trace through with V = 5, edges = [[0,1],[1,4],[4,3],[4,2],[2,3]]:

Step 1: Build adjacency list. Node 0: {1}. Node 1: {0, 4}. Node 2: {3, 4}. Node 3: {2, 4}. Node 4: {1, 2, 3}.

Step 2: Test node 0. Mark node 0 as visited (pretend removed). Node 0's neighbors: {1}. DFS from 1 reaches {1, 4, 2, 3}. Only 1 DFS call needed. Comp = 1. Node 0 is NOT an articulation point.

Step 3: Test node 1. Mark node 1 as visited. Node 1's neighbors: {0, 4}. DFS from 0: reaches only {0} (node 0 has no other connections besides node 1 which is removed). DFS from 4: reaches {4, 2, 3}. Two DFS calls needed. Comp = 2. Node 1 IS an articulation point.

Step 4: Test node 2. Mark node 2 as visited. Node 2's neighbors: {3, 4}. DFS from 3: reaches {3, 4, 1, 0} (via 3→4→1→0). Only 1 DFS call covers all neighbors. Comp = 1. Node 2 is NOT an articulation point.

Step 5: Test node 3. Mark node 3 as visited. Node 3's neighbors: {2, 4}. DFS from 2: reaches {2, 4, 1, 0}. Only 1 DFS call needed. Comp = 1. Node 3 is NOT an articulation point.

Step 6: Test node 4. Mark node 4 as visited. Node 4's neighbors: {1, 2, 3}. DFS from 1: reaches {1, 0}. DFS from 2: reaches {2, 3}. Two DFS calls needed. Comp = 2. Node 4 IS an articulation point.

Step 7: Return sorted result: [1, 4].

Brute Force — Testing Each Vertex for Articulation Property — Watch as we remove each vertex one at a time and check if its neighbors remain connected. Nodes 1 and 4 cause disconnection when removed.

Algorithm

  1. Build an adjacency list from the edges.
  2. For each vertex u from 0 to V-1:
    a. Mark vertex u as visited (pretend it's removed).
    b. For each unvisited neighbor of u, start a DFS. Count the number of separate DFS calls needed (comp).
    c. If comp > 1, vertex u is an articulation point — add it to the result.
    d. Reset visited array.
  3. Sort the result and return it. If empty, return [-1].

Code

#include <bits/stdc++.h>
using namespace std;

class Solution {
public:
    void dfs(int node, vector<vector<int>>& adj, vector<bool>& visited) {
        visited[node] = true;
        for (int neighbor : adj[node]) {
            if (!visited[neighbor]) {
                dfs(neighbor, adj, visited);
            }
        }
    }
    
    vector<int> articulationPoints(int V, vector<vector<int>>& edges) {
        // Build adjacency list
        vector<vector<int>> adj(V);
        for (auto& edge : edges) {
            adj[edge[0]].push_back(edge[1]);
            adj[edge[1]].push_back(edge[0]);
        }
        
        vector<int> result;
        
        for (int u = 0; u < V; u++) {
            // Pretend node u is removed
            vector<bool> visited(V, false);
            visited[u] = true; // skip this node
            
            int comp = 0;
            for (int neighbor : adj[u]) {
                if (!visited[neighbor]) {
                    dfs(neighbor, adj, visited);
                    comp++;
                }
            }
            
            if (comp > 1) {
                result.push_back(u);
            }
        }
        
        if (result.empty()) return {-1};
        sort(result.begin(), result.end());
        return result;
    }
};
import sys
from collections import defaultdict
sys.setrecursionlimit(200000)

class Solution:
    def articulationPoints(self, V: int, edges: list[list[int]]) -> list[int]:
        # Build adjacency list
        adj = defaultdict(list)
        for u, v in edges:
            adj[u].append(v)
            adj[v].append(u)
        
        def dfs(node, visited):
            visited[node] = True
            for neighbor in adj[node]:
                if not visited[neighbor]:
                    dfs(neighbor, visited)
        
        result = []
        
        for u in range(V):
            # Pretend node u is removed
            visited = [False] * V
            visited[u] = True  # skip this node
            
            comp = 0
            for neighbor in adj[u]:
                if not visited[neighbor]:
                    dfs(neighbor, visited)
                    comp += 1
            
            if comp > 1:
                result.append(u)
        
        return sorted(result) if result else [-1]
import java.util.*;

class Solution {
    private void dfs(int node, List<List<Integer>> adj, boolean[] visited) {
        visited[node] = true;
        for (int neighbor : adj.get(node)) {
            if (!visited[neighbor]) {
                dfs(neighbor, adj, visited);
            }
        }
    }
    
    public List<Integer> articulationPoints(int V, List<int[]> edges) {
        List<List<Integer>> adj = new ArrayList<>();
        for (int i = 0; i < V; i++) adj.add(new ArrayList<>());
        
        for (int[] edge : edges) {
            adj.get(edge[0]).add(edge[1]);
            adj.get(edge[1]).add(edge[0]);
        }
        
        List<Integer> result = new ArrayList<>();
        
        for (int u = 0; u < V; u++) {
            boolean[] visited = new boolean[V];
            visited[u] = true; // pretend removed
            
            int comp = 0;
            for (int neighbor : adj.get(u)) {
                if (!visited[neighbor]) {
                    dfs(neighbor, adj, visited);
                    comp++;
                }
            }
            
            if (comp > 1) {
                result.add(u);
            }
        }
        
        if (result.isEmpty()) {
            result.add(-1);
        }
        Collections.sort(result);
        return result;
    }
}

Complexity Analysis

Time Complexity: O(V × (V + E))

For each of the V vertices, we pretend to remove it and run DFS over the remaining graph. Each DFS costs O(V + E). Total: V × O(V + E) = O(V × (V + E)).

With V up to 10^5 and E up to 10^5, this results in roughly 10^5 × 2 × 10^5 = 2 × 10^10 operations — far too slow for competitive programming time limits.

Space Complexity: O(V + E)

The adjacency list takes O(V + E), and the visited array takes O(V) for each DFS run. The DFS recursion stack can be up to O(V) deep.

Why This Approach Is Not Efficient

The brute force approach tests each vertex independently by removing it and running a full DFS traversal. With V up to 10^5 vertices and each traversal costing O(V + E), the total time is O(V × (V + E)) ≈ O(V² + VE). For maximum constraints, this is approximately 10^10 operations — well beyond the typical 1-second time limit.

The root cause of inefficiency is that each vertex removal is treated as a completely independent problem. We rebuild the visited state from scratch every time and learn nothing from one test that could help the next.

Key insight: Instead of physically removing each vertex, we can analyze the DFS tree structure in a single pass. A vertex u is an articulation point if and only if:

  1. Root case: u is the DFS root and has two or more children in the DFS tree (removing the root disconnects its children since they were only reachable via the root).
  2. Non-root case: u has a child v in the DFS tree such that no vertex in the subtree rooted at v has a back-edge to an ancestor of u. This is captured by the condition low[v] ≥ disc[u] — meaning v's subtree cannot "escape" above u through any back-edge.

This is Tarjan's Algorithm, which solves the problem in a single DFS pass: O(V + E).

Optimal Approach - Tarjan's Algorithm

Intuition

Tarjan's algorithm for finding articulation points builds on a clever observation about DFS tree structure. When we run DFS on a graph, we create a tree (the DFS tree) where some edges go "down" to new nodes (tree edges) and some go "back" to already-visited ancestors (back-edges). Back-edges create cycles, and cycles are the reason some vertices are NOT articulation points — they provide alternative paths.

Think of a city with a road network. Some roads form the main route (DFS tree edges), while others are shortcuts back to earlier intersections (back-edges). An intersection is critical (an articulation point) only if there's NO shortcut that bypasses it. If every road below an intersection eventually has a shortcut back above it, then that intersection isn't critical.

To formalize this, we track two values for each vertex:

  • disc[u] (discovery time): The order in which DFS first visits vertex u.
  • low[u]: The smallest discovery time reachable from u's subtree through tree edges and at most one back-edge.

The two rules for identifying articulation points are:

  1. Root rule: If u is the DFS root and has ≥ 2 children in the DFS tree, u is an articulation point. Rationale: the root's children are in separate subtrees; they only connect through the root.
  2. Non-root rule: If u is not the root and has a child v where low[v] ≥ disc[u], then u is an articulation point. Rationale: v's entire subtree cannot reach any ancestor of u — no back-edge provides an escape route past u.

Note the subtle difference from bridges: bridges use low[v] > disc[u] (strict), while articulation points use low[v] ≥ disc[u] (non-strict). This is because even if the back-edge reaches u itself (low[v] == disc[u]), removing u would still disconnect v's subtree — the back-edge goes TO the very node being removed.

Step-by-Step Explanation

Let's trace through with V = 5, edges = [[0,1],[1,4],[4,3],[4,2],[2,3]]:

Step 1: Initialize timer = 0. All disc[] and low[] start as -1. Start DFS from node 0 (which becomes the root).

Step 2: Visit node 0. Set disc[0] = low[0] = 0. Timer becomes 1. children_count = 0. Explore neighbor 1.

Step 3: Visit node 1 (parent = 0). Set disc[1] = low[1] = 1. Timer becomes 2. Explore neighbor 0 — skip, parent. Explore neighbor 4.

Step 4: Visit node 4 (parent = 1). Set disc[4] = low[4] = 2. Timer becomes 3. Explore neighbor 1 — skip, parent. Explore neighbor 3.

Step 5: Visit node 3 (parent = 4). Set disc[3] = low[3] = 3. Timer becomes 4. Explore neighbor 2.

Step 6: Visit node 2 (parent = 3). Set disc[2] = low[2] = 4. Timer becomes 5. Explore neighbor 3 — skip, parent. Explore neighbor 4 — already visited, NOT parent. Back-edge! Update low[2] = min(4, disc[4]) = min(4, 2) = 2.

Step 7: Return to node 3 from child 2. Update low[3] = min(low[3], low[2]) = min(3, 2) = 2. Check: low[2] = 2 ≥ disc[3] = 3? NO (2 < 3). Node 3 is NOT an articulation point. Explore neighbor 4 — already visited, NOT parent. Back-edge! Update low[3] = min(2, disc[4]) = min(2, 2) = 2.

Step 8: Return to node 4 from child 3. Update low[4] = min(low[4], low[3]) = min(2, 2) = 2. Check: low[3] = 2 ≥ disc[4] = 2? YES! Node 4 IS an articulation point. Explore neighbor 2 — already visited, NOT parent. Back-edge! Update low[4] = min(2, disc[2]) = min(2, 4) = 2 (no change).

Step 9: Return to node 1 from child 4. Update low[1] = min(low[1], low[4]) = min(1, 2) = 1. Check: low[4] = 2 ≥ disc[1] = 1? YES! Node 1 IS an articulation point.

Step 10: Return to node 0 from child 1. Update low[0] = min(low[0], low[1]) = min(0, 1) = 0. Root check: node 0 has only 1 child (node 1) in DFS tree. Since children < 2, node 0 is NOT an articulation point.

Step 11: DFS complete. Result: [1, 4].

Final values: disc = [0, 1, 4, 3, 2], low = [0, 1, 2, 2, 2]. Articulation points: nodes 1 and 4.

Tarjan's Algorithm — Finding Articulation Points via DFS Discovery and Low Values — Watch how DFS assigns discovery times and computes low values. When low[v] ≥ disc[u] for non-root u, or root has ≥ 2 children, u is an articulation point.

Algorithm

  1. Build an adjacency list from the edges.
  2. Initialize disc[], low[] arrays with -1, a visited[] array, and a global timer = 0.
  3. For each unvisited node, start a DFS.
  4. In DFS for node u with parent p:
    a. Set disc[u] = low[u] = timer++. Mark visited.
    b. Initialize children_count = 0.
    c. For each neighbor v of u:
    • If v == p: skip (parent edge).
    • If v is unvisited: recurse DFS(v, u). Increment children_count. Update low[u] = min(low[u], low[v]). If p ≠ -1 (non-root) and low[v] ≥ disc[u]: mark u as articulation point.
    • If v is already visited: back-edge. Update low[u] = min(low[u], disc[v]).
      d. After processing all neighbors: if p == -1 (root) and children_count ≥ 2: mark u as articulation point.
  5. Collect all marked articulation points, sort, and return. If none, return [-1].

Code

#include <bits/stdc++.h>
using namespace std;

class Solution {
public:
    int timer = 0;
    
    void dfs(int u, int parent, vector<vector<int>>& adj,
             vector<int>& disc, vector<int>& low,
             vector<bool>& visited, vector<bool>& isAP) {
        visited[u] = true;
        disc[u] = low[u] = timer++;
        int children = 0;
        
        for (int v : adj[u]) {
            if (v == parent) {
                parent = -1; // skip parent only once
                continue;
            }
            if (!visited[v]) {
                children++;
                dfs(v, u, adj, disc, low, visited, isAP);
                low[u] = min(low[u], low[v]);
                
                // Non-root articulation point condition
                if (parent != -1 && low[v] >= disc[u]) {
                    isAP[u] = true;
                }
            } else {
                // Back edge
                low[u] = min(low[u], disc[v]);
            }
        }
        
        // Root articulation point condition
        // Note: we need to check original parent, not modified one
        // Using disc[u] == 0 for root check if started from 0
    }
    
    vector<int> articulationPoints(int V, vector<vector<int>>& edges) {
        vector<vector<int>> adj(V);
        for (auto& edge : edges) {
            if (edge[0] == edge[1]) continue; // skip self-loops
            adj[edge[0]].push_back(edge[1]);
            adj[edge[1]].push_back(edge[0]);
        }
        
        vector<int> disc(V, -1), low(V, -1);
        vector<bool> visited(V, false), isAP(V, false);
        timer = 0;
        
        for (int i = 0; i < V; i++) {
            if (!visited[i]) {
                // Custom DFS for root handling
                visited[i] = true;
                disc[i] = low[i] = timer++;
                int rootChildren = 0;
                
                for (int v : adj[i]) {
                    if (!visited[v]) {
                        rootChildren++;
                        dfs(v, i, adj, disc, low, visited, isAP);
                        low[i] = min(low[i], low[v]);
                        
                        if (low[v] >= disc[i] && rootChildren > 1) {
                            isAP[i] = true;
                        }
                    } else {
                        low[i] = min(low[i], disc[v]);
                    }
                }
                
                if (rootChildren >= 2) {
                    isAP[i] = true;
                }
            }
        }
        
        vector<int> result;
        for (int i = 0; i < V; i++) {
            if (isAP[i]) result.push_back(i);
        }
        
        if (result.empty()) return {-1};
        return result;
    }
};
import sys
from collections import defaultdict
sys.setrecursionlimit(200000)

class Solution:
    def articulationPoints(self, V: int, edges: list[list[int]]) -> list[int]:
        adj = defaultdict(list)
        for u, v in edges:
            if u == v:  # skip self-loops
                continue
            adj[u].append(v)
            adj[v].append(u)
        
        disc = [-1] * V
        low = [-1] * V
        visited = [False] * V
        is_ap = [False] * V
        timer = [0]
        
        def dfs(u: int, parent: int) -> None:
            visited[u] = True
            disc[u] = low[u] = timer[0]
            timer[0] += 1
            children = 0
            
            for v in adj[u]:
                if v == parent:
                    parent = -1  # skip parent only once
                    continue
                if not visited[v]:
                    children += 1
                    dfs(v, u)
                    low[u] = min(low[u], low[v])
                    
                    # Non-root: articulation point check
                    if parent != -1 and low[v] >= disc[u]:
                        is_ap[u] = True
                else:
                    # Back edge
                    low[u] = min(low[u], disc[v])
            
            # Root: articulation point if ≥ 2 children
            # We check original parent via disc comparison
        
        for i in range(V):
            if not visited[i]:
                # Handle root specially
                visited[i] = True
                disc[i] = low[i] = timer[0]
                timer[0] += 1
                root_children = 0
                
                for v in adj[i]:
                    if not visited[v]:
                        root_children += 1
                        dfs(v, i)
                        low[i] = min(low[i], low[v])
                    else:
                        low[i] = min(low[i], disc[v])
                
                if root_children >= 2:
                    is_ap[i] = True
        
        result = [i for i in range(V) if is_ap[i]]
        return result if result else [-1]
import java.util.*;

class Solution {
    private int timer = 0;
    
    private void dfs(int u, int parent, List<List<Integer>> adj,
                     int[] disc, int[] low, boolean[] visited, boolean[] isAP) {
        visited[u] = true;
        disc[u] = low[u] = timer++;
        int children = 0;
        
        for (int v : adj.get(u)) {
            if (v == parent) {
                parent = -1; // skip parent only once
                continue;
            }
            if (!visited[v]) {
                children++;
                dfs(v, u, adj, disc, low, visited, isAP);
                low[u] = Math.min(low[u], low[v]);
                
                // Non-root articulation point
                if (parent != -1 && low[v] >= disc[u]) {
                    isAP[u] = true;
                }
            } else {
                // Back edge
                low[u] = Math.min(low[u], disc[v]);
            }
        }
    }
    
    public List<Integer> articulationPoints(int V, List<int[]> edges) {
        List<List<Integer>> adj = new ArrayList<>();
        for (int i = 0; i < V; i++) adj.add(new ArrayList<>());
        
        for (int[] edge : edges) {
            if (edge[0] == edge[1]) continue; // skip self-loops
            adj.get(edge[0]).add(edge[1]);
            adj.get(edge[1]).add(edge[0]);
        }
        
        int[] disc = new int[V];
        int[] low = new int[V];
        boolean[] visited = new boolean[V];
        boolean[] isAP = new boolean[V];
        Arrays.fill(disc, -1);
        Arrays.fill(low, -1);
        timer = 0;
        
        for (int i = 0; i < V; i++) {
            if (!visited[i]) {
                // Handle root specially
                visited[i] = true;
                disc[i] = low[i] = timer++;
                int rootChildren = 0;
                
                for (int v : adj.get(i)) {
                    if (!visited[v]) {
                        rootChildren++;
                        dfs(v, i, adj, disc, low, visited, isAP);
                        low[i] = Math.min(low[i], low[v]);
                    } else {
                        low[i] = Math.min(low[i], disc[v]);
                    }
                }
                
                if (rootChildren >= 2) {
                    isAP[i] = true;
                }
            }
        }
        
        List<Integer> result = new ArrayList<>();
        for (int i = 0; i < V; i++) {
            if (isAP[i]) result.add(i);
        }
        
        if (result.isEmpty()) result.add(-1);
        return result;
    }
}

Complexity Analysis

Time Complexity: O(V + E)

We perform a single DFS traversal of the entire graph. Each vertex is visited exactly once (O(V)), and each edge is examined exactly twice — once from each endpoint (O(E)). The disc/low computations, comparison checks, and articulation point marking at each step are all O(1) operations. Total: O(V + E).

For maximum constraints (V = 10^5, E = 10^5), this is approximately 2 × 10^5 operations — an enormous improvement over the brute force's 10^10.

Space Complexity: O(V + E)

We store the adjacency list (O(V + E)), the disc[], low[], visited[], and isAP[] arrays (O(V) each), and the DFS recursion stack (O(V) in the worst case for a chain graph). Total: O(V + E).