Skip to main content

oxicuda_graphalg/connectivity/
k_core.rs

1//! k-core decomposition via the Batagelj-Zaversnik linear-time bucket method.
2//!
3//! The **k-core** of a graph is the maximal subgraph in which every vertex has
4//! degree at least `k`. The **core number** of a vertex `v` is the largest `k`
5//! such that `v` belongs to the k-core. The **degeneracy** of the graph is the
6//! maximum core number over all vertices (equivalently, the smallest value `d`
7//! such that every subgraph has a vertex of degree `<= d`).
8//!
9//! # Directed vs undirected
10//!
11//! Core numbers are an **undirected** notion. The crate's [`AdjacencyList`] is
12//! directed by default, so this module first materialises the *symmetric*
13//! adjacency (an edge `u -> v` contributes the undirected relation `{u, v}`),
14//! deduplicating parallel edges and ignoring self-loops. As a consequence a
15//! graph built with [`AdjacencyList::add_undirected_edge`] and a graph built
16//! with a single directed edge per pair yield identical core numbers.
17//!
18//! # Algorithm (Batagelj & Zaversnik, 2003), O(V + E)
19//!
20//! Vertices are bucket-sorted by current degree into the `vert` array. The
21//! arrays `bin` (start offset of each degree bucket), `pos` (position of each
22//! vertex inside `vert`), and `vert` (vertices ordered by current degree) are
23//! maintained so that the minimum-degree vertex can be extracted in O(1) and a
24//! neighbour's degree decremented (with its bucket boundary advanced) in O(1).
25//! Processing vertices left-to-right, the degree a vertex has at the moment it
26//! is processed is exactly its core number, and the processing order is a
27//! degeneracy ordering.
28
29use crate::error::GraphalgResult;
30use crate::repr::adjacency_list::AdjacencyList;
31
32/// Result of a full k-core decomposition.
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct KCoreResult {
35    /// `core_number[v]` is the core number of vertex `v`.
36    pub core_number: Vec<usize>,
37    /// The graph degeneracy: `max(core_number)` (`0` for an empty graph).
38    pub degeneracy: usize,
39}
40
41/// Build the symmetric (undirected) adjacency of a directed [`AdjacencyList`].
42///
43/// Parallel edges are collapsed and self-loops dropped, so `sym[u]` is the set
44/// of distinct neighbours of `u` in the underlying undirected graph.
45fn symmetric_adjacency(graph: &AdjacencyList) -> GraphalgResult<Vec<Vec<usize>>> {
46    let n = graph.n;
47    // First gather the raw symmetric neighbour multiset: for every directed edge
48    // u -> v (v != u) record v on u's list AND u on v's list. This makes the
49    // result undirected whether the input stored one or both directions, but it
50    // may contain duplicates (e.g. when both u -> v and v -> u are present).
51    let mut raw: Vec<Vec<usize>> = vec![Vec::new(); n];
52    for u in 0..n {
53        for &v in graph.neighbors(u)? {
54            if v == u {
55                continue; // self-loops do not contribute to undirected degree
56            }
57            raw[u].push(v);
58            raw[v].push(u);
59        }
60    }
61    // Deduplicate each vertex's neighbour list independently, in O(deg), using a
62    // per-vertex marker keyed by the owning vertex id.
63    let mut sym: Vec<Vec<usize>> = vec![Vec::new(); n];
64    let mut seen: Vec<usize> = vec![usize::MAX; n];
65    for u in 0..n {
66        for &v in &raw[u] {
67            if seen[v] != u {
68                seen[v] = u;
69                sym[u].push(v);
70            }
71        }
72    }
73    Ok(sym)
74}
75
76/// Internal Batagelj-Zaversnik core: given the symmetric adjacency, return the
77/// per-vertex core numbers together with the degeneracy (removal) ordering.
78fn batagelj_zaversnik(sym: &[Vec<usize>]) -> (Vec<usize>, Vec<usize>) {
79    let n = sym.len();
80    if n == 0 {
81        return (Vec::new(), Vec::new());
82    }
83
84    // deg[v] = current degree of v (starts at undirected degree).
85    let mut deg: Vec<usize> = sym.iter().map(|adj| adj.len()).collect();
86    let max_deg = deg.iter().copied().max().unwrap_or(0);
87
88    // bin[d] = number of vertices with degree d, later turned into start offsets.
89    let mut bin: Vec<usize> = vec![0usize; max_deg + 1];
90    for &d in &deg {
91        bin[d] += 1;
92    }
93    // Prefix-sum so bin[d] becomes the starting index of bucket d in `vert`.
94    let mut start = 0usize;
95    for d in 0..=max_deg {
96        let count = bin[d];
97        bin[d] = start;
98        start += count;
99    }
100
101    // pos[v] = index of v inside `vert`; vert = vertices ordered by degree.
102    let mut pos: Vec<usize> = vec![0usize; n];
103    let mut vert: Vec<usize> = vec![0usize; n];
104    for v in 0..n {
105        pos[v] = bin[deg[v]];
106        vert[pos[v]] = v;
107        bin[deg[v]] += 1;
108    }
109    // Restore bin to bucket starts (we advanced each by its count above).
110    for d in (1..=max_deg).rev() {
111        bin[d] = bin[d - 1];
112    }
113    bin[0] = 0;
114
115    // Process vertices in increasing current-degree order.
116    let mut core: Vec<usize> = vec![0usize; n];
117    let mut order: Vec<usize> = Vec::with_capacity(n);
118    for i in 0..n {
119        let v = vert[i];
120        core[v] = deg[v];
121        order.push(v);
122        // "Remove" v: every still-later neighbour u with deg[u] > deg[v] is
123        // shifted down one bucket (its degree decreases by one).
124        for &u in &sym[v] {
125            if deg[u] > deg[v] {
126                let du = deg[u];
127                let pu = pos[u];
128                let pw = bin[du];
129                let w = vert[pw];
130                if u != w {
131                    // Swap u to the front of its bucket so we can shrink it.
132                    vert[pu] = w;
133                    vert[pw] = u;
134                    pos[w] = pu;
135                    pos[u] = pw;
136                }
137                // Advance the start of bucket `du`, shrinking it, and move u into
138                // bucket `du - 1`.
139                bin[du] += 1;
140                deg[u] = du - 1;
141            }
142        }
143    }
144
145    (core, order)
146}
147
148/// Compute a full k-core decomposition: per-vertex core numbers and degeneracy.
149///
150/// Runs in O(V + E). An empty graph yields an empty `core_number` vector and a
151/// degeneracy of `0`.
152pub fn k_core_decomposition(graph: &AdjacencyList) -> GraphalgResult<KCoreResult> {
153    let sym = symmetric_adjacency(graph)?;
154    let (core_number, _order) = batagelj_zaversnik(&sym);
155    let degeneracy = core_number.iter().copied().max().unwrap_or(0);
156    Ok(KCoreResult {
157        core_number,
158        degeneracy,
159    })
160}
161
162/// Return only the per-vertex core numbers (`core_number[v]`).
163pub fn core_numbers(graph: &AdjacencyList) -> GraphalgResult<Vec<usize>> {
164    Ok(k_core_decomposition(graph)?.core_number)
165}
166
167/// Return the graph degeneracy (maximum core number; `0` for an empty graph).
168pub fn degeneracy(graph: &AdjacencyList) -> GraphalgResult<usize> {
169    Ok(k_core_decomposition(graph)?.degeneracy)
170}
171
172/// Return a degeneracy ordering: the vertices in the order they are removed by
173/// the Batagelj-Zaversnik peeling (repeatedly extracting a minimum-degree
174/// vertex). The vector is a permutation of `0..graph.n`.
175pub fn degeneracy_ordering(graph: &AdjacencyList) -> GraphalgResult<Vec<usize>> {
176    let sym = symmetric_adjacency(graph)?;
177    let (_core, order) = batagelj_zaversnik(&sym);
178    Ok(order)
179}
180
181/// Return the vertices of the k-core: all vertices whose core number is `>= k`.
182///
183/// The returned vector is sorted ascending. By definition these vertices induce
184/// the maximal subgraph with minimum degree `>= k`, and the sets are nested:
185/// `k_core_subgraph(k) ⊆ k_core_subgraph(k - 1)`.
186pub fn k_core_subgraph(graph: &AdjacencyList, k: usize) -> GraphalgResult<Vec<usize>> {
187    let core = core_numbers(graph)?;
188    Ok((0..core.len()).filter(|&v| core[v] >= k).collect())
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    /// Build an undirected clique K_n.
196    fn clique(n: usize) -> AdjacencyList {
197        let mut g = AdjacencyList::new(n);
198        for u in 0..n {
199            for v in (u + 1)..n {
200                g.add_undirected_edge(u, v).expect("edge");
201            }
202        }
203        g
204    }
205
206    /// Build an undirected path 0-1-2-...-(n-1).
207    fn path(n: usize) -> AdjacencyList {
208        let mut g = AdjacencyList::new(n);
209        for i in 0..n.saturating_sub(1) {
210            g.add_undirected_edge(i, i + 1).expect("edge");
211        }
212        g
213    }
214
215    /// Build an undirected cycle C_n.
216    fn cycle(n: usize) -> AdjacencyList {
217        let mut g = AdjacencyList::new(n);
218        for i in 0..n {
219            g.add_undirected_edge(i, (i + 1) % n).expect("edge");
220        }
221        g
222    }
223
224    /// Build an undirected star: center 0 with `leaves` leaves.
225    fn star(leaves: usize) -> AdjacencyList {
226        let mut g = AdjacencyList::new(leaves + 1);
227        for leaf in 1..=leaves {
228            g.add_undirected_edge(0, leaf).expect("edge");
229        }
230        g
231    }
232
233    #[test]
234    fn clique_k5_all_core_four() {
235        let g = clique(5);
236        let res = k_core_decomposition(&g).expect("kcore");
237        assert_eq!(res.core_number, vec![4, 4, 4, 4, 4]);
238        assert_eq!(res.degeneracy, 4);
239    }
240
241    #[test]
242    fn path_p5_all_core_one() {
243        let g = path(5);
244        let res = k_core_decomposition(&g).expect("kcore");
245        assert_eq!(res.core_number, vec![1, 1, 1, 1, 1]);
246        assert_eq!(res.degeneracy, 1);
247    }
248
249    #[test]
250    fn cycle_c5_all_core_two() {
251        let g = cycle(5);
252        let res = k_core_decomposition(&g).expect("kcore");
253        assert_eq!(res.core_number, vec![2, 2, 2, 2, 2]);
254        assert_eq!(res.degeneracy, 2);
255    }
256
257    #[test]
258    fn star_all_core_one() {
259        let g = star(4);
260        let res = k_core_decomposition(&g).expect("kcore");
261        assert_eq!(res.core_number, vec![1, 1, 1, 1, 1]);
262        assert_eq!(res.degeneracy, 1);
263    }
264
265    #[test]
266    fn tree_degeneracy_one() {
267        // A small binary-ish tree: 0-1, 0-2, 1-3, 1-4, 2-5.
268        let mut g = AdjacencyList::new(6);
269        for (u, v) in [(0, 1), (0, 2), (1, 3), (1, 4), (2, 5)] {
270            g.add_undirected_edge(u, v).expect("edge");
271        }
272        let res = k_core_decomposition(&g).expect("kcore");
273        assert_eq!(res.degeneracy, 1);
274        assert!(res.core_number.iter().all(|&c| c == 1));
275    }
276
277    #[test]
278    fn triangle_plus_pendant() {
279        // Triangle {0,1,2} plus a pendant edge 3-2.
280        let mut g = AdjacencyList::new(4);
281        g.add_undirected_edge(0, 1).expect("edge");
282        g.add_undirected_edge(1, 2).expect("edge");
283        g.add_undirected_edge(0, 2).expect("edge");
284        g.add_undirected_edge(3, 2).expect("edge");
285        let res = k_core_decomposition(&g).expect("kcore");
286        assert_eq!(res.core_number, vec![2, 2, 2, 1]);
287        assert_eq!(res.degeneracy, 2);
288    }
289
290    #[test]
291    fn directed_single_edge_equals_undirected() {
292        // Graph built with single directed edges must give the same core
293        // numbers as the undirected version (symmetric-adjacency handling).
294        let mut directed = AdjacencyList::new(3);
295        directed.add_edge(0, 1).expect("edge");
296        directed.add_edge(1, 2).expect("edge");
297        directed.add_edge(2, 0).expect("edge");
298        let res = core_numbers(&directed).expect("kcore");
299        assert_eq!(res, vec![2, 2, 2]); // it is C_3, all core 2
300    }
301
302    #[test]
303    fn parallel_edges_deduplicated() {
304        // Adding the same undirected edge twice must not inflate degree.
305        let mut g = AdjacencyList::new(3);
306        g.add_undirected_edge(0, 1).expect("edge");
307        g.add_undirected_edge(0, 1).expect("edge");
308        g.add_undirected_edge(1, 2).expect("edge");
309        let res = core_numbers(&g).expect("kcore");
310        assert_eq!(res, vec![1, 1, 1]);
311    }
312
313    #[test]
314    fn self_loops_ignored() {
315        let mut g = AdjacencyList::new(2);
316        g.add_undirected_edge(0, 0).expect("edge"); // self loop
317        g.add_undirected_edge(0, 1).expect("edge");
318        let res = core_numbers(&g).expect("kcore");
319        assert_eq!(res, vec![1, 1]);
320    }
321
322    #[test]
323    fn k_core_subgraph_nesting() {
324        // Use a graph with a spread of core numbers: triangle + pendant.
325        let mut g = AdjacencyList::new(4);
326        g.add_undirected_edge(0, 1).expect("edge");
327        g.add_undirected_edge(1, 2).expect("edge");
328        g.add_undirected_edge(0, 2).expect("edge");
329        g.add_undirected_edge(3, 2).expect("edge");
330        let degen = degeneracy(&g).expect("degeneracy");
331        for k in 1..=(degen + 1) {
332            let hi = k_core_subgraph(&g, k).expect("subgraph");
333            let lo = k_core_subgraph(&g, k - 1).expect("subgraph");
334            // Every vertex in the k-core is also in the (k-1)-core.
335            for &v in &hi {
336                assert!(lo.contains(&v), "nesting violated at k={k} vertex {v}");
337            }
338        }
339        // Concretely: 2-core is the triangle, 1-core is everything.
340        assert_eq!(k_core_subgraph(&g, 2).expect("sg"), vec![0, 1, 2]);
341        assert_eq!(k_core_subgraph(&g, 1).expect("sg"), vec![0, 1, 2, 3]);
342        assert_eq!(k_core_subgraph(&g, 0).expect("sg"), vec![0, 1, 2, 3]);
343    }
344
345    #[test]
346    fn degeneracy_ordering_is_permutation() {
347        let g = clique(5);
348        let mut order = degeneracy_ordering(&g).expect("order");
349        assert_eq!(order.len(), 5);
350        order.sort_unstable();
351        assert_eq!(order, vec![0, 1, 2, 3, 4]);
352    }
353
354    #[test]
355    fn empty_graph() {
356        let g = AdjacencyList::new(0);
357        let res = k_core_decomposition(&g).expect("kcore");
358        assert!(res.core_number.is_empty());
359        assert_eq!(res.degeneracy, 0);
360        assert!(degeneracy_ordering(&g).expect("order").is_empty());
361        assert!(k_core_subgraph(&g, 1).expect("sg").is_empty());
362    }
363
364    #[test]
365    fn single_vertex() {
366        let g = AdjacencyList::new(1);
367        let res = k_core_decomposition(&g).expect("kcore");
368        assert_eq!(res.core_number, vec![0]);
369        assert_eq!(res.degeneracy, 0);
370        assert_eq!(degeneracy_ordering(&g).expect("order"), vec![0]);
371        assert_eq!(k_core_subgraph(&g, 0).expect("sg"), vec![0]);
372        assert!(k_core_subgraph(&g, 1).expect("sg").is_empty());
373    }
374
375    #[test]
376    fn isolated_vertices_core_zero() {
377        // Edge 0-1 plus two isolated vertices 2, 3.
378        let mut g = AdjacencyList::new(4);
379        g.add_undirected_edge(0, 1).expect("edge");
380        let res = core_numbers(&g).expect("kcore");
381        assert_eq!(res, vec![1, 1, 0, 0]);
382        assert_eq!(degeneracy(&g).expect("d"), 1);
383    }
384
385    #[test]
386    fn complete_bipartite_k33() {
387        // K_{3,3}: parts {0,1,2} and {3,4,5}. Every vertex has degree 3 and the
388        // whole graph is 3-degenerate (it is 3-regular and has no vertex of
389        // degree < 3 in any 3-core... actually K_{3,3} core number is 3).
390        let mut g = AdjacencyList::new(6);
391        for u in 0..3 {
392            for v in 3..6 {
393                g.add_undirected_edge(u, v).expect("edge");
394            }
395        }
396        let res = k_core_decomposition(&g).expect("kcore");
397        assert_eq!(res.core_number, vec![3, 3, 3, 3, 3, 3]);
398        assert_eq!(res.degeneracy, 3);
399    }
400}