約瑟夫環解法大全(C語言版)

前言: 約瑟夫環不愧是一道經典的算法題,原來也經常看到,但一直沒有動手編碼研究。最近又被同學提起這個問題,就研究了一下,發現這個問題可以挖掘的東西有很多,怪不得一直是面試的熱門問題。

解法一,使用鏈表模擬:

使用鏈成環的單鏈表完全模擬這個計數淘汰的過程,計數到要淘汰的結點時,直接刪除該結點。

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;
}

解法三,動態規劃:

優點是代碼簡潔,時間複雜度僅爲 O(n)O(n)。缺點是隻能獲得最後存活者的編號,無法像模擬法一樣可以獲取淘汰過程中的編號序列。

先給出狀態轉移方程:

假設有 nn 個人,編號爲 0,1,,n10,1,\cdots,n-1,每報數 m(m<n)m(m<n) 次淘汰一個人。
f(n)f(n) 表示 nn 個人中最終存活者的編號。
{f(1)=0f(n)=(f(n1)+m)%n\begin{cases}f(1) = 0\\f(n) = (f(n-1)+m)\%n\end{cases}

遞推公式解釋:

nn 個人,編號爲 0,1,,n10,1,\cdots,n-1,從 編號爲 0 的人開始報數,第一個被淘汰的人,編號爲 m1m-1。此時剩下 n1n-1 個人,下一次報數從編號爲 mm 的人開始。將這剩下的n1n-1 個人按報數順序一字排開,序列爲:m,m+1,,n1,0,1,,m2m,m+1,\cdots,n-1,0,1,\cdots,m-2 對比總人數爲 n1n-1 個人時的編號序列:0,1,,n20,1,\cdots,n-2 可以得到兩者的對應關係爲f(n)=(f(n1)+m)%nf(n) = (f(n-1)+m)\%n。可以認爲這個遞推公式就是通過上述找規律的方式看出來的。
這就意味着,如果我們已知總人數爲 n1n-1 時最終存活者的編號,就可以得到這個人在總人數爲 nn 時對應的編號。

上面爲了方便,我們假設的是 m<nm<n,其實當 m>nm>n 時,遞推式子不變。因爲當 m>nm>n 時,每報數 mm 次淘汰一人,相當於每報數 m%nm\%n 次淘汰一人,所以有 f(n)=(f(n1)+m%n)%n=(f(n1)+m)%nf(n) = (f(n-1)+m\%n)\%n=(f(n-1)+m)\%n,式子不變。

遞推示例:

如果感覺上面描述的實在不好理解,可以自己找個例子用這個遞推的式子實戰一下,應該就有點感覺了。例如我們要求 n=5,m=3n=5,m=3 的情況,dp 過程是這樣的:

  1. f(1)=0f(1)=0,即人數爲 1 時,最終存活者的編號爲 0。
  2. f(2)=(f(1)+3)%2=(0+3)%2=1f(2)=(f(1)+3)\%2=(0+3)\%2=1,即那個在人數剩 1 個人時最終存活者的編號在人數爲 2 時,對應的編號爲 1。
  3. 同理 f(3)=(1+3)%3=1f(3)=(1+3)\%3=1,即最終存活者的編號對應到總人數爲 3 時,編號爲 1。
  4. 同理 f(4)=(1+3)%4=0f(4)=(1+3)\%4=0,對應爲總人數爲 4 時,編號爲 0。
  5. 同理 f(5)=(0+3)%5=3f(5)=(0+3)\%5=3,對應爲總人數爲 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;
}
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章