第5章〓基本算法 基本算法没有复杂的逻辑和步骤,但是计算效率高、应用场景丰富,是算法竞赛必考的知识点。 在算法竞赛中有一些“通用”的算法,例如尺取法、二分法、倍增法、前缀和、差分、离散化、分治、贪心等。这些算法没有复杂的逻辑,代码也简短,但是它们的应用场合多,效率也高,广泛应用在编程和竞赛中。在蓝桥杯大赛中,这些算法几乎是必考的。本章介绍前缀和、差分、二分法、贪心法。 5.1算法和算法复杂度 在前面几章讲解例题时有很多题分析了“算法复杂度”,使读者对算法分析有了一定的了解。算法分析是做竞赛题时的一个必要步骤,用于评估问题的难度和决定使用什么算法来求解。在蓝桥杯这种赛制中,一道题往往可以用多种算法求解,例如较差的算法可以通过30%的测试,中等的算法可以通过70%的测试,优秀的算法可以通过100%的测试。 本节回答两个基本问题: 什么是算法?如何评估算法? 5.1.1算法的概念 “程序=算法+数据结构”。算法是解决问题的逻辑、方法、过程,数据结构是数据在计算机中的存储和访问方式,两者紧密结合解决复杂问题。 算法(Algorithm)是对特定问题求解步骤的一种描述,是指令的有限序列。它有以下5个特征: (1) 输入。一个算法有零个或多个输入,可以没有输入。例如一个定时闹钟程序,不需要输入,但是能够每隔一段时间输出一个报警。 (2) 输出。一个算法有一个或多个输出。程序可以没有输入,但是一定要有输出。 (3) 有穷性。一个算法必须在执行有穷步之后结束,且每一步都在有穷时间内完成。 (4) 确定性。算法中的每一条指令必须有确切的含义,对于相同的输入只能得到相同的输出。 (5) 可行性。算法描述的操作可以通过已经实现的基本操作执行有限次来实现。 5.1.2计算资源 计算机软件运行需要的资源有两种: 计算时间和存储空间。资源是有限的,一个算法对这两个资源的使用程度可以用来衡量该算法的优劣。 时间复杂度: 代码运行需要的时间。 空间复杂度: 代码运行需要的存储空间。 与这两个复杂度对应,程序设计题会给出对运行时间和空间的说明,例如“时间限制: 1s,内存限制: 256MB”,参赛队员提交到判题系统的代码需要在1s内运行结束,且使用的空间不能超过256MB。若有一个不满足,就判错。 这两个限制条件非常重要,是检验代码性能的参数,参赛队员拿到题目后第一步需要分析代码运行需要的计算时间和存储空间。 如何衡量代码运行的时间?在代码中打印运行时间,可以得到一个直观的认识。 下面的Java代码只有一个while循环语句,代码对k累加n次,最后打印运行时间。 1import java.util.Date; 2public class Main { 3public static void main(String[] args) { 4int k = 0, n = 100000000;//1亿 5long s = new Date().getTime(); //开始时间 6while (n-- > 0) k += 5; 7long t = new Date().getTime();//终止时间 8System.out.println((double) (t - s) / 1000); 9} 10} 在作者的笔记本计算机上运行,循环n=1千万次,用时0.069s。换了一个台式计算机,用时0.03s。Java的循环比C++的循环更快。一般认为Java比C++慢,这里Java可能做了优化。 竞赛评测用的判题服务器(OJ系统),性能可能比这个好一些,也可能差不多。对于C++题目,如果题目要求“时间限制: 1s”,那么内部的循环次数n应该在1亿次以内,Java也在1亿次以内。对于同等规模的Python题目,时间限制一般是5~10s。 由于代码的运行时间依赖于计算机的性能,不同的计算机结果不同,所以直接把运行时间作为判断标准并不准确。用代码执行的“计算次数”来衡量更加合理,例如上述代码循环了n次,把它的运行次数记为O(n),这称为计算复杂度的“大O记号”。 5.1.3算法复杂度 衡量算法性能的主要标准是时间复杂度。 时间复杂度比空间复杂度更重要。一个算法使用的空间很容易分析,而时间复杂度往往关系到算法的根本逻辑,更能说明一个程序的优劣。因此,如果不特别说明,提到“复杂度”时一般指时间复杂度。 竞赛题一般情况下做简单的时间复杂度分析即可,不用精确计算,常用“大O记号”做估计。 例如,在一个有n个数的无序数列中查找某个数x,可能第一个数就是x,也可能最后一个数才是x,平均查找时间是n/2次,最差情况需要查找n次,把查找的时间复杂度记为最差情况下的O(n),而不是O(n/2)。按最差情况算,是因为判题系统可能故意用“很差”的数据来测试。再如,冒泡排序算法的计算次数约等于n2/2次,但是仍记为O(n2),而不是O(n2/2),这里把常数系数1/2忽略了。 另外,即使是同样的算法,不同的人写出的代码的效率也不一样。OJ系统所判定的运行时间是整个代码运行所花的时间,而不是理论上算法所需要的时间。同一个算法,不同的人写出的程序,复杂度和运行时间可能差别很大,跟他使用的编程语言、逻辑结构、库函数等都有关系。所以参赛队员需要进行训练,提高自己的编码能力,纠正自己不合理的写代码习惯。 用“大O记号”表示的算法复杂度有以下分类。 (1) O(1),常数时间。计算时间是一个常数,和问题的规模n无关。例如,在用公式计算时,计算一次的复杂度就是O(1); 哈希算法,用hash函数在常数时间内计算出存储位置; 在矩阵A[M][N]中查找第i行j列的元素,只需要访问一次A[i][j]就够了。 (2) O(log2n),对数时间。计算时间是对数,通常是以2为底的对数,在每一步计算后,问题的规模减小一半。例如,在一个长度为n的有序数列中查找某个数,用折半查找的方法只需要log2n次就能找到。O(log2n)和O(1)没有太大的差别。例如n=1千万时,log2n<24。 (3) O(n),线性时间。计算时间随规模n线性增长。在很多情况下,这是算法可能达到的最优复杂度,因为对输入的n个数,程序一般需要处理所有数,即计算n次。例如查找一个无序数列中的某个数,可能需要检查所有数。再如图的问题,有V个点和E条边,大多数图的问题都需要搜索所有点和边,复杂度的最优上限是O(V+E)。 (4) O(nlog2n)。这常是算法能达到的最优复杂度。例如分治法,一共O(log2n)个步骤,每个步骤对每个数操作一次,所以总复杂度是O(nlog2n)。 (5) O(n2)。一个有两重循环的算法,复杂度是O(n2)。类似的复杂度有O(n3)、O(n4)等。 (6) O(2n)。一般对应集合问题,例如一个集合中有n个数,这些数不分先后,子集共有2n个。 (7) O(n!)。一般对应排列问题。如果集合中的数分先后,按顺序输出所有的排列,共有O(n!)个。 把上面的复杂度分成两类: 多项式复杂度,包括O(1)、O(n)、O(nlog2n)、O(nk)等,其中k是一个常数;指数复杂度,包括O(2n)、O(n!)等。 如果一个算法是多项式复杂度,称它为“高效”算法; 如果一个算法是指数复杂度,称它为“低效”算法。 对于竞赛题目的限制时间,C/C++一般是1s,Java一般是3s,Python一般是5~10s。对应普通计算机的计算速度是每秒几千万次计算。通过上述时间复杂度可以换算出能解决的问题的数据规模。例如,如果一个算法的复杂度是O(n!),当n=11时,11!=39916800,这个算法只能解决n≤11的问题。问题规模和可用算法的时间复杂度如表5.1所示。 表5.1问题规模和可用算法的时间复杂度 问题规模n 可用算法的时间复杂度 O(log2n)O(n)O(nlog2n)O(n2)O(2n)O(n!) n≤11√√√√√√ n≤25√√√√√× n≤5000√√√√×× n≤106√√√××× n≤107√√×××× n>108√××××× 参赛队员拿到题目后,一定要分析自己准备使用的算法的复杂度,评估自己的代码能通过多少测试。 5.2前缀和 5.2.1前缀和的概念 前缀和是一种操作简单但是非常有效的优化方法,能把计算复杂度为O(n)的区间计算优化为O(1)的端点计算。 前缀和是出题者喜欢考核的知识点,在算法竞赛中很常见,在蓝桥杯大赛中几乎必考。原因有以下两点: (1) 原理简单,方便在很多场景下应用,与其他考点结合。 (2) 可以考核不同层次的能力。前缀和的题目一般也能用暴力法求解,暴力法能通过30%的测试,用前缀和优化后能通过70%~100%的测试。 首先了解“前缀和”的概念。一个长度为n的数组a[1]~a[n],前缀和sum[i]等于a[1]~a[i]的和: sum[i]=a[1]+a[2]+…+a[i] 使用递推,可以在O(n)时间内求得所有前缀和: sum[i]=sum[i-1]+a[i] 如果预计算出前缀和,就能使用它快速计算出数组中任意一个区间a[i]~a[j]的和,即: a[i]+a[i+1] +…+a[j-1]+a[j]=sum[j]- sum[i-1] 上式说明,复杂度为O(n)的区间求和计算优化为O(1)的前缀和计算。 5.2.2例题 前缀和是一种很简单的优化技巧,应用场合很多,在竞赛中极为常见。如果参赛队员在对题目建模时发现有区间求和的操作,可以考虑使用前缀和优化。 第1章曾用例题“求和”介绍了前缀和的应用,下面再举几个例子。 例5.1可获得的最小取值lanqiaoOJ 3142 问题描述: 有一个长度为n的数组a,进行k次操作取出数组中的元素。每次操作必须选择以下两种操作之一: (1)取出数组中的最大元素; (2)取出数组中的最小元素和次小元素。要求在进行k次操作后取出的数的和最小。 输入: 第一行输入两个整数n和k,表示数组的长度和操作次数; 第二行输入n个整数,表示数组a。 数据范围: 3≤n≤2×105,1≤ai≤109,1≤k≤99999,2k> k) & 1;//取a[i]的第k位 14sum^= v; 15//对所有a[i]的第k位做异或得到sum,sum等于0或1 16if (sum == 0) { //前缀和为0 17zero++; //0的数量加1 18cnt += one; 19//这次sum=0,这个sum与前面等于1的sum异或得1 20} else { //前缀异或为1 21one++; //1的数量加1 22cnt += zero; 23//这次sum=1,这个sum与前面等于0的sum异或得1 24} 25} 26ans += cnt * (1L << k); //第k位的异或和相加 27} 28System.out.println(ans); 29} 30} 前面的例子都是一维数组的前缀和,下面介绍二维数组的前缀和。 例5.4领地选择 https://www.luogu.com.cn/problem/P2004 问题描述: 作为在虚拟世界里统率千军万马的领袖,小Z认为天时、地利、人和三者是缺一不可的,所以谨慎地选择首都的位置对于小Z来说是非常重要的。首都被认为是一个占地c×c的正方形。小Z希望寻找到一个合适的位置,使得首都所占领的位置的土地价值和最大。 输入: 第一行3个整数n、m、c,表示地图的宽和长以及首都的边长; 接下来n行,每行m个整数,表示地图上每个地块的价值,价值可能为负数。 数据范围: 对于60%的数据,n,m≤50; 对于90%的数据,n,m≤300; 对于100%的数据,1≤n,m≤103,1≤c≤min(n,m)。 输出: 一行两个整数x、y,表示首都左上角的坐标。 输入样例: 3 4 2 1 2 3 1 -1 9 0 2 2 0 1 1输出样例: 1 2 概括题意: 在n×m的矩形中找一个边长为c的正方形,把正方形内所有坐标点的值相加,使价值最大。 简单的做法是枚举每个坐标,作为正方形的左上角,然后计算出边长c内所有地块的价值和,找到价值和最大的坐标。其时间复杂度为O(n×m×c2),能通过60%的测试。请读者练习。 本题是二维前缀和的直接应用。 一维前缀和定义在一维数组a[]上: sum[i]=a[1]+a[2]+…+a[i]。 把一维数组a[]看成一条直线上的坐标,前缀和就是所有坐标值的和,如图5.1所示。 图5.1一维数组a[]在直线上 二维前缀和是一维前缀和的推广。设二维数组a[][]有1~n行、1~m列,二维前缀和: sum[i][j]=a[1][1]+a[1][2]+a[1][3]+…+a[1][j]+ a[2][1]+a[2][2]+a[2][3]+…+a[2][j]+ …+ a[i][1]+a[i][2]+a[i][3]+…+a[i][j] 图5.2二维数组和二维平面 把a[i][j]的(i,j)看成二维平面的坐标,那么sum[i][j]就是左下角坐标(1,1)和右上角坐标(i,j)围成的矩形中所有坐标点的和,如图5.2所示。 二维前缀和sum[][]存在以下递推关系: sum[i][j]=sum[i-1][j]+sum[i][j-1]- sum[i-1][j-1]+a[i][j] 根据这个递推关系,用两重for循环可以计算出sum[][]。 对照图5.2理解这个公式,sum[i-1][j]是坐标(1,1)~(i-1,j)内所有的点,sum[i][j-1]是坐标(1,1)~(i,j-1)内所有的点,两者相加,其中sum[i-1][j-1]被加了两次,所以要减去一次。 下面Java代码的第12行让sum[][]和a[][]共用,从而节省一半空间。如果分别定义sum[][]和a[][],会超过题目的空间限制。 1import java.util.Scanner; 2public class Main { 3public static void main(String[] args) { 4Scanner sc = new Scanner(System.in); 5int n = sc.nextInt(); 6int m = sc.nextInt(); 7int c = sc.nextInt(); 8int[][] a = new int[n + 1][m + 1]; 9for (int i = 1; i <= n; i++) { 10for (int j = 1; j <= m; j++) { 11a[i][j] = sc.nextInt(); 12a[i][j] = a[i-1][j] + a[i][j-1] - a[i-1][j-1] + a[i][j]; 13//上一行等同于:s[i][j] = s[i-1][j] + s[i][j-1] - s[i-1][j-1] + a[i][j]; 14} 15} 16int Max = Integer.MIN_VALUE; 17int x = 0; 18int y = 0; 19for (int x1 = 1; x1 <= n - c + 1; x1++) { 20for (int y1 = 1; y1 <= m - c + 1; y1++) {//枚举所有坐标点 21int x2 = x1 + c - 1; 22int y2 = y1 + c - 1; //正方形的右下角坐标 23int ans=a[x2][y2]-a[x2][y1-1]-a[x1-1][y2]+a[x1-1][y1-1]; 24if (ans > Max) { 25Max = ans; 26x = x1; 27y = y1; 28} 29} 30} 31System.out.println(x + " " + y); 32} 33} 5.3差分 前缀和的主要应用是差分,差分是前缀和的逆运算。 5.3.1一维差分 与一维数组a[]对应的差分数组d[]的定义为: d[k]=a[k]- a[k-1] 即差分数组d[]是原数组a[]的相邻元素的差。根据d[]的定义,可以反过来推出: a[k]=d[1]+d[2] +…+d[k] 即a[]是d[]的前缀和,所以“差分是前缀和的逆运算”。 为了方便理解,把每个a[]看成直线上的坐标,把每个d[]看成直线上的小线段,它的两端是相邻的a[]。将这些小线段相加,就得到了从起点开始的长线段a[],如图5.3所示。 图5.3把每个d[]看成小线段,把每个a[]看成从a[1]开始的小线段的和 差分是一种巧妙、简单的方法,它应用于区间的修改和查询问题。把给定的数据元素集A分成很多区间,对这些区间做很多次操作,每次操作是对某个区间内的所有元素做相同的加减操作,若逐一修改这个区间内的每个元素,复杂度是O(n),非常耗时。为此引入“差分数组”,当修改某个区间时,只需要修改这个区间的“端点”,就能记录整个区间的修改,而对端点的修改非常快,复杂度是O(1)。当所有的修改操作结束后,再使用差分数组计算出新的A。 为什么使用差分数组能提高修改的效率?如何把O(n)的区间操作优化为O(1)的端点操作? 把[L,R]区间内的每个元素a[]加上v,只需要把对应的d[]做以下操作。 (1) 把d[L]加上v: d[L]+=v (2) 把d[R+1]减去v: d[R+1]-=v 使用d[],能精确地实现只修改区间内元素的目的,而不会修改区间外的a[]值。根据前缀和a[x]=d[1]+d[2]+…+d[x],有: (1) 1≤x 0) { 14int L = sc.nextInt(); 15int R = sc.nextInt(); 16for (int i = L; i <= R; i++) 17cnt[i]++; //第i个数被加了一次,累计一共加了多少次 18} 19for (int i = 1; i <= n; i++) 20ans1 += (long) a[i] * cnt[i]; //在原数组上求区间和 21Arrays.sort(a, 1, n + 1); 22Arrays.sort(cnt, 1, n + 1); 23for (int i = 1; i <= n; i++) 24ans2 += (long) a[i] * cnt[i]; 25System.out.println(ans2 - ans1); 26} 27} (2) 通过100%的测试。 本题是差分优化的直接应用。 前面通过70%测试的代码效率低的原因是用第16行的for循环计算cnt[]。根据差分的应用场景,每次查询的[L,R]就是对a[L]~a[R]中所有数的累加次数加1,也就是对cnt[L]~cnt[R]中的所有cnt[]加1。那么对cnt[]使用差分数组d[]即可。 下面代码的第17行用差分数组d[]记录cnt[]的变化,第21行用d[]恢复得到cnt[]。其他部分和前面通过70%测试的代码一样。 代码的计算复杂度,14行的while循环只有O(m),最耗时的是第24、25行的排序,复杂度为O(nlog2n),能通过100%的测试。 1import java.util.*; 2public class Main { 3public static void main(String[] args) { 4Scanner sc = new Scanner(System.in); 5int N = 100003; 6int[] a = new int[N]; 7int[] d = new int[N]; 8int[] cnt = new int[N]; 9int n = sc.nextInt(); 10for (int i = 1; i <= n; i++) 11a[i] = sc.nextInt(); 12int m = sc.nextInt(); 13long ans1 = 0, ans2 = 0; 14while (m-- > 0) { 15int L = sc.nextInt(); 16int R = sc.nextInt(); 17d[L]++; d[R+1]--; 18} 19cnt[0] = d[0]; 20for (int i = 1; i <= n; i++) 21cnt[i] = cnt[i-1] + d[i]; //用差分数组d[]求cnt[] 22for (int i = 1; i <= n; i++) 23ans1 += (long) a[i] * cnt[i]; 24Arrays.sort(a, 1, n+1); 25Arrays.sort(cnt, 1, n+1); 26for (int i = 1; i <=n; i++) 27ans2 += (long) a[i] * cnt[i]; 28System.out.println(ans2 - ans1); 29} 30} 再看一道例题。 例5.6推箱子 http://oj.ecustacm.cn/problem.php?id=1819 问题描述: 在一个高度为H的箱子的前方有一个长和高为N的障碍物,障碍物的每一列存在一个连续的缺口,第i列的缺口从第l个单位到第h个单位(从底部由0开始数)。现在请清理出一条高度为H的通道,使得箱子可以直接推出去。输出最少需要清理的障碍物的面积。如图5.4所示,样例中的障碍物长和高均为5,箱子的高度为2,不需要考虑箱子会掉入某些坑中,最少需要移除两个单位的障碍物可以清理出一条高度为2的通道。 图5.4移除障碍物推箱子 输入: 输入的第一行为两个正整数N和H,表示障碍物的尺寸和箱子的高度,1≤H≤N≤1000000; 接下来N行,每行包含两个整数li和hi,表示第i列缺口的范围,0≤li≤hi() { 14public int compare(Data x, Data y) { 15return x.R - y.R; 16} 17}); 18int ans = 0; 19int lastend = -1; 20for (int i = 0; i < n; i++) 21if (a[i].L >= lastend) { 22ans++; 23lastend = a[i].R; 24} 25System.out.println(ans); 26} 27} 3. 区间合并问题 给定若干区间,合并所有重叠的区间,并返回不重叠的区间的个数。 以图5.8为例,1、2、3、5合并,4、6合并,新区间是1′、4′。 图5.8区间合并 贪心策略: 按区间的左端点排序,然后逐一枚举每个区间,合并相交的区间。 定义不重叠的区间的个数(答案)为ans。设当前正在合并的区间的最右端点为end,当枚举到第i个区间[Li,Ri]时: 若Li≤end,说明与第i个区间相交,需要合并,ans不变,更新end=max(end,Ri)。 若Li>end,说明与第i个区间不相交,ans加1,更新end=max(end,Ri)。 请读者用图5.8所示的例子模拟合并过程。 4. 区间覆盖问题 给定一个目标大区间和一些小区间,问最少选择多少小区间可以覆盖大区间。 贪心策略: 尽量找出右端点更远的小区间。 图5.9区间覆盖 操作步骤: 先对小区间的左端点排序,然后依次枚举每个小区间,在所有能覆盖当前目标区间右端点的区间中选择右端点最大的区间。 在图5.9中,求最少用几个小区间能覆盖整个区间。先按左端点排序,设当前覆盖到了位置R,选择的小区间数量为cnt。 从区间1开始,R的值是区间1的右端点A,R=A,cnt=1。 找到能覆盖R=A的区间2、3,在区间2、3中选右端点更远的区间3,更新R为区间3的右端点B,R=B,cnt=2。 区间4不能覆盖R=B,跳过。 找到能覆盖R=B的区间5,更新R=C,cnt=3,结束。 例5.14区间覆盖(加强版)https://www.luogu.com.cn/problem/P2082 问题描述: 已知有 n 个区间,每个区间的范围是 [Li,Ri],请求出区间覆盖后的总长。 输入: 第一行一个整数n,接下来n行,每行两个整数Li、Ri(Li() { 14public int compare(Data x, Data y) { 15return Long.compare(x.L, y.L); 16} 17}); 18long lastend = -1; 19long ans = 0; 20for (int i = 0; i < n; i++) 21if (a[i].R >= lastend) { 22ans += a[i].R - Math.max(lastend, a[i].L) + 1; 23lastend = a[i].R + 1; 24} 25System.out.println(ans); 26} 27} 5.5.2例题 贪心题在算法竞赛中也是必考点,由于贪心法可以灵活地嵌入题目当中与其他算法结合,题目可难、可易。 例5.152023年第十四届蓝桥杯省赛Java大学C组试题E: 填充lanqiaoOJ 3519 时间限制: 3s内存限制: 512MB本题总分: 15分 问题描述: 有一个长度为n的01串s,其中有一些位置标记为“?”,在这些位置上可以任意填充0或者1。请问如何填充这些位置使得这个01串中出现互不重叠的00和11子串最多,输出子串的个数。 输入: 输入一行包含一个字符串。 输出: 输出一行包含一个整数,表示答案。 输入样例: 1110?0输出样例: 2 样例说明: 如果在“?”处填0,则最多出现一个00和一个11(111000)。对于所有评测用例,1≤n≤1000000。 本题有两种解法: 贪心法、DP。DP解法见第8章,这里用贪心法求解。 题目要求00、11尽可能地多,所以目的是尽可能多配对。配对只在相邻s[i]和s[i+1]之间发生。从s[0]、s[1]开始,每次观察相邻的两个字符s[i]、s[i+1],讨论以下情况。 (1) s[i]=s[i+1],这两个字符可能是"00""11""??",都是配对的。下一次观察s[i+2]、s[i+3]。 (2) s[i]或s[i+1]有一个是'?',那么可以配对。例如"1?""?1""0?""?0",都是配对的。下一次观察s[i+2]、s[i+3]。 (3) s[i] ='0',s[i+1] ='1',不配对。下一次观察s[i+1]、s[i+2]。 (4) s[i] ='1',s[i+1] ='0',不配对。下一次观察s[i+1]、s[i+2]。 下面是代码,只需要计算(1)、(2)即可。在代码中只有一个for循环,计算复杂度为O(n)。 1import java.util.Scanner; 2public class Main { 3public static void main(String[] args) { 4Scanner sc = new Scanner(System.in); 5String s = sc.next(); 6int ans = 0; 7for (int i = 0; i < s.length() - 1; i++) { 8if (s.charAt(i) == s.charAt(i + 1)) { 9ans++; 10i++; 11} 12else if (s.charAt(i) == '?' || s.charAt(i + 1) == '?') { 13ans++; 14i++; 15} 16} 17System.out.println(ans); 18} 19} 例5.16买二赠一 https://www.lanqiao.cn/problems/3539/learning/ 问题描述: 某商场有n件商品,其中第i件商品的价格是ai。现在该商场正在进行“买二赠一” 的优惠活动,具体规则是每购买两件商品,假设其中较便宜的商品的价格是P(如果两件商品的价格一样,则P等于其中一件商品的价格),就可以从剩余商品中任选一件价格不超过P/2(向下取整)的商品,免费获得这一件商品。可以通过反复购买两件商品来获得多件免费商品,但是每件商品只能被购买或免费获得一次。小明想知道如果要拿下所有商品(包含购买和免费获得),至少要花费多少钱? 输入: 第一行包含一个整数n; 第二行包含n个整数,代表a1、a2、a3、…、an。 输出: 输出一个整数,表示答案。 评测用例规模与约定: 对于30%的数据,1≤n≤20; 对于100%的数据,1≤n≤5×105,1≤ai≤109。 输入样例: 7 1 4 2 8 5 7 1输出样例: 25 样例说明: 小明可以先购买价格为4和8的商品,免费获得一件价格为1的商品; 然后购买价格为5和7的商品,免费获得价格为2的商品; 最后单独购买剩下的一件价格为1的商品。总计花费4+8+5+7+1=25,不存在花费更低的方案。 最贵的商品显然不能免单,买了两件不能免单的最贵的商品后获得一个免单机会,那么这个免单机会给谁呢?给能免单的最贵的商品。这个贪心思路显然是对的。 以样例为例,排序得{8 7 5 4 2 1 1}。先购买最贵的8、7,然后可以免单的最贵的是2。再购买剩下的最贵的5、4,免单1。最后单独购买1。总价是25。 需要查找价格为P/2的商品,由于价格已经排序,可以用二分法减少查找的时间。在C++STL中自带了二分查找函数lowerBound(),但是Java没有自带二分函数,下面自己写一个二分函数。 1import java.util.Arrays; 2import java.util.Scanner; 3public class Main { 4public static void main(String[] args) { 5Scanner sc = new Scanner(System.in); 6int n = sc.nextInt(); 7int[] a = new int[n]; 8for (int i = 0; i < n; i++) 9a[i] = sc.nextInt(); 10Arrays.sort(a); 11long ans = 0; 12int cnt = 0; 13int last = -1; 14int last_id = n - 1; 15boolean[] vis = new boolean[n]; 16for (int i = n - 1; i >= 0; i--) { 17if (!vis[i]) { 18cnt++; 19ans += a[i]; 20last = a[i]; 21} 22if (cnt == 2) { 23cnt = 0; 24int x = lowerBound(a, 0, last_id, last / 2); 25if (x > last_id || a[x] > last / 2) x--; 26if (x >= 0) { 27vis[x] = true; 28last_id = x - 1; 29} 30} 31} 32System.out.println(ans); 33} 34private static int lowerBound(int[] a, int L, int R, int target) { 35//二分查找 36while (L < R) { 37int mid = L + (R - L) / 2; 38if (a[mid] >= target) R = mid; 39else L = mid + 1; 40} 41return L; 42} 43} 例5.17购物https://www.luogu.com.cn/problem/P1658 问题描述: 某人要去购物,现在手上有n 种不同面值的硬币,每种硬币有无限多个。为了方便购物,他希望带尽量少的硬币,但要能组合出1~x的任意值。 输入: 第一行两个数x、n,下一行n个数,表示每种硬币的面值。 输出: 最少需要带的硬币个数,如果无解,输出-1。 评测用例规模与约定: 对于30%的评测用例,n≤3,x≤20; 对于100%的评测用例,n≤10,x≤103。 输入样例: 20 4 1 2 5 10输出样例: 5 为了方便处理,把硬币按面值从小到大排序。 无解是什么情况?如果没有面值为1的硬币,组合不到1,无解。如果有面值为1的硬币,那么所有的x都能满足,有解。所以,无解的充要条件是没有面值为1的硬币。 组合出1~x的任意值需要的硬币多吗?学过二进制的人都知道,1、2、4、8、…、2n-1这n个值可以组合出1~2n-1的所有数。这说明只需要很少的硬币就能组合出很大的x。 设已经组合出1~s的面值,即已经得到数字1、2、3、…、s,下一步扩展到s+1。当然,如果能顺便扩展到s+2、s+3、…,扩展得越大越好,这样就能用尽量少的硬币扩展出更大的面值。 如何扩展到s+1?就是在数字1、2、3、…、s的基础上添加一个面值为v的硬币,得到s+1。v可以选1、2、…、s+1,例如v=1,s+1=s+v; v=2,s+1=s-1+v;…; v=s+1,s+1=v。如果v=s+2,就不能组合到s+1了。 v的取值范围是[1,s+1],为了最大扩展,选v为[1,s+1]内的最大硬币,此时s扩展到s+v。这就是贪心策略。 以本题的输入样例为例说明计算过程。设答案为ans。 先选硬币1,得到s=1,ans=1。 再选[1,s+1]=[1,2]内的最大硬币2,扩展s=1为s=1+v=3,ans=2。 再选[1,s+1]=[1,4]内的最大硬币2,得到s=5,ans=3。 再选[1,s+1]=[1,6]内的最大硬币5,得到s=10,ans=4。 再选[1,s+1]=[1,11]内的最大硬币10,得到s=20,ans=5。此时s≥x,结束。 所以仅需要5个面值为1、2、2、5、10的硬币就可以组合得到1、2、3、4、…、20。 1import java.util.Arrays; 2import java.util.Scanner; 3public class Main { 4public static void main(String[] args) { 5Scanner input = new Scanner(System.in); 6int x = input.nextInt(); 7int n = input.nextInt(); 8int[] a = new int[n]; 9for (int i = 0; i < n; i++) a[i] = input.nextInt(); 10Arrays.sort(a); 11if (a[0] != 1) { System.out.println(-1); return; } 12int s = 0; 13int ans = 0; 14while (s < x) 15for (int v = n - 1; v >= 0; v--) 16if (a[v] <= s + 1) { 17s += a[v]; 18ans++; 19break; 20} 21System.out.println(ans); 22} 23} 例5.18最大团 http://oj.ecustacm.cn/problem.php?id=1762 问题描述: 数轴上有n个点,第i个点的坐标为xi、权值为wi。两个点i、j之间存在一条边当且仅当abs(xi-xj)≥wi+wj。请求出这张图的最大团的点数。团是两两之间存在边的定点集合。 输入: 输入的第一行为n,n≤200000; 接下来n行,每行两个整数xi、wi,0≤|xi|,wi≤109。 输出: 输出一个整数,表示最大团的点数。 输入样例: 4 2 3 3 1 6 1 0 2输出样例: 3 最大团是一个图论问题: 在一个无向图中找出一个点数最多的完全子图。所谓完全图,就是图中所有的点之间都有边。n个点互相连接,共有n(n-1)/2条边。 普通图上的最大团问题是NP问题,计算复杂度是指数级的。例如常见的BronKerbosch算法是一个暴力搜索算法,复杂度为O(3n/3)。所以如果出最大团的题目,一般不会在普通图上求最大团,而是在一些特殊的图上,用巧妙的、非指数复杂度的算法求最大团。本题n≤200000,只能用复杂度小于O(nlog2n)的巧妙算法。 本题的图比较特殊,所有的点都在一条直线上。在样例中,存在的边有(03)、(06)、(26)、(36),其中{0,3,6}这3个点之间都有边,它们构成了一个最大团。 另外,本题对边的定义也很奇怪: “两个点i、j之间存在一条边当且仅当abs(xi-xj)≥wi+wj”。 考虑以下两个问题: (1) 哪些点之间有边?题目的定义是abs(xi-xj)≥wi+wj,若事先把x排了序,设xj≥xi,移位得xj-wj≥xi+wi。这样就把每个点的x和w统一起来,方便计算。 (2) 哪些点构成了团?是最大团吗?考查3个点x1≤x2≤x3,若x1和x2有边,则应该有x1+w1≤x2-w2; 若x2和x3有边,则x2+w2≤x3-w3。推导x1和x3的关系: x1+w1≤x2-w2≤x2+w2≤x3-w3,即x1+w1≤x3-w3,说明x1和x3也有边。x1、x2、x3这3个点之间都有边,它们构成了一个团。依次这样操作,符合条件的x1、x2、x3、…构成了一个团。但是用这个方法得到的团是最大的吗? 图5.10最大团建模 为了方便思考,把上述讨论画成图,如图5.10所示。 把每个点的信息画成线段,左端点是x-w、右端点是x+w。问题建模为在n条线段中找出最多的线段,使得它们互不交叉。 大家学过贪心法,发现它是经典的“活动安排问题”,或者称为“区间调度问题”,即在n个活动中安排尽量多的活动,使得它们互不冲突。该问题的贪心解法如下: (1) 按活动的结束时间排序。本题按x+w排序。 (2) 选择第一个结束的活动,跳过与它时间冲突的活动。 (3) 重复步骤(2),直到活动为空。每次选择剩下的活动中最早结束的活动,并跳过与它时间冲突的活动。 下面代码的计算复杂度,排序为O(nlog2n),贪心为O(n),总复杂度为O(nlog2n)。 1import java.util.*; 2class Main { 3static class aa { int l, r; } 4public static void main(String[] args) { 5Scanner sc = new Scanner(System.in); 6int n = sc.nextInt(); 7aa[] a = new aa[n + 1]; 8for (int i = 1; i <= n; i++) { 9int x = sc.nextInt(); 10int w = sc.nextInt(); 11a[i] = new aa(); 12a[i].l = x - w; 13a[i].r = x + w; 14} 15Arrays.sort(a, 1, n+1, new Comparator() { //按右端点排序 16public int compare(aa a, aa b) { return a.r - b.r; } 17}); 18int R = a[1].r; 19int ans = 1; 20for (int i = 2; i <= n; i++) { 21//选剩下的活动中最早结束的活动,跳过冲突的活动 22if (a[i].l >= R) { 23R = a[i].r; 24ans++; 25} 26} 27System.out.println(ans); 28} 29} 例5.19奶牛优惠券https://www.luogu.com.cn/problem/P3045 问题描述: 农夫约翰需要购买奶牛。目前市面上有n头奶牛出售,约翰的预算只有m元,购买奶牛i需要花费pi。约翰有k张优惠券,当对奶牛i使用优惠券时只需要花费ci(ci≤pi)。每头奶牛只能使用一张优惠券。求约翰最多可以买多少头奶牛? 输入: 第一行3个正整数n、k、m,1≤n≤50000,1≤m≤1014,1≤k≤n; 接下来n行,每行两个整数pi和ci,1≤pi≤109,1≤ci≤pi。 输出: 输出一个整数,表示答案。 输入样例: 4 1 7 3 2 2 2 8 1 4 3输出样例: 3 题意简述如下: 有n个数,每个数可以替换为较小的数; 从n个数中选出一些数,在选的时候允许最多替换k个数,要求这些数相加不大于m,问最多能选出多少个数。 这个问题可以用女生买衣服类比。女生带着m元去买衣服,目标是尽量多买几件,越多越好。每件衣服都有优惠,但是必须使用优惠券,一件衣服只能用一张。衣服的优惠幅度不一样,有可能原价贵的优惠后反而更便宜。女生有k张优惠券,问她最多能买多少件衣服。 男读者可以问问女朋友,她会怎么买衣服。聪明的她可能会马上问: 优惠券是不是无限多?如果优惠券用不完,那么衣服的原价就形同虚设,按优惠价从小到大买即可。可惜,优惠券总是不够用。 她想出了这个方法: 按优惠价排序,先买优惠价便宜的,直到用完优惠券; 如果还有钱,再买原价便宜的。 但是这个方法不是最优的,因为优惠价格低的可能优惠幅度小,导致优惠券被浪费了。例如: 衣服: a,b,c,d,e 优惠价: 3,4,5,6,7 原价: 4,5,6,15,10 设有m=20元,k=3张优惠券。把3张优惠券用在a、b、c上并不是最优的,这样只能买3件。最优解是买4件: a、b、d用优惠价,c用原价,共19元。 下面对这个方法进行改进。既然有优惠幅度很大的衣服,就试一试把优惠券转移到这件衣服上,看能不能获得更大的优惠。把这次转移称为“反悔”。 设优惠价最便宜的前k件衣服用完了k张优惠券。现在看第i=k+1件衣服,要么用原价买,要么转移一张优惠券过来用优惠价买,看哪种结果更好。设原价是p,优惠价是c。 在反悔之前,第i件衣服用原价pi买,前面第j件衣服用优惠价cj买,共花费tot+pi+cj,其中tot是其他已经买的衣服的花费。 在反悔后,把第j件衣服的优惠券转移给第i件,改成原价pj,第i件衣服用优惠价ci,共花费tot+ci+pj。如果反悔更好,则有tot+pi+cjp1-c2,说明替换优惠券不值得,不用替换。下一件衣服用原价买p1。 2) 若d≤p1-c2,说明替换优惠券值得,下一件衣服用优惠价买c2,原来用优惠券的改成原价。 本题总体上是贪心,用3个优先队列处理贪心。但是优惠券的替换操作是贪心的“反悔”,所以称为“反悔贪心”。贪心是连续做局部最优操作,但有时局部最优推不出全局最优,此时可以用反悔贪心,撤销之前做出的决策,换条路重新贪心。 1import java.util.*; 2public class Main { 3public static void main(String[] args) { 4Scanner sc = new Scanner(System.in); 5int n = sc.nextInt(); 6int k = sc.nextInt(); 7long m = sc.nextLong(); 8int[] p = new int[n + 1]; 9int[] c = new int[n + 1]; 10boolean[] buy = new boolean[n+1];//buy[i]=1: 第i个物品被买了 11PriorityQueue P = new PriorityQueue<>((a, b) -> a[0] - b[0]); 12PriorityQueue C = new PriorityQueue<>((a, b) -> a[0] - b[0]); 13PriorityQueue D = new PriorityQueue<>(); 14for (int i = 1; i <= n; i++) { 15p[i] = sc.nextInt(); 16c[i] = sc.nextInt(); 17P.add(new int[]{p[i], i}); 18//原价,还没买的在这里,如果买了就移出去 19C.add(new int[]{c[i], i}); 20//优惠价,还没买的在这里,如果买了就移出去 21} 22for (int i = 1; i <= k; i++) 23D.add(0); //k张优惠券,开始时每个优惠为0 24long ans = 0; 25while (!P.isEmpty() && !C.isEmpty()) { 26int[] p1 = P.peek(); //取出原价最便宜的 27int[] c2 = C.peek(); //取出优惠价最便宜的 28if (buy[p1[1]]) { P.poll(); continue; } //这个已经买了,跳过 29if (buy[p1[1]]) { P.poll(); continue; } //这个已经买了,跳过 30if (D.peek() > p1[0] - c2[0]) { 31//用原价买i更划算,不用替换优惠券 32m -= p1[0];//买原价最便宜的 33P.poll(); //这里不要C.pop(),因为买的是p1,不是c2 34buy[p1[1]] = true; //标记p1买了 35} else { //替换优惠券 36m -= c2[0] + D.peek(); //买优惠价最便宜的 37C.poll(); //这里不要p.pop(),因为买的是c2,不是p1 38buy[c2[1]] = true; //标记c2买了 39D.poll(); //原来用优惠券的退回优惠券 40D.add(p[c2[1]] - c[c2[1]]); 41//c2使用优惠券,重新计算delta并进队列 42} 43if (m >= 0) ans++; 44else break; 45} 46System.out.println(ans); 47} 48} 【练习题】 langqiaoOJ: 三国游戏3518、平均3532、答疑1025、身份证3849、找零钱3854、01搬砖2201、卡牌游戏1057、寻找和谐音符3975、小蓝的旅行计划3534、翻硬币209、防御力226。 洛谷: 排座椅P1056、母舰P2813、排队接水P1223、小A的糖果P3817、加工生产调度P1248、负载平衡问题P4016。 5.6扩 展 学 习 从本章开始进入了算法学习阶段。本章的“基本算法”是一些“通用”的算法,可以在很多场景下应用。 在本书第2章的“2.1 杂题和编程能力”曾提到计算思维的作用,通过做杂题可以帮助大家建立基本的、自发的计算思维。在计算机科学中有大量经典的算法和数据结构。在这些知识点中,基本算法易于理解、适用面广、精巧高效,是计算思维的基础,真正的计算思维从这里开始。 除了本章介绍的前缀和、差分、二分、贪心,基本算法还有尺取法、倍增法、离散化、分治等。在逐渐深入学习算法的过程中,这些基本算法都是必须掌握的知识点。