谷歌Kickstart 2017 F輪程式設計題解析|掘金技術徵文

cutoutsy發表於2017-09-28

寫在前面

聽說Google的Kickstart應該是在半年前,Google Kickstart即是原APAC Test,G家的校園招聘線上筆試,不過一直沒有認真參加過,這個比賽時間一般是週日的下午1點到4點(北京時間),不過這次是後兩輪是在週日下午4點開始,然後持續12個小時,自己選擇其中的任意連續3個小時提交都有效。總的來說這次的題目比較簡單,除了C題的large外。

本次共有4道題目,我AC了前面2道題。裡面的解法是根據我的思考結合官方給出的分析給出的一些思路。

題目及分析

原題目傳送門:Round F - Dashboard

本文我會對題目進行簡單的中文描述。

Problem A. Kicksort

這兒有一種排序叫Kicksort,是來源於快速排序演算法。快速排序演算法選取一個基準,然後根據基準分為兩組,然後在每組裡遞迴的這樣做。但是這種演算法選取的基準可能會導致按照基準比較後只會產生一組而不是兩組,這違反了這種演算法的目的。我們稱這種基準為最差基準。

為了避免這種情況,我們建立Kicksort演算法,使用待排序序列的中間的值作為基準,演算法如下:

Kicksort(A): // A is a 0-indexed array with E elements
    If E ≤ 1, return A.
    Otherwise:
      Create empty new lists B and C.
      Choose A[floor((E-1)/2)] as the pivot P.
      For i = 0 to E-1, except for i = floor((E-1)/2):
        If A[i] ≤ P, append it to B.
        Otherwise, append it to C.
    Return the list Kicksort(B) + P + Kicksort(C).複製程式碼

通過使用Kicksort演算法,我們發現Kicksort仍然存在對於每一次選取的基準,都是最差基準。比如陣列:[1,4,3,2]。Kicksort演算法首先選取4作為基準,然後原陣列變為[1,3,2]。然後選取3作為基準,原陣列變為[1,2]。選取1作為基準,原陣列變為[2]。至此結束(Kicksort結束是陣列長度為0或者1時),可以看出每次選取的基準都是最差基準。

輸入
第一行是測試用例數量:T。
每個測試用例包括兩行,N:元素個數。
接下來的一行是待排序的序列,元素大小從1到N,包括1到N。

輸出
Case #x: y
x代表是第幾個測試案例
y代表如果Kicksort演算法一直選取最差基準則為“YES”,其他情況為“NO”。

小資料集
1 ≤ T ≤ 32
2 ≤ N ≤ 4

大陣列集
1 ≤ T ≤ 100
2 ≤ N ≤ 10000

例子:
輸入:
4
4
1 4 3 2
4
2 1 3 4
2
2 1
3
1 2 3

輸出:
Case #1: YES
Case #2: NO
Case #3: YES
Case #4: NO

題目分析

這個題目和快速排序相關,思路還是比較清晰的,我的思路是模擬快速排序,不過是選取中間的值作為基準,而不是我們平常在快速排序中的選取最後的值作為基準,如果在排序的過程中,如果存在按基準值分開為2組,則返回NO,如果一直按基準值分開後都是一組,則返回YES。

具體程式碼:

public class Problem1 {
    static boolean flag = true;
    public static List isWorstPivots(List<Integer> list) {
        if (list.size() <= 1) return list;
        List<Integer> left = new ArrayList<>();
        List<Integer> right = new ArrayList<>();
        int pivot = list.get((list.size()-1)/2);
        for (int i = 0; i < list.size(); i++) {
            if (i != (list.size()-1)/2) {
                if (list.get(i) <= pivot) {
                    left.add(list.get(i));
                } else {
                    right.add(list.get(i));
                }
            }
        }
        if (left.size() != 0 && right.size() != 0){
            flag = false;
        }
        List<Integer> ans = new ArrayList<>();
        ans.addAll(onlyWorstPivots(left));
        ans.add(pivot);
        ans.addAll(onlyWorstPivots(right));
        return ans;
    }
}複製程式碼

