社招面經總結——演算法題篇

碼蹄疾發表於2019-04-08

面試結果

總結下最近的面試:

  • 頭條後端:3面技術面掛
  • 螞蟻支付寶營銷-機器學習平臺開發: 技術面通過,年後被通知只有P7的hc
  • 螞蟻中臺-機器學習平臺開發: 技術面通過, 被螞蟻HR掛掉(脈脈上好多人遇到這種情況,一個是今年大環境不好,另一個,面試儘量不要趕上阿里財年年底,這算是一點tips吧)
  • 快手後端: 拿到offer
  • 百度後端: 拿到offer

最終拒了百度,去快手了, 一心想去阿里, 個人有點阿里情節吧,緣分差點。 總結下最近的面試情況, 由於面了20多面, 就按照題型分類給大家一個總結。推薦大家每年都要抽出時間去面一下,不一定跳槽,但是需要知道自己的不足,一定要你的工齡匹配上你的能力。比如就我個人來說,通過面試我知道資料庫的知識不是很懂,再加上由於所在組對資料庫接觸較少,這就是短板,作為一個後端工程師對資料庫說不太瞭解是很可恥的,在選擇offer的時候就可以適當有偏向性。下面分專題把最近的面試總結和大家總結一下。過分簡單的就不說了,比如列印一個圖形啥的, 還有的我不太記得清了,比如快手一面好像是一道動態規劃的題目。當然,可能有的解決方法不是很好,大家可以在手撕程式碼群裡討論。最後一篇我再談一下一些面試的技巧啥的。麻煩大家點贊轉發支援下~

股票買賣(頭條)

Leetcode 上有三題股票買賣,面試的時候只考了兩題,分別是easy 和medium的難度

Leetcode 121. 買賣股票的最佳時機

給定一個陣列,它的第 i 個元素是一支給定股票第 i 天的價格。

如果你最多隻允許完成一筆交易(即買入和賣出一支股票),設計一個演算法來計算你所能獲取的最大利潤。

注意你不能在買入股票前賣出股票。

示例 1:

輸入: [7,1,5,3,6,4]
輸出: 5
複製程式碼

解釋: 在第 2 天(股票價格 = 1)的時候買入,在第 5 天(股票價格 = 6)的時候賣出,最大利潤 = 6-1 = 5 。 注意利潤不能是 7-1 = 6, 因為賣出價格需要大於買入價格。 示例 2:

輸入: [7,6,4,3,1]
輸出: 0
複製程式碼

解釋: 在這種情況下, 沒有交易完成, 所以最大利潤為 0。

題解

紀錄兩個狀態, 一個是最大利潤, 另一個是遍歷過的子序列的最小值。知道之前的最小值我們就可以算出當前天可能的最大利潤是多少

class Solution {
    public int maxProfit(int[] prices) {
        // 7,1,5,3,6,4
        int maxProfit = 0;
        int minNum = Integer.MAX_VALUE;
        for (int i = 0; i < prices.length; i++) {
            if (Integer.MAX_VALUE != minNum && prices[i] - minNum > maxProfit) {
                maxProfit = prices[i] - minNum;
            }

            if (prices[i] < minNum) {
                minNum = prices[i];
            }
        }
        return maxProfit;
    }
}
複製程式碼

Leetcode 122. 買賣股票的最佳時機 II

這次改成股票可以買賣多次, 但是你必須要在出售股票之前把持有的股票賣掉。 示例 1:

輸入: [7,1,5,3,6,4]
輸出: 7
解釋: 在第 2 天(股票價格 = 1)的時候買入,在第 3 天(股票價格 = 5)的時候賣出, 這筆交易所能獲得利潤 = 5-1 = 4 。
     隨後,在第 4 天(股票價格 = 3)的時候買入,在第 5 天(股票價格 = 6)的時候賣出, 這筆交易所能獲得利潤 = 6-3 = 3 。
複製程式碼

示例 2:

輸入: [1,2,3,4,5]
輸出: 4
解釋: 在第 1 天(股票價格 = 1)的時候買入,在第 5 天 (股票價格 = 5)的時候賣出, 這筆交易所能獲得利潤 = 5-1 = 4 。
     注意你不能在第 1 天和第 2 天接連購買股票,之後再將它們賣出。
     因為這樣屬於同時參與了多筆交易,你必須在再次購買前出售掉之前的股票。
