bmssp 0.18.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,64 +1,130 @@
1
1
  # BMSSP: Bounded Multi-Source Shortest Paths
2
+
2
3
  [![codecov](https://codecov.io/gh/sirivasv/bmssp-js/branch/main/graph/badge.svg)](https://codecov.io/gh/sirivasv/bmssp-js)
3
4
  [![npm version](https://img.shields.io/npm/v/bmssp.svg)](https://www.npmjs.com/package/bmssp)
4
5
  [![Docker Image Version](https://img.shields.io/docker/v/sirivasv/bmssp-js?label=docker&sort=semver)](https://hub.docker.com/r/sirivasv/bmssp-js)
5
6
  [![GitHub Repo stars](https://img.shields.io/github/stars/sirivasv/bmssp-js?style=social)](https://github.com/sirivasv/bmssp-js/stargazers)
6
7
 
7
- This repository provides a community-driven JavaScript implementation of the Tsinghua Single Source Shortest Paths algorithm, based on the paper ["Breaking the Sorting Barrier for Directed Single-Source Shortest Paths"](https://dl.acm.org/doi/10.1145/3717823.3718179) by Duan Ran et al. from Tsinghua University.
8
-
9
- BMSSP stands for Bounded Multi-Source Shortest Paths.
10
-
11
- ## Project Overview
12
-
13
- - **Language:** JavaScript (ES Modules)
14
- - **Goal:** Provide an easy-to-use, modern implementation of the algorithm, published to [npmjs.com](https://www.npmjs.com/).
15
- - **Reference:** For more on ES modules, see the [MDN documentation](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Modules).
16
-
8
+ A community-driven JavaScript (ES Modules) implementation of the Tsinghua single-source
9
+ shortest-paths algorithm from the paper
10
+ ["Breaking the Sorting Barrier for Directed Single-Source Shortest Paths"](https://dl.acm.org/doi/10.1145/3717823.3718179)
11
+ (Duan, Mao, Mao, Shu, Yin — 2025): a deterministic **O(m·log^(2/3) n)** SSSP algorithm, the
12
+ first to beat Dijkstra's O(m + n·log n) bound on sparse directed graphs.
13
+
14
+ **BMSSP** stands for **B**ounded **M**ulti-**S**ource **S**hortest **P**aths.
15
+
16
+ ## Project status
17
+
18
+ **The algorithm is functional end-to-end as of `1.0.0`** 🎉 — `calculateShortestPaths()`
19
+ computes distances via the paper's method (FindPivots → block list → recursive BMSSP with
20
+ the bounded base case), validated node-by-node against a Dijkstra oracle, including on a
21
+ real 2-million-node road network. The project was built piece by piece against the paper,
22
+ with every building block shipped, tested, and released individually:
23
+
24
+ | Building block (paper) | Where | Status |
25
+ | --- | --- | --- |
26
+ | Reference Dijkstra oracle (ground truth for tests) | `src/dijkstra.mjs` | ✅ done |
27
+ | O(1) adjacency map for edge relaxation ([#45](https://github.com/Sirivasv/bmssp-js/issues/45)) | `BMSSP` constructor | ✅ done |
28
+ | Lemma 3.3 block-based partial-sort structure `D` ([#42](https://github.com/Sirivasv/bmssp-js/issues/42)) | `src/blockList.mjs` | ✅ done |
29
+ | Indexed binary min-heap ([#41](https://github.com/Sirivasv/bmssp-js/issues/41)) | `src/heap.mjs` | ✅ done |
30
+ | `BaseCase(B, S)` — Algorithm 2, bounded mini-Dijkstra ([#40](https://github.com/Sirivasv/bmssp-js/issues/40)) | `src/baseCase.mjs` | ✅ done |
31
+ | `FindPivots(B, S)` — Algorithm 1, frontier shrinking ([#44](https://github.com/Sirivasv/bmssp-js/issues/44)) | `src/findPivots.mjs` | ✅ done |
32
+ | `BMSSP(l, B, S)` — Algorithm 3, the main recursion ([#43](https://github.com/Sirivasv/bmssp-js/issues/43)) | `src/bmssp.mjs` | ✅ done — **1.0.0** |
33
+
34
+ > **Honest note:** the paper's win is asymptotic, and this repo optimizes for correctness
35
+ > and readability, not raw speed. On the full California road network (~2 M nodes, ~5.5 M
36
+ > edges) this implementation currently runs about 2× slower than its own reference
37
+ > Dijkstra — consistent with published experimental studies of the algorithm. Where inputs
38
+ > violate the paper's distinct-path-lengths assumption (e.g. zero-weight edges), documented
39
+ > tie guards keep the results correct
40
+ > ([#163](https://github.com/Sirivasv/bmssp-js/issues/163) tracks a principled tie-break).
41
+
42
+ ## How it works (in two ideas)
43
+
44
+ 1. **Shrink the frontier with pivots.** Instead of keeping every frontier vertex in a
45
+ priority queue like Dijkstra, run `k` rounds of Bellman-Ford-style relaxation and recurse
46
+ only on the "pivots" — roots of large shortest-path trees. Only ~1/k of the frontier
47
+ needs the expensive treatment.
48
+ 2. **Partial sorting instead of a heap.** A block-based structure keeps batches of vertices
49
+ ordered *between* blocks but unsorted *within* them — enough to repeatedly pull the next
50
+ closest batch without paying the Θ(log n)-per-vertex "sorting barrier."
51
+
52
+ The asymptotic win is theoretical (the crossover point is astronomically large); this repo
53
+ optimizes for a **correct, readable, well-tested** implementation, validated line-by-line
54
+ against a Dijkstra oracle — not for raw speed.
17
55
 
18
56
  ## Installation
19
57
 
20
- To install this package, you can use npm:
21
-
22
58
  ```bash
23
59
  npm install bmssp
24
60
  ```
25
61
 
26
62
  ## Usage
27
63
 
28
- To use this package, you can import it in your JavaScript code as follows:
64
+ The package is ESM-only (`.mjs`). Graphs are arrays of `[from, to, weight]` edges with
65
+ numeric node IDs and non-negative weights:
29
66
 
30
67
  ```javascript
31
- // This is a WIP example
32
68
  import { BMSSP } from "bmssp";
33
69
 
34
- const myBMSSP = new BMSSP([
70
+ const graph = new BMSSP([
35
71
  [0, 1, 50],
36
72
  [1, 2, 75],
37
73
  [0, 2, 25],
38
74
  ]);
39
75
 
40
- console.log(myBMSSP.graph);
76
+ graph.calculateShortestPaths(0);
77
+ console.log(graph.shortestPaths); // Map(3) { 0 => 0, 1 => 50, 2 => 25 }
41
78
  ```
42
79
 
43
- The file must use ECMAScript modules (ESM) syntax and have the `.mjs` file extension. Go to the `examples` directory for more usage examples.
80
+ A reference `dijkstra` implementation is also exported. See the `examples/` directory for
81
+ more.
44
82
 
45
- ### Using the docker image
83
+ ### Using the Docker image
46
84
 
47
- You can also use the published docker image and run the example:
85
+ Run the bundled example:
48
86
 
49
87
  ```bash
50
88
  docker run -it sirivasv/bmssp-js:latest
51
89
  ```
52
90
 
53
- Or your tests in a pre-configured environment (replace `folder-mytest/` with your tests folder and `index.mjs` with your test file):
91
+ Or run your own tests in a pre-configured environment (replace `folder-mytest/` with your
92
+ tests folder and `index.mjs` with your test file):
54
93
 
55
94
  ```bash
56
95
  docker run -it -v ./folder-mytest/:/bmssp-js/folder-mytest/ sirivasv/bmssp-js:latest node /bmssp-js/folder-mytest/index.mjs
57
96
  ```
58
97
 
59
- Other versions of the docker image can be found on [Docker Hub](https://hub.docker.com/r/sirivasv/bmssp-js/tags).
98
+ Other image versions are on [Docker Hub](https://hub.docker.com/r/sirivasv/bmssp-js/tags).
99
+
100
+ ## Development
101
+
102
+ ```bash
103
+ npm install
104
+ npm test # Jest suite, incl. BMSSP-vs-Dijkstra equivalence on a real road network
105
+ npm run lint # Prettier + ESLint
106
+ npm run bench # seeded micro-benchmarks (see benchmarks/README.md)
107
+ ```
108
+
109
+ The test suite's core contract: for every node, BMSSP's distances must equal the Dijkstra
110
+ oracle's — including on `test/roadNet-CA.txt`, a real road network from SNAP.
111
+
112
+ ## Roadmap
113
+
114
+ | Milestone | Theme | Status |
115
+ | --- | --- | --- |
116
+ | [`1.0.0`](https://github.com/Sirivasv/bmssp-js/milestones) | First end-to-end functional BMSSP (issues #40–#45) | ✅ done |
117
+ | `1.1.0` | Correctness hardening — fuzz tests, edge cases, tie-breaking, input validation | 🔨 current focus |
118
+ | `1.2.0` | Performance & ergonomics — exact Lemma 3.3 asymptotics, path reconstruction, BMSSP-vs-Dijkstra benchmarks | planned |
119
+ | `2.0.0` | API generalization — public multi-source/bounded entry point, flexible inputs | planned |
120
+
121
+ Contributions are welcome — see [CONTRIBUTING.md](CONTRIBUTING.md) and the
122
+ [`help wanted` / `good first issue` labels](https://github.com/Sirivasv/bmssp-js/issues).
123
+
124
+ ## Other implementations on GitHub
60
125
 
61
- ### Other Implementations in GitHub
126
+ <https://github.com/search?q=bmssp&type=repositories>
62
127
 
63
- https://github.com/search?q=bmssp&type=repositories
128
+ ## License
64
129
 
130
+ [MPL-2.0](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bmssp",
3
- "version": "0.18.0",
3
+ "version": "1.0.0",
4
4
  "description": "Javascript package implementation of the bmssp algorithm.",
5
5
  "main": "index.mjs",
6
6
  "keywords": [
package/src/bmssp.mjs CHANGED
@@ -1,4 +1,6 @@
1
- import { dijkstra } from "./dijkstra.mjs";
1
+ import { baseCase } from "./baseCase.mjs";
2
+ import { findPivots } from "./findPivots.mjs";
3
+ import { BlockList } from "./blockList.mjs";
2
4
 
3
5
  class BMSSP {
4
6
  constructor(inputGraph) {
@@ -27,6 +29,9 @@ class BMSSP {
27
29
 
28
30
  // Initialize shortest paths map
29
31
  this.initializeShortestPaths();
32
+
33
+ // Derive the paper's k / t parameters and the top recursion level
34
+ this.deriveParameters();
30
35
  }
31
36
 
32
37
  // Method to (re)build the adjacency map from this.graph.
@@ -59,7 +64,162 @@ class BMSSP {
59
64
  }
60
65
  }
61
66
 
62
- // Method to calculate shortest paths from startNode using Dijkstra
67
+ /**
68
+ * Derive the paper's parameters from n = |V| and store them on the
69
+ * instance:
70
+ * - k = max(1, ⌊(log₂ n)^(1/3)⌋) — Bellman-Ford rounds in FindPivots and
71
+ * the BaseCase settle cap,
72
+ * - t = max(1, ⌊(log₂ n)^(2/3)⌋) — branching / level sizing,
73
+ * - topLevel = max(1, ⌈log₂ n / t⌉) — level of the top BMSSP call.
74
+ * Everything is clamped to >= 1 so tiny graphs stay out of degenerate
75
+ * regimes: correctness never depends on the asymptotics.
76
+ */
77
+ deriveParameters() {
78
+ const logn = Math.log2(Math.max(2, this.nodeIDs.size));
79
+ this.k = Math.max(1, Math.floor(logn ** (1 / 3)));
80
+ this.t = Math.max(1, Math.floor(logn ** (2 / 3)));
81
+ this.topLevel = Math.max(1, Math.ceil(logn / this.t));
82
+ }
83
+
84
+ /**
85
+ * BMSSP(l, B, S) — Algorithm 3 of "Breaking the Sorting Barrier for
86
+ * Directed Single-Source Shortest Paths": the main bounded multi-source
87
+ * recursion, wiring FindPivots (Algorithm 1), the Lemma 3.3 BlockList and
88
+ * BaseCase (Algorithm 2) together.
89
+ *
90
+ * Preconditions (the top-level call satisfies them trivially):
91
+ * - Every vertex in S is complete (this.shortestPaths holds its true
92
+ * distance), and every incomplete vertex v with d(v) < B has a shortest
93
+ * path through some complete vertex of S.
94
+ *
95
+ * Distance estimates live in this.shortestPaths (the paper's d̂[·]) and
96
+ * are relaxed in place at every level.
97
+ *
98
+ * Two outcomes (Lemma 3.1):
99
+ * - Successful execution: the block list emptied — bound === B and
100
+ * vertices holds every v with d(v) < B reachable through S.
101
+ * - Partial execution: the k·2^(l·t) workload guard tripped — bound < B
102
+ * and vertices holds exactly the v with d(v) < bound.
103
+ * Every returned vertex is complete either way.
104
+ *
105
+ * @param {number} l - Recursion level; 0 delegates to baseCase
106
+ * @param {number} B - Strict upper bound on the distances in scope (Infinity is allowed)
107
+ * @param {Set<*>} S - Non-empty set of complete frontier sources
108
+ * @returns {{ bound: number, vertices: Set<*> }} The boundary B' <= B and
109
+ * the set U of vertices completed below it
110
+ */
111
+ bmssp(l, B, S) {
112
+ const dHat = this.shortestPaths;
113
+ if (l === 0) {
114
+ return baseCase(B, S, dHat, this.adjacency, this.k);
115
+ }
116
+
117
+ // Shrink the frontier: only the pivots are worth recursing on, and W
118
+ // is a batch of already-completed vertices folded in at the end
119
+ const { pivots, W } = findPivots(B, S, dHat, this.adjacency, this.k);
120
+
121
+ // Seed the Lemma 3.3 block list with the pivots. lastBound tracks the
122
+ // paper's Bi': B when P is empty, min d̂ over P before the first pull,
123
+ // then the boundary returned by the latest recursive call.
124
+ // A pivot with d̂ >= B is out of scope at this level (only possible
125
+ // when equal path lengths violate Assumption 2.1 and a pull returned
126
+ // a key tied with its separator): skip it — the ancestor whose band
127
+ // covers its distance is responsible for it.
128
+ const D = new BlockList(2 ** ((l - 1) * this.t), B);
129
+ let lastBound = B;
130
+ for (const x of pivots) {
131
+ const dx = dHat.get(x);
132
+ if (dx < B) {
133
+ D.insert(x, dx);
134
+ if (dx < lastBound) lastBound = dx;
135
+ }
136
+ }
137
+
138
+ const U = new Set();
139
+ const workloadCap = this.k * 2 ** (l * this.t);
140
+
141
+ while (U.size < workloadCap && !D.isEmpty()) {
142
+ // Bi, Si <- D.Pull(): the next-closest small batch and its separator
143
+ const { keys: Si, bound: Bi } = D.pull();
144
+ let { bound: BiPrime, vertices: Ui } = this.bmssp(l - 1, Bi, Si);
145
+
146
+ if (Ui.size === 0) {
147
+ // Degenerate tie stall: the child settled only vertices tied
148
+ // exactly at its boundary (possible only through zero-weight
149
+ // paths, which violate the paper's Assumption 2.1 — see #163),
150
+ // so its strict d̂ < B' filter returned nothing and this batch
151
+ // would be re-pulled forever. Escape hatch: settle everything
152
+ // below Bi reachable through the batch with an uncapped bounded
153
+ // Dijkstra per member — correct, just not sublinear.
154
+ Ui = new Set();
155
+ for (const x of Si) {
156
+ const { vertices } = baseCase(
157
+ Bi,
158
+ new Set([x]),
159
+ dHat,
160
+ this.adjacency,
161
+ Math.max(1, this.nodeIDs.size),
162
+ );
163
+ for (const v of vertices) Ui.add(v);
164
+ }
165
+ BiPrime = Bi;
166
+ }
167
+
168
+ lastBound = BiPrime;
169
+ for (const v of Ui) U.add(v);
170
+
171
+ // Relax out of the newly-completed Ui, routing improved neighbors
172
+ // by distance band: [Bi, B) re-enters the block list, [Bi', Bi) is
173
+ // staged for a batch prepend (closer than the current batch's floor)
174
+ const K = [];
175
+ for (const u of Ui) {
176
+ const du = dHat.get(u);
177
+ for (const [v, weight] of this.adjacency.get(u) ?? []) {
178
+ const candidate = du + weight;
179
+ // Paper relaxation: d̂[u] + w(u,v) <= d̂[v] always updates d̂
180
+ if (candidate <= (dHat.get(v) ?? Infinity)) {
181
+ dHat.set(v, candidate);
182
+ // A vertex already completed at this level cannot strictly
183
+ // improve (non-negative weights), so an equal-sum relaxation
184
+ // — which the <= allows, e.g. via a zero-weight cycle — must
185
+ // not be re-queued (mirrors the baseCase settled guard)
186
+ if (U.has(v)) continue;
187
+ if (candidate >= Bi && candidate < B) {
188
+ D.insert(v, candidate);
189
+ } else if (candidate >= BiPrime && candidate < Bi) {
190
+ K.push([v, candidate]);
191
+ }
192
+ }
193
+ }
194
+ }
195
+ // Batch members the child did not complete (d̂ still in [Bi', Bi))
196
+ // go back in front of everything else. A member tied exactly at the
197
+ // separator (d̂ == Bi < B, an Assumption 2.1 violation) is still in
198
+ // scope at this level and re-enters through a regular insert.
199
+ for (const x of Si) {
200
+ if (U.has(x)) continue;
201
+ const dx = dHat.get(x);
202
+ if (dx >= BiPrime && dx < Bi) {
203
+ K.push([x, dx]);
204
+ } else if (dx === Bi && Bi < B) {
205
+ D.insert(x, dx);
206
+ }
207
+ }
208
+ if (K.length > 0) D.batchPrepend(K);
209
+ }
210
+
211
+ // B' <- min(last Bi', B); fold in the FindPivots batch below it
212
+ const bound = Math.min(lastBound, B);
213
+ for (const x of W) {
214
+ if (dHat.get(x) < bound) U.add(x);
215
+ }
216
+ return { bound, vertices: U };
217
+ }
218
+
219
+ // Method to calculate shortest paths from startNode via the BMSSP
220
+ // recursion (Algorithm 3). The top-level call BMSSP(topLevel, ∞, {start})
221
+ // is always a successful execution, so it completes every reachable
222
+ // vertex; unreachable ones keep their Infinity estimate.
63
223
  calculateShortestPaths(startNode) {
64
224
  // To clean the state before calculation
65
225
  this.initializeShortestPaths();
@@ -69,10 +229,9 @@ class BMSSP {
69
229
  throw new Error("Start node not found in the graph");
70
230
  }
71
231
 
72
- const result = dijkstra(this.graph, this.nodeIDs, startNode);
73
- result.forEach((distance, nodeId) => {
74
- this.shortestPaths.set(nodeId, distance);
75
- });
232
+ // The source is complete at distance 0; everything else is Infinity
233
+ this.shortestPaths.set(startNode, 0);
234
+ this.bmssp(this.topLevel, Infinity, new Set([startNode]));
76
235
  }
77
236
  }
78
237
 
@@ -0,0 +1,113 @@
1
+ /**
2
+ * FindPivots(B, S) — Algorithm 1 of "Breaking the Sorting Barrier for Directed
3
+ * Single-Source Shortest Paths". Shrinks the frontier S: runs k rounds of
4
+ * Bellman-Ford-style relaxation out of S, collecting every vertex relaxed
5
+ * below B into W, then identifies the pivots — the vertices of S that root a
6
+ * large (>= k vertices) tree in the forest of tight shortest-path edges. Only
7
+ * the pivots need to be carried into the deeper BMSSP recursion.
8
+ *
9
+ * Preconditions (guaranteed by the caller, Algorithm 3):
10
+ * - Every vertex in S is complete (dHat holds its true distance).
11
+ * - Every incomplete vertex v with d(v) < B has a shortest path through some
12
+ * complete vertex in S.
13
+ *
14
+ * Distance estimates are read from and written into dHat in place — exactly
15
+ * like the paper's global d̂[·] labels, so improvements made here are visible
16
+ * to the levels above. The `<=` relaxation updates d̂ unconditionally; the
17
+ * `< B` test only gates membership in W.
18
+ *
19
+ * Two outcomes:
20
+ * - Early exit (|W| grows past k·|S|): the frontier is already small relative
21
+ * to W, so every vertex of S is a pivot.
22
+ * - Forest case (k rounds complete with |W| <= k·|S|): pivots are the S-roots
23
+ * of tight-edge trees with >= k vertices. Equal-length paths can make the
24
+ * tight-edge graph a DAG (see #163); each vertex is assigned at most one
25
+ * parent deterministically (first tight edge in W iteration order) so tree
26
+ * sizes are well-defined. Guarantees |pivots| <= |W| / k either way.
27
+ *
28
+ * @param {number} B - Strict upper bound gating membership in W (Infinity is allowed)
29
+ * @param {Set<*>|Iterable<*>} S - Non-empty set of complete frontier sources
30
+ * @param {Map<*, number>} dHat - Global distance estimates d̂[·], updated in place
31
+ * @param {Map<*, Array<[*, number]>>} adjacency - nodeId -> outgoing [to, weight] edges
32
+ * @param {number} k - Relaxation rounds / tree-size threshold, >= 1 (floored);
33
+ * the paper's ⌊log^(1/3) n⌋
34
+ * @returns {{ pivots: Set<*>, W: Set<*> }} The pivot subset of S and the set W
35
+ * of vertices touched below B (W always contains S)
36
+ * @throws {Error} If k is not a number >= 1, S is empty, or any source has no
37
+ * finite distance estimate
38
+ */
39
+ function findPivots(B, S, dHat, adjacency, k) {
40
+ if (typeof k !== "number" || Number.isNaN(k) || k < 1) {
41
+ throw new Error("k must be a number >= 1");
42
+ }
43
+ const rounds = Math.floor(k);
44
+ const sources = [...S];
45
+ if (sources.length === 0) {
46
+ throw new Error("S must contain at least one source node");
47
+ }
48
+ for (const x of sources) {
49
+ const distance = dHat.get(x);
50
+ if (typeof distance !== "number" || !Number.isFinite(distance)) {
51
+ throw new Error("every source must have a finite distance estimate");
52
+ }
53
+ }
54
+
55
+ // W accumulates everything relaxed below B; layer is the paper's W_{i-1}
56
+ const W = new Set(sources);
57
+ let layer = new Set(sources);
58
+
59
+ for (let i = 1; i <= rounds; i += 1) {
60
+ const nextLayer = new Set();
61
+ for (const u of layer) {
62
+ for (const [v, weight] of adjacency.get(u) ?? []) {
63
+ const candidate = dHat.get(u) + weight;
64
+ // Paper relaxation: <= always updates d̂; < B gates the W membership
65
+ if (candidate <= (dHat.get(v) ?? Infinity)) {
66
+ dHat.set(v, candidate);
67
+ if (candidate < B) nextLayer.add(v);
68
+ }
69
+ }
70
+ }
71
+ for (const v of nextLayer) W.add(v);
72
+ layer = nextLayer;
73
+ if (W.size > rounds * sources.length) {
74
+ // Frontier already small relative to W: every source is a pivot
75
+ return { pivots: new Set(sources), W };
76
+ }
77
+ }
78
+
79
+ // Forest F of tight edges inside W. Each vertex takes at most one parent,
80
+ // chosen deterministically, so tree sizes stay well-defined even when ties
81
+ // make F a DAG. Vertices on tight cycles (zero-weight cycles) all end up
82
+ // with parents, so they belong to no root's tree.
83
+ const children = new Map();
84
+ const hasParent = new Set();
85
+ for (const u of W) {
86
+ const du = dHat.get(u);
87
+ for (const [v, weight] of adjacency.get(u) ?? []) {
88
+ if (v === u || !W.has(v) || hasParent.has(v)) continue;
89
+ if (dHat.get(v) === du + weight) {
90
+ hasParent.add(v);
91
+ if (!children.has(u)) children.set(u, []);
92
+ children.get(u).push(v);
93
+ }
94
+ }
95
+ }
96
+
97
+ // Pivots: sources that root a tight-edge tree with >= k vertices
98
+ const pivots = new Set();
99
+ for (const x of sources) {
100
+ if (hasParent.has(x)) continue;
101
+ let size = 0;
102
+ const stack = [x];
103
+ while (stack.length > 0) {
104
+ const u = stack.pop();
105
+ size += 1;
106
+ for (const child of children.get(u) ?? []) stack.push(child);
107
+ }
108
+ if (size >= rounds) pivots.add(x);
109
+ }
110
+ return { pivots, W };
111
+ }
112
+
113
+ export { findPivots };