2024年12月真题
一、单选题(每题2分,共30分)
正确答案:C
解析:
选项A:数组需要预先分配一块连续的内存空间,当数据数量不确定时,为了能存储足够的数据,可能需要申请一个较大的数组,这样就可能会浪费空间。而链表是动态数据结构,其大小可以根据需要动态调整,所以当数据数量不确定时,链表比较合适,选项 A 正确。
选项B:在链表中访问节点,因为没有像数组那样的索引,需要从表头开始逐个节点遍历,平均时间复杂度为
O
(
n
)
O(n)
O(n),选项 B 正确。
选项D:链表的节点在内存中是分散存储的,各个节点之间通过指针连接在一起,选项 D 正确。
选项C:链表插入和删除元素(在已知位置插入或删除)的时间复杂度为
O
(
1
)
O(1)
O(1)(如果是在给定值的位置插入或删除,需要先查找该位置,查找的时间复杂度为
O
(
n
)
O(n)
O(n))。而数组插入和删除元素(中间位置)通常需要移动大量元素,时间复杂度为
O
(
n
)
O(n)
O(n),所以选项 C 错误。
正确答案:C
解析:循环单链表是一种链表结构,它的最后一个节点的指针域(next 指针)不是指向空(nullptr),而是指向链表的第一个节点,从而形成一个环状结构。
正确答案:A
解析:为了方便链表的增删操作,一些算法生成一个虚拟头节点,方便统一删除头节点和其他节点。
我们知道删除中间节点和尾节点代码可以统一为:cur->next = cur->next->next; 要想删除头节点也使用同样的代码,只需要在本来的头节点之前再插入一个节点作为新的头节点,让cur指向新的头节点。也就是第11、12、13行代码的作用,A选项代码是13行应该填充的代码。
需要注意的是,在完成删除造作后,需要使head指向真正的头节点,也就是将创建的虚拟头节点删除,也就是第26、27、28行的代码所起的作用
正确答案:D
解析:函数fibA和fibB实现的功能相同:求斐波那契数列第n项的值。函数fibA用的递推方法,也有称之为迭代方法的,fibB用的递归的方法。fibA递推方法的时间复杂度是
O
(
n
)
O(n)
O(n),fibB递归方法的时间复杂度是
O
(
φ
n
)
O(\varphi ^n)
O(φn)
φ
=
1
+
5
2
\varphi = \frac{1+\sqrt{5} }{2}
φ=21+5,具体求法可以去留言询问或者搜一下。
正确答案:C
解析:
gcd(24, 36):a为24,b为36,big为36,small为24,if条件(big%small == 0)不成立,进一步调用gcd(small, big%small),也即gcd(24, 12)。
gcd(24, 12):a为24,b为12,big为24,small为12,if条件(24%12==0)成立,返回samll,也即12。
答案为C。
正确答案:D
解析:A、B、C、D都可得到正确答案。但本题要求填写最佳代码。
B、D选项,循环的终止条件是i * i <= n。这种方法是合理的,因为如果n有一个大于sqrt(n)的质因数,那么它一定有一个小于sqrt(n)的对应质因数。例如,对于n = 9,只需要检查3就可以了,不需要检查4、5等。这是一种优化的寻找质因数的方法。很显然效率要高于A、C
另D选项的循环步长为2,也就是只考虑了奇数情况,我们知道偶数肯定不是质数,这种排除也是合理的,D的效率比B高一些。答案选D
正确答案:B
解析:埃筛的基本思想是标记法。假设要找出不超过 n 的所有素数,首先创建一个从 2 到 n 的连续整数序列。从最小的素数 2 开始,将的 2 倍数(除了 2 本身)标记为合数,即这些数不是素数。接着找到下一个未被标记的数,这个数一定是素数(因为比它小的数都已经筛选过了),然后将这个素数的倍数标记为合数。如此反复,直到遍历完所有小于等于
n
\sqrt{n}
n 的数。
选项A:埃筛的时间复杂度为
O
(
n
l
o
g
l
o
g
n
)
O(nloglogn)
O(nloglogn),具体求法可留言询问或者自己查询
选项C、D:埃筛是为了找出所有的素数,素数:素数(也称为质数)是指在大于 1 的自然数中,除了 1 和它自身外,不能被其他自然数整除的数。奇数9不是素数,C、D都不对
选项B:素数是从小往大找的,因此当 i 是素数时,其 i-1, i-2 倍数已经被标记过了,从
i
2
i^2
i2开始可减少重复标记。
正确答案:A
解析:线性筛的核心思想是每个合数只被它的最小质因数筛去。它也使用一个数组来标记数是否为素数,同时维护一个素数列表。从 2 开始遍历到 n,如果当前数是素数就加入素数列表。对于每个数 i,遍历已经找到的素数列表,将与素数相乘得到的数标记为合数。关键在于,当能被当前素数整除时,就停止标记,因为后面的合数会由更大的数和更小的质因数去筛掉。
选项B:线筛的每个合数只被它的最小质因数筛去
选项C:参考上一次关于埃筛算法思想的描述。
选项A:线筛的时间复杂度为
O
(
n
)
O(n)
O(n),这是因为每个数最多只被筛选一次,所以总的时间复杂度是线性的。
正确答案:A
解析:快速排序是一种基于分治策略的高效排序算法。它的基本思想是选择一个基准元素(pivot),将数组分为两部分:小于等于基准元素的部分和大于基准元素的部分。然后对这两部分分别进行快速排序,直到整个数组有序。
选项A:每次划分都得到一个更小规模的问题,更小的问题用同样的策略解决,使用递归解决。
选项B、D:快速排序在最好情况和平均情况下,时间复杂度为
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn),最坏情况(有序时)下,时间复杂度为
O
(
n
2
)
O(n^2)
O(n2)
选项C:快速排序是一种不稳定的排序算法
正确答案:B
解析:归并排序是一种基于分治策略的排序算法。它的基本思想是将一个数组分成两个子数组,分别对这两个子数组进行排序,然后将排好序的子数组合并成一个有序的数组。这个过程是递归进行的,直到子数组的长度为 1,此时数组自然就是有序的。
选项A:归并排序是一种稳定的排序算法
选项B:归并排序无论在任何情况下的时间复杂度都为
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn)
选项C:归并排序不是原地排序算法,因为在合并过程中需要使用额外的临时数组来存储元素。
在合并两个子数组时,需要创建一个大小与这两个子数组长度之和相等的临时数组。在最坏情况下,也就是整个数组的长度为时,需要一个大小为的临时数组。所以空间复杂度是
O
(
n
)
O(n)
O(n)。
选项D:输出结果应该是有序的,D错误
正确答案:C
解析:二分算法(二分查找)是一种用于在有序数组中查找特定元素的高效算法。它的基本思想是每次比较中间元素与目标元素,如果中间元素等于目标元素,则查找成功;如果中间元素大于目标元素,则在数组的左半部分继续查找;如果中间元素小于目标元素,则在数组的右半部分继续查找。这个过程不断重复,直到找到目标元素或者确定目标元素不存在。
A、B:根据二分算法的算法思想,A、B显然正确。
D:问题规模每次都减少一半,算法时间复杂度为
O
(
l
o
g
n
)
O(logn)
O(logn)
C:第2行代码,当左边界大于右边界,说明没有元素可再供查找,返回-1。当数组中不包含元素时,递归调用不断修改左右边界,终有一次第2行代码的条件会成立,递归终止。
正确答案:B
解析:利用二分查找查找某个数值在数组中第一次出现的下标,找不到返回-1
选项B:当target <= nums[middle]时,说明左边界在left到middle这个区间内,为了缩小搜索范围,需要将right更新为middle,这样下一次循环就会在更小的区间内继续查找左边界。
选项A:right = middle - 1,可能会错过左边界(当左边界恰好是middle时)。
选项C:选项right = middle + 1,会导致搜索区间错误,可能永远找不到左边界。
正确答案:A
解析:贪心算法是一种在每一步选择中都采取当前状态下的最优决策(局部最优解),希望以此来获得全局最优解的算法策略。它并不考虑整体的最优解,而是通过做出一系列贪心选择来逐步构建最终的解决方案。
每个孩子最多只能给一块饼干,饼干的尺寸大于等于孩子的胃口时,孩子才能得到满足。且小杨的目标是尽可能满足越多数量的孩子。思考贪心策略:考虑从饼干尺寸最大的开始匹配孩子胃口值(当然,从孩子胃口值最小的开始匹配饼干尺寸也是一种可行的思路,这里以从大饼干开始举例)。之所以选择从大饼干开始,是因为大饼干更有可能满足胃口较大的孩子,先使用大饼干去满足相对大胃口的孩子,能使得在后续匹配中,剩余的小饼干有更多机会去满足那些胃口较小的孩子,从而有望满足更多数量的孩子,符合尽可能满足更多孩子这一目标。
那首先需要把饼干、孩子胃口进行排序,第2、3行代码实现了该功能,进行了升序排序。因此后续对饼干和孩子胃口的数组要从后往前遍历。
第8行:如果下标为 index 的饼干可以满足下标为 i 的孩子,则用来记录能满足的孩子数目的变量 result 自增 1,同时 index 自减 1。
正确答案:D
解析:分治算法(Divide - and - Conquer)是一种基于递归思想的算法策略。它的基本思想是将一个复杂的问题分解为若干个规模较小、相互独立且与原问题形式相同的子问题,然后分别求解这些子问题,最后将子问题的解合并起来得到原问题的解。
归并排序和快速排序都采用了分治思想。
冒泡排序(Bubble Sort)是一种简单的排序算法。它的基本思想是通过反复比较相邻的元素,将较大(或较小)的元素逐步 “冒泡” 到数组的一端。跟分治思想无关。
正确答案:B
解析:
选项A:这个代码未对 数组a表示的整数,小于b表示的整数进行处理,因此在这种情况下,返回结果错误
选项C:代码的时间复杂度为
O
(
a
.
s
i
z
e
(
)
)
O(a.size())
O(a.size())
选项D:第21、22、23的代码对结果为 0 的情况进行了处理,如果结果为0,结果数组中只会存一个0。
二、判断题(每题2分,共20分)
正确答案:错误,正确,错误
解析:
第1题错误:单链表不仅支持在表头进行插入和删除操作,也可以在表中指定位置(通过遍历找到相应节点后)以及表尾进行插入和删除操作,只是在表头操作相对更便捷,时间复杂度为
O
(
1
)
O(1)
O(1),而在其他位置操作通常需要先遍历找到对应位置,时间复杂度与链表长度有关。
第2题正确:线性筛(欧拉筛)确实相对于埃拉托斯特尼筛法(埃筛)有这样的优势,埃氏筛法在筛数过程中,同一个合数可能会被多次筛除(例如 6 会被 2 和 3 都筛到),而线性筛通过让每个合数只会被它的最小质因数筛去一次的方式,减少了重复操作,提高了效率。
第3题错误:任何一个大于 1 的自然数都可以分解成若干个质数的乘积,但分解方式在不计较质数顺序的情况下才是唯一的,若考虑顺序,例如
6
=
2
∗
3
6=2*3
6=2∗3 和
6
=
3
∗
2
6=3*2
6=3∗2 就是不同顺序的分解形式,所以严格说分解方式不是唯一的(按常规数学上对于唯一性的严谨定义来说)。
正确答案:错误,正确,错误
解析:
第4题错误:贪心算法通过每一步选择当前最优解,但它并不一定能获得全局最优解。只有当问题具有贪心选择性质和最优子结构性质时,贪心算法才能保证得到全局最优解,比如找零问题符合贪心算法能得最优解,但像旅行商问题等就不能通过简单贪心策略得到全局最优解。
第5题正确:递归算法必须有一个明确的结束条件来终止递归调用过程,如果没有结束条件,函数就会不停地调用自身,一直向系统申请栈空间来存储相关信息,最终导致栈溢出,使程序崩溃。
第6题错误:快速排序和归并排序的平均时间复杂度均为
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn),但归并排序是稳定排序,快速排序是不稳定排序,因为快速排序在划分过程中可能会改变相等元素的相对顺序。
正确答案:错误,正确,正确
解析:
第7题错误:快速排序的平均时间复杂度是
O
(
n
l
o
g
n
)
O(nlogn)
O(nlogn),但在最坏情况下(例如数据已经有序时)时间复杂度会退化为
O
(
n
2
)
O(n^2)
O(n2);插入排序的平均时间复杂度是
O
(
n
2
)
O(n^2)
O(n2),但对于近乎有序的数据,其效率较高,所以不能说快速排序的时间复杂度总比插入排序的低。
第8题正确:二分查找需要能直接定位到中间元素以及按照索引跳跃式访问元素来不断缩小查找范围,数组在内存中是连续存储的,可以通过计算索引快速访问任意位置元素,而链表只能顺序遍历访问,执行跳跃式访问的效率极低,所以二分查找不适用于链表。
第9题正确:对于有序数组进行二分查找元素 19 时,第一次比较中间元素(假设取中间位置向下取整,为 56),发现 19 小于 56,然后在左半部分 {5,13,19,21,37} 继续查找,第二次比较中间元素(此时为 19),所以比较次数是 2次。
正确答案:正确
解析:递归函数每次调用自身时,系统会在栈上为新开启的函数调用分配内存空间,用于存放局部变量、调用返回地址等信息,随着递归深度的增加,栈空间占用会不断增大,相比迭代(通常可以利用有限的几个变量重复利用空间来完成任务),递归通常更加耗费内存空间,在递归深度过大时容易出现栈溢出问题。
三、编程题(每题25分,共50分)
题解
题目要求:奇妙数字:x=p^a,p为任意质数且a为正整数
奇妙数字的集合内m个数字{x1, x2, ..., xm}各不相同且奇妙数字的乘积是n的因子。
则有:n = k * x1 * x2 * ... * xm = k * p1^a1 * p2^a2 *...* pm^am
这个形式想到什么, 唯一分解定理:n = q1^a1 * q2^a2 * ... * qk^ak, 其中q1<q2<...<qk都是质数
则我们如果将qk^ak 拆成互不相同的多个数的乘积,就是上边的式子。
假设 qk^ak = qk^b1 * qk^b2 *...* qk^bk=qk^(b1 + b2 + ... + bk),要求b1,b2,..., bk互不相同。
也即ak拆成互不相同的多个数的和。 能拆多少个,奇妙数字的集合内数字个数就增加多少个。
#include<bits/stdc++.h> //万能头文件
using namespace std;
//num可以拆出多少个互不相同的数相加
long long func(int num){
int res=0;
long long tmp=1;
while(num>=tmp){
res++;
num-=tmp;
tmp++;
}
return res;
}
int main() {
long long n;
cin>>n;
long long res=0;
for(long long i=2; i*i<=n; i++){
if(n%i==0) {
//求出可分解出的质因子qk,以及对应个数ak
int cnt=0;
while(n%i==0) {
cnt++;
n/=i;
}
res += func(cnt);
}
}
if(n!=1) {
res += func(1);
}
cout<<res;
return 0;
}
以下是官方代码加注释的题解版
#include<bits/stdc++.h> //万能头文件
using namespace std;
#define ll long long
int n, m, cnt[1001]={}; //cnt[i] 武器i强化材料的种数
vector<int> cs[1001]; //cs[i][j] 武器i的第j种强化材料的花费
ll calc(int aim){
int cur_cnt=cnt[1]; //武器1现有强化材料数
ll res=0; //保证除武器1之外所有武器的强化材料数小于aim
vector<int> tmp;
/*aim为武器1的目标强化材料数,要求武器1的强化材料种类数严格大于其他的武器。
因此如果其他武器的强化材料数大于等于aim,则考虑将其转换成武器1。
*/
for(int i=2; i<=n; i++){ //遍历其他武器
//武器i有cs[i].size()个强化材料,计算其大于等于目标强化材料数aim的数量
//如果小于,不需要处理;如果大于等于,大于等于的值存入buy,即需要转成武器1的数量
int buy=max((int)cs[i].size()-aim+1, 0);
//将武器i的强化材料转换成武器1的,累加其花费
for(int j=0; j<buy; j++){
res += (ll)cs[i][j];
}
cur_cnt+=buy; //转换之后武器1的强化材料数
//把武器i不用转换的强化材料花费存入tmp中
for(int j=buy; j<cs[i].size(); j++){
tmp.push_back(cs[i][j]);
}
}
//遍历完除1之外的所有武器,使得所有武器的强化材料数小于aim
//tmp中保存了除1之外,所有武器剩余强化材料的开销
sort(tmp.begin(), tmp.end()); //对tmp中材料的花费按升序排序
//如果此时武器1的强化材料书还未达到aim值,则需要从tmp中选取花费低的加入,累计花费。
for(int i=0; i<aim-cur_cnt; i++){
res += (ll)tmp[i];
}
return res;
}
int main() {
cin>>n>>m;
for(int i=1; i<=m; i++){
int p, c;
cin>>p>>c;
cnt[p]++; //武器p的强化材料个数
cs[p].push_back(c); //武器p本次加入强化材料的花费
}
//对武器i的强化材料按花费升序排序
for(int i=1; i<=n; i++){
sort(cs[i].begin(), cs[i].end());
}
ll ans=1e18;
//要想适配武器1的强化材料种类数严格大于其他的武器。
//武器1现有cnt[1]种适配武器,最多可以有m种
//计算武器1适配材料为i个时的开销,选取其中的最小值
for(int i=max(cnt[1], 1); i<=m; i++){
ll res = calc(i);
//cout<<res<<endl;
ans = min(ans, res);
}
cout<<ans;
return 0;
}