分治算法、贪心算法和动态规划的典型例题

作者注:本文中代码均在 C++14 (GCC 9) O2 编译环境下编译通过。

Part 1 - 分治算法

例1 - 洛谷P1908 逆序对

Description

猫猫 TOM 和小老鼠 JERRY 最近又较量上了,但是毕竟都是成年人,他们已经不喜欢再玩那种你追我赶的游戏,现在他们喜欢玩统计。

最近,TOM 老猫查阅到一个人类称之为“逆序对”的东西,这东西是这样定义的:对于给定的一段正整数序列,逆序对就是序列中 a i > a j a_i>a_j ai>aj i < j i<j i<j 的有序对。知道这概念后,他们就比赛谁先算出给定的一段正整数序列中逆序对的数目。注意序列中可能有重复数字。

Input

第一行,一个数 n n n,表示序列中有 n n n 个数。

第二行 n n n 个数,表示给定的序列。序列中每个数字不超过 1 0 9 10^9 109

Output

输出序列中逆序对的数目。

Sample Input
6
5 4 2 6 3 1
Sample Output
11
Accepted Code 1

归并排序 (merge sort) 是利用归并的思想实现的排序方法,该算法采用经典的“分而治之”的策略,主要分为递归拆分子序列合并相邻有序子序列两个步骤。归并排序是一种稳定的排序算法,其时间复杂度为 O ( n log ⁡ n ) O(n\log n) O(nlogn)

对于本题,如果我们想要将一个序列排成升序序列,那么每次拆分后再合并时,左右两个子序列都是升序的,因此只需要统计右侧的序列中的每个数分别会与左侧的序列产生多少逆序对。

另外需要注意的一点是,本题的结果可能会超出int的范围,因此需要开long long类型的全局变量用来存放结果。

Language: C++

#include <iostream>

using namespace std;

const int N = 500005;
long long ans;
int a[N];
int t[N];

void merge_sort(int b, int e) {
	if (b == e) {
		return;
	}
	int mid = b + ((e - b) >> 1);
	int i = b;
	int j = mid + 1;
	int k = b;
	merge_sort(b, mid);
	merge_sort(mid + 1, e);
	while (i <= mid && j <= e) {
		if (a[i] <= a[j]) {
			t[k++] = a[i++];
		} else {
			t[k++] = a[j++];
			ans += mid - i + 1;
		}
	}
	while (i <= mid) {
		t[k++] = a[i++];
	}
	while (j <= e) {
		t[k++] = a[j++];
	}
	for (int m = b; m <= e; m++) {
		a[m] = t[m];
	}
}

int main() {
	int n;
	cin >> n;
	for (int i = 0; i < n; i++) {
		cin >> a[i];
	}
	merge_sort(0, n - 1);
	cout << ans << endl;
	return 0;
}
Accepted Code 2

本题还可以用数据离散化+树状数组的解法解决。

根据值来建立树状数组,在循环到第 i i i 项时,前 i − 1 i-1 i1 项都已加入树状数组。树状数组内比 a i a_i ai 大的元素都会与 a i a_i ai 构成逆序对,逆序对数量为 i − q u e r y ( a i ) i-query(a_i) iquery(ai),其中 q u e r y ( a i ) query(a_i) query(ai) 代表在树状数组内询问 1 1 1 a i a_i ai 项的前缀和。

观察数据范围,容易发现根据值来建立树状数组的空间不够,因此可以考虑对数据离散化。我们只需要数据之间的相对大小关系,因此可以将数据排序,再用区间 [ 1 , n ] [1,n] [1,n] 上的数表示原始数据的相对大小关系,最后对这个新的序列建立树状数组即可。

