为参加2021年蓝桥杯Java软件开发大学B组细心整理常见基础知识、搜索和常用算法解析例题(持续更新...)

蓝桥杯训练笔记

    • Eclipse使用技巧
      • 代码提示和自动补全
      • 代码自动保存
      • 常用快捷键
      • 代码模板
      • 调试的相关快捷键
    • 基础练习
      • 1.next()与nextLine()的区别
      • 2.进制转换
      • 3.最小公倍数与最大公约数
      • 4.Java中整数类型长度
      • 5.Java中的时间戳
      • 6.浮点型数据保留指定位数小数
      • 7.二维数组指定列排序
      • 8.卢卡斯定理
      • 9.快速幂
      • 10.欧拉函数
    • 数据结构
      • 树状数组
    • 搜索入门
      • DFS
      • BFS
      • 双向搜索
      • 回溯法
      • 记忆化搜索
    • 算法提高
      • 1.贪心算法
      • 2.分而治之
      • 3.动态规划
      • 4.模拟算法

Eclipse使用技巧

分享几个学习网站
学相伴
信息学奥林匹克竞赛
力扣

代码提示和自动补全

​ 对于编程人员来说,要记住大量的类名或类方法的名字,着实不是一件容易的事情。如果要IDE能够自动补全代码,那将为我们编程人员带来很大帮助。

​ eclipse代码里面的代码提示功能默认是关闭的,只有输入“.”的时候才会提示功能,下面说一下如何修改eclipse配置:

​ 开启代码自动提示功能打开 Window -> Perferences -> Java ->Editor -> Content Assist,在右边最下面一栏找到 auto-Activation ,下面有三个选项,找到第二个“Auto activation triggers for Java:”选项在其后的文本框中会看到一个“.”存在。这表示:只有输入“.”之后才会有代码提示和自动补全,我们要修改的地方就是这。把该文本框中的“.”换掉,换成“abcdefghijklmnopqrstuvwxyz.”,这样,你在Eclipse里面写Java代码就可以做到按“abcdefghijklmnopqrstuvwxyz.”中的任意一个字符都会有代码提示

代码自动保存

1.第一步:window-> Perferences-> General->WorkPlace -> build :勾选Save automatically before manual build

2.第二步:window-> Perferences->Run/Debug -> Launching:在Save required dirty…下勾选always

