1. 程式人生 > 其它 >資料結構與演算法之約瑟夫問題

資料結構與演算法之約瑟夫問題

約瑟夫問題描述的是什麼?

約瑟夫問題:有 N 個人圍成一圈,每個人都有一個編號,編號由入圈的順序決定,第一個入圈的人編號為 1,最後一個為 N,從第 k (1<=k<=N)個人開始報數,數到 m (1<=m<=N)的人將出圈,然後下一個人繼續從 1 開始報數,直至所有人全部出圈,求依次出圈的編號。

如何儲存資料

面對一道題,首先需要思考,要選用什麼樣的資料結構來儲存資料。約瑟夫問題描述的是迴圈報數出圈的問題,報數始終圍繞同一個方向進行,所以可以使用單向環形連結串列來儲存。

每當有一個人入圈,就創建出一個新的節點,節點間首尾相連,程式碼如下:

//節點
class Node {
	//節點序號
	private Integer no;
	//指向下一個節點的引用
	private Node next;

	public Node(Integer no) {
		this.no = no;
	}

	@Override
	public String toString() {
		return "Node{" +
			"no=" + no +
			",next=" + (next == null ? "" : next.no) +
			'}';
	}
}

//單向環形連結串列
class SingleCycleLinkedList {
	//頭引用
	private Node head;
	//尾引用
	private Node tail;
	//連結串列長度
	private int size;

	/**
	 * 初始化指定長度序號遞增的環形連結串列
	 *
	 * @param size
	 */
	public SingleCycleLinkedList(int size) {
		for (int i = 1; i <= size; i++) {
			add(new Node(i));
		}
		this.size = size;
	}

	/**
	 * 插入環形連結串列
	 *
	 * @param node
	 */
	public void add(Node node) {
		if (node == null) {
			return;
		}

		//連結串列為空,直接將 head, tail 引用指向新節點
		if (size == 0) {
			head = node;
			tail = node;
			size++;
			return;
		}

		//連結串列不為空,將新節點放在連結串列最後,同時新節點的 next 引用指向 head,完成成環操作
		tail.next = node;
		tail = tail.next;
		tail.next = head;
		size++;
}

	...
}

核心邏輯在 add 方法中,需要注意的是,當連結串列為空時,新增的節點不能自成環,也就是 next 引用不能指向自己,所以第一次新增時,直接將 head, tail 指向新增的節點,不去操作節點的 next 引用。當連結串列不為空時,需要引入成環的步驟,成環步驟分解如下:

1.tail.next = node

2.tail = tail.next

3.tail.next = head

這樣即完成了成環操作。

在 main 方法中進行測試,構造長度為 10 的環形連結串列:

SingleCycleLinkedList singleCycleLinkedList = new SingleCycleLinkedList(10);
System.out.println(singleCycleLinkedList);

結果如下:

Node{no=1,next=2}, Node{no=2,next=3}, Node{no=3,next=4}, Node{no=4,next=5}, Node{no=5,next=6}, Node{no=6,next=7}, Node{no=7,next=8}, Node{no=8,next=9}, Node{no=9,next=10}, Node{no=10,next=1}

解決約瑟夫問題

通過上一步,完成了資料的儲存,接下來需要解決如何迴圈報數出圈的問題。題目要求從第 k 個人開始報數,所以要先找到報數的起始位置,然後開始迴圈報數,數到 m 的人出圈,也就是對應的節點要移出連結串列。需要注意的是,單向連結串列的節點無法自我刪除,如圖所示:

如果想刪除編號為 2 的節點,cur 引用必須指向 1,這樣才能將 1 的 next 引用從原來的 2 指向 3:

所以,在找報數的起始位置時,應當從起始位置的上一個位置開始計數,這樣當尋找到待移除節點時,實際上是定位到了待移除節點的上一個節點。尋找報數起始位置的程式碼如下(程式碼中 start 變數就是引數 k):

//尋找開始報數的節點(這裡從 tail 開始遍歷,取報數節點的上一個節點,因為單向連結串列的節點刪除必須依賴上一個節點)
Node tmp = tail;
	int startIndex = 0;
	while (startIndex++ != size) {
		if (start == startIndex) {
			break;
		}
		tmp = tmp.next;
	}

找到了報數起始位置後,就要開始執行報數的操作,移除節點時需要注意,當連結串列中的節點數量只剩一個時,無需操作節點的 next 引用,直接將節點置空即可。
報數出圈的程式碼如下(程式碼中 step 變數就是引數 m):

//儲存順序出鏈的節點
List<Node> list = new ArrayList<>(size);

