1. 程式人生 > >【長篇讀後】跟著兩隻倉鼠學演算法 漫畫算法系列

【長篇讀後】跟著兩隻倉鼠學演算法 漫畫算法系列

感謝微信公眾號“演算法愛好者”,以及該漫畫系列的出處“程式設計師小灰”

這裡會長期小灰每一期的學習感悟總結。

算法系列

漫畫演算法:什麼是動態規劃?(整合版)
漫畫演算法:什麼是跳躍表?
漫畫演算法:什麼是 B 樹?
漫畫演算法:什麼是 B+ 樹?
漫畫演算法:什麼是一致性雜湊?
漫畫演算法:無序陣列排序後的最大相鄰差值
漫畫演算法:什麼是布隆演算法?
漫畫演算法:什麼是 A* 尋路演算法?
漫畫演算法:什麼是 Base64 演算法?
漫畫演算法:什麼是 MD5 演算法?
漫畫演算法:如何破解 MD5 演算法?
漫畫演算法:什麼是 SHA 系列演算法?
漫畫演算法:什麼是 AES 演算法?
漫畫演算法:AES 演算法的底層原理
漫畫演算法:什麼是紅黑樹?

最小棧實現

題目: 實現一個棧,帶有出棧(pop),入棧(push),取最小元素(getMin)三個方法。要保證這三個方法的時間複雜度都是O(1)。

方法一: 設定兩個Stack,一個Stack正常出棧入棧。另一個Stack用來輔助儲存最小元素stackMin。 stackMin第一個元素正常入棧,之後入棧的元素和stackMin棧頂的元素相比,若小於等於(等於這個條件非常關鍵,想想為什麼)則入stackMin。getMin直接返回stackMin棧頂元素(peek)。pop的時候如果Stack棧頂的值等於最小值,則雙Stack同步pop。

這個方法也是該文章中的思想(雖然文章中輔助棧存的是下標,畢竟用陣列實現的)

方法二: 與方法一略有不同,區別在於每次入棧的時候stackMin都會判斷入棧元素是否小於等於(這裡的等於就可有可無)棧頂元素,如果符合入棧,不符合的話將stackMin棧頂已有的元素重複入自身棧頂。 這樣做的好處在於,出棧的時候非常方便,直接同步雙棧pop就行了。除了非空,不需要做任何多餘的判斷。

這裡為了省事,就用Stack來實現了。
要看陣列實現棧的話,單獨另寫
陣列實現棧的基本操作

方法一: 入棧方便,出棧複雜


public class StackPro{

