左神直通BAT演算法筆記(基礎篇)-下

Anwen發表於2019-02-19

個人技術部落格:www.zhenganwen.top

二叉樹

實現二叉樹的先序、中序、後續遍歷,包括遞迴方式和非遞迴方式

遞迴方式

public static class Node{
  int data;
  Node left;
  Node right;
  public Node(int data) {
    this.data = data;
  }
}

public static void preOrderRecursive(Node root) {
  if (root != null) {
    System.out.print(root.data+" ");
    preOrderRecursive(root.left);
    preOrderRecursive(root.right);
  }
}

public static void medOrderRecursive(Node root) {
  if (root != null) {
    medOrderRecursive(root.left);
    System.out.print(root.data+" ");
    medOrderRecursive(root.right);
  }
}

public static void postOrderRecursive(Node root) {
  if (root != null) {
    postOrderRecursive(root.left);
    postOrderRecursive(root.right);
    System.out.print(root.data+" ");
  }
}

public static void main(String[] args) {
  Node root = new Node(1);
  root.left = new Node(2);
  root.right = new Node(3);
  root.left.left = new Node(4);
  root.left.right = new Node(5);
  root.right.left = new Node(6);
  root.right.right = new Node(7);
  preOrderRecursive(root);	//1 2 4 5 3 6 7
  System.out.println();
  medOrderRecursive(root);	//4 2 5 1 6 3 7 
  System.out.println();
  postOrderRecursive(root);	//4 5 2 6 7 3 1 
  System.out.println();
}
複製程式碼

以先根遍歷二叉樹為例,可以發現遞迴方式首先嚐試列印當前結點的值,隨後嘗試列印左子樹,列印完左子樹後嘗試列印右子樹,遞迴過程的base case是當某個結點為空時停止子過程的展開。這種遞迴嘗試是由二叉樹本身的結構所決定的,因為二叉樹上的任意結點都可看做一棵二叉樹的根結點(即使是葉子結點,也可以看做是一棵左右子樹為空的二叉樹根結點)。

觀察先序、中序、後序三個遞迴方法你會發現,不同點在於列印當前結點的值這一操作的時機。你會發現每個結點會被訪問三次:進入方法時算一次、遞迴處理左子樹完成之後返回時算一次、遞迴處理右子樹完成之後返回時算一次。因此在preOrderRecursive中將列印語句放到方法開始時就產生了先序遍歷;在midOrderRecursive中,將列印語句放到遞迴chu處理左子樹完成之後就產生了中序遍歷。

非遞迴方式

先序遍歷

拿到一棵樹的根結點後,首先列印該結點的值,然後將其非空右孩子、非空左孩子依次壓棧。棧非空迴圈:從棧頂彈出結點(一棵子樹的根節點)並列印其值,再將其非空右孩子、非空左孩子依次壓棧。

public static void preOrderUnRecur(Node root) {
  if (root == null) {
    return;
  }
  Stack<Node> stack = new Stack<>();
  stack.push(root);
  Node cur;
  while (!stack.empty()) {
    cur = stack.pop();
    System.out.print(cur.data+" ");
    if (cur.right != null) {
      stack.push(cur.right);
    }
    if (cur.left != null) {
      stack.push(cur.left);
    }
  }
  System.out.println();
}
複製程式碼

你會發現壓棧的順序和列印的順序是相反的,壓棧是先根結點,然後有右孩子就壓右孩子、有左孩子就壓左孩子,這是利用棧的後進先出。每次獲取到一棵子樹的根節點之後就可以獲取其左右孩子,因此無需保留其資訊,直接彈出並列印,然後保留其左右孩子到棧中即可。

中序遍歷

對於一棵樹,將該樹的左邊界全部壓棧,root的走向是隻要左孩子不為空就走向左孩子。當左孩子為空時彈出棧頂結點(此時該結點是一棵左子樹為空的樹的根結點,根據中序遍歷可以直接列印該結點,然後中序遍歷該結點的右子樹)列印,如果該結點的右孩子非空(說明有右子樹),那麼將其右孩子壓棧,這個右孩子又可能是一棵子樹的根節點,因此將這棵子樹的左邊界壓棧,這時回到了開頭,以此類推。

public static void medOrderUnRecur(Node root) {
  if (root == null) {
    return;
  }
  Stack<Node> stack = new Stack<>();
  while (!stack.empty() || root != null) {
    if (root != null) {
      stack.push(root);
      root = root.left;
    } else {
      root = stack.pop();
      System.out.print(root.data+" ");
      root = root.right;
    }
  }
  System.out.println();
}
複製程式碼
後序遍歷

思路一:準備兩個棧,一個棧用來儲存遍歷時的結點資訊,另一個棧用來排列後根順序(根節點先進棧,右孩子再進,左孩子最後進)。

public static void postOrderUnRecur1(Node root) {
  if (root == null) {
    return;
  }
  Stack<Node> stack1 = new Stack<>();
  Stack<Node> stack2 = new Stack<>();
  stack1.push(root);
  while (!stack1.empty()) {
    root = stack1.pop();
    if (root.left != null) {
      stack1.push(root.left);
    }
    if (root.right != null) {
      stack1.push(root.right);
    }
    stack2.push(root);
  }
  while (!stack2.empty()) {
    System.out.print(stack2.pop().data + " ");
  }
  System.out.println();
}
複製程式碼

思路二:只用一個棧。藉助兩個變數hch代表最近一次列印過的結點,c代表棧頂結點。首先將根結點壓棧,此後棧非空迴圈,令c等於棧頂元素(c=stack.peek())執行以下三個分支:

  1. c的左右孩子是否與h相等,如果都不相等,說明c的左右孩子都不是最近列印過的結點,由於左右孩子是左右子樹的根節點,根據後根遍歷的特點,左右子樹肯定都沒列印過,那麼將左孩子壓棧(列印左子樹)。
  2. 分支1沒有執行說明c的左孩子要麼不存在;要麼左子樹剛列印過了;要麼右子樹剛列印過了。這時如果是前兩種情況中的一種,那就輪到列印右子樹了,因此如果c的右孩子非空就壓棧。
  3. 如果前兩個分支都沒執行,說明c的左右子樹都列印完了,因此彈出並列印c結點,更新一下h
public static void postOrderUnRecur2(Node root) {
  if (root == null) {
    return;
  }
  Node h = null;  //最近一次列印的結點
  Node c = null;  //代表棧頂結點
  Stack<Node> stack = new Stack<>();
  stack.push(root);
  while (!stack.empty()) {
    c = stack.peek();
    if (c.left != null && c.left != h && c.right != h) {
      stack.push(c.left);
    } else if (c.right != null && c.right != h) {
      stack.push(c.right);
    } else {
      System.out.print(stack.pop().data + " ");
      h = c;
    }
  }
  System.out.println();
}
複製程式碼

在二叉樹中找一個結點的後繼結點,結點除lleft,right指標外還包含一個parent指標

這裡的後繼結點不同於連結串列的後繼結點。在二叉樹中,前驅結點和後繼結點是按照二叉樹中兩個結點被中序遍歷的先後順序來劃分的。比如某二叉樹的中序遍歷是2 1 3,那麼1的後繼結點是3,前驅結點是2

你當然可以將二叉樹中序遍歷一下,在遍歷到該結點的時候標記一下,那麼下一個要列印的結點就是該結點的後繼結點。

我們可以推測一下,當我們來到二叉樹中的某個結點時,如果它的右子樹非空,那麼它的後繼結點一定是它的右子樹中最靠左的那個結點;如果它的右孩子為空,那麼它的後繼結點一定是它的祖先結點中,把它當做左子孫(它存在於祖先結點的左子樹中)的那一個,否則它沒有後繼結點。

這裡如果它的右孩子為空的情況比較難分析,我們可以藉助一個指標parent,當前來到的結點node和其父結點parentparent.left比較,如果相同則直接返回parent,否則node來到parent的位置,parent則繼續向上追溯,直到parent到達根節點為止若node還是不等於parent的左孩子,則返回null表明給出的結點沒有後繼結點。

public class FindSuccessorNode {

    public static class Node{
        int data;
        Node left;
        Node right;
        Node parent;

        public Node(int data) {
            this.data = data;
        }
    }

    public static Node findSuccessorNode(Node node){
        if (node == null) {
            return null;
        }
        if (node.right != null) {
            node = node.right;
            while (node.left != null) {
                node = node.left;
            }
            return node;
        } else {
            Node parent = node.parent;
            while (parent != null && parent.left != node) {
                node = parent;
                parent = parent.parent;
            }
            return parent == null ? null : parent;
        }
    }

