算法实战:如何用贪心策略解决“分糖果”难题

在算法面试或实际的系统设计中,我们经常遇到看似简单却暗藏玄机的逻辑问题。今天,我们将深入探讨一个经典的数组处理问题——“分发糖果”。这个问题不仅考验我们对边界条件的处理能力,更是展示贪心算法思想的绝佳案例。

在这篇文章中,我们将一起探索如何在满足苛刻约束条件的前提下,计算出分发糖果所需的最小数量。我们将从问题本质出发,逐步构建最优解,并讨论代码实现中的各种细节和陷阱。

问题描述:平衡规则的分配艺术

想象一下,你是一位幼儿园老师,手里有一堆糖果,需要分发给排成一排的孩子们。每个孩子都有一个基于表现的评分(由一个整型数组 arr[] 表示)。你的分配必须严格遵守以下两条规则:

  • 公平保底:每个孩子至少必须得到一颗糖果。
  • 等级森严:如果某个孩子的评分比他相邻的邻居高,那么他必须比那个邻居拿到更多的糖果。

我们的目标是找到能够满足上述所有条件的糖果分配方案,使得所需的总糖果数量最小

直观的例子

为了更好地理解,让我们先看两个简单的示例。

示例 1:严格递增序列

> 输入arr[] = [1, 2, 3, 4, 5]

> 输出15

分析

在这个例子中,每个孩子的评分都严格高于前一个。根据规则,每个孩子都必须比左边的邻居多拿一颗糖。这形成了一个等差数列:

  • 孩子 0 (评分 1) -> 1 颗
  • 孩子 1 (评分 2) -> 2 颗 (比左边多)
  • 孩子 2 (评分 3) -> 3 颗 (比左边多)
  • 孩子 3 (评分 4) -> 4 颗 (比左边多)
  • 孩子 4 (评分 5) -> 5 颗 (比左边多)

总和:1 + 2 + 3 + 4 + 5 = 15
示例 2:评分相等的情况

> 输入arr[] = [9, 9, 9, 9]

> 输出4

分析

这里没有哪个孩子的评分严格高于邻居。规则只要求“高则多”,没说“一样也要多”。因此,我们不需要额外增加糖果,每个人只需满足保底规则即可。

总和:1 + 1 + 1 + 1 = 4

问题核心难点

你可能会觉得这很简单,只要做一次遍历不就行了吗?但问题在于“相邻”这个词具有双向性。

如果我们只处理左邻居,可能会漏看右邻居的规则。反之亦然。更棘手的是,局部最优解(例如给中间某个孩子很多糖)可能会导致全局分配不均。我们需要一种策略,能够同时兼顾左右两边的约束,同时保持总数最小。

解决方案:双向遍历的贪心策略

为了解决上述冲突,我们可以将问题分解。最优雅且高效的方法是使用贪心算法配合两次遍历。这种方法的时间复杂度是 O(n),空间复杂度也是 O(n)。

核心思想

我们可以认为每个孩子的最终糖果数,取决于他在两个局部方向上的最大需求:

  • 相对于左边的需求:如果我比左边高,我的糖果数必须 = 左边的 + 1。
  • 相对于右边的需求:如果我比右边高,我的糖果数必须 = 右边的 + 1。

为了同时满足这两个条件,一个孩子的最终糖果数应该是这两个方向计算结果的较大值(最大值)

为什么取最大值?

这是最关键的一点。假设小明左边的同学评分较低,右边的同学评分也较低。

  • 如果只看左边,小明可能只需要 2 颗糖。
  • 如果只看右边,小明可能需要 5 颗糖(因为右边有一个很长的递减序列连着他)。

如果我们只给 2 颗,就违反了右边的规则;如果给 5 颗,虽然多于左边的需求,但并未违反左边规则(只是糖更多了而已)。为了保证两边都不违规,且总数量尽可能小,取两者的最大值是唯一正确的选择。

算法步骤分解

  • 初始化:创建两个数组 INLINECODEf4d16585 和 INLINECODE35d50d9d,长度与孩子数量相同,并将所有元素初始化为 1(满足保底规则)。
  • 左到右遍历:从索引 1 开始到 n-1。比较当前孩子和左边邻居。如果 INLINECODEc04546c1,则更新 INLINECODE759902bf。这确保了左规则被满足。
  • 右到左遍历:从索引 n-2 开始到 0。比较当前孩子和右边邻居。如果 INLINECODE5944a7e2,则更新 INLINECODE397c8202。这确保了右规则被满足。
  • 合并结果:遍历所有孩子,计算 sum += max(leftCandy[i], rightCandy[i])

