0

I'm practicing some problems and started trying different solutions for the two-sum problem.

My first solution is the following:

nums = [2,7,11,15]
target = 9

def twoSum(nums: List[int], target: int) -> List[int]:
    """Given an array and a target, return the indexes of the elements adding to the target"""
    nums_len = len(nums)
    result = []
    for first in range(nums_len):
        for second in range(first + 1, nums_len):
            if len(result) == 2: break
            if nums[first] + nums[second] == target:
                result.extend([first, second])
        else:
            continue
        break
    return result
    
twoSum(nums, target) # returns [0,1] (sum of 2 and 7 = 9)

This solution got a runtime and memory usage of 8385 ms and 15.1 MB, respectively.

When trying to improve the code and readability, I decided to use a generator instead of having those else, continue, break statements at the end of the loops. Here's the code:

def double_loop(arr):
    for i in range(len(arr)):
        for j in range(i + 1, len(arr)):
            yield [i, j]
        
def twoSum(nums: List[int], target: int) -> List[int]:
    for result in double_loop(nums):
        if nums[result[0]] + nums[result[1]] == target:
            break
    return result

nums = [2,7,11,15]
target = 9
twoSum(nums, target) # returns the same as above

The issue is when I submit to Leetcode and it runs different inputs. Especially one where nums = [1, 2, 3, ..., 9999,10000] and target = 19999. I get a Time Limit Exceeded error.

Why is the second solution apparently wasting more resources and timing out? Can someone explain what's wrong with that approach?

thenewjames
  • 966
  • 2
  • 14
  • 25
  • Can you elaborate on overhead? Does it load all elements to memory and yields one by one at every call to `double_loop`? – thenewjames Jul 07 '22 at 04:02

3 Answers3

1

Your efficiency question about generators has been answered. But if your goal is to improve readability, look for what you can eliminate from your solution. Take your original solution:

def twoSum(nums: List[int], target: int) -> List[int]:
    """Given an array and a target, return the indexes of the elements adding to the target"""
    nums_len = len(nums)
    result = []
    for first in range(nums_len):
        for second in range(first + 1, nums_len):
            if len(result) == 2: break
            if nums[first] + nums[second] == target:
                result.extend([first, second])
        else:
            continue
        break
    return result

If you look, including result just makes your code more complex for no return. The same thing can be streamlined to:

def twoSum(nums: List[int], target: int) -> List[int]:
    """Given an array and a target, return the indexes of the elements adding to the target"""
    nums_len = len(nums)
    for first in range(nums_len):
        for second in range(first + 1, nums_len):
            if nums[first] + nums[second] == target:
                return [first, second]

This did change behavior slightly. If there is no solution you now return None instead of []. That is arguably more correct. More importantly, the code directly describes what it is doing. This will make life easier for anyone else who is reading it.

And if you want to improve performance, the biggest win is from learning more about data structures rather than refactoring to use more features. The following should scale much better.

def twoSum(nums: List[int], target: int) -> List[int]:
    """Given an array and a target, return the indexes of the elements adding to the target"""
    nums_len = len(nums)
    seen_at = {}
    for second in range(nums_len):
        if target - nums[second] in seen_at:
            return [seen_at[target - nums[second]], second]
        else:
            seen_at[nums[second]] = second

The reason that this improves things is that rather than a second loop to find the other summand, we just do a lookup for whether we've seen it. With n things, this is a O(n) algorithm instead of O(n^2).

On your time limit exceeded case, this runs in under 40 ms on my laptop.

btilly
  • 43,296
  • 3
  • 59
  • 88
  • This is great, thanks. Another slight improvement would be test if the current value is greater than the target and stop iterating – thenewjames Jul 08 '22 at 03:24
0

In the second example, you've replaced a relatively straight forward double nested loop (which is cheap, it just has two loop variables and some logic to set and increase them) with a generator that internally has the exact same logic, but adds the overhead of yielding those pairs of values one at a time and then receiving and unpacking them to work with them.

So, the expectation is definitely that it will be slower, you're doing more work after all.

There are some places to optimise your code though:

  • Python is often more efficient if you iterate directly over an iterable, like the list in this case (instead of ranging an index and indexing later)
  • you could choose to yield your results instead of returning a list, if there's a possibility the user of your function is not interested in all the results, but just the first (few)

So, here's a better version of your first solution:

# you use List, but since 3.9, you could just use list and avoid this import
from typing import List, Generator


nums = [2, 7, 11, 15]
target = 9


