chordal.py 13 KB

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