时间复杂度
常数时间的操作:
一个操作如果和数据量无关,每次操作都是固定时间内完成的操作,叫做常数操作。比如取数组元素操作、加减乘除操作都属于常数操作。
什么叫时间复杂度?
举个例子:选择排序。
选择排序的步骤:
for i = 0 to N-1:
遍历一遍i到N-1的数组,从数组中找到最小的元素的索引d;
将d号元素与第i号元素位置交换;
动画展示:selection sort
在这个流程中,有多少个常数操作?
通过计算,快速排序的常数操作总共进行了
N
(
N
−
1
)
−
2
N(N-1)-2
N(N−1)−2次。
也就是
a
N
2
+
b
N
+
c
aN^2+bN+c
aN2+bN+c次,所谓的时间复杂度就是指:只保留最高阶的常数次操作的项,且忽略掉最高阶系数剩下的东西。也就是
N
2
N^2
N2,简写成
O
(
N
2
)
O(N^2)
O(N2)
时间复杂度用于评估一个算法的优劣。
冒泡排序的时间复杂度分析
冒泡排序就是:多次遍历数组,相邻元素两两交换,将最大值元素放到最后。
动画演示:Bubble Sort
冒泡排序的步骤:
for i=0 to N-1:
flag=false; //用于记录本次遍历是否有交换
for j=0 to N-1-i:
if A[j]>A[j+1]:
flag=true;
swap(A[j],A[j+1]);
if flag==flase: //没有交换则表示数组已经排好序了。
break;
序号 | 取数 | 比较 | 交换 | 次数 |
---|---|---|---|---|
1 | 2 | 1 | 1 | N-1 |
2 | 2 | 1 | 1 | N-2 |
… | … | … | … | … |
N-1 | 2 | 1 | 1 | 1 |
总计需要: 3 × ( N − 1 + N − 2 + ⋯ + 1 ) = 3 × N ( N − 1 ) 2 3\times(N-1+N-2+\cdots+1)=3\times\frac{N(N-1)}{2} 3×(N−1+N−2+⋯+1)=3×2N(N−1),所以冒泡排序的时间复杂度为 O ( N 2 ) O(N^2) O(N2)
题外话:
异或运算: 可以将异或运算看成是没有进位的加法运算
异或运算的性质:
<1>. a ^ 0 = a; a ^ a=0;
<2>. 满足交换律和结合律:即a ^ b = b ^ a; a ^ b ^ c = a ^ (b ^ c)
异或运算的面试题:
一堆数组中有一个数字出现了奇数次,其他数字出现了偶数次,如何找到这个出现奇数次的数字?
答:将数组中所有元素进行异或运算,假设a是出现奇数次的数字,x是除去一个a后的数组,由于x内都是出现偶数次的数据,所以x内部的异或运算结果为0,a ^ x = a。
一堆数组中有两个数字出现了奇数次,其他数字出现了偶数次,如何找到这个出现奇数次的数字?
答:将数组中所有元素进行异或运算,假设a和b是出现奇数次的数字,那么最终的异或结果一定是a ^ b且 ≠ 0 \ne0 =0。假设a ^ b=0100 0010。(结果中一定不全为0,那些不为0的部分就是a和b的差异部分),找到不为0的其中一位,比如第1位。然后将原数组分成两类数据:(1).第1位为0的集合P (2).和第1位为1的集合Q。对集合P和Q分别进行异或,最终结果就是要求的两个数。思路:寻找二者差异,进行分类,达到降维的目的。
取a最右侧不为0的数:
#include <bitset>
…
cout << bitset<8>(a&(~a + 1)) << endl;
插入排序的时间复杂度分析
插入排序就是:确保0 ~ i范围上数组是有序的,从i+1号元素开始,为了确保0 ~ i+1上数组有序,需要向前交换,当停止交换时,0 ~ i+1就是有序的
动画演示:insertion sort
for i=1 to N-1:
for j=i to 1:
if A[j]<A[j-1]:
swap(A[j], A[j-1]);
else
break;
对于插入排序,数据状况的不同,会导致时间复杂度的不同。
比如
完全逆序的数组
[
5
,
4
,
3
,
2
,
1
]
[5,4,3,2,1]
[5,4,3,2,1],插入排序的时间复杂度为
O
(
N
2
)
O(N^2)
O(N2)。
完全顺序的数组
[
1
,
2
,
3
,
4
,
5
]
[1,2,3,4,5]
[1,2,3,4,5],时间复杂度为
O
(
N
)
O(N)
O(N)。
时间复杂度看的是最差的情况下的级别,所以,插入排序的时间复杂度为
O
(
N
2
)
O(N^2)
O(N2)
递归的时间复杂度评估:Matser 公式
递归就是指:调用函数自身,使问题规模变小的行为。
比如,查找一串数组中的最大值的递归算法如下:
int mmax(int* const A, const int low, const int high)
{
if (low == high)
return A[low];
int mid = (low+high)>>1;
int max_left = mmax(A, low, mid);
int max_right = mmax(A, mid+1, high);
return max_left>max_right ? max_left : max_right;
}
假设输入5个元素的数组
[
3
,
1
,
2
,
5
,
4
]
[3,1,2,5,4]
[3,1,2,5,4]
当第一次调用该函数时,将m(0,4)入栈。
然后从m(0,4)中划分出两个子问题:m(0,2)和m(3,4),先将m(0,2)入栈。
然后从m(0,2)中划分出两个子问题:m(0,1)和m(2,2),先将m(0,1)入栈。
然后从m(0,1)中划分出两个子问题:m(0,0)和m(1,1),将m(0,0)入栈。
此时,m(0,0)可以求出结果,出栈,然后将m(1,1)入栈。
Master公式:
T
(
N
)
=
a
T
(
N
b
)
+
O
(
N
d
)
T(N)=aT(\frac{N}{b})+O(N^d)
T(N)=aT(bN)+O(Nd)
那么上面递归的例子的Master公式就是:
2
T
(
N
2
)
+
O
(
1
)
2T(\frac{N}{2})+O(1)
2T(2N)+O(1)
Master公式和时间复杂度的关系:
{
O
(
N
d
)
,
l
o
g
b
a
<
d
O
(
N
l
o
g
b
a
)
,
l
o
g
b
a
>
d
O
(
N
d
∗
l
o
g
2
N
)
,
l
o
g
b
a
=
=
d
\begin{cases} O(N^d), & log_ba<d \\ O(N^{log_ba}), & log_ba>d \\ O(N^d*log_2N), & log_ba==d \end{cases}
⎩⎪⎨⎪⎧O(Nd),O(Nlogba),O(Nd∗log2N),logba<dlogba>dlogba==d
由于上面的例子中
l
o
g
2
2
>
0
log_22>0
log22>0,所以时间复杂度为
O
(
N
)
O(N)
O(N)
归并排序的时间复杂度
归并排序是指:将数组左右两个子数组A1和A2分别排好序,然后创建一块和源数组同样大小的辅助数组空间,分别将两端较小的元素拷贝到辅助数组中,得到排好序的辅助数组,然后将辅助数组拷贝回源数组中。
归并排序动画演示
sort(A,0,n-1);
void sort(A, low, high)
{
if(low>=high)
return;
int mid = (low+high)/2
sort(A,low, mid);
sort(A,mid+1,high);
for(i=low,j=mid+1,d=low;d<=high&&i<=mid&&j<=high;)
{
if(A[i]<A[j])
B[d++]=A[i++];
else
B[d++]=A[j++];
}
while(i<=mid)
B[d++]=A[i++];
while(j<=high)
B[d++]=A[j++];
for(d=low;d<=high;d++)
A[d]=B[d];
}
merge_sort.cpp
根据递归过程得到该递归的master公式:
T
(
N
)
=
2
T
(
N
2
)
+
O
(
N
)
T(N)=2T(\frac{N}{2})+O(N)
T(N)=2T(2N)+O(N)
也就是a=2,b=2,d=1。由于
l
o
g
b
a
=
1
log_ba=1
logba=1,
d
=
1
d=1
d=1所以该递归的时间复杂度为
N
l
o
g
2
N
Nlog_2N
Nlog2N
由Merge_sort改写的面试题每年必出!
面试题:小和问题
在一个数组中,每一个数左边比当前数小的数累加起来,叫做这个数组的小和,求一个数组的小和,要求时间复杂度小于 O ( N 2 ) O(N^2) O(N2)
例如[1,3,4,2,5],1左边比1小的数,没有;3左边比3小的数,1;4左边比4小的数,1,3;2左边比2小的数,1;5左边比5小的数,1,3,4,2;
所以该数组的小和为1+1+3+1+1+3+4+2=16。分析:
当一个问题比较复杂时,寻找与其等价的问题。
寻找左边小 ↔ \leftrightarrow ↔寻找右边大。而且寻找右边大可以精确地知道有几个比自己大。
例如,1右边比1大的个数:4个1;3右边比3大的个数:2个3;4右边比4大的个数:1个4;2右边比2大的个数:1个2;5右边比5大的个数:没有。所以最终的小和为4*1+2*3+1*4+1*2=16。
将一个大问题划分成两个小问题,两个小问题的数组对应的小和已知,并且已经排好序,合并两个小问题时就可以知道大问题的小和。xiaohe.cpp
最终的时间复杂度为 O ( N l o g 2 N ) O(Nlog_2N) O(Nlog2N)
逆序对问题:
在一个数组中,左边的数如果比右边的数大,则这两个数构成一个逆序对,请输出数组中总逆序对的数量。
比如:[3,2,4,5,1]。
对于3来说的逆序对包括:(3,2),(3,1)
对于2来说的逆序对包括:(2,1)
对于4来说的逆序对包括:(4,1)
对于5来说的逆序对包括:(5,1)
总计5对逆序对。分析:
找逆序等价于找右边比自己小的数。其他参考上面的问题。
快速排序的时间复杂度
问题1:
给定一个数组A和常数num。要求数组中所有小于等于num的元素都放在大于num元素的左边,整体不要求有序。
比如:A=[3,1,2,5,4],num=3。ans=[1,2,3,5,4]
分析:我们假设,刚开始小于等于3的区域在数组最左侧,
然后遍历数组中的元素,不断扩充小于等于3的区域:
(1). 如果元素A[i]
≤
\le
≤ 3,则将该元素与区域右侧元素交换,i++
(2). 如果元素A[i]
>
\gt
> 3,i++
最终会将数组划分成3块区域,就是不断地探索未知区域的过程。
p=0 //p表示"<=区域"的下标
for(i=0;i<N;i++)
if(A[i]<=num)
swap(A[i],A[p++]);
问题2:
荷兰国旗问题:
给定一个数组A,一个常数num。将所有小于num的元素放在数组左边,等于num的元素放在数组中间,大于num的元素放在数组右边,整体不要求有序。
分析:假设,"小于num区域"在数组最左侧,"大于num区域"在数组最右侧。遍历数组:
(1). 如果
A
[
i
]
<
n
u
m
A[i]<num
A[i]<num ,将A[i]和"小于num区域"右侧元素交换,该区域右扩p++,i++。
(2). 如果
A
[
i
]
=
=
n
u
m
A[i]==num
A[i]==num ,i++
(3). 如果
A
[
i
]
>
n
u
m
A[i]>num
A[i]>num ,将A[i]和"大于num区域"左侧元素交换,该区域左扩q- -。
p=0,q=N //p表示"<num区域"下标,q表示">num区域下标"
i=0
while(i<q)
if(A[i]<num)
swap(A[i],A[p++])
i++
else if(A[i]>num)
swap(A[i],A[--q])
else
i++
快速排序1.0
将数组的第0号元素当成num,再在剩下的元素中进行区域划分,划分成"<num区域"和">=num区域"。
然后将0号元素和"<num区域"最后的元素交换
然后,左侧区域和右侧区域分别执行相同的操作。
quick_sort1.cpp
快速排序2.0
利用荷兰国旗问题,将整个数组划分成3个区域:
,这样"==num区域"这一批数据就排好序了。
quick_sort2.cpp
快速排序的时间复杂度为
O
(
N
2
)
O(N^2)
O(N2)
因为可以举出最差的情况的例子:[1,2,3,4,5]
这是由于每次取划分值都取的太偏了。如果每次取划分值都在中间的位置,那么递归过程就变成了
T
(
N
)
=
T
(
N
2
)
+
T
(
N
2
)
+
O
(
N
)
T(N)=T(\frac{N}{2})+T(\frac{N}{2})+O(N)
T(N)=T(2N)+T(2N)+O(N),即时间复杂度为
O
(
N
l
o
g
2
N
)
O(Nlog_2N)
O(Nlog2N)。
快排3.0
每次将中间值与第low号元素交换,然后执行划分。