這個程式碼使用flag作為每一次的標誌,如果為TRUE,則返回“YES“,反之則返回“NO“”。這種演算法的時間複雜度為$O(n^2)$。

下面介紹一種時間複雜度為O(n)的演算法,這是官方題解介紹的。
首先我們考慮返回YES的不同長度序列,我們可以發現它們使用的索引都是一樣的。舉個例子,
對於長度為6的序列,首先會選取索引為(N-1)/2對於的值,即索引為2的值作為索引,然後剩餘的部分組成一個長度為5的序列,這時選取索引為2的值,這個對應於原序列的索引3,如果我們繼續這樣下去,選取的值的索引在原序列中的所有依次為2,3,1,4,0,5。即我們開始於(N-1)/2,然後向右移動1,然後向左移動2,然後向右移動3......這個不依賴於序列裡的值。

對於長度為奇數的情況,也是類似的,最開始從(N-1)/2開始,然後向左移動1,然後向右移動2,然後向左移動3......

這樣我們就按照這索引規律,在每次索引的時候判斷該索引對應的值是不是最大值或則最小值。如果一直都是最大值或者最小值,則最後返回“YES”,反之則返回“NO”。

對於本題來說,由於序列的元素是1~N,所以最小值是1,最大值是N,在程式碼裡維護好這兩個值就可以了。

具體程式碼:

public static boolean isWorstPivots(int[] nums) {
    int min = 1;
    int max = nums.length;
    int start = (nums.length - 1) / 2;
    int[] move = new int[2];
    for (int i = 0; i <= (nums.length - 1) / 2; i++) {
        if ((nums.length & 1) == 1) {
            move[0] = -1;
            move[1] = 1;
        } else {
            move[0] = 1;
            move[1] = -1;
        }
        for (int j = 0; j < move.length; j++) {
            if ((start + move[j] * i) > -1 || (start + move[j] * i) < nums.length ) {
                if (nums[start + move[j] * i] == min) {
                    min++;
                } else if (nums[start + move[j] * i] == max) {
                    max--;
                } else {
                    return false;
                }
                if (i == 0) break; //當i = 0時,即為初始位置,迴圈一次
            }
        }
    }
    return true;
}複製程式碼

這種思路的程式碼就比較簡潔清晰了。我在做這個題的時候採取的是第一種思路(比較容易想到)。

Problem B. Dance Battle

這個題目是關於在“舞蹈戰爭”中獲取最大分數。首先你的團隊有E點能量,0點的榮譽值。你會面對大小為N個對手團隊的陣容,第i個團隊他們的跳舞技能點為Si

在每一輪與對手對戰中,你可以採取下面這些策略:

  • 跳舞:你會損失能量值,大小為對手的Si,獲得1點榮譽值,對手不會退回陣容中。你的能量值損失後不能小於等於0。
  • 推遲:你的能量值和榮譽值不改變,對手會退回陣容中。
  • 休戰:你的能力值和榮譽值不改變,對手不退回陣容中。
  • 招募:你招募對手到你的團隊裡,對手不退回陣容中。你增加對手的Si。你會損失1點榮譽值。你的榮譽值不能少於0。

這場戰爭結束直到對手陣容裡沒有對手團隊了。求你能獲得的最大榮譽值。

輸入
第一行:T,表示測試用例數。
接下來E N,E表示你的初始能量數,N表示對手的數量。
接著是N個對手對應的跳舞技能點Si

輸出
每行輸出:Case #x: y。
x代表第幾個測試用例。
y代表獲得最大的榮譽值。

限制:
1 ≤ T ≤ 100。
1 ≤ E ≤ 106。
1 ≤ Si ≤ 106, for all i。

小資料集:
1 ≤ N ≤ 5

大資料集:
1 ≤ N ≤ 1000

栗子:
輸入:
2
100 1
100
10 3
20 3 15

輸出:
Case #1: 0
Case #2: 1

題目分析

