45. Jump Game II

这是一个被自己蠢哭的故事。。。

思路1 - 回溯

最近做了很多回溯的题目,比如数独问题和八皇后问题,所以看到这道题目也是先想用回溯把所有可能的解法列出来,然后求出最小的步数。其实就是暴力算法,结果是毫无疑问的TLE。

public class Solution {
    private int minStep = Integer.MAX_VALUE;

    public int jump(int[] nums) {
        if (nums == null || nums.length <= 1)
            return 0;
        if (nums.length == 2)
            return 1;
        // 要到达index,有哪些点可以一步到达?
        // 注意value list要是有序的
        // 这时一个类似“建索引”的过程
        Map<Integer, List<Integer>> map = new HashMap<Integer, List<Integer>>();

        for (int i = 0; i < nums.length; i++) {
            // 当前点可以一步到达哪些点?
            for (int j = 1; j <= nums[i]; j++) {
                // 从点i可以一步到达点target,都是数组下标
                int target = i + j;
                if (target >= nums.length)
                    break;
                // 有点类似倒排索引的概念
                if (map.containsKey(target)) {
                    map.get(target).add(i);
                } else {
                    List<Integer> list = new ArrayList<Integer>();
                    list.add(i);
                    map.put(target, list);
                }
            }
        }
        //System.out.println(map);

        minStep = Integer.MAX_VALUE;
        // 从最后一个点开始回溯
        tryStep(map, 0, nums.length - 1);
        return minStep;
    }

    // 下一步要走到nextIndex
    private void tryStep(Map<Integer, List<Integer>> map, int currentStep, int nextIndex) {
        // 不存在一步走到0的情况,递归终止
        if (nextIndex == 0) {
            minStep = Integer.min(minStep, currentStep);
            return;
        }
        // 如果当前的步数已经大于等于minStep了,继续找下去肯定会更大,递归终止
        if (currentStep >= minStep) {
            return;
        }
        // 有哪些点可以一步到达nextIndex?
        for (int i : map.get(nextIndex)) {
            tryStep(map, currentStep + 1, i);
        }
    }
}

理论上这段程序可以列出所有可能的解法,当然也就能求出minStep,但也是很低效。构造map的过程就是O(N^2)了,数据量一大,还没开始tryStep就会TLE。

思路2 - 伪DP

public class Solution {
    public int jump(int[] nums) {
        if (nums == null || nums.length <= 1)
            return 0;
        if (nums.length == 2)
            return 1;
        // 要到达index,有哪些点可以一步到达?
        // 注意value list要是有序的
        Map<Integer, List<Integer>> map = new HashMap<Integer, List<Integer>>();

        for (int i = 0; i < nums.length; i++) {
            // 当前点可以一步到达哪些点?
            for (int j = 1; j <= nums[i]; j++) {
                // 从点i可以一步到达点target,都是数组下标
                int target = i + j;
                if (target >= nums.length)
                    break;
                if (map.containsKey(target)) {
                    map.get(target).add(i);
                } else {
                    List<Integer> list = new ArrayList<Integer>();
                    list.add(i);
                    map.put(target, list);
                }
            }
        }

        // dp[i]表示从0到i所需要的最小步数
        int[] dp = new int[nums.length];
        dp[0] = 0;
        dp[1] = 1;   // 题目保证一定有解,所以dp[1]必定是1
        for (int i = 2; i < dp.length; i++) {
            List<Integer> oneStepList = map.get(i);  // 从哪些点可以一步走到i?
            int min = Integer.MAX_VALUE;
            // 这个for是不是能想办法优化掉
            for (int j : oneStepList) {
                min = Integer.min(min, dp[j]);
            }
            dp[i] = min + 1;
        }
        return dp[nums.length - 1];
    }
}

可以看出这不是真正的dp,因为当前状态不是依赖于有限的历史状态,结果就是求dp[i]时还要一个for循环。。。而且构造map的过程没啥改进,数据量一大,构造map时还是会TLE。

思路3 - greedy

思路其实和之前差不多,首先要知道有哪些点可以一步跳到end。但是这些点里我只要关心最左端的点就可以了,其他的点都不用关心,假设这个点是A。然后再计算有哪些点可以一步跳到A,同样找到最左端的点B。重复这个过程直到第0个元素。

这里有个很重要的规律:如果i<j<k,从i跳到j需要的最小步数是a,从i跳到k需要最小步数b,则有a<=b,仔细想想就能明白。

public class Solution {
    public int jump(int[] nums) {
        if (nums == null || nums.length <= 1)
            return 0;
        if (nums.length == 2)
            return 1;
        // 从第一个元素跳到最后一个元素
        return innerJump(nums, 0, nums.length - 1);
    }
    // 求出从begin跳到end需要的最小步数
    private int innerJump(int[] nums, int begin, int end) {
        if (begin == end) {
            return 0;
        }
        // greedy
        // 有哪些点可以一步跳到end,从这些点中取出最左侧的那个
        int left = leftmostIndex(nums, begin, end);
        return innerJump(nums, begin, left) + 1;
    }

