用头脑风暴学算法,对于一个问题,我们不只是要解决它,还要去思考有什么好的方法,差的方法去解决,甚至是一些错误的但可以提供思想借鉴的方法。
此问题“数组中出现次数超过一半的数字”是一道非常经典的算法题,我把它放在算法风暴系列第一篇来解析,探讨学习一个算法的过程,从慢到快,从最直观的方法到脑洞大开的方法,由表面深入本质。
下一篇:算法风暴之二—最小的k个数
问题描述
给定一个数组,且已知数组中有一个数出现次数超过一半(严格),请求出这个数。
问题很简单,方法也多样,但什么方法是最好的呢?为什么它最好?各种方法之间有什么优缺点?下面我们一一展开。
方法一:给数组排序
这大概是最直观的方法了,最容易想到,也是最多人能够想出来的。如果我们使用快排的话,只需要O(nlogn)
的时间就可以找到这个数。
那么思考这样一个问题:给数组排序了,然后怎么找这个数呢?有两种方法
1、从小到大遍历已排序数组,同时统计每个数出现的次数(某个数和上一个数不同则计数置为1),如果出现某个计数超过一半,那么正在计数的数就是所求数。
PS:这种方法可行,相比于快排的时间复杂度是可以忽略的,但是我们还有更好的方法,直击本质。
2、对一个已排好序的序列,出现次数超过一半的数必定是中位数。因此,我们只要输出中位数即可。
复杂度分析:
时间复杂度 |
O(nlogn) |
空间复杂度 |
O(n) |
手写快排代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
|
#include <algorithm>
#include <iostream>
#include <cstdlib>
#include <ctime>
#define RAND(start, end) start+(int)(end-start+1)*rand()/(RAND_MAX+1);
using namespace std;
const int maxn = 10005;
int Partition(int *data, int length, int start, int end)
{
if (start == end) return start;
srand((unsigned)time(NULL));
int index = RAND(start, end);
swap(data[index], data[end]);
int one = start - 1;
for (index = start; index < end; ++index) {
if (data[index] < data[end]) {
++one;
if (one != index) swap(data[one], data[index]);
}
}
++one;
swap(data[one], data[end]);
return one;
}
void QuickSort(int *data, int length, int start, int end)
{
if (length <= 1) return;
int mid = Partition(data, length, start, end);
if (mid > start)
QuickSort(data, length, start, mid - 1);
if (mid < end)
QuickSort(data, length, mid + 1, end);
}
int main()
{
int n, data[maxn];
cin >> n;
for (int i = 0; i < n; ++i) {
cin >> data[i];
}
QuickSort(data, n, 0, n - 1);
cout << data[n >> 1];
}
|
方法二:桶排序计数
如果我们需要统计的数组元素都是正整数呢?那么我们就可以使用桶排序,给他们计数,然后超过数组大小一半的就是结果了。
然而桶排序看上去很简单,“复杂度也不高”,却有很多的限制。
1、首先,数组统计的数需得是可hash的,不然无法将他们在hash数组上计数。但是某些情况,如元素有负值,可进行灵活转化,使其可hash。
2、其次,桶排序方法空间换时间,需要消耗额外的空间,取决于数据的范围。
3、桶排序并非真的那么快。桶排序的时间复杂度并非是普通的O(n)
, 它的n指的是最大数据范围,如果有这样一组数据1 100 10000 1000000
,那么桶排序将会有至少1000000次循环,且开出1e6的空间,大大浪费资源。
桶排序方法适合数据范围不大,且数据密度较大的数据。非也,则在此问题上算不上好方法。
代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
|
#include <iostream>
using namespace std;
int main()
{
int n, max_size = 0, ans = 0;
cin >> n;
int *data = new int[n];
for (int i = 0; i < n; ++i) {
cin >> data[i];
max_size = max(max_size, data[i]);
}
int *hash = new int[max_size + 1];
for (int i = 0; i <= max_size; ++i)
hash[i] = 0;
for (int i = 0; i < n; ++i)
hash[data[i]]++;
for (int i = 0; i <= max_size; ++i)
if (hash[i] > n >> 1) ans = i;
cout << ans;
delete [] data;
delete [] hash;
}
|
方法三:巧用栈
其实我们可以发现,上面的方法一和方法二,固然是这道题的解法之一,但不是非常具有针对性。也就是说,那两种方法是功能过剩的,而这所谓功能过剩,也正是导致它性能不是最佳的原因。
那么,我们就应该思考某种算法,只针对这个问题,完全的利用好效率。那么就要从题目出发,找蕴含在问题中的本质规律了。
其实这个问题的核心就是:出现次数超过一半。
我们做这样的思考:
假设k就是我们要求的那个数,那么对这个数组,删掉其中任意两个数所剩下的数组,其对应的k值会改变吗?答案是会的。但是,如果删掉任意两个不相同的数呢?答案是不会! 为什么不会?相信聪明的读者瞬间就明白原因,只需进行简单的推导就可以了。
具体的实现过程就是:每遍历一个数,就将其入栈,同时查询它和栈内前一个元素的大小,如果不同,就同时出栈,否则不变。
以上,就是用栈的方法解决这个问题的核心。
栈实现代码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
|
#include <iostream>
using namespace std;
int main()
{
int n;
cin >> n;
int *data = new int[n];
int *stack = new int[n];
int top = 0;
cin >> data[0];
stack[++top] = data[0];
for (int i = 1; i < n; ++i) {
cin >> data[i];
stack[++top] = data[i];
if (top > 1 && stack[top] != stack[top - 1]) top -= 2;
}
cout << stack[top];
delete [] data;
delete [] stack;
}
|
方法四:找中位数(第n/2大数)
从方法一的分析中我们知道,这个数组的中位数就是答案。方法一是通过给所有的数进行排序找出这个中位数,而我们思考,排序是否有些大材小用?找这个中位数的方法是否可以更简单些?
答案是有的,而且这类问题被称为找第k个数。
思想是快排的思想。时间复杂度为O(n)
利用快排思想,我们可以找出第n/2大的数,同时在第n/2th数左边的数都小于它,右边的数都大于它。这个数就是数组的中位数。
快速排序简称快排,利用分治的思想,在数组中随机选择一个数,然后以这个数为基准,把大于它的数划分到它的右侧,小于它的数划分到它的左侧,并且递归的分别对左右两侧数据进行处理,直到所有的区间都按照这样的规律划分好。
那么在这个问题中,如何利用快排的方法呢?快排是对每一个区间进行分治处理,而此问题不必,我们只要找到第n/2小的数。每次随机划分得的第m个数,如果m < n/2, 那么对[m + 1, n - 1]这个区间继续递归;如果m > n/2,那么对[0, m - 1]这个区间进行递归;如果刚好有m = n/2,那么函数结束,区间[0, n/2 - 1]的数就是最小的n/2个数。
此算法的平均时间复杂度为O(n), 快速排序的详细证明可参考“算法导论”。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
|
#include <iostream>
#include <cstdlib>
#include <ctime>
#define RAND(start, end) start + (int)(end - start + 1)*(rand()/(RAND_MAX + 1))
using namespace std;
int Partition(int *data, int length, int start, int end)
{
if (start == end) return start;
srand((unsigned)time(NULL));
int index = RAND(start, end);
swap(data[index], data[end]);
int one = start - 1;
for (index = start; index < end; ++index) {
if (data[index] < data[end]) {
++one;
if (one != index) swap(data[one], data[index]);
}
}
++one;
swap(data[one], data[end]);
return one;
}
void FindIt(int *data, int length, int start, int end)
{
int mid = Partition(data, length, start, end);
if (mid == length >> 1) return;
else if (mid > length >> 1) FindIt(data, length, start, mid - 1);
else FindIt(data, length, mid + 1, end);
}
int main()
{
int n;
cin >> n;
int *data = new int[n];
for (int i = 0; i < n; ++i) {
cin >> data[i];
}
FindIt(data, n, 0, n - 1);
cout << data[n >> 1];
}
|
下一篇:算法风暴之二—最小的k个数