這個題目其實是比較好想的,首先通過延時策略我們可以對對手按照Si進行由小到大排序。然後使用兩個指標,初始一個指向最左邊,一個指向最右邊,首先從左邊開始,如果當前的能量值大於Si,則進行跳舞策略,直到不滿足為止。此時從右邊開始(這個時候要確保至少還存在2個對手,若只剩一個對手時,直接執行休戰策略即可),進行招募策略,招募後再移動左邊的指標......整個過程保證能量值E大於0和榮譽值不小於0即可。

具體程式碼:

public static int findMaxHonor(int energy, int[] skills) {
    int honor = 0;
    Arrays.sort(skills);
    int size = skills.length - 1;
    for (int i = 0; i < skills.length; i++) {
        if (skills[i] != 0) {
            if (energy > skills[i] && energy - skills[i] > 0) {
                energy -= skills[i];
                honor++;
                skills[i] = 0;
            } else if (energy <= skills[i] && honor > 0 && size > i) {
                energy += skills[size];
                honor--;
                skills[size] = 0;
                size--;
                i--;
            }
        }
    }
    return honor;
}複製程式碼
Problem C. Catch Thme All

這個題目是以Pokemon GO抓取寵物小精靈為背景,這兒有一款遊戲叫Codejamon GO,去街道上抓取寵物小精靈。

你的城市有N個位置從1~N,你開始在位置1。這兒有M條雙向的道路(1~M)。第i條道路連線兩個不同的位置(Ui, Vi),並且需要花Di分鐘從一邊到另一邊。保證從位置1可以到其他任何位置。

在時間為0時,一個寵物小精靈會等概率出現在除了你當前位置(時間:0,位置:1)的其他任何位置。即每個位置出現寵物的概率為1/(N-1)。寵物一出現,你就會立刻抓住它,即抓取過程沒有時間花費。每次只有一個寵物小精靈存在,只有抓住了存在的這個才會出現下一個。

給你城市佈局,計算抓取P個寵物小精靈的期望時間,假設你在兩個位置走的是最快的路線。

輸入
第一行:T,表示測試用例數目。
每個測試用例第一行為: N M P,N代表位置數目,M代表道路數目,P代表要抓取的寵物小精靈數目
接下來的是M條道路資訊:Ui Vi Di,表示從位置Ui到Vi存在道路,且花費時間為Di

輸出
每行輸出Case #x: y。
x代表第幾個測試用例
y代表花費的期望時間,如果在正確答案的10-4的絕對或相對誤差範圍內,您的答案將被視為正確。

限制:
1 ≤ T ≤ 100.
N - 1 ≤ M ≤ (N * (N - 1)) / 2.
1 ≤ Di ≤ 10, for all i.
1 ≤ Ui < Vi ≤ N, for all i.
For all i and j with i ≠ j, Ui ≠ Ui and/or Vi ≠ Vi

小資料集
2 ≤ N ≤ 50.
1 ≤ P ≤ 200

大資料集
2 ≤ N ≤ 100.
1 ≤ P ≤ 109

栗子
輸入:
4
5 4 1
1 2 1
2 3 2
1 4 2
4 5 1
2 1 200
1 2 5
5 4 2
1 2 1
2 3 2
1 4 2
4 5 1
3 3 1
1 2 3
1 3 1
2 3 1

輸出:
Case #1: 2.250000
Case #2: 1000.000000
Case #3: 5.437500
Case #4: 1.500000

題目分析
首先我們可以想到圖的最短路演算法,比如使用Dijkstra演算法或Floyd-Warshall演算法來計算兩點的最短路徑,在這個題目裡即是時間最短的路徑。求出所有點之間的花費時間最少的路徑的時間,後面會用到。然後我們可以根據動態規劃來計算期望時間,令dp[K, L]代表從位置L出發抓取K個精靈的期望時間,然後我們可以得到狀態轉移方程:
dp[K, L] = Σi!=L(dp[K-1, i] + dis[L, i]) / (N-1).

而dp[K, L] = 0 (K = 0)

具體程式碼:
圖的頂點類:

public class Vertex implements Comparable<Vertex>{
    private int path;    // 最短路徑長度
    private boolean isMarked;    // 節點是否已經出列(是否已經處理完畢)

    public int getPath() {
        return path;
    }

    public void setPath(int path) {
        this.path = path;
    }

