朝花夕拾——二分

二分可以说是我编程最早接触的算法了,甚至不能说是一种具体的算法,而是一种解决问题的思路,当年打竞赛那会写得还比较多,大三以后就没怎么用过了。

二分是一个看上去很简单,但是实际一写很容易出错的算法,在我印象里是这样的,因为一些故意设计的题目很喜欢在它的边界判断给你挖坑,当年艾教说过,最好的办法是在 right - left <= 3 时停下来,然后用if自己判断,但我至今没遇到过这种二分题目。

今天刷leetcode每日一题时又遇到二分了,我直接加了一堆特判,结果发现自己想多了。


第一题 搜索插入位置

先来看题目:35. 搜索插入位置

给定一个排序数组和一个目标值,在数组中找到目标值,并返回其索引。如果目标值不存在于数组中,返回它将会被按顺序插入的位置。
你可以假设数组中无重复元素。

示例 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

废话不多说:

class Solution {
public:
    int searchInsert(vector<int>& nums, int target) {
        int l = 0, r = nums.size() - 1;
        int mid;
        while(l <= r) {
            mid = l + ((r - l) >> 1);
            if (nums[mid] == target) return mid;
            else {
                if (nums[mid] < target) {
                    l = mid + 1;
                } else {
                    r = mid - 1;
                }
            }
        }
        return l;
    }
};

这里要重点提一嘴,

  • 为什么不用特判开头和结尾?
  • 为什么直接返回left 等价于 如果target不存在且最终应该插入的位置。

我们来看二分的一个重要步骤——获得mid的值:

mid = ((r - l) / 2) + l
mid = (l + r) / 2

以上两种写法等价,在ACM中有的题目数据很刁钻,r + l有可能大于整形的最大值。因此需要特殊处理,其实在日常中完全没必要,这算是ACMer的常规操作了。

在用二分法逼近target时,要么从右向左逼近,或从左向右逼近:

  • 情况一:target在数组中,若是这种情况不需要判断边界,直接返回值left,因为left, right, mid必定会收缩到同一值。
  • 情况二:若target不在数组中,不管是向哪边逼近,一定会收缩到 l + 1 = r 的情况
    1. target 在 l r之间,即nums[l] < target && nums[r] > target这种情况下求mid,肯定差是一个 1 / 2 等价于0,即mid = l,即一定会触发l = mid + 1,即一定是lr靠近,若数组是升序,现在的left一定是待插入的位置,若是降序呢?可以自己想一想。
    2. target 在 l r左边,即nums[l] > target && nums[r] > target这种情况下求mid,肯定差是一个 1 / 2 等价于0,即mid = r,即一定会触发r = mid - 1,即一定是rl靠近,若数组是升序,现在的left一定是待插入的位置,即0
    3. target 在 l r右边,即nums[l] < target && nums[r] < target这种情况下求mid,肯定差是一个 1 / 2 等价于0,即mid = l+1,即一定会触发l = mid + 1,即一定是lr靠近,若数组是升序,现在的left一定是待插入的位置,即nums.size()

这相当于直接回答了第二个问题,间接回答了第一个问题。

写完了以后感觉废话挺多的,溜了


第二题 完全二叉树节点的个数

题目地址:222. 完全二叉树的节点个数

在这里插入图片描述
点评:好题,考察了位运算二分,而且还是考虑二进制特点的变式二分,由于题目的特殊性,存在最终两个区间在特殊数据的情况下,lowhigh指针永远无法合并。需要对二分边界特殊处理。

看代码:

/**
 * Definition for a binary tree node.
 * struct TreeNode {
 *     int val;
 *     TreeNode *left;
 *     TreeNode *right;
 *     TreeNode(int x) : val(x), left(NULL), right(NULL) {}
 * };
 */
class Solution {
public:
    int getTreeHeight(TreeNode* root) {
        int height = 0;
        while(root != NULL) {
            root = root->left;
            ++height;
        }
        return height;
    }

    int countNodes(TreeNode* root) {
        if (root == NULL) return 0;
        int height = getTreeHeight(root);
        int minSum = pow(2, height - 1);
        int maxSum = pow(2, height) - 1;
        int lo = minSum, hi = maxSum;
        while(lo < hi) {
            int mid = ((hi - lo + 1) >> 1) + lo;
            TreeNode* scan = root;
            int bit = 1 << (height - 2);
            int idx = height - 1;
            while(idx--) {
                if ((mid & bit) == 0) {
                    scan = scan->left;
                } else {
                    scan = scan->right;
                }
                bit >>= 1;
            }
            if (scan == NULL) {
                hi = mid - 1;
            } else {
                lo = mid;
            }
        }
        return lo;
    }
};

注意主函数while循环下的第一行,在上一题中我们提到,对于mid的求法在一般情况下为:

mid = (l + r) / 2       // 最简单的情况
mid = ((r - l) / 2) + l // 考虑溢出的情况

中间插一句解题思路:
由于完全二叉树的左子树的深度遍历一定可以求出完全二叉树的深度,深度求出来就可以知道此二叉树的结点数的范围,比如高度为4的完全二叉树,结点数一定在[8, 15]之间,因此我们就可以在8到15之间二分。

