在现实商业场景中,折扣策略是吸引顾客的重要手段。书店可能会为购买不同书籍的顾客提供不同的折扣,例如购买的书籍种类越多,折扣越大。在 Exercism 的 “book-store” 练习中,我们将解决一个有趣的优化问题:如何以最低的价格购买一组书籍。这不仅能帮助我们掌握动态规划算法,还能深入学习 Rust 中的集合操作和递归优化技巧。

问题描述

书店出售《编程珠玑》系列丛书,共有5卷。每本书的价格是8欧元(800欧分)。书店提供以下折扣:

  • 买1本书:无折扣,每本8欧元
  • 买2本不同的书:5%折扣,每本7.6欧元
  • 买3本不同的书:10%折扣,每本7.2欧元
  • 买4本不同的书:20%折扣,每本6.4欧元
  • 买5本不同的书:25%折扣,每本6欧元

问题是:给定一个购物篮中的书籍列表,如何分组购买以获得最低总价?

让我们先看看练习提供的函数签名:

pub fn lowest_price(books: &[u32]) -> u32 {
    unimplemented!(
        "Find the lowest price of the bookbasket with books {:?}",
        books
    )
}

我们需要实现这个函数,它应该:

  1. 接收一个书籍列表(用数字1-5表示不同卷)
  2. 计算购买这些书籍的最低价格

算法分析

这个问题的关键在于找到最优的分组策略。例如,对于输入 [1, 1, 2, 2, 3, 3, 4, 5],有两种分组方式:

  1. 一组5本不同书籍 + 一组3本不同书籍:

    • 价格 = 30欧元(5本折扣25%)+ 21.6欧元(3本折扣10%)= 51.6欧元
  2. 两组4本不同书籍:

    • 价格 = 25.6欧元(4本折扣20%)+ 25.6欧元(4本折扣20%)= 51.2欧元

显然,第二种分组方式更便宜。

基础实现

1. 计算价格函数

fn calculate_price(group_size: usize) -> u32 {
    let base_price = 800; // 每本书8欧元,以欧分为单位
    let discount = match group_size {
        1 => 0.0,
        2 => 0.05,
        3 => 0.10,
        4 => 0.20,
        5 => 0.25,
        _ => 0.0,
    };
    
    (base_price as f64 * group_size as f64 * (1.0 - discount)) as u32
}

2. 简单递归实现

use std::collections::HashMap;

pub fn lowest_price(books: &[u32]) -> u32 {
    if books.is_empty() {
        return 0;
    }
    
    // 统计每本书的数量
    let mut counts = [0; 6]; // 索引0未使用,1-5对应书籍卷号
    for &book in books {
        counts[book as usize] += 1;
    }
    
    // 只保留数量大于0的书籍
    let mut book_counts: Vec<u32> = counts.iter().skip(1).filter(|&&c| c > 0).cloned().collect();
    book_counts.sort_unstable();
    
    calculate_min_price(&book_counts)
}

fn calculate_min_price(book_counts: &[u32]) -> u32 {
    if book_counts.is_empty() {
        return 0;
    }
    
    let mut min_price = u32::MAX;
    
    // 尝试所有可能的分组大小(1到书籍种类数)
    for group_size in 1..=book_counts.len() {
        // 检查是否有足够的不同书籍来组成这个大小的组
        if book_counts.iter().filter(|&&count| count > 0).count() >= group_size {
            // 创建新状态:每种书籍数量减1(组成一个组)
            let mut new_counts = book_counts.to_vec();
            for i in 0..group_size {
                new_counts[new_counts.len() - 1 - i] -= 1;
            }
            
            // 清理0数量的书籍并重新排序
            new_counts.retain(|&count| count > 0);
            new_counts.sort_unstable();
            
            // 递归计算剩余书籍的最低价格
            let price = calculate_price(group_size) + calculate_min_price(&new_counts);
            min_price = min_price.min(price);
        }
    }
    
    if min_price == u32::MAX {
        0
    } else {
        min_price
    }
}

fn calculate_price(group_size: usize) -> u32 {
    let base_price = 800; // 每本书8欧元,以欧分为单位
    let discount = match group_size {
        1 => 0.0,
        2 => 0.05,
        3 => 0.10,
        4 => 0.20,
        5 => 0.25,
        _ => 0.0,
    };
    
    (base_price as f64 * group_size as f64 * (1.0 - discount)) as u32
}

优化实现(动态规划)

1. 使用记忆化优化

use std::collections::HashMap;