代码实现与深度解析

让我们来看看在不同编程语言中如何优雅地实现这一逻辑。我们将包含详细的注释,帮助你理解每一步的意图。

C++ 实现

C++ 是处理算法竞赛和高性能系统的首选,这里我们使用 vector 来动态管理数组。

#include 
#include 
#include  // 用于 max 函数

using namespace std;

/*
 * 计算最小糖果数的核心函数
 * 参数:arr - 存储孩子评分的数组引用
 * 返回值:满足条件的总糖果最小数
 */
int minCandy(vector& arr) {
    int n = arr.size();
    
    // 特殊情况处理:如果没有孩子,糖果数为0
    if (n == 0) return 0;

    // 步骤 1: 初始化两个辅助数组,每个人都先分 1 颗糖
    vector leftCandy(n, 1);
    vector rightCandy(n, 1);

    // 步骤 2: 从左向右遍历
    // 确保如果评分比左边高,糖果数一定比左边多
    for (int i = 1; i  arr[i - 1]) {
            leftCandy[i] = leftCandy[i - 1] + 1;
        }
        // 如果评分小于或等于左边,保持初始值 1 即可
    }

    // 步骤 3: 从右向左遍历
    // 确保如果评分比右边高,糖果数一定比右边多
    for (int i = n - 2; i >= 0; i--) {
        if (arr[i] > arr[i + 1]) {
            rightCandy[i] = rightCandy[i + 1] + 1;
        }
    }

    // 步骤 4: 计算最终结果
    int totalCandies = 0;
    for (int i = 0; i < n; i++) {
        // 取两者较大值,确保同时满足左右两边的约束
        totalCandies += max(leftCandy[i], rightCandy[i]);
    }

    return totalCandies;
}

int main() {
    // 测试用例 1: 递增序列
    vector ratings1 = {1, 2, 3, 4, 5};
    cout << "Test 1 Output: " << minCandy(ratings1) << endl; // 期望输出: 15

    // 测试用例 2: 相同评分
    vector ratings2 = {9, 9, 9, 9};
    cout << "Test 2 Output: " << minCandy(ratings2) << endl; // 期望输出: 4

    return 0;
}

Java 实现

在 Java 中,我们利用 Arrays.fill 来快速初始化数组,使代码更加简洁。

import java.util.Arrays;

public class CandyDistribution {
    
    public static int minCandy(int[] arr) {
        int n = arr.length;
        if (n == 0) return 0;

        // 初始化左、右糖果计数数组,默认值为1
        int[] leftCandy = new int[n];
        int[] rightCandy = new int[n];
        Arrays.fill(leftCandy, 1);
        Arrays.fill(rightCandy, 1);

        // 左向右扫描:处理评分升高的情况
        for (int i = 1; i  arr[i - 1]) {
                leftCandy[i] = leftCandy[i - 1] + 1;
            }
        }

        // 右向左扫描:处理评分升高(相对于右边邻居)的情况
        for (int i = n - 2; i >= 0; i--) {
            if (arr[i] > arr[i + 1]) {
                rightCandy[i] = rightCandy[i + 1] + 1;
            }
        }

        // 汇总结果
        int ans = 0;
        for (int i = 0; i < n; i++) {
            ans += Math.max(leftCandy[i], rightCandy[i]);
        }

        return ans;
    }

    public static void main(String[] args) {
        int[] arr = { 1, 2, 3, 4, 5 };
        System.out.println("Total candies required: " + minCandy(arr));
    }
}

Python 实现

Python 的语法糖让代码非常紧凑。我们使用 array 模块来模拟更低层的数组操作,或者直接使用 list 更加方便。为了符合性能优化的最佳实践,这里演示使用列表推导式的简洁写法。

def min_candy(arr):
    n = len(arr)
    if n == 0:
        return 0

    # 初始化两个数组,每个孩子默认 1 颗糖
    left_candy = [1] * n
    right_candy = [1] * n

    # 从左向右遍历
    for i in range(1, n):
        if arr[i] > arr[i - 1]:
            left_candy[i] = left_candy[i - 1] + 1

    # 从右向左遍历
    for i in range(n - 2, -1, -1):
        if arr[i] > arr[i + 1]:
            right_candy[i] = right_candy[i + 1] + 1

    # 使用 zip 和 sum 函数计算最终总和
    # 取对应位置的最大值并相加
    total = sum(max(l, r) for l, r in zip(left_candy, right_candy))
    return total