    public Vertex() {
        this.path = Integer.MAX_VALUE;
        this.isMarked = false;
    }

    public Vertex(int path) {
        this.path = path;
        this.isMarked = false;
    }

    @Override
    public int compareTo(Vertex o) {
        return o.path > path ? -1 : 1;
    }

    public void setMarked(boolean mark) {
        this.isMarked = mark;
    }

    public boolean isMarked() {
        return this.isMarked;
    }
}複製程式碼

計算期望時間:

public class Problem3 {
    // 頂點
    private List<Vertex> vertexs;
    // 邊
    private int[][] edges;
    // 沒有訪問的頂點
    private PriorityQueue<Vertex> unVisited;

    public Problem3(List<Vertex> vertexs, int[][] edges) {
        this.vertexs = vertexs;
        this.edges = edges;
        initUnVisited();
    }

    // 初始化未訪問頂點集合
    private void initUnVisited() {
        unVisited = new PriorityQueue<Vertex>();
        for (Vertex v : vertexs) {
            unVisited.add(v);
        }
    }

    // 搜尋各頂點最短路徑
    public void search() {
        while (!unVisited.isEmpty()) {
            Vertex vertex = unVisited.peek();
            vertex.setMarked(true);
            List<Vertex> neighbors = getNeighbors(vertex);
            updatesDistabce(vertex, neighbors);
            unVisited.poll();
            List<Vertex> temp = new ArrayList<>();
            while (!unVisited.isEmpty()) {
                temp.add(unVisited.poll());
            }
            for (Vertex v : temp) {
                unVisited.add(v);
            }
        }
    }

    // 更新所有鄰居的最短路徑
    private void updatesDistabce(Vertex vertex, List<Vertex> neighbors) {
        for (Vertex neighbor: neighbors) {
            int distance = getDistance(vertex, neighbor) + vertex.getPath();
            if (distance < neighbor.getPath()) {
                neighbor.setPath(distance);
            }
        }
    }

    private int getDistance(Vertex source, Vertex destination) {
        int sourceIndex = vertexs.indexOf(source);
        int destIndex = vertexs.indexOf(destination);
        return edges[sourceIndex][destIndex];
    }

    private List<Vertex> getNeighbors(Vertex vertex) {
        List<Vertex> neighbors = new ArrayList<Vertex>();
        int position = vertexs.indexOf(vertex);
        Vertex neighbor = null;
        int distance;
        for (int i = 0; i < vertexs.size(); i++) {
            if (i == position) {
                continue;
            }
            distance = edges[position][i];
            if (distance < Integer.MAX_VALUE) {
                neighbor = vertexs.get(i);
                if (!neighbor.isMarked()) {
                    neighbors.add(neighbor);
                }
            }
        }
        return neighbors;
    }

    public static double calculateExpectedTimeSmallDataSet(int locations, int codejamGo, List<String> graph) {
        int[][] edges = new int[locations][locations];
        for (int i = 0; i < locations; i++) {
            for (int j = 0; j < locations; j++) {
                edges[i][j] = Integer.MAX_VALUE;
            }
        }
        for (int i = 0; i < graph.size(); i++) {
            String edge = graph.get(i);
            int source = Integer.valueOf(edge.split(" ")[0]);
            int destination = Integer.valueOf(edge.split(" ")[1]);
            int value = Integer.valueOf(edge.split(" ")[2]);
            edges[source - 1][destination - 1] = value;
            edges[destination - 1][source - 1] = value;
        }
        List<Vertex> vertexs = new ArrayList<>();

        for (int begin = 0; begin < locations; begin++) {
            for (int i = 0; i < locations; i++) {
                vertexs.add((i == begin) ? new Vertex(0) : new Vertex());
            }

            Problem3 problem3 = new Problem3(vertexs, edges);
            problem3.search();
            for (int i = 0; i < vertexs.size(); i++) {
                edges[begin][i] = vertexs.get(i).getPath();
            }
            vertexs.clear();
        }

        double[][] dp = new double[codejamGo + 1][locations];
        Arrays.fill(dp[0], 0);

        for (int i = 1; i < codejamGo + 1; i++) {
            for (int j = 0; j < locations; j++) {
                double sum = 0;
                for (int k = 0; k < locations; k++) {
                    if (k != j) {
                        sum += dp[i - 1][k] + edges[j][k];
                    }
                }
                dp[i][j] = sum / (locations - 1);
            }
        }

        return dp[codejamGo][0];
    }
}複製程式碼

