《计算之魂》阅读笔记 02
1.3 怎样寻找最好的算法
例题 1.3
- 给定一个实数序列,设计一个最有效的算法,找到一个总和最大的区间
- 如序列:1.5, −12.3, 3.2, −5.5, 23.2, 3.2, −1.4, −12.2, 34.2, 5.4, −7.8, 1.1, −4.9
- 总和最大的区间:从第5个数 23.2 到第10个数 5.4
- 另一种表述:寻找一只股票的有效增长期
方法一:三重循环
- 假设:
- 这个序列有 K 个数: a 1 , a 2 , . . . , a K \bm{a_{1}, a_{2}, ..., a_{K}} a1,a2,...,aK
- 选取区间为: [ p , q ] \bm{[p, q]} [p,q]
- 区间内的数字总和:
S
(
p
,
q
)
=
a
p
+
a
p
+
1
+
.
.
.
+
a
q
\bm{S(p,q)} = a_{p} + a_{p+1} + ... + a_{q}
S(p,q)=ap+ap+1+...+aq
- 其中:
- p ∈ [ 1 , K ] \bm{p} ∈ [1, K] p∈[1,K]
- q ∈ [ p , K ] \bm{q} ∈ [p, K] q∈[p,K]
- 每一对 [ p , q ] \bm{[p, q]} [p,q] 组合平均要做 K / 3 \pmb{K / 3} K/3K/3 次加法
- 综上,时间复杂度是 O ( n 3 ) \pmb{O(n^3)} O(n3)O(n3)
注意:这里第3点,每对组合平均要做的加法次数,原文给的是 K / 4 K / 4 K/4(有误),勘误如下:
证明:参考大牛思路,可用数学归纳法,如下:
方法二:二重循环
- 记录三个中间值:
- 从 p 到当前位置 q 的总和 S ( p , q ) \bm{S(p,q)} S(p,q)
- 从 p 到当前位置 q 所有总和中的最大值 Max
- 区间结束的位置 r
- 示例:
- 假设区间起点 p = 500,这时 S(500,500) = a500,Max = a500,r = 500
- 遇到 a501 后,根据 a501 是否大于0 决定 是否更新 q、Max 和 r
- 以此类推,继续往后扫描
- 分析:
-
p
∈
[
1
,
K
]
p ∈ [1, K]
p∈[1,K],有
K
种取法 -
∀
p
∈
[
1
,
K
]
\bm{\forall} p ∈ [1, K]
∀p∈[1,K],需要从头到尾试
K-p
次
- 综上,时间复杂度是 O ( n 2 ) \pmb{O(n^2)} O(n2)O(n2)
-
p
∈
[
1
,
K
]
p ∈ [1, K]
p∈[1,K],有
方法三:分而治之
- 方法:
- 将序列一分为二: [ 1 , K / 2 ] [1,K/2] [1,K/2], [ K / 2 , K ] [K/2, K] [K/2,K]
- 分别求两个子序列的总和最大区间 A、B
- 用 递归算法 求每个子序列的总和最大区间
- 结论:
-
如果 A、B 间没有间隔,且区间总和均为正整数,则整个序列的总和最大区间就是 [ p , q ] \bm{[p, q]} [p,q]
-
如果 A、B 间有间隔,假设 A = [ p 1 , q 1 ] A=[p_{1}, q_{1}] A=[p1,q1], B = [ p 2 , q 2 ] B=[p_{2}, q_{2}] B=[p2,q2],则整个序列的总和最大区间是 [ p 1 , q 1 ] \bm{[p_{1}, q_{1}]} [p1,q1]、 [ p 2 , q 2 ] \bm{[p_{2}, q_{2}]} [p2,q2] 和 [ p 1 , q 2 ] \bm{[p_{1}, q_{2}]} [p1,q2] 中最大的一个
证明:分情况讨论即可:
-
时间复杂度是 O ( n l o g n ) \bm{O(nlogn)} O(nlogn)
-
方法四:正反扫描
- 在序列中扫描找到第一个大于0的数,复杂度
O
(
n
)
O(n)
O(n)
如果所有数字非正(≤0),那么要找的区间就是 整个序列中最大的数
- 借鉴方法二,令 p = 1 p=1 p=1, q = 2 , 3 , . . . , K q=2,3,...,K q=2,3,...,K,计算 S ( 1 , q ) \bm{S(1,q)} S(1,q)、Maxf(前向最大)和 r
- 扫描到最后(
q
=
K
q=K
q=K),所保留的 Maxf 对应的 r 就是要找区间的右边界
如果 ∀ q ∈ [ 2 , K ] \bm{\forall} q ∈ [2, K] ∀q∈[2,K],都有 S ( 1 , q ) ≥ 0 S(1,q)≥0 S(1,q)≥0,情况会比较简单
- 反向扫描,同理计算出 Maxb(后向最大)和 l \bm{l} l(左边界)
- 我们使用例题1.3的数据,依次计算前向累积和后向累积值,结果如下:
从图表中我们易知:- Maxf = 39.3,对应 r = 10 r=10 r=10
- Maxb = 40.8,对应 l = 5 l=5 l=5
-
但如果 S ( 1 , q ) S(1,q) S(1,q) 在某处小于0,并且之后一直小于0,情况就变复杂了
比如我们将1.3的数据改动 2 个(8 和 9 改为 -62.2 和 44.2),如下表:
由图表知,Maxf 出现在 r = 6 r=6 r=6 的位置,Maxb 出现在 l = 9 l=9 l=9 的位置:- 右边界在左边界的左边,算法错误
- 原本 [9, 10] 之间元素和为 49.6,是真正的 Maxf
- 在累加了前 8 个元素之后和小于0,并一直小于0,因此没找到
- 对步骤2、3进行改进:
- 步骤2:先把左边界
p
p
p 固定在第一个大于0的位置,令
q
=
p
,
p
+
1
,
.
.
.
,
K
q=p,p+1,...,K
q=p,p+1,...,K,计算 Maxf 和 r
如果算到 S ( p , q ) < 0 S(p,q)<0 S(p,q)<0,从 q q q 开始反向计算 Maxb,此时可以确定从第1个数到第 q q q 个数的和最大区间 [ l 1 , r 1 ] [l_{1}, r_{1}] [l1,r1](这里 l 1 = p l_{1}=p l1=p)和 Max1
- 步骤3:从
q
+
1
q+1
q+1 开始向后扫描,重复步骤2,可能在算到某个
q
′
q'
q′ 时,又会出现
S
(
q
+
1
,
q
′
)
<
0
S(q+1,q')<0
S(q+1,q′)<0,以此可以得到第二个局部最大区间
[
l
2
,
r
2
]
[l_{2}, r_{2}]
[l2,r2] 和 Max2,接着确定从头开始到
q
′
q'
q′ 的和最大区间
比较 Max1、Max2 和 Max1 + Max2 + S ( l 1 , r 2 ) S(l_{1}, r_{2}) S(l1,r2),发现从头开始到 q ′ q' q′ 的和最大区间是 [ l 1 , r 1 ] [l_{1}, r_{1}] [l1,r1] 或 [ l 2 , r 2 ] [l_{2}, r_{2}] [l2,r2],每次保留更大的区间到中间变量 Max 和 [ l , r ] [l,r] [l,r] 中即可
- 接着,步骤4用步骤3的方法,向后扫描完整个序列,更新完 Max
- 最后,得到的局部和最大区间 [ l , r ] [l,r] [l,r],就是全局和最大区间
- 步骤2:先把左边界
p
p
p 固定在第一个大于0的位置,令
q
=
p
,
p
+
1
,
.
.
.
,
K
q=p,p+1,...,K
q=p,p+1,...,K,计算 Maxf 和 r
- 小结一下序列在累计求和时出现的两种情况,如下两图所示:
简单来说,就是 全局和最大区间 = Max {大于0的局部和最大区间}
- 综上,这个算法只需要 将整个序列扫描两遍(正反),时间复杂度为 O ( n ) \bm{O(n)} O(n)
【思考题 1.3.1】
【问】将例题1.3的线性复杂度算法写成伪代码。
【答】参考了网络大牛的做法,加上了一些自己的理解:
def maxSubArray(arr):
# 算到 S(p,q)<0 时,得到的最大局部区间和
sum_tmp = 0
# 当前最大总和初始化
sum_max = arr[0]
# 左边界初始化
left = 0
# 右边界初始化
right = 0
# 正向局部左边界初始化
left_tmp = 0
# 完整遍历序列
for i in range(len(arr)):
# 正向累加
sum_tmp += arr[i]
# 局部最大 > 当前最大
if sum_tmp > sum_max:
# 更新当前最大值
sum_max = sum_tmp
# 更新右边界值 r
right = i
# 更新左边界值 l(上一轮S<0的局部左边界值)
left = left_tmp
# 局部总和值小于0,跳过当前数(简化)
if sum_tmp < 0:
# 更新局部和
sum_tmp = 0
# 更新局部左边界值
left_tmp = i + 1
# 返回全局最大值和全局总和最大区间
return sum_max, arr[left:right + 1]
if __name__ == '__main__':
arr = [1.5, -12.3, 3.2, -5.5, 23.2, 3.2, -1.4, -62.2, 44.2, 5.4, -7.8, 1.1, -4.9]
# (49.6, [44.2, 5.4])
print(maxSubArray(arr))
结测试,例题1.3的两种情况均验证成功。
【思考题 1.3.2】
【问】在一个数组中寻找一个区间,使得区间内的数字之和等于某个事先给定的数字。
【答】参考了网络大牛的做法:
def subarraySum(arr, T):
'''
1. 用字典代替哈希表(当子序列和已经存在于字典中时,可直接跳过)
2. 循环计算子序列和 S(1, i) 时,可以寻找 S(1, i) - T 是否在字典中,
找到的key值满足:对字典中已存在的 S(1, j),j < i
'''
hash_dict = {}
temp_sum = 0
for i in range(0, len(arr), 1):
temp_sum += arr[i]
if temp_sum == T:
return (0, i)
if temp_sum - T in hash_dict.keys():
return (has_dict[temp_sum - T] + 1, i)
if temp_sum in hash_dict.keys():
continue
else:
hash_dict[temp_sum] = i
【思考题 1.3.3】
【问】在一个二维矩阵中,寻找一个矩形的区域,使其中的数字之和达到最大值。
【答】参考了网络大牛的做法:
def getMaxMatrix(matrix):
r1 = c1 = r2 = c2 = 0
max_sum_matrix = matrix[0][0]
for i in range(0, len(matrix), 1):
arr_list = [0] * len(matrix[0])
for j in range(i, len(matrix), 1):
# 将新的一行与原矩阵逐项累加,生成新的一维序列
arr_list = [arr_list[k] + matrix[j][k] for k in range(0, len(arr_list), 1)]
(temp_max_sum, temp_left, temp_right) = self.maxSubArray(arr_list)
if temp_max_sum > max_sum_matrix:
max_sum_matrix = temp_max_sum
c1 = temp_left
c2 = temp_right
r1 = i
r2 = j
return [r1, c1, r2, c2]
总结
- 在计算机科学领域,从业者要想提高解决问题的能力,就需要不断培养对计算机的感觉。阅读例题1.3的四种解法,我们可以总结出如下建立感觉的方法:
- 认识问题边界(至少扫描整个序列一次)
- 优化算法(少做无用功)
- 逆向思维(反向累加)
参考资料
- 《计算之魂》第一章
- https://zhuanlan.zhihu.com/p/476641538
- 大牛的聊天记录