user4573386
user4573386

Reputation: 63

Efficient algorithm for planar 4-colour graph colouring with scoring

I am making a game where you have to colour an image using the 4-colour theorem. No adjacent regions can be the same colour. There are four colours, red green blue yellow, and you get 10 points for each red region, 6 for green, 3 for blue and 1 for yellow.

I want an algorithm to be able to work out the maximum score for any given image. I have the code for extracting a planar graph from the image, which gives, for each region, a list of it's neighbours.

So far I have done a brute force implementation which checks all possible colourings, however that grows like 4**n for n regions. One approach I can take is to try and optimise this search as much as possible.

Is there any faster way? I know for 2 colours there is a linear time algorithm however for game design reasons I will generally not generate images which can be coloured with 2 colours.

Thanks :)

Edit: as sascha requests here are some example python dicts, they keys are the region id's and the lists are the list of neighbours of that region

easy = {2: [4], 4: [2, 3, 14, 13], 3: [4], 14: [4], 13: [4]}

top score : 46 (I think)

(my python bruteforce 0.1s)

medium = {2: [4, 5, 6], 4: [2, 3], 3: [4, 18], 5: [2, 6], 6: [5, 2, 13, 18], 13: [6, 20, 21, 22], 18: [6, 3, 20, 22], 20: [18, 13], 22: [18, 13], 21: [13]}

top score : 77

(my python bruteforce 7.2s)

hard = {2: [5, 6, 9], 5: [2, 4], 4: [5, 23], 6: [2, 7, 10], 3: [8, 16], 8: [3, 7, 12], 7: [6, 8, 10, 11], 9: [2, 10], 10: [6, 9, 7, 13, 14, 15, 17, 18], 11: [7, 12, 13], 12: [8, 11, 15, 16, 19], 13: [10, 11, 15], 14: [10, 15], 15: [10, 13, 12, 14, 17, 19], 16: [3, 12, 25, 27], 17: [10, 15, 18], 18: [10, 17, 19, 20], 19: [15, 18, 12, 27], 20: [18, 22, 24, 26, 27, 25], 22: [20], 23: [4, 24, 26], 24: [23, 20], 25: [16, 20], 26: [23, 20], 27: [19, 20, 16]}

(my python bruteforce unknown)


Edit2:

So I finished the game, if you are interested you can check it out here.

For the sake of the game I realized I only needed a high score rather than the absolute top score (which is what the question asked for). So I implemented greedy colouring and ran it 10,000 times shuffling the graph each time and taking the best scoring result. On all small boards of less than 30 regions this produces the same result as the brute force methods however it's time complexity scales much better to larger boards. So it may not find the absolutely best solution it will always find a very good one.

Thanks so much to @SaiBot and @sascha for their help :)

Upvotes: 4

Views: 1660

Answers (2)

sascha
sascha

Reputation: 33532

Here some simplified Integer Programming approach using python.

The basic idea is to use the amazing capabilities of modern high-quality Mixed Integer Programming Software without implementing algorithms ourself. We just need to define the model (and maybe tune some thing)!

Keep in mind, that (Mixed-)Integer Programming is in general NP-hard and we assume those Heuristics are working for our problem here!

The code may look somewhat ugly as the used modelling-tool is quite low-level. The model itself is quite simple in it's structure.

Code (python3; numpy, scipy, cylp + CoinOR Cbc solver)

Here the prototype-like code, which is missing the extraction of the final-solution. As this is just a demo (you did not tag a language), this just shows it can be a viable approach.

from cylp.cy import CyClpSimplex
import itertools
import numpy as np
import scipy.sparse as sp
from timeit import default_timer as time

