4.常见算法策略
4.1 递归
编写递归程序三部曲:
-
定义函数功能;
-
找出递归的终止条件;
-
递推函数的等价关系式。(汉诺塔, 遍历)
举例:求连续n个数的和
第一步:定义函数功能
//求连续n个数的和
int sum(int n)
{
}
第二步:找出递归的终止条件
//求连续n个数的和
int sum(int n)
{
if (n == 0)
{
return 0;
}
}
第三步:找出递推函数的等价关系式
//求连续n个数的和
int sum(int n)
{
if (n == 0)
{
return 0;
}
return n + sum(n - 1);
}
4.1.1 求第n个斐波那契数
斐波那契数列(Fibonacci Sequence),又称黄金分割数列,因为数学家莱昂纳多斐波那契以兔子繁殖问题为列引入,故称为“兔子数列”,指的是这么一个数列:1 1 2 3 5 8 13 21 34 55 ......在数学上,斐波那契数列以如下被以递推的方法定义:F(0)=0,F(1)=1, F(n)=F(n - 1)+F(n - 2)(n ≥ 2,n ∈ N*)
斐波那契数列指的是这样一个数列:
这个数列从第3项开始,每一项都等于前两项之和。
#include <iostream>
using namespace std;
//求第3个斐波那契数列
int fibonacci(int n)
{
if (n == 0)
{
return 0;
}
if (n == 1)
{
return 1;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}
4.1.2 小青蛙跳台阶
给定一个n,代表一共有n级台阶,一只青蛙一开始在第0级,每次可以向上跳一级台阶,也可以向上跳两级台阶,求该青蛙跳到第n级台阶一共有多少多少种跳发?
思路分析:如果台阶只有一级台阶的话,那么青蛙只有一种跳法;如果有两级台阶的话,有两种跳法,一种是一次跳一级,另一种是一次跳两级;如果有三级台阶的话,青蛙第一次可以跳一级,剩下的两级可以按照上面提到的跳两级台阶的跳法,若青蛙在跳三级的时候,第一次二级的话,剩下的一级只能按一级台阶的跳法来跳。以此类推,n级台阶与三级台阶的跳法思路一样。
//小青蛙跳台阶问题,求跳到第n个台阶有多少种跳法
int frog_jump(int n)
{
if (n == 1)
{
return 1;
}
if (n == 2)
{
return 2;
}
return frog_jump(n - 1) + frog_jump(n - 2);
}
4.2 贪心策略
贪心策略的思想就是通过局部最优来达到全局最优。
要想选出全局最优解是非常苦难的事情,要证明每一步最优达到全局最优,需要严格的数学证明,否则不能说明为全局最优。
很多问题表面上看起来用贪心策略可以找到最优解,实际上却把最优解给漏掉了。
例子1:钱币支付问题
假设1元、2元、5元、10元、20元、50元、100元的纸币分别有c0、c1、c2、c3、c4、c5、c6张。现在要用这些纸币来支付k元,最少用多少张纸币?
很明确,用贪心算法,每一步尽可能用面值最大的纸币支付。
#include <iostream>
using namespace std;
const int N = 7;
int Value[N] = { 1,2,5,10,20,50,100 };
int Count[N] = { 3,0,2,1,0,3,5 };
//int solve(int money)
//{
// if (money == 0)
// {
// return 0;
// }
// for (int i = N - 1; i >= 0; i--)
// {
// if (money >= Value[i] && Count[i] > 0)
// {
// Count[i]--;
// int ans = solve(money - Value[i]);
// if (ans != -1)
// {
// return ans + 1;
// }
// Count[i]++;
// }
// }
// return -1;
//
//}
int solve(int money)
{
int num = 0;
for (int i = N - 1; i >= 0; i--)
{
int c = min(money / Value[i], Count[i]);
money -= c * Value[i];
num += c;
}
if (money > 0)
{
return -1;
}
return num;
}
int main()
{
int money;
cout << "请输入花费总额: ";
cin >> money;
int ans = solve(money);
if (ans != -1)
{
cout << "最少需要支付" << ans << "张纸币" << endl;
}
else
{
cout << "没有找到支付方法" << endl;
}
return 0;
}
例子2:西红柿首付的烦恼
王多鱼获得一笔奖金,要求购买最少得商品把钱花光,则没有零钱剩下,否则奖金被没收。
输入:
一个整数k:商品的种类(每个种类商品个数不限);
第i类商品的价值a[i];
一个整数m:奖金总额
输出:
最少得商品数量
举例:
输入: 7
商品价值: 1 2 5 10 20 50 100
奖金总额: 288
输出 : 8
#include <iostream>
using namespace std;
int main()
{
int k, m, n, cnt = 0, a[100];
cout << "商品的种类数量: " << endl;
cin >> k;
cout << "从大到小输出商品的价值 " << endl;
for (int i = 1; i <= k; i++)
{
cin >> a[i];
}
cout << "输入奖金的数量" << endl;
cin >> m;
for (int i = k; i >= 1; i--)
{
if (m >= a[i])
{
n = m / a[i];
m = m - n * a[i];
cnt += n;
cout<<a[i]<<"元的商品"<<n<<"个"<<"剩余奖金"<<m<<endl;
if (m == 0)
{
break;
}
}
}
cout <<"最少得商品数量为:"<< cnt << endl;
return 0;
}
4.3 分治策略
分治(Devide and Conquer),分而治之,就是把一个复杂的问题分成两个或者多个相同或相似的子问题,直到最后子问题可以简单的直接求解,原问题的解即子问题的合并(维基百科)。
分治策略的步骤
分解:将原问题分解为若干个规模较小、相互独立且与原问题形式相同的子问题。
解决:递归地求解各个子问题。如果子问题的规模足够小,则直接求解。
合并:将子问题的解合并为原问题的解。
4.3.1 归并排序、快速排序
1.归并排序:
分解:将待排序的数组分成两个子数组,每个子数组的规模为原数组的一半。
解决:对两个子数组分别进行归并排序,这是通过递归调用归并排序算法实现的。
合并:将两个已排序的子数组合并成一个有序的数组。
2.快速排序:
分解:选择一个基准元素,将数组分成两个子数组,一个子数组中的元素都小于等于基准元素,另一个子数组中的元素都大于等于基准元素。
解决:对两个子数组分别进行快速排序。
合并:由于子数组都是在原数组的基础上划分的,所以不需要显式地合并操作。
4.3.2 二分查找
二分查找针对的是一个有序的数据集合,每次查找时通过将待查找的元素与中间位置的元素进行比较,来确定待查找元素在集合中的位置范围,然后在缩小后的范围内继续进行查找,直到找到目标元素或者确定目标元素不存在。
具体步骤
1. 首先确定查找区间的上下界,初始时,下界为数组的第一个元素的下标,上界为数组的最后一个元素的下标。
2. 计算中间位置的下标,公式为 mid = (low + high) / 2(这里的除法为整数除法)。
3. 将待查找元素与中间位置的元素进行比较:
如果相等,则查找成功,返回中间位置的下标。
如果待查找元素小于中间位置的元素,则说明目标元素在中间位置的左侧,将上界更新为 mid - 1,继续在左侧区间进行查找。
如果待查找元素大于中间位置的元素,则说明目标元素在中间位置的右侧,将下界更新为 mid + 1,继续在右侧区间进行查找。
4. 重复步骤 2 和 3,直到找到目标元素或者下界大于上界(说明目标元素不存在)。
4.3.3 两个大数相乘
算法步骤:
接收两个以字符串形式表示的大整数 A和B 。
将大整数A分为两个部分A1和A2:使的其中 n 为大整数的长度;
同样,将B分为 B1和B2:即
计算 C1 = A1 * B1; C2 = A2 * B2; C3 = (A1+A2)*(B1+B2)
#include <iostream>
using namespace std;
//返回数字长度
int getNumLength(int num)
{
int length = 0;
while (num > 0)
{
num /= 10;
length++;
}
return length;
}
//用分治法求两个大数相乘
long long multiply(int num1, int num2)
{
int a_len = getNumLength(num1);
int b_len = getNumLength(num2);
int n = a_len > b_len ? a_len / 2 : b_len / 2;;
//A和B只要有一个数字是个位数,直接计算返回
if (num1 <= 9 || num2 <= 9)
{
return num1 * num2;
}
int a1 = num1 / pow(10, n);
int a2 = num1 % (int)pow(10, n);
int b1 = num2 / pow(10, n);
int b2 = num2 % (int)pow(10, n);
int n0 = multiply(a1, b1);
int n1 = multiply(a2, b2);
int n2 = multiply(a1 + a2, b1 + b2) - n0 - n1;
return n0 * pow(10, 2 * n) + n2 * pow(10, n) + n1;
}
void test_multiply()
{
int big1 = 123456;
int big2 = 654321;
long long result = multiply(big1, big2);
cout << "result:" << result << endl;
}
4.3.4 字符串的全排列
输入一个字符串abc,打印出其全排列的结果:abc,acb;bac,bca;cab,cba.
以字符串 “abc” 为例:
- 选择字符 “a” 作为固定字符,子字符串为 “bc”。
- 求子字符串 “bc” 的全排列,得到 “bc” 和 “cb”。
- 将 “a” 依次插入到 “bc” 和 “cb” 的不同位置:
- 插入到 “bc” 中,得到 “abc”、“bac”、“bca”。
- 插入到 “cb” 中,得到 “acb”、“cab”、“cba”
//用分治策略,求字符串的全排列
void fullpai(string str, int start, int end)
{
int len = str.length();
if (start == end)
{
cout << str << endl;
return;
}
for (int i = start; i <= end; i++)
{
if (i != start)
{
swap(str[i], str[start]);//交换i指向的元素和最左边的元素
}
fullpai(str, start + 1, end);
}
}
void test_fullpai()
{
string str1 = "abc";
fullpai(str1, 0, str1.length() - 1);
}
4.3.5 找出假的硬币
一堆硬币中有且仅有一枚假币,假币和外观和真币一模一样,但是重量比真币轻。用分治的思想,找出这枚假币。
分治思路:
- 首先将硬币分成相等的两部分(如果硬币总数是奇数,就把多出来的那一枚先放在一边)。
- 分别称这两部分硬币的重量。
- 如果两部分重量相等,那么假币就在之前单独放在一边的那一枚(如果有的话)或者在还没有称重的硬币中,然后对这部分硬币继续使用分治方法进行查找。
- 如果两部分重量不相等,那么假币就在较轻的那一部分中,对较轻的这部分硬币继续使用分治方法进行查找。
//用分治思想找出假币
int findFakeCoin(int a[], int start, int end)
{
int sum_left = 0, sum_right = 0;
int mid = (start + end) / 2;
if((end - start +1) % 2 == 0) //硬币为偶数
{
for (int i = start; i <= mid; i++)
{
sum_left += a[i];//累加左边的硬币
sum_right += a[mid + 1 + i - start];//累加右边的硬币
}
if (sum_left < sum_right)//假币在左半部分
{
findFakeCoin(a, start, mid);
}
else
{
findFakeCoin(a, mid + 1, end);
}
}
else //硬币为奇数
{
for (int i = start; i < mid; i++)
{
sum_left += a[i];
sum_right += a[mid + 1 + i - start];
}
if (sum_left == sum_right)
{
return mid;
}
else if (sum_left < sum_right)
{
findFakeCoin(a, start, mid - 1);
}
else
{
findFakeCoin(a, mid + 1, end);
}
}
}
void test_findFakeCoin()
{
int coins[21] = { 0 };
for (int i = 0; i < 21; i++)
{
coins[i] = 7;
}
coins[8] = 6;
int length = sizeof(coins) / sizeof(int);
cout <<"假币的索引为: "<<findFakeCoin(coins, 0, length - 1) << endl;
}
4.3.6 分治和递归的区别和联系
递归和分治是两个不同维度的概念。
分治是一种算法策略,其思想就是将原问题拆分成多个无重复的子问题,当子问题解决后合并子问题的解就能得到原问题的解,分治思想可以使用递归来实现,也可以不用递归来实现。而大多数分治问题都是用递归来实现的,因为递归代码清晰、直观、容易理解。
递归从狭义角度来说,是程序的一种实现方式,即调用自身,可以用递归解决分治问题,也可以用来解决其他问题。
4.4 动态规划
动态规划(Dynamic programming, 简称DP),是一种在数学、经济学和生物信息学中使用的,通过把原问题分解为相对简单的子问题的方式求解复杂问题的方法。
动态规划其实就是给定一个问题,我们把它拆分成一个个子问题,直到子问题可以直接解决,然后呢,把子问题答案保存起来,以减少重复计算,再根据子问题的答案反推,得出原问题解的一种方法。
动态规划的最核心的思想,就是在于拆分问题,记住过往,减少重复计算。
A:1+1+1+1+1+1+1+1=?
B:8
A:1+1+1+1+1+1+1+1+1=?
B:9
A:你为什么这么快就得出了答案 ?
B:只要在上次计算的基础上再加就行了,不用重复计算。
4.4.1 求第n个斐波那契数的优化
#include <iostream>
using namespace std;
//求第3个斐波那契数列
int fibonacci(int n)
{
if (n == 0)
{
return 0;
}
if (n == 1)
{
return 1;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}
//求第n个斐波那契数列,使用动态规划
int fibonacci_dp(int n)
{
//定义状态数组f,其中f[i]表示第I个斐波那契数
int f[1024] = { 0, 1 };//初始化边界值
for (int i = 2; i <= n; i++)
{
f[i] = f[i - 1] + f[i - 2];//状态转移方程
}
return f[n];//返回最终结果,时间复杂度为O(n)
}
4.4.2 小青蛙跳台阶问题
求解动态规划题目的一般步骤为:
1.设置一个状态数组,并且初始化为边界值;
2.寻找状态转移方程(解决动态规划问题的核心和难点)
3.根据实际需要返回最终结果
//小青蛙跳台阶问题,求跳到第n个台阶有多少种跳法
int frog_jump(int n)
{
if (n == 1)
{
return 1;
}
if (n == 2)
{
return 2;
}
return frog_jump(n - 1) + frog_jump(n - 2);
}
//小青蛙跳台阶问题,求调到第n个台阶有多少种跳法,使用动态规划
int frog_jump_dp(int n)
{
//定义状态数组f,其中f[i]表示小青蛙跳到第i个台阶的跳法总数
int f[1024] = { 0, 1, 2 };//初始化边界值
for (int i = 3; i <= n; i++)
{
f[i] = f[i - 1] + f[i - 2];//状态转移方程
}
return f[n];//返回最终结果,时间复杂度为O(n)
}
4.4.3 用最小花费爬楼梯
给一个整数数组cost,其中cost[i]表示从楼梯的第i个台阶向上爬需要支付的费用。一旦支付了此费用,既可以选择向上爬一个或者两个台阶。可以选择从下标为0或者下标为1的台阶开始爬楼梯。请计算爬到楼顶需要的最小花费额。
示例1:
输入:cost = {10, 15, 20};
输出:15
解释:从下表位1的台阶开始爬,支付15,向上爬两个台阶,即可到达楼梯顶部
示例2:
输入:cost = {1, 100, 1, 1, 1, 100, 1, 1, 100, 1}
输出:6
解释:从下标为0的台阶开始爬
支付1,向上爬两个台阶,到达下标为2的台阶
支付1,向上爬两个台阶,到达下标为4的台阶
支付1,向上爬两个台阶,到达下标为6的台阶
支付1,向上爬一个台阶,到达下标为7的台阶
支付1,向上爬;两个台阶,到达下标为9的台阶
支付1,向上爬一个台阶,即可到达楼梯顶部。
最小花费为6
解题思路:
假设n个阶梯分别对应数组下标0到n-1,楼梯顶部对应下标n,求到达n的最小花费。
设置状态数组dp,其中dp[i]表示到达下标i的最小花费,因为可以选择0或者1作为初始台阶,因此dp[0]=do[1]=0,。
当2<=i<=n时,可以从下标为i-1的台阶开始爬,花费cost[i-1]向上爬一级台阶到达下标i,或者从下标为i-2的台阶开始爬,花费cost[i-2]向上爬两级台阶到达下标。为了使总花费最小,dp[i]应该取上述两项的最小值,因此状态转移方程为:
dp[i] = min(dp[i-1]+cost[i-1], dp[i-2]+cost[i - 2])
具体实现代码:
//最小花费楼梯
int minCost(int cost[], int n)
{
//设置一个状态数组dp,其中dp[i]爬到第i个台阶的最小花费
int dp[1024] = { 0, 0, };//初始化边界值
for (int i = 2; i <= n; i++)
{
dp[i] = min(dp[i - 1] + cost[i - 1], dp[i - 2] + cost[i - 2]);//状态转移方程
}
return dp[n];//返回最终结果,时间复杂度为O(n)
}
void test_minCost()
{
int cost1[] = { 10, 15, 20 };
cout <<"爬到楼梯顶部的最小花费为:"<< minCost(cost1, 3) << endl;
int cost2[] = { 1, 100, 1, 1, 1, 100, 1, 1, 100, 1 };
cout <<"爬到楼梯顶部的最小花费为:"<< minCost(cost2, 10) << endl;
}
4.4.4 求最大子数组和
给定整数数组nums,求其最大数组 (子数组最少包含一个元素)的和。
子数组是数组中一个连续部分。
示例:
输入:nums = [-2, 1, -3, 4, -1, 2, 1, -5, 4]
输出:6
解释:子数组[4,-1,2,1]的和是最大的,为6。
使用动态规划来求解,算法思路:
设置状态数组dp,dp[i],以下标i指向的元素结尾的所有子数组的最大和。
以第i个整数组结尾的子数组分为两种情况:
和第i-1个整数结尾的子数组和相连;
和第i-1个整数结尾的子数组不相连,单独以第i个数组作为子数组;
状态转移方程:
dp[i] = max(dp[i - 1]+ nums[i],nums[i]) = max(dp[i - 1], 0 )+ nums[i]
具体实现:
//最大子数组的和,用暴力法
int max_subarry(int a[], int len)
{
int max = a[0];
for (int i = 0; i < len; i++)
{
for (int j = i; j < len; j++)
{
int sum = 0;
for (int k = i; k <= j; k++)
{
sum += a[k];
}
if (sum > max)
{
max = sum;
}
}
}
return max;
}
//最大子数组的和,用动态规划
int max_subarry_dp(int a[], int len)
{
//定义状态数组dp,其中dp[i]表示以第i个元素结尾的最大子数组的和
int dp[512];//定义状态数组
dp[0] = a[0];//初始化边界值
int maxValue = dp[0];
for (int i = 1; i < len; i++)
{
dp[i] = max(dp[i - 1],0)+ a[i];//状态转移方程
maxValue = max(maxValue, dp[i]);//更新最大值
}
return maxValue;//返回最终结果,时间复杂度为O(n)
}
void test_max_subarry()
{
int nums[] = { -2, 1, -3, 4, -1, 2, 1, -5, 4 };
int length = sizeof(nums) / sizeof(nums[0]);
cout << "最大子数组的和为:" << max_subarry(nums, length) << endl;
cout << "最大子数组的和为:" << max_subarry_dp(nums, length) << endl;
}
4.4.5 最长回文字符串
给定一个字符串s,找出s中最长的回文字符串
回文字符串:如果一个字符串的逆序和原始字符串相同,则称改字符串为回文字符串。
示例:
输入:s = "bcalevelast"
输出:"alevela"
解释:alevela就是原始字符串中最长的那个回文字符串
如果字符串长度为1,那必然是回文字符串;如果长度为2或者3,只需要最左边和最右边相等,那么该字符串就是回文字符串。
设dp[i] [j] 表示是s[i]到s[j]所表示的字符串是否是回文字符串,如果是回文字符串,则dp[i] [j]=1,如果不是,则dp[i] [j] = 0。根据s[i]是否等于s[j],分为两种情况:
1.若s[i]==s[j],那么只要s[i+1]到s[j-1]是回文字符串,那么s[i]到s[j]就是回文字符串;如果s[i+1]到s[j-1]不是回文字符串,那么s[i]到s[j]就不是回文字符串;
2.若s[i] != s[j],那么s[i]到s[j]一定不是回文字符串。
状态转移方程为:
dp[i][j] = dp[i + 1][j - 1],s[i] == s[j]
0, s[i] != s[j]
具体实现:
//动态规划,求最长回文字符串
string maxHuiwen(string s)
{
int len = s.size();
int start = 0;//最长回文字符串的起始位置
int max_length_huiwen = 1;//最长回文字符串的长度
int dp[50][50] = { 0 };//定义状态数组,其中dp[i][j]表示字符串s[i...j]是否为回文串
for (int j = 1; j < len; j++)
{
for (int i = 0; i < j; i++)
{
if (s[i] == s[j])
{
if (j - i < 3)
{
dp[i][j] = 1;
}
else
{
dp[i][j] = dp[i + 1][j - 1];
}
}
if (dp[i][j] == 1 && (j - i + 1 > max_length_huiwen))
{
max_length_huiwen = j - i + 1;
start = i;
}
}
}
return s.substr(start, max_length_huiwen);
}
void test_maxHuiwen()
{
string str1 = "bcalevelast";
cout << "最长回文字符串为:" << maxHuiwen(str1) << endl;
}
4.4.6 背包问题
背包问题可以分为三类:01背包(每个元素最多取一次)、完全背包(每个元素可以取多次)以及分组背包(可以有多个背包)。
01背包问题:有N件物品,每件物品都有各自的体积和价值,现有一个容量为V的背包,每件物品最多只能装入一次,如何让背包里装入的物品价值最大?
第i件物品的体积为vi,价值为wi。
vi | wi | 物品\背包 | 0 | 1 | 2 | 3 | 4 | 5 |
---|---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
1 | 2 | 1 | 0 | 2 | 2 | 2 | 2 | 2 |
2 | 4 | 2 | 0 | 2 | 4 | 6 | 6 | 6 |
3 | 4 | 3 | 0 | 2 | 4 | 6 | 6 | 8 |
4 | 5 | 4 | 0 | 2 | 4 | 6 | 6 | 8 |
解题思路:
定义状态数组dp,其中dp[i] [j]表示;只考虑前i个物品,背包容量为j的情况下,装入物品的最大价值。
状态转移方式:
dp[i] [j] = max(dp[i -1] [j], dp[i-1] [j-vi] + wi)
其中dp[i-1] [j] 表示,不装第i个物品的情况下,只考虑装前i-1个物品的最大价值。
dp[i-1] [j - vi] + wi 表示装入第i个物品,那么背包总容量就要减去第i个物品的体积,剩下的容量再去考虑装前i-1个物品的最大价值,再加上当前装入的第i件物品的价值wi。
具体实现:
//01背包问题
int maxValueofBag()
{
int V = 5;//背包容量
int N = 4;//物品个数
int v[5] = { 0, 1, 2, 3,4 };//物品体积
int w[5] = { 0, 2, 4, 4 , 5 };//物品价值
//定义状态数组dp,dp[i][j]表示背包容量为j时,装入前i个物品的最大价值
int dp[10][10] = { 0 };
//初始化状态数组
//商品个数为0的情况下;不同背包容量现爱装入的物品最大价值
for (int j = 0; j <= V; j++)
{
dp[0][j] = 0;
}
//背包容量为0的情况下,分别考虑装入不同个数物品时的最大价值
for (int i = 0; i <= N; i++)
{
dp[i][0] = 0;
}
//装物品的思路:不能装时,直接继承上一个状态;可以装时,比较装与不装的价值,取较大值
for (int i = 1; i <= N; i++)//外层循环,遍历物品价值
{
for (int j = 1; j <= V; j++)//内层循环遍历背包容量
{
if (j < v[i])//当前背包容量小于当前物品的体积,装不进去,只能不装
{
dp[i][j] = dp[i - 1][j];//状态转移方程
}
else
{
//能装进去,考虑要不要装,如果不装,用上一个状态dp[i-1][j]
// 如果装,装上第i个物品最大价值为dp[i-1][j-v[i]]+w[i]
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - v[i]] + w[i]);
}
}
}
//有四件物品,背包容量为5,所以dp[4][5]就是最大价值
return dp[4][5];//返回最终结果
}