贪心算法与高精度实现:从“国王游戏”问题解析算法思维与工程实践
2026/8/29 2:32:29 网站建设 项目流程

1. 从“国王游戏”到“贪心”的直觉挑战

如果你在洛谷刷题,或者正在准备算法竞赛,大概率会碰到P1080“国王游戏”这道题。它常常被归类为“贪心”算法的经典例题,但第一次接触时,很多人会感到困惑:这明明是一个关于排序的问题,为什么标签是“贪心”?更让人头疼的是,题目描述里涉及大臣们左手和右手的数字,以及一个看似复杂的奖赏规则,直觉上很难直接看出应该按什么标准来排序这些大臣。

我第一次做这道题时,也卡了很久。尝试了按左手数字升序、按右手数字升序、甚至按两者之和排序,提交后无一例外都是错误的。这恰恰是“贪心”类题目的魅力与难点所在:它要求你从问题本质中抽象出一个“局部最优”的决策标准,并且要能证明这个标准能导向全局最优解。对于“国王游戏”,这个标准并非显而易见,需要一番推导和洞察。

简单来说,这道题的情景是:国王和n个大臣排成一列,每个人左右手各写一个正整数。队伍最前面是国王。每位大臣获得的金币数等于他前面所有人(包括国王)左手数字的乘积,除以他自己右手的数字,然后向下取整。现在,你可以任意安排大臣们的排列顺序,目标是让获得最多金币的大臣,所得的金币数尽可能少。这是一个典型的“最小化最大值”问题,而解决方案的核心,就在于找到一个正确的大臣排列顺序。

网上很多题解直接给出了结论:按照每个大臣左手与右手数字的乘积升序排列。但作为一篇深入的解析,我们不仅要“知其然”,更要“知其所以然”。接下来,我将带你一步步拆解这个问题,从理解题意、建立数学模型,到推导出关键的排序策略,并最终给出高精度处理的实现细节。你会发现,这不仅仅是一道题的解,更是一次完整的贪心算法思维训练。

2. 问题建模:将宫廷游戏转化为数学表达式

要解决算法问题,第一步永远是彻底理解题意并将其形式化。我们先把题目描述翻译成清晰的数学语言。

设国王左手的数字为 $a_0$,右手的数字为 $b_0$(虽然国王的右手数字在计算中似乎用不到,但题目会给出)。有 $n$ 位大臣,第 $i$ 位大臣左手的数字为 $l_i$,右手的数字为 $r_i$。

假设我们确定了一个排列顺序,大臣们的序号按照这个顺序重新标记为 $1, 2, ..., n$。那么,排在第 $i$ 位的大臣:

  • 他前面所有人的左手数字乘积为:$A_i = a_0 \times l_1 \times l_2 \times ... \times l_{i-1}$
  • 他获得的金币数为:$P_i = \lfloor \frac{A_i}{r_i} \rfloor$,其中 $\lfloor \cdot \rfloor$ 表示向下取整。

我们的目标是:在所有可能的排列中,找到一个排列,使得 $\max(P_1, P_2, ..., P_n)$ 的值最小。

这里有一个关键点:向下取整函数 $\lfloor \cdot \rfloor$ 的存在,使得问题分析变得复杂。为了简化最初的贪心策略推导,一个常见的技巧是先忽略取整操作,考虑连续值 $C_i = A_i / r_i$。我们首先尝试最小化 $\max(C_1, C_2, ..., C_n)$。因为对于一组确定的 $A_i$ 和 $r_i$,有 $\lfloor C_i \rfloor \le C_i$,且当 $C_i$ 整体变小时,$\lfloor C_i \rfloor$ 的最大值通常也会变小或不变。所以,最小化 $C_i$ 的最大值是原问题一个合理的近似和目标。我们后续的贪心策略推导将基于 $C_i$ 进行。

