[算法系列]贪心算法策略介绍+10道经典例题解析

本部分介绍“贪心算法“ 。 接下来会介绍动态规划。回顾一下之前脉络:

什么是递归?如何设计递归算法? 
			||
			\/
常见的递归算法应用(快排、归并、堆、) 
			||
			\/
深入递归本质:数学归纳,递推
			||
			\/
深度遍历优先搜索(DFS)、回溯、剪枝
			||
			\/
贪心算法、动态规划

那么贪心、动规与前面这些有什么联系呢?为什么要放在这里介绍?

  • 首先,贪心、动规和dfs这样的搜素算法实际很相似,是为了搜索解空间获得(满足条件)的解。DFS是按照一定的(深度优先)次序。逐一枚举遍历。相比之下:
    • 动态规划和贪心算法同样都是一种递推算法. 均用局部最优解来推导全局最优解,是对遍历空间的一种优化
    • 当问题具有最优子结构时,可用动态规划,而贪心是动态规划的特例, 特殊之处在于 眼前的最优解即是全局最终的最优解。
    • 因此我们在遍历搜索解空间时,可以按照我们所预先设定的规则,逐一进行搜寻并缩小剩余的解的空间,直至获得所有解集。
  • 其次,贪心和dp追根溯源是从子问题的解推出更大问题规模的解,这一点上和递归所追求的一脉相承。不过将会看到,很多时候由于递归本身的复杂冗余计算和资源消耗,很多时候会对其进行简化(dp中的备忘录法等),形式上可能有所不同。个人觉得放在这里介绍应该还算合理。

1. 零钱兑换

有固定面额的零钱数组coins=[1,2,5],每种有无穷多枚。现给定一个amount,由这些conins组成amount,比如(11 = 5+ 2+2+2)。

问,在所有能构成amount的硬币组合中,所需硬币最少多少枚?
例:
输入:11
输出:3
解释:11=5*2+1,两枚5元的,一枚1元的

思路:我们先不忙解题,来结合前面的介绍来体会一下贪心吧!

  • 一堆coins组成amount,很显然会有很多种不同的解。如果要求我们列出所有的解,暴力搜索、dfs都是不错的方法。
  • 在这些所有解当中,一定会有一个解,这个解中所使用的硬币数量最少。
  • 如何寻找出这个解呢?可以暴力搜索所有解,然后找出len最小的那个。
  • 贪心是怎么想的呢?正如我们平时买东西一样,我们尽量每一次用最大面额去逼近amount,比如11元就用2个5元的,而不会用5个2元的。

我们在来看看dfs与贪心的区别与联系(相信各位心里已经比较清楚了)

[算法系列]贪心算法策略介绍+10道经典例题解析_第1张图片

上图是全部解空间树,红色数字表示该路径上用一个这个面额的硬币去组成。可以看到11 ==》 10 == 》5==》 0 这条路径最短,即为所求。

现在进一步地,体会一下局部最优即为整体最优

  • 初始问题:amount=11,从coins中选出小于amount的最大面额硬币(5元),amount变为6元。
  • 问题变为:amount=6。同样的,从coins中选出小于amount的最大面额硬币(5元),amount变为1元。
  • 问题变为:amount=1。于是再选一个1元硬币即可。

我们每一次操作都选定一个硬币,这是我们的局部最优,当解完后,我们发现每一次的局部最优也就构成了我们的整体最优解(11=5+5+1)。

进一步的,贪心思想这样想,我们一开始就用最大的面额的最多张数去构成amount,比如11 就用两张5元的。

下面是代码,其中包含了dfs式写法和迭代写法,和前讲述的递归设计取得了形式上的统一:

class Solution_g01{
	int[] values = new int[] {1,2,5};
	//迭代版
	public int findMinCom1(int amount) {
		int count = 0;
		for(int i = values.length-1 ; i>=0 ; i -- ) {
			int use = amount / values[i];	//最多能取多少个,当前最大面额的硬币
			count += use;
			amount -= use *values[i];		//减去当前最大面额的硬币数,进入下一轮迭代
		}
		return count;
	}
	
