这一篇主要关于最优二叉查找树的分析与实现,以及算法导论动态规划一章的几道思考题。
最优二叉查找树
最优二叉查找树,是经过优化的二叉查找树。在知晓每个键被查询到的概率的情况下,生成一棵二叉查找树,使得查找的期望深度最小。最优二叉查找树仍然保持二叉查找树的性质,所以查找概率最大的键不一定是树的跟。
形式地,给定由 $n$ 个互异关键字组成的有序序列 $K=\{k_{1},k_{2},...k_{n}\}$,对每个关键字 $k_{i}$,被查找的概率是 $p_{i}$,对每两个关键字 $k_{i}$ 和 $k_{i+1}$,查找落在区间 $(k_{i}, k_{i+1})$ (而查找失败的)的概率为 $q_{i+1}$,构造一棵二叉查找树使一次查找的期望深度 $E$ 最低。令序列 $d_{i}$ 为虚拟键,表示区间 $(k_{i-1}, k_{i})$ 。
查找期望深度为:
$$E_{tree}=\sum_{i=1}^{n}(depth(k_{i})+1)\cdot p_{i}+\sum_{i=0}^{n}(depth(d_{i})+1)\cdot q_{i}$$
递归的,查找期望深度也可以写成:
$$E_{tree}=(E_{left}+1)\cdot p_{left}+(E_{right}+1)\cdot p_{right}+1\cdot p_{root}$$
解决该问题的思路是:
序列 $k_{1}...k_{n}$ 的最优二叉查找树,就是以下这些查找树中,深度最小的一棵:
- $k_{1}$ 为根,左子树为空树,右子树为序列 $k_{2}...k_{n}$ 的最优二叉查找树 的 二叉查找树。
- ...
- $k_{i}$ 为根,左子树为序列 $k_{1}...k_{i-1}$ 的最优二叉查找树,右子树为序列 $k_{i+1}...k_{n}$ 的最优二叉查找树 的 二叉查找树。
- ...
- $k_{n}$ 为根,左子树为序列 $k_{1}...k_{n-1}$ 的最优二叉查找树,右子树为空树 的 二叉查找树。
根据递归的查找期望深度,对 $k_{i}$ 为根的二叉查找树,如果它是最优二叉查找树,则两个子树也一定是最优二叉查找树。需要记录的子问题就是,序列 $k_{i}...k_{j}$ 的最优二叉查找树深度,以及最优二叉查找树。在一张二维表中维护该深度,最后查询 $k_{1}...k_{n}$ 的最有二叉查找树就可以。
我的实现如下:为了简便,仅仅是求出了最优二叉查找树的深度,没有真正去维护树本身。
double getDepth(const std::vector<std::vector<double>>& depthTable, const int i, const int j){ if (i>j){ return 1; } else{ return depthTable[i][j]; } } double getProb(const std::vector<double>& p, const std::vector<double>& q, const int i, const int j){ if (i>j){ return q[i]; } double rslt = 0; for (int s=i; s<=j; s++){ rslt += p[s]+q[s]; } rslt += q[j+1]; return rslt; } void makeOBST(const std::vector<std::string>& k, const std::vector<double>& p, const std::vector<double>& q, std::vector<std::vector<double>>& depthTable, std::vector<std::vector<BryTree>>& treeTable, int i, int j){ if (i>j){return;} double minDepth = INT_MAX; for (int s=i; s<=j; s++){ const int i1 = i; const int j1 = s-1; const int i2 = s+1; const int j2 = j; makeOBST(k, p, q, depthTable, treeTable, i1, j1); makeOBST(k, p, q, depthTable, treeTable, i2, j2); double depth1 = getDepth(depthTable, i1, j1); double depth2 = getDepth(depthTable, i2, j2); double prob1 = getProb(p, q, i1, j1); double prob2 = getProb(p, q, i2, j2); double depth = (prob1*(depth1+1) + p[s]*1 + prob2*(depth2+1))/(prob1+prob2+p[s]); if(minDepth > depth){ minDepth = depth; } } depthTable[i][j] = minDepth; } void OBST(const std::vector<std::string>& k, const std::vector<double>& p, const std::vector<double>& q){ if (k.size()!=p.size() || p.size()!=q.size()-1){ throw std::runtime_error("OBST input vectors' sizes do not match."); } const int n = k.size(); std::vector<std::vector<BryTree>> treeTable(n, std::vector<BryTree>(n)); std::vector<std::vector<double>> depthTable(n, std::vector<double>(n, -1)); makeOBST(k, p, q, depthTable, treeTable, 0, n-1); std::cout<<depthTable[0][n-1]; }
传入数据 $p=\{0.15,0.10,0.05,0.10,0.20\}$ 和 $q=\{0.05,0.10,0.05,0.05,0.05,0.10\}$,输出结果为 $2.75$,与算法导论上相符。
思考题15-1 双调欧几里得问题 欧几里得旅行商问题是指平面上给定的 $n$ 个点,确定一条连接各点的最短闭合折线。这是一个NP完全问题。双调欧几里得问题对其进行了简化,给出从 最左侧的 点出发,严格从左至右经过各点,到达最右侧的点,再从最右侧的点严格从右至左回到最左侧的点。下图显示了7点欧几里得旅行商问题的解(左图)以及双调版本问题的解(右图)。
思路:将所有点从左向右排序,为 $k_{1}...k_{n}$ ,该序列的两个子集 $p_{1}...p_{s}$ 和 $q_{1}...q_{t}$,两个子集的交集为只有 $\k_{1}$和$k_{n}$两个元素。问题可以这样考虑:最短路径就是以下两条路径较短的一条:
- $p_{s}$为$k_{n}$,$q_{t}$为$k_{n-1}$时二者的最短路径,加上$q_{t}$与$k_{n}$间的距离。
- $p_{s}$为$k_{n-1}$,$q_{t}$为$k_{n}$时二者的最短路径,加上$p_{s}$与$k_{n}$间的距离。
可递归的并需要维护的子问题就是:一条路径以 $k_{i}$ 为终点,一条路径以 $k_{j}$ 为终点,路径不交叉,不遗漏 $k_{n}...k_{max(i,j)}$ 间的任意一点,的最短路径之和。那就是以下两条路径中最短的(认为 $j>i$):
- $P$ 以 $k_{i}$ 为终点,$Q$ 以 $k_{j-1}$ 为终点,再加上 $k_{i}$ 到 $k_{j}$ 的距离($k_{j}$ 加在 $P$ 上)。
- $P$ 以 $k_{i}$ 为终点,$Q$ 以 $k_{j-1}$ 为终点,再加上 $k_{j-1}$ 到 $k_{j}$ 的距离($k_{j}$ 加在 $Q$ 上)。
思考题15-2 整齐打印 在打印机上整齐地打印一个段落,段落由长度为 $L=\{l_{1}...l_{n}\}$ 的 $n$ 个单词组成,每一行至多输入 $M$ 个字符。如果某一行包括了单词 $l_{i}...l_{j}$,那么行末的空余字符格数为 $M-j+i-\sum_{k=i}^{j}l_{k}$。要求整个段落行末多余空格字符数的立方和最小。
思路:可递归的并需要维护的子问题就是:段落 $l_{i}...l_{n}$ 的整齐打印后的多余空格字符数立方和。假设第一行最多打印 $s$ 个字符,那么该子问题就的最优解就是如下数个问题中的最优的那个解:
- 第一行只打印 $1$ 个字符,第一行的多余空格字符数立方 加上 $l_{i+1}...l_{n}$。
- ...
- 第一行打印 $j$ 个字符,第一行的多余空格字符数立方 加上 $l_{i+j}...l_{n}$。
- ...
- 第一行打印 $s$ 个字符,第一行的多余空格字符数立方 加上 $l_{i+s}...l_{n}$。
整个问题就是求段落 $l_{1}...l_{n}$ 的整齐打印。
思考题15-4 计划公司聚会 一个公司具有层次式结构,所有职员形成了一棵以总裁为根的树。每一个职员都有对聚会的喜爱程度(一个实数),但每个职员都不想在聚会上遇到自己的直接上司。生成一张名单,使所有参与者对聚会喜爱程度的和最大,又避免任意两位参与者是直接的上下级关系。
思路:需要维护的子问题是,以某个职员为根的树,按照如上规则参加聚会的最大喜爱程度之和。为每个职员维护这个域。
- 当这个职员是叶子时,如果喜爱度是正,那就参加,喜爱度是负,就不参加。
- 当这个成员不是叶子时,而该成员的喜爱度为正,那么以他为跟的树的最大喜爱程度之和为以下数个情况下的最优解:
- 他不参加,他的所有直接下属为根的树的喜爱度之和相加。
- 他参加,他的所有直接下属的直接下属为根的树的喜爱度之和相加。
- 当这个成员不是叶子是,而该成员喜爱度为负,问题的解同上一条中的第一小条。
最后考察以总裁为根的树,即整个公司,参加聚会的成员。
思考题15-6 在棋盘上移动 一张 $n×n$ 的方格期盼,棋子从顶边移到底边,只能直行,斜行,类似于国际象棋中的小卒(只不过是从底边开始,而且斜行并不一定要吃掉棋子)。没走一次都会获得一笔金钱,选择合适的底边格子,完成上述移动过程(移动到顶边),并尽可能多地收集金钱。
思路:很简单,为每个格子维护一个域,表示从这个格子开始移动到顶边获取的最大金钱数。顶边上都是0,从顶边开始逐行生成,直到底边。在底边选取最大的域就可以了。
思考题15-7 最高效益调度 一台机器,在此机器上可处理 $n$ 个作业 $a_{1},a_{2}...a_{n}$ ,每个左右有一个处理时间 $t_{j}$,以及最后期限 $d_{j}$ 和 效益 $p_{j}$。假设处理时间都是整数,如何安排处理哪些作业,使效益最高。
思路:所有作业的最后期限,和开始作业的最早时间,之间的时间段,分割成整数段 $0~m$(每一段对应于处理时间中整数的单位)。然后为 $0...m$ 中的每个点 $i$ 维护一个域,表示 $0...i$ 时间段的最高效益及其作业分配情况。
总结
总算有点明白了“动态规划”四个字的含义。我想,如果将递归版本的算法全部展开为非递归的,那么大概可以看到,算法在不断产生局部子问题的最优解,并从最优解中寻找较大子问题的最优价,所有的子问题最优解都被妥善地维护起来,而不是立刻抛弃,这样下次遇到该子问题就可以直接使用结果了。