Summary of leetCode binary search methods

Posted by siminder on Sat, 08 Jan 2022 18:44:32 +0100

This article explores some of the most common dichotomy scenarios: finding a number, finding the left boundary, and finding the right boundary. Moreover, we just want to go into the details, such as whether the unequal sign should be equal, whether the mid should be added, and so on.

In the form of question and answer, analyze the differences in these details and the reasons for the differences, so that you can write the correct binary search algorithm flexibly and accurately.

Binary Search Framework

int binarySearch(int[] nums, int target) {
int left = 0, right = ...;

while(...) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) {
...
} else if (nums[mid] < target) {
left = ...
} else if (nums[mid] > target) {
right = ...
}
}
return ...;
}

One of the tricks of analytic dichotomy is not to see else, but to write everything clearly in else if so that all the details can be clearly shown. This article uses else if to make it clear that readers can simplify themselves after understanding it.

The part marked by.. is where the details may be problematic. When you see a binary lookup code, pay attention to it first. Later, we will use an example to analyze how these places can change.

Also, it is important to avoid overflow when calculating mid. The result of left + (right - left) / 2 in the code is the same as that of (left + right) / 2, but it effectively prevents the overflow caused by the direct addition of left and right.

Find a number (basic binary search)

int binarySearch(int[] nums, int target) {
int left = 0;
int right = nums.length - 1; // Be careful

while(left <= right) {
int mid = left + (right - left) / 2;
if(nums[mid] == target)
return mid;
else if (nums[mid] < target)
left = mid + 1; // Be careful
else if (nums[mid] > target)
right = mid - 1; // Be careful
}
return -1;
}

1. Why is the condition of the while loop <=, not <?

A: Because the assignment to initialize right is nums.length - 1, the index of the last element, not nums.length.

The two may occur in dichotomy lookups of different functions, except that the former is equivalent to a closed interval [left, right] at both ends, and the latter is equivalent to a left, right open interval [left, right], because the index size is nums.length is out of bounds.

In our algorithm, we use an interval where both ends of the former [left, right] are closed. This interval is actually the interval of each search.

When should I stop searching? Of course, when the target value is found, it can be terminated:

if(nums[mid] == target)
return mid;

However, if it is not found, the while loop terminates and then returns to -1. When should the while loop end? When the search interval is empty, it should be terminated, which means you can't find it, it means you can't find it.

When (left <= right) terminates with left == right + 1, written as an interval [right + 1, right], or with a specific number in [3, 2], the interval is empty, because no number is greater than or equal to 3 and less than or equal to 2. So at this point the while loop terminates correctly, just go back to -1.

When (left < right) the termination condition is left == right, written as an interval, [left, right], or enter [2, 2] with a specific number, when the interval is not empty and there is still a number 2, but at this point the while loop terminates. That is, the interval [2, 2] is missing, index 2 is not searched, and it would be wrong to return to -1 directly at that time.

Of course, if you have to use while (left < right), we already know the cause of the error, so make a patch:

//...
while(left < right) {
// ...
}
return nums[left] == target ? left : -1;

3. What are the defects of this algorithm?

A: At this point, you should have mastered all the details of the algorithm and the reason for this. However, this algorithm has limitations.

For example, if you have an ordered array nums = [1,2,2,2,3], target is 2, the index returned by this algorithm is 2, that's right. But if I want to get the left bound of the target, index 1, or I want the right bound of the target, index 3, this algorithm will not work.

This is a common requirement, and you might say, isn't it okay to find a target and search left or right linearly? Yes, but it's not good because it's hard to guarantee the logarithmic complexity of binary lookups.

Our subsequent algorithms will discuss these two binary search algorithms.

Binary search for left boundary

The following are the most common forms of code where the tags are the details you need to be aware of:

int left_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0;
int right = nums.length; // Be careful

while (left < right) { // Be careful
int mid = (left + right) / 2;
if (nums[mid] == target) {
right = mid;
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid; // Be careful
}
}
return left;
}

1. Why is <not <= in while?