//開始計數,數到指定間隔後節點出鏈
int count = 1;
while (size > 1) {
	if (count == step) {
		//節點出鏈
		//1.定義一個引用,指向待刪除節點
		Node delNode = tmp.next;
		//2.將當前節點的 next 引用指向待刪除節點的下一個節點
		tmp.next = delNode.next;
		//3.連結串列長度-1
		size--;
		//4.置空待刪除節點的 next 引用
		delNode.next = null;
		//5.儲存已刪除節點
		list.add(delNode);
		//6.重置計數器
		count = 1;
	} else {
		//繼續迴圈計數
		tmp = tmp.next;
		count++;
	}
}

//連結串列只剩一個節點時,不需要操作next指標刪除節點,直接將頭尾置空
tmp.next = null;
head = null;
tail = null;
size = 0;
list.add(tmp);

注意,在移除節點後,必須要保證連結串列仍然成環,移除步驟分解如下(假設連結串列剩 3 個節點,要移出編號為 3 的節點):

1.Node delNode = tmp.next

2.tmp.next = delNode.next

3.delNode.next = null

報數出圈的完整程式碼如下:

	/**
	 * 從 start 位置開始,每隔 step 後節點出鏈
	 *
	 * @param start 報數起始位置
	 * @param step 報數出圈間隔
	 * @return 依次出鏈的節點列表
	 */
	public List<Node> poll(int start, int step) {
		if (start <= 0 || start > size) {
			throw new RuntimeException("起始位置需大於0並且小於等於連結串列長度");
		}
		if (step <= 0 || step > size) {
			throw new RuntimeException("間隔需大於0");
		}
		if (size == 0) {
			return Collections.emptyList();
		}

		//尋找開始報數的節點(這裡從 tail 開始遍歷,取報數節點的上一個節點,因為單向連結串列的節點刪除必須依賴上一個節點)
		Node tmp = tail;
		int startIndex = 0;
		while (startIndex++ != size) {
			if (start == startIndex) {
				break;
			}
			tmp = tmp.next;
		}

		//儲存順序出鏈的節點
		List<Node> list = new ArrayList<>(size);

		//開始計數,數到指定間隔後節點出鏈
		int count = 1;
		while (size > 1) {
			if (count == step) {
				//節點出鏈
				//1.定義一個引用,指向待刪除節點
				Node delNode = tmp.next;
				//2.將當前節點的 next 引用指向待刪除節點的下一個節點
				tmp.next = delNode.next;
				//3.連結串列長度-1
				size--;
				//4.置空待刪除節點的 next 引用
				delNode.next = null;
				//5.儲存已刪除節點
				list.add(delNode);
				//6.重置計數器
				count = 1;
			} else {
				//繼續迴圈計數
				tmp = tmp.next;
				count++;
			}
		}

		//連結串列只剩一個節點時,不需要操作next指標刪除節點,直接將頭尾置空
		tmp.next = null;
		head = null;
		tail = null;
		size = 0;
		list.add(tmp);

		return list;
	}

對上述程式碼進行測試:

//n: 圈內人數, k: 報數的起始位置, m: 報數出隊的間隔
int n = 10;
int k = 2;
int m = 3;
			
List<Node> pollList = singleCycleLinkedList.poll(k, m);
System.out.printf("size: %d, start: %d, step: %d\n", n, k, m);
System.out.println(pollList.stream().map(node -> node.no).collect(Collectors.toList()));

結果如下:

size: 10, start: 2, step: 3
[4, 7, 10, 3, 8, 2, 9, 6, 1, 5]

資料驗證

當 n = 10, k = 2, m = 3 時,節點移除的分解步驟如下:

完整節點:Node{no=1}, Node{no=2}, Node{no=3}, Node{no=4}, Node{no=5}, Node{no=6}, Node{no=7}, Node{no=8}, Node{no=9}, Node{no=10}

4 出圈:Node{no=1}, Node{no=2}, Node{no=3}, Node{no=5}, Node{no=6}, Node{no=7}, Node{no=8}, Node{no=9}, Node{no=10}

7 出圈:Node{no=1}, Node{no=2}, Node{no=3}, Node{no=5}, Node{no=6}, Node{no=8}, Node{no=9}, Node{no=10}

10 出圈:Node{no=1}, Node{no=2}, Node{no=3}, Node{no=5}, Node{no=6}, Node{no=8}, Node{no=9}

3 出圈:Node{no=1}, Node{no=2}, Node{no=5}, Node{no=6}, Node{no=8}, Node{no=9}

8 出圈:Node{no=1}, Node{no=2}, Node{no=5}, Node{no=6}, Node{no=9}

2 出圈:Node{no=1}, Node{no=5}, Node{no=6}, Node{no=9}

9 出圈:Node{no=1}, Node{no=5}, Node{no=6}

6 出圈:Node{no=1}, Node{no=5}

1 出圈:Node{no=5}

5 出圈

出圈順序依次為: [4, 7, 10, 3, 8, 2, 9, 6, 1, 5]。與結果一致。