算法训练Day1|LeetCode704 二分查找、LeetCode27 移除元素

123 阅读7分钟

704 二分查找

1.题目描述

给定一个 n 个元素有序的(升序)整型数组 nums 和一个目标值 target  ,写一个函数搜索 nums 中的 target,如果目标值存在返回下标,否则返回 -1

示例 1:

输入: nums = [-1,0,3,5,9,12], target = 9
输出: 4
解释: 9 出现在 nums 中并且下标为 4

示例 2:

输入: nums = [-1,0,3,5,9,12], target = 2
输出: -1
解释: 2 不存在 nums 中因此返回 -1

提示:

  1. 你可以假设 nums 中的所有元素是不重复的。
  2. n 将在 [1, 10000]之间。
  3. nums 的每个元素都将在 [-9999, 9999]之间。

2.算法分析

这是一道最“原生”的二分查找题目,直接使用二分查找算法从序列中搜索目标元素即可(之所以称为“原生”是因为这道题的算法相较C++的upper_boundlower_bound函数来说要简单一些)。

所谓二分,就是在每一次搜索中取搜索区间中间的元素,和目标值进行比较,根据中间元素和目标值的大小关系判断目标值是位于中间元素的左侧、右侧还是恰好就是中间元素。由于每次都会将搜索空间减半,因此时间复杂度为O(logn)O(log\,n)。同时,二分查找的算法逻辑决定了使用二分查找的前提是序列必须有序,例如本题中的序列是升序的,因此,若目标值小于中间元素,则目标值在左子区间,若目标值大于中间元素,则目标值在右子区间,若二者相同,则找到目标值。

实现二分查找算法的关键在于对搜索区间边界的处理,用left表示左边界,用right表示右边界,用middle表示中间位置,在算法中:

  1. while循环的条件是left < right还是left <= right
  2. nums[middle] > target时,目标值在左子区间,此时需要更新搜索区间的右边界,那么应该是right = middle还是right = middle - 1

实际上,边界的处理取决于搜索区间的表示方式,常见的有左闭右开和左闭右闭两种(个人推荐凡是涉及到区间的表示都统一使用左闭右开,在计算并区间和区间长度时都很方便)。

左闭右开区间:

在左闭右开的区间表示下,合法的搜索范围应是left < right,当left == right时,区间内没有元素,目标值不在序列中,while循环应退出。当nums[middle] > target时,由于middle已经被搜索过,因此下一次的搜索范围将不包含middle,下一次搜索中right恰好与middle相同,故令right = middle

左闭右闭区间:

在左闭右闭的区间表示下,合法的搜索范围应是left <= right,当left == right时,区间内仅有一个元素。当nums[middle] > target时,由于middle已经被搜索过,因此下一次的搜索范围将不包含middle,又注意到right也在搜索范围内,故令right = middle - 1

要注意的是,不论使用哪一种区间表示,每一次更新边界时都必须将middle去除,否则当搜索区间内只有一个元素时,就会陷入死循环。

综上所述,在实现二分查找算法时,需要注意两点:第一是保持搜索区间表示的一致性和合法性,第二是注意每次更新边界时都必须去除此次搜索区间的中间位置。

3.解题代码

左闭右开区间:

class Solution {
    public int search(int[] nums, int target) {
        int l = 0; // 初始左边界
        int r = nums.length; // 初始右边界
        while(l < r) {
            // 为了防止溢出,采取左边界+区间长度一半的方式确定中间位置
            int mid = l + (r - l) / 2; // 在左闭右开的表示下,区间长度是r - l
            if(nums[mid] > target) { // 目标值在左子区间
                r = mid; // 更新右边界(去除mid)
            }
            else if(nums[mid] < target) { // 目标值在右子区间
                l = mid + 1; // 更新左边界(去除mid)
            }
            else {
                return mid;
            }
        }
        return -1;
    }
}

左闭右闭区间:

class Solution {
    public int search(int[] nums, int target) {
        int l = 0; // 初始左边界
        int r = nums.length - 1; // 初始右边界
        while(l <= r) {
            int mid = l + (r - l + 1) / 2;// 在左闭右闭的表示下,区间长度是r - l + 1
            if(nums[mid] > target) {
                r = mid - 1;
            }
            else if(nums[mid] < target) {
                l = mid + 1;
            }
            else {
                return mid;
            }
        }
        return -1;
    }
}

