Friday, March 24, 2017

Study Algorithms by Playing Pick 2: Find the Sum of Any 2 Integers from a List that are Equal to a Specific Value

The "Algorithms and Data Structure Problems" Pittsburgh group decided to study algorithms using the book Introduction to Algorithms by Cormen, Leiserson, Rivest, Stein (CLRS) [Edition 3].

Even though CLRS is a standard text book at many universities, it may not be appropriate given our audience. Below is my recommendation for an approach that may be appropriate for the group.

Problem Statement

We begin by explicitly stating what appears to be a simple problem: Find two integers from a list of integers that sum to a specific value. For example, given [1, 2, 3, 4, 5, 6], which two integers sum to 11? Mathematically, this can be stated as follows
  1. Given a set N = {x1, x2, ..., xn } and a constant c, find xi and xj such that
    1. xi + xj = c
    2. i != j
Additional requirements are as follows
  1. Allow for duplication of integers. In other words, [1, 2, 2, 4, 5, 6, 7] is valid.
  2. Only have to find 1 permutation of 2 integers that sum to c. Don't have to find all the permutations.
  3. Can't assume that the integers are sorted.
  4. System does not have any memory of what has been previously processed. For example, it cannot remember whether the last numbers processed were positive or negative or zero.
The implications of the additional requirements will be addressed throughout this blog post. Also, to keep things simple, we are going to state that no concurrent processing is allowed.

Note the writing style used in the problem statement. It's formal enough that people can learn something concrete but approachable enough that people won't skip it for fear of feeling dumb. That is why, initially, no Big O notation is used.

Obviously, the goal is not to teach a trivial addition algorithm. The goal is to help people start thinking about how to find and evaluate algorithms. The addition algorithm is a nice example for learning how to approach algorithms in general. In the particular case, the progression is as follows
  1. Brute Force: Start with nested for loops.
  2. Look for Patterns: Notice that "2 + 4" as well as "4 + 2" equal 6. Therefore can reduce the computations by half.
  3. Sort and Perform Binary Search
  4. Loosen Requirements: Re-use of integers allows the use of hashing.
Concepts are demonstrated via code snippets. The code snippets are executed using Python 3.6.1.

Simplest Case

The simplest case assumes that the integers are sorted and the quantity of integers is small enough that brute force is acceptable. Code Snippet 1 satisfies the simplest case. Please note that there is nothing wrong with brute force as long as it satisfies the requirements. Sometimes people forget this.

If the integer list is unsorted, a simple solution is to just sort it. The consequences of sorting are addressed later in this blog. Perhaps sorted data is unnecessary? This too will be addressed later.

Code Snippet 1
def sum_in(int_list, desired_sum):
    for i, int_a in enumerate(int_list):
        for j, int_b in enumerate(int_list):
            if i != j:
                sum_of_ints = int_a + int_b
                print("int_a = {}, int_b = {}, sum_of_ints = {}".format(int_a, int_b, sum_of_ints))
                if desired_sum == sum_of_ints:
                    return True
# test 1: Desired sum exists
print("-- Start test 1")
SOME_INTEGER_LIST = [1, 2, 3, 4, 5, 6]
DESIRED_SUM_OF_INTS = 11
if sum_in(SOME_INTEGER_LIST, DESIRED_SUM_OF_INTS) is True:
    print("DESIRED_SUM_OF_INTS = {} was found".format(DESIRED_SUM_OF_INTS))
else:
    print("DESIRED_SUM_OF_INTS = {} was not found".format(DESIRED_SUM_OF_INTS))
# test 2: Desired sum does not exist
print("-- Start test 2")
SOME_INTEGER_LIST = [2, 4, 8]
DESIRED_SUM_OF_INTS = 7
if sum_in(SOME_INTEGER_LIST, DESIRED_SUM_OF_INTS) is True:
    print("DESIRED_SUM_OF_INTS = {} was found".format(DESIRED_SUM_OF_INTS))
else:
    print("DESIRED_SUM_OF_INTS = {} was not found".format(DESIRED_SUM_OF_INTS))
Output of Code Snippet
-- Start test 1

int_a = 1, int_b = 2, sum_of_ints = 3
int_a = 1, int_b = 3, sum_of_ints = 4
int_a = 1, int_b = 4, sum_of_ints = 5
int_a = 1, int_b = 5, sum_of_ints = 6
int_a = 1, int_b = 6, sum_of_ints = 7

