题目链接:https://leetcode.cn/problems/yuan-quan-zhong-zui-hou-sheng-xia-de-shu-zi-lcof/
1. 题目介绍(62. 圆圈中最后剩下的数字)
0,1,···,n-1
这n个数字排成一个圆圈,从数字0开始,每次从这个圆圈里删除第m个数字(删除后从下一个数字开始计数)。求出这个圆圈里剩下的最后一个数字。
例如,
0、1、2、3、4
这5个数字组成一个圆圈,从数字0开始每次删除第3个数字,则删除的前4个数字依次是2、0、4、1,因此最后剩下的数字是3。
【测试用例】:
【条件约束】:
2. 题解
2.1 环形链表(经典解法)-- O(mn)
时间复杂度 O(mn),空间复杂度 O(n)
【解题思路】:
经典解法:用环形链表模拟圆圈;
- 我们可以创建一个共有 n 个节点的环形链表,然后每次在这个链表中删除第 m 个节点
……
这样做的优点就在于:
- 循环链表的向下枚举不需要考虑头尾问题,直接
node=node.next
向下;- 循环聊表的删除也不需要考虑头尾问题,直接
node.next=node.next.next
删除。
……
Note:当链表结构中不存在前驱节点时,且需要删除某一节点,我们尽可能的提前遍历到要删除节点的前一节点,就通过node.next=node.next.next 来实现删除,而不是等遍历到删除节点,再考虑删除的问题,因此此时没有前驱节点,你将很难确定你的前驱节点是谁,除非提前创建临时节点将当前节点的前驱记录了下来。
…………
但使用循环链表仍有很多问题:
- 比如说,该方法每次删除一个数字都需要 m 步的运算,一旦碰见 m 远大于 n 的情况,就可能存在在环形链表中重复遍历很多遍的情况,极大地影响了时间复杂度。
……
【实现策略】:
- 创建头节点
head
,并为其赋值为0;- 创建一个链表
team
,通过 for 循环对链表进行初始化;- 让链表的尾节点的后继指向头节点,使链表形成环;
- 定义索引变量
index
,用来指代当前走了多少步,如果走到了m-1步,就开始删除 m步 所处位置的节点;- 删除结束后,返回剩下节点的值。
class Solution {
// 定义链表结构
public class ListNode {
int val;
ListNode next;
ListNode(int x) { val = x; }
}
// Solution1:环形链表
public int lastRemaining(int n, int m) {
if(m == 1) return n-1; // 一次一个直接返回最后一个即可
ListNode head = new ListNode(0);
ListNode team = head; // 创建一个链表
for(int i = 1; i < n; i++)
{
team.next = new ListNode(i);
team = team.next;
}
team.next = head; //使形成环
int index = 1; //从1开始计数
while (head.next != head) { // 当剩余节点不止一个的时候
// 如果index = m-1 那就说明下个节点(m)该删除了
// 因为如果遍历到要删除的节点再删除, 此时由于没有前驱节点,我们无法确定当前节点的上一节点是谁?
// 一般的办法是定义一个临时遍历pre, 提前存储当前节点的上一节点
// 但此处直接提前判断, 在要删除的上一节点就执行删除操作,即: 将要删除节点的前驱的后继指向要删除节点的后继
if(index == m-1)
{
head.next = head.next.next;
// 删除之后, 让index归位, 方便后面重新计算
index = 1;
}
else {
index++;
}
head = head.next;
}
return head.val;
}
}
2.2 模拟链表 + 求余 – O(n2) ⭐
时间复杂度O(n2),空间复杂度O(n)
【解题思路】:
环形链表中使用链表直接模拟游戏全过程造成了非常严重的超时,n个数字,数到第m个出列,m如果非常大,远大于n,那么将产生很多次重复的转圈。
那么在本题解中,就要来解决这个重复转圈的问题:
- 首先,很多时候我们完全不需要手动去写一个链表结构,而是可以通过
ArrayList
和LinkedList
去模拟一个链表;如果使用LinkedList其底层也是链表,使用ArrayList的话其底层数据结构是数组,此处,我们选择了ArrayList来实现链表的模拟;- 其次,由上图我们可以推理出一个公式,
index=(index+m-1)%(list.size())
,根据此公式,我们可以直接确定下一次要删除的元素的具体位置,从而避开重复的转圈遍历。
……
Note:关于删除元素的位置公式,不理解的可以根据上图,自己走一遍流程。在本题中,数字和下标都是从 0 开始起算,所以第 m 步 走到的是 列表中第 m-1 个元素 (m < n 的情况下);而当我们每次找到并删除一个元素时,由于该元素已被删除,它的下标就自动被下一元素所继承,那还是相当于我们只需要走 m-1 步就到了下一个需要删除的元素的位置;同时,我们结合环形链表的特质,通过 位置 与 列表长度 的求余,就可以去掉多余的重复循环,直接找到 要删除元素在列表中的位置。
…………
【实现策略】:
- 创建列表
list
,用来模拟链表;- 初始化列表,将 0 ~ n-1的元素全部加入到列表中;
- 循环根据位置公式
idx = (idx+m-1) % list.size()
删除元素,直至列表中剩下最后一个元素;- 最后返回结果。
class Solution {
// Solution2:模拟链表
public int lastRemaining(int n, int m) {
if(m == 1) return n-1; // 一次一个直接返回最后一个即可
List<Integer> list = new ArrayList<>(); // 创建列表,用来模拟链表
for (int i = 0; i < n; i++) { // 初始化列表
list.add(i);
}
int idx = 0;
while (list.size() > 1) { // 循环删除元素,直至剩下最后一个
idx = (idx+m-1) % list.size(); // 根据公式,我们可以找到每次要删除的元素的位置
list.remove(idx);
}
return list.get(0);
}
}
2.3 数学分析 – O(n)
时间复杂度O(n),空间复杂度O(1)
【解题思路】:
说实话数学题解法,确实又快又好,但难的是怎么推出来,如果碰到其它问题,且之前没有接触过,一般人很难在很短的时间内就能得到正确的推理结果,所以这种数学解法看看,锻炼锻炼思维就好,不要求掌握。
……
推理公式:f(n,m)
指n个人,报第m个编号出列最终编号
以 n = 10,m = 9 为例:最后结果记为f(10,3)
那么其转化过程就为:
- f(10,3)=(f(9,3)+3)%10
- f(9,3)=(f(8,3)+3)%9
……- f(2,3)=(f(1,3)+3)%2
- f(1,3)=0
……
【实现策略】:
- 根据公式即可轻松写出代码;
class Solution {
// Solution3:数学分析
public int lastRemaining(int n, int m) {
int value=0;
for(int i=1;i<=n;i++)
{
value=(value+m)%i;
}
return value;
}
}
3. 参考资料
[1] 圆圈中最后剩下的数字(图解三种方法) – bigsai,该题解在循环链表这一方面讲的很清楚.
[2] Java解决约瑟夫环问题,告诉你为什么模拟会超时!-- Sweetiee🍬的小号,对数学分析题解进行了比较清晰的描述,值得参考.