算法细节系列(22):什么时候贪心完!

详细代码可以fork下Github上leetcode项目,不定期更新。

题目摘自leetcode:
1. Leetcode 316: Remove Duplicate Letters
2. Leetcode 134: Gas Station
3. Leetcode 402: Remove K Digits
4. Leetcode 045: Jump Game II
5. Leetcode 135: Candy

Leetcode 316: Remove Duplicate Letters

唉,贪心这些题,解法真的靠猜,可我咋怎么都猜不中!简直无奈。这是一道删重复字符题,但需要保证删除过后的字符集是the smallest in lexicographical order among all possible results。(也就是说,字符需要维持原先的相对位置)

如:

"bcabc" ---> "abc"

先说说我的思路吧,对于字符只有两种选择删or不删,所以先统计所有字符出现的频次,那些只出现一次的字符就不用考虑,而重点考虑那些重复的元素,删or不删。

问题来了,重复的元素出现的位置有多个,到底删哪个呢?因为,只出现一次的字符相对位置是固定的,所以假设我们找到了第一个频次为1的字符,那么在它前面的必然都是出现频次超过两次的元素,如:

"aabczecbzz"
e出现的频次为1,所以e必然留下,那么在前面的元素比e大的可以直接删,所以有:
"aabcecbzz"
那么比e小的元素该如何?肯定全部留下,所以把e后面的元素,且在前面出现的全部置空。如下:
"aabcezz"
这样我们就可以放心递归求解"aabc"而不会在后面出现了,然后继续递归求解e的后面部分。

到这里不知道是否有疑问?咱们继续,如果不存在频次为1的元素该怎么办?很好的问题。
答:没有频次为1的字符也很好办,我们就从原来的数中找最小字母所在的第一个index,然后按照上述方法继续递归。

代码写完发现只能通过212/286个样例,错误样例为:
"bbcaac"
经过一次递归处理得:
"bbcac"
问题出在bb不能直接删除,因为在a之后没有了b!所以a不是一个有效的划分。。。

虽然有上述的试错,但我们已经得到了正确答案,我们要找的划分应该保证左半部分的字符一定会出现在右半部分,那么在众多满足条件的划分中,我们一定寻找字符最小的那个。

所以,新的思路如下:
a. 针对所有元素遍历一遍,针对每个index得到左半部分和右半部分。保证左半部分的元素都能在右半部分找到。
b. 在所有符合情况的index中,找寻字符最小的那个index,这样,我们可以直接删除左半部分,剩下来的就是递归求解右半部分。

如:
a b c a c b
0 1 2 3 4 5
符合步骤a.的下标有 0, 1, 2
符合步骤b.的最小元素就只有下标为0的a了。

代码如下:

public String removeDuplicateLetters(String s) {
        int[] cnt = new int[26];
        char[] c = s.toCharArray();
        int n = c.length;
        for (int i = 0; i < n; i++){
            cnt[c[i]-'a']++;
        }
        //字母最小的 index, 删除最小index左边的元素该index右边一定存在
        int pos = 0;
        for (int i = 0; i < n; i++){
            //寻找最小的字符
            if (c[i] < c[pos]) pos = i;
            //且满足左半部分能在右半部分找到,否则直接break
            if(--cnt[c[i]-'a'] == 0) break;
        }

        return n == 0 ? "" : c[pos] + removeDuplicateLetters(s.substring(pos+1).replaceAll("" + s.charAt(pos), ""));//replaceAll删除右半部分重复的字符c[pos]
    }

Leetcode 134: Gas Station

这道题,我没弄明白,但思路可以说一下。首先需了解两个事实:

  • 性质1:当gas总和小于cost总和时,一定无解。
  • 性质2:当gas总和大于cost总和时,一定有解。(如何证明?)

做法如下:
一个sum变量不断累加gas[i]-cost[i],即sum += gas[i]-cost[i],如果sum出现了负,则说明位置0-i都不可能出现可能的位置(由性质1得),当然你可以这么认为,该条件开始时,sum起始一定为正,而加着加着后劲不足,出现了负,汽车是不可能开到i位置的,你更不可能从大于0的位置出发走个循环,因为前面正的sum你都加成负的了,更别说更小的sum了,sum<0只会出现的更早。(还是直接看性质1吧,好理解)