int_a = 2, int_b = 1, sum_of_ints = 3
int_a = 2, int_b = 3, sum_of_ints = 5
int_a = 2, int_b = 4, sum_of_ints = 6
int_a = 2, int_b = 5, sum_of_ints = 7
int_a = 2, int_b = 6, sum_of_ints = 8

...

int_a = 5, int_b = 1, sum_of_ints = 6
int_a = 5, int_b = 2, sum_of_ints = 7
int_a = 5, int_b = 3, sum_of_ints = 8
int_a = 5, int_b = 4, sum_of_ints = 9
int_a = 5, int_b = 6, sum_of_ints = 11

DESIRED_SUM_OF_INTS = 11 was found

-- Start test 2

int_a = 2, int_b = 4, sum_of_ints = 6
int_a = 2, int_b = 8, sum_of_ints = 10

int_a = 4, int_b = 2, sum_of_ints = 6
int_a = 4, int_b = 8, sum_of_ints = 12

int_a = 8, int_b = 2, sum_of_ints = 10
int_a = 8, int_b = 4, sum_of_ints = 12

DESIRED_SUM_OF_INTS = 7 was not found
You will notice that "2 + 4" as well as "4 + 2" equal 6.
int_a = 2, int_b = 4, sum_of_ints = 6

...

int_a = 4, int_b = 2, sum_of_ints = 6

...
The above corresponds to the commutative property of addition. If we create a matrix of the possible permutations of additions for [1, 2, 3, 4, 5, 6], a symmetric matrix is created.


Notice that the diagonal of the matrix has N/A. This corresponds to the fact that i != j. Another way of stating this is that we cannot re-use integers.