题目描述中最后一句说明序列中可能有重复数字,不处理相等的元素可能会导致求解过程的错误。当有与 a i a_i ai 相等的元素在 a i a_i ai 前被加入树状数组且其相对大小标记更大的时候,就会误将两个相等的数判定为逆序对。我们在排序的时候,需要将先出现的数字的标记也设置为较小的,可以考虑在输入数据时利用结构体数组存储,结构体成员变量分别为原始数据以及其对应的原始下标。在排序时可以指定sort函数的cmp参数,将原始数据作为第一关键字,原始下标作为第二关键字,对结构体数组升序排序。

Language: C++

#include <algorithm>
#include <iostream>

using namespace std;

const int N = 500005;
int n;
struct node {
	int idx;
	int val;
} a[N];
int tree[N];
int rank_[N];
long long ans;

inline void insert(int p, int d) {
	for (; p <= n; p += p & -p) {
		tree[p] += d;
	}
}

inline int query(int p) {
	int ret = 0;
	for (; p; p -= p & -p) {
		ret += tree[p];
	}
	return ret;
}

int main() {
	cin >> n;
	for (int i = 1; i <= n; i++) {
		cin >> a[i].val;
		a[i].idx = i;
	}
	sort(a + 1, a + n + 1, [](const node& o1, const node& o2) {
		return o1.val == o2.val ? o1.idx < o2.idx : o1.val < o2.val;
	});
	for (int i = 1; i <= n; i++) {
		rank_[a[i].idx] = i;
	}
	for (int i = 1; i <= n; i++) {
		insert(rank_[i], 1);
		ans += i - query(rank_[i]);
	}
	cout << ans << endl;
	return 0;
}

Part 2 - 贪心算法

例2.1 - Codeforces 1339C Powered Addition

Description

You have an array a a a of length n n n. For every positive integer x x x you are going to perform the following operation during the x x x-th second:

  • Select some distinct indices i 1 , i 2 , ⋯   , i k i_1,i_2,\cdots,i_k i1,i2,,ik which are between 1 1 1 and n n n inclusive, and add 2 x − 1 2^{x−1} 2x1 to each corresponding position of a a a. Formally, a i j : = a i j + 2 x − 1 a_{ij}:=a_{ij}+2^{x−1} aij:=aij+2x1 for j = 1 , 2 , ⋯   , k j=1,2,\cdots,k j=1,2,,k. Note that you are allowed to not select any indices at all.

You have to make a a a nondecreasing as fast as possible. Find the smallest number T T T such that you can make the array nondecreasing after at most T T T seconds.

Array a a a is nondecreasing if and only if a 1 ≤ a 2 ≤ ⋯ ≤ a n a_1 \leq a_2 \leq \cdots \leq a_n a1a2an.

You have to answer t t t independent test cases.

Input

The first line contains a single integer t   ( 1 ≤ t ≤ 1 0 4 ) t\ (1 \leq t \leq 10^4) t (1t104) — the number of test cases.

The first line of each test case contains single integer n   ( 1 ≤ n ≤ 1 0 5 ) n\ (1 \leq n \leq 10^5) n (1n105) — the length of array a a a. It is guaranteed that the sum of values of n n n over all test cases in the input does not exceed 1 0 5 10^5 105.

The second line of each test case contains n n n integers a 1 , a 2 , ⋯   , a n   ( − 1 0 9 ≤ a i ≤ 1 0 9 ) a_1,a_2,\cdots,a_n\ (−10^9 \leq a_i \leq 10^9) a1,a2,,an (109ai109).

Output

For each test case, print the minimum number of seconds in which you can make a a a nondecreasing.

Sample Input
3
4
1 7 6 5
5
1 2 3 4 5
2
0 -4
Sample Output
2
0
3
Accepted Code

题目大意:有一个长度为 n n n 的数组 a a a,在第 x x x 秒内可以选择介于 1 1 1 n n n 之间的索引 i 1 , i 2 , ⋯   , i k i_1, i_2, \cdots, i_k i1,i2,,ik,然后在数组 a a a 的对应位置加上 2 x − 1 2^{x-1} 2x1,也可以不选择任何索引。找出最短的时间 T T T,使得执行操作后数组 a a a 非严格递增。

