一、引言
原题:
剑指 Offer 62. 圆圈中最后剩下的数字 :0,1,n-1这n个数字排成一个圆圈,从数字0开始,每次从这个圆圈里删除第m个数字。求出这个圆圈里剩下的最后一个数字。
思路:
每次找到下一个要删除的位置,假设当前删除的位置是 index,下一个删除的数字的位置是 index+ m - 1 。由于数到末尾会从头继续数,所以最后取模一下,就是 (index+ m - 1)%n。
public int LastRemaining_Solution(int n, int m) {
if(n<1||m<0){
return -1;
}
LinkedList<Integer> list=new LinkedList<>();
for(int i=0;i<n;i++){
list.add(i);
}
int index=0;
while(list.size()>1){
index=(index+m-1)%list.size();
list.remove(index);
}
return list.get(0);
}
这段代码我在牛客网上运行是没有问题的,但是!在leetcode上运行,是会超时的。
二、分析
单看自己写的代码,看上去时间复杂度是O(n),其中list.add(i)的时间复杂度肯定是O(1),要是超时的话,问题应该就出在list.remove(index);
这时,你应该想到了那个我们很熟悉的性质
- 对于随机访问的get和set方法,ArrayList要优于LinkedList,因为LinkedList要移动指针。
- 对于新增和删除操作add和remove,LinkedList比较占优势,因为ArrayList要移动数据。
那…这不是矛盾了吗…
所以不如 去看看源码吧!
首先是看看 LinkedList
LinkedList删除指定节点的代码如下,可以看出时间复杂度确实是O(1)的
public E remove(int index) {
checkElementIndex(index);
return unlink(node(index));
}
/**
* Unlinks non-null node x.
*/
E unlink(Node<E> x) {
// assert x != null;
final E element = x.item;
final Node<E> next = x.next;
final Node<E> prev = x.prev;
if (prev == null) {
first = next;
} else {
prev.next = next;
x.prev = null;
}
if (next == null) {
last = prev;
} else {
next.prev = prev;
x.next = null;
}
x.item = null;
size--;
modCount++;
return element;
}
但是remove 时间复杂度仍然是 O(n)的!因为需要从头遍历到需要删除的位置。
注意unlink(node(index)),node(index)就是找到需要删除的位置,代码如下:
/**
* Returns the (non-null) Node at the specified element index.
*/
Node<E> node(int index) {
// assert isElementIndex(index);
if (index < (size >> 1)) {
Node<E> x = first;
for (int i = 0; i < index; i++)
x = x.next;
return x;
} else {
Node<E> x = last;
for (int i = size - 1; i > index; i--)
x = x.prev;
return x;
}
}
那 ArrayList 呢?
public E remove(int index) {
rangeCheck(index);
modCount++;
E oldValue = elementData(index);
int numMoved = size - index - 1;
if (numMoved > 0)
System.arraycopy(elementData, index+1, elementData, index,
numMoved);
elementData[--size] = null; // clear to let GC do its work
return oldValue;
}
索引到需要删除的位置,时间复杂度是 O(1),删除元素时间复杂度是 O(n)(因为后续元素需要向前移位), remove 整体时间复杂度是 O(n) 的。
看起来LinkedList 和 ArrayList 单次删除操作的时间复杂度是一样的 ?
但是看代码我们会发现,ArrayList 的 remove 操作在后续移位的时候,其实是内存连续空间的拷贝的!所以相比于LinkedList大量非连续性地址访问,ArrayList的性能是OK 的!
三、优化
所以代码应该改为:
public int LastRemaining_Solution(int n, int m) {
if(n<1||m<0){
return -1;
}
ArrayList<Integer> list=new ArrayList<>();
for(int i=0;i<n;i++){
list.add(i);
}
int index=0;
while(list.size()>1){
index=(index+m-1)%list.size();
list.remove(index);
}
return list.get(0);
}
每次删除的时间复杂度是 O(n),删除了 n-1 次,所以整体时间复杂度是 O(n^2)。leetcode 上该方法勉强可以通过,大概是 1s 多一点。
数学解法
当然了,著名的约瑟夫环问题,是有数学解法的!时间复杂度为O(n)。
可以通过举例来理解:
[0, 1, 2, 3, 4],每次删除第3个数字;即n=5,m=3
第一轮是 [0, 1, 2, 3, 4] ,这一轮 2 删除了。
第二轮开始时,从 3 开始,所以是 [3, 4, 0, 1],这一轮 0 删除了。
第三轮开始时,从 1 开始,所以是 [1, 3, 4] 这个数组,这一轮 4 删除了。
第四轮开始时,还是从 1 开始,所以是 [1, 3] 这个数组,这一轮 1 删除了。
最后剩下的数字是 3。
可以发现新的一轮的开头的位置是有规律的:
- 第二轮开头数字3在第一轮的位置是3,向前移动了3;
- 第三轮开头数字1在第二轮的位置是3,向前移动了3;
- 第四轮开头数字1在第三轮的位置是0,但是可以看成是循环链表,那么第三轮就是134134,数字1的位置是3,还是向前移动了3;
- 同样的,最后一轮开头数字3在第四轮的位置是1,但是可以看出是循环链表,那么第四轮就是1313,数字3的位置是3,还是向前移动了3;
所以新一轮的开头其实每次都是固定地向前移位 m 个位置。我们可以从最后剩下的 3 倒着看,我们可以反向推出这个数字在之前每个轮次的位置。
- 最后剩下的 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) % 上一轮剩余数字的个数。
public int lastRemaining(int n, int m) {
int ans = 0;
// 最后一轮剩下2个人,所以从2开始反推
for (int i = 2; i <= n; i++) {
ans = (ans + m) % i;
}
return ans;
}

本文探讨了剑指Offer62题目中圆圈中最后剩下的数字问题,对比了LinkedList和ArrayList在解决此问题时的性能差异,给出了数学解法,并分析了其时间复杂度。
2099

被折叠的 条评论
为什么被折叠?



