活动安排问题的动态规划、贪心算法和树搜索算法求解
一、题目描述
1、题目
给定一组活动的开始和结束时间,在其中选出最多活动,使它们不互相冲突。结果我们称之为最大相容活动集。
2、题目分析
其实这个问题还是比较容易理解的,就是在一定时间内尽可能让更多的活动被举行。
比如有一个多媒体教室,现在有四个待举办活动A、B、C、D。A是在8:00到10:00举行,简单记为[8, 10];B是[12, 14];C是[15, 17];D是[11, 19]。为了让尽可能多的活动举行,很明显我们要选择A、B、C为最大相容活动集,因为D与B、C都冲突。
二、分析并解决问题
1、动态规划
这里不再具体讲解动态规划的概念了,在我之前的博客里已经有了,如果不知道什么是动态规划的话,可以自行百度或者到我之前的博客简单了解一下:利用动态规划解决最长增长子序列问题。
(1)分析优化解的结构
在分析之前,我们需要先对活动按照活动结束时间升序排序,这有利于我们之后的分析。
首先,假设我们有已经按照活动结束时间升序排序的活动集[A, B, C, D, E]。如果我们想求[A, B, C, D, E]的最大相容活动集,其实就是[A, B, C, D]的最大相容活动集与活动E的相结合得出的最大相容活动集。但这样说可能比较难以理解,所以我们换种说法,假设A’、B’、C’、D’分别是以A、B、C、D活动结尾的最大相容活动集的个数,所以[A, B, C, D, E]的最大相容活动集的活动个数就是A’、B’、C’、D’与A’’、B’’、C’’、D’’(如果A与E相容,则A’‘就是A’+1)中的最大值。
同样的,要求[A, B, C, D]的最大相容活动集,那该活动集的活动个数便是A’、B’、C’与A’’、B’’、C’'中的最大值。以此类推。
用比较规范的语言来说就是:
其实,这种方法也可以用来解决最大上升子序列的问题,与我之前博客里的方法不一样,大家可以思考一下如何用这种方法解决最大上升子序列问题。
(2)构造状态转换方程
首先,我们使用数组actiArr[i]来记录以下标为i的活动结尾的最大相容活动个数;用preActiArr[i]来记录最大相容活动集中下标为i的活动的前一个活动的下标。
状态转换方程为:
当然这是最基本的转换方程,编程时我们可以添加别的变量使其变得更为简单。
(3)子问题重叠性
由于每次计算预选活动集P时都要获取以之前活动为结尾的最大相容活动集,所以具有子问题重叠性。
2、贪心算法
实际上,活动选择问题就是比较经典的可以用贪心算法解决的问题。
同样的,在分析之前,我们需要先对活动按照活动结束时间升序排序,这有利于我们之后的分析。
(1)选择贪心策略
这里的贪心策略非常简单,每次尽量选择结束时间最早的活动,这样就余下更多的时间,可能容纳更多的活动。
(2)优化子结构和贪心选择性的证明
在这里就不证明了,其实思路和我之前的博客一样,要是感兴趣的话可以去利用贪心算法解决最小平铺路径问题看看。
3、树搜索算法
(1)树搜索算法简介与分析
其实很多问题的解可以表示成为树。解为树的节点或路径。求解这些问题可以转化为树搜索问题。比如深度表示活动的下标,如果在该深度的节点是左子节点,则表示包含该活动;如果是右子节点,则表示不包含该活动。这里我们便用这个思路来解决该问题。
我个人感觉树搜索算法并不是那么常用,比如这个活动规划问题使用树搜索算法就不是特别合适。但树搜索算法中还是有很多方法分支的,如果感兴趣可以学习一下,但在这里就不详细说明了。
在这里我们要使用Hill Climbing策略(爬山策略:首先利用贪心思想快速求出一个较优解。)进行分支界限搜索(分支界限搜索:快速找到一个可行解,并将该解作为阈值,在其他节点进行搜索的时候,预测该节点的极值解(可能是最大解或是最小解,要看问题是要求求最大值或最小值),与阈值相比较,如果不满足阈值的话,直接不再向下搜索,因为我们已经知道再往下搜索得出的解一定不是最终解,再往下搜索也没有意义了,我们也可以称这种方式为枝剪)。
同样的,在分析之前,我们需要先对活动按照活动结束时间升序排序,这有利于我们之后的分析。而树的深度表示活动的下标。
因为我们用了界限分支搜索,所以要确定一个预测解的函数。这里我们预测该节点向下搜索得出解(最大相容活动集)的个数 n 是(该活动之前选择的活动个数 + 该活动之后不与该活动冲突的活动个数)。如果 n 不大于爬山策略得出的解,该节点就不再向下搜索。
当然树的节点也需要储存一些数据,这里我们设定节点的结构是:
1、parent -> 父节点;
2、leftChild -> 左子节点;
3、rightChild -> 右子节点;
4、border -> 当前边界值(即我们选择的活动集合中最晚结束时间);
5、extra -> 被选择活动的数量(通过这个预测最大可能活动数);
6、actiId -> 活动下标(用深度表示下标比较麻烦且不直观);
我们选择爬山策略的的贪心策略就是尽可能构建左子节点(说白了和上面的贪心算法的贪心策略一样,尽可能选结束时间早的活动,所以通过爬山策略取得的解已经是最优解,之后的搜索操作都是无意义的,因为这个问题本来就不适合用树搜索算法做,这里只是模拟树搜索算法的过程)。
三、算法实现
1、c语言
首先我们需要几个头文件声明一些结构体、随机生成活动序列的函数以及排序函数。
活动节点(node.h):
#ifndef __NODE_H__
#define __NODE_H__
// node
typedef struct Node{
int x; // 活动开始时间
int y; // 活动结束时间
} Node;
#endif
布尔类型(bool.h):
#ifndef __BOOL_H__
#define __BOOL_H__
// bool type
typedef enum {
false, true
} bool;
#endif
获取随机活动集合(random_arr.c):这里函数不是很严谨,可能存在(20, 20)的活动,但没什么影响。
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#include "bool.h"
#include "node.h"
//#define SIZE 10 // 活动数
#define random(min, max) (((min) + rand()%((max)-(min)))+1) // 随机数组上下界
//#define RANGE_MIN 0 // 下界
//#define RANGE_MAX 20 // 上界
void randomArr(Node* S, int SIZE, int RANGE_MIN, int RANGE_MAX){
srand((unsigned) time(NULL));
for(int i = 0; i < SIZE; i++){
Node node;
node.x = random(RANGE_MIN, RANGE_MAX);
node.y = random(RANGE_MIN, RANGE_MAX);
while(node.x >= node.y){
if(node.x == RANGE_MAX){
node.y = RANGE_MAX;
break;
}
if(node.x == RANGE_MAX - 1){
node.y = RANGE_MAX;
} else {
node.y = node.y + random(node.x - node.y + 1, RANGE_MAX - node.y);
}
}
S[i] = node;
}
}
归并排序(merge_sort.c):
#include "bool.h"
#include "node.h"
// 归并排序(稳定的)
// x -> true : 对前界排序
// asc -> true : 升序排序
void mergeSort(Node* nodes, int size, bool x, bool asc){
_mergeSort(nodes, 0, size - 1, x, asc);
}
// 归并排序
void _mergeSort(Node* nodes, int left, int right, bool x, bool asc){
if(left < right){
int mid = (left + right) / 2;
_mergeSort(nodes, left, mid, x, asc);
_mergeSort(nodes, mid + 1, right, x, asc);
_merge(nodes, left, mid, right, x, asc);
}
}
// 归并
void _merge(Node* nodes, int left, int mid, int right, bool x, bool asc){
// 暂存结果
Node temp[right - left + 1];
// 左右索引
int l_idx = left;
int r_idx = mid + 1;
int temp_idx = 0;
// 归并
while(l_idx <= mid && r_idx <= right){
if(x && asc){
if((nodes + l_idx)->x <= (nodes + r_idx)->x){ // 这个比较的等于号是稳定性的关键
temp[temp_idx++] = *(nodes + l_idx);
l_idx++;
} else {
temp[temp_idx++] = *(nodes + r_idx);
r_idx++;
}
} else if(x && !asc){
if((nodes + l_idx)->x >= (nodes + r_idx)->x){
temp[temp_idx++] = *(nodes + l_idx);
l_idx++;
} else {
temp[temp_idx++] = *(nodes + r_idx);
r_idx++;
}
} else if(!x && asc){
if((nodes + l_idx)->y <= (nodes + r_idx)->y){
temp[temp_idx++] = *(nodes + l_idx);
l_idx++;
} else {
temp[temp_idx++] = *(nodes + r_idx);
r_idx++;
}
} else if(!x && !asc){
if((nodes + l_idx)->y >= (nodes + r_idx)->y){
temp[temp_idx++] = *(nodes + l_idx);
l_idx++;
} else {
temp[temp_idx++] = *(nodes + r_idx);
r_idx++;
}
}
}
// 将剩余的部分附在最后
while(l_idx <= mid){
temp[temp_idx++] = *(nodes + l_idx);
l_idx++;
}
while(r_idx <= right){
temp[temp_idx++] = *(nodes + r_idx);
r_idx++;
}
// 将暂存的数据写回
for(int i = 0; i < right - left + 1; i++){
*(nodes + left + i) = temp[i];
}
}
(1)动态规划
为了减少状态转换方程那里的判断复杂程度以及最后求活动集合的难度。这里引进max_idx表示最大相容活动集的最后一个活动的下标和max_num表示最大相容活动集的活动个数。
#include <stdio.h>
#include <stdlib.h>
#include "node.h"
#include "bool.h"
#define SIZE 10 // 活动数
#define RANGE_MIN 0 // 下界
#define RANGE_MAX 20 // 上界
#define BORDER -1 // 边界
#define INIT 1 // 初始化值
int max_idx = 0;
int max_num = 0;
Node S[SIZE]; // 活动
int actiArr[SIZE]; // 以a(i)为结尾的最大活动数数组
int preActiArr[SIZE]; // 活动a(i)的前一个活动
void dynamic_progranmming(){
printf("\nThis is dynamic programming\n");
// 获取随机活动
randomArr(S, SIZE, RANGE_MIN, RANGE_MAX);
// 打印活动序列
printf("random array:\n");
_printActi();
// 初始化活动矩阵
_initActiArr();
// 排序
mergeSort(S, SIZE, false, true);
// 打印活动序列
printf("sorted array:\n");
_printActi();
// 求解
_dynamic_progranmming();
// 打印结果
_dynamic_result();
//printf("max idx : %d\n", max_idx);
//printArr(preActiArr);
//printf("max seq :\n");
//printArr(actiArr);
}
// 求解
void _dynamic_progranmming(){
// 遍历求解
int max;
for(int i = 1; i < SIZE; i++){
// 回查
max = 1;
for(int j = i - 1; j >= 0; j--){
if(S[i].x >= S[j].y && actiArr[j] + 1 > max){
max = actiArr[j] + 1;
preActiArr[i] = j;
}
}
actiArr[i] = max;
// 记录极值
if(max_num < max){
max_num = max;
max_idx = i;
}
}
}
// 初始化活动数组
void _initActiArr(){
for(int i = 0; i < SIZE; i++){
actiArr[i] = INIT;
preActiArr[i] = BORDER;
}
}
// 打印随机生成的活动
void _printActi(){
for(int i = 0; i < SIZE; i++){
printf("no.%d -> (%d, %d)\n", i, S[i].x, S[i].y);
}
}
// 打印数组
void printArr(int* a){
for(int i = 0; i < SIZE; i++){
printf("no.%d -> %d\n", i, *(a+i));
}
}
// 打印结果
void _dynamic_result(){
printf("max acti num -> %d, they are:\n", max_num);
_dynamic_node_result(max_idx);
}
// 打印节点
void _dynamic_node_result(int pos){
if(preActiArr[pos] == BORDER){
printf("(%d, %d)\n", S[pos].x, S[pos].y);
return;
}
_dynamic_node_result(preActiArr[pos]);
printf("(%d, %d)\n", S[pos].x, S[pos].y);
}
(2)贪心算法
贪心算法比较简单,没什么好说的。
#include <stdio.h>
#include <stdlib.h>
#include "node.h"
#include "bool.h"
#define SIZE 10 // 活动数
#define RANGE_MIN 0 // 下界
#define RANGE_MAX 20 // 上界
Node S[SIZE]; // 活动
int idx[SIZE]; // 结果集合
int top = 0; // 结果集合顶部下标
void greedy(){
printf("\nThis is greedy\n");
// 获取随机活动
randomArr(S, SIZE, RANGE_MIN, RANGE_MAX);
// 打印活动序列
printf("random array:\n");
_printActi();
// 排序
mergeSort(S, SIZE, false, true);
// 打印活动序列
printf("sorted array:\n");
_printActi();
// 贪心算取结果
_greedy();
// 打印结果
_greedy_result();
}
// 贪心获取结果
void _greedy(){
idx[top++] = 0;
for(int i = 1; i < SIZE; i++){
if(S[i].x >= S[idx[top - 1]].y){
idx[top++] = i;
}
}
}
// 打印结果
void _greedy_result(){
printf("max acti num -> %d, they are:\n", top);
for(int i = 0; i < top; i++){
printf("(%d, %d)\n", S[idx[i]].x, S[idx[i]].y);
}
}
(3)树搜索算法
这里我们需要引进树节点的结构体头文件(tree.h):
#ifndef __TREE_H__
#define __TREE_H__
// Tree node
typedef struct TreeNode{
struct TreeNode* parent;
struct TreeNode* leftChild;
struct TreeNode* rightChild;
int border; // 当前边界值
int extra; // 记录当前活动数
int actiId; // 活动序号
} TreeNode;
#endif
核心代码:
#include <stdio.h>
#include <stdlib.h>
#include "bool.h"
#include "node.h"
#include "tree.h"
#define SIZE 10 // 活动数
#define RANGE_MIN 0 // 下界
#define RANGE_MAX 20 // 上界
Node S[SIZE]; // 活动
TreeNode *root; // 根节点
TreeNode *lastActi; // 最大相容活动数对应的叶节点
int treeNodeNum = 0; // 树节点总数
void tree_search(){
printf("\nThis is tree search\n");
// 获取随机活动
randomArr(S, SIZE, RANGE_MIN, RANGE_MAX);
// 打印活动序列
printf("random array:\n");
_printActi();
// 排序
mergeSort(S, SIZE, false, true);
// 打印活动序列
printf("sorted array:\n");
_printActi();
// 初始化根节点
_initRoot();
// 界限法获取第一个结果
_treeFirstResult();
// 枝剪
_treeSearchPre(root);
// 打印结果
_treePrintResult();
}
// 初始化根节点
void _initRoot(){
root = (TreeNode*)malloc(sizeof(TreeNode));
root->border = 0;
root->extra = 0;
root->actiId = -1;
root->parent = NULL;
root->leftChild = NULL;
root->rightChild = NULL;
}
// 构造节点
TreeNode* _createTreeNode(TreeNode* parent, int border, int extra, int actiId, bool leftChild){
TreeNode* node;
if(leftChild){
parent->leftChild = (TreeNode*)malloc(sizeof(TreeNode));
node = parent->leftChild;
} else {
parent->rightChild = (TreeNode*)malloc(sizeof(TreeNode));
node = parent->rightChild;
}
node->border = border;
node->extra = extra;
node->actiId = actiId;
node->parent = parent;
node->rightChild = NULL;
node->leftChild = NULL;
return node;
}
// 界限法,先获取一个结果
void _treeFirstResult(){
TreeNode* node = root;
for(int i = 0; i < SIZE; i++){
if(node->parent != NULL){
//printf("%d, %d\n",node->parent->actiId, node->parent->border);
}
if(S[i].x >= node->border){
node = _createTreeNode(node, S[i].y, node->extra + 1, i, true);
} else {
node = _createTreeNode(node, node->border, node->extra, i, false);
}
}
// 第一个结果
lastActi = node;
}
// 获取可能的最大值(这里是与目标下标对应的活动之后与该活动相容的活动数)
int _getPossibleMaxActiNum(int actiId){
int max = 0;
for(int i = actiId + 1; i < SIZE; i++){
if(S[i].x >= S[actiId].y){
max++;
}
}
return max;
}
// 前序遍历进行枝剪
void _treeSearchPre(TreeNode* node){
treeNodeNum++;
if(node == NULL){
return;
} else {
// 树的深度不能超过活动数量上限
if(node->actiId >= SIZE - 1){
return;
}
// 判断是否有打到最大相容活动数的可能性
if(lastActi->extra < node->extra + _getPossibleMaxActiNum(node->actiId)){
// 不管怎么样,右子节点一定是可以有的
if(node->rightChild == NULL){ // 该子节点已经有了的话就不创建了
_createTreeNode(node, node->border, node->extra, node->actiId + 1, false);
}
// 尝试创建左子节点
if(node->leftChild == NULL && S[node->actiId + 1].x >= node->border){ // 该子节点已经有了的话就不创建了
_createTreeNode(node, S[node->actiId + 1].y, node->extra + 1, node->actiId + 1, true);
// 如果最大相容活动数更新的话,更新最大叶子节点
// 但这里实际上不是叶子节点,应该是最后一个被包含的活动对应的节点,可以减少操作
// 只有创建完左子节点(新包含了一个活动),才可能更新最大相容活动数,所以这里判断该节点新建的左子节点记录的extra(最大相容活动数)是否超过了之前记录的节点记录的extra,刷新了最大相容活动数上限
if(node->leftChild->extra > lastActi->extra){
lastActi = node->leftChild;
}
}
// 继续遍历
_treeSearchPre(node->leftChild);
_treeSearchPre(node->rightChild);
} else {
// 没有的话直接结束(枝剪)
return;
}
}
}
// 打印最大相容活动集合
void _treePrintResult(){
printf("there are %d tree nodes, max acti num -> %d, they are:\n", treeNodeNum, lastActi->extra);
_treePrintActis(lastActi);
}
// 递归获取每个活动
void _treePrintActis(TreeNode* node){
// 如果是根节点,停止递归
if(node->parent == NULL){
return;
}
// 递归
_treePrintActis(node->parent);
// 如果是左节点,说明该活动在结果集中
if(node == node->parent->leftChild){
printf("(%d, %d)\n", S[node->actiId].x, S[node->actiId].y);
}
}
(4)测试代码
#include <stdio.h>
#include <stdlib.h>
int main()
{
// dynamic
dynamic_progranmming();
// greedy
greedy();
// tree search
tree_search();
return 0;
}
(5)测试结果
四、后记
这次写的也比较仓促,有很多地方讲解的不够清楚,但我希望这个博客只是个引导,要想学到真东西,只看这篇博客是远远不够的,希望这些思路和代码能带给大家启发,帮助到大家。代码我会上传到空间,可以自取。如果有什么问题,可以在评论区提出。谢谢观看!