《编程之美》有一个题是给定一个小数,将其转化成最简分数,思路比较简单,首先将小数转化成分数,然后对分数化简。如果将问题倒过来,给定一个分数(N/D),将其转化成对应的小数,这该如何做?
我们先分析一下分数转化成小数的可能情况:1)小数是一个有限小数(0.abc…d);2)小数是纯循环小数(0.);3)小数是非纯循环小数(0.ab…c)。分数不可能产生无限不循环小数。第一种情况无需特殊考虑,只需要常规方法即可求得,但是可能小数位数非常长,这时我们只考虑前100位小数。如果小数是循环小数,但是循环节长度大于50,则我们认为其是一个有限小数。当小数是循环小数时,最关键的问题是找到循环节,这个问题不是一个非常简单的问题。
方法一
一种可行的方案是:先不考虑循环节的问题,直接求出分数的前100位小数,然后寻找循环节。但是这时又存在寻找循环节的问题,该问题同样不是一个非常容易的问题。问题的难度在于:1)针对小数位数很长的有限小数,怎样避免将其识别成循环小数;2)寻找循环节的初始位置。第一个问题可以通过判断小数是否是有限小数来解决,具体是:首先将分子分母约分成最简形式,然后判断分母的因子是否只包含2和5;如果是,则小数是有限小数,否则是无限小数。
第二个问题的前提是小数一定是循环小数,此时我们要找到循环节的长度和起始位置(因为不确定小数是纯循环小数还是非纯循环小数)。求循环节比较困难,有点类似于寻找字符串的最长重复子串,但是不完全一样。不过我们可以知道,求循环节的问题至少和寻找字符串的最长重复子串难度相当。
方法二
通过对方法一的分析,我们可以知道算法最后落脚到求字符串的最长重复子串,难度比较大,而且复杂度也比较高。现在我们换种思路,不必求出小数的前100位,而在计算到第三个循环节的时候就可以返回(后面会详述为什么是第三个循环节)。
要获得小数的每一位,我们可以模拟除法操作完成。在除法操作中,当被除数大于除数时,可以直接相除,余数可以通过模运算获得。在求第一位小数时,由于余数小于除数,按照运算规则,我们可以将余数乘以10,然后和被除数相除,获得第一位小数和新的余数。重复上述过程,直到余数为零或者已经求得100位小数。
为了求循环节,我们要寻找一下循环节有什么规律。当出现一次循环节之后,第二遍遍历时,循环节都会重复,我们可以利用这个规律去求循环节。保存循环节可以和每次除法之后的余数联系起来。每一次得到一位小数,都会有一个对应的余数,而余数的范围在0~D-1之间,所以我们可以利用一个长度为D的数组remainder保存每一次计算的小数结果。例如,某次运行的结果是5,余数是3,则remainder[3]=5。
一个直观的结果是,如果对某个位置进行赋值时,发现要赋的值和已经保存的值相同,则说明我们已经得到循环节,开始一个新的循环节计算;否则将该位置赋值为新值。如果一个小数是纯循环小数,则我们只需要统计我们对多少个位置进行赋值即可确定循环节的长度(如3/7)。如果小数是一个非纯循环小数,但是在得到其结果时余数和循环节内的结果余数相同,也可以直接统计赋值的位置获得循环节的长度(如5/6)。但是,这时我们会发现一个问题,非循环节部分的赋值位置和循环节的赋值位置没有关系,如果我们单纯地统计所有赋值的位置就会把非循环节的位置也统计进来导致错误(如7/12)。
这里的关键是我们没有对非循环节部分和循环节部分进行区分,只需要将循环节部分从非循环节部分中区别出来就不会出错。它们之间的根本区别就是非循环节部分只出现一次,而循环节部分出现无穷多次,我们可以利用这个结论去做区分。方法就是记录每一个位置赋值的次数,统计两遍循环节,这样就可以将非循环节部分和循环节部分区分开来。为了统计每个位置赋值的次数,我们需要分配一个和保存结果数组等长的数组time。如果当前的余数对应的remainder位置的值与将要赋的值不一样,则将time数组对应位置赋值为1,表示某个小数第一次出现。如果当前的余数对应的remainder位置的值与将要赋的值一样,则将time数组对应位置加1。如果time数组中出现赋值为3的位置,则停止计算,此时已经计算了两次循环节,已经获得了所有的信息。通过统计次数为2的位置我们就可以获得循环节的长度,然后就可以正常打印该分数的小数。
下面是完整的代码:
#include <stdio.h>
#include <stdlib.h>
char small[101];//保存计算结果的字符串
int main()
{
int N,D;
int result;
while(scanf("%d %d",&N, &D) != EOF)
{
result=N/D;
N=N%D;
if(N==0)
{
printf("%d.0\n",result);
}
else
{
int i=0,r,len=0;
//由于针对不同的分母,余数不同,不能提前分配空间,最好能先将分数化成最简分数
int* remainder=(int*)malloc(sizeof(int)*D);
int* atime=(int*)malloc(sizeof(int)*D);
//必须要将每一个余数可能的结果初始化为-1,因为每位结果可能是0~9
memset(remainder,-1,sizeof(int)*D);
memset(atime,0,sizeof(int)*D);
while(N!=0&&i<100)
{
N=N*10;
r=N/D;//获得商
N=N%D;//获得余数
if(remainder[N]==r)//非第一次遇到
{
if(atime[N]!=2)//非第三次遇到
atime[N]++;
else//第三次遇到
{
for(int j=0;j<D;j++)
{
if(remainder[j]!=-1&&atime[j]>1)len+=1;
}
break;
}
}
else
{
remainder[N]=r;
atime[N]=1;
}
small[i++]='0'+r;
}
small[i-len]='\0';
//获得第一个循环节的第一个字符
char tmp=small[i-len*2];
small[i-len*2]='\0';
//先打印非循环节部分
printf("%d.%s",result,small);
char *p=small+(i-len*2);
*p=tmp;
if(N!=0&&len>0)
printf("(");
printf("%s",p);
if(N!=0&&len>0)
printf(")\n");
else
printf("\n");
free(remainder);
free(atime);
}
}
return 0;
}
方法三
事实上,还有一个更容易理解的结论:如果每次相除获得的余数出现重复,则也说明已经获得循环节。所以,我们可以保留每次计算的余数,直到同一个余数出现两次。为了计算循环节的长度,我们需要遍历保存的余数,但是我们可以采用HashMap数据结构来避免每次的检索,此时HashMap中只需要保存每个<余数,位置>。这样当我们遇到重复时,只需要检索HashMap获得之前相同余数的位置,然后和当前位置相减即可获得循环节的长度。
由于采用了HashMap结构,所以我们采用Java语言编写:public static void function(int N, int D) {
HashMap<Integer, Integer> remainder = new HashMap<>();
StringBuilder sb = new StringBuilder();
sb.append(N / D);
sb.append('.');
N = N % D;
int i = 0;
while (remainder.get(N) == null) {
remainder.put(N, i++);
N = N * 10;
sb.append(N / D);
N = N % D;
if (N == 0)
break;
}
if (N != 0) {
int len = i - remainder.get(N);
sb.insert(sb.length() - len, '(');
sb.append(')');
}
System.out.println(sb.toString());
}