一 問題描述
約瑟夫環問題的基本描述如下:已知n個人(以編號1,2,3...n分別表示)圍坐在一張圓桌周圍。從編號為1的人開始報數,數到m的那個人出列;他的下一個人又從1開始報數,數到m的那個人又出列;依此規律重復下去,要求找到最后一個出列的人或者模擬這個過程。
二 問題解法
在解決這個問題之前,首先我們對人物進行虛擬編號,即相當於從0開始把人物重新進行編號,即用0,1,2,3,...n-1來表示人物的編號,最后返回的編號結果加上1,就是原問題的解(為什么這么做呢,下文有解釋)。而關於該問題的解通常有兩種方法:
1.利用循環鏈表或者數組來模擬整個過程。
具體來講,整個過程很明顯就可以看成是一個循環鏈表刪除節點的問題。當然,我們也可以用數組來代替循環鏈表來模擬整個計數以及出列的過程。此處只給出利用數組來模擬這個過程的解法,最終結果為最后一個出列的人的編號:
#include<iostream> #include<unordered_map> #include<queue> #include<cstring> #include<cstdlib> #include<cmath> #include<algorithm> #include<sstream> #include<set> #include<map> using namespace std; int main() { int n,m; cin>>n>>m; vector<int>rs(n); for(int i = 0 ; i < n; i++) rs[i] = i + 1;//對人物重新進行編號,從0開始 int cur_index = 0;//當前圓桌狀態下的出列人的編號 int out_cnt = 0;//用以表示出列的人數 int cnt = n;//表示當前圓桌的總人數 while(out_cnt < n - 1)//當out_cnt等於n-1時,循環結束,此時圓桌師生最后一個人,即我們要的結果 { if(cur_index + m > cnt) { if((cur_index + m) % cnt == 0)//這種情況需要單獨考慮,否則cur_index就變成負值了 cur_index = cnt - 1; else cur_index = (cur_index + m) % cnt - 1; } else cur_index = cur_index + m - 1; cnt--; out_cnt++; cout<<"當前出列的為:"<<*(rs.begin() + cur_index)<<endl; rs.erase(rs.begin() + cur_index);//從數組中刪去需要出隊的人員 } cout<<"最后一個出列的人物為 :"<<rs[0]<<endl; }
該方法的時間復雜度為O(nm),空間復雜度為O(n),整個算法的基本流程還是比較清晰的,相當於每次循環更新cur_cnt、cnt和out_cnt這三個變量,當out_cnt == n-1時,此時出隊的人數一共有n-1人,圓桌上只剩下一個人了,停止循環。此外,該算法有幾點需要注意:
(1)首先,我們為什么要對用戶進行重新編號(從0開始到n-1),在我看來,這是因為在整個循環過程中我們用到了對當前圓桌人數總數cnt進行了取余的操作,而取余的結果包括0到cnt -1,即包括0;如果編號是從1開始的話,在余數為0的時候需要特殊處理,而從0開始編號的話,一方面符合編程習慣(下標從0開始計數);另一方面面對取余操作不需要特殊處理。
(2)代碼中的cur_index指的當前圓桌狀態下需要出隊的人的編號,即數到m的人的編號(此處的編號指的是重新編號的編號);由於cur_index的值表示的是重新編號后的編號,但它的初值表示的是最開始數數的那個人的編號(初始值的時候就不表示需要出隊的人的編號了),由於題目要求的是從編號為1的人開始數數,並且其對應的新編號為0,故cur_index的初值為0。此外,在循環計算cur_index時,我們發現不論是哪種情況,cur_index更新值都有一個減1的操作;這是因為cur_index每次加m得到的值或者加m再取余得到的值,實際上是需要出隊人員的原始編號(即從1開始到n結束的那個編號),而cur_index應該表示的是重新編號后的編號,而新編號比舊編號小1,所以需要減去1,這其實可以看成是一個規律。此處可以舉個例子,例如:對於n=5,m=3;cur_index的初值為0,cur_index + 3 <5,所以cur_index + m = 3(不用進行取余操作了),如果不減1的話,3表示的是新編號,對應的舊編號就是4,而實際上應該出隊的人員的編號是3,對應的新編號是2。
(3)數組rs的下標相當於新編號,而數組存儲的內容相當於舊編號,rs每次刪除的元素對應每次出隊的人員的編號,在這里我們需要了解erase的原理,rs每刪除一個元素時,被刪除之后的元素就會前移,相當於新舊編號的對應關系也發生了變化,即下一個開始數數的人占據了之前出隊人員的位置,它的新編號發生了變化;而對向量進行erase操作后元素移動的原理實質和圓桌人員移動的情況是一致的啊,然后結合cur_index進行操作(把vector進行erase操作后移動元素的原理看成是圓桌人員移動),所以說能利用vector代替循環鏈表模擬整個過程。由於rs的內容表示舊編號,所以返回的結果直接是rs[0]的內容,不需要再加1了。
2.利用數學推導得出的公式直接求解。
方法1中利用了數組直接進行過程模擬,但空間復雜度比較高,下面給出一種更為常見的方法,即直接對整個過程歸納出一個數學公式來。公式的具體推導本文不詳細描述,可參考:https://blog.csdn.net/wusuopubupt/article/details/18214999
上文中給出了公式$f(i)=[f(i-1) + m] \% i$ 其中$f(i)$表示的是當圓桌人數為$i$時,應該出隊人員的編號(這里的編號指的是重新編號后的編號);其中當$i = 1$時,$f(i) = 0$ ,所以公式的完整表達式如下:
$$ f(i) = \begin{cases} [f(i-1) + m] \% i ,& \text{i > 1} \\ 0 , & \text{i = 1} \end{cases} $$
所以根據這個公式,可以遞歸實現,也可以用迭代實現,此處只給出迭代的實現:
#include<iostream> #include<unordered_map> #include<queue> #include<cstring> #include<cstdlib> #include<cmath> #include<algorithm> #include<sstream> #include<set> #include<map> using namespace std; int main() { int n,m; cin>>n>>m; int cnt= 0; int rs = 0;//f(1) = 0; for(int i = 2; i <= n; i++) { rs = (rs + m) % i; } cout<<"最后一個出列的人為:"<<rs + 1<<endl; }
該算法的時間復雜度為O(n),空間復雜度為O(1)。需要注意的一點是,rs的初始值為0,表示當總人數為1時,應該出列的人員編號(新編號)。這里與方法1中cur_index的初值表示的意義是完全不同的。整個公式的推導過程與方法1也是完全不同的(見參考鏈接)。
三 問題變種
之前所遇到的問題都是從編號(舊編號)為1的人開始報數,如果從編號為k的人開始報數呢?整個問題就變了,方法2的數學公式就不適用了;如果還想用方法2中的遞推數學公式的話,需要根據參考鏈接中的方法重新推導數學公式。但是方法1還是適用的,只需在cur_index初始化的時候,初始化k-1即可(代碼的其他部分不用變),curi_index的初值意義就表示開始數數的人的編號(新編號)。所以遇到哪種類型的問題,要看仔細嘍,可以重點掌握下方法1,它的通用型更強,畢竟是直接模擬整個過程啊。