代码随想录算法训练营第一天 | 704. 二分查找、27. 移除元素

704. 二分查找

leetcode:704. 二分查找
文档讲解:704. 二分查找
视频讲解:《代码随想录》算法视频公开课:手把手带你撕出正确的二分法

本题考查基础的二分查找,和暴力解法不同,根据数组的有序性,折半地缩小查找空间,达到快速查找的目的。

二分查找的思想很简单,但是在处理边界上容易出问题,归根结底是对搜索空间的定义不明确,导致查找时漏掉或者多加了元素。

常用的查找空间有两种,一种是左闭右闭,一种是左闭右开,现在分别对这两种实现方法做详细的实现。

左闭右闭

即区间内的元素全是未确定的元素,确定的元素不会在这里面(如要目标元素和一定不是目标的元素)。

class Solution {
    public int search(int[] nums, int target) {
        // 因为数组是有序的,所以小于最小值或大于最大值的元素一定不在数组中
        if (target < nums[0] || target > nums[nums.length - 1]) return -1;

        // 注意:左闭右闭区间,所以 right 起始为数组的最后一个元素
        int left = 0;
        int right = nums.length - 1;

        // 注意:这里是小于等于,因为 left == right 时,搜索区间只有一个元素,
        // 仍然是合法的搜索区间,可能这一个元素就是目标值
        while (left <= right) {
            // 获取中间值,避免大数相加溢出
            int mid = left + (right - left) / 2;

            if (nums[mid] == target) {
                return mid;
            } else if (nums[mid] < target) {
                // 目标值在右边,要舍弃另一半查找空间,因为 nums[mid] 已经确定不是目标值,
                // 所以 mid 不应该被包含在区间内,因此 left 直接取 mid 的下一个位置
                left = mid + 1;
            } else if (nums[mid] > target) {
                // 目标值在左边,要舍弃另一半查找空间,因为 nums[mid] 已经确定不是目标值,
                // 所以 mid 不应该被包含在区间内,因此 right 直接取 mid 的前一个位置
                right = mid - 1;
            }
        }

        return -1;
    }
}

左闭右开

区间的定义和左闭右闭是一样的,但是因为右边是开区间,所以对 right 指针的处理会有些不同。

class Solution {
    public int search(int[] nums, int target) {
        // 因为数组是有序的,所以小于最小值或大于最大值的元素一定不在数组中
        if (target < nums[0] || target > nums[nums.length - 1]) return -1;

        // 注意:左闭右开区间,right 指向的元素并不在区间内,因此 right 起始为 nums.length
        int left = 0;
        int right = nums.length;

        // 注意:这里是小于,因为是左闭右开区间,当 left == right 时,搜索区间为空,不应该再去循环了
        while (left < right) {
            // 获取中间值,避免大数相加溢出
            int mid = left + (right - left) / 2;

            if (nums[mid] == target) {
                return mid;
            } else if (nums[mid] < target) {
                // 和左闭右闭一样
                left = mid + 1;
            } else if (nums[mid] > target) {
                // 因为是左闭右开区间,right 指向的是不在区间内的元素,
                // 此时的 nums[mid] 刚确定完不是目标值,要从区间内排除,因此将 mid 赋值给 right
                // 且 mid - 1 可能是目标值,因此需要在区间内
                right = mid;
            }
        }

        return -1;
    }
}

27. 移除元素

leetcode:27. 移除元素
文档讲解:27. 移除元素
视频讲解:《代码随想录》算法视频公开课:数组中移除元素并不容易

题目要求在原数组上修改,所以是在考察数组的基本操作,因此只能采用覆盖的方法,因为数组的长度是固定的。

暴力解法

双层 for 循环遍历元素,如果是目标值,就将目标值后面的元素挨个往前覆盖,要注意循环结束的条件。

class Solution {
    public int removeElement(int[] nums, int val) {
        int len = nums.length;

        // 随着目标值不断的覆盖,遍历的终点在不断前移,
        // 因此要 "提前" 结束循环,不能再遍历到 nums.length
        for (int i = 0; i < len; i++) {
            if (nums[i] == val) {
                // 找到目标值,后面的元素往前覆盖,
                // 注意内层循环的结束条件是 len - 1,防止访问 j + 1 时越界
                for (int j = i; j < len - 1; j++) {
                    nums[j] = nums[j + 1];
                }
                // 重点:删掉一个元素,长度 - 1
                len--;
                // 重点:因为当前元素已经被后面的元素覆盖了,此时的 i 指向的是覆盖的那个元素,
                // 下一轮循环还要对它做判断,所以 i 回退一次
                i--;
            }
        }

        // 遍历结束,len 就是删除指定元素后剩余元素的数量
        return len;
    }
}

双指针