    private int leftmostIndex(int[] nums, int begin, int end) {
        // 最左端,哪个点能一步跳到end?
        for (int i = begin; i < end; i++) {
            int target = i + nums[i];
            if (target >= end) {
                return i;
            }
        }
        // 不可能返回-1,因为题目说一定有解
        return -1;
    }
}

这个解法理论上是正确的,但还是TLE。。。因为在计算leftmostIndex时,还是一个for循环去遍历。总的复杂度还是O(N^2)。leetcode有一个特殊的test case是[1,1,1,...,1],总共2.5w个1。。。碰到这个case这个解法必定TLE啊。。。而且由于数据量很大,递归的嵌套层次也很深,还容易StackOverflow。。。

思路4 - greedy + 线段树

思路3的解法其实是正确的,关键应该怎样提高求leftmost的效率?我想到了线段树。

对于输入[3,3,2,1,1,1],0号元素是3,可以从0号元素一步跳到[1,3]中的任意一个元素,表示为0->[1,3],同理有1->[2,4],但是对index in (1,2,3)的情况下,已经有0号元素可以跳到了,而0号元素明显在1号元素左边,所以实际上是1->[4,4]。我们可以为所有元素都计算出这样一个映射关系,映射到一个线段上,这个过程是O(N)的。

求leftmost时,我们只要快速计算出给定的index在哪个线段中,然后就能反向找出leftmost了。既然是区间相关的问题,自然就想到了线段树。

public class Solution {
    public int jump(int[] nums) {
        if (nums == null || nums.length <= 1)
            return 0;
        if (nums.length == 2)
            return 1;

        // 构造线段树的过程
        LineTree head = null;
        for (int i = 0; i < nums.length - 1; i++) {
            // 因为有0的存在,其实这不是一个完整的线段树,比如父节点是[0,10],子节点可能是[0,4]和[7,10]
            // 但不影响本题,因为我们用线段树是为了加速查找leftmost的过程
            // 我们不会寻找一个为0的元素的leftmost
            if (nums[i] == 0)
                continue;
            // 从当前元素i,可以一步达到哪些元素?
            int begin = i + 1, end = i + nums[i];
            if (end >= nums.length) // 注意end的边界
                end = nums.length - 1;
            if (begin <= end) {
                if (head == null) {
                    head = new LineTree(begin, end, i);
                } else {
                    // 插入一个节点,注意这里的逻辑
                    // 如果当前的[begin,end]完全包括在head的[begin,end]里,就不用做任何事
                    // 如果二者有重合的部分,才需要新增一个节点
                    // 由于这道题目的特殊性,重合的部分只可能是右侧,而且插入节点必定创建一个新的head
                    if (head.begin <= begin && end <= head.end) {
                        // do nothing
                        continue;
                    }
                    LineTree newHead = new LineTree(head.begin, end, -1);
                    LineTree right = null;
                    if (head.end < begin) {
                        right = new LineTree(begin, end, i);
                    } else {
                        right = new LineTree(head.end + 1, end, i);
                    }
                    newHead.left = head;
                    newHead.right = right;
                    head = newHead;
                }
            }
        }
        // 从第一个元素跳到最后一个元素
        return innerJump(nums, 0, nums.length - 1, head);
    }

    // 计算从begin跳到end的最小步数
    private int innerJump(int[] nums, int begin, int end, LineTree head) {
        // 把尾递归优化掉了,避免StackOverFlow
        int count = 0;
        while (begin < end) {
            // greedy。从left跳到end只需要1,所以我们只要计算从begin跳到left的最小步数再加1就好了
            int left = leftmostIndex(nums, end, head);
            count++;
            end = left;
        }
        return count;
    }

    private int leftmostIndex(int[] nums, int end, LineTree head) {
        LineTree cur = head;
        // 最左端,哪个点能一步跳到end?
        while (cur.contains(end)) {
            if (cur.isLeaf())
                return cur.value;
            if (cur.left != null && cur.left.contains(end))
                cur = cur.left;
            else
                cur = cur.right;
        }
        // 不可能返回-1,上面的while也不可能死循环或NPE,因为题目说一定有解
        return -1;
    }
}

class LineTree {
    // 线段树,包括begin和end,inclusive
    int begin = 0, end = 0;
    int value = 0;
    LineTree left, right;

    public LineTree(int begin, int end, int value) {
        this.begin = begin;
        this.end = end;
        this.value = value;
    }

    public boolean isLeaf() {
        return left == null && right == null;
    }

    public String toString() {
        return begin + "," + end + ":" + value;
    }

