Loading content...
What happens when a graph achieves maximum connectivity—when every vertex connects to every other? This extreme case, called a complete graph, serves as both a theoretical benchmark and a practical model for fully-connected systems.
Equally important is the concept of subgraphs—extracting meaningful portions from larger graphs. When analyzing a massive social network, we often focus on specific communities. When debugging a dependency graph, we might isolate a problematic subset. Subgraphs let us:
By the end of this page, you will understand complete graphs and their properties, master the distinction between subgraphs and induced subgraphs, learn about cliques and independent sets, explore bipartite graphs and graph coloring, and understand graph complements. These concepts form the foundation for advanced graph analysis and algorithm design.
A complete graph is a simple undirected graph where every pair of distinct vertices is connected by exactly one edge. This represents the maximum possible connectivity for a given number of vertices.
The complete graph on n vertices is denoted Kₙ (K for 'komplett' from German, or 'complete').
Kₙ = (V, E) where V = {1, 2, ..., n} and E = {{u, v} : u, v ∈ V, u ≠ v}
Key Properties of Kₙ:
| Graph | Vertices | Edges | Degree | Triangles |
|---|---|---|---|---|
| K₁ | 1 | 0 | 0 | 0 |
| K₂ | 2 | 1 | 1 | 0 |
| K₃ | 3 | 3 | 2 | 1 |
| K₄ | 4 | 6 | 3 | 4 |
| K₅ | 5 | 10 | 4 | 10 |
| K₆ | 6 | 15 | 5 | 20 |
| Kₙ | n | n(n-1)/2 | n-1 | C(n,3) |
Why n(n-1)/2 edges? Each of n vertices connects to n-1 others. That's n(n-1) connections, but we've counted each edge twice (once from each endpoint). So:
|E| = n(n-1)/2
This is also C(n, 2), the number of ways to choose 2 vertices from n to form an edge.
The number of triangles (K₃ subgraphs) in Kₙ is C(n, 3) = n(n-1)(n-2)/6. Each choice of 3 vertices forms exactly one triangle since they're all connected.
In directed graphs, a complete graph has both edges (u→v and v→u) for every pair of distinct vertices. For n vertices, this gives n(n-1) directed edges. If we only have one edge per pair (tournament), we get n(n-1)/2 edges.
A subgraph is a graph formed from a subset of the vertices and edges of another graph. Understanding different types of subgraphs is essential for graph analysis and algorithm design.
Graph H = (V', E') is a subgraph of G = (V, E) if:
A spanning subgraph contains all vertices but possibly fewer edges:
Example: A spanning tree of a connected graph uses all vertices but only n-1 edges.
An induced subgraph is determined entirely by choosing vertices; it includes all edges between those vertices:
The induced subgraph on vertex set S is denoted G[S].
Subgraph (non-induced)
We can choose to keep or remove any edge between selected vertices.
Example: From K₄, select vertices {1,2,3} and edges {(1,2), (2,3)} only.
Result: A path, not a triangle.
Induced Subgraph
All edges between selected vertices are automatically included.
Example: From K₄, select vertices {1,2,3}.
Result: K₃ (triangle), because all three edges must be included.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788
def create_subgraph( graph: dict[int, list[int]], vertices: set[int], edges: set[tuple[int, int]] | None = None) -> dict[int, list[int]]: """ Create a subgraph with specified vertices and edges. Args: graph: Original graph as adjacency list vertices: Subset of vertices to include edges: Optional set of edges to include. If None, include all edges between selected vertices (induced subgraph). Returns: Adjacency list for the subgraph """ subgraph = {v: [] for v in vertices} for v in vertices: for neighbor in graph.get(v, []): if neighbor in vertices: if edges is None: # Induced subgraph: include all edges between selected vertices subgraph[v].append(neighbor) elif (v, neighbor) in edges or (neighbor, v) in edges: # Explicit subgraph: only include specified edges subgraph[v].append(neighbor) return subgraph def induced_subgraph( graph: dict[int, list[int]], vertices: set[int]) -> dict[int, list[int]]: """ Create the induced subgraph G[S] for vertex set S. The induced subgraph contains exactly the vertices in S and ALL edges between them that exist in G. Time Complexity: O(|S| * max_degree) """ return create_subgraph(graph, vertices, edges=None) def is_subgraph(H: dict[int, list[int]], G: dict[int, list[int]]) -> bool: """ Check if H is a subgraph of G. H is a subgraph of G if: - Every vertex in H is in G - Every edge in H is in G """ # Check vertices if not set(H.keys()).issubset(set(G.keys())): return False # Check edges for v in H: for neighbor in H[v]: if neighbor not in G.get(v, []): return False return True # Example: Work with K4k4 = { 0: [1, 2, 3], 1: [0, 2, 3], 2: [0, 1, 3], 3: [0, 1, 2],} # Induced subgraph on {0, 1, 2} = K3k3 = induced_subgraph(k4, {0, 1, 2})print(f"K3 from K4: {k3}") # {0: [1, 2], 1: [0, 2], 2: [0, 1]} # Non-induced subgraph: just a pathpath = create_subgraph(k4, {0, 1, 2}, edges={(0, 1), (1, 2)})print(f"Path: {path}") # {0: [1], 1: [0, 2], 2: [1]} # Spanning subgraph: spanning tree of K4tree_edges = {(0, 1), (0, 2), (0, 3)}spanning_tree = create_subgraph(k4, set(k4.keys()), tree_edges)print(f"Spanning tree: {spanning_tree}")A clique is a subset of vertices that form a complete subgraph—every vertex in the clique is connected to every other vertex in the clique.
A clique in graph G = (V, E) is a subset C ⊆ V such that:
The graph above has:
Finding the maximum clique is NP-complete! This is one of the original 21 problems Richard Karp proved NP-complete in 1972.
Implications:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
def is_clique(graph: dict[int, list[int]], vertices: set[int]) -> bool: """ Check if a set of vertices forms a clique. Time Complexity: O(k²) where k = |vertices| """ vertex_list = list(vertices) for i in range(len(vertex_list)): for j in range(i + 1, len(vertex_list)): u, v = vertex_list[i], vertex_list[j] if v not in graph.get(u, []): return False return True def find_all_maximal_cliques(graph: dict[int, list[int]]) -> list[set[int]]: """ Find all maximal cliques using the Bron-Kerbosch algorithm. A maximal clique cannot be extended by adding another vertex. Time Complexity: O(3^(n/3)) worst case (exponential) """ cliques = [] vertices = set(graph.keys()) def bron_kerbosch(R: set, P: set, X: set): """ R: Current clique being built P: Prospective vertices that could extend the clique X: Excluded vertices (already processed) """ if not P and not X: # R is a maximal clique cliques.append(R.copy()) return # Pivot optimization: choose vertex with most neighbors in P pivot = max(P | X, key=lambda v: len(set(graph.get(v, [])) & P), default=None) if pivot is None: return pivot_neighbors = set(graph.get(pivot, [])) # Only consider vertices not adjacent to pivot for v in P - pivot_neighbors: neighbors = set(graph.get(v, [])) bron_kerbosch( R | {v}, P & neighbors, X & neighbors ) P = P - {v} X = X | {v} bron_kerbosch(set(), vertices, set()) return cliques def maximum_clique(graph: dict[int, list[int]]) -> set[int]: """Find the maximum (largest) clique.""" cliques = find_all_maximal_cliques(graph) return max(cliques, key=len) if cliques else set() # Examplegraph = { 0: [1, 2], 1: [0, 2, 3], 2: [0, 1, 3], 3: [1, 2, 4], 4: [3]} print(f"Is {{0,1,2}} a clique? {is_clique(graph, {0, 1, 2})}") # Trueprint(f"Is {{1,2,3}} a clique? {is_clique(graph, {1, 2, 3})}") # Trueprint(f"Is {{0,1,2,3}} a clique? {is_clique(graph, {0, 1, 2, 3})}") # False (0-3 not connected) all_cliques = find_all_maximal_cliques(graph)print(f"Maximal cliques: {all_cliques}")print(f"Maximum clique: {maximum_clique(graph)}")Cliques appear in: (1) Social networks: friend groups where everyone knows everyone, (2) Bioinformatics: protein complexes where all proteins interact, (3) Marketing: customer segments with similar purchasing patterns, (4) Scheduling: time slots where all participants are available.
An independent set (also called a stable set) is the opposite of a clique—a set of vertices where no two are connected.
An independent set in graph G = (V, E) is a subset I ⊆ V such that:
There's a beautiful duality between cliques and independent sets through graph complements (discussed later):
A clique in G corresponds to an independent set in the complement Ḡ, and vice versa.
| Property | Clique | Independent Set |
|---|---|---|
| Definition | All pairs connected | No pairs connected |
| Induced subgraph | Complete graph Kₖ | Empty graph (no edges) |
| Symbol for maximum | ω(G) | α(G) |
| Finding maximum | NP-complete | NP-complete |
| Verification | O(k²) | O(k²) |
| In complement graph | Becomes independent set | Becomes clique |
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081
def is_independent_set(graph: dict[int, list[int]], vertices: set[int]) -> bool: """ Check if a set of vertices forms an independent set (no edges between them). Time Complexity: O(k * max_degree) where k = |vertices| """ for v in vertices: for neighbor in graph.get(v, []): if neighbor in vertices: return False # Found an edge within the set return True def greedy_maximal_independent_set(graph: dict[int, list[int]]) -> set[int]: """ Find a maximal independent set using a greedy approach. Not guaranteed to find maximum, but runs in polynomial time. Time Complexity: O(V + E) """ independent = set() excluded = set() # Process vertices in some order (degree-based heuristic often helps) vertices = sorted(graph.keys(), key=lambda v: len(graph.get(v, []))) for v in vertices: if v not in excluded: # Add v to independent set independent.add(v) # Exclude all neighbors (they can't be in the set now) for neighbor in graph.get(v, []): excluded.add(neighbor) return independent def find_all_maximal_independent_sets(graph: dict[int, list[int]]) -> list[set[int]]: """ Find all maximal independent sets. Uses complement graph + Bron-Kerbosch (cliques in complement = indep sets in original). """ # Build complement graph all_vertices = set(graph.keys()) complement = {v: list(all_vertices - {v} - set(graph.get(v, []))) for v in graph} # Find cliques in complement (they're independent sets in original) from typing import List independent_sets: List[set] = [] def bron_kerbosch(R: set, P: set, X: set): if not P and not X: independent_sets.append(R.copy()) return for v in list(P): neighbors = set(complement.get(v, [])) bron_kerbosch(R | {v}, P & neighbors, X & neighbors) P = P - {v} X = X | {v} bron_kerbosch(set(), all_vertices, set()) return independent_sets # Example: Star graph (center + leaves)star = { 0: [1, 2, 3, 4], # Center 1: [0], # Leaf 2: [0], 3: [0], 4: [0],} # The leaves {1, 2, 3, 4} form an independent setprint(f"Is {{1,2,3,4}} independent? {is_independent_set(star, {1,2,3,4})}") # Trueprint(f"Is {{0,1}} independent? {is_independent_set(star, {0,1})}") # False # Maximum independent set in star = all leavesprint(f"Greedy MIS: {greedy_maximal_independent_set(star)}") # {1,2,3,4} or similarA bipartite graph is a graph whose vertices can be partitioned into two disjoint sets such that every edge connects a vertex in one set to a vertex in the other. There are no edges within either set.
Graph G = (V, E) is bipartite if there exist sets U and W such that:
A graph is bipartite if and only if it contains no odd-length cycles.
This provides both a definition and a test for bipartiteness.
The complete bipartite graph Km,n has:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
from collections import deque def is_bipartite(graph: dict[int, list[int]]) -> tuple[bool, dict[int, int] | None]: """ Check if a graph is bipartite using BFS 2-coloring. Returns: (is_bipartite, coloring) - is_bipartite: True if graph is bipartite - coloring: dict mapping vertex -> 0 or 1 (its partition), or None if not bipartite Time Complexity: O(V + E) """ color = {} for start in graph: if start in color: continue # Already colored (connected component already processed) # BFS to 2-color this component queue = deque([start]) color[start] = 0 while queue: vertex = queue.popleft() current_color = color[vertex] next_color = 1 - current_color # Alternate: 0->1, 1->0 for neighbor in graph.get(vertex, []): if neighbor not in color: color[neighbor] = next_color queue.append(neighbor) elif color[neighbor] == current_color: # Same color = odd cycle = not bipartite return False, None return True, color def get_bipartition(graph: dict[int, list[int]]) -> tuple[set[int], set[int]] | None: """ Get the two vertex sets of a bipartite graph. Returns (set_0, set_1) or None if not bipartite. """ is_bip, coloring = is_bipartite(graph) if not is_bip: return None set_0 = {v for v, c in coloring.items() if c == 0} set_1 = {v for v, c in coloring.items() if c == 1} return set_0, set_1 # Examplesbipartite = { 0: [2, 3], # Set A: {0, 1} 1: [2, 3], # Set B: {2, 3} 2: [0, 1], 3: [0, 1],} triangle = { 0: [1, 2], 1: [0, 2], 2: [0, 1],} is_bip, coloring = is_bipartite(bipartite)print(f"Bipartite? {is_bip}, partition: {get_bipartition(bipartite)}") # True is_bip, _ = is_bipartite(triangle)print(f"Triangle bipartite? {is_bip}") # False (odd cycle)Bipartite structure appears in: matching problems (job applicants ↔ jobs), recommendation systems (users ↔ items), constraint satisfaction (variables ↔ clauses), and network flow (sources ↔ sinks). Recognizing bipartiteness enables specialized algorithms like Hopcroft-Karp for maximum matching.
The complement of a graph G, denoted Ḡ or G', has the same vertices but opposite edges—an edge exists in Ḡ if and only if it doesn't exist in G (for distinct vertex pairs).
For graph G = (V, E), its complement is Ḡ = (V, Ē) where:
Ē = {(u, v) : u, v ∈ V, u ≠ v, (u, v) ∉ E}
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071
def complement(graph: dict[int, list[int]]) -> dict[int, list[int]]: """ Compute the complement of an undirected simple graph. The complement has an edge between u and v IFF the original graph does NOT have that edge. Time Complexity: O(V²) Space Complexity: O(V²) for dense complement """ vertices = set(graph.keys()) comp = {v: [] for v in vertices} for u in vertices: neighbors_of_u = set(graph.get(u, [])) for v in vertices: if v != u and v not in neighbors_of_u: comp[u].append(v) return comp def is_self_complementary(graph: dict[int, list[int]]) -> bool: """ Check if a graph is isomorphic to its complement. (Simplified check: same edge count, same degree sequence) """ comp = complement(graph) # Quick check: must have same number of edges edges_original = sum(len(adj) for adj in graph.values()) // 2 edges_comp = sum(len(adj) for adj in comp.values()) // 2 if edges_original != edges_comp: return False # Check degree sequences match degrees_original = sorted(len(adj) for adj in graph.values()) degrees_comp = sorted(len(adj) for adj in comp.values()) return degrees_original == degrees_comp # Example: Path graph P4p4 = { 0: [1], 1: [0, 2], 2: [1, 3], 3: [2],}# Original: 0-1-2-3 (path)# Complement: 0-2, 0-3, 1-3 (forms a different shape) comp_p4 = complement(p4)print(f"P4: {p4}")print(f"Complement: {comp_p4}") # P4 is actually self-complementary!print(f"P4 self-complementary? {is_self_complementary(p4)}") # Duality exampledef clique_via_complement(graph: dict[int, list[int]], vertices: set[int]) -> bool: """A set is a clique in G iff it's an independent set in complement(G).""" comp = complement(graph) # Check if vertices form independent set in complement for v in vertices: for neighbor in comp.get(v, []): if neighbor in vertices: return False # Has edge in complement = not clique in original return TrueSome graphs are self-complementary: isomorphic to their own complement. Examples include P₄ (4-vertex path) and C₅ (5-cycle). Self-complementary graphs on n vertices exist only when n(n-1)/2 is even, meaning n ≡ 0 or 1 (mod 4).
Graph coloring assigns colors to vertices such that no two adjacent vertices share the same color. Each color class forms an independent set.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
def greedy_coloring(graph: dict[int, list[int]]) -> dict[int, int]: """ Greedy graph coloring algorithm. Assigns the smallest possible color to each vertex in order. Not optimal, but provides upper bound on chromatic number. Time Complexity: O(V + E) """ color = {} for vertex in graph: # Find colors used by neighbors neighbor_colors = {color[n] for n in graph.get(vertex, []) if n in color} # Assign smallest color not used by neighbors c = 0 while c in neighbor_colors: c += 1 color[vertex] = c return color def chromatic_number_upper_bound(graph: dict[int, list[int]]) -> int: """Upper bound on chromatic number from greedy coloring.""" coloring = greedy_coloring(graph) return max(coloring.values()) + 1 if coloring else 0 def is_k_colorable(graph: dict[int, list[int]], k: int) -> tuple[bool, dict | None]: """ Check if graph is k-colorable using backtracking. Returns (is_colorable, coloring or None) """ vertices = list(graph.keys()) color = {} def backtrack(idx: int) -> bool: if idx == len(vertices): return True # All vertices colored successfully vertex = vertices[idx] neighbor_colors = {color.get(n) for n in graph.get(vertex, []) if n in color} for c in range(k): if c not in neighbor_colors: color[vertex] = c if backtrack(idx + 1): return True del color[vertex] return False if backtrack(0): return True, color return False, None # Examplespetersen = { # Petersen graph (simplified subset) 0: [1, 4, 5], 1: [0, 2, 6], 2: [1, 3, 7], 3: [2, 4, 8], 4: [0, 3, 9], 5: [0, 7, 8], 6: [1, 8, 9], 7: [2, 5, 9], 8: [3, 5, 6], 9: [4, 6, 7]} coloring = greedy_coloring(petersen)print(f"Greedy coloring uses {chromatic_number_upper_bound(petersen)} colors") # Check if 3-colorableis_3col, col3 = is_k_colorable(petersen, 3)print(f"Petersen 3-colorable? {is_3col}")Graph coloring solves: scheduling (time slots = colors, conflicts = edges), register allocation (variables = vertices, interference = edges), map coloring (regions = vertices, borders = edges), and frequency assignment (transmitters = vertices, interference = edges).
Complete graphs represent maximum connectivity, while subgraphs let us extract and analyze portions of larger graphs. These concepts interweave with cliques, independent sets, bipartiteness, and coloring to form a rich theoretical foundation. Let's consolidate:
Module Complete:
With this page, you've completed the Graph Properties module, covering connectivity, cycles, degrees, and special graph structures. You now have the vocabulary and analytical tools to describe and reason about graph structure—essential preparation for graph algorithms in the upcoming chapters.
You've mastered the fundamental structural properties of graphs: connectivity tells us what can reach what, cycles reveal circular dependencies, degrees quantify local connectivity, and complete subgraphs identify dense clusters. Next, we'll explore how trees relate to graphs, connecting these data structures in a unified framework.