A: Use the same method for analysis, because right = nums.length instead of nums.length - 1. So the search interval for each cycle is [left, right] left closed and right open.

When (left < right) terminates if left == right, the search interval [left, left] is empty, so it can terminate correctly.

PS: First of all, let's talk about the difference between a search for left and right boundaries and the above algorithm, which is also what many readers ask: the right is not nums.length - 1? Why not write nums here. Does length turn Search Interval left closed and right open?

Because this is a common way to search for binary lookups on the left and right boundaries, I'll take this writing as an example to ensure that you can understand it later. Actually, it's easier to write with both ends closed. I'll write the relevant code in the back to unify the three binary searches with one end closed. You can just look back patiently.

2. Why did you not return -1? What if the value target does not exist in nums?

A: Because step by step, first understand what this Left Boundary means: For this array, the algorithm returns 1. The meaning of this 1 can be interpreted as follows: there is one element less than 2 in nums.

For example, for an ordered array nums = [2,3,5,7],target = 1, the algorithm returns 0, meaning that there are 0 elements with nums less than 1.

For example, nums = [2,3,5,7], target = 8, the algorithm returns 4, meaning that there are four elements less than 8 in nums.

To sum up, the return value of a function (that is, the value of the left variable) has a closed interval [0, nums.length], so we can simply add two lines of code to return -1 at the right time:

while (left < right) {
//...
}
// target is larger than all numbers
if (left == nums.length) return -1;
// Processing similar to previous algorithms
return nums[left] == target ? left : -1;

3. Why left = mid + 1, right = mid? Unlike previous algorithms?
A: This is a good explanation because our search interval is [left, right] left closed and right open, so when nums[mid] is detected, the next search interval should remove mid and divide it into two intervals, that is, [left, mid] or [mid + 1, right].
4. Why can the algorithm search the left boundary?
A: The key is to deal with the situation where nums[mid] == target:

if (nums[mid] == target)
right = mid;

Thus, instead of returning immediately when a target is found, narrow the upper right of the "search interval" and continue searching in the interval [left, mid], i.e. shrink to the left continuously to lock the left boundary.

A: It's the same, because while terminates on the condition that left == right.

6. Can you find a way to turn right into nums? Length - 1, which means continue to use Search Intervals with both sides closed? This is in some way consistent with the first binary search.

A: Of course, as long as you understand the concept of "search interval", you can effectively avoid missing elements. You can change anything you like. Here we modify it strictly according to logic:

Because you don't want both ends of the search interval to be closed, right should be initialized to nums.length - 1, while's termination condition should be left == right + 1, that is, it should use <=:

int left_bound(int[] nums, int target) {
// The search interval is [left, right]
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
// if else ...
}

Because the search interval is closed at both ends and is now searching the left boundary, the update logic for left and right is as follows:

if (nums[mid] < target) {
// Search interval changed to [mid+1, right]
left = mid + 1;
} else if (nums[mid] > target) {
// Search interval becomes [left, mid-1]
right = mid - 1;
} else if (nums[mid] == target) {
// Shrink Right Boundary
right = mid - 1;
}

Since while's exit condition is left == right + 1, when the target is larger than all elements in nums, the following conditions can make the index out of bounds: if (left >= nums.length || nums[left] != target)
return -1;
return left;

At this point, the entire algorithm is finished, and the complete code is as follows:

int left_bound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
// The search interval is [left, right]
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
// Search interval changed to [mid+1, right]
left = mid + 1;
} else if (nums[mid] > target) {
// Search interval becomes [left, mid-1]
right = mid - 1;
} else if (nums[mid] == target) {
// Shrink Right Boundary
right = mid - 1;
}
}
// Check Outbound
if (left >= nums.length || nums[left] != target)
return -1;
return left;
}

This unifies with the first binary search algorithm, which is a "search interval" with both ends closed, and returns the value of the left variable. As long as you keep the logic of the dichotomous search, you can see which one you like and which one you like.

Find the binary search for the right boundary

