philiprehberger-graph 0.1.5 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 58ecee5cdc4e683a9699583be9ea4cc23a846be284b0edcd4348383568473f59
4
- data.tar.gz: 97c6c8514e208148c2439b15da484600b7f68cd7270fdebe2c117180382c90de
3
+ metadata.gz: f15e96fcf2bfd1b6c453e5f52f5f956dc63d62eb385f6aeedea59073b31f0379
4
+ data.tar.gz: 1f82fde8213303ead0365ba95914531b482955bf9b50962227e24f6ad60e8c8c
5
5
  SHA512:
6
- metadata.gz: d1178e65af43d9f227a2e0dabf732736b26de308dde887bf425d9d0b6c5cc4f524c731836a4e8549105c01a479b09e53f77f3f48b42889d72c2dac865976d1ec
7
- data.tar.gz: 040bbd93ac021891cd12353d63a6339c09037623618468b94189eb0c15674407106768fe44225f50307ee986fd94eb9b03b919e3d04d361a61f144c1ea5477f5
6
+ metadata.gz: 21fee32160f5d5d391d383d779df1ab66ed9b66d66f50d1e9c3e32bfe47f35856722fd2ddde5617ca4c0066a91a97bb9aafe91cb90df73b427edd6b02726d1c2
7
+ data.tar.gz: 5c48eabd6cd2ea1e2454ce3d9aa842f5859f3045a098d8928e740f617149c17320150c148f03bba09a36c4ffb520909e463b1a3d51bb64514ecb65dc7a985abd
data/CHANGELOG.md CHANGED
@@ -7,6 +7,34 @@ 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
+
10
38
  ## [0.1.5] - 2026-03-26
11
39
 
12
40
  ### Changed
data/README.md CHANGED
@@ -2,10 +2,9 @@
2
2
 
3
3
  [![Tests](https://github.com/philiprehberger/rb-graph/actions/workflows/ci.yml/badge.svg)](https://github.com/philiprehberger/rb-graph/actions/workflows/ci.yml)
4
4
  [![Gem Version](https://badge.fury.io/rb/philiprehberger-graph.svg)](https://rubygems.org/gems/philiprehberger-graph)
5
- [![License](https://img.shields.io/github/license/philiprehberger/rb-graph)](LICENSE)
6
- [![Sponsor](https://img.shields.io/badge/sponsor-GitHub%20Sponsors-ec6cb9)](https://github.com/sponsors/philiprehberger)
5
+ [![Last updated](https://img.shields.io/github/last-commit/philiprehberger/rb-graph)](https://github.com/philiprehberger/rb-graph/commits/main)
7
6
 
8
- Directed and undirected graph with traversal, shortest path, and topological sort
7
+ Directed and undirected graph data structure with traversal, shortest path, MST, max flow, coloring, and serialization
9
8
 
10
9
  ## Requirements
11
10
 
@@ -77,6 +76,84 @@ g.add_edge(:c, :d)
77
76
  g.connected_components # => [[:a, :b], [:c, :d]]
78
77
  ```
79
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
+
80
157
  ## API
81
158
 
82
159
  | Method | Description |
@@ -96,6 +173,16 @@ g.connected_components # => [[:a, :b], [:c, :d]]
96
173
  | `#topological_sort` | Topological ordering (DAG only) |
97
174
  | `#cycle?` | Check for cycles |
98
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 |
99
186
 
100
187
  ## Development
101
188
 
@@ -105,6 +192,24 @@ bundle exec rspec
105
192
  bundle exec rubocop
106
193
  ```
107
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
+
108
213
  ## License
109
214
 
110
215
  [MIT](LICENSE)
@@ -213,16 +213,192 @@ module Philiprehberger
213
213
  components = []
214
214
 
215
215
  @adjacency.each_key do |node|
216
- unless visited[node]
217
- component = []
218
- cc_visit(node, visited, component)
219
- components << component
220
- end
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
- unless visited[node]
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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Philiprehberger
4
4
  module Graph
5
- VERSION = "0.1.5"
5
+ VERSION = '0.2.3'
6
6
  end
7
7
  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.1.5
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-03-27 00:00:00.000000000 Z
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://github.com/philiprehberger/rb-graph
28
+ homepage: https://philiprehberger.com/open-source-packages/ruby/philiprehberger-graph
29
29
  licenses:
30
30
  - MIT
31
31
  metadata:
32
- homepage_uri: https://github.com/philiprehberger/rb-graph
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