其实我一开始没想出来要怎么二分,因为要遍历的节点全部在最下层,我想着“如果要二分节点,由于树形结构的原因,我不可能直接去索引,肯定要从树根向下索引,但是我怎么知道每次向下搜索时,要走左还是右呢?”

这里很牛逼的一个点就出来了,由于完全二叉树的结点的值是从0到n依次递增的,每一个值得二进制的0和1刚好对应在深度遍历时应该向左走还是向右走。很神奇,不懂得可以自己画一下。

但是由于此题要在完全二叉树的最下层进行二分,最下层的每一个数翻译成二进制的01都代表了在向下搜索时的走向。而且我们要找的是最大的那个数,因此,比如当我们找到k时,low不能等于k + 1,因为k有可能就是那个最大的数,因此low要等于k,这就引出了一个二分边界的变形。

再次回顾这篇文章的时候发现讲得不是很清晰,问题的关键在于有没有target,对于有的题目来说是有target的,如果有,就可以用if (arr[mid] == target) {}来判断一下。但是比如此题在读完题后发现是没有target的,因为,题目中是找完全二叉树最下层中最大的不为null的值,你也不知道这个数是谁,无法用上面的if语句判断。

1 2 3 4 5 n n n n n n n n n n n

比如上面这种情况,当你的midn的时候,一定可以用high = mid - 1,因为,根据题意,你要找的是一个整数,那么整数肯定在mid的左边。

但是,比如说你现在的mid指向的就是5,但是你不知道这个数是不是最大的,有可能是最大的,所以你用low = mid + 1的时候就要很小心,因为你可能指向的就是你要找的数,如果你执行了这句话,low指向n,high也指向n,你就永远也找不到你要找的值了。所以,只能执行low = mid来逼近结果,不能加1。

... 4 5 n ...

所以就可能出现这种情况,low指向4,high指向5,mid = low + high / 2指向4。当出现这种情况时就发生了死循环。就是因为low = mid,永远无法逼近结果。所以我们这里需要让mid向高处靠,而不是朝低处靠。因此就出现这样特殊的mid计算。

因此这里二分的边界是这样的:

mid = (l + r) / 2       		 // 最简单的情况
mid = ((r - l) / 2) + l 		 // 考虑整形溢出的情况
mid = ((hi - lo + 1) / 2) + lo;  // mid向右偏1/2

是不是很巧妙?

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

题目:很简单,就是给你一个排好序的升序数组。让你在里面找一个target,这个target可能不止一个,也可能不存在,让你分别找出这个数组中最左侧和最右侧的target的位置。

思路:得益于上一题,我这个题闭着眼睛就做出来了,没看题解。我先直接二分,找到一个target的位置,只要找到就停下。那么这个target的位置记为kk有可能是最左侧的,也有可能是最右侧的,但大概率还是在中间的。我们对k左右侧分别进行两个二分,左侧的而分是对区间[0, k],右侧是[k, length - 1],注意这里为什么不是[0, k - 1]和[k + 1, length - 1]。正如我们之前所说的,这个k有可能本身在最左侧或最右侧。其实这里提这个不是很重要,主要在于下面对边界的处理,是不是和上一题很像?由于题目场景的原因,当向右扩展的时候low不能等于mid+1,只能等于mid,就是因为这个当前的位置,有可能已经是最右侧的target了。

mid = (l + r) / 2       		 // 最简单的情况
mid = ((r - l) / 2) + l 		 // 考虑整形溢出的情况
mid = ((hi - lo + 1) / 2) + lo; // 包裹下界的情况

有没有想到这里?是不是一模一样?但是为什么左侧不用?因为编程语言的特性,当整数截断的时候,自动向下取整,所以左侧就不用特别判断。但是这里如果我们不让他手动向上取整,就会在最后两个值中无限循环,因为
mid永远加不上去。

class Solution {
public:
    vector<int> searchRange(vector<int>& nums, int target) {
        int hi = nums.size() - 1;
        int lo = 0;
        bool exit = false;
        int idx_left = -1, idx_right = -1;
        while(!exit && lo <= hi) {
            int mid = (lo + hi) / 2;
            if (nums[mid] < target) {
                lo = mid + 1;
            } else if (nums[mid] > target) {
                hi = mid - 1;
            } else {
                int lo_left = 0;
                int hi_left = mid;
                while(lo_left < hi_left) {
                    int mid_left = (lo_left + hi_left) / 2;
                    if (nums[mid_left] != target) {
                        lo_left = mid_left + 1;
                    } else {
                    	// 没有 -1
                        hi_left = mid_left;
                    }
                }
                idx_left = lo_left;
                int lo_right = mid;
                int hi_right = nums.size() - 1;
                while(lo_right < hi_right) {
                	// 特殊处理
                    int mid_right = (hi_right - lo_right + 1) / 2 + lo_right;
                    if (nums[mid_right] != target) {
                        hi_right = mid_right - 1;
                    } else {
                    	// 没有 +1
                        lo_right = mid_right;
                    }
                }
                idx_right = lo_right;
                exit = true;
            }
        }
        return vector<int> {idx_left, idx_right}; 
    }
};
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值