	//递归版
	public int  findMinCom2(int amount) {
		int count = 0;
		return dfs(amount , values.length -1);
		
	}

	private int dfs(int amount, int i) {
		if(amount == 0 )
			return 0;
		
		int use = amount / values[i];
		return use+dfs(amount - use * values[i], i-1);
	}
}

2.柃檬水找零LeetCode860

在柠檬水摊上,每一杯柠檬水的售价为 5 美元。

顾客排队购买你的产品,(按账单 bills 支付的顺序)一次购买一杯。

每位顾客只买一杯柠檬水,然后向你付 5 美元、10 美元或 20 美元。你必须给每个顾客正确找零,也就是说净交易是每位顾客向你支付 5 美元。

注意,一开始你手头没有任何零钱。

如果你能给每位顾客正确找零,返回 true ,否则返回 false 。

示例 1:
输入:[5,5,5,10,20]
输出:true
解释:
前 3 位顾客那里,我们按顺序收取 3 张 5 美元的钞票。
第 4 位顾客那里,我们收取一张 10 美元的钞票,并返还 5 美元。
第 5 位顾客那里,我们找还一张 10 美元的钞票和一张 5 美元的钞票。
由于所有客户都得到了正确的找零,所以我们输出 true。

示例 2:
输入:[5,5,10]
输出:true

示例 3:
输入:[10,10]
输出:false

示例 4:
输入:[5,5,10,10,20]
输出:false
解释:
前 2 位顾客那里,我们按顺序收取 2 张 5 美元的钞票。
对于接下来的 2 位顾客,我们收取一张 10 美元的钞票,然后返还 5 美元。
对于最后一位顾客,我们无法退回 15 美元,因为我们现在只有两张 10 美元的钞票。
由于不是每位顾客都得到了正确的找零,所以答案是 false。

提示:

0 <= bills.length <= 10000
bills[i] 不是 5 就是 10 或是 20

思路:当给别人进行找零时,尽量先用大面额的找个他,比如20找15时,先用10元,再用5元。当所拥有的钱数无法满足找零时返回false。

具体而言:

  1. 用两个变量模拟5元和10各有多少张。

  2. 依次模拟买东西,根据不同的面额进行相应的处理

    1. 5元:fives++

    2. 10元:判断fives > 0 , five --, tens++

    3. 20元: if tens > 0 : t -= 10;

      ​ t>0 且 fives >0 : t-=5, fives –

  3. 循环结束返回true

    public boolean lemonadeChange(int[] bills) {
    	int fives = 0,tens = 0 ; 
    	for(int b : bills) {
    		if(b == 5 ) fives ++;
    		else if(b == 10) {
    			if(fives > 0) fives --;
    			else return false;
    			tens++;
    		}else { //拿20过来买
    			int t = 15;
    			if(tens > 0) {
    				t -= 10;
    				tens --;
    			}
    			while(t >0 && fives > 0) {
    				t -= 5;
    				fives --;
    			}
    			if(t > 0) return false;
    				
    		}
    	}
    	return true;
    }

3.分发饼干 LeetCode 455

假设你是一位很棒的家长,想要给你的孩子们一些小饼干。但是,每个孩子最多只能给一块饼干。对每个孩子 i ,都有一个胃口值 gi ,这是能让孩子们满足胃口的饼干的最小尺寸;并且每块饼干 j ,都有一个尺寸 sj 。如果 sj >= gi ,我们可以将这个饼干 j 分配给孩子 i ,这个孩子会得到满足。你的目标是尽可能满足越多数量的孩子,并输出这个最大数值。

注意:

你可以假设胃口值为正。
一个小朋友最多只能拥有一块饼干。

示例 1:
输入: [1,2,3], [1,1]
输出: 1
解释:
你有三个孩子和两块小饼干,3个孩子的胃口值分别是:1,2,3。
虽然你有两块小饼干,由于他们的尺寸都是1,你只能让胃口值是1的孩子满足。
所以你应该输出1。

示例 2:
输入: [1,2], [1,2,3]
输出: 2

