二分法总结

参看文章:二分法总结 | 万字长文带你看透二分查找

基本思路

区间分类

  • 对于 左闭右闭 的区间:
    • 循环条件是: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
    }
}
  • 变化:
    1. 循环条件变为 while(l < r),原因是当 l = r = mid && nums[mid] === target,对于 while(l <= r) ,将永远无法退出循环。
    2. 在寻找右边界时,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]
};

本质

  • 当一个数组的一部分满足一个性质,另一个部分不满足某个性质时,就可以应用二分法。

题目合集

按权重生成随机数

剑指 Offer II 071. 按权重生成随机数

给定一个正整数数组 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=0n1wiw[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 ,它原来是一个升序排列的数组,并按上述情形进行了多次旋转。请你找出并返回数组中的 最小元素 。

154. 寻找旋转排序数组中的最小值 II

和153区别:数组 nums 可能存在 重复 元素值

剑指 Offer II 069. 山峰数组的顶部

和上述题目的区别:

  • 上述题目两个分段都是递增的,只有一个低谷点;这道题上峰左右单调性对称,有一个峰顶点。
  • 上述题目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(旋转点,n1]
    • 或者:
      { 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[旋转点,n1]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[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 。

81. 搜索旋转排序数组 II

和33的区别:整数数组 nums按非降序排列,数组中的值不必互不相同

面试题 10.03. 搜索旋转数组

和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]即可。
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 束花所需的最少天数

1482. 制作 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 天内送达包裹的能力

1011. 在 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
}
狒狒吃香蕉

剑指 Offer II 073. 狒狒吃香蕉

狒狒喜欢吃香蕉。这里有 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=0n1speedpile[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值是否满足要求。
    • 借助二分查找最值。
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值