算法分析与设计实验报告
写在前面
傻逼大学。
以下代码均是网上抄的均未经过测试哈哈。
实验背景
本实验选取了如下七个问题,并对所需算法过程进行了详细分析与探讨:
- 快速排序及第 \(k\) 大数:分治法。
- 棋盘覆盖问题:分治法。
- 计算矩阵连乘积:动态规划思想
- 防卫导弹:动态规划思想,动态规划优化
- 皇宫看守:动态规划思想,树形动态规划
- 背包问题:贪心思想
- 照亮的山景:贪心思想,几何模型转化
- 搬桌子问题:贪心思想
快速排序及第k大数
问题描述
给定一个包含 \(n\) 个整数的无序数组 \(A\),以及一个整数 \(k\),要求设计算法实现以下两个功能:
- 快速排序: 对数组 \(A\) 进行快速排序,使得数组中的元素按照非递减顺序排列。
- 第 \(k\) 小的问题: 找出数组 \(A\) 中第 \(k\) 小的元素,即找到一个整数 \(x\),使得在 \(A\) 中小于等于 \(x\) 的元素数量恰好为 \(k\) 个。
要求设计算法实现以上两个功能,并分析算法的时间复杂度。
问题分析
快速排序
快速排序(英语:Quicksort),又称分区交换排序(英语:partition-exchange sort),简称「快排」,是一种被广泛运用的排序算法。快速排序的工作原理是通过分治的方式来将一个数组排序,可以分为三个过程:
- 将数列划分为两部分(要求保证相对大小关系);
- 递归到两个子序列中分别进行快速排序;
- 不用合并,因为此时数列已经完全有序。
和归并排序不同,第一步并不是直接分成前后两个序列,而是在分的过程中要保证相对大小关系。具体来说,第一步要是要把数列分成两个部分,然后保证前一个子数列中的数都小于后一个子数列中的数。为了保证平均时间复杂度,一般是随机选择一个数 \(m\) 来当做两个子数列的分界;之后,维护一前一后两个指针 \(p\) 和 \(q\),依次考虑当前的数是否放在了应该放的位置(前还是后)。如果当前的数没放对,比如说如果后面的指针 \(q\) 遇到了一个比 \(m\) 小的数,那么可以交换 \(p\) 和 \(q\) 位置上的数,再把 \(p\) 向后移一位。当前的数的位置全放对后,再移动指针继续处理,直到两个指针相遇。实际上,快速排序没有指定应如何具体实现第一步,不论是选择 \(m\) 的过程还是划分的过程,都有不止一种实现方法。
时间复杂度分析
快速排序是一种不稳定的排序算法。其最优时间复杂度和平均时间复杂度为 \(O(n\log n)\),最坏时间复杂度为 \(O(n^2)\)。
- 对于最优情况,每一次选择的分界值都是序列的中位数,此时算法时间复杂度满足的递推式为 \(T(n) = 2T(\dfrac{n}{2}) + \Theta(n)\),由主定理,\(T(n) = \Theta(n\log n)\)。
- 对于最坏情况,每一次选择的分界值都是序列的最值,此时算法时间复杂度满足的递推式为 \(T(n) = T(n - 1) + \Theta(n)\),累加可得 \(T(n) = \Theta(n^2)\)。
- 对于平均情况,每一次选择的分界值可以看作是等概率随机的。
快速排序的优化
较为常见的优化思路有以下三种。
- 通过三数取中(即选取第一个、最后一个以及中间的元素中的中位数)的方法来选择两个子序列的分界元素(即比较基准)。这样可以避免极端数据(如升序序列或降序序列)带来的退化;
- 当序列较短时,使用插入排序的效率更高;
- 每趟排序后,将与分界元素相等的元素聚集在分界元素周围,这样可以避免极端数据(如序列中大部分元素都相等)带来的退化。
下面列举了两种较为成熟的快速排序优化方式:
- 三路快速排序:快速排序和基数排序的混合。与原始的快速排序不同,三路快速排序在随机选取分界点 \(m\) 后,将待排数列划分为三个部分:小于 \(m\)、等于 \(m\) 以及大于 \(m\)。这样做即实现了将与分界元素相等的元素聚集在分界元素周围这一效果。三路快速排序在处理含有多个重复值的数组时,效率远高于原始快速排序。其最佳时间复杂度为 \(O(n)\)。
- 内省排序:内省排序(英语:Introsort 或 Introspective sort)是快速排序和堆排序的结合,由 David Musser 于 1997 年发明。内省排序其实是对快速排序的一种优化,保证了最差时间复杂度为 \(O(n\log n)\)。它将快速排序的最大递归深度限制为 \(\lfloor \log_2 n \rfloor\),超过限制时就转换为堆排序。这样既保留了快速排序内存访问的局部性,又可以防止快速排序在某些情况下性能退化为 \(O(n^2)\) 级别。
第 k 大数
考虑快排每轮进行分治时进行的操作:选择基准元素,将小于/大于基准元素的元素分别划分到基准元素的两侧。则此时根据基准元素左侧的元素数量就可以得到基准元素的排名 \(r\):
- 若 \(r = k\),说明基准元素及其左侧元素即为前 \(k\) 大元素,停止算法。
- 若 \(r > k\),说明前 \(k\) 大元素均小于基准元素,问题转化为求基准元素左侧的所有元素的第 \(k\) 大,递归进行即可。
- 若 \(r < k\),说明不大于基准元素的均为前 \(k\) 大,可以直接加入答案中,问题转化为求基准元素右侧的所有元素的前 \(k - r\) 大值,递归进行即可。
与快速排序复杂度分析类似地,若基于随机选择基准元素,上述算法时间复杂度期望 \(O(n)\) 级别,但是时间复杂度上限为 \(O(n^2)\) 级别。
第 k 大数的优化
BFPRT 算法,又称中位数的中位数算法,一种对上述解法的优化,可保证复杂度上限为 \(O(n)\) 级别。
上述解法时间复杂度上界为 \(O(n^2)\) 的原因,是无法保证每次划分选择基准元素时均能选取到中位数,使基准元素两侧元素数量级不平衡,但是又会减治递归到元素数量较多一侧,从而使最坏情况下递归次数变为 \(O(n)\) 级别。
BFPRT 算法在算法四的基础上,对选取基准元素的过程进行了优化,使得基准元素能更加接近中位数,从而避免了上述最坏情况的出现。
具体地,在选择基准元素时,首先将整个序列每 5 个相邻元素进行分块,求得每块中的中位数,再将求得的所有中位数组成一个序列后,递归调用 BFPRT
算法求该序列的中位数即为基准元素。可以证明求得的基准元素一定被限制在整个序列的 \(30\% \sim 70\%\) 范围内,避免了最坏情况的发生。
在找基准元素仅仅首先遍历了整个序列,然后递归调用了 BFPRT
算法,则时间复杂度不变仍为 \(O(n)\) 级别。通过递归分析可知算法总时间复杂度为 \(O(n)\) 级别。
时间复杂度分析
下面将证明,该算法在最坏情况下的时间复杂度为 \(O(n)\)。设 \(T(n)\) 为问题规模为 \(n\) 时,解决问题需要的计算量。
先分析前两步——划分与寻找中位数。由于划分后每组内的元素数量非常少,可以认为寻找一组元素的中位数的时间复杂度为 \(O(1)\)。因此找出所有 \(\left \lfloor \dfrac{n}{5} \right \rfloor\) 组元素中位数的时间复杂度为 \(O(n)\)。
接下来分析第三步——递归过程。这一步进行了两次递归调用:第一次是寻找各组中位数中的中位数,需要的开销显然为 \(T\left(\dfrac{n}{5}\right)\),第二次是进入分界值的左侧部分或右侧部分。根据我们选取的划分元素,有 \(\dfrac{1}{2} \times \left \lfloor \dfrac{n}{5} \right \rfloor = \left \lfloor \dfrac{n}{10} \right \rfloor\) 组元素的中位数小于分界值,这几组元素中,比中位数还小的元素也一定比分界值要小,从而整个序列中小于分界值的元素至少有 \(3 \times \left \lfloor \dfrac{n}{10} \right \rfloor = \left \lfloor \dfrac{3n}{10} \right \rfloor\) 个。同理,整个序列中大于分界值的元素也至少有 \(\left \lfloor \dfrac{3n}{10} \right \rfloor\) 个。因此,分界值的左边或右边至多有 \(\dfrac{7n}{10}\) 个元素,这次递归的时间开销的上界为 \(T\left(\dfrac{7n}{10}\right)\)。
综上,我们可以列出这样的不等式:
假设 \(T(n) = O(n)\) 在问题规模足够小时成立。根据定义,此时有 \(T(n) \leq cn\),其中 \(c\) 为一正常数。将不等式右边的所有 \(T(n)\) 进行代换:
即可证明该算法在最坏情况下也具有 \(O(n)\) 的时间复杂度。
代码实现
普通快速排序
struct Range {
int start, end;
Range(int s = 0, int e = 0) { start = s, end = e; }
};
template <typename T>
void quick_sort(T arr[], const int len) {
if (len <= 0) return;
Range r[len];
int p = 0;
r[p++] = Range(0, len - 1);
while (p) {
Range range = r[--p];
if (range.start >= range.end) continue;
T mid = arr[range.end];
int left = range.start, right = range.end - 1;
while (left < right) {
while (arr[left] < mid && left < right) left++;
while (arr[right] >= mid && left < right) right--;
std::swap(arr[left], arr[right]);
}
if (arr[left] >= arr[range.end])
std::swap(arr[left], arr[range.end]);
else
left++;
r[p++] = Range(range.start, left - 1);
r[p++] = Range(left + 1, range.end);
}
}
内省排序
// 模板的 T 参数表示元素的类型,此类型需要定义小于(<)运算
template <typename T>
// arr 为需要被排序的数组,len 为数组长度
void quick_sort(T arr[], const int len) {
if (len <= 1) return;
// 随机选择基准(pivot)
const T pivot = arr[rand() % len];
// i:当前操作的元素下标
// arr[0, j):存储小于 pivot 的元素
// arr[k, len):存储大于 pivot 的元素
int i = 0, j = 0, k = len;
// 完成一趟三路快排,将序列分为:
// 小于 pivot 的元素 | 等于 pivot 的元素 | 大于 pivot 的元素
while (i < k) {
if (arr[i] < pivot)
swap(arr[i++], arr[j++]);
else if (pivot < arr[i])
swap(arr[i], arr[--k]);
else
i++;
}
// 递归完成对于两个子序列的快速排序
quick_sort(arr, j);
quick_sort(arr + k, len - k);
}
第 k 大
// 模板的 T 参数表示元素的类型,此类型需要定义小于(<)运算
template <typename T>
// arr 为查找范围数组,rk 为需要查找的排名(从 0 开始),len 为数组长度
T find_kth_element(T arr[], int rk, const int len) {
if (len <= 1) return arr[0];
// 随机选择基准(pivot)
const T pivot = arr[rand() % len];
// i:当前操作的元素
// j:第一个等于 pivot 的元素
// k:第一个大于 pivot 的元素
int i = 0, j = 0, k = len;
// 完成一趟三路快排,将序列分为:
// 小于 pivot 的元素 | 等于 pivot 的元素 | 大于 pivot 的元素
while (i < k) {
if (arr[i] < pivot)
swap(arr[i++], arr[j++]);
else if (pivot < arr[i])
swap(arr[i], arr[--k]);
else
i++;
}
// 根据要找的排名与两条分界线的位置,去不同的区间递归查找第 k 大的数
// 如果小于 pivot 的元素个数比k多
//则第 k 大的元素一定是一个小于 pivot 的元素
if (rk < j) return find_kth_element(arr, rk, j);
// 否则,如果小于 pivot 和等于 pivot 的元素加起来也没有 k 多,
// 则第 k 大的元素一定是一个大于 pivot 的元素
else if (rk >= k)
return find_kth_element(arr + k, rk - k, len - k);
// 否则,pivot 就是第 k 大的元素
return pivot;
}
棋盘覆盖问题
问题描述
在一个 \(2^k\times 2^k\) 个方格组成的棋盘中,恰有一个方格与其它方格不同,称该方格为一特殊方格,且称该棋盘为一特殊棋盘。在棋盘覆盖问题中,要用图示的 4 种不同形态的 L 型骨牌覆盖给定的特殊棋盘上除特殊方格以外的所有方格,且任何 2 个 L 型骨牌不得重叠覆盖。
问题分析
首先考虑足够小的子问题:对于 \(2\times 2\) 的棋盘,仅需一块骨牌必定可以将其覆盖;而对于 \(4\times 4\) 的棋盘,发现首先在棋盘中央放置一块缺口朝向特殊方格方向的骨牌后,棋盘实际上被分成了四个存在且仅存在一个无法覆盖的位置的 \(2\times 2\) 棋盘,它们的覆盖问题转化为了上述四个互不影响的子问题。
发现上述思路可以拓展到任意大小 \(2^k\times 2^k\) 的棋盘中:
- 首先在棋盘中央放置一块缺口朝向特殊方格方向的骨牌。
- 转化为覆盖四个存在且仅存在一个无法覆盖的位置的 \(2^{k - 1}\times 2 ^{k - 1}\) 的棋盘。
- 递归地处理四个独立的子问题。
由上述过程可知,一共会递归 \(k\) 次,第 \(i\) 次递归需要处理 \(4^{i}\) 个子问题,则总时间复杂度为 \(O(4^k)\) 级别。
代码实现
#include <iostream>
int tile = 1; // 骨牌序号
int board[128][128]; // 二维数组模拟棋盘
// (tr,tc)表示棋盘的左上角坐标(即确定棋盘位置),
//(dr,dc)表示特殊方块的位置
//size=2^k确定棋盘大小
void chessBoard(int tr, int tc, int dr, int dc, int size) {
// 递归出口
if (size == 1)
return;
int s = size / 2; //分割棋盘
int t = tile++; //t记录本层骨牌序号
// 判断特殊方格在不在左上棋盘
if (dr < tr + s && dc < tc + s) {
chessBoard(tr, tc, dr, dc, s); //特殊方格在左上棋盘的递归处理方法
} else {
board[tr + s - 1][tc + s - 1] = t;// 用t号的L型骨牌覆盖右下角
chessBoard(tr, tc, tr + s - 1, tc + s - 1, s); // 递归覆盖其余方格
}
// 判断特殊方格在不在右上棋盘
if (dr < tr + s && dc >= tc + s) {
chessBoard(tr, tc + s, dr, dc, s);
} else {
board[tr + s - 1][tc + s] = t;
chessBoard(tr, tc + s, tr + s - 1, tc + s, s);
}
// 判断特殊方格在不在左下棋盘
if (dr >= tr + s && dc < tc + s) {
chessBoard(tr + s, tc, dr, dc, s);
} else {
board[tr + s][tc + s - 1] = t;
chessBoard(tr + s, tc, tr + s, tc + s - 1, s);
}
// 判断特殊方格在不在右下棋盘
if (dr >= tr + s && dc >= tc + s) {
chessBoard(tr + s, tc + s, dr, dc, s);
} else {
board[tr + s][tc + s] = t;
chessBoard(tr + s, tc + s, tr + s, tc + s, s);
}
}
int main() {
int boardSize = 8; // 棋盘边长
chessBoard(0, 0, 3, 3, boardSize);
// (0, 0)为顶点,大小为boardSize的棋盘; 特殊方块位于(3, 3)
// 打印棋盘
int i, j;
printf("\n\n\n");
for (i = 0; i < boardSize; i++) {
for (j = 0; j < boardSize; j++) {
printf("%d\t", board[i][j]);
}
printf("\n\n\n");
}
return 0;
}
计算矩阵连乘积
问题描述
在科学计算中经常要计算矩阵的乘积。矩阵 \(A\) 和 \(B\) 可乘的条件是矩阵 \(A\) 的列数等于矩阵 \(B\) 的行数。若 \(A\) 是一个 \(p×q\) 的矩阵,\(B\) 是一个 \(q\times r\) 的矩阵,则其乘积 \(C=AB\) 是一个 \(p\times r\) 的矩阵。由该公式知计算 \(C=AB\)总共需要 \(p\times q\times r\) 次的数乘。其标准计算公式为:
由矩阵乘法的结合律可知,可以通过调整运算顺序,首先将某些矩阵经过运算后调整为行列较小的矩阵,从而减少套用公式时需要计算的数乘运算的次数。现给定 \(n\) 个矩阵 \(\{A_1,A_2, \cdots ,A_n\}\)。第 \(i\) 个矩阵的大小为 \(r_i\times c_i\),其中 \(A_i\) 与 \(A_i+1\) 是可乘的,\(i=1,2,\cdots,n-1\)。要求计算出这 \(n\) 个矩阵的连乘积 \(A_1A_2\cdots A_n\)。确定计算矩阵连乘积的计算次序,使得依此次序计算矩阵连乘积需要的数乘次数最少。
问题分析
发现对于某个区间的矩阵乘积,其最终结果与该区间合并为一个矩阵的运算顺序无关,且对之后的以该区间的矩阵乘积结果为运算对象的矩阵乘法无影响,满足无后效性;又任意区间的矩阵乘积一定是由该区间的一个前缀与对应的互补的后缀通过乘法运算得到,满足最优子结构,于是考虑区间动态规划解决该问题。
考虑记 \(f_{l, r} (1\le l\le r\le n)\) 表示将区间 \([l, r]\) 通过矩阵乘法合并为一个矩阵所需的最少数乘运算次数,初始化 \(\forall 1\le i\le n,\ f_{i, i} = 0\);由于一定是由较小的两个区间的矩阵乘积运算得到较大区间的矩阵成绩,则转移时需要按照区间长度枚举区间枚举区间 \(l, r\),然后枚举区间分界点 \(m\) 表示由区间 \([l, m]\) 与 \([m + 1, r]\) 的矩阵乘积运算转移到当前区间的矩阵乘积,则有状态转移方程:
最终答案即为 \(f_{1, n}\)。状态空间复杂度为 \(O(n^2)\) 级别,转移时间复杂度为 \(O(n^3)\) 级别。
代码实现
#include <iostream>
using namespace std;
#define N 7 //N为7,实际表示有6个矩阵
int p[N] = {30, 35, 15, 5, 10, 20, 25};
int m[N][N], s[N][N];
int LookupChain(int i, int j) {
if (m[i][j] > 0)
return m[i][j];
if (i == j)
return 0;
m[i][j] = LookupChain(i, i) +
LookupChain(i + 1, j) +
p[i - 1] * p[i] * p[j];
s[i][j] = i;
for (int k = i + 1; k < j; k++) {
int t = LookupChain(i, k) +
LookupChain(k + 1, j) +
p[i - 1] * p[k] * p[j];
if (t < m[i][j]) {
m[i][j] = t;
s[i][j] = k;
}
}
return m[i][j];
}
int MemorizedMatrixChain(int n, int m[][N], int s[][N]) {
for (int i = 1; i <= n; i++) { //初始化默认都是0
for (int j = 1; j <= n; j++)
m[i][j] = 0;
}
return LookupChain(1, n);
}
/*
*追踪函数:根据输入的i,j限定需要获取的矩阵链的始末位置,s存储断链点
*/
void Traceback(int i, int j, int s[][N]) {
if (i == j) { //回归条件
cout << "A" << i;
} else { //按照最佳断点一分为二,接着继续递归
cout << "(";
Traceback(i, s[i][j], s);
Traceback(s[i][j] + 1, j, s);
cout << ")";
}
}
int main() {
MemorizedMatrixChain(N - 1, m, s); //N-1因为只有六个矩阵
Traceback(1, 6, s);
return 0;
}
防卫导弹
问题描述
一种新型的防卫导弹可截击多个攻击导弹。它可以向前飞行,也可以用很快的速度向下飞行,可以毫无损伤地截击进攻导弹,但不可以向后或向上飞行。但有一个缺点,尽管它发射时可以达到任意高度,但它只能截击比它上次截击导弹时所处高度低或者高度相同的导弹。即一个导弹能被截击应满足下列两个条件之一:
- 它是该次测试中第一个被防卫导弹截击的导弹;
- 它是在上一次被截击导弹的发射后发射,且高度不大于上一次被截击导弹的高度的导弹。
现对这种新型防卫导弹进行测试,在每一次测试中,发射一系列的测试导弹(这些导弹发射的间隔时间固定,飞行速度相同),该防卫导弹所能获得的信息包括各进攻导弹的高度,以及它们发射次序。现要求编一程序,求在每次测试中,该防卫导弹最多能截击的进攻导弹数量。
问题分析
考虑抽象题目描述中的导弹截击的条件,问题实际上即求给定数列的最长不上升子序列。
设 \(f(i)\) 表示以 \(A_i\) 为结尾的最长不下降子序列的长度,则所求为:
计算 \(f(i)\) 时,尝试将 \(A_i\) 接到其他的最长不下降子序列后面,以更新答案。于是可以写出这样的状态转移方程:
时间复杂度为 \(O(n^2)\) 级别,可以通过如下方法优化至时间复杂度 \(O(n \log n)\):
回顾之前的状态:\((i, l)\)。但考虑不是按照相同的 \(i\) 处理状态,而是直接判断合法的 \((i, l)\)。再考虑之前的转移:\((j, l - 1) \rightarrow (i, l)\),就可以判断某个 \((i, l)\) 是否合法。初始时 \((1, 1)\) 合法,则仅需找到最大的 \(l\) 使 \((i, l)\) 合法即可得到最终最长不下降子序列的长度。则需要维护一个可能的转移列表,并逐个处理转移。
定义 \(a_1 \dots a_n\) 为原始序列,\(d_i\) 为所有的长度为 \(i\) 的不下降子序列的末尾元素的最小值,\(len\) 为子序列的长度。初始化 \(d_1=a_1,len=1\)。设已知最长的不下降子序列长度为 1,则令 \(i\) 从 2 到 \(n\) 循环,依次求出前 \(i\) 个元素的最长不下降子序列的长度,循环的时候仅需维护数组 \(d\) 以及 \(len\)。
考虑新增元素 \(a_i\) 后:
- 元素大于等于 \(d_{len}\),直接将该元素插入到 \(d\) 序列的末尾。由于枚举顺序从前往后,则当元素大于等于 \(d_{len}\) 时一定会有一个不下降子序列使得这个不下降子序列的末项后面可以再接这个元素。如果 \(d\) 不接这个元素,可以发现既不符合定义,又不是最优解。
- 元素小于 \(d_{len}\),找到 第一个 大于它的元素,用 \(a_i\) 替换它。同1,如果插在 \(d\) 的末尾,那么由于前面的元素大于要插入的元素,所以不符合 \(d\) 的定义,因此必须先找到 第一个 大于它的元素,再用 \(a_i\) 替换。
上述 2 中,若采用暴力查找,则时间复杂度仍然是 \(O(n^2)\) 的。但是根据 \(d\) 数组的定义,又由于本题要求不下降子序列,则 \(d\) 一定是单调不减的,因此可以用二分查找将时间复杂度降至 \(O(n\log n)\)。
代码实现
\(O(n^2)\) 朴素动态规划:
int a[MAXN], d[MAXN];
int dp() {
d[1] = 1;
int ans = 1;
for (int i = 2; i <= n; i++) {
d[i] = 1;
for (int j = 1; j < i; j++) {
if (a[j] <= a[i]) {
d[i] = max(d[i], d[j] + 1);
ans = max(ans, d[i]);
}
}
}
return ans;
}
\(O(n\log n)\) 优化:
for (int i = 0; i < n; ++i) scanf("%d", a + i);
memset(dp, 0x1f, sizeof dp);
mx = dp[0];
for (int i = 0; i < n; ++i) {
*std::upper_bound(dp, dp + n, a[i]) = a[i];
}
ans = 0;
while (dp[ans] != mx) ++ans;
皇宫看守
问题描述
太平王世子事件后,陆小凤成了皇上特聘的御前一品侍卫。皇宫以午门为起点,直到后宫嫔妃们的寝宫,呈一棵树的形状;某些宫殿间可以互相望见。大内保卫森严,三步一岗,五步一哨,每个宫殿都要有人全天候看守,在不同的宫殿安排看守所需的费用不同。可是陆小凤手上的经费不足,无论如何也没法在每个宫殿都安置留守侍卫。
请你编程计算帮助陆小凤布置侍卫,在看守全部宫殿的前提下,使得花费的经费最少。
问题分析
首先钦定根节点为 1,则此时树上的一个结点若要被覆盖,可被其父其子覆盖或自己覆盖自己。覆盖一个结点仅影响到与其直接相连的结点,与其距离 1 以上的结点不能被影响,满足无后效性,考虑树形 DP。
对于一棵子树,假设其中所有节点已被覆盖,仅需考虑子树根节点的父亲的覆盖情况对此子树的影响:
-
若子树的根节点自覆盖:
- 可不花费额外代价的情况下,将子树根节点的父亲覆盖。
- 也可花费额外代价将子树的根节点的父亲覆盖,从而覆盖子树的根结点,并影响其二级子节点。
-
若子树的根节点未被自己覆盖,即子树的根结点被其子覆盖,则可花费代价,将子树的根节点父亲覆盖,从而覆盖子树的根结点,并影响其二级子节点
由上,通过一个节点被覆盖的来源,可以设计三种状态。设 \(f_{i, 0/1/2}\) 表示:
- 第 \(i\) 个节点为根的子树全部被覆盖时的最小花费
- 第 \(i\) 个结点被其父亲/自己/儿子 覆盖时的最小花费。
- 第 \(i\) 个节点为根的子树内自己覆盖自己的点的最小花费。
对于以结点 \(i\) 为根结点的子树,其上述三个状态的值 可以通过下述方法获得:
- \(f_{i, 0}\),即为:
- \(f_{i, 1}\),将结点 \(i\) 进行自覆盖后,只能影响到 其直接子节点。其直接子节点的状态可以为以上三种任一,则有
-
\(f_{i, 2}\),根节点i被其子覆盖,则其子中必然有至少一进行了自覆盖,其它可进行自覆盖,也可进行子覆盖 (但不可进行父覆盖)。
- 先抛开必须有一进行自覆盖这一限制条件,则必然选择子覆盖和自覆盖 中代价更小的,即若满足 \(f_{j, 2} - f_{j, 1} > 0\),选择自覆盖,否则选择子覆盖。
- 则必须进行自覆盖的子结点,选择 \(f_{j, 2} - f_{j, 1}\) 最大的最优。对于其他的子节点,当 \(f_{j, 2} - f_{j, 1} > 0\) 时,选择自覆盖,否则选择子覆盖。
- 可以设计一比较巧妙的算法实现上述过程:先使 \(f_{i, 2} = \sum_{j \in \operatorname{son}_i}f_{j, 1}\),同时记录所有 \(f_{j, 2} - f_{j, 1}\),之后对记录的 \(f_{j, 2} - f_{j, 1}\) 进行升序排序,然后从头到尾进行选择,若 \(f_{j, 2} - f_{j, 1} < 0\),说明 \(f_{j, 1} > f_{j, 2}\),此时使 \(f_{i, 2} := f_{i, 2} + f_{j, 2} - f_{j, 1}\),则可消除之前选择 \(f_{j, 1}\) 的影响。
- 当选择了 \(\operatorname{son}_i - 1\) 个或者 \(f_{j, 2} - f_{j, 1} \ge 0\) 时结束选择。
答案即为 \(\min(f_{1, 1}, f_{1, 2})\)。
总时间空间复杂度 \(O(n)\) 级别。
代码实现
//
/*
By:Luckyblock
*/
#include <cmath>
#include <cstdio>
#include <cctype>
#include <vector>
#include <cstring>
#include <algorithm>
#define LL long long
const int kN = 3e5 + 10;
const int kM = kN << 1;
const LL kInf = 1e18 + 2077;
//=============================================================
int n, a[kN];
LL f[kN][4];
int edgenum, head[kN], v[kM], ne[kM];
//=============================================================
inline int read() {
int f = 1, w = 0; char ch = getchar();
for (; !isdigit(ch); ch = getchar()) if (ch == '-') f = -1;
for (; isdigit(ch); ch = getchar()) w = (w << 3) + (w << 1) + (ch ^ '0');
return f * w;
}
void Init() {
edgenum = 0;
for (int i = 1; i <= n; ++ i) {
head[i] = 0;
f[i][0] = f[i][1] = f[i][2] = 0;
}
}
void Add(int u_, int v_) {
v[++ edgenum] = v_;
ne[edgenum] = head[u_];
head[u_] = edgenum;
}
void Dfs(int u_, int fa_) {
f[u_][1] = a[u_];
std::vector <LL> son;
for (int i = head[u_]; i; i = ne[i]) {
int v_ = v[i];
if (v_ == fa_) continue;
Dfs(v_, u_);
f[u_][0] += std::min(f[v_][1], f[v_][2]);
f[u_][1] += std::min(f[v_][0], std::min(f[v_][1], f[v_][2]));
f[u_][2] += f[v_][1], son.push_back(f[v_][2] - f[v_][1]);
}
std::sort(son.begin(), son.end());
if (!son.size()) f[u_][2] = kInf;
for (int i = 0, sz = son.size(); i < sz - 1; ++ i) {
if (son[i] < 0) f[u_][2] += son[i];
else break;
}
}
//=============================================================
int main() {
// freopen("1.txt", "r", stdin);
int T = read();
while (T --) {
n = read();
Init();
for (int i = 1; i <= n; ++ i) a[i] = read();
for (int i = 1; i < n; ++ i) {
int u_ = read(), v_ = read();
Add(u_, v_), Add(v_, u_);
}
Dfs(1, 0);
printf("%lld\n", std::min(f[1][2], f[1][1]));
}
return 0;
}
背包问题
问题描述
有一个背包,背包容量是 \(M=150\)。有7个物品,其重量和价值如下:
物品 | A | B | C | D | E | F | G |
---|---|---|---|---|---|---|---|
重量 | 35 | 30 | 60 | 50 | 40 | 10 | 25 |
价值 | 10 | 40 | 30 | 50 | 35 | 40 | 30 |
物品可以分割成任意大小。要求尽可能让装入背包中的物品总价值最大,但不能超过总容量。求可以获得的最大的总价值之和。
问题分析
由于物品可以任意分割,于是并不需要考虑物品原重量,仅需考虑物品单位重量的价值也即性价比即可。发现只要是所有物品的总重量大于背包容纳量,那么背包一定能装满,且一定是按照性价比递减选择物品,具有最优子结构性质和贪心选择性质。
考虑使用贪心算法求解背包问题。
- 首先计算每种物品单位重量的价值 \(\frac{v_i}{w_i}\),
- 依贪心选择策略,将尽可能多的单位重量价值最高的物品装入背包。
- 若将这种物品全部装入背包后,背包内的物品总重量未超过\(M\),则选择单位重量价值次高的物品并尽可能多地装入背包。
- 依此策略一直地进行下去,直到背包装满为止。
需要仅需物品性价比的排序,则总时间复杂度 \(O(n\log n)\) 级别。
代码实现
#include <iostream>
using namespace std;
//按照单位重量的价值量大小降序排列
void Sort(int n, float * w, float * v) {
int i, j;
float temp1, temp2;
for (i = 1; i <= n; i++)
for (j = 1; j <= n - i; j++) //冒泡排序
{
temp1 = v[j] / w[j];
temp2 = v[j + 1] / w[j + 1];
if (temp1 < temp2) {
swap(w[j], w[j + 1]);
swap(v[j], v[j + 1]);
}
}
}
int main() {
float w[101]; //用来表示每个物品的重量
float v[101]; //用来表示每个物品的价值量
float x[101]; //表示最后放入背包的比例
int n; //物品数
float M; //背包最大容纳重量
cin >> n >> M;
//依次输入每件物品的重量和价值量
for (int i = 1; i <= n; i++)
cin >> w[i] >> v[i];
//按照单位重量的价值量大小降序排列
Sort(n, w, v);
int i;
for (i = 1; i <= n; i++)
x[i] = 0; //初始值,未装入背包,x[i]=0
float c = M; //更新背包容纳量
for (i = 1; i <= n; i++) {
if (c < w[i]) break; //不能完全装下
x[i] = 1;
c = c - w[i];
}
if (i <= n)
x[i] = c / w[i];
//输出
for (int i = 1; i <= n; i++) {
cout << "重量为" << w[i] << "价值量为" << v[i];
cout << "的物品" << "放入的比例为" << x[i] << endl;
}
return 0;
}
照亮的山景
问题描述
在一片山的上空,高度为 \(T\) 处有 \(N\) 个处于不同位置的灯泡,如图。如果山的边界上某一点于某灯 \(i\) 的连线不经过山的其它点,我们称灯 \(i\) 可以照亮该点。开尽量少的灯,使得整个山景都被照亮。山被表示成有 \(m\) 个转折点的折线。
提示:照亮整个山景相当于照亮每一个转折点。
问题分析
山可以被表示为有 \(M\) 个转折点的折线,很容易想到把照亮整个山景可以转化为照亮这 \(M\) 个折点(因为照亮 \(M\) 个折点就一定能照到整个山景)。
一个直接的想法是考虑每盏灯可以照亮多少折点,发现由于山与山之间存在互相遮挡关系,每盏灯照亮的折点的位置不一定是连续的,导致难以选择合适的灯并进行处理。但是反向地从折点的角度考虑,发现可以照亮每个折点的灯的范围一定是一段连续的区间,将构成折点的两条线段反向延长与灯的高度相交即可得到左右端点。
问题转化为选择尽可能少的灯,使得每个区间中至少有一盏灯存在。这是一个经典的区间选点覆盖的贪心问题,考虑将按照右端点从小到大排序,排序完之后 1 号区间里必定需要一个点,为了使得改点尽可能地也对之后的区间产生贡献,即如果之后的区间与这个区间有交集那就不需要添加新的点,则应当选择最靠近该区间的右端点的点。
需要预处理转折点的区间并排序,若暴力预处理时间复杂度为 \(O(nM^2)\) 级别,但可以使用栈+二分查找实现复杂度为 \(O(M\log N)\) 级别,然后枚举所有区间进行贪心,则总时间复杂度 \(O(M\log n + M\log M + n)\) 级别。
代码实现
#include <cstdio>
#include <string.h>
using namespace std;
const int MAX = 205;
int x[MAX], y[MAX], l[MAX], r[MAX];
int b[MAX], re[MAX];
int t, m, n, turnon;
bool light[MAX];
int forleft(int i) {
int hs = y[i];
double tan = 0.0;
int index = -1;
for (int j = i - 1; j >= 0; j--) {
if (y[j] > hs) {
hs = y[j];
double t_tan = (y[j] - y[i]) * 1.0 / (x[i] - x[j]);
if (tan < t_tan) {
tan = t_tan;
index = j;
}
}
}
return index;
}
int forright(int i) {
int hs = y[i];
double tan = 0.0;
int index = -1;
for (int j = i + 1; j < n; j++) {
if (y[j] > hs) {
hs = y[j];
double t_tan = (y[j] - y[i]) * 1.0 / (x[j] - x[i]);
if (tan < t_tan) {
tan = t_tan;
index = j;
}
}
}
return index;
}
void sort() {
for (int i = n - 1; i > 0; i--) {
if (!light[i - 1]) {
for (int j = 0; j < i; j++) {
if (!light[j] && b[l[j]] > b[l[j + 1]]) {
int tp1 = l[j], tp2 = r[j];
l[j] = l[j + 1];
r[j] = r[j + 1];
l[j + 1] = tp1;
r[j + 1] = tp2;
}
}
}
}
}
int main() {
turnon = 0;
memset(light, 0, sizeof(light));
for (int i = 0; i < n; i++) {
int tl = forleft(i);
int tr = forright(i);
int j = 0;
if (tl < 0)
l[i] = 0;
else {
int ttl = (int)((t - y[i]) * (x[tl] - x[i]) * 1.0
/ (y[tl] - y[i])) + x[i] + 1;
for (; j < m; j++) {
if (b[j] < ttl) continue;
else break;
}
l[i] = j;
}
if (tr < 0)
r[i] = m - 1;
else {
int ttr = (int)((t - y[i]) * (x[tr] - x[i]) * 1.0
/ (y[tr] - y[i])) + x[i];
for (; j < m; j++) {
if (b[j] < ttr) continue;
else break;
}
r[i] = j - 1;
}
}
for (int i = 0; i < n; i++)
for (int j = 0; j < n; j++)
if (i != j)
if (b[l[i]] <= b[l[j]] && b[r[j]] <= b[r[i]])
light[i] = 1;
sort();
int probe = 0;
for (int i = 0; i < n; i++)
if (!light[i]) {
re[turnon] = r[i];
turnon++;
for (int j = probe; j < i; j++) {
if (!light[j]\ & \ & l[j] < r[i]) {
light[j] = 1;
probe = j > probe ? j : probe;
}
}
}
printf("%d\n", turnon);
for (int i = 0; i < turnon; i++)
printf("%d ", re[i] + 1);
}
搬桌子问题
问题描述
某教学大楼一层有 \(n\) 个教室,从左到右依次编号为 \(1, 2, \cdots, n\)。
现在要把一些课桌从某些教室搬到另外一些教室,每张桌子都是从编号较小的教室搬到编号较大的教室,每一趟,都是从左到右走,搬完一张课桌后,可以继续从当前位置或往右走搬另一张桌子。求最少需要跑几趟。
问题分析
一个显然的贪心是将课桌按照七点从小到大排序,每次搬离当前距离最近的课桌即可。
代码实现
#include<stdio.h>
#include<stdlib.h>
#include<map>
//贪心实现搬桌子,每次搬运离当前桌子最近的桌子
typedef struct desk {
int start;
int end;
}
desk;
// 结构体排序
int cmp(void
const * a, void
const * b) {
desk * p = (desk * ) a;
desk * q = (desk * ) b;
if (p -> start != q -> start) return (p -> start) - (q -> start);
return (p -> end) - (q -> end);
}
int main() {
int rooms = 0, desks = 0;
scanf("%d %d\n", & rooms, & desks);
// 结构体数组存放desks
desk * dsk = (desk * ) malloc(sizeof(desk) * desks);
int * isMove = (int * ) malloc(sizeof(int) * desks); //桌子是否搬走
for (int i = 0; i < desks; i++) isMove[i] = 0;
for (int i = 0; i < desks; i++)
scanf("%d %d\n", &dsk[i].start, &dsk[i].end);
int count = 0; //趟数
int tmp = rooms + 1; //设置得比任何房间号都大
//设置得比任何房间号都大
qsort(dsk, desks, sizeof(int), cmp);
int pos = dsk[0].start; //当前位置
int num = 0; //已搬桌子数目
while (num < desks) {
int temp = 0;
for (int i = 0; i < desks; ++i) {
// 从左到右
if (temp <= dsk[i].start && isMove[i] != 1) {
// 下一个位置
temp = dsk[i].end;
isMove[i] = 1;
num++;
}
}
count++;
}
printf("%d", count);
}
总结
本次实验旨在分析与设计分治、贪心和动态规划算法,并通过实验比较它们在解决不同类型问题时的性能和效率。
通过本次实验,我们学到了许多有关分治、贪心和动态规划算法的知识,包括:
- 算法思想: 了解了分治、贪心和动态规划算法的核心思想,以及它们在不同场景下的应用。
- 实现技巧: 掌握了实现这些算法的常用技巧和编程方法,包括递归、迭代、递推等。
- 性能分析: 学会了如何分析算法的时间复杂度和空间复杂度,并通过实验验证了这些分析的准确性。
通过本次实验,我们深刻认识到算法在计算机科学中的重要性和广泛应用。同时,我们也意识到算法设计并不是一件容易的事情,需要不断的学习、实践和总结。在未来的学习和工作中,我们将继续努力,深入研究算法理论,提升自己的算法设计和分析能力。在未来,我们希望能够进一步深入研究和应用分治、贪心和动态规划算法,并探索它们在解决实际问题中的潜在价值。我们也希望能够继续参与算法领域的研究和开发工作,为推动计算机科学的发展做出自己的贡献。
写在最后
傻逼大学。