Luogu P1045 — Mersenne Numbers
link: https://www.luogu.com.cn/problem/P1045
1. Problem Statement
A Mersenne number is an integer of the form $2^P - 1$ where $P$ is prime. Given $P$ ($1000 \lt P \lt 3,100,000$), the task is to compute:
- The number of decimal digits of $2^P - 1$;
- The last $500$ decimal digits of $2^P - 1$, printed in $10$ rows of $50$ digits each, zero-padded on the left if necessary.
It is not required to verify the primality of $2^P - 1$ or $P$.
2. Key Observations & Theoretical Background
2.1 Number of Digits
The number of digits of a positive integer $N$ in base $10$ is $\lfloor \log_{10} N \rfloor + 1$. For $N = 2^P - 1$, since subtracting $1$ changes the digit count only when $2^P$ is an exact power of $10$ (which never happens for $P \ge 1$), we have:
$$ \text{digits}(2^P - 1) = \lfloor P \cdot \log_{10} 2 \rfloor + 1 $$
This avoids the need to compute the full high-precision integer just for the digit count.
2.2 Truncation to the Last 500 Digits
High-precision arithmetic can be truncated: when only the last $k$ digits are needed, any carry beyond position $k$ can be discarded. This transforms the time complexity of each multiplication from $O(L^2)$ to $O(k^2)$, where $L$ is the full length (up to $\approx 9.3 \times 10^5$) and $k = 500$. The speedup is on the order of $\left(\frac{L}{k}\right)^2 \approx 3.5 \times 10^6$.
2.3 Binary Exponentiation (Fast Power)
Computing $2^P$ by repeated multiplication requires $P$ steps — infeasible for $P \le 3.1 \times 10^6$. Instead, binary exponentiation reduces the number of multiplications to $O(\log P)$ by exploiting:
$$ 2^P = \prod_{i: b_i = 1} 2^{2^i}, \quad \text{where } (b_k b_{k-1} \dots b_0)_2 = P $$
2.4 The Trailing Digit of $2^P$
The sequence of the units digit of $2^n$ (for $n \ge 1$) cycles through ${2, 4, 8, 6}$. Hence $2^P$ always ends in an even digit, and $2^P - 1$ requires no borrow — a simple a[1]-- suffices. Recognizing this removes an unnecessary borrow-loop that would be both error-prone and irrelevant.
3. Implementation
#include <iostream>
#include <cstring>
#include <cstdio>
#include <algorithm>
#include <cmath>
using namespace std;
const int MAXN = 1005; // enough for 500-digit intermediate products
struct BigInt {
int len;
int a[MAXN];
void init(int x) {
int index = 1;
memset(a, 0, sizeof(a));
while (x) {
a[index++] = x % 10;
x /= 10;
}
len = max(index - 1, 1);
}
int& operator[](int i) { return a[i]; }
void flatten(int L) {
len = L;
for (int i = 1; i <= len; i++) {
a[i + 1] += a[i] / 10;
a[i] %= 10;
}
len++;
while (len > 1 && !a[len])
len--;
if (len > 500)
len = 500; // truncate: we only need last 500 digits
}
BigInt operator*(const BigInt& other) const {
BigInt res;
res.init(0);
for (int i = 1; i <= len; i++)
for (int j = 1; j <= other.len; j++)
res.a[i + j - 1] += a[i] * other.a[j];
res.flatten(len + other.len);
return res;
}
};
BigInt quick_pow(BigInt base, int exp) {
BigInt res;
res.init(1);
while (exp) {
if (exp & 1)
res = res * base;
base = base * base;
exp >>= 1;
}
return res;
}
int main() {
int p;
cin >> p;
BigInt base;
base.init(2);
BigInt res = quick_pow(base, p);
res.a[1]--; // 2^P always even, no borrow needed
// output digit count
cout << (int)(p * log10(2)) + 1 << endl;
// output last 500 digits, 10 rows × 50 columns
int cnt = 0;
for (int i = 500; i >= 1; i--) {
printf("%d", res[i]);
cnt++;
if (cnt == 50 && i > 1) {
printf("\n");
cnt = 0;
}
}
return 0;
}
4. Debugging Chronicle
The solution progressed through 5 iterations. The errors, in order of discovery:
TLE → WA → RE → AC
| Stage | Symptom | Root Cause | Fix |
|---|---|---|---|
| 1 | TLE | while(p--) linear exponentiation ($\Theta(P)$) |
Binary exponentiation ($\Theta(\log P)$) |
| 2 | WA×5 | len = index-- off-by-one; parameter len shadows member; res.a[...] = overwrites instead of accumulating; log10(p) instead of log10(2); cnt == 10 instead of 50 |
Correct each individually (see §4.1) |
| 3 | RE | MAXN = 550 while i+j-1 reaches 999 |
MAXN ≥ 1000 |
| 4 | WA | len = 505 typo, missing <algorithm>, while(!a[len]) no lower-bound guard |
500, add header, len > 1 && |
Meta-cognitive takeaway: The most instructive failure was not a code bug but a mindset one — reflexively writing a full borrow-subtraction loop for 2^P - 1 without realizing $2^P$ always ends in an even digit, so borrowing is impossible. Pause before coding: does the operand have special structure that simplifies the operation?
4.1 Key Pitfalls in BigInt Implementation
| # | Location | Wrong | Right |
|---|---|---|---|
| 1 | init() |
len = index-- (post-decrement: use-then-subtract) |
len = max(index - 1, 1) |
| 2 | flatten() signature |
void flatten(int len) shadows this->len |
Rename parameter to int L |
| 3 | flatten() body |
while(!a[l]) (l is the immutable parameter) |
while(len > 1 && !a[len]) |
| 4 | operator* |
= a[i] * other.a[j] (assignment) |
+= a[i] * other.a[j] (accumulation) |
| 5 | flatten() end |
if (len > 500) len = 505 (typo) |
len = 500 |
| 6 | Header | missing <algorithm> for std::max |
#include <algorithm> |
5. Summary of BigInt Design Principles
| Principle | Rationale |
|---|---|
Use memset + loop-based init |
Avoids manual zero-reset bugs; consistent initialization |
Return int& from operator[] |
Enables both read and write access through the same interface |
Accumulate with += in multiplication loops |
Multiple $(i,j)$ pairs may map to the same result position |
Guard while(!a[len]) with len > 1 |
Prevents underflow when the value is zero |
Truncate len after flatten() |
Only the last $k$ digits influence subsequent operations |
Explicitly assign this->len in flatten() |
Avoids parameter-name shadowing of member variables |
Mark operator* as const |
Signals no mutation of operands; enables chaining |
Luogu P1177 — Quick Sort
Link: https://www.luogu.com.cn/problem/P1177
1. Problem Statement (P1177)
Sort $N$ integers in ascending order and output them space-separated. For $100%$ of the testdata, $1 \leq N \leq 10^5$, $1 \le a_i \le 10^9$.
The challenge: writing a sorting algorithm from scratch that passes the anti-quicksort testcases — i.e., already-sorted input, reverse-sorted input, and arrays where all elements are equal.
2. Algorithm Overview
Quick sort is a divide-and-conquer algorithm. It picks a pivot, partitions the array into “less than pivot” and “greater than pivot” halves, then recurses on each half. When a subarray has length $\le 1$, it is trivially sorted.
flowchart TD
A["quick_sort(l, r)"] --> B{"l >= r ?"}
B -->|Yes| C["return (empty or single-element, stop)"]
B -->|No| D["1. Pick pivot = a[(l+r)/2]"]
D --> E["2. Partition: two pointers i, j move towards each other"]
E --> E1["i scans left, stops at element >= pivot"]
E --> E2["j scans right, stops at element <= pivot"]
E1 --> F{"i < j ?"}
E2 --> F
F -->|Yes| G["swap(a[i], a[j])"]
G --> E1
F -->|No| H["Partition done, mid = j"]
H --> I["3. Divide and conquer"]
I --> I1["quick_sort(l, mid)"]
I --> I2["quick_sort(mid+1, r)"]
I1 --> J["Left half sorted"]
I2 --> K["Right half sorted"]
J --> L["Entire sequence sorted"]
K --> L
2.1 Partition Step in Detail
The core of quick sort is the partition step (Hoare’s scheme). We use two pointers $i$ and $j$:
- $i$ scans left to right, skipping elements already smaller than pivot.
- $j$ scans right to left, skipping elements already larger than pivot.
- When both stop, they point at elements that belong on the other side — swap them.
sequenceDiagram
participant Array as "a[l..r]"
participant i as "Pointer i"
participant j as "Pointer j"
Note over Array: pivot = 4
Array->>i: i = l-1 (starts outside the interval)
Array->>j: j = r+1 (starts outside the interval)
loop while i < j
i->>i: do i++ while a[i] < pivot
Note over i: Stops at first element >= pivot
j->>j: do j-- while a[j] > pivot
Note over j: Stops at first element <= pivot
alt i < j
i->>j: swap(a[i], a[j])
Note over i,j: Swap and continue next round
else i >= j
Note over i,j: Pointers crossed, partition done
end
end
Note over Array: mid = j, left side <= pivot, right side >= pivot
3. Key Design Decisions & Rationale
3.1 Why i = l-1, j = r+1?
The loop uses do...while, which executes the body before checking the condition. If we initialized i = l, j = r, the very first do i++ would skip a[l] — missing the first element entirely.
By starting one step outside the interval, do i++ moves i to l on the first iteration, and do j-- moves j to r. This guarantees every element in $[l, r]$ is examined.
| Initialization | First do i++ effect |
Correct? |
|---|---|---|
i = l |
i becomes l+1, skips a[l] |
No |
i = l-1 |
i becomes l, starts at a[l] |
Yes |
3.2 Why pivot = a[(l+r)/2] (middle element)?
Choosing the middle element as pivot avoids the worst-case $O(n^2)$ degradation on already-sorted arrays. If we always chose a[l] or a[r], a sorted input would produce maximally unbalanced partitions every time.
3.3 Why mid = j (not i)?
After the while(i < j) loop exits, $i$ and $j$ have crossed. The invariant at exit is:
$$a[l \dots j] \le \text{pivot} \le a[j+1 \dots r]$$
Thus $j$ (not $i$) is the correct split point. Using $i$ would cause overlapping intervals and potential infinite recursion.
4. Implementation
#include <iostream>
using namespace std;
const int MAXN = 1e5 + 5;
int a[MAXN];
void quick_sort(int l, int r) {
// Empty or single-element interval: recursion terminates.
// When every subarray has length < 1, the whole sequence is sorted.
if (l >= r) return;
// 1. Pick a pivot — the array will be split into < pivot and > pivot halves.
int pivot = a[(l + r) / 2];
int i = l - 1, j = r + 1; // Works with do-while; pointers start outside the interval.
// 2. Partition: two pointers move towards each other, swapping misfits.
int mid; // mid records the final split point after the pointers cross.
while (i < j) {
do i++; while (a[i] < pivot); // Scan left for the first element >= pivot.
do j--; while (a[j] > pivot); // Scan right for the first element <= pivot.
if (i < j) swap(a[i], a[j]); // Swap into the correct side.
}
mid = j; // j is the split point: left <= pivot, right >= pivot.
// 3. Divide and conquer: recursively sort the two subarrays.
quick_sort(l, mid);
quick_sort(mid + 1, r);
}
int main() {
int n;
cin >> n;
for (int i = 1; i <= n; i++) cin >> a[i];
quick_sort(1, n);
// Avoid trailing space: print the first element, then " " + each remaining.
cout << a[1];
for (int i = 2; i <= n; i++)
cout << " " << a[i];
return 0;
}
5. Debugging Chronicle
| Stage | Symptom | Root Cause | Fix |
|---|---|---|---|
| 1 | WA / infinite loop | do j--; while (a[i] > pivot) — typo: used i instead of j in the condition; a[i] does not change as j decrements |
a[i] → a[j] |
| 2 | WA (format) | No spaces in output, all numbers concatenated | cout << a[i] << " ", and print the first element separately to avoid trailing space |
Meta-cognitive takeaway: The a[i] vs a[j] typo is especially dangerous because it doesn’t always crash — it silently produces wrong results or infinite loops depending on the data. When using dual pointers, double-check that each pointer’s condition references the correct variable.
6. Complexity Analysis
| Case | Time | Condition |
|---|---|---|
| Best | $O(n \log n)$ | Pivot always splits array into equal halves |
| Average | $O(n \log n)$ | Random pivot yields balanced partitions in expectation |
| Worst | $O(n^2)$ | Pivot always extreme (min or max), e.g. sorted array with naive pivot choice |
- Space: $O(\log n)$ for recursion stack (balanced case), $O(n)$ worst case.
- Stability: Quick sort is not stable — equal elements may be swapped across the pivot.
6.1 Why the Worst Case Matters for P1177
Luogu P1177 includes adversarial testcases designed to break naive quick sort:
| Testcase | Naive pivot (a[l]) |
Middle pivot (a[(l+r)/2]) |
|---|---|---|
| Already sorted | $O(n^2)$ — fail | $O(n \log n)$ — pass |
| Reverse sorted | $O(n^2)$ — fail | $O(n \log n)$ — pass |
| All elements equal | $O(n^2)$ — fail | $O(n^2)$ — fail (still degrades!) |
For the “all equal” case, even middle-pivot degrades. The fix is three-way partitioning (§7.1).
7. Further Optimizations
graph LR
A["Basic quicksort<br/>O(n²) worst"] --> B["Random pivot<br/>Avoid sorted-input degradation"]
B --> C["Three-way partitioning<br/>Handle many duplicates"]
C --> D["Insertion sort for small n<br/>Reduce constant factor"]
D --> E["Tail-recursion elimination<br/>Control stack depth"]
E --> F["Introspective sort<br/>Guaranteed O(n log n)"]
style A fill
style F fill
7.1 Three-Way Partitioning (Handling All-Equal Data)
When many elements equal the pivot, the standard two-way partition still degrades. Three-way partitioning splits into < pivot | = pivot | > pivot:
void quick_sort_3way(int l, int r) {
if (l >= r) return;
int pivot = a[l + rand() % (r - l + 1)];
int lt = l, gt = r, i = l;
while (i <= gt) {
if (a[i] < pivot) swap(a[i++], a[lt++]);
else if (a[i] > pivot) swap(a[i], a[gt--]); // Don't advance i — the swapped-in element hasn't been checked yet.
else i++;
}
quick_sort_3way(l, lt - 1);
quick_sort_3way(gt + 1, r);
// All elements in [lt, gt] equal pivot — already in place, no recursion needed.
}
7.2 Small-Array Insertion Sort
When $r - l \le 15$, insertion sort has lower constant factor:
if (r - l <= 15) {
for (int i = l + 1; i <= r; i++) {
int key = a[i], j = i - 1;
while (j >= l && a[j] > key) { a[j + 1] = a[j]; j--; }
a[j + 1] = key;
}
return;
}
7.3 Optimization Summary
| Technique | Solves | Added Complexity |
|---|---|---|
| Random / median-of-three pivot | Degradation on sorted input | Low |
| Three-way partitioning | Degradation on many duplicates | Medium |
| Insertion sort for small $n$ | High constant factor at leaves | Low |
| Tail-recursion elimination | Stack overflow on deep recursion | Low |
| Introspective sort (switch to heapsort) | Guarantees $O(n \log n)$ worst case | High (≈ std::sort) |
8. Key Pitfalls in Quick Sort Implementation
| # | Pitfall | Wrong | Right |
|---|---|---|---|
| 1 | Pointer initialization | i = l, j = r (with do-while, skips a[l]) |
i = l-1, j = r+1 |
| 2 | Wrong variable in condition | do j--; while (a[i] > pivot) |
do j--; while (a[j] > pivot) |
| 3 | Wrong split point | mid = i (i and j have already crossed) |
mid = j |
| 4 | Overlapping recursive intervals | quick_sort(l, mid-1) + quick_sort(mid, r) |
quick_sort(l, mid) + quick_sort(mid+1, r) |
| 5 | Pivot at endpoint | pivot = a[l] (degrades on sorted arrays) |
pivot = a[(l+r)/2] or random |
| 6 | Output format | Trailing space / no spaces | Print first element separately, then " " + a[i] |
9. Connection to std::sort
The C++ standard library’s std::sort uses Introsort:
- Uses quicksort as the primary algorithm.
- Switches to heapsort when recursion depth exceeds $2\lfloor \log_2 n \rfloor$ (prevents $O(n^2)$).
- Switches to insertion sort when the subarray is smaller than a threshold (usually 16).
For competitive programming, using std::sort directly is the safest choice; hand-writing quicksort is mainly for understanding divide-and-conquer and for interview preparation.
Luogu P1923 — Quick Select ($k$-th Smallest Element)
This problem is a simliar version of the former one, only a little difference.
Link: https://www.luogu.com.cn/problem/P1923
1. Problem Statement (P1923)
Given $n$ integers $a_i$ and an integer $k$ ($0 \le k < n$), output the $k$-th smallest number. The smallest number is the 0-th smallest (0-indexed).
This is the selection problem: find the $k$-th order statistic. A full sort would solve it in $O(n \log n)$, but quick select achieves $O(n)$ average time by recursing into only one partition per level.
2. Algorithm Overview (Quick Select)
Quick select is obtained by modifying exactly one part of quick sort — the recursive calls. After partitioning, we check which side contains the $k$-th element and recurse only there.
flowchart TD
A["quick_select(l, r)"] --> B{"l >= r ?"}
B -->|Yes| C["return (single element is the answer)"]
B -->|No| D["1. Pick pivot = a[(l+r)/2]"]
D --> E["2. Partition: i, j two-pointer scan"]
E --> E1["i scans left, stops at >= pivot"]
E --> E2["j scans right, stops at <= pivot"]
E1 --> F{"i < j ?"}
E2 --> F
F -->|Yes| G["swap(a[i], a[j])"]
G --> E1
F -->|No| H["Partition done"]
H --> I{"k <= j ?"}
I -->|Yes| I1["quick_select(l, j)"]
I -->|No| I2["quick_select(j+1, r)"]
I1 --> J["k-th element found in left half"]
I2 --> K["k-th element found in right half"]
3. Key Insight: Why if-else on $j$?
The only difference between quick sort and quick select lies in the final two recursive calls:
| Quick Sort | Quick Select |
|---|---|
quick_sort(l, mid); |
if (k <= j) quick_select(l, j); |
quick_sort(mid + 1, r); |
else quick_select(j + 1, r); |
Why can’t we unconditionally recurse into one side?
3.1 Post-Partition Invariant
After the while (i < j) loop exits, the following invariant holds:
$$a[l \dots j] \le \text{pivot} \le a[j+1 \dots r]$$
The pivot’s final resting position (call it pos) is not guaranteed to equal $j$. Depending on the data, it may land anywhere relative to $i$ and $j$:
| Case | Layout | Description |
|---|---|---|
| A | $[i,\ \text{pos},\ j]$ | Pivot sits strictly between the crossed pointers |
| B | $[\text{pos}(i),\ j]$ | Pivot lands at or to the left of $i$ |
| C | $[i,\ \text{pos}(j)]$ | Pivot lands at or to the right of $j$ |
Because the pivot’s exact position is ambiguous, we cannot blindly assume which side contains $k$. The if-else resolves this: if $k \le j$, the $k$-th element lies in $[l, j]$; otherwise in $[j+1, r]$.
3.2 Why Use $j$ (Not $i$) as the Split Decision Point?
By the loop invariant, $j$ is the conservative split: everything in $[l, j]$ is $\le$ pivot, everything in $[j+1, r]$ is $\ge$ pivot. The intervals $[l, j]$ and $[j+1, r]$ are guaranteed to be disjoint and cover the full range. Using $i$ would risk overlapping or missing elements, since $i \ge j+1$ after the pointers cross.
4. Implementation (Quick Select)
#include <iostream>
using namespace std;
const int MAXN = 5e6 + 5;
int a[MAXN];
int k; // global: the 0-indexed rank to locate
// Standard quick sort — for comparison
void quick_sort(int l, int r) {
if (l >= r) return;
int pivot = a[(l + r) / 2];
int i = l - 1, j = r + 1;
int mid;
while (i < j) {
do i++; while (a[i] < pivot);
do j--; while (a[j] > pivot);
if (i < j) swap(a[i], a[j]);
}
mid = j;
quick_sort(l, mid);
quick_sort(mid + 1, r);
}
// Quick select: only the recursive calls differ
void quick_select(int l, int r) {
if (l >= r) return; // Single element — this is our answer
int pivot = a[(l + r) / 2];
int i = l - 1, j = r + 1;
while (i < j) {
do i++; while (a[i] < pivot);
do j--; while (a[j] > pivot);
if (i < j) swap(a[i], a[j]);
}
// Only recurse into the half that contains the k-th element
if (k <= j)
quick_select(l, j);
else
quick_select(j + 1, r);
}
int main() {
ios::sync_with_stdio(false);
cin.tie(nullptr);
int n;
cin >> n >> k;
k++; // Problem uses 0-indexed k; our array is 1-indexed — adjust
for (int i = 1; i <= n; i++) cin >> a[i];
quick_select(1, n);
cout << a[k] << endl;
return 0;
}
Note: Luogu P1923 uses 0-indexed $k$ (the smallest is the 0-th). Since our array starts at index $1$, we pre-increment $k$ so
a[k]points to the correct element after the partial sort.
5. Complexity Analysis
| Case | Time | Condition |
|---|---|---|
| Best | $O(n)$ | Pivot is the $k$-th element itself — one partition suffices |
| Average | $O(n)$ | Each partition roughly halves the search space |
| Worst | $O(n^2)$ | Pivot always extreme (min or max), same degradation as naive quick sort |
The expected recurrence:
$$T(n) = T!\left(\frac{n}{2}\right) + O(n) \implies T(n) = O(n)$$
- Space: $O(\log n)$ for recursion stack in the balanced case, $O(n)$ in the worst case.
6. Pitfalls & Debugging Notes
| # | Pitfall | Wrong | Right |
|---|---|---|---|
| 1 | Blind recursion | Always recurse into (j+1, r) |
Check if (k <= j) to decide the correct side |
| 2 | Wrong split variable | Use i instead of j |
Always use j — the post-partition invariant guarantees correctness |
| 3 | 0-index / 1-index mismatch | Forget to adjust $k$ when array is 1-indexed | k++ before calling quick_select |
| 4 | Overlapping intervals | quick_select(l, i) + quick_select(i+1, r) |
Use j: quick_select(l, j) + quick_select(j+1, r) |
7. Connection to std::nth_element
The C++ standard library provides std::nth_element, which implements quick select (typically as introselect — falling back to heapsort when recursion depth is excessive):
// Equivalent to the hand-written quick_select above:
nth_element(a + 1, a + k, a + n + 1);
cout << a[k] << endl;
Hand-writing quick select reinforces the two-pointer partition invariant — the same reasoning that underpins quick sort, three-way partitioning (Dutch national flag), and partial sorting. Once you understand that $j$ is the safe split point, both algorithms become two variations of the same idea.