這種方式對於小資料集是完全能夠解決的,但對於大資料集就不適用了,這個時候我們需要對狀態轉移方程進行簡化。我們可以發現,對於每個dp[K, L],都是dp[K-1, i]的線性表示式,定義 Σj!=i(dis[i, j])。則我們可以寫成如下的形式:

狀態轉移方程化簡
狀態轉移方程化簡

FK代表dp[K, i]向量,A代表轉移矩陣,我們可以得到
FK = A FK-1 = $A^K$ F0
在計算$A^K$時,我們可以使用快速冪演算法。

具體程式碼:
快速冪演算法:

public class MatricQuickPower {

    public static double[][] multiply(double [][]a, double[][]b){
        double[][] arr = new double[a.length][b[0].length];
        for(int i = 0; i < a.length; i++){
           for(int j = 0; j < b[0].length; j++){
               for(int k = 0; k < a[0].length; k++){
                   arr[i][j] += a[i][k] * b[k][j];
               }  
           }  
        }  
        return arr;  
    }

    public static double[][] multiplyPower(double[][] a, int n){
        double[][] res = new double[a.length][a[0].length];
        for(int i = 0; i < res.length; i++){
            for(int j = 0; j < res[0].length; j++) {
                if (i == j)
                    res[i][j] = 1;
                else
                    res[i][j] = 0;
            }
        }  
        while(n != 0){
            if((n & 1) == 1)
                res = multiply(res, a);
            n >>= 1;
            a = multiply(a, a);
        }  
        return res;  
    }
}複製程式碼

計算期望時間:

public static double calculateExpectedTimeLargeDataSet(int locations, int codejamGo, List<String> graph) {
    int[][] edges = new int[locations][locations];
    for (int i = 0; i < locations; i++) {
        for (int j = 0; j < locations; j++) {
            edges[i][j] = Integer.MAX_VALUE;
        }
    }
    for (int i = 0; i < graph.size(); i++) {
        String edge = graph.get(i);
        int source = Integer.valueOf(edge.split(" ")[0]);
        int destination = Integer.valueOf(edge.split(" ")[1]);
        int value = Integer.valueOf(edge.split(" ")[2]);
        edges[source - 1][destination - 1] = value;
        edges[destination - 1][source - 1] = value;
    }
    List<Vertex> vertexs = new ArrayList<>();
    int[] distanceSum = new int[locations];
    for (int begin = 0; begin < locations; begin++) {
        for (int i = 0; i < locations; i++) {
            vertexs.add((i == begin) ? new Vertex(0) : new Vertex());
        }

        Problem3 problem3 = new Problem3(vertexs, edges);
        problem3.search();
        int totalDistance = 0;
        for (int i = 0; i < vertexs.size(); i++) {
            totalDistance += vertexs.get(i).getPath();
        }
        distanceSum[begin] = totalDistance;
        vertexs.clear();
    }

        double[][] zeroCodeJam = new double[locations + 1][1];
        for (int i = 0; i < zeroCodeJam.length; i++) {
            for (int j = 0; j < zeroCodeJam[i].length; j++) {
                zeroCodeJam[i][j] = 0;
            }
        }
        zeroCodeJam[locations][0] = 1;
        double[][] matris = new double[locations + 1][locations + 1];
        for (int i = 0; i < matris.length; i++) {
            if (i != locations) {
                for (int j = 0; j < matris[i].length; j++) {
                    if (i == j) {
                        matris[i][j] = 0;
                    } else if (j == locations) {
                        matris[i][j] = distanceSum[i] * 1.0/(locations - 1);
                    } else {
                        matris[i][j] = 1.0/(locations - 1);
                    }
                }
            }
            if (i == locations) {
                for (int j = 0; j < matris[i].length; j++) {
                    matris[i][j] = j == locations ? 1 : 0;
                }
            }
        }

        matris = MatricQuickPower.multiplyPower(matris, codejamGo);
        double ans = 0;
        for (int i = 0; i < zeroCodeJam.length; i++) {
            ans += matris[0][i] * zeroCodeJam[i][0];
        }
        return ans;
}複製程式碼

