Kiner算法刷题记(十):二分查找(手撕算法篇)

系列文章导引

  • 系列文章导引

开源项目

本系列所有文章都将会收录到GitHub中统一收藏与管理,欢迎ISSUEStar

GitHub传送门:Kiner算法算题记

69. x 的平方根

解题思路

这道题我们可以使用二分法来解决,由于我们知道:parseInt(x/2)^2 <= (√x)^2,因此我们可以知道,我们要查找的x的平方根的范围是在0~x/2的范围之内,只需要设置两个指针,并通过头尾指针取中间值的方式计算出mid,然后,让mid^2与x作比较便可。

代码演示

/*
 * @lc app=leetcode.cn id=69 lang=typescript
 *
 * [69] x 的平方根
 *
 * https://leetcode-cn.com/problems/sqrtx/description/
 *
 * algorithms
 * Easy (39.21%)
 * Likes:    688
 * Dislikes: 0
 * Total Accepted:    319.1K
 * Total Submissions: 812.5K
 * Testcase Example:  '4'
 *
 * 实现 int sqrt(int x) 函数。
 * 
 * 计算并返回 x 的平方根,其中 x 是非负整数。
 * 
 * 由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。
 * 
 * 示例 1:
 * 
 * 输入: 4
 * 输出: 2
 * 
 * 
 * 示例 2:
 * 
 * 输入: 8
 * 输出: 2
 * 说明: 8 的平方根是 2.82842..., 
 * 由于返回类型是整数,小数部分将被舍去。
 * 
 * 
 */

// @lc code=start

function mySqrt(x: number): number {
    // 特判:x为0时平方根为0,x为1时平方根为1
    if(x <= 1) {
        return x;
    }
    // 左游标为0
    let left = 0;
    // 右游标为parseInt(x/2)
    // 为什么右游标不是x而是parseInt(x/2)
    // 因为:parseInt(x/2)^2 <= (√x)^2
    // 因此,我们可以知道,√x最大就是x/2,即刚好parseInt(x/2)=√x
    let right = x >> 1;

    // 左右指针不相遇时进入循环
    while(left <= right) {
        // 根据左右游标求取中间值
        let mid = (left + right) >> 1;
        // 计算parseInt(x/2)^2
        let pow = mid * mid;
        // 如果刚好pow刚好等于x时,说明mid就是我们要找的平方根
        if(pow === x) return mid;
        // 如果pow比目标值小时,调整左指针为mid+1
        else if(pow < x) left = mid + 1;
        // 调整右指针
        else right = mid - 1;
    }
    // 由于题目让我们舍弃小数部分,因此想要求的是最接近x平方根的一个整数
    // 那么当跳出循环时,由于左右指针相遇,他们所指向的值就是最接近答案的整数
    return right;
};
// @lc code=end


上面是比较常规的二分解法,下面还有一个比较骚气的二分写法,一样能够通过所有案例运行成功,你知道这种写法能通过的原理么?

/*
 * @lc app=leetcode.cn id=69 lang=typescript
 *
 * [69] x 的平方根
 *
 * https://leetcode-cn.com/problems/sqrtx/description/
 *
 * algorithms
 * Easy (39.21%)
 * Likes:    688
 * Dislikes: 0
 * Total Accepted:    319.1K
 * Total Submissions: 812.5K
 * Testcase Example:  '4'
 *
 * 实现 int sqrt(int x) 函数。
 * 
 * 计算并返回 x 的平方根,其中 x 是非负整数。
 * 
 * 由于返回类型是整数,结果只保留整数的部分,小数部分将被舍去。
 * 
 * 示例 1:
 * 
 * 输入: 4
 * 输出: 2
 * 
 * 
 * 示例 2:
 * 
 * 输入: 8
 * 输出: 2
 * 说明: 8 的平方根是 2.82842..., 
 * 由于返回类型是整数,小数部分将被舍去。
 * 
 * 
 */

// @lc code=start
function mySqrt(x: number): number {
    let head = 0, tail = x, mid;
    tail+=1;
    for(let i=0;i<32;i++) {
        mid = head + ((tail - head) >> 1);
        if(mid*mid <= x) head = mid;
        else tail = mid;
    }
    return Math.abs(Math.floor(head));
};
// @lc code=end


