Showing posts with label Complexity Analysis. Show all posts
Showing posts with label Complexity Analysis. Show all posts

Thursday, December 28, 2023

NP Complete Class

Introduction

One of the - confusing - topics in Algorithm design is the concept of NP completeness. If you search the topic on the internet you will probably find tons of articles and lectures on the subject however in this short article I will summarize it so that it is easy to remember by the average student or software engineer.

It is all about Running Time

Computational complexity analysis refers to the study of computer algorithms in terms of efficiency. Given a problem of some input size, we need to know how fast or slow it runs as the input size grows to large values. Consider the following examples:

·      Looking an item up in a perfect hash table takes a constant time, theoretically speaking, no matter how big the hash table is, the item can be found instantly (assuming it exists), the algorithm is said to have a running time of O(1). This is indeed very fast.

·      The running time to look up an item in a sorted binary search tree of (n) elements is O(Log(n)) for example if the number of items is (32) then the number of comparisons needed to find an element is (5) in the worst case. As you can see this is a fast algorithm

·      Searching for an element in an unsorted array runs in linear time O(n). If the array contains 100 elements then you need to make 100 comparisons to find the element in the worst case. This means, the algorithm will slow down linearly as the input size grows. To be exact, the running time linearly grows as the input size becomes larger.

Polynomial versus Exponential

Algorithms with a running time function of the form O(n^k) where (k) is a constant are said to run (or solved) in polynomial time. Theoretically speaking, they are fast even if (k) is large. On the other hand, algorithms with a running time of the form O(k^n) where (k) is a constant are said to run in exponential time. These algorithms are very slow. Actually it may take a computer years to finish running the algorithm.

P, NP, NP-Complete, NP-Hard

Now we know what running time means, based on that we can classify problems into classes depending on how complex they are. I will be using plain English as opposed to mathematical terms in order to make it easy to understand.

P Problem

P means that there exists an algorithm to solve the problem which runs in polynomial time. If you are still not sure what running time or polynomial means then please read the introduction one more time.

NP Problem

NP problem means that there exists an algorithm to verify a given solution is correct in polynomial time. Note that we are talking about solution verification. We are not talking about the solution for the problem because no one has ever yet discovered a fast (polynomial) solution for NP problems nor proved the solution does not exist in the first place. NP does not mean None Polynomial, please do not say that in front of people because it is embarrassing, NP stands for non deterministic which means the problem can be solved in polynomial time BUT using none deterministic machine (of course it is not the regular computer we use every day, this computer is in the mind of some weird computer scientists. If you are interested you can research it on your own. Try to search for Turing Machine)

NP-Hard Problem

Now we know what NP means, NP-Hard problem is AT LEAST as hard as any NP problem. It could be harder, who knows. Again, when thinking about problem difficulty we are still referring to the running time of the algorithm. A harder problem takes more time to finish running.

Reduction

It feels bad when you try to solve a problem and it turns to be very hard to the extent that many people have tried it without success. In order to claim that the problem cannot be solved by many people then you need to prove that it is equivalent to some known hard problem. Reduction refers to transforming a problem to another well known (hard) problem so that people won’t dare to call you stupid.  This conversion process should run in polynomial time and can be used to prove a problem is NP-Complete as we will indicate later.

NP-Complete Problem

NP-Complete problem is both NP and NP-Hard. You know what NP and NP-Hard means, so NP-Complete means a problem that is easy to verify a given solution and every problem in NP can be reduced to our problem. The hard part is proving the first example of an NP-complete problem but our friend Steve Cook did that in the 1970s. Thanks to him because he did a great job so that we are not called stupid. In few words, in order to prove that a given problem is NP-Complete then

·      Show it is NP: given a solution, show that it can be verified in polynomial time

·      Show it is NP-Hard: pick an already known NP-Complete problem and show that you can reduce it back to our problem in polynomial time

What is the big deal about the question P = NP?

·      If P is the same as NP then this is good news, there are many interesting real life problems that could be solved very quickly.

·      It is going to be really embarrassing for the folks working in computer science field because it is believed (I am assuming someone got the $1000000 prize) for long time by many people that they are not the same.

·      In reality many believe they are not the same because many people knocked their heads against the wall for so long without any success and because crazy scientists need a proof a solution does not exist, it is going to be an open question.

 

Thursday, November 30, 2023

Complexity and Big-O Notation

An important question is: How efficient is an algorithm or piece of code? Efficiency covers lots of resources, including:
  • CPU (time) usage
  • memory usage
  • disk usage
  • network usage
All are important but we will mostly talk about CPU time in 367. Other classes will discuss other resources (e.g., disk usage may be an important topic in a database class).
Be careful to differentiate between:
  1. Performance: how much time/memory/disk/... is actually used when a program is run. This depends on the machine, compiler, etc. as well as the code.
  2. Complexity: how do the resource requirements of a program or algorithm scale, i.e., what happens as the size of the problem being solved gets larger.
Complexity affects performance but not the other way around.
The time required by a method is proportional to the number of "basic operations" that it performs. Here are some examples of basic operations:
  • one arithmetic operation (e.g., +, *).
  • one assignment
  • one test (e.g., x == 0)
  • one read
  • one write (of a primitive type)
Some methods perform the same number of operations every time they are called. For example, the size method of the List class always performs just one operation: return numItems; the number of operations is independent of the size of the list. We say that methods like this (that always perform a fixed number of basic operations) require constant time.
Other methods may perform different numbers of operations, depending on the value of a parameter or a field. For example, for the array implementation of the List class, the remove method has to move over all of the items that were to the right of the item that was removed (to fill in the gap). The number of moves depends both on the position of the removed item and the number of items in the list. We call the important factors (the parameters and/or fields whose values affect the number of operations performed) the problem size or the input size.
When we consider the complexity of a method, we don't really care about the exact number of operations that are performed; instead, we care about how the number of operations relates to the problem size. If the problem size doubles, does the number of operations stay the same? double? increase in some other way? For constant-time methods like the size method, doubling the problem size does not affect the number of operations (which stays the same).
Furthermore, we are usually interested in the worst case: what is the most operations that might be performed for a given problem size (other cases -- best case and average case -- are discussed below). For example, as discussed above, the remove method has to move all of the items that come after the removed item one place to the left in the array. In the worst case, all of the items in the array must be moved. Therefore, in the worst case, the time for remove is proportional to the number of items in the list, and we say that the worst-case time for remove is linear in the number of items in the list. For a linear-time method, if the problem size doubles, the number of operations also doubles.


TEST YOURSELF #1
Assume that lists are implemented using an array. For each of the following List methods, say whether (in the worst case) the number of operations is independent of the size of the list (is a constant-time method), or is proportional to the size of the list (is a linear-time method):
  • the constructor
  • add (to the end of the list)
  • add (at a given position in the list)
  • isEmpty
  • contains
  • get