這個題目還有其他的一些解法,更多解法移步ProblemC Analysis

Problem D. Eat Cake

這個題目是關於吃蛋糕的,蛋糕的高度都是一樣的,上下兩面都是正方形,數量不限制。現在
Wheatley想吃麵積為N的蛋糕,但是為了健康,想吃的蛋糕塊數最少,求最少蛋糕塊數。

輸入
第一行:T,測試用例數
接著每行代表一個測試用例:N,表示吃的蛋糕的面積

輸出
Case #x: y
x代表第幾個測試用例
y代表最小蛋糕塊數

小資料集
1 ≤ T ≤ 50
1 ≤ N ≤ 50

大資料集
1 ≤ T ≤ 100
1 ≤ N ≤ 10000

栗子
輸入:
3
3
4
5
輸出:
Case #1: 3
Case #2: 1
Case #3: 2

題目分析
首先我們發現小資料集的資料是很小的,我們可以用暴力的方法。

具體程式碼

public static int findCakeNumsByArea(int area) {
    if (area == 0) return 0;
    int ans = Integer.MAX_VALUE;
    for (int i = 1; i <= Math.sqrt(area); i++) {
        ans = Math.min(ans, findCakeNumsByArea(area - i * i) + 1);
    }
    return ans;
}複製程式碼

對於大資料集,我們可以採取動態規劃DP。將每次計算的結果儲存起來,這個程式碼比較簡單,大家可以看下下面的虛擬碼:

public class Problem4 {

    public boolean[] alreadyComputed;
    public int[] dp;

    public Problem4() {
        this.alreadyComputed = new boolean[10000 + 1];
        this.dp = new int[10000 + 1];
        Arrays.fill(alreadyComputed, false);
    }

    // dp
    public int findCakeNumsByAreaDp(int inputArea) {
        if (inputArea == 0) return 0;
        if (alreadyComputed[inputArea]) return dp[inputArea];
        dp[inputArea] = Integer.MAX_VALUE;
        alreadyComputed[inputArea] = true;
        for (int i = 1; i <= Math.sqrt(inputArea); i++) {
            dp[inputArea] = Math.min(dp[inputArea], findCakeNumsByAreaDp(inputArea - i * i) + 1);
        }
        return dp[inputArea];
    }
}複製程式碼

這種方法在使用前,需要提前將1~10000計算下,得到dp這個陣列,然後就可以計算給的資料集。

這兒我想介紹一種很簡單的方法,就是根據拉格朗日四平方和定理,即任何一個正整數都可以表示成不超過四個整數的平方之和。如果知道這個定理,那這個題目就簡單了。程式碼如下:

public static int findCakeNumsByArea(int area) {
    for (int i = 1; i <= Math.sqrt(area); i++) {
        if(i * i == area) return 1;
    }
    for (int i = 1; i <= Math.sqrt(area); i++) {
        for (int j = i; j <= Math.sqrt(area); j++) {
            if (i * i + j * j == area) return 2;
        }
    }
    for (int i = 1; i <= Math.sqrt(area); i++) {
        for (int j = i; j <= Math.sqrt(area); j++) {
            for (int k = j; k <= Math.sqrt(area); k++) {
                if (i * i + j * j + k * k == area) return 3;
            }
        }
    }
    return 4;
}複製程式碼

總結

以上的所有題目的完整程式碼可在2017-round-F下載。這次題目難度不大,考了一些基礎了演算法,比如快速排序演算法,最短路演算法等,還有涉及了較多的動態規劃,平時還需要多多練習基礎演算法,多多刷題。

歡迎大家批評指正與交流。

ps. 文中可能公式顯示不正確,原文請訪問我的部落格:谷歌Kickstart 2017 F輪程式設計題解答(Java版)
附掘金徵文大賽連結

相關文章