[Java] 關於一道面試題的思考

文中的速度測試部分,時間是經過簡單的 System.currentTimeMillis() 計算獲得的,
又因爲 Java 的特性,每次測試的結果都不必定相同,
對於低數量級的狀況有 ± 20 的浮動,對於高數量級的狀況有的能有 ± 1000 的浮動。

這道題本質上是個約瑟夫環問題,最佳解法在最下面,本文只是探究一下數組暴力和鏈表的表現差別。

題目

N 我的圍成一圈,順序排號。從第一我的開始報數(從1數到3),凡是到3的人退出圈子,問最後留下的是原來第幾號。java

樣例

  • 2 我的時留下的是第二個;
  • 3我的時留下的是第二個;
  • 5我的時留下的是第四個;
  • 12我的時留下的是第十個;
  • 100,000我的時留下的是第92620我的。

機器環境

CPU Intel Xeon E3-1231 v3 @ 3.40GHz算法

RAM 16 GB數組

暴力解決

雖然第一反應是用鏈表,但對於人數在1000如下的量級感受數組也足以勝任,所以先用數組試試。多線程

對於這種會 退出 的狀況,數組顯然不能像鏈表同樣直接斷開,所以採用標記法:app

先生成一個長度爲 N 的布爾型數組,用 true 填充。測試

報號時,對於報到 3 的位置,用 false 來標記該位置,下次循環若是遇到 false 則能夠直接跳過。優化

那麼等到數組內只剩一個 true 的時候,找到其位置,便是最後留下來的人的位置。this

既然暴力,那乾脆完全一點:線程

public static int findIndex(final int N) {
        boolean[] map = new boolean[N];
        Arrays.fill(map, true);
        int walk = 1;
         // 由於是站成一個圓,因此在遍歷到最後時須要將下標從新指向 0
           // count(map) 就是遍歷整個數組計算還剩餘的 true 的數量
        for (int index = 0; count(map) > 1; index = (index == N - 1) ? 0 : (index + 1)) {
            // 對於 false 能夠直接跳過,由於它們至關於不存在
               if (! map[index]) continue;
             // 報號時若是不是3 則繼續找下一位;
            if (walk++ != 3) continue;
             // 若是是 3,則重置報號,並將當前位置的值改成 false
            walk = 1;
            map[index] = false;
        }
        return find(map);
    }
    
    // 由於是 count(map) == 1 的狀況下才會調用這個方法,因此直接返回第一個 true 所在的位置便可
    public static int find(boolean[] map) {
        for (int i = 0; i < map.length; i++) {
            if (!map[i]) continue;
            return i + 1;
        }
        return -1;
    }

    public static int count(boolean[] map) {
        int count = 0;
        for (boolean bool : map) {
            count += bool ? 1 : 0;
        }
        return count;
    };

對於這個解法,能夠跑一下測試看看耗時:code

N time / ms
100 1
1,000 13
10,000 686
100,000 80554

很顯然,這種暴力的作法對於大一點的數量級就很吃力了,可是我又不想那麼快就用鏈表,有沒有哪裏是能夠優化的呢。

消除循環

其實在前面的解法中,耗時操做有這麼幾個:

  • findIndex 中不停得對整個 map 進行遍歷,即使對於 false 直接跳過,但杯水車薪。
  • count 中對整個 map 進行遍歷才能獲得此時數組中 true 的數量。
  • find 中一樣須要對整個 map 進行遍歷才能獲得剩下的一個 true 的下標。

其中第一點應該是這種解法的本質,沒什麼好辦法,那麼看看後兩點。

消除 count

這個方法想作的事就是每次循環時檢查此時數組中 true 的數量是否是隻剩一個了,由於這是循環的終結條件。

那麼咱們能夠引入一個計數器:

private static int findIndex(final int N) {
        boolean[] map = new boolean[N];
        Arrays.fill(map, true);
        int walk = 1;
         int countDown = N;
        for (int index = 0; countDown > 1; index = (index == N - 1) ? 0 : (index + 1)) {
            if (! map[index]) continue;
            if (walk++ != 3) continue;
            walk = 1;
            map[index] = false;
               countDown -= 1;
        }
        return find(map);
    }

改爲這種作法後,猜猜對於 100,000 這個數量級,這個暴力算法須要用時多久呢?

答案是 11 ms

對於 100,000,000 這個數量級,這個暴力算法仍只須要 3165 ms

稍稍透露一下,後邊的鏈表解法在這個數量級的成績是 7738 ms,固然多是我太垃圾了,發揮不出鏈表的威力 Orz)

