算法分析:求解最大子段和

算法分析系列文章中的代码可被任何人无偿使用于任何场景且无需注明来源也不必在使用前征得本文作者同意。

算法分析系列文章旨在传播准确、完整、简洁、易懂、规范的代码实现,并传授基本的编程思想和良好的编码习惯与技巧。

若文章中的代码存在问题或逻辑错误,请通过邮件等形式(见文章结尾)告知于本文作者以便及时修正错误或改进代码。

算法系列文章不可避免地会参考和学习众多网友的成果,在行文风格、内容及求解思路上也会进行借鉴,如有侵权嫌疑,请联系本文作者。

PS:若为转载该文章,请务必注明来源,本站点欢迎大家转载。

问题描述

给定一个整数(正负数不限)序列 $a_1, a_2, a_3, …, a_n$ ,从该序列中选取任意相邻的一段求和(简称为「子段和」),求解该序列的最大子段和。注:若整个序列的所有元素均为负数,则其最大子段和固定为0。

例如,序列[64, -24, 88, -39, -54, 16]的最大子段和为128(= 64 + (-24) + 88)。

求解方案

穷举法

穷举法就是从 $a_0$ 开始依次计算 并取其中的最大值,再从 $a_1$ 开始依次计算 并取其中的最大值,以此往复,直到 $a_n$ 为止,并取每次计算过程中的最大值,得到的最终结果即为所求。

该穷举过程用代码实现即为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
typedef struct _SubseqSum {
int value; // 序列区间求和后的值
int start; // 求和区间的开始位置
int end; // 求和区间的结束位置
} SubseqSum;

SubseqSum max_subseq_sum_force(int seq[], int seq_len) {
SubseqSum max_sum = { .value = 0, .start = 0, .end = 0 };

int max_sum_value = 0;
for (int i = 0; i < seq_len; i++) {
for (int j = i; j < seq_len; j++) {
// 计算从i到j这个区间的和
int sum_value = 0;
for (int k = i; k <= j; k++) {
sum_value += seq[k];
}
// 当i到j的区间的和大于当前已记录的最大子段和时,更新该最大子段和为该区间的和
if (sum_value > max_sum_value) {
max_sum_value = sum_value;

// 更新最大子段和的结果及其求和区间
max_sum.value = max_sum_value;
max_sum.start = i;
max_sum.end = j;
}
}
}
return max_sum;
}

注意:

  • 这里定义了结构体SubseqSum用于同时记录子段和及其求和区间,可便于对最终结果进行人工复查以验证代码的正确性
  • forif ... else ...等分支中即使仅有一行代码,甚至没有代码(如,while (true) {}),也不要省略花括号({}),这是避免代码混乱、提升代码可读性和准确性的前提
  • 通过指针类型的参数来获取函数内部的过程数据(如,int max_subseq_sum(int seq[], int seq_len, int &begin, int &end) {...})的方式不是一种良好的编码习惯。虽然,许多编程语言的函数不支持返回多值,但通过结构体等方式可以更好地达到目的(甚至好于返回多值),最终的代码也会更易阅读和理解

本例用到了三层循环,其时间复杂度为 $O(n^3)$ 。但仔细分析后可以发现,在第三层循环中,从 $i$ 到 $j$ 区间的和会被重复计算多次,即,存在计算序列 ,而实际上, 已经被计算过了,没有必要再重复计算,若将其存放在变量 $tmp$ 中(即, ),则计算 的值,等效于计算 的值。

按照以上思路,可将上面的穷举实现改进为如下代码(时间复杂度为 $O(n^2)$ ):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
typedef struct _SubseqSum {
int value; // 序列区间求和后的值
int start; // 求和区间的开始位置
int end; // 求和区间的结束位置
} SubseqSum;

SubseqSum max_subseq_sum_force_adv(int seq[], int seq_len) {
SubseqSum max_sum = { .value = 0, .start = 0, .end = 0 };

int max_sum_value = 0;
for (int i = 0; i < seq_len; i++) {
// 通过sum_value记录从i到j-1这个区间的和,
// 当求解i到j区间的和时,其便等效于sum_value+seq[j]
int sum_value = 0;
for (int j = i; j < seq_len; j++) {
sum_value += seq[j];

if (sum_value > max_sum_value) {
max_sum_value = sum_value;

// 更新最大子段和的结果及其求和区间
max_sum.value = max_sum_value;
max_sum.start = i;
max_sum.end = j;
}
}
}
return max_sum;
}

分治法

分治法,即,把一个复杂的问题分成两个或更多的相同或相似的子问题,直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。(引用自「维基百科」)

