21.
Binary Search Challenges
Written by Kelvin Lau
Challenge 1: Binary search as a free function
In the previous chapter, you implemented binary search as an extension of the RandomAccessCollection protocol. Since binary search only works on sorted collections, exposing the function as part of RandomAccessCollection will have a chance of misuse.
Your challenge is to implement binary search as a free function.
Challenge 2: Searching for a range
Write a function that searches a sorted array and that finds the range of indices for a particular element. For example:
let array = [1, 2, 3, 3, 3, 4, 5, 5]
findIndices(of: 3, in: array)
findIndices should return the range 2..<5, since those are the start and end indices for the value 3.
Solutions
Solution to Challenge 1
In this challenge, you’ll implement binary search as a free function. Here’s what the function looks like:
func binarySearch<Elements: RandomAccessCollection>(
for element: Elements.Element,
in collection: Elements,
in range: Range<Elements.Index>? = nil) -> Elements.Index?
where Elements.Element: Comparable {
let range = range ?? collection.startIndex..<collection.endIndex
guard range.lowerBound < range.upperBound else {
return nil
}
let size = collection.distance(from: range.lowerBound,
to: range.upperBound)
let middle = collection.index(range.lowerBound, offsetBy: size / 2)
if collection[middle] == element {
return middle
} else if collection[middle] > element {
return binarySearch(for: element, in: collection, in: range.lowerBound..<middle)
} else {
return binarySearch(for: element,
in: collection,
in: collection.index(after: middle)..<range.upperBound)
}
}
Solution to Challenge 2
An unoptimized but elegant solution is quite simple:
func findIndices(of value: Int, in array: [Int]) -> Range<Int>? {
guard let leftIndex = array.firstIndex(of: value) else {
return nil
}
guard let rightIndex = array.lastIndex(of: value) else {
return nil
}
return leftIndex..<rightIndex
}
The time complexity of this solution is O(n), which may not seem to be a cause for concern. However, the solution can be optimized to a O(_log n) time complexity solution.
Binary search is an algorithm that identifies values in a sorted collection, so keep that in mind whenever the problem promises a sorted collection. The binary search you implemented in the theory chapter is not powerful enough to reason whether the index is a start or end index. You’ll modify the binary search that you learned to accommodate for this new rule.
Write the following in your playground:
func findIndices(of value: Int,
in array: [Int]) -> CountableRange<Int>? {
guard let startIndex = startIndex(of: value,
in: array,
range: 0..<array.count) else {
return nil
}
guard let endIndex = endIndex(of: value,
in: array,
range: 0..<array.count) else {
return nil
}
return startIndex..<endIndex
}
func startIndex(of value: Int,
in array: [Int],
range: CountableRange<Int>) -> Int {
// more to come
}
func endIndex(of value: Int,
in array: [Int],
range: CountableRange<Int>) -> Int {
// more to come
}
This time, findIndices will use specialized binary searches. startIndex and endIndex will be the ones that do the heavy lifting with a customized binary search. You will modify binary search so that it also inspects whether the adjacent value (depending on whether you’re looking for the start or end index) is different to the current value. Update the startIndex method to the following:
func startIndex(of value: Int,
in array: [Int],
range: CountableRange<Int>) -> Int? {
// 1
let middleIndex = range.lowerBound +
(range.upperBound - range.lowerBound) / 2
// 2
if middleIndex == 0 || middleIndex == array.count - 1 {
if array[middleIndex] == value {
return middleIndex
} else {
return nil
}
}
// 3
if array[middleIndex] == value {
if array[middleIndex - 1] != value {
return middleIndex
} else {
return startIndex(of: value,
in: array,
range: range.lowerBound..<middleIndex)
}
} else if value < array[middleIndex] {
return startIndex(of: value,
in: array,
range: range.lowerBound..<middleIndex)
} else {
return startIndex(of: value,
in: array,
range: middleIndex..<range.upperBound)
}
}
Here’s what you do with this code:
- You start by calculating the middle value of the indices contained in
range. - This is the base case of this recursive function. If the middle index is the first or last accessible index of the array, you don’t need to call binary search any further. You’ll make the determination on whether or not the current index is a valid bound for the given value.
- Here, you check the value at the index and make your recursive calls. If the value at
middleIndexis equal to the value you’re given, you check to see if the predecessor is also the same value. If it isn’t, you know that you’ve found the starting bound. Otherwise, you’ll continue by recursively callingstartIndex.
The endIndex method is similar. Update the endIndex implementation to the following:
func endIndex(of value: Int,
in array: [Int],
range: CountableRange<Int>) -> Int? {
let middleIndex = range.lowerBound +
(range.upperBound - range.lowerBound) / 2
if middleIndex == 0 || middleIndex == array.count - 1 {
if array[middleIndex] == value {
return middleIndex + 1
} else {
return nil
}
}
if array[middleIndex] == value {
if array[middleIndex + 1] != value {
return middleIndex + 1
} else {
return endIndex(of: value,
in: array,
range: middleIndex..<range.upperBound)
}
} else if value < array[middleIndex] {
return endIndex(of: value,
in: array,
range: range.lowerBound..<middleIndex)
} else {
return endIndex(of: value,
in: array,
range: middleIndex..<range.upperBound)
}
}
Test out your solution by writing the following at the bottom of the playground:
let array = [1, 2, 3, 3, 3, 4, 5, 5]
if let indices = findIndices(of: 3, in: array) {
print(indices)
}
You should see the following output in the console:
2..<5
This improves the time complexity from the previous O(n) to O(log n).