其实,上面的这种解法,也是利用了题目不需要我们求取绝对精准的平方根解,而是求取归根平方根最接近的那个整数,我们知道,使用二分法,每次将会把区间分成原先的1/2,那么当我们固定运行32次之后,我们已经把区间缩小到了1/(2^32)了,已经是相当小的一个区间了,我们就可以认为这个区间内中点的值就是我们要求的x的平方根的最近的整数了,那么,我们是怎么确定固定循环的次数是32次的呢?因为我们整数的范围是-2^32~2^32,因此循环32次即可。咋样,这种解法是不是很骚。

35. 搜索插入位置

解题思路

这道题就需要我们之前讲过的二分查找0-1模型进行求解,除了依照题意,在目标值不在我们搜索区间中时,需要找到这个值要插入的位置外,其他操作都跟0-1模型一模一样。

代码演示

/*
 * @lc app=leetcode.cn id=35 lang=typescript
 *
 * [35] 搜索插入位置
 *
 * https://leetcode-cn.com/problems/search-insert-position/description/
 *
 * algorithms
 * Easy (47.00%)
 * Likes:    930
 * Dislikes: 0
 * Total Accepted:    390.1K
 * Total Submissions: 829.4K
 * Testcase Example:  '[1,3,5,6]\n5'
 *
 * 给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。
 * 
 * 你可以假设数组中无重复元素。
 * 
 * 示例 1:
 * 
 * 输入: [1,3,5,6], 5
 * 输出: 2
 * 
 * 
 * 示例 2:
 * 
 * 输入: [1,3,5,6], 2
 * 输出: 1
 * 
 * 
 * 示例 3:
 * 
 * 输入: [1,3,5,6], 7
 * 输出: 4
 * 
 * 
 * 示例 4:
 * 
 * 输入: [1,3,5,6], 0
 * 输出: 0
 * 
 * 
 */

// @lc code=start
function searchInsert(nums: number[], target: number): number {
    let min = 0, max = nums.length - 1;
    while(max - min > 3) {
        let mid = (min + max) >> 1;
        if(nums[mid]<target) min = mid + 1;
        else max = mid;
    }

    for(let i=min;i<=max;i++) {
        if(nums[i] >= target) return i;
    }
    // 注意,当我们经过上面的二分查找过程后发现目标值根本不在我们待查找的
    // 区间范围内时,依题意需要返回该值的插入位置,我们应该让他插入到最后一个元素
    // 的下一位
    return nums.length;
    
};
// @lc code=end


1. 两数之和

解题思路

这道题我们们之前借助哈希表使用空间换时间的方式实现过,那大家有没有想过,我们可以使用二分查找的方式来解决这个问题呢?首先,由于我们原数组是无序的,我们需要将数组排序才能使用二分法解决。在这里,我们使用了一个新的编程技巧,就是使用一个额外的下标数组,我们直接对下标按照原数组的值按照从小到大排序,这样就会回影响到我们的原数组了。然后,我们只需要循环一下下标数组,然后根据下标获取到对应的值(此时我们每次取出来的值相交于上一个值一定是递增的)。我们只需要通过二分查找当前位置之后数组中是否存在我们要招的目标值,如果有则返回。

代码演示

/*
 * @lc app=leetcode.cn id=1 lang=typescript
 *
 * [1] 两数之和
 *
 * https://leetcode-cn.com/problems/two-sum/description/
 *
 * algorithms
 * Easy (51.07%)
 * Likes:    11273
 * Dislikes: 0
 * Total Accepted:    2.1M
 * Total Submissions: 4.1M
 * Testcase Example:  '[2,7,11,15]\n9'
 *
 * 给定一个整数数组 nums 和一个整数目标值 target,请你在该数组中找出 和为目标值 target  的那 两个 整数,并返回它们的数组下标。
 * 
 * 你可以假设每种输入只会对应一个答案。但是,数组中同一个元素在答案里不能重复出现。
 * 
 * 你可以按任意顺序返回答案。
 * 
 * 
 * 
 * 示例 1:
 * 
 * 
 * 输入:nums = [2,7,11,15], target = 9
 * 输出:[0,1]
 * 解释:因为 nums[0] + nums[1] == 9 ,返回 [0, 1] 。
 * 
 * 
 * 示例 2:
 * 
 * 
 * 输入:nums = [3,2,4], target = 6
 * 输出:[1,2]
 * 
 * 
 * 示例 3:
 * 
 * 
 * 输入:nums = [3,3], target = 6
 * 输出:[0,1]
 * 
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * 2 
 * -10^9 
 * -10^9 
 * 只会存在一个有效答案
 * 
 * 
 * 进阶:你可以想出一个时间复杂度小于 O(n^2) 的算法吗?
 * 
 */

