mirror of
https://github.com/prdlk/leetcode.git
synced 2026-11-02 13:12:44 +00:00
Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0f479d6cf | ||
|
|
ac4dc654fc | ||
|
|
273148c08b | ||
|
|
7960dee431 | ||
|
|
0009075138 | ||
|
|
ca73d3235c | ||
|
|
97b62f95c9 | ||
|
|
1a440e55ce | ||
|
|
fd3b1952b1 | ||
|
|
82c26e2b6c | ||
|
|
f78ecb2bd4 | ||
|
|
b3d3c5fbc9 | ||
|
|
99a9248368 | ||
|
|
2a925f6a9a | ||
|
|
7d57faa958 | ||
|
|
26085cc027 | ||
|
|
216152d946 | ||
|
|
d0ace85bbc | ||
|
|
5b99a6a589 | ||
|
|
8446623607 | ||
|
|
3a1df27d30 | ||
|
|
ae1d1ceede | ||
|
|
f5f31ba1c3 | ||
|
|
d1c99c307e | ||
|
|
021b1fd06e | ||
|
|
edd37ed0e6 | ||
|
|
c2a8a31ede | ||
|
|
891a1f0c5a | ||
|
|
2e5d991d47 | ||
|
|
73d63c2565 | ||
|
|
cafc8a4c25 | ||
|
|
7c5061dcd1 | ||
|
|
f9e69960c4 | ||
|
|
0c12df3eba | ||
|
|
bfb53c14e8 | ||
|
|
2c30b3d017 | ||
|
|
1084410832 | ||
|
|
76c9c77307 | ||
|
|
23eb71d387 | ||
|
|
b8c779903f | ||
|
|
5059eb72cb | ||
|
|
204e178fa4 | ||
|
|
61dc19fb29 | ||
|
|
5c644ca050 | ||
|
|
fb2d4c88c2 | ||
|
|
1fa0731ea7 | ||
|
|
86107d5ef7 |
No files matched your search
@@ -114,7 +114,12 @@ One issue per curriculum topic; owns the problem sub-issues.
|
||||
```
|
||||
|
||||
`Scheduled` should come from the calendar of record — `apps/api/data/schedule.json`
|
||||
(day → topic issue) — not guessed.
|
||||
(`date → { topics: [issue, …] }`; a doubled day lists two) — not guessed.
|
||||
|
||||
Gate problems (`gate: [...]` in the same file) must exist in the catalog before their
|
||||
Saturday, or the Worker refuses to create the review issue: create each as a
|
||||
`set:optional` problem sub-issue of its natural topic. Being named in the plan keeps it
|
||||
out of the drill pool, so it stays unseen until the gate.
|
||||
|
||||
## Reference tables
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
---
|
||||
name: problem-approach
|
||||
description: Write the `## Approach` section — a Mermaid flowchart that traces the submitted solution — for every docs solution page under apps/docs/content/ that lacks one, using the repo's exact placement and node conventions. Use when asked to add/implement/fill in missing approaches, draw the approach diagram for a problem page, or when `/problem-approach` is run.
|
||||
---
|
||||
|
||||
# Problem approach diagrams
|
||||
|
||||
Every solution page (`apps/docs/content/(<category>)/<num>-<slug>.mdx`) has three
|
||||
hand-owned sections around the script-owned one:
|
||||
|
||||
```
|
||||
frontmatter → <Badge> → ::::warning (maybe) → ### Example N / ### Constraints
|
||||
## Approach ← this skill (Mermaid flowchart of the code below)
|
||||
## Solution ← bun run sync owns this; never edit inside it
|
||||
## Explanation ← bun run videos --write (NeetCode embed); not this skill
|
||||
```
|
||||
|
||||
`sync.ts` regenerates only `## Solution`, so an `## Approach` placed **before** it
|
||||
survives every re-sync; anything placed inside `## Solution` is overwritten on the
|
||||
next `work/` push.
|
||||
|
||||
## The command: fill every missing approach
|
||||
|
||||
1. **Find the pages** (numbered pages with no `## Approach` heading):
|
||||
|
||||
```sh
|
||||
grep -L '^## Approach' apps/docs/content/*/*.mdx
|
||||
```
|
||||
|
||||
2. **For each page**, read `## Solution` and write the diagram from *that code* — the
|
||||
one on the page, not the textbook algorithm. The reader compares diagram and code
|
||||
line by line; the diagram is a trace, not a lecture.
|
||||
|
||||
3. **Insert** the section between the `### Constraints:` list and `## Solution`,
|
||||
blank-line separated on both sides:
|
||||
|
||||
````md
|
||||
- `-100 <= Node.val <= 100`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
…
|
||||
```
|
||||
|
||||
## Solution
|
||||
````
|
||||
|
||||
4. **Check** each diagram by hand against the syntax rules below (the docs build does
|
||||
not parse Mermaid — a broken diagram fails only in the browser), then run the
|
||||
docs build once at the end and review the pages in `bun run dev`:
|
||||
|
||||
```sh
|
||||
cd apps/docs && bunx blume build --isolated && rm -rf .blume-verify
|
||||
```
|
||||
|
||||
Pages with two languages (`<CodeGroup>`) get **one** diagram, traced from the
|
||||
Python version. Pages with only JavaScript trace the JavaScript.
|
||||
|
||||
## Diagram conventions
|
||||
|
||||
Reference page: `apps/docs/content/(array)/15-3sum.mdx`. Every existing diagram
|
||||
follows this shape:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["threeSum(nums)"]) --> O["nums.sort() — duplicates become adjacent"]
|
||||
O --> F{"more i in 0..n-1?"}
|
||||
F -- no --> E(["return result"])
|
||||
F -- yes --> D{"nums[i] == nums[i-1]?"}
|
||||
D -- yes --> F
|
||||
D -- no --> P["left = i+1, right = n-1, target = -nums[i]"]
|
||||
P --> W{"left < right?"}
|
||||
W -- no --> F
|
||||
W -- yes --> C["current = nums[left] + nums[right]"]
|
||||
C --> Q{"current vs target"}
|
||||
Q -- equal --> A["append triplet, skip equal neighbours, left += 1, right -= 1"]
|
||||
A --> W
|
||||
Q -- "current < target" --> L["left += 1 — need a bigger sum"]
|
||||
L --> W
|
||||
Q -- "current > target" --> R["right -= 1 — need a smaller sum"]
|
||||
R --> W
|
||||
```
|
||||
|
||||
- `flowchart TD`, two-space indent, one edge per line, edges listed in execution
|
||||
order (entry first, exits as soon as they are reachable).
|
||||
- **Entry** is a stadium holding the signature: `S(["threeSum(nums)"])`. **Every
|
||||
exit** is a stadium: `E(["return result"])`, `R(["return true"])`.
|
||||
- **Statements** are rectangles `["…"]`, in the code's own identifiers and operators
|
||||
(`left += 1`, `freq[n] = (freq[n] or 0) + 1`). Several trivial assignments may share
|
||||
one node, comma-separated.
|
||||
- **Decisions** are diamonds `{"…?"}` with `-- yes -->` / `-- no -->` edges. A
|
||||
three-way branch names the outcomes: `-- equal -->`, `-- "current < target" -->`
|
||||
(quote an edge label whenever it holds `<`, `>`, or spaces around operators).
|
||||
- **Loops** are a decision: `for` → `{"more n in nums?"}`, `while` → the condition
|
||||
verbatim `{"left < right?"}`. `-- no -->` goes to whatever follows the loop; the
|
||||
body's last node edges back to the decision. `continue` is an edge back to the loop
|
||||
decision (`D -- yes --> F` above); `break` is an edge to the node after the loop.
|
||||
- **Recursion** is a statement, not an expanded subgraph: `A["left = invertTree(root.left)"]`.
|
||||
The base case is the first decision after entry.
|
||||
- **Classes** (design problems) use one `subgraph name["method(args)"]` per public
|
||||
method, plus a `state["…"]` subgraph describing the fields, with `state --> method`
|
||||
edges — see `(hash-table)/146-lru-cache.mdx`. Private helpers are named inside the
|
||||
method nodes (`"_remove_node then _add_node — move it beside the tail"`), not drawn.
|
||||
- **Annotate the why with an em dash** inside the node, sparingly (one clause, a few
|
||||
per diagram): `"nums.sort() — duplicates become adjacent"`, `"map = {0: 1} — the
|
||||
empty prefix"`. The dash explains the invariant that makes the step correct — the
|
||||
one thing the code does not say.
|
||||
- **Node ids**: one or two capitals, unique per diagram (`S I L Q C E R W A B`). Class
|
||||
diagrams prefix per method (`G1`, `P1`).
|
||||
|
||||
### Syntax rules (a diagram that breaks the page is worse than none)
|
||||
|
||||
- Wrap **every** label in double quotes — brackets, parentheses, `<`, `>` and `|` in an
|
||||
unquoted label break the parser.
|
||||
- Never put `"` inside a label; use `'` for string literals (`"s == ''?"`).
|
||||
- Never use `|` anywhere — not in node labels, not as `-->|text|` edge syntax; edge
|
||||
labels are always `-- text -->`. Write `or` for a logical or.
|
||||
- Every id an edge references must be declared with a shape the first time it appears.
|
||||
- Balanced: each `subgraph` has an `end`; each fence opens ```` ```mermaid ```` and closes ```` ``` ````.
|
||||
|
||||
### Code that does not run
|
||||
|
||||
Trace it anyway — the diagram documents what was submitted — and put a `::::warning`
|
||||
callout **before** the fence naming exactly why it fails (`(array)/121-best-time-to-buy-and-sell-stock.mdx`):
|
||||
|
||||
````md
|
||||
## Approach
|
||||
|
||||
::::warning
|
||||
The diagram traces the code below literally. That code does not run: `curr` and `ans` are never initialised, so the first `curr > left` test raises `NameError`.
|
||||
::::
|
||||
|
||||
```mermaid
|
||||
…
|
||||
```
|
||||
````
|
||||
|
||||
Do not fix the code on the page: `## Solution` mirrors `work/1`, and the fix belongs
|
||||
there (then `bun run sync`).
|
||||
|
||||
## Do not
|
||||
|
||||
- Do not write prose approaches, complexity tables or alternative algorithms — the
|
||||
section is one flowchart (plus the warning callout when the code is broken).
|
||||
- Do not touch `## Solution` or anything after it; `## Explanation` is filled by
|
||||
`bun run videos --write`.
|
||||
- Do not add an approach to `reference/` pages or `index.mdx` — only numbered
|
||||
solution pages (`title: 'NN. …'`) carry one.
|
||||
Executable
+35
@@ -0,0 +1,35 @@
|
||||
#!/bin/sh
|
||||
# Pre-commit: the docs pages under apps/docs/content are generated from work/1
|
||||
# by `bun run sync`, and deploy.yml publishes whatever main holds. Regenerate
|
||||
# them now and stage what sync changed, so a solution and its page land in the
|
||||
# same commit and a stale `## Solution` never reaches main.
|
||||
#
|
||||
# Sync reads the working tree, not the index, so the pages it writes only
|
||||
# match this commit when work/1 has nothing unstaged: an untracked or
|
||||
# half-staged solution would ship a page whose source is not in the commit.
|
||||
# In that case refuse and say what to stage.
|
||||
#
|
||||
# Only paths that became dirty during sync are staged. Anything already dirty
|
||||
# beforehand (hand-written `## Approach` prose in progress, a new index.mdx)
|
||||
# stays where the author left it.
|
||||
#
|
||||
# Bypass for a deliberate partial commit: git commit --no-verify
|
||||
set -eu
|
||||
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
|
||||
unstaged=$(git status --porcelain -- work/1 | grep -v '^[MADR] ' || true)
|
||||
if [ -n "$unstaged" ]; then
|
||||
printf 'pre-commit: work/1 has unstaged changes; stage or stash them so the generated pages match this commit:\n%s\n' "$unstaged" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
before=$(mktemp)
|
||||
trap 'rm -f "$before"' EXIT
|
||||
git status --porcelain -- apps/docs/content | sort > "$before"
|
||||
report=$(bun run --silent sync)
|
||||
generated=$(git status --porcelain -- apps/docs/content | sort | comm -13 "$before" - | cut -c4-)
|
||||
if [ -n "$generated" ]; then
|
||||
printf '%s\n' "$report" | grep -v 'skipped (in sync)' >&2
|
||||
printf '%s\n' "$generated" | xargs git add --
|
||||
fi
|
||||
@@ -33,6 +33,7 @@ flowchart LR
|
||||
- **The picker owns the local review loop; the buckets are its state.** `work/` holds three buckets (`1` = first solve, `3`/`7` = blind re-solves, named after the ladder rungs) and `bun run pick` has three sections — Tab cycles them. Pools are reconciled from the filesystem plus one `git log` (`apps/cli/work.ts`): a first solve dates from the ET date of the commit that added its `work/1` file — the same push that closes the problem issue and starts the +3/+7 windows — and a window stops being owed the moment its bucket holds a file. Uncapped, unlike the review issue's `WINDOW_CAP`: that issue is one day's assignment, the picker is everything still owed. An empty scaffold under `work/1` owes nothing (it is unsolved), which is why `isImplemented()` lives in `apps/cli/source.ts` and is shared with `close-solved.ts`. leetcode-cli reads its output directory from `~/.leetcode/workspaces/<active>/config.json` and nowhere else — no flag, no env var — so `pick.ts` MERGES `workDir` into that file around the child (never replays saved bytes: the CLI owns the file too) and leaves it pointed at `work/1`, then treats the scaffold's existence as the acceptance test, since leetcode-cli exits 0 even when an expired session made it write nothing. The picker itself writes no state: pushing the re-solve is what moves the ladder, via `close-solved` → `/admin/solved` with that file's bucket.
|
||||
- **The README charts are live, never committed.** They are Worker endpoints reading D1 per request (`Cache-Control: max-age=300`, honored by GitHub Camo), so a solved push moves them within ~5 minutes with no commit and no workflow of their own.
|
||||
- **D1 owns SRS state; GitHub issues own the catalog; `apps/api/data/schedule.json` owns the calendar.** The catalog reconcile never invents rows and never overwrites SRS columns (`stage`, `next_review`). `Target Date` is the *only* Project field the Worker mirrors (`apps/api/src/mirror.ts`): failures are warnings, never lost D1 writes; topic rows' `Target Date` is never written. SRS stage and first-attempt result live **only** in D1 — do not re-add them as Project fields. The Project's `Set`/`Difficulty` single-selects are a projection of the issue labels, reconciled by `bun run sync-project-fields` — a best-effort final step of `close-solved.yml`, never the Worker.
|
||||
- **The plan names days; the samplers fill the gaps.** `apps/api/data/schedule.json` maps a date to a `ScheduledDay` (`apps/api/src/srs.ts`): `topics` (one or more topic issues — a doubled day lists two, and the digest renders each with its core list then the optional "if time" list), `problems` (a set day: fixed numbers, rendered like a topic), `gate` (Saturday's blind set, verbatim — `createReviewIssue` refuses to create the issue if any number is not a catalog row, so gate problems must exist as `set:optional` sub-issues of their topic first), or `sweep` (every problem whose last attempt failed, taps to re-log, no drills). Every number a `gate` or `problems` entry names is in `RESERVED`, and the drill/gate samplers never draw it — that is what keeps a planned gate problem unseen until its Saturday. Dates absent from the plan still get the ladder's reviews and drills; a Saturday without a `gate` entry falls back to sampling this week's optional pools.
|
||||
- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `apps/api/src/srs.ts`, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind).
|
||||
- **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `apps/api/src/srs.test.ts`.
|
||||
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
|
||||
@@ -43,9 +44,10 @@ flowchart LR
|
||||
|---|---|
|
||||
| `work/<1\|3\|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/1/Easy/Array/1.two-sum.py`. The top directory is the spaced-repetition bucket: `1` = first solve, `3`/`7` = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
|
||||
| `apps/cli/` | Flat Bun TS workspace: automation entries (shebang + top-level await) and libraries (no shebang, side-effect-free on import). Root `bun run` scripts delegate here |
|
||||
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `islands/`, `public/`). `content/(<category>)/<num>-<slug>.mdx` generated by sync |
|
||||
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (day → topic issue, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
|
||||
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `components/` + `components.ts`, `islands/`, `public/`). `content/<topic>/<num>-<slug>.mdx` plus one `meta.ts` per topic folder, all generated by sync: the folder is the README campaign topic slug (`tree-dfs/`, `heap-priority-queue/`) and a real route segment (`/tree-dfs/invert-binary-tree`), the `meta.ts` carries the exact label and topic number so the sidebar reads in curriculum order, and the page badge is the `work/` data-structure category. A hand-written `<topic>/index.mdx` (`hidden: true`) is that topic's landing page: Blume ≥ 1.7.3 links the sidebar group row to it (`sliding-window/index.mdx` → `/sliding-window`) and the hidden flag drops the duplicate row. Two kinds of custom MDX component, both import-free on every page: `islands/<Name>.tsx` is hydrated React (auto-discovered; `ProgressDashboard`, `DependencySpine` — the README topic map as data, zoom/pan, nodes link to whatever route Blume's `/api/docs/navigation.json` reports for the topic folder, inert until a topic has pages), `components/<Name>.astro` registered in `components.ts` is static markup plus a custom element for behaviour, the way Blume's own `Frame` is built (`Carousel`/`CarouselImage`: `src` takes a URL, `/file.png`, or the page-relative `../../public/file.png`) |
|
||||
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (the plan: `date → { topics | problems | gate | sweep }`, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
|
||||
| `.github/workflows/` | deploy (docs → Pages, Worker → Cloudflare, on every main push), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
|
||||
| `.githooks/` | Tracked git hooks; `bun install` points `core.hooksPath` here (root `prepare` script, no husky). `pre-commit` runs `bun run sync` and stages only the pages sync changed, so a solution and its page land in one commit; it refuses to run while `work/1` has anything unstaged (sync reads the tree, so the page would not match the commit), and leaves pre-existing dirt under `apps/docs/content` (in-progress `## Approach` prose) unstaged. `git commit --no-verify` for a deliberate partial commit |
|
||||
|
||||
## Development Commands
|
||||
|
||||
@@ -66,6 +68,9 @@ bun run test # run ONE solution against LeetCode's judge (not a test
|
||||
bun run submit # submit ONE solution to LeetCode (newest scaffold first, nothing
|
||||
# hidden; last_submitted stamped in the db on exit 0)
|
||||
bun run sync # work/1 → apps/docs/content/ pages (re-solves are never published)
|
||||
bun run videos # docs pages with no <YouTube> embed → NeetCode video per page
|
||||
# (channel search scraped, no API key; --write appends the
|
||||
# `## Explanation` embed; "no NeetCode match" pages stay untouched)
|
||||
bun run dev|build # Blume docs site (runs in apps/docs/)
|
||||
bun run close-solved -- --dry-run # issue reconcilers (also DRY_RUN=1)
|
||||
bun run close-topics -- --dry-run
|
||||
@@ -101,7 +106,7 @@ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
|
||||
- `apps/api/src/index.ts` — router + cron dispatch; `apps/api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`.
|
||||
- `apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/56) LeetCode Daily Digest`.
|
||||
- `apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
|
||||
- `apps/cli/sync.ts` — work→docs contract: on existing pages only the `## Solution` section itself is script-owned, bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it (`## Approach`, `## Explanation`) are never touched. Never hand-write solution pages or edit inside `## Solution`.
|
||||
- `apps/cli/sync.ts` — work→docs contract: on existing pages only the `<Badge>` line and the `## Solution` section are script-owned, the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the badge is the `work/1/<Difficulty>/<Category>/` category — topic folders and their `meta.ts` are rewritten every run, a page is `rename`d when its topic changes, and a folder every page has left is deleted unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one). Never use `(group)` folders here: they add no route segment, so a topic `index.mdx` inside one collides with the root `/`. The two hand-owned sections around the solution have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before* `## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); `## Explanation` (the NeetCode `<YouTube>` embed, after `## Solution`) is appended by `bun run videos --write` (`apps/cli/videos.ts`).
|
||||
- `apps/cli/work.ts` — the `work/` layout: bucket paths, `isSolution()`/`solvedInBucket()` (the stub test, shared with `close-solved.ts`), `firstSolved()` (one `git log`, ET dates, keyed by LC number so past layout moves don't matter), and `reviewQueues()` (what each window still owes). Everything that needs to know where a solution file lives, or when it was solved, goes through here.
|
||||
- `apps/api/src/stats.ts` / `apps/cli/stats.ts` / `apps/cli/tui.ts` / `apps/cli/api.ts` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, per-topic temperature, gates, the 14-day due queue, and `heat` (every campaign day, zeros and future days included — the docs page windows the tail off it rather than asking a second question). Two readers consume it, the docs island and `bun run stats`, so its shape is both their contract: change all three together. `tui.ts` is hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped or `NO_COLOR` dashboard still reads. The TUI recomputes nothing: the only local fact it shows is the `work/` solution count, which is inventory, not schedule.
|
||||
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache); the docs `/progress` page fetches `/api/stats` client-side (`apps/docs/islands/ProgressDashboard.tsx`).
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
<p align="center">
|
||||
<a href="https://x.com/basedprad"><img alt="follow" src="https://shieldcn.dev/x/follow/basedprad.svg" /></a>
|
||||
<a href="https://lc.prad.nu"><img alt="badge" src="https://shieldcn.dev/badge/Read-Documentation.svg?logo=lu%3ABook" /></a>
|
||||
<a href="https://lc.prad.nu/progress"><img alt="gate pass rate" src="https://srs-api.prdlk.workers.dev/badge/gate.svg" /></a>
|
||||
<a href="https://leetcode.com/progress"><img alt="Custom badge" src="https://shieldcn.dev/badge/view-@prdlk.svg?logo=leetcode" /></a>
|
||||
<a href="https://prdlk.github.io/leetcode"><img alt="badge" src="https://shieldcn.dev/badge/Read-Documentation.svg?logo=lu%3ABook" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
@@ -337,4 +337,3 @@
|
||||
<tr><td align="center"><code>B</code></td><td align="center">252</td><td><a href="https://leetcode.com/problems/meeting-rooms/">Meeting Rooms I</a></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
+10
-3
@@ -14,8 +14,12 @@ Live at `https://srs-api.prdlk.workers.dev`.
|
||||
labels, milestones. `catalog.ts` reconciles them into D1 nightly (and via
|
||||
`/admin/reconcile`); it recomputes from scratch, never invents rows, and
|
||||
never overwrites SRS-owned columns (`stage`, `next_review`).
|
||||
- **The repo owns the schedule** — `data/schedule.json`, human-edited,
|
||||
bundled at deploy. Git history is its audit log.
|
||||
- **The repo owns the plan** — `data/schedule.json`, human-edited,
|
||||
bundled at deploy. Git history is its audit log. Each date is a
|
||||
`ScheduledDay`: `topics` (one or more topic issues), `problems` (a set day,
|
||||
fixed numbers), `gate` (Saturday's blind set, verbatim), or `sweep` (redo
|
||||
every miss, nothing new). Numbers a `gate` or `problems` entry names are
|
||||
`RESERVED`: the drill and gate samplers never draw them.
|
||||
- The GitHub Project mirror writes `Target Date` and nothing else: it is
|
||||
best-effort, so failures log warnings and never block a D1 write, and topic
|
||||
rows are never written. SRS stage and first-attempt result stay in D1 only —
|
||||
@@ -47,7 +51,10 @@ raw `Date` math.
|
||||
Blind drills draw only from topics **already learned**: scheduled in an
|
||||
earlier week (the current week's optional pool is reserved for Saturday's
|
||||
gate) and with at least one core problem on the ladder — a skipped learning
|
||||
day never feeds drills just because its calendar week lapsed.
|
||||
day never feeds drills just because its calendar week lapsed. A Saturday
|
||||
whose plan fixes the `gate` set uses that list as-is; every number must be a
|
||||
catalog row (a `set:optional` sub-issue of its topic), or the review issue is
|
||||
not created — a `/done` on an uncatalogued number could never be logged.
|
||||
|
||||
## Routes
|
||||
|
||||
|
||||
+26
-24
@@ -1,26 +1,28 @@
|
||||
{
|
||||
"2026-08-17": 3,
|
||||
"2026-08-18": 4,
|
||||
"2026-08-24": 5,
|
||||
"2026-08-25": 6,
|
||||
"2026-08-26": 7,
|
||||
"2026-08-27": 8,
|
||||
"2026-08-28": 9,
|
||||
"2026-08-31": 10,
|
||||
"2026-09-01": 11,
|
||||
"2026-09-02": 12,
|
||||
"2026-09-03": 13,
|
||||
"2026-09-04": 14,
|
||||
"2026-09-07": 15,
|
||||
"2026-09-08": 16,
|
||||
"2026-09-09": 17,
|
||||
"2026-09-10": 18,
|
||||
"2026-09-11": 19,
|
||||
"2026-09-14": 20,
|
||||
"2026-09-15": 21,
|
||||
"2026-09-16": 22,
|
||||
"2026-09-17": 23,
|
||||
"2026-09-18": 24,
|
||||
"2026-09-21": 25,
|
||||
"2026-09-22": 26
|
||||
"2026-08-17": { "topics": [3] },
|
||||
"2026-08-18": { "topics": [4] },
|
||||
"2026-08-24": { "topics": [5] },
|
||||
"2026-08-25": { "topics": [6] },
|
||||
"2026-08-26": { "topics": [7] },
|
||||
"2026-08-27": { "topics": [8] },
|
||||
"2026-08-28": { "topics": [9] },
|
||||
"2026-08-31": { "topics": [10] },
|
||||
"2026-09-01": { "topics": [11] },
|
||||
"2026-09-02": { "topics": [12] },
|
||||
"2026-09-03": { "topics": [13] },
|
||||
"2026-09-04": { "topics": [14] },
|
||||
"2026-09-07": { "topics": [15] },
|
||||
"2026-09-08": { "topics": [16] },
|
||||
"2026-09-21": { "topics": [17, 18] },
|
||||
"2026-09-22": { "topics": [19, 20] },
|
||||
"2026-09-23": { "topics": [21] },
|
||||
"2026-09-24": { "topics": [22] },
|
||||
"2026-09-25": { "topics": [23] },
|
||||
"2026-09-26": { "gate": [648, 1091, 802, 547, 90] },
|
||||
"2026-09-28": { "topics": [24] },
|
||||
"2026-09-29": { "topics": [25] },
|
||||
"2026-09-30": { "topics": [26] },
|
||||
"2026-10-01": { "problems": [399, 261, 909, 77, 52] },
|
||||
"2026-10-02": { "sweep": true },
|
||||
"2026-10-03": { "gate": [347, 1254, 1971, 131, 494, 986] }
|
||||
}
|
||||
+70
-24
@@ -5,14 +5,16 @@
|
||||
* the HTML and the plain-text alternative from that single tree (React Email
|
||||
* `render`), so the two can never drift.
|
||||
*
|
||||
* Retrieval rules: review and drill rows carry number + difficulty only —
|
||||
* never the topic, never a solution link. `DigestRow` has no field for
|
||||
* either, so the rule holds by construction. The learning day's core list is
|
||||
* the only labeled section. At most REVIEW_CAP (3) reviews per day; drills
|
||||
* fill toward DAILY_CAP (6) only when the due queue is light. Overflow is
|
||||
* levelled, not stacked: sendDigest gives every review past the cap a
|
||||
* concrete future date, ≤ REVIEW_CAP per day, oldest first. Sunday is a
|
||||
* two-line rest note.
|
||||
* Retrieval rules: review, drill and sweep rows carry number + difficulty
|
||||
* only — never the topic, never a solution link. `DigestRow` has no field for
|
||||
* either, so the rule holds by construction. The plan's own sections — a
|
||||
* learning day's topics (core, then the optional "if time" list) and a set
|
||||
* day's problems — are the only labeled, linked ones. At most REVIEW_CAP (3)
|
||||
* reviews per day; drills fill toward DAILY_CAP (6) only when the due queue
|
||||
* is light, and never on a sweep day, which assigns every open miss and
|
||||
* nothing new. Overflow is levelled, not stacked: sendDigest gives every
|
||||
* review past the cap a concrete future date, ≤ REVIEW_CAP per day, oldest
|
||||
* first. Sunday is a two-line rest note.
|
||||
*
|
||||
* Idempotency: email_log keys sends by ET date — a same-day re-send is a
|
||||
* no-op unless forced; the body itself is deterministic (drill picks are
|
||||
@@ -22,20 +24,23 @@
|
||||
import {
|
||||
CAMPAIGN_DAYS,
|
||||
type ProblemRow,
|
||||
RESERVED,
|
||||
REVIEW_CAP,
|
||||
SCHEDULE,
|
||||
type ScheduledDay,
|
||||
addDays,
|
||||
campaignDay,
|
||||
campaignWeek,
|
||||
dueReviews,
|
||||
isoWeek,
|
||||
levelReviews,
|
||||
lookupProblems,
|
||||
pickDrills,
|
||||
prettyDate,
|
||||
streak,
|
||||
weekdayOf,
|
||||
} from "./srs.ts";
|
||||
import { type DigestData, type DigestRow, renderDigest } from "./email.tsx";
|
||||
import { type DigestData, type DigestRow, type TopicRow, renderDigest } from "./email.tsx";
|
||||
import { signLink } from "./links.ts";
|
||||
|
||||
/** Total daily ceiling: drills only fill what the whole due queue leaves. */
|
||||
@@ -63,23 +68,50 @@ async function tapRow(env: Env, p: ProblemRow, date: string, staged: boolean): P
|
||||
};
|
||||
}
|
||||
|
||||
/** A plan-named problem: linked to its issue, ticked once it has entered the ladder. */
|
||||
function topicRow(env: Env, p: ProblemRow): TopicRow {
|
||||
return {
|
||||
lc: p.lc_number,
|
||||
difficulty: p.difficulty,
|
||||
url: `https://github.com/${env.REPO}/issues/${p.issue}`,
|
||||
solved: p.stage !== "new",
|
||||
};
|
||||
}
|
||||
|
||||
/** Problems whose most recent attempt failed — the miss log a sweep day works through. */
|
||||
async function openMisses(db: D1Database): Promise<ProblemRow[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT p.* FROM problems p
|
||||
JOIN attempts a ON a.lc_number = p.lc_number
|
||||
WHERE a.id = (SELECT MAX(id) FROM attempts WHERE lc_number = p.lc_number)
|
||||
AND a.result = 'fail' AND p.stage != 'retired'
|
||||
ORDER BY a.date, p.lc_number`,
|
||||
)
|
||||
.all<ProblemRow>();
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Everything the email needs, read straight out of D1 and the schedule. */
|
||||
export async function collectDigest(env: Env, date: string): Promise<DigestData> {
|
||||
const db = env.DB;
|
||||
const rest = weekdayOf(date) === 0;
|
||||
const plan: ScheduledDay = rest ? {} : (SCHEDULE[date] ?? {});
|
||||
|
||||
// Reviews surface at most REVIEW_CAP; drills fill toward DAILY_CAP only
|
||||
// when the WHOLE due queue is light — a heavy backlog must never trade
|
||||
// review slots for brand-new drill problems.
|
||||
// review slots for brand-new drill problems — and never on a sweep day.
|
||||
const due = rest ? [] : await dueReviews(db, date);
|
||||
const capped = due.slice(0, REVIEW_CAP);
|
||||
const drills = rest ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP));
|
||||
const drills =
|
||||
rest || plan.sweep ? [] : await pickDrills(db, date, DAILY_CAP - Math.min(due.length, DAILY_CAP));
|
||||
|
||||
const data: DigestData = {
|
||||
day: prettyDate(date),
|
||||
progress: `Day ${campaignDay(date)} of ${CAMPAIGN_DAYS} · Week ${campaignWeek(date)}`,
|
||||
streak: await streak(db, date),
|
||||
rest,
|
||||
topics: [],
|
||||
reviews: await Promise.all(capped.map((p) => tapRow(env, p, date, true))),
|
||||
carried: due.length - capped.length,
|
||||
drills: await Promise.all(drills.map((p) => tapRow(env, p, date, false))),
|
||||
@@ -88,28 +120,42 @@ export async function collectDigest(env: Env, date: string): Promise<DigestData>
|
||||
progressUrl: `${env.DOCS_URL}/progress`,
|
||||
};
|
||||
|
||||
// New topic — the only section allowed to name problems and link them.
|
||||
const topicIssue = rest ? undefined : SCHEDULE[date];
|
||||
if (topicIssue !== undefined) {
|
||||
// New topics — the plan's sections, the only ones allowed to name problems
|
||||
// and link them. Core first; the optional set is "if time", minus anything
|
||||
// the plan has reserved for a gate or a set day.
|
||||
for (const topicIssue of plan.topics ?? []) {
|
||||
const topic = await db
|
||||
.prepare("SELECT name FROM topics WHERE issue = ?")
|
||||
.bind(topicIssue)
|
||||
.first<{ name: string }>();
|
||||
const { results: core } = await db
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
"SELECT * FROM problems WHERE topic_issue = ? AND set_label = 'core' ORDER BY lc_number",
|
||||
"SELECT * FROM problems WHERE topic_issue = ? AND set_label IN ('core', 'optional') ORDER BY lc_number",
|
||||
)
|
||||
.bind(topicIssue)
|
||||
.all<ProblemRow>();
|
||||
data.topic = {
|
||||
data.topics.push({
|
||||
name: topic?.name ?? `#${topicIssue}`,
|
||||
core: core.map((p) => ({
|
||||
lc: p.lc_number,
|
||||
difficulty: p.difficulty,
|
||||
url: `https://github.com/${env.REPO}/issues/${p.issue}`,
|
||||
solved: p.stage !== "new",
|
||||
})),
|
||||
};
|
||||
core: results.filter((p) => p.set_label === "core").map((p) => topicRow(env, p)),
|
||||
ifTime: results
|
||||
.filter((p) => p.set_label === "optional" && !RESERVED.has(p.lc_number))
|
||||
.map((p) => topicRow(env, p)),
|
||||
});
|
||||
}
|
||||
|
||||
// A set day assigns problems by number rather than by topic.
|
||||
if (plan.problems) {
|
||||
const { rows, missing } = await lookupProblems(db, plan.problems);
|
||||
if (missing.length) console.warn(`digest ${date}: planned problems not in the catalog: ${missing.join(", ")}`);
|
||||
data.assigned = rows.map((p) => topicRow(env, p));
|
||||
}
|
||||
|
||||
// A sweep day works the miss log: every problem still standing on a
|
||||
// failure, minus the ones today's review list already carries.
|
||||
if (plan.sweep) {
|
||||
const reviewed = new Set(capped.map((p) => p.lc_number));
|
||||
const misses = (await openMisses(db)).filter((p) => !reviewed.has(p.lc_number));
|
||||
data.sweep = await Promise.all(misses.map((p) => tapRow(env, p, date, true)));
|
||||
}
|
||||
|
||||
// Saturday: the review issue already exists (created at midnight ET).
|
||||
|
||||
+85
-17
@@ -83,7 +83,7 @@ export interface DigestRow {
|
||||
failUrl: string;
|
||||
}
|
||||
|
||||
/** A learning-day core problem — the only rows allowed a link. */
|
||||
/** A plan-named problem — the only rows allowed a link. */
|
||||
export interface TopicRow {
|
||||
lc: number;
|
||||
difficulty: string;
|
||||
@@ -91,6 +91,13 @@ export interface TopicRow {
|
||||
solved: boolean;
|
||||
}
|
||||
|
||||
/** One learning-day topic: the core list, then the optional "if time" list. */
|
||||
export interface TopicSection {
|
||||
name: string;
|
||||
core: TopicRow[];
|
||||
ifTime: TopicRow[];
|
||||
}
|
||||
|
||||
export interface DigestData {
|
||||
/** "Tuesday, August 25" */
|
||||
day: string;
|
||||
@@ -98,11 +105,16 @@ export interface DigestData {
|
||||
progress: string;
|
||||
streak: number;
|
||||
rest: boolean;
|
||||
topic?: { name: string; core: TopicRow[] };
|
||||
/** Topics taught today, in plan order — empty on every other day. */
|
||||
topics: TopicSection[];
|
||||
/** A set day's problems, assigned by number rather than by topic. */
|
||||
assigned?: TopicRow[];
|
||||
reviews: DigestRow[];
|
||||
/** Reviews past the daily cap; levelled onto the coming days. */
|
||||
carried: number;
|
||||
drills: DigestRow[];
|
||||
/** A sweep day's miss log: present (even empty) only on a sweep day. */
|
||||
sweep?: DigestRow[];
|
||||
gate?: { week: number; url: string };
|
||||
yesterday: { total: number; failed: number[] };
|
||||
gateRate?: number;
|
||||
@@ -278,10 +290,15 @@ function Block({
|
||||
// ── email ────────────────────────────────────────────────────────
|
||||
|
||||
function DigestEmail({ data }: { data: DigestData }) {
|
||||
const load = data.reviews.length + data.drills.length;
|
||||
const preview = data.rest
|
||||
? "Rest day — nothing to do but rest."
|
||||
: `${load} to work through today${data.topic ? `, starting with ${data.topic.name}` : ""}.`;
|
||||
const load = data.reviews.length + data.drills.length + (data.sweep?.length ?? 0);
|
||||
const lead = data.topics[0]
|
||||
? `, starting with ${data.topics[0].name}`
|
||||
: data.assigned
|
||||
? ", starting with today's set"
|
||||
: data.sweep
|
||||
? " — sweep day, nothing new"
|
||||
: "";
|
||||
const preview = data.rest ? "Rest day — nothing to do but rest." : `${load} to work through today${lead}.`;
|
||||
|
||||
const passed = data.yesterday.total - data.yesterday.failed.length;
|
||||
const missed = data.yesterday.failed.map((lc) => `LC ${lc}`).join(", ");
|
||||
@@ -310,15 +327,47 @@ function DigestEmail({ data }: { data: DigestData }) {
|
||||
|
||||
{data.rest ? null : (
|
||||
<>
|
||||
{data.topic ? (
|
||||
<Section style={{ marginTop: "28px" }}>
|
||||
<Text style={label}>Something new today</Text>
|
||||
{data.topics.map((topic, i) => (
|
||||
<Section key={topic.name} style={{ marginTop: i === 0 ? "28px" : "20px" }}>
|
||||
{i === 0 ? (
|
||||
<Text style={label}>
|
||||
{data.topics.length === 1 ? "Something new today" : "Two new things today"}
|
||||
</Text>
|
||||
) : null}
|
||||
<Text style={{ color: INK, fontSize: "16px", fontWeight: 600, margin: "0 0 8px" }}>
|
||||
{data.topic.name}
|
||||
{topic.name}
|
||||
</Text>
|
||||
<Text style={hint}>Work through these from the top — a tick means it is already solved.</Text>
|
||||
<TopicTable rows={data.topic.core} />
|
||||
<TopicTable rows={topic.core} />
|
||||
{topic.ifTime.length > 0 ? (
|
||||
<>
|
||||
<Text style={{ ...hint, margin: "12px 0 8px" }}>
|
||||
If time — only once the core is done inside the clock.
|
||||
</Text>
|
||||
<TopicTable rows={topic.ifTime} />
|
||||
</>
|
||||
) : null}
|
||||
</Section>
|
||||
))}
|
||||
|
||||
{data.assigned ? (
|
||||
<Section style={{ marginTop: "28px" }}>
|
||||
<Text style={label}>Today's set</Text>
|
||||
<Text style={hint}>
|
||||
Picked by number, not by topic — all of them, in order, inside the clock.
|
||||
</Text>
|
||||
<TopicTable rows={data.assigned} />
|
||||
</Section>
|
||||
) : null}
|
||||
|
||||
{data.sweep ? (
|
||||
<Block
|
||||
title="Sweep — redo every red"
|
||||
note="Every problem still standing on a miss. Nothing new today; clear these first."
|
||||
rows={data.sweep}
|
||||
staged
|
||||
empty="The miss log is clear — nothing is standing on a fail."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Block
|
||||
@@ -409,16 +458,27 @@ function plainDigest(data: DigestData): string {
|
||||
: ` · ${data.streak} day${data.streak === 1 ? "" : "s"} in a row, keep it going.`),
|
||||
];
|
||||
|
||||
const linked = (p: TopicRow) =>
|
||||
` LC ${p.lc} — ${DIFFICULTY[p.difficulty]?.label ?? p.difficulty}${p.solved ? " (done)" : ""} — ${p.url}`;
|
||||
|
||||
if (!data.rest) {
|
||||
if (data.topic) {
|
||||
for (const topic of data.topics) {
|
||||
out.push(
|
||||
"",
|
||||
`SOMETHING NEW TODAY — ${data.topic.name}`,
|
||||
`SOMETHING NEW TODAY — ${topic.name}`,
|
||||
"Work through these from the top; the ones marked done you have already solved.",
|
||||
...data.topic.core.map(
|
||||
(p) =>
|
||||
` LC ${p.lc} — ${DIFFICULTY[p.difficulty]?.label ?? p.difficulty}${p.solved ? " (done)" : ""} — ${p.url}`,
|
||||
),
|
||||
...topic.core.map(linked),
|
||||
);
|
||||
if (topic.ifTime.length > 0) {
|
||||
out.push(" If time — only once the core is done inside the clock:", ...topic.ifTime.map(linked));
|
||||
}
|
||||
}
|
||||
if (data.assigned) {
|
||||
out.push(
|
||||
"",
|
||||
"TODAY'S SET",
|
||||
"Picked by number, not by topic — all of them, in order, inside the clock.",
|
||||
...data.assigned.map(linked),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -436,6 +496,14 @@ function plainDigest(data: DigestData): string {
|
||||
"No cold starts today.",
|
||||
],
|
||||
];
|
||||
if (data.sweep) {
|
||||
lists.unshift([
|
||||
"SWEEP — REDO EVERY RED",
|
||||
"Every problem still standing on a miss. Nothing new today; clear these first.",
|
||||
data.sweep,
|
||||
"The miss log is clear — nothing is standing on a fail.",
|
||||
]);
|
||||
}
|
||||
for (const [title, note, rows, empty] of lists) {
|
||||
out.push("", title);
|
||||
if (rows.length === 0) {
|
||||
|
||||
+58
-40
@@ -2,21 +2,27 @@
|
||||
* Saturday review issue: created at midnight ET so the 8 AM digest can link
|
||||
* to it; scored when the issue closes (webhook).
|
||||
*
|
||||
* The gate half is a topic-blind quiz — one unsolved optional problem per
|
||||
* topic scheduled this week (which is why daily drills only draw from
|
||||
* earlier weeks). The recap half is generated data, deliberately NOT a task
|
||||
* list: the week's solved problems are already scheduled by the +3/+7 ladder,
|
||||
* and re-assigning them on Saturday would be massed practice.
|
||||
* The gate half is a topic-blind quiz. When the plan fixes the set
|
||||
* (`gate` in data/schedule.json) that list IS the gate, verbatim — every
|
||||
* number must be a catalog problem, or the issue is not created, because a
|
||||
* `/done` on an uncatalogued number can never be logged. Otherwise the set
|
||||
* is sampled: one unsolved optional problem per topic scheduled this week
|
||||
* (which is why daily drills only draw from earlier weeks), never one the
|
||||
* plan has reserved. The recap half is generated data, deliberately NOT a
|
||||
* task list: the week's solved problems are already scheduled by the +3/+7
|
||||
* ladder, and re-assigning them on Saturday would be massed practice.
|
||||
*
|
||||
* Label is `review` (the retired Actions system owned `gate`). Creation is
|
||||
* idempotent by week; deferred Hards never appear.
|
||||
* idempotent by week; deferred Hards never appear in a sampled set.
|
||||
*/
|
||||
import {
|
||||
type ProblemRow,
|
||||
RESERVED,
|
||||
type Result,
|
||||
SCHEDULE,
|
||||
campaignWeek,
|
||||
isoWeek,
|
||||
lookupProblems,
|
||||
rng,
|
||||
sample,
|
||||
streak,
|
||||
@@ -32,7 +38,7 @@ function capitalized(difficulty: string): string {
|
||||
return difficulty[0]!.toUpperCase() + difficulty.slice(1);
|
||||
}
|
||||
|
||||
/** Unsolved problems of a topic in a set — never attempted, never drilled. */
|
||||
/** Unsolved problems of a topic in a set — never attempted, drilled, or reserved. */
|
||||
async function freshPool(db: D1Database, topic: number, set: string): Promise<ProblemRow[]> {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
@@ -44,7 +50,7 @@ async function freshPool(db: D1Database, topic: number, set: string): Promise<Pr
|
||||
)
|
||||
.bind(topic, set)
|
||||
.all<ProblemRow>();
|
||||
return results;
|
||||
return results.filter((p) => !RESERVED.has(p.lc_number));
|
||||
}
|
||||
|
||||
export interface CreateReport {
|
||||
@@ -67,40 +73,52 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
|
||||
return { created: false, issue: existing.issue, reason: `${title} exists (#${existing.issue})` };
|
||||
}
|
||||
|
||||
// Topics scheduled this week; review-only weeks (Sep 23 onward) sample
|
||||
// across every topic that still has unsolved optional problems.
|
||||
let topics = Object.entries(SCHEDULE)
|
||||
.filter(([d]) => campaignWeek(d) === week)
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.map(([, t]) => t);
|
||||
if (topics.length === 0) {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT DISTINCT topic_issue AS t FROM problems
|
||||
WHERE set_label = 'optional' AND stage = 'new'
|
||||
AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
|
||||
ORDER BY topic_issue`,
|
||||
)
|
||||
.all<{ t: number }>();
|
||||
topics = sample(results.map((r) => r.t), MAX_GATE_PROBLEMS, rng(`gate-topics-${iso}`)).sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
}
|
||||
if (topics.length === 0) return { created: false, reason: "no topics with unsolved pools" };
|
||||
|
||||
const random = rng(`gate-${iso}`);
|
||||
// The plan's fixed set wins; otherwise sample this week's topics, and in a
|
||||
// week without topics, across every topic that still has an unsolved pool.
|
||||
const picks: { p: ProblemRow; fallback: boolean }[] = [];
|
||||
for (const topic of topics) {
|
||||
const optional = await freshPool(db, topic, "optional");
|
||||
if (optional.length > 0) {
|
||||
picks.push({ p: sample(optional, 1, random)[0]!, fallback: false });
|
||||
continue;
|
||||
const planned = SCHEDULE[date]?.gate;
|
||||
if (planned) {
|
||||
const { rows, missing } = await lookupProblems(db, planned);
|
||||
if (missing.length) {
|
||||
return {
|
||||
created: false,
|
||||
reason: `planned gate names problems outside the catalog: LC ${missing.join(", LC ")}`,
|
||||
};
|
||||
}
|
||||
const core = await freshPool(db, topic, "core");
|
||||
if (core.length > 0) picks.push({ p: sample(core, 1, random)[0]!, fallback: true });
|
||||
// Both pools exhausted: the topic is fully solved; nothing to quiz.
|
||||
picks.push(...rows.map((p) => ({ p, fallback: false })));
|
||||
} else {
|
||||
let topics = Object.entries(SCHEDULE)
|
||||
.filter(([d]) => campaignWeek(d) === week)
|
||||
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||
.flatMap(([, day]) => day.topics ?? []);
|
||||
if (topics.length === 0) {
|
||||
const { results } = await db
|
||||
.prepare(
|
||||
`SELECT DISTINCT topic_issue AS t FROM problems
|
||||
WHERE set_label = 'optional' AND stage = 'new'
|
||||
AND lc_number NOT IN (SELECT lc_number FROM drill_pool_used)
|
||||
ORDER BY topic_issue`,
|
||||
)
|
||||
.all<{ t: number }>();
|
||||
topics = sample(results.map((r) => r.t), MAX_GATE_PROBLEMS, rng(`gate-topics-${iso}`)).sort(
|
||||
(a, b) => a - b,
|
||||
);
|
||||
}
|
||||
if (topics.length === 0) return { created: false, reason: "no topics with unsolved pools" };
|
||||
|
||||
const random = rng(`gate-${iso}`);
|
||||
for (const topic of topics) {
|
||||
const optional = await freshPool(db, topic, "optional");
|
||||
if (optional.length > 0) {
|
||||
picks.push({ p: sample(optional, 1, random)[0]!, fallback: false });
|
||||
continue;
|
||||
}
|
||||
const core = await freshPool(db, topic, "core");
|
||||
if (core.length > 0) picks.push({ p: sample(core, 1, random)[0]!, fallback: true });
|
||||
// Both pools exhausted: the topic is fully solved; nothing to quiz.
|
||||
}
|
||||
if (picks.length === 0) return { created: false, reason: `every pool for week ${week} is exhausted` };
|
||||
}
|
||||
if (picks.length === 0) return { created: false, reason: `every pool for week ${week} is exhausted` };
|
||||
|
||||
// Recap: everything attempted this week, plus streak — data, not tasks.
|
||||
const monday = weekMonday(date);
|
||||
@@ -154,7 +172,7 @@ export async function createReviewIssue(env: Env, gh: GitHub, date: string): Pro
|
||||
|
||||
const milestone = await db
|
||||
.prepare("SELECT milestone FROM topics WHERE issue = ? AND milestone IS NOT NULL")
|
||||
.bind(topics[0])
|
||||
.bind(picks[0]!.p.topic_issue)
|
||||
.first<{ milestone: number }>();
|
||||
|
||||
const created = (await gh.rest(`/repos/${gh.repo}/issues`, {
|
||||
|
||||
@@ -8,6 +8,8 @@ import { describe, expect, test } from "bun:test";
|
||||
|
||||
import {
|
||||
INTERVAL,
|
||||
RESERVED,
|
||||
SCHEDULE,
|
||||
STAGES,
|
||||
WINDOWS,
|
||||
addDays,
|
||||
@@ -21,6 +23,7 @@ import {
|
||||
sample,
|
||||
TEMPERATURES,
|
||||
temperatureOf,
|
||||
topicDate,
|
||||
weekdayOf,
|
||||
workingDay,
|
||||
} from "./srs.ts";
|
||||
@@ -125,6 +128,38 @@ describe("the rest day", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the plan", () => {
|
||||
test("never books the rest day, and gates only on Saturday", () => {
|
||||
for (const [date, day] of Object.entries(SCHEDULE)) {
|
||||
expect(isRestDay(date)).toBe(false);
|
||||
if (day.gate) expect(weekdayOf(date)).toBe(6);
|
||||
if (day.topics) expect(weekdayOf(date)).toBeLessThan(6);
|
||||
}
|
||||
});
|
||||
|
||||
test("a topic is taught once; a doubled day answers for both", () => {
|
||||
const seen = new Set<number>();
|
||||
for (const day of Object.values(SCHEDULE)) {
|
||||
for (const topic of day.topics ?? []) {
|
||||
expect(seen.has(topic)).toBe(false);
|
||||
seen.add(topic);
|
||||
}
|
||||
}
|
||||
expect(topicDate(17)).toBe("2026-09-21");
|
||||
expect(topicDate(18)).toBe("2026-09-21");
|
||||
expect(topicDate(999)).toBeUndefined();
|
||||
});
|
||||
|
||||
test("every gate and set problem is reserved from the samplers", () => {
|
||||
for (const day of Object.values(SCHEDULE)) {
|
||||
for (const lc of [...(day.gate ?? []), ...(day.problems ?? [])]) {
|
||||
expect(RESERVED.has(lc)).toBe(true);
|
||||
}
|
||||
}
|
||||
expect(RESERVED.size).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the ladder", () => {
|
||||
test("two rungs, named after the windows", () => {
|
||||
expect(STAGES).toEqual(["new", "+3", "+7", "retired"]);
|
||||
|
||||
+60
-5
@@ -126,12 +126,30 @@ export function prettyDate(date: string): string {
|
||||
|
||||
// ── the schedule (bundled; humans edit apps/api/data/schedule.json) ───
|
||||
|
||||
export const SCHEDULE: Record<string, number> = scheduleJson;
|
||||
/**
|
||||
* One calendar day of the plan. Most days teach topics (one, or two when the
|
||||
* plan doubles up); the rest are named by what they assign instead: a fixed
|
||||
* gate set for Saturday's review issue, a fixed problem set (a "B-set" day),
|
||||
* or a sweep — redo every miss, nothing new. Absent dates are unplanned:
|
||||
* the ladder still runs, the digest just has nothing new to say.
|
||||
*/
|
||||
export interface ScheduledDay {
|
||||
/** Topic issues taught that day, in the order the digest presents them. */
|
||||
topics?: number[];
|
||||
/** Problems assigned by number instead of by topic. */
|
||||
problems?: number[];
|
||||
/** Saturday: the blind set, fixed by the plan rather than sampled. */
|
||||
gate?: number[];
|
||||
/** Miss-log sweep: every problem whose last attempt failed, and no new ones. */
|
||||
sweep?: boolean;
|
||||
}
|
||||
|
||||
export const SCHEDULE: Record<string, ScheduledDay> = scheduleJson;
|
||||
|
||||
/** Date the schedule teaches a topic on, if it teaches it at all. */
|
||||
export function topicDate(topic: number): string | undefined {
|
||||
for (const [date, t] of Object.entries(SCHEDULE)) {
|
||||
if (t === topic) return date;
|
||||
for (const [date, day] of Object.entries(SCHEDULE)) {
|
||||
if (day.topics?.includes(topic)) return date;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -142,6 +160,16 @@ export function topicWeek(topic: number): number | undefined {
|
||||
return date === undefined ? undefined : campaignWeek(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every problem the plan names outright — a gate's blind set or a set day's
|
||||
* list. The plan owns these: the samplers (daily drills, a sampled gate)
|
||||
* never draw them, so a gate problem stays unseen until its Saturday and a
|
||||
* set day's problems wait for their day.
|
||||
*/
|
||||
export const RESERVED: ReadonlySet<number> = new Set(
|
||||
Object.values(SCHEDULE).flatMap((day) => [...(day.gate ?? []), ...(day.problems ?? [])]),
|
||||
);
|
||||
|
||||
// ── deterministic sampling ───────────────────────────────────────
|
||||
|
||||
/** FNV-1a → mulberry32: seeded PRNG so re-runs pick identical problems. */
|
||||
@@ -248,6 +276,31 @@ export async function getProblem(db: D1Database, lc: number): Promise<ProblemRow
|
||||
return db.prepare("SELECT * FROM problems WHERE lc_number = ?").bind(lc).first<ProblemRow>();
|
||||
}
|
||||
|
||||
/**
|
||||
* Catalog rows for a list of numbers the plan names, in the plan's order,
|
||||
* plus the numbers the catalog does not know — a planned problem without an
|
||||
* issue can never be logged, so callers decide whether that is fatal.
|
||||
*/
|
||||
export async function lookupProblems(
|
||||
db: D1Database,
|
||||
lcs: number[],
|
||||
): Promise<{ rows: ProblemRow[]; missing: number[] }> {
|
||||
if (lcs.length === 0) return { rows: [], missing: [] };
|
||||
const { results } = await db
|
||||
.prepare(`SELECT * FROM problems WHERE lc_number IN (${lcs.map(() => "?").join(",")})`)
|
||||
.bind(...lcs)
|
||||
.all<ProblemRow>();
|
||||
const byNumber = new Map(results.map((p) => [p.lc_number, p]));
|
||||
const rows: ProblemRow[] = [];
|
||||
const missing: number[] = [];
|
||||
for (const lc of lcs) {
|
||||
const row = byNumber.get(lc);
|
||||
if (row) rows.push(row);
|
||||
else missing.push(lc);
|
||||
}
|
||||
return { rows, missing };
|
||||
}
|
||||
|
||||
/**
|
||||
* What "due" means, in one place: not retired, scheduled on/before ?1, and
|
||||
* past its deferral if it has one. Every caller that asks "what is open on
|
||||
@@ -358,8 +411,9 @@ export async function levelReviews(db: D1Database, date: string): Promise<number
|
||||
* scheduled in an earlier week (the current week's optional pool is reserved
|
||||
* for Saturday's gate) AND showing learning evidence: at least one of the
|
||||
* topic's core problems has entered the ladder. A skipped learning day never
|
||||
* feeds drills just because its calendar week lapsed. Never repeated,
|
||||
* seeded by date. Boosted topics contribute up to 2 extra.
|
||||
* feeds drills just because its calendar week lapsed. Problems the plan names
|
||||
* outright (RESERVED) are never drawn. Never repeated, seeded by date.
|
||||
* Boosted topics contribute up to 2 extra.
|
||||
*/
|
||||
export async function pickDrills(
|
||||
db: D1Database,
|
||||
@@ -382,6 +436,7 @@ export async function pickDrills(
|
||||
)
|
||||
.all<ProblemRow & { boost: number }>();
|
||||
const pool = results.filter((p) => {
|
||||
if (RESERVED.has(p.lc_number)) return false;
|
||||
const w = topicWeek(p.topic_issue);
|
||||
return w !== undefined && w < week;
|
||||
});
|
||||
|
||||
@@ -8,12 +8,13 @@
|
||||
"test": "bun ./test.ts",
|
||||
"submit": "bun ./submit.ts",
|
||||
"sync": "bun ./sync.ts",
|
||||
"videos": "bun ./videos.ts",
|
||||
"close-solved": "bun ./close-solved.ts",
|
||||
"close-topics": "bun ./close-topics.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@clack/prompts": "^1.7.0",
|
||||
"@night-slayer18/leetcode-cli": "^3.5.0",
|
||||
"@night-slayer18/leetcode-cli": "^3.5.2",
|
||||
"@types/bun": "^1.3.14"
|
||||
}
|
||||
}
|
||||
+31
-17
@@ -30,17 +30,27 @@ function lcNumber(rel: string): number | undefined {
|
||||
}
|
||||
|
||||
/** Fuzzy-pick a solution under work/ and run `leetcode <command>` on it. */
|
||||
export async function runOnSolution(command: "test" | "submit", message: string): Promise<never> {
|
||||
export async function runOnSolution(
|
||||
command: "test" | "submit",
|
||||
message: string,
|
||||
): Promise<never> {
|
||||
const db = problemDb();
|
||||
|
||||
const scanned = [...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync({ cwd: WORK_DIR })];
|
||||
const scanned = [
|
||||
...new Bun.Glob("**/*.{js,ts,py,java,c,cpp,go,rs,rb,swift,kt,cs}").scanSync(
|
||||
{ cwd: WORK_DIR },
|
||||
),
|
||||
];
|
||||
|
||||
// register creation times so files older than the db still sort correctly
|
||||
for (const f of scanned) {
|
||||
const num = lcNumber(f);
|
||||
if (num === undefined) continue;
|
||||
const stat = statSync(join(WORK_DIR, f));
|
||||
db.ensureSolution(num, new Date(stat.birthtimeMs || stat.mtimeMs).toISOString());
|
||||
db.ensureSolution(
|
||||
num,
|
||||
new Date(stat.birthtimeMs || stat.mtimeMs).toISOString(),
|
||||
);
|
||||
}
|
||||
const activity = db.solutionActivity();
|
||||
|
||||
@@ -51,20 +61,21 @@ export async function runOnSolution(command: "test" | "submit", message: string)
|
||||
return (command === "test" ? row?.lastTested : row?.createdAt) ?? undefined;
|
||||
};
|
||||
|
||||
const files = scanned
|
||||
.sort((a, b) => {
|
||||
const ka = sortKey(a);
|
||||
const kb = sortKey(b);
|
||||
if (ka !== kb) {
|
||||
if (ka === undefined) return 1;
|
||||
if (kb === undefined) return -1;
|
||||
return kb.localeCompare(ka); // ISO timestamps: lexicographic = chronological
|
||||
}
|
||||
return a.localeCompare(b, undefined, { numeric: true });
|
||||
});
|
||||
const files = scanned.sort((a, b) => {
|
||||
const ka = sortKey(a);
|
||||
const kb = sortKey(b);
|
||||
if (ka !== kb) {
|
||||
if (ka === undefined) return 1;
|
||||
if (kb === undefined) return -1;
|
||||
return kb.localeCompare(ka); // ISO timestamps: lexicographic = chronological
|
||||
}
|
||||
return a.localeCompare(b, undefined, { numeric: true });
|
||||
});
|
||||
|
||||
if (files.length === 0) {
|
||||
log.error(`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`);
|
||||
log.error(
|
||||
`No solution files found in ${relative(process.cwd(), WORK_DIR)}/`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@@ -74,11 +85,14 @@ export async function runOnSolution(command: "test" | "submit", message: string)
|
||||
maxItems: 12,
|
||||
options: files.map((f) => {
|
||||
const parsed = parseWorkPath(f);
|
||||
const stage = parsed?.bucket === 1 ? "first solve" : `${parsed?.bucket}-day review`;
|
||||
const stage =
|
||||
parsed?.bucket === 1 ? "first solve" : `${parsed?.bucket}-day review`;
|
||||
return {
|
||||
value: f,
|
||||
label: basename(f),
|
||||
hint: parsed ? `${stage} · ${parsed.difficulty} · ${parsed.category}` : f,
|
||||
hint: parsed
|
||||
? `${stage} · ${parsed.difficulty} · ${parsed.category}`
|
||||
: f,
|
||||
};
|
||||
}),
|
||||
filter: (search, option) => {
|
||||
|
||||
+76
-17
@@ -4,18 +4,31 @@
|
||||
*
|
||||
* - New problems get a full page (frontmatter, badge, warning, examples,
|
||||
* constraints, solution) in the gold-standard format.
|
||||
* - Already-ported pages only get their `## Solution` section regenerated,
|
||||
* so hand-curated prose is never touched.
|
||||
* - Already-ported pages only get their script-owned parts regenerated — the
|
||||
* `<Badge>` line and the `## Solution` section — so hand-curated prose is
|
||||
* never touched.
|
||||
* - When a problem is solved in both JavaScript and Python the solution
|
||||
* renders as a <CodeGroup> (Python first); single-language solutions
|
||||
* render as a plain fence.
|
||||
*
|
||||
* Two classifications, two homes. The sidebar group is the campaign TOPIC
|
||||
* from the README table (`tree-dfs/`, ordered by topic number via a
|
||||
* script-owned `meta.ts`); the page badge is the data-structure CATEGORY the
|
||||
* file sits under in `work/1/<Difficulty>/<Category>/`. A problem outside the
|
||||
* plan (warm-ups) has no topic, so it groups under its category instead.
|
||||
*
|
||||
* Topic folders are real route segments (`/tree-dfs/invert-binary-tree`), not
|
||||
* `(group)` folders, so a hand-written `index.mdx` in one is the topic's
|
||||
* landing page at `/tree-dfs` and Blume links the sidebar group row to it.
|
||||
* Sync never writes or moves an `index.mdx`, and never removes a folder that
|
||||
* still holds one.
|
||||
*
|
||||
* Only the first-solve bucket is read: work/3 and work/7 hold blind re-solves
|
||||
* of problems whose page already exists (see work.ts), and a fresh scaffold
|
||||
* there would otherwise wipe the page's `## Solution`.
|
||||
*/
|
||||
import { mkdir, rename } from "node:fs/promises";
|
||||
import { basename, join } from "node:path";
|
||||
import { mkdir, readdir, rename, rmdir, unlink } from "node:fs/promises";
|
||||
import { basename, dirname, join } from "node:path";
|
||||
|
||||
const ROOT = join(import.meta.dir, "..", "..");
|
||||
const WORK = join(ROOT, "work", "1");
|
||||
@@ -158,24 +171,40 @@ function parseHeader(header: string): Header {
|
||||
|
||||
// ── README topic map ─────────────────────────────────────────────
|
||||
|
||||
/** A campaign topic: `<strong>NN. Name</strong>` in the README table. */
|
||||
interface Topic {
|
||||
order?: number; // README topic number; absent for an unplanned problem's category group
|
||||
name: string;
|
||||
}
|
||||
|
||||
const TOPIC_ALIASES: Record<string, string> = {
|
||||
"Hash-Based Lookup": "Hash Table",
|
||||
};
|
||||
|
||||
async function readmeTopics(): Promise<Map<number, string>> {
|
||||
/** LC number -> topic. A problem listed under two topics keeps the first. */
|
||||
async function readmeTopics(): Promise<Map<number, Topic>> {
|
||||
const md = await Bun.file(join(ROOT, "README.md")).text();
|
||||
const map = new Map<number, string>();
|
||||
let topic = "";
|
||||
const map = new Map<number, Topic>();
|
||||
let topic: Topic | null = null;
|
||||
for (const line of md.split("\n")) {
|
||||
const t = line.match(/<strong>\d+\.\s*([^<]+)<\/strong>/);
|
||||
if (t) topic = TOPIC_ALIASES[t[1]!.trim()] ?? t[1]!.trim();
|
||||
const t = line.match(/<strong>(\d+)\.\s*([^<]+)<\/strong>/);
|
||||
if (t) topic = { order: Number(t[1]), name: TOPIC_ALIASES[t[2]!.trim()] ?? t[2]!.trim() };
|
||||
if (!topic) continue;
|
||||
const n = line.match(/<td align="center">(\d+)<\/td>/);
|
||||
if (n) map.set(Number(n[1]), topic);
|
||||
if (n && !map.has(Number(n[1]))) map.set(Number(n[1]), topic);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Blume folder meta: exact topic label (the folder slug loses `/` and case) and
|
||||
* curriculum order. An unplanned category group (warm-ups) has no README
|
||||
* number and sorts at 0, ahead of topic 1, so the sidebar opens on the basics.
|
||||
*/
|
||||
function groupMeta(topic: Topic): string {
|
||||
return `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: ${JSON.stringify(topic.name)}, order: ${topic.order ?? 0} });\n`;
|
||||
}
|
||||
|
||||
// ── inline-code formatting heuristics ────────────────────────────
|
||||
|
||||
/** Identifiers worth backticking in prose, harvested from the code. */
|
||||
@@ -277,7 +306,11 @@ function warningText(h: Header): string {
|
||||
);
|
||||
}
|
||||
|
||||
function renderPage(sol: Solution, topic: string): string {
|
||||
function badgeLine(sol: Solution): string {
|
||||
return `<Badge variant="accent">${sol.category}</Badge>`;
|
||||
}
|
||||
|
||||
function renderPage(sol: Solution): string {
|
||||
const h = sol.header;
|
||||
const ids = identifiers(sol);
|
||||
const description = h.statement[0]?.replace(/\.\s*$/, "") ?? "";
|
||||
@@ -292,7 +325,7 @@ function renderPage(sol: Solution, topic: string): string {
|
||||
` badge: '${h.difficulty}'`,
|
||||
"---",
|
||||
"",
|
||||
`<Badge variant="accent">${topic}</Badge>`,
|
||||
badgeLine(sol),
|
||||
"",
|
||||
);
|
||||
|
||||
@@ -387,12 +420,19 @@ for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).s
|
||||
|
||||
const report: [string, string, string][] = [];
|
||||
const ordered = [...solutions.values()].sort((a, b) => a.num - b.num);
|
||||
const groups = new Map<string, Topic>(); // folder -> topic, for meta.ts
|
||||
const vacated = new Set<string>(); // folders a page moved out of; removed below if empty
|
||||
|
||||
for (const sol of ordered) {
|
||||
const langs = Object.keys(sol.code).sort().join("+");
|
||||
const workRef = `${sol.num}.${sol.slug} (${langs})`;
|
||||
// (category) group folder + leetcode-number prefix: numeric sidebar order, both stripped from the URL.
|
||||
const group = `(${sol.category.toLowerCase().replaceAll(" ", "-")})`;
|
||||
// <topic>/ folder (a real route segment, so its index.mdx is the topic landing
|
||||
// page) + leetcode-number prefix: numeric sidebar order, prefix stripped from the URL.
|
||||
// An unplanned problem's category may share a folder with a numbered topic
|
||||
// (`Hash Table` warm-ups under topic 1); the numbered one owns the meta.
|
||||
const topic = topics.get(sol.num) ?? { name: sol.category };
|
||||
const group = topic.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
||||
if (groups.get(group)?.order === undefined) groups.set(group, topic);
|
||||
const name = `${group}/${sol.num}-${sol.slug}.mdx`;
|
||||
const existing = pages.get(sol.num);
|
||||
await mkdir(join(DOCS, group), { recursive: true });
|
||||
@@ -406,10 +446,11 @@ for (const sol of ordered) {
|
||||
}
|
||||
const tail = page.slice(solutionEnd(page, at));
|
||||
const body = page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : "");
|
||||
const next = ensureLabel(body, sol.header);
|
||||
const next = ensureLabel(body, sol.header).replace(/^<Badge variant="accent">.*<\/Badge>$/m, badgeLine(sol));
|
||||
const actions: string[] = [];
|
||||
if (existing !== name) {
|
||||
await rename(join(DOCS, existing), join(DOCS, name));
|
||||
vacated.add(dirname(existing));
|
||||
actions.push("moved");
|
||||
}
|
||||
if (next !== page) {
|
||||
@@ -418,12 +459,30 @@ for (const sol of ordered) {
|
||||
}
|
||||
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
|
||||
} else {
|
||||
const topic = topics.get(sol.num) ?? sol.category;
|
||||
await Bun.write(join(DOCS, name), renderPage(sol, topic));
|
||||
await Bun.write(join(DOCS, name), renderPage(sol));
|
||||
report.push([workRef, name, "created"]);
|
||||
}
|
||||
}
|
||||
|
||||
// Script-owned folder meta: one per live group, rewritten only when it differs.
|
||||
for (const [group, topic] of groups) {
|
||||
const path = join(DOCS, group, "meta.ts");
|
||||
const meta = groupMeta(topic);
|
||||
if ((await Bun.file(path).exists()) && (await Bun.file(path).text()) === meta) continue;
|
||||
await Bun.write(path, meta);
|
||||
report.push([`topic ${topic.order ?? "-"}`, `${group}/meta.ts`, "written"]);
|
||||
}
|
||||
|
||||
// A group every page left behind is dead: drop its meta and the folder itself.
|
||||
for (const dir of vacated) {
|
||||
if (groups.has(dir)) continue;
|
||||
const left = await readdir(join(DOCS, dir));
|
||||
if (left.some((f) => f !== "meta.ts")) continue;
|
||||
if (left.length) await unlink(join(DOCS, dir, "meta.ts"));
|
||||
await rmdir(join(DOCS, dir));
|
||||
report.push(["", dir, "removed (empty)"]);
|
||||
}
|
||||
|
||||
const w0 = Math.max(...report.map((r) => r[0].length), 9);
|
||||
const w1 = Math.max(...report.map((r) => r[1].length), 9);
|
||||
console.log(`${"work file".padEnd(w0)} ${"docs page".padEnd(w1)} status`);
|
||||
|
||||
Executable
+173
@@ -0,0 +1,173 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Find the NeetCode explanation video for every docs page that has none.
|
||||
*
|
||||
* A finished solution page ends with `## Explanation` holding one
|
||||
* `<YouTube id="…" title="…" />` — NeetCode's walkthrough of the problem.
|
||||
* `sync.ts` never writes that section (it owns only `## Solution`), so pages
|
||||
* accumulate without one. This script lists every numbered page lacking a
|
||||
* `<YouTube` embed and looks each up on NeetCode's two channels, main channel
|
||||
* first, accepting only a title that names the page's exact LC number
|
||||
* (`… - Leetcode 226 - …`) and is not a #short. No YouTube API key: the
|
||||
* channel search page embeds its results as `ytInitialData` JSON, which is
|
||||
* all we parse. Ranking is the channel's own — the first exact-number match
|
||||
* wins.
|
||||
*
|
||||
* bun apps/cli/videos.ts # report: page → video (or "no match")
|
||||
* bun apps/cli/videos.ts --write # also append `## Explanation` + embed
|
||||
*
|
||||
* --write appends at end of file (below `## Solution`, where sync.ts leaves
|
||||
* human sections alone) and adds the `## Explanation` heading only when the
|
||||
* page lacks it. A page with no match is left untouched, so re-runs are
|
||||
* no-ops once every page has its embed.
|
||||
*/
|
||||
import { join } from "node:path";
|
||||
|
||||
import { printTable } from "./report.ts";
|
||||
import { ROOT } from "./work.ts";
|
||||
|
||||
const DOCS = join(ROOT, "apps", "docs", "content");
|
||||
const WRITE = process.argv.includes("--write");
|
||||
|
||||
/** Channel handles searched in order; the first exact match wins. */
|
||||
const CHANNELS = ["NeetCode", "NeetCodeIO"] as const;
|
||||
|
||||
interface Video {
|
||||
id: string;
|
||||
title: string;
|
||||
channel: string;
|
||||
}
|
||||
|
||||
// ── YouTube channel search (no API key) ──────────────────────────
|
||||
|
||||
const HEADERS = { "accept-language": "en-US,en;q=0.9", "user-agent": "Mozilla/5.0" };
|
||||
|
||||
/**
|
||||
* The `var ytInitialData = {…};</script>` blob of a YouTube page, parsed.
|
||||
* YouTube intermittently answers 200 with a page that lacks the blob or cuts
|
||||
* it short, so a bad page is retried a few times before it is an error.
|
||||
*/
|
||||
async function initialData(url: string): Promise<unknown> {
|
||||
let reason = "";
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
if (attempt) await Bun.sleep(500 * attempt);
|
||||
const res = await fetch(url, { headers: HEADERS });
|
||||
if (!res.ok) {
|
||||
reason = `status ${res.status}`;
|
||||
continue;
|
||||
}
|
||||
const html = await res.text();
|
||||
// Slice rather than regex: the blob is ~1 MB, and a lazy `.*?` across it
|
||||
// is what made the first cut of this script fail at random.
|
||||
const open = html.search(/ytInitialData\s*=\s*\{/);
|
||||
const close = open === -1 ? -1 : html.indexOf("</script>", open);
|
||||
if (close === -1) {
|
||||
reason = `no ytInitialData in ${html.length}-byte page`;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
return JSON.parse(html.slice(html.indexOf("{", open), close).replace(/;\s*$/, ""));
|
||||
} catch {
|
||||
reason = "truncated ytInitialData";
|
||||
}
|
||||
}
|
||||
throw new Error(`GET ${url} -> ${reason}`);
|
||||
}
|
||||
|
||||
/** Every videoRenderer in a channel search page, in the page's own order. */
|
||||
async function channelSearch(channel: string, query: string): Promise<Video[]> {
|
||||
const url = `https://www.youtube.com/@${channel}/search?query=${encodeURIComponent(query)}`;
|
||||
const data = await initialData(url);
|
||||
|
||||
const videos: Video[] = [];
|
||||
const walk = (node: unknown): void => {
|
||||
if (!node || typeof node !== "object") return;
|
||||
if ("videoRenderer" in node) {
|
||||
const v = node.videoRenderer;
|
||||
if (v && typeof v === "object" && "videoId" in v && typeof v.videoId === "string") {
|
||||
const runs =
|
||||
"title" in v && v.title && typeof v.title === "object" && "runs" in v.title
|
||||
? v.title.runs
|
||||
: undefined;
|
||||
const title = Array.isArray(runs)
|
||||
? runs.map((r) => (r && typeof r === "object" && "text" in r ? String(r.text) : "")).join("")
|
||||
: "";
|
||||
videos.push({ id: v.videoId, title, channel });
|
||||
}
|
||||
return;
|
||||
}
|
||||
for (const child of Object.values(node)) walk(child);
|
||||
};
|
||||
walk(data);
|
||||
return videos;
|
||||
}
|
||||
|
||||
/** The LC number a NeetCode title names (`… - Leetcode 226 - …`), if any. */
|
||||
function titledNumber(title: string): number | undefined {
|
||||
const m = title.match(/\bleetcode\s*#?\s*(\d+)\b/i);
|
||||
return m ? Number(m[1]) : undefined;
|
||||
}
|
||||
|
||||
async function findVideo(num: number): Promise<Video | undefined> {
|
||||
for (const channel of CHANNELS) {
|
||||
const hit = (await channelSearch(channel, `leetcode ${num}`)).find(
|
||||
(v) => titledNumber(v.title) === num && !/#shorts\b/i.test(v.title),
|
||||
);
|
||||
if (hit) return hit;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// ── docs pages ───────────────────────────────────────────────────
|
||||
|
||||
interface Page {
|
||||
num: number;
|
||||
rel: string;
|
||||
text: string;
|
||||
}
|
||||
|
||||
/** Numbered solution pages without a `<YouTube` embed, by LC number. */
|
||||
async function pagesMissingVideo(): Promise<Page[]> {
|
||||
const pages: Page[] = [];
|
||||
for (const rel of await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))) {
|
||||
const text = await Bun.file(join(DOCS, rel)).text();
|
||||
const t = text.match(/^title: '(\d+)\./m);
|
||||
if (t && !text.includes("<YouTube")) pages.push({ num: Number(t[1]), rel, text });
|
||||
}
|
||||
return pages.sort((a, b) => a.num - b.num);
|
||||
}
|
||||
|
||||
function withEmbed(page: Page, video: Video): string {
|
||||
const embed = `<YouTube id="${video.id}" title="${video.title.replaceAll('"', "'")}" />`;
|
||||
const heading = /^## Explanation\s*$/m.test(page.text) ? "" : "## Explanation\n\n";
|
||||
return `${page.text.replace(/\s*$/, "\n")}\n${heading}${embed}\n`;
|
||||
}
|
||||
|
||||
// ── main ─────────────────────────────────────────────────────────
|
||||
|
||||
const missing = await pagesMissingVideo();
|
||||
if (missing.length === 0) {
|
||||
console.log("every solution page has a video");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const rows: string[][] = [["docs page", "video", "title", WRITE ? "status" : ""]];
|
||||
let found = 0;
|
||||
for (const page of missing) {
|
||||
const video = await findVideo(page.num);
|
||||
if (!video) {
|
||||
rows.push([page.rel, "—", "no NeetCode match", WRITE ? "skipped" : ""]);
|
||||
continue;
|
||||
}
|
||||
found++;
|
||||
let status = "";
|
||||
if (WRITE) {
|
||||
await Bun.write(join(DOCS, page.rel), withEmbed(page, video));
|
||||
status = "written";
|
||||
}
|
||||
rows.push([page.rel, `https://youtu.be/${video.id}`, `${video.title} (${video.channel})`, status]);
|
||||
}
|
||||
|
||||
if (!WRITE) for (const row of rows) row.pop();
|
||||
printTable(rows);
|
||||
console.log(`\n${found}/${missing.length} page(s) matched${WRITE ? `, ${found} written` : ""}`);
|
||||
@@ -10,12 +10,4 @@ export default defineConfig({
|
||||
content: {
|
||||
root: "content",
|
||||
},
|
||||
navigation: {
|
||||
tabs: [
|
||||
{
|
||||
label: "Reference",
|
||||
path: "/reference",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Project MDX components, available on every page with no import. Static
|
||||
* `.astro` files ship no framework JS (a custom element carries any
|
||||
* behaviour); interactive React lives in `islands/` instead.
|
||||
*/
|
||||
import { defineComponents } from "blume";
|
||||
|
||||
export default defineComponents({
|
||||
mdx: {
|
||||
Carousel: "./components/Carousel.astro",
|
||||
CarouselImage: "./components/CarouselImage.astro",
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
---
|
||||
/**
|
||||
* A horizontal carousel of <CarouselImage> slides, built the way Blume's own
|
||||
* content components are: static markup, a custom element for the behaviour,
|
||||
* no framework. The track is a CSS scroll-snap strip (so it also swipes on
|
||||
* touch and the browser does the animation); prev/next sit outside it and
|
||||
* the play toggle sits below with a "n / N" counter.
|
||||
*
|
||||
* `time` is seconds per slide while playing (default 1.25); `autoplay` starts
|
||||
* playing on load. Play wraps around, pauses whenever the tab is hidden, and
|
||||
* never starts on its own for a reader who prefers reduced motion — the
|
||||
* button still works for them. Manual navigation (buttons, arrow keys, swipe)
|
||||
* does not stop playback; it only restarts the interval so a step is never
|
||||
* doubled.
|
||||
*/
|
||||
import Icon from "blume/components/Icon.astro";
|
||||
|
||||
interface Props {
|
||||
/** Seconds each slide stays while playing. */
|
||||
time?: number;
|
||||
autoplay?: boolean;
|
||||
}
|
||||
|
||||
const { time = 1.25, autoplay = false } = Astro.props;
|
||||
|
||||
const buttonClass =
|
||||
"inline-flex size-9 shrink-0 cursor-pointer items-center justify-center rounded-full border border-border bg-background text-muted-foreground transition-colors hover:border-foreground hover:text-foreground disabled:cursor-default disabled:opacity-40";
|
||||
---
|
||||
|
||||
<blume-carousel
|
||||
class="not-prose my-6 block"
|
||||
data-time={String(time)}
|
||||
data-autoplay={autoplay ? "" : undefined}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<button class={buttonClass} data-prev type="button" aria-label="Previous slide">
|
||||
<Icon class="rtl:-scale-x-100" name="chevron-left" size={18} />
|
||||
</button>
|
||||
<div
|
||||
class="flex min-w-0 flex-1 snap-x snap-mandatory overflow-x-auto scroll-smooth rounded-blume [scrollbar-width:none] [&::-webkit-scrollbar]:hidden"
|
||||
data-track
|
||||
tabindex="0"
|
||||
aria-live="polite"
|
||||
>
|
||||
<slot />
|
||||
</div>
|
||||
<button class={buttonClass} data-next type="button" aria-label="Next slide">
|
||||
<Icon class="rtl:-scale-x-100" name="chevron-right" size={18} />
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-center gap-3">
|
||||
<button class={buttonClass} data-play type="button" aria-label="Play" aria-pressed="false">
|
||||
<Icon class="[[aria-pressed=true]_&]:hidden" name="play" size={16} />
|
||||
<Icon class="[[aria-pressed=false]_&]:hidden" name="pause" size={16} />
|
||||
</button>
|
||||
<span class="text-muted-foreground text-xs tabular-nums leading-5" data-count></span>
|
||||
</div>
|
||||
</blume-carousel>
|
||||
|
||||
<script>
|
||||
class BlumeCarousel extends HTMLElement {
|
||||
#track!: HTMLElement;
|
||||
#play!: HTMLButtonElement;
|
||||
#count!: HTMLElement;
|
||||
#timer: number | null = null;
|
||||
#index = 0;
|
||||
|
||||
connectedCallback() {
|
||||
this.#track = this.querySelector("[data-track]")!;
|
||||
this.#play = this.querySelector("[data-play]")!;
|
||||
this.#count = this.querySelector("[data-count]")!;
|
||||
const prev = this.querySelector<HTMLButtonElement>("[data-prev]")!;
|
||||
const next = this.querySelector<HTMLButtonElement>("[data-next]")!;
|
||||
|
||||
const slides = this.#slides().length;
|
||||
if (slides < 2) {
|
||||
for (const b of [prev, next, this.#play]) b.disabled = true;
|
||||
}
|
||||
this.#render();
|
||||
|
||||
prev.addEventListener("click", () => this.#step(-1));
|
||||
next.addEventListener("click", () => this.#step(1));
|
||||
this.#play.addEventListener("click", () => (this.#timer === null ? this.#start() : this.#stop()));
|
||||
this.addEventListener("keydown", (event) => {
|
||||
if (event.key === "ArrowLeft") this.#step(-1);
|
||||
else if (event.key === "ArrowRight") this.#step(1);
|
||||
else return;
|
||||
event.preventDefault();
|
||||
});
|
||||
// Swipes and trackpad scrolls move the track without #step; follow them
|
||||
// once the scroll settles. Settle-only, not per event: a long smooth
|
||||
// scroll from #step passes over intermediate slides and must not be
|
||||
// read back as the reader's position mid-flight.
|
||||
let settle: number | undefined;
|
||||
this.#track.addEventListener(
|
||||
"scroll",
|
||||
() => {
|
||||
window.clearTimeout(settle);
|
||||
settle = window.setTimeout(() => this.#syncFromScroll(), 150);
|
||||
},
|
||||
{ passive: true }
|
||||
);
|
||||
document.addEventListener("visibilitychange", this.#onVisibility);
|
||||
|
||||
const reduced = matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
if (this.hasAttribute("data-autoplay") && slides > 1 && !reduced) this.#start();
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
this.#stop();
|
||||
document.removeEventListener("visibilitychange", this.#onVisibility);
|
||||
}
|
||||
|
||||
#slides(): HTMLElement[] {
|
||||
return [...this.#track.querySelectorAll<HTMLElement>(":scope > blume-carousel-image")];
|
||||
}
|
||||
|
||||
#step(delta: number) {
|
||||
const slides = this.#slides();
|
||||
this.#index = (this.#index + delta + slides.length) % slides.length;
|
||||
slides[this.#index]!.scrollIntoView({ block: "nearest", inline: "start", behavior: "smooth" });
|
||||
this.#render();
|
||||
if (this.#timer !== null) this.#start(); // restart the interval from this slide
|
||||
}
|
||||
|
||||
#syncFromScroll() {
|
||||
const at = Math.round(this.#track.scrollLeft / this.#track.clientWidth);
|
||||
if (at !== this.#index && Number.isFinite(at)) {
|
||||
this.#index = Math.min(Math.max(at, 0), this.#slides().length - 1);
|
||||
this.#render();
|
||||
}
|
||||
}
|
||||
|
||||
#start() {
|
||||
this.#stop();
|
||||
const ms = Number(this.dataset.time) * 1000;
|
||||
this.#timer = window.setInterval(() => this.#step(1), ms > 0 ? ms : 1250);
|
||||
this.#play.setAttribute("aria-pressed", "true");
|
||||
this.#play.setAttribute("aria-label", "Pause");
|
||||
}
|
||||
|
||||
#stop() {
|
||||
if (this.#timer !== null) window.clearInterval(this.#timer);
|
||||
this.#timer = null;
|
||||
this.#play.setAttribute("aria-pressed", "false");
|
||||
this.#play.setAttribute("aria-label", "Play");
|
||||
}
|
||||
|
||||
// A background tab keeps its timers but not its layout; resume on return.
|
||||
#wasPlaying = false;
|
||||
#onVisibility = () => {
|
||||
if (document.hidden) {
|
||||
this.#wasPlaying = this.#timer !== null;
|
||||
this.#stop();
|
||||
} else if (this.#wasPlaying) this.#start();
|
||||
};
|
||||
|
||||
#render() {
|
||||
const slides = this.#slides();
|
||||
this.#count.textContent = `${this.#index + 1} / ${slides.length}`;
|
||||
slides.forEach((s, i) => s.setAttribute("aria-hidden", i === this.#index ? "false" : "true"));
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get("blume-carousel")) {
|
||||
customElements.define("blume-carousel", BlumeCarousel);
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
/**
|
||||
* One slide of a <Carousel>: Blume's own <Frame> around an <img>, so a slide
|
||||
* is pixel-identical to a framed image elsewhere on the site — `alt` doubles
|
||||
* as the frame's hint line and `caption` is Markdown, exactly as in Frame.
|
||||
*
|
||||
* `src` accepts what MDX authors already write for images: a URL, a
|
||||
* root-relative `/file.png` (served from public/, prefixed with the deploy
|
||||
* base), or the page-relative `../../public/file.png` form. A relative path
|
||||
* that does not pass through public/ has nothing to serve it, so it is a
|
||||
* build error rather than a silent 404.
|
||||
*/
|
||||
import Frame from "blume/components/content/Frame.astro";
|
||||
import { withBase } from "blume/components/islands/base-path.ts";
|
||||
|
||||
interface Props {
|
||||
src: string;
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
}
|
||||
|
||||
const { src, alt, caption } = Astro.props;
|
||||
|
||||
const resolveSrc = (value: string): string => {
|
||||
if (URL.canParse(value)) return value;
|
||||
if (value.startsWith("/")) return withBase(value);
|
||||
const at = value.indexOf("public/");
|
||||
if (at === -1) {
|
||||
throw new Error(
|
||||
`<CarouselImage src="${value}">: a relative src must point into public/ (or use /file.png)`
|
||||
);
|
||||
}
|
||||
return withBase(`/${value.slice(at + "public/".length)}`);
|
||||
};
|
||||
---
|
||||
|
||||
<blume-carousel-image
|
||||
class="block w-full shrink-0 snap-center [&_blume-frame]:my-0"
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
>
|
||||
<Frame caption={caption} hint={alt}>
|
||||
<img alt={alt ?? ""} src={resolveSrc(src)} loading="lazy" draggable="false" />
|
||||
</Frame>
|
||||
</blume-carousel-image>
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
+25
@@ -27,6 +27,27 @@ Could you write a generalized algorithm to work on any possible set of character
|
||||
- `0 <= strs[i].length <= 200`
|
||||
- `strs[i]` contains any possible characters out of 256 valid ASCII characters.
|
||||
|
||||
## Approach
|
||||
|
||||
::::warning
|
||||
The diagram traces the code below literally. That code does not solve the problem: both `encode` and `decode` have only a docstring for a body, so each returns `None` and `codec.decode(codec.encode(strs))` yields `None` instead of `strs`.
|
||||
::::
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph state["state — Codec holds no fields"]
|
||||
X["Codec() — nothing is stored between encode and decode"]
|
||||
end
|
||||
subgraph encode["encode(strs)"]
|
||||
E1["docstring only — no statement runs"] --> E2(["return None"])
|
||||
end
|
||||
subgraph decode["decode(s)"]
|
||||
D1["docstring only — no statement runs"] --> D2(["return None"])
|
||||
end
|
||||
state --> encode
|
||||
state --> decode
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
@@ -46,3 +67,7 @@ class Codec:
|
||||
# codec = Codec()
|
||||
# codec.decode(codec.encode(strs))
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="B1k_sxOSgv8" title="Encode and Decode Strings - Leetcode 271 - Python" />
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Array", order: 0 });
|
||||
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: '39. Combination Sum'
|
||||
description: Given an array of distinct integers candidates and a target integer target, return a list of all unique combinations of candidates where the chosen numbers sum to target. You may return the combinations in any order
|
||||
sidebar:
|
||||
label: 'Combination Sum'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `candidates = [2,3,6,7], target = 7`
|
||||
- Output: `[[2,2,3],[7]]`
|
||||
- Explanation: `2` and `3` are `candidates`, and `2 + 2 + 3 = 7`. Note that `2` can be used multiple times. `7` is a candidate, and `7 = 7`. These are the only two combinations.
|
||||
|
||||
### Example 2:
|
||||
- Input: `candidates = [2,3,5], target = 8`
|
||||
- Output: `[[2,2,2,2],[2,3,3],[3,5]]`
|
||||
|
||||
### Example 3:
|
||||
- Input: `candidates = [2], target = 1`
|
||||
- Output: `[]`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= candidates.length <= 30`
|
||||
- `2 <= candidates[i] <= 40`
|
||||
- All elements of `candidates` are distinct.
|
||||
- `1 <= target <= 40`
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def combinationSum(self, candidates: list[int], target: int) -> list[list[int]]:
|
||||
candidates.sort()
|
||||
result, path = [], []
|
||||
|
||||
def backtrack(start, remaining):
|
||||
if remaining == 0:
|
||||
result.append(path[:])
|
||||
return
|
||||
for i in range(start, len(candidates)):
|
||||
if candidates[i] > remaining:
|
||||
break
|
||||
|
||||
path.append(candidates[i])
|
||||
backtrack(i, remaining - candidates[i])
|
||||
path.pop()
|
||||
|
||||
backtrack(0, target)
|
||||
return result
|
||||
```
|
||||
@@ -0,0 +1,52 @@
|
||||
---
|
||||
title: '46. Permutations'
|
||||
description: Given an array nums of distinct integers, return all the possible permutations. You can return the answer in any order
|
||||
sidebar:
|
||||
label: 'Permutations'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [1,2,3]`
|
||||
- Output: `[[1,2,3],[1,3,2],[2,1,3],[2,3,1],[3,1,2],[3,2,1]]`
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [0,1]`
|
||||
- Output: `[[0,1],[1,0]]`
|
||||
|
||||
### Example 3:
|
||||
- Input: `nums = [1]`
|
||||
- Output: `[[1]]`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= nums.length <= 6`
|
||||
- `-10 <= nums[i] <= 10`
|
||||
- All the integers of `nums` are unique.
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def permute(self, nums: list[int]) -> list[list[int]]:
|
||||
result, path = [], []
|
||||
used = [False] * len(nums)
|
||||
|
||||
def backtrack():
|
||||
if len(path) == len(nums):
|
||||
result.append(path[:])
|
||||
return
|
||||
for i in range(len(nums)):
|
||||
if used[i]:
|
||||
continue
|
||||
used[i] = True
|
||||
path.append(nums[i])
|
||||
backtrack()
|
||||
path.pop()
|
||||
used[i] = False
|
||||
|
||||
backtrack()
|
||||
return result
|
||||
```
|
||||
@@ -0,0 +1,41 @@
|
||||
---
|
||||
title: '78. Subsets'
|
||||
description: Given an integer array nums of unique elements, return all possible subsets (the power set)
|
||||
sidebar:
|
||||
label: 'Subsets'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [1,2,3]`
|
||||
- Output: `[[],[1],[2],[1,2],[3],[1,3],[2,3],[1,2,3]]`
|
||||
|
||||
### Example 2:
|
||||
- Input: `nums = [0]`
|
||||
- Output: `[[],[0]]`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= nums.length <= 10`
|
||||
- `-10 <= nums[i] <= 10`
|
||||
- All the numbers of `nums` are unique.
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def subsets(self, nums: list[int]) -> list[list[int]]:
|
||||
res = []
|
||||
|
||||
def backtrack(i, path):
|
||||
res.append(path[:])
|
||||
for num_idx in range(i, len(nums)):
|
||||
path.append(nums[num_idx])
|
||||
backtrack(num_idx + 1, path)
|
||||
path.pop()
|
||||
|
||||
backtrack(0, [])
|
||||
return res
|
||||
```
|
||||
@@ -0,0 +1,163 @@
|
||||
---
|
||||
title: Backtracking
|
||||
description: 'Backtracking is a depth-first search technique that builds a solution step by step and abandons (undoes) a partial choice as soon as it can’t lead to a valid complete solution.'
|
||||
hidden: true
|
||||
---
|
||||
|
||||
## What it is
|
||||
|
||||
Backtracking explores possible paths to a solution one step at a time. When a path reaches a dead end, you undo the last step and try a different option. Think of every problem as a decision tree. The root is an empty answer. Each level is a decision. Each branch is one option.
|
||||
|
||||
## When to use it
|
||||
|
||||
Use backtracking when you must generate **all** valid solutions or evaluate **all** combinations. Do not use it when you need only one solution or the optimal solution. For those, use greedy or dynamic programming. Backtracking has exponential time complexity.
|
||||
|
||||
## The four components
|
||||
|
||||
Every backtracking problem has the same parts:
|
||||
|
||||
1. **Base case** — when to save the current path and stop.
|
||||
2. **Choices** — the options at each step.
|
||||
3. **Constraints** — the rules that reject an invalid path early.
|
||||
4. **Backtrack step** — undo the last choice and try the next one.
|
||||
|
||||
## The template
|
||||
|
||||
```python
|
||||
def solve(data):
|
||||
result = []
|
||||
path = []
|
||||
|
||||
def backtrack(index):
|
||||
# 1. Base case
|
||||
if is_complete(index, path):
|
||||
result.append(path[:]) # copy — lists are passed by reference
|
||||
return
|
||||
|
||||
# 2. Choices
|
||||
for choice in get_choices(index, data):
|
||||
# 3. Constraints
|
||||
if not is_valid(choice, path):
|
||||
continue
|
||||
|
||||
path.append(choice) # make the choice
|
||||
backtrack(index + 1) # explore
|
||||
path.pop() # 4. Backtrack
|
||||
|
||||
backtrack(0)
|
||||
return result
|
||||
```
|
||||
|
||||
## Steps to apply the template
|
||||
|
||||
1. Draw the decision tree for a small input. Name each level and each branch.
|
||||
2. Write the base case. Ask: when is the path complete?
|
||||
3. Write the choices. Ask: what can I add at this level?
|
||||
4. Write the constraints. Ask: what makes a path invalid? Reject it before recursion.
|
||||
5. Add the backtrack step. Pop what you appended.
|
||||
6. Copy the path when you save it. Never append the same list reference.
|
||||
|
||||
## Four problems, one template
|
||||
|
||||
### Subsets
|
||||
|
||||
Choice: include or skip each number. No constraint. Base case: index equals list length.
|
||||
|
||||
```python
|
||||
def subsets(nums):
|
||||
result, path = [], []
|
||||
|
||||
def backtrack(i):
|
||||
if i == len(nums):
|
||||
result.append(path[:])
|
||||
return
|
||||
path.append(nums[i]) # include
|
||||
backtrack(i + 1)
|
||||
path.pop()
|
||||
backtrack(i + 1) # skip
|
||||
|
||||
backtrack(0)
|
||||
return result
|
||||
```
|
||||
|
||||
### Permutations
|
||||
|
||||
Same choices as subsets. Constraint: do not reuse a number already in the path. Base case: path length equals list length.
|
||||
|
||||
```python
|
||||
def permute(nums):
|
||||
result, path = [], []
|
||||
used = [False] * len(nums)
|
||||
|
||||
def backtrack():
|
||||
if len(path) == len(nums):
|
||||
result.append(path[:])
|
||||
return
|
||||
for i in range(len(nums)):
|
||||
if used[i]:
|
||||
continue
|
||||
used[i] = True
|
||||
path.append(nums[i])
|
||||
backtrack()
|
||||
path.pop()
|
||||
used[i] = False
|
||||
|
||||
backtrack()
|
||||
return result
|
||||
```
|
||||
|
||||
### Combinations
|
||||
|
||||
Same as subsets. Change one line: save the path only when its length equals `k`.
|
||||
|
||||
```python
|
||||
def combine(n, k):
|
||||
result, path = [], []
|
||||
|
||||
def backtrack(start):
|
||||
if len(path) == k:
|
||||
result.append(path[:])
|
||||
return
|
||||
for i in range(start, n + 1):
|
||||
path.append(i)
|
||||
backtrack(i + 1)
|
||||
path.pop()
|
||||
|
||||
backtrack(1)
|
||||
return result
|
||||
```
|
||||
|
||||
### N-Queens
|
||||
|
||||
Choice: which column to place the queen in the current row. Constraint: no shared column or diagonal. Base case: `n` queens placed. Backtrack: remove the last queen.
|
||||
|
||||
```python
|
||||
def solve_n_queens(n):
|
||||
result, cols = [], []
|
||||
used_cols, diag1, diag2 = set(), set(), set()
|
||||
|
||||
def backtrack(row):
|
||||
if row == n:
|
||||
result.append(cols[:])
|
||||
return
|
||||
for col in range(n):
|
||||
if col in used_cols or row - col in diag1 or row + col in diag2:
|
||||
continue
|
||||
cols.append(col)
|
||||
used_cols.add(col); diag1.add(row - col); diag2.add(row + col)
|
||||
backtrack(row + 1)
|
||||
cols.pop()
|
||||
used_cols.remove(col); diag1.remove(row - col); diag2.remove(row + col)
|
||||
|
||||
backtrack(0)
|
||||
return result
|
||||
```
|
||||
|
||||
## Common mistakes
|
||||
|
||||
- You append the `path` list itself instead of a copy. The result fills with empty lists.
|
||||
- You forget to pop. The path grows and never resets.
|
||||
- You check constraints after recursion instead of before. You waste time on dead paths.
|
||||
- You use backtracking when you need only one answer. Use greedy or DP instead.
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Backtracking", order: 20 });
|
||||
+22
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Binary Search Tree</Badge>
|
||||
<Badge variant="accent">Tree</Badge>
|
||||
|
||||
::::warning
|
||||
If the BST is modified often (i.e., we can do insert and delete operations) and you need to find the kth smallest frequently, how would you optimize?
|
||||
@@ -26,6 +26,23 @@ If the BST is modified often (i.e., we can do insert and delete operations) and
|
||||
- `1 <= k <= n <= 10^4`
|
||||
- `0 <= Node.val <= 10^4`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["kthSmallest(root, k)"]) --> I["n = 0, stack = [], cur = root"]
|
||||
I --> W{"cur or stack?"}
|
||||
W -- no --> E(["fall off the loop — unreachable, k <= n"])
|
||||
W -- yes --> L{"cur?"}
|
||||
L -- yes --> P["stack.append(cur), cur = cur.left — push the whole left spine"]
|
||||
P --> L
|
||||
L -- no --> O["cur = stack.pop(), n += 1 — nodes pop in ascending order"]
|
||||
O --> K{"n == k?"}
|
||||
K -- yes --> R(["return cur.val"])
|
||||
K -- no --> N["cur = cur.right"]
|
||||
N --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
@@ -52,3 +69,7 @@ class Solution:
|
||||
return cur.val
|
||||
cur = cur.right
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="5LUXSvjmGCw" title="Kth Smallest Element in a BST - Leetcode 230 - Python" />
|
||||
+21
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Binary Search Tree</Badge>
|
||||
<Badge variant="accent">Tree</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `root = [6,2,8,0,4,7,9,null,null,3,5], p = 2, q = 8`
|
||||
@@ -30,6 +30,22 @@ sidebar:
|
||||
- `p != q`
|
||||
- p and q will exist in the BST.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["lowestCommonAncestor(root, p, q)"]) --> I["cur = root"]
|
||||
I --> W{"cur?"}
|
||||
W -- no --> E(["fall off the loop — unreachable, p and q exist in the BST"])
|
||||
W -- yes --> A{"p.val < cur.val and q.val < cur.val?"}
|
||||
A -- yes --> L["cur = cur.left — both sit in the left subtree"]
|
||||
L --> W
|
||||
A -- no --> B{"p.val > cur.val and q.val > cur.val?"}
|
||||
B -- yes --> R["cur = cur.right — both sit in the right subtree"]
|
||||
R --> W
|
||||
B -- no --> C(["return cur — the paths to p and q split here"])
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
@@ -54,3 +70,7 @@ class Solution:
|
||||
else:
|
||||
return cur
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="gs2LMfuOR9k" title="Lowest Common Ancestor of a Binary Search Tree - Leetcode 235 - Python" />
|
||||
+22
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Binary Search Tree</Badge>
|
||||
<Badge variant="accent">Tree</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `root = [2,1,3]`
|
||||
@@ -22,6 +22,23 @@ sidebar:
|
||||
- The number of nodes in the tree is in the range [1, 10^4].
|
||||
- `-2^31 <= Node.val <= 2^31 - 1`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["isValidBST(root)"]) --> C["valid(root, float('-inf'), float('inf')) — the root may hold any value"]
|
||||
C --> E(["return that result"])
|
||||
subgraph valid["valid(node, low, high)"]
|
||||
V1{"not node?"}
|
||||
V1 -- yes --> V2(["return True"])
|
||||
V1 -- no --> V3{"not (low < node.val < high)?"}
|
||||
V3 -- yes --> V4(["return False — node breaks a bound set by an ancestor"])
|
||||
V3 -- no --> V5["valid(node.left, low, node.val) and valid(node.right, node.val, high) — each child tightens one bound"]
|
||||
V5 --> V6(["return that result"])
|
||||
end
|
||||
C --> V1
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
@@ -44,3 +61,7 @@ class Solution:
|
||||
|
||||
return valid(root, float("-inf"), float("inf"))
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="s6ATEkipzow" title="Validate Binary Search Tree - Depth First Search - Leetcode 98" />
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Binary Search Tree", order: 13 });
|
||||
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Binary Search</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
::::warning
|
||||
Notice that rotating an array [a[0], a[1], a[2], ..., a[n-1]] 1 time results in the array [a[n-1], a[0], a[1], a[2], ..., a[n-2]].
|
||||
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Binary Search</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
::::warning
|
||||
You must write an algorithm with O(log n) runtime complexity.
|
||||
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Binary Search</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
::::warning
|
||||
You must write an algorithm with O(log n) runtime complexity.
|
||||
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Binary Search</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
::::warning
|
||||
You must write an algorithm with O(log n) runtime complexity.
|
||||
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Binary Search</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
::::warning
|
||||
You must write a solution in O(log(m * n)) time complexity.
|
||||
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Binary Search</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `piles = [3,6,7,11], h = 8`
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Binary Search", order: 5 });
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: '127. Word Ladder'
|
||||
description: 'A transformation sequence from word beginWord to word endWord using a dictionary wordList is a sequence of words beginWord -> s1 -> s2 -> ... -> sk such that:'
|
||||
sidebar:
|
||||
label: 'Word Ladder'
|
||||
badge: 'Hard'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `beginWord = "hit", endWord = "cog", wordList = ["hot","dot","dog","lot","log","cog"]`
|
||||
- Output: `5`
|
||||
- Explanation: One shortest transformation sequence is "hit" -> "hot" -> "dot" -> "dog" -> cog", which is `5` words long.
|
||||
|
||||
### Example 2:
|
||||
- Input: `beginWord = "hit", endWord = "cog", wordList = ["hot","dot","dog","lot","log"]`
|
||||
- Output: `0`
|
||||
- Explanation: The `endWord` "cog" is not in `wordList`, therefore there is no valid transformation sequence.
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= beginWord.length <= 10`
|
||||
- `endWord.length == beginWord.length`
|
||||
- `1 <= wordList.length <= 5000`
|
||||
- `wordList[i].length == beginWord.length`
|
||||
- `beginWord`, `endWord`, and `wordList[i]` consist of lowercase English letters.
|
||||
- `beginWord != endWord`
|
||||
- All the words in `wordList` are unique.
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
import collections
|
||||
|
||||
|
||||
class Solution:
|
||||
def ladderLength(self, beginWord: str, endWord: str, wordList: list[str]) -> int:
|
||||
if endWord not in wordList:
|
||||
return 0
|
||||
|
||||
adj = collections.defaultdict(list)
|
||||
wordList.append(beginWord)
|
||||
|
||||
for word in wordList:
|
||||
for j in range(len(word)):
|
||||
pattern = word[:j] + "*" + word[j + 1 :]
|
||||
adj[pattern].append(word)
|
||||
|
||||
visit = set([beginWord])
|
||||
q = collections.deque([beginWord])
|
||||
res = 1
|
||||
|
||||
while q:
|
||||
for i in range(len(q)):
|
||||
word = q.popleft()
|
||||
if word == endWord:
|
||||
return res
|
||||
for j in range(len(word)):
|
||||
pattern = word[:j] + "*" + word[j + 1 :]
|
||||
for adjWord in adj[pattern]:
|
||||
if adjWord not in visit:
|
||||
visit.add(adjWord)
|
||||
q.append(adjWord)
|
||||
res += 1
|
||||
return 0
|
||||
```
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
title: '994. Rotting Oranges'
|
||||
description: 'You are given an m x n grid where each cell can have one of three values:'
|
||||
sidebar:
|
||||
label: 'Rotting Oranges'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `grid = [[2,1,1],[1,1,0],[0,1,1]]`
|
||||
- Output: `4`
|
||||
|
||||
### Example 2:
|
||||
- Input: `grid = [[2,1,1],[0,1,1],[1,0,1]]`
|
||||
- Output: `-1`
|
||||
- Explanation: The orange in the bottom left corner (row `2`, column `0`) is never rotten, because rotting only happens 4-directionally.
|
||||
|
||||
### Example 3:
|
||||
- Input: `grid = [[0,2]]`
|
||||
- Output: `0`
|
||||
- Explanation: Since there are already no fresh oranges at minute `0`, the answer is just `0`.
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `m == grid.length`
|
||||
- `n == grid[i].length`
|
||||
- `1 <= m, n <= 10`
|
||||
- grid[i][j] is 0, 1, or 2.
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
from collections import deque
|
||||
|
||||
|
||||
class Solution:
|
||||
def orangesRotting(self, grid: list[list[int]]) -> int:
|
||||
q = deque()
|
||||
time, fresh = 0, 0
|
||||
|
||||
rows, cols = len(grid), len(grid[0])
|
||||
for r in range(rows):
|
||||
for c in range(cols):
|
||||
if grid[r][c] == 1:
|
||||
fresh += 1
|
||||
if grid[r][c] == 2:
|
||||
q.append([r, c])
|
||||
|
||||
directions = [[0, 1], [0, -1], [1, 0], [-1, 0]]
|
||||
|
||||
while q and fresh > 0:
|
||||
for i in range(len(q)):
|
||||
r, c = q.popleft()
|
||||
for dr, dc in directions:
|
||||
row, col = dr + r, dc + c
|
||||
# check if in bounds
|
||||
if (
|
||||
row < 0
|
||||
or col < 0
|
||||
or row == len(grid)
|
||||
or col == len(grid[0])
|
||||
or grid[row][col] != 1
|
||||
):
|
||||
continue
|
||||
grid[row][col] = 2
|
||||
q.append([row, col])
|
||||
fresh -= 1
|
||||
time += 1
|
||||
return time if fresh == 0 else -1
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Graph BFS", order: 17 });
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
title: '133. Clone Graph'
|
||||
description: Given a reference of a node in a connected undirected graph
|
||||
sidebar:
|
||||
label: 'Clone Graph'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `adjList = [[2,4],[1,3],[2,4],[1,3]]`
|
||||
- Output: `[[2,4],[1,3],[2,4],[1,3]]`
|
||||
- Explanation: There are `4` nodes in the graph. 1st `node` (val = 1)'s neighbors are 2nd `node` (val = `2`) and 4th `node` (val = `4`). 2nd `node` (val = 2)'s neighbors are 1st `node` (val = `1`) and 3rd `node` (val = `3`). 3rd `node` (val = 3)'s neighbors are 2nd `node` (val = `2`) and 4th `node` (val = `4`). 4th `node` (val = 4)'s neighbors are 1st `node` (val = `1`) and 3rd `node` (val = `3`).
|
||||
|
||||
### Example 2:
|
||||
- Input: `adjList = [[]]`
|
||||
- Output: `[[]]`
|
||||
- Explanation: Note that the input contains one empty list. The graph consists of only one `node` with val = `1` and it does not have any neighbors.
|
||||
|
||||
### Example 3:
|
||||
- Input: `adjList = []`
|
||||
- Output: `[]`
|
||||
- Explanation: This an empty graph, it does not have any nodes.
|
||||
|
||||
### Constraints:
|
||||
|
||||
- The number of nodes in the graph is in the range [0, 100].
|
||||
- `1 <= Node.val <= 100`
|
||||
- Node.val is unique for each node.
|
||||
- There are no repeated edges and no self-loops in the graph.
|
||||
- The Graph is connected and all nodes can be visited starting from the given node.
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
"""
|
||||
# Definition for a Node.
|
||||
class Node:
|
||||
def __init__(self, val = 0, neighbors = None):
|
||||
self.val = val
|
||||
self.neighbors = neighbors if neighbors is not None else []
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
|
||||
class Solution:
|
||||
def cloneGraph(self, node: Optional["Node"]) -> Optional["Node"]:
|
||||
oldToNew = {}
|
||||
|
||||
def dfs(node):
|
||||
if node in oldToNew:
|
||||
return oldToNew[node]
|
||||
|
||||
copy = Node(node.val)
|
||||
oldToNew[node] = copy
|
||||
|
||||
for n in node.neighbors:
|
||||
copy.neighbors.append(dfs(n))
|
||||
return copy
|
||||
|
||||
return dfs(node) if node else None
|
||||
```
|
||||
@@ -0,0 +1,51 @@
|
||||
---
|
||||
title: '200. Number of Islands'
|
||||
description: Given an m x n 2D binary grid grid which represents a map of '1's (land) and '0's (water), return the number of islands
|
||||
sidebar:
|
||||
label: 'Number of Islands'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `grid = [ ["1","1","1","1","0"], ["1","1","0","1","0"], ["1","1","0","0","0"], ["0","0","0","0","0"] ]`
|
||||
- Output: `1`
|
||||
|
||||
### Example 2:
|
||||
- Input: `grid = [ ["1","1","0","0","0"], ["1","1","0","0","0"], ["0","0","1","0","0"], ["0","0","0","1","1"] ]`
|
||||
- Output: `3`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `m == grid.length`
|
||||
- `n == grid[i].length`
|
||||
- `1 <= m, n <= 300`
|
||||
- grid[i][j] is '0' or '1'.
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def numIslands(self, grid: List[List[str]]) -> int:
|
||||
rows, cols = len(grid), len(grid[0])
|
||||
|
||||
def dfs(r, c):
|
||||
if r < 0 or r >= rows or c < 0 or c >= cols or grid[r][c] != "1":
|
||||
return
|
||||
else:
|
||||
grid[r][c] = "0"
|
||||
dfs(r, c + 1) # right
|
||||
dfs(r + 1, c) # bottom
|
||||
dfs(r, c - 1) # left
|
||||
dfs(r - 1, c) # top
|
||||
|
||||
num_islands = 0
|
||||
for r in range(rows):
|
||||
for c in range(cols):
|
||||
if grid[r][c] == "1":
|
||||
num_islands += 1
|
||||
dfs(r, c)
|
||||
|
||||
return num_islands
|
||||
```
|
||||
@@ -0,0 +1,67 @@
|
||||
---
|
||||
title: '417. Pacific Atlantic Water Flow'
|
||||
description: There is an m x n rectangular island that borders both the Pacific Ocean and Atlantic Ocean. The Pacific Ocean touches the island's left and top edges, and the Atlantic Ocean touches the island's right and bottom edges
|
||||
sidebar:
|
||||
label: 'Pacific Atlantic Water Flow'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `heights = [[1,2,2,3,5],[3,2,3,4,4],[2,4,5,3,1],[6,7,1,4,5],[5,1,1,2,4]]`
|
||||
- Output: `[[0,4],[1,3],[1,4],[2,2],[3,0],[3,1],[4,0]]`
|
||||
- Explanation: The following cells can flow to the Pacific and Atlantic oceans, as shown below: `[0,4]`: `[0,4]` -> Pacific Ocean `[0,4]` -> Atlantic Ocean `[1,3]`: `[1,3]` -> `[0,3]` -> Pacific Ocean `[1,3]` -> `[1,4]` -> Atlantic Ocean `[1,4]`: `[1,4]` -> `[1,3]` -> `[0,3]` -> Pacific Ocean `[1,4]` -> Atlantic Ocean `[2,2]`: `[2,2]` -> `[1,2]` -> `[0,2]` -> Pacific Ocean `[2,2]` -> `[2,3]` -> `[2,4]` -> Atlantic Ocean `[3,0]`: `[3,0]` -> Pacific Ocean `[3,0]` -> `[4,0]` -> Atlantic Ocean `[3,1]`: `[3,1]` -> `[3,0]` -> Pacific Ocean `[3,1]` -> `[4,1]` -> Atlantic Ocean `[4,0]`: `[4,0]` -> Pacific Ocean `[4,0]` -> Atlantic Ocean Note that there are other possible paths for these cells to flow to the Pacific and Atlantic oceans.
|
||||
|
||||
### Example 2:
|
||||
- Input: `heights = [[1]]`
|
||||
- Output: `[[0,0]]`
|
||||
- Explanation: The water can flow from the only cell to the Pacific and Atlantic oceans.
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `m == heights.length`
|
||||
- `n == heights[r].length`
|
||||
- `1 <= m, n <= 200`
|
||||
- `0 <= heights[r][c] <= 10^5`
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def pacificAtlantic(self, heights: list[list[int]]) -> list[list[int]]:
|
||||
rows, cols = len(heights), len(heights[0])
|
||||
pac, atl = set(), set()
|
||||
|
||||
def dfs(r, c, seen, prevHeight):
|
||||
if (
|
||||
(r, c) in seen
|
||||
or r < 0
|
||||
or c < 0
|
||||
or r == rows
|
||||
or c == cols
|
||||
or heights[r][c] < prevHeight
|
||||
):
|
||||
return
|
||||
seen.add((r, c))
|
||||
dfs(r + 1, c, seen, heights[r][c])
|
||||
dfs(r - 1, c, seen, heights[r][c])
|
||||
dfs(r, c + 1, seen, heights[r][c])
|
||||
dfs(r, c - 1, seen, heights[r][c])
|
||||
|
||||
for c in range(cols):
|
||||
dfs(0, c, pac, heights[0][c])
|
||||
dfs(rows - 1, c, atl, heights[rows - 1][c])
|
||||
|
||||
for r in range(rows):
|
||||
dfs(r, 0, pac, heights[r][0])
|
||||
dfs(r, cols - 1, atl, heights[r][cols - 1])
|
||||
|
||||
res = []
|
||||
for r in range(rows):
|
||||
for c in range(cols):
|
||||
if (r, c) in pac and (r, c) in atl:
|
||||
res.append([r, c])
|
||||
|
||||
return res
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Graph DFS", order: 16 });
|
||||
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
::::warning
|
||||
Can you come up with an algorithm that is less than O(n^2) time complexity?
|
||||
File renamed without changes.
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [1,2,3,1]`
|
||||
File renamed without changes.
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
::::warning
|
||||
Your algorithm's time complexity must be better than O(n log n), where n is the array's size.
|
||||
File renamed without changes.
File renamed without changes.
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `strs = ["eat","tea","tan","ate","nat","bat"]`
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Hash Table", order: 1 });
|
||||
+19
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Heap / Priority Queue</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [3,2,1,5,6,4], k = 2`
|
||||
@@ -21,6 +21,20 @@ sidebar:
|
||||
- `1 <= k <= nums.length <= 10^5`
|
||||
- `-10^4 <= nums[i] <= 10^4`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["findKthLargest(nums, k)"]) --> F{"more i in range(len(nums))?"}
|
||||
F -- yes --> N["nums[i] = -nums[i] — negate so the min-heap acts as a max-heap"]
|
||||
N --> F
|
||||
F -- no --> H["heapq.heapify(nums)"]
|
||||
H --> W{"more _ in range(k - 1)?"}
|
||||
W -- yes --> P["heapq.heappop(nums) — discard the k-1 largest"]
|
||||
P --> W
|
||||
W -- no --> E(["return -heapq.heappop(nums)"])
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
@@ -39,3 +53,7 @@ class Solution:
|
||||
|
||||
return -heapq.heappop(nums)
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="XEmy13g1Qxc" title="Kth Largest Element in an Array - Quick Select - Leetcode 215 - Python" />
|
||||
+26
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Hard'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Heap / Priority Queue</Badge>
|
||||
<Badge variant="accent">Linked List</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `lists = [[1,4,5],[1,3,4],[2,6]]`
|
||||
@@ -30,6 +30,27 @@ sidebar:
|
||||
- `lists[i]` is sorted in ascending order.
|
||||
- The sum of lists[i].length will not exceed 10^4.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["mergeKLists(lists)"]) --> I["heap = []"]
|
||||
I --> F{"more i, node in enumerate(lists)?"}
|
||||
F -- yes --> C{"node?"}
|
||||
C -- yes --> P["heapq.heappush(heap, (node.val, i, node)) — i breaks ties so nodes are never compared"]
|
||||
P --> F
|
||||
C -- no --> F
|
||||
F -- no --> D["D = ListNode(), cur = D"]
|
||||
D --> W{"heap?"}
|
||||
W -- no --> E(["return D.next"])
|
||||
W -- yes --> O["val, i, node = heapq.heappop(heap) — the smallest head across all lists"]
|
||||
O --> L["cur.next = node, cur = node, node = node.next"]
|
||||
L --> N{"node?"}
|
||||
N -- yes --> Q["heapq.heappush(heap, (node.val, i, node))"]
|
||||
Q --> W
|
||||
N -- no --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
@@ -67,3 +88,7 @@ class Solution:
|
||||
# Space: O(n)
|
||||
return D.next
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="q5a5OiGbT6Q" title="Merge K Sorted Lists - Leetcode 23 - Python" />
|
||||
+22
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Heap / Priority Queue</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `points = [[1,3],[-2,2]], k = 1`
|
||||
@@ -23,6 +23,23 @@ sidebar:
|
||||
- `1 <= k <= points.length <= 10^4`
|
||||
- `-10^4 <= xi, yi <= 10^4`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["kClosest(points, k)"]) --> I["minHeap = []"]
|
||||
I --> F{"more x, y in points?"}
|
||||
F -- yes --> D["dist = x**2 + y**2 — squared distance orders the same as sqrt"]
|
||||
D --> A["minHeap.append([dist, x, y])"]
|
||||
A --> F
|
||||
F -- no --> H["heapq.heapify(minHeap), res = [] — O(n) build, smallest dist on top"]
|
||||
H --> W{"k > 0?"}
|
||||
W -- yes --> P["dist, x, y = heapq.heappop(minHeap)"]
|
||||
P --> R["res.append([x, y]), k -= 1"]
|
||||
R --> W
|
||||
W -- no --> E(["return res"])
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
@@ -46,3 +63,7 @@ class Solution:
|
||||
|
||||
return res
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="rI2EBUEMfTk" title="K Closest Points to Origin - Heap / Priority Queue - Leetcode 973 - Python" />
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Heap / Priority Queue", order: 14 });
|
||||
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Hybrid Structures</Badge>
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: ``
|
||||
+19
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Hybrid Structures</Badge>
|
||||
<Badge variant="accent">Linked List</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `l1 = [2,4,3], l2 = [5,6,4]`
|
||||
@@ -27,6 +27,20 @@ sidebar:
|
||||
- `0 <= Node.val <= 9`
|
||||
- It is guaranteed that the list represents a number that does not have leading zeros.
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["addTwoNumbers(l1, l2)"]) --> I["dummy = ListNode(), carry = 0, curr = dummy — dummy.next will be the head"]
|
||||
I --> W{"l1 or l2 or carry?"}
|
||||
W -- no --> E(["return dummy.next"])
|
||||
W -- yes --> A["s = (l1.val if l1 else 0) + (l2.val if l2 else 0) + carry — a missing digit counts as 0"]
|
||||
A --> D["carry, val = divmod(s, 10)"]
|
||||
D --> N["curr.next = ListNode(val), curr = curr.next"]
|
||||
N --> V["l1 = l1.next if l1 else None, l2 = l2.next if l2 else None"]
|
||||
V --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
@@ -55,3 +69,7 @@ class Solution:
|
||||
|
||||
return dummy.next
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="wgFPrzTjm7s" title="Add Two Numbers - Leetcode 2 - Python" />
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Hybrid Structures", order: 9 });
|
||||
@@ -3,7 +3,7 @@ title: Overview
|
||||
description: Live SRS campaign dashboard — phases, ladder, review queue, gates, and recent attempts, fetched from the SRS Worker.
|
||||
---
|
||||
|
||||

|
||||
<DependencySpine />
|
||||
|
||||
Live view of the 56-day campaign. Everything below is fetched client-side
|
||||
from the SRS Worker, so it is always current — no rebuild required.
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Linked List</Badge>
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
|
||||
::::warning
|
||||
Can you solve it using O(1) (i.e. constant) memory?
|
||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Linked List", order: 8 });
|
||||
+21
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Matrix Index Math</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `matrix = [[1,2,3],[4,5,6],[7,8,9]]`
|
||||
@@ -22,6 +22,22 @@ sidebar:
|
||||
- `1 <= n <= 20`
|
||||
- `-1000 <= matrix[i][j] <= 1000`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["rotate(matrix)"]) --> N["n = len(matrix)"]
|
||||
N --> F{"more r in range(n)?"}
|
||||
F -- yes --> G{"more c in range(r + 1, n)?"}
|
||||
G -- yes --> T["swap matrix[r][c] and matrix[c][r] — transpose across the diagonal"]
|
||||
T --> G
|
||||
G -- no --> F
|
||||
F -- no --> H{"more r in range(n)?"}
|
||||
H -- yes --> R["matrix[r].reverse() — transpose then mirror is a clockwise turn"]
|
||||
R --> H
|
||||
H -- no --> E(["return None — rotated in place"])
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
@@ -35,3 +51,7 @@ class Solution:
|
||||
for r in range(n):
|
||||
matrix[r].reverse()
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="fMSJSS7eO1w" title="Rotate Image - Matrix - Leetcode 48" />
|
||||
+23
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Matrix Index Math</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `matrix = [[1,2,3],[4,5,6],[7,8,9]]`
|
||||
@@ -23,6 +23,24 @@ sidebar:
|
||||
- `1 <= m, n <= 10`
|
||||
- `-100 <= matrix[i][j] <= 100`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["spiralOrder(matrix)"]) --> I["res = [], top = 0, bottom = len(matrix) - 1, left = 0, right = len(matrix[0]) - 1"]
|
||||
I --> W{"top <= bottom and left <= right?"}
|
||||
W -- no --> E(["return res"])
|
||||
W -- yes --> T["append matrix[top][c] for c in left..right, then top += 1 — top row, shrink from above"]
|
||||
T --> R["append matrix[r][right] for r in top..bottom, then right -= 1 — right column"]
|
||||
R --> B{"top <= bottom?"}
|
||||
B -- yes --> C["append matrix[bottom][c] for c in right down to left, then bottom -= 1 — bottom row"]
|
||||
C --> L{"left <= right?"}
|
||||
B -- no --> L
|
||||
L -- yes --> D["append matrix[r][left] for r in bottom down to top, then left += 1 — left column"]
|
||||
D --> W
|
||||
L -- no --> W
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
@@ -57,3 +75,7 @@ class Solution:
|
||||
|
||||
return res
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="BJnMZNwUk1M" title="Spiral Matrix - Microsoft Interview Question - Leetcode 54" />
|
||||
+34
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Matrix Index Math</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
::::warning
|
||||
You must do it in place.
|
||||
@@ -30,6 +30,35 @@ You must do it in place.
|
||||
- A simple improvement uses O(m + n) space, but still not the best solution.
|
||||
- Could you devise a constant space solution?
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["setZeroes(matrix)"]) --> I["m = len(matrix), n = len(matrix[0])"]
|
||||
I --> Z["row0_zero = any(matrix[0][c] == 0), col0_zero = any(matrix[r][0] == 0) — remember before they become markers"]
|
||||
Z --> F{"more r in range(1, m)?"}
|
||||
F -- yes --> G{"more c in range(1, n)?"}
|
||||
G -- yes --> Q{"matrix[r][c] == 0?"}
|
||||
Q -- yes --> M["matrix[r][0] = 0, matrix[0][c] = 0 — row 0 and column 0 are the marker lists"]
|
||||
M --> G
|
||||
Q -- no --> G
|
||||
G -- no --> F
|
||||
F -- no --> H{"more r in range(1, m)?"}
|
||||
H -- yes --> J{"more c in range(1, n)?"}
|
||||
J -- yes --> K{"matrix[r][0] == 0 or matrix[0][c] == 0?"}
|
||||
K -- yes --> W["matrix[r][c] = 0"]
|
||||
W --> J
|
||||
K -- no --> J
|
||||
J -- no --> H
|
||||
H -- no --> A{"row0_zero?"}
|
||||
A -- yes --> B["matrix[0][c] = 0 for c in range(n)"]
|
||||
B --> C{"col0_zero?"}
|
||||
A -- no --> C
|
||||
C -- yes --> D["matrix[r][0] = 0 for r in range(m)"]
|
||||
D --> E(["return None — zeroed in place"])
|
||||
C -- no --> E
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
@@ -65,3 +94,7 @@ class Solution:
|
||||
for r in range(m):
|
||||
matrix[r][0] = 0
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="T41rL0L3Pnw" title="Set Matrix Zeroes - In-place - Leetcode 73" />
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Matrix Index Math", order: 10 });
|
||||
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Monotonic Stack</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `temperatures = [73,74,75,71,69,72,76,73]`
|
||||
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Monotonic Stack</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `target = 12, position = [10,8,0,5,3], speed = [2,4,1,1,3]`
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Monotonic Stack", order: 7 });
|
||||
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Prefix Sum</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
::::warning
|
||||
You must write an algorithm that runs in O(n) time and without using the division operation.
|
||||
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Prefix Sum</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `["NumArray", "sumRange", "sumRange", "sumRange"]` `[[[-2, 0, 3, -5, 2, -1]], [0, 2], [2, 5], [0, 5]]`
|
||||
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Prefix Sum</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `nums = [1,1,1], k = 2`
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Prefix Sum", order: 2 });
|
||||
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Sliding Window</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `prices = [7,1,5,3,6,4]`
|
||||
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Sliding Window</Badge>
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `s = "abcabcbb"`
|
||||
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Sliding Window</Badge>
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `s = "ABAB", k = 2`
|
||||
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Sliding Window</Badge>
|
||||
<Badge variant="accent">Hash Table</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `s1 = "ab", s2 = "eidbaooo"`
|
||||
+1
@@ -1,6 +1,7 @@
|
||||
---
|
||||
title: Sliding Window
|
||||
description: 'Sliding window is a specialized two-pointer family where the pointers define a contiguous interval and you maintain information about that entire interval as it moves.'
|
||||
hidden: true
|
||||
---
|
||||
|
||||
:::warning
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Sliding Window", order: 4 });
|
||||
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Stack</Badge>
|
||||
<Badge variant="accent">Array</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `tokens = ["2","1","+","3","*"]`
|
||||
File renamed without changes.
+1
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Easy'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Stack</Badge>
|
||||
<Badge variant="accent">String</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `s = "()"`
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Stack", order: 6 });
|
||||
@@ -0,0 +1,56 @@
|
||||
---
|
||||
title: '207. Course Schedule'
|
||||
description: There are a total of numCourses courses you have to take, labeled from 0 to numCourses - 1. You are given an array prerequisites where prerequisites[i] = [ai, bi] indicates that you must take course bi first if you want to take course ai
|
||||
sidebar:
|
||||
label: 'Course Schedule'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Depth-First Search</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `numCourses = 2, prerequisites = [[1,0]]`
|
||||
- Output: `true`
|
||||
- Explanation: There are a total of `2` courses to take. To take course `1` you should have finished course `0`. So it is possible.
|
||||
|
||||
### Example 2:
|
||||
- Input: `numCourses = 2, prerequisites = [[1,0],[0,1]]`
|
||||
- Output: `false`
|
||||
- Explanation: There are a total of `2` courses to take. To take course `1` you should have finished course `0`, and to take course `0` you should also have finished course `1`. So it is impossible.
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= numCourses <= 2000`
|
||||
- `0 <= prerequisites.length <= 5000`
|
||||
- `prerequisites[i].length == 2`
|
||||
- `0 <= ai, bi < numCourses`
|
||||
- All the pairs `prerequisites[i]` are unique.
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def canFinish(self, numCourses: int, prerequisites: list[list[int]]) -> bool:
|
||||
indegree = [0] * numCourses
|
||||
adj = [[] for x in range(numCourses)]
|
||||
for prereq in prerequisites:
|
||||
adj[prereq[1]].append(prereq[0])
|
||||
indegree[prereq[0]] += 1
|
||||
|
||||
queue = []
|
||||
|
||||
for i in range(numCourses):
|
||||
if indegree[i] == 0:
|
||||
queue.append(i)
|
||||
|
||||
visited = 0
|
||||
while queue:
|
||||
node = queue.pop(0)
|
||||
visited += 1
|
||||
for neighbor in adj[node]:
|
||||
indegree[neighbor] -= 1
|
||||
if indegree[neighbor] == 0:
|
||||
queue.append(neighbor)
|
||||
|
||||
return numCourses == visited
|
||||
```
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: '210. Course Schedule II'
|
||||
description: There are a total of numCourses courses you have to take, labeled from 0 to numCourses - 1. You are given an array prerequisites where prerequisites[i] = [ai, bi] indicates that you must take course bi first if you want to take course ai
|
||||
sidebar:
|
||||
label: 'Course Schedule II'
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Depth-First Search</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `numCourses = 2, prerequisites = [[1,0]]`
|
||||
- Output: `[0,1]`
|
||||
- Explanation: There are a total of `2` courses to take. To take course `1` you should have finished course `0`. So the correct course order is `[0,1]`.
|
||||
|
||||
### Example 2:
|
||||
- Input: `numCourses = 4, prerequisites = [[1,0],[2,0],[3,1],[3,2]]`
|
||||
- Output: `[0,2,1,3]`
|
||||
- Explanation: There are a total of `4` courses to take. To take course `3` you should have finished both courses `1` and `2`. Both courses `1` and `2` should be taken after you finished course `0`. So one correct course order is `[0,1,2,3]`. Another correct ordering is `[0,2,1,3]`.
|
||||
|
||||
### Example 3:
|
||||
- Input: `numCourses = 1, prerequisites = []`
|
||||
- Output: `[0]`
|
||||
|
||||
### Constraints:
|
||||
|
||||
- `1 <= numCourses <= 2000`
|
||||
- `0 <= prerequisites.length <= numCourses * (numCourses - 1)`
|
||||
- `prerequisites[i].length == 2`
|
||||
- `0 <= ai, bi < numCourses`
|
||||
- `ai != bi`
|
||||
- All the pairs [ai, bi] are distinct.
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
class Solution:
|
||||
def findOrder(self, numCourses: int, prerequisites: list[list[int]]) -> list[int]:
|
||||
prereq = {c: [] for c in range(numCourses)}
|
||||
for crs, pre in prerequisites:
|
||||
prereq[crs].append(pre)
|
||||
|
||||
# course has 3 possible states:
|
||||
# visited -> crs has been added to output
|
||||
# visiting -> crs not added to output, but added to cycle
|
||||
# unvisited -> crs not added to output nor cycle
|
||||
output = []
|
||||
visit, cycle = set(), set()
|
||||
|
||||
def dfs(crs):
|
||||
if crs in cycle:
|
||||
return False
|
||||
if crs in visit:
|
||||
return True
|
||||
|
||||
cycle.add(crs)
|
||||
for pre in prereq[crs]:
|
||||
if dfs(pre) == False:
|
||||
return False
|
||||
|
||||
cycle.remove(crs)
|
||||
visit.add(crs)
|
||||
output.append(crs)
|
||||
return True
|
||||
|
||||
for c in range(numCourses):
|
||||
if dfs(c) == False:
|
||||
return []
|
||||
|
||||
return output
|
||||
```
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
title: Topological Sort
|
||||
description: 'Topological sort or topological ordering of a directed graph is an ordering of nodes such that every node appears in the ordering before all the nodes it points to. '
|
||||
hidden: true
|
||||
---
|
||||
|
||||
## Directed Graph
|
||||
|
||||
Before we get started on topological order, let's talk about directed graphs first. A graph is directed when its edges have directions (these edges are also called arcs). Suppose that v, w are vertices in a directed graph. Let's get familiar with a few concepts.
|
||||
|
||||
- **in-edge** - an edge pointing to or coming into the node
|
||||
- **out-edge** - an edge pointing away or going out from the node (we will refer those nodes on the other end of the out-edges of v to v's neighbors)
|
||||
- **in-degree** - the number of in-edges coming into the node
|
||||
- **out-degree** - the number of out-edges going out from the node
|
||||
- **dependency** - "v depends on w" / "v is dependent on w" means that there is a directed path from w to v where v, w are vertices in a directed graph. With these new terms under our belt, it will make more sense when we talk about directed graphs.
|
||||
|
||||
## What is a topological order?
|
||||
|
||||
Topological sort is not unique. For example, for a graph like this:
|
||||
|
||||

|
||||
|
||||
Both `[4, 5, 2]` and `[5, 4, 2]` are valid topological orders.
|
||||
|
||||

|
||||
|
||||
Task 3 is completely independent of task 2 and 4, and it can be anywhere in the order as long as it is before task 1 which depends on it. All of the following ordering are valid topological orderings.
|
||||
|
||||
`[4, 2, 3, 1], [4, 3, 2, 1], [3, 4, 2, 1]`
|
||||
|
||||
|
||||
:::warning[Graphs with Cycles Do Not Have Topological Ordering]
|
||||
It should be obvious that if a graph with a cycle does not have a topological ordering. In the above example, 2 has to come before 5 which has come before 4 which has to come before 2 which has to come before 5... which is impossible.
|
||||
:::
|
||||
|
||||
## Kahn's Algorithm
|
||||
|
||||
To obtain a topological order, we can use Kahn's algorithm which is very similar to Breadth First Search.
|
||||
|
||||
In order to understand why we must use this algorithm we first look at the problem it is trying to solve.
|
||||
|
||||
Given a directed graph does there exist a way to remove the nodes such that each time we remove a node we guarantee that no other nodes point to that particular node?
|
||||
|
||||
For this algorithm, we systematically remove one node at a time, each time removing a node such that no other nodes point to that node (in-degree is 0). If no such node exists, then there must be a cycle, and there is no way to order the nodes such that "every node appears in the ordering before all the nodes it points to" (no solution). After removing the current node, for each neighboring node the current node points to, we check whether any nodes point to this node. If there isn't any, we push this node into the queue. Notice it is important to keep track of the number of nodes pointing to a node (in-degree) in the question as we only push the node into the queue once all nodes the current node depended on have been removed.
|
||||
|
||||
Here is a graphic to demonstrate the idea.
|
||||
|
||||
<Carousel>
|
||||
<CarouselImage src='../../public/topo_kahn.001.png' />
|
||||
<CarouselImage src='../../public/topo_kahn.002.png' />
|
||||
<CarouselImage src='../../public/topo_kahn.003.png' />
|
||||
<CarouselImage src='../../public/topo_kahn.004.png' />
|
||||
<CarouselImage src='../../public/topo_kahn.005.png' />
|
||||
<CarouselImage src='../../public/topo_kahn.006.png' />
|
||||
<CarouselImage src='../../public/topo_kahn.007.png' />
|
||||
<CarouselImage src='../../public/topo_kahn.009.png' />
|
||||
<CarouselImage src='../../public/topo_kahn.010.png' />
|
||||
</Carousel>
|
||||
|
||||
## Example Code
|
||||
|
||||
:::tip
|
||||
Notice the topological sort algorithm is very similar to BFS. The main difference is that we only push nodes with 0 in-degree into the queue in topological sort whereas in BFS we push all the neighboring nodes into the queue.
|
||||
:::
|
||||
|
||||
```py
|
||||
from collections import deque
|
||||
|
||||
def find_indegree(graph):
|
||||
indegree = { node: 0 for node in graph } # dict
|
||||
for node in graph:
|
||||
for neighbor in graph[node]:
|
||||
indegree[neighbor] += 1
|
||||
return indegree
|
||||
|
||||
|
||||
def topo_sort(graph):
|
||||
res = []
|
||||
q = deque()
|
||||
indegree = find_indegree(graph)
|
||||
for node in indegree:
|
||||
if indegree[node] == 0:
|
||||
q.append(node)
|
||||
while len(q) > 0:
|
||||
node = q.popleft()
|
||||
res.append(node)
|
||||
for neighbor in graph[node]:
|
||||
indegree[neighbor] -= 1
|
||||
if indegree[neighbor] == 0:
|
||||
q.append(neighbor)
|
||||
return res if len(graph) == len(res) else None
|
||||
```
|
||||
@@ -0,0 +1,3 @@
|
||||
import { defineMeta } from "blume";
|
||||
|
||||
export default defineMeta({ title: "Topological Sort", order: 18 });
|
||||
+25
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Tree BFS</Badge>
|
||||
<Badge variant="accent">Tree</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `root = [3,9,20,null,null,15,7]`
|
||||
@@ -25,6 +25,26 @@ sidebar:
|
||||
- The number of nodes in the tree is in the range [0, 2000].
|
||||
- `-1000 <= Node.val <= 1000`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["levelOrder(root)"]) --> I["res = [], q = deque(), q.append(root)"]
|
||||
I --> W{"q non-empty?"}
|
||||
W -- no --> E(["return res"])
|
||||
W -- yes --> L["qLen = len(q), level = [] — qLen freezes the current level's size"]
|
||||
L --> F{"more i in range(qLen)?"}
|
||||
F -- no --> V{"level non-empty?"}
|
||||
V -- yes --> A["res.append(level)"]
|
||||
A --> W
|
||||
V -- no --> W
|
||||
F -- yes --> P["node = q.popleft()"]
|
||||
P --> N{"node is not None?"}
|
||||
N -- no --> F
|
||||
N -- yes --> C["level.append(node.val), q.append(node.left), q.append(node.right) — None children are filtered on the next level"]
|
||||
C --> F
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
@@ -59,3 +79,7 @@ class Solution:
|
||||
|
||||
return res
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="6ZnyEApgFYg" title="Binary Tree Level Order Traversal - BFS - Leetcode 102" />
|
||||
+21
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Tree BFS</Badge>
|
||||
<Badge variant="accent">Tree</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `root = [3,1,4,3,null,1,5]`
|
||||
@@ -28,6 +28,22 @@ sidebar:
|
||||
- The number of nodes in the binary tree is in the range [1, 10^5].
|
||||
- Each node's value is between [-10^4, 10^4].
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["goodNodes(root)"]) --> C["dfs(root, root.val) — the root is always good"]
|
||||
C --> E(["return that count"])
|
||||
subgraph dfs["dfs(node, max_val)"]
|
||||
D1{"node is None?"} -- yes --> D2(["return 0"])
|
||||
D1 -- no --> D3["is_good = 1 if node.val >= max_val else 0 — max_val is the largest value on the path so far"]
|
||||
D3 --> D4["new_max = max(max_val, node.val)"]
|
||||
D4 --> D5["l = dfs(node.left, new_max), r = dfs(node.right, new_max)"]
|
||||
D5 --> D6(["return is_good + l + r"])
|
||||
end
|
||||
C --> dfs
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
@@ -50,3 +66,7 @@ class Solution:
|
||||
|
||||
return dfs(root, root.val)
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="7cp5imvDzl4" title="Microsoft's Most Asked Question 2021 - Count Good Nodes in a Binary Tree - Leetcode 1448 - Python" />
|
||||
+25
-1
@@ -6,7 +6,7 @@ sidebar:
|
||||
badge: 'Medium'
|
||||
---
|
||||
|
||||
<Badge variant="accent">Tree BFS</Badge>
|
||||
<Badge variant="accent">Tree</Badge>
|
||||
|
||||
### Example 1:
|
||||
- Input: `root = [1,2,3,null,5,null,4]`
|
||||
@@ -29,6 +29,26 @@ sidebar:
|
||||
- The number of nodes in the tree is in the range [0, 100].
|
||||
- `-100 <= Node.val <= 100`
|
||||
|
||||
## Approach
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
S(["rightSideView(root)"]) --> I["res = [], q = deque([root])"]
|
||||
I --> W{"q non-empty?"}
|
||||
W -- no --> E(["return res"])
|
||||
W -- yes --> L["rightSide = None, qLen = len(q) — qLen freezes the current level's size"]
|
||||
L --> F{"more i in range(qLen)?"}
|
||||
F -- no --> V{"rightSide is not None?"}
|
||||
V -- yes --> A["res.append(rightSide.val) — the last real node popped on this level is the rightmost"]
|
||||
A --> W
|
||||
V -- no --> W
|
||||
F -- yes --> P["node = q.popleft()"]
|
||||
P --> N{"node is not None?"}
|
||||
N -- no --> F
|
||||
N -- yes --> C["rightSide = node, q.append(node.left), q.append(node.right)"]
|
||||
C --> F
|
||||
```
|
||||
|
||||
## Solution
|
||||
|
||||
```py
|
||||
@@ -62,3 +82,7 @@ class Solution:
|
||||
|
||||
return res
|
||||
```
|
||||
|
||||
## Explanation
|
||||
|
||||
<YouTube id="d4zLyf32e3I" title="Binary Tree Right Side View - Breadth First Search - Leetcode 199" />
|
||||
Loaded 100 of 151 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user