1 单选题(每题 2 分,共 30 分)
第1题 关于单链表、双链表和循环链表,下列说法正确的是( )。
A. 在单链表中,若已知任意结点的指针,则可以在𝑂(1)时间内删除该结点。
B. 循环链表中一定不存在空指针。
C. 在循环双链表中,尾结点的 next 指针一定为 nullptr 。
D. 在带头结点的循环单链表中,判定链表是否为空只需判断头结点的 next 是否指向自身。
解析:答案D。选项A,在单链表中,若只知道某个结点的指针,要删除该结点,通常需要找到其前驱结点,这需要从头结点开始遍历链表,时间复杂度为 𝑂(𝑛),所以错误。选项B,循环链表中每个结点的指针域通常不为空,但当链表为空时,头指针可能为null,因此循环链表中也可能存在空指针,所以错误。选项C,在循环双链表中,尾结点的next指针应指向头结点,而不是nullptr,所以错误。选项D,在带头结点的循环单链表中,若链表为空,则头结点的 next 指针会指向自身,因此只需判断头结点的next是否指向自身即可判断链表是否为空。故选D。
第2题 双向循环链表中要在结点 p 之前插入新结点 s (均非空),以下指针操作正确的是( )。
A.
cpp
s -> next = p;
p -> prev = s;
q -> next = s;
s -> prev = q;
B.
cpp
s -> prev = p;
s -> next = p -> next;
p -> next -> prev = s;
p -> next = s;
C.
cpp
s -> next = p;
s -> prev = p->prev;
p -> prev -> next = s;
p -> prev = s;
D.
cpp
s -> next = p;
s -> prev = nullptr;
p -> prev = s;
解析:答案C。在双向循环链表中,在结点 p 之前插入新结点 s 的正确操作顺序为:
s->next = p:将新结点s的next指针指向结点p
s->prev = p->prev:将新结点s的prev指针指向结点p的原前驱结点
p->prev->next = s:将原结点p的前驱结点的next指针指向新结点s
p->prev = s:将结点p的prev指针指向新结点s
选项C正确地按照这个顺序执行了所有必要的指针操作,所以正确。选项A中使用了未定义的变量q,且指针更新顺序不当,所以错误。选项B的s->prev = p会导致新结点s的prev指针指向p,而p是要插入位置的结点,这会破坏链表结构,所以错误。选项D的s->prev = nullptr会将新结点s的prev指针设为null,在循环链表中每个结点都应该有有效的前驱和后继指针,所以错误。故选C。
第3题 下面函数用"哑结点"统一处理删除单向链表中的头结点与中间结点。横线处应填( )。
cpp
struct Node{
int val;
Node* next;
Node(int v):val(v),next(nullptr){}
};
Node* eraseAll(Node* head, int x){
Node dummy(0);
dummy.next = head;
Node* cur = &dummy;
while(cur->next){
if(cur->next->val == x){
Node* del = cur->next;
______________________
delete del;
}else cur = cur->next;
}
return dummy.next;
}
A.
cpp
cur = cur->next;
B.
cpp
cur->next = del->next;
C.
cpp
del->next = cur->next;
D.
cpp
cur->next = nullptr;
解析:答案B。cur指向待删节点del的前驱节点,del = cur->next 即待删节点,del->next 是待删节点的后继节点。
cur → del → del->next ⇒ cur → del->next
(删除前) (删除后)
选项A,cur = cur->next; 仅移动指针,未修改链表结构,导致del未被移除(内存泄漏,链表仍含无效节点),所以错误。选项B,cur 指向要删除节点的前一个节点,del 指向要删除的节点,因此应该执行 cur->next = del->next 来跳过被删除的节点,所以正确。选项C,del->next = cur->next; 错误反转指针,使del指向自身前驱,破坏链表结构,所以错误。D. cur->next = nullptr; 这会截断链表,把后续所有节点都丢弃,所以错误。故选B。
第4题 对如下代码实现的欧几里得算法(辗转相除法),执行 gcd(48, 18) 得到的调用序列为( )。
cpp
int gcd(int a, int b) {
return b == 0 ? a : gcd(b, a % b);
}
A.
cpp
gcd(48,18) -> gcd(18,12) -> gcd(12,6) -> gcd(6,0)
B.
cpp
gcd(48,18) -> gcd(30,18) -> gcd(12,18)
C.
cpp
gcd(48,18) -> gcd(18,30) -> gcd(30,6)
D.
cpp
gcd(48,18) -> gcd(12,18) -> gcd(6,12)
解析:答案A。第1次gcd(48, 18)调用,a=48,b=18,b≠0,gcd(b, a % b)= gcd(18, 12)。第2次调用,a=18,b=12,b≠0,gcd(b, a % b)= gcd(12, 6)。第3次调用,a=12,b=6,b≠0,gcd(b, a % b)= gcd(6, 0)。第4次调用,a=6,b=0,返回a,即6。故选A。
第5题 下面代码实现了欧拉(线性)筛,横线处应填写( )。
cpp
vector<int> euler_sieve(int n) {
vector<bool> is_composite(n + 1, false);
vector<int> primes;
for (int i = 2; i <= n; i++) {
if (!is_composite[i])
primes.push_back(i);
for (int j = 0; __________________________ && (long long)i * primes[j] <= n; j++) {
is_composite[i * primes[j]] = true;
if (i % primes[j] == 0)
break;
}
}
return primes;
}
A.
cpp
j <= n
B.
cpp
j < sqrt(n)
C.
cpp
j < primes.size()
D.
cpp
j < i
解析:答案C。在欧拉筛(线性筛)的实现中,内层循环的终止条件需确保不越界访问质数数组且保持算法核心逻辑。内层循环遍历已知质数数组 primes,用当前数i乘以质数primes[j]标记合数(is_composite[i * primes[j]] = true)。
选项A,因为primes.size()远小于n(质数密度约𝑛/ln𝑛,此条件会遍历无效索引,导致越界,所以错误。选项B,质数数量与
无关(如𝑛=100时质数有25个,
=10),会遗漏质数,破坏筛法完整性,所以错误。选项C,j 必须满足 j < primes.size(),否则当 j ≥ primes.size() 时会越界访问 primes[j],导致未定义行为(如程序崩溃),所以正确。选项D,i可能极大(接近𝑛),但primes.size()较小,此条件会遍历大量无效索引,效率低下且可能越界。故选C。
第6题 埃氏筛中将内层循环从 j = i*i 开始而不是 j = 2*i 的主要原因是( )。
cpp
vector<int> eratosthenes_sieve(int n) {
vector<bool> is_composite(n + 1, false);
vector<int> primes;
for (int i = 2; i <= n; i++) {
if (is_composite[i]) continue;
primes.push_back(i);
for (long long j = (long long)i * i; j <= n; j += i)
is_composite[j] = true;
}
return primes;
}
A. 因为2*i一定不是合数
B. i*i一定是质数
C. 小于i*i的i的倍数已被更小质因子筛过
D. 这样可以把时间复杂度降为𝑂(𝑛)。
解析:答案C。在埃氏筛中,外层循环i从2开始递增,每当发现一个未被标记的数i,就将其加入质数列表,并标记它的所有倍数为合数。当我们用质数 i 去标记它的倍数时,所有小于i*i的i的倍数,形如:i*2, i*3, ..., i*(i-1)**,**其实早已被更小的质因子筛除过了。例如:当 i = 5 时,我们考虑标记 5×2=10, 5×3=15, 5×4=20, 5×5=25...,但注意:10 = 2×5 → 已在i=2时被标记;15 = 3×5 → 已在i=3时被标记;20 = 4×5 = 2²×5 → 仍由i=2标记过。所以,只有从i*i(i²)开始的倍数,程序i*i是第一个未被更小质数筛掉的i的倍数。因此,从j = i*i开始,避免了重复标记,极大提升了效率。
选项A,2*i一定是合数,所以完全错误;选项B,i 是质数,但i*i是平方数,必为合数, 所以错得荒谬;选项C,符合前面的分析,所以正确;选项D,混淆了算法,埃氏筛的时间复杂度是𝑂(𝑛 log log 𝑛),不是𝑂(𝑛),只有欧拉筛才能达到𝑂(𝑛),所以错误。故选C。
第7题 下面程序的运行结果为( )。
cpp
bool check(int n, int a[], int k, int dist) {
int cnt = 1;
int last = a[0];
for (int i = 1; i < n; i++) {
if (a[i] - last >= dist) {
cnt++;
last = a[i];
}
}
return cnt >= k;
}
int solve(int n, int a[], int k) {
std::sort(a, a + n);
int l = 0;
int r = a[n - 1] - a[0];
while (l < r) {
int mid = (l + r + 1) / 2;
if (check(n, a, k, mid))
l = mid;
else
r = mid - 1;
}
return l;
}
int main() {
int a[] = {1, 2, 8, 4, 9};
int n = 5;
int k = 3;
std::cout << solve(n, a, k) << std::endl;
return 0;
}
A. 2 B. 3 C. 4 D. 5
解析:答案B。程序核心目标是实现了"最大化最小距离"问题(即经典的最小磁力问题),通过二分查找+贪心策略求解:
输入数组:[1, 2, 8, 4, 9](排序后为 [1, 2, 4, 8, 9])
目标:从数组中选出至少k=3个元素,使相邻元素的最小距离尽可能大。
关键函数逻辑:check()函数(贪心验证):从第一个元素开始,统计满足a[i]-last≥dist的元素数量cnt。若cnt≥k说明当前距离dist可行。solve()函数(二分查找):二分区间:l=0, r=9-1=8 (最大距离)。二分策略:若check(mid)成立 → l=mid(尝试更大距离),否则 → r=mid-1(缩小距离)。终止条件:l==r 时返回最大可行距离。手动模拟执行过程如下表所示:

选项A,距离2可行,但3更大(题目要求最大化),所以错误。选项B,距离3可行,已最大化,如上表,所以正确。选项C、选项D,验证失败(如上表)。故选B。
第8题 在升序数组中查找第一个大于等于 x 的位置,下面循环中横线应填( )。
cpp
int lowerBound(const vector<int>& a, int x){
int l=0, r=a.size();
while(l<r){
int mid = l + (r - l)/2;
if(a[mid] >= x) _____________;
else l = mid + 1;
}
return l;
}
A. r = mid; B. r = mid - 1; C. l = mid; D. l = mid + 1;
解析:答案A。根据给出的代码框架和二分查找算法原理,当a[mid] >= x时,说明mid位置是一个大于等于x的候选位置(但不一定是第一个),因此需要将搜索范围向左缩小(包括mid本身),以继续寻找更靠左的满足条件的位置。此时,应调整右边界r为mid,以保持区间[l, r) 的合理性。
选项A,a[mid] >= x 时,mid是潜在的答案或更靠右的满足条件的位置。为了确保不遗漏第一个大于等于x的元素,应将右边界r设为mid,使新区间变为 [l, mid)(半开区间),这样 mid 仍可能在下轮迭代中被包含(因为循环条件是 l < r)。结合else分支l=mid+1(当a[mid]<x时,说明左半部分(包含mid)均小于x,故跳过mid),此逻辑保证了循环结束时 l == r,且l即为第一个大于等于x的位置(若所有元素均小于x,则l返回a.size(),表示无匹配),所以正确。选项B,可能跳过正确答案。例如,若mid是第一个大于等于x的位置,设置r=mid-1会丢失mid,导致结果错误。选项C,可能导致死循环或错过元素。例如,当l和mid相等时,循环无法终止(因为l不更新),所以错误。选项D,当a[mid]>=x时,mid可能是目标位置,但此选项直接跳过 mid,可能遗漏第一个满足条件的元素,所以错误。故选A。
第9题 关于递归函数调用,下列说法错误的是( )。
A. 递归调用层次过深时,可能会耗尽栈空间导致栈溢出
B. 尾递归函数可以通过编译器优化来避免栈溢出
C. 所有递归函数都可以通过循环结构来改写,从而避免栈溢出
D. 栈溢出发生时,程序会抛出异常并可以继续执行后续代码
解析:答案D。选项A,递归调用时,每次函数调用都会在调用栈上分配新的栈帧,存储局部变量、返回地址等信息,如果递归深度过大或缺乏有效终止条件,栈帧会持续累积,最终耗尽栈空间,导致栈溢出错误,所以正确。选项B,尾递归是一种特殊递归形式,其中递归调用是函数体中的最后一个操作,且返回值直接返回,不进行额外计算。编译器(如Scala、GCC)可将其优化为循环(尾调用消除),复用当前栈帧,避免栈增长和溢出,所以正确。选项C,理论上,任何递归函数均可转换为迭代版本(如使用循环和显式栈结构模拟递归逻辑),完全消除对系统调用栈的依赖。例如,深度优先搜索(DFS)等递归算法可改用循环和数组/栈数据结构管理状态,避免栈溢出风险,所以正确。选项D,栈溢出时,程序通常会抛出异常(如StackOverflowError),但无法安全继续执行后续代码。因为栈空间已耗尽,程序状态可能损坏,导致崩溃或终止,无法恢复,所以错误。故选D。
第10题 给定 n 根木头,第i 根长度为 a[i] 。要切成不少于 m 段等长木段,求最大可能长度,则横线上应填 写( )。
cpp
const int MAXN = 100005;
long long a[MAXN];
int n, m;
bool check(long long x){
long long cnt = 0;
for(int i = 1; i <= n; i++){
if(x == 0) return true;
cnt += a[i] / x;
if(cnt >= m) return true;
}
return false;
}
int main(){
cin >> n >> m;
long long mx = 0;
for(int i = 1; i <= n; i++){
cin >> a[i];
mx = max(mx, a[i]);
}
long long l = 1, r = mx;
long long ans = 0;
while(l <= r){
long long mid = l + (r - l) / 2;
if(check(mid)){
ans = mid;
______________________
}else{
______________________
}
}
cout << ans << endl;
return 0;
}
A.
cpp
l = mid + 1;
r = mid - 1;
B.
cpp
l = mid - 1;
r = mid + 1;
C.
cpp
l = mid + 1;
r = mid;
D.
cpp
l = mid;
r = mid + 1;
解析:答案A。给定问题要求将n根木头切成不少于m段等长木段,求最大可能长度。这是一个典型的最大化问题,通常使用二分查找解决。二分查找的核心在于根据中间值 mid 的验证结果(即当前长度下总段数是否≥m),动态调整搜索边界l(左边界)和r(右边界)。正确的边界更新逻辑是:
若mid满足条件(总段数≥m),说明 mid 可行,但可能存在更大的可行长度,因此需增大左边界(即l=mid+1);若mid不满足条件(总段数<m),说明mid过长,需减小右边界以尝试更小长度(即r=mid-1),所以选项A正确。l=mid-1; r=mid+1; 满足条件时应增大l,但此处减小l;不满足条件时应减小r,但此处增大 r,导致搜索方向相反,所以选项B错误。l=mid+1; r=mid; 不满足条件时仅设置r=mid而非r=mid-1,可能导致死循环(如l和r相邻时无法收敛)或错误解,所以选项C错误。l=mid; r=mid+1; 满足条件时未增大l,无法探索更大长度;不满足条件时增大r,反而搜索更长长度,与需求矛盾,所以选项D错误。故选A。
第11题 下面代码用分治求"最大连续子段和",其时间复杂度为( )。
cpp
int solve(vector<int>& a, int l, int r){
if(l == r) return a[l];
int mid = l + (r - l) / 2;
int left = solve(a, l, mid);
int right = solve(a, mid + 1, r);
int sum = 0, lmax = INT_MIN;
for(int i = mid; i >= l; i--){
sum += a[i];
lmax = max(lmax, sum);
}
sum = 0;
int rmax = INT_MIN;
for(int i = mid + 1; i <= r; i++){
sum += a[i];
rmax = max(rmax, sum);
}
return max({left, right, lmax + rmax});
}
A. 𝑂(𝑛²) B. 𝑂(𝑛 log 𝑛) C. 𝑂(log 𝑛) D. 𝑂(𝑛)
解析:答案B。递归结构:算法将数组分成两半(左子数组和右子数组),分别递归求解最大连续子段和(left和right)。每次递归问题规模减半,递归深度为log₂𝑛。
合并步骤:在合并结果时,计算跨越中点的最大子段和(即左半部分的最大后缀和与右半部分的最大前缀和之和)。这部分包含两个独立的循环:第一个循环从 mid 向左遍历到l,计算左半部分的最大后缀和。第二个循环从mid+1向右遍历到r,计算右半部分的最大前缀和。每个循环的时间复杂度为𝑂(𝑛) (因为最坏情况需遍历整个子数组)。
递归方程:设𝑇(𝑛)为规模𝑛的问题的时间复杂度,则递归方程为:
𝑇(𝑛)=2𝑇(𝑛/2)+𝑂(𝑛)
其中2 𝑇(𝑛/2)对应两个递归调用,𝑂(𝑛)对应合并步骤。
𝑇(𝑛)=2𝑇(𝑛/2)+𝑂(𝑛)= 2[2𝑇(𝑛/4)+𝑂(𝑛/2)]+𝑂(𝑛)= 4𝑇(𝑛/4)+2𝑂(𝑛)
=4[2 𝑇(𝑛/8)+𝑂(𝑛/8)]+2𝑂(𝑛)=8𝑇(𝑛/8)+3𝑂(𝑛)
⋮
=2ᵏ𝑇(𝑛/2ᵏ)+𝑘·𝑂(𝑛)
令𝑛/2ᵏ=1,即𝑘=log₂𝑛,且𝑇(1)=𝑂(1),代入得
𝑇(𝑛)=𝑛𝑇(1)+ log₂𝑛 𝑂(𝑛)=𝑂(𝑛 log₂𝑛)
与选项B一致,故选B。
第12题 游戏大赛决赛,两组选手分别按得分从小到大排好队,现在要把他们合并成一个有序排行榜。 A组:A = {12, 35, 67, 89},B组:B = {20, 45, 55, 78},下面是归并合并函数的核心循环,横线处应填 入( )。
cpp
int i = 0, j = 0;
vector<int> result;
while (i < A.size() && j < B.size()) {
if (___________________) {
result.push_back(A[i++]);
} else {
result.push_back(B[j++]);
}
}
while (i < A.size()) {
result.push_back(A[i++]);
}
while (j < B.size()) {
result.push_back(B[j++]);
}
A. A[i] >= B[j] B. A[i] <= B[j] C. i >= j D. i <= j
解析:答案B。要解决合并两个有序数组(A组和B组)的问题,核心在于归并排序的双指针法。给定代码片段中,横线处应填入的条件需确保合并后的数组保持升序。
选项A,A[i]>=B[j],此条件会导致当A[i]较大时添加A[i],但实际应优先添加较小值。例如,A=12和B=20时,12 >= 20为假,会错误添加B[j](20),但12更小,应优先添加,所以错误。
选项B,A[i] <= B[j],此条件在比较两个数组当前元素的值。当A[i]小于或等于B[j]时,将A[i]加入结果数组并移动指针i;否则,加入B[j]并移动指针j。这保证了每次添加的元素都是两个数组剩余部分中的最小值,从而维持升序合并。例如:
初始状态:A=12, B=20,12 <= 20为真,添加A[i++]。
下一步:A=35, B=20,35 <= 20为假,添加B[j++]。
依此类推,最终得到有序结果{12, 20, 35, 45, 55, 67, 78, 89},所以正确。
选项C,i>=j和选项D,i<=j,这些比较索引而非元素值,索引大小与元素顺序无关(如i=0、j=0时,索引相等但值可能不同),无法保证合并后的有序性,故错误。
第13题 有𝑛位同学的成绩已经从小到大排好序,现在对它执行下面这段以第一个元素为 pivot 的快速排序,请 问此次排序的时间复杂度是( )。
cpp
void quicksort(vector<int>& a, int l, int r) {
if (l >= r) return;
int pivot = a[l];
int i = l, j = r;
while (i < j) {
while (i < j && a[j] >= pivot) j--;
while (i < j && a[i] <= pivot) i++;
if (i < j) swap(a[i], a[j]);
}
swap(a[l], a[i]);
quicksort(a, l, i - 1);
quicksort(a, i + 1, r);
}
A. 𝑂(𝑛) B. 𝑂(𝑛 log 𝑛) C. 𝑂(𝑛²) D. 𝑂(log 𝑛)
解析:答案C。当输入数组已经按升序排好序时,如果快速排序选择第一个元素作为pivot(如代码所示),会导致最坏情况的发生。每次选择的pivot是当前子数组的最小元素(因为数组已排序)。在分区过程中,所有大于pivot的元素都会被移到pivot的右侧,而所有小于pivot的元素都会被移到pivot的左侧。由于数组已排序,pivot左侧没有元素(因为pivot是最小值),右侧包含所有其他元素。因此,每次分区后,一个子数组为空,另一个子数组包含𝑛‒1个元素。
递归树的深度为𝑛,因为每次递归都减少一个元素。每一层的分区操作需要𝑂(𝑛)时间。
总时间复杂度为 𝑛×𝑂(𝑛)=𝑂(𝑛²)。这种情况下,快速排序的时间复杂度退化为最坏情况,即𝑂(𝑛²),选项C正确,故选C。
第14题 下面关于排序算法的描述中,不正确的是( )。
A. 冒泡排序和插入排序都是稳定的排序算法
B. 快速排序和归并排序都是不稳定的排序算法
C. 冒泡排序和插入排序最好时间复杂度均为𝑂(𝑛)
D. 归并排序在最好、最坏和平均三种情况的时间复杂度均为𝑂(𝑛 log 𝑛)
解析:答案B。冒泡排序和插入排序都是稳定的排序算法,所以选项A正确。快速排序是不稳定的排序算法,因为元素在分区过程中可能会改变相等元素的相对顺序;归并排序是稳定的排序算法,因为它在合并过程中可以保证相等元素的相对顺序,所以选项B错误。冒泡排序在最好情况下(已有序)只需要一次遍历,时间复杂度为𝑂(𝑛);插入排序在最好情况下(已有序)时间复杂度为也是𝑂(𝑛),所以选项C正确。归并排序的时间复杂度在所有情况下都是𝑂(𝑛 log 𝑛),因为它采用分治策略,无论输入如何,都会进行log 𝑛层递归,每层需要𝑂(𝑛)时间合并,所以选项D正确。故选B。
第15题 下面代码实现两个整数除法,其中被除数为一个"大整数",用字符串表示,除数是一个小整数,用 int 表示,则横线处应该填写( )。
cpp
int main(){
string s;
int b;
cin >> s >> b;
vector<int> a;
for(char c : s){
a.push_back(c - '0');
}
vector<int> c;
long long rem = 0;
for(int i = 0; i < a.size(); i++){
rem = rem * 10 + a[i];
int q = rem / b;
c.push_back(q);
______________________
}
int pos = 0;
while(pos < c.size() - 1 && c[pos] == 0) pos++;
for(int i = pos; i < c.size(); i++){
cout << c[i];
}
cout << endl;
cout << rem << endl;
return 0;
}
A. rem /= b; B. rem %= b; C. rem = b; D. rem = q;
解析:答案B。在代码中rem = rem * 10 + a[i]:将上一步余数"下移一位",并加入当前数字,形成新的被除部分。int q = rem / b:计算当前位的商。c.push_back(q):保存该位商。所以要求rem %= b来更新余数为当前被除部分除以b的余数,为下一位计算做准备。这一步等价于:rem = rem - q * b,即减去已计算出的商所占部分,只保留"剩余"部分。故选B。
2 判断题(每题 2 分,共 20 分)
第1题 有一个存储了𝑛个整数的线性表,分别用数组和单链表两种方式实现。在已知下标(或结点指针)的前提下,数组的随机访问是𝑂(1),在链表中已知某结点的指针时,在该结点之后插入一个新结点的操作也是𝑂(1)。
解析:答案正确(√)。数组在内存中是连续存储的,因此在已知下标的情况下,可以通过索引直接计算出元素地址进行访问,时间复杂度为𝑂(1)。这是数组的基本特性,无需额外遍历。在单链表中,当已知某结点的指针时,在该结点之后插入一个新结点只需两步操作:⑴将新结点的next指针指向已知某结点的原后继结点。⑵将已知结点的next指针指向新结点。这两步均为常量操作,不涉及遍历链表,因此时间复杂度为𝑂(1)。
第2题 若数组 a 已按升序排列,则下面代码可以正确实现"在 a 中查找第一个大于等于 x 的元素的位置"。
cpp
int lowerBound(vector<int>& a,int x){
int l=0, r=a.size();
while(l < r) {
int mid = (l + r) / 2;
if( a[mid] >= x) r = mid;
else l = mid + 1;
}
return l;
}
解析:答案正确(√)。这段代码完全正确地实现了"查找第一个大于等于 x 的元素的位置",即标准的 lower_bound 二分查找算法。(参考选择题第8题程序和解析)
初始边界:l=0, r=a.size() → 表示搜索区间为[0, n),左闭右开,这是二分查找中处理"第一个满足条件"问题的经典设计。循环条件:while(l<r) → 确保区间非空,当l==r时,区间收缩为一个点,即答案。中点计算:mid=(l+r)/2 → 向下取整,避免死循环(与l+(r-l)/2等价)。
if (a[mid] >= x) r = mid; // 满足条件,答案在左半部分(含mid)
else l = mid + 1; // 不满足,答案在右半部分(不含mid)
只要 a[mid]>=x,就说明mid可能是答案,不能丢弃,于是让r=mid保留它;只有当 a[mid]<x,才说明答案一定在右边,于是l=mid+1。故正确。
第3题 快速排序只要每次都选取中间元素作为枢轴,就一定是稳定排序。
解析:答案错误(╳)。即使每次都选取中间元素作为枢轴,快速排序仍然是不稳定的排序算法。快速排序的核心是分区(partition)操作:将数组划分为小于等于枢轴和大于枢轴的两部分,并交换元素。即使你选择中间元素作为pivot,分区过程仍会跨位置交换元素,而这种交换不保证相等元素的相对顺序。故错误。
第4题 若某算法满足递推式:𝑇(𝑛)=2𝑇(𝑛/2)+𝑂(𝑛),则其时间复杂度为𝑂(𝑛 log 𝑛)。
解析:答案正确(√)。推导过程见选择题第11题解析。
第5题 在一个数组中,如果两个元素 a[i] 和 a[j] 满足 i < j 且 a[i] > a[j] ,则 a[i] 和 a[j] 是一个逆序对。
下面代码可以正确统计数组 a 区间 [l,r] 内的逆序对总数。
cpp
long long cnt=0;
void merge_count(vector<int>& a, int l, int m, int r){
int i = l, j = m + 1;
while(i <= m && j <= r) {
if(a[i] <= a[j]) i++;
else {
cnt += (m - i+ 1);
j++;
}
}
}
解析:答案错误(╳)。这段代码无法正确统计区间[l,r]内的逆序对总数,核心错误在于未实现归并排序的完整合并过程,缺少递归调用,且全局变量 cnt 在递归调用中会重复累加,导致结果严重错误。故错误。
第6题 唯一分解定理保证:若一个数未被任何不超过其平方根的质数筛去,则它一定是质数。
解析:答案正确(√)。该命题是素数判定中基于试除法的核心原理,它并非直接由"唯一分解定理"保证。唯一分解定理(算术基本定理)说的是:每个大于1的整数都可以唯一地分解为质数的乘积(不计顺序),它保证了分解的存在性与唯一性,但并未直接给出"如何判断一个数是否为质数"。但题目逻辑成立,且是数论中判断质数的经典方法。故正确。
第7题 假设数组𝑎的值域范围是𝐷,以下程序的时间复杂度是𝑂(𝑛 log 𝑛 + 𝑛 log 𝐷)。
cpp
bool check(int n, int a[], int k, int dist) {
int cnt = 1;
int last = a[0];
for (int i = 1; i < n; i++) {
if (a[i] - last >= dist) {
cnt++;
last = a[i];
}
}
return cnt >= k;
}
int solve(int n, int a[], int k) {
std::sort(a, a + n);
int l = 0;
int r = a[n - 1] - a[0];
while (l < r) {
int mid = (l + r + 1) / 2;
if (check(n, a, k, mid))
l = mid;
else
r = mid - 1;
}
return l;
}
int main() {
int a[] = {1, 2, 8, 4, 9};
int n = 5;
int k = 3;
std::cout << solve(n, a, k) << std::endl;
return 0;
}
解析:答案正确(√)。关键要理解𝐷=max(𝑎)−min(𝑎)是数组的值域跨度(最大值与最小值之差)。本题程序分两个阶段:⑴排序阶段:STL的sort()排序std::sort(a, a + n); 的时间复杂度为𝑂(𝑛 log 𝑛)。排序这是标准"最大化最小间距"问题(如"牛棚分配"、"信号塔部署")的前置条件。⑵二分搜索:
int l = 0;
int r = a[n - 1] - a; // D = max - min
while (l < r) {
int mid = (l + r + 1) / 2;
...
}
你在搜索的是最大可能的最小间距,这个值的取值范围是[0, 𝐷]。二分搜索的迭代次数是𝑂(log ₂(𝐷+1))≈𝑂(log ₂𝐷)。⑶𝑂(log ₂𝐷)次check调用:
bool check(int n, int a[], int k, int dist) {
int cnt = 1; int last = a;
for (int i = 1; i < n; i++) {
if (a[i] - last >= dist) { cnt++; last = a[i]; }
}
return cnt >= k;
}
这是一个线性贪心扫描,时间复杂度为𝑂(𝑛)。二分搜索总共调用𝑂(log 𝐷)次check,因此这部分总复杂度为𝑂(𝑛 log 𝐷)。所以总时间复杂度为𝑂(𝑛 log 𝑛 + 𝑂(𝑛 log 𝐷),所以正确。
第8题 若一个问题满足最优子结构性质,则一定可以用贪心算法得到最优解。
解析:答案错误(╳)。满足最优子结构性质并不保证能用贪心算法得到最优解------它只是贪心算法可行的必要条件,而非充分条件。贪心策略的有效性依赖于"局部最优能导向全局最优",而这并非所有最优子结构问题都天然具备的特性。
第9题 线性筛相比埃氏筛的核心改进在于:埃氏筛中一个合数可能被多个质数重复标记,线性筛通过"每个合数只被其最大质因子筛去"的策略,保证每个合数恰好被标记一次,从而实现𝑂(𝑛)的时间复杂度。
解析:答案错误(╳)。线性筛的核心改进在于确保每个合数只被其最小质因子筛去 ,而非最大质因子。故错误。
第10题 任何递归程序都可以改写为等价的非递归程序,但改写后的非递归程序一定需要显式地使用栈来模拟递归调用过程。
解析:答案错误(╳)。虽然任何递归程序都可以改写为等价的非递归程序,但并非一定需要显式使用栈------这取决于递归的结构和问题特性。注意:递归=调用栈,但"显式栈"≠唯一替代方案。⑴递归的本质是隐式栈,递归调用在运行时由系统维护一个调用栈(Call Stack),保存每一层的局部变量、返回地址、参数等。→ 非递归改写的目标,是用显式数据结构模拟这个隐式栈的行为。⑵但"显式栈"不是唯一方式!故错误。
3 编程题(每题 25 分,共 50 分)
3.1 编程题1
- 试题名称:有限不循环小数
- 时间限制:1.0 s
- 内存限制:512.0 MB
3.1.1题目描述
若
可化为一个有限的,不循环的小数,则称𝑎为终止数。请你求出在𝐿到𝑅中终止数的数量。
3.1.2 输入格式
输入一行,包含两个整数𝐿, 𝑅。
3.1.3 输出格式
输出一行,包含一个整数,表示𝐿到𝑅中终止数的数量。
3.1.4 样例
3.1.4.1 输入样例
cpp
2 11
3.1.4.2 输出样例
cpp
5
3.1.5 样例解释
在[2, 11]终止数有2、4、5、8、10。
3.1.6 数据范围1≤𝐿≤𝑅≤10⁶保证 。
3.1.7 编写程序
解析:一个整数𝑎(𝑎≠0)的倒数是1/𝑎,该倒数化为小数为有限小数。当且仅当:将1/𝑎化为最简分数后,其分母的质因数只包含2和/或5(可以多个2和/或多个5),这些数称终止数。
由于1/𝑎已是最简形式(分子为1),只需检查𝑎的质因数是否仅为2和/或5(如𝑎能被2和/或5整除,则最终结果为1则𝑎的质因数是否仅为2和/或5)。所以题目的问题就变成:从𝐿到𝑅的整数,其质因数是否仅为2和/或5的个数。
方法一:直接按题意编程实现。
程序通过枚举从L到R的每个整数,不断整除以2和5,直到不能整除为止。若最终结果为 1,则该数只包含质因子2和5,是终止数,则计数,最后输出计数值。完整程序代码(洛谷AC)如下:
cpp
#include <iostream>
using namespace std;
int main() {
int l, r, cnt = 0; // 𝐿≤𝑅≤10⁶. cnt为计数值
cin >> l >> r;
for (int i = l; i <= r; i++) {
int tmp = i;
while (tmp && tmp % 2 == 0) // 如tmp能被2整除
tmp /= 2; // tmp调整为除以2的商
while (tmp && tmp % 5 == 0) // 如tmp能被5整除
tmp /= 5; // tmp调整为除以5的商
if (tmp == 1) cnt++; // 如tmp=>2ˣ5ʸ,则最终tmp=1
}
cout << cnt << endl;
return 0;
}
方法二:用函数实现。
先编写判断其是否为"终止数"函数,对于某个数,不断整除以2和5,直到不能整除为止。若最终结果为 1,则该数只包含质因子2和5,是终止数,返回true,否则返回false。
程序通过枚举从L到R的每个整数,判断其是否为"终止数",是则计数,最后输出计数值。完整程序代码(洛谷AC)如下:
cpp
#include <iostream>
using namespace std;
bool isTerminatingNumber(int n) { // 判断一个数是否只包含因子2和5
while (n % 2 == 0) n /= 2; // 不断除以2直到不能整除
while (n % 5 == 0) n /= 5; // 不断除以5直到不能整除
return n == 1; // 如果最终结果为1,则只包含因子2和5
}
int main() {
int L, R, cnt=0; // L≤R≤10⁶, 计数值初始化为0
cin >> L >> R; // 读入L, R
for (int i = L; i <= R; i++) // 遍历区间[L, R]中的每个数
if (isTerminatingNumber(i)) cnt++; // 返回true则计数
cout << cnt << endl;
return 0;
}
3.2 编程题2
- 试题名称:找数
- 时间限制:1.0 s
- 内存限制:512.0 MB
3.2.1题目描述
给定一个包含𝑛个互不相同的正整数的数组𝐴与一个包含𝑚个互不相同的正整数的数组𝐵,请你帮忙计算有多少数在数组𝐴与数组𝐵中均出现。
3.2.2 输入格式
第一行包含两个整数𝑛, 𝑚。
第二行包含𝑛个正整数𝑎₁, 𝑎₂, ..., 𝑎ₙ表示数组𝐴。
第二行包含𝑚个正整数𝑏₁, 𝑏₂, ..., 𝑏ₘ表示数组𝐵。
3.2.3 输出格式
输出一个整数,表示在数组𝐴与数组𝐵中均出现的数的个数。
3.2.4 样例
3.2.4.1 输入样例
cpp
3 5
4 2 3
3 1 5 4 6
3.2.4.2 输出样例
cpp
2
3.2.5 样例解释
样例1,4、3在数组𝐴与𝐵中均出现。
3.2.6 数据范围
对于40%的数据,保证1≤𝑛, 𝑚≤1000。
对于100%的数据,保证1≤𝑛, 𝑚≤10⁵,1≤𝑎ᵢ, 𝑏ᵢ≤10⁹。
3.2.7 编写程序
解析:题目给定包含𝑛个互不相同的正整数的数组𝐴与一个包含𝑚个互不相同的正整数的数组𝐵,其本质是求两个数组(集合)的交集元素个数,可采用哈希集合法、排序+二分法、双指针法等方法实现。
方法一:排序+二分查找策略实现。
思路:由于数组𝐴和数组𝐵的规模相同,都10⁵级,先对数组𝐴进行排序,然后遍历数组𝐵,对数组𝐵中每一个元素用在数组𝐴中二分查找交集,每次成功查找时计数器增1。
时间复杂度:数组𝐴排序的时间复杂度为𝑂(𝑛 log 𝑛),遍历数组𝐵的时间复杂度为𝑂(𝑚) ,在数组𝐴中二分查找的时间复杂度为𝑂(log 𝑛),合计:𝑂(𝑛 log 𝑛 + 𝑚 log 𝑛)。
对本题:𝑂(𝑛 log 𝑛 + 𝑚 log 𝑛)= 10⁵log10⁵+10⁵log10⁵<3.33*10⁶<<10⁸,不会超时。
按此思路编写的参考代码(洛谷AC)如下:
cpp
#include <iostream>
#include <algorithm>
using namespace std;
const int MAX_N = 100010; // n,m≤10⁵
int main() {
ios::sync_with_stdio(false); // 此二行为优化输入输出性能
cin.tie(0); // 加速输入输出流
int n, m, cnt = 0, A[MAX_N], tmp; // 计数值初始为0
cin >> n >> m; // 读入n, m
for (int i = 0; i < n; i++) cin >> A[i]; // 读入数组A
sort(A, A + n); // 用STL的sort对数组A排序(时间复杂度𝑂(𝑛 log 𝑛))
for (int i = 0; i < m; i++) { // 遍历数组B
cin >> tmp; // 读入数组B的元素
int left = 0, right = n - 1; // 以下为在数组A中二分查找tmp
while (left <= right) {
int mid = left + (right - left) / 2;
if (A[mid] == tmp) { // 找到计数并跳出继续下一数查找
cnt++;
break;
} else if (A[mid] < tmp) {
left = mid + 1;
} else {
right = mid - 1;
}
}
}
cout << cnt << endl; // 输出结果
return 0;
}
方法二:哈希集合法策略实现。
使用哈希集合存储数组𝐴的元素,然后遍历数组𝐵检查元素是否在集合中。该方法时间复杂度为𝑂(𝑛+𝑚),空间复杂度𝑂(𝑛),完全满足题目数据要求。
关键核心:哈希表提供常数时间复杂度𝑂(1)的插入和查找操作。自动处理元素去重(本题目含𝑛个互不相同的正整数的数组𝐴,符合要求)。
按此思路编写的参考代码(洛谷AC)如下:
cpp
#include <iostream>
#include <unordered_set> // 哈希集合头文件
#include <vector>
using namespace std;
int main() {
ios::sync_with_stdio(false); // 此二行为优化输入输出性能
cin.tie(nullptr); // 加速输入输出流
int n, m, tmp,cnt = 0; // 计数值初始为0
cin >> n >> m; // 读入n, m
unordered_set<int> setA; // 定义数组A为哈希集合
for (int i = 0; i < n; i++) {
cin >> tmp; // 读入数组A元素
setA.insert(tmp); // 存入集合
}
for (int i = 0; i < m; i++) {
cin >> tmp; // 读入数组B元素
if (setA.find(tmp) != setA.end()) { // 检查数组B元素是否在集合中
cnt++; // 在集合中则计数
}
}
cout << cnt << endl;
return 0;
}