在一间房间总共有n个人,给定一个数k,然后按照如下规则去杀人:
最后剩余的人活命。
那么,给定了 n 和 k,最后活下来的人的编号是几?
思路一
根据问题描述,可以使用循环单链表模拟杀人过程:
根据上面分析循环单链表的操作过程,代码实现如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 | class Node(object): def __init__(self, value): self.value = value self.next = None def create_linkList(people_num): """创新循环单链表""" head = Node(1) pre = head for i in range(2, people_num+1): newNode = Node(i) pre.next = newNode pre = newNode pre.next = head return head people_num = 5 # 总人数 k = 2 #报k被杀 if k == 1: print("最后存活编号:" + str(people_num)) else: head = create_linkList(people_num) pre = None cur = head # 当前报数的人 while cur.next != cur: # #终止条件是节点的下一个节点指向本身,即只剩一个节点 for i in range(k-1): # 走到第k节点 pre = cur cur = cur.next print("杀掉:" + str(cur.value)) # 被删除节点编号 # 删除节点 pre.next = cur.next # 从被删除节点的下一个节点从新报数 cur.next = None cur = pre.next print("最后存活者编号是:" + str(cur.value)) |
这种方法的时间复杂度为:O(n*k),当人数量n很大,报的数k也很大时,并不适用。
思路二
递归思路,假设房间共有n = 10个人,初始编号为1,2,3,…10,设初始编号对应的编号位置为0, 1, 2, …9, 每次数到k = 3
的人杀死,求最后活下来的人的初始编号是几?
来看杀人过程:
约瑟夫问题递归思路求解过程
(表中红色为报数k=3的被杀死的人的编号,绿色为最后活下来的人的编号)
仔细观察表中每一轮初始编号的移动规律:
第二轮到第一轮的编号移动规律: (第二轮的编号x的编号位置 + k) % 10 ==> 第一轮编号x的编号位置
比如第二轮编号5的编号位置是1, (1 + 3) % 10 ==> 4, 得到第一轮编号5的的编号位置是4
进而得到第三轮到第二轮的编号移动规律:(第三轮编号x的编号位置 + k) % 9 ==> 第二轮编号x的编号位置
比如第三轮编号5的编号位置是7, (7 + 3) % 9 –> 1, 得到第二轮编号5的的编号位置是1
进而得到第N轮与第N-1轮的编号移动规律:(第N轮的编号x的编号位置 + k) % 第N-1轮总人数 ==> 第N-1轮编号x的编号位置
最后一轮存活着的编号x对应的编号位置一定是0, 那么根据以上规律,可以得到倒数第二轮编号x对应的编号位置,根据规律进一步可以得到倒数第三轮编号x对应的编号位置, 一直可以推导出第一轮编号x的对应编号位置,由第一轮编号x的对应编号位置+1
得到的便是最后存活的人的初始编号。
由上总结,当房间共有n个人,报数k杀死时,令f(n, k)表示最后存活着的编号位置,则有递归公式:
有了递推公式以后,代码实现如下:
1 2 3 4 5 6 7 8 9 | def josephus(n, k): if n == 1: return 0 else: return (josephus(n - 1, k) + k) % n n = 10 k = 3 print("最后存活者编号是:", josephus(n, k)+1) # 4 |
对思路二的优化
对递归思路的进一步优化,假设n非常大,而k又比较小,比如n=100, k=3, 被杀过程如下:
在上面杀人过程中,通过建立n/k的步长加快了杀人的速度,减少了算法时间。可以从下面这幅图中更加清晰的体会到:
约瑟夫问题递归思路求解过程优化
本来需要10轮的,现在只需要7轮,如果n=100,k=3的话优化效果会更明显。
根据以上分析,优化方法如下:
实现代码:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | import math def josephus(n, k): if n == 1: return 0 else: return (josephus(n - 1, k) + k) % n def kill_people(n, k): while math.floor(n/k) > 1: # 建立一个步长为n/k的递归过程; n = n - math.floor(n/k) kill_people(n, k) live_index = josephus(n, k) return live_index+1 n = 10 k = 3 print("最后存活者编号是", kill_people(n,k)) |
思路三
使用数组存储房间中的每个人: arr = [ i for i in range(1, 10+1) ]
arr数组代表房间里的10个人:[1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
每次被杀的人的编号: kill_num = (kill_num + k - 1) % len(arr)
。 其中的(k-1)对应数组的下标
有了被杀人的的编号后,将其pop出数组。
然后再次计算下一个被杀人的编号,直到数组中只剩一个人。
代码实现:
1 2 3 4 5 6 7 8 9 10 11 12 | def josephus(n, k): arr = [ i for i in range(1, n+1) ] kill_num = 0 while len(arr) != 1: kill_num = (kill_num + k - 1) % len(arr) print("杀死:" + str(arr.pop(kill_num))) return arr[0] n = 10 k = 3 print("最后存活者编号是:", josephus(n, k)) # 4 |
对思路三的优化
在思路三中需要构建一个数组,也可以不用数组来减少内存。使用动态规划来解:
1 2 3 4 5 6 7 8 9 | def Josephus(n, k): kill_num = 0 for i in range(1, n+1): kill_num = (k + kill_num) % i return kill_num + 1 n = 5 k = 2 print("最后存活者编号:", Josephus(n, k)) |
最后这个动态规划的方法来自:https://www.quora.com/What-is-the-best-solution-for-Josephus-problem-algorithm
约瑟夫问题是个著名的问题:N个人围成一圈,第一个人从1开始报数,报M的将被杀掉,下一个人接着从1开始报。如此反复,最后剩下一个,求最后的胜利者。
例如只有三个人,把他们叫做A、B、C,他们围成一圈,从A开始报数,假设报2的人被杀掉。
刚学数据结构的时候,我们可能用链表的方法去模拟这个过程,N个人看作是N个链表节点,节点1指向节点2,节点2指向节点3,……,节点N-1指向节点N,节点N指向节点1,这样就形成了一个环。然后从节点1开始1、2、3……往下报数,每报到M,就把那个节点从环上删除。下一个节点接着从1开始报数。最终链表仅剩一个节点。它就是最终的胜利者。
要模拟整个游戏过程,时间复杂度高达O(nm),当n,m非常大(例如上百万,上千万)的时候,几乎是没有办法在短时间内出结果的。
约瑟夫环是一个经典的数学问题,我们不难发现这样的依次报数,似乎有规律可循。为了方便导出递推式,我们重新定义一下题目。
问题: N个人编号为1,2,……,N,依次报数,每报到M时,杀掉那个人,求最后胜利者的编号。
这边我们先把结论抛出了。之后带领大家一步一步的理解这个公式是什么来的。
递推公式:
下面我们不用字母表示每一个人,而用数字。
1、2、3、4、5、6、7、8、9、10、111、2、3、4、5、6、7、8、9、10、11
下图表示这一过程(先忽视绿色的一行)
现在再来看我们递推公式是怎么得到的!
将上面表格的每一行看成数组,这个公式描述的是:幸存者在这一轮的下标位置
很神奇吧!现在你还怀疑这个公式的正确性吗?上面这个例子验证了这个递推公式的确可以计算出胜利者的下标,下面将讲解怎么推导这个公式。
问题1:假设我们已经知道11个人时,胜利者的下标位置为6。那下一轮10个人时,胜利者的下标位置为多少?
答:其实吧,第一轮删掉编号为3的人后,之后的人都往前面移动了3位,胜利这也往前移动了3位,所以他的下标位置由6变成3。
问题2:假设我们已经知道10个人时,胜利者的下标位置为3。那下一轮11个人时,胜利者的下标位置为多少?
答:这可以看错是上一个问题的逆过程,大家都往后移动3位,所以f(11,3)=f(10,3)+3f(11,3)=f(10,3)+3。不过有可能数组会越界,所以最后模上当前人数的个数,f(11,3)=(f(10,3)+3)%11f(11,3)=(f(10,3)+3)%11
问题3:现在改为人数改为N,报到M时,把那个人杀掉,那么数组是怎么移动的?
答:每杀掉一个人,下一个人成为头,相当于把数组向前移动M位。若已知N-1个人时,胜利者的下标位置位f(N−1,M)f(N−1,M),则N个人的时候,就是往后移动M为,(因为有可能数组越界,超过的部分会被接到头上,所以还要模N),既f(N,M)=(f(N−1,M)+M)%nf(N,M)=(f(N−1,M)+M)%n
注:理解这个递推式的核心在于关注胜利者的下标位置是怎么变的。每杀掉一个人,其实就是把这个数组向前移动了M位。然后逆过来,就可以得到这个递推式。
因为求出的结果是数组中的下标,最终的编号还要加1
下面给出代码实现:
int cir(int n,int m) { int p=0; for(int i=2;i<=n;i++) { p=(p+m)%i; } return p+1; }---------------------