算法基础

295 阅读31分钟

第一章 基础算法

在面试、笔试、机考等比较常见的基础算法知识,以及算法模板代码。 注:笔记是按照acwing上的算法基础课所整理的笔记。

1.1 排序

排序的算法有很多中,这里只举了速度较快,比较常用的快排和归并。

快速排序 快速排序的要点: 1、找准基准点(poivt),一般情况下选择 l+r >> 1(中间索引位), 或者最左边、最右边; 2、交换,采用双指针i,j进行交换,将比poivt小的a[i]与比poivt大的 a[j]进行交换; 3、注意点,因为是两个双指针向中间进行移动,所以要从数组的两端进 行移动,i = i - 1; j = j + 1; 4、递归左边、右边排序

void quick_sort(int *a,int l,int r)
{
    if(l >= r) return;
    int i = l - 1;
    int j = r + 1;
    int poivt = a[l + r >> 1];
    while(i < j)
    {
        do i ++; while(a[i] < poivt);
        do j --; while(a[j] > poivt);
        if(i < j) swap(a[i],a[j]);
    }
    quick_sort(a,l,j);
    quick_sort(a,j + 1,r);
}

归并排序 归并排序的要点: 1、以中轴(中间索引)进行划分左右边,以此进行递归; 2、用一个临时数组进行存储;

void merge_sort(int *a,int l ,int r)
{
    if(l >= r) return;
    int mid = l + r >> 1;
    merge_sort(a,l,mid);merge_sort(a,mid + 1,r);
    int k = 0;
    int i = l;
    int j = mid + 1;
    while(i <= mid && j <= r)
    {
        if(a[i] <= a[j]) tem[k++] = a[i++];
        else tem[k++] = a[j++];
    }
    while(i <= mid) tem[k++] = a[i++];
    while(j <= r) tem[k++] = a[j++];
    for(int i = l;j = 0; i <= r ;i++,j++) a[i] = tem[j];
}

1.2 二分

整数范围二分 二分的主要思想是可以把一种事物,按某种临界值划分为左右两份,例如 0 1 2 3 4 5 6 7 8 9 10 1 1 2 2 2 2 3 3 4 4 5 上面这个数组,值为1是在0——1之间,2是在2——5之间,3是在6——7之间, 4是在8——9之间,5是10-10。所以相对来说, 如果要寻找除了2以外的数的范围,则是[0,1] [6,10]; 以值不为2的这样一个临界值作为一个范围。

以下的程序我将以寻找值为2的范围来进行编写。
2的范围有两个临界条件,
1)a[i] >= 2
    int i = 0, j = n - 1;
    while(i<j)
    {
        int mid = i + j >> 1;
        if(a[mid] >= 2) r = mid;  满足这种临界值时,右指针移动
        else l = mid + 1;    加1是因为l以左的区域都不满足,进行整体滑动。
    }
2)a[i] <= 2
    int i = 0,j = n - 1;
    while(i<j)
    {
        int mid = i + j + 1 >> 1; 这里+1是如果满足临界值时,需要进行左指针滑动,则需要+1,不然会导致死循环。
        if(a[mid] <= 2) l = mid;
        else r = mid - 1;
    }

// 1、满足临界条件时,需要移动右指针
while(i < j)
{
    int mid = i + j >> 1;
    if(check(mid)) r = mid;
    else l = mid + 1;
}
// 2、满足临界条件时,需要移动左指针
while(i < j)
{
    int mid = i + j + 1 >> i;
    if(check(mid)) l = mid;
    else r = mid - 1;
}

模拟实现upper_bound(int key)和lower_bound(int key)函数,如果存在则返回下标,否则返回end。

upper_bound(int key)表示的是查找第一个出现的值大于等于key的数

  • 如果key在区间中没有出现过,那么返回第一个比key大的数的下标。
  • 如果key比所有区间内的数都大,那么返回r。这个时候会越界,小心。
  • 如果区间内有多个相同的key,返回最后一个key的下标+1。
int upper_bound(int* array, int size, int key)
{
    int first = 0, len = size - 1;
    int half, middle;

    while (len > 0) {
        half = len >> 1;
        middle = first + half;
        if (array[middle] > key)     //中位数大于key,在包含last的左半边序列中查找。
            len = half;
        else {
            first = middle + 1;    //中位数小于等于key,在右半边序列中查找。
            len = len - half - 1;
        }
    }
    return first;
}

lower_bound(int key)

  • 如果key在区间中没有出现过,那么返回第一个比key大的数的下标。
  • 如果key比所有区间内的数都大,那么返回r。这个时候会越界,小心。
  • 如果区间内有多个相同的key,返回第一个key的下标。
int lower_bound(int *array, int size, int key) 
{ 
    int first = 0, middle; 
    int half, len; 
    len = size; 
    while(len > 0) 
    { 
        half = len >> 1; 
        middle = first + half; 
        if(array[middle] < key) 
        { 
            first = middle + 1; 
            len = len-half-1; //在右边子序列中查找 
        } 
        else len = half; //在左边子序列(包含middle)中查找 
   } 
   return first; 
}

浮点数的二分 浮点数的二分主要解决的是开根呀之类的计算, 例如有一个9999数,需要开三次根,则需要进行二分进行划分, 并且计算的结果与9999进行比较 double i = min; double j = max; double mid = 0.0; while(j - i > 1e-6) { double mid = (i+j) / 2; if(mid * mid * mid > x) j = mid; else i = mid; }

1.3 大整数的加减乘除

加法 两个大整数的加法,采用两个vector数组进行存储, 进行手动模拟加法过程,因为存在进位的问题,为了方便进位操作, 采取逆序存储,即:123456789 ->(数组当中) 9 8 7 6 5 4 3 2 1。 还需要注意一个问题, 此处的两个大整数单单只考虑 C = A + B(A >= 0 B >= 0 C >= 0)。 如果说某一个数为负数,那么其实是变成了大整数的减法。

手动模拟过程
    a3 a2 a1 a0
+         b1 b0 t  
 c4 c3 c2 c1 c0    
 第一步:add = a0 + b0 + t; c0 = add % 10; t = add / 10
 第二步:add = a1 + b1 + t; c1 = add % 10; t = add / 10
                     以此类推
vector<int> add(vector<int> &A,vector<int> &B)
{
    vector<int> result;
    int add = 0;
    int t = 0;
    for(int i = 0 ;i < A.size() || i < B.size(); i++)
    {
        if(i < A.size()) add += A[i];
        if(i < B.size()) add += B[i];
        result.push_back(add % 10);
        t = add / 10;
        add = t;
    }
    if(add) result.push_back(add);
    return result;
}

减法 本次讨论的是两个大整数减法中C = A - B,A是要大于等于B的, 并且A和B都要大于等于0; 大整数减法同大整数加法一样是需要使用数组来进行存储, 并且在数组当中进行模拟。 此外,还需要额外判断下传入的A,B谁大谁小,以此来确认输出。

// 判断A是否大于等于B
bool cmp(vector<int> &A,vector<int> &B)
{
    if(A.size() != B.size()) return A.size() > B.size();
    for(int i = A.size() - 1;i>=0 ;i--)
        if(A[i] != B[i])
            return A[i] > B[i];
    return true;
}

