网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。
一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!
int mid = (high - low) / 2 + low;
int num = nums[mid];
if (num == target) {
return mid;
} else if (num > target) {
high = mid - 1;
} else {
low = mid + 1;
}
}
return -1;
}
};
通过上面我们也可以知道high = nums.size()或者high = nums.size()-1都是可以的。
##### 容易出错的地方2:high的取值判断
将high = mid - 1变成high = mid
class Solution {
public:
int search(vector& nums, int target) {
int low = 0, high = nums.size()-1;
while(low <= high){
int mid = (high - low) / 2 + low;
int num = nums[mid];
if (num == target) {
return mid;
} else if (num > target) {
high = mid;
} else {
low = mid + 1;
}
}
return -1;
}
};

这种显示超出时间限制那就是应该就是一直在进行循环,导致超时。
low=0,high=5
第一次循环:mid=2 ,nums[mid] = 3 > 2,high = mid = 2
第二次循环:mid=(0+2)/2=1 ,nums[mid] = 0 < 2,low = mid + 1 = 2
后面循环:因为low=high=2,而且while(low <= high),因此该循环就变成了死循环就会导致超时。
所以我们可以针对可能会进入死循环设置一个退出条件。
class Solution {
public:
int search(vector& nums, int target) {
int low = 0, high = nums.size()-1;
int count = 0;
while(low <= high){
count++;
int mid = (high - low) / 2 + low;
int num = nums[mid];
if (num == target) {
return mid;
} else if (num > target) {
high = mid;
} else {
low = mid + 1;
}
if (count >= nums.size())
{
return -1;
}
}
return -1;
}
};
当然上面这种解决方法不优雅,也不利于我们加深对二分法的使用,因此我们不建议使用该种方法。
我们还是继续分析上面的情况,进入死循环的原因是while(low <= high)中是小于等于,那我们改成小于不就解决了吗?
改成下面的代码之后
class Solution {
public:
int search(vector& nums, int target) {
int low = 0, high = nums.size()-1;
while(low < high){
int mid = (high - low) / 2 + low;
int num = nums[mid];
if (num == target) {
return mid;
} else if (num > target) {
high = mid;
} else {
low = mid + 1;
}
}
return -1;
}
};

当数组只有一个元素,则 low = 0, high = 1 -1 = 0,又因为while(low < high)。
所以根本就不会进入循环,也就根本没有执行判断。
因此我们将high = nums.size()-1改成high = nums.size()。
class Solution {
public:
int search(vector& nums, int target) {
int low = 0, high = nums.size();
while(low < high){
int mid = (high - low) / 2 + low;
int num = nums[mid];
if (num == target) {
return mid;
} else if (num > target) {
high = mid;
} else {
low = mid + 1;
}
}
return -1;
}
};
代码可以成功执行。
##### 容易出错的地方3:low的取值判断
将low = mid-1改成low = mid
class Solution {
public:
int search(vector& nums, int target) {
int low = 0, high = nums.size()-1;
while(low <= high){
int mid = (high - low) / 2 + low;
int num = nums[mid];
if (num == target) {
return mid;
} else if (num > target) {
high = mid-1;
} else {
low = mid;
}
}
return -1;
}
};

low=0,high=6
第一次循环:mid=3 ,nums[mid] = 5 > 2,high = mid - 1 = 2
第二次循环:mid=(0+2)/2=1 ,nums[mid] = 0 < 2,low = mid = 1
第三次循环:mid=(1+2)/2=1 ,nums[mid] = 0 < 2,low = mid = 1
第四次循环:mid=(1+2)/2=1 ,nums[mid] = 0 < 2,low = mid = 1
、、、
可以看出就这样会一直无限循环,这边的无限循环不是因为 low和high相等造成的,而是因为low = mid,而mid=(low+high)/2=low,而high比low和mid大1造成的。
---
从上面的可以看出,写二分法要非常注意边界条件,一个等号,一个+1都可能让程序产生不同的效果,因为我们要牢牢把握住标准解法,然后在标准解法的基础上进行变通。
### 双指针法(尺取法)
双指针技巧可以分为两类,一类是「快慢指针」,一类是「左右指针」。前者解决主要解决链表中的问题,比如典型的判定链表中是否包含环;后者主要解决数组(或者字符串)中的问题,比如二分查找。
尺取法其实是一个降低复杂度的优化算法,废话不多说,先上一道题。
**题目:给定一个数组和一个数s,在这个数组中找一个区间,使得这个区间之和等于s。**
**例如:给定的数组int x[14] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14};和一个s = 15。那么,可以找到的区间就应该有0到4, 3到5, 6到7.(注意这里的下标从0开始)**
对于这样的题,不用任何技巧就可以跑出结果,例如下面这个方法可能是大多数人能够想出来的:
先用一个数组sum[i]存放前i个元素的和,其实现用的是”递推思想“,注意,在编程中”递推“的思想用的特别多,一定要习惯这种思维方式。
sum[0] = x[0];//x为给定的原数组
for(int i = 1; i < n; i++){
sum[i] += sum[i-1];//递推思想
}
然后通过两层循环求解
for(int i = 0; i < n; i++)
for(int j = n-1; j >= 0; j–){
if(sum[j]-sum[i]==s) printf(“%d—%d\n”, i, j);
}
上面的方法当然是可行的,但是复杂度太高,有一个算法可以将其复杂度降为O(n)。这就是”尺取算法“。
尺取法:顾名思义,像尺子一样取一段,借用挑战书上面的话说,尺取法通常是对数组保存一对下标,即所选取的区间的左右端点,然后根据实际情况不断地推进区间左右端点以得出答案。之所以需要掌握这个技巧,是因为尺取法比直接暴力枚举区间效率高很多,尤其是数据量大的。
那么,用”尺取法“做上面这道题思路应该是这样的:
其实,这种方法很类似于蚯蚓的蠕动。
1)用一对脚标i, j。最开始都指向第一个元素。
2)如果区间i到j之和比s小,就让j往后挪一位,并把sum的值加上这个新元素。相当于蚯蚓的头向前伸了一下。
3)如果区间i到j之和比s大,就让sum减掉第一个元素。相当于蚯蚓的尾巴向前缩了一下。
4)如果i到j之和刚好等于s,则输入。
用一张图来表示就是这样的,每一行的黄色部分代表本次循环选中的区间

