[算法系列]貪心算法策略介紹+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與貪心的區別與聯繫(相信各位心裏已經比較清楚了)

在這裏插入圖片描述

上圖是全部解空間樹,紅色數字表示該路徑上用一個這個面額的硬幣去組成。可以看到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更新爲小的那個

在這裏插入圖片描述

  • 若添上來的區間右端點大於end,即說明:要麼和現在的區間沒重合,要麼有重合但一定要多一筆(總之minCount++):

在這裏插入圖片描述

在這裏插入圖片描述

    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,則無法繼續走了。

在這裏插入圖片描述

  • 那麼也就是說,i 出發是可以到達i 和 j 中間的任一位置。 並且,到達中間某一位置k時,其剩餘的油量是大於等於0的,(可能有結餘,也可能沒有嘛2)

  • 那麼現在假設從k出發(出發時是沒有結餘的),則也是無論如何到不了 j +1 的,最終和 i 出發類似,死在了j 與j+1之間。不知道有沒有體會到。。

在這裏插入圖片描述

  • 而我們外層遍歷的 i, 就是當i 到 j+1 失敗後, 繼續從i +1 出發,遍歷。這明顯多餘了。以爲i 與j 中間任何點都到不了j + 1嘛。

在這裏插入圖片描述

因此對外層循環進行小改動:

    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++
  • 最後用總區間個數減去不重複的區間數,得到即爲需要刪除的區間數

在這裏插入圖片描述

    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皇后問題,素數環問題)
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章