// @lc code=start

function findX(arr:number[], idxs: number[], head: number, x: number): number {
    let tail = idxs.length - 1, mid;
    while(head <= tail) {
        mid = (head + tail) >> 1;
        if(arr[idxs[mid]]===x) return mid;
        else if(arr[idxs[mid]]>x) tail = mid - 1;
        else head = mid + 1;
    }
    return -1;
}

function twoSum(nums: number[], target: number): number[] {
    // 定义一个下标数组
    const idxs: number[] = [];
    for(let i=0;i<nums.length;i++) idxs[i] = i;
    // 并对这个下标数组按照原数组值进行从小到大排序
    idxs.sort((a, b) => nums[a] - nums[b]);
    // 循环下标数组
    for(let i=0;i<idxs.length;i++) {
        // 原数组的值,由于我们是按照排过序的下标数组获取的值,因此我们每次拿出来
        // 的值一定是递增的
        const val = nums[idxs[i]];
        // 通过二分查找在后面的有序数组中查找到target-val
        const idx1 = findX(nums, idxs, i + 1, target - val);
        // 如果返回的索引不是-1,说明找到了答案,直接返回
        if(idx1!==-1) {
            return [idxs[i], idxs[idx1]];
        }
    }
    return [];
};
// @lc code=end


34. 在排序数组中查找元素的第一个和最后一个位置

解题思路

这道题中要找到第一个位置很简单,我们只需要使用二分查找的0-1模型便可以轻松搞定,这里就不再赘述了,那么最后一个位置我们要怎么求呢?其实我们可以换一种思维方式,我们为什么要找一个元素的最后一个位置呢?难道我们就不能找比这个元素大的第一个元素的位置么?只要找到了比目标元素大的第一个位置,那我们只需要减一就能找到目标元素的最后一个位置了。因此,要查找元素的最后一个位置,只需要先使用二分查找的0-1模型查找出大于目标元素的第一个位置,然后减一即可

代码演示

/*
 * @lc app=leetcode.cn id=34 lang=typescript
 *
 * [34] 在排序数组中查找元素的第一个和最后一个位置
 *
 * https://leetcode-cn.com/problems/find-first-and-last-position-of-element-in-sorted-array/description/
 *
 * algorithms
 * Medium (42.41%)
 * Likes:    1041
 * Dislikes: 0
 * Total Accepted:    263.3K
 * Total Submissions: 619.8K
 * Testcase Example:  '[5,7,7,8,8,10]\n8'
 *
 * 给定一个按照升序排列的整数数组 nums,和一个目标值 target。找出给定目标值在数组中的开始位置和结束位置。
 * 
 * 如果数组中不存在目标值 target,返回 [-1, -1]。
 * 
 * 进阶:
 * 
 * 
 * 你可以设计并实现时间复杂度为 O(log n) 的算法解决此问题吗?
 * 
 * 
 * 
 * 
 * 示例 1:
 * 
 * 
 * 输入:nums = [5,7,7,8,8,10], target = 8
 * 输出:[3,4]
 * 
 * 示例 2:
 * 
 * 
 * 输入:nums = [5,7,7,8,8,10], target = 6
 * 输出:[-1,-1]
 * 
 * 示例 3:
 * 
 * 
 * 输入:nums = [], target = 0
 * 输出:[-1,-1]
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * 0 
 * -10^9 
 * nums 是一个非递减数组
 * -10^9 
 * 
 * 
 */

// @lc code=start

function binarySearch(nums: number[], target: number): number {
    let min = 0, max = nums.length-1,mid;

    while(max - min > 3) {
        mid = (min + max) >> 1;
        if(nums[mid] >= target) max = mid;
        else min = mid + 1;
    }
    for(let i=min;i<=max;i++) {
        if(nums[i]>=target) return i;
    }
    return nums.length;
}

