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
+139 -19

No files matched your search

@@ -8,7 +8,7 @@ import {MatInputModule} from '@angular/material/input';
import {TranslatePipe, TranslateService} from '@ngx-translate/core'; import {TranslatePipe, TranslateService} from '@ngx-translate/core';
import {MatSnackBar} from '@angular/material/snack-bar'; 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 {AlgorithmInformation} from '../information/information.models';
import {SOURCE_PATHS} from '../information/source-paths'; import {SOURCE_PATHS} from '../information/source-paths';
import {Information} from '../information/information'; 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 {FourColorService} from './service/four-color.service';
import {UrlConstants} from '../../../constants/UrlConstants'; import {UrlConstants} from '../../../constants/UrlConstants';
import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics'; import {Metric, MetricsComponent} from '../../../shared/components/metrics/metrics';
import {LanguageService} from '../../../service/language.service';
@Component({ @Component({
selector: 'app-four-color', selector: 'app-four-color',
@@ -39,6 +40,7 @@ export class FourColorComponent implements AfterViewInit {
private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID)); private readonly isBrowser = isPlatformBrowser(inject(PLATFORM_ID));
private readonly translate = inject(TranslateService); private readonly translate = inject(TranslateService);
private readonly snackBar = inject(MatSnackBar); private readonly snackBar = inject(MatSnackBar);
private readonly language = inject(LanguageService);
readonly MIN_GRID_SIZE = MIN_GRID_SIZE; readonly MIN_GRID_SIZE = MIN_GRID_SIZE;
readonly MAX_GRID_SIZE = MAX_GRID_SIZE; readonly MAX_GRID_SIZE = MAX_GRID_SIZE;
@@ -70,12 +72,19 @@ export class FourColorComponent implements AfterViewInit {
map: FourColorMap = {grid: [], regions: []}; map: FourColorMap = {grid: [], regions: []};
readonly executionTime = signal(0); readonly executionTime = signal(0);
readonly attempts = signal(0); readonly attempts = signal(0);
readonly gaveUp = signal(false);
readonly regionCount = signal(0); readonly regionCount = signal(0);
readonly borderCount = 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[]>(() => [ readonly metrics = computed<Metric[]>(() => [
{label: 'METRICS.REGIONS', value: this.regionCount()}, {label: 'METRICS.REGIONS', value: this.regionCount()},
{label: 'METRICS.BORDERS', value: this.borderCount()}, {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'} {label: 'FOUR_COLOR.EXECUTION_TIME', value: this.executionTime(), unit: 'ms'}
]); ]);
readonly solutionStatus = signal<SolutionStatus>('INCOMPLETE'); readonly solutionStatus = signal<SolutionStatus>('INCOMPLETE');
@@ -105,6 +114,7 @@ export class FourColorComponent implements AfterViewInit {
this.regionCount.set(this.map.regions.length); this.regionCount.set(this.map.regions.length);
this.borderCount.set(this.fourColorService.countBorders(this.map.regions)); this.borderCount.set(this.fourColorService.countBorders(this.map.regions));
this.attempts.set(0); this.attempts.set(0);
this.gaveUp.set(false);
this.executionTime.set(0); this.executionTime.set(0);
this.resizeCanvas(); this.resizeCanvas();
this.drawGrid(); this.drawGrid();
@@ -230,20 +240,29 @@ export class FourColorComponent implements AfterViewInit {
const startTime = performance.now(); const startTime = performance.now();
this.resetColors(); this.resetColors();
const {solved, attempts} = this.fourColorService.solve(this.map); const {solved, gaveUp, attempts} = this.fourColorService.solve(this.map);
const endTime = performance.now(); const endTime = performance.now();
this.executionTime.set(endTime - startTime); this.executionTime.set(endTime - startTime);
this.attempts.set(attempts); this.attempts.set(attempts);
this.gaveUp.set(gaveUp);
if (solved) { if (solved) {
this.solutionStatus.set(this.fourColorService.evaluate(this.map.regions)); this.solutionStatus.set(this.fourColorService.evaluate(this.map.regions));
this.drawGrid(); this.drawGrid();
} else if (gaveUp) {
this.showLimitReachedAlert();
} else { } else {
const message = this.translate.instant('FOUR_COLOR.ALERT.NO_SOLUTION'); const message = this.translate.instant('FOUR_COLOR.ALERT.NO_SOLUTION');
this.snackBar.open(message, 'ALERT'); 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 { generateNewMap(): void {
this.initializeGrid(); this.initializeGrid();
} }
@@ -26,3 +26,4 @@ export interface FourColorMap {
export const CELLS_PER_REGION = 30; export const CELLS_PER_REGION = 30;
export const COLOR_COUNT = 4; export const COLOR_COUNT = 4;
export const MAX_ATTEMPTS = 100000;
@@ -107,6 +107,65 @@ describe('FourColorService', () => {
expect(service.solve(map).attempts).toBeGreaterThan(5); 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', () => { it('solves a generated map', () => {
const map = service.createMap(25, 25); const map = service.createMap(25, 25);
@@ -1,10 +1,12 @@
import {Injectable} from '@angular/core'; 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 {GridPos} from '../../../../shared/components/generic-grid/generic-grid';
import {SharedFunctions} from '../../../../shared/SharedFunctions'; import {SharedFunctions} from '../../../../shared/SharedFunctions';
export interface SolveResult { export interface SolveResult {
solved: boolean; solved: boolean;
/** true when the attempt limit stopped the search; colors are reset to 0 */
gaveUp: boolean;
attempts: number; attempts: number;
} }
@@ -14,6 +16,7 @@ export interface SolveResult {
export class FourColorService { export class FourColorService {
private attempts = 0; private attempts = 0;
private gaveUp = false;
createMap(rows: number, cols: number): FourColorMap { createMap(rows: number, cols: number): FourColorMap {
const grid: FourColorNode[][] = []; const grid: FourColorNode[][] = [];
@@ -46,11 +49,13 @@ export class FourColorService {
this.applyRegionColors(map); this.applyRegionColors(map);
} }
solve(map: FourColorMap): SolveResult { solve(map: FourColorMap, maxAttempts = MAX_ATTEMPTS): SolveResult {
this.attempts = 0; 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); this.applyRegionColors(map);
return {solved, attempts: this.attempts}; return {solved, gaveUp: this.gaveUp, attempts: this.attempts};
} }
countBorders(regions: Region[]): number { countBorders(regions: Region[]): number {
@@ -141,25 +146,59 @@ export class FourColorService {
return neighbors; return neighbors;
} }
private assignColors(regions: Region[], regionIndex: number): boolean { private assignColors(regions: Region[], maxAttempts: number): boolean {
if (regionIndex === regions.length) { const region = this.nextRegion(regions);
if (region === undefined) {
return true; return true;
} }
const region = regions[regionIndex];
for (let color = 1; color <= COLOR_COUNT; color++) { for (let color = 1; color <= COLOR_COUNT; color++) {
if (this.isColorValid(regions, region, color)) { if (!this.isColorValid(regions, region, color)) {
continue;
}
if (this.attempts >= maxAttempts) {
this.gaveUp = true;
return false;
}
this.attempts++; this.attempts++;
region.color = color; region.color = color;
if (this.assignColors(regions, regionIndex + 1)) { if (this.assignColors(regions, maxAttempts)) {
return true; return true;
} }
region.color = 0; region.color = 0;
} }
}
return false; 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 { private isColorValid(regions: Region[], region: Region, color: number): boolean {
for (const neighborId of region.neighbors) { for (const neighborId of region.neighbors) {
if (regions[neighborId].color === color) { if (regions[neighborId].color === color) {
+4 -3
View File
@@ -790,13 +790,14 @@
"DISCLAIMER_BOTTOM": "Zum einfärben in die Zelle klicken. Durch erneutes klicken ändert sich die Farbe." "DISCLAIMER_BOTTOM": "Zum einfärben in die Zelle klicken. Durch erneutes klicken ändert sich die Farbe."
}, },
"ALERT": { "ALERT": {
"NO_SOLUTION": "Keine Lösung gefunden (das sollte bei einer planaren Karte nicht passieren!)." "NO_SOLUTION": "Keine Lösung gefunden (das sollte bei einer planaren Karte nicht passieren!).",
"LIMIT_REACHED": "Keine Lösung innerhalb von {{limit}} Versuchen gefunden. Bitte eine neue Karte erzeugen."
}, },
"HOW_IT_WORKS": { "HOW_IT_WORKS": {
"INTRO": "Die Karte entsteht auf einem Gitter: pro 30 Zellen wird ein Keim an eine zufällige freie Position gesetzt. Alle Keime wachsen gleichzeitig per Breitensuche (eine gemeinsame Warteschlange), bis jede Zelle zu einer Region gehört. Zwei Regionen sind Nachbarn, wenn zwei ihrer Zellen eine Kante teilen.", "INTRO": "Die Karte entsteht auf einem Gitter: pro 30 Zellen wird ein Keim an eine zufällige freie Position gesetzt. Alle Keime wachsen gleichzeitig per Breitensuche (eine gemeinsame Warteschlange), bis jede Zelle zu einer Region gehört. Zwei Regionen sind Nachbarn, wenn zwei ihrer Zellen eine Kante teilen.",
"ITEM_1": "Datenstruktur: eine Liste von Regionen, jede mit einem Set ihrer Nachbar-IDs (Adjazenzliste). Das Gitter speichert pro Zelle nur Regions-ID und Farbe.", "ITEM_1": "Datenstruktur: eine Liste von Regionen, jede mit einem Set ihrer Nachbar-IDs (Adjazenzliste). Das Gitter speichert pro Zelle nur Regions-ID und Farbe.",
"ITEM_2": "Lösen: Backtracking in der Reihenfolge der Regions-IDs. Für jede Region werden die Farben 1 bis 4 probiert; passt keine, geht der Löser eine Region zurück. Keine Heuristik wie 'meiste Nachbarn zuerst', darum liegen die Farbversuche bei großen Karten manchmal deutlich über der Zahl der Regionen.", "ITEM_2": "Lösen: Backtracking. Der Löser nimmt immer die am stärksten eingeschränkte Region zuerst (DSATUR): die mit den meisten verschiedenen Farben bei ihren Nachbarn, bei Gleichstand die mit den meisten Nachbarn. Für sie werden die Farben 1 bis 4 probiert; passt keine, geht der Löser einen Schritt zurück. Versuche zählen jede Farbzuweisung, auch die später zurückgenommenen.",
"ITEM_3": "Die Standardkarte (25 × 25, 20 Regionen) löst er in unter 1 ms. Große Karten können sehr lange dauern, weil das Backtracking keine Heuristik hat und der Worst Case exponentiell ist.", "ITEM_3": "Die Standardkarte (25 × 25, 20 Regionen) löst er in unter 1 ms. Meist braucht er genau einen Versuch pro Region. Damit die Seite nie einfriert, gibt er nach 100.000 Versuchen auf (ungefähr 1 von 3.000 Karten in der größten Stufe) und meldet das; dann hilft eine neue Karte.",
"ITEM_4": "Prüfen: alle Regionen durchgehen und jede Farbe mit den Nachbarn vergleichen, O(Regionen + Grenzen)." "ITEM_4": "Prüfen: alle Regionen durchgehen und jede Farbe mit den Nachbarn vergleichen, O(Regionen + Grenzen)."
} }
}, },
+4 -3
View File
@@ -790,13 +790,14 @@
"DISCLAIMER_BOTTOM": "Click to color the region. Click again to change the color." "DISCLAIMER_BOTTOM": "Click to color the region. Click again to change the color."
}, },
"ALERT": { "ALERT": {
"NO_SOLUTION": "No solution found (this should not happen for a planar map!)." "NO_SOLUTION": "No solution found (this should not happen for a planar map!).",
"LIMIT_REACHED": "No solution found within {{limit}} attempts. Please generate a new map."
}, },
"HOW_IT_WORKS": { "HOW_IT_WORKS": {
"INTRO": "The map is built on a grid: one seed per 30 cells is placed at a random free position. All seeds grow at the same time by breadth-first search (one shared queue) until every cell belongs to a region. Two regions are neighbors when two of their cells share an edge.", "INTRO": "The map is built on a grid: one seed per 30 cells is placed at a random free position. All seeds grow at the same time by breadth-first search (one shared queue) until every cell belongs to a region. Two regions are neighbors when two of their cells share an edge.",
"ITEM_1": "Data structure: a list of regions, each with a set of its neighbor ids (adjacency list). The grid stores only region id and color per cell.", "ITEM_1": "Data structure: a list of regions, each with a set of its neighbor ids (adjacency list). The grid stores only region id and color per cell.",
"ITEM_2": "Solving: backtracking in region id order. For each region the colors 1 to 4 are tried; if none fits, the solver steps back one region. No heuristic like 'most neighbors first', so on big maps the color attempts are sometimes far above the number of regions.", "ITEM_2": "Solving: backtracking. The solver always takes the most constrained region first (DSATUR): the one whose neighbors already use the most distinct colors, on a tie the one with the most neighbors. For it the colors 1 to 4 are tried; if none fits, the solver steps back. Attempts count every color assignment, including the ones undone later.",
"ITEM_3": "The default map (25 × 25, 20 regions) solves in under 1 ms. Large maps can take very long, because the backtracking has no heuristic and the worst case is exponential.", "ITEM_3": "The default map (25 × 25, 20 regions) solves in under 1 ms. Usually it needs exactly one attempt per region. So the page never freezes, the solver gives up after 100,000 attempts (about 1 in 3,000 maps at the largest size) and says so; then generate a new map.",
"ITEM_4": "Checking: walk all regions and compare each color with its neighbors, O(regions + borders)." "ITEM_4": "Checking: walk all regions and compare each color with its neighbors, O(regions + borders)."
} }
}, },