vector<int> sub(vector<int> &A,vector<int> &B)
{
    vector<int> result;
    int t = 0;
    for(int i = 0;i < A.size() || i < B.size(); i++)
    {
        if(i < A.size()) t = A[i] - t; //这里需要减去借位
        if(i < B.size()) t = t - B[i];
        /* 这里就有两种情况了,第一种是t小于0,则表示需要借位
           第二种是t大于等于0 则不需要借位
           两者统一来就是  (t + 10) % 10 等价为 t%10 + 10%10,
           加10再去取余是为了使负的值,变为正数,再去取余,
           如果说是正数的话,加了10 再模去10,其实相当于没有加*/
        result.push_bakc( (t + 10) % 10); 
        if(t < 0) t = 1;
        else t = 0;
    }
    //去掉前导0
    while(result.size() > 1 && result.back() == 0) result.pop_back();
    return result;
}

乘法 大整数的乘法有两种情况(不考虑负数): 1) 两个大整数相乘 C = A * B 2) 一个大整数和一个常熟 C = A * b 本例描述的是第二种情况 a3 a2 a1 a0
b c0 = ( a0 * b + t ) % 10 t = (a0 * b + t) / 10; c4 c3 c2 c1 c0

vector<int> mul(vector<int> &A,int b)
{
    vector<int> result;
    int t = 0;
    for(int i = 0;i < A.size() ;i++)
    {
        t = A[i] * b + t;
        result.push_back(t % 10);
        t = t / 10;
    }
    // 进位是t>=0,所以要对t进行剩余处理
    while(t)
    {
        result.push_back(t % 10);
        t = t / 10;
    }
    // 去除前导0
    while(result.size() > 1 && result.back() == 0) result.pop_back(); 
    return result;
}

除法 后续更新

vector<int> div(vector<int>& A, int b, int& r)
{
   vector<int> C;
   r = 0;
   for (int i = A.size() - 1; i >= 0; i--)
   {
   	r = r * 10 + A[i];
   	C.push_back(r / b);
   	r %= b;
   }
   reverse(C.begin(), C.end());
   while (C.size() > 1 && C.back() == 0) C.pop_back();
   return C;
}

1.4 前缀和与差分

一维前缀和 给定某一数组,arr = {a0,a1.....an}; 其前缀和数组,sum[i] = sum[i-i] + a[i] 若要求出l,r区域内的和,则表达式为:sum[r] - sum[l-1] 优点:可以在O(1)时间复杂度求出某一段区域的和

一维差分 差分是可以在o1的时间复杂度,把某段区域加上一个value值, 或者是当满足某一条件时,需要在一个点或一个区域加上一个value值的时候, 就可以使用差分的知识。 一原数组,arr = {a0,a1,....,an} 差分数组,ant[i] += c,ant[i+1] -= c; 例如给定多个l,r区域,在多个l,r区域中加上一个k值,则有如下代码 while(m--) { int l,r,k; cin >> l >> r >> k; ant[l] += k; ant[r+1] -=k; } 最后再对差分数组使用前缀和,使其恢复原数组,这样一来, 整体的时间复杂度从O( m * (r - l) ) 变为 O(m + n); 二维前缀和 二维前缀和可以在O(1)的时间复杂度求出某一矩阵的和。 要点有两个: 1、根据输入的每一个Aij的值,对前缀和数组进行更新 sum[i][j] = sum[i-1][j] + sum[i][j-1] - sum[i-1][j-1] 2、求出某一段区域(x1,y1)左上角,(x2,y2)右下角的矩阵和 sum[x2][y2] - sum[x1-1][y2] - sum[x2][y1-1] + sum[x1-1][y1-1]; 二维差分 给以(x1, y1)为左上角,(x2, y2)为右下角的子矩阵中的所有元素加上c: S[x1, y1] += c, S[x2 + 1, y1] -= c, S[x1, y2 + 1] -= c, S[x2 + 1, y2 + 1] += c 在求出该差分举证的二维前缀和,即可得出原矩阵

1.5 位运算和双指针

位运算 1、求n的第k位数字: n >> k & 1 十进制的10->二进制:1010,若k = 1, 1010 >> 1 = 101 , 101 & 1 = 1 1、返回n的最后一位1:lowbit(n) = n & -n n = (1010)b lowbit(n) = 10;

1.6 离散化和区间合并

离散化 主要是针对一维稀疏数组进行一个压缩存储的方法。

题目:区间和
输入描述:第一行包含两个整数 n 和 m。
接下来 n 行,每行包含两个整数 x 和 c。
再接下来 m 行,每行包含两个整数 l 和 r。

image.png

这样一来就可以把一个大区间、大范围内存储少量的数值,
存储在一个紧凑的小范围内的数组,并且,alls数组和a数组相对应,
一个是存储下标,另一个是存储该下标的值。然后通过前缀和的特性,
把[l,r]区间内的和求出来。

// 本题的关键代码
// 1、二分查找索引下标
int find(vector<int> alls,int x)
{
    int l = 0,r = alls.size() - 1;
    while(l < r)
    {
        int mid = l + r >> 1;
        if(alls[mid] >= x) r = mid;
        else l = mid + 1;
    }
    return l + 1;  //这里是因为要求前缀和,所以a数组和s数组的下标是从1开始
}

// 2、对alls数组进行排序以及去重,alls数组存储的是下标
sort(alls.begin(),alls.end());
alls.erase(unique(alls.begin(),alls.end()),alls.end());

// 3、对alls数组去重完毕之后,把相应的值插入到a数组当中
void insert(vector<int> alls,vector<PII> adds,int a[])
{
    for(auto item : adds)
    {
        int x = find(alls,item.first);
        a[x] += item.second; //这里+=是考虑到有重复的数
    }
}

// 第四步和第五步就是求前缀和数组并输出相应区间的和了

区间合并

第二章 基本数据结构

一些常用的数据结构比如说链表、队列,以及常用的关于数据结构的算法。

2.1 链表

2.1.1 单链表

单链表的数组实现,i表示是第i号节点,
ei数组存储第i号节点的值,
nei数组存储第i号节点的下一节点,其中-1表示为空

image.png

// idx表示当前用到哪个节点
int head, e[N], ne[N],idx;
void init()
{
    head = -1;
    idx = 0;
}
// 头插法插入
void insert(int a)
{
    e[idx] = a;
    ne[idx] = head;
    head = idx ++;
}
// 指定的k节点后插入x
void insert(int k,int x)
{
    e[idx] = x;
    ne[idx] = ne[k];
    ne[k] = idx;
    idx ++;
}
// 移除头节点指向的节点
void remove()
{
    head = ne[head];
}
// 删除指定节点
void remove(int k)
{
    ne[k] = ne[ne[k]];
}
当前若要顺序插入3,7,9,1
如图所示

image.png

2.2 栈

2.2.1 朴素栈

使用数组去模拟朴素栈结构,栈是具有先进后出的特点。
栈的四种基本常用方法:
1push(压栈)
2pop(弹栈)
3query(查询栈顶元素)
4empty(栈顶是否为空)
int stack[N] , tt;
void push(int x)
{
    stack[++t] = x;
}
int pop()
{
    if(!tt) return stack[tt--];
    else return -1;
}
int query()
{
    if(!tt) return stack[tt];
    else return -1;
}
// 栈为空 返回true,否则返回false
bool empty()
{
    if(!tt) return false;
    else return true;
}

