代码随想录算法训练营Day37 | 738. 单调递增的数字 | 968. 监控二叉树 | 贪心算法

738. 单调递增的数字

题目链接 | 理论基础

从后向前

涉及到数字的改变,从前向后的遍历会导致无法修改当前位置之前的位数:如果 nums[i] > nums[i+1],应该执行 nums[i]--, nums[i+1] = 9,但这可能导致 nums[i] < nums[i-1](这个问题可能会向前延续)。然而由于还在向后遍历,无法处理向前的连锁反应(例如 332)。

从后向前是更好的思路:找到从前往后的第一个严格递减的位置,当前位置减一,并且从此往后全部设为 9。

class Solution:
    def monotoneIncreasingDigits(self, n: int) -> int:
        nums = list(str(n))

        decrease_flag = len(nums)
        for i in range(len(nums)-1, 0, -1):
            if int(nums[i-1]) > int(nums[i]):
                nums[i-1] = str(int(nums[i-1]) - 1)
                decrease_flag = i-1

        for j in range(decrease_flag+1, len(nums)):
            nums[j] = '9'

        return int("".join(nums))

从前向后(自己的解法)

从前向后遍历,寻找第一个 i 使得nums[i] > nums[i+1](出现严格递减的情况)。如果出现严格递减,则从当前的位置开始,向前搜索当前值出现的第一个位置 j。
最后,从前往后遍历构造新的数字:在 j 之前的数字 nums[:j] 保持不变,nums[j]减一,nums[j+1:] 全部设为 9。

class Solution:
    def monotoneIncreasingDigits(self, n: int) -> int:
        nums = []
        n_copy = n
        while n_copy != 0:
            nums.append(n_copy % 10)
            n_copy = n_copy // 10
        nums.reverse()
        
        curr_idx = 0
        while curr_idx < len(nums) - 1:
            if nums[curr_idx] > nums[curr_idx+1]:
                break
            curr_idx += 1

        if curr_idx == len(nums) - 1:
            return n
        
        while curr_idx > 0 and nums[curr_idx] == nums[curr_idx-1]:
            curr_idx -= 1
        result = 0
        for i in range(len(nums)):
            if i < curr_idx:
                result = result * 10 + nums[i]
            elif i == curr_idx:
                result = result * 10 + nums[i] - 1
            else:
                result = result * 10 + 9
        return result

968. 监控二叉树

题目链接 | 理论基础

非常复杂的题,二叉树+贪心,考验贪心算法的逻辑+二叉树的状态实现。

贪心逻辑

观察可以发现,题目的例子中所有的叶子节点上都没有摄像头,这暗示了本题的贪心逻辑:从叶子节点的父节点开始放置摄像头向上每隔两层放一个摄像头直到根节点

理论上,每个摄像头可以覆盖三层节点:当前节点、当前节点的父节点、当前节点的子节点。毫无疑问,在叶子节点上放置摄像头会浪费摄像头对下一层的覆盖。考虑到叶子节点在二叉树中的高占比,在叶子节点的父节点上放摄像头无疑是最节约资源的一种方法。

另外,如果当前节点已经放置了摄像头,其父节点已经被覆盖了。此时,父节点的父节点是不被覆盖的,最好的方法是在当前节点的父节点的父节点的父节点上放置摄像头,可以最大程度避免浪费每个摄像头的覆盖范围。

最后,这个逻辑在处理根节点的时候要分情况讨论。如果根节点的子节点的子节点上有摄像机,那么之前讨论的逻辑会期待在根节点的父节点上放置摄像机,但这无疑是不可能的。所以代码实现的时候,要专门讨论这个问题。

二叉树

贪心的逻辑说明这是一个自下而上的遍历,所以应该用“左右中”的后序遍历,因为当前节点的状态完全取决于两个子节点的状态。注意这里不是 dp 的状态转移,而是单纯的状态改变。

状态的传递无疑是依靠返回值。本题一共可以有三种状态

  • 无覆盖(0)
  • 有摄像头(1)
  • 有覆盖(2)

需要讨论的是,空节点的状态必须直接决定。如果选择“无覆盖”,就会强制要求空节点的父节点装摄像头,否则我们会认为这个(空)节点没有被覆盖(只知道返回值而不知道节点情况)。如果选择“有摄像头”,就会认为空节点的父节点肯定不需要摄像头。以上两种选择针对叶子节点尤其明显,肯定无法达城“叶子节点的父节点装摄像头”的要求。
如果选择“有覆盖”,则空节点的父节点的状态完全取决于另一个子节点,不造成任何影响(类似 True 在 conjunction 中的作用,对结果没有任何影响)。

状态改变的情况(有序)如下:

  1. 当前节点的两个子节点均是“有覆盖”的状态

    • 当前节点应该是“无覆盖”,因为下面没有摄像头,希望当前节点的父节点有摄像机
  2. 当前节点的两个字节点中至少有一个是“无覆盖”

    • 当前节点应该是“有摄像头”,否则那个“无覆盖”的节点就彻底不被覆盖了
  3. 当前节点的两个字节点中至少有一个是“有摄像头”(并且没有“无覆盖”)

    • 当前节点应该是“有覆盖”(摄像头的定义)

注意这个状态改变是有顺序要求的,如果一个字节点是“无覆盖”,一个是“有摄像头”,那当前节点也必须是“有摄像头”。

最后还要考虑到根节点的特殊性。如上所述的特殊情况,如果遍历结束时根节点是“无覆盖”,则“希望当前节点的父节点有摄像机”的情况没有被触发,所以要额外在根节点处放一个摄像头。
如上图所示,情况一的虚线是在执行当前操作后希望下一步会进行的,但无法保证当前节点还会有父节点。如果虚线不存在,就会触发这里的特殊情况。

题解

class Solution:
    def __init__(self):
        self.result = 0
    
    def backtrack(self, curr_node: Optional[TreeNode]) -> int:
        # 0: not covered
        # 1: has camera
        # 2: covered
        if curr_node == None:
            return 2
        
        left_status = self.backtrack(curr_node.left)
        right_status = self.backtrack(curr_node.right)

        # both children are covered, hope that curr_node's parent will have a camera
        if left_status == 2 and right_status == 2:
            return 0
        # exists one child that is not covered, must have a camera here
        if left_status == 0 or right_status == 0:
            self.result += 1
            return 1
        # exists one child that has a camera, and no child is not covered
        if left_status == 1 or right_status == 1:
            return 2
        
        # this should never be triggered
        return -1

    def minCameraCover(self, root: Optional[TreeNode]) -> int:
        if self.backtrack(root) == 0:
            self.result += 1
        return self.result

贪心总结

贪心算法完结

贪心算法很难总结规律。个人而言,如果一道题看上去非常符合 dp,但使用 dp 又显得太过复杂,那么就该考虑寻找贪心算法的解。

区间问题,非常经典的贪心(右边界 yyds)。

406.根据身高重建队列135.分发糖果 中,输入数据有多个维度的考量。这时一定要先解决一个维度,再解决另一个。在 135 中,解决维度的难点在于后处理的维度也要考虑前一个维度的结果。在 406 中,找到优先处理的维度更为重要。

此外,贪心算法也有几道难题:

  • 53. 最大子序和 巧妙地利用了数学的一些常识,从而获得了贪心性质;
  • 134. 加油站 和53非常像,但是在实现方面更困难,需要巧妙的抽象化才能意识到与53相同的本质;
  • 968. 监控二叉树 贪心(叶子节点)+二叉树(状态改变),非常精妙!
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值