算法第四章作业

贪心算法实战:区间选点问题的最优解探索

在算法学习路上,贪心算法绝对是个“个性鲜明”的存在。它没有动态规划那么多复杂的状态转移,思路直白到有点“一根筋”——每一步都捡当下看起来最好的选,但偏偏在不少经典问题上,能以极高效率找到全局最优解。区间选点问题就是其中之一,“用最少的点覆盖所有区间”这个需求,初看好像有点绕,但用对贪心策略,一下子就能豁然开朗。今天就结合我的学习笔记,把这个问题拆透:从问题本身到策略设计,再到为什么这个策略一定可行,最后附上可直接运行的代码,帮大家少走点弯路。

一、问题引入:什么是区间选点问题?

先把问题说清楚,避免后续理解跑偏:

给你n个在x轴上的闭区间,比如[1,3]、[2,5]这种,要求找出最少的点,让每个区间里都至少有一个点。打个比方就是,用最少的钉子把所有纸条都钉在墙上,每个纸条都得被钉子穿过才行。

举个直观例子:若给定区间 [1,3]、[2,5]、[4,6],最少需要几个点?答案是 1 个——比如点 4,就能同时覆盖这三个区间;再比如区间 [1,2]、[3,4]、[5,6],则需要 3 个点,每个区间各占一个。

这个问题的核心就是“找最少的点”,属于典型的优化问题。那为什么贪心能搞定它?关键就在于找到那个“每次选了都不亏”的局部最优决策。
二、贪心策略:怎么选点才最省?

贪心的核心思路就是“每次都选当下最优”,放到这个问题里,就是“选一个点,能覆盖尽可能多的区间”。那这个点该选在哪里呢?

我刚开始想的时候,也纠结过要不要选区间中间或者左端点。后来试了几个例子发现,选区间的右端点是最聪明的选择——因为后面的区间如果和当前区间有重叠,它们的右端点肯定不会比当前的小(后面会说为什么要先排序),选当前区间的右端点,能最大概率覆盖到后面的重叠区间。

梳理下来,完整的贪心策略就两步,特别好记:

1. 先排序:按区间右端点从小到大排

这一步是基础,千万别省。把所有区间按右端点升序排好,才能保证我们选的“当前区间右端点”是最小的那个,这样才能最大限度地覆盖后面的区间。如果不排序,直接随便选一个区间的右端点,很可能会错过很多重叠区间,最后选的点就多了。

比如上述例子 [1,3]、[2,5]、[4,6],排序后还是本身;若区间是 [2,5]、[1,3]、[4,6],排序后就变成 [1,3]、[2,5]、[4,6]。

2. 再选点:依次选当前区间的右端点

具体操作很简单,跟着步骤走就行:

  • 第一步:把排序后第一个区间的右端点当成第一个选的点,记下来。

  • 第二步:逐个看后面的区间。如果这个区间包含了上一个选的点,那就不用再选新的;如果不包含(也就是这个区间的左端点比上一个选的点还大),说明这个区间没被覆盖到,就把它的右端点当成新的选点记下来。

  • 全部看完后,记下来的这些点就是最少的点集了。

3. 实操演示:用例子跑一遍

  • 排序后:[1,3]、[2,5]、[4,6]

  • 选第一个点:第一个区间右端点 3,S =

  • 遍历第二个区间 [2,5]:2 ≤ 3 ≤ 5,已覆盖,不选新点。

  • 遍历第三个区间 [4,6]:4 比 3 大,说明 3 覆盖不到这个区间,只能选它的右端点 6,此时点集是 {3,6}。这里插一句,之前有人问我“为什么不选 4 呢?4 能覆盖三个区间啊”。其实这里我之前举的例子有点小问题,正确的重叠区间例子应该是 [1,4]、[2,5]、[3,6]——这种情况下选 4 就能覆盖所有。这也能说明一个点:最优解的点集可能不唯一,但最少的点数量是固定的。我们的贪心策略求的是“最少数量”,至于是哪个点,只要满足条件都可以。

三、关键疑问:为什么这个策略一定最优?

很多人学贪心的时候都会有这个顾虑:“每次都选当下最好的,最后真的是全局最好的吗?” 这个担心很正常,所以我们需要证明一下——为什么选第一个区间的右端点,最后能得到最少的点集。这个证明不用太复杂,用“构造法”就能说清楚。

我们要证明的核心是:选排序后第一个区间的右端点,是构成最优解的其中一步

证明思路(通俗版)

先做两个假设,方便理解:

  • OPT:假设存在一个最优解,也就是最少的点集,记为 S_opt,里面有 k 个点(k 是最少数量)。

  • G:我们用贪心策略选出来的点集,记为 S_greedy。

我们要证明的是:S_greedy 里的点数量也是 k,也就是说 G 也是最优解。

第一步:先看第一个区间。

排序后的第一个区间是 I₁ = [a₁, b₁]。既然 OPT 是最优解,那 S_opt 里肯定有一个点 x 是在 I₁ 里的——不然这个区间就没被覆盖,OPT 就不是可行解了,这和我们假设 OPT 是最优解矛盾。

第二步:比较 x 和我们贪心选的点 b₁。

情况 1:如果 x 就是 b₁,那太好了,说明我们贪心选的第一个点和最优解里的点是一样的,这一步没问题。