2.2.2 单调栈

单调栈顾名思义,具有单调性的栈,即要么单调递增,要么单调递减。
单调栈的应用场景:
    有一个序列(数组),给定序列中的某一个数,
    找出这个数的左边(右边)比这个数小(大)的数在什么地方。
单调栈特别重要的点,就是在一个无序的序列当中,我们怎么维护这个无序序列入栈时,
保持单调栈的单调性。
例如有一个无序序列(从左到右入栈):1 6 9 2 3 5 4
单调递增的栈:
栈为空,1入栈
栈{1},栈顶为1小于66入栈
栈{1,6},栈顶为6小于99入栈
栈{1,6,9},栈顶为9大于29出栈,栈顶为6大于26出栈,栈顶为1小于22入栈
栈{1,2},栈顶为2小于33入栈
栈{1,2,3},栈顶为3小于55入栈
栈{1,2,3,5},栈顶为5大于45出栈,栈顶为3小于44入栈。
最后栈内元素就为{1,2,3,4}
// 比较左边第一个大于(小于)的数
for(int i = 0 ;i < n ;i++)
{
    while(tt && stack[tt] >= a[i]) tt--;
    stack[ ++ tt] = a[i];
}
//比较右边第一个大于小于的数
for(int i = n - 1; i >= 0 ;i--)
{
    while(tt && stack[tt] <= a[i]) tt--;
    stack[++tt] = a[i];
}

Acwing:单调栈例题

2.3 单调队列

2.3.1 滑动窗口

题目描述 image.png 思路解析 如果是暴力来解决这个题的话,需要遍历n次的k大小的滑动窗口,时间复杂度也就是O(nk),在遍历滑动窗口时,会存在一些从来没有用到的数值。

以最大值为例,滑动窗口中有两个下标,i和j(i < j),并且a[i] <= a[j]的,当滑动窗口向右移动时,只要i还在窗口中,j就还在。由于a[j]的存在,那么在这个滑动窗口中,a[i]就不是最大值,这时就可以把a[i]给永远去掉。

所以对于最大值而言,使用一个单调递减的队列,那么最后队列的头就是这个滑动窗口的最大值。

保持队列的单调性,会不断的将新元素和队尾元素相比较,如果队尾元素小于等于新元素,那么就可以将队尾元素删掉。不断的执行这个操作,直到队列为空或者队尾元素大于新元素。

代码

#include<iostream>
using namespace std;
const int N = 1e6 + 10;
int n, k;
int q[N], a[N];

int main()
{
	cin >> n >> k;
	for (int i = 0; i < n; i++)
		cin >> a[i];
	int hh = 0, tt = -1;
	// 最小值
	for (int i = 0; i < n; i++)
	{
		if (hh <= tt && i - k + 1 > q[hh]) hh++;
		while (hh <= tt && a[q[tt]] >= a[i]) tt--;
		q[++tt] = i;
		if (i >= k - 1) cout << a[q[hh]] << ' ';
	}
	cout << endl;
	// 最大值
	hh = 0, tt = -1;
	for (int i = 0; i < n; i++)
	{
		if (hh <= tt && i - k + 1 > q[hh]) hh++;
		while (hh <= tt && a[q[tt]] <= a[i]) tt--;
		q[++tt] = i;
		if (i >= k - 1) cout << a[q[hh]] << ' ';
	}
	cout << endl;
	return 0;
}

第三章 搜索与图论

3.1 DFS

3.1.1 排列数字

3.1.2 n-皇后问题

3.2 BFS

3.2.1 走迷宫

3.2.2 八数码

3.3 树与图的深度优先遍历

3.3.1 树的重心

3.4 树与图的广度优先遍历

3.4.1 图中点的层次

3.5 拓扑排序

3.5.1 有向图的拓扑排序

3.6 Dijkstra

3.6.1 Dijkstr求最短路径

3.7 bellmen-ford

3.7.1 有边数限制的最短路

3.8 spfa

3.8.1 spfa求最短路

3.8.2 spfa判断路径

3.9 Floyd

3.9.1 Floyd求最短路

3.10 Prim

3.10.1 Prim算法求最小生成树

3.11 Kruskal

3.11.1 Kruskal算法求最小生成树

3.12 染色法判定二分图

3.12.1 染色法判定二分图

3.13 匈牙利算法

3.13.1 二分图的最大匹配

第四章 常见的数学知识

4.1 质数

4.1.1 试除法判定质数

4.1.2 分解质因数

4.1.3 筛质数

4.2 约数

4.2.1 试除法求约数

4.2.2 约数个数

4.2.3 约数之和

4.2.4 最大公约数

4.3 欧拉函数

4.3.1 欧拉函数

4.3.2 筛法求欧拉函数

4.4 快速幂

4.4.1 快速幂

4.4.2 快速幂求逆元

4.5 扩展欧几里得算法

4.5.1 扩展欧几里得算法

4.5.2 线性同余方程

4.6 中国剩余定理

4.6.1 表达整数的奇怪方式

4.7 高斯消元

4.7.1 高斯消元解线性方程组

4.7.2 高斯消元解异或线性方程组

4.8 求组合数

4.8.1 求组合数I

4.8.2 求组合数II

4.8.3 求组合数III

4.8.4 求组合数IV

4.8.5 满足条件的01序列

4.9 容斥原理

4.9.1 能被整除的数

4.10 博弈论

4.10.1 Nim游戏

4.10.2 台阶-Nim游戏

4.10.3 集合-Nim游戏

4.10.4 拆分-Nim游戏

第五章 动态规划

先说一下感受,从本科课程的算法课学了动态规划开始,
就对此类算法懵懵懂懂的,完全是一团雾水,听了y总的课之后,
大体分为这几个步骤去解决动态规划的问题:
    1、找准状态,也就是维度;
    2、推导状态转移方程,也就是递推公式;
    3、对状态转移方程的优化,时间和空间上的优化。
优化方面举一个简单的例子,求n的阶乘这个例子,这个递推公式为:fn = n * fn;
for(int i = 1;i<=n;i++)
{
    f[i] = f[i - 1] * i;
}
这里是对空间进行优化,我们要求n的阶乘只需知道前一个数n-1的阶乘是多少即可,
那么就可以只用一个变量来存储循环过程当中前一个数阶乘的结果
int tem = 1;
for(int i = 1;i<=n;i++)
{
    tem = tem * i;
}
这样就可以从O(n)的空间复杂度降到O(1);
这里只是举一个简单的例子,动态规划算法是解决一大类的问题,具体的递推公式
和具体的优化方法也得具体的来看。总之一句话,动态规划就是练得多就会得多,
没有捷径。

处理动态规划问题一般思路图: image.png

5.1 背包问题

5.1.1 01背包问题

问题描述 有 N 件物品和一个容量是 V 的背包。每件物品只能使用一次。 第 i 件物品的体积是 vi,价值是 wi。 求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大。 输出最大价值。 分析 N件物品所自由组合并且容量小于V,只能使用一次的种类有n种, 每种组合对应一种容量大小,并且所对应的价值也是一种,所以需要处理 两种状态,有i件物品并且容量大小为j,价值为f[i,j]; 属性:max f[i,j]就是要求的集合,表示前i件物品所对应的容量为j的最大价值为f[i,j]