通过分治法的思想,可以将序列 等分为两部分,即, (称为左子序列) 与 (称为右子序列) 两个子序列,再分别求解这两个子序列的最大子段和。最终原序列的最大子段和的求解便存在以下情况:

  • 原序列的最大子段和等于左子序列的最大子段和
  • 原序列的最大子段和等于右子序列的最大子段和
  • 原序列的最大子段和为 ,其中,

前两种情况通过递归可以得到结果,而对于第三种情况,可以从 $\frac{n}{2}$ 和 $\frac{n}{2}+1$ 开始分别向左右两边求和,即,定义如下表达式:

即为第三种情况的最优解。注意,为了准确传达出向左右两边推进求和的过程,这里对求和公式做了变换,让 $k$ 始终从中间位置(即 处)开始向 递减或向 递增。

根据以上分析可编写其实现代码为(时间复杂度为 $O(n\log n)$ ):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
typedef struct _SubseqSum {
int value; // 序列区间求和后的值
int start; // 求和区间的开始位置
int end; // 求和区间的结束位置
} SubseqSum;

int max(int num0, int num1) {
return num0 > num1 ? num0 : num1;
}

SubseqSum max_subseq_sum_divide(int seq[], int left, int right) {
if (left == right) {
return (SubseqSum) {
.value = max(0, seq[left]),
.start = left,
.end = left
};
}

int center = (left + right) / 2;
// 计算左边区间的最大子段和
SubseqSum left_max_sum = max_subseq_sum_divide(seq, left, center);
// 计算右边区间的最大子段和
SubseqSum right_max_sum = max_subseq_sum_divide(seq, center + 1, right);
// 计算从中间位置向左右区间的最大子段和
SubseqSum center_max_sum = max_subseq_sum_divide_for_center(seq, center, left, right);

// 三个部分的最大结果即为所求的最大子段和
SubseqSum max_sum = center_max_sum;
if(max_sum.value < left_max_sum.value) {
max_sum = left_max_sum;
}
if(max_sum.value < right_max_sum.value) {
max_sum = right_max_sum;
}
return max_sum;
}

// 从中间位置开始对该位置左右两边子段进行求和
SubseqSum max_subseq_sum_divide_for_center(int seq[], int center, int left, int right) {
SubseqSum max_sum = { .value = 0, .start = center, .end = center };

// [left, ..., center, center + 1, ..., right]
// <-- i j -->

// 先从center开始向左推进以计算左边子段求和的最大值:
// - i记录的是左边求和区间的左边界(右边界为center)
// - 只有求和结果(left_sum_value)大于0才会推进
int left_sum_value = 0;
int left_max_sum_value = 0;
for (int i = center; i >= left; i--) {
left_sum_value += seq[i];

if (left_sum_value > left_max_sum_value) {
left_max_sum_value = left_sum_value;
max_sum.start = i;
}
}
// 再从center+1开始向右推进以计算右边子段求和的最大值:
// - j记录的是右边求和区间的右边界(左边界为center+1)
// - 只有求和结果(right_sum_value)大于0才会推进
int right_sum_value = 0;
int right_max_sum_value = 0;
for(int j = center + 1; j <= right; j++) {
right_sum_value += seq[j];

if(right_sum_value > right_max_sum_value) {
right_max_sum_value = right_sum_value;
max_sum.end = j;
}
}

// 最后所求的子段和为左右两个子段的 最大求和值 之和
int max_sum_value = left_max_sum_value + right_max_sum_value;

max_sum.value = max_sum_value;
// 子段求和未向左推进(向左求和的结果依然为0)但向右推进(向右求和的结果大于0)了,
// 则表示求和区间应该从右边开始
if (left_max_sum_value == 0 && right_max_sum_value > 0) {
max_sum.start = center + 1;
}
// 子段求和未向右推进(向右求和的结果依然为0)但向左推进(向左求和的结果大于0)了,
// 则表示求和区间应该从左边开始
if (right_max_sum_value == 0 && left_max_sum_value > 0) {
max_sum.end = center;
}
// 而若向左/向右均没有推进,则保持原地不动
if (left_max_sum_value == 0 && right_max_sum_value == 0) {
max_sum.start = max_sum.end = center;
}

return max_sum;
}

注意:

  • 在实现代码中将上面提到的第三种情况提取出来以便对该特例进行独立分析,也避免了对前面的主过程的阅读和分析造成的干扰
  • max_subseq_sum_divide_for_center的最后需要对求和区间的起止位置进行修正,具体内容见代码注释

动态规划法

在应用该方法之前,先来看看其数学的推导过程。

