拿到题目,首先需要做的就是分析题目所要达到的目标以及其中的限定条件。从题目的描述中可以发现,本题的目标就是在一个有且仅有一个元素值重复的数组中找出这个唯一的重复元素,而限定条件就是每个数组元素只能访问一次、并且不许使用辅助存储空间。很显然,从前面对Hash法的分析中可知,如果题目没有对是否可以使用辅助数组做限制的话,最简单的方法就是使用Hash法。
方法一:Hash法 当使用Hash法时,具体过程如下所示:首先定义一个长度为1000的Hash数组,将Hash数组中的元素值都初始化为0,将原数组中的元素逐一映射到该Hash数组中,当对应的Hash数组中的值为0时,则置该Hash数组中该处的值为1,当对应的Hash数组中该处的值为1时,则表明该位置的数在原数组中是重复的,输出即可。
示例代码如下所示:
public class Test
{
//
//方法功能: 在数组中找唯一重复的元素
//输入参数: array:数组对象的引用
//返回值: 返回重复元素的值,无重复元素则返回-1
//
public static int FindDup(int[] array)
{
if(null==array)
{
return-1;
}
int len=array.Length;
Dictionary<int, int> hashtable=new Dictionary<int, int>();
int i;
for (i=0; i<len-1; i++)
{
hashtable.Add(i, 0);
}
for(i=0; i<len; i++)
{
if(hashtable[array[i]-1]== 0)
{
hashtable[array[i]-1]=array[i]-1;
}
else
{
return array[i];
}
}
return -1;
}
public static void Main(String[] args)
{
int[] array={1, 3, 4, 2, 5, 3};
Console.WriteLine(FindDup(array));
Console.Read();
}
}
程序的运行结果为
3
算法性能分析: 上述方法是一种典型的以空间换时间的方法,它的时间复杂度为O(n),空间复杂度为O(n),很显然,在题目没有明确限制的情况下,上述方法不失为一种好方法,但是,由于题目要求不能用额外的辅助空间,所以,上述方法不可取,是否存在其他满足题意的方法呢?
方法二:累加求和法 计算机技术与数学本身是一家,抛开计算机专业知识不提,上述问题其实可以回归成一个数学问题。数学问题的目标是在一个数字序列中寻找重复的那个数。根据题目意思可以看出,1~1000个数中除了唯一一个数重复以外,其他各数有且仅有出现一次,由数学性质可知,这1001个数包括1到1000中的每一个数各1次,外加1~1000中某一个数,很显然,1001个数中有1000个数是固定的,唯一一个不固定的数也知道其范围(1~1000中某一个数),那么最容易想到的方法就是累加求和法。
所谓累加求和法,指的是将数组中的所有N+1(此处N的值取1000)个元素相加,然后用得到的和减去1+2+3+…N(此处N的值为1000)的和,得到的差即为重复的元素的值。这一点不难证明。
由于1001个数的数据量较大,不方便说明以上算法。为了简化问题,以数组序列{1,3,4,2,5,3}为例。该数组长度为6,除了数字3以外,其他4个数字没有重复。按照上述方法,首先,计算数组中所有元素的和sumb, sumb=1+3+4+2+5+3=18,数组中只包含1~5的数,计算1~5一共5个数字的和suma, suma=1+2+3+4+5=15;所以,重复的数字的值为sumb-suma=3。由于本方法的代码实现较为简单,此处就不提供代码了,有兴趣的读者可以自己实现。
算法性能分析: 上述方法的时间复杂度为O(n),空间复杂度为O(l)。
在使用求和法计算时,需要注意一个问题,即当数据量巨大时,有可能会导致计算结果溢出。以本题为例,1~1000范围内的1000个数累加,其和为(1+1000)*1000/2,即500500,普通的int型变量能够表示出来,所以,本题中不存在此问题。但如果累加的数值巨大时,就很有可能溢出了。
此处是否还可以继续发散一下,如果累加求和法能够成立的话,累乘求积法是不是也是可以成立的呢?只是累加求积法在使用的过程中很有可能会存在数据越界的情况,如果再由此定义一个大数乘法来,那就有点得不偿失了。所以,求积的方式是理论上成立的,只是在实际的使用过程中可操作性不强而已,一般更加推荐累加求和法。
方法三:异或法 采用以上累加求和的方法,虽然能够解决本题的问题,但也存在一个潜在的风险,就是当数组中的元素值太大或者数组太长时,计算的和值有可能会出现溢出的情况,进而无法求解出数组中的唯一重复元素。
鉴于求和法存在的局限性,可以采用位运算中异或的方法。根据异或运算的性质可知,当相同元素异或时,其运算结果为0,当相异元素异或时,其运算结果为非0,任何数与数字0进行异或运算,其运算结果为该数。本题中,正好可以使用到此方法,即将数组里的元素逐一进行异或运算,得到的值再与数字1、2、3…N进行异或运算,得到的最终结果即为所求的重复元素。
以数组{1,3,4,2,5,3}为例。(l^3^4^2^5^3)^(1^2^3^4^5)=(1^1)^(2^2)^(3^3^3)^(4^4)^(5^5)=0^0^3^0^0=3。
示例代码如下所示:
public static int findDup(int[] array)
{
if(null==array)
return-1;
int len=array.Length;
int result=0;
int i;
for(i=0; i<len; i++)
result^=array[i];
for(i=1; i<len; i++)
result^=i;
return result;
}
程序员的运行结果为
3
算法性能分析: 上述方法的时间复杂度为O(n),也没有申请辅助的存储空间。
方法四:数据映射法 数组取值操作可以看作一个特殊的函数f:D→R,定义域为下标值0~1000,值域为1到1000。如果对任意一个数i,把f(i)称为它的后继,i叫f(i)的前驱。0只有后继,没有前驱,其他数字既有后继也有前驱,重复的那个数字有两个前驱,将利用这些特征。
采用此种方法,可以发现一个规律,即从0开始画一个箭头指向它的后继,从它的后继继续指向后继的后继,这样,必然会有一个结点指向之前已经出现过的数,即为重复的数。
利用下标与单元中所存储的内容之间的特殊关系,进行遍历访问单元,一旦访问过的单元赋予一个标记(把数组中元素变为它的相反数),利用标记作为发现重复数字的关键。
以数组array={1,3,4,3,5,2}为例。从下标0开始遍历数组,
1)array[0]的值为1,说明没有被遍历过,接下来遍历下标为1的元素,同时标记已遍历过的元素(变为相反数):array={-1,3,4,3,5,2};
2)array[1]的值为3,说明没被遍历过,接下来遍历下标为3的元素,同时标记已遍历过的元素:array={-1,-3,4,3,5,2};
3)array[3]的值为3,说明没被遍历过,接下来遍历下标为3的元素,同时标记已遍历过的元素:
array={-1,-3,4,-3,5,2};
4)array[3]的值为-3,说明已经被遍历过了,找到了重复的元素。
示例代码如下所示。
public static int FindDup(int[] array)
{
if(null==array)
return -1;
int len=array.Length;
int index=0;
int i=0;
while (true)
{
//数组中的元素的值只能小于len, 否则会越界
if(array[i]>=len)
return -1;
if(array[index]<0)
break;
//访问后通过变相反数的方法进行标记
array[index]*=-1;
//index的后继为array[index]
index=-1*array[index];
if(index>=len)
{
Console.WriteLine("数组中有非法数字");
return-1;
}
}
return index;
}
算法说明: 因为每个数在数组中都有自己应该在的位置,如果一个数是在自己应该在的位置(在本题中就是它的值就是它的下标,即所在的位置),那永远不会对它进行调换,也就是不会访问到它,除非它就是那个多出的数,那与它相同的数访问到它的时候便就是结果了;如果一个数鸠占鹊巢,所在的位置不是它应该待的地方,那它会去找它应该在的位置,在它位置的数也应该去找它应该在的位置,碰到了负数,也就是说已经出现了这个数,所以,便也得出结果了。
算法性能分析: 上述方法的时间复杂度为O(n),也没有申请辅助的存储空间。
这个方法的缺点是修改了数组中元素的值,当然也可以在找到重复元素之后对数组进行一次遍历,把数组中的元素改为它的绝对值的方法来恢复对数组的修改。
方法五:环形相遇法 就是采用类似于单链表是否存在环的方法进行问题求解。“判断单链表是否存在环”是一个非常经典的问题,同时单链表可以采用数组实现,此时每个元素值作为next指针指向下一个元素。本题可以转化为“已知一个单链表中存在环,找出环的入口点”这种想法。具体思路如下:将array[i]看作第i个元素的索引,即:array[i]->array[array[i]]->array[array[array[i]]]->array[array[array[array[i]]]]->…最终形成一个单链表,由于数组a中存在重复元素,则一定存在一个环,且环的入口元素即为重复元素。
该题的关键在于,数组array的大小是n,而元素的范围是[1,n-1],所以,array[0]不会指向自己,进而不会陷入错误的自循环。如果元素的范围中包含0,则该题不可直接采用该方法。以数组序列{1,3,4,2,5,3}为例。按照上述规则,这个数组序列对应的单链表如下图所示:
从上图可以看出这个链表有环,且环的入口点为3,所以,这个数组中重复元素为3。
在实现的时候可以参考求单链表环的入口点的算法:用两个速度不同的变量slow和fast来访问,其中,slow每次前进一步,fast每次前进两步。在有环结构中,它们总会相遇。接着从数组首元素与相遇点开始分别遍历,每次各走一步,它们必定相遇,且相遇第一点为环入口点。
示例代码如下所示:
public static int FindDupl(int[] array)
{
if (null==array)
return -1;
int slow=0;
int fast=0;
do
{
fast=array[array[fast]]; //fast一次走两步
slow=array[slow]; //slow一次走一步
}while (slow!=fast);//找到相遇点
fast=0;
do
{
fast=array[fast];
slow=array[slow];
}while (slow!=fast);//找到入口点
return slow;
}
程序的运行结果为
3
算法性能分析: 上述方法的时间复杂度为O(n),也没有申请辅助的存储空间。
当数组中的元素不合理的时候,上述算法有可能会有数组越界的可能性,因此,为了安全性和健壮性,可以在执行fast=array[array[fast]]; slow=array[slow]; 操作的时候分别检查array[slow]与array[fast]的值是否会越界,如果越界,则说明提供的数据不合法。
引申:对于一个给定的自然数N,有一个N+M个元素的数组,其中存放了小于等于N的所有自然数,求重复出现的自然数序列{X} 分析与解答: 对于这个扩展需要,已经标记过的数字在后面一定不会再访问到,除非它是重复的数字,也就是说只要每次将重复数字中的一个改为靠近N+M的自然数,让遍历能访问到数组后面的元素,就能将整个数组遍历完。此种方法非常不错,而且它具有可扩展性。
鉴于以上分析,可以构建如下示例代码:
class Test
{
static HashSet<int> FindRepeat(int[] array, int len, int num)
{
HashSet<int>s=new HashSet<int>();
if (null==array||len<1||num<1||len<=num)
{
return s;
}
int index=array[0];
num=num-1;
while(true)
{
if(array[index]<0)
{
num--;
//
//找到了重复的数字index, 为了保证能遍历数组中所有的数, 把index
//下标对应的值修改为尽可能接近N+M的数
//
array[index]=len-num;
s.Add(index);
}
if(num==0)
{
return s;
}
array[index]*=-1;
index=array[index]*(-1);
}
}
static void Main()
{
int[] array={1,2,3,3,3,4,5,5,5,5,6};
int Length=array.Length;
int num=6;
HashSet<int>s=FindRepeat(array, Length, num);
foreach (var item in s)
{
Console.WriteLine(item.To String());
}
Console.Read();
}
}
程序的运行结果为
3 5
算法性能分析: 上述方法的时间复杂度为O(n),也没有申请辅助的存储空间。
当数组中的元素不合理的时候,上述方法有可能会有数组越界的可能性,也有可能会进入死循环,为了避免这种情况发生,可以增加适当的安全检查代码。