leetcode45跳跃游戏II

leetcode45 Jump Game II

Posted by BY on August 14, 2018

前言

动态规划一定能获得最优解,但是效率并不一定最高。

正文

问题来源

本问题来自leetcode上的45题。用go语言做的

问题描述

给定一个非负整数数组,你最初位于数组的第一个位置。
数组中的每个元素代表你在该位置可以跳跃的最大长度。
你的目标是使用最少的跳跃次数到达数组的最后一个位置。

示例 1:

给定一个链表,两两交换其中相邻的节点,并返回交换后的链表。
示例:

输入: [2,3,1,1,4]
输出: 2
解释: 跳到最后一个位置的最小跳跃数是 2。
     从下标为 0 跳到下标为 1 的位置,跳 1 步,然后跳 3 步到达数组的最后一个位置。

说明:
假设你总是可以到达数组的最后一个位置

分析:

动态规划思想很简单,就不仔细说了 本人写的Go代码如下:

const INT_MAX = int(^uint(0) >> 1)
func jump(nums []int) int {
        len := len(nums)
        if 0 == len {
                return 0
        }       
        dp := make([]int, len)
        j := len - 1 
        dp[j] = 0
        j--     
        for ; j >= 0; j-- {
                steps := nums[j]                 
                min := INT_MAX - 10000
                stepTime := 0
                for i := j+1; (i < len) && (stepTime < steps); i++ {
                        if dp[i] + 1 < min { 
                                min = dp[i] + 1
                        //      fmt.Printf("j:%d i: %d min: %d \n",j ,i, min)
                        }       
                        stepTime++
                }       
                dp[j] = min
        }       
        return dp[0]
}

最朴素的想法是,对于每个位置,挨个尝试一遍不同的跳法,这样总能找到最优解,最坏情况下A[i]=n,那么时间复杂度为O(n^2)。显然会超时,所以在这个朴素的算法上改进。

如果用动态规划求解,考虑如何划分子问题。一个很自然的想法是将起跳点为子问题边界,令p[i]表示从第i个位置起跳,到终点所需最小步数,则代价函数为p[i] = 1 + min{p[i + j]}, (1 ≤ j ≤ A[i])。再分析一下时间复杂度,对于每个p[i]需要循环A[i]次,一共有n个p[i],所以最坏情况下时间复杂度为O(n^2),可以看出,动态规划实际上跟朴素算法在时间复杂度上没有区别。

考虑能否使用贪心法,对于每个位置,尽量往最远的地方跳。显然这样只考虑了1跳的情况,比较”短视”,很容易举出反例。

但是,可以把贪心法改进一下,考虑2跳的情况,即贪心的条件是:当前跳跃+下次跳跃的总距离最远。那这样是不是就可以了呢?答案是可以的。反证法:

假设当前由贪心法选择的方案不是最优解,那么一定存在一个最优跳法,它所需要的总步数更少,并且它至少在某一个位置下,当前一跳和下一跳(共2次跳跃)跳的不是最远的。考虑那个位置,如果按照贪心策略,那么得到的跳法(2次跳跃)的活动范围完全覆盖了最优解选择的跳法的范围。这样就保证了,贪心法一定不会错过某一个能跳的特别远的好机会。因此把这个位置的的跳法从最优解替换成贪心策略的跳法,总得跳数不会增加。这样总可以在有限的次数内,把最优解完全替换成了贪心法得到的解,且跳数跟最优解一样,这就跟最优解的定义矛盾了。 看一下贪心法的时间复杂度,虽然在每个起跳位置仍然要枚举A[i]次,但对于每个位置只遍历一遍,所以是O(n)。

回过头来想想:本题的特点是在每个步骤上,可选择的范围是连续的,从1到A[i]。正是这个条件导致了动态规划过于低效以及可以使用贪心法,A[i]表示在i处只能跳A[i]那么远,就不能用贪心法了,动归也不再是O(n^2)的了。

转化为代码时,实际上我们关心的只是前两跳最多能跳多远,所以用range保存第1跳能及的范围,next表示在此基础上第2跳能及的范围。当所处位置尚在第1跳范围内时,更新第2跳的范围,当所处位置超过了第1跳的范围,count加1,表示不得不跳一次,同时将第1跳的范围更新成原先第2跳的范围。如此迭代进行下去。

//go
func jump(nums []int) int {
    curr := 0
    maxLen := 0
    count := 0
    ln := len(nums) 
    
    for i := 0; i < ln; i++ {
        if i > curr {
            curr = maxLen
            count++
        }
        
        if maxLen < i + nums[i] {
            maxLen = i + nums[i]
        }
    }
    return count
}
//C i表示当前位置,是从1开始索引的
int jump(int A[], int n) {
        int range = 1; // 第1跳能及的最远范围
        int next = 0; // 第2跳能及的最远范围
        int i = 1;
        int count = 0;

        while (range < n) {
            if (i <= range) {
                next = max(next, i + A[i - 1]);
                i++;
            }
            else {
                count++;
                range = next;
            }
        }

        return count;
    }

总结:

多思考是否有简单并且高效的解法

结语

闲来无事的话,多练习练习,做做题。