function searchRange(nums: number[], target: number): number[] {

    let res = [-1,-1];

    // 首先,使用二分算法的0-1模型查找第一个位置
    res[0] = binarySearch(nums, target);

    // 如果最终找到的元素第一个位置等于数组最后一位的下一位,或者是从原数组中根据索引找到的值不等于目标元素
    // 则直接返回没有找到
    if(res[0] === nums.length || nums[res[0]] !== target) {
        return [-1,-1];
    }

    // 如果找到了第一个元素,那么最后一个元素的位置就可以通过查找比这个元素大的第一个元素的位置,然后减一即可
    res[1] = binarySearch(nums, target+1) - 1;
    return res;

};
// @lc code=end


1658. 将 x 减到 0 的最小操作数

解题思路

依据题意,我们的操作数数组中是不存在负数的,根据这个条件,我们可以把问题转变为将数组从左到右某几个元素的和sumL与数组从右到左某几个元素的和sumR相加等于我们的目标数字,即:target = sumL(i) + sumR(j)。那么,我们现在的问题就是要如何求这个sumLsumR。我们之前讲过不止一次的前缀和的概念来求取区间和,即数组下标i对应的元素对应的就是从数组0~i元素之和。我们可以分别求出左右两边各自的前缀和,由于元素组中不存在负数,因此前缀和数组一定是单调递增的有序数组,所以我们用左边的每一个前缀和数字使用二分查找的0-1模型在右边前缀和数组中查找target - sumL(i)的数,找到之后,我们只需要累加左右两边元素的索引即可得出最小操作数。

代码演示

/*
 * @lc app=leetcode.cn id=1658 lang=typescript
 *
 * [1658] 将 x 减到 0 的最小操作数
 *
 * https://leetcode-cn.com/problems/minimum-operations-to-reduce-x-to-zero/description/
 *
 * algorithms
 * Medium (28.04%)
 * Likes:    63
 * Dislikes: 0
 * Total Accepted:    7K
 * Total Submissions: 24.5K
 * Testcase Example:  '[1,1,4,2,3]\n5'
 *
 * 给你一个整数数组 nums 和一个整数 x 。每一次操作时,你应当移除数组 nums 最左边或最右边的元素,然后从 x 中减去该元素的值。请注意,需要
 * 修改 数组以供接下来的操作使用。
 * 
 * 如果可以将 x 恰好 减到 0 ,返回 最小操作数 ;否则,返回 -1 。
 * 
 * 
 * 
 * 示例 1:
 * 
 * 
 * 输入:nums = [1,1,4,2,3], x = 5
 * 输出:2
 * 解释:最佳解决方案是移除后两个元素,将 x 减到 0 。
 * 
 * 
 * 示例 2:
 * 
 * 
 * 输入:nums = [5,6,7,8,9], x = 4
 * 输出:-1
 * 
 * 
 * 示例 3:
 * 
 * 
 * 输入:nums = [3,2,20,1,1,3], x = 10
 * 输出:5
 * 解释:最佳解决方案是移除后三个元素和前两个元素(总共 5 次操作),将 x 减到 0 。
 * 
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * 1 
 * 1 
 * 1 
 * 
 * 
 */

// @lc code=start

function binarySearch(arr: number[], x: number): number {
    let min = 0,max = arr.length-1,mid;
    while(max - min > 3) {
        mid = (min + max) >> 1;
        if(arr[mid]>=x) max = mid;
        else min = mid + 1;
    }
    for(let i=min;i<=max;i++) {
        if(arr[i] === x) return i;
    }
    return -1;
}

function minOperations(nums: number[], x: number): number {
    // 先求出从左到右和从右到左的元素前缀和
    let sumL: number[] = [], sumR: number[] = [];
    sumL[0] = sumR[0] = 0;
    for(let i=0;i<nums.length;i++) sumL[i+1] = sumL[i] + nums[i];
    for(let i=nums.length-1;i>=0;--i) sumR[nums.length - i] = sumR[nums.length - i - 1] + nums[i];

    let res = Number.MAX_SAFE_INTEGER;

    // 循环从左到右前缀和数组,并拿每一个数字到从右到左的前缀和数组中找出对应的解
    for(let i=0;i<sumL.length;i++) {
        // 拿左边每一个前缀和到从右到左的前缀和数组中查找目标解的索引
        const j = binarySearch(sumR, x - sumL[i]);
        // 处理边界条件,如果找不到目标解或者是两个索引相加,即操作数的数量已经操过了原数组的数量,说明i和j有了交叉重叠,
        // 这两种情况都是非法情况,直接略过
        if(j===-1 || i + j > nums.length) continue;
        // 如果没有出现上述情况的话,就说明找到了一组解,我们拿找到的解和上一组进行对比,取最小值
        res = Math.min(res, i+j);
    }
    // 如果循环结束,res仍然是初始的值的话,就说明没找到,返回-1
    if(res===Number.MAX_SAFE_INTEGER) res = -1;
    return res;

};
// @lc code=end