pub fn lowest_price(books: &[u32]) -> u32 {
    if books.is_empty() {
        return 0;
    }
    
    // 统计每本书的数量
    let mut counts = [0; 6]; // 索引0未使用,1-5对应书籍卷号
    for &book in books {
        counts[book as usize] += 1;
    }
    
    // 只保留数量大于0的书籍并排序
    let mut book_counts: Vec<u32> = counts.iter().skip(1).filter(|&&c| c > 0).cloned().collect();
    book_counts.sort_unstable();
    
    let mut memo = HashMap::new();
    calculate_min_price_memo(&book_counts, &mut memo)
}

fn calculate_min_price_memo(book_counts: &[u32], memo: &mut HashMap<Vec<u32>, u32>) -> u32 {
    if book_counts.is_empty() {
        return 0;
    }
    
    // 检查是否已经计算过这个状态
    if let Some(&price) = memo.get(book_counts) {
        return price;
    }
    
    let mut min_price = u32::MAX;
    
    // 尝试所有可能的分组大小(1到书籍种类数)
    for group_size in 1..=book_counts.len() {
        // 检查是否有足够的不同书籍来组成这个大小的组
        if book_counts.iter().filter(|&&count| count > 0).count() >= group_size {
            // 创建新状态:每种书籍数量减1(组成一个组)
            let mut new_counts = book_counts.to_vec();
            for i in 0..group_size {
                new_counts[new_counts.len() - 1 - i] -= 1;
            }
            
            // 清理0数量的书籍并重新排序
            new_counts.retain(|&count| count > 0);
            new_counts.sort_unstable();
            
            // 递归计算剩余书籍的最低价格
            let price = calculate_price(group_size) + calculate_min_price_memo(&new_counts, memo);
            min_price = min_price.min(price);
        }
    }
    
    let result = if min_price == u32::MAX { 0 } else { min_price };
    memo.insert(book_counts.to_vec(), result);
    result
}

fn calculate_price(group_size: usize) -> u32 {
    let base_price = 800; // 每本书8欧元,以欧分为单位
    let discount = match group_size {
        1 => 0.0,
        2 => 0.05,
        3 => 0.10,
        4 => 0.20,
        5 => 0.25,
        _ => 0.0,
    };
    
    (base_price as f64 * group_size as f64 * (1.0 - discount)) as u32
}

更高效的实现

1. 直接基于计数的实现

use std::collections::HashMap;

pub fn lowest_price(books: &[u32]) -> u32 {
    if books.is_empty() {
        return 0;
    }
    
    // 统计每本书的数量
    let mut counts = [0; 5];
    for &book in books {
        counts[(book - 1) as usize] += 1;
    }
    
    // 对计数进行排序
    counts.sort_unstable();
    
    let mut memo = HashMap::new();
    calculate_min_price(&counts, &mut memo)
}

fn calculate_min_price(counts: &[u32; 5], memo: &mut HashMap<[u32; 5], u32>) -> u32 {
    // 如果所有计数都是0,返回0价格
    if counts.iter().all(|&c| c == 0) {
        return 0;
    }
    
    // 检查记忆化
    if let Some(&cached) = memo.get(counts) {
        return cached;
    }
    
    let mut min_price = u32::MAX;
    
    // 尝试不同大小的组合(从大到小尝试,可能更快找到最优解)
    for group_size in (1..=5).rev() {
        // 检查是否有足够的书籍组成这个大小的组
        if counts[5 - group_size] > 0 {
            // 创建新的计数数组
            let mut new_counts = *counts;
            for i in (5 - group_size)..5 {
                new_counts[i] -= 1;
            }
            
            // 重新排序
            new_counts.sort_unstable();
            
            let price = calculate_price(group_size) + calculate_min_price(&new_counts, memo);
            min_price = min_price.min(price);
        }
    }
    
    let result = min_price;
    memo.insert(*counts, result);
    result
}

fn calculate_price(group_size: usize) -> u32 {
    const BOOK_PRICE: u32 = 800;
    let discount = match group_size {
        1 => 0.0,
        2 => 0.05,
        3 => 0.10,
        4 => 0.20,
        5 => 0.25,
        _ => 0.0,
    };
    
    (BOOK_PRICE as f64 * group_size as f64 * (1.0 - discount)) as u32
}

测试用例分析

通过查看测试用例,我们可以更好地理解需求:

#[test]
/// Only a single book
fn test_only_a_single_book() {
    process_total_case((vec![1], vec![vec![1]]), 800);
}

