[一篇看懂] 最優化問題: 分治法 貪心算法 動態規劃(舉例說明)

歡迎指教 歡迎評論留言

分治算法

  • 先劃分, 大問題變小問題,
  • 等到問題規模小到可以直接解決了,再去處理這個足夠小的子問題
  • 最後將子問題的最優解’合併’起來, 組合成原問題的最終解.
    三種解決方案都是將大規模的難解的問題改成小規模容易解的問題.
    比如分治算法經典的’最大子序和’問題.

舉例:53. 最大子序和

給定一個整數數組 nums ,找到一個具有最大和的連續子數組(子數組最少包含一個元素),返回其最大和。
示例:

輸入: [-2,1,-3,4,-1,2,1,-5,4],
輸出: 6
解釋: 連續子數組 [4,-1,2,1] 的和最大,爲 6。

分治算法解分析

class Solution {
    public int maxSubArray(int[] nums) {
    	// 最優子串可能在左串 可能在右串 可能是包含中間元素的中間串
        return recur(nums,0,nums.length-1);
    }
    // 遞歸 分治法
	private int recur(int[] nums, int l, int r) {
		if(l==r) return nums[l];// 只有一個元素的時候
		int mid=(l+r)/2;
		// 對左中右取最大  
		int leftSum=recur(nums,l,mid);
		int rightSum=recur(nums,mid+1,r);
		// cross的計算不是原問題規模更小的問題 是合併的一部分
		int crossSum=midSum(nums, l, r, mid);
		int res=Math.max(leftSum, Math.max(rightSum, crossSum));
		return res;
	}
	// 求中間子串: 這個求和不是原問題的子問題(必須 跨越中點) 所以不用recur()計算
	private int midSum(int[] nums, int l, int r,int mid) {
		if(l==r) return nums[l];// 只有一個元素
		int sumTmp=0,leftSum=Integer.MIN_VALUE;
		int rightSum=leftSum;
		for(int i=mid;i!=-1;i--) {
			sumTmp+=nums[i];
			leftSum=Math.max(sumTmp, leftSum);
		}// 包括mid位置的左半邊最大和
		sumTmp=0;
		for(int i=mid+1;i!=nums.length;i++) {
			sumTmp+=nums[i];
			rightSum=Math.max(sumTmp, rightSum);
		}// 不包括mid位置的右半邊最大和
		return leftSum+rightSum;
	}
}

分析原問題,發現問題的解無外乎三中情況. 子序列都在在mid的前面, 子序列都在mid的後面, 子序列不都在前面也不都在後面, 跨越了mid, 前後都有的, 代碼中的midSum函數就是算這個的.
原問題劃分爲:

  • 1.前半部分和 2. 後半部分和 3. 包括中間的中間部分和.

前後半部分都是大問題變小問題經典的形式, 中間部分和不是原問題的同結構子問題, 所以是屬於分治算法合併部分

動態規劃算法

  • 三種解決方案都需要’最優子結構’形式,
  • 分治的最優子問題互相獨立, 沒有’重疊子問題’的情況; 如果有這個問題,直接使用分治算法會有許多的子問題需要重複計算. 用動態規劃可以大大的減少時間花銷.
  • 動態規劃有自頂向下和自底向上兩種方法:
    • 自頂向下方法其實就是帶備忘錄的分治算法, 在每次遞歸調用時, 將結果存在一個’備忘錄’中, 它可以是一個數組. 在需要子問題解的時候不需要再次計算子問題的解, 直接返回先前已經計算的結果(如果沒有計算過的話就繼續計算)
    • 自底向上的方法其實是從小規模問題到大規模問題, 計算較大規模最優解是會利用到剛剛(上回循環)計算出的較小規模最優解的結果, 每個子問題只會計算一次, 大大減少時間複雜度.自底向上說到底就是在大問題可以化成小問題的基礎想, 從小問題出發, 逐漸擴大, 最終擴大到原本問題的規模

舉例: 55. 跳躍遊戲

給定一個非負整數數組,你最初位於數組的第一個位置。
數組中的每個元素代表你在該位置可以跳躍的最大長度。
判斷你是否能夠到達最後一個位置。
示例 1:

輸入: [2,3,1,1,4]
輸出: true
解釋: 我們可以先跳 1 步,從位置 0 到達 位置 1, 然後再從位置 1 跳 3 步到達最後一個位置。

動態規劃算法分析

回溯法

public class Solution {
    public boolean canJumpFromPosition(int position, int[] nums) {
        if (position == nums.length - 1)  return true;
        int furthestJump = Math.min(position + nums[position], nums.length - 1);// 避免跳到超出數據容量處去了
        for (int nextPosition = position + 1; nextPosition <= furthestJump; nextPosition++) // 從position+1處開始找能跳的點
            if (canJumpFromPosition(nextPosition, nums))
                return true;
        return false;
    }
    public boolean canJump(int[] nums) {
    	// 從0開始遍歷遞歸
        return canJumpFromPosition(0, nums);
    }
}
  • 時間複雜度是O(2^n), 冪級的時間複雜度, 很明顯需要優化
  • 原問題有’最優子結構’, 的確是不斷縮小範圍. 並且在遞歸的過程中有很多重複的子問題的計算
    • 對於重複子問題往往使用動態規劃可以優化時間複雜度
  • 並且發現這種分治每次縮小範圍都是只剩下一個子問題, 說不定可以使用貪心算法(下文會說)

