关卡名 | 逢试必考的二分查找 | 我会了✔️ |
| 1.山脉数组的峰顶索引 | ✔️ |
2.旋转数字的最小数字 | ✔️ | |
3.寻找缺失数字 | ✔️ | |
4.优化求平方根 | ✔️ | |
5.中序与搜索树原理 | ✔️ | |
6.二叉搜索树中搜索特定值 | ✔️ | |
7.验证二叉搜索树 | ✔️ |
基于二分查找思想,可以拓展出很多算法问题的,而且很多都是考察的热门, 这里我们整理了几道经典的问题。
二分在算法中的应用非常多,也是很多大厂钟爱的考察类型,感兴趣的同学可以继续研究一下:
- CC150面试题53:在排序数组中查找数字,要求:统一几个数字在排序数组中出现的次数,例如,输入排序数组{1,2,3,3,3,3,4,5}和数字3,由于3在数组中出现了4次,因此输出4。
- leetcode 34. 在排序数组中查找元素的第一个和最后一个位置
- LeetCode875.爱吃香蕉的珂珂
- LeetCode29.两数相除
在前面我们发现很多题使用前序、后序或者层次遍历都可以解决,但几乎没有中序遍历的。这是因为中序与前后序相比有不一样的特征,例如中序可以和搜索树结合在一起,但是前后序则不行。
在理解了二分搜索之后,我们会发现中序搜索与二分查找简直就是一个娘养的,实在太像了。这里我们就来研究一下中序搜索的问题。
1.基于二分查找的拓展问题
1.1 山脉数组的峰顶索引
LeetCode852.这个题的要求有点啰嗦,核心意思就是在数组中的某位位置i开始,从0到i是递增的,从i+1 到数组最后是递减的,让你找到这个最高点。
详细要求是:符合下列属性的数组 arr 称为山脉数组 :arr.length >= 3存在 i(0 < i < arr.length - 1)使得:
- arr[0] < arr[1] < ... arr[i-1] < arr[i]
- arr[i] > arr[i+1] > ... > arr[arr.length - 1]
给你由整数组成的山脉数组 arr ,返回任何满足 arr[0] < arr[1] < ... arr[i - 1] < arr[i] > arr[i + 1] > ... > arr[arr.length - 1] 的下标 i 。
这个题其实就是前面找最小值的相关过程而已,最简单的方式是对数组进行一次遍历。
当我们遍历到下标i时,如果有arr[i-1]<arr[i]>arr[i+1],那么i就是我们需要找出的下标。
其实还可以更简单一些,因为是从左开始找的,开始的时候必然是arr[i-1]<a[i],所以只要找到第一个arr[i]>arr[i+1]的位置即可。代码就是:
public int peakIndexInMountainArray(int[] arr) {int n = arr.length;int ans = -1;for (int i = 1; i < n - 1; ++i) {if (arr[i] > arr[i + 1]) {ans = i;break;}}return ans;
}
这个题能否使用二分来优化一下呢?当然可以。
对于二分的某一个位置 mid,mid 可能的位置有3种情况:
- mid在上升阶段的时候,满足arr[mid]>a[mid-1] && arr[mid]<arr[mid+1]
- mid在顶峰的时候,满足arr[i]>a[i-1] && arr[i]>arr[i+1]
- mid在下降阶段,满足arr[mid]<a[mid-1] && arr[mid]>arr[mid+1]
因此我们根据 mid 当前所在的位置,调整二分的左右指针,就能找到顶峰。
public int peakIndexInMountainArray(int[] arr) {if (arr.length== 3)return 1;int left = 1, right = arr.length - 2;while(left < right) {int mid =left+ ((right - left)>>1);if (arr[mid] > arr[mid - 1] && arr[mid] > arr[mid + 1])return mid;if (arr[mid] < arr[mid + 1] && arr[mid] > arr[mid - 1])left = mid + 1;if (arr[mid] > arr[mid + 1] && arr[mid] < arr[mid - 1])right = mid - 1;}return left;
}
1.2. 旋转数字的最小数字
我们说刷算法要按照专题来刷,这样才能看清很多题目的内在关系,二分查找也是如此,很多题目看似与二分无关,但是就是在考察二分查找,我们一起看一下。
LeetCode153 已知一个长度为 n 的数组,预先按照升序排列,经由1到n次旋转后,得到输入数组。例如原数组 nums = [0,1,2,4,5,6,7] 在变化后可能得到:
- 若旋转 4 次,则可以得到 [4,5,6,7,0,1,2]
- 若旋转 7 次,则可以得到 [0,1,2,4,5,6,7]
注意,数组 [a[0], a[1], a[2], ..., a[n-1]] 旋转一次的结果为数组 [a[n-1], a[0], a[1], a[2], ..., a[n-2]] 。
示例1:
输入:nums = [4,5,1,2,3]
输出:1
解释:原数组为 [1,2,3,4,5] ,旋转 3 次得到输入数组。
示例2:
输入:nums = [4,5,6,7,0,1,2]
输出:0
解释:原数组为 [0,1,2,4,5,6,7] ,旋转 4 次得到输入数组。
本部分都摘自LeetCode一个不包含重复元素的升序数组在经过旋转之后,可以得到下面可视化的折线图:
其中横轴表示数组元素的下标,纵轴表示数组元素的值。图中标出了最小值的位置,是我们需要查找的目标。
我们考虑数组中的最后一个元素 x:在最小值右侧的元素(不包括最后一个元素本身),它们的值一定都严格小于 x;而在最小值左侧的元素,它们的值一定都严格大于 x。因此,我们可以根据这一条性质,通过二分查找的方法找出最小值。
在二分查找的每一步中,左边界为 low,右边界为 high,区间的中点为 pivot,最小值就在该区间内。我们将中轴元素 nums[pivot] 与右边界元素 nums[high] 进行比较,可能会有以下的三种情况:
第一种情况是nums[pivot]<nums[high]。如下图所示,这说明nums[pivot] 是最小值右侧的元素,因此我们可以忽略二分查找区间的右半部分。
第二种情况是 nums[pivot]>nums[high]。如下图所示,这说明nums[pivot] 是最小值左侧的元素,因此我们可以忽略二分查找区间的左半部分。
由于数组不包含重复元素,并且只要当前的区间长度不为 1,pivot 就不会与high 重合;而如果当前的区间长度为 1,这说明我们已经可以结束二分查找了。因此不会存在 nums[pivot]=nums[high] 的情况。
当二分查找结束时,我们就得到了最小值所在的位置。
public int findMin(int[] nums) {int low = 0;int high = nums.length - 1;while (low < high) {int pivot = low + ((high - low) >>1);if (nums[pivot] < nums[high]) {high = pivot;} else {low = pivot + 1;}}return nums[low];
}
这里你是否注意到high = pivot;而不是我们习惯的high = pivot-1呢?这是为了防止遗漏元素,例如[3,1,2],执行的时候nums[pivot]=1,小于nums[high]=2,此时如果high=pivot-1,则直接变成了0。所以对于这种边界情况,很难解释清楚,最好的策略就是多写几种场景测试一下看看。这也是二分查找比较烦的情况,一般来说解释比较困难,也不容易理解清楚,所以写几个典型的例子试一下,面试的时候大部分case能过就能通过。
我们可以再拓展一下,如果在上面的基础上存在重复元素会怎么样呢?感兴趣的同学可以研究一下LeetCode154这道题。
1.3 找缺失数字
public int missingNumber (int[] a) {int left = 0;int right = a.length-1;while(left < right){int mid = (left+right)/2;if(a[mid]==mid){left = mid+1;}else{right = mid;}}return left;
}
1.4 优化求平方根
剑指offer题目
实现函数 int sqrt(int x).计算并返回x的平方根这个题的思路是用最快的方式找到n*n=x的n。
如果整数没有平方根,一般采用向下取整的方式得到结果。采用折半进行比较的实现过程是:
public int sqrt (int x) {int l=1,r=x;while(l <= r){int mid = l + ((r - l)>>1);if(x/mid > mid){l = mid + 1;} else if(x / mid < mid){r = mid - 1;} else if(x/mid == mid){return mid;}}return r;
}
这种优化思想要记住,凡是在有序区间查找的场景,都可以用二分查找来优化速度。 如果有序区间是变化的,那就每次都针对这个变化的区间进行二分查找。这种题目在LeetCode中特别多的。
2 中序与搜索树原理
在前面我们发现很多题使用前序、后序或者层次遍历都可以解决,但几乎没有中序遍历的。这是因为中序与前后序相比有不一样的特征,例如中序可以和搜索树结合在一起,但是前后序则不行。
二叉搜索树是一个很简单的概念,但是想说清楚却不太容易。简单来说就是如果一棵二叉树是搜索树,则按照中序遍历其序列正好是一个递增序列。比较规范的定义是:
- 若它的左子树不空,则左子树上所有节点的值均小于它的根节点的值;
- 若它的右子树不空,则右子树上所有节点的值均大于它的根节点的值;
- 它的左、右子树也分别为二叉排序树。下面这两棵树一个中序序列是{3,6,9,10,14,16,19},一个是{3,6,9,10},因此都是搜索树:
搜索树的题目虽然也是用递归,但是与前后序有很大区别,主要是因为搜索树是有序的,就可以根据条件决定某些递归就不必执行了,这也称为“剪枝”。
2.1 二叉搜索树中搜索特定值
LeetCode 700.给定二叉搜索树(BST)的根节点和一个值。 你需要在BST中找到节点值等于给定值的节点。 返回以该节点为根的子树。 如果节点不存在,则返回 NULL。例如:
target为2,给定二叉搜索树:
4/ \2 7/ \1 3
你应该返回如下子树:
2 / \ 1 3
本题看起来很复杂,但是实现非常简单,递归:
- 如果根节点为空 root == null 或者根节点的值等于搜索值 val == root.val,返回根节点。
- 如果 val < root.val,进入根节点的左子树查找 searchBST(root.left, val)。
- 如果 val > root.val,进入根节点的右子树查找 searchBST(root.right, val)。
public TreeNode searchBST(TreeNode root, int val) {if (root == null || val == root.val) return root;return val < root.val ? searchBST(root.left, val) : searchBST(root.right, val);
}
如果采用迭代方式,也不复杂:
- 如果根节点不空 root != null 且根节点不是目的节点 val != root.val:
- 如果 val < root.val,进入根节点的左子树查找 root = root.left。
- 如果 val > root.val,进入根节点的右子树查找 root = root.right。
public TreeNode searchBST(TreeNode root, int val) {while (root != null && val != root.val)root = val < root.val ? root.left : root.right;return root;
}
2.2 验证二叉搜索树
LeetCode98.给你一个二叉树的根节点 root ,判断其是否是一个有效的二叉搜索树。
有效 二叉搜索树定义如下:
- 节点的左子树只包含 小于 当前节点的数。
- 节点的右子树只包含 大于 当前节点的数。
- 所有左子树和右子树自身必须也是二叉搜索树。
示例:
示例1:
输入:root = [2,1,3]
输出:true
示例2:
输入:root = [5,1,4,null,null,3,6]
输出:false
解释:根节点的值是 5 ,但是右子节点的值是 4 。
根据题目给出的性质,我们可以进一步知道二叉搜索树「中序遍历」得到的值构成的序列一定是升序的,在中序遍历的时候实时检查当前节点的值是否大于前一个中序遍历到的节点的值即可。
递归法:
long pre = Long.MIN_VALUE;
public boolean isValidBST(TreeNode root) {if (root == null) {return true;}// 如果左子树下某个元素不满足要求,则退出if (!isValidBST(root.left)) {return false;}// 访问当前节点:如果当前节点小于等于中序遍历的前一个节点,说明不满足BST,返回 false;否则继续遍历。if (root.val <= pre) {return false;}pre = root.val;// 访问右子树return isValidBST(root.right);
}
迭代法:
/*** 迭代实现** @param root* @return*/public static boolean isValidBST2(TreeNode root) {Deque<TreeNode> stack = new LinkedList<TreeNode>();double inorder = 0;while (!stack.isEmpty() || root != null) {while (root != null) {stack.push(root);root = root.left;}root = stack.pop();// 如果中序遍历得到的节点的值小于等于前一个 inorder,说明不是二叉搜索树if (root.val <= inorder) {return false;}inorder = root.val;root = root.right;}return true;}
如果这个题理解了,可以继续研究LeetCode530.二叉搜索树的最小绝对差和LeetCode501.二叉搜索树中的众数两个题。