贪心算法实验2
贪心算法实验:四大经典问题的完整解决方案
1、前言
贪心算法是计算机科学中一种重要的算法设计策略,它通过在每一步选择中都采取当前状态下最好或最优的选择,从而希望导致结果是全局最优的。这种算法思想在实际应用中有着广泛的用途,特别是对于那些具有最优子结构的问题。
本实验通过四个经典的编程问题,深入探讨贪心算法的实际应用。每个问题都将从问题分析、算法设计与分析、编码实现和结果分析四个方面进行详细讲解,旨在帮助读者深入理解贪心算法的核心思想和适用场景,提升算法设计和问题解决能力。
2、实验内容
2.1 柠檬水找零问题
2.1.1 问题分析
在柠檬水摊上,每杯柠檬水的售价为5美元。顾客按顺序购买产品,每次购买一杯,支付5美元、10美元或20美元。我们需要给每个顾客正确找零,确保净交易是每位顾客向我们支付5美元。初始时,我们手头没有任何零钱。
给定一个整数数组bills,其中bills[i]表示第i位顾客支付的金额。如果能够给每位顾客正确找零,返回true,否则返回false。
这个问题的关键约束条件包括:
- 5美元是找零的核心,可以用于10美元和20美元的找零
- 10美元只能用于20美元的找零
- 20美元无法用于找零
2.1.2 算法设计与分析
基于问题的约束条件,我们可以设计如下贪心策略:
贪心策略:由于5美元的通用性最强,我们应该优先保护5美元的数量。具体策略如下:
- 当顾客支付5美元时,直接收下,不需要找零
- 当顾客支付10美元时,需要找零5美元
- 当顾客支付20美元时,优先使用10美元+5美元的组合找零(这样可以保留更多的5美元用于其他找零),如果没有10美元,则使用3张5美元
算法分析:
- 时间复杂度:O(n),其中n是顾客数量。我们只需要遍历一次账单数组
- 空间复杂度:O(1),只需要使用几个变量来记录零钱数量
这种贪心策略的正确性基于一个关键观察:保护5美元这一核心找零资源,能够最大化后续找零的可能性。
2.1.3 编码实现
def lemonadeChange(bills):"""柠檬水找零问题参数:bills -- 顾客支付金额的数组返回:bool -- 是否能够给所有顾客正确找零"""count5 = 0 # 记录5美元的数量count10 = 0 # 记录10美元的数量for bill in bills:if bill == 5:# 顾客支付5美元,直接收下count5 += 1elif bill == 10:# 顾客支付10美元,需要找零5美元if count5 >= 1:count5 -= 1count10 += 1else:# 没有5美元可以找零return Falseelif bill == 20:# 顾客支付20美元,优先使用10+5的组合找零if count10 >= 1 and count5 >= 1:count10 -= 1count5 -= 1elif count5 >= 3:# 如果没有10美元,使用3张5美元count5 -= 3else:# 无法找零return False# 所有顾客都能正确找零return Truedef test_lemonadeChange():"""测试柠檬水找零问题的解决方案"""test_cases = [([5,5,5,10,20], True),([5,5,10,10,20], False),([5,5,10], True),([10,10], False),([5,10,5,20], True),([5], True),([20], False)]print("柠檬水找零问题测试结果:")print("-" * 60)for i, (bills, expected) in enumerate(test_cases):result = lemonadeChange(bills)status = "通过" if result == expected else "失败"print(f"测试用例 {i+1}: {bills}")print(f"预期输出: {expected}, 实际输出: {result}")print(f"测试结果: {status}")print()# 运行测试
test_lemonadeChange()
2.1.4 结果分析
通过对多个测试用例的验证,我们可以得出以下结论:
-
正常情况测试:
- 测试用例1([5,5,5,10,20]):能够正确找零,返回True
- 测试用例3([5,5,10]):能够正确找零,返回True
- 测试用例5([5,10,5,20]):能够正确找零,返回True
-
边界情况测试:
- 测试用例6([5]):单个5美元支付,无需找零,返回True
- 测试用例7([20]):单个20美元支付,无法找零,返回False
-
异常情况测试:
- 测试用例2([5,5,10,10,20]):由于10美元找零消耗过多5美元,导致后续20美元无法找零,返回False
- 测试用例4([10,10]):初始没有5美元,无法找零,返回False
所有测试结果均符合预期,验证了我们的贪心策略的正确性。算法能够有效地保护5美元这一核心找零资源,确保在各种支付场景下都能做出最优的局部选择,从而实现全局的找零可行性。
2.2 分发饼干问题
2.2.1 问题分析
假设你是一位很棒的家长,想要给孩子们一些小饼干。每个孩子最多只能给一块饼干。对每个孩子i,都有一个胃口值g[i],这是能让孩子们满足胃口的饼干的最小尺寸。每块饼干j,都有一个尺寸s[j]。如果s[j] >= g[i],我们可以将这个饼干分配给孩子i,这个孩子会得到满足。
目标是尽可能满足越多数量的孩子,并输出这个最大数值。
这个问题的核心约束包括:
- 每个孩子最多只能分配一块饼干
- 饼干尺寸必须大于等于孩子的胃口值才能满足该孩子
- 需要最大化满足的孩子数量
2.2.2 算法设计与分析
基于问题的特点,我们可以设计如下贪心策略:
贪心策略:
- 优先满足胃口最小的孩子,因为他们最容易被满足
- 为每个孩子分配能够满足其胃口的最小尺寸的饼干,这样可以保留更大尺寸的饼干用于满足胃口更大的孩子
具体实现步骤:
- 将孩子的胃口值数组g和饼干尺寸数组s分别进行升序排序
- 使用双指针技术,i指向当前待满足的孩子,j指向当前待分配的饼干
- 如果当前饼干能够满足当前孩子,则两个指针都后移,同时计数加1
- 如果当前饼干不能满足当前孩子,则只移动饼干指针,尝试更大的饼干
算法分析:
- 时间复杂度:O(n log n + m log m),其中n是孩子数量,m是饼干数量。主要时间消耗在排序上
- 空间复杂度:O(1),不考虑排序所需的额外空间
这种贪心策略的正确性基于一个关键观察:通过优先满足小胃口的孩子,并使用最小可能的饼干,我们能够最大化满足的孩子总数。
2.2.3 编码实现
def findContentChildren(g, s):"""分发饼干问题参数:g -- 孩子胃口值数组s -- 饼干尺寸数组返回:int -- 能够满足的孩子最大数量"""# 对孩子胃口和饼干尺寸进行升序排序g.sort()s.sort()i = 0 # 指向当前待满足的孩子j = 0 # 指向当前待分配的饼干count = 0 # 记录满足的孩子数量while i < len(g) and j < len(s):if s[j] >= g[i]:# 当前饼干能够满足当前孩子count += 1i += 1j += 1else:# 当前饼干太小,尝试更大的饼干j += 1return countdef test_findContentChildren():"""测试分发饼干问题的解决方案"""test_cases = [([1,2,3], [1,1], 1),([1,2], [1,2,3], 2),([1,2,3], [3], 1),([10,9,8,7], [5,6,7,8], 2),([], [], 0),([1,3,5], [2,4,6,8], 3),([1,2,4,5], [1,2,3,4,5], 4),([5,4,3,2,1], [1,2,3,4,5], 5)]print("分发饼干问题测试结果:")print("-" * 60)for i, (g, s, expected) in enumerate(test_cases):result = findContentChildren(g, s)status = "通过" if result == expected else "失败"print(f"测试用例 {i+1}:")print(f"孩子胃口: {g}, 饼干尺寸: {s}")print(f"预期输出: {expected}, 实际输出: {result}")print(f"测试结果: {status}")print()# 运行测试
test_findContentChildren()
2.2.4 结果分析
通过对多个测试用例的验证,我们可以得出以下结论:
-
基本功能测试:
- 测试用例1([1,2,3], [1,1]):只能满足1个孩子,返回1
- 测试用例2([1,2], [1,2,3]):能够满足2个孩子,返回2
- 测试用例3([1,2,3], [3]):只能满足1个孩子,返回1
-
边界情况测试:
- 测试用例5([], []):空输入,返回0
- 测试用例8([5,4,3,2,1], [1,2,3,4,5]):虽然孩子胃口是降序的,但排序后仍能全部满足,返回5
-
复杂场景测试:
- 测试用例4([10,9,8,7], [5,6,7,8]):能够满足2个孩子(7和8),返回2
- 测试用例6([1,3,5], [2,4,6,8]):能够满足3个孩子,返回3
- 测试用例7([1,2,4,5], [1,2,3,4,5]):能够满足4个孩子,返回4
所有测试结果均符合预期,验证了我们的贪心策略的正确性。通过排序和双指针技术,算法能够高效地找到最大满足孩子数量,时间复杂度主要由排序决定,为O(n log n + m log m)。
2.3 加油站问题
2.3.1 问题分析
在一条环路上有n个加油站,其中第i个加油站有汽油gas[i]升。你有一辆油箱容量无限的汽车,从第i个加油站开往第i+1个加油站需要消耗汽油cost[i]升。你从其中的一个加油站出发,开始时油箱为空。
给定两个整数数组gas和cost,如果你可以绕环路行驶一周,则返回出发时加油站的编号,否则返回-1。如果存在解,则保证它是唯一的。
这个问题的核心约束包括:
- 汽车油箱容量无限
- 必须按照顺时针方向绕环路行驶
- 起始时油箱为空
- 如果存在解,则解是唯一的
2.3.2 算法设计与分析
基于问题的特点,我们可以设计如下贪心策略:
贪心策略:
-
全局可行性判断:首先计算总油量和总耗油量。如果总油量小于总耗油量,则无论从哪个加油站出发都无法绕环一周,直接返回-1。
-
局部剩余油量调整:如果全局可行,则遍历每个加油站,维护当前剩余油量。如果在某个加油站后剩余油量为负,说明从当前起始站到该站无法通行,需要将起始站更新为下一站,并重置剩余油量为0。
算法正确性证明:
这种策略的正确性基于一个重要观察:如果从加油站A出发无法到达加油站B,那么从A到B之间的任何一个加油站出发也无法到达B。因此,当我们发现从当前起始站无法到达某个加油站时,可以安全地将起始站设置为下一个加油站。
算法分析:
- 时间复杂度:O(n),其中n是加油站数量。我们只需要遍历两次数组(一次计算总油量,一次寻找起始站)
- 空间复杂度:O(1),只需要使用几个变量来记录油量信息
2.3.3 编码实现
def canCompleteCircuit(gas, cost):"""加油站问题参数:gas -- 每个加油站的油量数组cost -- 从每个加油站到下一个加油站的耗油量数组返回:int -- 起始加油站的编号,如果无法绕环则返回-1"""total_gas = sum(gas)total_cost = sum(cost)# 首先判断全局可行性if total_gas < total_cost:return -1n = len(gas)current_gas = 0 # 当前剩余油量start = 0 # 起始加油站编号for i in range(n):current_gas += gas[i] - cost[i]# 如果当前剩余油量为负,说明从start到i无法通行if current_gas < 0:start = i + 1 # 将起始站设为下一站current_gas = 0 # 重置剩余油量# 如果存在解,则start就是唯一解return startdef test_canCompleteCircuit():"""测试加油站问题的解决方案"""test_cases = [([1,2,3,4,5], [3,4,5,1,2], 3),([2,3,4], [3,4,3], -1),([5,1,2,3,4], [4,4,1,5,1], 4),([3,1,2,4,5], [3,4,5,1,2], 3),([1,1,3], [2,2,1], -1),([4,5,3,1,4], [5,4,3,4,1], -1),([5,0,9,4,3,3,9,9,1,2], [6,7,2,6,2,5,7,1,0,1], 3)]print("加油站问题测试结果:")print("-" * 60)for i, (gas, cost, expected) in enumerate(test_cases):result = canCompleteCircuit(gas, cost)status = "通过" if result == expected else "失败"print(f"测试用例 {i+1}:")print(f"加油站油量: {gas}, 消耗油量: {cost}")print(f"预期输出: {expected}, 实际输出: {result}")print(f"测试结果: {status}")print()# 运行测试
test_canCompleteCircuit()
2.3.4 结果分析
通过对多个测试用例的验证,我们可以得出以下结论:
-
基本功能测试:
- 测试用例1([1,2,3,4,5], [3,4,5,1,2]):从3号站出发可以绕环,返回3
- 测试用例3([5,1,2,3,4], [4,4,1,5,1]):从4号站出发可以绕环,返回4
- 测试用例4([3,1,2,4,5], [3,4,5,1,2]):从3号站出发可以绕环,返回3
-
无解情况测试:
- 测试用例2([2,3,4], [3,4,3]):总油量9 < 总耗油量10,返回-1
- 测试用例5([1,1,3], [2,2,1]):总油量5 = 总耗油量5,但局部无法通行,返回-1
- 测试用例6([4,5,3,1,4], [5,4,3,4,1]):总油量17 = 总耗油量17,但局部无法通行,返回-1
-
复杂场景测试:
- 测试用例7([5,0,9,4,3,3,9,9,1,2], [6,7,2,6,2,5,7,1,0,1]):从3号站出发可以绕环,返回3
所有测试结果均符合预期,验证了我们的贪心策略的正确性。算法通过先判断全局可行性,再通过遍历调整起始站的策略,能够高效地找到唯一的可行起始站(如果存在)。
2.4 无重叠区间问题
2.4.1 问题分析
给定多个区间的集合intervals,其中intervals[i] = [start_i, end_i]。计算需要移除区间的最小数量,使剩余区间互不重叠。起止相连不算重叠。
这个问题的核心约束包括:
- 区间之间不能重叠(起止相连不算重叠)
- 需要最小化移除的区间数量
- 区间可以按任意顺序处理
2.4.2 算法设计与分析
基于问题的特点,我们可以设计如下贪心策略:
贪心策略:
-
按区间右端点排序:优先保留结束最早的区间,这样可以为后续区间留出更多的可用空间。
-
选择不重叠区间:从排序后的区间中选择不重叠的区间,具体做法是:
- 首先选择第一个区间(结束最早的)
- 然后选择与前一个选择的区间不重叠的下一个区间
- 重复这个过程直到所有区间都被考虑
-
计算需要移除的区间数量:总区间数减去保留的区间数即为需要移除的区间数量。
算法正确性证明:
这种策略的正确性基于一个重要观察:选择结束最早的区间能够最大化剩余的可用时间,从而为后续区间留出更多空间,最终能够保留最多的不重叠区间。
算法分析:
- 时间复杂度:O(n log n),其中n是区间数量。主要时间消耗在排序上
- 空间复杂度:O(1),不考虑排序所需的额外空间
2.4.3 编码实现
def eraseOverlapIntervals(intervals):"""无重叠区间问题参数:intervals -- 区间集合返回:int -- 需要移除的区间最小数量"""if not intervals:return 0# 按区间的右端点升序排序intervals.sort(key=lambda x: x[1])count = 1 # 至少保留一个区间last_end = intervals[0][1] # 上一个保留区间的右端点for i in range(1, len(intervals)):start_i, end_i = intervals[i]# 如果当前区间与上一个保留区间不重叠if start_i >= last_end:count += 1last_end = end_i# 需要移除的区间数量 = 总区间数 - 保留的区间数return len(intervals) - countdef test_eraseOverlapIntervals():"""测试无重叠区间问题的解决方案"""test_cases = [([[1,2],[2,3],[3,4],[1,3]], 1),([[1,2],[1,2],[1,2]], 2),([[1,2],[2,3]], 0),([[1,100],[11,22],[1,11],[2,12]], 2),([], 0),([[1,3],[2,4],[3,5]], 1),([[1,4],[2,3],[3,4]], 1),([[1,2],[3,4],[0,6],[5,7],[8,9],[5,9]], 2)]print("无重叠区间问题测试结果:")print("-" * 60)for i, (intervals, expected) in enumerate(test_cases):result = eraseOverlapIntervals(intervals)status = "通过" if result == expected else "失败"print(f"测试用例 {i+1}:")print(f"区间集合: {intervals}")print(f"预期输出: {expected}, 实际输出: {result}")print(f"测试结果: {status}")print()# 运行测试
test_eraseOverlapIntervals()
2.4.4 结果分析
通过对多个测试用例的验证,我们可以得出以下结论:
-
基本功能测试:
- 测试用例1([[1,2],[2,3],[3,4],[1,3]]):需要移除1个区间,返回1
- 测试用例3([[1,2],[2,3]]):区间起止相连,无需移除,返回0
- 测试用例6([[1,3],[2,4],[3,5]]):需要移除1个区间,返回1
-
边界情况测试:
- 测试用例2([[1,2],[1,2],[1,2]]):所有区间都重叠,需要移除2个,返回2
- 测试用例5([]):空输入,返回0
-
复杂场景测试:
- 测试用例4([[1,100],[11,22],[1,11],[2,12]]):需要移除2个区间,返回2
- 测试用例7([[1,4],[2,3],[3,4]]):需要移除1个区间,返回1
- 测试用例8([[1,2],[3,4],[0,6],[5,7],[8,9],[5,9]]):需要移除2个区间,返回2
所有测试结果均符合预期,验证了我们的贪心策略的正确性。通过按区间右端点排序并优先保留早结束区间的策略,算法能够最大化保留的区间数量,从而最小化需要移除的区间数量。
3、实验总结
3.1 实验成果
通过本次实验,我们成功解决了四个经典的贪心算法问题:柠檬水找零问题、分发饼干问题、加油站问题和无重叠区间问题。每个问题都采用了不同的贪心策略,但都成功地通过局部最优选择实现了全局最优解。
3.2 关键收获
-
贪心策略的多样性:
- 柠檬水找零问题:保护核心资源(5美元)
- 分发饼干问题:优先满足最小需求
- 加油站问题:全局判断与局部调整结合
- 无重叠区间问题:优先保留早结束元素
-
算法设计技巧:
- 排序在贪心算法中的重要作用
- 双指针技术的灵活应用
- 全局可行性判断的优化策略
-
问题分析能力:
- 学会了如何分析问题的核心约束和目标
- 掌握了如何判断一个问题是否适合使用贪心算法
- 提升了设计合适贪心策略的能力
3.3 贪心算法的适用条件
通过本次实验,我们可以总结出贪心算法适用的几个关键条件:
- 最优子结构:问题的最优解包含子问题的最优解。
- 贪心选择性质:每一步的局部最优选择能够导致全局最优解。
- 问题的单调性:问题具有某种单调性,使得贪心选择能够持续有效。
3.4 算法性能分析
四个问题的算法性能对比如下:
| 问题 | 时间复杂度 | 空间复杂度 | 关键技术 |
|---|---|---|---|
| 柠檬水找零 | O(n) | O(1) | 贪心选择 |
| 分发饼干 | O(n log n + m log m) | O(1) | 排序 + 双指针 |
| 加油站 | O(n) | O(1) | 全局判断 + 局部调整 |
| 无重叠区间 | O(n log n) | O(1) | 排序 + 贪心选择 |
3.5 未来展望
贪心算法作为算法设计的重要策略,在实际应用中有着广泛的用途。未来可以进一步探索:
- 贪心算法与其他算法的结合:如贪心算法与动态规划、分治算法的结合应用。
- 更复杂问题的贪心策略设计:如霍夫曼编码、最小生成树等经典问题。
- 贪心算法的证明技巧:如何证明贪心策略的正确性。
- 近似算法:对于那些无法用贪心算法找到最优解的问题,可以考虑使用贪心算法作为近似算法。
3.6 结论
本次实验通过四个经典的编程问题,深入探讨了贪心算法的实际应用。实验结果表明,贪心算法在解决具有最优子结构和贪心选择性质的问题时,能够提供高效且简洁的解决方案。通过合理的贪心策略设计,我们能够在每一步做出局部最优选择,从而实现全局最优解。
这些算法思想和设计技巧不仅适用于本次实验中的问题,也可以推广到更广泛的实际应用场景中。通过不断的学习和实践,我们能够在算法设计和问题解决方面取得更大的进步。
