一:暴力-蛮力:百钱百鸡、趣味题
概述
蛮力法(brute force method, BF),是一种简单直接地解决问题的方法,常常直接基于问题的描述和所涉及的概念定义。通常蛮力策略也常常是最容易实现的方法。
使用蛮力法求解,必须要找出所有可能的情况,然后对每一种情况试探是否为所求。蛮力法需要对数据进行扫描或对问题解空间进行遍历,确保对每一个元素都访问且只访问一次(既不要遗漏,也不要重复) ,从而找出问题的解。
常见的遍历技巧:
数组、集合、线性表、链接表、栈、队列的遍历
子集的遍历
树、图的遍历
组合的遍历
排列的遍历
优缺点
虽然巧妙和高效的算法很少来自于蛮力法,但它在算法设计策略中仍然具有重要地位。
- 蛮力法适应能力强,是唯一一种几乎什么问题都能解决的一般性方法。
- 蛮力法一般容易实现,在问题规模不大的情况下,蛮力法能够快速给出一种可接受速度下的求解方法。
- 虽然通常情况下蛮力法效率很低,但可以作为衡量同类问题更高效算法的准绳。
典型应用:
- 查找问题:顺序查找、串匹配问题
- 排序问题:选择排序、冒泡排序
- 组合问题:任务指派问题(全排列)、0/1 背包问题(幂集)
- 图问题:哈密顿回路(顶点排列)
- 几何问题:最近点对、凸包问题
问题1:百钱百鸡问题
公元 5 世纪末,中国古代数学家张丘建在《算经》中提出一个问题
意思是公鸡每只 5 元、母鸡每只 3 元、小鸡 3 只 1 元,用 100 元买 100 只鸡,求公鸡、母鸡、小鸡的只数。
解法一(三重循环)1.采用穷举法,可能买到的公鸡数从 0 到 19 只,母鸡数从 0 到 33 只,小鸡数从 0 到 100 只。 2.c%3==0 !!易忘 采用三重循环来遍历全部组合,运算步骤上万步。
#include <stdio.h>
int main() {
int a,b,c;//公鸡、母鸡、小鸡
for (a=0; a<20; a++) {
for (b=0; b<34; b++) {
for (c=0; c<100; c++) {
if ((a+b+c==100)&&(5*a+3*b+c/3==100)&&(c%3==0)) {
printf("公鸡、母鸡和小鸡分别有:%d,%d,%d\n",a,b,c);
}
}
}
}
}
解法二(二重循环)
解法一(三重循环)情况数目很大, 需优化。
1.由于先a后b,故b循环时可以b<(100-a*5)/3
2.另外 c=100-a-b。我们可以通过 c=100-a-b 这条已知条件,减去一个循环, 使算法复杂法降为 N 的平方,运算步骤减至几百步。
#include <stdio.h>
int main(){
int a,b,c;
for (a=0;a<20;a++){
for (b=0;b<(100-a*5)/3;b++) {
c = 100-a-b;
if ((5*a+3*b+c/3==100)&&(c%3==0)) {
printf("公鸡、母鸡和小鸡分别有: %d,%d,%d\n",a,b,c);
}
}
}
}
解法三(一重循环)
1.已知方程组故可只用一重循环来确定公鸡的数量 2.根据 b=(25-7*c/a)>=0,得出 a 小于 15
#include <stdio.h>
int main() {
int a,b,c; //定义变量公鸡,母鸡,小鸡
for(a=0; a<15; a++) { //根据 b=(25-7*c/a)>=0,得出 a 小于 15
b=25-a*7/4.0;
c=75+a*3/4.0;
if (b>=0 && c>=0 && c%3==0 && a+b+c==100)
printf("公鸡、母鸡和小鸡分别有: %d,%d,%d\n",a,b,c);
}
return 0;
}
问题2.趣味题-犯罪问题
问题描述:警察局抓住了 A、B、C、D 四名盗窃嫌疑犯,其中只有一人是小偷。
在审问时
现在已经知道这四人中有三人说的是真话,一人说的是假话。请问到底谁是小偷?
#include <stdio.h>
int main(void) {
int a,b,c,d;
for(a=0;a<=1;a++) //1 代表小偷 0 代表不是小偷
for(b=0;b<=1;b++)
for(c=0;c<=1;c++)
for(d=0;d<=1;d++)
if(!a+c+!d+d==3 && a+b+c+d==1)
printf("A:%d B:%d C:%d D:%d",a,b,c,d);
}
!a+c+!d+d==3这里不太理解
1.硬币兑换问题
问题描述:用 100 元面值的纸币兑换成面值 1 元、2 元和 5 元硬币,有多种不同的兑换方法,请输出所有可能的方案。
#include <stdio.h>
int main() {
int a,b,c; //5元、2元、1元
int count = 0;
for (a=0;a<=20;a++) {
for (b=0; b<=(100-5*a)/2; b++) {
for (c=0; c<=(100-5*a-2*b); c++) {
if ((5*a+2*b+1*c)==100) {
count++;
printf("1元、2元和5元分别有:%d,%d,%d,一共%d种可能\n",c,b,a,count);
}
}
}
}
}
小问题:for (b=0; b<50; b++) 改为 for (b=0; b<(100-5*a)/2; b++) 受到上面百钱百鸡的影响,没有加等号
2.零件问题
问题描述:有一堆零件,(100-200 个之间),如果以四个零件为一组,则多 2 个零件,如果以 7 个零件为一组进行分组,则剩 3 个零件,如果以 9 个零件为一组,则剩 5 个零件。请问零件一共有多少?请输出所有可能的方案。
3.啤酒饮料问题
问题描述:啤酒每罐 2.3 元,饮料每瓶 1.9 元,小明买了若干饮料喝啤酒,总共花了 82.3 元,并且他买的啤酒的数量比饮料的少,请问他买了多少罐啤酒和多少瓶饮料。请输出所有可能的方案。
4.污损数字问题
有等式[■×(■3+■)]²=8■■9,其中■处为 1 个数字,滴上了墨水无法辨认。请编程找出■表示哪些数字, 输出整个等式。
5.委派任务
某侦察队接到一项紧急任务,要求在 A、B、C、D、E、F 六个队员中尽可能多地挑若干人,但有以下限制条件:
A 和 B 两人中至少去一人;
A 和 D 不能一起去;
A、E 和 F 三人中要派两人去;
B 和 C 都去或都不去;
C 和 D 两人中去一个;
若 D 不去,则 E 也不去。
输出结果如下:
#include <stdio.h>
int main() {
int a,b,c,d,e,f;
for (a=0; a<=1; a++) //1 代表去 0 代表不去
for (b=0; b<=1; b++)
for (c=0; c<=1; c++)
for (d=0; d<=1; d++)
for (e=0; e<=1; e++)
for (f=0; f<=1; f++)
{
if (d == 0)
e = 0;
if (a+b>=1 && a+d == 1 && a+e+f == 2 && (b+c==0 || b+c==2) && c+d==1)
{
if(a==1) printf("A 去\n"); else printf("A 不去\n");
if(b==1) printf("B 去\n"); else printf("B 不去\n");
if(c==1) printf("C 去\n"); else printf("C 不去\n");
if(d==1) printf("D 去\n"); else printf("D 不去\n");
if(e==1) printf("E 去\n"); else printf("E 不去\n");
if(f==1) printf("F 去\n"); else printf("F 不去\n");
}
e=1;
}
return 0;
}
6.数组重复元素问题
找出数组中重复的数字。在一个长度为 n (2 <= n <= 100000)的数组里的所有数字都在 1~n-1的范围内。数组中某些数字是重复的,但不知道有几个数字重复了,也不知道每个数字重复了几次。
以输入 0 表示数组数字结束。请输出所有刚好重复了 2 次的数字,并按数字从小到大的顺序输出。
注意:重复的数字只输出一次。
示例:
输入:5 2 5 2 2 3 4 3 8 2 0
输出:3 5
#include <stdio.h>
int main() {
}
二:分治:归并排序、快速排序
0、课前热身题——程序调试
例题:阅读下面代码,说明程序的作用
#include <stdio.h>
void r(){
char c;
scanf("%c", &c);
if( c != '\n')
{
r();
printf("%c",c);
}
}
int main(){
printf("输入一个字符串: ");
r();
return 0;
}
1、复习冒泡排序
思路直观,代码最简单:算法重复地走访过要排序的数列,每次比较相邻的一对元素,如果他们的顺序错误(如从大到小、首字母从 Z 到 A),则交换它们的位置。
代码:依次比较相邻的两个数 a[j]和 a[j+1],将小数放在前面,大数放在后面。
#include<stdio.h>
#include<stdlib.h>
void BubbleSort(int a[], int n){
int temp;
for (int i = 0; i < n-1; i++) { // 外循环只要 n-1 次
for (int j = 0; j < n-1-i ; j++) { //内循环,把最大的数放在最后——沉底。
//如果 a[j]比 a[j+1]大,则错位,交换
if (a[j] > a[j + 1]) {
temp = a[j]; //用中间变量交换数组两个位置的值
a[j] = a[j + 1];
a[j + 1] = temp;
}
}
}
}
int main(){
int n = 10;
int a[] = {2,8,3,13,1,6,7,5,20,9 };
BubbleSort(a,n);
for (int q = 0; q < 10; q++)
printf("%d ", a[q]);
return 0;
}
分治法概述
分治,字面上的解释是“分而治之”:把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。
分、治其实还有个合并的过程:
• 分(Divide):递归解决较小的问题(到终止层或者可以解决的时候停下)
• 治(Conquer):递归求解,如果问题够小直接求解。
• 合并(Combine):将子问题的解构建父类问题
问题1:归并排序【分治思想,时间复杂度 O(nlogn)】
#include <stdio.h>
#include <stdlib.h>
void MergeArray(int* a, int left, int mid, int right)
{
int i = left;
int j = mid + 1;
int length = right - left + 1;int* temp = (int*)malloc((length) * sizeof(int));
int k = 0;
while (i <= mid && j <= right)
{
if (a[i] <= a[j])
{
temp[k] = a[i];
i++;
k++;
}
else
{
temp[k] = a[j];
j++;
k++;
}
}
while (i <= mid)
{
temp[k] = a[i];
i++;
k++;
}
while (j <= right)
{
temp[k] = a[j];
j++;
k++;
}//拷贝数据回原来数组
for (k = 0; k < length; k++)
{
a[left + k] = temp[k];
}
free(temp);
}
void MergeSort_Find(int* a, int left, int right){
if (left < right){
int mid = (left + right) / 2;
MergeSort_Find(a, left, mid);
MergeSort_Find(a, mid + 1, right);
MergeArray(a, left, mid, right);
}
}
int main(){
int i, a[100],n,key;
printf("请输入数组长度:\n");
scanf("%d",&n);
printf("请输入数组元素:\n");
for(i=0;i<n;i++)
scanf("%d",&a[i]);
MergeSort_Find(a,0,n-1);
printf("MAX=%d\nMIN=%d\n", a[n-1],a[0]);
return 0;
}
3.2 快速排序【分治思想,时间复杂度 O(nlogn)】
4、实训题目
用分治法查找数组元素的最大值和最小值
要求:使用分治的算法,根据不同的输入用例,能准确的输出用例中的最
大值与最小值。
程序思路:利用分治法,将一个数组元素个数大于 2 的数组分成两个子数
组,然后对每一个子数组递归调用,直到最小的子数组的元素个数为 1 个或者
是 2 个,此时直接就能得出最大值与最小值,然后合并子数组,比较 2 个子数
组的最大值与最小值,依次进行下去,知道找到整个数组的最大值与最小值。
/*以下为测试数据
test1:
8
1 4 15 -3 6 12 -5 9
result1:
MAX = 15
MIN = -5
——————————————-
test2:
7
1 4 15 -3 6 12 -5
result2:
MAX = 15
MIN = -5
——————————————-
test3:
2
1 4
result3:
MAX = 4
MIN = 1
——————————————-
test4:
1 -
50
result4:
MAX = -50
MIN = -50
#include <stdio.h>
#include <stdlib.h>
void MergeArray(int* a, int left, int mid, int right)
{
int i = left;
int j = mid + 1;
int length = right - left + 1;int* temp = (int*)malloc((length) * sizeof(int));
int k = 0;
while (i <= mid && j <= right)
{
if (a[i] <= a[j])
{
temp[k] = a[i];
i++;
k++;
}
else
{
temp[k] = a[j];
j++;
k++;
}
}
while (i <= mid)
{
temp[k] = a[i];
i++;
k++;
}
while (j <= right)
{
temp[k] = a[j];
j++;
k++;
}//拷贝数据回原来数组
for (k = 0; k < length; k++)
{
a[left + k] = temp[k];
}
free(temp);
}
void MergeSort(int* a, int left, int right){
if (left < right){
int mid = (left + right) / 2;
MergeSort(a, left, mid);
MergeSort(a, mid + 1, right);
MergeArray(a, left, mid, right);
}
}
int main(){
int a[10] = { 5, 2, 11, 6, 7, 3 , 9 ,10 ,1 ,4};
int n = 10;
MergeSort(a, 0, 9);
printf("MAX=%d\nMIN=%d\n", a[10-1],a[0]);
return 0;
}
二:分治加强:最优股票利润
问题1:股票最大买入卖出方案——最大连续子段和问题
问题:给定一个 int 数组,长度为 n,数组中存放的是某只股票 n 天来的相对于前一交易日的股价涨跌情况。求在一次买入、卖出的操作下,能获得的最大利润。
例如[3, -7, 4, -3, 5, -2, -1, 2, 6, -2],它的最大利润为 11=[4+(-3)+5+(-2)+(-1)+(2)+(6)]。
此问题实质上是求该序列连续的子段和的最大值,即最大连续子序列之和。
又比如,对一个数列[-2, 1, -3, 4, -1, 2, 1, -5, 4],其连续子数列中和最大的是[4, -1, 2, 1],其和为6。
解决该问题有很多方法可以通过暴力、分治和动态规划法。
a:暴力法
• 思想:从序列首元素开始穷举所有可能的子序列。
• 算法复杂度为 O(n3)。 改进的蛮力算法,直接在划定子序列时累加元素值,减少一层循环。
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int main(int argc, char** argv)
{
int n = 10;
int a[] = { 3, -7, 4, -3, 5, -2, -1, 2, 6, -2 };
int maxSum = INT_MIN;
int indexI, indexJ;
for (int i = 0; i < n; i++)
{
for (int j = n - 1; j >= i; j--)
{
int tempSum = 0;
for (int k = i; k <= j; k++)
tempSum += a[k];
if (tempSum > maxSum)
{
maxSum = tempSum;
indexI = i;
indexJ = j;
}
}
};
printf("Max sum array is from a[%d]...a[%d], and the sum is : %d",indexI, indexJ, maxSum);
return 0;
}
b:分治法
• 思想:将序列划分为左右两部分,则最大子段和可能在三处出现:左半部、右半部以及跨越左右边界的部分。递归的终止条件是: left == right。
过程:
在数组的 mid = (left+right)/2 位置处分开,形成两个子数组。那么,最大子段和 可能出现在三个位置:
1.可能出现在 左子数组
2.可能出现在 右子数组
3.可能出现在 过 mid 的 中间某部分 元素 组成的子数组。
b.1 版:求跨边界最大子段 left_right_Max 还是用穷举法。
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int max3(int a, int b, int c)
{
int max = a;
if (b > max)
max = b;
if (c > max)
max = c;
}
void findMaxSum(int a[], int left, int right, int* maxSum)
{
if (left < right) // 设置递归条件,即大问题分解为小问题的条件。如果不满足,则递归终止
{
int mid = (left + right) / 2;
int left_Max, right_Max, left_right_Max = 0;
findMaxSum(a, left, mid, &left_Max); // 递归求左半部分最大子序列和
findMaxSum(a, mid + 1, right, &right_Max); // 递归求右半部分最大子序列和
// 处理左右边界问题:最大子序列跨越中间,包含左半部分最右一个数,同时包含右半部分最左一个数
for (int i = left; i <= mid; i++)
{
for (int j = right; j >= mid + 1; j--)
{
int sum = 0; // 临时求和变量
for (int k = i; k <= j; k++)
sum += a[k];
if (sum > left_right_Max)
left_right_Max = sum;
}
}
*maxSum = max3(left_Max, right_Max, left_right_Max); // 返回三个部分的最大子序列和
}
else
*maxSum = a[left];
}
int main(int argc, char** argv){
int n = 10;
int a[] = { 3, -7, 4, -3, 5, -2, -1, 2, 6, -2 };
int maxSum = INT_MIN;
findMaxSum(a, 0, n - 1, &maxSum);
printf("最大字段和为: %d", maxSum);
return 0;
}
b.2 版:求跨边界的最大子段 left_right_Max 用分治法。
b.1算法时间复杂度非常高,需优化。
改进 left_right_Max 的求法:以 mid 为中心分别向两边计算和。
a.从 mid 出发,每次向左边扩张一步,并且记录当前的和值 sum,如果当前的和比 S1 大,就更新 S1,一直向左扩张到位置 Left。
b.从 mid +1 出发,每次扩张一步,计算当前的和 sum,如果当前的值比 S2 大,那么,就更新 S2 的值,一直向右扩张到位置 Right。
c.计算过 mid 的连续值的和, S1+S2 的值 Sum。 这个就是跨边界的和
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int max3(int a, int b, int c)
{
int max = a;
if (b > max)
max = b;
if (c > max)
max = c;
}
void findMaxSum(int a[], int left, int right, int* maxSum){
if (left < right) // 设置递归条件,即大问题分解为小问题的条件。如果不满足,则递归终止
{
int mid = (left + right) / 2;
int left_Max, right_Max, left_right_Max;
findMaxSum(a, left, mid, &left_Max); // 求左半部分最大子序列和
findMaxSum(a, mid + 1, right, &right_Max); // 求右半部分最大子序列和
// 处理左右边界问题:最大子序列跨越中间,包含左半部分最右一个数,同时包含右半部分最左一个数
int S1 = 0, S2 = 0;
int sum = 0; // 临时求和变量
for (int i = mid; i >= left; i--)
{
sum += a[i];
if (sum > S1)
S1 = sum; // 左边包含边界最大序列和
}
sum = 0;
for (int j = mid + 1; j <= right; j++)
{
sum += a[j];
if (sum > S2)
S2 = sum; // 右边包含边界最大序列和
}
left_right_Max = S1 + S2; // 最大边界子序列和等于两部分边界之和
*maxSum = max3(left_Max, right_Max, left_right_Max); // 返回三个部分的最大子序列和
}
else
*maxSum = a[left];
}
int main(int argc, char** argv)
{
int n = 10;
int a[] = { 3, -7, 4, -3, 5, -2, -1, 2, 6, -2 };
int maxSum = INT_MIN;
findMaxSum(a, 0, n - 1, &maxSum);
printf("最大字段和为: %d", maxSum);
return 0;
}
c.动态规划法
用数组 dp[j]表示序列 a[1,…,j]中以 a[j]结尾的(注意,必须含有元素 a[j]),向前连续延伸的最
大子段的和的值,
若数组 dp 的全部元素可以求出来,则原问题则所求的最大子段和为
由 b[j]的定义可易知:
当 b[j-1]>0 时, b[j]=b[j-1]+a[j]
否则 b[j]=a[j]。
故 b[j]的动态规划递归式为:
T(n)=O(n)
//时间复杂度为 O(n)
#include <stdio.h>#include <string.h>
#include <stdlib.h>
int main(int argc, char** argv)
{
int n = 10;
int a[] = { 3, -7, 4, -3, 5, -2, -1, 2, 6, -2 };
int dp[10];
dp[0] = a[0];
//计算动态数组
for (int i = 1; i < n; i++)
{
dp[i] = max(dp[i - 1] + a[i], a[i]);
}
//找出数组最大子段和
int maxSum = INT_MIN;
for (int j = 0; j < n; j++)
{
maxSum = max(maxSum, dp[j]);
}
printf("最大字段和为: %d", maxSum);
return 0;
}
5、作业
作业 1、二分查找
二分查找也称折半查找(Binary Search),它是一种效率较高的查找方法。
算法要求:
1.必须采用顺序存储结构。
2.必须按关键字大小有序排列。
查找过程
首先,假设表中元素是按升序排列,将表中间位置记录的关键字与查找关键字比较,如果两者相等,则查找成功;
否则利用中间位置记录将表分成前、后两个子表,如果中间位置记录的关键字大于查找关键字,则进一步查找前一子表,否则进一步查找后一子表。
重复以上过程,直到找到满足条件的记录,使查找成功,或直到子表不存在为止,此时查找不成功(返回-1)。
int BinarySearch(int* a, int len, int key)
{
int l=0, r=n-1,mid=0;
while (l<=r)
{
int mid= l+(r-l)/2; //避免溢出
if(a[mid]==key)
return mid ; //返回要查找元素的下标
if(key>a[mid])
l=mid+1;
else
r=mid-1;
}
return 0;
}
int main() {
int a[8] = {1,3,8,13,15,19,22,30};
for (int q = 0; q < 8; q++)
printf("%d ", a[q]);
printf("\n 请输入要查找的数字: ");
int key;
scanf("%d",&key);
int index = BinarySearch(a,8,key);
if (index>=0)
printf("数字 %d 的下标为 %d\n",key,index);
else
printf("数字 %d 未找到! \n",key);return 0;
}
三:动态规划:走楼梯、数塔
问题1:爬楼梯问题
有一座高度是 10 级台阶的楼梯,从下往上走, 每跨一步只能向上 1 级或者 2 级台阶。要求用程序来求出一共有多少种走法。
比如,每次走 1 级台阶,一共走 10 步,这是其中一种走法。我们可以简写成1,1,1,1,1,1,1,1,1,1。
再比如,每次走 2 级台阶,一共走 5 步,这是另一种走法。我们可以简写成2,2,2,2,2。
当然,除此之外,还有很多很多种走法。
a.暴力法
用排列组合的思想,写一个多层嵌套循环遍历所有的可能性。每遍历出一个组合,让计数器加一。
缺点:算法时间复杂度将是指数级。
b.动态规划法
假设你只差最后一步走到第十级楼梯,这时会出现几种情况呢?
由于每一步只能走 1 级或者 2 级,所以只有两种情况。
第一种情况是从第 9 级走到第 10 级。
第二种情况是从第 8 级走到第 10 级。
咱们暂且不管从 0 走到 8 级台阶的过程,也不管从 0 级走到 9 级台阶的过程。
想要走到第 10 级,最后一步必然是从 8 级或者 9 级开始。
接下来引申出一个新的问题:如果我们已知 0 到 9 级台阶的走法有 X 种, 0 到 8级台阶的走法有 Y 种,那么 0 到 10 级台阶的走法有多少种?
答案是 X+Y 种。
10 级台阶的所有走法可以根据最后一步的不同而分成两部分。
第一部分的最后一步是从 9 级到 10 级,这部分的走法数量和 9 级台阶的走法数量是相等的,也就是 X。
第二部分的最后一步是从 8 级到 10 级,这部分的走法数量和 8 级台阶的走法数量是相等的,也就是 Y。
这两部分相加,总的走法数量是 X+Y!
这样一来就可以得出一个结论:
从 0 到 10 级台阶的走法数量 = 0 到 9 级的走法数量 + 0 到 8 级的走法数量。
可以简单表示为 F(10) = F(9) + F(8)
那么,我们如何计算 F(9)和 F(8)呢?
利用刚才同样的思路,不难得到:
F(9) = F(8) + F(7)
F(8) = F(7) + F(6)
…………
我们正在把一个复杂的问题分阶段进行简化,逐步简化成简单的问题。这就是
动态规划的思想。
F(1) = 1
F(2) = 2
F(n) = F(n-1) + F(n-2) 当 n≥3
动态规划当中包含三个重要的概念:【最优子结构】、【边界】、【状态转移公
式】。
• 刚才我们分析出 F(10)= F(9)+F (8),因此 F(9)和 F(8)是 F(10)的【最优子结
构】。
• 当只有 1 级台阶或 2 级台阶时,我们可以直接得出结果,无需继续简化。我们称
F(1)和 F(2)是问题的【边界】。如果一个问题没有边界,将永远无法得到有限
的结果。
【状态转移方程】
• F(n)= F(n-1)+F(n-2)是阶段与阶段之间的【状态转移方程】。这是动态规划的核心,决定了问题的每一个阶段和下一阶段的关系。
b.1 简单递归实现
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int getClimbingWays(int n, int * count)
{
(*count)++;
if (n < 1) return 0;
if (n == 1) return 1;
if (n == 2) return 2;
return getClimbingWays(n-1,count) + getClimbingWays(n-2,count);
}
int main(int argc, char** argv)
{
int n,count=0;
printf("请输入 n=");
scanf("%d", &n);
printf("走法有%d 种\n", getClimbingWays(n,&count));
printf("递归调用了%d 次", count);
return 0;
}
b.2 递归实现的优化——备忘录法(自顶向下求解)
很多子问题被重复计算了。下图中, 相同的颜色代表了方法被传入相同的参数。
对于这种存在大量子问题重复计算的情况,如何避免?
可以把计算结果暂存起来——就是所谓的【备忘录算法】。
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int result[1000];
int getClimbingWays(int n)
{
if (n < 1) return 0;
if (n == 1) return 1;
if (n == 2) return 2;
if (result[n] != -1) return result[n];
else {
result[n] = getClimbingWays(n - 1) + getClimbingWays(n - 2);
return result[n];
}
}
int main(int argc, char** argv)
{
int n;
printf("请输入 n=");
scanf("%d", &n);
for (int i = 0; i <= n; i++)
{
result[i] = -1;
}
printf("走法有%d 种\n", getClimbingWays(n));
return 0;
}
b.3 自底向上法求解
我们回到一开始的思路,我们是假定前面的楼梯已经走完,只考虑最后一步,所以才得出来 f(n) = f(n-1) + f(n-2)的递归式,这是一个自顶向下求解的式子
一般来说,按照正常的思路应该是一步一步往上走,应该是自底向上去求解才比较符合正常人的思维。
这是一开始走的一个和两个楼梯的走法数,即之前说的初始状态
这是进行了一次迭代得出了 3 个楼梯的走法, f(3)只依赖于 f(1) 和f(2)。继续看下一步
这里又进行了一次迭代得出了 4 个楼梯的走法, f(4)只依赖于 f(2) 和f(3)
我们发现每次迭代只需要前两次迭代的数据,不用像备忘录一样去保存所有子状态的数据
这时我们可以再看看当前的时间复杂度和空间复杂度当前时间复杂度仍为 O(n),但空间复杂度降为 O(1)这就是理想的结果
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int result[1000];
int getWays(int n)
{
//方法 1
//if (n < 1)
// return 0;
//if (n == 1)
// return 1;
//if (n == 2)
// return 2;
//
//int a = 1, b = 2;
//int temp = a + b;
//for (int i = 3; i <= n; i++)
//{
// temp = a + b;
// a = b;
// b = temp;
//}
//return temp;//方法 2
result[1] = 1;
result[2] = 2;
for (int i = 3; i <= n; i++)
{
result[i] = result[i-1] + result[i-2];
}
return result[n];
}
int main(int argc, char** argv)
{
int n;
printf("请输入 n=");
scanf("%d", &n);
printf("走法有%d 种\n", getWays(n));
return 0;
}
问题2:数塔问题
数字塔是第 i 行有 i 个数字组成, 从上往下每个数字只能走到他正下方数字或
者正右下方数字,求数字塔从上到下所有路径中和最大的路径的全体数字之
和。
如有下数字塔
3
1 5
8 4 3
2 6 7 9
6 2 3 5 1
最大路径是 3-5-3-9-5,和为 25。
从下往上看时:
每进来上面一行,上面一行每个数字有两条路径到达下面一行,所以选一条最大的就可以
所以最大值就是最上面数字就是 25.
具体方法也是建立一个二维数组,最下面一行数据添到二维数组最后一行,从
下往上填数字,所以状态转化方程是 dp[i][j]=Math.max(dp[i+1][j+1],dp[i+1][j]) + n[i][j]
数塔中元素用二维数组 a[][]表示
可设 dp[i][j]为第 i 层的第 j 个顶点到底层经过的最大数字和。
所以我们可以从后往前推出状态转移方程(逆向思维),
dp[0][0]等于第 1 层第 1 个数字到底层经过的最大数字和,它等于 dp[1][0] +
a[0][0]或 dp[1][1] + a[0][0]中的最大的数值。
【状态转移方程】
我们可以公式化,得到如下状态转换方程:
dp[i][j] = max( dp[i+1][j] + dp[i+1][j+1] ) + a[i][j]
由状态定义我们可以得出当数字在最底层时,它到最底层就是数字本身,即可
得出 dp[i][j] = a[i][j] (0≤i,j≤N)
作业要求:写出 dp 数组的手工计算结果
25 | ||||
---|---|---|---|---|
18 | 22 | |||
17 | 16 | 17 | ||
8 | 9 | 12 | 14 | |
6 | 2 | 3 | 5 | 1 |
三:动态规划加强
动态规划基本框架
⾸先,动态规划问题的⼀般形式就是求最值:⽐如最⼤连续⼦段和、爬楼梯问题求的有多少种爬楼⽅法、数塔问题的最⼤数字和。
既然是要求最值,核⼼问题是什么呢?求解动态规划的核⼼问题是穷举。因为要求最值,肯定要把所有可⾏的答案穷举出来,然后在其中找最值呗。
但是,为了优化算法的效率,⼜不⽤直接穷举的⽅法。
⾸先,动态规划的穷举有点特别,因为这类问题存在「重叠⼦问题」,如果暴⼒穷举的话效率会极其低下,所以需要「备忘录」或者「DP 数组」来优化穷举过程,避免不必要的计算。
⽽且,动态规划问题⼀定会具备「最优⼦结构」,才能通过⼦问题的最值得到原问题的最值。
另外,虽然动态规划的核⼼思想就是穷举求最值,但是问题可以千变万化,穷举所有可⾏解其实并不是⼀件容易的事,只有列出正确的「状态转移⽅程」才能正确地穷举。
以上提到的重叠⼦问题、最优⼦结构、状态转移⽅程就是动态规划三要素。
重叠⼦问题:是⼀个递归解决⽅案⾥包含的⼦问题虽然很多,但不同⼦问题很少。少量的⼦问题被重复解决很多次。
例⼦:爬楼梯问题就可以分解为许多⼦问题,⽽且这些⼦问题是重叠的,要重复计算多次。⽽归并排序也有很多⼦问题,但是这些⼦问题是相互独⽴的,不重叠的。
最优⼦结构性质(最优化原理)是指原问题的最优解包含⼦问题的最优解。
例如:⼀个国家中跑步最快的运动员(问题的最优解),那么他必须是他所在省份中跑步最快的运动
员(⼦问题的最优解),这样他才可能是这个国家最快的运动员。
例如:数塔问题和爬楼梯问题。
在实际的算法问题中,写出状态转移⽅程是最困难的。
下⾯给出⼀个思维框架,辅助你思考状态转移⽅程:
明确 base case -> 明确「状态」-> 明确「选择」 -> 定义 dp 数组/函数的含义。
按上⾯的套路⾛,最后的结果就可以套这个框架:
// 初始化 base case
dp[0][0][...] = base
// 进⾏状态转移
for 状态1 in 状态1的所有取值:
for 状态2 in 状态2的所有取值:
for ...
dp[状态1][状态2][...] = 求最值(选择1,选择2...)
问题1:零钱问题
假设您是个⼟豪,身上带了⾜够数量的1、5、10、20、50、100元⾯值的钞票。现在您的⽬标是凑出某个⾦额w,需要⽤到尽量少的钞票数量。
a.贪心法局限
依据⽣活经验,先凑大的再小的。这种策略称为“贪⼼”:假设我们⾯对的局⾯是“需要凑出w”,贪⼼策略会尽快让w变得更⼩。能让w少100就尽量让它少100,这样我们接下来⾯对的局⾯就是凑出w-100。⻓期的⽣活经验表明,贪⼼策略是正确的。
但是,如果我们换⼀组钞票的⾯值,贪⼼策略就也许不成⽴了。如果⼀个奇葩国家的钞票⾯额分别是1、5、11,那么我们在凑出15的时候,贪⼼策略会出错:
15=1×11+4×1 (贪⼼策略使⽤了5张钞票)
15=3×5 (正确的策略,只⽤3张钞票)
为什么会这样呢?贪⼼策略错在了哪⾥?
⿏⽬⼨光。
刚刚已经说过,贪⼼策略的纲领是:“尽量使接下来⾯对的w更⼩”。这样,贪⼼策略在w=15的局⾯时,会优先使⽤11来把w降到4;但是在这个问题中,凑出4的代价是很⾼的,必须使⽤4×1。如果使⽤了5,w会降为10,虽然没有4那么⼩,但是凑出10只需要两张5元。
在这⾥我们发现,贪⼼是⼀种只考虑眼前情况的策略。
那么,现在我们怎样才能避免⿏⽬⼨光呢?
b.动态规划法
如果直接暴⼒枚举凑出amount的⽅案,明显复杂度过⾼。太多种⽅法可以凑出amount了,枚举它们的时间是不可承受的。我们现在来尝试找⼀下性质。
重新分析刚刚的例⼦。amount=15时,我们如果取11,接下来就⾯对amount=4的情况;如果取5,则接下来⾯对amount=10的情况。
我们发现这些问题都有相同的形式:“给定amount,凑出amount所⽤的最少钞票是多少张?”接下来,我们⽤ 来表示“凑出n所需的最少钞票数量”。
那么,amount=15时,我们如果先取11,最后的代价(⽤掉的钞票总数)是多少呢?
明显,代价 ,它的意义是:利⽤11来凑出15,付出的代价等于
f(4)加上⾃⼰这⼀张钞票。现在我们暂时不管f(4)怎么求出来。
依次类推,⻢上可以知道:
amount=15时,我们如果先取5,最后的代价 amount=15时,我们如果先取1,最后的代价 |
。 。 |
---|---|
那么,现在amount=15的时候,我们该取那种钞票呢?当然是各种⽅案中,cost值最低的那⼀个!
- 取11:
- 取5:
- 取1:
显⽽易⻅,cost值最低的是取5的⽅案。我们通过上⾯三个式⼦,做出了正确的决策!
4
这给了我们⼀个⾄关重要的启示:
只与 相关;
更确切地说:
【状态转移方程】
这个式⼦是⾮常激动⼈⼼的。我们要求出 | ,只需要求出⼏个更⼩的 |
---|---|
们从⼩到⼤把所有的 | 求出来不就好了?注意⼀下边界情况即可。 |
值;既然如此,我
回顾⼀下本题的算法设计过程:
1、确定 base case,这个很简单,显然⽬标⾦额 amount 为 0 时算法返回 0,因为不需要任何硬币
就已经凑出⽬标⾦额了。
2、确定「状态」,也就是原问题和⼦问题中会变化的变量。由于钞票数量⽆限,钞票的⾯额也是题⽬
给定的,只有⽬标⾦额会不断地向 base case 靠近,所以唯⼀的「状态」就是⽬标⾦额 amount 。
3、确定「选择」,也就是导致「状态」产⽣变化的⾏为。⽬标⾦额为什么变化呢,因为你在选择硬
币,你每选择⼀枚硬币,就相当于减少了⽬标⾦额。所以说所有硬币的⾯值,就是你的「选择」。
4、明确 dp 函数/数组的定义。⾃顶向下的解法会有⼀个递归的 dp 函数,⾃底向上使⽤ dp 数组来
辅助。 dp 函数体现在函数参数,⽽ dp 数组体现在数组索引: dp 数组的定义:当⽬标⾦额为 i
时,⾄少需要 dp[i] 枚硬币凑出。
⾃
b.1.顶向下法(带备忘录的递归)
b.2.⾃底向上法(dp数组)
代码如下
#define _CRT_SECURE_NO_WARNINGS
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
int min3(int a, int b, int c)
{
int min = a;
if (b < min) min = b;
if (c < min) min = c;
return min;
}
void coinChange(int amount, int coins[]) //⾃底向上求解dp数组
{
int dp[1001];
dp[0] = 0;
for (int i = 1; i <= amount; i++) //外循环,遍历所有的状态
{
int cost = INT_MAX; // 成本——i⾦额所需要的最少的硬币数量。
//for (int j = 0; j < 3; j++) //内循环,遍历所有的“选择 / 币值”
//{
// int coin = coins[j];
// if (coin > i) continue; // 此⾯值的硬币不适⽤,则跳过
// cost = min(cost,dp[i - coin] + 1); // 计算此选择(币值)下的成本,如果⽐已经找到的最⼩成本更⼩,则更新cost
//}
int cost1 = INT_MAX; // 选择币值为1的“成本”
int cost5 = INT_MAX; // 选择币值为5的“成本”
int cost11 = INT_MAX; // 选择币值为11的“成本”
if (1 <= i)
cost1 = dp[i - 1] + 1;
if (5 <= i)
cost5 = dp[i - 5] + 1;
if (11 <= i)
cost11 = dp[i - 11] + 1;
cost = min3(cost1, cost5, cost11);
dp[i] = cost;
printf("dp[%d]=%d\n",i, dp[i]);
}
}
int main(int argc, char** argv)
{
int coins[] = { 1,5,11 };
int amount;
printf("请输⼊⾦额=");
scanf("%d", &amount);
coinChange(amount,coins);
return 0;
}
问题2:最短路径
问题很简单:不回退的前提下,找到A到F的最短路径。
问题3:最⼤连续⼦段和问题
dp数组从下标1开始
作业:要求要有过程,要画出表格。
1、计算题:零钱问题。我们有⾯值为1元3元5元的硬币若⼲枚,凑够11元最少需要多少枚硬币?求出其dp数组。
2、计算题:最短路径问题。求出下图的A到J的最短路径⻓度。并说明最短路径的顶点序列。
3、计算题:最⼤⼦序列和问题。对于数组 [3, -2, 1, -4, 5, -5 , 6, -3, 7, -5, 4 ]。求出其dp数组,并说明最⼤和是多少。
4、编程题:爬楼梯问题。楼梯有 15 阶台阶,我们⼀步可以踏上可以上 1 阶、2 阶或 3 阶。编写程序,输出有多少种上楼梯的⽅式。
四:贪心
问题1:零钱问题
#define _CRT_SECURE_NO_WARNINGS
#include<stdio.h>
#include<string.h>
#include<stdlib.h>
int main()
{
int w;
int amount[6];
int coins[6] = { 1, 5, 10, 20, 50, 100};
printf("输入金额=");
scanf("%d",&w);
for (int i=5;i>=0;i--){
amount[i] = w/coins[i];
w = w % coins[i];
printf("面值为%d的硬币%d枚\n",coins[i],amount[i]);
}
return 0;
}