""" Instances """
# hard = {2: [4], 4: [2, 3, 14, 13], 3: [4], 14: [4], 13: [4]}
# hard = {2: [4, 5, 6], 4: [2, 3], 3: [4, 18], 5: [2, 6], 6: [5, 2, 13, 18], 13: [6, 20, 21, 22], 18: [6, 3, 20, 22], 20: [18, 13], 22: [18, 13], 21: [13]}
hard = {2: [5, 6, 9],
        5: [2, 4],
        4: [5, 23],
        6: [2, 7, 10],
        3: [8, 16],
        8: [3, 7, 12],
        7: [6, 8, 10, 11],
        9: [2, 10],
        10: [6, 9, 7, 13, 14, 15, 17, 18],
        11: [7, 12, 13],
        12: [8, 11, 15, 16, 19],
        13: [10, 11, 15],
        14: [10, 15],
        15: [10, 13, 12, 14, 17, 19],
        16: [3, 12, 25, 27],
        17: [10, 15, 18],
        18: [10, 17, 19, 20],
        19: [15, 18, 12, 27],
        20: [18, 22, 24, 26, 27, 25],
        22: [20],
        23: [4, 24, 26],
        24: [23, 20],
        25: [16, 20],
        26: [23, 20],
        27: [19, 20, 16]}

""" Preprocessing -> neighbor conflicts
    (remove dupes after sorting <-> symmetry

    Remark: for difficult use-cases one could try to think about special
    characteristics of the graph, like (not necessarily for this problem)
    chordal -> calc all max-cliques in P(olynomial-time) => pretty good convex-hull

    Here: just forbid conflicting-pairs (in each color-dimension).
"""

START_T = time()

conflicts = []
for key, vals in hard.items():
    for val in vals:
        conflicts.append((key, val))
conflicts_np = np.array(conflicts)
conflicts_np = np.sort(conflicts, axis=1)
conflicts_np = np.unique(conflicts_np, axis=0)

""" Preprocessing -> map IDs to gapless range [0-N)
"""
unique = np.unique(conflicts)
old2new = {}
new2old = {}
counter = itertools.count()
N = unique.shape[0]

for i in unique:
    new_id = next(counter)
    old2new[i] = new_id
    new2old[new_id] = i

conflicts_np = np.vectorize(old2new.get)(conflicts_np)

""" Sparse conflict matrix """
conflict_matrix = sp.coo_matrix((np.ones(conflicts_np.shape[0]*2),
                                (np.tile(np.arange(conflicts_np.shape[0]), 2),
                                 conflicts_np.ravel(order='F'))), shape=(conflicts_np.shape[0], N*4))
I, J, V = sp.find(conflict_matrix)

""" Integer Programming """

model = CyClpSimplex()
# 4 colors -> 4 binary vars per element in N
x = model.addVariable('x', N*4, isInt=True)
# scoring: linear-objective
model.objective = -np.hstack((np.full(N, 10), np.full(N, 6), np.full(N, 3), np.full(N, 1)))
# sub-opt way of forcing binary-constraints (from ints)
# (this awkward usage is due to problem with cylp in the past)
model += sp.eye(N*4) * x >= np.zeros(N*4)
model += sp.eye(N*4) * x <= np.ones(N*4)

# conflicts in each color-dimensions
# sub-opt numpy/scipy usage
for ind, i in enumerate(range(4)):
    if ind == 0:
        model += conflict_matrix * x <= 1
    else:
        shifted_conflicts = sp.coo_matrix((V,(I,J+(ind*N))), shape=(conflict_matrix.shape[0], N*4))
        model += shifted_conflicts * x <= 1

# force exactly one color per element
# sub-opt numpy/scipy usage
template = np.zeros(N*4)
template[0] = 1
template[N] = 1
template[2*N] = 1
template[3*N] = 1
all_color_dims = [sp.csc_matrix(np.roll(template, i).reshape(1,-1)) for i in range(N)]
model += sp.vstack(all_color_dims) *x == 1

cbcModel = model.getCbcModel() # Clp -> Cbc model / LP -> MIP
start_time = time()
status = cbcModel.solve()
end_time = time()

print("  CoinOR CBC used {:.{prec}f} secs".format(end_time - start_time, prec=3))
print("  Complete process used {:.{prec}f} secs".format(end_time - START_T, prec=3))

Output

Welcome to the CBC MILP Solver 
Version: 2.9.9 
Build Date: Jan 15 2018 

