Skip to main content

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        let size = 2000u64;
274        let mut edges: Vec<(u64, u64)> = (0..size).map(|i| (i, (i + 1) % size)).collect();
275        edges.extend((0..size).map(|i| (size + 1, i)));
276        let s = Snapshot::of(&linked(&edges));
277        // Every edge of the ring closes with the hub, and the ring has as many
278        // edges as it has nodes.
279        assert_eq!(triangle_count(&s), u64::from(size as u32));
280    }
281
282    #[test]
283    fn it_agrees_with_the_slow_one() {
284        let mut rng = Rng::new(0x7a13);
285        for case in 0..60 {
286            let nodes = 3 + rng.next_u64() % 40;
287            let edges: Vec<(u64, u64)> = (0..nodes * 4)
288                .map(|_| (rng.next_u64() % nodes, rng.next_u64() % nodes))
289                .collect();
290            let s = Snapshot::of(&linked(&edges));
291            assert_eq!(triangle_count(&s), reference(&s), "case {case}");
292        }
293    }
294}