Answer:

  • constructor: This method allocates the initial array, sets current to -1 and sets numItems to 0. This has nothing to do with the sequence size and is constant-time.
  • add (to the end of the list): In the worst case, the array was full and you have to allocate a new, larger array, and copy all items. In this case the number of operations is proportional to the size of the list. If the array is not full, this is a constant-time operation (because all you have to do is copy one value into the array and increment numItems).
  • add (at a given position in the list): As for the other version of add, if the array is full, time proportional to the size of the list is required to copy the values from the old array to the new array. However, even if the array is not full, this version of add can require time proportional to the size of the list. This is because, when adding at position k, all of the items in positions k to the end must be moved over. In the worst case (when the new item is added at the beginning of the list), this requires moving all items over, and that takes time proportional to the number of items in the list.
  • isEmpty: This method simply returns the result of comparing numItems with 0; this is a constant=time operation.
  • contains: This method involves looking at each item in the list in turn to see if it is equal to the given item. In the worst case (the given item is at the end of the list or is not in the list at all), this takes time proportional to the size of the list.
  • get: This method checks for a bad position and either throws an exception or returns the value in the given position in the array. In either case it is independent of the size of the list and so it is a constant-time operation.

Constant and linear times are not the only possibilities. For example, consider method createList:
    List createList( int N ) {
      List L = new List();
      for (int k=1; k<=N; k++) L.add(0, new Integer(k));
      return L;
    }
    
Note that, for a given N, the for-loop above is equivalent to:
    L.add(0, new Integer(1) );
    L.add(0, new Integer(2) );
    L.add(0, new Integer(3) );
        ...
    L.add(0, new Integer(N) );
    
If we assume that the initial array is large enough to hold N items, then the number of operations for each call to add is proportional to the number of items in the list when add is called (because it has to move every item already in the array one place to the right to make room for the new item at position 0). For the N calls shown above, the list lengths are: 0, 1, 2, ..., N-1. So what is the total time for all N calls? It is proportional to 0 + 1 + 2 + ... + N-1.
Recall that we don't care about the exact time, just how the time depends on the problem size. For method createList, the "problem size" is the value of N (because the number of operations will be different for different values of N). It is clear that the time for the N calls (and therefore the time for method createList) is not independent of N (so createList is not a constant-time method). Is it proportional to N (linear in N)? That would mean that doubling N would double the number of operations performed by createList. Here's a table showing the value of 0+1+2+...+(N-1) for some different values of N:

N0+1+2+...+(N-1)
46
828
16120
Clearly, the value of the sum does more than double when the value of N doubles, so createList is not linear in N. In the following graph, the bars represent the lengths of the list (0, 1, 2, ..., N-1) for each of the N calls.
The value of the sum (0+1+2+...+(N-1)) is the sum of the areas of the individual bars. You can see that the bars fill about half of the square. The whole square is an N-by-N square, so its area is N2; therefore, the sum of the areas of the bars is about N2/2. In other words, the time for method createList is proportional to the square of the problem size; if the problem size doubles, the number of operations will quadruple. We say that the worst-case time for createList is quadratic in the problem size.


TEST YOURSELF #2
Consider the following three algorithms for determining whether anyone in the room has the same birthday as you.
Algorithm 1: You say your birthday, and ask whether anyone in the room has the same birthday. If anyone does have the same birthday, they answer yes.
Algorithm 2: You tell the first person your birthday, and ask if they have the same birthday; if they say no, you tell the second person your birthday and ask whether they have the same birthday; etc, for each person in the room.
Algorithm 3: You only ask questions of person 1, who only asks questions of person 2, who only asks questions of person 3, etc. You tell person 1 your birthday, and ask if they have the same birthday; if they say no, you ask them to find out about person 2. Person 1 asks person 2 and tells you the answer. If it is no, you ask person 1 to find out about person 3. Person 1 asks person 2 to find out about person 3, etc.
Question 1: For each algorithm, what is the factor that can affect the number of questions asked (the "problem size")?
Question 2: In the worst case, how many questions will be asked for each of the three algorithms?
Question 3: For each algorithm, say whether it is constant, linear, or quadratic in the problem size in the worst case.

Answer:
Question 1: The problem size is the number of people in the room.
Question 2: Assume there are N people in the room. In algorithm 1 you always ask 1 question. In algorithm 2, the worst case is if no one has your birthday. Here you have to ask every person to figure this out. This is N questions. In algorithm 3, the worst case is the same as algorithm 2. The number of questions is 1 + 2 + 3 + ... + N-1 + N. We showed before that this sum is N(N+1)/2.

Question 3: Given the number of questions you can see that algorithm 1 is constant time, algorithm 2 is linear time, and algorithm 3 is quadratic time in the problem size.

Big-O Notation

We express complexity using big-O notation. For a problem of size N:
  • a constant-time method is "order 1": O(1)
  • a linear-time method is "order N": O(N)
  • a quadratic-time method is "order N squared": O(N2)
Note that the big-O expressions do not have constants or low-order terms. This is because, when N gets large enough, constants and low-order terms don't matter (a constant-time method will be faster than a linear-time method, which will be faster than a quadratic-time method). See below for an example.
Formal definition:
    A function T(N) is O(F(N)) if for some constant c and for all values of N greater than some value n0:
    T(N) <= c * F(N)
The idea is that T(N) is the exact complexity of a method or algorithm as a function of the problem size N, and that F(N) is an upper-bound on that complexity (i.e., the actual time/space or whatever for a problem of size N will be no worse than F(N)). In practice, we want the smallest F(N) -- the least upper bound on the actual complexity.
For example, consider T(N) = 3 * N2 + 5. We can show that T(N) is O(N2) by choosing c = 4 and n0 = 2. This is because for all values of N greater than 2:
3 * N2 + 5 <= 4 * N2
T(N) is not O(N), because whatever constant c and value n0 you choose, I can always find a value of N greater than n0 so that 3 * N2 + 5 is greater than c * N.

How to Determine Complexities