状态转移方程计算:
f[i,j]的集合,可以划分两种集合,一种是含i的集合,另外一个是不包含i的集合
对于不包含i的集合,价值就为f[i-1,j],而对于包含i的集合,我们需要先去掉i,
再加上i的价值,即为f[i-1,j-v[i]] + w[i],当然这里要判断j >= v[i]
然后取这两个集合的最大值:
f[i,j] = max( f[i-1,j],f[i-1,j-v[i]] + w[i])

image.png

代码

// f[i,j] = Max(f[i-1,j],f[i-1,j-vi] + wi)
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1010;
int v[N],w[N];
int f[N][N];

int main()
{
   int n,m;
   cin >> n >> m;
   for(int i = 1;i <= n;i++)
   {
       cin >> v[i] >> w[i]; 
   }
   
   for(int i = 1 ;i <= n; i++)
   {
       for(int j = 1; j <= m;j++)
       {
           f[i][j] = f[i-1][j];
           if(j >= v[i]) f[i][j] = max(f[i-1][j] , f[i-1][j-v[i]] + w[i]);
           
       }
   }

   cout << f[n][m];
   return 0;
}
 上面的f[i,j]的状态转移方程中,f[i-1,j]和f[i-1,j-v[i]]行维度是一样的,
 所以可以压缩为f[j] = max (f[j],f[j-v[i]] + w[i])压缩为一维,压缩为一维的时候,
 需要注意j的取值范围:j属于[v[i],m],
 压缩成一维的时候需要降序循环,就是说从m开始到v[i],因为在二维状态下,
 状态f[i,j]是由上一轮的i-1的状态更新来的,f[i,j]和f[i-1,j]是独立的,
 优化到一维时,f[较小体积]更新到f[较大体积],则有可能应该用i-1的状态,却
 是用第i轮的状态。
 例如:f[7]是由f[4]更新而来,f[4]对应二维的就是f[i-1][4],f[7]对应f[i][7],
 但是如果从小进行枚举这里的f[4]就变成了f[i][4]。所以j要进行逆序循环。

代码优化:

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1010;
int v[N],w[N];
int f[N];

int main()
{
    int n,m;
    cin >> n >> m;
    for(int i = 1;i <= n;i++)
    {
        cin >> v[i] >> w[i]; 
    }
    
    for(int i = 1 ;i <= n; i++)
    {
        for(int j = m; j >= v[i];j--)
        {
            f[j] = max(f[j] , f[j-v[i]] + w[i]);
            
        }
    }

    cout << f[m];
    return 0;
}

5.1.2 完全背包问题

完全背包问题和01背包问题区别就在于前者所选择的物件是任意的。集合的划分有很大的区别。

问题描述 有 N 种物品和一个容量是 V 的背包,每种物品都有无限件可用。 第 i 种物品的体积是 vi,价值是 wi。 求解将哪些物品装入背包,可使这些物品的总体积不超过背包容量,且总价值最大 输出最大价值。 分析 状态表示、集合、属性和01背包问题一样。 不一样的点是集合的划分,划分f[i,j]的集合当中, 假设集合里面包含有含有0、1、2......、k个i, 那么我们在这k个子集当中选出价值最大的集合,就可以作为f[i,j]集合的最大价值。 如图所示。 image.png 代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1010;
int f[N][N];
int v[N],w[N];
int main()
{
    int n,m;
    cin >> n >> m;
    for(int i = 1;i <= n; i++)
        cin >> v[i] >> w[i];
    for (int i = 1; i <= n; i ++ )
        for(int j = 1; j <= m ;j++)
        {
            f[i][j] = f[i-1][j];
            if(j >= v[i]) f[i][j] = max(f[i-1][j],f[i][j-v[i]] + w[i]);
        }
    cout << f[n][m];
    return 0;
}
同样的,和01背包问题一样,完全背包问题可以压缩为一维,
即f[j] = max(f[j],f[j-v[i]] + w[i]);
j的取值范围为[v[i],m];
// 代码优化版本
#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1010;
int f[N];
int v[N],w[N];
int main()
{
    int n,m;
    cin >> n >> m;
    for(int i = 1;i <= n; i++)
        cin >> v[i] >> w[i];
    for (int i = 1; i <= n; i ++ )
        for(int j = v[i]; j <= m ;j++)
        {
            f[j] = max(f[j],f[j-v[i]] + w[i]);
        }
    cout << f[m];
    return 0;
}

对比优化之后的01背包问题和完全背包问题的代码 第二个for循环,一个是正序,一个是逆序 正序则表示的是完全背包问题,逆序则表示的是01背包问题。

5.1.3 多重背包问题

问题描述 有 N 种物品和一个容量是 V 的背包。 第 i 种物品最多有 si 件,每件体积是 vi,价值是 wi。 求解将哪些物品装入背包,可使物品体积总和不超过背包容量,且价值总和最大。 输出最大价值。 分析 集合的确定:前i种物品的体积为j的所有方案 属性:max 集合的划分和完全背包问题一样,不同的是i物品的个数是由s[i]来决定 即f[i,j] = max(f[i-1,j],f[i-1,j-v[i]] + w[i],f[i-1,j-2v[i]] + 2w[i],...,f[i-1,j-s[i]v[i]] + s[i]w[i]); 代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 110;
int f[N][N];
int s[N],v[N],w[N];

int main()
{
    int n ,m;
    cin >> n >> m;
    for(int i = 1;i <= n;i ++)
    {
        cin >> v[i] >> w[i] >> s[i];
    }
    for(int i = 1; i<=n;i++)
        for(int j = 1;j <=m ;j++)
            for(int k = 0;k <=s[i] && k * v[i] <= j;k++)
                f[i][j] = max(f[i][j],f[i-1][j - k * v[i]] + k * w[i]);
                
    cout << f[n][m];
    return 0;
}

多重背包的优化 f[i,j] = max(f[i-1,j],f[i-1,...,j - sv] + s*w) f[i,j-v] = max(f[i-1,j - v],......,f[i-1,j-sv] + (s-1)w,f[i-1,j-(s+1)v] + sw); 这里从递归是无法直接进行优化的,这里是用到了二进制优化的方法,对s的长度进行优化,使得为logs。

二进制优化 例如如果s最大为1000,那么就可以划分为1 2 4 8 32 64 128 256 512 489 从1000个物品划分为10个物品,1000内的数都可以通过这10个数相加得到, 如果说我们现在要求99个物品的最大价值,那么只需把64、32、2、1四个类别的最大 价值加起来就作为99个物品的最大价值。

二进制优化最重要的一步就是进行初始化,对v、w数组进行初始化
int ans = 0;
for(int i = 1; i <= n; i++)
{
    int v,w,s;
    cin >> v >> w >> s;
    int k = 1;
    while(k <= s)
    {
        ans++;
        v[ans] = v * k;
        w[ans] = w * k;
        s -= k;
        k *= 2;
    }
    if(s > 0)
    {
        ans++;
        v[ans] = v * s;
        w[ans] = w * s;
    }
}
二进制优化之后就转换为01背包问题

多重背包问题二进制优化代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 10100;
int f[N];
int v[N],w[N];

