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