    public static void main(String[] args) {
        Node root = new Node(1);
        root.left = new Node(2);
        root.left.parent = root;
        root.left.left = new Node(4);
        root.left.left.parent = root.left;
        root.left.right = new Node(5);
        root.left.right.parent = root.left;
        root.right = new Node(3);
        root.right.parent = root;
        root.right.right = new Node(6);
        root.right.right.parent = root.right;

       if (findSuccessorNode(root.left.right) != null) {
            System.out.println("node5's successor node is:"+findSuccessorNode(root.left.right).data);
        } else {
            System.out.println("node5's successor node doesn't exist");
        }

        if (findSuccessorNode(root.right.right) != null) {
            System.out.println("node6's successor node is:"+findSuccessorNode(root.right.right).data);
        } else {
            System.out.println("node6's successor node doesn't exist");
        }
    }
}
複製程式碼

介紹二叉樹的序列化和反序列化

序列化

二叉樹的序列化要注意的兩個點如下:

  1. 每序列化一個結點數值之後都應該加上一個結束符表示一個結點序列化的終止,如!
  2. 不能忽視空結點的存在,可以使用一個佔位符如#表示空結點的序列化。
/**
     * 先根遍歷的方式進行序列化
     * @param node  序列化來到了哪個結點
     * @return
     */
public static String serializeByPre(Node node) {
  if (node == null) {
    return "#!";
  }
  //收集以當前結點為根節點的樹的序列化資訊
  String res = node.data + "!";
  //假設能夠獲取左子樹的序列化結果
  res += serializeByPre(node.left);
  //假設能夠獲取右子樹的序列化結果
  res += serializeByPre(node.right);
  //返回以當前結點為根節點的樹的序列化結果
  return res;
}

public static void main(String[] args) {
  Node root = new Node(1);
  root.left = new Node(2);
  root.left.left = new Node(4);
  root.left.right = new Node(5);
  root.right = new Node(3);
  root.right.right = new Node(6);

  System.out.println(serializeByPre(root));
}
複製程式碼

重建

怎麼序列化的,就怎麼反序列化

public static Node reconstrut(String serializeStr) {
  if (serializeStr != null) {
    String[] datas = serializeStr.split("!");
    if (datas.length > 0) {
      //藉助佇列儲存結點數值
      Queue<String> queue = new LinkedList<>();
      for (String data : datas) {
        queue.offer(data);
      }
      return recon(queue);
    }
  }
  return null;
}

private static Node recon(Queue<String> queue) {
  //依次出隊元素重建結點
  String data = queue.poll();
  //重建空結點,也是base case,當要重建的某棵子樹為空時直接返回
  if (data.equals("#")) {
    return null;
  }
  //重建頭結點
  Node root = new Node(Integer.parseInt(data));
  //重建左右子樹
  root.left = recon(queue);
  root.right = recon(queue);
  return root;
}

public static void main(String[] args) {
  Node root = new Node(1);
  root.left = new Node(2);
  root.left.left = new Node(4);
  root.left.right = new Node(5);
  root.right = new Node(3);
  root.right.right = new Node(6);

  String str = serializeByPre(root);
  Node root2 = reconstrut(str);
  System.out.println(serializeByPre(root2));
}
複製程式碼

判斷一個樹是否是平衡二叉樹

平衡二叉樹的定義:當二叉樹的任意一棵子樹的左子樹的高度和右子樹的高度相差不超過1時,該二叉樹為平衡二叉樹。

根據定義可知,要確認一個二叉樹是否是平衡二叉樹勢必要遍歷所有結點。而遍歷到每個結點時,要想知道以該結點為根結點的子樹是否是平衡二叉樹,我們要收集兩個資訊:

  1. 該結點的左子樹、右子樹是否是平衡二叉樹
  2. 左右子樹的高度分別是多少,相差是否超過1

那麼我們來到某個結點時(子過程),我們需要向上層(父過程)返回的資訊就是該結點為根結點的樹是否是平衡二叉樹以及該結點的高度,這樣的話,父過程就能繼續向上層返回應該收集的資訊。

package top.zhenganwen.algorithmdemo.recursive;

/**
 * 判斷是否為平衡二叉樹
 */
public class IsBalanceBTree {
    public static class Node{
        int data;
        Node left;
        Node right;
        public Node(int data) {
            this.data = data;
        }
    }
    /**
     * 遍歷時,來到某個結點需要收集的資訊
     * 1、以該結點為根節點的樹是否是平衡二叉樹
     * 2、該結點的高度
     */
    public static class ReturnData {
        public boolean isBalanced;
        public int height;
        public ReturnData(boolean isBalanced, int height) {
            this.isBalanced = isBalanced;
            this.height = height;
        }
    }

    public static ReturnData isBalancedBinaryTree(Node node){
        if (node == null) {
            return new ReturnData(true, 0);
        }
        ReturnData leftData = isBalancedBinaryTree(node.left);
        if (leftData.isBalanced == false) {
            //只要有一棵子樹不是平衡二叉樹,則會一路返回false,該樹的高度自然不必收集了
            return new ReturnData(false, 0);
        }
        ReturnData rightDta = isBalancedBinaryTree(node.right);
        if (rightDta.isBalanced == false) {
            return new ReturnData(false, 0);
        }
        //返回該層收集的結果
        if (Math.abs(leftData.height - rightDta.height) > 1) {
            return new ReturnData(false, 0);
        }
        //若是平衡二叉樹,樹高等於左右子樹較高的那個加1
        return new ReturnData(true, Math.max(leftData.height, rightDta.height) + 1);
    }

    public static void main(String[] args) {
        Node root = new Node(1);
        root.left = new Node(2);
        root.left.left = new Node(4);
        root.right = new Node(3);
        root.right.right = new Node(5);
        root.right.right.right = new Node(6);
        System.out.println(isBalancedBinaryTree(root).isBalanced);	//false
    }
}
複製程式碼

遞迴很好用,該題中的遞迴用法也是一種經典用法,可以高度套路:

  1. 分析問題的解決需要哪些步驟(這裡是遍歷每個結點,確認每個結點為根節點的子樹是否為平衡二叉樹)
  2. 確定遞迴:父問題是否和子問題相同
  3. 子過程要收集哪些資訊
  4. 本次遞迴如何利用子過程返回的資訊得到本過程要返回的資訊
  5. base case

判斷一棵樹是否是搜尋二叉樹

搜尋二叉樹的定義:對於二叉樹的任意一棵子樹,其左子樹上的所有結點的值小於該子樹的根節點的值,而其右子樹上的所有結點的值大於該子樹的根結點的值,並且整棵樹上任意兩個結點的值不同。

根據定義,搜尋二叉樹的中序遍歷列印將是一個升序序列。因此我們可以利用二叉樹的中序遍歷的非遞迴方式,比較中序遍歷時相鄰兩個結點的大小,只要有一個結點的值小於其後繼結點的那就不是搜尋二叉樹。

import java.util.Stack;

/**
 * 判斷是否是搜尋二叉樹
 */
public class IsBST {
    public static class Node {
        int data;
        Node left;
        Node right;

        public Node(int data) {
            this.data = data;
        }
    }

    public static boolean isBST(Node root) {
        if (root == null) {
            return true;
        }
        int preData = Integer.MIN_VALUE;
        Stack<Node> stack = new Stack<>();
        while (root != null || !stack.empty()) {
            if (root != null) {
                stack.push(root);
                root = root.left;
            } else {
                Node node = stack.pop();
                if (node.data < preData) {
                    return false;
                } else {
                    preData = node.data;
                }
                root = node.right;
            }
        }
        return true;
    }

    public static void main(String[] args) {
        Node root = new Node(6);
        root.left = new Node(3);
        root.left.left = new Node(1);
        root.left.right = new Node(4);
        root.right = new Node(8);
        root.right.left = new Node(9);
        root.right.right = new Node(10);

        System.out.println(isBST(root));	//false
    }
}
複製程式碼

判斷一棵樹是否是完全二叉樹

根據完全二叉樹的定義,如果二叉樹上某個結點有右孩子無左孩子則一定不是完全二叉樹;否則如果二叉樹上某個結點有左孩子而沒有右孩子,那麼該結點所在的那一層上,該結點右側的所有結點應該是葉子結點,否則不是完全二叉樹。

import java.util.LinkedList;
import java.util.Queue;

/**
 * 判斷是否為完全二叉樹
 */
public class IsCompleteBTree {
    public static class Node {
        int data;
        Node left;
        Node right;
        public Node(int data) {
            this.data = data;
        }
    }