思路:

  • 假设输出答案为k的话,表示一定能满足从小到大排序中前k个小孩的胃口。 因此我们反过来想,假设给定的胃口序列不是单调递增的,我们可以将其转换为单增,然后用饼干序列去满足。
  • 如何满足呢?对于每一个小孩胃口,我们尽量用所满足的最小的饼干去分配。
    public int findContentChildren(int[] g, int[] s) {
    	Arrays.sort(g);
    	Arrays.sort(s);
    	
    	int i = 0 , j = 0 , res = 0;
    	for(i = 0 ; i < g.length ; i++) {
    		while(j < s.length && s[j] < g[i] )j++; //当前饼干小于当前小孩胃口,跳过,寻找下一块饼干
    		if(j < s.length) { //将当前饼干分给第i个
    			res ++;
    			j++;
    		}
    	}
    	return res;
    }

4.摇摆序列 LeetCode376

如果连续数字之间的差严格地在正数和负数之间交替,则数字序列称为摆动序列。第一个差(如果存在的话)可能是正数或负数。少于两个元素的序列也是摆动序列。

例如, [1,7,4,9,2,5] 是一个摆动序列,因为差值 (6,-3,5,-7,3) 是正负交替出现的。相反, [1,4,7,2,5] 和 [1,7,4,5,5] 不是摆动序列,第一个序列是因为它的前两个差值都是正数,第二个序列是因为它的最后一个差值为零。

给定一个整数序列,返回作为摆动序列的最长子序列的长度。 通过从原始序列中删除一些(也可以不删除)元素来获得子序列,剩下的元素保持其原始顺序。

示例 1:
输入: [1,7,4,9,2,5]
输出: 6
解释: 整个序列均为摆动序列。

示例 2:
输入: [1,17,5,10,13,15,10,5,16,8]
输出: 7
解释: 这个序列包含几个长度为 7 摆动序列,其中一个可为[1,17,10,13,10,16,8]。

示例 3:
输入: [1,2,3,4,5,6,7,8,9]
输出: 2
进阶:
你能否用 O(n) 时间复杂度完成此题?

  • 思路: 先去重,然后找出序列中的所有极大值和极小值
    public int wiggleMaxLength(int[] nums) {
        //去重
    	int j = 0;
        if(nums.length == 0) return 0;
    	for(int i = 1; i <nums.length ; i ++) {
    		if(nums[i] != nums[j])
    			nums[++j] = nums[i];
    	}
    	
    	// int nums_new[] = Arrays.copyOf(nums, ++j);
    	j++;
    	int res = 2;
    	if(j <= 2) return j;
    	for(int i = 1; i +1 < j ; i++) {
    		int a= nums[i -1],b = nums[i],c = nums[i+1];
    		//如果是局部最小和局部最大
    		if(a<b && b >c) res++;  //极大值
    		else if (a>b && b<c) res++; //极小值
    	}
    	return res;
    }

5.根据身高重建队列LeetCode 406

假设有打乱顺序的一群人站成一个队列。 每个人由一个整数对(h, k)表示,其中h是这个人的身高,k是排在这个人前面且身高大于或等于h的人数。 编写一个算法来重建这个队列。

注意:
总人数少于1100人。

示例

输入:
[[7,0], [4,4], [7,1], [5,0], [6,1], [5,2]]

输出:
[[5,0], [7,0], [5,2], [6,1], [4,4], [7,1]]

思考:前面放个高的人,根据题意,个高的人是看不到个子比自己低的人,因此可以先考虑身高高的人。

  • 将所有人按照身高从高到低排序。若遇到两个身高相同的,则按照k值从小到大排序(k小的要在前面)

    [7,0][7,1][6,1][5,0][5,1][4,4]
    
  • 接下来就比较容易了,不过刚开始可能不太好想:

    • 在上面按身高和k排好序的序列中,k值表示的是其所应该处在的位置下标,比如[7,0] 应该放在第0个位置上,[5,1]应该放在下标为1的位置上。
    • 不过,当k出现相同值时应该怎么办呢?很简单,身高低的放前面,比如下面这个例子:
      • [7,0],[5,0]显然是不合理的,5前有比他高的,k不会为0.
      • 具体如何实习这一细节呢?其实先插入[7,1]到下标为1的位置,再插[6,1]
    public int[][] reconstructQueue(int[][] people) {
    	
    	//按照身高排
    	Arrays.sort(people,new Comparator<int[]>() {
    		@Override
    		public int compare(int[] o1, int[] o2) {
    			int res =o2[0] - o1[0];
    			if(res == 0)	return o1[1] - o2[1];
    			else	return res;
    		}
		});
    	
    	List<int[]> output = new LinkedList<>();
    	for(int[] p : people)
    		output.add(p[1], p);
    	return output.toArray(new int[people.length][2]);

    }

