【算法-剑指 Offer】62. 圆圈中最后剩下的数字(环形链表;约瑟夫环;动态规划)

剑指 Offer 62. 圆圈中最后剩下的数字 - 力扣(LeetCode)

发布:2021年9月12日12:18:52

问题描述及示例

0,1,···,n-1这n个数字排成一个圆圈,从数字0开始,每次从这个圆圈里删除第m个数字(删除后从下一个数字开始计数)。求出这个圆圈里剩下的最后一个数字。

例如,0、1、2、3、4这5个数字组成一个圆圈,从数字0开始每次删除第3个数字,则删除的前4个数字依次是2、0、4、1,因此最后剩下的数字是3。

来源:力扣(LeetCode)
链接:https://leetcode-cn.com/problems/yuan-quan-zhong-zui-hou-sheng-xia-de-shu-zi-lcof
著作权归领扣网络所有。商业转载请联系官方授权,非商业转载请注明出处。

示例 1:
输入: n = 5, m = 3
输出: 3

示例 2:
输入: n = 10, m = 17
输出: 2

提示:
1 <= n <= 10^5
1 <= m <= 10^6

我的题解

很明显可以看出题目的结构模型是一个环形结构,而提到环形结构,我马上想到的就是环形链表。而且题目中还涉及元素的删除,根据我做的上一道题(详见下方参考博客)时学习到的经验,我觉得此时用链表结构来存储元素是比较合适的。

参考:【算法-LeetCode】146. LRU 缓存机制(双向链表;Map;栈)_赖念安的博客-CSDN博客

更新:2021年9月12日21:47:03

我还是太年轻了,看到这道题是简单题就觉得没有那么多弯弯绕绕,没想到还是这么难顶。原来这个题目是一个著名的数学问题:约瑟夫环问题。感觉还是挺有意思的,但是实在是想不到那种不超时的解法……只能感叹数学和算法的美妙。

【更新结束】

我的题解1(环形链表—因超时而无法通过)

思路很简单,就是创建一个环形链表,链表中的每个节点都有相应的数字。然后根据题目中删除数字的规则逐步删除相应的节点,直到只剩下最后一个节点(即该节点的 next 域指向自己)。

总共有三个辅助函数:

  • ListNode(val):链表节点的构造函数。参数 val 用于指定该节点的节点值。
  • createList(num):创建一个单链表,参数 num 用于指定链表的长度。注意其返回值是链表的尾指针。
  • deleteNode(list, step):删除链表 list 中的某个节点,参数 step 是指定所有删除的目标节点离尾结点的步长(也即是题目中提到的 m)。注意其返回值是一个所要删除的节点前驱节点。
/**
 * @param {number} n
 * @param {number} m
 * @return {number}
 */
function ListNode(val) {
  this.val = val;
  this.next = null;
}

function createList(num) {
  // head是链表的头指针,其始终指向链表的头部
  let head = new ListNode(0);
  // rear是链表的尾指针,其始终指向链表的尾部
  let rear = head;
  // 下面的for循环用于创建一个长度为num的单链表,注意此时该链表还不是环形的
  for(let i = 1; i < num; i++) {
    rear.next = new ListNode(i);
    rear = rear.next;
  }
  // 让链表成环
  rear.next = head;
  // 注意程序最后返回的结果是链表的尾指针,这主要是为了方便应对下面的deleteNode
  return rear;
}

// 注意这里的步长step是从list的尾部开始算起的,因为这个deleteNode要返回的结果是
// 要删除的节点target的前驱节点,如果从头算起的话会不方便处理step为1的情况
function deleteNode(list, step) {
  // beforeTarget是要删除的节点的前驱节点
  let beforeTarget = list;
  // 开始定位beforeTarget
  while(step > 1) {
    beforeTarget = beforeTarget.next;
    step--;
  }
  // 通过改变beforeTarget的next域指向来达到删除目标节点的目的
  beforeTarget.next = beforeTarget.next.next;
  // 最后将beforeTarget返回,作为下次的尾节点以便后续操作
  return beforeTarget;
}

var lastRemaining = function(n, m) {
  // 首先创建环形链表
  let list = createList(n);
  // 开始根据删除规则逐个删除节点
  while(list.next !== list) {
    // 注意list尾指针的变化
    list = deleteNode(list, m);
  }
  // 如果链表中只剩下了一个节点,则将其节点值返回作为最终结果
  return list.val;
};

提交记录
26 / 36 个通过测试用例
状态:超出时间限制
时间:2021/09/12 12:22

在这里插入图片描述

最后输入的测试用例

把上面这个超时的测试用例放在Edge中运行,运行比较长一段时间后控制台输出如下结果:

在这里插入图片描述

在浏览器中的运行结果(环形链表)

我的题解2(数组—因超时而无法通过)

因为上面的链表思路超时了,没办法只能另寻他法。于是我想到用数组来存储那些数字,然后结合数组的 length 和取余的操作来进行对要删除的数字的定位。

首先是创建一个长度为 n 的一维数组,数组的元素值就是其下标值。用 start 来记录每次删除数字时的参考起点的下标,用 target 用来记录每次要删除的数字的下标。根据题目的规则找到合适的 starttarget 后,再从数组中删除目标数字,重复这个操作,直到数组中只剩下一个元素。

关键就在于如何找到每次的 starttarget 。这需要考虑到步长 m 、每次都会变的起点 start已经每次都会变的数组长度这几个关键因素。其实总结自己确定 starttarget 的过程,感觉这个过程有点像是找规律,就是在找一个通用的函数式来获取目标值。当时我画了许多手稿,感觉腰讲清楚还是有点麻烦的,因为针对一些情况总是要做判断,一开始下面的程序是有大量的 if else 语句的,后来用了三元运算符才显得稍微整洁一点。这里就不做具体解释了。

