philiprehberger-graph 0.1.4 → 0.2.3
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 +34 -0
- data/README.md +109 -3
- data/lib/philiprehberger/graph/graph.rb +378 -8
- data/lib/philiprehberger/graph/version.rb +1 -1
- data/lib/philiprehberger/graph.rb +2 -1
- metadata +4 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f15e96fcf2bfd1b6c453e5f52f5f956dc63d62eb385f6aeedea59073b31f0379
|
|
4
|
+
data.tar.gz: 1f82fde8213303ead0365ba95914531b482955bf9b50962227e24f6ad60e8c8c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 21fee32160f5d5d391d383d779df1ab66ed9b66d66f50d1e9c3e32bfe47f35856722fd2ddde5617ca4c0066a91a97bb9aafe91cb90df73b427edd6b02726d1c2
|
|
7
|
+
data.tar.gz: 5c48eabd6cd2ea1e2454ce3d9aa842f5859f3045a098d8928e740f617149c17320150c148f03bba09a36c4ffb520909e463b1a3d51bb64514ecb65dc7a985abd
|
data/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.2.3] - 2026-04-01
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Fix infinite recursion in union-find `uf_find` used by Kruskal MST algorithm
|
|
14
|
+
- Fix `Graph.new` infinite recursion in module-level factory method
|
|
15
|
+
- Fix greedy coloring test expectation for directed graphs
|
|
16
|
+
|
|
17
|
+
## [0.2.2] - 2026-03-31
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
- Add GitHub issue templates, dependabot config, and PR template
|
|
21
|
+
|
|
22
|
+
## [0.2.1] - 2026-03-31
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
- Standardize README badges, support section, and license format
|
|
26
|
+
|
|
27
|
+
## [0.2.0] - 2026-03-28
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- Minimum spanning tree with Kruskal's and Prim's algorithms (`minimum_spanning_tree`)
|
|
32
|
+
- Maximum flow using Edmonds-Karp algorithm (`max_flow`)
|
|
33
|
+
- Greedy graph coloring (`coloring`, `chromatic_number_estimate`)
|
|
34
|
+
- Bipartiteness checking (`bipartite?`, `bipartite_sets`)
|
|
35
|
+
- Strongly connected components via Tarjan's algorithm (`strongly_connected_components`)
|
|
36
|
+
- Graph serialization to DOT and JSON formats (`to_dot`, `to_json`, `Graph.from_json`)
|
|
37
|
+
|
|
38
|
+
## [0.1.5] - 2026-03-26
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- Add Sponsor badge and fix License link format in README
|
|
43
|
+
|
|
10
44
|
## [0.1.4] - 2026-03-24
|
|
11
45
|
|
|
12
46
|
### Changed
|
data/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/philiprehberger/rb-graph/actions/workflows/ci.yml)
|
|
4
4
|
[](https://rubygems.org/gems/philiprehberger-graph)
|
|
5
|
-
[](https://github.com/philiprehberger/rb-graph/commits/main)
|
|
6
6
|
|
|
7
|
-
Directed and undirected graph with traversal, shortest path, and
|
|
7
|
+
Directed and undirected graph data structure with traversal, shortest path, MST, max flow, coloring, and serialization
|
|
8
8
|
|
|
9
9
|
## Requirements
|
|
10
10
|
|
|
@@ -76,6 +76,84 @@ g.add_edge(:c, :d)
|
|
|
76
76
|
g.connected_components # => [[:a, :b], [:c, :d]]
|
|
77
77
|
```
|
|
78
78
|
|
|
79
|
+
### Minimum Spanning Tree
|
|
80
|
+
|
|
81
|
+
```ruby
|
|
82
|
+
g = Philiprehberger::Graph.new
|
|
83
|
+
g.add_edge(:a, :b, weight: 4)
|
|
84
|
+
g.add_edge(:a, :c, weight: 2)
|
|
85
|
+
g.add_edge(:b, :c, weight: 1)
|
|
86
|
+
g.add_edge(:b, :d, weight: 5)
|
|
87
|
+
|
|
88
|
+
g.minimum_spanning_tree(algorithm: :kruskal)
|
|
89
|
+
# => [{from: :b, to: :c, weight: 1}, {from: :a, to: :c, weight: 2}, {from: :b, to: :d, weight: 5}]
|
|
90
|
+
g.minimum_spanning_tree(algorithm: :prim)
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Maximum Flow
|
|
94
|
+
|
|
95
|
+
```ruby
|
|
96
|
+
g = Philiprehberger::Graph.new(directed: true)
|
|
97
|
+
g.add_edge(:s, :a, weight: 10)
|
|
98
|
+
g.add_edge(:s, :b, weight: 5)
|
|
99
|
+
g.add_edge(:a, :t, weight: 5)
|
|
100
|
+
g.add_edge(:b, :t, weight: 10)
|
|
101
|
+
g.add_edge(:a, :b, weight: 15)
|
|
102
|
+
|
|
103
|
+
g.max_flow(:s, :t) # => 15
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
### Graph Coloring
|
|
107
|
+
|
|
108
|
+
```ruby
|
|
109
|
+
g = Philiprehberger::Graph.new
|
|
110
|
+
g.add_edge(:a, :b)
|
|
111
|
+
g.add_edge(:b, :c)
|
|
112
|
+
g.add_edge(:c, :a)
|
|
113
|
+
|
|
114
|
+
g.coloring # => {:a=>0, :b=>1, :c=>2}
|
|
115
|
+
g.chromatic_number_estimate # => 3
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### Bipartiteness
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
g = Philiprehberger::Graph.new
|
|
122
|
+
g.add_edge(:a, :b)
|
|
123
|
+
g.add_edge(:b, :c)
|
|
124
|
+
|
|
125
|
+
g.bipartite? # => true
|
|
126
|
+
g.bipartite_sets # => [#<Set: {:a, :c}>, #<Set: {:b}>]
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
### Strongly Connected Components
|
|
130
|
+
|
|
131
|
+
```ruby
|
|
132
|
+
g = Philiprehberger::Graph.new(directed: true)
|
|
133
|
+
g.add_edge(:a, :b)
|
|
134
|
+
g.add_edge(:b, :a)
|
|
135
|
+
g.add_edge(:b, :c)
|
|
136
|
+
g.add_edge(:c, :d)
|
|
137
|
+
g.add_edge(:d, :c)
|
|
138
|
+
|
|
139
|
+
g.strongly_connected_components # => [[:d, :c], [:b, :a]]
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
### Serialization
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
g = Philiprehberger::Graph.new
|
|
146
|
+
g.add_edge(:a, :b, weight: 3)
|
|
147
|
+
|
|
148
|
+
g.to_dot
|
|
149
|
+
# => "graph G {\n a;\n b;\n a -- b [weight=3];\n}"
|
|
150
|
+
|
|
151
|
+
g.to_json
|
|
152
|
+
# => '{"directed":false,"nodes":["a","b"],"edges":[{"from":"a","to":"b","weight":3}]}'
|
|
153
|
+
|
|
154
|
+
g2 = Philiprehberger::Graph::Graph.from_json(g.to_json)
|
|
155
|
+
```
|
|
156
|
+
|
|
79
157
|
## API
|
|
80
158
|
|
|
81
159
|
| Method | Description |
|
|
@@ -95,6 +173,16 @@ g.connected_components # => [[:a, :b], [:c, :d]]
|
|
|
95
173
|
| `#topological_sort` | Topological ordering (DAG only) |
|
|
96
174
|
| `#cycle?` | Check for cycles |
|
|
97
175
|
| `#connected_components` | Find connected components |
|
|
176
|
+
| `#minimum_spanning_tree(algorithm:)` | Kruskal's or Prim's MST |
|
|
177
|
+
| `#max_flow(source, sink)` | Edmonds-Karp maximum flow |
|
|
178
|
+
| `#coloring` | Greedy graph coloring |
|
|
179
|
+
| `#chromatic_number_estimate` | Estimated chromatic number |
|
|
180
|
+
| `#bipartite?` | Check if graph is bipartite |
|
|
181
|
+
| `#bipartite_sets` | Get bipartite partition sets |
|
|
182
|
+
| `#strongly_connected_components` | Tarjan's SCC algorithm |
|
|
183
|
+
| `#to_dot` | Serialize to DOT format |
|
|
184
|
+
| `#to_json` | Serialize to JSON |
|
|
185
|
+
| `.from_json(str)` | Deserialize from JSON |
|
|
98
186
|
|
|
99
187
|
## Development
|
|
100
188
|
|
|
@@ -104,6 +192,24 @@ bundle exec rspec
|
|
|
104
192
|
bundle exec rubocop
|
|
105
193
|
```
|
|
106
194
|
|
|
195
|
+
## Support
|
|
196
|
+
|
|
197
|
+
If you find this project useful:
|
|
198
|
+
|
|
199
|
+
⭐ [Star the repo](https://github.com/philiprehberger/rb-graph)
|
|
200
|
+
|
|
201
|
+
🐛 [Report issues](https://github.com/philiprehberger/rb-graph/issues?q=is%3Aissue+is%3Aopen+label%3Abug)
|
|
202
|
+
|
|
203
|
+
💡 [Suggest features](https://github.com/philiprehberger/rb-graph/issues?q=is%3Aissue+is%3Aopen+label%3Aenhancement)
|
|
204
|
+
|
|
205
|
+
❤️ [Sponsor development](https://github.com/sponsors/philiprehberger)
|
|
206
|
+
|
|
207
|
+
🌐 [All Open Source Projects](https://philiprehberger.com/open-source-packages)
|
|
208
|
+
|
|
209
|
+
💻 [GitHub Profile](https://github.com/philiprehberger)
|
|
210
|
+
|
|
211
|
+
🔗 [LinkedIn Profile](https://www.linkedin.com/in/philiprehberger)
|
|
212
|
+
|
|
107
213
|
## License
|
|
108
214
|
|
|
109
|
-
MIT
|
|
215
|
+
[MIT](LICENSE)
|
|
@@ -213,16 +213,192 @@ module Philiprehberger
|
|
|
213
213
|
components = []
|
|
214
214
|
|
|
215
215
|
@adjacency.each_key do |node|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
216
|
+
next if visited[node]
|
|
217
|
+
|
|
218
|
+
component = []
|
|
219
|
+
cc_visit(node, visited, component)
|
|
220
|
+
components << component
|
|
221
221
|
end
|
|
222
222
|
|
|
223
223
|
components
|
|
224
224
|
end
|
|
225
225
|
|
|
226
|
+
# Find the minimum spanning tree using Kruskal's or Prim's algorithm.
|
|
227
|
+
# Only works on undirected graphs.
|
|
228
|
+
#
|
|
229
|
+
# @param algorithm [Symbol] :kruskal or :prim
|
|
230
|
+
# @return [Array<Hash>] edges in the MST as {from:, to:, weight:} hashes
|
|
231
|
+
# @raise [Error] if the graph is directed or disconnected
|
|
232
|
+
def minimum_spanning_tree(algorithm: :kruskal)
|
|
233
|
+
raise Error, 'minimum spanning tree requires an undirected graph' if @directed
|
|
234
|
+
raise Error, 'graph is empty' if @adjacency.empty?
|
|
235
|
+
|
|
236
|
+
components = connected_components
|
|
237
|
+
raise Error, 'graph is disconnected' if components.length > 1
|
|
238
|
+
|
|
239
|
+
case algorithm
|
|
240
|
+
when :kruskal then kruskal_mst
|
|
241
|
+
when :prim then prim_mst
|
|
242
|
+
else raise Error, "unknown algorithm: #{algorithm}"
|
|
243
|
+
end
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
# Compute the maximum flow from source to sink using Edmonds-Karp (BFS-based Ford-Fulkerson).
|
|
247
|
+
# Only works on directed graphs.
|
|
248
|
+
#
|
|
249
|
+
# @param source [Object] source node
|
|
250
|
+
# @param sink [Object] sink node
|
|
251
|
+
# @return [Numeric] the maximum flow value
|
|
252
|
+
# @raise [Error] if the graph is not directed or nodes don't exist
|
|
253
|
+
def max_flow(source, sink)
|
|
254
|
+
raise Error, 'max_flow requires a directed graph' unless @directed
|
|
255
|
+
raise Error, 'source node not found' unless @adjacency.key?(source)
|
|
256
|
+
raise Error, 'sink node not found' unless @adjacency.key?(sink)
|
|
257
|
+
return 0 if source == sink
|
|
258
|
+
|
|
259
|
+
edmonds_karp(source, sink)
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# Greedy graph coloring. Assigns the smallest available color (integer) to each node.
|
|
263
|
+
#
|
|
264
|
+
# @return [Hash] mapping of node => color (integer starting from 0)
|
|
265
|
+
def coloring
|
|
266
|
+
result = {}
|
|
267
|
+
@adjacency.each_key do |node|
|
|
268
|
+
used_colors = Set.new
|
|
269
|
+
neighbors(node).each do |neighbor|
|
|
270
|
+
used_colors << result[neighbor] if result.key?(neighbor)
|
|
271
|
+
end
|
|
272
|
+
color = 0
|
|
273
|
+
color += 1 while used_colors.include?(color)
|
|
274
|
+
result[node] = color
|
|
275
|
+
end
|
|
276
|
+
result
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Estimate the chromatic number using greedy coloring.
|
|
280
|
+
#
|
|
281
|
+
# @return [Integer] estimated chromatic number
|
|
282
|
+
def chromatic_number_estimate
|
|
283
|
+
return 0 if @adjacency.empty?
|
|
284
|
+
|
|
285
|
+
colors = coloring
|
|
286
|
+
(colors.values.max || 0) + 1
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Check whether the graph is bipartite.
|
|
290
|
+
#
|
|
291
|
+
# @return [Boolean]
|
|
292
|
+
def bipartite?
|
|
293
|
+
!bipartite_sets.nil?
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# Find bipartite partition sets using BFS 2-coloring.
|
|
297
|
+
#
|
|
298
|
+
# @return [Array<Set, Set>, nil] two sets of nodes, or nil if not bipartite
|
|
299
|
+
def bipartite_sets
|
|
300
|
+
color = {}
|
|
301
|
+
set_a = Set.new
|
|
302
|
+
set_b = Set.new
|
|
303
|
+
|
|
304
|
+
@adjacency.each_key do |start|
|
|
305
|
+
next if color.key?(start)
|
|
306
|
+
|
|
307
|
+
color[start] = 0
|
|
308
|
+
set_a << start
|
|
309
|
+
queue = [start]
|
|
310
|
+
|
|
311
|
+
until queue.empty?
|
|
312
|
+
node = queue.shift
|
|
313
|
+
all_neighbors = bipartite_neighbors(node)
|
|
314
|
+
all_neighbors.each do |neighbor|
|
|
315
|
+
if color.key?(neighbor)
|
|
316
|
+
return nil if color[neighbor] == color[node]
|
|
317
|
+
else
|
|
318
|
+
color[neighbor] = 1 - color[node]
|
|
319
|
+
if color[neighbor].zero?
|
|
320
|
+
set_a << neighbor
|
|
321
|
+
else
|
|
322
|
+
set_b << neighbor
|
|
323
|
+
end
|
|
324
|
+
queue << neighbor
|
|
325
|
+
end
|
|
326
|
+
end
|
|
327
|
+
end
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
[set_a, set_b]
|
|
331
|
+
end
|
|
332
|
+
|
|
333
|
+
# Find strongly connected components using Tarjan's algorithm.
|
|
334
|
+
# Only works on directed graphs.
|
|
335
|
+
#
|
|
336
|
+
# @return [Array<Array<Object>>] arrays of node ids per SCC
|
|
337
|
+
# @raise [Error] if the graph is not directed
|
|
338
|
+
def strongly_connected_components
|
|
339
|
+
raise Error, 'strongly_connected_components requires a directed graph' unless @directed
|
|
340
|
+
|
|
341
|
+
tarjan_scc
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# Serialize the graph to DOT format.
|
|
345
|
+
#
|
|
346
|
+
# @return [String] DOT format string
|
|
347
|
+
def to_dot
|
|
348
|
+
type = @directed ? 'digraph' : 'graph'
|
|
349
|
+
connector = @directed ? '->' : '--'
|
|
350
|
+
lines = ["#{type} G {"]
|
|
351
|
+
|
|
352
|
+
@adjacency.each_key do |node|
|
|
353
|
+
lines << " #{dot_escape(node)};"
|
|
354
|
+
end
|
|
355
|
+
|
|
356
|
+
seen = {}
|
|
357
|
+
@adjacency.each do |from, edges_list|
|
|
358
|
+
edges_list.each do |edge|
|
|
359
|
+
key = @directed ? [from, edge[:node]] : [from, edge[:node]].sort
|
|
360
|
+
next if seen[key]
|
|
361
|
+
|
|
362
|
+
seen[key] = true
|
|
363
|
+
attrs = edge[:weight] == 1 ? '' : " [weight=#{edge[:weight]}]"
|
|
364
|
+
lines << " #{dot_escape(from)} #{connector} #{dot_escape(edge[:node])}#{attrs};"
|
|
365
|
+
end
|
|
366
|
+
end
|
|
367
|
+
|
|
368
|
+
lines << '}'
|
|
369
|
+
lines.join("\n")
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
# Serialize the graph to JSON.
|
|
373
|
+
#
|
|
374
|
+
# @return [String] JSON string
|
|
375
|
+
def to_json(*_args)
|
|
376
|
+
require 'json'
|
|
377
|
+
data = {
|
|
378
|
+
directed: @directed,
|
|
379
|
+
nodes: @adjacency.keys,
|
|
380
|
+
edges: edges.map { |e| { from: e[:from], to: e[:to], weight: e[:weight] } }
|
|
381
|
+
}
|
|
382
|
+
JSON.generate(data)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Deserialize a graph from JSON.
|
|
386
|
+
#
|
|
387
|
+
# @param json_str [String] JSON string
|
|
388
|
+
# @return [Graph] a new graph instance
|
|
389
|
+
def self.from_json(json_str)
|
|
390
|
+
require 'json'
|
|
391
|
+
data = JSON.parse(json_str, symbolize_names: true)
|
|
392
|
+
graph = new(directed: data[:directed])
|
|
393
|
+
(data[:nodes] || []).each { |n| graph.add_node(n.is_a?(String) ? n.to_sym : n) }
|
|
394
|
+
(data[:edges] || []).each do |e|
|
|
395
|
+
from = e[:from].is_a?(String) ? e[:from].to_sym : e[:from]
|
|
396
|
+
to = e[:to].is_a?(String) ? e[:to].to_sym : e[:to]
|
|
397
|
+
graph.add_edge(from, to, weight: e[:weight] || 1)
|
|
398
|
+
end
|
|
399
|
+
graph
|
|
400
|
+
end
|
|
401
|
+
|
|
226
402
|
private
|
|
227
403
|
|
|
228
404
|
def dfs_visit(node, visited, result)
|
|
@@ -282,9 +458,7 @@ module Philiprehberger
|
|
|
282
458
|
visited = {}
|
|
283
459
|
|
|
284
460
|
@adjacency.each_key do |node|
|
|
285
|
-
|
|
286
|
-
return true if undirected_cycle_visit(node, nil, visited)
|
|
287
|
-
end
|
|
461
|
+
return true if !visited[node] && undirected_cycle_visit(node, nil, visited)
|
|
288
462
|
end
|
|
289
463
|
|
|
290
464
|
false
|
|
@@ -319,6 +493,202 @@ module Philiprehberger
|
|
|
319
493
|
cc_visit(neighbor, visited, component) unless visited[neighbor]
|
|
320
494
|
end
|
|
321
495
|
end
|
|
496
|
+
|
|
497
|
+
# ── MST helpers ──
|
|
498
|
+
|
|
499
|
+
def kruskal_mst
|
|
500
|
+
all_edges = edges.sort_by { |e| e[:weight] }
|
|
501
|
+
parent = {}
|
|
502
|
+
rank = Hash.new(0)
|
|
503
|
+
|
|
504
|
+
@adjacency.each_key { |n| parent[n] = n }
|
|
505
|
+
|
|
506
|
+
mst = []
|
|
507
|
+
all_edges.each do |edge|
|
|
508
|
+
root_from = uf_find(parent, edge[:from])
|
|
509
|
+
root_to = uf_find(parent, edge[:to])
|
|
510
|
+
next if root_from == root_to
|
|
511
|
+
|
|
512
|
+
mst << edge
|
|
513
|
+
uf_union(parent, rank, root_from, root_to)
|
|
514
|
+
break if mst.length == @adjacency.length - 1
|
|
515
|
+
end
|
|
516
|
+
|
|
517
|
+
mst
|
|
518
|
+
end
|
|
519
|
+
|
|
520
|
+
def prim_mst
|
|
521
|
+
start = @adjacency.keys.first
|
|
522
|
+
in_mst = { start => true }
|
|
523
|
+
mst = []
|
|
524
|
+
candidate_edges = (@adjacency[start] || []).map { |e| { from: start, to: e[:node], weight: e[:weight] } }
|
|
525
|
+
|
|
526
|
+
until candidate_edges.empty? || mst.length == @adjacency.length - 1
|
|
527
|
+
candidate_edges.sort_by! { |e| e[:weight] }
|
|
528
|
+
idx = candidate_edges.index { |e| !in_mst[e[:to]] }
|
|
529
|
+
break if idx.nil?
|
|
530
|
+
|
|
531
|
+
edge = candidate_edges.delete_at(idx)
|
|
532
|
+
next if in_mst[edge[:to]]
|
|
533
|
+
|
|
534
|
+
in_mst[edge[:to]] = true
|
|
535
|
+
mst << edge
|
|
536
|
+
(@adjacency[edge[:to]] || []).each do |e|
|
|
537
|
+
candidate_edges << { from: edge[:to], to: e[:node], weight: e[:weight] } unless in_mst[e[:node]]
|
|
538
|
+
end
|
|
539
|
+
end
|
|
540
|
+
|
|
541
|
+
mst
|
|
542
|
+
end
|
|
543
|
+
|
|
544
|
+
def uf_find(parent, node)
|
|
545
|
+
root = node
|
|
546
|
+
root = parent[root] while parent[root] != root
|
|
547
|
+
|
|
548
|
+
# Path compression
|
|
549
|
+
while parent[node] != root
|
|
550
|
+
next_node = parent[node]
|
|
551
|
+
parent[node] = root
|
|
552
|
+
node = next_node
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
root
|
|
556
|
+
end
|
|
557
|
+
|
|
558
|
+
def uf_union(parent, rank, a, b)
|
|
559
|
+
if rank[a] < rank[b]
|
|
560
|
+
parent[a] = b
|
|
561
|
+
elsif rank[a] > rank[b]
|
|
562
|
+
parent[b] = a
|
|
563
|
+
else
|
|
564
|
+
parent[b] = a
|
|
565
|
+
rank[a] += 1
|
|
566
|
+
end
|
|
567
|
+
end
|
|
568
|
+
|
|
569
|
+
# ── Max flow helpers ──
|
|
570
|
+
|
|
571
|
+
def edmonds_karp(source, sink)
|
|
572
|
+
# Build residual capacity graph
|
|
573
|
+
capacity = Hash.new { |h, k| h[k] = Hash.new(0) }
|
|
574
|
+
@adjacency.each do |from, edges_list|
|
|
575
|
+
edges_list.each do |edge|
|
|
576
|
+
capacity[from][edge[:node]] += edge[:weight]
|
|
577
|
+
end
|
|
578
|
+
end
|
|
579
|
+
|
|
580
|
+
total_flow = 0
|
|
581
|
+
loop do
|
|
582
|
+
# BFS to find augmenting path
|
|
583
|
+
parent = { source => nil }
|
|
584
|
+
visited = { source => true }
|
|
585
|
+
queue = [source]
|
|
586
|
+
|
|
587
|
+
until queue.empty?
|
|
588
|
+
node = queue.shift
|
|
589
|
+
break if node == sink
|
|
590
|
+
|
|
591
|
+
capacity[node].each do |neighbor, cap|
|
|
592
|
+
next unless cap.positive? && !visited[neighbor]
|
|
593
|
+
|
|
594
|
+
visited[neighbor] = true
|
|
595
|
+
parent[neighbor] = node
|
|
596
|
+
queue << neighbor
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
|
|
600
|
+
break unless visited[sink]
|
|
601
|
+
|
|
602
|
+
# Find bottleneck
|
|
603
|
+
path_flow = Float::INFINITY
|
|
604
|
+
node = sink
|
|
605
|
+
while parent[node]
|
|
606
|
+
path_flow = [path_flow, capacity[parent[node]][node]].min
|
|
607
|
+
node = parent[node]
|
|
608
|
+
end
|
|
609
|
+
|
|
610
|
+
# Update residual capacities
|
|
611
|
+
node = sink
|
|
612
|
+
while parent[node]
|
|
613
|
+
capacity[parent[node]][node] -= path_flow
|
|
614
|
+
capacity[node][parent[node]] += path_flow
|
|
615
|
+
node = parent[node]
|
|
616
|
+
end
|
|
617
|
+
|
|
618
|
+
total_flow += path_flow
|
|
619
|
+
end
|
|
620
|
+
|
|
621
|
+
total_flow
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# ── Bipartite helpers ──
|
|
625
|
+
|
|
626
|
+
def bipartite_neighbors(node)
|
|
627
|
+
result = Set.new
|
|
628
|
+
(@adjacency[node] || []).each { |e| result << e[:node] }
|
|
629
|
+
if @directed
|
|
630
|
+
@adjacency.each do |from, edges_list|
|
|
631
|
+
edges_list.each { |e| result << from if e[:node] == node }
|
|
632
|
+
end
|
|
633
|
+
end
|
|
634
|
+
result
|
|
635
|
+
end
|
|
636
|
+
|
|
637
|
+
# ── Tarjan's SCC ──
|
|
638
|
+
|
|
639
|
+
def tarjan_scc
|
|
640
|
+
index_counter = [0]
|
|
641
|
+
stack = []
|
|
642
|
+
on_stack = {}
|
|
643
|
+
index = {}
|
|
644
|
+
lowlink = {}
|
|
645
|
+
result = []
|
|
646
|
+
|
|
647
|
+
@adjacency.each_key do |node|
|
|
648
|
+
tarjan_visit(node, index_counter, stack, on_stack, index, lowlink, result) unless index.key?(node)
|
|
649
|
+
end
|
|
650
|
+
|
|
651
|
+
result
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
def tarjan_visit(node, index_counter, stack, on_stack, index, lowlink, result)
|
|
655
|
+
index[node] = index_counter[0]
|
|
656
|
+
lowlink[node] = index_counter[0]
|
|
657
|
+
index_counter[0] += 1
|
|
658
|
+
stack.push(node)
|
|
659
|
+
on_stack[node] = true
|
|
660
|
+
|
|
661
|
+
neighbors(node).each do |neighbor|
|
|
662
|
+
if !index.key?(neighbor)
|
|
663
|
+
tarjan_visit(neighbor, index_counter, stack, on_stack, index, lowlink, result)
|
|
664
|
+
lowlink[node] = [lowlink[node], lowlink[neighbor]].min
|
|
665
|
+
elsif on_stack[neighbor]
|
|
666
|
+
lowlink[node] = [lowlink[node], index[neighbor]].min
|
|
667
|
+
end
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
return unless lowlink[node] == index[node]
|
|
671
|
+
|
|
672
|
+
component = []
|
|
673
|
+
loop do
|
|
674
|
+
w = stack.pop
|
|
675
|
+
on_stack[w] = false
|
|
676
|
+
component << w
|
|
677
|
+
break if w == node
|
|
678
|
+
end
|
|
679
|
+
result << component
|
|
680
|
+
end
|
|
681
|
+
|
|
682
|
+
# ── DOT helpers ──
|
|
683
|
+
|
|
684
|
+
def dot_escape(value)
|
|
685
|
+
str = value.to_s
|
|
686
|
+
if str.match?(/\A[a-zA-Z_]\w*\z/)
|
|
687
|
+
str
|
|
688
|
+
else
|
|
689
|
+
"\"#{str.gsub('"', '\\"')}\""
|
|
690
|
+
end
|
|
691
|
+
end
|
|
322
692
|
end
|
|
323
693
|
end
|
|
324
694
|
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'json'
|
|
3
4
|
require 'set'
|
|
4
5
|
|
|
5
6
|
require_relative 'graph/version'
|
|
@@ -14,7 +15,7 @@ module Philiprehberger
|
|
|
14
15
|
# @param directed [Boolean] whether the graph is directed
|
|
15
16
|
# @return [Graph] a new graph instance
|
|
16
17
|
def self.new(directed: false)
|
|
17
|
-
Graph.new(directed: directed)
|
|
18
|
+
Philiprehberger::Graph::Graph.new(directed: directed)
|
|
18
19
|
end
|
|
19
20
|
end
|
|
20
21
|
end
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: philiprehberger-graph
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.3
|
|
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-
|
|
11
|
+
date: 2026-04-01 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
|
|
@@ -25,11 +25,11 @@ files:
|
|
|
25
25
|
- lib/philiprehberger/graph.rb
|
|
26
26
|
- lib/philiprehberger/graph/graph.rb
|
|
27
27
|
- lib/philiprehberger/graph/version.rb
|
|
28
|
-
homepage: https://
|
|
28
|
+
homepage: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-graph
|
|
29
29
|
licenses:
|
|
30
30
|
- MIT
|
|
31
31
|
metadata:
|
|
32
|
-
homepage_uri: https://
|
|
32
|
+
homepage_uri: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-graph
|
|
33
33
|
source_code_uri: https://github.com/philiprehberger/rb-graph
|
|
34
34
|
changelog_uri: https://github.com/philiprehberger/rb-graph/blob/main/CHANGELOG.md
|
|
35
35
|
bug_tracker_uri: https://github.com/philiprehberger/rb-graph/issues
|