6.用最少数量的箭引爆气球

在二维空间中有许多球形的气球。对于每个气球,提供的输入是水平方向上,气球直径的开始和结束坐标。由于它是水平的,所以y坐标并不重要,因此只要知道开始和结束的x坐标就足够了。开始坐标总是小于结束坐标。平面内最多存在104个气球。

一支弓箭可以沿着x轴从不同点完全垂直地射出。在坐标x处射出一支箭,若有一个气球的直径的开始和结束坐标为 xstart,xend, 且满足 xstart ≤ x ≤ xend,则该气球会被引爆。可以射出的弓箭的数量没有限制。 弓箭一旦被射出之后,可以无限地前进。我们想找到使得所有气球全部被引爆,所需的弓箭的最小数量。

Example:

输入:
[[10,16], [2,8], [1,6], [7,12]]

输出:
2

解释:
对于该样例,我们可以在x = 6(射爆[2,8],[1,6]两个气球)和 x = 11(射爆另外两个气球)。

  • 先将区间按照左端点从小到大 排序,依次向右边考虑

  • end表示目前能够到达的最远位置

  • 每添上一段区间,考虑minCount是否需要增加:

    • 若新添上来的区间右端点小于end,则将end更新为小的那个

[算法系列]贪心算法策略介绍+10道经典例题解析_第2张图片

  • 若添上来的区间右端点大于end,即说明:要么和现在的区间没重合,要么有重合但一定要多一笔(总之minCount++):

[算法系列]贪心算法策略介绍+10道经典例题解析_第3张图片

[算法系列]贪心算法策略介绍+10道经典例题解析_第4张图片

    public int findMinArrowShots(int[][] points) {
    	if(points.length < 2) return points.length;
    	//按照区间起点进行排队
    	Arrays.sort(points,new Comparator<int[]>() {
    		@Override
    		public int compare(int[] o1, int[] o2) {
    			if(o1[0] != o2[0])
    				return o1[0] - o2[0];
    			return o1[1] - o2[1];
    		}
		});
    	
    	int minCount = 1;
    	//end表示目前能够到达的最远位置
    	int end = points[0][1];
    	
    	//贪心过程,每射上一支箭,记录当前能够射穿的区间
    	for(int i = 1 ; i < points.length; i ++) {
    		if(points[i][0] <= end)
    			end = Math.min(end, points[i][1]);
    		else {
				minCount++;
				end  = points[i][1];
			}
    	}
    	return minCount;
    }

7.移掉k位数字 LeetCode402

给定一个以字符串表示的非负整数 num,移除这个数中的 k 位数字,使得剩下的数字最小。

注意:

num 的长度小于 10002 且 ≥ k。
num 不会包含任何前导零。
示例 1 :

输入: num = “1432219”, k = 3
输出: “1219”
解释: 移除掉三个数字 4, 3, 和 2 形成一个新的最小的数字 1219。
示例 2 :

输入: num = “10200”, k = 1
输出: “200”
解释: 移掉首位的 1 剩下的数字为 200. 注意输出不能有任何前导零。
示例 3 :

输入: num = “10”, k = 2
输出: “0”
解释: 从原数字移除所有的数字,剩余为空就是0。

