generalized-maxflow 0.3.0__py3-none-win_arm64.whl
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.
- generalized_maxflow-0.3.0.dist-info/METADATA +686 -0
- generalized_maxflow-0.3.0.dist-info/RECORD +10 -0
- generalized_maxflow-0.3.0.dist-info/WHEEL +5 -0
- generalized_maxflow-0.3.0.dist-info/entry_points.txt +3 -0
- generalized_maxflow-0.3.0.dist-info/licenses/LICENSE +21 -0
- gmaxflow/__init__.py +133 -0
- gmaxflow/__main__.py +62 -0
- gmaxflow/_native/gmaxflow_core.dll +0 -0
- gmaxflow/_native.py +75 -0
- gmaxflow/py.typed +0 -0
|
@@ -0,0 +1,686 @@
|
|
|
1
|
+
Metadata-Version: 2.1
|
|
2
|
+
Name: generalized-maxflow
|
|
3
|
+
Version: 0.3.0
|
|
4
|
+
Summary: Generalized maximum flow: maximum flow with per-edge gain multipliers
|
|
5
|
+
Keywords: maximum-flow,generalized-flow,network-flow,graph,optimization,lossy-network
|
|
6
|
+
Author: wesboyt
|
|
7
|
+
Maintainer: wesboyt
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 wesboyt
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
|
|
30
|
+
Classifier: Development Status :: 4 - Beta
|
|
31
|
+
Classifier: Intended Audience :: Science/Research
|
|
32
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
33
|
+
Classifier: Operating System :: OS Independent
|
|
34
|
+
Classifier: Programming Language :: C++
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: Implementation :: CPython
|
|
37
|
+
Classifier: Programming Language :: Python :: Implementation :: PyPy
|
|
38
|
+
Classifier: Topic :: Scientific/Engineering :: Mathematics
|
|
39
|
+
Project-URL: Homepage, https://github.com/wesboyt/generalized-maxflow
|
|
40
|
+
Project-URL: Source, https://github.com/wesboyt/generalized-maxflow
|
|
41
|
+
Project-URL: Issues, https://github.com/wesboyt/generalized-maxflow/issues
|
|
42
|
+
Requires-Python: >=3.8
|
|
43
|
+
Description-Content-Type: text/markdown
|
|
44
|
+
|
|
45
|
+
# generalized-maxflow
|
|
46
|
+
|
|
47
|
+
Maximum flow through a network whose edges **change the amount that passes
|
|
48
|
+
through them**.
|
|
49
|
+
|
|
50
|
+
Every edge carries a capacity and a multiplier. Pushing `x` units into an edge
|
|
51
|
+
consumes `x` units of its capacity and delivers `multiplier · x` units at the
|
|
52
|
+
far end, so flow can leak away (multiplier below 1), be conserved (exactly 1) or
|
|
53
|
+
be amplified (above 1). This is the *generalized maximum flow* problem, and it
|
|
54
|
+
is what you want for power transmission with line losses, currency conversion,
|
|
55
|
+
evaporation and spoilage in logistics, or any pipeline whose stages have a
|
|
56
|
+
yield.
|
|
57
|
+
|
|
58
|
+
The interface is a JSON adjacency list, the same for every binding:
|
|
59
|
+
|
|
60
|
+
```json
|
|
61
|
+
{
|
|
62
|
+
"source": "s",
|
|
63
|
+
"sink": "t",
|
|
64
|
+
"adjacency": {
|
|
65
|
+
"s": [{"to": "a", "capacity": 10, "multiplier": 1.0}],
|
|
66
|
+
"a": [{"to": "t", "capacity": 5, "multiplier": 2.0}]
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"status": "optimal",
|
|
74
|
+
"max_flow": 10,
|
|
75
|
+
"source_outflow": 5,
|
|
76
|
+
"paths": [
|
|
77
|
+
{
|
|
78
|
+
"kind": "path",
|
|
79
|
+
"flow_in": 5, "flow_out": 10, "gain": 2,
|
|
80
|
+
"nodes": ["s", "a", "t"],
|
|
81
|
+
"steps": [
|
|
82
|
+
{"edge": 0, "from": "s", "to": "a", "direction": "forward", "multiplier": 1, "flow_in": 5, "flow_out": 5},
|
|
83
|
+
{"edge": 1, "from": "a", "to": "t", "direction": "forward", "multiplier": 2, "flow_in": 5, "flow_out": 10}
|
|
84
|
+
],
|
|
85
|
+
"bottleneck": {"edge": 1, "from": "a", "to": "t", "direction": "forward", "step": 1}
|
|
86
|
+
}
|
|
87
|
+
],
|
|
88
|
+
"certificate": {"no_augmenting_path": true, "no_generating_cycle_reaching_sink": true}
|
|
89
|
+
}
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Note what the answer is **not**: 5. The constriction is `a -> t`, which admits
|
|
93
|
+
only 5 units, but those 5 units are doubled on the way through. A generalized
|
|
94
|
+
flow solver has to account for gain downstream of the constriction.
|
|
95
|
+
|
|
96
|
+
## Try it
|
|
97
|
+
|
|
98
|
+
### **[→ wesboyt.github.io/generalized-maxflow](https://wesboyt.github.io/generalized-maxflow/)**
|
|
99
|
+
|
|
100
|
+
The solver compiled to WebAssembly, running in the page: the worked examples
|
|
101
|
+
below plus a box for your own graphs. Nothing is uploaded — the whole solver is
|
|
102
|
+
148 KB of WebAssembly and every graph is solved locally.
|
|
103
|
+
|
|
104
|
+
To run it yourself:
|
|
105
|
+
|
|
106
|
+
```bash
|
|
107
|
+
bindings/wasm/build.sh # needs the Emscripten SDK
|
|
108
|
+
cp bindings/wasm/pkg/{index.mjs,gmaxflow.js,gmaxflow.wasm} docs/demo/
|
|
109
|
+
python -m http.server -d docs/demo 8000 # then open localhost:8000
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Every push also builds it in CI, downloadable as the `demo-site` artifact of the
|
|
113
|
+
**demo** workflow; running that workflow by hand republishes the hosted copy.
|
|
114
|
+
|
|
115
|
+
## Install
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
pip install generalized-maxflow
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
The wheels are tagged `py3-none-<platform>`: the solver is a plain C library
|
|
122
|
+
loaded through `ctypes`, never linked against libpython, so one wheel per OS and
|
|
123
|
+
CPU serves every CPython from 3.8 up, the free-threaded builds, and PyPy. There
|
|
124
|
+
are wheels for Linux (x86-64 and aarch64, glibc and musl), macOS (x86-64 and
|
|
125
|
+
arm64) and Windows (x86-64 and ARM64); anywhere else pip builds the sdist, which
|
|
126
|
+
needs CMake and a C++17 compiler. The solve releases the GIL.
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
npm install generalized-maxflow # WebAssembly build, browser and Node
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
From source you need CMake 3.18 and a C++17 compiler:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build
|
|
136
|
+
./build/gmaxflow graph.json
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Use it
|
|
140
|
+
|
|
141
|
+
Python:
|
|
142
|
+
|
|
143
|
+
```python
|
|
144
|
+
import gmaxflow
|
|
145
|
+
|
|
146
|
+
answer = gmaxflow.solve(graph) # graph is a dict or JSON text
|
|
147
|
+
print(answer["max_flow"])
|
|
148
|
+
|
|
149
|
+
for path in answer["paths"]:
|
|
150
|
+
print(path["nodes"], path["flow_in"], "->", path["flow_out"],
|
|
151
|
+
"constricted at", path["bottleneck"]["edge"])
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
JavaScript / WebAssembly:
|
|
155
|
+
|
|
156
|
+
```js
|
|
157
|
+
import createSolver from "generalized-maxflow";
|
|
158
|
+
|
|
159
|
+
const solver = await createSolver();
|
|
160
|
+
const answer = solver.solve(graph); // takes and returns plain objects
|
|
161
|
+
console.log(answer.max_flow);
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Command line (reads a file or stdin, writes JSON):
|
|
165
|
+
|
|
166
|
+
```bash
|
|
167
|
+
gmaxflow graph.json
|
|
168
|
+
cat graph.json | gmaxflow --compact
|
|
169
|
+
```
|
|
170
|
+
|
|
171
|
+
C++:
|
|
172
|
+
|
|
173
|
+
```cpp
|
|
174
|
+
#include "gmf/gmf.hpp"
|
|
175
|
+
|
|
176
|
+
gmf::Problem problem;
|
|
177
|
+
problem.num_nodes = 3;
|
|
178
|
+
problem.source = 0;
|
|
179
|
+
problem.sink = 2;
|
|
180
|
+
problem.edges = {{0, 1, 10.0, 1.0}, {1, 2, 5.0, 2.0}};
|
|
181
|
+
|
|
182
|
+
const gmf::Result result = gmf::solve(problem);
|
|
183
|
+
// result.max_flow == 10, result.augmentations holds the filled paths
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
## Input
|
|
187
|
+
|
|
188
|
+
| field | meaning |
|
|
189
|
+
| --- | --- |
|
|
190
|
+
| `source`, `sink` | node labels; any string or number. Aliases: `src`/`start`, `dst`/`target`/`end` |
|
|
191
|
+
| `adjacency` | `{tail: [edge, ...]}`, or an array whose index is the tail |
|
|
192
|
+
| `edges` | a flat list; each edge names its own `from` |
|
|
193
|
+
| `nodes` | optional, fixes the node ordering |
|
|
194
|
+
| `options` | see below |
|
|
195
|
+
|
|
196
|
+
An edge is an object with `to`, `capacity` and `multiplier` (aliases: `head`/`v`,
|
|
197
|
+
`cap`, `gain`/`mult`; `from`/`tail`/`u` when it is not implied by the adjacency
|
|
198
|
+
key), or a positional array — `[to, capacity, multiplier]` inside an adjacency
|
|
199
|
+
list, `[from, to, capacity, multiplier]` in an `edges` list. A missing or null
|
|
200
|
+
capacity means uncapacitated. Capacities must be non-negative and multipliers
|
|
201
|
+
finite and strictly positive.
|
|
202
|
+
|
|
203
|
+
| option | default | meaning |
|
|
204
|
+
| --- | --- | --- |
|
|
205
|
+
| `epsilon` | `1e-12` | relative tolerance, scaled by the largest capacity |
|
|
206
|
+
| `max_iterations` | size-derived | stop after this many augmentations |
|
|
207
|
+
| `collect_paths` | `true` | set false to drop the `paths` array and save memory |
|
|
208
|
+
| `cancel_generating_cycles` | `true` | set false to reject amplifying inputs instead of solving them |
|
|
209
|
+
| `use_potentials` | `true` | set false to force the slow reference search (for cross-checking) |
|
|
210
|
+
| `fat_path` | `true` | run the Tardos–Wayne Fat-Path scaling phases; false is plain highest-gain augmentation |
|
|
211
|
+
| `gain_scaling` | `true` | round gains to powers of $`(1+\xi)^{1/n}`$ inside those phases |
|
|
212
|
+
| `pretty` | `false` | indent the output |
|
|
213
|
+
|
|
214
|
+
## Output
|
|
215
|
+
|
|
216
|
+
`status` is one of `optimal`, `unbounded`, `iteration_limit`, `suboptimal`,
|
|
217
|
+
`generating_cycle_rejected` or `invalid_input`, and `optimal` is a boolean.
|
|
218
|
+
|
|
219
|
+
`max_flow` is what arrives at the sink and `source_outflow` is what left the
|
|
220
|
+
source; with lossy or amplifying edges these differ, and that is the point.
|
|
221
|
+
|
|
222
|
+
`paths` is the sequence of augmentations in the order they were filled, each
|
|
223
|
+
with the `nodes` it visits, the `steps` it took, the flow entering and leaving,
|
|
224
|
+
the `gain`, and the `bottleneck` — the constricting edge that decided how much
|
|
225
|
+
this path could carry. A step's `direction` is `backward` when the augmentation
|
|
226
|
+
pushed flow *back* over an edge to re-route earlier flow. A `kind` of
|
|
227
|
+
`flow_generating_cycle` means the entry is a cycle whose multipliers multiply to
|
|
228
|
+
more than one, together with the route carrying the flow it manufactured.
|
|
229
|
+
|
|
230
|
+
`certificate` is the proof the answer is maximum, and `stats` reports iterations,
|
|
231
|
+
searches, the Fat-Path phases and augmentations, the cycles cancelled, a timing
|
|
232
|
+
breakdown by phase and the solver's own memory footprint.
|
|
233
|
+
|
|
234
|
+
---
|
|
235
|
+
|
|
236
|
+
# The algorithm
|
|
237
|
+
|
|
238
|
+

|
|
239
|
+
|
|
240
|
+
## 1. The problem
|
|
241
|
+
|
|
242
|
+
Let $`G=(V,E)`$ be a directed graph with a source $`s`$ and a sink $`t`$. Each arc
|
|
243
|
+
$`e\in E`$ has a capacity $`u(e)\in[0,\infty]`$ and a **multiplier** (or gain)
|
|
244
|
+
$`\gamma(e)>0`$. A *generalized flow* is $`f:E\to\mathbb{R}`$ with
|
|
245
|
+
|
|
246
|
+
```math
|
|
247
|
+
0 \le f(e) \le u(e) \quad \forall e \in E,
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
```math
|
|
251
|
+
\sum_{e=(w,v)} \gamma(e)f(e) \;-\!\! \sum_{e=(v,w)} f(e) \;=\; 0
|
|
252
|
+
\qquad \forall v \in V\setminus\{s,t\}.
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Pushing $`f(e)`$ units into $`e=(v,w)`$ at $`v`$ delivers $`\gamma(e)f(e)`$ at $`w`$. The
|
|
256
|
+
objective is the net amount arriving at the sink,
|
|
257
|
+
|
|
258
|
+
```math
|
|
259
|
+
|f| \;=\; \sum_{e=(w,t)} \gamma(e)f(e) \;-\!\! \sum_{e=(t,w)} f(e),
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
to be maximised. With $`\gamma \equiv 1`$ this is ordinary maximum flow. It is a
|
|
263
|
+
linear program, which is how `tools/lp_check.py` verifies the solver
|
|
264
|
+
independently.
|
|
265
|
+
|
|
266
|
+
For a path or cycle $`P`$ write $`\gamma(P)=\prod_{e\in P}\gamma(e)`$. A cycle $`C`$
|
|
267
|
+
with $`\gamma(C)>1`$ is **flow-generating**: traversing it returns more than was
|
|
268
|
+
put in.
|
|
269
|
+
|
|
270
|
+
## 2. The residual network
|
|
271
|
+
|
|
272
|
+
Each arc $`e=(v,w)`$ contributes two residual arcs,
|
|
273
|
+
|
|
274
|
+
| arc | capacity | multiplier |
|
|
275
|
+
| --- | --- | --- |
|
|
276
|
+
| $`e`$, forward | $`u(e)-f(e)`$ | $`\gamma(e)`$ |
|
|
277
|
+
| $`e^{-1}=(w,v)`$, backward | $`\gamma(e)f(e)`$ | $`1/\gamma(e)`$ |
|
|
278
|
+
|
|
279
|
+
The backward capacity is what the arc *delivered*, and its multiplier is the
|
|
280
|
+
reciprocal, because retracting $`y`$ units at $`w`$ returns $`y/\gamma(e)`$ at $`v`$.
|
|
281
|
+
Pushing $`x`$ into either direction is the single rule
|
|
282
|
+
|
|
283
|
+
```math
|
|
284
|
+
r(a) \mathrel{-}= x, \qquad r(a^{-1}) \mathrel{+}= x\,\gamma(a).
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
Reverse arcs are required for correctness, not speed. Greedy highest-gain
|
|
288
|
+
filling without them gets stuck exactly as plain Ford–Fulkerson does: the
|
|
289
|
+
instance in `examples/rerouting.json` has optimum 3 and greedy reaches 2. Note
|
|
290
|
+
also that the reverse of a lossy arc is a gain-amplifying one, so after the
|
|
291
|
+
first augmentation no search may assume all multipliers are below 1.
|
|
292
|
+
|
|
293
|
+
## 3. The bottleneck obeys the chain rule
|
|
294
|
+
|
|
295
|
+
Along a path $`P=(a_1,\dots,a_k)`$ the amount entering $`a_i`$ when $`x`$ units enter
|
|
296
|
+
$`a_1`$ is $`x\prod_{j<i}\gamma(a_j)`$. The largest feasible $`x`$ is therefore
|
|
297
|
+
|
|
298
|
+
```math
|
|
299
|
+
\delta(P) \;=\; \min_{1\le i\le k} \; \frac{r(a_i)}{\prod_{j<i}\gamma(a_j)},
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
and the arc attaining the minimum is the **constricting edge**. It is not the
|
|
303
|
+
arc of smallest residual capacity: gain downstream of a constriction counts.
|
|
304
|
+
|
|
305
|
+
When a walk uses an arc in both directions — a cycle one way and the route
|
|
306
|
+
carrying its excess the other — the two uses *cancel* rather than compete.
|
|
307
|
+
Writing $`\alpha`$ for the total coefficient on the forward arc of an edge and
|
|
308
|
+
$`\beta`$ for that on its backward arc, the net change to that edge's flow per
|
|
309
|
+
unit $`x`$ is $`\nu = \alpha - \beta/\gamma`$, and the limit is
|
|
310
|
+
|
|
311
|
+
```math
|
|
312
|
+
x \;\le\; \begin{cases}
|
|
313
|
+
r(e)\,/\,\nu & \nu > 0,\\[2pt]
|
|
314
|
+
r(e^{-1})\,/\,(-\nu\,\gamma) & \nu < 0,\\[2pt]
|
|
315
|
+
\infty & \nu = 0.
|
|
316
|
+
\end{cases}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Charging the two directions separately stays *feasible*, which is why the error
|
|
320
|
+
is easy to miss; it only understates what the edge allows. It also made one
|
|
321
|
+
40-node instance repeat a single cycle forever with $`x`$ shrinking by a tenth
|
|
322
|
+
each time — 500 000 iterations moved the delivered flow by $`3\times10^{-6}`$.
|
|
323
|
+
|
|
324
|
+
## 4. Highest-gain augmentation
|
|
325
|
+
|
|
326
|
+
Maximising a product of multipliers is minimising a sum of logarithms. Set
|
|
327
|
+
|
|
328
|
+
```math
|
|
329
|
+
c(a) \;=\; -\ln \gamma(a),
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
so a highest-gain path is a shortest path under $`c`$, and a flow-generating cycle
|
|
333
|
+
is a negative-cost cycle. The solver works in this log space throughout, which
|
|
334
|
+
also stops long lossy chains from underflowing: fifty halvings give
|
|
335
|
+
$`\gamma(P)=2^{-50}`$, and a solver multiplying doubles loses such paths.
|
|
336
|
+
|
|
337
|
+
Onaga's loop: while a residual $`s\to t`$ path exists, augment along one of
|
|
338
|
+
maximum gain. The choice of *maximum* is what makes it correct. It is the exact
|
|
339
|
+
finish of this solver; most of the flow is first placed by the Fat-Path phases
|
|
340
|
+
of section 8, which choose paths by capacity as well as gain.
|
|
341
|
+
|
|
342
|
+
> **Theorem (Onaga, 1966).** If the residual network contains no
|
|
343
|
+
> flow-generating cycle, then augmenting along a maximum-gain path leaves it
|
|
344
|
+
> with none.
|
|
345
|
+
|
|
346
|
+
Augmenting along a lesser path can break that invariant, after which the
|
|
347
|
+
stopping test below no longer certifies anything.
|
|
348
|
+
|
|
349
|
+
## 5. Node potentials
|
|
350
|
+
|
|
351
|
+
Maintain $`\pi:V\to\mathbb{R}`$ with non-negative **reduced costs**
|
|
352
|
+
|
|
353
|
+
```math
|
|
354
|
+
c_\pi(a) \;=\; c(a) + \pi(v) - \pi(w) \;\ge\; 0 \qquad \forall a=(v,w)
|
|
355
|
+
\text{ residual}.
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
Two facts make this the centre of the implementation.
|
|
359
|
+
|
|
360
|
+
**Such a $`\pi`$ exists if and only if there is no flow-generating cycle.**
|
|
361
|
+
Summing $`c_\pi\ge 0`$ around a cycle telescopes to $`\sum c(a)\ge 0`$, i.e.
|
|
362
|
+
$`\gamma(C)\le 1`$. So a valid potential *is* a certificate that no amplifying
|
|
363
|
+
cycle exists, and the search for one is the same computation as the search for
|
|
364
|
+
the other.
|
|
365
|
+
|
|
366
|
+
**With $`\pi`$ in hand each augmentation is one Dijkstra.** Reduced costs are
|
|
367
|
+
non-negative, so the highest-gain path comes from an early-exit Dijkstra over
|
|
368
|
+
$`c_\pi`$ instead of a label-correcting sweep over every arc. Tardos and Wayne put
|
|
369
|
+
it the same way: *"an augmentation takes $`\tilde O(m)`$ time, using an algorithm
|
|
370
|
+
based on Dijkstra's shortest path algorithm."*
|
|
371
|
+
|
|
372
|
+
After a search that settles $`S`$ with distances $`d`$, stopping at $`d_{\text{stop}}`$:
|
|
373
|
+
|
|
374
|
+
```math
|
|
375
|
+
\pi(v) \;\mathrel{+}=\; d(v) - d_{\text{stop}} \qquad \forall v \in S,
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
leaving every other potential alone. This is valid because adding a constant to
|
|
379
|
+
*all* potentials changes no reduced cost, so the update is the "shift by
|
|
380
|
+
$`d_{\text{stop}}`$" that costs nothing plus a correction only on settled nodes.
|
|
381
|
+
For $`a=(v,w)`$ with $`v`$ settled and $`w`$ not, $`d(w)\ge d_{\text{stop}}`$ gives
|
|
382
|
+
$`c_\pi(a)+d(v)-d_{\text{stop}}\ge 0`$; the other cases are immediate. Arcs on the
|
|
383
|
+
chosen path end at $`c_\pi=0`$, hence so do their reverses, which is why the new
|
|
384
|
+
residual arcs an augmentation creates keep $`\pi`$ valid.
|
|
385
|
+
|
|
386
|
+
To re-establish $`\pi`$ from scratch, run a label-correcting sweep with every node
|
|
387
|
+
of the sink-reaching subgraph starting at $`\lambda(v)=0`$ and relaxing
|
|
388
|
+
$`\lambda(w)\ge\lambda(v)+\ln\gamma(a)`$; then $`\pi=-\lambda`$. Starting every node
|
|
389
|
+
at zero, rather than only the source, is what lets it find a cycle *anywhere* —
|
|
390
|
+
a cycle can manufacture flow with the source cut off entirely, as
|
|
391
|
+
`examples/currency_arbitrage.json` does. Any $`\lambda`$ satisfying the
|
|
392
|
+
constraints works, not just the minimal one, so after a push the sweep is
|
|
393
|
+
re-seeded only from the nodes that push disturbed.
|
|
394
|
+
|
|
395
|
+
## 6. Finding cycles without paying for them
|
|
396
|
+
|
|
397
|
+
The textbook negative-cycle test waits for a node to be relaxed more than $`|V|`$
|
|
398
|
+
times, which means up to $`|V|`$ passes over every arc *after* the cycle has
|
|
399
|
+
already formed. The parent forest closes within a few relaxations of the cycle
|
|
400
|
+
appearing, so the solver scans it for a cycle every $`|V|`$ relaxations — an
|
|
401
|
+
$`O(V)`$ walk with three-colour marking, so $`O(1)`$ amortised per relaxation. On a
|
|
402
|
+
dense amplifying network this replaced 14 million relaxations per cycle found
|
|
403
|
+
with a few thousand.
|
|
404
|
+
|
|
405
|
+
Two guards keep it honest. Relaxation requires improvement by at least
|
|
406
|
+
$`\varepsilon`$, so a chain can close on a cycle whose gain is not actually above
|
|
407
|
+
one; every hit is verified before being acted on. And parent pointers outlive
|
|
408
|
+
the search that set them, so a cycle read off them may name arcs that have since
|
|
409
|
+
been saturated — every arc is re-checked for usable residual, because acting on
|
|
410
|
+
a stale cycle once caused the solver to retire a live arc and return a flow 26%
|
|
411
|
+
below optimum labelled `optimal`.
|
|
412
|
+
|
|
413
|
+
## 7. Stopping, with a certificate
|
|
414
|
+
|
|
415
|
+
A flow-generating cycle together with a residual path from it to the sink is a
|
|
416
|
+
**generalized augmenting path** (GAP): it manufactures flow and delivers it.
|
|
417
|
+
|
|
418
|
+
> **Theorem (Onaga; Truemper, 1977).** A feasible generalized flow is maximum if
|
|
419
|
+
> and only if its residual network contains neither an $`s\to t`$ path nor a
|
|
420
|
+
> flow-generating cycle with a path to the sink.
|
|
421
|
+
|
|
422
|
+
This follows from LP duality. An improving direction is a flow in the residual
|
|
423
|
+
network conserved off $`\{s,t\}`$ that raises the net at $`t`$; decomposing it gives
|
|
424
|
+
source-to-sink paths, flow-generating cycles with their excess routed somewhere,
|
|
425
|
+
flow-absorbing cycles, and *bicycles* — a generating cycle joined to an
|
|
426
|
+
absorbing one. Only two kinds raise the net at $`t`$, so if any direction
|
|
427
|
+
improves, one of those two is present.
|
|
428
|
+
|
|
429
|
+
The solver stops on this condition and reports it. Both checks are recomputed
|
|
430
|
+
from scratch at the end, deliberately using the *slow* routines — plain
|
|
431
|
+
reachability for the path, full Bellman–Ford rounds for the cycle — rather than
|
|
432
|
+
the accelerated ones the main loop runs on, because a certificate sharing the
|
|
433
|
+
loop's heuristics could only confirm the loop's own blind spots. `status` is
|
|
434
|
+
`optimal` only when both hold, and the certificate also carries the largest
|
|
435
|
+
conservation error and capacity violation.
|
|
436
|
+
|
|
437
|
+
`tools/gap_probe.py` performs the same two checks in Python from the reported
|
|
438
|
+
edge flows, sharing no code with the solver.
|
|
439
|
+
|
|
440
|
+
## 8. Fat-Path with gain-scaling
|
|
441
|
+
|
|
442
|
+
Onaga's loop has no useful bound on the number of augmentations: highest-gain
|
|
443
|
+
first will happily spend an augmentation on a path that carries $`10^{-9}`$. The
|
|
444
|
+
solver therefore runs Tardos and Wayne's *Rounded Fat-Path* (IPCO 1998, §6)
|
|
445
|
+
first, and leaves Onaga only the last scrap of the gap.
|
|
446
|
+
|
|
447
|
+
**Fat paths.** Write $`\mu(v)`$ for the inverse of the best gain from $`v`$ to the
|
|
448
|
+
sink, the *canonical label*. An arc is $`\delta`$-*fat* when it can raise the
|
|
449
|
+
sink's excess by $`\delta`$, i.e. $`r(a)/\mu(v)\ge\delta`$, and a path is fat
|
|
450
|
+
when every arc is. With potentials, $`\mu(v)=e^{\pi(t)-\pi(v)}`$, so the test is
|
|
451
|
+
one comparison per arc against a per-node threshold. The phases are:
|
|
452
|
+
|
|
453
|
+
```math
|
|
454
|
+
\begin{aligned}
|
|
455
|
+
&\Delta \leftarrow \Delta_0 \ge \mathrm{OPT}-|f| \\
|
|
456
|
+
&\textbf{repeat}\\
|
|
457
|
+
&\quad \textsc{CancelCycles} \quad\text{(the relabelled network becomes lossy)}\\
|
|
458
|
+
&\quad \textsc{FatAugmentations}(\Delta/2m) \quad\text{(highest-gain fat paths until none)}\\
|
|
459
|
+
&\quad \Delta \leftarrow \Delta/2\\
|
|
460
|
+
&\textbf{until } \Delta \text{ is below the tolerance}
|
|
461
|
+
\end{aligned}
|
|
462
|
+
```
|
|
463
|
+
|
|
464
|
+
Each fat augmentation either delivers $`\delta=\Delta/2m`$ or empties its source,
|
|
465
|
+
so a phase makes at most $`n+2m`$ of them, and the discrepancy halves per phase:
|
|
466
|
+
$`O(\log(n/\xi))`$ phases for a $`\xi`$-optimal flow. $`\Delta_0`$ is the cheaper
|
|
467
|
+
of two cuts, what the sink's residual in-arcs can deliver and the relabelled
|
|
468
|
+
capacity out of the source; a phase in which nothing fat exists jumps $`\Delta`$
|
|
469
|
+
straight down to the fattest arc the search had to skip, since every path leaves
|
|
470
|
+
the fat subnetwork through one.
|
|
471
|
+
|
|
472
|
+
**Canonical labels.** Every phase starts from exact labels: one Dijkstra
|
|
473
|
+
*backwards* from the sink over the reduced costs gives $`D(v)`$, and
|
|
474
|
+
$`\pi(v)\leftarrow\pi(v)-D(v)`$ makes $`e^{\pi(v)-\pi(t)}`$ the true best gain to
|
|
475
|
+
the sink while keeping every reduced cost non-negative (it is the triangle
|
|
476
|
+
inequality $`D(v)\le c_\pi(v,w)+D(w)`$). Reading fatness off stale potentials
|
|
477
|
+
instead — which on a lossy network claim a gain of one everywhere — filled paths
|
|
478
|
+
that delivered almost nothing and cost 99 000 cycle cancellations on a 1 000-node
|
|
479
|
+
instance.
|
|
480
|
+
|
|
481
|
+
**Gain-scaling.** Rounding each gain down to a power of $`b=(1+\xi)^{1/n}`$ is
|
|
482
|
+
rounding its cost up to a multiple of $`q=\ln b`$. In the relabelled lossy network
|
|
483
|
+
those costs are the reduced costs, so the rounded network is a Dijkstra on the
|
|
484
|
+
integer keys $`\lceil c_\pi(a)/q\rceil`$. A search on those keys leaves every
|
|
485
|
+
reduced cost above $`-q`$ — never below, however many searches run — so the phases
|
|
486
|
+
keep $`q`$-optimal potentials, and CancelCycles need only remove cycles whose total
|
|
487
|
+
log-gain exceeds $`q`$: exactly the cycles a sweep that ignores improvements
|
|
488
|
+
smaller than $`q`$ cannot settle around. $`\xi`$ shrinks with $`\Delta`$ as
|
|
489
|
+
$`\xi=\min(\tfrac12,\Delta/\Delta_0)`$. Rounding costs at most a factor
|
|
490
|
+
$`b^{|P|}\le 1+\xi`$ on any path, which is Tardos and Wayne's Theorem 4.
|
|
491
|
+
|
|
492
|
+
**CancelCycles.** After a phase the potentials are valid on every arc except the
|
|
493
|
+
thin ones the phase skipped, and every generating cycle passes through one of
|
|
494
|
+
those. For a violating arc $`(v,w)`$ the most generating cycle through it is the
|
|
495
|
+
arc plus the highest-gain $`w\to v`$ path over the certified arcs — one early-exit
|
|
496
|
+
Dijkstra, after whose potential update the arc's reduced cost *is* the cycle's
|
|
497
|
+
log-loss. Cancelling through violating arcs first, and only then running the
|
|
498
|
+
label-correcting sweep for whatever two violating arcs share, cut a 3 200-node
|
|
499
|
+
amplifying instance from 5 878 cancellations and 15.9 s to 313 and 1.3 s. A
|
|
500
|
+
cancelled cycle's excess is routed to the sink by the highest-gain path over the
|
|
501
|
+
certified arcs, not the fewest hops: the excess belongs on the rest of the path
|
|
502
|
+
it was rerouted from, and an arbitrary route creates new cycles as fast as they
|
|
503
|
+
are removed.
|
|
504
|
+
|
|
505
|
+
**Two departures from the paper.** Both come from reporting a flow that conserves
|
|
506
|
+
at every node. The paper keeps its flow in the rounded network and
|
|
507
|
+
*interprets* it in the original at the end, which strands excess at interior
|
|
508
|
+
nodes; here a path chosen in the rounded network is pushed with its true gains,
|
|
509
|
+
so it delivers at least what the rounded network promised and nothing is
|
|
510
|
+
stranded. And the paper's CancelCycles leaves each cycle's excess on the cycle
|
|
511
|
+
for the next augmentations to collect; here it is carried to the sink with the
|
|
512
|
+
cycle, as a generalized augmenting path. The paper's exact algorithm ends by
|
|
513
|
+
rounding a $`B^{-4m}`$-optimal flow, which means nothing in floating point; instead
|
|
514
|
+
the phases stop once $`\Delta`$ is below the tolerance and Onaga's loop finishes
|
|
515
|
+
on the true gains, from a flow already within the last $`\Delta`$ of optimal.
|
|
516
|
+
`fat_path: false` and `gain_scaling: false` turn each layer off, and the tests
|
|
517
|
+
check all three variants against each other.
|
|
518
|
+
|
|
519
|
+
## 9. Complexity
|
|
520
|
+
|
|
521
|
+
Each search is $`O(m\log n)`$ with early exit. The Fat-Path phases bound the
|
|
522
|
+
augmentations at $`n+2m`$ per phase over $`O(\log(n/\xi))`$ phases, which is what
|
|
523
|
+
replaces Onaga's unbounded count — Onaga's algorithm alone *"may run in
|
|
524
|
+
exponential time (or may not even terminate for irrational inputs)"*. What the
|
|
525
|
+
implementation does not inherit is the paper's bound on CancelCycles, which
|
|
526
|
+
rests on Goldberg–Tarjan cancel-and-tighten over integer costs; the
|
|
527
|
+
violating-arc Dijkstra and the label-correcting sweep here measure faster on
|
|
528
|
+
every input tried but carry no such guarantee, and neither does the exact
|
|
529
|
+
finish. There is a limit, `1000 + 20(n+m)` iterations by default, and reaching
|
|
530
|
+
it is reported as `iteration_limit` rather than dressed up as an answer.
|
|
531
|
+
|
|
532
|
+
| | bound | note |
|
|
533
|
+
| --- | --- | --- |
|
|
534
|
+
| Goldberg–Plotkin–Tardos 1991 | first polynomial combinatorial | cancels *all* generating cycles as a batch |
|
|
535
|
+
| Goldfarb–Jin–Orlin 1997 | $`\tilde O(m^3\log B)`$ | capacity scaling, highest-gain paths |
|
|
536
|
+
| **Tardos–Wayne 1998** | matches best known | **implemented here** (section 8): Fat-Path with gain-scaling |
|
|
537
|
+
| Radzik 2004 | $`\tilde O(m^2 n\log B)`$ | best known weakly polynomial |
|
|
538
|
+
| Végh 2016 | strongly polynomial | first such result; continuous scaling |
|
|
539
|
+
| Olver–Végh 2017 | $`O((m+n\log n)\,mn\log(n^2/m))`$ | simpler and faster strongly polynomial |
|
|
540
|
+
|
|
541
|
+
The strongly polynomial results are landmarks but not implementation guides: at
|
|
542
|
+
$`n=10^3`$, $`m=5\times10^3`$ the Olver–Végh bound is on the order of $`10^{11}`$
|
|
543
|
+
operations, far above what augmenting paths cost in practice. The preflow-push
|
|
544
|
+
variant in the Tardos–Wayne paper is the natural next step.
|
|
545
|
+
|
|
546
|
+
Two notes on floating point. `epsilon` is relative, scaled by the largest finite
|
|
547
|
+
capacity, so "optimal" means optimal to that tolerance. An arc is retired from
|
|
548
|
+
the search only when nothing at all can move through it.
|
|
549
|
+
|
|
550
|
+
## 10. On concurrency
|
|
551
|
+
|
|
552
|
+
The breadth-first sweep is the one phase that fans out naturally, so it was
|
|
553
|
+
built multi-threaded, measured, and then removed. The measurements:
|
|
554
|
+
|
|
555
|
+
| | breadth-first sweep | share of solve | ceiling at 16 cores |
|
|
556
|
+
| --- | --- | --- | --- |
|
|
557
|
+
| lossy, 16k nodes | 0.001 s | **0.06 %** | 1.0006× |
|
|
558
|
+
| amplifying, 3.2k nodes | 0.232 s | 68 % | 2.7× in theory |
|
|
559
|
+
|
|
560
|
+
The dominant phase is Dijkstra, which is order-dependent: it settles nodes in
|
|
561
|
+
non-decreasing distance, and that order is what lets each node be finalised
|
|
562
|
+
once. Relaxed concurrent priority queues (skip-list or MultiQueue based) do give
|
|
563
|
+
parallel shortest paths, but they settle nodes out of order and re-settle them,
|
|
564
|
+
and here the *exact* maximum-gain path is what Onaga's invariant rests on — so
|
|
565
|
+
the risk lands on correctness, not just speed.
|
|
566
|
+
|
|
567
|
+
Meanwhile the parallelisable phase has been optimised into insignificance. The
|
|
568
|
+
sweep is 0.06 % of a lossy solve, so Amdahl caps the whole benefit at 1.0006×
|
|
569
|
+
no matter how many cores are thrown at it. On amplifying inputs there is real
|
|
570
|
+
headroom, but the absolute numbers are already a quarter of a second, each round
|
|
571
|
+
of the frontier is tens of microseconds of work, and a barrier costs a
|
|
572
|
+
comparable amount. Atomic doubles work — the removed implementation used
|
|
573
|
+
compare-exchange on `std::atomic<double>` — but WebAssembly needs
|
|
574
|
+
`SharedArrayBuffer` and cross-origin isolation for threads at all, so the
|
|
575
|
+
browser build would stay sequential regardless.
|
|
576
|
+
|
|
577
|
+
The parallel sweep as built was also 780× *slower* than the sequential one, for
|
|
578
|
+
two reasons worth recording: it spawned threads per frontier round rather than
|
|
579
|
+
once, and it lacked the parent-forest cycle check, so it ran the full $`|V|`$-round
|
|
580
|
+
bound while the sequential path aborted in a few thousand relaxations. Fixing
|
|
581
|
+
both would have bought a fraction of a 1.5× on one input class. It was removed
|
|
582
|
+
rather than kept.
|
|
583
|
+
|
|
584
|
+
## Performance
|
|
585
|
+
|
|
586
|
+
AMD, single core, release build. `layered` is $`\sqrt n`$ layers of $`\sqrt n`$
|
|
587
|
+
nodes — deep graphs where shortest paths are hundreds of arcs long; `uniform` is
|
|
588
|
+
a random graph.
|
|
589
|
+
|
|
590
|
+
| instance | nodes | edges | Fat-Path | Onaga only | solver memory |
|
|
591
|
+
| --- | --- | --- | --- | --- | --- |
|
|
592
|
+
| lossy, layered | 40 002 | 159 600 | **9.2 s** | 15.2 s | 18 MB |
|
|
593
|
+
| lossy, layered | 15 878 | 63 252 | **1.03 s** | 1.59 s | 7 MB |
|
|
594
|
+
| lossy, uniform | 16 000 | 80 000 | 0.10 s | 0.11 s | 6 MB |
|
|
595
|
+
| lossy, uniform | 100 000 | 500 000 | 0.78 s | **0.49 s** | 40 MB |
|
|
596
|
+
| lossy, uniform | 250 000 | 1 250 000 | 2.2 s | 2.5 s | 100 MB |
|
|
597
|
+
| amplifying, layered | 3 194 | 12 656 | 1.33 s | **1.15 s** | 1 MB |
|
|
598
|
+
| amplifying, uniform | 3 200 | 16 000 | 0.35 s | 0.36 s | 1 MB |
|
|
599
|
+
|
|
600
|
+
"Onaga only" is `fat_path: false`. Fat-Path pays off where paths are long and
|
|
601
|
+
many: on the deep layered networks it fills fat paths with smaller searches, and
|
|
602
|
+
the margin grows with size. On shallow random graphs a handful of highest-gain
|
|
603
|
+
augmentations already finish the job and the phases are overhead — a backward
|
|
604
|
+
Dijkstra and a potential repair each — and on amplifying inputs much of the work
|
|
605
|
+
is the input's own cycles, which both variants cancel the same way. Against the
|
|
606
|
+
same benchmark before the potentials work: the 16k layered case was 11.07 s and
|
|
607
|
+
the 3 200-node amplifying case was **588 s**. Run it yourself with
|
|
608
|
+
`build/gmf_bench`, or `gmf_bench layered 16000 4 0.95 onaga` (or `exact`, for
|
|
609
|
+
Fat-Path without the gain rounding) to compare.
|
|
610
|
+
|
|
611
|
+
The layout is built for the inner loop. Arcs are renumbered so those leaving a
|
|
612
|
+
node are contiguous, and the relaxation reads head, residual and cost straight
|
|
613
|
+
through memory with no indirection. Multiplier and capacity live once per edge
|
|
614
|
+
rather than twice per arc — and are not copied from the caller's problem at all.
|
|
615
|
+
Incoming arcs are derived as twins of outgoing ones instead of a second
|
|
616
|
+
adjacency. Scratch arrays are stamped rather than cleared, so a search costs the
|
|
617
|
+
subgraph it touches rather than $`O(V)`$. The steps of every augmentation share
|
|
618
|
+
one array at 16 bytes each; `collect_paths: false` drops it entirely.
|
|
619
|
+
|
|
620
|
+
## How it is checked
|
|
621
|
+
|
|
622
|
+
- Networks whose multipliers are all 1 are compared against an independent
|
|
623
|
+
Dinic maximum flow implementation; 300 random instances per run.
|
|
624
|
+
- `tools/lp_check.py` states the problem as a linear program and solves it with
|
|
625
|
+
scipy's HiGHS, which shares no code with the solver. Several thousand random
|
|
626
|
+
instances across lossy and amplifying regimes, up to 80 nodes, agree to 1e-6.
|
|
627
|
+
This is what caught every defect in `tests/test_regression.cpp`, including one
|
|
628
|
+
that only appeared on a single 40-node graph after thousands had passed.
|
|
629
|
+
- `tools/gap_probe.py` rebuilds the residual network from a reported answer in
|
|
630
|
+
Python and independently looks for the two things the certificate claims are
|
|
631
|
+
absent.
|
|
632
|
+
- The Dijkstra engine is checked against the label-correcting reference
|
|
633
|
+
(`use_potentials: false`) on random instances, and every answer is checked for
|
|
634
|
+
capacity feasibility and flow conservation.
|
|
635
|
+
- Fat-Path with gain-scaling, Fat-Path on exact gains and plain Onaga
|
|
636
|
+
(`fat_path: false`) are checked against each other on 240 random instances
|
|
637
|
+
across both regimes.
|
|
638
|
+
- `tests/test_regression.cpp` holds the instances that once produced a wrong
|
|
639
|
+
answer, each with the optimum from the linear program and a note on the defect
|
|
640
|
+
it caught.
|
|
641
|
+
|
|
642
|
+
```bash
|
|
643
|
+
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build
|
|
644
|
+
./build/gmf_tests
|
|
645
|
+
python tools/lp_check.py --trials 400 --max-multiplier 2.0
|
|
646
|
+
python tools/lp_check.py --graph examples/power_grid.json
|
|
647
|
+
pytest tests/test_python.py
|
|
648
|
+
```
|
|
649
|
+
|
|
650
|
+
## Examples
|
|
651
|
+
|
|
652
|
+
`examples/` holds three graphs worth running, each annotated with what it shows:
|
|
653
|
+
|
|
654
|
+
| file | what it shows |
|
|
655
|
+
| --- | --- |
|
|
656
|
+
| `power_grid.json` | transmission with line losses: 1012 units leave the plant, 918 reach the city |
|
|
657
|
+
| `rerouting.json` | the optimum needs flow pushed *back* over an edge; greedy filling stops at 2 of 3 |
|
|
658
|
+
| `currency_arbitrage.json` | exchange rates as multipliers, so a rate cycle whose product exceeds 1 manufactures 69.78 USD from no starting capital |
|
|
659
|
+
|
|
660
|
+
```bash
|
|
661
|
+
./build/gmaxflow examples/currency_arbitrage.json
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
## References
|
|
665
|
+
|
|
666
|
+
- K. Onaga, *Dynamic programming of optimum flows in lossy communication nets*,
|
|
667
|
+
IEEE Trans. Circuit Theory, 1966.
|
|
668
|
+
- K. Truemper, *On max flows with gains and pure min-cost flows*, SIAM J. Appl.
|
|
669
|
+
Math., 1977.
|
|
670
|
+
- A. V. Goldberg, S. A. Plotkin, É. Tardos, *Combinatorial algorithms for the
|
|
671
|
+
generalized circulation problem*, Math. of OR 16(2), 1991.
|
|
672
|
+
- D. Goldfarb, Z. Jin, J. B. Orlin, *Polynomial-time highest-gain augmenting path
|
|
673
|
+
algorithms for the generalized circulation problem*, Math. of OR 22(4), 1997.
|
|
674
|
+
- É. Tardos, K. D. Wayne, *Simple generalized maximum flow algorithms*, IPCO
|
|
675
|
+
1998. ([pdf](https://www.cs.princeton.edu/~wayne/papers/gain-scaling.pdf))
|
|
676
|
+
- T. Radzik, *Improving time bounds on maximum generalised flow computations*,
|
|
677
|
+
Theor. Comput. Sci. 312, 2004.
|
|
678
|
+
- L. A. Végh, *A strongly polynomial algorithm for generalized flow
|
|
679
|
+
maximization*, Math. of OR 42(2), 2017.
|
|
680
|
+
- N. Olver, L. A. Végh, *A simpler and faster strongly polynomial algorithm for
|
|
681
|
+
generalized flow maximization*, J. ACM 67(2), 2020.
|
|
682
|
+
- R. K. Ahuja, T. L. Magnanti, J. B. Orlin, *Network Flows*, 1993, chapter 15.
|
|
683
|
+
|
|
684
|
+
## License
|
|
685
|
+
|
|
686
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
gmaxflow/__init__.py,sha256=ewcr-uowEcrAYXQaHY5tLv9YG_WkdYiwD4Ar98HQCtE,4205
|
|
2
|
+
gmaxflow/__main__.py,sha256=V1aoKenDW4BcfApRmI1Bz-51CsK5W2ShlrtkJ-fleM4,1964
|
|
3
|
+
gmaxflow/_native/gmaxflow_core.dll,sha256=oOuFRGfIOERHwKqFL8WOnvgvZ1z6YkozxgjKlzvTsfw,267776
|
|
4
|
+
gmaxflow/_native.py,sha256=jA4yv3i8_VAVWhvs_sIUCTjmSfaZ7_yn7rNym0uqIcc,2265
|
|
5
|
+
gmaxflow/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
generalized_maxflow-0.3.0.dist-info/METADATA,sha256=G5oiIfA-xV5JjyGX5faQn_UXF0jBEYRYiwPtfg4bIiQ,32219
|
|
7
|
+
generalized_maxflow-0.3.0.dist-info/WHEEL,sha256=D69HPxC3SlbabnD-f_ROoJhjrQemtxP7K3VXxdgu2k4,102
|
|
8
|
+
generalized_maxflow-0.3.0.dist-info/entry_points.txt,sha256=GatboyWibkoW3FvBO4RD0iTgwsawgaHMoDpzS-BVnPo,53
|
|
9
|
+
generalized_maxflow-0.3.0.dist-info/licenses/LICENSE,sha256=UEVF4aLh-igVqqdFowYCunJg4iQBDLWriO1w89Mq4lM,1064
|
|
10
|
+
generalized_maxflow-0.3.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 wesboyt
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
gmaxflow/__init__.py
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""Generalized maximum flow: maximum flow with per-edge gain multipliers.
|
|
2
|
+
|
|
3
|
+
Every edge carries a capacity and a multiplier. Pushing x units into an edge
|
|
4
|
+
consumes x units of its capacity and delivers ``multiplier * x`` units at the
|
|
5
|
+
other end, so flow can be lost (a multiplier below 1) or amplified (above 1).
|
|
6
|
+
The solver maximises what arrives at the sink.
|
|
7
|
+
|
|
8
|
+
>>> import gmaxflow
|
|
9
|
+
>>> gmaxflow.max_flow({
|
|
10
|
+
... "source": "s",
|
|
11
|
+
... "sink": "t",
|
|
12
|
+
... "adjacency": {
|
|
13
|
+
... "s": [{"to": "a", "capacity": 10, "multiplier": 1.0}],
|
|
14
|
+
... "a": [{"to": "t", "capacity": 5, "multiplier": 2.0}],
|
|
15
|
+
... },
|
|
16
|
+
... })
|
|
17
|
+
10.0
|
|
18
|
+
|
|
19
|
+
The answer also carries the sequence of paths that were filled, each with the
|
|
20
|
+
flow that entered it, the flow it delivered and the edge that constricted it::
|
|
21
|
+
|
|
22
|
+
answer = gmaxflow.solve(graph)
|
|
23
|
+
for path in answer["paths"]:
|
|
24
|
+
print(path["nodes"], path["flow_in"], "->", path["flow_out"])
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import json
|
|
30
|
+
from typing import Any, Dict, Mapping, Optional, Union
|
|
31
|
+
|
|
32
|
+
from . import _native
|
|
33
|
+
|
|
34
|
+
__all__ = [
|
|
35
|
+
"solve",
|
|
36
|
+
"solve_json",
|
|
37
|
+
"max_flow",
|
|
38
|
+
"version",
|
|
39
|
+
"InvalidGraphError",
|
|
40
|
+
"NotOptimalError",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
Graph = Union[Mapping[str, Any], str, bytes]
|
|
44
|
+
|
|
45
|
+
class InvalidGraphError(ValueError):
|
|
46
|
+
"""The graph itself was rejected: a bad endpoint, capacity or multiplier."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class NotOptimalError(RuntimeError):
|
|
50
|
+
"""The solver stopped without certifying the answer as maximum.
|
|
51
|
+
|
|
52
|
+
The partial answer is on the exception as ``.answer``.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
def __init__(self, message: str, answer: Dict[str, Any]) -> None:
|
|
56
|
+
super().__init__(message)
|
|
57
|
+
self.answer = answer
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def solve_json(request: str) -> str:
|
|
61
|
+
"""Solve a problem given as a JSON document and return a JSON document.
|
|
62
|
+
|
|
63
|
+
This is the raw interface the C++ library exposes; the bytes are not parsed
|
|
64
|
+
on the way in or out.
|
|
65
|
+
"""
|
|
66
|
+
return _native.solve_json(request)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def solve(
|
|
70
|
+
graph: Graph,
|
|
71
|
+
*,
|
|
72
|
+
epsilon: Optional[float] = None,
|
|
73
|
+
max_iterations: Optional[int] = None,
|
|
74
|
+
collect_paths: Optional[bool] = None,
|
|
75
|
+
cancel_generating_cycles: Optional[bool] = None,
|
|
76
|
+
fat_path: Optional[bool] = None,
|
|
77
|
+
gain_scaling: Optional[bool] = None,
|
|
78
|
+
require_optimal: bool = False,
|
|
79
|
+
) -> Dict[str, Any]:
|
|
80
|
+
"""Solve a generalized maximum flow problem.
|
|
81
|
+
|
|
82
|
+
`graph` is a mapping (or JSON text) with a ``source``, a ``sink`` and either
|
|
83
|
+
an ``adjacency`` map or an ``edges`` list. Options may be passed here as
|
|
84
|
+
keywords or inside the graph under ``options``; keywords win.
|
|
85
|
+
|
|
86
|
+
With `require_optimal`, an answer the solver could not certify raises
|
|
87
|
+
`NotOptimalError` instead of being returned.
|
|
88
|
+
|
|
89
|
+
Raises `InvalidGraphError` when the graph is malformed.
|
|
90
|
+
"""
|
|
91
|
+
if isinstance(graph, (str, bytes)):
|
|
92
|
+
request = json.loads(graph)
|
|
93
|
+
else:
|
|
94
|
+
request = dict(graph)
|
|
95
|
+
if not isinstance(request, dict):
|
|
96
|
+
raise InvalidGraphError("the graph must be a JSON object")
|
|
97
|
+
|
|
98
|
+
options = dict(request.get("options") or {})
|
|
99
|
+
overrides = {
|
|
100
|
+
"epsilon": epsilon,
|
|
101
|
+
"max_iterations": max_iterations,
|
|
102
|
+
"collect_paths": collect_paths,
|
|
103
|
+
"cancel_generating_cycles": cancel_generating_cycles,
|
|
104
|
+
"fat_path": fat_path,
|
|
105
|
+
"gain_scaling": gain_scaling,
|
|
106
|
+
}
|
|
107
|
+
options.update({name: value for name, value in overrides.items() if value is not None})
|
|
108
|
+
if options:
|
|
109
|
+
request["options"] = options
|
|
110
|
+
|
|
111
|
+
answer = json.loads(_native.solve_json(json.dumps(request)))
|
|
112
|
+
|
|
113
|
+
if answer.get("status") == "invalid_input":
|
|
114
|
+
raise InvalidGraphError(answer.get("error", "invalid graph"))
|
|
115
|
+
if require_optimal and not answer.get("optimal"):
|
|
116
|
+
raise NotOptimalError(
|
|
117
|
+
f"{answer.get('status')}: {answer.get('message', '')}".strip(), answer
|
|
118
|
+
)
|
|
119
|
+
return answer
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def max_flow(graph: Graph, **options: Any) -> float:
|
|
123
|
+
"""The maximum flow delivered at the sink.
|
|
124
|
+
|
|
125
|
+
Accepts the same options as `solve`. Returns ``float('inf')`` when the
|
|
126
|
+
network admits unbounded flow.
|
|
127
|
+
"""
|
|
128
|
+
return float(solve(graph, **options)["max_flow"])
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def version() -> str:
|
|
132
|
+
"""The version of the native solver."""
|
|
133
|
+
return _native.version()
|
gmaxflow/__main__.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Command line entry point: ``gmaxflow graph.json`` or JSON on stdin."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import Optional, Sequence
|
|
9
|
+
|
|
10
|
+
from . import solve_json, version
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: Optional[Sequence[str]] = None) -> int:
|
|
14
|
+
parser = argparse.ArgumentParser(
|
|
15
|
+
prog="gmaxflow",
|
|
16
|
+
description="Generalized maximum flow: maximum flow with per-edge gain multipliers.",
|
|
17
|
+
)
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"graph",
|
|
20
|
+
nargs="?",
|
|
21
|
+
default="-",
|
|
22
|
+
help="a JSON file holding the graph; omit it to read stdin",
|
|
23
|
+
)
|
|
24
|
+
parser.add_argument("-o", "--output", help="write the answer here instead of stdout")
|
|
25
|
+
parser.add_argument("-c", "--compact", action="store_true", help="emit one line of JSON")
|
|
26
|
+
parser.add_argument("-v", "--version", action="version", version=version())
|
|
27
|
+
arguments = parser.parse_args(argv)
|
|
28
|
+
|
|
29
|
+
if arguments.graph == "-":
|
|
30
|
+
text = sys.stdin.read()
|
|
31
|
+
else:
|
|
32
|
+
with open(arguments.graph, encoding="utf-8") as handle:
|
|
33
|
+
text = handle.read()
|
|
34
|
+
if not text.strip():
|
|
35
|
+
parser.error("no input: pass a file or pipe JSON on stdin")
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
request = json.loads(text)
|
|
39
|
+
except json.JSONDecodeError as problem:
|
|
40
|
+
print(json.dumps({"status": "invalid_input", "error": str(problem)}), file=sys.stdout)
|
|
41
|
+
return 1
|
|
42
|
+
if isinstance(request, dict):
|
|
43
|
+
options = dict(request.get("options") or {})
|
|
44
|
+
options["pretty"] = not arguments.compact
|
|
45
|
+
request["options"] = options
|
|
46
|
+
|
|
47
|
+
answer = solve_json(json.dumps(request))
|
|
48
|
+
|
|
49
|
+
if arguments.output:
|
|
50
|
+
with open(arguments.output, "w", encoding="utf-8") as handle:
|
|
51
|
+
handle.write(answer + "\n")
|
|
52
|
+
else:
|
|
53
|
+
sys.stdout.write(answer + "\n")
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
return 1 if json.loads(answer).get("status") == "invalid_input" else 0
|
|
57
|
+
except json.JSONDecodeError:
|
|
58
|
+
return 1
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
sys.exit(main())
|
|
Binary file
|
gmaxflow/_native.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""ctypes binding to the native solver. Internal; use the public functions.
|
|
2
|
+
|
|
3
|
+
The solver is a shared library with a plain C ABI, so it never links against
|
|
4
|
+
libpython: one wheel per OS and CPU serves every CPython, free-threaded builds
|
|
5
|
+
included, and PyPy. ctypes releases the GIL for the length of each call, so
|
|
6
|
+
other threads keep running while a large graph is solved.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ctypes
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
16
|
+
_ENV = "GMAXFLOW_LIB"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _library_path() -> str:
|
|
20
|
+
"""$GMAXFLOW_LIB if set, else the library inside the installed package."""
|
|
21
|
+
override = os.environ.get(_ENV)
|
|
22
|
+
if override:
|
|
23
|
+
return override
|
|
24
|
+
name = {"win32": "gmaxflow_core.dll", "darwin": "gmaxflow_core.dylib"}.get(
|
|
25
|
+
sys.platform, "gmaxflow_core.so"
|
|
26
|
+
)
|
|
27
|
+
return os.path.join(_HERE, "_native", name)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load() -> ctypes.CDLL:
|
|
31
|
+
path = _library_path()
|
|
32
|
+
if not os.path.exists(path):
|
|
33
|
+
raise ImportError(
|
|
34
|
+
"the gmaxflow native solver was not found (%s). Install a wheel "
|
|
35
|
+
"(`pip install generalized-maxflow`), or build from a checkout with "
|
|
36
|
+
"`pip install .`; a library built by hand with CMake can be named in "
|
|
37
|
+
"$%s." % (path, _ENV)
|
|
38
|
+
)
|
|
39
|
+
return ctypes.CDLL(path)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_lib = _load()
|
|
43
|
+
|
|
44
|
+
_lib.gmf_solve_json_n.restype = ctypes.c_void_p
|
|
45
|
+
_lib.gmf_solve_json_n.argtypes = [
|
|
46
|
+
ctypes.c_char_p,
|
|
47
|
+
ctypes.c_size_t,
|
|
48
|
+
ctypes.POINTER(ctypes.c_size_t),
|
|
49
|
+
]
|
|
50
|
+
_lib.gmf_free.restype = None
|
|
51
|
+
_lib.gmf_free.argtypes = [ctypes.c_void_p]
|
|
52
|
+
_lib.gmf_version.restype = ctypes.c_char_p
|
|
53
|
+
_lib.gmf_version.argtypes = []
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def solve_json(request: str) -> str:
|
|
57
|
+
"""Solve the JSON request and return the JSON answer."""
|
|
58
|
+
data = request.encode("utf-8")
|
|
59
|
+
size = ctypes.c_size_t(0)
|
|
60
|
+
answer = _lib.gmf_solve_json_n(data, len(data), ctypes.byref(size))
|
|
61
|
+
if not answer:
|
|
62
|
+
raise MemoryError("the solver ran out of memory")
|
|
63
|
+
try:
|
|
64
|
+
return ctypes.string_at(answer, size.value).decode("utf-8")
|
|
65
|
+
finally:
|
|
66
|
+
_lib.gmf_free(answer)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def version() -> str:
|
|
70
|
+
return _lib.gmf_version().decode("ascii")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def library_path() -> str:
|
|
74
|
+
"""Where the loaded native library lives, for diagnostics."""
|
|
75
|
+
return _library_path()
|
gmaxflow/py.typed
ADDED
|
File without changes
|