In general, how can you determine the running time of a piece of code? The answer is that it depends on what kinds of statements are used.
  1. Sequence of statements
      statement 1;
      statement 2;
        ...
      statement k;
      
    (Note: this is code that really is exactly k statements; this is not an unrolled loop like the N calls to add shown above.) The total time is found by adding the times for all statements:total time = time(statement 1) + time(statement 2) + ... + time(statement k)
    If each statement is "simple" (only involves basic operations) then the time for each statement is constant and the total time is also constant: O(1). In the following examples, assume the statements are simple unless noted otherwise.
  2. if-then-else statements
      if (condition) {
          sequence of statements 1
      }
      else {
          sequence of statements 2
      }
      
    Here, either sequence 1 will execute, or sequence 2 will execute. Therefore, the worst-case time is the slowest of the two possibilities: max(time(sequence 1), time(sequence 2)). For example, if sequence 1 is O(N) and sequence 2 is O(1) the worst-case time for the whole if-then-else statement would be O(N).
  3. for loops
      for (i = 0; i < N; i++) {
          sequence of statements
      }
      
    The loop executes N times, so the sequence of statements also executes N times. Since we assume the statements are O(1), the total time for the for loop is N * O(1), which is O(N) overall.
  4. Nested loopsFirst we'll consider loops where the number of iterations of the inner loop is independent of the value of the outer loop's index. For example:
      for (i = 0; i < N; i++) {
          for (j = 0; j < M; j++) {
              sequence of statements
          }
      }
      
    The outer loop executes N times. Every time the outer loop executes, the inner loop executes M times. As a result, the statements in the inner loop execute a total of N * M times. Thus, the complexity is O(N * M). In a common special case where the stopping condition of the inner loop is j < N instead of j < M (i.e., the inner loop also executes N times), the total complexity for the two loops is O(N2).Now let's consider nested loops where the number of iterations of the inner loop depends on the value of the outer loop's index. For example:
      for (i = 0; i < N; i++) {
          for (j = i+1; j < N; j++) {
              sequence of statements
          }
      }
      
    Now we can't just multiply the number of iterations of the outer loop times the number of iterations of the inner loop, because the inner loop has a different number of iterations each time. So let's think about how many iterations that inner loop has. That information is given in the following table:
    Value of iNumber of iterations of inner loop
    0N
    1N-1
    2N-2
    ......
    N-22
    N-11
    So we can see that the total number of times the sequence of statements executes is: N + N-1 + N-2 + ... + 3 + 2 + 1. We've seen that formula before: the total is O(N2).

    TEST YOURSELF #3
    What is the worst-case complexity of the each of the following code fragments?
    1. Two loops in a row:
        for (i = 0; i < N; i++) {
            sequence of statements
        }
        for (j = 0; j < M; j++) {
            sequence of statements
        }
        
      How would the complexity change if the second loop went to N instead of M?
    2. A nested loop followed by a non-nested loop:
        for (i = 0; i < N; i++) {
            for (j = 0; j < N; j++) {
                sequence of statements
            }
        }
        for (k = 0; k < N; k++) {
            sequence of statements
        }
        
    3. A nested loop in which the number of times the inner loop executes depends on the value of the outer loop index:
        for (i = 0; i < N; i++) {
            for (j = N; j > i; j--) {
                sequence of statements
            }
        }
        
    Answer:
    1. The first loop is O(N) and the second loop is O(M). Since you don't know which is bigger, you say this is O(N+M). This can also be written as O(max(N,M)). In the case where the second loop goes to N instead of M the complexity is O(N). You can see this from either expression above. O(N+M) becomes O(2N) and when you drop the constant it is O(N). O(max(N,M)) becomes O(max(N,N)) which is O(N).
    2. The first set of nested loops is O(N2) and the second loop is O(N). This is O(max(N2,N)) which is O(N2).
    3. This is very similar to our earlier example of a nested loop where the number of iterations of the inner loop depends on the value of the index of the outer loop. The only difference is that in this example the inner-loop index is counting down from N to i+1. It is still the case that the inner loop executes N times, then N-1, then N-2, etc, so the total number of times the innermost "sequence of statements" execites is O(N2).

  5. Statements with method calls:When a statement involves a method call, the complexity of the statement includes the complexity of the method call. Assume that you know that method f takes constant time, and that method g takes time proportional to (linear in) the value of its parameter k. Then the statements below have the time complexities indicated.
      f(k);  // O(1)
      g(k);  // O(k)
      
    When a loop is involved, the same rule applies. For example:
      for (j = 0; j < N; j++) g(N);
      
    has complexity (N2). The loop executes N times and each method call g(N) is complexity O(N).

    TEST YOURSELF #4
    For each of the following loops with a method call, determine the overall complexity. As above, assume that method f takes constant time, and that method g takes time linear in the value of its parameter.
      1. for (j = 0; j < N; j++) f(j);
      
      2. for (j = 0; j < N; j++) g(j);
      
      3. for (j = 0; j < N; j++) g(k);
      
    Answer: 

  1. Each call to f(j) is O(1). The loop executes N times so it is N x O(1) or O(N).
  2. The first time the loop executes j is 0 and g(0) takes "no operations". The next time j is 1 and g(1) takes 1 operations. The last time the loop executes j is N-1 and g(N-1) takes N-1 operations. The total work is the sum of the first N-1 numbers and is O(N2).
  3. Each time through the loop g(k) takes k operations and the loop executes N times. Since you don't know the relative size of k and N, the overall complexity is O(N x k).

Best-case and Average-case Complexity

