fix(four-color): stop solver freeze on large maps
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:
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) {
|
||||
|
||||
Reference in new issue
Block a user