文中的速度测试部分,时间是经过简单的 System.currentTimeMillis() 计算获得的, 又因为 Java 的特性,每次测试的结果都不必定相同, 对于低数量级的状况有 ± 20 的浮动,对于高数量级的状况有的能有 ± 1000 的浮动。 这道题本质上是个约瑟夫环问题,最佳解法在最下面,本文只是探究一下数组暴力和链表的表现差别。
N 我的围成一圈,顺序排号。从第一我的开始报数(从1数到3),凡是到3的人退出圈子,问最后留下的是原来第几号。java
CPU Intel Xeon E3-1231 v3 @ 3.40GHz算法
RAM 16 GB数组
虽然第一反应是用链表,但对于人数在1000如下的量级感受数组也足以胜任,所以先用数组试试。多线程
对于这种会 退出 的状况,数组显然不能像链表同样直接断开,所以采用标记法:app
先生成一个长度为 N 的布尔型数组,用 true
填充。测试
报号时,对于报到 3 的位置,用 false
来标记该位置,下次循环若是遇到 false
则能够直接跳过。优化
那么等到数组内只剩一个 true
的时候,找到其位置,便是最后留下来的人的位置。this
既然暴力,那干脆完全一点:线程
public static int findIndex(final int N) { boolean[] map = new boolean[N]; Arrays.fill(map, true); int walk = 1; // 由于是站成一个圆,因此在遍历到最后时须要将下标从新指向 0 // count(map) 就是遍历整个数组计算还剩余的 true 的数量 for (int index = 0; count(map) > 1; index = (index == N - 1) ? 0 : (index + 1)) { // 对于 false 能够直接跳过,由于它们至关于不存在 if (! map[index]) continue; // 报号时若是不是3 则继续找下一位; if (walk++ != 3) continue; // 若是是 3,则重置报号,并将当前位置的值改成 false walk = 1; map[index] = false; } return find(map); } // 由于是 count(map) == 1 的状况下才会调用这个方法,因此直接返回第一个 true 所在的位置便可 public static int find(boolean[] map) { for (int i = 0; i < map.length; i++) { if (!map[i]) continue; return i + 1; } return -1; } public static int count(boolean[] map) { int count = 0; for (boolean bool : map) { count += bool ? 1 : 0; } return count; };
对于这个解法,能够跑一下测试看看耗时:code
N | time / ms |
---|---|
100 | 1 |
1,000 | 13 |
10,000 | 686 |
100,000 | 80554 |
很显然,这种暴力的作法对于大一点的数量级就很吃力了,可是我又不想那么快就用链表,有没有哪里是能够优化的呢。
其实在前面的解法中,耗时操做有这么几个:
findIndex
中不停得对整个 map
进行遍历,即使对于 false
直接跳过,但杯水车薪。count
中对整个 map
进行遍历才能获得此时数组中 true
的数量。find
中一样须要对整个 map
进行遍历才能获得剩下的一个 true
的下标。其中第一点应该是这种解法的本质,没什么好办法,那么看看后两点。
count
这个方法想作的事就是每次循环时检查此时数组中 true
的数量是否是只剩一个了,由于这是循环的终结条件。
那么咱们能够引入一个计数器:
private static int findIndex(final int N) { boolean[] map = new boolean[N]; Arrays.fill(map, true); int walk = 1; int countDown = N; for (int index = 0; countDown > 1; index = (index == N - 1) ? 0 : (index + 1)) { if (! map[index]) continue; if (walk++ != 3) continue; walk = 1; map[index] = false; countDown -= 1; } return find(map); }
改为这种作法后,猜猜对于 100,000 这个数量级,这个暴力算法须要用时多久呢?
答案是 11 ms 。
对于 100,000,000 这个数量级,这个暴力算法仍只须要 3165 ms。
稍稍透露一下,后边的链表解法在这个数量级的成绩是 7738 ms,固然多是我太垃圾了,发挥不出链表的威力 Orz)
find
这个方法要作的是从整个数组中找到惟一的 true
的下标,这一样能够用一个外部变量来消除循环:
private static int findIndex(final int N) { boolean[] map = new boolean[N]; Arrays.fill(map, true); int walk = 1; // 记录如今访问到值为 true 的下标 int current = 0; int countDown = N; for (int index = 0; countDown > 1; index = (index == N - 1) ? 0 : (index + 1)) { if (! map[index]) continue; if (walk++ != 3) { // 记录最后一次遇到 true 的位置 current = index; continue; } walk = 1; map[index] = false; countDown -= 1; } // 人的位置是从 1 开始数的,因此这里要加 1 return current + 1; }
可是这个改动对速度的提高效果很小,对于 100,000,000 这个数量级,速度仍然在 3158 ~ 3191 ms 左右。
使用链表能够很方便得体现 退出 这个概念,链表的长度会随着算法的进行而愈来愈短直至剩下最后一个元素。由于没有 跳过标记为 false
的步骤,理论上会比暴力数组解法要快。
static class Node { // 当前节点的下标,即人的位置 int index; // 上一个节点 Node prev; // 下一个节点 Node next; public Node (int index) { this.index = index; } public Node append(Node next) { this.next = next; next.prev = this; return next; } // 须要报号为3的人(当前元素)退出时,从链表中断开并将两边拼接起来 public Node jump() { Node newNode = this.next; newNode.prev = this.prev; newNode.prev.next = newNode; this.prev = null; this.next = null; return newNode; } public static int findIndex(final int N) { Node root = new Node(1); // 初始化链表并赋值,这个过程对于很大的数量级而言速度确定是慢过对数组的赋值的, // 毕竟类的实例化须要开销。所以这段初始化不计入时间 Node current = root; for (int i = 2; i <= N; i++) { current = current.append(new Node(i)); } // 将首尾相连构成循环列表 current = current.append(root); long mills = System.currentTimeMillis(); int COUNTER = N; int walk = 1; while (COUNTER > 1) { if (walk++ != 3) { current = current.next; } else { current = current.jump(); walk = 1; COUNTER -= 1; } } System.out.println(System.currentTimeMillis() - mills); return current.index; } }
N | 数组暴力法 / ms | 数组暴力法(改进) / ms | 链表法 / ms |
---|---|---|---|
100 | 2 | 0 | 0 |
1,000 | 15 | 1 | 0 |
10,000 | 673 | 5 | 1 |
100,000 | 79998 | 10 | 3 |
1,000,000 | N/A | 38 | 64 |
10,000,000 | N/A | 309 | 718 |
100,000,000 | N/A | 3151 | 7738 |
对于 1,000,000 及以上的数量级就没测原数组暴力法了,太慢了...
能够看到,在百万级别,改进的数组暴力法已经要比链表法快一半了,在亿级要快的更多。
固然这个速度差别很大程度上是由于随着数量级的加大,链表法所须要的内存开销已经超出一个合理的范围了,随之而来的就是链表的断开重组操做要比 标记 重太多了。
可是这只是 想知道最后一我的的位置 的状况,数组的下标能够作到必定程度的契合,若是状况更复杂了,显然数组就不够用了。
对于链表法在超大数量级的解法,感受能够用多线程来作一次总体循环内的截断,只是这样复杂度就上去了,暂时不作了,有兴趣的读者能够自行尝试一下。
public static int josephus(int n) { int res = 0; if (n == 0) return 0; if (n < 3) { for (int i = 2; i <= n; i++) { res = (res + 3) % i; } } else { res = josephus(n - n / 3); if (res < (n % 3)) { res = res - (n % 3) + n; } else { res = res - (n % 3) + (res - (n % 3)) / 2; } } return res; } public static void main(String ...args) { System.out.println(hosephus(1000000000)); }
这个解法对于一亿这个数量级的运算时间是不到 0 ms,来自个人 ACMer 同窗 ( 打不过正规军啊,跪了
据我同窗所说:
递归层数 log 级别,n 能够达到 1e18 级别,15 ms 内给出答案。