Before looking at the Big O notation, let's just count all the possible permutations of addition. Here we use the term permutation as strictly defined in mathematics. For a discussion of the difference between permutations and combinations, refer to Combinations and Permutations at MathIsFun.Com. The example above corresponds to permutations without repetition, which means that we should use the equation below
Here n is the number of integers in the list, and r = 2 because we are taking 2 numbers at a time. Below is a table that evaluates the equation above. It shows how quickly the number of required additions increases as the number of integers in the list increases.
Number of Integers  Number of
in the List         Additions
------------------  ---------
    2                     2
    3                     6               
    4   `                12
    5                    20
    6                    30
   10                    90
  100                 9,900
  150                22,350
  500               249,500
1,000               999,000
Now, let's look at the Big O notation. The nested for loops result in O(n2). Clearly, this approach is not scalable.

Reduce Computations by Taking Advantage of Symmetry Arising from Commutative Property of Addition

Code Snippet 2: Reduce computations by taking advantage of symmetry
def sum_in(int_list, desired_sum):
    for i in range(1, len(int_list)):
        int_a = int_list[i]
        desired_int = desired_sum - int_a
        for j in range(0, i):    # Cutting computations in half
            int_b = int_list[j]
            print("i = {}, j = {}".format(i, j))
            print("int_a = {}, int_b = {}, Int Sum = {}".format(int_a, int_b, int_a+int_b))
            if (int_b == desired_int):
                return True
    return False
We could try to short circuit the computations being performed in this code snippet. For example, if the desired sum is positive, both integers are positive, and the integer sum is greater than the desired sum, there is no reason to continue to perform computations. Below is a counter example. The key is that the integers can be negative, zero or positive and the system does not have any memory of what has been processed previously. Notice that here we are using one of the earlier-mentioned additional requirements.

Counter Example
def sum_in( [-6, -2, 0, 5, 6, 8], 2 )

i = 1, j = 0
int_a = -2, int_b = -6, Int Sum = -8

i = 2, j = 0
int_a = 0, int_b = -6, Int Sum = -6

i = 2, j = 1
int_a = 0, int_b = -2, Int Sum = -2

i = 3, j = 0
int_a = 5, int_b = -6, Int Sum = -1

i = 3, j = 1
int_a = 5, int_b = -2, Int Sum = 3

i = 3, j = 2
int_a = 5, int_b = 0, Int Sum = 5

i = 4, j = 0
int_a = 6, int_b = -6, Int Sum = 0

i = 4, j = 1
int_a = 6, int_b = -2, Int Sum = 4

i = 4, j = 2
int_a = 6, int_b = 0, Int Sum = 6

i = 4, j = 3
int_a = 6, int_b = 5, Int Sum = 11

For the above, notice the following

   I) Both integers are positive

   II) The integer sum of 11 is greater than the desired sum of 2

i = 5, j = 0
int_a = 8, int_b = -6, Int Sum = 2

For the above, notice that the integer sum of 2 is equal to the desired sum of 2
Even though the number of computations is cut in half by iterating over half the matrix, the approach is still not scalable. This is just a repetition that O(cn2) == O(n2) for any constant c.

Alternative Approach: Binary Search for the Desired Integer

Because the above approaches are not scalable, we need to step back and try another approach. In this other approach, we will use the a priori knowledge that the integers are sorted. Since we know what the desired integer is, we can do a binary search for it over the ordered integers. For background information about binary search, refer to the book "Problem Solving with Algorithms and Data Structures using Python" by Brad Miller and David Ranum.

Luckily, Python has the bisect module which can be used to search sorted lists. Code Snippet 3 uses the bisect_left method of the bisect_module.

Code Snippet 3 (Source: Docs.Python.Org - bisect module - Searching Sorted Lists)
def index(a, x):
    'Locate the leftmost value exactly equal to x'
    i = bisect_left(a, x)
    if i != len(a) and a[i] == x:
       return i
    raise ValueError
Since we are dealing with open source, we can go look at the code for bisect_left and see that it is your standard binary search.

Code Snippet 4: bisect_left
def bisect_left(a, x, lo=0, hi=None):
    """Return the index where to insert item x in list a, assuming a is sorted.
    The return value i is such that all e in a[:i] have e < x, and all e in
    a[i:] have e >= x.  So if x already appears in the list, a.insert(x) will
    insert just before the leftmost x already there.
    Optional args lo (default 0) and hi (default len(a)) bound the
    slice of a to be searched.
    """
    if lo < 0:
        raise ValueError('lo must be non-negative')
    if hi is None:
        hi = len(a)
    while lo < hi:
        mid = (lo+hi)//2
        if a[mid] < x: lo = mid+1
        else: hi = mid
    return lo
Putting it all together, results in the code snippet below.

Code Snippet 5
from bisect import bisect_left
def index(some_list, some_value):
    'Locate the leftmost value exactly equal to some_value'
    i = bisect_left(some_list, some_value)
    if i != len(some_list) and some_list[i] == some_value:
        return i
    raise ValueError
def sum_in(int_list, desired_sum):
    int_list.sort()
    for i, int_a in enumerate(int_list):
        desired_int = desired_sum - int_a
        try:
            j = index(int_list, desired_int)
            if (i == j):
                continue
            else:
                return True
        except ValueError:
            continue
    return False
By the way, some consider the use of exceptions for flow control a bad practice. For a typical article on this topic, refer to "Using Exception Handling for Control Flow (in Python)" by Scott Lobdell.

Now, let's talk about the number of computations involved in Code Snippet 5. It is commonly known that a binary search is O(log n). Because we have to do the binary search once for each integer, we have to multiply by n. This results in a performance of O(n [log n] ). For the rationale of why the base of the log does not matter, refer to Wikipedia: Sorting algorithm.

Significant Performance Improvement: Computations Reduced from O(n2) to O(n [log n])

Notice that in going from Code Snippet 2 to Code Snippet 5, performance went from O(n2) to O(n [log n]). This is a significant performance improvement.

To visually confirm this, let's examine the graph below. Notice how the purple line {O(n [log n]} is relatively linearly but the dark blue line {O(n2} just shoots straight up.

 
Why Not Just Use Python Built-in Functionality?

Those familiar with Python will be tempted to simplify the above code by using the index() method of list objects, which results in the code snippet below.

Code Snippet 6: Use list.index
def sum_in(int_list, desired_sum):
    for i, int_a in enumerate(int_list):
        desired_int = desired_sum - int_a
        try:
            j = int_list.index(desired_int)
            if (i == j):
                continue
            else:
                return True
        except ValueError:
            continue
    return False
The use of list.index is equivalent to invoking the in operator. Note that in calls the __contains__ method. This is an O(n) operation (StackOverFlow.Com, Complexiity at Wiki.Python.Org). Since we have to execute O(n) for each iteration of the for statement, the overall performance is now back to O(n2). Yucky.

One would be tempted to simplify the above code snippet by directly using the in operator. Unfortunately, we cannot do this, because we not only have to know that the integer is present, but that its index differs from the current integer being processed (i != j).

Since the Python set collection has a O(1) performance, why not use it? Unfortunately, in Python the set collection quietly ignores repeated integers. Recall that in the problem statement, [1, 2, 2, 4, 5, 6, 7] was allowable. Later in the blog, we will address this sublety.

Complicate Problem by Having Integer List Not Sorted

The complication of an unsorted integer list is easily addressed simply by just sorting. It is commonly known that no comparison sorts can perform better than O(n [log n]) in the average or worst case. For background information on this, refer to Wikipedia: Sorting algorithm. Consequently, the computations required to determine if two integers in an unsorted integer list sum to a particular value is
  1. n [log n] cost for sorting the unordered integer list
  2. n [log n] cost for a binary search for the other integer
The total computation complexity is O(2 * n [log n] ) == O(n [log n] ).

Remove Restriction: Integers can Now be Re-Used (i = j)

We can now finally use the in.

Code Snippet 7: Use in operator
def sum_in(int_list, desired_sum):
    for i, int_a in enumerate(int_list):
        desired_int = desired_sum - int_a
        if desired_int in int_list:
            return True
        else:
            continue
    return False
Remember, we are now back to the O(n2) performance. Yucky.

Re-Use of Integers Permits the Application of Set

Because the re-use of integers is allowed, we can use Python's built in set and get a O(1) look-up performance. However, don't forget that you initially have to create the set which involves a cost of O(n). Luckily this is just a one-time setup fee. Please note that creating a set involves hashing, which, depending on the nature of the data, can be problematic.

Also, by using set, we are implicitly removing the additional requirement that the system has no memory because we are storing hash values. This is an example of why people must be aware of all ramifications associated with data structures and their associated algorithms.

Code Snippet 8: Use set
def sum_in(int_list, desired_sum):
    int_set = set(int_list)
    for i, int_a in enumerate(int_list):
        desired_int = desired_sum - int_a
        if desired_int in int_set:
            return True
        else:
            continue
    return False
The above code snippet is the verbose version of the code snippet found at the StackOverFlow question "How to write an algorithm to check if the sum of any two numbers in an array/list matches a given number?"

Code Snippet 9: From StackOverFlow
def sum_in(int_list, desired_sum):
    set_of_input = set(int_list) # O(n)
    return any( ( desired_sum - n) in set_of_input for n in set_of_input ) # O(n)

At this point, it is important to remember that we removed a restriction and integers can now be re-used (i=j). This means that when "sum_in([-2, 1, 5, 6, 8], 2)" returns True, it is a correct answer because "1 + 1 == 2." This may or may not be acceptable depending on the situation.

Also, at this point, we need to address the distinction between "repeated integers not allowed" and "integers can be re-used." Repeated integers are simply not allowed because Python's set quietly ignores them. "Integers can be re-used" refers to the fact that "sum_in([-2, 1, 5, 6, 8], 2)" returning True is a correct answer because "1 + 1 == 2."

Code snippets 8 and 9 require that the entire set (hash values) is computed ahead of time. Why not create the hash value as we process the data? This way, we might find the desired integers before we have created all the hash values. The other advantage of creating the hash values as we process the data is that it allows for repeated integers ([1, 2, 2, 4, 5, 6, 7]) but not the re-use of integers.
.
Code Snippet 10: Generate hash values as process data
def sum_in(int_list, desired_sum):
    indexMap = {}
    for i in range( len( int_list ) ):
        if ( desired_sum - int_list[i] ) in indexMap:
            return True
        else:
            indexMap[ int_list[i] ] = i
    return False
Note that code snippet 10 returns False for "sum_in([-2, 1, 5, 6, 8], 2)". In other words, integers cannot be re-used (i != j).

Above material similar to exercise 2.3-7 of CLRS

Some people will recognize that the material above is similar to exercise 2.3-7 of CLRS. Below is the actual exercise. We have deviated from it for pedagogical reasons.

Alternative Solution

Zhao HG on GitBook solves the problem in O(n log n) time using the code snippet below. Notice its simple elegance of just walking two pointers. However, it is complex because have 3 tests, and for each test, there is a different action.

Code Snippet 11
def two_sum(a, x):
    l, r = 0, len(a)-1
    while l < r:
        if a[l] + a[r] == x:
            return True
        elif a[l] + a[r] < x:
            l += 1
        else:
            r -= 1
    return False
Notice how we have examined the algorithm from 3 different dimensions.
  1. The first dimension was time. How long does it take to perform the computations? 
  2. The second dimension was required storage. Please note that storage is used in the generic sense. It does not just refer to memory. For further details, please refer to the Wikipedia article "Memory Hierarchy." 
  3. The third dimension is algorithm/software complexity. You can use the radon package to quantify the complexity. Please note that complexity is a critical consideration when deploying production code. For an example, refer to "Why Netflix Never Implemented The Algorithm That Won The Netflix $1 Million Challenge by Mike Masnick".
Unit Tests

Some people will have an opinion that such trivial code snippets do not require unit tests. I strongly disagree. Unit tests are a good way to make sure that you haven't done anything silly. For the purpose of this blog, I have created rather extensive unit tests to prevent a lot of back and forth when people propose adding short circuit logic to reduce the number of computations performed. The conversations will probably start and end with, "Did you run the proposed short circuit logic against the unit tests?"

As for the unit tests themselves, we begin by realizing that each integer can be negative, zero or positive and that the desired sum can also be negative, zero or positive. This leads to 27 possible test cases. For an explicit list of all 27 tests, click here. Luckly, most of them are not applicable because they can't happen mathematically or they can't happen because the data is sorted.

In turn, each test case has sub-tests. One set of tests finds the desired sum while another set of tests does not. Within, those sub-tests, position matters: left edge, somewhere in the middle and right edge. To see the tests themselves, click here. When all is said and done, we end up with 104 tests. This may seem excessive, but it beats the alternative of having countless conversations about why the computations for the nested for loops was not short circuited.

Also, if you want to create tests to estimate run time, one could use the timeit module. Another option is to use the pytest-benchmark.

Subtlety: Repeated Integers Versus Re-Use of Integers

If repeated integers are allowed, you could have a list of integers like [3, 5, 1, 5, 12]. So, if repeated integers are allowed and wanted the sum to be 10, you could use the two 5's to come up with 10.

However, if repeated integers are not allowed, the previous list would become [3, 5, 1, 12]. In this case, if wanted the sum to be 10, there would be no two integers that sum to 10.

Now comes the caveat. If have [3, 5, 1, 12] and integers can be re-used, could re-use 5 a second time to have two integers that sum to 10.

Summary

This blog demonstrates two things.
  1. First it is critical to know the runtimes of built-in algorithms/tools of a language. 
  2. Secondly, the material demonstrates a common process. You start out with what appears a simple problem. You try a brute force approach but quickly realize that it won't scale. You then look for patterns like symmetry to reduce the number of required computations. If you are lucky, this will be good enough. If not, an alternative approach will have to be tried.
This blog also demonstrates the key traits and skills people need to choose an algorithm. You need some idea of the consequences of doing things like adding a loop.

Next you have to be creative. What if we subtract the integer that we're currently examining from the desired sum and then use the resulting integer to do a search? Understanding the Big O notation is a good skill and helps you understand consequences, but you also need to understand the principles behind it.

Lastly, know your data. Questions like "Is it sorted or unsorted?" or "Is it symmetric?" will help us in the algorithm selection process.

Presentations

To download the presentation for the PGH Data Science Lightning Talks on 7/5/2017, click here.

To download the presentation for the PGHPython: Study Algorithms by Playing Pick 2 on 7/26/2017, click here. For a video recording of the presentation, click here (YouTube).

21 comments:

  1. I would like to thank everyone that provided comments on my preliminary version of the blog post. The preliminary blog post was hurriedly done to demonstrate a possible approach that could be used by the "Algorithms and data structure problems" Pittsburgh group.

    Today, I was able to go through the comments and clean up the blog post.

    The Python community will be annoyed because I am using Python 2 and not 3. Their annoyance is appropriate. Unfortunately, I only have Windows machines and can't afford to screw them up at this point in time.

    Professional software developers will be disappointed by the coding style. Their annoyance is appropriate. Unfortunately, I am creating this blog post in my spare time. :-)

    The mathematically inclined will consider it insulting that more than 1 sentence is spent on the fact that addition is commutative. Believe me, my goal is not to insult anyone.

    However, none of the above 3 groups is the intended audience for this blog. The intended audience are people that haven't done math in a long time and are trying to learn algorithms.

    ReplyDelete
  2. This comment has been removed by the author.

    ReplyDelete
  3. Good job! 2Sum is definitely an interesting algo problem to play with. You started with the brute force solution and optimized the problem-solving gradually.
    I notice that you have considered only time complexity so far. How about space complexity? For example, if we are Okay with some memory cost, we can do O(n) in time even with an unsorted list. The idea to use a hashtable to store list elements as we traverse the list. At the same time we check if (target - current_element) in the hashtable. A hashtable is a data structure that facilitates O(1) look-up (usually). As a result, the solution has O(n) time and space complexities. The code can be like the following
    def twoSum(nums, target):
    """
    :nums: input list, duplicates allowed
    :target: the sum value
    :return: the indices of the two elements, return [-1, -1] if not found
    """
    indexMap = {}
    for i in range(len(nums)):
    if target-nums[i] in indexMap:
    return [indexMap[target-nums[i]], i]
    else:
    indexMap[nums[i]] = i
    return [-1, -1]

    ReplyDelete
  4. I'm a college student, so I think I'm in the target demographic here. This was quite fun!

    I really like this blog-style writeup - it's approachable enough that I won't skip out on reading it for fear of feeling dumb, and formal enough that nothing's ambiguous and I'll learn something concrete (unlike, say, a pop-math YouTube video).

    I also like the incremental improvement in each snippet - helps me get familiar with each concept individually, so that by the time I get to Zhoa HG's two_sum function I can understand it fully with little effort.

    ReplyDelete
  5. More interesting approaches are discussed and compared.
    Good to know the bisect module. I did not know it before.

    ReplyDelete
  6. BTW, occasionally, you might want to do experiments to test the time complexities of the various approaches. It is fun. I use a module called "time" to record the time consumptions.

    ReplyDelete
  7. Interesting post - I enjoyed following the logic through. I'm not enough of a programmer to know generally what will take more processing power, so elegance of a solution is based on the elegance of the idea. Do you know of a tool for measuring the efficiency of a process? Maybe an offshoot of TDD? I've used exercism.io in the past, and their ideal solutions tend to be those with the least characters of code. Is there ak correlation?

    In any case, the mental exercise is quite enjoyable, and a learned a few things. Thank you

    ReplyDelete
  8. Great writeup and clear communication style. Double points for focusing on unit tests! I can't stress how important tests are in the real world.

    Large complicated systems arise from smaller less complicated systems. As the overall complexity grows, it's imperative that the smaller systems work in predictable ways. Some might say that an example like this doesn't merit unit tests, especially in a tight deadline environment like a startup. I would argue that that environment is precisely where you can't afford to NOT have tests!

    Thanks for sharing!

    ReplyDelete
  9. You have a way of explaining things that are detailed enough to be comprehensive but lay enough to be digestible by a person that definitely doesn't do this often.

    On an interesting and fun note, you can also look at solving 2Sum in Python 2 for code golf! Very different set of skills for that game.

    ReplyDelete
    Replies
    1. Thanks for the kind words.

      The suggestion of "solving 2Sum in Python 2 for code golf" sounds interesting. Unfortunately, I probably won't get to it.

      Delete
  10. In code snippet 10, making a dict is unnecessary because you don't really care about the indices of the two numbers, just that the complementary number exists. So you could take advantage of the fact that calling "in" is O(1) on sets as well as dicts. So instead of creating a dict with the number as the key and the index as the value (which is never used), just create a set and add each number to it.

    Zhao HG's algorithm is clever! But it does not fully solve the original problem, which does not specify that S is sorted. His entry actually begins with, "Sort the elements then..." As you said, comparison sorting algorithms are O(n log n) at best, so that's what his solution would be as well (which would satisfy the given problem).

    Note, though, that you *could* keep it to O(n) by using a non-comparison sorting algorithm like counting sort or radix sort if the list is reasonably well-behaved (e.g. not a bunch of huge numbers), though blindly applying one of those algorithms could be bad for your computer's well-being. Plus, it technically doesn't answer the actual problem, which asks for an O(n log n) algorithm.

    ReplyDelete
    Replies
    1. "So instead of creating a dict with the number as the key and the index as the value (which is never used), just create a set and add each number to it."

      Thanks for the suggestion

      Delete
    2. "technically doesn't answer the actual problem, which asks for an O(n log n) algorithm"

      Several others have made a similar comment. I tried to address this by stating "We have deviated from it for pedagogical reasons." in the blog.

      Also, if you can do better than requested, why not?

      Thanks again for the very detailed comments.

      Delete
  11. I'd recommend adding this code to a Github repo and welcoming people to submit their own solutions. I always find it interesting to see how other approach non-trivial problems.

    ReplyDelete
    Replies
    1. Thanks for the suggestion. Unfortunately, it will be a while before I get to it.

      Delete
  12. Great post, Robert! Extremely thorough!

    ReplyDelete
  13. This comment has been removed by the author.

    ReplyDelete
  14. There is a much more efficient and simple O(n) solution than anything you discussed, that handles all cases. In C#:

    public bool DoesPairSumToN(List candidates, int n)
    {
      var complements = new HashSet();
      foreach (var candidate in candidates)
      {
        if (complements.Contains(candidate))
          return true;
        complements.Add(n - candidate);
      }
      return false;
    }

    ... if you need to return the exact values, return candidate and (n-candidate)

    ReplyDelete
  15. <int> generic arguments were left out of the List and HashSet above because I forgot to escape them.

    ReplyDelete
  16. Eric at PGHPython: Study Algorithms by Playing Pick 2 by The Bit Plumber
    (https://www.meetup.com/Pittsburgh-Code-Supply/events/241717498/)
    had the following comment

    Thanks for the nice talk (do you remember talking about SymPy a few years ago?).

    To combine the points about when the O(n^2) vs. O(n*log(n)) algorithms cross over
    and benchmarking, which approach is best of course depends.
    See http://bigocheatsheet.com/ for algorithmic complexities of many common
    sorting algorithms. Most sorts have different behavior for the best-case
    (almost completely sorted) and worst-case (completely unsorted). This is
    where benchmarking on real data would be important.

    Below is my response

    >do you remember talking about SymPy a few years ago?
    Yes

    >O(n^2) vs. O(n*log(n)) algorithms cross over and benchmarking
    >which approach is best of course depends
    Notice the use of the word "depends"

    That is one of the key points. It all depends on your
    client and your data.

    >http://bigocheatsheet.com/ for algorithmic complexities
    >of many common sorting algorithms

    Below is a code snippet. Before using the code snippet please read
    https://docs.python.org/3/library/math.html#power-and-logarithmic-functions

    import math

    def compare_n_squared_and_n_log_n(upper_bound):
    for i in range(1,upper_bound):
    n_squared = i**2
    n_log_n = i*math.log2(i)
    print("i = ", i, "- n_squared = ", n_squared, "- n_log_n = ", n_log_n)

    Let's begin from n = 1

    n = 1 - n_squared = 1 - n_log_n = 0.0
    n = 2 - n_squared = 4 - n_log_n = 2.0
    n = 3 - n_squared = 9 - n_log_n = 4.8
    n = 4 - n_squared = 16 - n_log_n = 8.0
    n = 5 - n_squared = 25 - n_log_n = 11.6

    You used the word "cross over". From the very beginning n squared is always worse.

    So, how do you define "cross over"

    Next, let's look at some order of magnitude stuff

    One order of magnitude: When n = 59, n**2 = 3,481 and n_log_n = 347.1

    Two orders of magnitude: When n = 997, n**2 = 994,009 and n_log_n = 9,931.6

    Three orders of magnitude: When n = 13,747, n**2 = 188,980,009 and n_log_n = 188,977.7

    Is cross over when things are 3 magnitudes worse?

    ReplyDelete
  17. This was very informative and well written. Thank you for taking the time to create this.

    Expanding a bit on the problem:
    Say that we now have 2 separate lists of integers.

    For example, we want to Christmas shop for a family member who has two main hobbies. We want to know if we can buy exactly 1 gift related to each hobby so that the gifts will sum to a given price we have budgeted. We have the prices of potential gifts stored in two lists (one for each hobby).

    Conditions:
    - Dealing with integers rather than floats (for simplicity)
    - Neither list is sorted
    - Can repeat integers if they happen to occur in both lists
    - Memory is available for hashing

    One possible solution (based on a modified version of code snippet 8):

    def find_gifts(list_a, list_b, budget):
    list_a = set(list_a)
    list_b = set(list_b)
    for i, item_a in enumerate(list_a):
    target = budget – item_a
    if target in list_b:
    return True
    else:
    continue
    return False

    How do we calculate the big O for the above? I think it involves:
    - creating 2 sets at O(a+b)
    - iterating over one of them O(a)
    - then using python lookup in the other set O(1)

    Does this become O(a^2 + b)?

    Also, is there another solution that would have a better big O performance?

    ReplyDelete