Skip to content
This repository was archived by the owner on Jun 18, 2026. It is now read-only.

Commit 4c4e654

Browse files
perf: candidate-frontier Linear Threshold simulation — O(candidates) per round instead of O(V)
Both simulateLT() and simulateLTLightweight() previously iterated ALL vertices every round to find susceptible nodes that crossed their activation threshold. For large graphs with sparse activation (common in Monte Carlo trials), most vertices are checked needlessly. Optimized approach: - Maintain a 'candidate frontier' of susceptible nodes with ≥1 active predecessor, seeded from initial seed neighbors - Track per-node active-predecessor counts incrementally (merge on neighbor activation) instead of recomputing from scratch each round - Pre-compute predecessor sizes once to avoid repeated collection.size() - When a node activates, propagate its successors into the frontier Complexity: O(rounds × |candidates| + total_activations × avg_degree) instead of O(rounds × V × avg_predecessor_degree). For influence maximization Monte Carlo (hundreds of trials), this compounds into significant speedup on graphs where activation fraction << 1.
1 parent d601b67 commit 4c4e654

1 file changed

Lines changed: 116 additions & 44 deletions

File tree

Gvisual/src/gvisual/InfluenceSpreadSimulator.java

Lines changed: 116 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -223,12 +223,18 @@ public SimulationResult simulateIC(Collection<String> seeds,
223223
// ─── Linear Threshold ───────────────────────────────────────
224224

225225
/**
226-
* Linear Threshold simulation.
226+
* Linear Threshold simulation using a candidate-frontier approach.
227227
*
228-
* <p>Uses synchronous activation: all nodes are evaluated against
228+
* <p>Uses synchronous activation: all candidates are evaluated against
229229
* the current round's state, and newly activated nodes only become
230230
* visible in the next round. This prevents activation order within
231231
* a single round from affecting results.</p>
232+
*
233+
* <p>Maintains an incremental active-predecessor count per node and a
234+
* frontier of susceptible candidates (nodes with ≥1 active predecessor).
235+
* Each round only evaluates candidates instead of all V vertices,
236+
* reducing per-round cost from O(V × avg_pred_degree) to
237+
* O(|candidates| + activated × avg_degree).</p>
232238
*/
233239
public SimulationResult simulateLT(Collection<String> seeds, int maxRounds) {
234240
validateSeeds(seeds);
@@ -242,56 +248,74 @@ public SimulationResult simulateLT(Collection<String> seeds, int maxRounds) {
242248
thresholds.put(node, random.nextDouble());
243249
}
244250

251+
// Pre-compute predecessor sizes
252+
Map<String, Integer> predSize = new HashMap<>();
253+
for (String node : graph.getVertices()) {
254+
predSize.put(node, getPredecessors(node).size());
255+
}
256+
257+
// Track active-predecessor count per node incrementally
258+
Map<String, Integer> activePredCount = new HashMap<>();
259+
// Track which active predecessor triggered each node (for timeline)
260+
Map<String, String> activePredSource = new HashMap<>();
261+
262+
// Build initial candidate frontier from seed neighbors
263+
Set<String> candidates = new LinkedHashSet<>();
264+
for (String seed : seeds) {
265+
if (!graph.containsVertex(seed)) continue;
266+
for (String succ : getNeighbors(seed)) {
267+
if (state.get(succ) == NodeState.SUSCEPTIBLE) {
268+
int prev = activePredCount.getOrDefault(succ, 0);
269+
if (prev == 0) activePredSource.put(succ, seed);
270+
activePredCount.put(succ, prev + 1);
271+
candidates.add(succ);
272+
}
273+
}
274+
}
275+
245276
int round = 0;
246277
snapshots.add(createSnapshot(round, state));
247-
boolean changed = true;
248278

249-
while (changed) {
279+
while (!candidates.isEmpty()) {
250280
round++;
251281
if (maxRounds > 0 && round > maxRounds) break;
252-
changed = false;
253282

254283
// Collect all activations for this round before applying any
255284
List<String> toActivate = new ArrayList<>();
256285
Map<String, String> activatedBy = new LinkedHashMap<>();
257286

258-
for (String node : graph.getVertices()) {
259-
if (state.get(node) != NodeState.SUSCEPTIBLE) continue;
260-
261-
// LT uses predecessors (incoming edges): a node activates
262-
// when enough nodes pointing TO it are active.
263-
Collection<String> influencers = getPredecessors(node);
264-
if (influencers.isEmpty()) continue;
265-
266-
int activeNeighbors = 0;
267-
String anActiveNeighbor = null;
268-
for (String neighbor : influencers) {
269-
if (state.get(neighbor) == NodeState.INFECTED ||
270-
state.get(neighbor) == NodeState.RECOVERED) {
271-
activeNeighbors++;
272-
if (anActiveNeighbor == null) {
273-
anActiveNeighbor = neighbor;
274-
}
275-
}
276-
}
277-
278-
double fraction = (double) activeNeighbors / influencers.size();
279-
if (fraction >= thresholds.get(node)) {
287+
for (String node : candidates) {
288+
int ps = predSize.get(node);
289+
if (ps == 0) continue;
290+
int ac = activePredCount.getOrDefault(node, 0);
291+
if ((double) ac / ps >= thresholds.get(node)) {
280292
toActivate.add(node);
281-
if (anActiveNeighbor != null) {
282-
activatedBy.put(node, anActiveNeighbor);
293+
String source = activePredSource.get(node);
294+
if (source != null) {
295+
activatedBy.put(node, source);
283296
}
284297
}
285298
}
286299

287-
// Apply all activations simultaneously
300+
if (toActivate.isEmpty()) break;
301+
302+
// Apply all activations simultaneously and propagate frontier
288303
for (String node : toActivate) {
289304
state.put(node, NodeState.INFECTED);
290-
changed = true;
305+
candidates.remove(node);
291306
String source = activatedBy.get(node);
292307
if (source != null) {
293308
timeline.add(new InfectionEvent(source, node, round));
294309
}
310+
// Propagate: successors of newly activated node become candidates
311+
for (String succ : getNeighbors(node)) {
312+
if (state.get(succ) == NodeState.SUSCEPTIBLE) {
313+
int prev = activePredCount.getOrDefault(succ, 0);
314+
if (prev == 0) activePredSource.put(succ, node);
315+
activePredCount.put(succ, prev + 1);
316+
candidates.add(succ);
317+
}
318+
}
295319
}
296320

297321
snapshots.add(createSnapshot(round, state));
@@ -563,33 +587,81 @@ private LightweightResult simulateICLightweight(Collection<String> seeds,
563587
return new LightweightResult(state, round);
564588
}
565589

590+
/**
591+
* Lightweight LT simulation using a candidate frontier instead of
592+
* scanning all V vertices every round.
593+
*
594+
* <p>Maintains a set of susceptible "candidates" — nodes with at least
595+
* one active predecessor. Each round only evaluates candidates (not all
596+
* vertices), and when a node activates, its successors are added to the
597+
* candidate set for the next round. Pre-computes predecessor sizes and
598+
* tracks per-node active-predecessor counts incrementally, avoiding
599+
* redundant inner-loop counting.</p>
600+
*
601+
* <p>Complexity drops from O(rounds × V × avg_predecessor_degree) to
602+
* O(rounds × |candidates| + total_activations × avg_degree), which is
603+
* dramatically faster when activation is sparse relative to graph size.</p>
604+
*/
566605
private LightweightResult simulateLTLightweight(Collection<String> seeds, int maxRounds) {
567606
Map<String, NodeState> state = initState(seeds);
568607
Map<String, Double> thresholds = new HashMap<>();
569608
for (String node : graph.getVertices()) thresholds.put(node, random.nextDouble());
570609

610+
// Pre-compute predecessor sizes (immutable per simulation)
611+
Map<String, Integer> predSize = new HashMap<>();
612+
for (String node : graph.getVertices()) {
613+
predSize.put(node, getPredecessors(node).size());
614+
}
615+
616+
// Track active-predecessor count per node incrementally
617+
Map<String, Integer> activePredCount = new HashMap<>();
618+
619+
// Build initial candidate frontier: susceptible nodes with ≥1 active predecessor
620+
Set<String> candidates = new LinkedHashSet<>();
621+
for (String seed : seeds) {
622+
if (!graph.containsVertex(seed)) continue;
623+
// Each seed's successors (neighbors in undirected) become candidates
624+
for (String succ : getNeighbors(seed)) {
625+
if (state.get(succ) == NodeState.SUSCEPTIBLE) {
626+
activePredCount.merge(succ, 1, Integer::sum);
627+
candidates.add(succ);
628+
}
629+
}
630+
}
631+
571632
int round = 0;
572-
boolean changed = true;
573-
while (changed) {
633+
while (!candidates.isEmpty()) {
574634
round++;
575635
if (maxRounds > 0 && round > maxRounds) break;
576-
changed = false;
636+
577637
List<String> toActivate = new ArrayList<>();
578-
for (String node : graph.getVertices()) {
579-
if (state.get(node) != NodeState.SUSCEPTIBLE) continue;
580-
Collection<String> influencers = getPredecessors(node);
581-
if (influencers.isEmpty()) continue;
582-
int active = 0;
583-
for (String nb : influencers)
584-
if (state.get(nb) == NodeState.INFECTED || state.get(nb) == NodeState.RECOVERED)
585-
active++;
586-
if ((double) active / influencers.size() >= thresholds.get(node))
638+
for (String node : candidates) {
639+
int ps = predSize.get(node);
640+
if (ps == 0) continue;
641+
int ac = activePredCount.getOrDefault(node, 0);
642+
if ((double) ac / ps >= thresholds.get(node)) {
587643
toActivate.add(node);
644+
}
588645
}
646+
647+
if (toActivate.isEmpty()) break;
648+
649+
// Remove activated nodes from candidates, propagate to their successors
650+
Set<String> nextCandidates = new LinkedHashSet<>();
589651
for (String node : toActivate) {
590652
state.put(node, NodeState.INFECTED);
591-
changed = true;
653+
candidates.remove(node);
654+
// Propagate: successors of newly activated node may become candidates
655+
for (String succ : getNeighbors(node)) {
656+
if (state.get(succ) == NodeState.SUSCEPTIBLE) {
657+
activePredCount.merge(succ, 1, Integer::sum);
658+
nextCandidates.add(succ);
659+
}
660+
}
592661
}
662+
663+
// Merge remaining old candidates with new ones
664+
candidates.addAll(nextCandidates);
593665
}
594666
return new LightweightResult(state, round);
595667
}

0 commit comments

Comments
 (0)