資料結構與演算法之約瑟夫問題
約瑟夫問題描述的是什麼?
約瑟夫問題:有 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]。與結果一致。