现在,问题转化为:如何排列大臣,使得序列 ${ C_i = \frac{a_0 \times l_1 \times ... \times l_{i-1}}{r_i} }$ 的最大值最小?

3. 贪心策略的推导:为什么是“左右手乘积”?

这是全文最核心的部分。我们将通过分析相邻两项交换的影响,来推导出全局最优的排序策略。

考虑队列中相邻的两位大臣 $i$ 和 $j$,他们当前的位置是 $i$ 在前,$j$ 在后。设他们前面所有人的左手乘积为 $S$(一个固定的值)。

  • 原顺序($i$ 在前,$j$ 在后)下:

    • 大臣 $i$ 获得的金币数(连续值)为:$C_{i1} = \frac{S}{r_i}$
    • 大臣 $j$ 获得的金币数为:$C_{j1} = \frac{S \times l_i}{r_j}$
    • 此时,这两位大臣相关的最大金币值为 $M_1 = \max(C_{i1}, C_{j1})$
  • 如果交换顺序($j$ 在前,$i$ 在后):

    • 大臣 $j$ 获得的金币数为:$C_{j2} = \frac{S}{r_j}$
    • 大臣 $i$ 获得的金币数为:$C_{i2} = \frac{S \times l_j}{r_i}$
    • 此时,这两位大臣相关的最大金币值为 $M_2 = \max(C_{j2}, C_{i2})$

我们关心的是,哪种顺序能使得 $M_1$ 和 $M_2$ 更小?或者说,在什么条件下,原顺序比交换后的顺序更优(即 $M_1 \le M_2$)?

由于 $S > 0$,我们可以比较 $M_1$ 和 $M_2$。一个重要的观察是:为了最小化整个序列的最大值,我们肯定希望任意相邻两项的排列,都能使它们俩之间的最大值尽可能小。这是一种典型的贪心思想:通过保证局部相邻顺序最优,来期望达到全局最优。这通常需要满足“邻项交换”的贪心证明。

我们来推导 $M_1 \le M_2$ 的条件: $M_1 = \max(\frac{S}{r_i}, \frac{S \times l_i}{r_j})$ $M_2 = \max(\frac{S}{r_j}, \frac{S \times l_j}{r_i})$

两边同时除以 $S$(正数,不影响大小关系),比较式变为: $\max(\frac{1}{r_i}, \frac{l_i}{r_j}) \le \max(\frac{1}{r_j}, \frac{l_j}{r_i})$

这个式子看起来还是有点复杂。为了简化,我们假设在最优排列中,交换任意相邻两项都不会使结果变差。那么,对于任意的 $i, j$,如果 $i$ 排在 $j$ 前面是最优的,那么必须有 $M_1 \le M_2$。

经过一些数学变换(可以尝试通分、去分母),或者通过更直观的“猜想-验证”法,结合大量题解和经验,我们可以得到一个更简洁的充分条件:当 $l_i \times r_i \le l_j \times r_j$ 时,将 $i$ 排在 $j$ 前面,可以保证 $M_1 \le M_2$

让我们来验证一下这个猜想。设 $T_i = l_i \times r_i$, $T_j = l_j \times r_j$,且 $T_i \le T_j$。 我们需要证明 $\max(\frac{1}{r_i}, \frac{l_i}{r_j}) \le \max(\frac{1}{r_j}, \frac{l_j}{r_i})$。

这个证明需要分情况讨论,并且并非绝对严格(因为 $\max$ 函数的存在),但在竞赛和本题的语境下,这是一个被广泛接受且正确的结论。一种理解方式是:$l \times r$ 这个乘积,某种程度上综合了该大臣“对前面乘积的贡献”($l$)和“对自身金币数的放大系数”($1/r$)。乘积小的人,意味着他要么左手数字小(对后续累积影响小),要么右手数字大(自身金币数被除得多),让他排前面,对整体最大值的增长压力更小。

因此,我们的贪心策略就是:将所有大臣按照 $l_i \times r_i$ 的值从小到大进行排序。然后按照这个顺序依次排队,计算每个大臣的金币数,并找出其中的最大值。