本题采取贪心策略,找到数组中最大的降序差值,然后判断是 2 2 2 的多少倍即可。当最大的降序差值都使得数组非降序的时候,比其小的也一定实现了数组非降序。

Language: C++

#include <climits>
#include <iostream>

using namespace std;

int main() {
    int t;
    cin >> t;
    while (t--) {
        int n;
        cin >> n;
        int a_max = INT_MIN;
        int diff = 0;
        for (int i = 0; i < n; i++) {
            int a;
            cin >> a;
            a_max = max(a, a_max);
            diff = max(diff, a_max - a);
        }
        int ans = 0;
        while (diff) {
            diff /= 2;
            ans++;
        }
        cout << ans << endl;
    }
    return 0;
}

例2.2 - 洛谷P1090 [NOIP2004 提高组] 合并果子 / [USACO06NOV] Fence Repair G

Description

在一个果园里,多多已经将所有的果子打了下来,而且按果子的不同种类分成了不同的堆。多多决定把所有的果子合成一堆。

每一次合并,多多可以把两堆果子合并到一起,消耗的体力等于两堆果子的重量之和。可以看出,所有的果子经过 n − 1 n-1 n1 次合并之后, 就只剩下一堆了。多多在合并果子时总共消耗的体力等于每次合并所耗体力之和。

因为还要花大力气把这些果子搬回家,所以多多在合并果子时要尽可能地节省体力。假定每个果子重量都为 1 1 1,并且已知果子的种类数和每种果子的数目,你的任务是设计出合并的次序方案,使多多耗费的体力最少,并输出这个最小的体力耗费值。

例如有 3 3 3 种果子,数目依次为 1 1 1 2 2 2 9 9 9。可以先将 1 1 1 2 2 2 堆合并,新堆数目为 3 3 3,耗费体力为 3 3 3。接着,将新堆与原先的第三堆合并,又得到新的堆,数目为 12 12 12,耗费体力为 12 12 12。所以多多总共耗费体力 = 3 + 12 = 15 =3+12=15 =3+12=15。可以证明 15 15 15 为最小的体力耗费值。

Input

共两行。

第一行是一个整数 n   ( 1 ≤ n ≤ 10000 ) n\ (1 \leq n \leq 10000) n (1n10000),表示果子的种类数。

第二行包含 n n n 个整数,用空格分隔,第 i i i 个整数 a i   ( 1 ≤ a i ≤ 20000 ) a_i\ (1 \leq a_i \leq 20000) ai (1ai20000) 是第 i i i 种果子的数目。

Output

一个整数,也就是最小的体力耗费值。输入数据保证这个值小于 2 31 2^{31} 231

Sample Input
3
1 2 9
Sample Output
15
Note

对于 30 % 30\% 30% 的数据,保证有 n ≤ 1000 n \le 1000 n1000

对于 50 % 50\% 50% 的数据,保证有 n ≤ 5000 n \le 5000 n5000

对于全部的数据,保证有 n ≤ 10000 n \le 10000 n10000

Accepted Code

采取贪心策略,每次可以将数量最少的两堆果子合并,直到最后只剩下一堆果子,这样即可保证耗费的体力值最小。

在这种策略下,可以很容易想到使用排序来解决这道题。但是每次都仅取排序结果的最小值和次小值相加,还要更新数组再排序,有超时的风险。因此可以采取C++的STL,将每次输入的数据用优先队列 (priority queue) 存储,优先队列中的每个元素都有优先级,而优先级高的将会先出队。定义优先队列priority_queue<int, vector<int>, greater<int>> q;,则每次优先出队的元素就是队列中的最小值和次小值。这样就可以很轻松地解决本题。

Language: C++

#include <iostream>
#include <queue>

using namespace std;