複製程式碼

示例 3:

輸入: [7,6,4,3,1]
輸出: 0
解釋: 在這種情況下, 沒有交易完成, 所以最大利潤為 0。
複製程式碼

題解

由於可以無限次買入和賣出。我們都知道炒股想掙錢當然是低價買入高價丟擲,那麼這裡我們只需要從第二天開始,如果當前價格比之前價格高,則把差值加入利潤中,因為我們可以昨天買入,今日賣出,若明日價更高的話,還可以今日買入,明日再丟擲。以此類推,遍歷完整個陣列後即可求得最大利潤。

class Solution {
    public int maxProfit(int[] prices) {
        // 7,1,5,3,6,4
        int maxProfit = 0;
        for (int i = 0; i < prices.length; i++) {
            if (i != 0 && prices[i] - prices[i-1] > 0) {
                maxProfit += prices[i] - prices[i-1];
            }
        }
        return maxProfit;
    }
}
複製程式碼

LRU cache (頭條、螞蟻)

這道題目是頭條的高頻題目,甚至我懷疑,頭條這個面試題是題庫裡面的必考題。看脈脈也是好多人遇到。第一次我寫的時候沒寫好,可能由於這個掛了。

轉自知乎:zhuanlan.zhihu.com/p/34133067

題目

運用你所掌握的資料結構,設計和實現一個 LRU (最近最少使用) 快取機制。它應該支援以下操作: 獲取資料 get 和 寫入資料 put 。

獲取資料 get(key) - 如果金鑰 (key) 存在於快取中,則獲取金鑰的值(總是正數),否則返回 -1。 寫入資料 put(key, value) - 如果金鑰不存在,則寫入其資料值。當快取容量達到上限時,它應該在寫入新資料之前刪除最近最少使用的資料值,從而為新的資料值留出空間。

進階:

你是否可以在 O(1) 時間複雜度內完成這兩種操作?

LRUCache cache = new LRUCache( 2 /* 快取容量 */ );

cache.put(1, 1);
cache.put(2, 2);
cache.get(1);       // 返回  1
cache.put(3, 3);    // 該操作會使得金鑰 2 作廢
cache.get(2);       // 返回 -1 (未找到)
cache.put(4, 4);    // 該操作會使得金鑰 1 作廢
cache.get(1);       // 返回 -1 (未找到)
cache.get(3);       // 返回  3
cache.get(4);       // 返回  4
複製程式碼

題解

這道題在今日頭條、快手或者矽谷的公司中是比較常見的,程式碼要寫的還蠻多的,難度也是hard級別。

最重要的是LRU 這個策略怎麼去實現, 很容易想到用一個連結串列去實現最近使用的放在連結串列的最前面。 比如get一個元素,相當於被使用過了,這個時候它需要放到最前面,再返回值, set同理。 那如何把一個連結串列的中間元素,快速的放到連結串列的開頭呢? 很自然的我們想到了雙端連結串列。

基於 HashMap 和 雙向連結串列實現 LRU 的

整體的設計思路是,可以使用 HashMap 儲存 key,這樣可以做到 save 和 get key的時間都是 O(1),而 HashMap 的 Value 指向雙向連結串列實現的 LRU 的 Node 節點,如圖所示。

image.png

LRU 儲存是基於雙向連結串列實現的,下面的圖演示了它的原理。其中 head 代表雙向連結串列的表頭,tail 代表尾部。首先預先設定 LRU 的容量,如果儲存滿了,可以通過 O(1) 的時間淘汰掉雙向連結串列的尾部,每次新增和訪問資料,都可以通過 O(1)的效率把新的節點增加到對頭,或者把已經存在的節點移動到隊頭。

下面展示了,預設大小是 3 的,LRU儲存的在儲存和訪問過程中的變化。為了簡化圖複雜度,圖中沒有展示 HashMap部分的變化,僅僅演示了上圖 LRU 雙向連結串列的變化。我們對這個LRU快取的操作序列如下:

save("key1", 7)
save("key2", 0)
save("key3", 1)
save("key4", 2)
get("key2")
save("key5", 3)
get("key2")
save("key6", 4)
複製程式碼

相應的 LRU 雙向連結串列部分變化如下:

image.png

總結一下核心操作的步驟:

save(key, value),首先在 HashMap 找到 Key 對應的節點,如果節點存在,更新節點的值,並把這個節點移動隊頭。如果不存在,需要構造新的節點,並且嘗試把節點塞到隊頭,如果LRU空間不足,則通過 tail 淘汰掉隊尾的節點,同時在 HashMap 中移除 Key。

get(key),通過 HashMap 找到 LRU 連結串列節點,因為根據LRU 原理,這個節點是最新訪問的,所以要把節點插入到隊頭,然後返回快取的值。

    private static class DLinkedNode {
        int key;
        int value;
        DLinkedNode pre;
        DLinkedNode post;
    }

    /**
     * 總是在頭節點中插入新節點.
     */
    private void addNode(DLinkedNode node) {

        node.pre = head;
        node.post = head.post;

        head.post.pre = node;
        head.post = node;
    }

    /**
     * 摘除一個節點.
     */
    private void removeNode(DLinkedNode node) {
        DLinkedNode pre = node.pre;
        DLinkedNode post = node.post;

        pre.post = post;
        post.pre = pre;
    }

    /**
     * 摘除一個節點,並且將它移動到開頭
     */
    private void moveToHead(DLinkedNode node) {
        this.removeNode(node);
        this.addNode(node);
    }

    /**
     * 彈出最尾巴節點
     */
    private DLinkedNode popTail() {
        DLinkedNode res = tail.pre;
        this.removeNode(res);
        return res;
    }

    private HashMap<Integer, DLinkedNode>
            cache = new HashMap<Integer, DLinkedNode>();
    private int count;
    private int capacity;
    private DLinkedNode head, tail;

    public LRUCache(int capacity) {
        this.count = 0;
        this.capacity = capacity;

        head = new DLinkedNode();
        head.pre = null;

        tail = new DLinkedNode();
        tail.post = null;

        head.post = tail;
        tail.pre = head;
    }

    public int get(int key) {

        DLinkedNode node = cache.get(key);
        if (node == null) {
            return -1; // cache裡面沒有
        }

        // cache 命中,挪到開頭
        this.moveToHead(node);

        return node.value;
    }


    public void put(int key, int value) {
        DLinkedNode node = cache.get(key);

        if (node == null) {

            DLinkedNode newNode = new DLinkedNode();
            newNode.key = key;
            newNode.value = value;

            this.cache.put(key, newNode);
            this.addNode(newNode);

            ++count;

            if (count > capacity) {
                // 最後一個節點彈出
                DLinkedNode tail = this.popTail();
                this.cache.remove(tail.key);
                count--;
            }
        } else {
            // cache命中,更新cache.
            node.value = value;
            this.moveToHead(node);
        }
    }
    
複製程式碼

二叉樹層次遍歷(頭條)

這個題目之前也講過,Leetcode 102題。

題目

給定一個二叉樹,返回其按層次遍歷的節點值。 (即逐層地,從左到右訪問所有節點)。

例如: 給定二叉樹: [3,9,20,null,null,15,7],

    3
   / \
  9  20
    /  \
   15   7
複製程式碼

返回其層次遍歷結果:

[
  [3],
  [9,20],
  [15,7]
]
複製程式碼

題解

我們資料結構的書上教的層序遍歷,就是利用一個佇列,不斷的把左子樹和右子樹入隊。但是這個題目還要要求按照層輸出。所以關鍵的問題是: 如何確定是在同一層的。 我們很自然的想到: 如果在入隊之前,把上一層所有的節點出隊,那麼出隊的這些節點就是上一層的列表。 由於佇列是先進先出的資料結構,所以這個列表是從左到右的。

/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode(int x) { val = x; }
 * }
 */
