本篇介绍也是基于王争老师的课程~~
数据结构与算法之美
做一些自己的总结和思考
1、你我都懂的二分思想
它是一种查找数据的算法,技巧。整体的过程简直就是和名字一模一样,大概意思就是,“数据分一半,然后再找”。
万能例子,猜数字~!
你心里想一个0~1000的数字。
我来猜,我每次说出一个数,你告我,我的数字和目标数字的大小关系。
第一下潜意识想,好像挺难猜的,1000个数字,怎么搞也要猜个百来下吧。其实学会这个技巧,最差劲👎也只需要10次,绝对猜出来!!!。
比如,你想的数字是126。
第一次猜:500 回答:你说的数字大了。
第二次猜:250 回答:你说的数字还是大了。
第三次猜:125 回答:你说的数字小了(哎呦接近了)
第四次猜:187 回答:大了
第五次猜:156 回答:大了
第六次猜:140 回答:还是大了
第七次猜:132 回答:还是大了
第八次猜:128 回答:还是大了(完了要猜到了)
第九次猜:126
不管你随便想哪个数字,10次内绝对猜出来。
为什么会这样的?为什么1001个数字,怎么就10次呢?
其实认真看猜的过程,还有看看这文章的题目,二分二分~~~!你可以感受到的。
游戏秘籍
往中间猜!!!!!!
- 每次都猜已知范围内的中间数就可以了
- 如果已知范围的所有个数和偶数,比如0~9,你就猜最中间的4(0,1,2,3,4,5,6,7,8,9)
- 如果范围内所有个数是奇数,比如5~8,你就取中间,向下取整,6
实际开发场景
比如,有1001条订单数据,按照订单的金额,从小到大排序了。我们需要寻找是否有金额是100元的数据。
订单数据存储在数组中。
常理直接遍历数组,金额如果有100,我们就返回true啦。最坏的情况下,我们可能要遍历整个数组,
时间复杂度是O(n).
但是我们用二分法就,每次只找数据的一半,直到我的数据范围只剩一个。
range:1001/2
range:1001/2/2
。。。
range:1
按照这个计算逻辑我们可以知道,最多2的10次方,因为2的10次方是1024.
所以时间复杂度是可以去到O(logn)
二分查找,效率O(logn)
代码逻辑
left指针为左区间,right为右区间,每次猜中间mid
mid = Math.floor((left + right)/2)- 循环的找已知区间的中间点,其实就是每次按照结果调整区间
2、 二分查找的实现
查找有序数组中某个值,数组不存在重复元素。
const arr = [1,3,4,5,7,...]
function bsearch(arr,num){
let left = 0
let right = arr.length - 1
while(left <= right){
let mid = left + Math.floor((right - left) / 2)
if(arr[mid] === num) return mid
else if (arr[mid] > num){
right = mid - 1
}else if (arr[mid] < num){
left = mid + 1
}
}
return -1
}
bsearch(arr, 3)
关键逻辑点
- 循环条件
left <= right
如果是left < right当[1,2],我们要找的数字是2的时候,mid = 0,arr[mid] < 2,left = 0 + 1,此时,left === right,退出了循环。就找不到下标是right的正确结果。 - 区间边界的更新
left和right指针的更新,right = mid - 1,left = mid + 1
可能出现会写成right = mid,left = mid的情况,如果是这样当left === right,就陷入了无限循环,无法退出while。
3、 二分查找的局限
- 二分查找依赖顺序的存储结构,数组
因为主要的核心逻辑是要找到中间下标的元素,所以数据结构要支持随机访问元素。数组是支持随机访问的,且时间复杂度是O(1) - 二分查找算法需要作用于有序的数据
如果数据无序,我们也无法通过比对大小,进行区间left、right的修改。 - 合适的数据量
少量的数据十位数左右,这时直接遍历其实也没差别。但是太多的数据量也会影响内存空间,因为数组结构,内存的申请就需要连续大量的空间
4、 二分查找变形
二分查找的基本实现,对你来说so easy啦。下面👇看看其他的变形逻辑也是很常见的。
下面变形的前提都是
存在重复元素的有序数组中
查找第一个出现给定值的元素
直接看代码再讲
function bsearch(arr,num){
let left = 0
let right = arr.length - 1
while(left <= right){
let mid = left + Math.floor((right - left) / 2)
if (arr[mid] > num){
right = mid - 1
}else if (arr[mid] < num){
left = mid + 1
}else {
if(mid === 0 || arr[mid - 1] < num) return mid
else right = mid - 1
}
}
return -1
}
这个逻辑和基本的二分法不同之处就是
if (arr[mid] === num){
if(mid === 0 || arr[mid - 1] < num) return mid
else right = mid - 1
}
当找到相同值时,不着急返回,而是再判断一下是不是第一个元素,或者之前的元素是不是比给定值小,如果是的话,证明就是第一个出现的元素。
查找最后一个出现给定值的元素
function bsearch(arr,num){
let left = 0
let right = arr.length - 1
while(left <= right){
let mid = left + Math.floor((right - left) / 2)
if (arr[mid] > num){
right = mid - 1
}else if (arr[mid] < num){
left = mid + 1
}else {
if(mid === arr.length - 1 || arr[mid + 1] > num) return mid
else left = mid + 1
}
}
return -1
}
查找第一个大于等于给定值的元素
function bsearch(arr,num){
let left = 0
let right = arr.length - 1
while(left <= right){
let mid = left + Math.floor((right - left) / 2)
if (arr[mid] >= num){
if(mid === 0 || arr[mid - 1] < num){
return mid
}
right = mid - 1
}else if (arr[mid] < num){
left = mid + 1
}
}
return -1
}
leetcode实战
凡是遇到有序的数据,可支持随机下标访问的,查找某个元素。这时候就可以想到二分法。
凡是遇到有序的数据,可支持随机下标访问的,查找某个元素。这时候就可以想到二分法。
凡是遇到有序的数据,可支持随机下标访问的,查找某个元素。这时候就可以想到二分法。
69. x 的平方根👈
问题:
实现 int sqrt(int x) 函数。 计算并返回 x 的平方根,其中 x 是非负整数。 由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。
示例:
输入: 4
输出: 2
输入: 8
输出: 2
说明: 8 的平方根是 2.82842...,
由于返回类型是整数,小数部分将被舍去。
思路:
拆解一下题意
- 找某个数字N
- 因为查找的值小数部分省略,则
N*N <= X X >= 0
数字,天然的就是一个有序的数据,那不就是从0~X,找出一个数字 N*N <= X。
先从简单的来。
如果是找N*N === X,找到返回,找不到返回 -1,就是最基础的二分法
var mySqrt = function (x) {
let [left, right] = [0, x]
while (left <= right) {
let mid = Math.floor((right - left) / 2) + left
if (mid * mid < x) {
left = mid + 1
} else if (mid * mid > x) {
right = mid - 1
} else {
return mid
}
}
return -1
};
但是如果没找到我们也要返回一个N*N <= X的N值。
那想想,退出循环前的最后逻辑肯定是left = mid + 1或right = mid - 1,关键点都是mid。
如果mid * mid > X, 退出循环。则最接近的值肯定是 mid - 1。
如果mid * mid < X, left > right,超过区间退出循环。那只能是mid了。
so,👇代码:
var mySqrt = function (x) {
// 找一个数字n, n*n最接近x,但不能大于
let [left, right] = [0, x]
let mid
while (left <= right) {
mid = Math.floor((right - left) / 2) + left
if (mid * mid < x) {
left = mid + 1
} else if (mid * mid > x) {
right = mid - 1
} else {
return mid
}
}
return mid * mid > x ? mid - 1 : mid
};
81. 搜索旋转排序数组 II👈
问题:
已知存在一个按非降序排列的整数数组 nums ,数组中的值不必互不相同。
在传递给函数之前,nums 在预先未知的某个下标 k(0 <= k < nums.length)上进行了 旋转 ,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]](下标 从 0 开始 计数)。例如, [0,1,2,4,4,4,5,6,6,7] 在下标 5 处经旋转后可能变为 [4,5,6,6,7,0,1,2,4,4] 。
给你 旋转后 的数组 nums 和一个整数 target ,请你编写一个函数来判断给定的目标值是否存在于数组中。如果 nums 中存在这个目标值 target ,则返回 true ,否则返回 false 。
示例:
输入:nums = [2,5,6,0,0,1,2], target = 0
输出:true
输入:nums = [2,5,6,0,0,1,2], target = 3
输出:false
提示:
1 <= nums.length <= 5000
-104 <= nums[i] <= 104
题目数据保证 nums 在预先未知的某个下标上进行了旋转
-104 <= target <= 104
思路:
一看有序数组,找元素。二分法呀~~!往下看看,这是其中一种解题思路
但是题目那么长,还是有下面两个疑惑的点的
- 题目将数组在某个点旋转了。这样数组就不是一个完整的有序数组了。
- 会有重复元素,但是题目却直接让我们找给定值是否存在?迷惑行为。这根本也不会影响二分法的逻辑,最基本二分法就可以实现。如果是重复元素,找第一个出现的,可能才会涉及二分法的变形。
那我们就想办法解决第一点疑惑就好了。nums = [2,5,6,0,0,1,2]
看看这个示例数组,其实如果我们先用二分法分成两个区间会变成什么?[2,5,6,0] [0,1,2],哎,这里面至少有一个数组是有序的,因为题目只是在某一个下标进行了“旋转”。这样其中一个有序的数组,我们就可以用二分法解决了。再看看左边的区间怎么搞。
那还不是一样,在二分呗。[2,5] [6,0]直到我们分割到区间只有一个两个元素时,直接判断返回就好。这里也回归复习一下递归的思想。
思路总计一下
代码:
var search = function (nums, target) {
// 二分数组,找出一段有序,一段无序
function mySplit(l, r) {
if(l > r)return false
let mid = Math.floor(l + (r - l) / 2)
if(nums[mid] === target) return true
// 尾部比头部大,肯定是有序区间
if (nums[l] < nums[mid]) {
// 有序
const res = find(l, mid - 1)
if (res) return res
} else {
// 无序,递归
const res = mySplit(l, mid - 1)
if (res) return res
}
if (nums[mid + 1] < nums[r]) {
const res = find(mid + 1, r)
if (res) return res
} else {
const res = mySplit(mid + 1, r)
if (res) return res
}
return false
}
function find(start, end) {
if (nums[start] > target || nums[end] < target) return false
while (start <= end) {
let mid2 = Math.floor(start + (end - start) / 2)
if (nums[mid2] === target) {
return true
}
if (nums[mid2] > target) end = mid2 - 1
if (nums[mid2] < target) start = mid2 + 1
}
return false
}
// 接收首尾下标进行分割
return mySplit(0, nums.length - 1)
};
74. 搜索二维矩阵
问题:
编写一个高效的算法来判断 m x n 矩阵中,是否存在一个目标值。该矩阵具有如下特性:
每行中的整数从左到右按升序排列。 每行的第一个整数大于前一行的最后一个整数。
示例:
输入:matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]], target = 3 输出:true
输入:matrix = [[1,3,5,7],[10,11,16,20],[23,30,34,60]], target = 13 输出:false
思路:
有序数组,找元素,做了几题了。这好不容易。
但是是个二维数组,这里有两种方式解题。
方式一
因为题意,每一行第一个数字大于上一行最后一个数字。这样直接将每行首尾链接,就是一个完整有序的数据了。然后直接基础二分法求解。
方式二
第一列,肯定是这一行最小的数字。所以我们先找第一列,找的最后一个小于等于目标值的那个。二分查找的变形,这没问题吧。然后目标值只可能出现在那一行,对那一行的数据进行二分查找就好了。
比如:我们要找17.
先对第一列找到最后一个小于等于目标值的那行。那就是第二行[10,11,16,20],然后二分查找。so easy
代码:
方式一
其实我们也不用真的创建一个数组,将首尾行拼接起来。其实我们只要能根据数组下标访问到对应的元素就好了。
mn的矩阵,所有元素的区间是0~mn-1.
- 对下标除每行的个数,向下取整就知道行数啦
- 对下标取余,就知道当前行的第几个元素
var searchMatrix = function (matrix, target) {
// 将每一行拼接起来(下一行开头拼接上一行结尾),形成有序数组
function bsearch(left, right) {
while (left <= right) {
let mid = left + Math.floor((right - left) / 2)
// 注意这里mid对应的元素怎么取的
let x = Math.floor(mid / matrix[0].length)
let y = mid % matrix[0].length
let element = matrix[x][y]
// -------------
if (element === target) return true
if (element > target) {
right = mid - 1
}
if (element < target) {
left = mid + 1
}
}
return false
}
// 传递
return bsearch(0, matrix.length * matrix[0].length -1)
};
方式二
var searchMatrix = function (matrix, target) {
function bsearch(left, right, sign, index) {
while (left <= right) {
let mid = left + Math.floor((right - left) / 2)
let element = sign === 'row' ? matrix[index][mid] : matrix[mid][index]
if (element === target) return true
if (element > target) {
right = mid - 1
}
if (element < target) {
if (sign === 'column' && (mid === matrix.length - 1 || matrix[mid + 1][0] > target)) {
return mid
}
left = mid + 1
}
}
return false
}
// 先找出第一列,最后一个小于等于指定元素的值的index
const columnIndex = bsearch(0, matrix.length - 1, 'column', 0)
if (typeof columnIndex === 'boolean') return columnIndex
// 然后找到那一行,对那一行再进行二分查找
if (columnIndex > -1) {
return bsearch(0, matrix[columnIndex].length - 1, 'row', columnIndex)
} else return false
};
4. 寻找两个正序数组的中位数
问题:
给定两个大小分别为 m 和 n 的正序(从小到大)数组 nums1 和 nums2。请你找出并返回这两个正序数组的 中位数 。
提示: nums1.length == m nums2.length == n 0 <= m <= 1000 0 <= n <= 1000 1 <= m + n <= 2000 -106 <= nums1[i], nums2[i] <= 106 进阶:你能设计一个时间复杂度为 O(log (m+n)) 的算法解决此问题吗?
示例:
输入:nums1 = [1,3], nums2 = [2]
输出:2.00000
解释:合并数组 = [1,2,3] ,中位数 2
输入:nums1 = [1,2], nums2 = [3,4]
输出:2.50000
解释:合并数组 = [1,2,3,4] ,中位数 (2 + 3) / 2 = 2.5
输入:nums1 = [0,0], nums2 = [0,0]
输出:0.00000
思路:
我们要找中位数,其实就是取中间数组下标的元素就好了。
只不过这里有两个有序数组,没有合成一个。
题目要求O(log (m+n)) 的算法,想想也不多。堆排序,归并排序,
堆排序明显不适合,归并排序呢?其实这里也不用归并排序了,直接遍历数组合并。严格来说时间复杂度O(m+n)了。不太符合题目要求。
符合O(log (m+n))的算法,二分肯定是可以考虑的。可以利用二分的思想,去找元素。直到找到需要的下标元素为止。如两个数字长度是m+n,中间数是第k个元素。
则我们就要找到最小的第k个元素,最小的第k个元素!
二分思想加入
我们每次在每个数组中找一半,则第一次我们找第k个最小的元素就好了。
那我们平摊到两个数组,每个数组找k/2个。则A[k/2 - 1]就是A数组找k/2个元素中最大的,因为数组有序嘛。
B[k/2 -1]就是B数组找出来最大的。
那两个数组都找出来了。怎么搞呢,我们怎么确定哪些数字是最小的0~k个呢?
直接比他们的最大值就好了
两个最大的对比。A[k/2 - 1]和B[k/2 -1]对比,
-
如果a>=b,
那肯定B[0]~B[k/2 -1]这几个数字肯定在范围内。那B[0]~B[k/2 -1]这几个数字找到了。目前找到k/2个元素,其中最大的是B[k/2 -1]。 -
如果b>a,
那就是A[0]~A[k/2 -1]这几个数字找到,最大的是A[k/2 -1]。
接着标记一下A数组找到了 k/2个或B数组找到了K/2个。下次直接从标记处找
下一轮继续找,我们要找的数字还剩几个?k-k/2 = k/2。
就继续平摊到两个数组,每个需要找k/4个元素出来,拿最大的比较。
直到把第k个元素或者k-1个元素找出来。我们就知道中间数了。
画图,从头走一遍!!
前提是两个数组是有序,升序的哈A:[1,2,3,4,5] B:[2,3,4]
-
一共有8个元素,中间数是第4、第5个数。那k=5
-
两个数组分别找出k/2 ~= 2个元素对比
-
比较找出元素的最后一个,3>2,所以A找到的两个元素肯定没问题,它们都是排在最小的第k个元素前面。这时候标记一下a1=2,再记录一下当前找到最大的元素是pastMax=2,因为每次找到的最大元素,你要对比和已收集的最大元素的关系,才知道谁排在最后,这样已收集元素等于k或k-1时,你就知道元素的具体值了。
-
继续下一轮,我们需要找k-2=3,则平摊到两个数组,每个数组需要往后找一个元素。
-
3>2,那B数组找到一个小的元素,b1=1,之前maxpast=2,现在找到的元素也是2,那谁排前后无所谓的。此时已收集3个数字[1,2,2]
-
下一轮,我们需要找k-3=2,则每个数组继续找一个元素。
-
3>=3,则我们选择把其中一个数组的元素加入已收集,就加B好了。则b1=2,pastMax易主为3。此时,我们已经找到4个元素拉!!,第4个元素小的元素就是刚才的pastMax,它是目前找到4个元素最大的,那当然是第4个小的元素了。
-
下一轮,k-2-2=1。这种情况下,应该也是每个数组找一个出来对比,小的就是我们要找到的元素。但是特殊情况其中一个数组已经搜寻完毕。我们直接取A数组的下一个元素就好了。那就是3
-
👌 return (3+3)/2 = 3
var findMedianSortedArrays = function (nums1, nums2) {
let finnal = Math.floor((nums1.length + nums2.length) / 2) + 1
let point1 = 0
let point2 = 0
let pastMax = -Infinity
const res = []
while (finnal > 0) {
let half = Math.max(Math.floor(finnal / 2), 1)
let num1, num2
// 边界情况,需要找的往后找到数字长度超过数组长度了
let more1 = Math.min(half, nums1.length - point1)
num1 = nums1[point1 + more1 - 1]
let more2 = Math.min(half, nums2.length - point2)
num2 = nums2[point2 + more2 - 1]
// 边界情况,其中一个数组已经找完了。
if (nums1.length === point1) {
// ~~~这里比较特别,如果其中一个找完了,也不能直接取另一个数组的元素,要和pastMax对比
// 谁才是目前找到元素最大的
if (finnal > 2 && nums2[point2 + finnal - 2] >= pastMax) {
res.push(nums2[point2 + finnal - 2], nums2[point2 + finnal - 1])
break
}
num1 = Infinity
}
if (nums2.length === point2) {
if (finnal > 2 && nums1[point1 + finnal - 2] >= pastMax) {
res.push(nums1[point1 + finnal - 2], nums1[point1 + finnal - 1])
break
}
num2 = Infinity
}
if (num1 >= num2) {
point2 += more2
finnal -= more2
pastMax = Math.max(num2, pastMax)
} else {
point1 += more1
finnal -= more1
pastMax = Math.max(num1, pastMax)
}
if (finnal === 1 || finnal === 0) {
res.push(pastMax)
}
}
if ((nums1.length + nums2.length) % 2 === 0) {
return (res[0] + res[1]) / 2
} else {
return res[1] || res[0]
}
};