代码改变世界

[数字技巧]重复数字统计算法的空间优化

2013-08-29 15:54  庸男勿扰  阅读(789)  评论(0编辑  收藏  举报

  今天在微博上看到的一道面试题,觉得非常有意思,特记录下来。

  原题是这样的:

      给定数组A,大小为n,数组元素为1到n的数字,不过有的数字出现了多次,有的数字没有出现。请给出算法和程序,统计哪些数字没有出现,哪些数字出现了多少次。能够在O(n)的时间复杂度,O(1)的空间复杂度要求下完成么?

  这道题目最大的难点就在于时空限制,确切的说是空间限制,如果没有空间复杂度为O(1)的要求,我们很容易想出用一个hash表来记录元素的出现次数。实现的代码如下:

 1 #include <stdio.h>
 2 #include <stdlib.h>
 3 #include <string.h>
 4 
 5 int a[1001];
 6 int b[1001]
 7 int n;
 8 void solve4()
 9 {
10      for(int i=1; i<=n; i++)
11      {
12           b[a[i]]++;
13      }
14      for(int i=1; i<=n; i++)
15           printf("%d\n",b[i]);
16 }
17 int main()
18 {
19      freopen("1.in","r",stdin);
20      freopen("1.out","w",stdout);
21      
22      scanf("%d",&n);
23      for(int i=1; i<=n; i++)
24      {
25           scanf("%d",&a[i]);
26      }
27      solve4();
28      
29      return 0;     
30 }
View Code

  上述方法采用了额外的空间记录元素出现次数,因而空间复杂度是O(n)的。这与题目要求不符。

  显然,这就要求我们要充分利用已有的空间即原数组空间来记录。Hash的方法是使用另外一个数组b,通过b[k]来表示k在数组a中出现的次数。那么,如果要在已有的数组上改,就要让a[k]能表示k在a中出现的次数。所以就有了第一种改进思路。

改进一:

  通过对原数组进行改造,使得数字k出现在以他为下标的位置上。如数组54131,经过改造后变成11345,然后再统计出现次数,为了与原数组中的数字区分开,使用负数统计,即0代表未出现过,-j代表出现j次。

 1 #include <cstdio>
 2 #include <cstring>
 3 #include <ctime>
 4 #include <assert.h>
 5 #include <algorithm>
 6 #include <cmath>
 7 using namespace std;
 8 
 9 int a[1001];
10 int n;
11 
12 void solve2()
13 {
14      for(int i = 1; i <=n; i++){
15           if(a[i]>0){//当前位置尚未统计过
16               int  t = a[i];//保存当前位置值并置0,说明i尚未出现
17               a[i] = 0;
18               while(a[t] > 0){//尚未统计t出现次数
19                    int temp = a[t];
20                    a[t] = -1;//t出现一次
21                    t = temp;//循环统计原来a[t]值在数组中出现的次数
22               }
23               a[t] --;//已统计过,-1即可
24           }
25      }
26      //绝对值为出现次数
27      for(int  i = 1; i <=n; i++){
28          a[i] = abs(a[i]);
29          printf("%d\n",a[i]);
30      }      
31 }
32 int main()
33 {
34      freopen("1.in","r",stdin);
35      freopen("1.out","w",stdout);
36      
37      scanf("%d",&n);
38      for(int i=1; i<=n; i++)
39      {
40           scanf("%d",&a[i]);
41      }
42      solve2();
43      
44      return 0;     
45 }
View Code

  微博上还有一种思路,是分类法,大致思路与上述改进一是类似的。顺序扫描数组,当a[i]>0即a[i]尚未被统计过时,根据a[i]与i的大小分类讨论。

  1、a[i] = i:说明i出现在正确位置上,且是第一次出现,直接将a[i]置为-1,代表出现过一次;

  2、a[i] < i:说明a[i]出现在他应该出现的位置后面,“小数在后”,由于是顺序扫描,他应该出现的那个位置肯定已经被统计过,直接-1即可,即a[a[i]] -= 1;

  3、a[i] > i:说明a[i]出现在他应该出现的位置前面,“大数在前”,此时不能直接像2中那样修改,因为,大数应该出现的位置可能没有统计过,此时要再次分类讨论:

    3.1、当a[a[i]] == a[i],即a[i]出现了两次,此时可以直接将a[a[i]] = -2;因为在当前位置之前,a[i]这个数肯定未出现过,不然已经被当做情况3讨论过,a[a[i]]应该就是负数了(代表a[i]出现的次数);

    3.2、当a[a[i]] < 0,即a[i]已经被考察过了,直接-1,即a[a[i]] -= 1;

    3.3、其余情况指,a[a[i]]是另一个尚未统计过的数,此时将a[i]换到正确的位置a[a[i]],继续考察a[i]。

代码如下:

 1 #include <cstdio>
 2 #include <cstring>
 3 #include <ctime>
 4 #include <assert.h>
 5 #include <algorithm>
 6 #include <cmath>
 7 using namespace std;
 8 
 9 int a[1001];
10 int n;
11 void solve5()
12 {
13       for (int i = 1; i <= n; ++i) {
14         if (a[i] <= 0) continue;//尚未被统计过的 
15         bool succ = false;
16         while (succ == false) {//尚未被统计过 
17             if (a[i] == i) { 
18                 a[i] = -1;//第一次出现 
19                 succ = true;
20             } else if (a[i] < i) {//a[i]出现在他应该在的位置的后面
21                 a[a[i]] -= 1;//前面肯定已经统计过 ,直接修改即可 
22                 a[i] = 0;
23                 succ = true;
24             } else if (a[i] > i) {//a[i]出现在他应该出现的位置的前面,不能随意修改,会覆盖尚未统计的数 
25                 if (a[a[i]] == a[i]) {//他应该出现的位置的数也是他本身 
26                     a[a[i]] = -2;//计数出现2次 
27                     a[i] = 0;
28                     succ = true;
29                 }else if (a[a[i]] < 0) {//该数已经统计过 
30                     a[a[i]] -= 1;
31                     a[i] = 0;
32                     succ = true;
33                 }else {
34                     swap(a[i], a[a[i]]);//将a[i]换到他应该出现的位置上,继续考察当前位置上的数 
35                 }
36             }
37         }
38     }
39     //绝对值 
40     for (int i = 1; i <= n; ++i) {
41         printf("%d\n", abs(a[i]));
42     }
43 }
44 int main()
45 {
46      freopen("1.in","r",stdin);
47      freopen("1.out","w",stdout);
48      
49      scanf("%d",&n);
50      for(int i=1; i<=n; i++)
51      {
52           scanf("%d",&a[i]);
53      }
54      solve5();
55      
56      return 0;     
57 }
View Code

 

改进二:

  改进二技巧性比较强,通过对数组进行三次处理。假定数组从1~n。

  步骤一:a[i]=a[i]*(n+1)

  步骤二:a[a[i]/(n+1)]++

  步骤三:输出a[i]%(n+1)即为则依次为i在数组出现的次数。

代码如下:  

 1 #include <cstdio>
 2 #include <cstring>
 3 #include <ctime>
 4 #include <assert.h>
 5 #include <algorithm>
 6 #include <cmath>
 7 using namespace std;
 8 
 9 int a[1001];
10 int n;
11 
12 void solve1()
13 {
14       int i;
15      for(i=1; i<=n; i++)
16      {
17           a[i] = a[i]*(n+1);
18      }
19      for(i=1; i<=n; i++)
20      {
21           a[a[i]/(n+1)]++;
22      }
23      for(i=1; i<=n; i++)
24      {
25           printf("%d\n",a[i]%(n+1));
26      }
27                  
28 }
29 int main()
30 {
31      freopen("1.in","r",stdin);
32      freopen("1.out","w",stdout);
33      
34      scanf("%d",&n);
35      for(int i=1; i<=n; i++)
36      {
37           scanf("%d",&a[i]);
38      }
39      solve1();
40      
41      return 0;     
42 }
View Code

   这个算法的出发点肯定也是要利用a[i]来表示i在数组中出现的次数,更形式化一点就是使用a[a[i]]来表示数组中每个数出现的次数,每出现一次就++即可。也就是说原a[i]的"增量"就是i出现的次数。要想得到增量,显然的做法是取余。这是为什么步骤一和步骤二要先乘后除的原因。如果不做这样的操作(或者认为乘除是1),那么取余就都为0了。

  搞清楚这一初衷,我们不难理解为什么用来乘除取余的数k是n+1,而不是n或者更小。

  原因1:只有k足够大(即大于n)时,在步骤1乘了k之后,步骤二又除以才会还在原来的位置,而不会因为a[i]多次加1改变a[i]/k的值;

  原因2:当k为n或者更小时,有可能存在某一个数,他出现的次数也是k,那么其增量为k,与k取余后为0,因此,k的取值应该大于n,即大于出现次数的上界。

   对于改进二,还有另一种方法,改进二的思路是,加1得到增量再取余。另一种类似的方法是,加一个“大数”再除以"大数"得到加上“大数”的次数即出现的频率。

代码如下:

 1 #include <cstdio>
 2 #include <cstring>
 3 #include <algorithm>
 4 #include <cmath>
 5 using namespace std;
 6 
 7 int a[1001];
 8 int n;
 9 
10 void solve3()
11 {
12      int i;
13      for(i=1; i<=n; i++)
14      {
15           a[a[i]%(n+1)] += (n+1);
16      }
17      for(i=1; i<=n; i++)
18      {
19           printf("%d\n",a[i]/(n+1));
20      }     
21 }
22 int main()
23 {
24      freopen("1.in","r",stdin);
25      freopen("1.out","w",stdout);
26      
27      scanf("%d",&n);
28      for(int i=1; i<=n; i++)
29      {
30           scanf("%d",&a[i]);
31      }
32      solve1();
33      
34      return 0;     
35 }
View Code

  这个算法中也涉及到一个"大数"k,代码中取值为n+1,事实上,与上面类似,这个值k必须大于n。原因如下:

  原因1:只有a[i]<k才能保证a[i] % k是不变的

  原因2:最后每一个元素表示为a[i] = x + f*k,其中x<k,并且f就是我们要统计的频率。

  其实,这道题的核心思想就是让元素出现在该出现的位置,陈利人老师出过另外一道题是最小没出现的正整数,与这个有点类似。题目如下:

  给定一个无序的整数数组,怎么找到第一个大于0,并且不在此数组的最小整数。比如[1,2,0] 返回 3, [3,4,-1,1] 返回 2。最好能O(1)空间和O(n)时间。

  CSDN上有人给出详细的题解,有兴趣的可以看看:http://blog.csdn.net/ju136/article/details/8153274

  写得不好,如有错误或表述不清楚的,希望大家指出。