class Solution {
    public List<List<Integer>> levelOrder(TreeNode root) {
        List<List<Integer>> res = new LinkedList<>();
        if (root == null) {
            return res;
        }

        LinkedList<TreeNode> queue = new LinkedList<>();
        queue.add(root);
        while (!queue.isEmpty()) {
            int size = queue.size();
            List<Integer> currentRes = new LinkedList<>();
            // 當前佇列的大小就是上一層的節點個數, 依次出隊
            while (size > 0) {
                TreeNode current = queue.poll();
                if (current == null) {
                    continue;
                }
                currentRes.add(current.val);
                // 左子樹和右子樹入隊.
                if (current.left != null) {
                    queue.add(current.left);
                }
                if (current.right != null) {
                    queue.add(current.right);
                }
                size--;
            }
            res.add(currentRes);
        }
        return res;
    }
}
複製程式碼

這道題可不可以用非遞迴來解呢?

遞迴的子問題:遍歷當前節點, 對於當前層, 遍歷左子樹的下一層層,遍歷右子樹的下一層

遞迴結束條件: 當前層,當前子樹節點是null

/**
 * Definition for a binary tree node.
 * public class TreeNode {
 *     int val;
 *     TreeNode left;
 *     TreeNode right;
 *     TreeNode(int x) { val = x; }
 * }
 */
class Solution {
    public List<List<Integer>> levelOrder(TreeNode root) {
        List<List<Integer>> res = new LinkedList<>();
        if (root == null) {
            return res;
        }
        levelOrderHelper(res, root, 0);
        return res;
    }

    /**
     * @param depth 二叉樹的深度
     */
    private void levelOrderHelper(List<List<Integer>> res, TreeNode root, int depth) {
        if (root == null) {
            return;
        }
        
        if (res.size() <= depth) {
            // 當前層的第一個節點,需要new 一個list來存當前層.
            res.add(new LinkedList<>());
        }
        // depth 層,把當前節點加入
        res.get(depth).add(root.val);
        // 遞迴的遍歷下一層.
        levelOrderHelper(res, root.left, depth + 1);
        levelOrderHelper(res, root.right, depth + 1);
    }
}
複製程式碼

二叉樹轉連結串列(快手)

這是Leetcode 104題。 給定一個二叉樹,原地將它展開為連結串列。

例如,給定二叉樹


    1
   / \
  2   5
 / \   \
3   4   6
複製程式碼

將其展開為:

1
 \
  2
   \
    3
     \
      4
       \
        5
         \
          6
複製程式碼

這道題目的關鍵是轉換的時候遞迴的時候記住是先轉換右子樹,再轉換左子樹。 所以需要記錄一下右子樹轉換完之後連結串列的頭結點在哪裡。注意沒有新定義一個next指標,而是直接將right 當做next指標,那麼Left指標我們賦值成null就可以了。

class Solution {
    private TreeNode prev = null;

    public void flatten(TreeNode root) {
        if (root == null)  return;
        flatten(root.right); // 先轉換右子樹
        flatten(root.left); 
        root.right = prev;  // 右子樹指向連結串列的頭
        root.left = null; // 把左子樹置空
        prev = root; // 當前結點為連結串列頭
    }
}
複製程式碼

用遞迴解法,用一個stack 記錄節點,右子樹先入棧,左子樹後入棧。

class Solution {
    public void flatten(TreeNode root) {
        if (root == null) return;
        Stack<TreeNode> stack = new Stack<TreeNode>();
        stack.push(root);
        while (!stack.isEmpty()) {
            TreeNode current = stack.pop();
            if (current.right != null) stack.push(current.right);
            if (current.left != null) stack.push(current.left);
            if (!stack.isEmpty()) current.right = stack.peek();
            current.left = null;
        }
    }
}
複製程式碼

二叉樹尋找最近公共父節點(快手)

Leetcode 236 二叉樹的最近公共父親節點

題解

從根節點開始遍歷,如果node1和node2中的任一個和root匹配,那麼root就是最低公共祖先。 如果都不匹配,則分別遞迴左、右子樹,如果有一個 節點出現在左子樹,並且另一個節點出現在右子樹,則root就是最低公共祖先. 如果兩個節點都出現在左子樹,則說明最低公共祖先在左子樹中,否則在右子樹。