接下来附上完整源代码:
#include
#include
using namespace std;
void findSUM(int *A, int n, int s){
int i = 0, j = 0;
int sum = A[0];
while(i <= j && j < n){
if(sum >= s){
if(sum == s) printf(“%d—%d\n”, i, j);
sum -= A[i];
i++;
}
else{
j++;
sum += A[j];
}
}
}
int main(){
std::ios::sync_with_stdio(false);
std::cin.tie(0);
int m;
int x[14] = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14};
cin >> m;
findSUM(x, 14, m);
return 0;
}
大家可以看到,”尺取法“一般只有O(n)的复杂度,针对大规模数据还是很有效的。另外,”尺取法“有时候也叫“双指针法”,当然,名字并没有那么重要,领会思想就行。
#### **一、快慢指针的常见算法**
快慢指针一般都初始化指向链表的头结点 head,前进时快指针 fast 在前,慢指针 slow 在后,巧妙解决一些链表中的问题。
1、判定链表中是否含有环
单链表的特点是每个节点只知道下一个节点,所以一个指针的话无法判断链表中是否含有环的。
如果链表中不含环,那么这个指针最终会遇到空指针 null 表示链表到头了,这还好说,可以判断该链表不含环。
public boolean hasCycle(ListNode head) {
while (head != null){
head = head.next;
}
return false;
}
但是如果链表中含有环,那么这个指针就会陷入死循环,因为环形数组中没有 null 指针作为尾部节点。
经典解法就是用两个指针,一个每次前进两步,一个每次前进一步。如果不含有环,跑得快的那个指针最终会遇到 null,说明链表不含环;如果含有环,快指针最终会超慢指针一圈,和慢指针相遇,说明链表含有环。
public boolean hasCycle(ListNode head){
ListNode fast, slow;
fast = slow = head;
while(fast != null && fast.next != null){
fast = fast.next.next;
slow = slow.next;
if(fast == slow){
return true;
}
}
return false;
}
**2、已知链表中含有环,返回这个环的起始位置**
public static ListNode detectCycle(ListNode head) {
ListNode fast = head;
ListNode slow = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) {
ListNode index1 = fast;
ListNode index2 = head;
while (index1 != index2) {
index1 = index1.next;
index2 = index2.next;
}
return index2;
}
}
return null;
}
**3、寻找链表的中点**
类似上面的思路,我们还可以让快指针一次前进两步,慢指针一次前进一步,当快指针到达链表尽头时,慢指针就处于链表的中间位置。
public static ListNode findMid(ListNode head) {
ListNode slow, fast;
slow = fast = head;
while (fast != null && fast.next != null) {
fast = fast.next.next;
slow = slow.next;
}
return slow;// slow 就在中间位置
}
当链表的长度是奇数时,slow 恰巧停在中点位置;如果长度是偶数,slow 最终的位置是中间偏右。
寻找链表中点的一个重要作用是对链表进行归并排序。
回想数组的归并排序:求中点索引递归地把数组二分,最后合并两个有序数组。对于链表,合并两个有序链表是很简单的,难点就在于二分。
但是现在你学会了找到链表的中点,就能实现链表的二分了。
**4、寻找链表的倒数第 k 个元素**
我们的思路还是使用快慢指针,让快指针先走 k 步,然后快慢指针开始同速前进。这样当快指针走到链表末尾 null 时,慢指针所在的位置就是倒数第 k 个链表节点(为了简化,假设 k 不会超过链表长度):
public static ListNode findK(ListNode head, int k) {
ListNode slow, fast;
slow = fast = head;
while (k-- > 0){
fast = fast.next;
}
while (fast != null) {
slow = slow.next;
fast = fast.next;
}
return slow;
}
应用比如:力扣第 19 题「删除链表的倒数第n个元素」
class Solution {
public ListNode removeNthFromEnd(ListNode head, int n) {
ListNode slow = head;
ListNode fast = head;
for(int i = 0; i < n; i++){
fast = fast.next;
}
if(fast == null){// 如果此时快指针走到头了,说明倒数第 n 个节点就是第一个结点
return head.next;
}
while(fast != null && fast.next != null){// 让慢指针和快指针同步向前
slow = slow.next;
fast = fast.next;
}
slow.next = slow.next.next;// slow.next 就是倒数第 n 个节点,删除它
return head;
}
}
#### **二、左右指针的常用算法**
左右指针在数组中实际是指两个索引值,一般初始化为 left = 0, right = nums.length - 1 。
**1、二分查找**
前文 二分查找算法详解 有详细讲解,这里只写最简单的二分算法,旨在突出它的双指针特性:
int binarySearch(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
while(left <= right) {
int mid = left + (right - left) / 2;
if(nums[mid] == target)
return mid;
else if (nums[mid] < target)
left = mid + 1;
else if (nums[mid] > target)
right = mid - 1;
}
return -1;
}
**2、两数之和**
直接看一道 LeetCode 题目吧:

只要数组有序,就应该想到双指针技巧。这道题的解法有点类似二分查找,通过调节 left 和 right 可以调整 sum 的大小:
int twoSum(int[] nums, int target) {
int left = 0;
int right = nums.length - 1;
while(left < right) {
int sum = nums[left] + nums[right];
if(sum == target)
return new int[]{left + 1, right + 1};
else if (sum < target)
left++;
else if (sum > target)
right–;
}
return -1;
}
**3、反转数组**
public void reverse(int[] nums) {
int left = 0;
int right = nums.length - 1;
while (left < right) {
// swap(nums[left], nums[right])
int temp = nums[left];
nums[left] = nums[right];
nums[right] = temp;
left++; right–;
}
}
**4、滑动窗口算法**
/* 滑动窗口算法框架 */
void slidingWindow(string s, string t) {
unordered_map<char, int> need, window;
for (char c : t) need[c]++;
int left = 0, right = 0;
int valid = 0;
while (right < s.size()) {
// c 是将移入窗口的字符
char c = s[right];
// 右移窗口
right++;
// 进行窗口内数据的一系列更新
...
/\*\*\* debug 输出的位置 \*\*\*/
printf("window: [%d, %d)\n", left, right);
/\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*\*/
// 判断左侧窗口是否要收缩
while (window needs shrink) {
// d 是将移出窗口的字符
char d = s[left];
// 左移窗口
left++;
// 进行窗口内数据的一系列更新
...
}
}
}
其中两处…表示的更新窗口数据的地方,到时候直接往里面填就行了。
而且,这两个…处的操作分别是右移和左移窗口更新操作,等会你会发现它们操作是完全对称的。
**注:把索引左闭右开区间[left, right)称为一个窗口。**
### 滑动窗口
滑动窗口实际上也是双指针的应用,滑动窗口的右指针不断扩大窗口的范围,当窗口内的对象符合某个条件时,进行统计或者某种操作;然后左指针收缩窗口来打破条件,以便让右指针右移继续扩大窗口
#### 209 长度最小的子数组
使用左右指针来构建一个滑动窗,
当窗口内的数字的和<s时,右指针右移来扩大窗口;
当窗口内的数字之和>=s时,统计窗口的长度,并将左指针右移来缩小窗口
class Solution:
def minSubArrayLen(self, s: int, nums: List[int]) -> int:
if not nums:
return 0
i, j = 0, 0
windowSum = 0
min_len = float(‘inf’)
while j<len(nums):
windowSum += nums[j]
while i<=j and windowSum>=s:
min_len = min(min_len, j-i+1)
windowSum -= nums[i]
i+=1
j+=1
return 0 if min_len == float('inf') else min_len
### 贪心算法(**贪婪算法**)
先来看看维基百科的定义:
**贪心算法**(英语:greedy algorithm),又称**贪婪算法**,是一种在每一步选择中都采取在当前状态下最好或最优(即最有利)的选择,从而希望导致结果是最好或最优的[算法]( )。[[1]]( )比如在[旅行推销员问题]( )中,如果旅行员每次都选择最近的城市,那这就是一种贪心算法。
贪心算法在有最优子结构的问题中尤为有效。最优子结构的意思是局部最优解能决定全局最优解。简单地说,问题能够分解成子问题来解决,子问题的最优解能递推到最终问题的最优解。
贪心算法与[动态规划]( )的不同在于它对每个子问题的解决方案都做出选择,不能回退。动态规划则会保存以前的运算结果,并根据以前的结果对当前进行选择,有回退功能。
贪心法可以解决一些[最优化]( )问题,如:求[图]( )中的[最小生成树]( )、求[哈夫曼编码]( )……对于其他问题,贪心法一般不能得到我们所要求的答案。一旦一个问题可以通过贪心法来解决,那么贪心法一般是解决这个问题的最好办法。由于贪心法的高效性以及其所求得的答案比较接近最优结果,贪心法也可以用作辅助算法或者直接解决一些要求结果不特别精确的问题。在不同情况,选择最优的解,可能会导致辛普森悖论(Simpson’s Paradox),不一定出现最优的解。
贪心算法在数据科学领域被广泛应用,特别是金融工程。其中一个贪心算法例子就是Ensemble method。
贪心算法,又名贪婪法,是寻找**最优解问题**的常用方法,这种方法模式一般将求解过程分成**若干个步骤**,但每个步骤都应用贪心原则,选取当前状态下**最好/最优的选择**(局部最有利的选择),并以此希望最后堆叠出的结果也是最好/最优的解。{看着这个名字,贪心,贪婪这两字的内在含义最为关键。这就好像一个贪婪的人,他事事都想要眼前看到最好的那个,看不到长远的东西,也不为最终的结果和将来着想,贪图眼前局部的利益最大化,有点走一步看一步的感觉。}
**贪婪法的基本步骤:**
步骤1:从某个初始解出发;
步骤2:采用迭代的过程,当可以向目标前进一步时,就根据局部最优策略,得到一部分解,缩小问题规模;
步骤3:将所有解综合起来。
**事例一:找零钱问题**
假设你开了间小店,不能电子支付,钱柜里的货币只有 **25 分、10 分、5 分和 1 分**四种硬币,如果你是售货员且要找给客户 **41 分钱的硬币**,如何安排才能找给客人的钱既**正确**且硬币的个数又**最少**?
这里需要明确的几个点:
1.货币只有 25 分、10 分、5 分和 1 分四种硬币;
2.找给客户 41 分钱的硬币;
3.硬币最少化
**思考,能使用我们今天学到的贪婪算法吗?怎么做?**
(回顾一下上文贪婪法的基本步骤,1,2,3)
1.找给顾客sum\_money=41分钱,可选择的是25 分、10 分、5 分和 1 分四种硬币。能找25分的,不找10分的原则,初次先找给顾客25分;
2.还差顾客sum\_money=41-25=16。然后从25 分、10 分、5 分和 1 分四种硬币选取局部最优的给顾客,也就是选10分的,此时sum\_money=16-10=6。重复迭代过程,还需要sum\_money=6-5=1,sum\_money=1-1=0。至此,顾客收到零钱,交易结束;
3.此时41分,分成了1个25,1个10,1个5,1个1,共四枚硬币。
**编程实现**
#include
using namespace std;
#define ONEFEN 1
#define FIVEFEN 5
#define TENFEN 10
#define TWENTYFINEFEN 25
int main()
{
int sum_money=41;
int num_25=0,num_10=0,num_5=0,num_1=0;
//不断尝试每一种硬币
while(money>=TWENTYFINEFEN) { num_25++; sum_money -=TWENTYFINEFEN; }
while(money>=TENFEN) { num_10++; sum_money -=TENFEN; }
while(money>=FIVEFEN) { num_5++; sum_money -=FIVEFEN; }
while(money>=ONEFEN) { num_1++; sum_money -=ONEFEN; }
//输出结果
cout<< "25分硬币数:"<<num_25<<endl;
cout<< "10分硬币数:"<<num_10<<endl;
cout<< "5分硬币数:"<<num_5<<endl;
cout<< "1分硬币数:"<<num_1<<endl;
return 0;
}
**事例二:背包最大价值问题**
有一个背包,最多能承载重量为 **C=150的物品**,现在有**7个物品**(物品不能分割成任意大小),编号为 1~7,重量分别是 wi=[35,30,60,50,40,10,25],价值分别是 pi=[10,40,30,50,35,40,30],现在从这 7 个物品中选择一个或多个装入背包,要求在物品总重量不超过 C 的前提下,所装入的物品总价值最高。
这里需要明确的几个点:
1.每个物品都有重量和价值两个属性;
2.每个物品分被选中和不被选中两个状态(后面还有个问题,待讨论);
3.可选物品列表已知,背包总的承重量一定。
所以,构建描述每个物品的数据体结构 OBJECT和背包问题定义为:
//typedef是类型定义的意思
//定义待选物体的结构体类型
typedef struct tagObject
{
int weight;
int price;
int status;
}OBJECT;
//定义背包问题
typedef struct tagKnapsackProblem
{
vectorobjs;
int totalC;
}KNAPSACK_PROBLEM;
这里采用定义结构体的形式,主要是可以减少代码的书写量,可以实现代码的复用性和可扩展性,简化,提高可读性。就是贪图简单方便,规避繁琐。
如下,实例化objects
OBJECT objects[] = { { 35,10,0 },{ 30,40,0 },{ 60,30,0 },{ 50,50,0 },
{ 40,35,0 },{ 10,40,0 },{ 25,30,0 } };
**思考:如何选,才使得装进背包的价值最大呢?**
策略1:价值主导选择,每次都选价值最高的物品放进背包;
策略2:重量主导选择,每次都选择重量最轻的物品放进背包;
策略3:价值密度主导选择,每次选择都选价值/重量最高的物品放进背包。
(贪心法则:求解过程**分成若干个步骤**,但每个步骤都应用贪心原则,选取当前状态下**最好的或最优的选择**(局部最有利的选择),并以此希望最后堆叠出的结果也是**最好或最优的解**)
**策略1:价值主导选择,每次都选价值最高的物品放进背包**
根据这个策略最终选择装入背包的物品编号依次是 4、2、6、5,此时包中物品总重量是 130,总价值是 165。
//遍历没有被选的objs,并且选择price最大的物品,返回被选物品的编号
int Choosefunc1(std::vector& objs, int c)
{
int index = -1; //-1表示背包容量已满
int max_price = 0;
//在objs[i].status == 0的物品里,遍历挑选objs[i].price最大的物品
for (int i = 0; i < static_cast(objs.size()); i++)
{
if ((objs[i].status == 0) && (objs[i].price > max_price ))//objs没有被选,并且price> max_price
{
max_price = objs[i].price;
index = i;
}
}
return index;
}
**策略2:重量主导选择,每次都选择重量最轻(小)的物品放进背包**
根据这个策略最终选择装入背包的物品编号依次是 6、7、2、1、5,此时包中物品总重量是 140,总价值是 155。
int Choosefunc2(std::vector& objs, int c)
{
int index = -1;
int min_weight= 10000;
for (int i = 0; i < static_cast(objs.size()); i++)
{
if ((objs[i].status == 0) && (objs[i].weight < min_weight))
{
min_weight= objs[i].weight;
index = i;
}
}
return index;
}
**策略3:价值密度主导选择,每次选择都选价值/重量最高(大)的物品放进背包**
物品的价值密度 si 定义为 pi/wi,这 7 件物品的价值密度分别为 si=[0.286,1.333,0.5,1.0,0.875,4.0,1.2]。根据这个策略最终选择装入背包的物品编号依次是 6、2、7、4、1,此时包中物品的总重量是 150,总价值是 170。
int Choosefunc3(std::vector& objs, int c)
{
int index = -1;
double max_s = 0.0;
for (int i = 0; i < static_cast(objs.size()); i++)
{
if (objs[i].status == 0)
{
double si = objs[i].price;
si = si / objs[i].weight;
if (si > max_s)
{
max_s = si;
index = i;
}
}
}
return index;
}
**有了物品,有了方法,下面就是将两者结合起来的贪心算法GreedyAlgo**
void GreedyAlgo(KNAPSACK_PROBLEM *problem, SELECT_POLICY spFunc)
{
int idx;
int sum_weight_current = 0;
//先选
while ((idx = spFunc(problem->objs, problem->totalC- sum_weight_current)) != -1)
{ //再检查,是否能装进去
if ((sum_weight_current + problem->objs[idx].weight) <= problem->totalC)
{
problem->objs[idx].status = 1;//如果背包没有装满,还可以再装,标记下装进去的物品状态为1
sum_weight_current += problem->objs[idx].weight;//把这个idx的物体的重量装进去,计算当前的重量
}
else
{
//不能选这个物品了,做个标记2后重新选剩下的
problem->objs[idx].status = 2;
}
}
PrintResult(problem->objs);//输出函数的定义,查看源代码
}
**注意:这里对objs[idx].status定义了三种状态,分别是待选择为0(初始所有状态均为0),装进包里变为1,判断不符合变为2,这样最后只需要拿去状态为1的即可。**
**主函数部分**
OBJECT objects[] = { { 35,10,0 },{ 30,40,0 },{ 60,30,0 },{ 50,50,0 },
{ 40,35,0 },{ 10,40,0 },{ 25,30,0 } };
int main()
{
KNAPSACK_PROBLEM problem;
problem.objs.assign(objects, objects + 7);//assign赋值,std::vector::assign
problem.totalC = 150;
cout << "Start to find the best way ,NOW" << endl;
GreedyAlgo(&problem, Choosefunc3);
system("pause");
return 0;
}
查看策略3的输出结果:

**但是,我们再回顾一下第一个事例问题**
现在问题变了,还是需要找给顾客41分钱,现在的货币只有 25 分、20分、10 分、5 分和 1 分四种硬币;该怎么办?
**按照贪心算法的三个步骤:**
1.41分,局部最优化原则,先找给顾客25分;
2.此时,41-25=16分,还需要找给顾客10分,然后5分,然后1分;
3.最终,找给顾客一个25分,一个10分,一个5分,一个1分,共四枚硬币。
**是不是觉得哪里不太对,如果给他2个20分,加一个1分,三枚硬币就可以了呢?\_;**
**总结:贪心算法的优缺点**
**优点:简单,高效,省去了为了找最优解可能需要穷举操作,通常作为其它算法的辅助算法来使用;**
**缺点:不从总体上考虑其它可能情况,每次选取局部最优解,不再进行回溯处理,所以很少情况下得到最优解。**
完整代码:[https://github.com/QianLingjun/]( )
### 动态规划
#### 0. intro
很有意思的问题。以往见过许多教材,对动态规划(DP)的引入属于“奉天承运,皇帝诏曰”式:不给出一点引入,见面即拿出一大堆公式吓人;学生则死啃书本,然后突然顿悟。针对入门者的教材不应该是这样的。恰好我给入门者讲过四次DP入门,迭代出了一套比较靠谱的教学方法,所以今天跑过来献丑。
现在,我们试着自己来一步步“重新发明”DP。
#### 1. 从一个生活问题谈起
先来看看生活中经常遇到的事吧——假设您是个土豪,身上带了足够的1、5、10、20、50、100元面值的钞票。现在您的目标是凑出某个金额w,**需要用到尽量少的钞票。**
依据生活经验,我们显然可以采取这样的策略:能用100的就尽量用100的,否则尽量用50的……依次类推。在这种策略下,666=6×100+1×50+1×10+1×5+1×1,共使用了10张钞票。
这种策略称为“**贪心**”:假设我们面对的局面是“需要凑出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元。
在这里我们发现,贪心是一种**只考虑眼前情况**的策略。
那么,现在我们怎样才能避免鼠目寸光呢?
如果直接暴力枚举凑出w的方案,明显复杂度过高。太多种方法可以凑出w了,枚举它们的时间是不可承受的。我们现在来尝试找一下性质。
重新分析刚刚的例子。w=15时,我们如果取11,接下来就面对w=4的情况;如果取5,则接下来面对w=10的情况。我们发现这些问题都有相同的形式:“给定w,凑出w所用的最少钞票是多少张?”接下来,我们用f(n)来表示“凑出n所需的最少钞票数量”。
那么,如果我们取了11,最后的代价(用掉的钞票总数)是多少呢?
明显[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-LN2ZpB1M-1631201859692)(https://www.zhihu.com/equation?tex=\text{cost}+%3D+f(4)]+%2B+1+%3D+4+%2B+1+%3D+5) ,它的意义是:利用11来凑出15,付出的代价等于f(4)加上自己这一张钞票。现在我们暂时不管f(4)怎么求出来。
依次类推,马上可以知道:如果我们用5来凑出15,cost就是[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-uhCl9Mrq-1631201859693)(https://www.zhihu.com/equation?tex=f(10)]+%2B+1+%3D+2+%2B+1+%3D+3) 。
那么,现在w=15的时候,我们该取那种钞票呢?**当然是各种方案中,cost值最低的那一个**!
* 取11:[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kqgYyTk6-1631201859694)(https://www.zhihu.com/equation?tex=\text{cost}%3Df(4)]%2B1%3D4%2B1%3D5)
* 取5: [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ndn1XT7G-1631201859694)(https://www.zhihu.com/equation?tex=\text{cost}%3Df(10)]%2B1%3D2%2B1%3D3)
* 取1: [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TUEFe6kZ-1631201859695)(https://www.zhihu.com/equation?tex=\text{cost}%3Df(14)]%2B1%3D4%2B1%3D5)
显而易见,cost值最低的是取5的方案。**我们通过上面三个式子,做出了正确的决策**!
这给了我们一个**至关重要**的启示—— [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-IzQHYF9i-1631201859695)(https://www.zhihu.com/equation?tex=f(n)]) 只与 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NgIT1sEj-1631201859696)(https://www.zhihu.com/equation?tex=f(n-1)]%2Cf(n-5)%2Cf(n-11)) 相关;更确切地说:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-CtM3e2yo-1631201859696)(https://www.zhihu.com/equation?tex=f(n)]%3D\min{f(n-1)%2Cf(n-5)%2Cf(n-11)}%2B1)
这个式子是非常激动人心的。我们要求出f(n),只需要求出几个更小的f值;既然如此,我们从小到大把所有的f(i)求出来不就好了?注意一下边界情况即可。代码如下:

我们以 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wVpbXU4l-1631201859698)(https://www.zhihu.com/equation?tex=O(n)]) 的复杂度解决了这个问题。现在回过头来,我们看看它的原理:
* [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HoZz4VQR-1631201859698)(https://www.zhihu.com/equation?tex=f(n)]) 只与[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RMAQupRc-1631201859698)(https://www.zhihu.com/equation?tex=f(n-1)]%2Cf(n-5)%2Cf(n-11))的**值**相关。
* 我们只关心 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-d5aYrCX9-1631201859699)(https://www.zhihu.com/equation?tex=f(w)]) 的**值**,不关心是怎么凑出w的。
这两个事实,保证了我们做法的正确性。它比起贪心策略,会分别算出取1、5、11的代价,从而做出一个正确决策,这样就避免掉了“鼠目寸光”!
它与暴力的区别在哪里?我们的暴力枚举了“使用的硬币”,然而这属于冗余信息。我们要的是答案,根本不关心这个答案是怎么凑出来的。譬如,要求出f(15),只需要知道f(14),f(10),f(4)的值。\*\*其他信息并不需要。\*\*我们舍弃了冗余信息。我们只记录了对解决问题有帮助的信息——f(n).
我们能这样干,取决于问题的性质:求出f(n),只需要知道几个更小的f©。**我们将求解f©称作求解f(n)的“子问题”。**
**这就是DP**(动态规划,dynamic programming).
**将一个问题拆成几个子问题,分别求解这些子问题,即可推断出大问题的解**。
>
> 思考题:请稍微修改代码,输出我们凑出w的**方案**。
>
>
>
#### 2. 几个简单的概念
【无后效性】
一旦f(n)确定,“我们如何凑出f(n)”就再也用不着了。
要求出f(15),只需要知道f(14),f(10),f(4)的值,而f(14),f(10),f(4)是如何算出来的,对之后的问题没有影响。
**“未来与过去无关”,**这就是**无后效性**。
(严格定义:如果给定某一阶段的状态,则在这一阶段以后过程的发展不受这阶段以前各段状态的影响。)
【最优子结构】
回顾我们对f(n)的定义:我们记“凑出n所需的**最少**钞票数量”为f(n).
f(n)的定义就已经蕴含了“最优”。利用w=14,10,4的**最优**解,我们即可算出w=15的**最优**解。
大问题的**最优解**可以由小问题的**最优解**推出,这个性质叫做“最优子结构性质”。
引入这两个概念之后,我们如何判断一个问题能否使用DP解决呢?
**能将大问题拆成几个小问题,且满足无后效性、最优子结构性质。**
#### **3. DP的典型应用:DAG最短路**
问题很简单:给定一个城市的地图,所有的道路都是单行道,而且不会构成环。每条道路都有过路费,问您从S点到T点花费的最少费用。

一张地图。边上的数字表示过路费。
这个问题能用DP解决吗?我们先试着记从S到P的最少费用为f§.
想要到T,要么经过C,要么经过D。从而[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-gN0JDwF9-1631201859700)(https://www.zhihu.com/equation?tex=f(T)]%3D\min{f©%2B20%2Cf(D)%2B10})
好像看起来可以DP。现在我们检验刚刚那两个性质:
* 无后效性:对于点P,一旦f§确定,以后就只关心f§的值,不关心怎么去的。
* 最优子结构:对于P,我们当然只关心到P的最小费用,即f§。如果我们从S走到T是 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-iAPnzQ4E-1631201859700)(https://www.zhihu.com/equation?tex=S+\to+P\to+Q\to+T)] ,那肯定S走到Q的最优路径是 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-yxxAsf5z-1631201859701)(https://www.zhihu.com/equation?tex=S\to+P\to+Q)] 。对一条最优的路径而言,从S走到\*\*沿途上所有的点(子问题)\*\*的最优路径,都是这条大路的一部分。这个问题的最优子结构性质是显然的。
既然这两个性质都满足,那么本题可以DP。式子明显为:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-HXzg5f7d-1631201859701)(https://www.zhihu.com/equation?tex=f§]%3D\min{f®%2Bw\_{R→P}})
其中R为有路通到P的所有的点, [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Yn2RmPv7-1631201859702)(https://www.zhihu.com/equation?tex=w\_{R→P})] 为R到P的过路费。
代码实现也很简单,拓扑排序即可。
#### 4. 对DP原理的一点讨论
【DP的核心思想】
DP为什么会快?
无论是DP还是暴力,我们的算法都是在**可能解空间**内,寻找**最优解**。
来看钞票问题。暴力做法是枚举所有的可能解,这是最大的可能解空间。
DP是枚举**有希望成为答案的解**。这个空间比暴力的小得多。
也就是说:DP自带剪枝。
DP舍弃了一大堆不可能成为最优解的答案。譬如:
15 = 5+5+5 被考虑了。
15 = 5+5+1+1+1+1+1 从来没有考虑过,因为这不可能成为最优解。
从而我们可以得到DP的核心思想:**尽量缩小可能解空间。**
在暴力算法中,可能解空间往往是指数级的大小;如果我们采用DP,那么有可能把解空间的大小降到多项式级。
一般来说,解空间越小,寻找解就越快。这样就完成了优化。
【DP的操作过程】
一言以蔽之:**大事化小,小事化了。**
将一个大问题转化成几个小问题;
求解小问题;
推出大问题的解。
【如何设计DP算法】
下面介绍比较通用的设计DP算法的步骤。
首先,把我们面对的**局面**表示为x。这一步称为**设计状态**。
对于状态x,记我们要求出的答案(e.g. 最小费用)为f(x).我们的目标是求出f(T).
**找出f(x)与哪些局面有关(记为p)**,写出一个式子(称为**状态转移方程**),通过f§来推出f(x).
【DP三连】
设计DP算法,往往可以遵循DP三连:
我是谁? ——设计状态,表示局面
我从哪里来?
我要到哪里去? ——设计转移
设计状态是DP的基础。接下来的设计转移,有两种方式:一种是考虑我从哪里来(本文之前提到的两个例子,都是在考虑“我从哪里来”);另一种是考虑我到哪里去,这常见于求出f(x)之后,**更新能从x走到的一些解**。这种DP也是不少的,我们以后会遇到。
总而言之,“我从哪里来”和“我要到哪里去”只需要考虑清楚其中一个,就能设计出状态转移方程,从而写代码求解问题。前者又称pull型的转移,后者又称push型的转移。(这两个词是
>
> 思考题:如何把钞票问题的代码改写成“我到哪里去”的形式?
> 提示:求出f(x)之后,更新f(x+1),f(x+5),f(x+11).
>
>
>
#### 5. 例题:最长上升子序列
扯了这么多形而上的内容,还是做一道例题吧。
最长上升子序列(LIS)问题:给定长度为n的序列a,从a中抽取出一个子序列,这个子序列需要单调递增。问最长的上升子序列(LIS)的长度。
e.g. 1,5,3,4,6,9,7,8的LIS为1,3,4,6,7,8,长度为6。
如何设计状态(我是谁)?
我们记 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-skMhBqfJ-1631201859702)(https://www.zhihu.com/equation?tex=f(x)]) 为以 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-NLeiGgKR-1631201859702)(https://www.zhihu.com/equation?tex=a\_x)] 结尾的LIS长度,那么答案就是 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZUihDRRZ-1631201859703)(https://www.zhihu.com/equation?tex=\max{f(x)]}) .
状态x从哪里推过来(我从哪里来)?
考虑比x小的每一个p:如果 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-RDhnUsgI-1631201859703)(https://www.zhihu.com/equation?tex=a\_x>a\_p)] ,那么f(x)可以取f§+1.
解释:我们把 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-kTr9oNTc-1631201859703)(https://www.zhihu.com/equation?tex=a\_x)] 接在 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-EhL3jLhe-1631201859704)(https://www.zhihu.com/equation?tex=a\_p)] 的后面,肯定能构造一个以 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GhJrQ0vF-1631201859704)(https://www.zhihu.com/equation?tex=a\_x)] 结尾的上升子序列,长度比以 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5I7a5ZpS-1631201859704)(https://www.zhihu.com/equation?tex=a\_p)] 结尾的LIS大1.那么,我们可以写出状态转移方程了:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xQRs2JEG-1631201859705)(https://www.zhihu.com/equation?tex=f(x)]%3D\max\_{p<x+%2C+a\_p<a\_x+}{f§}%2B1)
至此解决问题。两层for循环,复杂度 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KDyt7Zls-1631201859706)(https://www.zhihu.com/equation?tex=O(n^2)]) .

从这三个例题中可以看出,DP是一种思想,一种“大事化小,小事化了”的思想。带着这种思想,DP将会成为我们解决问题的利器。
最后,我们一起念一遍DP三连吧——我是谁?我从哪里来?我要到哪里去?
#### 6. 习题
如果读者有兴趣,可以试着完成下面几个习题:
一、请采取一些优化手段,以 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1d94vVKS-1631201859707)(https://www.zhihu.com/equation?tex=O(n\log+n)]) 的复杂度解决LIS问题。



**既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!**
**由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新**
**[如果你需要这些资料,可以戳这里获取](https://bbs.csdn.net/topics/618658159)**
下来直接上传(img-GhJrQ0vF-1631201859704)(https://www.zhihu.com/equation?tex=a\_x)] 结尾的上升子序列,长度比以 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-5I7a5ZpS-1631201859704)(https://www.zhihu.com/equation?tex=a\_p)] 结尾的LIS大1.那么,我们可以写出状态转移方程了:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xQRs2JEG-1631201859705)(https://www.zhihu.com/equation?tex=f(x)]%3D\max\_{p<x+%2C+a\_p<a\_x+}{f§}%2B1)
至此解决问题。两层for循环,复杂度 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-KDyt7Zls-1631201859706)(https://www.zhihu.com/equation?tex=O(n^2)]) .

从这三个例题中可以看出,DP是一种思想,一种“大事化小,小事化了”的思想。带着这种思想,DP将会成为我们解决问题的利器。
最后,我们一起念一遍DP三连吧——我是谁?我从哪里来?我要到哪里去?
#### 6. 习题
如果读者有兴趣,可以试着完成下面几个习题:
一、请采取一些优化手段,以 [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-1d94vVKS-1631201859707)(https://www.zhihu.com/equation?tex=O(n\log+n)]) 的复杂度解决LIS问题。
[外链图片转存中...(img-1vhFK2NU-1715535952525)]
[外链图片转存中...(img-lt34KGRj-1715535952525)]
[外链图片转存中...(img-JCHfVfYP-1715535952525)]
**既有适合小白学习的零基础资料,也有适合3年以上经验的小伙伴深入学习提升的进阶课程,涵盖了95%以上Go语言开发知识点,真正体系化!**
**由于文件比较多,这里只是将部分目录截图出来,全套包含大厂面经、学习笔记、源码讲义、实战项目、大纲路线、讲解视频,并且后续会持续更新**
**[如果你需要这些资料,可以戳这里获取](https://bbs.csdn.net/topics/618658159)**