数学基础之函数增长与复杂性分类
1、渐进符号
我们都知道在过题的时候一定会有资源限制,因此我们在选取算法的时候需要先简单计算一下算法的时间复杂度,如果超了,就得进行优化或者换更快的算法。
但是我们在计算时间复杂度的时候,往往并不需要算出精确的结果,实际上,对于足够大的输入规模,我们只要研究算法的渐进效率(运行时间的增长量级)即可。下面我们先来简单学习一下几种渐进符号。
Θ
Θ
Θ
(这是由mathtype打出的公式,下面有什么出现歧义的表示可参照图片理解,因为用Latex打出的公式太长了)
显然,该公式要求
g
(
n
)
g(n)
g(n)是渐进非负的(要保证集合非空)。
Θ
Θ
Θ记号的主要功能是用来表示一个算法运行时间的确界(根据数据的规模),通常表示为:
f
(
n
)
∈
Θ
(
g
(
n
)
)
f(n)\inΘ(g(n))
f(n)∈Θ(g(n))。由定义可知函数
f
(
n
)
f(n)
f(n)的上界为
c
1
g
(
n
)
c_{1}g(n)
c1g(n),下界为
c
2
g
(
n
)
c_{2}g(n)
c2g(n)。
举个例子,现在有
f
(
n
)
=
2
n
2
−
1
f(n)=2n^{2}-1
f(n)=2n2−1,那么就有
f
(
n
)
=
Θ
(
n
2
)
f(n)=Θ(n^{2})
f(n)=Θ(n2) 。因为根据定义,我们可取
n
0
=
1
,
c
1
=
1
,
c
2
=
2
,
n_{0}=1,c_{1}=1,c_{2}=2,
n0=1,c1=1,c2=2, 满足
0
≤
n
2
≤
2
n
2
−
1
≤
2
n
2
0\leq n^{2}\leq 2n^{2}-1\leq 2n^{2}
0≤n2≤2n2−1≤2n2对
n
>
1
n>1
n>1恒成立。
O
Ο
O
O Ο O记号的主要功能是用来表示算法的最坏情况(因为它表示的是一个渐进上界),相信眼尖的同学已经发现这是 Θ Θ Θ记号右边的情况,所以这里就不站在数学角度举例了,我们来看插入排序的核心代码:
void insert_sort(int* a, int n) // 注意这里是引用传递,否则排序无效
{
// 利用双层for循环做升序排序
for (int i = 0; i < n; i++) {
for (int j = i; j > 0; j--) {
if (a[j] < a[j-1]) {
swap(a[j], a[j - 1]);
}
}
}
}
这个算法的最坏情况就是数组a是降序排列的,那么它的运行时间上界就是 O ( n 2 ) Ο(n^{2}) O(n2)
ο
ο
ο
ο
ο
ο记号表示非渐进紧确的上界,不理解这个概念没关系,我们只需要记住上述公式可理解为:
l
i
m
n
→
∞
f
(
n
)
g
(
n
)
=
0
lim_{n\to \infty}\frac{f(n)}{g(n)}=0
limn→∞g(n)f(n)=0
这涉及到了高等数学中高阶与低阶的知识,感兴趣的同学可以自行了解一下,这里举一个简单的例子:
2
n
+
1
=
ο
(
n
2
)
2n+1=ο(n^{2})
2n+1=ο(n2)
Ω
Ω
Ω
Ω
Ω
Ω记号与
O
Ο
O记号相对,
O
Ο
O记号给出的是函数的渐进上界,而
Ω
Ω
Ω记号则给出了函数的渐进下界。研究渐进上界帮助我们判断一个算法能否用来处理一道题目,而研究渐进下界则可以帮助我们改善算法复杂度。
ω
ω
ω
ω
ω
ω记号与
ο
ο
ο记号相对,
ο
ο
ο记号记号表示非渐进紧确的上界,而
ω
ω
ω记号则表示非渐进紧确的下界。同样的公式可以理解为:
l
i
m
n
→
∞
f
(
n
)
g
(
n
)
=
∞
lim_{n\to \infty}\frac{f(n)}{g(n)}=\infty
limn→∞g(n)f(n)=∞,这里也举一个简单的小例子,
2
n
2
+
1
=
ο
(
n
)
)
2n^{2}+1=ο(n))
2n2+1=ο(n))
最后给两个小性质,可以帮助我们考虑复合函数:
- O ( g 1 ( n ) ) + O ( g 2 ( n ) ) = O ( m a x { g 1 ( n ) , g 2 ( n ) } ) Ο(g_{1}(n))+Ο(g_{2}(n))=Ο(max\{g_{1}(n),g_{2}(n)\}) O(g1(n))+O(g2(n))=O(max{g1(n),g2(n)})
- O ( g 1 ( n ) ) ⋅ O ( g 2 ( n ) ) = O ( g 1 ( n ) g 2 ( n ) ) Ο(g_{1}(n))\cdotΟ(g_{2}(n))=Ο(g_{1}(n)g_{2}(n)) O(g1(n))⋅O(g2(n))=O(g1(n)g2(n))
这两个性质同样适用于 Θ Θ Θ记号和 Ω Ω Ω记号,但是一般情况下,我们熟练掌握 O Ο O记号就可以了。
2、阶的计算
在我们计算算法时间复杂度时,通常有如下复杂度关系:
c
<
l
o
g
n
<
n
<
n
l
o
g
n
<
n
a
<
a
n
<
n
!
c<logn<n<nlogn<n^{a}<a^{n}<n!
c<logn<n<nlogn<na<an<n!(其中,
c
c
c为常数,
a
a
a为大于1的常数)
递归算法是我们经常会使用的算法,因此我们必须熟练掌握它的时间复杂度的阶的计算方法——主定理计算:
T
(
n
)
=
a
T
(
n
/
b
)
+
f
(
n
)
T(n)=aT(n/b)+f(n)
T(n)=aT(n/b)+f(n)
T
(
n
)
T(n)
T(n)的阶在计算时,一般需要分为3种情况进行讨论:
(
1
)
∃
ϵ
>
0
,
s
.
t
.
f
(
n
)
=
O
(
n
l
o
g
b
a
−
ϵ
)
,
(1)\exists\epsilon>0,s.t.f(n)=Ο(n^{log_{b}a-\epsilon}),
(1)∃ϵ>0,s.t.f(n)=O(nlogba−ϵ),则
T
(
n
)
=
Θ
(
n
l
o
g
b
a
)
T(n)=\Theta(n^{log_{b}a})
T(n)=Θ(nlogba)。
(
2
)
f
(
n
)
=
Θ
(
n
l
o
g
b
a
)
,
(2)f(n)=\Theta(n^{log_{b}a}),
(2)f(n)=Θ(nlogba),则
T
(
n
)
=
Θ
(
n
l
o
g
b
a
l
o
g
n
)
T(n)=\Theta(n^{log_{b}a}logn)
T(n)=Θ(nlogbalogn)。
(
3
)
∃
ϵ
>
0
,
s
.
t
.
f
(
n
)
=
Ω
(
n
l
o
g
b
a
+
ϵ
)
,
(3)\exists\epsilon>0,s.t.f(n)=\Omega(n^{log_{b}a+\epsilon}),
(3)∃ϵ>0,s.t.f(n)=Ω(nlogba+ϵ),且
∀
c
<
1
,
∃
n
,
s
.
t
.
a
f
(
n
b
)
≤
c
f
(
n
)
,
\forall c<1,\exists n,s.t.af(\frac{n}{b})\leq cf(n),
∀c<1,∃n,s.t.af(bn)≤cf(n),则
T
(
n
)
=
Θ
(
f
(
n
)
)
T(n)=\Theta(f(n))
T(n)=Θ(f(n))。
上述定理实际上是比较复杂的,所幸由于在绝大多数情况下,
f
(
n
)
f(n)
f(n)的执行时间是多项式时间,所以主定理可以特殊为:
T
(
n
)
=
a
T
(
n
/
b
)
+
O
(
n
d
)
T(n)=aT(n/b)+Ο(n^{d})
T(n)=aT(n/b)+O(nd),相应的,3种情况也可以改写为:
(
1
)
(1)
(1)若
d
>
l
o
g
b
a
,
d>log_{b}a,
d>logba,则
T
(
n
)
=
O
(
n
d
)
T(n)=Ο(n^{d})
T(n)=O(nd)。
(
2
)
(2)
(2)若
d
=
l
o
g
b
a
,
d=log_{b}a,
d=logba,则
T
(
n
)
=
O
(
n
d
l
o
g
n
)
T(n)=Ο(n^{d}logn)
T(n)=O(ndlogn)。
(
3
)
(3)
(3)若
d
<
l
o
g
b
a
,
d<log_{b}a,
d<logba,则
T
(
n
)
=
O
(
n
l
o
g
b
a
)
T(n)=Ο(n^{log_{b}a})
T(n)=O(nlogba)。
这里我们举个例子来理解一下主定理的用法:
归并排序
熟悉归并排序的人都知道它的时间复杂度是
O
(
n
l
o
g
n
)
Ο(nlogn)
O(nlogn),下面我们来看这是如何计算出来的:
void merge(int *a, int start, int middle, int end)
{
int l1 = middle - start + 1; // 一分为二后数组1的长度
int l2 = end - middle; // 一分为二后数组2的长度
int i, j, k;
int a1[l1+1];
int a2[l2+1];
for(i=0; i<l1; i++) // 遍历数组1
a1[i] = a[start+i];
for(j=0; j<l2; j++) // 遍历数组2
a2[j] = a[middle+j+1];
a1[l1]=0x7fffffff;
a2[l2]=0x7fffffff; // 两个数组最后一个值都存放int型所能表示的最大值
for(i=0,j=0,k=start; k<=end; k++)
{
if(a1[i] <= a2[j]) a[k] = a1[i++];
else a[k] = a2[j++];
} // 合并数组
}
void merge_sort(int *a, int start, int end)
{
if(start < end)
{
int middle = (start + end) / 2; //将数组一分为二
merge_sort(a, start, middle); //重复操作直至不可分
merge_sort(a, middle+1, end);
merge(a, start, middle, end);
}
}
上述代码是归并排序的核心代码,归并的要义就在于把规模为
n
n
n的问题分成两个相似的子问题,规模都降为
n
/
2
n/2
n/2,再加上分解和合并的复杂度
f
(
n
)
=
Θ
(
n
)
f(n)=\Theta(n)
f(n)=Θ(n),所以套用主定理的模型:
T
(
n
)
=
2
T
(
n
/
2
)
+
f
(
n
)
T(n)=2T(n/2)+f(n)
T(n)=2T(n/2)+f(n),可见满足简化后的情况
(
2
)
(2)
(2),所以有
T
(
n
)
=
O
(
n
l
o
g
n
)
T(n)=Ο(nlogn)
T(n)=O(nlogn)。
3、复杂度分类
在学习复杂度分类之前,我们需要先了解一下图灵机的概念,图灵机是一种抽象的计算模型,这里只做简单了解,详细请参考百度百科https://baike.baidu.com/item/图灵机/2112989?fr=aladdin。
图灵机有k个磁带,每一个磁带都有一个对磁带某位置进行读操作或者写操作的磁头。但是有唯一磁带是输入磁带,它的磁头只能进行读操作,剩下的k-1个磁带是工作磁带,它们的磁带可以进行读操作,也可以进行写操作。
图灵机还有一个用于存储当前状态的寄存器,注意图灵机的状态集是有限的。
图灵机的工作原理:
1、判断图灵机当前状态。
2、读取k个磁带上磁头对应位置的内容。
3、修改工作磁带上磁头位置的内容(可以保持不变)。
4、分别将每个磁头移动一个位置或者原地不动。
下面来看复杂度分类:
P复杂度类:
存在相应的确定性图灵机(有一套确定的规则),可以在输入长度的多项式时间内计算出问题的结果,比如两个整数相加。
NP复杂度类:
存在相应的确定性图灵机,可以在输入长度的多项式时间内判断一个结果是否正确,比如子集和问题(判断给定整数集合是否存在一个子集,该子集的元素之和为某定值)。
NPC(NP-Complete)复杂度类:
由NP里最难的问题组成。
由此可见,以上三个复杂度类相互之间是存在包含关系的:
1、P是NP的子集(是否是真子集至今未被证明)
2、NPC是NP的子集