Exponential and NP Algorithms

Steven J. Zeil

Last modified: Oct 26, 2023
Contents:

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.

1 A Hard Problem - Graph Coloring

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

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.

1.1 Graph Coloring: A Backtracking Solution

The most obvious solution to this problem is arrived at through a design referred to as backtracking.

Recall that the essence of backtracking is:

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(k^{n})$.

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?

2 NP Problems

We call the set of programs whose worst case is a polynomial order the class P.

2.1 A Parallel Approach to Graph Coloring

This procedure works by asking different computers to separately consider the different colors for v.

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;
          spawn colorGraph_rec(g,w,numColors,colors);
          c = chooseColor(g, v, numColors, colors, c);
        }
      if (any spawned process succeeds)
        return true;  // A solution has been found
      else
        return false;
    }
}

The solutions being considered would still look like a (pruned) tree:

 

But now, all nodes at the same level of the call trees are being executed in parallel.

The running time is therefore proportional the depth of the call tree times the cost per call: $O(|V|)$. And that’s a polynomial!

2.2 Nondeterministic Machines

We call this mythical machine that allows us to spawn off parallel computations at no cost a nondeterministic machine.

The set of programs that can be run in Polynomial time on a Nondeterministic machine is called the NP algorithms, and the set of problems solvable by an algorithm from that set are NP problems.

Note that any problem that can be solved in polynomial time on a single, conventional processor, can certainly be solved in polynomial time on an infinite number of processors, so $P \subseteq NP$.

There are some exponential time algorithms that can’t be solved in polynomial time even on a nondeterministic machine. So the NP problems clearly occupy an intermediate niche between these really nasty exponential problems and the polynomial time P problems.

This leads to one of the more famous unresolved speculations in computer science:

Is P = NP ?

In other words, is an NP problem one for which a polynomial-time solution on a conventional processor exists, but we just haven’t discovered it yet? Or are at least some NP problems truly exponential-time, with no polynomial-time solution possible?

There’s another interesting thing about the NP problems. They all have the curious property that it’s “easier” to tell if we have a solution than it is to compute such a solution in the first place. In fact, this is how the NP problems are usually defined: the set of problems for which we can determine, in polynomial time, whether a proposed solution is correct or not.

You can see how that idea relates to graph coloring. It’s hard to find a coloring for graph. But if you show me a colored graph, I can traverse the graph in polynomial time, comparing each vertex to the ones adjacent to it, and determine whether any two adjacent vertices have the same color. So I can tell, in polynomial time, whether a proposed coloring is correct, but I need exponential time to find such a coloring.

2.3 NP-Complete Problems

The speculation about P = NP is particularly interesting in view of the discovery of a special subset of the NP problems.

Among the NP problems, there is a collection of problems called NP-complete problems that have the property

B can be reduced (converted) to A in polynomial time.

Consequently, if we could find a polynomial-time solution to even one NP-complete problem, we would have a polynomial-time solution to all the NP-complete problems.

Graph coloring is NP-complete.

2.3.1 The Traveling Salesman 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.

2.3.2 Hamiltonian Cycles

By way of illustration of this idea of reducing one problem to another, consider the Hamiltonian Cycle problem:

Given a connected graph, is there a simple cycle visiting each node?

This can be converted into the Traveling Salesman problem in $O(|V|^2)$ time.

 

For example, given this graph (This has Hamiltonian cycle [3,1,4,2,5,6,3].)

 

We assign each edge a cost 1:

 

and then fill in the remaining edges, giving each new edge the cost 2:

The original graph has a Hamiltonian cycle if and only if this graph has a Traveling Salesman solution with cost = $|V|$.

 

For example, if we solve the traveling salesman problem for this graph, we see that the cheapest cycle involves only edges from the original graph.

There are $|V|$ of them (since the cycle must visit every vertex), so the total cost is $|V|$.

If the cost had come out higher, we would know that there had to be at least one cost=2 edge, meaning that no Hamiltonian cycle existed.

So, if we could solve the Traveling Salesman problem in polynomial time, we would be able to solve the Hamiltonian Cycle problem in polynomial time as well.

This is typical of the kind of reduction that relates NP-complete problems to the rest of the NP problems.

2.4 So, is P = NP?

3 If you can’t get across, go around.

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:

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!