int main() {
	int n;
	cin >> n;
	priority_queue<int, vector<int>, greater<int>> q;
	while (n--) {
		int a;
		cin >> a;
		q.push(a);
	}
	int ans = 0;
	while (q.size() > 1) {
		int t1 = q.top();
		q.pop();
		int t2 = q.top();
		q.pop();
		ans += t1 + t2;
		q.push(t1 + t2);
	}
	cout << ans << endl;
	return 0;
}

Part 3 - 动态规划

例3.1 - 洛谷P1216 [USACO1.5][IOI1994]数字三角形

Description

观察下面的数字金字塔。

写一个程序来查找从最高点到底部任意处结束的路径,使路径经过数字的和最大。每一步可以走到左下方的点也可以到达右下方的点。

        7 
      3   8 
    8   1   0 
  2   7   4   4 
4   5   2   6   5 

在上面的样例中,从 7 → 3 → 8 → 7 → 5 7 \to 3 \to 8 \to 7 \to 5 73875 的路径产生了最大。

Input

第一个行一个正整数 r r r,表示行的数目。

后面每行为这个数字金字塔特定行包含的整数。

Output

单独的一行,包含那个可能得到的最大的和。

Sample Input
5
7
3 8
8 1 0
2 7 4 4
4 5 2 6 5
Sample Output
30
Note

对于 100 % 100\% 100% 的数据, 1 ≤ r ≤ 1000 1\le r \le 1000 1r1000,所有输入在 [ 0 , 100 ] [0,100] [0,100] 范围内。

Accepted Code 1

提到动态规划,我脑海中出现的第一道题就是这道题。它历史悠久,最早可以追溯到1994年国际信息学奥林匹克竞赛 (IOI) 的 The Triangle,而将近30年之后,曾经的 IOI 竞赛题已经变成了动态规划的入门必做题。

对三角形 t r i tri tri 的行和列均从 0 0 0 开始编号,其第 i i i 行第 j j j 列记作 ( i , j ) (i,j) (i,j)。用 d p ( i , j ) dp(i,j) dp(i,j) 表示从三角形顶部走到位置 ( i , j ) (i,j) (i,j) 的最小路径和。由于每一步只能移动到下一行相邻的位置上,因此要想走到位置 ( i , j ) (i,j) (i,j),上一步就只能在位置 ( i − 1 , j − 1 ) (i - 1, j - 1) (i1,j1) 或者位置 ( i − 1 , j ) (i - 1, j) (i1,j)。在这两个位置中,选择一个路径和较大的来进行转移,有
d p ( i , j ) = max ⁡ ( d p ( i − 1 , j − 1 ) , d p ( i − 1 , j ) ) + t r i i , j dp(i,j) = \max (dp(i-1,j-1), dp(i-1,j)) + tri_{i,j} dp(i,j)=max(dp(i1,j1),dp(i1,j))+trii,j
特别地,当 j = 0 j=0 j=0 j = i j=i j=i 时,上述状态转移方程中有一些项是没有意义的,此时状态转移方程为
d p ( i , 0 ) = d p ( i − 1 , 0 ) + t r i i , 0 d p ( i , i ) = d p ( i − 1 , i − 1 ) + t r i i , i dp(i,0)=dp(i-1,0)+tri_{i,0}\\ dp(i,i)=dp(i-1,i-1)+tri_{i,i} dp(i,0)=dp(i1,0)+trii,0dp(i,i)=dp(i1,i1)+trii,i
最终的答案即为 d p ( n − 1 , 0 ) dp(n-1,0) dp(n1,0) d p ( n − 1 , n − 1 ) dp(n-1,n-1) dp(n1,n1) 中的最大值。

本解法的时间复杂度为 O ( n 2 ) O(n^2) O(n2),空间复杂度为 O ( n 2 ) O(n^2) O(n2),其中 n n n 为三角形的行数。

Language: C++

#include <algorithm>
#include <iostream>
#include <vector>

using namespace std;