    public static boolean isCompleteBTree(Node root) {
        if (root == null) {
            return true;
        }
        Queue<Node> queue = new LinkedList<>();
        queue.offer(root);
        boolean leaf = false;
        while (!queue.isEmpty()) {
            Node node = queue.poll();
            //左空右不空
            if (node.left == null && node.right != null) {
                return false;
            }
          	//如果開啟了葉子結點階段,結點不能有左右孩子
            if (leaf &&
                    (node.left != null || node.right != null)) {
                return false;
            }
            //將下一層要遍歷的加入到佇列中
            if (node.left != null) {
                queue.offer(node.left);
            }
            if (node.right != null) {
                queue.offer(node.right);
            } else {
                //左右均為空,或左不空右空。該結點同層的右側結點均為葉子結點,開啟葉子結點階段
                leaf = true;
            }

        }
        return true;
    }

    public static void main(String[] args) {
        Node root = new Node(1);
        root.left = new Node(2);
        root.right = new Node(3);
        root.left.right = new Node(4);

        System.out.println(isCompleteBTree(root));//false
    }
}
複製程式碼

已知一棵完全二叉樹,求其結點個數,要求時間複雜度0(N)

如果我們遍歷二叉樹的每個結點來計算結點個數,那麼時間複雜度將是O(N^2),我們可以利用滿二叉樹的結點個數為2^h-1(h為樹的層數)來加速這個過程。

首先完全二叉樹,如果其左子樹的最左結點在樹的最後一層,那麼其右子樹肯定是滿二叉樹,且高度為h-1;否則其左子樹肯定是滿二叉樹,且高度為h-2。也就是說,對於一個完全二叉樹結點個數的求解,我們可以分解求解過程:1個根結點+ 一棵滿二叉樹(高度為h-1或者h-2)+ 一棵完全二叉樹(高度為h-1)。前兩者的結點數是可求的(1+2^level -1=2^level),後者就又成了求一棵完全二叉樹結點數的問題了,可以使用遞迴。

/**
 * 求一棵完全二叉樹的節點個數
 */
public class CBTNodesNum {
  public static class Node {
    int data;
    Node left;
    Node right;
    public Node(int data) {
      super();
      this.data = data;
    }
  }

  // 獲取完全二叉樹的高度
  public static int getLevelOfCBT(Node root) {
    if (root == null)
      return 0;
    int level = 0;
    while (root != null) {
      level++;
      root = root.left;
    }
    return level;
  }

  public static int getNodesNum(Node node) {
    //base case
    if (node == null)
      return 0;
    int level = getLevelOfCBT(node);
    if (getLevelOfCBT(node.right) == level - 1) {
      // 左子樹滿,且高度為 level-1;收集左子樹節點數2^(level-1)-1和頭節點,對右子樹重複此過程
      int leftNodesAndRoot = 1 << (level - 1);
      return getNodesNum(node.right) + leftNodesAndRoot;
    } else {
      // 右子樹滿,且高度為 level-2;收集右子樹節點數2^(level-2)-1和頭節點1,對左子樹重複此過程
      int rightNodesAndRoot = 1 << (level - 2);
      return getNodesNum(node.left) + rightNodesAndRoot;

    }
  }

  public static void main(String[] args) {
    Node root = new Node(1);
    root.left = new Node(2);
    root.right = new Node(3);
    root.left.left = new Node(4);
    root.left.right = new Node(5);
    root.right.left = new Node(6);
    root.right.right = new Node(7);

    System.out.println(getNodesNum(root));
  }
}
複製程式碼

並查集

並查集是一種樹型的資料結構,用於處理一些不交集(Disjoint Sets)的合併及查詢問題。有一個聯合-查詢演算法union-find algorithm)定義了兩個用於此資料結構的操作:

  • Find:確定元素屬於哪一個子集。它可以被用來確定兩個元素是否屬於同一子集。
  • Union:將兩個子集合併成同一個集合。

並查集結構的實現

首先並查集本身是一個結構,我們在構造它的時候需要將所有要操作的資料扔進去,初始時每個資料自成一個結點,且每個結點都有一個父指標(初始時指向自己)。

左神直通BAT演算法筆記(基礎篇)-下

初始時並查集中的每個結點都算是一個子集,我們可以對任意兩個元素進行合併操作。值得注意的是,union(nodeA,nodeB)並不是將結點nodeAnodeB合併成一個集合,而是將nodeA所在的集合和nodeB所在的集合合併成一個新的子集:

左神直通BAT演算法筆記(基礎篇)-下

那麼合併兩個集合的邏輯是什麼呢?首先要介紹一下代表結點這個概念:找一結點所在集合的代表結點就是找這個集合中父指標指向自己的結點(並查集初始化時,每個結點都是各自集合的代表結點)。那麼合併兩個集合就是將結點個數較少的那個集合的代表結點的父指標指向另一個集合的代表結點:

左神直通BAT演算法筆記(基礎篇)-下

還有一個find操作:查詢兩個結點是否所屬同一個集合。我們只需判斷兩個結點所在集合的代表結點是否是同一個就可以了:

左神直通BAT演算法筆記(基礎篇)-下

程式碼示例:

import java.util.*;

public class UnionFindSet{
	public static class Node{
		//whatever you like to store   int , char , String ..etc
	}
	private Map<Node,Node> fatherMap;
	private Map<Node,Integer> nodesNumMap;

	//give me the all nodes need to save into the UnionFindSet
	public UnionFindSet(List<Node> nodes){
		fatherMap = new HashMap();
		nodesNumMap = new HashMap();
		for(Node node : nodes){
			fatherMap.put(node,node);
			nodesNumMap.put(node,1);
		}
	}

	public void union(Node a,Node b){
		if(a == null || b == null){
			return;
		}
		Node rootOfA = getRoot(a);
		Node rootOfB = getRoot(b);
		if(rootOfA != rootOfB){
			int numOfA = nodesNumMap.get(rootOfA);
			int numOfB = nodesNumMap.get(rootOfB);
			if(numOfA >= numOfB){
				fatherMap.put(rootOfB , rootOfA);
				nodesNumMap.put(rootOfA, numOfA + numOfB);
			}else{
				fatherMap.put(rootOfA , rootOfB);
				nodesNumMap.put(rootOfB, numOfA + numOfB);
			}
		}
	}

	public boolean find(Node a,Node b){
		if(a == null || b == null){
			return false;
		}
		Node rootOfA = getRoot(a);
		Node rootOfB = getRoot(b);
		return rootOfA == rootOfB ? true : false;
	}

	public Node getRoot(Node node){
		if(node == null){
			return null;
		}
		Node father = fatherMap.get(node);
		if(father != node){
			father = fatherMap.get(father);
		}
		fatherMap.put(node, father);
		return father;
	}
	
	public static void main(String[] args){
		Node a = new Node();
		Node b = new Node();
		Node c = new Node();
		Node d = new Node();
		Node e = new Node();
		Node f = new Node();
		Node[] nodes = {a,b,c,d,e,f};

		UnionFindSet set = new UnionFindSet(Arrays.asList(nodes));
		set.union(a, b);
		set.union(c, d);
		set.union(b, e);
		set.union(a, c);
		System.out.println(set.find(d,e));
	}
}
複製程式碼

你會發現unionfind的過程中都會有找一個結點所在集合的代表結點這個過程,所以我把它單獨抽出來成一個getRoot,而且利用遞迴做了一個優化:找一個結點所在集合的代表結點時,會不停地向上找父指標指向自己的結點,最後在遞迴回退時將沿途路過的結點的父指標改為直接指向代表結點:

左神直通BAT演算法筆記(基礎篇)-下

誠然,這樣做是為了提高下一次查詢的效率。

並查集的應用

並查集結構本身其實很簡單,但是其應用卻很難。這裡以島問題做引子,當矩陣相當大的時候,用單核CPU去跑這個遍歷和感染效率是很低的,可能會使用平行計算框架來完成島數量的統計。也就是說矩陣可能被分割成幾個部分,逐個統計,最後在彙總。那麼問題來了:

左神直通BAT演算法筆記(基礎篇)-下

上面這個矩陣的島數量是1;但如果從中間豎著切開,那麼左邊的島數量是1,右邊的島數量是2,總數是3。如何處理切割後,相鄰子矩陣之間的邊界處的1相鄰導致的重複統計呢?其實利用並查集的特性就很容易解決這個問題:

左神直通BAT演算法筆記(基礎篇)-下

首先將切割邊界處的資料封裝成結點加入到並查集中併合並同一個島上的結點,在分析邊界時,查邊界兩邊的1是否在同一個集合,如果不在那就union這兩個結點,並將總的島數量減1;否則就跳過此行繼續分析下一行邊界上的兩個點。

貪心策略

拼接最小字典序

