清风建模—动态规划
以求解斐波那契数列来举例子(尽管它不算严格的动态规划)。
1)子问题和原问题
原问题就是你要求解的这个问题本身,子问题是和原问题相似但规模较小的问题(原问题本身就是子问题的最复杂的情形,即子问题的特例)。
例如:要求F(10),那么求出F(10)就是原问题,求出F(k)(k<10)都是子问题。
2)状态
状态就是子问题中会变化的某个量,可以把状态看成我们要求解的问题的自变量。
例如:我们要求的F(10),那么这里的自变量10就是一个状态。
3)状态转移方程
能够表示状态之间转移关系的方程,一般利用关于状态的某个函数建立起来。
例如:F(n)=F(n-1)+ F(n-2) ,当n为>2的整数时;当n=1或2时,F(n)=1, 这种最简单的初始条件一般称为边界条件,也被称为基本方程。
4)DP数组(DP就是动态规划的缩写)
DP数组也可以叫 “子问题数组” ,因为DP数组中的每一个元素都对应一个子问题的结果,DP数组的下标一般就是该子问题对应的状态。
例如:使用自底向上法编程求解时,我们定义的向量FF就可以看成一个DP数组,数组下标从1取到n,对应的元素从F(1)取到F(n)。
【题目描述】 你是一个专业的小偷,计划偷窃沿街的房屋。每间房内都藏有一定的现金,影响你偷窃的唯一制约因素就是相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你在不触动警报装置的情况下,能够偷窃到的最高金额。
示例1:
输入: [1,2,3,1]
输出: 4
解释: 偷窃 1 号房屋 (金额 = 1) ,然后偷窃 3 号房屋 (金额 = 3)。偷窃到的最高金额 = 1 + 3 = 4 。
示例2:
输入: [2,7,2,3,8]
输出: 15
解释: 偷窃 2 号房屋 (金额 = 7), 偷窃 5 号房屋 (金额 = 8)。偷窃到的最高金额 = 7 + 8 = 15 。注意这里只偷了2间房,但是金额比偷3间房要大。
【思路分析】
假设一共有 n n n 间房子,第 i i i 间房子中有 M i M_i Mi 金额的钱财,如下图所示:
步骤一:定义原问题和子问题
原问题:从全部 n n n 间房子中能够偷到的最大金额;
子问题:从前 k k k ( k ≤ n ) (k≤n) (k≤n)间房子中能够偷到的最大金额。
步骤二:定义状态
我们记 f ( k ) f(k) f(k) 为小偷能从前 k k k 间房子中偷到的最大金额,这里的 k k k 就表示这个小偷在子问题时的一个状态,即小偷仅偷前 k k k 间房子(注意:这里的 k k k 有点像函数中的自变量, f ( k ) f(k) f(k)实际上也可以视为一个关于状态 k k k 的函数)。 特别地,当 k = n k=n k=n 时, f ( n ) f(n) f(n) 就是要求解的原问题。
前提条件:不能偷两个相邻的房子。
步骤三:寻找状态转移方程
所谓的寻找状态转移方程,本质上就是寻找子问题之间的一个递推关系 ,我们这里先给出答案,然后再进行解释:
注: f ( k ) f(k) f(k) 为小偷能从前 k k k 间房子中偷到的最大金额。
前两个情况就是动态规划问题中的边界条件,也称为基本方程。
情况3中即 k ≥ 3 k≥3 k≥3 时,分两种情况讨论:
1)小偷已经偷了第 k − 1 k-1 k−1 间房子,这时候小偷不能偷第 k k k 间房子,此时最多能获得 f ( k − 1 ) f(k- 1) f(k−1) 的钱财。
2)小偷没有偷第 k − 1 k-1 k−1 间房子,根据定义小偷在前 k − 2 k-2 k−2 间房子能够偷到的最大钱财为 f ( k − 2 ) f(k-2) f(k−2),又因为没有偷第 k − 1 k-1 k−1 间房子,那么小偷一定会去偷第 k k k 间房,此时最多能获得 f ( k − 2 ) + M k f(k-2)+M_k f(k−2)+Mk 的钱财。
因此,小偷采取哪种情况行事取决于这两种情况下最多能偷到的金额大小,即 式3。
递归方法求解:
function f = djjs_digui( M )
k = length(M); % 房子数量
if k==1
f = M(1);
elseif k==2
f = max(M(1),M(2));
else
M_1 = M(1:k-1); % 第1~k-1间房子的金额
M_2 = M(1:k-2); % 第1~k-2间房子的金额
f = max(djjs_digui(M_1),(djjs_digui(M_2)+M(k)));
end
end
用 M = [2,7,2,3,8]; djjs_digui(M)
调用该函数得结果为15。若用递归方法求解,运算过程中会有很多重叠的解,导致算法复杂度很高。
我们将M的值扩大为40个值并计算运行时间:
tic
M = [2,7,2,3,8,2,7,2,3,8,2,7,2,3,8,2,7,2,3,8,2,7,2,3,8,2,7,2,3,8,2,7,2,3,8,2,7,2,3,8]
djjs_digui(M)
toc
动态规划求解:
function f = djjs_dp(M) % 输入的M就是每间房子里面的金额
% 动态规划
n = length(M);
if n==1 % 只有第1间房子可以偷
f=M(1);
elseif n==2 % 只有前两间房子可以偷
f=max(M(1),M(2));
else % n≥3
FF=ones(1,n); % DP数组,保存f(k)
FF(1)=M(1); % 边界条件
FF(2)=max(M(1),M(2)); % 边界条件
for i=3:n % 利用状态转移方程循环计算
FF(i)=max(FF(i-1),(FF(i-2)+M(i)));
end
f=FF(n); % 输出FF中最后一个元素,也就是原问题的解
end
end
调用动态规划的函数:
tic
M = [2,7,2,3,8,2,7,2,3,8,2,7,2,3,8,2,7,2,3,8,2,7,2,3,8,2,7,2,3,8,2,7,2,3,8,2,7,2,3,8]
djjs_dp(M)
toc
运行时间甚至不到1s!
利用状态压缩 改进上述代码:
简而言之,对于前面讲的打家劫舍问题而言,我们发现,最后一步计算 f ( n ) f(n) f(n) 的时候,实际上只用到了 f ( n − 1 ) f(n-1) f(n−1) 和 f ( n − 2 ) f(n-2) f(n−2) 的结果,再往之前的子问题的结果实际上早就已经用不到了。因此,我们可以利用两个变量保存前两个子问题的结果, 这样既可以依次计算出所有的子问题,又能节省计算机内存的消耗。
怎么输出偷窃的房屋编号?
注意:产生最大偷窃金额的方案不唯一,我们这里只要求输出任意一个方案即可。例如M=[1,3,2]就有两种方案。
根据FF的定义容易证明下面两个结论:①对于任意的 i i i 均有 F F ( i ) > = M ( i ) FF(i)>=M(i) FF(i)>=M(i) ,当且仅当小偷只偷了一间房子时等号成立;②FF递增(不要求严格)。
那么,怎么从FF中推出IND呢? 我们先初始化IND为一个空向量。
这里我们只需要在最开始写的函数上加上一个返回值IND,表示我们盗窃的房屋编号。
第1步: 找到FF中第一次出现最大值的位置,此时是小偷偷的最后一间房子。反证:假设小偷还偷了后面的房子,那么FF一定会增加,矛盾。因此FF中第一个最大值对应的下标就是小偷偷的最后一间房子的标号。 比如在我们上面这个例子里面,FF的最大值是12, 第一次出现的下标为6,这表示6号房子是小偷最后偷的一间房子,因此我们可以把6添加到向量IND内。
第2步: 记第一步找到的下标为ind,判断FF(ind)与M(ind)的大小, 根据结论①,FF(ind)≥M(ind)。所以可以分为两种情况讨论:
(1)如果FF(ind)=M(ind),这意味着小偷在之前没有偷任何房子 ,这可以根据上面的结论①得到,那么我们就可以直接返回IND。
(2) 如果FF(ind)>M(ind), 则说明小偷还偷了其他房子 ,在我们这个例子中:ind=6,FF(ind)=12,M(ind)=6,那么我们可以用FF(ind)减去M(ind),得到的结果就是在ind=6号房子之前能够偷得的最大金额,在我们这个例子里面,FF(ind)-M(ind)=6然后我们在FF里面找到第一个出现这个6的位置,这个位置就是小偷倒数第二次选择的房子 ,本例中FF(4)=6, 因此可以把4也添加到IND中,这样不断循环下去,直到FF(ind)=M(ind)为止。 例如本例中,FF(4)=6而M(4)=2,因为6-2=4>0,所以小偷在4号房子之前最多能偷到的金额为4,接下来我们再在FF里面找到第一个出现这个4的位置,得到了2号房子,则把2添加到IND内,然后判断出FF(2)=M(2),这时候返回IND即可。
function [f,IND] = djjs_dp_house(M) % 输入的M就是每间房子里面的金额
% 动态规划
n = length(M);
if n==1 % 只有第1间房子可以偷
f=M(1); IND=1;
elseif n==2 % 只有前两间房子可以偷
[f,IND]=max([M(1),M(2)]);
else % n≥3
FF=zeros(1,n); % DP数组,保存f(k)
FF(1)=M(1); % 边界条件
FF(2)=max(M(1),M(2)); % 边界条件
for i=3:n % 利用状态转移方程循环计算
FF(i)=max(FF(i-1),FF(i-2)+M(i));
end
f=FF(i); % 输出FF中最后一个元素,也就是原问题的解
IND=[]; % IND可以通过DP数组FF推算出来
ind=find(FF==FF(end),1);
IND=[IND,ind]; % 将第一个找到的加入到数组
while FF(ind) > M(ind)
ind=find(FF==(FF(ind)-M(ind)),1);
IND=[IND,ind];
end
IND=IND(end:-1:1); % 翻转一下
end
end
用M = [1,4,1,2,5,6,3]; [f,IND] = djjs_dp_house(M)
调用可得结果:
【问题描述】 在一个 m ∗ n m*n m∗n (m和n都大于1)的棋盘的每一格都放有一个礼物,每个礼物都有一定的价值(价值大于0)。你可以从棋盘的左上角开始拿格子里的礼物,并每次向右或者向下移动一格、直到到达棋盘的右下角。给定一个棋盘及其上面的礼物的价值,请计算你最多能拿到多少价值的礼物?
【思路分析】
假设棋盘大小为 m ∗ n m*n m∗n ,第 i i i 行第 j j j 列格子中有价值为 M i j M_{ij} Mij 的礼物,如下图所示:
由题意得,棋盘中的某一个单元格只可能从它上边一个单元格或左边一个单元格到达。
步骤一:定义原问题和子问题
原问题: 从棋盘的左上角开始拿格子里的礼物直到到达棋盘的右下角所能获得的最大礼物价值。
子问题: 从棋盘的左上角开始直到到达棋盘的第 i i i 行第 j j j 列的格子所能获得的最大礼物价值(这里 1 ≤ i ≤ m , 1 ≤ j ≤ n 1≤i≤m,1≤j≤n 1≤i≤m,1≤j≤n )。
步骤二:定义状态
记 f ( i , j ) f(i,j) f(i,j) 为从棋盘左上角走至第 i i i 行第 j j j 列的格子所能获得的最大礼物价值,这里可认为 ( i , j ) (i,j) (i,j) 就是上述子问题所对应的状态;特别的当 i = m , j = n i=m,j=n i=m,j=n 时,该状态对应的 f ( m , n ) f(m,n) f(m,n) 就是我们要求解的原问题的答案。
步骤三:寻找状态转移方程
f ( i , j ) f(i,j) f(i,j) 为从棋盘左上角走至第 i i i 行第 j j j 列的格子所能获得的最大礼物价值。
只输入最大值的matlab代码:
function f = max_gift_value1(M)
[m,n]=size(M); % 输入的M就是棋盘中每个礼物的价值
FF=M; % 初始化DP数组和M完全相同,用来保存f(i,j)
FF(:,1)=cumsum(M(:,1)); % 计算FF的第一列,cumsum计算累加和
FF(1,:)=cumsum(M(1,:)); % 计算FF的第一行
% 循环右下部分的元素
for i = 2:m
for j = 2:n
tem1 = FF(i,j-1) + M(i,j); % 从左边来
tem2 = FF(i-1,j) + M(i,j); % 从上面来
FF(i,j) = max(tem1,tem2)
end
end
f = FF(m,n);
end
补充:cumsum
计算累加和
当M=[5 1 5 1;3 5 3 5;4 2 1 1;1 5 1 3;1 5 3 4];
对其第一列使用累加FF(:,1)=cumsum(M(:,1));
可得:
调用f = max_gift_value1(M)
可得f=32。
怎么输出所走的路线?
注意:产生的最大礼物的路线不唯一,我们这里只输出一个路线。
判断从哪个方向来的还有另一种思路:站在12这个点,看它上面的值和左边的值哪个大,哪个方向的值更大,就是从哪个方向来的。
function [f,path] = max_gift_value2(M)
% 输入的M就是棋盘中每个礼物的价值
[m,n] = size(M); % 棋盘m行n列
FF = M; % 初始化DP数组和M完全相同,用来保存f(i,j)
% 计算FF的第一列
FF(:,1) = cumsum(M(:,1)); % cumsum函数用来计算累加和
% 计算FF的第一行
FF(1,:) = cumsum(M(1,:));
% 循环计算右下部分的元素
for i = 2:m
for j = 2:n
tem1 = FF(i,j-1) + M(i,j); % 从左边来
tem2 = FF(i-1,j) + M(i,j); % 从上面来
FF(i,j) = max(tem1,tem2);
end
end
f = FF(m,n);
% 根据程序中得到的DP数组(FF)来推出对应的路径path
path = zeros(m,n); % path全为0和1组成,1表示经过该格子
i = m; j = n;
while i ~= 1 || j ~= 1 % 只要没有回到原点
path(i,j) = 1; % 把path矩阵的第i行第j列变成1(表示访问了这个格子)
if i == 1 % 如果到了第一行
path(1,1:j) = 1; % 剩余的路径沿着左边一直走就可以了
break % 退出循环
end
if j == 1 % 如果到了第一列
path(1:i,1) = 1; % 剩余的路径沿着上方一直走就可以了
break % 退出循环
end
tmp1 = FF(i-1,j); % 上方单元格FF的值
tmp2 = FF(i,j-1); % 左边单元格FF的值
ind = find([tmp1,tmp2] == (FF(i,j)-M(i,j)),1); % 看哪个值等于FF(i,j)-M(i,j)
if ind == 1 % 如果上方单元格FF的值等于FF(i,j)-M(i,j)
i = i-1; % 说明上一步沿着上方来的
else % 如果左边单元格FF的值等于FF(i,j)-M(i,j)
j = j-1; % 说明上一步沿着左边来的
end
end
end
同样的M矩阵,求出的路径为:
path
由0和1组成,1表示经过该格子。
本例题的python代码可参考:数据结构之动态规划(三) | 礼物的最大值(剑指offer47)python+matlab
以下内容原版来自:代码随想录,一文搞懂回溯搜索。在这里,我只做重要的摘录。
「回溯是递归的副产品,只要有递归就会有回溯」,所以回溯法也经常和二叉树遍历,深度优先搜索混在一起,因为这两种方式都是用了递归。
回溯实际上是一种试探算法,这种算法跟暴力搜索最大的不同在于,在回溯算法里,是一步一步地小心翼翼地进行向前试探,会对每一步探测到的情况进行评估,如果当前的情况已经无法满足要求,那么就没有必要继续进行下去,也就是说,它可以帮助我们避免走很多的弯路。
回溯算法的特点在于,当出现非法的情况时,算法可以回退到之前的情景,可以是返回一步,有时候甚至可以返回多步,然后再去尝试别的路径和办法。这也就意味着,想要采用回溯算法,就必须保证,每次都有多种尝试的可能。
一般的解题步骤:
1、判断当前情况是否非法,如果非法就立即返回;
2、当前情况是否已经满足递归结束条件,如果是就将当前结果保存起来并返回;
3、当前情况下,遍历所有可能出现的情况并进行下一步的尝试;
4、递归完毕后,立即回溯,回溯的方法就是取消前一步进行的尝试。
回溯法就是暴力搜索,并不是什么高效的算法,最多再剪枝一下。
回溯算法能解决如下问题:
题目链接: https://leetcode-cn.com/problems/combinations/
题目描述: 给定两个整数 n 和 k,返回 1 … n 中所有可能的 k 个数的组合。
示例: 输入: n = 4, k = 2
输出:[[2,4],[3,4],[2,3],[1,2],[1,3],[1,4]]
可以直观的看出其搜索的过程:「for循环横向遍历,递归纵向遍历,回溯不断调整结果集」,这个理念贯穿整个回溯法系列,也是我做了很多回溯的题目,不断摸索其规律才总结出来的。
要解决 n为100,k为50的情况,暴力写法需要嵌套50层for循环,那么回溯法就用递归来解决嵌套层数的问题。
递归来做层叠嵌套(可以理解是开k层for循环),每一次的递归中嵌套一个for循环,那么递归就可以用于解决多层嵌套循环的问题了。
每次从集合中选取元素,可选择的范围随着选择的进行而收缩,调整可选择的范围。
图中可以发现n相当于树的宽度,k相当于树的深度。
那么如何在这个树上遍历,然后收集到我们要的结果集呢?
图中每次搜索到了叶子节点,我们就找到了一个结果。
相当于只需要把达到叶子节点的结果收集起来,就可以求得 n个数中k个数的组合集合。
组合问题的C++代码如下:
class Solution {
private:
vector<vector<int>> result; // 存放符合条件结果的集合
vector<int> path; // 用来存放符合条件结果
void backtracking(int n, int k, int startIndex) { //每次循环从startindex开始
if (path.size() == k) { //当遍历到组合大小为2时,代表走到了叶子节点
result.push_back(path); //将此时的path添加到结果路径中
return;
}
for (int i = startIndex; i <= n; i++) { //控制树的横向遍历
path.push_back(i); // 处理节点
backtracking(n, k, i + 1); // 递归:控制树的纵向遍历,注意下一层搜索要从i+1开始
path.pop_back(); // 回溯,撤销处理的节点。(这里取1时,12组合、要把2pop出去,然后13组合,以此类推……)
}
}
public:
vector<vector<int>> combine(int n, int k) {
result.clear(); // 可以不写
path.clear(); // 可以不写
backtracking(n, k, 1);
return result;
}
};
算法步骤分析: 回溯算法:求组合问题
for (int i = startIndex; i <= n - (k - path.size()) + 1; i++) // i为本次搜索的起始位置
已经选择的元素个数:path.size()
;
还需要的元素个数为: k - path.size()
;
在集合n中至多要从该起始位置 : n - (k - path.size()) + 1
,开始遍历
举个例子,n = 4,k = 3, 目前已经选取的元素为0(path.size为0),n - (k - 0) + 1 即 4 - ( 3 - 0) + 1 = 2。从2开始搜索都是合理的,可以是组合[2, 3, 4]。k - path.size()
表示path
数组还能放几个元素,再用n
减去它就能得到从哪个下标开始能够满足path
放k
个元素这个条件。
剪枝精髓: for循环在寻找起点的时候要有一个范围,如果这个起点到集合终止之间的元素已经不够题目要求的k个元素了,就没有必要搜索了。「在for循环上做剪枝操作是回溯法剪枝的常见套路!」 后面的题目还会经常用到。
算法步骤分析:回溯算法:组合问题再剪剪枝
题目链接:https://leetcode-cn.com/problems/combination-sum-iii/
题目描述: 找出所有相加之和为 n 的 k 个数的组合。组合中只允许含有 1 - 9 的正整数,并且每种组合中不存在重复的数字。(说明:所有数字都是正整数。解集不能包含重复的组合。)
示例: 输入: k = 3, n = 9
输出: [[1,2,6], [1,3,5], [2,3,4]]
需要的参数有:
完整的C++代码如下:
class Solution {
private:
vector<vector<int>> result; // 存放结果集
vector<int> path; // 符合条件的结果
// targetSum:目标和,也就是题目中的n。
// k:题目中要求k个数的集合。
// sum:已经收集的元素的总和,也就是path里元素的总和。
// startIndex:下一层for循环搜索的起始位置。
void backtracking(int targetSum, int k, int sum, int startIndex) {
if (path.size() == k) { //k其实就已经限制树的深度,因为就取k个元素,树再往下深了没有意义。所以如果path.size() 和 k相等了,就终止。
if (sum == targetSum) result.push_back(path);
return; // 如果path.size() == k 但sum != targetSum 直接返回
}
for (int i = startIndex; i <= 9; i++) {
sum += i; // 处理
path.push_back(i); // 处理
backtracking(targetSum, k, sum, i + 1); // 注意i+1调整startIndex
sum -= i; // 回溯
path.pop_back(); // 回溯
}
}
public:
vector<vector<int>> combinationSum3(int k, int n) {
result.clear(); // 可以不加
path.clear(); // 可以不加
backtracking(n, k, 0, 1);
return result;
}
};
这里也可以剪枝。
已选元素总和如果已经大于n(图中数值为4)了,那么往后遍历就没有意义了,直接剪掉。那么剪枝的地方一定是在递归终止的地方剪,剪枝代码如下:
if (sum > targetSum) { // 剪枝操作
return;
}
代码分析:回溯算法:求组合总和!
题目链接:https://leetcode-cn.com/problems/palindrome-partitioning/
题目描述: 给定一个字符串 s,将 s 分割成一些子串,使每个子串都是回文串。返回 s 所有可能的分割方案。
在回溯算法:分割回文串中,我们开始讲解切割问题,虽然最后代码看起来好像是一道模板题,但是从分析到学会套用这个模板,是比较难的。
几个难点如下:
代码分析:回溯算法:分割回文串
本题涉及到两个关键问题:1、切割问题,有不同的切割方式;2、判断回文
C++完整的代码如下:
class Solution {
private:
vector<vector<string>> result;
vector<string> path; // 放已经回文的子串
void backtracking (const string& s, int startIndex) {
// 如果起始位置已经大于s的大小,说明已经找到了一组分割方案了
if (startIndex >= s.size()) {
result.push_back(path);
return;
}
for (int i = startIndex; i < s.size(); i++) {
if (isPalindrome(s, startIndex, i)) { // 是回文子串
// 获取[startIndex,i]在s中的子串
string str = s.substr(startIndex, i - startIndex + 1);
path.push_back(str);
} else { // 不是回文,跳过
continue;
}
backtracking(s, i + 1); // 寻找i+1为起始位置的子串
path.pop_back(); // 回溯过程,弹出本次已经填在的子串
}
}
bool isPalindrome(const string& s, int start, int end) {
for (int i = start, j = end; i < j; i++, j--) {
if (s[i] != s[j]) {
return false;
}
}
return true;
}
public:
vector<vector<string>> partition(string s) {
result.clear();
path.clear();
backtracking(s, 0);
return result;
}
};
题目链接: https://leetcode-cn.com/problems/permutations/
题目描述: 给定一个 没有重复 数字的序列,返回其所有可能的全排列。
示例: 输入: [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
可元素1在[1,2]中已经使用过了,但是在[2,1]中还要在使用一次1,所以处理排列问题就不用使用startIndex了。但排列问题需要一个used数组,标记已经选择的元素,其实就是记录此时path里都有哪些元素使用了,一个排列里一个元素只能使用一次。 如图橘黄色部分所示。
依旧是回溯三部曲:递归函数参数、递归终止条件、单层搜索的逻辑
本题中具体的分析请看:回溯算法:排列问题
class Solution {
public:
vector<vector<int>> result;
vector<int> path;
void backtracking (vector<int>& nums, vector<bool>& used) {
// 此当收集元素的数组path的大小达到和nums数组一样大的时候,说明找到了一个全排列,也表示到达了叶子节点。
if (path.size() == nums.size()) {
result.push_back(path);
return;
}
for (int i = 0; i < nums.size(); i++) {
if (used[i] == true) continue; // path里已经收录的元素,直接跳过
used[i] = true;
path.push_back(nums[i]);
backtracking(nums, used);
path.pop_back();
used[i] = false; //pop之后将其值回溯为0
}
}
vector<vector<int>> permute(vector<int>& nums) {
result.clear();
path.clear();
vector<bool> used(nums.size(), false);
backtracking(nums, used);
return result;
}
};
题目链接:https://leetcode-cn.com/problems/permutations-ii/
题目描述: 给定一个可包含重复数字的序列 nums ,按任意顺序返回所有不重复的全排列。
示例 1: 输入:nums = [1,1,2]
输出:[[1,1,2],[1,2,1],[2,1,1]]
示例 2: 输入:nums = [1,2,3]
输出:[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]
这道题目和上一小节的区别在于「给定一个可包含重复数字的序列」,要返回「所有不重复的全排列」。这里又涉及到去重了。
「还要强调的是去重一定要对元素进行排序,这样我们才方便通过相邻的节点来判断是否重复使用了」。
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking (vector<int>& nums, vector<bool>& used) {
// 此时说明找到了一组
if (path.size() == nums.size()) {
result.push_back(path);
return;
}
for (int i = 0; i < nums.size(); i++) {
// used[i - 1] == true,说明同一树支nums[i - 1]使用过
// used[i - 1] == false,说明同一树层nums[i - 1]使用过
// 如果同一树层nums[i - 1]使用过则直接跳过
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) { //★★
continue;
}
if (used[i] == false) {
used[i] = true;
path.push_back(nums[i]);
backtracking(nums, used);
path.pop_back();
used[i] = false;
}
}
}
public:
vector<vector<int>> permuteUnique(vector<int>& nums) {
result.clear();
path.clear();
sort(nums.begin(), nums.end()); // 排序
vector<bool> used(nums.size(), false);
backtracking(nums, used);
return result;
}
};
代码分析见:回溯算法:排列问题Ⅱ
题目链接: https://leetcode-cn.com/problems/subsets/
题目描述: 给定一组不含重复元素的整数数组 nums,返回该数组所有可能的子集(幂集)。说明:解集不能包含重复的子集。
示例: 输入: nums = [1,2,3]
输出:[[3],[1],[2],[1,2,3],[1,3],[2,3],[1,2],[]]
如果把 子集问题、组合问题、分割问题 都抽象为一棵树的话,「那么组合问题和分割问题都是收集树的叶子节点,而子集问题是找树的所有节点!」
其实子集也是一种组合问题,因为它的集合是无序的,子集{1,2} 和 子集{2,1}是一样的。「那么既然是无序,取过的元素不会重复取,写回溯算法的时候,for就要从startIndex开始,而不是从0开始!」
从图中红线部分,可以看出「遍历这个树的时候,把所有节点都记录下来,就是要求的子集集合」。剩余集合为空的时候,就是叶子节点。那么什么时候剩余集合为空呢?就是startIndex已经大于数组的长度了,就终止了,因为没有元素可取了。
「求取子集问题,不需要任何剪枝!因为子集就是要遍历整棵树」。
C++完整代码如下:
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& nums, int startIndex) {
result.push_back(path); // 收集子集
if (startIndex >= nums.size()) { // 终止条件可以不加
return;
}
for (int i = startIndex; i < nums.size(); i++) {
path.push_back(nums[i]); // 子集收集元素
backtracking(nums, i + 1); // 注意从i+1开始,元素不重复取
path.pop_back(); // 回溯
}
}
public:
vector<vector<int>> subsets(vector<int>& nums) {
result.clear();
path.clear();
backtracking(nums, 0);
return result;
}
};
代码分析见:回溯算法:求子集问题!
题目链接: https://leetcode-cn.com/problems/n-queens/
题目描述: n 皇后问题研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。
下图是8皇后的一种解法。
注:皇后,是国际象棋中的棋子,意味着国王的妻子。皇后只做一件事,那就是“吃子”。当她遇见可以吃的棋子时,就迅速冲上去吃掉棋子。当然,她横、竖、斜都可走一到七步,可进可退。(引用自百度百科 )
皇后们的约束条件:不能同行、不能同列、不能同斜线
确定完约束条件,来看看究竟要怎么去搜索皇后们的位置,其实搜索皇后的位置,可以抽象为一棵树。下面我用一个3 * 3 的棋牌,将搜索过程抽象为一颗树,如图:
从图中,可以看出,二维矩阵中矩阵的高就是这颗树的高度,矩阵的宽就是树型结构中每一个节点的宽度。
那么我们用皇后们的约束条件,来回溯搜索这颗树,「只要搜索到了树的叶子节点,说明就找到了皇后们的合理位置了」。
C++完整代码如下:
class Solution {
private:
vector<vector<string>> result;
// n 为输入的棋盘大小
// row 是当前递归到棋牌的第几行了
void backtracking(int n, int row, vector<string>& chessboard) {
if (row == n) {
result.push_back(chessboard);
return;
}
for (int col = 0; col < n; col++) {
if (isValid(row, col, chessboard, n)) { // 验证合法就可以放
chessboard[row][col] = 'Q'; // 放置皇后
backtracking(n, row + 1, chessboard);
chessboard[row][col] = '.'; // 回溯,撤销皇后
}
}
}
bool isValid(int row, int col, vector<string>& chessboard, int n) {
int count = 0;
// 检查列
for (int i = 0; i < row; i++) { // 这是一个剪枝
if (chessboard[i][col] == 'Q') {
return false;
}
}
// 检查 45度角是否有皇后(斜线)
for (int i = row - 1, j = col - 1; i >=0 && j >= 0; i--, j--) {
if (chessboard[i][j] == 'Q') {
return false;
}
}
// 检查 135度角是否有皇后()斜线
for(int i = row - 1, j = col + 1; i >= 0 && j < n; i--, j++) {
if (chessboard[i][j] == 'Q') {
return false;
}
}
return true;
}
public:
vector<vector<string>> solveNQueens(int n) {
result.clear();
std::vector<std::string> chessboard(n, std::string(n, '.'));
backtracking(n, 0, chessboard);
return result;
}
};
着重看一下单层搜索的逻辑:
递归深度就是row控制棋盘的行,每一层里for循环的col控制棋盘的列,一行一列,确定了放置皇后的位置。每次都是要从新的一行的起始位置开始搜,所以都是从0开始。
更多分析请看:回溯算法:N皇后问题
题目链接: https://leetcode-cn.com/problems/sudoku-solver/
题目描述: 编写一个程序,通过填充空格来解决数独问题。
一个数独的解法需遵循如下规则:
数字 1-9 在每一行只能出现一次。
数字 1-9 在每一列只能出现一次。
数字 1-9在每一个以粗实线分隔的 3x3 宫内只能出现一次。
空白格用 ‘.’ 表示。
提示:
篇幅有限,更多详细内容参考:回溯算法—解数独
参考资料:彻底搞懂回溯算法(本文真的很详细)
分治法作为一种常见的算法思想,其概念为:把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题,直到最后子问题可以简单的直接求解,子问题的解的合并即是原问题的解。 举个例子,要算16个数的和可能一下子算不出来的,但是可以通过几次一分为二(拆分),直到分成两个数、两个数一组;再对这些数两两相加,算出每组的和后,再两两相加,直到最后只剩下了一个数,就算出16个数的和(合治)。
可以用分治法解决的问题一般有如下特征:
1、问题的规模缩小到一定的程度就可以容易地解决。 此特征是大多数问题所具备的,当问题规模增大时,解决问题的复杂度不可避免地会增加。
2、问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质。此特征也较为常见,是应用分治法的前提。
3、拆分出来的子问题的解,可以合并为该问题的解。这个特征在是否采用分治法的问题上往往具有决定性作用,比如棋盘覆盖、汉诺塔等,需要将子问题的解汇总,才是最终问题的解。
4、拆分出来的各个子问题是相互独立的,即子问题之间不包含公共的子问题。该特征涉及到分治法的效率,如果各子问题是不独立的,则需要重复地解公共的子问题,此时用动态规划法更好。
分治法详细的解说,参考:分治算法及常见例子
分支定界算法始终围绕着一颗搜索树进行的,我们将原问题看作搜索树的根节点,从这里出发,分支的含义就是将大的问题分割成小的问题。 大问题可以看成是搜索树的父节点,那么从大问题分割出来的小问题就是父节点的子节点了。
分支的过程就是不断给树增加子节点的过程。而定界就是在分支的过程中检查子问题的上下界,如果子问题不能产生一比当前最优解还要优的解,那么砍掉这一支。 直到所有子问题都不能产生一个更优的解时,算法结束。
以下内容均来自B站一位老师的讲解:【运筹学】-整数线性规划(一)(分支定界法)
讲的浅显易懂,值得一看。我将在下面总结精华部分:
分支定界法原理: 不断将线性规划问题B的可行域分为子区域,逐步缩小 z ∗ z^* z∗ 的上界和增大z*的下界,最终求得 z ∗ z^* z∗ 。
上下界更新: 问题B各分支最优目标函数中的最大值作为新的上界;在已求出整数条件的解中的最大者作为新的下界。
★求解步骤:
1)初始定界: 求解线性规划问题B(不考虑整数约束条件)的最优解,并将其目标函数值作为 z ∗ z^* z∗ 的初始上届;求解A的一个整数解,将目标函数值作为 z ∗ z^* z∗ 的初始下界。
2)分支: 选择B最优解中不满足证书条件的变量,添加约束将B分为两个子问题 B 1 B_1 B1、 B 2 B_2 B2 ,不考虑整数约束求解 B 1 B_1 B1、 B 2 B_2 B2 的最优解。
3)定界: 比较各分支的解,将最优目标函数值得最大值作为新的上界;将各分支满足整数条件的解中的最大者作为新的下界。
4)剪支: 比较各分支的解,若小于新的下界,剪支;若无可行解,剪支;若大于新的下界,重复2、3、4步骤。
代码及详细分析参考:干货 | 10分钟带你全面掌握branch and bound(分支定界)算法-概念篇