所以,一旦sum<0,更新位置为i+1,且sum置零,重新累加,搜索一个更小的不存在的环,如果这个环最后结束后依旧满足sum>0,且最终构成的大环>0,那么返回位置i+1。

代码如下:

public int canCompleteCircuit(int[] gas, int[] cost) {
        int n = gas.length;
        int sum = 0;
        int pos = 0;
        int tol = 0;
        for (int i = 0; i < n; i++){
            sum += gas[i] - cost[i];
            //符合性质1的所有位置都不再符合,更新下标为i+1
            if (sum < 0){
                //虽说小环不符合,但大环不一定不符合,不断累加,全部累加完毕进行判断。
                tol += sum;
                sum = 0;
                pos = i + 1;
            }
        }
        tol += sum;
        return tol < 0 ? -1 : pos;
    }

所以这道题,抽象来看,只有一个知识点,尽可能多的把大环上的一些连续位置给排除了(利用性质1),它排除是连续性的,所以我们可以非常贪心的一下子更新候选下标为pos = i + 1,直到遍历结束,最后判断下整个大环是否符合性质2,符合就返回下标pos。

Leetcode 402: Remove K Digits

思路:

num = "1432219" k = 3
暴力一点的,把每一个位置都删一次,如:
k = 1
432219
132219
142219
...
143221
取最小的num,这就变成了重复子问题,用递归或者迭代都可以,建议迭代,递归容易stack over flow. 针对贪心这货出现的频次相当高

虽然看上去时间复杂度为O(k*n),不算太高,但字符串之间的大小比较也是开销,所以果断TLE,代码就不列举了。

想象一下,这道题的每个数字大小可以看成崎岖不平的山脉,而删一个数字,对大趋势不会有任何改变。(删除某一位,无非就是把后续的数字全部平移上来)

考虑递增的情况,如果删小的,那么平移过后,比原来的大。如:
1234119
删2得
134119
删3得
124119
显然递增情况删大而不删小。

考虑递减的情况。如果删小,那么平移过后,依然比原来大,不例举了。所以综合上述两个性质,删peek。(删在递增情况下第一次出现递减的那个元素)

其实删的就是山峰,那么多个山峰出现,应该删哪个呢?此处用到了贪心!

删第一个山峰,因为数越靠近左侧,它减小的效果越显著。

代码如下:

public String removeKdigits(String num, int k) {
        char[] cs = num.toCharArray();
        while(k-- != 0){
            int i = 1;
            while (i < cs.length && cs[i] >= cs[i-1]) i++;
            num = num.substring(0, i-1) + num.substring(i);
            cs = num.toCharArray();
        }
        return format(num);
    }

    private String format(String ans) {
        char[] cs = ans.toCharArray();
        int i = 0;
        while (i < cs.length && cs[i] == '0') i++;
        ans = ans.substring(i);
        return ans.isEmpty() ? "0" : ans;
    }

还是一句话,迭代能写,还是用迭代做,递归容易stack over flow,虽然简单一些。

该问题还有更快的版本,前者代码没用到状态记录,而我们知道,在遍历时,适当记录一些信息能够大大加快代码的效率。

思路:
每次都求第一个山峰,所以一旦删除一个元素,前半部分还维持着递增趋势,所以我们删第二个元素时,无非遇到两种情况,依旧递增,那么在原来的基础上应该,继续while循环即可。

如果跟上一个元素相比,出现递减,那么直接删除上一个元素即可。维护该状态记录的我们用stack可以轻松解决,stack中存放不断递增的元素,也就是那个山头。

代码如下:

private String format(String ans) {
        char[] cs = ans.toCharArray();
        int i = 0;
        while (i < cs.length && cs[i] == '0') i++;
        ans = ans.substring(i);
        return ans.isEmpty() ? "0" : ans;
    }

    public String removeKdigits(String num, int k) {
        char[] cs = num.toCharArray();
        Stack<Character> stack = new Stack<>();
        for (int i = 0; i < cs.length; i++){
            while(k > 0 && !stack.isEmpty() && stack.peek() > cs[i]){
                stack.pop();
                k--;
            }
            stack.push(cs[i]);
        }
        while (k > 0){
            stack.pop();
            k--;
        }
        StringBuilder sb = new StringBuilder();
        while(!stack.isEmpty()) sb.append(stack.pop());
        return format(sb.reverse().toString());
    }

