1. 程式人生 > >小朋友學資料結構(1):約瑟夫環的連結串列解法、陣列解法和數學公式解法

小朋友學資料結構(1):約瑟夫環的連結串列解法、陣列解法和數學公式解法

約瑟夫環(Josephus)問題是由古羅馬的史學家約瑟夫(Josephus)提出的,他參加並記錄了公元66—70年猶太人反抗羅馬的起義。約瑟夫作為一個將軍,設法守住了裘達伯特城達47天之久,在城市淪陷之後,他和40名死硬的將士在附近的一個洞穴中避難。在那裡,這些叛亂者表決說“要投降毋寧死”。於是,約瑟夫建議每個人輪流殺死他旁邊的人,而這個順序是由抽籤決定的。約瑟夫有預謀地抓到了最後一簽,並且,作為洞穴中的兩個倖存者之一,他說服了他原先的犧牲品一起投降了羅馬。
約瑟夫環問題的具體描述是:設有編號為1,2,……,n的n(n>0)個人圍成一個圈,從第1個人開始報數,報到m時停止報數,報m的人出圈,再從他的下一個人起重新報數,報到m時停止報數,報m的出圈,……,如此下去,直到所有人全部出圈為止。當任意給定n和m後,設計演算法求n個人出圈的次序。

解法一:用迴圈連結串列實現

#include<stdio.h>
#include<stdlib.h>

typedef struct node
{
    int data;
    struct node *next;
}Node;

/**
 * @功能 約瑟夫環
 * @引數 total:總人數
 * @引數 from:第一個報數的人
 * @引數 count:出列者喊到的數
 * @作者 zheng
 * @更新 2013-12-5
 */