情况 2:如果 x 比 b₁ 小。这时候注意,我们已经把所有区间按右端点排序了,所以后面所有区间的右端点都不会比 b₁ 小。如果 x 能覆盖后面的某个区间,那 b₁ 肯定也能覆盖这个区间——因为 x < b₁ ≤ 后面区间的右端点,而且 x 已经在后面区间里了(aᵢ ≤ x),所以 aᵢ ≤ b₁ 肯定成立,b₁ 自然也在这个区间里。

这就意味着,我们可以把 OPT 里的 x 换成 b₁,得到一个新的点集 S_opt'。这个新点集和 OPT 里的点数量一样,而且也能覆盖所有区间,所以 S_opt' 也是最优解。

第三步:推广到所有区间。

解决了第一个区间的问题,剩下的没被 b₁ 覆盖的区间,其实就是一个更小的区间选点问题(子问题)。我们对这个子问题再用同样的贪心策略,依然能得到子问题的最优解。一步步推下去,最后我们选出来的 S_greedy,点数量和 OPT 一样,所以 G 也是最优解。

总结一下:选第一个区间的右端点这个贪心选择,是能构成最优解的,所以整个策略是可靠的。

四、时间复杂度:这个算法快不快?

算法的速度主要看两部分:排序和遍历。

  1. 排序阶段:对 n 个区间按右端点排序,用快速排序、归并排序这些常用算法,时间复杂度是 O(n log n)。这是整个算法最费时间的地方。

  2. 遍历阶段:排序完之后,逐个看一遍 n 个区间,每个区间只需要判断一次“上一个点能不能覆盖它”,这部分是 O(n),很快。

所以整个算法的总时间复杂度是 O(n log n)。这已经是这个问题的最优速度了——因为基于比较的排序算法,最快就是 O(n log n),而排序又是这个问题绕不开的步骤,所以没法再优化了。

五、代码实现:直接抄就能用

我用 Python 写了完整的实现,包含了异常处理和三个测试案例,大家可以直接复制到本地跑一跑,理解会更深刻。

def interval_point_selection(intervals):
    # 异常处理:若区间列表为空,返回空集
    if not intervals:
        return [], 0
    
    # 步骤1:按区间右端点升序排序
    intervals_sorted = sorted(intervals, key=lambda x: x[1])
    
    # 步骤2:迭代选点
    result = []
    # 选第一个区间的右端点
    result.append(intervals_sorted[0][1])
    
    # 遍历后续区间
    for i in range(1, len(intervals_sorted)):
        a_i, b_i = intervals_sorted[i]
        last_point = result[-1]
        # 若当前区间左端点 > 上一个选点,说明未覆盖,选当前区间右端点
        if a_i > last_point:
            result.append(b_i)
    
    return result, len(result)

 测试案例
if __name__ == "__main__":
    # 案例1:有重叠区间
    intervals1 = [[1,3], [2,5], [4,6]]
    points1, count1 = interval_point_selection(intervals1)
    print(f"案例1 区间:{intervals1}")
    print(f"最少点集:{points1},点数量:{count1}\n")  # 输出:[3,6],数量2
    
    # 案例2:无重叠区间
    intervals2 = [[1,2], [3,4], [5,6]]
    points2, count2 = interval_point_selection(intervals2)
    print(f"案例2 区间:{intervals2}")
    print(f"最少点集:{points2},点数量:{count2}\n")  # 输出:[2,4,6],数量3
    
    # 案例3:完全重叠区间
    intervals3 = [[1,5], [2,4], [3,3]]
    points3, count3 = interval_point_selection(intervals3)
    print(f"案例3 区间:{intervals3}")
    print(f"最少点集:{points3},点数量:{count3}")  # 输出:[3],数量1
    

六、延伸思考:贪心算法的“能”与“不能”

通过区间选点这个问题,其实能更清楚地看到贪心算法的本质,它不是万能的,但在适合的场景里效率极高。

1. 贪心能生效的两个关键

  • 贪心选择性质:局部最优能凑出全局最优(这是最核心的,必须能证明,不然很容易用错)。

  • 最优子结构:问题的最优解里,包含了它所有子问题的最优解(这个和动态规划是共通的,但贪心不用像 DP 那样存子问题的解)。
    2. 贪心的局限性:不是所有问题都能用

最典型的就是 0-1 背包问题。如果用“选价值密度最高的物品”这种贪心思路,很可能得不到最优解——比如一个价值高但占空间大的物品,选了之后剩下的空间放不下其他东西,总价值反而不如选几个小的。但如果是分数背包(物品可以切开来装),贪心就管用了,因为能把空间刚好用满。

另外,贪心策略特别“挑问题”。同样是区间问题,如果你想“用最少的区间覆盖整个目标区间”,那贪心策略就得改成“按左端点排序,每次选能覆盖当前起点且右端点最远的区间”,原来的选右端点策略就失效了。

七、最后总结

区间选点问题的贪心解法,核心就两句话:按区间右端点排序,依次选每个未覆盖区间的右端点。这个策略看起来简单,但背后需要证明它的正确性,这也是学习贪心算法的关键——不是死记硬背策略,而是理解“为什么这个局部最优能变成全局最优”。

我自己的经验是,学算法最好的方式就是“理解思路 + 动手实现 + 多练例子”。把这个问题搞懂之后,再去看哈夫曼编码、活动安排这些其他贪心问题,会发现思路都是相通的。

如果这篇笔记帮你理清了思路,欢迎点赞收藏~ 大家如果有其他觉得难理解的贪心问题,也可以在评论区留言,我们一起讨论!

posted @ 2025-12-28 23:14  奈落见杏  阅读(2)  评论(0)    收藏  举报