   private Stack<Integer> storeData;
   private
Stack<Integer> storeMin; public StackPro(){ storeData = new Stack<Integer>(); storeMin = new Stack<Integer>(); } public void push(Integer item){ if(storeMin.isEmpty()){ storeMin.push(item); }else if(item<=storeMin.peek()){ storeMin.push(item); } storeData.push(item); } public Integer pop(){ if(storeData.isEmpty()) throw new RuntimeException("Your stack is Empty!"); if(storeData.peek()==getMin()){ //minStack的判斷在getMin()裡進行 storeMin.pop(); } return storeData.pop(); } public Integer getMin(){ if(storeMin.isEmpty()) throw new RuntimeException("Your stack is Empty"); return storeMin.peek(); } }

方法二: 入棧複雜,出棧方便。需要的空間較大

/** 
   只需要相應改寫push和pop方法就行了,其他的省略
**/

   public void push(Integer item){
      if(storeMin.isEmpty()){
           storeMin.push(item);
       }else{
          if(item<storeMin.peek()){
               storeMin.push(item);
          }else{
               storeMin.push(storeMin.peek());
          }
     }      
       storeData.push(item);
   }

    public Integer pop(){
       if(storeData.isEmpty())
           throw new RuntimeException("Your stack is Empty!");
        storeMin.pop();
        return storeData.pop();
    }

這道題的啟發就是: 巧妙利用輔助空間完成功能。

判斷2的乘方

題目:實現一個方法,判斷一個正整數是否是2的乘方(比如16是2的4次方,返回True;18不是2的乘方,返回False)。要求效能儘可能高。

最容易想到的就是設定一箇中間量,然後讓該值從1開始不斷乘以2和target進行比較,如果小於就繼續乘以2,如果==返回true。如果大於則直接返回false。

     public boolean is2Fang(int target){
        int tmp = 1;
        while(tmp<=target){
            if(tmp==target)
               return true;
            else 
                tmp<<=1;
        }
        return false;
    }

雖然這樣的方法的時間複雜度也能夠達到O(lgn)級別,但仍然不是最快的。

改進: 利用2的整數次冪數的特性,以及位運算。


   public boolean is2FangPro(int target){
        return 0==(target&target-1);  //由於運算子優先順序關係,target-1不需要括號
    }

推導過程略,因為太簡單了..

找出缺失的整數

題目: 一個無序數組裡有99個不重複正整數,範圍從1到100,唯獨缺少一個整數。如何找出這個缺失的整數?

這道題目的解法真的是非常非常多啊。。。
先說一個不咋樣的是我條件反射想到的。
方法零:

利用bitMap的思想,建立一個100長度的陣列初始為0,如果來一個95就將下標為94的格子塗黑(賦值1)..

public int getLackNum(int [] arr){
        int [] bitMap = new int[100];  //100個0
        for(int j=0;j<arr.length;j++){   
            bitMap[arr[j]-1]=1;  //存在100則把下標為99的格子染黑
        }
        for(int x=0;x<100;x++){
            if(1!=bitMap[x])
                return x+1;
        }
        return -1;  //找不到返回-1
    }

再談談文章中的方法。
方法一:

建立一個HashMap,以1到100為鍵,值都是0 。然後遍歷整個陣列,每讀到一個整數,就找到HashMap當中對應的鍵,讓其值加一。
由於陣列中缺少一個整數,最終一定有99個鍵對應的值等於1, 剩下一個鍵對應的值等於0。遍歷修改後的HashMap,找到這個值為0的鍵。

我個人不是很青睞這種方法,設定hashMap還行,最後遍歷hashMap太蠢了。。 不過我還是程式碼實現以下…


public int getLackNum(int [] arr){
        Map<Integer,Integer> map = new HashMap<Integer,Integer>();
        for(int i=1;i<=100;i++){
            map.put(i, 0);
        }   //初始化map
        for(int x : arr){  //塗黑
            map.put(x,1);
        }
        Iterator<Entry<Integer,Integer>> it = map.entrySet().iterator();
        while(it.hasNext()){
            Map.Entry<Integer, Integer> entry = it.next();
            Integer key = entry.getKey();
            Integer value = entry.getValue();
                    if(value==0) return key;
        }
        return -1;
    }

方法二:

將無序陣列排序,然後看相鄰元素是否連續,找到首個不連續的位置返回座標 (依賴於題目的100個數只差一個)

public int getLackNum(int [] arr){
        Arrays.sort(arr);
        for(int i=1;i<arr.length;i++){
            if((arr[i]-arr[i-1])!=1)
                return arr[i-1]+1;
        }
        return -1;
    }

方法三: 也是該問題的最優解

將無序陣列相加減去1-100的和… 大道至簡

有些無聊… 就不討論了

public int getLackNum(int [] arr){
        int sum = 5050;
        for(int i=0;i<arr.length;i++){
            sum-=arr[i];
        }
        return sum;
    }

題目擴充套件1:

一個無序數組裡有若干個正整數,範圍從1到100,其中99個整數都出現了偶數次,只有一個整數出現了奇數次(比如1,1,2,2,3,3,4,5,5),如何找到這個出現奇數次的整數?

思路:
我條件反射的想到的是利用bitMap,出現的塗黑,再出現塗白。這樣最後奇數次的就是黑的。。

不過有更高階更省事的方法。

依次對陣列中所有的數進行亦或

   public int getLackNum(int [] arr){
          int result = 0;   //根據亦或的規律
          for(int i=0;i<arr.length;i++)
               result = result^arr[i];
          return result;
    }

題目擴充套件2:

一個無序數組裡有若干個正整數,範圍從1到100,其中98個整數都出現了偶數次,只有兩個整數出現了奇數次(比如1,1,2,2,3,4,5,5),如何找到這個出現奇數次的整數?

思路:

這裡用bitMap相對更簡單一些,反而是文章中的分治法我覺得較為麻煩


public int[] getLackNum(int[] arr) {
        int [] result = new int[2];  //結果集
        int [] bitMap = new int[100];  //無序陣列全量表長度
        for (int n : arr) {
            bitMap[n - 1] = bitMap[n - 1] == 1 ? 0 : 1;
        }
        int index = 0;
        for(int i=0;i<bitMap.length;i++){
            if(bitMap[i]==1) result[index++] = i+1;
            if(index == 2) return result;
        }
        return result;
    }

輾轉相除法是什麼鬼?

這條演算法基於一個定理:兩個正整數a和b(a>b),它們的最大公約數等於a除以b的餘數c和b之間的最大公約數

如何求兩個數的最大公約數

輾轉相除法很經典,畢竟歐幾里得演算法。需記憶,相反我倒是覺得暴力列舉很需要動腦子(捂臉)

輾轉相除法:

public int gcb(int a, int b) {
        if(a==b)
            return a;
        if(a<b)
            return gcd(b,a);
        return gcd(b,a%b);
    }

輾轉相除法的好處在於,如果出現1001和1000這樣的,只需兩次就能發現這倆的公約數為1。 第一次1001%1000 = 1, 1000%1 = 0。 return 1。 於是1001和1000的最大公約數為1。

更相減損術:
思路: 當兩個整形數較大的時候,取模效率較低。

更相減損術, 出自於中國古代的《九章算術》,也是一種求最大公約數的演算法。

他的原理更加簡單:兩個正整數a和b(a>b),它們的最大公約數等於a-b的差值c和較小數b的最大公約數。比如10和25,25減去10的差是15,那麼10和25的最大公約數,等同於10和15的最大公約數。

 public static int gcb(int a,int b){
        if(a==b)
            return a;
        if(a<b)
            return gcd(b,a);
        return gcd(b,a-b);
    }

但這種演算法不適用於類似 (10000,1)這樣差距很大的組合。明顯公約數是1,但卻需要遞減999次。

眾所周知,移位運算的效能非常快。對於給定的正整數a和b,不難得到如下的結論。其中gcb(a,b)的意思是a,b的最大公約數函式:
當a和b均為偶數,gcb(a,b) = 2*gcb(a/2, b/2) = 2*gcb(a>>1, b>>1)
當a為偶數,b為奇數,gcb(a,b) = gcb(a/2, b) = gcb(a>>1, b)
當a為奇數,b為偶數,gcb(a,b) = gcb(a, b/2) = gcb(a, b>>1)
當a和b均為奇數,利用更相減損術運算一次,gcb(a,b) = gcb(b, a-b), 此時a-b必然是偶數,又可以繼續進行移位運算。

利用上面的規律,對更相減損術進行優化。

更相減損術優化:


/**
   同時利用 &1運算來判斷奇數偶數。
*/

public static int gcd(int a, int b) {
        if(a==b)
            return b;
        if(a<b){
            return gcd(b,a);
        }else{
            if((a&1)==0&&(b&1)==0)
                return gcd(a>>1,b>>1)<<1;  //關鍵點 不然結果會小2的N次冪
            else if((a&1)==0&&(b&1)==1)
                return gcd(a>>1,b);
            else if((a&1)==1&&(b&1)==0)
                return gcd(a,b>>1);
            else
                return gcd(b,a-b);
        }
    }

結論:
1. 暴力列舉法:時間複雜度是O(min(a, b)))
2. 輾轉相除法:時間複雜度不太好計算,可以近似為O(log(max(a, b))),但是取模運算效能較差。
3. 更相減損術:避免了取模運算,但是演算法效能不穩定,最壞時間複雜度為O(max(a, b)))
4. 更相減損術與移位結合:不但避免了取模運算,而且演算法效能穩定,時間複雜度O(log(max(a, b)))

Bitmap 演算法

公眾號裡關於這個講了不少。我個人覺得bitMap不是一個具體的演算法,而是一種思想,一個模型。

你可以理解為有一個長度為N的陣列,數組裡的每一個值只有兩種情況0或1,或者是true和false。為了簡化,我們這裡就假設這個陣列只有0或1的情況,初始都是0。然後我們根據情況將對應的位置染黑。 這就是bitMap,每一個位置大小都是一個bit.

應用這個思想可以解決很多問題,比如著名的布隆過濾器

EWAHCompressedBitmap