public class Solution {  
    public TreeNode lowestCommonAncestor(TreeNode root, TreeNode p, TreeNode q) {  
        //發現目標節點則通過返回值標記該子樹發現了某個目標結點  
        if(root == null || root == p || root == q) return root;  
        //檢視左子樹中是否有目標結點,沒有為null  
        TreeNode left = lowestCommonAncestor(root.left, p, q);  
        //檢視右子樹是否有目標節點,沒有為null  
        TreeNode right = lowestCommonAncestor(root.right, p, q);  
        //都不為空,說明做右子樹都有目標結點,則公共祖先就是本身  
        if(left!=null&&right!=null) return root;  
        //如果發現了目標節點,則繼續向上標記為該目標節點  
        return left == null ? right : left;  
    }  
}
複製程式碼

資料流求中位數(螞蟻)

面了螞蟻中臺的團隊,二面面試官根據彙報層級推測應該是P9級別及以上,在美國面我,面試風格偏矽谷那邊。題目是hard難度的,leetcode 295題。 這道題目是Leetcode的hard難度的原題。給定一個資料流,求資料流的中位數,求中位數或者topK的問題我們通常都會想用堆來解決。 但是面試官又進一步加大了難度,他要求記憶體使用很小,沒有磁碟,但是壓榨空間的同時可以忍受一定時間的損耗。且面試官不僅要求說出思路,要寫出完整可經過大資料檢測的production code。

先不考慮記憶體

不考慮記憶體的方式就是Leetcode 論壇上的題解。 基本思想是建立兩個堆。左邊是大根堆,右邊是小根堆。 如果是奇數的時候,大根堆的堆頂是中位數。

例如:[1,2,3,4,5] 大根堆建立如下:

      3
     / \
    1   2
複製程式碼

小根堆建立如下:

      4
     / 
    5   
複製程式碼

偶數的時候則是最大堆和最小堆頂的平均數。

例如: [1, 2, 3, 4]

大根堆建立如下:

      2
     / 
    1   
複製程式碼

小根堆建立如下:

      3
     / 
    4   
複製程式碼

然後再維護一個奇數偶數的狀態即可求中位數。

public class MedianStream {
    private PriorityQueue<Integer> leftHeap = new PriorityQueue<>(5, Collections.reverseOrder());
    private PriorityQueue<Integer> rightHeap = new PriorityQueue<>(5);

    private boolean even = true;

    public double getMedian() {
        if (even) {
            return (leftHeap.peek() + rightHeap.peek()) / 2.0;
        } else {
            return leftHeap.peek();
        }
    }

    public void addNum(int num) {
        if (even) {
            rightHeap.offer(num);
            int rightMin = rightHeap.poll();
            leftHeap.offer(rightMin);
        } else {
            leftHeap.offer(num);
            int leftMax = leftHeap.poll();
            rightHeap.offer(leftMax);
        }
        System.out.println(leftHeap);
        System.out.println(rightHeap);
        // 奇偶變換.
        even = !even;
    }
}
複製程式碼

壓榨記憶體

但是這樣做的問題就是可能記憶體會爆掉。如果你的流無限大,那麼意味著這些資料都要存在記憶體中,堆必須要能夠建無限大。如果記憶體必須很小的方式,用時間換空間。

  • 流是可以重複去讀的, 用時間換空間;
  • 可以用分治的思想,先讀一遍流,把流中的資料個數分桶;
  • 分桶之後遍歷桶就可以得到中位數落在哪個桶裡面,這樣就把問題的範圍縮小了。
public class Median {
    private static int BUCKET_SIZE = 1000;

    private int left = 0;
    private int right = Integer.MAX_VALUE;