command line - ICbcModel -solve -quit (default strategy 1)
Continuous objective value is -200 - 0.00 seconds
Cgl0003I 0 fixed, 0 tightened bounds, 20 strengthened rows, 0 substitutions
Cgl0003I 0 fixed, 0 tightened bounds, 24 strengthened rows, 0 substitutions
Cgl0003I 0 fixed, 0 tightened bounds, 16 strengthened rows, 0 substitutions
Cgl0004I processed model has 153 rows, 100 columns (100 integer (100 of which binary)) and 380 elements
Cutoff increment increased from 1e-05 to 0.9999
Cbc0038I Initial state - 0 integers unsatisfied sum - 0
Cbc0038I Solution found of -194
Cbc0038I Before mini branch and bound, 100 integers at bound fixed and 0 continuous
Cbc0038I Mini branch and bound did not improve solution (0.01 seconds)
Cbc0038I After 0.01 seconds - Feasibility pump exiting with objective of -194 - took 0.00 seconds
Cbc0012I Integer solution of -194 found by feasibility pump after 0 iterations and 0 nodes (0.01 seconds)
Cbc0001I Search completed - best objective -194, took 0 iterations and 0 nodes (0.01 seconds)
Cbc0035I Maximum depth 0, 0 variables fixed on reduced cost
Cuts at root node changed objective from -194 to -194
Probing was tried 0 times and created 0 cuts of which 0 were active after adding rounds of cuts (0.000 seconds)
Gomory was tried 0 times and created 0 cuts of which 0 were active after adding rounds of cuts (0.000 seconds)
Knapsack was tried 0 times and created 0 cuts of which 0 were active after adding rounds of cuts (0.000 seconds)
Clique was tried 0 times and created 0 cuts of which 0 were active after adding rounds of cuts (0.000 seconds)
MixedIntegerRounding2 was tried 0 times and created 0 cuts of which 0 were active after adding rounds of cuts (0.000 seconds)
FlowCover was tried 0 times and created 0 cuts of which 0 were active after adding rounds of cuts (0.000 seconds)
TwoMirCuts was tried 0 times and created 0 cuts of which 0 were active after adding rounds of cuts (0.000 seconds)

Result - Optimal solution found

Objective value:                -194.00000000
Enumerated nodes:               0
Total iterations:               0
Time (CPU seconds):             0.01
Time (Wallclock seconds):       0.01

Total time (CPU seconds):       0.01   (Wallclock seconds):       0.01

  CoinOR CBC used 0.013 secs
  Complete process used 0.042 secs

Results

Your "large" instance is solved within 0.042 secs (complete time with sub-opt code) and 0.013 secs are spent in the core-solver. Of course this is just one instance and interpreting this is not that scientific!

The result is the same as SaiBot's interesting customized-solution (and your smaller examples)!

(Some earlier code had a scoring-bug, which made me ask Saibot for double-checking his solution, which i can now reproduce!)

Transfer

MIP-solvers should be available on most architectures and environments, probably even on mobiles (with some potential non-trivial build-process). Modelling/Usage of those depends somewhat on the modelling-system and surrounding-software.

Upvotes: 3

SaiBot
SaiBot

Reputation: 3745

Here is my try on the problem. I was not able to come up with a better time complexity but optimized the brute-force.

I processed the nodes one-by-one only allowing for colorings such that no two neighbor nodes have the same color.

I added an upper bound estimation for each intermediate (non complete) coloring. For this I assumed that every non-colored node will be colored in the highest-scoring color (only allowing different colors than the already colored neighbours). So in the upper bound calculation two adjacent nodes that have not been colored yet could both be colored "red". With this estimation I built a branch-and-bound algorithm, that terminates the current search-path when the upper-bound estimation is still lower than the current maximum.

The runtime for the small graph is less than 1 ms, for the medium graph it is 15 ms and for the large graph 3.2 seconds. The results for the three graphs are 46, 77 and 194, respectively.

import time
import copy

