Find single number in pairs of unique numbers of a Python list in O(lg n)

Viewed 521

I have a question for Divide and Conquering in programming algorithms. Suppose you are given a random integer list in Python which consists of:

  1. Unique contiguous pairs of integers
  2. A single integer somewhere in the list

And the conditions are exclusive, meaning while [2,2,1,1,3,3,4,5,5,6,6] is valid, these are not:

  1. [2,2,2,2,3,3,4] (violates condition 1: because there are two pairs of 2s while there can only be a maximum of 1 pair of any number)
  2. [1,4,4,5,5,6,6,1] (violates condition 1: because there is a pair of 1s but they are not contiguous).
  3. [1,4,4,5,5,6,6,3] (violates condition 2: there are 2 single numbers, 1 and 3)

Now the question is can you find the 'single' number index in an O(lgn) algorithm?

My original jab is this:

def single_num(array, arr_max_len):

  i = 0

  while (i < arr_max_len):
    if (arr_max_len - i == 1):
      return i
    elif (array[i] == array[i + 1]):
      i = i + 2
    else:
      return i # don't have to worry about odd index because it will never happen
  
  return None 

However, the algorithm seems to run at O(n/2) time, which seems like the best it could do.

Even if I use divide and conquer, I don't think it's going to get better than O(n/2) time, unless there's some method that's beyond my scope of comprehension at the moment.

Anyone has any better idea, or can I arguably say, this is already in O(log n) time?

EDIT: It seems like Manuel has the best solution, if allowed Ill have some time to implement a solution myself for understanding, and then accept Manuel’s answer.

2 Answers

Solution

Just binary search the even indexes to find the first whose value differs from the next value.

from bisect import bisect

