[LeetCode] 二分查找题型总结

写在前面

二分查找属于数据结构与算法中基础算法,属于必须掌握的算法之一,往往向这类基础算法广受面试官喜爱,一则算法的内容很普通,二则二分查找属于查找算法中的优化算法,面试官可以考察面试者是否关注算法复杂度,我们在解题时,若题面显著要求时间复杂度对对数,那么很大概率要考察二分查找。

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<=SiSj1<=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 Siupper<=Sj1<=Silower,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;
    }
};
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值