chordal.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429
  1. """
  2. Algorithms for chordal graphs.
  3. A graph is chordal if every cycle of length at least 4 has a chord
  4. (an edge joining two nodes not adjacent in the cycle).
  5. https://en.wikipedia.org/wiki/Chordal_graph
  6. """
  7. import sys
  8. import networkx as nx
  9. from networkx.algorithms.components import connected_components
  10. from networkx.utils import arbitrary_element, not_implemented_for
  11. __all__ = [
  12. "is_chordal",
  13. "find_induced_nodes",
  14. "chordal_graph_cliques",
  15. "chordal_graph_treewidth",
  16. "NetworkXTreewidthBoundExceeded",
  17. "complete_to_chordal_graph",
  18. ]
  19. class NetworkXTreewidthBoundExceeded(nx.NetworkXException):
  20. """Exception raised when a treewidth bound has been provided and it has
  21. been exceeded"""
  22. @not_implemented_for("directed")
  23. @not_implemented_for("multigraph")
  24. def is_chordal(G):
  25. """Checks whether G is a chordal graph.
  26. A graph is chordal if every cycle of length at least 4 has a chord
  27. (an edge joining two nodes not adjacent in the cycle).
  28. Parameters
  29. ----------
  30. G : graph
  31. A NetworkX graph.
  32. Returns
  33. -------
  34. chordal : bool
  35. True if G is a chordal graph and False otherwise.
  36. Raises
  37. ------
  38. NetworkXNotImplemented
  39. The algorithm does not support DiGraph, MultiGraph and MultiDiGraph.
  40. Examples
  41. --------
  42. >>> e = [
  43. ... (1, 2),
  44. ... (1, 3),
  45. ... (2, 3),
  46. ... (2, 4),
  47. ... (3, 4),
  48. ... (3, 5),
  49. ... (3, 6),
  50. ... (4, 5),
  51. ... (4, 6),
  52. ... (5, 6),
  53. ... ]
  54. >>> G = nx.Graph(e)
  55. >>> nx.is_chordal(G)
  56. True
  57. Notes
  58. -----
  59. The routine tries to go through every node following maximum cardinality
  60. search. It returns False when it finds that the separator for any node
  61. is not a clique. Based on the algorithms in [1]_.
  62. References
  63. ----------
  64. .. [1] R. E. Tarjan and M. Yannakakis, Simple linear-time algorithms
  65. to test chordality of graphs, test acyclicity of hypergraphs, and
  66. selectively reduce acyclic hypergraphs, SIAM J. Comput., 13 (1984),
  67. pp. 566–579.
  68. """
  69. return len(_find_chordality_breaker(G)) == 0
  70. def find_induced_nodes(G, s, t, treewidth_bound=sys.maxsize):
  71. """Returns the set of induced nodes in the path from s to t.
  72. Parameters
  73. ----------
  74. G : graph
  75. A chordal NetworkX graph
  76. s : node
  77. Source node to look for induced nodes
  78. t : node
  79. Destination node to look for induced nodes
  80. treewidth_bound: float
  81. Maximum treewidth acceptable for the graph H. The search
  82. for induced nodes will end as soon as the treewidth_bound is exceeded.
  83. Returns
  84. -------
  85. induced_nodes : Set of nodes
  86. The set of induced nodes in the path from s to t in G
  87. Raises
  88. ------
  89. NetworkXError
  90. The algorithm does not support DiGraph, MultiGraph and MultiDiGraph.
  91. If the input graph is an instance of one of these classes, a
  92. :exc:`NetworkXError` is raised.
  93. The algorithm can only be applied to chordal graphs. If the input
  94. graph is found to be non-chordal, a :exc:`NetworkXError` is raised.
  95. Examples
  96. --------
  97. >>> G = nx.Graph()
  98. >>> G = nx.generators.classic.path_graph(10)
  99. >>> induced_nodes = nx.find_induced_nodes(G, 1, 9, 2)
  100. >>> sorted(induced_nodes)
  101. [1, 2, 3, 4, 5, 6, 7, 8, 9]
  102. Notes
  103. -----
  104. G must be a chordal graph and (s,t) an edge that is not in G.
  105. If a treewidth_bound is provided, the search for induced nodes will end
  106. as soon as the treewidth_bound is exceeded.
  107. The algorithm is inspired by Algorithm 4 in [1]_.
  108. A formal definition of induced node can also be found on that reference.
  109. References
  110. ----------
  111. .. [1] Learning Bounded Treewidth Bayesian Networks.
  112. Gal Elidan, Stephen Gould; JMLR, 9(Dec):2699--2731, 2008.
  113. http://jmlr.csail.mit.edu/papers/volume9/elidan08a/elidan08a.pdf
  114. """
  115. if not is_chordal(G):
  116. raise nx.NetworkXError("Input graph is not chordal.")
  117. H = nx.Graph(G)
  118. H.add_edge(s, t)
  119. induced_nodes = set()
  120. triplet = _find_chordality_breaker(H, s, treewidth_bound)
  121. while triplet:
  122. (u, v, w) = triplet
  123. induced_nodes.update(triplet)
  124. for n in triplet:
  125. if n != s:
  126. H.add_edge(s, n)
  127. triplet = _find_chordality_breaker(H, s, treewidth_bound)
  128. if induced_nodes:
  129. # Add t and the second node in the induced path from s to t.
  130. induced_nodes.add(t)
  131. for u in G[s]:
  132. if len(induced_nodes & set(G[u])) == 2:
  133. induced_nodes.add(u)
  134. break
  135. return induced_nodes
  136. def chordal_graph_cliques(G):
  137. """Returns all maximal cliques of a chordal graph.
  138. The algorithm breaks the graph in connected components and performs a
  139. maximum cardinality search in each component to get the cliques.
  140. Parameters
  141. ----------
  142. G : graph
  143. A NetworkX graph
  144. Yields
  145. ------
  146. frozenset of nodes
  147. Maximal cliques, each of which is a frozenset of
  148. nodes in `G`. The order of cliques is arbitrary.
  149. Raises
  150. ------
  151. NetworkXError
  152. The algorithm does not support DiGraph, MultiGraph and MultiDiGraph.
  153. The algorithm can only be applied to chordal graphs. If the input
  154. graph is found to be non-chordal, a :exc:`NetworkXError` is raised.
  155. Examples
  156. --------
  157. >>> e = [
  158. ... (1, 2),
  159. ... (1, 3),
  160. ... (2, 3),
  161. ... (2, 4),
  162. ... (3, 4),
  163. ... (3, 5),
  164. ... (3, 6),
  165. ... (4, 5),
  166. ... (4, 6),
  167. ... (5, 6),
  168. ... (7, 8),
  169. ... ]
  170. >>> G = nx.Graph(e)
  171. >>> G.add_node(9)
  172. >>> cliques = [c for c in chordal_graph_cliques(G)]
  173. >>> cliques[0]
  174. frozenset({1, 2, 3})
  175. """
  176. for C in (G.subgraph(c).copy() for c in connected_components(G)):
  177. if C.number_of_nodes() == 1:
  178. if nx.number_of_selfloops(C) > 0:
  179. raise nx.NetworkXError("Input graph is not chordal.")
  180. yield frozenset(C.nodes())
  181. else:
  182. unnumbered = set(C.nodes())
  183. v = arbitrary_element(C)
  184. unnumbered.remove(v)
  185. numbered = {v}
  186. clique_wanna_be = {v}
  187. while unnumbered:
  188. v = _max_cardinality_node(C, unnumbered, numbered)
  189. unnumbered.remove(v)
  190. numbered.add(v)
  191. new_clique_wanna_be = set(C.neighbors(v)) & numbered
  192. sg = C.subgraph(clique_wanna_be)
  193. if _is_complete_graph(sg):
  194. new_clique_wanna_be.add(v)
  195. if not new_clique_wanna_be >= clique_wanna_be:
  196. yield frozenset(clique_wanna_be)
  197. clique_wanna_be = new_clique_wanna_be
  198. else:
  199. raise nx.NetworkXError("Input graph is not chordal.")
  200. yield frozenset(clique_wanna_be)
  201. def chordal_graph_treewidth(G):
  202. """Returns the treewidth of the chordal graph G.
  203. Parameters
  204. ----------
  205. G : graph
  206. A NetworkX graph
  207. Returns
  208. -------
  209. treewidth : int
  210. The size of the largest clique in the graph minus one.
  211. Raises
  212. ------
  213. NetworkXError
  214. The algorithm does not support DiGraph, MultiGraph and MultiDiGraph.
  215. The algorithm can only be applied to chordal graphs. If the input
  216. graph is found to be non-chordal, a :exc:`NetworkXError` is raised.
  217. Examples
  218. --------
  219. >>> e = [
  220. ... (1, 2),
  221. ... (1, 3),
  222. ... (2, 3),
  223. ... (2, 4),
  224. ... (3, 4),
  225. ... (3, 5),
  226. ... (3, 6),
  227. ... (4, 5),
  228. ... (4, 6),
  229. ... (5, 6),
  230. ... (7, 8),
  231. ... ]
  232. >>> G = nx.Graph(e)
  233. >>> G.add_node(9)
  234. >>> nx.chordal_graph_treewidth(G)
  235. 3
  236. References
  237. ----------
  238. .. [1] https://en.wikipedia.org/wiki/Tree_decomposition#Treewidth
  239. """
  240. if not is_chordal(G):
  241. raise nx.NetworkXError("Input graph is not chordal.")
  242. max_clique = -1
  243. for clique in nx.chordal_graph_cliques(G):
  244. max_clique = max(max_clique, len(clique))
  245. return max_clique - 1
  246. def _is_complete_graph(G):
  247. """Returns True if G is a complete graph."""
  248. if nx.number_of_selfloops(G) > 0:
  249. raise nx.NetworkXError("Self loop found in _is_complete_graph()")
  250. n = G.number_of_nodes()
  251. if n < 2:
  252. return True
  253. e = G.number_of_edges()
  254. max_edges = (n * (n - 1)) / 2
  255. return e == max_edges
  256. def _find_missing_edge(G):
  257. """Given a non-complete graph G, returns a missing edge."""
  258. nodes = set(G)
  259. for u in G:
  260. missing = nodes - set(list(G[u].keys()) + [u])
  261. if missing:
  262. return (u, missing.pop())
  263. def _max_cardinality_node(G, choices, wanna_connect):
  264. """Returns a the node in choices that has more connections in G
  265. to nodes in wanna_connect.
  266. """
  267. max_number = -1
  268. for x in choices:
  269. number = len([y for y in G[x] if y in wanna_connect])
  270. if number > max_number:
  271. max_number = number
  272. max_cardinality_node = x
  273. return max_cardinality_node
  274. def _find_chordality_breaker(G, s=None, treewidth_bound=sys.maxsize):
  275. """Given a graph G, starts a max cardinality search
  276. (starting from s if s is given and from an arbitrary node otherwise)
  277. trying to find a non-chordal cycle.
  278. If it does find one, it returns (u,v,w) where u,v,w are the three
  279. nodes that together with s are involved in the cycle.
  280. """
  281. if nx.number_of_selfloops(G) > 0:
  282. raise nx.NetworkXError("Input graph is not chordal.")
  283. unnumbered = set(G)
  284. if s is None:
  285. s = arbitrary_element(G)
  286. unnumbered.remove(s)
  287. numbered = {s}
  288. current_treewidth = -1
  289. while unnumbered: # and current_treewidth <= treewidth_bound:
  290. v = _max_cardinality_node(G, unnumbered, numbered)
  291. unnumbered.remove(v)
  292. numbered.add(v)
  293. clique_wanna_be = set(G[v]) & numbered
  294. sg = G.subgraph(clique_wanna_be)
  295. if _is_complete_graph(sg):
  296. # The graph seems to be chordal by now. We update the treewidth
  297. current_treewidth = max(current_treewidth, len(clique_wanna_be))
  298. if current_treewidth > treewidth_bound:
  299. raise nx.NetworkXTreewidthBoundExceeded(
  300. f"treewidth_bound exceeded: {current_treewidth}"
  301. )
  302. else:
  303. # sg is not a clique,
  304. # look for an edge that is not included in sg
  305. (u, w) = _find_missing_edge(sg)
  306. return (u, v, w)
  307. return ()
  308. @not_implemented_for("directed")
  309. def complete_to_chordal_graph(G):
  310. """Return a copy of G completed to a chordal graph
  311. Adds edges to a copy of G to create a chordal graph. A graph G=(V,E) is
  312. called chordal if for each cycle with length bigger than 3, there exist
  313. two non-adjacent nodes connected by an edge (called a chord).
  314. Parameters
  315. ----------
  316. G : NetworkX graph
  317. Undirected graph
  318. Returns
  319. -------
  320. H : NetworkX graph
  321. The chordal enhancement of G
  322. alpha : Dictionary
  323. The elimination ordering of nodes of G
  324. Notes
  325. -----
  326. There are different approaches to calculate the chordal
  327. enhancement of a graph. The algorithm used here is called
  328. MCS-M and gives at least minimal (local) triangulation of graph. Note
  329. that this triangulation is not necessarily a global minimum.
  330. https://en.wikipedia.org/wiki/Chordal_graph
  331. References
  332. ----------
  333. .. [1] Berry, Anne & Blair, Jean & Heggernes, Pinar & Peyton, Barry. (2004)
  334. Maximum Cardinality Search for Computing Minimal Triangulations of
  335. Graphs. Algorithmica. 39. 287-298. 10.1007/s00453-004-1084-3.
  336. Examples
  337. --------
  338. >>> from networkx.algorithms.chordal import complete_to_chordal_graph
  339. >>> G = nx.wheel_graph(10)
  340. >>> H, alpha = complete_to_chordal_graph(G)
  341. """
  342. H = G.copy()
  343. alpha = {node: 0 for node in H}
  344. if nx.is_chordal(H):
  345. return H, alpha
  346. chords = set()
  347. weight = {node: 0 for node in H.nodes()}
  348. unnumbered_nodes = list(H.nodes())
  349. for i in range(len(H.nodes()), 0, -1):
  350. # get the node in unnumbered_nodes with the maximum weight
  351. z = max(unnumbered_nodes, key=lambda node: weight[node])
  352. unnumbered_nodes.remove(z)
  353. alpha[z] = i
  354. update_nodes = []
  355. for y in unnumbered_nodes:
  356. if G.has_edge(y, z):
  357. update_nodes.append(y)
  358. else:
  359. # y_weight will be bigger than node weights between y and z
  360. y_weight = weight[y]
  361. lower_nodes = [
  362. node for node in unnumbered_nodes if weight[node] < y_weight
  363. ]
  364. if nx.has_path(H.subgraph(lower_nodes + [z, y]), y, z):
  365. update_nodes.append(y)
  366. chords.add((z, y))
  367. # during calculation of paths the weights should not be updated
  368. for node in update_nodes:
  369. weight[node] += 1
  370. H.add_edges_from(chords)
  371. return H, alpha