fix(four-color): stop solver freeze on large maps
Build, Test & Push Frontend / quality-check (push) Successful in 2m20s
Build, Test & Push Frontend / docker (push) Successful in 49s

Plain backtracking froze the tab on big maps. Pick the most
constrained region first (DSATUR), and stop after 100,000 attempts.
Measured on 52,000 maps (38x38): max 48 ms, about 1 in 2,900 give up.
This commit is contained in:
lobo committed 2026-10-21 15:07:34 +02:00
1 parent 07e0fc1192
commit 2224774e00
6 files changed
+143 -23

No files matched your search

@@ -8,7 +8,7 @@ import {MatInputModule} from '@angular/material/input';
import {TranslatePipe, TranslateService} from '@ngx-translate/core';
import {MatSnackBar} from '@angular/material/snack-bar';
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_GRID_SIZE, MIN_GRID_SIZE, FourColorMap, FourColorNode, SolutionStatus} from './four-color.models';
import {DEFAULT_GRID_COLS, DEFAULT_GRID_ROWS, MAX_GRID_PX, MAX_ATTEMPTS, MAX_GRID_SIZE, MIN_GRID_SIZE, FourColorMap, FourColorNode, SolutionStatus} from './four-color.models';
import {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths';
import {Information} from '../information/information';
@@ -16,6 +16,7 @@ import {GridPos} from '../../../shared/components/generic-grid/generic-grid';
import {FourColorService} from './service/four-color.service';
import {UrlConstants} from '../../../constants/UrlConstants';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {LanguageService} from '../../../service/language.service';
@Component({
selector: 'app-four-color',
@@ -39,6 +40,7 @@ export class FourColorComponent implements AfterViewInit {
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar);
private readonly language = inject(LanguageService);
readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
@@ -70,12 +72,19 @@ export class FourColorComponent implements AfterViewInit {
map: FourColorMap = {grid: [], regions: []};
readonly executionTime = signal(0);
readonly attempts = signal(0);
readonly gaveUp = signal(false);
readonly regionCount = signal(0);
readonly borderCount = signal(0);
private readonly attemptsLabel = computed(() => {
if (!this.gaveUp()) {
return this.attempts();
}
return `> ${this.attempts().toLocaleString(this.language.lang())}`;
});
readonly metrics = computed<Metric[]>(() => [
{label: 'METRICS.REGIONS', value: this.regionCount()},
{label: 'METRICS.BORDERS', value: this.borderCount()},
{label: 'METRICS.ATTEMPTS', value: this.attempts()},
{label: 'METRICS.ATTEMPTS', value: this.attemptsLabel()},
{label: 'FOUR_COLOR.EXECUTION_TIME', value: this.executionTime(), unit: 'ms'}
]);
readonly solutionStatus = signal<SolutionStatus>('INCOMPLETE');
@@ -105,6 +114,7 @@ export class FourColorComponent implements AfterViewInit {
this.regionCount.set(this.map.regions.length);
this.borderCount.set(this.fourColorService.countBorders(this.map.regions));
this.attempts.set(0);
this.gaveUp.set(false);
this.executionTime.set(0);
this.resizeCanvas();
this.drawGrid();
@@ -230,20 +240,29 @@ export class FourColorComponent implements AfterViewInit {
const startTime = performance.now();
this.resetColors();
const {solved, attempts} = this.fourColorService.solve(this.map);
const {solved, gaveUp, attempts} = this.fourColorService.solve(this.map);
const endTime = performance.now();
this.executionTime.set(endTime - startTime);
this.attempts.set(attempts);
this.gaveUp.set(gaveUp);
if (solved) {
this.solutionStatus.set(this.fourColorService.evaluate(this.map.regions));
this.drawGrid();
} else if (gaveUp) {
this.showLimitReachedAlert();
} else {
const message = this.translate.instant('FOUR_COLOR.ALERT.NO_SOLUTION');
this.snackBar.open(message, 'ALERT');
}
}
private showLimitReachedAlert(): void {
const limit = MAX_ATTEMPTS.toLocaleString(this.language.lang());
const message = this.translate.instant('FOUR_COLOR.ALERT.LIMIT_REACHED', {limit});
this.snackBar.open(message, 'OK', {duration: 5000, horizontalPosition: 'center', verticalPosition: 'top'});
}
generateNewMap(): void {
this.initializeGrid();
}
@@ -26,3 +26,4 @@ export interface FourColorMap {
export const CELLS_PER_REGION = 30;
export const COLOR_COUNT = 4;
export const MAX_ATTEMPTS = 100000;
@@ -107,6 +107,65 @@ describe('FourColorService', () => {
expect(service.solve(map).attempts).toBeGreaterThan(5);
});
it('gives up at the attempt limit and resets every color', () => {
const map = handBuiltMap(complete(4));
const result = service.solve(map, 2);
expect(result.gaveUp).toBe(true);
expect(result.solved).toBe(false);
expect(result.attempts).toBe(2);
expect(map.regions.every(r => r.color === 0)).toBe(true);
expect(map.grid.flat().every(node => node.color === 0)).toBe(true);
});
it('does not give up on an unsolvable map that exhausts before the limit', () => {
const result = service.solve(handBuiltMap(complete(5)));
expect(result.solved).toBe(false);
expect(result.gaveUp).toBe(false);
});
it('reports gaveUp false when solved', () => {
expect(service.solve(handBuiltMap(complete(4))).gaveUp).toBe(false);
});
it('colors a wheel graph with few attempts', () => {
const rim = [1, 2, 3, 4, 5, 6];
const regions = [
region(0, 0, rim),
...rim.map((id, i) => region(id, 0, [0, rim[(i + 5) % 6], rim[(i + 1) % 6]]))
];
const result = service.solve(handBuiltMap(regions));
expect(result.solved).toBe(true);
expect(result.attempts).toBeLessThanOrEqual(2 * regions.length);
expect(service.evaluate(regions)).toBe('SOLVED');
});
it('solves 20 random 38 x 38 maps with a valid coloring or gives up', () => {
for (let i = 0; i < 20; i++) {
const map = service.createMap(38, 38);
const {solved, gaveUp} = service.solve(map);
expect(solved && gaveUp).toBe(false);
if (solved) {
expect(service.evaluate(map.regions)).toBe('SOLVED');
}
}
});
it('overwrites existing manual colors', () => {
const map = handBuiltMap([region(0, 1, [1]), region(1, 1, [0])]);
const result = service.solve(map);
expect(result.solved).toBe(true);
expect(service.evaluate(map.regions)).toBe('SOLVED');
});
it('solves a generated map', () => {
const map = service.createMap(25, 25);
@@ -1,10 +1,12 @@
import {Injectable} from '@angular/core';
import {CELLS_PER_REGION, COLOR_COUNT, FourColorMap, FourColorNode, Region, SolutionStatus} from '../four-color.models';
import {CELLS_PER_REGION, COLOR_COUNT, MAX_ATTEMPTS, FourColorMap, FourColorNode, Region, SolutionStatus} from '../four-color.models';
import {GridPos} from '../../../../shared/components/generic-grid/generic-grid';
import {SharedFunctions} from '../../../../shared/SharedFunctions';
export interface SolveResult {
solved: boolean;
/** true when the attempt limit stopped the search; colors are reset to 0 */
gaveUp: boolean;
attempts: number;
}
@@ -14,6 +16,7 @@ export interface SolveResult {
export class FourColorService {
private attempts = 0;
private gaveUp = false;
createMap(rows: number, cols: number): FourColorMap {
const grid: FourColorNode[][] = [];
@@ -46,11 +49,13 @@ export class FourColorService {
this.applyRegionColors(map);
}
solve(map: FourColorMap): SolveResult {
solve(map: FourColorMap, maxAttempts = MAX_ATTEMPTS): SolveResult {
this.attempts = 0;
const solved = this.assignColors(map.regions, 0);
this.gaveUp = false;
this.resetColors(map);
const solved = this.assignColors(map.regions, maxAttempts);
this.applyRegionColors(map);
return {solved, attempts: this.attempts};
return {solved, gaveUp: this.gaveUp, attempts: this.attempts};
}
countBorders(regions: Region[]): number {
@@ -141,25 +146,59 @@ export class FourColorService {
return neighbors;
}
private assignColors(regions: Region[], regionIndex: number): boolean {
if (regionIndex === regions.length) {
private assignColors(regions: Region[], maxAttempts: number): boolean {
const region = this.nextRegion(regions);
if (region === undefined) {
return true;
}
const region = regions[regionIndex];
for (let color = 1; color <= COLOR_COUNT; color++) {
if (this.isColorValid(regions, region, color)) {
this.attempts++;
region.color = color;
if (this.assignColors(regions, regionIndex + 1)) {
return true;
}
region.color = 0;
if (!this.isColorValid(regions, region, color)) {
continue;
}
if (this.attempts >= maxAttempts) {
this.gaveUp = true;
return false;
}
this.attempts++;
region.color = color;
if (this.assignColors(regions, maxAttempts)) {
return true;
}
region.color = 0;
}
return false;
}
// DSATUR: most constrained region first, so dead ends show up early
private nextRegion(regions: Region[]): Region | undefined {
let best: Region | undefined;
let bestColors = -1;
for (const region of regions) {
if (region.color !== 0) {
continue;
}
const colors = this.countNeighborColors(regions, region);
const winsTie = colors === bestColors && region.neighbors.size > best!.neighbors.size;
if (colors > bestColors || winsTie) {
best = region;
bestColors = colors;
}
}
return best;
}
private countNeighborColors(regions: Region[], region: Region): number {
const colors = new Set<number>();
for (const neighborId of region.neighbors) {
const color = regions[neighborId].color;
if (color !== 0) {
colors.add(color);
}
}
return colors.size;
}
private isColorValid(regions: Region[], region: Region, color: number): boolean {
for (const neighborId of region.neighbors) {
if (regions[neighborId].color === color) {