前言
大家好,又是我。
想必大家都有要兑换积分的时候,比如商场的购物积分、运营商那里的话费积分,抑或是学校的积分卡、课程学习的点数等等,那么,假设商品 / 礼品不限量,我们怎么才能尽可能地把积分花到一分不剩呢?
其实这个也是老师布置的结课作业之一,我在网上搜索相关关键字,但好像没有看到类似的文章,于是想着来抛砖引玉一下。
下面把老师的作业要求放上来:
- 用户在购物网站上有消费积分m,最近网站推出积分换购的活动。换购商品的种类数是不确定的,如:“双肩包”下架了,但又增加了“香皂”、“牙刷”等。
- 每种商品换购的次数不限,如:换购“新款毛巾”10条、12条、100条,都可以。
- 用户积分换购后几乎都会有剩余,找出换购后积分损失最小的前5种组合。
具体实现过程
思路
个人不是很聪明,隐约感觉这个和“零钱兑换”有点相似,但不是很确定。
嗯,有人说这个可以用动态规划……抱歉,这个我不会(目前还搞不懂);只会枚举法。
嗯,那就枚举法了。
当然,我也知道,枚举法耗时很高,甚至可能等不到尽头,但好在我们只要求若干可行方案。实践表明,一般很快就可以计算得出需要的结果。
说到枚举法,那一定要用到循环啦,而如若是多种商品,那就要用到多重循环了;而这里有一个问题就是,商品的数目不定,那如何才能实现不定数量的多重循环呢?我决定采用递归的方法来实现。
同时,为了避免超时,我将程序设置成找到5个“最优解”——即剩余积分为0的结果——就终止计算并输出结果。然而,在和同学的交流的过程中,我发现一些比较特殊的情况,在简单的枚举法下还是容易超时;
a. 当遇到无论如何也会有剩余积分的时候,且积分总量很大(即商品价值全为10的倍数,而积分不是)时,不能终止运算,造成不必要的时间复杂度;
b. 当遇到商品价值全部相同,而积分不能被其整除,也会导致一直枚举完全部情况
c. 当商品价值全为偶数,而积分是奇数时;
还有一个不知道算不算bug的问题,就是商品的价值取值丰度不够,而积分过大,可能会导致超时(估计这是枚举法不可避免的缺陷)。
先放一个简单的解释过程的代码:
#include <stdio.h>
#include <algorithm>
#include <functional>
#define ITEMS_COUNT 5
int price[ITEMS_COUNT] = {19, 20, 31, 45, 16}; // 存储商品价格
int result[ITEMS_COUNT] = {0, 0, 0, 0, 0}; // 存储购买方案
int least_remain_credits = 0x7FFFFFFF; // 最小的剩余积分,先初始化为一个很大的数,这里是int的最大范围
void foo(int tot_items, int now_item_id, int current_credits) {
if (now_item_id > tot_items - 1) { // 递归出口:每种商品都尝试过了
if (current_credits < least_remain_credits) { // 如果当前方案剩余积分更少:
least_remain_credits = current_credits; // 更新最小剩余积分
for (int i = 0; i < ITEMS_COUNT; ++i) printf("%d ", result[i]); // 输出购买方案
printf("remain: %d \n", least_remain_credits);
}
return; // 递归之后逐层退回
}
for (int i = current_credits / price[now_item_id]; i >= 0; --i) { // 先尝试买尽可能多的当前商品,如果不行再递减
result[now_item_id] = i; // 记录当前商品购买数量
// 进入下一个商品的讨论
foo(tot_items, now_item_id + 1, current_credits - i * price[now_item_id]);
}
}
int main() {
std::sort(price, price + ITEMS_COUNT + 1, std::greater<int>());
foo(ITEMS_COUNT, 0, 13243);
}
商品数据读入与存储
下面来写程序吧,我们先捡简单的做,就从数据读入存储做起;
首先,老师的要求是用结构体来存储数据,我这里定义了一个叫spInfo
的数据类型;
struct spInfo
{
char name[256];
int value;
};
然后定义一个结构体数组spList
。商品的初始数据可以通过初始化的形式写进程序,比如这样:
#define MAX_SP_NUM 256
spInfo spList[MAX_SP_NUM] = {
{"Towels",380},
{"Tooth Paste",260},
{"Pencil",80}
};
不过,有了之前学生信息管理大作业的经验,为何不做成从文件读取呢?这样方便修改,方便从网站上复制粘贴信息下来,总之较为用户友好;当然,也可以选择键盘输入。接下来我要介绍文件读入的方法,如果不需要请跳过接下来的部分。
首先,我们的文件需要写成这个样式,文件名这里设为了splist.txt
:
双肩包 = 8288
移动电源 = 7909
不锈钢保温杯 = 8699
新款毛巾 = 678
滋润型护肤霜 = 721
trim()
、str2num()
是自己写的函数,用来修剪掉字符串首末的空格与换行。str2num()
可以用stdlib.h
里面的atoi()
代替。详情参见我写的学生信息管理……
void trim(char* strIn, char* strOut) // support in-place opreation
{
char *a=strIn, *b;
while (*a == ' '||*a == '\n' || *a == '\t') a++; // ignore spaces at the beginning
b = strIn + strlen(strIn) - 1; // get pointer pointing at the end of the line
while (*b == ' '||*b == '\n' || *a == '\t') b--; // ignore spaces at the end
while (a<=b) *strOut++ = *a++; // transplace
*strOut='\0';
}
load_spList()
用来执行文件读取。
int load_spList(const char* filePath = "splist.txt")
{
FILE *fp = fopen(filePath,"r");
char buffer[512]; //缓冲区,用来读入文件的一行
char tmp[512]; //临时字符数组
int k=0;
while(!feof(fp))
{
fgets(buffer, sizeof(buffer), fp);
char *src = buffer;
char *dst = tmp;
while (*src == ' ') src++; //ignore spaces
if (*src =='\n') continue; //ignore empty line;
trim(buffer,buffer);
while (*src != '=') *dst++ = *src++;
*dst = '\0';
trim(tmp,tmp);
strcpy(spList[k].name,tmp);
src = str2num(src, &spList[k].value);
k++;
}
return k;
}
枚举的递归实现
下面声明了一些全局变量、数组,用来存储数据和结果;
#define RESULT_NUM 5
int results[RESULT_NUM][MAX_SP_NUM + 1];
int tmp_result[MAX_SP_NUM];
int results_index = 0;
int best_results = 0;
int EXPECT_REMAIN = 0;
RESULT_NUM
是用来限定要求输出方案的数目,results
数组用来存储结果(其每行最后的那个位置用来存放剩余积分),tmp_result
用来存储当前讨论过程中的商品兑换数量,results_index
用来表示现在存放结果的数组中到了第几行,best_results
用来记录已经找到的最优方案,EXPECT_REMAIN
用来设定最佳方案应当剩余的积分数(应对上文提出的会引起超时的问题)。
下面放送核心函数a()
,用来执行递归操作。函数的操作可以这样理解,对第1件商品,先假设买n件,积分减减,然后调用递归讨论下一件,买m件……直到到了出口条件:积分用光,或者达到最优的情况,或者商品枚举完毕。
据此,我们的函数应该直到这样几个事:
- 当前讨论的商品序号
- 总商品数
- 剩余积分
- 结果记录表 ——已设为全局变量
当然,我这里想到这几种,你也可以根据你的想法设置自己需要的变量。
注:update_plan()
函数用来完成向结果存储数组的誊写。
void update_plan(int tot_sp, int now_sp_index, int now_credit, int results_index)
{
//写入结果,同时避免写入tmp_result里面来自上一次讨论的无意义的结果
for (int p = 0; p<= now_sp_index - 1; p++ ) results[results_index][p] = tmp_result[p];
//把其他的部分置零,因为结果存储数组里可能会有之前没那么优秀的结果的数据
for (int p = now_sp_index; p< tot_sp; p++) results[results_index][p]=0;
//最后一个位置存放剩余的积分数
results[results_index][MAX_SP_NUM] = now_credit;
}
void a(int tot_sp,int now_sp_index, int now_credit)
{
if (best_results == RESULT_NUM) return; //已经找够了,就退出
// 如果剩余积分达“最佳状态”,或者讨论完了所有商品:
if (now_credit <= EXPECT_REMAIN || now_sp_index == tot_sp )
{
//如果这是个最佳结果,则“最佳结果”数加一
if (now_credit == EXPECT_REMAIN) best_results++;
//如果结果存储数组还没有满
if (results_index < RESULT_NUM)
{
update_plan(tot_sp,now_sp_index,now_credit,results_index);
results_index++;
}
else //否则得看有没有可以替换的数据
{
int max_remain_i = 0;
for (int w=0; w < RESULT_NUM; w++) //遍历结果数组,看谁的剩余积分最多
{
if (results[w][MAX_SP_NUM] > results[max_remain_i][MAX_SP_NUM]) max_remain_i = w;
}
if(now_credit<results[max_remain_i][MAX_SP_NUM]) //如果当前找到的方案比它优秀,那么就更新
update_plan(tot_sp,now_sp_index,now_credit,max_remain_i);
}
return;
}
//从当前商品能兑换的最大量开始讨论
for (int i = now_credit/spList[now_sp_index].value; i >=0 ; i--)
{
tmp_result[now_sp_index]=i; //假设购买i件当前商品
//往下一层递归
a(tot_sp, now_sp_index+1, now_credit - spList[now_sp_index].value*i);
//递归函数终止后,会回到上一级,然后进入下一个循环,即商品数-1,然后接着讨论
}
}
main函数
经过一些尝试性的运算可以得知,如果积分很多,商品价值的种类有一定丰度,很容易就能花光所有积分 ;如果积分不多,那么就算商品很多,整个枚举过程很快就能结束(可以想一下,因为兑换不了几个,所以递归很快就能终止)。
int main()
{
//首先读入的商品的信息
int tot_sp = load_spList();
//设置三个标签,对应前文所提到的特殊情况
int flag_1 = 1; //假设所有商品都是10的倍数
int flag_2 = 1; //假设所有商品价值均相等
int flag_3 = 1; //假设所有商品价值全为偶数
//输出读取到的商品信息
printf("# Items: \n");
for (int i=0;i<tot_sp;i++)
{
printf("%5d | %s\n",spList[i].value,spList[i].name);
if (spList[i].value%10 != 0) flag_1 = 0;
if (i>0) if (spList[i].value != spList[i-1].value) flag_2 = 0;
if (spList[i].value % 2 == 1) flag_3 = 0;
}
printf("# Total %d items;\n",tot_sp);
//获取用户积分数
int m;
printf("# Please input the credits you've got: ");
scanf("%d",&m);
//设定“最佳剩余积分”,即积分不可能少于这个
if (flag_1 == 1) EXPECT_REMAIN = m % 10;
if (flag_3 == 1 && flag_1 != 1) EXPECT_REMAIN = m % 2;
if (flag_2 == 1) EXPECT_REMAIN = m % spList[0].value;
// 比如,积分不是10的倍数,但恰好商品全是10的倍数,
// 则剩余积分不可能为0,最好情况也只可能是m%10。
//benchmark(m,tot_sp,flag_1,flag_2,flag_3);
//跑分函数用来检测有没有会被卡住的情况,该函数的definition见下个部分
//开始运算
a(tot_sp,0,m);
//运算结束,输出结果
printf("\n");
int cnt = 1; //记录输出方案的个数
for(int i=0;i<results_index;i++)
{
if(results[i][MAX_SP_NUM]==m) continue; //跳过积分没有被使用的情况
printf("# Plan %d:\n",cnt++); //输出方案序号
int remain = m;
for (int j=0;j<tot_sp;j++)
{
if (results[i][j] == 0) continue; //不输出没有兑换的商品
printf("- %5d * %9d | %s\n",results[i][j],spList[j].value,spList[j].name);
//remain -= results[i][j] * spList[j].value;
//设置一个remain,用来校验是否计算正确,可以去掉它,直接使用数组中存储的那个结果
}
printf ("# Remain credits: %d\n", results[i][MAX_SP_NUM]);
//printf ("# Remain credits: %d\n",remain);
printf("\n");
}
printf("# Finished!\n");
if(cnt == 0) printf("# No solution can be found!\n");
return 0;
}
跑分:检测算法性能
加入了跑分函数,可以用来检测程序是否会被从1到m之间的某个数卡住。貌似没啥用。
int benchmark(int m, int tot_sp, int flag_1, int flag_2, int flag_3)
{
if (flag_1 == 1) EXPECT_REMAIN = m % 10;
if (flag_3 == 1 && flag_1 != 1) EXPECT_REMAIN = m % 2;
if (flag_2 == 1) EXPECT_REMAIN = m % spList[0].value;
for (int i=1;i<m;i++)
{
a(tot_sp,0,i);
printf ("credits: %8d, Bingo! \n",i);
results_index = 0;
best_results = 0;
memset(results,0,sizeof(results));
memset(tmp_result,0x00,sizeof(tmp_result));
}
}
最后
可能有人说,你这个算法不行,结果都是随便找到的解,那我想兑换自己想要的怎么办?
对于这种情况,我觉得可以先把自己想要的东西兑换后,把积分减去,然后再次运行这个程序就好了;再者,我只负责帮你把积分花干净,没有说能够帮助你做出最心仪的选择呀。
如果你喜欢它,可以拿它去试一下,看能不能在生活中用到(估计没什么人用——“剩点积分怎么了,为了那点儿东西还要写个程序,太麻烦了!”)。还可以做一些优化,比如结果先排序再输出,比如可以预先把想要的商品“兑换”掉等等。总的来说,这个程序能够应付大多问题,虽然还是会有卡住的情况,如果你不幸 / 有幸遇到了,可以在下面留言。