int main()
{
   int n,m;
   cin >> n >> m;
   int ans = 0;
   for(int i = 1;i <= n;i++)
   {
       int a , b, s;
       cin >> a >> b >> s;
       int k = 1;
       while(k <= s)
       {
           ans++;
           v[ans] = a * k;
           w[ans] = b * k;
           s -= k;
           k *= 2;
       }
       if(s > 0)
       {
           ans++;
           v[ans] = a * s;
           w[ans] = b * s;
       }
   }
   for(int i = 1;i <= ans;i++)
   {
       for(int j = m; j >= v[i];j--)
       {
           f[j] = max(f[j],f[j - v[i]] + w[i]);
           
       }
       
   }
   cout << f[m];
   return 0;
}

5.1.4 分组背包问题

问题描述 有 N 组物品和一个容量是 V 的背包。 每组物品有若干个,同一组内的物品最多只能选一个。 每件物品的体积是 vij,价值是 wij,其中 i 是组号,j 是组内编号。 求解将哪些物品装入背包,可使物品总体积不超过背包容量,且总价值最大。 输出最大价值。 分析 每组物品最多只能选一个,这就转化为01背包问题,所以在进行一维优化时, j要进行逆序,原因在01背包问题有讲。 image.png 代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 110;
int f[N][N], s[N];
int v[N][N], w[N][N];

int main()
{
   int n, m;
   cin >> n >> m;
   for(int i = 1; i <= n; i ++)
   {
       cin >> s[i];
       for(int j = 1;j <= s[i]; j++)
       {
           cin >> v[i][j] >> w[i][j];
       }
   }
   
   for(int i = 1; i<= n;i ++)
   {
       for(int j = 1; j <= m ; j++)
       {
           f[i][j] = f[i-1][j];
           for(int k = 1; k <= s[i]; k ++)
           {
               if(j >= v[i][k]) f[i][j] = max(f[i][j],f[i-1][j - v[i][k]] + w[i][k]);
           }
       }
   }
   cout << f[n][m];
   return 0;
}
// 一维优化,
for (int i = 1; i <= n; i++)
   for (int j = m; j >= 1; j--)
       for (int k = 1; k <= s[i]; k++)
           if (j >= v[i][k]) f[j] = max(f[j], f[j - v[i][k]] + w[i][k]);

分组背包转化成完全背包 只需要在题目描述中把每组物品只能选择一个改成把每组物品选择多个即可。 分析过程一样 for (int i = 1; i <= n; i++) for (int j = 1; j <= m; j++) for (int k = 1; k <= s[i]; k++) if (j >= v[i][k]) f[j] = max(f[j], f[j - v[i][k]] + w[i][k]);

5.2 线性DP

5.2.1 数字三角形

问题描述 image.png 分析 如图所示,右斜为j,行为i, image.png

f[i,j] = max(f[i-1,j-1],f[i-1,j]) + a[i,j]作为转移方程。

image.png

此时需要注意的点是,行和列的两侧都要进行初始化,扫描到边界的时候,转移方程会访问到。

代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 510, MIN = -1e9;
int f[N][N];
int a[N][N];

int main()
{
    int n;
    cin >> n;
    for(int i = 0; i <= n + 1; i ++)
        for(int j = 0; j <= i + 1;j ++)
            f[i][j] = MIN;
    for(int i = 1;i <= n;i++)
        for(int j = 1; j <= i; j ++)
        {
            cin >> a[i][j];
            if(i==1 && j == 1) f[i][j] = a[i][j];
            else f[i][j] = max(f[i-1][j-1],f[i-1][j]) + a[i][j];
        }
    int ans = MIN;
    for(int i = 1;i <= n;i++)
        ans = max(ans,f[n][i]);
    cout << ans;
    return 0;
}

如图为其中一个运行的结果

image.png

我们如何获得,最大路径值的路径呢,可以自顶向下看,[i,j]的点可以决定下一层的,[i + 1,j] 和[i+1,j+1],就类似为二叉树的结构。所以可以从最底层开始,找到最大的哪个点,然后依次往上层查找最大值。

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 510, MIN = -1e9;
int f[N][N];
int a[N][N];
int main()
{
    int n;
    cin >> n;
    for(int i = 0; i <= n + 1; i ++)
        for(int j = 0; j <= i + 1;j ++)
            f[i][j] = MIN;
    for(int i = 1;i <= n;i++)
        for(int j = 1; j <= i; j ++)
        {
            cin >> a[i][j];
            if(i==1 && j == 1)
            {
                f[i][j] = a[i][j];
                
            }
            else
            {
                f[i][j] = max(f[i-1][j-1],f[i-1][j]) + a[i][j];
            }
        }
    int ans = MIN;
    int j;
    for(int i = 1;i <= n;i++)
        {
            ans = max(ans,f[n][i]);
            if(ans <= f[n][i])
            {
                j = i;
            }
        }
    cout << a[n][j] << ' ';
    for(int i = n - 1; i >= 1;i--)
    {
        if(f[i][j-1] > f[i][j])
        {
            cout << a[i][j-1] << ' ';
            j--;
        }
        else cout << a[i][j] << ' ';
    }
    cout << endl;
    cout << ans;
    return 0;
}

5.2.2 最长上升子序列

问题描述 给定一个长度为N的数列,求数值严格单调递增的子序列的长度最长是多少。 分析 状态表示:f[i] : 前i个递增子序列的最大长度 属性:max 状态计算:以i结尾,i前面有i-1个数,所以有i-1个集合 j属于[0,i-1],如果ai大于aj,那么就需要比较一下fi和fj + 1的大小 fi = max(fi,fj + 1) 代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1010;
int f[N];
int a[N];

int main()
{
   int n ;
   cin >> n;
   for(int i = 0; i < n ;i++) cin >> a[i];
   
   int ma = 0;
   for(int i = 0; i < n; i++)
   {
       f[i] = 1; // 初始化
       for(int j = 0; j < i; j ++)
           if(a[i] > a[j]) f[i] = max(f[i],f[j] + 1);
       ma = max(ma,f[i]);
   }
   cout << ma;
   return 0;
}

路径记忆化 使用一个额外的数组去存储每种结果的上一个索引值。 代码

 #include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1010;
int f[N];
int a[N],p[N];

int main()
{
   int n ;
   cin >> n;
   for(int i = 0; i < n ;i++) cin >> a[i];
   
   int ma = 0;
   for(int i = 0; i < n; i++)
   {
       f[i] = 1; // 初始化
       p[i] = 0;
       for(int j = 0; j < i; j ++)
           if(a[i] > a[j])
           {
               if(f[i] < f[j] + 1)
               {
                   
                   p[i] = j;
               }
               f[i] = max(f[i],f[j] + 1);
           }
       ma = max(ma,f[i]);
   }
   
   int k = 0;
   for(int i = 0; i<n;i++)
       if( f[k] < f[i]) k = i;
   for(int i = 0, len = f[k]; i<len;i++)
   {
       cout << a[k] << ' ';
       k = p[k];
   }
   cout << ma;
   return 0;
}

优化 我们要求的是递增的子序列,可以使用一个额外数组记录 stk[i]为长度为i的上升序列的最小末尾,并不是记录像单调栈那种的递增序列。 这是一种贪心的策略,较小的数作为子序列的开头比较大的数作为子序列的开头要更好; 使用二分去找到stk[i] >= a[i],如果找到,就覆盖,没找到就进行扩容 例如: 原数组:3 1 2 1 8 5 6 一:3 二:(3大于1,覆盖) 1 三:(1 小于2,扩容) 1 2 四:(1等于1,覆盖) 1 2 五:(2小于8,扩容) 1 2 8 六:(8大于5,覆盖) 1 2 5 七:(5小于6,扩容) 1 2 5 6