給定一個字串型別的陣列strs,找到一種拼接方式,使得把所有字串拼起來之後形成的字串具有最低的字典序。

此題很多人的想法是把陣列按照字典序排序,然後從頭到尾連線,形成的字串就是所有拼接結果中字典序最小的那個。但這很容易證明是錯的,比如[ba,b]的排序結果是[b,ba],拼接結果是bba,但bab的字典序更小。

正確的策略是,將有序字串陣列從頭到尾兩兩拼接時,應取兩兩拼接的拼接結果中字典序較小的那個。證明如下

如果令.代表拼接符號,那麼這裡的命題是如果str1.str2 < str2.str2str2.str3 < str3.str2,那麼一定有str1.str3 < str3.str1。這可以使用數學歸納法來證明。如果將a~z對應到0~25,比較兩個字串的字典序的過程,其實就比較兩個26進位制數大小的過程。str1.str2拼接的過程可以看做兩個26進位制數拼接的過程,若將兩字串解析成數字int1int2,那麼拼接就對應int1 * 26^(str2的長度) + int2,那麼證明過程就變成了兩個整數不等式遞推另一個不等式了。

金條和銅板

一塊金條切成兩半,是需要花費和長度數值一樣的銅板的。比如長度為20的 金條,不管切成長度多大的兩半,都要花費20個銅板。一群人想整分整塊金 條,怎麼分最省銅板?

例如,給定陣列{10,20,30},代表一共三個人,整塊金條長度為10+20+30=60. 金條要分成10,20,30三個部分。 如果, 先把長度60的金條分成10和50,花費60 再把長度50的金條分成20和30,花費50 一共花費110銅板。但是如果, 先把長度60的金條分成30和30,花費60 再把長度30金條分成10和20,花費30 一共花費90銅板。

輸入一個陣列,返回分割的最小代價。

貪心策略,將給定的陣列中的元素扔進小根堆,每次從小根堆中先後彈出兩個元素(如10和20),這兩個元素的和(如30)就是某次分割得到這兩個元素的花費,再將這個和扔進小根堆。直到小根堆中只有一個元素為止。(比如扔進30之後,彈出30、30,此次花費為30+30=60,再扔進60,堆中只有一個60了,結束,總花費30+60-=90)

public stzuoatic int lessMoney(int arr[]){
  if (arr == null || arr.length == 0) {
    return 0;
  }
  //PriorityQueue是Java語言對堆結構的一個實現,預設將按自然順序的最小元素放在堆頂
  PriorityQueue<Integer> minHeap = new PriorityQueue();
  for (int i : arr) {
    minHeap.add(i);
  }
  int res = 0;
  int curCost = 0;
  while (minHeap.size() > 1) {
    curCost = minHeap.poll() + minHeap.poll();
    res += curCost;
    minHeap.add(curCost);
  }
  return res;
}

public static void main(String[] args) {
  int arr[] = {10, 20, 30};
  System.out.println(lessMoney(arr));
}
複製程式碼

IPO

輸入: 引數1:正數陣列costs;引數2:正數陣列profits;引數3:正數k;引數4:正數m。costs[i]表示i號專案的花費(成本),profits[i]表示i號專案做完後在扣除花費之後還能掙到的錢(利潤),k表示你不能並行,只能序列的最多做k個專案 m表示你初始的資金。

說明:你每做完一個專案,馬上獲得的收益,可以支援你去做下一個專案。

輸出: 你最後獲得的最大錢數。

貪心策略:藉助兩個堆,一個是存放各個專案花費的小根堆、另一個是存放各個專案利潤的大根堆。首先將所有專案放入小根堆而大根堆為空,對於手頭上現有的資金(本金),將能做的專案(成本低於現有資金)從小根堆依次彈出並放入到大根堆,再彈出大根堆堆頂專案來完成,完成後根據利潤更新本金。本金更新後,再將小根堆中能做的專案彈出加入到大根堆中,再彈出大根堆中的堆頂專案來做,重複此操作,直到某次本金更新和兩個堆更新後大根堆無專案可做或者完成的專案個數已達k個為止。

import java.util.Comparator;
import java.util.PriorityQueue;

public class IPO {

  public class Project{
    int cost;
    int profit;
    public Project(int cost, int profit) {
      this.cost = cost;
      this.profit = profit;
    }
  }

  public class MinCostHeap implements Comparator<Project> {
    @Override
    public int compare(Project p1, Project p2) {
      return p1.cost-p2.cost; //升序,由此構造的堆將把花費最小專案的放到堆頂
    }
  }

  public class MaxProfitHeap implements Comparator<Project> {
    @Override
    public int compare(Project p1, Project p2) {
      return p2.profit-p1.profit;
    }
  }

  public int findMaximizedCapital(int costs[], int profits[], int k, int m) {
    int res = 0;
    PriorityQueue<Project> minCostHeap = new PriorityQueue<>(new MinCostHeap());
    PriorityQueue<Project> maxProfitHeap = new PriorityQueue<>(new MaxProfitHeap());
    for (int i = 0; i < costs.length; i++) {
      Project project = new Project(costs[i], profits[i]);
      minCostHeap.add(project);
    }
    for (int i = 0; i < k; i++) {
      //unlock project
      while (minCostHeap.peek().cost < m) {
        maxProfitHeap.add(minCostHeap.poll());
      }
      if (maxProfitHeap.isEmpty()) {
        return m;
      }
      m +=  maxProfitHeap.poll().profit;
    }

    return m;
  }

}
複製程式碼

會議室專案宣講

一些專案要佔用一個會議室宣講,會議室不能同時容納兩個專案的宣講。 給你每一個專案開始的時間和結束的時間(給你一個陣列,裡面 是一個個具體的專案),你來安排宣講的日程,要求會議室進行 的宣講的場次最多。返回這個最多的宣講場次。

貪心策略:

1、開始時間最早的專案先安排。反例:開始時間最早,但持續時間佔了一整天,其他專案無法安排。

2、持續時間最短的專案先安排。反例:這樣安排會導致結束時間在此期間和開始時間在此期間的所有專案不能安排。

3、最優策略:最先結束的專案先安排。

import java.util.Arrays;
import java.util.Comparator;

public class Schedule {

  public class Project {
    int start;
    int end;
  }

  public class MostEarlyEndComparator implements Comparator<Project> {
    @Override
    public int compare(Project p1, Project p2) {
      return p1.end-p2.end;
    }
  }

  public int solution(Project projects[],int currentTime) {
    //sort by the end time
    Arrays.sort(projects, new MostEarlyEndComparator());
    int res = 0;
    for (int i = 0; i < projects.length; i++) {
      if (currentTime <= projects[i].start) {
        res++;
        currentTime = projects[i].end;
      }
    }
    return res;
  }
}
複製程式碼

經驗:貪心策略相關的問題,累積經驗就好,不必花費大量精力去證明。解題的時候要麼找相似點,要麼腦補策略然後用對數器、測試用例去證。

遞迴和動態規劃

暴力遞迴

  1. 把問題轉化為規模縮小了的同類問題的子問題
  2. 有明確的不需要繼續進行遞迴的條件(base case)
  3. 有當得到了子問題的結果之後的決策過程
  4. 不記錄每一個子問題的解

動態規劃

  1. 從暴力遞迴中來
  2. 將每一個子問題的解記錄下來,避免重複計算
  3. 把暴力遞迴的過程,抽象成了狀態表達
  4. 並且存在化簡狀態表達,使其更加簡潔的可能

P和NP

P指的是我明確地知道怎麼算,計算的流程很清楚;而NP問題指的是我不知道怎麼算,但我知道怎麼嘗試(暴力遞迴)。

暴力遞迴

n!問題

我們知道n!的定義,可以根據定義直接求解:

int getFactorial_1(int n){
  int res=1;
  for(int i = 1 ; i <= n ; n++){
    res*=i;
  }
  return res;
}
複製程式碼

但我們可以這樣想,如果知道(n-1)!,那通過(n-1)! * n不就得出n!了嗎?於是我們就有了如下的嘗試:

int getFactorial_2(int n){
  if(n=1)
    return 1;
  return getFactorial_2(n-1) * n;
}
複製程式碼

n!的狀態依賴(n-1)!(n-1)!依賴(n-2)!,就這樣依賴下去,直到n=1這個突破口,然後回溯,你會發現整個過程就回到了1 * 2 * 3 * …… * (n-1) * n的計算過程。

漢諾塔問題

該問題最基礎的一個模型就是,一個竹竿上放了2個圓盤,需要先將最上面的那個移到輔助竹竿上,然後將最底下的圓盤移到目標竹竿,最後把輔助竹竿上的圓盤移回目標竹竿。

