diff --git a/releasenotes/notes/core-number-dense-state-60fb188fd560af1d.yaml b/releasenotes/notes/core-number-dense-state-60fb188fd560af1d.yaml new file mode 100644 index 0000000000..a8cc2f6c07 --- /dev/null +++ b/releasenotes/notes/core-number-dense-state-60fb188fd560af1d.yaml @@ -0,0 +1,8 @@ +other: + - | + Reduced hashing and allocation during :func:`~rustworkx.core_number` and + ``rustworkx_core::connectivity::core_number`` by using dense working storage + for core decomposition. Adjacency uses a contiguous buffer and per-call + markers to remove duplicate neighbors without sorting. Public signatures, + result ordering, and node IDs are unchanged, including when nodes have + been removed from a graph. diff --git a/rustworkx-core/src/connectivity/core_number.rs b/rustworkx-core/src/connectivity/core_number.rs index da336f54fa..70f598043f 100644 --- a/rustworkx-core/src/connectivity/core_number.rs +++ b/rustworkx-core/src/connectivity/core_number.rs @@ -12,12 +12,10 @@ use std::hash::Hash; -use hashbrown::{HashMap, HashSet}; use petgraph::Direction::{Incoming, Outgoing}; use petgraph::visit::{GraphBase, IntoNeighborsDirected, IntoNodeIdentifiers, NodeCount}; -use rayon::prelude::*; -use crate::dictmap::*; +use crate::dictmap::{DictMap, InitWithHasher}; /// Return the core number for each node in the graph. /// @@ -55,56 +53,78 @@ where return DictMap::new(); } - let mut cores: DictMap = DictMap::with_capacity(node_num); - let mut node_vec: Vec = graph.node_identifiers().collect(); - let mut degree_map: HashMap = HashMap::with_capacity(node_num); - let mut nbrs: HashMap> = HashMap::with_capacity(node_num); - let mut node_pos: HashMap = HashMap::with_capacity(node_num); - - for k in node_vec.iter() { - let k_nbrs: HashSet = graph - .neighbors_directed(*k, Incoming) - .chain(graph.neighbors_directed(*k, Outgoing)) - .collect(); - let k_deg = k_nbrs.len(); - - nbrs.insert(*k, k_nbrs); - cores.insert(*k, k_deg); - degree_map.insert(*k, k_deg); - } - node_vec.par_sort_by_key(|k| degree_map.get(k)); - - let mut bin_boundaries: Vec = - Vec::with_capacity(degree_map[&node_vec[node_num - 1]] + 1); - bin_boundaries.push(0); - let mut curr_degree = 0; - for (i, v) in node_vec.iter().enumerate() { - node_pos.insert(*v, i); - let v_degree = degree_map[v]; - if v_degree > curr_degree { - for _ in 0..v_degree - curr_degree { - bin_boundaries.push(i); + // DictMap provides compact working indices without requiring + // NodeIndexable or allocating up to the largest node ID. Keep its insertion + // order unchanged so the returned map still follows node_identifiers(). + let mut cores: DictMap = + graph.node_identifiers().map(|node| (node, 0)).collect(); + // Store adjacency in one buffer. Each row's dense index is a fresh marker, + // so incoming/outgoing duplicates are removed without sorting or hashing + // another set. The sentinel cannot equal a live row index, including zero. + let mut neighbors = Vec::new(); + let mut offsets = Vec::with_capacity(node_num + 1); + let mut seen = vec![usize::MAX; node_num]; + offsets.push(0); + for (row, &node) in cores.keys().enumerate() { + for neighbor in graph + .neighbors_directed(node, Incoming) + .chain(graph.neighbors_directed(node, Outgoing)) + { + let index = cores.get_index_of(&neighbor).unwrap(); + // Match the existing neighbor set, including reciprocal edges. + if seen[index] != row { + seen[index] = row; + neighbors.push(index); } - curr_degree = v_degree; } + offsets.push(neighbors.len()); + } + drop(seen); + let mut degree: Vec<_> = offsets.windows(2).map(|row| row[1] - row[0]).collect(); + let mut bins = vec![0; degree.iter().copied().max().unwrap() + 1]; + for &value in °ree { + bins[value] += 1; + } + let mut start = 0; + for count in &mut bins { + let next = start + *count; + *count = start; + start = next; } - for v_ind in 0..node_vec.len() { - let v = node_vec[v_ind]; - let v_nbrs = nbrs[&v].clone(); - for u in v_nbrs { - if cores[&u] > cores[&v] { - nbrs.get_mut(&u).unwrap().remove(&v); - let pos = node_pos[&u]; - let bin_start = bin_boundaries[cores[&u]]; - *node_pos.get_mut(&u).unwrap() = bin_start; - *node_pos.get_mut(&node_vec[bin_start]).unwrap() = pos; - node_vec.swap(bin_start, pos); - bin_boundaries[cores[&u]] += 1; - *cores.get_mut(&u).unwrap() -= 1; + // Counting sort puts every vertex in its degree bucket. Placement advances + // bins to bucket ends; shifting them restores starts without a second buffer. + let mut positions = vec![0; node_num]; + let mut vertices = vec![0; node_num]; + for (node, &value) in degree.iter().enumerate() { + positions[node] = bins[value]; + vertices[bins[value]] = node; + bins[value] += 1; + } + for value in (1..bins.len()).rev() { + bins[value] = bins[value - 1]; + } + bins[0] = 0; + + for order in 0..node_num { + let node = vertices[order]; + for &neighbor in &neighbors[offsets[node]..offsets[node + 1]] { + if degree[neighbor] > degree[node] { + let neighbor_degree = degree[neighbor]; + let position = positions[neighbor]; + let bucket_start = bins[neighbor_degree]; + let bucket_node = vertices[bucket_start]; + vertices.swap(position, bucket_start); + positions[neighbor] = bucket_start; + positions[bucket_node] = position; + bins[neighbor_degree] += 1; + degree[neighbor] -= 1; } } } + for (value, core) in cores.values_mut().zip(degree) { + *value = core; + } cores } @@ -113,6 +133,199 @@ mod tests { use crate::connectivity::core_number; use petgraph::prelude::*; + // Exercise IDs that cannot be used as dense vector indices. + #[test] + fn test_graph_map_node_ids_and_order() { + let mut graph = DiGraphMap::<&str, ()>::new(); + let nodes = ["isolated", "tail", "z", "a", "middle"]; + for node in nodes { + graph.add_node(node); + } + for (a, b) in [ + ("z", "a"), + ("a", "z"), + ("a", "middle"), + ("middle", "z"), + ("tail", "middle"), + ] { + graph.add_edge(a, b, ()); + } + let result = core_number(&graph); + assert_eq!(result.keys().copied().collect::>(), nodes); + assert_eq!( + result.values().copied().collect::>(), + [0, 1, 2, 2, 2] + ); + } + + #[test] + fn test_deleted_and_reused_node_indices() { + fn check() { + for stride in [2, 1000] { + let mut graph = StableGraph::<(), (), Ty>::default(); + for _ in 0..7 * stride { + graph.add_node(()); + } + for i in 0..7 * stride { + if i % stride != 0 { + graph.remove_node(NodeIndex::new(i)); + } + } + graph.remove_node(NodeIndex::new(stride)); + assert_eq!(graph.add_node(()), NodeIndex::new(stride)); + for (a, b) in [(0, 1), (1, 2), (2, 0), (2, 3), (4, 5)] { + graph.add_edge(NodeIndex::new(a * stride), NodeIndex::new(b * stride), ()); + } + let result = core_number(&graph); + assert_eq!( + result.keys().map(|node| node.index()).collect::>(), + (0..7).map(|i| i * stride).collect::>() + ); + assert_eq!( + result.values().copied().collect::>(), + [2, 2, 2, 1, 1, 1, 0] + ); + } + } + check::(); + check::(); + } + + #[test] + fn test_reciprocal_edges_count_one_neighbor() { + let graph = DiGraph::<(), ()>::from_edges([(0, 1), (1, 0)]); + let result = core_number(&graph); + assert_eq!(result.values().copied().collect::>(), [1, 1]); + } + + // Independent oracle: a node's core number is the largest minimum degree + // among all vertex subsets containing it. No degree buckets or peeling. + fn subset_core_numbers(adjacency: &[Vec]) -> Vec { + let n = adjacency.len(); + let mut result = vec![0; n]; + for subset in 1..1_usize << n { + let degree = (0..n) + .filter(|&a| subset & (1 << a) != 0) + .map(|a| { + (0..n) + .filter(|&b| subset & (1 << b) != 0 && (adjacency[a][b] || adjacency[b][a])) + .count() + }) + .min() + .unwrap(); + for (node, core) in result.iter_mut().enumerate() { + if subset & (1 << node) != 0 { + *core = (*core).max(degree); + } + } + } + result + } + + #[test] + fn test_all_four_node_directed_graphs() { + let edges: Vec<_> = (0..4) + .flat_map(|a| (0..4).filter(move |&b| a != b).map(move |b| (a, b))) + .collect(); + for mask in 0..1_usize << edges.len() { + let mut graph = DiGraph::<(), ()>::new(); + let mut adjacency = vec![vec![false; 4]; 4]; + for _ in 0..4 { + graph.add_node(()); + } + for (bit, &(a, b)) in edges.iter().enumerate() { + if mask & (1 << bit) != 0 { + graph.add_edge(NodeIndex::new(a), NodeIndex::new(b), ()); + adjacency[a][b] = true; + } + } + let expected = subset_core_numbers(&adjacency); + let actual = core_number(&graph); + assert_eq!( + actual.values().copied().collect::>(), + expected, + "edge mask {mask}" + ); + } + } + + // Reuse the same graph through mixed core levels, sparse rows and + // empty adjacency. Working state must belong to one call, not a previous graph. + #[test] + fn test_irregular_graphs_and_repeated_calls() { + fn check() { + let mixed = [ + (0, 1), + (0, 2), + (0, 3), + (1, 2), + (1, 3), + (2, 3), + (3, 4), + (4, 5), + (5, 3), + (5, 6), + ]; + let star = [(0, 1), (0, 2), (0, 3), (0, 4), (0, 5), (0, 6)]; + for stride in [1, 3] { + let mut graph = StableGraph::<(), (), Ty>::default(); + for _ in 0..8 * stride { + graph.add_node(()); + } + for node in 0..8 * stride { + if node % stride != 0 { + graph.remove_node(NodeIndex::new(node)); + } + } + for edges in [&[][..], &mixed[..], &star[..], &mixed[..], &[][..]] { + graph.clear_edges(); + let mut adjacency = vec![vec![false; 8]; 8]; + for &(source, target) in edges { + graph.add_edge( + NodeIndex::new(source * stride), + NodeIndex::new(target * stride), + (), + ); + adjacency[source][target] = true; + if Ty::is_directed() && (source + target) % 2 == 0 { + graph.add_edge( + NodeIndex::new(target * stride), + NodeIndex::new(source * stride), + (), + ); + } + } + let expected = subset_core_numbers(&adjacency); + for _ in 0..2 { + let actual = core_number(&graph); + assert_eq!( + actual.keys().map(|node| node.index()).collect::>(), + (0..8).map(|node| node * stride).collect::>() + ); + assert_eq!(actual.values().copied().collect::>(), expected); + } + } + } + } + check::(); + check::(); + } + + #[test] + fn test_neighbor_set_compatibility() { + // Preserve the existing set-based behavior for unsupported loops and + // parallel edges. In particular, a self-neighbor in row zero occurs once. + let mut graph = DiGraph::<(), ()>::new(); + for _ in 0..4 { + graph.add_node(()); + } + graph.extend_with_edges([(0, 0), (0, 0), (0, 1), (0, 1), (1, 0), (2, 2)]); + for _ in 0..3 { + let actual = core_number(&graph); + assert_eq!(actual.values().copied().collect::>(), [1, 1, 1, 0]); + } + } + #[test] fn test_directed_empty() { let graph = DiGraph::::new(); diff --git a/tests/digraph/test_core_number.py b/tests/digraph/test_core_number.py index 1644b39cca..3f4bb21058 100644 --- a/tests/digraph/test_core_number.py +++ b/tests/digraph/test_core_number.py @@ -94,3 +94,68 @@ def test_directed_paper_example(self): res = rustworkx.core_number(digraph) self.assertIsInstance(res, dict) self.assertEqual(res, self.example_core) + + # Compact working indices must not replace public node IDs. + def test_removed_and_reused_indices(self): + graph = rustworkx.PyDiGraph() + graph.add_nodes_from(range(10)) + graph.remove_nodes_from([1, 3, 5, 7, 9]) + self.assertEqual(graph.add_node("reused"), 9) + self.assertEqual(graph.add_node("isolated"), 7) + graph.add_edges_from_no_data([(0, 2), (2, 4), (4, 0), (4, 6), (8, 9)]) + expected = {0: 2, 2: 2, 4: 2, 6: 1, 7: 0, 8: 1, 9: 1} + for function in (rustworkx.core_number, rustworkx.digraph_core_number): + with self.subTest(function=function.__name__): + result = function(graph) + self.assertEqual(result, expected) + self.assertEqual(list(result), list(expected)) + + def test_reciprocal_edges_count_one_neighbor(self): + graph = rustworkx.PyDiGraph() + graph.extend_from_edge_list([(0, 1), (1, 0)]) + for function in (rustworkx.core_number, rustworkx.digraph_core_number): + with self.subTest(function=function.__name__): + self.assertEqual(function(graph), {0: 1, 1: 1}) + + # Repeated calls include reciprocal arcs and noncompact public IDs. + def test_repeated_calls_after_edge_changes(self): + graph = rustworkx.PyDiGraph() + graph.add_nodes_from(range(16)) + graph.remove_nodes_from(range(1, 16, 2)) + mixed = [ + (0, 2), + (2, 0), + (0, 4), + (0, 6), + (2, 4), + (2, 6), + (4, 6), + (6, 8), + (8, 10), + (10, 6), + (10, 12), + ] + star = [(0, node) for node in range(2, 14, 2)] + for edges, values in [ + ([], [0] * 8), + (mixed, [3, 3, 3, 3, 2, 2, 1, 0]), + (star, [1] * 7 + [0]), + (mixed, [3, 3, 3, 3, 2, 2, 1, 0]), + ([], [0] * 8), + ]: + graph.clear_edges() + graph.add_edges_from_no_data(edges) + expected = dict(zip(range(0, 16, 2), values)) + for function in (rustworkx.core_number, rustworkx.digraph_core_number): + for _ in range(2): + result = function(graph) + self.assertEqual(result, expected) + self.assertEqual(list(result), list(expected)) + + def test_neighbor_set_compatibility(self): + # Retain historical behavior without broadening the documented input contract. + graph = rustworkx.PyDiGraph() + graph.add_nodes_from(range(4)) + graph.add_edges_from_no_data([(0, 0), (0, 0), (0, 1), (0, 1), (1, 0), (2, 2)]) + for function in (rustworkx.core_number, rustworkx.digraph_core_number): + self.assertEqual(function(graph), {0: 1, 1: 1, 2: 1, 3: 0}) diff --git a/tests/graph/test_core_number.py b/tests/graph/test_core_number.py index 8415fb478e..86005799a4 100644 --- a/tests/graph/test_core_number.py +++ b/tests/graph/test_core_number.py @@ -94,3 +94,41 @@ def test_undirected_paper_example(self): res = rustworkx.core_number(graph) self.assertIsInstance(res, dict) self.assertEqual(res, self.example_core) + + # Compact working indices must not replace public node IDs. + def test_removed_and_reused_indices(self): + graph = rustworkx.PyGraph() + graph.add_nodes_from(range(10)) + graph.remove_nodes_from([1, 3, 5, 7, 9]) + self.assertEqual(graph.add_node("reused"), 9) + self.assertEqual(graph.add_node("isolated"), 7) + graph.add_edges_from_no_data([(0, 2), (2, 4), (4, 0), (4, 6), (8, 9)]) + expected = {0: 2, 2: 2, 4: 2, 6: 1, 7: 0, 8: 1, 9: 1} + for function in (rustworkx.core_number, rustworkx.graph_core_number): + with self.subTest(function=function.__name__): + result = function(graph) + self.assertEqual(result, expected) + self.assertEqual(list(result), list(expected)) + + # Per-call work arrays must not retain earlier adjacency or core values. + def test_repeated_calls_after_edge_changes(self): + graph = rustworkx.PyGraph() + graph.add_nodes_from(range(16)) + graph.remove_nodes_from(range(1, 16, 2)) + mixed = [(0, 2), (0, 4), (0, 6), (2, 4), (2, 6), (4, 6), (6, 8), (8, 10), (10, 6), (10, 12)] + star = [(0, node) for node in range(2, 14, 2)] + for edges, values in [ + ([], [0] * 8), + (mixed, [3, 3, 3, 3, 2, 2, 1, 0]), + (star, [1] * 7 + [0]), + (mixed, [3, 3, 3, 3, 2, 2, 1, 0]), + ([], [0] * 8), + ]: + graph.clear_edges() + graph.add_edges_from_no_data(edges) + expected = dict(zip(range(0, 16, 2), values)) + for function in (rustworkx.core_number, rustworkx.graph_core_number): + for _ in range(2): + result = function(graph) + self.assertEqual(result, expected) + self.assertEqual(list(result), list(expected))