image.png

代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 100000 + 10;
int a[N], stk[N];

int main()
{
    int n;
    cin >> n;
    for (int i = 0; i < n; i++) cin >> a[i];

    int len = 0;
    stk[len++] = a[0];
    for (int i = 0; i < n; i++)
    {
        if (stk[len-1] < a[i])
        {
            stk[len++] = a[i];
        }
        else
        {
            int l = 0, r = len;
            while (l < r)
            {
                int mid = l + r >> 1;
                if (stk[mid] >= a[i]) r = mid;
                else l = mid + 1;
            }
            stk[l] = a[i];
        }

    }
    cout << len;
    return 0;
}

5.2.3 最长公共子序列

题目描述 image.png 样例 image.png 分析 状态表示:f[i,j],i表示第一个序列的前i个元素,j表示第二个序列的前j个元素 集合:所有由第一个序列前i个元素和第二个序列前j个元素组成的子序列 属性:max 状态计算: 集合的划分:可分为四个子集,第一个为存在i,表示1,存在j表示1,则 00 01 10 11 所以第一个集合和最后一个集合就可以表示为:f[i-1,j-1] f[i-1,j-1] + 1 至于中间两个可以用f[i-1,j] 和f[i,j-1]来替换,但是这种是不等价的,因为 f[i-1,j]表示的是以第一个序列前i-1个元素和第二个序列前j个元素,组成子序列长度的最大值, 和01这种状态表示的含义不一样,01表示的是存在j不存在i的子序列。 但是由于这里是求最大值,也因为f[i-1,j]包含有01这种情况,所以可以去替换它 转移方程:f[i,j] = max(f[i-1,j-1],f[i-1,j],f[i,j-1],f[i-1,j-1] + 1); 代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1010;
char a[N] ,b[N];
int f[N][N];
int main()
{
    int n ,m;
    cin >> n >> m;
    for(int i = 1;i <= n ;i++) cin >> a[i];
    for(int i = 1;i <= m; i++) cin >> b[i];
    
    for(int i = 1 ; i <= n;i++)
    {
        for(int j = 1; j<= m;j++)
        {
            f[i][j] = max(f[i-1][j],f[i][j-1]);
            if(a[i] == b[j])
            {
                f[i][j] = max(f[i][j-1],f[i-1][j-1] + 1);
            }
        }
    }
    cout << f[n][m];
    return 0;
}

找出最长公共子序列 使用一个标记数组来标记方向 1:斜向上 a[i] == b[i] 2:向左 f[i,j-1] > f[i-1,j] 3:向上 f[i,j-1] < f[i-1,j]

         [i-1,j]
         
[i,j-1]  [i,j]   对于i,j坐标来说,有三个方向的坐标,上、左、斜上。

如图:

image.png

代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1010;
char a[N] ,b[N];
int f[N][N],flag[N][N];
char res[N];
int main()
{
    int n ,m;
    cin >> n >> m;
    for(int i = 1;i <= n ;i++) cin >> a[i];
    for(int i = 1;i <= m; i++) cin >> b[i];
    
    for(int i = 1 ; i <= n;i++)
    {
        for(int j = 1; j<= m;j++)
        {
            
            if(a[i] == b[j])
            {
                f[i][j] = f[i-1][j-1] + 1;
                flag[i][j] = 1;
            }
            else if(f[i][j-1] > f[i-1][j])
            {
                flag[i][j] = 2;
                f[i][j] = f[i][j-1];
            }
            else
            {
                flag[i][j] = 3;
                f[i][j] = f[i-1][j];
            }
        }
    }
    int k = 0;
    int i = n, j = m;
    while(i > 0 && j > 0)
    {
        if(flag[i][j] == 1)
        {
            res[++k] = a[i];
            i--;j--;
        }
        else if(flag[i][j] == 2) j--;
        else i--;
    }
    for(int i = 1; i<=k;i++) cout << res[i] << ' ';
    cout << endl;
    cout << f[n][m];
    return 0;
}

5.2.4 最短编辑距离

题目描述 给定两个字符串A和B,现在要将A经过若干操作变为B,可进行的操作有: 删除–将字符串A中的某个字符删除。 插入–在字符串A的某个位置插入某个字符。 替换–将字符串A中的某个字符替换为另一个字符。 现在请你求出,将A变为B至少需要进行多少次操作。 输入输出

image.png

分析 状态表示:f[i,j] 集合:所有把第一个序列的前i个字母变成第二个序列中的前j个字母的集合的操作集合; 属性:min 状态计算:集合划分以对第一个序列的前i个字母变成第二个序列的前j个字母的操作的不同进行划分 添加:(1i)(1j-1)相同时,那么在添加bj元素时,能和ai匹配上,则f[i,j-1] + 1 删除:(1i-1)(1j)相同时,删除第i个元素,如果使得相同则有f[i-1.j] + 1 修改: 相同:不操作,f[i-1,j-1] 不相同:(1i-1)(1j-1)相同时,比较i和j是否相同,不相同则进行修改操作,则有f[i-1,j-1] + 1 所以就有状态转移方程:f[i,j] = min(f[i,j-1] + 1,f[i-1,j] + 1,f[i-1,j-1] + 1);

代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1010;
int f[N][N];
char a[N], b[N];
int main()
{
    int n, m;
    scanf("%d%s", &n, a + 1);
    scanf("%d%s", &m, b + 1);

    //对边界初始化
    for (int i = 0; i <= n; i++) f[0][i] = i;  //对第一行初始化
    for (int j = 0; j <= m; j++) f[j][0] = j;  //对第一列初始化

    for (int i = 1; i <= n; i++)
    {
        for (int j = 1; j <= m; j++)
        {
            f[i][j] = min(f[i][j - 1] + 1, f[i - 1][j] + 1);
            if (a[i] == b[j]) f[i][j] = min(f[i][j], f[i - 1][j - 1]);
            else f[i][j] = min(f[i][j], f[i - 1][j - 1] + 1);
        }
    }
    cout << f[n][m];
    return 0;
}

记忆化 可以使用和最长公共子序列的标记二维数组方法,标记方向以及是什么操作。

5.2.5 最大子序列的和

题目描述 给定一维数组,求出连续的子序列最大值是多少 例如:-1 5 -9 -6 3 分析 状态表示:f[i] 前i个序列当中所有连续子序列的和 属性:max 状态方程:f[i] = max(f[i-1] + a[i],a[i]); 代码

// 包含输出连续子序列
#include<iostream>
using namespace std;
const int N = 10000 + 10;
int a[N];
int f[N];
// 前i个子序列的最大和
int n;
int main()
{
	cin >> n;
	for (int i = 1; i <= n; i++) cin >> a[i];
	int ans = 0;
	int begin = 1;
	for (int i = 1; i <= n; i++)
	{
		if (f[i - 1] + a[i] < a[i]) begin = i;
		f[i] = max(f[i - 1] + a[i], a[i]);
		ans = max(ans, f[i]);
	}
	cout << ans << endl;
	while (ans)
	{
		cout << a[begin] << ' ';
		ans -= a[begin];
		begin++;
	}
	return 0;
}

