算法,是真心弱。我一直认为,做算法的或者说能轻松解答算法题的,一种人是脑子特别灵光。因为算法真的很烧脑;另一种就是刷题,要么能刷到全记住,要么能刷到孰能生巧。可惜,以上我都不是。
因为很重视这次面试,所以从CSDN众多网友的博客上了解到字节跳动面试的一般流程,最后基本就是一道算法题。无奈,自己算法基础太差,所以就准备按照网友的面试经历刷几道题。但算法这种东西真的是日积月累的事儿,临时抱佛脚真心没啥用。面试安排在下午五点,我在五点之前也只看了三道题:快排、树的非递归前序遍历和反转链表。当时的心情,就跟买彩票一样。结果也可想而知,没中。
言归正规,面试官出的是一道旋转数组相关的算法题,对应LeetCode上的33.搜索旋转排序数组,下面是这道题的描述
33. 搜索旋转排序数组
假设按照升序排序的数组在预先未知的某个点上进行了旋转。
( 例如,数组 [0,1,2,4,5,6,7] 可能变为 [4,5,6,7,0,1,2] )。
搜索一个给定的目标值,如果数组中存在这个目标值,则返回它的索引,否则返回 -1 。
你可以假设数组中不存在重复的元素。
你的算法时间复杂度必须是 O(log n) 级别。
示例 1:
输入: nums = [4,5,6,7,0,1,2], target = 0
输出: 4
示例 2:
输入: nums = [4,5,6,7,0,1,2], target = 3
输出: -1
来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/search-in-rotated-sorted-array
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。
不同点在于面试官出的题没有要求时间复杂度,可能是给了但我没注意到,也可能是面试官想验证下我的水平--虽然我没说,但实现最优解是你的义务。结果就是导致我实在想不出其他更优的解法后,直接采用了暴力解法,也就是遍历的方式。
在我们分析这道题如何解决之前,先需要聊一下二分查找。相信很多人都可以大概的说出二分查找的思路,但说的可能不够严谨,比如对于结束条件的描述或者对于中间位置定位的描述。下面引用百度百科关于二分查找的说明和JAVA代码示例
首先,假设表中元素是按升序排列,将表中间位置记录的关键字与查找关键字比较,如果两者相等,则查找成功;否则利用中间位置记录将表分成前、后两个子表,如果中间位置记录的关键字大于查找关键字,则进一步查找前一子表,否则进一步查找后一子表。重复以上过程,直到找到满足条件的记录,使查找成功,或直到子表不存在为止,此时查找不成功。
public static int binarySearch(Integer[] srcArray, int des) {
//定义初始最小、最大索引
int start = 0;
int end = srcArray.length - 1;
//确保不会出现重复查找,越界
while (start <= end) {
//计算出中间索引值
int middle = (end + start)>>>1 ;//防止溢出
if (des == srcArray[middle]) {
return middle;
//判断下限
} else if (des < srcArray[middle]) {
end = middle - 1;
//判断上限
} else {
start = middle + 1;
}
}
//若没有,则返回-1
return -1;
}
强烈建议大家还是认真的阅读说明和可以亲自手写一下二分查找。写过了,才能了解更多细节以及基于此的延伸和扩展。
关于二分查找有几个点(不是重点,也不是难点,就是个点而已)。第一个点关于while结束条件。根据说明我们知道二分查找的结束条件应该是子表不存在或者说子数组不存在。对应于我们的代码就是start>end,即是说当数组的起始下标大于结束下标时,数组是无意义的,也就可以认为是不存在。我们的代码里while的判断条件表示为真时继续执行,所以对应结束条件的非操作也就是start<=end。认真看这个表达式,你应该可以看出来,这个表达式可以分成两种情况,小于或等于。对于等于的情况其实是一种特殊情况,此时并不需要算中间值,因为中间值就是start或者end,我们可以直接用des==srcArray[start]或者des==srcArray[end]来判断目标值是否等于这个单个元素。当然如果你不把等于这种特殊情况拎出来,还是当做普通情况来处理,也没问题,代码上更统一一些。下面是把等于这个情况拎出来后的实现
public static int binarySearch(Integer[] srcArray, int des) {
//定义初始最小、最大索引
int start = 0;
int end = srcArray.length - 1;
//确保不会出现重复查找,越界
while (start < end) {
//计算出中间索引值
int middle = (end + start)>>>1 ;//防止溢出
if (des == srcArray[middle]) {
return middle;
//判断下限
} else if (des < srcArray[middle]) {
end = middle - 1;
//判断上限
} else {
start = middle + 1;
}
}
if (start == end && des = srcArray[start]) {
return middle;
}
//若没有,则返回-1
return -1;
}
第二个点是关于中间值的计算。按照直接的思维,判断两个数的中间值用(end+start)/2就可以了。但这种方式是有问题的,当end+start大于定义类型(在代码中是int,最大值是2147483647)能表示的最大值时,会发生溢出。也就是两个正数相加会得到一个负数。这样一方面是我们的中间值计算错误,一方面会引起数组越界(数组下标是0到length-1)。下面我们来验证下
int i = Integer.MAX_VALUE;
System.out.println("i:" + i);
System.out.println("i+1:" + i + 1);
System.out.println("i+i:" + (i + i));
System.out.println("(i+1)/2:" + (i + 1)/2);
System.out.println("i+1>>>1:" + (i + 1 >>> 1));
下面是执行结果
i:2147483647
i+1:21474836471
i+i:-2
(i+1)/2:-1073741824
i+1>>>1:1073741824
所以我们不可以用直观思维的求中间值的方法,百度百科给的例子也的确不是这种方法,而是(end+start)>>>1(括号加不加都不可以,加号和无符合右移的优先级是一样的,所以会顺序执行)。下面给出常用的两种求中间值的方法以及说明
1、int middle = start + (end - start) >> 1;
//此处的右移可以替换成除2的方式,因为不会有溢出的问题。但右移的效率更高
//这种计算方法其实是在直观思维的基础之上做了转换
//直观思维是相加除2直接求中间值,
//这种计算方式等于是根据起始值或结束值如何得到中间值
//这样描述似乎很乱,举个栗子就是假设起始值是2,结束值是6,我们可以很容易的求出中间值为4。但其实这个
//4是指针对起点0来说。对于起始值呢,这个4就是起始值加2得到的。这个加的2是通过结束值-起始值,然后
//除2得到的。
//根据java向下取整的计算方式,我们不能用结束值减去差值的方式得到中间值
2、int middle = (end + start) >>> 1;
//这种计算方式加法会溢出,但无符号右移保证了计算的正确性
//无符号右移后高位补0,不会得到负数
第三个点是目标值和中间值的比较逻辑。分三种情况,等于(直接返回),小于(指目标值<中间值,目标值在前半区或不存在)和大于(指目标值>中间值,目标值在后半区或不存在)。我们的核心逻辑就是如何判断目标值在哪个半区以及相应的修改上限和下限。这个思路一会儿会在旋转数组的问题上用到。
-----------------------------------------------------------------华丽的分割线,手敲的-------------------------------------------------------------------
说完二分查找,让我们回归这道旋转数组的面试题。
因为要求了时间复杂度是O(logn),所以自然想到要用二分查找。但有个问题,因为原始的升序数组已经经过了左旋或者右旋,我们二分之后,无法通过目标值与中间值的简单比较来判断目标值在前半区还是后半区。看示例1,目标值是0,我们先找到中间值7。按照二分的方法0<7,所以0应该在前半区。这显然是错误的。所以问题转化为我们如何判断目标值在哪个区(前提自然是你对二分查找熟悉,知道判断目标值在哪个分区是重点)?接下来,就是找规律的时刻了。我是没找到,经过面试官的提示,才发现--前半部分和后半部分至少一边是有序的。有序就是有范围,我们可以通过目标值和这个有序部分的最小值和最大值进行比较判断目标值是否在这个有序部分中。找到这个判断方法,我们就可以实现我们的判断。此时,有两种考虑方式,第一种我们基于二分查找的代码,将des 我们的想法就是找到所有目标值可能在前半部分的情况,见下面 1、当满足左侧有序,且目标值大于等于起始值,小于中间值时,在左侧 2、当满足右侧有序,且目标值大于结束值或小于中间值是,在左侧 下面是完整代码 第二种是情况导向,我们根据我们的分析将所有情况列出来,然后对应各类情况修改上限或下限。见下面 大功告成public static boolean inLeft(int des, int srcArray[middle]) {
return des < srcArray[middle]
}
public boolean inLeft(int[] nums, int target, int start, int end, int middle) {
if(nums[start]
public int search(int[] nums, int target) {
int start = 0;
int end = nums.length - 1;
while(start<=end) {
int middle = (end + start) >>> 1;
if(nums[middle] == target) {
return middle;
} else if(inLeft(nums, target, start, end, middle)) {
end = middle - 1;
} else {
start = middle + 1;
}
}
return -1;
}
public boolean inLeft(int[] nums, int target, int start, int end, int middle) {
if(nums[start]
// 先根据 nums[mid] 与 nums[lo] 的关系判断 mid 是在左段还是右段
if (nums[mid] >= nums[lo]) {
// 再判断 target 是在 mid 的左边还是右边,从而调整左右边界 lo 和 hi
if (target >= nums[lo] && target < nums[mid]) {
hi = mid - 1;
} else {
lo = mid + 1;
}
} else {
if (target > nums[mid] && target <= nums[hi]) {
lo = mid + 1;
} else {
hi = mid - 1;
}
}