約瑟夫環問題求解:約瑟夫告訴我,當年,他就是這麼在決賽圈躺贏吃雞的....

1. 約瑟夫環問題

傳說有這樣一個故事,在羅馬人佔領喬塔帕特後,39 個猶太人與約瑟夫及他的朋友躲到一個洞中,39個猶太人決定寧願死也不要被敵人抓到,於是決定了一個自殺方式,41個人排成一個圓圈,第一個人從1開始報數,依次往後,如果有人報數到3,那麼這個人就必須自殺,然後再由他的下一個人重新從1開始報數,直到所有人都自殺身亡爲止。然而約瑟夫和他的朋友並不想遵從。於是,約瑟夫要他的朋友先假裝遵從,他將朋友與自己安排在第16個與第31個位置,從而逃過了這場死亡遊戲 。

問題轉換:

n個人坐一圈,第一個人編號爲1,第二個人編號爲2,第n個人編號爲n。

  1. 編號爲1的人開始從1報數,依次向後,報數爲ans的那個人退出圈;

  2. 自退出那個人開始的下一個人再次從1開始報數,以此類推;

  3. 求出最後退出的那個人的編號

看來,約瑟夫是玩過雙排,知道決賽圈怎麼打,才能帶基友吃雞。一跳飛機別人都在唯唯諾諾,約瑟夫同志直接規劃好在決賽圈躺贏。

現在,我來告訴你當年約瑟夫是怎麼想的,怎麼用以下方法直接在決賽圈躺贏吃雞!

說來慚愧,作爲一個大三的老菜雞,數據結構我在大一學過了,但是當時覺得這玩意好複雜,感覺沒啥用,就上課划水。但是,比較有印象的一節課就是老師講約瑟夫環問題,當時也沒怎麼搞懂,現在重頭來。如果有剛接觸開始學習數據結構的同學,一定要好好學習呀。

2. 分析一波


約瑟夫問題是個環,我們把每個人編號後放到這個環中。首先,這個環怎麼來實現呢?數據結構裏也沒有環呀?

我們首先想到的應該就是循環鏈表,這是個環呀。那,鏈表能實現的,數據應該也可以呀。

其實,約瑟夫環問題共有三種解法,分別是循環鏈表,數組,還有數學方法來解決。

循環鏈表

【循環鏈表解題思路】:

  1. 構建含有n個結點的單向循環鏈表,分別存儲1~n的值,分別代表這n個人;

  2. 使用計數器count,記錄當前報數的值;

  3. 遍歷鏈表,每循環一次,count++;

  4. 判斷count的值,如果是ans,則從鏈表中刪除這個結點並打印結點的值,把count重置爲0;


來具體分析一下鏈表解題思路是怎麼做的?

首先是來構建節點數爲n的循環鏈表

  • 創建節點類

  • 構建循環鏈表

其次是開始循環報數,刪除節點

刪除節點的細節性操作:

【完整的代碼】

package com.topic.joseph;

/**
 * @Author: Mr.Q
 * @Date: 2020-05-06 21:10
 * @Description:循環鏈表
 * @Solution:
 * 1.構建含有41個結點的單向循環鏈表,分別存儲1~41的值,分別代表這41個人;
 * 2.使用計數器count,記錄當前報數的值;
 * 3.遍歷鏈表,每循環一次,count++;
 * 4.判斷count的值,如果是3,則從鏈表中刪除這個結點並打印結點的值,把count重置爲0
 */
public class CircularList {
    /**
     * 約瑟夫環
     * @param n 圍成環人的編號(從1開始到n)
     * @param ans 數到ans的那個人出列
     * @return 倖存人的編號
     */
    public static int joseph(int n, int ans) {
        if ( ans < 2) {
            return n;
        }
        //創建循環鏈表
        Node first = buildCircularList(n);

        //count計數器,模擬報數
        int count = 0;

        //遍歷刪除節點,模擬自殺
        //記錄每次遍歷(報數)拿到的節點
        Node<Integer> temp = first;
        //記錄當前節點的上一個節點befo,爲的是在刪除(自殺)時,befo直接指向自殺節點的下一個節點,完成當前節點的刪除
        Node<Integer> befo = null; //默認的首節點無上一個節點

        //如果當前環只剩最後一個節點時,結束循環(防止自環)
        while (temp != temp.next) {
            //模擬報數
            count++;
            //判斷當前報數是不是ans
            if (count == ans) {
                //如果是ans,則把當前結點刪除調用,打印當前結點;
                //重置count=0,讓當前結點temp後移
                befo.next = temp.next; //befo直接指向自殺節點的下一個節點,完成當前節點的刪除
                System.out.print(temp.data + " ");
                count = 0;
                temp = temp.next;
            }else {
                //如果不是ans,讓befo變爲當前結點,讓當前結點後移
                befo = temp;
                temp = temp.next;
            }
        }
        return temp.data;
    }

    //節點類
    private static class Node<T> {
        //存儲數據
        T data;
        //指向下一個節點
        Node next;
        public Node(T data, Node next) {
            this.data = data;
            this.next = next;
        }
    }

