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

我的技術博客:www.zhenganwen.topjava

二叉樹

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

遞歸方式

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是當某個結點爲空時中止子過程的展開。這種遞歸嘗試是由二叉樹自己的結構所決定的,由於二叉樹上的任意結點均可看作一棵二叉樹的根結點(即便是葉子結點,也能夠看作是一棵左右子樹爲空的二叉樹根結點)。node

觀察先序、中序、後序三個遞歸方法你會發現,不一樣點在於打印當前結點的值這一操做的時機。你會發現每一個結點會被訪問三次:進入方法時算一次、遞歸處理左子樹完成以後返回時算一次、遞歸處理右子樹完成以後返回時算一次。所以在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的走向是隻要左孩子不爲空就走向左孩子。當左孩子爲空時彈出棧頂結點(此時該結點是一棵左子樹爲空的樹的根結點,根據中序遍歷能夠直接打印該結點,而後中序遍歷該結點的右子樹)打印,若是該結點的右孩子非空(說明有右子樹),那麼將其右孩子壓棧,這個右孩子又多是一棵子樹的根節點,所以將這棵子樹的左邊界壓棧,這時回到了開頭,以此類推。api

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安全

你固然能夠將二叉樹中序遍歷一下,在遍歷到該結點的時候標記一下,那麼下一個要打印的結點就是該結點的後繼結點。bash

咱們能夠推測一下,當咱們來到二叉樹中的某個結點時,若是它的右子樹非空,那麼它的後繼結點必定是它的右子樹中最靠左的那個結點;若是它的右孩子爲空,那麼它的後繼結點必定是它的祖先結點中,把它當作左子孫(它存在於祖先結點的左子樹中)的那一個,不然它沒有後繼結點。

這裏若是它的右孩子爲空的狀況比較難分析,咱們能夠藉助一個指針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;
        }
    }
    /** * 遍歷時,來到某個結點須要收集的信息 * 一、以該結點爲根節點的樹是不是平衡二叉樹 * 二、該結點的高度 */
    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:將兩個子集合併成同一個集合。

並查集結構的實現

首先並查集自己是一個結構,咱們在構造它的時候須要將全部要操做的數據扔進去,初始時每一個數據自成一個結點,且每一個結點都有一個父指針(初始時指向本身)。

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

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

還有一個find操做:查找兩個結點是否所屬同一個集合。咱們只需判斷兩個結點所在集合的表明結點是不是同一個就能夠了:

代碼示例:

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,並且利用遞歸作了一個優化:找一個結點所在集合的表明結點時,會不停地向上找父指針指向本身的結點,最後在遞歸回退時將沿途路過的結點的父指針改成直接指向表明結點:

誠然,這樣作是爲了提升下一次查找的效率。

並查集的應用

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

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

首先將切割邊界處的數據封裝成結點加入到並查集中併合並同一個島上的結點,在分析邊界時,查邊界兩邊的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;
  }

}
複製代碼

會議室項目宣講

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

貪心策略:

一、開始時間最先的項目先安排。反例:開始時間最先,但持續時間佔了一成天,其餘項目沒法安排。

二、持續時間最短的項目先安排。反例:這樣安排會致使結束時間在此期間和開始時間在此期間的全部項目不能安排。

三、最優策略:最早結束的項目先安排。

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)

打印一個字符串的全部子序列

字符串的子序列和子串有着不一樣的定義。子串指串中相鄰的任意個字符組成的串,而子序列能夠是串中任意個不一樣字符組成的串。

嘗試:開始時,令子序列爲空串,扔給遞歸方法。首先來到字符串的第一個字符上,這時會有兩個決策:將這個字符加到子序列和不加到子序列。這兩個決策會產生兩個不一樣的子序列,將這兩個子序列做爲這一級收集的信息扔給子過程,子過程來到字符串的第二個字符上,對上級傳來的子序列又有兩個決策,……這樣最終能將全部子序列組合窮舉出來:

/** * 打印字符串的全部子序列-遞歸方式 * @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, "");
}
複製代碼

打印一個字符串的全部全排列結果

/** * 本級任務:將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年後,母牛的數量。

那麼求第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的表中,不就達到動態規劃的目的了嗎?

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

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

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

這就是動態規劃,它不是憑空想出來的。首先咱們嘗試着解決這個問題,寫出了暴力遞歸。再由暴力遞歸中的變量的變化範圍創建一張對應的結果記錄表,以base case做爲突破口肯定可以直接肯定的結果,最後解決廣泛狀況對應的結果。

一個數是不是數組中任意個數的和

給你一個數組arr,和一個整數aim。若是能夠任意選擇arr中的數字,能不能累加獲得aim,返回true或者false。

此題的思路跟求解一個字符串的全部子序列的思路一致,窮舉出數組中全部任意個數相加的不一樣結果。

暴力遞歸版本

/** * 選擇任意個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」的決策以後的結果是能夠肯定的:

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

哪些暴力遞歸能改成動態規劃

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

有後效性和無後效性

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

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

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

哈希

哈希函數

百科:散列函數(英語: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時,勢必會存在哈希衝突(兩條數據經哈希計算後放入同一個桶中),這時的解決方案就是將後一條入桶的數據做爲後繼結點鏈入到桶中已有的數據以後,如此,每一個桶中存放的就是一個鏈表。那麼這就是哈希表的經典結構:

當數據量較少時,哈希表的增刪改查操做的時間複雜度都是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兩種狀態,如圖所示:

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

咱們把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在計算出哈希值以後認爲對應到環中的一個位置上,如圖所示

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

這樣的話,若是刪除machine2節點,則只需將machine2上的數據遷移到machine3上便可,而沒必要大動干戈遷移全部數據。當添加節點的時候,也只需將新增節點到逆時針方向新增節點前一個節點這之間的數據遷移給新增節點便可。

但這時仍是存在以下兩個問題:

  • 機器較少時,經過機器id哈希將機器對應到環上以後,幾個機器可能沒有均分環

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

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

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

此時數據定位算法不變,只是多了一步虛擬節點到實際節點的映射,好比上圖的查找表。當某一條數據計算出歸屬於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]);
            }
        }
    }
}

//二、實現一個功能相同、絕對正確但複雜度很差的算法(這裏摘取你們熟知的冒泡排序)
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]);
            }
        }
    }
}

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

//四、實現一個比對測試算法和正確算法運算結果的方法
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);

      	//五、比對兩個算法,只要有一個樣本沒經過就終止,並打印原始樣本
        if (!isEqual(arr1, arr2, length)) {
            printf("test fail!the sample is: ");
            travels(arr, length);
            return 0;
        }
    }
   
  	//六、測試所有經過,該算法大機率上正確
    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;
}
複製代碼

不少時候咱們分析遞歸時都喜歡在心中模擬代碼執行,去追溯、還原整個遞歸調用過程。但事實上沒有必要這樣作,由於每相鄰的兩個步驟執行的邏輯都是相同的,所以咱們只須要分析第一步到第二步是如何執行的以及遞歸的終點在哪裏就能夠了。

一切的遞歸算法均可以轉化爲非遞歸,由於咱們徹底能夠本身壓棧。只是說遞歸的寫法更加簡潔。在實際工程中,遞歸的使用是極少的,由於遞歸建立子函數的開銷很大而且存在安全問題(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)

相關文章
相關標籤/搜索