27 移除元素

1.题目描述

给你一个数组 nums 和一个值 val,你需要 原地 移除所有数值等于 val 的元素。元素的顺序可能发生改变。然后返回 nums 中与 val 不同的元素的数量。

假设 nums 中不等于 val 的元素数量为 k,要通过此题,您需要执行以下操作:

  • 更改 nums 数组,使 nums 的前 k 个元素包含不等于 val 的元素。nums 的其余元素和 nums 的大小并不重要。
  • 返回 k

用户评测:

评测机将使用以下代码测试您的解决方案:

int[] nums = [...]; // 输入数组
int val = ...; // 要移除的值
int[] expectedNums = [...]; // 长度正确的预期答案。
                            // 它以不等于 val 的值排序。

int k = removeElement(nums, val); // 调用你的实现

assert k == expectedNums.length;
sort(nums, 0, k); // 排序 nums 的前 k 个元素
for (int i = 0; i < actualLength; i++) {
    assert nums[i] == expectedNums[i];
}

如果所有的断言都通过,你的解决方案将会 通过

示例 1:

输入: nums = [3,2,2,3], val = 3
输出: 2, nums = [2,2,_,_]
解释: 你的函数函数应该返回 k = 2, 并且 nums 中的前两个元素均为 2。
你在返回的 k 个元素之外留下了什么并不重要(因此它们并不计入评测)。

示例 2:

输入: nums = [0,1,2,2,3,0,4,2], val = 2
输出: 5, nums = [0,1,4,0,3,_,_,_]
解释: 你的函数应该返回 k = 5,并且 nums 中的前五个元素为 0,0,1,3,4。
注意这五个元素可以任意顺序返回。
你在返回的 k 个元素之外留下了什么并不重要(因此它们并不计入评测)。

提示:

  • 0 <= nums.length <= 100
  • 0 <= nums[i] <= 50
  • 0 <= val <= 100

2.算法分析

注意到序列的长度不超过100,因此暴力算法对该题目而言也是可行的,即遍历序列,若发现需要去除的元素,则从序列中去除该元素,显然,暴力算法的时间复杂度为O(n2)O(n^2)(现代计算机一秒大约能执行10810^8条基本语句,当nn取100时,基本语句的执行次数为10410^4,还是能在一秒内执行完毕的)。

可以看到,在暴力算法中,两层遍历分别执行了寻找新数组中的元素(即不等于val的元素)和移动元素两种操作。那么,我们可以在一次遍历中同时完成寻找新数组中的元素和移动元素的操作吗?答案是肯定的,只需要用双指针法即可:

  • fast指针用于寻找新数组中的元素;
  • slow指针用于表示元素在新数组中的索引。

由此,我们就可以用一次遍历就完成所有的操作,时间复杂度简化为O(n)O(n)

3.解题代码

暴力算法:

class Solution {
    public int removeElement(int[] nums, int val) {
        int k = nums.length;// 初始时假设所有元素都和val不同
        // 注意,由于会删除元素,因此序列的长度是会变化的
        for(int i = 0; i < k; i++) {
            if(nums[i] == val) {
                for(int j = i; j < k - 1; j++) {
                    nums[j] = nums[j + 1];
                }
                k--;
            }
            // 考虑到可能会出现连续几个val,因此需要检查一下在元素前移后,i处的元素是否是val
            // 若i处的元素是val,则下一次还需要从现在的i处开始遍历元素
            if(nums[i] == val) {
                i--;
            }
        }
        return k;
    }
}

双指针法:

class Solution {
    public int removeElement(int[] nums, int val) {
        int fast = 0;
        int slow = 0;
        for(; fast < nums.length; fast++) {
            if(nums[fast] != val) { // 判断是否是新数组中的元素
                nums[slow] = nums[fast];// 将元素放到slow指向的位置
                slow++;// 将slow向前移动一位
            }
        }
        return slow;
    }
}