5.3 区间DP

5.3.1 石子合并

题目描述

image.png

分析

image.png

代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 310;
int f[N][N];
int n;
int a[N];
int main()
{
    cin >> n;
    for(int i = 1; i<= n;i ++) cin >> a[i];
    for(int i = 1; i <= n;i ++) a[i] += a[i-1];  //因为是连续的,使用前缀和来计算堆的代价
    
    for(int len = 1; len <= n ;len ++)  //选取区间的长度,从长度为1开始
        for(int i = 1; i + len <= n; i++)  //i作为起始点
        { 
            int j = i + len;   // j作为结束位置
            f[i][j] = 1e9;
            for(int k = i; k < j; k++)
            {
                f[i][j] = min(f[i][j],f[i][k] + f[k+1][j] + a[j] - a[i-1]);
            }
        }
    cout << f[1][n];
    return 0;
}

5.4 计数类DP

5.4.1 整数划分

题目描述

image.png

分析 例如n取5的时候,有七种方案 5 4 1 3 2 2 1 1 1 3 1 1 2 2 1 1 1 1 1 1 这七种方案 数字的取值是[1,n],可以观察得到,这可以转变完全背包问题,即容量为n 物品是1到n,体积也是1到n,选取多少个物品,体积之和为n的方案个数

状态表示:f[i,j] 所有前i个物品的j体积的个数
属性:个数
状态计算:选取0个、1个、2个、。。。、k个i所构成的体积数
f[i,j]     =               f[i-1,j] + f[i-1,j - i] + ... + f[i-1,j - ki]
f[i,j - i] = f[i - 1, j -i] + ..... + f[i - 1, j - ki] + f[i -1, j - (k+1)i]
如图所示

image.png 最后从二维再压缩成一维f[j] = f[j] + f[j -i]

注意点,取模的时候,是每个结果都取模

朴素版本代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1010 , mod = 1e9 + 7;
int n;
int f[N][N];

int main()
{
    cin >> n;
    for(int i = 0 ; i<= n; i++) f[i][0] = 1;// 前i个物品都不选,方案为1
    for(int i = 1; i <= n; i++)
        for(int j = 0; j <= n; j++)
        {
            f[i][j] = f[i - 1][j] % mod;
            if(j >= i) f[i][j] = (f[i-1][j] + f[i][j - i]) % mod;
        }
    cout << f[n][n];
    return 0;
}

压缩版本代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 1010 , mod = 1e9 + 7;
int n;
int f[N];
int main()
{
    cin >> n;
    f[0] = 1;
    for(int i = 1; i <= n; i++)
        for(int j = i; j <= n; j++)
            f[j] = (f[j] + f[j - i]) % mod;
    cout << f[n];
    return 0;
}

5.5 数位统计DP

5.5.1 计数问题

问题描述

分析

代码

# include <iostream>
# include <cmath>
using namespace std;

int dgt(int n) // 计算整数n有多少位
{
    int res = 0;
    while (n) ++res, n /= 10;
    return res;
}

int cnt(int n, int i) // 计算从1到n的整数中数字i出现多少次 
{
    int res = 0, d = dgt(n);
    for (int j = 1; j <= d; j++) // 从右到左第j位上数字i出现多少次
    {
        // l和r是第j位左边和右边的整数 ; dj是第j位的数字
        int p = pow(10, j - 1), l = n / p / 10, r = n % p, dj = n / p % 10;
        // 计算第j位左边的整数小于l 的情况
        if (i) res += l * p;  // 当i != 0  ,直接算
        if (!i && l) res += (l - 1) * p; // 如果i = 0, 左边高位不能全为0。比如 i 自己就是最高位,就会出现l==0的情况

        // 计算第j位左边的整数等于l
        if ((dj > i) && (i || l)) res += p;
        //eg n==1231 ,算第二位0出现的次数 , 考察 1200...1231 之间,只有1200 - 1209 才有第二位是0,放心大胆加上一个p=10;
        //Q:值得注意的是 特判 i 和 l 不全是 0 ,为什么 ? 
        //A:如果都不是0 ,那就是最一般的情况没什么好说的  
        //  1.如果 i==0 ,l!=0 ,这段代码还有意义 ,比如上面的例子 
        //  2. i!=0 , l == 0 ,也有意义 ,eg n==2210 ,算 千位上 1 出现的次数 , 满足 i!= 0 , l == 0 , 
        //      那么显然有1000个 , 也就是 res+=p,这里p=1000 3. 最后 全都是0 的情况 ,eg n==1210 ,算 千位上 0 出现的次数  ,
        //      满足都是0的条件 ,脚指头一想就知道这个没意义


        if (dj == i) res += r + 1;
        //eg n==1201 , 算第二位0出现的次数,需要考察1200,1201., 我们只需要让r+1即可,也就是 1(我们的r) + 1
        //Q:值得注意的是,我这里删掉了 (i || l) ,why ? 
        //A : 1.如果 i==0 ,l!=0 ,是ok的 ,
        //  如上例  2. i!=0 , l == 0 ,也有意义 ,eg n==2210 ,算 千位上 2出现的次数 ,
        //  满足 dj == i == 2 != 0 , l == 0 。那么显然有210个 , 也就是 res+=r+1,这里r=210,所谓+1是考虑了2000这个数  
        //  3. 全是零的话 ,这个if是进不来的。因为 l==0 ,说明我们要考察最高位 ,暗示dj !=0 ,要是最高位dj == 0 数据就非法了;另外 i == 0 。
        //      怎么可能满足 dj == i 的条件呢? 
    }
    return res;
}

int main()
{
    int a, b;
    while (cin >> a >> b, a)
    {
        if (a > b) swap(a, b);
        for (int i = 0; i <= 9; ++i) cout << cnt(b, i) - cnt(a - 1, i) << ' ';
        cout << endl;
    }
    return 0;
}

5.6 状态压缩DP

所谓状态压缩,就是采用二进制数对其状态进行保存。

为什么不用数组呢?因为用一个二进制数记录方便作位运算。

一般情况下题目描述N范围为几十的这种,就暗示可以使用状态压缩dp

5.6.1 蒙德里安的梦想

题目描述 image.png 分析 题目核心: 1、先放横着的,再放竖着的,如果说先把横着的放完,那么最后肯定只能是放竖着的。 2、总方案数 = 只放横着的小方块的合法方案数。 3、如何判断当前方案是不是合法的。 合法方案是当横着的方块放完毕之后,能不能用竖着的方块刚好塞满。 可以按列来看,每一列内部所有连续的空着的小方块,需要是偶数个。(因为是要塞1x2的方块) 转换代码:(j & k) == 0 && state[j | k] 1、j & k == 0 表示j状态和k状态之间不能有重叠的方块。 2、state[j | k] 表示j状态和k状态之间可塞的块是否为偶数

选择对每一列的状态使用一个二进制数来表示,列上没有方块时,表示0,反之为1。 例如如图:

image.png

列号状态表示
00001
11001
21010
30010

优化的一个步骤:预处理合法的摆放位置

判断横着摆放方格的时候,预处理判断合法的摆放方式,防止竖着的方格放完后,不能完全铺满整个棋盘。

例如: image.png

