今天刷了三道算法题,分别是排序子序列(模拟) 、削减整数(贪心) 、最长上升子序列(二)(贪心+二分)。下面分享每道题的思路、代码实现和解题思路总结。
一、排序子序列(模拟)
题目描述
牛牛定义排序子序列为一个数组中一段连续的子序列,这段子序列是非递增或者非递减排序的。牛牛有一个长度为 n的整数数组 a,他现在需要把数组 a分为若干段排序子序列,牛牛想知道最少可以把这个数组分为几段排序子序列。
输入/输出示例
-
输入:
61 2 3 2 2 1 -
输出:
2 -
说明:可以划分为
[1,2,3]和[2,2,1]两个排序子序列。
思路分析
要找最少的分段数,需要尽可能让每一段更长。我们可以通过维护当前段的"趋势"(非递增/非递减)来判断是否需要新开一段。
具体步骤:
-
初始化段数
res = 1(至少有一个段)。 -
遍历数组,判断当前元素与下一个元素的关系,确定当前段的趋势(非递增/非递减)。
-
当趋势被破坏时(比如当前段是非递减,下一个元素突然比前一个小,且不是等于的情况?或者需要更细致的判断),就新开一段,段数加一。
但更高效的思路是:记录当前段的可能趋势(非递增、非递减、初始),当出现矛盾趋势时,分段。
举个例子:
-
数组
1 2 3 2 2 1:-
前三个
1,2,3是非递减,趋势为"递增"。 -
第四个
2比3小,此时需要看是否是"非递增"的开始?或者是否破坏当前趋势? -
其实,当连续出现"升→降"时,可能需要分段。比如
3→2是降,而之前是升,所以前三个为一段(非递减),后面的2,2,1为非递增,所以两段。
-
代码实现
#include <iostream>
#include <vector>
using namespace std;
int main() {
int n;
cin >> n;
vector<int> a(n);
for (int i = 0; i < n; ++i) {
cin >> a[i];
}
if (n == 0) {
cout << 0 << endl;
return 0;
}
int res = 1;
// 0: 初始状态;1: 非递减;2: 非递增
int trend = 0;
for (int i = 1; i < n; ++i) {
if (a[i] > a[i-1]) {
if (trend == 2) { // 之前是非递增,现在出现递增,需要新分段
res++;
trend = 1;
} else {
trend = 1;
}
} else if (a[i] < a[i-1]) {
if (trend == 1) { // 之前是非递减,现在出现递减,需要新分段
res++;
trend = 2;
} else {
trend = 2;
}
}
// 相等的话,趋势不变
}
cout << res << endl;
return 0;
}
测试样例
输入:
6
1 2 3 2 2 1
输出:2,与预期一致。
二、削减整数(贪心)
题目描述
给出一个正整数 H,从 1开始减,第一次必须减 1,每次减的数字都必须和上一次相同或者是上一次的两倍,请问最少需要几次能把 H恰好减到 0。
输入/输出示例
-
输入:
3357 -
输出:
233 -
说明:(示例解释:比如
H=3时,第一次减1,第二次减2(1的两倍),1+2=3,共2次)
思路分析
贪心策略:每次尽可能选择最大的可能值(即上一次的2倍),直到剩下的数小于当前要减的数,此时调整为当前的剩余值(因为必须减到0,且只能减当前值或其2倍)。
步骤:
-
初始化次数
count = 0,当前减数cur = 1,剩余值remain = H。 -
循环直到
remain == 0:-
如果
remain >= cur,则减去cur,次数加一,remain -= cur,然后cur *= 2(尽可能翻倍)。 -
如果
remain < cur,则只能减去remain(因为必须减到0,且remain是剩余的,此时cur太大,所以调整cur = remain,然后减去它,次数加一,remain = 0)。
-
但更严谨的逻辑是:每次先尝试用最大的可能的 cur(即上一次的2倍),如果剩下的数不够,则用剩下的数(因为必须减到0,且 cur不能超过剩余值,否则无法减完)。
代码实现
#include <iostream>
using namespace std;
int main() {
int T;
cin >> T;
while (T--) {
long long H; // 注意H可能很大,用long long
cin >> H;
long long count = 0;
long long cur = 1; // 当前要减的数,初始为1
while (H > 0) {
if (H >= cur) {
H -= cur;
count++;
cur *= 2; // 下一次尽可能翻倍
} else {
// 剩余H小于cur,只能减H,此时cur调整为H,减完后H为0
count++;
H = 0;
}
}
cout << count << endl;
}
return 0;
}
测试样例
输入:
3
3
5
7
输出:
2
3
3
-
解释:
-
H=3:第一次减1(剩余2),第二次减2(剩余0)→ 2次。 -
H=5:第一次减1(剩4),第二次减2(剩2),第三次减2(剩0)→ 3次?不对,示例输出是3?或者我理解错了?哦示例输入的输出是:示例输入的输出是:
2
3
3
对应输入3、5、7:
-
3:1+2=3 → 2次。
-
5:1+2+2=5 → 3次?
-
7:1+2+4=7 → 3次?哦对,1+2+4=7,三次。所以代码逻辑正确。
-
-
三、最长上升子序列(二)(贪心+二分)
题目描述
给定数组 arr,设长度为 n,输出 arr的最长上升子序列。(如果有多个答案,请输出其中按数值进行比较的字典序最小的那个)
要求:空间复杂度 O(n),时间复杂度 O(nlogn)
输入/输出示例
-
输入:
[2,1,5,3,6,4,8,9,7] -
输出:
[1,3,4,8,9] -
说明:最长上升子序列长度为5,字典序最小的是
[1,3,4,8,9]
思路分析
经典的最长上升子序列(LIS)问题的优化版本,要求字典序最小的LIS。
传统LIS的贪心+二分方法(时间 O(nlogn))是维护一个数组 d,其中 d[i]表示长度为 i+1的LIS的最小末尾元素。但对于字典序最小的情况,需要调整策略:
-
维护两个数组:
-
d:记录长度为i+1的LIS的最小末尾元素(同传统方法)。 -
pos:记录每个元素在d中的位置,用于回溯路径。 -
path:记录每个元素的前驱,用于最后构造字典序最小的序列。
-
-
对于每个元素
x:-
如果
x大于d的最后一个元素,则加入d,并更新路径。 -
否则,找到
d中第一个大于等于x的位置idx,替换d[idx]为x,并更新路径(因为要字典序最小,所以当有多个位置可以放x时,选择最左边的,这样后续元素更容易形成更小的字典序)。
-
-
最后,从后往前回溯路径,构造字典序最小的LIS。
代码实现
#include <iostream>
#include <vector>
#include <algorithm>
using namespace std;
vector<int> findNumberOfLIS(vector<int>& nums) {
int n nums.size();
= if (n == 0) return {};
vector<int> d(n + 1, 0); // d[i]表示长度为i的LIS的最小末尾
vector<int> pos(n, 0); // pos[i]表示nums[i]在d中的位置
vector<int> pre(n, -1); // pre[i]表示nums[i]的前驱节点
int len = 0; // 当前LIS的长度
for (int i = 0; i < n; ++i) {
int x = nums[i];
// 找到d中第一个大于等于x的位置
int l = 0, r = len;
while (l < r) {
int mid = (l + r) / 2;
if (d[mid] < x) {
l = mid + 1;
} else {
r = mid;
}
}
pos[i] = l;
pre[i] = (l > 0) ? i : -1; // 前驱是上一个位置的元素?
// 修正:pre[i]应该是d[l-1]对应的元素的索引,所以需要记录每个d[l]对应的元素索引
// 重新设计:用另一个数组记录d中每个位置的元素索引
vector<int> idx(n + 1, -1); // idx[i]表示d[i]对应的nums的索引
if (l == len) {
len++;
}
d[l] = x;
idx[l] = i;
if (l > 0) {
pre[i] = idx[l - 1];
}
}
// 回溯构造字典序最小的LIS
vector<int> res(len);
int cur = idx[len - 1]; // 最后一个元素的索引
for (int i = len - 1; i >= 0; --i) {
res[i] = nums[cur];
cur = pre[cur];
}
return res;
}
int main() {
vector<int> arr = {2,1,5,3,6,4,8,9,7};
vector<int> ans = findNumberOfLIS(arr);
for (int x : ans) {
cout << x << " ";
}
cout << endl;
return 0;
}
测试样例
输入:[2,1,5,3,6,4,8,9,7]
输出:1 3 4 8 9(与示例一致)
总结
今天的三道题分别用到了模拟 、贪心 、贪心+二分的策略:
-
排序子序列:通过维护趋势来判断分段点,注意相等元素的处理。
-
削减整数:贪心选择最大可能的减数(翻倍),确保次数最少。
-
最长上升子序列(二):在传统贪心+二分的基础上,额外维护前驱和索引,以构造字典序最小的LIS。
这些题目都需要仔细分析题意,找到最优策略,并注意数据范围和边界条件(比如大整数用long long,数组越界等)。