注意:这里有一个非常重要的细节,就是国王的位置是固定的,始终在最前面。我们排序和计算的对象只是大臣。国王的左手数字 $a_0$ 是初始乘积的一部分。

4. 高精度计算:算法思路落地的主要障碍

推导出排序策略,只完成了问题的一半。本题另一个核心难点,也是主要的代码实现难点,在于数据的规模。题目中 $a_0, b_0, l_i, r_i$ 均可以是很大的整数(上限为10000),而 $n$ 最多为1000。

考虑最极端的情况:国王左手数字是10000,每个大臣的左手数字也都是10000。那么排在第1000位的大臣,他前面的乘积 $A_i$ 将是 $10000^{1000}$。这是一个有 $4001$ 位十进制数字的天文数字!显然,任何标准整数类型(如int,long long)都无法存储。因此,我们必须进行高精度运算

具体到本题,我们需要两种高精度操作:

  1. 高精度乘法:用于计算前缀乘积 $A_i = A_{i-1} \times l_i$。
  2. 高精度除法:用于计算每位大臣的金币数 $P_i = \lfloor A_{i-1} / r_i \rfloor$。注意,这里是被除数 $A_{i-1}$ 是高精度数,除数 $r_i$ 是普通整数。

此外,我们还需要高精度数之间的比较,以找出所有 $P_i$ 中的最大值。

4.1 高精度数的表示与乘法

通常,我们用数组或vector来存储高精度数,每个元素代表十进制的一位(或一个数字块,如万进制)。为了乘法方便,我们采用倒序存储,即数组下标0存储个位,下标1存储十位,以此类推。

高精度乘单精度的乘法是基础。其过程类似于竖式乘法:从低位到高位,当前位的值等于当前位 * 单精度数 + 进位,然后取模10得到该位结果,整除10作为新的进位。

// 示例:高精度向量a乘以整数b,结果存储在a中 vector<int> mul(vector<int> &a, int b) { vector<int> c; int t = 0; // 进位 for (int i = 0; i < a.size() || t; i++) { if (i < a.size()) t += a[i] * b; c.push_back(t % 10); t /= 10; } // 去除前导零,但注意乘积为0时应保留一个0 while (c.size() > 1 && c.back() == 0) c.pop_back(); return c; }

在本题中,我们维护一个当前的前缀乘积curr_product(高精度),初始值为国王的左手数字 $a_0$。每处理到一个新大臣 $i$,我们先用当前乘积除以该大臣的右手数字 $r_i$,得到金币数 $P_i$,然后再将curr_product乘以该大臣的左手数字 $l_i$,更新前缀乘积以供下一位大臣使用。

这个顺序很重要:对于第 $i$ 位大臣,他的金币数基于他前面所有人的左手乘积,即还没有乘上他本人左手数字时的curr_product

4.2 高精度除以单精度

高精度除以单精度相对简单,得到的是整数商。我们从被除数的高位开始模拟除法过程。注意,我们的高精度数是倒序存储(个位在低索引),但除法需要从最高位开始。所以,在除法函数内部,我们通常从向量的末尾(即数字的最高位)开始遍历。

// 示例:高精度向量a除以整数b,商存储在结果向量中,返回余数(本题不需要) vector<int> div(vector<int> &a, int b, int &r) { // r是余数引用 vector<int> c; r = 0; // 注意!这里要从最高位开始除,所以倒序遍历a for (int i = a.size() - 1; i >= 0; i--) { r = r * 10 + a[i]; c.push_back(r / b); r %= b; } // 此时c是正序存储的(高位在低索引),且可能有前导零 reverse(c.begin(), c.end()); while (c.size() > 1 && c.back() == 0) c.pop_back(); return c; }

在本题中,我们调用div(curr_product, r_i, remainder)来得到金币数 $P_i$(高精度向量)。然后我们需要比较这个 $P_i$ 和当前记录的最大金币数max_reward,更新最大值。

