1. 线性数据结构及有序性
线性数据结构的定义很简单,满足以下四个条件即可:
(1) 存在一个首元素
(2) 存在一个尾元素
(3) 除了首元素外,所有元素有且仅有一个前驱元素
(4) 除了尾元素外,所有元素有且仅有一个后继元素
常见的线性数据结构有:数组、链表、队列、栈等。
线性数据结构有序性的定义也很简单,如果除了尾元素,所有元素都大于等于(小于等于)其后继元素,那么可以说该线性结构是有序的。
使线性数据结构有序的方法是排序。排序算法是一类基础算法,网上有很多关于其的讲解,此处不再赘述,具体请自行学习。
2. 查找
查找就是在一些数据中,找到一个数据,使其值等于某个给定值的过程。对于无序的线性数据结构,我们只有一种查找方法——顺序查找,即从首元素开始,沿着后继元素进行遍历,依次检查所有元素的值,得出查找结果。显然,这种方法的复杂度是O(n)。对于有序线性数据结构,假如我们可以在O(1)时间内查询任意位置上的元素的值(如数组),就有一些复杂度更低的查找算法。
2.1 二分查找
对于一个有序线性数据结构,假设其元素的值由小到大,我们用 a i a_{i} ai表示其第i个元素的值,用n表示数据结构的长度。如果我们要查找的值比 a i a_{i} ai大,那么它也比 a 1 a_{1} a1、 a 2 a_{2} a2… a i − 1 a_{i-1} ai−1大;如果我们要查找的值比 a i a_{i} ai小,那么它也比 a i + 1 a_{i+1} ai+1、 a i + 2 a_{i+2} ai+2… a n a_{n} an小。也就是说,比较待查找的值和 a i a_{i} ai的大小,如果相等,我们就得到了查找结果;如果 a i a_{i} ai较小,那么就能排除 a 1 a_{1} a1、 a 2 a_{2} a2… a i − 1 a_{i-1} ai−1、 a i a_{i} ai所对应的共i个元素;如果 a i a_{i} ai较大,那么就能排除 a i a_{i} ai、 a i + 1 a_{i+1} ai+1、 a i + 2 a_{i+2} ai+2… a n a_{n} an所对应的共n - i + 1个元素。
因此,对于有序线性数据结构,我们查找的算法变成了挑选一个元素,比较其值和待查找值的大小,然后要么得到查找结果,要么排除一部分元素,然后在剩下的元素中继续上述过程(挑选一个元素并比较),直到得到查找结果。现在的问题在于,该怎么挑选元素。
一般情况下,我们比较关心算法的最坏情况。对于上述算法,每次比较最坏情况下可以排除min(i, n - i + 1)个元素,当i = n / 2时,min(i, n - i + 1)最大并等于n / 2,因此,我们每次挑选当前剩余元素中,处于中间位置的元素。
下面是二分查找的c++代码:
//arr:数组首地址,size:数组长度,val:待查找的值
//返回待查找的值在数组中的位置,不存在则返回-1
int binarySearch(int * arr, int size, int val)
{
int left = 0, right = size; //[left, right)为未排除元素所在的区间
while (left < right)
{
int mid = (left + right) >> 1; //mid为当前区间的中点
if (arr[mid] < val)
left = mid + 1;
else if (arr[mid] == val)
return mid;
else
right = mid;
}
return -1;
}
由于一次比较至少可以排除n / 2个元素,所以二分查找的时间复杂度为O( l o g n log{n} logn)。
2.2 插值查找
上述的二分查找还有许多变种,比如斐波那契查找(每次挑选黄金分割点处的元素)。这些查找算法都有一个共性,就是挑选元素的位置和待查找的值无关。比如二分查找,无论待查找的值是多少,都会挑选中点处元素进行比较。再考虑我们平时查字典的过程,当我们查找a开头的单词时,绝对不会先把字典翻到中间,而是会翻到开头部分;而当我们查找z开头的单词时,则会翻到结尾部分。查字典的过程给了我们启发,能不能根据待查找的值,来决定挑选哪个位置的元素进行比较?
要想进行插值查找,首先需要数据结构有序,其次,需要通过待查找的值,计算得出挑选元素的位置。假设数据结构中元素按其值从小到大排列,查找区间第一个元素的值(最小值)为L,最后一个元素的值(最大值)为R,待查找的值为x,查找区间的长度为n,我们一般挑选(x - L) * n / (R - L)位置上的元素。(x - L) / (R - L)估计出了查找区间中值比待查找值小的元素的比例,用这个比例再乘以区间长度,就得到了值等于待查找值的元素的估计位置。
计算出挑选元素的位置后,剩下的过程就和二分查找一样了,如果挑选元素的值等于待查找的值,直接得到查找结果,如果不相等,则排除一部分元素,再重复进行上述过程。
下面是插值查找的c++代码:
//arr:数组首地址,size:数组长度,val:待查找的值
//返回待查找的值在数组中的位置,不存在则返回-1
int interpolationSearch(int * arr, int size, int val)
{
long long left = 0, right = size; //[left, right)为未排除元素所在的区间,开long long防溢出
while (left < right)
{
if (arr[right - 1] == arr[left]) //如果不加这个判断,后面计算pos的地方可能会出现除数为0
return val == arr[left] ? left : -1;
long long pos = (val - arr[left]) * (right - left - 1) / (arr[right - 1] - arr[left]) + left; //开long long防溢出
if (pos < left || pos >= right) //检查计算的位置是否在当前区间内
return -1;
if (arr[pos] < val)
left = pos + 1;
else if (arr[pos] == val)
return pos;
else
right = pos;
}
return -1;
}
很明显,数据结构内元素的值从小到大分布得越均匀,使用(x - L) * n / (R - L)计算得出的估计位置越准确,插值查找的效率越高。同时可以发现,二分查找中,挑选的元素都是当前区间的中点,那么本次挑选的元素和上一次挑选的元素,大约间隔了n / 2个位置(n为当前区间长度),因为上一次挑选的元素在当前区间的端点附近;而插值查找中,如果对位置估计得比较精确,那么相邻两次挑选的元素间隔很小。因此,插值查找比起二分查找,缓存的命中率更高。
插值查找的时间复杂度具体是多少呢?这里直接给出结论,假设数组的长度为n,插值查找的最坏时间复杂度为O(n)(比如数组是一个公比为2的等比数列),如果数组内所有元素的取值都是一个区间内的随机值,那么插值查找的平均时间复杂度为θ( l o g l o g n loglog{n} loglogn),快于二分查找。因此插值查找的适用范围比二分查找小,但在数据结构内元素的值从小到大分布得较为均匀时比二分查找更高效。关于其时间复杂度的证明较为复杂,需要用到概率论的知识,详见附录。
注意,以上讲述的二分查找和插值查找,其时间复杂度的结论均建立在数据结构支持O(1)时间查询任意位置元素的值的前提下,因此这两种算法比较适用于数组,对于链表其效率会大打折扣。
3. 去重
去重就是在数据结构中,如果有多个元素的值相同,只保留其中的一个元素,直到所有元素的值都不相同。对于无序的线性数据结构,去重可以通过一个二重循环来实现:
//arr:待去重的数组,size:数组的长度
//返回去重后数组的长度
int deDuplicate(int * arr, int size)
{
int cur = 0;
for (int i = 0; i < size; ++i)
{
char duplicate = 0;
for (int j = 0; j < cur; ++j)
if (arr[j] == arr[i])
{
duplicate = 1;
break;
}
if (!duplicate)
arr[cur++] = arr[i];
}
return cur;
}
显然,这个算法的时间复杂度为O( n 2 n^2 n2),n为数组的长度。
假如线性数据结构有序,那么值相同的多个元素,其位置必然连续。如果我们发现了一段连续的元素,其值均相同,我们只需要保留这段元素中的一个元素就行了。这样我们就可以得到一个O(n)的去重算法:
//arr:待去重的数组,size:数组的长度
//返回去重后数组的长度
int deDuplicate(int * arr, int size)
{
if (size == 0) //特殊判断
return 0;
int cur = 1;
for (int i = 1; i < size; ++i)
if (arr[i] != arr[i - 1])
arr[cur++] = arr[i];
return cur;
}
我们检查每个元素的值是否与其前一个元素的值相同,这样出现一段重复元素时,我们只保留其中第一个元素。
我们发现,无序线性数据结构的去重需要O( n 2 n^2 n2)的时间,而对线性数据结构进行排序,只需要O(n l o g n log{n} logn)的时间。因此我们发现,将无序线性数据结构先排序,再去重,效率要高于直接去重(时间复杂度O(n l o g n log{n} logn))。不过还有一个问题,排序影响了原来无序线性数据结构中,元素的相对位置。其实要在不影响时间复杂度的前提下解决这个问题也很简单,请看下面的c++代码:
//arr:待去重的数组,size:数组的长度
//返回去重后数组的长度
int unorderedDeDuplicate(int * arr, int size)
{
int copy[size];
for (int i = 0; i < size; ++i) //先将数组复制一份
copy[i] = arr[i];
sort(copy, size); //排序
const int len = orderedDeDuplicate(copy, size); //O(n)去重
char visit[len] = {
0};
int cur = 0;
for (int i = 0; i < size; ++i)
{
int pos = binarySearch(copy, len