【C++】競程筆記(約瑟夫問題)

歡迎您點進本篇文章,本系列旨在記錄與筆記個人學習歷程,內容僅供學習用途,斟酌參考。若你喜歡本文,也可以為本篇文章按愛心,或是追蹤我來取得最新文章的資訊。

什麼是約瑟夫問題(Josephus Problem)?

約瑟夫問題(Josephus Problem)是一個經典的數學和演算法問題,而同樣的問題又稱為約瑟夫環。

歷史由來:

這個問題是以弗拉維奧·約瑟夫斯命名的,他是1世紀的一名猶太歷史學家。他在自己的日記中寫道,他和他的40個戰友被羅馬軍隊包圍在洞中。他們討論是自殺還是被俘,最終決定自殺,並以抽籤的方式決定誰殺掉誰。約瑟夫斯和另外一個人是最後兩個留下的人。約瑟夫斯說服了那個人,他們將向羅馬軍隊投降,不再自殺。約瑟夫斯把他的存活歸因於運氣或天意,他不知道是哪一個。
From Wikipedia

問題定義

約瑟夫問題的標準數學模型如下:

  • 總人數(nn):有 nn 個人圍成一圈,編號為 1,2,...,n1, 2, ..., n
  • 報數間隔(mmkk):從第 1 個人開始報數(從 1 報到 mm)。
  • 規則:
    1. 報到 mm 的人出局(被殺)。
    2. 從出局者的下一個人開始,重新從 11 開始報數。
    3. 重複上述過程,直到只剩下最後一個人。
  • 目標:找出最後倖存者的編號。

至於這個約瑟夫問題運作起來長什麼樣子?大概就像以下的動圖那樣:

Josephus Problem gif

gif source:https://medium.com/carwow-product-engineering/the-josephus-problem-2ef02b77ada9

解法

模擬法

以陣列(Array)或循環鏈結串列(Circular Linked List)。

  • 邏輯:建立一個包含 11nn 的列表,模擬報數過程,每次刪除第 mm 個元素,直到列表長度為 1。
  • 缺點:當 nn 非常大時(如 n=1,000,000n = 1,000,000),模擬刪除元素的操作非常耗時,時間複雜度通常是 O(nm)O(n \cdot m)O(n2)O(n^2),效率較低。

遞迴法

此為解決約瑟夫問題最高效的方法,時間複雜度為 O(n)O(n)

由於每殺掉一個人,問題規模就縮小一次,變成了 n1n-1 個人的子問題,可透過「舊編號」與「新編號」之間的映射關係來推導公式。

(用 0-based indexing)令 f(n,m)f(n, m) 表示 nn 個人,報數到 mm 出局時,最後倖存者的索引。

  1. 第一個人出局:第一輪報數後,第 (m1)(m-1) 號人出局(從 0 開始數,第 mm 個人索引是 m1m-1),剩餘 n1n-1 個人組成一個新的圈。
  2. 重新編號:刪除元素後,原本的索引會發生位移。
  • 舉例: n=7,m=3n=7, m=3(索引 0~6)。
    • 第一輪刪掉索引 2(即第3人)。
    • 下一個人(原索引 3)變成了下一輪的起點(新索引 0)。
    • 原索引 4 就變成新索引 1,以此類推。
    • 這種位移關係可表示成: $$\text{OldIndex} = (\text{NewIndex} + m) % n$$

根據這個位移關係,可以得出遞迴式:$$f(n, m) = (f(n-1, m) + m) % n$$

終止條件就是當只剩 1 個人時(n=1n=1),其索引必定是 0。$$f(1, m) = 0$$

最終公式(0-based):$$f(n, m) =
\begin{cases}
0 & \text{if } n = 1 \
(f(n-1, m) + m) % n & \text{if } n > 1
\end{cases}$$

(1-based):$$f(n, m) =
\begin{cases}
1 & \text{if } n = 1 \
(f(n-1, m) + m - 1) % n + 1 & \text{if } n > 1
\end{cases}$$

LeetCode 1823. Find the Winner of the Circular Game

Problem Source:https://leetcode.com/problems/find-the-winner-of-the-circular-game/description/

注意這題是 1-based indexing。

範例程式碼:

1
2
3
4
5
6
7
8
class Solution {
public:
int findTheWinner(int n, int k) {
if (n == 1)
return 1;
return (findTheWinner(n - 1, k) + k - 1) % n + 1;
}
};

既然都談到遞迴了對吧,那就不能少了最重要的 Dynamic Programming,以下是 DP 範例程式碼(經滾動 DP 優化):