public class Hanoi {

    public static void process(String source,String target,String auxiliary,int n){
        if (n == 1) {
            System.out.println("move 1 disk from " + source + " to " + target);
            return;
        }
      	//嘗試把前n-1個圓盤暫時放到輔助竹竿->子問題
        process(source, auxiliary, target, n - 1);
      	//將底下最大的圓盤移到目標竹竿
        System.out.println("move 1 disk from "+source+" to "+target);
      	//再嘗試將輔助竹竿上的圓盤移回到目標竹竿->子問題
        process(auxiliary,target,source,n-1);
    }

    public static void main(String[] args) {
        process("Left", "Right", "Help", 3);
    }
}
複製程式碼

根據Master公式計算得T(N) = T(N-1)+1+T(N-1),時間複雜度為O(2^N)

列印一個字串的所有子序列

字串的子序列和子串有著不同的定義。子串指串中相鄰的任意個字元組成的串,而子序列可以是串中任意個不同字元組成的串。

嘗試:開始時,令子序列為空串,扔給遞迴方法。首先來到字串的第一個字元上,這時會有兩個決策:將這個字元加到子序列和不加到子序列。這兩個決策會產生兩個不同的子序列,將這兩個子序列作為這一級收集的資訊扔給子過程,子過程來到字串的第二個字元上,對上級傳來的子序列又有兩個決策,……這樣最終能將所有子序列組合窮舉出來:

左神直通BAT演算法筆記(基礎篇)-下

/**
	 * 列印字串的所有子序列-遞迴方式
	 * @param str	目標字串
	 * @param index	當前子過程來到了哪個字元的決策上(要還是不要)
	 * @param res	上級扔給本級的子序列
	 */
public static void printAllSubSequences(String str,int index,String res) {
  //base case : 當本級子過程來到的位置到達串末尾,則直接列印
  if(index == str.length()) {
    System.out.println(res);
    return;
  }
  //決策是否要index位置上的字元
  printAllSubSequences(str, index+1, res+str.charAt(index));
  printAllSubSequences(str, index+1, res);
}

public static void main(String[] args) {
  printAllSubSequences("abc", 0, "");
}
複製程式碼

列印一個字串的所有全排列結果

左神直通BAT演算法筆記(基礎篇)-下

/**
	 * 本級任務:將index之後(包括index)位置上的字元和index上的字元交換,將產生的所有結果扔給下一級
	 * @param str
	 * @param index	
	 */
public static void printAllPermutations(char[] chs,int index) {
  //base case
  if(index == chs.length-1) {
    System.out.println(chs);
    return;
  }
  for (int j = index; j < chs.length; j++) {
    swap(chs,index,j);
    printAllPermutations(chs, index+1);
  }
}

public static void swap(char[] chs,int i,int j) {
  char temp = chs[i];
  chs[i] = chs[j];
  chs[j] = temp;
}

public static void main(String[] args) {
  printAllPermutations("abc".toCharArray(), 0);
}
複製程式碼

母牛生牛問題

母牛每年生一隻母牛,新出生的母牛成長三年後也能每年生一隻母牛,假設不會死。求N年後,母牛的數量。

左神直通BAT演算法筆記(基礎篇)-下

那麼求第n年母牛的數量,按照此公式順序計算即可,但這是O(N)的時間複雜度,存在O(logN)的演算法(放到進階篇中討論)。

暴力遞迴改為動態規劃

為什麼要改動態規劃?有什麼意義?

動態規劃由暴力遞迴而來,是對暴力遞迴中的重複計算的一個優化,策略是空間換時間。

最小路徑和

給你一個二維陣列,二維陣列中的每個數都是正數,要求從左上角走到右下角,每一步只能向右或者向下。沿途經過的數字要累加起來。返回最小的路徑和。

遞迴嘗試版本

/**
	 * 從矩陣matrix的(i,j)位置走到右下角元素,返回最小沿途元素和。每個位置只能向右或向下
	 * 
	 * @param matrix
	 * @param i
	 * @param j
	 * @return 最小路徑和
	 */
public static int minPathSum(int matrix[][], int i, int j) {
  // 如果(i,j)就是右下角的元素
  if (i == matrix.length - 1 && j == matrix[0].length - 1) {
    return matrix[i][j];
  }
  // 如果(i,j)在右邊界上,只能向下走
  if (j == matrix[0].length - 1) {
    return matrix[i][j] + minPathSum(matrix, i + 1, j);
  }
  // 如果(i,j)在下邊界上,只能向右走
  if (i == matrix.length - 1) {
    return matrix[i][j] + minPathSum(matrix, i, j + 1);
  }
  // 不是上述三種情況,那麼(i,j)就有向下和向右兩種決策,取決策結果最小的那個
  int left = minPathSum(matrix, i, j + 1);
  int down = minPathSum(matrix, i + 1, j);
  return matrix[i][j] + Math.min(left,down );
}

public static void main(String[] args) {
  int matrix[][] = { 
    { 9, 1, 0, 1 }, 
    { 4, 8, 1, 0 }, 
    { 1, 4, 2, 3 } 
  };
  System.out.println(minPathSum(matrix, 0, 0)); //14
}
複製程式碼

根據嘗試版本改動態規劃

上述暴力遞迴的缺陷在於有些子過程是重複的。比如minPathSum(matrix,0,1)minPathSum(matrix,1,0)都會依賴子過程minPathSum(matrix,1,1)的狀態(執行結果),那麼在計算minPathSum(matrix,0,0)時勢必會導致minPathSum(matrix,1,1)的重複計算。那我們能否通過對子過程計算結果進行快取,在再次需要時直接使用,從而實現對整個過程的一個優化呢。

由暴力遞迴改動態規劃的核心就是將每個子過程的計算結果進行一個記錄,從而達到空間換時間的目的。那麼minPath(int matrix[][],int i,int j)中變數ij的不同取值將導致i*j種結果,我們將這些結果儲存在一個i*j的表中,不就達到動態規劃的目的了嗎?

觀察上述程式碼可知,右下角、右邊界、下邊界這些位置上的元素是不需要嘗試的(只有一種走法,不存在決策問題),因此我們可以直接將這些位置上的結果先算出來:

左神直通BAT演算法筆記(基礎篇)-下

而其它位置上的元素的走法則依賴右方相鄰位置(i,j+1)走到右下角的最小路徑和和下方相鄰位置(i+1,j)走到右下角的最小路徑和的大小比較,基於此來做一個向右走還是向左走的決策。但由於右邊界、下邊界位置上的結果我們已經計算出來了,因此對於其它位置上的結果也就不難確定了:

左神直通BAT演算法筆記(基礎篇)-下

我們從base case開始,倒著推出了所有子過程的計算結果,並且沒有重複計算。最後minPathSum(matrix,0,0)也迎刃而解了。

這就是動態規劃,它不是憑空想出來的。首先我們嘗試著解決這個問題,寫出了暴力遞迴。再由暴力遞迴中的變數的變化範圍建立一張對應的結果記錄表,以base case作為突破口確定能夠直接確定的結果,最後解決普遍情況對應的結果。

一個數是否是陣列中任意個數的和

給你一個陣列arr,和一個整數aim。如果可以任意選擇arr中的數字,能不能累加得到aim,返回true或者false。

此題的思路跟求解一個字串的所有子序列的思路一致,窮舉出陣列中所有任意個數相加的不同結果。

左神直通BAT演算法筆記(基礎篇)-下

暴力遞迴版本

/**
     * 選擇任意個arr中的元素相加是否能得到aim
     *
     * @param arr
     * @param aim
     * @param sum 上級扔給我的結果
     * @param i   決策來到了下標為i的元素上
     * @return
     */
public static boolean isSum(int arr[], int aim, int sum,int i) {
  //決策完畢
  if (i == arr.length) {
    return sum == aim;
  }
  //決策來到了arr[i]:加上arr[i]或不加上。將結果扔給下一級
  return isSum(arr, aim, sum + arr[i], i + 1) || isSum(arr, aim, sum, i + 1);
}

public static void main(String[] args) {
  int arr[] = {1, 2, 3};
  System.out.println(isSum(arr, 5, 0, 0));
  System.out.println(isSum(arr, 6, 0, 0));
  System.out.println(isSum(arr, 7, 0, 0));
}
複製程式碼