假设存在序列 $a_1, a_2, a_3, …, a_n$ ,记 $b_j$ 表示该序列从 $1$ 到 $j$ 的区间内的最大子段和,则其可用如下公式表示:

也就是以下等式成立:

因此,求解整个序列的最大子段和 $F(n)$ 的数学公式即为:

也就是说,要求解整个序列的最大子段和,可以转化为计算从 $1$ 到 $n$ 的区间内的 $b_j$ ( $1 \leq j \leq n$ ) 的最大值。

而 $b_j$ 可以用递归表达式表示为:

但是,当 小于等于0时,无论 为正还是负,最终 都将小于 ,这时将有 成立,因此,最终 可表示为:

以上推导过程需要仔细阅读和分析,在完全掌握该推导过程后,便可很容易编写出对应的求解代码(时间复杂度为 $O(n)$ ),即:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
#define MAX_SEQ_LEN 1000
typedef struct _SubseqSum {
int value; // 序列区间求和后的值
int start; // 求和区间的开始位置
int end; // 求和区间的结束位置
} SubseqSum;

int max(int num0, int num1) {
return num0 > num1 ? num0 : num1;
}

SubseqSum max_subseq_sum_dynamic_programming(int seq[], int seq_len) {
// 求和序列:存放子段求和的中间结果,开始元素为传入序列的第0项
int seq_sum[MAX_SEQ_LEN] = { seq[0] };
SubseqSum max_sum = { .value = max(0, seq_sum[0]), .start = 0, .end = 0 };

int max_sum_value = 0;
int expected_sum_start = 0;
for(int j = 1; j < seq_len; j++) {
// 向左看,若前面已计算的子段和大于0,则加上当前项后,可能会得到更大的子段和,
// 即对应公式中的“b[j] = b[j-1] + a[j]”分支
if (seq_sum[j - 1] > 0) {
seq_sum[j] = seq_sum[j - 1] + seq[j];
}
// 而若前面已计算的子段和小于0,则丢弃该结果,从当前位置开始重新计算子段和,
// 即对应公式中的“b[j] = a[j]”分支
else {
seq_sum[j] = seq[j];
// 但新的子段和不一定大于当前已得到的最大子段和,
// 故,需临时存放该新子段的开始位置,待最大子段和被更新后再更新其所在的子段区间
expected_sum_start = j;
}

// 这里取公式中的“b[j]”的最大值
if (seq_sum[j] > max_sum_value) {
max_sum_value = seq_sum[j];

// 应用新的子段和,并更新该子段的开始和结束位置
max_sum.value = max_sum_value;
max_sum.start = expected_sum_start;
max_sum.end = j;
}
}
return max_sum;
}

参考

附录

以下为完整的各方案代码,并包含性能测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
#include <stdio.h>
#include <stdlib.h>
#include <sys/time.h> // for gettimeofday()
#include <time.h> // for time()

#define MAX_SEQ_LEN 1000
typedef struct _SubseqSum {
int value; // 序列区间求和后的值
int start; // 求和区间的开始位置
int end; // 求和区间的结束位置
} SubseqSum;

double current_timestamp();
int max(int num0, int num1);
void init_random_sequence(int seq[], int len);
void print_sequence(int seq[], int len);
int sum_subseq(int seq[], int start, int end);

SubseqSum max_subseq_sum_force(int seq[], int seq_len);
SubseqSum max_subseq_sum_force_adv(int seq[], int seq_len);
SubseqSum max_subseq_sum_divide(int seq[], int left, int right);
SubseqSum max_subseq_sum_divide_for_center(int seq[], int center, int left, int right);
SubseqSum max_subseq_sum_dynamic_programming(int seq[], int seq_len);

