写在前面
二分查找属于数据结构与算法中基础算法,属于必须掌握的算法之一,往往向这类基础算法广受面试官喜爱,一则算法的内容很普通,二则二分查找属于查找算法中的优化算法,面试官可以考察面试者是否关注算法复杂度,我们在解题时,若题面显著要求时间复杂度对对数,那么很大概率要考察二分查找。
153. 寻找旋转排序数组中的最小值
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。
请找出其中最小的元素。
你可以假设数组中不存在重复元素。
解题思路: 题面设定序列不存在重复元素,因此整体的难度降低不少,我们想象一下旋转序列大致的形态,锯齿状,当然我们应当一开始先判断数组未做旋转,然后在锯齿状数组下,我们分析,当去旋转序列中点时,若中点值大于左指针值,则左指针右移,同理右指针左移,最后若左、右指针相距1时,最大最小值即为两指针所指,旋转点也即在两指针中间。
class Solution {
public:
int findMin(vector<int>& nums) {
int left = 0, right = nums.size() - 1;
if (nums[left] < nums[right]) return nums[left];
while (right - left > 1) {
int mid = left + (right - left) / 2;
if (nums[mid] > nums[left]) left = mid;
else right = mid;
}
return min(nums[left], nums[right]);
}
};
当然还有另一种解法,可参考这篇博客,一般情况下,最小值位于旋转数组的右半部,因此将right指针逼近它,OK,解法思路非常简单,若mid值大于右指针,则说明mid在左半部,left应该指向mid下一个,否则mid位于右半部,right指向它即可。
class Solution {
public:
int findMin(vector<int>& nums) {
int left = 0, n = nums.size(), right = n - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] > nums[right]) left = mid + 1;
else right = mid;
}
return nums[right];
}
};
154. 寻找旋转排序数组中的最小值 II
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。
请找出其中最小的元素。
注意数组中可能存在重复的元素。
解题思路: 本题是题153的扩展,区别是本题数组可能存在重复元素,那么当判断mid值与right的关系时,就存在三种情况,小于或者大于的情况下同,而等于的情况,则无法判断是将left移动到mid出还是讲right移动到mid处,但有一点可以确定,旋转点一定在[mid,right]区间内,并且旋转点到right间的区间一定是非递减,那么right可以左移1,以线性复杂度逼近旋转点,其他的与题153解法相同。
class Solution {
public:
int findMin(vector<int>& nums) {
int left = 0, n = nums.size(), right = n - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] > nums[right]) left = mid + 1;
else if (nums[mid] < nums[right]) right = mid;
else --right;
}
return nums[right];
}
};
另外博客也提供了另一种解法,感觉不错,也记录一下,采取的是二分或者说分治的做法,将原序列分成两部分,将两部分的各自最小值求出来,然后两个最小值的最小值即为整个序列的最小值
如果没记错,这道题应该是《剑指offer》上的题,剑指上的思路和代码比这里代码繁琐一下,而且他只展示了解法1,两个解法推荐都应该掌握,而且第二种解法,递归,序列中是否有重复元素都通用。
class Solution {
public:
int findMin(vector<int>& nums) {
return helper(nums, 0, nums.size() - 1);
}
int helper(vector<int>& nums, int left, int right) {
if (right - left <= 1) return min(nums[left], nums[right]);
int mid = left + (right - left) / 2;
return min(helper(nums, left, mid), helper(nums, mid, right));
}
};
315. 计算右侧小于当前元素的个数
给定一个整数数组 nums,按要求返回一个新数组 counts。数组 counts 有该性质: counts[i] 的值是 nums[i] 右侧小于 nums[i] 的元素的数量。
解题思路: 起初的想法是用暴力解法解,但是OJ报TLE了,然后参考了之前解题的代码(看来题目时间长了,完全忘记了之前怎么解题的了╮(╯▽╰)╭),OK,本题考查的是排序+二分查找,不愧是hard题,具体的是,考查插入排序,然后在查找插入的位置时使用的是二分查找,对数组nums从后向前插入排序,插入的位置即为原数组右边小于当前值的元素个数,思路明确了,代码写起来不难。
OK,我们思考一下为什么是这样解题?本题的特征是,对所有位置求其同向某侧的属性(比如大于或者小于的元素个数),之前也碰到类似的题,解法方向有,从一端向另一个端,具体一下,比如从右向左统计一次右边属性,然后从左向右统计左边属性,然后再遍历一次数组,即可依据左右属性解决问题,当然此题不是这种解法,相当于提供了另一种解法,动态的插入+统计,然后计算某向属性。
class Solution {
public:
vector<int> countSmaller(vector<int>& nums) {
int n = nums.size();
vector<int> res(n), tmp;
for (int i = n - 1; i >= 0; --i) {
int left = 0, right = tmp.size();
while (left < right) {
int mid = left + (right - left) / 2;
if (tmp[mid] < nums[i]) left = mid + 1;
else right = mid;
}
res[i] = right;
tmp.insert(tmp.begin() + right, nums[i]);
}
return res;
}
};
162. 寻找峰值
峰值元素是指其值大于左右相邻值的元素。
给定一个输入数组 nums,其中 nums[i] ≠ nums[i+1],找到峰值元素并返回其索引。
数组可能包含多个峰值,在这种情况下,返回任何一个峰值所在位置即可。
你可以假设 nums[-1] = nums[n] = -∞。
解题思路: 之前也碰到过类似的题,不过题目是给定一个先增后减的序列,然后找出序列的峰值,跟此题解法是相同的,解题的关键是二分之后left和right该如何移动,i.e.,二分之后该查找哪半部的标准是什么?这也是所有二分查找算法的关键一步,那么我们想一下,当我们找到mid之后,峰值肯定大于等于mid值,由于序列在每一分段上严格递增或者递减,若mid落在递增序列上,那么峰值在它右边,那应该把left压到mid+1上,若mid落在递减序列上,那么峰值在它左边,right压到mid上,判断mid是在递增序列上还是递减序列上只需要与它相邻的元素做比较即可。
class Solution {
public:
int findPeakElement(vector<int>& nums) {
return helper(nums, 0, nums.size() - 1);
}
int helper(vector<int> &nums, int left, int right) {
if (left == right) return nums[left];
if (right - left <= 1) return max(nums[left], nums[right]);
int mid = left + (right - left) / 2;
return max(helper(nums, left, mid), helper(nums, mid + 1, right));
}
};
327. 区间和的个数
给定一个整数数组 nums,返回区间和在 [lower, upper] 之间的个数,包含 lower 和 upper。
区间和 S(i, j) 表示在 nums 中,位置从 i 到 j 的元素之和,包含 i 和 j (i ≤ j)。
说明:
最直观的算法复杂度是 O(n2) ,请在此基础上优化你的算法。
解题思路: 这题我没做出来,参考了这篇博客的解法,解法的关键是要做思路的转变,由于是求序列和,因此序列和数组的思想要用上(不一定要建立序列和数组,有时可以在遍历的过程中记录序列和),题面转换一下即求满足条件
l
o
w
e
r
<
=
S
i
−
S
j
−
1
<
=
u
p
p
e
r
,
1
<
=
j
<
=
i
lower<=S_i-S_{j-1}<=upper,1<=j<=i
lower<=Si−Sj−1<=upper,1<=j<=i的i,j对个数,然后当我们把i定下来时候时,把式子变换一下,
S
i
−
u
p
p
e
r
<
=
S
j
−
1
<
=
S
i
−
l
o
w
e
r
S_i-upper<=S_{j-1}<=S_i-lower
Si−upper<=Sj−1<=Si−lower,i.e.,求j个数,为使序列和有序便于二分查找,选用multiset,下确界索引即为lower_bound(si-upper),上确界大一个的索引upper_bound(si-lower),那么差值即为满足条件的j的个数。初始态时,multiset要放入0,不然若nums[0]
满足条件时会被漏掉,可以用下面示例验证程序即可知道原因。
[-2,5,-1]
-2
2
class Solution {
public:
int countRangeSum(vector<int>& nums, int lower, int upper) {
if (nums.empty()) return 0;
int n = nums.size(), res = 0;
multiset<long> ms;
long sum = 0;
ms.insert(0);
for (int i = 0; i < n; ++i) {
sum += nums[i];
auto left = ms.lower_bound(sum - upper);
auto right = ms.upper_bound(sum - lower);
res += distance(left, right);
ms.insert(sum);
}
return res;
}
};
33. 搜索旋转排序数组
解题思路: 与旋转数组相关的题还有题154和题153,区别是那两题讨论的是找旋转点,而此题讨论的是找目标值,我们先探索一下这类题的本质是什么,特征是有序,要么全部有序要么分段有序,找旋转点或者找目标值均是在找特定值(未知或已知),那么解题的最终均落实到查找上,而查找最笨的方法是Brute Force线性,而若序列有序,我们可以通过折半压缩查找范围,OK,本质道出来了,如何压缩查找范围,因为即使存在重复值时我们也尽量在Brute Force来临之前尽量先压小范围。而折半查找压缩范围一般是将left或者right向mid压缩,落实到此题,外加target,讨论四者间的位置关系即可解题,看下图,分类讨论6种情况(先处理相等情况,遵守corner case优先)。写完代码后,查了一下网上的解法,大部分代码思路相同,与本解法的区别是对6种分类讨论做了合并,OK,请看代码。
// 考察二分查找,分类讨论nums[mid],nums[left],target的位置关系
// 然后决定left=mid+1或者right=mid-1来压缩查找范围
// T: O(lgn),S: O(1)
class Solution {
public:
int search(vector<int>& nums, int target) {
if (nums.empty()) return -1;
if (nums.size() == 1) {
return nums[0] == target ? 0 : -1;
}
int left = 0, right = nums.size() - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) return mid;
if (nums[left] == target) return left;
if (nums[right] == target) return right;
if (nums[mid] > nums[left]) {
if (target < nums[left]) {
left = mid + 1;
} else {
if (target > nums[mid]) left = mid + 1;
else right = mid - 1;
}
} else {
if (target > nums[left]) {
right = mid - 1;
} else {
if (target > nums[mid]) {
left = mid + 1;
} else {
right = mid - 1;
}
}
}
}
return -1;
}
};
看到群友的解法,虽然从代码上相当于是对6种情况的合并,但是解法的思路缺很好,这里也介绍一下,不过一般在判断mid,left,right与target相等时建议提前判断。下面解法的思路是找mid与left或者right组成的有序序列,然后判断若target落在这个有序序列内,比如[left,mid]
区间有序,target落在[left,mid]
内,那么right向mid靠近,否则left向mid靠近(这个画一下图就能理解),其他同理。
class Solution {
public:
int search(vector<int>& nums, int target) {
if (nums.empty()) return -1;
if (nums.size() == 1) {
return nums[0] == target ? 0 : -1;
}
int left = 0, right = nums.size() - 1;
while (left <= right) {
int mid = left + (right - left) / 2;
if (nums[mid] == target) return mid;
if (nums[mid] < nums[left]) { // mid->right: sorted
if (target > nums[mid] && target <= nums[right]) left = mid + 1;
else right = mid - 1;
} else { // left->mid: sorted
if (target >= nums[left] && target < nums[mid]) right = mid - 1;
else left = mid + 1;
}
}
return -1;
}
};
378. 有序矩阵中第K小的元素
解题思路: 有序矩阵的特点是上->下,左->右递增,而此题有一个特殊字眼,第k小,属于TOP-K系列问题,自然用堆排序(优先级队列)可以解此问题,但是时间复杂度是O(m*n*lgK)
,但是这个没利用到序列有序的特点。有序中查找问题,还有一个最常见的最大即二分查找,二分查找的关键是,1)查找范围和查找的值,2)二分后查找哪一半部的标准(i.e.,缩小范围),查找范围选[最小值,最大值]
,然后逐行统计不大于中值的个数,这里可以借助函数upper_bound,i.e.,第一个大于目标值的数对应位置,时间复杂度为O(m*lgn*lg(maxDiff))
,maxDiff为最大最小值差值。
class Solution {
public:
int kthSmallest(vector<vector<int>>& matrix, int k) {
int left = matrix[0][0], rows = matrix.size(), cols = matrix[0].size(), right = matrix[rows - 1][cols -1];
while (left < right) {
int midVal = left + (right - left) / 2, cnt = 0;
for (int i = 0; i < rows; ++i) {
cnt += (upper_bound(matrix[i].begin(), matrix[i].end(), midVal) - matrix[i].begin());
}
cout << cnt << endl;
if (cnt < k) left = midVal + 1;
else right = midVal;
}
return left;
}
};