常用快捷键

  • 1. ctrl+2,L:为本地变量赋值

    注:ctrl和2同时按完以后释放,再快速按L。不能同时按!

  • 2. Alt+方向键

    这也是个节省时间的法宝。这个组合将当前行的内容往上或下移动。

  • 3. ctrl+m

    大显示屏幕能够提高工作效率是大家都知道的。Ctrl+m是编辑器窗口最大化的快捷键。

  • 4. ctrl+.及ctrl+1:下一个错误及快速修改

    ctrl+.将光标移动至当前文件中的下一个报错处或警告处。这组快捷键我一般与ctrl+1一并使用,即修改建议的快捷键。

  • 5.代码助手:Ctrl+Space(简体中文操作系统是Alt+/

  • 6.Ctrl+D: 删除当前行

  • 7.Ctrl+shift+F:格式化当前代码

  • 8.Alt+Shift+Z:包裹选中的代码

代码模板

  • syso 加内容辅助快捷键:alt+/,System.out.println();
  • for或者foreach 加内容辅助快捷键:alt+/,快速构建for循环
  • main:加内容辅助快捷键:alt+/,快速构建主函数

注意:

​ 除了使用alt+‘/’, Eclipse默认还提供了很多代码模板。打开 Windows->Preferences->Java->Editor->Templates,可以看到所有已定义的代码模板列表。

调试的相关快捷键

F5 单步跳入
F6 单步跳过
F7 单步返回
F8 继续
Ctrl+Shift+D 显示变量的值
Ctrl+Shift+B 在当前行设置或者去掉断点
Ctrl+R 运行至行(超好用,可以节省好多的断点)

基础练习

1.next()与nextLine()的区别

  • next()一定要读取到有效字符后才可以结束输入,对输入有效字符之前遇到的空格键、Tab键或Enter键等结束符,next()方法会自动将其去掉,只有在输入有效字符之后,next()方法才将其后输入的空格键、Tab键或Enter键等视为分隔符或结束符。

  • nextLine()方法的结束符只是Enter键,即nextLine()方法返回的是Enter键之前的所有字符,它是可以得到带空格的字符串的。

  • 注意:如果next()或者nextInt()等下面有nextLine()时,中间要再加一句nextLine()用来接收next()或者nextInt()等过滤的回车、tab、空格。这样才能让下面的nextLine()生效,否则它就接收了enter、tab、空格等,导致用户没有输入就结束了。

例如:

// scanner.next()与scanner.nextLine()的区别

2.进制转换

  • 任意进制转十进制:按权值展开。
1103(8)  ---> 8^3+8^2+8^0*3=579(10)
  • **十进制转任意进制:**倒除取余。十进制数除以目标进制,下一次计算将上一次的商作为被除数除以目标进制,直到商为0结束循环,先输出最后一次的商,然后倒序输出余数
//将n进制转为十进制
public static int toTenNum(int num, int n) {
    int i = 0;
    int sum = 0;
    while (num != 0) {
        sum += (int) ((num % 10) * Math.pow(n, i));
        num /= 10;
        i++;
    }
    return sum;
}

//将十进制转为n进制
public static int toNNum(int num, int n) {
    List<Integer> list = new ArrayList<>();//存入余数
    //倒除取余
    while (num / n != 0) {
        list.add(num % n);
        num /= n;
    }
    list.add(num);//加入最后一次的商
    String str = "";
    for (int i = list.size() - 1; i >= 0; i--) {
        str += list.get(i);
    }
    return Integer.parseInt(str);
}

3.最小公倍数与最大公约数

  • 欧几里得算法两个整数的最大公约数等于其中较小的那个数和两数相除余数的最大公约数。
//递归实现
public static int gcd(int a, int b) {
    //a>b
    if (b == 0) {
        return a;
    } else {
        return gcd(b, a % b);
    }
}
  • 最大公约数和最小公倍数关系A 和 B 的最大公约数 * 最小公倍数 = A * B

注意求三个数的最小公倍数,可以先求任意两个数的最小公倍数,然后将该结果与第三个数求最小公倍即得三个数的最小公倍数。

4.Java中整数类型长度

byte:

  • byte数据类型是8位、有符号的,以二进制补码表示的整数;(256个数字),占1字节
  • 最小值是-128(-2^7);
  • 最大值是127(2^7-1);
  • byte类型用在大型数组中节约空间,主要代替整数,因为byte变量占用的空间只有int类型的四分之一;

short:

  • short数据类型是16位、有符号的以二进制补码表示的整数,占2字节
  • 最小值是-32768(-2^15);
  • 最大值是32767(2^15 - 1);
  • Short数据类型也可以像byte那样节省空间。一个short变量是int型变量所占空间的二分之一;

int:

  • int数据类型是32位、有符号的以二进制补码表示的整数;占4字节
  • 最小值是-2,147,483,648(-2^31);
  • 最大值是2,147,485,647(2^31 - 1);
  • 一般地整型变量默认为int类型;

long:

  • long数据类型是64位、有符号的以二进制补码表示的整数;占8字节
  • 最小值是-9,223,372,036,854,775,808(-2^63);
  • 最大值是9,223,372,036,854,775,807(2^63 -1);
  • 这种类型主要使用在需要比较大整数的系统上;

long a=111111111111111111111111(错误,整数型变量默认是int型)

long a=111111111111111111111111L(正确,强制转换)

5.Java中的时间戳

时间戳是指格林威治时间1970年01月01日00时00分00秒(北京时间1970年01月01日08时00分00秒)起至现在的总秒数。

6.浮点型数据保留指定位数小数

  • 方法一:(推荐)
double maxVal=3.14159265656436456456......;
System.out.println(String.format("%.2f", maxVal));//保留两位小数,自动四舍五入
  • 方法二:数字格式化
DecimalFormat df = new DecimalFormat("#.00");
System.out.println(df.format(f));

7.二维数组指定列排序

  • 方法一
// 重写compare函数实现对二维数组指定列进行升序排序
	Arrays.sort(arr, new Comparator<int[]>() {
		public int compare(int[] a, int[] b) {
			return a[1] - b[1];
		}
	});
  • 方案二

    使用排序算法对指定列进行比较交换、排序;

8.卢卡斯定理

Lucas 定理用于求解大组合数取模的问题,其中模数必须为素数。正常的组合数运算可以通过递推公式求解(,但当问题规模很大,而模数是一个不大的质数的时候,就不能简单地通过递推求解来得到答案,需要用到 Lucas 定理。

C(n, m) % p = C(n / p, m / p) * C(n%p, m%p) % p

为参加2021年蓝桥杯Java软件开发大学B组细心整理常见基础知识、搜索和常用算法解析例题(持续更新...)_第1张图片

代码实现

long long Lucas(long long n, long long m, long long p) {
  if (m == 0) return 1;
  return (Lucas(n % p, m % p, p) * Lucas(n / p, m / p, p)) % p;
}

9.快速幂

​ 快速幂,又称二进制取幂(Binary Exponentiation,也称平方法),是一个在Q(log n) 的时间内计算 a的n次幂的小技巧,而暴力的计算需要 Q(n)的时间。而这个技巧也常常用在非计算的场景,因为它可以应用在任何具有结合律的运算中。其中显然的是它可以应用于模意义下取幂、矩阵幂等运算。

// 快速幂算法
public static long binpow(long a, long b) {
    long res = 1;
    int count=0;
    while (b > 0) {
        if ((b & 1) == 1) {// 相当于b%2 == 1
            res = res * a;
        }
        a = a * a;
        b >>= 1;// 相当于b = b / 2的1次幂
        count++;
    }
    System.out.println("count:"+count);
    return res;
}

打印结果:

count:4
1594323

应用:模意义下取幂

求:image-20210329145212401

这是一个非常常见的应用,例如它可以用于计算模意义下的乘法逆元

既然我们知道取模的运算不会干涉乘法运算,因此我们只需要在计算的过程中取模即可。

代码实现:

//模意义下取幂算法
//取模的运算不会干涉乘法运算
public static long binpow(long a, long b, long m) {
    a %= m;
    long res = 1;
    while (b > 0) {
        if ((b & 1) == 1)
            res = res * a % m;
        a = a * a % m;
        b >>= 1;
    }
    return res;
}

注意:根据费马小定理,如果 m 是一个质数,我们可以计算 ,即新指数b等于原指数n mod(m-1) 来加速算法过程。

费马小定理介绍如下:

为参加2021年蓝桥杯Java软件开发大学B组细心整理常见基础知识、搜索和常用算法解析例题(持续更新...)_第2张图片

10.欧拉函数

​ 欧拉函数phi(n)数论中非常重要的一个函数,其表示1到n-1之间,与n互质(公约数只有1的两个整数)的数的个数

​ 当 n 是质数的时候,显然有phi(n)= n - 1

欧拉函数的一些性质

  • (1)欧拉函数是积性函数

    如果gcd(a,b)=1,(即两个数互质)则有phi(axb)=phi(a)xphi(b)

  • (2)若n=pk,p是质数,那么phi(n)=pk-p^(k-1)

如果只要求一个数的欧拉函数值,那么直接根据定义质因数分解的同时求就好了

// 分解质因数:Pollard Rho 算法优化
public static int phi(int n) {
    int ans = n;
    for (int i = 2; i * i <= n; i++)
        if (n % i == 0) { // 如果 i 能够整除 N,说明 i 为 N 的一个质因子
            ans = ans / i * (i - 1);//n*(1-(1/p))转化为n/p*(p-1)
            while (n % i == 0) //划去所有i的倍数,类似于素数筛的原理
                n /= i;
        }
    if (n > 1)// 说明再经过操作之后 N 留下了一个素数,最后如果n大于1说明还有一个质数因子
        ans = ans / n * (n - 1);
    return ans;
}

数据结构

树状数组

什么是树状数组?

​ 就是用数组来模拟树形结构。那么衍生出一个问题,为什么不直接建树?答案是没必要,因为树状数组能处理的问题就没必要建树。和Trie树的构造方式有类似之处。

树状数组可以解决什么问题?

​ 可以解决大部分基于区间上的更新和求和问题。

优缺点

优点:

  • 修改和查询的复杂度都是O(logN),而且相比线段树系数要少很多,比传统数组要快,而且容易写。

缺点:

  • 复杂的区间问题还是不能解决,功能还是有限。

树状数组介绍

为参加2021年蓝桥杯Java软件开发大学B组细心整理常见基础知识、搜索和常用算法解析例题(持续更新...)_第3张图片

黑色数组代表原来的数组(下面用**A[i]代替),红色结构代表我们的树状数组(下面用C[i]**代替),发现没有,每个位置只有一个方框,令每个位置存的就是子节点的值的和,则有

  • C[1] = A[1];
  • C[2] = A[1] + A[2];
  • C[3] = A[3];
  • C[4] = A[1] + A[2] + A[3] + A[4];
  • C[5] = A[5];
  • C[6] = A[5] + A[6];
  • C[7] = A[7];
  • C[8] = A[1] + A[2] + A[3] + A[4] + A[5] + A[6] + A[7] + A[8];

可以发现,这颗树是有规律

C[i] = A[i - 2^k+1] + A[i - 2^k+2] + … + A[i]; //k为i的二进制中从最低位到高位连续零的长度

例如i = 8(1000)时候,k = 3,可自行验证。

这个怎么实现求和呢,比如我们要找前7项和,那么应该是SUM = C[7] + C[6] + C[4];

而根据上面的式子,容易的出SUMi = C[i] + C[i-2^k1] + C[(i - 2^k1) - 2^k2] + …;

其实树状数组就是一个二进制上面的应用。

现在新的问题来了2k该怎么求呢,不难得出2k = i&(i^(i-1));但这个还是不好求出呀,

前辈的智慧就出来了2^k = i&(-i);借鉴大佬

​ 总结一下:x&(-x),当x为0时结果为0;x为奇数时,结果为1;x为偶数时,结果为x中2的最大次方的因子。

而且这个有一个专门的称呼,叫做lowbit,即取2^k。

如何建立树状数组?

​ 上面已经解释了如何用树状数组求区间和,那么如果我们要更新某一个点的值呢,还是一样的,上面说了**C[i] = A[i - 2^k+1] + A[i - 2^k+2] + … + A[i]; **,那么如果我们更新某个A[i]的值,则会影响到所有包含有A[i]位置。如果求A[i]包含哪些位置里呢,同理有:

A[i] 包含于 C[i + 2^k]、C[(i + 2^k) + 2^k]…;

​ 好,现在已经搞清楚了更新和求和,就可以来建树状数组了。如果上面的求和、更新或者lowbit步骤还没搞懂的化,建议再思考弄懂再往下看。

那么构造一个树状数组则为:

//树状数组
public class Main07 {
	public static int n;
	public static int[] A = new int[1005]; // 对应原数组和树状数组
	public static int[] C = new int[1005];
	public static void main(String[] args) {

	}
	// 用来取一个二进制最低位的一与后边的0组成的数
	public static int lowbit(int x) {
		return x & (-x);
	}
	// 更新
	public static void update(int i, int k) { // 在i位置加上k
		while (i <= n) {
			C[i] += k;
			i += lowbit(i);
		}
	}
	// 求和
	public static int getsum(int i) { // 求A[1 - i]的和
		int res = 0;
		while (i > 0) {
			res += C[i];
			i -= lowbit(i);
		}
		return res;
	}
}

搜索入门

搜索,也就是对状态空间进行枚举,通过穷尽所有的可能来找到最优解,或者统计合法解的个数。搜索有很多优化方式,如(1)减小状态空间;(2)更改搜索顺序,剪枝等。因此,搜索是一些高级算法的基础。

DFS

算法思想

​ 在 搜索算法 中,该词常常指利用递归函数方便地实现暴力枚举的算法,与图论中的 DFS 算法有一定相似之处,但并不完全相同。

​ 它的思想是从一个顶点V0开始,沿着一条路一直走到底,如果发现不能到达目标解,那就返回到上一个节点,然后从另一条路开始走到底,这种尽量往深处走的概念即是深度优先的概念。

​ 同时与 BFS 类似,DFS 会对其访问过的点打上访问标记,在遍历图时跳过已打过标记的点,以确保 每个点仅访问一次。符合以上两条规则的函数,便是广义上的 DFS

具体地说,DFS 大致结构如下:

DFS(v) // v 可以是图中的一个顶点,也可以是抽象的概念,如 dp 状态等。
  在 v 上打访问标记
  for u in v 的相邻节点
    if u 没有打过访问标记 then
      DFS(u)
    end
  end
end

​ 以上代码只包含了 DFS 必需的主要结构。实际的 DFS 会在以上代码基础上加入一些代码,利用 DFS 性质进行其他操作。

例1.整数分割问题

​ 把正整数 分解为 3个不同的正整数,如6=1+2+3 ,排在后面的数必须大于等于前面的数,输出所有方案。

思考

对于这个问题,如果不知道搜索,应该怎么办呢?

当然是三重循环,参考代码如下:

for (int i = 1; i <= n; ++i)
  for (int j = i; j <= n; ++j)
    for (int k = j; k <= n; ++k)
      if (i + j + k == n) printf("%d=%d+%d+%d\n", n, i, j, k);

那如果是分解成四个整数呢?再加一重循环?

那分解成小于等于 m 个整数呢?这时候就需要用到递归搜索了。

该类搜索算法的特点在于,将要搜索的目标分成若干“层”,每层基于前几层的状态进行决策,直到达到目标状态。

思路:

考虑上述问题,将正整数 n分解成小于等于 m个正整数之和,且排在后面的数必须大于等于前面的数,输出所有方案。

设一组方案将正整数 n分解成 k个正整数 a1,a2,a3,…,ak的和。

我们将问题分层,第 i 层决定 ai 。则为了进行第 i层决策,我们需要记录三个状态变量:,image-20210327154229226表示后面所有正整数的和;以及 image-20210327154315282,表示前一层的正整数,以确保正整数递增;以及 i ,确保我们最多输出 m 个正整数。

为了记录方案,我们用 arr 数组,第 i 项表示 ai. 注意到 arr 实际上是一个长度为 i 的栈。

代码实现:

//DFS:整数分割
public class Main01 {
	public static int m;
	public static int[] arr = new int[100]; // arr 用于记录方案

	public static void main(String[] args) {
		Scanner scanner = new Scanner(System.in);
		int n = scanner.nextInt();
		m = scanner.nextInt();
		dfs(n, 1, 1);
		scanner.close();
	}
	//dfs
	public static void dfs(int n, int i, int a) {
		if (n == 0) {
			for (int j = 1; j <= i - 1; ++j)
				System.out.print(arr[j] + "\t");
			System.out.println(" ");
		}
		if (i <= m) {
			for (int j = a; j <= n; ++j) {
				arr[i] = j;
				dfs(n - j, i + 1, j); // 请仔细思考该行含义
			}
		}
	}
}

例2.全排列dfs实现

代码实现:

//输出n的全排列
public class Main02 {
	public static boolean[] vis = new boolean[100];
	public static int[] a = new int[100];
	public static int n;

	public static void main(String[] args) {
		Scanner scanner = new Scanner(System.in);
		n = scanner.nextInt();
		dfs(1);
		scanner.close();
	}
	//dfs
	public static void dfs(int step) {
		if (step == n + 1) { // 边界
			for (int i = 1; i <= n; i++) {
				System.out.print(a[i] + "\t");
			}
			System.out.println("");
			return;
		}
		for (int i = 1; i <= n; i++) {
			if (vis[i] == false) {
				vis[i] = true;
				a[step] = i;
				dfs(step + 1);
				vis[i] = false;
			}
		}
		return;
	}
}

打印结果:

输入:3
输出:
    1	2	3	
    1	3	2	
    2	1	3	
    2	3	1	
    3	1	2	
    3	2	1

BFS

算法思想

​ BFS(Breadth First Search) 即广度优先搜索,在数和图中非常常见的一种搜索算法。类似于树的按层次遍历,是对树的层次遍历方法的推广。

​ 所谓广度优先。就是每次都尝试访问同一层的节点。 如果同一层都访问完了,再访问下一层。这样做的结果是,BFS 算法找到的路径是从起点开始的 最短 合法路径。换言之,这条路所包含的边数最小。在 BFS 结束时,每个节点都是通过从起点到该点的最短路径访问的。

具体实现如下面伪代码:

bfs(s) {
  q = new queue()
  q.push(s), visited[s] = true
  while (!q.empty()) {
    u = q.pop()
    for each edge(u, v) {
      if (!visited[v]) {
        q.push(v)
        visited[v] = true
      }
    }
  }
}

**(1)**具体来说,我们用一个队列 Q 来记录要处理的节点,然后开一个 布尔数组来标记某个节点是否已经访问过了。

**(2)**开始的时候,我们把起点 s 以外的节点的 vis 值设为 0,意思是没有访问过。然后把起点 s 放入队列 Q 中。

**(3)**之后,我们每次从队列 Q 中取出队首的点 u,把 u 相邻的所有点 v 标记为已经访问过了并放入队列 Q。直到某一时刻,队列 Q 为空,这时 BFS 结束。

**(4)**在 BFS 的过程中,也可以记录一些额外的信息。比如上面的代码中,d 数组是用来记录某个点到起点的距离(要经过的最少边数),p 数组是记录从起点到这个点的最短路上的上一个点。

**(5)**有了 d 数组,可以方便地得到起点到一个点的距离。

**(6)**有了 p 数组,可以方便地还原出起点到一个点的最短路径。上面的 restore 函数就是在做这件事:restore(x) 输出的是从起点到 x 这个点所经过的点。

为参加2021年蓝桥杯Java软件开发大学B组细心整理常见基础知识、搜索和常用算法解析例题(持续更新...)_第4张图片

双向搜索

算法思想

​ 双向同时搜索的基本思路是从状态图上的起点和终点同时开始进行 广搜 或 深搜。如果发现搜索的两端相遇了,那么可以认为是获得了可行解

Meet in the middle

​ Meet in the middle 算法没有正式译名,常见的翻译为「折半搜索」、「双向搜索」或「中途相遇」。它适用于输入数据较小,但还没小到能直接使用暴力搜索的情况。

​ 主要思想是将整个搜索过程分成两半,分别搜索,最后将两半的结果合并

例1:关灯问题

​ 有 m 盏灯,每盏灯与若干盏灯相连,每盏灯上都有一个开关,如果按下一盏灯上的开关,这盏灯以及与之相连的所有灯的开关状态都会改变。一开始所有灯都是关着的,你需要将所有灯打开求最小的按开关次数。(1 <= n <= 35)

解题思路:

​ meet in middle 就是让我们先找一半的状态,也就是找出只使用编号为 1 到 mid 的开关能够到达的状态,再找出只使用另一半开关能到达的状态。如果前半段和后半段开启的灯互补,将这两段合并起来就得到了一种将所有灯打开的方案。具体实现时,可以把前半段的状态以及达到每种状态的最少按开关次数存储在 map 里面,搜索后半段时,每搜出一种方案,就把它与互补的第一段方案合并来更新答案。

回溯法

算法思想

​ 回溯法是一种经常被用在 深度优先搜索(DFS) 和 广度优先搜索(BFS) 的技巧。

​ 其本质是**:走不通就回头**。

工作原理

  1. 构造空间树;
  2. 进行遍历;
  3. 如遇到边界条件,即不再向下搜索,转而搜索另一条链
  4. 达到目标条件,输出结果。

例1:跳跳棋盘

​ 现在有一个如下的 6 x 6 的跳棋棋盘,有六个棋子被放置在棋盘上,使得每行,每列,每条对角线(包括两条主对角线的所有对角线)上都至多有一个棋子。

                                    0   1   2   3   4   5   6
                                      -------------------------
                                    1 |   | O |   |   |   |   |
                                      -------------------------
                                    2 |   |   |   | O |   |   |
                                      -------------------------
                                    3 |   |   |   |   |   | O |
                                      -------------------------
                                    4 | O |   |   |   |   |   |
                                      -------------------------
                                    5 |   |   | O |   |   |   |
                                      -------------------------
                                    6 |   |   |   |   | O |   |
                                      -------------------------

​ 上面的布局可以用序列 { 2,4,6,1,3,5} 来描述,第 i 个数字表示在第 i 行的第 ai 列有一个棋子,如下所示

​ 行号 i :{1,2,3,4,5,6}

​ 列号 ai : { 2,4,6,1,3,5}

​ 这只是跳棋放置的一个方案。请编一个程序找出所有方案并把它们以上面的序列化方法输出,按字典顺序排列。你只需输出前 3 个解并在最后一行输出解的总个数。特别注意:你需要优化你的程序以保证在更大棋盘尺寸下的程序效率。

记忆化搜索

算法思想

算法提高

1.贪心算法

算法思想

​ 顾名思义,**贪心算法总是作出在当前看来最好的选择。**也就是说贪心算法并不从整体最优考虑,它所作出的选择只是在某种意义上的局部最优选择。当然,希望贪心算法得到的最终结果也是整体最优的。**虽然贪心算法不能对所有问题都得到整体最优解,但对许多问题它能产生整体最优解。**如单源最短路经问题,最小生成树问题等。在一些情况下,即使贪心算法不能得到整体最优解,其最终结果却是最优解的很好近似。

例1:找零钱问题

​ 一个小孩用一美元来买价值不足一美元的糖果,售货员希望用最少的硬币找给小孩零钱。假设有面值25美分、10美分、5美分、及1美分的硬币,而且数目不限。售货员每次选择一枚硬币,凑成要找的零钱。选择时所依赖的是贪婪准则在不超过要找的零钱总数情况下,每一次都选择面值尽可能大的硬币。直到凑成的零钱总数等于要找的零钱总数。

​ 假设要找给小孩67美分,前两步选择的是两个25美分的硬币(第三步就不能选择25美分的硬币,否则零钱总数就超过67美分),第三步选择10美分的硬币,然后是5美分的硬币,最后是两个1美分的硬币。

代码实现:

public static void main(String[] args) {
    int change = 67;// 要找的零钱
    int max = 0;// 定义选择25美分的数量
    int mid = 0;// 定义选择10美分的数量
    int litte = 0;// 定义选择5美分的数量
    int min = 0;// 定义选择1美分的数量

    if (change != 0) {
        max = change / 25;
        change %= 25;

        mid = change / 10;
        change %= 10;

        litte = change / 5;

        min = change % 5;

    }
}

例2:0/1背包问题

​ 有n个物品和一个容量为c的背包,从n个物品中选取装包的物品。物品i的重量为wi,价值为pi。一个可行的背包装载是指,装包的物品总重量不超过背包的容量。一个最佳背包装载是指,物品总价值最高的可行的背包装载

可能的贪婪策略:

​ **(1)价值贪婪准则:从剩余物品中选出可以装入背包的价值最大的物品。**使用这种策略,不一定得到最优解。

​ **(2)重量贪婪策略:从剩余物品中选出可装入背包的重量最小的物品。**使用这种策略,不一定得到最优解。

​ **(3)价值密度pi/wi贪婪法则:从剩余物品中选出可装入背包的pi/wi值最大的物品。**这种策略也不能保证获得最优解。

0/1背包问题是一个NP-复杂问题。虽然价值密度贪婪法则不能保证获得最优解,但是我们认为它是一个好的启发式算法,而且在更多的时候它的解非常接近最优解

例3.跳跃游戏

​ 给定一个非负整数数组,你最初位于数组的第一个位置。数组中的每个元素代表你在该位置可以跳跃的最大长度判断你是否能够到达最后一个位置。

示例 1:

输入: [2,3,1,1,4]
输出: true
解释: 我们可以先跳 1 步,从位置 0 到达 位置 1, 然后再从位置 1 跳 3步到达最后一个位置。 示例 2:

示例 2:

输入: [3,2,1,0,4]
输出: false
解释: 无论怎样,你总会到达索引为 3 的位置。但该位置的最大跳跃长度是 0 ,所以你永远不可能到达最后一个位置。

思路:

  • 判断数组长度作第一步判断;

  • max代表当前最大能到达的位置,默认值是 0 ,即从下标 0 的位置出发;

  • 从下标 0 的位置依次访问到终点的前一个位置,每到一个位置就用当前的能到达的位置 max 和 当前位置( i +

    nums[i]当前位置的最大步 )比较,取出最大到达位置的数,更新 max 最大到达数

  • 若发现当前 max < i ,即当前的最大到达位置不包括当前位置,即不能达到终点,返回false;

  • 当走完终点前的位置,还需要判断当前最大到达的位置 max 是否 大于或等于 终点的下标,如果大于或等于 则返回true,否则返回 false;

注意:

贪心算法是没有做题套路,大多数情况下只能靠自己去想思路,或者举极端的例子去验证是否能用贪心算法,本道题也可以用动态规划,但是却没有贪心算法这么巧妙。

代码实现

public boolean canJump(int[] nums) {
    int max=0; //首先定义 当前能跨到的数组下标 初始值是0
    if(nums.length<=1) return true; //如果传过来的数组长度为1 那只有一个出发点,必定可以到达
    for(int i=0;i<nums.length-1;i++)
    {
        //通过每一步取最大值,如果当前的跨度不包括当前值,即达不到当前值的位置,返回false;
        if(max < i) return false;
        max = Math.max(max, i+nums[i]);
    }
    //包括 返回true
    if(max>=nums.length-1) 
        return true;
    else //遍历到最后,如果最大值不包括最后一位  返回false
        return false;
}

2.分而治之

算法思想

3.动态规划

算法思想

​ 动态规划和贪婪法一样,对一个问题的解是一系列抉择的结果。在贪婪法中我们依据贪婪准则做出的每一个抉择都是不可撤回的。而在动态规划中,我们要考察一系列抉择,以确定一个最优抉择序列是否包含最优抉择子序列

​ 一个”大问题“之所以能用”动态规划“解决,并不是因为它能拆解成一堆小问题,而是这些”小问题会不会被重复调用。

动态规划算法的关键在于解决冗余,这是动态规划算法的根本目的。把问题分解成若干个子问题,然后记录下每个子问题的解,避免重复计算,再从众多子问题中选出最优解。

例1.最大子数组

​ 输入一个整型数组,数组中的一个或连续多个整数组成一个子数组求所有子数组的和的最大值。要求时间复杂度为O(n)。

示例1:
输入: nums = [-2,1,-3,4,-1,2,1,-5,4]
输出: 6
解释:连续子数组[4,-1,2,1] 的和最大,为 6。

代码实现

public static int maxSubArray(int[] nums) {
    int[] dp = new int[nums.length];
    dp[0] = nums[0];
    int max = dp[0];
    for(int i=1;i < nums.length;i++){
        dp[i] = Math.max(dp[i-1] + nums[i],nums[i]);// dp[i]应该理解为包含nums[j]的子数组最大值,
                                                    // 然后通过下一步式子来判断是否对最终的max有贡献
        max = Math.max(max,dp[i]);//
    }
    //输出一下dp
    for(int i:dp){
        System.out.println(i);
    }
    return max;
}

例2. 0/1背包问题

给定 n 个重量为w1,w2···wn,价值为v1,v2···vn,背包的承重量为 W 。背包问题是给定 W 重量的背包装入尽可能高价值的物品。

解题思路

定义F(i,w):前 i 个物品,当前背包还可以装w重量。

我们可以把前 i 个物品中放入重量为 W 的背包中价值最大化问题分解成两个问题:

  1. 装入背包的物品不包括第 i 个物品,F(i , w) = F(i -1 , w);
  2. 装入背包的物品包括第 i 个物品,F(i , w) = F(i -1 , w - wi) + vi;

假设:有四个物品,重量分别为 2, 1, 3, 2; 价值为 12, 10, 20, 15; 给定一个承重为 W = 5 的背包,求装入物品的最大价值是多少?

为参加2021年蓝桥杯Java软件开发大学B组细心整理常见基础知识、搜索和常用算法解析例题(持续更新...)_第5张图片

首先,根据以上步骤,可以将问题分解为:

  1. F(4, 5) = max{F(3, 5), F(3, 3) + 15 };
  2. F(3, 5) = max{F(2, 5), F(2, 2) + 20 };
  3. F(3, 3) = max{F(2, 3), F(2, 0) + 20 };
  4. F(2, 5) = max{F(1, 5), F(1, 4) + 10 };
    .
    .
    .
  5. F(0, 5) = 0;
  6. F(i, 0) = 0;

同样的,我们需要定义一个二维数组来保存每一个子问题的解,避免重复计算。

为参加2021年蓝桥杯Java软件开发大学B组细心整理常见基础知识、搜索和常用算法解析例题(持续更新...)_第6张图片

代码实现

public class Main{
    static int[] v = {12, 10, 20, 15};
    static int[] w = {2, 1, 3, 2};
    
    public static void main(String[] args){
    int W = 5;//背包最大容量
    int[][] F = new int[v.length][W];//存储子问题的解
    Func(F, v.length-1, W-1);
    }
 	
    public static int Func(int[][] F,int i,int W)
    {
        if(i < 0 || W < 0) return 0;//如果价值矩阵长度为0,即没有物品,返回价值为0
        if(F[i][W] != 0) return F[i][W];
        if((W-w[i]) >= -1)
            F[i][W] = Math.max(Func(F,i-1, W), Func(F,i-1, W-w[i]) + v[i]);
        else
            F[i][W] = Func(F,i-1, W);
        return F[i][W];
    }
}

3.最长单调递增子序列

最长单调递增子序列是一个很经典的问题,他所需要的就是要在整个序列中(序列一般来讲应该是无序的)寻找出一个最长的升序子序列,这个子序列中的所有元素都必须来自这个原序列,它们可以在原序列中是不相邻的,但是它们之间的相对位置是不可以改动的。

解题思路

我们考虑单调递增子序列的独立问题求解方法:

​ 我们可以发现这个问题具有最优子结构性质,也就是说我们可以把这个问题拆分成若干个存在重复情况的子问题进行求解。

​ 如果我们想查找序列1,7,3,5,9,4,8的最长公共子序列,可以划分为寻找1,7,3,5,9,4的最长公共子序列(在这里称作子问题1),末尾再加上8,看前面的子问题1中获得的最长公共子序列答案最后一个元素是否小于8,如果小于8,那么原问题的解就是子问题1的解后面加上8,如果子问题1的解最后一个元素大于8,那么原问题的解就是子问题1的解。如果存在两个相同长度的解,并且一个最后元素大于8,一个小于8,那么我们便选择小于8的解最后在加上8即可。

状态转移方程如下(dp[i]代表以第i个元素结尾的升序序列长度为多少):
​ dp[i] = dp[j]+1( 如果存在i前面的一个j满足src[j] < src[i] 且 dp[j] > =dp[i] )
​ dp[i] = dp[i] (如果前面存在一个j,满足src[j] < src[i] 但是 dp[j] < dp[i]-1)
​ dp[i] = 1 (如果前面所有的元素都大于第i个元素,以及初始化数据)

最后由于我们并不知道真正的最长子序列是以哪个元素结尾的,所以我们要找出dp数组中的最大值:

Arrays.stream(dp).max().getAsInt();//获取数组中元素最大值

代码实现

public static void main(String[] args) {
    int[] arr = { 1, 7, 3, 5, 9, 4, 8 };
    int n = arr.length;
    int[] dp = new int[n];// dp[i]代表以第i个元素结尾的升序序列长度为多少
    Arrays.fill(dp, 1);// 初始化数据

    for (int i = 0; i < n; i++) {
        for (int j = 0; j < i; j++) {
            if (arr[j] < arr[i] && dp[j] >= dp[i]) {
                dp[i] = dp[j] + 1;
            }
        }
    }
    System.out.println(Arrays.stream(dp).max().getAsInt());
}

4.区间动态规划

区间类动态规划是线性动态规划的扩展,它在分阶段地划分问题时,与阶段中元素出现的顺序和由前一阶段的哪些元素合并而来有很大的关系。令状态f( i , j ) 表示将下标位置 i 到 j 的所有元素合并能获得的价值的最大值,那么 :

f( i, j )=max{f( i, k )+f( k+1, j ) + cost } , cost 为将这两组元素合并起来的代价。

区间 DP 的特点:

合并:即将两个或多个部分进行整合,当然也可以反过来;

特征:能将问题分解为能两两合并的形式;

求解:对整个问题设最优值,枚举合并点,将问题分解为左右两个部分,最后合并两个部分的最优值得到原问题的最优值。

典型例题:能量项链 矩阵乘法链 合并石子

例1.矩阵乘法链

​ 有n个矩阵,大小分别为a0a1, a1a2, a2a3, …, a[n-1]a[n],现要将它们依次相乘,只能使用结合率,求最少需要多少次运算。
​ 两个大小分别为p
q和q
r的矩阵相乘时的运算次数计为pqr。

代码示例:

import java.util.Scanner;

//矩阵乘法链
public class Main {
	public static int n;// 数字的个数,实际的矩阵个数为n-1
	public static long[] arr;
	public static long[][] dp;

	public static void main(String[] args) {
		Scanner scanner = new Scanner(System.in);
		n = scanner.nextInt() + 1;
		arr = new long[n];
		dp = new long[n][n];
		for (int i = 0; i < n; i++)
			arr[i] = scanner.nextInt();

		// 递推方程为: dp[i][j] = min{dp[i][k]+dp[k+1][j]+arr[i-1]*arr[k]*arr[j]} i<= k 
		for (int len = 2; len < n; len++)
			for (int i = 1; i < n - len + 1; i++) {
				int j = i + len - 1;
				if (i == j)
					continue;
				if (dp[i][j] != 0L)
					continue;
				for (int k = i; k < j; k++) {
					long t = dp[i][k] + dp[k + 1][j] + getRes(i - 1, k, j);
					if (k == i) {
						dp[i][j] = t;
						continue;
					}
					if (t < dp[i][j])
						dp[i][j] = t;
				}
			}
		System.out.println(dp[1][n - 1]);
		scanner.close();
	}

	public static long getRes(int i, int j, int k) {
		return arr[i] * arr[j] * arr[k];
	}
}

5."状压"动态规划

​ 状压 dp 是动态规划的一种,通过将状态压缩为整数来达到优化转移的目的。

​ 回忆0/1背包问题,要求每一种放法的背包价值,所以我们状态应该是这n件物品的放与不放的情况。最容易想到的是开个n维数组,第i个维度的下标如果是1的话代表放第i件物品,0的话代表不放第i件物品;但是这样很容易造成空间浪费,而且多维数组也不好开;
​ 我们仔细观察就会发现,每件物品有放与不放两种选择;假设我们有5件物品的时候,用1和0代表放和不放如果这5件物品都不放的话,那就是00000;如果这5件物品都放的话, 那就是11111;看到这,我们知道可以用二进制表示所有物品的放与不放的情况;如果这些二进制用十进制表示的话就只有一个维度了。而且这一个维度能表示所有物品放与不放的情况;这个过程就叫做状态压缩;

细节:观察可以知道在上面的例子中00000 ~ 11111可以代表所有的情况,转化为十进制就是0~(1<<5 - 1);

例题互不侵犯

​ 在 N x N 的棋盘里面放 K 个国王,使他们互不攻击,共有多少种摆放方案。国王能攻击到它上下左右,以及左上左下右上右下八个方向上附近的各一个格子,共 8 个格子。

解题思路:

(1)我们用 f(i ,j s) 表示前 i 行 ,当前状态为 j ,且已经放置 s 个国王时的方案数。

(2)其中 j 这一维用一个二进制整数表示(该数的某一个数位为0代表对应的列不放国王,数位为 1 代表对应的列放国王)。

(3)我们需要在刚开始的时候预处理出一行的所有合法状态: sta(x)( 排除同一行内两个国王相邻的不合法情况),在转移的时候枚举这些可能状态进行转移。

​ **(4)**设上一行的状态为 x ,得到如下的状态转移方程:f(i ,j ,s)=求和符号 f(i-1 ,x, s-sta(x));

注意:需要注意在转移时排除相邻两行国王互相攻击的不合法情况。

参考代码:

//互不侵犯 -- 状压dp
public class Main {
	public static long[] sta = new long[2005];
	public static long[] sit = new long[2005];
	public static long[][][] f = new long[15][2005][105];
	public static int n;
	public static int k;
	public static int cnt;

	public static void main(String[] args) {
		Scanner scanner = new Scanner(System.in);
		n = scanner.nextInt();
		k = scanner.nextInt();
		dfs(0, 0, 0);// 先预处理一行的所有合法状态
		for (int i = 1; i <= cnt; i++) {
			f[1][i][(int) sta[i]] = 1;
		}
		for (int i = 2; i <= n; i++)
			for (int j = 1; j <= cnt; j++)
				for (int l = 1; l <= cnt; l++) {
					if ((sit[j] & sit[l]) == 1)
						continue;
					if (((sit[j] << 1) & sit[l]) == 1)
						continue;
					if ((sit[j] & (sit[l] << 1)) == 1)
						continue;
					// 排除不合法转移
					for (long p = sta[j]; p <= k; p++)
						f[i][j][(int) p] += f[i - 1][l][(int) (p - sta[j])];
				}
		long ans = 0;
		for (int i = 1; i <= cnt; i++)
			ans += f[n][i][k]; // 累加答案
		System.out.println(ans);
		scanner.close();
	}

	// 预处理
	public static void dfs(int x, int num, int cur) {
		if (cur >= n) { // 有新的合法状态
			sit[++cnt] = x;
			sta[cnt] = num;
			return;
		}
		dfs(x, num, cur + 1); // cur位置不放国王
		dfs(x + (1 << cur), num + 1, cur + 2); // cur位置放国王,与它相邻的位置不能再放国王
	}
}

6.概率动态规划

​ 概率 DP 用于解决概率问题与期望问题。一般情况下,解决概率问题需要顺序循环,而解决期望问题使用逆序循环,如果定义的状态转移方程存在后效性问题,还需要用到 高斯消元 来优化。

DP求概率

​ 这类题目采用顺推,也就是从初始状态推向结果。同一般的 DP 类似的,难点依然是对状态转移方程的刻画,只是这类题目经过了概率论知识的包装。

例1:袋子里的老鼠

​ 袋子里有 w 只白鼠和 b 只黑鼠,公主和龙轮流从袋子里抓老鼠。谁先抓到白色老鼠谁就赢,如果袋子里没有老鼠了并且没有谁抓到白色老鼠,那么算龙赢。公主每次抓一只老鼠,龙每次抓完一只老鼠之后会有一只老鼠跑出来。每次抓的老鼠和跑出来的老鼠都是随机的。公主先抓。问公主赢的概率。

思路

​ 设dp[i] [j]为轮到公主时袋子里有i 只白鼠, j 只黑鼠,公主赢的概率。初始化边界:dp[0] [j]=0,因为没有白鼠了算龙赢,dp[i] [0]=1,因为袋子里只有白鼠,公主随便抓都赢。考虑dp[i] [j]的转移

  • 公主抓到一只白鼠,公主赢了。概率为: i/(i+j)

  • 公主抓到一只黑鼠,龙抓到一只白鼠,龙赢了。概率为:*j/(i+j)i/(i+j-1)

  • 公主抓到一只黑鼠,龙抓到一只黑鼠,跑出来一只黑鼠,转移到 dp[i] [j-3]。概率为**:j/(i+j)*(j-1)/(i+j-1) * (j-2)/(i+j-2)**

  • 公主抓到一只黑鼠,龙抓到一只黑鼠,跑出来一只白鼠,转移到dp[i-1] [j-2]。概率为**:j/(i+j)*(j-1)/(i+j-1) * i/(i+j-2)**

    考虑公主赢的概率,第二种情况不参与计算。并且要保证后两种情况合法,所以还要判断 i ,j 的大小: 满足第三种

情况至少要有 3 只黑鼠,满足第四种情况要有 1 只白鼠和 2 只黑鼠

代码实现:

//袋子里的老鼠
public class Main {
	public static int w;// 白鼠
	public static int b;// 黑鼠
	public static double[][] dp;

	public static void main(String[] args) {
		Scanner scanner = new Scanner(System.in);
		w = scanner.nextInt();
		b = scanner.nextInt();
		dp = new double[w + 1][b + 1];
		// 1.初始化边界
		// 袋子里面只有白鼠
		for (int i = 1; i <= w; i++)
			dp[i][0] = 1;
		// 袋子里面只有黑鼠
		for (int i = 1; i <= b; i++)
			dp[0][i] = 0;
		// 2.考虑dp[i][j]的状态转移
		for (int i = 1; i <= w; i++)
			for (int j = 1; j <= b; j++) {
				// 情况一: 公主抓到一只白鼠的概率
				dp[i][j] += (double) i / (i + j);
				// 情况二: 公主抓到一只黑鼠,龙抓到一只黑鼠,跑出来一只黑鼠 ,转移到 dp[i] [j-3]
				if (j >= 3)
					dp[i][j] += (double) j / (i + j) * (j - 1) / (i + j - 1) * (j - 2) / (i + j - 2) * dp[i][j - 3];
				// 情况三: 公主抓到一只黑鼠,龙抓到一只黑鼠,跑出来一只白鼠 ,转移到 dp[i-1] [j-2]
				if (i >= 1 && j >= 2)
					dp[i][j] += (double) j / (i + j) * (j - 1) / (i + j - 1) * i / (i + j - 2) * dp[i - 1][j - 2];
			}
		System.out.println(String.format("%.4f", dp[w][b]));
		scanner.close();
	}
}

7.插头(连通性)动态规划

​ 有些 状压 DP 问题要求我们记录状态的连通性信息,这类问题一般被形象的称为插头 DP 或连通性状态压缩 DP。这些问题通常需要我们对状态的连通性进行编码,讨论状态转移过程中连通性的变化。

术语:

	- 阶段:动态规划执行的顺序,后续阶段的结果只与前序阶段的结果有关(无后效性)。
  • 轮廓线:已决策状态和未决策状态的分界线。
  • 插头:一个格子某个方向的插头存在,表示这个格子在这个方向与相邻格子相连。

一 . 路径模型

例1.多条回路

求用若干条回路覆盖N x M 棋盘的方案数,有些位置有障碍。

例2.一条回路

求用一条回路覆盖 N x M 棋盘的方案数。

例3.一条路径

一个 N x M 的方阵(N , M <9),每个格点有一个权值,求一段路径,最大化路径覆盖的格点的权值和。

二.染色模型

​ 有一类常见的模型,需要我们对棋盘进行染色相邻的相同颜色节点被视为**连通。**在路径类问题中,状态转移的时候我们枚举当前路径的方向,而在染色类问题中,我们枚举当前节点染何种颜色。在染色模型中,状态中处在相同连通性的节点可能不止两个。但总体来说依然大同小异。我们不妨来看一个经典的例题。

例4.黑白

​ 在N x M 的棋盘内对未染色的格点进行黑白染色,要求所有黑色区域和白色区域连通,且任意一个2 x 2的子矩形内的颜色不能完全相同,求合法的方案数,并构造一组合法的方案。

4.模拟算法

算法思想

​ 模拟就是用计算机来模拟题目中要求的操作

​ 模拟题目通常具有码量大、操作多、思路繁复的特点。由于它码量大,经常会出现难以查错的情况,如果在考试中写错是相当浪费时间的。

技巧

写模拟题时,遵循以下的建议有可能会提升做题速度:

  • 在动手写代码之前,在草纸上尽可能地写好要实现的流程。
  • 在代码中,尽量把每个部分模块化,写成函数、结构体或类。
  • 对于一些可能重复用到的概念,可以统一转化,方便处理:如,某题给你 “YY-MM-DD 时:分” 把它抽取到一个函数,处理成秒,会减少概念混淆。
  • 调试时分块调试。模块化的好处就是可以方便的单独调某一部分。
  • 写代码的时候一定要思路清晰,不要想到什么写什么,要按照落在纸上的步骤写。

实际上,上述步骤在解决其它类型的题目时也是很有帮助的。

例1.爬虫问题

​ 一只一英寸的蠕虫位于 n 英寸深的井的底部。它每分钟向上爬 u 英寸,但是必须休息一分钟才能再次向上爬。在休息的时候,它滑落了 d 英寸。之后它将重复向上爬和休息的过程。蠕虫爬出井口花费了多长时间?我们将不足一分钟的部分算作一整分钟。如果蠕虫爬完后刚好到达井的顶部,我们也设作蠕虫已经爬出井口。

解题思路

​ 直接使用程序模拟蠕虫爬井的过程就可以了。用一个循环重复蠕虫的爬井过程,当攀爬的长度超过或者等于井的深度时跳出。注意上爬和下滑时都要递增时间。

代码实现

int n, u, d;
Scanner scanner = new Scanner(System.in);
while (scanner.hasNext()) {
    n = scanner.nextInt();
    u = scanner.nextInt();
    d = scanner.nextInt();

    // 模拟爬虫爬出井的过程
    int time = 0, dist = 0;
    while (true) {
        dist += u;
        time++;
        if (dist >= n)
            break;
        dist -= d;
        time++;
    }
    System.out.println(time);
}
scanner.close();

你可能感兴趣的:(技术,算法,动态规划,图论,java)