int main(int argc, char *argv[]) {
int seq_len = 50;
int seq[MAX_SEQ_LEN];
init_random_sequence(seq, seq_len);
// int seq[] = {-31, 95, 62, -45, 13, 31, -77
// , 22, 94, -65, -67, 50, -66, 28
// , -98, -34, -97, -66, -84, 87, 32
// , -28, 43, -75, -64, 24, 88, 39
// , -54, -16, 89, 82, -81, 45, 61
// , -62, -51, -4, -41, -32, -21, -37
// , 32, 63, -44, -39, -30, -19, 71
// , 77};
// int seq[] = {-5, 11, -4, 13, -4, -2};
// int seq[] = {0, 0, 0, 0, 0, 0}; // 不同方法得到的求和区间会不同
// int seq_len = sizeof(seq) / sizeof(seq[0]);
SubseqSum max_sum;
double start_time, end_time;

print_sequence(seq, seq_len);
printf("\n以上序列的最大子段和为:\n");

start_time = current_timestamp();
max_sum = max_subseq_sum_force(seq, seq_len);
end_time = current_timestamp();
printf("- 穷举算法 : %d (求和区间: [%d, %d] +=>> %d), 耗时: %f毫秒\n"
, max_sum.value
, max_sum.start, max_sum.end
, sum_subseq(seq, max_sum.start, max_sum.end)
, end_time - start_time);

start_time = current_timestamp();
max_sum = max_subseq_sum_force_adv(seq, seq_len);
end_time = current_timestamp();
printf("- 穷举算法(改进版): %d (求和区间: [%d, %d] +=>> %d), 耗时: %f毫秒\n"
, max_sum.value
, max_sum.start, max_sum.end
, sum_subseq(seq, max_sum.start, max_sum.end)
, end_time - start_time);

start_time = current_timestamp();
max_sum = max_subseq_sum_divide(seq, 0, seq_len - 1);
end_time = current_timestamp();
printf("- 分治算法 : %d (求和区间: [%d, %d] +=>> %d), 耗时: %f毫秒\n"
, max_sum.value
, max_sum.start, max_sum.end
, sum_subseq(seq, max_sum.start, max_sum.end)
, end_time - start_time);

start_time = current_timestamp();
max_sum = max_subseq_sum_dynamic_programming(seq, seq_len);
end_time = current_timestamp();
printf("- 动态规划算法 : %d (求和区间: [%d, %d] +=>> %d), 耗时: %f毫秒\n"
, max_sum.value
, max_sum.start, max_sum.end
, sum_subseq(seq, max_sum.start, max_sum.end)
, end_time - start_time);

return 0;
}

// 穷举(蛮力)法求解
SubseqSum max_subseq_sum_force(int seq[], int seq_len) {
SubseqSum max_sum = { .value = 0, .start = 0, .end = 0 };

int max_sum_value = 0;
for (int i = 0; i < seq_len; i++) {
for (int j = i; j < seq_len; j++) {
// 计算从i到j这个区间的和
int sum_value = 0;
for (int k = i; k <= j; k++) {
sum_value += seq[k];
}
// 当i到j的区间的和大于当前已记录的最大子段和时,更新该最大子段和为该区间的和
if (sum_value > max_sum_value) {
max_sum_value = sum_value;

// 更新最大子段和的结果及其求和区间
max_sum.value = max_sum_value;
max_sum.start = i;
max_sum.end = j;
}
}
}
return max_sum;
}

// 穷举法(改进版)求解
SubseqSum max_subseq_sum_force_adv(int seq[], int seq_len) {
SubseqSum max_sum = { .value = 0, .start = 0, .end = 0 };

int max_sum_value = 0;
for (int i = 0; i < seq_len; i++) {
// 通过sum_value记录从i到j-1这个区间的和,
// 当求解i到j区间的和时,其便等效于sum_value+seq[j]
int sum_value = 0;
for (int j = i; j < seq_len; j++) {
sum_value += seq[j];

if (sum_value > max_sum_value) {
max_sum_value = sum_value;

// 更新最大子段和的结果及其求和区间
max_sum.value = max_sum_value;
max_sum.start = i;
max_sum.end = j;
}
}
}
return max_sum;
}

// 分治法求解
SubseqSum max_subseq_sum_divide(int seq[], int left, int right) {
if (left == right) {
return (SubseqSum) {
.value = max(0, seq[left]),
.start = left,
.end = left
};
}

int center = (left + right) / 2;
// 计算左边区间的最大子段和
SubseqSum left_max_sum = max_subseq_sum_divide(seq, left, center);
// 计算右边区间的最大子段和
SubseqSum right_max_sum = max_subseq_sum_divide(seq, center + 1, right);
// 计算从中间位置向左右区间的最大子段和
SubseqSum center_max_sum = max_subseq_sum_divide_for_center(seq, center, left, right);

// 三个部分的最大结果即为所求的最大子段和
SubseqSum max_sum = center_max_sum;
if(max_sum.value < left_max_sum.value) {
max_sum = left_max_sum;
}
if(max_sum.value < right_max_sum.value) {
max_sum = right_max_sum;
}
return max_sum;
}