上述代码还可以进一步优化,我们自己来实现栈,那么最后就不需要用sb去reverse了,代码如下:

public String removeKdigits(String num, int k) {
        char[] cs = num.toCharArray();
        char[] stack = new char[num.length()];
        int top = -1;
        for (int i = 0; i < cs.length; i++){
            while (k > 0 && top != -1 && stack[top] > cs[i]){
                top--;
                k--;
            }
            stack[++top] = cs[i];
        }
        while (k-- != 0) top--;
        return top == -1 ? "0" : format(new String(stack,0,top+1));
    }

Leetcode 045: Jump Game II

思路:
还是看范围,根据当前位置,能走的位置有i + nums[i],在这些位置中找寻能覆盖的范围最大的那个pos,继续迭代寻找,直到抵到最终位置。(BFS,贪心策略,每次寻找范围最大的那个pos,作为下一个起始位置)

代码如下:

public int jump(int[] nums) {
        int step = 0, range = 0, now = -1;
        while (range < nums.length - 1) {
            step++;
            int maxRange = 0;
            int index = -1;
            for (int i = now + 1; i <= range; i++) {
                if (i + nums[i] >= maxRange) {
                    maxRange = i + nums[i];
                    index = i;
                }
            }
            range = maxRange;
            now = index;
        }
        return step;
    }

还可以从另外一个角度看问题,比如说,刚开始必然有0+nums[0],所以说,如果进入位置1时,没有超过这个0+nums[0],必然不会发生step,而当前i超过了起初的edge,一定在[0,i]的某个状态进行更新,但无关乎位置,我们只要记录在[0,i]中,i+nums[i]的最大值即可。

代码如下:

public int jump(int[] nums) {
        int step = 0, edge = 0, maxRange = 0;
        int len = nums.length;
        for (int i = 0; i < len; i++){
            if (i > edge){
                step ++;
                edge = maxRange;
                //每次当作一个子问题看
                maxRange = 0;
            }
            maxRange = Math.max(maxRange, i + nums[i]);
        }
        return step;
    }

这几道题有一些明显的特点,每次进入迭代状态更新时,都变成了原问题的子问题,这估计是贪心的一个比较有趣的现象。或者说,我们只要每次能找到状态更新的子问题就能把问题给解决了?

Leetcode 135: Candy

思路:
这道题还是找上升和下降的趋势,只不过这次上升和下降的趋势是独立的,所以我们可以分两次遍历来简化该问题。初始所有糖果为1,遇到递增的ratings,糖果在原来的基础上+1.反之一样。

初始化:
ratings:     [5, 6, 2, 2, 4, 8, 9, 5, 4, 0, 5, 1]
candies:     [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]

正向traverse
ratings:     [5, 6, 2, 2, 4, 8, 9, 5, 4, 0, 5, 1]
candies:     [1, 2, 1, 1, 2, 3, 4, 1, 1, 1, 2, 1]

反向traverse
ratings:     [5, 6, 2, 2, 4, 8, 9, 5, 4, 0, 5, 1]
candies:     [1, 2, 1, 1, 2, 3, 4, 3, 2, 1, 2, 1]

代码如下:

public int candy(int[] ratings) {
        int len = ratings.length;
        int[] candies = new int[len];
        Arrays.fill(candies, 1);
        for (int i = 0; i < len; i++){
            if (i == 0) continue;
            if (ratings[i] > ratings[i-1]){
                candies[i] = candies[i-1] + 1;
            }
        }
        for (int i = len - 1; i >= 0; i--){
            if (i == len -1) continue;
            if (ratings[i] > ratings[i+1]){
                candies[i] = Math.max(candies[i+1] + 1,candies[i]);
            }
        }
        int sum = 0;
        for (int candy : candies){
            sum += candy;
        }
        return sum;
    }