4.3 高精度数的比较

比较两个高精度数的大小,规则如下:

  1. 首先比较位数。位数多的数更大。
  2. 如果位数相同,则从最高位(存储向量的末尾)开始逐位比较,直到出现不同的数字。
// 示例:比较高精度向量a和b的大小,a>b返回1,a<b返回-1,相等返回0 int cmp(vector<int> &a, vector<int> &b) { if (a.size() != b.size()) return a.size() > b.size() ? 1 : -1; for (int i = a.size() - 1; i >= 0; i--) { if (a[i] != b[i]) return a[i] > b[i] ? 1 : -1; } return 0; }

这样,我们就可以在计算每个 $P_i$ 时,用cmp(P_i, max_reward)来更新全局最大值。

5. 完整实现流程与代码框架

将以上所有步骤串联起来,我们就得到了解决“国王游戏”的完整算法流程:

  1. 数据输入与存储:读入国王的 $a_0$, $b_0$ 和 $n$,然后读入 $n$ 个大臣的 $(l_i, r_i)$,存储在一个结构体或pair数组中。
  2. 排序:按照 $l_i \times r_i$ 的值,对所有大臣进行升序排序。这是贪心策略的核心。
  3. 初始化
    • max_reward(高精度)初始化为0(即向量{0})。
    • curr_product(高精度)初始化为 $a_0$。这里需要注意,$a_0$ 可能很大,需要用高精度数表示。一个简单的方法是将其每一位数字拆开,倒序存入向量。
  4. 遍历计算:按排序后的顺序遍历每一位大臣minister[i]: a.计算金币:令reward = div(curr_product, minister[i].r, remainder)。这里div函数返回商(高精度),remainder是余数(本题用不到,但函数需要)。 b.更新最大值:比较rewardmax_reward,如果reward > max_reward,则更新max_reward = reward。 c.更新前缀乘积:令curr_product = mul(curr_product, minister[i].l),为下一位大臣的计算做准备。
  5. 输出结果:将max_reward这个高精度数从最高位到最低位依次输出。

下面是一个C++的代码框架,体现了上述逻辑:

#include <iostream> #include <vector> #include <algorithm> using namespace std; struct Minister { int l, r; // 重载小于运算符,用于按 l*r 排序 bool operator<(const Minister &other) const { return l * r < other.l * other.r; } }; // 高精度乘法:向量a * 整数b,返回结果向量 vector<int> mul(vector<int> &a, int b) { vector<int> c; int t = 0; for (int i = 0; i < a.size() || t; i++) { if (i < a.size()) t += a[i] * b; c.push_back(t % 10); t /= 10; } while (c.size() > 1 && c.back() == 0) c.pop_back(); return c; } // 高精度除法:向量a / 整数b,返回商向量,r是余数 vector<int> div(vector<int> &a, int b, int &r) { vector<int> c; r = 0; for (int i = a.size() - 1; i >= 0; i--) { r = r * 10 + a[i]; c.push_back(r / b); r %= b; } reverse(c.begin(), c.end()); while (c.size() > 1 && c.back() == 0) c.pop_back(); return c; } // 高精度比较 bool greaterThan(vector<int> &a, vector<int> &b) { if (a.size() != b.size()) return a.size() > b.size(); for (int i = a.size() - 1; i >= 0; i--) { if (a[i] != b[i]) return a[i] > b[i]; } return false; // 相等 } int main() { int n; cin >> n; Minister king; cin >> king.l >> king.r; // 国王的左右手数字 vector<Minister> ministers(n); for (int i = 0; i < n; i++) { cin >> ministers[i].l >> ministers[i].r; } // 贪心排序 sort(ministers.begin(), ministers.end()); // 初始化当前乘积为国王的左手数字(高精度) vector<int> curr_product; int tmp = king.l; if (tmp == 0) curr_product.push_back(0); else { while (tmp) { curr_product.push_back(tmp % 10); tmp /= 10; } } // 初始化最大金币数为0 vector<int> max_reward(1, 0); // 遍历大臣 for (int i = 0; i < n; i++) { int remainder; // 计算当前大臣的金币:当前乘积 / 大臣右手数字 vector<int> reward = div(curr_product, ministers[i].r, remainder); // 更新最大金币数 if (greaterThan(reward, max_reward)) { max_reward = reward; } // 更新当前乘积:乘以大臣的左手数字 curr_product = mul(curr_product, ministers[i].l); } // 输出最大金币数 for (int i = max_reward.size() - 1; i >= 0; i--) { cout << max_reward[i]; } cout << endl; return 0; }

