目录
计算问题和算法
计算问题:输⼊和输出之间的⼆元关系,例如输入两个数,计算它们的和。每⼀个输⼊可能对应零个、⼀个或者多个输出。
算法:找到以上⼆元关系的⽅法。对于每⼀个输⼊,能在有限时间内正确找到其对应的输出。
算法的效率:一般通过时间复杂度来衡量。
算法的正确性:一般通过归纳法证明。
最⼤⼦数组问题
输入:一个长度为 n 的数组。
输出:该数组的最大的子数组
类似题目:洛谷:最大子段和,注意此处讨论的最大子数组可以为空,而最大子段和中的不能为空。
穷举法求最大子数组
穷举所有子数组a[i…j],计算子数组的和,得到最大的子数组。
int dynamic_programming(vector<int>& nums) {
int ans=0;
for(int i=0;i<nums.size();i++){
int t=0;
for(int j=i;j<nums.size();j++){
t+=nums[j];
ans=max(ans,t);
}
}
return ans;
}
在最大子段和上提交的代码:
#include<bits/stdc++.h>
using namespace std;
int dynamic_programming(vector<int>& nums) {
int ans=nums[0];
for(int i=0;i<nums.size();i++){
int t=0;
for(int j=i;j<nums.size();j++){
t+=nums[j];
ans=max(ans,t);
}
}
return ans;
}
int main() {
int n, tmp;
vector<int> v;
cin>>n;
while(n--){
cin>>tmp;
v.push_back(tmp);
}
cout<<brute_force(v)<<endl;
return 0;
}
最终结果,因为数据量较大,二重循环会超时。因此枚举答案是行不通的,需要更优的算法。
归纳法求最⼤⼦数组
归纳:如何基于较小规模的问题来求解较⼤规模的问题
- 基本情况:⾜够小的问题可以直接求解。例如将大规模问题不断划分,最终变为O(1)的问题,就可以直接求解,再合并。
- 归纳步骤:建立小问题和⼤问题之间的关系(递归关系)。在得到子问题的解时,需要得到子问题与父问题的关系,用于将子问题合并,从而得到父问题的解。
问题:求a[i…j]的最大子数组
- 基本情况:i==j时,此时最大子数组即为max{a[i], 0}(最大子数组可以为空)。
- 递归关系: O P T ( a [ 1.. n ] ) = m a x O P T ( L e f t ) , O P T ( R i g h t ) , S ( C r o s s ) OPT(a[1..n]) = max{OPT(Left), OPT(Right), S(Cross)} OPT(a[1..n])=maxOPT(Left),OPT(Right),S(Cross)。S(Cross)是指横跨Left和Right的最大值。
均匀划分
归纳法将数组 a 分为Left和Right两部分,均匀划分则尽量将Left和Right的长度划分至相等。
此时:Left = a[1…m], Right = a[m+1…n], m=(1+n)/2;
Cross是横跨Left和Right的子数组,因此必须至少包含Left和Right的一个元素:
- Cross = a[i…j] = a[i…m] + a[m+1…j]
- a[i…m]:从a[m]开始,向左扫描数组
- a[m+1…j]:从a[m+1]开始,向右扫描数组
int divide_and_conquer(const vector<int>& a, int i, int j){
if(i==j) return max(a[i],0);
int m=(i+j)/2;
int left = divide_and_conquer(a,i,m);
int right = divide_and_conquer(a,m+1,j);
int cross_l = a[m],mcl=a[m];
for(int k=m-1;k>=i;k--){
cross_l+=a[k];
mcl=max(mcl, cross_l);
}
int cross_r = a[m+1],mcr=a[m+1];
for(int k=m+2;k<=j;k++){
cross_r+=a[k];
mcr=max(mcr, cross_r);
}
return max(left, max(right, mcl+mcr));
}
在最大子段和上提交的代码:
#include<bits/stdc++.h>
using namespace std;
int divide_and_conquer(const vector<int>& a, int i, int j){
if(i==j) return a[i];
int m=(i+j)/2;
int left = divide_and_conquer(a,i,m);
int right = divide_and_conquer(a,m+1,j);
int cross_l = a[m],mcl=a[m];
for(int k=m-1;k>=i;k--){
cross_l+=a[k];
mcl=max(mcl, cross_l);
}
int cross_r = a[m+1],mcr=a[m+1];
for(int k=m+2;k<=j;k++){
cross_r+=a[k];
mcr=max(mcr, cross_r);
}
return max(left, max(right, mcl+mcr));
}
int main() {
int n, tmp;
vector<int> v;
cin>>n;
while(n--){
cin>>tmp;
v.push_back(tmp);
}
cout<<divide_and_conquer(v,0,v.size()-1)<<endl;
return 0;
}
不均匀划分
仍然将数组划分成两个部分,但尽量不均匀的划分,将数组分成长度尽量不等的两部分: a[1…n−1] a[n…n]
O P T ( a [ 1.. n ] ) = m a x ( O P T ( L e f t ) , O P T ( R i g h t ) , S ( C r o s s ) ) OPT(a[1..n]) = max(OPT(Left), OPT(Right), S(Cross)) OPT(a[1..n])=max(OPT(Left),OPT(Right),S(Cross))
int divide_and_conquer(const vector<int>& a, int i, int j){
if(i==j) return max(a[i],0);
int m=j-1;
int left = divide_and_conquer(a,i,m);
int right = divide_and_conquer(a,m+1,j);
int cross_l = a[m],mcl=a[m];
for(int k=m-1;k>=i;k--){
cross_l+=a[k];
mcl=max(mcl, cross_l);
}
return max(left, max(right, mcl+a[j]));
}
在最大子段和上提交的代码:
#include<bits/stdc++.h>
using namespace std;
int divide_and_conquer(const vector<int>& a, int i, int j){
if(i==j) return a[i];
int m=j-1;
int left = divide_and_conquer(a,i,m);
int right = divide_and_conquer(a,m+1,j);
int cross_l = a[m],mcl=a[m];
for(int k=m-1;k>=i;k--){
cross_l+=a[k];
mcl=max(mcl, cross_l);
}
return max(left, max(right, mcl+a[j]));
}
int main() {
int n, tmp;
vector<int> v;
cin>>n;
while(n--){
cin>>tmp;
v.push_back(tmp);
}
cout<<divide_and_conquer(v,0,v.size()-1)<<endl;
return 0;
}
因为每次只减少一个元素,因此递归深度也有n,此时退化成了与二重循环相同的时间复杂度,因此后三个样例也超时了。
动态规划求最大子数组
对于数字 i 都有两个状态:
- 如果选择 a[i] ,则从a[1…i-1]的最大子数组 + a[i],与a[i] (此时a[1…i-1]的最大的子数组会让a[i]变得更小)。 p r e i = m a x ( n i , p r e i − 1 + n i ) pre_i=max(n_i, pre_{i-1}+n_i) prei=max(ni,prei−1+ni)
- 如果不选 a[i], 此时则a[i]不存在与最长子数组中,子数组从a[i+1]开始。对应 p r e i + 1 = n i + 1 pre_{i+1}=n_{i+1} prei+1=ni+1
对于计算a[i]的
p
r
e
i
pre_i
prei,此时a[i-1]一定是选择了的:
p
r
e
i
−
1
=
m
a
x
(
n
i
−
1
,
p
r
e
i
−
2
+
n
i
−
2
)
pre_{i-1}=max(n_{i-1}, pre_{i-2}+n_{i-2})
prei−1=max(ni−1,prei−2+ni−2)。
因此基于此,pre是a[1…i]的包含a[i]的最大的子数组的值,ans代表a[1…i]的最大的子数组的值。
int divide_and_conquer(const vector<int>& a){
int ans=0, pre=0;
for(int n : a){
pre=max(n, pre+n);
ans=max(ans,pre);
}
return ans;
}
在最大子段和题目中提交的代码:
#include<bits/stdc++.h>
using namespace std;
int divide_and_conquer(const vector<int>& a){
int ans=a[0], t=0;
for(int n : a){
t=max(n, t+n);
ans=max(ans,t);
}
return ans;
}
int main() {
int n, tmp;
vector<int> v;
cin>>n;
while(n--){
cin>>tmp;
v.push_back(tmp);
}
cout<<divide_and_conquer(v)<<endl;
return 0;
}
算法正确性证明
归纳假设:对于某个整数 ,算法能够正确地计算:
- L[k]:以a[k]结尾的⼦数组的最⼤和。
- ans:数组a[1…k]的⼦数组的最⼤和。
基本情况:k=1,数组只有一个元素a[1],计算完毕,L[k]=a[1],ans = max(a[1], 0)。
归纳步骤:令a[k+1]结尾的最大子数组是a[i…k+1]
- i = k+1或者i < k+1
- 对于后⼀种情况,a[i…k]必然是以a[k]结尾的最⼤⼦数组,根据归纳假设,其解为L[k]。
- 所以 max(a[k+1], L(k) + a[k+1]) 能够正确计算L(k+1)
因此能正确得到最大子数组。
算法效率分析
Word-RAM模型
内存:由⼀系列连续的内存单元组成,每个内存单元可以存放 个比特,且具有⼀个地址,地址范围:0 ~ 2^w-1
- 内存单元也称为word
处理器
- 可以在常数时间内读写⼀个内存单元
- 可以在常数时间内对两个内存单元中的内容进⾏基本的⼆元运算:加、减、乘、除、模、位运算、逻辑运算
输⼊输出:按内存单元逐⼀读⼊或写出
算法性能
- 时间:基本运算的数量
- 空间:使用内存单元的数量
渐近记号
一般对于算法的效率,采用最坏情况来进行分析。
渐进记号一般指最坏情况下算法使用的基本操作的数量。
在分析基本操作数量时,渐近记号忽略:
- 常数因⼦:仍然和机器、编程语⾔等因素相关
- 低阶项:输⼊规模很⼤时⽆关紧要
5 n 2 + 4 n + 3 5n^2 + 4n + 3 5n2+4n+3 —> 5 n 2 5n^2 5n2 —> n 2 n^2 n2
O记号
O
(
g
(
n
)
)
O(g(n))
O(g(n)) = {
f
(
n
)
f(n)
f(n):存在正常量c和
n
0
n_0
n0,使得对所有
n
>
=
n
0
n>=n_0
n>=n0,有
0
<
=
f
(
n
)
<
=
c
g
(
n
)
0<=f(n)<=cg(n)
0<=f(n)<=cg(n)}。
f
(
n
)
=
O
(
g
(
n
)
)
f(n)=O(g(n))
f(n)=O(g(n))表示函数
f
(
n
)
f(n)
f(n)属于函数集合
O
(
g
(
n
)
)
O(g(n))
O(g(n))。
O记号的性质
Ω记号
Ω
(
g
(
n
)
)
Ω(g(n))
Ω(g(n)) = {
f
(
n
)
f(n)
f(n) :存在正常量c和
n
0
n_0
n0,使得对所有
n
>
=
n
0
n>=n_0
n>=n0,有
0
<
=
c
g
(
n
)
<
=
f
(
n
)
0<=cg(n)<=f(n)
0<=cg(n)<=f(n)}
f
(
n
)
=
Ω
(
g
(
n
)
)
f(n)=Ω(g(n))
f(n)=Ω(g(n))表示函数
f
(
n
)
f(n)
f(n)属于函数集合
Ω
(
g
(
n
)
)
Ω(g(n))
Ω(g(n))。
渐近记号与极限
等式的右边只有渐近记号,比如 n = O ( n 2 ) n = O(n^2) n=O(n2)
- 等号实际上是集合的成员关系,即 n ∈ O ( n 2 ) n ∈ O(n^2) n∈O(n2)
等式的右边包含渐近记号,比如 2 n 2 + 3 n + 1 = 2 n 2 + Θ ( n ) 2n^2 + 3n + 1 = 2n2 + Θ(n) 2n2+3n+1=2n2+Θ(n)
- Θ(n)代表⼀个匿名函数f(n),其中 f(n) ∈ Θ(n)
- 用于隐藏⽆关紧要的细节
等式的左边包含渐近记号,比如 2 n 2 + Θ ( n ) = Θ ( n 2 ) 2n^2 + Θ(n) = Θ(n2) 2n2+Θ(n)=Θ(n2)
- ⽆论怎样选择等号左边的匿名函数
f
(
n
)
∈
Θ
(
n
)
f(n) ∈ Θ(n)
f(n)∈Θ(n),总有⼀种办法来选择等
号右边的匿名函数 g ( n ) ∈ Θ ( n 2 ) g(n) ∈ Θ(n2) g(n)∈Θ(n2),使得等式成立,即 2 n 2 + f ( n ) = g ( n ) 2n2 + f(n) = g(n) 2n2+f(n)=g(n)。
求解最⼤⼦数组算法的效率
穷举法求最大子数组: T ( n ) = O ( n 2 ) T(n) = O(n^2) T(n)=O(n2)
归纳法求最⼤⼦数组,均匀划分: T ( n ) = 2 T ( n / 2 ) + O ( n ) T(n) = 2T(n/2) + O(n) T(n)=2T(n/2)+O(n) --> T ( n ) = O ( n l o g n ) T(n) = O(nlogn) T(n)=O(nlogn)
归纳法求最⼤⼦数组,不均匀划分: T ( n ) = T ( n − 1 ) + O ( n ) T(n) = T(n -1) + O(n) T(n)=T(n−1)+O(n) --> T ( n ) = O ( n 2 ) T(n) = O(n^2) T(n)=O(n2)
归纳法求最⼤⼦数组,不均匀划分(cross优化): T ( n ) = T ( n − 1 ) + O ( 1 ) T(n) = T(n -1) + O(1) T(n)=T(n−1)+O(1) --> T ( n ) = O ( n ) T(n) = O(n) T(n)=O(n)
int left_mid_max_recur(const vector<int>& a, int n){
L[n] = (n == 0) ? a[0] : max(L[n - 1] + a[n], a[n]);
return L[n]; // global variable L = vector<int>(a.size());
}
动态规划法求最大子数组: T ( n ) = O ( n ) T(n) = O(n) T(n)=O(n)