philiprehberger-graph 0.2.4 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +111 -0
- data/lib/philiprehberger/graph/graph.rb +233 -0
- data/lib/philiprehberger/graph/version.rb +1 -1
- metadata +4 -3
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 73596f486ec100249fa2df0e4a9ef6aa1bdcd5e37e16b9b587dd6802f3fc9584
|
|
4
|
+
data.tar.gz: 0ddc969e510fb5543d9b981d53f73c15b53e73066defb0b7226998c79ef7ec15
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4cffa4c6398bfa251b9b8f82704d883d36e4c2dc7f1296369ff073364b8a71d0256a8f75b056d78372a38ca99809e8a09c560d549da4b8b8efa38ccf35130811
|
|
7
|
+
data.tar.gz: 80ca432cc5c0b1b35c979ccfebb1e7409d9170687a99ae9f4336e5159ce72a8d063cd5c8f7cc4829a4e22d9bad34c8d329e4fb8e8d3a3a668876994455ac628d
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.0] - 2026-04-17
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- Enumerator-style node iteration (`each_node`)
|
|
14
|
+
- Enumerator-style edge iteration (`each_edge`)
|
|
15
|
+
- Distance-only shortest path query (`shortest_path_distance`)
|
|
16
|
+
- Graph complement operation (`complement`)
|
|
17
|
+
|
|
18
|
+
## [0.3.0] - 2026-04-10
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
- Node and edge existence checks (`node?`, `edge?`)
|
|
22
|
+
- Edge weight retrieval (`weight`)
|
|
23
|
+
- Graph size queries (`node_count`, `edge_count`, `empty?`)
|
|
24
|
+
- Directed graph degree decomposition (`in_degree`, `out_degree`)
|
|
25
|
+
- Path existence check (`path?`)
|
|
26
|
+
- Subgraph extraction (`subgraph`)
|
|
27
|
+
- Graph transpose (`transpose`)
|
|
28
|
+
- Graph density calculation (`density`)
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
- Update gemspec description to reflect full feature set
|
|
32
|
+
- Add missing fields to GitHub issue templates
|
|
33
|
+
|
|
10
34
|
## [0.2.4] - 2026-04-08
|
|
11
35
|
|
|
12
36
|
### Changed
|
data/README.md
CHANGED
|
@@ -139,6 +139,101 @@ g.add_edge(:d, :c)
|
|
|
139
139
|
g.strongly_connected_components # => [[:d, :c], [:b, :a]]
|
|
140
140
|
```
|
|
141
141
|
|
|
142
|
+
### Iteration
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
g = Philiprehberger::Graph.new
|
|
146
|
+
g.add_edge(:a, :b)
|
|
147
|
+
g.add_edge(:a, :c)
|
|
148
|
+
|
|
149
|
+
g.each_node { |id| puts id }
|
|
150
|
+
g.each_edge { |e| puts "#{e[:from]} -> #{e[:to]}" }
|
|
151
|
+
|
|
152
|
+
# Enumerator chaining
|
|
153
|
+
high_degree = g.each_node.select { |id| g.degree(id) > 1 }
|
|
154
|
+
heavy_edges = g.each_edge.select { |e| e[:weight] > 5 }
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
### Shortest Path Distance
|
|
158
|
+
|
|
159
|
+
```ruby
|
|
160
|
+
g = Philiprehberger::Graph.new
|
|
161
|
+
g.add_edge(:a, :b, weight: 1)
|
|
162
|
+
g.add_edge(:b, :c, weight: 2)
|
|
163
|
+
g.add_edge(:a, :c, weight: 10)
|
|
164
|
+
|
|
165
|
+
g.shortest_path_distance(:a, :c) # => 3
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
### Graph Complement
|
|
169
|
+
|
|
170
|
+
```ruby
|
|
171
|
+
g = Philiprehberger::Graph.new
|
|
172
|
+
g.add_edge(:a, :b)
|
|
173
|
+
g.add_edge(:b, :c)
|
|
174
|
+
|
|
175
|
+
c = g.complement
|
|
176
|
+
c.edge?(:a, :c) # => true
|
|
177
|
+
c.edge?(:a, :b) # => false
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
### Query Methods
|
|
181
|
+
|
|
182
|
+
```ruby
|
|
183
|
+
g = Philiprehberger::Graph.new
|
|
184
|
+
g.add_edge(:a, :b, weight: 3)
|
|
185
|
+
g.add_edge(:b, :c)
|
|
186
|
+
g.add_node(:d)
|
|
187
|
+
|
|
188
|
+
g.node?(:a) # => true
|
|
189
|
+
g.edge?(:a, :b) # => true
|
|
190
|
+
g.weight(:a, :b) # => 3
|
|
191
|
+
g.node_count # => 4
|
|
192
|
+
g.edge_count # => 2
|
|
193
|
+
g.empty? # => false
|
|
194
|
+
g.path?(:a, :c) # => true
|
|
195
|
+
g.path?(:a, :d) # => false
|
|
196
|
+
g.density # => 0.333...
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
### Directed Degree
|
|
200
|
+
|
|
201
|
+
```ruby
|
|
202
|
+
g = Philiprehberger::Graph.new(directed: true)
|
|
203
|
+
g.add_edge(:a, :b)
|
|
204
|
+
g.add_edge(:a, :c)
|
|
205
|
+
g.add_edge(:b, :c)
|
|
206
|
+
|
|
207
|
+
g.in_degree(:c) # => 2
|
|
208
|
+
g.out_degree(:a) # => 2
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Subgraph
|
|
212
|
+
|
|
213
|
+
```ruby
|
|
214
|
+
g = Philiprehberger::Graph.new
|
|
215
|
+
g.add_edge(:a, :b)
|
|
216
|
+
g.add_edge(:b, :c)
|
|
217
|
+
g.add_edge(:c, :d)
|
|
218
|
+
|
|
219
|
+
sg = g.subgraph([:a, :b, :c])
|
|
220
|
+
sg.nodes # => [:a, :b, :c]
|
|
221
|
+
sg.edge?(:a, :b) # => true
|
|
222
|
+
sg.node?(:d) # => false
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
### Transpose
|
|
226
|
+
|
|
227
|
+
```ruby
|
|
228
|
+
g = Philiprehberger::Graph.new(directed: true)
|
|
229
|
+
g.add_edge(:a, :b)
|
|
230
|
+
g.add_edge(:b, :c)
|
|
231
|
+
|
|
232
|
+
t = g.transpose
|
|
233
|
+
t.edge?(:b, :a) # => true
|
|
234
|
+
t.edge?(:c, :b) # => true
|
|
235
|
+
```
|
|
236
|
+
|
|
142
237
|
### Serialization
|
|
143
238
|
|
|
144
239
|
```ruby
|
|
@@ -163,13 +258,25 @@ g2 = Philiprehberger::Graph::Graph.from_json(g.to_json)
|
|
|
163
258
|
| `#add_edge(from, to, weight:)` | Add a weighted edge |
|
|
164
259
|
| `#remove_node(id)` | Remove a node and its edges |
|
|
165
260
|
| `#remove_edge(from, to)` | Remove an edge |
|
|
261
|
+
| `#node?(id)` | Check if a node exists |
|
|
262
|
+
| `#edge?(from, to)` | Check if an edge exists |
|
|
263
|
+
| `#weight(from, to)` | Get edge weight (nil if none) |
|
|
166
264
|
| `#neighbors(node)` | Get neighbor node ids |
|
|
167
265
|
| `#degree(node)` | Number of edges on a node |
|
|
266
|
+
| `#in_degree(node)` | Incoming edges (directed) |
|
|
267
|
+
| `#out_degree(node)` | Outgoing edges (directed) |
|
|
168
268
|
| `#nodes` | All node ids |
|
|
169
269
|
| `#edges` | All edges as hashes |
|
|
270
|
+
| `#node_count` | Number of nodes |
|
|
271
|
+
| `#edge_count` | Number of edges |
|
|
272
|
+
| `#empty?` | Whether the graph has no nodes |
|
|
273
|
+
| `#each_node` | Iterate nodes (yields or returns Enumerator) |
|
|
274
|
+
| `#each_edge` | Iterate edges (yields or returns Enumerator) |
|
|
275
|
+
| `#path?(from, to)` | Check if a path exists |
|
|
170
276
|
| `#bfs(start)` | Breadth-first search |
|
|
171
277
|
| `#dfs(start)` | Depth-first search |
|
|
172
278
|
| `#shortest_path(from, to)` | Dijkstra's shortest path |
|
|
279
|
+
| `#shortest_path_distance(from, to)` | Shortest distance (numeric) |
|
|
173
280
|
| `#topological_sort` | Topological ordering (DAG only) |
|
|
174
281
|
| `#cycle?` | Check for cycles |
|
|
175
282
|
| `#connected_components` | Find connected components |
|
|
@@ -180,6 +287,10 @@ g2 = Philiprehberger::Graph::Graph.from_json(g.to_json)
|
|
|
180
287
|
| `#bipartite?` | Check if graph is bipartite |
|
|
181
288
|
| `#bipartite_sets` | Get bipartite partition sets |
|
|
182
289
|
| `#strongly_connected_components` | Tarjan's SCC algorithm |
|
|
290
|
+
| `#subgraph(nodes)` | Extract a subgraph |
|
|
291
|
+
| `#transpose` | Reverse all edges (directed) |
|
|
292
|
+
| `#density` | Graph density (0.0 to 1.0) |
|
|
293
|
+
| `#complement` | Graph complement (missing edges) |
|
|
183
294
|
| `#to_dot` | Serialize to DOT format |
|
|
184
295
|
| `#to_json` | Serialize to JSON |
|
|
185
296
|
| `.from_json(str)` | Deserialize from JSON |
|
|
@@ -17,6 +17,26 @@ module Philiprehberger
|
|
|
17
17
|
@directed
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
+
# Iterate over all node ids.
|
|
21
|
+
#
|
|
22
|
+
# @yield [Object] each node id
|
|
23
|
+
# @return [Enumerator] if no block given
|
|
24
|
+
def each_node(&)
|
|
25
|
+
return enum_for(:each_node) unless block_given?
|
|
26
|
+
|
|
27
|
+
@adjacency.each_key(&)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# Iterate over all edges as {from:, to:, weight:} hashes.
|
|
31
|
+
#
|
|
32
|
+
# @yield [Hash] each edge
|
|
33
|
+
# @return [Enumerator] if no block given
|
|
34
|
+
def each_edge(&)
|
|
35
|
+
return enum_for(:each_edge) unless block_given?
|
|
36
|
+
|
|
37
|
+
edges.each(&)
|
|
38
|
+
end
|
|
39
|
+
|
|
20
40
|
# Add a node to the graph.
|
|
21
41
|
#
|
|
22
42
|
# @param id [Object] the node identifier
|
|
@@ -79,6 +99,63 @@ module Philiprehberger
|
|
|
79
99
|
(@adjacency[node] || []).length
|
|
80
100
|
end
|
|
81
101
|
|
|
102
|
+
# Return the in-degree of a node (directed graphs).
|
|
103
|
+
# For undirected graphs, returns the same as degree.
|
|
104
|
+
#
|
|
105
|
+
# @param node [Object] the node
|
|
106
|
+
# @return [Integer]
|
|
107
|
+
def in_degree(node)
|
|
108
|
+
return degree(node) unless @directed
|
|
109
|
+
|
|
110
|
+
count = 0
|
|
111
|
+
@adjacency.each_value do |edges_list|
|
|
112
|
+
edges_list.each { |e| count += 1 if e[:node] == node }
|
|
113
|
+
end
|
|
114
|
+
count
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Return the out-degree of a node (directed graphs).
|
|
118
|
+
# For undirected graphs, returns the same as degree.
|
|
119
|
+
#
|
|
120
|
+
# @param node [Object] the node
|
|
121
|
+
# @return [Integer]
|
|
122
|
+
def out_degree(node)
|
|
123
|
+
return degree(node) unless @directed
|
|
124
|
+
|
|
125
|
+
(@adjacency[node] || []).length
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Check if a node exists.
|
|
129
|
+
#
|
|
130
|
+
# @param id [Object] the node identifier
|
|
131
|
+
# @return [Boolean]
|
|
132
|
+
def node?(id)
|
|
133
|
+
@adjacency.key?(id)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# Check if an edge exists between two nodes.
|
|
137
|
+
#
|
|
138
|
+
# @param from [Object] source node
|
|
139
|
+
# @param to [Object] destination node
|
|
140
|
+
# @return [Boolean]
|
|
141
|
+
def edge?(from, to)
|
|
142
|
+
return false unless @adjacency.key?(from)
|
|
143
|
+
|
|
144
|
+
@adjacency[from].any? { |e| e[:node] == to }
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Get the weight of an edge.
|
|
148
|
+
#
|
|
149
|
+
# @param from [Object] source node
|
|
150
|
+
# @param to [Object] destination node
|
|
151
|
+
# @return [Numeric, nil] the edge weight, or nil if no edge exists
|
|
152
|
+
def weight(from, to)
|
|
153
|
+
return nil unless @adjacency.key?(from)
|
|
154
|
+
|
|
155
|
+
edge = @adjacency[from].find { |e| e[:node] == to }
|
|
156
|
+
edge&.dig(:weight)
|
|
157
|
+
end
|
|
158
|
+
|
|
82
159
|
# Return all node ids.
|
|
83
160
|
#
|
|
84
161
|
# @return [Array<Object>]
|
|
@@ -86,6 +163,27 @@ module Philiprehberger
|
|
|
86
163
|
@adjacency.keys
|
|
87
164
|
end
|
|
88
165
|
|
|
166
|
+
# Return the number of nodes.
|
|
167
|
+
#
|
|
168
|
+
# @return [Integer]
|
|
169
|
+
def node_count
|
|
170
|
+
@adjacency.length
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Return the number of edges.
|
|
174
|
+
#
|
|
175
|
+
# @return [Integer]
|
|
176
|
+
def edge_count
|
|
177
|
+
edges.length
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Whether the graph has no nodes.
|
|
181
|
+
#
|
|
182
|
+
# @return [Boolean]
|
|
183
|
+
def empty?
|
|
184
|
+
@adjacency.empty?
|
|
185
|
+
end
|
|
186
|
+
|
|
89
187
|
# Return all edges as [from, to, weight] tuples.
|
|
90
188
|
#
|
|
91
189
|
# @return [Array<Hash>]
|
|
@@ -176,6 +274,36 @@ module Philiprehberger
|
|
|
176
274
|
nil
|
|
177
275
|
end
|
|
178
276
|
|
|
277
|
+
# Find the shortest distance between two nodes using Dijkstra's algorithm.
|
|
278
|
+
#
|
|
279
|
+
# @param from [Object] source node
|
|
280
|
+
# @param to [Object] destination node
|
|
281
|
+
# @return [Numeric, nil] the shortest distance, or nil if no path exists
|
|
282
|
+
def shortest_path_distance(from, to)
|
|
283
|
+
return nil unless @adjacency.key?(from) && @adjacency.key?(to)
|
|
284
|
+
return 0 if from == to
|
|
285
|
+
|
|
286
|
+
distances = Hash.new(Float::INFINITY)
|
|
287
|
+
distances[from] = 0
|
|
288
|
+
unvisited = @adjacency.keys.dup
|
|
289
|
+
|
|
290
|
+
until unvisited.empty?
|
|
291
|
+
current = unvisited.min_by { |n| distances[n] }
|
|
292
|
+
break if distances[current] == Float::INFINITY
|
|
293
|
+
|
|
294
|
+
unvisited.delete(current)
|
|
295
|
+
|
|
296
|
+
return distances[to] if current == to
|
|
297
|
+
|
|
298
|
+
(@adjacency[current] || []).each do |edge|
|
|
299
|
+
alt = distances[current] + edge[:weight]
|
|
300
|
+
distances[edge[:node]] = alt if alt < distances[edge[:node]]
|
|
301
|
+
end
|
|
302
|
+
end
|
|
303
|
+
|
|
304
|
+
nil
|
|
305
|
+
end
|
|
306
|
+
|
|
179
307
|
# Topological sort (directed acyclic graphs only).
|
|
180
308
|
#
|
|
181
309
|
# @return [Array<Object>] nodes in topological order
|
|
@@ -205,6 +333,111 @@ module Philiprehberger
|
|
|
205
333
|
end
|
|
206
334
|
end
|
|
207
335
|
|
|
336
|
+
# Check if a path exists between two nodes using BFS.
|
|
337
|
+
#
|
|
338
|
+
# @param from [Object] source node
|
|
339
|
+
# @param to [Object] destination node
|
|
340
|
+
# @return [Boolean]
|
|
341
|
+
def path?(from, to)
|
|
342
|
+
return false unless @adjacency.key?(from) && @adjacency.key?(to)
|
|
343
|
+
return true if from == to
|
|
344
|
+
|
|
345
|
+
visited = { from => true }
|
|
346
|
+
queue = [from]
|
|
347
|
+
|
|
348
|
+
until queue.empty?
|
|
349
|
+
node = queue.shift
|
|
350
|
+
neighbors(node).each do |neighbor|
|
|
351
|
+
return true if neighbor == to
|
|
352
|
+
|
|
353
|
+
unless visited[neighbor]
|
|
354
|
+
visited[neighbor] = true
|
|
355
|
+
queue << neighbor
|
|
356
|
+
end
|
|
357
|
+
end
|
|
358
|
+
end
|
|
359
|
+
|
|
360
|
+
false
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
# Extract a subgraph containing only the specified nodes and edges between them.
|
|
364
|
+
#
|
|
365
|
+
# @param node_ids [Array<Object>] the nodes to include
|
|
366
|
+
# @return [Graph] a new graph
|
|
367
|
+
def subgraph(node_ids)
|
|
368
|
+
node_set = node_ids.is_a?(Set) ? node_ids : Set.new(node_ids)
|
|
369
|
+
g = self.class.new(directed: @directed)
|
|
370
|
+
|
|
371
|
+
node_set.each { |id| g.add_node(id) if @adjacency.key?(id) }
|
|
372
|
+
|
|
373
|
+
@adjacency.each do |from, edges_list|
|
|
374
|
+
next unless node_set.include?(from)
|
|
375
|
+
|
|
376
|
+
edges_list.each do |edge|
|
|
377
|
+
next unless node_set.include?(edge[:node])
|
|
378
|
+
next if !@directed && from.to_s > edge[:node].to_s
|
|
379
|
+
|
|
380
|
+
g.add_edge(from, edge[:node], weight: edge[:weight])
|
|
381
|
+
end
|
|
382
|
+
end
|
|
383
|
+
|
|
384
|
+
g
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
# Return a new graph with all edges reversed.
|
|
388
|
+
# Only works on directed graphs.
|
|
389
|
+
#
|
|
390
|
+
# @return [Graph] a new graph with reversed edges
|
|
391
|
+
# @raise [Error] if the graph is not directed
|
|
392
|
+
def transpose
|
|
393
|
+
raise Error, 'transpose requires a directed graph' unless @directed
|
|
394
|
+
|
|
395
|
+
g = self.class.new(directed: true)
|
|
396
|
+
@adjacency.each_key { |id| g.add_node(id) }
|
|
397
|
+
@adjacency.each do |from, edges_list|
|
|
398
|
+
edges_list.each do |edge|
|
|
399
|
+
g.add_edge(edge[:node], from, weight: edge[:weight])
|
|
400
|
+
end
|
|
401
|
+
end
|
|
402
|
+
g
|
|
403
|
+
end
|
|
404
|
+
|
|
405
|
+
# Calculate the density of the graph.
|
|
406
|
+
# Density is the ratio of actual edges to possible edges.
|
|
407
|
+
#
|
|
408
|
+
# @return [Float] density between 0.0 and 1.0
|
|
409
|
+
def density
|
|
410
|
+
n = @adjacency.length
|
|
411
|
+
return 0.0 if n < 2
|
|
412
|
+
|
|
413
|
+
m = edge_count.to_f
|
|
414
|
+
max_edges = @directed ? n * (n - 1) : n * (n - 1) / 2.0
|
|
415
|
+
m / max_edges
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
# Return the complement graph.
|
|
419
|
+
# The complement contains edges between all pairs of nodes that are NOT connected
|
|
420
|
+
# in the original graph.
|
|
421
|
+
#
|
|
422
|
+
# @return [Graph] a new graph with complementary edges
|
|
423
|
+
def complement
|
|
424
|
+
g = self.class.new(directed: @directed)
|
|
425
|
+
@adjacency.each_key { |id| g.add_node(id) }
|
|
426
|
+
|
|
427
|
+
node_list = @adjacency.keys
|
|
428
|
+
node_list.each_with_index do |a, i|
|
|
429
|
+
targets = @directed ? node_list : node_list[(i + 1)..]
|
|
430
|
+
targets.each do |b|
|
|
431
|
+
next if a == b
|
|
432
|
+
next if edge?(a, b)
|
|
433
|
+
|
|
434
|
+
g.add_edge(a, b)
|
|
435
|
+
end
|
|
436
|
+
end
|
|
437
|
+
|
|
438
|
+
g
|
|
439
|
+
end
|
|
440
|
+
|
|
208
441
|
# Find connected components (undirected) or weakly connected components (directed).
|
|
209
442
|
#
|
|
210
443
|
# @return [Array<Array<Object>>] arrays of node ids per component
|
metadata
CHANGED
|
@@ -1,18 +1,19 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: philiprehberger-graph
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.4.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Philip Rehberger
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: bin
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-04-
|
|
11
|
+
date: 2026-04-17 00:00:00.000000000 Z
|
|
12
12
|
dependencies: []
|
|
13
13
|
description: Graph data structure supporting directed and undirected modes with adjacency
|
|
14
14
|
list storage. Includes BFS, DFS, Dijkstra shortest path, topological sort, cycle
|
|
15
|
-
detection,
|
|
15
|
+
detection, connected components, minimum spanning tree, maximum flow, graph coloring,
|
|
16
|
+
bipartiteness checking, strongly connected components, and DOT/JSON serialization.
|
|
16
17
|
email:
|
|
17
18
|
- me@philiprehberger.com
|
|
18
19
|
executables: []
|