chore: clean up content phase organization

This commit is contained in:
Prad Nukala committed 2026-09-25 14:00:22 -04:00
1 parent 5dc2265ee5
commit 6f9e4ecb3b
14 files changed
+706 -45

No files matched your search

+3 -40
View File
@@ -7,6 +7,7 @@
* `set:optional` and `set:deferred` are extra credit. A topic is finished when
* every one of its core sub-issues is closed; optional/deferred state is
* ignored, matching the day rule in each topic body ("Core problems first").
* The rule lives in topics.ts, shared with release-topics.ts.
*
* Like close-solved.ts this reconciles state instead of reacting to an event
* payload: re-runs are no-ops, backfilling needs no special casing, and only
@@ -19,6 +20,7 @@
*/
import { github } from "./github.ts";
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
import { coreSet, readProblems } from "./topics.ts";
const DRY =
process.argv.includes("--dry-run") ||
@@ -53,52 +55,13 @@ for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=ope
}
topics.sort((a, b) => a.number - b.number);
// ── core sub-issue state per topic ───────────────────────────────
interface Core {
/** Core sub-issues, closed and open alike, lowest number first. */
all: number[];
/** The core sub-issues still open — non-empty means the topic stays open. */
pending: number[];
}
/**
* Read a topic's core sub-issue state. The sub_issues payload carries the full
* issue objects (state + labels), so no per-problem follow-up request is needed.
*/
async function readCore(topic: number): Promise<Core> {
const all: number[] = [];
const pending: number[] = [];
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) {
if (!raw || typeof raw !== "object") continue;
if (!("number" in raw) || typeof raw.number !== "number") continue;
if (!("state" in raw) || typeof raw.state !== "string") continue;
if (!("labels" in raw) || !Array.isArray(raw.labels)) continue;
const core = raw.labels.some(
(label) =>
label &&
typeof label === "object" &&
"name" in label &&
label.name === "set:core",
);
if (!core) continue;
all.push(raw.number);
if (raw.state === "open") pending.push(raw.number);
}
all.sort((a, b) => a - b);
pending.sort((a, b) => a - b);
return { all, pending };
}
// ── reconcile ────────────────────────────────────────────────────
const report: string[][] = [];
let closed = 0;
for (const topic of topics) {
const core = await readCore(topic.number);
const core = coreSet(await readProblems(gh, topic.number));
const progress = `${core.all.length - core.pending.length}/${core.all.length}`;
// A topic with no core sub-issues has nothing to complete: never close it,
+2 -1
View File
@@ -10,7 +10,8 @@
"sync": "bun ./sync.ts",
"videos": "bun ./videos.ts",
"close-solved": "bun ./close-solved.ts",
"close-topics": "bun ./close-topics.ts"
"close-topics": "bun ./close-topics.ts",
"release-topics": "bun ./release-topics.ts"
},
"devDependencies": {
"@clack/prompts": "^1.7.0",
+333
View File
@@ -0,0 +1,333 @@
#!/usr/bin/env bun
/**
* Publish one GitHub release per finished topic, versioned by timever
* (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC — https://gist.github.com/twolfson/de1b004dd22536b8e668).
*
* "Finished" is topics.ts's rule — every core sub-issue closed — the same one
* close-topics.ts closes the topic issue on, and the version is the instant
* the LAST core problem closed. Keying off the sub-issues rather than the
* topic issue lets this run as the tail of Close Solved: the push that closes
* a topic's final core problem releases it in the same run, without waiting
* for close-topics to chain after it. The topic issue itself closes seconds
* later, on the same day.
*
* Each release is a changelog snapshot at that instant: the topic's problem
* sub-issues grouped as closed on the finish day (ET), closed earlier, and
* open at release, each with its set, difficulty, and the solution files as
* they stood in the tagged commit — the last main commit before the finish.
*
* The tag is annotated and backdated (tagger date = finish), so
* `git tag --sort=taggerdate` reads as the campaign's history even for tags
* minted by a backfill. The release's own published date cannot be backdated
* (the API has no field for it), and the docs changelog (githubReleases in
* apps/docs/blume.config.ts) dates entries by it — so a backfilled entry
* carries its real finish time in the version and the body's first sentence.
*
* Reconciles instead of reacting: every finished topic without a release gets
* one, so a first run backfills the whole campaign; an existing release keeps
* its tag and commit and only has its body rewritten when render() would say
* something else. Re-runs with nothing new are no-ops.
* A release is found by the marker comment in its body, never by tag name, so
* reopening and re-closing a core problem can never mint a second release.
* A tag that exists without its release (a run that died between the two) is
* reused, not re-created.
*
* bun apps/cli/release-topics.ts # tag, push, release, fix bodies
* bun apps/cli/release-topics.ts --dry-run # print the would-be releases
*
* Auth: GH_TOKEN / GITHUB_TOKEN, else `gh auth token` (needs contents: write).
* Git: a clone with full history, an `origin` it may push tags to, and a
* committer identity (the tagger).
*/
import { etDate } from "../api/src/srs.ts";
import { github } from "./github.ts";
import { markdownTable, printTable, writeStepSummary } from "./report.ts";
import { coreSet, labelValue, readProblems, type Problem } from "./topics.ts";
import { ROOT, parseWorkPath } from "./work.ts";
const DRY =
process.argv.includes("--dry-run") ||
process.env.DRY_RUN === "1" ||
process.env.DRY_RUN === "true";
/** The docs site (apps/docs/blume.config.ts `deployment.site`); sync publishes `/<num>` short links. */
const DOCS_SITE = "https://lc.prd.lk";
const TIMEVER = "https://gist.github.com/twolfson/de1b004dd22536b8e668";
const gh = await github();
function git(...args: string[]): string {
const run = Bun.spawnSync(["git", ...args], { cwd: ROOT });
if (run.exitCode !== 0) throw new Error(`git ${args.join(" ")}: ${run.stderr.toString().trim()}`);
return run.stdout.toString().trim();
}
// ── topics ───────────────────────────────────────────────────────
interface Topic {
number: number;
title: string;
milestone: string | null;
body: string;
}
/** Narrow one /issues element; the `topic` label filter cannot exclude PRs. */
function readTopic(value: unknown): Topic | undefined {
if (!value || typeof value !== "object") return;
if ("pull_request" in value) return;
if (!("number" in value) || typeof value.number !== "number") return;
if (!("title" in value) || typeof value.title !== "string") return;
const milestone =
"milestone" in value &&
value.milestone &&
typeof value.milestone === "object" &&
"title" in value.milestone &&
typeof value.milestone.title === "string"
? value.milestone.title
: null;
const body = "body" in value && typeof value.body === "string" ? value.body : "";
return { number: value.number, title: value.title, milestone, body };
}
interface Finished {
topic: Topic;
problems: Problem[];
core: number[];
finishedAt: string;
}
// Closed and open topics alike: the rule reads the sub-issues, so a topic
// finished moments ago (close-topics not yet run) releases now.
const finished: Finished[] = [];
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
const topic = readTopic(raw);
if (!topic) continue;
const problems = await readProblems(gh, topic.number);
const core = coreSet(problems);
if (core.finishedAt) finished.push({ topic, problems, core: core.all, finishedAt: core.finishedAt });
}
// Oldest first, so a backfill creates releases in campaign order.
finished.sort((a, b) => a.finishedAt.localeCompare(b.finishedAt) || a.topic.number - b.topic.number);
// ── existing releases ────────────────────────────────────────────
/** Topic -> its release. The body is kept in sync with render() on every run. */
const released = new Map<number, { id: number; tag: string; body: string }>();
for await (const raw of gh.list(`/repos/${gh.repo}/releases`)) {
if (!raw || typeof raw !== "object") continue;
if (!("id" in raw) || typeof raw.id !== "number") continue;
if (!("tag_name" in raw) || typeof raw.tag_name !== "string") continue;
if (!("body" in raw) || typeof raw.body !== "string") continue;
const m = raw.body.match(/<!-- release-topics: #(\d+) -->/);
if (m) released.set(Number(m[1]), { id: raw.id, tag: raw.tag_name, body: raw.body });
}
// ── timever ──────────────────────────────────────────────────────
/**
* Timever of an instant. The API reports whole seconds, so SUBTIME is the
* tiebreak: two topics finishing in the same second (one push closing both
* last core problems) get consecutive nanoseconds, in (instant, topic) order.
*/
const tags = new Map<number, string>(); // topic -> tag
{
let prev = "";
let ns = 0;
for (const f of finished) {
const iso = new Date(f.finishedAt).toISOString(); // 2026-09-24T18:36:38.000Z
const base = `${iso.slice(0, 10).replaceAll("-", "")}.${iso.slice(11, 19).replaceAll(":", "")}`;
ns = base === prev ? ns + 1 : Number(iso.slice(20, 23)) * 1_000_000;
prev = base;
tags.set(f.topic.number, `${base}.${String(ns).padStart(9, "0")}`);
}
}
// ── release body ─────────────────────────────────────────────────
const ET_WHEN = new Intl.DateTimeFormat("en-US", {
timeZone: "America/New_York",
weekday: "short",
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
const ET_DAY = new Intl.DateTimeFormat("en-US", {
timeZone: "America/New_York",
month: "short",
day: "numeric",
});
/** Topic body sections worth carrying into the changelog (skipped while still `TODO`). */
const NOTES = ["Trigger", "Invariant", "Trap"] as const;
function bodyField(body: string, field: string): string | undefined {
return body.match(new RegExp(`^\\*\\*${field}:\\*\\*\\s*(.+)$`, "m"))?.[1]?.trim();
}
function bodySection(body: string, heading: string): string | undefined {
const m = body.match(new RegExp(`^### ${heading}\\n([\\s\\S]*?)(?=^### |(?![\\s\\S]))`, "m"));
const text = m?.[1]?.trim();
return text && text !== "TODO" ? text : undefined;
}
function render(f: Finished, tag: string, sha: string, files: Map<number, string[]>): string {
const { topic, problems } = f;
const finishDay = etDate(new Date(f.finishedAt));
// Snapshot at the finish instant: a problem closed later was open then.
const closedBy = (p: Problem) => p.closedAt !== null && p.closedAt <= f.finishedAt;
const onDay = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) === finishDay);
const earlier = problems.filter((p) => closedBy(p) && etDate(new Date(p.closedAt!)) !== finishDay);
const open = problems.filter((p) => !closedBy(p));
const row = (p: Problem): string[] => {
const lc = Number(p.title.match(/^LC (\d+)/)?.[1]);
const code = closedBy(p)
? (files.get(lc) ?? []).map(
(path) => `[${path.slice(path.lastIndexOf(".") + 1)}](https://github.com/${gh.repo}/blob/${tag}/${encodeURI(path)})`,
)
: [];
return [
`#${p.number}`,
p.title,
labelValue(p, "set:") ?? "—",
labelValue(p, "diff:") ?? "—",
closedBy(p) ? ET_DAY.format(new Date(p.closedAt!)) : "—",
code.length ? [...code, `[docs](${DOCS_SITE}/${lc})`].join(" · ") : "—",
];
};
const table = (list: Problem[]) =>
markdownTable([["Issue", "Problem", "Set", "Difficulty", "Closed (ET)", "Solution"], ...list.map(row)]);
// Blume's changelog derives each page's meta description from the opening
// prose, stripping markdown and line breaks without adding a space — so lead
// with one self-contained sentence and keep the fields on one `·` line.
const when = ET_WHEN.format(new Date(f.finishedAt));
const summary =
`Finished ${when} ET: all ${f.core.length} core problems closed, ` +
`${onDay.length} ${onDay.length === 1 ? "problem" : "problems"} closed that day.`;
const fields: string[] = [];
const scheduled = bodyField(topic.body, "Scheduled");
if (scheduled) fields.push(`**Scheduled:** ${scheduled}`);
if (topic.milestone) fields.push(`**Phase:** ${topic.milestone}`);
fields.push(`**Topic:** #${topic.number}`);
const buildsOn = bodyField(topic.body, "Builds on");
if (buildsOn) fields.push(`**Builds on:** ${buildsOn}`);
// The marker is how a later run finds this release; see the file header.
const sections = [`<!-- release-topics: #${topic.number} -->\n${summary}`, fields.join(" · ")];
sections.push(`### Closed ${ET_DAY.format(new Date(f.finishedAt))} — the day the topic finished\n\n${table(onDay)}`);
if (earlier.length) sections.push(`### Closed earlier\n\n${table(earlier)}`);
if (open.length) sections.push(`### Open at release\n\n${table(open)}`);
const notes = NOTES.flatMap((h) => {
const text = bodySection(topic.body, h);
return text ? [`**${h}.** ${text}`] : [];
});
if (notes.length) sections.push(`### Notes\n\n${notes.join("\n\n")}`);
sections.push(
`---\n\n\`${tag}\` is [timever](${TIMEVER}) for the UTC instant the core set completed; ` +
`it tags ${sha.slice(0, 7)}, the last main commit before it.`,
);
return `${sections.join("\n\n")}\n`;
}
// ── reconcile ────────────────────────────────────────────────────
const latest = finished.at(-1)?.topic.number;
const report: string[][] = [];
const previews: string[] = [];
let created = 0;
let updated = 0;
for (const f of finished) {
const { topic } = f;
const finishedEt = etDate(new Date(f.finishedAt));
const existing = released.get(topic.number);
// An existing release keeps its tag and commit; only its body is reconciled.
const tag = existing?.tag ?? tags.get(topic.number)!;
const sha = existing
? git("rev-parse", `${tag}^{commit}`)
: git("rev-list", "-1", "--first-parent", `--before=${f.finishedAt}`, "HEAD");
if (!sha) {
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "ERROR: no commit before finish"]);
continue;
}
// LC number -> its first-solve files in the tagged tree. Keyed by file name,
// like firstSolved(): tags before the bucket split see `work/<Difficulty>/…`,
// and the work/3 + work/7 re-solves are not the solve that closed the issue.
const files = new Map<number, string[]>();
for (const path of git("ls-tree", "-r", "--name-only", sha, "--", "work").split("\n")) {
const bucket = parseWorkPath(path.slice("work/".length))?.bucket;
const lc = path.match(/\/(\d+)\.[^/]+\.(?:js|py)$/)?.[1];
if (!lc || (bucket && bucket !== 1)) continue;
files.set(Number(lc), [...(files.get(Number(lc)) ?? []), path]);
}
const body = render(f, tag, sha, files);
if (existing) {
if (existing.body === body) {
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "released"]);
} else if (DRY) {
previews.push(`## ${tag} — ${topic.title}\n\n${body}`);
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "would update"]);
} else {
await gh.api(`/repos/${gh.repo}/releases/${existing.id}`, {
method: "PATCH",
body: JSON.stringify({ body }),
});
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "updated"]);
updated++;
}
continue;
}
if (DRY) {
previews.push(`## ${tag} — ${topic.title}\n\n${body}`);
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "would release"]);
continue;
}
const remote = git("ls-remote", "--tags", "origin", `refs/tags/${tag}`);
if (!remote) {
const local = Bun.spawnSync(["git", "rev-parse", "-q", "--verify", `refs/tags/${tag}`], { cwd: ROOT });
if (local.exitCode !== 0) {
const tagged = Bun.spawnSync(
["git", "tag", "-a", tag, sha, "-m", `${topic.title} (#${topic.number})\n\nCore set complete: ${f.core.map((n) => `#${n}`).join(", ")}`],
{ cwd: ROOT, env: { ...process.env, GIT_COMMITTER_DATE: f.finishedAt } },
);
if (tagged.exitCode !== 0) throw new Error(`git tag ${tag}: ${tagged.stderr.toString().trim()}`);
}
git("push", "origin", `refs/tags/${tag}`);
}
await gh.api(`/repos/${gh.repo}/releases`, {
method: "POST",
body: JSON.stringify({
tag_name: tag,
name: topic.title,
body,
make_latest: topic.number === latest ? "true" : "false",
}),
});
report.push([`#${topic.number}`, topic.title, finishedEt, tag, "released now"]);
created++;
}
// ── report ───────────────────────────────────────────────────────
if (previews.length) console.log(`${previews.join("\n")}\n`);
const rows = [["topic", "title", "finished (ET)", "tag", "status"], ...report];
printTable(rows);
const would = (status: string) => report.filter((r) => r[4] === status).length;
console.log(
`\n${finished.length} finished topics · ` +
(DRY
? `would release ${would("would release")}, update ${would("would update")}`
: `released ${created}, updated ${updated}`),
);
await writeStepSummary(
`### release-topics${DRY ? " (dry run)" : ""}\n\n` +
`${finished.length} finished topics, ${released.size} already released.\n\n` +
(report.length ? `${markdownTable(rows)}\n` : "No finished topics.\n") +
(previews.length ? `\n${previews.join("\n")}` : ""),
);
+75
View File
@@ -0,0 +1,75 @@
/**
* Topic issues and their problem sub-issues: the ONE reading of "is this topic
* finished", shared by close-topics.ts (closes the topic issue) and
* release-topics.ts (publishes the topic's timever release) so the two can
* never disagree about when a topic was done.
*
* Rule: a topic is finished when it has a core set (`set:core` sub-issues) and
* every one of them is closed. Optional and deferred problems are extra credit
* and never hold a topic open. A topic with no core sub-issues is never
* finished — that would be indistinguishable from a mis-labelled problem set.
*
* Library: side-effect-free on import; every call takes the caller's github().
*/
import type { GitHub } from "./github.ts";
/** One problem sub-issue, narrowed from the sub_issues payload. */
export interface Problem {
number: number;
/** `LC <num> · <Name>`. */
title: string;
/** ISO instant the issue closed; null while it is open. */
closedAt: string | null;
labels: string[];
}
export interface Core {
/** Core sub-issues, closed and open alike, lowest number first. */
all: number[];
/** The core sub-issues still open — non-empty means the topic stays open. */
pending: number[];
/** When the last core sub-issue closed; null until the topic is finished. */
finishedAt: string | null;
}
/**
* Every sub-issue of a topic. The sub_issues payload carries the full issue
* objects (state + labels), so no per-problem follow-up request is needed.
*/
export async function readProblems(gh: GitHub, topic: number): Promise<Problem[]> {
const problems: Problem[] = [];
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) {
if (!raw || typeof raw !== "object") continue;
if (!("number" in raw) || typeof raw.number !== "number") continue;
if (!("title" in raw) || typeof raw.title !== "string") continue;
if (!("state" in raw) || typeof raw.state !== "string") continue;
if (!("labels" in raw) || !Array.isArray(raw.labels)) continue;
const closedAt =
raw.state === "closed" && "closed_at" in raw && typeof raw.closed_at === "string"
? raw.closed_at
: null;
const labels = raw.labels.flatMap((label: unknown) =>
label && typeof label === "object" && "name" in label && typeof label.name === "string"
? [label.name]
: [],
);
problems.push({ number: raw.number, title: raw.title, closedAt, labels });
}
return problems.sort((a, b) => a.number - b.number);
}
/** The value of a `prefix:value` label (`set:` → `core`), if the problem has one. */
export function labelValue(problem: Problem, prefix: string): string | undefined {
return problem.labels.find((l) => l.startsWith(prefix))?.slice(prefix.length);
}
export function coreSet(problems: Problem[]): Core {
const core = problems.filter((p) => labelValue(p, "set:") === "core");
const pending = core.filter((p) => !p.closedAt).map((p) => p.number);
// ISO instants from the API share one format, so string order is time order.
const finishedAt =
core.length && !pending.length
? core.map((p) => p.closedAt!).reduce((a, b) => (a > b ? a : b))
: null;
return { all: core.map((p) => p.number), pending, finishedAt };
}
+9 -2
View File
@@ -1,5 +1,5 @@
import { defineConfig } from "blume";
import { filesystem, githubReleases } from "blume/sources";
import redirects from "./redirects";
// Blume's defaults (`inter`, `ibm-plex-mono`) are fetched from fonts.gstatic.com
@@ -44,7 +44,14 @@ export default defineConfig({
lastModified: "git",
redirects,
content: {
root: "content",
sources: [
filesystem({ root: "content" }),
githubReleases({
prefix: "changelog",
owner: "prdlk",
repo: "leetcode",
}),
],
},
theme: {
fonts: { display: inter, body: inter, mono: plexMono },
@@ -0,0 +1,44 @@
---
title: '198. House Robber'
description: You are a professional robber planning to rob houses along a street. Each house has a certain amount of money stashed, the only constraint stopping you from robbing each of them is that adjacent houses have security systems connected and it will automatically contact the police if two adjacent houses were broken into on the same night
icon: dot
sidebar:
label: 'House Robber'
badge: 'Medium'
---
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `nums = [1,2,3,1]`
- Output: `4`
- Explanation: Rob house `1` (money = `1`) and then rob house `3` (money = `3`). Total amount you can rob = `1 + 3 = 4`.
### Example 2:
- Input: `nums = [2,7,9,3,1]`
- Output: `12`
- Explanation: Rob house `1` (money = `2`), rob house `3` (money = `9`) and rob house `5` (money = `1`). Total amount you can rob = `2 + 9 + 1 = 12`.
### Constraints:
- `1 <= nums.length <= 100`
- `0 <= nums[i] <= 400`
## Solution
```py
class Solution:
def rob(self, nums: list[int]) -> int:
n = len(nums)
if n == 0:
return 0
if n <= 2:
return max(nums)
dp = [0] * n
dp[0] = nums[0]
dp[1] = max(nums[0], nums[1])
for i in range(2, n):
dp[i] = max(dp[i - 1], dp[i - 2] + nums[i])
return dp[-1]
```
@@ -0,0 +1,60 @@
---
title: '322. Coin Change'
description: You are given an integer array coins representing coins of different denominations and an integer amount representing a total amount of money
icon: dot
sidebar:
label: 'Coin Change'
badge: 'Medium'
---
<Badge variant="accent">Array</Badge>
### Example 1:
- Input: `coins = [1,2,5], amount = 11`
- Output: `3`
- Explanation: `11 = 5 + 5 + 1`
### Example 2:
- Input: `coins = [2], amount = 3`
- Output: `-1`
### Example 3:
- Input: `coins = [1], amount = 0`
- Output: `0`
### Constraints:
- `1 <= coins.length <= 12`
- `1 <= coins[i] <= 2^31 - 1`
- `0 <= amount <= 10^4`
## Solution
```py
from math import inf
class Solution:
def coinChange(self, coins: list[int], amount: int) -> int:
n = len(coins)
memo = [[-1] * (amount + 1) for _ in range(n + 1)]
def min_coins(coins, i, amount, memo):
if amount == 0:
return 0
if i == 0 or amount < 0:
return inf
if memo[i][amount] != -1:
return memo[i][amount]
skip = min_coins(coins, i - 1, amount, memo)
use = min_coins(coins, i, amount - coins[i - 1], memo)
if use != inf:
use += 1
memo[i][amount] = min(skip, use)
return memo[i][amount]
result = min_coins(coins, n, amount, memo)
return result if result != inf else -1 # pyright: ignore[reportReturnType]
```
+2
View File
@@ -33,6 +33,7 @@ export default [
{ from: "/153", to: "/binary-search/find-minimum-in-rotated-sorted-array" },
{ from: "/155", to: "/stack/min-stack" },
{ from: "/167", to: "/two-pointers/two-sum-ii-input-array-is-sorted" },
{ from: "/198", to: "/1d-dynamic-programming/house-robber" },
{ from: "/199", to: "/tree-bfs/binary-tree-right-side-view" },
{ from: "/200", to: "/graph-dfs/number-of-islands" },
{ from: "/206", to: "/linked-list/reverse-linked-list" },
@@ -49,6 +50,7 @@ export default [
{ from: "/238", to: "/prefix-sum/product-of-array-except-self" },
{ from: "/242", to: "/hash-table/valid-anagram" },
{ from: "/303", to: "/prefix-sum/range-sum-query-immutable" },
{ from: "/322", to: "/1d-dynamic-programming/coin-change" },
{ from: "/323", to: "/union-find/number-of-connected-components-in-an-undirected-graph" },
{ from: "/344", to: "/two-pointers/reverse-string" },
{ from: "/347", to: "/hash-table/top-k-frequent-elements" },