def upperBoundScore(graph, dfsGraphOder, dfsIndex, coloring, scoring, currentScore):
    maxAdditionalScore = 0;
    for i in range(dfsIndex, len(dfsGraphOder)):
        neighbourColors = {coloring[node] for node in graph[dfsGraphOder[i]]}
        possibleColors = {1, 2, 3, 4} - neighbourColors
        if len(possibleColors) < 1:  # if for one node no color is available stop
            return -1
        maxAdditionalScore += scoring[list(possibleColors)[0]]
    return currentScore+maxAdditionalScore

def colorRemainingGraph(graph, dfsGraphOder, dfsIndex, coloring, scoring, currentScore):
    global maxScore
    global bestColoring
    # whole graph colored
    if dfsIndex == len(dfsGraphOder):
        if currentScore > maxScore:
            maxScore = currentScore
            bestColoring = copy.deepcopy(coloring)
    # only proceed if current coloring can get better then best coloring
    elif upperBoundScore(graph, dfsGraphOder, dfsIndex, coloring, scoring, currentScore) > maxScore:
        neighbourColors ={coloring[node] for node in graph[dfsGraphOder[dfsIndex]]}
        possibleColors = list({1, 2, 3, 4} - neighbourColors)
        for c in possibleColors:
            coloring[dfsGraphOder[dfsIndex]] = c
            currentScore += scoring[c]
            colorRemainingGraph(graph, dfsGraphOder, dfsIndex+1, coloring, scoring, currentScore)
            currentScore -= scoring[c]
            coloring[dfsGraphOder[dfsIndex]] = 0

#graph = {2: [4], 4: [2, 3, 14, 13], 3: [4], 14: [4], 13: [4]}
#graph = {2: [4, 5, 6], 4: [2, 3], 3: [4, 18], 5: [2, 6], 6: [5, 2, 13, 18], 13: [6, 20, 21, 22], 18: [6, 3, 20, 22], 20: [18, 13], 22: [18, 13], 21: [13]}
graph = {2: [5, 6, 9], 5: [2, 4], 4: [5, 23], 6: [2, 7, 10], 3: [8, 16], 8: [3, 7, 12], 7: [6, 8, 10, 11], 9: [2, 10], 10: [6, 9, 7, 13, 14, 15, 17, 18], 11: [7, 12, 13], 12: [8, 11, 15, 16, 19], 13: [10, 11, 15], 14: [10, 15], 15: [10, 13, 12, 14, 17, 19], 16: [3, 12, 25, 27], 17: [10, 15, 18], 18: [10, 17, 19, 20], 19: [15, 18, 12, 27], 20: [18, 22, 24, 26, 27, 25], 22: [20], 23: [4, 24, 26], 24: [23, 20], 25: [16, 20], 26: [23, 20], 27: [19, 20, 16]}

# 0 = uncolored, 1 = red, 2 = green, 3 = blue, 4 = Yellow
scoring = {1:10, 2:6, 3:3, 4:1}
coloring = {node: 0 for node in graph.keys()}
nodeOrder = list(graph.keys())
maxScore = 0
bestColoring = {}

start = time.time()
colorRemainingGraph(graph, nodeOrder, 0, coloring, scoring, 0)
end = time.time()
print("Runtime: "+ str(end - start))
print("Max Score: "+str(maxScore))
print(bestColoring)

For the large graph the resulting coloring is (1 = red, 2 = green, 3 = blue, 4 = yellow):

{2: 1, 3: 1, 4: 1, 5: 2, 6: 2, 7: 1, 8: 2, 9: 2, 10: 3, 11: 2, 12: 1, 13: 1, 14: 1, 15: 4, 16: 2, 17: 2, 18: 1, 19: 2, 20: 2, 22: 1, 23: 2, 24: 1, 25: 1, 26: 1, 27: 1}

To verify that the coloring outputed by the algorithm is correct one can use the below code, which checks if any two neighbor nodes have the same color.

def checkSolution(graph, coloring):
    validColoring=1
    for node in graph:
        for neighbour in graph[node]:
            if coloring[node] == coloring[neighbour]:
                print("wrong coloring found "+ str(node) + " and " + str(neighbour) + " have the same color")
                validColoring = 0
    if validColoring:
        print("Coloring is valid")

Upvotes: 3

Related Questions