1
2
3
4
5
6
7
8
9
10
class Solution {
public:
int findTheWinner(int n, int k) {
int s = 1;
for (int i = 2; i <= n; ++i){
s = (s + k - 1) % i + 1;
}
return s;
}
};

該題的 DP 解題三步法:

  1. 定義狀態:dp[i]dp[i] 為當總人數為 ii 人,報數間隔為 mm 時,最終倖存者的「索引編號」。目標是求出 dp[n]dp[n]
  2. 定義轉移式:

設當前有 ii 個人,編號為 0,1,2,...,i10, 1, 2, ..., i-1,報數到 mm 的人出局。

  • 第一輪出局的人的索引為 (m1)(modi)(m-1) \pmod i
  • 出局者的下一個人成為新的報數起點(新的索引 0)。

假設 ii 個人的倖存者位置是 PoldP_{old},而刪掉一人後變成 i1i-1 個人,倖存者在剩下的人群中相對位置是 PnewP_{new},也就是 dp[i1]dp[i-1]

根據逆推邏輯得到:原問題的倖存者位置 = (子問題的倖存者位置 + 偏移量 mm) % 當前人數

最後得到完整轉移式:$$dp[i] = (dp[i-1] + m) % i$$

  1. 定義初始狀態:當只有 11 個人時 (i=1i=1),倖存者索引必定是 0。$$dp[1] = 0$$

由於計算 dp[i]dp[i] 只需要知道 dp[i1]dp[i-1],不需要保留更早之前的紀錄,因此可用一個變數 ss 來做更新即可,可將空間複雜度降至 O(1)O(1) 。(滾動 DP 優化方法)

Uva 151 - Power Crisis

PDF Source:https://onlinejudge.org/external/1/151.pdf

Uva Online Judge:https://onlinejudge.org/index.php?option=com_onlinejudge&Itemid=8&page=show_problem&problem=87

Zerojudge:https://zerojudge.tw/ShowProblem?problemid=q891

該題為約瑟夫問題的變體。

題目架構:

  • NN 個地區( 11NN
  • 地區 11 總是第一個被切斷。
  • 剩下 N1N-1 個地區,從地區 22 開始,每數到第 mm 個地區就切斷它。
  • 目標:找出最小的 mm,使得地區 13 是最後一個被切斷的。

轉化成約瑟夫問題的形式:

首先由於地區 11 都是第一個被切斷的,只需考慮 N1N - 1 的地區即可,因此就變成長度為 N1N - 1 的約瑟夫問題。

另外為方便計算,將地區給重新編號:

  • 地區 202 \rightarrow 0
  • 地區 313 \rightarrow 1
  • \cdots
  • 地區 131113 \rightarrow 11

這樣就需要找到一個 mm,使得在這 N1N-1 個人的環中,最後被切斷的索引為 11。

約瑟夫問題公式:$$f(n, m) =
\begin{cases}
0 & \text{if } n = 1 \
(f(n-1, m) + m) % n & \text{if } n > 1
\end{cases}$$

範例程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>

using namespace std;

int ans(int n, int k){
int s = 0;
for (int i = 2; i <= n; ++i){
s = (s + k) % i;
}
return s;
}

int main(){
ios::sync_with_stdio(false), cin.tie(NULL);
int N;
while (cin >> N && N != 0){
int m = 1;
while (true){
int last = ans(N - 1, m);
if (last == 11){
cout << m << '\n';
break;
}
m++;
}
}
}

總整理

遞迴公式

(0-based):$$f(n, m) =
\begin{cases}
0 & \text{if } n = 1 \
(f(n-1, m) + m) % n & \text{if } n > 1
\end{cases}$$

(1-based):$$f(n, m) =
\begin{cases}
1 & \text{if } n = 1 \
(f(n-1, m) + m - 1) % n + 1 & \text{if } n > 1
\end{cases}$$

dp 公式

1
2
3
4
5
6
7
int ans(int n, int k){
int s = 0;
for (int i = 2; i <= n; ++i){
s = (s + k) % i;
}
return s;
}

參考資料(Reference)

約瑟夫問題 - 維基百科,自由的百科全書

Thomas H. Cormen, Charles E. Leiserson, Ronald L. Rivest, and Clifford Stein. Introduction to Algorithms, Second Edition. MIT Press and McGraw-Hill, 2001. ISBN 0-262-03293-7. Chapter 14: Augmenting Data Structures, pp.318.

Josephus Problem - GeeksforGeeks

The Josephus Problem. …is a famous puzzle in computer science… | by Tom Lord | Carwow Product, Design & Engineering | Medium