思路: 在一个数字中移掉固定k位数字,使得剩下最小。进行如下思考:剩下的的数字长度是一定的,因此我们希望的是高位数字越小越好。

  • 假设:该数字重高位到地位逐渐递增,如 “ 123456”,若k=3,则很显然去掉后三个数字得到的“123” 为最小值。这是容易发现的
  • 那么,并非单调递增,像“123452”,k=3呢,首先得删掉这个5. why?
    • 假设删掉5,数字变为“12345**** ”
    • 假设不删5,数字变为“ 12342**** ” 可以看到第二个一定比第一个小
  • 因此在“123452中” 应当删掉5, 变为“12342” 。
  • 同样的道理,下一次我们应当删除4, 变为“1232”。
  • 当删掉的数字个数等于k时停止删除,也就得到了“122”,此为最小、

算法:

  • 上述的规则使得我们通过一个接一个的删除数字,逐步的接近最优解。
  • 这个问题可以用贪心算法来解决。上述规则阐明了我们如何接近最终答案的基本逻辑。一旦我们从序列中删除一个数字,剩下的数字就形成了一个新的问题,我们可以继续使用这个规则。
  • 注意,在某些情况下,规则对任意数字都不适用,即单调递增序列。在这种情况下,我们只需要删除末尾的数字来获得最小数。
  • 我们可以利用栈来实现上述算法,存储当前迭代数字之前的数字。
    public String removeKdigits(String num, int k) {
    	//用一个链表模拟栈
    	LinkedList<Character> stack = new LinkedList<Character>();
    	
    	for(char c : num.toCharArray()) {
    		while(stack.size() > 0 && k > 0 && stack.peekLast() >c) {
    			stack.removeLast();
    			k--;
    		}
    		stack.add(c);
    	}
    	
    	//当K还没用完,序列已经变为顺序了,那么从栈后面直接删除即可
    	while(k-- > 0)
    		stack.removeLast();
    	
    	//将这个栈(链表)转换为最终的string,另外若前面是0,注意删除
    	StringBuilder res = new StringBuilder();
    	boolean prezero = true;
    	for(char c : stack) {
    		if(prezero && c == '0') continue;
    		prezero = false;
    		res.append(c);
    	}
    	
    	if(res.length() == 0) return "0";
    	return res.toString();
    }

8.加油站 LeetCode134

在一条环路上有 N 个加油站,其中第 i 个加油站有汽油 gas[i] 升。

你有一辆油箱容量无限的的汽车,从第 i 个加油站开往第 i+1 个加油站需要消耗汽油 cost[i] 升。你从其中的一个加油站出发,开始时油箱为空。

如果你可以绕环路行驶一周,则返回出发时加油站的编号,否则返回 -1。

说明:

如果题目有解,该答案即为唯一答案。
输入数组均为非空数组,且长度相同。
输入数组中的元素均为非负数。
示例 1:

输入:
gas = [1,2,3,4,5]
cost = [3,4,5,1,2]

输出: 3

解释:
从 3 号加油站(索引为 3 处)出发,可获得 4 升汽油。此时油箱有 = 0 + 4 = 4 升汽油
开往 4 号加油站,此时油箱有 4 - 1 + 5 = 8 升汽油
开往 0 号加油站,此时油箱有 8 - 2 + 1 = 7 升汽油
开往 1 号加油站,此时油箱有 7 - 3 + 2 = 6 升汽油
开往 2 号加油站,此时油箱有 6 - 4 + 3 = 5 升汽油
开往 3 号加油站,你需要消耗 5 升汽油,正好足够你返回到 3 号加油站。
因此,3 可为起始索引。
示例 2:

输入:
gas = [2,3,4]
cost = [3,4,3]

输出: -1

解释:
你不能从 0 号或 1 号加油站出发,因为没有足够的汽油可以让你行驶到下一个加油站。
我们从 2 号加油站出发,可以获得 4 升汽油。 此时油箱有 = 0 + 4 = 4 升汽油
开往 0 号加油站,此时油箱有 4 - 3 + 2 = 3 升汽油
开往 1 号加油站,此时油箱有 3 - 3 + 3 = 3 升汽油
你无法返回 2 号加油站,因为返程需要消耗 4 升汽油,但是你的油箱只有 3 升汽油。
因此,无论怎样,你都不可能绕环路行驶一周。