/**
 * @param {number} n
 * @param {number} m
 * @return {number}
 */

var lastRemaining = function (n, m) {
  let arr = Array.from({ length: n }).map(
    (current, index) => index
  );
  let start = 0;
  let target = 0;
  while (arr.length > 1) {
    // remainingSteps 是数组当前长度减去起点位置后还需要走几步才能到要删的数字所在位置
    let remainingSteps = m - (arr.length - start);
    if (remainingSteps > 0) {
      // 这里要注意剩余步数刚好是arr.length时的情况,详细请看下方【补充1】
      target = remainingSteps % arr.length ? remainingSteps % arr.length - 1 : arr.length - 1;
      start = remainingSteps % arr.length ? target : 0;
    } else {
      target = start + m - 1;
      start = remainingSteps === 0 ? 0 : target;
    }
    // 原来这里我是用delete和filter来实现的,后来直接用splice反而更快,详看下方【补充2】
    arr.splice(target, 1);
  }
  return arr[0];
};


提交记录
3 / 36 个通过测试用例
状态:超出时间限制
时间:202191215:44:13

补充1
本来以为这个能过,但是没想到这个也是因为超时而无法通过,我估计应该是 Array.filter() 函数内部实现的方式在遇到比较大的数据量时比较耗时间。而且比较奇怪的是,这次在浏览器中的运行结果和之前用链表实现的那个方法的运行结果还不一样,难道真的是我的思路错了?看到通过的测试用例数比上次都更少,那应该是我的实现方法错了。

在这里插入图片描述

在浏览器中的运行结果1(数组)

然后我又通过调试发现了一点小错误,改正之后在浏览器中运行的结果如下,在LeetCode中仍然为超时。

在这里插入图片描述

在浏览器中的运行结果2(数组)

相应的改动如下:

// 原先的写法
target = remainingSteps === arr.length ? arr.length - 1 : remainingSteps % arr.length - 1;
start = remainingSteps === arr.length ? 0 : target;

// 改正后的写法
target = remainingSteps % arr.length ? remainingSteps % arr.length - 1 : arr.length - 1;
start = remainingSteps % arr.length ? target : 0;

补充2

在这里插入图片描述

在浏览器中的运行结果3(数组)

在这里插入图片描述

在LeetCode中的提交结果(数组)

相应的改动如下:

// 原先的写法
delete (arr[target]);
arr = arr.filter(val => val !== undefined);

// 改善后的写法
arr.splice(target, 1);

改成直接用 splice() 删除数组中元素后,我发现在Edge浏览器中相较于之前还是能比较快输出结果的,但是在LeetCode上提交却发现仍然超时……难道又要回到链表?

我的题解3(数学公式推导/动态规划)

准确来说这并不是我的题解,而是参考了别人的思路写出来的,非常感谢这些博主的分享。

/**
 * @param {number} n
 * @param {number} m
 * @return {number}
 */

var lastRemaining = function (n, m) {
  let survivor = 0;
  for(let i = 1; i <= n; i++) {
    survivor = (survivor + m) % i;
  }
  return survivor;
};

提交记录
36 / 36 个通过测试用例
状态:通过
执行用时:80 ms, 在所有 JavaScript 提交中击败了54.44%的用户
内存消耗:37.5 MB, 在所有 JavaScript 提交中击败了90.76%的用户
时间:2021/09/12 19:24	

因为实在是没有其他思路了,所以去看了看别人的题解,期间看到一篇说这个问题的博客,感觉讲得还是很好的。

参考:约瑟夫环——公式法(递推公式)_再难也要坚持-CSDN博客_约瑟夫环公式

上面的博客中提到这个问题其实就是一个著名的数学问题:约瑟夫环。里面详细讲解了相关的推导过程,我就不再重复了,但是里面的那张表格配图似乎不是很完整,可能会稍微有点不好理解,所以我特意重新画了一张,算是补充吧。

在这里插入图片描述

配合上面提到的博客辅助观察

这种推导方法最后得到的递推形式非常像动态规划中的状态转移方程,所以也可以从动态规划的角度来理解这种方法。在LeetCode的题解区也有题友提出了这种想法,可以参考下面的题解:

更新:2021年9月12日21:36:44

参考:胎教毕业也能懂,动态规划求解约瑟夫环问题 - 圆圈中最后剩下的数字 - 力扣(LeetCode)

官方题解

更新:2021年7月29日18:43:21

因为我考虑到著作权归属问题,所以【官方题解】部分我不再粘贴具体的代码了,可到下方的链接中查看。

更新:2021年9月12日12:23:44

参考:圆圈中最后剩下的数字 - 圆圈中最后剩下的数字 - 力扣(LeetCode)

【更新结束】

有关参考

更新:2021年9月12日13:35:27
参考:Array.prototype.filter() - JavaScript | MDN
参考:Array.prototype.map() - JavaScript | MDN
参考:Array.prototype.forEach() - JavaScript | MDN
参考:Array.from() - JavaScript | MDN
参考:js数组中过滤掉false, null, 0, “”, undefined, and NaN值的方法_shelomi的专栏-CSDN博客
参考:javascript - js中数组的undefined为什么不会被遍历-PHP中文网问答
更新:2021年9月12日17:10:45
参考:约瑟夫环——公式法(递推公式)_再难也要坚持-CSDN博客_约瑟夫环公式
参考:胎教毕业也能懂,动态规划求解约瑟夫环问题 - 圆圈中最后剩下的数字 - 力扣(LeetCode)
参考:Java解决约瑟夫环问题,告诉你为什么模拟会超时! - 圆圈中最后剩下的数字 - 力扣(LeetCode)

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值