《计算之魂》Task2
1. 问题描述
总和最大区间问题
给定一个实数序列,设计一个最有效的算法,找到一个总和最大的区间。
比如在下面的序列中:
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)。
《计算之魂》 书中给出的应用实例:寻找一只股票最长的有效增长期。
研究股票投资的人都想了解一只股票最长的有效增长期是哪一个时间段,即从哪天开始买进到哪天卖出的收益最大。
2. 解决方法
假设这个序列有K
个数,依次是
a
1
,
a
2
,
…
,
a
K
a_1,a_2,…,a_K
a1,a2,…,aK。
假定区间起始的数字序号为p
,结束的数字序号为q
,这些数字的总和为S(p,q)
,则
S
(
p
,
q
)
=
a
p
+
a
p
+
1
+
…
+
a
q
S(p,q)=a_p+a_{p+1}+…+a_q
S(p,q)=ap+ap+1+…+aq
方法 | 时间复杂度 |
---|---|
三重循环 | o ( n 3 ) o(n^3) o(n3) |
两重循环 | o ( n 2 ) o(n^2) o(n2) |
分治算法 | o ( n log n ) o(n \log n) o(nlogn) |
正、反两遍扫描 | o ( n ) o(n ) o(n) |
2.1 方法1:三重循环
做一次三重循环
- 第一重循环:
p :1 -> K
- 第二重循环:
q :p -> K
- 第三重循环:前两次循环产生
O
(
K
2
)
O(K^2)
O(K2)种组合;每一种组合中,计算
S(p,q)
平均要做K/3
次加法
时间复杂度: o ( n 3 ) o(n^3) o(n3)
扩展:前两次循环产生 O ( K 2 ) O(K^2) O(K2)种组合;每一种组合中,计算
S(p,q)
平均要做K/3
次加法
(问题来源于群内讨论,给出求解步骤)
解:令 S ′ ( i , K ) S'(i,K) S′(i,K)表示 p = i p=i p=i时, q q q从 i i i一直到 K K K的加法次数,则
S ′ ( 1 , K ) = 0 + 1 + ⋯ + ( K − 2 ) + ( K − 1 ) = ∑ i = 0 K − 1 i S ′ ( 2 , K ) = 0 + 1 + ⋯ + ( K − 2 ) = ∑ i = 0 K − 2 i ⋮ S ′ ( K − 1 , K ) = 0 + 1 = ∑ i = 0 1 i S ′ ( K , K ) = 0 = ∑ i = 0 0 i \begin{align*} S'(1, K) &= 0 + 1 + \cdots + (K-2) + (K-1) &= \sum _ {i=0} ^ {K-1} i \\ S'(2, K) &= 0 + 1 + \cdots + (K-2) = \sum _ {i=0} ^ {K-2} i \\ \vdots \\ S'(K-1, K) &= 0 + 1 = \sum _ {i=0} ^ {1} i \\ S'(K, K) &= 0 = \sum _ {i=0} ^ {0} i \end{align*} S′(1,K)S′(2,K)⋮S′(K−1,K)S′(K,K)=0+1+⋯+(K−2)+(K−1)=0+1+⋯+(K−2)=i=0∑K−2i=0+1=i=0∑1i=0=i=0∑0i=i=0∑K−1i加法总次数为 S = S ′ ( 1 , K ) + S ′ ( 2 , K ) + ⋯ + S ′ ( K , K ) = ∑ j = 0 K − 1 ∑ i = 0 j i S =\displaystyle S'(1, K) + S'(2, K)+ \cdots +S'(K, K)=\sum _ {j=0} ^ {K-1} \sum _ {i=0} ^ {j} i S=S′(1,K)+S′(2,K)+⋯+S′(K,K)=j=0∑K−1i=0∑ji
求和总次数为 C = 1 + 2 + ⋯ + K = ∑ i = 1 K i C = \displaystyle 1 + 2 + \cdots + K = \sum _ {i=1} ^ {K} i C=1+2+⋯+K=i=1∑Ki
计算平均加法次数:
S C = ∑ j = 0 K − 1 ∑ i = 0 j i ∑ i = 1 K i = ∑ j = 0 K − 1 1 2 ( 1 + j ) j ∑ i = 1 K i = 1 2 ∑ j = 0 K − 1 j 2 + ∑ j = 0 K − 1 j ∑ i = 1 K i = 1 2 1 6 K ( K − 1 ) ( 2 K − 1 ) + 1 2 K ( K − 1 ) 1 2 K ( K + 1 ) ≈ K 3 \begin{align*} \frac{S}{C} = \frac{\displaystyle \sum _ {j=0} ^ {K-1}\sum _ {i=0} ^ {j} i}{\displaystyle \sum _ {i=1} ^ {K} i} =\frac{\displaystyle \sum _ {j=0} ^ {K-1}\frac{1}{2}(1 + j)j}{\displaystyle \sum _ {i=1} ^ {K} i} =\frac{1}{2} \frac{\displaystyle \sum _ {j=0} ^ {K-1}j^2 + \sum _ {j=0} ^ {K-1} j }{\displaystyle \sum _ {i=1} ^ {K} i}\\ =\frac{1}{2} \frac{\displaystyle \frac{1}{6}K(K -1)(2K -1) + \frac{1}{2}K(K -1) }{\displaystyle \frac{1}{2}K(K +1)} \approx \frac{K}{3} \end{align*} CS=i=1∑Kij=0∑K−1i=0∑ji=i=1∑Kij=0∑K−121(1+j)j=21i=1∑Kij=0∑K−1j2+j=0∑K−1j=2121K(K+1)61K(K−1)(2K−1)+21K(K−1)≈3K
2.2 方法2:两重循环
区间的起点定在了位置p
后,如果已经计算了从p
到q
之间的数字的总和S(p,q)
,计算从p
到q+1
之间的数字的总和S(p,q+1)
时,只需要在原来的基础上再做一次加法。无需占用额外的存储空间来保留所有的中间结果S(p,q)
。只需要记录这样三个中间值。
- 第一个值是从
p
开始到当前位置q
为止的总和S(p,q)
,因为我们接下来计算S(p,q+1)
时要用到它。 - 第二个值则是从
p
开始到当前位置q
为止所有总和中最大的那个值,我们假定为Max
。有了这个值之后,如果S(p,q+1)≤Max
,则Max维持不变;如果S(p,q+1)>Max
,则要更新Max
,当然,我们也要记录下来Max
是在区间[p,q+1]
取得的。 - 第三个要记录的值就是区间结束的位置,我们不妨以
r
来表示。如果Max
的值更新了,相应的区间结束位置也要更新为q+1
。
时间复杂度: o ( n 2 ) o(n^2) o(n2)
一个具体的例子。
假定区间的起始点是p=500,这时
S
(
500
,
500
)
=
a
500
,
M
a
x
=
a
500
,
r
=
500
S(500,500)=a_{500},Max=a_{500},r=500
S(500,500)=a500,Max=a500,r=500接下来,遇到了第501个数字,
- 若
a
501
>
0
a_{501}>0
a501>0,显然
S
(
500
,
501
)
>
S
(
500
,
500
)
S(500,501)>S(500,500)
S(500,501)>S(500,500),
记当前最大区间总和 M a x = S ( 500 , 501 ) , r = 501 Max=S(500,501),r=501 Max=S(500,501),r=501; - 若 a 501 ≤ 0 a_{501}≤0 a501≤0,最大的区间总和依然是Max=S(500,500),不需要做任何改变。
再往后,遇到第502个数字时,我们只需算出S(500,502),如果S(500,502)>Max,则更新Max,并且记录下r=502,否则维持原来的Max和r,然后继续往后扫描。
2.3 方法3:分治算法
分治算法
将序列一分为二,分成从1到
K
2
\frac{K}{2}
2K( K 是奇数是为
K
−
1
2
\frac{K-1}{2}
2K−1),以及从
K
2
+
1
\frac{K}{2} + 1
2K+1到 K 两个子序列。
这两个子序列分别求它们的总和最大区间。接下来有两种情况。
-
前后两个子序列的总和最大区间中间没有间隔,也就是说,前一个子序列的总和最大区间是 [ p , K 2 ] [p,\frac{K}{2}] [p,2K],后一个总和最大区间恰好是 [ K 2 + 1 , q ] [\frac{K}{2} + 1,q] [2K+1,q]。如果两个区间各自的和均为正整数,这时,整个序列总和最大区间就是 [ p , q ] [p, q] [p,q];否则,就选取两个子序列的总和最大区间中大的一个。
-
前后两个子序列的总和最大区间中间有间隔,我们假定这两个子序列的总和最大区间分别是 [ p 1 , q 1 ] [p_1,q_1] [p1,q1]和 [ p 2 , q 2 ] [p_2,q_2] [p2,q2]。这时,整个序列的总和最大区间是下面三者中最大的那一个:
(1) [ p 1 , q 1 ] [p_1,q_1] [p1,q1];
(2) [ p 2 , q 2 ] [p_2,q_2] [p2,q2];
(3) [ p 1 , q 2 ] [p_1,q_2] [p1,q2]。
上述三个区间的总和,前两个是已经计算出的,第三个其实是对从 q 1 + 1 q_1+1 q1+1 到 p 2 − 1 p_2-1 p2−1 间的数字求和,复杂度为 O ( K ) O(K) O(K)。有了上面三个值,挑出最大的一个即可。
讨论:分析如下。
另一个值得思考的问题是,在[p1,q2]区间内出现跨区域的最大总和区间问题,好像没有出现在上述情况下(问题源于交流群)。
时间复杂度: o ( n log n ) o(n \log n) o(nlogn)
2.4 方法4:正、反两遍扫描
2.4.1 通常情况下
方法2中先设定区间的左边界p,此条件下确定总和最大区间的右边界q。无形中已经找到了总和最大区间的右边界。我们从这个想法出发,来寻找一下线性复杂度,即O(K)的算法,步骤如下。
- 先在序列中扫描找到第一个大于零的数,假定这个数不存在(即所有的数字非零即负),那么整个序列中最大的那个数就是所要找的区间。这时算法的复杂度是O(K)。
- 左边界固定在第一个数,然后让q=2,3,…,K,计算S(1,q),以及到目前为止的最大值Maxf和达到最大值的右边界r。
- 如果对于所有的q,都有S(1,q)≥0,或者存在某个q0,当q>q0,上述条件满足,这个情况比较简单。当扫描到最后,即q=K时,所保留的那个Maxf所对应的r就是我们要找的区间的右边界。
- 反向扫描,得到左边界。
如下表1.1
从前往后一步步累加计算一遍。Maxf=39.3,r=10
(右边界)
从后往前计算后向累计之和,用同样的方法,Maxb=40.8,l=5
(左边界)
表1.1 序列中的元素、前向累计之和和后向累计之和
图1.2 序列中元素的值、前向累计之和以及后向累计之和
如图1.2所示,总和最大的区间就是[l,r] =[5, 10]。
2.4.2 S(1,q)从某位置开始一直小于0
在这个问题中,如果S(1,q)在某个地方小于零,然后就一直小于零,这个事情就变得比较麻烦了。比如我们将上面的那组数据改动两个,如表1.2所示,这时如果我们直接采用前面步骤3的方法就会出现问题。
表1.2 改动后的元素、前向累计之和和后向累计之和
从表1.2中可以看出,从前往后累加最大值出现在r=6的位置,而反过来从后往前累加,最大值出现在l=9的位置。右边界反而在左边界的左边。上述算法显然要出错。造成这个问题的原因是从一开始累加的总和在遇到第8个元素时下跌到零以下,然后一直在零以下。这样一来,原本区间[9, 10]之间的元素之和为49.6,它应该是总和最大区间,但是在累加了前8个元素之后和依然小于零,因此我们找不到,如图1.3所示。
图1.3 前向累计之和在某个位置之后就一直小于零的情况,其峰值在后向累计之和峰值之前
为了解决这个问题,我们需要对步骤2和步骤3稍作改进。
- 我们先把左边界固定在第一个大于零的位置,假设为p,然后让q=p,p+1,…,K,计算S(p,q),以及到目前为止的最大值Max和达到最大值的右边界r。如果我们算到某一步时,发现S(p,q)<0,这时,我们需要从位置q开始,反向计算Maxb,并且可以确定从第1个数到第q个数之间和最大的区间,我们假定它为[l1,r1],这个区间的和为Max1。
- 我们从q+1开始往后扫描,重复上述过程。先是找到第一个大于0的元素,从那里开始做累加操作,可能在遇到某个q′时,又出现S(q+1,q)<0的情况了,这时我们得到第二个局部和最大区间[l2,r2],相应的区间之和为Max2。
- 采用与步骤3同样的方法,不断往后扫描整个序列,得到一个个局部和最大的区间[li,rr]和相应的部分和Maxi,然后比较Maxi和Max,决定是否更新Max。
最后,这样得到的局部和最大区间[l,r],就是整个序列的总和最大区间。
3. 小结
- 问题的边界:有助于对最优算法的时间复杂度有一定的判断
- 检查算法是否存在无用功:精炼思维,优化算法
- 逆向思维
4. 思考题1.3
该部分思考题均学习自交流群内大佬 (ID:胡锐锋)分享学习视频
共读《计算之魂》交流分享视频
Q1
Q1: 将上述例题的线性复杂度算法写成伪代码
答:
def maxSubArray(nums: list) -> (int, list):
# 区间和
sub_sum = 0
# 整个区间最大值
max_sum = 0
# 左边界、有边界
left = 0
right = 0
# 第一个大于0的位置
p = 0
# (1)扫描找到第一个大于0的数
for i in range(len(nums)):
if nums[i] > 0:
p = i
break
for q in range(p, len(nums)):
# 计算从0到i的和
sub_sum += nums[q]
# (4) 比较局部最大和
if sub_sum > max_sum:# 更新 max
max_sum = sub_sum# 记录左右边界
right = q
left = p# (2) 当 s<0 时
if sub_sum < 0:
sub_sum = 0# 从 q+1 开始往后扫描
p = q + 1
return max_sum, nums[left: right + 1]
if __name__ == '__main__':
assert maxSubArray([-1, -2 ,1, -3, 4, -1, 2, 1, -5, 4]) == (6, [4, -1, 2, 1])
assert maxSubArray([1]) == (1, [1])
assert maxSubArray([5, 4, -1, 7, 8]) == (23, [5, 4, -1, 7, 8])
Q2
Q2: 在一个数组中寻找一个区间,使得区间内的数字之和等于某个事先给定的数字
答:
def subarraySum(target: int, nums: list):
# 定义哈希表{key=sub_sum_value, value=right}
hash_dict = {}
# 区间和
sub_sum = 0
for q in range(len(nums)):
sub_sum += nums[q]
# 情况1: S(1,q) == target
if sub_sum == target:
return nums[0:q +1]
# 情况2: S(p,q) == target
# S(p, q) = S(1, q) - S(1, p-1)
# S(1, p-1) = S(1,q) - target
if sub_sum - target in hash_dict.keys():
p = hash_dict[sub_sum - target]
return nums[p+1 : q+1]
if sub_sum not in hash_dict.keys():
hash_dict[sub_sum] = q
if __name__ == '__main__':
assert subarraySum(6, [-1, -2 ,1, -3, 4, -1, 2, 1, -5, 4]) == [4, -1, 2, 1]
assert subarraySum(1, [1]) == [1]
assert subarraySum(23, [5, 4, -1, 7, 8]) == [5, 4, -1, 7, 8]
Q3
Q3: 在一个二维矩阵中,寻找一个矩形的区域,使其中的数字之和达到最大值
def get_max_matrix(matrix):
n = len(matrix)
m = len(matrix[0])
b = [0] * m
max_sum = - float('inf')
bert_r1, best_c1 = 0, 0
r1, c1, r2, c2 =0, 0, 0, 0
for i in range(n):
# 变更子矩阵的时候,将b数组清零
for t in range(m):
b[t] = 0
for j in range(i, n):
sub_sum = 0
for k in range(m):
b[k] += matrix[j][k]
# 计算最大子数组
if sub_sum > 0:
sub_sum += b[k]
else:
sub_sum = b[k]
best_r1 = i
best_c1 = k
if sub_sum > max_sum:
max_sum = sub_sum
# 更新值
r1 = best_r1
c1 = best_c1
r2 = j
c2 = k
return max_sum, [r1, c1, r2, c2]
if __name__ == '__main__':
matrix = [[-1, 0], [0, -1]]
max_sum, ans = get_max_matrix(matrix)
print(max_sum)
print(ans)