Similar to the algorithm for finding the left boundary, there are also two writings provided here, or the common left-closed-right-open writings are written first. Only two are different from the search left boundary and are labeled:

int right_bound(int[] nums, int target) {
if (nums.length == 0) return -1;
int left = 0, right = nums.length;

while (left < right) {
int mid = (left + right) / 2;
if (nums[mid] == target) {
left = mid + 1; // Be careful
} else if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid;
}
}
return left - 1; // Be careful
}

1. Why can this algorithm find the right boundary?

A: Similarly, the key point is here:

if (nums[mid] == target) {
left = mid + 1;

When nums[mid] == target, do not immediately return, but increase the lower bound left of the Search Range, causing it to shrink to the right continuously to lock the right boundary.
2. Why does it end up returning left-1, unlike the function at the left boundary? And I think now that I'm searching for the right boundary, I should go back to right.
A: First, the termination condition of the while loop is left == right, so left and right are the same. You have to reflect the characteristics of the right side and return to right - 1.

As to why one should be subtracted, this is a special point for searching the right boundary, the key is to judge under this condition:
3. Why did you not return -1? What if the value target does not exist in nums?

A: Similar to previous left boundary searches, because while terminates with left == right, which means that the value range of left is [0, nums.length], two lines of code can be added to return -1 correctly:

while (left < right) {
// ...
}
if (left == 0) return -1;
return nums[left-1] == target ? (left-1) : -1;

4. Can we also unify the search interval of this algorithm into a closed form? In this way, the three writing methods are completely unified, and you can write them with your eyes closed.

A: Yes, of course. Similar to the uniform writing of search left boundary, only two places need to be changed:

int right_bound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] == target) {
// Shrink the left border instead
left = mid + 1;
}
}
// Instead, check for right s crossing the border, as shown below
if (right < 0 || nums[right] != target)
return -1;
return right;
}

When the target is smaller than all the elements, the right is reduced to -1, so you need to guard against crossing the border at the end: So far, the two ways of searching for the binary search on the right boundary have been completed. In fact, it is easier to remember when the "search interval" is unified into two closed ends, right?

Logical Unification

To sort out the causal logic of these detailed differences:

First, the most basic binary search algorithm:

Because we initialize right = nums.length - 1 determines that our "search interval" is [left, right], so it determines while (left <= right), and it also determines left = mid+1 and right = mid-1, because we only need to find an index for a target so we can return immediately when nums[mid] == target

Second, find the binary search of the left boundary:

Because we initialize right = nums.length therefore determines that our "search interval" is [left, right], so while (left < right) also determines left = mid + 1 and right = mid because we need to find the leftmost index of the target so when nums [mid] = target do not immediately return but tighten the right boundary to lock the left boundary

Third, look for the binary search of the right boundary:

Because we initialize right = nums.length therefore determines that our "search interval" is [left, right], so while (left < right) also determines left = mid + 1 and right = mid because we need to find the rightmost index of the target so when nums [mid] = target do not immediately return but tighten the left to lock the right. And because you have to leave = mid + 1 when you tighten the left boundary, you have to subtract one at the end whether you return to left or right

For binary searches that search for left and right boundaries, a common method is to use a left-closed right-open "search interval". We also logically unified the "search interval" into two closed ends, which is easy to remember. You can change three styles by modifying two places:

int binary_search(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while(left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if(nums[mid] == target) {
// Return directly
return mid;
}
}
// Return directly
return -1;
}

int left_bound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] == target) {
// Don't go back, lock the left boundary
right = mid - 1;
}
}
// Finally, check for left crossings
if (left >= nums.length || nums[left] != target)
return -1;
return left;
}

int right_bound(int[] nums, int target) {
int left = 0, right = nums.length - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] < target) {
left = mid + 1;
} else if (nums[mid] > target) {
right = mid - 1;
} else if (nums[mid] == target) {
// Don't go back, lock the right boundary
left = mid + 1;
}
}
// Finally, check for right s that are out of bounds
if (right < 0 || nums[right] != target)
return -1;
return right;
}

Topics: Algorithm leetcode