    /**
     * 構建循環鏈表,分別存儲1~n的編號
     * @param n n人編號
     * @return
     */
    private static Node buildCircularList(int n) {
        //首節點構建
        Node<Integer> first = null;
        //記錄新創建的節點的前一個節點prev
        Node<Integer> prev = null;
        for (int i = 1; i <= n; i++) {
            //如果是第一個節點
            if (i == 1) {
                //首節點放入編號1,指向爲空(因爲此時後面還沒有節點,鏈表只有一個節點)
                first = new Node<> (i, null);
                prev = first;
                continue; //本次循環執行結束
            }

            //如果不是第一個節點,將產生的新節點鏈在prev後
            Node<Integer> node = new Node<> (i, null);
            prev.next = node;
            //鏈接之後,讓prev指向當前鏈表的最新節點,繼續創建下一個新節點
            prev = node;

            //如果是最後一個節點,則需要指向first,形成循環鏈表
            if (i == n) {
                prev.next = first;
            }
        }
        return first;
    }
}

數組

數組的思想就是通過一個數組,數組中每個元素都是一個人,然後對數組進行循環處理,反覆遍歷來達到”環“的效果,每當數組中的人數到ans時,將其標記爲淘汰。直到最後數組中只有一個人未被淘汰。

爲了更直觀的體現淘汰與存貨兩種狀態,我們創建一個boolean數組。

當然,int數組也可以,把數組按1-n編號,只需要把淘汰的元素致爲-1即可。boolean類型的巧妙之處就是利用數組下標來編號。

【第一步】我們需要一個長度爲n的布爾值數組,數組的index就表示了第幾個人,元素的truefalse表示了這個人是否被淘汰。一開始我們需要將所有人都設置爲未被淘汰。

【第二步】 我們需要三個變量:

  1. stay記錄剩下未被淘汰的人數,初始值爲總人數;

  2. count計數器,每過一個人加一,加到ans時歸零,初始化爲0

  3. index標記從哪裏開始,index記錄了此時數到了第幾個人,當index等於總人數n時歸零 ,初始化爲0
    因爲是一個圈,所以最後一個人數完後又輪到第一個人數

【第三步】開始循環計算了

  • 首先判斷剩餘的人數是否大於一,如果大於一進入循環;

  • 判斷第index人,如果這個人未被淘汰,則計數器加一,如果等於ans則淘汰這個人,否則跳過計數繼續

  • 當index等於總人數n時,第二輪循環開始

【最後】計算結束後,數組中只有一個元素爲true,而這個就是約瑟夫那位靚仔了!

【完整代碼】

package com.topic.joseph;

/**
 * @Author: Mr.Q
 * @Date: 2020-05-07 09:12
 * @Description:數組求解
 * @Solution:
 */
public class ArraySolution {
    /**
     * @param n 圍成環人的編號(從1開始到n)
     * @param ans 數到ans的那個人出列
     * @return 倖存人的編號
     */
    public static int joseph(int n, int ans) {
        //開始時設置一個長度爲n的數組,並將元素都設爲true
        //數組的下標代表人到編號,數組元素的值(T,F)代表是否淘汰
        Boolean[] peopleFlags = new Boolean[n];
        for (int i = 0; i < n; i++) {
            peopleFlags[i] = true;
        }

        //剩下未被淘汰的人數
        int stay = n;
        //計數器,每過一個人加一,加到ans時歸零
        int count = 0;
        //標記從哪裏開始,index記錄了此時數到了第幾個人,當index等於總人數n時歸零
        //因爲是一個圈,所以最後一個人數完後又輪到第一個人數
        int index = 0;
        while (stay > 1) {
            if (peopleFlags[index]) {
                //說明還沒有被淘汰 計數器加1
                count++;
                if (count == ans) {
                    count = 0; //計數器歸0
                    peopleFlags[index] = false; //此人被淘汰
                    stay--; //未被淘汰的人數-1
                }
            }
            index++;

            //數到本輪最後一人時,則又從第一人開始計數
            if (index == n) {
                index = 0;
            }
        }

        //經過上面的循環,現在數組中被淘汰的人都標記爲false,最後沒被淘汰都人標記爲true
        for (int j = 0; j < n; j++) {
            if (peopleFlags[j]) {
                return j + 1;
            }
        }
        return -1;
    }
}

數學解法

這就涉及到咱的知識盲區了,作爲一個數學渣渣,就算把頭髮拔光也想不出來。不過,咱學學大佬們怎麼操作。

首先我們把這n個人的序號編號從0~n-1(理由很簡單,由於m是可能大於n的,而當m大於等於n時,那麼第一個出列的人編號是m%n,而m%n是可能等於0的,這樣編號的話能夠簡化後續出列的過程).

當數到m-1的那個人出列,因此我們編號完成之後,開始分析出列的過程:

第一次出列:

一開始的時候,所有人的編號排成序列的模式即爲:

0, 1, 2, 3, 4, 5 … n-2,n-1

那麼第一次出列的人的編號則是(m-1)%n1,那麼在第一個人出列之後,從他的下一個人又開始從0開始報數,爲了方便我們設

