前言: 約瑟夫環不愧是一道經典的算法題,原來也經常看到,但一直沒有動手編碼研究。最近又被同學提起這個問題,就研究了一下,發現這個問題可以挖掘的東西有很多,怪不得一直是面試的熱門問題。
解法一,使用鏈表模擬:
使用鏈成環的單鏈表完全模擬這個計數淘汰的過程,計數到要淘汰的結點時,直接刪除該結點。
typedef struct NODE{
int num;
struct NODE* next;
}PN;
int cycle0(int n, int m){ // 使用鏈表實現
PN* head = (PN*)malloc(sizeof(PN));
head->num = 0;
head->next = NULL;
PN* tail = head; // 恆指向鏈表尾
for(int i = 1; i < n; ++i){
tail->next = (PN*)malloc(sizeof(PN));
tail = tail->next;
tail->num = i;
tail->next = NULL;
}
tail->next = head; // 鏈成環
PN* p = head;
int j = 0; // 報數器
while(p->next != p){ // 如果 p 的下一個結點指向自己,說明環中只剩一個結點
if(j == m-2){ // 每次報到 m-2 刪除當前 p 指向結點的下一個結點
PN* q = p->next;
p->next = q->next;
free(q); // 釋放內存
p = p->next;
j = 0;
}else{
p = p->next;
++j;
}
}
return p->num;
}
解法二,使用數組模擬:
數組模擬法用數組下標對應人的編號。最簡單直白的模擬方式,就是使用數組值來表示存活或者淘汰,一般我們用 0 和 1 來表示,如果數組元素值對應淘汰,則在計數時跳過該元素。
int cycle1(int n, int m){ // 使用數組實現 1 代表活 0 代表淘汰(反過來也可以)
int a[n];
for(int i = 0; i < n; ++i){
a[i] = 1;
}
int i = 0, j = 0, count = 0;
while(count < n-1){
i %= n;
if(a[i] == 1){
if(j == m-1){
j = 0;
a[i] = 0;
++count;
}else ++j;
++i;
}else while(a[(++i)%n] == 0){} // 跳過淘汰者,這些人不計入報數
}
for(int i = 0; i < n; ++i){
if(a[i] == 1){
return i;
}
}
}
上面的代碼寫了一個循環跳過淘汰者,代碼形式上太不美觀。我們可以通過藉助標誌值來計數,優化代碼形式,讓代碼看起來更美觀,但並未優化代碼效率。
int cycle2(int n, int m){ // 優化數組模擬法的代碼 0 代表活 1 代表淘汰(反過來也可以)
int a[n] = {0};
int i = 0, j = 0, count = 0;
while(count < n-1){
if(a[i] == 0 && j == m-1){
j = 0;
a[i] = 1;
++count;
}
j += 1-a[i]; // 利用 a[i] 的值來對 j 進行計數
i = (i+1)%n;
}
for(int i = 0; i < n; ++i){
if(a[i] == 0) return i;
}
}
上面的代碼雖然沒有能提升效率,但給了一個優化思路,即充分利用數組元素的值。我們可以利用數組值來輔助定位到當前存活者的下一存活者,儘量跳過中間的淘汰者。輔助定位的方法是,當數組元素值等於元素下標時,表示此人存活,當需要淘汰當前的人時,就用後面一個元素的數組值覆蓋當前的元素值,這樣當前元素值和下標不等,表示當前這個人已被淘汰,還可以藉助數組值定位到下一個可能存活的人身上。
int cycle3(int n, int m){ // 繼續優化數組模擬法的代碼,數組值 等於 下標表示存活
// 使用數組值引導到下一個人的下標
int a[n];
for(int i = 0; i < n; ++i){
a[i] = i;
}
int i = 0, j = 0, count = 0;
while(count < n-1){
i %= n;
if(a[i] == i){
if(j == m-1){
j = 0;
a[i] = a[(i+1)%n];
++count;
}else ++j;
++i;
}else i = a[i]; // 優化的點
}
for(int i = 0; i < n; ++i){
if(a[i] == i){
return i;
}
}
}
最後沿襲這個思路還可以進一步優化算法,還是通過數組值來確定下一存活者,但這次是精準定位到下一存活者。與上一方法不同的是,數組值存儲的是下一個存活者的編號,使用兩個索引,分別爲 p 和 c,p 爲上一個存活者的編號,其數組值爲當前存活者編號,c 爲當前存活者的編號,其數組值爲下一個存活者的編號,當需要淘汰當前存活者時,令 a[p] = a[c] 即可,即上一存活者指向的是下一存活者,當前存活者的編號被覆蓋,相當於在數組中刪除了當前存活者。這種改進算法不僅不需要每次判斷數組值等於多少,而且可達的數組值一定表示的是真實的下一個存活者,大大提升了上一算法的效率。
// 你可以類比二叉樹的雙親表示法(使用數組表示二叉樹)來理解這個算法
// 這個算法的本質是,相當於使用數組來模擬鏈表,數組值就是指針,覆蓋數組值就相當於鏈表中的刪除結點操作。
int cycle4(int n, int m){ // 繼續優化數組模擬法的代碼
int a[n];
a[n-1] = 0;
for(int i = 0; i < n-1; ++i){
a[i] = i+1;
}
int c = 0, p = n-1, j = 0, count = 0;
while(count < n-1){
if(j == m-1){
j = 0;
a[p] = a[c]; // 刪除當前存活者,p 此時指向的就是下一存活者,所以 p 指針不需要移動。
++count;
}else{
++j;
p = c;
}
c = a[c];
}
return c;
}
如果你理解了上述算法的本質是模擬鏈表,那麼就像我們給出的第一個鏈表模擬法的算法一樣,我們使用一個指針便可以完成遍歷和刪除結點的操作,並不需要使用 p,c 兩個索引來配合遍歷和刪除操作。
int cycle5(int n, int m){ // 使用單索引
int a[n];
a[n-1] = 0;
for(int i = 0; i < n-1; ++i){
a[i] = i+1;
}
int c = n-1, j = 0, count = 0;
while(count < n-1){
if(j == m-1){
j = 0;
a[c] = a[a[c]]; // 刪除當前存活者
++count;
}else{
++j;
c = a[c];
}
}
return c;
}
解法三,動態規劃:
優點是代碼簡潔,時間複雜度僅爲 。缺點是隻能獲得最後存活者的編號,無法像模擬法一樣可以獲取淘汰過程中的編號序列。
先給出狀態轉移方程:
假設有 個人,編號爲 ,每報數 次淘汰一個人。
表示 個人中最終存活者的編號。
遞推公式解釋:
個人,編號爲 ,從 編號爲 0 的人開始報數,第一個被淘汰的人,編號爲 。此時剩下 個人,下一次報數從編號爲 的人開始。將這剩下的 個人按報數順序一字排開,序列爲: 對比總人數爲 個人時的編號序列: 可以得到兩者的對應關係爲。可以認爲這個遞推公式就是通過上述找規律的方式看出來的。
這就意味着,如果我們已知總人數爲 時最終存活者的編號,就可以得到這個人在總人數爲 時對應的編號。
上面爲了方便,我們假設的是 ,其實當 時,遞推式子不變。因爲當 時,每報數 次淘汰一人,相當於每報數 次淘汰一人,所以有 ,式子不變。
遞推示例:
如果感覺上面描述的實在不好理解,可以自己找個例子用這個遞推的式子實戰一下,應該就有點感覺了。例如我們要求 的情況,dp 過程是這樣的:
- ,即人數爲 1 時,最終存活者的編號爲 0。
- ,即那個在人數剩 1 個人時最終存活者的編號在人數爲 2 時,對應的編號爲 1。
- 同理 ,即最終存活者的編號對應到總人數爲 3 時,編號爲 1。
- 同理 ,對應爲總人數爲 4 時,編號爲 0。
- 同理 ,對應爲總人數爲 5 時,編號爲 3。遞推結束。
遞歸解法如下:
// dp 的遞歸解法
int cycle6(int n, int m){
if(n == 1) return 0;
return (cycle5(n-1,m) + m)%n;
}
遞推解法如下:
// dp 的遞推解法
int cycle7(int n, int m){
int alive = 0; // 對應 i = 1 的結果
for(int i = 2; i <= n; ++i){
alive = (alive + m)%i;
}
return alive;
}
最後附上完整的測試代碼:
#include<stdio.h>
#include<stdlib.h>
typedef struct NODE{
int num;
struct NODE* next;
}PN;
int cycle0(int n, int m){ // 使用鏈表實現
PN* head = (PN*)malloc(sizeof(PN));
head->num = 0;
head->next = NULL;
PN* tail = head; // 恆指向鏈表尾
for(int i = 1; i < n; ++i){
tail->next = (PN*)malloc(sizeof(PN));
tail = tail->next;
tail->num = i;
tail->next = NULL;
}
tail->next = head; // 鏈成環
PN* p = head;
int j = 0; // 報數器
while(p->next != p){ // 如果 p 的下一個結點指向自己,說明環中只剩一個結點
if(j == m-2){ // 每次報到 m-2 刪除當前 p 指向結點的下一個結點
PN* q = p->next;
p->next = q->next;
free(q); // 釋放內存
p = p->next;
j = 0;
}else{
p = p->next;
++j;
}
}
return p->num;
}
int cycle1(int n, int m){ // 使用數組實現 1 代表活 0 代表淘汰(反過來也一樣)
int a[n];
for(int i = 0; i < n; ++i){
a[i] = 1;
}
int i = 0, j = 0, count = 0;
while(count < n-1){
i %= n;
if(a[i] == 1){
if(j == m-1){
j = 0;
a[i] = 0;
++count;
}else ++j;
++i;
}else while(a[(++i)%n] == 0){}
}
for(int i = 0; i < n; ++i){
if(a[i] == 1){
return i;
}
}
}
int cycle2(int n, int m){ // 優化數組的代碼 0 代表活 1 代表淘汰(反過來也一樣)
int a[n] = {0};
int i = 0, j = 0, count = 0;
while(count < n-1){
if(a[i] == 0 && j == m-1){
j = 0;
a[i] = 1;
++count;
}
j += 1-a[i]; // 利用 a[i] 的值來對 j 進行計數
i = (i+1)%n;
}
for(int i = 0; i < n; ++i){
if(a[i] == 0) return i;
}
}
int cycle3(int n, int m){ // 繼續優化數組的代碼,數組值 等於 下標表示活
// 使用數組值引導到下一個人的下標
int a[n];
for(int i = 0; i < n; ++i){
a[i] = i;
}
int i = 0, j = 0, count = 0;
while(count < n-1){
i %= n;
if(a[i] == i){
if(j == m-1){
j = 0;
a[i] = a[(i+1)%n];
++count;
}else ++j;
++i;
}else i = a[i]; // 優化的點
}
for(int i = 0; i < n; ++i){
if(a[i] == i){
return i;
}
}
}
// 本質是使用數組模擬鏈表
int cycle4(int n, int m){ // 繼續優化數組的代碼 不需要每次都判斷 數組值
// 和上一種方法不同的是,數組值表示的是下一個存活的人
int a[n];
a[n-1] = 0;
for(int i = 0; i < n-1; ++i){
a[i] = i+1;
}
int c = 0, p = n-1, j = 0, count = 0;
while(count < n-1){
if(j == m-1){
j = 0;
a[p] = a[c]; // 刪除當前存活的人 即 p 索引的數組值
++count;
}else{
++j;
p = c;
}
c = a[c];
}
return c;
}
// 使用單索引實現
int cycle5(int n, int m){
int a[n];
a[n-1] = 0;
for(int i = 0; i < n-1; ++i){
a[i] = i+1;
}
int c = n-1, j = 0, count = 0;
while(count < n-1){
if(j == m-1){
j = 0;
a[c] = a[a[c]]; // 刪除當前存活者
++count;
}else{
++j;
c = a[c];
}
}
return c;
}
// dp 的解法只能輸出最後存活者的序號,無法輸出淘汰序列
int cycle6(int n, int m){ // dp 的遞歸解法
if(n == 1) return 0;
return (cycle5(n-1,m) + m)%n;
}
int cycle7(int n, int m){ // dp 的遞推解法
int alive = 0; // 對應 i = 1 的結果
for(int i = 2; i <= n; ++i){
alive = (alive + m)%i;
}
return alive;
}
int main(){
int n = 10000, m = 3;
printf("%d\n",cycle0(n,m));
printf("%d\n",cycle1(n,m));
printf("%d\n",cycle2(n,m));
printf("%d\n",cycle3(n,m));
printf("%d\n",cycle4(n,m));
printf("%d\n",cycle5(n,m));
printf("%d\n",cycle6(n,m));
printf("%d\n",cycle7(n,m));
return 0;
}