@@ -66,14 +66,35 @@ inline std::vector<std::vector<std::uint64_t>> build_cluster_to_nodes(
6666 return result;
6767}
6868
69- // Scratch flag used only during the initial gain scan, so the chain can tell
70- // whether each neighbor belongs to the current bipartition. After init the
71- // addressable heap is the source of truth (heap.contains(u) replaces the
72- // stale-tracking arrays the old hand-rolled heap required).
69+ // Per-node scratch reused across chains.
70+ //
71+ // - `in_pair` : 1 while the node sits in (A ∪ B) for the current chain.
72+ // - `moved` : 1 once the node has been popped (tentatively moved this
73+ // chain). Pairs with `in_pair` to distinguish "moved" from
74+ // "non-bordered, never pushed".
75+ // - `cross_count` : number of cross-side bipartition neighbors. Matches
76+ // nifty's `referenced_by`: a node is only allowed in the
77+ // heap once this is positive, which restricts the chain to
78+ // border moves and avoids committing "orphan" single-node
79+ // migrations into an arbitrary neighbor cluster. Without
80+ // this restriction the chain commits negative-internal-weight
81+ // nodes into B even when their true best home is a new
82+ // cluster, locking them out of the later split phase.
83+ // - `stash_gain` : the node's current gain estimate, kept in sync regardless
84+ // of whether the node is currently in the heap. Used to seed
85+ // `heap.push` when a non-bordered node first joins the
86+ // border.
7387struct ChainBuffers {
7488 std::vector<char > in_pair;
75-
76- explicit ChainBuffers (const std::size_t n_nodes) : in_pair(n_nodes, 0 ) {}
89+ std::vector<char > moved;
90+ std::vector<std::uint32_t > cross_count;
91+ std::vector<double > stash_gain;
92+
93+ explicit ChainBuffers (const std::size_t n_nodes)
94+ : in_pair(n_nodes, 0 ),
95+ moved(n_nodes, 0 ),
96+ cross_count(n_nodes, 0 ),
97+ stash_gain(n_nodes, 0.0 ) {}
7798};
7899
79100struct ChainScratch {
@@ -90,6 +111,10 @@ struct ChainScratch {
90111// cluster c during this outer iteration"; the filter on `labels[v] == c`
91112// removes stale entries on the fly. Returns the committed cumulative gain
92113// (>= 0).
114+ //
115+ // Also handles single-cluster splits: pass `cluster_b` as a fresh label
116+ // (no live members) and the chain will try to peel off a subset of `cluster_a`
117+ // into the new label.
93118inline double run_chain (
94119 const UndirectedGraph &graph,
95120 const std::vector<double > &costs,
@@ -130,33 +155,48 @@ inline double run_chain(
130155 ++live_b;
131156 }
132157 }
133- // No two-side swap is possible if either side is empty or both sides have
134- // a single node (the only non-trivial outcome is a join, handled by
135- // apply_joins).
136- if (live_a == 0 || live_b == 0 || (live_a == 1 && live_b == 1 )) {
158+ // Skip if no non-trivial move exists: the cluster_b == fresh-label split
159+ // case is allowed when cluster_a has at least two live nodes.
160+ if (live_a + live_b < 2 || (live_a == 1 && live_b == 1 )) {
137161 for (const auto v : queue_nodes) {
138162 bufs.in_pair [static_cast <std::size_t >(v)] = 0 ;
139163 }
140164 return 0.0 ;
141165 }
142166
167+ // For pair-chains (both sides non-empty) the chain is restricted to nodes
168+ // with at least one cross-side neighbor. For splits (B empty) every live
169+ // A node is eligible because there is no border yet — the first move has
170+ // to peel off the weakest-attached interior node.
171+ const bool is_split = (live_b == 0 );
143172 for (const auto v : queue_nodes) {
144173 double w_to_a = 0.0 ;
145174 double w_to_b = 0.0 ;
175+ std::uint32_t cross = 0 ;
176+ const auto v_label = labels[static_cast <std::size_t >(v)];
146177 for (const auto adj : graph.node_adjacency (v)) {
147- if (!bufs.in_pair [static_cast <std::size_t >(adj.node )]) {
178+ const auto u_key = static_cast <std::size_t >(adj.node );
179+ if (!bufs.in_pair [u_key]) {
148180 continue ;
149181 }
150182 const auto c = costs[static_cast <std::size_t >(adj.edge )];
151- if (labels[static_cast <std::size_t >(adj.node )] == cluster_a) {
183+ const auto u_label = labels[u_key];
184+ if (u_label == cluster_a) {
152185 w_to_a += c;
153186 } else {
154187 w_to_b += c;
155188 }
189+ if (u_label != v_label) {
190+ ++cross;
191+ }
156192 }
157- const auto v_label = labels[static_cast <std::size_t >(v)];
158193 const double gain_v = (v_label == cluster_a) ? (w_to_b - w_to_a) : (w_to_a - w_to_b);
159- heap.push (static_cast <std::size_t >(v), gain_v);
194+ const auto v_key = static_cast <std::size_t >(v);
195+ bufs.stash_gain [v_key] = gain_v;
196+ bufs.cross_count [v_key] = cross;
197+ if (is_split || cross > 0 ) {
198+ heap.push (v_key, gain_v);
199+ }
160200 }
161201
162202 struct Move {
@@ -169,51 +209,62 @@ inline double run_chain(
169209 double cumulative = 0.0 ;
170210 double best_cumulative = 0.0 ;
171211 std::size_t best_prefix = 0 ;
172- // The chain keeps running through negative moves because a later prefix
173- // can still recover. In practice deep negative runs almost never improve
174- // best_cumulative, so cap the lookahead at a small constant after each new
175- // best.
176- constexpr std::size_t max_steps_without_improvement = 32 ;
177- std::size_t steps_since_best = 0 ;
178212
179213 while (!heap.empty ()) {
180214 const auto top = heap.pop ();
181215 const auto v = static_cast <std::uint64_t >(top.key );
182216 const auto gain_v = top.priority ;
183- const auto old_label = labels[static_cast <std::size_t >(v)];
217+ const auto v_key = static_cast <std::size_t >(v);
218+ const auto old_label = labels[v_key];
184219 const auto new_label = (old_label == cluster_a) ? cluster_b : cluster_a;
185220
221+ bufs.moved [v_key] = 1 ;
186222 cumulative += gain_v;
187223 chain.push_back ({v, new_label});
188224
189225 if (cumulative > best_cumulative + epsilon) {
190226 best_cumulative = cumulative;
191227 best_prefix = chain.size ();
192- steps_since_best = 0 ;
193- } else {
194- ++steps_since_best;
195- if (steps_since_best > max_steps_without_improvement) {
196- break ;
197- }
198228 }
199229
200230 for (const auto adj : graph.node_adjacency (v)) {
201231 const auto u_key = static_cast <std::size_t >(adj.node );
202- if (!heap. contains ( u_key) ) {
232+ if (!bufs. in_pair [ u_key] || bufs. moved [u_key] ) {
203233 continue ;
204234 }
205- // u is in the heap, so it's an unmoved member of the current
206- // bipartition. labels[u] is therefore u's actual side (chain
207- // moves are tentative — they only mutate `labels` on commit).
208235 const auto c = costs[static_cast <std::size_t >(adj.edge )];
209- const auto u_label = labels[static_cast <std:: size_t >(adj. node ) ];
236+ const auto u_label = labels[u_key ];
210237 const double delta = (u_label == old_label) ? 2.0 * c : -2.0 * c;
211- heap.change (u_key, heap.priority_of (u_key) + delta);
238+ bufs.stash_gain [u_key] += delta;
239+ if (heap.contains (u_key)) {
240+ heap.change (u_key, bufs.stash_gain [u_key]);
241+ }
242+ // Border maintenance. For pair-chains, only nodes that are
243+ // currently bordered may be popped. A node becomes bordered when
244+ // it gains its first cross-side neighbor (cross_count 0 -> 1) and
245+ // un-borders when it loses its last (cross_count -> 0).
246+ if (u_label == old_label) {
247+ ++bufs.cross_count [u_key];
248+ if (!is_split && !heap.contains (u_key)) {
249+ heap.push (u_key, bufs.stash_gain [u_key]);
250+ }
251+ } else {
252+ if (bufs.cross_count [u_key] > 0 ) {
253+ --bufs.cross_count [u_key];
254+ }
255+ if (!is_split && bufs.cross_count [u_key] == 0
256+ && heap.contains (u_key)) {
257+ heap.erase (u_key);
258+ }
259+ }
212260 }
213261 }
214262
215263 for (const auto v : queue_nodes) {
216- bufs.in_pair [static_cast <std::size_t >(v)] = 0 ;
264+ const auto v_key = static_cast <std::size_t >(v);
265+ bufs.in_pair [v_key] = 0 ;
266+ bufs.moved [v_key] = 0 ;
267+ bufs.cross_count [v_key] = 0 ;
217268 }
218269
219270 if (best_cumulative > epsilon) {
@@ -349,6 +400,30 @@ inline std::vector<std::uint64_t> kernighan_lin(
349400 }
350401 }
351402
403+ // Try splitting each existing cluster off a fresh label. Pair-chains
404+ // can only swap members between existing clusters, so without this
405+ // pass the algorithm can never *increase* the partition count — any
406+ // local minimum that requires breaking up a cluster is unreachable.
407+ // Whether a given problem actually benefits depends on whether the
408+ // pair-chain phase leaves any cluster with internally-negative-weight
409+ // nodes.
410+ std::uint64_t next_label = number_of_clusters;
411+ for (std::uint64_t cluster = 0 ; cluster < number_of_clusters; ++cluster) {
412+ while (true ) {
413+ if (next_label >= cluster_to_nodes.size ()) {
414+ cluster_to_nodes.resize (static_cast <std::size_t >(next_label) + 1 );
415+ }
416+ const auto delta = detail_kl::run_chain (
417+ graph, costs, labels, cluster_to_nodes, bufs, scratch, cluster, next_label, epsilon
418+ );
419+ if (delta <= epsilon) {
420+ break ;
421+ }
422+ improved = true ;
423+ ++next_label;
424+ }
425+ }
426+
352427 const auto pairs_for_join = detail_kl::compute_cluster_pairs (graph, costs, labels);
353428 const auto current_number_of_clusters = labels.empty ()
354429 ? std::uint64_t {0 }
0 commit comments