1. 程式人生 > >從“約瑟夫問題”談起

從“約瑟夫問題”談起

      約瑟夫問題是一個出現在電腦科學和數學中的問題。在計算機程式設計的演算法中,類似問題又稱為約瑟夫環。

      據說著名猶太曆史學家 Josephus有過以下的故事:在羅馬人佔領喬塔帕特後,39 個猶太人與Josephus及他的朋友躲到一個洞中,39個猶太人決定寧願死也不要被敵人抓到,於是決定了一個自殺方式,41個人排成一個圓圈,由第1個人開始報數,每報數到第3人該人就必須自殺,然後再由下一個重新報數,直到所有人都自殺身亡為止。然而Josephus 和他的朋友並不想自殺。為避免與其他39個決定自殺的猶太人發生衝突,Josephus要他的朋友先假裝遵從,他將朋友與自己安排在第16個與第31個位置,於是逃過了這場死亡遊戲。

      17世紀的法國數學家加斯帕在《數目的遊戲問題》中講了這樣一個故事:15個教徒和15 個非教徒在深海上遇險,必須將一半的人投入海中,其餘的人才能倖免於難,於是想了一個辦法:30個人圍成一圓圈,從第一個人開始依次報數,每數到第九個人就將他扔入大海,如此迴圈進行,直到僅餘15個人為止。問怎樣的​排法,才能使每次投入大海的都是非教徒。

【例1】約瑟夫問題。

      N個人圍成一圈,從某個人開始,按順時針方向從1開始依次編號。從編號為1的人開始順時針“1,2,…M”報數,報到M的人退出圈子。這樣不斷迴圈下去,圈子裡的人將不斷減少。由於人的個數是有限的,因此最終會剩下一個人,該人就是優勝者。輸入N和M,輸出出圈順序。

例如,N=6、M=5,出圈的順序是:5,4,6,2,3,1。

      (1)程式設計思路。

      為輸出出圈順序,採用一個數組來進行模擬。

      定義int circle[N+1],並按circle[i]=i+1的方式賦予各元素初值。該值代表兩個含義:1)值為0,代表編號i+1的人不再圈中;2)值非0,代表圈中第i個位置的人編號為i+1。

      定義變數i代表報數位置的流動,i的初值為0,代表編號為1的人的位置,i的變化方式為:

 i=(i+1)%(n),即0-->1-->2……->n-1  ->0-->1……。

     i流動到了位置i後,該位置的人若已出圈(circle[i]==0),顯然無法報數,得跳過該位置;若該位置的人在圈中,則報數(定義一個表示報數的變數p,初值為0,每次報數p++)。

    當報數到m(即p==m)時,位置i的人出圈,記錄出圈人數cnt++,同時p置為0。當出圈人數等於N時迴圈結束。

      (2)源程式。

#include <stdio.h>
int main()
{
      int n,m,i,p,cnt;
      int circle[50];
      while (scanf("%d%d",&n,&m) && n!=0)
      {
           for (i=0;i<n;i++)
               circle[i]=i+1;
           i=0; // 報數指示
           p=0; // 報數計數器
           cnt=0; // 出隊人數
           while (cnt<n)
           {
                 if (circle[i]!=0) p++;
                 if (p==m)
                 {
                      printf("%d ",circle[i]);
                      cnt++;
                      circle[i]=0;
                      p=0;
                  }
                  i=(i+1)%(n);
             }
             printf("\n");
      }
      return 0;
}

下面我們從例1的基礎上進行擴充套件討論。

例如,執行例1的程式時,輸入41  3,則輸出為:

3  6  9  12  15   18   21   24   27   30   33   36   39   1   5   10   14   19   23   28   32   37

41  7  13  20  26  34  40  8  17  29  38  11  25  2  22  4  35  16  31

      為這個輸出結果進行的模擬是需要耗時的。實際上,在大多數問題中,我們不關心中間的結果,只關心某個最終結果。例如,在Josephus 的故事中,Josephus 和他的朋友不想自殺,Josephus 需要關心的是最後一個和倒數第2個出圈的編號是多少,至於中間過程(39個猶太人誰先自殺,誰後自殺)對Josephus 來說無意義。因此,Josephus 需要的是快速確定最後一個和倒數第2個出圈的編號,然後站到對應位置即可。而無需耗時模擬整個過程。