消除 find

這個方法要作的是從整個數組中找到惟一的 true 的下標,這一樣能夠用一個外部變量來消除循環:

private static int findIndex(final int N) {
        boolean[] map = new boolean[N];
        Arrays.fill(map, true);
        int walk = 1;
         // 記錄如今訪問到值爲 true 的下標
        int current = 0;
        int countDown = N;
        for (int index = 0; countDown > 1; index = (index == N - 1) ? 0 : (index + 1)) {
            if (! map[index]) continue;
            if (walk++ != 3) {
                   // 記錄最後一次遇到 true 的位置
                current = index;
                continue;
            }
            walk = 1;
            map[index] = false;
            countDown -= 1;
        }
         // 人的位置是從 1 開始數的,因此這裏要加 1
        return current + 1;
    }

可是這個改動對速度的提高效果很小,對於 100,000,000 這個數量級,速度仍然在 3158 ~ 3191 ms 左右。

不暴力了,用鏈表吧

使用鏈表能夠很方便得體現 退出 這個概念,鏈表的長度會隨着算法的進行而愈來愈短直至剩下最後一個元素。由於沒有 跳過標記爲 false 的步驟,理論上會比暴力數組解法要快。

static class Node {
           // 當前節點的下標,即人的位置
        int index;
           // 上一個節點
        Node prev;
         // 下一個節點
        Node next;

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

        public Node append(Node next) {
            this.next = next;
            next.prev = this;
            return next;
        }

         // 須要報號爲3的人(當前元素)退出時,從鏈表中斷開並將兩邊拼接起來
        public Node jump() {
            Node newNode = this.next;
            newNode.prev = this.prev;
            newNode.prev.next = newNode;
            this.prev = null;
            this.next = null;
            return newNode;
        }

        public static int findIndex(final int N) {
            Node root = new Node(1);
               // 初始化鏈表並賦值,這個過程對於很大的數量級而言速度確定是慢過對數組的賦值的,
             // 畢竟類的實例化須要開銷。所以這段初始化不計入時間
            Node current = root;
            for (int i = 2; i <= N; i++) {
                current = current.append(new Node(i));
            }
            // 將首尾相連構成循環列表
            current = current.append(root);
          
            long mills = System.currentTimeMillis();
          
            int COUNTER = N;
            int walk = 1;
            while (COUNTER > 1) {
                if (walk++ != 3) {
                    current = current.next;
                } else {
                    current = current.jump();
                    walk = 1;
                    COUNTER -= 1;
                }
            }

            System.out.println(System.currentTimeMillis() - mills);
            return current.index;

        }
    }

看看兩種解法的速度對比

N 數組暴力法 / ms 數組暴力法(改進) / ms 鏈表法 / ms
100 2 0 0
1,000 15 1 0
10,000 673 5 1
100,000 79998 10 3
1,000,000 N/A 38 64
10,000,000 N/A 309 718
100,000,000 N/A 3151 7738

​ 對於 1,000,000 及以上的數量級就沒測原數組暴力法了,太慢了...

總結

能夠看到,在百萬級別,改進的數組暴力法已經要比鏈表法快一半了,在億級要快的更多。

固然這個速度差別很大程度上是由於隨着數量級的加大,鏈表法所須要的內存開銷已經超出一個合理的範圍了,隨之而來的就是鏈表的斷開重組操做要比 標記 重太多了。

可是這只是 想知道最後一我的的位置 的狀況,數組的下標能夠作到必定程度的契合,若是狀況更復雜了,顯然數組就不夠用了。

對於鏈表法在超大數量級的解法,感受能夠用多線程來作一次總體循環內的截斷,只是這樣複雜度就上去了,暫時不作了,有興趣的讀者能夠自行嘗試一下。

算法的力量

public static int josephus(int n) {
        int res = 0;
        if (n == 0) return 0;
        if (n < 3) {
            for (int i = 2; i <= n; i++) {
                res = (res + 3) % i;
            }
        } else {
            res = josephus(n - n / 3);
            if (res < (n % 3)) {
                res = res - (n % 3) + n;
            } else {
                res = res - (n % 3) + (res - (n % 3)) / 2;
            }
        }
        return res;
    }

    public static void main(String ...args) {
        System.out.println(hosephus(1000000000));
    }

這個解法對於一億這個數量級的運算時間是不到 0 ms,來自個人 ACMer 同窗 ( 打不過正規軍啊,跪了

據我同窗所說:

遞歸層數 log 級別,n 能夠達到 1e18 級別,15 ms 內給出答案。
相關文章
相關標籤/搜索