动态规划
基本概念
过程:每次决策依赖于当前状态,又随即引起状态的转移。
一个决策序列就是在变化的状态中产生出来的,所以,这种多阶段最优化决策解决问题的过程就称为动态规划。
基本思想与策略
基本思想与分治法类似,也是将待求解的问题分解为若干个子问题(阶段),按顺序求解子阶段,前一子问题的解,为后一子问题的求解提供了有用的信息。在求解任一子问题时,列出各种可能的局部解,通过决策保留那些有可能达到最优的局部解,丢弃其他局部解。依次解决各子问题,最后一个子问题就是初始问题的解。
由于动态规划解决的问题多数有重叠子问题这个特点,为减少重复计算,对每一个子问题只解一次,将其不同阶段的不同状态保存在一个二维数组中。
与分治法最大的差别是:适合于用动态规划法求解的问题,经分解后得到的子问题往往不是互相独立的(即下一个子阶段的求解是建立在上一个子阶段的解的基础上,进行进一步的求解)。
适用的情况
能采用动态规划求解的问题的一般要具有3个性质:
- 最优化原理:如果问题的最优解所包含的子问题的解也是最优的,就称该问题具有最优子结构,即满足最优化原理。
- 无后效性:即某阶段状态一旦确定,就不受这个状态以后决策的影响。也就是说,某状态以后的过程不会影响当前状态。
- 有重叠子问题:即子问题之间是不独立的,一个子问题在下一阶段决策中可能被多次使用到。(该性质并不是动态规划适用的必要条件,但是如果没有这条性质,动态规划算法同其他算法相比就不具备优势)
求解的基本步骤
动态规划所处理的问题是一个多阶段决策问题,一般由初始状态开始,通过对中间阶段决策的选择,达到结束状态。这些决策形成了一个决策序列,同时确定了完成整个过程的一条活动路线(通常是求最优的活动路线)。
动态规划的设计都有着一定的模式,一般要经历以下几个步骤:
初始状态 → │决策1│ → │决策2│ → … → │决策n│ → 结束状态
- 划分阶段:按照问题的时间或空间特征,把问题分为若干个阶段。在划分阶段时,注意划分后的阶段一定要是有序的或者是可排序的,否则问题就无法求解。
- 确定状态和状态变量:将问题发展到各个阶段时所处于的各种客观情况用不同的状态表示出来。当然,状态的选择要满足无后效性。
- 确定决策并写出状态转移方程:因为决策和状态转移有着天然的联系,状态转移就是根据上一阶段的状态和决策来导出本阶段的状态。所以如果确定了决策,状态转移方程也就可写出。但事实上常常是反过来做,根据相邻两个阶段的状态之间的关系来确定决策方法和状态转移方程。
- 寻找边界条件:给出的状态转移方程是一个递推式,需要一个递推的终止条件或边界条件。
一般,只要解决问题的阶段、状态和状态转移决策确定了,就可以写出状态转移方程(包括边界条件)。
实际应用中可以按以下几个简化的步骤进行设计:
1. 分析最优解的性质,并刻画其结构特征。
2. 递归的定义最优解。
3. 以自底向上或自顶向下的记忆化方式(备忘录法)计算出最优值
4. 根据计算最优值时得到的信息,构造问题的最优解
算法实现的说明
动态规划的主要难点在于理论上的设计,也就是上面4个步骤的确定,一旦设计完成,实现部分就会非常简单。
使用动态规划求解问题,最重要的就是确定动态规划三要素:
1. 问题的阶段
2. 每个阶段的状态
3. 从前一个阶段转化到后一个阶段之间的递推关系
递推关系必须是从次小的问题开始到较大的问题之间的转化,从这个角度来说,动态规划往往可以用递归程序来实现,不过递推可以充分利用前面保存的子问题的解来减少重复计算,所以对于大规模问题来说,有递归不可比拟的优势,这也是动态规划算法的核心之处。
确定了动态规划的这三要素,整个求解过程就可以用一个最优决策表来描述,最优决策表是一个二维表,其中行表示决策的阶段,列表示问题状态,表格需要填写的数据一般对应此问题的在某个阶段某个状态下的最优值(如最短路径,最长公共子序列,最大价值等),填表的过程就是根据递推关系,从1行1列开始,以行或者列优先的顺序,依次填写表格,最后根据整个表格的数据通过简单的取舍或者运算求得问题的最优解。
dp(n, m) = max{ dp(n-1, m), dp(n-1, m-w[n]) + P(n, m) }
经典题型
背包问题
0-1背包:每种物品只有一个,只有两种状态:拿或不拿,即0或1。
完全背包:每种物品无限个。
多重背包:每种物品有限个num[i]。
0-1背包
问题描述:
有n个物品,它们有各自的重量和价值,给定一个容量固定的背包,如何装才能让背包里装的物品价值总和最大?
分析:
二维表dp[i][capacity]:面对第i个物品,且背包容量为capacity时,在做决策后所能获得的最大价值。
决策:为使背包中物品价值总和最大化,第i个物品应该装进去吗?
1. 当capacity < weight[i]时,背包容量不足以放下第i个物品,不能装。
dp[i][capacity] = dp[i-1][capacity],表示和上一次状态一样。
- 当capacity >= weight[i]时,背包能放下第i个物品,这时候要考虑装下该物品时能否获得更大价值。
若不装:dp[i][capacity] = dp[i-1][capacity]
若装:dp[i][capacity] = dp[i-1][capacity-weight[i]] + value[i],其中dp[i-1][capacity-weight[i]]表示:在上一次面对第i-1个物品,背包容量为capacity-weight[i]时做出决策后的最大价值。
装不装,就取决于这两个情况下,哪种获得的价值最大。
由上即得状态转移方程:
dp[i][capacity] = max{ dp[i-1][capacity], dp[i-1][capacity-weight[i]] + value[i] };
例题:
山洞里共有a, b, c, d, e这5个宝物,重量分别是2, 2, 6, 5, 4,价值分别是6, 3, 5, 4, 6,你有容量为10的背包,怎样装才能带走最多的财富?
物品 | a | b | c | d | e |
---|---|---|---|---|---|
重量 | 2 | 2 | 6 | 5 | 4 |
价值 | 6 | 3 | 5 | 4 | 6 |
二维表dp[6][11],根据状态转移方程,依次填好表格。
行:5个物品
列:背包容量
0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 |
---|---|---|---|---|---|---|---|---|---|---|
a | 0 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 | 6 |
b | 0 | 6 | 6 | 9 | 9 | 9 | 9 | 9 | 9 | 9 |
c | 0 | 6 | 6 | 9 | 9 | 9 | 9 | 11 | 11 | 14 |
d | 0 | 6 | 6 | 9 | 9 | 9 | 10 | 11 | 13 | 14 |
e | 0 | 6 | 6 | 9 | 9 | 12 | 12 | 15 | 15 | 15 |
填写过程:
容量 | 决策过程 |
---|---|
1 | 都放不下,故都为0。 |
…… | …… |
4 | 面对a时,因为容量4>重量2,且是第1个物品,所以装入a。面对b时,4>2,找到面对a时容量4-2=2时的背包最大价值6,若装:6+3=9;若不装:6。而9>6,所以装入b。面对……。 |
…… | …… |
8 | 面对……。面对e时,8>4,找到面对d时容量8-4=4时的背包最大价值9,若装:9+6=15;若不装:11。而15>11,所以装入e。 |
public class Main {
// 背包容量
private static int capacity = 10;
private static String[] items = new String[] { "a", "b", "c", "d", "e" };
private static int[] weight = new int[] { 2, 2, 6, 5, 4 };
private static int[] value = new int[] { 6, 3, 5, 4, 6 };
// 决策表
private static int[][] dp = new int[items.length][capacity + 1];
public static void main(String[] args) {
decide();
outPutMaxValue();
outPutChosenItems();
}
private static void decide() {
for (int j = 1; j <= capacity; j++) {
for (int i = 0; i < items.length; i++) {
if (j >= weight[i]) {
if (i == 0) {
dp[i][j] = value[i];
continue;
}
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
} else {
if (i == 0) {
dp[i][j] = 0;
continue;
}
dp[i][j] = dp[i - 1][j];
}
}
}
}
private static void outPutChosenItems() {
int[] isChosen = new int[items.length];
for (int i = items.length - 1, c = capacity; i > 0; i--) {
if (dp[i][c] == dp[i - 1][c]) {
isChosen[i] = 0;
} else {
isChosen[i] = 1;
c -= weight[i];
}
if (i - 1 == 0) {
isChosen[0] = dp[0][c] > 0 ? 1 : 0;
}
}
for (int i = 0; i < items.length; i++) {
if (isChosen[i] == 1) {
System.out.print(items[i] + " ");
}
}
System.out.println();
}
private static void outPutMaxValue() {
System.out.println(dp[items.length - 1][capacity]);
}
}
public class Main {
private static int capacity = 10;
private static String[] items = new String[] { "a", "b", "c", "d", "e" };
private static int[] weight = new int[] { 2, 2, 6, 5, 4 };
private static int[] value = new int[] { 6, 3, 5, 4, 6 };
private static int[] dp = new int[capacity + 1];
private static int[][] path = new int[items.length][capacity];
public static void main(String[] args) {
decide();
outPutMaxValue();
outPutChosenItems();
}
private static void decide() {
for (int i = 0; i < items.length; i++) {
for (int j = capacity; j >= weight[i]; j--) {
if (dp[j] < dp[j - weight[i]] + value[i]) {
dp[j] = dp[j - weight[i]] + value[i];
path[i][j] = 1;
}
}
}
}
private static void outPutChosenItems() {
for (int i = items.length - 1, c = capacity; i >= 0 && c >= 0; i--) {
if (path[i][c] == 1) {
System.out.print(items[i] + " ");
c -= weight[i];
}
}
System.out.println();
}
private static void outPutMaxValue() {
System.out.println(dp[capacity]);
}
}
HDU2456:饭卡
如果购买一个商品前,卡上剩余金额>=5元,就一定可以购买成功(即使购买后卡上余额为负);否则无法购买(即使金额足够)。
某天,饭堂有n种菜出售,每种菜可购买一次。已知菜的价格和卡上余额,问最少可使卡上余额为多少?
输入:
第一行为整数n,表示菜的数量(n<=1000)
第二行包括n个正整数,表示每种菜的价格(不超过50)
第三行为正整数m,表示卡上的余额(m<=1000)
输出:
卡上最小余额
样例输入1:
1
50
5
样例输出1:
-45
样例输入2:
10
1 2 3 2 1 1 2 3 2 1
50
样例输出2:
32
分析:
每种菜只有买和不买2种情况,并且前面买了菜后剩余的钱影响后面的决策,所以是动态规划,而且是0-1背包。
什么时候余额最少呢?应该是余额最接近5时,选最贵的菜,余额最少。
比如只有5块钱,类似样例输入1那样买最贵的50块,这样就变最少了。
因为只有余额>=5块钱才能用,所以5块钱要保留下来。
更进一步地转化为0-1背包:保留余额中的5块钱,而另一部分钱(背包容量)就尽可能花到剩下最少(最贵的菜还不能买),最后再去买最贵的菜。
比如:有3种菜,价格分别是2, 3, 1,卡上余额为7。
保留5块钱,为了尽可能花完剩下的7-5=2块钱,就买了第1个菜,2块钱就花完了,最后用5块钱买最贵的那个3块钱的菜,这样最少余额为2。
import java.util.Arrays;
import java.util.Scanner;
public class Main {
// 背包容量
private static int rest;
private static int[] need;
private static int[][] dp;
public static void main(String[] args) {
Scanner scanner = new Scanner(new BufferedInputStream(System.in));
int n = scanner.nextInt();
need = new int[n];
for (int i = 0; i < n; i++) {
need[i] = scanner.nextInt();
}
int card = scanner.nextInt();
scanner.close();
// 如果卡余额不足5块钱,买不了菜
if (card < 5) {
System.out.println(card);
return;
}
// 方便后面选最贵菜
Arrays.sort(need);
// 分成5块钱和另外剩下的一部分钱
rest = card - 5;
dp = new int[n][rest + 1];
// 剩下的钱买菜
buy();
// 最后剩下的钱去买最贵的菜
System.out.println(card - dp[need.length - 2][rest] - need[need.length - 1]);
}
private static void buy() {
for (int j = 1; j <= rest; j++) {
// 最贵的菜要留到最后买,所以这里不买最贵的菜
for (int i = 0; i < need.length - 1; i++) {
if (j >= need[i]) {
if (i == 0) {
dp[i][j] = need[i];
continue;
}
dp[i][j] = Math.max(dp[i - 1][j], dp[i - 1][j - need[i]] + need[i]);
} else {
if (i == 0) {
dp[i][j] = 0;
continue;
}
dp[i][j] = dp[i - 1][j];
}
}
}
/*
for (int j = 0; j < need.length; j++) {
for (int i = 0; i <= rest; i++) {
System.out.print(dp[j][i] + " ");
}
System.out.println();
}
*/
}
}
import java.io.BufferedInputStream;
import java.util.Arrays;
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(new BufferedInputStream(System.in));
while (true) {
int n = scanner.nextInt();
if (n == 0) {
break;
}
int[] need = new int[n];
for (int i = 0; i < n; i++) {
need[i] = scanner.nextInt();
}
int card = scanner.nextInt();
if (card < 5) {
System.out.println(card);
continue;
}
Arrays.sort(need);
if (card == 5 || n == 1) {
System.out.println(card - need[n - 1]);
} else {
int rest = card - 5;
int[] dp = new int[rest + 1];
for (int i = 0; i < n - 1; i++) {
for (int j = rest; j >= need[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - need[i]] + need[i]);
}
}
System.out.println(card - dp[rest] - need[n - 1]);
}
}
}
}
HDU1171:Big Event in HDU
Nowadays, we all know that Computer College is the biggest department in HDU. But, maybe you don’t know that Computer College had ever been split into Computer College and Software College in 2002.
The splitting is absolutely a big event in HDU! At the same time, it is a trouble thing too. All facilities must go halves. First, all facilities are assessed, and two facilities are thought to be same if they have the same value. It is assumed that there is N (0
import java.io.BufferedInputStream;
import java.util.ArrayList;
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(new BufferedInputStream(System.in));
int n = scanner.nextInt();
while (n > 0) {
int sum = 0;
ArrayList<Integer> value = new ArrayList<>();
for (int i = 0, nums = 0, v = 0; i < n; i++) {
v = scanner.nextInt();
value.add(v);
nums = scanner.nextInt();
sum += v * nums;
while (nums != 1) {
value.add(v);
nums--;
}
}
int total = value.size();
Integer[] f = new Integer[total];
value.toArray(f);
int bag = sum >> 1;
int[] dp = new int[bag + 1];
for (int i = 0; i < total; i++) {
for (int j = bag; j >= f[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - f[i]] + f[i]);
}
}
System.out.println((sum - dp[bag]) + " " + dp[bag]);
n = scanner.nextInt();
}
}
}
HDU2602:Bone Collector
裸0-1背包
输入:
第一行为整数T,表示测试样例个数,每个样例三行
第二行包括两个整数N,V,分别表示骨头数量和背包体积(N<=1000,V<=1000)
第三行包括N个整数,表示每根骨头的价值
第四行包括N个整数,表示每根骨头的体积
输出:
最大价值
样例输入:
1
5 10
1 2 3 4 5
5 4 3 2 1
样例输出:
14
import java.io.BufferedInputStream;
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(new BufferedInputStream(System.in));
int n = scanner.nextInt();
if (n == 0) {
return;
}
for (; n > 0; n--) {
int nums = scanner.nextInt();
int bag = scanner.nextInt();
int[] value = new int[nums];
int[] volume = new int[nums];
for (int i = 0; i < nums; i++) {
value[i] = scanner.nextInt();
}
for (int i = 0; i < nums; i++) {
volume[i] = scanner.nextInt();
}
int[] dp = new int[bag + 1];
for (int i = 0; i < nums; i++) {
for (int j = bag; j >= volume[i]; j--) {
dp[j] = Math.max(dp[j], dp[j - volume[i]] + value[i]);
}
}
System.out.println(dp[bag]);
}
}
}
HDU2639:Bone Collector II
Today we are not desiring the maximum value of bones,but the K-th maximum value of the bones.NOTICE that,we considerate two ways that get the same value of bones are the same.That means,it will be a strictly decreasing sequence from the 1st maximum , 2nd maximum .. to the K-th maximum.
If the total number of different values is less than K,just ouput 0.
输入:
The first line contain a integer T , the number of cases.
Followed by T cases , each case three lines , the first line contain two integer N , V, K(N <= 100 , V <= 1000 , K <= 30)representing the number of bones and the volume of his bag and the K we need. And the second line contain N integers representing the value of each bone. The third line contain N integers representing the volume of each bone.
输出:
One integer per line representing the K-th maximum of the total value (this number will be less than 231).
样例输入:
3
5 10 2
1 2 3 4 5
5 4 3 2 1
5 10 12
1 2 3 4 5
5 4 3 2 1
5 10 16
1 2 3 4 5
5 4 3 2 1
样例输出:
12
2
0
分析:
这次是找第K个最优解,上一题是第1个最优。
第K个最优解:
在0-1背包中,状态数组是f[c],表示在容量为c时的最优决策,而不是最优的并没有保存下来。
比如:f[c] = max{1, 2}中,最优f[c] = 2,而1被舍弃了。
而现在,我不仅想知道最优决策,我还想知道稍微差一点的决策,即第2决策、第3决策……排个名。
f[c]我们可以看做是f[c][1]这样的二维数组,它的第二维只有一个元素,也就是最优决策。
现在我们增大第二维,比如:f[c][3],表示不仅保留了最优解1,次解2,3也保留下来了。
即在决策过程中,不把前一个状态的最优解扔掉,而是保存下来。
import java.io.BufferedInputStream;
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(new BufferedInputStream(System.in));
int n = scanner.nextInt();
if (n == 0) {
return;
}
for (; n > 0; n--) {
int nums = scanner.nextInt();
int bag = scanner.nextInt();
int k = scanner.nextInt();
int[] value = new int[nums];
int[] volume = new int[nums];
for (int i = 0; i < nums; i++) {
value[i] = scanner.nextInt();
}
for (int i = 0; i < nums; i++) {
volume[i] = scanner.nextInt();
}
int[][] dp = new int[bag + 1][31];
int[] a = new int[31], b = new int[31];
for (int i = 0; i < nums; i++) {
for (int j = bag; j >= volume[i]; j--) {
for (int t = 1; t <= k; t++) {
// 把解都保存起来
a[t] = dp[j][t];
b[t] = dp[j - volume[i]][t] + value[i];
}
a[k + 1] = b[k + 1] = -1;
int t = 1, ai = 1, bi = 1;
// 下面的循环相当于求a和b并集,也就是所有的可能解
while (t <= k && (a[ai] <=k || b[bi] <=k)) {
if (a[ai] > b[bi]) {
dp[j][t] = a[ai];
ai++;
} else {
dp[j][t] = b[bi];
bi++;
}
if (dp[j][t] != dp[j][t - 1]) {
t++;
}
}
}
System.out.println(dp[bag][k]);
}
}
}
完全背包
import static java.lang.System.out;
public class Main {
private static int capacity = 10;
private static String[] items = new String[] { "a", "b", "c", "d", "e" };
private static int[] weight = new int[] { 2, 2, 6, 5, 4 };
private static int[] value = new int[] { 6, 3, 5, 4, 6 };
private static int[] dp = new int[capacity + 1];
public static void main(String[] args) {
decide();
outPutMaxValue();
}
private static void decide() {
for (int i = 0; i < items.length; i++) {
for (int j = weight[i]; j <= capacity; j++) {
dp[j] = Math.max(dp[j], dp[j - weight[i]] + value[i]);
}
}
}
private static void outPutMaxValue() {
out.println(dp[capacity]);
}
}
回溯法
基本概念
类似枚举的搜索尝试过程,主要是在搜索尝试过程中寻找问题的解,当发现已不满足求解条件时,就“回溯”返回,尝试别的路径。
设想把你放在一个迷宫里,想要走出迷宫,最直接的办法是什么呢?
没错,试。先选一条路走起,走不通就往回退尝试别的路,走不通继续往回退,直到找到出口或所有路都试过走不出去为止。
回顾深度优先搜索
左图是一个无向图,从点1开始的DFS过程可能是下图右的情况,其中实线表示搜索时的路径,虚线表示返回时的路径:
基本思想与策略
在包含问题的所有解的解空间树中,按照深度优先搜索的策略,从根结点出发深度探索解空间树。
当探索到某一结点时,先判断该结点是否包含问题的解:
- 包含:从该结点出发继续探索下去。
- 不包含:逐层向其祖先结点回溯。
回溯法就是对隐式图的深度优先搜索算法。
- 求问题的所有解时,要回溯到根,且根结点的所有可行的子树都已被搜索遍才结束。
- 求任一个解时,只要搜索到问题的一个解就可以结束。
求解的基本步骤
- 针对所给问题,定义问题的解空间;
- 确定易于搜索的解空间结构;
- 以深度优先方式搜索解空间,并在搜索过程中用剪枝函数避免无效搜索。
子集树与排列树
下面的两棵解空间树是回溯法解题时常遇到的两类典型的解空间树。
(1)从n个元素的集合S中找出满足某种性质的子集(相应的解空间树称为子集树)。
如:0-1背包问题(如上图)所相应的解空间树就是子集树,这类子集树通常有 2n 2 n 个叶结点,其结点总个数为 2n+1−1 2 n + 1 − 1 。遍历子集树的算法需时间O( 2n 2 n )。
(2)确定n个元素满足某种性质的排列(相应的解空间树称为排列树)。
如:旅行售货员问题:
某售货员要到4个城市去推销商品,已知各城市之间的路程,请问他应该如何选定一条从城市1出发,经过每个城市一遍,最后回到城市1的路线,使得总的周游路程最小?
该题的解空间树就是排列树,这类排列树通常有n!个叶结点。遍历子集树的算法需时间O(n!)。
基本框架
class BackTrack {
// 原空间
public static int[] originalCurrentAnswer;
public BackTrack(int[] originalCurrentAnswer) {
BackTrack.originalCurrentAnswer = originalCurrentAnswer;
}
/*
* 回溯法
* @param currentAnswer 当前解空间
* @param currentDepth 当前搜索深度
* @param args 其他参数
*/
public static void backTrack(int[] currentAnswer, int currentDepth, String[] args) {
// 判断当前的部分解向量
// currentAnswer[1...currentDepth]是否是一个符合条件的解
if (isAnswer(currentAnswer, currentDepth, args)) {
// 对于符合条件的解进行处理,通常是输出、计数等
dealAnswer(currentAnswer, currentDepth, args);
} else {
// 根据当前状态,构造这一步可能的答案
int[] possibleAnswer = buildPossibleAnswer(currentAnswer, currentDepth, args);
int possibleAnswerLength = possibleAnswer.length;
for (int i = 0; i < possibleAnswerLength; i++) {
currentAnswer[currentDepth] = possibleAnswer[i];
// 前者将采取的选择更新到原始数据结构上,后者把这一行为撤销。
make(currentAnswer, currentDepth, args);
// 剪枝
if (pruning(currentAnswer, currentDepth, args)) {
backTrack(currentAnswer, currentDepth + 1, args);
}
unmake(currentAnswer, currentDepth, args);
}
}
}
}
经典题型
求集合的所有子集
对于每个元素都有选和不选两种路径(1选,0不选)
解空间就是集合选与不选的状态,这里初始是{0,0,0},也是问题的一个解,空集。
这里没有剪枝(具体问题具体加),直接就是深度搜索,直到当前深度等于集合的大小,就可以输出了。
class BackTrack {
public static int[] originalCurrentAnswer;
public BackTrack(int[] originalCurrentAnswer) {
BackTrack.originalCurrentAnswer = originalCurrentAnswer;
}
public static void backTrack(int[] currentAnswer, int currentDepth) {
if (isAnswer(currentAnswer, currentDepth)) {
dealAnswer(currentAnswer, currentDepth);
} else {
int[] possibleAnswer = buildPossibleAnswer(currentAnswer, currentDepth);
int possibleAnswerLength = possibleAnswer.length;
for (int i = 0; i < possibleAnswerLength; i++) {
currentAnswer[currentDepth] = possibleAnswer[i];
backTrack(currentAnswer, currentDepth + 1);
}
}
}
private static boolean isAnswer(int[] currentAnswer, int currentDepth) {
// 当前搜索深度已达到原空间的深度
return currentDepth == originalCurrentAnswer.length;
}
/*
* 按位对应
* 如集合A={a,b}
* 对于任意一个元素,在每个子集中,要么存在,要么不存在
* 映射为子集:
* (1,1)->(a,b) (1,0)->(a) (0,1)->(b) (0,0)->空集
*/
private static int[] buildPossibleAnswer(int[] currentAnswer, int currentDepth) {
// 选or不选即 1, 0
int[] isChosen = { 1, 0 };
return isChosen;
}
private static void dealAnswer(int[] currentAnswer, int currentDepth) {
for (int i = 0; i < currentDepth; i++) {
// 如果选,则输出
if (currentAnswer[i] == 1) {
System.out.print(originalCurrentAnswer[i] + " ");
}
}
System.out.println();
}
}
求全排列
交换两个位置的值,然后进入下一个深度,直到当前深度达到序列的长度。
public static void backTrack(char[] currentAnswer, int currentDepth, int length) {
if (currentDepth == length) {
System.out.println(new String(currentAnswer));
} else {
for (int i = currentDepth; i < length; i++) {
if (isUnique(currentAnswer, currentDepth, i)) { // 剪枝(去重)
swap(currentAnswer, currentDepth, i); // 交换元素
backTrack(currentAnswer, currentDepth + 1, length);
swap(currentAnswer, currentDepth, i); // 还原
}
}
}
}
private static boolean isUnique(char[] currentAnswer, int currentDepth, int k) {
for (int i = currentDepth; i < length; i++)
if (currentAnswer[i] == currentAnswer[k])
return false;
return true;
}
private static void swap(char[] currentAnswer, int m, int n) {
char tmp = currentAnswer[n];
currentAnswer[n] = currentAnswer[m];
currentAnswer[m] = tmp;
}
八皇后问题
将八个皇后摆在一张8*8的国际象棋棋盘上,使每个皇后都无法吃掉别的皇后,一共有多少种摆法?
(在国际象棋中,皇后是最强大的一枚棋子,可以吃掉与其在同行、同列和同斜线的敌方棋子)
一种可能的情况:
1、从空棋盘起,逐行放置棋子。
2、每在一个布局中放下一个棋子,即推演到下一个新的布局。
3、如果当前行上没有可合法放置棋子的位置,则回溯到上一行,重新布放上一行的棋子。
class BackTrack {
public static int count = 0;
public static int DIM;
public BackTrack(int DIM) {
BackTrack.DIM = DIM;
}
public static void backTrack(int[][] chess, int row, int[] isColumnCollision) {
if (isAnswer(row)) {
dealAnswer(chess);
} else {
for (int i = 0; i < DIM; i++) {
// 同列存在皇后
// 或同对角线存在皇后
// 因为每次都是新的一行,所以不用检查行是否存在皇后
if (isColumnCollision[i] == 1 || isDiagonalCollision(chess, row, i))
continue;
chess[row][i] = 1;
isColumnCollision[i] = 1;
backTrack(chess, row + 1, isColumnCollision);
chess[row][i] = 0;
isColumnCollision[i] = 0;
}
}
}
// 同对角线是否冲突
private static boolean isDiagonalCollision(int[][] chess, int x, int y) {
// 以当前皇后坐标为起点,到左上角的对角线
for (int i = x - 1, j = y - 1; i >= 0 && j >= 0; i--, j--)
if (chess[i][j] == 1)
return true;
// 到右上角的对角线
for (int i = x - 1, j = y + 1; i >= 0 && j < DIM; i--, j++)
if (chess[i][j] == 1)
return true;
return false;
}
private static boolean isAnswer(int row) {
return row == DIM;
}
private static void dealAnswer(int[][] chess) {
count += 1;
for (int i = 0; i < DIM; i++) {
for (int j = 0; j < DIM; j++) {
System.out.print(chess[i][j] + " ");
}
System.out.println();
}
System.out.println();
}
}
火力网问题
在一个n*n的网格里,每个网格可能为“墙壁”(用x表示)和“街道”(用o表示)。现在在街道放置碉堡,每个碉堡可以向上下左右四个方向开火,子弹射程无限远。墙壁可以阻挡子弹。问最多能放置多少个碉堡,使它们彼此不会互相摧毁。
如下图,墙壁用黑正方形表示,街道用空白正方形表示,圆球就代表碉堡。
1、2、3可行,4、5不可行。因为4、5的两个碉堡同行、同列,他们会互相攻击。
类似八皇后问题
贪心法
基本概念
所谓贪心是指:在对问题求解时,总是做出在当前看来是最好的选择。
也就是说,不从整体最优上加以考虑,所做出的仅是在某种意义上的局部最优解。
贪心法没有固定的算法框架,算法设计的关键是贪心策略的选择。
注意:
- 贪心算法不是对所有问题都能得到整体最优解。
- 选择的贪心策略必须具备无后效性,即某个状态以后的过程不会影响以前的状态,只与当前状态有关。
所以对所采用的贪心策略一定要仔细分析其是否满足无后效性。
求解的基本步骤
- 建立数学模型来描述问题。
- 把求解的问题分成若干个子问题。
- 对每个子问题求解,得到子问题的局部最优解。
- 把子问题的局部最优解合成原来解问题的一个解。
贪心策略适用的前提是:局部最优策略能导致产生全局最优解。
实际上,贪心算法适用的情况很少。
一般,对一个问题分析是否适用于贪心算法,可以先选择该问题下的几个实际数据进行分析,就可做出判断。
基本框架
从问题的某一初始解出发;
while (能朝给定总目标前进一步) {
利用可行的决策,求出可行解的一个解元素;
}
由所有解元素组合成问题的一个可行解;
例题分析
下面是一个可以试用贪心算法解的题目,贪心解的确不错,可惜不是最优解。
[0-1背包]
有一个背包,背包容量是M=150。有7个物品,物品可以分割成任意大小。要求尽可能让装入背包中的物品总价值最大,但不能超过总容量。
物品 A B C D E F G
重量 35 30 60 50 40 10 25
价值 10 40 30 50 35 40 30
分析:
目标函数: ∑pi最大
约束条件是装入的物品总重量不超过背包容量:∑wi<=M( M=150)
1. 根据贪心的策略,每次挑选价值最大的物品装入背包,得到的结果是否最优?
2. 每次挑选所占重量最小的物品装入是否能得到最优解?
3. 每次选取单位重量价值最大的物品,成为解本题的策略。
值得注意的是,贪心算法并不是完全不可以使用,贪心策略一旦经过证明成立后,它就是一种高效的算法。
贪心算法还是很常见的算法之一,这是由于它简单易行,构造贪心策略不是很困难。
可惜的是,它需要证明后才能真正运用到题目的算法中。
一般来说,贪心算法的证明围绕着:整个问题的最优解一定由在贪心策略中存在的子问题的最优解得来的。
对于例题中的3种贪心策略,都是无法成立(无法被证明)的,解释如下:
(1)贪心策略:选取价值最大者。反例:
W=30
物品:A B C
重量:28 12 12
价值:30 20 20
根据策略,首先选取物品A,接下来就无法再选取了,可是,选取B、C则更好。
(2)贪心策略:选取重量最小。它的反例与第一种策略的反例差不多。
(3)贪心策略:选取单位重量价值最大的物品。反例:
W=30
物品:A B C
重量:28 20 10
价值:28 20 10
根据策略,三种物品单位重量价值一样,程序无法依据现有策略作出判断,如果选择A,则答案错误。
分治法
基本概念
字面上的解释是“分而治之”,就是把一个复杂的问题分成两个或更多的相同或相似的子问题,再把子问题分成更小的子问题……直到最后子问题可以简单的直接求解,原问题的解即子问题的解的合并。
这个技巧是很多高效算法的基础,如排序算法(快速排序,归并排序),傅立叶变换(快速傅立叶变换)……
任何一个可以用计算机求解的问题所需的计算时间都与其规模有关。
问题的规模越小,越容易直接求解,解题所需的计算时间也越少。
例如,对于n个元素的排序问题,当n=1时,不需任何计算。n=2时,只要作一次比较即可排好序。n=3时只要作3次比较即可,…。而当n较大时,问题就不那么容易处理了。要想直接解决一个规模较大的问题,有时是相当困难的。
基本思想与策略
将一个难以直接解决的大问题,分割成一些规模较小的相同问题,以便各个击破,分而治之。
策略:
对于一个规模为n的问题,若该问题可以容易地解决(比如说规模n较小)则直接解决,否则将其分解为k个规模较小的子问题,这些子问题互相独立且与原问题形式相同,递归地解这些子问题,然后将各子问题的解合并得到原问题的解。
如果原问题可分割成k个子问题,1
适用情况
分治法所能解决的问题一般具有以下几个特征:
1. 该问题的规模缩小到一定的程度就可以容易地解决
2. 该问题可以分解为若干个规模较小的相同问题,即该问题具有最优子结构性质。
3. 利用该问题分解出的子问题的解可以合并为该问题的解;
4. 该问题所分解出的各个子问题是相互独立的,即子问题之间不包含公共的子子问题。
第1条特征是绝大多数问题都可以满足的,因为问题的计算复杂性一般是随着问题规模的增加而增加;
第2条特征是应用分治法的前提它也是大多数问题可以满足的,此特征反映了递归思想的应用;
第3条特征是关键,能否利用分治法完全取决于问题是否具有第三条特征,如果具备了第一条和第二条特征,而不具备第三条特征,则可以考虑用贪心法或动态规划法。
第4条特征涉及到分治法的效率,如果各子问题是不独立的则分治法要做许多不必要的工作,重复地解公共的子问题,此时虽然可用分治法,但一般用动态规划法较好。
求解的基本步骤
实际上就是类似于数学归纳法,找到解决本问题的求解方程公式,然后根据方程公式设计递归程序。
- 先找到最小问题规模时的求解方法
- 然后考虑随着问题规模增大时的求解方法
- 找到求解的递归函数式后(各种规模或因子),设计递归程序即可。
分治法在每一层递归上都有三个步骤:
- 分解:将原问题分解为若干个规模较小,相互独立,与原问题形式相同的子问题;
- 解决:若子问题规模较小而容易被解决则直接解,否则递归地解各个子问题;
- 合并:将各个子问题的解合并为原问题的解。
Divide-and-Conquer(P)
1. if |P|≤n0
2. then return(ADHOC(P))
3. 将P分解为较小的子问题 P1 ,P2 ,…,Pk
4. for i←1 to k
5. do yi ← Divide-and-Conquer(Pi) △ 递归解决Pi
6. T ← MERGE(y1,y2,…,yk) △ 合并子问题
7. return(T)
其中|P|表示问题P的规模;n0为一阈值,表示当问题P的规模不超过n0时,问题已容易直接解出,不必再继续分解。
ADHOC(P)是该分治法中的基本子算法,用于直接解小规模的问题P。因此,当P的规模不超过n0时直接用算法ADHOC(P)求解。
算法MERGE(y1,y2,…,yk)是该分治法中的合并子算法,用于将P的子问题P1 ,P2 ,…,Pk的相应的解y1,y2,…,yk合并为P的解。
经典题型
- 二分搜索
- 大整数乘法
- Strassen矩阵乘法
- 棋盘覆盖
- 合并排序
- 快速排序
- 线性时间选择
- 最接近点对问题
- 循环赛日程表
- 汉诺塔
分支限界法
基本概念
类似于回溯法,也是一种在问题的解空间树T上搜索问题解的算法。但在一般情况下,分支限界法与回溯法的求解目标不同。回溯法的求解目标是找出T中满足约束条件的所有解,而分支限界法的求解目标则是找出满足约束条件的一个解,或是在满足约束条件的解中找出使某一目标函数值达到极大或极小的解,即在某种意义下的最优解。
(1)分支搜索算法
所谓“分支”就是采用广度优先的策略,依次搜索E-结点的所有分支,也就是所有相邻结点,抛弃不满足约束条件的结点,其余结点加入活结点表。然后从表中选择一个结点作为下一个E-结点,继续搜索。
选择下一个E-结点的方式不同,则会有几种不同的分支搜索方式:
1. FIFO搜索
2. LIFO搜索
3. 优先队列式搜索
(2)分支限界搜索算法
基本思想与策略
由于求解目标不同,导致分支限界法与回溯法在解空间树T上的搜索方式也不相同。
回溯法以深度优先的方式搜索解空间树T,而分支限界法则以广度优先或以最小耗费优先的方式搜索解空间树T。
支限界法的搜索策略是:在扩展结点处,先生成其所有的儿子结点(分支),然后再从当前的活结点表中选择下一个扩展对点。
为了有效地选择下一扩展结点,以加速搜索的进程,在每一活结点处,计算一个函数值(限界),并根据这些已计算出的函数值,从当前活结点表中选择一个最有利的结点作为扩展结点,使搜索朝着解空间树上有最优解的分支推进,以便尽快地找出一个最优解。
分支限界法常以广度优先或以最小耗费(最大效益)优先的方式搜索问题的解空间树。
问题的解空间树是表示问题解空间的一棵有序树,常见的有子集树和排列树。
在搜索问题的解空间树时,分支限界法与回溯法对当前扩展结点所使用的扩展方式不同。
在分支限界法中,每一个活结点只有一次机会成为扩展结点。
活结点一旦成为扩展结点,就一次性产生其所有儿子结点。
在这些儿子结点中,那些导致不可行解或导致非最优解的儿子结点被舍弃,其余儿子结点被子加入活结点表中。
此后,从活结点表中取下一结点成为当前扩展结点,并重复上述结点扩展过程。
这个过程一直持续到找到所求的解或活结点表为空时为止。
回溯法和分支限界法的一些区别
有一些问题其实无论用回溯法还是分支限界法都可以得到很好的解决,但是另外一些则不然。也许我们需要具体一些的分析——到底何时使用分支限界而何时使用回溯呢?
回溯法和分支限界法的一些区别:
- 方法对解空间树的搜索方式存储结点的常用数据结构结点存储特性常用应用
- 回溯法深度优先搜索堆栈活结点的所有可行子结点被遍历后才被从栈中弹出找出满足约束条件的所有解
- 分支限界法广度优先或最小消耗优先搜索队列、优先队列每个结点只有一次成为活结点的机会找出满足约束条件的一个解或特定意义下的最优解