if __name__ == "__main__":
    ratings = [1, 2, 3, 4, 5]
    print(f"Minimum candies required: {min_candy(ratings)}")

深入剖析:为什么这种方法有效?

作为开发者,我们不仅要知其然,还要知其所以然。让我们从数学和逻辑的角度验证一下这种贪心策略的正确性。

无后效性

贪心算法的关键在于“无后效性”。当我们进行第一遍从左到右的扫描时,我们实际上是在说:“只要我比左边邻居高,我就必须至少比他多一个糖,至于右边邻居是谁,我暂时不管。” 这建立了一组基于左侧约束的基准解。

局部最优导向全局最优

第二遍扫描从右向左,此时我们面临一个选择:当前孩子 i 的糖果数应该是多少?

  • 如果 INLINECODE9720d3a8,那么 INLINECODE53613d67 被设为 rightCandy[i+1] + 1。这个值是基于右边连续递减序列计算出来的绝对最小值。
  • 如果此时 leftCandy[i](来自第一遍扫描)更大,说明左边有一个很长的上升坡度,要求孩子 i 必须拥有更多的糖果。

max(left, right) 实际上是在寻找一个能同时覆盖左右两边“坡度”要求的最小高度。这就像在山谷中修路,既要满足左边的坡度,又要满足右边的坡度,路面的高度必须至少是两者要求中的最大值,这样车才能开得过去(满足约束),且土方量最少(总数最小)。

复杂度分析与应用场景

复杂度

  • 时间复杂度:O(N)。我们遍历了数组三次(一次左到右,一次右到左,一次求和),都是线性操作。
  • 空间复杂度:O(N)。我们需要两个额外的数组来存储中间结果。虽然我们可以尝试优化空间(只用一个数组 + 一个变量),但代码的可读性和维护成本会增加,而在大多数工程场景下,O(N) 的空间开销是完全可接受的。

实际应用场景

除了分糖果,这种算法逻辑还适用于很多资源分配场景:

  • 绩效薪资计算:员工按工位排列,绩效高的员工工资必须比相邻的绩效低的员工高,如何计算总薪资包的预算?
  • 服务器资源调度:在环形或线性拓扑的服务器集群中,根据负载动态调整优先级或资源配额。
  • 地形渲染:在图形学中,根据高度图的限制生成最小化的网格数据。

常见错误与陷阱

在实现这个算法时,新手容易犯以下错误:

  • 混淆逻辑判断:有人在单向遍历时试图同时处理左右两边,导致逻辑极其复杂且容易出错。记住:分而治之是解决复杂问题的金钥匙。
  • 忽略评分相等的情况:题目条件是 INLINECODEf42ee08e (严格大于),而不是 INLINECODE0060d1df。如果评分相等,不需要多给糖。如果写成了 >=,会导致结果偏大,不符合“最小数量”的要求。
  • 整数溢出:虽然在这个问题中数据规模通常不会导致溢出,但在处理类似的加法问题时,始终要注意数据类型的范围(例如 C++ 中使用 INLINECODE409f5888 而不是 INLINECODE84ffbfab)。
  • 初始化遗漏:忘记将数组初始化为 1,导致糖果数为 0,违反了第一条规则。

性能优化建议

如果你在面试中被问到“能否降低空间复杂度?”,你可以这样回答:

我们可以只用一个数组 INLINECODE2cd2a577 和一个变量 INLINECODE294de75e。

  • 先进行左到右扫描,更新 candies[]
  • 然后进行右到左扫描。在扫描过程中,我们不存储整个 INLINECODE9b7667c2 数组,而是用一个临时变量记录当前的右侧需求,并直接更新 INLINECODE05164cbc。

这样可以将空间复杂度降低到 O(1)(不计入输出数组),但在实际工程中,为了代码清晰度,双向数组法依然是首选。

总结

通过这篇文章,我们不仅解决了一个有趣的算法问题,更重要的是学习了如何将复杂的约束拆解为简单的单向规则。这种“双向扫描 + 取最大值”的模式是处理数组相邻约束问题的通用模板。

下次当你遇到类似的“既要顾左,又要顾右”的问题时,不妨试试这种分而治之的策略。希望这篇文章能帮助你在面试或工作中更加游刃有余!

如果你喜欢这种深度的技术解析,欢迎继续关注更多算法与工程实践的分享。

声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。如需转载,请注明文章出处豆丁博客和来源网址。https://shluqu.cn/25885.html
点赞
0.00 平均评分 (0% 分数) - 0