演算法專題:約瑟夫問題

INFOR38th 學術 葉倚誠

問題概述

在羅馬-猶太戰爭中,約瑟夫與他的 40 個戰友被羅馬軍隊包圍在一個洞穴中,他們寧死不屈,決定透過自殺來結束生命。

 

他們所有人圍成一個圓圈,由某個人開始依順時針報數,每數到第 3 個人,那個人就必須自殺,然後由下一個人重新報數,直到所有人死亡。

 

約瑟夫並不想死,他迅速計算出了兩個安全位置,並與一名好友站在那裡,並成為最後活下來的兩個人,向羅馬軍隊投降。

問題背景

設有 n 個人圍成一圈,編號為 1 到 n。從編號 1 的人開始順時針報數,每數到第 m 個人,該人就會被淘汰出局,接著由下一個人重新從 1 開始報數。

 

如此循環,直到最後只剩下一人,請問最後活下來的人原本的編號是多少?

問題定義

Vector 模擬

開一個動態陣列儲存所有人

每次計算出局者的索引值並將其移除

 

每次刪除的時間複雜度為 O(n)

總時間複雜度為 O(n)

#include <bits/stdc++.h>
using namespace std;

int josephusVector(int n, int m) {
    vector<int> people;
    for (int i = 1; i <= n; ++i) {
        people.push_back(i);
    }
    int idx = 0;
    while (people.size() > 1) {
        idx = (idx + m - 1) % people.size();
        people.erase(people.begin() + idx);
    }
    return people[0];
}

int main() {
    int n = 5, m = 2;
    int ans = josephusVector(n, m);
    cout << ans << endl;
    return 0;
}

Queue 模擬

​利用先進先出(FIFO)的特性,將圓圈拉成一條直線:

 

​沒數到 m 的人:從隊頭取出,直接移到隊尾

​數到 m 的人:從隊頭取出,直接淘汰(不放回隊尾)

#include <bits/stdc++.h>
using namespace std;

int josephusQueue(int n, int m) {
    queue<int> q;
    for (int i = 1; i <= n; ++i) {
        q.push(i);
    }
    while (q.size() > 1) {
        for (int i = 0; i < m - 1; ++i) {
            q.push(q.front());
            q.pop();
        }
        q.pop();
    }
    return q.front();
}

int main() {
    int n = 5, m = 2;
    int ans = josephusQueue(n, m);
    cout << ans << endl;
    return 0;
}

遞迴解法

​我們用 n = 5, m = 3(5 個人,每數到 3 出局)來舉例:

 

步驟 1:原問題(n = 5)

​此時有 5 個人,初始編號為 0, 1, 2, 3, 4

從 0 開始順時針數 3 下,2 號出局。

步驟 2:子問題(n = 4)

​現在剩下 4 個人了,遊戲規定由出局者的下一個人重新開始報數,所以 3 號成為了新一輪的起點

 

​這時候,我們可以看作是這 4 個人舉辦了一場全新的「4人約瑟夫遊戲」

為了算這場新遊戲,我們給這 4 個人一組全新編號

從新起點 3 號開始編為 0, 1, 2, 3

新編號 x' (n - 1 人遊戲)舊編號 x (n 人遊戲)
03
14
20
31
X2

x' = (x + m) (mod n)

已知 1 個人時: J(1, 3) = 0

​推導 2 個人時: J(2, 3) = (J(1, 3) + 3) (mod 2) = (0 + 3) (mod 2) = 1

​推導 3 個人時: J(3, 3) = (J(2, 3) + 3) (mod 3) = (1 + 3) (mod 3) = 1

​推導 4 個人時: J(4, 3) = (J(3, 3) + 3) (mod 4) = (1 + 3) (mod 4) = 0

​推導 5 個人時: J(5, 3) = (J(4, 3) + 3) (mod 5) = (0 + 3) (mod 5) = 3

使用關係式  x' = (x + m) (mod n) 推導遞迴解

#include <bits/stdc++.h>
using namespace std;

int josephusRecursive(int n, int m) {
    if (n == 1) {
        return 0;
    }
    return (josephusRecursive(n - 1, m) + m) % n;
}

int main() {
    int n = 5, m = 2;
    int ans = josephusRecursive(n, m) + 1;
    cout << ans << endl;
    return 0;
}

動態規劃

定義一個一維陣列 dp[i]

 

當總人數為 i 個人、每數到 m 淘汰一人時,最後倖存者在 0 索引值得編號

轉移方程式

 

根據前面在遞迴中所推導出規律,人數為 i 人的狀態,可以由人數為 i-1 人的狀態推導出來:

$$dp[i] = (dp[i-1] + m) \pmod i$$

 邊界條件$$dp[1] = 0$$

仔細觀察轉移方程式

$$dp[i] = (dp[i-1] + m) \pmod i$$

 

你會發現,我們在計算 dp[i] 的時候,只需要用到前一個狀態 dp[i-1] 的值,至於更早之前的 dp[i-2]dp[i-3] 早就沒用了

滾動 DP

 

我們只需要一個滾動變數來不斷更新答案即可

這樣可以把空間複雜度壓到極致的 O(1)

#include <bits/stdc++.h>
using namespace std;

int josephusDP(int n, int m) {
    int ans = 0;
    for (int i = 2; i <= n; ++i) {
        ans = (ans + m) % i;
    }
    return ans + 1;
}

int main() {
    int n = 5, m = 2;
    int ans = josephusDP(n, m);
    cout << ans << endl;
    return 0;
}

M = 2 特例

我們可以把人數 n 拆解成n = 2 + L

其中 2ᵏ 是不大於 n 的最大 2 的次方數,而 L 是剩下的餘數

 

此時,最後倖存者的 1 基準編號公式會精簡成:

$$J(n, 2) = 2L + 1$$

 

  • 假設 n = 5,最大 2 的次方是 4(即 2²),所以 5 = 2² + 1, L = 1

    答案 2(1) + 1 = 3 號

  • 假設 n = 41,最大 2 的次方是 32(即 2⁵),所以 41 = 32 + 9,L = 9

    答案 2(9) + 1 = 19 號

這個規律在二進位下代表:

n 的二進位最高位(最左邊的 1)移到最右邊,就是答案

n = 41 為例:

  • 41 的二進位是:101001

  • 把最左邊的 1 移到最右邊:010011

  • 010011 的十進位就是 19

#include <bits/stdc++.h>
using namespace std;

int josephusBitwiseM2(int n) {
    int highest_bit = 1 << (31 - __builtin_clz(n));
    int L = n - highest_bit;
    return (L << 1) | 1;
}

int main() {
    int n = 5;
    int ans = josephusBitwiseM2(n);
    cout << ans << endl;
    return 0;
}

The End

約瑟夫問題

By Ethan Yeh

約瑟夫問題

  • 51