def twoSum(nums: List[int], target: int) -> Generator[int, None, None]:
    for i, first in enumerate(nums):
        for j, second in enumerate(nums[i+1:], start=i+1):
            if first + second == target:
                yield (i, j)  # yielding a tuple instead of a list, which suits

# in this case we want all the results, so grabbing them in a list
result = list(twoSum(nums, target))
print(result)

Output:

[(0, 1)]

An example of using the generator for a less manageable result:

big_result = twoSum(list(range(100000)), 237)
print(next(big_result), next(big_result))

And here's a version of your function that fixes two additional points: adding back in the limit to return a needed number of results, and being more permissive about the iterable passed in (so that you don't have to cast to a list to avoid type hints):

from typing import Iterable, Generator


def twoSum(nums: Iterable[int], target: int, max_results: int=-1) -> Generator[int, None, None]:
    for i, first in enumerate(nums):
        for j, second in enumerate(nums[i+1:], start=i+1):
            if not max_results:
                return
            if first + second == target:
                if max_results > 0:
                    max_results -= 1
                yield (i, j)


needed_result = list(twoSum([2, 7, 11, 15], 9, 1))

But also note that you wouldn't normally put in a limit like that, but rather just get the number of results from the generator and then discard it. The generator will get garbage-collected once there's no reference to it anymore.

Grismar
  • 27,561
  • 4
  • 31
  • 54
  • It's not *strictly* more work, though, as it dropped the `if len(result) == 2`. But apparently that saves less time than what the other changes cost extra. – Kelly Bundy Jul 07 '22 at 04:10
  • It's actually far worse, since the generator version won't stop at 2 results, but try and generate all of them, which means the results aren't even the same, and `twoSums` wasn't written as a generator itself, so there is no advantage and a terrible disadvantage. – Grismar Jul 07 '22 at 04:12
  • Thanks. I was reading [this post](https://nedbatchelder.com/text/iter.html#h_customizing_iteration) and it says: Generators have the advantage that they are lazy: they do no work until the first value is requested, and they only do enough work to produce that value. Work after that doesn’t happen until the next value is requested. This makes them use fewer resources, and usable on more kinds of iterables. – thenewjames Jul 07 '22 at 04:13
  • 1
    That's true, but your function that *uses* the generator wasn't lazy at all, it exhausted the generator, so in addition to the overhead, it caused way more work to be done - I've updated the answer to reflect that. – Grismar Jul 07 '22 at 04:14
  • 1
    What? No. Note the `break`. That stops it at the first result. – Kelly Bundy Jul 07 '22 at 04:15
  • Ah good point - doesn't change the reasoning of the answer though, but I'll update to remove that remark, since it is incorrect. – Grismar Jul 07 '22 at 04:20
  • 2
    It also sounds like you think their first version finds two results. It doesn't. It finds **one** result - one pair of indexes. – Kelly Bundy Jul 07 '22 at 04:24
  • It does, although it has a line of code in there that guards against having added a second (`if len(result) == 2: break`) - once I found the basic problem with the code, I didn't stop to analyse the remaining mistakes. I've updated the answer to remove the hidden assumption - I think it safe to leave it alone at this point. – Grismar Jul 07 '22 at 04:28
  • 1
    Not sure what you're saying. Sounds like "It does find two and it prevents finding two", i.e., you seem to contradict yourself. And the answer still talks about "fixing" an issue that doesn't exist. – Kelly Bundy Jul 07 '22 at 04:38
0

It's possible to reduce the complexity to O(n log n).

You need first to sort the nums list. Then, for each n in nums, you binary search the list for target-n (binary search costs O(log n) ).

def binary_search(self, a, x, lo=0, hi=None):
  """ ref: https://stackoverflow.com/questions/212358/ """
  from bisect import bisect_left
  if hi is None: 
    hi = len(a)
  pos = bisect_left(a, x, lo, hi)                  # find insertion position
  return pos if pos != hi and a[pos] == x else -1  # don't walk off the end

def twoSum(self, nums: List[int], target: int) -> List[int]:
  nums.sort()
  for i,n in enumerate(nums):
    j = self.binary_search(nums, target-n)
    if j != -1:
      return [i,j]
  • You can reduce to `O(n)` pretty easily using a dictionary instead of a binary search/sort. – btilly Jul 07 '22 at 14:16
  • Yes, you're right. However, you need to have enough space to keep a dictionary for big values of n. And probably it's O(n) amortized since insertion is O(1) on average. – João Neto Jul 07 '22 at 17:31
  • Yes, but the space is rarely an issue. Ditto theoretical questions about the performance of dictionaries. However the improved constants in moving from user code in Python to Python built-ins tends to be very significant. – btilly Jul 07 '22 at 17:54