暴力解法的时间复杂度是 O(n^2),时间花费在了挨个移动元素上,所以优化的重点就在这里,能否一次遍历解决问题。

双指针的思路是定义两个指针,初始都指向数组的第一个元素,left 指针表示该指针之前的元素都是非目标值,right 指针用来遍历数组,如果当前遍历的元素不是目标值,则将nums[right] 赋值给 nums[left],且 left、right 同时后移,如果当前遍历的元素是目标值,则 left 停顿,right 继续后移,重复上述循环。

class Solution {
    public int removeElement(int[] nums, int val) {
        int left = 0, right = 0;

        while (right < nums.length) {
            // 重申一下,left 表示该位置之前的元素都是要留下的元素,不包含要删除的元素
            // 所以这里要处理不等于目标值的元素
            if (nums[right] != val) {
                nums[left] = nums[right];
                left++;
            }
            right++;
        }

        return left;
    }
}

这个双指针的思想很巧妙,一个指针用来控场(存储),一个指针用来遍历,符合条件的元素就扔进存储中,存储不断扩大,left 不断后移。

交流

交流一

群友关于 “移除数组元素” 提出了个问题,问该题使用暴力解法的时候,为什么解法 2 会通过,解法 1 会超时。如果是因为结束条件是 nums.size() 的话,那么第二层循环的 j 为什么可以用 nums.size(),而不是size?

// 解法 1
class Solution {
public:
    int removeElement(vector<int>& nums, int val) {
        int size = nums.size();
        // i 的结束条件是 nums.size()
        for (int i = 0; i < nums.size(); i++) {
            if (nums[i] == val) {
                // j 的结束条件也是 nums.size()
                for (int j = i; j < nums.size() - 1; j++) {
                    nums[j] = nums[j + 1];
                }
                size--;
                i--;
            }
        }
        return size;
    }
}
// 解法 2
class Solution {
public:
    int removeElement(vector<int>& nums, int val) {
        int size = nums.size();
        // i 的结束条件是 size
        for (int i = 0; i < size; i++) {
            if (nums[i] == val) {
                // j 的结束条件也是 nums.size()
                for (int j = i; j < nums.size() - 1; j++) {
                    nums[j] = nums[j + 1];
                }
                size--;
                i--;
            }
        }
        return size;
    }
}

我按他的解法画图执行了一下,发现当测试用例的最后一个元素是要删除的元素时代码会执行超时,看下图分析:

蓝色箭头表示外层循环,每一次发现目标值都会将其后面的元素挨个往前覆盖,然后后面多出一个“越界”的元素(即方块中的元素)。外层循环按理来说应该在 3 位置结束,但因为解法 1 中外层循环的结束条件是 i < nums.size(),因此外层会继续往后遍历,如图中下面蓝色的部分,但是后面的元素都是目标值,i 也一直会回退到当前目标值的位置,导致无限循环。导致无限循环的原因是测试用例的最后一个元素是要删除的元素,如果把最后的 2 去掉,解法 1 也能通过。

上面的分析能够解释解法 1 为什么超时,但还不是问题的重点。问题的重点是外层循环的结束条件不应该是 i < nums.size(),因为随着目标值的不断删除,数组的长度会“变小”,后面要被遍历的元素会不断前移,因此应该按照现有的数组长度结束循环。

那么为什么内层循环的结束条件可以是 j < nums.size() 呢?因为内层循环的作用不是遍历,而是将目标值后的元素挨个往前覆盖,即便是“越界”(超出当前数组的长度)了,也不会影响当前数组长度内的元素,因为它的作用就是覆盖。虽然没影响,但是毕竟还是多执行了几次无意义的覆盖,所以内层循环的结束条件也应该是 j < 当前数组的长度

总结,我们操作的是 当前数组长度范围内 的元素,删除目标值后,数组长度会变小,后面的元素也都往前移动了,那些 “越界” 的元素就不用管了,内外层循环不需要去遍历它们,因此循环结束条件都应该是当前数组的长度。

交流二

群友提了关于 “二分搜索” 的问题,问既然理论上四种区间都可以,为什么左开右闭区间会超时呢?代码如下:

class Solution {
    public int search(int[] nums, int target) {
        if (target < nums[0] || target > nums[nums.length - 1]) return -1;

        // 左开右闭区间
        int left = -1, right = nums.length - 1;

        while (left < right) {
            int mid = left + (right - left) / 2;

            if (nums[mid] == target) {
                return mid;
            } else if (nums[mid] < target) {
                left = mid;
            } else if (nums[mid] > target) {
                right = mid - 1;
            }
        }

        return -1;
    }
}

拿题目给的测试用例画图试了下:

看来是 mid 搞得鬼,左开右闭区间下,把 mid 的取值改为向上取整就可以了,还不清楚为什么。

int mid = (int)(Math.ceil((left + right) / 2.0));

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注