81. 搜索旋转排序数组 II

解题思路

代码演示

/*
 * @lc app=leetcode.cn id=81 lang=typescript
 *
 * [81] 搜索旋转排序数组 II
 *
 * https://leetcode-cn.com/problems/search-in-rotated-sorted-array-ii/description/
 *
 * algorithms
 * Medium (41.54%)
 * Likes:    441
 * Dislikes: 0
 * Total Accepted:    107.7K
 * Total Submissions: 258.9K
 * Testcase Example:  '[2,5,6,0,0,1,2]\n0'
 *
 * 已知存在一个按非降序排列的整数数组 nums ,数组中的值不必互不相同。
 * 
 * 在传递给函数之前,nums 在预先未知的某个下标 k(0 )上进行了 旋转 ,使数组变为 [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 。
 * 
 * 
 * 
 * 示例 1:
 * 
 * 
 * 输入:nums = [2,5,6,0,0,1,2], target = 0
 * 输出:true
 * 
 * 
 * 示例 2:
 * 
 * 
 * 输入:nums = [2,5,6,0,0,1,2], target = 3
 * 输出:false
 * 
 * 
 * 
 * 提示:
 * 
 * 
 * 1 
 * -10^4 
 * 题目数据保证 nums 在预先未知的某个下标上进行了旋转
 * -10^4 
 * 
 * 
 * 
 * 
 * 进阶:
 * 
 * 
 * 这是 搜索旋转排序数组 的延伸题目,本题中的 nums  可能包含重复元素。
 * 这会影响到程序的时间复杂度吗?会有怎样的影响,为什么?
 * 
 * 
 */

// @lc code=start
function search(nums: number[], target: number): boolean {
    // 如果数组两端找到了等于target的元素,就直接返回true
    if(nums[0] === target || nums[nums.length-1] === target) return true;
    let l = 0, r = nums.length - 1, mid, head, tail;
    // 需要把两端相同的元素排除掉
    while(l < nums.length && nums[l] === nums[0]) ++l;
    while(r >= 0 && nums[r] === nums[0]) --r;
    head = l, tail = r;


    //  以下为旋转之前数组单调性的示意图
    // y轴
    //  |            /|
    //  |          /  |
    //  |        /    |
    //  |      /      |
    //  |    /|       |
    //  |  /  |       |
    //  |/____|_______|________________________________ x轴  
    //  a     b       c

    //   以下为旋转之后数组单调性的示意图
    // y轴
    //  |      /|
    //  |    /  |
    //  |  /    |
    //  |/      |   
    //  |       |    /|
    //  |       |  /  |
    //  |_______|/____|_______________________________ x轴
    //  b       c     b
    //          a'

    // 根据上线旋转后数组的单调性示意图,我们需要把整个数组分成两大部分来处理
    // 我们需要先计算出数组中间位置的值nums[mid],看一下mid到底是落在bc端还是落在cb端
    // 确定了mid落在那一段之后,因为无论bc端还是cb端都是单调递增的,就可以直接使用二分查找法进行查找了

    while(l <= r) {
        mid = (l + r) >> 1;
        // 如果mid所指向的值刚好等于target,则返回true代表已找到
        if(nums[mid] === target) return true;
        // 如果中间值对应的值小于或等于数组末尾(去除头尾相同元素的数组)对应的值时,说明我们的
        // mid落到了后半段区间
        if(nums[mid] <= nums[tail]) {
            // 如果target在nums[mid]与nums[tail]之前,说明太小,将左指针移动到mid+1
            if(target <= nums[tail] && target > nums[mid]) l = mid + 1;
            // 否则说明太大,将右指针移动到mid-1
            else r = mid - 1;
        } else {// 否则我们的mid落到了前半段区间中
            // 如果target在nums[head]和nums[mid]之间,说明太大了,右指针移动到mid-1
            if(target >= nums[head] && target < nums[mid]) r = mid - 1;
            // 否则左指针移动到mid+1
            else l = mid  + 1;
        }
    }
    return false;

};
// @lc code=end


你可能感兴趣的:(知识梳理,前端基础,算法,二分法,二分查找)