    public boolean contains(int x) {
        return x >= begin && x <= end;
    }
}

但这个解法还是TLE。。。因为[1,1,1,1...1]这种case,对应的映射关系是0->[1,1], 1->[2,2], 2->[3,3], 3->[4,4] ...,线段树会退化为一颗“点树”,由于数据都在叶子节点,所以数据量大时查找会很慢。

思路5 - greedy + TreeMap

还是在思路4的基础上优化。线段树退化为“点树”,查找很慢,部分原因是因为数据都在叶子节点上。如果改成非叶节点也能存数据,类似于二叉树的形式,是不是会好很多?

但转念一想不对啊,即使是二叉树,这种情况下也会退化为list,因为输入是有序的:0->[1,1], 1->[2,2], 2->[3,3], 3->[4,4] ...,查找性能肯定还是很差。一般应对这种情况会用到AVL树,难道要我写个AVL版的二叉线段树?AVL树实在很复杂,我估计我写不来。。。

于是考虑使用新的数据结构,最好是能在O(1)时间内找到leftmost。看到O(1)就会想到map,有没有线段map?突然想到guava中有个RangeMap,看了下API,卧槽这就是我要的啊。虽然它本质上是用红黑树存储数据的(红黑树本身就是平衡的),查找是O(logN),不是O(1)。最关键的是leetcode中不能用guava啊。。。

于是去google,找找jdk中有没有类似RangeMap的工具,找到了这个:http://stackoverflow.com/questions/1314650/using-java-map-for-range-searches。原来TreeMap也可以做到类似的功能,它的内部也是个红黑树。。。以前都不知道TreeMap可以做范围查询,还是自己用的不熟啊。。。

public class Solution {
    public int jump(int[] nums) {
        if (nums == null || nums.length <= 1)
            return 0;
        if (nums.length == 2)
            return 1;

        // build index
        // 用TreeMap去模拟范围查询
        TreeMap<Integer, Integer> map = new TreeMap<Integer, Integer>();
        int curEnd = -1;
        for (int i = 0; i < nums.length - 1; i++) {
            if (nums[i] == 0)
                continue;
            // 从当前元素i,可以一步达到哪些元素?
            int begin = i + 1, end = i + nums[i];
            if (end >= nums.length) // 注意end的边界
                end = nums.length - 1;
            if (begin <= end) {
                if (curEnd == -1) {
                    map.put(begin, i);
                    curEnd = end;
                    continue;
                }
                if (end <= curEnd) {
                    // do nothing
                    continue;
                }

                if (begin > curEnd) {
                    map.put(begin, i);
                } else {
                    map.put(curEnd + 1, i);
                }
                curEnd = end;
            }
        }
        // 从第一个元素跳到最后一个元素
        return innerJump(nums, 0, nums.length - 1, map);
    }

    // 计算从begin跳到end的最小步数
    private int innerJump(int[] nums, int begin, int end, TreeMap<Integer, Integer> map) {
        int count = 0;
        while (begin < end) {
            // greedy。从left跳到end只需要1,所以我们只要计算从begin跳到left的最小步数再加1就好了
            int left = leftmostIndex(nums, end, map);
            count++;
            end = left;
        }
        return count;
    }
    // 注意这个求leftmost的过程
    private int leftmostIndex(int[] nums, int end, TreeMap<Integer, Integer> map) {
        return map.floorEntry(end).getValue();
    }
}

整体的算法复杂度应该是O(NlogN)吧。这个算法终于AC了,但是还是比较慢,只能beat 1.7%。。。

思路6 - 另一种greedy

看了其他人的解法,发现其实解法可以很简单。。。20行代码以内就可以解决。。。而且是O(N)的解法。顿时觉得自己之前考虑那么多,纠结那么久好蠢啊。。。

本质还是greedy,但却跟我是不一样的思路:

public class Solution {
    public int jump(int[] nums) {
        if (nums == null || nums.length <= 1)
            return 0;
        if (nums.length == 2)
            return 1;

        int count = 0;
        // 走n步最多可以走到last,走n+1步最多可以走到now
        // 这个过程要仔细想想
        int now = 0, last = 0;
        for (int i = 0; i < nums.length - 1; i++) {
            if (nums[i] + i > now)
                now = nums[i] + i;
            if (last == i) {
                last = now;
                count++;
            }
        }

        return last >= nums.length - 1 ? count : count + 1;
    }
}

这个解法的思路是“正向”的,计算走n步最多能走到哪里。而我的思路是“反向”的,计算要走到end,上一步应该走哪里。所以即使是相同步数下,我们给出的解法可能也不同,比如对于输入[3,100,200,4,5,6],我的解是3->100>6,而另一种解法是3->200->6

只能呵呵了。。。

results matching ""

    No results matching ""