对于每一列的状态(二进制数),依次遍历每一行,在遍历的途中记录需要记录的连续的没有放置的方格数(连续的0),当出现连续的0是奇数时,表面该列的这种状态是不合法的。

动态规划:

集合:f[i,j]表示前i-1列已经摆好,从第i-1列伸出第i列的状态恰好是j的所有方案数。

集合的划分为:2^n个子集,k属于[0,2^n]。

f[i - 1,k] 表示的是前i-2列已经摆好,从第i-2列伸到第i-1列的状态恰好是k的所有方案数。

所以:f[i,j] = sum(f[i - 1,k]) k 属于[0,2^n];

注意点:

1、第0列是没有从第-1列伸出来的方案,所以初始化为1,即f[0,0] = 1
2、第m列是不存在小方块的,因为前m-1列已经摆放完毕,所以最后结果是f[m,0]

image.png

额外补充知识点 1、判断一个数x是否为偶数 x & 1,如果结果为1为奇数否则为偶数 朴素动态规划(800ms+)代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 12, M = 1 << N;
int n,m;
long long f[N][M];
bool state[M];

int main()
{
    while(cin >> n >> m, n || m)
    {
        memset(f,0,sizeof f);
        for(int i = 0; i < 1 << n; i++)
        {
            state[i] = true;
            int count = 0;
            for(int j = 0; j < n; j ++)
            {
                if(i >> j & 1) // 判断j列是否摆放方块
                {
                    if(count & 1)
                    {
                        state[i] = false;
                        break;
                    }
                    count = 0;
                }
                else count++;
            }
            if(count & 1) state[i] = false;
        }
        
        f[0][0] = 1;
        for(int i = 1; i <=m ;i ++)
            for(int j = 0; j < 1 << n; j++)
                for(int k = 0; k < 1 << n; k ++)
                    if((j & k) == 0 && state[j | k])
                        f[i][j] += f[i - 1][k];
        cout << f[m][0] << endl;
    }
    return 0;
}

动态规划+dfs(8ms+)代码

#include<bits/stdc++.h>
using namespace std;
int n, m;
long long dp[12][2500];
void dfs(int row, int col, int state, int next) {
    //row为当前行,col为当前列,state为当前列的状态,next为可到达的下一列的状态
    //当前列全覆盖后可到达的下一个状态加上当前状态的方案数
    if (row == n) {
        //当前列所有行都已覆盖完毕
        dp[col + 1][next] += dp[col][state];
        return;
    }
    //如果当前行的格子已被覆盖,跳过
    if (state & (1 << row)) dfs(row + 1, col, state, next);
    else {
        //当前行未被覆盖,可放一个1*2的方块
        dfs(row + 1, col, state, next | (1 << row));
        //当前行和下一行都未被覆盖,可放一个2*1的方块
        if (row + 1 < n && (state & (1 << (row + 1))) == 0) dfs(row + 2, col, state, next);
    }
}
int main()
{
    while (scanf("%d%d", &n, &m) && n && m) {
        if (n > m) swap(n, m);
        //因为n行m列和n列m行的方案数等价,所以我们不妨将min(n, m)作为二进制枚举的指数,减少方案数
        memset(dp, 0, sizeof(dp));
        dp[0][0] = 1;
        for (int i = 0; i < m; i++) {
            for (int j = 0; j < (1 << n); j++) {
                if (dp[i][j] > 0) {     //筛选出之前搜索过可到达的状态
                    dfs(0, i, j, 0);
                }
            }
        }
        //因为下标从0开始,所以dp[m][0]表示第m + 1列没有任何第m列的方块伸出的方案数
        cout << dp[m][0] << endl;
    }
    return 0;
}

5.6.2 最短Hamilton路径

题目描述 image.png 分析 状态表示:f[i,j]

集合:所有从0走到j,走过的所有点是属于i的所有路径
属性:Min

状态计算:对f[i,j]划分子集,以倒数第二个点来分,倒数第二点可能是k 属于0 1 2 ... n-1,那么子集的解释就是,所有从0走到j,并且走过的所有点属于i的所有路径,并且倒数第二点是k。

如图所示: image.png

注意点 1、i >> j & 1,对于i状态要包含j这个点 2、(i - (1 << j)) >> k & 1 , 因为我们要求的是i这个状态节点j这个点,并且还需要验证这个状态是否包含k 3、初始化:从0走到0,那么走过的所有点,就只有0这一个点,那么状态第0位上为1,其余都为0,即f[1,0] = 0

代码

#include <iostream>
#include <cstring>
#include <algorithm>
using namespace std;
const int N = 20, M = 1 << N;
int f[M][N];
int a[N][N];

int main()
{
    int n;
    cin >> n;
    for(int i = 0; i < n ;i ++)
        for(int j = 0; j < n; j ++)
            cin >> a[i][j];
    
    memset(f, 0x3f, sizeof f);
    f[1][0] = 0;
    
    for(int i = 0; i < 1 << n; i ++)
    {
        for(int j = 0; j < n; j ++)
            if( i >> j & 1)
                for(int k = 0; k < n; k ++)
                {
                    if((i - (1 << j)) >> k & 1)
                        f[i][j] = min(f[i][j],f[i - (1 << j)][k] + a[k][j]);
                }
    }
    cout << f[(1 << n) - 1][ n - 1];
    return 0;
}

那么这里有一个问题,这题描述的所有点只走一次,那么如果说求的是回到起点的最短路径是多少呢。

观察f[i,j]函数,描述的是从0走到j,所有走过的点是i的路径,那么对于这个二维数组的最后一行,的每一列,表示的该点的最短路径。所以要求回到起点的那么f[(1 << n) - 1][j] + a[j,0],就作为回到起点的最短路径。

代码

#include <iostream>
#include <cstring>

using namespace std;

const int N = 20, M = 1 << N;

int n;
int w[N][N];
int f[M][N];

int main()
{
    cin >> n;
    for(int i = 0;i < n;i ++ )
        for(int j = 0;j < n;j ++ )
            cin >> w[i][j];

    memset(f,0x3f,sizeof f);
    f[1][0] = 0;

    for(int i = 0;i < (1 << n) ;i ++ )
    {
        for(int j = 0;j < n;j ++ )
            if(i >> j & 1)
                for(int k = 0;k < n;k ++ )
                    if( (i - (1 << j)) >> k & 1) 
                        f[i][j] = min(f[i][j],f[i - (1 << j)][k] + w[k][j]);
    }

    // 枚举所有到达出发点前一个城市的状态并取最小值
    int ans = 0x3f3f3f3f;
    for(int i = 1;i < n;i ++ )
        ans = min(ans, f[(1 << n) - 1][i] + w[i][0]); 

    cout << ans << endl;

    return 0;
}

5.7 树形DP

顾名思义,处理具有树形关系的现实情境的问题,树形DP在实际开发当中也会经常遇见。

5.7.1 没有上司的舞会

题目描述 image.png 分析

5.8 记忆化搜索

第六章 贪心

6.1 区间问题

6.1.1 区间选点

6.1.2 最大不相交区间数量

6.1.3 区间分组

6.1.4 区间覆盖

6.2 Huffman数

6.2.1 合并果子

6.3 排序不等式

6.3.1 排队打水

6.4 绝对值不等式

6.4.1 货仓选址

6.5 推公式

6.5.1 耍杂技的牛