蓝桥杯每日一题-----数位dp

前言

今天浅谈一下数位dp的板子,我最初接触到数位dp的时候,感觉数位dp老难了,一直不敢写,最近重新看了一些数位dp,发现没有想象中那么难,把板子搞会了,变通也会变的灵活的多!

引入

以一道例题作为数位dp的引入,题目如下,链接
蓝桥杯每日一题-----数位dp_第1张图片
数据范围为1e9,一般的算法很难把这道题拿下,类似求在一段区间范围内,满足某些条件的数字的个数,并且数据范围很大时就会联想到数位dp算法。

第一个板子

我遇到的数位dp板子有三个,第一个来源于y总的讲解,附上链接和这道题的代码,y总的讲解逻辑清晰,想学习可以直接看视频。

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.StreamTokenizer;
import java.util.Arrays;
import java.util.Scanner;

public class Main {
    static int[][] f = new int[11][10];
    static int k;
public static void main(String[] args) throws IOException {
    StreamTokenizer sc=new StreamTokenizer(new BufferedReader(new InputStreamReader(System.in)));
    sc.nextToken();
    //Scanner scanner = new Scanner(System.in);
    int t = (int)sc.nval;
    while(t> 0) {
        t--;
        sc.nextToken();
        k = (int)sc.nval;
        sc.nextToken();
        int l = (int)sc.nval;
        sc.nextToken();
        int r = (int)sc.nval;
        //int l = scanner.nextInt();
        //int r = scanner.nextInt();
        for (int i = 0; i < 11; i++) {
            Arrays.fill(f[i], 0);
        }
        init();
        System.out.println(dp(r) - dp(l - 1));
        //dp(r);
    }
    return;
}

private static int dp(int num) {
    // TODO Auto-generated method stub
    if(num == 0) {
        return 0;
    }
    int res = 0;
    int last = 0;//上一个位数的数字
    int[] nu = new int[12];
    int n = 1;
    while (num > 0 ) {
        nu[n++] = num%10;
        num = num / 10;
    }
    n--;
    //System.out.println(n);
    for (int i = n; i > 0; i--) {//遍历位数
        int x = nu[i];
        int jj;
        if(i == n) {
            jj = 1;
        }else {
            jj = 0;
        }
        //System.out.println(x);
        //System.out.println(last);
        for (; jj < x; jj++) {//遍历该位数上可以填的数字
        
            if(Math.abs(jj - last) <= k || i == n) {
                //System.out.println("mm" + i);
                res += f[i][jj];
            }
        }
        if(Math.abs(x-last) <= k || i == n) {
            last = x;
            //System.out.println("1111");
        }else {
            break;
        }
        if(i==1) {
            res++;
        }
    }
    //加包含前导0的,其实就是加上不是和num同位数的数字,
    for (int i = 1; i < n; i++) {
        for (int j = 1; j < 10; j++) {//从1开始
            res += f[i][j];
        }
    }
    //System.out.println(res);
    return res;
}

private static void init() {
    // TODO Auto-generated method stub
    for (int i = 0; i < 10; i++) {//初始化只有一位数字的时候,一定符合要求
         f[1][i] = 1;//注意i一定从0开始
    }
    for (int i = 2; i < 10; i++) {//初始化其它位数的数字
        for (int j = 0; j < 10; j++) {//注意,这里可以包含0
            for (int m = 0; m < 10; m++) {
                if(Math.abs(m-j) <= k) {
                    f[i][j] += f[i-1][m];
                }
            }
        }
    }
}
}

第二个板子

  1. 求区间[L,R]内符合条件的数字,转换为求区间[0,L-1]与[0,R]内符合条件的数字个数,答案就是这两个做差。
  2. 在遍历数字的时候,先遍历数字的位数,假设我要求[0,R]内满足条件的数字,R的位数为cnt,在遍历cnt的时候我要注意,不能超过R,如果R是23456,那么第cnt位能够选择的数字范围是[0,2]。如果我第cnt位选择了1,在遍历cnt-1位的时候我就不需要考虑遍历数字是否越界问题,因为cnt位已经决定了后面的位数怎么选都不会比23456大。如果第cnt位选择了2,在遍历cnt-1位时我可以填的范围就变成了[0,3],所以我需要有一个变量记录当前我前面选择的数字是否是贴上界的,令这个变量为limit,如果limit=1,当前位数可以选择的数字上界是a[cnt],否则就是9,其中数组a是把23456拆分的结果,拆分代码如下
int cnt = 0;
	while(n > 0) {
		a[++cnt] = (int) (n%10);
		n/=10;
	}

根据上述分析,会有如下代码,

int up = limit==1?a[cnt]:9;//求当前位数最大能够填的数字
limit&(i==up?1:0)//下一个limit是否还等于1,i表示当前位数填的数字,如果填了最大的那个数,且当前的limit是1,则填下一位数时能够填的最大数字也是受约束的