【例2】猴子選大王。

      一堆猴子都有編號,編號是1,2,3 ...m,這群猴子(m個)按照1~m的順序圍坐一圈,從第1開始數,每數到第N個,該猴子就要離開此圈,這樣依次下來,直到圈中只剩下最後一隻猴子,則該猴子為大王。已知猴子數m和報數間隔n(設1<=n<=m<=50),問編號為多少的猴子當大王?

      (1)程式設計思路1。

       將例1的源程式略作修改,增加一個變數last記錄最後獲勝者編號,不輸出中間過程。顯然,

if (cnt==n) last=circle[i];

       (2)源程式1。

#include <stdio.h>
int main()
{
     int n,m,i,p,cnt,last;
     int circle[50];
     while (scanf("%d%d",&n,&m) && n!=0)
     {
         for (i=0;i<n;i++)
             circle[i]=i+1;
         i=0; // 報數指示
         p=0; // 報數計數器
         cnt=0; // 出隊人數
         while (cnt<n)
         {
              if (circle[i]!=0) p++;
              if (p==m)
              {
                   cnt++;
                   if (cnt==n) last=circle[i];
                   circle[i]=0;
                   p=0;
              }
              i=(i+1)%(n);
       }
       printf("%d\n",last);
    }
    return 0;
}

(3)程式設計思路2。

       源程式1中採用陣列模擬,由於猴子在圈中還是出圈是通過陣列元素circle[i]的值非0還是0來判斷,位置並未真正刪除,因此當n和m很大時,程式的執行效率很低。例如,僅求最後一個出圈的元素,迴圈就得執行m*n次(p從1報到m,每次報數流動i得走完整一圈,其中n-1個已出圈,圈中僅一個元素)。

      為提高執行效率,可以考慮採用迴圈連結串列來進行模擬,這樣每次出圈就將連結串列中的相應元素刪除。迴圈連結串列只剩最後一個元素時,輸出勝者編號。

       (4)源程式2。

#include <stdio.h>
struct Jose
{

      int code; // 編號
      Jose *next;
};
int main()
{
      Jose *head,*p1,*p2;
      int n,m,i,cnt,tmp;
      scanf("%d%d",&n,&m);
      while (n!=0 && m!=0)
      {
            head=new Jose;
            head->code=1;
            p2=head;
            for (i=2;i<=n;i++) // 建立迴圈連結串列
           {
                p1=new Jose;
                p1->code=i;
                p2->next=p1;
                p2=p1;
            }
            p2->next=head;
            p1=head;
            cnt=n;
            while (cnt>1)
            {
                  tmp=m%cnt; // 提高效率之舉,當m大於圈中人數時會迴圈多圈,可以不用
                  if (tmp==0) tmp=cnt;
                  i=1;
                  while (i<tmp)       // 報數m-1次
                  {
                        i++;
                        p2=p1;
                        p1=p1->next;
                   }
                   p2->next=p1->next; // 報m的結點出圈
                   delete p1; // 釋放出圈結點的空間
                   cnt--;
                   p1=p2->next;
           }
           printf("%d\n",p1->code);
           delete p1;
           scanf("%d%d",&n,&m);
    }
    return 0;
}

