💬 欢迎讨论:如果你在学习过程中有任何问题或想法,欢迎在评论区留言,我们一起交流学习。你的支持是我继续创作的动力! 👍 点赞、收藏与分享:觉得这篇文章对你有帮助吗?别忘了点赞、收藏并分享给更多的小伙伴哦!你们的支持是我不断进步的动力! 🚀 分享给更多人:如果你觉得这篇文章对你有帮助,欢迎分享给更多对C++算法感兴趣的朋友,让我们一起进步!
接上篇:【优选算法篇】寻找隐藏的宝藏:用二分查找打开算法世界的大门(上篇)-CSDN博客
引言:通过上篇文章带大家简单了解“二分查找算法”,小试牛刀。接下来将让大家感受一下二分查找在解题的妙处。
总结:掌握二分查找不仅能通过高频面试题,更能体现逻辑思维与优化能力,是提升算法竞争力的必备技能。
前言:
二分查找是经典算法之一,以其高效的 O(log n) 时间复杂度在解决有序数据的查找问题中备受推崇。然而,面试和实际开发中,二分查找的基本用法往往不能满足复杂场景的需求。本篇博客将从二分查找的基础出发,逐步深入到进阶场景,帮助你掌握其在实际问题中的灵活应用。
二分查找是一种针对有序数组进行快速查找的算法。其核心思想是每次将搜索范围缩小一半,直到找到目标值或范围为空。
进阶场景往往结合数学、逻辑优化,提升算法效率。
题目链接:852. 山脉数组的峰顶索引 - 力扣(LeetCode)
题目描述:
该算法用于在一个符合“山峰数组”性质的数组中找到峰值的索引。山峰数组是指数组元素先严格递增后严格递减,且保证存在一个峰值。通过二分查找优化时间复杂度为 O(logn)。
left
和 right
,分别指向数组的起点和终点。
mid = left + (right - left) / 2
。arr[mid]
和 arr[mid + 1]
: arr[mid] > arr[mid + 1]
:说明峰值在左侧,包括 mid
,缩小范围为 [left, mid]
。arr[mid] < arr[mid + 1]
:说明峰值在右侧,缩小范围为 [mid + 1, right]
。left == right
时,区间收敛到一个点,该点即为峰值索引。
left
(或 right
),即为峰值所在的索引。
class Solution {
public:
int peakIndexInMountainArray(vector<int>& arr) {
int left = 0, right = arr.size() - 1; // 初始化左右指针,分别指向数组的起点和终点
while (left < right) { // 当左右指针未相遇时,继续搜索
int mid = left + (right - left) / 2; // 计算中点,避免直接加法可能导致的溢出
if (arr[mid] > arr[mid + 1]) {
// 如果当前元素比右侧元素大,说明峰值在左侧(包括 mid)
right = mid; // 缩小右边界到 mid
} else {
// 如果当前元素比右侧元素小,说明峰值在右侧
left = mid + 1; // 缩小左边界到 mid + 1
}
}
// 最终 left 和 right 收敛到同一点,即峰值索引
return left;
}
};
left
和 right
分别指向数组的首尾。mid
与其右侧的元素 mid + 1
的大小。arr[mid] > arr[mid + 1]
,说明峰值在左边(包括 mid
),调整 right
为 mid
。arr[mid] < arr[mid + 1]
,说明峰值在右边,调整 left
为 mid + 1
。left == right
时,搜索区间收敛到单个元素,该元素即为峰值索引。假设输入 arr = [0, 2, 1, 0]
:
left = 0, right = 3
。mid = 1, arr[mid] = 2, arr[mid + 1] = 1
。arr[mid] > arr[mid + 1]
,收缩右边界:right = mid = 1
。mid = 0, arr[mid] = 0, arr[mid + 1] = 2
。arr[mid] < arr[mid + 1]
,收缩左边界:left = mid + 1 = 1
。left == right == 1
,返回索引 1
,即峰值索引。暴力解法思路
class Solution {
public:
int peakIndexInMountainArray(vector<int>& arr) {
// 从数组的第二个元素开始检查
for (int i = 1; i < arr.size() - 1; i++) {
// 判断当前位置是否是峰值
if (arr[i] > arr[i - 1] && arr[i] > arr[i + 1]) {
return i; // 找到峰值,返回索引
}
}
return -1; // 理论上永远不会执行到这行,因为数组是山峰数组
}
};
i = 1
开始遍历,直到倒数第二个元素 i = arr.size() - 2
(因为峰值不可能在数组的两端)。arr[i]
,检查它是否大于其前一个元素 arr[i-1]
且大于其后一个元素 arr[i+1]
。如果是,说明该元素就是峰值,返回其索引 i
。n
是数组的长度。二分查找是一种重要的算法,能够在 O(logn) 时间内完成查找,适用于大规模数据的高效处理。
题目描述:
left
和 right
,并且不断缩小搜索区间直到找到一个峰值。mid
,然后比较 nums[mid]
和 nums[mid+1]
: nums[mid] > nums[mid+1]
,说明可能的峰值在左侧(包括当前 mid
),所以将右边界更新为 mid
。nums[mid] < nums[mid+1]
,说明峰值可能在右侧,更新左边界为 mid + 1
。left
和 right
收敛时,left
即为峰值元素的索引。class Solution {
public:
int findPeakElement(vector<int>& nums) {
int left = 0, right = nums.size() - 1; // 初始化左右边界
while (left < right) { // 当左边界小于右边界时继续二分查找
int mid = left + (right - left) / 2; // 计算中间位置
// 如果 mid 的元素大于 mid+1,峰值可能在左半边或是 mid
if (nums[mid] > nums[mid + 1]) {
right = mid; // 收缩右边界
} else { // 如果 mid 的元素小于 mid+1,峰值可能在右半边
left = mid + 1; // 收缩左边界
}
}
return left; // 最终 left 和 right 会收敛到峰值的位置
}
};
left
和 right
指针分别初始化为数组的起始位置和结束位置。while (left < right)
:只要左边界小于右边界,就继续进行二分查找。直到左边界和右边界重合时,退出循环。int mid = left + (right - left) / 2
:计算当前区间的中间位置,避免直接使用 (left + right) / 2
可能导致的溢出。nums[mid] > nums[mid+1]
:说明 mid
是可能的峰值或峰值在 mid
左侧,因此将右边界 right
更新为 mid
。nums[mid] < nums[mid+1]
:说明峰值在 mid
右侧,因此将左边界 left
更新为 mid + 1
。left
和 right
会收敛到同一个位置,即峰值元素的索引。n
是数组的长度。暴力解法步骤
nums[i] > nums[i - 1]
且 nums[i] > nums[i + 1]
,则该元素是峰值,返回其索引。nums[0] > nums[1]
,则第一个元素是峰值;如果最后一个元素 nums[n-1] > nums[n-2]
,则最后一个元素是峰值。class Solution {
public:
int findPeakElement(vector<int>& nums) {
// 如果数组长度为1,直接返回
if (nums.size() == 1) {
return 0;
}
// 遍历数组,从第二个元素到倒数第二个元素
for (int i = 1; i < nums.size() - 1; i++) {
if (nums[i] > nums[i - 1] && nums[i] > nums[i + 1]) {
return i; // 如果满足条件,返回索引
}
}
// 边界情况:检查数组的首尾元素是否为峰值
if (nums[0] > nums[1]) {
return 0; // 第一个元素是峰值
}
if (nums[nums.size() - 1] > nums[nums.size() - 2]) {
return nums.size() - 1; // 最后一个元素是峰值
}
return -1; // 应该不会执行到这里,因为题目保证至少有一个峰值
}
};
适用场景
暴力解法的优点是简单直观,易于理解和实现。虽然它的时间复杂度较高,为 O(n),但在小规模数据上完全可行。如果你希望解决更大规模的数组问题,可以考虑采用二分查找优化算法。
这个算法通过二分查找的方式高效地找到了峰值元素的索引,时间复杂度为 O(logn),大大减少了暴力解法可能的性能瓶颈。其核心在于利用了数组的单调性,精确定位了峰值的范围,并根据比较结果逐步缩小查找范围,最终找到峰值。
题目链接:153. 寻找旋转排序数组中的最小值 - 力扣(LeetCode)
题目描述:
4.1 算法思路:
这个问题的关键是理解数组被旋转后的结构:原本升序的数组,在旋转后可能分成两个升序子数组,最小元素位于两个子数组之间的断点处。利用二分查找可以快速定位这个断点。
left
和 right
指针,分别指向数组的开始和结束位置。
mid
,并判断中间元素和右端元素的大小关系:
nums[mid] > nums[right]
:说明最小值在 mid
右侧。此时,将 left
更新为 mid + 1
,继续在右半部分查找。nums[mid] <= nums[right]
:说明最小值在 mid
左侧或 mid
就是最小值,因此将 right
更新为 mid
。left < right
,当 left
和 right
收敛到相同位置时,left
就是最小元素的索引。
class Solution {
public:
int findMin(vector<int>& nums) {
int left = 0, right = nums.size() - 1;
while (left < right) {
int mid = left + (right - left) / 2;
// 如果中间值大于右边值,最小值在右半部分
if (nums[mid] > nums[right]) {
left = mid + 1;
}
// 否则,最小值在左半部分(包含 mid)
else {
right = mid;
}
}
// 最终 left 和 right 会重合,返回最小值
return nums[left];
}
};
left = 0
,指向数组的第一个元素。right = nums.size() - 1
,指向数组的最后一个元素。mid
,然后与 right
位置的元素进行比较。nums[mid] > nums[right]
,说明最小值在右半部分,因此将 left = mid + 1
。nums[mid] <= nums[right]
,说明最小值在左半部分(包括 mid
),因此将 right = mid
。left
和 right
收敛(left == right
)时,退出循环,此时 left
就是最小元素的索引,返回 nums[left]
。暴力解法的核心思路是:
步骤:
class Solution {
public:
int findMin(vector<int>& nums) {
// 如果数组只有一个元素,直接返回
if (nums.size() == 1) {
return nums[0];
}
// 遍历数组,查找最小元素
for (int i = 1; i < nums.size(); i++) {
if (nums[i] < nums[i - 1]) {
return nums[i]; // 如果当前位置的元素小于前一个元素,则说明该元素为最小值
}
}
// 如果没有找到旋转点,数组没有旋转,最小值为第一个元素
return nums[0];
}
};
适用场景
暴力解法的优点是实现简单,易于理解,适用于数据规模较小的情况。但由于它的时间复杂度是 O(n),在处理大规模数组时,效率较低。在处理较大的旋转数组时,可以考虑使用二分查找方法来提升效率,达到 O(log n) 的时间复杂度。
题目链接:LCR 173. 点名 - 力扣(LeetCode)
题目描述:
5.1 算法思路:
records
数组表示学生的出勤记录,且已按升序排列。records[i] != i
的最小索引。i
对应的值 records[i]
大于 i
,则说明从 i
到数组的末尾,后续学生都应该是出勤的。反之,如果某个位置 i
对应的值小于 i
,则说明当前位置之前的学生一定是缺勤的。left
和 right
分别指向数组的起始和结束位置。通过比较 records[mid]
和 mid
的大小关系来决定调整 left
和 right
。 records[mid] == mid
,表示该位置的学生出勤,说明最小缺勤学生的位置在右边(即 left = mid + 1
)。records[mid] != mid
,说明可能存在缺勤学生,因此继续在左边查找(即 right = mid
)。left
和 right
重合时,检查这个位置的出勤情况,返回相应的结果。left
和 right
相等时,我们检查 records[left]
是否等于 left
,如果是,则返回 left + 1
,否则返回 left
。class Solution {
public:
int takeAttendance(vector<int>& records) {
int left = 0, right = records.size() - 1;
while (left < right) {
int mid = left + (right - left) / 2;
// 如果 mid 对应的学生编号与索引匹配,则最小缺勤学生在右边
if (records[mid] == mid) {
left = mid + 1;
}
// 如果 mid 对应的学生编号与索引不匹配,则最小缺勤学生在左边
else {
right = mid;
}
}
// 最终判断 left 和 right 指向的索引位置
if (records[left] == left) return left + 1;
else return left;
}
};
left = 0
,right = records.size() - 1
,指向数组的两端。mid = left + (right - left) / 2
。records[mid] == mid
,表示该位置的学生出勤,更新 left = mid + 1
,搜索右半部分。records[mid] != mid
,则更新 right = mid
,继续在左半部分查找。left == right
时,退出循环。records[left]
与 left
是否匹配,返回相应的结果。records[left] == left
,说明此位置是出勤学生,返回 left + 1
。left
,说明当前位置为缺勤学生。n
是数组的长度。
适用场景
除了使用二分查找来解决该问题外,还可以通过其他几种方法来实现。以下是四种常见的解法,包括哈希、位运算、等差求和公式等。
哈希解法是通过使用哈希集合来记录已经出现过的元素。我们可以遍历数组并查找缺失的元素。
思路:
示例代码:
class Solution {
public:
int takeAttendance(vector<int>& records) {
unordered_set<int> recordSet;
for (int i = 0; i < records.size(); i++) {
recordSet.insert(records[i]);
}
for (int i = 0; i < records.size(); i++) {
if (recordSet.find(i) == recordSet.end()) {
return i; // 返回缺失的最小值
}
}
return records.size(); // 如果没有缺失值,则返回数组的长度
}
};
时间复杂度:
空间复杂度:
位运算解法利用异或(XOR)运算的特性来实现。异或的特性是:
思路:
x ^ x = 0
),最后剩下的就是缺失的最小值。示例代码:
class Solution {
public:
int takeAttendance(vector<int>& records) {
int result = 0;
for (int i = 0; i < records.size(); i++) {
result ^= i ^ records[i]; // 将索引和元素进行异或
}
return result == records.size() ? result : result + 1; // 返回缺失的值
}
};
时间复杂度:
空间复杂度:
通过利用等差数列求和公式来找到缺失的最小元素。
思路:
sum = n * (n-1) / 2
来计算理论上的数组元素和。示例代码:
class Solution {
public:
int takeAttendance(vector<int>& records) {
int n = records.size();
int expectedSum = (n * (n - 1)) / 2; // 等差数列求和公式
int actualSum = 0;
for (int i = 0; i < records.size(); i++) {
actualSum += records[i];
}
return expectedSum - actualSum;
}
};
时间复杂度:
空间复杂度:
通过先对数组进行排序,然后遍历数组找到最小的缺失元素。
思路:
示例代码:
class Solution {
public:
int takeAttendance(vector<int>& records) {
sort(records.begin(), records.end()); // 对数组进行排序
for (int i = 0; i < records.size(); i++) {
if (records[i] != i) {
return i; // 返回缺失的最小值
}
}
return records.size(); // 如果没有缺失值,返回数组的长度
}
};
时间复杂度:
空间复杂度:
建议:每种方法都有其优缺点,具体选择哪种解法取决于题目的限制条件和数据规模。
通过二分查找,可以在 O(log n) 时间内找出最小缺勤学生的位置。二分查找的核心在于通过比较 records[mid]
和 mid
来逐步缩小搜索范围,最终确定缺勤学生的位置。这种方法能够显著提高查找效率,特别是在大规模数据集上。
通过上述「二分查找在旋转排序数组中的应用」、「查找最小缺勤学生」及「寻找峰值元素」等例子,可以总结出二分查找算法的核心思想、应用场景和优化技巧。 二分查找是一种高效的算法,适用于处理有序数据或特定条件的数据结构,能够显著优化查找、定位问题的时间复杂度。通过灵活调整左右指针,可以快速缩小搜索范围,在处理查找类问题时,尤其是在大数据量的情况下,表现出强大的优势。
路虽远,行则将至;事虽难,做则必成
亲爱的读者们,下一篇文章再会!!!