up表示当前位数可以填的数字上界。

  1. 接下来处理一下前导零,如果前导零的存在不会影响结果,那么不需要处理,否则就要处理一下前导零。比如求包含2和4的数字个数就不需要处理前导0,因为他不影响结果。如果要求数字各个位数不为0
    假设我要求[0,R]内满足条件的数字,R的位数为cnt,在遍历cnt的时候我要注意,在这个位置填的0就是前导0,如果我在这个位置填了0,再去遍历第cnt-1位数字时,这里填的0还是前导0.如果我第cnt位没有填0,那么我在cnt-1位填的0就不是前导0,他是有效的一维数,就像001和101一样,00里面的0都是前导0,是无效的。而10里的0是十位上的0,是有效的。我们用zeros来表示当前位的0是否是前导0,第cnt位的0肯定是前导0,如果第cnt位填了0,第cnt-1位的0才是前导0,否则就不是。所以有

    zeros&(i==0?1:0)//表示下一位的zeros是否是0,i表示当前位填的数字,如果当前位填了0并且当前位的zeros是1,那么下一位的zeros也是1.

    前导0的使用要比上界limit的使用更灵活一点,他是根据题目的要求来使用的。

  2. 这里主要讲记忆化递归。因为这一个板子用的是dfs,而dfs可能会有超时的问题,所以就有了记忆化递归。记忆化递归要标记好当前的状态,所以用来记忆当前状态的数组也是要根据题目设定。
    当题目中有多个测评样例时,进行记忆化的时候要注意不要记住在特定数下的答案。所以有下面的代码

    if(limit == 0) dp[cnt][last][zeros] = res;

    为什么要在limit=0时才进行记忆化呢?因为limit=1说明当前的数是贴上界的,而这个数是受当前这个样例影响的,比如2345这个数字,在遍历到百位数字3时,我是贴上界的,如果千位数字是2,那么此时十位数字只能选0-4,此时得到的res是从0-4递归得到的。但是如果换一个数字2377,在遍历到百位数字3时,我如果是不贴上界的,可选的数字应该是0-9,如果是贴上界的,可选的数字是0-7,明显此时的状态dp[3][3][0]和数字2345的转移是不一样的。所以我只有不贴上界的时候,说明后面的转移都是0-9时才进行记忆。
    读取记忆的时候也是同样的道理,

    if(limit==0&&dp[cnt][last][zeros]!=-1) { return dp[cnt][last][zeros]; }

    只有此时不贴上界的时候才能读取之前的记忆。
    记忆化数组根据具体的题目来设定,并不是统一的,具有高度灵活性,只要解释起来没问题就可以。像小明数这道题没有记忆limit,有些情况也是可以记忆limit的。

分析题目

针对小明数而言,前导0会影响答案,为什么?题目要求相邻两数之差绝对值不超过k,如果存在前导0并且不加以判断那么会认为前导0也是有效数字,那么0后面只能填0-k,但实际前导0应该是无效数字,前面一个数字是前导0,后面可以填0~9中的任意一个数字。如果没有判断前导零,会导致结果比实际的小。 求某些数字相邻位数上的数字之差的绝对值不超过k,来想一下dfs的时候需要什么,必然要有的是当前的位数cnt和是否贴上界limit,刚刚也分析了需要判断前导零,所以有zeros。遍历到cnt位时,来判断一下当前位可以填啥,我要满足相邻位数上的数字之差的绝对值不超过k,我得知道前一个位数我填的是几,所以就有了last,指示前一个位数上填的数字。然后就没有其它的了,所以 dfs(cnt,last,zeros,limit),接下来就直接放代码了。

import java.util.Arrays;
import java.util.Scanner;
public class Main {
	static int dp[][][] = new int[20][20][2];//还要记录当前的状态是不是有前导零!!!!!!!
	static int a[] = new int[20];
	static int k,ans;
	static int nums[] = new int[20];
public static void main(String[] args) {
	Scanner scanner = new Scanner(System.in);
   	int t = scanner.nextInt();
	while(t-- > 0) {
		for(int i = 0;i < 20;i++)
			for(int j = 0;j < 20;j++)
			Arrays.fill(dp[i][j],-1);
		k = scanner.nextInt();
		long l = scanner.nextLong();
		long r = scanner.nextLong();
		System.out.println(solve(r)-solve(l-1));

private static int solve(long n) {
	// TODO Auto-generated method stub
	int cnt = 0;
	while(n > 0) {
		a[++cnt] = (int) (n%10);
		n/=10;
	}
	return dfs(cnt,1,-1,1);
}

private static int dfs(int cnt, int limit, int last,int zeros) {//前导0对答案有影响!!!!!!
	// TODO Auto-generated method stub
	if(cnt==0) {
		return 1;
	}
	if(limit==0&&dp[cnt][last][zeros]!=-1) {
		return dp[cnt][last][zeros];
	}
	int res =0;
	int up = limit==1?a[cnt]:9;
	for(int i = 0;i <= up;i++) {
		if(Math.abs(last-i)<=k||zeros==1) {//3 1 2 0 dp[1][0]
			nums[cnt] = i;
			res += dfs(cnt-1, limit&(i==up?1:0), i,zeros&(i==0?1:0));//120
		}
	}
	if(limit == 0) dp[cnt][last][zeros] = res;	     
	return res;
}
}

如果代码有问题,欢迎在评论区内提出来!第三个板子就不讲啦,其实就是把第二个板子的dfs变成dp,但是个人感觉没有dfs灵活,目前在用第二个板子。

你可能感兴趣的:(蓝桥杯,职场和发展)