![255fc62142276daf212f8039df7bac8a.png](https://i-blog.csdnimg.cn/blog_migrate/6dbc6de410b26db81b7b2f9ad9ddc10d.jpeg)
干货预警:所有文章都会首发于我的公众号【甜姨的奇妙冒险】,欢迎watch。
![3df5cca33722c13c92453a55ecd36911.png](https://i-blog.csdnimg.cn/blog_migrate/a7f1784c18b6ab70a06f4235adcea322.jpeg)
这个问题是以弗拉维奥·约瑟夫命名的,他是1世纪的一名犹太历史学家。他在自己的日记中写道,他和他的40个战友被罗马军队包围在洞中。他们讨论是自杀还是被俘,最终决定自杀,并以抽签的方式决定谁杀掉谁。约瑟夫斯和另外一个人是最后两个留下的人。约瑟夫斯说服了那个人,他们将向罗马军队投降,不再自杀。约瑟夫斯把他的存活归因于运气或天意,他不知道是哪一个。——【约瑟夫问题】维基百科
我的原文链接:Java解决约瑟夫环问题,告诉你为什么模拟会超时!
一、原题描述
![95f5de980a97768b15797eed438a1ee3.png](https://i-blog.csdnimg.cn/blog_migrate/7102ce379b20cea8f6fdc030d8071676.jpeg)
二、链表模拟 O(N^2),告诉你为啥模拟会超时!
如果单纯用链表模拟的话,时间复杂度是 O(NM)(其中每次找到被删除的数字,需要O(M)的时间复杂度,一共需要删除 N-1 次)。可以看下本题的数据范围,1 <= m <= 10^6,所以O(NM)肯定是超时的。(关于运行时间的预估,经验是如果 N < 10^5, 那么 O(N^2) 的耗时大概是几秒左右。当然因为时间复杂度会忽略常数,而且执行程序的机器性能也不同,因此实际上 O(N^2) 的实际耗时可能1秒多,也可能十几秒)
实际上不需要O(M)去找下一个被删除的数字,我们可以直接求得下一个被删除的索引位置!
假设当前删除的位置是 idx,下一个删除的数字的位置就是 idx + m。但是,由于把当前位置的数字删除了,后面的数字会前移一位,所以实际上下一个删除的位置是 idx + m - 1。由于数到末尾会从头重新数,这里可以通过取模来完美解决,假设当前删除的位置是 idx,那么下一个被删除的位置就是 (idx + m - 1)(mod n) 。
基于上述思路,我分别用 LinkedList 和 ArrayList 进行实现。其中,LinkedList 会超时,因为 LinkedList 虽然删除节点的时间复杂度是 O(1),但是因为需要从头遍历到需要删除的位置,因此单次删除的时间复杂度仍然是 O(N);那 ArrayList 呢?索引到指定删除位置是 O(1),但因为删除当前位置的元素后,后续位置的元素均需要向前移位,因此单次删除的时间复杂度也是O(N)。
看起来 LinkedList 和 ArrayList 单次删除操作的时间复杂度是一样的?所累哇多卡纳!
ArrayList 的删除操作在后续移位的时候,其实是内存连续空间的拷贝,对于CPU缓存友好,所以相比于 LinkedList 找到指定索引时的大量非连续地址访问,ArrayList的性能是好一些的!
以下是 ArrayList 的 remove 源码(JDK 8):
public
单次删除的时间复杂度是 O(N), 一共删除了 n - 1次,所以整体时间复杂度是O(N^2)。Leetcode上该方法勉强可以通过,大概是1秒多一点。
上述基于 ArrayList 模拟的AC代码如下:
class
三、数学解法 O(N)
这么著名的约瑟夫环问题,是有数学解法的!
因为数据是放在数组里,所以我在数组后面加上了数组的复制,以体现是环状的。我们先忽略图片里的箭头:
![efceaa119d69edb49c069c22ed34b427.png](https://i-blog.csdnimg.cn/blog_migrate/723d1750f5db6570f407bbe35da7c625.jpeg)
很明显我们每次删除的是第m个数字,我都标红了。
第一轮是 [0, 1, 2, 3, 4] ,所以是 [0, 1, 2, 3, 4] 这个数组的多个复制。这一轮 2 删除了。
第二轮开始时,从 3 开始,所以是 [3, 4, 0, 1] 这个数组的多个复制。这一轮 0 删除了。
第三轮开始时,从 1 开始,所以是 [1, 3, 4] 这个数组的多个复制。这一轮 4 删除了。
第四轮开始时,还是从 1 开始, 所以是 [1, 3] 这个数组的多个复制。这一轮 1 删除了。
最后剩下的数字是 3 。
图中的绿色的线指的是新的一轮的开头是怎么指定的,每次都是固定地向前移动m个位置。
最后剩下的 3 的下标是 0 。
第四轮反推,补上m个位置,然后模上当时的数组大小 2, 位置是 (0 + 3 ) % 2 = 1 。
第三轮反推,补上m个位置,然后模上当时的数组大小 3, 位置是 (1 + 3) % 3 = 1 。
第二轮反推,补上m个位置,然后模上当时的数组大小 4, 位置是 (1 + 3) % 4 = 0 。
第一轮反推,补上m个位置,然后模上当时的数组大小 5, 位置是 (0 + 3) % 5 = 3 。
所以最终剩下的数字的下标就是 3 。因为数组是从0开始的,所以最终答案就是 3 。
总结一下反推的过程,就是 (当前index + m) % 上一轮剩余数字的个数 。
代码就很简单了
class
锵锵锵![✅] 每日营业1/1
我的多篇题解被Leetcode官网评为“精选题解”
大佬们随手扫一下文章上方二维码关注我的公众号鸭 (●˙ε˙●)