单本书无折扣,价格为800欧分(8欧元)。

#[test]
/// Two different books
fn test_two_different_books() {
    process_total_case((vec![1, 2], vec![vec![1, 2]]), 1_520);
}

两本不同书籍享受5%折扣,每本7.6欧元,总价15.2欧元(1520欧分)。

#[test]
/// Two groups of four is cheaper than group of five plus group of three
fn test_two_groups_of_four_is_cheaper_than_group_of_five_plus_group_of_three() {
    process_total_case(
        (
            vec![1, 1, 2, 2, 3, 3, 4, 5],
            vec![vec![1, 2, 3, 4], vec![1, 2, 3, 5]],
        ),
        5_120,
    );
}

这个测试用例展示了优化的重要性:两组4本书(每组享受20%折扣)比一组5本加一组3本更便宜。

性能优化版本

考虑性能的进一步优化实现:

use std::collections::HashMap;

pub fn lowest_price(books: &[u32]) -> u32 {
    if books.is_empty() {
        return 0;
    }
    
    // 统计每本书的数量(针对5卷系列进行优化)
    let mut counts = [0u32; 5];
    for &book in books {
        if book >= 1 && book <= 5 {
            counts[(book - 1) as usize] += 1;
        }
    }
    
    // 对计数进行排序以获得规范形式
    counts.sort_unstable();
    
    // 使用静态记忆化以避免重复计算
    thread_local! {
        static MEMO: std::cell::RefCell<HashMap<[u32; 5], u32>> = 
            std::cell::RefCell::new(HashMap::new());
    }
    
    MEMO.with(|memo| {
        let mut memo_ref = memo.borrow_mut();
        calculate_min_price(&counts, &mut memo_ref)
    })
}

fn calculate_min_price(counts: &[u32; 5], memo: &mut HashMap<[u32; 5], u32>) -> u32 {
    // 如果所有计数都是0,返回0价格
    if counts.iter().all(|&c| c == 0) {
        return 0;
    }
    
    // 检查记忆化
    if let Some(&cached) = memo.get(counts) {
        return cached;
    }
    
    let mut min_price = u32::MAX;
    
    // 从最大的可能组开始尝试(贪心策略)
    for group_size in (1..=5).rev() {
        // 检查是否有足够的书籍组成这个大小的组
        // 由于counts已排序,前(5-group_size)个元素应该是0
        if counts[5 - group_size] > 0 {
            // 创建新的计数数组
            let mut new_counts = *counts;
            // 从最大的计数开始减少(因为已排序)
            for i in (5 - group_size)..5 {
                new_counts[i] -= 1;
            }
            
            // 重新排序以保持规范形式
            new_counts.sort_unstable();
            
            let price = calculate_price(group_size) + calculate_min_price(&new_counts, memo);
            min_price = min_price.min(price);
        }
    }
    
    let result = if min_price == u32::MAX { 0 } else { min_price };
    memo.insert(*counts, result);
    result
}

fn calculate_price(group_size: usize) -> u32 {
    const BOOK_PRICE: u32 = 800;
    match group_size {
        1 => BOOK_PRICE,
        2 => (BOOK_PRICE as f64 * 0.95 * 2.0) as u32, // 5%折扣
        3 => (BOOK_PRICE as f64 * 0.90 * 3.0) as u32, // 10%折扣
        4 => (BOOK_PRICE as f64 * 0.80 * 4.0) as u32, // 20%折扣
        5 => (BOOK_PRICE as f64 * 0.75 * 5.0) as u32, // 25%折扣
        _ => 0,
    }
}

错误处理和边界情况

考虑边界情况的实现:

use std::collections::HashMap;

pub fn lowest_price(books: &[u32]) -> u32 {
    // 处理空输入
    if books.is_empty() {
        return 0;
    }
    
    // 验证输入(书籍编号应在1-5范围内)
    if books.iter().any(|&book| book < 1 || book > 5) {
        // 根据题目要求,可能应该返回错误,但函数签名不允许
        // 这里我们选择忽略无效书籍
        return 0;
    }
    
    // 统计每本书的数量
    let mut counts = [0u32; 5];
    for &book in books {
        counts[(book - 1) as usize] += 1;
    }
    
    // 对计数进行排序
    counts.sort_unstable();
    
    let mut memo = HashMap::new();
    calculate_min_price(&counts, &mut memo)
}