    // 流這裡用int[] 代替
    public double findMedian(int[] nums) {
        // 第一遍讀取stream 將問題複雜度轉化為記憶體可接受的量級.
        int[] bucket = new int[BUCKET_SIZE];
        int step = (right - left) / BUCKET_SIZE;
        boolean even = true;
        int sumCount = 0;
        for (int i = 0; i < nums.length; i++) {
            int index = nums[i] / step;
            bucket[index] = bucket[index] + 1;
            sumCount++;
            even = !even;
        }
        // 如果是偶數,那麼就需要計算第topK 個數
        // 如果是奇數, 那麼需要計算第 topK和topK+1的個數.
        int topK = even ? sumCount / 2 : sumCount / 2 + 1;

        int index = 0;
        int indexBucketCount = 0;
        for (index = 0; index < bucket.length; index++) {
            indexBucketCount = bucket[index];
            if (indexBucketCount >= topK) {
                // 當前bucket 就是中位數的bucket.
                break;
            }
            topK -= indexBucketCount;
        }
        
        // 劃分到這裡其實轉化為一個topK的問題, 再讀一遍流.
        if (even && indexBucketCount == topK) { 
            left = index * step;
            right = (index + 2) * step;
            return helperEven(nums, topK);
            // 偶數的時候, 恰好劃分到在左右兩個子段中.
            // 左右兩段中 [topIndex-K + (topIndex-K + 1)] / 2.
        } else if (even) {
            left = index * step;
            right = (index + 1) * step;
            return helperEven(nums, topK);
            // 左邊 [topIndex-K + (topIndex-K + 1)] / 2 
        } else {
            left = index * step;
            right = (index + 1) * step;
            return helperOdd(nums, topK);
            // 奇數, 左邊topIndex-K
        }
    }
}
複製程式碼

這裡邊界條件我們處理好之後,關鍵還是helperOdd 和 helperEven這兩個函式怎麼去求topK的問題. 我們還是轉化為一個topK的問題,那麼求top-K和top(K+1)的問題到這裡我們是不是可以用堆來解決了呢? 答案是不能,考慮極端情況。 中位數的重複次數非常多

eg:
[100,100,100,100,100...] (1000億個100)
複製程式碼

你的劃分恰好落到這個桶裡面,記憶體同樣會爆掉。

再用時間換空間

假如我們的劃分bucket大小是10000,那麼最大的時候區間就是20000。(對應上面的偶數且落到兩個分桶的情況) 那麼既然劃分到某一個bucket了,我們直接用數數字的方式來求topK 就可以了,用堆的方式也可以,更高效一點,其實這裡問題規模已經劃分到很小了,兩種方法都可以。 我們選用TreeMap這種資料結構計數。然後分奇數偶數去求解。

    private double helperEven(int[] nums, int topK) {
        TreeMap<Integer, Integer> map = new TreeMap<>();
        for (int i = 0; i < nums.length; i++) {
            if (nums[i] >= left && nums[i] <= right) {
                if (!map.containsKey(nums[i])) {
                    map.put(nums[i], 1);
                } else {
                    map.put(nums[i], map.get(nums[i]) + 1);
                }
            }
        }

        int count = 0;
        int kNum = Integer.MIN_VALUE;
        int kNextNum = 0;
        for (Map.Entry<Integer, Integer> entry : map.entrySet()) {
            int currentCountIndex = entry.getValue();
            if (kNum != Integer.MIN_VALUE) {
                kNextNum = entry.getKey();
                break;
            }
            if (count + currentCountIndex == topK) {
                kNum = entry.getKey();
            } else if (count + currentCountIndex > topK) {
                kNum = entry.getKey();
                kNextNum = entry.getKey();
                break;
            } else {
                count += currentCountIndex;
            }
        }

        return (kNum + kNextNum) / 2.0;
    }

    private double helperOdd(int[] nums, int topK) {
        TreeMap<Integer, Integer> map = new TreeMap<>();
        for (int i = 0; i < nums.length; i++) {
            if (nums[i] >= left && nums[i] <= right) {
                if (!map.containsKey(nums[i])) {
                    map.put(nums[i], 1);
                } else {
                    map.put(nums[i], map.get(nums[i]) + 1);
                }
            }
        }
        int count = 0;
        int kNum = Integer.MIN_VALUE;
        for (Map.Entry<Integer, Integer> entry : map.entrySet()) {
            int currentCountIndex = entry.getValue();
            if (currentCountIndex + count >= topK) {
                kNum = entry.getKey();
                break;
            } else {
                count += currentCountIndex;
            }
        }

        return kNum;
    }
複製程式碼

至此,我覺得算是一個比較好的解決方案,leetcode社群沒有相關的解答和探索,歡迎大家交流。

熱門閱讀

Leetcode名企之路

相關文章