void JOSEPHUS(int total, int from, int count)
{
    Node *p1
, *head; head = NULL; int i; // 建立迴圈連結串列 for(i = 1; i <= total; i++) { Node *newNode = (Node *)malloc(sizeof(Node)); newNode->data = i; if(NULL == head) { head = newNode; } else { p1->next = newNode; } p1 = newNode; } p1->next
= head; // 尾節點連到頭結點,使整個連結串列迴圈起來 p1 = head; // 使pcur指向頭節點 // 把當前指標pcur移動到第一個報數的人 // 若從第一個人開始報數,這一段可要可不要 for(i = 1; i < from; i++) { p1 = p1->next; } Node *p2 = NULL; // 迴圈地刪除佇列中報到count的結點 while(p1->next != p1) { for(i = 1; i < count; i++) { p2 = p1; p1 = p1->next; } p2->next = p1->next; printf("Delete number: %d\n", p1->data); // 列印所要刪除結點的資料 free(p1); // 刪除結點,從記憶體釋放該結點佔用的記憶體空間 p1 = p2->next; // p1指標指向新的結點p2->next,即原先的p1->next } printf("The last one is No.%d\n", p1->data); } int main() { int total, from, count; scanf("%d%d%d", &total, &from, &count); JOSEPHUS(total, from, count); return 0; }

執行結果:

13 1 3
Delete number: 3
Delete number: 6
Delete number: 9
Delete number: 12
Delete number: 2
Delete number: 7
Delete number: 11
Delete number: 4
Delete number: 10
Delete number: 5
Delete number: 1
Delete number: 8
The last one is No.13

解法二:陣列實現

思路:設陣列a有n個變數,每個變數中初始放的標識數是1,表示這個人在佇列裡,若出列標識數就變為0。
現在計數器從1開始向後數,每報一個數即把累加器加1。這裡累加器表示報數人數。累列到m時,報數的人要出列,標識數要變為0。下一個人從1開始重新報數。
報到最後一個人後,從第一個人開始繼續報數。

#include<stdio.h>
#include<memory.h>

int main()
{
    int n, m;
    scanf("%d%d", &n, &m);
    int flag[n + 1];    // 在佇列裡標記為1,出列標記為0
    memset(flag, 0, sizeof(flag));  //把陣列每個元素置0,在memory.h中宣告

    int i = 0;
    int outCnt = 0;     //  記錄出列的人數
    int numoff = 0;     //  報數

    // 預設都標記為1
    for(i = 1; i <= n; i++)
    {
        flag[i] = 1;
    }

    while(outCnt < n - 1)
    {
        for(i = 1; i <= n; i++ )
        {
            if (1 == flag[i])
            {
                numoff++;
                if(numoff == m)
                {
                    printf("Dequeue:%d\n", i);
                    outCnt++;
                    flag[i] = 0;    // 已出列的人標記為0
                    numoff = 0;     // 從頭開始報數
                }
            }
        }
    }

    for(i = 1; i <= n; i++)
    {
        if(1 == flag[i])
        {
            printf("The last one is: %d\n", i);
        }
    }

    return 0;
}

執行結果:

13 3
Dequeue:3
Dequeue:6
Dequeue:9
Dequeue:12
Dequeue:2
Dequeue:7
Dequeue:11
Dequeue:4
Dequeue:10
Dequeue:5
Dequeue:1
Dequeue:8
The last one is: 13

解法三:用數學公式求解

上面編寫的解約瑟夫環的程式模擬了整個報數的過程,因為N和M都比較小,程式執行時間還可以接受,很快就可以出計算結果。可是,當參與的總人數N及出列值M非常大時,其運算速度就慢下來。例如,當N的值有上百萬,M的值為幾萬時,到最後雖然只剩2個人,也需要迴圈幾萬次(由M的數量決定)才能確定2個人中下一個出列的序號。顯然,在這個程式的執行過程中,很多步驟都是進行重複無用的迴圈。
那麼,能不能設計出更有效率的程式呢?
辦法當然有。其中,在約瑟夫環中,只是需要求出最後的一個出列者最初的序號,而不必要去模擬整個報數的過程。因此,為了追求效率,可以考慮從數學角度進行推算,找出規律然後再編寫程式即可。

為了討論方便,先根據原意將問題用數學語言進行描述。
問題:將編號為0~(N–1)這N個人進行圓形排列,按順時針從0開始報數,報到M–1的人退出圓形佇列,剩下的人繼續從0開始報數,不斷重複。求最後出列者最初在圓形佇列中的編號。

下面首先列出0~(N-1)這N個人的原始編號如下:
0 1 2 3 … N-3 N-2 N-1

根據前面曾經推導的過程可知,第一個出列人的編號一定是(M–1)%N。例如,在13個人中,若報到3的人出列,則第一個出列人的編號一定是(3–1)%13=2,注意這裡的編號是從0開始的,因此編號2實際對應以1為起點中的編號3。根據前面的描述,m的前一個元素(M–1)已經出列,則出列1人後的列表如下:
0 1 2 3 … M-3 M-2 ○ M M+1 M+2 … N-3 N-2 N-1
注意,上面的圓圈表示被刪除的數。

根據規則,當有人出列之後,下一個位置的人又從0開始報數,則以上列表可調整為以下形式(即以M位置開始,N–1之後再接上0、1、2……,形成環狀):
M M+1 M+2 … N-2 N-1 0 1 … M-3 M-2

按上面排列的順序從0開始重新編號,可得到下面的對應關係:
M M+1 M+2 … N-2 N-1 0 1 … M-3 M-2
0 1 2 … N-(M+2) N-(M+1) N-M N-(M-1) … N-3 N-2
這裡,假設上一行的數為x,下一行的數為y,則對應關係為:

y = (x - M + N) % N         公式【1

或者

x = (y + M) % N             公式【2】

通過上表的轉換,將出列1人後的資料重新組織成了0~(N–2)共N–1個人的列表,繼續求N–1個參與人員,按報數到M–1即出列,求解最後一個出列者最初在圓形佇列中的編號。

看出什麼規律沒有?通過一次處理,將問題的規模縮小了。即對於N個人報數的問題,可以分解為先求解(N–1)個人報數的子問題;而對於(N–1)個人報數的子問題,又可分解為先求[(N-1)-1]個人報數的子問題,……。

問題中的規模最小時是什麼情況?就是隻有1個人時(N=1),報數到(M–1)的人出列,這時最後出列的是誰?當然只有編號為0這個人。因此,可設有以下函式:

F(1) = 0

那麼,當N=2,報數到(M–1)的人出列,最後出列的人是誰?應該是隻有一個人報數時得到的最後出列的序號加上M,因為報到M-1的人已出列,只有2個人,則另一個出列的就是最後出列者,利用公式【2】,可表示為以下形式:

F(2) = [F(1) + M] % N = [F(1) + M] % 2

比如,N=2, M=3時,有F(2) = [F(1) + M]%N = (0 + 3)%2 = 1

根據上面的推導過程,可以很容易推匯出,當N=3時的公式:

F(3) = [F(2) + M] % N = [F(2) + M] % 3

於是,咱們可以得到遞推公式:

F(1) = 0
F(N) = [F(N - 1) + M] % N   (N>1)

有了此遞推公式,可使用遞迴方法來設計程式:

#include <iostream>
using namespace std;

int josephus(int n, int m)
{
    if(1 == n)
    {
        return 0;
    }

    return (josephus(n - 1, m) + m) % n;
}

int main()
{
    int n, m;
    cin >> n >> m;
    cout << "最後出列的人的編號為(從0開始編號):" << josephus(n, m) << endl;


    return 0;
}

執行結果:

13 3
最後出列的人的編號為(從0開始編號):12

使用遞迴函式會佔用計算機較多的記憶體,當遞迴層次太深時可能導致程式不能執行,比如64層的漢諾塔需要計算很長的時間。
因此,這裡可以將程式直接編寫為以下的遞推形式:

#include <iostream>
using namespace std;

int main()
{
    int n, m;
    cin >> n >> m;

    int out = 0;
    for(int i = 2; i <= n; i++)
    {
        out = (out + m) % i;
    }

    cout << "最後出列的人的編號為(從0開始編號):" << out << endl;

    return 0;
}

TopCoder & Codeforces & AtCoder交流QQ群:648202993
更多內容請關注微信公眾號
wechat_public.jpg