int main() {
	int n;
	cin >> n;
	vector<vector<int> > tri(n, vector<int>(n, 0));
	for (int i = 0; i < n; i++) {
		for (int j = 0; j <= i; j++) {
			cin >> tri[i][j];
		}
	}
	vector<vector<int> > dp(n, vector<int>(n, 0));
	dp[0][0] = tri[0][0];
	for (int i = 1; i < n; i++) {
		dp[i][0] = tri[i][0] + dp[i - 1][0];
		for (int j = 1; j < i; j++) {
			dp[i][j] = max(dp[i - 1][j - 1], dp[i - 1][j]) + tri[i][j];
		}
		dp[i][i] = dp[i - 1][i - 1] + tri[i][i];
	}
	cout << *max_element(dp[n - 1].begin(), dp[n - 1].end()) << endl;
	return 0;
}
Accepted Code 2

观察上一种解法的状态转移方程,不难发现 d p ( i , j ) dp(i,j) dp(i,j) 只与 d p ( i − 1 , ⋯   ) dp(i-1,\cdots) dp(i1,) 有关,而与之前的状态无关,因此我们不必存储之前的状态,优化状态转移方程,从而进一步降低空间复杂度。

i i i 0 0 0 递减枚举 j j j,这样我们只需要一个长度为 n n n 的一维数组就可以完成状态转移。之所以递减枚举,是因为当我们在计算位置 ( i , j ) (i, j) (i,j) 时, d p ( j + 1 ) dp(j+1) dp(j+1) d p ( i ) dp(i) dp(i) 已经是第 i i i 行的值,而 d p ( 0 ) dp(0) dp(0) d p ( j ) dp(j) dp(j) 仍然是第 i − 1 i-1 i1​ 行的值,此时有状态转移方程
d p ( j ) = max ⁡ ( d p ( j − 1 ) , d p ( j ) ) + t r i i , j dp(j)=\max(dp(j−1),dp(j))+tri_{i,j} dp(j)=max(dp(j1),dp(j))+trii,j
如果递增枚举 j j j,那么在计算位置 ( i , j ) (i, j) (i,j) 时, d p ( 0 ) dp(0) dp(0) d p ( j − 1 ) dp(j-1) dp(j1) 已经是第 i i i 行的值,使用上面的状态转移方程,则是在 ( i , j − 1 ) (i, j-1) (i,j1) ( i − 1 , j ) (i-1, j) (i1,j) 中进行选择,这显然是错误的。

本解法的时间复杂度为 O ( n 2 ) O(n^2) O(n2),空间复杂度为 O ( n ) O(n) O(n),其中 n n n 为三角形的行数。这样只使用了 n n n 的空间存储状态,减少了空间消耗。

Language: C++

#include <algorithm>
#include <iostream>
#include <vector>

using namespace std;

int main() {
	int n;
	cin >> n;
	vector<vector<int>> tri(n, vector<int>(n, 0));
	for (int i = 0; i < n; i++) {
		for (int j = 0; j <= i; j++) {
			cin >> tri[i][j];
		}
	}
	vector<int> dp(n, 0);
	dp[0] = tri[0][0];
	for (int i = 1; i < n; i++) {
		dp[i] = dp[i - 1] + tri[i][i];
		for (int j = i - 1; j > 0; j--) {
			dp[j] = max(dp[j - 1], dp[j]) + tri[i][j];
		}
		dp[0] += tri[i][0];
	}
	cout << *max_element(dp.begin(), dp.end()) << endl;
	return 0;
}
Accepted Code 3

这道题还可以采取自底向上的策略,从三角形的倒数第二行开始向顶层遍历,对该行的每个元素加上 max ⁡ ( t r i i + 1 , j , t r i i + 1 , j + 1 ) \max(tri_{i+1,j},tri_{i+1,j+1}) max(trii+1,j,trii+1,j+1),即与其下一行相邻的位置对应元素的较大值。这样,当我们遍历到最顶层时, t r i 0 , 0 tri_{0,0} tri0,0 即为最终答案。