fn calculate_min_price(counts: &[u32; 5], memo: &mut HashMap<[u32; 5], u32>) -> u32 {
    // 基本情况:所有计数都是0
    if counts.iter().all(|&c| c == 0) {
        return 0;
    }
    
    // 检查记忆化
    if let Some(&cached) = memo.get(counts) {
        return cached;
    }
    
    let mut min_price = u32::MAX;
    
    // 尝试所有可能的组大小
    for group_size in 1..=5 {
        // 检查是否可以组成这个大小的组
        if counts[5 - group_size] > 0 {
            // 创建新状态
            let mut new_counts = *counts;
            for i in (5 - group_size)..5 {
                if new_counts[i] > 0 {
                    new_counts[i] -= 1;
                }
            }
            
            // 重新排序
            new_counts.sort_unstable();
            
            // 递归计算
            let price = calculate_price(group_size) + calculate_min_price(&new_counts, memo);
            min_price = min_price.min(price);
        }
    }
    
    let result = min_price;
    memo.insert(*counts, result);
    result
}

fn calculate_price(group_size: usize) -> u32 {
    const BOOK_PRICE: u32 = 800;
    match group_size {
        1 => BOOK_PRICE,
        2 => (BOOK_PRICE as f64 * 1.90) as u32,   // 2 * (1 - 0.05)
        3 => (BOOK_PRICE as f64 * 2.70) as u32,   // 3 * (1 - 0.10)
        4 => (BOOK_PRICE as f64 * 3.20) as u32,   // 4 * (1 - 0.20)
        5 => (BOOK_PRICE as f64 * 3.75) as u32,   // 5 * (1 - 0.25)
        _ => 0,
    }
}

实际应用场景

书店折扣问题在实际开发中有以下应用:

  1. 电商折扣系统:实现复杂的购物车折扣逻辑
  2. 资源分配:优化资源分组以最小化成本
  3. 任务调度:将任务分组以优化执行效率
  4. 库存管理:优化库存组合以减少成本

算法复杂度分析

  1. 时间复杂度:O(n₁ × n₂ × n₃ × n₄ × n₅),其中 nᵢ 是第i种书籍的数量

    • 由于最多只有5种书籍,且每种书籍数量有限,实际运行时间是可接受的
    • 通过记忆化避免重复计算
  2. 空间复杂度:O(n₁ × n₂ × n₃ × n₄ × n₅)

    • 主要用于存储记忆化表

与其他算法的比较

// 贪心算法(不总是最优)
pub fn lowest_price_greedy(books: &[u32]) -> u32 {
    if books.is_empty() {
        return 0;
    }
    
    let mut counts = [0u32; 5];
    for &book in books {
        counts[(book - 1) as usize] += 1;
    }
    
    let mut total = 0;
    while counts.iter().any(|&c| c > 0) {
        let group_size = counts.iter().filter(|&&c| c > 0).count();
        total += calculate_price(group_size);
        
        // 减少计数
        counts.sort_unstable(); // 确保0在前面
        for i in (5 - group_size)..5 {
            if counts[i] > 0 {
                counts[i] -= 1;
            }
        }
    }
    
    total
}

// 穷举算法(指数时间复杂度)
pub fn lowest_price_brute_force(books: &[u32]) -> u32 {
    // 这种方法对于大输入是不可行的
    // 仅用于小规模输入或演示目的
    unimplemented!("Brute force approach is too slow for practical use")
}

总结

通过 book-store 练习,我们学到了:

  1. 动态规划:掌握了使用记忆化优化递归算法的技巧
  2. 状态表示:学会了如何有效地表示和转换问题状态
  3. 优化策略:理解了贪心算法与动态规划的区别
  4. 集合操作:熟练使用数组和哈希表进行数据统计
  5. 边界处理:学会了处理各种边界情况
  6. 性能优化:了解了记忆化和状态规范化等优化技巧

这些技能在实际开发中非常有用,特别是在实现复杂的业务逻辑、优化资源分配和解决组合优化问题时。书店折扣问题虽然看起来简单,但它涉及到了动态规划、状态搜索和优化算法等许多核心概念,是学习 Rust 算法实现的良好起点。

通过这个练习,我们也看到了 Rust 在算法实现方面的强大能力,以及如何用安全且高效的方式表达复杂的优化逻辑。这种结合了安全性和性能的语言特性正是 Rust 的魅力所在。

Logo

开放原子旋武开源社区由开放原子开源基金会孵化及运营,业务方向涉及操作系统、终端设备、安全技术、基础软件等关键领域,致力于推动Rust编程语言在中国的开源生态建设与产业落地,面向开发者全面宣传和推广Rust。

更多推荐