We’ve spent a lot of time and effort this semester worrying about finding efficient algorithms, and learning how to tell if they are efficient in the first place. It’s fitting that we end up by noting that there are some very practical problems for which no known efficient solution exists, and for which there is considerable reason to doubt that an efficient solution is possible.
An optimizing compiler may try to save storage by storing variables in the same memory locations if their values are never needed simultaneously:
{
int w,x,y,z;
cin >> w >> x;
y = x + w;
cout << y;
z = x - 1;
cout << z << x;
}
z
could share a location with w
or y
. No other sharing is possible.
The compiler has enough information to construct an “interference graph” in which
vertices represent variables
an edge connects two vertices if the corresponding variables cannot share storage
We then try to color the graph, using as few colors as possible, so that no two adjacent vertices have the same color.
Colors represent storage locations. Two vertices with the same color represent variables that can be stored at the same location without interfering with each other.
Graph coloring problems arise in a number of scheduling and resource allocation situations. Similar problems include scheduling a series of meetings so that people attending the meetings are never scheduled to be in two places at once, assigning classrooms to courses, etc.
The most obvious solution to this problem is arrived at through a design referred to as backtracking.
Recall that the essence of backtracking is:
Number the solution variables [v0,v1,…,vn−1].
Number the possible values for each variable [c0,c1,…,ck−1].
Start by assigning c0 to each vi.
If we have an acceptable solution, stop.
If the current solution is not acceptable, let i = n-1
.
If i < 0
, stop and signal that no solution is possible.
Let j be the index such that vi=cj. If j < k-1
, assign cj+1 to vi and go back to step 4.
But if j≥k−1, assign c0 to vi, decrement i, and go back to step 6.
Although this approach will find a solution eventually (if one exists), it isn’t speedy. Backtracking over n variables, each of which can take on k possible values, is O(kn).
For graph coloring, we will have one variable for each node in the graph. Each variable will take on any of the available colors.
To do a backtracking solution to the graph coloring problem, we start with the plausibility test. The function shown here assumes that the vertices are sequentially numbered and that the colors are represented by integers stored in a backtracking state generator (from our earlier lesson on backtracking).
int clashingColors (const Graph& g,
const BackTrack& colors)
{
// Test to see if the current coloring is OK. If not, return the
// lowest number of a vertex that we want to change for the next
// potential solution.
int vertexToChange = vertexNumbers.size();
for (auto v = vertices(g).first; v != vertices(g).second; ++v)
{
int vNum = *v;
int vColor = colors[vNum];
// Check to see if *v is adjacent to a
// vertex of the same color.
// If so, one of them has to change.
int clashingVertex = -1;
auto outgoing = out_edges(*v, g);
for (auto e = outgoing.first;
(clashingVertex < 0) && (e != outgoing.second); ++e)
{
Vertex w = target(*e, g);
int wColor = colors[w];
if (vColor == wColor)
clashingVertex = max(vNum, w);
}
if (clashingVertex >= 0)
vertexToChange = min(vertexToChange, clashingVertex);
}
return vertexToChange;
}
This function checks all the vertices in the graph to see if any are adjacent to another of the same color. To facilitate pruning of the backtrack search, when it finds clashing assignments, it returns the smallest vertex number that we can safely assume must be changed to yield a solution.
The main routine is a fairly straightforward use of the backtracking generator with pruning.
void colorGraph_backtracking (Graph& g, unsigned numColors,
ColorMap& colors)
{
// Search for acceptable solution via backtracking
BackTrack problem (vertexNumbers.size(), numColors);
bool solved = false;
while (!solved && problem.more())
{
int pruneAt = clashingColors(g, problem);
if (pruneAt >= vertexNumbers.size())
solved = true;
else
problem.prune(pruneAt+1);
}
colors.clear();
if (solved)
{
// Construct the solution (map of vertices to colors)
for (auto v = vertices(g).first; v != vertices(g).second; ++v)
colors[*v] = problem[*v];
}
}
Try out the running the backtracking form in an animation. Try it at least once where you use fewer colors than are actually possible for that graph, to give yourself a feel for how backtracking (mis)behaves when searching for a subtle or non-existent solution.
Before moving on, it’s worth remembering that backtracking is often (usually?) written in recursive form.
bool colorGraph_backtracking (Graph& g, unsigned numColors,
ColorMap& colors)
{
for (auto v = vertices(g).first; v != vertices(g).second; ++v)
{
colors[*v] = -1;
}
colorGraph_rec (g, 0, numColors, colors);
}
bool colorGraph_rec (Graph& g,
Vertex v,
unsigned numColors,
ColorMap& colors)
{
if (v != *(vertices(g).second))
{
// Try to color vertex v, then recursively color the rest.
c = chooseColor(g, v, numColors, colors, -1);
while (c >= 0 && c < numColors)
{
colors[v] = c;
Vertex w = v;
++w;
if (colorGraph_rec(g,w,numColors,colors))
return true; // A solution has been found
else
// We have backtracked to here - try a different color for v
c = chooseColor(g, v, numColors, colors, c);
}
// If we exit the above loop, no solution is possible given the
// colors that had already been assigned to g.vbegin()..v
colors[v] = -1;
return false;
}
else
return true;
}
If the solution variables for a problem form a graph or tree, working with adjacent variables may cause pruning to take place earlier, since the plausibility tests are often framed in terms of properties of adjacent variables.
The solutions considered by this recursive routine are the same as in the iterative case, but now this tree could also be considered to represent the recursive calls made, with each vertex representing an activation (call) to the recursive routine from its parent.
The algorithms I have shown you for coloring are exponential time.
Can we do better?
Yes, but only in the constant multipliers.
There is no known algorithm for graph coloring that is not worst-case exponential time.
There is considerable reason to believe that no polynomial-time algorithm is possible for this problem.
The best known NP-complete problem is the Traveling Salesman Problem: Given a complete graph with edge costs, find the cheapest simple cycle that visits each node exactly once.
Distances | ||||||
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | |
1 | 0 | 6 | 14 | 13 | 20 | 21 |
2 | 6 | 0 | 18 | 13 | 11 | 20 |
3 | 14 | 18 | 0 | 5 | 24 | 8 |
4 | 13 | 13 | 5 | 0 | 7 | 12 |
5 | 20 | 11 | 24 | 7 | 0 | 15 |
6 | 21 | 20 | 8 | 12 | 15 | 0 |
A lot of practical resource allocation and scheduling problems can be shown to be equivalent to the traveling salesman problem, which is somewhat unfortunate because, like all NP-complete problems, we only know exponential-time algorithms for it.
In practice, when faced with an NP or exponential problem, we often resort to approximate solutions or to algorithms that do not always give the best solution.
A heuristic is an approach to a solution that often, but not always, succeeds. Heuristic algorithms often work by exploring a small number of the most common or most likely possibilities in any given situation. For example, a common heuristic approach for many algorithms is the “greedy” approach: always try to take the largest possible step towards a likely overall solution.
A heuristic algorithm for coloring a graph using k colors:
Discard all vertices of degree less than k, keeping them on a stack.
If the rest is successful, we can come back to these and trivially color them.
Keep remaining vertices sorted by the number of colors already assigned to adjacent vertices. (We call this the “number of constraints” on the vertex.)
Repeatedly pick the most constrained vertex and assign it any legal color. (This is the “greedy” part, as we are hoping that by guessing early at the “hardest” parts of the overall problem, the rest will all work out.)
If all the remaining vertices are colored, start popping discarded vertices from the stack, assigning them any legal color as you do.
Try out the the heuristic form of graph coloring in an animation. Note how much better it behaves when faced with problems where you have allowed too few colors to actually color the graph.
Now, this algorithm is not guaranteed to always find a solution, even if one is possible. But it’s not all that easy to come up with a graph on which this algorithm will fail but the much more expensive backtracking approach would succeed. Try it!