yo_graph/algo/triangle.rs
1//! Counting triangles, in the ordered form.
2//!
3//! Schank and Wagner, "Finding, Counting and Listing All Triangles in Large
4//! Graphs", WEA 2005, which is the `forward` algorithm, plus the degree
5//! ordering from Ortmann and Brandes, "Triangle Listing Algorithms: Back from
6//! the Diversion", ALENEX 2014, whose point is that most of the published
7//! variants are the same algorithm under different orderings and the ordering is
8//! what decides how fast they are.
9//!
10//! # What is being counted
11//!
12//! Three nodes with an edge between each pair, counted once however many ways
13//! there are to walk it. Direction is ignored, a self loop is not an edge for
14//! this purpose, and two parallel edges between the same pair are one edge. That
15//! is the only definition that makes the answer a property of the graph rather
16//! than of how it happened to be written down, and it is what every published
17//! number is counting, so a number from here can be checked against one from
18//! somewhere else.
19//!
20//! # The ordering is the whole algorithm
21//!
22//! The naive count walks every pair of neighbours of every node and asks whether
23//! they are joined, which counts each triangle six times and spends its whole
24//! life on the highest degree node in the graph.
25//!
26//! The ordered form gives every node a rank, and only ever looks from a node to
27//! the neighbours that outrank it. A triangle then has exactly one lowest ranked
28//! corner and is found exactly once, from there.
29//!
30//! Which way round the rank goes is the whole thing. Rank by degree with the
31//! lowest first, so that every node looks up at the neighbours with more edges
32//! than it has. The hub is then at the top of the order with almost nothing
33//! above it, so its list is nearly empty and the work lands on the nodes that
34//! have three edges each. Rank it the other way and the hub carries a list of
35//! every node it touches and is intersected against all of them, which is
36//! measurably worse than not ordering at all.
37//!
38//! On a graph where every node has about the same degree the ordering does not
39//! matter and the cost is the same either way.
40//!
41//! # Intersecting two sorted lists
42//!
43//! A merge when the two are about the same length, and a binary search of the
44//! long one for each member of the short one when one is more than 32 times the
45//! other. A merge of a 3 element list against a 400 thousand element list reads
46//! all 400 thousand, and looking up three of them costs about sixty loads, so
47//! the switch is worth having and the exact ratio it happens at is not.
48//!
49//! ```
50//! use yo_graph::{Graph, NO_PROPS, Snapshot, algo};
51//!
52//! let mut g = Graph::new();
53//! for (a, b) in [(1u64, 2u64), (2, 3), (3, 1)] {
54//! g.link(a, b, 1, NO_PROPS)?;
55//! }
56//!
57//! assert_eq!(algo::triangle_count(&Snapshot::of(&g)), 1);
58//! # Ok::<(), yo_common::Error>(())
59//! ```
60
61use crate::Snapshot;
62
63/// When one list is this much longer than the other, search it instead of
64/// walking it.
65const SKEW: usize = 32;
66
67/// How many triangles the graph has, reading it as undirected and simple.
68#[must_use]
69pub fn triangle_count(g: &Snapshot) -> u64 {
70 let n = g.nodes() as usize;
71 if n < 3 {
72 return 0;
73 }
74
75 let (at, up) = upward(g);
76 let mut found = 0u64;
77 for node in 0..n {
78 let mine = &up[at[node] as usize..at[node + 1] as usize];
79 for other in mine {
80 let theirs = &up[at[*other as usize] as usize..at[*other as usize + 1] as usize];
81 // Both lists hold ranks above this node, so anything in both is a
82 // node joined to both ends of this edge, which is a triangle.
83 found += common(mine, theirs);
84 }
85 }
86 found
87}
88
89/// The graph as one list per node of the neighbours that outrank it, sorted,
90/// with the lists themselves indexed by rank.
91///
92/// Everything past this point is in rank space rather than dense ids, which
93/// costs a translation here and saves one on every comparison afterwards.
94fn upward(g: &Snapshot) -> (Vec<u64>, Vec<u32>) {
95 let n = g.nodes() as usize;
96
97 // Lowest degree first, and the lower node first when two are the same, so
98 // that the order is the graph's and not the order a hash table happened to
99 // hand its nodes back in. Lowest first is what puts the hubs at the top,
100 // where almost nothing outranks them and their lists come out empty.
101 let mut order: Vec<u32> = (0..n as u32).collect();
102 order.sort_unstable_by_key(|node| (g.out_degree(*node) + g.in_degree(*node), *node));
103 let mut rank = vec![0u32; n];
104 for (at, node) in order.iter().enumerate() {
105 rank[*node as usize] = at as u32;
106 }
107
108 // One list per rank, holding the ranks above it. Built by hand rather than
109 // as a counting sort, because the duplicates that a parallel edge and the
110 // two directions of the same edge produce have to go before the offsets are
111 // worked out, and a count that has to be corrected afterwards is a count
112 // that is done twice.
113 let mut at = vec![0u64; n + 1];
114 let mut up: Vec<u32> = Vec::new();
115 let mut mine: Vec<u32> = Vec::new();
116 for (r, node) in order.iter().enumerate() {
117 mine.clear();
118 for side in [g.out(*node), g.into_(*node)] {
119 for other in side {
120 let other = rank[*other as usize];
121 if other > r as u32 {
122 mine.push(other);
123 }
124 }
125 }
126 mine.sort_unstable();
127 mine.dedup();
128 up.extend_from_slice(&mine);
129 at[r + 1] = up.len() as u64;
130 }
131 (at, up)
132}
133
134/// How many entries two ascending lists have in common.
135fn common(a: &[u32], b: &[u32]) -> u64 {
136 if a.len() > b.len() * SKEW {
137 return search(b, a);
138 }
139 if b.len() > a.len() * SKEW {
140 return search(a, b);
141 }
142 let (mut i, mut j, mut found) = (0, 0, 0);
143 while i < a.len() && j < b.len() {
144 match a[i].cmp(&b[j]) {
145 std::cmp::Ordering::Less => i += 1,
146 std::cmp::Ordering::Greater => j += 1,
147 std::cmp::Ordering::Equal => {
148 found += 1;
149 i += 1;
150 j += 1;
151 }
152 }
153 }
154 found
155}
156
157/// The same answer, for the case where `short` is very much the shorter.
158///
159/// Each step searches only the part of `long` that is left, because both lists
160/// ascend and a member of `short` cannot be behind the one before it.
161fn search(short: &[u32], long: &[u32]) -> u64 {
162 let (mut from, mut found) = (0, 0);
163 for want in short {
164 match long[from..].binary_search(want) {
165 Ok(at) => {
166 found += 1;
167 from += at + 1;
168 }
169 Err(at) => from += at,
170 }
171 if from >= long.len() {
172 break;
173 }
174 }
175 found
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181 use crate::graph::NO_PROPS;
182 use crate::{Graph, Snapshot};
183 use std::collections::BTreeSet;
184 use yo_common::Rng;
185
186 /// Every triple, asked about directly. Cubic and obviously right.
187 fn reference(g: &Snapshot) -> u64 {
188 let n = g.nodes() as usize;
189 let mut near: Vec<BTreeSet<u32>> = vec![BTreeSet::new(); n];
190 for node in 0..n as u32 {
191 for other in g.out(node).iter().chain(g.into_(node)) {
192 if *other != node {
193 near[node as usize].insert(*other);
194 near[*other as usize].insert(node);
195 }
196 }
197 }
198 let mut found = 0;
199 for a in 0..n as u32 {
200 for b in a + 1..n as u32 {
201 if !near[a as usize].contains(&b) {
202 continue;
203 }
204 for c in b + 1..n as u32 {
205 if near[a as usize].contains(&c) && near[b as usize].contains(&c) {
206 found += 1;
207 }
208 }
209 }
210 }
211 found
212 }
213
214 fn linked(edges: &[(u64, u64)]) -> Graph {
215 let mut g = Graph::new();
216 for (from, to) in edges {
217 g.link(*from, *to, 1, NO_PROPS).expect("an edge");
218 }
219 g
220 }
221
222 #[test]
223 fn three_nodes_joined_up_are_one_triangle() {
224 let s = Snapshot::of(&linked(&[(1, 2), (2, 3), (3, 1)]));
225 assert_eq!(triangle_count(&s), 1);
226 }
227
228 #[test]
229 fn a_chain_has_none() {
230 let s = Snapshot::of(&linked(&[(1, 2), (2, 3), (3, 4), (4, 5)]));
231 assert_eq!(triangle_count(&s), 0);
232 }
233
234 /// Five nodes all joined to each other have five choose three triangles.
235 #[test]
236 fn a_complete_graph_has_all_of_them() {
237 let mut edges = Vec::new();
238 for a in 0..5u64 {
239 for b in a + 1..5 {
240 edges.push((a, b));
241 }
242 }
243 assert_eq!(triangle_count(&Snapshot::of(&linked(&edges))), 10);
244 }
245
246 #[test]
247 fn which_way_the_edges_point_makes_no_difference() {
248 let one = Snapshot::of(&linked(&[(1, 2), (2, 3), (3, 1)]));
249 let other = Snapshot::of(&linked(&[(1, 2), (1, 3), (2, 3)]));
250 assert_eq!(triangle_count(&one), triangle_count(&other));
251 }
252
253 #[test]
254 fn a_self_loop_and_a_second_edge_are_not_a_triangle() {
255 let s = Snapshot::of(&linked(&[(1, 1), (1, 2), (2, 1), (2, 2)]));
256 assert_eq!(triangle_count(&s), 0);
257
258 // And they do not turn one triangle into several either.
259 let s = Snapshot::of(&linked(&[(1, 2), (2, 1), (2, 3), (3, 1), (3, 3)]));
260 assert_eq!(triangle_count(&s), 1);
261 }
262
263 #[test]
264 fn too_few_nodes_to_have_one() {
265 assert_eq!(triangle_count(&Snapshot::default()), 0);
266 assert_eq!(triangle_count(&Snapshot::of(&linked(&[(1, 2)]))), 0);
267 }
268
269 /// A hub joined to everything, which is the shape the degree ordering is
270 /// there for, and the shape that takes the skewed intersection path.
271 #[test]
272 fn a_hub_over_a_ring() {
273 // A ring with a hub over it has a triangle for every ring edge at any
274 // size, and the assert counts them, so the size only has to be big
275 // enough to be a ring.
276 let size = if cfg!(miri) { 30u64 } else { 2000 };
277 let mut edges: Vec<(u64, u64)> = (0..size).map(|i| (i, (i + 1) % size)).collect();
278 edges.extend((0..size).map(|i| (size + 1, i)));
279 let s = Snapshot::of(&linked(&edges));
280 // Every edge of the ring closes with the hub, and the ring has as many
281 // edges as it has nodes.
282 assert_eq!(triangle_count(&s), u64::from(size as u32));
283 }
284
285 #[test]
286 fn it_agrees_with_the_slow_one() {
287 let mut rng = Rng::new(0x7a13);
288 // Fewer and smaller cases under Miri. Four edges a node is kept
289 // because a graph too sparse to have a triangle in it would agree with
290 // the slow one about zero and say nothing.
291 let (cases, spread) = if cfg!(miri) { (3, 8) } else { (60, 40) };
292 for case in 0..cases {
293 let nodes = 3 + rng.next_u64() % spread;
294 let edges: Vec<(u64, u64)> = (0..nodes * 4)
295 .map(|_| (rng.next_u64() % nodes, rng.next_u64() % nodes))
296 .collect();
297 let s = Snapshot::of(&linked(&edges));
298 assert_eq!(triangle_count(&s), reference(&s), "case {case}");
299 }
300 }
301}