diff --git "a/content/posts/algorithm/trick/\344\272\214\345\210\206\346\263\225.md" "b/content/posts/algorithm/trick/\344\272\214\345\210\206\346\263\225.md" index 96cdab0..28d92ec 100644 --- "a/content/posts/algorithm/trick/\344\272\214\345\210\206\346\263\225.md" +++ "b/content/posts/algorithm/trick/\344\272\214\345\210\206\346\263\225.md" @@ -1,5 +1,5 @@ --- -title: '二分法' +title: '二分搜索' date: 2023-01-03T11:24:54+08:00 --- @@ -11,22 +11,22 @@ date: 2023-01-03T11:24:54+08:00 ### 循环不变式 -- `while low <= high` +- `while low <= high` - 1. 终止条件,一定是 `low + 1 == high`,也即 low > right - 2. 意味着,整个区间 `[low..high]` 里的每个元素都被遍历过了 + 1. 终止条件,一定是 `low + 1 == high`,也即 low > right + 2. 意味着,整个区间 `[low..high]` 里的每个元素都被遍历过了 -- `while low < high` +- `while low < high` - 1. 终止条件,一定是 `low == high` - 2. 意味着,整个区间 `[low..high]` 里当 low == high 时的元素可能会没有被遍历到,需要打个补丁 + 1. 终止条件,一定是 `low == high` + 2. 意味着,整个区间 `[low..high]` 里当 low == high 时的元素可能会没有被遍历到,需要打个补丁 ### 可行解区间 对于二分搜索过程中的每一次循环,它的可行解区间都应当一致,结合对于循环不变式的理解: -- `[low...high]`,左右都闭区间,一般根据 mid 就能判断下一个搜索区间是 `low = mid + 1` 还是 `high = mid - 1` -- `[low...high)`,左闭右开区间,维持可行解区间,下一个搜索区间左边是 `low = mid + 1`,右边是 `high = mid` +- `[low...high]`,左右都闭区间,一般根据 mid 就能判断下一个搜索区间是 `low = mid + 1` 还是 `high = mid - 1` +- `[low...high)`,左闭右开区间,维持可行解区间,下一个搜索区间左边是 `low = mid + 1`,右边是 `high = mid` 因为数组的特性,常用的就是这两种区间。当然也有左右都开的区间 `(low...high)`,对应的循环不变式为 `while low + 1 < high`,不过比较少见。 @@ -50,13 +50,13 @@ date: 2023-01-03T11:24:54+08:00 var findMin = function (nums) { const n = nums.length let left = 0 - let right = n - 1 // 区间 [left, right] 如果用右开区间则不方便判断右值 + let right = n - 1 // 区间 [left, right] 如果用右开区间则不方便判断右值 // 循环不变量对应区间为 [left, right) - while(left < right) { + while (left < right) { const mid = left + ((right - left) >> 1) // 向下取整 - if(nums[mid] > nums[right]) { + if (nums[mid] > nums[right]) { left = mid + 1 // left 可能和mid相等 - }else { + } else { right = mid // right } } @@ -64,13 +64,13 @@ var findMin = function (nums) { } ``` -- 无重复、升序(单调递增)和 O(log n) 都在提示我们可以使用二分法来解决 -- 思考旋转,导致的结果 - 1. 左<中,中<右(min 在左,收缩右) - 2. 左<中,中>右(min 在右,收缩左) - 3. 左>中,中<右(min 在左,收缩右),所以只比较中与右的关系即可 -- 求的 mid,因为向下取整后偏向 left 一侧,所以 `left <= mid`、`mid < right`,为了保证循环不变量的恒等,left 将收缩为 mid + 1,right 将收缩为 mid -- 如果用 mid 和左比,那么 mid 需要先加一再向下取整,这样 mid 就更靠近右边的 right 了,然后先找出最大值,再向右偏一位即可 +- 无重复、升序(单调递增)和 O(log n) 都在提示我们可以使用二分法来解决 +- 思考旋转,导致的结果 + 1. 左<中,中<右(min 在左,收缩右) + 2. 左<中,中>右(min 在右,收缩左) + 3. 左>中,中<右(min 在左,收缩右),所以只比较中与右的关系即可 +- 求的 mid,因为向下取整后偏向 left 一侧,所以 `left <= mid`、`mid < right`,为了保证循环不变量的恒等,left 将收缩为 mid + 1,right 将收缩为 mid +- 如果用 mid 和左比,那么 mid 需要先加一再向下取整,这样 mid 就更靠近右边的 right 了,然后先找出最大值,再向右偏一位即可 第二种做法:`while(left <= right)` @@ -84,11 +84,11 @@ var findMin = function (nums) { let left = 0 let right = n - 1 // 循环不变式对应区间为 [left, right] - while(left <= right) { + while (left <= right) { const mid = left + ((right - left) >> 1) // 向下取整 - if(nums[mid] >= nums[right]) { + if (nums[mid] >= nums[right]) { left = mid + 1 - }else { + } else { right = mid } } @@ -107,26 +107,26 @@ var findMin = function (nums) { * @param {number[]} nums * @return {number} */ -var findMin = function(nums) { +var findMin = function (nums) { let left = 0 let right = nums.length - 1 - while(left < right) { - const mid = left + (right - left >> 1) - if(nums[mid] > nums[right]) { + while (left < right) { + const mid = left + ((right - left) >> 1) + if (nums[mid] > nums[right]) { left = mid + 1 - }else if(nums[mid] < nums[right]){ + } else if (nums[mid] < nums[right]) { right = mid - }else if (nums[mid] === nums[right]) { + } else if (nums[mid] === nums[right]) { // 注意 这里的收缩右边界 与 寻找一个数(这个数就是重复的数字) 的收缩右边界情况不一样 right-- } } return nums[left] -}; +} ``` -- 一堆数字中寻找目标数字(这个数字是重复的)的边界,是将 left 或 right 与 mid 相关,逼近关系是 `left = mid + 1` 和 `right = mid` -- 这道题是寻找最小值,最小值不一定是重复的,但是可能有重复的数字,所以逼近关系是 `right--` +- 一堆数字中寻找目标数字(这个数字是重复的)的边界,是将 left 或 right 与 mid 相关,逼近关系是 `left = mid + 1` 和 `right = mid` +- 这道题是寻找最小值,最小值不一定是重复的,但是可能有重复的数字,所以逼近关系是 `right--` ### [33. 搜索渲染排序数组](https://leetcode.cn/problems/search-in-rotated-sorted-array/) @@ -136,31 +136,32 @@ var findMin = function(nums) { * @param {number} target * @return {number} */ - // 毫无疑问 二分法, 就是需要注意二分过程中的细节 搜索区间和循环不变量 -var search = function(nums, target) { +// 毫无疑问 二分法, 就是需要注意二分过程中的细节 搜索区间和循环不变量 +var search = function (nums, target) { const n = nums.length - let l = 0, r = n - 1 + let l = 0, + r = n - 1 // 搜索区间是 [l..r] - while(l <= r) { - const mid = l + (r - l >> 1) - if(nums[mid] === target) return mid + while (l <= r) { + const mid = l + ((r - l) >> 1) + if (nums[mid] === target) return mid // 因为mid偏向左半边 所以 确定左半边比较容易 - if (nums[0] <= nums[mid] ) { - if(nums[l] <= target && target < nums[mid]) { + if (nums[0] <= nums[mid]) { + if (nums[l] <= target && target < nums[mid]) { r = mid - 1 - }else { + } else { l = mid + 1 } - }else { - if(nums[mid] < target && target <= nums[r]) { + } else { + if (nums[mid] < target && target <= nums[r]) { l = mid + 1 - }else { + } else { r = mid - 1 } } } return -1 -}; +} ``` > 这道题还真的只有用 小于等于才好做, 想一想为什么呢? @@ -175,49 +176,49 @@ var search = function(nums, target) { * @param {number} target * @return {number} */ -var search = function(nums, target) { +var search = function (nums, target) { let left = 0 // 其实这里初始化成 nums.length 也是能OK的,当收缩边界的时候就需要注意了, 见下一题 let right = nums.length - 1 // 循环不变量对应区间 [left, right) - while(left < right) { - const mid = left + (right - left >> 1) - if(nums[mid] === target) { + while (left < right) { + const mid = left + ((right - left) >> 1) + if (nums[mid] === target) { return mid - }else if(nums[mid] < target) { + } else if (nums[mid] < target) { left = mid + 1 - }else { - right = mid -1 + } else { + right = mid - 1 } } return nums[left] === target ? left : -1 -}; +} /** * @param {number[]} nums * @param {number} target * @return {number} */ -var search = function(nums, target) { +var search = function (nums, target) { let left = 0 let right = nums.length - 1 - while(left <= right) { - const mid = left + (right - left >> 1) - if(nums[mid] === target) { + while (left <= right) { + const mid = left + ((right - left) >> 1) + if (nums[mid] === target) { return mid - }else if(nums[mid] < target) { + } else if (nums[mid] < target) { left = mid + 1 - }else { - right = mid -1 + } else { + right = mid - 1 } } return -1 -}; +} ``` 如上,第一种和第二种仅仅是 循环不变式 发生了变化: -- 第一种:循环不变量对应区间为 `[left, right)`,结束条件是 `left === right`,如[2,2)就停止了,这意味着区间内可能会有元素没有遍历过,所以需要加一层兜底,比如数组只有一个数字的情况 -- 第二种:循环不变量对应区间为 `[left, right]`,结束条件是 `left === right + 1`,如[3,2]才会停止,这意味着,区间内所有元素都被搜索过了 +- 第一种:循环不变量对应区间为 `[left, right)`,结束条件是 `left === right`,如[2,2)就停止了,这意味着区间内可能会有元素没有遍历过,所以需要加一层兜底,比如数组只有一个数字的情况 +- 第二种:循环不变量对应区间为 `[left, right]`,结束条件是 `left === right + 1`,如[3,2]才会停止,这意味着,区间内所有元素都被搜索过了 ### [剑指 Offer 53 - I. 在排序数组中查找数字 I](https://leetcode.cn/problems/zai-pai-xu-shu-zu-zhong-cha-zhao-shu-zi-lcof/description/) @@ -229,39 +230,40 @@ var search = function(nums, target) { * @param {number} target * @return {number} */ -var search = function(nums, target) { +var search = function (nums, target) { // 寻找出左右边界,然后相减即是区间了 let left = 0 let right = nums.length - 1 // 实际上这里是 nums.length 也ok, 因为是求左边界 [left, right) 对右边界无所谓 - while(left < right) { - const mid = left + (right - left >> 1) - if(nums[mid] === target) { + while (left < right) { + const mid = left + ((right - left) >> 1) + if (nums[mid] === target) { right = mid // 寻找左边界, 按照[left, right), 所以 right = mid - }else if(nums[mid] < target) { + } else if (nums[mid] < target) { left = mid + 1 - }else { + } else { right = mid } } const l = left // 此处收缩右边界 right = mid 不会发生越界 left = 0 right = nums.length // 注意这里~~~ 因为是收缩左边界去找右边界了,由于循环不变量为[left,right), 所以初始区间右边界应该与之保持一致为 nums.length - while(left < right) { - const mid = left + (right - left >> 1) - if(nums[mid] === target) { + while (left < right) { + const mid = left + ((right - left) >> 1) + if (nums[mid] === target) { left = mid + 1 // 寻找右边界,收缩左边界,按照[left, right),left = mid + 1 - }else if(nums[mid] < target) { + } else if (nums[mid] < target) { left = mid + 1 - }else { + } else { right = mid } } const r = left - 1 < 0 ? 0 : left - 1 // mid是在搜索区间内的,而+1后的left不一定,结束条件是 left === right,由于是向右逼近,所以left应该-1 - if(l <= r && nums[l] === target && nums[r] === target) { // 防止压根没有 + if (l <= r && nums[l] === target && nums[r] === target) { + // 防止压根没有 return r - l + 1 } return 0 -}; +} ``` 怎么样,细节是不是拉满~~~ @@ -277,38 +279,38 @@ var search = function(nums, target) { * @param {number} target * @return {number[]} */ -var searchRange = function(nums, target) { +var searchRange = function (nums, target) { let left = 0 let right = nums.length - 1 - while(left < right) { - const mid = left + (right - left >> 1) - if(nums[mid] === target) { + while (left < right) { + const mid = left + ((right - left) >> 1) + if (nums[mid] === target) { right = mid - }else if(nums[mid] < target) { + } else if (nums[mid] < target) { left = mid + 1 - }else { + } else { right = mid } } const l = left left = 0 right = nums.length - while(left < right) { - const mid = left + (right - left >> 1) - if(nums[mid] === target) { + while (left < right) { + const mid = left + ((right - left) >> 1) + if (nums[mid] === target) { left = mid + 1 - }else if(nums[mid] < target) { + } else if (nums[mid] < target) { left = mid + 1 - }else { + } else { right = mid } } const r = left - 1 < 0 ? 0 : left - 1 - if(l <= r && nums[l] === target && nums[r] === target) { + if (l <= r && nums[l] === target && nums[r] === target) { return [l, r] } return [-1, -1] -}; +} ``` ### [35. 搜索插入位置](https://leetcode.cn/problems/search-insert-position/) @@ -321,22 +323,22 @@ var searchRange = function(nums, target) { * @param {number} target * @return {number} */ -var searchInsert = function(nums, target) { +var searchInsert = function (nums, target) { let left = 0 let right = nums.length // 这道题因为拆入位置可能为左边界或右边界,所以必须初始化为 nums.length 才行 // 如下,如果对右边界做了提前兜底,那么初始化right为nums.length-1也是没问题的, 这下更理解了吧? // let right = nums.length - 1 // if(target > nums[nums.length - 1]) return nums.length - while(left < right) { - const mid = left + (right - left >> 1) - if(nums[mid] >= target) { + while (left < right) { + const mid = left + ((right - left) >> 1) + if (nums[mid] >= target) { right = mid - }else if(nums[mid] < target){ + } else if (nums[mid] < target) { left = mid + 1 } } return right -}; +} ``` ## 衍生变形题 @@ -353,15 +355,15 @@ TODO 第一点,其实和二分没多大关系,就是在数组问题上,一定要对索引敏感,比如: -- 区间 `[a, b]`,那么 a~b (包含 a 和 b) 的长度为 `a - b + 1` -- 区间 `(a, b)`,那么 a~b (不包含 a 和 b) 的长度为 `a - b - 1` -- 区间 `[a, b) || (a, b]`,那么 a~b (不包含其中一个)的长度为 `a - b` +- 区间 `[a, b]`,那么 a~b (包含 a 和 b) 的长度为 `a - b + 1` +- 区间 `(a, b)`,那么 a~b (不包含 a 和 b) 的长度为 `a - b - 1` +- 区间 `[a, b) || (a, b]`,那么 a~b (不包含其中一个)的长度为 `a - b` 同样的,求索引也可以利用上述方法。 第二点,`while([condition])` 这里的 `condition` 是 `小于还是小于等于` 一定要搞清楚,有时候要从实际问题出发。 -- 如果是 `<`,结束条件是 left === right,没啥好争议的,结果取 left 或 right 都一样 -- 如果是 `<=`,结束条件是 left > right,注意 📢:这里就会有问题了,是 left 先越过 right 还是 right 先越过 left,这个是要搞清楚的,题目不同,也就不一样,但是会影响我们的结果,有时候会产生越界问题 +- 如果是 `<`,结束条件是 left === right,没啥好争议的,结果取 left 或 right 都一样 +- 如果是 `<=`,结束条件是 left > right,注意 📢:这里就会有问题了,是 left 先越过 right 还是 right 先越过 left,这个是要搞清楚的,题目不同,也就不一样,但是会影响我们的结果,有时候会产生越界问题 第三点,在二分搜索中,while 循环时的循环条件称为**循环不变式**,它对应的区间称之为**循环不变量**, **_就是在 while 寻找中每一次边界的处理都要坚持根据区间的定义来操作_**,这直接决定了我们的逼近策略,very important! diff --git "a/content/posts/algorithm/trick/\346\225\260\347\273\204-\346\273\221\345\212\250\347\252\227\345\217\243.md" "b/content/posts/algorithm/trick/\346\225\260\347\273\204-\346\273\221\345\212\250\347\252\227\345\217\243.md" index 6f845a2..67b7bc7 100644 --- "a/content/posts/algorithm/trick/\346\225\260\347\273\204-\346\273\221\345\212\250\347\252\227\345\217\243.md" +++ "b/content/posts/algorithm/trick/\346\225\260\347\273\204-\346\273\221\345\212\250\347\252\227\345\217\243.md" @@ -433,7 +433,7 @@ var numSubarrayProductLessThanK = function (nums, k) { --- -## 应用 +## 总结 滑动窗口算法适用于解决以下类型的问题: @@ -449,3 +449,5 @@ var numSubarrayProductLessThanK = function (nums, k) { 滑动窗口算法通常用于解决需要在数组或字符串上维护一个固定大小的窗口,并在窗口内执行特定操作或计算的问题。这种算法技术可以有效降低时间复杂度,通常为 `O(n)`,适用于处理大型数据集。 > 对于窗口大小的区间可以设计两端都开或者两端都闭的区间,但设计为左闭右开区间是最方便处理的。因为这样初始化 left = right = 0 时区间 [0, 0) 中没有元素,但只要让 right 向右移动(扩大)一位,区间 [0, 1) 就包含一个元素 0 了。如果你设置为两端都开的区间,那么让 right 向右移动一位后开区间 (0, 1) 仍然没有元素;如果你设置为两端都闭的区间,那么初始区间 [0, 0] 就包含了一个元素。这两种情况都会给边界处理带来不必要的麻烦。 + +> 滑动窗口可以分为固定窗口大小,非固定窗口大小。**存储窗口数据的数据类型可以为 hashMap,hashSet 或者简单的数字(比如前缀和前缀积)**