小朋友學資料結構(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
更多內容請關注微信公眾號