(5)程式設計思路3。

      本例中的源程式2相比源程式1可以提高執行效率,但畢竟也是採用過程模擬,因此對於n和m較大的情況,效率仍然不高。有沒有可以根據n和m的值直接推出最後出圈人編號的辦法呢?

       為了討論方便,先把問題稍微改變一下,並不影響原意。

  問題描述:n個人(編號0~(n-1)),從0開始報數,報到(m-1)的退出,剩下的人繼續從0開始報數。求勝利者的編號。
  我們知道第1個人(編號一定是(m-1)%n)出列之後,剩下的n-1個人組成了一個新的約瑟夫環(以編號為k=m%n的人開始):
  k , k+1 , k+2  ...  n-2 , n-1 , 0 , 1 , 2 , ... k-2
  並且從k開始報0。
  現在我們把他們的編號做一下轉換:
  k --> 0        k+1 --> 1    k+2 --> 2
          ...                ...
  k-3 --> n-3  k-2 --> n-2
  變換後就完完全全成為了(n-1)個人報數的子問題,假如我們知道這個子問題的解:例如x是最終的勝利者,那麼根據轉換把這個x變回去不剛好就是n個人情況的解嗎?

      下面我們來推導變回去的公式。

       序列1: 1 , 2 , 3 , 4 ,  …k-1 , k , k+1  ,…, n-2 , n-1 , n
  序列2: 1 , 2 , 3 , 4 , … k-1 ,  k+1 , … , n-2 , n-1 , n
  序列3:  k+1 , k+2 , k+3 , …,  n-2 , n-1 , n ,  1 , 2 , 3 ,… , k-2 , k-1
  序列4: 1 ,  2 , 3 , 4 , … , 5 , 6 , 7 , 8 , …, n-2 , n-1
  ∵  k=m%n;

    ∴  x' = x+k = x+ m%n ;   而  x+ m%n 可能大於n

  ∴ x'=  (x+ m%n)%n =  (x+m)%n 。
  如何知道(n-1)個人報數的問題的解f(n-1)呢? 顯然只要知道(n-2)個人的解f(n-2)就行了。(n-2)個人的解呢?當然是先求f(n-3) ---- 這顯然就是一個倒推問題!
  令 f[i] 表示i個人玩報m退出的約瑟夫環遊戲的最後勝利者的編號,則有遞推公式:
  f[1] = 0 ;
  f[i] = (f[i-1]+m)%i;     (i>1)
  有了這個遞推公式,我們就很容易求得n個人報m退出的約瑟夫問題的最後勝利者編號f[n]。因為實際生活中編號總是從1開始,我們輸出f[n]+1即可。

       編寫程式時,我們可以採用陣列遞推以便儲存中間結果,也可以不儲存中間任何結果採用迭代直接得到最後勝利者編號。

       (6)採用迭代方式實現的源程式3。

#include <stdio.h>
int main()
{
      int n,m,i,s;
      scanf("%d%d",&n,&m);
      while (n!=0 && m!=0)
     {
           s=0;
           for (i=2;i<=n;i++)
                s=(s+m) % i;
           printf("%d\n",s+1);
           scanf("%d%d",&n,&m);
      }
      return 0;
}

(7)採用遞推方式實現的源程式4。 

// 採用打表的方式,先將所有的值求出來儲存在二維陣列f[51][51]中。
// f[n][m]的值代表n個人報m遊戲的最後勝利者編號。
// 則有 f[i][m]=0, (i=1)
// f[i][m]= (f[i-1][m]+m)%i (i>1)
#include <stdio.h>
int main()
{
     int n,m,i,j,f[51][51];
     for (i=1;i<51;i++)
           f[1][i]=0;
     for (i=1;i<51;i++)
     {
           for (j=2;j<51;j++)
                f[j][i]=(f[j-1][i]+i)%j;
     }
     scanf("%d%d",&n,&m);
     while (n!=0 && m!=0)
     {
            printf("%d\n",f[n][m]+1);
            scanf("%d%d",&n,&m);
     }
     return 0;
}

 【例3】城市斷電。

      有n(3<=n<150)個城市圍成圈,先將第1個城市(編號為1)斷電,然後每隔m個城市使一個城市斷電,直到剩下最後一個城市不斷電。問使2號城市不斷電的最小的m是多少?

      (1)程式設計思路。

      採用例2的求最後勝利者的方式,對n個城市,從m=1開始搜尋,若當前m可使2號城市作為勝利者,則m就是所求,否則m=m+1後,繼續搜尋。

      程式採用打表的方式,先將n=3~149的對應m值求出來並儲存到陣列ans[150]中。

      另外,需要注意的是第1個城市先斷電了,2號城市相當第1個城市,也可以把問題看成編號從1~n-1的約瑟夫問題。

      (2)源程式。

#include <stdio.h>
int main()
{
      int ans[150],i,j,m,tmp;
      for (i = 3;i<150;i++)
      {
           m = 1;
           while(1)
           {
                 tmp = 1; // 注意第1個城市已經斷電,相當從1~n-1個城市
                 for  (j = 2;j < i; j++)
                 {
                      tmp = (tmp + m)%j;
                      if  (tmp == 0)
                      {
                          tmp = j;
                       }
                  }
                  if (tmp == 1) // 最後勝利者是2號城市

                                      // (編號為1一開始就斷電,2號相當圈中第1個城市)
                  {
                       ans[i] = m;
                       break;
                   }
                   m++;
             }
     }
     int n;
     scanf("%d",&n);
     while (n!=0)
     {
           printf("%d\n",ans[n]);
           scanf("%d",&n);
     }
     return 0;
}

將此源程式提交給POJ 2244 “Eeny Meeny Moo”,可以Accepted。

&n