二分搜索的应用和考察点:
二分搜索常见的应用场景:
1. 在有序序列中查找一个数
如果范围缩小到0也没有查找到这个数,则返回false。
整个过程的时间复杂度为o(log N).
并不一定非要在有序序列中才能用二分搜索,凡是可以在二分搜索过程中,可以留下一半,扔掉一半都可以用二分搜索。
二分搜索的思想并不难,难的是能否快速写出正确的代码。
1. 对于边界条件的考察以及代码实现的能力。
对中间划分点的逻辑判断,以及设置循环的终止条件,防止陷入死循环,
2. 二分搜索常见题目的变化
给定处理或查找的对象不同。(有重复值的数组和无重复值的数组)
判断条件不同。
要求返回的内容不同。
3. 在有序循环数组中进行二分搜索。
比如:
1,2,3,4,5循环之后可以是
2,3,4,5,1
3,4,5,1,2
4,5,1,2,3
5,1,2,3,4
4. 二分搜索的重要提醒!!
经典写法是:
mid=(left+right)/2
但是有些情况下爱left+right有可能会溢出;
所以更安全的写法是:
mid=left+(right-left)/2
案例1:
给定一个无序数组arr,已知任意相邻的两个元素,值都不重复,请返回任意一个局部最小的位置。
所谓局部最小的位置是指,如果arr[0]<arr[1],那么位置0就是一个局部最小的位置,如果arr[N-1](也就是arr最右的数)小于arr[N-2],那么位置N-1也是局部最小的位置。
如果位置i既不是最左位置也不是最右位置,那么只要满足arr[i]同时小于它左右两侧的值即(arr[i-1]和arr[i+1]),那么位置i也是一个局部最小的位置。
本题依然可以用二分搜索来实现。时间复杂度为o(logn).
这里可以利用任意相邻的两个元素值都不重复这个特点,用二分搜索的方式来实现。
1. arr为空或长度为0,返回-1,表示局部最位置不存在。
2. 如果arr长度为1,返回0;因为此时0是局部最小位置。
3. 如果arr长度大于1,可以按照下面的顺序进行:
如果arr0<arr1,那么可以直接返回位置0;
如果arrn-1<arrn-2,那么可以直接返回位置n-1;
如果上面都不满足,也就是两头的位置都不是局部最小位置,说明arr0>arr1,并且arrn-1>arrn-2.
因为数组中任意相邻的两个数,都是不相等的,所以,从最左的位置向右看,趋势是向下的;从最右的位置向左看,趋势也是向下的;
这时候可以查看中间位置middle处的值:
如果middle的值小于其左边数同时小于其右边数,说明middle本身就是一个局部最小位置,直接返回middle即可;
如果middle比右边的数小,比左边的数大,说明从middle向左看趋势也是向下的,所以知道middle左边一定存在局部最小的位置,所以可以对middle的左边继续做二分搜索;
如果middle比右边的数大,比左边的数小,说明从middle向右看趋势也是向下的,所以知道middle右边一定存在局部最小的位置,所以可以对middle的右边继续做二分搜索;
如果middle上的数既大于它的左边又大于它的右边,说明middle左右都可以进行二分搜索。
从上面的例子可知,二分搜索并不一定只能在有序的数组上面才能进行,只要在查找的时候发现可以查找一半,淘汰另外一半,这样的是都可以进行二分搜索的。
class Solution {
public:
int getLessIndex(vector<int> arr) {
int n=arr.size();
if(n==0)
return -1;
if(n==1)
return 0;
if(arr[0]<arr[1])
return 0;
if(arr[n-1]<arr[n-2])
return n-1;
return partition(arr,1,n-2);
}
int partition(vector<int>& arr,int left,int right){
if(left==right)
return left;
else{
int middle=left+(right-left)/2;
if(arr[middle]>=arr[middle-1])
return partition(arr,left,middle-1);//这里之所以为left到middle-1是因为middle位置的一定不是局部最小值了,所以不需要包括middle。
else if(arr[middle]>=arr[middle+1])
return partition(arr,middle+1,right);
else
return middle;
}
}
};
案例二:给定给一个有序数组arr,再给定一个整数num,请在arr中找到num这个数出现的最左边的位置。
变量res初始化为-1,如果最后仍然为-1,说明没有找到num这个数;
否则res就是最左边的num的位置;
因为数组有有可能存在相同的数,所以在查找的时候如果查找到了num,那么res就要更新称为查找到的num的位置;
不停的做二分搜索,一直到查找到最左边的num的位置;
class LeftMostAppearance {
public:
int findPos(vector<int> arr, int n, int num) {
// write code here
//这道题的解法有两种,分别为递归的方式和循环的方式!
int res=-1;
int right=n-1;
int left=0;
int middle;
while(left<=right){
middle=left+(right-left)/2;
if(arr[middle]>num)
right=middle-1;
else if(arr[middle]<num)
left=middle+1;
else{
res=middle;
right=middle-1;
}
}
return res;
}
};
这道题的解决方式可以采用循环,也可以采用递归的方式。但是需要注意的是,在进行递归的时候,一定更要注意边界条件,也就是每次调用递归函数的时候,left,right等参数的设置,非常重要!!
class LeftMostAppearance {
public:
int findPos(vector<int> arr, int n, int num) {
// write code here
//这道题的解法有两种,分别为递归的方式和循环的方式!
int right=n-1;
int left=0;
return getpos(arr,0,n-1,num);
}
int getpos(vector<int>& arr, int left,int right, int num)
{
if(left==right){
if(arr[left]==num)
return left;
else
return -1;
}else{
int middle=left+(right-left)/2;
if(arr[middle]>=num){
return getpos(arr,left,middle,num);//一定更要注意这里!!!因为这里面包含了arr[middle]==num的情况,所以左边一定要包含middle进去!!!!
}else{
return getpos(arr,middle+1,right,num);
}
}
}
};
上面就是采用递归方式实现的额!
案例三:给定给一个有序循环数组arr,返回arr中的最小值,有序循环数组是指,有序数组左边任意长度的部分放到右边去,右边的部分拿到左边来,比如数组[1,2,3,3,4],是有序循环数组,[4,1,2,3,3]也是。
循环有序数组有这么一个性质:以数组中间元素将循环有序数组划分为两部分,则一部分为一个严格有序数组,而另一部分为一个更小的循环有序数组。以循环递增数组为例,当中间元素大于首元素时,前半部分为严格递增数组,后半部分为循环递增数组;当中间元素小于首元素时,前半部分为循环递增数组;后半部分为严格递增数组。
长度不超过2的循环有序数组其实就是有序数组。
具体的解法为:
假设在数组L到R的范围上查找整个数组的最小值;
开始时,如果arr[L]<arr[R],那么这一段数组整体是有序的,说明最小值就是最左边的arr[L];
如果arr[L]>=arr[R]说明L和R之间包含了循环的部分,这时候查看L到R范围上处于中间位置M的数:
如果arr[L]>arr[M],说明最小值位于L和M之间的位置,此时就在左部分进行二分查找;
如果arr[M]>arr[R],说明最小值位置M和R之间的位置,此时就在右部分进行二分查找;
如果arr[L]>arr[M]不满足,同时arr[M]>arr[R]也不满足,那么就有
arr[L]<=arr[M],并且arr[M]<=arr[R],同时我们还知道arr[L]>=arr[R],所以根据这三个式子我们可以知道
arr[L]=arr[M]=arr[R],面对这种情况是无法用二分的方式来进行查找的。比如下面的例子:
2 2 ... ... 2 1 2 ... ... 2 2
对于上面的例子来说,无论1出现在哪个位置都是满足有序循环数组这个要求的,所以无法用二分查找的方式找到最小值的位置,只能用遍历的方式来寻找。
class MinValue {
public:
int getMin(vector<int> arr, int n) {
// write code here
return getmin(arr,0,n-1);
}
int getmin(vector<int>& arr,int left,int right){
if(arr[left]<arr[right])
return arr[left];
else{
int middle=left+(right-left)/2;
if(arr[left]>arr[middle])
return getmin(arr,left+1,middle);
else if(arr[right]<arr[middle])
return getmin(arr,middle+1,right);
else{
int i=left;
int min=arr[left];
while(i!=right){
i++;
if(arr[i]<min)
min=arr[i];
}
return min;
}
}
}
};
具体的代码如上面所示!
案例四:
给定一个有序数组arr,其中不含有重复元素,请找到满足arr[i]==i条件的最左的位置。如果所有位置上的数都不满足条件,返回-1.
,首先生成全局变量res=-1,记录最后一次arr[i]=i的位置;
然后判断arr[0]>n-1,如果是的话,就知道,因为整个数组中元素的值是有序的,所以0到n-1上所有的值都是大于n-1的,而arr中位置的取值只能是0到n-1,所以肯定不存在arr[i]=i这种情况,所以返回-1;
然后考察arr[n-1]<0,如果是的话,也返回-1;
然后如果arr[middle]>middle.因为数组既没有重复值,又是递增的,而递增的量最小为1,而位置的增量则严格是1,那么就考察arr[0]到arr[middle-1]这一范围就可以了、因为从middle到n这个范围内不会出现arr[i]=i的情况。
然后如果arr[middle]<middle,同理0到middle被排除,只能对middle+1到n这个范围继续二分。
如果arr[middle]==middle,则更新res=middle,然后因为我们要找的是最左位置,所以继续对0到middle-1上继续二分搜索。
class Find {
public:
int findPos(vector<int> arr, int n) {
// write code here
if(n==0||arr==NULL)
return -1;//这里一定要注意边界条件
if(arr[0]>n-1)
return -1;
if(arr[n-1]<0)
return -1;
return findposition(arr,0,n-1);
}
int findposition(vector<int>& arr,int left,int right){
int middle=left+(right-left)/2;
int res=INT_MAX;
if(left<=right){//递归终止条件,是非常重要的!!!
if(arr[middle]>middle)
return findposition(arr,left,middle-1);
else if(arr[middle]<middle)
return findposition(arr,middle+1,right);
else{
res=middle;
if(res<findposition(arr,left,middle-1))
return res;
else
return findposition(arr,left,middle-1);
}
}else{
return res;
}
}
};
上面的代码是利用递归编写的,同时用循环也可以实现:
import java.util.*; public class Find { public int findPos(int[] arr, int n) { if (arr == null || n == 0) { return -1; } int left = 0; int right = n - 1; int res = -1; while (left <= right) { if (arr[left] > left || arr[right] < right) { break; } int mid = (left + right) / 2; if (arr[mid] < mid) { left = mid + 1; } else if (arr[mid] > mid) { right = mid - 1; } else { res = mid; right = mid - 1; } } return res; } }
递归方式还有一种写法如下:
class
Find {
public
:
int
findPos(vector<
int
> arr,
int
n) {
// write code here
if
(n ==
0
|| arr[
0
]>n-
1
|| arr[n-
1
]<
0
)
return
-
1
;
int
pos = findPos(arr,
0
,n-
1
);
if
(pos != -
1
)
return
arr[pos];
else
return
pos;
}
int
findPos(vector<
int
> arr,
int
left,
int
right){
int
mid = left + (right - left) /
2
;
if
(right - left ==
0
){
if
(arr[left] == left)
return
left;
else
return
-
1
;
}
else
if
(arr[mid] >= mid)
return
findPos(arr,left,mid);
else
if
(arr[mid] < mid)
return
findPos(arr,mid+
1
,right);
else
return
-
1
;
}
};
案例五:给定一棵完全二叉树的头节点head,返回这棵树的节点个数。如果完全二叉树的节点数为N,请实现时间复杂度低于o(n)的解法。
可以借助于二分搜索的思想:
完全二叉树添加节点总是从最后一层添加,并且是从左到右依次添加;而删除过程是从最后一层开始从右到左依次删除。
首先找到二叉树最左的节点,目的是想统计完全二叉树的高度,根据完全二叉树的性质,最左的节点一定位于完全二叉树的最后一层上,假如计算出的二叉树的高度为H;
然后找到二叉树头节点的右子树的最左节点,如果这个左节点位于整个二叉树的最后一层,说明头节点的左子树一定是一个满的二叉树,因此可以根据满二叉树计算公式计算出左子树的节点个数,然后加上头节点,再利用递归的方式计算出头节点的右子树的节点个数;但是如果头节点的右子树的最左节点并不是位于整个二叉树最后一层,说明头节点的右子树也是一个满二叉树,但是他的高度比左子树少一层,因此可以利用满二叉树的公式计算出头节点右子树的节点个数再加上头节点,再对头节点左子树进行递归就可以了。
上面的方式比遍历方式快很多。遍历方式,时间复杂度为o(n).;而上面最优解的过程,时间复杂度为o(logH2),H代表整棵树二叉树的高度。
上面的算法的总体思想就是查看右子树的最左节点位于什么位置!!!
根据http://www.cnblogs.com/jmliao/p/6706333.html可知如下特性:
1. 二叉树的基本性质
- 二叉树的第i层至多有2i-1个结点(i>=1)
证明:(归纳法)
归纳基:i=1时,只有一个结点,2i-1=20=1;
归纳假设:假设对所有的i命题成立;
归纳证明:二叉树中每个结点最多有两个子树,则第i层的结点数为2*2i-2=2i-1.
- 深度为h的二叉树至多有2h-1个结点,一共1,... ,h层(h>=1)
证明:n=20+21+...+2h-1=2h-1.(等比数列)
- 对于一棵二叉树,若含有n0个叶子结点,n2个度为2的结点,则必存在关系式:n2=n0-1
证明:设二叉树的结点总数为n=n0+n1+n2;
二叉树上的分支总数为b=n1+2n2;
又b=n-1;
故:n2=n0-1.
- 具有n个结点的完全二叉树的深度为[log2n]+1.[]表示向下取整
证明:设完全二叉树的深度为k,则:2k-1<=n<2k
即k-1<=log2n<k
因为k只能取整数,所以k=[log2n]+1.这里的取整是向下取整!!!!这里的深度最小为1,此时只有一个根节点。
- 若对含n个结点的完全二叉树从上到下且从左至右进行1至n的编号,则对完全二叉树中任意一个编号为i的结点:
若i=1,则该结点是二叉树的根,无双亲,否则,编号为[i/2]的结点为其双亲结点;
若2i>n,则该结点无左孩子结点,否则,编号为2i的结点为其左孩子结点;
若2i+1>n,则该结点无右孩子结点,否则,编号为2i+1的结点为其右孩子结点。
证明:设完全二叉树中第i个结点的位置为第k层第j个结点,即i=2k-1-1+j;
则左孩子结点的索引为:l=2k-1+2(j-1)+1=2*(2k-1-1+j)=2*i;
左孩子结点的索引为:r=2k-1+2(j-1)+2=2*(2k-1-1+j)+1=2*i+1;
2. 二叉树中各种结点数目的计算
若一个完全二叉树的结点数目为n,求n0,n1,n2,数的高度h,左孩子结点数目nl和右孩子结点数目nr?
(n0为度为0的结点,n1为度为1的结点,n2为度为2的结点)
求解思路:
树的高度h=[log2n]+1;
因为是完全二叉树,所以1~h-1层全满;
前h-1层的结点数目为nh-1=2h-1-1;
第h层的叶子结点数目为nc1=n-nh-1(二叉树节点数目n减去前h-1层的节点数量);
第h-1层的叶子结点数目为nc2=2h-2-ceil(nc1/2);(ceil为向上取整)
所以n0=nc1+nc2;
n2=n0-1;
n1=n-n0-n2;
左孩子结点数目nl=n2+n1;
右孩子结点数目nr=n2;
3. 对于二叉树而言,有一个公式:度为2的结点个数等于度为0的结点个数减去1。 即:n(2)=n(0)-1。
在c++中,#include <math.h> log是e为底的对数 log10是10为底的对数 log2是2为底的对数 其他的用换底公式自己转换。
floor(x)返回的是小于或等于x的最大整数。ceil(x)返回的是大于x的最小整数。
1 << (h - l))这句代码的意思是将1向左移动h-1位,实际上就是pow(2,h-1)。
1.按位右移运算符(>>)
将数据除以2^n(2的n次方):低位补02.按位左移运算符(<<)
C++中的int型一般都占四个字节,范围在-2147483648~2147483647之间。其中,非负整数的二进制表示范围是0x00000000~0x7FFFFFFF;负数的二进制是以补码的形式表示的,可以用对应的正数补码加1来表示,由此可得其范围为0xFFFFFFFF(表示-1)~0x80000001(表示-2147483647),还有一个最小数-2147483648自然就表示为0x80000000。其中-2147483648是一个常量表达式,他不是一个常量,它是(-2147483637-1)。
将数据乘以2^n(2的n次方):正数右移最高位补0,负数右移最高位补1!
正整数的补码是其二进制表示,与原码相同。
求负整数的补码,将其对应正数二进制表示所有位取反(包括符号位,0变1,1变0)后加1。
数0的补码表示是 唯一的。[+0]补=[+0]反=[+0]原=00000000[ -0]补=11111111+1=00000000已知一个数的补码,求原码的操作其实就是对该补码再求补码:⑴如果补码的符号位为“0”,表示是一个正数,其原码就是补码。⑵如果补码的符号位为“1”,表示是一个负数,那么求给定的这个补码的补码就是要求的原码。【例4】已知一个补码为11111001,则原码是10000111(-7)。因为符号位为“1”,表示是一个负数,所以该位不变,仍为“1”。其余七位1111001取反后为0000110;再加1,所以是10000111。补码的绝对值
【例5】-65的补码是10111111若直接将10111111转换成十进制,发现结果并不是-65,而是191。事实上,在计算机内,如果是一个 二进制数,其最左边的位是1,则我们可以判定它为负数,并且是用补码表示。若要得到一个负二进制补码的数值,只要对补码全部取反并加1,就可得到其数值。如:二进制值:10111111(-65的补码)各位取反:01000000加1:01000001(+65)
这道题的代码实现如下所示:
/*
struct TreeNode {
int val;
struct TreeNode *left;
struct TreeNode *right;
TreeNode(int x) :
val(x), left(NULL), right(NULL) {
}
};*/
class CountNodes {
public:
int count(TreeNode* root) {
// write code here
int result=getcount(root);
return result;
}
int getcount(TreeNode* root){
//这里面节点总数量是未知的,树的深度可以通过寻找最左节点来获得,因为这个二叉树时完全二叉树
int result=1;
if(root->right==NULL&&root->left==NULL)
return result;
int deepth=getdeepth(root);
TreeNode* right=root->right;
int rightdeepth=getdeepth(right);
if(rightdeepth+1==deepth)//说明右子树的最左节点位于整颗树的最后一层,也就是说以root为根节点的左子树是满二叉树
{
//左子树节点数量加上一个根节点也就是加上1再加右子树节点数量
result=(1<<rightdeepth)+getcount(root->right);
}else{
//说明右子树的最左节点并没有位于整棵树的最后一层,所以右子树是一个满二叉树,但是深度是deepth-2
result=(1<<rightdeepth)+getcount(root->left);
}
return result;
}
int getdeepth(TreeNode* root)//计算以root为根节点的子树的深度,包括root节点在内。
{
TreeNode* left=root;
int count=0;
while(left!=NULL){
left=left->left;
count++;//count就是树的深度
}
return count;
}
};
案例六:如何更快的求一个整数k的N次方。如果两个整数相乘并得到结果的时间复杂度为O(1),得到整数k的N次方的过程请实现时间复杂度为O(logn)的方法。
典型做法是乘n-1次,时间复杂度为o(n);
这里,首先将N写成二进制的形式,这个二进制有多少位,就是用多少次的乘法,然后将二进制为1的部分的结果乘起来。这种做法的时间复杂度为o(logn).
这个方法的重点在于利用上次计算的结果,也就是说知道N的二进制形式之后,二进制后的N有多少位,就要对当前计算出的结果进行多少次的平方。假如说:
计算出的二进制N有10位,那么就要依次计算出k k平方 (k平方)平方 (k(平方)平方)平方.....知道计算了10次为止,然后将对应二进制位置为1的计算出的值
拿出来进行相乘就可以啦!!重点就是中间计算平方的过程,因为利用了上一个计算结果,所以减少了计算次数!!
class QuickPower {
public:
int getPower(int k, int N) {
// write code here
//首先求N的二进制形式!
int remainder;
long result=1;//注意这里一定是长整型long
long temp;
bool flag=false;
while(N!=0){
remainder=N%2;
N=N/2;
if(flag==false){
temp=k;
flag=true;
}
else{
temp=temp*temp%1000000007;//注意这里一定更要mod
}
if(remainder==1)
result=result*temp%1000000007; //注意这里一定更要mod
}
return (int)result;
}
};
需要注意的是上面求N的余数和商的过程,实际上可以通过&和>>操作符来实现,具体实现如下面所示:
class
QuickPower {
public
:
int
getPower(
int
k,
int
N) {
long
result =
1
;
long
temp = k;
int
m =
1000000007
;
for
(; N >
0
; N >>=
1
) {//这里通过>>操作将N除以2
if
((N&
1
) !=
0
) {//确定余数是否为1,这里将N想象成他的二进制形式
result *= temp;
}
temp = (temp * temp) % m;
result %= m;
}
return
(
int
)result;
}
};
注意上面的程序中之所以要mod 1000000007这个数是10位的整数,而int型数据最大是10位的整数,实际上通常有符号的long和int范围是一样的,无符号long和int范围也是一样的,但是也有例外,具体要看编译器
。C语言只规定short <= int <=long int。具体还得看具体的编译器,long int型并不能肯定就是64位的,很多时候long int和int表示的范围是一致的。
因此上面的程序中为了防止最后的计算结果为long long类型,超过int,从而导致溢出,设置了mod 1000000007。一个保证两倍不爆int的足够大的好记的质数。1000000007*2=2000000014
这个数是小于int的最大值的。
最小的十位质数是:1000000007
最大的十位质数是:9999999967
unsigned int 0~4294967295
int -2147483648~2147483647
unsigned long 0~4294967295
long -2147483648~2147483647
long long的最大值:9223372036854775807
long long的最小值:-9223372036854775808
unsigned long long的最大值:1844674407370955161
__int64的最大值:9223372036854775807
__int64的最小值:-9223372036854775808
unsigned __int64的最大值:18446744073709551615