@@ -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