思路:该题一拿到肯定想到的是暴力枚举。即枚举每一个位置出发,看其加的油和消耗的油能否满足(gas_left > 0)…若走到了一圈,则返回该处下标i,否则进行下一个位置的检测。 最后如若所有位置不成,返回-1。

	//暴力
    public int canCompleteCircuit(int[] gas, int[] cost) {
    	for(int i = 0 ,j = 0; i < gas.length ; i ++) {
    		int gas_left = 0;
    		for(j = 0 ; j < cost.length;  j ++) {
    			int k = (i + j) % cost.length;
    			gas_left += gas[k] - cost[k];
    			if(gas_left < 0)
    				break;
    		}
    		if( j >= gas.length) return i; 	//走了一圈	
    	}
    	return - 1;
    }

其实上述代码可以进行优化,将O(n^2)降为O(n)级别。

  • 仔细看上述代码,从i处出发,当我们走到 j 处时,自己当前前所剩的油加上该处可以加的油小于cost,则无法继续走了。

[算法系列]贪心算法策略介绍+10道经典例题解析_第5张图片

  • 那么也就是说,i 出发是可以到达i 和 j 中间的任一位置。 并且,到达中间某一位置k时,其剩余的油量是大于等于0的,(可能有结余,也可能没有嘛2)

  • 那么现在假设从k出发(出发时是没有结余的),则也是无论如何到不了 j +1 的,最终和 i 出发类似,死在了j 与j+1之间。不知道有没有体会到。。

[算法系列]贪心算法策略介绍+10道经典例题解析_第6张图片

  • 而我们外层遍历的 i, 就是当i 到 j+1 失败后, 继续从i +1 出发,遍历。这明显多余了。以为i 与j 中间任何点都到不了j + 1嘛。

[算法系列]贪心算法策略介绍+10道经典例题解析_第7张图片

因此对外层循环进行小改动:

    public int canCompleteCircuit(int[] gas, int[] cost) {
    	for(int i = 0 ,j = 0; i < gas.length ; i +=j+1) {	//直接迈过 i 和j 中间的所有点
    		int gas_left = 0;
    		for(j = 0 ; j < cost.length;  j ++) {
    			int k = (i + j) % cost.length;
    			gas_left += gas[k] - cost[k];
    			if(gas_left < 0)
    				break;
    		}
    		if( j >= gas.length) return i; 	//走了一圈	
    	}
    	return - 1;
    }

9.分糖果 LeetCode135

老师想给孩子们分发糖果,有 N 个孩子站成了一条直线,老师会根据每个孩子的表现,预先给他们评分。

你需要按照以下要求,帮助老师给这些孩子分发糖果:

每个孩子至少分配到 1 个糖果。
相邻的孩子中,评分高的孩子必须获得更多的糖果。
那么这样下来,老师至少需要准备多少颗糖果呢?

示例 1:

输入: [1,0,2]
输出: 5
解释: 你可以分别给这三个孩子分发 2、1、2 颗糖果。
示例 2:

输入: [1,2,2]
输出: 4
解释: 你可以分别给这三个孩子分发 1、2、1 颗糖果。
第三个孩子只得到 1 颗糖果,这已满足上述两个条件。

思路:两个数组left和right,先后从左和从右遍历。

  • 从左向右遍历left[]时,若left[i] > left[i-1] ,则left[i] = left[i-1] +1
  • 从右向左遍历right[]时,若right[i] > right[i+1] ,则right[i] = righr[i+!] +1
  • 最终,计算数量res 取left[i]right[i]中较大的那个
    public int candy(int[] ratings) {
    	
    	int[] left = new int [ratings.length];
    	Arrays.fill(left,1);
    	for(int i = 1 ; i < ratings.length ; i ++)
    		if(ratings[i] > ratings[i - 1])	
    			left[i] = left[i-1] +1;
    	
        int res = left[ratings.length -1];

    	int[] right = new int[ratings.length];
    	Arrays.fill(right, 1);
    	for(int i = ratings.length - 2 ; i >=0 ;i --) {
    		if(ratings[i+1] < ratings[i])
    			right[i] = right[i +1] +1;
    		res += Math.max(left[i] ,right[i] );
    	}	
    	
    	return res;
    }

