From 5b50f3f0b5d53deb2e01d5e803c0ecd90490189b Mon Sep 17 00:00:00 2001 From: georgios-ts <45130028+georgios-ts@users.noreply.github.com> Date: Thu, 29 Sep 2022 17:56:28 +0300 Subject: [PATCH] Add Left-Right Planarity test. (#475) * Add Left-Right Planarity test. This commit implements a new function `is_planar` that checks if an undirected graph can be drawn in a plane without any edge intersections. The planarity check algorithm is based on the Left-Right Planarity Test. Reference: http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.217.9208 * fix clippy * fix compiler error * add generalized Petersen graph as test cases * remove old release note * Update release note import * fix release note * `or_insert` can be omitted. * cargo fmt Co-authored-by: Matthew Treinish Co-authored-by: Ivan Carvalho <8753214+IvanIsCoding@users.noreply.github.com> --- docs/source/api.rst | 1 + .../notes/is-planar-58bb8604ae00f1a1.yaml | 12 + rustworkx-core/src/lib.rs | 1 + rustworkx-core/src/planar/lr_planar.rs | 697 ++++++++++++++++++ rustworkx-core/src/planar/mod.rs | 17 + rustworkx-core/tests/planar.rs | 268 +++++++ src/lib.rs | 3 + src/planar/mod.rs | 37 + tests/rustworkx_tests/graph/test_planar.py | 283 +++++++ 9 files changed, 1319 insertions(+) create mode 100644 releasenotes/notes/is-planar-58bb8604ae00f1a1.yaml create mode 100644 rustworkx-core/src/planar/lr_planar.rs create mode 100644 rustworkx-core/src/planar/mod.rs create mode 100644 rustworkx-core/tests/planar.rs create mode 100644 src/planar/mod.rs create mode 100644 tests/rustworkx_tests/graph/test_planar.py diff --git a/docs/source/api.rst b/docs/source/api.rst index db30369de..87f041389 100644 --- a/docs/source/api.rst +++ b/docs/source/api.rst @@ -182,6 +182,7 @@ Other Algorithm Functions rustworkx.core_number rustworkx.graph_greedy_color rustworkx.metric_closure + rustworkx.is_planar .. _generator_funcs: diff --git a/releasenotes/notes/is-planar-58bb8604ae00f1a1.yaml b/releasenotes/notes/is-planar-58bb8604ae00f1a1.yaml new file mode 100644 index 000000000..363fa6546 --- /dev/null +++ b/releasenotes/notes/is-planar-58bb8604ae00f1a1.yaml @@ -0,0 +1,12 @@ +--- +features: + - | + Implements a new function :func:`~rustworkx.is_planar` that + checks whether an undirected :class:`~rustworkx.PyGraph` is planar. + + .. jupyter-execute:: + + import rustworkx as rx + + graph = rx.generators.mesh_graph(5) + print('Is K_5 graph planar?', rx.is_planar(graph)) diff --git a/rustworkx-core/src/lib.rs b/rustworkx-core/src/lib.rs index f15f4c79c..40d839545 100644 --- a/rustworkx-core/src/lib.rs +++ b/rustworkx-core/src/lib.rs @@ -73,6 +73,7 @@ pub mod centrality; pub mod connectivity; /// Module for maximum weight matching algorithms. pub mod max_weight_matching; +pub mod planar; pub mod shortest_path; pub mod traversal; // These modules define additional data structures diff --git a/rustworkx-core/src/planar/lr_planar.rs b/rustworkx-core/src/planar/lr_planar.rs new file mode 100644 index 000000000..c91b50989 --- /dev/null +++ b/rustworkx-core/src/planar/lr_planar.rs @@ -0,0 +1,697 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +use std::cmp::Ordering; +use std::hash::Hash; +use std::vec::IntoIter; + +use hashbrown::{hash_map::Entry, HashMap}; +use petgraph::{ + visit::{ + EdgeCount, EdgeRef, GraphBase, GraphProp, IntoEdges, IntoNodeIdentifiers, NodeCount, + Visitable, + }, + Undirected, +}; + +use crate::traversal::{depth_first_search, DfsEvent}; + +type Edge = (::NodeId, ::NodeId); + +fn modify_if_min(xs: &mut HashMap, key: K, val: V) +where + K: Hash + Eq, + V: Ord + Copy, +{ + xs.entry(key).and_modify(|e| { + if val < *e { + *e = val; + } + }); +} + +fn edges_filtered_and_sorted_by( + graph: G, + a: G::NodeId, + filter: P, + compare: F, +) -> IntoIter> +where + G: IntoEdges, + P: Fn(&Edge) -> bool, + F: Fn(&Edge) -> K, + K: Ord, +{ + let mut edges = graph + .edges(a) + .filter_map(|edge| { + let e = (edge.source(), edge.target()); + if filter(&e) { + Some(e) + } else { + None + } + }) + .collect::>(); + edges.sort_by_key(compare); + // Remove parallel edges since they do *not* affect whether a graph is planar. + edges.dedup(); + edges.into_iter() +} + +fn is_target(edge: Option<&Edge>, v: G::NodeId) -> Option<&Edge> { + edge.filter(|e| e.1 == v) +} + +#[derive(Clone, Copy, PartialEq, PartialOrd)] +struct Interval { + inner: Option<(T, T)>, +} + +impl Default for Interval { + fn default() -> Self { + Interval { inner: None } + } +} + +impl Interval { + fn new(low: T, high: T) -> Self { + Interval { + inner: Some((low, high)), + } + } + + fn is_empty(&self) -> bool { + self.inner.is_none() + } + + fn unwrap(self) -> (T, T) { + self.inner.unwrap() + } + + fn low(&self) -> Option<&T> { + match self.inner { + Some((ref low, _)) => Some(low), + None => None, + } + } + + fn high(&self) -> Option<&T> { + match self.inner { + Some((_, ref high)) => Some(high), + None => None, + } + } + + fn as_ref(&mut self) -> Option<&(T, T)> { + self.inner.as_ref() + } + + fn as_mut(&mut self) -> Option<&mut (T, T)> { + self.inner.as_mut() + } + + fn as_mut_low(&mut self) -> Option<&mut T> { + match self.inner { + Some((ref mut low, _)) => Some(low), + None => None, + } + } +} + +impl Interval<(T, T)> +where + T: Copy + Hash + Eq, +{ + /// Returns ``true`` if the interval conflicts with ``edge``. + fn conflict(&self, lr_state: &LRState, edge: Edge) -> bool + where + G: GraphBase, + { + match self.inner { + Some((_, ref h)) => lr_state.lowpt.get(h) > lr_state.lowpt.get(&edge), + _ => false, + } + } +} + +#[derive(Clone, Copy, PartialEq, PartialOrd)] +struct ConflictPair { + left: Interval, + right: Interval, +} + +impl Default for ConflictPair { + fn default() -> Self { + ConflictPair { + left: Interval::default(), + right: Interval::default(), + } + } +} + +impl ConflictPair { + fn new(left: Interval, right: Interval) -> Self { + ConflictPair { left, right } + } + + fn swap(&mut self) { + std::mem::swap(&mut self.left, &mut self.right) + } + + fn is_empty(&self) -> bool { + self.left.is_empty() && self.right.is_empty() + } +} + +impl ConflictPair<(T, T)> +where + T: Copy + Hash + Eq, +{ + /// Returns the lowest low point of a conflict pair. + fn lowest(&self, lr_state: &LRState) -> usize + where + G: GraphBase, + { + match (self.left.low(), self.right.low()) { + (Some(l_low), Some(r_low)) => lr_state.lowpt[l_low].min(lr_state.lowpt[r_low]), + (Some(l_low), None) => lr_state.lowpt[l_low], + (None, Some(r_low)) => lr_state.lowpt[r_low], + (None, None) => std::usize::MAX, + } + } +} + +enum Sign { + Plus, + Minus, +} + +/// Similar to ``DfsEvent`` plus an extra event ``FinishEdge`` +/// that indicates that we have finished processing an edge. +enum LRTestDfsEvent { + Finish(N), + TreeEdge(N, N), + BackEdge(N, N), + FinishEdge(N, N), +} + +// An error: graph is *not* planar. +struct NonPlanar {} + +struct LRState +where + G::NodeId: Hash + Eq, +{ + graph: G, + /// roots of the DFS forest. + roots: Vec, + /// distnace from root. + height: HashMap, + /// parent edge. + eparent: HashMap>, + /// height of lowest return point. + lowpt: HashMap, usize>, + /// height of next-to-lowest return point. Only used to check if an edge is chordal. + lowpt_2: HashMap, usize>, + /// next back edge in traversal with lowest return point. + lowpt_edge: HashMap, Edge>, + /// proxy for nesting order ≺ given by twice lowpt (plus 1 if chordal). + nesting_depth: HashMap, usize>, + /// stack for conflict pairs. + stack: Vec>>, + /// marks the top conflict pair when an edge was pushed in the stack. + stack_emarker: HashMap, ConflictPair>>, + /// edge relative to which side is defined. + eref: HashMap, Edge>, + /// side of edge, or modifier for side of reference edge. + side: HashMap, Sign>, +} + +impl LRState +where + G: GraphBase + NodeCount + EdgeCount + IntoEdges + Visitable, + G::NodeId: Hash + Eq, +{ + fn new(graph: G) -> Self { + let num_nodes = graph.node_count(); + let num_edges = graph.edge_count(); + + LRState { + graph, + roots: Vec::new(), + height: HashMap::with_capacity(num_nodes), + eparent: HashMap::with_capacity(num_edges), + lowpt: HashMap::with_capacity(num_edges), + lowpt_2: HashMap::with_capacity(num_edges), + lowpt_edge: HashMap::with_capacity(num_edges), + nesting_depth: HashMap::with_capacity(num_edges), + stack: Vec::new(), + stack_emarker: HashMap::with_capacity(num_edges), + eref: HashMap::with_capacity(num_edges), + side: graph + .edge_references() + .map(|e| ((e.source(), e.target()), Sign::Plus)) + .collect(), + } + } + + fn lr_orientation_visitor(&mut self, event: DfsEvent) { + match event { + DfsEvent::Discover(v, _) => { + if let Entry::Vacant(entry) = self.height.entry(v) { + entry.insert(0); + self.roots.push(v); + } + } + DfsEvent::TreeEdge(v, w, _) => { + let ei = (v, w); + let v_height = self.height[&v]; + let w_height = v_height + 1; + + self.eparent.insert(w, ei); + self.height.insert(w, w_height); + // now initialize low points. + self.lowpt.insert(ei, v_height); + self.lowpt_2.insert(ei, w_height); + } + DfsEvent::BackEdge(v, w, _) => { + // do *not* consider ``(v, w)`` as a back edge if ``(w, v)`` is a tree edge. + if Some(&(w, v)) != self.eparent.get(&v) { + let ei = (v, w); + self.lowpt.insert(ei, self.height[&w]); + self.lowpt_2.insert(ei, self.height[&v]); + } + } + DfsEvent::Finish(v, _) => { + for edge in self.graph.edges(v) { + let w = edge.target(); + let ei = (v, w); + + // determine nesting depth. + let low = match self.lowpt.get(&ei) { + Some(val) => *val, + None => + // if ``lowpt`` does *not* contain edge ``(v, w)``, it means + // that it's *not* a tree or a back edge so we skip it since + // it's oriented in the reverse direction. + { + continue + } + }; + + if self.lowpt_2[&ei] < self.height[&v] { + // if it's chordal, add one. + self.nesting_depth.insert(ei, 2 * low + 1); + } else { + self.nesting_depth.insert(ei, 2 * low); + } + + // update lowpoints of parent edge. + if let Some(e_par) = self.eparent.get(&v) { + match self.lowpt[&ei].cmp(&self.lowpt[e_par]) { + Ordering::Less => { + self.lowpt_2 + .insert(*e_par, self.lowpt[e_par].min(self.lowpt_2[&ei])); + self.lowpt.insert(*e_par, self.lowpt[&ei]); + } + Ordering::Greater => { + modify_if_min(&mut self.lowpt_2, *e_par, self.lowpt[&ei]); + } + _ => { + let val = self.lowpt_2[&ei]; + modify_if_min(&mut self.lowpt_2, *e_par, val); + } + } + } + } + } + _ => {} + } + } + + fn lr_testing_visitor(&mut self, event: LRTestDfsEvent) -> Result<(), NonPlanar> { + match event { + LRTestDfsEvent::TreeEdge(v, w) => { + let ei = (v, w); + if let Some(&last) = self.stack.last() { + self.stack_emarker.insert(ei, last); + } + } + LRTestDfsEvent::BackEdge(v, w) => { + let ei = (v, w); + if let Some(&last) = self.stack.last() { + self.stack_emarker.insert(ei, last); + } + self.lowpt_edge.insert(ei, ei); + let c_pair = ConflictPair::new(Interval::default(), Interval::new(ei, ei)); + self.stack.push(c_pair); + } + LRTestDfsEvent::FinishEdge(v, w) => { + let ei = (v, w); + if self.lowpt[&ei] < self.height[&v] { + // ei has return edge + let e_par = self.eparent[&v]; + let val = self.lowpt_edge[&ei]; + + match self.lowpt_edge.entry(e_par) { + Entry::Occupied(_) => { + self.add_constraints(ei, e_par)?; + } + Entry::Vacant(o) => { + o.insert(val); + } + } + } + } + LRTestDfsEvent::Finish(v) => { + if let Some(&e) = self.eparent.get(&v) { + let u = e.0; + self.remove_back_edges(u); + + // side of ``e = (u, v)` is side of a highest return edge + if self.lowpt[&e] < self.height[&u] { + if let Some(top) = self.stack.last() { + let e_high = match (top.left.high(), top.right.high()) { + (Some(hl), Some(hr)) => { + if self.lowpt[hl] > self.lowpt[hr] { + hl + } else { + hr + } + } + (Some(hl), None) => hl, + (None, Some(hr)) => hr, + _ => { + // Otherwise ``top`` would be empty, but we don't push + // empty conflict pairs in stack. + unreachable!() + } + }; + self.eref.insert(e, *e_high); + } + } + } + } + } + + Ok(()) + } + + fn until_top_of_stack_hits_emarker(&mut self, ei: Edge) -> Option>> { + if let Some(&c_pair) = self.stack.last() { + if self.stack_emarker[&ei] != c_pair { + return self.stack.pop(); + } + } + + None + } + + fn until_top_of_stack_is_conflicting(&mut self, ei: Edge) -> Option>> { + if let Some(c_pair) = self.stack.last() { + if c_pair.left.conflict(self, ei) || c_pair.right.conflict(self, ei) { + return self.stack.pop(); + } + } + + None + } + + /// Unify intervals ``pi``, ``qi``. + /// + /// Interval ``qi`` must be non - empty and contain edges + /// with smaller lowpt than interval ``pi``. + fn union_intervals(&mut self, pi: &mut Interval>, qi: Interval>) { + match pi.as_mut_low() { + Some(p_low) => { + let (q_low, q_high) = qi.unwrap(); + self.eref.insert(*p_low, q_high); + *p_low = q_low; + } + None => { + *pi = qi; + } + } + } + + /// Adding constraints associated with edge ``ei``. + fn add_constraints(&mut self, ei: Edge, e: Edge) -> Result<(), NonPlanar> { + let mut c_pair = ConflictPair::>::default(); + + // merge return edges of ei into ``c_pair.right``. + while let Some(mut q_pair) = self.until_top_of_stack_hits_emarker(ei) { + if !q_pair.left.is_empty() { + q_pair.swap(); + + if !q_pair.left.is_empty() { + return Err(NonPlanar {}); + } + } + + // We call unwrap since ``q_pair`` was in stack and + // ``q_pair.right``, ``q_pair.left`` can't be both empty + // since we don't push empty conflict pairs in stack. + let qr_low = q_pair.right.low().unwrap(); + if self.lowpt[qr_low] > self.lowpt[&e] { + // merge intervals + self.union_intervals(&mut c_pair.right, q_pair.right); + } else { + // make consinsent + self.eref.insert(*qr_low, self.lowpt_edge[&e]); + } + } + + // merge conflicting return edges of e1, . . . , ei−1 into ``c_pair.left``. + while let Some(mut q_pair) = self.until_top_of_stack_is_conflicting(ei) { + if q_pair.right.conflict(self, ei) { + q_pair.swap(); + + if q_pair.right.conflict(self, ei) { + return Err(NonPlanar {}); + } + } + + // merge interval below lowpt(ei) into ``c_pair.right``. + if let Some((qr_low, qr_high)) = q_pair.right.as_ref() { + if let Some(pr_low) = c_pair.right.as_mut_low() { + self.eref.insert(*pr_low, *qr_high); + *pr_low = *qr_low; + } + }; + self.union_intervals(&mut c_pair.left, q_pair.left); + } + + if !c_pair.is_empty() { + self.stack.push(c_pair); + } + + Ok(()) + } + + fn until_lowest_top_of_stack_has_height( + &mut self, + v: G::NodeId, + ) -> Option>> { + if let Some(c_pair) = self.stack.last() { + if c_pair.lowest(self) == self.height[&v] { + return self.stack.pop(); + } + } + + None + } + + fn follow_eref_until_is_target(&self, edge: Edge, v: G::NodeId) -> Option> { + let mut res = Some(&edge); + while let Some(b) = is_target::(res, v) { + res = self.eref.get(b); + } + + res.copied() + } + + /// Trim back edges ending at parent v. + fn remove_back_edges(&mut self, v: G::NodeId) { + // drop entire conflict pairs. + while let Some(c_pair) = self.until_lowest_top_of_stack_has_height(v) { + if let Some(pl_low) = c_pair.left.low() { + self.side.insert(*pl_low, Sign::Minus); + } + } + + // one more conflict pair to consider. + if let Some(mut c_pair) = self.stack.pop() { + // trim left interval. + if let Some((pl_low, pl_high)) = c_pair.left.as_mut() { + match self.follow_eref_until_is_target(*pl_high, v) { + Some(val) => { + *pl_high = val; + } + None => { + // just emptied. + // We call unwrap since right interval cannot be empty for otherwise + // the entire conflict pair had been removed. + let pr_low = c_pair.right.low().unwrap(); + self.eref.insert(*pl_low, *pr_low); + self.side.insert(*pl_low, Sign::Minus); + c_pair.left = Interval::default(); + } + } + } + + // trim right interval + if let Some((pr_low, ref mut pr_high)) = c_pair.right.as_mut() { + match self.follow_eref_until_is_target(*pr_high, v) { + Some(val) => { + *pr_high = val; + } + None => { + // just emptied. + // We call unwrap since left interval cannot be empty for otherwise + // the entire conflict pair had been removed. + let pl_low = c_pair.left.low().unwrap(); + self.eref.insert(*pr_low, *pl_low); + self.side.insert(*pr_low, Sign::Minus); + c_pair.right = Interval::default(); + } + }; + } + + if !c_pair.is_empty() { + self.stack.push(c_pair); + } + } + } +} + +/// Visits the DFS - oriented tree that we have pre-computed +/// and stored in ``lr_state``. We traverse the edges of +/// a node in nesting depth order. Events are emitted at points +/// of interest and should be handled by ``visitor``. +fn lr_visit_ordered_dfs_tree( + lr_state: &mut LRState, + v: G::NodeId, + mut visitor: F, +) -> Result<(), E> +where + G: GraphBase + IntoEdges, + G::NodeId: Hash + Eq, + F: FnMut(&mut LRState, LRTestDfsEvent) -> Result<(), E>, +{ + let mut stack: Vec<(G::NodeId, IntoIter>)> = vec![( + v, + edges_filtered_and_sorted_by( + lr_state.graph, + v, + // if ``lowpt`` does *not* contain edge ``e = (v, w)``, it means + // that it's *not* a tree or a back edge so we skip it since + // it's oriented in the reverse direction. + |e| lr_state.lowpt.contains_key(e), + // we sort edges based on nesting depth order. + |e| lr_state.nesting_depth[e], + ), + )]; + + while let Some(elem) = stack.last_mut() { + let v = elem.0; + let adjacent_edges = &mut elem.1; + let mut next = None; + + for (v, w) in adjacent_edges { + if Some(&(v, w)) == lr_state.eparent.get(&w) { + // tree edge + visitor(lr_state, LRTestDfsEvent::TreeEdge(v, w))?; + next = Some(w); + break; + } else { + // back edge + visitor(lr_state, LRTestDfsEvent::BackEdge(v, w))?; + visitor(lr_state, LRTestDfsEvent::FinishEdge(v, w))?; + } + } + + match next { + Some(w) => stack.push(( + w, + edges_filtered_and_sorted_by( + lr_state.graph, + w, + |e| lr_state.lowpt.contains_key(e), + |e| lr_state.nesting_depth[e], + ), + )), + None => { + stack.pop(); + visitor(lr_state, LRTestDfsEvent::Finish(v))?; + if let Some(&(u, v)) = lr_state.eparent.get(&v) { + visitor(lr_state, LRTestDfsEvent::FinishEdge(u, v))?; + } + } + } + } + + Ok(()) +} + +/// Check if an undirected graph is planar. +/// +/// A graph is planar iff it can be drawn in a plane without any edge +/// intersections. +/// +/// The planarity check algorithm is based on the +/// Left-Right Planarity Test: +/// +/// [`Ulrik Brandes: The Left-Right Planarity Test (2009)`](http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.217.9208) +/// +/// # Example: +/// ```rust +/// use rustworkx_core::petgraph::graph::UnGraph; +/// use rustworkx_core::planar::is_planar; +/// +/// let grid = UnGraph::<(), ()>::from_edges(&[ +/// // row edges +/// (0, 1), (1, 2), (3, 4), (4, 5), (6, 7), (7, 8), +/// // col edges +/// (0, 3), (3, 6), (1, 4), (4, 7), (2, 5), (5, 8), +/// ]); +/// assert!(is_planar(&grid)) +/// ``` +pub fn is_planar(graph: G) -> bool +where + G: GraphProp + + NodeCount + + EdgeCount + + IntoEdges + + IntoNodeIdentifiers + + Visitable, + G::NodeId: Hash + Eq, +{ + let mut state = LRState::new(graph); + + // Dfs orientation phase + depth_first_search(graph, graph.node_identifiers(), |event| { + state.lr_orientation_visitor(event) + }); + + // Left - Right partition. + for v in state.roots.clone() { + let res = lr_visit_ordered_dfs_tree(&mut state, v, |state, event| { + state.lr_testing_visitor(event) + }); + if res.is_err() { + return false; + } + } + + true +} diff --git a/rustworkx-core/src/planar/mod.rs b/rustworkx-core/src/planar/mod.rs new file mode 100644 index 000000000..e67dd2775 --- /dev/null +++ b/rustworkx-core/src/planar/mod.rs @@ -0,0 +1,17 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +//! Module for planar graphs. + +mod lr_planar; + +pub use lr_planar::is_planar; diff --git a/rustworkx-core/tests/planar.rs b/rustworkx-core/tests/planar.rs new file mode 100644 index 000000000..d8d064fcf --- /dev/null +++ b/rustworkx-core/tests/planar.rs @@ -0,0 +1,268 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +//! Test module for planar graphs. + +use rustworkx_core::petgraph::graph::UnGraph; +use rustworkx_core::planar::is_planar; + +#[test] +fn test_simple_planar_graph() { + let graph = UnGraph::<(), ()>::from_edges(&[ + (1, 2), + (2, 3), + (3, 4), + (4, 6), + (6, 7), + (7, 1), + (1, 5), + (5, 2), + (2, 4), + (4, 5), + (5, 7), + ]); + let res = is_planar(&graph); + assert!(res) +} + +#[test] +fn test_planar_grid_3_3_graph() { + let graph = UnGraph::<(), ()>::from_edges(&[ + // row edges + (0, 1), + (1, 2), + (3, 4), + (4, 5), + (6, 7), + (7, 8), + // col edges + (0, 3), + (3, 6), + (1, 4), + (4, 7), + (2, 5), + (5, 8), + ]); + let res = is_planar(&graph); + assert!(res) +} + +#[test] +fn test_planar_with_self_loop() { + let graph = UnGraph::<(), ()>::from_edges(&[ + (1, 1), + (2, 2), + (3, 3), + (4, 4), + (5, 5), + (1, 2), + (1, 3), + (1, 5), + (2, 5), + (2, 4), + (3, 4), + (3, 5), + (4, 5), + ]); + let res = is_planar(&graph); + assert!(res) +} + +#[test] +fn test_goldner_harary_planar_graph() { + // test goldner-harary graph (a maximal planar graph) + let graph = UnGraph::<(), ()>::from_edges(&[ + (1, 2), + (1, 3), + (1, 4), + (1, 5), + (1, 7), + (1, 8), + (1, 10), + (1, 11), + (2, 3), + (2, 4), + (2, 6), + (2, 7), + (2, 9), + (2, 10), + (2, 11), + (3, 4), + (4, 5), + (4, 6), + (4, 7), + (5, 7), + (6, 7), + (7, 8), + (7, 9), + (7, 10), + (8, 10), + (9, 10), + (10, 11), + ]); + let res = is_planar(&graph); + assert!(res) +} + +#[test] +fn test_multiple_components_planar_graph() { + let graph = UnGraph::<(), ()>::from_edges(&[(1, 2), (2, 3), (3, 1), (4, 5), (5, 6), (6, 4)]); + let res = is_planar(&graph); + assert!(res) +} + +#[test] +fn test_planar_multi_graph() { + let graph = UnGraph::<(), ()>::from_edges(&[(0, 1), (0, 1), (0, 1), (1, 2), (2, 0)]); + let res = is_planar(&graph); + assert!(res) +} + +#[test] +fn test_k3_3_non_planar() { + let graph = UnGraph::<(), ()>::from_edges(&[ + (0, 3), + (0, 4), + (0, 5), + (1, 3), + (1, 4), + (1, 5), + (2, 3), + (2, 4), + (2, 5), + ]); + let res = is_planar(&graph); + assert_eq!(res, false) +} + +#[test] +fn test_k5_non_planar() { + let graph = UnGraph::<(), ()>::from_edges(&[ + (0, 1), + (0, 2), + (0, 3), + (0, 4), + (1, 2), + (1, 3), + (1, 4), + (2, 3), + (2, 4), + (3, 4), + ]); + let res = is_planar(&graph); + assert_eq!(res, false) +} + +#[test] +fn test_multiple_components_non_planar() { + let graph = UnGraph::<(), ()>::from_edges(&[ + (0, 1), + (0, 2), + (0, 3), + (0, 4), + (1, 2), + (1, 3), + (1, 4), + (2, 3), + (2, 4), + (3, 4), + (6, 7), + (7, 8), + (8, 6), + ]); + let res = is_planar(&graph); + assert_eq!(res, false) +} + +#[test] +fn test_non_planar() { + // tests a graph that has no subgraph directly isomorphic to K5 or K3_3. + let graph = UnGraph::<(), ()>::from_edges(&[ + (1, 5), + (1, 6), + (1, 7), + (2, 6), + (2, 3), + (3, 5), + (3, 7), + (4, 5), + (4, 6), + (4, 7), + ]); + let res = is_planar(&graph); + assert_eq!(res, false) +} + +#[test] +fn test_planar_graph1() { + let graph = UnGraph::<(), ()>::from_edges(&[ + (3, 10), + (2, 13), + (1, 13), + (7, 11), + (0, 8), + (8, 13), + (0, 2), + (0, 7), + (0, 10), + (1, 7), + ]); + let res = is_planar(&graph); + assert!(res) +} + +#[test] +fn test_non_planar_graph2() { + let graph = UnGraph::<(), ()>::from_edges(&[ + (1, 2), + (4, 13), + (0, 13), + (4, 5), + (7, 10), + (1, 7), + (0, 3), + (2, 6), + (5, 6), + (7, 13), + (4, 8), + (0, 8), + (0, 9), + (2, 13), + (6, 7), + (3, 6), + (2, 8), + ]); + let res = is_planar(&graph); + assert_eq!(res, false) +} + +#[test] +fn test_non_planar_graph3() { + let graph = UnGraph::<(), ()>::from_edges(&[ + (0, 7), + (3, 11), + (3, 4), + (8, 9), + (4, 11), + (1, 7), + (1, 13), + (1, 11), + (3, 5), + (5, 7), + (1, 3), + (0, 4), + (5, 11), + (5, 13), + ]); + let res = is_planar(&graph); + assert_eq!(res, false) +} diff --git a/src/lib.rs b/src/lib.rs index a4586c7aa..062122db4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -25,6 +25,7 @@ mod iterators; mod json; mod layout; mod matching; +mod planar; mod random_graph; mod shortest_path; mod steiner_tree; @@ -45,6 +46,7 @@ use isomorphism::*; use json::*; use layout::*; use matching::*; +use planar::*; use random_graph::*; use shortest_path::*; use steiner_tree::*; @@ -469,6 +471,7 @@ fn rustworkx(py: Python<'_>, m: &PyModule) -> PyResult<()> { m.add_wrapped(wrap_pyfunction!(articulation_points))?; m.add_wrapped(wrap_pyfunction!(biconnected_components))?; m.add_wrapped(wrap_pyfunction!(chain_decomposition))?; + m.add_wrapped(wrap_pyfunction!(is_planar))?; m.add_wrapped(wrap_pyfunction!(read_graphml))?; m.add_wrapped(wrap_pyfunction!(digraph_node_link_json))?; m.add_wrapped(wrap_pyfunction!(graph_node_link_json))?; diff --git a/src/planar/mod.rs b/src/planar/mod.rs new file mode 100644 index 000000000..2683c871a --- /dev/null +++ b/src/planar/mod.rs @@ -0,0 +1,37 @@ +// Licensed under the Apache License, Version 2.0 (the "License"); you may +// not use this file except in compliance with the License. You may obtain +// a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +// WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +// License for the specific language governing permissions and limitations +// under the License. + +use crate::graph::PyGraph; +use rustworkx_core::planar; + +use pyo3::prelude::*; + +/// Check if an undirected graph is planar. +/// +/// A graph is planar iff it can be drawn in a plane without any edge +/// intersections. The planarity check algorithm is based on the +/// Left-Right Planarity Test [Brandes]_. +/// +/// :param PyGraph graph: The graph to be used. +/// +/// :returns: Whether the provided graph is planar. +/// :rtype: bool +/// +/// .. [Brandes] Ulrik Brandes: +/// The Left-Right Planarity Test +/// 2009 +/// http://citeseerx.ist.psu.edu/viewdoc/summary?doi=10.1.1.217.9208 +#[pyfunction] +#[pyo3(text_signature = "(graph, /)")] +pub fn is_planar(graph: &PyGraph) -> bool { + planar::is_planar(&graph.graph) +} diff --git a/tests/rustworkx_tests/graph/test_planar.py b/tests/rustworkx_tests/graph/test_planar.py new file mode 100644 index 000000000..92750ad0f --- /dev/null +++ b/tests/rustworkx_tests/graph/test_planar.py @@ -0,0 +1,283 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import unittest + +import itertools +import rustworkx as rx + + +class TestPlanarGraph(unittest.TestCase): + def test_simple_planar_graph(self): + graph = rx.PyGraph() + graph.extend_from_edge_list( + [ + (1, 2), + (2, 3), + (3, 4), + (4, 6), + (6, 7), + (7, 1), + (1, 5), + (5, 2), + (2, 4), + (4, 5), + (5, 7), + ] + ) + res = rx.is_planar(graph) + self.assertTrue(res) + + def test_planar_with_selfloop(self): + graph = rx.PyGraph() + graph.extend_from_edge_list( + [ + (1, 1), + (2, 2), + (3, 3), + (4, 4), + (5, 5), + (1, 2), + (1, 3), + (1, 5), + (2, 5), + (2, 4), + (3, 4), + (3, 5), + (4, 5), + ] + ) + res = rx.is_planar(graph) + self.assertTrue(res) + + def test_grid_graph(self): + graph = rx.generators.grid_graph(5, 5) + res = rx.is_planar(graph) + self.assertTrue(res) + + def test_k3_3(self): + graph = rx.PyGraph() + graph.extend_from_edge_list( + [ + (0, 3), + (0, 4), + (0, 5), + (1, 3), + (1, 4), + (1, 5), + (2, 3), + (2, 4), + (2, 5), + ] + ) + res = rx.is_planar(graph) + self.assertFalse(res) + + def test_k5(self): + graph = rx.generators.mesh_graph(5) + res = rx.is_planar(graph) + self.assertFalse(res) + + def test_multiple_components_planar(self): + graph = rx.PyGraph() + graph.extend_from_edge_list([(0, 1), (1, 2), (2, 0), (3, 4), (4, 5), (5, 3)]) + res = rx.is_planar(graph) + self.assertTrue(res) + + def test_multiple_components_non_planar(self): + graph = rx.generators.mesh_graph(5) + # add another planar component to the non planar component + # G stays non planar + graph.extend_from_edge_list([(6, 7), (7, 8), (8, 6)]) + res = rx.is_planar(graph) + self.assertFalse(res) + + def test_non_planar_with_selfloop(self): + graph = rx.generators.mesh_graph(5) + # add self loops + for i in range(5): + graph.add_edge(i, i, None) + res = rx.is_planar(graph) + self.assertFalse(res) + + def test_non_planar1(self): + # tests a graph that has no subgraph directly isomorph to K5 or K3_3 + graph = rx.PyGraph() + graph.extend_from_edge_list( + [ + (1, 5), + (1, 6), + (1, 7), + (2, 6), + (2, 3), + (3, 5), + (3, 7), + (4, 5), + (4, 6), + (4, 7), + ] + ) + res = rx.is_planar(graph) + self.assertFalse(res) + + def test_loop(self): + # test a graph with a selfloop + graph = rx.PyGraph() + graph.extend_from_edge_list([(0, 1), (1, 1)]) + res = rx.is_planar(graph) + self.assertTrue(res) + + def test_goldner_harary(self): + # test goldner-harary graph (a maximal planar graph) + graph = rx.PyGraph() + graph.extend_from_edge_list( + [ + (1, 2), + (1, 3), + (1, 4), + (1, 5), + (1, 7), + (1, 8), + (1, 10), + (1, 11), + (2, 3), + (2, 4), + (2, 6), + (2, 7), + (2, 9), + (2, 10), + (2, 11), + (3, 4), + (4, 5), + (4, 6), + (4, 7), + (5, 7), + (6, 7), + (7, 8), + (7, 9), + (7, 10), + (8, 10), + (9, 10), + (10, 11), + ] + ) + res = rx.is_planar(graph) + self.assertTrue(res) + + def test_planar_multigraph(self): + graph = rx.PyGraph() + graph.extend_from_edge_list([(1, 2), (1, 2), (1, 2), (1, 2), (2, 3), (3, 1)]) + res = rx.is_planar(graph) + self.assertTrue(res) + + def test_non_planar_multigraph(self): + graph = rx.generators.mesh_graph(5) + graph.add_edges_from_no_data([(1, 2)] * 5) + res = rx.is_planar(graph) + self.assertFalse(res) + + def test_single_component(self): + # Test a graph with only a single node + graph = rx.PyGraph() + graph.add_node(1) + res = rx.is_planar(graph) + self.assertTrue(res) + + def test_graph1(self): + graph = rx.PyGraph() + graph.extend_from_edge_list( + [ + (3, 10), + (2, 13), + (1, 13), + (7, 11), + (0, 8), + (8, 13), + (0, 2), + (0, 7), + (0, 10), + (1, 7), + ] + ) + res = rx.is_planar(graph) + self.assertTrue(res) + + def test_graph2(self): + graph = rx.PyGraph() + graph.extend_from_edge_list( + [ + (1, 2), + (4, 13), + (0, 13), + (4, 5), + (7, 10), + (1, 7), + (0, 3), + (2, 6), + (5, 6), + (7, 13), + (4, 8), + (0, 8), + (0, 9), + (2, 13), + (6, 7), + (3, 6), + (2, 8), + ] + ) + res = rx.is_planar(graph) + self.assertFalse(res) + + def test_graph3(self): + graph = rx.PyGraph() + graph.extend_from_edge_list( + [ + (0, 7), + (3, 11), + (3, 4), + (8, 9), + (4, 11), + (1, 7), + (1, 13), + (1, 11), + (3, 5), + (5, 7), + (1, 3), + (0, 4), + (5, 11), + (5, 13), + ] + ) + res = rx.is_planar(graph) + self.assertFalse(res) + + def test_generalized_petersen_graph_planar_instances(self): + # see Table 2: https://www.sciencedirect.com/science/article/pii/S0166218X08000371 + planars = itertools.chain( + iter((n, 1) for n in range(3, 17)), + iter((n, 2) for n in range(6, 17, 2)), + ) + for (n, k) in planars: + with self.subTest(n=n, k=k): + graph = rx.generators.generalized_petersen_graph(n=n, k=k) + self.assertTrue(rx.is_planar(graph)) + + def test_generalized_petersen_graph_non_planar_instances(self): + # see Table 2: https://www.sciencedirect.com/science/article/pii/S0166218X08000371 + no_planars = itertools.chain( + iter((n, 2) for n in range(5, 17, 2)), + iter((n, k) for k in range(3, 9) for n in range(2 * k + 1, 17)), + ) + for (n, k) in no_planars: + with self.subTest(n=n, k=k): + graph = rx.generators.generalized_petersen_graph(n=n, k=k) + self.assertFalse(rx.is_planar(graph))