From beed91764e862a338e3fa012785822be36683dd4 Mon Sep 17 00:00:00 2001 From: Randy Davila Date: Mon, 2 Feb 2026 22:01:48 -0600 Subject: [PATCH 1/4] added the majority of new invariants/ predicates --- src/graphcalc/core/basics.py | 157 ++++ src/graphcalc/invariants/__init__.py | 4 + src/graphcalc/invariants/classics.py | 674 ++++++++++++++ src/graphcalc/invariants/core_invariants.py | 698 ++++++++++++++ .../invariants/critical_invariants.py | 853 ++++++++++++++++++ src/graphcalc/invariants/degree.py | 260 ++++++ src/graphcalc/invariants/local_invariants.py | 703 +++++++++++++++ 7 files changed, 3349 insertions(+) create mode 100644 src/graphcalc/invariants/core_invariants.py create mode 100644 src/graphcalc/invariants/critical_invariants.py create mode 100644 src/graphcalc/invariants/local_invariants.py diff --git a/src/graphcalc/core/basics.py b/src/graphcalc/core/basics.py index 2c7c279..1489539 100644 --- a/src/graphcalc/core/basics.py +++ b/src/graphcalc/core/basics.py @@ -42,6 +42,8 @@ 'connected_and_cograph', 'nontrivial', 'isolate_free', + 'is_C4_free', + 'is_induced_C4_free', ] class SimpleGraph(nx.Graph): @@ -1288,3 +1290,158 @@ def isolate_free(G: GraphLike) -> bool: if order(G) == 0: return True return all(deg > 0 for deg in gc.degree_sequence(G)) + +def is_C4_free(G): + r""" + Test whether a graph is **C4-free** (contains no 4-cycle as a subgraph). + + This function returns ``True`` iff :math:`G` contains **no** copy of the 4-cycle + :math:`C_4` as a (not-necessarily-induced) subgraph. + + Characterization used + --------------------- + An undirected simple graph contains a (not necessarily induced) 4-cycle iff there exist + **distinct** vertices :math:`u \neq v` having at least **two distinct common neighbors**. + Indeed, if :math:`x` and :math:`y` are distinct common neighbors of :math:`u` and :math:`v`, + then the edges + + .. math:: + + ux,\; xv,\; vy,\; yu + + form a 4-cycle subgraph :math:`u-x-v-y-u`. + + If additional edges among these four vertices exist (e.g. :math:`uv` or :math:`xy`), + they appear as chords; the 4-cycle is still present as a subgraph. + + Parameters + ---------- + G : networkx.Graph-like + Intended for **finite simple undirected graphs**. The test is based on common-neighbor + intersections computed via ``G.neighbors(u)`` and assumes undirected adjacency. + + If you pass a directed graph, neighbors are interpreted according to NetworkX's directed + neighbor semantics (successors), which typically does *not* match the undirected notion of + a 4-cycle; convert first via ``G.to_undirected()`` if desired. + + Returns + ------- + bool + ``True`` iff :math:`G` contains no (not-necessarily-induced) :math:`C_4` subgraph. + + Notes + ----- + - This checks for :math:`C_4` as a **subgraph**, not as an induced subgraph. In particular, + graphs that contain a 4-cycle with one or both diagonals are **not** C4-free. + - Graphs with fewer than 4 vertices are vacuously C4-free. + + Complexity + ---------- + Let :math:`n=|V(G)|` and :math:`m=|E(G)|`. Building neighbor sets takes :math:`O(n+m)` time. + The code then checks all :math:`\binom{n}{2}` unordered vertex pairs and computes set + intersections. In the worst case (dense graphs) the runtime is :math:`O(n^3)`, and it is + typically faster on sparse graphs. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.is_C4_free(nx.cycle_graph(4)) + False + >>> gc.is_C4_free(nx.complete_graph(4)) # contains many C4 subgraphs (with chords) + False + >>> gc.is_C4_free(nx.path_graph(6)) + True + """ + nbrs = {u: set(G.neighbors(u)) for u in G.nodes()} + nodes = list(G.nodes()) + for i in range(len(nodes)): + u = nodes[i] + for j in range(i + 1, len(nodes)): + v = nodes[j] + if len(nbrs[u] & nbrs[v]) >= 2: + return False + return True + + +def is_induced_C4_free(G): + r""" + Test whether a graph is **induced-C4-free** (contains no induced 4-cycle). + + This function returns ``True`` iff :math:`G` contains **no induced** subgraph isomorphic + to :math:`C_4`. Equivalently, there do not exist four vertices whose induced subgraph + is exactly a 4-cycle. + + Characterization used + --------------------- + An undirected simple graph contains an induced :math:`C_4` iff there exist distinct vertices + :math:`u \neq v` (the opposite vertices of the cycle) such that: + + 1. :math:`u` and :math:`v` are **nonadjacent**, + 2. :math:`u` and :math:`v` have two distinct common neighbors :math:`x` and :math:`y`, and + 3. :math:`x` and :math:`y` are **nonadjacent**. + + Then the induced subgraph on :math:`\{u,x,v,y\}` has edges :math:`ux, xv, vy, yu` and no chords, + hence it is exactly :math:`C_4`. + + Parameters + ---------- + G : networkx.Graph-like + Intended for **finite simple undirected graphs**. The test uses ``G.has_edge`` and + common-neighbor intersections from ``G.neighbors``. + + If you pass a directed graph, adjacency and neighbor semantics follow NetworkX's directed + conventions and typically do not match the undirected induced-cycle notion; convert first via + ``G.to_undirected()`` if that matches your intent. + + Returns + ------- + bool + ``True`` iff :math:`G` contains no induced :math:`C_4`. + + Notes + ----- + - This property is **strictly weaker** than being C4-free as a subgraph: a graph may contain + 4-cycles with chords (hence not be C4-free) yet still be induced-C4-free. + For example, :math:`K_4` contains many 4-cycles as subgraphs but has no induced :math:`C_4`. + - Graphs with fewer than 4 vertices are vacuously induced-C4-free. + + Complexity + ---------- + Building neighbor sets takes :math:`O(n+m)`. The outer loop considers all unordered pairs + :math:`\{u,v\}` (i.e. :math:`O(n^2)` pairs). For each nonadjacent pair, it inspects pairs + within the common-neighbor set; in the worst case this can be :math:`O(n^4)` for dense graphs, + though it is typically much faster on sparse graphs. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.is_induced_C4_free(nx.cycle_graph(4)) + False + >>> gc.is_induced_C4_free(nx.complete_graph(4)) # has C4 subgraphs but no induced C4 + True + >>> gc.is_induced_C4_free(nx.path_graph(6)) + True + """ + nbrs = {u: set(G.neighbors(u)) for u in G.nodes()} + nodes = list(G.nodes()) + for i in range(len(nodes)): + u = nodes[i] + for j in range(i + 1, len(nodes)): + v = nodes[j] + if G.has_edge(u, v): + continue # opposite vertices in an induced C4 must be nonadjacent + + common = list(nbrs[u] & nbrs[v]) + if len(common) < 2: + continue + + # Need two common neighbors that are not adjacent (to avoid chord x-y). + for a in range(len(common)): + x = common[a] + for b in range(a + 1, len(common)): + y = common[b] + if not G.has_edge(x, y): + return False + return True diff --git a/src/graphcalc/invariants/__init__.py b/src/graphcalc/invariants/__init__.py index 3522af0..9eb0897 100644 --- a/src/graphcalc/invariants/__init__.py +++ b/src/graphcalc/invariants/__init__.py @@ -40,3 +40,7 @@ from graphcalc.invariants.domination import * from graphcalc.invariants.spectral import * from graphcalc.invariants.zero_forcing import * +from graphcalc.invariants.core_invariants import * +from graphcalc.invariants.critical_invariants import * +from graphcalc.invariants.local_invariants import * + diff --git a/src/graphcalc/invariants/classics.py b/src/graphcalc/invariants/classics.py index 18ea27f..79ed3e5 100644 --- a/src/graphcalc/invariants/classics.py +++ b/src/graphcalc/invariants/classics.py @@ -2,6 +2,7 @@ import pulp import itertools import networkx as nx +import math from graphcalc.core import SimpleGraph from graphcalc.utils import ( @@ -26,6 +27,9 @@ "triameter", "vertex_clique_cover_partition", "vertex_clique_cover_number", + "arboricity", + "linear_arboricity", + "bipartite_number", ] @enforce_type(0, (nx.Graph, SimpleGraph)) @@ -725,3 +729,673 @@ def triameter(G: GraphLike) -> int: if s > tri: tri = s return tri + +def bipartite_number(G): + r""" + Compute the **bipartite number** of a graph :math:`G`. + + The bipartite number :math:`b(G)` is the order (number of vertices) of a largest + **induced bipartite subgraph** of :math:`G`. Equivalently, it is the maximum size of a + vertex subset whose induced subgraph is bipartite: + + .. math:: + + b(G) \;=\; \max\{\, |S| : S \subseteq V(G)\ \text{and}\ G[S]\ \text{is bipartite}\,\}, + + where :math:`G[S]` denotes the subgraph of :math:`G` induced by :math:`S`. + + This invariant is complementary to the **odd cycle transversal number** (also called the + **vertex bipartization number**), which is the minimum number of vertices that must be + deleted to make the graph bipartite. Writing :math:`\tau_{\mathrm{odd}}(G)` for that minimum, + + .. math:: + + b(G) \;=\; |V(G)| - \tau_{\mathrm{odd}}(G). + + Parameters + ---------- + G : networkx.Graph-like + A finite undirected graph. The bipartiteness test is performed with + :func:`networkx.algorithms.bipartite.basic.is_bipartite` on induced subgraphs. + + For best-defined behavior, use a simple graph (no self-loops, no parallel edges). + A self-loop makes a graph non-bipartite; parallel edges do not affect bipartiteness. + + Returns + ------- + int + The bipartite number :math:`b(G)`. If :math:`G` is empty (has no vertices), returns 0. + + Notes + ----- + - Since bipartite graphs are exactly those containing **no odd cycle**, this function + searches for the largest induced subgraph that is free of odd cycles. + - The empty graph is bipartite; hence the optimization problem is always feasible, and + :math:`b(G)` is well-defined for all finite graphs. + - The implementation below is **exact** but **brute force**: it checks induced subgraphs + in decreasing order of size and returns the first bipartite one found. This is intended + only for small graphs. + + Complexity + ---------- + Exponential in :math:`n = |V(G)|`. In the worst case it may examine :math:`\Theta(2^n)` + vertex subsets, and each check runs a bipartiteness test on the induced subgraph. + Practical only for small :math:`n` (often :math:`n \lesssim 20`, depending on density). + + Examples + -------- + >>> import graphcalc as gc + >>> from graphcalc.generators import cycle_graph + >>> G = cycle_graph(5) # C5 is not bipartite; deleting any 1 vertex gives P4 + >>> gc.bipartite_number(G) + 4 + + >>> from graphcalc.generators import complete_graph + >>> H = complete_graph(4) # K4: largest induced bipartite subgraph is K2,2 on 4 vertices? no, K4 has triangles + >>> gc.bipartite_number(H) + 2 + """ + n = G.number_of_nodes() + if n == 0: + return 0 + + nodes = list(G.nodes()) + + # Search from large to small; stop at first feasible size. + for k in range(n, 0, -1): + for S in itertools.combinations(nodes, k): + if nx.is_bipartite(G.subgraph(S)): + return k + + # k = 0 always works (empty graph is bipartite), but keep conventionally: + return 0 + +def average_distance(G): + r""" + Compute the **average finite shortest-path distance** of an undirected graph. + + This function returns the mean of the unweighted shortest-path distance + :math:`d_G(u,v)` over all **unordered vertex pairs** :math:`\{u,v\}` for which + the distance is finite (i.e., :math:`u` and :math:`v` lie in the same connected + component). Formally, let + + .. math:: + + P \;=\; \bigl\{ \{u,v\} \subseteq V(G) : u \neq v,\ d_G(u,v) < \infty \bigr\}, + + then the returned value is + + .. math:: + + \operatorname{avgdist}(G) \;=\; + \begin{cases} + \dfrac{1}{|P|}\sum_{\{u,v\}\in P} d_G(u,v), & |P|>0,\\[6pt] + 0, & |P|=0. + \end{cases} + + Conventions + ----------- + - If :math:`|V(G)| < 2`, the function returns ``0.0``. + - If :math:`G` is disconnected, only pairs of vertices within the same connected + component are included; pairs in different components are **ignored** (they are + not treated as having infinite distance). + - With these conventions, the only way :math:`|P|=0` is when :math:`|V(G)|<2`. + + Parameters + ---------- + G : networkx.Graph-like + A finite undirected graph. Distances are computed as **unweighted** + shortest-path lengths (BFS distances). If you require weighted distances, + use Dijkstra-based routines (e.g. ``nx.single_source_dijkstra_path_length``) + and adjust the definition accordingly. + + Returns + ------- + float + The average finite shortest-path distance over all connected unordered vertex pairs. + + Notes + ----- + - For a connected graph, this equals the standard **average shortest-path length** + (also called the mean distance). + - Some authors define the average distance only for connected graphs (and otherwise + return :math:`\infty` or raise an exception). This implementation instead computes + an *intra-component* mean, which stays finite on disconnected graphs. + + Complexity + ---------- + Let the connected components of :math:`G` be :math:`C_1,\dots,C_t`. The implementation + runs a BFS from each vertex within each component and counts each unordered pair once. + The time complexity is + + .. math:: + + O\!\left(\sum_{i=1}^t |V(C_i)|\bigl(|V(C_i)| + |E(C_i)|\bigr)\right), + + and the additional memory used by each BFS is :math:`O(|V(C_i)|)`. + + Examples + -------- + >>> import networkx as nx + >>> # Path on 4 vertices: distances are 1,2,3,1,2,1 (sum 10 over 6 pairs) + >>> G = nx.path_graph(4) + >>> average_distance(G) + 1.6666666666666667 + + >>> # Disconnected: average over pairs within components only + >>> H = nx.disjoint_union(nx.path_graph(3), nx.path_graph(2)) + >>> average_distance(H) + 1.25 + """ + n = G.number_of_nodes() + if n < 2: + return 0.0 + + total = 0 + count = 0 + + for comp in nx.connected_components(G): + nodes = list(comp) + k = len(nodes) + if k < 2: + continue + + # Count each unordered pair {u,v} exactly once by only adding distances to "later" vertices. + index = {v: i for i, v in enumerate(nodes)} + for u in nodes: + dist_u = nx.single_source_shortest_path_length(G, u) + iu = index[u] + for v, d in dist_u.items(): + iv = index.get(v) + if iv is not None and iv > iu: + total += d + count += 1 + + return (total / count) if count > 0 else 0.0 + +def _all_simple_paths_vertex_sets(G): + r""" + Enumerate candidate path vertex-sets for an exact **vertex-disjoint path cover** search. + + This helper constructs a collection of subsets :math:`P \subseteq V(G)` such that the vertices + in :math:`P` can be ordered to form a **simple undirected path** in :math:`G`. Each candidate + path is represented only by its **vertex set** (a ``frozenset``), not by an ordered sequence. + + In particular, all singleton sets :math:`\{v\}` are included, corresponding to paths of length 0. + + Parameters + ---------- + G : networkx.Graph-like + Intended for finite undirected graphs (typically simple graphs). Paths are interpreted in + the usual undirected sense and enumerated using :func:`networkx.all_simple_paths`. + + Returns + ------- + list[frozenset] + A list of distinct vertex sets. Each returned set :math:`P` has the property that + :math:`G` contains at least one simple path whose vertex set is exactly :math:`P`. + + Notes + ----- + - Many different simple paths can share the same vertex set (e.g. the path and its reverse, + or multiple embeddings in graphs with symmetries). Collapsing them to sets is **sound** for + the downstream solver used in :func:`path_cover_number`, because that solver only needs: + (i) which vertices are covered, and (ii) whether candidate paths are vertex-disjoint. + The internal traversal order of a path does not matter for these constraints. + - This generator is intentionally brute-force and can become enormous quickly, since the number + of simple paths in a graph can be exponential in :math:`|V|`. + + Complexity + ---------- + Potentially exponential (and in dense graphs, extremely large) in :math:`n=|V(G)|`, because it + enumerates all simple paths between all unordered pairs of vertices (up to cutoff :math:`n-1`). + This helper is intended only for very small graphs. + + See Also + -------- + path_cover_number : Uses these candidates in an exact backtracking search for a minimum path cover. + """ + nodes = list(G.nodes()) + path_sets = {frozenset([v]) for v in nodes} + + # Enumerate simple paths between unordered pairs to avoid duplication. + for s, t in itertools.combinations(nodes, 2): + for path in nx.all_simple_paths(G, s, t, cutoff=len(nodes) - 1): + path_sets.add(frozenset(path)) + + return list(path_sets) + + +def path_cover_number(G, max_n=20): + r""" + Compute the **path cover number** of an undirected graph: the minimum size of a + vertex-disjoint path cover. + + This function solves the following optimization problem. Find the smallest integer :math:`k` + for which there exist **pairwise vertex-disjoint** simple paths + :math:`P_1,\dots,P_k` in :math:`G` such that their vertex sets partition the vertex set: + + .. math:: + + V(G) \;=\; V(P_1)\,\dot\cup\,V(P_2)\,\dot\cup\,\cdots\,\dot\cup\,V(P_k), + + where each :math:`P_i` is a simple undirected path (and paths of length 0, i.e. single vertices, + are allowed). + + In other words, this is the minimum number of disjoint paths whose union covers all vertices. + This quantity is also called a **path partition number** in some sources. + + Conventions + ----------- + - Paths are **simple** undirected paths. + - **Singleton paths** :math:`\{v\}` are allowed (paths of length 0). + - The chosen paths must be **vertex-disjoint**, so the cover is a partition of :math:`V(G)`. + - If :math:`G` has no vertices, the value is 0. + + Parameters + ---------- + G : networkx.Graph-like + Intended for finite undirected graphs (typically simple graphs). + max_n : int, optional + Safety cutoff on :math:`|V(G)|`. This implementation is exact but brute-force and can + become infeasible quickly. If :math:`|V(G)| > \texttt{max_n}`, a ``ValueError`` is raised. + + Returns + ------- + int + The minimum number of vertex-disjoint paths whose union is :math:`V(G)`. + + Raises + ------ + ValueError + If :math:`|V(G)| > \texttt{max_n}`. + + Notes + ----- + - This is **not** the standard “minimum path cover” problem for DAGs (which is polynomial-time + via maximum matching). Here the input is an **undirected** graph and the paths must be + vertex-disjoint and cover all vertices; this variant is NP-hard in general. + - Implementation strategy: + 1. Enumerate candidate paths by their vertex sets using :func:`_all_simple_paths_vertex_sets`. + 2. Backtrack to choose a minimum number of these sets that form a partition of :math:`V(G)`. + The backtracking branches on an uncovered vertex :math:`v` and tries all candidate path-sets + containing :math:`v` that fit inside the remaining uncovered vertices. + + Complexity + ---------- + Exponential in :math:`n = |V(G)|`. The helper that enumerates simple paths can itself be + exponential, and the subsequent set-packing/partition backtracking is also exponential. + Intended only for small graphs (your cutoff parameter controls this). + + Examples + -------- + >>> import networkx as nx + >>> # A path is coverable by a single path + >>> G = nx.path_graph(6) + >>> path_cover_number(G) + 1 + + >>> # An edgeless graph on n vertices needs n singleton paths + >>> H = nx.empty_graph(5) + >>> path_cover_number(H) + 5 + + >>> # Two disjoint paths need two paths in the cover + >>> J = nx.disjoint_union(nx.path_graph(3), nx.path_graph(4)) + >>> path_cover_number(J) + 2 + """ + n = G.number_of_nodes() + if n == 0: + return 0 + if n > max_n: + raise ValueError(f"path_cover_number brute force intended for n <= {max_n}, got n={n}") + + U = set(G.nodes()) + path_sets = _all_simple_paths_vertex_sets(G) + + # Group candidate path-sets by a vertex they contain (branching heuristic). + by_vertex = {v: [] for v in U} + for P in path_sets: + for v in P: + by_vertex[v].append(P) + + best = n # trivial cover by singletons + + def backtrack(remaining, used_count): + nonlocal best + if used_count >= best: + return + if not remaining: + best = used_count + return + + v = next(iter(remaining)) + + for P in by_vertex[v]: + if P.issubset(remaining): + backtrack(remaining - set(P), used_count + 1) + + backtrack(U, 0) + return best + +def arboricity(G: nx.Graph) -> int: + """ + Compute the (undirected) arboricity a(G) exactly. + + Arboricity measures how many forests are needed to cover the edges of a graph. + Formally, a(G) is the minimum integer k such that E(G) can be partitioned into + k forests. + + Nash–Williams / Tutte characterization (exact) + ---------------------------------------------- + A classic theorem gives an exact formula: + + a(G) = max_{H ⊆ G, |V(H)| >= 2} ceil( |E(H)| / (|V(H)| - 1) ). + + Equivalently, a(G) is the smallest k such that for every vertex subset S with |S|>=2: + |E(G[S])| <= k (|S| - 1). + + This function computes arboricity exactly by testing candidate k via a *min-cut* + reduction that decides whether there exists a violating subset S with: + |E(S)| > k(|S| - 1). + + Min-cut oracle + -------------- + To test a given k, we solve: + + max_S ( |E(S)| - k|S| ) + + (since |E(S)| - k(|S|-1) = (|E(S)| - k|S|) + k, a violation exists iff + max_S (|E(S)| - k|S|) > -k ). + + This objective can be optimized exactly by an s-t min-cut construction: + - Create a node for each original vertex (V-nodes). + - Create a node for each original edge (E-nodes). + - Add arc: source -> E-node with capacity 1. + - Add arcs: E-node -> its two endpoints (V-nodes) with capacity INF. + - Add arc: V-node -> sink with capacity k. + + If we take an s-side subset S of vertex nodes, then the cut cost corresponds to: + cut = m - |E(S)| + k|S| + so minimizing cut is maximizing |E(S)| - k|S|. + + Parameters + ---------- + G : nx.Graph + An undirected (simple) graph. Self-loops are ignored. Parallel edges in a MultiGraph + will increase |E(S)|; if you want multigraph arboricity, pass a simple projection or + be explicit about your convention. + + Returns + ------- + int + The exact arboricity a(G). + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.arboricity(nx.path_graph(10)) + 1 + >>> gc.arboricity(nx.complete_graph(6)) + 3 + >>> # K_{a,b} has arboricity ceil(ab/(a+b-1)) in many cases; this computes it exactly: + >>> gc.arboricity(nx.complete_bipartite_graph(3,4)) + 2 + """ + if G.is_directed(): + raise nx.NetworkXError("arboricity() here is for undirected graphs only.") + + H = nx.Graph(G) + H.remove_edges_from(nx.selfloop_edges(H)) + n = H.number_of_nodes() + m = H.number_of_edges() + if m == 0: + return 0 + if n <= 1: + return 0 + + # Quick lower/upper bounds + delta = max((d for _, d in H.degree()), default=0) + lo = 1 + hi = max(1, delta) # arboricity <= Δ for simple graphs + + # Monotone property: if k works, larger k works. + def violates(k: int) -> bool: + # Returns True iff there exists S with |S| >= 2 and |E(S)| > k(|S|-1). + # Equivalently, iff max_{|S|>=2} (|E(S)| - k|S|) > -k. + nodes = list(H.nodes()) + if len(nodes) < 2: + return False + + # Build once-per-pair (n<=30 so fine). + for a_i in range(len(nodes)): + a = nodes[a_i] + for b_i in range(a_i + 1, len(nodes)): + b = nodes[b_i] + + DG = nx.DiGraph() + s, t = "_s", "_t" + DG.add_node(s) + DG.add_node(t) + + # Vertex nodes -> sink + for v in H.nodes(): + DG.add_edge(v, t, capacity=float(k)) + + INF = float(m + 1) # big enough + # Force a,b into the s-side + DG.add_edge(s, a, capacity=INF) + DG.add_edge(s, b, capacity=INF) + + # Edge nodes + for ei, (u, v) in enumerate(H.edges()): + en = ("e", ei) + DG.add_edge(s, en, capacity=1.0) + DG.add_edge(en, u, capacity=INF) + DG.add_edge(en, v, capacity=INF) + + cut_value, _ = nx.minimum_cut(DG, s, t, capacity="capacity") + max_val = m - cut_value # = max_{S ⊇ {a,b}} (|E(S)| - k|S|) + + if (max_val + k) > 1e-9: + return True + + return False + + + # Binary search smallest k with no violation + while lo < hi: + mid = (lo + hi) // 2 + if violates(mid): + lo = mid + 1 + else: + hi = mid + return lo + +from dataclasses import dataclass + +@dataclass +class _DSURollback: + parent: List[int] + size: List[int] + history: List[Tuple[int, int, int]] # (b, parent_b_old, size_a_old) + + @classmethod + def create(cls, n: int) -> "_DSURollback": + return cls(parent=list(range(n)), size=[1] * n, history=[]) + + def find(self, x: int) -> int: + while self.parent[x] != x: + x = self.parent[x] + return x + + def union(self, a: int, b: int) -> bool: + ra, rb = self.find(a), self.find(b) + if ra == rb: + return False + if self.size[ra] < self.size[rb]: + ra, rb = rb, ra + # attach rb -> ra + self.history.append((rb, self.parent[rb], self.size[ra])) + self.parent[rb] = ra + self.size[ra] += self.size[rb] + return True + + def snapshot(self) -> int: + return len(self.history) + + def rollback(self, snap: int) -> None: + while len(self.history) > snap: + rb, parent_rb_old, size_ra_old = self.history.pop() + ra = self.parent[rb] + self.parent[rb] = parent_rb_old + self.size[ra] = size_ra_old + +def linear_arboricity(G: nx.Graph) -> int: + r""" + Compute the **linear arboricity** :math:`\mathrm{la}(G)` exactly (intended for small simple graphs). + + A *linear forest* is a forest whose connected components are paths (including isolated vertices). + Equivalently, a graph is a linear forest if and only if it is acyclic and has maximum degree at + most 2. + + The *linear arboricity* :math:`\mathrm{la}(G)` is the minimum integer :math:`k` such that the edge + set can be partitioned into :math:`k` linear forests. + + .. math:: + + \mathrm{la}(G) + \;=\; + \min\{\, k : E(G)=E(L_1)\,\dot\cup\,\cdots\,\dot\cup\,E(L_k)\ \text{with each } L_i + \text{ a linear forest}\,\}. + + Exactness and search strategy + ----------------------------- + Computing linear arboricity is NP-hard in general. This implementation is intended for small + graphs (typically :math:`n \le 20\text{--}30`) and performs an exact incremental feasibility search: + + - Lower bound: :math:`\lceil \Delta(G)/2 \rceil`, since in any linear forest each vertex has degree + at most 2, so across :math:`k` forests a vertex can support at most :math:`2k` incident edges. + - Upper bound: :math:`|E(G)|`, since assigning each edge its own color class yields a linear forest. + + For each :math:`k` from the lower bound upward, the algorithm attempts to assign each edge one of + :math:`k` colors so that each color class induces a linear forest. The first feasible :math:`k` is + returned. + + Feasibility checking (fixed k) + ------------------------------ + Backtracking assigns edges to colors subject to: + + - Per-color degree constraint: for every color class and vertex, degree is at most 2. + - Per-color acyclicity: adding an edge may not create a cycle in that color class. + This is enforced via a rollback disjoint-set union (DSU) structure per color. + + Parameters + ---------- + G : nx.Graph + Undirected graph. This routine is intended for **simple** graphs. + Self-loops are ignored. + + Note: if a MultiGraph is provided, it is first projected to a simple graph via ``nx.Graph(G)``, + which collapses parallel edges; thus multiplicity is not preserved under this implementation. + + Returns + ------- + int + The exact linear arboricity :math:`\mathrm{la}(G)` (under the above conventions). + Returns 0 if :math:`|E(G)|=0`. + + Complexity + ---------- + Exponential in :math:`|E(G)|` in the worst case due to backtracking; practical only for small graphs. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.linear_arboricity(nx.path_graph(10)) + 1 + >>> gc.linear_arboricity(nx.cycle_graph(10)) + 2 + """ + if G.is_directed(): + raise nx.NetworkXError("linear_arboricity() here is for undirected graphs only.") + + H = nx.Graph(G) + H.remove_edges_from(nx.selfloop_edges(H)) + n = H.number_of_nodes() + m = H.number_of_edges() + if m == 0: + return 0 + + nodes = list(H.nodes()) + idx = {v: i for i, v in enumerate(nodes)} + edges = [(idx[u], idx[v]) for u, v in H.edges()] + # Heuristic: assign edges in an order that tends to prune earlier (high-degree endpoints first) + deg = [0] * n + for a, b in edges: + deg[a] += 1 + deg[b] += 1 + edges.sort(key=lambda e: (deg[e[0]] + deg[e[1]]), reverse=True) + + Delta = max(deg) + lb = math.ceil(Delta / 2) + ub = max(1, Delta) + + def feasible(k: int) -> bool: + # per color DSU + per color degrees + dsus = [_DSURollback.create(n) for _ in range(k)] + deg_c = [[0] * n for _ in range(k)] + + # Quick necessary condition: total “degree capacity” per vertex across colors is 2k. + # If some vertex has degree > 2k, impossible. + if any(d > 2 * k for d in deg): + return False + + # Backtracking + def bt(i: int) -> bool: + if i == len(edges): + return True + a, b = edges[i] + + # Try colors in an order that is likely to work: those with more remaining capacity on a,b + candidates = list(range(k)) + candidates.sort( + key=lambda c: (2 - deg_c[c][a]) + (2 - deg_c[c][b]), + reverse=True, + ) + + for c in candidates: + if deg_c[c][a] >= 2 or deg_c[c][b] >= 2: + continue + # cycle check: adding edge (a,b) creates cycle iff find(a)==find(b) in that color + dsu = dsus[c] + if dsu.find(a) == dsu.find(b): + continue + + snap = dsu.snapshot() + # apply + dsu.union(a, b) + deg_c[c][a] += 1 + deg_c[c][b] += 1 + + if bt(i + 1): + return True + + # undo + deg_c[c][a] -= 1 + deg_c[c][b] -= 1 + dsu.rollback(snap) + + return False + + return bt(0) + + for k in range(lb, ub + 1): + if feasible(k): + return k + + # Theoretically should never happen with ub=Δ, but keep a safe fallback: + return ub diff --git a/src/graphcalc/invariants/core_invariants.py b/src/graphcalc/invariants/core_invariants.py new file mode 100644 index 0000000..09fc81a --- /dev/null +++ b/src/graphcalc/invariants/core_invariants.py @@ -0,0 +1,698 @@ +# ============================================================ +# GENERAL "CORE" FUNCTIONS (intersection of all optimum sets) +# ============================================================ + +from itertools import combinations +import networkx as nx +import graphcalc as gc + +def core_set_minimum(G, k_func, is_valid_set): + r""" + Compute the **minimum core**: the intersection of all optimal (minimum-cardinality) valid sets. + + Many graph parameters are defined as the minimum size of a vertex set satisfying some property. + Examples include dominating sets, zero forcing sets, feedback vertex sets, etc. This function + takes such a property (via a membership oracle) and returns the set of vertices that appear in + **every** minimum-size solution. + + Formal definition + ----------------- + Let :math:`P` be a property of vertex subsets (e.g., “is a dominating set”), and suppose + :math:`k(G)` is the minimum cardinality of a set satisfying :math:`P`: + + .. math:: + + k(G) \;=\; \min\{\, |S| : S \subseteq V(G),\ P(G,S)\,\}. + + The **minimum core** (sometimes called the *core of minimum solutions*) is + + .. math:: + + \operatorname{Core}_{\min}(G) + \;=\; + \bigcap\{\, S \subseteq V(G) : P(G,S)\ \text{and}\ |S|=k(G)\,\}. + + Intuitively, :math:`\operatorname{Core}_{\min}(G)` is the set of vertices that are + *forced* to occur in every optimal solution. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. + k_func : callable + A function ``k_func(G) -> int`` returning the optimal minimum size :math:`k(G)`. + Typical examples are ``gc.domination_number`` or ``gc.zero_forcing_number``. + is_valid_set : callable + A predicate ``is_valid_set(G, S) -> bool`` that decides the property :math:`P(G,S)`, + where ``S`` is an iterable of vertices (e.g. a tuple from combinations). + + Important: ``is_valid_set`` should interpret ``S`` as a *vertex subset*; it should not + depend on order or multiplicity. + + Returns + ------- + set + The set :math:`\operatorname{Core}_{\min}(G)`. + + Conventions: + - If :math:`|V(G)|=0`, returns the empty set. + - If :math:`k(G)=0`, returns the empty set (since the empty set is a minimum solution, + and the intersection over the family of minimum solutions is empty). + - If the running intersection becomes empty during enumeration, the function returns early. + + If no valid set of size :math:`k(G)` is found (which indicates an inconsistency between + ``k_func`` and ``is_valid_set``), this implementation returns the empty set. + + Raises + ------ + ValueError + If ``k_func(G)`` does not return an integer, or returns a value outside + :math:`[0, |V(G)|]`. + + Notes + ----- + - **Exact brute force.** This routine enumerates all :math:`k`-subsets of :math:`V(G)` and + tests validity. It is intended only for small graphs and/or small :math:`k`. + - Correctness depends on consistency: ``k_func`` must return the true minimum size for the + property tested by ``is_valid_set``. If they disagree, the output is not meaningful. + + Complexity + ---------- + Let :math:`n=|V(G)|` and :math:`k=k(G)`. In the worst case, the function performs + :math:`\binom{n}{k}` calls to ``is_valid_set``. Thus the runtime is exponential in :math:`n` + in general, and also depends on the cost of the validity test itself. + + """ + n = gc.order(G) + if n == 0: + return set() + + k = k_func(G) + if not isinstance(k, int): + raise ValueError(f"k_func(G) must return an int, got {type(k)}") + if k < 0 or k > n: + raise ValueError(f"k_func(G) returned k={k}, but must satisfy 0 <= k <= n={n}") + + if k == 0: + return set() + + nodes = list(G.nodes()) + core = None # running intersection over all minimum solutions found + + for S in combinations(nodes, k): + if is_valid_set(G, S): + Sset = set(S) + core = Sset if core is None else (core & Sset) + if not core: + return set() # early exit: intersection already empty + + # If k is correct, at least one valid set of size k should exist. + # If none were found, return empty set (signals inconsistency but stays safe). + return core if core is not None else set() + + +def core_number_minimum(G, k_func, is_valid_set): + r""" + Compute the **minimum core number**: the size of the minimum core. + + This is the cardinality of the intersection of all minimum-cardinality valid sets: + + .. math:: + + c_{\min}(G) \;=\; \bigl|\operatorname{Core}_{\min}(G)\bigr|, + + where :math:`\operatorname{Core}_{\min}(G)` is returned by + :func:`core_set_minimum`. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. + k_func : callable + A function ``k_func(G) -> int`` returning the optimal minimum size :math:`k(G)` for the + chosen property. + is_valid_set : callable + A predicate ``is_valid_set(G, S) -> bool`` deciding validity of a candidate vertex set. + + Returns + ------- + int + The size of the minimum core, i.e. the number of vertices that appear in **every** + minimum solution. Returns 0 for the empty graph, and also returns 0 when :math:`k(G)=0`. + + Notes + ----- + This is a thin wrapper around :func:`core_set_minimum`. + + Complexity + ---------- + Same as :func:`core_set_minimum`. + """ + return len(core_set_minimum(G, k_func, is_valid_set)) + +def core_set_maximum_fast(G, f_max): + r""" + Compute the **maximum core** (intersection of all maximum solutions) via vertex-deletion tests. + + Many maximization parameters are defined as the optimum value attained by some vertex subset + (e.g., maximum independent set, maximum clique). For such parameters, one can often detect + whether a vertex is **forced** to appear in every maximum solution by checking whether deleting + that vertex decreases the optimum. + + This function returns the set of vertices that belong to **every** maximum solution, under the + following (crucial) assumption: + + Assumption (vertex-forcing by deletion) + --------------------------------------- + For the parameter value + + .. math:: + + M(G) \;=\; f_{\max}(G), + + the following equivalence holds for every vertex :math:`v \in V(G)`: + + .. math:: + + v \text{ belongs to every maximum solution } \iff f_{\max}(G - v) < f_{\max}(G), + + where :math:`G - v` denotes the induced subgraph obtained by deleting :math:`v`. + + Intuition: + - If deleting :math:`v` does **not** decrease the optimum value, then there exists an optimal + solution contained entirely in :math:`V(G)\setminus\{v\}`; hence :math:`v` is not forced. + - If deleting :math:`v` **does** decrease the optimum value, then no optimal solution can avoid + :math:`v`, so :math:`v` lies in the intersection of all optimal solutions. + + For example, this equivalence holds for: + - :math:`\alpha(G)` (maximum independent set size): if :math:`\alpha(G-v) = \alpha(G)`, then + there is a maximum independent set avoiding :math:`v`. + - :math:`\omega(G)` (maximum clique size): if :math:`\omega(G-v) = \omega(G)`, then there is a + maximum clique avoiding :math:`v`. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. + f_max : callable + A function ``f_max(G) -> int`` returning a maximization optimum value. + Typical examples include ``gc.independence_number`` (for :math:`\alpha`) or + ``gc.clique_number`` (for :math:`\omega`). + + The correctness of this routine depends on ``f_max`` satisfying the + *vertex-forcing by deletion* equivalence stated above. + + Returns + ------- + set + The **maximum core**: + + .. math:: + + \operatorname{Core}_{\max}(G) + \;=\; + \bigcap\{\, S \subseteq V(G) : S \text{ is a maximum solution for } f_{\max} \,\}, + + i.e., the set of vertices that appear in **every** maximum solution. + + If :math:`|V(G)| = 0`, returns the empty set. + + Notes + ----- + - This method is exact **provided the stated equivalence holds** for your parameter. + It is not valid for arbitrary maximization invariants, especially those not realized by + vertex subsets in a straightforward induced-subgraph way. + - Compared to enumerating all maximum solutions, this is typically much faster: it performs + one deletion test per vertex. + + Complexity + ---------- + The function performs :math:`|V(G)|` evaluations of ``f_max`` on induced subgraphs with one + vertex deleted. Thus, the total time is dominated by: + + .. math:: + + O\!\left(|V(G)| \cdot T_{f_{\max}}(|V(G)|-1, |E(G-v)|)\right), + + where :math:`T_{f_{\max}}` is the time needed to compute ``f_max`` on a graph. + The additional overhead from forming induced subgraphs is typically :math:`O(|V|+|E|)` per vertex + (depending on graph representation). + """ + n = gc.order(G) + if n == 0: + return set() + + base = f_max(G) + node_set = set(G.nodes()) + + core = set() + for v in list(node_set): + H = G.subgraph(node_set - {v}).copy() + if f_max(H) < base: + core.add(v) + return core + + +def core_number_maximum_fast(G, f_max): + r""" + Compute the **maximum core number**: the size of the maximum core. + + This is the cardinality of the set of vertices that appear in every maximum solution: + + .. math:: + + c_{\max}(G) \;=\; \bigl|\operatorname{Core}_{\max}(G)\bigr|, + + where :math:`\operatorname{Core}_{\max}(G)` is returned by + :func:`core_set_maximum_fast`. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. + f_max : callable + A function ``f_max(G) -> int`` satisfying the deletion-test equivalence described in + :func:`core_set_maximum_fast`. + + Returns + ------- + int + The size of the maximum core, i.e., the number of vertices forced to appear in **every** + maximum solution. Returns 0 for the empty graph. + + Notes + ----- + This is a thin wrapper around :func:`core_set_maximum_fast`. + + Complexity + ---------- + Same as :func:`core_set_maximum_fast`. + + """ + return len(core_set_maximum_fast(G, f_max)) + +def alpha_core_set(G): + r""" + Compute the **α-core** of a graph: the intersection of all maximum independent sets. + + Let :math:`\alpha(G)` denote the independence number of :math:`G`, i.e., the maximum size of an + independent set. The **α-core** (also called the *core with respect to maximum independent sets*) + is the set of vertices that belong to **every** maximum independent set: + + .. math:: + + \operatorname{core}_\alpha(G) + \;=\; + \bigcap \{\, I \subseteq V(G) : I \text{ is an independent set and } |I|=\alpha(G) \,\}. + + This implementation uses the standard deletion characterization for independence number: + + .. math:: + + v \in \operatorname{core}_\alpha(G) \iff \alpha(G - v) < \alpha(G), + + where :math:`G-v` is the induced subgraph obtained by deleting :math:`v`. Equivalently, if + :math:`\alpha(G-v)=\alpha(G)`, then there exists a maximum independent set avoiding :math:`v`, + so :math:`v` is not in the intersection of all maximum independent sets. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. + + Returns + ------- + set + The α-core of :math:`G`, i.e., the set of vertices contained in every maximum independent set. + Returns the empty set if :math:`G` has no vertices. + + Notes + ----- + - The deletion test used here is exact for :math:`\alpha(G)` because any maximum independent set + of :math:`G-v` is also an independent set of :math:`G` that avoids :math:`v`. + - This method requires only :math:`|V(G)|` evaluations of :math:`\alpha(\cdot)` on vertex-deleted + induced subgraphs, and is typically much faster than enumerating all maximum independent sets. + + Complexity + ---------- + Performs :math:`|V(G)|` calls to ``gc.independence_number`` on graphs with one vertex deleted. + Total runtime therefore depends on the complexity of computing :math:`\alpha(G)` in your backend. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> # In a star, every maximum independent set consists of all leaves, so the α-core is the set of leaves. + >>> G = nx.star_graph(4) # 5 vertices: center + 4 leaves + >>> gc.alpha_core_set(G) == set(range(1, 5)) + True + """ + return core_set_maximum_fast(G, gc.independence_number) + + +def alpha_core_number(G): + r""" + Compute the **α-core number** of a graph: the size of the intersection of all maximum independent sets. + + If :math:`\operatorname{core}_\alpha(G)` denotes the α-core (the set of vertices contained in every + maximum independent set), this function returns its cardinality: + + .. math:: + + |\operatorname{core}_\alpha(G)| + \;=\; + \left|\bigcap \{\, I \subseteq V(G) : I \text{ is an independent set and } |I|=\alpha(G) \,\}\right|. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. + + Returns + ------- + int + The α-core number of :math:`G`. Returns 0 if :math:`G` has no vertices. + + Notes + ----- + This is a thin wrapper around :func:`alpha_core_set`. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.star_graph(4) + >>> gc.alpha_core_number(G) + 4 + """ + return len(alpha_core_set(G)) + +def clique_core_set(G): + r""" + Compute the **clique core** of a graph :math:`G`: the intersection of all maximum cliques. + + Let :math:`\omega(G)` denote the **clique number** of :math:`G`, i.e., the maximum size of a + clique in :math:`G`. The **clique core** is the set of vertices that appear in **every** + maximum clique: + + .. math:: + + \operatorname{core}_\omega(G) + \;=\; + \bigcap \{\, C \subseteq V(G) : C \text{ is a clique in } G \text{ and } |C|=\omega(G)\,\}. + + Equivalently, :math:`\operatorname{core}_\omega(G)` is the set of vertices that are *forced* + to occur in any maximum clique. + + Implementation (deletion test) + ------------------------------ + This implementation uses the standard deletion characterization for the clique number: + + .. math:: + + v \in \operatorname{core}_\omega(G) \iff \omega(G - v) < \omega(G), + + where :math:`G-v` is the induced subgraph obtained by deleting :math:`v`. + + Justification: if :math:`\omega(G-v)=\omega(G)`, then :math:`G-v` has a maximum clique of size + :math:`\omega(G)` that avoids :math:`v`, so :math:`v` is not in the intersection. Conversely, if + deleting :math:`v` reduces :math:`\omega`, then no maximum clique can avoid :math:`v`. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. For standard clique semantics, this is intended for **simple undirected** + graphs. + + Returns + ------- + set + The clique core :math:`\operatorname{core}_\omega(G)`, i.e., the set of vertices contained in + every maximum clique. If :math:`G` has no vertices, returns the empty set. + + Notes + ----- + - If :math:`G` has a **unique** maximum clique, then the clique core equals that clique. + - If :math:`G` has multiple maximum cliques with little overlap, the clique core may be empty. + - Correctness of the deletion test is specific to parameters like :math:`\omega(G)` that are + realized by vertex subsets in induced subgraphs (a maximum clique of :math:`G-v` is also a clique + in :math:`G` that avoids :math:`v`). + + Complexity + ---------- + Performs :math:`|V(G)|` calls to ``gc.clique_number`` on graphs with one vertex deleted. The total + runtime is therefore dominated by the complexity of computing :math:`\omega(G)` in your backend. + + See Also + -------- + clique_core_number : Returns the cardinality :math:`|\operatorname{core}_\omega(G)|`. + core_set_maximum_fast : Generic deletion-test routine used internally. + """ + return core_set_maximum_fast(G, gc.clique_number) + + +def clique_core_number(G): + r""" + Compute the **clique core number** of a graph :math:`G`: the size of the clique core. + + If :math:`\operatorname{core}_\omega(G)` denotes the intersection of all maximum cliques of + :math:`G`, this function returns its cardinality: + + .. math:: + + |\operatorname{core}_\omega(G)| + \;=\; + \left|\bigcap \{\, C \subseteq V(G) : C \text{ is a clique and } |C|=\omega(G)\,\}\right|. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph (intended for simple undirected graphs). + + Returns + ------- + int + The number of vertices that appear in every maximum clique of :math:`G`. + Returns 0 if :math:`G` has no vertices or if the clique core is empty. + + Notes + ----- + This quantity can be interpreted as a robustness measure for maximum cliques: it counts how many + vertices are unavoidable in any maximum clique. + + Complexity + ---------- + Same as :func:`clique_core_set`, since this is a thin wrapper. + + See Also + -------- + clique_core_set : Returns the clique core set itself. + """ + return len(clique_core_set(G)) + +def domination_core_set(G): + r""" + Compute the **domination core** of a graph :math:`G`: the intersection of all minimum dominating sets. + + Let :math:`\gamma(G)` denote the **domination number** of :math:`G`, i.e., the minimum size of a + dominating set. The **domination core** is the set of vertices that appear in **every** + minimum dominating set: + + .. math:: + + \operatorname{core}_\gamma(G) + \;=\; + \bigcap \{\, S \subseteq V(G) : S \text{ is dominating in } G \text{ and } |S|=\gamma(G)\,\}. + + Equivalently, :math:`\operatorname{core}_\gamma(G)` is the set of vertices that are *forced* + to occur in any minimum dominating set. + + A set :math:`S \subseteq V(G)` is **dominating** if every vertex of :math:`G` is in :math:`S` or + has a neighbor in :math:`S`, i.e., :math:`N[S]=V(G)`. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. This is intended primarily for **simple undirected** graphs under the standard + adjacency notion. + + Returns + ------- + set + The domination core :math:`\operatorname{core}_\gamma(G)`. + + Conventions: + - If :math:`|V(G)|=0`, returns the empty set. + - If :math:`\gamma(G)=0` (which occurs only for the empty graph under standard conventions), + returns the empty set. + - If the intersection of all minimum dominating sets is empty, returns the empty set. + + Notes + ----- + - This function delegates to :func:`core_set_minimum` using: + * ``k_func = gc.domination_number`` and + * ``is_valid_set = gc.is_dominating_set``. + - The implementation is **exact** but may be expensive: it enumerates all :math:`\gamma(G)`-subsets + of :math:`V(G)` and tests which ones are dominating. + + Complexity + ---------- + Let :math:`n=|V(G)|` and :math:`k=\gamma(G)`. In the worst case, the function performs + :math:`\binom{n}{k}` dominance checks, so the runtime is exponential in :math:`n` in general + (and depends on the cost of ``gc.is_dominating_set``). + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> # In a star, every minimum dominating set is {center}, so the domination core is {center}. + >>> G = nx.star_graph(4) + >>> gc.domination_core_set(G) == {0} + True + """ + return core_set_minimum(G, gc.domination_number, gc.is_dominating_set) + + +def domination_core_number(G): + r""" + Compute the **domination core number** of a graph :math:`G`: the size of the domination core. + + If :math:`\operatorname{core}_\gamma(G)` denotes the intersection of all minimum dominating sets + of :math:`G`, this function returns its cardinality: + + .. math:: + + |\operatorname{core}_\gamma(G)| + \;=\; + \left|\bigcap \{\, S \subseteq V(G) : S \text{ is dominating and } |S|=\gamma(G)\,\}\right|. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. + + Returns + ------- + int + The number of vertices that appear in every minimum dominating set of :math:`G`. + Returns 0 if :math:`G` is empty or if the domination core is empty. + + Notes + ----- + This is a thin wrapper around :func:`domination_core_set`. + + Complexity + ---------- + Same as :func:`domination_core_set`, since this function simply takes the set cardinality. + + See Also + -------- + domination_core_set : Returns the domination core set itself. + """ + return len(domination_core_set(G)) + +def zero_forcing_core_set(G): + r""" + Compute the **zero forcing core** of a graph :math:`G`: the intersection of all minimum zero forcing sets. + + Let :math:`Z(G)` denote the **zero forcing number** of :math:`G`, i.e., the minimum size of a + zero forcing set under the standard zero forcing rule. The **zero forcing core** is the set of + vertices that appear in **every** minimum zero forcing set: + + .. math:: + + \operatorname{core}_Z(G) + \;=\; + \bigcap \{\, S \subseteq V(G) : S \text{ is zero forcing in } G \text{ and } |S|=Z(G)\,\}. + + Equivalently, :math:`\operatorname{core}_Z(G)` is the set of vertices that are *forced* to occur + in any minimum zero forcing set. + + Zero forcing (brief definition) + ------------------------------- + Start with a set :math:`S` of initially colored vertices. Repeatedly apply the **color-change rule**: + a colored vertex with **exactly one** uncolored neighbor forces that neighbor to become colored. + A set :math:`S` is a **zero forcing set** if this process eventually colors all vertices. The + zero forcing number :math:`Z(G)` is the minimum size of such a set. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. This is intended primarily for **simple undirected** graphs under the standard + adjacency notion. + + Returns + ------- + set + The zero forcing core :math:`\operatorname{core}_Z(G)`. + + Conventions: + - If :math:`|V(G)|=0`, returns the empty set. + - If :math:`Z(G)=0` (which occurs only for the empty graph under standard conventions), + returns the empty set. + - If the intersection of all minimum zero forcing sets is empty, returns the empty set. + + Notes + ----- + - This function delegates to :func:`core_set_minimum` using: + * ``k_func = gc.zero_forcing_number`` and + * ``is_valid_set = gc.is_zero_forcing_set``. + - The implementation is **exact** but may be expensive: it enumerates all :math:`Z(G)`-subsets of + :math:`V(G)` and tests which ones are zero forcing. + + Complexity + ---------- + Let :math:`n=|V(G)|` and :math:`k=Z(G)`. In the worst case, the function performs + :math:`\binom{n}{k}` zero-forcing checks, so the runtime is exponential in :math:`n` in general + (and depends on the cost of ``gc.is_zero_forcing_set``). + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> # In a path, there are minimum zero forcing sets of size 1 (either endpoint), + >>> # so the intersection is empty. + >>> G = nx.path_graph(6) + >>> gc.zero_forcing_core_set(G) + set() + """ + return core_set_minimum(G, gc.zero_forcing_number, gc.is_zero_forcing_set) + + +def zero_forcing_core_number(G): + r""" + Compute the **zero forcing core number** of a graph :math:`G`: the size of the zero forcing core. + + If :math:`\operatorname{core}_Z(G)` denotes the intersection of all minimum zero forcing sets of + :math:`G`, this function returns its cardinality: + + .. math:: + + |\operatorname{core}_Z(G)| + \;=\; + \left|\bigcap \{\, S \subseteq V(G) : S \text{ is zero forcing and } |S|=Z(G)\,\}\right|. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. + + Returns + ------- + int + The number of vertices that appear in every minimum zero forcing set of :math:`G`. + Returns 0 if :math:`G` is empty or if the zero forcing core is empty. + + Notes + ----- + This is a thin wrapper around :func:`zero_forcing_core_set`. + + Complexity + ---------- + Same as :func:`zero_forcing_core_set`. + + See Also + -------- + zero_forcing_core_set : Returns the zero forcing core set itself. + """ + return len(zero_forcing_core_set(G)) diff --git a/src/graphcalc/invariants/critical_invariants.py b/src/graphcalc/invariants/critical_invariants.py new file mode 100644 index 0000000..0ccca67 --- /dev/null +++ b/src/graphcalc/invariants/critical_invariants.py @@ -0,0 +1,853 @@ +# ============================================================ +# GENERAL VERTEX/EDGE DELETION (criticality & sensitivity) +# ============================================================ + +import graphcalc as gc + +def vertex_deletion_deltas(G, f): + r""" + Compute **single-vertex deletion deltas** for a graph parameter :math:`f`. + + For each vertex :math:`v \in V(G)`, form the induced vertex-deleted subgraph + + .. math:: + + G - v \;:=\; G[V(G)\setminus\{v\}], + + and compute the deletion delta + + .. math:: + + \Delta_v f(G) \;:=\; f(G - v) - f(G). + + The function returns the mapping :math:`v \mapsto \Delta_v f(G)` for all vertices of :math:`G`. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. + f : callable + A function ``f(H) -> number`` defined on graphs ``H``. Examples include invariants/parameters + such as order, size, independence number, clique number, domination number, chromatic number, + etc. The function is evaluated first on ``G`` and then on each induced subgraph ``G-v``. + + Returns + ------- + dict + A dictionary mapping each vertex ``v`` of ``G`` to the numeric value ``f(G - v) - f(G)``. + If :math:`|V(G)| = 0`, returns an empty dictionary ``{}``. + + Notes + ----- + - These deltas are useful for **sensitivity** and **criticality** analyses, e.g. identifying + vertices whose deletion changes the parameter, and by how much. + - The induced subgraph ``G-v`` is passed to ``f`` as a ``.copy()`` to protect against accidental + mutation inside ``f``. If ``f`` is guaranteed to be pure/read-only, you may omit ``.copy()`` + for speed. + + Raises + ------ + Exception + Propagates any exception raised by ``f`` when applied to ``G`` or to any vertex-deleted + induced subgraph. + + Complexity + ---------- + Let :math:`n = |V(G)|`. This routine performs :math:`n+1` evaluations of ``f`` (once on ``G`` and + once on each of the :math:`n` induced subgraphs with one vertex removed). Thus the overall cost is + dominated by: + + .. math:: + + O\!\left(n \cdot T_f(n-1)\right), + + where :math:`T_f(\cdot)` denotes the time to evaluate ``f`` on a graph of the indicated size + (plus the overhead of building/copying induced subgraphs). + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.path_graph(4) + >>> # Deltas for the independence number under single-vertex deletions: + >>> d = vertex_deletion_deltas(G, gc.independence_number) + >>> sorted(d.items()) # doctest: +ELLIPSIS + [...] + """ + n = gc.order(G) + if n == 0: + return {} + + base = f(G) + nodes = list(G.nodes()) + node_set = set(nodes) + + deltas = {} + for v in nodes: + H = G.subgraph(node_set - {v}).copy() + deltas[v] = f(H) - base + return deltas + +def vertex_critical_set(G, f, *, kind="change"): + r""" + Select vertices by how a graph parameter :math:`f` changes under single-vertex deletion. + + For each vertex :math:`v \in V(G)`, define the vertex-deletion delta + + .. math:: + + \Delta_v f(G) \;:=\; f(G - v) - f(G), + + where :math:`G - v` is the induced subgraph obtained by deleting :math:`v`. + + This function returns the subset of vertices classified by the sign (or nonzero-ness) + of :math:`\Delta_v f(G)`: + + - ``kind='change'``: vertices with :math:`\Delta_v f(G) \neq 0` (deletion changes the value) + - ``kind='increase'``: vertices with :math:`\Delta_v f(G) > 0` (value increases after deletion) + - ``kind='decrease'``: vertices with :math:`\Delta_v f(G) < 0` (value decreases after deletion) + - ``kind='same'``: vertices with :math:`\Delta_v f(G) = 0` (value unchanged after deletion) + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. + f : callable + A function ``f(H) -> number`` defined on graphs ``H``. The value is evaluated on ``G`` and on + each induced subgraph ``G - v``. + kind : {'change', 'increase', 'decrease', 'same'}, optional + Which deletion behavior to select. + + Returns + ------- + set + A set of vertices ``v`` satisfying the requested deletion behavior with respect to :math:`f`. + + If :math:`|V(G)|=0`, returns the empty set. + + Raises + ------ + ValueError + If ``kind`` is not one of ``{'change','increase','decrease','same'}``. + Exception + Propagates any exception raised by ``f`` when evaluated on ``G`` or on any vertex-deleted + induced subgraph. + + Notes + ----- + - This is a generic vertex-sensitivity / “criticality” selector. Terminology varies by context: + * For **maximization** parameters (e.g. :math:`\alpha`, :math:`\omega`), authors sometimes call + vertices with ``kind='decrease'`` *critical*, since deleting them reduces the optimum. + * For **minimization** parameters (e.g. :math:`\chi`, :math:`\gamma`), authors sometimes call + vertices with ``kind='increase'`` *critical*, since deleting them increases the minimum. + This function does not assume whether :math:`f` is a max or min parameter; it simply filters by + the sign of :math:`\Delta_v f(G)`. + - The deltas are computed by :func:`vertex_deletion_deltas`, which forms induced subgraphs and + evaluates ``f`` on them. + + Complexity + ---------- + Dominated by computing the deltas: :math:`O(|V(G)|)` evaluations of ``f`` on graphs with one vertex + deleted (plus one evaluation on ``G`` itself). The additional filtering is :math:`O(|V(G)|)`. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.path_graph(5) + >>> # Vertices whose deletion changes the independence number: + >>> vertex_critical_set(G, gc.independence_number, kind="change") # doctest: +ELLIPSIS + {...} + """ + if kind not in {"change", "increase", "decrease", "same"}: + raise ValueError("kind must be one of {'change','increase','decrease','same'}") + + deltas = vertex_deletion_deltas(G, f) + if kind == "change": + return {v for v, d in deltas.items() if d != 0} + if kind == "increase": + return {v for v, d in deltas.items() if d > 0} + if kind == "decrease": + return {v for v, d in deltas.items() if d < 0} + return {v for v, d in deltas.items() if d == 0} # kind == "same" + +def vertex_critical_number(G, f, *, kind="change"): + r""" + Count vertices by how a graph parameter :math:`f` changes under single-vertex deletion. + + This is the cardinality of :func:`vertex_critical_set`: + + .. math:: + + \bigl|\{\, v \in V(G) : \Delta_v f(G)\ \text{satisfies the requested condition}\,\}\bigr|, + + where + + .. math:: + + \Delta_v f(G) \;=\; f(G - v) - f(G) + + and :math:`G-v` denotes the induced subgraph obtained by deleting :math:`v`. + + The ``kind`` argument specifies which condition is used: + + - ``kind='change'``: :math:`\Delta_v f(G) \neq 0` + - ``kind='increase'``: :math:`\Delta_v f(G) > 0` + - ``kind='decrease'``: :math:`\Delta_v f(G) < 0` + - ``kind='same'``: :math:`\Delta_v f(G) = 0` + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. + f : callable + A function ``f(H) -> number`` defined on graphs ``H``. + kind : {'change', 'increase', 'decrease', 'same'}, optional + Which deletion behavior to count. + + Returns + ------- + int + The number of vertices in :math:`G` whose deletion has the specified effect on :math:`f`. + + If :math:`|V(G)|=0`, this returns 0. + + Raises + ------ + ValueError + If ``kind`` is not one of ``{'change','increase','decrease','same'}``. + Exception + Propagates any exception raised by ``f`` when evaluated on ``G`` or on a vertex-deleted + induced subgraph (via :func:`vertex_critical_set` / :func:`vertex_deletion_deltas`). + + Notes + ----- + This is a thin wrapper around :func:`vertex_critical_set` that returns only the count. + + Complexity + ---------- + Same as :func:`vertex_critical_set`: dominated by :math:`O(|V(G)|)` evaluations of ``f`` on + vertex-deleted induced subgraphs (plus one evaluation on ``G``). + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.path_graph(6) + >>> vertex_critical_number(G, gc.independence_number, kind="change") + 0 + """ + return len(vertex_critical_set(G, f, kind=kind)) + +def vertex_deletion_max_jump(G, f): + r""" + Compute the **maximum one-vertex deletion jump** of a graph parameter :math:`f`. + + For each vertex :math:`v \in V(G)`, consider the vertex-deletion delta + + .. math:: + + \Delta_v f(G) \;=\; f(G - v) - f(G), + + where :math:`G - v` is the induced subgraph obtained by deleting :math:`v`. + This function returns the maximum absolute change over all single-vertex deletions: + + .. math:: + + J_{\max}(G;f) \;=\; \max_{v \in V(G)} \bigl|f(G - v) - f(G)\bigr| + \;=\; \max_{v \in V(G)} |\Delta_v f(G)|. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. + f : callable + A function ``f(H) -> number`` defined on graphs ``H``. + + Returns + ------- + number + The maximum absolute delta :math:`\max_v |\Delta_v f(G)|`. If :math:`|V(G)| = 0`, + returns 0. + + Raises + ------ + Exception + Propagates any exception raised by ``f`` when evaluated on ``G`` or on any vertex-deleted + induced subgraph (via :func:`vertex_deletion_deltas`). + + Notes + ----- + - This is a simple **one-vertex sensitivity** measure for :math:`f`: it quantifies the largest + single-vertex deletion impact on the parameter value. + - If :math:`G` is empty, there are no deletions to consider, so the maximum jump is defined here + to be 0. + - The deltas are computed by :func:`vertex_deletion_deltas`, which evaluates ``f`` on ``G`` and on + each induced subgraph ``G-v``. + + Complexity + ---------- + Dominated by computing the deltas: :math:`O(|V(G)|)` evaluations of ``f`` on graphs with one vertex + deleted (plus one evaluation on ``G``), and then an :math:`O(|V(G)|)` scan to take the maximum. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.path_graph(6) + >>> vertex_deletion_max_jump(G, gc.zero_forcing_number) + 1 + """ + deltas = vertex_deletion_deltas(G, f) + return 0 if not deltas else max(abs(d) for d in deltas.values()) + +def edge_deletion_deltas(G, f): + r""" + Compute **single-edge deletion deltas** for a graph parameter :math:`f`. + + For each edge :math:`e \in E(G)`, form the graph obtained by deleting that edge while + keeping all vertices, and compute the delta: + + .. math:: + + \Delta_e f(G) \;:=\; f(G - e) - f(G), + + where :math:`G-e` denotes the graph with edge :math:`e` removed (vertex set unchanged). + + The function returns the mapping :math:`e \mapsto \Delta_e f(G)` for all edges of :math:`G`. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. This routine is primarily intended for **simple undirected graphs**. + + - For a ``DiGraph``, “edge deletion” refers to deleting a directed arc, and edges are ordered. + This function still iterates ``G.edges()`` but then collapses each edge to an unordered + key (a ``frozenset``), which is generally **not** appropriate for directed graphs. + - For a ``MultiGraph`` / ``MultiDiGraph``, parallel edges require edge keys to distinguish + copies; this function does not accept edge keys and is therefore not suitable without + adaptation. + + f : callable + A function ``f(H) -> number`` defined on graphs ``H``. The value is evaluated on ``G`` and on + each edge-deleted graph ``G-e``. + + Returns + ------- + dict + A dictionary mapping each edge to the numeric value ``f(G - e) - f(G)``. + + Edges are keyed as ``frozenset({u, v})`` so they are treated as **unordered**, and node labels + need not be comparable. For a simple undirected graph, this keying gives a one-to-one + correspondence between edges and dictionary entries. + + If :math:`|E(G)| = 0`, returns an empty dictionary ``{}``. + + Raises + ------ + Exception + Propagates any exception raised by ``f`` when evaluated on ``G`` or on any edge-deleted graph. + + Notes + ----- + - The implementation copies ``G`` once per edge and deletes that edge. This protects the original + graph from mutation regardless of whether ``f`` mutates its input. + - These deltas are useful for edge-sensitivity / edge-criticality analyses: they quantify how + much the parameter changes when a single edge is removed. + + Complexity + ---------- + Let :math:`m=|E(G)|`. This routine performs :math:`m+1` evaluations of ``f`` (once on ``G`` and once + for each of the :math:`m` single-edge deletions). The total runtime is dominated by the cost of + evaluating ``f`` on graphs with one edge removed, plus the overhead of copying the graph: + + .. math:: + + O\!\left(m \cdot T_f(n, m-1)\right), + + where :math:`T_f` is the time to evaluate ``f`` on a graph of the given size. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.cycle_graph(5) + >>> d = edge_deletion_deltas(G, gc.chromatic_number) + >>> all(v <= 0 for v in d.values()) # removing an edge cannot increase chi for simple graphs + True + """ + if gc.size(G) == 0: + return {} + + base = f(G) + deltas = {} + + for (u, v) in G.edges(): + H = G.copy() + H.remove_edge(u, v) + e = frozenset((u, v)) # canonical undirected key + deltas[e] = f(H) - base + + return deltas + +def edge_critical_number(G, f, *, kind="change"): + r""" + Count edges by how a graph parameter :math:`f` changes under single-edge deletion. + + For each edge :math:`e \in E(G)`, define the edge-deletion delta + + .. math:: + + \Delta_e f(G) \;:=\; f(G - e) - f(G), + + where :math:`G-e` is the graph obtained from :math:`G` by deleting the edge :math:`e` + (keeping all vertices). This function counts how many edges fall into a given sign class + of :math:`\Delta_e f(G)`: + + - ``kind='change'``: edges with :math:`\Delta_e f(G) \neq 0` + - ``kind='increase'``: edges with :math:`\Delta_e f(G) > 0` + - ``kind='decrease'``: edges with :math:`\Delta_e f(G) < 0` + - ``kind='same'``: edges with :math:`\Delta_e f(G) = 0` + + Parameters + ---------- + G : networkx.Graph-like + A finite graph, intended for **simple undirected** graphs (so that edges are naturally + unordered and uniquely determined by their endpoints). The deltas are computed by + :func:`edge_deletion_deltas`, which is not designed for multigraph edge keys. + f : callable + A function ``f(H) -> number`` defined on graphs ``H``. + kind : {'change', 'increase', 'decrease', 'same'}, optional + Which deletion behavior to count. + + Returns + ------- + int + The number of edges :math:`e` satisfying the requested condition on + :math:`\Delta_e f(G)`. If :math:`|E(G)|=0`, returns 0. + + Raises + ------ + ValueError + If ``kind`` is not one of ``{'change','increase','decrease','same'}``. + Exception + Propagates any exception raised by ``f`` when evaluated on ``G`` or on any edge-deleted graph + (via :func:`edge_deletion_deltas`). + + Notes + ----- + - Terminology varies: some authors reserve “edge-critical” for a specific direction of change, + e.g.: + * for **maximization** parameters (such as :math:`\alpha` or :math:`\omega`), edges with + ``kind='decrease'``; + * for **minimization** parameters (such as :math:`\chi`), edges with ``kind='increase'``. + This function is agnostic and provides all four sign-based categories via ``kind``. + - This is a counting wrapper; if you want the edges themselves, define an analogous + ``edge_critical_set`` using the same delta predicate. + + Complexity + ---------- + Dominated by :func:`edge_deletion_deltas`: :math:`O(|E(G)|)` evaluations of ``f`` on graphs with + one edge deleted (plus one evaluation on ``G``), and then an :math:`O(|E(G)|)` scan to count. + + """ + if kind not in {"change", "increase", "decrease", "same"}: + raise ValueError("kind must be one of {'change','increase','decrease','same'}") + + deltas = edge_deletion_deltas(G, f) + + if kind == "change": + return sum(1 for d in deltas.values() if d != 0) + if kind == "increase": + return sum(1 for d in deltas.values() if d > 0) + if kind == "decrease": + return sum(1 for d in deltas.values() if d < 0) + return sum(1 for d in deltas.values() if d == 0) # kind == "same" + +def domination_vertex_increase_number(G): + r""" + Count **domination-increase-critical vertices** of a graph. + + Let :math:`\gamma(G)` denote the domination number of a graph :math:`G`. For each vertex + :math:`v \in V(G)`, define the vertex-deletion delta + + .. math:: + + \Delta_v \gamma(G) \;:=\; \gamma(G-v) - \gamma(G), + + where :math:`G-v` is the induced subgraph obtained by deleting :math:`v`. + + This function returns the number of vertices whose deletion **increases** the domination number: + + .. math:: + + c_v^+(\gamma)(G) + \;=\; + \bigl|\{\, v \in V(G) : \gamma(G-v) > \gamma(G) \,\}\bigr| + \;=\; + \bigl|\{\, v \in V(G) : \Delta_v \gamma(G) > 0 \,\}\bigr|. + + Intuition: these are vertices whose removal makes the remaining graph harder to dominate + (more dominators are needed). + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. + + Returns + ------- + int + The number of vertices :math:`v` with :math:`\gamma(G-v) > \gamma(G)`. + Returns 0 if :math:`G` has no vertices. + + Notes + ----- + - For minimization parameters like :math:`\gamma`, this “increase under deletion” notion is + the closest analogue of a *critical vertex* in the sense that deleting it worsens the optimum. + - This is a thin wrapper around :func:`vertex_critical_number` with + ``f = gc.domination_number`` and ``kind = 'increase'``. + + Complexity + ---------- + Dominated by :func:`vertex_critical_number` / :func:`vertex_deletion_deltas`: performs + :math:`O(|V(G)|)` evaluations of ``gc.domination_number`` on vertex-deleted induced subgraphs. + + See Also + -------- + domination_vertex_decrease_number + domination_vertex_change_number + domination_vertex_max_jump + """ + return vertex_critical_number(G, gc.domination_number, kind="increase") + + +def domination_vertex_decrease_number(G): + r""" + Count vertices whose deletion **decreases** the domination number. + + Using :math:`\gamma(G)` for domination number and :math:`\Delta_v\gamma(G)=\gamma(G-v)-\gamma(G)`, + this function returns + + .. math:: + + c_v^-(\gamma)(G) + \;=\; + \bigl|\{\, v \in V(G) : \gamma(G-v) < \gamma(G) \,\}\bigr| + \;=\; + \bigl|\{\, v \in V(G) : \Delta_v \gamma(G) < 0 \,\}\bigr|. + + Intuition: deleting such a vertex makes the remaining graph easier to dominate. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. + + Returns + ------- + int + The number of vertices :math:`v` with :math:`\gamma(G-v) < \gamma(G)`. + Returns 0 if :math:`G` has no vertices. + + Notes + ----- + This is a thin wrapper around :func:`vertex_critical_number` with + ``f = gc.domination_number`` and ``kind = 'decrease'``. + + Complexity + ---------- + :math:`O(|V(G)|)` evaluations of ``gc.domination_number`` on vertex-deleted induced subgraphs. + + See Also + -------- + domination_vertex_increase_number + domination_vertex_change_number + """ + return vertex_critical_number(G, gc.domination_number, kind="decrease") + + +def domination_vertex_change_number(G): + r""" + Count vertices whose deletion **changes** the domination number. + + This returns the number of vertices :math:`v` such that + + .. math:: + + \gamma(G-v) \ne \gamma(G). + + Equivalently, it counts vertices with nonzero deletion delta + :math:`\Delta_v\gamma(G)=\gamma(G-v)-\gamma(G)\ne 0`. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. + + Returns + ------- + int + The number of vertices whose deletion changes :math:`\gamma(G)`. + Returns 0 if :math:`G` has no vertices. + + Notes + ----- + This is a thin wrapper around :func:`vertex_critical_number` with + ``f = gc.domination_number`` and ``kind = 'change'``. + + Complexity + ---------- + :math:`O(|V(G)|)` evaluations of ``gc.domination_number`` on vertex-deleted induced subgraphs. + + See Also + -------- + domination_vertex_increase_number + domination_vertex_decrease_number + """ + return vertex_critical_number(G, gc.domination_number, kind="change") + + +def domination_vertex_same_number(G): + r""" + Count vertices whose deletion leaves the domination number unchanged. + + This returns the number of vertices :math:`v` such that + + .. math:: + + \gamma(G-v) = \gamma(G). + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. + + Returns + ------- + int + The number of vertices whose deletion does not change :math:`\gamma(G)`. + Returns 0 if :math:`G` has no vertices. + + Notes + ----- + This is a thin wrapper around :func:`vertex_critical_number` with + ``f = gc.domination_number`` and ``kind = 'same'``. + + Complexity + ---------- + :math:`O(|V(G)|)` evaluations of ``gc.domination_number`` on vertex-deleted induced subgraphs. + """ + return vertex_critical_number(G, gc.domination_number, kind="same") + + +def domination_vertex_max_jump(G): + r""" + Compute the maximum absolute change in domination number under deletion of a single vertex. + + Let :math:`\gamma(G)` denote the domination number. This function returns: + + .. math:: + + \max_{v\in V(G)} \bigl|\gamma(G-v) - \gamma(G)\bigr|. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. + + Returns + ------- + int + The maximum absolute vertex-deletion delta for :math:`\gamma`. Returns 0 if :math:`G` has + no vertices. + + Notes + ----- + This is a one-vertex sensitivity measure for domination number, implemented as a thin wrapper + around :func:`vertex_deletion_max_jump` with ``f = gc.domination_number``. + + Complexity + ---------- + :math:`O(|V(G)|)` evaluations of ``gc.domination_number`` on vertex-deleted induced subgraphs. + """ + return vertex_deletion_max_jump(G, gc.domination_number) + + +def domination_edge_increase_number(G): + r""" + Count edges whose deletion **increases** the domination number. + + For each edge :math:`e \in E(G)`, define the edge-deletion delta + + .. math:: + + \Delta_e \gamma(G) \;:=\; \gamma(G-e) - \gamma(G), + + where :math:`G-e` is the graph obtained by deleting :math:`e` (keeping all vertices). + + This function returns + + .. math:: + + c_e^+(\gamma)(G) + \;=\; + \bigl|\{\, e \in E(G) : \gamma(G-e) > \gamma(G) \,\}\bigr|. + + Intuition: these are edges whose removal makes the graph harder to dominate (typically by + reducing adjacency options). + + Parameters + ---------- + G : networkx.Graph-like + A finite graph, intended for simple undirected graphs. + + Returns + ------- + int + The number of edges :math:`e` with :math:`\gamma(G-e) > \gamma(G)`. Returns 0 if :math:`G` + has no edges. + + Notes + ----- + This is a thin wrapper around :func:`edge_critical_number` with + ``f = gc.domination_number`` and ``kind = 'increase'``. + + Complexity + ---------- + :math:`O(|E(G)|)` evaluations of ``gc.domination_number`` on single-edge-deleted graphs. + """ + return edge_critical_number(G, gc.domination_number, kind="increase") + + +def domination_edge_decrease_number(G): + r""" + Count edges whose deletion **decreases** the domination number. + + This returns the number of edges :math:`e` such that + + .. math:: + + \gamma(G-e) < \gamma(G). + + Parameters + ---------- + G : networkx.Graph-like + A finite graph, intended for simple undirected graphs. + + Returns + ------- + int + The number of edges whose deletion decreases :math:`\gamma(G)`. Returns 0 if :math:`G` has + no edges. + + Notes + ----- + This is a thin wrapper around :func:`edge_critical_number` with + ``f = gc.domination_number`` and ``kind = 'decrease'``. + + Complexity + ---------- + :math:`O(|E(G)|)` evaluations of ``gc.domination_number`` on single-edge-deleted graphs. + """ + return edge_critical_number(G, gc.domination_number, kind="decrease") + + +def domination_edge_change_number(G): + r""" + Count edges whose deletion **changes** the domination number. + + This returns the number of edges :math:`e` such that + + .. math:: + + \gamma(G-e) \ne \gamma(G). + + Parameters + ---------- + G : networkx.Graph-like + A finite graph, intended for simple undirected graphs. + + Returns + ------- + int + The number of edges whose deletion changes :math:`\gamma(G)`. Returns 0 if :math:`G` has + no edges. + + Notes + ----- + This is a thin wrapper around :func:`edge_critical_number` with + ``f = gc.domination_number`` and ``kind = 'change'``. + + Complexity + ---------- + :math:`O(|E(G)|)` evaluations of ``gc.domination_number`` on single-edge-deleted graphs. + """ + return edge_critical_number(G, gc.domination_number, kind="change") + + +def domination_edge_same_number(G): + r""" + Count edges whose deletion leaves the domination number unchanged. + + This returns the number of edges :math:`e` such that + + .. math:: + + \gamma(G-e) = \gamma(G). + + Parameters + ---------- + G : networkx.Graph-like + A finite graph, intended for simple undirected graphs. + + Returns + ------- + int + The number of edges whose deletion does not change :math:`\gamma(G)`. Returns 0 if :math:`G` + has no edges. + + Notes + ----- + This is a thin wrapper around :func:`edge_critical_number` with + ``f = gc.domination_number`` and ``kind = 'same'``. + + Complexity + ---------- + :math:`O(|E(G)|)` evaluations of ``gc.domination_number`` on single-edge-deleted graphs. + """ + return edge_critical_number(G, gc.domination_number, kind="same") + + +def domination_edge_max_jump(G): + r""" + Compute the maximum absolute change in domination number under deletion of a single edge. + + This returns: + + .. math:: + + \max_{e\in E(G)} \bigl|\gamma(G-e) - \gamma(G)\bigr|. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph, intended for simple undirected graphs. + + Returns + ------- + int + The maximum absolute edge-deletion delta for :math:`\gamma`. Returns 0 if :math:`G` has + no edges. + + Notes + ----- + This computes edge-deletion deltas via :func:`edge_deletion_deltas` and returns the maximum + absolute value (0 if there are no edges). + + Complexity + ---------- + :math:`O(|E(G)|)` evaluations of ``gc.domination_number`` on single-edge-deleted graphs. + """ + deltas = edge_deletion_deltas(G, gc.domination_number) + return 0 if not deltas else max(abs(d) for d in deltas.values()) diff --git a/src/graphcalc/invariants/degree.py b/src/graphcalc/invariants/degree.py index 032ecc0..e8fceae 100644 --- a/src/graphcalc/invariants/degree.py +++ b/src/graphcalc/invariants/degree.py @@ -723,3 +723,263 @@ def harmonic_index(G: GraphLike) -> float: 33(6), 1006-1009 (1993). """ return 2*sum((1/(degree(G, v) + degree(G, u)) for u, v in G.edges())) + +def irregularity(G): + r""" + Compute the **(Albertson) irregularity** of a graph :math:`G`. + + The (Albertson) irregularity is the edge-sum of absolute degree differences: + + .. math:: + + \operatorname{irr}(G) \;=\; \sum_{uv \in E(G)} \bigl| \deg(u) - \deg(v) \bigr|, + + where :math:`\deg(u)` denotes the (undirected) degree of vertex :math:`u`. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. For standard usage in graph invariants, :math:`G` is typically a + simple undirected graph. Degrees are read from ``G.degree()`` and the sum ranges over + the edges returned by ``G.edges()``. + + - If ``G`` is a MultiGraph, parallel edges are iterated with multiplicity and degrees + count multiplicity, so this computes the natural multigraph extension. + - If ``G`` is directed, ``G.degree()`` is the total degree (in-degree + out-degree), + so the result is the Albertson irregularity with respect to total degree unless you + replace it by ``G.in_degree()`` or ``G.out_degree()`` by convention. + + Returns + ------- + int + The value :math:`\operatorname{irr}(G)`. In particular, if :math:`G` has no edges, + the sum is empty and the function returns 0. + + Notes + ----- + - This invariant is commonly attributed to **Albertson** and is often called the + *Albertson irregularity*. + - :math:`\operatorname{irr}(G)=0` if and only if :math:`G` is **regular** on every edge, + i.e., every edge joins two vertices of equal degree. (For simple graphs, this holds + in particular for regular graphs.) + - The quantity is additive over components in the sense that it is a sum over edges; there + are no cross-component contributions. + + Complexity + ---------- + Let :math:`n=|V(G)|` and :math:`m=|E(G)|`. Constructing the degree dictionary takes + :math:`O(n+m)` time. The subsequent edge-sum takes :math:`O(m)` time. Memory usage is + :math:`O(n)` for the cached degrees. + + Examples + -------- + >>> import networkx as nx + >>> # Path P4 has degrees [1,2,2,1]; edge differences are 1,0,1 so irr=2 + >>> G = nx.path_graph(4) + >>> irregularity(G) + 2 + + >>> # Any regular graph has irr=0 + >>> H = nx.cycle_graph(6) + >>> irregularity(H) + 0 + """ + deg = dict(G.degree()) + return sum(abs(deg[u] - deg[v]) for u, v in G.edges()) + +def n1_degree_count(G): + r""" + Compute :math:`n_1(G)`, the number of degree-1 vertices of a graph :math:`G`. + + This invariant is the multiplicity of 1 in the degree multiset (degree sequence) of :math:`G`: + + .. math:: + + n_1(G) \;=\; \bigl|\{\, v \in V(G) : \deg(v) = 1 \,\}\bigr|. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. Degrees are taken from ``G.degree()``, following NetworkX conventions: + + - ``Graph``: undirected degree. + - ``DiGraph``: total degree (in-degree + out-degree). + - ``MultiGraph`` / ``MultiDiGraph``: degree counts edge multiplicity. + + Returns + ------- + int + The number of vertices :math:`v` with :math:`\deg(v)=1`. If :math:`G` has no vertices, + returns 0. + + Notes + ----- + - For simple undirected graphs, :math:`n_1(G)` is the number of **leaves**. + - Isolated vertices (degree 0) do not contribute. + - This quantity depends on the degree convention for directed/multi graphs as described above. + + Complexity + ---------- + :math:`O(|V(G)|)`, since it scans the degree view once. + + Examples + -------- + >>> import networkx as nx + >>> G = nx.path_graph(5) # degrees: 1,2,2,2,1 + >>> n1_degree_count(G) + 2 + """ + return sum(1 for _, d in G.degree() if d == 1) + + +def distinct_degree_count(G): + r""" + Return the number of distinct vertex degrees attained in a graph :math:`G`. + + Formally, this computes the cardinality of the set of degrees appearing among vertices: + + .. math:: + + \bigl|\{\, \deg(v) : v \in V(G) \,\}\bigr|. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. Degrees are taken from ``G.degree()``, following NetworkX conventions: + + - ``Graph``: undirected degree. + - ``DiGraph``: total degree (in-degree + out-degree). + - ``MultiGraph`` / ``MultiDiGraph``: degree counts edge multiplicity. + + Returns + ------- + int + The number of distinct degree values occurring in :math:`G`. For the empty graph + (no vertices), this returns 0. + + Notes + ----- + - For simple undirected graphs, this is the number of distinct entries in the degree sequence. + - This is sometimes used as a coarse measure of “degree heterogeneity”. + - If you want distinct *in-degrees* or *out-degrees* for a digraph, use ``G.in_degree()`` + or ``G.out_degree()`` instead of ``G.degree()``. + + Complexity + ---------- + :math:`O(|V(G)|)` time and :math:`O(k)` additional space, where :math:`k` is the number of + distinct degrees (at most :math:`|V(G)|`). + + Examples + -------- + >>> import networkx as nx + >>> G = nx.path_graph(5) # degrees: {1,2} + >>> distinct_degree_count(G) + 2 + >>> H = nx.empty_graph(4) # degrees: {0} + >>> distinct_degree_count(H) + 1 + >>> distinct_degree_count(nx.empty_graph(0)) + 0 + """ + return len({d for _, d in G.degree()}) + + +def count_of_maximum_degree_vertices(G): + r""" + Count the vertices attaining the maximum degree in a graph :math:`G`. + + Let :math:`\Delta(G) = \max\{\deg(v) : v \in V(G)\}` be the maximum degree. This function returns + + .. math:: + + \bigl|\{\, v \in V(G) : \deg(v) = \Delta(G) \,\}\bigr|. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. Degrees are taken from ``G.degree()`` using NetworkX conventions for the + graph type (undirected degree for ``Graph``, total degree for ``DiGraph``, multiplicity for + ``MultiGraph``). + + Returns + ------- + int + The number of vertices of degree :math:`\Delta(G)`. If :math:`G` has no vertices, + returns 0. + + Notes + ----- + - For simple undirected graphs, this is the number of vertices with maximum degree. + - For directed graphs, this uses **total degree** unless you substitute ``G.in_degree()`` + or ``G.out_degree()`` by convention. + + Complexity + ---------- + :math:`O(|V(G)|)` time to scan degrees (and :math:`O(|V(G)|)` auxiliary space in this + particular implementation due to materializing the degree list). + + Examples + -------- + >>> import networkx as nx + >>> G = nx.star_graph(5) # center degree 5, leaves degree 1 + >>> count_of_maximum_degree_vertices(G) + 1 + """ + degs = [d for _, d in G.degree()] + if not degs: + return 0 + dmax = max(degs) + return sum(1 for d in degs if d == dmax) + + +def count_of_minimum_degree_vertices(G): + r""" + Count the vertices attaining the minimum degree in a graph :math:`G`. + + Let :math:`\delta(G) = \min\{\deg(v) : v \in V(G)\}` be the minimum degree. This function returns + + .. math:: + + \bigl|\{\, v \in V(G) : \deg(v) = \delta(G) \,\}\bigr|. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. Degrees are taken from ``G.degree()`` using NetworkX conventions for the + graph type (undirected degree for ``Graph``, total degree for ``DiGraph``, multiplicity for + ``MultiGraph``). + + Returns + ------- + int + The number of vertices of degree :math:`\delta(G)`. If :math:`G` has no vertices, + returns 0. + + Notes + ----- + - For simple undirected graphs, isolated vertices (degree 0) determine :math:`\delta(G)=0` + whenever they exist. + - For directed graphs, this uses **total degree** unless you substitute ``G.in_degree()`` + or ``G.out_degree()`` by convention. + + Complexity + ---------- + :math:`O(|V(G)|)` time to scan degrees (and :math:`O(|V(G)|)` auxiliary space in this + particular implementation due to materializing the degree list). + + Examples + -------- + >>> import networkx as nx + >>> G = nx.path_graph(5) # min degree is 1, achieved by 2 endpoints + >>> count_of_minimum_degree_vertices(G) + 2 + >>> H = nx.empty_graph(4) # all degrees 0 + >>> count_of_minimum_degree_vertices(H) + 4 + """ + degs = [d for _, d in G.degree()] + if not degs: + return 0 + dmin = min(degs) + return sum(1 for d in degs if d == dmin) + diff --git a/src/graphcalc/invariants/local_invariants.py b/src/graphcalc/invariants/local_invariants.py new file mode 100644 index 0000000..2215744 --- /dev/null +++ b/src/graphcalc/invariants/local_invariants.py @@ -0,0 +1,703 @@ +import networkx as nx +import graphcalc as gc + +__all__ = [ + "local_parameter", + "local_parameter_radius", + "local_independence_number", + "local_clique_number", + "local_domination_number", + "local_chromatic_number", + "local_zero_forcing_number", + "local_residue", + "local_harmonic_index", +] + +# ============================================================ +# GENERAL LOCAL OPERATORS +# ============================================================ + +def local_parameter(G, f, *, neighborhood="open", agg="max"): + r""" + Apply a graph parameter locally to neighborhood-induced subgraphs and aggregate the results. + + For each vertex :math:`v \in V(G)`, this operator forms the induced subgraph on either the + **open neighborhood** :math:`N(v)` or the **closed neighborhood** :math:`N[v]=N(v)\cup\{v\}`, + evaluates a graph parameter :math:`f` on that induced subgraph, and then aggregates the local + values over all vertices. + + Formally, let + + .. math:: + + S_v \;=\; + \begin{cases} + N(v), & \text{if neighborhood = 'open'},\\ + N[v], & \text{if neighborhood = 'closed'}. + \end{cases} + + This function computes: + + .. math:: + + \operatorname{Agg}_{v\in V(G)}\; f\!\left(G[S_v]\right), + + where :math:`G[S_v]` is the induced subgraph on :math:`S_v`, and :math:`\operatorname{Agg}` + is one of ``max``, ``min``, ``sum``, or the arithmetic mean ``avg``. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. Neighborhoods are defined using ``G.neighbors(v)``, so this is primarily + intended for **undirected graphs** under the standard adjacency notion. If ``G`` is a + directed graph, NetworkX defines ``neighbors`` as successors, which generally yields a + different “out-neighborhood” notion. + f : callable + A function that accepts a graph ``H`` (a NetworkX graph) and returns a numeric value. + Typical examples include invariant/parameter functions such as ``order``, ``size``, + independence number, clique number, domination number, zero forcing number, etc. + + This implementation calls ``f`` on a **copy** of each induced subgraph to guard against + accidental mutation inside ``f``. + neighborhood : {'open', 'closed'}, optional + Which neighborhood to use for the local induced subgraphs: + - ``'open'``: :math:`S_v = N(v)` (neighbors only) + - ``'closed'``: :math:`S_v = N[v]` (neighbors plus the vertex itself) + agg : {'max', 'min', 'sum', 'avg'}, optional + Aggregation operator applied to the multiset of local values + :math:`\{ f(G[S_v]) : v\in V(G)\}`. + + Returns + ------- + number + The aggregated value. If :math:`|V(G)| = 0`, returns 0. + + Notes + ----- + - If :math:`v` is isolated, then :math:`N(v)=\varnothing`. In that case: + * with ``neighborhood='open'`` the induced graph is empty, ``G[∅]``; + * with ``neighborhood='closed'`` the induced graph is the single-vertex graph, ``G[{v}]``. + Ensure your parameter function ``f`` is defined on these graphs. + - The induced subgraphs are formed independently for each vertex; overlapping neighborhoods + are allowed and expected. + - If ``f`` is guaranteed to be pure/read-only, you may remove the ``.copy()`` calls for speed. + + Complexity + ---------- + This constructs one induced subgraph per vertex and evaluates ``f`` on each. The total runtime + is dominated by: + - the cost of forming induced subgraphs on :math:`N(v)` or :math:`N[v]`, and + - the cost of evaluating ``f`` on each such subgraph. + In symbols, the cost is roughly :math:`\sum_{v\in V(G)} T_f(|S_v|, |E(G[S_v])|)` plus overhead. + + Raises + ------ + ValueError + If ``neighborhood`` is not in ``{'open','closed'}`` or ``agg`` is not in + ``{'max','min','sum','avg'}``. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.path_graph(5) + >>> # Max degree inside open neighborhoods: each open neighborhood is an induced subgraph + >>> gc.local_parameter(G, gc.maximum_degree, neighborhood="open", agg="max") + 0 + >>> # Max order of closed neighborhoods: max |N[v]| over v + >>> local_parameter(G, gc.order, neighborhood="closed", agg="max") + 3 + """ + n = gc.order(G) + if n == 0: + return 0 + + if neighborhood not in {"open", "closed"}: + raise ValueError("neighborhood must be 'open' or 'closed'") + if agg not in {"max", "min", "sum", "avg"}: + raise ValueError("agg must be one of {'max','min','sum','avg'}") + + vals = [] + for v in G.nodes(): + S = set(G.neighbors(v)) + if neighborhood == "closed": + S.add(v) + + H = G.subgraph(S).copy() + vals.append(f(H)) + + if agg == "max": + return max(vals) + if agg == "min": + return min(vals) + if agg == "sum": + return sum(vals) + return sum(vals) / len(vals) # agg == "avg" + + +def local_parameter_radius(G, f, *, r=1, closed=True, agg="max"): + r""" + Apply a graph parameter locally to **radius-:math:`r` distance balls** and aggregate. + + For each vertex :math:`v \in V(G)`, form the vertex set of the (closed) distance ball + + .. math:: + + B_r(v) \;=\; \{\, u \in V(G) : d_G(u,v) \le r \,\}, + + where :math:`d_G` is the unweighted shortest-path distance in :math:`G`. The function then + evaluates a graph parameter :math:`f` on the induced subgraph :math:`G[B_r(v)]` (or on the + induced **open ball** if ``closed=False``), and aggregates the resulting values over all vertices. + + If ``closed=False``, the center vertex :math:`v` is removed from the ball before inducing: + :math:`B_r(v) \setminus \{v\}`. + + Common special cases + -------------------- + - ``r=1, closed=False`` gives induced open neighborhoods :math:`G[N(v)]`. + - ``r=1, closed=True`` gives induced closed neighborhoods :math:`G[N[v]]`. + - ``r=2, closed=True`` gives induced closed distance-2 balls. + - ``r=2, closed=False`` gives induced open distance-2 balls (distance ≤ 2, excluding the center). + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. Distances are computed with unweighted BFS via + :func:`networkx.single_source_shortest_path_length`. This is primarily intended for + **undirected graphs**; for directed graphs, distances respect edge directions. + f : callable + A function accepting a graph ``H`` and returning a numeric value. + This implementation calls ``f`` on a **copy** of each induced ball subgraph. + r : int, optional + Radius :math:`r \ge 0`. If ``r=0``, each ball is ``{v}`` (or empty if ``closed=False``). + closed : bool, optional + If True, include the center vertex :math:`v` in the ball; if False, exclude it. + agg : {'max', 'min', 'sum', 'avg'}, optional + Aggregation operator applied to the multiset of local values + :math:`\{ f(G[B_r(v)]) : v\in V(G)\}` (or open balls when ``closed=False``). + + Returns + ------- + number + The aggregated value. If :math:`|V(G)| = 0`, returns 0. + + Raises + ------ + ValueError + If ``r < 0`` or if ``agg`` is not in ``{'max','min','sum','avg'}``. + + Notes + ----- + - The ball is computed with BFS distances in the *original graph* ``G`` and then induced. + This is different from, e.g., taking the radius-:math:`r` neighborhood inside some already + induced subgraph. + - Ensure your parameter ``f`` is defined on the empty graph, since open balls can be empty + when ``r=0`` or when a vertex is isolated and you exclude the center. + + Complexity + ---------- + For each vertex, a BFS to depth ``r`` is performed. In the worst case (when ``r`` is large + relative to the diameter), this is essentially one BFS per vertex, so the time is + :math:`O(|V||E|)` for sparse graphs (more precisely :math:`O(\sum_v (|V|+|E|))` in the worst case). + The dominant cost is usually the BFS calls plus evaluation of ``f`` on each induced ball. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.path_graph(6) + >>> # Max size of closed radius-2 balls in a path on 6 vertices: + >>> gc.local_parameter_radius(G, gc.order, r=2, closed=True, agg="max") + 5 + >>> # Average size of open radius-1 balls is the average degree: + >>> gc.local_parameter_radius(G, gc.order, r=1, closed=False, agg="avg") + 1.6666666666666667 + """ + if r < 0: + raise ValueError("r must be >= 0") + n = gc.order(G) + if n == 0: + return 0 + + vals = [] + for v in G.nodes(): + dist = nx.single_source_shortest_path_length(G, v, cutoff=r) + S = set(dist.keys()) + if not closed: + S.discard(v) + H = G.subgraph(S).copy() + vals.append(f(H)) + + if agg == "max": + return max(vals) + if agg == "min": + return min(vals) + if agg == "sum": + return sum(vals) + if agg == "avg": + return sum(vals) / len(vals) + + raise ValueError("agg must be one of {'max','min','sum','avg'}") + +def local_independence_number(G): + r""" + Compute the **local independence number** of a graph :math:`G` (with respect to open neighborhoods). + + The local independence number is defined as the maximum independence number attained by the + induced subgraph on an open neighborhood: + + .. math:: + + \alpha_{\mathrm{loc}}(G) \;=\; \max_{v \in V(G)} \alpha\!\bigl(G[N(v)]\bigr), + + where :math:`N(v)` is the **open neighborhood** of :math:`v` (the set of neighbors of :math:`v`), + :math:`G[N(v)]` is the induced subgraph on :math:`N(v)`, and :math:`\alpha(H)` denotes the + independence number of a graph :math:`H`. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. Neighborhoods are taken using ``G.neighbors(v)``, so this is primarily + intended for **undirected graphs** with the standard adjacency notion. For directed graphs, + NetworkX interprets ``neighbors`` as successors, which yields an out-neighborhood variant. + + Returns + ------- + int + The value :math:`\alpha_{\mathrm{loc}}(G)`. If :math:`G` has no vertices, returns 0. + + Notes + ----- + - If :math:`v` is isolated, then :math:`N(v)=\varnothing` and :math:`G[N(v)]` is the empty graph, + so :math:`\alpha(G[N(v)]) = 0`. Thus isolated vertices do not increase the maximum. + - This is a neighborhood-based refinement of the global independence number. In general, + :math:`\alpha_{\mathrm{loc}}(G)` need not equal :math:`\alpha(G)` and may be smaller or larger + (e.g., in graphs with a high-degree vertex whose neighborhood is sparse). + - For a complete graph :math:`K_n` (n ≥ 2), every open neighborhood induces :math:`K_{n-1}`, + so :math:`\alpha_{\mathrm{loc}}(K_n)=1`. + + Complexity + ---------- + This function delegates to :func:`local_parameter` with ``gc.independence_number``. + The runtime is therefore dominated by the cost of computing the independence number on each + neighborhood-induced subgraph. For exact independence number routines, this may be exponential + in the neighborhood sizes. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> # In a star, the center's neighborhood is an independent set of size n-1. + >>> G = nx.star_graph(5) # 6 vertices total: center + 5 leaves + >>> gc.local_independence_number(G) + 5 + + >>> # In a complete graph, every neighborhood is a clique. + >>> H = nx.complete_graph(6) + >>> gc.local_independence_number(H) + 1 + """ + return local_parameter(G, gc.independence_number, neighborhood="open", agg="max") + +def local_clique_number(G): + r""" + Compute the **local clique number** of a graph :math:`G` (with respect to open neighborhoods). + + The local clique number is defined as the maximum clique number attained by an induced + open neighborhood: + + .. math:: + + \omega_{\mathrm{loc}}(G) \;=\; \max_{v \in V(G)} \omega\!\bigl(G[N(v)]\bigr), + + where :math:`N(v)` is the **open neighborhood** of :math:`v` (the set of neighbors of :math:`v`), + :math:`G[N(v)]` is the subgraph induced by :math:`N(v)`, and :math:`\omega(H)` denotes the + **clique number** of :math:`H` (the size of a largest complete subgraph of :math:`H`). + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. Neighborhoods are computed via ``G.neighbors(v)``, so this is primarily + intended for **undirected graphs** under the standard adjacency notion. For directed graphs, + NetworkX interprets ``neighbors`` as successors, which yields an out-neighborhood variant. + + Returns + ------- + int + The value :math:`\omega_{\mathrm{loc}}(G)`. If :math:`G` has no vertices, returns 0. + + Notes + ----- + - If :math:`v` is isolated, then :math:`N(v)=\varnothing` and :math:`G[N(v)]` is empty, so + :math:`\omega(G[N(v)]) = 0`. Thus isolated vertices do not increase the maximum. + - :math:`\omega(G[N(v)])` measures how “clique-like” the neighborhood of :math:`v` is. In + particular, it equals the maximum number of neighbors of :math:`v` that are pairwise adjacent. + - For a complete graph :math:`K_n` (n ≥ 2), every open neighborhood induces :math:`K_{n-1}`, so + :math:`\omega_{\mathrm{loc}}(K_n)=n-1`. + - For a bipartite graph, every open neighborhood induces an independent set, so + :math:`\omega_{\mathrm{loc}}(G) \le 1` (and equals 1 whenever there is an edge). + + Complexity + ---------- + This function delegates to :func:`local_parameter` with ``gc.clique_number``. + The runtime is therefore dominated by the cost of computing the clique number on each + neighborhood-induced subgraph. Exact clique number routines are typically exponential in + neighborhood size. + + Examples + -------- + >>> import networkx as nx + >>> # In a star, each leaf has neighborhood {center} (clique number 1), + >>> # and the center has neighborhood consisting of independent leaves (clique number 1). + >>> G = nx.star_graph(5) + >>> gc.local_clique_number(G) + 1 + + >>> # In a complete graph, neighborhoods are complete. + >>> H = nx.complete_graph(6) + >>> gc.local_clique_number(H) + 5 + + >>> # In a triangle, each vertex's neighborhood is an edge (clique number 2). + >>> T = nx.complete_graph(3) + >>> gc.local_clique_number(T) + 2 + """ + return local_parameter(G, gc.clique_number, neighborhood="open", agg="max") + +def local_domination_number(G): + r""" + Compute the **local domination number** of a graph :math:`G` (with respect to open neighborhoods). + + The local domination number is defined as the maximum domination number attained by an induced + open neighborhood: + + .. math:: + + \gamma_{\mathrm{loc}}(G) \;=\; \max_{v \in V(G)} \gamma\!\bigl(G[N(v)]\bigr), + + where :math:`N(v)` is the **open neighborhood** of :math:`v` (the set of neighbors of :math:`v`), + :math:`G[N(v)]` is the subgraph induced by :math:`N(v)`, and :math:`\gamma(H)` denotes the + **domination number** of a graph :math:`H`. + + Recall that the domination number :math:`\gamma(H)` is + + .. math:: + + \gamma(H) \;=\; \min\{\, |D| : D \subseteq V(H)\ \text{and}\ N_H[D] = V(H) \,\}, + + i.e., the minimum size of a vertex set :math:`D` such that every vertex of :math:`H` lies in + :math:`D` or has a neighbor in :math:`D` (here :math:`N_H[D]` denotes the closed neighborhood of + :math:`D` in :math:`H`). + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. Neighborhoods are computed via ``G.neighbors(v)``, so this is primarily + intended for **undirected graphs** under the standard adjacency notion. For directed graphs, + NetworkX interprets ``neighbors`` as successors, which yields an out-neighborhood variant. + + Returns + ------- + int + The value :math:`\gamma_{\mathrm{loc}}(G)`. If :math:`G` has no vertices, returns 0. + + Notes + ----- + - If :math:`v` is isolated, then :math:`N(v)=\varnothing` and :math:`G[N(v)]` is the empty graph. + Under the standard convention used by most domination-number implementations, the empty graph + has domination number 0, so isolated vertices do not increase the maximum. + - This is a **local** parameter: it measures how “difficult” it is to dominate each open-neighborhood + subgraph, rather than dominating :math:`G` itself. In general, :math:`\gamma_{\mathrm{loc}}(G)` + is not equal to :math:`\gamma(G)`. + + Complexity + ---------- + This function delegates to :func:`local_parameter` with ``gc.domination_number``. + The runtime is therefore dominated by the cost of computing the domination number on each + neighborhood-induced subgraph. Exact domination number routines are typically exponential in + neighborhood size. + + Examples + -------- + >>> import networkx as nx + >>> # In a star, the center's neighborhood is an independent set of size n-1, + >>> # whose domination number equals n-1 (each isolated vertex must be chosen). + >>> G = nx.star_graph(5) + >>> gc.local_domination_number(G) + 5 + + >>> # In a complete graph, each neighborhood is complete, so domination number is 1. + >>> H = nx.complete_graph(6) + >>> gc.local_domination_number(H) + 1 + """ + return local_parameter(G, gc.domination_number, neighborhood="open", agg="max") + +def local_zero_forcing_number(G): + r""" + Compute the **local zero forcing number** of a graph :math:`G` (with respect to open neighborhoods). + + The local zero forcing number is defined as the maximum zero forcing number attained by an induced + open neighborhood: + + .. math:: + + Z_{\mathrm{loc}}(G) \;=\; \max_{v \in V(G)} Z\!\bigl(G[N(v)]\bigr), + + where :math:`N(v)` is the **open neighborhood** of :math:`v`, :math:`G[N(v)]` is the subgraph + induced by :math:`N(v)`, and :math:`Z(H)` denotes the **zero forcing number** of a graph :math:`H`. + + Zero forcing number (brief definition) + -------------------------------------- + Given a graph :math:`H` and an initial set :math:`S \subseteq V(H)` of colored vertices, apply the + *standard zero forcing rule* repeatedly: + + - If a colored vertex has **exactly one** uncolored neighbor, it *forces* that neighbor to become colored. + + The set :math:`S` is a **zero forcing set** if this process eventually colors all vertices of :math:`H`. + The zero forcing number is the minimum size of such a set: + + .. math:: + + Z(H) \;=\; \min\{\, |S| : S \subseteq V(H)\ \text{is a zero forcing set for } H \,\}. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. Neighborhoods are computed via ``G.neighbors(v)``, so this is primarily + intended for **undirected graphs** under the standard adjacency notion. For directed graphs, + NetworkX interprets ``neighbors`` as successors, which yields an out-neighborhood variant. + + Returns + ------- + int + The value :math:`Z_{\mathrm{loc}}(G)`. If :math:`G` has no vertices, returns 0. + + Notes + ----- + - If :math:`v` is isolated, then :math:`N(v)=\varnothing` and :math:`G[N(v)]` is the empty graph. + Under the standard convention used by most zero-forcing implementations, the empty graph has + zero forcing number 0, so isolated vertices do not increase the maximum. + - This is a **local** refinement: it measures how large a forcing set is needed to control each + neighborhood-induced subgraph, rather than :math:`G` itself. In general, + :math:`Z_{\mathrm{loc}}(G)` is not equal to :math:`Z(G)`. + + Complexity + ---------- + This function delegates to :func:`local_parameter` with ``gc.zero_forcing_number``. + The runtime is therefore dominated by the cost of computing the zero forcing number on each + neighborhood-induced subgraph. Exact zero forcing number routines are typically exponential in + neighborhood size. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> # In a complete graph, each neighborhood is complete, and Z(K_m)=m-1. + >>> K6 = nx.complete_graph(6) + >>> gc.local_zero_forcing_number(K6) # neighborhoods are K5 + 4 + + >>> # In a star, the center's neighborhood is an independent set of size n-1, whose Z equals n-1. + >>> S = nx.star_graph(5) # 6 vertices total: center + 5 leaves + >>> gc.local_zero_forcing_number(S) + 5 + """ + return local_parameter(G, gc.zero_forcing_number, neighborhood="open", agg="max") + +def local_residue(G): + r""" + Compute the **local Havel–Hakimi residue** of a graph :math:`G` (with respect to open neighborhoods). + + This parameter is defined by applying the **Havel–Hakimi residue** (as implemented by + :func:`graphcalc.residue`) to each induced open neighborhood subgraph and taking the maximum: + + .. math:: + + \operatorname{res}_{\mathrm{loc}}(G) + \;=\; + \max_{v \in V(G)} \operatorname{res}\!\bigl(G[N(v)]\bigr), + + where :math:`N(v)` is the **open neighborhood** of :math:`v`, :math:`G[N(v)]` is the subgraph + induced by :math:`N(v)`, and :math:`\operatorname{res}(H)` denotes the **Havel–Hakimi residue** + of :math:`H`. + + Havel–Hakimi residue (brief description) + ---------------------------------------- + The Havel–Hakimi process operates on a (nonnegative) integer degree sequence + :math:`d=(d_1,\dots,d_n)`: + + 1. Sort the sequence in nonincreasing order. + 2. Remove the first term :math:`d_1`. + 3. Subtract 1 from each of the next :math:`d_1` terms. + 4. Repeat until no positive terms remain (or the process fails for non-graphical sequences). + + When applied to the **degree sequence of a graph** :math:`H`, this iterative reduction + produces a terminal sequence with some number of zeros. The **residue** :math:`\operatorname{res}(H)` + (in the Havel–Hakimi sense) is the number of zeros in this terminal sequence. Equivalently, it is + the number of vertices left with degree 0 at termination of the Havel–Hakimi reduction started from + the degree sequence of :math:`H`. + + (This matches the convention used by :func:`graphcalc.residue`.) + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. Neighborhoods are computed via ``G.neighbors(v)``, so this is primarily + intended for **undirected graphs** under the standard adjacency notion. For directed graphs, + NetworkX interprets ``neighbors`` as successors; if you want an undirected notion, convert via + ``G.to_undirected()`` first. + + Returns + ------- + int + The value :math:`\operatorname{res}_{\mathrm{loc}}(G)`. If :math:`G` has no vertices, + returns 0. + + Notes + ----- + - If :math:`v` is isolated, then :math:`N(v)=\varnothing` and :math:`G[N(v)]` is the empty graph. + The Havel–Hakimi residue of the empty graph is 0 under the usual convention, so isolated vertices + do not increase the maximum. + - This is a **local** construction: it measures the Havel–Hakimi residue on neighborhood-induced + subgraphs rather than on :math:`G` itself. + - The Havel–Hakimi residue is defined via degree sequences and depends only on the degree sequence + of the input graph, not on its particular labeling. + + Complexity + ---------- + This function delegates to :func:`local_parameter` with ``gc.residue``. The runtime is dominated + by the cost of evaluating ``gc.residue`` on each neighborhood-induced subgraph. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> # For a complete graph K_n, each open neighborhood is K_{n-1} (regular), + >>> # so the local residue is the HH-residue of K_{n-1}. + >>> G = nx.complete_graph(6) + >>> gc.local_residue(G) + 1 + """ + return local_parameter(G, gc.residue, neighborhood="open", agg="max") + +def local_harmonic_index(G): + r""" + Compute the **local harmonic index** of a graph :math:`G` (with respect to open neighborhoods). + + This parameter is defined by applying the harmonic index to each induced open neighborhood + subgraph and taking the maximum: + + .. math:: + + H_{\mathrm{loc}}(G) \;=\; \max_{v \in V(G)} H\!\bigl(G[N(v)]\bigr), + + where :math:`N(v)` is the **open neighborhood** of :math:`v`, :math:`G[N(v)]` is the subgraph + induced by :math:`N(v)`, and :math:`H(\cdot)` denotes the **harmonic index** as implemented by + :func:`graphcalc.harmonic_index` (here referenced as ``gc.harmonic_index``). + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. Neighborhoods are computed via ``G.neighbors(v)``, so this is primarily + intended for **undirected graphs** under the standard adjacency notion. For directed graphs, + NetworkX interprets ``neighbors`` as successors, which yields an out-neighborhood variant. + + Returns + ------- + number + The value :math:`H_{\mathrm{loc}}(G)`. If :math:`G` has no vertices, returns 0. + + Notes + ----- + - A widely used definition of the (edge-based) harmonic index of a simple undirected graph :math:`H` is + + .. math:: + + H(H) \;=\; \sum_{xy \in E(H)} \frac{2}{\deg_H(x) + \deg_H(y)}. + + This function uses the **exact convention implemented by** ``gc.harmonic_index``; the above + formula is included for orientation. + - If :math:`v` is isolated, then :math:`N(v)=\varnothing` and :math:`G[N(v)]` is the empty graph. + Under the standard edge-sum convention, the harmonic index of an edgeless graph is 0 (empty sum), + so isolated vertices do not increase the maximum. + - This is a **local** refinement: it measures harmonic index on neighborhood-induced subgraphs rather + than on :math:`G` itself. + + Complexity + ---------- + This function delegates to :func:`local_parameter` with ``gc.harmonic_index``. The runtime is + dominated by the cost of evaluating ``gc.harmonic_index`` on each neighborhood-induced subgraph. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.path_graph(6) + >>> local_harmonic_index(G) # doctest: +SKIP + ... # depends on gc.harmonic_index implementation details + """ + return local_parameter(G, gc.harmonic_index, neighborhood="open", agg="max") + +def local_chromatic_number(G): + r""" + Compute the **local chromatic number** of a graph :math:`G` (with respect to open neighborhoods). + + This parameter is defined by taking, over all vertices :math:`v`, the chromatic number of the + subgraph induced by the open neighborhood :math:`N(v)` and then maximizing: + + .. math:: + + \chi_{\mathrm{loc}}(G) \;=\; \max_{v \in V(G)} \chi\!\bigl(G[N(v)]\bigr), + + where :math:`N(v)` is the **open neighborhood** of :math:`v`, :math:`G[N(v)]` denotes the induced + subgraph on :math:`N(v)`, and :math:`\chi(H)` is the **chromatic number** of :math:`H` (the minimum + number of colors in a proper vertex coloring of :math:`H`). + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. Neighborhoods are computed via ``G.neighbors(v)``, so this is primarily + intended for **undirected graphs** under the standard adjacency notion. For directed graphs, + NetworkX interprets ``neighbors`` as successors, which yields an out-neighborhood variant. + + Returns + ------- + int + The value :math:`\chi_{\mathrm{loc}}(G)`. If :math:`G` has no vertices, returns 0. + + Notes + ----- + - Empty/isolated-neighborhood convention: if :math:`v` is isolated, then :math:`N(v)=\varnothing` + and :math:`G[N(v)]` is the empty graph. Many conventions set :math:`\chi(\varnothing)=0`, while + some implementations return 1. This function uses whatever convention is implemented by + ``gc.chromatic_number`` when applied to the empty graph. This only affects the value when + :math:`G` has isolated vertices. + - This is a **local** refinement of coloring complexity: it measures the strongest coloring + requirement among neighborhood-induced subgraphs, rather than coloring :math:`G` itself. + In general, :math:`\chi_{\mathrm{loc}}(G)` need not equal :math:`\chi(G)`. + + Complexity + ---------- + This function delegates to :func:`local_parameter` with ``gc.chromatic_number``. + The runtime is therefore dominated by the cost of computing the chromatic number on each + neighborhood-induced subgraph. Exact chromatic number routines are typically exponential in + neighborhood size. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> # Complete graph: each neighborhood is complete, so chi_loc(K_n) = n-1. + >>> K6 = nx.complete_graph(6) + >>> gc.local_chromatic_number(K6) + 5 + + >>> # Bipartite graphs have neighborhoods inducing independent sets, so chi_loc is at most 1 + >>> # (or 0 on isolated neighborhoods depending on the empty-graph convention). + >>> P6 = nx.path_graph(6) + >>> gc.local_chromatic_number(P6) + 1 + """ + return local_parameter(G, gc.chromatic_number, neighborhood="open", agg="max") From a07e6e9fe140fbbaef6b54bcd1bd0f996daee506 Mon Sep 17 00:00:00 2001 From: Randy Davila Date: Tue, 3 Feb 2026 12:06:24 -0600 Subject: [PATCH 2/4] added most of the new invariants and updated docs --- docs/source/api_reference.rst | 3 +- docs/source/modules/invariants.rst | 29 - docs/source/modules/invariants/classics.rst | 7 + .../invariants/coloring_predicates.rst | 7 + .../modules/invariants/core_invariants.rst | 7 + .../invariants/critical_invariants.rst | 7 + .../modules/invariants/cycle_invariants.rst | 7 + docs/source/modules/invariants/degree.rst | 7 + docs/source/modules/invariants/domination.rst | 7 + .../modules/invariants/graph_indices.rst | 7 + docs/source/modules/invariants/index.rst | 18 + .../modules/invariants/local_invariants.rst | 7 + docs/source/modules/invariants/spectral.rst | 7 + .../invariants/transversal_invariants.rst | 7 + .../modules/invariants/zero_forcing.rst | 7 + docs/source/modules/viz.rst | 17 + docs/source/solvers.rst | 8 +- docs/source/usage.rst | 16 +- src/graphcalc/core/basics.py | 10 +- src/graphcalc/invariants/__init__.py | 4 + src/graphcalc/invariants/classics.py | 81 +- .../invariants/coloring_predicates.py | 180 +++ src/graphcalc/invariants/core_invariants.py | 65 +- .../invariants/critical_invariants.py | 89 +- src/graphcalc/invariants/cycle_invariants.py | 1251 +++++++++++++++++ src/graphcalc/invariants/degree.py | 141 +- src/graphcalc/invariants/domination.py | 278 +++- src/graphcalc/invariants/graph_indices.py | 824 +++++++++++ src/graphcalc/invariants/local_invariants.py | 127 +- src/graphcalc/invariants/spectral.py | 61 +- .../invariants/transversal_invariants.py | 262 ++++ src/graphcalc/invariants/zero_forcing.py | 83 +- tests/test_degree.py | 3 +- 33 files changed, 3250 insertions(+), 384 deletions(-) delete mode 100644 docs/source/modules/invariants.rst create mode 100644 docs/source/modules/invariants/classics.rst create mode 100644 docs/source/modules/invariants/coloring_predicates.rst create mode 100644 docs/source/modules/invariants/core_invariants.rst create mode 100644 docs/source/modules/invariants/critical_invariants.rst create mode 100644 docs/source/modules/invariants/cycle_invariants.rst create mode 100644 docs/source/modules/invariants/degree.rst create mode 100644 docs/source/modules/invariants/domination.rst create mode 100644 docs/source/modules/invariants/graph_indices.rst create mode 100644 docs/source/modules/invariants/index.rst create mode 100644 docs/source/modules/invariants/local_invariants.rst create mode 100644 docs/source/modules/invariants/spectral.rst create mode 100644 docs/source/modules/invariants/transversal_invariants.rst create mode 100644 docs/source/modules/invariants/zero_forcing.rst create mode 100644 docs/source/modules/viz.rst create mode 100644 src/graphcalc/invariants/coloring_predicates.py create mode 100644 src/graphcalc/invariants/cycle_invariants.py create mode 100644 src/graphcalc/invariants/graph_indices.py create mode 100644 src/graphcalc/invariants/transversal_invariants.py diff --git a/docs/source/api_reference.rst b/docs/source/api_reference.rst index 64ecea6..7750544 100644 --- a/docs/source/api_reference.rst +++ b/docs/source/api_reference.rst @@ -9,8 +9,9 @@ This section provides a detailed overview of all modules, classes, and functions modules/core modules/data modules/generators - modules/invariants + modules/invariants/index modules/polytopes + modules/viz .. Modules .. ------- diff --git a/docs/source/modules/invariants.rst b/docs/source/modules/invariants.rst deleted file mode 100644 index 131cb5f..0000000 --- a/docs/source/modules/invariants.rst +++ /dev/null @@ -1,29 +0,0 @@ -Invariants -========== - -The invariants module provides functions to compute various graph invariants. - -.. automodule:: graphcalc.invariants.classics - :members: - :undoc-members: - :show-inheritance: - -.. automodule:: graphcalc.invariants.degree - :members: - :undoc-members: - :show-inheritance: - -.. automodule:: graphcalc.invariants.domination - :members: - :undoc-members: - :show-inheritance: - -.. automodule:: graphcalc.invariants.spectral - :members: - :undoc-members: - :show-inheritance: - -.. automodule:: graphcalc.invariants.zero_forcing - :members: - :undoc-members: - :show-inheritance: diff --git a/docs/source/modules/invariants/classics.rst b/docs/source/modules/invariants/classics.rst new file mode 100644 index 0000000..e2e2dae --- /dev/null +++ b/docs/source/modules/invariants/classics.rst @@ -0,0 +1,7 @@ +Classics +======== + +.. automodule:: graphcalc.invariants.classics + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules/invariants/coloring_predicates.rst b/docs/source/modules/invariants/coloring_predicates.rst new file mode 100644 index 0000000..278d1c7 --- /dev/null +++ b/docs/source/modules/invariants/coloring_predicates.rst @@ -0,0 +1,7 @@ +Coloring Predicates +=================== + +.. automodule:: graphcalc.invariants.coloring_predicates + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules/invariants/core_invariants.rst b/docs/source/modules/invariants/core_invariants.rst new file mode 100644 index 0000000..624307b --- /dev/null +++ b/docs/source/modules/invariants/core_invariants.rst @@ -0,0 +1,7 @@ +Core Invariants +=============== + +.. automodule:: graphcalc.invariants.core_invariants + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules/invariants/critical_invariants.rst b/docs/source/modules/invariants/critical_invariants.rst new file mode 100644 index 0000000..997dcb6 --- /dev/null +++ b/docs/source/modules/invariants/critical_invariants.rst @@ -0,0 +1,7 @@ +Critical Invariants +=================== + +.. automodule:: graphcalc.invariants.critical_invariants + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules/invariants/cycle_invariants.rst b/docs/source/modules/invariants/cycle_invariants.rst new file mode 100644 index 0000000..220d018 --- /dev/null +++ b/docs/source/modules/invariants/cycle_invariants.rst @@ -0,0 +1,7 @@ +Cycle Invariants +================ + +.. automodule:: graphcalc.invariants.cycle_invariants + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules/invariants/degree.rst b/docs/source/modules/invariants/degree.rst new file mode 100644 index 0000000..2a31eae --- /dev/null +++ b/docs/source/modules/invariants/degree.rst @@ -0,0 +1,7 @@ +Degree +====== + +.. automodule:: graphcalc.invariants.degree + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules/invariants/domination.rst b/docs/source/modules/invariants/domination.rst new file mode 100644 index 0000000..1177d56 --- /dev/null +++ b/docs/source/modules/invariants/domination.rst @@ -0,0 +1,7 @@ +Domination +========== + +.. automodule:: graphcalc.invariants.domination + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules/invariants/graph_indices.rst b/docs/source/modules/invariants/graph_indices.rst new file mode 100644 index 0000000..2b3bd8d --- /dev/null +++ b/docs/source/modules/invariants/graph_indices.rst @@ -0,0 +1,7 @@ +Graph Indices +============= + +.. automodule:: graphcalc.invariants.graph_indices + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules/invariants/index.rst b/docs/source/modules/invariants/index.rst new file mode 100644 index 0000000..33bdaf6 --- /dev/null +++ b/docs/source/modules/invariants/index.rst @@ -0,0 +1,18 @@ +Invariants +========== + +.. toctree:: + :maxdepth: 2 + + classics + degree + domination + spectral + zero_forcing + graph_indices + cycle_invariants + local_invariants + transversal_invariants + coloring_predicates + core_invariants + critical_invariants diff --git a/docs/source/modules/invariants/local_invariants.rst b/docs/source/modules/invariants/local_invariants.rst new file mode 100644 index 0000000..5a88f2f --- /dev/null +++ b/docs/source/modules/invariants/local_invariants.rst @@ -0,0 +1,7 @@ +Local Invariants +================ + +.. automodule:: graphcalc.invariants.local_invariants + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules/invariants/spectral.rst b/docs/source/modules/invariants/spectral.rst new file mode 100644 index 0000000..d83c363 --- /dev/null +++ b/docs/source/modules/invariants/spectral.rst @@ -0,0 +1,7 @@ +Spectral +======== + +.. automodule:: graphcalc.invariants.spectral + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules/invariants/transversal_invariants.rst b/docs/source/modules/invariants/transversal_invariants.rst new file mode 100644 index 0000000..a71a54f --- /dev/null +++ b/docs/source/modules/invariants/transversal_invariants.rst @@ -0,0 +1,7 @@ +Transversal Invariants +====================== + +.. automodule:: graphcalc.invariants.transversal_invariants + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules/invariants/zero_forcing.rst b/docs/source/modules/invariants/zero_forcing.rst new file mode 100644 index 0000000..9c1a8b7 --- /dev/null +++ b/docs/source/modules/invariants/zero_forcing.rst @@ -0,0 +1,7 @@ +Zero Forcing +============ + +.. automodule:: graphcalc.invariants.zero_forcing + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/modules/viz.rst b/docs/source/modules/viz.rst new file mode 100644 index 0000000..52a4844 --- /dev/null +++ b/docs/source/modules/viz.rst @@ -0,0 +1,17 @@ +Visualization +============= + +.. automodule:: graphcalc.viz + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: graphcalc.viz.edges + :members: + :undoc-members: + :show-inheritance: + +.. automodule:: graphcalc.viz.vertces + :members: + :undoc-members: + :show-inheritance: diff --git a/docs/source/solvers.rst b/docs/source/solvers.rst index 37f54d4..6282ac4 100644 --- a/docs/source/solvers.rst +++ b/docs/source/solvers.rst @@ -1,3 +1,5 @@ +.. _using-custom-solvers: + Using Custom Solvers ==================== @@ -205,7 +207,7 @@ Troubleshooting **“PuLP: cannot execute highs.”** - You selected ``HiGHS_CMD`` but the ``highs`` executable is not on ``PATH``. - Install it (see :doc:`Installation`) or use the Python package + Install it (see :doc:`installation`) or use the Python package ``highspy`` and set ``GRAPHCALC_SOLVER=highs``. As a quick fix, force CBC: ``GRAPHCALC_SOLVER=cbc``. @@ -223,11 +225,11 @@ Troubleshooting - On Ubuntu 22.04 GitHub runners, install ``coinor-cbc`` and set ``GRAPHCALC_SOLVER=cbc``. Or install ``highspy`` and set - ``GRAPHCALC_SOLVER=highs``. See :doc:`Installation` for ready-to-use YAML. + ``GRAPHCALC_SOLVER=highs``. See :doc:`installation` for ready-to-use YAML. See Also -------- -- :doc:`Installation` — how to install solvers on your platform. +- :doc:`installation` — how to install solvers on your platform. - PuLP solver docs for detailed option names/behavior. diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 3bc59bc..ed89e9a 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -6,7 +6,7 @@ choose a solver, and build reusable analysis tables with pandas. All functions accept either a ``networkx.Graph`` or ``graphcalc.SimpleGraph``. Many invariants are NP-hard and use a MILP solver under the hood—see -:doc:`Installation` and :doc:`Using-Custom-Solvers`. +:doc:`installation` and :ref:`using-custom-solvers`. .. contents:: :local: @@ -43,7 +43,7 @@ Quick Start Choosing a Solver (optional) ---------------------------- -GraphCalc auto-detects a solver (see :doc:`Installation`). You can override per-call: +GraphCalc auto-detects a solver (see :doc:`installation`). You can override per-call: .. code-block:: python @@ -61,7 +61,7 @@ Or set an environment variable globally: export GRAPHCALC_SOLVER=cbc # or: highs, glpk, auto For all supported forms (string / dict / class / instance / callable), see -:doc:`Using-Custom-Solvers`. +:ref:`using-custom-solvers`. Core Recipes @@ -184,7 +184,7 @@ Compute a few selected properties for one graph Solver-backed invariants in this call use the **auto-detected** solver. To force a solver for batch runs, set ``GRAPHCALC_SOLVER`` (see - :doc:`Installation`) or call those functions directly with ``solver=...``. + :doc:`installation`) or call those functions directly with ``solver=...``. Compute a table for multiple graphs @@ -273,12 +273,12 @@ Troubleshooting - **“No LP/MIP solver found.”** Install one solver (CBC / HiGHS / GLPK) or set ``GRAPHCALC_SOLVER``. - See :doc:`Installation`. + See :doc:`installation`. - **“PuLP: cannot execute highs.”** You selected ``HiGHS_CMD`` but the ``highs`` executable isn’t on ``PATH``. Install it, or install the Python package ``highspy`` and use ``solver="highs"``. - Or force CBC: ``solver="cbc"``. See :doc:`Installation`. + Or force CBC: ``solver="cbc"``. See :doc:`installation`. - **Long runtimes.** Use time limits (``solver_options={"timeLimit": ...}``) or switch to a faster solver. @@ -287,5 +287,5 @@ Troubleshooting See Also -------- -- :doc:`Installation` — installing solvers and verifying detection. -- :doc:`Using-Custom-Solvers` — every way to select/configure a solver. +- :doc:`installation` — installing solvers and verifying detection. +- :ref:`using-custom-solvers` — every way to select/configure a solver. diff --git a/src/graphcalc/core/basics.py b/src/graphcalc/core/basics.py index 1489539..a56f6f4 100644 --- a/src/graphcalc/core/basics.py +++ b/src/graphcalc/core/basics.py @@ -1231,20 +1231,21 @@ def connected_and_cograph(G: GraphLike) -> bool: return connected(G) and cograph(G) def nontrivial(G: GraphLike) -> bool: - """ + r""" Determine whether a graph is nontrivial. - A graph is nontrivial if it has at least two vertices, i.e., order(G) ≥ 2. + A graph is **nontrivial** if it has at least two vertices, i.e., + :math:`|V(G)| \ge 2`. Parameters ---------- G : networkx.Graph or graphcalc.SimpleGraph - An undirected graph. + The input graph. Returns ------- bool - True if |V(G)| ≥ 2, False otherwise. + ``True`` if :math:`|V(G)| \ge 2`, and ``False`` otherwise. Examples -------- @@ -1257,7 +1258,6 @@ def nontrivial(G: GraphLike) -> bool: """ return order(G) >= 2 - def isolate_free(G: GraphLike) -> bool: """ Determine whether a graph is isolate-free (no degree-0 vertices). diff --git a/src/graphcalc/invariants/__init__.py b/src/graphcalc/invariants/__init__.py index 9eb0897..5ac8233 100644 --- a/src/graphcalc/invariants/__init__.py +++ b/src/graphcalc/invariants/__init__.py @@ -43,4 +43,8 @@ from graphcalc.invariants.core_invariants import * from graphcalc.invariants.critical_invariants import * from graphcalc.invariants.local_invariants import * +from graphcalc.invariants.transversal_invariants import * +from graphcalc.invariants.coloring_predicates import * +from graphcalc.invariants.cycle_invariants import * +from graphcalc.invariants.graph_indices import * diff --git a/src/graphcalc/invariants/classics.py b/src/graphcalc/invariants/classics.py index 79ed3e5..90df9ba 100644 --- a/src/graphcalc/invariants/classics.py +++ b/src/graphcalc/invariants/classics.py @@ -1080,57 +1080,73 @@ def backtrack(remaining, used_count): return best def arboricity(G: nx.Graph) -> int: - """ - Compute the (undirected) arboricity a(G) exactly. + r""" + Compute the (undirected) arboricity :math:`a(G)` exactly. Arboricity measures how many forests are needed to cover the edges of a graph. - Formally, a(G) is the minimum integer k such that E(G) can be partitioned into - k forests. + Formally, :math:`a(G)` is the minimum integer :math:`k` such that :math:`E(G)` can be + partitioned into :math:`k` forests. + + Nash–Williams / Tutte characterization + -------------------------------------- + A classical theorem gives the exact formula + + .. math:: + a(G) \;=\; \max_{H \subseteq G,\; |V(H)| \ge 2}\; + \left\lceil \frac{|E(H)|}{|V(H)| - 1} \right\rceil. - Nash–Williams / Tutte characterization (exact) - ---------------------------------------------- - A classic theorem gives an exact formula: + Equivalently, :math:`a(G)` is the smallest :math:`k` such that for every vertex subset + :math:`S \subseteq V(G)` with :math:`|S| \ge 2`, - a(G) = max_{H ⊆ G, |V(H)| >= 2} ceil( |E(H)| / (|V(H)| - 1) ). + .. math:: + |E(G[S])| \;\le\; k\,(|S| - 1). - Equivalently, a(G) is the smallest k such that for every vertex subset S with |S|>=2: - |E(G[S])| <= k (|S| - 1). + This function computes arboricity exactly by testing candidate values of :math:`k` via a + min-cut reduction that detects whether there exists a violating subset :math:`S` with - This function computes arboricity exactly by testing candidate k via a *min-cut* - reduction that decides whether there exists a violating subset S with: - |E(S)| > k(|S| - 1). + .. math:: + |E(G[S])| \;>\; k\,(|S| - 1). Min-cut oracle -------------- - To test a given k, we solve: + For a fixed :math:`k`, consider the objective - max_S ( |E(S)| - k|S| ) + .. math:: + \max_{S \subseteq V(G)} \bigl(|E(G[S])| - k|S|\bigr). - (since |E(S)| - k(|S|-1) = (|E(S)| - k|S|) + k, a violation exists iff - max_S (|E(S)| - k|S|) > -k ). + Since + :math:`|E(G[S])| - k(|S|-1) = (|E(G[S])| - k|S|) + k`, + a violation exists if and only if - This objective can be optimized exactly by an s-t min-cut construction: - - Create a node for each original vertex (V-nodes). - - Create a node for each original edge (E-nodes). - - Add arc: source -> E-node with capacity 1. - - Add arcs: E-node -> its two endpoints (V-nodes) with capacity INF. - - Add arc: V-node -> sink with capacity k. + .. math:: + \max_{S} \bigl(|E(G[S])| - k|S|\bigr) \;>\; -k. + + This maximum can be obtained via an :math:`s`–:math:`t` min-cut construction: - If we take an s-side subset S of vertex nodes, then the cut cost corresponds to: - cut = m - |E(S)| + k|S| - so minimizing cut is maximizing |E(S)| - k|S|. + - Create a node for each original vertex (V-nodes). + - Create a node for each original edge (E-nodes). + - Add an arc ``source -> E-node`` with capacity 1. + - Add arcs ``E-node ->`` its two endpoint V-nodes with capacity ``INF``. + - Add an arc ``V-node -> sink`` with capacity :math:`k`. + + If the :math:`s`-side contains a vertex subset :math:`S`, then the cut value is + + .. math:: + \text{cut} \;=\; m - |E(G[S])| + k|S|, + + so minimizing the cut is equivalent to maximizing :math:`|E(G[S])| - k|S|`. Parameters ---------- - G : nx.Graph - An undirected (simple) graph. Self-loops are ignored. Parallel edges in a MultiGraph - will increase |E(S)|; if you want multigraph arboricity, pass a simple projection or - be explicit about your convention. + G : networkx.Graph + An undirected graph. Self-loops are ignored. For a MultiGraph, parallel edges + increase :math:`|E(G[S])|`; if you want multigraph arboricity, be explicit about + the convention or pass a simple projection. Returns ------- int - The exact arboricity a(G). + The exact arboricity :math:`a(G)`. Examples -------- @@ -1140,8 +1156,7 @@ def arboricity(G: nx.Graph) -> int: 1 >>> gc.arboricity(nx.complete_graph(6)) 3 - >>> # K_{a,b} has arboricity ceil(ab/(a+b-1)) in many cases; this computes it exactly: - >>> gc.arboricity(nx.complete_bipartite_graph(3,4)) + >>> gc.arboricity(nx.complete_bipartite_graph(3, 4)) 2 """ if G.is_directed(): diff --git a/src/graphcalc/invariants/coloring_predicates.py b/src/graphcalc/invariants/coloring_predicates.py new file mode 100644 index 0000000..63a37e8 --- /dev/null +++ b/src/graphcalc/invariants/coloring_predicates.py @@ -0,0 +1,180 @@ +import networkx as nx +import graphcalc as gc + +__all__ = [ + "can_edge_color_with_k", + "is_class1", + "is_class2", +] + +def _is_simple_graph(G): + """True iff G is an undirected simple graph (no parallel edges; no direction).""" + return (not getattr(G, "is_directed", lambda: False)()) and (not getattr(G, "is_multigraph", lambda: False)()) + +def can_edge_color_with_k(G, k): + r""" + Decide whether :math:`G` admits a proper edge-coloring with *at most* ``k`` colors. + + A **proper edge-coloring** is a map :math:`c : E(G)\to\{0,1,\dots,k-1\}` such that + any two edges sharing an endpoint receive different colors. Equivalently, `c` is a + proper vertex-coloring of the line graph :math:`L(G)`. + + This routine uses simple backtracking and is intended only for small graphs. + + Parameters + ---------- + G : networkx.Graph + The input graph (intended for undirected simple graphs). + k : int + The number of available colors. + + Notes + ----- + - Necessary condition: ``k >= Δ(G)`` for any graph with at least one edge. This is + checked for early rejection. + - The implementation searches edges in a heuristic order (edges incident to + higher-degree vertices first). + - This is a decision procedure; it does not return a coloring. + + Conventions: + - If ``k <= 0``, returns ``False``. + - If ``|E(G)| = 0``, returns ``True`` (empty coloring). + + Complexity + ---------- + Exponential in :math:`|E(G)|` in the worst case. Intended only for small graphs. + + Returns + ------- + bool + ``True`` iff :math:`G` has a proper edge-coloring using at most ``k`` colors. + """ + if k <= 0: + return False + if G.number_of_edges() == 0: + return True + + # Quick necessary condition: k >= Δ(G) + if k < gc.max_degree(G): + return False + + deg = dict(G.degree()) + edges = list(G.edges()) + edges.sort( + key=lambda e: (deg[e[0]] + deg[e[1]], max(deg[e[0]], deg[e[1]])), + reverse=True + ) + + # Track which colors are used on edges incident to each vertex + used = {v: set() for v in G.nodes()} + + def backtrack(i): + if i == len(edges): + return True + + u, v = edges[i] + forbidden = used[u] | used[v] + + for c in range(k): + if c in forbidden: + continue + + used[u].add(c) + used[v].add(c) + + if backtrack(i + 1): + return True + + used[u].remove(c) + used[v].remove(c) + + return False + + return backtrack(0) + +def is_class1(G, max_edges=40): + r""" + Test whether a simple graph is **Class 1** (edge-chromatic number equals maximum degree). + + For a simple undirected graph :math:`G`, the **edge-chromatic number** + :math:`\chi'(G)` is the minimum number of colors in a proper edge-coloring. + The graph is: + + - **Class 1** if :math:`\chi'(G) = \Delta(G)`, + - **Class 2** if :math:`\chi'(G) = \Delta(G) + 1`. + + By Vizing's Theorem, every simple graph satisfies + :math:`\chi'(G) \in \{\Delta(G), \Delta(G)+1\}`. + + This function decides Class 1 by: + 1. Returning ``True`` for bipartite graphs (Kőnig's line coloring theorem), and + 2. Otherwise running an exact backtracking test for a :math:`\Delta(G)`-edge-coloring. + + Parameters + ---------- + G : networkx.Graph + The input graph. Must be an undirected simple graph (``nx.Graph``). + max_edges : int, default=40 + Safety cutoff to avoid exponential blowups in backtracking. + + Notes + ----- + - If ``|E(G)| = 0`` then :math:`\chi'(G)=\Delta(G)=0`, so :math:`G` is Class 1. + - Bipartite graphs satisfy :math:`\chi'(G)=\Delta(G)`. + + Returns + ------- + bool + ``True`` iff :math:`G` is Class 1, i.e., :math:`\chi'(G) = \Delta(G)`. + + Raises + ------ + ValueError + If ``G`` is not a simple undirected graph, or if ``|E(G)| > max_edges``. + """ + if not _is_simple_graph(G): + raise ValueError("Class 1/2 classification here is defined for undirected simple graphs (nx.Graph).") + + m = G.number_of_edges() + if m > max_edges: + raise ValueError(f"Edge-coloring backtracking capped at {max_edges} edges; got {m}.") + + if m == 0: + return True + + Δ = gc.max_degree(G) + if Δ == 0: + return True + + # Kőnig's line coloring theorem: bipartite graphs have χ'(G)=Δ(G) + if nx.is_bipartite(G): + return True + + return can_edge_color_with_k(G, Δ) + + +def is_class2(G, max_edges=40): + r""" + Test whether a simple graph is **Class 2** (edge-chromatic number equals :math:`\Delta(G)+1`). + + For simple graphs, Vizing's theorem implies: + :math:`G` is Class 2 if and only if :math:`G` is not Class 1. + + Parameters + ---------- + G : networkx.Graph + The input graph. Must be an undirected simple graph (``nx.Graph``). + max_edges : int, default=40 + Passed through to :func:`is_class1` for the backtracking cutoff. + + Returns + ------- + bool + ``True`` iff :math:`G` is Class 2, i.e., :math:`\chi'(G) = \Delta(G) + 1`. + + Raises + ------ + ValueError + If ``G`` is not a simple undirected graph, or if ``|E(G)| > max_edges``. + """ + return not is_class1(G, max_edges=max_edges) diff --git a/src/graphcalc/invariants/core_invariants.py b/src/graphcalc/invariants/core_invariants.py index 09fc81a..9f23aa5 100644 --- a/src/graphcalc/invariants/core_invariants.py +++ b/src/graphcalc/invariants/core_invariants.py @@ -56,8 +56,7 @@ def core_set_minimum(G, k_func, is_valid_set): Conventions: - If :math:`|V(G)|=0`, returns the empty set. - - If :math:`k(G)=0`, returns the empty set (since the empty set is a minimum solution, - and the intersection over the family of minimum solutions is empty). + - If :math:`k(G)=0`, returns the empty set (since the empty set is a minimum solution, and the intersection over the family of minimum solutions is empty). - If the running intersection becomes empty during enumeration, the function returns early. If no valid set of size :math:`k(G)` is found (which indicates an inconsistency between @@ -71,10 +70,8 @@ def core_set_minimum(G, k_func, is_valid_set): Notes ----- - - **Exact brute force.** This routine enumerates all :math:`k`-subsets of :math:`V(G)` and - tests validity. It is intended only for small graphs and/or small :math:`k`. - - Correctness depends on consistency: ``k_func`` must return the true minimum size for the - property tested by ``is_valid_set``. If they disagree, the output is not meaningful. + - **Exact brute force.** This routine enumerates all :math:`k`-subsets of :math:`V(G)` and tests validity. It is intended only for small graphs and/or small :math:`k`. + - Correctness depends on consistency: ``k_func`` must return the true minimum size for the property tested by ``is_valid_set``. If they disagree, the output is not meaningful. Complexity ---------- @@ -121,24 +118,21 @@ def core_number_minimum(G, k_func, is_valid_set): c_{\min}(G) \;=\; \bigl|\operatorname{Core}_{\min}(G)\bigr|, - where :math:`\operatorname{Core}_{\min}(G)` is returned by - :func:`core_set_minimum`. + where :math:`\operatorname{Core}_{\min}(G)` is returned by :func:`core_set_minimum`. Parameters ---------- G : networkx.Graph-like A finite graph. k_func : callable - A function ``k_func(G) -> int`` returning the optimal minimum size :math:`k(G)` for the - chosen property. + A function ``k_func(G) -> int`` returning the optimal minimum size :math:`k(G)` for the chosen property. is_valid_set : callable A predicate ``is_valid_set(G, S) -> bool`` deciding validity of a candidate vertex set. Returns ------- int - The size of the minimum core, i.e. the number of vertices that appear in **every** - minimum solution. Returns 0 for the empty graph, and also returns 0 when :math:`k(G)=0`. + The size of the minimum core, i.e. the number of vertices that appear in **every** minimum solution. Returns 0 for the empty graph, and also returns 0 when :math:`k(G)=0`. Notes ----- @@ -179,16 +173,12 @@ def core_set_maximum_fast(G, f_max): where :math:`G - v` denotes the induced subgraph obtained by deleting :math:`v`. Intuition: - - If deleting :math:`v` does **not** decrease the optimum value, then there exists an optimal - solution contained entirely in :math:`V(G)\setminus\{v\}`; hence :math:`v` is not forced. - - If deleting :math:`v` **does** decrease the optimum value, then no optimal solution can avoid - :math:`v`, so :math:`v` lies in the intersection of all optimal solutions. + - If deleting :math:`v` does **not** decrease the optimum value, then there exists an optimal solution contained entirely in :math:`V(G)\setminus\{v\}`; hence :math:`v` is not forced. + - If deleting :math:`v` **does** decrease the optimum value, then no optimal solution can avoid :math:`v`, so :math:`v` lies in the intersection of all optimal solutions. For example, this equivalence holds for: - - :math:`\alpha(G)` (maximum independent set size): if :math:`\alpha(G-v) = \alpha(G)`, then - there is a maximum independent set avoiding :math:`v`. - - :math:`\omega(G)` (maximum clique size): if :math:`\omega(G-v) = \omega(G)`, then there is a - maximum clique avoiding :math:`v`. + - :math:`\alpha(G)` (maximum independent set size): if :math:`\alpha(G-v) = \alpha(G)`, then there is a maximum independent set avoiding :math:`v`. + - :math:`\omega(G)` (maximum clique size): if :math:`\omega(G-v) = \omega(G)`, then there is a maximum clique avoiding :math:`v`. Parameters ---------- @@ -219,11 +209,8 @@ def core_set_maximum_fast(G, f_max): Notes ----- - - This method is exact **provided the stated equivalence holds** for your parameter. - It is not valid for arbitrary maximization invariants, especially those not realized by - vertex subsets in a straightforward induced-subgraph way. - - Compared to enumerating all maximum solutions, this is typically much faster: it performs - one deletion test per vertex. + - This method is exact **provided the stated equivalence holds** for your parameter. It is not valid for arbitrary maximization invariants, especially those not realized by vertex subsets in a straightforward induced-subgraph way. + - Compared to enumerating all maximum solutions, this is typically much faster: it performs one deletion test per vertex. Complexity ---------- @@ -513,27 +500,22 @@ def domination_core_set(G): Parameters ---------- G : networkx.Graph-like - A finite graph. This is intended primarily for **simple undirected** graphs under the standard - adjacency notion. + A finite graph. This is intended primarily for **simple undirected** graphs under the standard adjacency notion. Returns ------- set The domination core :math:`\operatorname{core}_\gamma(G)`. - Conventions: - - If :math:`|V(G)|=0`, returns the empty set. - - If :math:`\gamma(G)=0` (which occurs only for the empty graph under standard conventions), - returns the empty set. - - If the intersection of all minimum dominating sets is empty, returns the empty set. + Conventions: + - If :math:`|V(G)|=0`, returns the empty set. + - If :math:`\gamma(G)=0` (which occurs only for the empty graph under standard conventions), returns the empty set. + - If the intersection of all minimum dominating sets is empty, returns the empty set. Notes ----- - - This function delegates to :func:`core_set_minimum` using: - * ``k_func = gc.domination_number`` and - * ``is_valid_set = gc.is_dominating_set``. - - The implementation is **exact** but may be expensive: it enumerates all :math:`\gamma(G)`-subsets - of :math:`V(G)` and tests which ones are dominating. + - This function delegates to :func:`core_set_minimum` using: ``k_func = gc.domination_number`` and ``is_valid_set = gc.is_dominating_set``. + - The implementation is **exact** but may be expensive: it enumerates all :math:`\gamma(G)`-subsets of :math:`V(G)` and tests which ones are dominating. Complexity ---------- @@ -618,8 +600,7 @@ def zero_forcing_core_set(G): Parameters ---------- G : networkx.Graph-like - A finite graph. This is intended primarily for **simple undirected** graphs under the standard - adjacency notion. + A finite graph. This is intended primarily for **simple undirected** graphs under the standard adjacency notion. Returns ------- @@ -628,8 +609,7 @@ def zero_forcing_core_set(G): Conventions: - If :math:`|V(G)|=0`, returns the empty set. - - If :math:`Z(G)=0` (which occurs only for the empty graph under standard conventions), - returns the empty set. + - If :math:`Z(G)=0` (which occurs only for the empty graph under standard conventions), returns the empty set. - If the intersection of all minimum zero forcing sets is empty, returns the empty set. Notes @@ -637,8 +617,7 @@ def zero_forcing_core_set(G): - This function delegates to :func:`core_set_minimum` using: * ``k_func = gc.zero_forcing_number`` and * ``is_valid_set = gc.is_zero_forcing_set``. - - The implementation is **exact** but may be expensive: it enumerates all :math:`Z(G)`-subsets of - :math:`V(G)` and tests which ones are zero forcing. + - The implementation is **exact** but may be expensive: it enumerates all :math:`Z(G)`-subsets of :math:`V(G)` and tests which ones are zero forcing. Complexity ---------- diff --git a/src/graphcalc/invariants/critical_invariants.py b/src/graphcalc/invariants/critical_invariants.py index 0ccca67..5ab02e2 100644 --- a/src/graphcalc/invariants/critical_invariants.py +++ b/src/graphcalc/invariants/critical_invariants.py @@ -4,6 +4,25 @@ import graphcalc as gc +__all__ = [ + "vertex_deletion_deltas", + "vertex_critical_set", + "vertex_critical_number", + "vertex_deletion_max_jump", + "edge_deletion_deltas", + "edge_critical_number", + "domination_vertex_increase_number", + "domination_vertex_decrease_number", + "domination_vertex_change_number", + "domination_vertex_same_number", + "domination_vertex_max_jump", + "domination_edge_increase_number", + "domination_edge_decrease_number", + "domination_edge_change_number", + "domination_edge_same_number", + "domination_edge_max_jump", +] + def vertex_deletion_deltas(G, f): r""" Compute **single-vertex deletion deltas** for a graph parameter :math:`f`. @@ -11,13 +30,11 @@ def vertex_deletion_deltas(G, f): For each vertex :math:`v \in V(G)`, form the induced vertex-deleted subgraph .. math:: - G - v \;:=\; G[V(G)\setminus\{v\}], and compute the deletion delta .. math:: - \Delta_v f(G) \;:=\; f(G - v) - f(G). The function returns the mapping :math:`v \mapsto \Delta_v f(G)` for all vertices of :math:`G`. @@ -27,23 +44,27 @@ def vertex_deletion_deltas(G, f): G : networkx.Graph-like A finite graph. f : callable - A function ``f(H) -> number`` defined on graphs ``H``. Examples include invariants/parameters - such as order, size, independence number, clique number, domination number, chromatic number, - etc. The function is evaluated first on ``G`` and then on each induced subgraph ``G-v``. + A function ``f(H) -> number`` defined on graphs ``H``. + + Examples include invariants/parameters such as ``order``, ``size``, + ``independence_number``, ``clique_number``, ``domination_number``, + and ``chromatic_number``. + + The function is evaluated first on ``G`` and then on each induced + subgraph ``G - v``. Returns ------- dict - A dictionary mapping each vertex ``v`` of ``G`` to the numeric value ``f(G - v) - f(G)``. - If :math:`|V(G)| = 0`, returns an empty dictionary ``{}``. + A dictionary mapping each vertex ``v`` of ``G`` to the numeric value + ``f(G - v) - f(G)``. + + If :math:`|V(G)|=0`, returns the empty dictionary ``{}``. Notes ----- - - These deltas are useful for **sensitivity** and **criticality** analyses, e.g. identifying - vertices whose deletion changes the parameter, and by how much. - - The induced subgraph ``G-v`` is passed to ``f`` as a ``.copy()`` to protect against accidental - mutation inside ``f``. If ``f`` is guaranteed to be pure/read-only, you may omit ``.copy()`` - for speed. + - These deltas are useful for **sensitivity** and **criticality** analyses. + - The induced subgraph ``G - v`` is passed to ``f`` as a ``.copy()`` to protect against accidental mutation inside ``f``. If ``f`` is guaranteed to be read-only, you may omit ``.copy()`` for speed. Raises ------ @@ -51,28 +72,15 @@ def vertex_deletion_deltas(G, f): Propagates any exception raised by ``f`` when applied to ``G`` or to any vertex-deleted induced subgraph. - Complexity - ---------- - Let :math:`n = |V(G)|`. This routine performs :math:`n+1` evaluations of ``f`` (once on ``G`` and - once on each of the :math:`n` induced subgraphs with one vertex removed). Thus the overall cost is - dominated by: - - .. math:: - - O\!\left(n \cdot T_f(n-1)\right), - - where :math:`T_f(\cdot)` denotes the time to evaluate ``f`` on a graph of the indicated size - (plus the overhead of building/copying induced subgraphs). - Examples -------- >>> import networkx as nx >>> import graphcalc as gc + >>> from graphcalc.invariants.critical_invariants import vertex_deletion_deltas >>> G = nx.path_graph(4) - >>> # Deltas for the independence number under single-vertex deletions: - >>> d = vertex_deletion_deltas(G, gc.independence_number) - >>> sorted(d.items()) # doctest: +ELLIPSIS - [...] + >>> d = vertex_deletion_deltas(G, gc.order) + >>> d[0], d[1], d[2], d[3] + (-1, -1, -1, -1) """ n = gc.order(G) if n == 0: @@ -136,12 +144,9 @@ def vertex_critical_set(G, f, *, kind="change"): Notes ----- - This is a generic vertex-sensitivity / “criticality” selector. Terminology varies by context: - * For **maximization** parameters (e.g. :math:`\alpha`, :math:`\omega`), authors sometimes call - vertices with ``kind='decrease'`` *critical*, since deleting them reduces the optimum. - * For **minimization** parameters (e.g. :math:`\chi`, :math:`\gamma`), authors sometimes call - vertices with ``kind='increase'`` *critical*, since deleting them increases the minimum. - This function does not assume whether :math:`f` is a max or min parameter; it simply filters by - the sign of :math:`\Delta_v f(G)`. + * For **maximization** parameters (e.g. :math:`\alpha`, :math:`\omega`), authors sometimes call vertices with ``kind='decrease'`` *critical*, since deleting them reduces the optimum. + * For **minimization** parameters (e.g. :math:`\chi`, :math:`\gamma`), authors sometimes call vertices with ``kind='increase'`` *critical*, since deleting them increases the minimum. + - This function does not assume whether :math:`f` is a max or min parameter; it simply filters by the sign of :math:`\Delta_v f(G)`. - The deltas are computed by :func:`vertex_deletion_deltas`, which forms induced subgraphs and evaluates ``f`` on them. @@ -431,19 +436,15 @@ def edge_critical_number(G, f, *, kind="change"): ValueError If ``kind`` is not one of ``{'change','increase','decrease','same'}``. Exception - Propagates any exception raised by ``f`` when evaluated on ``G`` or on any edge-deleted graph - (via :func:`edge_deletion_deltas`). + Propagates any exception raised by ``f`` when evaluated on ``G`` or on any edge-deleted graph (via :func:`edge_deletion_deltas`). Notes ----- - - Terminology varies: some authors reserve “edge-critical” for a specific direction of change, - e.g.: - * for **maximization** parameters (such as :math:`\alpha` or :math:`\omega`), edges with - ``kind='decrease'``; + - Terminology varies: some authors reserve “edge-critical” for a specific direction of change, e.g.: + * for **maximization** parameters (such as :math:`\alpha` or :math:`\omega`), edges with ``kind='decrease'``; * for **minimization** parameters (such as :math:`\chi`), edges with ``kind='increase'``. - This function is agnostic and provides all four sign-based categories via ``kind``. - - This is a counting wrapper; if you want the edges themselves, define an analogous - ``edge_critical_set`` using the same delta predicate. + - This function is agnostic and provides all four sign-based categories via ``kind``. + - This is a counting wrapper; if you want the edges themselves, define an analogous ``edge_critical_set`` using the same delta predicate. Complexity ---------- diff --git a/src/graphcalc/invariants/cycle_invariants.py b/src/graphcalc/invariants/cycle_invariants.py new file mode 100644 index 0000000..1369ba9 --- /dev/null +++ b/src/graphcalc/invariants/cycle_invariants.py @@ -0,0 +1,1251 @@ +from __future__ import annotations + +from typing import Any, Hashable, Iterable, Optional, Set, Tuple + + +import math +import itertools +import networkx as nx +import graphcalc as gc + +__all__ = [ + "triangle_count", + "cycle_rank", + "girth", + "circumference", + "odd_girth", + "even_girth", + "feedback_vertex_set", + "feedback_vertex_number", + "maximum_number_of_vertex_disjoint_cycles", + "decycling_number", + "maximum_induced_forest_number", +] + +def triangle_count(G): + r""" + Count (unordered) triangles in an undirected simple graph :math:`G`. + + A **triangle** is a 3-cycle (an induced copy of :math:`C_3`, equivalently a copy of + :math:`K_3`). This function returns the number of distinct unordered triples + :math:`\{u,v,w\}` that form a triangle in :math:`G`. + + Parameters + ---------- + G : networkx.Graph + The input graph (intended for finite simple undirected graphs). + + Notes + ----- + :func:`networkx.triangles` returns a dictionary mapping each vertex :math:`v` to the + number of triangles incident to :math:`v`. Summing these values counts each triangle + exactly three times (once at each of its vertices), so we divide by 3. + + This interpretation is for undirected simple graphs. For directed graphs or + multigraphs, the notion of a “triangle” and the behavior of + :func:`networkx.triangles` may differ. + + Returns + ------- + int + The number of (unordered) triangles in :math:`G`. + + Examples + -------- + A complete graph :math:`K_3` has exactly one triangle: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.complete_graph(3) + >>> gc.triangle_count(G) + 1 + + A complete graph :math:`K_4` has :math:`\\binom{4}{3} = 4` triangles: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.complete_graph(4) + >>> gc.triangle_count(G) + 4 + + A 4-cycle has no triangles: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.cycle_graph(4) + >>> gc.triangle_count(G) + 0 + + Disjoint union: counts triangles across all components: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.disjoint_union(nx.complete_graph(3), nx.path_graph(4)) + >>> gc.triangle_count(G) + 1 + """ + return sum(nx.triangles(G).values()) // 3 + + + +def cycle_rank(G): + r""" + Compute the cyclomatic number (cycle rank) of an undirected graph :math:`G`. + + The **cyclomatic number** is + :math:`r(G) = |E(G)| - |V(G)| + c(G)`, + where :math:`c(G)` is the number of connected components of :math:`G`. + + For undirected graphs, :math:`r(G)` equals the dimension of the cycle space over + :math:`\mathrm{GF}(2)` (the number of independent cycles). In particular, a forest + has cycle rank 0. + + Parameters + ---------- + G : networkx.Graph + The input graph (intended for finite undirected graphs). + + Notes + ----- + - If :math:`|V(G)| = 0`, we take :math:`c(G)=0`, so :math:`r(G)=0`. + - This formula is for undirected graphs. Directed graphs use different notions of + cycle rank / cycle space. + + Returns + ------- + int + The cyclomatic number :math:`r(G)`. + + Examples + -------- + Trees and forests have cycle rank 0: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> T = nx.path_graph(5) + >>> gc.cycle_rank(T) + 0 + + A single cycle :math:`C_n` has cycle rank 1: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.cycle_graph(6) + >>> gc.cycle_rank(G) + 1 + + A connected graph with two independent cycles has cycle rank 2: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.cycle_graph(4) + >>> G.add_edge(0, 2) # adds a chord, creating a second independent cycle + >>> gc.cycle_rank(G) + 2 + + Disconnected graphs add ranks across components: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.disjoint_union(nx.cycle_graph(3), nx.cycle_graph(4)) + >>> gc.cycle_rank(G) + 2 + """ + n = G.number_of_nodes() + m = G.number_of_edges() + c = nx.number_connected_components(G) if n > 0 else 0 + return m - n + c + + +def girth(G): + r""" + Compute the girth of an undirected graph :math:`G` (length of a shortest cycle). + + The **girth** :math:`g(G)` is the minimum length among all cycles in :math:`G`. + + Conventions + ----------- + - If :math:`G` is acyclic (a forest), return ``math.inf``. + - If :math:`|V(G)| < 3`, return ``math.inf``. + + Parameters + ---------- + G : networkx.Graph + The input graph (intended for finite undirected graphs). + + Notes + ----- + Exact algorithm: run a BFS from each source vertex :math:`s`. Whenever an edge + :math:`(u,v)` is encountered with :math:`v` already discovered and + :math:`v \neq \mathrm{parent}[u]`, a cycle is detected with length + + .. math:: + \mathrm{dist}[u] + \mathrm{dist}[v] + 1. + + Taking the minimum over all sources yields the girth. + + This is correct for undirected **simple** graphs. For multigraphs, parallel edges + create 2-cycles and self-loops create 1-cycles, which this routine does not + explicitly handle. + + Complexity + ---------- + :math:`O(n(n+m))` time where :math:`n=|V(G)|` and :math:`m=|E(G)|`. + + Returns + ------- + int | float + The length of a shortest cycle, or ``math.inf`` if :math:`G` has no cycles. + + Examples + -------- + A tree has no cycles: + + >>> import math + >>> import networkx as nx + >>> import graphcalc as gc + >>> T = nx.path_graph(6) + >>> gc.girth(T) == math.inf + True + + A triangle has girth 3: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.cycle_graph(3) + >>> gc.girth(G) + 3 + + A 5-cycle has girth 5: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.cycle_graph(5) + >>> gc.girth(G) + 5 + + Adding a chord creates a triangle, reducing the girth: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.cycle_graph(5) + >>> G.add_edge(0, 2) + >>> gc.girth(G) + 3 + """ + n = G.number_of_nodes() + if n < 3: + return math.inf + + best = math.inf + for s in G.nodes(): + dist = {s: 0} + parent = {s: None} + q = [s] + for u in q: + for v in G.neighbors(u): + if v not in dist: + dist[v] = dist[u] + 1 + parent[v] = u + q.append(v) + elif parent[u] != v: + best = min(best, dist[u] + dist[v] + 1) + if best == 3: + return 3 + return best + + +def odd_girth(G): + r""" + Compute the odd girth of an undirected graph :math:`G` (length of a shortest odd cycle). + + The **odd girth** is the minimum length among all **odd** cycles in :math:`G`. + + Conventions + ----------- + - If :math:`G` has no odd cycle (equivalently, :math:`G` is bipartite), return + ``math.inf``. + - If :math:`|V(G)| < 3`, return ``math.inf``. + + Parameters + ---------- + G : networkx.Graph + The input graph (intended for finite undirected graphs). + + Notes + ----- + This uses the same BFS-based cycle detection as :func:`girth`. For each source + vertex :math:`s`, run BFS and whenever an edge :math:`(u,v)` is encountered with + :math:`v` already discovered and :math:`v \neq \mathrm{parent}[u]`, a cycle is + detected with length + + .. math:: + L = \mathrm{dist}[u] + \mathrm{dist}[v] + 1. + + We take the minimum such :math:`L` over all sources subject to :math:`L` being odd. + + As with :func:`girth`, this is intended for undirected **simple** graphs. Self-loops + (odd cycle of length 1) and parallel edges (even cycle of length 2) in multigraphs + are not handled explicitly. + + Complexity + ---------- + :math:`O(n(n+m))` time where :math:`n=|V(G)|` and :math:`m=|E(G)|`. + + Returns + ------- + int | float + The length of a shortest odd cycle in :math:`G`, or ``math.inf`` if none exists. + + Examples + -------- + Bipartite graphs have no odd cycles: + + >>> import math + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.cycle_graph(4) + >>> gc.odd_girth(G) == math.inf + True + + A triangle has odd girth 3: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.cycle_graph(3) + >>> gc.odd_girth(G) + 3 + + An odd cycle :math:`C_5` has odd girth 5: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.cycle_graph(5) + >>> gc.odd_girth(G) + 5 + + If a graph has both even and odd cycles, only odd ones matter: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.cycle_graph(6) + >>> G.add_edge(0, 2) # creates a triangle + >>> gc.odd_girth(G) + 3 + """ + n = G.number_of_nodes() + if n < 3: + return math.inf + + best = math.inf + for s in G.nodes(): + dist = {s: 0} + parent = {s: None} + q = [s] + for u in q: + for v in G.neighbors(u): + if v not in dist: + dist[v] = dist[u] + 1 + parent[v] = u + q.append(v) + elif parent[u] != v: + L = dist[u] + dist[v] + 1 + if L % 2 == 1: + best = min(best, L) + if best == 3: + return 3 + return best + +def even_girth(G): + r""" + Compute the even girth of an undirected graph :math:`G` (length of a shortest even cycle). + + The **even girth** is the minimum length among all **even** cycles in :math:`G`. + + Conventions + ----------- + - If :math:`G` has no even cycle, return ``math.inf``. + - If :math:`|V(G)| < 4`, return ``math.inf`` (in a simple graph the shortest even + cycle has length 4). + + Parameters + ---------- + G : networkx.Graph + The input graph (intended for finite undirected graphs). + + Notes + ----- + This uses the same BFS-based cycle detection as :func:`girth`. For each source + vertex :math:`s`, run BFS and whenever an edge :math:`(u,v)` is encountered with + :math:`v` already discovered and :math:`v \neq \mathrm{parent}[u]`, a cycle is + detected with length + + .. math:: + L = \mathrm{dist}[u] + \mathrm{dist}[v] + 1. + + We take the minimum such :math:`L` over all sources subject to :math:`L` being even. + + Intended for undirected **simple** graphs. In multigraphs, parallel edges create an + even 2-cycle, so the even girth could be 2; this routine does not handle that case + explicitly. + + Complexity + ---------- + :math:`O(n(n+m))` time where :math:`n=|V(G)|` and :math:`m=|E(G)|`. + + Returns + ------- + int | float + The length of a shortest even cycle in :math:`G`, or ``math.inf`` if none exists. + + Examples + -------- + A triangle has no even cycle: + + >>> import math + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.cycle_graph(3) + >>> gc.even_girth(G) == math.inf + True + + A 4-cycle has even girth 4: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.cycle_graph(4) + >>> gc.even_girth(G) + 4 + + An even cycle :math:`C_6` has even girth 6: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.cycle_graph(6) + >>> gc.even_girth(G) + 6 + + If a graph has a 4-cycle anywhere, the even girth is 4: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.grid_2d_graph(2, 3) # contains a 4-cycle + >>> gc.even_girth(G) + 4 + """ + n = G.number_of_nodes() + if n < 4: + return math.inf + + best = math.inf + for s in G.nodes(): + dist = {s: 0} + parent = {s: None} + q = [s] + for u in q: + for v in G.neighbors(u): + if v not in dist: + dist[v] = dist[u] + 1 + parent[v] = u + q.append(v) + elif parent[u] != v: + L = dist[u] + dist[v] + 1 + if L % 2 == 0: + best = min(best, L) + if best == 4: + return 4 + return best + +def circumference(G, max_n=16): + r""" + Compute the circumference of an undirected graph :math:`G` (length of a longest simple cycle). + + The **circumference** :math:`c(G)` is the maximum length of a simple cycle in :math:`G`. + + Conventions + ----------- + - If :math:`G` has no cycles, return ``0``. + - If :math:`|V(G)| < 3`, return ``0``. + + Parameters + ---------- + G : networkx.Graph + The input graph (finite, undirected). + max_n : int, default=16 + Safety cutoff on :math:`|V(G)|`. This routine is exponential-time. + + Returns + ------- + int + The circumference :math:`c(G)`, i.e., the length of a longest simple cycle, or ``0`` if + :math:`G` is acyclic. + + Raises + ------ + ValueError + If :math:`|V(G)| >` ``max_n``. + + Notes + ----- + This is an exact brute-force method intended only for very small graphs. + + The algorithm searches vertex subsets from large to small. For each :math:`k` and each + subset :math:`S \subseteq V(G)` with :math:`|S|=k`, it considers the induced subgraph + :math:`H = G[S]` and checks whether :math:`H` contains a simple cycle of length :math:`k` + (i.e., a Hamiltonian cycle in :math:`H`). + + A quick certificate catches the case :math:`H \cong C_k`: if :math:`H` is connected, has + exactly :math:`k` edges, and every vertex in :math:`H` has degree 2, then :math:`H` is a + cycle and we immediately return :math:`k`. + + Otherwise, the implementation performs an exhaustive Hamiltonian-cycle check on :math:`H`. + This can still be expensive on dense graphs, but is controlled by ``max_n``. + + Complexity + ---------- + Exponential in :math:`n=|V(G)|` in the worst case (subset enumeration plus Hamiltonian checking). + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> T = nx.path_graph(6) + >>> gc.circumference(T) + 0 + + >>> G = nx.cycle_graph(7) + >>> gc.circumference(G) + 7 + + >>> G = nx.complete_graph(5) + >>> gc.circumference(G) + 5 + + >>> G = nx.disjoint_union(nx.cycle_graph(4), nx.cycle_graph(6)) + >>> gc.circumference(G) + 6 + """ + + n = G.number_of_nodes() + if n < 3: + return 0 + if n > max_n: + raise ValueError(f"circumference brute force intended for n <= {max_n}, got n={n}") + + if cycle_rank(G) == 0: + return 0 + + nodes = list(G.nodes()) + + for k in range(n, 2, -1): + for S in itertools.combinations(nodes, k): + H = G.subgraph(S) + + # If H is exactly C_k, we can certify immediately. + if nx.is_connected(H) and H.number_of_edges() == k and all(d == 2 for _, d in H.degree()): + return k + + # Otherwise, brute-force search for a simple cycle of length k. + # Warning: can explode on dense graphs. + D = H.to_directed() + for cyc in nx.simple_cycles(D): + if len(cyc) == k: + return k + + return 0 + +def feedback_vertex_set( + G: nx.Graph, + *, + exact: bool = True, + time_limit_nodes: int = 60, + return_size_only: bool = False, +) -> Any: + r""" + Compute a feedback vertex set (FVS) of an undirected graph :math:`G`. + + A **feedback vertex set** is a subset :math:`S \subseteq V(G)` that intersects every + cycle of :math:`G`. Equivalently, removing :math:`S` makes the graph acyclic: + + .. math:: + S \text{ is an FVS} \iff G - S \text{ is a forest.} + + The **feedback vertex number** is the minimum size of such a set: + + .. math:: + \tau_V(G) = \min\{ |S| : G - S \text{ is acyclic} \}. + + This function can either: + - compute a *minimum* FVS (exact mode) using branch-and-bound, or + - compute a (usually small) FVS using a greedy heuristic. + + Parameters + ---------- + G : networkx.Graph + The input graph. Intended for undirected simple graphs. (For MultiGraphs, self-loops + and parallel edges create 1- and 2-cycles under multigraph conventions, and + :func:`networkx.cycle_basis` is defined for simple undirected graphs.) + exact : bool, default=True + If ``True``, attempt to return a *minimum* feedback vertex set (exact :math:`\tau_V(G)`) + via branch-and-bound. If ``False``, return a feedback vertex set found by a greedy + heuristic (not guaranteed optimal). + time_limit_nodes : int, default=60 + A size-based advisory threshold for exact search. (Note: in the current implementation, + this value is not enforced when ``exact=True``; it is primarily a guardrail parameter + for callers / future “auto” behavior.) + return_size_only : bool, default=False + If ``True``, return only the size (an ``int``). Otherwise return a vertex set. + + Returns + ------- + set | int + If ``return_size_only=False``, returns a set ``S`` that is a feedback vertex set. + If ``exact=True``, ``S`` is minimum. + If ``return_size_only=True``, returns ``|S|`` (which equals :math:`\tau_V(G)` in exact mode). + + Notes + ----- + Exact mode (branch-and-bound) + ----------------------------- + The exact solver repeatedly finds a cycle and branches on which vertex of that cycle to delete: + + 1. If the graph is acyclic, return the empty set. + 2. Find any cycle :math:`C` (via :func:`networkx.cycle_basis` on a component). + 3. For each :math:`v \in C`, recursively solve on :math:`G - v` and take the best solution. + + Pruning uses: + - an initial upper bound from the greedy heuristic, and + - a lower bound from greedily packing vertex-disjoint cycles (each such cycle forces at least one vertex into any FVS). + + Heuristic mode + -------------- + The greedy heuristic repeatedly computes a cycle basis in a cyclic component, removes the vertex + that appears in the most basis cycles, and repeats until the graph becomes a forest. + + Caveats + ------- + - This targets **undirected** graphs only. + - Exact mode is exponential in the worst case (FVS is NP-hard) and can blow up on large/dense graphs. Use ``exact=False`` if you need a fast answer. + + Examples + -------- + A cycle :math:`C_n` has :math:`\tau_V(C_n)=1`: + + >>> import networkx as nx + >>> G = nx.cycle_graph(8) + >>> S = feedback_vertex_set(G, exact=True) + >>> len(S) + 1 + >>> nx.is_forest(G.subgraph(set(G) - S)) + True + + A complete graph :math:`K_n` has :math:`\tau_V(K_n)=n-2`: + + >>> G = nx.complete_graph(6) + >>> len(feedback_vertex_set(G, exact=True)) + 4 + + Heuristic mode returns a valid (not necessarily minimum) FVS: + + >>> G = nx.complete_graph(7) + >>> S = feedback_vertex_set(G, exact=False) + >>> nx.is_forest(G.subgraph(set(G) - S)) + True + + See Also + -------- + - Feedback edge set: for undirected graphs, the minimum feedback edge set size equals + :math:`|E|-|V|+c` (cycle rank), whereas the feedback vertex set problem is different and NP-hard. + """ + + if G.is_directed(): + raise nx.NetworkXError("feedback_vertex_set currently supports undirected graphs only.") + + # Trivial cases + if G.number_of_edges() == 0: + return 0 if return_size_only else set() + + # If graph is already a forest, τ_V = 0 + if nx.is_forest(G): + return 0 if return_size_only else set() + + # Guardrail: switch to heuristic unless user insists + if (not exact) or (G.number_of_nodes() > time_limit_nodes and exact is False): + S = _fvs_greedy(G) + return len(S) if return_size_only else S + + # If the graph is big and exact=True, we still try; caller is knowingly asking for it. + best_set = _fvs_branch_and_bound(G) + return len(best_set) if return_size_only else best_set + + +def _any_cycle_nodes(H: nx.Graph) -> Optional[Tuple[Hashable, ...]]: + """Return nodes of one simple cycle in H, or None if H is acyclic.""" + # nx.cycle_basis works per connected component; get any component with a cycle. + for comp in nx.connected_components(H): + sub = H.subgraph(comp) + basis = nx.cycle_basis(sub) + if basis: + return tuple(basis[0]) + return None + + +def _lower_bound_disjoint_cycles(H: nx.Graph) -> int: + """ + Greedy lower bound: pack vertex-disjoint cycles. + Each packed cycle forces at least 1 vertex in any feedback vertex set. + """ + H2 = H.copy() + count = 0 + while True: + cyc = _any_cycle_nodes(H2) + if cyc is None: + break + count += 1 + H2.remove_nodes_from(cyc) # enforce vertex-disjointness + return count + + +def _fvs_branch_and_bound(G: nx.Graph) -> Set[Hashable]: + """Exact minimum FVS via branch-and-bound on cycles.""" + # Upper bound from greedy (good initial pruning) + best = _fvs_greedy(G) + + def rec(H: nx.Graph, chosen: Set[Hashable]) -> None: + nonlocal best + + # Prune by current best + if len(chosen) >= len(best): + return + + # If acyclic, update best + if nx.is_forest(H): + best = set(chosen) + return + + # Lower bound prune + lb = _lower_bound_disjoint_cycles(H) + if len(chosen) + lb >= len(best): + return + + # Branch on a cycle + cycle = _any_cycle_nodes(H) + if cycle is None: + best = set(chosen) + return + + # A small heuristic ordering: branch on higher "cycle participation" first + # to find good solutions earlier (tighter upper bounds). + # Count occurrences in a cycle basis of that component. + comp = next(c for c in nx.connected_components(H) if set(cycle).issubset(c)) + basis = nx.cycle_basis(H.subgraph(comp)) + score = {v: 0 for v in cycle} + for C in basis: + for v in C: + if v in score: + score[v] += 1 + branch_vertices = sorted(cycle, key=lambda v: score.get(v, 0), reverse=True) + + for v in branch_vertices: + H_next = H.copy() + H_next.remove_node(v) + chosen.add(v) + rec(H_next, chosen) + chosen.remove(v) + + rec(G.copy(), set()) + return best + + +def _fvs_greedy(G: nx.Graph) -> Set[Hashable]: + """ + Greedy FVS: + repeatedly remove the vertex that appears in the most cycles of a cycle basis. + """ + H = G.copy() + S: Set[Hashable] = set() + + while not nx.is_forest(H): + cyc = _any_cycle_nodes(H) + if cyc is None: + break + + # Build a cycle basis in that component and count participation + comp = next(c for c in nx.connected_components(H) if set(cyc).issubset(c)) + basis = nx.cycle_basis(H.subgraph(comp)) + counts = {} + for C in basis: + for v in C: + counts[v] = counts.get(v, 0) + 1 + + # If basis is empty for some reason, fall back to removing a node from cyc + if not counts: + v = cyc[0] + else: + v = max(counts, key=counts.get) + + S.add(v) + H.remove_node(v) + + return S + +def feedback_vertex_number( + G: nx.Graph, + *, + exact: bool = True, + time_limit_nodes: int = 60, +) -> int: + r""" + Compute the feedback vertex number :math:`\tau_V(G)` of an undirected graph :math:`G`. + + A **feedback vertex set (FVS)** is a subset :math:`S \subseteq V(G)` that intersects + every cycle of :math:`G`. Equivalently, removing :math:`S` makes the graph acyclic: + + .. math:: + S \text{ is an FVS} \iff G - S \text{ is a forest.} + + The **feedback vertex number** is the minimum possible size of a feedback vertex set: + + .. math:: + \tau_V(G) = \min\{ |S| : G - S \text{ is acyclic} \}. + + This function returns :math:`\tau_V(G)` in exact mode (branch-and-bound) and a + heuristic upper bound in heuristic mode. + + Parameters + ---------- + G : networkx.Graph + The input graph. Intended for undirected simple graphs. (For MultiGraphs, self-loops + and parallel edges can create 1- and 2-cycles under multigraph conventions; this + routine is designed for simple graphs.) + exact : bool, default=True + If ``True``, compute :math:`\tau_V(G)` exactly via branch-and-bound. If ``False``, + return the size of a feedback vertex set found by a greedy heuristic (not guaranteed + optimal). + time_limit_nodes : int, default=60 + A size-based advisory threshold. If ``exact=False`` and ``|V(G)|`` is large, this + parameter is a reminder to prefer the heuristic. (Note: in the current implementation, + exact mode is not automatically disabled based on this threshold.) + + Returns + ------- + int + The feedback vertex number :math:`\tau_V(G)` (exact mode) or the size of a valid + feedback vertex set (heuristic mode). + + Notes + ----- + In exact mode, the solver repeatedly finds a cycle (via :func:`networkx.cycle_basis`) + and branches on which vertex of that cycle to delete, using: + + - an initial upper bound from the greedy heuristic, and + - a lower bound from greedily packing vertex-disjoint cycles for pruning. + + The worst-case running time is exponential (the problem is NP-hard). + + Examples + -------- + A cycle :math:`C_n` has :math:`\tau_V(C_n)=1`: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.cycle_graph(8) + >>> gc.feedback_vertex_number(G) + 1 + + A tree has no cycles: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> T = nx.path_graph(10) + >>> gc.feedback_vertex_number(T) + 0 + + A complete graph :math:`K_n` has :math:`\tau_V(K_n)=n-2`: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.complete_graph(6) + >>> gc.feedback_vertex_number(G) + 4 + + Heuristic mode returns an upper bound (valid but not necessarily minimum): + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.complete_graph(9) + >>> gc.feedback_vertex_number(G, exact=False) >= 7 + True + """ + # Fast exits + if G.is_directed(): + raise nx.NetworkXError("feedback_vertex_number supports undirected graphs only.") + + if G.number_of_edges() == 0 or nx.is_forest(G): + return 0 + + if exact: + return len(_fvs_branch_and_bound(G)) + else: + return len(_fvs_greedy(G)) + +def maximum_number_of_vertex_disjoint_cycles(G: nx.Graph) -> int: + r""" + Compute the maximum number of pairwise vertex-disjoint (simple) cycles in an undirected graph. + + A **cycle packing** is a collection of simple cycles :math:`C_1,\dots,C_t` such that no + vertex appears in more than one cycle. This function returns + + .. math:: + \nu(G) = \max\{t : G \text{ contains } t \text{ vertex-disjoint cycles}\}. + + Relationship to feedback vertex sets + ------------------------------------ + If :math:`\tau_V(G)` is the feedback vertex number (minimum size of a feedback vertex set), + then + + .. math:: + \nu(G) \le \tau_V(G), + + because each vertex-disjoint cycle must contribute at least one distinct vertex to any + feedback vertex set. + + Approach + -------- + The algorithm is exact but exponential-time. It uses recursion with memoization over + induced subgraphs represented by a bitmask of remaining vertices. + + In each recursive call, it picks a vertex :math:`v` that lies on some cycle and branches: + + - **Exclude** :math:`v` from the packing: delete :math:`v` and recurse. + - **Include** :math:`v` in the packing: the packing must contain exactly one cycle through + :math:`v`. Enumerate all simple cycles through :math:`v`, choose one, delete all vertices + of that cycle, and recurse. Take the best result. + + This branching is exact because in any optimal packing, either :math:`v` is unused, or it + lies on exactly one chosen cycle. + + Graph conventions + ----------------- + This routine is intended for undirected **simple** graphs. If ``G`` is a multigraph, the + implementation first converts it to a simple graph via ``nx.Graph(G)`` (collapsing parallel + edges) and removes self-loops. The returned value therefore corresponds to the underlying + simple graph. + + Parameters + ---------- + G : networkx.Graph + The input graph. Must be undirected. + + Returns + ------- + int + The maximum number of vertex-disjoint simple cycles in ``G``. + + Raises + ------ + networkx.NetworkXError + If ``G`` is directed. + + Examples + -------- + A single cycle has packing number 1: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.maximum_number_of_vertex_disjoint_cycles(nx.cycle_graph(8)) + 1 + + Two disjoint triangles yield a packing of size 2: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.disjoint_union(nx.complete_graph(3), nx.complete_graph(3)) + >>> gc.maximum_number_of_vertex_disjoint_cycles(G) + 2 + + A tree has no cycles: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> T = nx.path_graph(10) + >>> gc.maximum_number_of_vertex_disjoint_cycles(T) + 0 + """ + if G.is_directed(): + raise nx.NetworkXError("This function supports undirected graphs only.") + H = nx.Graph(G) # copy & simplify to simple graph container + H.remove_edges_from(nx.selfloop_edges(H)) + if H.number_of_edges() == 0: + return 0 + + # Memoize by a bitmask of remaining vertices (n <= 30 typical). + nodes = list(H.nodes()) + idx = {v: i for i, v in enumerate(nodes)} + n = len(nodes) + + # adjacency bitmasks for fast induced-subgraph construction + adj_mask = [0] * n + for u, v in H.edges(): + iu, iv = idx[u], idx[v] + adj_mask[iu] |= 1 << iv + adj_mask[iv] |= 1 << iu + + memo: Dict[int, int] = {} + + def induced_cycle_exists(mask: int) -> bool: + # quick acyclicity test via m - n + c; compute edges count in induced graph + # for n <= 30, brute counting edges via bit operations is fine. + vs = [i for i in range(n) if (mask >> i) & 1] + if len(vs) < 3: + return False + # count edges + e2 = 0 + for i in vs: + e2 += (adj_mask[i] & mask).bit_count() + m_ind = e2 // 2 + # compute components with DFS on bitmasks + comp = 0 + unseen = mask + while unseen: + comp += 1 + start = (unseen & -unseen).bit_length() - 1 + stack = 1 << start + unseen &= ~(1 << start) + while stack: + x = (stack & -stack).bit_length() - 1 + stack &= ~(1 << x) + nbrs = adj_mask[x] & unseen + unseen &= ~nbrs + stack |= nbrs + # cyclomatic number > 0 => has a cycle + return m_ind - len(vs) + comp > 0 + + def find_any_cycle(mask: int) -> Optional[List[int]]: + # Find one cycle using networkx on the induced subgraph (fine for n <= 30). + # We convert mask -> subgraph just for cycle discovery. + vs = [nodes[i] for i in range(n) if (mask >> i) & 1] + if len(vs) < 3: + return None + sub = H.subgraph(vs) + basis = nx.cycle_basis(sub) + if not basis: + return None + return [idx[v] for v in basis[0]] + + def cycles_through_vertex(mask: int, v_i: int) -> List[int]: + """ + Return a list of cycle-vertex-masks for all simple cycles in the induced + subgraph on `mask` that contain vertex v_i. + + Implementation: enumerate cycles by exploring simple paths from neighbors + of v back to v. This can still be large in dense graphs, but n<=30 typical. + """ + v = nodes[v_i] + sub_nodes = [nodes[i] for i in range(n) if (mask >> i) & 1] + sub = H.subgraph(sub_nodes) + + # Early exit if v not present + if v not in sub: + return [] + + # Enumerate cycles containing v by enumerating pairs of neighbors (a,b) + # and simple paths between them that avoid v, then adding edges (v,a),(v,b). + nbrs = list(sub.neighbors(v)) + if len(nbrs) < 2: + return [] + + cycles_masks: Set[int] = set() + # For each unordered pair of neighbors, enumerate all simple paths between them avoiding v. + for a_idx in range(len(nbrs)): + for b_idx in range(a_idx + 1, len(nbrs)): + a = nbrs[a_idx] + b = nbrs[b_idx] + # enumerate all simple paths a->b in sub with v removed + sub_wo_v = sub.copy() + sub_wo_v.remove_node(v) + if a not in sub_wo_v or b not in sub_wo_v: + continue + try: + for path in nx.all_simple_paths(sub_wo_v, a, b): + # cycle is v + path vertices + cyc_vs = set(path) + cyc_vs.add(v) + cm = 0 + for u in cyc_vs: + cm |= 1 << idx[u] + cycles_masks.add(cm) + except nx.NetworkXNoPath: + continue + + # Return as list; no need to include duplicate vertex-sets + return list(cycles_masks) + + def rec(mask: int) -> int: + if mask in memo: + return memo[mask] + if not induced_cycle_exists(mask): + memo[mask] = 0 + return 0 + + cyc = find_any_cycle(mask) + if cyc is None: + memo[mask] = 0 + return 0 + + # Pick a vertex on a cycle to branch on + v_i = cyc[0] + + # Branch 1: v_i unused -> delete it + best = rec(mask & ~(1 << v_i)) + + # Branch 2: v_i used -> pick a cycle through v_i, remove all its vertices + for cyc_mask in cycles_through_vertex(mask, v_i): + best = max(best, 1 + rec(mask & ~cyc_mask)) + + memo[mask] = best + return best + + full = (1 << n) - 1 + return rec(full) + +def decycling_number(G: nx.Graph) -> int: + r""" + Compute the decycling number (feedback vertex number) of an undirected graph :math:`G`. + + The **decycling number** is the minimum number of vertices that must be removed to + destroy all cycles. Equivalently, it is the size of a minimum **feedback vertex set** + (FVS): + + .. math:: + \tau_V(G) = \min\{ |S| : S \subseteq V(G)\ \text{and}\ G - S\ \text{is a forest}\}. + + Parameters + ---------- + G : networkx.Graph + The input graph. Intended for undirected simple graphs. + + Notes + ----- + This function delegates to :func:`feedback_vertex_set` in exact mode and returns the + size of the resulting set. + + Conventions: + - If :math:`G` is a forest, then :math:`\tau_V(G)=0`. + - For disconnected graphs, :math:`G-S` must be acyclic in every component. + + Complexity + ---------- + Computing :math:`\tau_V(G)` is NP-hard in general; exact computation may take + exponential time in the worst case. + + Returns + ------- + int + The decycling number :math:`\tau_V(G)`. + + Examples + -------- + A tree has decycling number 0: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> T = nx.path_graph(8) + >>> gc.decycling_number(T) + 0 + + A cycle has decycling number 1: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.cycle_graph(7) + >>> gc.decycling_number(G) + 1 + + A complete graph :math:`K_n` has :math:`\tau_V(K_n)=n-2`: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.complete_graph(6) + >>> gc.decycling_number(G) + 4 + + See Also + -------- + feedback_vertex_set : Compute an actual (minimum) feedback vertex set. + maximum_induced_forest_number : Uses the identity :math:`|V(G)|-\tau_V(G)`. + """ + return len(feedback_vertex_set(G, exact=True)) + + +def maximum_induced_forest_number(G: nx.Graph) -> int: + r""" + Compute the maximum induced forest number of an undirected graph :math:`G`. + + The **maximum induced forest number** is the largest size of a vertex subset + :math:`U \subseteq V(G)` such that the induced subgraph :math:`G[U]` is acyclic + (a forest): + + .. math:: + f(G) = \max\{ |U| : U \subseteq V(G)\ \text{and}\ G[U]\ \text{is a forest}\}. + + Relationship to the decycling number + ------------------------------------ + This invariant is the complement of the decycling number: + + .. math:: + f(G) = |V(G)| - \tau_V(G), + + since choosing a feedback vertex set :math:`S` with :math:`G-S` a forest is equivalent + to choosing :math:`U = V(G)\setminus S` inducing a forest. + + This implementation uses that identity: + ``f(G) = G.order() - decycling_number(G)``. + + Parameters + ---------- + G : networkx.Graph + The input graph. Intended for undirected simple graphs. + + Notes + ----- + Exact computation inherits the complexity of :func:`decycling_number`, which is NP-hard + in general and may take exponential time. + + Returns + ------- + int + The maximum induced forest number :math:`f(G)`. + + Examples + -------- + A forest keeps all vertices: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> T = nx.path_graph(8) + >>> gc.maximum_induced_forest_number(T) + 8 + + For a cycle :math:`C_n`, removing one vertex breaks all cycles: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.cycle_graph(7) + >>> gc.maximum_induced_forest_number(G) + 6 + + For :math:`K_n`, the largest induced forest has size 2: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.complete_graph(6) + >>> gc.maximum_induced_forest_number(G) + 2 + + See Also + -------- + decycling_number : The feedback vertex number :math:`\tau_V(G)`. + feedback_vertex_set : Compute an actual optimal deletion set (when exact=True). + """ + return G.order() - decycling_number(G) diff --git a/src/graphcalc/invariants/degree.py b/src/graphcalc/invariants/degree.py index e8fceae..239d3ba 100644 --- a/src/graphcalc/invariants/degree.py +++ b/src/graphcalc/invariants/degree.py @@ -22,7 +22,6 @@ "k_residue_from_degrees", "residue", "k_residue", - "harmonic_index", ] @enforce_type(0, (nx.Graph, SimpleGraph)) @@ -495,36 +494,50 @@ def elimination_sequence_from_degrees(degrees: Sequence[int]) -> List[int]: # ────────────────────────────────────────────────────────────────────────────── def k_residue_from_degrees(degrees: Sequence[int], k: int) -> int: - """ - Compute the k-residue R_k from a degree sequence via its elimination sequence. + r""" + Compute the :math:`k`-residue :math:`R_k` from a degree sequence via its elimination sequence. - Definition - ---------- - For elimination sequence E = E(D) and k >= 1, - R_k(E) = sum_{i=0}^{k-1} (k - i) * f_i(E), - where f_i(E) is the frequency of i in E. + Let :math:`D` be a (graphic) degree sequence and let :math:`E=E(D)` be its + Havel–Hakimi elimination sequence (including trailing zeros). For :math:`k \ge 1`, + + .. math:: + R_k(E) \;=\; \sum_{i=0}^{k-1} (k-i)\, f_i(E), + + where :math:`f_i(E)` is the frequency of :math:`i` in :math:`E`. This function computes + :math:`R_k(E(D))` from the input degree sequence. Parameters ---------- - degrees : Sequence[int] - Nonnegative integer degree sequence (assumed graphical when from a graph). + degrees : sequence of int + A sequence of nonnegative integers (assumed graphical when coming from a graph). k : int - Parameter k >= 1. + The parameter :math:`k \ge 1`. Returns ------- int - The k-residue R_k(D). + The :math:`k`-residue :math:`R_k(D)`. + + Raises + ------ + ValueError + If ``k < 1``. See Also -------- - elimination_sequence_from_degrees + elimination_sequence_from_degrees : Compute the elimination sequence :math:`E(D)`. + k_residue : Compute :math:`R_k(G)` directly from a graph. + + Examples + -------- + >>> import graphcalc as gc + >>> gc.k_residue_from_degrees([2, 2, 1, 1], 1) == gc.residue_from_degrees([2, 2, 1, 1]) + True """ if k < 1: raise ValueError("k must be an integer >= 1.") E = elimination_sequence_from_degrees(degrees) - # Count only 0..k-1 freq = [0] * k for x in E: if 0 <= x < k: @@ -532,6 +545,7 @@ def k_residue_from_degrees(degrees: Sequence[int], k: int) -> int: return int(sum((k - i) * freq[i] for i in range(k))) + # ────────────────────────────────────────────────────────────────────────────── # Graph wrappers that reuse the degree-sequence core # ────────────────────────────────────────────────────────────────────────────── @@ -625,104 +639,57 @@ def residue(G: GraphLike) -> int: @enforce_type(1, int) def k_residue(G: GraphLike, k: int) -> int: r""" - Compute the k-residue R_k(G) from the Havel–Hakimi elimination sequence. + Compute the :math:`k`-residue :math:`R_k(G)` from the Havel–Hakimi elimination sequence. - Definition (Jelen generalizing Favaron–Mahéo–Saclé, Griggs–Kleitman, Triesch) - --------------------------------------------------------------------------- - Let D be a graphic degree sequence and E = E(D) its Havel–Hakimi elimination - sequence (the list of values removed at each step, including trailing zeros). - For k ≥ 1, - R_k(E) = sum_{i=0}^{k-1} (k - i) * f_i(E), - where f_i(E) is the frequency of i in E. Since E is determined by D(G), we - write R_k(G). + Let :math:`D` be a graphic degree sequence and let :math:`E=E(D)` be its + **Havel–Hakimi elimination sequence** (the list of values removed at each step, + including trailing zeros). For :math:`k \ge 1`, define - Special case: k = 1 gives R_1(G) = f_0(E) = R(G) (the usual residue). + .. math:: + R_k(E) \;=\; \sum_{i=0}^{k-1} (k-i)\, f_i(E), + + where :math:`f_i(E)` is the frequency of :math:`i` in :math:`E`. Since :math:`E` + is determined by the degree sequence of :math:`G`, we write :math:`R_k(G)`. + + The special case :math:`k=1` gives the classical residue: + + .. math:: + R_1(G) \;=\; f_0(E) \;=\; R(G). Parameters ---------- - G : nx.Graph or SimpleGraph - Input graph. + G : networkx.Graph or graphcalc.SimpleGraph + The input graph. k : int - Parameter k ≥ 1. + The parameter :math:`k \ge 1`. Returns ------- int - The k-residue R_k(G). + The :math:`k`-residue :math:`R_k(G)`. Notes ----- - We explicitly build the elimination sequence E by performing the Havel–Hakimi - process and recording the removed value at each step, including zeros at the - end. This ensures f_0(E) equals the classical residue. + This function constructs the elimination sequence :math:`E` by running the + Havel–Hakimi process and recording the removed value at each step, including + trailing zeros. Including those zeros ensures :math:`f_0(E)` matches the + classical residue. Examples -------- + >>> import graphcalc as gc >>> from graphcalc.generators import path_graph, complete_graph >>> G = path_graph(4) - >>> k_residue(G, 1) == residue(G) # should match your residue() + >>> gc.k_residue(G, 1) == gc.residue(G) True + >>> H = complete_graph(4) - >>> k_residue(H, 2) # weighted count of 0s and 1s in E(H) + >>> gc.k_residue(H, 2) 3 """ degrees = gc.degree_sequence(G) # or list(dict(G.degree()).values()) return k_residue_from_degrees(degrees, k) -@enforce_type(0, (nx.Graph, SimpleGraph)) -def harmonic_index(G: GraphLike) -> float: - r""" - Returns the harmonic index of a graph. - - The harmonic index of a graph is defined as: - - .. math:: - H(G) = \sum_{uv \in E(G)} \frac{2}{d(u) + d(v)} - - where: - - :math:`E(G)` is the edge set of the graph :math:`G`. - - :math:`d(u)` is the degree of vertex :math:`u`. - - The harmonic index is commonly used in mathematical chemistry and network science - to measure structural properties of molecular and network graphs. - - Parameters - ---------- - G : nx.Graph - The graph. - - Returns - ------- - float - The harmonic index of the graph. - - Examples - -------- - >>> import graphcalc as gc - >>> from graphcalc.generators import path_graph, complete_graph - - >>> G = path_graph(4) # Path graph with 4 vertices - >>> gc.harmonic_index(G) - 1.8333333333333333 - - >>> H = complete_graph(3) # Complete graph with 3 vertices - >>> gc.harmonic_index(H) - 1.5 - - Notes - ----- - - The harmonic index assumes all edge weights are equal to 1. If you want - to consider weighted graphs, modify the function to account for edge weights. - - The harmonic index is symmetric with respect to the graph's structure, making - it invariant under graph isomorphism. - - References - ---------- - S. Klavžar and I. Gutman, A comparison of the Schultz molecular topological - index and the Wiener index. *Journal of Chemical Information and Computer Sciences*, - 33(6), 1006-1009 (1993). - """ - return 2*sum((1/(degree(G, v) + degree(G, u)) for u, v in G.edges())) def irregularity(G): r""" diff --git a/src/graphcalc/invariants/domination.py b/src/graphcalc/invariants/domination.py index 5535fa9..1d86c29 100644 --- a/src/graphcalc/invariants/domination.py +++ b/src/graphcalc/invariants/domination.py @@ -16,6 +16,8 @@ "domination_number", "minimum_total_domination_set", "total_domination_number", + "minimum_connected_dominating_set", + "connected_domination_number", "minimum_independent_dominating_set", "independent_domination_number", "complement_is_connected", @@ -295,16 +297,25 @@ def minimum_independent_dominating_set( r""" Find a minimum **independent dominating set** of :math:`G` via integer programming. + An **independent dominating set** is a set :math:`S \subseteq V(G)` such that: + + - (**Independent**) no two vertices of :math:`S` are adjacent, and + - (**Dominating**) every vertex of :math:`G` is in :math:`S` or adjacent to a vertex in :math:`S`. + + Equivalently, :math:`S` is both an independent set and a dominating set. + Let :math:`x_v \in \{0,1\}` indicate whether :math:`v` is chosen. We solve .. math:: \min \sum_{v \in V} x_v subject to the **independence** constraints + .. math:: x_u + x_v \le 1 \quad \forall \{u,v\}\in E, and the **domination** constraints (using the closed neighborhood) + .. math:: \sum_{u \in N[v]} x_u \ge 1 \quad \forall v \in V. @@ -334,6 +345,7 @@ def minimum_independent_dominating_set( >>> len(S) 2 """ + prob = pulp.LpProblem("MinIndependentDominatingSet", pulp.LpMinimize) # One binary var per vertex @@ -394,6 +406,182 @@ def independent_domination_number( """ return len(minimum_independent_dominating_set(G, **solver_kwargs)) +@enforce_type(0, (nx.Graph, SimpleGraph)) +@with_solver +def minimum_connected_dominating_set( + G: GraphLike, + *, + verbose: bool = False, + solve=None, # injected by @with_solver +) -> Set[Hashable]: + r""" + Find a minimum connected dominating set of :math:`G` via integer programming. + + A **connected dominating set** is a set :math:`S \subseteq V(G)` such that: + + - (**Dominating**) every vertex is in :math:`S` or adjacent to a vertex in :math:`S`. + - (**Connected**) the induced subgraph :math:`G[S]` is connected. + + Let :math:`x_v \in \{0,1\}` indicate whether :math:`v` is selected. Domination is enforced by + + .. math:: + \sum_{u \in N[v]} x_u \ge 1 \quad \forall v \in V, + + where :math:`N[v]` is the closed neighborhood of :math:`v`. + + Connectivity is enforced with a single-commodity flow formulation that chooses a root + among the selected vertices and sends one unit of flow to each other selected vertex. + + Parameters + ---------- + G : networkx.Graph or graphcalc.SimpleGraph + The input graph. + verbose : bool, default=False + If True, print solver output (when supported). + + Notes + ----- + Accepts the standard solver kwargs from :func:`graphcalc.solvers.with_solver` + (e.g., ``solver="highs"`` or ``solver={"name":"GUROBI_CMD","options":{...}}``). + + Conventions + ----------- + - If :math:`|V(G)|=0`, returns the empty set. + - If :math:`G` is disconnected and nonempty, no connected dominating set exists and this function raises ``ValueError``. + + Returns + ------- + set of hashable + A minimum connected dominating set. + + Examples + -------- + >>> import graphcalc as gc + >>> from graphcalc.generators import path_graph, cycle_graph + >>> len(gc.minimum_connected_dominating_set(path_graph(4))) + 2 + >>> len(gc.minimum_connected_dominating_set(cycle_graph(6))) + 4 + """ + import pulp + + n = G.number_of_nodes() + if n == 0: + return set() + + # For disconnected graphs, no connected dominating set exists. + if not nx.is_connected(G): + raise ValueError( + "minimum_connected_dominating_set is defined only for connected graphs (or the empty graph)." + ) + + nodes = list(G.nodes()) + + prob = pulp.LpProblem("MinConnectedDominatingSet", pulp.LpMinimize) + + # Selection variables + x = {v: pulp.LpVariable(f"x_{v}", cat="Binary") for v in nodes} + + # Root choice among selected vertices + r = {v: pulp.LpVariable(f"r_{v}", cat="Binary") for v in nodes} + prob += pulp.lpSum(r.values()) == 1, "one_root" + for v in nodes: + prob += r[v] <= x[v], f"root_implies_selected_{v}" + + # K = |S| + K = pulp.LpVariable("K", lowBound=1, upBound=n, cat="Integer") + prob += K == pulp.lpSum(x.values()), "K_def" + + # Flow variables on directed arcs + arcs = [] + for u, v in G.edges(): + arcs.append((u, v)) + arcs.append((v, u)) + + M = n # big-M + f = {(u, v): pulp.LpVariable(f"f_{u}_{v}", lowBound=0, upBound=n, cat="Continuous") for (u, v) in arcs} + + # Flow can traverse only selected vertices + for u, v in arcs: + prob += f[(u, v)] <= M * x[u], f"cap_tail_{u}_{v}" + prob += f[(u, v)] <= M * x[v], f"cap_head_{u}_{v}" + + # Linearize z_v = K * r_v + z = {v: pulp.LpVariable(f"z_{v}", lowBound=0, upBound=n, cat="Continuous") for v in nodes} + for v in nodes: + prob += z[v] <= K, f"z_le_K_{v}" + prob += z[v] <= n * r[v], f"z_le_nrv_{v}" + prob += z[v] >= K - n * (1 - r[v]), f"z_ge_K_minus_n_{v}" + prob += z[v] >= 0, f"z_ge_0_{v}" + + # Objective + prob += pulp.lpSum(x.values()) + + # Domination constraints (closed neighborhoods) + for v in nodes: + Nclosed = closed_neighborhood(G, v) + prob += pulp.lpSum(x[u] for u in Nclosed) >= 1, f"dom_{v}" + + # Flow conservation: inflow - outflow = x[v] - z[v] + for v in nodes: + inflow = pulp.lpSum(f[(u, v)] for u in G.neighbors(v)) + outflow = pulp.lpSum(f[(v, u)] for u in G.neighbors(v)) + prob += inflow - outflow == x[v] - z[v], f"flow_{v}" + + # Solve (raises if not Optimal) + solve(prob) + + return _extract_and_report(prob, x, verbose=verbose) + +@enforce_type(0, (nx.Graph, SimpleGraph)) +def connected_domination_number( + G: GraphLike, + **solver_kwargs, # forwards (verbose, solver, solver_options) +) -> int: + r""" + Return the **connected domination number** :math:`\gamma_c(G)`. + + The connected domination number is the minimum size of a connected dominating set: + + .. math:: + \gamma_c(G) = \min\{ |S| : S \subseteq V(G),\ S \text{ dominates } G,\ \text{and } G[S]\text{ is connected}\}. + + This wraps :func:`minimum_connected_dominating_set`. + + Parameters + ---------- + G : networkx.Graph or graphcalc.SimpleGraph + The input graph. + + Other Parameters + ---------------- + verbose : bool, default=False + solver : str or dict or pulp.LpSolver or type or callable or None, optional + solver_options : dict, optional + Forwarded to :func:`minimum_connected_dominating_set`. + + Returns + ------- + int + The connected domination number :math:`\gamma_c(G)`. + + Raises + ------ + ValueError + If :math:`G` is disconnected and nonempty. + + Examples + -------- + >>> import graphcalc as gc + >>> from graphcalc.generators import path_graph, cycle_graph + >>> gc.connected_domination_number(path_graph(3)) + 1 + >>> gc.connected_domination_number(path_graph(4)) + 2 + >>> gc.connected_domination_number(cycle_graph(6)) + 4 + """ + return len(minimum_connected_dominating_set(G, **solver_kwargs)) @enforce_type(0, (nx.Graph, gc.SimpleGraph)) def complement_is_connected(G: GraphLike, S: Union[Set[Hashable], List[Hashable]]) -> bool: @@ -548,22 +736,27 @@ def minimum_roman_dominating_function( r""" Compute a minimum Roman dominating function (RDF) via integer programming. - A Roman dominating function on :math:`G=(V,E)` is a map :math:`f:V\to\{0,1,2\}` - such that every vertex with label 0 has a neighbor with label 2. We minimize - :math:`\sum_{v\in V} f(v)` using binary indicators: - :math:`x_v=1` iff :math:`f(v)=1`, and :math:`y_v=1` iff :math:`f(v)=2` - (so :math:`f(v)=x_v+2y_v`). + A **Roman dominating function** on :math:`G=(V,E)` is a map :math:`f:V\to\{0,1,2\}` + such that every vertex with label 0 has a neighbor with label 2. The **weight** of + :math:`f` is :math:`\sum_{v\in V} f(v)`. + + We minimize the weight using binary indicators: + + - :math:`x_v=1` iff :math:`f(v)=1` + - :math:`y_v=1` iff :math:`f(v)=2` + + so that :math:`f(v)=x_v+2y_v`. Formulation ----------- .. math:: - \min \sum_{v\in V} (x_v + 2y_v) + \min \sum_{v\in V} (x_v + 2y_v) .. math:: - x_v + y_v + \sum_{u\in N(v)} y_u \ge 1 \quad \forall v\in V + x_v + y_v + \sum_{u\in N(v)} y_u \ge 1 \quad \forall v\in V .. math:: - x_v + y_v \le 1 \quad \forall v\in V + x_v + y_v \le 1 \quad \forall v\in V Parameters ---------- @@ -580,11 +773,11 @@ def minimum_roman_dominating_function( Returns ------- dict - { - "x": {v: 0/1}, # vertices labeled 1 - "y": {v: 0/1}, # vertices labeled 2 - "objective": float # min sum_v (x_v + 2 y_v) - } + A dictionary with keys: + + - ``"x"``: dict mapping ``v`` to 0/1 indicating whether :math:`f(v)=1` + - ``"y"``: dict mapping ``v`` to 0/1 indicating whether :math:`f(v)=2` + - ``"objective"``: float, the minimum weight :math:`\sum_v (x_v + 2y_v)` Examples -------- @@ -595,6 +788,7 @@ def minimum_roman_dominating_function( >>> isinstance(sol["objective"], float) True """ + prob = pulp.LpProblem("RomanDomination", pulp.LpMinimize) # Binary variables per vertex @@ -676,32 +870,46 @@ def minimum_double_roman_dominating_function( r""" Compute a minimum double Roman dominating function (DRDF) via integer programming. - A DRDF is a labeling :math:`f:V \to \{0,1,2,3\}` such that: - 1) If :math:`f(v)=0`, then either some neighbor has label 3, or at least - two neighbors have label 2. - 2) If :math:`f(v)=1`, then some neighbor has label at least 2. + A **double Roman dominating function** is a labeling + :math:`f:V(G)\to\{0,1,2,3\}` such that: - We use binary indicators per vertex :math:`v`: - :math:`x_v=1` iff :math:`f(v)=1`, :math:`y_v=1` iff :math:`f(v)=2`, - :math:`z_v=1` iff :math:`f(v)=3`, with exclusivity :math:`x_v+y_v+z_v\le 1`. - The weight is :math:`\sum_v (x_v+2y_v+3z_v)`. + 1. If :math:`f(v)=0`, then either some neighbor of :math:`v` has label 3, or at least + two neighbors of :math:`v` have label 2. + 2. If :math:`f(v)=1`, then some neighbor of :math:`v` has label at least 2. + + We use binary indicators for each vertex :math:`v`: + + - :math:`x_v=1` iff :math:`f(v)=1` + - :math:`y_v=1` iff :math:`f(v)=2` + - :math:`z_v=1` iff :math:`f(v)=3` + + with exclusivity :math:`x_v+y_v+z_v\le 1`. The objective is + + .. math:: + \min \sum_{v\in V} (x_v + 2y_v + 3z_v). Formulation ----------- - .. math:: - \min \sum_{v\in V} (x_v + 2y_v + 3z_v) + Domination constraint for vertices that may be labeled 0 (linearized): - Domination for 0-labeled vertices (linearized): .. math:: - x_v + y_v + z_v \;+\; \tfrac{1}{2}\!\sum_{u\in N(v)} y_u \;+\; \sum_{u\in N(v)} z_u \;\ge\; 1 \quad \forall v + x_v + y_v + z_v + \;+\; \tfrac{1}{2}\sum_{u\in N(v)} y_u + \;+\; \sum_{u\in N(v)} z_u + \;\ge\; 1 + \quad \forall v\in V. + + Domination constraint for vertices labeled 1: - Domination for 1-labeled vertices: .. math:: - \sum_{u\in N(v)} (y_u + z_u) \;\ge\; x_v \quad \forall v + \sum_{u\in N(v)} (y_u + z_u) \;\ge\; x_v + \quad \forall v\in V. Exclusivity: + .. math:: - x_v + y_v + z_v \;\le\; 1 \quad \forall v + x_v + y_v + z_v \;\le\; 1 + \quad \forall v\in V. Parameters ---------- @@ -713,17 +921,17 @@ def minimum_double_roman_dominating_function( Notes ----- Accepts standard solver kwargs via :func:`graphcalc.solvers.with_solver` - (e.g., ``solver="highs"``, ``solver={"name":"GUROBI_CMD","options":{...}}``). + (e.g., ``solver="highs"`` or ``solver={"name":"GUROBI_CMD","options":{...}}``). Returns ------- dict - { - "x": {v: 0/1}, # vertices labeled 1 - "y": {v: 0/1}, # vertices labeled 2 - "z": {v: 0/1}, # vertices labeled 3 - "objective": float # min sum_v (x_v + 2y_v + 3z_v) - } + A dictionary with keys: + + - ``"x"``: dict mapping ``v`` to 0/1 indicating whether :math:`f(v)=1` + - ``"y"``: dict mapping ``v`` to 0/1 indicating whether :math:`f(v)=2` + - ``"z"``: dict mapping ``v`` to 0/1 indicating whether :math:`f(v)=3` + - ``"objective"``: float, the minimum value :math:`\sum_v (x_v + 2y_v + 3z_v)` Examples -------- diff --git a/src/graphcalc/invariants/graph_indices.py b/src/graphcalc/invariants/graph_indices.py new file mode 100644 index 0000000..a40bbc2 --- /dev/null +++ b/src/graphcalc/invariants/graph_indices.py @@ -0,0 +1,824 @@ + +from typing import Hashable, List +from typing import Iterable, List, Sequence, Tuple + +import math +import networkx as nx +import graphcalc as gc +from graphcalc import SimpleGraph +from graphcalc.utils import enforce_type, GraphLike + +__all__ = [ + "randic_index", + "zagreb_1", + "zagreb_2", + "reciprocal_zagreb_1", + "reciprocal_zagreb_2", + "abc_index", + "ga_index", + "reciprocal_ga_index", + "sum_connectivity_index", + "sombor_index", + "reciprocal_sombor_index", + "hyper_zagreb_index", + "reciprocal_hyper_zagreb_index", + "augmented_zagreb_index", + "reciprocal_augmented_zagreb_index", + "harmonic_index", +] + +def randic_index(G, alpha=-0.5): + r""" + Compute the generalized Randić index :math:`R_\alpha(G)` of a graph :math:`G`. + + For a real parameter :math:`\alpha`, the **generalized Randić index** + (also called the generalized connectivity index) is + + .. math:: + R_\alpha(G) \;=\; \sum_{\{u,v\}\in E(G)} \bigl(d(u)\,d(v)\bigr)^{\alpha}, + + where :math:`d(u)` denotes the degree of :math:`u`. + + The **classical Randić index** is the special case :math:`\alpha=-\tfrac12`: + + .. math:: + R(G) \;=\; \sum_{\{u,v\}\in E(G)} \frac{1}{\sqrt{d(u)\,d(v)}}. + + Parameters + ---------- + G : networkx.Graph + The input graph. Intended for finite simple undirected graphs. + alpha : float, default=-0.5 + The exponent :math:`\alpha`. + + Notes + ----- + - Only edges contribute, so isolated vertices do not affect the value. + - In a simple graph, every edge has endpoints of degree at least 1, so + :math:`\alpha<0` causes no division-by-zero issues. + - For multigraphs, degrees count multiplicity and parallel edges are summed + repeatedly; this matches the literal formula over the multiset of edges, which + may differ from some chemistry conventions. + + Returns + ------- + float + The value :math:`R_\alpha(G)`. + + Examples + -------- + A path on 4 vertices has degrees :math:`(1,2,2,1)`, so + + .. math:: + R(G)=\tfrac{1}{\sqrt{2}}+\tfrac{1}{2}+\tfrac{1}{\sqrt{2}}. + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.path_graph(4) + >>> round(gc.randic_index(G), 6) + 1.914214 + + A complete graph :math:`K_n` has all degrees :math:`n-1`, so + :math:`R_{-1/2}(K_n) = |E|/(n-1) = n/2`. + + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.randic_index(nx.complete_graph(6)) + 3.0 + + Changing :math:`\alpha` changes the weighting: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.path_graph(4) + >>> gc.randic_index(G, alpha=1) # sum of degree products over edges + 8 + """ + deg = dict(G.degree()) + return sum((deg[u] * deg[v]) ** alpha for u, v in G.edges()) + +def zagreb_1(G): + r""" + Compute the first Zagreb index :math:`M_1(G)`. + + The **first Zagreb index** is + + .. math:: + M_1(G) = \sum_{v \in V(G)} d(v)^2, + + where :math:`d(v)` is the degree of :math:`v`. + + Parameters + ---------- + G : networkx.Graph + The input graph. Intended for finite simple undirected graphs. + + Returns + ------- + int + The first Zagreb index :math:`M_1(G)`. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.zagreb_1(nx.path_graph(4)) # degrees 1,2,2,1 + 10 + >>> gc.zagreb_1(nx.complete_graph(4)) # degrees all 3 + 36 + """ + return sum(d * d for _, d in G.degree()) + + +def zagreb_2(G): + r""" + Compute the second Zagreb index :math:`M_2(G)`. + + The **second Zagreb index** is + + .. math:: + M_2(G) = \sum_{\{u,v\} \in E(G)} d(u)\,d(v), + + where :math:`d(\cdot)` denotes vertex degree. + + Parameters + ---------- + G : networkx.Graph + The input graph. Intended for finite simple undirected graphs. + + Returns + ------- + int + The second Zagreb index :math:`M_2(G)`. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.zagreb_2(nx.path_graph(4)) # edge degree-products: 1*2 + 2*2 + 2*1 + 8 + >>> gc.zagreb_2(nx.complete_graph(4)) # 6 edges, each contributes 3*3 + 54 + """ + deg = dict(G.degree()) + return sum(deg[u] * deg[v] for u, v in G.edges()) + + +def reciprocal_zagreb_1(G): + r""" + Compute the reciprocal first Zagreb index :math:`RM_1(G)`. + + A common “reciprocal” variant replaces :math:`d(v)^2` by its reciprocal: + + .. math:: + RM_1(G) = \sum_{v \in V(G)} \frac{1}{d(v)^2}. + + Isolated vertices (degree 0) contribute nothing in this implementation. + + Parameters + ---------- + G : networkx.Graph + The input graph. Intended for finite simple undirected graphs. + + Returns + ------- + float + The reciprocal first Zagreb index :math:`RM_1(G)`. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.path_graph(4) # degrees 1,2,2,1 + >>> gc.reciprocal_zagreb_1(G) + 2.5 + >>> H = nx.empty_graph(3) # all isolated -> contributes 0 by convention here + >>> gc.reciprocal_zagreb_1(H) + 0.0 + """ + s = 0.0 + for _, d in G.degree(): + if d > 0: + s += 1.0 / (d * d) + return s + + +def reciprocal_zagreb_2(G): + r""" + Compute the reciprocal second Zagreb index :math:`RM_2(G)`. + + A common “reciprocal” variant replaces :math:`d(u)d(v)` by its reciprocal: + + .. math:: + RM_2(G) = \sum_{\{u,v\} \in E(G)} \frac{1}{d(u)\,d(v)}. + + For simple graphs, every edge has endpoints of degree at least 1, so no + division-by-zero occurs. + + Parameters + ---------- + G : networkx.Graph + The input graph. Intended for finite simple undirected graphs. + + Returns + ------- + float + The reciprocal second Zagreb index :math:`RM_2(G)`. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.path_graph(4) # edge degree-products: 2,4,2 -> reciprocals: 1/2+1/4+1/2 + >>> gc.reciprocal_zagreb_2(G) + 1.25 + >>> gc.reciprocal_zagreb_2(nx.complete_graph(4)) # 6 edges, each 1/(3*3) + 0.6666666666666666 + """ + deg = dict(G.degree()) + return sum(1.0 / (deg[u] * deg[v]) for u, v in G.edges()) + +def abc_index(G): + r""" + Compute the Atom–Bond Connectivity (ABC) index of a graph :math:`G`. + + The **ABC index** is + + .. math:: + \mathrm{ABC}(G) \;=\; \sum_{\{u,v\}\in E(G)} + \sqrt{\frac{d(u)+d(v)-2}{d(u)\,d(v)}}\,, + + where :math:`d(\cdot)` denotes vertex degree. + + Parameters + ---------- + G : networkx.Graph + The input graph. Intended for finite simple undirected graphs. + + Notes + ----- + - For a pendant edge with :math:`d(u)=d(v)=1` (which can only occur in :math:`K_2`), + the summand is :math:`\sqrt{0/1}=0`. + - In a simple graph, every edge has endpoints of degree at least 1, so the denominator + is positive. + + Returns + ------- + float + The Atom–Bond Connectivity index :math:`\mathrm{ABC}(G)`. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.abc_index(nx.complete_graph(2)) # single edge with degrees 1,1 + 0.0 + + For :math:`P_3` (degrees 1-2-1), each edge contributes :math:`\sqrt{1/2}`, so the + total is :math:`\sqrt{2}`: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> round(gc.abc_index(nx.path_graph(3)), 6) + 1.414214 + + A 4-cycle has 4 edges with degree-pair (2,2), so each contributes :math:`\sqrt{(2)/(4)}=\sqrt{1/2}`: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> round(gc.abc_index(nx.cycle_graph(4)), 6) + 2.828427 + """ + deg = dict(G.degree()) + s = 0.0 + for u, v in G.edges(): + du, dv = deg[u], deg[v] + s += math.sqrt((du + dv - 2) / (du * dv)) + return s + +def ga_index(G): + r""" + Compute the Geometric–Arithmetic (GA) index of a graph :math:`G`. + + The **GA index** is the degree-based topological index + + .. math:: + \mathrm{GA}(G) \;=\; \sum_{\{u,v\}\in E(G)} + \frac{2\sqrt{d(u)\,d(v)}}{d(u)+d(v)}, + + where :math:`d(\cdot)` denotes vertex degree. + + Parameters + ---------- + G : networkx.Graph + The input graph. Intended for finite simple undirected graphs. + + Notes + ----- + - For a simple graph, :math:`d(u),d(v)\ge 1` on every edge, and :math:`d(u)+d(v)\ge 2`, + so the expression is well-defined. + - Each summand lies in :math:`(0,1]` by AM–GM, with equality 1 iff :math:`d(u)=d(v)`. + + Returns + ------- + float + The GA index :math:`\mathrm{GA}(G)`. + + Examples + -------- + For :math:`K_2`, the single edge has degrees (1,1), so GA = 1: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.ga_index(nx.complete_graph(2)) + 1.0 + + For :math:`P_3`, each edge has degrees (1,2), contributing :math:`2\sqrt{2}/3`: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> round(gc.ga_index(nx.path_graph(3)), 6) + 1.885618 + + For :math:`C_4`, all edges have degrees (2,2), so each term is 1 and GA = 4: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.ga_index(nx.cycle_graph(4)) + 4.0 + """ + deg = dict(G.degree()) + s = 0.0 + for u, v in G.edges(): + du, dv = deg[u], deg[v] + s += (2.0 * math.sqrt(du * dv)) / (du + dv) + return s + + +def reciprocal_ga_index(G): + r""" + Compute the reciprocal Geometric–Arithmetic index of a graph :math:`G`. + + This “reciprocal” variant sums the reciprocals of the GA edge terms: + + .. math:: + \mathrm{RGA}(G) \;=\; \sum_{\{u,v\}\in E(G)} + \frac{d(u)+d(v)}{2\sqrt{d(u)\,d(v)}}. + + For simple graphs, degrees on an edge are at least 1, so this is well-defined. + + Parameters + ---------- + G : networkx.Graph + The input graph. Intended for finite simple undirected graphs. + + Returns + ------- + float + The reciprocal GA index :math:`\mathrm{RGA}(G)`. + + Examples + -------- + For :math:`K_2`, RGA = 1: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.reciprocal_ga_index(nx.complete_graph(2)) + 1.0 + + For :math:`C_4`, every edge has degrees (2,2), so each term is 1 and RGA = 4: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.reciprocal_ga_index(nx.cycle_graph(4)) + 4.0 + """ + deg = dict(G.degree()) + s = 0.0 + for u, v in G.edges(): + du, dv = deg[u], deg[v] + s += (du + dv) / (2.0 * math.sqrt(du * dv)) + return s + +def sum_connectivity_index(G, alpha=-0.5): + r""" + Compute the generalized sum-connectivity index :math:`SC_\alpha(G)` of a graph :math:`G`. + + For a real parameter :math:`\alpha`, the **generalized sum-connectivity index** is + + .. math:: + SC_\alpha(G) \;=\; \sum_{\{u,v\}\in E(G)} \bigl(d(u)+d(v)\bigr)^{\alpha}, + + where :math:`d(\cdot)` denotes vertex degree. + + The classical sum-connectivity index is the special case :math:`\alpha=-\tfrac12`. + + Parameters + ---------- + G : networkx.Graph + The input graph. Intended for finite simple undirected graphs. + alpha : float, default=-0.5 + The exponent :math:`\alpha`. + + Notes + ----- + - Only edges contribute, so isolated vertices do not affect the value. + - In a simple graph, every edge satisfies :math:`d(u)+d(v)\ge 2`, so negative + :math:`\alpha` causes no division-by-zero issues. + + Returns + ------- + float + The value :math:`SC_\alpha(G)`. + + Examples + -------- + For :math:`K_2`, the single edge has degrees (1,1), so :math:`SC_{-1/2} = 2^{-1/2}`: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> round(gc.sum_connectivity_index(nx.complete_graph(2)), 6) + 0.707107 + + For :math:`P_4`, edge degree-sums are 3, 4, 3, so + :math:`SC_{-1/2} = 3^{-1/2} + 4^{-1/2} + 3^{-1/2}`: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> round(gc.sum_connectivity_index(nx.path_graph(4)), 6) + 1.654701 + + With :math:`\alpha=1`, this is the sum of degree-sums over edges: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.sum_connectivity_index(nx.path_graph(4), alpha=1) + 10 + """ + deg = dict(G.degree()) + return sum((deg[u] + deg[v]) ** alpha for u, v in G.edges()) + +def sombor_index(G): + r""" + Compute the Sombor index :math:`SO(G)` of a graph :math:`G`. + + The **Sombor index** is the degree-based topological index + + .. math:: + SO(G) \;=\; \sum_{\{u,v\}\in E(G)} \sqrt{d(u)^2 + d(v)^2}, + + where :math:`d(\cdot)` denotes vertex degree. + + Parameters + ---------- + G : networkx.Graph + The input graph. Intended for finite simple undirected graphs. + + Returns + ------- + float + The Sombor index :math:`SO(G)`. + + Examples + -------- + For :math:`K_2`, degrees are (1,1), so :math:`SO(K_2)=\sqrt{2}`: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> round(gc.sombor_index(nx.complete_graph(2)), 6) + 1.414214 + + For :math:`P_3`, degrees are 1-2-1, so each edge contributes :math:`\sqrt{1^2+2^2}=\sqrt{5}`: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> round(gc.sombor_index(nx.path_graph(3)), 6) + 4.472136 + + For :math:`C_4`, all degrees are 2, so each edge contributes :math:`\sqrt{8}`: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> round(gc.sombor_index(nx.cycle_graph(4)), 6) + 11.313708 + """ + deg = dict(G.degree()) + s = 0.0 + for u, v in G.edges(): + du, dv = deg[u], deg[v] + s += math.sqrt(du * du + dv * dv) + return s + + +def reciprocal_sombor_index(G): + r""" + Compute the reciprocal Sombor index :math:`RSO(G)` of a graph :math:`G`. + + This “reciprocal” variant sums the reciprocals of the per-edge Sombor terms: + + .. math:: + RSO(G) \;=\; \sum_{\{u,v\}\in E(G)} \frac{1}{\sqrt{d(u)^2 + d(v)^2}}. + + For simple graphs, every edge has endpoints of degree at least 1, so each denominator + is at least :math:`\sqrt{2}` and the expression is well-defined. + + Parameters + ---------- + G : networkx.Graph + The input graph. Intended for finite simple undirected graphs. + + Returns + ------- + float + The reciprocal Sombor index :math:`RSO(G)`. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> round(gc.reciprocal_sombor_index(nx.complete_graph(2)), 6) + 0.707107 + + >>> import networkx as nx + >>> import graphcalc as gc + >>> round(gc.reciprocal_sombor_index(nx.path_graph(3)), 6) # 2 edges, each 1/sqrt(5) + 0.894427 + """ + deg = dict(G.degree()) + s = 0.0 + for u, v in G.edges(): + du, dv = deg[u], deg[v] + s += 1.0 / math.sqrt(du * du + dv * dv) + return s + +def hyper_zagreb_index(G): + r""" + Compute the Hyper Zagreb index :math:`HM(G)` of a graph :math:`G`. + + The **Hyper Zagreb index** is + + .. math:: + HM(G) \;=\; \sum_{\{u,v\}\in E(G)} \bigl(d(u)+d(v)\bigr)^2, + + where :math:`d(\cdot)` denotes vertex degree. + + Parameters + ---------- + G : networkx.Graph + The input graph. Intended for finite simple undirected graphs. + + Returns + ------- + int + The Hyper Zagreb index :math:`HM(G)` (integer-valued for simple graphs). + + Examples + -------- + For :math:`P_4`, edge degree-sums are 3, 4, 3, so + :math:`HM(P_4) = 3^2 + 4^2 + 3^2 = 34`: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.hyper_zagreb_index(nx.path_graph(4)) + 34 + + For :math:`K_4`, all degrees are 3 and each of the 6 edges has sum 6, so + :math:`HM(K_4) = 6 \cdot 6^2 = 216`: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.hyper_zagreb_index(nx.complete_graph(4)) + 216 + + A graph with no edges has Hyper Zagreb index 0: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.hyper_zagreb_index(nx.empty_graph(5)) + 0 + """ + deg = dict(G.degree()) + return sum((deg[u] + deg[v]) ** 2 for u, v in G.edges()) + + +def reciprocal_hyper_zagreb_index(G): + r""" + Compute the reciprocal Hyper Zagreb index :math:`RHM(G)` of a graph :math:`G`. + + This “reciprocal” variant sums the reciprocals of the Hyper Zagreb edge terms: + + .. math:: + RHM(G) \;=\; \sum_{\{u,v\}\in E(G)} \frac{1}{\bigl(d(u)+d(v)\bigr)^2}. + + For simple graphs, every edge satisfies :math:`d(u)+d(v)\ge 2`, so the expression is + well-defined. + + Parameters + ---------- + G : networkx.Graph + The input graph. Intended for finite simple undirected graphs. + + Returns + ------- + float + The reciprocal Hyper Zagreb index :math:`RHM(G)`. + + Examples + -------- + For :math:`K_2`, the single edge has degree-sum 2, so :math:`RHM=1/4`: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.reciprocal_hyper_zagreb_index(nx.complete_graph(2)) + 0.25 + + For :math:`P_4`, edge degree-sums are 3, 4, 3, so + :math:`RHM(P_4)=1/3^2 + 1/4^2 + 1/3^2`: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> round(gc.reciprocal_hyper_zagreb_index(nx.path_graph(4)), 6) + 0.284722 + """ + deg = dict(G.degree()) + return sum(1.0 / ((deg[u] + deg[v]) ** 2) for u, v in G.edges()) + +def augmented_zagreb_index(G): + r""" + Compute the Augmented Zagreb index :math:`AZI(G)` of a graph :math:`G` + (with a total-function convention). + + The usual definition is + + .. math:: + AZI(G) \;=\; \sum_{\{u,v\}\in E(G)} + \left(\frac{d(u)\,d(v)}{d(u)+d(v)-2}\right)^3, + + where :math:`d(\cdot)` denotes vertex degree. + + Convention / edge cases + ----------------------- + The denominator :math:`d(u)+d(v)-2` is zero exactly when :math:`d(u)=d(v)=1`, + which in a simple graph occurs only for :math:`G=K_2`. Different sources either + treat :math:`AZI(K_2)` as undefined or define a special value. + + This implementation uses a **total-function** convention: + any edge with :math:`d(u)+d(v)-2 \le 0` contributes 0 to the sum. + In particular, this yields :math:`AZI(K_2)=0`. + + Parameters + ---------- + G : networkx.Graph + The input graph. Intended for finite simple undirected graphs. + + Notes + ----- + For all other edges in a simple graph, :math:`d(u)+d(v)-2 \ge 1`, so the summand is + well-defined. + + Returns + ------- + float + The Augmented Zagreb index :math:`AZI(G)` under the convention above. + + Examples + -------- + By convention, :math:`AZI(K_2)=0`: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.augmented_zagreb_index(nx.complete_graph(2)) + 0.0 + + For :math:`P_3`, degrees are 1-2-1, and each edge contributes + :math:`((2)/(1))^3 = 8`, so :math:`AZI(P_3)=16`: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.augmented_zagreb_index(nx.path_graph(3)) + 16.0 + + For :math:`C_4`, every edge has degrees (2,2), so each contributes + :math:`((4)/(2))^3 = 8`, hence :math:`AZI(C_4)=32`: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.augmented_zagreb_index(nx.cycle_graph(4)) + 32.0 + """ + deg = dict(G.degree()) + s = 0.0 + for u, v in G.edges(): + du, dv = deg[u], deg[v] + denom = du + dv - 2 + if denom <= 0: + continue + s += ((du * dv) / denom) ** 3 + return s + +def reciprocal_augmented_zagreb_index(G): + r""" + Compute a reciprocal variant of the Augmented Zagreb index. + + This per-edge reciprocal variant is + + .. math:: + RAZI(G) \;=\; \sum_{\{u,v\}\in E(G)} + \left(\frac{d(u)+d(v)-2}{d(u)\,d(v)}\right)^3, + + which is the reciprocal of the usual AZI edge fraction (inside the cube). + + Parameters + ---------- + G : networkx.Graph + The input graph. Intended for finite simple undirected graphs. + + Notes + ----- + - For :math:`K_2`, the single edge has :math:`d(u)=d(v)=1`, so the summand is 0 and + :math:`RAZI(K_2)=0`. + - For simple graphs, :math:`d(u),d(v)\ge 1` on every edge, so the expression is + well-defined. + + Returns + ------- + float + The reciprocal Augmented Zagreb index :math:`RAZI(G)`. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.reciprocal_augmented_zagreb_index(nx.complete_graph(2)) + 0.0 + + For :math:`P_3`, each edge has degrees (1,2), so each contributes + :math:`((1)/(2))^3 = 1/8` and the total is 1/4: + + >>> import networkx as nx + >>> import graphcalc as gc + >>> gc.reciprocal_augmented_zagreb_index(nx.path_graph(3)) + 0.25 + """ + deg = dict(G.degree()) + s = 0.0 + for u, v in G.edges(): + du, dv = deg[u], deg[v] + # denom (of the reciprocal fraction) is du*dv >= 1 on any simple-graph edge + s += ((du + dv - 2) / (du * dv)) ** 3 + return s + +@enforce_type(0, (nx.Graph, SimpleGraph)) +def harmonic_index(G: GraphLike) -> float: + r""" + Returns the harmonic index of a graph. + + The harmonic index of a graph is defined as: + + .. math:: + H(G) = \sum_{uv \in E(G)} \frac{2}{d(u) + d(v)} + + where: + - :math:`E(G)` is the edge set of the graph :math:`G`. + - :math:`d(u)` is the degree of vertex :math:`u`. + + The harmonic index is commonly used in mathematical chemistry and network science + to measure structural properties of molecular and network graphs. + + Parameters + ---------- + G : nx.Graph + The graph. + + Returns + ------- + float + The harmonic index of the graph. + + Examples + -------- + >>> import graphcalc as gc + >>> from graphcalc.generators import path_graph, complete_graph + + >>> G = path_graph(4) # Path graph with 4 vertices + >>> gc.harmonic_index(G) + 1.8333333333333333 + + >>> H = complete_graph(3) # Complete graph with 3 vertices + >>> gc.harmonic_index(H) + 1.5 + + Notes + ----- + - The harmonic index assumes all edge weights are equal to 1. If you want + to consider weighted graphs, modify the function to account for edge weights. + - The harmonic index is symmetric with respect to the graph's structure, making + it invariant under graph isomorphism. + + References + ---------- + S. Klavžar and I. Gutman, A comparison of the Schultz molecular topological + index and the Wiener index. *Journal of Chemical Information and Computer Sciences*, + 33(6), 1006-1009 (1993). + """ + return 2*sum((1/(gc.degree(G, v) + gc.degree(G, u)) for u, v in G.edges())) diff --git a/src/graphcalc/invariants/local_invariants.py b/src/graphcalc/invariants/local_invariants.py index 2215744..ec5d743 100644 --- a/src/graphcalc/invariants/local_invariants.py +++ b/src/graphcalc/invariants/local_invariants.py @@ -11,6 +11,7 @@ "local_zero_forcing_number", "local_residue", "local_harmonic_index", + "local_annihilation_number", ] # ============================================================ @@ -26,69 +27,57 @@ def local_parameter(G, f, *, neighborhood="open", agg="max"): evaluates a graph parameter :math:`f` on that induced subgraph, and then aggregates the local values over all vertices. - Formally, let + Formally, define .. math:: - S_v \;=\; \begin{cases} N(v), & \text{if neighborhood = 'open'},\\ N[v], & \text{if neighborhood = 'closed'}. \end{cases} - This function computes: + This function computes .. math:: - \operatorname{Agg}_{v\in V(G)}\; f\!\left(G[S_v]\right), - where :math:`G[S_v]` is the induced subgraph on :math:`S_v`, and :math:`\operatorname{Agg}` - is one of ``max``, ``min``, ``sum``, or the arithmetic mean ``avg``. + where :math:`G[S_v]` is the induced subgraph on :math:`S_v` and :math:`\operatorname{Agg}` is one + of ``max``, ``min``, ``sum``, or the arithmetic mean ``avg``. Parameters ---------- G : networkx.Graph-like A finite graph. Neighborhoods are defined using ``G.neighbors(v)``, so this is primarily - intended for **undirected graphs** under the standard adjacency notion. If ``G`` is a - directed graph, NetworkX defines ``neighbors`` as successors, which generally yields a - different “out-neighborhood” notion. + intended for **undirected graphs**. For directed graphs, NetworkX treats ``neighbors`` as + successors, yielding an out-neighborhood variant. f : callable - A function that accepts a graph ``H`` (a NetworkX graph) and returns a numeric value. + A function that accepts a graph ``H`` and returns a numeric value. + Typical examples include invariant/parameter functions such as ``order``, ``size``, - independence number, clique number, domination number, zero forcing number, etc. + ``independence_number``, ``clique_number``, ``domination_number``, and + ``zero_forcing_number``. This implementation calls ``f`` on a **copy** of each induced subgraph to guard against accidental mutation inside ``f``. - neighborhood : {'open', 'closed'}, optional - Which neighborhood to use for the local induced subgraphs: - - ``'open'``: :math:`S_v = N(v)` (neighbors only) - - ``'closed'``: :math:`S_v = N[v]` (neighbors plus the vertex itself) - agg : {'max', 'min', 'sum', 'avg'}, optional - Aggregation operator applied to the multiset of local values - :math:`\{ f(G[S_v]) : v\in V(G)\}`. + neighborhood : {'open', 'closed'}, default='open' + Which neighborhood to use. + + ``'open'`` means :math:`S_v = N(v)` (neighbors only). + + ``'closed'`` means :math:`S_v = N[v]` (neighbors plus the vertex itself). + agg : {'max', 'min', 'sum', 'avg'}, default='max' + Aggregation operator applied to the multiset :math:`\{ f(G[S_v]) : v \in V(G)\}`. Returns ------- number - The aggregated value. If :math:`|V(G)| = 0`, returns 0. + The aggregated value. If :math:`|V(G)|=0`, returns ``0``. Notes ----- - - If :math:`v` is isolated, then :math:`N(v)=\varnothing`. In that case: - * with ``neighborhood='open'`` the induced graph is empty, ``G[∅]``; - * with ``neighborhood='closed'`` the induced graph is the single-vertex graph, ``G[{v}]``. - Ensure your parameter function ``f`` is defined on these graphs. - - The induced subgraphs are formed independently for each vertex; overlapping neighborhoods - are allowed and expected. - - If ``f`` is guaranteed to be pure/read-only, you may remove the ``.copy()`` calls for speed. - - Complexity - ---------- - This constructs one induced subgraph per vertex and evaluates ``f`` on each. The total runtime - is dominated by: - - the cost of forming induced subgraphs on :math:`N(v)` or :math:`N[v]`, and - - the cost of evaluating ``f`` on each such subgraph. - In symbols, the cost is roughly :math:`\sum_{v\in V(G)} T_f(|S_v|, |E(G[S_v])|)` plus overhead. + - If :math:`v` is isolated, then :math:`N(v)=\varnothing`. In that case, with ``neighborhood='open'`` the induced graph is empty, and with ``neighborhood='closed'`` the induced graph is a single vertex. Ensure your parameter function ``f`` is defined on these graphs. + - The induced subgraphs are formed independently for each vertex; overlapping neighborhoods are allowed. + - If ``f`` is guaranteed to be read-only, you may remove the ``.copy()`` calls for speed. Raises ------ @@ -101,13 +90,12 @@ def local_parameter(G, f, *, neighborhood="open", agg="max"): >>> import networkx as nx >>> import graphcalc as gc >>> G = nx.path_graph(5) - >>> # Max degree inside open neighborhoods: each open neighborhood is an induced subgraph >>> gc.local_parameter(G, gc.maximum_degree, neighborhood="open", agg="max") 0 - >>> # Max order of closed neighborhoods: max |N[v]| over v - >>> local_parameter(G, gc.order, neighborhood="closed", agg="max") + >>> gc.local_parameter(G, gc.order, neighborhood="closed", agg="max") 3 """ + n = gc.order(G) if n == 0: return 0 @@ -701,3 +689,68 @@ def local_chromatic_number(G): 1 """ return local_parameter(G, gc.chromatic_number, neighborhood="open", agg="max") + +def local_annihilation_number(G): + r""" + Compute the **local annihilation number** of a graph :math:`G` + (with respect to open neighborhoods). + + Let :math:`a(H)` denote the **annihilation number** of a graph :math:`H` (as implemented by + :func:`graphcalc.annihilation_number`). The **local annihilation number** is the maximum + annihilation number attained by an open-neighborhood induced subgraph: + + .. math:: + a_{\mathrm{loc}}(G) \;=\; \max_{v \in V(G)} a\!\bigl(G[N(v)]\bigr), + + where :math:`N(v)` is the **open neighborhood** of :math:`v` and :math:`G[N(v)]` is the subgraph + induced by :math:`N(v)`. + + Parameters + ---------- + G : networkx.Graph-like + A finite graph. Neighborhoods are computed via ``G.neighbors(v)``, so this is primarily + intended for **undirected graphs**. For directed graphs, NetworkX interprets + ``neighbors`` as successors, yielding an out-neighborhood variant. + + Returns + ------- + int + The local annihilation number :math:`a_{\mathrm{loc}}(G)`. If :math:`G` has no vertices, + returns 0. + + Notes + ----- + - If :math:`v` is isolated, then :math:`N(v)=\varnothing` and :math:`G[N(v)]` is the empty graph. + The value contributed by such a vertex is :math:`a(\varnothing)` under the convention used by + :func:`graphcalc.annihilation_number` on the empty graph. (This only matters when :math:`G` + has isolated vertices.) + - This function is a “local” refinement: it measures how large the annihilation number can be + inside a vertex neighborhood, rather than on :math:`G` itself. + - Implementation: this is a thin wrapper around :func:`local_parameter` with + ``gc.annihilation_number``, ``neighborhood="open"``, and ``agg="max"``. + + Complexity + ---------- + Runtime is dominated by computing :func:`graphcalc.annihilation_number` on each neighborhood-induced + subgraph. + + Examples + -------- + Complete graphs: :math:`N(v)` induces :math:`K_{n-1}`. + + >>> import networkx as nx + >>> import graphcalc as gc + >>> K6 = nx.complete_graph(6) + >>> gc.local_annihilation_number(K6) == gc.annihilation_number(nx.complete_graph(5)) + True + + Bipartite graphs: each open neighborhood induces an independent set (no edges), so each + neighborhood subgraph is edgeless. + + >>> import networkx as nx + >>> import graphcalc as gc + >>> P6 = nx.path_graph(6) + >>> gc.local_annihilation_number(P6) == gc.annihilation_number(nx.empty_graph(2)) + True + """ + return local_parameter(G, gc.annihilation_number, neighborhood="open", agg="max") diff --git a/src/graphcalc/invariants/spectral.py b/src/graphcalc/invariants/spectral.py index 2eb484d..b53ea7a 100644 --- a/src/graphcalc/invariants/spectral.py +++ b/src/graphcalc/invariants/spectral.py @@ -142,28 +142,32 @@ def adjacency_eigenvalues(G: GraphLike) -> float: True """ A = nx.to_numpy_array(G, dtype=int) # Adjacency matrix - eigenvals = np.linalg.eigvals(A) + eigenvals = np.linalg.eigvalsh(A) return np.sort(eigenvals) @enforce_type(0, (nx.Graph, gc.SimpleGraph)) -def laplacian_eigenvalues(G: GraphLike) -> float: +def laplacian_eigenvalues(G: GraphLike): # ideally -> np.ndarray r""" - Compute the eigenvalues of the Laplacian matrix of a graph. + Compute the eigenvalues of the (combinatorial) Laplacian matrix of a graph. - For a graph :math:`G=(V,E)` with Laplacian matrix - :math:`L(G) = D(G) - A(G)`, the **Laplacian eigenvalues** - are the roots of the characteristic polynomial + For a graph :math:`G=(V,E)`, the (combinatorial) Laplacian is + + .. math:: + L(G) \;=\; D(G) - A(G), + + where :math:`A(G)` is the adjacency matrix and :math:`D(G)` is the diagonal matrix of + vertex degrees. The **Laplacian eigenvalues** are the eigenvalues of :math:`L(G)`, + equivalently the values :math:`\lambda` satisfying .. math:: \det(\lambda I - L(G)) = 0. - These eigenvalues are always nonnegative and play a central role - in spectral graph theory. In particular: - * The multiplicity of 0 equals the number of connected components. - * The second-smallest eigenvalue (the **algebraic connectivity**) - measures how well the graph is connected. - * The largest eigenvalue provides bounds on graph invariants - such as the diameter. + Laplacian eigenvalues are real and nonnegative and play a central role in spectral + graph theory. In particular: + + - The multiplicity of 0 equals the number of connected components of :math:`G`. + - The second-smallest eigenvalue is the **algebraic connectivity**. + - The largest eigenvalue provides bounds for various graph invariants. Parameters ---------- @@ -173,7 +177,7 @@ def laplacian_eigenvalues(G: GraphLike) -> float: Returns ------- numpy.ndarray - The sorted eigenvalues of the Laplacian matrix :math:`L(G)`. + The Laplacian eigenvalues of :math:`L(G)`, sorted in nondecreasing order. Examples -------- @@ -183,9 +187,20 @@ def laplacian_eigenvalues(G: GraphLike) -> float: >>> G = cycle_graph(4) >>> np.allclose(gc.laplacian_eigenvalues(G), np.array([0., 2., 2., 4.])) True + + The number of zero eigenvalues equals the number of connected components: + + >>> import numpy as np + >>> import networkx as nx + >>> import graphcalc as gc + >>> H = nx.disjoint_union(nx.path_graph(3), nx.path_graph(2)) # 2 components + >>> eigs = gc.laplacian_eigenvalues(H) + >>> int(np.sum(np.isclose(eigs, 0.0))) + 2 """ + L = laplacian_matrix(G) - eigenvals = np.linalg.eigvals(L) + eigenvals = np.linalg.eigvalsh(L) return np.sort(eigenvals) @enforce_type(0, (nx.Graph, gc.SimpleGraph)) @@ -329,19 +344,17 @@ def zero_adjacency_eigenvalues_count(G: GraphLike) -> int: r""" Count the number of zero eigenvalues of the adjacency matrix. - For a graph :math:`G = (V,E)` with adjacency matrix :math:`A(G)`, - this function returns the multiplicity of the eigenvalue :math:`0` in the spectrum - of :math:`A(G)`: + For a graph :math:`G=(V,E)` with adjacency matrix :math:`A(G)`, this function returns + the multiplicity of the eigenvalue :math:`0` in the spectrum of :math:`A(G)`: .. math:: - m_0(G) = |\{ i : \lambda_i(A(G)) = 0 \}|. + m_0(G) \;=\; \bigl|\{\, i : \lambda_i(A(G)) = 0 \,\}\bigr|. Properties ---------- - * :math:`m_0(G)` is the **nullity** of the adjacency matrix. - * Closely related to the **rank**: - .. math:: \mathrm{rank}(A(G)) = |V| - m_0(G). - * In some cases, reflects structural redundancy and graph symmetry. + - :math:`m_0(G)` is the **nullity** of the adjacency matrix :math:`A(G)`. + - It is related to rank by :math:`\mathrm{rank}(A(G)) = |V(G)| - m_0(G)`. + - In many families of graphs, the nullity reflects structural redundancy. Parameters ---------- @@ -351,7 +364,7 @@ def zero_adjacency_eigenvalues_count(G: GraphLike) -> int: Returns ------- int - The multiplicity of the zero eigenvalue of the adjacency matrix. + The multiplicity of the zero eigenvalue of :math:`A(G)`. Examples -------- diff --git a/src/graphcalc/invariants/transversal_invariants.py b/src/graphcalc/invariants/transversal_invariants.py new file mode 100644 index 0000000..fb73fd7 --- /dev/null +++ b/src/graphcalc/invariants/transversal_invariants.py @@ -0,0 +1,262 @@ +import itertools +import networkx as nx +import graphcalc as gc + +__all__ = [ + "maximal_clique_transversal_number", + "maximal_independent_set_transversal_number", + "minimal_dominating_set_transversal_number", +] + +def maximal_clique_transversal_number(G): + r""" + Compute the maximal-clique transversal number of :math:`G`. + + A **maximal-clique transversal** is a set :math:`S \subseteq V(G)` that intersects + every *maximal* clique of :math:`G`. Equivalently, if :math:`\mathcal{C}` is the + family of maximal cliques of :math:`G`, then :math:`S` is feasible iff + :math:`S \cap C \neq \varnothing` for all :math:`C \in \mathcal{C}`. + + This function returns the minimum possible size: + :math:`\tau_{\max\mathrm{clique}}(G) = \min\{ |S| : S \cap C \neq \varnothing \;\; \forall C \in \mathcal{C}\}`. + + Parameters + ---------- + G : networkx.Graph + The input graph (intended for finite simple undirected graphs). + + Notes + ----- + This is an exact brute-force hitting-set computation: + + 1. Enumerate maximal cliques using :func:`networkx.find_cliques`. + 2. Search subsets :math:`S \subseteq V(G)` in increasing cardinality until one intersects every maximal clique. + + Conventions: + - If :math:`|V(G)| = 0`, returns ``0``. + - For an edgeless graph on :math:`n` vertices, returns :math:`n` (each vertex is a maximal clique). + + Complexity can be exponential in :math:`n` (both the number of maximal cliques and + the subset search). Intended only for small graphs. + + Returns + ------- + int + The minimum size of a vertex set that meets every maximal clique of :math:`G`. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.complete_graph(4) + >>> gc.maximal_clique_transversal_number(G) + 1 + >>> H = nx.empty_graph(5) + >>> gc.maximal_clique_transversal_number(H) + 5 + """ + n = G.number_of_nodes() + if n == 0: + return 0 + + maximal_cliques = [set(C) for C in nx.find_cliques(G)] + # For simple graphs with n>0, this list is nonempty (at least singleton cliques). + if not maximal_cliques: + return 0 + + nodes = list(G.nodes()) + for k in range(0, n + 1): + for S in itertools.combinations(nodes, k): + Sset = set(S) + if all(Sset & C for C in maximal_cliques): + return k + + # Fallback: always possible with S = V(G) + return n + + +def maximal_independent_set_transversal_number(G): + r""" + Compute the maximal-independent-set transversal number of :math:`G`. + + A set :math:`S \subseteq V(G)` is a transversal of maximal independent sets if it + intersects every *maximal* independent set :math:`I` of :math:`G`, i.e., + :math:`S \cap I \neq \varnothing` for all maximal independent sets :math:`I`. + + This function returns the minimum possible size of such a set. + + Notes + ----- + Maximal independent sets of :math:`G` are exactly maximal cliques of the complement + graph :math:`\overline{G}`. Therefore, + :math:`\tau_{\max\mathrm{ind}}(G) = \tau_{\max\mathrm{clique}}(\overline{G})`, + and this function delegates to + ``maximal_clique_transversal_number(nx.complement(G))``. + + Conventions: + - If :math:`|V(G)| = 0`, returns ``0``. + - For a complete graph :math:`K_n`, every maximal independent set is a singleton, so the answer is :math:`n`. + + Parameters + ---------- + G : networkx.Graph + The input graph (intended for finite simple undirected graphs). + + Returns + ------- + int + The minimum size of a vertex set intersecting every maximal independent set of :math:`G`. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.complete_graph(4) + >>> gc.maximal_independent_set_transversal_number(G) + 4 + >>> H = nx.empty_graph(5) + >>> gc.maximal_independent_set_transversal_number(H) + 1 + """ + if G.number_of_nodes() == 0: + return 0 + H = nx.complement(G) + return maximal_clique_transversal_number(H) + +def _is_minimal_dominating_set(G, S): + r""" + Test whether :math:`S` is an inclusion-minimal dominating set of :math:`G`. + + A set :math:`S \subseteq V(G)` is **inclusion-minimal dominating** if: + + - :math:`S` is a dominating set, and + - no proper subset of :math:`S` is a dominating set. + + Parameters + ---------- + G : networkx.Graph + The input graph. + S : iterable + Candidate vertex set. + + Returns + ------- + bool + ``True`` iff :math:`S` is an inclusion-minimal dominating set of :math:`G`. + """ + S = set(S) + if not gc.is_dominating_set(G, S): + return False + # Check minimality by removing one vertex at a time + for u in list(S): + if gc.is_dominating_set(G, S - {u}): + return False + return True + + +def _all_minimal_dominating_sets(G): + r""" + Enumerate all inclusion-minimal dominating sets of :math:`G` (brute force). + + Notes + ----- + This routine checks all subsets of :math:`V(G)` and retains those that are + dominating and inclusion-minimal. It is exponential in :math:`|V(G)|` and is + intended only for very small graphs. + + Parameters + ---------- + G : networkx.Graph + The input graph. + + Returns + ------- + list of set + A list of all inclusion-minimal dominating sets of :math:`G`. + """ + nodes = list(G.nodes()) + n = len(nodes) + mins = [] + + # Enumerate all subsets; collect those that are inclusion-minimal dominating + for k in range(0, n + 1): + for S in itertools.combinations(nodes, k): + if _is_minimal_dominating_set(G, S): + mins.append(set(S)) + return mins + + +def minimal_dominating_set_transversal_number(G, max_n=20): + r""" + Compute the minimal-dominating-set transversal number of :math:`G`. + + Let :math:`\mathcal{D}` be the family of all **inclusion-minimal dominating sets** + of :math:`G`. A set :math:`S \subseteq V(G)` is a transversal (hitting set) for + :math:`\mathcal{D}` if + :math:`S \cap D \neq \varnothing` for all :math:`D \in \mathcal{D}`. + + This function returns the minimum possible size: + :math:`\tau_{\min\mathrm{dom}}(G) = \min\{ |S| : S \cap D \neq \varnothing \;\; \forall D \in \mathcal{D}\}`. + + Parameters + ---------- + G : networkx.Graph + The input graph (intended for finite simple undirected graphs). + max_n : int, default=20 + Safety cutoff on :math:`|V(G)|`. This routine enumerates all inclusion-minimal + dominating sets and then solves a hitting-set problem over :math:`V(G)`, both + of which can be exponential. + + Notes + ----- + Exact brute force: + + 1. Enumerate all inclusion-minimal dominating sets :math:`\mathcal{D}`. + 2. Search subsets :math:`S \subseteq V(G)` in increasing cardinality until one intersects every :math:`D \in \mathcal{D}`. + + Conventions: + - If :math:`|V(G)| = 0`, returns ``0``. + - If :math:`|V(G)| > 0`, then :math:`\mathcal{D}` is nonempty, so the answer is at least ``1``. + + Returns + ------- + int + The minimum size of a vertex set intersecting every inclusion-minimal dominating set of :math:`G`. + + Raises + ------ + ValueError + If :math:`|V(G)| > \texttt{max_n}`. + + Examples + -------- + >>> import networkx as nx + >>> import graphcalc as gc + >>> G = nx.complete_graph(4) + >>> gc.minimal_dominating_set_transversal_number(G) + 4 + >>> H = nx.star_graph(5) + >>> gc.minimal_dominating_set_transversal_number(H) + 2 + """ + n = G.number_of_nodes() + if n == 0: + return 0 + if n > max_n: + raise ValueError( + f"minimal_dominating_set_transversal_number brute force intended for n <= {max_n}, got n={n}" + ) + + minimal_dom_sets = _all_minimal_dominating_sets(G) + # For n>0 this should be nonempty; keep a guard anyway. + if not minimal_dom_sets: + return 0 + + nodes = list(G.nodes()) + for k in range(1, n + 1): + for S in itertools.combinations(nodes, k): + Sset = set(S) + if all(Sset & D for D in minimal_dom_sets): + return k + + return n diff --git a/src/graphcalc/invariants/zero_forcing.py b/src/graphcalc/invariants/zero_forcing.py index cde7251..cd705e8 100644 --- a/src/graphcalc/invariants/zero_forcing.py +++ b/src/graphcalc/invariants/zero_forcing.py @@ -1389,59 +1389,74 @@ def burning_number( **solver_kwargs, # accepted but consumed by @with_solver ) -> int | Tuple[int, List[Hashable]]: r""" - Compute the graph burning number :math:`b(G)` using a Mixed Integer Program (MIP). - - Decision variables - ------------------ - - :math:`x_{v,t} \in \{0,1\}` indicates vertex :math:`v` is chosen as a new fire - source at round :math:`t`. - - Constraints - ----------- - - Exactly one ignition per round: - :math:`\sum_{v \in V} x_{v,t} = 1 \ \ \forall t=1,\dots,T`. - - Coverage at the horizon :math:`T`: - for every vertex :math:`u`, at least one started fire covers it by time :math:`T`: - :math:`\sum_{t=1}^T \sum_{v: \, d(u,v) \le T - t} x_{v,t} \ge 1`. - - Strategy - -------- - Solve a sequence of feasibility MIPs for :math:`T=1,2,\dots,\text{UB}` and return - the smallest feasible :math:`T`. We use: - - lower bound :math:`\max(1, \text{number_of_components}(G))` - - upper bound :math:`\text{radius}(G) + 1` if :math:`G` is connected, otherwise :math:`n`. + Compute the **burning number** :math:`b(G)` using a mixed-integer program (MIP). + + In the graph burning process, one vertex is ignited at each round, and fire spreads + one edge per round from every burning vertex. The **burning number** :math:`b(G)` is + the minimum number of rounds needed to burn all vertices. + + MIP model for a fixed horizon :math:`T` + --------------------------------------- + Let :math:`x_{v,t}\in\{0,1\}` indicate that vertex :math:`v` is chosen as a new ignition + source at round :math:`t` (for :math:`t=1,\dots,T`). + + Ignition constraints (one new source per round): + + .. math:: + \sum_{v\in V} x_{v,t} = 1 \quad \forall t\in\{1,\dots,T\}. + + Coverage constraints (every vertex burned by time :math:`T`): + a vertex :math:`u` is burned by round :math:`T` if there exists an ignition :math:`(v,t)` + with :math:`d(u,v)\le T-t`, where :math:`d` is graph distance. This is enforced by + + .. math:: + \sum_{t=1}^{T}\;\sum_{\substack{v\in V:\\ d(u,v)\le T-t}} x_{v,t} \;\ge\; 1 + \quad \forall u\in V. + + Search strategy + --------------- + We solve a sequence of feasibility MIPs for :math:`T = \mathrm{LB}, \mathrm{LB}+1, \dots` + and return the smallest feasible :math:`T`. + + - Lower bound: :math:`\max(1, \#\text{components}(G))`. + - Upper bound: if :math:`G` is connected, :math:`\mathrm{UB}=\min(n, \mathrm{radius}(G)+1)`; otherwise we use :math:`\mathrm{UB}=n`, which is always valid. Parameters ---------- G : networkx.Graph or graphcalc.SimpleGraph Undirected graph (not necessarily connected). - return_schedule : bool, default False - If True, also return a list ``[v1, ..., vT]`` giving the ignitions. + return_schedule : bool, default=False + If True, also return a list ``[v1, ..., vT]`` giving the ignition vertices in order. Other Parameters ---------------- - solve : callable, injected by :func:`graphcalc.solvers.with_solver` - The unified solve routine (handles HiGHS, Gurobi, CBC, etc.). - **solver_kwargs - Standard kwargs understood by :func:`graphcalc.solvers.with_solver`. + verbose : bool, default=False + solver : str or dict or pulp.LpSolver or type or callable or None, optional + solver_options : dict, optional + Standard solver kwargs accepted by :func:`graphcalc.solvers.with_solver`. Returns ------- - int or (int, list[hashable]) - The burning number :math:`b(G)`, and optionally a minimum ignition schedule. + int or (int, list of hashable) + If ``return_schedule=False``, returns the burning number :math:`b(G)`. + If ``return_schedule=True``, returns ``(b(G), schedule)`` where ``schedule`` is a + minimum-length ignition sequence. + + Notes + ----- + This function requires a MILP-capable solver supported by your :func:`with_solver` + configuration. Examples -------- >>> import networkx as nx - >>> from graphcalc import burning_number - >>> # Example (may require a MILP solver via your with_solver config): + >>> import graphcalc as gc >>> G = nx.path_graph(9) - >>> b, sched = burning_number(G, return_schedule=True) - >>> b - 3 + >>> b, sched = gc.burning_number(G, return_schedule=True) >>> len(sched) == b True """ + H: nx.Graph = G # NetworkX-compatible n = H.number_of_nodes() if n == 0: diff --git a/tests/test_degree.py b/tests/test_degree.py index a25258e..55b4d2f 100644 --- a/tests/test_degree.py +++ b/tests/test_degree.py @@ -17,13 +17,14 @@ sub_total_domination_number, annihilation_number, residue, - harmonic_index, elimination_sequence_from_degrees, k_residue_from_degrees, residue_from_degrees, k_residue, ) +from graphcalc.invariants.graph_indices import harmonic_index + @pytest.mark.parametrize("G, node, expected", [ (complete_graph(4), 0, 3), # Complete graph: degree is n-1 (path_graph(4), 1, 2), # Path graph: middle node degree is 2 From 93720d69ab33e80465ad6810ca2c384afa994f72 Mon Sep 17 00:00:00 2001 From: Randy Davila Date: Tue, 3 Feb 2026 12:34:17 -0600 Subject: [PATCH 3/4] some cleanup was needed --- docs/source/modules/viz.rst | 2 +- src/graphcalc/invariants/classics.py | 7 +++++-- src/graphcalc/invariants/core_invariants.py | 15 +++++++++++++++ src/graphcalc/invariants/cycle_invariants.py | 6 +++--- src/graphcalc/invariants/graph_indices.py | 4 +--- src/graphcalc/viz/__init__.py | 2 +- src/graphcalc/viz/{vertces.py => vertices.py} | 0 7 files changed, 26 insertions(+), 10 deletions(-) rename src/graphcalc/viz/{vertces.py => vertices.py} (100%) diff --git a/docs/source/modules/viz.rst b/docs/source/modules/viz.rst index 52a4844..ad1bc31 100644 --- a/docs/source/modules/viz.rst +++ b/docs/source/modules/viz.rst @@ -11,7 +11,7 @@ Visualization :undoc-members: :show-inheritance: -.. automodule:: graphcalc.viz.vertces +.. automodule:: graphcalc.viz.vertices :members: :undoc-members: :show-inheritance: diff --git a/src/graphcalc/invariants/classics.py b/src/graphcalc/invariants/classics.py index 90df9ba..9b96c6a 100644 --- a/src/graphcalc/invariants/classics.py +++ b/src/graphcalc/invariants/classics.py @@ -3,10 +3,14 @@ import itertools import networkx as nx import math +from dataclasses import dataclass from graphcalc.core import SimpleGraph from graphcalc.utils import ( - get_default_solver, enforce_type, GraphLike, _extract_and_report + get_default_solver, + enforce_type, + GraphLike, + _extract_and_report, ) from graphcalc.solvers import with_solver @@ -1229,7 +1233,6 @@ def violates(k: int) -> bool: hi = mid return lo -from dataclasses import dataclass @dataclass class _DSURollback: diff --git a/src/graphcalc/invariants/core_invariants.py b/src/graphcalc/invariants/core_invariants.py index 9f23aa5..03c83d8 100644 --- a/src/graphcalc/invariants/core_invariants.py +++ b/src/graphcalc/invariants/core_invariants.py @@ -6,6 +6,21 @@ import networkx as nx import graphcalc as gc +__all__ = [ + "core_set_minimum", + "core_number_minimum", + "core_set_maximum_fast", + "core_number_maximum_fast", + "alpha_core_set", + "alpha_core_number", + "clique_core_set", + "clique_core_number", + "domination_core_set", + "domination_core_number", + "zero_forcing_core_set", + "zero_forcing_core_number", +] + def core_set_minimum(G, k_func, is_valid_set): r""" Compute the **minimum core**: the intersection of all optimal (minimum-cardinality) valid sets. diff --git a/src/graphcalc/invariants/cycle_invariants.py b/src/graphcalc/invariants/cycle_invariants.py index 1369ba9..80ddb87 100644 --- a/src/graphcalc/invariants/cycle_invariants.py +++ b/src/graphcalc/invariants/cycle_invariants.py @@ -1,12 +1,10 @@ from __future__ import annotations -from typing import Any, Hashable, Iterable, Optional, Set, Tuple - +from typing import Any, Dict, Hashable, Optional, Set, Tuple import math import itertools import networkx as nx -import graphcalc as gc __all__ = [ "triangle_count", @@ -356,6 +354,7 @@ def odd_girth(G): return 3 return best + def even_girth(G): r""" Compute the even girth of an undirected graph :math:`G` (length of a shortest even cycle). @@ -456,6 +455,7 @@ def even_girth(G): return 4 return best + def circumference(G, max_n=16): r""" Compute the circumference of an undirected graph :math:`G` (length of a longest simple cycle). diff --git a/src/graphcalc/invariants/graph_indices.py b/src/graphcalc/invariants/graph_indices.py index a40bbc2..2d396cd 100644 --- a/src/graphcalc/invariants/graph_indices.py +++ b/src/graphcalc/invariants/graph_indices.py @@ -1,6 +1,4 @@ - -from typing import Hashable, List -from typing import Iterable, List, Sequence, Tuple +from typing import Iterable, Sequence, Tuple import math import networkx as nx diff --git a/src/graphcalc/viz/__init__.py b/src/graphcalc/viz/__init__.py index 1755cb5..ddb2d9d 100644 --- a/src/graphcalc/viz/__init__.py +++ b/src/graphcalc/viz/__init__.py @@ -1,2 +1,2 @@ from graphcalc.viz.edges import * -from graphcalc.viz.vertces import * +from graphcalc.viz.vertices import * diff --git a/src/graphcalc/viz/vertces.py b/src/graphcalc/viz/vertices.py similarity index 100% rename from src/graphcalc/viz/vertces.py rename to src/graphcalc/viz/vertices.py From 088372d8f770f91e68ada0b2d1cd4756c71f5a67 Mon Sep 17 00:00:00 2001 From: Randy Davila Date: Tue, 3 Feb 2026 12:51:08 -0600 Subject: [PATCH 4/4] fixed missing inclusions for modules --- src/graphcalc/invariants/classics.py | 21 ++++++------ src/graphcalc/invariants/degree.py | 41 +++++++++++------------ src/graphcalc/invariants/graph_indices.py | 1 - 3 files changed, 31 insertions(+), 32 deletions(-) diff --git a/src/graphcalc/invariants/classics.py b/src/graphcalc/invariants/classics.py index 9b96c6a..0a2b9b6 100644 --- a/src/graphcalc/invariants/classics.py +++ b/src/graphcalc/invariants/classics.py @@ -34,6 +34,8 @@ "arboricity", "linear_arboricity", "bipartite_number", + "average_distance", + "path_cover_number", ] @enforce_type(0, (nx.Graph, SimpleGraph)) @@ -881,14 +883,15 @@ def average_distance(G): Examples -------- >>> import networkx as nx + >>> import graphcalc as gc >>> # Path on 4 vertices: distances are 1,2,3,1,2,1 (sum 10 over 6 pairs) >>> G = nx.path_graph(4) - >>> average_distance(G) + >>> gc.average_distance(G) 1.6666666666666667 >>> # Disconnected: average over pairs within components only >>> H = nx.disjoint_union(nx.path_graph(3), nx.path_graph(2)) - >>> average_distance(H) + >>> gc.average_distance(H) 1.25 """ n = G.number_of_nodes() @@ -1016,14 +1019,11 @@ def path_cover_number(G, max_n=20): Notes ----- - - This is **not** the standard “minimum path cover” problem for DAGs (which is polynomial-time - via maximum matching). Here the input is an **undirected** graph and the paths must be - vertex-disjoint and cover all vertices; this variant is NP-hard in general. + - This is **not** the standard “minimum path cover” problem for DAGs (which is polynomial-time via maximum matching). Here the input is an **undirected** graph and the paths must be vertex-disjoint and cover all vertices; this variant is NP-hard in general. - Implementation strategy: 1. Enumerate candidate paths by their vertex sets using :func:`_all_simple_paths_vertex_sets`. 2. Backtrack to choose a minimum number of these sets that form a partition of :math:`V(G)`. - The backtracking branches on an uncovered vertex :math:`v` and tries all candidate path-sets - containing :math:`v` that fit inside the remaining uncovered vertices. + - The backtracking branches on an uncovered vertex :math:`v` and tries all candidate path-sets containing :math:`v` that fit inside the remaining uncovered vertices. Complexity ---------- @@ -1034,19 +1034,20 @@ def path_cover_number(G, max_n=20): Examples -------- >>> import networkx as nx + >>> import graphcalc as gc >>> # A path is coverable by a single path >>> G = nx.path_graph(6) - >>> path_cover_number(G) + >>> gc.path_cover_number(G) 1 >>> # An edgeless graph on n vertices needs n singleton paths >>> H = nx.empty_graph(5) - >>> path_cover_number(H) + >>> gc.path_cover_number(H) 5 >>> # Two disjoint paths need two paths in the cover >>> J = nx.disjoint_union(nx.path_graph(3), nx.path_graph(4)) - >>> path_cover_number(J) + >>> gc.path_cover_number(J) 2 """ n = G.number_of_nodes() diff --git a/src/graphcalc/invariants/degree.py b/src/graphcalc/invariants/degree.py index 239d3ba..9c6295b 100644 --- a/src/graphcalc/invariants/degree.py +++ b/src/graphcalc/invariants/degree.py @@ -1,6 +1,6 @@ from typing import Hashable, List -from typing import Iterable, List, Sequence, Tuple +from typing import List, Sequence import networkx as nx import graphcalc as gc @@ -22,6 +22,11 @@ "k_residue_from_degrees", "residue", "k_residue", + "irregularity", + "n1_degree_count", + "distinct_degree_count", + "count_of_maximum_degree_vertices", + "count_of_minimum_degree_vertices", ] @enforce_type(0, (nx.Graph, SimpleGraph)) @@ -427,14 +432,6 @@ def annihilation_number(G: GraphLike) -> int: if sum(D[:i]) <= m: return i - -# If you have these in your package: -# from .simplegraph import SimpleGraph -# from .typing import GraphLike -# from .decorators import enforce_type -# import graphcalc as gc - # replace with your union (nx.Graph | SimpleGraph) - # ────────────────────────────────────────────────────────────────────────────── # Core: elimination sequence from a degree list # ────────────────────────────────────────────────────────────────────────────── @@ -544,8 +541,6 @@ def k_residue_from_degrees(degrees: Sequence[int], k: int) -> int: freq[x] += 1 return int(sum((k - i) * freq[i] for i in range(k))) - - # ────────────────────────────────────────────────────────────────────────────── # Graph wrappers that reuse the degree-sequence core # ────────────────────────────────────────────────────────────────────────────── @@ -741,14 +736,15 @@ def irregularity(G): Examples -------- >>> import networkx as nx + >>> import graphcalc as gc >>> # Path P4 has degrees [1,2,2,1]; edge differences are 1,0,1 so irr=2 >>> G = nx.path_graph(4) - >>> irregularity(G) + >>> gc.irregularity(G) 2 >>> # Any regular graph has irr=0 >>> H = nx.cycle_graph(6) - >>> irregularity(H) + >>> gc.irregularity(H) 0 """ deg = dict(G.degree()) @@ -792,8 +788,9 @@ def n1_degree_count(G): Examples -------- >>> import networkx as nx + >>> import graphcalc as gc >>> G = nx.path_graph(5) # degrees: 1,2,2,2,1 - >>> n1_degree_count(G) + >>> gc.n1_degree_count(G) 2 """ return sum(1 for _, d in G.degree() if d == 1) @@ -839,13 +836,14 @@ def distinct_degree_count(G): Examples -------- >>> import networkx as nx + >>> import graphcalc as gc >>> G = nx.path_graph(5) # degrees: {1,2} - >>> distinct_degree_count(G) + >>> gc.distinct_degree_count(G) 2 >>> H = nx.empty_graph(4) # degrees: {0} - >>> distinct_degree_count(H) + >>> gc.distinct_degree_count(H) 1 - >>> distinct_degree_count(nx.empty_graph(0)) + >>> gc.distinct_degree_count(nx.empty_graph(0)) 0 """ return len({d for _, d in G.degree()}) @@ -888,8 +886,9 @@ def count_of_maximum_degree_vertices(G): Examples -------- >>> import networkx as nx + >>> import graphcalc as gc >>> G = nx.star_graph(5) # center degree 5, leaves degree 1 - >>> count_of_maximum_degree_vertices(G) + >>> gc.count_of_maximum_degree_vertices(G) 1 """ degs = [d for _, d in G.degree()] @@ -937,11 +936,12 @@ def count_of_minimum_degree_vertices(G): Examples -------- >>> import networkx as nx + >>> import graphcalc as gc >>> G = nx.path_graph(5) # min degree is 1, achieved by 2 endpoints - >>> count_of_minimum_degree_vertices(G) + >>> gc.count_of_minimum_degree_vertices(G) 2 >>> H = nx.empty_graph(4) # all degrees 0 - >>> count_of_minimum_degree_vertices(H) + >>> gc.count_of_minimum_degree_vertices(H) 4 """ degs = [d for _, d in G.degree()] @@ -949,4 +949,3 @@ def count_of_minimum_degree_vertices(G): return 0 dmin = min(degs) return sum(1 for d in degs if d == dmin) - diff --git a/src/graphcalc/invariants/graph_indices.py b/src/graphcalc/invariants/graph_indices.py index 2d396cd..9127c96 100644 --- a/src/graphcalc/invariants/graph_indices.py +++ b/src/graphcalc/invariants/graph_indices.py @@ -1,4 +1,3 @@ -from typing import Iterable, Sequence, Tuple import math import networkx as nx