本解法的时间复杂度为 O ( n 2 ) O(n^2) O(n2),其中 n n n 为三角形的行数。空间复杂度为 O ( n ) O(n) O(n),所有操作均为原地修改三角形数组,没有使用额外的空间。

Language: C++

#include <iostream>
#include <vector>

using namespace std;

int main() {
	int n;
	cin >> n;
	vector<vector<int>> tri(n, vector<int>(n, 0));
	for (int i = 0; i < n; i++) {
		for (int j = 0; j <= i; j++) {
			cin >> tri[i][j];
		}
	}
	for (int i = n - 2; i >= 0; i--) {
		for (int j = 0; j <= i; j++) {
			tri[i][j] += max(tri[i + 1][j], tri[i + 1][j + 1]);
		}
	}
	cout << tri[0][0] << endl;
	return 0;
}

例3.2 - 计蒜客T1722 利润

Description

奶牛们开始了新的生意,它们的主人约翰想知道它们到底能做得多好。这笔生意已经做了 N   ( 1 ≤ N ≤ 100 , 000 ) N\ (1\le N\le 100,000) N (1N100,000) 天,每天奶牛们都会记录下这一天的利润 P i   ( − 1000 ≤ P i ≤ 1000 ) P_i\ (-1000 \le P_i \le 1000) Pi (1000Pi1000)

约翰想要找到奶牛们在连续的时间期间(至少一天)所获得的最大的总利润,请你写一个计算最大利润的程序来帮助他。

Input

第一行,一个整数 N N N,表示天数。

接下来 N N N 行,每行一个整数 P i P_i Pi

Output

一个整数,表示最大的总利润。

Sample Input
7 
-3 
4 
9 
-2 
-5 
8 
-3 
Sample Output
14
Accepted Code 1

分析题意可知,题目要求找出和最大的(连续)子数组。对于数组p,我们可以定义状态转移方程 d p dp dp,表示以p[i]为结尾的最大子数组和为 d p ( i ) dp(i) dp(i)。假设我们已经求出了 d p ( i − 1 ) dp(i - 1) dp(i1) 的值,那么 d p ( i ) dp(i) dp(i)

  • 与前面的相邻子数组连接,形成一个和更大的子数组;
  • 不与前面的子数组连接,而是自己作为一个子数组。

既然要求最大子数组和,当然要选择更大的结果,即
d p ( i ) = max ⁡ ( d p ( i − 1 ) + p i , p i ) dp(i) = \max(dp(i - 1) + p_i, p_i) dp(i)=max(dp(i1)+pi,pi)
这样, d p dp dp 的最大值即为本题所求结果。

Language: C++

#include <climits>
#include <iostream>
#include <vector>

using namespace std;

int main() {
    int n;
    cin >> n;
    vector<int> p(n);
    for (int i = 0; i < n; i++) {
        cin >> p[i];
    }
    int ans = INT_MIN;
    vector<int> dp(n);
    dp[0] = p[0];
    for (int i = 1; i < n; i++) {
        dp[i] = max(dp[i - 1] + p[i], p[i]);
        ans = max(dp[i], ans);
    }
    cout << ans << endl;
    return 0;
}
Accepted Code 2

观察上面的代码,不难发现 d p ( i ) dp(i) dp(i) 仅与 d p ( i − 1 ) dp(i - 1) dp(i1) 的状态有关。因此我们可以进行状态压缩,将空间复杂度从 O ( n ) O(n) O(n) 降低为 O ( 1 ) O(1) O(1)

Language: C++

#include <climits>
#include <iostream>
#include <vector>

using namespace std;

int main() {
    int n;
    cin >> n;
    vector<int> p(n);
    for (int i = 0; i < n; i++) {
        cin >> p[i];
    }
    int ans = INT_MIN;
    int dp = p[0];
    for (int i = 1; i < n; i++) {
        dp = max(dp + p[i], p[i]);
        ans = max(dp, ans);
    }
    cout << ans << endl;
    return 0;
}
  • 0
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值