假设给定一个数组A,要求你找到数组的中点,并且将离中点最近的k个数组成员抽取出来。例如数组A={7, 14, 10, 12, 2, 11, 29, 3, 4},该数组的中点是10,如果令k=5,那么里中点最近的5个数就是{7,14,10,12,11}.
要求对任意一个数组,设计一个时间复杂度为O(n)的算法,该算法能找出距离数组中点最近的K个元素。
这道题的难度是比较大的,在面试中出现的概率应该不大,如果你能在一小时内把它解决并给出代码,那么你的水平在BAT中当个技术经理以上的职位应该是没有太大问题的。
解决这个问题要分两步走:
1, 找到一个算法,能够让我们在O(n)的时间内查找到数组的中点。
2, 假设我们找到了中点m, 那么我们把计算数组前k个元素与中点m的距离,也就是 d(i) = |A[i] - m|, 把这k个元素与中点的距离构建一个大堆。接着在剩下的n - k 个元素中,我们逐次计算他们与中点m的距离,然后用这个距离与大堆中的最大值相比较,如果这个距离大过大堆返回的最大值,那么就忽略这个元素,如果它的距离比大堆的最大值小,那么就把大堆中的最大值去掉,将当前元素加入大堆。
当完成步骤2之后,大堆中的k个元素就是距离终点最近的K个元素。在第二步中,我们需要把元素加入一个大堆,对于一个含有k个元素的大堆而言,加入一个元素的复杂度是O(lgk),所以执行第二步所需的时间复杂度是O(n * lgk)。 由于k是一个常数,所以第二步相对于变量n来说,也是线性的。
现在问题在于第一步的实现,也就是如何通过线性时间的算法找到一个数组的中点,事实上,只要找到第一步的算法,那么整个问题就可以解决了,本质上第二步是多余的,所以本文的重点将在对一步的分析上。我们看看如何设计一个算法,使得我们能在O(n)的时间复杂度内,在数组中找到任意第k大的元素。显然,数组中点是数组中第n/2大的元素,于是要解决问题,我们只要找到第(n/2-k/2)大的元素,然后再找到(n/2+k/2)大的元素,最后查找所有处于这两者之间的元素,那么问题就解决了。
我们看看如何在O(n)的时间内,找到数组中第i大的元素,它的算法步骤如下:
1, 把n个元素分成若干个小组,每个小组有5个元素,于是总共能分成n/5组
2, 对每个小组中的五个元素进行排序,然后取出他们的中点。
3, 利用本算法递归的去查找步骤2中所有中点组成的集合中的中点,假定得到的中点为x。
4, 根据步骤4得到的x把数组划分为两部分,把所有小于x的元素放到x的左边,把所有大于x的元素放到x的右边。假设x的左边有k-1个元素,那么x就是第k小的元素,在x的右边就有n-k个元素。
5, 如果i == k, 那么直接返回x, 如果 i < k, 那么我们在x左边元素集合中,递归的使用该算法去查找第i 小的元素。如果i > k, 那么我们在x的右边元素集合中,使用该算法去查找第(i - k)小的元素。
该算法的实现代码如下:
int select(int[] A, int i) {
//在数组中查找第i小的元素
if (A.length == 1) {
return A[0];
}
/*
* 步骤1,将数组分成小组,每个小组含有5个元素,最后一组很可能没有5个元素
*/
ArrayList<int[]> arrCollection = new ArrayList<int[]>();
int p = 0;
int cnt = 5;
int[] arr = null;
while (p < A.length) {
if (cnt >= 4) {
int len = Math.min(5, A.length - p);
arr = new int[len];
cnt = 0;
arrCollection.add(arr);
} else {
cnt++;
}
arr[cnt] = A[p];
p++;
}
/*
* 步骤2,把每个小组中的元素排序,并取出每个小组中的中点
*/
int[] medians = new int[arrCollection.size()];
for (int j = 0; j < arrCollection.size(); j++) {
Arrays.sort(arrCollection.get(j));
arr = (int[])arrCollection.get(j);
medians[j] = arr[arr.length / 2];
}
/*
* 步骤3,递归的去获取中点集合中的中点
*/
int x = select(medians, medians.length/2);
/*
* 步骤4,把小于x的元素放到x左边,把大于x的元素放到x的右边
*/
int[] B = new int[A.length];
int begin = 0, end = A.length - 1;
int pos = 0;
while (pos < A.length) {
if (A[pos] < x) {
B[begin] = A[pos];
begin++;
}
if (A[pos] > x) {
B[end] = A[pos];
end--;
}
pos++;
}
B[begin] = x;
A = B;
/*
* 执行步骤5,如果x左边的元素是k-1个,同时i == k, 那么返回x
* 如果i < k, 那么递归的去在左边k-1个元素中查找第i小的元素
* 如果i > k, 那么递归的在右边n-k个元素中,查找第(i - k)小的元素
*/
if (i == begin) {
return x;
} else if (i < begin) {
arr = Arrays.copyOf(A, begin);
return select(arr, i);
} else {
arr = Arrays.copyOfRange(A, begin+1, A.length);
return select(arr, i - begin - 1);
}
}
我们简单对算法进行分析一下:
第一步是把元素分成若干个小组,一次循环就可以完成,所以复杂度是O(n);
第二步是找到每个小组中的中点,由于每个小组只有5个元素,所以第二步的时间复杂度也是O(n);
第三步是在所有中点的集合中再找出中点,这一步涉及到算法的递归,所以我们暂时不做分析。
第四步是把数组元素分成两部分,所需时间复杂度也是O(n)。
第五步是在数组中的某一个子集中再次递归的运行算法。
我们看看执行第3步后,也就是取到中点的中点,假设中点的中点为x,然后我们会把数组元素根据x分成两部分:
我们看上图,假设中间点x是我们找到的中点集合中的中点,这样右下角阴影部分的节点,他们的值肯定是大于x的。因为我们把所有节点分成若干个小组,每个小组有5个点,最后一个小组有可能不足5个元素,所有去掉包含x的那个小组,以及最后一个小组,那么就有 (1/2 * n/5) 个小组中,下半部的三个节点都会大于x.于是大于x的元素的个数就至少有:
3* [ (1/2 *n/5) - 2] = 3n/10 - 6 个。
如果我们要找的元素处于前半部分,那么前半部分的元素个数最多有 n - (3n/10 - 6) = 7n/10 + 6 个,如果处于后半部分,那么要处理的元素就有3n/10 - 6 个,也就是说,当我们执行第5步的时候,递归处理的元素个数最多不超过7n/10 + 6个。
我们不深入更加细致的复杂度分析,可以保证的是,算法的最终复杂度是O(n), 也就是说依靠上面算法实现,我们可以在线性时间内从数组中查找处于任意位置的元素。
根据给定的数组A = {7, 14, 10, 12, 2, 11, 29, 3, 4}, 它排序后为:{2, 3, 4, 7, 10, 11, 12, 14, 29},于是第0小的元素是2,第8小的元素是29.如果运行代码:
int y = select(A, 8);
那么我们得到的y值就是29。对代码更详细的讲解和调试演示,请参看视频:
更多技术信息,包括操作系统,编译器,面试算法,机器学习,人工智能,请关照我的公众号: