Compare commits

...
Author SHA1 Message Date
Prad Nukala ea44a99c40 feat(work): add solution for LeetCode 36 Valid Sudoku 2026-10-13 15:34:15 -04:00
Prad Nukala 00fa322a14 feat(work): add solution for LeetCode 19 Remove Nth Node From End of List 2026-10-13 15:14:50 -04:00
Prad Nukala bf56e170a4 chore: remove sd 2026-10-13 14:29:10 -04:00
Prad Nukala 4cf976f683 feat(work): add solution for LeetCode 876 Middle of the Linked List 2026-10-13 14:25:36 -04:00
Prad Nukala ace1944786 docs: clarify /progress page header tab and fix apps/docs table formatting 2026-10-13 09:16:41 -04:00
Prad Nukala 13734e2e27 docs: add /progress page with live stats cards and update navigation and component imports 2026-10-13 09:16:30 -04:00
Prad Nukala cb4a03b6fa feat: integrate system design 2026-10-07 14:18:14 -04:00
Prad Nukala e14432d32a docs(docs): improve system design guide and simplify custom page tabs 2026-10-07 14:17:38 -04:00
Prad Nukala 8843cb776c docs(AGENTS): fix apps/docs table row formatting 2026-10-07 13:45:26 -04:00
Prad Nukala 0e58b63d4d docs(docs): add header tabs for Changelog and Appendix pages 2026-10-07 13:45:21 -04:00
Prad Nukala b5c3f1a159 docs(readme): fix formatting of apps/docs entry in AGENTS.md 2026-10-07 13:00:01 -04:00
Prad Nukala 37d3d9ceaf feat(work): add Minimum Window Substring & Basic Calculator solutions and implement StockSpanner algorithm 2026-10-07 12:59:02 -04:00
Prad Nukala 5627d0ca64 docs(docs): add new problem documentation pages and source selector component 2026-10-07 12:58:59 -04:00
Prad Nukala 598688977a feat(work): add 901 online stock span solution and implement removeDuplicates algorithm 2026-10-06 13:47:23 -04:00
Prad Nukala a3e846c606 docs(docs): add documentation for Next Greater Element I and Remove Duplicates from Sorted Array II, update redirects 2026-10-06 13:47:20 -04:00
Prad Nukala 52d9654ade feat(array): add solution for problem 80 remove duplicates from sorted array ii 2026-10-06 13:16:50 -04:00
Prad Nukala f202c3a0a1 docs(docs): add two‑pointer guide with decision tree and illustrations 2026-10-06 13:16:45 -04:00
Prad Nukala 030887c7df docs(docs): add detailed stack overview with operation examples and SVG illustrations 2026-10-06 12:58:52 -04:00
Prad Nukala 733c71d364 feat(work): add solution for Next Greater Element I 2026-10-06 12:53:56 -04:00
Prad Nukala 115b901fbe docs(docs): add YouTube video and detailed monotonic stack explanation with Python example 2026-10-06 12:53:26 -04:00
Prad Nukala ceaa9a2e75 feat(work): implement binary search solution for median of two sorted arrays 2026-10-05 15:47:05 -04:00
Prad Nukala 7c7019ec0c docs: restructure problem paths, change section display to groups, and improve documentation content 2026-10-05 15:47:02 -04:00
Prad Nukala cd5f416939 style(cli): reformat sync script for readability and consistency 2026-10-05 15:46:55 -04:00
Prad Nukala 6247915ae5 feat: refactored docs structure 2026-10-05 14:22:28 -04:00
Prad Nukala 31a1fcfdbd docs(docs): add MDX pages for Median of Two Sorted Arrays and Largest Rectangle in Histogram and update site redirects 2026-10-05 14:01:30 -04:00
Prad Nukala 66affbe128 feat(work): implement largestRectangleArea solution and fix formatting 2026-10-05 14:01:18 -04:00
Prad Nukala 9de25fecff feat: refactored docs structure 2026-10-05 13:53:14 -04:00
Prad Nukala d388b3000e feat(work): add new LeetCode solutions for median, largest rectangle, and simplify path 2026-10-05 13:51:47 -04:00
Prad Nukala f14dcf2723 docs(skills): update paths to docs directory in problem-approach skill 2026-10-05 13:51:38 -04:00
Prad Nukala d171fe1565 chore(config): remove obsolete docs scripts and .gitignore entries 2026-10-05 13:51:17 -04:00
Prad Nukala e9d40020f4 refactor(cli): generate docs/site.json manifest and update docs site references 2026-10-05 13:51:14 -04:00
Prad Nukala 0b004fcba2 ci(deploy): remove Pages build steps and limit permissions to worker deployment 2026-10-05 13:51:10 -04:00
Prad Nukala 33022c806d docs(docs): update problem page paths to use (problems)/<num>_<slug>.mdx format and adjust related examples 2026-10-05 13:12:48 -04:00
Prad Nukala d59b9dccd9 feat(work): add Find Peak Element solution (162) in Python 2026-10-05 13:08:59 -04:00
Prad Nukala 17c691c481 docs(redirects): add new redirects for problems 128, 162, and 643 2026-10-05 13:08:51 -04:00
Prad Nukala ea62cda955 feat(content): add new problem pages, reorganize docs into (problems) subfolders, and update meta display to page 2026-10-05 13:07:56 -04:00
Prad Nukala 74939c31d7 refactor(cli): adjust sync for (problems) group and page display meta 2026-10-05 13:07:52 -04:00
Prad Nukala 0d5c40ac4a style(docs): remove leading hyphens from AGENTS.md table rows 2026-10-05 12:34:59 -04:00
Prad Nukala 4a1838ab28 feat(work): add sliding window solution for maximum average subarray and implement longest consecutive sequence 2026-10-05 12:34:54 -04:00
Prad Nukala c2a375fffc feat(docs): add Appendix glossary and page for undocumented tags 2026-10-05 12:34:34 -04:00
Prad Nukala 0e9e06eb8d docs(content): revamp sliding-window examples and purge empty appendix pages 2026-10-05 12:34:30 -04:00
Prad Nukala 937266b629 feat(config): add appendix redirects and navigation entries, remove list schema, update comments 2026-10-05 12:34:27 -04:00
Prad Nukala 88c0c9b898 refactor(components): add appendix tag icons and drop list badge 2026-10-05 12:34:23 -04:00
Prad Nukala 099123539e refactor(cli): drop guide page creation and streamline appendix term handling in sync script 2026-10-05 12:34:17 -04:00
Prad Nukala 8c39b3ac23 feat(work): add solution for LeetCode 643 Maximum Average Subarray I 2026-10-05 11:24:01 -04:00
Prad Nukala 7ead021ee2 docs(docs): fix cursor handling and hover tip refactor in docs UI components 2026-10-05 11:23:57 -04:00
Prad Nukala 0d066b8104 chore(config): bump dependencies to latest versions 2026-10-05 11:23:52 -04:00
Prad Nukala 9af5ab1fbb docs(content): add YouTube embed to sliding window page 2026-10-05 11:01:17 -04:00
Prad Nukala f770408ea2 feat(work): add solution for LeetCode 1004 Max Consecutive Ones III 2026-10-02 17:19:55 -04:00
Prad Nukala cf6f09825a docs(docs): add redirect for LeetCode 1004 Max Consecutive Ones III page 2026-10-02 17:19:51 -04:00
Prad Nukala ab09e40a2e docs(content): add Max Consecutive Ones III page and update Array badge count 2026-10-02 17:19:34 -04:00
Prad Nukala 91dad73ebf feat(work): add solution for LeetCode 189 Rotate Array 2026-10-02 16:01:45 -04:00
Prad Nukala 400bd5abf5 docs(docs): add redirect for LeetCode 189 Rotate Array 2026-10-02 16:01:42 -04:00
Prad Nukala a258b46cf0 docs(content): add Rotate Array page and bump array/math badges 2026-10-02 16:01:39 -04:00
Prad Nukala 4875fd5139 feat(work): add solution for LeetCode 525 Contiguous Array 2026-10-02 13:03:24 -04:00
Prad Nukala 7b14673e95 docs(redirects): add redirect for Contiguous Array problem 2026-10-02 13:03:21 -04:00
Prad Nukala 1c45e03ddd docs(content): add Contiguous Array page and update array badge count 2026-10-02 13:03:18 -04:00
Prad Nukala cbc858b667 feat(work): add 290.word-pattern solution 2026-10-02 12:42:01 -04:00
Prad Nukala c890fd35a7 docs(redirects): add redirect for Word Pattern problem 2026-10-02 12:41:58 -04:00
Prad Nukala 3e62d6fa6f docs(content): add 290 Word Pattern page and fix String badge count 2026-10-02 12:41:52 -04:00
Prad Nukala 017f111594 docs(content): improve hash table page formatting and headings 2026-10-01 16:51:07 -04:00
192 changed files with 3981 additions and 843 deletions

No files matched your search

+3 -3
View File
@@ -5,7 +5,7 @@ description: Write the `## Approach` section — a Mermaid flowchart that traces
# Problem approach diagrams
Every solution page (`apps/docs/content/(<phase>)/<topic>/<num>-<slug>.mdx`) has a
Every solution page (`apps/docs/content/(<phase>)/<topic>/(problems)/<num>_<slug>.mdx`) has a
hand-owned section before the script-owned one:
```
@@ -100,7 +100,7 @@ flowchart TD
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
edges — see `hybrid-structures/(problems)/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
@@ -122,7 +122,7 @@ flowchart TD
### 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`):
callout **before** the fence naming exactly why it fails (`sliding-window/(problems)/121_best-time-to-buy-and-sell-stock.mdx`):
````md
## Approach
+4 -4
View File
@@ -38,7 +38,7 @@ flowchart LR
- **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.
- **One "finished topic" rule, two consumers.** `apps/cli/topics.ts` (`coreSet()`: every `set:core` sub-issue closed, zero core = never) is read by `close-topics.ts` (closes the topic issue) and `release-topics.ts` (the `release` job of `close-solved.yml`). The release reads the sub-issues, not the topic issue, so the push that finishes a topic releases it in the same run instead of waiting for Close Topics to chain after it. Version = [timever](https://gist.github.com/twolfson/de1b004dd22536b8e668) (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the instant the last core problem closed; the tag is annotated and backdated to that instant, on the last first-parent main commit before it; the body is a changelog snapshot at that instant (a one-sentence summary first, then the problems closed on the finish day / earlier, with solution links at the tag — problems still open at the finish are left out). A release is found by its `<!-- release-topics: #N -->` body marker, never by tag name, so re-closing a core problem never mints a second one; an existing release keeps its tag and has its name (the topic title) and body rewritten whenever the render changes. The docs `/changelog` is ONE custom page, `apps/docs/pages/changelog.astro` (a NeonUI-style timeline: date left, a card per release right, no per-release subpages, so nothing of it enters the sidebar or prev/next), linked from the header by `navigation.actions` (not a tab). `apps/docs/lib/releases.ts` fetches the releases at build (`GITHUB_TOKEN` in `deploy.yml`; a failed fetch fails the build), dates each by its timever tag, not `published_at` (which the API cannot backdate), tags it `Topic` or `Review` from the release name, and lists the rows of its `### Closed …` tables — so the tag format and that table shape are the page's contract with `render()`. There is no changelog RSS feed.
- **One "finished topic" rule, two consumers.** `apps/cli/topics.ts` (`coreSet()`: every `set:core` sub-issue closed, zero core = never) is read by `close-topics.ts` (closes the topic issue) and `release-topics.ts` (the `release` job of `close-solved.yml`). The release reads the sub-issues, not the topic issue, so the push that finishes a topic releases it in the same run instead of waiting for Close Topics to chain after it. Version = [timever](https://gist.github.com/twolfson/de1b004dd22536b8e668) (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the instant the last core problem closed; the tag is annotated and backdated to that instant, on the last first-parent main commit before it; the body is a changelog snapshot at that instant (a one-sentence summary first, then the problems closed on the finish day / earlier, with solution links at the tag — problems still open at the finish are left out). A release is found by its `<!-- release-topics: #N -->` body marker, never by tag name, so re-closing a core problem never mints a second one; an existing release keeps its tag and has its name (the topic title) and body rewritten whenever the render changes. The docs `/changelog` is ONE custom page, `apps/docs/pages/changelog.astro` (a NeonUI-style timeline: date left, a card per release right, no per-release subpages, so nothing of it enters the sidebar or prev/next), linked from the header by a `navigation.tabs` entry (beside `/appendix`'s). `apps/docs/lib/releases.ts` fetches the releases at build (`GITHUB_TOKEN` in `deploy.yml`; a failed fetch fails the build), dates each by its timever tag, not `published_at` (which the API cannot backdate), tags it `Topic` or `Review` from the release name, and lists the rows of its `### Closed …` tables — so the tag format and that table shape are the page's contract with `render()`. There is no changelog RSS feed.
- **Review issues re-parent problems; topics still own them.** The transfer phase's 20 `Review <N> — <Phase>` issues (label `review`, milestone `Review — Transfer & Mocks`; not the Worker's `Review — Week N` gate issues) hold open optional/deferred problems as sub-issues in solve order, moved there with `replace_parent`. `apps/api/src/reviews.ts` is the ONE recovery of ownership — `readReviews()` (one GraphQL request per 50 `review` issues, cheap enough for the Worker's cron subrequest budget) reads each moved problem's topic off its last `parent_issue_removed` event naming a topic, `movedFromTopics()` groups them — shared by the Worker's catalog reconcile (a moved problem's D1 row keeps syncing its labels and `topic_issue`) and `apps/cli/topics.ts` (`readProblems()` returns a topic's sub-issues plus its moved problems, so the finished rule and existing release bodies do not change when a problem moves). A Review issue finishes by `completion()` in `topics.ts` — every sub-issue closed, any set — which `close-topics.ts` closes it on and `release-topics.ts` releases it on, exactly like a topic: timever of the last close, same `<!-- release-topics: #N -->` marker (issue numbers never collide), body fields from the review's `**Scheduled:**`/`**Phase:**`/`**Load:**` lines.
- **Review days are plan days.** `schedule.json` books each Review issue on its own date — Mon/Tue/Thu/Fri only, Oct 1 → Nov 3 — as `review: { issue, title, problems }` (solve order). The digest renders it as a linked "Today's review" section with the issue's day rules (`REVIEW_RULES` in `email.tsx`, one sentence for HTML and text), its problems join `RESERVED` so drills and sampled gates never draw them early, and `gate: []` skips a Saturday's checkpoint (Oct 3). The issue's sub-issues stay the truth: the catalog reconcile compares every booked review's title and problem list with GitHub and returns any mismatch as `reviewDrift` (also logged), so a re-arranged Review issue shows up the next sync instead of silently disagreeing with the email. Move a review by editing its `schedule.json` date AND the issue's `**Scheduled:**` line (the release body reads it).
@@ -48,7 +48,7 @@ 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/`, `components/` + `components.ts`, `islands/`, `public/`). `content/(0N-<phase>)/<topic>/<num>-<slug>.mdx` plus one `meta.ts` per phase and per topic folder, all generated by sync (a phase's once, then hand-owned): the phase folder is a `(group)` (sidebar section, no route segment), the topic 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` files carry the exact labels, phase/topic numbers and icons so the sidebar reads in curriculum order with each topic a collapsible group. A problem page's badge row (between the description and the body) is rendered, not written: the MDX `wrapper` (`components/ProblemBadges.astro`, registered as `mdx.wrapper` in `components.ts` — Blume's page template renders the title and description itself with no slot before the body, so the wrapper, which MDX hands no route, reads it off `Astro.url`) reads the page's `topics:` (LeetCode's topic tags, from sync) and `video:` (NeetCode YouTube id, from `bun run videos`) frontmatter — both declared via `frontmatter.extend` in `blume.config.ts`, hand-rolled Standard Schemas, no zod — and links a tag the campaign teaches to its topic landing page (filled), any other tag to its Appendix guide page `/appendix/<slug>` (outlined; LeetCode's problem list if the guide is gone, gray and iconless), and the video as a red `Explanation` badge. Every badge in the row is a pill. A tag badge carries the icon its target shows in the sidebar (read off `data.navigation`: the topic `meta.ts` icon, the guide's `icon:`) and the tag's own colour from `lib/tag-colors.ts` — unique per tag site-wide, assigned greedily over every tag with a page so tags that share problem pages sit furthest apart in OKLab, in a lightness band that keeps ≥ 3:1 contrast in both themes (Blume's Badge takes one hex for both). The reverse direction is the `PageFooter` slot (`components/BacklinksList.astro`): an Obsidian-style `<num> <name>` list under every topic landing and guide page of the problems whose badges link there, plus a topic's own folder members (so a topic no tag names, `hybrid-structures`, still lists its problems), in a native `<details>` open on load whose top-right chevron collapses it (no JS); problem pages are recognised by their `<num>. <name>` title and render no list. Which tags the campaign teaches is ONE map, `apps/docs/topic-landings.ts` (tag slug → landing routes), and where a badge points is ONE function beside it, `tagTarget()` — read by both components (so badges and backlinks cannot disagree) and the map by sync. `content/appendix/` (the sidebar's `Appendix` row, right under Progress: `order: 0` ahead of the phase groups, `display: "page"` so it drills into its own panel — no header tab) is one page per tag outside that map, filed by hand into `(group)` categories — `(1-data-structures)/`, `(2-algorithms)/`, `(3-techniques)/` (hand-written `meta.ts` each; no route segment, so every guide stays `/appendix/<slug>`). Sync finds a guide by slug anywhere under `appendix/` and creates a placeholder only when none exists or it is empty — at the `appendix/` root, for you to file into a category and give a Lucide `icon:` — the one every badge linking to it shows — that no topic and no other guide uses (sync warns while it is missing, a `TOPIC_ICONS` value, or another guide's) — and after that owns exactly one key of it: `sidebar.badge`, the sidebar count, rewritten every run to the guide's backlink count (problem pages carrying the tag — equal to the `BacklinksList` rows, since a guide's tag has no landing; dropped at zero). Everything else is hand-owned (a guide whose tag no page carries any more is left alone, badge dropped), with frontmatter `list:` — the tag's LeetCode problem list, rendered by the same wrapper as an orange `LeetCode list` badge; sync also writes `appendix/meta.ts`. A hand-written `(0N-<phase>)/<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; the five `/progress` cards `SolvedRing`/`SubmissionHeatmap`/`SubmissionChart`/`TopicBubbles`/`RecentSolves`, and `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/docs/` | Blume site (`blume.config.ts`, `content/`, `pages/`, `components/` + `components.ts`, `islands/`, `public/`). `content/(0N-<phase>)/<topic>/(problems)/<num>_<slug>.mdx` plus one `meta.ts` per phase and per topic folder, all generated by sync (a phase's once, then hand-owned): the phase folder is a `(group)` (sidebar section, no route segment), the topic folder is the README campaign topic slug (`tree-dfs/`, `heap-priority-queue/`) and a real route segment, and `(problems)` is a second `(group)` inside it, so `tree-dfs/(problems)/226_invert-binary-tree.mdx` routes to `/tree-dfs/invert-binary-tree` (Blume sorts by the `<num>_` prefix and strips it from the route). The `meta.ts` files carry the exact labels, phase/topic numbers and icons so the sidebar reads in curriculum order; every topic meta is `display: "page"`, so a topic is one sidebar row whose panel holds its landing page and a "Problems" section. A problem page's badge row (between the description and the body) is rendered, not written: the MDX `wrapper` (`components/ProblemBadges.astro`, registered as `mdx.wrapper` in `components.ts` — Blume's page template renders the title and description itself with no slot before the body, so the wrapper, which MDX hands no route, reads it off `Astro.url`) reads the page's `topics:` (LeetCode's topic tags, from sync) and `video:` (NeetCode YouTube id, from `bun run videos`) frontmatter — both declared via `frontmatter.extend` in `blume.config.ts`, hand-rolled Standard Schemas, no zod — and links a tag the campaign teaches to its topic landing page (filled), any other tag to its card on the Appendix glossary `/appendix#<slug>` (outlined; LeetCode's problem list if the tag has no term, gray and iconless), and the video as a red `Explanation` badge. Every badge in the row is a pill. A tag badge carries the icon its target shows (a landing's: the topic `meta.ts` icon read off `data.navigation`; an Appendix term's: its `icon` in `appendix.ts`) and the tag's own colour from `lib/tag-colors.ts` — unique per tag site-wide, assigned greedily over every tag with a landing or a term so tags that share problem pages sit furthest apart in OKLab, in a lightness band that keeps ≥ 3:1 contrast in both themes (Blume's Badge takes one hex for both). The reverse direction is the `PageFooter` slot (`components/BacklinksList.astro`): an Obsidian-style `<num> <name>` list under every topic landing page of the problems whose badges link there, plus a topic's own folder members (so a topic no tag names, `hybrid-structures`, still lists its problems), in a native `<details>` open on load whose top-right chevron collapses it (no JS); problem pages are recognised by their `<num>. <name>` title and render no list. The sidebar is Blume's default nav tree (no `Sidebar` slot override). The custom pages `/progress`, `/changelog` and `/appendix` are header `navigation.tabs` (on mobile, Blume lists them at the top of the drawer); never `navigation.featured`, which Blume renders above the nav tree. Which tags the campaign teaches is ONE map, `apps/docs/topic-landings.ts` (tag slug → landing routes), and where a badge points is ONE function beside it, `tagTarget()` — read by both components and the Appendix page (so badges, backlinks and term cards cannot disagree) and the map by sync. Every other tag a problem carries is ONE hand-written table, `apps/docs/appendix.ts` (`APPENDIX`: slug → `{ name, icon, category, definition }`), rendered by ONE custom page, `pages/appendix.astro` — a glossary with an A–Z letter index and a filter box (an `<appendix-filter>` custom element over static markup; no JS, the full list), one card per term (icon tinted the tag's colour, category, definition, LeetCode list link, and a `<details>` "Solved" list of the problem pages whose badge `tagTarget()` sends there, paged `PAGE_SIZE` (5) rows at a time by numbered buttons under it — a `<solved-pages>` custom element; without JS every row shows and the buttons stay hidden), each card `id`'d by slug and highlighted as `:target`. It is no content folder, so it enters neither the sidebar tree nor prev/next: its header tab is its link. Sync writes nothing for it — it warns about a carried tag with no term, a term whose tag a topic now teaches (delete it), and a term `icon` that is a `TOPIC_ICONS` value or another term's. The former per-term pages `/appendix/<slug>` are exact redirects to `/appendix#<slug>` in `blume.config.ts`, generated from `APPENDIX`'s keys. A hand-written `(0N-<phase>)/<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; `DependencySpine` — the README topic map as data, zoom/pan, nodes link to whatever route Blume's `/api/docs/navigaLine truncated
| `apps/api/` | Cloudflare Worker workspace: `src/` modules, `data/schedule.json` (the plan: `date → { topics | problems | review | gate | sweep }`, human-edited, bundled at deploy), `migrations/` (append-only; `0002` is the +3/+7 ladder rebuild) |
| `.github/workflows/` | deploy (docs → Pages with `GITHUB_TOKEN` for the releases-backed changelog, Worker → Cloudflare with the `OPENROUTER_API_KEY` Actions secret put into it first, on every main push), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`; then the `release` job: timever tag + release per finished topic, `contents: write`), 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 what sync changed (pages, `apps/docs/redirects.ts`), so a solution, its page and its `/<num>` short link 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 |
@@ -113,10 +113,10 @@ curl -X POST -H "Authorization: Bearer $LINK_KEY" \
- `apps/api/src/ask.ts` — `POST /ask`, the docs assistant's backend. Blume's own assistant route is server-rendered and cannot ship in the static Pages build (`BLUME_SERVER_FEATURE_REQUIRED`), so `blume.config.ts` sets `ai.assistant.endpoint` to this route instead of a `provider`. It speaks the Blume island's wire contract (`{ messages, page: { path } }` in, a plain-text stream out, a 503 "The assistant is not configured…" when the key is missing), validates like Blume's generated route (1–40 user/assistant messages, 24 000 chars, 64 KB — no caller system prompt), grounds on the published `llms-full.txt` (current page first, then keyword-ranked excerpts in Blume's 6 × 2 000 / 10 000-char budget, cached 5 min), and streams OpenRouter's chat completions over plain `fetch` — no AI SDK — for the `ASK_MODEL` var. `:free` models share an upstream pool and often answer 429, which the island shows as its rate-limit notice.
- `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/<CAMPAIGN_DAYS>) LeetCode Daily Digest` — 79 with the plan ending Nov 3; booking a later day in `schedule.json` lengthens it, and the heatmaps and `/api/stats` `heat` with it.
- `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 frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) and `issue:` (the URL of the problem's `problem`-labelled GitHub issue, `LC <num> · …`, listed via `github()` — rendered as the neutral `Issue #N` badge after `Explanation`) are filled once — on create, or on any run that finds them missing — never refreshed (delete a key to refetch; offline or without `gh` auth, the page is written without it and the next sync retries; a sync with nothing missing makes no request). Only a problem the README plan lists gets `issue:` — an unplanned warm-up has no catalog issue, so it is never looked up. `## Solution` is 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 `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `content/(0N-<phase>)/<topic>/`. The topic `meta.ts` is script-owned and rewritten every run (label, curriculum order, `TOPIC_ICONS` icon, `display: "group"`); the phase `meta.ts` is written once when missing (`Phase N: Name`, `order: N`) and hand-owned after — rename its title freely. Never rewriting it loses nothing: its order and name are also the folder name, so a README change to either lands in a new folder with a fresh meta. The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one — so page-relative asset paths in a moved landing page are yours to fix). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The hand-owned parts 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); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added by `bun run videos --write` (`apps/cli/videos.ts`).
- `apps/cli/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) and `issue:` (the URL of the problem's `problem`-labelled GitHub issue, `LC <num> · …`, listed via `github()` — rendered as the neutral `Issue #N` badge after `Explanation`) are filled once — on create, or on any run that finds them missing — never refreshed (delete a key to refetch; offline or without `gh` auth, the page is written without it and the next sync retries; a sync with nothing missing makes no request). Only a problem the README plan lists gets `issue:` — an unplanned warm-up has no catalog issue, so it is never looked up. `## Solution` is 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 `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `content/(0N-<phase>)/<topic>/`, with every problem page in the topic's `(problems)/` group as `<num>_<slug>.mdx` (a page is found by its frontmatter title, wherever it sits, so a layout change is one sync of `rename`s). The topic `meta.ts` is script-owned and rewritten every run (label, curriculum order, `TOPIC_ICONS` icon, `display: "page"`); the phase `meta.ts` is written once when missing (`Phase N: Name`, `order: N`) and hand-owned after — rename its title freely. Never rewriting it loses nothing: its order and name are also the folder name, so a README change to either lands in a new folder with a fresh meta. The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing through an emptied `(problems)` and topic into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one — so page-relative asset paths in a moved landing page are yours to fix). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The hand-owned parts 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); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added 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` / `apps/docs/lib/stats.ts` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, `attempting` (open misses, via `MISS_WHERE` in `srs.ts` — the sweep day's definition), per-topic temperature, gates, the 14-day due queue, `heat` (every campaign day, zeros and future days included — the TUI windows its tail rather than asking a second question), `recent` (the ten problems most recently solved, dated by each one's first pass) and `year` (the 365 days ending today — always the calendar year to date — each split by difficulty into attempts and first solves; the docs cards window and bucket it themselves). Two readers consume it, the docs `/progress` islands and `bun run stats`, so its shape is their contract: change the Worker and both readers together (additive fields a reader ignores are safe). `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 is five LeetCode-style islands — `SolvedRing` (difficulty gauge + "Attempting"), `SubmissionHeatmap` and `SubmissionChart` sharing one row (the heatmap: the last six months in month blocks, the README's colour ramp as theme.css `--heat-0..4`, its attempts / active days / max streak counted from the cells it draws; the chart: this calendar year as stacked Easy/Med./Hard bars per month or week, first solves or every attempt, via two shadcn `ToggleGroup`s), `TopicBubbles` (the campaign topics circle-packed by `d3-hierarchy`, filled by solved share), `RecentSolves` (the ten most recent, linked through `redirects.ts`) — composed from shadcn/ui (`components/ui/{card,chart,skeleton,toggle,toggle-group}.tsx`, Recharts under `chart`, Radix under the toggles) on Blume's tokens: `apps/docs/theme.css` maps shadcn's names onto `--blume-*` (never `accent`, which is Blume's brand colour — vendored components that use it, like `toggle.tsx`, are retokened to `background`/`muted`) and gives shadcn's `data-slot` parts their border colour. They share `lib/stats.ts` (`useStats()`), so the page makes ONE `/api/stats` request however many islands hydrate. shadcn sources live outside `islands/` (every `.tsx` there becomes an island) and import `lib/utils` relatively — Blume's Vite has no `@/` alias; `components.json` + `tsconfig.json` exist for the shadcn CLI and `tsc`, so after `shadcn add` rewrite `@/…` imports to relative ones — and check `package.json`: the CLI has twice installed an unrelated `cn` package for `lib/utils` and skipped real deps (`class-variance-authority`). Blume's `.prose :where(a, p, ol)` rules ignore `not-prose`, so island markup overrides them with `!` utilities, as Blume's own components do.
- `README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache). The docs `/progress` page is a custom page, `apps/docs/pages/progress.astro` (no content folder, so outside the sidebar and prev/next; its header tab is its link), mounting five LeetCode-style React cards from `components/progress/` with `client:load` — imported by the page, never used from MDX, so they are not in `islands/` — `SolvedRing` (difficulty gauge + "Attempting"), `SubmissionHeatmap` and `SubmissionChart` sharing one row (the heatmap: the last six months in month blocks, the README's colour ramp as theme.css `--heat-0..4`, its attempts / active days / max streak counted from the cells it draws; the chart: this calendar year as stacked Easy/Med./Hard bars per month or week, first solves or every attempt, via two shadcn `ToggleGroup`s), `TopicBubbles` (the campaign topics circle-packed by `d3-hierarchy`, filled by solved share), `RecentSolves` (the ten most recent, linked through `redirects.ts`) — composed from shadcn/ui (`components/ui/{card,chart,skeleton,toggle,toggle-group}.tsx`, Recharts under `chart`, Radix under the toggles) on Blume's tokens: `apps/docs/theme.css` maps shadcn's names onto `--blume-*` (never `accent`, which is Blume's brand colour — vendored components that use it, like `toggle.tsx`, are retokened to `background`/`muted`) and gives shadcn's `data-slot` parts their border colour. They share `lib/stats.ts` (`useStats()`), so the page makes ONE `/api/stats` request however many cards hydrate. shadcn sources live outside `islands/` (every `.tsx` there becomes an island) and import `lib/utils` relatively; `components.json` + `tsconfig.json` exist for the shadcn CLI and `tsc`, so after `shadcn add` rewrite `@/…` imports to relative ones (house style) — and check `package.json`: the CLI has twice installed an unrelated `cn` package for `lib/utils` and skipped real deps (`class-variance-authority`). Blume turns every tsconfig `paths` target into a Vite alias AND a dev dep-scan glob (`<target>/**/*.astro`…), so `paths` maps only `@/components`, `@/lib`, `@/hooks` — each bare and `/*`, since `components.json` names the bare forms — never `@/*` → `./*`: that glob reaches through `node_modules/blume`, where Vite's scanner reads a `<script>` inside a `RootLayout.astro` comment as a tag and `blume dev` skips dependency pre-bundling. Blume's `.prose :where(a, p, ol)` rules ignore `not-prose`, so island markup overrides them with `!` utilities, as Blume's own components do.
- `apps/docs/blume.config.ts` — site base `/leetcode`; imports `apps/docs/redirects.ts`, sync-generated (`/<num>` → `/<topic>/<slug>`, one row per page, exact root-relative paths — Blume adds the base and, for the static Pages build, emits a meta-refresh page per rule plus `blume-redirects.json`); `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`, `PHASE_ALIASES`).
- Old `.github/srs/` JSON state, its `srs-*` Actions, and the one-shot `import-srs.ts` importer are RETIRED and deleted — do not resurrect; git history is the record.
+199 -165
View File
@@ -28,17 +28,12 @@
* page is written without it and the next sync retries. Delete the key to
* refetch it.
* - Every tag a page carries that no campaign topic teaches (not a key of
* `apps/docs/topic-landings.ts`) gets a placeholder guide page in the
* Appendix, `appendix/<slug>.mdx`, with `list:` pointing at LeetCode's
* problem list for the tag. Created once (a missing or empty file), then
* hand-owned except for `sidebar.badge`, which sync keeps at the guide's
* backlink count (the problem pages carrying the tag; dropped at zero); the
* tag's badge on a problem page links to it. A guide may be filed into a
* category `(group)` folder (e.g. `appendix/(2-algorithms)/`) — no route
* segment, so `/appendix/<slug>` holds — and is found there by slug; a new
* placeholder lands at the `appendix/` root for a human to file (and give
* an `icon:`, which every badge linking to the guide shows; sync warns
* while one is missing or shared).
* `apps/docs/topic-landings.ts`) belongs in the Appendix glossary,
* `apps/docs/appendix.ts`, which is hand-written: sync only checks it,
* warning about a carried tag with no term (its badge falls back to
* LeetCode's list), a term whose tag a topic now teaches, and a term icon
* that is missing, a topic's (`TOPIC_ICONS`), or another term's — every
* badge linking to the term shows it.
*
* The sidebar group is the campaign TOPIC from the README table (`tree-dfs/`,
* ordered by topic number via a script-owned `meta.ts`). A problem outside the
@@ -50,11 +45,14 @@
* segment — while the topic folder is a real segment
* (`/tree-dfs/invert-binary-tree`), so a hand-written `index.mdx` in it 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. A phase folder's `meta.ts` is written once, when
* missing, then hand-owned (its title is yours to rename); a topic's is
* script-owned. An unplanned category group has no phase and sits at the
* content root.
* it. The topic's problem pages sit one level down in a `(problems)` group,
* `tree-dfs/(problems)/226_invert-binary-tree.mdx`: no route segment either,
* and Blume strips the `<num>_` prefix from the route while sorting by it, so
* the URL is still `/tree-dfs/invert-binary-tree`. Sync never writes or moves
* an `index.mdx`, and never removes a folder that still holds one. A phase
* folder's `meta.ts` is written once, when missing, then hand-owned (its
* title is yours to rename); a topic's is script-owned. An unplanned category
* group has no phase and sits at the content root.
*
* 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
@@ -68,6 +66,7 @@
import { mkdir, readdir, rename, rmdir, unlink } from "node:fs/promises";
import { basename, dirname, join } from "node:path";
import { APPENDIX } from "../docs/appendix.ts";
import { TOPIC_LANDINGS } from "../docs/topic-landings.ts";
import { github } from "./github.ts";
@@ -105,11 +104,15 @@ interface Solution {
// ── work/ parsing ────────────────────────────────────────────────
/** Split a source file into (header comment text, code). */
function splitSource(src: string, lang: Lang): { header: string; code: string } {
function splitSource(
src: string,
lang: Lang,
): { header: string; code: string } {
let header: string, code: string;
if (lang === "js") {
const end = src.indexOf("*/");
if (!src.trimStart().startsWith("/*") || end === -1) throw new Error("missing /* header */");
if (!src.trimStart().startsWith("/*") || end === -1)
throw new Error("missing /* header */");
header = src
.slice(src.indexOf("/*") + 2, end)
.split("\n")
@@ -141,7 +144,11 @@ function paragraphs(lines: string[]): string[] {
flush();
continue;
}
if (/^([•-] |Input:|Output:|Explanation:|Example \d+:|Constraints:|Follow[- ]?ups?:)/.test(text)) {
if (
/^([•-] |Input:|Output:|Explanation:|Example \d+:|Constraints:|Follow[- ]?ups?:)/.test(
text,
)
) {
flush();
cur = text;
} else {
@@ -159,7 +166,10 @@ function parseHeader(header: string): Header {
const num = Number.parseInt(titleLine, 10);
const title = titleLine.replace(/^\d+\.\s*/, "");
const difficulty =
lines.find((l) => l.trim().startsWith("Difficulty:"))?.split(":")[1]?.trim() ?? "";
lines
.find((l) => l.trim().startsWith("Difficulty:"))
?.split(":")[1]
?.trim() ?? "";
// Body: everything after the ───── separator.
const sep = lines.findIndex((l) => /^[─]{3,}/.test(l.trim()));
@@ -289,10 +299,18 @@ async function readmeTopics(): Promise<Map<number, Topic>> {
const p = line.match(/^## Phase ([IVX]+) — (.+?) · /);
if (p) {
const name = p[2]!.replaceAll("&amp;", "&");
phase = { order: ROMAN.indexOf(p[1]!) + 1, name: PHASE_ALIASES[name] ?? name };
phase = {
order: ROMAN.indexOf(p[1]!) + 1,
name: PHASE_ALIASES[name] ?? name,
};
}
const t = line.match(/<strong>(\d+)\.\s*([^<]+)<\/strong>/);
if (t) topic = { order: Number(t[1]), name: TOPIC_ALIASES[t[2]!.trim()] ?? t[2]!.trim(), phase };
if (t)
topic = {
order: Number(t[1]),
name: TOPIC_ALIASES[t[2]!.trim()] ?? t[2]!.trim(),
phase,
};
if (!topic) continue;
const n = line.match(/<td align="center">(\d+)<\/td>/);
if (n && !map.has(Number(n[1]))) map.set(Number(n[1]), topic);
@@ -341,15 +359,22 @@ function phaseMeta(phase: Phase): string {
/**
* Blume folder meta for a topic: exact label (the folder slug loses `/` and
* case), curriculum order, the sidebar icon, and `display: "group"` so the
* topic collapses under its phase heading. An unplanned category group
* case), curriculum order, the sidebar icon, and `display: "page"` so the
* topic is one row under its phase heading that drills into its own panel
* (the landing page, then its `(problems)` list). An unplanned category group
* (warm-ups) has no README number and sorts at 0, ahead of phase 1, so the
* sidebar opens on the basics.
*/
function groupMeta(topic: Topic): string {
const icon = TOPIC_ICONS[topic.name];
if (!icon) console.warn(`no icon for group ${JSON.stringify(topic.name)}: add it to TOPIC_ICONS`);
const fields = [`title: ${JSON.stringify(topic.name)}`, `order: ${topic.order ?? 0}`];
if (!icon)
console.warn(
`no icon for group ${JSON.stringify(topic.name)}: add it to TOPIC_ICONS`,
);
const fields = [
`title: ${JSON.stringify(topic.name)}`,
`order: ${topic.order ?? 0}`,
];
if (icon) fields.push(`icon: ${JSON.stringify(icon)}`);
fields.push(`display: "group"`);
return `import { defineMeta } from "blume";\n\nexport default defineMeta({ ${fields.join(", ")} });\n`;
@@ -378,17 +403,24 @@ async function fetchTopicTags(sol: Solution): Promise<TopicTag[] | undefined> {
referer: `https://leetcode.com/problems/${sol.slug}/`,
},
body: JSON.stringify({
query: "query topicTags($titleSlug: String!) { question(titleSlug: $titleSlug) { topicTags { name slug } } }",
query:
"query topicTags($titleSlug: String!) { question(titleSlug: $titleSlug) { topicTags { name slug } } }",
variables: { titleSlug: sol.slug },
}),
});
if (!res.ok) throw new Error(`POST /graphql -> ${res.status} ${res.statusText}`);
const data = (await res.json()) as { data?: { question?: { topicTags?: TopicTag[] } | null } };
if (!res.ok)
throw new Error(`POST /graphql -> ${res.status} ${res.statusText}`);
const data = (await res.json()) as {
data?: { question?: { topicTags?: TopicTag[] } | null };
};
const tags = data.data?.question?.topicTags;
if (!Array.isArray(tags)) throw new Error("POST /graphql -> no such question");
if (!Array.isArray(tags))
throw new Error("POST /graphql -> no such question");
return tags.map(({ name, slug }) => ({ name, slug }));
} catch (err) {
console.warn(`no topics for ${sol.num}.${sol.slug}: ${err instanceof Error ? err.message : err}`);
console.warn(
`no topics for ${sol.num}.${sol.slug}: ${err instanceof Error ? err.message : err}`,
);
return undefined;
}
}
@@ -396,15 +428,30 @@ async function fetchTopicTags(sol: Solution): Promise<TopicTag[] | undefined> {
/** The frontmatter `topics:` block, one flow mapping per tag (JSON strings are valid YAML). */
function topicsYaml(tags: TopicTag[]): string {
if (tags.length === 0) return "topics: []";
return ["topics:", ...tags.map((t) => ` - { name: ${JSON.stringify(t.name)}, slug: ${JSON.stringify(t.slug)} }`)].join("\n");
return [
"topics:",
...tags.map(
(t) =>
` - { name: ${JSON.stringify(t.name)}, slug: ${JSON.stringify(t.slug)} }`,
),
].join("\n");
}
/** The frontmatter's `topics:` as sync wrote them (or a human edited them); [] when absent. */
function frontmatterTopics(page: string): TopicTag[] {
const fm = page.match(/^---\n([\s\S]*?)\n---/)?.[1];
const data: unknown = fm ? Bun.YAML.parse(fm) : undefined;
if (!data || typeof data !== "object" || !("topics" in data) || !Array.isArray(data.topics)) return [];
return data.topics.filter((t): t is TopicTag => typeof t?.name === "string" && typeof t?.slug === "string");
if (
!data ||
typeof data !== "object" ||
!("topics" in data) ||
!Array.isArray(data.topics)
)
return [];
return data.topics.filter(
(t): t is TopicTag =>
typeof t?.name === "string" && typeof t?.slug === "string",
);
}
// ── GitHub problem issues ────────────────────────────────────────
@@ -422,7 +469,9 @@ function problemIssues(): Promise<Map<number, string> | undefined> {
try {
const gh = await github();
const urls = new Map<number, string>();
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=problem&state=all`)) {
for await (const raw of gh.list(
`/repos/${gh.repo}/issues?labels=problem&state=all`,
)) {
if (!raw || typeof raw !== "object" || "pull_request" in raw) continue;
if (!("title" in raw) || typeof raw.title !== "string") continue;
if (!("html_url" in raw) || typeof raw.html_url !== "string") continue;
@@ -431,7 +480,9 @@ function problemIssues(): Promise<Map<number, string> | undefined> {
}
return urls;
} catch (err) {
console.warn(`no problem issues: ${err instanceof Error ? err.message : err}`);
console.warn(
`no problem issues: ${err instanceof Error ? err.message : err}`,
);
return undefined;
}
})();
@@ -448,73 +499,28 @@ async function issueUrl(sol: Solution): Promise<string | undefined> {
if (!topics.has(sol.num)) return undefined;
const urls = await problemIssues();
const url = urls?.get(sol.num);
if (urls && !url) console.warn(`no problem issue titled "LC ${sol.num} · …" for ${sol.num}.${sol.slug}`);
if (urls && !url)
console.warn(
`no problem issue titled "LC ${sol.num} · …" for ${sol.num}.${sol.slug}`,
);
return url;
}
// ── guides ───────────────────────────────────────────────────────
/**
* Placeholder for a tag no campaign topic teaches: the `/appendix/<slug>` page a
* problem's topic badge links to. `list:` is the tag's LeetCode problem list,
* rendered as a badge by the docs MDX `wrapper`. Written once — the page
* is hand-owned from then on.
*/
function guidePage(tag: TopicTag): string {
return [
"---",
`title: ${JSON.stringify(tag.name)}`,
`list: ${JSON.stringify(`https://leetcode.com/problem-list/${tag.slug}/`)}`,
"---",
"",
":::note",
`This guide has not been written yet. The LeetCode list above has every problem tagged ${tag.name}.`,
":::",
"",
].join("\n");
}
/**
* Folder meta for `appendix/`: a drill-in (`display: "page"`) sidebar row
* sorted ahead of the phase groups, so it sits right under the root pages
* (Introduction, Keywords, Progress).
*/
const APPENDIX_META = `import { defineMeta } from "blume";\n\nexport default defineMeta({ title: "Appendix", icon: "library", order: 0, display: "page" });\n`;
/**
* A guide's frontmatter with `sidebar.badge` set to its backlink count — the
* one key sync owns on a hand-owned guide — or dropped (with a `sidebar:` left
* empty) at zero, so the sidebar shows a count only where there is something
* to count.
*/
function withBacklinkBadge(page: string, count: number): string {
const fm = page.match(/^---\n[\s\S]*?\n---/);
if (!fm) return page;
let head = fm[0].replace(/^ {2}badge: .*\n/m, "");
if (count > 0) {
const line = ` badge: '${count}'`;
head = /^sidebar:$/m.test(head)
? head.replace(/^sidebar:$/m, `sidebar:\n${line}`)
: head.replace(/\n---$/, `\nsidebar:\n${line}\n---`);
} else {
head = head.replace(/^sidebar:\n(?! {2})/m, "");
}
return head + page.slice(fm[0].length);
}
// ── inline-code formatting heuristics ────────────────────────────
/** Identifiers worth backticking in prose, harvested from the code. */
function identifiers(sol: Solution): Set<string> {
const ids = new Set<string>(["n", "m", "k"]);
for (const code of Object.values(sol.code)) {
for (const m of code.matchAll(/@param\s*\{[^}]*\}\s*(\w+)/g)) ids.add(m[1]!);
for (const m of code.matchAll(/@param\s*\{[^}]*\}\s*(\w+)/g))
ids.add(m[1]!);
for (const m of code.matchAll(/def \w+\(self,?\s*([^)]*)\)/g))
for (const arg of m[1]!.split(","))
if (arg.trim()) ids.add(arg.split(":")[0]!.trim());
for (const m of code.matchAll(/(?:var|const|let) (\w+) = function\s*\(([^)]*)\)/g))
for (const arg of m[2]!.split(","))
if (arg.trim()) ids.add(arg.trim());
for (const m of code.matchAll(
/(?:var|const|let) (\w+) = function\s*\(([^)]*)\)/g,
))
for (const arg of m[2]!.split(",")) if (arg.trim()) ids.add(arg.trim());
}
return ids;
}
@@ -571,7 +577,11 @@ function backtickify(text: string, ids: Set<string>): string {
while (j > i && /^[+\-*/=%]$/.test(toks[j]!.core)) j--;
const span = toks
.slice(i, j + 1)
.map((tok, idx, arr) => (idx === arr.length - 1 ? `${tok.pre}${tok.core}` : `${tok.pre}${tok.core}${tok.post}`))
.map((tok, idx, arr) =>
idx === arr.length - 1
? `${tok.pre}${tok.core}`
: `${tok.pre}${tok.core}${tok.post}`,
)
.join(" ");
out.push(`\`${span}\`${toks[j]!.post}`);
i = j + 1;
@@ -599,14 +609,22 @@ function warningText(h: Header): string {
return (
h.statement
.slice(1)
.find((p) => /^(You must|Your solution must|Your algorithm|Notice that|Could you)/.test(p)) ?? ""
.find((p) =>
/^(You must|Your solution must|Your algorithm|Notice that|Could you)/.test(
p,
),
) ?? ""
);
}
/** Sidebar/page icon for every problem page (Lucide, resolved by Blume). */
const PAGE_ICON = "dot";
function renderPage(sol: Solution, tags: TopicTag[] | undefined, issue: string | undefined): string {
function renderPage(
sol: Solution,
tags: TopicTag[] | undefined,
issue: string | undefined,
): string {
const h = sol.header;
const ids = identifiers(sol);
const description = h.statement[0]?.replace(/\.\s*$/, "") ?? "";
@@ -615,7 +633,9 @@ function renderPage(sol: Solution, tags: TopicTag[] | undefined, issue: string |
parts.push(
"---",
`title: '${h.num}. ${h.title.replaceAll("'", "''")}'`,
/[:#]/.test(description) ? `description: '${description.replaceAll("'", "''")}'` : `description: ${description}`,
/[:#]/.test(description)
? `description: '${description.replaceAll("'", "''")}'`
: `description: ${description}`,
`icon: ${PAGE_ICON}`,
...(tags ? [topicsYaml(tags)] : []),
...(issue ? [`issue: ${JSON.stringify(issue)}`] : []),
@@ -637,7 +657,8 @@ function renderPage(sol: Solution, tags: TopicTag[] | undefined, issue: string |
parts.push(`- Explanation: ${backtickify(ex.explanation[0]!, ids)}`);
else if (ex.explanation.length > 1) {
parts.push("- Explanation:");
for (const item of ex.explanation) parts.push(` - ${backtickify(item, ids)}`);
for (const item of ex.explanation)
parts.push(` - ${backtickify(item, ids)}`);
}
parts.push("");
});
@@ -662,7 +683,10 @@ for await (const rel of new Bun.Glob("**/*.{js,py}").scan(WORK)) {
}
const num = Number(m[1]);
const slug = m[2]!;
const { header, code } = splitSource(await Bun.file(join(WORK, rel)).text(), lang);
const { header, code } = splitSource(
await Bun.file(join(WORK, rel)).text(),
lang,
);
const parsed = parseHeader(header);
const existing = solutions.get(num);
if (existing) {
@@ -711,7 +735,10 @@ async function ensureTopics(page: string, sol: Solution): Promise<string> {
if (!fm || /^topics:/m.test(fm[0])) return page;
const tags = await fetchTopicTags(sol);
if (!tags) return page;
return fm[0].replace(/^sidebar:$/m, `${topicsYaml(tags)}\nsidebar:`) + page.slice(fm[0].length);
return (
fm[0].replace(/^sidebar:$/m, `${topicsYaml(tags)}\nsidebar:`) +
page.slice(fm[0].length)
);
}
/** Insert `issue:` before `sidebar:` when the frontmatter lacks it; an existing URL is kept as is. */
@@ -720,7 +747,10 @@ async function ensureIssue(page: string, sol: Solution): Promise<string> {
if (!fm || /^issue:/m.test(fm[0])) return page;
const url = await issueUrl(sol);
if (!url) return page;
return fm[0].replace(/^sidebar:$/m, `issue: ${JSON.stringify(url)}\nsidebar:`) + page.slice(fm[0].length);
return (
fm[0].replace(/^sidebar:$/m, `issue: ${JSON.stringify(url)}\nsidebar:`) +
page.slice(fm[0].length)
);
}
/**
@@ -743,7 +773,9 @@ function solutionEnd(page: string, from: number): number {
// Existing docs pages, keyed by leetcode number from the frontmatter title.
const pages = new Map<number, string>(); // num -> path relative to DOCS
for (const rel of (await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))).sort()) {
for (const rel of (
await Array.fromAsync(new Bun.Glob("**/*.mdx").scan(DOCS))
).sort()) {
const text = await Bun.file(join(DOCS, rel)).text();
const t = text.match(/^title: '(\d+)\./m);
if (t) pages.set(Number(t[1]), rel);
@@ -756,21 +788,21 @@ const phases = new Map<string, Phase>(); // phase folder -> phase, for meta.ts
const vacated = new Set<string>(); // folders a page moved out of; removed below if empty
const redirects: string[] = []; // `{ from: "/<num>", to: "/<topic>/<slug>" }` rows, numeric order
const tags = new Map<string, TopicTag>(); // every tag slug any problem page carries -> tag
const tagged = new Map<string, number>(); // tag slug -> how many problem pages carry it
for (const sol of ordered) {
const langs = Object.keys(sol.code).sort().join("+");
const workRef = `${sol.num}.${sol.slug} (${langs})`;
// (0N-<phase>)/<topic>/ folder (the topic is a real route segment, so its
// index.mdx is the landing page) + leetcode-number prefix: numeric sidebar
// order, prefix stripped from the URL.
const topic = topics.get(sol.num) ?? topicByName.get(sol.category) ?? { name: sol.category };
// (0N-<phase>)/<topic>/(problems)/ folder (the topic is a real route segment,
// so its index.mdx is the landing page; `(problems)` adds none) +
// leetcode-number prefix: numeric sidebar order, prefix stripped from the URL.
const topic = topics.get(sol.num) ??
topicByName.get(sol.category) ?? { name: sol.category };
const dir = topicDir(topic);
groups.set(dir, topic);
if (topic.phase) phases.set(phaseDir(topic.phase), topic.phase);
const name = `${dir}/${sol.num}-${sol.slug}.mdx`;
const name = `${dir}/${sol.num}_${sol.slug}.mdx`;
const existing = pages.get(sol.num);
await mkdir(join(DOCS, dir), { recursive: true });
await mkdir(join(DOCS, dirname(name)), { recursive: true });
let written: string; // the page as it stands after this run, for its topics
if (existing) {
@@ -781,8 +813,12 @@ for (const sol of ordered) {
continue;
}
const tail = page.slice(solutionEnd(page, at));
const body = page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : "");
const next = await ensureIssue(await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol), sol);
const body =
page.slice(0, at) + solutionSection(sol) + (tail ? `\n${tail}` : "");
const next = await ensureIssue(
await ensureTopics(ensureIcon(ensureLabel(body, sol.header)), sol),
sol,
);
const actions: string[] = [];
if (existing !== name) {
await rename(join(DOCS, existing), join(DOCS, name));
@@ -796,17 +832,20 @@ for (const sol of ordered) {
report.push([workRef, name, actions.join(" + ") || "skipped (in sync)"]);
written = next;
} else {
const page = renderPage(sol, await fetchTopicTags(sol), await issueUrl(sol));
const page = renderPage(
sol,
await fetchTopicTags(sol),
await issueUrl(sol),
);
await Bun.write(join(DOCS, name), page);
report.push([workRef, name, "created"]);
written = page;
}
for (const tag of frontmatterTopics(written)) {
tags.set(tag.slug, tag);
tagged.set(tag.slug, (tagged.get(tag.slug) ?? 0) + 1);
}
for (const tag of frontmatterTopics(written)) tags.set(tag.slug, tag);
// The route drops the phase `(group)` and the page's number prefix.
redirects.push(` { from: "/${sol.num}", to: ${JSON.stringify(`/${slugify(topic.name)}/${sol.slug}`)} },`);
redirects.push(
` { from: "/${sol.num}", to: ${JSON.stringify(`/${slugify(topic.name)}/${sol.slug}`)} },`,
);
}
// Script-owned short links, one per page; imported by blume.config.ts.
@@ -819,54 +858,48 @@ for (const sol of ordered) {
"];",
"",
].join("\n");
if (!(await Bun.file(REDIRECTS).exists()) || (await Bun.file(REDIRECTS).text()) !== module) {
if (
!(await Bun.file(REDIRECTS).exists()) ||
(await Bun.file(REDIRECTS).text()) !== module
) {
await Bun.write(REDIRECTS, module);
report.push([`${redirects.length} redirects`, "apps/docs/redirects.ts", "written"]);
report.push([
`${redirects.length} redirects`,
"apps/docs/redirects.ts",
"written",
]);
}
}
// One guide page per tag that no campaign topic teaches, created when missing
// (an empty file counts as missing) and never rewritten. A guide is found by
// slug anywhere under appendix/ — a human files it into a category `(group)`,
// which leaves its route alone — and a new one lands at the appendix/ root. A
// guide whose tag no page carries any more is left alone: it is hand-owned.
const guideTags = [...tags.values()]
.filter((tag) => !TOPIC_LANDINGS[tag.slug])
.sort((a, b) => a.slug.localeCompare(b.slug));
const guideFiles = new Map<string, string>(); // slug -> path relative to DOCS
for await (const rel of new Bun.Glob("appendix/**/*.mdx").scan(DOCS)) guideFiles.set(basename(rel, ".mdx"), rel);
for (const tag of guideTags) {
const rel = guideFiles.get(tag.slug) ?? `appendix/${tag.slug}.mdx`;
const file = Bun.file(join(DOCS, rel));
if ((await file.exists()) && file.size > 0) continue;
await Bun.write(file, guidePage(tag));
report.push([`tag ${tag.slug}`, rel, "created"]);
// Every carried tag no campaign topic teaches is an Appendix term, filed by
// hand in apps/docs/appendix.ts: warn about the gaps and the dead weight. A
// term's icon is also the icon on every problem-page badge linking to its
// card, so it must not be a topic group's (`TOPIC_ICONS`), so a term badge
// never reads as a topic's, nor another term's.
for (const tag of [...tags.values()].sort((a, b) =>
a.slug.localeCompare(b.slug),
)) {
if (!TOPIC_LANDINGS[tag.slug] && !APPENDIX[tag.slug])
console.warn(
`tag ${tag.slug}: no Appendix term; add ${JSON.stringify(tag.name)} to apps/docs/appendix.ts`,
);
}
// Every guide's sidebar badge is its backlink count: the problem pages whose
// tag badge links to it, which is every page carrying the tag, since a guide's
// tag has no landing (`tagTarget()`). A guide whose tag a landing has since
// claimed has no backlinks left. Counts move with every solved problem, so the
// badge is rewritten whenever it differs; the rest of the guide is untouched.
// The same pass checks each guide's hand-picked `icon:`, which is also the icon
// on every problem-page badge linking to the guide: it must exist, must not be
// a topic group's (`TOPIC_ICONS`), so a guide badge never reads as a topic's,
// and must not be another guide's.
const topicIcons = new Set(Object.values(TOPIC_ICONS));
const guideIcons = new Map<string, string>(); // icon -> the guide that took it first
for await (const rel of new Bun.Glob("appendix/**/*.mdx").scan(DOCS)) {
const slug = basename(rel, ".mdx");
const count = TOPIC_LANDINGS[slug] ? 0 : (tagged.get(slug) ?? 0);
const page = await Bun.file(join(DOCS, rel)).text();
const icon = page.match(/^---\n[\s\S]*?\n---/)?.[0].match(/^icon: (\S+)$/m)?.[1];
if (!icon) console.warn(`${rel}: no icon; its badges render without one — file it and add \`icon:\``);
else if (topicIcons.has(icon)) console.warn(`${rel}: icon ${JSON.stringify(icon)} is a topic's (TOPIC_ICONS); pick another`);
else if (guideIcons.has(icon)) console.warn(`${rel}: icon ${JSON.stringify(icon)} is ${guideIcons.get(icon)}'s too; pick another`);
else guideIcons.set(icon, rel);
const next = withBacklinkBadge(page, count);
if (next === page) continue;
await Bun.write(join(DOCS, rel), next);
report.push([`${count} backlinks`, rel, "badged"]);
const termIcons = new Map<string, string>(); // icon -> the term that took it first
for (const [slug, term] of Object.entries(APPENDIX)) {
if (TOPIC_LANDINGS[slug])
console.warn(
`appendix ${slug}: a campaign topic teaches it now; delete its term`,
);
if (topicIcons.has(term.icon))
console.warn(
`appendix ${slug}: icon ${JSON.stringify(term.icon)} is a topic's (TOPIC_ICONS); pick another`,
);
else if (termIcons.has(term.icon))
console.warn(
`appendix ${slug}: icon ${JSON.stringify(term.icon)} is ${termIcons.get(term.icon)}'s too; pick another`,
);
else termIcons.set(term.icon, slug);
}
// Phase metas: created when missing (an empty file counts as missing), never
@@ -878,15 +911,15 @@ for (const [dir, phase] of phases) {
report.push([`phase ${phase.order}`, `${dir}/meta.ts`, "created"]);
}
// Script-owned folder metas — one per live topic folder, plus appendix/ —
// rewritten only when they differ.
const metas: [label: string, dir: string, meta: string][] = [
...[...groups].map(([dir, topic]): [string, string, string] => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)]),
...(guideTags.length ? [["appendix", "appendix", APPENDIX_META] as [string, string, string]] : []),
];
// Script-owned folder metas — one per live topic folder — rewritten only when
// they differ.
const metas: [label: string, dir: string, meta: string][] = [...groups].map(
([dir, topic]) => [`topic ${topic.order ?? "-"}`, dir, groupMeta(topic)],
);
for (const [label, dir, meta] of metas) {
const path = join(DOCS, dir, "meta.ts");
if ((await Bun.file(path).exists()) && (await Bun.file(path).text()) === meta) continue;
if ((await Bun.file(path).exists()) && (await Bun.file(path).text()) === meta)
continue;
await Bun.write(path, meta);
report.push([label, `${dir}/meta.ts`, "written"]);
}
@@ -914,4 +947,5 @@ 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`);
console.log(`${"─".repeat(w0)} ${"─".repeat(w1)} ${"─".repeat(20)}`);
for (const [a, b, c] of report) console.log(`${a.padEnd(w0)} ${b.padEnd(w1)} ${c}`);
for (const [a, b, c] of report)
console.log(`${a.padEnd(w0)} ${b.padEnd(w1)} ${c}`);
+232
View File
@@ -0,0 +1,232 @@
/**
* The Appendix glossary: one term per LeetCode topic tag that problem pages
* carry but no campaign topic teaches (not a key of `topic-landings.ts`).
* `pages/appendix.astro` renders each as a card — icon, category, definition,
* and the solved problems carrying the tag — anchored at `/appendix#<slug>`,
* which is where `tagTarget()` sends that tag's badge on a problem page.
*
* Hand-written: `apps/cli/sync.ts` only checks it, warning about a carried
* tag with no entry (its card renders bare), an entry whose tag a campaign
* topic now teaches (dead weight, delete it), and an icon that is a topic's
* (`TOPIC_ICONS`) or another term's — the icon is also the one on every
* badge linking here, so it must not read as anything else. Plain data, no
* Astro imports: sync imports this file under Bun.
*/
export type AppendixCategory = "Data Structures" | "Algorithms" | "Techniques";
export interface AppendixTerm {
/** LeetCode's tag name. */
name: string;
/** Lucide icon, unique across terms and topics. */
icon: string;
category: AppendixCategory;
/** One sentence: what the term means, the way a solution uses it. */
definition: string;
}
export const APPENDIX: Record<string, AppendixTerm> = {
// ── data structures ──────────────────────────────────────────────
array: {
name: "Array",
icon: "list-ordered",
category: "Data Structures",
definition: "A contiguous, index-addressed sequence: O(1) access by position, O(n) to insert or delete in the middle.",
},
"binary-tree": {
name: "Binary Tree",
icon: "git-branch",
category: "Data Structures",
definition: "A rooted tree in which every node has at most two children, left and right.",
},
"directed-acyclic-graph": {
name: "Directed Acyclic Graph",
icon: "route",
category: "Data Structures",
definition: "A directed graph with no cycles, which is exactly what lets its nodes be put in a topological order.",
},
"doubly-linked-list": {
name: "Doubly-Linked List",
icon: "link-2",
category: "Data Structures",
definition: "A linked list whose nodes point to both neighbours, so a node you already hold is unlinked in O(1).",
},
graph: {
name: "Graph Theory",
icon: "share-2",
category: "Data Structures",
definition: "Nodes joined by edges, directed or not, weighted or not, stored as an adjacency list or matrix.",
},
"k-d-tree": {
name: "K-D Tree",
icon: "axis-3d",
category: "Data Structures",
definition: "A binary tree that splits k-dimensional points on alternating axes, for nearest-neighbour and range queries.",
},
queue: {
name: "Queue",
icon: "list-end",
category: "Data Structures",
definition: "First in, first out: push at the back, pop from the front, both O(1).",
},
string: {
name: "String",
icon: "quote",
category: "Data Structures",
definition: "A sequence of characters; solutions lean on its small alphabet, its substrings, and its immutability.",
},
tree: {
name: "Tree",
icon: "tree-deciduous",
category: "Data Structures",
definition: "A connected acyclic graph, usually rooted, in which every node but the root has exactly one parent.",
},
// ── algorithms ───────────────────────────────────────────────────
"bidirectional-search": {
name: "Bidirectional Search",
icon: "move-horizontal",
category: "Algorithms",
definition: "Search from the start and the goal at once and stop where the frontiers meet, about 2·b^(d/2) states instead of b^d.",
},
"binary-lifting": {
name: "Binary Lifting",
icon: "chevrons-up",
category: "Algorithms",
definition: "Precompute every node's 2^k-th ancestor so any k-th ancestor, and so the LCA, takes O(log n) jumps.",
},
"bit-manipulation": {
name: "Bit Manipulation",
icon: "binary",
category: "Algorithms",
definition: "Treat integers as bit vectors: masks, shifts, and XOR in place of arithmetic or extra memory.",
},
"bracket-sequences": {
name: "Bracket Sequences",
icon: "parentheses",
category: "Algorithms",
definition: "Strings of opening and closing brackets; balanced means the running depth never dips below zero and ends at zero.",
},
"bucket-sort": {
name: "Bucket Sort",
icon: "container",
category: "Algorithms",
definition: "Scatter values into buckets by range or frequency, then read the buckets in order: linear when the key range is bounded.",
},
"complete-knapsack": {
name: "Complete Knapsack",
icon: "package-plus",
category: "Algorithms",
definition: "Knapsack in which every item may be taken any number of times, so the DP walks capacity upward and reuses the row.",
},
"divide-and-conquer": {
name: "Divide and Conquer",
icon: "split",
category: "Algorithms",
definition: "Split the input into independent parts, solve each recursively, and combine their answers.",
},
"floyds-cycle-finding-algorithm": {
name: "Floyd's Cycle Finding Algorithm",
icon: "repeat",
category: "Algorithms",
definition: "A slow and a fast pointer meet inside any cycle; restarting one from the head finds the cycle's entry, in O(1) space.",
},
"knapsack-problem": {
name: "Knapsack Problem",
icon: "backpack",
category: "Algorithms",
definition: "Pick items with weights and values to maximise value under a capacity; the 0/1 version is an O(n·W) DP.",
},
"longest-common-subsequence": {
name: "Longest Common Subsequence",
icon: "diff",
category: "Algorithms",
definition: "The longest sequence that appears in order, not necessarily contiguously, in two strings: the classic O(n·m) 2-D DP.",
},
"lowest-common-ancestor": {
name: "Lowest Common Ancestor",
icon: "git-merge",
category: "Algorithms",
definition: "The deepest node of a rooted tree that is an ancestor of both given nodes.",
},
memoization: {
name: "Memoization",
icon: "database",
category: "Algorithms",
definition: "Cache a pure function's result per argument so every subproblem is solved once: top-down dynamic programming.",
},
"merge-sort": {
name: "Merge Sort",
icon: "blend",
category: "Algorithms",
definition: "Sort each half recursively, then merge the two sorted halves: O(n log n), stable, O(n) extra space.",
},
quickselect: {
name: "Quickselect",
icon: "crosshair",
category: "Algorithms",
definition: "Partition around a pivot and recurse into one side only, finding the k-th smallest element in O(n) on average.",
},
quicksort: {
name: "Quicksort",
icon: "shuffle",
category: "Algorithms",
definition: "Partition around a pivot, then sort each side in place: O(n log n) on average, O(n²) at worst.",
},
recursion: {
name: "Recursion",
icon: "iteration-ccw",
category: "Algorithms",
definition: "Solve a problem by calling the same function on smaller inputs until a base case answers directly.",
},
simulation: {
name: "Simulation",
icon: "monitor-play",
category: "Algorithms",
definition: "Carry out the process the statement describes, step by step; the work is modelling the state faithfully.",
},
sorting: {
name: "Sorting",
icon: "arrow-down-wide-narrow",
category: "Algorithms",
definition: "Order the input first so a later step, such as two pointers, a greedy choice, or binary search, becomes possible.",
},
"tournament-sort": {
name: "Tournament Sort",
icon: "trophy",
category: "Algorithms",
definition: "A winner tree of pairwise comparisons: the root is the extreme element, and replaying one path yields the next in O(log n).",
},
// ── techniques ───────────────────────────────────────────────────
combinatorics: {
name: "Combinatorics",
icon: "dices",
category: "Techniques",
definition: "Count arrangements and selections (permutations, combinations, Catalan numbers), usually modulo a prime.",
},
counting: {
name: "Counting",
icon: "tally-5",
category: "Techniques",
definition: "Tally occurrences in a hash map or fixed array, then answer from the counts instead of the raw input.",
},
design: {
name: "Design",
icon: "pencil-ruler",
category: "Techniques",
definition: "Implement a class to an API spec, choosing the internals so every operation meets its time bound.",
},
geometry: {
name: "Geometry",
icon: "shapes",
category: "Techniques",
definition: "Points, lines, and areas on a plane or grid: distances, slopes, overlaps, and orientation by cross product.",
},
math: {
name: "Math",
icon: "calculator",
category: "Techniques",
definition: "An arithmetic or number-theory insight (modular arithmetic, GCD, digit manipulation, a closed form) in place of brute force.",
},
};
+26 -17
View File
@@ -1,6 +1,7 @@
import { defineConfig } from "blume";
import type { StandardSchema } from "blume/core/standard-schema.ts";
import { filesystem } from "blume/sources";
import { APPENDIX } from "./appendix";
import redirects from "./redirects";
// Blume's defaults (`inter`, `ibm-plex-mono`) are fetched from fonts.gstatic.com
@@ -36,10 +37,9 @@ const plexMono = {
* components/ProblemBadges.astro. Problem pages: `topics:` is LeetCode's topic
* tags and `issue:` the problem's GitHub issue URL (both written by
* apps/cli/sync.ts), `video:` the NeetCode walkthrough's
* YouTube id (apps/cli/videos.ts). Guide pages (content/appendix/, created by
* sync): `list:` is the tag's LeetCode problem list. Blume rejects undeclared
* keys and takes any Standard Schema; these are hand-rolled so the docs
* workspace needs no zod. All optional — every other page carries none.
* YouTube id (apps/cli/videos.ts). Blume rejects undeclared keys and takes
* any Standard Schema; these are hand-rolled so the docs workspace needs no
* zod. All optional — every other page carries none.
*/
const optional = <T>(
message: string,
@@ -68,12 +68,6 @@ const videoSchema = optional(
(value): value is string =>
typeof value === "string" && /^[\w-]{11}$/.test(value),
);
const listSchema = optional(
"expected a https://leetcode.com/problem-list/<slug>/ URL",
(value): value is string =>
typeof value === "string" &&
/^https:\/\/leetcode\.com\/problem-list\/[\w-]+\/$/.test(value),
);
const issueSchema = optional(
"expected a https://github.com/<owner>/<repo>/issues/<n> URL",
(value): value is string =>
@@ -93,12 +87,20 @@ export default defineConfig({
repo: "leetcode",
},
lastModified: "git",
redirects,
// Sync's `/<num>` short links, then the Appendix's former per-term pages
// (`/appendix/<slug>`, published until the glossary became one page) onto
// their cards, so old links and search results still land.
redirects: [
...redirects,
...Object.keys(APPENDIX).map((slug) => ({
from: `/appendix/${slug}`,
to: `/appendix#${slug}`,
})),
],
frontmatter: {
extend: {
topics: topicsSchema,
video: videoSchema,
list: listSchema,
issue: issueSchema,
},
},
@@ -125,9 +127,11 @@ export default defineConfig({
llmsTxt: true,
mcp: { enabled: false },
},
// The only content source. `/changelog` is a custom page (pages/
// changelog.astro) reading the GitHub releases itself — one timeline, no
// per-release pages, so nothing of it enters the sidebar or prev/next.
// The only content source. `/changelog`, `/appendix` and `/progress` are
// custom pages (pages/*.astro): `/changelog` reads the GitHub releases
// itself — one timeline, no per-release pages — `/appendix` is one glossary
// page built from appendix.ts, and `/progress` is the live stats cards, so
// none enters the sidebar tree or prev/next.
content: {
sources: [filesystem({ root: "content" })],
},
@@ -139,7 +143,12 @@ export default defineConfig({
sidebar: {
display: "flat", // "flat" | "group" | "page"
},
// A plain header link on the right, not a tab.
actions: [{ href: "/changelog", label: "Changelog" }],
// The custom pages (pages/*.astro) as header tabs. None is a content
// folder, so the tabs scope nothing out of the sidebar tree.
tabs: [
{ label: "Changelog", path: "/changelog" },
{ label: "Progress", path: "/progress" },
{ label: "Appendix", path: "/appendix" },
],
},
});
+1 -1
View File
@@ -14,7 +14,7 @@ export default defineComponents({
wrapper: "./components/ProblemBadges.astro",
},
layout: {
// Topic landing + guide pages' "what links here": every problem pointing at the page.
// Topic landing pages' "what links here": every problem pointing at the page.
PageFooter: "./components/BacklinksList.astro",
},
});
+4 -3
View File
@@ -1,9 +1,10 @@
---
/**
* The `PageFooter` slot: an Obsidian-style backlinks list under a topic landing
* or guide page — every problem page that points at it, as `<num> <name>`
* rows in LeetCode-number order. Pages nothing points at (problem pages, the
* overview, the changelog) render nothing.
* page — every problem page that points at it, as `<num> <name>` rows in
* LeetCode-number order. Pages nothing points at (problem pages, the
* overview) render nothing; a tag no topic teaches lists its problems on its
* Appendix card instead (`pages/appendix.astro`), from the same answer.
*
* A problem points at a page when either
* - one of its topic badges links there — the same `tagTarget()` answer
+16 -28
View File
@@ -11,28 +11,27 @@
*
* - `topics:` (problem pages, written by `apps/cli/sync.ts`) is LeetCode's own
* topic tags. A tag the campaign teaches (`topic-landings.ts`) links to that
* topic's landing page and is filled; any other tag links to its guide
* page in the Appendix, `/appendix/<slug>` (sync creates one per such tag), and is outlined.
* topic's landing page and is filled; any other tag links to its card on the
* Appendix glossary, `/appendix#<slug>` (`appendix.ts`), and is outlined.
* `tagTarget()` picks the route (and between two landings, e.g. DFS); the
* `PageFooter` backlinks list reads the same answer. Each tag badge is a
* pill carrying the icon its target shows in the sidebar (the topic's
* `meta.ts` icon, the guide's `icon:`) and the tag's own colour
* (`lib/tag-colors.ts`), assigned over every tag the site has a page for —
* no two tags share one, and tags that share problem pages are spread
* furthest apart. A tag with no page (the LeetCode-list fallback) stays
* gray and iconless.
* `PageFooter` backlinks list and the Appendix page read the same answer.
* Each tag badge is a pill carrying the icon its target shows (a topic's
* `meta.ts` icon in the sidebar, a term's `icon` on its card) and the tag's
* own colour (`lib/tag-colors.ts`), assigned over every tag the site has a
* page or card for — no two tags share one, and tags that share problem
* pages are spread furthest apart. A tag with neither (the LeetCode-list
* fallback) stays gray and iconless.
* - `video:` (problem pages, written by `apps/cli/videos.ts`) is the NeetCode
* walkthrough's YouTube id, the red "Explanation" badge.
* - `issue:` (problem pages, written by sync) is the problem's GitHub issue
* URL, a neutral "Issue #N" badge with the GitHub mark, right after
* "Explanation" at the end of the row.
* - `list:` (guide pages, written by sync on create) is the tag's LeetCode
* problem list, an orange "LeetCode list" badge.
*/
import { getEntry } from "astro:content";
import data from "blume:data";
import Badge from "blume/components/content/Badge.astro";
import { stripBase, withBase } from "blume/components/islands/base-path.ts";
import { APPENDIX } from "../appendix.ts";
import { tagColors } from "../lib/tag-colors.ts";
import { TOPIC_LANDINGS, tagTarget, type TopicTag } from "../topic-landings.ts";
@@ -46,7 +45,6 @@ const at = data.routes.find((r: { path: string }) => r.path === route);
const entry = at ? await getEntry(at.collection, at.entryId) : undefined;
const tags: TopicTag[] = entry?.data.topics ?? [];
const video: string | undefined = entry?.data.video;
const list: string | undefined = entry?.data.list;
const issue: string | undefined = entry?.data.issue;
const served = new Set<string>(data.routes.map((r: { path: string }) => r.path));
@@ -67,15 +65,12 @@ const walk = (nodes: NavNode[]) => {
};
walk(data.navigation.sidebar);
// Every tag with a page — the ones a landing teaches, plus every served guide —
// coloured against how problem pages (`<num>. <name>` titles, the shape sync
// writes) combine them. Only a page with tags needs the answer.
// Every tag with a landing or an Appendix card, coloured against how problem
// pages (`<num>. <name>` titles, the shape sync writes) combine them. Only a
// page with tags needs the answer.
const colors = tags.length
? tagColors(
[
...Object.keys(TOPIC_LANDINGS),
...[...served].flatMap((path) => path.match(/^\/appendix\/([^/]+)$/u)?.slice(1) ?? []),
],
[...Object.keys(TOPIC_LANDINGS), ...Object.keys(APPENDIX)],
await Promise.all(
data.routes
.filter((r: { title: string }) => /^\d+\.\s/u.test(r.title))
@@ -94,7 +89,7 @@ const badges = tags.map((tag) => {
name: tag.name,
href: external ? target.href : withBase(target.href),
external,
icon: external ? undefined : icons.get(target.href),
icon: target.kind === "appendix" ? APPENDIX[tag.slug]?.icon : external ? undefined : icons.get(target.href),
color: external ? undefined : colors.get(tag.slug),
stroke: target.kind !== "landing",
};
@@ -104,7 +99,7 @@ const linkClass = "no-underline transition-opacity hover:opacity-80";
---
{
(badges.length > 0 || video || list || issue) && (
(badges.length > 0 || video || issue) && (
<div class="not-prose mb-6 flex flex-wrap gap-1.5">
{badges.map((b) => (
<a
@@ -132,13 +127,6 @@ const linkClass = "no-underline transition-opacity hover:opacity-80";
</Badge>
</a>
)}
{list && (
<a href={list} target="_blank" rel="noopener" class={linkClass}>
<Badge variant="warning" icon="list" shape="pill">
LeetCode list
</Badge>
</a>
)}
</div>
)
}
@@ -5,12 +5,10 @@
* hover. A row links to the problem's docs page when one exists: the
* sync-generated short links (`redirects.ts`) are exactly the pages built.
*/
import { StatsPending } from "../components/progress/shared";
import { Card, CardContent, CardHeader, CardTitle } from "../components/ui/card";
import { useStats, utcDate } from "../lib/stats";
import redirects from "../redirects";
export const client = "load";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { useStats, utcDate } from "../../lib/stats";
import redirects from "../../redirects";
import { StatsPending } from "./shared";
const PAGES = new Map(redirects.map((r) => [r.from, r.to]));
@@ -8,13 +8,11 @@
*/
import { useState } from "react";
import { Pie, PieChart } from "recharts";
import { StatsPending } from "../components/progress/shared";
import { Card, CardContent } from "../components/ui/card";
import { type ChartConfig, ChartContainer } from "../components/ui/chart";
import { DIFFICULTIES, type Difficulty, useStats } from "../lib/stats";
import { cn } from "../lib/utils";
export const client = "load";
import { Card, CardContent } from "../ui/card";
import { type ChartConfig, ChartContainer } from "../ui/chart";
import { DIFFICULTIES, type Difficulty, useStats } from "../../lib/stats";
import { cn } from "../../lib/utils";
import { StatsPending } from "./shared";
const chartConfig = {
easy: { label: "Easy", color: "var(--chart-1)" },
@@ -57,14 +55,14 @@ export default function SolvedRing() {
const done = rows.reduce((n, r) => n + r.done, 0);
const span = SWEEP - GAP * (rows.length - 1);
const segments: ((typeof rows)[number] & { from: number; to: number; solvedTo: number })[] = [];
let cursor = START;
const segments = rows.map((r) => {
for (const r of rows) {
const size = total > 0 ? (span * r.total) / total : 0;
const from = cursor;
cursor -= size + GAP;
const solved = r.total > 0 ? size * (r.done / r.total) : 0;
return { ...r, from, to: from - size, solvedTo: from - solved };
});
segments.push({ ...r, from: cursor, to: cursor - size, solvedTo: cursor - solved });
cursor -= size + GAP;
}
const focused = focus ? rows.find((r) => r.difficulty === focus) : undefined;
const arc = { innerRadius: "80%", outerRadius: "88%", cornerRadius: 99, stroke: "none", dataKey: "value" } as const;
@@ -9,13 +9,11 @@
*/
import { useState } from "react";
import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from "recharts";
import { StatsPending } from "../components/progress/shared";
import { Card, CardAction, CardContent, CardFooter, CardHeader } from "../components/ui/card";
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "../components/ui/chart";
import { ToggleGroup, ToggleGroupItem } from "../components/ui/toggle-group";
import { DIFFICULTIES, type Day, type Difficulty, useStats, utcDate } from "../lib/stats";
export const client = "load";
import { Card, CardAction, CardContent, CardFooter, CardHeader } from "../ui/card";
import { type ChartConfig, ChartContainer, ChartTooltip, ChartTooltipContent } from "../ui/chart";
import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group";
import { DIFFICULTIES, type Day, type Difficulty, useStats, utcDate } from "../../lib/stats";
import { StatsPending } from "./shared";
const chartConfig = {
easy: { label: "Easy", color: "var(--chart-1)" },
@@ -8,11 +8,9 @@
* 4) and rows start on Monday like it, so the three heatmaps read the same.
* Drawn as SVG in a viewBox, so it scales to its column instead of scrolling.
*/
import { StatsPending, TipRow, useHoverTip } from "../components/progress/shared";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { type Day, useStats, utcDate } from "../lib/stats";
export const client = "load";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { type Day, useStats, utcDate } from "../../lib/stats";
import { StatsPending, TipRow, useHoverTip } from "./shared";
const MONTHS = 6;
const CELL = 10;
@@ -76,7 +74,7 @@ function layout(year: Day[]): { blocks: Block[]; width: number } {
export default function SubmissionHeatmap() {
const load = useStats();
const hover = useHoverTip<HTMLDivElement>();
const { ref, show, hide, node } = useHoverTip<HTMLDivElement>();
if (load.phase !== "ready") {
return (
@@ -116,7 +114,7 @@ export default function SubmissionHeatmap() {
</CardDescription>
</CardHeader>
<CardContent className="flex flex-1 items-center">
<div ref={hover.ref} className="relative w-full" onPointerLeave={hover.hide}>
<div ref={ref} className="relative w-full" onPointerLeave={hide}>
<svg
viewBox={`0 0 ${width} ${height}`}
className="h-auto w-full"
@@ -135,7 +133,7 @@ export default function SubmissionHeatmap() {
rx={2}
fill={HEAT[Math.min(cell.attempts, 4)]}
onPointerMove={(event) =>
hover.show(
show(
event,
<>
<div className="font-medium">{DAY.format(cell.date)}</div>
@@ -157,7 +155,7 @@ export default function SubmissionHeatmap() {
</g>
))}
</svg>
{hover.node}
{node}
</div>
</CardContent>
</Card>
@@ -9,12 +9,10 @@
*/
import { packEnclose, packSiblings } from "d3-hierarchy";
import { useId } from "react";
import { StatsPending, TipRow, useHoverTip } from "../components/progress/shared";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../components/ui/card";
import { type Stats, useStats } from "../lib/stats";
import { cn } from "../lib/utils";
export const client = "load";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { type Stats, useStats } from "../../lib/stats";
import { cn } from "../../lib/utils";
import { StatsPending, TipRow, useHoverTip } from "./shared";
const PAD = 2; // gap between bubbles, in layout units
const UNIT = 12; // radius of a one-problem bubble
@@ -35,7 +33,7 @@ function lines(name: string): string[] {
export default function TopicBubbles() {
const load = useStats();
const hover = useHoverTip<HTMLDivElement>();
const { ref, show, hide, node } = useHoverTip<HTMLDivElement>();
const clip = useId().replace(/:/g, "");
if (load.phase !== "ready") {
@@ -70,7 +68,7 @@ export default function TopicBubbles() {
</CardDescription>
</CardHeader>
<CardContent>
<div ref={hover.ref} className="relative mx-auto w-full max-w-xl" onPointerLeave={hover.hide}>
<div ref={ref} className="relative mx-auto w-full max-w-xl" onPointerLeave={hide}>
<svg
viewBox={`${bound.x - bound.r} ${bound.y - bound.r} ${bound.r * 2} ${bound.r * 2}`}
className="h-auto w-full"
@@ -89,7 +87,7 @@ export default function TopicBubbles() {
key={c.issue}
className={cn("transition-opacity hover:opacity-90", !c.taught && "opacity-50")}
onPointerMove={(event) =>
hover.show(
show(
event,
<>
<div className="font-medium">{c.name}</div>
@@ -131,7 +129,7 @@ export default function TopicBubbles() {
);
})}
</svg>
{hover.node}
{node}
</div>
</CardContent>
</Card>
+1 -1
View File
@@ -1,5 +1,5 @@
/**
* What the four progress islands share besides the data: the not-ready body
* What the progress cards share besides the data: the not-ready body
* (a skeleton while /api/stats loads, one muted line if it fails) and the
* hover tooltip for the two charts drawn as plain SVG (the heatmap calendar
* and the topic bubbles — Recharts has neither layout). The tooltip wears the
@@ -0,0 +1,48 @@
---
title: '162. Find Peak Element'
description: A peak element is an element that is strictly greater than its neighbors
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Binary Search", slug: "binary-search" }
issue: "https://github.com/prdlk/leetcode/issues/64"
sidebar:
label: 'Find Peak Element'
badge: 'Medium'
---
::::warning
You must write an algorithm that runs in O(log n) time.
::::
### Example 1:
- Input: `nums = [1,2,3,1]`
- Output: `2`
- Explanation: `3` is a peak element and your function should return the index number `2`.
### Example 2:
- Input: `nums = [1,2,1,3,5,6,4]`
- Output: `5`
- Explanation: Your function can return either index number `1` where the peak element is `2`, or index number `5` where the peak element is `6`.
### Constraints:
- `1 <= nums.length <= 1000`
- `-2^31 <= nums[i] <= 2^31 - 1`
- `nums[i] != nums[i + 1] for all valid i.`
## Solution
```py
class Solution:
def findPeakElement(self, nums: list[int]) -> int:
left, right = 0, len(nums) - 1
while left < right:
mid = (left + right) // 2
if nums[mid] < nums[mid + 1]:
left = mid + 1
else:
right = mid
return left
```
@@ -0,0 +1,63 @@
---
title: '4. Median of Two Sorted Arrays'
description: Given two sorted arrays nums1 and nums2 of size m and n respectively, return the median of the two sorted arrays
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Binary Search", slug: "binary-search" }
- { name: "Divide and Conquer", slug: "divide-and-conquer" }
issue: "https://github.com/prdlk/leetcode/issues/66"
sidebar:
label: 'Median of Two Sorted Arrays'
badge: 'Hard'
---
### Example 1:
- Input: `nums1 = [1,3], nums2 = [2]`
- Output: `2.00000`
- Explanation: merged array = `[1,2,3]` and median is `2`.
### Example 2:
- Input: `nums1 = [1,2], nums2 = [3,4]`
- Output: `2.50000`
- Explanation: merged array = `[1,2,3,4]` and median is `(2 + 3`) / `2 = 2.5`.
### Constraints:
- `nums1.length == m`
- `nums2.length == n`
- `0 <= m <= 1000`
- `0 <= n <= 1000`
- `1 <= m + n <= 2000`
- `-10^6 <= nums1[i], nums2[i] <= 10^6`
## Solution
```py
class Solution:
def findMedianSortedArrays(self, nums1: list[int], nums2: list[int]) -> float:
if len(nums1) > len(nums2):
return self.findMedianSortedArrays(nums2, nums1)
m, n = len(nums1), len(nums2)
left, right = 0, m
while left <= right:
partA = (left + right) // 2
partB = (m + n + 1) // 2 - partA
maxLeftA = float("-inf") if partA == 0 else nums1[partA - 1]
minRightA = float("inf") if partA == m else nums1[partA]
maxLeftB = float("-inf") if partB == 0 else nums2[partB - 1]
minRightB = float("inf") if partB == n else nums2[partB]
if maxLeftA <= minRightB and maxLeftB <= minRightA:
if (m + n) % 2 == 0:
return (max(maxLeftA, maxLeftB) + min(minRightA, minRightB)) / 2
else:
return max(maxLeftA, maxLeftB)
elif maxLeftA > minRightB:
right = partA - 1
else:
left = partA + 1
```
@@ -0,0 +1,56 @@
---
title: '128. Longest Consecutive Sequence'
description: Given an unsorted array of integers nums, return the length of the longest consecutive elements sequence
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Hash Table", slug: "hash-table" }
- { name: "Union-Find", slug: "union-find" }
issue: "https://github.com/prdlk/leetcode/issues/34"
sidebar:
label: 'Longest Consecutive Sequence'
badge: 'Medium'
---
::::warning
You must write an algorithm that runs in O(n) time.
::::
### Example 1:
- Input: `nums = [100,4,200,1,3,2]`
- Output: `4`
- Explanation: The longest consecutive elements sequence is [1, `2`, `3`, 4]. Therefore its length is `4`.
### Example 2:
- Input: `nums = [0,3,7,2,5,8,4,6,0,1]`
- Output: `9`
### Example 3:
- Input: `nums = [1,0,1,2]`
- Output: `3`
### Constraints:
- `0 <= nums.length <= 10^5`
- `-10^9 <= nums[i] <= 10^9`
## Solution
```py
class Solution:
def longestConsecutive(self, nums: list[int]) -> int:
seen = set(nums)
best = 0
for n in seen:
if n - 1 in seen:
continue
length = 1
while length + n in seen:
length += 1
best = max(length, best)
return best
```
@@ -0,0 +1,57 @@
---
title: '290. Word Pattern'
description: Given a pattern and a string s, find if s follows the same pattern
icon: dot
topics:
- { name: "Hash Table", slug: "hash-table" }
- { name: "String", slug: "string" }
issue: "https://github.com/prdlk/leetcode/issues/35"
sidebar:
label: 'Word Pattern'
badge: 'Easy'
---
### Example 1:
- Input: `pattern = "abba", s = "dog cat cat dog"`
- Output: `true`
- Explanation: The bijection can be established as: • 'a' maps to "dog". • 'b' maps to "cat".
### Example 2:
- Input: `pattern = "abba", s = "dog cat cat fish"`
- Output: `false`
### Example 3:
- Input: `pattern = "aaaa", s = "dog cat cat dog"`
- Output: `false`
### Constraints:
- `1 <= pattern.length <= 300`
- `pattern` contains only lower-case English letters.
- `1 <= s.length <= 3000`
- `s` contains only lowercase English letters and spaces ' '.
- `s` does not contain any leading or trailing spaces.
- All the words in `s` are separated by a single space.
## Solution
```py
class Solution:
def wordPattern(self, pattern: str, s: str) -> bool:
if len(pattern) != len(s.split()):
return False
words = s.split()
map = {}
for i in range(len(pattern)):
c = pattern[i]
w = words[i]
if c in map and map[c] != w:
return False
else:
map[c] = w
return len(set(pattern)) == len(set(words))
```
@@ -4,8 +4,6 @@ description: 'A key maps to a value in constant time, which removes the need for
hidden: true
---
## Hash Tables
Sometimes when programming, we need a data structure that maps an arbitrary data type to another arbitrary data type, like an array with non-integer indices. For example, a phone book that allows lookup by a person's name requires such data type.
A **hash table** (or a hash map) is one way of representing this data structure. The idea of hash table is this:
@@ -14,7 +12,9 @@ Data is stored in a fix-sized array, where each data value has an unique index.
<Frame caption="Hash Table Operations">
<img src="../../../public/hash-table-operations.svg" alt="Hash Table Operations" />
![](../../../public/hash-table-operations.svg)
</Frame>
@@ -22,7 +22,7 @@ Data is stored in a fix-sized array, where each data value has an unique index.
Assuming the hash function uses constant time, the average time complexity is `O(n / k)` for each insertion/access operation on the table, where `n` is the number of entries in the hash table and `k` is the array size of the table. This assumes the hash function distributes keys uniformly across the `k` buckets, so each bucket holds about `n / k` entries on average.
**Frequency Counting**
### Frequency Counting
```python
def get_counter(arr: list[int]) -> dict[int, int]:
@@ -39,10 +39,12 @@ def get_counter(arr: list[int]) -> dict[int, int]:
return counter
```
::: tip[Python provides a built-in `collections.Counter` class]
:::tip
Python provides a built-in [`collections.Counter`](https://docs.python.org/3/library/collections.html#collections.Counter) class that does this for us.
:::
```python
from collections import Counter
fq = Counter(arr)
```
:::
@@ -0,0 +1,59 @@
---
title: '496. Next Greater Element I'
description: The next greater element of some element x in an array is the first greater element that is to the right of x in the same array
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Hash Table", slug: "hash-table" }
- { name: "Stack", slug: "stack" }
- { name: "Monotonic Stack", slug: "monotonic-stack" }
issue: "https://github.com/prdlk/leetcode/issues/74"
sidebar:
label: 'Next Greater Element I'
badge: 'Easy'
---
::::warning
Could you find an O(nums1.length + nums2.length) solution?
::::
### Example 1:
- Input: `nums1 = [4,1,2], nums2 = [1,3,4,2]`
- Output: `[-1,3,-1]`
- Explanation:
- The next greater element for each value of `nums1` is as follows:
- `4` is underlined in `nums2 = [1,3,4,2]`. There is no next greater element, so the answer is `-1`.
- `1` is underlined in `nums2 = [1,3,4,2]`. The next greater element is `3`.
- `2` is underlined in `nums2 = [1,3,4,2]`. There is no next greater element, so the answer is `-1`.
### Example 2:
- Input: `nums1 = [2,4], nums2 = [1,2,3,4]`
- Output: `[3,-1]`
- Explanation:
- The next greater element for each value of `nums1` is as follows:
- `2` is underlined in `nums2 = [1,2,3,4]`. The next greater element is `3`.
- `4` is underlined in `nums2 = [1,2,3,4]`. There is no next greater element, so the answer is `-1`.
### Constraints:
- `1 <= nums1.length <= nums2.length <= 1000`
- `0 <= nums1[i], nums2[i] <= 10^4`
- All integers in `nums1` and `nums2` are unique.
- All the integers of `nums1` also appear in nums2.
## Solution
```py
class Solution:
def nextGreaterElement(self, nums1: list[int], nums2: list[int]) -> list[int]:
mono_stack = []
next_greater = {}
for cur in nums2:
while mono_stack and cur > mono_stack[-1]:
val = mono_stack.pop()
next_greater[val] = cur
mono_stack.append(cur)
return [next_greater.get(x, -1) for x in nums1]
```
@@ -0,0 +1,51 @@
---
title: '84. Largest Rectangle in Histogram'
description: Given an array of integers heights representing the histogram's bar height where the width of each bar is 1, return the area of the largest rectangle in the histogram
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Stack", slug: "stack" }
- { name: "Monotonic Stack", slug: "monotonic-stack" }
- { name: "Range Minimum/Maximum Query", slug: "range-minimum-maximum-query" }
issue: "https://github.com/prdlk/leetcode/issues/76"
sidebar:
label: 'Largest Rectangle in Histogram'
badge: 'Hard'
---
### Example 1:
- Input: `heights = [2,1,5,6,2,3]`
- Output: `10`
- Explanation: The above is a histogram where width of each bar is `1`. The largest rectangle is shown in the red area, which has an area = `10` units.
### Example 2:
- Input: `heights = [2,4]`
- Output: `4`
### Constraints:
- `1 <= heights.length <= 10^5`
- `0 <= heights[i] <= 10^4`
## Solution
```py
class Solution:
def largestRectangleArea(self, heights: list[int]) -> int:
maxArea = 0
stack = []
for i, h in enumerate(heights):
start = i
while stack and stack[-1][1] > h:
index, height = stack.pop()
width = i - index
maxArea = max(maxArea, height * width)
start = index
stack.append((start, h))
for i, h in stack:
maxArea = max(maxArea, h * (len(heights) - i))
return maxArea
```
@@ -0,0 +1,45 @@
---
title: '901. Online Stock Span'
description: Design an algorithm that collects daily price quotes for some stock and returns the span of that stock's price for the current day
icon: dot
topics:
- { name: "Stack", slug: "stack" }
- { name: "Design", slug: "design" }
- { name: "Monotonic Stack", slug: "monotonic-stack" }
- { name: "Data Stream", slug: "data-stream" }
issue: "https://github.com/prdlk/leetcode/issues/75"
sidebar:
label: 'Online Stock Span'
badge: 'Medium'
---
### Example 1:
- Input: ``
- Output: ``
- Explanation: Input ["StockSpanner", "next", "next", "next", "next", "next", "next", "next"] `[[]`, `[100]`, `[80]`, `[60]`, `[70]`, `[60]`, `[75]`, [85]] Output [null, `1`, `1`, `1`, `2`, `1`, `4`, 6] Explanation StockSpanner stockSpanner = new StockSpanner(); stockSpanner.next(100); // return `1` stockSpanner.next(80); // return `1` stockSpanner.next(60); // return `1` stockSpanner.next(70); // return `2` stockSpanner.next(60); // return `1` stockSpanner.next(75); // return `4`, because the last `4` prices (including today's `price` of `75`) were less than or equal to today's `price`. stockSpanner.next(85); // return `6`
### Constraints:
- `1 <= price <= 10^5`
- At most 10^4 calls will be made to next.
## Solution
```py
class StockSpanner:
def __init__(self):
self.stack = []
def next(self, price: int) -> int:
ans = 1
while self.stack and self.stack[-1][0] <= price:
ans += self.stack.pop()[1]
self.stack.append([price, ans])
return ans
# Your StockSpanner object will be instantiated and called as such:
# obj = StockSpanner()
# param_1 = obj.next(price)
```
@@ -3,3 +3,34 @@ title: Monotonic Stack
description: 'A stack keeps its items in sorted order, which finds the next larger or smaller item in one pass.'
hidden: true
---
<YouTube id="Dq_ObZwTY_Q" title="Monotonic Stack" />
A monotonic stack keeps its values in sorted order as you push elements. The order can be increasing or decreasing, depending on the problem.
A decreasing monotonic stack is the most common version. Before pushing a new value `x`, we pop from the top while the top is smaller than or equal to `x`. After those pops, pushing `x` keeps the stack decreasing from bottom to top.
This push rule is the whole idea. A monotonic deque uses the same rule, but a deque also lets you remove old elements from the front, which is useful for sliding window problems.
## Example Implementation
This implementation maintains a decreasing monotonic stack.
```python
def mono_stack(insert_entries):
stack = []
for entry in insert_entries:
# Pop smaller (or equal) values so stack stays decreasing.
while stack and stack[-1] <= entry:
stack.pop()
# Do something with the popped item here
stack.append(entry)
```
:::tip
In interview problems, the stack usually stores indices instead of values. You can read the value from the original array when needed, and the index gives you distance information such as **"how many steps away is the next greater element."**
:::
For an array `nums`, if index `j` pops when you process index `i`, then `i` is the first index to the right that satisfies the stack condition for `j`. In a next-greater-element problem, that means `nums[i]` is the next greater value for `nums[j]`.
@@ -0,0 +1,53 @@
---
title: '525. Contiguous Array'
description: Given a binary array nums, return the maximum length of a contiguous subarray with an equal number of 0 and 1
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Hash Table", slug: "hash-table" }
- { name: "Prefix Sum", slug: "prefix-sum" }
issue: "https://github.com/prdlk/leetcode/issues/39"
sidebar:
label: 'Contiguous Array'
badge: 'Medium'
---
### Example 1:
- Input: `nums = [0,1]`
- Output: `2`
- Explanation: [0, 1] is the longest contiguous subarray with an equal number of `0` and `1`.
### Example 2:
- Input: `nums = [0,1,0]`
- Output: `2`
- Explanation: [0, 1] (or [1, 0]) is a longest contiguous subarray with equal number of `0` and `1`.
### Example 3:
- Input: `nums = [0,1,1,1,1,1,0,0,0]`
- Output: `6`
- Explanation: `[1,1,1,0,0,0]` is the longest contiguous subarray with equal number of `0` and `1`.
### Constraints:
- `1 <= nums.length <= 10^5`
- `nums[i]` is either 0 or 1.
## Solution
```py
class Solution:
def findMaxLength(self, nums: List[int]) -> int:
map = {0: -1}
count = 0
longest = 0
for i, num in enumerate(nums):
count += 1 if num == 1 else -1
if count in map:
longest = max(longest, i - map[count])
else:
map[count] = i
return longest
```
@@ -0,0 +1,52 @@
---
title: '1004. Max Consecutive Ones III'
description: Given a binary array nums and an integer k, return the maximum number of consecutive 1's in the array if you can flip at most k 0's
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Binary Search", slug: "binary-search" }
- { name: "Sliding Window", slug: "sliding-window" }
- { name: "Prefix Sum", slug: "prefix-sum" }
issue: "https://github.com/prdlk/leetcode/issues/56"
sidebar:
label: 'Max Consecutive Ones III'
badge: 'Medium'
---
### Example 1:
- Input: `nums = [1,1,1,0,0,0,1,1,1,1,0], k = 2`
- Output: `6`
- Explanation: `[1,1,1,0,0,1,1,1,1,1,1]` Bolded numbers were flipped from `0` to `1`. The longest subarray is underlined.
### Example 2:
- Input: `nums = [0,0,1,1,0,0,1,1,1,0,1,1,0,0,0,1,1,1,1], k = 3`
- Output: `10`
- Explanation: `[0,0,1,1,1,1,1,1,1,1,1,1,0,0,0,1,1,1,1]` Bolded numbers were flipped from `0` to `1`. The longest subarray is underlined.
### Constraints:
- `1 <= nums.length <= 10^5`
- `nums[i]` is either 0 or 1.
- `0 <= k <= nums.length`
## Solution
```py
class Solution:
def longestOnes(self, nums: list[int], k: int) -> int:
left = 0
cur = 0
ans = 0
for right in range(len(nums)):
if nums[right] == 0:
cur += 1
while cur > k:
if nums[left] == 0:
cur -= 1
left += 1
ans = max(ans, right - left + 1)
return ans
```
@@ -0,0 +1,46 @@
---
title: '643. Maximum Average Subarray I'
description: You are given an integer array nums consisting of n elements, and an integer k
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Sliding Window", slug: "sliding-window" }
issue: "https://github.com/prdlk/leetcode/issues/55"
sidebar:
label: 'Maximum Average Subarray I'
badge: 'Easy'
---
### Example 1:
- Input: `nums = [1,12,-5,-6,50,3], k = 4`
- Output: `12.75000`
- Explanation: Maximum average is `(12 - 5 - 6 + 50`) / `4 = 51 / 4 = 12.75`
### Example 2:
- Input: `nums = [5], k = 1`
- Output: `5.00000`
### Constraints:
- `n == nums.length`
- `1 <= k <= n <= 10^5`
- `-10^4 <= nums[i] <= 10^4`
## Solution
```py
class Solution:
def findMaxAverage(self, nums: list[int], k: int) -> float:
window_sum = 0
for i in range(k):
window_sum += nums[i]
largest = window_sum
for right in range(k, len(nums)):
left = right - k
window_sum -= nums[left]
window_sum += nums[right]
largest = max(largest, window_sum)
return largest / k
```
@@ -0,0 +1,46 @@
---
title: '76. Minimum Window Substring'
description: Given two strings s and t of lengths m and n respectively, return the minimum window substring of s such that every character in t (including duplicates) is included in the window. If there is no such substring, return the empty string ""
icon: dot
topics:
- { name: "Hash Table", slug: "hash-table" }
- { name: "String", slug: "string" }
- { name: "Sliding Window", slug: "sliding-window" }
issue: "https://github.com/prdlk/leetcode/issues/57"
sidebar:
label: 'Minimum Window Substring'
badge: 'Hard'
---
::::warning
Could you find an algorithm that runs in O(m + n) time?
::::
### Example 1:
- Input: `s = "ADOBECODEBANC", t = "ABC"`
- Output: `"BANC"`
- Explanation: The minimum window substring "BANC" includes 'A', 'B', and 'C' from string `t`.
### Example 2:
- Input: `s = "a", t = "a"`
- Output: `"a"`
- Explanation: The entire string `s` is the minimum window.
### Example 3:
- Input: `s = "a", t = "aa"`
- Output: `""`
- Explanation: Both 'a's from `t` must be included in the window. Since the largest window of `s` only has one 'a', return empty string.
### Constraints:
- `m == s.length`
- `n == t.length`
- `1 <= m, n <= 10^5`
- `s` and `t` consist of uppercase and lowercase English letters.
## Solution
```py
class Solution:
def minWindow(self, s: str, t: str) -> str:
```
@@ -4,6 +4,10 @@ description: 'Sliding window is a specialized two-pointer family where the point
hidden: true
---
<YouTube id="tSTO9w3piAo" title="Sliding Window" />
Many problems have two moving boundaries. That alone does not make them sliding window problems.
A problem belongs to this family when:
@@ -44,18 +48,14 @@ The window size never changes. The main task is updating the window state effici
```python
def fixed_window(nums, k):
window_sum = 0
best = float('-inf')
for right in range(len(nums)):
window_sum += nums[right] # add the new element
if right >= k - 1: # window is full
best = max(best, window_sum) # record answer
window_sum -= nums[right - k + 1] # remove the oldest element
return best
def sliding_window_fixed(input, window_size):
ans = window = input[0:window_size]
for right in range(window_size, len(input)):
left = right - window_size
remove input[left] from window
append input[right] to window
ans = optimal(ans, window)
return ans
```
<Steps>
@@ -83,24 +83,43 @@ Here the window expands until invalid, then shrinks until valid again.
</Frame>
<Tabs inline>
<Tab title="Longest Window">
```python
def flexible_window(nums, target):
def sliding_window_flexible_longest(input):
initialize window, ans
left = 0
window_sum = 0
best = 0
for right in range(len(nums)):
window_sum += nums[right] # grow the window
while window_sum > target: # window is invalid
window_sum -= nums[left] # shrink from the left
for right in range(len(input)):
append input[right] to window
while invalid(window): # update left until window is valid again
remove input[left] from window
left += 1
ans = max(ans, window) # window is guaranteed to be valid here
return ans
best = max(best, right - left + 1) # window is valid, record answer
return best
```
</Tab>
<Tab title="Shortest Window">
```python
def sliding_window_flexible_shortest(input):
initialize window, ans
left = 0
for right in range(len(input)):
append input[right] to window
while valid(window):
ans = min(ans, window) # window is guaranteed to be valid here
remove input[left] from window
left += 1
return ans
```
</Tab>
</Tabs>
<Steps>
<Step title="">
Add `nums[right]` to the window.
@@ -115,26 +134,6 @@ def flexible_window(nums, target):
---
For "shortest window" problems, swap the logic: use `while` for the *valid* case, record the answer inside the loop, then shrink.
```python
def shortest_valid_window(nums, target):
left = 0
window_sum = 0
best = float('inf')
for right in range(len(nums)):
window_sum += nums[right]
while window_sum >= target: # window is valid
best = min(best, right - left + 1) # record, then try to shrink
window_sum -= nums[left]
left += 1
return best if best != float('inf') else 0
```
## 3. Frequency-tracking windows
:::tip
@@ -0,0 +1,43 @@
---
title: '224. Basic Calculator'
description: Given a string s representing a valid expression, implement a basic calculator to evaluate it, and return the result of the evaluation
icon: dot
topics:
- { name: "Math", slug: "math" }
- { name: "String", slug: "string" }
- { name: "Stack", slug: "stack" }
- { name: "Recursion", slug: "recursion" }
issue: "https://github.com/prdlk/leetcode/issues/71"
sidebar:
label: 'Basic Calculator'
badge: 'Hard'
---
### Example 1:
- Input: `s = "1 + 1"`
- Output: `2`
### Example 2:
- Input: `s = " 2-1 + 2 "`
- Output: `3`
### Example 3:
- Input: `s = "(1+(4+5+2)-3)+(6+8)"`
- Output: `23`
### Constraints:
- `1 <= s.length <= 3 * 10^5`
- `s` consists of digits, '+', '-', '(', ')', and ' '.
- `s` represents a valid expression.
- '+' is not used as a unary operation (i.e., "+1" and "+(2 + 3)" is invalid).
- '-' could be used as a unary operation (i.e., "-1" and "-(2 + 3)" is valid).
- There will be no two consecutive operators in the input.
- Every number and running calculation will fit in a signed 32-bit integer.
## Solution
```py
class Solution:
def calculate(self, s: str) -> int:
```
@@ -0,0 +1,62 @@
---
title: '71. Simplify Path'
description: You are given an absolute path for a Unix-style file system, which always begins with a slash '/'. Your task is to transform this absolute path into its simplified canonical path
icon: dot
topics:
- { name: "String", slug: "string" }
- { name: "Stack", slug: "stack" }
issue: "https://github.com/prdlk/leetcode/issues/70"
sidebar:
label: 'Simplify Path'
badge: 'Medium'
---
### Example 1:
- Input: `path = "/home/"`
- Output: `"/home"`
- Explanation: The trailing slash should be removed.
### Example 2:
- Input: `path = "/home//foo/"`
- Output: `"/home/foo"`
- Explanation: Multiple consecutive slashes are replaced by a single one.
### Example 3:
- Input: `path = "/home/user/Documents/../Pictures"`
- Output: `"/home/user/Pictures"`
- Explanation: A double period ".." refers to the directory up a level (the parent directory).
### Example 4:
- Input: `path = "/../"`
- Output: `"/"`
- Explanation: Going one level up from the root directory is not possible.
### Example 5:
- Input: `path = "/.../a/../b/c/../d/./"`
- Output: `"/.../b/d"`
- Explanation: "..." is a valid name for a directory in this problem.
### Constraints:
- `1 <= path.length <= 3000`
- `path` consists of English letters, digits, period '.', slash '/' or '_'.
- `path` is a valid absolute Unix path.
## Solution
```py
class Solution:
def simplifyPath(self, path: str) -> str:
stack = []
segments = [item for item in path.split("/") if item]
for p in segments:
if stack and p == "..":
stack.pop()
elif not stack and p == ".." or p == ".":
continue
else:
stack.append(p)
return "/" + "/".join(stack)
```
@@ -3,3 +3,60 @@ title: Stack
description: 'Items are added and removed from one end, so the last item added is the first item removed.'
hidden: true
---
A stack works like a stack of plates in a cafeteria. You add new plates on top. When you need a plate, you take from the top. The last plate you added is the first one you remove.
This is **Last-In-First-Out (LIFO)**. The most recent item goes out first.
![](../../../public/stack-plates-metaphor.svg)
## Core Stack Operations
A stack has two fundamental operations:
- **Push**: add an item to the top of the stack
- **Pop**: remove the item at the top of the stack
Some stacks have additional operations:
- **Peek**: get the item at the top of the stack without removing it
- **IsEmpty**: check if the stack is empty
![](../../../public/stack-operations-sequence.svg)
```python
# Using Python list as a stack
stack = []
# Push elements
stack.append(10)
stack.append(20)
stack.append(30)
# Stack: [10, 20, 30] (30 is on top)
# Pop element
top = stack.pop() # Returns 30
# Stack: [10, 20]
# Peek at top
if stack:
top_element = stack[-1] # Returns 20 without removing
```
Python lists provide `append()` for pushing and `pop()` for popping. The `peek()` method is not built-in, but you can implement it yourself.
## Last in, First out
The defining characteristic of a stack is LIFO order. Whatever you put in last comes out first. This is different from a queue, which is First-In-First-Out (FIFO).
Add items: A, B, C
- Stack: [A, B, C] (C on top)
Remove items:
- First pop: C
- Second pop: B
- Third pop: A
The order reverses. Items come out in the opposite order they went in.
@@ -0,0 +1,50 @@
---
title: '189. Rotate Array'
description: Given an integer array nums, rotate the array to the right by k steps, where k is non-negative
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Math", slug: "math" }
- { name: "Two Pointers", slug: "two-pointers" }
issue: "https://github.com/prdlk/leetcode/issues/47"
sidebar:
label: 'Rotate Array'
badge: 'Medium'
---
### Example 1:
- Input: `nums = [1,2,3,4,5,6,7], k = 3`
- Output: `[5,6,7,1,2,3,4]`
- Explanation: rotate `1` steps to the right: `[7,1,2,3,4,5,6]` rotate `2` steps to the right: `[6,7,1,2,3,4,5]` rotate `3` steps to the right: `[5,6,7,1,2,3,4]`
### Example 2:
- Input: `nums = [-1,-100,3,99], k = 2`
- Output: `[3,99,-1,-100]`
- Explanation: rotate `1` steps to the right: `[99,-1,-100,3]` rotate `2` steps to the right: `[3,99,-1,-100]`
### Constraints:
- `1 <= nums.length <= 10^5`
- `-2^31 <= nums[i] <= 2^31 - 1`
- `0 <= k <= 10^5`
- Try to come up with as many solutions as you can. There are at least three different ways to solve this problem.
- Could you do it in-place with O(1) extra space?
## Solution
```py
class Solution:
def rotate(self, nums: list[int], k: int) -> None:
N = len(nums) - 1
cut = k % len(nums)
def reverse(l, r):
while l <= r:
nums[l], nums[r] = nums[r], nums[l]
l += 1
r -= 1
reverse(0, N)
reverse(0, cut - 1)
reverse(cut, N)
```
@@ -0,0 +1,41 @@
---
title: '80. Remove Duplicates from Sorted Array II'
description: Given an integer array nums sorted in non-decreasing order, remove some duplicates in-place such that each unique element appears at most twice. The relative order of the elements should be kept the same
icon: dot
topics:
- { name: "Array", slug: "array" }
- { name: "Two Pointers", slug: "two-pointers" }
issue: "https://github.com/prdlk/leetcode/issues/48"
sidebar:
label: 'Remove Duplicates from Sorted Array II'
badge: 'Medium'
---
### Example 1:
- Input: `nums = [1,1,1,2,2,3]`
- Output: `5, nums = [1,1,2,2,3,_]`
- Explanation: Your function should return `k = 5`, with the first five elements of `nums` being `1`, `1`, `2`, `2` and `3` respectively. It does not matter what you leave beyond the returned `k` (hence they are underscores).
### Example 2:
- Input: `nums = [0,0,1,1,1,1,2,3,3]`
- Output: `7, nums = [0,0,1,1,2,3,3,_,_]`
- Explanation: Your function should return `k = 7`, with the first seven elements of `nums` being `0`, `0`, `1`, `1`, `2`, `3` and `3` respectively. It does not matter what you leave beyond the returned `k` (hence they are underscores).
### Constraints:
- `1 <= nums.length <= 3 * 10^4`
- `-10^4 <= nums[i] <= 10^4`
- `nums` is sorted in non-decreasing order.
## Solution
```py
class Solution:
def removeDuplicates(self, nums: list[int]) -> int:
slow = 0
for fast in range(len(nums)):
if slow < 2 or nums[fast] != nums[slow - 2]:
nums[slow] = nums[fast]
slow += 1
return slow
```
@@ -3,3 +3,160 @@ title: Two Pointers
description: 'Two indices move through the data from different positions and meet at the answer.'
hidden: true
---
Most interview problems in this section fall into one of four families:
- **Same direction pointers**: one pointer reads, one pointer writes, or one pointer stays behind another while scanning left to right.
- **Opposite direction pointers**: one pointer starts on the left, one on the right, and each move eliminates impossible answers.
- **Sliding window**: the two pointers define a contiguous interval, and you maintain some property of that interval as the window expands and shrinks.
- **Fast and slow pointers**: both pointers move through a linked structure, but at different speeds or with a fixed gap.
![](../../../public/two-pointers-decision-tree.svg)
## Same Direction
Same-direction pointer problems usually scan from left to right, but the two pointers do different jobs.
Usually:
- one pointer reads the next value from the original input
- one pointer marks where the next kept value should go
Sometimes both pointers move forward together, but one pointer stays behind the other to preserve a gap or a relationship.
### When this family applies
Reach for same-direction pointers when the problem asks you to:
- rewrite an array in-place
- remove or compress elements without extra memory
- keep only the values that satisfy some condition
- maintain a fixed gap between two forward-moving pointers
### Core Invariant
The invariant in this family is usually about the prefix you have already processed.
Typical invariant:
> "Everything before the write pointer is already in the correct final form."
That is what makes these problems feel different from opposite-direction scans. You are not searching from both sides. You are building the answer progressively as you sweep through the input.
### Subtypes in this section
#### 1. Compaction / Overwrite
The slow pointer marks the next output position. The fast pointer scans the input. When the current value should be kept, copy it forward and advance the slow pointer.
![](../../../public/same-direction-compaction.svg)
#### 2. Relative Speed
Both pointers move forward, but one moves twice as fast. The invariant is about their distance and how much of the structure each pointer has consumed.
![](../../../public/same-direction-relative-speed.svg)
#### 3. Fixed Gap
Both pointers move forward while preserving a gap. The invariant is not about a rewritten prefix. It is about the distance between the pointers.
![](../../../public/same-direction-fixed-gap.svg)
### What changes across problems
The family stays the same, but the invariant changes slightly:
- in array compaction, the invariant is about the prefix being correct
- in fast/slow midpoint problems, the invariant is about relative speed
- in fixed-gap problems, the invariant is about distance
So the unifying idea is not "both pointers move right." The unifying idea is:
**"the structure to the left of the pointers already has a known meaning"**
### What to watch out for
Common mistakes:
- Updating the write pointer before copying the current value.
- Forgetting exactly what region is already valid.
- Losing track of whether the invariant is about a prefix, a gap, or relative speed.
As you go through the following problems, pay attention to one question:
**What does the region before the slow pointer mean right now?**
## Opposite Direction
Opposite-direction pointer problems place one pointer on the left and one on the right, then move inward.
The hard part is never the loop itself. **The hard part is justifying which pointer is safe to move.**
### When this family applies
Reach for opposite-direction pointers when the problem asks you to:
- the input is sorted, or can be made monotonic by sorting
- you are comparing a left choice and a right choice
- the problem has symmetry
- moving one side lets you eliminate a set of impossible answers
### Core Invariant
The invariant in this family is usually:
> "All candidate answers outside the current `[left, right]` range have already been ruled out."
That means each move must come with a proof. If you move a pointer without knowing why the discarded cases cannot contain the optimum, the algorithm is just guessing.
### Subtypes in this section
#### 1. Monotonic sum elimination
Because the array is sorted:
- if the sum is too small, moving the left pointer right is the only useful move
- if the sum is too large, moving the right pointer left is the only useful move
Sorted order gives you a monotonic rule.
![](../../../public/opposite-direction-monotonic-sum.svg)
#### 2. Symmetry checking
Here the pointers compare mirrored positions. The movement rule is mostly "move both," except when you must skip punctuation or spaces.
![](../../../public/opposite-direction-symmetry.svg)
#### 3. Bottleneck elimination
This one is important because it looks different from the other two. The key idea is that the shorter wall limits the area. If you keep that shorter wall and decrease the width, you cannot improve the answer. So it is safe to discard that wall.
![](../../../public/opposite-direction-bottleneck.svg)
### Why this is not a sliding window
Opposite-direction scans do not maintain an interval property like "this window is valid."
Instead, each step does one of these:
- rules out sums that are too small or too large
- checks mirrored characters
- discards a bottleneck boundary
The interval itself is just the remaining search space.
### What to watch out for
Common mistakes:
- Using two pointers on an unsorted pair problem without first creating monotonicity.
- Moving the taller wall in Container With Most Water without an elimination proof.
- Calling the method sliding window just because two boundaries exist.
As you read the problems in this family, keep asking:
**What exact set of candidate answers did this pointer move eliminate?**
Loaded 100 of 192 files, more files were not shown because too many files have changed in this diff. Show more