文章目录
参看文章:二分法总结 | 万字长文带你看透二分查找
基本思路
区间分类
- 对于 左闭右闭 的区间:
- 循环条件是:left <= right
- 右边界的更新规则是:right = mid - 1
- 对于 左闭右开 的区间:
- 循环条件是:left < right
- 右边界的更新规则是:right = mid
- 这两种区间的划分在于右边界right是否可达,当右边界初始值为 nums.length - 1 时,右边界可达,属于左闭右闭;当右边界初始值为 nums.length 时,右边界不可达,属于左闭右开。
// [left, right], rightInit = nums.length - 1
function binarySearch (nums, target, left, right) {
while (left <= right) {
let mid = (left + right) >> 1
if (nums[mid] === target) {
return mid
} else if (nums[mid] > target) {
right = mid - 1
} else {
left = mid + 1
}
}
return -1
}
// [left, right), rightInit = nums.length
function binarySearch (nums, target, left, right) {
while (left < right) {
let mid = (left + right) >> 1
if (nums[mid] === target) {
return mid
} else if (nums[mid] > target) {
right = mid
} else {
left = mid + 1
}
}
return -1
}
边界二分
- 当数组中存在重复元素,或者寻找第一个大于等于、小于等于目标元素时,相当于寻找目标元素最大或最小的下标,即左右边界。
- 当 target[mid] = target时,应该继续找下去。
- 找左边界,最终的下标应该尽可能靠左,所以
- mid = Math.floor()
- r = mid
- 找右边界,最终的下标应该尽可能靠右,所以
- mid = Math.ceil()
- l = mid
- 找左边界,最终的下标应该尽可能靠左,所以
// 左边界
function binarySearchWithLeftBoundary (nums, target, left, right) {
while (left < right) {
// mid = Math.floor((left + right) / 2)
let mid = (left + right) >> 1
/*
if (nums[mid] === target) {
right = mid
} else if (nums[mid] > target) {
right = mid - 1
// 以下代码对这部分进行了合并
// 在这个分支慢了一步
}
*/
if (nums[mid] >= target) {
right = mid
} else {
left = mid + 1
}
}
return left
}
// 右边界
function binarySearchWithRightBoundary (nums, target, left, right) {
while (left < right) {
// mid = Math.ceil((left + right) / 2)
let mid = (left + right + 1) >> 1
/*
if (nums[mid] === target) {
left = mid
} else if (nums[mid] < target) {
left = mid + 1
// 以下代码对这部分进行了合并
// 在这个分支慢了一步
}
*/
if (nums[mid] <= target) {
left = mid
} else {
right = mid - 1
}
return left
}
}
- 变化:
- 循环条件变为 while(l < r),原因是当 l = r = mid && nums[mid] === target,对于 while(l <= r) ,将永远无法退出循环。
- 在寻找右边界时,mid的计算公式变为了 mid = (left + right + 1) >> 1,原因是如果 mid = (left + right) >> 1,当 right = left + 1 && nums[mid] <= target时,left将无法更新,将永远卡在这一步。
function binarySearch (nums, target, left, right, flag) {
const BoundaryEnum = {
'LEFT': 1,
'RIGHT': 2
}
while (left < right) {
let mid = flag === BoundaryEnum['LEFT'] ?
(left + right) >> 1 :
(left + right + 1) >> 1
if (nums[mid] === target) {
if (flag === BoundaryEnum['LEFT']) {
right = mid
} else {
left = mid
}
} else if (nums[mid] < target) {
left = mid + 1
} else {
right = mid - 1
}
}
return left
}
var searchRange = function(nums, target) {
const LeftIndex = binarySearch(nums, target, BoundaryEnum.LEFT)
const RightIndex = binarySearch(nums, target, BoundaryEnum.RIGHT)
if (LeftIndex <= RightIndex && RightIndex < nums.length && nums[LeftIndex] === target && nums[RightIndex] === target) {
return [LeftIndex, RightIndex]
}
return [-1, -1]
};
本质
- 当一个数组的一部分满足一个性质,另一个部分不满足某个性质时,就可以应用二分法。
题目合集
- 704. 二分查找
- 34. 在排序数组中查找元素的第一个和最后一个位置
- 35. 搜索插入位置
- 本题等价于在数组中寻找第一个大于或等于target,有一点需要注意,最终left有4种可能:
- 数组中存在target,则nums[left] = target
- 数组中没有target但有比target大的,则nums[left]是第一个比target大的
- 数组中全都比target小,则left = right = nums.length - 1
- 数组中全都比target大,则left = 0
- 本题等价于在数组中寻找第一个大于或等于target,有一点需要注意,最终left有4种可能:
- 367. 有效的完全平方数
- 剑指 Offer II 072. 求平方根
- 注意:对于非负整数num来说
- 如果num = 0, 则结果为0
- 其余结果在[1, num >> 1]中
- 74. 搜索二维矩阵
- 剑指 Offer II 070. 排序数组中只出现一次的数字
- 对于正常排序的数对而言,其下标满足:偶数下标是第一个数,奇数下标是第二个数。
- 如果mid = 偶数,判断nums[mid] === nums[mid+1];如果mid = 奇数,判断nums[mid] === nums[mid - 1];
- 如果不满足,则导致失序的单数一定在前面。
按权重生成随机数
给定一个正整数数组 w ,其中 w[i] 代表下标 i 的权重(下标从 0 开始),请写一个函数 pickIndex ,它可以随机地获取下标 i,选取下标 i 的概率与 w[i] 成正比。
官方题解:前缀和 + 二分
- 选取下标i的概率,可以用古典概型模型进行计算,用线段法进行模拟。
- 选取下标 i 的概率 p(i) =
w
[
i
]
∑
i
=
0
n
−
1
w
i
\frac{w[i]}{\sum^{n-1}_{i=0}w_i}
∑i=0n−1wiw[i]
- 选取下标 i 的概率 p(i) =
w
[
i
]
∑
i
=
0
n
−
1
w
i
\frac{w[i]}{\sum^{n-1}_{i=0}w_i}
∑i=0n−1wiw[i]
- 构造前缀和,对[1, total]进行分段。
- 随机生成数x,利用二分法,找到满足 pre[i - 1] + 1 <= x <= pre[i] 的 i
/**
* @param {number[]} w
*/
var Solution = function(w) {
// w的前缀和
this.preSum = [w[0]]
this.total = w.reduce((total, curValue) => {
total += curValue
this.preSum.push(total)
return total
})
};
/**
* @return {number}
*/
Solution.prototype.pickIndex = function() {
// 生成[1, totalSum]之间的随机数
const target = Math.random() * this.total + 1
let left = 0
let right = this.preSum.length
while (left <= right) {
let mid = (left + right) >> 1
/* w = [3, 1, 2, 4]
** pre = [3, 4, 6, 10]
** 把[1, 10]区间进行分段:[1, 3] [4, 4] [5, 6] [7, 10] -> [pre[i] - w[i] + 1, pre[i]]
** 对于随机数target, 要找到这样的区间满足:
** pre[i] - w[i] + 1 <= target <= pre[i]
** 并返回i
*/
let preMid = mid ? this.preSum[mid - 1] + 1 : 1
console.log(preMid, this.preSum[mid], target)
if (preMid <= target && target <= this.preSum[mid]) {
return mid
} else if (target > this.preSum[mid]){
left = mid + 1
} else {
right = mid - 1
}
}
return -1
};
旋转排序数组
题目合集
寻找旋转排序数组中的最小值
寻找旋转排序数组中的最小值(有重复)
山峰数组的顶部
寻找数组内的元素,该元素一定存在
153. 寻找旋转排序数组中的最小值已知一个长度为 n 的数组,预先按照升序排列,经由 1 到 n 次 旋转 后,得到输入数组。给你一个元素值 互不相同 的数组 nums ,它原来是一个升序排列的数组,并按上述情形进行了多次旋转。请你找出并返回数组中的 最小元素 。
和153区别:数组 nums 可能存在 重复 元素值
和上述题目的区别:
- 上述题目两个分段都是递增的,只有一个低谷点;这道题上峰左右单调性对称,有一个峰顶点。
- 上述题目mid需要和边界值比较;这道题mid和自己的相邻点比较
- 数组中元素互不相同:
- 旋转后的数组有以下性质,对任何旋转点都有:
{ a r r a y [ 0 ] < = a r r a y [ i ] ∀ i ∈ [ 0 , 旋转点 ] a r r a y [ 0 ] > a r r a y [ i ] ∀ i ∈ ( 旋转点 , n − 1 ] \left\{ \begin{array}{l} array[0] <= array[i] & \forall i \in [0, 旋转点] \\array[0] > array[i] & \forall i \in (旋转点, n - 1] \end{array} \right. {array[0]<=array[i]array[0]>array[i]∀i∈[0,旋转点]∀i∈(旋转点,n−1] - 或者:
{ a r r a y [ i ] > = a r r a y [ l a s t ] ∀ i ∈ [ 旋转点 , n − 1 ] a r r a y [ 0 ] < a r r a y [ i ] ∀ i ∈ [ 0 , 旋转点 ) \left\{ \begin{array}{l} array[i] >= array[last] & \forall i \in [旋转点, n-1] \\array[0] < array[i] & \forall i \in [0, 旋转点) \end{array} \right. {array[i]>=array[last]array[0]<array[i]∀i∈[旋转点,n−1]∀i∈[0,旋转点) - 以[5, 6, 1, 2, 3, 4]为例,旋转点是1,旋转点左边[5, 6]所有元素均大于等于5,右边[1, 2, 3, 4]所有元素均小于5。
- 所以,根据二分法的本质,一部分满足array[0] <= array[i],另一部分不满足,可以使用二分法求解。
/* 和left比较 */ var findMin = function(nums) { let left = 0 let right = nums.length - 1 while (left < right) { let mid = (left + right + 1) >> 1 if (nums[left] <= nums[mid]) { left = mid } else { right = mid - 1 } } return left + 1 < nums.length ? nums[left + 1] : nums[0] }; /* 和right比较 */ var findMin = function(nums) { let left = 0 let right = nums.length - 1 while (left < right) { let mid = (left + right) >> 1 if (nums[mid] <= nums[right]) { right = mid } else { left = mid + 1 } } return nums[left] };
- 旋转后的数组有以下性质,对任何旋转点都有:
- 含有重复元素:重点在于等号处如何处理
- 当 nums[mid] === nums[right]时,不能单纯的让right直接移到mid处,此时我们也不清楚mid在最小值的左边还是右边,只能让本次比较作废,缩小范围。
var findMin = function(nums) { let left = 0 let right = nums.length - 1 while (left < right) { let mid = (left + right) >> 1 if (nums[mid] === nums[right]) { right-- }else if (nums[mid] < nums[right]) { right = mid } else { left = mid + 1 } } return nums[left] };
- 当 nums[mid] === nums[right]时,不能单纯的让right直接移到mid处,此时我们也不清楚mid在最小值的左边还是右边,只能让本次比较作废,缩小范围。
- 山峰数组的顶部
- 在峰顶左边,数据严格递增;峰顶右边,数据严格递减。所以,只需要判断nums[mid]和nums[mid - 1]即可。
- 把等号情况可以并到左段中,使用右边界写法~
var peakIndexInMountainArray = function(arr) {
const len = arr.length
let left = 0
let right = len - 1
while (left < right) {
let mid = (left + right + 1) >> 1
if (arr[mid] > arr[mid - 1]) {
left = mid
} else {
right = mid - 1
}
}
return left
};
搜索旋转排序数组
搜索旋转排序数组(有重复)
搜索旋转排序数组(最小index)
在数组中寻找目标元素,不一定存在
33. 搜索旋转排序数组整数数组 nums 按升序排列,数组中的值 互不相同 。给你 旋转后 的数组 nums 和一个整数 target ,如果 nums 中存在这个目标值 target ,则返回它的下标,否则返回 -1 。
和33的区别:整数数组 nums按非降序排列,数组中的值不必互不相同。
和81的区别:如果存在target,需要返回最小的index
- 基于以上对旋转后数组的性质分析,当计算mid将数组一分为二后,其中存在一段A一定严格递增;另一段B会因为包含了旋转点出现断层。通过判断target是否在A[start]~A[end]范围内,就可以实现缩小查询范围。
- 无重复元素时:
var search = function(nums, target) { let left = 0 let right = nums.length - 1 while (left <= right) { let mid = (left + right) >> 1 if (nums[mid] === target) { return mid } else if (nums[mid] <= nums[right]) { // 右段有序 if (nums[mid] < target && target <= nums[right]) { left = mid + 1 } else { right = mid - 1 } } else { // 左段有序 if (nums[left] <= target && target < nums[mid]) { right = mid - 1 } else { left = mid + 1 } } } return -1 }
- 有重复元素时,当nums[mid] === nums[right]时,right–即可。【具体见上述对 154. 寻找旋转排序数组中的最小值 II 的解答】。
var search = function(nums, target) { let left = 0 let right = nums.length - 1 while (left <= right) { let mid = (left + right) >> 1 if (nums[mid] === target) { return true } else if (nums[mid] === nums[right]) { right-- } else if (nums[mid] < nums[right]) { // 右段有序 if (nums[mid] < target && target <= nums[right]) { left = mid + 1 } else { right = mid - 1 } } else { // 左段有序 if (nums[left] <= target && target < nums[mid]) { right = mid - 1 } else { left = mid + 1 } } } return false }
- 有重复元素且包含多个target时,返回最小index:
- [5, 5, 5, 1, 2, 3, 4, 5]:第一次循环中,nums[mid] = 1 < target <= nums[right],直接更新left = mid + 1,直接略过了最小index。
- 这是一种特殊情况,在左右边界处对称重复。在内部是不可能对称重复的。所以,只需要在一开始将right缩小为和nums[left] !== nums[right]即可。
- [5, 5, 5, 1, 2, 3, 4, 5]:第一次循环中,nums[mid] = 1 < target <= nums[right],直接更新left = mid + 1,直接略过了最小index。
var search = function(nums, target) {
let left = 0
let right = nums.length - 1
// 处理左右边界一致问题,让区间尽可能向左缩小
while (left <= right && nums[left] === nums[right]){
right--
}
while (left < right) {
let mid = (left + right) >> 1
if (nums[mid] === target) {
right = mid
} else if (nums[mid] === nums[right]) {
right--
} else if (nums[mid] < nums[right]) { // 右段有序
if (nums[mid] < target && target <= nums[right]) {
left = mid + 1
} else {
right = mid - 1
}
} else { // 左段有序
if (nums[left] <= target && target < nums[mid]) {
right = mid - 1
} else {
left = mid + 1
}
}
}
return left < nums.length && nums[left] === target ? left : -1
}
- 题目理解:
- 对于循转定义是【注意,数组 [a[0], a[1], a[2], …, a[n-1]] 旋转一次 的结果为数组 [a[n-1], a[0], a[1], a[2], …, a[n-2]] 】,那么,旋转k次的结果是【a[n-k],a[n-k+1], …, a[n-1], a[0], a[1], …, a[n-k-1]】。也就是说这里只是把原数组进行分段后,进行了平移;每次旋转操作对象都是初始数组,不存在原数组更新的问题。(这一点我一开始理解错了…)
- 所以,数组在最小值左右仍然保持非递减。在整个数据分布中,只会存在一个断点。
旋转数组题目总结
- 恢复二段性:处理左右边界对称相等问题
- 找到旋转点,依据性质 nums[i] <= nums[right]
- 找到target所在的有序段
- 在target所在的有序段内寻找 答案
check函数求最值
题目合集
制作 m 束花所需的最少天数
给你一个整数数组 bloomDay,以及两个整数 m 和 k 。
现需要制作 m 束花。制作花束时,需要使用花园中 相邻的 k 朵花 。
花园中有 n 朵花,第 i 朵花会在 bloomDay[i] 时盛开,恰好 可以用于 一束 花中。
请你返回从花园中摘 m 束花需要等待的最少的天数。如果不能摘到 m 束花则返回 -1 。
- 算法思路:
- bloomDay.min <= 最少天数 <= bloomDay.max,在[min, max]中取mid,看第mid天是否能制作花束(nums[mid] === target),如果能,缩小右边界(为了找到最小值)。
- 此时会发现:
- bloomDay本身是否有序不重要,[min, max]本身是升序的,在整个区间中探索mid。
- target本身可能不是一个值,而是一种规则。如在本题中,target = 完成花束制作。
- nums[mid] === target,可以视为check函数,即判断取mid值时,是否符合target规则。
/* 求bloomDay中天数最值 */
function getMaxAndMinInArray (list) {
let max = -Infinity
let min = Infinity
list.forEach(item => {
max = Math.max(item, max)
min = Math.min(item, min)
})
return {
max,
min
}
}
/* check函数,判断days天时,是否能按要求完成花束制作 */
function canBloom (bloomDay, days, m, k) {
let near = k
/* 注意:这里的for-of循环不能使用Array.forEach
因为forEach中无法通过return退出(终止循环)
使用forEach会导致永远return false
*/
for(const day of bloomDay) {
if (day <= days) {
near--
if (!near) {
m--
near = k
if (!m) {
return true
}
}
} else {
near = k
}
}
return false
}
/* 二分 */
var minDays = function(bloomDay, m, k) {
if (m * k > bloomDay.length) {
return -1
}
let {max, min} = getMaxAndMinInArray(bloomDay)
while (min < max) {
let mid = (min + max) >> 1
if (canBloom(bloomDay, mid, m, k)) {
max = mid
} else {
min = mid + 1
}
}
return max
};
在 D 天内送达包裹的能力
传送带上的包裹必须在 days 天内从一个港口运送到另一个港口。
传送带上的第 i 个包裹的重量为 weights[i]。每一天,我们都会按给出重量(weights)的顺序往传送带上装载包裹。我们装载的重量不会超过船的最大运载重量。
返回能在 days 天内将传送带上的所有包裹送达的船的最低运载能力。
- 和上述花束问题一样,在搜索空间[min = weights.max, max = sum(weights)]中计算mid,判断(check)最低运载能力为mid时是否能在days天内传送所有包裹(target)。
var shipWithinDays = function(weights, days) {
let {max, min} = getMaxAndMinValueOfShip(weights)
while (min < max) {
let mid = (min + max) >> 1
if (canThisWeightFit(weights, days, mid)) {
max = mid
} else {
min = mid + 1
}
}
return min
};
function getMaxAndMinValueOfShip (weights) {
let min = weights[0]
/* 注意:这里使用reduce((total, curValue) => { ... }),
* reduce只有一个参数,在第一次循环时,total = array[0], curValue = array[1]
* 此时,需要min初始化为weights[0]
* reduce(() => {}, 0)
* 如果指定了初始值,可以min可以初始化为0
*/
let max = weights.reduce((total, curValue) => {
min = Math.max(curValue, min)
return total + curValue
})
return {
max, min
}
}
function canThisWeightFit(weights, days, curWeight) {
let curLeft = curWeight
for(const weight of weights) {
if (curLeft >= weight) {
curLeft -= weight
} else {
curLeft = curWeight - weight
days--
}
}
return days >= 1
}
狒狒吃香蕉
狒狒喜欢吃香蕉。这里有 n 堆香蕉,第 i 堆中有 piles[i] 根香蕉。警卫已经离开了,将在 h 小时后回来。
狒狒可以决定她吃香蕉的速度 k (单位:根/小时)。每个小时,她将会选择一堆香蕉,从中吃掉 k 根。如果这堆香蕉少于 k 根,她将吃掉这堆的所有香蕉,然后这一小时内不会再吃更多的香蕉,下一个小时才会开始吃另一堆的香蕉。
狒狒喜欢慢慢吃,但仍然想在警卫回来前吃掉所有的香蕉。
返回她可以在 h 小时内吃掉所有香蕉的最小速度 k(k 为整数)。
- 最少每小时吃1根,最大速度是piles中的最大堆包含的香蕉树,因为如果速度在比这大也没有用,吃完该堆内所有香蕉后该小时内也不会吃其他香蕉了。
- 吃一堆香蕉的时间为:hoursForEachPile = ⌈ p i l e s p e e d ⌉ \lceil \frac{pile}{speed} \rceil ⌈speedpile⌉,吃完所有堆香蕉的时间是:totalHoursForPiles = ∑ i = 0 n − 1 ⌈ p i l e [ i ] s p e e d ⌉ \sum^{n-1}_{i=0}\lceil\frac{pile[i]}{speed}\rceil ∑i=0n−1⌈speedpile[i]⌉,如果totalHoursForPiles <= h,则满足条件。
/* check函数 */
function canEatAll (piles, h, speed) {
for(const pile of piles) {
h -= Math.ceil(pile / speed)
if (h < 0) {
return false
}
}
return true
}
最值问题小结
- 题目问最值,我们无法确定最值,那么就先假设一个值,看其能否完成。 然后借助二分查找寻找出最值。
- 步骤:
- 确定取值范围。
- 利用二分,计算mid,通过check函数判断该mid值是否满足要求。
- 借助二分查找最值。