Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT
package com.barrybecker4.optimization.strategy.minconflicts

/**
* Tunables for [[MinConflictsStrategy]].
*
* Every step picks one position that is in conflict, looks at every move of that position, and takes the move that
* leaves the fewest conflicts.
*
* @param maxSteps number of moves taken.
* @param noiseProbability chance that a step takes a random move of the position instead of the best one.
* A little noise (0.02 to 0.1) lets the search leave a local optimum.
* @param randomTieBreaking choose at random among moves that are equally good; if false the first is taken.
* @param restartAfterStagnantSteps after this many steps without a new best solution, jump to a random solution (the
* best is kept); 0 disables.
*/
final case class MinConflictsConfig(
maxSteps: Int = 100000,
noiseProbability: Double = 0.02,
randomTieBreaking: Boolean = true,
restartAfterStagnantSteps: Int = 2000
) {
require(maxSteps >= 0, s"maxSteps must not be negative, got $maxSteps")
require(noiseProbability >= 0.0 && noiseProbability <= 1.0, s"noiseProbability must be in [0, 1], got $noiseProbability")
require(restartAfterStagnantSteps >= 0, s"restartAfterStagnantSteps must not be negative, got $restartAfterStagnantSteps")
}

object MinConflictsConfig {
val Default: MinConflictsConfig = MinConflictsConfig()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT
package com.barrybecker4.optimization.strategy.minconflicts

import com.barrybecker4.math.MathUtil
import com.barrybecker4.optimization.optimizee.{EvaluationBudgetExceededException, Optimizee}
import com.barrybecker4.optimization.parameter.{ParameterArray, ParameterArrayWithFitness}
import com.barrybecker4.optimization.strategy.OptimizationStrategy

import scala.util.Random

/**
* The min-conflicts heuristic for constraint problems whose fitness is the number of broken constraints (so that 0 is
* a solution). Each step takes a position at random from [[Optimizee.conflictedPositions]], scores every move of
* that position ([[com.barrybecker4.optimization.parameter.ParameterArray.movesAt]]), and takes the one with the fewest
* conflicts left, or, with the noise probability, a random one. Unlike hill climbing it moves even when no move improves,
* which walks across plateaus.
*
* It is only as good as the conflict information. When the optimizee does not report conflicted positions
* (the default), the position is chosen at random from all of them and the search is a randomized greedy search that
* does far less than it can. The array must have local moves ([[com.barrybecker4.optimization.parameter.ParameterArray.supportsLocalMoves]]).
*
* Works with absolute fitness only. When the optimizee is wrapped with an evaluation budget the best solution found
* so far is returned when the budget is used up.
*
* @param optimizee the thing to be optimized.
* @param rnd chooses positions, noise and ties.
*/
class MinConflictsStrategy(optimizee: Optimizee, rnd: Random = MathUtil.RANDOM) extends OptimizationStrategy(optimizee) {

private var config: MinConflictsConfig = MinConflictsConfig.Default

def getConfig: MinConflictsConfig = config

def setConfig(config: MinConflictsConfig): Unit = { this.config = config }

override def doOptimization(params: ParameterArray, fitnessRange: Double): ParameterArrayWithFitness = {
require(!optimizee.evaluateByComparison, "Min-conflicts needs absolute fitness, not comparison")
require(params.supportsLocalMoves, "Min-conflicts needs an array with local moves")
var best = ParameterArrayWithFitness(params, Double.PositiveInfinity)
try best = search(params)
catch { case _: EvaluationBudgetExceededException => best = bestSoFar.getOrElse(best) }
best
}

private def search(start: ParameterArray): ParameterArrayWithFitness = {
var current = scored(start)
var best = current
notifyOfChange(best)
var stagnant = 0
var step = 0
while (step < config.maxSteps && !isOptimalFitnessReached(best)) {
step += 1
current = takeStep(current)
if (current.fitness < best.fitness) { best = current; stagnant = 0; notifyOfChange(best) } else stagnant += 1
if (shouldRestart(stagnant)) { current = scored(best.pa.getRandomSample); stagnant = 0 }
trace("step " + step + " current = " + current.fitness + " best = " + best.fitness)
}
best
}

private def shouldRestart(stagnantSteps: Int): Boolean =
config.restartAfterStagnantSteps > 0 && stagnantSteps >= config.restartAfterStagnantSteps

private def scored(array: ParameterArray): ParameterArrayWithFitness =
ParameterArrayWithFitness(array, cleaned(optimizee.evaluateFitness(array)))

/** A NaN must never look like an improvement. */
private def cleaned(fitness: Double): Double = if (fitness.isNaN) Double.PositiveInfinity else fitness

/** A conflicted position, or any position if the optimizee cannot say which are. */
private def pickPosition(current: ParameterArray): Int = {
val conflicted = optimizee.conflictedPositions(current)
if (conflicted.nonEmpty) conflicted(rnd.nextInt(conflicted.size)) else rnd.nextInt(current.size)
}

private def takeStep(current: ParameterArrayWithFitness): ParameterArrayWithFitness = {
val moves = current.pa.movesAt(pickPosition(current.pa)).toIndexedSeq
if (moves.isEmpty) current
else {
val fitnesses = moves.map(move => cleaned(optimizee.evaluateMove(current.pa, current.fitness, move)))
val chosen = if (rnd.nextDouble() < config.noiseProbability) rnd.nextInt(moves.size) else indexOfBest(fitnesses)
ParameterArrayWithFitness(current.pa.applyMove(moves(chosen)), fitnesses(chosen))
}
}

/** The index of the lowest fitness; a tie goes to a random one of them, or the first. */
private def indexOfBest(fitnesses: IndexedSeq[Double]): Int = {
val ties = fitnesses.indices.filter(i => fitnesses(i) == fitnesses.min)
if (config.randomTieBreaking) ties(rnd.nextInt(ties.size)) else ties.head
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
// Copyright by Barry G. Becker, 2026. Licensed under MIT License: http://www.opensource.org/licenses/MIT
package com.barrybecker4.optimization.strategy.minconflicts

import com.barrybecker4.optimization.benchmark.{GraphColoringProblem, NQueensProblem}
import com.barrybecker4.optimization.optimizee.{BudgetedOptimizee, Optimizee}
import com.barrybecker4.optimization.parameter.{Move, ParameterArray, PuzzleLikePath, PermutedParameterArray, Swap}
import org.scalatest.funsuite.AnyFunSuite

import scala.collection.mutable.ArrayBuffer
import scala.util.Random

class MinConflictsStrategySuite extends AnyFunSuite {

private def strategyFor(problem: Optimizee, config: MinConflictsConfig, seed: Long = 1) = {
val strategy = new MinConflictsStrategy(problem, new Random(seed))
strategy.setConfig(config)
strategy
}

test("it solves a larger N-queens problem quickly") {
val problem = new NQueensProblem(100, 1)
val budget = new BudgetedOptimizee(problem, 2000000)
val result = strategyFor(budget, MinConflictsConfig()).doOptimization(problem.getInitialGuess, 1.0)
assertResult(0.0)(result.fitness)
assert(!budget.wasBudgetExhausted)
}

test("it solves a small graph coloring problem") {
val problem = new GraphColoringProblem(60, 4, 5.0, 2)
val result = strategyFor(problem, MinConflictsConfig()).doOptimization(problem.getInitialGuess, 1.0)
assertResult(0.0)(result.fitness)
}

test("every move that is scored belongs to a queen that is in conflict") {
var scoredMoves = 0
var outsideConflicts = 0
val recording = new NQueensProblem(40, 3) {
override def evaluateMove(c: ParameterArray, f: Double, m: Move): Double = {
m match {
case Swap(position, _) =>
scoredMoves += 1
if (f > 0 && !conflictedPositions(c).contains(position)) outsideConflicts += 1
case _ =>
}
super.evaluateMove(c, f, m)
}
}
strategyFor(recording, MinConflictsConfig(maxSteps = 30, noiseProbability = 0.0, restartAfterStagnantSteps = 0))
.doOptimization(recording.getInitialGuess, 1.0)
assert(scoredMoves > 0)
assertResult(0)(outsideConflicts)
}

test("a run with no noise repeats for the same seed") {
def run() = {
val problem = new NQueensProblem(52, 3)
strategyFor(problem, MinConflictsConfig(maxSteps = 25, noiseProbability = 0.0), seed = 4)
.doOptimization(problem.getInitialGuess, 1.0).fitness
}
assertResult(run())(run())
}

test("each step scores every move of the position, which is the size less one for a permutation") {
val problem = new NQueensProblem(40, 3)
val budget = new BudgetedOptimizee(problem, Int.MaxValue)
strategyFor(budget, MinConflictsConfig(maxSteps = 5, restartAfterStagnantSteps = 0)).doOptimization(problem.getInitialGuess, 1.0)
assertResult(1 + 5 * 39)(budget.evaluationsUsed)
}

test("when the problem reports no conflicts it still runs, with a random position") {
val problem = new NQueensProblem(30, 3) { override def conflictedPositions(c: ParameterArray): Seq[Int] = Seq.empty }
val start = problem.getInitialGuess
val result = strategyFor(problem, MinConflictsConfig(maxSteps = 200)).doOptimization(start, 1.0)
assert(result.fitness <= problem.evaluateFitness(start))
}

test("when the budget is used up the best so far is returned") {
val problem = new NQueensProblem(60, 3)
val budget = new BudgetedOptimizee(problem, 300)
val result = strategyFor(budget, MinConflictsConfig()).doOptimization(problem.getInitialGuess, 1.0)
assert(budget.wasBudgetExhausted)
assertResult(result.fitness)(problem.evaluateFitness(result.pa))
}

test("an array without local moves is refused") {
val problem = new NQueensProblem(30, 3)
val plain = new PuzzleLikePath(problem.getInitialGuess.asInstanceOf[PermutedParameterArray].params, "path")
intercept[IllegalArgumentException](strategyFor(problem, MinConflictsConfig()).doOptimization(plain, 1.0))
}

test("comparison of fitness is refused") {
val problem = new NQueensProblem(30, 1) { override def evaluateByComparison: Boolean = true }
intercept[IllegalArgumentException](strategyFor(problem, MinConflictsConfig()).doOptimization(problem.getInitialGuess, 1.0))
}

test("the configuration refuses values that make no sense") {
intercept[IllegalArgumentException](MinConflictsConfig(noiseProbability = 1.5))
intercept[IllegalArgumentException](MinConflictsConfig(maxSteps = -1))
intercept[IllegalArgumentException](MinConflictsConfig(restartAfterStagnantSteps = -1))
}
}
Loading