// 分治法求解:从中间位置开始对该位置左右两边子段进行求和
SubseqSum max_subseq_sum_divide_for_center(int seq[], int center, int left, int right) {
SubseqSum max_sum = { .value = 0, .start = center, .end = center };

// [left, ..., center, center + 1, ..., right]
// <-- i j -->

// 先从center开始向左推进以计算左边子段求和的最大值:
// - i记录的是左边求和区间的左边界(右边界为center)
// - 只有求和结果(left_sum_value)大于0才会推进
int left_sum_value = 0;
int left_max_sum_value = 0;
for (int i = center; i >= left; i--) {
left_sum_value += seq[i];

if (left_sum_value > left_max_sum_value) {
left_max_sum_value = left_sum_value;
max_sum.start = i;
}
}
// 再从center+1开始向右推进以计算右边子段求和的最大值:
// - j记录的是右边求和区间的右边界(左边界为center+1)
// - 只有求和结果(right_sum_value)大于0才会推进
int right_sum_value = 0;
int right_max_sum_value = 0;
for(int j = center + 1; j <= right; j++) {
right_sum_value += seq[j];

if(right_sum_value > right_max_sum_value) {
right_max_sum_value = right_sum_value;
max_sum.end = j;
}
}

// 最后所求的子段和为左右两个子段的 最大求和值 之和
int max_sum_value = left_max_sum_value + right_max_sum_value;

max_sum.value = max_sum_value;
// 子段求和未向左推进(向左求和的结果依然为0)但向右推进(向右求和的结果大于0)了,
// 则表示求和区间应该从右边开始
if (left_max_sum_value == 0 && right_max_sum_value > 0) {
max_sum.start = center + 1;
}
// 子段求和未向右推进(向右求和的结果依然为0)但向左推进(向左求和的结果大于0)了,
// 则表示求和区间应该从左边开始
if (right_max_sum_value == 0 && left_max_sum_value > 0) {
max_sum.end = center;
}
// 而若向左/向右均没有推进,则保持原地不动
if (left_max_sum_value == 0 && right_max_sum_value == 0) {
max_sum.start = max_sum.end = center;
}

return max_sum;
}

// 动态规划法求解
SubseqSum max_subseq_sum_dynamic_programming(int seq[], int seq_len) {
// 求和序列:存放子段求和的中间结果,开始元素为传入序列的第0项
int seq_sum[MAX_SEQ_LEN] = { seq[0] };
SubseqSum max_sum = { .value = max(0, seq_sum[0]), .start = 0, .end = 0 };

int max_sum_value = 0;
int expected_sum_start = 0;
for(int j = 1; j < seq_len; j++) {
// 向左看,若前面已计算的子段和大于0,则加上当前项后,可能会得到更大的子段和,
// 即对应公式中的“b[j] = b[j-1] + a[j]”分支
if (seq_sum[j - 1] > 0) {
seq_sum[j] = seq_sum[j - 1] + seq[j];
}
// 而若前面已计算的子段和小于0,则丢弃该结果,从当前位置开始重新计算子段和,
// 即对应公式中的“b[j] = a[j]”分支
else {
seq_sum[j] = seq[j];
// 但新的子段和不一定大于当前已得到的最大子段和,
// 故,需临时存放该新子段的开始位置,待最大子段和被更新后再更新其所在的子段区间
expected_sum_start = j;
}

// 这里取公式中的“b[j]”的最大值
if (seq_sum[j] > max_sum_value) {
max_sum_value = seq_sum[j];

// 应用新的子段和,并更新该子段的开始和结束位置
max_sum.value = max_sum_value;
max_sum.start = expected_sum_start;
max_sum.end = j;
}
}
return max_sum;
}

int max(int num0, int num1) {
return num0 > num1 ? num0 : num1;
}

// 获取当前系统时间的毫秒值
double current_timestamp() {
struct timeval te;
gettimeofday(&te, NULL);

double msec = te.tv_sec * 1000.0 + (te.tv_usec / 1000.0);
return msec;
}

void init_random_sequence(int seq[], int len) {
// https://www.geeksforgeeks.org/rand-and-srand-in-ccpp/
srand(time(0));

for (int i = 0; i < len; i++) {
// 取0-100之间的数并随机产生正负
seq[i] = (int) (rand() * 1.0 / RAND_MAX * 100) * (rand() % 2 == 0 ? 1 : -1);
}
}

void print_sequence(int seq[], int len) {
int columns = 10;
for (int i = 0; i < len; i++) {
printf("%3d: %3d, ", i, seq[i]);

if ((i + 1) % columns == 0 && i < len - 1) {
printf("\n");
}
}
}

int sum_subseq(int seq[], int start, int end) {
int sum = 0;
for (int i = (start < end ? start : end); i <= (end > start ? end : start); i++) {
sum += seq[i];
}
return sum;
}
文章作者: flytreeleft
文章链接: https://flytreeleft.github.io/algorithm-calculating-maximum-interval-sum/
版权声明: 本博客所有文章除特别声明外,均采用 知识共享署名 4.0 国际许可协议 许可协议。转载请注明来自 flytreeleft's Blog