自頂向下(用空間換時間)

enum Index { // 設計了一個每局變量
    GOOD, BAD, UNKNOWN 
}
public class Solution {
    Index[] memo;// 判斷每一個位置能不能跳到尾巴(end)
    public boolean canJump(int[] nums) {
	        memo = new Index[nums.length];
	        for (int i = 0; i < memo.length; i++) 
	            memo[i] = Index.UNKNOWN;// 初始化memo數組
	        memo[memo.length - 1] = Index.GOOD;// 最尾巴位置可以跳
	        return canJumpFromPosition(0, nums);
	}
    private boolean canJumpFromPosition(int position, int[] nums) {
    	// 以下兩行就是動態規劃自頂向下的精髓(也叫帶備忘錄的動態規劃)
        if (memo[position] != Index.UNKNOWN) // 若當前位置可以跳 直接返回
            return memo[position] == Index.GOOD ? true : false;
        //若對應的子問題沒有求解過, 則繼續計算
        int furthestJump = Math.min(position + nums[position], nums.length - 1);// 避免跳到超出數據容量處去了
        for (int nextPosition = position + 1; nextPosition <= furthestJump; nextPosition++) {// 從position+1處開始找能跳的點
            if (canJumpFromPosition(nextPosition, nums)) {
                memo[position] = Index.GOOD;
                return true;
            }
        }
        // 找不到能跳的點
        memo[position] = Index.BAD;
        return false;
    }

自底向上

	public boolean canJump2(int[] nums) {
		// can數組標識當前位置是否能跳到尾巴(end)
		boolean[] can=new boolean[nums.length];// 默認爲false
		can[nums.length-1]=true;// 最尾爲真(可跳)
		int jumpMax=0;//最遠可跳的距離
		for(int i=nums.length-2;i>=0;i--) {//從len-2向前遍歷
			//i+nums[i]是i處可跳的距離
			jumpMax=Math.min(i+nums[i], nums.length-1);
			for(int j=i+1;j<=jumpMax;j++)//從i+1遍歷到jumpMax 看有沒有可達尾的
				if(can[j]) 
					can[i]=true; break;
		}
		return can[0];
	}

貪心算法

貪心算法通常是自頂向下的算法, 每一步貪心都把當前問題的規模縮減一點
貪心算法在使用時要證明(相當於做了數學歸納法):

  1. 證明做出當前的貪心選擇後**, 只剩下一個子問題**, 不能像分治和動態一樣有多個子問題.
  2. 證明貪心選擇總是安全的, 能夠一路貪心貪到原問題最優解
  • 貪心算法實際是對每一步的當前問題找最優解, 不依賴於子問題的最優解和將來選擇的最優解

優化
如果在貪心算法的每步操作時, 不得不考慮衆多選擇: 很多時候需要對原問題的輸入輸入做點排序操作. 便於每步貪心時減少’查找當前問題最優解’的時間複雜度.

舉例: 跳躍遊戲

public boolean canJump(int[] nums) {
		int leftMostIndex=nums.length-1;// 初始化爲最右元素
		for(int i=nums.length-1;i>=0;i--) {// 從後往前
			//每次遍歷貪到最右(最大下標)
			if(nums[i]+i>=leftMostIndex)//i處的元素夠得到 '最左可達元素'
				leftMostIndex=i;
		}
		return leftMostIndex==0;
	}
  • 每步貪心的確是可以縮減規模, 並只剩下左部分規模的子問題
  • 每步都貪’最左’的元素, 如果最左元素都夠不到的話, 就是不可達, 如果最左元素夠得到, 那就是可達

舉例2: 最大子序和

class Solution {
	public int maxSubArray(int[] nums) {
		int len = nums.length;
		int currSum = nums[0]; // 每一步的當前最優解
		int maxSum = nums[0];
		// 從左到右 每步貪心
		for (int i = 1; i < len; ++i) {
			// 每步都貪出當前 以j結尾的最大值;
			currSum = Math.max(nums[i], currSum + nums[i]);
			// 更新maxSum
			maxSum = Math.max(maxSum, currSum);
		}
		return maxSum;
	}
}
  • currSum = Math.max(nums[j], currSum + nums[j]);
    • 比較以j結尾的某個’最大子數組和’和[j]的值
    • 若[i,j]的值更大或二者相等
      • '最大子數組’加一個元素
      • currSum更新爲[i,j]的大小
    • 若[i,j]的值比[j]的值小
      • 還不如從j開始新的’最大連續子數組’
      • 更新’currSum’
    • 若最大子數組不是以[j-1]結尾的
      • 其對應的值早就存在maxSum中,是currSum的曾經的某一個賦值
      • maxSum = Math.max(maxSum, currSum);比較時也不會影響maxSum的正確性

貪心算法常見解決問題

單源最短路徑問題, 最小生成樹問題

發佈了65 篇原創文章 · 獲贊 14 · 訪問量 4萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章