9.去除重复字母 LeetCode316

给你一个仅包含小写字母的字符串,请你去除字符串中重复的字母,使得每个字母只出现一次。需保证返回结果的字典序最小(要求不能打乱其他字符的相对位置)。

示例 1:

输入: “bcabc”
输出: “abc”
示例 2:

输入: “cbacdcbc”
输出: “acdb”

思路:

  1. 遍历字符串里的字符,如果读到的字符的 ASCII 值是升序,依次存到一个栈中;
  2. 如果读到的字符在栈中已经存在,这个字符我们不需要;(这里用一个flag[] 来存放每个元素是否已经在栈中)
  3. 如果读到的 ASCII 值比栈顶元素严格小,看看栈顶元素在后面是否还会出现,如果还会出现,则舍弃栈顶元素,而选择后出现的那个字符,这样得到的字典序更小。
    • !stack.empty()
    • ASCII 值比栈顶元素严格小: curChar < stack.peek()
    • 当前位置不是最后出现的位置, charLastIndex[stack.peek() - 'a'] >= i (这里charLastIndex数组保存每个元素在string中最后出现的位置)
  4. 最后的结果即为stack中的。
    public String removeDuplicateLetters(String s) {
        int len = s.length();
        // 特判
        if (len < 2) {
            return s;
        }

        // 记录是否在已经得到的字符串中
        boolean[] set = new boolean[26];

        // 记录每个字符出现的最后一个位置
        int[] lastAppearIndex = new int[26];
        for (int i = 0; i < len; i++) {
            lastAppearIndex[s.charAt(i) - 'a'] = i;
        }
		
        //使用栈来模拟
        Stack<Character> stack = new Stack<>();
        for (int i = 0; i < len; i++) {
            char currentChar = s.charAt(i);
            //如果栈中已经存在了,跳过即可
            if (set[currentChar - 'a'])
                continue;
			
            //栈非空,并且当前字符小于栈顶,同时栈顶元素在后面的字符串中还会出现
            while (!stack.empty() && stack.peek() > currentChar && 
                   lastAppearIndex[stack.peek() - 'a'] >= i) {
                char top = stack.pop();
                set[top - 'a'] = false;
            }
			
            //入栈
            stack.push(currentChar);
            set[currentChar - 'a'] = true;
        }

        StringBuilder stringBuilder = new StringBuilder();
        while (!stack.empty()) 
            stringBuilder.insert(0, stack.pop());
                                 
        return stringBuilder.toString();
    }

10.无重复区间 LeetCode435

给定一个区间的集合,找到需要移除区间的最小数量,使剩余区间互不重叠。

注意:

可以认为区间的终点总是大于它的起点。
区间 [1,2] 和 [2,3] 的边界相互“接触”,但没有相互重叠。

示例 1:
输入: [ [1,2], [2,3], [3,4], [1,3] ]
输出: 1
解释: 移除 [1,3] 后,剩下的区间没有重叠。

示例 2:
输入: [ [1,2], [1,2], [1,2] ]
输出: 2
解释: 你需要移除两个 [1,2] 来使剩下的区间没有重叠。

示例 3:
输入: [ [1,2], [2,3] ]
输出: 0
解释: 你不需要移除任何区间,因为它们已经是无重叠的了。

思路: 以每个区间的右端点从小到大排序,然后找出不重复的区间,最后用总区间个数去减。

  • count计算不重复的区间个数,初始为1
  • end 保存当前的右端点,初始为第一个元素的右端点,
  • 依次遍历intervals,当interval的start大于等于end时,count++
  • 最后用总区间个数减去不重复的区间数,得到即为需要删除的区间数

