经典算法题解析

再次总结经典算法题目。

复杂度分析

一个程序的运行时间主要和两个因素有关:

1.执行每条语句的耗时。2.执行每条语句的频率。前者取决于硬件,后者取决于算法本身和程序的输入。在相同的硬件环境下,不同算法的执行时间只取决于语句的执行频率,因此可以将对执行时间的关注进一步简化为对执行频率的关注。

定义:$T(n) = O(f(n))$ 表示当 $n$ 趋于无穷大时,$T(n)$ 的增长率不超过 $f(n)$。

复杂度名称形象描述典型案例
$O(1)$常数阶无论数据多少,一瞬间完成数组下标取值、哈希表单次查询
$O(\log n)$对数阶每走一步,范围缩小一半二分查找(Binary Search)
$O(n)$线性阶每一个都要过一遍简单遍历、求最大值
$O(n \log n)$线性对数阶比较高效的排序快速排序、归并排序、堆排序
$O(n^2)$平方阶每个人都要跟所有人打个招呼冒泡排序、插入排序、嵌套循环
$O(2^n)$指数阶噩梦,数据多一点点就炸了暴力解斐波那契、汉诺塔
$O(n!)$阶乘阶宇宙爆炸级别全排列问题(旅行商问题暴力解)

递归算法的复杂度分析

总时间 = 递归调用的总次数 $\times$ 每次调用产生的额外工作量

当你看不清逻辑时,就把递归画成一棵树。每一层代表递归的一级,每个节点代表一次函数调用。

例子:斐波那契数列(暴力递归)

1
fib(n) = fib(n-1) + fib(n-2)
  • 树的深度:$n$。
  • 每一层的节点数:每个节点分出 2 个子节点,第 $k$ 层就有 $2^k$ 个节点。
  • 总节点数:$1 + 2 + 4 + … + 2^{n-1} \approx 2^n$。
  • 结论:复杂度是 $O(2^n)$。这是指数级的,数据稍微大一点(比如 $n=50$)电脑就跑不动了。

例子:归并排序 (Merge Sort)

  1. 把数组对半切开:$2 \times T(n/2)$
  2. 合并两个有序数组:$O(n)$
  • 树的深度:$\log n$(因为每次都砍一半,砍 $\log n$ 次就剩 1 个了)。
  • 每一层的工作量:每一层的所有节点合并起来的总长度永远是 $n$,所以每一层的工作量都是 $O(n)$。
  • 结论:$O(n \times \log n)$。

如果你面对的是类似“分治法”的递归(规模按比例缩小),可以直接套用主定理。它的标准形式是:

  • $a$:产生的子问题个数。
  • $n/b$:每个子问题的规模(把原问题缩小的倍数)。
  • $f(n)$:递归之外要做的工作(比如合并结果的花费)。

快速判断口诀:

  1. 如果 $f(n)$ 很大(合并比递归慢):复杂度就是 $O(f(n))$。
  2. 如果 $a$ 很大(分裂出的子问题太多):复杂度就是 $O(n^{\log_b a})$。
  3. 如果两边差不多(比如归并排序):复杂度就是 $O(f(n) \cdot \log n)$。

递归不仅耗时间,还非常耗内存递归的空间复杂度 = 递归调用的最大深度 $\times$ 每次调用产生的辅助空间即使你的递归里没开数组,每次函数调用都会在 系统栈(Stack) 里压入一个“栈帧”(保存局部变量、返回地址等)。

数学相关

公倍数与公因数

最大公因数gcd

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int gcd(int a,int b) {
return b==0?a:gcd(b,a%b);
}
int gcd(int a,int b) {
while(b!=0) {
int tmp = a;
a = b;
b = tmp%b;
}
return a;
}
int lcm(int a, int b) {
return a * b / gcd(a, b);
}

利用辗转相除法,可以很方便地求得两个数的最大公因数(greatest common divisor,GCD);将两个数相乘再除以最大公因数即可得到最小公倍数(least common multiple, LCM)。

也可以通过扩展欧几里得算法(extended gcd)在求得 a 和 b 最大公因数的同时,也得到它们的系数 x 和 y,从而使 ax + by = gcd(a, b)

普通欧几里得算法只能帮你算出 $a$ 和 $b$ 的最大公约数(GCD),而扩展欧几里得算法不仅能算出 GCD,还能帮你找到一对整数 $x, y$,使得它们满足贝祖等式(Bézout’s identity)

推导

普通 GCD 的递归项是 $\text{gcd}(a, b) = \text{gcd}(b, a \pmod b)$。

假设我们已经通过递归,找到了下一层状态 $b$ 和 $a \pmod b$ 的解 $x’$ 和 $y’$,即:

因为 $a \pmod b = a - \lfloor a/b \rfloor \cdot b$,代入上式:

整理一下,把含有 $a$ 和 $b$ 的项分开:

对比最原始的等式 $ax + by = \text{gcd}(a, b)$,我们可以直接得出 $x$ 和 $y$ 的变换规律:

  • $x = y’$
  • $y = x’ - \lfloor a/b \rfloor \cdot y’$
1
2
3
4
5
6
7
8
9
10
long long exgcd(long long a, long long b, long long &x, long long &y) {
if (b == 0) {
x = 1;
y = 0;
return a; // 返回的是 gcd(a, b)
}
long long d = exgcd(b, a % b, y, x); // 这里的传参顺序有玄机,利用 y 接收 x'
y -= (a / b) * x; // 此时 x 已经是 y',y 已经是 x',套用公式进行更新
return d;
}

EXGCD 并不是为了算 GCD 凑热闹的,它的主要应用在以下三个领域:

A. 求解模反元素(Modular Inverse)

如果你需要计算 $(a / b) \pmod m$,由于取模运算不支持直接除法,你需要找到 $b$ 的逆元 $x$,满足 $bx \equiv 1 \pmod m$。

这等价于解方程:$bx + my = 1$。

只有当 $\text{gcd}(b, m) = 1$ 时,逆元才存在。

B. 求解线性同余方程

求解 $ax \equiv c \pmod m$。

这可以转化为 $ax + my = c$。只要 $c$ 是 $\text{gcd}(a, m)$ 的倍数,方程就有解。

C. 中国剩余定理 (CRT)

在处理多个同余方程组时,EXGCD 是合并方程的核心工具。

求模和取余在有负数时表现不同,对于a%b和a mod b

% (取余 - Remainder)左边是老板。结果的符号跟被除数(左边的 $a$)一致。

mod (取模 - Modulo)右边是老板。结果的符号跟除数(右边的 $b$)一致。

数学本质:向 0 靠拢 vs 向负无穷靠拢

之所以符号不同,是因为它们在处理 $a / b$ 的商(quotient)时,取整的方向不同。取余 (%):使用 截断取整(Truncate)。往 $0$ 的方向靠。取模 (mod):使用 地板取整(Floor)。往负无穷的方向靠(向下取整)。

质数

质数又称素数,指的是指在大于 1 的自然数中,除了 1 和它本身以外不再有其他因数的自然数。值得注意的是,每一个数都可以分解成质数的乘积

判断是否是质数

  1. 基础方案:平方根试除法 ($O(\sqrt{n})$)

这是最直观的方法。核心逻辑是:如果 $n$ 有一个大于 $\sqrt{n}$ 的因数,那么它必然有一个对应的因数小于 $\sqrt{n}$。

  • 逻辑:从 $2$ 遍历到 $\sqrt{n}$,只要能整除就不是质数。
  • 注意:$1$ 不是质数,$2$ 是最小的质数。
1
2
3
4
5
6
7
8
bool isPrime(int n) {
for(int i =2;i*i<=n;i++) {
if(n%i == 0) {
return false;
}
}
return true;
}
  1. 进阶优化:$6k \pm 1$ 法(面试推荐)

这是一个非常聪明的优化。除了 $2$ 和 $3$ 之外,所有的质数都可以表示为 $6k-1$ 或 $6k+1$ 的形式。

为什么?

我们将所有整数按 $6$ 的余数分类:

  • $6k, 6k+2, 6k+3, 6k+4$ 分别能被 $6, 2, 3, 2$ 整除,显然不是质数(除非是 $2$ 和 $3$ 本身)。
  • 剩下的只有 $6k+1$ 和 $6k+5$(即 $6k-1$)。
1
2
3
4
5
6
7
8
9
10
11
12
bool isPrime(int n) {
if (n <= 3) return n > 1;
// 排除掉不在 6 附近波动的数
if (n % 2 == 0 || n % 3 == 0) return false;

// 从 5 开始(即 6*1 - 1),步长为 6
// 每次检查 i (6k-1) 和 i+2 (6k+1)
for (int i = 5; i * i <= n; i += 6) {
if (n % i == 0 || n % (i + 2) == 0) return false;
}
return true;
}
方法复杂度优点缺点
暴力试除$O(n)$简单到不需要脑子$n > 10^7$ 基本就废了
平方根优化$O(\sqrt{n})$逻辑清晰,最常用处理 $10^{14}$ 以上的数开始吃力
$6k \pm 1$ 优化$O(\frac{\sqrt{n}}{3})$常数极小,比普通试除快 3 倍需要记住 6 的倍数特性
Miller-Rabin$O(k \log^3 n)$解决天文数字级别的判定实现复杂,需要大数处理

计算 $1$ 到 $n$ 之间的质数个数

根据 $n$ 的范围不同,我们有三种主流的解决方法:

埃氏筛法

埃氏筛的核心思想非常朴素:质数的倍数一定不是质数。

  • 流程
    1. 创建一个长度为 $n+1$ 的布尔数组,初始全部设为“是质数”。
    2. 从 $2$ 开始往后扫描。
    3. 如果当前数 $i$ 是质数,就把它所有的倍数($2i, 3i, \dots$)全部标记为“不是质数”。
    4. 为了优化,可以从 $i \times i$ 开始标记,因为更小的倍数(如 $2i$)在扫描到前面的数字时已经被标记过了。
  • 复杂度:时间 $O(n \log \log n)$,空间 $O(n)$。
  • 评价:在 $n < 10^7$ 时表现非常出色,代码实现极其简单。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
vector<int> sieve(int n) {
vector<bool> isPrime(n + 1, true);
isPrime[0] = isPrime[1] = false;

for (int i = 2; i * i <= n; i++) {
if (isPrime[i]) {
// 从 i*i 开始标记,步长为 i
for (int j = i * i; j <= n; j += i) {
isPrime[j] = false;
}
}
}

vector<int> result;
for (int i = 2; i <= n; i++) {
if (isPrime[i]) result.push_back(i);
}
return result;
}

线性筛法

埃氏筛有一个小缺点:一个合数可能被多次重复标记(比如 $6$ 既会被 $2$ 筛掉,也会被 $3$ 筛掉)。线性筛通过让每个合数只被它的最小质因子筛掉,实现了严格的 $O(n)$ 复杂度。

通过核心逻辑 if (i % p == 0) break;,保证每个合数只被它的最小质因子筛掉一次

  • 核心逻辑

    维护一个质数列表。对于每一个数 $i$,遍历已找到的质数 $p$:

    1. 标记 $i \times p$ 为合数。
    2. 关键停止条件:如果 i % p == 0,立即停止。这保证了 $i \times (\text{下一个质数})$ 会被那个质数更小的因子筛掉,从而避免重复。
1
2
3
4
5
6
7
8
9
10
11
12
13
int countPrimes(int n) {
vector<int> primes;
vector<bool> isPrime(n, true);
for (int i = 2; i < n; ++i) {
if (isPrime[i]) primes.push_back(i);
for (int p : primes) {
if (i * p >= n) break;
isPrime[i * p] = false;
if (i % p == 0) break; // 核心:避免重复筛选
}
}
return primes.size();
}

分块筛或 Meissel-Lehmer 算法

如果遇到的 $n$ 达到了 $10^{10}$ 甚至更高,内存开不下 $O(n)$ 的数组,线性筛就失效了。

分块筛:将 $1$ 到 $n$ 分成若干个小块,每块大小约 $\sqrt{n}$,逐块统计,空间复杂度降至 $O(\sqrt{n})$。

Meissel-Lehmer 算法:这是一种基于数论组合公式的方法,不需要遍历所有数,就能直接计算出质数个数,复杂度约为 $O(n^{2/3})$。

1175.请你帮忙给从 1n 的数设计排列方案,使得所有的「质数」都应该被放在「质数索引」(索引从 1 开始)上;你需要返回可能的方案总数。

让我们一起来回顾一下「质数」:质数一定是大于 1 的,并且不能用两个小于它的正整数的乘积来表示。

由于答案可能会很大,所以请你返回答案 模 mod 10^9 + 7 之后的结果即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
class Solution {
public:
// long factorial(int n) {
// long res = 1;
// for (int i = 1; i <= n; i++) {
// res *= i;
// res %= MOD;
// }
// return res;
// }

long long factorial(int n) {
if(n==0){
return 1;
}
if (n == 1) {
return 1;
}
return (n * factorial(n - 1)) % (MOD);
}
const int MOD = 1e9 + 7;
int calcPrimesCount(int n) {
vector<bool> isPrimes(1 + n, true);
int cnt = n - 1;
for (int i = 2; i <= n; i++) {
if (isPrimes[i]) {
for (int j = i * i; j <= n; j += i) {
if (isPrimes[j]) {
isPrimes[j] = false;
cnt--;
}
}
}
}
return cnt;
}
// 线性筛
int LinearcalcPrimesCount(int n) {
vector<bool> isPrimes(1 + n, true);
vector<int> primes;
for (int i = 2; i <= n; i++) {
if (isPrimes[i]) {
primes.push_back(i);
}
for (int& t : primes) {
int num = t * i;
if (num > n) {
break;
}
isPrimes[num] = false;
// 排除
if (i % t == 0) {
break;
}
}
}
return primes.size();
}
int numPrimeArrangements(int n) {
// 计算1-n的质数个数
// 结果m!(n-m)!
int m = calcPrimesCount(n);
// 2 3
return (int) (factorial(m) * factorial(n - m) % MOD);
}
};

模运算有一个非常重要的性质:

这意味着,如果你要计算一连串数字的乘积并取模,你可以在中间任何一步取模,结果都不会改变。

运算类型规则是否等价
加法$(a + b) \% m = (a\%m + b\%m) \% m$
减法$(a - b) \% m = (a\%m - b\%m + m) \% m$(需加 $m$ 防止负数)
乘法$(a \times b) \% m = (a\%m \times b\%m) \% m$
除法$(a / b) \% m$否!(需使用逆元

如果需要永远返回正数的取模结果(例如在处理循环数组下标时),可以使用这个通用的“数学模”技巧:

一个数的因数与一个数的质因数

求解一个数的所有因数

如果 $n \pmod i == 0$,那么 $i$ 就是 $n$ 的因数。 核心思路: 利用对称性。如果 $i$ 是 $n$ 的因数,那么 $n/i$ 必然也是 $n$ 的因数。因此,我们只需要遍历到 $\sqrt{n}$ 即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
vector<int> getDivisors(int n) {
vector<int> res;
for (int i = 1; i * i <= n; i++) {
if (n % i == 0) {
res.push_back(i); // 较小的因数
if (i * i != n) {
res.push_back(n / i); // 对称的较大因数(避免完全平方数重复计算)
}
}
}
sort(res.begin(), res.end()); // 如果需要有序
return res;
}

求解一个数的质因数分解

将一个合数表示成若干个质数相乘的形式。 核心思路: 试除法。从最小的质数 $2$ 开始尝试,只要能整除,就一直除下去,直到除不动为止,然后再试下一个数。

1
2
3
4
5
6
7
8
9
10
11
vector<int> getPrimeFactors(int n) {
vector<int> res;
for (int i = 2; i * i <= n; i++) {
while (n % i == 0) {
res.push_back(i);
n /= i; // 关键:除掉已经找到的质因子
}
}
if (n > 1) res.push_back(n); // 如果最后剩下的数大于1,说明它是最后一个质因子
return res;
}

不同的质因数分解

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
vector<int> getDistinctPrimeFactors(long long n) {
vector<int> res;
for (long long i = 2; i * i <= n; i++) {
if (n % i == 0) {
res.push_back(i); // 记录这个不同的质因数
while (n % i == 0) {
n /= i; // 关键:彻底除尽,把所有的 i 都从 n 中剥离
}
}
}
// 如果最后 n > 1,剩下的 n 本身就是一个质数
if (n > 1) {
res.push_back(n);
}
return res;
}

2521给你一个正整数数组 nums ,对 nums 所有元素求积之后,找出并返回乘积中 不同质因数 的数目。

注意:

  • 质数 是指大于 1 且仅能被 1 及自身整除的数字。
  • 如果 val2 / val1 是一个整数,则整数 val1 是另一个整数 val2 的一个因数。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
int distinctPrimeFactors(vector<int>& nums) {
// 求解数组中每个数的不同质因数
unordered_set<int> uset;
for (auto n : nums) {
// 计算每个n的不同质因数
for (int i = 2; i * i <= n; i++) {
if (n % i == 0) {
uset.insert(i);
while (n % i == 0) {
n /= i;
}
}
}
if (n > 1) {
uset.insert(n);
}
}
return uset.size();
}
};

$1$ 到 $N$ 范围内所有数的质因数

利用线性筛(欧拉筛)进行预处理,通过维护一个最小质因子数组 (Minimum Prime Factor, MPF),将每个数的分解过程优化到极速。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const int MAXN = 1000005;
int min_prime[MAXN]; // 记录每个数的最小质因子
vector<int> primes;

void sieve(int n) {
for (int i = 2; i <= n; i++) {
if (min_prime[i] == 0) { // i 是质数
min_prime[i] = i; // 质数的最小质因子是它自己
primes.push_back(i);
}
for (int p : primes) {
if (p > min_prime[i] || i * p > n) break;
min_prime[i * p] = p; // 记录合数的最小质因子
if (i % p == 0) break;
}
}
}
vector<int> factorize(int x) {
vector<int> factors;
while (x > 1) {
factors.push_back(min_prime[x]); // 拿到当前最小质因子
x /= min_prime[x]; // 直接除掉它
}
return factors;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const int MAXN = 1e6 + 5;
int min_p[MAXN]; // 存储最小质因子
int f[MAXN]; // 存储质因子相关的递推属性(如质因子总数)
vector<int> primes;

void linear_sieve(int n) {
f[1] = 0; // 1 没有质因子
for (int i = 2; i <= n; i++) {
if (min_p[i] == 0) { // i 是质数
min_p[i] = i;
primes.push_back(i);
f[i] = 1; // 质数本身只有 1 个质因子
}
for (int p : primes) {
if (p > min_p[i] || i * p > n) break;

int target = i * p;
min_p[target] = p; // p 一定是 target 的最小质因子

// --- 核心递推逻辑 ---
if (i % p == 0) {
// p 已经是 i 的质因子
f[target] = f[i] + 1; // 示例:总质因子数递增
break;
} else {
// p 是一个全新的、更小的质因子
f[target] = f[i] + 1;
}
}
}
}

$1$ 到 $N$ 范围内所有数的不同的质因数个数

如果只需要知道每个数有多少个不同的质因子,线性筛还可以在筛的过程中递推:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
int distinct_count[MAXN];
void count_sieve(int n) {
for (int i = 2; i <= n; i++) {
if (min_prime[i] == 0) { // 质数
min_prime[i] = i;
distinct_count[i] = 1;
primes.push_back(i);
}
for (int p : primes) {
if (i * p > n) break;
min_prime[i * p] = p;
if (i % p == 0) {
// p 是 i 的因子,i*p 和 i 的不同质因子种类一样
distinct_count[i * p] = distinct_count[i];
break;
} else {
// p 不是 i 的因子,i*p 比 i 多了一个质因子 p
distinct_count[i * p] = distinct_count[i] + 1;
}
}
}
}

1-n中所有数的因数个数

最容易理解和编写的方法。我们不关注某个数有哪些因数,而是关注每个数作为因数贡献了多少次

逻辑:

  1. 准备一个数组 count[n+1],全部初始化为 0。
  2. 遍历 $i$ 从 $1$ 到 $n$(作为可能的因数)。
  3. 对于每个 $i$,找到它在 $n$ 范围内的所有倍数 $j = i, 2i, 3i, \dots$,并将 count[j] 加 1。
1
2
3
4
5
6
7
8
9
vector<int> countAllDivisors(int n) {
vector<int> count(n + 1, 0);
for (int i = 1; i <= n; i++) {
for (int j = i; j <= n; j += i) {
count[j]++; // i 是 j 的一个因数
}
}
return count;
}

如果追求极致的 $O(n)$ 性能,可以利用约数个数定理和线性筛。

约数个数定理

如何利用质因数分解的结果,一秒求出某个数的所有因数个数

约数个数定理:

如果 $i$ 的质因数分解为 $p_1^{a_1} \cdot p_2^{a_2} \cdots p_k^{a_k}$,那么其因数个数为:

假设一个数 $n$ 的质因数分解结果为:

(其中 $p$ 是质因数,$a$ 是该质因数的指数)

那么 $n$ 的所有正因数个数为:

如果你需要批量处理,可以利用之前提到的 min_prime(最小质因子)数组,配合递推公式在 $O(N)$ 时间内完成。

算法逻辑:

我们维护两个数组:

  1. d[i]:数字 $i$ 的因数个数。
  2. num[i]:数字 $i$ 的最小质因子的幂次(即上面的 $a_1$)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
const int MAXN = 1000005;
int d[MAXN], num[MAXN], primes[MAXN], cnt;
bool is_prime[MAXN];

void get_divisors(int n) {
d[1] = 1; // 1 只有一个因数
for (int i = 2; i <= n; i++) {
if (!is_prime[i]) {
primes[++cnt] = i;
d[i] = 2; // 质数只有 1 和自己,共 2 个因数
num[i] = 1; // 最小质因子 i 的幂次是 1
}
for (int j = 1; j <= cnt && i * primes[j] <= n; j++) {
is_prime[i * primes[j]] = true;
if (i % primes[j] == 0) {
// 如果 primes[j] 是 i 的最小质因子
num[i * primes[j]] = num[i] + 1;
// 利用公式:原本是 (num[i]+1),现在变成 (num[i]+2)
d[i * primes[j]] = d[i] / (num[i] + 1) * (num[i * primes[j]] + 1);
break;
} else {
// 如果 primes[j] 是比 i 的最小质因子还要小的质数
num[i * primes[j]] = 1;
// 新质因子的指数是 1,所以因数个数直接乘以 (1+1)=2
d[i * primes[j]] = d[i] * 2;
}
}
}
}

核心:

1
2
3
4
5
6
7
8
9
10
11
// i: 当前处理的数, p: 当前遍历到的质数
if (i % p == 0) {
// 情况 B:p 已经是 i 的最小质子
e[i * p] = e[i] + 1; // 指数加1
d[i * p] = d[i] / (e[i] + 1) * (e[i * p] + 1); // 更新总个数
break; // 线性筛核心:找到最小质因子就停止
} else {
// 情况 A:p 是全新的最小质子
e[i * p] = 1; // 新质子的指数就是1
d[i * p] = d[i] * 2; // 总个数直接翻倍 (因为 (1+1)=2 )
}

因数之和

假设一个数 $n$ 的质因数分解结果为:

那么 $n$ 的所有正约数之和为:

要在 $O(N)$ 时间内递推,逻辑和之前求个数的方法(维护最小质因子指数)极其相似,但这次我们需要维护:

  • sigma[i]:$i$ 的约数之和。
  • g[i]:$i$ 的最小质因子贡献的那一部分和(即公式中第一个括号的值:$1 + p_1 + \dots + p_1^{a_1}$)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
const int MAXN = 1000005;
long long sigma[MAXN], g[MAXN]; // sigma 存总和,g 存最小质因子部分的等比数列和
int primes[MAXN], cnt;
bool not_prime[MAXN];

void get_sigma(int n) {
sigma[1] = 1; // 1 的约数之和就是 1
for (int i = 2; i <= n; i++) {
if (!not_prime[i]) {
primes[++cnt] = i;
sigma[i] = i + 1; // 质数 p 的约数之和是 1 + p
g[i] = i + 1; // 最小质因子部分也是 1 + p
}
for (int j = 1; j <= cnt && i * primes[j] <= n; j++) {
not_prime[i * primes[j]] = true;
if (i % primes[j] == 0) {
// 情况 A:primes[j] 是 i 的最小质因子
// 新的 g = 原有的 g 乘上 p 再加 1 (例如:从 1+2+4 变成 1+2+4+8)
g[i * primes[j]] = g[i] * primes[j] + 1;
// 更新总和:先除掉旧的最小质因子部分,再乘上新的
sigma[i * primes[j]] = sigma[i] / g[i] * g[i * primes[j]];
break;
} else {
// 情况 B:primes[j] 是一个新的、更小的质因子
g[i * primes[j]] = primes[j] + 1;
// 直接乘上新质因子的贡献 (1 + p)
sigma[i * primes[j]] = sigma[i] * (primes[j] + 1);
}
}
}
}
属性约数个数 d(n)约数之和 σ(n)
基础单位指数 $(a_i + 1)$等比数列和 $(1 + p_i + \dots + p_i^{a_i})$
质数 $p$ 的值$2$$p + 1$
递推核心维护最小质因子的指数维护最小质因子的等比数列和

常见质因数属性的递推公式

利用上面的模板,你可以在 $O(N)$ 内一次性求出以下所有属性:

A. 质因子总数(包含重复)

  • 含义:$12 = 2 \times 2 \times 3 \to f(12) = 3$。
  • 递推:$f(i \cdot p) = f(i) + 1$。

B. 不同质因子的个数

  • 含义:$12 = 2^2 \times 3 \to f(12) = 2$。
  • 递推
    • 如果 i % p == 0:$f(i \cdot p) = f(i)$($p$ 已经出现过了)。
    • 如果 i % p != 0:$f(i \cdot p) = f(i) + 1$($p$ 是新面孔)。

C. 最小质因子的幂次(指数)

  • 含义:$12 = 2^2 \times 3 \to f(12) = 2$。
  • 递推
    • 如果 i % p == 0:$f(i \cdot p) = f(i) + 1$。
    • 如果 i % p != 0:$f(i \cdot p) = 1$。

四因数

给你一个整数数组 nums,请你返回该数组中恰有四个因数的这些整数的各因数之和。如果数组中不存在满足题意的整数,则返回 0

暴力法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution {
public:
int sumFourDivisors(vector<int>& nums) {
int totalSum = 0;
for (int n : nums) {
int count = 0;
int currentSum = 0;
for (int i = 1; i * i <= n; ++i) {
if (n % i == 0) {
count++;
currentSum += i;
if (i * i != n) { // 避免平方数重复计算
count++;
currentSum += n / i;
}
}
if (count > 4) break; // 剪枝优化
}
if (count == 4) {
totalSum += currentSum;
}
}
return totalSum;
}
};

欧拉筛

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class Solution {
public:
int sumFourDivisors(vector<int>& nums) {
const int N = 100000;
vector<int> primes;
vector<bool> isNotPrime(N + 1, false);
vector<int> d(N + 1, 0); // 因数个数
vector<long long> sigma(N + 1, 0); // 因数之和
vector<int> a(N + 1, 0); // 最小质因子的指数
vector<long long> g(N + 1, 0); // 最小质因子的等比数列和

d[1] = 1; sigma[1] = 1;
for (int i = 2; i <= N; ++i) {
if (!isNotPrime[i]) {
primes.push_back(i);
d[i] = 2;
sigma[i] = i + 1;
a[i] = 1;
g[i] = i + 1;
}
for (int p : primes) {
if (i * p > N) break;
isNotPrime[i * p] = true;
if (i % p == 0) {
a[i * p] = a[i] + 1;
d[i * p] = d[i] / (a[i] + 1) * (a[i * p] + 1);
g[i * p] = g[i] * p + 1;
sigma[i * p] = sigma[i] / g[i] * g[i * p];
break;
} else {
a[i * p] = 1;
d[i * p] = d[i] * 2;
g[i * p] = p + 1;
sigma[i * p] = sigma[i] * (p + 1);
}
}
}

int ans = 0;
for (int n : nums) {
if (d[n] == 4) ans += sigma[n];
}
return ans;
}
};

进制转换

十进制转七进制

给定一个整数 num,将其转化为 7 进制,并以字符串形式输出。

进制转换类型的题,通常是利用除法和取模(mod)来进行计算,同时也要注意一些细节,如负数和零。如果输出是数字类型而非字符串,则也需要考虑是否会超出整数上下界。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
string convertToBase7(int num) {
if (num == 0) {
return "0";
}
string base7;
bool is_negative = num < 0;
num = abs(num);
while (num) {
int quotient = num / 7, remainder = num % 7;
base7 = to_string(remainder) + base7;
num = quotient;
}
return is_negative ? "-" + base7 : base7;
}

给定一个非负整数,判断它的阶乘结果的结尾有几个 0。

每个尾部的 0 由 2 × 5 =10 而来,因此我们可以把阶乘的每一个元素拆成质数相乘,统计有多少个 2 和 5。明显的,质因子 2 的数量远多于质因子 5 的数量,因此我们可以只统计阶乘结果里有多少个质因子 5。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int trailingZeroes(int n) {
// 尾随0的个数就是质因数5的个数
// 计算5-n中质因数为5的个数
int cnt{};
for (int i = 5; i <= n; i++) {
int tmp = i;
while (tmp % 5 == 0) {
cnt++;
tmp /= 5;
}
}
return cnt;
}
};

字符串相加。给定两个字符串形式的非负整数 num1num2 ,计算它们的和并同样以字符串形式返回。

你不能使用任何內建的用于处理大整数的库(比如 BigInteger), 也不能直接将输入的字符串转换为整数形式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
string addStrings(string num1, string num2) {
string res;
int sz1 = num1.size();
int sz2 = num2.size();
int i = sz1 - 1, j = sz2 - 1;
int car{};
while (i >= 0 || j >= 0) {
int r = car;
r += (i >= 0) ? (num1[i--] - '0') : 0;
r += (j >= 0) ? (num2[j--] - '0') : 0;
res = to_string(r % 10) + res;
car = r / 10;
}
if (car > 0) {
res = "1" + res;
}
return res;
}
};

实现 pow(x, n) ,即计算 x 的整数 n 次幂函数(即,xn )。

利用递归,可以较为轻松地解决本题。注意边界条件的处理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
double myPow(double x, int n) {
if (n == 0) {
return 1;
}
if (x == 0) {
return 0;
}
if (n == numeric_limits<int>::min()) {
return 1 / (x * myPow(x, numeric_limits<int>::max()));
}
if (n < 0) {
return 1 / myPow(x, -n);
}
if (n % 2 != 0) {
return x * myPow(x, n - 1);
}
double myPowSqrt = myPow(x, n >> 1);
return myPowSqrt * myPowSqrt;
}

随机取样

给定一个数组,要求实现两个指令函数。第一个函数“shuffle”可以随机打乱这个数组,第二个函数“reset”可以恢复原来的顺序。

采用经典的 Fisher-Yates 洗牌算法,原理是通过随机交换位置来实现随机打乱,有正向和反向两种写法,且实现非常方便。注意这里“reset”函数以及 Solution 类的构造函数的实现细节。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
vector<int> nums;
vector<int> onums;
Solution(vector<int>& nums) :nums(nums), onums(nums) {}

vector<int> reset() { return onums; }

vector<int> shuffle() {
// 反向
for (int i = nums.size() - 1; i >= 0; i--) {
swap(nums[i], nums[rand() % (i + 1)]);
}
return nums;
}
};

洗牌算法

对于数组nums,其长度为n。我们调用shuffle()方法返回的数组,应该有n!种可能,我们就可以说shuffle()方法返回的数组是随机的。

那最简单直白的做法就是,将数组numsn!种排列组合都提前生成出来,每调用一次shuffle(),就取一种nums的排列组合出来返回。

这时候就轮到我们的洗牌算法出场了。不需要提前把n!种排列组合都生成好。

洗牌算法的思路很简单。我们有个长度为n的数组nums,对于每个nums[i]来说,都生成一个[i,n−1]范围的随机数,作为random_idx,然后交换nums[i]nums[random_idx

为什么说洗牌算法实现的shuffle()返回的数组会有n!种可能呢?

  • 对于nums[0],它可能会和[0,n−1]范围内的任何一个数交换,有n种可能。
  • 对于nums[1],它可能会和[1,n−1]范围内的任何一个数交换,有n−1种可能。
  • 对于nums[n-1],它只能和nums[n-1]自己交换,只有1种可能。

所以总的可能性是: n+(n−1)+(n−2)+…+1=n!

按权重随机选择

给你一个 下标从 0 开始 的正整数数组 w ,其中 w[i] 代表第 i 个下标的权重。

请你实现一个函数 pickIndex ,它可以 随机地 从范围 [0, w.length - 1] 内(含 0w.length - 1)选出并返回一个下标。选取下标 i概率w[i] / sum(w)

  • 例如,对于 w = [1, 3],挑选下标 0 的概率为 1 / (1 + 3) = 0.25 (即,25%),而选取下标 1 的概率为 3 / (1 + 3) = 0.75(即,75%)。

    前缀和+二分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class Solution {
public:
vector<int>& w;
vector<int> prefix_sum;

Solution(vector<int>& w) : w(w), prefix_sum(w.size()) {
// 2 3 5
prefix_sum[0] = w[0];
for (int i = 1; i < prefix_sum.size(); i++) {
prefix_sum[i] = prefix_sum[i - 1] + w[i];
}
}

int lower_bound(vector<int>& nums, int val) {
// 求第一个大于等于val的索引
int left = 0, right = nums.size() - 1;
int res{};
while (left <= right) {
int mid = (right - left) / 2 + left;
if (nums[mid] >= val) {
res = mid;
right = mid - 1;
} else {
left = mid + 1;
}
}
return res;
}

int pickIndex() {
// 计算前缀和数组
int randomVal = (rand() % (prefix_sum.back())) + 1;
// 判断生成的randomIdx在哪个区间 lower_bound
int res_idx = lower_bound(prefix_sum, randomVal);
return res_idx;
}
};

/**
* Your Solution object will be instantiated and called as such:
* Solution* obj = new Solution(w);
* int param_1 = obj->pickIndex();
*/

image-20260127200828547

不用加号的加法

设计一个函数把两个数字相加。不得使用 + 或者其他算术运算符。

考虑两个二进制位相加的四种情况如下:

0 + 0 = 0
0 + 1 = 1
1 + 0 = 1
1 + 1 = 0 (进位)
可以发现,对于整数 a 和 b:

在不考虑进位的情况下,其无进位加法结果为 a⊕b。

而所有需要进位的位为 a & b,进位后的进位结果为 (a & b) << 1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int add(int a, int b) {
// 0 0 0
// 0 1 0
// 1 1 1
// 不考虑进位 ,加法结果 s = a^b;
// 进位如何计算 c = (a&b)<<1;
while (b != 0) {
unsigned int car = (a & b) << 1;
a = a ^ b;
b = car;
}
return a;
}
};

两数相除

给你两个整数,被除数 dividend 和除数 divisor。将两数相除,要求 不使用 乘法、除法和取余运算。

整数除法应该向零截断,也就是截去(truncate)其小数部分。例如,8.345 将被截断为 8-2.7335 将被截断至 -2

返回被除数 dividend 除以除数 divisor 得到的

注意:假设我们的环境只能存储 32 位 有符号整数,其数值范围是 [−231, 231 − 1] 。本题中,如果商 严格大于 231 − 1 ,则返回 231 − 1 ;如果商 严格小于 -231 ,则返回 -231

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public:
int divide(int dividend, int divisor) {
// 1. 处理最特殊的溢出情况
if (dividend == INT_MIN && divisor == -1)
return INT_MAX;
if (dividend == INT_MIN && divisor == 1)
return INT_MIN;
// 2. 确定最终符号
bool negative = (dividend > 0) ^ (divisor > 0);

// 3. 全部转为负数处理,防止绝对值溢出
int a = dividend > 0 ? -dividend : dividend;
int b = divisor > 0 ? -divisor : divisor;

int res = 0;
// 4. 核心逻辑:利用位移寻找最大的倍数
while (a <= b) {
int value = b;
int k = 1;
// 这里的判断是为了防止 value << 1 溢出
// 注意 a 和 value 都是负数,所以是 >=
while (value >= (INT_MIN >> 1) && a <= (value << 1)) {
value <<= 1;
k <<= 1;
}
a -= value;
res += k;
}
return negative ? -res : res;
}
};
1
while (value >= (INT_MIN >> 1) && a <= (value << 1))

这行代码有两个判断条件:

  1. value >= (INT_MIN >> 1)
    • 这是安全检查
    • 因为接下来我们要执行 value << 1(即乘以 $2$)。如果 value 已经比 INT_MIN 的一半还要小了,再乘 $2$ 就会溢出。
  2. a <= (value << 1)
    • 这是空间检查
    • 翻译成白话:“如果我把现在除数再翻一倍,被除数 $a$ 还够不够减?”
    • 如果够减,就执行 value <<= 1(价值翻倍)和 k <<= 1(数量翻倍)。

字符串相乘

给定两个以字符串形式表示的非负整数 num1num2,返回 num1num2 的乘积,它们的乘积也表示为字符串形式。

注意:不能使用任何内置的 BigInteger 库或直接将输入转换为整数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public:
string multiply(string num1, string num2) {
//
if (num1 == "0" || num2 == "0") {
return "0";
}
// 3 4
int n1 = num1.size();
int n2 = num2.size();
// 12
// 19
// 0018
vector<int> ans(n1 + n2);
for (int i = n1 - 1; i >= 0; i--) {
for (int j = n2 - 1; j >= 0; j--) {
// 相乘+低位
int mul = (num1[i] - '0') * (num2[j] - '0') + ans[i + j + 1];
ans[i + j + 1] = mul % 10;
ans[i + j] += mul / 10;
}
}
string res;
for (int i = 0; i < ans.size(); i++) {
if (res.empty() && ans[i] == 0) {
continue;
}
res += to_string(ans[i]);
}
return res;
}
};
1
2
3
int sum = mul + res[i + j + 1]; // 1. 把当前的乘积加上这一位原有的数(包含之前的进位)
res[i + j + 1] = sum % 10; // 2. 确定这一位的最终数字(0-9)
res[i + j] += sum / 10; // 3. 把多出来的进位“送”给左边一位

这种写法的精妙之处在于:它把复杂的进位处理变成了“原地滚雪球”。 你不需要写 while 循环去处理连续进位(比如 $999 + 1$),因为外层的 i, j 循环在向左移动时,会自动处理掉之前留在 res[i+j] 里的进位。

x的平方根

给你一个非负整数 x ,计算并返回 x算术平方根

由于返回类型是整数,结果只保留 整数部分 ,小数部分将被 舍去 。

注意:不允许使用任何内置指数函数和算符,例如 pow(x, 0.5) 或者 x ** 0.5

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int mySqrt(int x) {
// 二分法
int left = 1, right = x;
int ans{};
while (left <= right) {
int mid = (right - left) / 2 + left;
if (mid <= x/mid) {
ans = mid;
left = mid + 1;
} else {
right = mid - 1;
}
}
return ans;
}
};

此外也可以用牛顿迭代法

计数质数

给定整数 n ,返回 所有小于非负整数 n 的质数的数量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public:
int countPrimes(int n) {
if (n <= 1) {
return 0;
}
int cnt = n - 2;
// 埃氏筛
// 质数的倍数不是质数
vector<bool> isPrime(n, true);
for (int i = 2; i < n; i++) {
if (isPrime[i]) {
for (long long j = (long long)i * i; j < n; j += i) {
if (isPrime[j]) {
cnt--;
isPrime[j] = false;
}
}
}
}
return cnt;
}
};

丑数

丑数 就是只包含质因数 235 整数。

给你一个整数 n ,请你判断 n 是否为 丑数 。如果是,返回 true ;否则,返回 false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
bool isUgly(int n) {
if (n <= 0) {
return false;
}
// 计算一个数的所有质因数
// 是否包含非2,3,5的正整数
vector<int> factor = {2,3,5};
for(auto& f:factor) {
while(n%f == 0) {
n/=f;
}
}
return n==1;
}
};
  • 质数:质数概念,质数筛选法
  • 实现加减乘除、平方、次方以及开根号。
  • 矩阵运算,矩阵基本性质,矩阵旋转。
  • 最大公约数。
  • 排列组合。

位运算

位运算是算法题里比较特殊的一种类型,它们利用二进制位运算的特性进行一些奇妙的优化和计算。常用的位运算符号包括:

  • :按位异或
  • &:按位与
  • |:按位或
  • ~:取反
  • <<:算术左移
  • >>:算术右移

以下是一些常见的位运算特性,其中 0s1s 分别表示只由 01 构成的二进制数字。

1
2
3
x ^ 0s = x      x & 0s = 0     x | 0s = x
x ^ 1s = ~x x & 1s = x x | 1s = 1s
x ^ x = 0 x & x = x x | x = x

除此之外,n & (n - 1) 可以去除 n 的位级表示中最低的那一位,例如对于二进制表示 11110100,减去 1 得到 11110011,这两个数按位与得到 11110000。n & (-n) 可以得到 n 的位级表示中最低的那一位,例如对于二进制表示 11110100,取负得到 00001100,这两个数按位与得到 00000100。x - x&(-x) = x&(x-1)

二进制特性

给定多个字母串,求其中任意两个字母串的长度乘积的最大值,且这两个字母串不能含有相同字母。

怎样快速判断两个字母串是否含有重复数字呢?可以为每个字母串建立一个长度为 26 的二进制数字,每个位置表示是否存在该字母。如果两个字母串含有重复数字,那它们的二进制表示的按位与不为 0。同时,我们可以建立一个哈希表来存储二进制数字到最长子母串长度的映射关系

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
int maxProduct(vector<string>& words) {
unordered_map<int, int> cache;
int max_prod = 0;
for (const string& word : words) {
int mask = 0, w_len = word.length();
for (char c : word) {
mask |= 1 << (c - ’a’);
}
cache[mask] = max(cache[mask], w_len);
for (auto [h_mask, h_len] : cache) {
if ((mask & h_mask) == 0) {
max_prod = max(max_prod, w_len * h_len);
}
}
}
return max_prod;
}

给定一个非负整数 n,求从 0 到 n 的所有数字的二进制表达中,分别有多少个 1。

可以利用动态规划和位运算进行快速的求解。定义一个数组 dp,其中 dp[i] 表示数字 i 的二进制含有 1 的个数。对于第 i 个数字,如果它二进制的最后一位为 1,那么它含有 1 的个数则为 dp[i-1] + 1;如果它二进制的最后一位为 0,那么它含有 1 的个数和其算术右移结果相同,即 dp[i>>1]。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
vector<int> countBits(int n) {
// 一次遍历 状态递推
vector<int> res(n + 1);
// 如果i是奇数 res[i] = res[i-1]+1
// 偶数 res[i] = res[i/2];
for (int i = 1; i <= n; i++) {
if (i & 1) {
res[i] = res[i - 1] + 1;
} else {
res[i] = res[i / 2];
}
}
return res;
}
};

回文

验证回文串

给你一个字符串 s最多 可以从中删除一个字符。

请你判断 s 是否能成为回文字符串:如果能,返回 true ;否则,返回 false

考虑“最多删除一个字符,然后判断其能否成为回文字符串”。对上述回文字符串算法稍加改造,然后加上一些额外的逻辑来解决本题。我们仍然采用头/尾双指针的方法,并且更新指针的逻辑和上面也是一样的,不同之处如下。1.如果头/尾指针对应的字符相同,那么没有必要删除任何字符。2.如果头/尾指针对应的字符不同,那么必须删除一个字符才可能使之回文,并且由于只能删除一次,接下来只需要判断剩下的字符串是否能够构成回文即可。具体算法如下。

1.建立头/尾双指针l和r,分别指向字符串的第一个元素和最后一个元素。

2.如果l和r没有交会,则比较两个指针对应的字符。● 如果两个字符相同,则更新双指针,即l+=1,r-=1,重复执行步骤。● 如果两个字符不同,考虑删除左指针对应的字符或删除右指针对应的字符,并观察删除之后是否可以构成回文字符串。如果可以,则直接返回True;如果不可以,则直接返回False。

3.表示该字符串不需要删除字符就已经是回文字符串,直接返回True。

判断回文链表

判断回文数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class Solution {
public:
bool isPalindrome(int x) {
if (x < 0 || (x != 0 && x % 10 == 0)) {
return false;
}
int reverse_num{};
while (x > reverse_num) {
reverse_num = reverse_num * 10 + x % 10;
x /= 10;
}
// 字长度为奇数
return reverse_num == x || x == reverse_num / 10;
}
};

最长回文串

给你一个字符串 s,找到 s 中最长的 回文 子串。

动态规划或中心扩展

最长回文子序列

动态规划

dp[i][j] 表示字符串 s 的下标范围 [i,j] 内的最长回文子序列的长度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Solution {
public:
int longestPalindromeSubseq(string s) {
// 动态规划
int sz = s.size();
vector<vector<int>> dp(
sz,
vector<int>(sz)); // 以s[i-j]的子串中最长回文子序列
// dp[i][j] = dp[i+1][j-1]+2, s[i] == s[j]
// max(dp[i+1][j],dp[i][j-1])
// 由于状态转移方程都是从长度较短的子序列向长度较长的子序列转移,因此需要注意动态规划的循环顺序
for (int i = sz - 1; i >= 0; i--) {
for (int j = i; j < sz; j++) {
if (i == j) {
dp[i][j] = 1;
} else {
if (s[i] == s[j]) {
dp[i][j] = dp[i + 1][j - 1] + 2;
} else {
dp[i][j] = max(dp[i + 1][j], dp[i][j - 1]);
}
}
}
}
return dp[0][sz - 1];
}
};

滚动数组优化,由于dp[i][j]仅依赖于dp[i+1][j]和dp[i][j-1],使用滚动数组优化

核心逻辑是:既然计算当前状态只依赖于前一个(或前几个)状态,那我们就没必要把整张 DP 大表都存在内存里。

在标准的 DP 中,我们通常会开一个很大的数组(比如 dp[n][m])来记录每一个子问题的解。但很多时候,你在计算第 $i$ 行时,只会用到第 $i-1$ 行的数据,而第 $i-2, i-3$ 行的数据就变成了“过时信息”

两种常见的演进方式

第一种:模运算切换(双行滚动)

这种方式最直观。如果你发现 $dp[i]$ 只依赖于 $dp[i-1]$,你可以只开两个数组:dp[0]dp[1]

  • 第 0 次:计算结果存入 dp[0]
  • 第 1 次:根据 dp[0] 计算结果,存入 dp[1]
  • 第 2 次:根据 dp[1] 计算结果,存入 dp[0](覆盖掉没用的旧数据)

代码技巧:

使用 i % 2 或者 i & 1 来切换下标。

1
2
3
// 优化前:dp[i] = dp[i-1] + dp[i-2]
// 优化后:
dp[i % 2] = dp[(i - 1) % 2] + dp[(i - 2) % 2];

第二种:单行覆盖(最极致的优化)

如果你能巧妙地安排计算顺序,甚至连两行都不需要,只需要一个一维数组

最经典的例子是 0-1 背包问题

  • 原本dp[i][j] 表示前 $i$ 个物品在容量为 $j$ 时的最大价值。
  • 优化后dp[j]
  • 关键点:为了防止在计算当前行时使用了“已经被更新过的当前行数据”(即重复放入物品),我们需要倒序遍历容量 $j$。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public:
int longestPalindromeSubseq(string s) {
// 动态规划
int sz = s.size();
vector<int> dp(sz); // 以s[i-j]的子串中最长回文子序列
// dp[i][j] = dp[i+1][j-1]+2, s[i] == s[j]
// max(dp[i+1][j],dp[i][j-1])
vector<int> tmp(sz);
for (int i = sz - 1; i >= 0; i--) {
for (int j = i; j < sz; j++) {
if (i == j) {
dp[j] = 1;
} else {
if (s[i] == s[j]) {
dp[j] = tmp[j - 1] + 2;
} else {
dp[j] = max(tmp[j], dp[j - 1]);
}
}
}
tmp = dp;
}
return dp[sz - 1];
}
};

超级回文数

如果一个正整数自身是回文数,而且它也是一个回文数的平方,那么我们称这个数为 超级回文数

现在,给你两个以字符串形式表示的正整数 left 和 right ,统计并返回区间 [left, right] 中的 超级回文数 的数目。

直接在 $10^{18}$ 的区间里找回文数无异于大海捞针,但构造 $10^9$ 以内的回文数非常快。

一个回文数 $R$ 可以由它的“前半部分”决定:

  • 如果 $R$ 的长度为 $L$,我们只需要枚举前 $\lceil L/2 \rceil$ 位数字。
  • 例如,前缀 123 可以构造出:
    • 奇数长度回文:12321
    • 偶数长度回文:123321

由于 $R \le 10^9$,它的前缀最大只需要到 $10^{4.5} \approx 31622$。实际上,我们只需要从 $1$ 枚举到 $10^5$ 左右,就能构造出所有的回文根 $R$。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class Solution {
public:
bool check(string& x, long long left, long long right) {
if (x.size() >= 10) {
return false;
}
long long num = stoll(x);
long long r = num * num;
if (r > right) {
return false;
}

// 验证根号值在范围内并且平方为回文
string s = to_string(r);
if (r >= left && validPalindrome(s)) {
return true;
}
return false;
}
bool validPalindrome(string& s) {
int l = 0, r = s.size() - 1;
while (l < r) {
if (s[l] != s[r]) {
return false;
}
l++;
r--;
}
return true;
}
int superpalindromesInRange(string left, string right) {
const int MAGIC = 1e5;
int res{};
long long l = stoll(left);
long long r = stoll(right);
for (int i = 1; i < MAGIC; i++) {
string s = to_string(i);
string rs = s;
for (int i = s.size() - 2; i >= 0; i--) {
rs += s[i];
}
if (check(rs, l, r)) {
res++;
}
rs = s;
for (int i = s.size() - 1; i >= 0; i--) {
rs += s[i];
}
if (check(rs, l, r)) {
res++;
}
}
return res;
}
};

游戏问题

给定一个长度为4的整数数组 cards 。你有 4 张卡片,每张卡片上都包含一个范围在 [1,9] 的数字。您应该使用运算符 ['+', '-', '*', '/'] 和括号 '('')' 将这些卡片上的数字排列成数学表达式,以获得值24。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
class Solution {
public:
bool solve(vector<double>& nums) {
if (nums.size() == 1) {
return abs(nums[0] - 24.0) < 1e-6;
}
// 选择其中两个数
for (int i = 0; i < nums.size(); i++) {
for (int j = 0; j < nums.size(); j++) {
if (i == j) {
// 同一个数
continue;
}
vector<double> nextNums;
// 剩下的数
for (int k = 0; k < nums.size(); k++) {
if (k != i && k != j) {
// 选择剩余的数
nextNums.push_back(nums[k]);
}
}
// 对选择的两个数进行计算
double n = nums[i] + nums[j];
nextNums.push_back(n);
if (solve(nextNums)) {
return true;
}
nextNums.pop_back();
// 减法
n = nums[i] - nums[j];
nextNums.push_back(n);
if (solve(nextNums)) {
return true;
}
nextNums.pop_back();

// 乘法
n = nums[i] * nums[j];
nextNums.push_back(n);
if (solve(nextNums)) {
return true;
}
nextNums.pop_back();
// 除法
if (abs(nums[j]) > 1e-6) {
n = nums[i] / nums[j];
nextNums.push_back(n);
if (solve(nextNums)) {
return true;
}
nextNums.pop_back();
}
}
}
return false;
}

bool judgePoint24(vector<int>& cards) {
// 4个数字选择其中两个 进行计算 直到只剩1个
// 回溯/穷举
vector<double> nums;
for (auto& n : cards) {
nums.push_back(static_cast<double>(n));
}
return solve(nums);
}
};

解数独

编写一个程序,通过填充空格来解决数独问题。

数独的解法需 遵循如下规则

  1. 数字 1-9 在每一行只能出现一次。
  2. 数字 1-9 在每一列只能出现一次。
  3. 数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。(请参考示例图)

数独部分空格内已填入了数字,空白格用 '.' 表示

广度优先和深度优先遍历

据搜索方式的不同,搜索算法大致可以分为深度优先遍历(Depth First Search,DFS)和广度优先遍历(Breadth First Search,BFS)。

以树为例,DFS的思路是沿着子树尽可能深地搜索树的分支,到达叶子节点后通过回溯重复上述过程,直到所有的节点都被访问。BFS的思路则是一层一层地访问节点,直到完成遍历。由于DFS和BFS的这种差异,BFS一般用来求解最短问题(dijkstra算法的特例),而DFS书写起来比较简单,因此对于不是最短问题的情况,我们优先考虑使用DFS。然而事无绝对,DFS 也可以解决最短问题,但是要注意栈溢出的问题。在很多情况下,两者可以交替使用,比如本章要讲的岛屿问题。不管是DFS还是BFS,本质上都是搜索,而这样的搜索通常来说都是暴力搜索,因此当需要对问题的所有可能情况进行穷举时,我们就应该想到DFS和BFS。而第16章要讲解的回溯法,也是DFS的一种,即也是一种暴力搜索方法,只不过回溯法会涉及前进和回溯的过程。

使用DFS进行解题的大概思路是定义起始节点和结束节点,从起点开始不断深入其他节点,在搜索的过程中判断是否满足特定条件

image-20260202132556224

如果在树的题目中使用DFS,由于树是不存在环的,因此有关树的题目大多数不需要visited,但是如果对树的结构做了修改,使之出现了环,那就仍然需要visited

对于二叉树的题目,除了递归出口的条件,还会写一些其他的逻辑,这些逻辑由于位置的不同,产生的效果也截然不同。根据DFS逻辑位置的不同,我们将其分为三种类型,一种是自顶向下(前序遍历)的,一种是自底向上(后序遍历)的,最后一种是中序遍历。

大多数有关树的题目使用后序遍历会比较简单,并且大多需要依赖左/右子树的返回值。例如第1448题统计二叉树中好节点的数目。

● 也有一部分有关树的题目需要前序遍历,而前序遍历通常要结合参数扩展技巧。例如第1022题从根到叶的二进制数之和。

● 如果能使用参数和节点本身的值来决定应该传递给它的子节点的参数,那么就用前序遍历。

● 对于树中的任意一个节点,如果知道它子节点的答案,就能计算出当前节点的答案,那么就用后序遍历。● 如果遇到二叉搜索树,则考虑使用中序遍历。

相对于DFS来说,BFS的变种比较少,能解决的问题种类比较单一。BFS比较适合用来找最短距离,因此如果题目中提到了最短距离,首先应该想到使用BFS。使用BFS进行解题的思路同样是定义起始节点和结束节点,从起点开始不断深入其他节点,在搜索的过程中判断是否满足特定条件。BFS和DFS只是遍历的方向不同,即上面提到的DFS是尽可能深地搜索树的分支,而BFS则是一层一层地访问节点。队列可以帮我们实现“一层一层地访问节点”的效果。其本质就是不断访问邻居,把邻居逐个加入队列,根据队列先进先出的特点,把每一层节点访问完后,会继续访问下一层节点

路径之和

给你二叉树的根节点 root 和一个表示目标和的整数 targetSum 。判断该树中是否存在 根节点到叶子节点 的路径,这条路径上所有节点值相加等于目标和 targetSum 。如果存在,返回 true ;否则,返回 false

叶子节点 是指没有子节点的节点。

一种直观的思路是自顶向下,使用前序遍历+参数扩展,在向下递归的同时更新参数,当到达叶子节点或空节点时判断是否满足条件。在这里,我们可以将目标和sum通过参数扩展的形式向下传递,在叶子节点上判断当前节点的val是否等于传递下来的参数sum。这是一种非常常见的DFS解题思路,除了前序遍历,还有一种常见的二叉树的深度遍历法是后序遍历,即在递归函数返回时对问题进行求解,使用子树的返回值来计算当前节点的返回值。通常来讲,DFS有递归和迭代两种实现方式。因为树结构天然具有递归的特性(子树性质和整个树性质一致),使用递归可以很容易地将整个树问题转换成子树问题。当我们层层递归到最小的子树时,这个最小子树的解(也被称为递归出口)往往很容易就能够得到,再一步步回溯就能得到原问题的解。小提示:树的题目,优先考虑使用DFS递归解决。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class Solution {
public:
bool dfs(TreeNode* node, int targetSum) {
if (!node) {
return false;
}
if (node->left == nullptr && node->right == nullptr) {
if (node->val == targetSum) {
return true;
}
return false;
}
bool leftIsValid = dfs(node->left, targetSum - node->val);
bool rightIsValid = dfs(node->right, targetSum - node->val);
if (leftIsValid || rightIsValid) {
return true;
}
return false;
}
bool hasPathSum(TreeNode* root, int targetSum) {
// 先序遍历DFS
// 递归
// return dfs(root, targetSum);
// 迭代
if (!root) {
return false;
}
stack<pair<TreeNode*, int>> stk;
stk.push({root, targetSum});
while (!stk.empty()) {
auto [node, target] = stk.top();
stk.pop();
if (node->left == nullptr && node->right == nullptr) {
if (node->val == target) {
return true;
}
}
if (node->left) {
stk.push({node->left, target - node->val});
}
if (node->right) {
stk.push({node->right, target - node->val});
}
}
return false;
}
};

二叉树中的最大路径和

二叉树中的 路径 被定义为一条节点序列,序列中每对相邻节点之间都存在一条边。同一个节点在一条路径序列中 至多出现一次 。该路径 至少包含一个 节点,且不一定经过根节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Solution {
public:
int res{INT_MIN};
// 获得该节点的“贡献”
// 也就是该节点为根节点/开始,的最大路径和
int dfs(TreeNode* node) {
if (!node) {
return 0;
}
// 后序遍历
int lVal = max(0, dfs(node->left));
int rVal = max(0, dfs(node->right));
// 计算每个节点贡献的值
int nodeVal = max(lVal, rVal) + node->val;
// 更新最大值
res = max(res, lVal + rVal + node->val);
return nodeVal;
}
int maxPathSum(TreeNode* root) {
// 路径和的组成
// 1.经过根节点以及左右子节点
// 2.不经过根节点 左子树
// 3.不经过根节点 右子树
// 选取这其中的最大值
dfs(root);
return res;
}
};

岛屿问题

给你一个大小为 m x n 的二维二进制网格 grid 。网格表示一个地图,其中,0 表示水,1 表示陆地。最初,grid 中的所有单元格都是水单元格(即,所有单元格都是 0)。

可以通过执行 addLand 操作,将某个位置的水转换成陆地。给你一个数组 positions ,其中 positions[i] = [ri, ci] 是要执行第 i 次操作的位置 (ri, ci)

返回一个整数数组 answer ,其中 answer[i] 是将单元格 (ri, ci) 转换为陆地后,地图中岛屿的数量。

岛屿 的定义是被「水」包围的「陆地」,通过水平方向或者垂直方向上相邻的陆地连接而成。你可以假设地图网格的四边均被无边无际的「水」所包围。

需要动态地求出每次addLand操作之后的无向图中的连通分量。而求连通分量的数量的问题都可以通过DFS、BFS或并查集来解决。首先来看DFS和BFS,任何通过DFS和BFS来解决的图类问题都有一个前提:图是被预先处理好的。而本题中的图是动态变化的,因此这时用DFS或BFS来处理效率就不那么高了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
class Solution {
public:
class UnionFind {
private:
vector<int> parent;
int m;
int cnt{};

public:
UnionFind(int m, int n) : m(m) {
int sz = m * n;
parent.resize(sz);
for (int i = 0; i < sz; i++) {
parent[i] = -1; // 水
}
}

void addLand(int x, int y) {
int p = x * m + y;
if (parent[p] != -1) {
// 如果是陆地
return;
}
parent[p] = p; // 设置值为本身位置(>0)
cnt++;
}
int find(int p) { return parent[p] == p ? p : parent[p] = find(p); }
int find(int pos_x, int pos_y) {
int p = pos_x * m + pos_y;
return find(p);
}

bool isLand(int x, int y) {
int pos = x * m + y;
return parent[pos] != -1;
}
int getCount() { return cnt; }
void join(int u, int v) {
int px = find(u);
int py = find(v);
if (px == py) {
return;
}
parent[py] = px;
cnt--;
}
};
vector<pair<int, int>> dirs = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
vector<int> numIslands2(int m, int n, vector<vector<int>>& positions) {
UnionFind uf(m, n);
int sz = positions.size();
vector<int> res(sz);
for (int i = 0; i < sz; i++) {
int x = positions[i][0];
int y = positions[i][1];
if (uf.isLand(x, y)) {
// 本身是陆地
res[i] = res[i - 1];
} else {
// 是水
uf.addLand(x, y);
// 进行合并
for (auto& dir : dirs) {
int nx = x + dir.first;
int ny = y + dir.second;
// 判断四个方向能否合并
if (nx < 0 || nx >= m || ny < 0 || ny >= n) {
continue;
}
if (!uf.isLand(nx, ny)) {
continue; // 如果是水 跳过
}
int pos = x * m + y;
uf.join(pos, nx * m + ny);
}
}
res[i] = uf.getCount();
}
return res;
}
};

DFS和BFS都属于树/图的搜索算法,两者在用于具体问题时各有优劣,具体如下。

● 求给定图中两点之间最短路径或检验图的二分性,使用BFS更优。

● 求无向图的连通分量数量,两者差不多。两者在实现过程中使用的基础数据结构也有区别。

在实际做题当中,一般使用栈来实现DFS,使用队列来实现BFS。另外,DFS和回溯算法之间的关系界线是模糊的,网上的说法也各不一样,在这里我们没必要过于纠结其精确的定义。对于DFS,另外一个知识点也是值得注意的。在二叉树中,DFS可以被分为前序遍历、中序遍历和后序遍历,并且引申出一系列相关题目。最后,本章的路径和问题、岛屿问题只详细讲述了两种算法的基本写法,而在实际的刷题过程中,我们可能会使用这两种基本写法的变种或延伸,比如运用双向搜索技巧、dijkstra 算法、A* 算法等

从前序和中序构建二叉树

给定两个整数数组 preorderinorder ,其中 preorder 是二叉树的先序遍历inorder 是同一棵树的中序遍历,请构造二叉树并返回其根节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left),
* right(right) {}
* };
*/
class Solution {
public:
TreeNode* buildNode(vector<int>& preorder, vector<int>& inorder,
unordered_map<int, int>& inOrderMap, int leftIdxPre,
int rightIdxPre, int leftIdxIn, int rightIdxIn) {
if (leftIdxPre > rightIdxPre) {
return nullptr;
}
// 1.找到根节点
int nodeVal = preorder[leftIdxPre];

// 2.确定根节点在中序中位置
int idxInOrder = inOrderMap[nodeVal];

// 3.确定左右子树范围
// 左子树节点个数
int leftNodeNum = idxInOrder - leftIdxIn;
// 4. 递归处理
TreeNode* node = new TreeNode(nodeVal);
// 在先序中 左节点开始的位置是leftIdxPre+1
node->left =
buildNode(preorder, inorder, inOrderMap, leftIdxPre + 1,
leftIdxPre + leftNodeNum, leftIdxIn, idxInOrder - 1);
node->right = buildNode(preorder, inorder, inOrderMap,
leftIdxPre + leftNodeNum + 1, rightIdxPre,
idxInOrder + 1, rightIdxIn);
return node;
}

TreeNode* buildTree(vector<int>& preorder, vector<int>& inorder) {
// 先序遍历确定根节点
// 中序遍历确定左右子树/子节点
unordered_map<int, int> inOrderMap;
int sz = inorder.size();
for (int i = 0; i < sz; i++) {
inOrderMap[inorder[i]] = i; // 记录中序遍历节点位置
}
auto node =
buildNode(preorder, inorder, inOrderMap, 0, sz - 1, 0, sz - 1);
return node;
}
};

不同路径

一个机器人位于一个 m x n 网格的左上角 (起始点在下图中标记为 “Start” )。

机器人每次只能向下或者向右移动一步。机器人试图达到网格的右下角(在下图中标记为 “Finish” )。

问总共有多少条不同的路径?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
int uniquePaths(int m, int n) {
vector<vector<int>> dp(m,vector<int>(n));
// 初始化
for(int i = 0;i<n;i++) {
dp[0][i] = 1;
}
for(int i = 0;i<m;i++) {
dp[i][0] = 1;
}
for(int i = 1;i<m;i++) {
for(int j = 1;j<n;j++) {
dp[i][j] = dp[i-1][j] + dp[i][j-1];
}
}
return dp[m-1][n-1];
}
};

不同路径I

给定一个 m x n 的整数数组 grid。一个机器人初始位于 左上角(即 grid[0][0])。机器人尝试移动到 右下角(即 grid[m - 1][n - 1])。机器人每次只能向下或者向右移动一步。

网格中的障碍物和空位置分别用 10 来表示。机器人的移动路径中不能包含 任何 有障碍物的方格。

返回机器人能够到达右下角的不同路径数量。

测试用例保证答案小于等于 2 * 109

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
// 动态规划
int m = obstacleGrid.size();
int n = obstacleGrid[0].size();
// dp[i][j]表示到达i,j的路径数目
vector<vector<int>> dp(m, vector<int>(n));
// 初始化
for (int j = 0; j < n; j++) {
if (obstacleGrid[0][j] == 0) {
dp[0][j] = 1;
} else {
break;
}
}
for (int i = 0; i < m; i++) {
if (obstacleGrid[i][0] == 0) {
dp[i][0] = 1;
} else {
break;
}
}
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
if (obstacleGrid[i][j] == 1) {
continue;
}
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1];
}
};

不同路径II

在二维网格 grid 上,有 4 种类型的方格:

  • 1 表示起始方格。且只有一个起始方格。
  • 2 表示结束方格,且只有一个结束方格。
  • 0 表示我们可以走过的空方格。
  • -1 表示我们无法跨越的障碍。

返回在四个方向(上、下、左、右)上行走时,从起始方格到结束方格的不同路径的数目

每一个无障碍方格都要通过一次,但是一条路径中不能重复通过同一个方格

虽然题目名字叫“不同路径”,但它和普通的动态规划路径题完全不同,因为它有一个硬性约束:必须经过每一个无障碍方格(0)恰好一次。图论的角度来看,这实际上是在寻找网格图中的哈密顿路径(Hamiltonian Path)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
class Solution {
public:
int res{};
vector<pair<int, int>> dirs = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
void dfs(vector<vector<int>>& grid, int x, int y, int step,
int targetStep) {
int m = grid.size();
int n = grid[0].size();
// 四个方向
for (auto& dir : dirs) {
int nx = x + dir.first;
int ny = y + dir.second;
if (nx < 0 || nx >= m || ny < 0 || ny >= n) {
continue;
}
if (grid[nx][ny] == -1 || grid[nx][ny] == 1) {
// 不能到达
continue;
}
if (grid[nx][ny] == 2) {
// 到达终点且必须都走一次
if (step == targetStep) {
res++;
}
continue;
}
// 不能重复走
grid[nx][ny] = -1;
dfs(grid, nx, ny, step + 1, targetStep);
grid[nx][ny] = 0;
}
}
int uniquePathsIII(vector<vector<int>>& grid) {
// dfs
int m = grid.size();
int n = grid[0].size();
// 记录0的个数
int cnt{};
int start_x{}, start_y{};
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] == 0) {
cnt++;
}
if (grid[i][j] == 1) {
start_x = i;
start_y = j;
}
}
}
// 四个方向走
dfs(grid, start_x, start_y, 0, cnt);
return res;
}
};
  1. DFS 回溯 (Backtracking):试错的探险家

回溯的核心是“尝试 -> 撤销”。它不关心过去是否算过这个点,它关心的是当前的路径

  • 核心逻辑:走不通就退回来,把标记抹掉,换条路再试。
  • 状态依赖:当前的状态通常依赖于路径历史(比如你走过的路,别人就不能再走了)。
  • 空间复杂度:通常较小,只取决于递归深度。
  • 时间复杂度:通常是指数级的,比如 $O(2^n)$ 或 $O(n!)$。
  1. 记忆化搜索 (Memoization):聪明的收纳狂

记忆化搜索本质上是“自顶向下的动态规划(DP)”。它的核心是“查表 -> 存储”

  • 核心逻辑:如果这个子问题我以前算过,直接把结果扔给你,绝不浪费时间重算。
  • 状态依赖:当前的状态只取决于当前的参数,与你是怎么走到这一步的(路径)无关。这就是所谓的“无后效性”。
  • 空间复杂度:较大,需要额外的空间(哈希表或数组)来存储中间结果。
  • 时间复杂度:通常能将指数级降低到多项式级,如 $O(n^2)$。

如果在 DFS 递归函数的末尾看到了类似这样的代码:

1
2
grid[x][y] = 0; // 恢复现场
return res;

这通常是 回溯

如果你在递归函数的开头和结尾看到了这样的代码:

1
2
3
if (memo[state] != -1) return memo[state]; // 查表
...
return memo[state] = res; // 存表

这一定是 记忆化搜索

特性记忆化搜索 (Top-down)动态规划 (Bottom-up)
实现方式递归 + 缓存 (通常是哈希表或数组)迭代 + 递推表 (通常是数组)
思维方向自顶向下:从大问题拆解成小问题自底向上:从小问题推导出大问题
计算顺序依赖驱动:只计算到达目标所需的子状态顺序驱动:按预定顺序计算所有可能状态
空间开销缓存空间 + 递归调用栈 (容易溢出)缓存空间 (通常可以通过滚动数组优化)
适用场景状态空间稀疏、转移方程复杂状态空间密集、需要极致性能优化

公式:$f(n) = f(n-1) + f(n-2)$

记忆化搜索版

1
2
3
4
5
6
int memo[1001]; // 初始化为 -1
int fib(int n) {
if (n <= 1) return n;
if (memo[n] != -1) return memo[n]; // 查表
return memo[n] = fib(n - 1) + fib(n - 2); // 存表
}

动态规划版

1
2
3
4
5
6
7
8
9
int fib(int n) {
if (n <= 1) return n;
vector<int> dp(n + 1);
dp[0] = 0; dp[1] = 1;
for (int i = 2; i <= n; i++) {
dp[i] = dp[i - 1] + dp[i - 2]; // 顺序填表
}
return dp[n];
}

统计二叉树中好节点数目

给你一棵根为 root 的二叉树,请你返回二叉树中好节点的数目。

「好节点」X 定义为:从根到该节点 X 所经过的节点中,没有任何节点的值大于 X 的值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
/**
* Definition for a binary tree node.
* struct TreeNode {
* int val;
* TreeNode *left;
* TreeNode *right;
* TreeNode() : val(0), left(nullptr), right(nullptr) {}
* TreeNode(int x) : val(x), left(nullptr), right(nullptr) {}
* TreeNode(int x, TreeNode *left, TreeNode *right) : val(x), left(left),
* right(right) {}
* };
*/
class Solution {
public:
int res{};
void dfs(TreeNode* node, int max_val) {
if (!node) {
return;
}
// 判断一个节点是好节点
// 从根节点到当前节点的值均小于等于当前节点的值
// 记录路径中的最大值判断是否小于等于当前值
max_val = max(max_val, node->val);
if (max_val <= node->val) {
res++;
}
dfs(node->left, max_val);
dfs(node->right, max_val);
}
int goodNodes(TreeNode* root) {
dfs(root, INT_MIN);
return res;
}
};

矩阵中最长递增路径

定一个 m x n 整数矩阵 matrix ,找出其中 最长递增路径 的长度。

对于每个单元格,你可以往上,下,左,右四个方向移动。 你 不能对角线 方向上移动或移动到 边界外(即不允许环绕)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Solution {
public:
int ans{1};
vector<pair<int, int>> dirs = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
int dfs(vector<vector<int>>& matrix, int x, int y,
vector<vector<int>>& memo) {
if (memo[x][y] != -1) {
return memo[x][y];
}
int m = matrix.size();
int n = matrix[0].size();
// 还未访问过
int val{1};
for (auto& dir : dirs) {
int nx = x + dir.first;
int ny = y + dir.second;
if (nx < 0 || nx >= m || ny < 0 || ny >= n) {
continue;
}
if (matrix[nx][ny] < matrix[x][y]) {
val = max(val, 1 + dfs(matrix, nx, ny, memo));
}
}
memo[x][y] = val;
ans = max(ans, val);
return val;
}
int longestIncreasingPath(vector<vector<int>>& matrix) {
// 反向来,从一个位置向周围四个方向走
// 直到走到最小值,记录该路径的最长递增路径
// 重复处理时记忆化
int m = matrix.size();
int n = matrix[0].size();
vector<vector<int>> memo(m, vector<int>(n, -1));
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
dfs(matrix, i, j, memo);
}
}
return ans;
}
};

博弈论题目

Nim游戏

你和你的朋友,两个人一起玩 Nim 游戏

  • 桌子上有一堆石头。
  • 你们轮流进行自己的回合, 你作为先手
  • 每一回合,轮到的人拿掉 1 - 3 块石头。
  • 拿掉最后一块石头的人就是获胜者。

假设你们每一步都是最优解。请编写一个函数,来判断你是否可以在给定石头数量为 n 的情况下赢得游戏。如果可以赢,返回 true;否则,返回 false

我能赢吗

在 “100 game” 这个游戏中,两名玩家轮流选择从 110 的任意整数,累计整数和,先使得累计整数和 达到或超过 100 的玩家,即为胜者。

如果我们将游戏规则改为 “玩家 不能 重复使用整数” 呢?

例如,两个玩家可以轮流从公共整数池中抽取从 1 到 15 的整数(不放回),直到累计整数和 >= 100。

给定两个整数 maxChoosableInteger (整数池中可选择的最大数)和 desiredTotal(累计和),若先出手的玩家能稳赢则返回 true ,否则返回 false 。假设两位玩家游戏时都表现 最佳

博弈类题目通常很难写出迭代式的 DP,但用记忆化搜索配合状态压缩(Bitmask)则非常直观。

考虑边界情况,当所有数字选完仍无法到达 desiredTotal 时,两人都无法获胜,返回 false。当所有数字的和大于等于 desiredTotal 时,其中一方能获得胜利,需要通过搜索来判断获胜方。

在游戏中途,假设已经被使用的数字的集合为 usedNumbers,这些数字的和为 currentTotal。当某方行动时,如果他能在未选择的数字中选出一个 i,使得 i+currentTotal≥desiredTotal,则他能获胜。否则,需要继续通过搜索来判断获胜方。在剩下的数字中,如果他能选择一个 i,使得对方在接下来的局面中无法获胜,则他会获胜。否则,他会失败。

根据这个思想设计搜索函数 dfs,其中 usedNumbers 可以用一个整数来表示,从低位到高位,第 i 位为 1 则表示数字 i 已经被使用,为 0 则表示数字 i 未被使用。如果当前玩家获胜,则返回 true,否则返回 false。为了避免重复计算,需要使用记忆化的操作来降低时间复杂度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Solution {
public:
// memo[mask] == 0: 未计算
// memo[mask] == 1: 必胜 (true)
// memo[mask] == 2: 必败 (false)
int memo[1 << 21];

bool canIWin(int maxChoosableInteger, int desiredTotal) {
// 特判 1:如果所有数字之和都达不到目标,谁都赢不了
int sum = (1 + maxChoosableInteger) * maxChoosableInteger / 2;
if (sum < desiredTotal) return false;

// 特判 2:如果最大的数直接能达到目标,先手秒胜
if (desiredTotal <= 0) return true;

return dfs(maxChoosableInteger, desiredTotal, 0);
}

bool dfs(int maxInt, int total, int mask) {
// 查表:如果算过这个状态,直接返回
if (memo[mask] != 0) return memo[mask] == 1;

// 尝试选择每一个还没被选过的数字
for (int i = 1; i <= maxInt; i++) {
int bit = 1 << i;
if (!(mask & bit)) { // 如果数字 i 还没被选

// 1. 如果选了 i 直接达到目标,我赢了
// 2. 或者选了 i 之后,递归下去对方会输 (dfs 返回 false)
if (total - i <= 0 || !dfs(maxInt, total - i, mask | bit)) {
memo[mask] = 1; // 记录必胜
return true;
}
}
}

memo[mask] = 2; // 所有尝试都失败了,我必败
return false;
}
};

猜数字大小 II

我们正在玩一个猜数游戏,游戏规则如下:

  1. 我从 1n 之间选择一个数字。
  2. 你来猜我选了哪个数字。
  3. 如果你猜到正确的数字,就会 赢得游戏
  4. 如果你猜错了,那么我会告诉你,我选的数字比你的 更大或者更小 ,并且你需要继续猜数。
  5. 每当你猜了数字 x 并且猜错了的时候,你需要支付金额为 x 的现金。如果你花光了钱,就会 输掉游戏

给你一个特定的数字 n ,返回能够 确保你获胜 的最小现金数,不管我选择那个数字

image-20260203160641411

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int getMoneyAmount(int n) {
// dp[i][j] = k+max(dp[i][k-1],dp[k+1][j])
// dp[i][j]表示在[i,j]范围内获胜的最小现金数
vector<vector<int>> dp(n + 1, vector<int>(n + 1));
for(int i = n-1;i>=1;i--) {
for(int j = i+1;j<=n;j++) {
dp[i][j] = dp[i][j-1] + j;
for(int k = i;k<j;k++) {
dp[i][j] = min(dp[i][j],k+max(dp[i][k-1],dp[k+1][j]));
}
}
}
return dp[1][n];
}
};

翻转游戏

你和朋友玩一个叫做「翻转游戏」的游戏。游戏规则如下:

给你一个字符串 currentState ,其中只含 '+''-' 。你和朋友轮流将 连续 的两个 "++" 反转成 "--" 。当一方无法进行有效的翻转时便意味着游戏结束,则另一方获胜。

计算并返回 一次有效操作 后,字符串 currentState 所有的可能状态,返回结果可以按 任意顺序 排列。如果不存在可能的有效操作,请返回一个空列表 []

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
vector<string> generatePossibleNextMoves(string currentState) {
// 对连续的++或者--进行翻转
vector<string> res;
int start_idx{};
while (start_idx < currentState.size() - 1) {
if (currentState[start_idx] == currentState[start_idx + 1]) {
// 翻转
string s = currentState;
if (currentState[start_idx] == '+') {
s[start_idx] = '-';
s[start_idx + 1] = '-';
res.push_back(s);
}
}
start_idx++;
}
return res;
}
};

翻转游戏II

你和朋友玩一个叫做「翻转游戏」的游戏。游戏规则如下:

给你一个字符串 currentState ,其中只含 ‘+’ 和 ‘-‘ 。你和朋友轮流将 连续 的两个 “++” 反转成 “—“ 。当一方无法进行有效的翻转时便意味着游戏结束,则另一方获胜。默认每个人都会采取最优策略。

请你写出一个函数来判定起始玩家 是否存在必胜的方案 :如果存在,返回 true ;否则,返回 false 。

在博弈问题中,我们通常使用递归来模拟每一轮的操作。

  • 必胜态 (Winning State):从当前状态出发,存在至少一种移动方式,能够进入“必败态”。
  • 必败态 (Losing State):从当前状态出发,无论做出什么移动,下个状态都是“必胜态”;或者根本无法移动。

算法步骤:

  1. 遍历字符串:寻找所有连续的 ++
  2. 模拟翻转:将当前的 ++ 替换为 --,得到一个新的字符串。
  3. 递归判断:调用函数判断对手在面对新字符串时是否会输掉。如果对手输了(返回 false),说明我们找到了一个必胜点,直接返回 true
  4. 记忆化 (Memoization):为了避免重复计算(同一个字符串可能通过不同的翻转路径达到),我们使用哈希表记录已经计算过的状态。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Solution {
public:
bool dfs(string& currentState, unordered_map<string, bool>& memo) {
if (memo.count(currentState)) {
return memo[currentState];
}
// DFS
for (int i = 0; i < currentState.size() - 1; i++) {
if (currentState[i] == currentState[i + 1]) {
if (currentState[i] == '+') {
string nextState = currentState;
nextState[i] = '-';
nextState[i + 1] = '-';
bool flag = dfs(nextState, memo);
if (!flag) {
// 对方必输
memo[currentState] = true;
return true;
}
}
}
}
memo[currentState] = false;
return false;
}
bool canWin(string currentState) {
// 动态规划/记忆化搜索
// 递推
// 记忆化搜索
unordered_map<string, bool> memo;
return dfs(currentState, memo);
}
};

对于 这类博弈题,递归 + 记忆化 是通杀方案。只要记住“我的胜利建立在对手的绝望之上”这个博弈原则,逻辑就能顺理成章。

求出硬币游戏赢家

给你两个 整数 xy ,分别表示价值为 75 和 10 的硬币的数目。

Alice 和 Bob 正在玩一个游戏。每一轮中,Alice 先进行操作,Bob 后操作。每次操作中,玩家需要拿走价值 总和 为 115 的硬币。如果一名玩家无法执行此操作,那么这名玩家 输掉 游戏。

两名玩家都采取 最优 策略,请你返回游戏的赢家。

1
2
3
4
5
6
7
8
9
class Solution {
public:
string winningPlayer(int x, int y) {
// 每次x-1,y-4
// 直到为0
int ops = min(x, y / 4);
return ops % 2 ? "Alice" : "Bob";
}
};

预测赢家

给一个整数数组 nums 。玩家 1 和玩家 2 基于这个数组设计了一个游戏。

玩家 1 和玩家 2 轮流进行自己的回合,玩家 1 先手。开始时,两个玩家的初始分值都是 0 。每一回合,玩家从数组的任意一端取一个数字(即,nums[0]nums[nums.length - 1]),取到的数字将会从数组中移除(数组长度减 1 )。玩家选中的数字将会加到他的得分上。当数组中没有剩余数字可取时,游戏结束。

如果玩家 1 能成为赢家,返回 true 。如果两个玩家得分相等,同样认为玩家 1 是游戏的赢家,也返回 true 。你可以假设每个玩家的玩法都会使他的分数最大化。

在处理这类“两个人都采取最优策略”的问题时,我们不要去管玩家 1 拿了多少分、玩家 2 拿了多少分。我们只关心一个值:当前玩家相对于对手的“净胜分”

定义函数 $f(i, j)$:表示在数组 nums 从索引 $i$ 到 $j$ 的这段区间内,当前走棋的人能拿到的最大净分数(即:我的得分 - 对手的得分)。

  • 如果你选左端点 nums[i]:你得到了 nums[i] 分,剩下的区间是 $[i+1, j]$。在剩下的区间里,对手会作为先手,他能拿到的最大净分值是 $f(i+1, j)$。所以你的净胜分就是 nums[i] - f(i+1, j)
  • 如果你选右端点 nums[j]:同理,你的净胜分就是 nums[j] - f(i, j-1)

作为大师级玩家,你当然会在这两种选择中选那个更大的

  1. 递归逻辑与状态转移

基准情况 (Base Case)

当 $i == j$ 时,只剩一个数字,当前玩家直接拿走,净胜分为 $f(i, i) = \text{nums}[i]$。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public:
int max_diff(vector<int>& nums, int i, int j) {
if (j == i) {
return nums[i];
}
// 净分数
int l = nums[i] - max_diff(nums, i + 1, j);
int r = nums[j] - max_diff(nums, i, j - 1);
return max(l, r);
}
bool predictTheWinner(vector<int>& nums) {
return max_diff(nums, 0, nums.size() - 1) >= 0;
}
};
// 动态规划
class Solution {
public:
bool PredictTheWinner(vector<int>& nums) {
int length = nums.size();
auto dp = vector<vector<int>> (length, vector<int>(length));
for (int i = 0; i < length; i++) {
dp[i][i] = nums[i];
}
for (int i = length - 2; i >= 0; i--) {
for (int j = i + 1; j < length; j++) {
dp[i][j] = max(nums[i] - dp[i + 1][j], nums[j] - dp[i][j - 1]);
}
}
return dp[0][length - 1] >= 0;
}
};

求出胜利玩家数目

给你一个整数 n ,表示在一个游戏中的玩家数目。同时给你一个二维整数数组 pick ,其中 pick[i] = [xi, yi] 表示玩家 xi 获得了一个颜色为 yi 的球。

如果玩家 i 获得的球中任何一种颜色球的数目 严格大于 i 个,那么我们说玩家 i 是胜利玩家。换句话说:

  • 如果玩家 0 获得了任何的球,那么玩家 0 是胜利玩家。
  • 如果玩家 1 获得了至少 2 个相同颜色的球,那么玩家 1 是胜利玩家。
  • 如果玩家 i 获得了至少 i + 1 个相同颜色的球,那么玩家 i 是胜利玩家。

请你返回游戏中 胜利玩家 的数目。

注意,可能有多个玩家是胜利玩家。

石子游戏

Alice 和 Bob 用几堆石子在做游戏。一共有偶数堆石子,排成一行;每堆都有 整数颗石子,数目为 piles[i]

游戏以谁手中的石子最多来决出胜负。石子的 总数奇数 ,所以没有平局。

Alice 和 Bob 轮流进行,Alice 先开始 。 每回合,玩家从行的 开始结束 处取走整堆石头。 这种情况一直持续到没有更多的石子堆为止,此时手中 石子最多 的玩家 获胜

假设 Alice 和 Bob 都发挥出最佳水平,当 Alice 赢得比赛时返回 true ,当 Bob 赢得比赛时返回 false

永远为true

单词拆分

给你一个字符串 s 和一个字符串列表 wordDict 作为字典。如果可以利用字典中出现的一个或多个单词拼接出 s 则返回 true

注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
int sz = s.size();
unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
vector<bool> dp(sz + 1);
dp[0] = true;
// 状态转移方程
// dp[i]表示前i个字符能否被表示
// dp[j] = dp[j-len]|word[j-len:j]
for (int i = 1; i <= sz; i++) {
for (int j = 0; j < i; j++) {
// 字符串起点 j-(i-1)
string sstr = s.substr(j, i - j);
if (wordSet.count(sstr) && dp[j]) {
dp[i] = true;
}
}
}
return dp[sz];
}
};
class Solution {
public:
unordered_map<int, bool> memo;
bool dfs(string& s, int start_idx, unordered_set<string> wordSet) {
if (memo.count(start_idx)) {
return memo[start_idx];
}
if (start_idx == s.size()) {
return true;
}
for (int i = start_idx; i < s.size(); i++) {
string tmp = s.substr(start_idx, i - start_idx + 1);
if (wordSet.count(tmp) && dfs(s, i + 1, wordSet)) {
memo[start_idx] = true;
return true;
}
}
memo[start_idx] = false;
return false;
}
bool wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
return dfs(s, 0, wordSet);
}
};

单词拆分II

给定一个字符串 s 和一个字符串字典 wordDict ,在字符串 s 中增加空格来构建一个句子,使得句子中所有的单词都在词典中。以任意顺序 返回所有这些可能的句子。

注意:词典中的同一个单词可能在分段中被重复使用多次。

将大问题拆解为:“当前单词 + 剩余子串的所有拆分可能”

  • 定义函数 dfs(start):返回字符串 s[start:] 能够组成的所有合法句子列表。
  • 递归过程
    1. start 开始,尝试所有可能的结尾 end
    2. 如果 s[start:end] 是一个单词:
      • 递归调用 dfs(end) 获取后缀的所有组合。
      • 将当前单词与后缀的每一个组合用空格连接。
    3. 记忆化:使用字典 memo 记录 start 对应的所有结果。如果下次再遇到相同的 start,直接返回结果,不再重复搜索。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
class Solution {
public:
// 记忆化搜索 存储结果
unordered_map<int, vector<string>> memo;
vector<string> dfs(string& s, unordered_set<string>& uset, int start) {
if (memo.count(start)) {
return memo[start];
}
if (start == s.size()) {
// 到末尾
return {""};
}
vector<string> ans;
for (int j = start; j < s.size(); j++) {
string sstr = s.substr(start, j - start + 1);
if (uset.count(sstr)) {
// 获得后缀
auto res = dfs(s, uset, j + 1);
for (auto& s : res) {
if (s.empty()) {
ans.push_back(sstr);
} else {
ans.push_back(sstr + " " + s);
}
}
}
}
memo[start] = ans;
return ans;
}
vector<string> wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> uset(wordDict.begin(), wordDict.end());
return dfs(s, uset, 0);
}
};

划分为k个相等的子集

给定一个整数数组 nums 和一个正整数 k,找出是否有可能把这个数组分成 k 个非空子集,其总和都相等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class Solution {
public:
unordered_map<int, bool> memo;
bool dfs(vector<int>& nums, int usedNums, int curSum, int targetSum,
int cnt, int k) {
if (memo.count(usedNums)) {
return memo[usedNums];
}
if (cnt == k) {
memo[usedNums] = true;
return true;
}
if (targetSum == curSum) {
if (dfs(nums, usedNums, 0, targetSum, cnt + 1, k)) {
memo[usedNums] = true;
return true;
}
}
for (int i = 0; i < nums.size(); i++) {
if (usedNums & (1 << i)) {
// 已经用过
continue;
}
if (nums[i] + curSum > targetSum) {
continue;
}

if (nums[i] + curSum <= targetSum) {
if (dfs(nums, usedNums | (1 << i), nums[i] + curSum, targetSum,
cnt, k)) {
return true;
}
}
}
memo[usedNums] = false;
return false;
}
bool canPartitionKSubsets(vector<int>& nums, int k) {
int sum_val = accumulate(nums.begin(), nums.end(), 0);
// 不能整除
if (sum_val % k) {
return false;
}
int target = sum_val / k;
return dfs(nums, 0, 0, target, 0, k);
}
};

戳气球

n 个气球,编号为0n - 1,每个气球上都标有一个数字,这些数字存在数组 nums 中。

现在要求你戳破所有的气球。戳破第 i 个气球,你可以获得 nums[i - 1] * nums[i] * nums[i + 1] 枚硬币。 这里的 i - 1i + 1 代表和 i 相邻的两个气球的序号。如果 i - 1i + 1 超出了数组的边界,那么就当它是一个数字为 1 的气球。

求所能获得硬币的最大数量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
class Solution {
public:
int maxCoins(vector<int>& nums) {
int n = nums.size();
// 1 xxxx 1
vector<int> coins(n + 2, 1);
for (int i = 1; i <= n; i++) {
coins[i] = nums[i - 1];
}
// dp[i][j]表示在(i,j)范围也就是[i+1,j-1]范围的最大硬币数
// k在[i+1,j-1]内,表示(i,j)内最后一个戳破的气球
// dp[i][j] = dp[i][k]*dp[k][j]nums[i]*nums[k]*nums[j]
vector<vector<int>> dp(n + 2, vector<int>(n + 2));
for (int i = n + 1; i >= 0; i--) {
for (int j = i + 2; j <= n + 1; j++) {
for (int k = i + 1; k < j; k++) {
dp[i][j] =
max(dp[i][k] + dp[k][j] + coins[i] * coins[j] * coins[k],
dp[i][j]);
}
}
}
return dp[0][n + 1];
}
};
// 记忆化搜索
class Solution {
public:
vector<vector<int>> rec;
vector<int> val;

public:
int solve(int left, int right) {
if (left >= right - 1) {
return 0;
}
if (rec[left][right] != -1) {
return rec[left][right];
}
for (int i = left + 1; i < right; i++) {
int sum = val[left] * val[i] * val[right];
sum += solve(left, i) + solve(i, right);
rec[left][right] = max(rec[left][right], sum);
}
return rec[left][right];
}

int maxCoins(vector<int>& nums) {
int n = nums.size();
val.resize(n + 2);
for (int i = 1; i <= n; i++) {
val[i] = nums[i - 1];
}
val[0] = val[n + 1] = 1;
rec.resize(n + 2, vector<int>(n + 2, -1));
return solve(0, n + 1);
}
};

通配符匹配

给你一个输入字符串 (s) 和一个字符模式 (p) ,请你实现一个支持 '?''*' 匹配规则的通配符匹配:

  • '?' 可以匹配任何单个字符。
  • '*' 可以匹配任意字符序列(包括空字符序列)。

判定匹配成功的充要条件是:字符模式必须能够 完全匹配 输入字符串(而不是部分匹配

定义 $dp[i][j]$ 为 $s$ 的前 $i$ 个字符和 $p$ 的前 $j$ 个字符是否匹配。

情况 A:p[j-1] 是普通字符或 ?

如果 s[i-1] == p[j-1] 或者 p[j-1] == '?',匹配 1 对 1。

情况 B:p[j-1]*

这时候 * 有两种选择:

  1. 当成空字符串(匹配 0 个):直接看模式串前一个位置是否匹配当前字符串。

  2. 匹配 1 个或多个字符:既然 * 能匹配任意序列,只要 $s$ 的前一个字符已经和当前 * 匹配上了,那么当前字符也能被这个 * 吸收。

综合逻辑:$dp[i][j] = dp[i][j-1] \lor dp[i-1][j]$

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Solution {
public:
bool isMatch(string s, string p) {
// 动态规划
int n1 = s.size();
int n2 = p.size();
vector<vector<bool>> dp(n1 + 1, vector<bool>(n2 + 1));
dp[0][0] = true;
// 状态转移方程
// p[j] = '*' 可以匹配任意序列
// dp[i][j] = dp[i-1][j]
// 初始化
for (int i = 1; i <= n2; i++) {
if (p[i - 1] == '*') {
dp[0][i] = dp[0][i - 1];
} else {
dp[0][i] = false;
break;
}
}
for (int i = 1; i <= n1; i++) {
for (int j = 1; j <= n2; j++) {
if (p[j - 1] == '*') {
// 匹配0个 或者 匹配多个
dp[i][j] = dp[i][j - 1] || dp[i - 1][j];
} else if (p[j - 1] == '?' || p[j - 1] == s[i - 1]) {
dp[i][j] = dp[i - 1][j - 1];
}
}
}
return dp[n1][n2];
}
};

正则表达式匹配

给你一个字符串 s 和一个字符规律 p,请你来实现一个支持 '.''*' 的正则表达式匹配。

  • '.' 匹配任意单个字符
  • '*' 匹配零个或多个前面的那一个元素

所谓匹配,是要涵盖 整个 字符串 s 的,而不是部分字符串。

难点在于 * 的处理:它不是独立存在的,而是必须和前一个字符绑在一起看,表示“0 个或多个前面的那个元素”。这种“回头看”的逻辑让匹配变得非常复杂。

遍历 i(从 0 到 $m$)和 j(从 1 到 $n$),对于每个 dp[i][j]

情况 A:p[j-1] 不是 *

如果当前字符匹配(s[i-1] == p[j-1]p[j-1] == '.'),则:

情况 B:p[j-1]*

此时模式串中 * 前面的字符是 p[j-2]

  1. 匹配 0 次:无论如何,我们可以选择忽略 x*

  2. 匹配 1 次或多次:如果 s[i-1] 能匹配 p[j-2],则我们可以“吃掉” s 的当前字符,并保持模式串位置不变。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
class Solution {
public:
bool isMatch(string s, string p) {
int sz1 = s.size();
int sz2 = p.size();
vector<vector<bool>> dp(sz1 + 1, vector<bool>(sz2 + 1));
dp[0][0] = true;
// 初始化dp
for (int j = 2; j <= sz2; j++) {
if (p[j - 1] == '*') {
dp[0][j] = dp[0][j - 2];
}
}
for (int i = 1; i <= sz1; i++) {
for (int j = 1; j <= sz2; j++) {
// 判断更新dp情况
// 如果前一个字符相等
// cb a*
if (p[j - 1] == '*') {
// 匹配0个
dp[i][j] = dp[i][j - 2];
bool is_match =
((s[i - 1] == p[j - 2]) || (p[j - 2] == '.'));
if (is_match) {
// 如果前一个字符匹配
// ab a*c
dp[i][j] = (dp[i - 1][j] || dp[i][j]);
}
} else {
// 正常匹配
if (s[i - 1] == p[j - 1] || p[j - 1] == '.') {
dp[i][j] = dp[i - 1][j - 1];
}
}
}
}
return dp[sz1][sz2];
}
};

记忆化搜索题目

  1. 矩阵与路径类(最直观的应用)

这类问题通常具有明确的方向性,递归路径清晰,但存在大量重复计算。

  • 329. 矩阵中的最长递增路径 (Hard)
    • 核心点:从任意点出发找最长路径。如果不用记忆化,DFS 会呈指数级增长。由于递增的限制,路径不会成环,非常适合递归缓存结果。
  • 62. 不同路径 (Medium)
    • 核心点:虽然入门选手机通常用迭代 DP,但用递归+记忆化实现逻辑最自然。
  1. 博弈论类(必须用记忆化)

正如你刚才问的“翻转游戏”,博弈题几乎是记忆化搜索的本命题。

  • 464. 我能赢吗 (Medium)
    • 核心点:状态压缩 + 记忆化。需要记录哪些数字被选过(用二进制位表示),并判断当前玩家是否必胜。
  • 486. 预测赢家 (Medium)
    • 核心点:经典的从数组两端取数的博弈,递归逻辑是 max(左端取数 - 剩下的递归结果, 右端取数 - 剩下的递归结果)
  • 877. 石子游戏 (Medium)
    • 核心点:虽然数学推导必胜,但作为算法练习,它是典型的区间记忆化搜索。
  1. 拆分与区间类(Range DP)

将一个大问题拆解成多个小区间,再合并结果。

  • 312. 戳气球 (Hard)
    • 核心点:区间 DP 的巅峰之作。自顶向下的记忆化搜索比自底向上的三层循环更容易理解:solve(left, right) 表示戳破 (left, right) 之间所有气球的最大收益。
  • 139. 单词拆分 (Medium)
    • 核心点:判断字符串是否能由字典组成。递归判断 s[i:] 是否合法,并用 memo 记录。
  • 140. 单词拆分 II (Hard)
    • 核心点:不仅要判断,还要返回所有路径。由于要构造大量字符串,记忆化搜索几乎是唯一解法。
  1. 字符串匹配类
  • 10. 正则表达式匹配 (Hard)
    • 核心点:处理 *. 的复杂逻辑。记忆化搜索可以让你专注处理当前的字符匹配,而不用纠结 DP 表的初始化。
  • 44. 通配符匹配 (Hard)
    • 核心点:逻辑与 10 题类似,是练习递归思维的佳作。
  1. 状态压缩类(进阶必备)

当状态无法简单用数组下标表示时,通常配合位运算。

当你发现题目有以下特征时,请优先考虑记忆化搜索:

  1. 状态转移方向“不规律”或“跳跃”

    比如在博弈类题目(如之前的“翻转游戏”)中,一个状态下一步可能跳到任何地方,你很难写出一个简单的 for 循环顺序。

  2. 状态空间很大,但实际访问的状态很少(稀疏性)

    如果你开了一个 $1000 \times 1000$ 的 DP 表,但题目实际上只需要计算其中的几十个格子,用循环(DP)会浪费大量时间去填那些没用的格子。

  3. 递归逻辑更符合直觉

    比如“单词拆分”或者“组合总和”,这种“拆解大问题”的思维用递归写起来非常顺手。

    什么时候选“动态规划”?(For Loop + DP Table)

当你发现题目有以下特征时,动态规划更香:

  1. 状态转移极其规律

    比如“爬楼梯”、“路径和”、“打家劫舍”。可以清晰地看到第 i 步只依赖 i-1i-2

  2. 需要极致的空间优化

    如果你发现 dp[i] 只依赖 dp[i-1],你可以用滚动数组把 $O(n)$ 空间压缩到 $O(1)$。记忆化搜索(因为有递归栈)很难做这种优化。

  3. 避免递归深度限制

    在某些语言(如 Python/C++)中,如果递归太深(比如几万层),会导致栈溢出(Stack Overflow)。这时必须用循环(DP)。

用rand7实现rand10

给定方法 rand7 可生成 [1,7] 范围内的均匀随机整数,试写一个方法 rand10 生成 [1,10] 范围内的均匀随机整数。

你只能调用 rand7() 且不能调用其他方法。请不要使用系统的 Math.random() 方法。

每个测试用例将有一个内部参数 n,即你实现的函数 rand10() 在测试时将被调用的次数。请注意,这不是传递给 rand10() 的参数。

二分法

二分法是一种常用的算法,主要包括原始二分查找及实现难度更大的二分变种。二分法是分治思想的体现,它与分治法的区别在于分治法是将一个复杂的问题不断分解成几个规模更小的子问题,直至子问题可以直接求解;而二分法则是不断地通过比较操作将问题规模缩小一半,直至找到目标元素

搜索旋转排序数组

整数数组 nums 按升序排列,数组中的值 互不相同

在传递给函数之前,nums 在预先未知的某个下标 k0 <= k < nums.length)上进行了 向左旋转,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]](下标 从 0 开始 计数)。例如, [0,1,2,4,5,6,7] 下标 3 上向左旋转后可能变为 [4,5,6,7,0,1,2]

给你 旋转后 的数组 nums 和一个整数 target ,如果 nums 中存在这个目标值 target ,则返回它的下标,否则返回 -1 。你必须设计一个时间复杂度为 O(log n) 的算法解决此问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
public:
int search(vector<int>& nums, int target) {
// 二分法
// 左侧值均大于右侧值
// 通过nums[mid]比较nums[right]
int left = 0, right = nums.size() - 1;
while (left <= right) {
int mid = (right - left) / 2 + left;
if (target == nums[mid]) {
return mid;
} else if (nums[left] > nums[mid]) {
// mid-right单增
if (target > nums[mid] && target <= nums[right]) {
left = mid + 1;
} else {
right = mid - 1;
}
} else {
// left-mid单增
if (target >= nums[left] && target < nums[mid]) {
right = mid - 1;
} else {
left = mid + 1;
}
}
}
return -1;
}
};

寻找旋转排序数组中的最小值

已知一个长度为 n 的数组,预先按照升序排列,经由 1n旋转 后,得到输入数组。例如,原数组 nums = [0,1,2,4,5,6,7] 在变化后可能得到:

  • 若旋转 4 次,则可以得到 [4,5,6,7,0,1,2]
  • 若旋转 7 次,则可以得到 [0,1,2,4,5,6,7]

注意,数组 [a[0], a[1], a[2], ..., a[n-1]] 旋转一次 的结果为数组 [a[n-1], a[0], a[1], a[2], ..., a[n-2]]

给你一个元素值 互不相同 的数组 nums ,它原来是一个升序排列的数组,并按上述情形进行了多次旋转。请你找出并返回数组中的 最小元素

查询的中间元素变得不确定,以往能够通过直接比对判断某个元素是否符合题目要求,而现在缺少直接判断的条件(mid==target)。幸运的是,还是能够通过二分法不断地缩小最终答案可能存在的区间,当区间只剩下一个元素时(l==h),那么它就是最终答案。二分法中有一种类型是查找最左(最右)满足条件的值,这也运用了类似的思想,即在找到满足条件的一个候选答案时,不是直接返回,而是贪心地继续查看是否还有其他答案。例如要在一个数组[1,2,2,3,4]中找最左边的等于2的值,当我们找到索引值为2的项时,不能直接返回,而是继续贪心地搜索区间,将右边的区间舍弃并继续查看左侧是否还有另外一个2

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Solution {
public:
int findMin(vector<int>& nums) {
// 旋转数组
// 左侧值均大于右侧
// 两侧都是增加
// 最小值的左右两侧值均大于它
int ans{};
int left = 0, right = nums.size() - 1;
while (left <= right) {
if (left == right) {
return nums[left];
}
int mid = (right - left) / 2 + left;
if (nums[mid] < nums[right]) {
right = mid;
} else {
left = mid + 1;
}
}
return -1;
}
};
class Solution {
public:
int findMin(vector<int>& nums) {
int low = 0;
int high = nums.size() - 1;
while (low < high) {
int pivot = low + (high - low) / 2;
if (nums[pivot] < nums[high]) {
high = pivot;
}
else {
low = pivot + 1;
}
}
return nums[low];
}
};

搜索旋转排序II

已知存在一个按非降序排列的整数数组 nums ,数组中的值不必互不相同。

在传递给函数之前,nums 在预先未知的某个下标 k0 <= k < nums.length)上进行了 旋转 ,使数组变为 [nums[k], nums[k+1], ..., nums[n-1], nums[0], nums[1], ..., nums[k-1]](下标 从 0 开始 计数)。例如, [0,1,2,4,4,4,5,6,6,7] 在下标 5 处经旋转后可能变为 [4,5,6,6,7,0,1,2,4,4]

给你 旋转后 的数组 nums 和一个整数 target ,请你编写一个函数来判断给定的目标值是否存在于数组中。如果 nums 中存在这个目标值 target ,则返回 true ,否则返回 false

你必须尽可能减少整个操作步骤。

在上一题中,通过 nums[left] <= nums[mid] 就能确切地判断左半部分是否有序。但在有重复的情况下,如果 nums[left] == nums[mid],我们就无法判断旋转点到底在左边还是右边。

为了解决这个问题,当我们遇到 nums[left] == nums[mid] 时,我们不能简单地排除一半区间,但我们可以确定:既然 nums[mid] 不是我们要找的 target,那么当前的 left 也是多余的。

  • 对策: 执行 left++,缩小范围,然后进入下一次二分判定。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Solution {
public:
bool search(vector<int>& nums, int target) {
int left = 0, right = nums.size() - 1;
while (left <= right) {
int mid = (right - left) / 2 + left;
if (nums[mid] == target) {
return true;
}
if (nums[mid] == nums[left]) {
left++;
continue;
}
if (nums[mid] < nums[left]) {
// mid-right单增
if (target > nums[mid] && target <= nums[right]) {
left = mid + 1;
} else {
right = mid - 1;
}
} else {
if (target >= nums[left] && target < nums[mid]) {
right = mid - 1;
} else {
left = mid + 1;
}
}
}
return false;
}
};

爱吃香蕉的珂珂

珂珂喜欢吃香蕉。这里有 n 堆香蕉,第 i 堆中有 piles[i] 根香蕉。警卫已经离开了,将在 h 小时后回来。

珂珂可以决定她吃香蕉的速度 k (单位:根/小时)。每个小时,她将会选择一堆香蕉,从中吃掉 k 根。如果这堆香蕉少于 k 根,她将吃掉这堆的所有香蕉,然后这一小时内不会再吃更多的香蕉。

珂珂喜欢慢慢吃,但仍然想在警卫回来前吃掉所有的香蕉。

返回她可以在 h 小时内吃掉所有香蕉的最小速度 kk 为整数)。

由于每小时最多只能吃一堆香蕉,速度最多达到最大堆的数量即可,因此,速度的范围为[1,max(piles)],也就是答案一定在这个范围内。

道题可以看作在[1,max(piles)]中查找一个元素k。因此一个简单的思路是枚举从1到max(piles)的所有速度,并判断是否可以吃完,返回最早能够吃完的速度即可。注意在从1到max(piles)进行枚举的过程中,速度是单调递增变化的,这很容易让我们联想到前面的题目。具体来说,当我们在[l,h]中判断中间的速度mid是否可行时,有如下可能。● mid不可行,则速度不够快,最小速度位于[mid+1,h]中,更新左边界l为mid+1。

● mid可行,则可能的最小速度小于或等于mid,最小速度位于[l,mid]中,mid可能是最终答案,但不能直接排除,更新右边界h为mid。

● l等于h,则查找区间只剩下一个l,最小速度等于l。因此这道题就是找到最小的可以吃完的速度,也就是上一节提到的最左满足条件的值的题目类型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Solution {
public:
bool isValid(vector<int>& piles, int m, int h) {
// 速度为m,看能否在h小时内吃完
long long cnt{};
for (auto& p : piles) {
// cnt += ceil(double(p) / m);
cnt += (p + m - 1) / m;
}
return cnt <= h;
}
int minEatingSpeed(vector<int>& piles, int h) {
// 最大最小值
// 二分
int max_val = *max_element(piles.begin(), piles.end());
int l = 1, r = max_val;
int res{};
while (l <= r) {
int m = (r - l) / 2 + l;
if (isValid(piles, m, h)) {
res = m;
r = m - 1; // 必须为m-1,否则可能死循环
} else {
l = m + 1;
}
}
return res;
}
};

类似的题目

给你一个 下标从 0 开始 的整数数组 candies 。数组中的每个元素表示大小为 candies[i] 的一堆糖果。你可以将每堆糖果分成任意数量的 子堆 ,但 无法 再将两堆合并到一起。

另给你一个整数 k 。你需要将这些糖果分配给 k 个小孩,使每个小孩分到 相同 数量的糖果。每个小孩可以拿走 至多一堆 糖果,有些糖果可能会不被分配。

返回每个小孩可以拿走的 最大糖果数目

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
public:
bool isValid(vector<int>& candies, int m, long long k) {
if (m == 0) {
return true;
}
long long cnt{};
for (auto& c : candies) {
cnt += c / m;
}
return cnt >= k;
}

int maximumCandies(vector<int>& candies, long long k) {
// 每个小孩拿到的糖果数量(1,min(candies[i]))
//
int l = 0, r = *max_element(candies.begin(), candies.end());
int res{};
while (l <= r) {
int m = (r - l) / 2 + l;
if (isValid(candies, m, k)) {
l = m + 1;
res = m;
} else {
r = m - 1;
}
}
return res;
}
};

给你一个整数 n ,表示有 n 间零售商店。总共有 m 种商品,每种商品的数目用一个下标从 0 开始的整数数组 quantities 表示,其中 quantities[i] 表示第 i 种商品的数目。

你需要将 所有商品 分配到零售商店,并遵守这些规则:

  • 一间商店 至多 只能有 一种商品 ,但一间商店拥有的商品数目可以为 任意 件。
  • 分配后,每间商店都会被分配一定数目的商品(可能为 0 件)。用 x 表示所有商店中分配商品数目的最大值,你希望 x 越小越好。也就是说,你想 最小化 分配给任意商店商品数目的 最大值

请你返回最小的可能的 x

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution {
public:
bool isValid(vector<int>& quantities, int m, int n) {
// 满足条件
int cnt{};
for (auto& q : quantities) {
cnt += (q + m - 1) / m;
}
return cnt <= n;
}
int minimizedMaximum(int n, vector<int>& quantities) {
int l = 1, r = *max_element(quantities.begin(), quantities.end());
int ans{};
while (l <= r) {
int mid = (r - l) / 2 + l;
if (isValid(quantities, mid, n)) {
r = mid - 1;
ans = mid;
} else {
l = mid + 1;
}
}
return ans;
}
};

给你一个整数数组 ranks ,表示一些机械工的 能力值ranksi 是第 i 位机械工的能力值。能力值为 r 的机械工可以在 r * n2 分钟内修好 n 辆车。

同时给你一个整数 cars ,表示总共需要修理的汽车数目。

请你返回修理所有汽车 最少 需要多少时间。

注意:所有机械工可以同时修理汽车。

寻找峰值

峰值元素是指其值严格大于左右相邻值的元素。

给你一个整数数组 nums,找到峰值元素并返回其索引。数组可能包含多个峰值,在这种情况下,返回 任何一个峰值 所在位置即可。

你可以假设 nums[-1] = nums[n] = -∞ 。你必须实现时间复杂度为 O(log n) 的算法来解决此问题。

这道题的背景是在数组中查找目标值(峰值元素),虽然数组不是有序的,但峰值元素具备某种性质(大于左右相邻值的元素),可以尝试使用二分法。关注中间元素和左右相邻元素的关系,当右相邻元素大于中间元素时,意味着右相邻元素可能是峰值,其大于左边元素的条件已经满足,只要右相邻元素也大于它右边元素即可。顺着右边的方向继续扫描,存在以下两种情况。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Solution {
public:
int findPeakElement(vector<int>& nums) {
int sz = nums.size();
int l = 0, r = sz - 1;
while (l <= r) {
int m = (r - l) / 2 + l;
int n = nums[m];
if ((m + 1 < sz) && nums[m + 1] > n) {
// 右侧值可能是峰值
l = m + 1;
} else if (m != 0 && nums[m - 1] > n) {
r = m - 1;
} else {
return m;
}
}
return -1;
}
};
class Solution {
public int findPeakElement(int[] nums) {
int left = 0, right = nums.length - 1;
while (left < right) {
int mid = left + (right - left) / 2;
if (nums[mid] > nums[mid + 1]) {
// 中间元素大于右边元素,说明峰值在左边(包括mid)
right = mid;
} else {
// 中间元素小于右边元素,说明峰值在右边
left = mid + 1;
}
}
return left;
}
}

寻找峰值II

一个 2D 网格中的 峰值 是指那些 严格大于 其相邻格子(上、下、左、右)的元素。

给你一个 从 0 开始编号m x n 矩阵 mat ,其中任意两个相邻格子的值都 不相同 。找出 任意一个 峰值 mat[i][j]返回其位置 [i,j]

你可以假设整个矩阵周边环绕着一圈值为 -1 的格子。

要求必须写出时间复杂度为 O(m log(n))O(n log(m)) 的算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
vector<int> findPeakGrid(vector<vector<int>>& mat) {
int m = mat.size();
int n = mat[0].size();
int l =0, r = m - 1;
while (l <= r) {
int i = (r - l) / 2 + l;
int j = max_element(mat[i].begin(), mat[i].end()) - mat[i].begin();
if ((i >= 1) && mat[i][j] < mat[i - 1][j]) {
r = i - 1;
continue;
}
if ((i < m - 1) && mat[i][j] < mat[i + 1][j]) {
l = i + 1;
continue;
}
return {i, j};
}
return {};
}
};

分割数组的最大值

给定一个非负整数数组 nums 和一个整数 k ,你需要将这个数组分成 k 个非空的连续子数组,使得这 k 个子数组各自和的最大值 最小。返回分割后最小的和的最大值。子数组 是数组中连续的部分。

假设这个和为 $X$:

  • 如果我们可以把数组分成 $\le k$ 个子数组,且每个子数组的和都不超过 $X$,说明 $X$ 可能太大了,我们可以尝试更小的 $X$。
  • 如果我们无论如何都要分成超过 $k$ 个子数组才能保证每组和 $\le X$,说明 $X$ 太小了,必须调大。

确定二分的边界:

  • 左边界 (left):数组中的最大值。因为每个数都要属于一个子数组,最大值所在的组之和至少就是它自己。
  • 右边界 (right):数组中所有元素之和。对应 $k=1$ 的极端情况。

对于给定的目标值 mid,我们如何判断 $k$ 个子数组是否够用? 使用贪心策略:从左往右累加元素,只要当前和超过了 mid,就必须在这里“切一刀”,开启一个新的子数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public:
bool isValid(vector<int>& nums, int k, int m) {
int cnt{1};
int acc{};
for (int i = 0; i < nums.size(); i++) {
if (acc + nums[i] <= m) {
acc += nums[i];
} else {
acc = nums[i];
cnt++;
}
}
return cnt <= k;
}
int splitArray(vector<int>& nums, int k) {
int l = *max_element(nums.begin(), nums.end());
int r = accumulate(nums.begin(), nums.end(), 0);
int ans{};
while (l <= r) {
int m = (r - l) / 2 + l;
if (isValid(nums, k, m)) {
// 满足条件
r = m - 1;
ans = m;
} else {
l = m + 1;
}
}
return ans;
}
};

简单的题目能够一眼看出“查找”任务,读者也就可以联想到使用二分法;中等级别或困难级别的题目往往背景复杂,无法马上看出是“查找”任务,需要读者进一步加工并对题目进行转换,利用题目中的已知信息,构建查找的目标,以及目标所在的范围。此外,当题目的数据规模超过1e7时,有较大的可能是二分法类型的题目,这也是一个识别二分法的小技巧。

关注查找范围内的中间元素,挖掘背后的规律,往往中间元素和题目的目标值、左右相邻元素及左右边界元素等存在一定的关联,根据这些关联可以将查找范围缩小一半。

具体的实现方法包括原始的二分查找及二分查找的变种,二者的实现难度不大,唯一需要注意的是二分查找的变种的边界问题,当更新左边界l=mid时,需要修改循环的退出条件为l+1==h or l==h。

两球之间的磁力

在代号为 C-137 的地球上,Rick 发现如果他将两个球放在他新发明的篮子里,它们之间会形成特殊形式的磁力。Rick 有 n 个空的篮子,第 i 个篮子的位置在 position[i] ,Morty 想把 m 个球放到这些篮子里,使得任意两球间 最小磁力 最大。

已知两个球如果分别位于 xy ,那么它们之间的磁力为 |x - y|

给你一个整数数组 position 和一个整数 m ,请你返回最大化的最小磁力。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public:
bool isValid(vector<int>& pos, int r, int m) {
// 以r为最小磁力,能放多少个球
int res{1};
int prevpose = pos[0];
// 磁力越大,能放的球越少
for (int i = 1; i < pos.size(); i++) {
int p = pos[i];
if (p - prevpose >= r) {
res++;
prevpose = p;
}
}
return res >= m;
}
int maxDistance(vector<int>& position, int m) {
sort(position.begin(), position.end());
int l = 1, r = position.back() - position.front();
int ans{};
while (l <= r) {
int mid = (r - l) / 2 + l;
if (isValid(position, mid, m)) {
ans = mid;
l = mid + 1;
} else {
r = mid - 1;
}
}
return ans;
}
};

最长递增子序列

给你一个整数数组 nums ,找到其中最长严格递增子序列的长度。

子序列 是由数组派生而来的序列,删除(或不删除)数组中的元素而不改变其余元素的顺序。例如,[3,6,2,7] 是数组 [0,3,1,6,2,2,7] 的子序列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include <vector>
#include <algorithm>

using namespace std;

class Solution {
public:
int lengthOfLIS(vector<int>& nums) {
if (nums.empty()) return 0;

// tails[i] 存储长度为 i+1 的子序列的最小末尾
vector<int> tails;

for (int x : nums) {
// 使用二分查找在 tails 中找到第一个 >= x 的位置
auto it = lower_bound(tails.begin(), tails.end(), x);

if (it == tails.end()) {
// 如果 x 比所有尾数都大,说明可以延长 LIS
tails.push_back(x);
} else {
// 如果找到了,就用 x 更新那个位置,减小该长度子序列的末尾值
*it = x;
}
}

return tails.size();
}
};

位运算

计算机中的数据都是以二进制的形式存储的,二进制的运算都是按位来进行的。以整数为例,位运算就是直接对整数在内存中的二进制位进行操作,效率比算术运算要高。

例如,求一个数的2倍的值,使用位运算比算术运算要快很多,因为算术运算的乘法指令所用的指令周期(指令周期是指CPU从内存取出一条指令并执行这条指令的时间总和)比位运算的移位指令所用的指令周期长

位运算包括取反、按位或、按位异或、按位与、移位等操作。常见的位运算符如下。

  • 取反(~):按位取反,1变0,0变1。
  • 按位或(|):操作位中只要有1,则结果为1;否则结果为0。
  • 按位异或(^):操作位中只要有两位相反(一个为1,一个为0),则结果为1;否则结果为0。
  • 按位与(&):操作位中只要有两位全部为1,则结果为1;否则结果为0。
  • 移位(<<或>>):移位分为算术移位和逻辑移位;根据移位方向又分为左移运算和右移运算。

给定一个正整数 n,编写一个函数,获取一个正整数的二进制形式并返回其二进制表达式中 设置位 的个数(也被称为汉明重量)。

不去检测整数的每一位,而是依次将最低位且值为1的比特位翻转为0,并增加计数器。当执行结果使整数为0时,该整数不再包含任何为1的比特,返回计数器的值。此时的关键问题是如何执行“翻转最低有效比特为1的比特为0”,此处可以使用n &(n-1)的操作。

1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
int hammingWeight(int n) {
int res{};
while (n > 0) {
n = n & (n - 1);
res++;
}
return res;
}
};

两整数之和

给你两个整数 ab不使用 运算符 +- ,计算并返回两整数之和。

有符号整数通常用补码来表示和存储,补码具有如下特征:

正整数的补码与原码相同;负整数的补码为其原码除符号位外的所有位取反后加 1。可以将减法运算转化为补码的加法运算来实现。符号位与数值位可以一起参与运算.

逻辑右移 (Logical Right Shift)

  • 规则:不管三七二十一,左边统一补 0
  • 适用:无符号数(unsigned)。
  • 后果:如果你把一个负数进行逻辑右移,符号位的 1 会被移走,左边补 0,这个数会瞬间从负数变成一个巨大的正数。

算术右移 (Arithmetic Right Shift)

  • 规则:左边补符号位(原来是 0 就补 0,原来是 1 就补 1)。
  • 适用:有符号数(int)。
  • 意义:为了维持数学上的“除以 2”。如果一个负数除以 2 之后变成了正数,那数学逻辑就崩了,所以必须补 1 来保持它的负号。

术左移和逻辑左移是同一条指令。

  • 操作:右边统一补 0

在 C++20 之前,int 的右移行为是由“编译器实现决定”的(Implementation-defined),虽然几乎所有现代编译器都默认使用算术右移,但理论上存在不确定性。C++20 正式将其统一为算术右移。

在 Java 语言中,为了避免 C++ 这种“隐式”的规则,专门设计了两个操作符:

  • >>:算术右移(保留符号)。
  • >>>:无符号右移(逻辑右移,通通补 0)。
1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
    int getSum(int a, int b) {
        while (b != 0) {
            unsigned int carry = (unsigned int)(a & b) << 1;
            a = a ^ b;
            b = carry;
        }
        return a;
    }
};

整数替换

给定一个正整数 n ,你可以做如下操作:

  1. 如果 n 是偶数,则用 n / 2替换 n
  2. 如果 n 是奇数,则可以用 n + 1n - 1替换 n

返回 n 变为 1 所需的 最小替换次数

当 n 为奇数时,我们可以选择将 n 增加 1 或减少 1。由于这两种方法都会将 n 变为偶数,那么下一步一定是除以 2,因此这里我们可以看成使用两次操作,将 n 变为 (n+1)/2和(n-1)/2.

image-20260210124408364

著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public:
int search(unordered_map<int, int>& memo, int n) {
if (memo.count(n)) {
return memo[n];
}
if (n & 1) {
// n
// n-1 n+1
// (n-1)/2 == n//2
// (n+1)/2 == n//2+1
int l1 = search(memo, n / 2);
int l2 = search(memo, n / 2 + 1);
memo[n] = min(l1, l2) + 2;
} else {
int l = search(memo, n / 2);
memo[n] = l + 1;
}
return memo[n];
}
int integerReplacement(int n) {
unordered_map<int, int> memo;
memo[1] = 0;
return search(memo, n);
}
};

利用二进制特性的最优解。核心思想是:奇数时,通过 $+1$ 或 $-1$ 尽可能让低位产生更多的 $0$。

算法逻辑:

  • 若 $n$ 是偶数:直接右移($n = n >> 1$)。
  • 若 $n$ 是奇数
    • 观察最后两位:
      • 如果是 ...01:减 1 效果更好(变为 ...00)。
      • 如果是 ...11:加 1 效果更好(进位变为 ...00)。
    • 特例处理:当 $n = 3$ 时,二进制虽是 11,但 $3 \to 2 \to 1$(2步)优于 $3 \to 4 \to 2 \to 1$(3步),所以 $3$ 选择减 1。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
public:
int integerReplacement(int n) {
// 贪心
int res{};
while (n != 1) {
if (n & 1) {
// 奇数
// 01
// 11
if (n == 3) {
// 特殊情况
res += 2;
n = 1;
} else if ((n & 3) == 3) {
res += 2;
n = n / 2 + 1;
} else if ((n & 3) == 1) {
res += 2;
n /= 2;
}
} else {
// 偶数
n /= 2;
res++;
}
}
return res;
}
};

只出现一次的数字

给你一个 非空 整数数组 nums ,除了某个元素只出现一次以外,其余每个元素均出现两次。找出那个只出现了一次的元素。你必须设计并实现线性时间复杂度的算法来解决此问题,且该算法只使用常量额外空间。

1
2
3
4
5
6
7
8
9
10
class Solution {
public:
int singleNumber(vector<int>& nums) {
int res{};
for(auto& n:nums) {
res ^= n;
}
return res;
}
};

只出现一次的数字II

给你一个整数数组 nums ,除某个元素仅出现 一次 外,其余每个元素都恰出现 三次 。请你找出并返回那个只出现了一次的元素。你必须设计并实现线性时间复杂度的算法且使用常数级空间来解决此问题。

简单异或法无法筛选出唯一的单一元素,因为异或法无法将3个元素消除,但考虑到在元素的二进制形式中,对于出现3次的元素,它的二进制形式中的每一位都是3的倍数,统计所有数字的二进制形式中1出现的次数,并对3求余,如果结果不为0,则说明出现1次的数字在该二进制位上为1

逐位计数法(通用且稳健),这种方法遍历 32 个比特位,统计每一位上 $1$ 出现的次数。如果某一位的计数不能被 3 整除,那么目标数字在该位就是 $1$。如果改为出现 $k$ 次,只需将 % 3 改为 % k

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int singleNumber(vector<int>& nums) {
int res{};
for (int i = 0; i < 32; i++) {
int r{};
for (auto& n : nums) {
r += ((n>>i)&1); // 第i位出现次数
}
if (r % 3 == 1) {
res |= (1 << i);
}
}
return res;
}
};

另外还可以利用真值表推导转换为规则并行处理二进制

只出现一次的数字II

给你一个整数数组 nums,其中恰好有两个元素只出现一次,其余所有元素均出现两次。 找出只出现一次的那两个元素。你可以按 任意顺序 返回答案。你必须设计并实现线性时间复杂度的算法且仅使用常量额外空间来解决此问题。

假设这两个只出现一次的数字是 $x$ 和 $y$。

  1. 全局异或:对数组所有元素进行异或。因为出现两次的数字都会抵消($a \oplus a = 0$),最终结果 xorSum = x ^ y
  2. 寻找差异位:由于 $x \neq y$,xorSum 必然不为 0。这意味着 $x$ 和 $y$ 的二进制表示中至少有一位是不同的(一个是 0,一个是 1)。我们取出 xorSum 中最低位的 1(称为 lowbit)。
  3. 分组异或:根据 lowbit 这一位是否为 1,将原数组中的所有数字分成两组:
    • 第一组:该位为 1 的所有数字。
    • 第二组:该位为 0 的所有数字
  4. 结果产出
    • 相同的数字必然会被分到同一组并相互抵消。
    • $x$ 和 $y$ 必然会被分到不同的组。
    • 对两组分别异或,剩下的两个数就是 $x$ 和 $y$。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <vector>

using namespace std;

class Solution {
public:
vector<int> singleNumber(vector<int>& nums) {
// 1. 全员异或得到 x ^ y
// 使用 long 防止 INT_MIN 取负时溢出
long xorSum = 0;
for (int num : nums) {
xorSum ^= num;
}

// 2. 获取 x ^ y 中最低位的 1 (lowbit)
// 这个 1 是 x 和 y 的不同点
int lowbit = xorSum & -xorSum;

// 3. 分组异或得到两个结果
int x = 0, y = 0;
for (int num : nums) {
if (num & lowbit) {
// 第一组:该位为 1
x ^= num;
} else {
// 第二组:该位为 0
y ^= num;
}
}

return {x, y};
}
};

在面试过程中,如果题目中出现二进制、与2的倍数相关的问题、不能使用算术运算符等情况时,都可以考虑是否可以使用位运算解题。当然,在做题的过程中,也要善于总结规律,这样在真正的笔试、面试过程中,才能够迅速写出简洁、高效的代码。另外还有一种常见的位运算使用场景是状态压缩

设计

这类题更加强调对数据结构的设计,以达到高效实现某些操作的目的。这种题目需要我们对各种基础数据结构的特性及基本操作有着非常好的理解。

最小栈

设计一个支持 pushpoptop 操作,并能在常数时间内检索到最小元素的栈。

实现 MinStack 类:

  • MinStack() 初始化堆栈对象。
  • void push(int val) 将元素val推入堆栈。
  • void pop() 删除堆栈顶部的元素。
  • int top() 获取堆栈顶部的元素。
  • int getMin() 获取堆栈中的最小元素。

对于栈来说,如果一个元素 a 在入栈时,栈里有其它的元素 b, c, d,那么只要 a 在栈中,b, c, d 就一定在栈中。

那么在任何时候只要栈a在栈中,栈中元素一定是a,b,c,d.那么可以在每个元素 a 入栈时把当前栈的最小值 m 存储起来。在这之后无论何时,如果栈顶元素是 a,就可以直接返回存储的最小值 m

只需要设计一个数据结构,使得每个元素 a 与其相应的最小值 m 时刻保持一一对应。因此可以使用一个辅助栈,与元素栈同步插入与删除

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class MinStack {
public:
stack<int> stk; // 存储数据 正常栈
stack<int> mstack;
MinStack() { mstack.push(INT_MAX); }

void push(int val) {
stk.push(val);
mstack.push(min(mstack.top(),val));
}
// 弹出一个值时需要更新最小值
void pop() {
stk.pop();
mstack.pop();
}

int top() {
int val = stk.top();
return val;
}

int getMin() { return mstack.top(); }
};

实现前缀树

前缀树 是一种树形数据结构,用于高效地存储和检索字符串数据集中的键。这一数据结构有相当多的应用情景,例如自动补全和拼写检查。

一棵有根树,其每个节点包含以下字段:

指向子节点的指针数组 children。对于本题而言,数组长度为 26,即小写英文字母的数量。此时 children[0] 对应小写字母 a,children[1] 对应小写字母 b,…,children[25] 对应小写字母 z。
布尔字段 isEnd,表示该节点是否为字符串的结尾

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
class Trie {
public:
class Node {
public:
vector<Node*> children{26};
bool isEnd{false};
};
Node* root{};
Trie() { root = new Node(); }

void insert(string word) {
Node* cur = root;
for (auto& ch : word) {
int idx = ch - 'a';
Node* node = cur->children[idx];
if (node == nullptr) {
// 如果不存在
node = new Node;
cur->children[idx] = node;
}
cur = node;
}
// 当前字符结束 代表有这个字符串
cur->isEnd = true;
}

bool search(string word) {
Node* cur = root;
for (auto& ch : word) {
int idx = ch - 'a';
Node* node = cur->children[idx];
if (node == nullptr) {
// 不存在
return false;
}
cur = node;
}
if (cur->isEnd == false) {
// 当前字符串仍然有后续字符不存在
return false;
}
return true;
}

bool startsWith(string prefix) {
Node* cur = root;
for (auto& ch : prefix) {
int idx = ch - 'a';
Node* node = cur->children[idx];
if (node == nullptr) {
// 不存在
return false;
}
cur = node;
}
return true;
}
};

LRU缓存

请你设计并实现一个满足 LRU (最近最少使用) 缓存 约束的数据结构。

实现 LRUCache 类:

  • LRUCache(int capacity)正整数 作为容量 capacity 初始化 LRU 缓存
  • int get(int key) 如果关键字 key 存在于缓存中,则返回关键字的值,否则返回 -1
  • void put(int key, int value) 如果关键字 key 已经存在,则变更其数据值 value ;如果不存在,则向缓存中插入该组 key-value 。如果插入操作导致关键字数量超过 capacity ,则应该 逐出 最久未使用的关键字。

函数 getput 必须以 O(1) 的平均时间复杂度运行。

LRU 缓存机制可以通过哈希表辅以双向链表实现,我们用一个哈希表和一个双向链表维护所有在缓存中的键值对。

双向链表按照被使用的顺序存储了这些键值对,靠近头部的键值对是最近使用的,而靠近尾部的键值对是最久未使用的。

哈希表即为普通的哈希映射(HashMap),通过缓存数据的键映射到其在双向链表中的位置。

在双向链表的实现中,使用一个伪头部(dummy head)和伪尾部(dummy tail)标记界限,这样在添加节点和删除节点的时候就不需要检查相邻的节点是否存在。

为什么使用双向链表而不是单向链表?其原因在于,如果想在常数时间内将链表中间的节点移动到尾部,需要能够在O(1)时间内获得当前节点的前驱节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class LRUCache {
public:
// 查询 unordered_map
// 增加 双向链表 增/改 将节点放在首位
unordered_map<int, list<pair<int, int>>::iterator> umap;
// class LinkedNode{
// public:
// int key{};
// int val{};
// LinkedNode* next_node{};
// LinedNode* prev_node{};
// };
// LinkedNode * root;
// unordered_map<int,LinkedNode*> umap
list<pair<int, int>> vals;
int _capacity{};
int cnt{};
LRUCache(int capacity) : _capacity(capacity) {}

int get(int key) {
// 获取值
if (umap.count(key)) {
// 返回值 并更新节点在链表中位置
auto it = umap[key];
int val = it->second;
// 插入在map中值
umap.erase(key);
vals.erase(it); // 在双向链表中删除
// 插入值
vals.push_front({key, val});
umap[key] = vals.begin(); // 更新
return val;
}
// 不存在
return -1;
}

void put(int key, int value) {
if (umap.count(key)) {
// 如果存在 更新位置
auto it = umap[key];
vals.erase(it);
vals.push_front({key, value});
umap.erase(key);
umap[key] = vals.begin();
} else {
// 不存在 插入值并更新位置 判断容量
vals.push_front({key, value});
umap[key] = vals.begin();
cnt++;
// 判断容量
if (cnt > _capacity) {
// 删除链表最后一个元素
auto it = vals.rbegin();
umap.erase(it->first);
vals.pop_back();
}
}
}
};

实际的算法历史上,出现了很多性能更加优秀的变种,比如下面这两种。● LRU-K算法,用于删除第k个最近使用的数据。● ARC算法,维护了最近被删除数据的历史,特别适合用于需要连续扫描的情况。而在著名的Redis中同样实现了两个LRU算法的变种。

● volatile-LRU:从已设置过期时间的数据集中挑选最近最少使用的数据来淘汰。

● allkeys-LRU:从所有数据集中挑选最近最少使用的数据来淘汰。在Redis中LRU变种的具体实现细节

LFU缓存

请你为 最不经常使用(LFU)缓存算法设计并实现数据结构。

实现 LFUCache 类:

  • LFUCache(int capacity) - 用数据结构的容量 capacity 初始化对象
  • int get(int key) - 如果键 key 存在于缓存中,则获取键的值,否则返回 -1
  • void put(int key, int value) - 如果键 key 已存在,则变更其值;如果键不存在,请插入键值对。当缓存达到其容量 capacity 时,则应该在插入新项之前,移除最不经常使用的项。在此问题中,当存在平局(即两个或更多个键具有相同使用频率)时,应该去除 最久未使用 的键。

为了确定最不常使用的键,可以为缓存中的每个键维护一个 使用计数器 。使用计数最小的键是最久未使用的键。

当一个键首次插入到缓存中时,它的使用计数器被设置为 1 (由于 put 操作)。对缓存中的键执行 getput 操作,使用计数器的值将会递增。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
class LFUCache {
public:
class Node {
public:
int key{};
int val{};
int freq{};

public:
Node() {

};
Node(int k, int v, int f) : key(k), val(v), freq(f) {}
};
unordered_map<int, list<Node>::iterator> nodeMap; // 值->节点在list中位置
unordered_map<int, list<Node>> freqMap; // 频率->相同频率节点
int min_freq{INT_MAX};
int _capacity{};
int cnt{};
LFUCache(int capacity) : _capacity(capacity) {}

int get(int key) {
if (!nodeMap.count(key)) {
return -1;
}
// 如果存在 频率+1,更新双向链表中位置/时间
// 找到节点
int val = touch(key);
return val;
}
int touch(int key) {
auto it = nodeMap[key];
int val = it->val;
int freq = it->freq;
// 移除频率中该节点值
freqMap[freq].erase(it);
if(freqMap[freq].empty()) {
freqMap.erase(freq);
if(freq == min_freq) {
min_freq++;
}
}
++freq;
freqMap[freq].push_front({key, val, freq});
nodeMap[key] = freqMap[freq].begin();
return val;
}
void put(int key, int value) {
// 如果存在 更新频率与位置
if (nodeMap.count(key)) {
auto it = nodeMap[key];
it->val = value;
touch(key);
return;
}
// 不存在
if (cnt == _capacity) {
// 淘汰频率最低的值
auto& nodes = freqMap[min_freq];
// 删除链表中最后一个节点
auto it = nodes.rbegin();
nodeMap.erase(it->key);
nodes.pop_back();
if (nodes.empty()) {
freqMap.erase(min_freq);
}
cnt--;
}
// 添加节点
// 移除频率中该节点值
freqMap[1].push_front({key, value, 1});
nodeMap[key] = freqMap[1].begin();
min_freq = 1;
cnt++;
}
};

设计跳表

跳表 是在 O(log(n)) 时间内完成增加、删除、搜索操作的数据结构。跳表相比于树堆与红黑树,其功能与性能相当,并且跳表的代码长度相较下更短,其设计思想与链表相似。

跳表是一种随机化的数据结构,可以被看做二叉树的一个变种,它在性能上和红黑树、AVL 树不相上下,但是跳表的原理非常简单,目前在 Redis 和 LevelDB 中都有用到。跳表的期望空间复杂度为 O(n),跳表的查询,插入和删除操作的期望时间复杂度均为 O(logn)。

跳表实际为一种多层的有序链表,跳表的每一层都为一个有序链表,且满足每个位于第 i 层的节点有 p 的概率出现在第 i+1 层,其中 p 为常数。

查找时,从当前最高层开始开始,如果当前层水平地逐个比较直至当前节点的下一个节点大于等于目标节点,然后移动至下一层进行查找,重复这个过程直至到达第一层。此时,若下一个节点是目标节点,则成功查找;反之,则元素不存在。由于从高层往低层开始查找,由于低层出现的元素可能不会出现在高层,因此跳表在进行查找的过程中会跳过一些元素,相比于有序链表的查询,跳表的查询速度会更快。

添加时,从跳表的当前的最大层数 level 层开始查找,在当前层水平地逐个比较直至当前节点的下一个节点大于等于目标节点,然后移动至下一层进行查找,重复这个过程直至到达第 1 层。设新加入的节点为 newNode,我们需要计算出此次节点插入的层数 lv,如果 level 小于 lv,则同时需要更新 level。用数组 update 保存每一层查找的最后一个节点,第 i 层最后的节点为 update[i]。我们将 newNode 的后续节点指向 update[i] 的下一个节点,同时更新 update[i] 的后续节点为 newNode。 lv随机生成,从第一层开始,如果概率小于某个值,则再加一层。

删除时,首先我们需要查找当前元素是否存在跳表中。从跳表的当前的最大层数 level 层开始查找,在当前层水平地逐个比较直至当前节点的下一个节点大于等于目标节点,然后移动至下一层进行查找,重复这个过程直至到达第 1 层。如果第 1 层的下一个节点不等于 num 时,则表示当前元素不存在直接返回。我们用数组 update 保存每一层查找的最后一个节点,第 i 层最后的节点为 update[i]。此时第 i 层的下一个节点的值为 num,则我们需要将其从跳表中将其删除。由于第 i 层的以 p 的概率出现在第 i+1 层,因此我们应当从第 1 层开始往上进行更新,将 num 从 update[i] 的下一跳中删除,同时更新 update[i] 的后续节点,直到当前层的链表中没有出现 num 的节点为止。最后我们还需要更新跳表中当前的最大层数 level。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
class Skiplist {
public:
const static int MAX_LEVEL = 32;
const static int factor = .25;
class SkipListNode {
public:
int val{};
vector<SkipListNode*> next;
SkipListNode(int t_val, int max_level = MAX_LEVEL)
: val(t_val), next(max_level, nullptr) {}
};
SkipListNode* skipNode;
int level{};
Skiplist() { skipNode = new SkipListNode(-1); }

bool search(int target) {
// 从最顶层搜索,
SkipListNode* cur = skipNode;
for (int i = level - 1; i >= 0; i--) {
// 如果同层下一个节点值小于target 继续到下一个节点
// 否则跳向下一层
while (cur->next[i] && cur->next[i]->val < target) {
cur = cur->next[i];
}
}
cur = cur->next[0];
if (cur && cur->val == target) {
return true;
}
return false;
}
int randomLevel() {
int lv{1};
while (rand() < factor && lv < MAX_LEVEL) {
lv++;
}
return lv;
}
void add(int num) {
vector<SkipListNode*> update(MAX_LEVEL, skipNode);
SkipListNode* cur = skipNode;
for (int i = level - 1; i >= 0; i--) {
while (cur->next[i] && cur->next[i]->val < num) {
cur = cur->next[i];
}
// 记录每层要添加的位置
update[i] = cur;
}
int lv = randomLevel();
level = max(lv, MAX_LEVEL);
SkipListNode* newNode = new SkipListNode(num, lv);
for (int i = 0; i < lv; i++) {
// 到lv之前 每层都添加
newNode->next[i] = update[i]->next[i];
update[i]->next[i] = newNode;
}
}

bool erase(int num) {
vector<SkipListNode*> update(MAX_LEVEL, nullptr);
// 记录从最高层要删除的节点之前的节点
SkipListNode* cur = skipNode;
for (int i = level - 1; i >= 0; i--) {
while (cur->next[i] && cur->next[i]->val < num) {
cur = cur->next[i];
}
update[i] = cur;
}
cur = cur->next[0];
// 看要删除的值是否存在
if (!cur || cur->val != num) {
return false;
}
for (int i = 0; i < level; i++) {
if (update[i]->next[i] != cur) {
break;
}
update[i]->next[i] = cur->next[i];
}
delete cur;
while (level > 1 && skipNode->next[level - 1] == nullptr) {
level--;
}
return true;
}
};

添加与搜索单词

字典树(前缀树)是一种树形数据结构,用于高效地存储和检索字符串数据集中的键。前缀树可以用 O(∣S∣) 的时间复杂度完成如下操作,其中 ∣S∣ 是插入字符串或查询前缀的长度:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class WordDictionary {
public:
struct Node {
char ch{};
vector<Node*> next{26, nullptr};
// 是否是字符串结尾
bool isEnd{};
};
Node* root{};
WordDictionary() { root = new Node; }

void addWord(string word) {
Node* cur = root;
for (auto& ch : word) {
Node* node = cur->next[ch - 'a'];
if (node == nullptr) {
node = new Node;
node->ch = ch;
cur->next[ch - 'a'] = node;
}
cur = node;
}
cur->isEnd = true;
}
bool dfs(string& word, int idx, Node* cur) {
if (idx == word.size()) {
if (cur->isEnd == true) {
return true;
}
return false;
}
char ch = word[idx];
if (ch == '.') {
// 针对下一个节点
for (int i = 0; i < 26; i++) {
if (cur->next[i]) {
if (dfs(word, idx + 1, cur->next[i])) {
return true;
}
}
}
} else {
int index = ch - 'a';
if (cur->next[index]) {
return dfs(word, idx + 1, cur->next[index]);
}
}
return false;
}
bool search(string word) {
Node* cur = root;
return dfs(word, 0, cur);
}
};

二叉树的序列化与反序列化

序列化是将一个数据结构或者对象转换为连续的比特位的操作,进而可以将转换后的数据存储在一个文件或者内存中,同时也可以通过网络传输到另一个计算机环境,采取相反方式重构得到原数据。

请设计一个算法来实现二叉树的序列化与反序列化。这里不限定你的序列 / 反序列化算法执行逻辑,你只需要保证一个二叉树可以被序列化为一个字符串并且将这个字符串反序列化为原始的树结构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class Codec {
public:
// 序列为字符串
// 依规定顺序和规则序列化和反序列化
// DFS 前序 中序 后序
// BFS 层序
// Encodes a tree to a single string.
void serializeTree(TreeNode* node, string& str) {
if (!node) {
// 根节点
str += "None,";
return;
}
str += to_string(node->val) + ",";
serializeTree(node->left, str);
serializeTree(node->right, str);
}
string serialize(TreeNode* root) {
// 规定序列化顺序
// 先序
string res;
serializeTree(root, res);
return res;
}
TreeNode* rserialize(list<string>& dataArray) {
// 根据数组值
if (dataArray.front() == "None") {
// 空节点
dataArray.erase(dataArray.begin());
return nullptr;
}
TreeNode* node = new TreeNode(stoi(dataArray.front()));
dataArray.erase(dataArray.begin());
node->left = rserialize(dataArray);
node->right = rserialize(dataArray);
return node;
}

// Decodes your encoded data to tree.
TreeNode* deserialize(string data) {
list<string> dataArray;
string str;
for (char& ch : data) {
if (ch == ',') {
dataArray.push_back(str);
str.clear();
} else {
str.push_back(ch);
}
}
if (!str.empty()) {
dataArray.push_back(str);
str.clear();
}

// 拆分为数组
return rserialize(dataArray);
}
};

可以先序遍历这颗二叉树,遇到空子树的时候序列化成 None,否则继续递归序列化。那么我们如何反序列化呢?首先我们需要根据 , 把原先的序列分割开来得到先序遍历的元素列表,然后从左向右遍历这个序列:

如果当前的元素为 None,则当前为空树.否则先解析这棵树的左子树,再解析它的右子树

字符的编码与解码

请你设计一个算法,可以将一个 字符串列表 编码成为一个 字符串。这个编码后的字符串是可以通过网络进行高效传送的,并且可以在接收端被解码回原来的字符串列表。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
class Codec {
public:
string strLen(string& str) {
int sz = str.size();
return to_string(sz) + "#" + str;
}
// Encodes a list of strings to a single string.
string encode(vector<string>& strs) {
string res;
for (auto& str : strs) {
res += strLen(str);
}
return res;
}

// Decodes a single string to a list of strings.
vector<string> decode(string s) {
vector<string> res;
for (int i = 0; i < s.size(); i++) {
int num{};
while (i < s.size() && isdigit(s[i])) {
num = num * 10 + (s[i] - '0');
i++;
}
i++;
res.push_back(s.substr(i, num));
i = i+num-1;
}
return res;
}
};

循环双端队列

以通过一个数组进行模拟,通过操作数组的索引构建一个虚拟的首尾相连的环。在循环队列结构中,设置一个队尾 rear 与队首 front,且大小固定

在循环队列中,当队列为空,可知 front=rear;而当所有队列空间全占满时,也有 front=rear。为了区别这两种情况,假设队列使用的数组有 capacity 个存储空间,则此时规定循环队列最多只能有capacity−1 个队列元素,当循环队列中只剩下一个空存储单元时,则表示队列已满。根据以上可知,队列判空的条件是 front=rear,而队列判满的条件是 front=(rear+1)modcapacity。

elements:一个固定大小的数组,用于保存循环队列的元素。
capacity:循环队列的容量,即队列中最多可以容纳的元素数量。
front:队列首元素对应的数组的索引。
rear:队列尾元素对应的索引的下一个索引。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
class MyCircularDeque {
public:
int _cap{};
int* arr;
int start{};
int end{}; // 记录最后一个位置+1
MyCircularDeque(int k) {
_cap = k + 1;
arr = new int[_cap];
}

bool insertFront(int value) {
if (isFull()) {
return false;
}
arr[(start - 1 + _cap) % _cap] = value;
start = (start - 1 + _cap) % _cap;
return true;
}

bool insertLast(int value) {
if (isFull()) {
return false;
}
arr[end] = value;
end = (end + 1) % _cap;
return true;
}

bool deleteFront() {
if (isEmpty())
return false;
start = (start + 1) % _cap;
return true;
}

bool deleteLast() {
if (isEmpty())
return false;
end = (end - 1 + _cap) % _cap;
return true;
}

int getFront() {
if (isEmpty())
return -1;
return arr[start];
}

int getRear() {
if (isEmpty())
return -1;
return arr[(end - 1 + _cap) % _cap];
}

bool isEmpty() { return start == end; }

bool isFull() { return ((end + 1) % _cap) == start; }
};

双指针

双指针是一个很宽泛的概念,就像数组、链表一样,其类型有很多。比如二分法经常用到左/右端点双指针,滑动窗口会用到快/慢指针和固定间距指针,因此双指针其实是一种综合性很强的类型,类似于数组、栈等,但是这里所讲述的双指针,往往指的是某几种类型的双指针,而不是只要有两个指针就是双指针

头/尾指针是指游标同时指向数组、字符串的第一个元素和最后一个元素,典型应用是求数组元素或子串是否满足特定条件。快/慢指针是指两个指针的移动速度不同(比如有的移动步长为2,有的移动步长为1),典型的应用是判断链表是否有环。

盛最多水的容器

给定一个长度为 n 的整数数组 height 。有 n 条垂线,第 i 条线的两个端点是 (i, 0)(i, height[i]) 。找出其中的两条线,使得它们与 x 轴共同构成的容器可以容纳最多的水。返回容器可以储存的最大水量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
int maxArea(vector<int>& height) {
// 双指针
int ans{};
int l = 0, r = height.size() - 1;
while (l < r) {
int h = min(height[l], height[r]);
ans = max(ans, (r-l) * h);
if (height[l] < height[r]) {
l++;
} else {
r--;
}
}
return ans;
}
};

环形链表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
bool hasCycle(ListNode *head) {
ListNode* slow = head,*fast = head;
while(fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if(slow == fast) {
return true;
}
}
return false;
}
};

无重复字符的最长字串

给定一个字符串 s ,请你找出其中不含有重复字符的 最长 子串 的长度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
int lengthOfLongestSubstring(string s) {
// 记录字串内重复出现字符
vector<bool> chs(128);
int left{};
int ans{};
for (int i = 0; i < s.size(); i++) {
while (left<i && chs[s[i]]) {
// 出现过 移动左窗口
chs[s[left]] = false;
left++;
}
// 记录
chs[s[i]] = true;
ans = max(ans, i - left + 1);
}
return ans;
}
};

双指针一般解决的是与字符串、数组、链表相关的问题,当题目中出现需要字符串的子串、数组的几个子元素、链表的多个节点时,都可以考虑一下能否用双指针的方法来解决。一旦确定可以使用双指针来解答问题,要注意是否可以通过边界条件提前退出检测、是否可以利用其他结构体(比如字典等)来优化解法

双指针问题首先要考虑使用的双指针类型是选择从两边开始的头/尾指针,还是选择从一边开始的快/慢指针。针对快/慢指针,在用于滑动窗口问题时,还可以考虑通过指针控制窗口的大小来优化算法

动态规划

动态规划和其他算法思想如递归、回溯、分治和贪心等方法都有一定的联系。其背后的基本思想是枚举,虽然看起来简单,但如何涵盖所有的可能,并尽量减少重叠子问题的计算是一个难点。

解动态规划类问题,分析过程是有章可循的,通过对阶段、子问题和状态的拆解基本可以得到解决问题的框架。具体的求解过程一般可以通过推导状态转移方程或填状态转移表这两种方法来实现。

解决动态规划问题的核心在于找到状态转移方程和处理边界条件。这两者中更为困难的当然是状态转移方程了,看出了状态转移方程,解题就是水到渠成的事情了。对于某一道动态规划题目来说,状态转移方程可能不止一种,不同的状态转移方程对应不同的解法,而不同的转移方程的性能差别可能是巨大的

鸡蛋掉落问题

给你 k 枚相同的鸡蛋,并可以使用一栋从第 1 层到第 n 层共有 n 层楼的建筑。

已知存在楼层 f ,满足 0 <= f <= n ,任何从 高于 f 的楼层落下的鸡蛋都会碎,从 f 楼层或比它低的楼层落下的鸡蛋都不会破。每次操作,你可以取一枚没有碎的鸡蛋并把它从任一楼层 x 扔下(满足 1 <= x <= n)。如果鸡蛋碎了,你就不能再次使用它。如果某枚鸡蛋扔下后没有摔碎,则可以在之后的操作中 重复使用 这枚鸡蛋。请你计算并返回要确定 f 确切的值最小操作次数 是多少?

可以转换思路,换一个视角来看:如果我们有 $k$ 个鸡蛋,允许扔 $m$ 次,我们最高能测出多少层楼?

设 $dp[m][k]$ 为:当有 $k$ 个鸡蛋,可以扔 $m$ 次时,能确定的最大楼层数

当我们从某一层扔下一枚鸡蛋时,只有两种结果:

  1. 鸡蛋碎了
    • 我们损失了一个鸡蛋(剩下 $k-1$ 个)。
    • 我们损失了一次机会(剩下 $m-1$ 次)。
    • 我们能向下确定的楼层数是 $dp[m-1][k-1]$。
  2. 鸡蛋没碎
    • 鸡蛋数量不变(剩下 $k$ 个)。
    • 我们损失了一次机会(剩下 $m-1$ 次)。
    • 我们能向上确定的楼层数是 $dp[m-1][k]$。

再加上当前扔鸡蛋的那一层(1 层),总共能确定的楼层数为:

  1. 初始化一个二维数组(或者优化为一维)。
  2. 不断增加投掷次数 $m$,直到 $dp[m][k] \ge n$。
  3. 此时的 $m$ 就是我们要找的最小操作次数
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
int superEggDrop(int k, int n) {
if (n == 1) {
return 1;
}
vector<vector<int>> dp(n + 1, vector<int>(k + 1));
// 1层楼
for (int i = 1; i <= k; i++) {
dp[1][i] = 1;
}
int ans{};
for (int i = 2; i <= n; i++) {
for (int j = 1; j <= k; j++) {
dp[i][j] = dp[i - 1][j] + dp[i - 1][j - 1] + 1;
}
if (dp[i][k] >= n) {
ans = i;
break;
}
}
return ans;
}
};

打家劫舍系列

如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警。给定一个代表每个房屋存放金额的非负整数数组,计算你 不触动警报装置的情况下 ,一夜之内能够偷窃到的最高金额。

如果房屋数量大于两间,应该如何计算能够偷窃到的最高总金额呢?对于第 k (k>2) 间房屋,有两个选项:

  1. 偷窃第 k 间房屋,那么就不能偷窃第 k−1 间房屋,偷窃总金额为前 k−2 间房屋的最高总金额与第 k 间房屋的金额之和。

  2. 不偷窃第 k 间房屋,偷窃总金额为前 k−1 间房屋的最高总金额。

在两个选项中选择偷窃总金额较大的选项,该选项对应的偷窃总金额即为前 k 间房屋能偷窃到的最高总金额。

从状态转移方程可以知道状态f(n)只依赖状态f(n-1)和状态f(n-2),因此,额外的n大小的辅助空间是不需要的,只需要两个额外的变量来表示两个依赖状态即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public:
int rob(vector<int>& nums) {
int sz = nums.size();
if(sz == 1) {
return nums[0];
}
vector<int> dp(sz);
dp[0] = nums[0];
dp[1] = max(nums[0],nums[1]);
for(int i = 2;i<sz;i++) {
dp[i] = max(dp[i-2]+nums[i],dp[i-1]);
}
return dp[sz-1];
}
};
class Solution {
public:
int rob(vector<int>& nums) {
int sz = nums.size();
if (sz == 1) {
return nums[0];
}
int prev = 0, cur = 0;
for (int i = 0; i < sz; i++) {
int tmp = cur;
cur = max(cur, nums[i] + prev);
prev = tmp;
}
return cur;
}
};

这个地方所有的房屋都 围成一圈 ,这意味着第一个房屋和最后一个房屋是紧挨着的。同时,相邻的房屋装有相互连通的防盗系统,如果两间相邻的房屋在同一晚上被小偷闯入,系统会自动报警 。给定一个代表每个房屋存放金额的非负整数数组,计算你 在不触动警报装置的情况下 ,今晚能够偷窃到的最高金额。

首尾的房屋是相连的,也就是说:如果偷了开头的房屋,那么结尾的房屋不能偷;如果偷了结尾的房屋,那么开头的房屋不能偷。范围[0,n-1)的解和范围[1,n-1]的解中的较大值即为解,这里n是数组的长度。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
int robRange(vector<int>& nums, int start, int end) {
int first = nums[start], second = max(nums[start], nums[start + 1]);
for (int i = start + 2; i <= end; i++) {
int temp = second;
second = max(first + nums[i], second);
first = temp;
}
return second;
}

int rob(vector<int>& nums) {
int length = nums.size();
if (length == 1) {
return nums[0];
} else if (length == 2) {
return max(nums[0], nums[1]);
}
return max(robRange(nums, 0, length - 2), robRange(nums, 1, length - 1));
}
};

小偷又发现了一个新的可行窃的地区。这个地区只有一个入口,我们称之为 root

除了 root 之外,每栋房子有且只有一个“父“房子与之相连。一番侦察之后,聪明的小偷意识到“这个地方的所有房屋的排列类似于一棵二叉树”。 如果 两个直接相连的房子在同一天晚上被打劫 ,房屋将自动报警。

给定二叉树的 root 。返回 *在不触动警报的情况下 ,小偷能够盗取的最高金额* 。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public:
pair<int,int> dfs(TreeNode* node) {
if (!node) {
return {0,0};
}
// 偷当前节点
auto [l1,l2] = dfs(node->left);
auto [r1,r2] = dfs(node->right);
// 偷当前节点
int f1 = node->val + l2+r2;
// 不偷当前节点
int f2 = max(l1,l2)+max(r1,r2);
return {f1,f2};
}
int rob(TreeNode* root) {
if (!root) {
return 0;
}
auto [x1,x2] = dfs(root);
return max(x1,x2);
}
};

不同路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution {
public:
int uniquePaths(int m, int n) {
vector<int> dp(n,1);
for(int i =1;i<m;i++) {
for(int j =1;j<n;j++) {
dp[j] += dp[j-1];
}
}
return dp[n-1];
// vector<vector<int>> dp(m, vector<int>(n));
// for (int i = 0; i < m; i++) {
// dp[i][0] = 1;
// }
// for (int i = 0; i < n; i++) {
// dp[0][i] = 1;
// }
// for (int i = 1; i < m; i++) {
// for (int j = 1; j < n; j++) {
// dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
// }
// }
// return dp[m - 1][n - 1];
}
};

给定一个 m x n 的整数数组 grid。一个机器人初始位于 左上角(即 grid[0][0])。机器人尝试移动到 右下角(即 grid[m - 1][n - 1])。机器人每次只能向下或者向右移动一步。

网格中的障碍物和空位置分别用 10 来表示。机器人的移动路径中不能包含 任何 有障碍物的方格。

返回机器人能够到达右下角的不同路径数量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Solution {
public:
int uniquePathsWithObstacles(vector<vector<int>>& obstacleGrid) {
// 动态规划
int m = obstacleGrid.size();
int n = obstacleGrid[0].size();
// dp[i][j]表示到达i,j的路径数目
vector<vector<int>> dp(m, vector<int>(n));
// 初始化
for (int j = 0; j < n; j++) {
if (obstacleGrid[0][j] == 0) {
dp[0][j] = 1;
} else {
break;
}
}
for (int i = 0; i < m; i++) {
if (obstacleGrid[i][0] == 0) {
dp[i][0] = 1;
} else {
break;
}
}
for (int i = 1; i < m; i++) {
for (int j = 1; j < n; j++) {
if (obstacleGrid[i][j] == 1) {
continue;
}
dp[i][j] = dp[i - 1][j] + dp[i][j - 1];
}
}
return dp[m - 1][n - 1];
}
};

零钱系列

给你一个整数数组 coins ,表示不同面额的硬币;以及一个整数 amount ,表示总金额。

计算并返回可以凑成总金额所需的 最少的硬币个数 。如果没有任何一种硬币组合能组成总金额,返回 -1

你可以认为每种硬币的数量是无限的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int coinChange(vector<int>& coins, int amount) {
vector<int> dp(amount + 1, INT_MAX);
dp[0] = 0;
for (int i = 1; i <= amount; i++) {
for (auto& coin : coins) {
if (coin > i || dp[i - coin] == INT_MAX) {
continue;
}
dp[i] = min(dp[i - coin] + 1,dp[i]);
}
}
return (dp[amount] == INT_MAX) ? -1 : dp[amount];
}
};

给你一个整数数组 coins 表示不同面额的硬币,另给一个整数 amount 表示总金额。

请你计算并返回可以凑成总金额的硬币组合数。如果任何硬币组合都无法凑出总金额,返回 0

假设每一种面额的硬币有无限个。 题目数据保证结果符合 32 位带符号整数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class Solution {
public:
int change(int amount, vector<int>& coins) {
// 方案数
// 组合 完全背包
int n = coins.size();
vector<int> dp(amount + 1); // 凑到金额i的方案数
dp[0] = 1;
for (int i = 0; i < n; ++i) {
for (int j = 1; j <= amount; j++) {
if (j >= coins[i] && dp[j]<INT_MAX-dp[j-coins[i]]) {
dp[j] += dp[j - coins[i]];
}
}
}
return dp[amount];
}
};
class Solution {
public:
int change(int amount, vector<int>& coins) {
int n = coins.size();
// memo[index][target] 存储结果
vector<vector<int>> memo(n, vector<int>(amount + 1, -1));

return dfs(0, amount, coins, memo);
}

private:
int dfs(int index, int target, vector<int>& coins, vector<vector<int>>& memo) {
// 基准情况 1:金额正好凑齐
if (target == 0) return 1;

// 基准情况 2:硬币用完了或者金额过大
if (index == coins.size() || target < 0) return 0;

// 检查记忆化
if (memo[index][target] != -1) return memo[index][target];

// 决策:
// 1. 使用当前硬币:dfs(index, target - coins[index])
// 2. 跳过当前硬币:dfs(index + 1, target)
int res = dfs(index, target - coins[index], coins, memo)
+ dfs(index + 1, target, coins, memo);

return memo[index][target] = res;
}
};

解动态规划类问题,分析过程是有章可循的,通过对阶段、子问题和状态的拆解基本可以得到解决问题的框架。具体的求解过程一般可以通过推导状态转移方程或填状态转移表这两种方法来实现。

动态规划问题通常伴随着滚动数组的技巧,从而在空间上达到更优,这正是其相对于记忆化递归而言最大的优点,还有一个好处是动态规划避免了递归产生的额外调用栈的性能开销。

递归调用子问题时会出现很多重复的子问题计算。一个显而易见的想法是将已经计算过的子问题结果保存起来以备后面使用。如此处理之后,在下一次遇到同样的子问题时直接返回结果可以大大地降低计算的时间复杂度。这种解题思路被称为带“记忆”的递归调用(也被称为自顶向下的动态规划)。

动态规划-可重复装背包问题-排列数和组合数理解 2025.3.17完全背包问题中,求组合数是外层遍历物品,内层遍历背包容 - 掘金

动态规划:01背包理论基础 | 代码随想录

背包问题总结【0-1背包、完全背包、排列组合问题】_背包问题 排列 组合-CSDN博客

01背包,完全背包,组合排列与滚动数组

1 背包问题

  • 场景:有 $N$ 件物品和一个容量为 $W$ 的背包。每件物品仅有一件,只有“装(1)”或“不装(0)”两种选择。
  • 核心矛盾:在容量有限的情况下,如何抉择才能让价值最大。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
#include <bits/stdc++.h>
using namespace std;

int main() {
int n, bagweight;// bagweight代表行李箱空间

cin >> n >> bagweight;

vector<int> weight(n, 0); // 存储每件物品所占空间
vector<int> value(n, 0); // 存储每件物品价值

for(int i = 0; i < n; ++i) {
cin >> weight[i];
}
for(int j = 0; j < n; ++j) {
cin >> value[j];
}
// dp数组, dp[i][j]代表行李箱空间为j的情况下,从下标为[0, i]的物品里面任意取,能达到的最大价值
vector<vector<int>> dp(weight.size(), vector<int>(bagweight + 1, 0));

// 初始化, 因为需要用到dp[i - 1]的值
// j < weight[0]已在上方被初始化为0
// j >= weight[0]的值就初始化为value[0]
for (int j = weight[0]; j <= bagweight; j++) {
dp[0][j] = value[0];
}

for(int i = 1; i < weight.size(); i++) { // 遍历科研物品
for(int j = 0; j <= bagweight; j++) { // 遍历行李箱容量
if (j < weight[i]) dp[i][j] = dp[i - 1][j]; // 如果装不下这个物品,那么就继承dp[i - 1][j]的值
else {
dp[i][j] = max(dp[i - 1][j], dp[i - 1][j - weight[i]] + value[i]);
}
}
}
cout << dp[n - 1][bagweight] << endl;

return 0;
}

倒序遍历是为了保证物品i只被放入一次。如果一旦正序遍历了,那么物品0就会被重复加入多次

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>
#include <vector>
using namespace std;

int main() {
// 读取 M 和 N
int M, N;
cin >> M >> N;

vector<int> costs(M);
vector<int> values(M);

for (int i = 0; i < M; i++) {
cin >> costs[i];
}
for (int j = 0; j < M; j++) {
cin >> values[j];
}

// 创建一个动态规划数组dp,初始值为0
vector<int> dp(N + 1, 0);

// 外层循环遍历每个类型的研究材料
for (int i = 0; i < M; ++i) {
// 内层循环从 N 空间逐渐减少到当前研究材料所占空间
for (int j = N; j >= costs[i]; --j) {
// 考虑当前研究材料选择和不选择的情况,选择最大值
dp[j] = max(dp[j], dp[j - costs[i]] + values[i]);
}
}

// 输出dp[N],即在给定 N 行李空间可以携带的研究材料最大价值
cout << dp[N] << endl;

return 0;
}

完全背包

  • 场景:基本背景相同,但每种物品都有无限件。只要背包装得下,你可以一直拿同一种物品。
  • 核心矛盾:不再是“拿不拿”,而是“拿几个
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
#include <vector>
using namespace std;

int main() {
int n, bagWeight;
int w, v;
cin >> n >> bagWeight;
vector<int> weight(n);
vector<int> value(n);
for (int i = 0; i < n; i++) {
cin >> weight[i] >> value[i];
}

vector<vector<int>> dp(n, vector<int>(bagWeight + 1, 0));

// 初始化
for (int j = weight[0]; j <= bagWeight; j++)
dp[0][j] = dp[0][j - weight[0]] + value[0];

for (int i = 1; i < n; i++) { // 遍历物品
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
if (j < weight[i]) dp[i][j] = dp[i - 1][j];
else dp[i][j] = max(dp[i - 1][j], dp[i][j - weight[i]] + value[i]);
}
}

cout << dp[n - 1][bagWeight] << endl;

return 0;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
#include <iostream>
#include <vector>
using namespace std;

int main() {
int N, bagWeight;
cin >> N >> bagWeight;
vector<int> weight(N, 0);
vector<int> value(N, 0);
for (int i = 0; i < N; i++) {
int w;
int v;
cin >> w >> v;
weight[i] = w;
value[i] = v;
}

vector<int> dp(bagWeight + 1, 0);
for(int j = 0; j <= bagWeight; j++) { // 遍历背包容量
for(int i = 0; i < weight.size(); i++) { // 遍历物品
if (j - weight[i] >= 0) dp[j] = max(dp[j], dp[j - weight[i]] + value[i]);
}
}
cout << dp[bagWeight] << endl;

return 0;
}

为什么需要滚动数组?

在二维状态 $dp[i][j]$ 中,计算第 $i$ 行只依赖于第 $i-1$ 行。因此我们可以只用一个一维数组 $dp[j]$,通过不断“覆盖”旧值来节省空间

对于纯完全背包问题,其for循环的先后循环是可以颠倒的

求组合数 (Combinations) —— 顺序无关

  • 场景:硬币 {1, 2}{2, 1} 算同一种。
  • 秘籍外层遍历物品,内层遍历容量
  • 逻辑:先算完只用硬币 1 的方案,再往里加硬币 2。这保证了硬币 2 只会出现在硬币 1 之后。

    求排列数 (Permutations) —— 顺序有关

  • 场景:硬币 {1, 2}{2, 1} 算两种(比如爬楼梯,先走 1 阶再走 2 阶不同于先 2 后 1)。

  • 秘籍外层遍历容量,内层遍历物品
  • 逻辑:对于每一个容量 $j$,我们都重新尝试所有的硬币。这样在凑出 3 的时候,既可以从 $dp[3-1]$ 转移(最后一步是 1),也可以从 $dp[3-2]$ 转移(最后一步是 2)。
  1. 物理层:解决“选几次” (01 vs 完全)

这主要体现在内层循环的遍历方向上。

逆序遍历:解决“01 背包”

  • 场景:每个物品只能选 1 次。
  • 逻辑:我们需要用“上一行(即不包含当前物品)”的数据。
  • 原因:因为是从大到小更新,当我们更新 $dp[j]$ 时,$dp[j - weight]$ 还是旧的值,还没有被当前物品污染过。

正序遍历:解决“完全背包”

  • 场景:每个物品可以选无限次。
  • 逻辑:我们需要用“当前行(即已经考虑过放入当前物品)”的数据。
  • 原因:从小到大更新,更新 $dp[j]$ 时,$dp[j - weight]$ 可能已经在本轮循环中放入过当前物品了,现在是在它的基础上“再多拿一个”。
  1. 逻辑层:解决“怎么排” (组合 vs 排列)

这主要体现在内、外层循环谁包着谁

外层物品,内层容量 $\rightarrow$ 组合 (Combination)

  • 适用问题{1, 5}{5, 1} 算同一种方案。

  • 代表题:LeetCode 518. 零钱兑换 II。

  • 底层原理

    我们是挨个处理物品的。处理完 1 号硬币,再处理 2 号。这就意味着,在任何一个状态下,硬币的编号只能是递增的。你不可能在放入 2 号硬币后的某个状态里,又跑回去拿 1 号硬币。

    结论:这种顺序强制去掉了“乱序”的可能性,只留下唯一的一种组合。

外层容量,内层物品 $\rightarrow$ 排列 (Permutation)

  • 适用问题{1, 5}{5, 1} 算两种不同的方案。

  • 代表题:LeetCode 377. 组合总和 Ⅳ(虽然叫组合总和,其实求的是排列)。

  • 底层原理

    对于每一个容量 $j$,我们都把所有物品拿出来试一遍。

    比如容量为 6:

    • 它可以从 $dp[6-1]$ 走一步 1 过来(最后一步是 1)。

    • 它可以从 $dp[6-5]$ 走一步 5 过来(最后一步是 5)。

      只要最后一步不同,就是不同的路径。

    结论:这种顺序允许同一个容量被不同的“最后一步”推导出来,从而计入所有排列。

极值问题:对顺序“迟钝”

  • 适用问题:求“最少硬币数”或“最大价值”。
  • 代表题:LeetCode 322. 零钱兑换(求最少硬币数)。
  • 现象:你会发现,求最少硬币数时,外层是物品还是容量,代码都能过。
  • 原因:极值问题($\min / \max$)只关心最终结果,不关心你是通过 {1, 5} 还是 {5, 1} 凑出来的。反正它们凑出来的硬币数都是 2,取 $\min$ 的结果是一样的。

当你看到一道背包题,请按以下步骤决定你的 for 循环:

步骤提问决定
1. 看次数物品只能用一次吗?:内层逆序;:内层正序。
2. 看性质是求最大价值吗?:外层物品/容量皆可,通常选外层物品(逻辑更顺)。
3. 看顺序是求方案数且 {1,2} != {2,1} 吗?:外层容量,内层物品。
4. 看顺序是求方案数且 {1,2} == {2,1} 吗?:外层物品,内层容量。

单词拆分

给你一个字符串 s 和一个字符串列表 wordDict 作为字典。如果可以利用字典中出现的一个或多个单词拼接出 s 则返回 true注意:不要求字典中出现的单词全部都使用,并且字典中的单词可以重复使用。

定义 dp[i] 表示字符串 si 个字符组成的字符串 s[0..i−1] 是否能被空格拆分成若干个字典中出现的单词。

dp[i]=dp[j] && check(s[j..i−1])

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
bool wordBreak(string s, vector<string>& wordDict) {
int sz = s.size();
// dp[j]表示字符串s[0-j]能否被字典中单词拼接
vector<bool> dp(sz + 1);
dp[0] = true;
unordered_set<string> wordSet(wordDict.begin(),wordDict.end());
// dp[j] = dp[j-word.size()] || word exist
for(int i = 1;i<=sz;i++) {
for(int j = 0;j<i;j++) {
string word = s.substr(j,i-j);
if(wordSet.count(word)) {
dp[i] = dp[i] || dp[j];
}
}
}
return dp[sz];
}
};

给定一个字符串 s 和一个字符串字典 wordDict ,在字符串 s 中增加空格来构建一个句子,使得句子中所有的单词都在词典中。以任意顺序 返回所有这些可能的句子。注意:词典中的同一个单词可能在分段中被重复使用多次。

对于字符串 s,如果某个前缀是单词列表中的单词,则拆分出该单词,然后对 s 的剩余部分继续拆分。如果可以将整个字符串 s 拆分成单词列表中的单词,则得到一个句子。在对 s 的剩余部分拆分得到一个句子之后,将拆分出的第一个单词(即 s 的前缀)添加到句子的头部,即可得到一个完整的句子。上述过程可以通过回溯实现。

假设字符串 s 的长度为 n,回溯的时间复杂度在最坏情况下高达 O(n )。时间复杂度高的原因是存在大量重复计算,可以通过记忆化的方式降低时间复杂度。

具体做法是,使用哈希表存储字符串 s 的每个下标和从该下标开始的部分可以组成的句子列表,在回溯过程中如果遇到已经访问过的下标,则可以直接从哈希表得到结果,而不需要重复计算。如果到某个下标发现无法匹配,则哈希表中该下标对应的是空列表,因此可以对不能拆分的情况进行剪枝优化。

还有一个可优化之处为使用哈希集合存储单词列表中的单词,这样在判断一个字符串是否是单词列表中的单词时只需要判断该字符串是否在哈希集合中即可,而不再需要遍历单词列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public:
unordered_map<int, vector<string>> memo;
vector<string> dfs(string& s, int index, unordered_set<string>& wordSet) {
if (index == s.size()) {
return {""};
}
if (memo.count(index)) {
return memo[index];
}
vector<string> res;
for (int j = index; j < s.size(); j++) {
string str = s.substr(index, j - index + 1);
if (wordSet.count(str)) {
auto ans = dfs(s, j + 1, wordSet);
for (auto& sstr : ans) {
if (sstr == "") {
res.push_back(str);
} else {
res.push_back(str + " " + sstr);
}
}
}
}
memo[index] = res;
return res;
}
vector<string> wordBreak(string s, vector<string>& wordDict) {
unordered_set<string> wordSet(wordDict.begin(), wordDict.end());
return dfs(s, 0, wordSet);
}
};

股票系列

给定一个数组 prices ,它的第 i 个元素 prices[i] 表示一支给定股票第 i 天的价格。

你只能选择 某一天 买入这只股票,并选择在 未来的某一个不同的日子 卖出该股票。设计一个算法来计算你所能获取的最大利润。返回你可以从这笔交易中获取的最大利润。如果你不能获取任何利润,返回 0

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
int maxProfit(vector<int>& prices) {
// 最大值减去最小值
int min_price{INT_MAX};
int ans{};
for(int i = 0;i<prices.size();i++) {
min_price = min(min_price,prices[i]);
ans = max(prices[i] - min_price,ans);
}
return ans;
}
};

每次遍历,相对于比较当前价格与其前边的每个价格来寻找最大差值max_diff,我们只需要对比前面出现过的那个最小值即可。定义并使用变量min_price来保存遇到的最小价格,将循环内操作的时间复杂度降低至常数阶,算法的总体时间复杂度也就降至为O(n)。

一个整数数组 prices ,其中 prices[i] 表示某支股票第 i 天的价格。在每一天,你可以决定是否购买和/或出售股票。你在任何时候 最多 只能持有 一股 股票。然而,你可以在 同一天 多次买卖该股票,但要确保你持有的股票不超过一股。返回 你能获得的 最大 利润

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int maxProfit(vector<int>& prices) {
int sz = prices.size();
vector<vector<int>> dp(sz, vector<int>(2));
// dp[i][0]表示当前不持有的最大利润
// dp[i][1]表示当前持有的最大利润
dp[0][1] = -prices[0];
for (int i = 1; i < sz; i++) {
// 卖出
dp[i][0] = max(dp[i - 1][0], prices[i] + dp[i - 1][1]);
// 买入
dp[i][1] = max(dp[i - 1][1], -prices[i] + dp[i - 1][0]);
}
return dp[sz - 1][0];
}
};

注意到上面的状态转移方程中,每一天的状态只与前一天的状态有关,而与更早的状态都无关,因此我们不必存储这些无关的状态,只需要将 dp[i−1][0] 和 dp[i−1][1] 存放在两个变量中,通过它们计算出 dp[i][0] 和 dp[i][1] 并存回对应的变量,以便于第 i+1 天的状态转移即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
int maxProfit(vector<int>& prices) {
int sz = prices.size();
vector<vector<int>> dp(sz, vector<int>(2));
// dp[i][0]表示当前不持有的最大利润
// dp[i][1]表示当前持有的最大利润
int soldPrice{};
int buyPrice = -prices[0];
for (int i = 1; i < sz; i++) {
int tmp = soldPrice;
// 卖出
soldPrice = max(soldPrice,buyPrice+prices[i]);
// dp[i][0] = max(dp[i - 1][0], prices[i] + dp[i - 1][1]);
// 买入
buyPrice = max(buyPrice,-prices[i]+tmp);
// dp[i][1] = max(dp[i - 1][1], -prices[i] + dp[i - 1][0]);
}
return soldPrice;
}
};

给定一个整数数组 prices,其中 prices[i]表示第 i 天的股票价格 ;整数 fee 代表了交易股票的手续费用。

你可以无限次地完成交易,但是你每笔交易都需要付手续费。如果你已经购买了一个股票,在卖出它之前你就不能再继续购买股票了。返回获得利润的最大值。

注意:这里的一笔交易指买入持有并卖出股票的整个过程,每笔交易你只需要为支付一次手续费。

考虑 dp[i][0] 的转移方程,如果这一天交易完后手里没有股票,那么可能的转移状态为前一天已经没有股票,即 dp[i−1][0],或者前一天结束的时候手里持有一支股票,即 dp[i−1][1],这时候我们要将其卖出,并获得 prices[i] 的收益,但需要支付 fee 的手续费。因此为了收益最大化,我们列出如下的转移方程:

dp[i][0]=max{dp[i−1][0],dp[i−1][1]+prices[i]−fee}
再来按照同样的方式考虑 dp[i][1] 按状态转移,那么可能的转移状态为前一天已经持有一支股票,即 dp[i−1][1],或者前一天结束时还没有股票,即 dp[i−1][0],这时候我们要将其买入,并减少 prices[i] 的收益。可以列出如下的转移方程:

dp[i][1]=max{dp[i−1][1],dp[i−1][0]−prices[i]}
对于初始状态,根据状态定义我们可以知道第 0 天交易结束的时候有 dp[0][0]=0 以及 dp[0][1]=−prices[0]。

因此,我们只要从前往后依次计算状态即可。由于全部交易结束后,持有股票的收益一定低于不持有股票的收益,因此这时候 dp[n−1][0] 的收益必然是大于 dp[n−1][1] 的,最后的答案即为 dp[n−1][0]。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public:
int maxProfit(vector<int>& prices, int fee) {
int n = prices.size();
vector<vector<int>> dp(n, vector<int>(2));
dp[0][0] = 0, dp[0][1] = -prices[0];
for (int i = 1; i < n; ++i) {
dp[i][0] = max(dp[i - 1][0], dp[i - 1][1] + prices[i] - fee);
dp[i][1] = max(dp[i - 1][1], dp[i - 1][0] - prices[i]);
}
return dp[n - 1][0];
}
};
//注意到在状态转移方程中,dp[i][0] 和 dp[i][1] 只会从 dp[i−1][0] 和 dp[i−1][1] 转移而来,因此我们不必使用数组存储所有的状态,而是使用两个变量 sell 以及 buy 分别表示 dp[..][0] 和 dp[..][1] 直接进行状态转移即可。

class Solution {
public:
int maxProfit(vector<int>& prices, int fee) {
int sz = prices.size();
// vector<int> buyPrice(sz);
int soldPrice{};
int buyPrice = -prices[0];
for(int i =1;i<prices.size();i++) {
// 状态压缩
int newSoldPrice = max(soldPrice,buyPrice-fee+prices[i]);
int newBuyPrice = max(buyPrice,soldPrice-prices[i]);
soldPrice = newSoldPrice;
buyPrice = newBuyPrice;
}
return soldPrice;
}
};

给定一个整数数组prices,其中第 prices[i] 表示第 *i* 天的股票价格 。

设计一个算法计算出最大利润。在满足以下约束条件下,你可以尽可能地完成更多的交易(多次买卖一支股票):卖出股票后,你无法在第二天买入股票 (即冷冻期为 1 天)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Solution {
public:
int maxProfit(vector<int>& prices) {
int sz= prices.size();
// vector<vector<int>> dp(sz,vector<int>(3));
int dp0{},dp1{},dp2{};
// dp[i][0] 买入状态
// dp[i][1] 卖出状态 不包括冷冻期
// dp[i][2] 冷冻期
dp0 = -prices[0];
for(int i = 1;i<sz;i++) {
// 买入状态可由 买入或者冷冻期转换
// dp[i][0] = max(dp[i-1][0],dp[i-1][2]-prices[i]);
int newDp0 = max(dp0,dp2-prices[i]);
int newDp1 = max(dp1,dp0+prices[i]);
int newDp2 = dp1;
dp0 = newDp0;
dp1 = newDp1;
dp2 = newDp2;
// 卖出状态可由买入或者卖出转换
// dp[i][1] = max(dp[i-1][1],dp[i-1][0]+prices[i]);
// 冷冻期收益就是前一天卖出时收益
// dp[i][2] = dp[i-1][1];
}
return max(dp1,dp2);
}
};

给你一个整数数组 prices 和一个整数 k ,其中 prices[i] 是某支给定的股票在第 i 天的价格。

设计一个算法来计算你所能获取的最大利润。你最多可以完成 k 笔交易。也就是说,你最多可以买 k 次,卖 k 次。注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Solution {
public:
int maxProfit(int k, vector<int>& prices) {
if (prices.empty()) {
return 0;
}

int n = prices.size();
k = min(k, n / 2);
vector<vector<int>> buy(n, vector<int>(k + 1, -1e9));
vector<vector<int>> sell(n, vector<int>(k + 1, -1e9));
if (k >= 1) {
buy[0][1] = -prices[0];
}
sell[0][0] = 0;
for (int i = 1; i < n; ++i) {
sell[i][0] = 0;
for (int j = 1; j <= k; ++j) {
buy[i][j] = max(buy[i - 1][j], sell[i - 1][j - 1] - prices[i]);
sell[i][j] = max(sell[i - 1][j], buy[i - 1][j] + prices[i]);
}
}

// 最终结果在最后一天完成各种交易次数中的最大值
return *max_element(sell[n - 1].begin(), sell[n - 1].end());
}
};

每一笔交易都包含“买入”和“卖出”两个动作。对于最多 $k$ 次交易,我们在每一天可能处于以下 $2k$ 种状态之一:

  • buy[j]:第 $j$ 次持有股票(已经买入,还没卖出)。
  • sell[j]:第 $j$ 次卖出股票(手里是空的,且已经完成了 $j$ 次交易)。

这里 $j$ 的取值范围是 $1$ 到 $k$。

对于每一天的价格 price,我们要更新这 $2k$ 个状态:

  1. 第 $j$ 次持有 (buy[j])

    • 保持现状:昨天就持有第 $j$ 次的股票。
    • 今天买入:由上一次交易完成的状态 sell[j-1] 减去今天的价格。
    • 公式:buy[j] = max(buy[j], sell[j-1] - price)
  2. 第 $j$ 次卖出 (sell[j])

    • 保持现状:昨天就已经完成了第 $j$ 次交易。
    • 今天卖出:由本次持有的状态 buy[j] 加上今天的价格。
    • 公式:sell[j] = max(sell[j], buy[j] + price)

    核心思维是一致的:用状态记录你的“处境”,用转移方程描述“动作”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Solution {
public:
int maxProfit(int k, vector<int>& prices) {
int sz = prices.size() ;
k = min(k, sz/2);
vector<int> buy(k + 1, INT_MIN);
vector<int> sell(k + 1);
for (int i = 0; i < sz; i++) {
for (int j = 1; j <= k; j++) {
buy[j] = max(sell[j - 1] - prices[i], buy[j]);
sell[j] = max(prices[i] + buy[j], sell[j]);
}
}
return sell[k];
}
};

给定一个数组,它的第 i 个元素是一支给定的股票在第 i 天的价格。设计一个算法来计算你所能获取的最大利润。你最多可以完成 两笔 交易。注意:你不能同时参与多笔交易(你必须在再次购买前出售掉之前的股票)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
public:
int maxProfit(vector<int>& prices) {
int n = prices.size();
int buy1 = -prices[0], sell1 = 0;
int buy2 = -prices[0], sell2 = 0;
for (int i = 1; i < n; ++i) {
buy1 = max(buy1, -prices[i]);
sell1 = max(sell1, buy1 + prices[i]);
buy2 = max(buy2, sell1 - prices[i]);
sell2 = max(sell2, buy2 + prices[i]);
}
return sell2;
}
};

class Solution {
public:
int maxProfit(vector<int>& prices) {
vector<int> buy(3, INT_MIN);
vector<int> sell(3);
for (int i = 0; i < prices.size(); i++) {
for (int j = 1; j <= 2; j++) {
buy[j] = max(buy[j], -prices[i] + sell[j - 1]);
sell[j] = max(sell[j], prices[i] + buy[j]);
}
}
return sell[2];
}
};

给你两个下标从 0 开始的数组 presentfuturepresent[i]future[i] 分别代表第 i 支股票现在和将来的价格。每支股票你最多购买 一次 ,你的预算为 budget 。求最大的收益。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
int maximumProfit(vector<int>& present, vector<int>& future, int budget) {
// 0/1背包问题
// 容量为i时最大收益
vector<int> dp(budget+1);
int sz = present.size();
vector<int> value(sz);
for(int i =0;i<sz;i++) {
value[i] = future[i] - present[i];
}
for(int i = 0;i<sz;i++) {
for(int j = budget;j>=0;j--) {
if(j>=present[i]) {
dp[j] = max(dp[j],dp[j-present[i]]+value[i]);
}
}
}
return dp[budget];
}
};

当股票收益最大时,应购买哪些股票?如果有多种方案,返回其中任意一种。

要找出具体的股票清单(路径回溯),我们不能直接使用状态压缩后的一维数组,因为一维数组丢弃了“这个最优值是从哪个物品转移过来”的信息。我们需要使用 二维数组 dp[i][j]

回溯算法:

  1. 从最后一支股票 $n$ 和总预算 budget 开始。

  2. 如果 $dp[i][j] == dp[i-1][j]$,说明第 $i$ 支股票没买,继续检查 $dp[i-1][j]$。

  3. 如果 $dp[i][j] \neq dp[i-1][j]$,说明第 $i$ 支股票买了,记录下标 $i$,然后剩余预算变为 $j - \text{present}[i]$,继续检查 $dp[i-1][j - \text{present}[i]]$。

  4. ```c++
    class Solution {
    public:

    pair<int, vector<int>> getStocks(vector<int>& present, vector<int>& future, int budget) {
        int n = present.size();
        // dp[i][j] 表示前 i 支股票在预算 j 下的最大收益
        vector<vector<int>> dp(n + 1, vector<int>(budget + 1, 0));
    
        for (int i = 1; i <= n; ++i) {
            int cost = present[i-1];
            int profit = max(0, future[i-1] - present[i-1]);
            for (int j = 0; j <= budget; ++j) {
                if (j >= cost) {
                    dp[i][j] = max(dp[i-1][j], dp[i-1][j - cost] + profit);
                } else {
                    dp[i][j] = dp[i-1][j];
                }
            }
        }
    
        // 回溯找出购买的股票下标
        vector<int> selectedStocks;
        int curBudget = budget;
        for (int i = n; i >= 1; --i) {
            // 如果当前最大收益不等于“不买这支股票时的收益”,说明买了
            if (dp[i][curBudget] > dp[i-1][curBudget]) {
                selectedStocks.push_back(i - 1); // 存入下标
                curBudget -= present[i-1];
            }
        }
    
        return {dp[n][budget], selectedStocks};
    }
    

    };

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56



    # 滑动窗口

    滑动窗口是双指针的特殊应用,该题型本身并不复杂,但有些具体细节需要注意。滑动窗口常用于解决数组、字符串的子元素问题,它可以将嵌套的循环展开,通过减少内层循环次数来降低算法的时间复杂度。滑动窗口类题目通常需要用到双指针,还可能用到其他的数据结构,比如哈希表、队列。

    按照滑动窗口的窗口大小是否固定,以及可变窗口中求最大窗口还是最小窗口,可以分类如下。

    ● 固定窗口类型

    ● 可变窗口类型。此类题目不会给出窗口大小,而是求符合条件的最大窗口或最小窗口。

    ➢ 求最大窗口。例如第424题。

    ➢ 求最小窗口。例如第72题。

    通常会使用双指针来界定窗口的边界,两个指针之间的部分属于窗口内,反之属于窗口外。固定窗口类型的题目,两个指针要同时移动;而可变窗口类型的题目,则移动其中一个指针来实现窗口大小的变化。

    滑动窗口类型的题目是有“套路”可循的,用两个指针分别表示窗口的左右端点,然后右指针不断地去扩充右侧窗口边界,左指针不断地缩小左边窗口边界,同时维护窗口的信息,在这个过程中不断判断窗口信息是否满足条件,如果是,则更新答案;如果不是,则继续移动窗口(收缩、扩展或平移)。

    **滑动窗口最大值**

    给你一个整数数组 `nums`,有一个大小为 `k` 的滑动窗口从数组的最左侧移动到数组的最右侧。你只可以看到在滑动窗口内的 `k` 个数字。滑动窗口每次只向右移动一位。

    返回 *滑动窗口中的最大值*

    ```c++
    class Solution {
    public:
    vector<int> maxSlidingWindow(vector<int>& nums, int k) {
    // 固定窗口
    // 每次加入一个值 删除一个值
    vector<int> res;
    list<int> lst; // 维护最大值
    int sz = nums.size();
    for(int i = 0;i<k;i++) {
    while(!lst.empty() && nums[i]>lst.back()) {
    lst.pop_back();
    }
    lst.push_back(nums[i]);
    }
    res.push_back(lst.front());
    for(int i = k;i<sz;i++) {
    while(!lst.empty() && nums[i]>lst.back()) {
    lst.pop_back();
    }
    lst.push_back(nums[i]);
    if(nums[i-k] == lst.front()) {
    lst.pop_front();
    }
    res.push_back(lst.front());
    }
    return res;
    }
    };

最小覆盖子串

给定两个字符串 st,长度分别是 mn,返回 s 中的 最短窗口 子串,使得该子串包含 t 中的每一个字符(包括重复字符)。如果没有这样的子串,返回空字符串 ""

测试用例保证答案唯一。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
class Solution {
public:
string minWindow(string s, string t) {
unordered_map<char, int> cnts;
int targetCnt{};
for (auto& ch : t) {
cnts[ch]++;
targetCnt++;
}
// 计算窗口内有效字符
int slen{INT_MAX};
int start_pos{};
int totalCnt{};
int left{};
for (int i = 0; i < s.size(); i++) {
if (cnts.count(s[i])) {
// 还有剩余字符
if (cnts[s[i]] >= 1) {
// 剩余字符个数大于0,有效字符+1
totalCnt++;
}
cnts[s[i]]--;
while (left <= i && totalCnt == targetCnt) {
int len = i - left + 1;
if (len < slen) {
start_pos = left;
slen = len;
}
if (cnts.count(s[left])) {
if (cnts[s[left]] >= 0) {
totalCnt--;
}
cnts[s[left]]++;
}
left++;
}
}
}
return INT_MAX == slen ? "" : s.substr(start_pos, slen);
}
};

替换后的最长重复字符

你一个字符串 s 和一个整数 k 。你可以选择字符串中的任一字符,并将其更改为任何其他大写英文字符。该操作最多可执行 k 次。在执行上述操作后,返回 包含相同字母的最长子字符串的长度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
int characterReplacement(string s, int k) {
// 可以跳过k次
unordered_map<char, int> cnts;
int ans{};
int left{};
int max_cnt{};
for (int i = 0; i < s.size(); i++) {
cnts[s[i]]++;
max_cnt = max(max_cnt, cnts[s[i]]);
while(left<=i && i-left+1>max_cnt+k) {
//移动左窗口 不满足要求
cnts[s[left]]--;
left++;
}
ans = max(ans,i-left+1);
}
return ans;
}
};

字符串的排列

给你两个字符串 s1s2 ,写一个函数来判断 s2 是否包含 s1 的 排列。如果是,返回 true ;否则,返回 false 。换句话说,s1 的排列之一是 s2子串

此问题的关键点是,如果能在S2中找到一个子串的长度与S1相等,并且S1中每个字符对应的个数与S2中这个子串的每个字符对应的个数相等,那S2就一定包含S1的一个排列。因此可以使用与S1等长的滑动窗口,判断S2在这个窗口内的字符出现个数和S1的字符出现个数是否相等。又因为题目中给出了条件,所有的字符都是小写字母,因此可以通过哈希表来统计每个字符出现的个数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Solution {
public:
bool checkInclusion(string s1, string s2) {
int sz = s1.size();
if(sz>s2.size()) {
return false;
}
vector<int> cnts(26);
for (auto& ch : s1) {
cnts[ch - 'a']++;
}
vector<int> targets(26);
for (int i = 0; i < s1.size(); i++) {
targets[s2[i] - 'a']++;
}
if (targets == cnts) {
return true;
}
for (int i = sz; i < s2.size(); i++) {
targets[s2[i] - 'a']++;
targets[s2[i - sz] - 'a']--;
if (targets == cnts) {
return true;
}
}
return false;
}
};

滑动窗口大多用于解决数组、字符串、链表的子区间题型,如果题目中出现求解子数组、子串,或者寻找符合某个特征的子数组、子串问题,就可以考虑使用滑动窗口的方法。滑动窗口问题的优化思路主要包括:是否可以通过某个条件来缩减检测的子序列个数;是否可以通过使用特定的数据结构(如字典)来降低检测子序列的某个特征是否符合条件的复杂度。其中,缩减子序列的方法包括改变序列的大小、边界的提前退出等。

image-20260218195651652

博弈问题

石子游戏

lice 和 Bob 用几堆石子在做游戏。一共有偶数堆石子,排成一行;每堆都有 整数颗石子,数目为 piles[i] 。游戏以谁手中的石子最多来决出胜负。石子的 总数奇数 ,所以没有平局。

Alice 和 Bob 轮流进行,Alice 先开始 。 每回合,玩家从行的 开始结束 处取走整堆石头。 这种情况一直持续到没有更多的石子堆为止,此时手中 石子最多 的玩家 获胜

假设 Alice 和 Bob 都发挥出最佳水平,当 Alice 赢得比赛时返回 true ,当 Bob 赢得比赛时返回 false

1
return true;

预测赢家

给你一个整数数组 nums 。玩家 1 和玩家 2 基于这个数组设计了一个游戏。

玩家 1 和玩家 2 轮流进行自己的回合,玩家 1 先手。开始时,两个玩家的初始分值都是 0 。每一回合,玩家从数组的任意一端取一个数字(即,nums[0]nums[nums.length - 1]),取到的数字将会从数组中移除(数组长度减 1 )。玩家选中的数字将会加到他的得分上。当数组中没有剩余数字可取时,游戏结束。

如果玩家 1 能成为赢家,返回 true 。如果两个玩家得分相等,同样认为玩家 1 是游戏的赢家,也返回 true 。你可以假设每个玩家的玩法都会使他的分数最大化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
bool predictTheWinner(vector<int>& nums) {
int n = nums.size();
// dp[i][j]表示还剩nums[i-j]时最大差异
vector<vector<int>> dp(n,vector<int>(n));
for(int i = n-2;i>=0;i--) {
for(int j = i;j<n;j++) {
if(j == i) {
dp[i][j] = nums[i];
}else{
dp[i][j] = max(nums[i] - dp[i+1][j],nums[j] - dp[i][j-1]);
}
}
}
return dp[0][n-1]>=0;
}
};

猜数字游戏

我们正在玩一个猜数游戏,游戏规则如下:

  1. 我从 1n 之间选择一个数字。
  2. 你来猜我选了哪个数字。
  3. 如果你猜到正确的数字,就会 赢得游戏
  4. 如果你猜错了,那么我会告诉你,我选的数字比你的 更大或者更小 ,并且你需要继续猜数。
  5. 每当你猜了数字 x 并且猜错了的时候,你需要支付金额为 x 的现金。如果你花光了钱,就会 输掉游戏

给你一个特定的数字 n ,返回能够 确保你获胜 的最小现金数,不管我选择那个数字

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
int getMoneyAmount(int n) {
vector<vector<int>> dp(1 + n, vector<int>(1 + n));
// dp[i][j]
for (int i = n - 1; i >= 1; i--) {
for (int j = 1 + i; j <= n; j++) {
dp[i][j] = j+dp[i][j-1];
for (int k = i; k < j; k++) {
dp[i][j] =
min(dp[i][j], k + max(dp[i][k - 1], dp[k + 1][j]));
}
}
}
return dp[1][n];
}
};

实现方面,创建行数和列数都是 n+1 的二维数组 f,其中 f[i][j] 即为状态 f(i,j)。在根据状态转移方程计算时需要注意下标的边界问题,当 j=n 时,如果 k=j 则 k+1>n,此时 f[k][j] 会出现下标越界。为了避免出现下标越界,计算 f[i][j] 的方法是:首先令 f[i][j]=j+f[i][j−1],然后遍历 i≤k<j 的每个 k,更新 f[i][j] 的值。

image-20260219112111053

分治法

分治法是一种很重要的算法,属于五大常用算法之一。其字面意思是“分而治之”,具体可以分为3个步骤。 

“分”指的是将一个复杂的问题分成多个性质相同但规模更小的子问题,而子问题同样能够继续分解直到能够被解决。 

  1. “治”指的是对子问题分别进行处理。

“合”就是将子问题的解进行合并,从而得到原问题的解。

与动态规划一样,分治法很大程度上也基于递归的思想,两者的区别在于动态规划分解后的子问题是有重复的(重叠子问题性质),而分治法的子问题通常不会重复。因此,分治法所能解决的问题一般具有以下几个特征。1.问题的规模缩小到一定程度后可以被很容易地解决。

2.问题可以分解为若干个规模较小的相同性质的问题。

3.问题的解等于子问题解的合并。

4.问题分解的各个子问题相互独立,没有重复。

上述前3点决定了问题能否通过分治法来解决。而最后一点涉及分治法的效率,原因在于如果各个子问题不相互独立,则会产生重复的工作,此时虽然可以使用分治法,但使用动态规划效率会更高。

下面我们列出常见的可以使用分治法的经典问题

● 二分搜索。

● 大整数的乘法。

核心思路(分→治→合)

传统大数乘法(逐位相乘再相加)时间复杂度是 O(n2),而 Karatsuba 算法通过分治将复杂度降到 O(nlog23)≈O(n1.585),核心步骤:

  1. 分(Divide)

    设两个 n 位大数 x和 y,拆分为高位和低位:x = a 10^(n/2) + b,y = c 10^(n/2) + d

    (比如 x=1234,拆为 a=12,b=34;y=5678,拆为 c=56,d=78)

  2. 治(Conquer)

    递归计算 3 个子问题(而非传统的 4 个,这是优化核心):

    • ac = 分治乘法(a, c)
    • bd = 分治乘法(b, d)
    • ad_bc = 分治乘法(a+b, c+d) - ac - bd
  3. 合(Combine)

    原结果 = ac 10^n + ad_bc 10^(n/2) + bd

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
def karatsuba(x: int, y: int) -> int:
# 基准情况:数字足够小时直接相乘
if x < 10 or y < 10:
return x * y

# 确定拆分的位数(取两个数的最大位数的一半)
n = max(len(str(x)), len(str(y)))
half = n // 2

# 拆分x和y为高位a/b,低位c/d
a, b = divmod(x, 10**half)
c, d = divmod(y, 10**half)

# 递归计算3个子问题
ac = karatsuba(a, c)
bd = karatsuba(b, d)
ad_bc = karatsuba(a + b, c + d) - ac - bd

# 合并结果
return ac * 10**(2*half) + ad_bc * 10**half + bd

● strassen矩阵乘法。

image-20260223131535419

● 棋盘覆盖问题。● 归并排序和快速排序。● 最接近点对问题。● 汉诺塔问题。

合并K个升序链表

给你一个链表数组,每个链表都已经按升序排列。请你将所有链表合并到一个升序链表中,返回合并后的链表.

用分治的方法进行合并。

将 k 个链表配对并将同一对中的链表合并;
第一轮合并以后, k 个链表被合并成了 k/2个链表,然后是k/4个链表等等.

核心逻辑:

  • lists 分为左右两半。
  • 递归处理左半部分,递归处理右半部分。
  • 最后调用“合并两个有序链表”的函数将两部分合并
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
class Solution {
public:
ListNode* mergeTwoLists(ListNode* node1, ListNode* node2) {
ListNode* dummy = new ListNode;
ListNode* cur = dummy;
while (node1 && node2) {
if (node1->val < node2->val) {
cur->next = node1;
node1 = node1->next;
} else {
cur->next = node2;
node2 = node2->next;
}
cur = cur->next;
}
if (node1) {
cur->next = node1;
}
if (node2) {
cur->next = node2;
}
return dummy->next;
}
ListNode* mergeLists(int left, int right, vector<ListNode*>& lists) {
if (left > right) {
return nullptr;
}
if (left == right) {
return lists[left];
}
int mid = (right - left) / 2 + left;
ListNode* l1 = mergeLists(left, mid, lists);
ListNode* l2 = mergeLists(mid + 1, right, lists);
return mergeTwoLists(l1, l2);
}
ListNode* mergeKLists(vector<ListNode*>& lists) {
int sz = lists.size();
if (sz == 0) {
return nullptr;
}
ListNode* res = mergeLists(0, sz - 1, lists);
return res;
}
};
class Solution {
public:
ListNode* mergeKLists(vector<ListNode*>& lists) {
auto comp = [](ListNode* a, ListNode* b) { return a->val > b->val; };
// 最小堆
priority_queue<ListNode*, vector<ListNode*>, decltype(comp)> pq(comp);

for (auto l : lists) {
if (l) {
pq.push(l);
}
}
ListNode *dummy = new ListNode, *cur = dummy;
while (!pq.empty()) {
cur->next = pq.top();
pq.pop();
cur = cur->next;
if (cur->next) {
pq.push(cur->next);
}
}
return dummy->next;
}
};

数组中的第K个最大元素

给定整数数组 nums 和整数 k,请返回数组中第 **k** 个最大的元素。请注意,你需要找的是数组排序后的第 k 个最大的元素,而不是第 k 个不同的元素。你必须设计并实现时间复杂度为 O(n) 的算法解决此问题。

在计算机科学中,快速选择(quick select)作为一种在无序列表中获得第k小元素的选择算法,是分治思想的经典应用之一。它和快速排序(quick sort)算法一样,都来自Tony Hoare,因此也被称为Hoare’s selection algorithm。快速排序算法在实践中拥有非常好的平均性能,很多工业界的排序算法都有其身影(比如各种高级编程语言官方库中的排序方法)。与快速排序算法思想相近的快速选择算法及其变种,同样拥有非常好的平均性能,但缺点也是类似的,即该算法在最坏情况下性能很差。回归到题目,求第k大元素可以直接转换成求第n-k+1小的元素,这里的n是数组的大小,也就是说我们可以在这里使用快速选择算法

速选择算法和快速排序算法共用了partition子过程,也就是分治法中的分解操作。而两者之间的区别在于,快速排序算法会将问题划分为两个子问题分开递归解决,其只需要递归处理一个子问题即可。快速选择算法的逻辑很简单,如下所示。1.随机选择一个pivot(支点)。2.使用partition子过程将pivot放在数组中合适的位置,将其设为pos。partition的作用就是将小于pivot的元素移到左边,大于或等于pivot的元素移到右边。

partition之后,我们先判断pos是否是想要的结果,如果是,则直接得到答案(这里也是上面讲述分治法时提到的可以直接求解的最简单子问题)。

如果不是,判断答案是在pos的左边还是右边,然后在新的范围中重复步骤1和2。这就是将问题拆分成子问题,并对子问题进行递归处理的过程。这里随机选择一个pivot是为了尽量降低快速选择算法中最坏情况发生的可能性。另外一种技巧是随机化打乱数组的预处理。

通常来讲,有两种partition方式:Lomuto partition和Hoare partition。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
public:
int partition(vector<int>& nums, int left, int right, int k) {
if (left == right) {
return nums[left];
}
int i = left - 1, j = right + 1;
int pivot = nums[left];
while (true) {
do {
i++;
} while (nums[i] < pivot);
do {
j--;
} while (nums[j] > pivot);
if (i >= j) {
break;
}
swap(nums[i], nums[j]);
}
if (k <= j) {
return partition(nums, left, j, k);
} else {
return partition(nums, j + 1, right, k);
}
}
int findKthLargest(vector<int>& nums, int k) {
return partition(nums, 0, nums.size() - 1, nums.size() - k);
}
};

Hoare 分区的特点是:

  • 基准值最终可能在任意位置
  • 只保证 left..j的元素 ≤ 基准值
  • 只保证 j+1..right的元素 ≥ 基准值
  • 为什么 Hoare 分区可以用于快速选择?关键在于:不需要知道 pivot 的确切位置,只需要知道目标元素 k在哪一边分区即可。
1
2
3
4
5
6
// 分区后:j 是分界点
// [left .. j] [j+1 .. right]
// ≤ pivot ≥ pivot

if (k <= j) search left; // 注意:包含 j!
else search right;

lumoto分区特点. 基准值放在正确位置,对重复元素处理较差

1
2
3
4
5
6
7
// 分区后:pivot 在最终位置 p
// [left .. p-1] [p] [p+1 .. right]
// ≤ pivot pivot ≥ pivot

if (k == p) return nums[p];
else if (k < p) search left;
else search right; // 注意:k > p
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
int lomutoPartition(vector<int>& arr, int low, int high) {
int pivot = arr[high]; // 选最后一位
int i = low - 1;
for (int j = low; j < high; j++) {
if (arr[j] <= pivot) {
i++;
swap(arr[i], arr[j]);
}
}
swap(arr[i + 1], arr[high]); // 把 pivot 放到中间
return i + 1;
}
int quickSelect2(vector<int>&nums,int l,int r) {
int target = nums[l];
int i = l,j = r;
while(i<j) {
while(i<j && nums[j]>=target) {
j--;
}
while(i<j && nums[i]<=target) {
i++;
}
if(i>=j) {
break;
}
swap(nums[i],nums[j]);
}
swap(nums[i],nums[l]);
return i;
}
int hoarePartition(vector<int>& arr, int low, int high) {
int pivot = arr[low]; // 选第一位
int i = low - 1;
int j = high + 1;
while (true) {
do { i++; } while (arr[i] < pivot);
do { j--; } while (arr[j] > pivot);
if (i >= j) return j;
swap(arr[i], arr[j]);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// ====================== 1. Lomuto 分区(带随机基准) ======================
int lomutoPartition(vector<int>& arr, int low, int high) {
// 步骤1:随机选择基准值,交换到右边界(Lomuto默认选右边界)
int randIdx = low + rand() % (high - low + 1); // 生成[low, high]的随机数
swap(arr[randIdx], arr[high]);

// 步骤2:标准 Lomuto 分区逻辑
int pivot = arr[high]; // 基准值(已交换到右边界)
int i = low - 1; // 左区(<=pivot)的右边界

for (int j = low; j < high; ++j) {
// 把<=pivot的元素归集到左区
if (arr[j] <= pivot) {
++i;
swap(arr[i], arr[j]);
}
}

// 步骤3:将基准值放到分界点(i+1)
swap(arr[i + 1], arr[high]);
return i + 1; // 返回基准值的位置(分界点)
}

// ====================== 2. Hoare 分区(带随机基准) ======================
int hoarePartition(vector<int>& arr, int low, int high) {
// 步骤1:随机选择基准值,交换到左边界(Hoare示例选左边界)
int randIdx = low + rand() % (high - low + 1);
swap(arr[randIdx], arr[low]);

// 步骤2:标准 Hoare 分区逻辑
int pivot = arr[low]; // 基准值(已交换到左边界)
int i = low - 1; // 左指针(初始左移一位,避免漏判)
int j = high + 1; // 右指针(初始右移一位,避免漏判)

while (true) {
// 左指针右移:找 > pivot 的元素(适配找第k大,左区存更大值)
do {
++i;
} while (arr[i] < pivot);

// 右指针左移:找 < pivot 的元素
do {
--j;
} while (arr[j] > pivot);

// 指针相遇,分区结束
if (i >= j) {
return j; // 返回分界点(左区[low,j] >= pivot,右区[j+1,high] <= pivot)
}

// 交换违规元素
swap(arr[i], arr[j]);
}
}
特性Lomuto 分区Hoare 分区
指针方向同向移动(都向右)对向移动(向中间靠拢)
代码简洁度非常简洁略复杂(有死循环风险,需谨慎处理)
交换频率低(效率更高)
Pivot 位置结束后 Pivot 就在最终排序位置结束后 Pivot 可能不在最终位置
重复元素处理较差(容易造成分区不平衡)更好(能更均匀地分配重复元素)

搜索二维矩阵

编写一个高效的算法来搜索 *m* x *n* 矩阵 matrix 中的一个目标值 target 。该矩阵具有以下特性:

  • 每行的元素从左到右升序排列。
  • 每列的元素从上到下升序排列。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public:
bool searchValue(vector<vector<int>>& matrix, int target, int row,
int col) {
if (row >= matrix.size() || col < 0) {
return false;
}
if (matrix[row][col] == target) {
return true;
} else if (matrix[row][col] < target) {
// 行数增加
return searchValue(matrix, target, row + 1, col);
} else {
// 列数减小
return searchValue(matrix, target, row, col - 1);
}
}
bool searchMatrix(vector<vector<int>>& matrix, int target) {
int m = matrix.size();
int n = matrix[0].size();
return searchValue(matrix, target, 0, n - 1);
}
};

如果能够满足本章开头说的分治法特征,就可以尝试使用分治法来处理,具体如下所示。● 问题的规模缩小到一定程度后可以被很容易地解决。● 问题可以分解为若干个规模较小的相同性质的问题。● 问题的解等于子问题解的合并。● 问题分解的各个子问题相互独立,没有重复。而在具体使用分治法时,紧紧抓住“分解”“解决”和“合并”这3个步骤

寻找两个正序数组的中位数

给定两个大小分别为 mn 的正序(从小到大)数组 nums1nums2。请你找出并返回这两个正序数组的 中位数 。算法的时间复杂度应该为 O(log (m+n))

要达到 O (log (m+n)) 的时间复杂度,必须使用二分查找而非简单的合并数组(合并数组的时间复杂度是 O (m+n))。核心思路是:

  1. 将问题转化为寻找两个有序数组中的第 k 小元素(中位数本质上就是第 (m+n)/2 小的元素)。
  2. 通过二分法不断缩小搜索范围:每次比较两个数组中第 k/2 位置的元素,排除不可能包含第 k 小元素的部分,直到 k=1 时直接取较小值。
  3. 处理奇偶情况:如果 m+n 是奇数,中位数就是第 (m+n+1)/2 小的元素;如果是偶数,就是第 (m+n)/2 和 (m+n)/2 +1 小的元素的平均值。

如何找第k小?

假设我们要找第 k 小元素:

  1. 比较两个数组的第 k/2个元素
  2. 较小的那个数组的前 k/2个元素都不可能是第 k 小
  3. 排除这些元素,问题规模减小
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class Solution {
public:
double findMedianSortedArrays(vector<int>& nums1, vector<int>& nums2) {
int m = nums1.size(), n = nums2.size();
int total = m + n;

// 中位数可能是两个数(偶数长度)或一个数(奇数长度)
if (total % 2 == 1) {
// 奇数:第 k 小,k = (total+1)/2
return findKth(nums1, 0, nums2, 0, (total + 1) / 2);
} else {
// 偶数:第 k 小和第 k+1 小的平均值,k = total/2
int left = findKth(nums1, 0, nums2, 0, total / 2);
int right = findKth(nums1, 0, nums2, 0, total / 2 + 1);
return (left + right) / 2.0;
}
}

private:
// 寻找两个有序数组的第 k 小元素
// i, j 分别是 nums1 和 nums2 的起始位置
double findKth(vector<int>& nums1, int i, vector<int>& nums2, int j, int k) {
int m = nums1.size(), n = nums2.size();

// 如果一个数组为空,直接从另一个数组取第k个
if (i >= m) return nums2[j + k - 1];
if (j >= n) return nums1[i + k - 1];

// 如果 k=1,返回两个数组首元素的最小值
if (k == 1) return min(nums1[i], nums2[j]);

// 计算每个数组应该比较的位置
// 防止越界:如果剩余长度不足 k/2,就取剩余长度
int mid1 = min(i + k / 2 - 1, m - 1);
int mid2 = min(j + k / 2 - 1, n - 1);

// 比较两个数组第 k/2 个元素
if (nums1[mid1] <= nums2[mid2]) {
// 说明 nums1 的前 k/2 个元素都不可能是第 k 小
// 排除掉 nums1[i...mid1] 这些元素
return findKth(nums1, mid1 + 1, nums2, j, k - (mid1 - i + 1));
} else {
// 排除掉 nums2[j...mid2] 这些元素
return findKth(nums2, i, nums2, mid2 + 1, k - (mid2 - j + 1));
}
}
};

在成千上万个文件中找第 $k$ 大的数

这个问题将之前的“有序数组排除法”推向了分布式计算大数据处理的实战领域。

当数据量大到无法装入内存,且分布在成千上万个文件中时,我们不能再依赖简单的指针移动,而要利用“值域划分”“分桶统计”的思想。这本质上是把“对索引的二分”转变成了“对数值范围的二分”

以下是三种最主流的解决策略:

  1. 桶计数法(最推荐:MapReduce 思想)

这是处理海量数据最稳健的方法。它的核心是将“寻找第 k 大”转化为“统计落在各个区间的数字个数”。

步骤:

  1. 确定值域:假设数字是 32 位整数,范围是 [0,232−1]。
  2. 分桶:将这个范围均匀分成 M 个桶(例如 1024 个桶)。每个桶代表一个数值区间(如桶 0 代表 [0,4×106])。
  3. 第一轮扫描(Map):遍历成千上万个文件,统计落入每个桶中的数字总数。
    • 结果示例:桶 0 有 10 亿个数,桶 1 有 5 亿个数……
  4. 定位目标桶(Reduce):从大到小累加桶的计数。
    • 如果前 2 个桶的总数小于 k,但前 3 个桶的总数大于等于 k,那么第 k 大的数一定在桶 2 中。
  5. 精细化搜索

    • 如果桶 2 中的数据量已经足够小(能装进内存),直接读入内存用 QuickSelect 解决。
    • 如果依然太大,对桶 2 的值域范围再次进行“分桶”,重复上述过程。
  6. 外部快速选择(External QuickSelect)

这种方法是 QuickSelect 的分布式版本,适合数据分布在多台服务器上的场景。

步骤:

  1. 选基准值(Pivot):从随机一个文件中选一个数作为 Pivot。
  2. 分布式分区:所有节点同时扫描自己负责的文件,将数字分为“大于 Pivot”、“等于 Pivot”和“小于 Pivot”三类,并记录每类的总数
  3. 判断路径
    • 如果“大于 Pivot”的总数 ≥k,则第 k 大在这一半,抛弃其余数据,在这一半中继续找。
    • 如果总数 <k 且(大于+等于)的总数 ≥k,则 Pivot 就是答案。
    • 否则,去“小于 Pivot”的那一半找第 k−(大于+等于) 大。

缺点:如果 Pivot 选得不好(比如选到了极值),需要频繁扫描磁盘,I/O 开销极大。

  1. 堆排序法(仅限 k 较小时)

如果你要找的是“前 1000 大”,而不是“中位数”或“第 10 亿大”,那么堆是无敌的。

步骤:

  1. 建立一个大小为 k最小堆
  2. 逐个读取文件中的数字:
    • 如果数字比堆顶大,弹出堆顶,压入新数。
  3. 扫描完所有文件后,堆顶即为所求。

优点:只需要扫描一遍数据,内存占用极低(仅 O(k))。

计算右侧小于当前元素的个数

给你一个整数数组 nums ,按要求返回一个新数组 counts 。数组 counts 有该性质: counts[i] 的值是 nums[i] 右侧小于 nums[i] 的元素的数量。

在归并排序的“合并(Merge)”阶段,我们将两个有序的子数组合并。假设左子数组为 L,右子数组为 R

  • 当我们从 L 中取出一个元素放入结果数组时,如果 R 中已经有若干个元素先被放入了结果数组,那么这些先被放入的 R 中的元素,一定是原数组中位于该 L 元素右侧且比它小的数
  • 我们只需要在合并时,记录 R 中有多少个元素已经被移动到了 L 元素的前面即可。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class Solution {
public:
void merge(vector<pair<int, int>>& nums, int left, int right,
vector<int>& cnt) {
if (left >= right)
return;
int mid = (right - left) / 2 + left;
merge(nums, left, mid, cnt);
merge(nums, 1 + mid, right, cnt);
vector<pair<int, int>> tmp(right - left + 1);
int i = left, j = mid + 1;
int rightCount{};
int idx{};
while (i <= mid && j <= right) {
if (nums[i].first <= nums[j].first) {
cnt[nums[i].second] += rightCount;
tmp[idx++] = nums[i++];
} else {
// 大于
rightCount++;
tmp[idx++] = nums[j++];
}
}
// 处理左半部分剩余元素 设置左半部分元素的count
while (i <= mid) {
cnt[nums[i].second] += rightCount;
tmp[idx++] = nums[i++];
}
// 处理右半部分剩余元素
while (j <= right) {
tmp[idx++] = nums[j++];
}
for (int i = left; i <= right; i++) {
nums[i] = tmp[i - left];
}
}
vector<int> countSmaller(vector<int>& nums) {
vector<pair<int, int>> index_nums;
int sz = nums.size();
for (int i = 0; i < sz; i++) {
index_nums.push_back({nums[i], i}); // 值和对应位置
}
vector<int> cnt(sz);
merge(index_nums, 0, sz - 1, cnt);
return cnt;
}
};

交易逆序对的总数

在股票交易中,如果前一天的股价高于后一天的股价,则可以认为存在一个「交易逆序对」。请设计一个程序,输入一段时间内的股票交易记录 record,返回其中存在的「交易逆序对」总数

  1. 逆序对定义:对于 i < j,如果 record[i] > record[j],则 (i, j)是一个逆序对
  2. 归并排序法的核心思想
    • 分治:将数组分成两半
    • 合并时统计:如果左半部分的元素大于右半部分的元素,则左半部分剩余的所有元素都与右半部分的该元素构成逆序对
    • 时间复杂度:O(nlogn),空间复杂度:O(n)
  3. 为什么归并排序法有效
    • 在合并两个有序子数组时,可以高效地统计跨越两个子数组的逆序对
    • 递归地统计每个子数组内部的逆序对

这是面试中常见的问题,归并排序法是必须掌握的标准解法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
class Solution {
public:
int reversePairs(vector<int>& record) {
int n = record.size();
if (n < 2) return 0;

vector<int> temp(n);
return mergeSort(record, temp, 0, n - 1);
}

int mergeSort(vector<int>& nums, vector<int>& temp, int left, int right) {
if (left >= right) return 0;

int mid = left + (right - left) / 2;
int count = 0;

// 分治
count += mergeSort(nums, temp, left, mid);
count += mergeSort(nums, temp, mid + 1, right);

// 合并并计数
int i = left, j = mid + 1, k = left;

// 先统计逆序对
while (i <= mid && j <= right) {
if (nums[i] > nums[j]) {
// nums[i] > nums[j],那么 nums[i..mid] 都 > nums[j]
count += (mid - i + 1);
j++;
} else {
i++;
}
}

// 再真正合并(也可以边统计边合并)
i = left, j = mid + 1, k = left;
while (i <= mid && j <= right) {
if (nums[i] <= nums[j]) {
temp[k++] = nums[i++];
} else {
temp[k++] = nums[j++];
}
}

while (i <= mid) temp[k++] = nums[i++];
while (j <= right) temp[k++] = nums[j++];

for (int idx = left; idx <= right; idx++) {
nums[idx] = temp[idx];
}

return count;
}
};

漂亮数组

如果长度为 n 的数组 nums 满足下述条件,则认为该数组是一个 漂亮数组

  • nums 是由范围 [1, n] 的整数组成的一个排列。
  • 对于每个 0 <= i < j < n ,均不存在下标 ki < k < j)使得 2 * nums[k] == nums[i] + nums[j]

给你整数 n ,返回长度为 n 的任一 漂亮数组 。本题保证对于给定的 n 至少存在一个有效答案。

通过以下步骤递归构造长度为 $n$ 的漂亮数组:

  1. :将 $n$ 个数分成两部分,左边是 $(n+1)/2$ 个数,右边是 $n/2$ 个数。
  2. 变换
    • 左边由长度为 $(n+1)/2$ 的漂亮数组通过 $2x - 1$ 变换得到(映射为奇数)。
    • 右边由长度为 $n/2$ 的漂亮数组通过 $2x$ 变换得到(映射为偶数)。
  3. :将左右两部分拼接。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
public:
unordered_map<int, vector<int>> memo;
vector<int> dfs(int n) {
if (memo.count(n)) {
return memo[n];
}
// 将n分为两半 (n+1)/2 n/2
// 左半部分全为奇数
vector<int> res(n);
int i{};
vector<int> l = dfs((n + 1) / 2);
// 进行转换
for (auto& n : l) {
// 全转为奇数
res[i++] = 2 * n - 1;
}
vector<int> r = dfs(n / 2);
for (auto& n : r) {
// 全转为偶数
res[i++] = 2 * n;
}
memo[n] = res;
return res;
}
vector<int> beautifulArray(int n) {
memo[1] = {1};
return dfs(n);
}
};

贪心

每次根据问题的当前状态,选择一个局部最优策略,并且能够不断迭代,最后产生一个全局最优解。换句话说,每次都是从当前问题出发,而不考虑之前或之后的问题的状态,然后做出一个最有利于当前问题的决策,迭代更新问题,不断重复同样的操作直到问题得到解决,此时得到的解为全局最优解。

一般而言,贪心法的题目只要求我们想到一个合理的局部最优策略,并且通过自己举例测试局部最优策略是否会出问题即可,而不需要去关注如何证明这个策略能够产生一个全局最优解。

分发饼干

为了尽可能满足最多数量的孩子,从贪心的角度考虑,应该按照孩子的胃口从小到大的顺序依次满足每个孩子,且对于每个孩子,应该选择可以满足这个孩子的胃口且尺寸最小的饼干。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
int findContentChildren(vector<int>& g, vector<int>& s) {
sort(g.begin(), g.end());
sort(s.begin(), s.end());
int i{}, j{};
while (i < g.size() && j < s.size()) {
if (g[i] <= s[j]) {
// 满足胃口
i++;
j++;
} else {
// 不满足
j++;
}
}
return i;
}
};

跳跃游戏

给你一个非负整数数组 nums ,你最初位于数组的 第一个下标 。数组中的每个元素代表你在该位置可以跳跃的最大长度。

判断你是否能够到达最后一个下标,如果可以,返回 true ;否则,返回 false

依次遍历数组中的每一个位置,并实时维护 最远可以到达的位置。对于当前遍历到的位置 x,如果它在 最远可以到达的位置 的范围内,那么我们就可以从起点通过若干次跳跃到达该位置,因此我们可以用 x+nums[x] 更新 最远可以到达的位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Solution {
public:
bool canJump(vector<int>& nums) {
int max_step{};
for (int i = 0; i < nums.size(); i++) {
if (i <= max_step) {
max_step = max(max_step, nums[i] + i);
if (max_step >= nums.size() - 1) {
return true;
}
} else {
break;
}
}
return false;
}
};

任务调度器

给你一个用字符数组 tasks 表示的 CPU 需要执行的任务列表,用字母 A 到 Z 表示,以及一个冷却时间 n。每个周期或时间间隔允许完成一项任务。任务可以按任何顺序完成,但有一个限制:两个 相同种类 的任务之间必须有长度为 n 的冷却时间。返回完成所有任务所需要的 最短时间间隔

假设出现次数最多的任务是 A,它的频率为 max_freq。 为了让时间最短,我们应该尽可能把 A 均匀地排开,中间填入冷却时间或其它任务。

计算出的结果可能面临两种情况:

  • 情况 A:空位不够填

    如果任务种类非常多,导致空位被填满后还有多余任务。此时,我们不需要任何额外的冷却时间(Idle time),总时间就是任务的总数 tasks.size()

  • 情况 B:空位填不满

    如果冷却时间 $n$ 很大,而任务种类很少,空位填不满,必须插入 idle。此时答案就是上面的公式结果。

结论:最终结果是 $\max(\text{tasks.size()}, (max_f - 1) \times (n + 1) + max_count)$。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Solution {
public:
int leastInterval(vector<char>& tasks, int n) {
vector<int> cnts(26);
int max_cnt{};
for(auto& ch:tasks) {
cnts[ch-'A']++;
max_cnt = max(max_cnt,cnts[ch-'A']);
}
int max_chars{};
for(int i = 0;i<26;i++){
if(cnts[i] == max_cnt) {
max_chars++;
}
}
// 选择频率最多的字符
// 该字符每个一组,
int res = (n+1)*(max_cnt-1) + max_chars;
return max<int>(tasks.size(),res);
}
};

分发糖果

n 个孩子站成一排。给你一个整数数组 ratings 表示每个孩子的评分。

你需要按照以下要求,给这些孩子分发糖果:

  • 每个孩子至少分配到 1 个糖果。
  • 相邻两个孩子中,评分更高的那个会获得更多的糖果。

请你给每个孩子分发糖果,计算并返回需要准备的 最少糖果数目

我们可以将「相邻的孩子中,评分高的孩子必须获得更多的糖果」这句话拆分为两个规则,分别处理。

左规则:当 ratings[i−1]<ratings[i] 时,i 号学生的糖果数量将比 i−1 号孩子的糖果数量多。

右规则:当 ratings[i]>ratings[i+1] 时,i 号学生的糖果数量将比 i+1 号孩子的糖果数量多。

我们遍历该数组两次,处理出每一个学生分别满足左规则或右规则时,最少需要被分得的糖果数量。每个人最终分得的糖果数量即为这两个数量的最大值。

具体地,以左规则为例:我们从左到右遍历该数组,假设当前遍历到位置 i,如果有 ratings[i−1]<ratings[i] 那么 i 号学生的糖果数量将比 i−1 号孩子的糖果数量多,我们令 left[i]=left[i−1]+1 即可,否则我们令 left[i]=1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public:
int candy(vector<int>& ratings) {
int sz = ratings.size();
vector<int> left(sz, 1);
vector<int> right(sz, 1);
for (int i = 1; i < sz; i++) {
if (ratings[i] > ratings[i - 1]) {
left[i] = left[i - 1] + 1;
}
}
int ans{};
for (int i = sz - 2; i >= 0; i--) {
if (ratings[i] > ratings[i + 1]) {
right[i] = right[i + 1] + 1;
}
}
for(int i = 0;i<sz;i++) {
ans += max(left[i],right[i]);
}
return ans;
}
};

无重叠区间

给定一个区间的集合 intervals ,其中 intervals[i] = [starti, endi] 。返回 需要移除区间的最小数量,使剩余区间互不重叠

注意 只在一点上接触的区间是 不重叠的。例如 [1, 2][2, 3] 是不重叠的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Solution {
public:
int eraseOverlapIntervals(vector<vector<int>>& intervals) {
sort(intervals.begin(), intervals.end());
// [1,2] [1,3] [2,3] [3,4]
int lastTime = intervals[0][1];
int res{};
for (int i = 0; i < intervals.size() - 1; i++) {
if (lastTime <= intervals[i + 1][0]) {
lastTime = intervals[i + 1][1];
} else {
// 重叠
lastTime = min(lastTime, intervals[i + 1][1]);
res++;
}
}
return res;
}
};
class Solution {
public:
int eraseOverlapIntervals(vector<vector<int>>& intervals) {
auto comp = [](vector<int>& a,vector<int>& b){
return a[1]<b[1];
};
sort(intervals.begin(),intervals.end(),comp);
int lastTime = intervals[0][1];
int cnt{1};
for(int i = 1;i<intervals.size();i++) {
if(lastTime<=intervals[i][0]) {
// 不需要移除
cnt++;
lastTime = intervals[i][1];
}else{
// 大于开始时间 需要移除
}
}
return intervals.size()-cnt;
}
};

跳跃游戏II

给定一个长度为 n0 索引整数数组 nums。初始位置在下标 0。

每个元素 nums[i] 表示从索引 i 向后跳转的最大长度。换句话说,如果你在索引 i 处,你可以跳转到任意 (i + j) 处:

  • 0 <= j <= nums[i]
  • i + j < n

返回到达 n - 1 的最小跳跃次数。测试用例保证可以到达 n - 1

目标是:在当前跳跃能达到的范围内,寻找下一跳能跳得最远的位置

当你从索引 i 遍历到 end 之间时,你会不断更新 farthest(即 max(farthest, i + nums[i]))。当你真正走到 end 这个边界时,说明你不得不再跳一次了。此时,你把跳跃次数 jumps 加 1,并将新的边界 end 设置为刚才探测到的 farthest

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Solution {
public:
int jump(vector<int>& nums) {
int n = nums.size();
if (n <= 1) return 0;

int jumps = 0; // 跳跃次数
int farthest = 0; // 目前能跳到的最远位置
int end = 0; // 当前跳跃步数能覆盖的边界

// 注意:我们不需要遍历最后一个元素,因为题目保证能到达
// 如果遍历到最后一个元素,可能会多算一次跳跃
for (int i = 0; i < n - 1; i++) {
// 1. 在当前范围内,探测下一跳最远能到哪
farthest = max(farthest, i + nums[i]);

// 2. 到达当前跳跃的边界了
if (i == end) {
jumps++;
end = farthest; // 开启下一段势力范围

// 如果已经能覆盖终点,可以提前结束
if (end >= n - 1) break;
}
}

return jumps;
}
};

移掉k位数字

给你一个以字符串表示的非负整数 num 和一个整数 k ,移除这个数中的 k 位数字,使得剩下的数字最小。请你以字符串形式返回这个最小的数字。

让越小的数字尽可能排在越左边(高位)。这本质上是一个贪心问题,而实现这个贪心策略的最佳工具是 单调栈 (Monotonic Stack)。想象一个数字序列,如果左边的数字比右边的大(例如 ...43...),那么删掉左边的这个“大数”,整个数字就会立刻变小。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
class Solution {
public:
string removeKdigits(string num, int k) {
if (num.length() == k) return "0";

string res = ""; // 这里的 string 可以直接当做栈来用

for (char d : num) {
// 当当前数字比“栈”顶小,且还有删除名额时,弹出末尾数字
while (k > 0 && !res.empty() && res.back() > d) {
res.pop_back();
k--;
}
res.push_back(d);
}

// 如果 k 还没用完,从末尾截断(因为此时 res 已经是升序的了)
while (k > 0) {
res.pop_back();
k--;
}

// 处理前导零
int start = 0;
while (start < res.size() && res[start] == '0') {
start++;
}

string ans = res.substr(start);
return ans.empty() ? "0" : ans;
}
};

根据身高重建队列

假设有打乱顺序的一群人站成一个队列,数组 people 表示队列中一些人的属性(不一定按顺序)。每个 people[i] = [hi, ki] 表示第 i 个人的身高为 hi ,前面 正好ki 个身高大于或等于 hi 的人。

请你重新构造并返回输入数组 people 所表示的队列。返回的队列应该格式化为数组 queue ,其中 queue[j] = [hj, kj] 是队列中第 j 个人的属性(queue[0] 是排在队列前面的人)。

先安置“高个子”,再让“矮个子”插队。因为高个子的相对位置只受比他更高或一样高的人影响,而矮个子对他来说是“隐形”的。

我们可以通过两个步骤来搞定:

第一步:排序

我们要对数组进行排序:

  1. 身高 $h$ 降序:高个子排在前面。
  2. 人数 $k$ 升序:如果身高一样,要求前面人少的排在前面。

第二步:插入

遍历排序后的数组,直接将每个人插入到他对应的 $k$ 位置。

  • 逻辑保证:当我们处理第 $i$ 个人时,已经在队列里的所有人身高都 $\ge$ 他的身高。
  • 此时,他要求前面有 $k$ 个比他高或相等的人,我们就直接把他放在索引 $k$ 的位置。哪怕后面有更矮的人插到他前面,也不会影响他的 $k$ 值。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Solution {
public:
vector<vector<int>> reconstructQueue(vector<vector<int>>& people) {
// 先排序身高
auto comp = [](vector<int>& a,vector<int>& b) {
if(a[0] == b[0]) {
// 身高相等 排序低的在前面
return a[1]<b[1];
}
return a[0]>b[0];
};
sort(people.begin(),people.end(),comp);
vector<vector<int>> res;
//
for(auto& p:people) {
res.insert(res.begin()+p[1],p);
}
return res;
}
};

用最少数量的箭引爆气球

有一些球形气球贴在一堵用 XY 平面表示的墙面上。墙面上的气球记录在整数数组 points ,其中points[i] = [xstart, xend] 表示水平直径在 xstartxend之间的气球。你不知道气球的确切 y 坐标。

一支弓箭可以沿着 x 轴从不同点 完全垂直 地射出。在坐标 x 处射出一支箭,若有一个气球的直径的开始和结束坐标为 xstartxend, 且满足 xstart ≤ x ≤ xend,则该气球会被 引爆 。可以射出的弓箭的数量 没有限制 。 弓箭一旦被射出之后,可以无限地前进。

给你一个数组 points返回引爆所有气球所必须射出的 最小 弓箭数

按照气球的结束坐标 ($x_{end}$) 进行升序排序。

为什么选结束坐标?如果我们按照结束坐标排序,第一支箭的最优射出位置一定是第一个气球的结束位置。因为这样射出的箭,在引爆当前气球的同时,最有机会引爆后面那些“开始得很早”的气球。

算法步骤:

  1. 排序:将气球按 $x_{end}$ 从小到大排序。
  2. 初始化:至少需要 1 支箭(假设数组不为空),初始射箭位置设为第一个气球的结束坐标。
  3. 遍历:从第二个气球开始遍历:
    • 如果当前气球的开始坐标 $x_{start}$ 大于 上一次射箭的位置:说明这支箭射不到这个气球。
    • 更新:我们需要一支新箭,增加箭的计数,并将射箭位置更新为当前气球的结束坐标。
    • 否则:这支箭可以顺便引爆当前气球,位置保持不变。

按区间的开始位置(Start)进行升序排序。

核心思路:贪心加排序为什么要按开始位置排序? 因为排序后,可以合并的区间一定是连续出现的。我们只需要比较当前区间的“开始”和上一个合并区间的“结束”即可。

算法步骤:

  1. 排序:按照每个区间的 start 从小到大排序。
  2. 初始化:创建一个空的结果数组 res,先把第一个区间放进去。
  3. 遍历:从第二个区间开始遍历:
    • res 中最后一个区间的末尾 last_end
    • 取当前遍历区间的开头 curr_start 和末尾 curr_end
    • 判断重叠:如果 curr_start <= last_end,说明重叠了!
      • 合并:将 res 中最后一个区间的末尾更新为 max(last_end, curr_end)
    • 不重叠:如果 curr_start > last_end,说明这两个区间接不上。
      • 直接添加:把当前区间整个塞进 res
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution {
public:
int findMinArrowShots(vector<vector<int>>& points) {
if (points.empty()) return 0;

// 1. 按结束坐标升序排序
// 注意:使用 lambda 表达式时,直接相减可能会导致溢出,建议使用 < 判断
sort(points.begin(), points.end(), [](const vector<int>& a, const vector<int>& b) {
return a[1] < b[1];
});

int arrows = 1; // 至少需要一支箭
int last_end = points[0][1]; // 第一支箭射在第一个气球的末尾

for (int i = 1; i < points.size(); ++i) {
// 2. 如果当前气球的开始位置在箭的射程之外
if (points[i][0] > last_end) {
arrows++; // 必须再射一支
last_end = points[i][1]; // 更新这支箭的位置到当前气球末尾
}
}

return arrows;
}
};

合并区间

以数组 intervals 表示若干个区间的集合,其中单个区间为 intervals[i] = [starti, endi] 。请你合并所有重叠的区间,并返回 一个不重叠的区间数组,该数组需恰好覆盖输入中的所有区间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution {
public:
vector<vector<int>> merge(vector<vector<int>>& intervals) {
auto comp = [](vector<int>& a,vector<int>&b) {
if(a[0] == b[0]) {
return a[1]<b[1];
}
return a[0]<b[0];
};
sort(intervals.begin(),intervals.end(),comp);
vector<vector<int>> res;
res.push_back(intervals[0]);
for(int i = 1;i<intervals.size();i++) {
auto& lastInterval = res.back();
if(lastInterval[1]>=intervals[i][0]) {
// 重叠区间 合并
lastInterval[1] = max(lastInterval[1],intervals[i][1]);
}else{
// 不重叠区间
res.push_back(intervals[i]);
}
}
return res;
}
};

合并区间(本题):我们需要知道谁先开始,因为合并是向前推进的。如果按 End 排序,一个跨度极大的区间(如 [1, 100])可能会排在最后,导致你之前合并好的所有小片段都要重新去和它比对,逻辑会变复杂。

射气球/不重叠区间:我们需要尽早结束当前区间,好给后面的区间留位置。所以“谁先结束”是最重要的贪心标准。

插入区间

给你一个 无重叠的 按照区间起始端点排序的区间列表 intervals,其中 intervals[i] = [starti, endi] 表示第 i 个区间的开始和结束,并且 intervals 按照 starti 升序排列。同样给定一个区间 newInterval = [start, end] 表示另一个区间的开始和结束。

intervals 中插入区间 newInterval,使得 intervals 依然按照 starti 升序排列,且区间之间不重叠(如果有必要的话,可以合并区间)。

返回插入之后的 intervals注意 你不需要原地修改 intervals。你可以创建一个新数组然后返回它。

可以把处理过程想象成在一条时间轴上排队,把 newInterval 插入进去,并把受到波及的人“融合”掉:

  • 阶段一:左侧无重叠

    直接把所有结束时间早于 newInterval 开始时间的区间丢进结果集。它们完全在左边,互不干扰。

  • 阶段二:中间重叠区(融合)

    只要当前的区间没有完全在 newInterval 的右边(即当前区间的开始时间 $\le$ newInterval 的结束时间),就说明有重叠。

    • 融合策略:更新 newInterval 的起点为 min(起点),终点为 max(终点)
  • 阶段三:右侧无重叠

    把合并后的 newInterval 放进结果集,然后把剩下的还没处理的区间(它们都在右边,开始时间晚于 newInterval 的结束时间)全部接在后面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution {
public:
vector<vector<int>> insert(vector<vector<int>>& intervals,
vector<int>& newInterval) {
vector<vector<int>> res;
int i{};
while (i < intervals.size() && intervals[i][1] < newInterval[0]) {
// 不重叠 添加
res.push_back(intervals[i]);
i++;
}
while (i < intervals.size() && intervals[i][0] <= newInterval[1]) {
// 重叠 合并
newInterval[0] = min(newInterval[0], intervals[i][0]);
newInterval[1] = max(newInterval[1], intervals[i][1]);
i++;
}
res.push_back(newInterval);
while (i < intervals.size()) {
res.push_back(intervals[i]);
i++;
}
return res;
}
};

灌溉花园的最少水龙头数目

在 x 轴上有一个一维的花园。花园长度为 n,从点 0 开始,到点 n 结束。

花园里总共有 n + 1 个水龙头,分别位于 [0, 1, ..., n]

给你一个整数 n 和一个长度为 n + 1 的整数数组 ranges ,其中 ranges[i] (下标从 0 开始)表示:如果打开点 i 处的水龙头,可以灌溉的区域为 [i - ranges[i], i + ranges[i]]

请你返回可以灌溉整个花园的 最少水龙头数目 。如果花园始终存在无法灌溉到的地方,请你返回 -1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Solution {
public:
int minTaps(int n, vector<int>& ranges) {
// 1. 预处理:将区间转换为类似于“跳跃游戏”的数组
vector<int> max_reach(n + 1, 0);
for (int i = 0; i <= n; i++) {
int left = max(0, i - ranges[i]);
int right = min(n, i + ranges[i]);
// 在 left 这个位置,最远能覆盖到 right
max_reach[left] = max(max_reach[left], right);
}

// 2. 执行“跳跃游戏 II”的贪心逻辑
int taps = 0; // 使用的水龙头数量
int cur_end = 0; // 当前已覆盖范围的右边界
int farthest = 0; // 下一步能达到的最远位置

for (int i = 0; i <n; i++) {
// 更新当前能探测到的最远位置
farthest = max(farthest, max_reach[i]);

// 如果连当前的 i 都覆盖不到,说明中间有断层
if (i >= farthest) return -1;

// 到达当前水龙头的覆盖极限,必须开启下一个
if (i == cur_end) {
taps++;
cur_end = farthest;

// 如果已经覆盖到 n,可以提前退出
if (cur_end >= n) break;
}
}

return cur_end >= n ? taps : -1;
}
};

总体而言贪心类型的题目难度级别大部分只有中等,对应的解题技巧主要包括3个步骤。

● 掌握常见的贪心策略,例如本章的内容或其他人总结的经典贪心思想。

● 不断练习,积累题目经验,补充贪心策略,开拓自己的眼界和认知。

● 面对一道新颖的贪心类型的题目,要敢于设想一个局部最优策略。寻找局部最优策略的过程就是试错的过程,可以基于自己的生活经验或解题经验,并对策略的可行性进行简单的推理论证。

回溯

回溯法是一种复杂度很高的暴力搜索算法,实现简单且有固定模板,常被用于搜索排列组合问题的所有可行性解。不同于普通的暴力搜索,回溯法会在每一步判断状态是否合法,而不是等到状态全部生成后再进行确认。当某一步状态非法时,它将回退到上一步中正确的位置,然后继续搜索其他不同的状态。前进和后退是回溯法的关键动作,因此可以使用递归去模拟整个过程,即使用递归实现回溯法。

组合总和

给你一个 无重复元素 的整数数组 candidates 和一个目标整数 target ,找出 candidates 中可以使数字和为目标数 target 的 所有 不同组合 ,并以列表形式返回。你可以按 任意顺序 返回这些组合。

candidates 中的 同一个 数字可以 无限制重复被选取 。如果至少一个数字的被选数量不同,则两种组合是不同的。 对于给定的输入,保证和为 target 的不同组合数少于 150 个。

回溯法的本质是深度优先遍历,具体的实现方法是递归,因此需要定义一个递归函数来模拟整个搜索过程,即dfs()。不断向下递归的过程,也就是搜索前进的过程,那么在这个过程中需要注意哪些问题呢?这些问题与递归函数的内容及参数息息相关,值得我们关注理解。

(i)如何区别不同的递归?或者如何知道现在搜索到哪里了?每一层递归的函数内容是固定的,有所区别的只有参数信息,因此可以将参数信息作为区分的标记。通过获取当前递归的参数信息,也就能够认识到搜索的位置了。也可以称这些参数信息为递归携带的状态,模板中定义了3个状态,分别是idx、cur和path。其中,idx标记位置信息,例如idx=1可以表示搜索到数组的第1个数字,idx=2可以表示搜索到数组的第2个数字;cur和path实际上都是从出发点到当前位置的路径上的某个信息,需要根据题目的要求灵活定义。

(ii)递归如何结束?有几个结束出口?搜索的目标就是找到可行性解。通常情况下找到可行性解就应该结束搜索,但在一些特殊场景下,不同的解可能会重叠,例如找到解后继续搜索可能会得到新的解,此时就不能结束搜索。此外,当无法继续搜索时也应该结束搜索,例如依次遍历数组元素,如果递归过程中idx等于数组末尾的下标,则不能继续往下搜索,否则会发生程序错误。

(iii)递归过程中状态可能会互相影响,如何解决?这个问题可能不是很好理解。举个例子来说,假设当前可以向左前进,也可以向右前进,并且需要保存走过的路径。基于上述第一个问题,应该在递归中携带状态path来保存当前路径,并在进入下一层递归之前改变状态:向左前进则path.append(left),向右前进则path.append(right)。如果先选择向左前进,path已经发生变化,包括向左的一些路径信息,再选择向右前进就会存在问题。一种简单的解决方案是:每次进入下一层递归时重新复制path,但复制path的时间复杂度为O(n),时间开销太大,无法充分利用path。可以考虑另外一种解决方案:在递归结束的地方恢复原来path的状态,即在下一层递归前通过path.append(num)改变状态,并在递归结束时通过path.pop()恢复状态。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution {
public:
void backtrace(vector<vector<int>>& res, vector<int>& r, vector<int>& nums,
int idx, int sum_val, int target) {
if (idx == nums.size()) {
return;
}
if (sum_val == target) {
res.push_back(r);
return;
}
for (int i = idx; i < nums.size() && (nums[i] + sum_val <= target); i++) {
r.push_back(nums[i]);
backtrace(res, r, nums, i, sum_val + nums[i], target);
r.pop_back();
}
}
vector<vector<int>> combinationSum(vector<int>& candidates, int target) {
vector<vector<int>> res;
vector<int> r;
sort(candidates.begin(), candidates.end());
backtrace(res, r, candidates, 0, 0, target);
return res;
}
};

组合总和II

给定一个候选人编号的集合 candidates 和一个目标数 target ,找出 candidates 中所有可以使数字和为 target 的组合。candidates 中的每个数字在每个组合中只能使用 一次

注意:解集不能包含重复的组合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
class Solution {
private:
vector<vector<int>> result;
vector<int> path;
void backtracking(vector<int>& candidates, int target, int sum, int startIndex, vector<bool>& used) {
if (sum == target) {
result.push_back(path);
return;
}
for (int i = startIndex; i < candidates.size() && sum + candidates[i] <= target; i++) {
// used[i - 1] == true,说明同一树枝candidates[i - 1]使用过
// used[i - 1] == false,说明同一树层candidates[i - 1]使用过
// 要对同一树层使用过的元素进行跳过
if (i > 0 && candidates[i] == candidates[i - 1] && used[i - 1] == false) {
continue;
}
sum += candidates[i];
path.push_back(candidates[i]);
used[i] = true;
backtracking(candidates, target, sum, i + 1, used); // 和39.组合总和的区别1,这里是i+1,每个数字在每个组合中只能使用一次
used[i] = false;
sum -= candidates[i];
path.pop_back();
}
}

public:
vector<vector<int>> combinationSum2(vector<int>& candidates, int target) {
vector<bool> used(candidates.size(), false);
path.clear();
result.clear();
// 首先把给candidates排序,让其相同的元素都挨在一起。
sort(candidates.begin(), candidates.end());
backtracking(candidates, target, 0, 0, used);
return result;
}
};

组合总和III

找出所有相加之和为 nk 个数的组合,且满足下列条件:

  • 只使用数字1到9
  • 每个数字 最多使用一次

返回 所有可能的有效组合的列表 。该列表不能包含相同的组合两次,组合可以以任何顺序返回。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public:
void backtrace(vector<vector<int>>& res, int sum_val, int idx,
vector<int>& r, int k, int n) {
if (r.size() == k) {
if (sum_val == n) {
res.push_back(r);
}
return;
}
for (int i = idx; i <= 9 && i + sum_val <= n; i++) {
r.push_back(i);
backtrace(res, sum_val + i, i + 1, r, k, n);
r.pop_back();
}
}
vector<vector<int>> combinationSum3(int k, int n) {
vector<vector<int>> res;
vector<int> r;
backtrace(res, 0, 1, r, k, n);
return res;
}
};

子集

给你一个整数数组 nums ,数组中的元素 互不相同 。返回该数组所有可能的子集(幂集)。解集 不能 包含重复的子集。你可以按 任意顺序 返回解集。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
void backtrace(vector<vector<int>>& res,vector<int>& r,vector<int>& nums,int idx) {
// 添加所有节点
res.push_back(r);
// 截至条件
// if(idx == nums.size()) {
// return;
// }
for(int i = idx;i<nums.size();i++) {
r.push_back(nums[i]);
backtrace(res,r,nums,i+1);
r.pop_back();
}
}
vector<vector<int>> subsets(vector<int>& nums) {
vector<vector<int>> res;
vector<int> r;
backtrace(res,r,nums,0);
return res;
}
};

全排列

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
class Solution {
public:
void backtrace(vector<vector<int>>& res, vector<int>& r, vector<int>& nums,
int idx, vector<bool>& used) {
// 截至条件
if (r.size() == nums.size()) {
res.push_back(r);
return;
}
for (int i = 0; i < nums.size(); i++) {
// 如果使用过
if (used[i]) {
continue;
}
r.push_back(nums[i]);
used[i] = true;
backtrace(res, r, nums, i + 1, used);
used[i] = false;
r.pop_back();
}
}
vector<vector<int>> permute(vector<int>& nums) {
vector<vector<int>> res;
vector<int> r;
int sz = nums.size();
vector<bool> used(sz);
backtrace(res, r, nums, 0, used);
return res;
}
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
void backtrack(vector<vector<int>>& res, vector<int>& output, int first, int len){
// 所有数都填完了
if (first == len) {
res.emplace_back(output);
return;
}
for (int i = first; i < len; ++i) {
// 动态维护数组
swap(output[i], output[first]);
// 继续递归填下一个数
backtrack(res, output, first + 1, len);
// 撤销操作
swap(output[i], output[first]);
}
}
vector<vector<int>> permute(vector<int>& nums) {
vector<vector<int> > res;
backtrack(res, nums, 0, (int)nums.size());
return res;
}
};

解数独

给定一个不含重复数字的数组 nums ,返回其 所有可能的全排列 。你可以 按任意顺序 返回答案。

编写一个程序,通过填充空格来解决数独问题。

数独的解法需 遵循如下规则

  1. 数字 1-9 在每一行只能出现一次。
  2. 数字 1-9 在每一列只能出现一次。
  3. 数字 1-9 在每一个以粗实线分隔的 3x3 宫内只能出现一次。(请参考示例图)

数独部分空格内已填入了数字,空白格用 '.' 表示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class Solution {
public:
bool isValid(vector<vector<char>>& grid, int row, int col, int num) {
// 每行
int m = grid.size();
int n = grid[0].size();
for (int j = 0; j < n; j++) {
if (grid[row][j] == '0' + num) {
return false;
}
}
// 每列
for (int i = 0; i < m; i++) {
if (grid[i][col] == '0' + num) {
return false;
}
}
// 格子内部
int start_i = row / 3 * 3;
int start_j = col / 3 * 3;
for (int i = start_i; i < start_i + 3; i++) {
for (int j = start_j; j < start_j + 3; j++) {
if (grid[i][j] == '0' + num) {
return false;
}
}
}
return true;
}
bool backtrace(vector<vector<char>>& board) {
int m = board.size();
int n = board[0].size();
// 截至条件
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (board[i][j] != '.') {
continue;
}
for (int num = 1; num <= 9; num++) {
if (isValid(board, i, j, num)) {
board[i][j] = '0' + num;
if (backtrace(board)) {
return true;
}
board[i][j] = '.';
}
}
return false;
}
}
return true;
}

void solveSudoku(vector<vector<char>>& board) {
// 回溯进行选择 然后判断是否合规
int m = board.size();
int n = board[0].size();
backtrace(board);
}
};

全排列II

给定一个可包含重复数字的序列 nums按任意顺序 返回所有不重复的全排列。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Solution {
public:
void backtrace(vector<vector<int>>& res, vector<int>& r, vector<bool>& used,
vector<int>& nums) {
if (r.size() == nums.size()) {
res.push_back(r);
return;
}
for (int i = 0; i < nums.size(); i++) {
// 同一层如果使用过
if(i>0 && (nums[i] == nums[i-1]) && !used[i-1]) {
//
continue;
}
// 同一树枝使用过
if (used[i]) {
continue;
}
used[i] = true;
r.push_back(nums[i]);
backtrace(res, r, used, nums);
r.pop_back();
used[i] = false;
}
}
vector<vector<int>> permuteUnique(vector<int>& nums) {
vector<vector<int>> res;
vector<int> r;
vector<bool> used(nums.size());
sort(nums.begin(),nums.end());
backtrace(res, r, used, nums);
return res;
}
};

N皇后

按照国际象棋的规则,皇后可以攻击与之处在同一行或同一列或同一斜线上的棋子。

n 皇后问题 研究的是如何将 n 个皇后放置在 n×n 的棋盘上,并且使皇后彼此之间不能相互攻击。

给你一个整数 n ,返回所有不同的 n 皇后问题 的解决方案。

每一种解法包含一个不同的 n 皇后问题 的棋子放置方案,该方案中 'Q''.' 分别代表了皇后和空位。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
class Solution {
public:
bool isValid(vector<string>& board, int i, int j) {
// 判断该位置能否放置
// 同一行
int n = board.size();
for (int col = 0; col < n; col++) {
if (board[i][col] == 'Q') {
return false;
}
}
// 同一列
for (int row = 0; row < n; row++) {
if (board[row][j] == 'Q') {
return false;
}
}
// 斜线
// 从右下到左上
for (int row = i, col = j; row >= 0 && col >= 0; col--, row--) {
if (board[row][col] == 'Q') {
return false;
}
}
// 从左下到右上
for (int row = i, col = j; row >= 0 && col < n; col++, row--) {
if (board[row][col] == 'Q') {
return false;
}
}
return true;
}
void backtrace(vector<vector<string>>& res, vector<string>& board,
int row) {
int n = board.size();
// 结束条件 最后一行
if (n == row) {
res.push_back(board);
return;
}
// 对于每一行
for (int j = 0; j < n; j++) {
if (isValid(board, row, j)) {
board[row][j] = 'Q';
backtrace(res, board, row + 1);
board[row][j] = '.';
}
}
}
vector<vector<string>> solveNQueens(int n) {
vector<vector<string>> res;
string tmp(n, '.');
vector<string> board(n, tmp);
backtrace(res, board, 0);
return res;
}
};

组合

给定两个整数 nk,返回范围 [1, n] 中所有可能的 k 个数的组合。

你可以按 任何顺序 返回答案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Solution {
public:
void backtrace(vector<vector<int>>& res, int n, vector<int>& r, int idx,
int cnt, int k) {
if (cnt == k) {
res.push_back(r);
return;
}
// 剪枝
for (int i = idx; cnt+n-i+1>=k; i++) {
r.push_back(i);
backtrace(res, n, r, i + 1, cnt + 1, k);
r.pop_back();
}
}
vector<vector<int>> combine(int n, int k) {
vector<vector<int>> res;
vector<int> r;
backtrace(res, n, r, 1, 0, k);
return res;
}
};

子集II

给你一个整数数组 nums ,其中可能包含重复元素,请你返回该数组所有可能的 子集(幂集)。

解集 不能 包含重复的子集。返回的解集中,子集可以按 任意顺序 排列。

在递归时,若发现没有选择上一个数,且当前数字与上一个数相同,则可以跳过当前生成的子集

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
class Solution {
public:
void backtrace(vector<vector<int>>& res, vector<int>& r, vector<int>& nums,
int idx, vector<bool>& used) {
// 添加每个节点
res.push_back(r);
int sz = nums.size();
for (int i = idx; i < sz; i++) {
if (i > 0 && nums[i] == nums[i - 1] && used[i - 1] == false) {
// 同一层选择相同值
continue;
}
used[i] = true;
r.push_back(nums[i]);
backtrace(res, r, nums, i + 1, used);
r.pop_back();
used[i] = false;
}
}
vector<vector<int>> subsetsWithDup(vector<int>& nums) {
int sz = nums.size();
vector<bool> used(sz);
vector<vector<int>> res;
vector<int> r;
sort(nums.begin(),nums.end());
backtrace(res, r, nums, 0, used);
return res;
}
};

黄金矿工

你要开发一座金矿,地质勘测学家已经探明了这座金矿中的资源分布,并用大小为 m * n 的网格 grid 进行了标注。每个单元格中的整数就表示这一单元格中的黄金数量;如果该单元格是空的,那么就是 0

为了使收益最大化,矿工需要按以下规则来开采黄金:

  • 每当矿工进入一个单元,就会收集该单元格中的所有黄金。
  • 矿工每次可以从当前位置向上下左右四个方向走。
  • 每个单元格只能被开采(进入)一次。
  • 不得开采(进入)黄金数目为 0 的单元格。
  • 矿工可以从网格中 任意一个 有黄金的单元格出发或者是停止。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
class Solution {
public:
int res{};
vector<pair<int, int>> dirs = {{0, 1}, {0, -1}, {1, 0}, {-1, 0}};
void backtrace(vector<vector<int>>& grid, int x, int y, int val) {
res = max(res, val);
// 选择四个方向
int m = grid.size();
int n = grid[0].size();
for (auto& dir : dirs) {
int nx = x + dir.first;
int ny = y + dir.second;
if (nx < 0 || nx >= m || ny < 0 || ny >= n) {
// 无法到达
continue;
}
if (grid[nx][ny] == 0) {
// 无法到达
continue;
}
int tmp = grid[nx][ny];
grid[nx][ny] = 0;
backtrace(grid, nx, ny, val + tmp);
grid[nx][ny] = tmp;
}
}
int getMaximumGold(vector<vector<int>>& grid) {
int m = grid.size();
int n = grid[0].size();
for (int i = 0; i < m; i++) {
for (int j = 0; j < n; j++) {
if (grid[i][j] != 0) {
int val = grid[i][j];
grid[i][j] = 0;
backtrace(grid, i, j, val);
grid[i][j] = val;
}
}
}
return res;
}
};

组合总和IV

给你一个由 不同 整数组成的数组 nums ,和一个目标整数 target 。请你从 nums 中找出并返回总和为 target 的元素排列的个数。题目数据保证答案符合 32 位整数范围。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
int combinationSum4(vector<int>& nums, int target) {
// dp[i]表示总和为i的组合个数0
vector<long long> dp(target + 1,0);
sort(nums.begin(), nums.end());
dp[0] = 1;
for (int i = 1; i <= target; i++) {
for (auto n : nums) {
if (n <= i && dp[i]+dp[i-n]<=INT_MAX) {
// 状态转移方程
// dp[i] += dp[i-n]
dp[i] += dp[i - n];
}
}
}
return dp[target];
}
};

回溯算法能解决如下问题:

  • 组合问题:N个数里面按一定规则找出k个数的集合
  • 排列问题:N个数按一定规则全排列,有几种排列方式
  • 切割问题:一个字符串按一定规则有几种切割方式
  • 子集问题:一个N个数的集合里有多少符合条件的子集
  • 棋盘问题:N皇后,解数独等等

有趣的题目

求众数II

给定一个大小为n的数组,找出其中所有出现次数超过n/3 次的元素。说明:要求算法的时间复杂度为O(n),空间复杂度为O(1)。

字符串模拟

字符串模拟。要攻克它们,我建议你按这个分级练习路径来:

第一关:入门级(固定映射)

  • LC 13. 罗马数字转整数:练习基本的哈希映射。
  • LC 12. 整数转罗马数字:练习贪心凑数思想。

第二关:进阶级(带有进制逻辑)

  • LC 168. Excel表列名称:理解 26 进制与偏移量的关系(这是你之前刚看过的)。
  • LC 171. Excel表列序号:反向模拟。

第三关:大模拟级(细节控)

  • LC 8. 字符串转换整数 (atoi):练习处理空格、正负号、边界溢出(面试高频)。
  • LC 43. 字符串相乘:练习手写竖式计算,理解大数处理逻辑。

第四关:终极 Boss

  • LC 273. 整数转换英文表示:这就是你现在的目标。
  • LC 68. 文本左右对齐:字符串模拟题的“噩梦”

整数转换英文表示

将非负整数 num 转换为其对应的英文表示。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class Solution {
public:
vector<string> tens = {"", "Ten", "Twenty", "Thirty", "Forty",
"Fifty", "Sixty", "Seventy", "Eighty", "Ninety"};
vector<string> singles = {"", "One", "Two", "Three",
"Four", "Five", "Six", "Seven",
"Eight", "Nine", "Ten", "Eleven",
"Twelve", "Thirteen", "Fourteen", "Fifteen",
"Sixteen", "Seventeen", "Eighteen", "Nineteen"};
vector<string> units = {"", "Thousand", "Million", "Billion"};
string helper(int num) {
// 小于1000
if (num == 0) {
return "";
} else if (num < 20) {
return singles[num] + " ";
} else if (num < 100) {
return tens[num / 10] + " " + helper(num % 10);
} else {
return singles[num / 100] + " Hundred " + helper(num % 100);
}
}
string numberToWords(int num) {
// 每个三个一份
if (num == 0) {
return "Zero";
}
string res;
int i{};
while (num) {
if (num % 1000) {
// 处理当前这三位,并在后面加上单位
res = helper(num % 1000) + units[i] + " " + res;
}
num /= 1000;
i++;
}
while (!res.empty() && res.back() == ' ') {
res.pop_back();
}
return res;
}
};

汉字数字转阿拉伯数字

处理它的核心思路是:分权累加,遇“大单位”结算

核心逻辑:

  1. 映射表:准备好数字(一:1)和单位(十:10, 百:100, 万:10000)。

  2. 状态变量

    • section_sum: 当前小节(万以下)的累加和。
    • total_sum: 最终的总和。
    • temp_val: 当前读到的数字。
  3. 遍历规则

    • 读到数字:存入 temp_val
    • 读到小单位(十、百、千):用 temp_val 乘以单位,加进 section_sum
    • 读到大单位(万、亿):将 section_sum 加上 temp_val 后,整体乘以大单位,并入 total_sum,然后清空 section_sum

    汉字数字的逻辑是“权重累加”。核心在于区分:

    • 小单位(十、百、千):仅修饰前一个数字。
    • 大单位(万、亿):修饰前面整整一截数字。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
#include <iostream>
#include <string>
#include <unordered_map>

long long cnToAr(std::u16string s) {
std::unordered_map<char16_t, int> digits = {
{u'零', 0}, {u'一', 1}, {u'二', 2}, {u'三', 3}, {u'四', 4},
{u'五', 5}, {u'六', 6}, {u'七', 7}, {u'八', 8}, {u'九', 9}
};
std::unordered_map<char16_t, long long> units = {
{u'十', 10}, {u'百', 100}, {u'千', 1000},
{u'万', 10000}, {u'亿', 100000000}
};

long long total = 0; // 总结果
long long section = 0; // 当前“万”或“亿”之内的累加和
long long num = 0; // 暂存当前读到的数字

for (char16_t ch : s) {
if (digits.count(ch)) {
num = digits[ch];
} else if (units.count(ch)) {
long long unit = units[ch];
if (unit == 10000 || unit == 100000000) {
// 遇到大单位,结算当前 section 并乘以权重
section = (section + num) * unit;
total += section;
section = 0;
} else {
// 遇到小单位(十百千)
if (num == 0 && unit == 10) num = 1; // 处理“十二”开头的“十”
section += num * unit;
}
num = 0;
}
}
return total + section + num;
}

处理亿万级别

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
#include <iostream>
#include <string>
#include <unordered_map>

using namespace std;

class Solution {
// 使用 static 成员,避免重复创建 map
inline static const unordered_map<char16_t, long long> vals = {
{u'零', 0}, {u'一', 1}, {u'二', 2}, {u'三', 3}, {u'四', 4},
{u'五', 5}, {u'六', 6}, {u'七', 7}, {u'八', 8}, {u'九', 9} // 修正了“九”
};
inline static const unordered_map<char16_t, long long> units = {
{u'十', 10}, {u'百', 100}, {u'千', 1000}
};
inline static const unordered_map<char16_t, long long> segments = {
{u'万', 10000}, {u'亿', 100000000}
};

public:
long long chineseNumsToArabicNums(u16string s) {
long long res = 0; // 最终结果
long long section = 0; // “万”或“亿”之内的段内和
long long val = 0; // 当前数字

for (char16_t ch : s) {
if (vals.count(ch)) {
val = vals.at(ch);
}
else if (units.count(ch)) {
long long unit = units.at(ch);
if (val == 0 && unit == 10) val = 1; // 处理“十二”
section += val * unit;
val = 0;
}
else if (segments.count(ch)) {
long long segUnit = segments.at(ch);
// 核心修正:section 加上当前的 val,再乘以万或亿
section = (section + val) * segUnit;

// 处理“亿”和“万”的嵌套(如一亿万,虽然少见但逻辑要通)
if (segUnit == 100000000) {
res += section;
section = 0;
} else {
// 如果是万,先存在 section 里,可能后面还有亿
}
val = 0;
}
else {
// 处理零,通常不需要操作,只需重置 val 为 0 即可
val = 0;
}
}
return res + section + val;
}
};

阿拉伯数字转汉字数字

反过来的核心逻辑是“四位一组”。中国数字是每 4 位(一个“万”)进一级,而不是英文的每 3 位。

处理重点:

  • 零的处理:中间连续的 0 只读一个“零”(如 1005 $\rightarrow$ 一千零五)。
  • 末尾的零:每组末尾的 0 不读(如 120 $\rightarrow$ 一百二十)。

整数转罗马数字

罗马数字是通过添加从最高到最低的小数位值的转换而形成的。将小数位值转换为罗马数字有以下规则:

  • 如果该值不是以 4 或 9 开头,请选择可以从输入中减去的最大值的符号,将该符号附加到结果,减去其值,然后将其余部分转换为罗马数字。
  • 如果该值以 4 或 9 开头,使用 减法形式,表示从以下符号中减去一个符号,例如 4 是 5 (V) 减 1 (I): IV ,9 是 10 (X) 减 1 (I):IX。仅使用以下减法形式:4 (IV),9 (IX),40 (XL),90 (XC),400 (CD) 和 900 (CM)。
  • 只有 10 的次方(I, X, C, M)最多可以连续附加 3 次以代表 10 的倍数。你不能多次附加 5 (V),50 (L) 或 500 (D)。如果需要将符号附加4次,请使用 减法形式

给定一个整数,将其转换为罗马数字。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
string intToRoman(int num) {
vector<pair<int, string>> vecs = {
{1000, "M"}, {900, "CM"}, {500, "D"}, {400, "CD"}, {100, "C"},
{90, "XC"}, {50, "L"}, {40, "XL"}, {10, "X"}, {9, "IX"},
{5, "V"}, {4, "IV"}, {1, "I"}};
string res;
for (auto& p : vecs) {
while (num >= p.first) {
num -= p.first;
res += p.second;
}
if (num == 0)
break;
}
return res;
}
};

罗马字转整数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
int romanToInt(string s) {
unordered_map<char, int> umap = {
{'I', 1}, {'V', 5}, {'X', 10}, {'L', 50},
{'C', 100}, {'D', 500}, {'M', 1000},
};
// 字符右边比左边大就是减否则+
int res{};
for (int i = 0; i < s.size(); i++) {
if (i < s.size() - 1 && umap[s[i]] < umap[s[i + 1]]) {
res -= umap[s[i]];
} else {
res += umap[s[i]];
}
}
return res;
}
};

Excel表列名称

给你一个整数 columnNumber ,返回它在 Excel 表中相对应的列名称。

例如:

1
2
3
4
5
6
7
8
A -> 1
B -> 2
C -> 3
...
Z -> 26
AA -> 27
AB -> 28
...
1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
string convertToTitle(int columnNumber) {
string res{};
while (columnNumber) {
columnNumber--;
char ch = ('A' + (columnNumber % 26));
res = ch + res;
columnNumber /= 26;
}
return res;
}
};

表列序号

给你一个字符串 columnTitle ,表示 Excel 表格中的列名称。返回 该列名称对应的列序号

1
2
3
4
5
6
7
8
9
10
class Solution {
public:
int titleToNumber(string columnTitle) {
int res{};
for (auto& ch : columnTitle) {
res = res * 26 + (ch - 'A' + 1);
}
return res;
}
};

字符串转换整数

请你来实现一个 myAtoi(string s) 函数,使其能将字符串转换成一个 32 位有符号整数。

函数 myAtoi(string s) 的算法如下:

  1. 空格:读入字符串并丢弃无用的前导空格(" "
  2. 符号:检查下一个字符(假设还未到字符末尾)为 '-' 还是 '+'。如果两者都不存在,则假定结果为正。
  3. 转换:通过跳过前置零来读取该整数,直到遇到非数字字符或到达字符串的结尾。如果没有读取数字,则结果为0。
  4. 舍入:如果整数数超过 32 位有符号整数范围 [−231, 231 − 1] ,需要截断这个整数,使其保持在这个范围内。具体来说,小于 −231 的整数应该被舍入为 −231 ,大于 231 − 1 的整数应该被舍入为 231 − 1

返回整数作为最终结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
class Solution {
public:
int myAtoi(string s) {
int idx{};
while (idx < s.size()) {
if (s[idx] == ' ') {
idx++;
continue;
} else {
break;
}
}
bool neg{};
if (s[idx] == '-') {
idx++;
neg = true;
} else if (s[idx] == '+') {
idx++;
}
int num{};
for (int i = idx; i < s.size(); i++) {
if (s[i] < '0' || s[i] > '9') {
break;
}
if (num > INT_MAX / 10 ||
((num == INT_MAX / 10 )&& (s[i]-'0') > INT_MAX % 10)) {
return neg ? INT_MIN : INT_MAX;
}
num = num * 10 + (s[i] - '0');
}

return neg ? -num : num;
}
};

为什么溢出判断是 INT_MAX / 10

这是这道题最容易写错的地方。

  • INT_MAX2147483647
  • 假设当前 res214748364
    • 如果你下一位 digit8,那么 214748364 * 10 + 8 就会变成 2147483648,这已经超过了 INT_MAX
    • 所以,如果 res > 214748364,或者 res == 214748364digit > 7,我们就直接根据正负号返回最大值或最小值。

字符串相乘

给定两个以字符串形式表示的非负整数 num1num2,返回 num1num2 的乘积,它们的乘积也表示为字符串形式。注意:不能使用任何内置的 BigInteger 库或直接将输入转换为整数。

我们可以把乘法拆解为每一位的乘积。假设 num1 长度为 $m$,num2 长度为 $n$:

  1. 结果长度:$num1 \times num2$ 的积,其位数最多为 $m + n$(例如 $99 \times 99 = 9801$,$2+2=4$ 位)。

  2. 下标映射(关键)

    如果我们从右往左遍历:

    • num1[i]num2[j] 相乘的结果 mul
    • mul低位会累加到结果数组的下标 i + j + 1
    • mul高位(进位)会累加到结果数组的下标 i + j

    image-20260211173106800

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
#include <iostream>
#include <vector>
#include <string>

using namespace std;

class Solution {
public:
string multiply(string num1, string num2) {
if (num1 == "0" || num2 == "0") return "0";

int m = num1.size(), n = num2.size();
// 结果最多有 m + n 位
vector<int> res(m + n, 0);

// 从后往前遍历进行乘法
for (int i = m - 1; i >= 0; i--) {
for (int j = n - 1; j >= 0; j--) {
// 计算当前两位数字的乘积
int mul = (num1[i] - '0') * (num2[j] - '0');
// 加上原本由于之前的计算留在该位置上的进位
int sum = mul + res[i + j + 1];

// 更新当前位和进位
res[i + j + 1] = sum % 10; // 低位
res[i + j] += sum / 10; // 进位直接累加到前一位
}
}

// 转为字符串
string ans = "";
for (int i = 0; i < res.size(); i++) {
// 跳过前导零
if (ans.empty() && res[i] == 0) continue;
ans += to_string(res[i]);
}

return ans.empty() ? "0" : ans;
}
};

模拟竖式乘法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
class Solution {
public:
string add(string num1, string num2) {
int i = num1.size() - 1;
int j = num2.size() - 1;
string r;
int car{};
while (i >= 0 || j >= 0) {
int val = ((i >= 0) ? (num1[i] - '0') : 0) +
((j >= 0) ? (num2[j] - '0') : 0) + car;
car = val / 10;
r = to_string(val % 10) + r;
i--;
j--;
}
if (car) {
r = "1" + r;
}
return r;
}
string multiply(string num1, string num2) {
if (num1 == "0"|| num2 == "0") {
return "0";
}
string res = "0";
for (int i = num1.size() - 1; i >= 0; i--) {
char& ch1 = num1[i];
string r{};
int car{};
for (int j = num2.size() - 1; j >= 0; j--) {
char& ch2 = num2[j];
int val = car + (ch1 - '0') * (ch2 - '0');
car = val / 10;
val = val % 10;
r = to_string(val) + r;
}
if (car) {
r = to_string(car) + r;
}
int offset = num1.size() - 1 - i;
r += string(offset, '0');
res = add(r, res);
}
return res;
}
};

KMP

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
class Solution {
public:
void getNext(int* next, const string& s) {
int j = -1;
next[0] = j;
for(int i = 1; i < s.size(); i++) { // 注意i从1开始
while (j >= 0 && s[i] != s[j + 1]) { // 前后缀不相同了
j = next[j]; // 向前回退
}
if (s[i] == s[j + 1]) { // 找到相同的前后缀
j++;
}
next[i] = j; // 将j(前缀的长度)赋给next[i]
}
}
int strStr(string haystack, string needle) {
if (needle.size() == 0) {
return 0;
}
vector<int> next(needle.size());
getNext(&next[0], needle);
int j = -1; // // 因为next数组里记录的起始位置为-1
for (int i = 0; i < haystack.size(); i++) { // 注意i就从0开始
while(j >= 0 && haystack[i] != needle[j + 1]) { // 不匹配
j = next[j]; // j 寻找之前匹配的位置
}
if (haystack[i] == needle[j + 1]) { // 匹配,j和i同时向后移动
j++; // i的增加在for循环里
}
if (j == (needle.size() - 1) ) { // 文本串s里出现了模式串t
return (i - needle.size() + 1);
}
}
return -1;
}
};

链表

合并有序链表

将两个升序链表合并为一个新的 升序 链表并返回。新链表是通过拼接给定的两个链表的所有节点组成的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class Solution {
public:
ListNode* mergeTwoLists(ListNode* list1, ListNode* list2) {
ListNode* dummy = new ListNode;
ListNode* cur = dummy;
while (list1 && list2) {
if (list1->val < list2->val) {
cur->next = list1;
list1 = list1->next;
} else {
cur->next = list2;
list2 = list2->next;
}
cur = cur->next;
}
if (list1) {
cur->next = list1;
}
if (list2) {
cur->next = list2;
}
return dummy->next;
}
};

排序链表

给你链表的头结点 head ,请将其按 升序 排列并返回 排序后的链表

归并排序链表可以分为三个步骤:

  1. 切分(Split):使用快慢指针找到链表的中点,将链表断开成左右两部分。
  2. 递归(Recursive Sort):递归地对左右两部分进行排序。
  3. 合并(Merge):将两个有序的小链表合并成一个大的有序链表(类似于“合并两个有序链表”)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class Solution {
public:
ListNode* sortList(ListNode* head) {
// 基准情况:如果链表为空或只有一个节点,直接返回
if (!head || !head->next) return head;

// 1. 找到中点并断开
ListNode* mid = getMid(head);
ListNode* rightHead = mid->next;
mid->next = nullptr; // 【关键】切断连接

// 2. 递归排序左右两半
ListNode* left = sortList(head);
ListNode* right = sortList(rightHead);

// 3. 合并有序链表
return merge(left, right);
}

private:
// 快慢指针找中点(返回前半部分的最后一个节点)
ListNode* getMid(ListNode* head) {
ListNode *slow = head, *fast = head->next;
while (fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
}
return slow;
}

// 合并两个有序链表
ListNode* merge(ListNode* l1, ListNode* l2) {
ListNode dummy(0);
ListNode* tail = &dummy;
while (l1 && l2) {
if (l1->val < l2->val) {
tail->next = l1;
l1 = l1->next;
} else {
tail->next = l2;
l2 = l2->next;
}
tail = tail->next;
}
tail->next = l1 ? l1 : l2;
return dummy.next;
}
};

反转链表

给你单链表的头节点 head ,请你反转链表,并返回反转后的链表。

1
2
3
4
5
6
7
8
ListNode* reverseNode(ListNode* node, ListNode* prev = nullptr) {
if (node == nullptr) {
return prev;
}
ListNode* n = node->next;
node->next = prev;
return reverseNode(n, node);
}

反转链表II

给你单链表的头指针 head 和两个整数 leftright ,其中 left <= right 。请你反转从位置 left 到位置 right 的链表节点,返回 反转后的链表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
class Solution {
public:
ListNode* reverseNode(ListNode* node, ListNode* prev = nullptr) {
if (node == nullptr) {
return prev;
}
ListNode* n = node->next;
node->next = prev;
return reverseNode(n, node);
}
ListNode* reverseBetween(ListNode* head, int left, int right) {
ListNode* dummy = new ListNode;
dummy->next = head;
ListNode* cur = dummy;
left--;
while(left--) {
cur = cur->next;
}
// 找到左边节点的前一个节点
// 方便进行链表头反转
ListNode* pre = cur;
cur = dummy;
while(right--) {
cur = cur->next;
}
// 找到右边节点
// 方便找到下一个节点并设置nullptr
ListNode* rightNode = cur;
ListNode* tail = rightNode->next;
rightNode->next = nullptr;
ListNode* start = pre->next;
pre->next = reverseNode(pre->next);
start->next = tail;
return dummy->next;
}
};

image-20260222222516630

删除链表的倒数第 N 个结点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
// 双指针
ListNode* dummy = new ListNode;
dummy->next = head;
ListNode *slow = dummy, *fast = dummy;
while (n--) {
fast = fast->next;
}
while (fast && fast->next) {
fast = fast->next;
slow = slow->next;
}
slow->next = slow->next->next;
return dummy->next;
}
};

回文链表

给你一个单链表的头节点 head ,请你判断该链表是否为回文链表。如果是,返回 true ;否则,返回 false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
class Solution {
public:
ListNode* getPreMidNode(ListNode* node) {
if (!node) {
return nullptr;
}
ListNode *slow = node, *fast = node;
while (fast->next && fast->next->next) {
fast = fast->next->next;
slow = slow->next;
}
return slow;
}
ListNode* reverseNode(ListNode* node, ListNode* prev = nullptr) {
if (!node) {
return prev;
}
ListNode* next_node = node->next;
node->next = prev;
return reverseNode(next_node, node);
}
bool isPalindrome(ListNode* head) {
// 中点
ListNode* preNode = getPreMidNode(head);
ListNode* mid = preNode->next;
preNode->next = nullptr;
// 反转
mid = reverseNode(mid);
// 对比
ListNode* cur = head;
while (cur && mid) {
if (cur->val != mid->val) {
return false;
}
cur = cur->next;
mid = mid->next;
}
return true;
}
};

链表的中间节点

给你单链表的头结点 head ,请你找出并返回链表的中间结点。如果有两个中间结点,则返回第二个中间结点

1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
ListNode* middleNode(ListNode* head) {
ListNode* slow = head,*fast = head;
while(fast && fast->next) {
fast = fast->next->next;
slow = slow->next;
}
return slow;
}
};

删除链表的中间节点

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
ListNode* deleteMiddle(ListNode* head) {
ListNode* dummy = new ListNode;
dummy->next = head;
ListNode *slow = dummy, *fast = head;
while (fast && fast->next) {
fast = fast->next->next;
slow = slow->next;
}
slow->next = slow->next->next;
return dummy->next;
}
};

重排链表

给定一个单链表 L 的头节点 head ,单链表 L 表示为:

1
L0 → L1 → … → Ln - 1 → Ln

请将其重新排列后变为:

1
L0 → Ln → L1 → Ln - 1 → L2 → Ln - 2 → …

不能只是单纯的改变节点内部的值,而是需要实际的进行节点交换。

寻找链表中点 + 链表逆序 + 合并链表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
class Solution {
public:
void reorderList(ListNode* head) {
if (!head || !head->next || !head->next->next) return;

// 1. 使用快慢指针找到中点
ListNode *slow = head, *fast = head;
while (fast->next && fast->next->next) {
slow = slow->next;
fast = fast->next->next;
}

// 2. 翻转后半部分链表 (从 slow->next 开始)
ListNode* mid = slow;
ListNode* l2 = mid->next;
mid->next = nullptr; // 断开前半部分和后半部分
l2 = reverseList(l2);

// 3. 交错合并两个链表 l1 和 l2
ListNode* l1 = head;
while (l1 && l2) {
ListNode* next1 = l1->next;
ListNode* next2 = l2->next;

l1->next = l2;
l2->next = next1;

l1 = next1;
l2 = next2;
}
}

private:
ListNode* reverseList(ListNode* head) {
ListNode* prev = nullptr;
ListNode* curr = head;
while (curr) {
ListNode* nextTemp = curr->next;
curr->next = prev;
prev = curr;
curr = nextTemp;
}
return prev;
}
};

环形链表

给你一个链表的头节点 head ,判断链表中是否有环。

如果链表中有某个节点,可以通过连续跟踪 next 指针再次到达,则链表中存在环。 为了表示给定链表中的环,评测系统内部使用整数 pos 来表示链表尾连接到链表中的位置(索引从 0 开始)。注意:pos 不作为参数进行传递 。仅仅是为了标识链表的实际情况。

如果链表中存在环 ,则返回 true 。 否则,返回 false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Solution {
public:
bool hasCycle(ListNode *head) {
ListNode* slow = head,*fast = head;
while(fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if(slow == fast) {
return true;
}
}
return false;
}
};

环形链表II 给定一个链表,返回链表开始入环的第一个节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
class Solution {
public:
ListNode *detectCycle(ListNode *head) {
// 先使用
ListNode* slow= head,*fast = head;
while(fast && fast->next) {
slow = slow->next;
fast = fast->next->next;
if(slow == fast) {
// 快慢指针相遇 快指针领先一圈
// a+b+(b+c) = 2*(a+b)
// => a = c
ListNode* cur = head;
while(cur!=slow) {
cur = cur->next;
slow = slow->next;
}
return cur;
}
}
return nullptr;


}
};

相交链表

给定两个单链表的头节点 headAheadB ,请找出并返回两个单链表相交的起始节点。如果两个链表没有交点,返回 null

1
2
3
4
5
6
7
8
9
10
11
class Solution {
public:
ListNode *getIntersectionNode(ListNode *headA, ListNode *headB) {
ListNode* cur1 = headA,*cur2 = headB;
while(cur1!=cur2) {
cur1 = (cur1 == nullptr)? headB:cur1->next;
cur2 = (cur2 == nullptr)? headA:cur2->next;
}
return cur1;
}
};

链表最大孪生和

在一个大小为 nn偶数 的链表中,对于 0 <= i <= (n / 2) - 1i ,第 i 个节点(下标从 0 开始)的孪生节点为第 (n-1-i) 个节点 。

  • 比方说,n = 4 那么节点 0 是节点 3 的孪生节点,节点 1 是节点 2 的孪生节点。这是长度为 n = 4 的链表中所有的孪生节点。

孪生和 定义为一个节点和它孪生节点两者值之和。

给你一个长度为偶数的链表的头节点 head ,请你返回链表的 最大孪生和

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
class Solution {
public:
ListNode* midNode(ListNode* node) {
ListNode *slow = node, *fast = node;
while (fast && fast->next) {
fast = fast->next->next;
slow = slow->next;
}
return slow;
}
ListNode* revereNode(ListNode* node) {
ListNode* prev{};
while (node) {
ListNode* n = node->next;
node->next = prev;
prev = node;
node = n;
}
return prev;
}
int pairSum(ListNode* head) {
// 获取中间节点
ListNode* mid = midNode(head);
ListNode* l=revereNode(mid);
int max_val{INT_MIN};
while(head && l) {
max_val = max(max_val,head->val+l->val);
head = head->next;
l = l->next;
}
return max_val;
}
};

K 个一组翻转链表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
class Solution {
public:
ListNode* reverseKGroup(ListNode* head, int k) {
// 创建一个虚拟头节点,方便处理
ListNode* dummy = new ListNode(0);
dummy->next = head;

ListNode* prev = dummy; // 上一组的尾节点
ListNode* end = dummy; // 当前组的尾节点

while (end->next != nullptr) {
// 找到当前组的尾节点
for (int i = 0; i < k && end != nullptr; i++) {
end = end->next;
}

// 如果不足 k 个,直接结束
if (end == nullptr) {
break;
}

// 记录当前组的头节点和下一组的头节点
ListNode* start = prev->next;
ListNode* nextGroup = end->next;

// 断开当前组与后面的连接
end->next = nullptr;

// 翻转当前组
prev->next = reverseList(start);

// 连接翻转后的链表
start->next = nextGroup;

// 更新 prev 和 end 为下一组的前一个节点
prev = start;
end = prev;
}

ListNode* result = dummy->next;
delete dummy;
return result;
}

private:
// 翻转整个链表
ListNode* reverseList(ListNode* head) {
ListNode* prev = nullptr;
ListNode* curr = head;

while (curr != nullptr) {
ListNode* next = curr->next;
curr->next = prev;
prev = curr;
curr = next;
}

return prev;
}
};

两两交换链表中的节点

给你一个链表,两两交换其中相邻的节点,并返回交换后链表的头节点。你必须在不修改节点内部的值的情况下完成本题(即,只能进行节点交换)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public:
ListNode* swapPairs(ListNode* head) {
if (!head) {
return nullptr;
}
ListNode* dummy = new ListNode;
dummy->next = head;
ListNode* pre = dummy;
ListNode* next_node = head;
while (next_node && next_node->next) {
// 交换节点
ListNode* secondNode = next_node->next;
pre->next = secondNode;
next_node->next = secondNode->next;
secondNode->next = next_node;

pre = next_node;
next_node = pre->next;
}
return dummy->next;
}
};

递归,递归的终止条件是链表中没有节点,或者链表中只有一个节点,此时无法进行交换。

递归过程

  1. 设当前头节点为 head,下一个节点为 next
  2. head 的下一个节点应该是“后续所有节点两两交换后”的结果。
  3. next 的下一个节点指向当前的 head
  4. 返回 next 作为这部分交换后的新头节点。
1
2
3
4
5
6
7
8
9
10
11
12
class Solution {
public:
ListNode* swapPairs(ListNode* head) {
if (head == nullptr || head->next == nullptr) {
return head;
}
ListNode* newHead = head->next;
head->next = swapPairs(newHead->next);
newHead->next = head;
return newHead;
}
};

交换链表中的节点

给你链表的头节点 head 和一个整数 k

交换 链表正数第 k 个节点和倒数第 k 个节点的值后,返回链表的头节点(链表 从 1 开始索引

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
ListNode* swapNodes(ListNode* head, int k) {
// 快慢指针
ListNode *slow = head, *fast = head;
k--;
while (k--) {
fast = fast->next;
}
ListNode* node1 = fast;
while(fast && fast->next) {
fast = fast->next;
slow = slow->next;
}
ListNode* node2 = slow;
swap(node1->val,node2->val);
return head;
}
};

翻转偶数长度组的节点

给你一个链表的头节点 head

链表中的节点 按顺序 划分成若干 非空 组,这些非空组的长度构成一个自然数序列(1, 2, 3, 4, ...)。一个组的 长度 就是组中分配到的节点数目。换句话说:

  • 节点 1 分配给第一组
  • 节点 23 分配给第二组
  • 节点 456 分配给第三组,以此类推

注意,最后一组的长度可能小于或者等于 1 + 倒数第二组的长度

反转 每个 偶数 长度组中的节点,并返回修改后链表的头节点 head

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
class Solution {
public:
ListNode* reverseEvenLengthGroups(ListNode* head) {
// 创建虚拟头节点,简化边界处理
ListNode* dummy = new ListNode(0);
dummy->next = head;

// pre: 当前组的前一个节点(上一组的尾节点)
// tail: 用于遍历找到当前组的尾节点
ListNode* pre = dummy;
ListNode* tail = dummy;

int groupSize = 1; // 当前组期望的长度

// 循环处理每一组
while (tail->next != nullptr) {
int actualCount = 0; // 当前组实际有多少个节点

// 步骤1: 找到当前组的尾节点
// 尝试走 groupSize 步,但可能走不完(最后一组)
for (int i = 0; i < groupSize && tail->next != nullptr; i++) {
tail = tail->next; // 移动到当前组的下一个节点
actualCount++; // 统计实际节点数
}

// 步骤2: 判断是否需要反转
if (actualCount % 2 == 0) {
// 情况A: 需要反转当前组(实际长度为偶数)

// 记录当前组的起始节点和下一组的起始节点
ListNode* groupStart = pre->next; // 当前组的第一个节点
ListNode* nextGroupStart = tail->next; // 下一组的第一个节点

// 断开当前组与下一组的连接,便于单独反转
tail->next = nullptr;

// 反转当前组
ListNode* reversedGroupHead = reverseList(groupStart);

// 重新连接链表
pre->next = reversedGroupHead; // 上一组的尾连接反转后的组头
groupStart->next = nextGroupStart; // 反转前的组头(现在是组尾)连接下一组

// 更新 pre 和 tail 为当前组的尾节点(反转前的头节点)
pre = groupStart;
tail = groupStart;
} else {
// 情况B: 不需要反转(实际长度为奇数)
// 直接让 pre 指向当前组的尾节点,为下一组做准备
pre = tail;
// tail 已经在正确位置,不需要移动
}

// 步骤3: 准备处理下一组,期望长度+1
groupSize++;
}

ListNode* result = dummy->next;
delete dummy; // 清理虚拟头节点
return result;
}

private:
// 反转链表的辅助函数
ListNode* reverseList(ListNode* head) {
ListNode* prev = nullptr; // 前一个节点
ListNode* curr = head; // 当前节点

while (curr != nullptr) {
ListNode* nextNode = curr->next; // 保存下一个节点
curr->next = prev; // 反转指针方向
prev = curr; // 前移prev
curr = nextNode; // 前移curr
}

return prev; // 返回新的头节点
}
};

删除链表的倒数第N个节点

给你一个链表,删除链表的倒数第 n 个结点,并且返回链表的头结点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
ListNode* removeNthFromEnd(ListNode* head, int n) {
// 双指针
ListNode* dummy = new ListNode;
dummy->next = head;
ListNode *slow = dummy, *fast = dummy;
while (n--) {
fast = fast->next;
}
while (fast && fast->next) {
fast = fast->next;
slow = slow->next;
}
slow->next = slow->next->next;
return dummy->next;
}
};

数字/数论题目

数根又称数字根(Digital root),是自然数的一种性质,每个自然数都有一个数根。对于给定的自然数,反复将各个位上的数字相加,直到结果为一位数,则该一位数即为原自然数的数根。

计算数根的最直观的方法是模拟计算各位相加的过程,直到剩下的数字是一位数。利用自然数的性质,则能在 O(1) 的时间内计算数根。

给定一个非负整数 num,反复将各个位上的数字相加,直到结果为一位数。返回这个结果。

对 num 分类讨论:

num 不是 9 的倍数时,其数根即为 num 除以 9 的余数。

num 是 9 的倍数时:

如果 num=0,则其数根是 0;

如果 num>0,则各位相加的结果大于 0,其数根也大于 0,因此其数根是

1
2
3
4
5
6
class Solution {
public:
int addDigits(int num) {
return (num - 1) % 9 + 1;
}
};

丑数

丑数 就是只包含质因数 235 整数。

给你一个整数 n ,请你判断 n 是否为 丑数 。如果是,返回 true ;否则,返回 false

1
2
3
4
5
6
7
8
9
10
11
12
13
class Solution {
public:
bool isUgly(int n) {
if(n<=0) return false;
vector<int> nums{2,3,5};
for(auto& num:nums) {
while(n%num==0) {
n/=num;
}
}
return n==1;
}
};

给你一个整数 n ,请你找出并返回第 n丑数丑数 就是质因子只包含 235 的正整数

我们要维护一个有序的丑数列表。假设我们已经有了前几个丑数,下一个丑数一定是:

  • 之前的某个丑数 $\times 2$
  • 之前的某个丑数 $\times 3$
  • 之前的某个丑数 $\times 5$

为了保证列表的有序性,我们每次都选这三个乘积中最小的那一个。

算法逻辑

  1. 定义数组 dpdp[i] 表示第 $i+1$ 个丑数。初始化 dp[0] = 1
  2. 定义三个指针 p2, p3, p5,初始都指向下标 0
  3. 循环 $n-1$ 次:
    • 计算 next2 = dp[p2] * 2, next3 = dp[p3] * 3, next5 = dp[p5] * 5
    • 取三者最小值作为下一个丑数:dp[i] = min(next2, next3, next5)
    • 关键点:哪个指针产生的最小值,就把哪个指针向后移一位。如果多个指针产生的乘积相同(比如 $2 \times 3 = 6$ 和 $3 \times 2 = 6$),则这些指针都要移动,以实现去重。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
class Solution {
public:
// 生成最小质因数
int nthUglyNumber(int n) {
vector<int> dp(n);
dp[0] = 1;
int n2{}, n3{}, n5{};
for (int i = 1; i < n; i++) {
int p2 = dp[n2] * 2;
int p3 = dp[n3] * 3;
int p5 = dp[n5] * 5;
int min_num = min(min(p2, p3), p5);
dp[i] = min_num;
if (min_num == p2) {
n2++;
}
if (min_num == p3) {
n3++;
}
if (min_num == p5) {
n5++;
}
}
return dp[n - 1];
}
};

可以把寻找丑数的过程看作是合并三个有序链表

  • 链表 1:$1\times2, 2\times2, 3\times2, 4\times2, 5\times2, \dots$
  • 链表 2:$1\times3, 2\times3, 3\times3, 4\times3, 5\times3, \dots$
  • 链表 3:$1\times5, 2\times5, 3\times5, 4\times5, 5\times5, \dots$

由于我们每次都从这三个链表的“头部”取最小值,且“头部”的定义是由 p2, p3, p5 指向的 dp 元素决定的,因此我们能保证生成的 dp 数组是严格递增且不遗漏任何丑数的。

丑数是可以被 a b c 整除的 正整数

给你四个整数:nabc ,请你设计一个算法来找出第 n 个丑数。 二分+容斥原理

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class Solution {
public:
long long gcd(int a, int b) { return b == 0 ? a : gcd(b, a % b); }
long long lcm(int a, int b) { return (long long)a / gcd(a, b) * b; }
// 计算[1, x]范围内丑数的个数(容斥原理)
long long count(long long x, long long a, long long b, long long c, long long lcm_ab,
long long lcm_ac, long long lcm_bc, long long lcm_abc) {
return (x / a + x / b + x / c - x / lcm_ab - x / lcm_ac - x / lcm_bc +
x / lcm_abc);
}
int nthUglyNumber(int n, int a, int b, int c) {
long long lcm_ab = lcm(a, b);
long long lcm_ac = lcm(a, c);
long long lcm_bc = lcm(c, b);
long long lcm_abc = lcm(lcm_ab, c);
int left = 1, right = 2e9;
while (left < right) {
int mid = (right - left) / 2 + left;
if (count(mid, a, b, c, lcm_ab, lcm_ac, lcm_bc, lcm_abc) < n) {
left = mid + 1;
} else {
right = mid;
}
}
return left;
}
};

超级丑数 是一个正整数,并满足其所有质因数都出现在质数数组 primes 中。给你一个整数 n 和一个整数数组 primes ,返回第 n超级丑数 。题目数据保证第 n超级丑数32-bit 带符号整数范围内。

定义数组 dp,其中 dp[i] 表示第 i 个超级丑数,第 n 个超级丑数即为 dp[n]。

由于最小的超级丑数是 1,因此 dp[1]=1。

如何得到其余的超级丑数呢?创建与数组 primes 相同长度的数组 pointers,表示下一个超级丑数是当前指针指向的超级丑数乘以对应的质因数。初始时,数组 pointers 的元素值都是 1。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public:
int nthSuperUglyNumber(int n, vector<int>& primes) {
vector<long> dp(n + 1);
int m = primes.size();
vector<int> pointers(m, 0);
vector<long> nums(m, 1);
for (int i = 1; i <= n; i++) {
long minNum = INT_MAX;
for (int j = 0; j < m; j++) {
minNum = min(minNum, nums[j]);
}
dp[i] = minNum;
for (int j = 0; j < m; j++) {
if (nums[j] == minNum) {
pointers[j]++;
nums[j] = dp[pointers[j]] * primes[j];
}
}
}
return dp[n];
}
};

快乐数

编写一个算法来判断一个数 n 是不是快乐数。

「快乐数」 定义为:

  • 对于一个正整数,每一次将该数替换为它每个位置上的数字的平方和。
  • 然后重复这个过程直到这个数变为 1,也可能是 无限循环 但始终变不到 1。
  • 如果这个过程 结果为 1,那么这个数就是快乐数。

如果 n快乐数 就返回 true ;不是,则返回 false

快慢指针,通过反复调用 getNext(n) 得到的链是一个隐式的链表。隐式意味着我们没有实际的链表节点和指针,但数据仍然形成链表结构。起始数字是链表的头 “节点”,链中的所有其他数字都是节点。next 指针是通过调用 getNext(n) 函数获得。意识到实际有个链表,那么这个问题就可以转换为检测一个链表是否有环

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
int getNext(int num) {
int res{};
while(num) {
res += (num%10)*(num%10);
num/=10;
}
return res;
}
bool isHappy(int n) {
int slowNum = n,fastNum = getNext(n);
while(fastNum!= 1 && fastNum!=slowNum) {
slowNum = getNext(slowNum);
fastNum = getNext(getNext(fastNum));
}
return fastNum == 1;
}
};

加一

给定一个表示 大整数 的整数数组 digits,其中 digits[i] 是整数的第 i 位数字。这些数字按从左到右,从最高位到最低位排列。这个大整数不包含任何前导 0。将大整数加 1,并返回结果的数字数组。

只需要对数组 digits 进行一次逆序遍历,找出第一个不为 9 的元素,将其加一并将后续所有元素置零即可。如果 digits 中所有的元素均为 9,那么对应着「思路」部分的第三种情况,我们需要返回一个新的数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
vector<int> plusOne(vector<int>& digits) {
int sz =digits.size();
for(int i = sz-1;i>=0;i--) {
if(digits[i]!=9) {
digits[i] +=1;
for(int j = i+1;j<sz;j++) {
digits[j] = 0;
}
return digits;
}
}
//全为9
vector<int> ans(1+sz);
ans[0] = 1;
return ans;
}
};

给单链表加一

给定一个用链表表示的非负整数, 然后将这个整数 再加上 1

这些数字的存储是这样的:最高位有效的数字位于链表的首位 head

算法步骤

  • 初始化哨兵节点为 ListNode(0) 并将其设置为新的头节点:sentinel.next = head。

  • 找到最右边的不等于九的数字。

  • 将该数字加一。
  • 将所有后面的九都设为零。
  • 如果哨兵节点被设置为1,则返回哨兵节点, 否则返回头 sentinel.next。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Solution {
public:
ListNode* plusOne(ListNode* head) {
// 找到最右边的非9数字
ListNode* dummy = new ListNode(0);
dummy->next = head;
ListNode* flag = dummy;
while (head) {
if (head->val != 9) {
flag = head;
}
head = head->next;
}
flag->val += 1;
flag = flag->next;
// 将后面的值置为0
while (flag) {
flag->val = 0;
flag = flag->next;
}
return (dummy->val == 0)?dummy->next:dummy;
}
};

数组形式整数加法

整数的 数组形式 num 是按照从左到右的顺序表示其数字的数组。

  • 例如,对于 num = 1321 ,数组形式是 [1,3,2,1]

给定 num ,整数的 数组形式 ,和整数 k ,返回 整数 num + k数组形式

任何时候,若加法的结果大于等于 10,把进位的 1 加入到下一位的计算中,所以最终结果为 1035。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class Solution {
public:
vector<int> addToArrayForm(vector<int>& num, int k) {
int sz = num.size();
vector<int> ans;
for (int i = sz - 1; i >= 0; i--) {
int val = num[i] + k % 10;
k /= 10;
k += val / 10;
ans.push_back(val % 10);
}
while (k>0) {
ans.push_back(k % 10);
k /= 10;
}
reverse(ans.begin(),ans.end());
return ans;
}
};

将整数减少到零需要的最少操作数

给你一个正整数 n ,你可以执行下述操作 任意 次:

  • n 加上或减去 2 的某个

返回使 n 等于 0 需要执行的 最少 操作数。

如果 x == 2i 且其中 i >= 0 ,则数字 x2 的幂。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
class Solution {
public:
unordered_map<int, int> memo;
int dfs(int num) {
if (memo.count(num)) {
return memo[num];
}
if ((num & 1) == 0) {
// 00 10
memo[num] = dfs(num >> 1);
} else {
// 如果是奇数
// 01 11
if ((num & 3) == 3) {
memo[num] = 1 + dfs(num + 1);
} else {
memo[num] = 1 + dfs(num - 1);
}
}
return memo[num];
}
int minOperations(int n) {
// 如果是偶数 00 10 直接减去
memo[0] = 0;
return dfs(n);
}
};

class Solution {
public:
unordered_map<int, int> memo;
int dfs(int num) {
if (memo.count(num)) {
return memo[num];
}
int r{};
if ((num & (num - 1)) == 0) {
r = 1;
} else {
int lb = num & (-num);
r = 1 + min(dfs(num + lb), dfs(num - lb));
}
memo[num] = r;
return r;
}
int minOperations(int n) {
// 如果是偶数 00 10 直接减去
memo[0] = 0;
return dfs(n);
}
};

计算字符串的数字和

给你一个由若干数字(0 - 9)组成的字符串 s ,和一个整数。

如果 s 的长度大于 k ,则可以执行一轮操作。在一轮操作中,需要完成以下工作:

  1. s 拆分 成长度为 k 的若干 连续数字组 ,使得前 k 个字符都分在第一组,接下来的 k 个字符都分在第二组,依此类推。注意,最后一个数字组的长度可以小于 k
  2. 用表示每个数字组中所有数字之和的字符串来 替换 对应的数字组。例如,"346" 会替换为 "13" ,因为 3 + 4 + 6 = 13
  3. 合并 所有组以形成一个新字符串。如果新字符串的长度大于 k 则重复第一步。

返回在完成所有轮操作后的 s

以模拟题目中的操作过程更新字符串 s,具体在每一轮操作中:

我们用字符串(或数组,视不同语言字符串的实现方式而确定)tmp 来维护该轮操作的结果。随后,我们遍历字符串 s,以每 k 个字符为一组,计算该组的数字和 val,并转化为字符串添加至 tmp 尾部。最终,我们将 s 更新为 tmp 所表示的字符串。

我们执行上述操作直到 s 的长度小于等于 k 为止,并最终返回 s 作为答案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Solution {
public:
string digitSum(string s, int k) {
while (s.size() > k) {
string ns;
int sz = s.size();
for (int i = 0; i < s.size(); i += k) {
int num{};
for (int j = i; j < i + k && j < sz; j++) {
num += (s[j] - '0');
}
ns += to_string(num);
}
s = ns;
}
return s;
}
};

image-20260221213221536

图算法

最短路径

最短路是图论中的经典问题即:给出一个有向图,一个起点,一个终点,问起点到终点的最短路径。

dijkstra

dijkstra算法:在有权图(权值非负数)中求从起点到其他节点的最短路径算法。

需要注意两点:

  • dijkstra 算法可以同时求 起点到所有节点的最短路径
  • 权值不能为负数

dijkstra 算法是贪心的思路,不断寻找距离 源点最近的没有访问过的节点。minDist数组 用来记录每一个节点距离源点的最小距离

dijkstra三部曲

  1. 第一步,选源点到哪个节点近且该节点未被访问过
  2. 第二步,该最近节点被标记访问过
  3. 第三步,更新非访问节点到源点的距离(即更新minDist数组)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
int main() {
int n, m, p1, p2, val;
cin >> n >> m;
vector<vector<int>> grid(n + 1, vector<int>(n + 1, INT_MAX));
for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
grid[p1][p2] = val;
}

int start = 1;
int end = n;

std::vector<int> minDist(n + 1, INT_MAX);

std::vector<bool> visited(n + 1, false);

minDist[start] = 0;

//加上初始化
vector<int> parent(n + 1, -1);

for (int i = 1; i <= n; i++) {

int minVal = INT_MAX;
int cur = 1;

for (int v = 1; v <= n; ++v) {
if (!visited[v] && minDist[v] < minVal) {
minVal = minDist[v];
cur = v;
}
}

visited[cur] = true;

for (int v = 1; v <= n; v++) {
if (!visited[v] && grid[cur][v] != INT_MAX && minDist[cur] + grid[cur][v] < minDist[v]) {
minDist[v] = minDist[cur] + grid[cur][v];
parent[v] = cur; // 记录边
}
}

}

// 输出最短情况
for (int i = 1; i <= n; i++) {
cout << parent[i] << "->" << i << endl;
}
}

邻接表存储+堆优化版本,其实思路依然是 dijkstra 三部曲:

  1. 第一步,选源点到哪个节点近且该节点未被访问过
  2. 第二步,该最近节点被标记访问过
  3. 第三步,更新非访问节点到源点的距离(即更新minDist数组)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
// 小顶堆
class mycomparison {
public:
bool operator()(const pair<int, int>& lhs, const pair<int, int>& rhs) {
return lhs.second > rhs.second;
}
};
// 定义一个结构体来表示带权重的边
struct Edge {
int to; // 邻接顶点
int val; // 边的权重

Edge(int t, int w): to(t), val(w) {} // 构造函数
};

int main() {
int n, m, p1, p2, val;
cin >> n >> m;

vector<list<Edge>> grid(n + 1);

for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
// p1 指向 p2,权值为 val
grid[p1].push_back(Edge(p2, val));

}

int start = 1; // 起点
int end = n; // 终点

// 存储从源点到每个节点的最短距离
std::vector<int> minDist(n + 1, INT_MAX);

// 记录顶点是否被访问过
std::vector<bool> visited(n + 1, false);

// 优先队列中存放 pair<节点,源点到该节点的权值>
priority_queue<pair<int, int>, vector<pair<int, int>>, mycomparison> pq;


// 初始化队列,源点到源点的距离为0,所以初始为0
pq.push(pair<int, int>(start, 0));

minDist[start] = 0; // 起始点到自身的距离为0

while (!pq.empty()) {
// 1. 第一步,选源点到哪个节点近且该节点未被访问过 (通过优先级队列来实现)
// <节点, 源点到该节点的距离>
pair<int, int> cur = pq.top(); pq.pop();

if (visited[cur.first]) continue;

// 2. 第二步,该最近节点被标记访问过
visited[cur.first] = true;

// 3. 第三步,更新非访问节点到源点的距离(即更新minDist数组)
for (Edge edge : grid[cur.first]) { // 遍历 cur指向的节点,cur指向的节点为 edge
// cur指向的节点edge.to,这条边的权值为 edge.val
if (!visited[edge.to] && minDist[cur.first] + edge.val < minDist[edge.to]) { // 更新minDist
minDist[edge.to] = minDist[cur.first] + edge.val;
pq.push(pair<int, int>(edge.to, minDist[edge.to]));
}
}

}

if (minDist[end] == INT_MAX) cout << -1 << endl; // 不能到达终点
else cout << minDist[end] << endl; // 到达终点最短路径
}

Bellman_ford

带负权值的单源最短路问题,Bellman_ford算法的核心思想是 对所有边进行松弛n-1次操作(n为节点数量),从而求得目标最短路。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
int main() {
int n, m, p1, p2, val;
cin >> n >> m;

vector<vector<int>> grid;

// 将所有边保存起来
for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
// p1 指向 p2,权值为 val
grid.push_back({p1, p2, val});

}
int start = 1; // 起点
int end = n; // 终点

vector<int> minDist(n + 1 , INT_MAX);
minDist[start] = 0;
for (int i = 1; i < n; i++) { // 对所有边 松弛 n-1 次
for (vector<int> &side : grid) { // 每一次松弛,都是对所有边进行松弛
int from = side[0]; // 边的出发点
int to = side[1]; // 边的到达点
int price = side[2]; // 边的权值
// 松弛操作
// minDist[from] != INT_MAX 防止从未计算过的节点出发
if (minDist[from] != INT_MAX && minDist[to] > minDist[from] + price) {
minDist[to] = minDist[from] + price;
}
}
}
if (minDist[end] == INT_MAX) cout << "unconnected" << endl; // 不能到达终点
else cout << minDist[end] << endl; // 到达终点最短路径

}

队列优化版本(SPFA)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52

struct Edge { //邻接表
int to; // 链接的节点
int val; // 边的权重

Edge(int t, int w): to(t), val(w) {} // 构造函数
};

int main() {
int n, m, p1, p2, val;
cin >> n >> m;

vector<list<Edge>> grid(n + 1);

vector<bool> isInQueue(n + 1); // 加入优化,已经在队里里的元素不用重复添加

// 将所有边保存起来
for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
// p1 指向 p2,权值为 val
grid[p1].push_back(Edge(p2, val));
}
int start = 1; // 起点
int end = n; // 终点

vector<int> minDist(n + 1 , INT_MAX);
minDist[start] = 0;

queue<int> que;
que.push(start);

while (!que.empty()) {

int node = que.front(); que.pop();
isInQueue[node] = false; // 从队列里取出的时候,要取消标记,我们只保证已经在队列里的元素不用重复加入
for (Edge edge : grid[node]) {
int from = node;
int to = edge.to;
int value = edge.val;
if (minDist[to] > minDist[from] + value) { // 开始松弛
minDist[to] = minDist[from] + value;
if (isInQueue[to] == false) { // 已经在队列里的元素不用重复添加
que.push(to);
isInQueue[to] = true;
}
}
}

}
if (minDist[end] == INT_MAX) cout << "unconnected" << endl; // 不能到达终点
else cout << minDist[end] << endl; // 到达终点最短路径
}

判断负权回路:有负权回路的情况下,一直都会有更短的最短路,所以 松弛 第n次,minDist数组 也会发生改变再多松弛一次,看minDist数组 是否发生变化. 如果是SPFA,节点加入队列的次数超过了 n-1次 ,那么该图就一定有负权回路。

单源有限最短路径

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
int main() {
int src, dst,k ,p1, p2, val ,m , n;

cin >> n >> m;

vector<vector<int>> grid;

for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
grid.push_back({p1, p2, val});
}

cin >> src >> dst >> k;

vector<int> minDist(n + 1 , INT_MAX);
minDist[src] = 0;
vector<int> minDist_copy(n + 1); // 用来记录上一次遍历的结果
for (int i = 1; i <= k + 1; i++) {
minDist_copy = minDist; // 获取上一次计算的结果
for (vector<int> &side : grid) {
int from = side[0];
int to = side[1];
int price = side[2];
// 注意使用 minDist_copy 来计算 minDist
if (minDist_copy[from] != INT_MAX && minDist[to] > minDist_copy[from] + price) {
//在每次计算 minDist 时候,要基于 对所有边上一次松弛的 minDist 数值才行,所以我们要记录上一次松弛的minDist
minDist[to] = minDist_copy[from] + price;
}
}
}
if (minDist[dst] == INT_MAX) cout << "unreachable" << endl; // 不能到达终点
else cout << minDist[dst] << endl; // 到达终点最短路径

}

Floyd算法

多源最短路算法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

int main() {
int n, m, p1, p2, val;
cin >> n >> m;

vector<vector<int>> grid(n + 1, vector<int>(n + 1, 10005)); // 因为边的最大距离是10^4

for(int i = 0; i < m; i++){
cin >> p1 >> p2 >> val;
grid[p1][p2] = val;
grid[p2][p1] = val; // 注意这里是双向图

}
// 开始 floyd
for (int k = 1; k <= n; k++) {
for (int i = 1; i <= n; i++) {
for (int j = 1; j <= n; j++) {
grid[i][j] = min(grid[i][j], grid[i][k] + grid[k][j]);
}
}
}
// 输出结果
int z, start, end;
cin >> z;
while (z--) {
cin >> start >> end;
if (grid[start][end] == 10005) cout << -1 << endl;
else cout << grid[start][end] << endl;
}
}

如果遇到单源且边为正数,直接Dijkstra。至于 使用朴素版还是 堆优化版 还是取决于图的稠密度.一般情况下,可以直接用堆优化版本。

如果遇到单源边可为负数,直接 Bellman-Ford,同样 SPFA 还是 Bellman-Ford 取决于图的稠密度。一般情况下,直接用 SPFA。

如果有负权回路,优先 Bellman-Ford, 如果是有限节点最短路 也优先 Bellman-Ford,理由是写代码比较方便。

如果是遇到多源点求最短路,直接 Floyd

最小生成树

最小生成树是所有节点的最小连通子图,即:以最小的成本(边的权值)将图中所有节点链接到一起。

Prim

  1. 第一步,选距离生成树最近节点
  2. 第二步,最近节点加入生成树
  3. 第三步,更新非生成树节点到生成树的距离(即更新minDist数组)

Kruskal

拓扑排序

拓扑排序 是在图上的一种排序。概括来说,给出一个 有向图,把这个有向图转成线性的排序 就叫拓扑排序

同样,拓扑排序也可以检测这个有向图 是否有环,即存在循环依赖的情况。拓扑排序的一些应用场景,例如:大学排课,文件下载依赖 等等。

只要记住如下两步拓扑排序的过程,代码就容易写了:

  1. 找到入度为0 的节点,加入结果集
  2. 将该节点从图中移除

并查集

并查集常用来解决连通性问题。

大白话就是当我们需要判断两个元素是否在同一个集合里的时候,我们就要想到用并查集。

并查集主要有两个功能:

  • 将两个元素添加到一个集合中。
  • 判断两个元素在不在同一个集合

融会贯通

循环位移问题

循环移位是一类非常经典的问题,通常涉及数组、字符串和链表等线性数据结构,包括移动问题、查找问题和包含问题等类型。虽然表现形式有很多,但其问题本质是相似的,只要理解其背后的算法思想,你便能掌握不同数据结构下的实现。

通用解题模板

解题技巧

常考题

  1. 数据结构之王:LRU 缓存 (Least Recently Used)

这道题是大厂出镜率最高的题目,没有之一。它考察的不仅是算法,更是你对复合数据结构的理解。

  • 题目内容:实现 LRU缓存 机制,要求 getput 都是 $O(1)$ 时间复杂度。
  • 考察核心哈希表 + 双向链表
  • 面试官想看什么:你是否能手写双向链表的节点删除和插入,是否考虑了线程安全(如果是 Java 面试),以及对空间复杂度的权衡。
  1. 数组与指针:接雨水 (Trapping Rain Water)

如果说 LRU 是数据结构的必考,那么接雨水就是双指针和单调栈的巅峰。

  • 题目内容:给定 $n$ 个非负整数表示每个柱子的高度,计算按此排列的柱子,下雨能接多少雨水。
  • 考察核心双指针单调栈动态规划
  • 面试官想看什么:你能不能从 $O(n^2)$ 的暴力解法优化到 $O(n)$。这道题能一眼看出一个候选人的算法上限。
  1. 树论基础:二叉树的最近公共祖先 (LCA)

二叉树题目中,LCA 是区分度最高的一道。

  • 题目内容:给定一个二叉树, 找到该树中两个指定节点的最近公共祖先。
  • 考察核心递归回溯
  • 面试官想看什么:你对递归边界的控制能力。很多候选人能写出代码,但说不清为什么当 leftright 都不为空时,当前节点就是祖先。
  1. 排序与选择:前 K 个高频元素 / 第 K 大的数

这类题目直接对应业务场景(如:排行榜、热门搜索)。

  • 考察核心堆排序 (Heap)快速选择 (Quick Select)
  • 面试官想看什么:如果你用 sort 函数,面试直接结束。他想看你能不能用 $O(n \log k)$ 甚至 $O(n)$ 的时间复杂度解决问题,并让你手写一个大顶堆或小顶堆。
  1. 动态规划入门与进阶:最长递增子序列 (LIS)

大厂面试必有一道 DP(动态规划)。

  • 考察核心状态转移方程的推导
  • 面试官想看什么:你能写出 $O(n^2)$ 的 DP 是及格,如果你能写出 $O(n \log n)$ 的“贪心 + 二分查找”解法,那就是优秀。

大厂面试高频分类表

类别必刷经典题核心技巧
链表反转链表、环形链表 II快慢指针、虚拟头节点
二叉树层次遍历、锯齿形遍历BFS、队列应用
字符串无重复字符的最长子串滑动窗口、哈希表
排序手写快排、归并排序分治法、递归
场景题100亿个URL去重、大文件排序布隆过滤器、外部排序

参考资料

  1. https://noworneverev.github.io/leetcode_101
  2. krahets/LeetCode-Book: 《剑指 Offer》《图解算法数据结构》《Krahets 笔面试精选 88 题》Python, Java, C++ 解题代码
  3. 一个标星25.5k⭐开源的编程题解仓库:leetcode - 知乎
  4. doocs/leetcode: 🔥LeetCode solutions in any programming language | 多种编程语言实现 LeetCode、《剑指 Offer(第 2 版)》、《程序员面试金典(第 6 版)》题解
  5. 算法
  6. krahets/hello-algo
  7. https://github.com/halfrost/LeetCode-Go
  8. labuladong.online
  9. 代码随想录
  10. CodeTop 面试题目总结
  11. codeforces-go/leetcode/SOLUTIONS.md at master · EndlessCheng/codeforces-go
  12. 分享|如何科学刷题? - 讨论 - 力扣(LeetCode)
-------------本文结束感谢您的阅读-------------
感谢阅读.

欢迎关注我的其它发布渠道