约瑟夫环 超时问题

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

一、引言

原题:
剑指 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);

这时,你应该想到了那个我们很熟悉的性质

  1. 对于随机访问的get和set方法,ArrayList要优于LinkedList,因为LinkedList要移动指针。
  2. 对于新增和删除操作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;
    }
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值