Loading learning content...
Graphs are fundamentally about relationships. The vertices are entities, but the interesting questions—shortest paths, connectivity, clustering—all arise from how vertices relate to each other through edges.
To reason about these relationships precisely, we need vocabulary. Terms like "adjacent," "neighbor," and "endpoint" may seem simple, but they form the linguistic foundation for describing graph structure, specifying algorithms, and proving theorems. This page builds your fluency in the language of graph relationships.
By the end of this page, you will understand what endpoints are, master the concept of adjacency between vertices, distinguish between neighbors and adjacency in directed graphs, and confidently use these terms when analyzing graph structure or implementing graph algorithms.
Every edge in a graph connects exactly two vertices (or one vertex to itself in the case of a self-loop). These vertices are called the endpoints of the edge.
Formal Definition:
For an edge e = (u, v) (directed) or e = {u, v} (undirected):
Directed Graph Terminology:
For a directed edge e = (u, v):
| Edge | Tail (Source) | Head (Target) | Description |
|---|---|---|---|
| e₁ = (A, B) | A | B | Edge from A to B |
| e₂ = (B, C) | B | C | Edge from B to C |
| e₃ = (A, C) | A | C | Edge from A to C |
In undirected graphs, both endpoints play symmetric roles. There's no 'from' or 'to'—just 'connected.' We simply call them the two endpoints, without distinguishing source from target.
Adjacency is the fundamental relationship between vertices in a graph. Two vertices are adjacent if they are directly connected by an edge.
Formal Definition (Undirected):
In an undirected graph G = (V, E), vertices u and v are adjacent (or neighbors) if: $${u, v} \in E$$
In other words, there exists an edge with u and v as its endpoints.
Formal Definition (Directed):
In a directed graph G = (V, E):
Note the asymmetry: in directed graphs, "u is adjacent to v" and "v is adjacent to u" are different statements.
123456789101112131415161718192021222324252627
Undirected Graph: V = {A, B, C, D} E = {{A,B}, {B,C}, {B,D}} Adjacency relationships: A is adjacent to: B B is adjacent to: A, C, D C is adjacent to: B D is adjacent to: B Note: "A adjacent to B" ⟺ "B adjacent to A" --- Directed Graph: V = {A, B, C, D} E = {(A,B), (B,C), (C,B), (B,D)} Adjacency relationships: From A: B is adjacent to A (A → B exists) From B: C, D are adjacent to B (B → C, B → D exist) From C: B is adjacent to C (C → B exists) From D: nothing (no outgoing edges) Note: B adjacent to C (B → C) AND C adjacent to B (C → B) But A adjacent to B (A → B) does NOT mean B adjacent to A (no B → A edge)Neighbors of a vertex are all vertices adjacent to it. The set of all neighbors is called the vertex's neighborhood.
Formal Definition (Undirected):
For a vertex v in an undirected graph G = (V, E), the neighborhood of v is: $$N(v) = {u \in V : {u, v} \in E}$$
The closed neighborhood includes v itself: $$N[v] = N(v) \cup {v}$$
Formal Definition (Directed):
For a vertex v in a directed graph:
Out-neighbors (successors): $$N^+(v) = {u \in V : (v, u) \in E}$$
In-neighbors (predecessors): $$N^-(v) = {u \in V : (u, v) \in E}$$
The out-neighbors are vertices you can reach in one step from v; in-neighbors are vertices that can reach v in one step.
| Notation | Name | Definition | Usage |
|---|---|---|---|
| N(v) | Open neighborhood | Vertices adjacent to v | Undirected graphs |
| N[v] | Closed neighborhood | N(v) ∪ {v} | When v should be included |
| N⁺(v) | Out-neighborhood | Vertices reachable from v | Directed graphs |
| N⁻(v) | In-neighborhood | Vertices that reach v | Directed graphs |
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
from typing import Set, Dict, List class UndirectedGraph: """Graph with neighborhood operations.""" def __init__(self): self.adj: Dict[str, Set[str]] = {} def add_vertex(self, v: str): if v not in self.adj: self.adj[v] = set() def add_edge(self, u: str, v: str): self.add_vertex(u) self.add_vertex(v) self.adj[u].add(v) self.adj[v].add(u) def neighbors(self, v: str) -> Set[str]: """N(v): Open neighborhood - vertices adjacent to v.""" return self.adj.get(v, set()).copy() def closed_neighborhood(self, v: str) -> Set[str]: """N[v]: Closed neighborhood - N(v) ∪ {v}.""" result = self.neighbors(v) result.add(v) return result def are_adjacent(self, u: str, v: str) -> bool: """Check if u and v are neighbors.""" return v in self.adj.get(u, set()) class DirectedGraph: """Directed graph with in/out neighborhood operations.""" def __init__(self): self.out_adj: Dict[str, Set[str]] = {} # v -> successors self.in_adj: Dict[str, Set[str]] = {} # v -> predecessors def add_vertex(self, v: str): if v not in self.out_adj: self.out_adj[v] = set() self.in_adj[v] = set() def add_edge(self, u: str, v: str): """Add directed edge u → v.""" self.add_vertex(u) self.add_vertex(v) self.out_adj[u].add(v) self.in_adj[v].add(u) def out_neighbors(self, v: str) -> Set[str]: """N⁺(v): Vertices reachable from v in one step.""" return self.out_adj.get(v, set()).copy() def in_neighbors(self, v: str) -> Set[str]: """N⁻(v): Vertices that can reach v in one step.""" return self.in_adj.get(v, set()).copy() # Example usageg = UndirectedGraph()for edge in [("A", "B"), ("B", "C"), ("B", "D"), ("C", "D")]: g.add_edge(*edge) print(f"N(B) = {g.neighbors('B')}") # {A, C, D}print(f"N[B] = {g.closed_neighborhood('B')}") # {A, B, C, D}print(f"A adjacent to B? {g.are_adjacent('A', 'B')}") # True dg = DirectedGraph()for u, v in [("A", "B"), ("B", "C"), ("C", "B"), ("B", "D")]: dg.add_edge(u, v) print(f"N⁺(B) = {dg.out_neighbors('B')}") # {C, D}print(f"N⁻(B) = {dg.in_neighbors('B')}") # {A, C}The number of neighbors of a vertex equals its degree: |N(v)| = deg(v). For directed graphs: |N⁺(v)| = out-degree(v) and |N⁻(v)| = in-degree(v). This connection makes neighborhood operations central to degree-based algorithms.
In directed graphs, we distinguish between edges entering and leaving a vertex. This gives rise to two degree measures:
Out-Degree:
The out-degree of vertex v, denoted deg⁺(v) or out-deg(v), is the number of edges leaving v: $$\deg^+(v) = |{(v, u) : (v, u) \in E}| = |N^+(v)|$$
In-Degree:
The in-degree of vertex v, denoted deg⁻(v) or in-deg(v), is the number of edges entering v: $$\deg^-(v) = |{(u, v) : (u, v) \in E}| = |N^-(v)|$$
Total Degree:
Sometimes we care about the total number of edges incident to v: $$\deg(v) = \deg^+(v) + \deg^-(v)$$
| Vertex Type | In-Degree | Out-Degree | Interpretation |
|---|---|---|---|
| Source | 0 | 0 | No incoming edges; starting point |
| Sink | 0 | 0 | No outgoing edges; terminal point |
| Isolated | 0 | 0 | No edges at all |
| Internal | 0 | 0 | Both incoming and outgoing edges |
123456789101112131415161718
Directed Graph: V = {A, B, C, D, E} E = {(A,B), (A,C), (B,C), (B,D), (C,D), (D,E)} Degree Analysis: Vertex A: in-deg = 0, out-deg = 2 → Source Vertex B: in-deg = 1, out-deg = 2 → Internal Vertex C: in-deg = 2, out-deg = 1 → Internal Vertex D: in-deg = 2, out-deg = 1 → Internal Vertex E: in-deg = 1, out-deg = 0 → Sink Verification (Directed Handshaking Lemma): Sum of in-degrees: 0 + 1 + 2 + 2 + 1 = 6 Sum of out-degrees: 2 + 2 + 1 + 1 + 0 = 6 Number of edges: 6 ✓ Both sums equal |E| because each edge contributes 1 to one vertex's out-degree and 1 to another's in-degree.In a directed graph: Σ deg⁺(v) = Σ deg⁻(v) = |E|. Every edge has exactly one tail (contributing to out-degree) and one head (contributing to in-degree). This is the directed version of the handshaking lemma.
Understanding adjacency helps solve real-world problems by mapping domain concepts to graph operations:
Social Network Analysis:
Web Crawling:
Dependency Management:
| Domain | N⁺(v) Meaning | N⁻(v) Meaning | Example Query |
|---|---|---|---|
| Accounts v follows | v's followers | Who does Elon follow? | |
| Web Graph | Pages v links to | Pages linking to v | Find all backlinks to a URL |
| Package Manager | Dependencies of v | Dependents of v | What breaks if we remove v? |
| Course Prerequisites | Courses v enables | Courses needed for v | What can I take after Calc? |
| City Traffic | Roads leaving v | Roads entering v | Which streets lead downtown? |
Many graph algorithms boil down to: 'For each vertex, examine its neighbors.' BFS explores neighbors level-by-level. DFS explores neighbors depth-first. PageRank propagates scores through neighbors. Understanding neighborhood operations is key to understanding graph algorithms.
Based on their neighbor relationships, vertices are often classified into categories that reveal structural properties:
By Degree:
By Direction (Directed Graphs):
By Connectivity:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172
from typing import List, Dict, Set, Tuplefrom collections import defaultdict def classify_vertices(edges: List[Tuple[str, str]], directed: bool = False) -> Dict[str, str]: """ Classify vertices based on their degree properties. Returns a dictionary mapping vertex to its classification. """ if directed: in_degree: Dict[str, int] = defaultdict(int) out_degree: Dict[str, int] = defaultdict(int) vertices: Set[str] = set() for u, v in edges: out_degree[u] += 1 in_degree[v] += 1 vertices.add(u) vertices.add(v) classification = {} for v in vertices: in_d = in_degree[v] out_d = out_degree[v] if in_d == 0 and out_d == 0: classification[v] = "isolated" elif in_d == 0: classification[v] = "source" elif out_d == 0: classification[v] = "sink" else: classification[v] = "internal" return classification else: # Undirected degree: Dict[str, int] = defaultdict(int) for u, v in edges: degree[u] += 1 degree[v] += 1 classification = {} for v, d in degree.items(): if d == 0: classification[v] = "isolated" elif d == 1: classification[v] = "leaf" elif d >= 5: # Arbitrary threshold for "hub" classification[v] = "hub" else: classification[v] = "internal" return classification # Example: Directed graphdirected_edges = [("A", "B"), ("A", "C"), ("B", "C"), ("C", "D")]print("Directed graph classification:")for v, cls in classify_vertices(directed_edges, directed=True).items(): print(f" {v}: {cls}")# A: source, B: internal, C: internal, D: sink # Example: Undirected graph (tree structure)undirected_edges = [("A", "B"), ("B", "C"), ("B", "D"), ("B", "E"), ("B", "F"), ("B", "G")]print("Undirected graph classification:")for v, cls in classify_vertices(undirected_edges, directed=False).items(): print(f" {v}: {cls}")# A: leaf, B: hub, C-G: leavesIn Directed Acyclic Graphs (DAGs), sources and sinks are especially important. Every DAG has at least one source (vertex with no predecessors) and at least one sink (vertex with no successors). Topological sort algorithms often start from sources or end at sinks.
Sometimes we care about vertices that are farther than one hop away. This leads to the concept of k-hop neighborhoods:
Second-Order Neighbors (2-hop):
The 2-hop neighborhood of v includes all vertices reachable in exactly 2 steps: $$N_2(v) = {u : \exists w \in N(v) \text{ such that } u \in N(w)} \setminus (N(v) \cup {v})$$
In plain language: neighbors of neighbors, excluding direct neighbors and v itself.
k-hop Neighborhood:
More generally, Nₖ(v) contains vertices reachable in exactly k steps but not fewer.
Applications:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
from typing import Set, Dictfrom collections import deque def k_hop_neighborhood(adj: Dict[str, Set[str]], start: str, k: int) -> Dict[int, Set[str]]: """ Compute vertices at each distance from start, up to k hops. Returns: {distance: set of vertices at that distance} """ result: Dict[int, Set[str]] = {i: set() for i in range(k + 1)} result[0].add(start) visited = {start} queue = deque([(start, 0)]) while queue: vertex, dist = queue.popleft() if dist < k: for neighbor in adj.get(vertex, set()): if neighbor not in visited: visited.add(neighbor) result[dist + 1].add(neighbor) queue.append((neighbor, dist + 1)) return result def friends_of_friends(adj: Dict[str, Set[str]], person: str) -> Set[str]: """ Find 'friends of friends' - vertices at distance exactly 2. Useful for friend recommendations. """ direct_friends = adj.get(person, set()) fof = set() for friend in direct_friends: for friend_of_friend in adj.get(friend, set()): # Exclude self and direct friends if friend_of_friend != person and friend_of_friend not in direct_friends: fof.add(friend_of_friend) return fof # Example: Social networksocial_network = { "Alice": {"Bob", "Carol"}, "Bob": {"Alice", "Carol", "Dave"}, "Carol": {"Alice", "Bob", "Eve"}, "Dave": {"Bob", "Frank"}, "Eve": {"Carol"}, "Frank": {"Dave"},} print("Alice's direct friends:", social_network["Alice"])# {'Bob', 'Carol'} print("Alice's friends-of-friends:", friends_of_friends(social_network, "Alice"))# {'Dave', 'Eve'} - reachable in 2 hops but not 1 print("Neighborhoods at each distance from Alice:")neighborhoods = k_hop_neighborhood(social_network, "Alice", 3)for dist, vertices in neighborhoods.items(): print(f" Distance {dist}: {vertices}")# Distance 0: {'Alice'}# Distance 1: {'Bob', 'Carol'}# Distance 2: {'Dave', 'Eve'}# Distance 3: {'Frank'}Breadth-First Search naturally computes k-hop neighborhoods as it explores. The BFS level equals the distance from the start vertex. This is why BFS is foundational for social network analysis and influence modeling.
We've built fluency in the vocabulary of graph relationships. Let's consolidate:
What's Next:
Most definitions assume "well-behaved" edges—each edge connects two distinct vertices exactly once. But what about edges from a vertex to itself? Or multiple edges between the same pair? The next page explores self-loops and multi-edges—the special cases that simple graph definitions exclude.
You now command the vocabulary of graph relationships. Terms like 'adjacent,' 'neighbor,' 'in-degree,' and 'k-hop neighborhood' are no longer mysterious—they're precise tools for describing and analyzing graph structure. This vocabulary underlies every graph algorithm.