暴力遞迴改動態規劃(高度套路)

  1. 首先看遞迴函式的引數,找出變數。這裡arraim是固定不變的,可變的只有sumi

  2. 對應變數的變化範圍建立一張表儲存不同子過程的結果,這裡i的變化範圍是0~arr.length-10~2,而sum的變化範圍是0~陣列元素總和,即0~6。因此需要建一張3*7的表。

  3. base case入手,計算可直接計算的子過程,以isSum(5,0,0)的計算為例,其子過程中“是否+3”的決策之後的結果是可以確定的:

    左神直通BAT演算法筆記(基礎篇)-下

  4. 按照遞迴函式中base case下的嘗試過程,推出其它子過程的計算結果,這裡以i=1,sum=1的推導為例:

    左神直通BAT演算法筆記(基礎篇)-下

哪些暴力遞迴能改為動態規劃

看過上述例題之後你會發現只要你能夠寫出嘗試版本,那麼改動態規劃是高度套路的。但是不是所有的暴力遞迴都能夠改動態規劃呢?不是的,比如漢諾塔問題和N皇后問題,他們的每一步遞迴都是必須的,沒有多餘。這就涉及到了遞迴的有後效性和無後效性。

有後效性和無後效性

無後效性是指對於遞迴中的某個子過程,其上級的決策對該級的後續決策沒有任何影響。比如最小路徑和問題中以下面的矩陣為例:

左神直通BAT演算法筆記(基礎篇)-下

對於(1,1)位置上的8,無論是通過9->1->8還是9->4->8來到這個8上的,這個8到右下角的最小路徑和的計算過程不會改變。這就是無後效性。

只有無後效性的暴力遞迴才能改動態規劃。

雜湊

雜湊函式

左神直通BAT演算法筆記(基礎篇)-下

百科:雜湊函式(英語:Hash function)又稱雜湊演算法雜湊函式,是一種從任何一種資料中建立小的數字“指紋”的方法。雜湊函式把訊息或資料壓縮成摘要,使得資料量變小,將資料的格式固定下來。該函式將輸入域中的資料打亂混合,重新建立一個叫做雜湊值(hash values,hash codes,hash sums,或hashes)的指紋。

雜湊函式的性質

雜湊函式的輸入域可以是非常大的範圍,比如,任意一個字串,但是輸出域是固定的範圍(一定位數的bit),假設為S,並具有如下性質:

  1. 典型的雜湊函式都有無限的輸入值域。
  2. 當給雜湊函式傳入相同的輸入值時,返回值一樣。
  3. 當給雜湊函式傳入不同的輸入值時,返回值可能一樣,也可能不一樣,這時當然的,因為輸出域統一是S,所以會有不同的輸入值對應在S中的一個元素上(這種情況稱為 雜湊衝突)。
  4. 最重要的性質是很多不同的輸入值所得到的返回值會均勻分佈在S上。

前3點性質是雜湊函式的基礎,第4點是評價一個雜湊函式優劣的關鍵,不同輸入值所得到的所有返回值越均勻地分佈在S上,雜湊函式越優秀,並且這種均勻分佈與輸入值出現的規律無關。比如,“aaa1”、“aaa2”、“aaa3”三個輸入值比較類似,但經過優秀的雜湊函式計算後得到的結果應該相差非常大。

雜湊函式的經典實現

參考文獻:雜湊函式的介紹

比如使用MD5對“test”和“test1”兩個字串雜湊的結果如下(雜湊結果為128個bit,資料範圍為0~(2^128)-1,通常轉換為32個16進位制數顯示):

test	098f6bcd4621d373cade4e832627b4f6
test1 5a105e8b9d40e1329780d62ea2265d8a
複製程式碼

雜湊表

百科:雜湊表Hash table,也叫雜湊表),是根據(Key)而直接訪問在記憶體儲存位置的資料結構。也就是說,它通過計算一個關於鍵值的函式,將所需查詢的資料對映到表中一個位置來訪問記錄,這加快了查詢速度。這個對映函式稱做雜湊函式,存放記錄的陣列稱做雜湊表

雜湊表的經典實現

雜湊表初始會有一個大小,比如16,表中每個元素都可以通過陣列下標(0~15)訪問。每個元素可以看做一個桶,當要往表裡放資料時,將要存放的資料的鍵值通過雜湊函式計算出的雜湊值模上16,結果正好對應0~15,將這條資料放入對應下標的桶中。

那麼當資料量超過16時,勢必會存在雜湊衝突(兩條資料經雜湊計算後放入同一個桶中),這時的解決方案就是將後一條入桶的資料作為後繼結點鏈入到桶中已有的資料之後,如此,每個桶中存放的就是一個連結串列。那麼這就是雜湊表的經典結構:

左神直通BAT演算法筆記(基礎篇)-下

當資料量較少時,雜湊表的增刪改查操作的時間複雜度都是O(N)的。因為根據一個鍵值就能定位一個桶,即使存在雜湊衝突(桶裡不只一條資料),但只要雜湊函式優秀,資料量幾乎均分在每個桶上(這樣很少有雜湊衝突,即使有,一個桶裡也只會有很少的幾條資料),那就在遍歷一下桶裡的連結串列比較鍵值進一步定位資料即可(反正連結串列很短)。

雜湊表擴容

如果雜湊表大小為16,對於樣本規模N(要儲存的資料數量)來說,如果N較小,那麼根據雜湊函式的雜湊特性,每個桶會均分這N條資料,這樣落到每個桶的資料量也較小,不會影響雜湊表的存取效率(這是由桶的連結串列長度決定的,因為存資料要往連結串列尾追加首先就要遍歷得到尾結點,取資料要遍歷連結串列比較鍵值);但如果N較大,那麼每個桶裡都有N/16條資料,存取效率就變成O(N)了。因此雜湊表哈需要一個擴容機制,當表中某個桶的資料量超過一個閥值時(O(1)O(N)的轉變,這需要一個演算法來權衡),需要將雜湊表擴容(一般是成倍的)。

擴容步驟是,建立一個新的較大的雜湊表(假如大小為m),將原雜湊表中的資料取出,將鍵值的雜湊值模上m,放入新表對應的桶中,這個過程也叫rehash

如此的話,那麼原來的O(N)就變成了O(log(m/16,N)),比如擴容成5倍那就是O(log(5,N))(以5為底,N的對數)。當這個底數較大的時候就會將N的對數壓得非常低而和O(1)非常接近了,並且實際工程中基本是當成O(1)來用的。

你也許會說rehash很費時,會導致雜湊表效能降低,這一點是可以側面避免的。比如擴容時將倍數提高一些,那麼rehash的次數就會很少,平衡到整個雜湊表的使用來看,影響就甚微了。或者可以進行離線擴容,當需要擴容時,原雜湊表還是供使用者使用,在另外的記憶體中執行rehash,完成之後再將新表替換原表,這樣的話對於使用者來說,他是感覺不到rehash帶來的麻煩的。

雜湊表的JVM實現

Java中,雜湊表的實現是每個桶中放的是一棵紅黑樹而非連結串列,因為紅黑樹的查詢效率很高,也是對雜湊衝突帶來的效能問題的一個優化。

布隆過濾器

不安全網頁的黑名單包含100億個黑名單網頁,每個網頁的URL最多佔用64B。現在想要實現一種網頁過濾系統,可以根據網頁的URL判斷該網頁是否在黑名單上,請設計該系統。

要求如下:

  1. 該系統允許有萬分之一以下的判斷失誤率。
  2. 使用的額外空間不要超過30GB。

如果將這100億個URL通過資料庫或雜湊表儲存起來,就可以對每條URL進行查詢,但是每個URL有64B,數量是100億個,所以至少需要640GB的空間,不滿足要求2。

如果面試者遇到網頁黑名單系統、垃圾郵件過濾系統,爬蟲的網頁判重系統等題目,又看到系統容忍一定程度的失誤率,但是對空間要求比較嚴格,那麼很可能是面試官希望面試者具備布隆過濾器的知識。一個布隆過濾器精確地代表一個集合,並可以精確判斷一個元素是否在集合中。注意,只是精確代表和精確判斷,到底有多精確呢?則完全在於你具體的設計,但想做到完全正確是不可能的。布隆過濾器的優勢就在於使用很少的空間就可以將準確率做到很高的程度。該結構由Burton Howard Bloom於1970年提出。

那麼什麼是布隆過濾器呢?

假設有一個長度為m的bit型別的陣列,即陣列的每個位置只佔一個bit,如果我們所知,每一個bit只有0和1兩種狀態,如圖所示:

左神直通BAT演算法筆記(基礎篇)-下

再假設一共有k個雜湊函式,這些函式的輸出域S都大於或等於m,並且這些雜湊函式都足夠優秀且彼此之間相互獨立(將一個雜湊函式的計算結果乘以6除以7得出的新雜湊函式和原函式就是相互獨立的)。那麼對同一個輸入物件(假設是一個字串,記為URL),經過k個雜湊函式算出來的結果也是獨立的。可能相同,也可能不同,但彼此獨立。對算出來的每一個結果都對m取餘(%m),然後在bit array 上把相應位置設定為1(我們形象的稱為塗黑)。如圖所示

