简述
在许多基本数据结构算法中,算法的过程最终要转换为对数组下标的操作上。如二分查找,快排,求中位数,循环数组等,具体的计算过程往往是先对条件表达式计算,然后根据大于小于或者等于的情况,由当前位置计算下一个位置。本文将讨论常见的下标计算中出现的问题。
在一个数组上进行搜索操作,(通常是排好序的数组),或者部分有序,或者满足某些特定排列的数组。搜索的过程一般都满足经典的三段式
- 设定初始左右边界,并确认循环不变式初始时满足条件
- 在循环中反复计算新的下标并测试,按照测试结果设定新的边界值
- 循环结束,确认达成目标/目标不存在
其中最复杂的部分就是构造循环不变式,通常完成不变式的构造后,问题基本上就很清晰了。但是某些案例下,根据测试结果构造新的变量这一步会极其复杂。如在循环位移的数组上搜索。在这些复杂场景下,新下标的计算往往不是简单的+1或者-1这么简单,而是需要我们仔细的证明下标计算的过程中不会漏掉符合条件的位置,或者数组越界。
本文接下来的部分将分析这几个场景下的下标计算问题。
二维数组中处理坐标的小技巧
二维数组中当需要进行层次遍历并对周围元素进行处理时,我们需要将当前点放入队列,同时遍历周围的四个(上下左右)或者八个点。
若写一个内部类point保存点坐标则略显麻烦(刷算法题),这个时候我们可以将坐标转换成整数,假设矩阵列数为colomn,点坐标为(x, y)则
pos = x * colomn + y
同时设置两个数组,通过两个数组小标k来循环遍历周围元素,访问上下左右的示例如下
vr = {-1, 0, 1, 0};
hr = {0, -1, 0, 1};
for(int k = 0 ; k < 4; k++){
x = x + hr[k];
y = y + hr[k];
}
访问周围八个元素的同理。
中间值的几种求法
按照基本数学公式 mid = (left + right)/ 2,但是leetcode上的高分答案往往能看到 mid = left + (right - left) / 2的写法。
搜索JDK中的Arrays.sort( )方法,可以看到mid = (low + high) >>> 2的写法。
从效率上来看第二种多做了一次加减运算,但是好处是可以避免加法运算造成数组溢出的情况,特别是面试算法的时候需要考虑极端情况。推导过程很简单:
Mid = (left + right)/ 2 = (2*left + right -left ) / 2 = left + (right - left) / 2
第三种算法字面上看效率最高,因为右移运算只需要一个指令就能执行,但是实际情况下第一种写法在编译时会自动的被优化成位移运算,两者没有差异。
在一个排序数组上进行二分搜索是最基本的场景,最常见的代码如下
int l = 0;
Int r = array.length-1;
while(l <= r){
int mid = l + (r - l) / 2;
if(array[mid] > target)
r = mid - 1;
else if (array[mid] < target)
l = mid + 1;
else
return mid;
}
正确性证明如下:
l初始值为0,r为数组最后一位,l <= r 成立,考虑数组长度为0时的特殊情况,即初始情况下l=r也是满足的。
由于数组有序,若mid位置的值是所求值,则返回结果,否则根据大小情况,分别设置r = mid - 1 / l = mid + 1的情况。由于每次新的边界都是mid加一或者减一,因此r 与 l 的差值每次只会减少1,不会发生跳过目标的情况。循环完成时若没有找到答案,最后l > r,若存在结果则一定会返回mid。
循环位移数组分割点
如果数组是循环位移过的数组时,如1 2 3 4 5循环左移2位后变成 3 4 5 1 2时,是否还能在此基础之上进行二分查找呢?
当然可以,但是要先找到唯一的分割点,假设我们要找数组中最小的元素,由于位移后的数组分成了两部分,如 3 4 5 6 | 1 2 当求出mid的值的时候,我们将其与最右边的元素num[r]比较,即可得知mid分布在左半边还是右半边。
若num[mid] > num[r] 则所求最小值在mid右侧 l = mid + 1
若num[mid] < num[r] 则所求最小值在mid左边 r = mid
算法停止时l = r,此时num[l] 即是数组最小值,(靠r = mid 保证)
特殊情况:
数组未旋转时,最终 l == r == 0(算法开始时通过比较num[l] < num[r]直接处理此种情况)
数组里所有数字都相等时,l == r == num.length -1
while(l<r){
mid = l+r /2
if(num[mid] < num[r]
r = mid;
else
l = mid+1; //为什么不是 l=mid? 会出现无限循环的情况
}
循环数组中进行二分查找
确定了数组分割点后,我们就可以利用数组求模的性质直接进行二分搜索。如上
3 4 5 6 1 2 数组左移两位,最小值下标为4,对比未位移的数组,我们在右边补齐
1 2 3 4 5 6 1 2设未位移数组中下标a1,移位后同一数组下标a2,我们假定执行了循环右移操作,则最小为的1往右位移了四位,其下标4刚好是位移的位数。考虑到循环位移是有周期性的,循环位移数组长度len次则得到原数组,假设位移m次,因此新坐标的计算公式就是:
a2 = (a1 + m)% len
m = k * len + minIndex (k为整数,为正右移,为负左移)
而最小值的下标 minIndex = m % len
因此可得由旧坐标求新坐标的公式为
a2 = (a1 + minIndex)% len
所以,当我们得知最小值坐标后,即可以将循环位移的数组当做正常排序数组进行二分查找,比正常二分查找多了一步实际坐标值的计算,即
realMid = (mid + minIndex)% num.length
int rot=lo;
lo=0;hi=n-1;
while(lo<=hi){
int mid=(lo+hi)/2;
int realmid=(mid+rot)%n;
if(A[realmid]==target)
return realmid;
if(A[realmid]<target)
lo=mid+1;
else
hi=mid-1;
}