题目:数组中有一个数字出现的次数超过数组长度的一半,请找出这个数字。例如,输入一个长度为9的数组{1,2,3,2,2,2,5,4,2}。由于数字2在数组中出现了5次,超过数组长度的一半,因此输出2。
最直接的方法:排序,然后顺序查找出次数超过一般的数字,排序的时间复杂度为O(nlogn)。这显然不是本题的最佳结果。
本题两种解法:1.基于双指针快排思想的解法,2.根据数组特点的解法,下面依次阐述:
基于双指针快排思想的解法
首先我们可以回顾一下双指针快排的基本思路:声明两个指针p1和p2分别指向待排序数组的第一个元素和最后一个元素,并且从数组中找一个元素当作轴(轴可以预先放在数组最后);然后移动两个指针(p1向右移动,p2向左移动),p1遇到比轴大的元素时停下,p2遇到比轴小的元素时停下,此时交换p1和p2指向的元素,直到p1和p2相遇;交换轴和p1指向的元素,最终实现轴左边的元素都小于等于轴,轴右边的元素都大于等于轴。
回到本题,题目要求出现次数超过数组长度一半的数字,换种理解可以是:求排序后数组的中位数。既然数组中存在次数超过数组长度一半的数字,中位数肯定就是题目的解。假设数组长度为n,中位数就是n/2位置的数。
综上所述,本题解题思路就是:我们在数组中随机选择一个数字,然后调整数组中数字的顺序,使得比选中的数字小的数字都排在它的左边,比选中的数字大的数字都排在它的右边。如果这个选中的数字的下标刚好是n/2,那么这个数字就是数组的中位数;如果它的下标大于n/2,那么中位数应该位于它的左边,我们可以接着在它的左边部门的数组中查找;如果它的下标大于n/2,那么中位数应该位于它的右边,我们可以接着在它的右边部分的数组中查找。
代码如下,关键地方都给出了注释:
public Integer moreThanHalfNum(int[] numbers) {
if (numbers == null || numbers.length == 0) return null;
int p1 = 0, p2 = numbers.length - 1; // 数组的起始和结束下标
int middle = numbers.length >> 1; // 中位数的下标
int index = partition(numbers, p1, p2); // index可以理解为随机数的下标
while (index != middle) { // 循环直到随机数的下标是中位数位置下标时结束,最终在中位数位置的随机数就是我们想要的结果
if (index < middle) {
p1 = index + 1;
} else {
p2 = index - 1;
}
index = partition(numbers, p1, p2); // 在缩小后的数组范围内继续寻找下一个随机数位置
}
return checkMoreThanHalf(numbers, numbers[index]) ? numbers[index] : null; // 返回时做次检查
}
/**
* 快排思想
*
* @param numbers
* @param start
* @param end
* @return 返回随机选择的一个数字排序后的位置
*/
public int partition(int[] numbers, int start, int end) {
int pivot = end; // 这里我们就用数组最后一个数字作为轴(理应随机选择),该变量记录轴的位置
while (start != end) {
while (start != end && numbers[start] <= numbers[pivot]) {
start++;
}
while (start != end && numbers[end] >= numbers[pivot]) {
end--;
}
swap(numbers, start, end);
}
swap(numbers, pivot, start);
return start;
}
/**
* 最终检查输入的数组是否是无效的
* 如:输入的数组中不存在超过数组长度一半的数字的情况
*
* @param numbers
* @param result
* @return
*/
public boolean checkMoreThanHalf(int[] numbers, int result) {
int times = 0;
for (int i = 0; i < numbers.length; i++) {
if (numbers[i] == result) times++;
}
return times > numbers.length >> 1;
}
public void swap(int[] numbers, int index1, int index2) {
int temp = numbers[index1];
numbers[index1] = numbers[index2];
numbers[index2] = temp;
}
此解法的时间复杂度为O(n)。书中说基于partition方法的算法时间复杂度不是很直观,要了解的话叫我们参考《算法导论》等书籍相关章节。
我在这里简单说一下我的理解:上面代码中第一次执行partition方法的时间复杂度就为O(n),所以大家可能疑惑——放在while循环中的partition方法总的时间复杂度还是O(n)?其实我们可以看出,这里while循环的作用就是缩小查询范围,每次缩小范围后再执行partition方法,那是不是越执行到后面,此时partition方法的时间复杂度就越来越低了...对。我们可以举个例子:有这样的一串时间复杂度相加O(n)+O(n/2)+O(n/4)+...+O(1),我们就将其看作是partition方法的时间复杂度,这串时间复杂度可以等同于O(2n),去掉常数项的时间复杂度就是O(n)。
这种基于双指针快排思想的解法可能略显复杂,下面我们换个角度来看看另一种时间复杂度同样是O(n)的解法...
根据数组特点的解法
摘于书中——数组中有一个数字出现的次数超过数组长度的一半,也就是说它出现的次数比其他所有数字出现的次数和还要多。因此,我们可以考虑在遍历数组的时候保存两个值:一个是数组中的一个数字;另一个是次数。当我们遍历到下一个数字的时候,如果下一个数字和我们之前保存的数字相同,则次数加1;如果下一个数字和我们之前保存的数字不同,则次数减一。如果次数为零,那么我们需要保存下一个数字,并把次数设为1。由于我们要找的数字出现次数比其他所有数字出现的次数之和还要多,那么要找的数字肯定是最后一次把次数设为1时对应的数字。
代码如下:
public Integer moreThanHalfNum(int[] numbers) {
if (numbers == null || numbers.length == 0) return null;
int result = numbers[0];
int times = 1;
for(int i = 1; i < numbers.length; i++) {
if (times == 0) {
result = numbers[i];
times = 1;
} else if (numbers[i] == result) {
times++;
} else {
times--;
}
}
return checkMoreThanHalf(numbers, result) ? result : null; // 返回时做次检查
}
checkMoreThanHalf方法和上一种解法中的相同。
这两种解法的时间复杂度都是O(n)。第一种解法中的思想很好(基于partition方法的思想),第二种解法灵活使用到了题目的条件。基于partition方法的思想可以在O(n)的时间复杂度内得到数组中任意第k大的数字,比如书中第40题——最小的k个数,可以使用的这种思想解题。