左神直通BAT演算法筆記(基礎篇)-下

我們把bit型別的陣列記為bitMap。至此,一個輸入物件對bitMap的影響過程就結束了,也就是bitMap的一些位置會被塗黑。接下來按照該方法,處理所有的輸入物件(黑名單中的100億個URL)。每個物件都可能把bitMap中的一些白位置塗黑,也可能遇到已經塗黑的位置,遇到已經塗黑的位置讓其繼續為黑即可。處理完所有的輸入物件後,可能bitMap中已經有相當多的位置被塗黑。至此,一個布隆過濾器生成完畢,這個布隆過濾器代表之前所有輸入物件組成的集合。

那麼在檢查階段時,如何檢查一個物件是否是之前的某一個輸入物件呢(判斷一個URL是否是黑名單中的URL)?假設一個物件為a,想檢查它是否是之前的輸入物件,就把a通過k個雜湊函式算出k個值,然後把k個值都取餘(%m),就得到在[0,m-1]範圍傷的k個值。接下來在bitMap上看這些位置是不是都為黑。如果有一個不為黑,說明a一定不再這個集合裡。如果都為黑,說明a在這個集合裡,但可能誤判。

再解釋具體一點,如果a的確是輸入物件 ,那麼在生成布隆過濾器時,bitMap中相應的k個位置一定已經塗黑了,所以在檢查階段,a一定不會被漏過,這個不會產生誤判。會產生誤判的是,a明明不是輸入物件,但如果在生成布隆過濾器的階段因為輸入物件過多,而bitMap過小,則會導致bitMap絕大多數的位置都已經變黑。那麼在檢查a時,可能a對應的k個位置都是黑的,從而錯誤地認為a是輸入物件(即是黑名單中的URL)。通俗地說,布隆過濾器的失誤型別是“寧可錯殺三千,絕不放過一個”。

布隆過濾器到底該怎麼生成呢?只需記住下列三個公式即可:

  • 對於輸入的資料量n(這裡是100億)和失誤率p(這裡是萬分之一),布隆過濾器的大小m:m = - (n*lnp)/(ln2*ln2),計算結果向上取整(這道題m=19.19n,向上取整為20n,即需要2000億個bit,也就是25GB)
  • 需要的雜湊函式的個數k:k = ln2 * m/n = 0.7 * m/n(這道題k = 0.7 * 20n/n = 14
  • 由於前兩步都進行了向上取整,那麼由前兩步確定的布隆過濾器的真正失誤率p:p = (1 - e^(-nk/m))^k

一致性雜湊演算法的基本原理

題目

工程師常使用伺服器叢集來設計和實現資料快取,以下是常見的策略:

  1. 無論是新增、查詢還是珊瑚資料,都先將資料的id通過雜湊函式換成一個雜湊值,記為key
  2. 如果目前機器有N臺,則計算key%N的值,這個值就是該資料所屬的機器編號,無論是新增、刪除還是查詢操作,都只在這臺機器上進行。

請分析這種快取策略可能帶來的問題,並提出改進的方案。

解析

題目中描述的快取從策略的潛在問題是,如果增加或刪除機器時(N變化)代價會很高,所有的資料都不得不根據id重新計算一遍雜湊值,並將雜湊值對新的機器數進行取模啊哦做。然後進行大規模的資料遷移。

為了解決這些問題,下面介紹一下一致性雜湊演算法,這時一種很好的資料快取設計方案。我們假設資料的id通過雜湊函式轉換成的雜湊值範圍是2^32,也就是0~(2^32)-1的數字空間中。現在我們可以將這些數字頭尾相連,想象成一個閉合的環形,那麼一個資料id在計算出雜湊值之後認為對應到環中的一個位置上,如圖所示

左神直通BAT演算法筆記(基礎篇)-下

接下來想象有三臺機器也處在這樣一個環中,這三臺機器在環中的位置根據機器id(主機名或者主機IP,是主機唯一的就行)設計算出的雜湊值對2^32取模對應到環上。那麼一條資料如何確定歸屬哪臺機器呢?我們可以在該資料對應環上的位置順時針尋找離該位置最近的機器,將資料歸屬於該機器上:

左神直通BAT演算法筆記(基礎篇)-下

這樣的話,如果刪除machine2節點,則只需將machine2上的資料遷移到machine3上即可,而不必大動干戈遷移所有資料。當新增節點的時候,也只需將新增節點到逆時針方向新增節點前一個節點這之間的資料遷移給新增節點即可。

但這時還是存在如下兩個問題:

  • 機器較少時,通過機器id雜湊將機器對應到環上之後,幾個機器可能沒有均分環

    左神直通BAT演算法筆記(基礎篇)-下

    那麼這樣會導致負載不均。

  • 增加機器時,可能會打破現有的平衡:

    左神直通BAT演算法筆記(基礎篇)-下

為了解決這種資料傾斜問題,一致性雜湊演算法引入了虛擬節點機制,即對每一臺機器通過不同的雜湊函式計算出多個雜湊值,對多個位置都放置一個服務節點,稱為虛擬節點。具體做法:比如對於machine1的IP192.168.25.132(或機器名),計算出192.168.25.132-1192.168.25.132-2192.168.25.132-3192.168.25.132-4的雜湊值,然後對應到環上,其他的機器也是如此,這樣的話節點數就變多了,根據雜湊函式的性質,平衡性自然會變好:

左神直通BAT演算法筆記(基礎篇)-下

此時資料定位演算法不變,只是多了一步虛擬節點到實際節點的對映,比如上圖的查詢表。當某一條資料計算出歸屬於m2-1時再根據查詢表的跳轉,資料將最終歸屬於實際的m1節點。

基於一致性雜湊的原理有很多種具體的實現,包括Chord演算法、KAD演算法等,有興趣的話可以進一步學習。

RandomPool

設計一種結構,在該結構中有如下三個功能:

  • inserrt(key):將某個key加入到該結構中,做到不重複加入。
  • delete(key):將原本在結構中的某個key移除。
  • getRandom():等概率隨機返回結構中的任何一個key。

要求:insert、delete和getRandom方法的時間複雜度都是O(1)

思路:使用兩個雜湊表和一個變數size,一個表存放某key的標號,另一個表根據根據標號取某個keysize用來記錄結構中的資料量。加入key時,將size作為該key的標號加入到兩表中;刪除key時,將標號最大的key替換它並將size--;隨機取key時,將size範圍內的隨機數作為標號取key

import java.util.HashMap;

public class RandomPool {
    public int size;
    public HashMap<Object, Integer> keySignMap;
    public HashMap<Integer, Object> signKeyMap;

    public RandomPool() {
        this.size = 0;
        this.keySignMap = new HashMap<>();
        this.signKeyMap = new HashMap<>();
    }

    public void insert(Object key) {
        //不重複新增
        if (keySignMap.containsKey(key)) {
            return;
        }
        keySignMap.put(key, size);
        signKeyMap.put(size, key);
        size++;
    }

    public void delete(Object key) {
        if (keySignMap.containsKey(key)) {
            Object lastKey = signKeyMap.get(--size);
            int deleteSign = keySignMap.get(key);
            keySignMap.put(lastKey, deleteSign);
            signKeyMap.put(deleteSign, lastKey);
            keySignMap.remove(key);
            signKeyMap.remove(lastKey);
        }
    }

    public Object getRandom() {
        if (size > 0) {
            return signKeyMap.get((int) (Math.random() * size));
        }
        return null;
    }

}
複製程式碼

小技巧

對數器

概述

有時我們對編寫的演算法進行測試時,會採用自己編造幾個簡單資料進行測試。然而別人測試時可能會將大數量級的資料輸入進而測試演算法的準確性和健壯性,如果這時出錯,面對龐大的資料量我們將無從查起(是在操作哪一個資料時出了錯,演算法沒有如期起作用)。當然我們不可能對這樣一個大資料進行斷點除錯,去一步一步的分析錯誤點在哪。這時 對數器 就粉墨登場了,對數器 就是通過隨機制造出幾乎所有可能的簡短樣本作為演算法的輸入樣本對演算法進行測試,這樣大量不同的樣本從大概率上保證了演算法的準確性,當有樣本測試未通過時又能列印該簡短樣本對錯誤原因進行分析。

對數器的使用

  1. 對於你想測試的演算法
  2. 實現功能與該演算法相同但絕對正確、複雜度不好的演算法
  3. 準備大量隨機的簡短樣本的
  4. 實現比對的方法:對於每一個樣本,比對該演算法和第二步中演算法的執行結果以判斷該演算法的正確性
  5. 如果有一個樣本比對出錯則列印該樣本
  6. 當樣本數量很多時比對測試依然正確,可以確定演算法a已經正確

對數器使用案例——對自寫的插入排序進行測試:

void swap(int *a, int *b){
    int temp = *a;
    *a = *b;
    *b = temp;
}

//1.有一個自寫的演算法,但不知其健壯性(是否會有特殊情況使程式異常中斷甚至崩潰)和正確性
void insertionSort(int arr[], int length){
    if(arr==NULL || length<=1){
        return;
    }
    for (int i = 1; i < length; ++i) {
        for (int j = i - 1; j >= 0 || arr[j] <= arr[j + 1]; j--) {
            if (arr[j] > arr[j + 1]) {
                swap(&arr[j], &arr[j + 1]);
            }
        }
    }
}

//2、實現一個功能相同、絕對正確但複雜度不好的演算法(這裡摘取大家熟知的氣泡排序)
void bubbleSort(int arr[], int length) {
    for (int i = length-1; i > 0; i--) {
        for (int j = 0; j < i; ++j) {
            if (arr[j] > arr[j + 1]) {
                swap(&arr[j], &arr[j + 1]);
            }
        }
    }
}

//3、實現一個能夠產生隨機簡短樣本的方法
void generateSample(int arr[], int length){
    for (int i = 0; i < length; ++i) {
        arr[i] = rand() % 100-rand()%100;//控制元素在-100~100之間,考慮到零正負三種情況
    }
}

//4、實現一個比對測試演算法和正確演算法運算結果的方法
bool isEqual(int arr1[],int arr2[],int length) {
    if (arr1 != NULL && arr2 != NULL) {
        for (int i = 0; i < length; ++i) {
            if (arr1[i] != arr2[i]) {
                return false;
            }
        }
        return true;
    }
    return false;
}

void travels(int arr[], int length){
    for (int i = 0; i < length; ++i) {
        printf("%d ", arr[i]);
    }
    printf("\n");
}

void copy(int source[], int target[],int length){
    for (int i = 0; i < length; ++i) {
        target[i] = source[i];
    }
}

int main(){

    srand(time(NULL));
    int testTimes=10000;       
    //迴圈產生100000個樣本進行測試
    for (int i = 0; i < testTimes; ++i) {
        int length = rand() % 10;   //控制每個樣本的長度在10以內,便於出錯時分析樣本(因為簡短)
        int arr[length];
        generateSample(arr, length);

      	//不要改變原始樣本,在複製樣本上改動
        int arr1[length], arr2[length];
        copy(arr, arr1, length);
        copy(arr, arr2, length);
        bubbleSort(arr1,length);
        insertionSort(arr2, length);

//        travels(arr, length);
//        travels(arr1, length);

      	//5、比對兩個演算法,只要有一個樣本沒通過就終止,並列印原始樣本
        if (!isEqual(arr1, arr2, length)) {
            printf("test fail!the sample is: ");
            travels(arr, length);
            return 0;
        }
    }
   
  	//6、測試全部通過,該演算法大概率上正確
    printf("nice!");
    return 0;
}
複製程式碼

列印二叉樹

有時我們不確定二叉樹中是否有指標連空了或者連錯了,這時需要將二叉樹具有層次感地列印出來,下面就提供了這樣一個工具。你可以將你的頭逆時針旋轉90度看列印結果。v表示該結點的頭結點是左下方距離該結點最近的一個結點,^表示該結點的頭結點是左上方距離該結點最近的一個結點。

package top.zhenganwen.algorithmdemo.recursive;

public class PrintBinaryTree {

	public static class Node {
		public int value;
		public Node left;
		public Node right;

		public Node(int data) {
			this.value = data;
		}
	}

	public static void printTree(Node head) {
		System.out.println("Binary Tree:");
		printInOrder(head, 0, "H", 17);
		System.out.println();
	}

	public static void printInOrder(Node head, int height, String to, int len) {
		if (head == null) {
			return;
		}
		printInOrder(head.right, height + 1, "v", len);
		String val = to + head.value + to;
		int lenM = val.length();
		int lenL = (len - lenM) / 2;
		int lenR = len - lenM - lenL;
		val = getSpace(lenL) + val + getSpace(lenR);
		System.out.println(getSpace(height * len) + val);
		printInOrder(head.left, height + 1, "^", len);
	}

	public static String getSpace(int num) {
		String space = " ";
		StringBuffer buf = new StringBuffer("");
		for (int i = 0; i < num; i++) {
			buf.append(space);
		}
		return buf.toString();
	}

	public static void main(String[] args) {
		Node head = new Node(1);
		head.left = new Node(-222222222);
		head.right = new Node(3);
		head.left.left = new Node(Integer.MIN_VALUE);
		head.right.left = new Node(55555555);
		head.right.right = new Node(66);
		head.left.left.right = new Node(777);
		printTree(head);

		head = new Node(1);
		head.left = new Node(2);
		head.right = new Node(3);
		head.left.left = new Node(4);
		head.right.left = new Node(5);
		head.right.right = new Node(6);
		head.left.left.right = new Node(7);
		printTree(head);

		head = new Node(1);
		head.left = new Node(1);
		head.right = new Node(1);
		head.left.left = new Node(1);
		head.right.left = new Node(1);
		head.right.right = new Node(1);
		head.left.left.right = new Node(1);
		printTree(head);

	}

}
複製程式碼

遞迴的實質和Master公式

遞迴的實質

遞迴的實質就是系統在幫我們壓棧。首先讓我們來看一個遞迴求階乘的例子:

int fun(int n){
	if(n==0){
    return 1;
	}
  return n*fun(n-1);
}
複製程式碼

課上老師一般告訴我們遞迴就是函式自己呼叫自己。但這聽起來很玄學。事實上,在函式執行過程中如果呼叫了其他函式,那麼當前函式的執行狀態(執行到了第幾行,有幾個變數,各個變數的值是什麼等等)會被儲存起來壓進棧(先進後出的儲存結構,一般稱為函式呼叫棧)中,轉而執行子過程(呼叫的其他函式,當然也可以是當前函式)。若子過程中又呼叫了函式,那麼呼叫前子過程的執行狀態也會被儲存起來壓進棧中,轉而執行子過程的子過程……以此類推,直到有一個子過程沒有呼叫函式、能順序執行完畢時會從函式呼叫棧依次彈出棧頂被儲存起來的未執行完的函式(恢復現場)繼續執行,直到函式呼叫棧中的函式都執行完畢,整個遞迴過程結束。

例如,在main中執行fun(3),其遞迴過程如下:

int main(){
  int i = fun(3);
  printf("%d",i);
  return 0;
}
複製程式碼

左神直通BAT演算法筆記(基礎篇)-下

很多時候我們分析遞迴時都喜歡在心中模擬程式碼執行,去追溯、還原整個遞迴呼叫過程。但事實上沒有必要這樣做,因為每相鄰的兩個步驟執行的邏輯都是相同的,因此我們只需要分析第一步到第二步是如何執行的以及遞迴的終點在哪裡就可以了。

一切的遞迴演算法都可以轉化為非遞迴,因為我們完全可以自己壓棧。只是說遞迴的寫法更加簡潔。在實際工程中,遞迴的使用是極少的,因為遞迴建立子函式的開銷很大並且存在安全問題(stack overflow)。

Master公式

包含遞迴的演算法的時間複雜度有時很難通過演算法表面分析出來, 比如 歸併排序。這時Master公式就粉墨登場了,當某遞迴演算法的時間複雜度符合T(n)=aT(n/b)+O(n^d)形式時可以直接求出該演算法的直接複雜度:

  • 當(以b為底a的對數)log(b,a) > d時,時間複雜度為O(n^log(b,a))
  • log(b,a) = d時,時間複雜度為O(n^d * logn)
  • log(b,a) < d時,時間複雜度為O(n^d)

其中,n為樣本規模,n/b為子過程的樣本規模(暗含子過程的樣本規模必須相同,且相加之和等於總樣本規模),a為子過程的執行次數,O(n^d)為除子過程之後的操作的時間複雜度。

以歸併排序為例,函式本體先對左右兩半部分進行歸併排序,樣本規模被分為了左右各n/2b=2,左右各歸併排序了一次,子過程執行次數為2a=2,併入操作的時間複雜度為O(n+n)=O(n)d=1,因此T(n)=2T(n/2)+O(n),符合log(b,a)=d=1,因此歸併排序的時間複雜度O(n^1*logn)=O(nlogn)

相關文章