动态规划理论
什么样的问题适合使用动态规划求解呢?其需要满足“一个模型三个特征”。
一个模型指的是多阶段决策最优解模型。我们一般使用动态规划来解决最优解问题,解决问题的过程需要经过多个决策阶段,每个决策阶段包含一组状态,然后选择一组最优的决策序列,以得到我们期望的最优解。
三个特种主要包括:最优子结构,无后效性,重复子问题。
1、最优子结构指的是,一个问题的最优解,可以分解为多个子问题的最优解,通过子问题的最优解,可以求解出问题的最优解。对应到动态规划的问题模型上,就是后面阶段的状态,可以通过前面阶段的状态推倒出来。
2、无后效性指的是,推倒后面阶段的状态的值的时候,我们只关心前面阶段的状态的值,而不关心其推倒过程;某阶段的状态值一旦确定,就不受后面阶段的状态的推倒过程的影响。
3、重复子问题指的是,不同的决策序列到达某个阶段时,可能会产生相同的状态值。
例子
假如我们有一个n*n的矩阵”uint32_t board[n][n]”,每个矩阵的值都是一个正整数。棋子最开始在矩阵的左上角board[0][0],棋子每次只能向右或者向下走一步,我们将棋子每走一步经过的位置的值加起来得到路径长度,那么棋子从矩阵的左上角board[0][0]走到矩阵的右下角board[n-1][n-1]的最短路径长度是多少以及最短路径对应的走法?
回溯算法求解
这个问题最简单的解法是使用回溯算法来求解,其代码如下:
const uint32_t BOARD_SIZE = 4;
uint32_t board[4][4] = {
{1,12,30,4},
{5,9,17,8},
{3,9,11,12},
{13,14,15,16},
};
uint64_t min_path[4][4] = {
{-1,-1,-1,-1 },
{-1,-1,-1,-1 },
{-1,-1,-1,-1 },
{-1,-1,-1,-1 }
};
void get_min_path_len(uint32_t x, uint32_t y, uint64_t& path_len)
{
uint64_t org_path_len = path_len;
path_len += board[x][y];
if (min_path[x][y] > path_len)
{
min_path[x][y] = path_len;
}
if (x == BOARD_SIZE - 1 && y == BOARD_SIZE - 1)
{
return;
}
if (x < BOARD_SIZE - 1 && y < BOARD_SIZE - 1)//可以向右与向下
{
get_min_path_len(x + 1, y, path_len);
get_min_path_len(x, y + 1, path_len);
}
else if (x < BOARD_SIZE - 1)//可以向右
{
get_min_path_len(x + 1, y, path_len);
}
else if (y < BOARD_SIZE - 1)//可以向下
{
get_min_path_len(x, y + 1, path_len);
}
path_len = org_path_len;
}
int main()
{
uint64_t path_len = 0;
get_min_path_len(0, 0, path_len);
printf("min_path: %lu, path is:\n", min_path[BOARD_SIZE - 1][BOARD_SIZE - 1]);
uint32_t x = BOARD_SIZE - 1;
uint32_t y = BOARD_SIZE - 1;
for (uint32_t i = 0; i < 2 * (BOARD_SIZE - 1); ++i)
{
printf("(%d,%d), ", x, y);
if (x > 0 && min_path[x - 1][y] + board[x][y] == min_path[x][y])
{
x -= 1;
}
else if (y > 0 && min_path[x][y - 1] + board[x][y] == min_path[x][y])
{
y -= 1;
}
}
printf("\n");
return 0;
}
是否可以用动态规划解法
1、棋子从右上角的board[0][0]走到左下角的board[n-1][n-1],每次都可以选择向右或者向下,总共有2*(n-1)种走法,每一个走法对应一个阶段,总共有2*(n-1)个阶段。假设某个阶段走到了位置board[x][y]那么其对应的状态为min_path[x][y],min_path[x][y]为走到board[x][y]的最短路径。可以看出其符合多阶段决策模型。
2、某个阶段的最短路径可以拆分成min_path[x-1][y],min_path[x][y-1] 的最小值加上位置board[x][y]的值。也就是公式min_path[x][y]=min(min_path[x-1][y],min_path[x][y-1])+board[x][y]。可以看出任何一个阶段的状态min_path[x][y],可以拆分成到达(x-1,y)或者(x,y-1)的最短路径,因此其满足最有子结构。
3、显然着推导某个阶段状态值min_path[x][y],我们只关心到达这个状态的前2个阶段的状态值min_path[x-1][y]与min_path[x][y-1],并不关心其推导过程;显然着某个阶段状态值min_path[x][y]一旦确定,后续阶段的状态值的推导过程,并不会改变这个阶段状态值min_path[x][y]的值。因此其满足无后效性。
4、最后再看看其是否存在重复子问题。上面的回溯算法的递归树如下:
可以看出到达状态min_path[2][2]有多种走法可以到达,所以其存在重复子问题。
状态转移填表法
状态转移表法一般是用回溯法写出递归公式,然后根据递归公式画出递归树,然后根据递归树查看是否有重复子问题,如果有的话,可以根据递归树画出状态转移表,然后根据状态转移表写出动态规划代码。
上面的例子中,状态转移表如下
1、从左到右填充第一行的最短路径
uint64_t min_path[4][4] = {
{1,13,43,47 },
{-1,-1,-1,-1 },
{-1,-1,-1,-1 },
{-1,-1,-1,-1 }
};
2、从左到右填充第二行的最短路径
uint64_t min_path[4][4] = {
{1,13,43,47 },
{6,15,32,40 },
{-1,-1,-1,-1 },
{-1,-1,-1,-1 }
};
3、从左到右填充第三行的最短路径
uint64_t min_path[4][4] = {
{1,13,43,47 },
{6,15,32,40 },
{9,18,29,31 },
{-1,-1,-1,-1 }
};
4、从左到右填充第四行的最短路径
uint64_t min_path[4][4] = {
{1,13,43,47 },
{6,15,32,40 },
{9,18,29,41 },
{22,32,44,57 }
};
从上面的天状态转移表的过程可以写出代码:
const uint32_t BOARD_SIZE = 4;
uint32_t board[4][4] = {
{1,12,30,4},
{5,9,17,8},
{3,9,11,12},
{13,14,15,16},
};
uint64_t min_path[4][4] = {
{-1,-1,-1,-1 },
{-1,-1,-1,-1 },
{-1,-1,-1,-1 },
{-1,-1,-1,-1 }
};
void get_min_path_len()
{
for (uint32_t i = 0; i != BOARD_SIZE; ++i)
{
for (uint32_t j = 0; j != BOARD_SIZE; ++j)
{
if (i > 0)
{
if (j > 0)
{
min_path[i][j] = board[i][j] + std::min(min_path[i-1][j], min_path[i][j-1]);
}
else
{
min_path[i][j] = board[i][j] + min_path[i - 1][j];
}
}
else
{
if (j > 0)
{
min_path[i][j] = board[i][j] + min_path[i][j - 1];
}
else
{
min_path[i][j] = board[i][j];
}
}
}
}
}
int main()
{
get_min_path_len();
printf("min_path: %lu, path is:\n", min_path[BOARD_SIZE - 1][BOARD_SIZE - 1]);
uint32_t x = BOARD_SIZE - 1;
uint32_t y = BOARD_SIZE - 1;
for (uint32_t i = 0; i < 2 * (BOARD_SIZE - 1); ++i)
{
printf("(%d,%d), ", x, y);
if (x > 0 && min_path[x - 1][y] + board[x][y] == min_path[x][y])
{
x -= 1;
}
else if (y > 0 && min_path[x][y - 1] + board[x][y] == min_path[x][y])
{
y -= 1;
}
}
printf("\n");
return 0;
}
状态转移方程法
状态转移方程法是通过分析如何将问题的最优解分解成多个子问题的最优解,然后找到终止条件,最终得出递归公式。然后根据递归公式写出递归代码,注意需要添加备忘录,以解决重复子问题。
那么对于上一个例子,其递归公式如下:
min_path[x][y] = board[x][y] + min(min_path[x-1][y],min_path[x][y-1])
如果x==0 且 y==0,则min_path[x][y] = board[x][y]
那么其递归代码如下:
const uint32_t BOARD_SIZE = 4;
uint32_t board[4][4] = {
{1,12,30,4},
{5,9,17,8},
{3,9,11,12},
{13,14,15,16},
};
uint64_t min_path[4][4] = {
{-1,-1,-1,-1 },
{-1,-1,-1,-1 },
{-1,-1,-1,-1 },
{-1,-1,-1,-1 }
};
uint64_t get_min_path_len(int x, int y)
{
if (min_path[x][y] != (uint64_t)(-1))
{
return min_path[x][y];
}
if (x > 0)
{
if (y > 0)
{
min_path[x][y] = board[x][y] + std::min(get_min_path_len(x - 1, y), get_min_path_len(x, y - 1));
}
else
{
min_path[x][y] = board[x][y] + get_min_path_len(x - 1, y);
}
}
else
{
if (y > 0)
{
min_path[x][y] = board[x][y] + get_min_path_len(x, y - 1);
}
else
{
min_path[x][y] = board[x][y];
}
}
return min_path[x][y];
}
int main()
{
get_min_path_len(3,3);
printf("min_path: %lu, path is:\n", min_path[BOARD_SIZE - 1][BOARD_SIZE - 1]);
uint32_t x = BOARD_SIZE - 1;
uint32_t y = BOARD_SIZE - 1;
for (uint32_t i = 0; i < 2 * (BOARD_SIZE - 1); ++i)
{
printf("(%d,%d), ", x, y);
if (x > 0 && min_path[x - 1][y] + board[x][y] == min_path[x][y])
{
x -= 1;
}
else if (y > 0 && min_path[x][y - 1] + board[x][y] == min_path[x][y])
{
y -= 1;
}
}
printf("\n");
return 0;
}
例子二
比如我们有v1,v2,v3…vn种不同币值的硬币(单位为元),每种币值的硬币都足够多,请问支付w元,至少需要多少种硬币,比如我们有1元、3元与5元三种硬币,那么支付9元至少需要3个3元的硬币,支付10元至少需要2个5元的硬币。
回溯法
const uint32_t MAX_NUM = 3;
const uint32_t v[] = { 1,3,5 };
uint32_t min_num = -1;
void coin_num(uint32_t index, uint32_t cur_coin_num, uint32_t left_w)
{
if (left_w == 0)
{
if (cur_coin_num < min_num)
{
min_num = cur_coin_num;
return;
}
}
if (index == 0)
{
if (left_w % v[index] == 0)
{
cur_coin_num += left_w / v[index];
if (cur_coin_num < min_num)
{
min_num = cur_coin_num;
}
}
return;
}
uint32_t select_num = left_w / v[index] + 1;
uint32_t org_coin_num = cur_coin_num;
for (uint32_t i = 0; i != select_num; ++i)
{
coin_num(index - 1, org_coin_num + i, left_w);
left_w -= v[index];
}
}
int main()
{
//{ 1,3,5 };
min_num = -1;
coin_num(MAX_NUM - 1, 0, 9);
printf("min_num = %d\n", min_num);
min_num = -1;
coin_num(MAX_NUM - 1, 0, 10);
printf("min_num = %d\n", min_num);
min_num = -1;
coin_num(MAX_NUM - 1, 0, 11);
printf("min_num = %d\n", min_num);
min_num = -1;
coin_num(MAX_NUM - 1, 0, 13);
printf("min_num = %d\n", min_num);
return 0;
}
状态转移表法
const int MAX_NUM = 3;
const int v[] = { 1,3,5 };
int min_num_status[100];//下标表示剩余支付多少元,值表示最少支付硬币个数
void coin_num(int w)
{
for (int j = w; j >= 0; --j)
{
min_num_status[j] = -1;
}
for (int i = MAX_NUM - 1; i >= 0; --i)
{
for (int j = w; j >= 0; --j)
{
if (j != w && min_num_status[j] == -1)//没有支付过且不是w元
{
continue;
}
int num = j / v[i];
for (int k = 1, col = j - v[i]; k <= num; ++k, col -= v[i])
{
if (min_num_status[col] == -1)
{
min_num_status[col] = min_num_status[col + v[i]] == -1 ? k : min_num_status[col + v[i]] + k;
}
else
{
min_num_status[col] = min(min_num_status[col + v[i]] == -1 ? k : min_num_status[col + v[i]] + k, min_num_status[col]);
}
}
}
}
printf("w = %d, min_coin:%d\n", w, min_num_status[0]);
}
int main()
{
//{ 1,3,5 };
coin_num(9);
coin_num(10);
coin_num(11);
coin_num(13);
return 0;
}
状态转移方程法
const int MAX_NUM = 3;
const int v[] = { 1,3,5 };
int min_num_status[100];//数组下标支付现金,值表示支付的最少硬币个数
int coin_num(int index, int left_w)
{
if (min_num_status[left_w] != -1)
{
return min_num_status[left_w];
}
if (index == 0)
{
return left_w % v[index] == 0 ? left_w / v[index] : -1;
}
int org_left_w = left_w;
int num = left_w / v[index];
int cur_min;
for (int i = 0; i <= num; ++i, left_w -= v[index])
{
cur_min = coin_num(index - 1, left_w);
if (cur_min != -1)
{
cur_min = cur_min + i;
if (min_num_status[org_left_w] == -1)
{
min_num_status[org_left_w] = cur_min;
}
else if (min_num_status[org_left_w] > cur_min)
{
min_num_status[org_left_w] = cur_min;
}
}
}
return min_num_status[org_left_w];
}
void reset_status(uint32_t w)
{
for (uint32_t i = 0; i <= w; ++i)
{
min_num_status[i] = -1;
}
}
int main()
{
//{ 1,3,5 };
uint32_t w = 9;
reset_status(w);
int min_num = coin_num(MAX_NUM - 1, w);
printf("w = %d, min_num = %d\n", w, min_num);
w = 10;
reset_status(w);
min_num = coin_num(MAX_NUM - 1, w);
printf("w = %d, min_num = %d\n", w, min_num);
w = 11;
reset_status(w);
min_num = coin_num(MAX_NUM - 1, w);
printf("w = %d, min_num = %d\n", w, min_num);
w = 13;
reset_status(w);
min_num = coin_num(MAX_NUM - 1, w);
printf("w = %d, min_num = %d\n", w, min_num);
return 0;
}
总结
需要说明的是有些动态规划问题适合使用“状态转移表法”解决,有些适合使用“状态转移方程法”解决,有些两种方法都适合。
动态规划与回溯法、贪心法的区别
1、贪心法是动态规划的一个特例,其与动态规划的最大的区别在于贪心选择性,所谓的贪心选择性指的是通过局部的最优选择,可以得出全局的最优选择,对结果贡献越大的决策,优先选择。其并不关心是否有重复子问题。
2、回溯法就是穷举所有的解法,然后选择最优解。二回溯法往往存在大量的重复子问题,为此不是高效的算法。