[算法系列]贪心算法策略介绍+10道经典例题解析_第8张图片

    public int eraseOverlapIntervals(int[][] intervals) {
    	if(intervals.length <= 1) return 0;
    	Arrays.sort(intervals,new Comparator<int[]>() {
    		public int compare(int[] o1, int[] o2) {
    			return o1 [1] - o2[1];
    		}
    	});
		
		int count = 1;
		//end 表示当前的末尾端点,初始为第一元素
		int end = intervals[0][1];	
		
		for(int[] interval : intervals) {
			int start = interval[0];
			if(start >= end) {	//表示找到了一个不重合的
				count++;
				end = interval[1];	//end更新为当前最右端点
			}
		}
		return intervals.length - count;
    }

现在可以再看看第6题射区间问题。其和该题的不同之处在于两点:

  • 在区间端点处重合也算重合,而该题不算
  • 求得是不重合区间(集)个数,而该题求的是重合多余的。

因此只需要小改动,相比原题的以左端点排序可能还是要简单点


... ...
		
		int count = 1;
		//end 表示当前的末尾端点,初始为第一元素
		int end = intervals[0][1];	
		
		for(int[] interval : intervals) {
			int start = interval[0];
			if(start >  end) {	//表示找到了一个不重合的(这里是严格大于)
				count++;
				end = interval[1];	//end更新为当前最右端点
			}
		}
		return count;	
    }

贪心小结

  • 最优子结构:对比DFS,不是进行各种支路的试探,而是当下就可用某种策略确定选择,无需考虑未来。(未来的情况即使演变也不会影响当下的选择)
  • 只要一直这么选下去,就能得出最终的解,每一步都是当下(子问题)的最优解,结果是原问题的最优解,这叫做最优子结构。
  • 更书面的说法:如果问题的一个最优解中包含了子问题的最优解,则该问题具有最优子结构。
  • 具备这类结构的问题,可以用局部最优解来推导全局最优解,可以认为其是一种剪枝法,每层剪去那些不是最佳的子树,所以其本质是对“dfs遍历法”的优化,
  • 由上一步的最优解推导下一步的最优解,而上一步之前的(历史)最优解则不作保留。

贪心算法设计

  • 首先要确定是否能够用贪心,即需要证明当前决定的策略是否包含在了最优解当中,并且后续的子问题与现在规模问题的决策(路径)不相互依赖。比如找零钱中,每次在[1,2,5]中选取最大面额的,总能使得构成的数量最少,以14元为例,你总会先用5元的进行找零得到9元,再用5元的进行找零得到4元,然后用两张2元的。但是这是如何证明的呢?
    另外,思考一波,如果面额数组为[1,2,5,7,10],同样构成14元,这时候如果按照最大的取,14=10+2+2,需要3张,而显然最优解为14=7+7,需要两张,这不是贪心,那是怎么想的呢?
  • 其次,贪心其实是一种算法设计的策略,而并非一种实实在在的算法,因此相比比较有规律的dfs,贪心的表现形式有不少(如上面例题),其解答模式也不尽相同,有一种具体问题具体分析的感觉,那么在贪心问题的思考中,有没有一些不变的方式和套路呢?
    从上面的题解中可以发现,贪心往往会和排序、遍历联系在一起,我们可能会用“最大或最小,最多或最少”的去逐个满足,具体看题目要求,但是其根本问题是以一种相同的方式去缩小问题规模(这一点同递归一样),同时这个过程就是我们所需要的最优过程,结果即是最优解。
  • 找零钱、零钱兑换;分饼干糖果;区间重合问题;排队问题;加油站问题

之后也会继续扩充本篇博文,记录一些贪心例题与解答。

往期回顾:

  1. [算法系列] 搞懂递归, 看这篇就够了 !! 递归设计思路 + 经典例题层层递进
  2. [算法系列] 递归应用: 快速排序+归并排序算法及其核心思想与拓展 … 附赠 堆排序算法
  3. [算法系列] 深入递归本质+经典例题解析——如何逐步生成, 以此类推,步步为营
  4. [算法系列]搞懂DFS(1)——经典例题(数独游戏, 部分和, 水洼数目)图文详解
  5. [算法系列]搞懂DFS(2)——模式套路+经典例题详解(n皇后问题,素数环问题)

你可能感兴趣的:(数据结构/算法)