k1 = m%n1(n1爲當前序列的總人數)那麼在第一個人出列之後,k1則是下一次新的編號序列的首位元素,

那麼我們得到的新的編號序列爲:

k1,k1+1,k1+2,k1+3…n-2,n-1,0,1,2…k1-3,k1-2 (k1-1第一次已出列)

那麼在這個新的序列中,第一個人依舊是從0開始報數,那麼在這個新的序列中,每個人報的相應數字爲:

0, 1, 2, 3 … n-2

那麼第二次每個人報的相應數字與第一次時自己相應的編號對應起來的關係則爲:

0 --> k1

1 --> k1+1

2 --> k1+2

n-2 —> (k1+n-2)%n1(n1爲當前序列的總人數,因爲是循環的序列,k1+n-1可能大於總人數)

那麼這時我們要解決的問題就是n-1個人的報數問題(即n-1階約瑟夫環的問題)

可能以上過程你還是覺得不太清晰,那麼我們重複以上過程,繼續推導剩餘的n-1個人的約瑟夫環的問題:

那麼在這剩下的n-1個人中,我們也可以爲了方便,將這n-1個人編號爲:

0,1,2,3,4…n-2

那麼此時出列的人的編號則是(m-1) % n2(n2爲當前序列的總人數),同樣的我們設k2 = m % n2,那麼在這個人出列了以後,序列重排,重排後新的編號序列爲:

k2,k2+1,k2+2,k2+3…n-2,n-1,0,1,2…k2-3,k2-2 (k2-1第一次已出列)

那麼在這個新的序列中,第一個人依舊是從1開始報數,那麼在這個新的序列中,每個人報的相應數字爲:

1,2,3,4…n-2

那麼這樣的話是不是又把問題轉化成了n-2階約瑟夫環的問題呢?

後面的過程與前兩次的過程一模一樣,那麼遞歸處理下去,直到最後只剩下一個人的時候,便可以直接得出結果
當我們得到一個人的時候(即一階約瑟夫環問題)的結果,那麼我們是否能通過一階約瑟夫環問題的結果,推導出二階約瑟夫環的結果呢?

藉助上面的分析過程,我們知道,當在解決n階約瑟夫環問題時,序號爲k1的人出列後,剩下的n-1個人又重新組成了一個n-1階的約瑟夫環,那麼:

假如得到了這個n-1階約瑟夫環問題的結果爲ans(即最後一個出列的人編號爲ans),那麼我們通過上述分析過程,可以知道,n階約瑟夫環的結果:
(ans + k)%n(n爲當前序列的總人數),而k = m%n

則有:n階約瑟夫環的結果

(ans + m % n)%n,那麼我們還可以將該式進行一下簡單的化簡:

  • 當 m < n 時,易得上式可化簡爲:(ans + m)% n

  • 而當m>=n時,那麼上式則化簡爲:(ans % n + m%n%n)% n
    即爲:(ans % n + m%n)% n

  • 而 (ans + m)% n = (ans % n + m%n)% n

因此得證:

(ans + m % n)%n = (ans + m)% n

這樣的話,我們就得到了遞推公式,由於編號是從0開始的,那麼我們可以令

f[1] = 0;//當一個人的時候,出隊人員編號爲0

f[n] = (f[n-1] + m)%n; //m表示每次數到該數的人出列,n表示當前序列的總人數

而我們只需要得到第n次出列的結果即可,那麼不需要另外聲明數組保存數據,只需要直接一個for循環求得n階約瑟夫環問題的結果即可

由於往往現實生活中編號是從1-n,那麼我們把最後的結果加1即可

果然啊,數學纔是大哥。

使用Java提供的LinkedList

相比較於自己實現的循環鏈表,用API的LinkedList簡化了很多,關鍵是在於remove()方法。

  • 設置index指針,模擬報數。到達ans或者一輪判斷走完時重置

  • remove刪除自殺的節點,來不斷縮短鏈表長度

  • 最終鏈表只剩一個元素,即爲存活的約瑟夫的編號

【代碼】

/**

 * @Author: Mr.Q
 * @Date: 2020-05-08 18:26
 * @Description:Java自帶鏈表實現
 */
public class LinkedListSolution {
    public static int joseph(int n, int ans) {
        LinkedList<Integer> list = new LinkedList<> ();
        for (int i = 1; i <= n; i++) {
           list.add(i);
        }
        int index = 0;
        while (list.size() > 1) {
            for (int i = 0; i < list.size(); i++) {
                index++;
                int away = 0;
                if (index == ans) {
                    away = list.get(i);
                    list.remove(i);
                    index = 1;  //指針重置
                    if(i == list.size() || index == ans){
                        index = 0;
                    }
                    System.out.print(away + " ");
                }
            }
        }
        return list.get(0);
    }
}

這不就簡簡單單奧利幹了麼,當年的約瑟夫,就是這麼躺贏的!

【參考文章】

  1. 約瑟夫環問題

  2. 約瑟夫環的幾種實現方式

  3. 使用JAVALinkedList解決約瑟夫圓環問題

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章