def single_num(a):
    class E:
        def __getitem__(_, i):
            return a[2*i] != a[2*i+1]
    return 2 * bisect(E(), False, 0, len(a)//2)

Explanation

Visualization of the virtual "list" E() that I'm searching on:

       0  1   2  3   4  5   6  7   8  9   10 (indices)
  a = [2, 2,  1, 1,  3, 3,  4, 5,  5, 6,  6]
E() = [False, False, False, True,  True]
       0      1      2      3      4     (indices)

In the beginning, the pairs match (so != results in False-values). Starting with the single number, the pairs don't match (so != returns True). Since False < True, that's a sorted list which bisect happily searches in.

Alternative implementation

Without bisect, if you're not yet tired of writing binary searches:

def single_num(a):
    i, j = 0, len(a) // 2
    while i < j:
        m = (i + j) // 2
        if a[2*m] == a[2*m+1]:
            i = m + 1
        else:
            j = m
    return 2*i

Sigh...

I wish bisect would support giving it a callable so I could just do return 2 * bisect(lambda i: a[2*i] != a[2*i+1], False, 0, len(a)//2). Ruby does, and it's perhaps the most frequent reason I sometimes solve coding problems with Ruby instead of Python.

Testing

Btw I tested both with all possible cases for up to 1000 pairs:

from random import random

for pairs in range(1001):
    a = [x for _ in range(pairs) for x in [random()] * 2]
    single = random()
    assert len(set(a)) == pairs and single not in a
    for i in range(0, 2*pairs+1, 2):
        a.insert(i, single)
        assert single_num(a) == i
        a.pop(i)

A lg n algorithm is one in which you split the input into smaller parts, and discard some of the smaller part such that you have a smaller input to work with. Since this is a searching problem, the likely solution for a lg n time complexity is binary search, in which you split the input in half each time.


My approach is to start off with a few simple cases, to spot any patterns that I can make use of.

In the following examples, the largest integer is the target number.

# input size: 3  
[1,1,2]
[2,1,1]

# input size: 5  
[1,1,2,2,3]
[1,1,3,2,2]
[3,1,1,2,2]

# input size: 7  
[1,1,2,2,3,3,4]
[1,1,2,2,4,3,3]
[1,1,4,2,2,3,3]
[4,1,1,2,2,3,3]

# input size: 9  
[1,1,2,2,3,3,4,4,5]
[1,1,2,2,3,3,5,4,4]
[1,1,2,2,5,3,3,4,4]
[1,1,5,2,2,3,3,4,4]
[5,1,1,2,2,3,3,4,4]

You probably notice that the input size is always an odd number i.e. 2*x + 1.

Since this is a binary search, you can check if the middle number is your target number. If the middle number is the single number (if middle_number != left_number and middle_number != right_number), then you have found it. Otherwise, you have to search the left side or the right side of the input.

Notice that in the sample test cases above, in which the middle number is not the target number, there is a pattern between the middle number and its pair.

For input size 3 (2*1 + 1), if middle_number == left_number, the target number is on the right, and vice versa.

For input size 5 (2*2 + 1), if middle_number == left_number, the target number is on the left, and vice versa.

For input size 7 (2*3 + 1), if middle_number == left_number, the target number is on the right, and vice versa.

For input size 9 (2*4 + 1), if middle_number == left_number, the target number is on the left, and vice versa.

That means the parity of x in 2*x + 1 (the array length) affects whether to search the left or right side of the input: search the right if x is odd and search the left if x is even, if middle_number == left_number (and vice versa).


Base on all these information, you can come up with a recursive solution. Note that you have to ensure that the input size is odd in each recursive call. (Edit: Ensuring that input size is odd makes the code even more messy. You probably want to come up with a solution in which parity of input size does not matter.)

def find_single_number(array: list, start_index: int, end_index: int):
    # base case: array length == 1
    if start_index == end_index:
        return start_index
    
    middle_index = (start_index + end_index) // 2
        
    # base case: found target
    if array[middle_index] != array[middle_index - 1] and array[middle_index] != array[middle_index + 1]:
        return middle_index
        
    # make use of parity of array length to search left or right side
    # end_index == array length - 1
    x = (end_index - start_index) // 2

    # ensure array length is odd
    include_middle = (middle_index % 2 == 0)
        
    if array[middle_index] == array[middle_index - 1]:  # middle == number on its left
        if x % 2 == 0:  # x is even
            # search left side
            return find_single_number(
                array,
                start_index,
                middle_index if include_middle else middle_index - 1
            )

        else:  # x is odd
            # search right side side
            return find_single_number(
                array,
                middle_index if include_middle else middle_index + 1,
                end_index,
            )

    else:  # middle == number on its right
        if x % 2 == 0:  # x is even
            # search right side side
            return find_single_number(
                array,
                middle_index if include_middle else middle_index + 1,
                end_index,
            )

        else:  # x is odd
            # search left side
            return find_single_number(
                array,
                start_index,
                middle_index if include_middle else middle_index - 1
            )


# test out the code
if __name__ == '__main__':
    array = [2,2,1,1,3,3,4,5,5,6,6]  # target: 4 (index: 6)
    print(find_single_number(array, 0, len(array) - 1))

    array = [1,1,2]  # target: 2 (index: 2)
    print(find_single_number(array, 0, len(array) - 1))

    array = [1,1,3,2,2]  # target: 3 (index: 2)
    print(find_single_number(array, 0, len(array) - 1))

    array = [1,1,4,2,2,3,3]  # target: 4 (index: 2)
    print(find_single_number(array, 0, len(array) - 1))

    array = [5,1,1,2,2,3,3,4,4]  # target: 5 (index:0)
    print(find_single_number(array, 0, len(array) - 1))

My solution is probably not the most efficient or elegant, but I hope my explanation helps you understand the approach towards tackling these kind of algorithmic problems.


Proof that it has a time complexity of O(lg n):

Let's assume that the most important operation is the comparison of the middle number against the left and right number (if array[middle_index] != array[middle_index - 1] and array[middle_index] != array[middle_index + 1]), and that it has a time cost of 1 unit. Let us refer to this comparison as the main comparison.

Let T be time cost of the algorithm.
Let n be the length of the array.

Since this solution involves recursion, there is a base case and recursive case.

For the base case (n = 1), it is just the main comparison, so:
T(1) = 1.

For the recursive case, the input is split in half (either left half or right half) each time; at the same time, there is one main comparison. So:
T(n) = T(n/2) + 1

Now, I know that the input size must always be odd, but let us assume that n = 2k for simplicity; the time complexity would still be the same.

We can rewrite T(n) = T(n/2) + 1 as:
T(2k) = T(2k-1) + 1

Also, T(1) = 1 is: T(20) = 1

When we expand T(2k) = T(2k-1) + 1, we get:

T(2k)
= T(2k-1) + 1
= [T(2k-2) + 1] + 1 = T(2k-2) + 2
= [T(2k-3) + 1] + 2 = T(2k-3) + 3
= [T(2k-4) + 1] + 3 = T(2k-4) + 4
= ...(repeat until k)
= T(2k-k) + k = T(20) + k = k + 1

Since n = 2k, that means k = log2 n.

Substituting n back in, we get: T(n) = log2 n + 1

1 is a constant so it can be dropped; same goes for the base of the log operation.

Therefore, the upperbound of the time complexity of the algorithm is:
T(n) = lg n

Related