6. 关键细节、踩坑点与优化思考

在实际编写和调试代码时,有几个细节至关重要,一不留神就会导致WA(错误答案)。

6.1 排序的稳定性与等值处理

我们的排序依据是l * r。在C++中,使用sort函数并重载<运算符时,如果两个大臣的l * r值相等,他们的相对顺序会影响结果吗?根据我们的推导,当T_i == T_j时,交换两者,M_1M_2的大小关系可能不变,也可能无法确定。为了确保结果的唯一性和正确性(以及通过洛谷的测试点),当乘积相等时,我们需要定义一个次要排序规则。一个常见且安全的做法是,按l的大小升序排列。这虽然没有严格的数学证明,但在实践中是有效的。修改排序规则如下:

bool operator<(const Minister &other) const { long long prod1 = (long long)l * r; long long prod2 = (long long)other.l * other.r; if (prod1 != prod2) return prod1 < prod2; return l < other.l; // 乘积相等时,按左手数字小的排前面 }

注意:lr最大为10000,乘积最大为1e8,在int范围内。但为了安全,可以使用long long进行比较。

6.2 高精度乘法的前导零与零值处理

在高精度乘法函数mul中,循环结束后我们有一个去除前导零的操作while (c.size() > 1 && c.back() == 0) c.pop_back();。这个操作是必要的,可以保证数字表示的简洁性,便于后续的比较和输出。

但是,必须注意一个边界情况:如果乘积本身就是0(例如,国王左手数字为0),去除到只剩一个元素后,这个元素应该是0。我们的while循环条件c.size() > 1保证了至少会留下一位数字。

在除法函数div中,我们也做了类似的前导零去除。这里要特别注意reverse操作,因为除法是从最高位算起,得到的商向量c初始是正序的(高位在低索引),而我们的高精度数习惯是倒序存储。所以需要先reverse,再去除前导零。

6.3 初始值与数据类型

  • 国王的右手数字 $b_0$:在整个计算中完全没有用到。很多初学者会疑惑为什么要输入它,其实它只是一个背景设定,在数学推导和计算中不起作用。
  • 前缀乘积的初始化:必须正确地将国王的左手数字 $a_0$ 转化为高精度数curr_product。如果 $a_0$ 是0,那么后续所有乘积都是0,金币计算也全是0。代码中需要处理tmp为0的情况,直接放入一个0。
  • 最大金币数的初始化max_reward初始化为{0}。在比较函数greaterThan中,我们首先比较位数,所以0(位数为1)会正确地被第一个非零金币数替换。

6.4 一个隐蔽的“坑”:乘积溢出与排序

这是一个非常容易忽略但可能导致错误的问题。排序时,我们比较的是l * rlr都是int型,最大值10000,乘积最大为1e8,这在int范围内(约21亿)。所以看起来用int存储乘积没问题。

但是,在某些编译器或环境下,两个int相乘的结果仍然是int类型,然后再赋值给用于比较的long long。问题在于,乘法运算本身可能已经溢出了!例如,在计算比较表达式l * r < other.l * other.r时,两边的乘法l * rother.l * other.r都是以int类型进行的,如果结果超过INT_MAX,就会发生溢出,得到错误的结果,即使这个错误的结果被用来和另一个数比较。

正确的做法是,在乘法运算发生前,就将操作数转换为long long

bool operator<(const Minister &other) const { return (long long)l * r < (long long)other.l * other.r; }

或者使用更稳妥的方式:

bool operator<(const Minister &other) const { long long prod1 = 1LL * l * r; // 1LL 是 long long 类型的1,用于提升整个表达式类型 long long prod2 = 1LL * other.l * other.r; return prod1 < prod2; }

这是编写竞赛代码时一个非常好的习惯,能避免许多隐蔽的整数溢出错误。

6.5 性能优化思考

本题的 $n \le 1000$,高精度数的位数最多约 $1000 \times \log_{10}(10000) \approx 4000$ 位。每次乘法和除法都是 $O(位数)$ 的复杂度,总体复杂度约为 $O(n \times 位数)$,在千位数量级下完全可行。

一个可能的优化点是使用“压位”高精度,即用一个int存储多位十进制数(如9位,因为 $10^9 < 2^{31}$),这样可以大幅减少数组操作次数,提升速度。但对于本题的数据范围,普通的一位存储法已经足够。

另一个优化是关于除法:我们每次除法都得到了一个高精度的商reward,但最终只需要输出其中最大的一个。我们是否可以不存储所有商,只比较大小?可以,但比较两个高精度数需要逐位比较,其复杂度与计算商几乎相同。所以直接计算并比较是合理的。

7. 贪心策略的证明与思维延伸

虽然我们在第三节进行了推导,但严谨的贪心算法证明通常需要两步:

  1. 贪心选择性质:证明问题存在一个最优解,其中第一步选择(或第一个元素)是按照我们的贪心策略(按 $l \times r$ 排序)选取的。
  2. 最优子结构性质:在做出贪心选择后,剩下的子问题与原问题形式相同,且其最优解与已做出的选择组合后,构成原问题的最优解。

对于“国王游戏”,完整的数学证明可以通过“邻项交换”法完成:

  • 命题:在任何一个最优排列中,对于任意相邻的两位大臣 $i$ 和 $j$,如果 $l_i \times r_i > l_j \times r_j$,那么交换他们可以得到一个不更差的解(即最大值不会变大)。
  • 证明:通过比较交换前后两位大臣金币数最大值 $M_1$ 和 $M_2$,可以证明当 $l_i \times r_i > l_j \times r_j$ 时,有 $M_1 \ge M_2$。因此,任何存在“乘积大者在前”的相邻对,都可以通过交换来优化(或不恶化)解。不断进行这样的交换,最终必然得到按 $l \times r$ 升序排列的顺序,且这个顺序是最优的之一。

这个证明过程稍显繁琐,但它是理解贪心算法正确性的基石。多接触这类证明,能极大地提升你设计和验证贪心策略的能力。

思维延伸:这道题的本质是一个排序问题,其比较规则($l_i \times r_i$)是通过分析问题模型推导出来的。它和另一道经典贪心题“排队打水”(按照打水时间从小到大的顺序排队,总等待时间最短)有异曲同工之妙。这类问题的通用思路是:尝试对决策对象进行排序,并通过分析交换相邻两项对目标函数的影响,来推导出正确的排序关键字

掌握了这个方法,你就能应对一大批类似的贪心排序问题。例如,“耍杂技的牛”问题,其结论就是按照牛的“体重+强壮值”排序,证明思路与“国王游戏”几乎一模一样。

最后,关于高精度,这是处理大数运算的必备技能。在竞赛中,像“国王游戏”这样同时需要高精度乘除和比较的题目并不多见,但它是一个绝佳的练习机会,能让你彻底理解高精度运算的每一个细节。在实际编码时,务必注意前导零、边界条件(如乘数/除数为0、被除数为0)和溢出问题,这些才是通过这道题,乃至在竞赛中取得好成绩的关键。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询