歡迎指教 歡迎評論留言
分治算法
- 先劃分, 大問題變小問題,
- 等到問題規模小到可以直接解決了,再去處理這個足夠小的子問題
- 最後將子問題的最優解’合併’起來, 組合成原問題的最終解.
三種解決方案都是將大規模的難解的問題改成小規模容易解的問題.
比如分治算法經典的’最大子序和’問題.
舉例: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];
}
貪心算法
貪心算法通常是自頂向下的算法, 每一步貪心都把當前問題的規模縮減一點
貪心算法在使用時要證明(相當於做了數學歸納法):
- 證明做出當前的貪心選擇後**, 只剩下一個子問題**, 不能像分治和動態一樣有多個子問題.
- 證明貪心選擇總是安全的, 能夠一路貪心貪到原問題最優解
- 貪心算法實際是對每一步的當前問題找最優解, 不依賴於子問題的最優解和將來選擇的最優解
優化
如果在貪心算法的每步操作時, 不得不考慮衆多選擇: 很多時候需要對原問題的輸入輸入做點排序操作. 便於每步貪心時減少’查找當前問題最優解’的時間複雜度.
舉例: 跳躍遊戲
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的正確性
貪心算法常見解決問題
單源最短路徑問題, 最小生成樹問題