Some methods may require different amounts of time on different calls, even when the problem size is the same for both calls. For example, consider the add method that adds an item to the end of the list. In the worst case (the array is full), that method requires time proportional to the number of items in the list (because it has to copy all of them into the new, larger array). However, when the array is not full, add will only have to copy one value into the array, so in that case its time is independent of the length of the list; i.e., constant time.
In general, we may want to consider the best and average time requirements of a method as well as its worst-case time requirements. Which is considered the most important will depend on several factors. For example, if a method is part of a time-critical system like one that controls an airplane, the worst-case times are probably the most important (if the plane is flying towards a mountain and the controlling program can't make the next course correction until it has performed a computation, then the best-case and average-case times for that computation are not relevant -- the computation needs to be guaranteed to be fast enough to finish before the plane hits the mountain).
On the other hand, if occasionally waiting a long time for an answer is merely inconvenient (as opposed to life-threatening), it may be better to use an algorithm with a slow worst-case time and a fast average-case time, rather than one with so-so times in both the average and worst cases.
Note that calculating the average-case time for a method can be tricky. You need to consider all possible values for the important factors, and whether they will be distributed evenly.

When do Constants Matter?

Recall that when we use big-O notation, we drop constants and low-order terms. This is because when the problem size gets sufficiently large, those terms don't matter. However, this means that two algorithms can have thesame big-O time complexity, even though one is always faster than the other. For example, suppose algorithm 1 requires N2 time, and algorithm 2 requires 10 * N2 + N time. For both algorithms, the time is O(N2), but algorithm 1 will always be faster than algorithm 2. In this case, the constants and low-order terms do matter in terms of which algorithm is actually faster.
However, it is important to note that constants do not matter in terms of the question of how an algorithm "scales" (i.e., how does the algorithm's time change when the problem size doubles). Although an algorithm that requires N2 time will always be faster than an algorithm that requires 10*N2 time, for both algorithms, if the problem size doubles, the actual time will quadruple.
When two algorithms have different big-O time complexity, the constants and low-order terms only matter when the problem size is small. For example, even if there are large constants involved, a linear-time algorithm will always eventually be faster than a quadratic-time algorithm. This is illustrated in the following table, which shows the value of 100*N (a time that is linear in N) and the value of N2/100 (a time that is quadratic in N) for some values of N. For values of N less than 104, the quadratic time is smaller than the linear time. However, for all values of N greater than 104, the linear time is smaller.
N100*NN2/100
102104102
103105104
104106106
105107108
1061081010
1071091012

Friday, May 2, 2014

Correctness of Algorithms

What does it mean to produce a correct solution to a problem? We can usually specify precisely what a correct solution would entail. For example, if your GPS produces a correct solution to finding the best route to travel, it might be the route, out of all possible routes from where you are to your desired destination, that will get you there soonest or perhaps the route that has the shortest possible distance or the route that will get you there soonest but also avoids tolls. Of course, the information that your GPS uses to determine a route might not match reality. Unless your GPS can access real-time traffic information, it might assume that the time to traverse a road equals the road's distance divided by the road's speed limit. If the road is congested, however, the GPS might give you bad advice if you're looking for the fastest route. We can still say that the routing algorithm that the GPS runs is correct, however, even if the input to the algorithm is not; for the input given to the routing algorithm, the algorithm produces the fastest route. Now, for some problems, it might be difficult or even impossible to say whether an algorithm produces a correct solution.
Sometimes, however, we can accept that a computer algorithm might produce an incorrect answer, as long as we can control how often it does so. Encryption provides a good example. The commonly used RSA cryptosystem relies on determining whether large numbers-really large, as in hundreds of digits long-are prime. If you have ever written a computer program, you could probably write one that determines whether a number n is prime. It would test all candidate divisors from 2 through n - 1, and if any of these candidates is indeed a divisor of n, then n is composite. If no number between 2 and n - 1 is a divisor of n, then n is prime. But if n is hundreds of digits long, that's a lot of candidate divisors, more than even a really fast computer could check in any reasonable amount of time. Of course, you could make some optimizations, such as eliminating all even candidates once you find that 2 is not a divisor, or stopping once you get to sqrt(n) (since if d is greater than sqrt(n) and d is a divisor of n, then n/ d is less than sqrt(n) and is also a divisor of n; therefore, if n has a divisor, you will find it by the time you get to sqrt(n)). If n is hundreds of digits long, then although sqrt(n) has only about half as many digits as n does, it's still a really large number. The good news is that we know of an algorithm that tests quickly whether a number is prime. The bad news is that it can make errors. In particular, if it declares that n is composite, then n is definitely composite, but if it declares that n is prime, then there's a chance that n is actually composite. But the bad news is not all that bad: we can control the error rate to be really low, such as one error in every 250 times. That's rare enough-one error in about every million billion times-for most of us to be comfortable with using this method to determine whether a number is prime for RSA.
Correctness is a tricky issue with another class of algorithms, called approximation algorithms. Approximation algorithms apply to optimization problems, in which we want to find the best solution according to some quantitative measure. Finding the fastest route, as a GPS does, is one example, where the quantitative measure is travel time. For some problems, we have no algorithm that finds an optimal solution in any reasonable amount of time, but we know of an approximation algorithm that, in a reasonable amount of time, can find a solution that is almost optimal. By "almost optimal;' we typically mean that the quantitative measure of the solution found by the approximation algorithm is within some known factor of the optimal solution's quantitative measure. As long as we specify what the desired factor is, we can say that a correct solution from an approximation algorithm is any solution that is within that factor of the optimal solution.

Saturday, December 28, 2013

Class NP and NP-Completeness

Class NP


  • The class of decision problems for which there is a polynomially bounded nondeterministic algorithm.


  • The class of problems that are "slightly harder" than P.


  • In general, an exponential number of subproblems, each of which can be solved or verified in polynomial time.


  • Being in class NP constitutes an upper bound on problem complexity.


Nondeterministic Turing Machine (NDTM)



  • Model a problem as a tree in which the nodes represent states, with time flowing from the root to the children. A leaf represents a solution.


  • In an NDTM, the computation splits whenever a guess is needed.
    • The tree has polynomial depth but is exponentially bushy.


  • "Guess" nodes are like OR nodes: if either of the children returns T, the "guess" node returns T.


  • NP-hard



  • A problem Q is NP-hard if every problem in NP is reducible to Q.


  • A lower bound on a problem: "at least as hard as any problem in NP."


  • NP-Complete



  • The hardest decision problems in NP.


  • If there were a polynomially bounded algorithm for an NP-complete problem, then there would be a polynomially bounded algorithm for each problem in NP.


  • Establishes both a lower and an upper bound (although it may be that P=NP).


  • If any NP-complete problem is in P, then P=NP.


  • Satisfiability (SAT)



  • Instance: Given a set U = {u1, ..., um} of variables and a collection C = {c1, ..., cn} of clauses over U.


  • Question: Is there a truth assignment to U that satisfies C?


  • A logical formula in conjunctive normal form (CNF): a conjunction of disjunctions
    e.g., (x1 + x2 + ¬x3) · (x1 + x3) · (¬x2)

    • easy to falsify: make all terms in one clause fail.


    • hard to satisfy: terms in clauses interact.



  • Cook's Theorem: SAT is NP-complete.

    • uses SAT to simulate a machine



  • Once the primordial NP-complete problem is found, we can prove other problems NP-complete without reference to machines:

    • To prove that problem Q is NP-complete, reduce SAT (or any other NP-complete problem) to Q:
            SAT <=P Q


    • E.g., 3SAT: like SAT, but | ci | = 3 for 1 <= i <= n.


    • Need to show that 3SAT is NP-hard:
      Since X <=P SAT for any problem X in NP, we need only show SAT <=P 3SAT.


    • Proof:
      General form of clauses in SAT is cj = (z1 + z2 + ... + zk), where each zi is either an input or the negation of an input.
      k = 1
      {z1 + z1 + z1}
      k = 2
      {z1 + z2 + z2}
      k = 3
      {z1 + z2 + z3}
      k >= 4
      Add auxiliary variables that transmit information between pieces.
      E.g., {z1 + z2 + z3 + z4 + z5} becomes {(z1 + z2 + y1) (¬y1 + z3 + y2) (¬y2 + z4 + z5)}
      The transformation is polynomial in the length of the SAT instance.

    • Note: 2SAT is solvable in polynomial time.



  • Other NP-Complete Problems


    • Graph coloring
      A coloring of a graph is the assignment of a "color" to each vertex of the graph such that adjacent vertices are not assigned the same color. The chromatic number of a graph G, X(G) [Chi(G)], is the smallest number of colors needed to color G.
      Optimization problem: Given an undirected graph G, determine X(G) (and produce an optimal coloring).
      Decision problem: Given G and a positive integer k, is there a coloring of G using at most k colors (i.e., is G k-colorable)?


    • Job scheduling with penalties
      Suppose we are given n jobs to be executed one at a time and, for each job, an execution time, deadline, and penalty for missing the deadline.
      Optimization problem: Determine the minimum possible penalty (and find an optimal schedule).
      Decision problem: Given a nonnegative integer k, is there a schedule with penalty < k?
      Note: Job scheduling without penalties such that at most k jobs miss their deadlines is in P.


    • Multiprocessor scheduling
      Suppose we are given a deadline and a set of tasks of varying length to be performed on two identical processors.
      Optimization problem: Determine an allocation of the tasks to the two processors such that the deadline can be met.
      Decision problem: Can the tasks be arranged so that the deadline is met?


    • Bin packing
      Optimization problem: Given an unlimited number of bins, each of size 1, and n objects with sizes s1, ..., sn where 0 < si < 1, determine the smallest number of bins into which the objects can be packed (and find an optimal packing).
      Decision problem: Given, in addition to the inputs described above, an integer k, do the objects fit in k bins?


    • Knapsack problem
      Optimization problem: Given a knapsack of capacity C and n objects with sizes s1, ..., sn and "profits" p1, ..., pn, find the largest total profit of any subset of the objects that fits in the knapsack ( and find a subset that achieves the maximum profit).
      Decision problem: Given k, is there a subset of the objects that fits in the knapsack and has total profit at least k?


    • Subset sum problem
      Optimization problem: Given a positive integer C and n objects with positive sizes s1, ..., sn, among subsets of the objects with sum at most C, what is the largest subset sum?
      Decision problem: Is there a subset of the objects whose sizes add up to exactly C?
      The subset sum problem is a simpler version of the knapsack problem, in which the profit for each object is the same as its size.


    • Partition
      Suppose we are given a set of integers.
      Decision problem: Can the integers be partitioned into two sets whose sum is equal?


    • Hamiltonian cycles and Hamiltonian paths
      A Hamiltonian cycle (path) is a simple cycle (path) that passes through every vertex of a graph exactly once.
      Decision problem: Does a given undirected graph have a Hamiltonian cycle (path)?
      Note: An Euler tour -- a cycle that traverses each edge of a graph exactly once -- can be found in O(e) time.


    • Traveling salesperson problem (TSP) or minimum tour problem
      Optimization problem: Given a complete, weighted graph, find a minimum-weight Hamiltonian cycle.
      Decision problem: Given a complete, weighted graph and an integer k, is there a Hamiltonian cycle with total weight at most k?
      A complete graph has an edge between each pair of vertices.


    • Vertex cover
      A vertex cover for an undirected graph G is a subset V' of vertices such that each edge in G is incident upon some vertex in V'.
      Optimization problem: Find a vertex cover for G with as few vertices as possible.
      Decision problem: Given an integer k, does G have a vertex cover consisting of k vertices?
      Note: The edge cover problem is in P.


    • Clique
      A clique is a subset V' of vertices in an undirected graph G such that every pair of distinct vertices in V' is joined by an edge in G (i.e., the subgraph induced by V' is complete). A clique with k vertices is called a k-clique.
      Optimization problem: Find a clique with as many vertices as possible.
      Decision problem: Given an integer k, does G have a k-clique?


    • Independent set
      An independent set is a subset V' of vertices in an undirected graph G such that no pair of vertices in V' is joined by an edge of G.
      Optimization problem: Find an independent set with as many vertices as possible.
      Decision problem: Given an integer k, does G have an independent set consisting of k vertices?


    • Feedback edge set
      A feedback edge set in a digraph G is a subset E' of edges such that every cycle in G has an edge in E'.
      Optimization problem: Find a feedback edge set with as few edges as possible.
      Decision problem: Given an integer k, does G have a feedback edge set consisting of k edges?
      Note: The feedback edge set for undirected graphs is in P.


    • Longest simple path problem
      Optimization problem: What is the longest simple path between any two vertices in graph G?
      Decision problem: Given an integer k, is there a path in graph G longer than k?
      Note: The shortest path between any two vertices can be found in O(ne) time.


    • Subgraph isomorphism
      Decision problem: Given two graphs G1 and G2, determine if G1 is isomorphic to a subgraph of G2.


    • Integer linear programming
      Linear programming is a method of analyzing systems of linear inequalities to arrive at optimal solutions to problems
      Decision problem: Given a linear program, is there an integral solution?


    References
    • Baase and Van Gelder, Computer Algorithms, 3e (Addison Wesley Longman, 2000)
    • Sedgewick, Algorithms (Addison Wesley, 1983)
    • Cormen, Leiserson, Rivest, and Stein, Introduction to Algorithms, 2e (MIT Press/McGraw Hill, 2001) The authors provide NP-completeness proofs reducing 3SAT to CLIQUE to VERTEX-COVER to HAMILTONIAN-CYCLE to TSP, and 3SAT to SUBSET-SUM.

    Turing Machines and Computability

    Computing Machines and Algorithms

    An algorithm is a computational process that takes a problem instance and in a finite amount of time produces a solution. . . . It is hard to make the definition of algorithm more precise except by saying that a computational process is anything that can be done by a program for a computing machine, and in that case one must accept that a human being with paper and pencil is a kind of computing machine. (Floyd and Beigel, The Language of Machines, Computer Science Press, W.H. Freeman, 1994, p. 444.)

    Computability and Decidability

    Problems which are intended to be solved by a computational process may be stated in a variety of ways:
    Decision Problems
    A decision problem is stated as a question with a "yes" or "no" answer, such as:
    • Is the number 23171 prime?
    • Does 2005 January 1 fall on a Friday?
    • Is the list of names in the file 'clients.txt' in sorted order?
    • Does she love me?
    If the problem is stated as a Boolean statement --- an assertion which is either true or false --- we call the statement a predicate.
    If there is an effective procedure for answering the question or evaluating the assertion, we say that the problem is decidable.
    Functions
    Other problems require a unique but particular answer:
    • What is the smallest prime factor of 23171?
    • On which day of the week will the year 2005 begin?
    • What ordering of the names in the file 'clients.txt' would represent ascending sorted order?
    • Which persons from the following list of eligible bachelors love me?
    This type of problem can be viewed as the evaluation of a function, since the answer is unique. It can also be viewed as a mapping of an input value to an output value. If we have a function that computes smallest prime factors, we give it 23171 as an input value and it yields 17 as an output value. Note that problems which we might think of as procedures or processes, such as sorting, can also be viewed as functions which map inputs to outputs --- a sorting program maps a given unsorted list of names to a unique sorted list of names.
    A function is said to be computable if there is an effective procedure (an algorithm) for evaluating the function which, given any input or set of input values for which the function is defined, produces the correct result and halts in a finite amount of time. (If the function is undefined for some input values in its domain [i.e., it is a partial function rather than a total function], the procedure is not required to halt. For example, we can regard the problem of finding the smallest prime factor of a given natural number as computable even if it fails to halt if given 0 as an input, and assuming that we define the function to be 1 if the input number is prime or 1.)
    A decision problem can be reformulated as a function by defining a function which returns 0 if the answer is 'no' or the predicate is false or 1 if the answer is 'yes' or the predicate is true.
    Relations
    Some problems may have multiple correct answers or no correct answers. (This is the general case which could be viewed as including the previous two categories.)
    • Find a prime factor of 23171.
    • What month in the year 2005 will have a Friday the 13th?
    • What is a possible shuffled ordering of the following list of cards?
    • Who loves me?
    Mathematicians call such problems relations, because the answers are not unique.
    A relation may be computable even if it doesn't produce a result, as would happen if we provided a prime number as the input to a prime-factor-finding procedure. Since we could reformulate a relation as a function by specifying a particular value to be returned if there is no answer to the problem and by defining the function to return a list of all the possible answers or a randomly selected answer if there are multiple correct answers to the problem, it suffices to talk about computable functions and to ignore the distinction between functions and relations for the purposes of determining computability.

    Turing Machines and Computability

    The Decision Problem
    In 1936, Alan Turing published a paper called "On computable numbers, with an application to the Entscheidungsproblem [decision problem]", in which he addressed a previously unsolved mathematical problem posed by the German mathematician David Hilbert in 1928: Is there, in principal, any definite mechanical method or process by which all mathematical questions could be decided? (source)
    To answer the question (in the negative), Turing proposed a simple abstract computing machine, modelled after a mathematician with a pencil, an eraser, and a stack of pieces of paper (Floyd and Beigel, p . 444). He asserted that any function is a computable function if it is computable by one of these abstract machines. He then investigated whether there were some mathematical problems which could not be solved by any such abstract machine. Such abstract computing machines are now called "Turing machines". One particular Turing machine, called a "Universal Turing Machine", served as the model for designing actual programmable computers.
    The Turing Machine
    A Turing machine (TM) consists of a control unit and a read/write head positioned over a tape of unlimited length which contains a finite string (sequence) of characters from some alphabet (designated set of possible characters). The tape is conceptually divided into squares or frames, each of which can hold precisely one character. The possible operations of a TM are:
    • read the character in the square currently under the head
    • write a character in the square under the head (overwriting the character that was there, if any)
    • move the head one square to the right or left
    • check if the tape head is at the left end of the tape
    In most formulations, the tape is "one-way infinite", extending infinitely to the right. The head cannot move left from the left end of the tape; if it moves right beyond the end of the string on the tape to a previously unvisited square, this square is assumed to be blank (i.e., a blank is presumed to be a valid character in the designated alphabet). [The Turing machine simulator in the online materials accompanying the textbook does not behave this way --- you have to be sure to provide sufficient 'b' characters to represent blanks at the end of the input string.]
    It is fairly easy to imagine how the read/write head operates on a tape -- we can imagine it as being similar to a tape recorder, or we can simulate its action using a pencil, an eraser, and a strip of paper. However, it is the contents of the control unit that really defines aTuring machine. While every Turing machine has a read/write head, a tape, and a control unit, it is the contents of the control unit that differentiates one Turing machine from another. Since we are familiar with modern computers, we might think of the contents of the control unit as the program that is loaded into the control unit's memory. The program determines what the Turing machine actually does.
    Actually, it would be better to think of the program as being built in to the control unit, since Turing envisioned each Turing machine as being custom-built to execute a single algorithm (i.e., compute a single function.) The image of loading a program into a control unit would be more appropriately applied to the Universal Turing Machine, which we discuss below.
    We can describe or define the "program" that is built in to a given Turing machine using a diagram or by listing the set of rules the control unit should follow. We can think of each rule as a single "instruction" in the Turing machine's program. In addition to the operations related to the read/write head listed above -- read a character, write a character, move left or right on the tape, and check to make sure we are not moving off the left end of the tape -- the control unit keeps track of what state it is currently in, and can change from one state to another if the current rule (instruction) says to do so. Typically, each state has a number of rules associated with it; different rules are used for each state. The control unit selects the rule that should be executed next depending on
    • which state it is currently in and
    • which character is read from the current square of the tape.
    Most illustrations of the operation of Turing machines do very simple things on very simple input strings. For example, Lab 8.1 in the textbook illustrates the operation of TM1, a machine that copies a string of 0's and 1's (terminated by '!') to the blank portion of the tape, and TM2, which adds 1 to a positive integer represented in unary notation at the start of the tape. (See also a description of TM1 and TM2 using diagrams and explanatory text.) We typically use very simple Turing machines operating on simple input strings not because we can't define Turing machines to do complex tasks, but because it takes so many rules to do even simple things that we would quickly get confused trying to understand even a moderately complex Turing machine.
    Why are descriptions of Turing machines so verbose? Because Turing purposely chose a very simple machine which is restricted to a few very simple operations in order to make it obvious that his proof of the answer to the decision problem was correct.
    Why a Turing Machine?
    Most actual computers do not use a tape for input, storage, and output --- they use random-access memory (RAM), stacks, and registers. So why do we use a Turing machine --- a tape machine --- as a model of computing?
    1. It can be (i.e., has been) proved that a single one-way infinite tape "is computationally as powerful as any collection of known memory devices" (Floyd and Beigel, p. 92).
    2. "Because Turing machines are very simple compared with computers in practical use, it is conceptually easier to prove impossibility results for Turing machines. These impossibility results apply as well to all known computers." (Ibid.)
    One might also ask, "Aren't Turing machines slow?" Since the Turing machine is an abstract machine --- that is, it exists only as a mental concept, or as a diagram on paper --- its speed is both irrelevant and unknown. All we need to assume is that each operation of a Turing machine takes some non-zero amount of time to execute. This allows us to talk about Turing machines that never halt for some inputs. (If operations were assumed to be instantaneous, then even a process that required an infinite number of steps would finish right away, wouldn't it?)
    The Church-Turing Thesis
    Several other researchers tried to address the Decision Problem by other methods. Alonzo Church introduced recursive partial functions as a formalization of algorithmically computable functions. Emil Post proposed symbol manipulation systems for making logical deductions. When it was proven that all three models were equivalent (i.e., they defined the same class of functions, and agreed as to which of them are computable), Church recognized that "all formalizations of algorithms were destined to yield the same class of computable functions" (Denning, Dennis, and Qualitz, Machines, Languages, and Computation, Prentice-Hall, 1978, p. 477) and proposed what has come to be known as the Church-Turing thesis (given here as formulated by Floyd and Beigel, p. 444):
    A Turing machine program can simulate any physically realizable computational process at all --- including that of the most powerful digital computers or a human being.
    This is a thesis, not a theorem. It cannot be proven, because the term "computational process" (equivalently, "algorithm") is not formally defined. Turing proposed his abstract machines precisely for the purpose of formalizing the concept of algorithm.
    The Universal Turing Machine
    Turing also showed that it is possible (actually, fairly easy) to design a single Turing machine which can simulate the computations of any Turing machine, given an encoded description of the target TM and its initial configuration (the "input" string on its tape, the initial state, and the position of the head). Such a machine is called a Universal Turing Machine (UTM).
    Physical programmable computers are effectively Universal Turing Machines which simulate the computations of any Turing machine (think, "algorithm" or special-purpose computer) by executing a program, which we can think of as a description of the computational process to be performed.
    The existence of the UTM, together with the Church-Turing thesis, implies that "there is a certain minimal level of computational ability that is sufficient for any algorithmic computation" (Denning, Dennis, and Qualitz, p. 486).

    Noncomputable Functions

    The most startling result of Turing's 1936 paper was his assertion that there are well-defined problems that cannot be solved by any computational procedure. If these problems are formulated as functions, we call such functions noncomputable; if formulated as predicates, they are called undecidable. Using Turing's concept of the abstract machine, we would say that a function is noncomputable if there exists no Turing machine that could compute it.
    Goedel Numbering
    The proof that there are functions which are noncomputable uses a method of converting problems to numbers invented by Kurt Goedel.
    Goedel numbering
    A function that encodes each element of a set into a unique integer.
    Just as we can encode any Turing machine in a description which can be submitted to a Universal Turing Machine for simulation, it is also possible to assign a "serial number" (a unique positive integer) to every possible Turing machine. Many different encoding methods are possible --- the textbook presents one method using binary strings. This implies that the set of all Turing machines is countable, that is, is in one-to-one correspondence with the integers.
    However, the set of all functions f: N -> N (matchings of inputs to outputs over the natural numbers) is known to be uncountable. We must conclude that Turing machines are able to compute only a subset of the number-theoretic functions.

    The Halting Problem

    We might assume that those functions which are noncomputable mustn't be useful. However, Turing also provided a constructive proof for his thesis by showing that a particular function which would be very useful to computer scientists was noncomputable: the halting problem.
      Given a Turing machine M and an initial tape T, does M halt when started on tape T?
    The same problem can be stated in an alternative formulation which highlights its significance for computer scientists:
      Given a program P and a string x, does P halt on input x?

    Self-referential Paradoxes
    Epimenides' Paradox:
    This sentence is false.
    The text below a picture of a pipe in Rene Magritte's The Air and the Song (1964):
    Ceci n'est pas une pipe.
    Bertand Russell's Barber Paradox:
    The barber in a certain town had a sign on the wall saying, "I shave those men, and only those, who do not shave themselves."
    The proof that the halting problem is noncomputable relies on the same device illustrated in the preceding paradoxes and used by Goedel to prove his Incompleteness Theorem: self-reference.
    Suppose there is an algorithm H to solve the halting problem: given an encoding of a program P and an input string x, it returns 'true' if program P halts on input x, and 'false' otherwise. Use H as a subroutine to perform the conditional test in the following program H' (as formulated by Floyd and Beigel, p. 479):
      input x, a string which encodes a program
      if program x halts on input x then
        loop forever
      else
        halt
    Note that this program passes its input string to the subroutine H both as the encoding of a program (or, equivalently, a Turing machine) and as the input string for which we are to determine if program x halts. This means that H' halts on input x only if x doesn't halt on input x.
    What happens if we give H' its own description as its input string?
    Hoare and Allison re-state the conclusion this way (in "Incomputability", Computing Surveys 4, no. 3 [Sept. 1972]):
    Any language containing conditionals and recursive function definitions which is powerful enough to program its own interpreter cannot be used to program its own 'terminates' function.

    Other Noncomputable Functions

    There are many problems related to programs which are undecidable --- so many, in fact, that H.G. Rice proved the following theorem (using a complicated definition of "nontrivial"):
    Any nontrivial property of programs is undecidable.
    There are also undecidable problems in other subject areas. Note that proving that a problem is noncomputable does not mean that a solution cannot be computed in some cases (i.e., for some inputs), but that it is impossible to construct a solution that works for any input.
    Diophantine Equations
    It is impossible to write a program to find a solution for Hilbert's Tenth Problem (Diophantine Equations), that is, polynomial equations over the integers such as
      xy - 3x2z + 62xyz - 14 = 0

    References

    . A biography of David Hilbert including a list of the 23 problems he posed in his lecture at the International Congress of Mathematicians in Paris in 1900.
    . The Mathematical Problems of David Hilbert

    . Links to resources on Turing machines

    Saturday, November 30, 2013

    A brief overview of Complexity Theory

    Complexity Theory is concerned with the study of the intrinsic complexity of computational tasks. Its ``final'' goals include the determination of the complexity of any well-defined task. Additional ``final'' goals include obtaining an understanding of the relations between various computational phenomena (e.g., relating one fact regarding computational complexity to another). Indeed, we may say that the former type of goals is concerned with absolute answers regarding specific computational phenomena, whereas the latter type is concerned with questions regarding the relation between computational phenomena.
    Interestingly, the current success of Complexity Theory in coping with the latter type of goals has been more significant. In fact, the failure to resolve questions of the ``absolute'' type, led to the flourishing of methods for coping with questions of the ``relative'' type. Putting aside for a moment the frustration caused by the failure, we must admit that there is something fascinating in the success: in some sense, establishing relations between phenomena is more revealing than making statements about each phenomenon. Indeed, the first example that comes to mind is the theory of NP-completeness. Let us consider this theory, for a moment, from the perspective of these two types of goals.
    Complexity theory has failed to determine the intrinsic complexity of tasks such as finding a satisfying assignment to a given (satisfiable) propositional formula or finding a 3-coloring of a given (3-colorable) graph. But it has established that these two seemingly different computational tasks are in some sense the same (or, more precisely, are computationally equivalent). The author finds this success amazing and exciting, and hopes that the reader shares his feeling. The same feeling of wonder and excitement is generated by many of the other discoveries of Complexity theory. Indeed, the reader is invited to join a fast tour of some of the other questions and answers that make up the field of Complexity theory.
    We will indeed start with the ``P versus NP Question''. Our daily experience is that it is harder to solve a problem than it is to check the correctness of a solution (e.g., think of either a puzzle or a research problem). Is this experience merely a coincidence or does it represent a fundamental fact of life (or a property of the world)? Could you imagine a world in which solving any problem is not significantly harder than checking a solution to it? Would the term ``solving a problem'' not lose its meaning in such a hypothetical (and impossible in our opinion) world? The denial of the plausibility of such a hypothetical world (in which ``solving'' is not harder than ``checking'') is what ``P different from NP'' actually means, where P represents tasks that are efficiently solvable and NP represents tasks for which solutions can be efficiently checked.
    The mathematically (or theoretically) inclined reader may also consider the task of proving theorems versus the task of verifying the validity of proofs. Indeed, finding proofs is a special type of the aforementioned task of ``solving a problem'' (and verifying the validity of proofs is a corresponding case of checking correctness). Again, ``P different from NP'' means that there are theorems that are harder to prove than to be convinced of their correctness when presented with a proof. This means that the notion of a proof is meaningful (i.e., that proofs do help when trying to be convinced of the correctness of assertions). Here NP represents sets of assertions that can be efficiently verified with the help of adequate proofs, and P represents sets of assertions that can be efficiently verified from scratch (i.e., without proofs).
    In light of the foregoing discussion it is clear that the P-versus-NP Question is a fundamental scientific question of far-reaching consequences. The fact that this question seems beyond our current reach led to the development of the theory of NP-completeness. Loosely speaking, this theory identifies a set of computational problems that are as hard as NP. That is, the fate of the P-versus-NP Question lies with each of these problems: if any of these problems is easy to solve then so are all problems in NP. Thus, showing that a problem is NP-complete provides evidence to its intractability (assuming, of course, ``P different than NP''). Indeed, demonstrating NP-completeness of computational tasks is a central tool in indicating hardness of natural computational problems, and it has been used extensively both in computer science and in other disciplines. NP-completeness indicates not only the conjectured intractability of a problem but rather also its ``richness'' in the sense that the problem is rich enough to ``encode'' any other problem in NP. The use of the term ``encoding'' is justified by the exact meaning of NP-completeness, which in turn is based on establishing relations between different computational problems (without referring to their ``absolute'' complexity).
    The foregoing discussion of the P-versus-NP Question also hints to the importance of representation, a phenomenon that is central to complexity theory. In general, complexity theory is concerned with problems the solutions of which are implicit in the problem's statement. That is, the problem contains all necessary information, and one merely needs to process this information in order to supply the answer. Thus, complexity theory is concerned with manipulation of information, and its transformation from one representation (in which the information is given) to another representation (which is the one desired). Indeed, a solution to a computational problem is merely a different representation of the information given; that is, a representation in which the answer is explicit rather than implicit. For example, the answer to the question of whether or not a given Boolean formula is satisfiable is implicit in the formula itself (but the task is to make the answer explicit). Thus, complexity theory clarifies a central issue regarding representation; that is, the distinction between what is explicit and what is implicit in a representation. Furthermore, it even suggests a quantification of the level of non-explicitness.
    In general, complexity theory provides new viewpoints on various phenomena that were considered also by past thinkers. Examples include the aforementioned concepts of proofs and representation as well as concepts like randomness, knowledge, interaction, secrecy and learning. We next discuss some of these concepts and the perspective offered by complexity theory.
    The concept of randomness has puzzled thinkers for ages. Their perspective can be described as ontological: they asked ``what is randomness'' and wondered whether it exist at all (or is the world deterministic). The perspective of complexity theory is behavioristic: it is based on defining objects as equivalent if they cannot be told apart by any efficient procedure. That is, a coin toss is (defined to be) ``random'' (even if one believes that the universe is deterministic) if it is infeasible to predict the coin's outcome. Likewise, a string (or a distribution of strings) is ``random'' if it is infeasible to distinguish it from the uniform distribution (regardless of whether or not one can generate the latter). Interestingly, randomness (or rather pseudorandomness) defined this way is efficiently expandable; that is, under a reasonable complexity assumption (to be discussed next), short pseudorandom strings can be deterministically expanded into long pseudorandom strings. Indeed, it turns out that randomness is intimately related to intractability. Firstly, note that the very definition of pseudorandomness refers to intractability (i.e., the infeasibility of distinguishing a pseudorandomness object from a uniformly distributed object). Secondly, as hinted above, a complexity assumption that refers to the existence of functions that are easy to evaluate but hard to invert (called one-way functions) imply the existence of deterministic programs (called pseudorandom generators) that stretch short random seeds into long pseudorandom sequences. In fact, it turns out that the existence of pseudorandom generators is equivalent to the existence of one-way functions.
    Complexity theory offers its own perspective on the concept of knowledge (and distinguishes it from information). It views knowledge as the result of a hard computation. Thus, whatever can be efficiently done by anyone is not considered knowledge. In particular, the result of an easy computation applied to publicly available information is not considered knowledge. In contrast, the value of a hard to compute function applied to publicly available information is knowledge, and if somebody provides you with such a value then it has provided you with knowledge. This discussion is related to the notion of zero-knowledge interactions, which are interactions in which no knowledge is gained. Such interactions may still be useful, because they may assert the correctness of specific data that was provided beforehand.
    The foregoing paragraph has explicitly referred to interaction. It has pointed one possible motivation for interaction: gaining knowledge. It turns out that interaction may help in a variety of other contexts. For example, it may be easier to verify an assertion when allowed to interact with a prover rather than when reading a proof. Put differently, interaction with some teacher may be more beneficial than reading any book. We comment that the added power of such interactive proofs is rooted in their being randomized (i.e., the verification procedure is randomized), because if the verifier's questions can be determined beforehand then the prover may just provide the transcript of the interaction as a traditional written proof.
    Another concept related to knowledge is that of secrecy: knowledge is something that one party has while another party does not have (and cannot feasibly obtain by itself) - thus, in some sense knowledge is a secret. In general, complexity theory is related to Cryptography, where the latter is broadly defined as the study of systems that are easy to use but hard to abuse. Typically, such systems involve secrets, randomness and interaction as well as a complexity gap between the ease of proper usage and the infeasibility of causing the system to deviate from its prescribed behavior. Thus, much of Cryptography is based on complexity theoretic assumptions and its results are typically transformations of relatively simple computational primitives (e.g., one-way functions) into more complex cryptographic applications (e.g., a secure encryption scheme).
    We have already mentioned the context of learning when referring to learning from a teacher versus learning from a book. Recall that complexity theory provides evidence to the advantage of the former. This is in the context of gaining knowledge about publicly available information. In contrast, computational learning theory is concerned with learning objects that are only partially available to the learner (i.e., learning a function based on its value at a few random locations or even at locations chosen by the learner). Complexity theory sheds light on the intrinsic limitations of learning (in this sense).
    Complexity theory deals with a variety of computational tasks. We have already mentioned two fundamental types of tasks: searching for solutions (or ``finding solutions'') and making decisions (e.g., regarding the validity of assertion). We have also hinted that in some cases these two types of tasks can be related. Now we consider two additional types of tasks: counting the number of solutions and generating random solutions. Clearly, both the latter tasks are at least as hard as finding arbitrary solutions to the corresponding problem, but it turns out that for some natural problems they are not significantly harder. Specifically, under some natural conditions on the problem, approximately counting the number of solutions and generating an approximately random solution is not significantly harder than finding an arbitrary solution.
    Having mentioned the notion of approximation, we note that the study of the complexity of finding approximate solutions has also received a lot of attention. One type of approximation problems refers to an objective function defined on the set of potential solutions. Rather than finding a solution that attains the optimal value, the approximation task consists of finding a solution that attains an ``almost optimal'' value, where the notion of ``almost optimal'' may be understood in different ways giving rise to different levels of approximation. Interestingly, in many cases even a very relaxed level of approximation is as difficult to achieve as the original (exact) search problem (i.e., finding an approximate solution is as hard as finding an optimal solution). Surprisingly, these hardness of approximation results are related to the study of probabilistically checkable proofs, which are proofs that allow for ultra-fast probabilistic verification. Amazingly, every proof can be efficiently transformed into one that allows for probabilistic verification based on probing a constant number of bits (in the alleged proof). Turning back to approximation problems, we note that in other cases a reasonable level of approximation is easier to achieve than solving the original (exact) search problem.
    Approximation is a natural relaxation of various computational problems. Another natural relaxation is the study of average-case complexity, where the ``average'' is taken over some ``simple'' distributions (representing a model of the problem's instances that may occur in practice). We stress that, although it was not stated explicitly, the entire discussion so far has referred to ``worst-case'' analysis of algorithms. We mention that worst-case complexity is a more robust notion than average-case complexity. For starters, one avoids the controversial question of what are the instances that are ``important in practice'' and correspondingly the selection of the class of distributions for which average-case analysis is to be conducted. Nevertheless, a relatively robust theory of average-case complexity has been suggested, albeit it is far less developed than the theory of worst-case complexity.
    In view of the central role of randomness in complexity theory (as evident, say, in the study of pseudorandomness, probabilistic proof systems, and cryptography), one may wonder as to whether the randomness needed for the various applications can be obtained in real-life. One specific question, which received a lot of attention, is the possibility of ``purifying'' randomness (or ``extracting good randomness from bad sources''). That is, can we use ``defected'' sources of randomness in order to implement almost perfect sources of randomness. The answer depends, of course, on the model of such defected sources. This study turned out to be related to complexity theory, where the most tight connection is between some type of randomness extractors and some type of pseudorandom generators.
    So far we have focused on the time complexity of computational tasks, while relying on the natural association of efficiency with time. However, time is not the only resource one should care about. Another important resource is space: the amount of (temporary) memory consumed by the computation. The study of space complexity has uncovered several fascinating phenomena, which seem to indicate a fundamental difference between space complexity and time complexity. For example, in the context of space complexity, verifying proofs of validity of assertions (of any specific type) has the same complexity as verifying proofs of invalidity for the same type of assertions.
    In case the reader feels dizzy, it is no wonder. We took an ultra-fast air-tour of some mountain tops, and dizziness is to be expected. Needless to say, the rest of the course will be in a totally different style. We will climb some of these mountains by foot, step by step, and will stop to look around and reflect.
    Absolute Results (a.k.a. Lower-Bounds). As stated up-front, absolute results are not known for many of the ``big questions'' of complexity theory (most notably the P-versus-NP Question). However, several highly non-trivial absolute results have been proved. For example, it was shown that using negation can speed-up the computation of monotone functions (which do not require negation for their mere computation). In addition, many promising techniques were introduced and employed with the aim of providing a low-level analysis of the progress of computation. However, the focus of this course is elsewhere.