<?xml version="1.0" encoding="utf-8"?>
<?xml-stylesheet href="/feeds/atom-style.xsl" type="text/xsl"?>
<feed xmlns="http://www.w3.org/2005/Atom">
    <id>https://re.karlbaey.top/</id>
    <title>Karlblogs</title>
    <updated>2026-02-22T18:32:44.666Z</updated>
    <generator>Astro-Theme-Retypeset with Feed for Node.js</generator>
    <author>
        <name>Jerry Karlbaey</name>
        <uri>https://re.karlbaey.top/</uri>
    </author>
    <link rel="alternate" href="https://re.karlbaey.top/"/>
    <link rel="self" href="https://re.karlbaey.top/atom.xml"/>
    <subtitle>I'm Karlbaey! This is my personal blog, where I share my thoughts and experiences!</subtitle>
    <rights>Copyright © 2026 Jerry Karlbaey</rights>
    <entry>
        <title type="html"><![CDATA[Astro 解决代码块的文件名重叠，附带测试工程师笑话]]></title>
        <id>https://re.karlbaey.top/articles/solving-a-duplicate-filename-bug-and-a-test-engineer-joke/</id>
        <link href="https://re.karlbaey.top/articles/solving-a-duplicate-filename-bug-and-a-test-engineer-joke/"/>
        <updated>2026-02-22T18:22:47.123Z</updated>
        <summary type="html"><![CDATA[一个测试工程师走进一家酒吧，要了一杯啤酒；一个测试工程师走进一家酒吧，要了一杯咖啡……没完了？（附带绝赞调优记录）]]></summary>
        <content type="html"><![CDATA[<p>很久以前咱就听过一个测试工程师笑话。保守估计这笑话年纪比咱还大。</p>
<blockquote>
<p>一个测试工程师走进一家酒吧，要了一杯啤酒；</p>
<p>一个测试工程师走进一家酒吧，要了一杯咖啡；</p>
<p>一个测试工程师走进一家酒吧，要了0.7杯啤酒；</p>
<p>一个测试工程师走进一家酒吧，要了-1杯啤酒；</p>
<p>一个测试工程师走进一家酒吧，要了2^32杯啤酒；</p>
<p>一个测试工程师走进一家酒吧，要了一杯洗脚水；</p>
<p>一个测试工程师走进一家酒吧，要了一杯蜥蜴；</p>
<p>一个测试工程师走进一家酒吧，要了一份asdfQwer@24dg!&amp;*(@；</p>
<p>一个测试工程师走进一家酒吧，什么也没要；</p>
<p>一个测试工程师走进家酒吧，又走出去又从窗户进来又从后门出去从下水道钻进来；</p>
<p>一个测试工程师走进家酒吧，又走出去又进来又出去又进来又出去，最后在外面把老板打了一顿；</p>
<p>一个测试工程师走进一；</p>
<p>一个测试工程师走进一家酒吧，要了一杯烫烫烫的锟斤拷；</p>
<p>一个测试工程师走进一家酒吧，要了NaN杯Null；</p>
<p>一个测试工程师冲进一家酒吧，要了500T啤酒咖啡洗脚水野猫狼牙棒奶茶；</p>
<p>一个测试工程师把酒吧拆了；</p>
<p>一个测试工程师化装成老板走进一家酒吧，要了500杯啤酒，并且不付钱；</p>
<p>一万个测试工程师在酒吧外呼啸而过；</p>
<p>一个测试工程师走进一家酒吧，要了一杯啤酒‘;DROPTABLE酒吧；</p>
<p>测试工程师们满意地离开了酒吧；</p>
<p>然后一名顾客点了一份炒饭，酒吧炸了。</p>
</blockquote>
<p>然后就想到了我最近搞的一个小东西。是个 Astro 框架的博客主题。我给这加了个功能：作者可以给自己的代码块加一个文件名，同样可以自动展示语言是什么。</p>
<p>然后我就想，如果某作者闲得蛋疼搞出一个特别长的文件名和语言名，那不就是会重叠吗？所以我上 <a href="//shiki.style">Shiki</a> 找了找最长的语言名，还真给我找到了，是 Fortran 的一种格式标准 <code>fortran-fixed-form</code>。所以就有了下面的逆天玩意。</p>
<p>源代码：</p>
<pre><code>```fortran-fixed-form title="_this__is__a__very__long__title__that__no__one__would__ever__use__in__proper__files.f"
*     euclid.f (FORTRAN 77)
*     Find greatest common divisor using the Euclidean algorithm

      PROGRAM EUCLID
        PRINT *, 'A?'
        READ *, NA
*     其余部分省略……
```
</code></pre>
<p>哈哈，不出所料。</p>
<p><img src="https://cdn.nodeimage.com/i/i1TODTkR4qzfkJckHdjXOzlXoGRb8gXR.png" alt="_image" /></p>
<p>然后就是改呗。总之改完的 CSS 长这样。</p>
<pre><code>.markdown {
  /* 文件名标题栏 (自动生成的 figcaption) */
  figure[data-rehype-pretty-code-figure] figcaption {
    /* ... */
    padding: 0.5rem 1rem;
    padding-right: 9rem;
    /* ... */

    /* 防止文件名太长换行 */
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
  }
  
  /* 嗯这是一个位置 */
  figcaption[data-rehype-pretty-code-title] {
    position: relative;
  }

  /* 语言显示 (利用 figure 上的 data-language 属性) */
  figcaption[data-rehype-pretty-code-title][data-language]::after {
    content: attr(data-language);
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    right: 0;
    padding-inline: 1rem;
    font-size: 0.75rem;
    font-weight: bold;
    text-transform: uppercase;
    color: rgba(255, 255, 255, 0.5);
    pointer-events: none;
  }
}
</code></pre>
<p>效果很好，很适合我。</p>
<p><img src="https://cdn.nodeimage.com/i/3LKRXBzeTN7iOF1rMDp6bya2yCAYUAuD.png" alt="_image" /></p>
<p><img src="https://cdn.nodeimage.com/i/jgIUJehm3che6I5ZiuYC6oAgxQzFDJzr.png" alt="_image" /></p>
<p>但是如果有个天才，想出一门新语言，标记 ID 叫做 <code>objective-javascript-based-on-type-challenges-of-typescript</code>，那我又该怎么办呢。</p>
<p>:::fold[我真这么搞了]</p>
<p><img src="https://cdn.nodeimage.com/i/x3gO727Cw8lioOdOjbRUZTjvj40x6fF0.png" alt="_image" /></p>
<p>移动端表现：</p>
<p><img src="https://cdn.nodeimage.com/i/mc6jZGD4J03BCSCArhfiCE3y1La6GtG4.png" alt="_image" /></p>
<p>想出一门这么牛逼的语言，该它崩溃。</p>
<p>:::</p>
<p>嗯对，刚才想洗个澡，结果把水龙头拧开发现花洒不出水，真把我紧张坏了。然后把浴室里的另外几个水龙头也打开，发现有水啊。最后跟花洒大眼瞪小眼，发现是花洒上的龙头没有开。</p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2026-02-22T18:22:47.123Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[简单记一记如何解决切换主题时的明暗闪烁]]></title>
        <id>https://re.karlbaey.top/articles/handle-fouc-problems/</id>
        <link href="https://re.karlbaey.top/articles/handle-fouc-problems/"/>
        <updated>2026-02-18T10:50:36.977Z</updated>
        <summary type="html"><![CDATA[对于一个明暗双主题的 Astro 主题，在切换页面时经常会出现这样的情况。这玩意的学名叫做 <abbr title="Flash of Un...]]></summary>
        <content type="html"><![CDATA[<p>对于一个明暗双主题的 Astro 主题，在切换页面时经常会出现这样的情况。</p>
<p><img src="https://cdn.karlbaey.top/file/1771412799397_fouc.avif" alt="上图中在切换页面时会出现闪烁问题" /></p>
<p>这玩意的学名叫做 &lt;abbr title="Flash of Unstyled Content"&gt;FOUC&lt;/abbr&gt;，也叫做 <strong>Flash of White</strong>。原因是浏览器在加载页面时，会先渲染 HTML。如果此时 JavaScript 还没来得及运行并给 <code>html</code> 标签加上 <code>dark</code> 类名，或者 CSS 还没加载完，浏览器就会默认使用白色背景，导致在暗色模式下看到一瞬间的白屏。</p>
<p>因为我的主题 <a href="https://github.com/Karlbaey/astro-theme-haku">Haku</a> 也有这样的问题，那就来解决一下。</p>
<p>这是主题 <a href="https://github.com/Karlbaey/astro-theme-haku/blob/master/src/styles/global.css">global.css</a> 中的部分代码。</p>
<pre><code>@import url("https://fonts.loli.net/css2?family=Noto+Sans+SC:wght@100..900");
@import url("https://fonts.loli.net/css2?family=SN+Pro:ital,wght@0,200..900;1,200..900&amp;display=swap");
@import "tailwindcss";

@tailwind utilities;

@custom-variant dark (&amp;:where(.dark, .dark *));

@layer base {
  :root {
    --haku-font: "SN Pro", "Noto Sans SC", sans-serif;
    --color-border: #a8a8a8;
    --color-readme: #44403b;
    --color-readme-anchor: #155dfc;
    --color-tag: #0a0a0a;
    --color-tag-bg: #dfdfdf;
    --color-tag-bg-hover: #a8a8a8;
    --color-tag-title: #0a0a0a;
    --color-tag-text: #202020;

    font-family: var(--haku-font);
    overflow-y: scroll;
  }

  html.dark {
    --color-readme-anchor: #2b7fff;
    --color-border: #3a3a3a;
    --color-readme: #d6d3d1;
    --color-tag: #dbdbdb;
    --color-tag-bg: #2b2b2b;
    --color-tag-bg-hover: #666666;
    --color-tag-title: #d5d5d6;
    --color-tag-text: #c5c5c5;
  }
}
</code></pre>
<p>首先要去掉 <code>@import</code> 指令，把它放在 <code>&lt;head&gt;</code> 标签中。因为外部 CSS 的优先级比较低，把 CDN 提供的 CSS 放在 HTML 文件里加载更好。</p>
<pre><code>&lt;head&gt;
    
    &lt;link
        rel="stylesheet"
        href="https://fonts.loli.net/css2?family=Noto+Sans+SC:wght@100..900"
    /&gt;
    &lt;link
        rel="stylesheet"
        href="https://fonts.loli.net/css2?family=SN+Pro:ital,wght@0,200..900;1,200..900&amp;display=swap"
    /&gt;
&lt;/head&gt;
</code></pre>
<p>然后要定义加载时的默认背景色，这就用 <code>color-scheme</code> 和 CSS 变量实现。</p>
<pre><code>:root {
    color-scheme: light;
    background-color: var(--color-bg);
}

html.dark {
    color-scheme: dark;
    background-color: var(--color-bg);
}
</code></pre>
<p><code>--color-bg</code> 自己看着，调成背景色就行。</p>
<p>最后，也是最重要的，加一个 JS 阻塞脚本。在 <code>&lt;head&gt;</code> 标签里加一个脚本，从本地配置里面找有没有 <code>theme</code> 一项。</p>
<pre><code>  &lt;script is:inline&gt;
    (function () {
      const theme = localStorage.getItem("theme");
      const systemDark = window.matchMedia(
        "(prefers-color-scheme: dark)",
      ).matches;
      if (theme === "dark" || (!theme &amp;&amp; systemDark)) {
        document.documentElement.classList.add("dark");
      } else {
        document.documentElement.classList.remove("dark");
      }
    })();
  &lt;/script&gt;
</code></pre>
<p>这样就能根据读者本地环境自动适配，能够避免打开网页的时候被白光闪一下。</p>
<p>这个问题已经在两个 PR 里解决了：<a href="https://github.com/Karlbaey/astro-theme-haku/pull/50">#50</a> 还有 <a href="https://github.com/Karlbaey/astro-theme-haku/pull/49">#49</a>。</p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2026-02-18T10:50:36.977Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[分析一段精妙的地球 JavaScript 代码]]></title>
        <id>https://re.karlbaey.top/articles/analyze-earth-javascript/</id>
        <link href="https://re.karlbaey.top/articles/analyze-earth-javascript/"/>
        <updated>2026-02-09T04:29:34.250Z</updated>
        <summary type="html"><![CDATA[之前网上冲浪时偶然看到了一个网站：https://aem1k.com/。里面有很多 JavaScript 的极简代码，比如下面这个。<ifr...]]></summary>
        <content type="html"><![CDATA[<p>之前网上冲浪时偶然看到了一个网站：<a href="https://aem1k.com/">https://aem1k.com/</a>。里面有很多 JavaScript 的极简代码，比如下面这个。</p>
<p>&lt;iframe style="background-color:white" src="https://karlbaey.github.io/Karlgo/Algos/HTML/earth.html" width="600px" height="300px" title="earth" /&gt;</p>
<p>源代码长这样：</p>
<pre><code>.&lt;script&gt;
eval(z='p="&lt;"+"pre&gt;"/* ,.oq#+     ,._, */;for(y in n="zw24l6k\
4e3t4jnt4qj24xh2 x/* =&lt;,m#F^    A W###q. */42kty24wrt413n243n\
9h243pdxt41csb yz/* #K       q##H######Am */43iyb6k43pk7243nm\
r24".split(4)){/* dP      cpq#q##########b, */for(a in t=pars\
eInt(n[y],36)+/*         p##@###YG=[#######y */(e=x=r=[]))for\
(r=!r,i=0;t[a/*         d#qg `*PWo##q#######D */]&gt;i;i+=.05)wi\
th(Math)x-= /*       aem1k.com  Q###KWR#### W[ */.05,0&lt;cos(o=\
new Date/1e3/*      .Q#########Md#.###OP  A@ , */-x/PI)&amp;&amp;(e[~\
~(32*sin(o)*/* ,    (W#####Xx######.P^     T % */sin(.5+y/7))\
+60] =-~ r);/* #y    `^TqW####P###BP           */for(x=0;122&gt;\
x;)p+="   *#"/* b.        OQ####x#K           */[e[x++]+e[x++\
]]||(S=("eval"/* l         `X#####D  ,       */+"(z=\'"+z.spl\
it(B = "\\\\")./*           G####B" #       */join(B+B).split\
(Q="\'").join(B+Q/*          VQBP`        */)+Q+")//m1k")[x/2\
+61*y-1]).fontcolor/*         TP         */(/\\w/.test(S)&amp;&amp;"#\
03B");document.body.innerHTML=p+=B+"\\n"}setTimeout(z)')//
&lt;/script&gt;
</code></pre>
<p>这段源代码的知识点单拎一个出来都能震撼人好久，所以我试试看能不能把它拆解开并解清楚原理。</p>
<p>这段代码分两部分：外面的 <code>&lt;script&gt;</code> 标签和内层的 <code>eval()</code> 函数。<code>eval()</code> 函数就是把括号内的内容当作 JS 代码执行。</p>
<p>而外面的 <code>&lt;script&gt;</code> 标签前面加一个点，其实是为了定位。纯 JS 的网页没有 <code>&lt;body&gt;</code> 标签，而这个点就充当了 <code>&lt;body&gt;</code> 标签的角色，让动画有位置展示。</p>
<p>至于 <code>eval()</code> 函数内的代码，这段代码的混淆性特别强，先把它展开来看看。</p>
<pre><code>p = "&lt;" + "pre&gt;";
for (y in (n =
  "zw24l6k4e3t4jnt4qj24xh2 x42kty24wrt413n243n9h243pdxt41csb yz43iyb6k43pk7243nmr24".split(
    4,
  ))) {
  for (a in (t = parseInt(n[y], 36) + (e = x = r = [])))
    for (r = !r, i = 0; t[a] &gt; i; i += 0.05)
      with (Math)
        ((x -= 0.05),
          0 &lt; cos((o = new Date() / 1e3 - x / PI)) &amp;&amp;
            (e[~~(32 * sin(o) * sin(0.5 + y / 7)) + 60] = -~r));
  for (x = 0; 122 &gt; x; )
    p +=
      "   *#"[e[x++] + e[x++]] ||
      (S = ("eval" +
        "(z=\'" +
        z
          .split((B = "\\\\"))
          .join(B + B)
          .split((Q = "\'"))
          .join(B + Q) +
        Q +
        ")//m1k")[x / 2 + 61 * y - 1]).fontcolor(/\\w/.test(S) &amp;&amp; "#03B");
  document.body.innerHTML = p += B + "\\n";
}
setTimeout(z);
</code></pre>
<h2>压缩地图</h2>
<p>原来的图中存了一整幅地图，但是代码量只有 1023 个字符，为了存图画，就必须压缩。就比如一张原始的地图长这样：</p>
<pre><code>. . . . # # # # # # . . . . . # . . . .
. . # # # # # # # . . . . # # # # # . .
. # # # # # # # # . . # # # # # # # # .
. . # # # # # . . . . # # # # # # # # .
. . . # # # # . . . # # # # # # # # . .
. . . . # # # . . . # # # # # # # # . .
. . . . # # # . . . # . # # # # # # . .
. . . . # # . . . . # # # # # # # . . .
. . . . . # . . . # # # # # # # . . . .
. . . . . . # . . # # # # . . # . . . .
. . . . . . # # . . # # # . . . # . . .
. . . . . . # # # . . # # . . . . . . .
. . # . . . # # . . . # # . . . . # . .
. . . . . . # # . . . # . . . . # # . .
. . . . . . # . . . . # . . . . # # . .
</code></pre>
<p>上面的 <code>.</code> 代表海水，<code>#</code> 代表陆地<a href="%E5%AE%9E%E5%9C%A8%E6%98%AF%E5%BE%88%E6%8A%BD%E8%B1%A1%EF%BC%8C%E8%BF%99%E6%98%AF%E4%BB%8E%E6%BA%90%E4%BB%A3%E7%A0%81%E4%B8%AD%E6%8F%90%E5%8F%96%E5%87%BA%E6%9D%A5%E7%9A%84%EF%BC%8C%E5%90%8E%E9%9D%A2%E4%BC%9A%E8%AF%B4%E6%80%8E%E4%B9%88%E8%BD%AC%E6%8D%A2%E3%80%82">^1</a>。</p>
<p>但在这里，整张图就 300 个字符了，占源代码大小的 30%，因此必须进行压缩。观察这个图，容易发现图中只存在两种字符，而且每一行都由 <code>.</code> 开始，那也就意味着可以用字符连续出现的次数表达每一行。比如，第一行 <code>. . . . # # # # # # . . . . . # . . . .</code> 就等价于 <code>46514</code>。这样就可以按照纬度把地图进行压缩。</p>
<pre><code>46514 27452 18281 25481 34382 43382 4331162 42473 51374 6124214 6223313 63227 213232412 6231422 6141422
</code></pre>
<p>压缩率不错，已经达到了三分之一，但是仍然占源代码大小的 10%。为了继续压缩，接着试试提高进制。JavaScript 中最高可转换的进制是 36 进制，那么继续压缩。</p>
<pre><code>zw2 l6k e3t jnt qj2 xh2 2kty2 wrt 13n2 3n9h2 3pdxt 1csb 3iyb6k 3pk72 3nmr2
</code></pre>
<p>这样就是压缩的极限了。但是不能用数组存，如果用数组存就会让每个字符串前面多出两个引号，字符串之间又多出个逗号，只能弃掉，换成字符串存储。就比如用一个 36 进制里面不会出现的字符来分割，比如 <code>!</code>，那就能够用函数 <code>split()</code> 来分割。但是实际上不会用 <code>!</code>，因为 <code>!</code> 是字符，还要搭进去两个引号，选一个没出现过的数字 <code>4</code> 更好。<a href="%E4%B8%BA%E4%BA%86%E6%BA%90%E4%BB%A3%E7%A0%81%E5%B1%95%E7%A4%BA%E6%95%88%E6%9E%9C%E6%98%AF%E4%B8%AA%E6%96%B9%E5%BD%A2%EF%BC%8C%E6%BA%90%E4%BB%A3%E7%A0%81%E4%B8%AD%E8%BF%9B%E8%A1%8C%E4%BA%86%E8%A1%A5%E4%BD%8D%E3%80%82">^2</a></p>
<pre><code>"zw24l6k4e3t4jnt4qj24xh2 x42kty24wrt413n243n9h243pdxt41csb yz43iyb6k43pk7243nmr24"
</code></pre>
<h2>画出地图</h2>
<p>这里是整段代码最神奇的部分。</p>
<h3>解压缩 &amp; 初始化变量</h3>
<p>上面压缩完之后，自然是要用函数解压缩。因此首先调用循环和函数 <code>parseInt()</code> 来解压，具体就像是这样。</p>
<pre><code>for (y in (n =
  "zw24l6k4e3t4jnt4qj24xh2 x42kty24wrt413n243n9h243pdxt41csb yz43iyb6k43pk7243nmr24".split(
    4,
  ))) {
  for (a in (t = parseInt(n[y], 36) + (e = x = r = [])))
}
</code></pre>
<p>变量 <code>t</code> 实际上就是 <code>n</code> 隐式转换成数组之后用 36 进制解码数组 <code>n</code> 的每一位，也就是说 <code>t</code> 实际上就是地图压缩成的一系列数字。紧接着再把一个数字加上空数组变成字符串。就像是：</p>
<pre><code>n[0] = zw2;
parseInt(n[0]) = 46514;
46514 + [] = "46514";
</code></pre>
<p>三个数组变量各有各的用处。</p>
<ul>
<li><code>e = x = r = []</code>
<ul>
<li><code>e</code>: 用来存储当前这一行要显示的像素点（相当于一行显存）。</li>
<li><code>x</code>: 用作经度坐标。</li>
<li><code>r</code>: 用作地形类型的开关（陆地/海洋）。</li>
</ul>
</li>
</ul>
<h3>控制画图密度</h3>
<p>地图是一个矩形，但实际展示却是像地球仪一样上下窄中间宽，这就必须控制画图的密度（体现在图上就是地图的经度），这需要用到球面投影（也就是三角函数）。</p>
<p>通过 <code>with (Math)</code>[^3] 引入 JavaScript 的数学库就可以使用 <code>sin()</code> 和 <code>cos()</code> 等数学函数了。</p>
<pre><code>for (r = !r, i = 0; t[a] &gt; i; i += 0.05)
      with (Math)
        ((x -= 0.05),
          0 &lt; cos((o = new Date() / 1e3 - x / PI)) &amp;&amp;
            (e[~~(32 * sin(o) * sin(0.5 + y / 7)) + 60] = -~r));
</code></pre>
<p><code>r=!r</code>，实际上就是反转 <code>r</code> 的布尔值；而 <code>t[a] &gt; i</code> 中，<code>t[a]</code> 越大就代表要画的部分越长，这恰好对应了上文用空白表示海洋，<code>#</code> 表示陆地的压缩方式。</p>
<p><code>i += 0.05</code> 是一个精细的步长，实际上是把地图的精度放大了 20 倍。</p>
<p><code>x</code> 代表地球的经度，每次递减 <code>0.05</code> 就能让地球旋转起来，而后面的 <code>&amp;&amp;</code> 相当于一个 <code>if (cond) ...</code> 语句。这样，其实就相当于：</p>
<pre><code>while (还在绘制) {
    (经度减小), 
    if (o &lt; cos()) {
        e[计算列的序号] = -~r
    }
}
</code></pre>
<p>那么继续分析 <code>cos()</code> 中的 <code>(o = new Date() / 1e3 - x / PI)</code>，其实就是取一个变量 <code>o</code>，它等于现在的时间（取时间戳）除以 1000[^4]，用它减去经度除以 <code>Math.PI</code>，就得到了当前地球自转的相位。</p>
<p>得到相位的目的是什么呢？<code>cos(x)</code> 的值，是在 <code>[-1, 1]</code> 之间振荡，那么得到了当前的相位就知道了地球上的某一点是否在图中展示出来。当相位小于 0，就表示这一点转到了朝向观察者的一面。</p>
<h3>更新显存</h3>
<p>上面说了，<code>e</code> 是负责渲染地图的显存。所以根据上面得到的经度 <code>x</code> 和纬度 <code>y</code>，就能够计算得到当前一点的水平投影位置和数据。</p>
<p>源代码中的计算公式是这样的：</p>
<pre><code>e[~~(32 * sin(o) * sin(0.5 + y / 7)) + 60] = -~r)
</code></pre>
<p><code>32 * sin(o) * sin(0.5 + y / 7)</code> 计算位置。<code>sin(0.5 + y / 7)</code> 根据纬度 <code>y</code> 调整球体的宽度（赤道宽，两极窄）。32 是放大倍数，决定了当前地球的显示宽度。60 是圆心的偏移。</p>
<p>最后按照位运算技巧（<code>-~r</code> 等价于 <code>r+=1</code>）给 <code>e</code> 存入要么是 <code>1</code> 要么是 <code>2</code> 的值（后面有用）。</p>
<p>下面这张图展示了在数组 <code>e</code> 中，对于每一个纬度 <code>y</code> ，有数据的索引范围。容易看到，当纬度在 7 时索引范围最宽，0 和 14 时索引范围最窄。也就是对应实际展示中两极窄赤道宽的样式。</p>
<p><img src="https://cdn.karlbaey.top/file/1770885229914_image-20260212163223346.png" alt="distribution_of_indices_in_array_e_across_row" /></p>
<h2>渲染合成</h2>
<p>这段代码开始展示艺术，它将地球放到当前的浏览器中。</p>
<pre><code>for (x = 0; 122 &gt; x; )
    p +=
      "   *#"[e[x++] + e[x++]] ||
      (S = ("eval" +
        "(z=\'" +
        z
          .split((B = "\\\\"))
          .join(B + B)
          .split((Q = "\'"))
          .join(B + Q) +
        Q +
        ")//m1k")[x / 2 + 61 * y - 1]).fontcolor(/\\w/.test(S) &amp;&amp; "#03B");
  document.body.innerHTML = p += B + "\\n";
</code></pre>
<p>简单地说，它会判断当前的这一个字符是不是地球。<strong>如果是，就画出地球的像素点；如果不是，就画出源代码。</strong></p>
<p>这段代码利用了 JavaScript 的逻辑或运算符 <code>||</code> 的“短路”特性来实现图层叠加。将它拆解为 <strong>A || B</strong> 两部分来看。</p>
<h3>A 部分</h3>
<p>长这样：<code>"  *#"[e[x++] + e[x++]]</code>。</p>
<p>这个字符串是个调色板。索引 0-2（空格）表示亮度很低或者是海洋；索引 3（*）表示陆地边缘或中等亮度；索引 4 （#）表示高亮的陆地中心。</p>
<p>有了调色板，就可以用数组 <code>e</code> 中的数据提取了。</p>
<p>因为通常情况下字符的高是宽的大约两倍，为了不让地球看起来像个橄榄，需要压缩分辨率。</p>
<p>具体方法也很简单，只需要 <code>x++</code> 两次，这样输出一个字符实际上消耗了 <code>e</code> 的两个数据点，就达成了压缩分辨率的目的。[^5]</p>
<h3>B 部分</h3>
<p>如果当前位置没有地球（即 <code>e</code>数组里没值），代码就会执行 <code>||</code> 右边的部分。这部分非常硬核，它在通过计算生成<strong>程序自身的源代码字符串</strong>。</p>
<pre><code>(S = ("eval" + "(z=\'" + ... + ")//m1k")[索引]).fontcolor(...)
</code></pre>
<p>先重组源码。<code>"eval" + "(z=\'" + ...</code> 这一长串是在<strong>动态拼凑</strong>当前正在运行的这段代码的字符串形式。它处理了转义字符（<code>split</code> 和 <code>join</code> 里的 <code>\\</code> 和 <code>\'</code>），确保拼出来的代码字符串是合法的。</p>
<p>然后计算背景文字的位置。</p>
<ul>
<li>
<p><code>[x / 2 + 61 * y - 1]</code></p>
<ol>
<li>
<p><code>61</code>：因为外层循环 <code>122 &gt; x</code> 且每次步进 2（<code>x++</code> 两次），所以一行宽 61 个字符。</p>
</li>
<li>
<p><code>y</code>：当前是第几行。</p>
</li>
</ol>
</li>
</ul>
<p>这个公式确保了背景里的源代码是整整齐齐地按顺序排布的，就像一张印着代码的壁纸铺在地球后面。</p>
<p>最后染色。上面已经说过 <code>&amp;&amp;</code> 运算符的作用。<code>.fontcolor(/\\w/.test(S) &amp;&amp; "#03B");</code> 就是在用正则表达式匹配每一个字母、数字和下划线，并给它们染上蓝色。其余部分保持默认。</p>
<p><code>document.body.innerHTML = p += B + "\\n";</code> 将计算得到的结果拼接到 <code>p</code>（最后展示出的 HTML）中。</p>
<p>总结起来，这部分代码的伪代码表示如下。</p>
<pre><code>for (字符位 in 屏幕) {
    if (地球数据 in e) {
        draw = "   *#"选一个画进去;
    } else {
        draw = 如果这里是显示源代码，显示的第几个字母;
        给源代码染色;
    }
    最终展示 += draw;
}
</code></pre>
<p>这就是为什么在最终效果图中看到：前面是一个旋转的地球，而地球的空隙和背景里，整整齐齐地写着这段代码本身。</p>
<h2>循环往复</h2>
<p>在字符串 <code>z</code>（还记得有这个字符串吗）的最后出现了一个 <code>setTimeout(z)</code>，它的作用是<strong>在极短的时间后，把字符串 <code>z</code> 当作代码再执行一次。</strong>（接近 0 毫秒）</p>
<p>这样做有两个原因。</p>
<ol>
<li><strong>为了复制自己</strong>。变量 <code>z</code> 的使命是存储<strong>完整的源代码</strong>。 如果 <code>setTimeout(z)</code> 这句话不在 <code>z</code> 里面，那么当程序执行 <code>eval(z)</code> 时，它会运行一遍绘图逻辑，然后就<strong>结束了</strong>。因为它不知道“运行完之后该干什么”。 只有把“<strong>运行完之后，再运行一遍我自己</strong>”这条指令（即 <code>setTimeout(z)</code>）写进源代码字符串 <code>z</code> 里，程序才能在执行完那一刻，知道要触发下一次执行。</li>
<li><strong>为了显示背景代码</strong>。记得上一段提到的背景代码显示逻辑吗？ <code>(S = ("eval" + "(z=\'" + z ...)</code> 这段代码会把 <code>z</code> 的内容打印在屏幕背景上。 如果 <code>setTimeout(z)</code> 不在 <code>z</code> 里，那么屏幕背景上显示的源代码就会<strong>少这一句</strong>。为了让显示的源代码和实际运行的源代码完全一致（完美的 Quine），它必须包含自身所有的逻辑，包括循环指令。</li>
</ol>
<p>讲真的。这段 JavaScript 代码，设计精巧，在不到 1 KiB（1023 Bytes）的大小里面融入了 Quine（自产生程序）、位运算、压缩算法还有代码高亮，实在是很牛逼。</p>
<p>最后在这里贴出来原作者 Martin Kleppe 的讲解。（在 YouTube 上观看）</p>
<p><a href="https://www.youtube.com/watch?v=RTxtiLp1C8Y">On YouTube - Martin Kleppe: 1024+ Seconds of JS Wizardry -- JSConf EU 2013</a></p>
<p>（B 站也有）</p>
<p><a href="https://www.bilibili.com/video/BV1FwcizQE2G/?share_source=copy_web&amp;vd_source=a4fdf727d78452154490479802034059">On Bilibili - Martin Kleppe: 1024+ Seconds of JS Wizardry -- JSConf EU 2013</a></p>
<p>[^3]: 这种写法很不规范就是了，而且很容易爆 <code>undefined</code> 和安全问题。
[^4]: 时间的变化相比起经度的变化微乎其微，可忽略不计。
[^5]: 将相邻两个点求和然后计算起到了简单的抗锯齿作用。</p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2026-02-09T04:29:34.250Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[程序设计#5 - 二分查找]]></title>
        <id>https://re.karlbaey.top/articles/program-design-episode-5-binary-search/</id>
        <link href="https://re.karlbaey.top/articles/program-design-episode-5-binary-search/"/>
        <updated>2026-01-10T09:19:21.286Z</updated>
        <summary type="html"><![CDATA[如果要在一个有序数组里寻找第一个大于等于 target 的元素下标，最直觉的写法就是遍历，但是这样的复杂度是 O(n)，在大数据下较劣。原因...]]></summary>
        <content type="html"><![CDATA[<p>如果要在一个有序数组里寻找第一个大于等于 <code>target</code> 的元素下标，最直觉的写法就是遍历，但是这样的复杂度是 <code>O(n)</code>，在大数据下较劣。原因是，没有利用到<strong>数组有序</strong>这个性质。</p>
<p><strong>二分查找</strong>（binary search）是一种能在有序数组中快速找到指定元素的算法。它每次将搜索范围减小一半，因此非常高效。时间复杂度通常为 <code>O(log n)</code>。</p>
<p>但这么高效的算法是陷阱密布的。接下来我想用尽可能简单的语言避开二分查找的每一个坑点。</p>
<p>在这之前必须了解几个常用的库函数，它们都在 <code>&lt;algorithm&gt;</code> 头文件中。</p>
<pre><code>vector&lt;int&gt; v;

/* 一些初始化代码 */

auto i = lower_bound(v.begin(), v.end(), elem);
auto j = upper_bound(v.begin(), v.end(), elem);
</code></pre>
<p>也可以是</p>
<pre><code>auto i = ranges::lower_bound(v, elem);
auto j = ranges::upper_bound(v, elem);
</code></pre>
<p>或者</p>
<pre><code>auto [i, j] = ranges::equal_range(v, elem);
</code></pre>
<p>其中 <code>i</code> 和 <code>j</code> 会返回一个迭代器，分别代表数组中第一个<strong>大于或等于</strong> <code>elem</code> 的位置和第一个<strong>严格大于</strong> <code>elem</code> 的位置。</p>
<p>我们希望自己实现一个二分查找，因此这里仅作了解，下面是正式部分。</p>
<h2>红蓝染色法</h2>
<p>我们在本篇所说的二分查找都是在一个<strong>非严格递增顺序</strong>的数组中寻找<strong>大于等于</strong> <code>target</code> 的数，简单记为 <code>lowerbound()</code>。</p>
<p>二分查找的写法按照区间来划分可以有三种：<strong>闭区间</strong>、<strong>左闭右开区间</strong>和<strong>开区间</strong>。</p>
<p>所谓<strong>闭区间</strong>，意思是在 <code>[l, r]</code> 这个下标区间内，所有的元素都无法确定大小。左闭右开区间是 <code>[l, r)</code>，开区间则是 <code>(l, r)</code>。这里先说闭区间。</p>
<p>标题中的<strong>红蓝染色法</strong>是一种二分查找常用的标记元素的方法。先看下面的例子。</p>
<blockquote>
<p><a href="https://leetcode.cn/problems/find-first-and-last-position-of-element-in-sorted-array/description/">LC<em>34</em>在排序数组中查找元素的第一个和最后一个位置</a></p>
<p>给你一个按照非递减顺序排列的整数数组 <code>nums</code>，和一个目标值 <code>target</code>。请你找出给定目标值在数组中的开始位置和结束位置。</p>
<p>如果数组中不存在目标值 <code>target</code>，返回 <code>[-1, -1]</code>。</p>
<p>你必须设计并实现时间复杂度为 <code>O(log n)</code> 的算法解决此问题。</p>
</blockquote>
<p>这是原题给的样例。</p>
<pre><code>输入：nums = [5,7,7,8,8,10], target = 8
输出：[3,4]
</code></pre>
<p><strong>红蓝染色法</strong>简单来说，就是将所有严格小于 <code>target</code> 的部分染成红色块，大于或等于 <code>target</code> 的部分染成蓝色块，这样最终第一个蓝色块就是 <code>lowerbound()</code> 的返回值了。</p>
<p>下面是这个流程的图示。</p>
<p><img src="https://cdn.karlbaey.top/file/1767966186915_binary_closed.svg" alt="_binary_closed" /></p>
<p>这就是我们所说的红蓝染色法。</p>
<p>同时闭区间 <code>[l, r]</code> 以外的元素都是已经确定好会被排除的元素。**换言之，我们写闭区间的二分查找，本质上就是保证 <code>[r+1, nums.size()]</code> 中的元素大于等于 <code>target</code>，而 <code>[0,l-1]</code> 中的元素小于 <code>target</code>。**上一句话很重要，这就是确保二分查找不出现死循环的关键。</p>
<p>（几乎所有死循环的二分查找代码，都是因为没有保证区间从头到尾都不变。其中有一些闭区间写着写着变成左开右闭区间了）</p>
<p>同时注意，C++ 中如果 <code>nums</code> 的长度到了 <code>int</code> 类型最大值，使用 <code>m = (l+r) / 2</code> 就会导致溢出，解决方案是换成 <code>m = l + (r - l) / 2</code>。</p>
<h3>闭区间写法</h3>
<pre><code>class Solution {
    int lowerbound(vector&lt;int&gt;&amp; nums, int target) {
        int l = 0, r = nums.size() - 1;
        while (l &lt;= r) { // 保证区间不为空
            int m = l + (r - l) / 2;     // 防止溢出，Python 可以写为 m = (r + l) // 2
            if (nums[m] &gt;= target) { // 大于或等于 target，更新 r
                r = m - 1;
            } else { // 严格小于 target，更新 l
                l = m + 1;
            }
        }
        return l;
    }

public:
    vector&lt;int&gt; searchRange(vector&lt;int&gt;&amp; nums, int target) {
        // 实际上就是返回第一个大于等于 target 的元素对应的下标
        // 还有第一个严格大于 target 的元素对应的下标减去 1
        int start = lowerbound(nums, target);
        if (start == nums.size() || nums[start] != target) { // 前者为了防空数组，后者防数组中不存在 target
            return {-1, -1};
        }
        int end = lowerbound(nums, target + 1) - 1;
        return {start, end};
    }
};
</code></pre>
<p>在 <code>nums</code> 中寻找最后一个大于等于 <code>target</code> 元素对应的下标，等价于寻找第一个严格大于 <code>target</code> 的元素对应的下标减去 1。</p>
<p>以此类推，可以写出一张针对不同情况的二分查找调用方式。</p>
<table>
<thead>
<tr>
<th>需求</th>
<th>调用方式</th>
<th>……不存在</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>&gt;= x</code> 的第一个元素的下标</td>
<td><code>lowerbound(nums, x)</code></td>
<td><code>nums.size()</code></td>
</tr>
<tr>
<td><code>&gt; x</code> 的第一个元素的下标</td>
<td><code>lowerbound(nums, x+1)</code></td>
<td><code>nums.size()</code></td>
</tr>
<tr>
<td><code>&lt;= x</code> 的最后一个元素的下标</td>
<td><code>lowerbound(nums, x+1)-1</code></td>
<td><code>-1</code></td>
</tr>
<tr>
<td><code>&lt; x</code> 的最后一个元素的下标</td>
<td><code>lowerbound(nums, x)-1</code></td>
<td><code>-1</code></td>
</tr>
</tbody>
</table>
<p>当然，除了闭区间以外，还有两种写法：<strong>左闭右开区间</strong>和<strong>开区间</strong>。这三种写法实际使用没有区别，看更喜欢哪一种写法就好。</p>
<h3>左闭右开区间写法</h3>
<pre><code>int lowerbound_1(vector&lt;int&gt;&amp; nums, int target) { // 左闭右开区间 [l,r) 写法
        int l = 0, r = nums.size();
        while (l &lt; r) {
            int m = l + (r - l) / 2;
            if (nums[m] &lt; target) {
                l = m + 1; // 继续二分 [m+1, r)
            } else {
                r = m; // 继续二分 [l, m)
            }
        }
        // 最后 l 和 r 会重叠，此时区间 [l, r) 内没有元素，循环结束
        // 返回 l 或 r 都可以
        return r;
    }
</code></pre>
<h3>开区间写法</h3>
<p>因为到最后区间 <code>(l, r)</code> 中没有元素，所以在二分时要确保 <code>l</code> 和 <code>r</code> 中间至少隔了一位，也就是 <code>l+1 &lt; r</code>。</p>
<pre><code>int lowerbound_2(vector&lt;int&gt;&amp; nums, int target) { // 开区间 (l,r) 写法
        int l = -1, r = nums.size();
        while (l + 1 &lt; r) {
            int m = l + (r - l) / 2;
            if (nums[m] &lt; target) {
                l = m; // 继续二分 (m, r)
            } else {
                r = m; // 继续二分 (l, m)
            }
        }
        return r;
    }
</code></pre>
<p><a href="https://leetcode.cn/problems/maximum-count-of-positive-integer-and-negative-integer/description/">LC<em>2529</em>正整数和负整数的最大计数</a>就可以转化为寻找 <code>0</code> 的起始点和结束点。</p>
<pre><code>class Solution {
    int lowerbound(vector&lt;int&gt;&amp; nums, int target) { // 闭区间 [l,r] 写法
        int l = 0, r = nums.size() - 1;
        while (l &lt;= r) {             // 保证区间不为空
            int m = l + (r - l) / 2; // 防止溢出，Python 可以写为 m = (r + l) // 2
            if (nums[m] &gt;= target) { // 大于或等于 target，更新 r
                r = m - 1;           // 继续二分 [l, m-1]
            } else {                 // 严格小于 target，更新 l
                l = m + 1;           // 继续二分 [m+1, r]
            }
        }
        return l;
    }

    int max(int a, int b) {
        if (a &gt; b) {
            return a;
        }
        return b;
    }

public:
    int maximumCount(vector&lt;int&gt;&amp; nums) {
        int l = lowerbound(nums, 0);
        int r = lowerbound(nums, 1);
        return max(l, nums.size()-r);
    }
};
</code></pre>
<hr />
<p>二分查找的本质在于用 <code>O(log n)</code> 的时间换一个新的条件，而且因为二分查找只能在有序数组里面用，因此常常和快速排序（时间复杂度为 <code>O(n log n)</code>）结合起来。</p>
<p>看一道先排序再二分答案的题目。</p>
<p><a href="https://leetcode.cn/problems/find-the-distance-value-between-two-arrays/">LC<em>1385</em>两个数组间的距离值</a></p>
<blockquote>
<p>给你两个整数数组 <code>arr1</code> ， <code>arr2</code> 和一个整数 <code>d</code> ，请你返回两个数组之间的 <strong>距离值</strong> 。</p>
<p>「<strong>距离值</strong>」 定义为符合此距离要求的元素数目：对于元素 <code>arr1[i]</code> ，不存在任何元素 <code>arr2[j]</code> 满足 <code>|arr1[i]-arr2[j]| &lt;= d</code> 。</p>
</blockquote>
<p>假如 <code>arr2</code> 是有序的，那么对于 <code>arr1</code> 中的每一个元素 <code>arr1[i]</code>，只要证明 <code>arr2</code> 中没有区间 <code>[arr1[i]-d, arr1[i]+d]</code> 中的元素就可以了。</p>
<p>这就是所说的排序+二分查找，代码不难，直接给出。</p>
<pre><code>class Solution {
    int lowerbound(vector&lt;int&gt;&amp; nums, int target) {
        int l = 0, r = nums.size();
        while (l &lt; r) {
            int m = l + (r - l) / 2;
            if (nums[m] &gt;= target) {
                r = m;
            } else {
                l = m + 1;
            }
        }
        return l;
    }

public:
    int findTheDistanceValue(vector&lt;int&gt;&amp; arr1, vector&lt;int&gt;&amp; arr2, int d) {
        int res = 0;
        sort(arr2.begin(), arr2.end()); // C++ 排序库函数（快速排序），在头文件 &lt;algorithm&gt; 中
        for (int i : arr1) {
            int k = lowerbound(arr2, i - d);
            if (k == arr2.size() || arr2[k] &gt; i + d) {
                res++;
            }
        }
        return res;
    }
};
</code></pre>
<hr />
<p>二分查找也可以结合起前缀和。</p>
<blockquote>
<p>前缀和（全称前缀和数组）是一种算法技巧，可以快速求出原数组的任意子数组和。对于前缀和数组 <code>pre</code> 和原数组 <code>nums</code>，有这样的性质：</p>
<pre><code>pre[i] - pre[j] == nums[j] + nums[j+1] + ... + nums[i]
</code></pre>
<p>其中 <code>i &gt;= j</code>。容易发现 <code>pre[0] == 0</code> 且 <code>pre.size() - nums.size() == 1</code>。</p>
<p>C++ 标准库 <code>&lt;numeric&gt;</code> 提供了计算前缀和的函数 <code>partial_sum()</code>。但计算得到的结果第一个元素不是 <code>0</code> 而是原数组的第一个元素。</p>
</blockquote>
<p><a href="https://leetcode.cn/problems/longest-subsequence-with-limited-sum/description/">LC<em>2389</em>和有限的最长子序列</a></p>
<blockquote>
<p>给你一个长度为 <code>n</code> 的整数数组 <code>nums</code> ，和一个长度为 <code>m</code> 的整数数组 <code>queries</code> 。</p>
<p>返回一个长度为 <code>m</code> 的数组 <code>answer</code> ，其中 <code>answer[i]</code> 是 <code>nums</code> 中 元素之和小于等于 <code>queries[i]</code> 的 <strong>子序列</strong> 的 <strong>最大</strong> 长度 。</p>
<p><strong>子序列</strong>是指从一个原始序列中通过去除某些元素而不改变剩余元素的相对顺序所形成的新序列。子序列在原数组中不一定是连续的。<em>（PS：这一段我改了，力扣原文写得不知所云）</em></p>
</blockquote>
<p>因为是求子序列和，所以完全不必在意按什么顺序挑选元素。也就是说，挑选出原数组中最小的几个元素，让它们求和恰好不大于 <code>queries[i]</code> 就可以了。</p>
<pre><code>class Solution {
public:
    vector&lt;int&gt; answerQueries(vector&lt;int&gt;&amp; nums, vector&lt;int&gt;&amp; queries) {
        ranges::sort(nums); // 先排序，准备计算前缀和
        partial_sum(nums.begin(), nums.end(), nums.begin()); // 直接存到原数组里，节约空间，下同
        for (int&amp; q : queries) {
            q = ranges::upper_bound(nums, q) - nums.begin(); // upper_bound 求的是第一个严格大于 q 的数 k，说明 k-1 一定是不大于 q 的。
            // 并且，下标从 0 开始，二分查找得到的结果恰好就是子序列长度
        }
        return queries;
    }
};
</code></pre>
<hr />
<p>二分查找给我写麻了，一想到后面还有<em>二分答案</em>跟<em>最长递增子序列</em>这两位神仙就头大，就写到这了。🤪</p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2026-01-10T09:19:21.286Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[程序设计#4 SP 接雨水的四种解法]]></title>
        <id>https://re.karlbaey.top/articles/program-design-episode-4-sp-trap-rain-water/</id>
        <link href="https://re.karlbaey.top/articles/program-design-episode-4-sp-trap-rain-water/"/>
        <updated>2025-12-28T06:24:10.948Z</updated>
        <summary type="html"><![CDATA[https://leetcode.cn/problems/trapping-rain-water/description/comments...]]></summary>
        <content type="html"><![CDATA[<blockquote>
<p><a href="https://leetcode.cn/problems/trapping-rain-water/description/comments/3115811/">https://leetcode.cn/problems/trapping-rain-water/description/comments/3115811/</a></p>
<p><em>孔乙己身材高大，却总带着一副青白的脸色，眼角时常堆积着倦意，一头乱发油腻地贴在额前。他自称拿过ACM银牌，却似乎从未进过大厂，也不懂面试的门道，日子过得颇为落魄。和人交谈时，三句不离“时间复杂度”、“空间复杂度”，总说些让人半懂不懂的话。</em></p>
<p><em>有一天，他刚解完一道Hard题目，旁边围观的新人便故意高声嚷道：“孔乙己，你肯定又写暴力解法了！”孔乙己瞪大眼睛道：“你怎么这样凭空污人清白……”“什么清白？我前天亲眼见你写了个O(n²)的解法，被评论区骂得狗血淋头。”孔乙己顿时涨红了脸，额上青筋条条绽出，争辩道：“O(n²)不能算暴力……算法的事，能算暴力么？”接着便是一些难懂的话，什么“约束条件下各有适用”，什么“n很小不必最优”之类，引得众人都哄笑起来：论坛内外充满了快活的空气。</em></p>
<p><em>这些时候，我也可以跟着笑，管理员是绝不会责备的。而且管理员一见孔乙己，也每每故意这么问他，逗大家发笑。孔乙己自知无法与他们交流，只好转而找萌新说话。有一回他对我说：“你刷过题吗？”我略一点头。他说：“刷过题……那我考你一考，接雨水这道题，怎么解？”我想，一个刷题刷到这般境地的人，也配考我么？便转过脸去，不再理会。孔乙己等了许久，很恳切地说道：“不会解罢……我教给你，记着！这题该好好记住，将来做了面试官，可用来考人。”我暗想我离面试官还远着呢，再说面试官也一般不考Hot100；既觉得好笑，又不耐烦，便懒懒地回他：“谁要你教，不是双指针秒杀么？”孔乙己显出极高兴的样子，用两个指头的长指甲敲着桌子，点头说：“对呵对呵！……接雨水可有三样解法，你知道么？”我更加不耐烦了，扭头走远。孔乙己刚蘸了酒想在桌上手撕代码，见我怎么也不热心，终于叹一口气，露出极惋惜的表情。</em></p>
</blockquote>
<p>今天不折腾双指针和滑动窗口了，今天弄一个经典的算法题：接雨水。</p>
<blockquote>
<p>原题：<a href="https://leetcode.cn/problems/trapping-rain-water/">42. 接雨水 - 力扣（LeetCode）</a> 或者 <a href="https://www.luogu.com.cn/problem/T574153">T574153 接雨水 - 洛谷</a>。</p>
<p>给定 <code>n</code> 个非负整数表示每个宽度为 <code>1</code> 的柱子的高度图（记为 <code>height</code>），计算按此排列的柱子，下雨之后能接多少雨水。</p>
</blockquote>
<p>先看图：</p>
<p><img src="https://cdn.karlbaey.top/file/1766849072426_rainwatertrap.png" alt="_" /></p>
<pre><code>input:

12
0 1 0 2 1 0 1 3 2 1 2 1
</code></pre>
<h2>暴力解法</h2>
<p>假设现在遍历到了 <code>height[i]</code>，那么从它开始，分别向左向右遍历寻找最高的柱子（记作 <code>left_max</code> 和 <code>right_max</code>），这两个柱子中的较小者减去 <code>height[i]</code> 就是当前柱子的存水量。</p>
<p>换言之，代码表示如下。</p>
<pre><code>int water = std::min(left_max, right_max) - height[i];
</code></pre>
<p>只要它是一个正数，我们就能直接把它加入总存水量。</p>
<pre><code>int trap_bruteforce(const std::vector&lt;int&gt;&amp; height) {
    if (height.size() &lt; 3)
        return 0;

    int total = 0;

    for (int i = 0; i &lt; height.size(); i++) {
        int left_max = 0;
        for (int j = 0; j &lt; i; j++) {
            left_max = std::max(left_max, height[j]);
        }

        int right_max = 0;
        for (int j = i + 1; j &lt; height.size(); j++) {
            right_max = std::max(right_max, height[j]);
        }

        int water = std::min(left_max, right_max) - height[i];

        if (water &gt; 0) {
            total += water;
        }
    }

    return total;
}
</code></pre>
<p>（这里没有使用 <code>trap</code> 作为函数名，因为还有其他的方式。）</p>
<p>这样做非常直观，但是时间复杂度是 <code>O(n^2)</code>（每次遍历一个 <code>height[i]</code> 都要遍历完整个数组），最大就能接受 <code>10000</code> 的数据量，它在时间表现上是较劣的。</p>
<h2>动态规划预处理</h2>
<p>在暴力算法中我们发现，我们重复求了很多次最高的柱子。例如 <code>height[5]</code>、<code>height[6]</code> 和 <code>height[7]</code> 左侧最高的柱子均是 <code>height[4]</code>，但是重复求了 3 次。为了避免这种重复的问题，考虑用<strong>动态规划</strong>的思想求解。</p>
<p>“动态规划”是一个（在我的教程里的）新词，简单地引入一下：</p>
<blockquote>
<p>我们先用暴力法想一想：对每个位置 <code>i</code>，都要向左找最高柱子、向右找最高柱子。</p>
<p>但你会发现：当我们从 <code>i</code> 走到 <code>i+1</code> 时，“左边最高的柱子”（右边也一样）这件事几乎没有变化，我们在重复做同样的工作。 于是我们可以把这些“会重复出现的中间结果”先算出来保存，后面直接查表使用。</p>
<p><strong>这种“把复杂问题拆成一系列更小问题，并把中间结果记下来避免重复计算”的思想，就叫动态规划（Dynamic Programming，DP）。</strong></p>
</blockquote>
<p>我们只是简单地进行预处理，但还是有必要说说 DP 的要素。</p>
<p><strong>状态</strong>：状态（state）就是<strong>用变量表示子问题的解</strong>。我们要做的预处理就是求出两个状态数组，记作 <code>left_max</code> 和 <code>right_max</code>。其中 <code>left_max[i]</code> 表示 <code>height[i]</code> 左侧最高柱子的高度（包括它自己），<code>right_max</code> 同理。</p>
<p><strong>无后效性</strong>：既然已经求到了 <code>left_max[i]</code>，那么无论 <code>left_max[i+1]</code>、<code>left_max[i+2]</code>……的值怎么变，都不会影响 <code>left_max[i]</code>。这样，<strong>未来的问题对现在和过去的问题没有任何影响</strong>，就叫作无后效性（memorylessness）。</p>
<p><strong>最优子结构</strong>：如果我们发现<strong>一个问题的最优解，可以由它的子问题的最优解组合而成</strong>，我们就说这个问题具有<strong>最优子结构</strong>（optimal substructure）。也就是如何把子问题组合成大问题。</p>
<p>在这里的问题是：从现在的 <code>height[i]</code> 往左数，最高的柱子在哪里？很显然，要么是之前已经算好的 <code>left_max[i-1]</code>，要么是当前的 <code>height[i]</code>，也就是：</p>
<pre><code>left_max[i] = std::max(left_max[i-1], height[i]);
</code></pre>
<p>特别地，当 <code>i == 0</code>，最高的柱子就是 <code>height[0]</code>。同理可得：</p>
<pre><code>right_max[i] = std::max(right_max[i+1], height[i]);
</code></pre>
<p><strong>重叠子问题</strong>：暴力法对每个 <code>i</code> 都重新扫描左侧最大值，这个重新的过程，实际上就是多次解决重叠子问题（overlapping subproblems）。</p>
<p>动态规划只需要解决一次重叠子问题，后面再复用，所以它比暴力法快得多。</p>
<p><strong>状态转移方程</strong>：用一个表达式，<strong>从已经得到的状态得出当前的状态</strong>，这个表达式叫做状态转移方程（State transition equation）。</p>
<p>这里的状态转移方程就是在最优子结构中给出的两个表达式。</p>
<pre><code>left_max[i] = std::max(left_max[i-1], height[i]);
right_max[i] = std::max(right_max[i+1], height[i]);
</code></pre>
<p>那么就能写出动态规划预处理的函数，剩下的内容和暴力法差别不大。</p>
<pre><code>int trap_dp(const std::vector&lt;int&gt;&amp; height) {
    if (height.size() &lt; 3)
        return 0;

    int n = height.size();
    std::vector&lt;int&gt; left_max(n), right_max(n);

    int total = 0;

    left_max[0] = height[0];
    for (int i = 1; i &lt; n; i++) {
        left_max[i] = std::max(left_max[i - 1], height[i]);
    }

    right_max[n - 1] = height[n - 1];
    for (int j = n - 2; j &gt;= 0; j--) {
        right_max[j] = std::max(right_max[j + 1], height[j]);
    }

    for (int i = 0; i &lt; n; i++) {
        int water = std::min(left_max[i], right_max[i]) - height[i];
        if (water &gt; 0) {
            total += water;
        }
    }

    return total;
}
</code></pre>
<p>上面的两种方法在本质上是一样的，只有时间复杂度的区别。动态规划预处理的时间复杂度是 <code>O(n)</code>，空间复杂度是 <code>O(n)</code>（开了两个和 <code>height</code> 等大的数组）。</p>
<h2>单调栈</h2>
<p>单调栈跟前面所提到的单调队列一样，栈中的元素是单调递增或递减的。</p>
<p>这里我们使用单调递减的栈（记作 <code>st</code>）来存储待选的左边墙清单，清单里保持从栈底到栈顶高度递减。这样，只要遇到了一个更高的柱子时，意味着发现了一个可以装水的“山谷”被右墙封住了，可以开始结算积水：</p>
<ul>
<li>底：刚弹出来的那根柱子 <code>st.pop()</code>。</li>
<li>左：弹出后新的栈顶 <code>st.top()</code>。</li>
<li>右：当前走到的柱子 <code>i</code>。</li>
</ul>
<p>记得存的是<strong>索引</strong>，不然无法判断到了哪一个左墙元素。</p>
<pre><code>int trap_stack(const std::vector&lt;int&gt;&amp; height) {
    if (height.size() &lt; 3)
        return 0;

    std::stack&lt;int&gt; st;
    int n = height.size();
    int total = 0;

    for (int i = 0; i &lt; n; i++) {
        // 发现了更高的右墙
        while (!st.empty() &amp;&amp; height[i] &gt; height[st.top()]) {
            int bottom = st.top();
            st.pop();
            if (st.empty()) {
                break; // 左侧没有墙了，装不下水
            }

            int left = st.top();
            int width = i - left - 1;

            int water = std::min(height[left], height[i]) - height[bottom];
            // 当遇到更高的柱子时，我们就会弹出栈顶柱子，计算这个柱子和栈顶柱子之间的水量。
            if (water &gt; 0) {
                total += water * width;
            }
        }
        st.emplace(i);
    }
    return total;
}
</code></pre>
<p>对于测试用例：</p>
<pre><code>5
60 20 20 10 30
</code></pre>
<p>在碰到索引为 <code>4</code> 的 <code>30</code> 后，栈会相继弹出 <code>10</code>、<code>20</code> 和 <code>20</code>，同时计算储水量，也就是 <code>10 * 1 + 10 * 3 == 40</code>。</p>
<p>容易发现，单调栈实际上是把每一个山谷里的水划分成横向的一层一层，然后计算每一层的水量再求和。</p>
<h2>双指针</h2>
<p>我们可以设想，用两个指针 <code>l</code> 和 <code>r</code> ，它们相向而行，同时每移动一次，就试着更新当前左/右侧的最高柱子 <code>left_max</code> 和 <code>right_max</code>。</p>
<p>哪边的最高墙更矮（<code>left_max &lt;= right_max</code>），就先结算哪一边。</p>
<p>因为能存的水量取决于 <code>min(left_max, right_max)</code>，短板那一边已经确定水位上限了，另一边再高也不影响这一侧。反之亦然。</p>
<p>所以根据之前双指针的知识，就得到了如下代码。</p>
<pre><code>int trap_2pointers(const std::vector&lt;int&gt;&amp; height) {
    if (height.size() &lt; 3)
        return 0;

    int l = 0, r = height.size() - 1;
    int left_max = height[l], right_max = height[r];
    int total = 0;

    while (l &lt; r) {
        if (left_max &lt;= right_max) {
            l++;
            left_max = std::max(height[l], left_max);
            total += std::max(0, left_max - height[l]);
        } else {
            r--;
            right_max = std::max(height[r], right_max);
            total += std::max(0, right_max - height[r]);
        }
    }

    return total;
}
</code></pre>
<p>走一个实际的例子看看。</p>
<pre><code>height = [4,2,0,3,2,5]

初始：l=0, r=5，更新得 left_max=4, right_max=5

因为 4 &lt;= 5，结算左侧：4-4=0，l=1

l=1：left_max=4，结算：4-2=2，l=2

l=2：结算：4-0=4，l=3

l=3：结算：4-3=1，l=4

l=4：结算：4-2=2，l=5 停止
总水量：0+2+4+1+2=9
</code></pre>
<p>右侧一直有高度 5 作为“足够高的挡板”，左侧的水位上限始终由 <code>left_max=4</code> 决定，所以可以一路放心结算左边。</p>
<p>其中，假如 <code>left_max &lt;= right_max</code>，左指针就会开始向右移动，直到 <code>left_max &gt; right_max</code>。反之亦然。</p>
<hr />
<p>接雨水问题的本质是：<strong>每个位置能装多少水，只取决于它左右两边最高柱子的较小值</strong>。不同解法的区别只在于：你如何更快地得到“左边最高”和“右边最高”，以及如何把重复计算省掉。</p>
<p>暴力和动态规划预处理的本质是相同的，动态规划是先制作了一张速查表，要查找柱子时只需要查表即可。单调栈解法先把左边的柱子暂时缓存，遇到更高的右边柱子就立刻结算。双指针法因为预先决定好了两边的存水量上限，因此在结算其中一侧时，不会被另一侧影响。</p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2025-12-28T06:24:10.948Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[一个让在 UVa 写题目更方便的 VSCode 设置]]></title>
        <id>https://re.karlbaey.top/articles/easier-vsc-cpp-settings-for-uva-online-judge/</id>
        <link href="https://re.karlbaey.top/articles/easier-vsc-cpp-settings-for-uva-online-judge/"/>
        <updated>2025-12-13T16:43:17.778Z</updated>
        <summary type="html"><![CDATA[最近我在练习老古董网站 UVa https://onlinejudge.org。这个网站已经古老到：编译选项只支持 C、Java、C++ 以...]]></summary>
        <content type="html"><![CDATA[<h2>前面的碎碎念</h2>
<p>最近我在练习老古董网站 UVa <a href="https://onlinejudge.org">https://onlinejudge.org</a>。</p>
<p>这个网站已经古老到：</p>
<p><img src="https://cdn.karlbaey.top/file/GiqjZRcq.png" alt="_" /></p>
<p>编译选项只支持 C、Java、C++ 以及 Pascal，其中唯一的解释型语言甚至用的是相对过时的 Python 3.5.1。</p>
<p>而且甚至有一个历史遗留参数 <code>-lcrypt</code>，它在现在的 MinGW[^1] 上是找不到的。</p>
<blockquote>
<p>DeepSeek 给出的解释：</p>
<p><img src="https://cdn.karlbaey.top/file/vezBZ6JI.png" alt="——" /></p>
<p><em>现在 UVa OJ 还叫做 UVa OJ，uHunt 更像是 UVa 使用的一个社交平台。</em></p>
</blockquote>
<p>所以，因为我使用的 GNU Compiler Collection 版本是 15.2.0，就需要在 VSCode 中自己设置一下编译选项。</p>
<h2>开始配置 ⚙️</h2>
<p>之前简要地写过一篇 blog 记录用 VSCode 写 C++。</p>
<p><a href="https://re.karlbaey.top/articles/to-let-your-vs-code-support-cpp/">https://re.karlbaey.top/articles/to-let-your-vs-code-support-cpp/</a></p>
<p>我们仍然是在 Code Runner 这一插件中做手脚，如下所示。</p>
<pre><code>// 原文中的配置
{
  "code-runner.executorMap": {
    "cpp": "cd /d $dir &amp;&amp; g++ -fexec-charset=GBK $fileName -o $fileNameWithoutExt &amp;&amp; .\\$fileNameWithoutExt.exe"
    // 其余省略
  }
}
</code></pre>
<p>现在把它改成这样：</p>
<pre><code>{
  "cpp": "cd /d $dir\ng++ -std=c++11 -O2 $fileName -o rel.exe\n.\\rel.exe &lt; input.txt &gt; output.txt"
}
</code></pre>
<p>这里其实是执行了 3 条命令。</p>
<pre><code>cd /d $dir
g++ -lm -O2 -std=c++11 -pipe -DONLINE_JUDGE $fileName -o rel.exe
.\rel.exe &lt; input.txt &gt; output.txt
</code></pre>
<ol>
<li>进入文件所带目录（顺便更换盘符）。</li>
<li>使用 UVa 提供的参数编译文件。</li>
<li>将标准输入调整为文件 <code>input.txt</code>，标准输出调整为文件 <code>output.txt</code>。</li>
</ol>
<p>只要在源代码所在的目录新建一个文件 <code>input.txt</code>，然后把测试样例写进去，这样 VSCode 就会自动测试好并把结果存进 <code>output.txt</code> 中了[^2]！</p>
<p>用 <a href="https://onlinejudge.org/index.php?option=com_onlinejudge&amp;Itemid=8&amp;page=show_problem&amp;category=0&amp;problem=431"><code>UVa_490_-_Rotating_Sentences</code></a> 试试看。</p>
<p><img src="https://cdn.karlbaey.top/file/vwMM6PC0.png" alt="_" /></p>
<p>接着在写好的 <code>UVa_490_-_Rotating_Sentences.cpp</code> 文件窗口内按下 &lt;kbd&gt;Ctrl&lt;/kbd&gt; + &lt;kbd&gt;'&lt;/kbd&gt;。</p>
<p><img src="https://cdn.karlbaey.top/file/JCMh9QzI.png" alt="_" /></p>
<p>什么都没有发生。打开 <code>output.txt</code> 看看。</p>
<p><img src="https://cdn.karlbaey.top/file/jvKa5v8J.png" alt="_" /></p>
<p><em>太多就不一一展示了。</em></p>
<p>这样就算配置好了。上面提供的 Code Runner 配置亲测可用，可以直接粘贴到 <code>settings.json</code> 里。</p>
<hr />
<p>喜报：过了！</p>
<p><img src="https://cdn.karlbaey.top/file/RuJG7g3L.png" alt="_" /></p>
<p>[^1]: 我的主力系统是 Windows 11，无法避免使用 MinGW。
[^2]: 没有 <code>output.txt</code> 的话，会自动创建一个。</p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2025-12-13T16:43:17.778Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[程序设计#4%2 - 【经典算法】双指针和滑动窗口·第三部分]]></title>
        <id>https://re.karlbaey.top/articles/program-design-two-pointer-algorithm-and-sliding-window-sep-three/</id>
        <link href="https://re.karlbaey.top/articles/program-design-two-pointer-algorithm-and-sliding-window-sep-three/"/>
        <updated>2025-11-02T05:07:47.773Z</updated>
        <summary type="html"><![CDATA[在上一期的【双指针和滑动窗口·第二部分】，我们着重接触了滑动窗口的两种最基本情况：定长窗口（常用于寻找子数组求和的最大值，子串内最多元音数等...]]></summary>
        <content type="html"><![CDATA[<p>在<a href="https://karlbaey.top/articles/program-design-two-pointer-algorithm-and-sliding-window-sep-two/">上一期</a>的【双指针和滑动窗口·第二部分】，我们着重接触了滑动窗口的两种最基本情况：<strong>定长窗口</strong>（常用于寻找子数组求和的最大值，子串内最多元音数等问题）以及<strong>不定长窗口</strong>（例如上一期 <a href="https://leetcode.cn/problems/minimum-window-substring/description/">leco 76</a> 为代表的“越短越好”窗口和 <a href="https://leetcode.cn/problems/minimum-operations-to-reduce-x-to-zero/description/">leco 1658</a> 为代表的“越长越好”窗口）。</p>
<p>但是更多时候我们不能单纯地使用这一类窗口。比如，如果我们需要求得每次窗口移动时，窗口内的最大值，这时候以过去的技巧来说会很麻烦，最直观的想法就是对每个窗口进行排序。但是那样的时间复杂度会退化到 <code>O(nk)</code>，数组长度大起来这就不可接受了。所以我们得给上一期探照灯，也就是朴素的滑动窗口，加一些 plugin。</p>
<h2>求窗口内的最大/小值 - 单调队列</h2>
<p>对于求最值，最朴素的观点当然就是通过遍历每一个窗口，接着寻找最值。简单遍历的时间复杂度是 <code>O(k)</code>，加上滑动窗口本身的复杂度 <code>O(n)</code>，总的时间复杂度就会退化到 <code>O(nk)</code>，这是不可接受的。因此我们借助一些新的工具，这个工具能够时刻返回窗口内最大/最小的元素。</p>
<p>这个工具叫做<strong>单调队列</strong>（monotonic queue），一个单调队列中的所有元素总保持单调性。它和通常的队列一样，有一个头和一个尾，我们压入元素时都是从队尾压入。</p>
<blockquote>
<p>能从两端进，两端出的数据结构叫做双端队列（double-ended queue，简称 <strong>deque</strong>）。</p>
</blockquote>
<p>首先应该做的是为这个双端队列定下三条规则。</p>
<ol>
<li>（入队）当一个新元素入队时，它应该从队尾开始，把所有比自己小的元素全部从队尾移出队列，然后自己进入。<strong>这条规则保证了队列中所有的元素必定是单调递减的。</strong></li>
<li>（取队头）队列是单调递减的，因此队头永远是最大值。</li>
<li>（过期）每压入一个元素，都应该判断当前队头元素是否已经不在窗口内。如果是就把队头元素移出队列。</li>
</ol>
<p>*取最小值只需要改为入队时移出所有比自己大的元素。*现在是第二个问题：<strong>应该存储下标，还是元素本身？</strong></p>
<p>答案是<strong>下标</strong>。我们需要根据队列内元素在原数组中的下标来决定是否移除队头元素（第三条规则），假如 <code>nums = [2, 2, 2], k = 3</code>，我们无法区分这三个 <code>2</code>。</p>
<p>现在我们通过一个实际的例子来看看单调队列是如何工作的。</p>
<pre><code>nums = [1, 3, -1, -5, 3, 6, 7], k = 3
此处用到的单调递减队列称作 q
</code></pre>
<p><img src="https://gcore.jsdelivr.net/gh/Karlbaey/tutu@master/picture10/deque1.SVG" alt="deque1" /></p>
<p><img src="https://gcore.jsdelivr.net/gh/Karlbaey/tutu@master/picture10/deque2.SVG" alt="deque2" /></p>
<p>理论存在，实践开始。Go 中没有内置的双端队列[^1]，我们使用一个切片来模拟。</p>
<pre><code>type Deque []int

func (d *Deque) PushBack(element int) { *d = append(*d, element) }
func (d *Deque) PopBack()             { *d = (*d)[:len(*d)-1] }
func (d *Deque) Front() int           { return (*d)[0] }
func (d *Deque) Back() int            { return (*d)[len(*d)-1] }
func (d *Deque) IsEmpty() bool        { return len(*d) == 0 }
func (d *Deque) PopFront(l int) {
    if !d.IsEmpty() &amp;&amp; d.Front() &lt; l { // 这是一个判断元素的逻辑，只有队头元素过期才会移出，否则什么都不做
        *d = (*d)[1:]
    }
}
</code></pre>
<p>有了单调队列，我们只需要套用滑动窗口的公式就能解决<a href="https://www.luogu.com.cn/problem/P1886">模板题</a>了。</p>
<pre><code>func main() {
    reader := bufio.NewReader(os.Stdin)
    writer := bufio.NewWriter(os.Stdout)
    defer writer.Flush()

    line, _ := reader.ReadString('\n')
    parts := strings.Fields(line)
    n, _ := strconv.Atoi(parts[0])
    k, _ := strconv.Atoi(parts[1])

    line, _ = reader.ReadString('\n')
    parts = strings.Fields(line)
    nums := make([]int, n)
    for i := range n {
        nums[i], _ = strconv.Atoi(parts[i])
    }

    var mind, maxd Deque
    minRes := make([]string, 0, n-k+1)
    maxRes := make([]string, 0, n-k+1)

    for i, num := range nums {
        for !mind.IsEmpty() &amp;&amp; num &lt;= nums[mind.Back()] { // 存储的是下标，因此要用 nums[i] 访问实际值
            mind.PopBack()
        }
        mind.PushBack(i)
        mind.PopFront(i - k + 1) // 当前窗口的左端

        for !maxd.IsEmpty() &amp;&amp; num &gt;= nums[maxd.Back()] {
            maxd.PopBack()
        }
        maxd.PushBack(i)
        maxd.PopFront(i - k + 1)

        if i &gt;= k-1 {
            minRes = append(minRes, strconv.Itoa(nums[mind.Front()]))
            maxRes = append(maxRes, strconv.Itoa(nums[maxd.Front()]))
        }
    }

    fmt.Fprintln(writer, strings.Join(minRes, " "))
    fmt.Fprintln(writer, strings.Join(maxRes, " "))
}
</code></pre>
<p>或者是 <a href="https://leetcode.cn/problems/sliding-window-maximum/">239. 滑动窗口最大值 - 力扣（LeetCode）</a>，其中 <code>Deque</code> 的定义不变。</p>
<pre><code>func maxSlidingWindow(nums []int, k int) []int {
    n := len(nums)
    var maxd Deque
    maxRes := make([]int, 0, n-k+1)

    for i, num := range nums {
        for !maxd.IsEmpty() &amp;&amp; num &gt;= nums[maxd.Back()] { // 存储的是下标，因此要用 nums[i] 访问实际值
            maxd.PopBack()
        }
        maxd.PushBack(i)
        maxd.PopFront(i - k + 1) // 当前窗口的左端

        if i &gt;= k-1 {
            maxRes = append(maxRes, nums[maxd.Front()])
        }
    }

    return maxRes
}
</code></pre>
<h2>满足 K 个条件 - 求子数组个数</h2>
<p>这里的<strong>满足 K 个条件</strong>通常指子数组中有 K 个字符或整数，或者是对子数组中的 K 个整数求积小于给定值。</p>
<p>先看两种简单的：越短越好和越长越好。</p>
<h3>越长越好的子数组</h3>
<p>首先看这一题：<a href="https://leetcode.cn/problems/number-of-substrings-containing-all-three-characters/description/">1358. 包含所有三种字符的子字符串数目 - 力扣（LeetCode）</a>。</p>
<p>先想象一下，我们从前三个字符开始，扫描子字符串，然后计算字符数，这样的暴力解法会造成非常恐怖的时间消耗。</p>
<p>所以我们试着总结一下子字符串的特点，看看能不能避免掉暴力搜索。</p>
<pre><code>s = "aacbbabcc"
</code></pre>
<p>我们发现 <code>s</code> 中，从第一个字符开始搜索，第一个满足要求的子字符串是 <code>s[0:4] == "aacb"</code> 。</p>
<p>那我们就发现，<code>s[0:4], s[0:5], ..., s[0:9]</code>，这几个子串也是满足要求的，因为他们一定包含了前四个字符 <code>"aacb"</code>。这些加起来一共是 <code>len(s)-r</code>，其中 <code>r</code> 是当前窗口的右端点，此时答案加上 6（9-3）。</p>
<p>然后是 <code>s[1:4] == "acb"</code>，这同样满足条件，所以 <code>s[1:4], s[1:5], ..., s[1:9]</code> 这些也都是满足条件的，一共是 <code>len(s)-r</code>，也就是 6 个。</p>
<p>以此类推，也就得到了<strong>越长越好子数组</strong>的解法。</p>
<pre><code>func numberOfSubstrings(s string) int {
    var freq [3]int
    var l, ans int
    for r, ch := range s {
        freq[ch-'a']++ // 0、1 和 2 分别代表 a、b 和 c
        for freq[0] &gt; 0 &amp;&amp; freq[1] &gt; 0 &amp;&amp; freq[2] &gt; 0 { // 子串中同时包含 a、b 和 c
            ans += len(s)-r
            freq[s[l]-'a']--
            l++
        }
    }
    return ans
}
</code></pre>
<p>因此我们能从 leco1358 中抽象出一个具有普遍性的解法：一旦窗口扫描到一个符合条件的窗口，那么此时记 <code>r</code> 为窗口的右端点，一共有 <code>len(arr)-r</code> 个窗口符合条件，接着继续往后扫描即可。</p>
<p>因此 <a href="https://leetcode.cn/problems/count-subarrays-where-max-element-appears-at-least-k-times/">2962. 统计最大元素出现至少 K 次的子数组 - 力扣（LeetCode）</a> 除了把字符串换成了数组，其余的思想是完全一致的。为了节约时间，可使用 <code>slices</code> 包的 <code>slices.Max()</code> 函数快速获得数组内的最大值。</p>
<pre><code>func countSubarrays(nums []int, k int) int64 {
    target := slices.Max(nums)
    var l, now int
    var ans int64
    for r, num := range nums {
        if num == target {
            now++
        }
        for now &gt;= k {
            ans += int64(len(nums)-r) // 当前窗口符合条件，意味着有 len(nums)-r 个窗口符合条件。
            if nums[l] == target { // 窗口收缩
                now--
            }
            l++
        }
    }
    return ans
}
</code></pre>
<p>当然，除了越长越好，还有一种越短越好的子数组，这时候我们换一个思路。</p>
<h3>越短越好的子数组</h3>
<p>这类问题最经典的就是寻找子数组求和不大于给定值。</p>
<p>不过这里有个求积的，也就是<a href="https://leetcode.cn/problems/subarray-product-less-than-k/description/">713. 乘积小于 K 的子数组 - 力扣（LeetCode）</a>。</p>
<p>因为是正整数求积，所以当 <code>k &lt;= 1</code> 时，不可能有子数组符合条件。</p>
<p>接着我们为了避免暴力搜索，观察一下满足条件的子数组有什么特征。</p>
<pre><code>nums = [2,3,5,6,7,1,1,2]
k    = 100
</code></pre>
<p>第一个满足条件的子数组是 <code>[2]</code>，没什么特别的。</p>
<p>第二个满足条件的子数组是 <code>[2, 3]</code>，这时候我们发现，以 3 为右端点的子数组中，一共有 2 个子数组符合条件，分别是 <code>[2, 3]</code> 和 <code>[3]</code>。</p>
<p>第三个满足条件的子数组是 <code>[2, 3, 5]</code>，与第二个类似，以 5 为右端点的子数组中，一共有 3 个子数组符合条件，分别是 <code>[2, 3, 5]</code>、<code>[3, 5]</code> 和 <code>[5]</code>。</p>
<p>原因是，右端点相同，子数组越短当然子数组求积越小，就越能符合条件。</p>
<p>记当前窗口右端点为 <code>r</code>，左端点为 <code>l</code>，显然此时右端点相同的子数组一共有 <code>r-l+1</code> 个。</p>
<p>将上面的逻辑融入滑动窗口的模板中，于是得到了这里的代码。</p>
<pre><code>func numSubarrayProductLessThanK(nums []int, k int) int {
    if k &lt;= 1 { // 子数组求积不可能比这还要小，跳过
        return 0
    }
    
    var l, ans int
    var now int = 1
    for r, num := range nums {
        now *= num
        for now &gt;= k { // 收缩的条件
            now /= nums[l]
            l++
        }
        ans += r-l+1 // 相同右端点一共有 r-l+1 个子数组
    }
    return ans
}
</code></pre>
<p>有一道困难题的思路与这非常类似：<a href="https://leetcode.cn/problems/count-subarrays-with-score-less-than-k/description/">2302. 统计得分小于 K 的子数组数目 - 力扣（LeetCode）</a>。</p>
<p>上面的结论可以直接套用：右端点相同，子数组越短那么子数组的得分必定越小。</p>
<p>于是能直接推出代码，如下。</p>
<pre><code>func countSubarrays(nums []int, k int64) (ans int64) {
    var now, l int
    
    for r := range nums {
        now += nums[r]
        for int64(now * (r-l+1)) &gt;= k { // 得分超过了 k
            now -= nums[l]
            l++
        }
        ans += int64(r-l+1) // 右端点为 r，符合条件的子数组数目
    }
    return ans
}
</code></pre>
<h3>恰好满足 K 个条件</h3>
<p>我们常见的<strong>恰好满足 K 个条件</strong>，大多是在数组中寻找一个求和为 K 的子数组。</p>
<p>如果要用通常的滑动窗口思路解决，那么直接写判断子数组和的逻辑会非常复杂，因为一旦移动端点，子数组的和就可能会变。也就是说：</p>
<ul>
<li>如果我移动 <code>r</code>，我可能会找到一个<strong>以 <code>l</code> 开头的更长的</strong>合格子数组。</li>
<li>如果我移动 <code>l</code>，我可能会找到一个<strong>以 <code>r</code> 结尾的更短的</strong>合格子数组。</li>
</ul>
<p>对于这种情况，我们直接给出公式：<code>ans = AtMost(K)-AtMost(K-1)</code>，<code>AtMost(K)</code> 这个函数能够求出和小于或等于 <code>K</code> 的子数组数目。证明在下方。</p>
<p>看一道例题：<a href="https://leetcode.cn/problems/binary-subarrays-with-sum/description/">930. 和相同的二元子数组 - 力扣（LeetCode）</a></p>
<p>这就是一道标准的恰好满足 K 个条件的滑动窗口题。</p>
<p>要找恰好求和等于 <code>goal</code> 的数组，如果我们直接求解，这就相当于暴力了：需要枚举每一个左端点和右端点。</p>
<p>我们看看上面的<strong>越短越好的子数组</strong>，假如我们需要求的是求和小于或等于 <code>goal</code> 的子数组，我们在上面已经知道怎么做了。</p>
<p>这时候，我们得到的子数组数目一共包含了 <code>goal, goal-1, goal-2, ..., 0</code> 的组合。注意看！里面是包含了等于 <code>goal</code> 的子数组数目的。</p>
<p>那么下一步就很清晰了，我们只要求出包含了 <code>goal-1, goal-2, ..., 0</code> 的组合，然后用前面求出的减去这个，答案就出来了。</p>
<p>这等价于求和小于或等于 <code>goal-1</code> 的子数组。</p>
<p>我们套用上面公式的 <code>AtMost()</code> 函数，给出答案。</p>
<pre><code>func AtMost(K int, nums []int) int {
    var l, now int
    var ans int
    for r := range nums {
        now += nums[r]
        for now &gt; K &amp;&amp; l &lt;= r {
            // 注意，当 K == -1（即 goal == 0） 时，这个条件是恒满足的，因此需要判断是否越界
            now -= nums[l]
            l++
        }
        ans += r-l+1
    }
    return ans
}

func numSubarraysWithSum(nums []int, goal int) int {
    return AtMost(goal, nums)-AtMost(goal-1, nums)
}
</code></pre>
<hr />
<p>那么，从定长滑动窗口开始，到这里的更加灵活的滑动窗口，我们一共经历了三个部分，分别是：</p>
<ol>
<li>快慢指针和对撞指针
<ul>
<li>数组去重</li>
<li>寻找链表中点与链表环的起点</li>
<li>二分查找</li>
<li>寻找元组问题（有序数组内的两数之和等）</li>
</ul>
</li>
<li>滑动窗口 - 定长与不定长
<ul>
<li>子数组和最大/小</li>
<li>维护数组内元素的唯一性（基于哈希表）</li>
<li>破洞数组</li>
<li>循环数组内的滑动窗口（基于取余运算实现循环）</li>
<li>反向窗口（正难则反思想）</li>
<li>最小覆盖子串（越短越好的子数组）</li>
</ul>
</li>
<li>（本节）窗口内求最值和（最多/最少/恰好）满足 K 个条件
<ul>
<li>单调队列</li>
<li>求满足 K 个条件的子数组的个数</li>
</ul>
</li>
</ol>
<p>下一期将会基于一道困难题： <a href="https://leetcode.cn/problems/trapping-rain-water/">LeetCode 42. 接雨水</a> 继续双指针算法的学习，并且我们将会引入多数组中的指针协作（也就是两个指针在两个不同的数组里运动）。</p>
<p>🎉<em><strong>撒花</strong></em>🎉</p>
<p>[^1]: C++ 标准模板库有一个 deque 模板，跟这里使用的是一致的，可通过 <code>#include &lt;deque&gt;</code> 引入。</p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2025-11-02T05:07:47.773Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[程序设计#4%1 - 【经典算法】双指针和滑动窗口·第二部分]]></title>
        <id>https://re.karlbaey.top/articles/program-design-two-pointer-algorithm-and-sliding-window-sep-two/</id>
        <link href="https://re.karlbaey.top/articles/program-design-two-pointer-algorithm-and-sliding-window-sep-two/"/>
        <updated>2025-10-05T13:08:57.510Z</updated>
        <summary type="html"><![CDATA[上一期中，我们接触了双指针算法的两种基本用法：快慢指针和对撞指针。其中，快慢指针适用于解决去重问题以及解决有环的链表的问题；而对撞指针适合在...]]></summary>
        <content type="html"><![CDATA[<p><a href="https://karlbaey.top/articles/program-design-two-pointer-algorithm-and-sliding-window-sep-one/">上一期</a>中，我们接触了双指针算法的两种基本用法：<strong>快慢指针</strong>和<strong>对撞指针</strong>。其中，快慢指针适用于解决去重问题以及解决有环的链表的问题；而对撞指针适合在排好序的数组中寻找元组（例如元组中每个数的和恰好是定值）。</p>
<p>我们提到，双指针算法就像是在漆黑的走廊里派出两名干员。现在我们把干员换成一盏大功率探照灯，它能够一次把走廊中的一部分照亮，原来的两名干员就变成了光照亮之处的左右端点。</p>
<p>有没有发现，这段被照亮的地方就好比在走廊中打开了一个窗口。所以，这种算法思想就叫做<strong>滑动窗口算法</strong>。</p>
<h2>滑动窗口 - 长度固定</h2>
<p>我们从简单的情况说起。</p>
<p>假设我们在大数组里寻找一个长度固定为 <code>k</code> 的子数组，并且让小数组元素的平均值最大，因为子数组长度固定，所以我们需要让子数组元素的和最大。</p>
<p>我们可以使用 <code>now</code> 来记录当前窗口内的和，<code>ans</code> 每次循环都与 <code>now</code> 比较取较大值。</p>
<pre><code>ans = max(now, ans)
</code></pre>
<p>这也就是 <a href="https://leetcode.cn/problems/maximum-average-subarray-i/description/">leco634 子数组最大平均数 I</a> 的过程。</p>
<h3>子数组 - 和最大/最小</h3>
<p>leco643：</p>
<blockquote>
<p>给你一个由 <code>n</code> 个元素组成的整数数组 <code>nums</code> 和一个整数 <code>k</code> 。</p>
<p>请你找出平均数最大且 <strong>长度为 <code>k</code></strong> 的连续子数组，并输出该最大平均数。</p>
<p>任何误差小于 10&lt;sup&gt;-5&lt;/sup&gt; 的答案都将被视为正确答案。</p>
</blockquote>
<p>定长滑动窗口其实就是速度相同且同向而行的双指针，因此我们只要用快慢指针的思想改一改即可。在这个过程中我们需要在 <code>now</code> 加上每个窗口右端点（用 <code>nums[r]</code> 表示）的值，并且在窗口长度已经达到 <code>k</code> 时，减去窗口左端点的值（用 <code>nums[l]</code> 表示）。也就是说：</p>
<ol>
<li>窗口右端进入（<code>now += nums[r]</code>）</li>
<li>判断 <code>r &gt;= k</code>
<ol>
<li><code>true</code>：更新 <code>ans</code> 的值（<code>ans = max(now, ans)</code> ）</li>
<li><code>false</code>：什么也不做，进入下一次循环</li>
</ol>
</li>
<li><code>now</code> 减去左端点的值 <code>nusm[l]</code></li>
</ol>
<blockquote>
<p><code>l == r-k+1</code> 的原因：</p>
<p>数组中，从 <code>nums[l]</code> 到 <code>nums[r]</code> 应该有 <code>k</code> 个元素。也就是说，从 <code>l</code> 开始数，数 <code>(k-1)</code> 个数就是 <code>r</code>。如下所示。</p>
<p><code>l == r-(k-1) =&gt; l == r-k+1</code></p>
</blockquote>
<p>⚠️<strong>注意</strong>：因为要求找的是数组中的<strong>最大平均值</strong>，所以必须把 <code>ans</code> 初始化为一个很小的数。把 <code>ans</code> 初始化为 <code>0</code> 的话，如果数组中的每个数都是负数，那就会出现返回值是 <code>0</code> 的错误（此时 <code>ans</code> 必然大于等于任何情况下的 <code>now</code>）。<em>这是一个坑点，我自己就踩过好几次。</em></p>
<p>写成代码，如下所示。</p>
<pre><code>func findMaxAverage(nums []int, k int) float64 {
    var now int
    var ans int = -2147483647 // 一个小到不可能的数
    for r := range nums {
        now += nums[r] // 一定要窗口右端先进！

        l := r-k+1
        if l &lt; 0 { // 窗口长不足 k
            continue
        }

        ans = max(ans, now) // 取最大值

        now -= nums[l] // 左端出窗口
    }

    return float64(ans) / float64(k)
}
</code></pre>
<p>当然了，对于字符串，我们也可以用类似的方案。例如 <a href="https://leetcode.cn/problems/minimum-recolors-to-get-k-consecutive-black-blocks/description/">leco2379</a>。</p>
<blockquote>
<p>给你一个长度为 <code>n</code> 下标从 <strong>0</strong> 开始的字符串 <code>blocks</code> ，<code>blocks[i]</code> 要么是 <code>'W'</code> 要么是 <code>'B'</code> ，表示第 <code>i</code> 块的颜色。字符 <code>'W'</code> 和 <code>'B'</code> 分别表示白色和黑色。</p>
<p>给你一个整数 <code>k</code> ，表示想要 <strong>连续</strong> 黑色块的数目。</p>
<p>每一次操作中，你可以选择一个白色块将它 <strong>涂成</strong> 黑色块。</p>
<p>请你返回至少出现 <strong>一次</strong> 连续 <code>k</code> 个黑色块的 <strong>最少</strong> 操作次数。</p>
</blockquote>
<p>这道题实际上就是在问：<strong>一个长为 <code>k</code> 的子串中，<code>'B'</code> 的最大数目是多少？</strong> <em>也可以是问 <code>'W'</code> 的最小数目。</em></p>
<p>所以运用与上面类似的思路，就能得到如下的代码。</p>
<pre><code>func minimumRecolors(blocks string, k int) int {
    var now, ans int
    for r := range blocks {
        // 这里是求最多的黑色
        // 如果换成 if blocks[r] == 'W' {} 那就是求最少的白色
        if blocks[r] == 'B' {
            now++
        }
        // 下方操作同 leco643
        l := r-k+1
        if l &lt; 0 {
            continue
        }

        ans = max(now, ans)

        if blocks[l] == 'B' {
            now--
        }
    }
    return k-ans // 这里因为求到的是 B 的最大值，所以要用 k 去减
}
</code></pre>
<p>我们能够从上面的过程推导出一个<strong>定长滑动窗口</strong>的标准流程。</p>
<ol>
<li><strong>进入窗口</strong>：右端点进入窗口，常常写作 <code>now += nums[r]</code>。</li>
<li><strong>判断边界</strong>：因为我们已经知道，左端点 <code>l</code> 实际上就是 <code>r-k+1</code>，所以必须确保 <code>l &gt;= 0</code>（否则会超出索引），这样能确保窗口长度必定在等于 <code>k</code> 时才会更新数据。
<ul>
<li>如果不满足 <code>l &gt;= 0</code> 就进入下一次循环，不更新数据。</li>
</ul>
</li>
<li><strong>更新数据</strong>：如果第 2 步的判断为 <code>true</code>，那就把当前 <code>ans</code> 和 <code>now</code> 中的较大值赋给 <code>ans</code>。这样到最后 <code>ans</code> 就是子数组和的最大值。</li>
<li><strong>退出窗口</strong>：<code>now -= nums[l]</code>。</li>
</ol>
<p>上面四步是定长滑动窗口的模板解法，适用于每一个定长滑动窗口问题。<strong>通常来说，最困难的部分就是寻找窗口大小和起始点。因为窗口不仅能从左往右滑，也可以从右往左滑。通过合适地确定 <code>r</code> 值能将这两种情况合并到同一个循环中。</strong></p>
<blockquote>
<p>时间复杂度：<code>O(n)</code>。右端点需要访问每一个元素。</p>
<p>空间复杂度：通常为 <code>O(1)</code>，但如果需要用哈希表等记录窗口内元素信息，则可能为 <code>O(k)</code> 或 <code>O(字符集大小)</code>[^1]。</p>
</blockquote>
<p>那么接下来就看一些寻找符合条件窗口的问题。</p>
<h3>子数组 - 数组内元素唯一</h3>
<p>现在我们给窗口增加一个限制：子数组必须保证有若干个元素互不相同。</p>
<p>这可以用 Go 中的<strong>映射</strong>（map）来记录元素出现次数。<em>像这样，在一个数据类型中，一个值对应另一个值的，我们叫它哈希表（Hashmap）。下同。</em></p>
<pre><code>freq := map[int]int{}
</code></pre>
<p>上面就是一个统计字符出现顺序的哈希表。我们可以使用 <code>freq[num]++</code> 和 <code>freq[num]--</code> 来修改元素出现顺序。</p>
<p>以 <a href="https://leetcode.cn/problems/maximum-sum-of-almost-unique-subarray/description/">2841. 几乎唯一子数组的最大和 - 力扣（LeetCode）</a>和为例。我们至少需要让 <code>len(freq) &gt;= m</code> 才更新 <code>ans</code>。<em><a href="https://leetcode.cn/problems/maximum-sum-of-distinct-subarrays-with-length-k/description/">2461. 长度为 K 子数组中的最大和 - 力扣（LeetCode）</a> 则只需要让 <code>len(freq) == k</code> 即可。</em></p>
<blockquote>
<p>给你一个整数数组 <code>nums</code> 和两个正整数 <code>m</code> 和 <code>k</code> 。</p>
<p>请你返回 <code>nums</code> 中长度为 <code>k</code> 的 <strong>几乎唯一</strong> 子数组的 <strong>最大和</strong> ，如果不存在几乎唯一子数组，请你返回 <code>0</code> 。</p>
<p>如果 <code>nums</code> 的一个子数组有至少 <code>m</code> 个互不相同的元素，我们称它是 <strong>几乎唯一</strong> 子数组。</p>
<p>子数组指的是一个数组中一段连续 <strong>非空</strong> 的元素序列。</p>
</blockquote>
<p>在这里，每当右端点进入窗口时，就需要 <code>freq[nums[r]]++</code>；左端点出窗口时，则需要 <code>freq[nums[l]]--</code> 并且注意删到 <code>0</code> 就要清空哈希表中的这一项。（因为我们实际用到的是哈希表的长度）</p>
<pre><code>func maxSum(nums []int, m int, k int) int64 {
    var ans, now int
    freq := map[int]int{} // 统计字符出现次数

    for r := range nums {
        now += nums[r]
        freq[nums[r]]++

        l := r-k+1
        if l &lt; 0 {
            continue
        }

        if len(freq) &gt;= m { 
            ans = max(ans, now)
        }
        
        // 如果是 leco2641
        /*
        if len(freq) == k {
            ans = max(ans, now)
        }
        */

        now -= nums[l]
        freq[nums[l]]--
        if freq[nums[l]] == 0 {
            delete(freq, nums[l])
        }
    }
    return int64(ans)
}
</code></pre>
<h3>子数组 - 窗口有破洞</h3>
<p>现在的走廊上有无法照亮的地方，即使我们用探照灯，也什么都看不见。但是幸运的是，我们不需要考虑破洞的元素是否需要加入窗口里，因为无论如何答案里都会加上破洞（<strong>无需考虑的元素</strong>）的值。</p>
<p>我们看看怎样把这部分破洞正确处理。</p>
<p><a href="https://leetcode.cn/problems/grumpy-bookstore-owner/description/">1052. 爱生气的书店老板 - 力扣（LeetCode）</a></p>
<blockquote>
<p>有一个书店老板，他的书店开了 <code>n</code> 分钟。每分钟都有一些顾客进入这家商店。给定一个长度为 <code>n</code> 的整数数组 <code>customers</code> ，其中 <code>customers[i]</code> 是在第 <code>i</code> 分钟开始时进入商店的顾客数量，所有这些顾客在第 <code>i</code> 分钟结束后离开。</p>
<p>在某些分钟内，书店老板会生气。 如果书店老板在第 <code>i</code> 分钟生气，那么 <code>grumpy[i] = 1</code>，否则 <code>grumpy[i] = 0</code>。</p>
<p>当书店老板生气时，那一分钟的顾客就会不满意，若老板不生气则顾客是满意的。</p>
<p>书店老板知道一个秘密技巧，能抑制自己的情绪，可以让自己连续 <code>minutes</code> 分钟不生气，但却只能使用一次。</p>
<p>请你返回 <em>这一天营业下来，最多有多少客户能够感到满意</em> 。</p>
</blockquote>
<p>我们总结一下题目，能够得到这样的事实。</p>
<ul>
<li><code>minutes</code> 是窗口长度。</li>
<li><code>grumpy[i] == 1</code> 则应该将 <code>customers[i]</code> 加入窗口。</li>
<li><code>grumpy[i] == 0</code> 则应该忽略 <code>customers[i]</code>。</li>
<li>无论 <code>grumpy[i]</code> 是多少，<code>customers[i]</code> 都会占用窗口长度。
<ul>
<li><em>也就是说窗口内满足 <code>grumpy[i] == 0</code> 的 <code>customers[i]</code> 可视作 <code>0</code></em>。</li>
</ul>
</li>
</ul>
<p>我们可以使用一个数组 <code>sat</code> 来记录，<code>sat[0]</code> 表示老板不生气时的顾客数目，<code>sat[1]</code> 表示老板生气但是抑制住情绪（即仍然满意）的顾客数。</p>
<p>接着用条件控制 <code>customers[i]</code> 的进出窗口，如下所示。</p>
<pre><code>var sat = [2]int{}

if grumpy[r] == 1 {
    now += customers[r]
} else {
    sat[grumpy[i]] += customers[r]
}

// ...

if grumpy[l] == 1 {
    now -= customers[l]
}
</code></pre>
<p>知道了怎么处理破洞的窗口，那就很好写出完整的实现代码了。</p>
<pre><code>func maxSatisfied(customers []int, grumpy []int, minutes int) int {
    var sat = [2]int{} // 最多满意的顾客数
    var now int // 老板愤怒时的顾客数（窗口长度为 minutes）
    for r, c := range customers {
        // 下面的 if else 用来分流老板状态不同时的顾客数
        if grumpy[r] == 1 {
            now += c
        } else {
            sat[grumpy[r]] += c
        }
        
        l := r - minutes + 1
        if l &lt; 0 {
            continue
        }

        sat[1] = max(now, sat[1]) // 更新最多的老板愤怒时的顾客数

        if grumpy[l] == 1 {
            now -= customers[l]
        }
    }
    return sat[0] + sat[1]
}
</code></pre>
<h4>自己挖破洞 - 抛弃元素问题</h4>
<p>我们把上面的<em>数组内元素唯一</em>和<em>窗口有破洞</em>结合起来。我们此时需要保证窗口内有不超过 <code>m</code> 个相同的元素，多了要扔掉。</p>
<p>扔掉的元素不再计入窗口内元素之和，但是仍然占用窗口长度。</p>
<p>这就是 <a href="https://leetcode.cn/problems/minimum-discards-to-balance-inventory/">leco3679</a>。</p>
<blockquote>
<p>给你两个整数 w 和 m，以及一个整数数组 arrivals，其中 arrivals[i] 表示第 i 天到达的物品类型（天数从 1 开始编号）。</p>
<p>物品的管理遵循以下规则：</p>
<ul>
<li>每个到达的物品可以被 保留 或 丢弃 ，物品只能在到达当天被丢弃。</li>
<li>对于每一天 <code>i</code>，考虑天数范围为 <code>[max(1, i - w + 1), i]</code>（也就是直到第 <code>i</code> 天为止最近的 <code>w</code> 天）：
<ul>
<li>对于 任何 这样的时间窗口，在被保留的到达物品中，每种类型最多只能出现 <code>m</code> 次。</li>
<li>如果在第 <code>i</code> 天保留该到达物品会导致其类型在该窗口中出现次数 超过 <code>m</code> 次，那么该物品必须被丢弃。</li>
</ul>
</li>
</ul>
<p>返回为满足每个 <code>w</code> 天的窗口中每种类型最多出现 <code>m</code> 次，最少 需要丢弃的物品数量。</p>
</blockquote>
<p>只要用哈希表记录元素出现次数，并且在<strong>抛弃元素后把被抛弃的元素设置为 <code>0</code> 即可</strong>。<em>这是因为原题中的数据范围决定了 <code>arrivals[i] &gt;= 1</code>，如果有 <code>0</code> 就不能这样做。</em></p>
<p>剩下的部分与常规滑动窗口完全一致。</p>
<pre><code>func minArrivalsToDiscard(arrivals []int, w int, m int) int {
    var freq = map[int]int{} // 记录元素出现次数
    var ans int
    for r := range arrivals {
        freq[arrivals[r]]++
        if freq[arrivals[r]] &gt; m {
            ans++
            freq[arrivals[r]]--
            // 易错点！否则出窗口时会导致已经抛弃的元素再抛弃一次
            arrivals[r] = 0
        }

        l := r - w + 1
        if l &lt; 0 {
            continue
        }

        freq[arrivals[l]]--
    }
    return ans
}
</code></pre>
<h3>寻找窗口</h3>
<h4>窗口初始位置不同 - 循环数组</h4>
<p><strong>循环数组</strong>的意思是，一个数组的最后一项的下一项恰好是第一项，这就意味着这个数组对于任何大于等于零的索引[^2]都不会 <code>panic</code>。</p>
<p>一个循环数组的第 <code>i</code> 位索引可以表示为 <code>nums[i%len(nums)]</code>。</p>
<p>在这样的数组里，窗口端点索引可以大于等于数组长度，因此滑动窗口既可以从右向左滑，也可以从左向右滑。<strong>但是！通过合理地选择窗口起点，完全能把这两种情况合并到一起。</strong></p>
<p><a href="https://leetcode.cn/problems/defuse-the-bomb/description/">leco1652 拆炸弹</a> 就是这样的一道题。</p>
<blockquote>
<p>你有一个炸弹需要拆除，时间紧迫！你的情报员会给你一个长度为 <code>n</code> 的 <strong>循环</strong> 数组 <code>code</code> 以及一个密钥 <code>k</code> 。</p>
<p>为了获得正确的密码，你需要替换掉每一个数字。所有数字会 <strong>同时</strong> 被替换。<em>（注：这一句可以忽略）</em></p>
<ul>
<li>如果 <code>k &gt; 0</code> ，将第 <code>i</code> 个数字用 <strong>接下来</strong> <code>k</code> 个数字之和替换。</li>
<li>如果 <code>k &lt; 0</code> ，将第 <code>i</code> 个数字用 <strong>之前</strong> <code>k</code> 个数字之和替换。</li>
<li>如果 <code>k == 0</code> ，将第 <code>i</code> 个数字用 <code>0</code> 替换。</li>
</ul>
<p>由于 <code>code</code> 是循环的， <code>code[n-1]</code> 下一个元素是 <code>code[0]</code> ，且 <code>code[0]</code> 前一个元素是 <code>code[n-1]</code> 。</p>
<p>给你 <strong>循环</strong> 数组 <code>code</code> 和整数密钥 <code>k</code> ，请你返回解密后的结果来拆除炸弹！</p>
</blockquote>
<p>先观察一下窗口运行的特点。<em>设解密后的数组为 <code>ans</code></em>。</p>
<ul>
<li><strong><code>k &gt; 0</code></strong>：<code>ans[0]</code> 填入的就是 <code>code[1 : k+1]</code> 求和的值。</li>
<li><code>k &lt; 0</code>：<code>ans[0]</code> 填入的就是 <code>code[n+k : n]</code> 求和的值。</li>
</ul>
<p>将这个结论推广下去。（省略循环数组的取余操作）</p>
<ul>
<li><strong><code>k &gt; 0</code></strong>：<code>ans[i]</code> 填入的就是 <code>code[i+1 : k+i+1]</code> 求和的值。</li>
<li><code>k &lt; 0</code>：<code>ans[i]</code> 填入的就是 <code>code[n+k+i : n+i]</code> 求和的值。</li>
</ul>
<p>把窗口的起始点搞明白之后，先写初始化窗口的代码。</p>
<pre><code>n := len(code)
r := k + 1

// 然后 k &lt; 0
if k &lt; 0 {
    r = n
    k = -k
}
</code></pre>
<p>上面的代码完全仿照了一开始的推导，那么接下来就写滑动窗口部分。</p>
<pre><code>// 把第一个窗口内的和计算出来
var now int
for _, c := range code[r-k : r] {
    now += c
}

// 窗口开始滑动
var ans = make([]int, n)
for i := range code {
    ans[i] = now
    // 在第一个窗口的基础上开始滑动
    now += code[r%n] - code[(r-k)%n] // 因为循环数组，所以要取余运算
    // 这里的 r-k 就是左端点
    r++
}
return ans
</code></pre>
<p>也就是这样。</p>
<pre><code>func decrypt(code []int, k int) []int {
    // 先写 k &gt; 0 的情况
    n := len(code)
    r := k + 1
    // 然后 k &lt; 0
    if k &lt; 0 {
        r = n
        k = -k
    }

    // 把第一个窗口内的和计算出来
    var now int
    for _, c := range code[r-k : r] {
        now += c
    }

    // 窗口开始滑动
    var ans = make([]int, n)
    for i := range code {
        ans[i] = now
        now += code[r%n] - code[(r-k)%n] // 因为循环数组，所以要取余运算
        // 这里的 r-k 就是左端点
        r++
    }
    return ans
}
</code></pre>
<p>这就是<em>拆炸弹</em>的解法。这里不需要特判 <code>k == 0</code> 的情况。因为此时的 <code>r</code> 与 <code>r-k</code> 是同一个数，滑动时会互相抵消。</p>
<h4>寻找窗口 - 重新安排活动时间</h4>
<p><a href="https://leetcode.cn/problems/reschedule-meetings-for-maximum-free-time-i/description/">3439. 重新安排会议得到最多空余时间 I - 力扣（LeetCode）</a></p>
<blockquote>
<p>给你一个整数 <code>eventTime</code> 表示一个活动的总时长，这个活动开始于 <code>t = 0</code> ，结束于 <code>t = eventTime</code> 。</p>
<p>同时给你两个长度为 <code>n</code> 的整数数组 <code>startTime</code> 和 <code>endTime</code> 。它们表示这次活动中 <code>n</code> 个时间 <strong>没有重叠</strong> 的会议，其中第 <code>i</code> 个会议的时间为 <code>[startTime[i], endTime[i]]</code> 。</p>
<p>你可以重新安排 <strong>至多</strong> <code>k</code> 个会议，安排的规则是将会议时间平移，且保持原来的 <strong>会议时长</strong> ，你的目的是移动会议后 <strong>最大化</strong> 相邻两个会议之间的 <strong>最长</strong> 连续空余时间。</p>
<p>移动前后所有会议之间的 <strong>相对</strong> 顺序需要保持不变，而且会议时间也需要保持互不重叠。</p>
<p>请你返回重新安排会议以后，可以得到的 <strong>最大</strong> 空余时间。</p>
<p><strong>注意</strong>，会议 <strong>不能</strong> 安排到整个活动的时间以外。</p>
</blockquote>
<p>我们观察一下活动时间与空余时间的关系。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/picture1/example0_rescheduled.png" alt="Reschedule" /></p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/picture1/example1_rescheduled.png" alt="Reschedule" /></p>
<p>容易发现，每一段<strong>活动时间</strong>的<em>左侧</em>都有一段对应的空余时间，而在最后又有一段空余时间（空余时长为 <code>0</code> 也计入）。</p>
<p>这也就意味着，设原题给出的 <code>startTime</code> 长度为 <code>n</code>，空余时间的段数就是 <code>n+1</code>。</p>
<p>我们把空余时间抽象成一个数组 <code>free</code>，其中 <code>free[i]</code> 表示第 <code>i</code> 段空余时间的长度（可以是 <code>0</code>）。</p>
<p>然后我们发现，每操作一段会议时间，实际上是合并了两段空余时间。</p>
<p>推广下去，操作 <code>k</code> 段会议时间就是合并了 <code>k+1</code> 段空余时间。**而且！**这 <code>k+1</code> 段空余时间在 <code>free</code> 数组中是连续的。</p>
<p>有没有觉得很熟悉？没错，问题就转化成了<strong>在 <code>free</code> 数组中寻找长为 <code>k+1</code> 的子数组，并保证子数组内元素和最大。</strong></p>
<p>这又是一道经典的定长滑动窗口，直接套入模板。但在开始前需要写一个生成 <code>free</code> 数组的代码。</p>
<pre><code>func maxFreeTime(eventTime int, k int, startTime []int, endTime []int) int {
    // 生成 free 数组
    n := len(startTime)
    free := make([]int, n+1)
    free[0] = startTime[0]
    for i := 1; i &lt; n; i++ {
        free[i] = startTime[i] - endTime[i-1]
    }
    free[n] = eventTime - endTime[n-1]

    // 开始滑动窗口

    var now, ans int
    for r := range free {
        now += free[r]
        l := r - k
        if l &lt; 0 {
            continue
        }

        ans = max(now, ans)

        now -= free[l]
    }
    return ans
}
</code></pre>
<h2>滑动窗口 - 长度不固定</h2>
<p>当窗口长度不定时，此时我们不能再用上面的 <strong>进-判-出</strong> 三步走，这时候进出窗口完全靠我们自行判断了。</p>
<p>先用一道模板题试试水。</p>
<p><a href="https://leetcode.cn/problems/longest-substring-without-repeating-characters/description/">3. 无重复字符的最长子串 - 力扣（LeetCode）</a> <em>这道题还有一个兄弟版本 <a href="https://leetcode.cn/problems/maximum-length-substring-with-two-occurrences/">leco3090</a>。</em></p>
<blockquote>
<p>给一个字符串 <code>s</code> ，请你找出其中不含有重复字符的 <strong>最长 子串</strong> 的长度。<em>子串是字符串中连续的非空字符序列。</em></p>
</blockquote>
<p>这可用下面的流程表示。</p>
<ol>
<li>右端点进入窗口</li>
<li>判断现在进入窗口的字符是否已经出现超过 <code>1</code> 次。
<ul>
<li><code>true</code> 收缩左窗口直到当前字符出现次数等于 <code>1</code>。</li>
<li><code>false</code> 不做处理，继续。</li>
</ul>
</li>
<li>用一个变量 <code>ans</code> 在每次循环末尾存储当前 <code>ans</code> 与 <code>r-l+1</code> 中的较大值。</li>
</ol>
<p>上面流程清晰易懂，所以直接展示代码。</p>
<pre><code>func lengthOfLongestSubstring(s string) int {
    var freq = map[byte]int{}
    var l, ans int
    for r := range s {
        freq[s[r]]++ // 右端点进入窗口
        for freq[s[r]] &gt; 1 { // 左端点离开窗口的条件
            freq[s[l]]--
            l++
        }
        ans = max(ans, r-l+1) // 取较大值
    }
    return ans
}
</code></pre>
<p>可视化：</p>
<pre><code>将要操作的字符串: abcabcbb
当前窗口: a
当前窗口: ab
当前窗口: abc
当前窗口: abca
⚠️  发现重复字符 'a' 窗口收缩
当前窗口: bca
当前窗口: bcab
⚠️  发现重复字符 'b' 窗口收缩
当前窗口: cab
当前窗口: cabc
⚠️  发现重复字符 'c' 窗口收缩
当前窗口: abc
当前窗口: abcb
⚠️  发现重复字符 'a' 窗口收缩
当前窗口: bcb
⚠️  发现重复字符 'b' 窗口收缩
当前窗口: cb
当前窗口: cbb
⚠️  发现重复字符 'c' 窗口收缩
当前窗口: bb
⚠️  发现重复字符 'b' 窗口收缩
当前窗口: b
最终答案: 3
</code></pre>
<h3>不定长窗口 - 反向窗口</h3>
<p>所谓<strong>反向窗口</strong>，就是我们实际求得的窗口是恰好与答案相反的。</p>
<p>我们这里会提到一个非常重要的思想：<strong>正难则反</strong>。</p>
<p>顾名思义，如果我们正向求解一个问题是困难的，那不妨从答案的<strong>对立事件</strong>入手，然后用全集减去对立事件，这样可能会方便一点。</p>
<p>例如抽卡问题：<a href="https://leetcode.cn/problems/minimum-operations-to-reduce-x-to-zero/description/">1658. 将 x 减到 0 的最小操作数 - 力扣（LeetCode）</a></p>
<blockquote>
<p><em>注：这里把数组看作一排卡片，每个元素就是一张卡片。</em></p>
<p>给你一个整数数组 <code>nums</code> 和一个整数 <code>x</code> 。每一次操作时，你应当移除数组 <code>nums</code> 最左边或最右边的元素，然后从 <code>x</code> 中减去该元素的值。请注意，需要 <strong>修改</strong> 数组以供接下来的操作使用。</p>
<p>如果可以将 <code>x</code> <strong>恰好</strong> 减到 <code>0</code> ，返回 <strong>最小操作数</strong> ；否则，返回 <code>-1</code> 。</p>
</blockquote>
<p>如果我们真的从左边和右边开始，一张一张抽卡试探，那是很麻烦的。</p>
<p>那我们换个思路，看看抽完卡之后剩下的卡片有什么特点。</p>
<p>设 <code>nums</code> 全部元素求和的值是 <code>s</code>，那么如果能够让 <code>x == 0</code>，抽完之后的卡片求和就是 <code>s-x</code> （设为 <code>target</code>）。</p>
<p>那我们就可以在这排卡片中寻找最长的一段，让这一段的卡片数字之和等于 <code>target</code>。</p>
<p>有没有发现这就是<em>越长越好的不定长滑动窗口</em>？那我们就用模板代码写出来。</p>
<pre><code>func minOperations(nums []int, x int) int {
    var now, l int
    var ans int = -1 // 默认找不到 x
    
    var target int = -x
    for _, i := range nums {
        target += i // 这样求出来就是上面所说的 s-x
    }
    
    if target &lt; 0 {
        // 此时的 x 大于 nums 所有元素之和
        // 找不到，直接返回
        return ans
    }
    // 下面就是模板代码
    for r := range nums {
        now += nums[r]
        for now &gt; target {
            now -= nums[l]
            l++
        }
        if now == target { // 只有在 now == s-x 才更新
            ans = max(ans, r-l+1)
        }
    }
    
    if ans &lt; 0 {
        return -1
    }
    return len(nums) - ans
}
</code></pre>
<h3>不定长窗口 - 越短越好</h3>
<p>这里的越短越好指的是<strong>窗口覆盖所有所需元素时，窗口越短越好。</strong></p>
<p>这大多出现在<strong>寻找最短子串问题</strong>中，例如 <a href="https://leetcode.cn/problems/minimum-window-substring/description/">76. 最小覆盖子串 - 力扣（LeetCode）</a>。</p>
<blockquote>
<p>给你一个字符串 <code>s</code> 、一个字符串 <code>t</code> 。返回 <code>s</code> 中涵盖 <code>t</code> 所有字符的最小子串。如果 <code>s</code> 中不存在涵盖 <code>t</code> 所有字符的子串，则返回空字符串 <code>""</code> 。</p>
<p><strong>注意：</strong></p>
<ul>
<li>对于 <code>t</code> 中重复字符，我们寻找的子字符串中该字符数量必须不少于 <code>t</code> 中该字符数量。</li>
<li>如果 <code>s</code> 中存在这样的子串，我们保证它是唯一的答案。</li>
</ul>
</blockquote>
<p><em>这还是道困难题，解开你的困难题解答数就 +1 了</em>。</p>
<p>因为 <code>s</code> 和 <code>t</code> 中只有英文字母，所以考虑用长为 123 （比 <code>z</code> 对应的 ASCII 码点 <code>122</code> 多 <code>1</code>。因为索引从 <code>0</code> 开始，所以这样能方便运算）的数组 <code>freq</code> 来标记 <code>t</code> 与 <code>s</code> 中存储的字符。</p>
<p>然后用一个变量 <code>types</code> 来记录 <code>t</code> 字符种类数，每当检测到 <code>freq[i] == 0</code> 时 <code>types--</code>，这样就能够做到同时统计字符数和字符种类。</p>
<p>当 <code>types == 0</code> 时，意味着已经搜索到了一个含有 <code>t</code> 中全部字符的子串，就考虑收缩窗口寻找更优解。</p>
<pre><code>func minWindow(s string, t string) string {
    var freq = [123]int{}
    var types int // t 的字符种类数
    for i := range t {
        if freq[t[i]] == 0 { // 新的字符种类
            types++
        }
        freq[t[i]]++
    }

    var l int
    ansl, ansr := -1, 2147483647 // 大到不可能的边界
    for r := range s {
        freq[s[r]]--
        if freq[s[r]] == 0 { // 窗口中 s[r] 这个字符的数量，刚好满足了 t 的要求，不多也不少
            types--
        }
        // 找到了 t 中所有的字符（数目也一致）
        for types == 0 {
            if r-l &lt; ansr-ansl { // 更新答案
                ansl, ansr = l, r
            }
            // 在把 s[l] 移出窗口之前，s[l] 是一个数量刚刚好的关键字符
            // 也就是说，这时候还将 freq[s[l]] 移出，就不满足要求了
            if freq[s[l]] == 0 {
                types++
            }
            freq[s[l]]++
            l++ // 边界收缩
        }
    }
    if ansl == -1 { // 特判未找到的情况
        return ""
    }
    return s[ansl : ansr+1]
}
</code></pre>
<p>把搜索过程可视化，就是这样的。</p>
<pre><code>输入：
s := "ADOBECODEBANC"
t := "ABC"

t 包含的字符种类数: 3
==================================
搜索进度:  1 / 13
当前子串包含的字符种类数: 1
当前扫描得到的子串: "A"
==================================
搜索进度:  2 / 13
当前子串包含的字符种类数: 1
当前扫描得到的子串: "AD"
==================================
搜索进度:  3 / 13
当前子串包含的字符种类数: 1
当前扫描得到的子串: "ADO"
==================================
搜索进度:  4 / 13
当前子串包含的字符种类数: 2
当前扫描得到的子串: "ADOB"
==================================
搜索进度:  5 / 13
当前子串包含的字符种类数: 2
当前扫描得到的子串: "ADOBE"
==================================
搜索进度:  6 / 13
当前子串包含的字符种类数: 3
当前扫描得到的子串: "ADOBEC"
满足条件！将尝试收缩窗口。当前子串："ADOBEC"
==================================
搜索进度:  7 / 13
当前子串包含的字符种类数: 2
当前扫描得到的子串: "DOBECO"
==================================
搜索进度:  8 / 13
当前子串包含的字符种类数: 2
当前扫描得到的子串: "DOBECOD"
==================================
搜索进度:  9 / 13
当前子串包含的字符种类数: 2
当前扫描得到的子串: "DOBECODE"
==================================
搜索进度:  10 / 13
当前子串包含的字符种类数: 2
当前扫描得到的子串: "DOBECODEB"
==================================
搜索进度:  11 / 13
当前子串包含的字符种类数: 3
当前扫描得到的子串: "DOBECODEBA"
满足条件！将尝试收缩窗口。当前子串："DOBECODEBA"
满足条件！将尝试收缩窗口。当前子串："OBECODEBA"
满足条件！将尝试收缩窗口。当前子串："BECODEBA"
满足条件！将尝试收缩窗口。当前子串："ECODEBA"
满足条件！将尝试收缩窗口。当前子串："CODEBA"
==================================
搜索进度:  12 / 13
当前子串包含的字符种类数: 2
当前扫描得到的子串: "ODEBAN"
==================================
搜索进度:  13 / 13
当前子串包含的字符种类数: 3
当前扫描得到的子串: "ODEBANC"
满足条件！将尝试收缩窗口。当前子串："ODEBANC"
满足条件！将尝试收缩窗口。当前子串："DEBANC"
满足条件！将尝试收缩窗口。当前子串："EBANC"
满足条件！将尝试收缩窗口。当前子串："BANC"

最终答案: "BANC"
</code></pre>
<h2>额外部分</h2>
<p>其实定长滑动窗口还可以优化，例如 <a href="https://leetcode.cn/problems/maximum-average-subarray-i/description/">leco634 子数组最大平均数 I</a> 可以这样写。</p>
<pre><code>func findMaxAverage(nums []int, k int) float64 {
    var now int
    // 计算第一个窗口和
    for _, x := range nums[:k] {
        now += nums[r]
    }
    for r := k; r &lt; len(nums); r++ {
        now += nums[r] - nums[r-k]
        ans = max(ans, now) // 取最大值
    }
    return float64(ans) / float64(k)
}
</code></pre>
<p>这种优化思路就是，<strong>先计算第一个窗口值，然后把第一个窗口值作为 <code>ans</code> 的初始值，然后从 <code>k</code> 处开始遍历寻找窗口</strong>。</p>
<p>在这之后只要将 <code>now += nums[r] - nums[r-k]</code> 就能实现更新窗口值。</p>
<hr />
<p><em>因为写了一篇困难题的解决思路，就把它作为<strong>双指针和滑动窗口</strong>的第二部分结尾吧，第三部分讲一些特殊的窗口。</em></p>
<p>滑动窗口实际上是在维护一个<strong>队列</strong>（queue），队列遵循<strong>先进先出</strong>（First In First Out, FIFO）的原则。</p>
<p>这个队列最多只会遍历整个数组或字符串一次，因此单纯滑动窗口算法的时间复杂度只有 <code>O(n)</code>，比暴力搜索高效得多。</p>
<blockquote>
<p>队列有一个头和一个尾，头就是窗口的左端点，尾就是窗口的右端点。元素不断地从右端点进入窗口，又不断地从左端点离开窗口，这就对应了队列的“<strong>入队</strong>”（enqueue）和“<strong>出队</strong>”（dequeue）操作。</p>
<p>这就是滑动窗口的本质。因为队列在原序列中必然连续，因此滑动窗口极其适合解决子数组/子串问题，并能把至少 <code>O(n^2)</code> 的暴力搜索优化到 <code>O(n)</code>。</p>
</blockquote>
<p>这是 <em>双指针与滑动窗口</em> 的第二部分，在这里，我们接触了定长滑动窗口的模板，并且看了一些窗口长度藏得极其诡异的题目。</p>
<p>然后给下一期的不定长滑动窗口起了一个头。因为滑动窗口本质是在维护一个队列。</p>
<p>这个队列当条件满足时入队，条件不满足时出队。</p>
<p>本期教程就到这里，下一期（第三部分）就涉及一些特别的窗口问题。</p>
<p>🎉撒花！🎉</p>
<p>[^1]: 常用 <code>O(|Σ|)</code> 表示。
[^2]: 如果是实际代码，还是会的。</p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2025-10-05T13:08:57.510Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[让你的 VS Code 也能写 C++]]></title>
        <id>https://re.karlbaey.top/articles/to-let-your-vs-code-support-cpp/</id>
        <link href="https://re.karlbaey.top/articles/to-let-your-vs-code-support-cpp/"/>
        <updated>2025-10-03T14:52:01.124Z</updated>
        <summary type="html"><![CDATA[现在主流的 C++ IDE 一般是 Visual Studio 和 Dev-C++，但是前者占用高，后者 GUI 太简陋。所以我们想用最好的...]]></summary>
        <content type="html"><![CDATA[<p>现在主流的 C++ IDE 一般是 Visual Studio 和 Dev-C++，但是前者占用高，后者 GUI 太简陋。</p>
<p>所以我们想用最好的文本编辑器 VS Code 来写 C++ 代码。</p>
<p>但是因为 VS Code 没有集成好 C++ 的开发环境，我们必须多走几步。</p>
<h2>配置 C++ 环境</h2>
<p>在 GitHub 仓库 <a href="https://github.com/niXman/mingw-builds-binaries/releases">Releases · niXman/mingw-builds-binaries</a> 下载 C++ 环境。</p>
<p>选择最底下的 <code>win32-seh-ucrt</code>。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/picture1/image-20251003221236196.png" alt="UCRT" /></p>
<p>然后把下载好的压缩文件解压到合适的文件夹。我解压在 <code>E:\Programs\ucrt</code> 里面。</p>
<p>然后把<strong>二进制目录</strong> <code>bin</code> 放到系统环境变量 <code>Path</code> 里。我应该放入的配置是 <code>E:\Programs\ucrt\mingw64\bin</code>，然后一路按下确定。</p>
<blockquote>
<p>环境变量直接在 Windows 自带搜索框中搜索即可</p>
</blockquote>
<p>这样就配置好了 C++ 的编译器，接着配置 VS Code。</p>
<h2>VS Code 配置</h2>
<p>下载两个插件。按下 &lt;kbd&gt;Ctrl&lt;/kbd&gt;+&lt;kbd&gt;Shift&lt;/kbd&gt;+&lt;kbd&gt;X&lt;/kbd&gt; 打开插件市场。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/picture1/image-20251003220525756.png" alt="Code Runner" /><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/picture1/image-20251003220443788.png" alt="C++" /></p>
<p>然后按下 &lt;kbd&gt;F1&lt;/kbd&gt; 搜索 <code>Preferences: Open User Settings</code>，搜索 <code>Code-runner</code>，把 <code>Run In Terminal</code> 打开。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/picture1/image-20251003222730205.png" alt="Run In Terminal" /></p>
<blockquote>
<p>这样做的原因是，Code Runner 自带的终端是<strong>只读的终端</strong>，如果需要写 ACM 输入输出那就什么都干不了，所以我们要换成 VS Code 内嵌终端。</p>
</blockquote>
<p>接着继续按下 &lt;kbd&gt;F1&lt;/kbd&gt; 搜索 <code>Preferences: Open User Settings (JSON)</code>。&lt;kbd&gt;Ctrl&lt;/kbd&gt;+&lt;kbd&gt;F&lt;/kbd&gt; 搜索 <code>code-runner.executorMap</code>，如下面所示修改。</p>
<pre><code>"code-runner.executorMap": {
        // 其余省略
        //其他地方不要动！
        "c": "cd $dir &amp;&amp; gcc -fexec-charset=GBK $fileName -o $fileNameWithoutExt &amp;&amp; $dir$fileNameWithoutExt",
        "cpp": "cd /d $dir &amp;&amp; g++ -fexec-charset=GBK $fileName -o $fileNameWithoutExt &amp;&amp; .\\$fileNameWithoutExt.exe",
}
</code></pre>
<p>然后写一个 <code>hello.cpp</code> 测试一下。</p>
<pre><code>#include &lt;iostream&gt;

int main() {
    std::cout &lt;&lt; "Hello, World!" &lt;&lt; std::endl;
    return 0;
}
</code></pre>
<p>按下 &lt;kbd&gt;Ctrl&lt;/kbd&gt;+&lt;kbd&gt;Alt&lt;/kbd&gt;+&lt;kbd&gt;N&lt;/kbd&gt;，如果不出意外，那就会在终端中输出 <code>Hello, World!</code> 了。</p>
<h2>一些额外的东西</h2>
<p>其实 Code Runner 的配置也可以为 Golang 修改。</p>
<pre><code>"go": "cd /d $dir &amp;&amp; go build $fileName &amp;&amp; .\\$fileNameWithoutExt.exe"
</code></pre>
<p>这样在 &lt;kbd&gt;Ctrl&lt;/kbd&gt;+&lt;kbd&gt;Alt&lt;/kbd&gt;+&lt;kbd&gt;N&lt;/kbd&gt; 启动 Code Runner 后，它就会自动编译好 Golang 代码并且执行了。</p>
<p>⚠️<strong>注意</strong>：配置中使用到了 <code>cd</code> 命令。</p>
<pre><code>cd /d E:\xxxx
</code></pre>
<p>注意这里的 <code>/d</code> 参数是必要的，它能同时切换盘符（从 C 盘切换到 E 盘）和进入目录，如果不加 <code>/d</code> 就会导致什么都没有发生。</p>
<p><em><strong>(The end)</strong></em></p>
<p>🎉</p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2025-10-03T14:52:01.124Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[我对各种字体的看法（Go 语言版）]]></title>
        <id>https://re.karlbaey.top/articles/some-inexplicable-fonts-in-golang-coding/</id>
        <link href="https://re.karlbaey.top/articles/some-inexplicable-fonts-in-golang-coding/"/>
        <updated>2025-10-02T16:30:27.612Z</updated>
        <summary type="html"><![CDATA[都是 Windows 内置的，少数是我下的开源字体。示例代码：这个支持连字，是我最喜欢的字体。https://github.com/tons...]]></summary>
        <content type="html"><![CDATA[<p>都是 Windows 内置的，少数是我下的开源字体。</p>
<p>示例代码：</p>
<pre><code>package main

import (
    "fmt"
)

func main() {
    fmt.Println("Hello World!")
}
</code></pre>
<h3>先来几个口味淡的</h3>
<h4>Fira Code</h4>
<p>这个支持连字，是我最喜欢的字体。</p>
<p><a href="https://github.com/tonsky/FiraCode/">https://github.com/tonsky/FiraCode/</a></p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/fontsFor/Fira_Code.png" alt="Fira_Code" /></p>
<h4>Noto Sans SC</h4>
<p>技术文档字体的上乘之选，但是不等宽。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/fontsFor/Noto_Sans_SC.png" alt="Noto_Sans_SC" /></p>
<h4>Noto Serif SC</h4>
<p>这个字体如果搭配上严肃文学简直就是绝杀！我在看《枪炮、病菌与钢铁》、《1984》还有《百年孤独》的时候用的就是它，氛围感简直一绝！</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/fontsFor/Noto_Serif_SC.png" alt="Noto_Serif_SC" /></p>
<h4>Constantia</h4>
<p>感觉如果做旧效果处理得好一点就会有种打字机手稿的味道。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/fontsFor/Constantia.png" alt="Constantia" /></p>
<h4>Microsoft YaHei</h4>
<p>说实话比较难评价。说它不好，像我这种 Windows 用户天天看，说讨厌是不可能的；说它好，它在美观（主观看法）又不如上面的几个。</p>
<p>但是微软雅黑的可读性非常好，如果在摆弄文件的时候看不到微软雅黑反而会不知所措。</p>
<p>（微软为什么总喜欢在奇怪的地方发力:thinking:）</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/fontsFor/Microsoft_YaHei.png" alt="Microsoft_YaHei" /></p>
<h3>视力杀手</h3>
<h4>Impact</h4>
<p>一些英文漫画好像会用，但是这横扁竖粗的形体看着真是抓心挠肝。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/fontsFor/anotImpact.png" alt="Impact" /></p>
<h4>Comic Sans MS</h4>
<p>英文漫画的御用字体。同样是习惯成自然导致觉得还不错，换别的反而不适应。</p>
<p>说真的要不是看这段代码，我还真不知道这玩意做了英文小写字母。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/fontsFor/anotComic_Sans_MS.png" alt="Comic_Sans_MS" /></p>
<h4>Ink Free</h4>
<p>像杂草一样，说实话不好看:neutral_face:，可能只有字幕组才会用。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/fontsFor/anotInk_Free.png" alt="Ink_Free" /></p>
<h4>Segoe Script</h4>
<p>仿手写字体里最好看的一个。跟 Ink Free 一样不知道放在哪里好。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/fontsFor/anot/herSegoe_Script.png" alt="Segoe_Script" /></p>
<h3>纯属胡闹</h3>
<h4>Wingdings &amp; Wingdings 3</h4>
<p>代码混淆领域最权威的存在，其他混淆代码方案都弱爆了。</p>
<p>（乱讲的，不要打我呜呜呜:sob:）</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/fontsFor/anot/herWingdings.png" alt="Wingdings" /></p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/fontsFor/anot/herWingdings_3.png" alt="Wingdings_3" /></p>
<h4>猜谜</h4>
<p>猜猜这段代码的作用。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/fontsFor/anot/herWingdings_MysAlgo.png" alt="Wingdings_MysAlgo" /></p>
<p>&lt;details&gt;
&lt;summary&gt;谜底&lt;/summary&gt;
&lt;img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/picture1/herFira_Code.fourSum.png" alt="Fira_Code.fourSum" /&gt;
&lt;/details&gt;</p>
<p>来源：<a href="https://karlbaey.top/articles/program-design-two-pointer-algorithm-and-sliding-window-sep-one/#leco18-%E5%9B%9B%E6%95%B0%E4%B9%8B%E5%92%8C-%E8%A7%A3%E6%B3%95">程序设计#4 四数之和 - 【经典算法】双指针和滑动窗口·第一部分 | Karlblogs</a></p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2025-10-02T16:30:27.612Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[程序设计#4 - 【经典算法】双指针和滑动窗口·第一部分]]></title>
        <id>https://re.karlbaey.top/articles/program-design-two-pointer-algorithm-and-sliding-window-sep-one/</id>
        <link href="https://re.karlbaey.top/articles/program-design-two-pointer-algorithm-and-sliding-window-sep-one/"/>
        <updated>2025-09-24T12:26:57.203Z</updated>
        <summary type="html"><![CDATA[书接上回。滑动窗口算法是双指针算法的变形，而且因为入门门槛低，我打算下一期就做这个了。双指针算法（two-pointer algorithm...]]></summary>
        <content type="html"><![CDATA[<p>书接上回。</p>
<blockquote>
<p>滑动窗口算法是双指针算法的变形，而且因为入门门槛低，我打算下一期就做这个了。</p>
</blockquote>
<p><strong>双指针算法</strong>（two-pointer algorithm）是处理<strong>序列</strong>（如数组、链表、字符串）的强大技巧。它通过维护两个指针（索引）来追踪序列中的某个范围或元素，从而高效地解决问题。</p>
<p>它尤其擅长解决<strong>子数组或子串</strong>的相关问题（特别是滑动窗口）。子数组就是一个完整数组里连续的一段，子串就是字符串里连续的一段。</p>
<p>对于输入所给的序列，尽管它们具有确定的顺序，但我们对它一无所知。除非使用索引 <code>arr[i]</code> 查询，否则永远也不可能一次性知道所有元素的位置。</p>
<p>这时候，如果我们身处一个序列中，就像是在一条漆黑的走廊里。运用暴力算法就像是一次只打开一盏灯，看当前位置的元素，然后穷举所有的可能结果。这样做的最坏时间复杂度可能超过 O(n&lt;sup&gt;2&lt;/sup&gt;)。</p>
<p>换一个思路，通过移动两个指针来动态维护一个<strong>区间</strong>或<strong>追踪两个元素</strong>。在移动过程中，根据当前区间的信息，我们可以<strong>智能地排除许多无效的候选解</strong>，从而避免暴力枚举，将时间复杂度优化到 O(n) 或 O(n log n)。</p>
<p>这就叫做<strong>双指针</strong><a href="%E6%9C%AC%E6%96%87%E7%9A%84%E6%8C%87%E9%92%88%E6%8C%87%E7%B4%A2%E5%BC%95%E6%88%96%E4%BD%8D%E7%BD%AE%E6%A0%87%E8%AE%B0%EF%BC%8C%E4%B8%8D%E6%98%AF%E5%86%85%E5%AD%98%E6%8C%87%E9%92%88%E3%80%82">^1</a><strong>算法</strong>。</p>
<p>打个比方，在黑暗的走廊中一个人找答案当然是很慢的，所以我们派出两名干员，拿着手电筒搜索，他们速度一快一慢，或者左右相向而行。这样，通过两名探员的左右协同，就实现了快速定位或是缩小范围的目的。而又因为我们对两名探员的位置是时刻掌握的，所以同时也能求出这个范围的大小。所以，这样就能很快地找到答案。</p>
<p>另外，利用快慢指针的特点，我们能快速地判断一个未知长度的序列（通常是链表）的中点位置。</p>
<p><strong>滑动窗口</strong>是双指针的变形，它的本质就是把两个指针作为窗口的左端点和右端点。这就好比在走廊里打开了一盏<strong>可调节宽度的探照灯</strong>，并且在序列中平滑地移动，这样也能高效地寻找最优解。</p>
<h2>双指针基础应用</h2>
<h3>新手教程 - intro</h3>
<p>上面已经写到，两个指针通常一快一慢（也叫<strong>快慢指针</strong>或<strong>同向指针</strong>），或是相向而行（<strong>对撞指针</strong>）。我们通过两个例题体会一下。</p>
<p>快慢指针：<a href="https://leetcode.cn/problems/remove-element/">27. 移除元素 - 力扣（LeetCode）</a></p>
<blockquote>
<p>给你一个数组 <code>nums</code> 和一个值 <code>val</code>，你需要&lt;b&gt;&lt;span style="color:#00BFFF;"&gt;原地&lt;/span&gt;&lt;/b&gt;移除所有数值等于 <code>val</code> 的元素。元素的顺序可能发生改变。然后返回 <code>nums</code> 中与 <code>val</code> 不同的元素的数量。</p>
<p>假设 <code>nums</code> 中不等于 <code>val</code> 的元素数量为 <code>k</code>，要通过此题，您需要执行以下操作：</p>
<ul>
<li>更改 <code>nums</code> 数组，使 <code>nums</code> 的前 <code>k</code> 个元素包含不等于 <code>val</code> 的元素。<code>nums</code> 的其余元素和 <code>nums</code> 的大小并不重要。</li>
<li>返回 <code>k</code>。</li>
</ul>
</blockquote>
<p>如果这一题不用原地修改，那么只需要简单地开一个新数组就可以了。</p>
<p>但是这里不行。所以考虑用一个快指针遍历整个数组，当快指针遍历到 <code>val</code> 时就跳过，否则就将此时的值传递给 <code>nums[slow]</code>，接着 <code>slow++</code>。</p>
<p>定义两个关键变量 <code>slow</code> 和 <code>fast</code>：</p>
<ul>
<li><code>slow</code> 指向下一个可以被<strong>写入</strong>新元素的位置。<code>slow</code> 的值也代表了函数处理后有效数组的长度。它从 <code>0</code> 开始。</li>
<li><code>fast</code> 用于查看数组的全部元素。</li>
</ul>
<p>然后需要写 <code>slow</code> 前进的条件：</p>
<pre><code>if nums[fast] != val {
    // ...
}
</code></pre>
<p>这行代码就是在说：当前的元素不是 <code>val</code>，应该被保留。</p>
<p>结合一下，就能得到下面的代码。</p>
<pre><code>func removeElement(nums []int, val int) int {
    var slow int
    for fast := range nums {
        if nums[fast] != val {
            nums[slow] = nums[fast]
            slow++
        }
    }
    return slow
}
</code></pre>
<p><a href="https://leetcode.cn/problems/reverse-string/">344. 反转字符串 - 力扣（LeetCode）</a></p>
<p><em>我在之前就写过解法，直接粘贴过来即可。</em></p>
<pre><code>func reverseString(s []byte)  {
    l := 0
    r := len(s) - 1
    
    // 两个指针碰头时，就代表他们抵达了中点
    // 这时候跳出循环，字符串就完成了反转
    for l &lt; r {
        s[l], s[r] = s[r], s[l] // a, b = b, a 表示交换 a 和 b 的值
        l++
        r--
    }
}
</code></pre>
<p>这一段代码并不难理解。事实上就是把第一位跟最后一位交换，把第二位跟倒数第二位交换……直到两个指针在中间碰头，整个字符串也就完成了交换。</p>
<h3>快慢指针 - 变形</h3>
<h4>数组去重问题 - 忽略重复项</h4>
<p>上面看的都是一些简单而且特殊的情形，现在我们把快慢指针（fast-slow pointers）的用法往下推广。</p>
<p>例如上面 leco27 的第一种变形：<a href="https://leetcode.cn/problems/remove-duplicates-from-sorted-array/description/">26. 删除有序数组中的重复项 - 力扣（LeetCode）</a></p>
<p>这题跟 leco27 的共通点在于，需要<strong>考查快指针遍历到的元素是否符合不重复的条件</strong>。</p>
<p>为了寻找普遍性，这里我们改为实现一个 <code>RmDup()</code> 函数。这个函数接受两个输入：数组 <code>nums</code> 以及最大重复次数 <code>k</code>。因为数组已经排好序，所以相同的元素一定是相邻的。所以与 leco27 相同，快慢指针分别代表：</p>
<ul>
<li><strong>快指针 <code>fast</code></strong> 遍历整个数组</li>
<li><strong>慢指针 <code>slow</code></strong> 标记新数组的写入位置</li>
</ul>
<p>先从 <code>k == 1</code> 的情形开始。</p>
<p><strong>第一步·确定范围</strong>。假设 <code>nums[i] == nums[i-1]</code>（不超出索引范围），那么我们就知道，从 <code>nums[i-1]</code> 到 <code>nums[i]</code> 都是相同的数字，一共是 <code>2</code> 个。</p>
<p>接着，如果 <code>nums[i] == nums[i-k]</code>，从 <code>nums[i-k]</code> 到 <code>nums[i]</code> 都是相同的数字，一共是 <code>k+1</code> 个</p>
<p>所以，在上面的这种情况下，只要停留在一组相同数字的第 <code>k+1</code> 位，就能实现保留 <code>k</code> 位的效果。</p>
<p><strong>第二步·考查元素</strong>。这就涉及到整个算法的核心逻辑。</p>
<blockquote>
<p>位置<code>slow-k</code>到<code>slow-1</code>正好是<strong>最近写入的 k 个元素</strong>。<code>slow-k</code>就是<strong>从当前写入位置向前数第 k 个元素</strong>。</p>
<p>如果这 k 个元素都与<code>nums[fast]</code>相同，<strong>说明当前元素已经达到重复上限</strong>。</p>
<pre><code>有效数组: [x, x, ..., nums[slow-k], ..., nums[slow-1]]
索引:      0, 1, ..., slow-k, ..., slow-1
长度:      &lt;------- slow 个元素 -------&gt;
</code></pre>
<p>这种方法的巧妙之处在于：<strong>不需要维护复杂的计数器</strong>，只需要利用有序数组的特性和简单的指针运算就能判断重复次数。</p>
</blockquote>
<p>如果当前考查的元素 <code>nums[fast]</code> 不等于 <code>nums[slow-k]</code>，就说明这个元素应该被保留，将它放到 <code>slow</code> 的位置上。</p>
<p>这也意味着，我们只需要把 <code>slow</code> 的值初始化为 <code>k</code>，因为在索引 <code>k</code> 以前的元素必定没有重复超过 <code>k</code> 次</p>
<blockquote>
<p>这样做的原因是，如果 <code>nums[slow-k] == nums[fast]</code>，意味着保留的元素超过了 k 位，所以忽略。</p>
</blockquote>
<p>假设 <code>k == 2</code>，数组为 <code>[1, 1, 1, 2, 2, 3, 4, 4, 4]</code>。</p>
<pre><code>nums: [1, 1, 1, 2, 2, 3, 4, 4, 4]
index: 0  1  2  3  4  5  6  7  8
</code></pre>
<table>
<thead>
<tr>
<th>步骤数</th>
<th><code>fast</code></th>
<th><code>slow</code></th>
<th><code>nums[slow-k]</code></th>
<th><code>nums[fast]</code></th>
<th>是否保留</th>
</tr>
</thead>
<tbody>
<tr>
<td>0（初始化）</td>
<td>-</td>
<td>2</td>
<td>-</td>
<td>-</td>
<td>保留前 2 个</td>
</tr>
<tr>
<td>1</td>
<td>2</td>
<td>2</td>
<td><code>nums[0] == 1</code></td>
<td><code>nums[2] == 1</code></td>
<td>❌</td>
</tr>
<tr>
<td>2</td>
<td>3</td>
<td>2</td>
<td><code>nums[0] == 1</code></td>
<td><code>nums[3] == 2</code></td>
<td>✅</td>
</tr>
<tr>
<td>3</td>
<td>4</td>
<td>3</td>
<td><code>nums[1] == 1</code></td>
<td><code>nums[4] == 2</code></td>
<td>✅</td>
</tr>
<tr>
<td>4</td>
<td>5</td>
<td>4</td>
<td><code>nums[2] == 2</code></td>
<td><code>nums[5] == 3</code></td>
<td>✅</td>
</tr>
<tr>
<td>5</td>
<td>6</td>
<td>5</td>
<td><code>nums[3] == 2</code></td>
<td><code>nums[6] == 4</code></td>
<td>✅</td>
</tr>
<tr>
<td>6</td>
<td>7</td>
<td>6</td>
<td><code>nums[4] == 3</code>[^2]</td>
<td><code>nums[7] == 4</code></td>
<td>✅</td>
</tr>
<tr>
<td>7</td>
<td>8</td>
<td>7</td>
<td><code>nums[5] == 4</code></td>
<td><code>nums[8] == 4</code></td>
<td>❌</td>
</tr>
</tbody>
</table>
<p>于是处理完的数组就变成了:</p>
<pre><code>nums: [1, 1, 2, 2, 3, 4, 4]
index: 0  1  2  3  4  5  6
</code></pre>
<p>具体实现代码如下。注意特判数组长度小于 k 的情况。</p>
<pre><code>func removeDuplicates(nums []int) int {
    return RmDup(nums, 1)
}

func RmDup(nums []int, k int) int {
    if len(nums) &lt; k { // 特判
        return len(nums)
    }
    
    slow := k
    for fast := k; fast &lt; len(nums); fast++ {
        if nums[slow-k] != nums[fast] { // 关键代码，直接决定了是否保留 nums[fast]
            nums[slow] = nums[fast]
            slow++
        }
    }
    return slow
}
</code></pre>
<blockquote>
<p>这个算法其实是有局限性的。如果要处理的是一个二维数组，这个算法就束手无策了。而且对于未排序数组，这个算法无效。但我们在此处其实是为了讲明白<strong>双指针</strong>的用法，改进方案现在不需要考虑。</p>
</blockquote>
<p>这道题将 <code>k</code> 改为 2 就是 <a href="https://leetcode.cn/problems/remove-duplicates-from-sorted-array-ii/description/">80. 删除有序数组中的重复项 II - 力扣（LeetCode）</a>，只要这样改即可。</p>
<pre><code>func removeDuplicates(nums []int) int {
    return RmDup(nums, 2)
}

// 其余不变
</code></pre>
<h4>链表问题 - 链表中点</h4>
<p>快慢指针还有一个特点：如果当两个指针的运动速度恒定，那我们能够很方便地找出一个序列中的第 <code>1/n</code> 位，其中 <code>n</code> 是快指针速度除以慢指针速度的结果。</p>
<p>这在链表这种长度未知的序列中非常奏效。比如 <a href="https://leetcode.cn/problems/middle-of-the-linked-list/description/">876. 链表的中间结点 - 力扣（LeetCode）</a>。</p>
<blockquote>
<p><strong>链表快速入门</strong>：</p>
<p>链表（这里指无环单向链表，singly-linked list）由若干个结点组成，其中<strong>每个结点有一个值</strong>，而且有一个<strong>指向下一个结点的指针</strong>（叫做后继指针，常用<strong>结构体指针</strong>实现）。</p>
<p>只要知道了链表的第一个结点，就能顺序推出链表的所有结点。反过来则不行，但双向链表可以。</p>
<p>链表是可以<strong>有环的</strong>。<strong>环链表</strong>的最后一个节点的后继指针指向链表中的某个节点，形成一个闭环，而不是像无环链表一样指向空指针。</p>
<p><strong>双向链表</strong>的结点除了指向有下一个结点的指针，也有<strong>指向上一个结点的指针</strong>（叫做<strong>前驱指针</strong>），所以此时只要知道双向链表的任意一个结点就能推出链表的所有节点。</p>
<p>特别地，<strong>循环链表</strong>的最后一个结点指向头结点，从而形成一个闭环。</p>
<p>下面是一个实现单向链表<strong>结点</strong>的结构体代码。</p>
<pre><code>type ListNode struct {
    Data int // 当前结点的值
    Next *ListNote // 后继指针，指向另一个结点结构体
}
</code></pre>
</blockquote>
<p>因为需要找的是链表的中点（二分之一位置），所以让快指针走两步时，慢指针只走一步。</p>
<pre><code>/**
 * 单向链表的定义如下
 * type ListNode struct {
 *     Val int
 *     Next *ListNode
 * }
 * 如果不加声明，默认单向链表定义都如此
 */
func middleNode(head *ListNode) *ListNode {
    slow, fast := head, head
    for fast != nil &amp;&amp; fast.Next != nil { // 判定空指针，而且不能颠倒，原因看下面
        fast = fast.Next.Next
        slow = slow.Next
    }
    return slow
}
</code></pre>
<p>原题中输入的链表结点是一个<strong>结构体指针</strong>，<strong>是指针就必须要考虑空指针的情况</strong>。</p>
<p>因为 <code>&amp;&amp;</code> 运算符会先判断前面的值是否为 <code>true</code>（判定结果如果是 <code>false</code> 就不用判定后面的值了），所以应该先判断 <code>fast</code> 是否为一个空指针。否则当 <code>fast</code> 是一个空指针时，先判断 <code>fast.Next</code> 会导致程序 <code>panic</code>。</p>
<h4>链表问题 - 链表中环的起点和长度</h4>
<p>快慢指针的速度差还能帮助解决判定链表是否有环的问题。</p>
<p>假如有一个跑道，事先不知道这个跑道是环形跑道还是直线型跑道，两名同学一快一慢从起点开始跑。如果最终快的同学赶上了慢的同学，说明跑道是有环的；否则，如果快的同学跑到了终点（<code>fast == nil</code> 或 <code>fast.Next == nil</code>）都没追上慢的同学，说明跑道是直线型的。</p>
<p>不难理解，所以直接写出如下的代码。</p>
<pre><code>func hasCycle(head *ListNode) bool {
    slow, fast := head, head
    
    for fast != nil &amp;&amp; fast.Next != nil { // 判定是否走到了链表末尾结点
        slow = slow.Next
        fast = fast.Next.Next
        
        if slow == fast { // 快指针追上了
            return true
        }
    }
    return false
}
</code></pre>
<p>这只能告诉我们链表是否有环。那么我们就来试试看分析两个指针和链表是否有一些有趣的性质，让我们能找到环的起点跟环的长度。</p>
<blockquote>
<p><strong>数学推导</strong>：</p>
<p>设 <code>slow</code> 走过路径长为 $ x $，那么 <code>fast</code> 走过的路径长为 $ 2x $．</p>
<p>如下图所示，将链表抽象为如下所示的图形．设链表的头为点 $ A $，环的起点为点 $ P $，两指针相遇点为点 $ B $．</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/pictures0/%E9%93%BE%E8%A1%A8.svg" alt="链表" /></p>
<p>令 $ m = \left| AP \right|, n = \left| PB \right|, $ 环的长度为 $ L $，且取两自然数（不是任取） $ a, b \in \mathbb{N} $．显然对于 $ x $，有</p>
<p>$$
\begin{align}
2x &amp;= m + n + a \cdot L \
x  &amp;= m + n + b \cdot L
\end{align}
$$</p>
<p>两者作差，即</p>
<p>$$
x = \left( a - b \right)L
$$</p>
<p>也就是说 <code>slow</code> 走过的距离恰好是环长度的整数倍．因为无论走了多少圈，<code>slow</code> 最终都在点 $ B $ 上，因此 <code>slow</code> 可视作从点 $ B $ 开始走了一整圈，回到点 $ B $．所以有</p>
<p>$$
\begin{align}
\left( a - 2b \right)L = m + n &amp;\Rightarrow m + n = L \
&amp;\Rightarrow m = L - n
\end{align}
$$</p>
<p>$ m $ 就是所求的环的起始位置．</p>
<p>容易想到，将快指针 <code>fast</code> 复位到点 $ A $，接着将 <code>fast</code> 与 <code>slow</code> 的速度同时调至每次前进一步．</p>
<p>此时只要 <code>fast == slow</code>，此时的 <code>slow</code> 就是环的起点．</p>
</blockquote>
<p>上面的推导过程转换为代码，如下所示。</p>
<pre><code>func detectCycle(head *ListNode) *ListNode {
    slow, fast := head, head
    for {
        if fast == nil || fast.Next == nil { // 将判断空指针移动到循环内
            return nil
        }
        
        fast = fast.Next.Next
        slow = slow.Next
        
        // 两指针碰头，说明链表有环
        if fast == slow {
            // fast 回复到链表头，重新开始遍历
            fast = head
            break
        }
    }

    for {
        if fast == slow {
            break
        }
        slow = slow.Next
        fast = fast.Next // 两者速度调整到每次前进一步
    }

    return slow // 找到起点
}
</code></pre>
<p>这就是<a href="https://leetcode.cn/problems/linked-list-cycle-ii/">142. 环形链表 II - 力扣（LeetCode）</a>的解法。</p>
<p>如果要求环的长度，只要把 <code>slow</code> 按住不动，<code>fast</code> 前进直到再次碰头即可。方法雷同所以不再展示代码。<em>事实上这种找链表环的方法叫做弗洛伊德判圈法，它是一种基于数学归纳法和基本事实的判圈方法。</em></p>
<h3>对撞指针 - 变形</h3>
<h4>二分查找 - 对撞指针特例</h4>
<p>事实上在<a href="https://karlbaey.top/articles/program-design-complexity-of-a-program-episode-three-p-two/#%E5%85%B7%E4%BD%93%E4%BB%A3%E7%A0%81">复杂度</a>那时候我们就见过<strong>对撞指针</strong>（collision pointer）了，不过那时候它还不叫对撞指针，而是叫做<strong>二分查找</strong>。</p>
<p><strong>二分查找</strong>就是对撞指针的特例之一。它通过不断减半左右指针框住的范围大小，从而实现在 <code>O(log n)</code> 的时间复杂度以内，在有序数组里搜索（search）特定元素。</p>
<pre><code>func peakIndexInMountainArray(arr []int) int {
    var left, mid int
    right := len(arr) - 1
    
    for right &gt; left {
        mid = (right + left) / 2 // 找中点
        if arr[mid] &lt; arr[mid+1] { // 判断峰值方向
            // 为真，山顶在 mid 右侧
            left = mid + 1
        } else {
            // 为假，山顶在 mid 左侧
            right = mid
        }
    }
    return left
}
</code></pre>
<p>在上面的代码中，<code>left</code> 和 <code>right</code> 最终会撞在一起，此时也就找到了山顶。</p>
<p>二分查找虽然快，但是它只能针对已经以特定顺序排好的数组进行搜索，否则就是白忙活。</p>
<p>对撞指针也是一样，必须在排好序的数组里，它才能发挥作用。</p>
<p>因为排好序的数组具有单调性，单调性能给指针移动提供依据，因此这种单调性是双指针搜索不可或缺的。</p>
<h4>寻找双元组 - 有序数组内的对撞指针</h4>
<p><a href="https://leetcode.cn/problems/two-sum/description/">leco1 两数之和</a>给出的哈希表解法事实上适用于所有同类问题，但是如果放在 <a href="https://leetcode.cn/problems/two-sum-ii-input-array-is-sorted/description/">167. 两数之和 II - 输入有序数组 - 力扣（LeetCode）</a>里面，我们会发现空间复杂度跟 leco1 相差无几，这显然不合理。</p>
<p>再往下看，leco1 的数组是无序的，但 leco167 的数组已经预先排好序（非递减顺序）。我们没有用上<strong>排好序</strong>这个性质，所以才会导致较劣的空间复杂度。</p>
<p>上面已经说过，对撞指针非常适合应用在排好序的数组中，我们就分析一下，利用排好序，能不能优化复杂度。</p>
<blockquote>
<p>给你一个下标从 <strong>1</strong> 开始的整数数组 <code>numbers</code> ，该数组已按 <strong>非递减顺序排列</strong> ，请你从数组中找出满足相加之和等于目标数 <code>target</code> 的两个数。如果设这两个数分别是 <code>numbers[index1]</code> 和 <code>numbers[index2]</code> ，则 <code>1 &lt;= index1 &lt; index2 &lt;= numbers.length</code> 。</p>
<p>以长度为 2 的整数数组 <code>[index1, index2]</code> 的形式返回这两个整数的下标 <code>index1</code> 和 <code>index2</code>。</p>
<p>你可以假设每个输入 <strong>只对应唯一的答案</strong> ，而且你 <strong>不可以</strong> 重复使用相同的元素。</p>
<p>你所设计的解决方案必须只使用常量级的额外空间。</p>
</blockquote>
<p>假设当前将指针摆在第一位和最后一位，给出一个 <code>now</code> 记录当前的和，也就是 <code>now = numbers[left] + nunbers[right]</code>。</p>
<p>因为数组已经排好了序，所以</p>
<ul>
<li>要使 <code>now</code> 增大，必须将左指针右移（增大较小的数）</li>
<li>要使 <code>now</code> 减小，必须将右指针左移（减小较大的数）</li>
</ul>
<p>因此很容易根据上面的条件写出流程控制。直到 <code>now == target</code> 即可返回。</p>
<p>⚠️<strong>注意</strong>：对撞指针事实上仍然是两个指针，因此在原题中明确提到不能重复使用元素的前提下，不能让左右指针重合。</p>
<pre><code>// 错误示范
if now &lt;= target {
    left++
}
</code></pre>
<p>将上面的过程画成图，如下所示。</p>
<pre><code>初始值
numbers: [2, 7, 11, 15]
target: 9

初始状态: left=0, right=3

numbers: [2, 7, 11, 15]
          ↑         ↑
         left     right

第 1 轮: 2 + 15 = 17 &gt; 9 → 太大，right--

numbers: [2, 7, 11, 15]
          ↑     ↑
         left right

第 2 轮: 2 + 11 = 13 &gt; 9 → 太大，right--

numbers: [2, 7, 11, 15]
          ↑  ↑
        left right

第 3 轮: 2 + 7 = 9 == 9 → 找到答案！

返回值: [left+1, right+1] == [1, 2]
</code></pre>
<p>注意题目中的下标不是我们常见的 <strong>0-based</strong> 下标（索引从 <strong>0</strong> 开始）而是 <strong>1-based</strong> 下标，因此处理完要给两指针加上 1。</p>
<pre><code>func twoSum(numbers []int, target int) []int {
    left, right := 0, len(numbers)-1

    // 指针开始运动
    for left &lt; right {
        now := numbers[left]+numbers[right]
        
        if now == target { // 满足条件
            return []int{left+1, right+1}
        } else if now &lt; target { // 比 target 小，左指针右移
            left++
        } else {
            right--
        }
    }
    return []int{}
}
</code></pre>
<h4>寻找三元组 - 对撞指针高阶</h4>
<p>然后我们突然发现 Go 内置了 <code>sort</code> 包用于调用排序函数。所以我们甚至能够把双指针算法推广到更复杂的情况中。</p>
<p>例如 <a href="https://leetcode.cn/problems/3sum/description/">leco15 三数之和</a>。它输入的数组没有排序，我们就能够先调用 <code>sort.Ints()</code>，将复杂的三元组问题转化为简单的双指针问题。</p>
<blockquote>
<p>想一想为什么数组要先排序？</p>
</blockquote>
<p>我们先在数组中取一个固定点 <code>i</code>。这个 <code>i</code> 的相反数 <code>-i</code> 事实上就是 leco167 中的 <code>target</code> ，意思就是，我们要在数组中找到两个数 <code>nums[left]</code> 和 <code>nums[right]</code> ，使得 <code>-nums[i] == nums[left] + nums[right]</code>。</p>
<p>观察三元组的特点，我们能得到 <code>i</code> 在区间 <code>[0, len(nums)-3]</code> 中。也就是说，我们的目标转换成了解决 <code>len(nums)-3</code> 次<strong>两数之和问题</strong>。</p>
<p>整个程序的流程如下。</p>
<ol>
<li><strong>给数组排序</strong>：<code>sort.Ints(nums)</code></li>
<li>对于每个索引 <code>i</code>：
<ul>
<li><strong>跳过重复值</strong>。如果 <code>i &gt; 0</code> 且 <code>nums[i] == nums[i-1]</code>，跳过当前 <code>i</code></li>
<li><strong>设置目标</strong>。<code>target = -nums[i]</code></li>
<li><strong>初始化对撞指针</strong>。<code>left = i + 1</code>，<code>right = n - 1</code>（左指针初始化为 <code>i+1</code> 的原因是避免跟 <code>i</code> 重复，但下面还有去重）</li>
<li><strong>对向指针搜索</strong>：
<ul>
<li>当 <code>left &lt; right</code>：
<ul>
<li>计算 <code>sum = nums[left] + nums[right]</code></li>
<li>如果 <code>sum == target</code>：找到解，记录三元组
<ul>
<li><strong>去重处理</strong>：跳过所有重复的 <code>nums[left]</code> 和 <code>nums[right]</code></li>
</ul>
</li>
<li>如果 <code>sum &lt; target</code> ➡️ <code>left++</code></li>
<li>如果 <code>sum &gt; target</code> ➡️ <code>right--</code></li>
</ul>
</li>
</ul>
</li>
</ul>
</li>
<li>返回所有三元组。</li>
</ol>
<p>可视化处理如下。</p>
<p><strong>排序</strong>：</p>
<pre><code>原数组: [-1, 0, 1, 2, -1, -4]
排序后：
nums = [-4, -1, -1, 0, 1, 2]
</code></pre>
<p><strong>第一轮</strong>：</p>
<pre><code>i = -4
target = 4
nums: [-4, -1, -1, 0, 1, 2]
       ↑   ↑            ↑
       i  left         right

-1 + 2 = 1 &lt; 4 → left++
-1 + 2 = 1 &lt; 4 → left++  
0 + 2 = 2 &lt; 4 → left++
1 + 2 = 3 &lt; 4 → left++ (left &gt;= right，结束)

本轮未找到符合条件的三元组
</code></pre>
<p><strong>第二轮</strong>：</p>
<pre><code>i = -1
target = 1
nums: [-4, -1, -1, 0, 1, 2]
            ↑   ↑        ↑
            i  left    right

-1 + 2 = 1 == 1 → 找到解 [-1, -1, 2]
去重：移动left和right跳过重复值
然后 left++，right--
</code></pre>
<p><strong>继续搜索</strong>：</p>
<pre><code>数组: [-4, -1, -1, 0, 1, 2]
            ↑     ↑  ↑
            i  left  right

0 + 1 = 1 == 1 → 找到解 [-1, 0, 1]
本轮找到 1 个三元组 [-1, -1, 2] [-1, 0, 1]
</code></pre>
<p>转换成代码如下所示。</p>
<pre><code>func threeSum(nums []int) [][]int {
    sort.Ints(nums) // 排序
    res := [][]int{}
    n := len(nums)

    for i := range n - 2 { // 在区间 [0, n-3] 内寻找 i
        // 去掉重复的 nums[i]
        // 这一行是最关键的！直接决定去重是否彻底
        if i &gt; 0 &amp;&amp; nums[i] == nums[i-1] {
            continue
        }
        
        // 数组是已经排好序的，因此假如 i == 0，无论如何都找不到符合条件的三元组
        if nums[i] &gt; 0 {
            break
        }
        
        // 开始对撞指针
        left, right := i+1, n-1
        target := -nums[i] // 与 leco167 一致，先定下 target

        for left &lt; right { // 指针撞上
            now := nums[left] + nums[right]
            
            if now == target { // 找到了三元组，加入答案中
                res = append(res, []int{nums[i], nums[left], nums[right]})
                //下面两个循环都是为了去除重复的 nums[left] 和 nums[right]
                for left &lt; right &amp;&amp; nums[left] == nums[left+1] {
                    left++
                }
                for left &lt; right &amp;&amp; nums[right] == nums[right-1] {
                    right--
                }
                // 到了这里，说明 nums[left] 和 nums[right] 都停在重复的最后一位
                left++
                right--
                // 如果去重安排在记录解之前，会造成漏解
            } else if now &lt; target { // 下方处理同 leco167
                left++
            } else {
                right--
            }
        }
    }
    return res
}
</code></pre>
<p>这个算法的<strong>时间复杂度</strong>也很有意思。首先 <code>sort.Ints()</code> 看作调用快速排序，所以它的时间复杂度是 <code>O(n log n)</code>。</p>
<p>下面虽然嵌套三层循环，但是最里面一层的循环是为了<strong>去重</strong>，<strong>只有外面两层循环会遍历数组</strong>。所以这个三层循环的时间复杂度是 <code>O(n^2)</code></p>
<p>运用<strong>渐进分析</strong>，当 <code>n</code> 很大时（本题中为 1,000），实际上的时间复杂度是 <code>O(n^2)</code>。</p>
<p>如果使用单纯的三层循环遍历，那么时间复杂度会恶化到 <code>O(n^3)</code>。我们在上面写的算法是显然更优的。</p>
<p><strong>空间复杂度</strong>主要看排序算法。因为是快速排序，空间复杂度就是 <code>O(1)</code>。</p>
<hr />
<p>上面的 <strong>固定数 -&gt; 对撞指针</strong>（有时也叫<strong>固定指针+对向双指针的三指针模式</strong>）可以看做一种<strong>双指针思想</strong>，<a href="https://leetcode.cn/problems/3sum-closest/description/">leco16 最接近的三数之和</a>、<a href="https://leetcode.cn/problems/4sum/description/">leco18 四数之和</a>也可以用这种思想解决。它们的解法放在本文的额外部分。</p>
<p>⚠️<strong>注意</strong>：在寻找元组问题中，<strong>去重</strong>是这一类题的最大难点。必须在找到一组解后，跳过所有相同的左指针值和右指针值。同时，固定的那个数也需要去重。</p>
<h2>额外部分</h2>
<h3>leco16 最接近的三数之和 解法</h3>
<pre><code>func threeSumClosest(nums []int, target int) int {
    var res int
    abs := 2147483647 // impossible
    sort.Ints(nums)
    fmt.Println(nums)
    n := len(nums)

    for i := range n - 2 {
        if i &gt; 0 &amp;&amp; nums[i] == nums[i-1] { // 去掉重复的 i
            continue
        }

        left, right := i+1, n-1

        for left &lt; right {
            now := nums[i] + nums[left] + nums[right]
            if now == target {
                return target
            } else if now &lt; target {
                if abs &gt; target-now {
                    abs = target - now
                    res = now
                }
                left++
            } else {
                if abs &gt; now-target {
                    abs = now - target
                    res = now
                }
                right--
            }
        }
    }
    return res
}
</code></pre>
<h3>leco18 四数之和 解法</h3>
<pre><code>func fourSum(nums []int, target int) [][]int {
    if len(nums) &lt; 4 {
        return [][]int{}
    }
    res := [][]int{}
    n := len(nums)
    sort.Ints(nums)
    for i := range n - 3 {
        if i &gt; 0 &amp;&amp; nums[i] == nums[i-1] { // 去重
            continue
        }
        if nums[i]+nums[i+1]+nums[i+2]+nums[i+3] &gt; target { // 第一组优化
            break
        }
        if nums[i]+nums[n-1]+nums[n-2]+nums[n-3] &lt; target { // 第二组优化
            continue
        }

        for j := i + 1; j &lt; n-2; j++ {
            if j &gt; i+1 &amp;&amp; nums[j] == nums[j-1] {
                continue
            }
            if nums[j]+nums[j+1]+nums[j+2]+nums[i] &gt; target { // 第一组优化
                break
            }
            if nums[j]+nums[n-1]+nums[n-2]+nums[i] &lt; target { // 第二组优化
                continue
            }

            left, right := j+1, n-1

            for left &lt; right {
                now := nums[j] + nums[left] + nums[right] + nums[i]
                if now == target {
                    res = append(res, []int{nums[j], nums[left], nums[right], nums[i]})
                    for left &lt; right &amp;&amp; nums[left] == nums[left-1] {
                        left++
                    }
                    for left &lt; right &amp;&amp; nums[right] == nums[right+1] {
                        right--
                    }
                } else if now &lt; target {
                    left++
                } else {
                    right--
                }
            }
        }
    }
    return res
}
</code></pre>
<p>这其实就是 <strong>leco15 三数之和</strong> 多套了一层循环。因为方法类似就不再解释循环的用途，但其中有两个优化的点。</p>
<p>下面两个优化可以放在三数之和的解法中。</p>
<h4>第一组优化</h4>
<pre><code>if nums[i]+nums[i+1]+nums[i+2]+nums[i+3] &gt; target { // 第一组优化
    break
}
</code></pre>
<pre><code>if nums[j]+nums[j+1]+nums[j+2]+nums[i] &gt; target { // 第一组优化
    break
}
</code></pre>
<p>如果 <code>nums[i]+nums[i+1]+nums[i+2]+nums[i+3] &gt; target</code>，这就说明此时的 <code>i</code> 已经太大了，在这之后无论怎么找都不会找到等于 <code>target</code> 的四数之和。（因为此时 <code>nums[i+1]</code>、<code>nums[i+2]</code> 和 <code>nums[i+3]</code> 已经是 <code>nums[i]</code> 之后的三个最小值）</p>
<p>下面在 <code>j</code> 的循环里也是一样。</p>
<h4>第二组优化</h4>
<pre><code>if nums[i]+nums[n-1]+nums[n-2]+nums[n-3] &lt; target { // 第二组优化
    continue
}
</code></pre>
<pre><code>if nums[j]+nums[n-1]+nums[n-2]+nums[i] &lt; target { // 第二组优化
    continue
}
</code></pre>
<p>如果 <code>nums[j]+nums[n-1]+nums[n-2]+nums[i] &lt; target</code>，就说明此时的 <code>i</code> 太小了，直接跳过进入下一次循环即可。（因为 <code>nums[n-1]</code>、<code>nums[n-2]</code> 和 <code>nums[i]</code> 已经是整个数组里三个最大的数了）</p>
<hr />
<p>在双指针和滑动窗口的<strong>第一部分</strong>，我们讨论了双指针两种最最基础的应用：<strong>快慢指针</strong>和<strong>对撞指针</strong>。</p>
<p>快慢指针可以用来解决去除数组内重复项的问题，也可以用来寻找链表等未知长度序列的中点以及链表环。而对撞指针适合在排好序的数组内寻找和一定的元组，对撞指针只遍历了一次数组，所以对撞指针的时间复杂度是 <code>O(n)</code>，远远优于暴力两次遍历的 <code>O(n^2)</code>。</p>
<p>以上就是 <strong>【经典算法】双指针和滑动窗口·第一部分</strong> 的全部内容。下一期将会讲解双指针的一个经典变形：<strong>滑动窗口</strong>。接着就是双指针的特殊应用（荷兰国旗问题，三指针算法）。</p>
<p>🎉撒花🎉</p>
<p>[^2]: 注意此时 <code>nums[4]</code> 已经被修改了（在表格第 6 行第 4 列）。下同。</p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2025-09-24T12:26:57.203Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[大半夜听了寂静岭之后]]></title>
        <id>https://re.karlbaey.top/articles/when-listening-to-silent-hill-2-ost-before-sunrise/</id>
        <link href="https://re.karlbaey.top/articles/when-listening-to-silent-hill-2-ost-before-sunrise/"/>
        <updated>2025-09-20T18:54:00.144Z</updated>
        <summary type="html"><![CDATA[最近翻出了《寂静岭2》的 OST 来听，2024 重制版跟老版的比起来差别好大啊。《Theme of Laura》这一首特别明显，原版这首歌...]]></summary>
        <content type="html"><![CDATA[<p>最近翻出了《寂静岭2》的 OST 来听，2024 重制版跟老版的比起来差别好大啊。</p>
<p>《Theme of Laura》这一首特别明显，原版这首歌的结尾就好像是“我还有话要说，但是时间不多了，下次再谈吧”的那种感觉；重制版取消了这个安排，反而像结束了很长很长的故事，卸下了一个沉重的包袱之后，开启新人生的样子。</p>
<p>其实是很正常的，James 本身就是对 Mary 抱有非常非常深的愧疚。记得以前听过一句话：“我对你的恨是真的，我对你的愧疚也是真的，但是最让我崩溃的是，我对你的爱也是真的。”James 就在这种挣扎中接受了寂静岭的考验（鬼畜男就是模仿 James 照顾卧病在床久矣的 Mary 期间那种欲脱而不能得的无力感而塑造出来的）。在“离开”结局里 James 和 Laura 一起离开了寂静岭，这就说明 James 面对了自己的过错，抱着对 Mary 的愧疚决定度过余生了。因为 James 在事实上杀死了 Mary，领养 Laura 大概也是为了弥补这种愧疚吧。</p>
<p>山冈晃接受采访的时候说，因为他在创作这首歌之后接触到的第一个人物就是 Laura，于是就机缘巧合下定下了这名字。现在看，真是天作之合。</p>
<p>另一首世界级名曲《Promise》也有很大的改动，因为听过由由的吉他那一版的封神作，所以我其实也没有报多么大的期望。有点出乎我意料的是重制版《Promise》换了一个思路，虽然保持了原版的鼓点没有变，但是尾杀段明显能听出来里世界给人的彷徨和抓挠感。原版中的尾杀段编曲确实丰富，但不知怎么的好像少了点重制版的张力。</p>
<p>仔细听一下，其实能发现，无论是哪一版，全曲的鼓点一直保留着几乎不变的节奏，说实话有点像人的心跳声。也就是因为这一个鼓点，把电吉他的诉说感推到巅峰了。电吉他的失真音色就是最最适合这一类音乐的。</p>
<p>原版《Promise》给了“玛丽亚”结局[^1]，重制版给了“在水中”结局，其实也能看出来制作组认为“在水中”才应该是 James 真正的结束：抱着所有的自毁倾向和愧疚冲进了托卢卡湖。《Promise》起始于平静的电吉他，终止于平静的电吉他；《寂静岭2》开始于浓雾弥漫的托卢卡湖畔，结束于托卢卡湖平静的水下，也算是对应上了。</p>
<p>重制版达成“玛丽亚”结局之后 Steam 会跳出成就“恶性循环”，听起来真的是很戏谑的成就名：既暗示了 Maria “应愿而生”（原版的 DLC 也叫这个）的本质，又说明了 James 永远也跳不出这个愧疚铸造的怪圈。</p>
<p>可能我在之后也会时时想起《寂静岭2》，想起来这一对苦命鸳鸯。他们的故事是在整个寂静岭系列中最最震撼我的。可能不如其他作那么宏大，但是实在是非常难忘。以前看的事情少，就觉得寂静岭这种怪物吓人、场景诡谲的游戏不适合我；现在反而觉得他做到了大多数恐怖游戏无法做到（或者是不愿做到）的事情：教人勇敢。现在我也想看一看自己的寂静岭长什么样了。</p>
<p>每当一个游戏被决定制作重制版的时候，我总是会不自觉地对比原版。这就导致，可能一些重制版做得还不赖，但是跟原版比起来差的太远导致我想骂。但是我承认《寂静岭2》的重制版是超出我预期的。晃叔居然能在科乐美一砍再砍预算的前提下，仍然把整条 OST 用最好的状态呈现出来，太了不起了。</p>
<p>啊啊，原来《寂静岭2》的 OST 这么长，如果下次想起来要写，再继续吧。</p>
<hr />
<p>居然还不赖。 :thinking:</p>
<p>[^1]: 没玩过 PS 上的原版，不知道有没有记错。</p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2025-09-20T18:54:00.144Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[程序设计#3%2 - 算法的复杂度]]></title>
        <id>https://re.karlbaey.top/articles/program-design-complexity-of-a-program-episode-three-p-two/</id>
        <link href="https://re.karlbaey.top/articles/program-design-complexity-of-a-program-episode-three-p-two/"/>
        <updated>2025-09-14T09:38:11.035Z</updated>
        <summary type="html"><![CDATA[复杂度是能够用来衡量程序好坏的概念，它用来描述随着输入大小 n 的增长，程序的处理时间和占用空间会怎样改变。其中有一个观念不可忽略：就是我们...]]></summary>
        <content type="html"><![CDATA[<p>复杂度是能够用来<strong>衡量程序好坏</strong>的概念，它用来描述随着输入大小 <code>n</code> 的增长，程序的处理时间和占用空间会怎样改变。其中有一个观念不可忽略：就是我们通常假设 <code>n</code> 是一个比较大的数（例如 10,000），这样计算复杂度才是有价值的，否则，在 <code>n</code> 很小时，复杂度无法明确地展示程序的好坏。</p>
<h2>复杂度的诞生</h2>
<p>用一个比喻开始。</p>
<p>你是一名图书馆管理员。这天馆长找你，让你在一个有 100,000 本书的书架上找到所有的《三体2：黑暗森林》。</p>
<p>这个书架可能有两种最极端的情况。</p>
<ul>
<li>A：所有的书杂乱地放在书架上，毫无规律。</li>
<li>B：所有的书都严格地按照书名拼音 A-Z 的顺序整齐摆好。</li>
</ul>
<p>更有利于我们搜索的排列方式显然是 B。在后者的前提下，我们只要在书架找到“S”开头的书籍，接着找“T”……很有可能找了二十本不到，就找完需要的书了。</p>
<p>相反，对于 A，在最坏的前提下，需要找完所有 100,000 本书才能知道是否找完。</p>
<p><strong>复杂度（complexity）就是为了衡量你这个“找书”任务的效率而诞生的。<strong>它不关心你的手速有多快（处理器主频），也不关心图书馆的灯光亮不亮（编程语言的速度），它只关心</strong>这个方法本身</strong>的好坏。我们需要一个统一的标准，来评判不同“找书方法”（算法）的效率，这就是时间复杂度和空间复杂度。</p>
<p>为什么不用实际时间？因为同一段代码在不同性能的电脑上运行，时间是不一样的。我们应该关注算法<strong>内在的、固有的</strong>效率。</p>
<p>今天的家用计算机每秒能执行几十亿次运算，而且不同计算机性能差异不可忽略，所以找一个标准来衡量程序好坏是很必要的。</p>
<h2>复杂度的计算</h2>
<h3>时间复杂度</h3>
<p>回到找书的例子。</p>
<p>我们如果需要知道一本书的书名，就需要拿起书本，看看封面。这个看封面的操作我们称作<strong>基本操作</strong>。</p>
<p>假设书架上有 <code>n</code> 本书：</p>
<ul>
<li>在情况 A 里，我们需要拿起每一本书。这时需要执行 <code>n</code> 次基本操作。</li>
<li>在情况 B 里，我们可以使用二分查找。看看第 <code>n/2</code> 本的书名，如果发现首字母是 <code>M</code>，就往后半部分找，以此类推。每次要找的部分都是上一次的一半（也就是查找部分的大小从 <code>n</code> 开始，一直往后就是 <code>n/2</code>、<code>n/4</code>、<code>n/8</code>……），所以这样找书只需要 <code>log_2 n</code> 次基本操作。
<ul>
<li>二分查找是一种搜索思路。这种搜索思路可以这样解释：将待搜索的部分平分，然后根据中点，确认当前要找的东西在哪一部分，再把这一部分继续平分。直到没有或只剩一个要搜索的部分，就得到答案了。</li>
</ul>
</li>
</ul>
<p>这里的 <code>n</code> 和 <code>log_2 n</code> 就是两种找书方案的复杂度。当 <code>n</code> 变得非常大时，我们也能大概看出这两种方案需要的基本操作次数。</p>
<p>但是放在写好的程序里，不需要非常精确地说明每一个程序的复杂度。因为常数（<code>log_2 n</code> 中的 <code>2</code> 就是常数）会在 <code>n</code> 变得非常大时变得不重要。我们只关心<strong>增长的趋势和速度</strong>。</p>
<p>所以计算机科学家借用了数学的渐进分析，使用<strong>大 O 表示法</strong>[^1]表示“大约”的复杂度等级。常见的等级有：</p>
<ul>
<li><strong>O(1) - 常数时间：</strong> 这是最好的情况。就像我们手头有一个魔法口袋，无论图书馆有多少书（<code>n</code> 有多大），你<strong>伸手一次</strong>就能直接拿出《三体2》。操作次数不随 <code>2</code> 增大而改变。
<ul>
<li><em>现实例子：通过数组下标直接访问元素 <code>arr[5]</code>。</em></li>
</ul>
</li>
<li><strong>O(log n) - 对数时间：</strong> <strong>非常高效！</strong> 就像我们的<strong>有序图书馆</strong>，用二分法找书。N即使增长到原来的2倍，你只需要多找1次。书从100本变成100万本，你只需要从7次操作增加到20次操作。
<ul>
<li><em>现实例子：二分查找。</em></li>
</ul>
</li>
<li><strong>O(n) - 线性时间：</strong> 比如在<strong>乱序图书馆</strong>里一本一本地找。书的数量增加几倍，你需要的时间也大致增加几倍。这是最公平、最常见的情况。
<ul>
<li><em>现实例子：遍历一个数组寻找最大值。</em></li>
</ul>
</li>
<li><strong>O(n&lt;sup&gt;2&lt;/sup&gt;) - 平方时间：</strong> 情况开始恶化。这就像馆长让你不是找一本书，而是<strong>给所有书都拍一张“合影”</strong>。你需要让每本书都和另一本配对。操作次数大约是 <code>n*n</code> 次。如果书从 100 本增加到 1,000 本，你的工作量会从 10,000 次暴增到 1,000,000 次！
<ul>
<li><em>现实例子：使用双重循环检查数组中是否有重复元素。</em></li>
</ul>
</li>
<li><strong>O(2&lt;sup&gt;n&lt;/sup&gt;) - 指数时间：</strong> 这就像“汉诺塔”游戏，盘每增加一个，所需步骤就会翻倍。<code>n</code> 稍微大一点（比如超过 63），这个算法可能直到宇宙毁灭都算不完。<strong>这是我们拼命想要避免的复杂度。</strong>
<ul>
<li><em>现实例子：一个非常低效的递归解法，比如暴力穷举所有组合。</em></li>
</ul>
</li>
</ul>
<p>下面这张图展示了不同时间复杂度的增长趋势。横轴表示<strong>输入数据量</strong>，纵轴表示<strong>消耗时间</strong>。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/pictures/geogebra-export.svg" alt="Complexity" /></p>
<p>通常来说，一个遍历输入的 <code>for</code> 循环就会占用 O(n) 的时间复杂度，而两个循环嵌套就会占用 O(n&lt;sup&gt;2&lt;/sup&gt;) 的时间复杂度，以此类推。上升趋势更大就会涉及到<strong>回溯</strong>算法和<strong>递归</strong>。</p>
<p>时间复杂度的计算遵循<strong>渐进分析</strong>，也就是<strong>只保留最高次项</strong>。例如，我们分析得到一个算法的时间复杂度是 O(n&lt;sup&gt;2&lt;/sup&gt;) + O(n)。</p>
<pre><code>func Aaaaaaa(inp []int) int {
    for i := range inp {
        for j := range inp { // 双层嵌套 -&gt; O(n^2)
            // Something
        }
    }
    
    for k := range inp { // O(n)
        // Another something
    }
    
    return 0
}
</code></pre>
<p>它实际上的复杂度应该是 <strong>O(n&lt;sup&gt;2&lt;/sup&gt;)</strong>，因为 O(n&lt;sup&gt;2&lt;/sup&gt;) 的增速远大于 O(n)，O(n) 造成的影响在 <code>n</code> 很大时几乎可以忽略。</p>
<h3>空间复杂度</h3>
<p>空间复杂度直接反映了为了程序执行而额外占用的空间大小。</p>
<p><strong>任务本身</strong>就需要一个图书馆（存储所有书），这个空间是<strong>固有的、无法优化的</strong>，输出值也是一样。</p>
<p>我们关心的是<strong>你作为管理员</strong>，在执行“找书”这个任务时，需要<strong>额外</strong>申请多少工具和空间。</p>
<p>同样用大O表示法：</p>
<ul>
<li><strong>O(1)：</strong> 只需要固定大小的空间（比如几个变量），<strong>原地操作</strong>，非常省空间。比如找书时我们只用记住当前在手上的书，这就是常数空间。</li>
<li><strong>O(n)：</strong> 需要额外开辟一个和输入数据量 <code>n</code> 成正比的数组或列表。如果要把所有的书名都记在一个本子上，就相当于额外占用了一个大小为 <code>n</code> 的空间。</li>
<li><strong>O(n&lt;sup&gt;2&lt;/sup&gt;)：</strong> 需要开辟一个二维数组（比如矩阵），空间消耗巨大。</li>
</ul>
<p>对于一个递归深度为 <code>n</code> 的算法（计算阶乘就属于一种递归），其空间复杂度至少为 <strong>O(n)</strong>。因为每次递归调用，空间占用都会在原有基础上 +1。</p>
<hr />
<p>通常，我们追求的是<strong>时间更快</strong>或<strong>空间更省</strong>的算法。但鱼和熊掌不可兼得，很多时候需要在<strong>时间和空间之间进行权衡</strong>：</p>
<ul>
<li>有时我们可以<strong>用空间换时间</strong>：比如多建一个索引本（消耗额外空间），但能让以后找书的速度飞快（减少时间）。</li>
<li>有时我们可以<strong>用时间换空间</strong>：比如为了不占用新本子，我们宁愿在原始书堆里多花时间慢慢找。</li>
</ul>
<h3>具体代码</h3>
<p>例如 <a href="https://leetcode.cn/problems/minimum-size-subarray-sum/description/">LeetCode 209 长度最小的子数组</a>，我们使用滑动窗口算法，就能在 O(n) 的时间复杂度以内解决。因为每个元素最多被窗口的左端和右端分别访问一次。</p>
<pre><code>func minSubArrayLen(target int, nums []int) int {
    var now, l int
    var ans int = 100100 // 用一个不可能的大数初始化答案

    for r, num := range nums { // `r` 是窗口右边界，循环向右移动
        now += num
        for now &gt;= target {
            ans = min(ans, r-l+1)
            now -= nums[l] // 当窗口满足条件时，尝试收缩左边界以找到更优解
            l++ // 左边界右移
        }
    }
    if ans == 100100 {
        return 0
    }
    return ans
}
</code></pre>
<p>这段代码的思路是这样的：</p>
<ul>
<li>初始化 <code>ans</code> 和 <code>now</code> 作为辅助变量。</li>
<li><code>now</code> 窗口右端对应值 <code>nums[r]</code>，窗口右端扩张。
<ul>
<li>如果 <code>now &gt;= target</code>，那么以当前窗口长度 <code>r-l+1</code> 和 <code>ans</code> 中的较小值作为答案。</li>
<li><code>now</code> 减去 <code>nums[l]</code>，窗口左端收缩</li>
<li>如果此时仍然 <code>now &gt;= target</code>，那么更新 <code>ans</code>；否则跳出循环，重新 <code>now += nums[r]</code></li>
</ul>
</li>
<li>最后排除无满足窗口的情况。</li>
</ul>
<p>上面的代码虽然嵌套了两个 <code>for</code> 循环，但是因为这实际上是两个分开执行的循环，所以最极端的时间复杂度应该是 O(2n) 而不是 O(n&lt;sup&gt;2&lt;/sup&gt;)。</p>
<p>由于我们只使用了几个整数用来存临时变量，所以空间复杂度是 O(1)</p>
<p>滑动窗口算法是双指针算法的变形，而且因为入门门槛低，我打算下一期就做这个了。</p>
<p>滑动窗口是很简单的，熟练之后可以在很短的时间里默写出来。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/pictures/%E5%B1%8F%E5%B9%95%E6%88%AA%E5%9B%BE%202025-09-14%20152710.png" alt="其实只用了 8 分 12 秒" /></p>
<p>再看一个 O(log n) 的示例。</p>
<p><a href="https://leetcode.cn/problems/peak-index-in-a-mountain-array/description/">LeetCode 852. 山脉数组的峰顶索引</a>：这道题最快的方式就是使用二分查找。我们只要判断中点右侧的值比中点值大还是小就能知道峰值方向。</p>
<pre><code>func peakIndexInMountainArray(arr []int) int {
    var l, mid int
    r := len(arr) - 1
    
    for r &gt; l {
        mid = (r + l) / 2 // 找中点
        if arr[mid] &lt; arr[mid+1] { // 判断峰值方向
            l = mid + 1
        } else {
            r = mid
        }
    }
    return l
}
</code></pre>
<p>这样，<code>l</code> 和 <code>r</code> 就会迅速地朝峰值靠近，直到 <code>l == r</code>，我们就找到了峰值的索引。</p>
<p>因为每一次查找，数组 <code>arr</code> 都被分割成了原来的二分之一，也就是说，实际上只执行了 $ \log_2{\textrm{n}} $ 次操作（<code>n = len(arr)</code>）。</p>
<blockquote>
<p>我们知道，对数是指数的逆运算，因此对于方程</p>
<p>$$
2^x = n
$$</p>
<p>此处的 <code>x</code> 就是我们需要的操作次数。那么</p>
<p>$$
x = \log_2{n}
$$</p>
<p>转换成时间复杂度，二分查找的时间复杂度就是 <code>O(log n)</code>。</p>
</blockquote>
<p>如果 <code>n == 100000000</code>，我们只需要 $ \log_2{\textrm{n}} \approx 27$ 次操作。这是非常高效的。</p>
<p>二分查找的缺陷在于，如果边界（<code>l</code> 和 <code>r</code>）没有设置好，很容易超出索引或者导致溢出。</p>
<p>二分查找的空间复杂度是 <code>O(1)</code>，我们只用了若干个 <code>int</code> 类型变量。</p>
<h2>额外部分</h2>
<p>算法执行时间与输入规模 <code>n</code> 的关系称为<strong>时间复杂度</strong>，通常用 <strong>大 O 表示法</strong> 写成 <code>T(n) = O(f(n))</code>。这里的 <code>O(f(n))</code> 并不意味着算法真的花费了 <code>f(n)</code> 的时间，而是表示其<strong>增长速率</strong>不会超过 <code>f(n)</code> 的常数倍，从而为我们提供了一个衡量算法效率的标尺。</p>
<p>也就是说，算法执行时间实际上受到 <code>f(n)</code>[^2]的限制。</p>
<p>除了<strong>大 O 表示法</strong>，还有其他的符号能描述算法的好坏。</p>
<p>我们常用的通常有这三个符号：</p>
<ul>
<li>
<p>Ο：表示上界。描述的是算法运行时间<strong>最坏情况</strong>的表现。</p>
</li>
<li>
<p>Ω：表示下界。描述的是算法运行时间<strong>最好情况</strong>的表现。</p>
</li>
<li>
<p>Θ：既是上界也是下界，Θ 表示的是算法的运行时间有一个<strong>确定的增长级别</strong>。也就是说，如果某个算法的时间复杂度是Θ(f(n))，那么它的运行时间最终会被 <code>c1 * f(n)</code> 和 <code>c2 * f(n)</code> 限制在中间（其中c1、c2是常数）。它<strong>不是等于</strong>，而是<strong>增长速率与f(n)相同</strong>。</p>
</li>
</ul>
<p>Ο 是渐进上界，Ω 是渐进下界。Θ 需同时满足大 Ο 和 Ω，所以称为确界。Ο 是复杂度分析中最常用的，因为它表示了最差效率。</p>
<p>中译中就是，Ο 告诉我们“算法再慢不会慢于这个程度”，Ω 告诉我们“算法再快不会快于这个程度”，而 Θ 则告诉我们“算法的速度基本就在这个范围内”。</p>
<p>例如，对于<strong>滑动窗口</strong>算法：</p>
<ul>
<li>最好情况：<strong>Ω(1)</strong>（第一个元素就满足条件）</li>
<li>最坏情况：<strong>O(n)</strong></li>
<li>平均情况：<strong>O(n)</strong></li>
<li>它<strong>不能用Θ</strong>来描述，因为它的最好和最坏情况不在同一个增长级别上。</li>
</ul>
<p>而对于<strong>快速排序</strong>：</p>
<ul>
<li><strong>最坏情况</strong>（例如数组已排序或逆序，这会导致分区严重不均衡）：<strong>O(n&lt;sup&gt;2&lt;/sup&gt;)</strong></li>
<li><strong>最好情况</strong>（划分完全均衡）：<strong>Ω(n log n)</strong>。</li>
<li><strong>平均情况</strong>： <strong>Θ(n log n)</strong>。</li>
<li><strong>尽管存在最坏的 O(n²) 情况，但通过随机选取基准值，容易证明，快速排序的平均（期望）时间复杂度是 Θ(n log n)。</strong>[^3]因为在随机化后，每一种分区虽然都有可能，但<strong>导致极差性能的划分情况出现的概率非常低</strong>，而<strong>导致良好性能的划分情况占据了主导</strong>，使得整体平均性能足够优秀。</li>
</ul>
<p>对于时间复杂度分析，我们关心的是输入规模 <code>n</code> 趋于无穷大时的趋势，而非具体的执行时间。通过渐进分析，我们可以抽象掉硬件、常数项等细节，直接比较算法的本质效率。</p>
<p>可以通过下面这张图来比较复杂度的较大值，越靠右效率越差。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/pictures/878897ba38c4d77c8a2be72374ddcc68.png" alt="复杂度比较" /></p>
<hr />
<p>复杂度是用来<strong>衡量程序好坏</strong>的概念，分为时间复杂度和空间复杂度。时间复杂度能描述算法执行所需的大致时间，而空间复杂度描述算法运行时所需的内存空间。复杂度是用来评估算法效率的主要指标。一些算法因为复杂度表现非常优异，我们直到现在还在使用。例如二分查找（O(log n)），迪杰斯特拉最短路径算法（O(n&lt;sup&gt;2&lt;/sup&gt;)）[^4]。</p>
<p>下一期 #4 将会开始经典算法部分，第一期就是双指针以及它的变形。</p>
<p>🎉撒花🎉</p>
<p>[^1]: 大 O 表示法通常表示的是最坏情况下的时间复杂度，而对于平均情况和最好情况则不能明确体现。
[^2]: f(n) 可替换成下面<em>复杂度比较</em>一图中大 O 括号内的内容。
[^3]: 参考阅读：<a href="https://zhuanlan.zhihu.com/p/330669300">数学期望值</a>。
[^4]: 事实上如果采用<strong>优先队列</strong>（priority queue），可以优化到 O((E+V)logV)。</p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2025-09-14T09:38:11.035Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[程序设计#3%1 - 面向对象程序设计]]></title>
        <id>https://re.karlbaey.top/articles/program-design-object-oriented-programming-episode-three-p-one/</id>
        <link href="https://re.karlbaey.top/articles/program-design-object-oriented-programming-episode-three-p-one/"/>
        <updated>2025-09-06T17:33:08.502Z</updated>
        <summary type="html"><![CDATA[本篇教程不是必须的，我们写的算法题一般用不上面向对象程序设计（除了少数实现数据结构的题目）。但是因为非常重要，它作为程序设计的一条支线发布...]]></summary>
        <content type="html"><![CDATA[<p>本篇教程不是必须的，我们写的算法题一般用不上面向对象程序设计（除了少数实现数据结构的题目）。但是因为非常重要，它作为程序设计的一条支线发布。面向对象程序设计是<a href="https://karlbaey.top/articles/program-design-struct-and-data-structures-for-newbie-episode-three/">程序设计#3 - 结构体和数据结构入门</a>的支线。</p>
<p>摘要：<strong>面向对象程序设计</strong>是基于<strong>对象</strong>的编程。对象是对现实世界事物的<strong>抽象表示</strong>，并将具有相同特征和行为的事物归类为<strong>类</strong>。Go 语言通过<strong>结构体</strong>、<strong>方法</strong>和<strong>接口</strong>来实现面向对象编程的特性。</p>
<p>本文通过汽车的例子来减小理解概念的困难。为了避免打断理解，完整的代码示例被移动到了额外部分。</p>
<h2>从过程到对象</h2>
<p>我们在 <a href="https://karlbaey.top/articles/program-design-sequence-for-newbies-and-buffering-io-episode-two/">#2</a> 以及 #2 之前的教程中设计的程序都有一个<strong>明确的过程</strong>。比如，题目中给了一个数组，我们应该对这个数组做些操作才能符合样例的要求。这些操作通过<strong>变量赋值</strong>和<strong>流程控制</strong>实现。因为我们希望<strong>实现具体的目标</strong>，所以就一定要依赖一系列步骤。像这样强调过程的程序设计，我们叫做面向过程程序设计（POP，procedure oriented programming）。面向过程的程序只要程序结束了，事情就完成了。</p>
<p>面向过程的程序优点在于，这样写的程序通常<strong>直观高效</strong>。例如我们发现哪一步出问题了，只需要在出错的地方修改就行。</p>
<p>但代码量堆叠起来，堆到了几十万行，那面向过程程序是很麻烦的，因为出一点问题就可能导致完整的项目要大修。我们希望有一个办法，把程序的每个操作改成某个<strong>对象</strong>（object，它是本条支线的主角）的动作。这样，我们在操作程序时就只需要让对象做出指定的动作就行了。</p>
<p>我们以造汽车和开车为例，先看看用面向过程的思想<a href="%E5%87%86%E7%A1%AE%E5%9C%B0%E8%AF%B4%EF%BC%8C%E9%9D%A2%E5%90%91%E8%BF%87%E7%A8%8B%E6%98%AF%E4%B8%80%E7%A7%8D%E7%BC%96%E7%A8%8B%E7%9A%84**%E8%8C%83%E5%BC%8F**%EF%BC%88paradigm%EF%BC%89%E3%80%82%E4%B9%9F%E5%B0%B1%E6%98%AF%E4%B8%80%E7%A7%8D%E5%86%99%E4%BB%A3%E7%A0%81%E7%9A%84%E5%85%B8%E5%9E%8B%E3%80%82%E7%94%A8%E6%9B%B4%E5%89%8D%E5%8D%AB%E7%9A%84%E8%AF%AD%E8%A8%80%E6%9D%A5%E8%AF%B4%EF%BC%8C%E8%8C%83%E5%BC%8F%E5%B0%B1%E6%98%AF%E6%96%B9%E6%B3%95%E8%AE%BA%E3%80%82">^1</a>解决这个问题会是怎样。</p>
<ol>
<li>造引擎
<ol>
<li>制造外壳</li>
<li>安装螺丝 A B C D</li>
<li>安装活塞……</li>
</ol>
</li>
<li>造车身
<ol>
<li>焊接钢板 W X Y Z</li>
<li>喷涂喷漆……</li>
</ol>
</li>
<li>开车
<ol>
<li>拧钥匙（方向盘右侧）</li>
<li>检查油压
<ol>
<li>如果油压足够，那么启动车辆成功。否则失败。</li>
</ol>
</li>
<li>踩离合器</li>
<li>挂挡……</li>
</ol>
</li>
</ol>
<p>这样看来，面向过程的程序确实很直观，因为它把所有可能的步骤都写进去了。</p>
<p>但有个问题，如果不想开手动挡汽车，换成了自动档汽车，那这个过程就需要几乎完全重写。因为踩离合器，挂档的逻辑完全变了。我们把这种<strong>各组件之间依赖性特别强</strong>的特点叫做<strong>高度耦合</strong>。</p>
<p>如果我们发现引擎有一枚螺丝需要换型号，那我们就要从所有的汽车中找到这枚螺丝，在这个过程中非常容易出现缺漏和出错。如果要加上新的活塞，也有这种隐患。这就是面向过程的第二个问题，<strong>难以维护和扩展</strong>。</p>
<p>而且更关键的是，程序员必须百分百了解汽车从生产到实际使用的过程以及所有的数据，因为这部车的所有内容都暴露在外，万一被破坏者修改了数据，就会导致不可逆的错误，程序员必须及时改正。这种风险大大加重了程序员的负担。</p>
<p>接下来换一个方法，我们用<strong>面向对象程序设计</strong>（OOP，object oriented programming）的思想来试着解决上面的问题。</p>
<p>需要注意的是，对象是具体的个体，也就是上文中具体的车。车的蓝图叫做<strong>类</strong>（class）。[^2]</p>
<p>相应地，把类变成具体的对象，这个过程叫做<strong>实例化</strong>。</p>
<ol>
<li>创建类（画蓝图）
<ul>
<li><code>type Engine struct {isMotivated bool ...}</code> 接着写启动的方法 <code>func (e Engine) Start() {}</code>（引擎）</li>
<li><code>type Transmission struct {speedRatio float64}</code>（变速箱）</li>
<li><code>type Doors [4]bool</code> （汽车门）</li>
<li><code>type Car {Engine Engine; Transmission Trasmission;}</code>（汽车蓝图，结合了引擎、变速箱等）</li>
</ul>
</li>
<li>协作和封装
<ul>
<li>我们定义了引擎的蓝图之后，只需要告诉引擎 <code>Engine.Start()</code> 就能够让引擎自己发动，而不用我们知道启动原理。因为启动的方式已经在引擎的结构体中写好了，采用哪一种启动方式（燃油喷射或者电机启动）是引擎自己的事情。我们发现，这种操作将具体的操作细节隐藏起来，而只需要执行对象预先设置好的接口，这种方式叫做<strong>封装</strong>（encapsulation）。</li>
<li>汽车对象“有”一个变速箱。当我们希望驾驶汽车时，汽车对象内部会自动协调变速箱等元件，让它能够适应实际驾驶的需要。比如，自动档汽车和手动挡汽车的变速箱运行逻辑不同，但这都能够交给汽车自己协调，我们驾驶汽车的指令不需要改变。不同的汽车运行逻辑不一致，但实际驾驶时都用到了 <code>Car.Drive()</code> 这样的指令。同样的指令却能适应不同的场景，这就是面向对象的<strong>多态</strong>（polymorphism）。</li>
</ul>
</li>
<li>继承和扩展
<ul>
<li>我们上面写的是燃油车的蓝图，现在我们需要电动车的蓝图。因为它们都是车，就没有必要重写了。我们只要将燃油车的蓝图复制一份给电动车，然后再做修改就行。复制过程就是面向对象的<strong>继承</strong>（inheritance）。[^3]</li>
<li>在复制好蓝图后（继承后），我们只要给电动车蓝图加上电池<em>类</em>和通电自检<em>方法</em>即可。</li>
<li>依照上面说的，电动车继承自车，这叫做重用（reuse）。</li>
<li><strong>这一过程不需要我们触碰原来的蓝图</strong>，这是很安全的。</li>
</ul>
</li>
</ol>
<p>面向对象解决了上面的几个痛点。</p>
<p><strong>封装隐藏了复杂性，而只用操作简单的接口</strong>。这与面向过程相比具有很大的优越性，我们只要会踩油门，会转方向盘就行，车里的零件怎么运转我们不用管。这极大地拉低了使用和理解的门槛。</p>
<p><strong>继承实现了代码的高效率复用和扩展</strong>。已经写好的对象可以直接复用通用的代码，我们就能专注在写有差别的部分。复制模板提高了开发的效率。</p>
<p><strong>多态提高了代码的灵活性和可维护性</strong>。即使燃油车和电动车的启动逻辑不同，它们也能根据自己的特点做出不同反应。日后增加新的车型时，我们<strong>几乎不用修改驾驶<em>方法</em>的代码</strong>。</p>
<p><strong>封装、继承和多态是面向对象程序设计的三要素</strong>。</p>
<p>面向对象是很有利于团队协作的。团队中的每个人负责一个具体的类，这样能减少冲突，而只要在最后把所有的类搭起来就好。</p>
<p>在这一部分的最后，我们需要给面向过程和面向对象下一个公平的结论。</p>
<ul>
<li>面向过程最适合完成小而简单的任务。因为它的脉络是很明确的。</li>
<li>面向对象适合完成大的任务。举个例子，在之后我们手写大型数据结构（比如红黑树）时，面向对象的模块化就很适合使用。</li>
</ul>
<p>大部分的现代语言都是面向对象的，例如 Go、C++ 以及 Python（Python 中万物皆对象，任何可能的操作都是涉及对象的）。像 C、BASIC 和 Pascal，这些语言是为面向过程设计的。</p>
<p>其实在这之前我们已经见识过了面向对象编程的好处，那就是 <a href="https://karlbaey.top/articles/program-design-struct-and-data-structures-for-newbie-episode-three/#%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84---%E6%A0%88">#3 中的栈</a>。</p>
<blockquote>
<p>💡<strong>栈（Stack）是一种后进先出（LIFO，Last In First Out）的数据结构。</strong></p>
<p>距离栈的入口最近的元素，我们叫做<strong>栈顶元素</strong>。相应的，距离栈的入口最远的元素叫做栈底元素。</p>
</blockquote>
<p>我们把栈抽象成了一个类，要用时就将它实例化。</p>
<p>现在我们有了两把锋利的剑。一把叫做面向过程，它很轻，但是遇见大的怪物就束手无策；另外一把叫做面向对象，它比上一把剑重得多，但是在处理大怪物时效率奇高。同样，我们在实际的程序设计中，往往需要结合起它们的优点才能写出兼顾美观和性能的程序。</p>
<h2>额外部分</h2>
<h3>一台完整的车</h3>
<pre><code>package main

import (
    "fmt"
    "math/rand"
    "time"
)

// 启动器，用来发动汽车
type Starter interface {
    Start() bool
}

// 可驾驶的接口
type Drivable interface {
    Accelerate(speed float64) bool
    Brake() bool
    GetSpeed() float64
}

// 可加油的接口
type Fuelable interface {
    Refuel(amount float64) bool
    GetFuel() float64
}

// ====汽车基本组件====

type Engine struct {
    power     float64 // 全部都是私有属性（小写字母开头）
    isRunning bool
    fuelType  string
}

func (e *Engine) Start() bool {
    if e.isRunning {
        fmt.Println("引擎已启动")
        return false
    }

    fmt.Println("启动中")
    time.Sleep(1 * time.Second)

    // 打火失败
    if rand.Float32() &lt; 0.1 {
        fmt.Println("引擎启动失败了")
    }

    e.isRunning = true
    fmt.Println("引擎启动成功")
    return true
}

func (e *Engine) Stop() {
    if e.isRunning {
        e.isRunning = false
        fmt.Println("引擎已停止")
    }
}

func (e *Engine) IsRunning() bool {
    return e.isRunning
}

// 写一个油箱
type FuelTank struct {
    capacity  float64
    fuelLevel float64
}

// 实现油箱接口
func (f *FuelTank) Refuel(amount float64) bool {
    if amount &lt;= 0 {
        fmt.Println("无效油量")
        return false
    }

    newLevel := f.fuelLevel + amount
    if newLevel &gt; f.capacity {
        fmt.Printf("油箱溢出，当前容量为 %.1f L\n", f.capacity)
        f.fuelLevel = f.capacity
    } else {
        f.fuelLevel = newLevel
    }

    fmt.Printf("加入了 %.1f L 的燃油。当前油量：%.1fL\n", amount, f.fuelLevel)
    return true
}

func (f *FuelTank) Consume(amount float64) bool {
    if amount &lt;= 0 {
        return false
    }

    if f.fuelLevel &lt; amount {
        fmt.Println("燃油不足！")
        return false
    }

    f.fuelLevel -= amount
    return true
}

func (f *FuelTank) GetFuel() float64 {
    return f.fuelLevel
}

// 变速箱结构体
type Transmission struct {
    gearCount int // 全部档位数
    nowGear   int // 当前的档位
}

func (t *Transmission) ShiftUp() bool {
    if t.nowGear &lt; t.gearCount {
        t.nowGear++
        fmt.Printf("档位提高至 %d 档\n", t.nowGear)
        return true
    }

    fmt.Println("当前已是最高档位")
    return false
}

func (t *Transmission) ShiftDown() bool {
    if t.nowGear &gt; 0 {
        t.nowGear--
        fmt.Printf("档位减至 %d 档\n", t.nowGear)
        return true
    }

    fmt.Println("当前汽车未启动")
    return false
}

// 下面是具体的组件
// 也就是体现面向对象的多态

type GasolineEngine struct {
    Engine       // 组合我们写好的引擎结构体
    cylinder int // 汽缸数
}

// 重写 start 方法，因为多了一个引擎预热的操作
func (g *GasolineEngine) Start() bool {
    fmt.Println("预热引擎中……")
    time.Sleep(500 * time.Millisecond) // 预热五百毫秒（半秒）
    return g.Engine.Start()
}

// 电动马达
type ElectricMotor struct {
    Engine
    batteryCapacity float64
}

// 重写 start
func (e *ElectricMotor) Start() bool {
    fmt.Println("启动电动马达……")
    time.Sleep(200 * time.Millisecond) // 电动马达比汽油机启动更快

    e.isRunning = true
    if rand.Float32() &lt; 0.02 {
        fmt.Println("电池电压不足，启动失败")
        return false
    }

    e.isRunning = true
    fmt.Println("电动马达启动成功")
    return true
}

// 自动变速箱
type AutomaticTransmission struct {
    Transmission
}

func (a *AutomaticTransmission) ShiftUp() bool {
    fmt.Println("自动换档中")
    time.Sleep(300 * time.Millisecond)
    return a.Transmission.ShiftUp()
}

// 手动变速箱
type ManualTransmission struct {
    Transmission
}

func (m *ManualTransmission) ShiftUp() bool {
    fmt.Println("手动加速中……离合器正在发力")
    time.Sleep(500 * time.Millisecond)
    return m.Transmission.ShiftUp()
}

// 汽车主体
type Car struct {
    model        string
    speed        float64
    engine       Starter // 调用了接口作为类型，以实现多态
    fuelTank     Fuelable
    transmission interface {
        ShiftUp() bool
        ShiftDown() bool
    }
    bodyStyle string
}

// 实现 Drivable 接口
func (c *Car) Accelerate(speed float64) bool {
    if !c.engine.Start() {
        fmt.Println("引擎未发动，无法加速")
        return false
    }

    // 检查油/电量
    if fuelable, ok := c.engine.(interface{ GetFuelLevel() float64 }); ok { // 类型断言
        if fuelable.GetFuelLevel() &lt;= 0 {
            fmt.Println("无法加速，燃料已耗尽")
            return false
        }
    }

    c.speed += speed
    fmt.Printf("加速至 %.1f km/h \n", c.speed)

    // 自动换档（简化逻辑）
    if c.transmission != nil &amp;&amp; speed &gt; 0 {
        if c.speed &gt; 30 {
            c.transmission.ShiftUp()
        }
    }

    return true
}

func (c *Car) Brake() bool {
    if c.speed &lt;= 0 {
        fmt.Println("汽车已停下")
        return false
    }

    c.speed -= 10
    if c.speed &lt; 0 {
        c.speed = 0
    }

    fmt.Printf("减速至 %.1f km/h \n", c.speed)

    // 根据速度自动降档
    if c.transmission != nil &amp;&amp; c.speed &lt; 20 {
        c.transmission.ShiftDown()
    }

    return true
}

func (c *Car) GetSpeed() float64 {
    return c.speed
}

func (c *Car) Refuel(amount float64) bool {
    return c.fuelTank.Refuel(amount)
}

func (c *Car) GetFuelLevel() float64 {
    return c.fuelTank.GetFuel()
}

// === 构造函数 ===

// 创建汽油车
func NewGasolineCar(model, bodyStyle string) *Car {
    engine := &amp;GasolineEngine{
        Engine: Engine{
            power:    150,
            fuelType: "gasoline",
        },
        cylinder: 4,
    }

    fuelTank := &amp;FuelTank{
        capacity:  50.0,
        fuelLevel: 10.0,
    }

    transmission := &amp;AutomaticTransmission{
        Transmission: Transmission{
            gearCount: 6,
        },
    }

    return &amp;Car{
        model:        model,
        engine:       engine,
        fuelTank:     fuelTank,
        transmission: transmission,
        bodyStyle:    bodyStyle,
    }
}

// 创建电动车
func NewElectricCar(model, bodyStyle string) *Car {
    engine := &amp;ElectricMotor{
        Engine: Engine{
            power:    200,
            fuelType: "electricity",
        },
        batteryCapacity: 75.0,
    }

    // 电动车的"油箱"实际上是电池
    fuelTank := &amp;FuelTank{
        capacity:  75.0,
        fuelLevel: 50.0,
    }

    // 电动车通常使用单速变速箱
    transmission := &amp;AutomaticTransmission{
        Transmission: Transmission{
            gearCount: 1,
        },
    }

    return &amp;Car{
        model:        model,
        engine:       engine,
        fuelTank:     fuelTank,
        transmission: transmission,
        bodyStyle:    bodyStyle,
    }
}

// ========== 主函数 ==========

func main() {
    rand.Seed(time.Now().UnixNano())

    fmt.Println("=== 汽油车演示 ===")
    gasCar := NewGasolineCar("Toyota Camry", "Sedan")
    fmt.Printf("初始油量: %.1f L\n", gasCar.GetFuelLevel())
    gasCar.Refuel(20.0)
    gasCar.Accelerate(50.0)
    gasCar.Accelerate(30.0)
    gasCar.Brake()
    gasCar.Brake()
    fmt.Println()

    fmt.Println("=== 电动车演示 ===")
    electricCar := NewElectricCar("Tesla Model 3", "Sedan")
    fmt.Printf("初始电量: %.1f %%\n", electricCar.GetFuelLevel()/0.75) // 转换为百分比
    electricCar.Accelerate(60.0)
    electricCar.Accelerate(40.0)
    electricCar.Brake()

    // 演示多态 - 统一处理不同类型的汽车
    fmt.Println("\n=== 多态演示 ===")
    cars := []Drivable{gasCar, electricCar}
    for i, car := range cars {
        fmt.Printf("汽车 %d 当前速度: %.1f km/h\n", i+1, car.GetSpeed())
    }
}

</code></pre>
<p>输出</p>
<pre><code>=== 汽油车演示 ===
初始油量: 10.0L
加入了 20.0 L 的燃油。当前油量：30.0L
预热引擎中……
启动中
引擎启动成功
加速至 50.0 km/h
自动换档中
档位提高至 1 档
预热引擎中……
引擎已启动
引擎未发动，无法加速
减速至 40.0 km/h
减速至 30.0 km/h

=== 电动车演示 ===
初始电量: 66.7%
启动电动马达……
电动马达启动成功
加速至 60.0 km/h
自动换档中
档位提高至 1 档
启动电动马达……
电动马达启动成功
加速至 100.0 km/h
自动换档中
当前已是最高档位
减速至 90.0 km/h

=== 多态演示 ===
汽车 1 当前速度: 30.0 km/h
汽车 2 当前速度: 90.0 km/h
</code></pre>
<p>这一段代码就很好地展示了面向对象程序设计的核心思想。</p>
<p><strong>封装</strong>：在上面的结构体中，所有的结构体字段都是私有（private）的（通过字段首字母小写实现）。</p>
<p>访问接口则通过公共方法提供。例如 <code>Engine</code> 的 <code>isRunning</code> 字段只能通过 <code>IsRunning()</code> 方法访问。</p>
<p><strong>多态</strong>：通过接口实现多态行为。<code>Starter</code>、<code>Drivable</code> 和 <code>Fuelable</code> 接口定义了通用行为，而具体的方法（<code>GasolineEngine</code> 和 <code>ElectricMotor</code>）提供特定实现。</p>
<p>也就是说，汽车可以统一处理不同类型的引擎和变速箱。</p>
<p><strong>组合/继承</strong>：Go 没有传统继承，我们在这里使用结构体嵌入（embedding）实现组合。可以重写父结构体的方法（如 <code>Start()</code> 方法）来实现代码复用和扩展。</p>
<hr />
<p>顺便说一句，如果使用 VS Code 编辑代码的话，把光标悬浮在结构体上就能看到我们写好的注释。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/pictures/image-20250907123559434.png" alt="变速箱结构体的基础信息" /></p>
<p>如果需要显示<strong>变速箱结构体</strong>那一行说明，只要在 <code>type Transmission struct {}</code> 上面紧贴着写注释就可以了。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/pictures/image-20250907123926153.png" alt="变速箱注释写法" /></p>
<p>[^2]: Go 语言没有类的概念，我们实现一个类往往通过<strong>结构体</strong>完成。
[^3]: Go 没有类似 Python 的继承。Go 实现继承是通过将新类组合旧类来实现的，请看下方额外部分。</p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2025-09-06T17:33:08.502Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[程序设计#3 - 结构体和数据结构入门]]></title>
        <id>https://re.karlbaey.top/articles/program-design-struct-and-data-structures-for-newbie-episode-three/</id>
        <link href="https://re.karlbaey.top/articles/program-design-struct-and-data-structures-for-newbie-episode-three/"/>
        <updated>2025-08-30T12:14:52.594Z</updated>
        <summary type="html"><![CDATA[摘要：本篇教程将会详细讲述 Go 结构体的使用方法，并且搭配上经典数据结构栈（stack）来帮助理解。与往期教程相同的是，这次依然会使用线上...]]></summary>
        <content type="html"><![CDATA[<p><strong>摘要</strong>：本篇教程将会详细讲述 Go 结构体的使用方法，并且搭配上经典数据结构<strong>栈</strong>（stack）来帮助理解。与往期教程相同的是，这次依然会使用线上题库的公开题。建议读者在学习结构体之后自己动手试一试，并且在<strong>函数</strong>部分体会一下使用<strong>值传参</strong>、<strong>指针传参</strong>以及<strong>引用传参</strong>的差异。这里的“传参”意思是<strong>把数据或变量传递给函数或方法进行处理的过程。</strong> 这种说法太抽象，不利于理解，请接着往下读。</p>
<p>此外，在<a href="#%E9%A2%9D%E5%A4%96%E9%83%A8%E5%88%86">额外部分</a>会解释一些关于计算机架构的问题，例如指针的空间占用为什么会是 4 字节或 8 字节。</p>
<h2>论结构体的诞生</h2>
<p>我们在之前的教程中用的都是<strong>整数、小数、字符串还有数组等等</strong>这样平凡的数据类型。但是如果需要操作的东西越来越多，数据类型也大相径庭，我们之前采用的数据类型就不够看了。当数据类型逐渐复杂，只能存一种数据类型的数组显然不够用。为了解决这种尴尬的局面，我们需要一个新数据类型：<strong>结构体</strong>。</p>
<p>💡<strong>结构体的定义方式是这样。</strong></p>
<pre><code>type StructureName struct {
    field1 type1
    field2 type2
    // 例如这样
    /*
    Name string
    */
    /* 以此类推 */
}
</code></pre>
<p>从定义方式我们就能看出来，结构体将不同的数据类型“缝合”（专业术语叫做“封装”）起来，成为一个<strong>数据构成的集合</strong>。它将多个不同类型、但逻辑上相关的数据捆绑在一起，形成一个更有意义的整体。</p>
<p>例如假设现在我们是一个网站的管理员，需要记录一名用户的身份信息。</p>
<p>如果我们仍然用多个变量来存的话，这对管理来说就是一场灾难。数组只能存相同类型的数据，所以把一名用户的全部信息存到一个数组里显然不现实。</p>
<p>如果真的要用不同的变量存同一用户的不同信息，实际文件中的代码会像这样。</p>
<pre><code>// 包名，引入包

func main() {
    userMyUsername := "aaaa"
    userMyID := 12345
    userMyEmail := "random@example.com"
    userMyPassword := "abcdefg"
    userMyVerified := false
    /* 如此循环往复 */
}
</code></pre>
<p>可以看到，仅仅是五条最基本的信息就要五个变量来存。如果像大论坛一样，动辄几十万人，那就要上百万条变量。</p>
<p>每次都这样想一个新变量名太让人头疼了。我们希望有一种新的数据类型，能够存放我们需要的全部用户数据。这种新数据类型叫做<strong>结构体</strong>。</p>
<p>💡如果使用结构体，代码可以这样写。</p>
<pre><code>package main

import "fmt"

type User struct { // 定义用来记录用户基本信息的结构体
    Username string
    Id       int
    Email    string
    Password string
    Verified bool
}
</code></pre>
<p>我们以 Karlbaey 的妹妹 Karlbaeu 来举个例子，我们可以把她在论坛中的基本信息用结构体 <code>User</code> 来存放，这样就避免了多次定义变量，也让代码整齐有序。</p>
<pre><code>func main() {
    var Karlbaeu = User{
        Username: "Karlbaeu",
        Id:       1024,
        Email:    "random@example.com",
        Password: "abcd1234",
        Verified: true}

    fmt.Printf(
        "Karlbaeu 的用户名是：%s\n"+ // 注意是 + 连接符
            "Karlbaeu 的用户 ID 是：%d\n"+
            "Karlbaeu 的注册邮箱是：%s\n"+
            "Karlbaeu 的密码是：%s\n"+
            "Karlbaeu 是否是已验证用户？%t\n",
        Karlbaeu.Username, Karlbaeu.Id, Karlbaeu.Email, Karlbaeu.Password, Karlbaeu.Verified,
    )
}
</code></pre>
<p>输出</p>
<pre><code>Karlbaeu 的用户名是：Karlbaeu
Karlbaeu 的用户 ID 是：1024
Karlbaeu 的注册邮箱是：random@example.com
Karlbaeu 的密码是：abcd1234
Karlbaeu 是否是已验证用户？true
</code></pre>
<p>如果需要修改某个特定的元素也很简单。例如，Karlbaeu 换邮箱了，现在把她的邮箱换成正活跃的邮箱。</p>
<p>要修改特定元素可以使用 <code>Structure.Field = xxx</code> 这样的语法。例如，在上面主函数的下方加两行新代码。</p>
<pre><code>Karlbaeu.Email = "activated@example.com"
fmt.Printf("Karlbaeu 的新邮箱是：%s\n", Karlbaeu.Email)
</code></pre>
<p>新增输出</p>
<pre><code>Karlbaeu 的新邮箱是：activated@example.com
</code></pre>
<p>当然也可以将整个结构体<strong>初始化为零值</strong>。<strong>一个结构体初始化为零值，就等于它包含的所有数据都被设置为默认值</strong>，例如 <code>bool</code> 类型将被设置为 <code>false</code>。</p>
<pre><code>func main() {
    var Karlbaeu User
        // 也可以使用
    // Karlbaeu := new(User)

    fmt.Println("Karlbaeu 的基本信息：", Karlbaeu)
}
</code></pre>
<p>使用 <code>var Karlbaeu User</code> 初始化的是一个<strong>值</strong>，<code>Karlbaeu := new(User)</code> 初始化的是一个<strong>指针</strong>。</p>
<p>输出</p>
<pre><code>Karlbaeu 的基本信息： { 0   false}
</code></pre>
<p>需要注意，定义好的<strong>结构体不能直接用</strong>，必须使用 <code>var</code> 或者 <code>:=</code> 来将结构体<strong>实例化</strong>（instantiate）。打个比方，结构体是一台汽车的设计蓝图，蓝图当然不能给人开，所以需要照着蓝图造一台车，已经造好的车才是能给人开的，这里的车就是<strong>实例</strong>（instance）。这个<strong>依照蓝图造车的过程就叫做实例化</strong>。<strong>实例化</strong>是<strong>面向对象程序设计</strong>的内容，在支线<strong>面向对象程序设计</strong><a href="%E6%AD%A3%E5%9C%A8%E7%A3%A8%E5%A2%A8%EF%BC%8C%E4%BC%9A%E5%8A%A0%E6%80%A5%E8%B5%B6%E5%87%BA%E6%9D%A5%E3%80%82%E9%9D%A2%E5%90%91%E5%AF%B9%E8%B1%A1%E7%A8%8B%E5%BA%8F%E8%AE%BE%E8%AE%A1%E6%98%AF%E9%9D%9E%E5%B8%B8%E5%85%B3%E9%94%AE%E7%9A%84%E6%A6%82%E5%BF%B5%EF%BC%8C%E5%87%A0%E4%B9%8E%E6%89%80%E6%9C%89%E7%BC%96%E7%A8%8B%E8%AF%AD%E8%A8%80%E9%83%BD%E7%94%A8%E5%BE%97%E5%88%B0%E3%80%82">^1</a>中再继续研究，这里只需要知道为什么这样做就行。</p>
<pre><code>package main

import "fmt"

type Car struct {
    Volume int // 单位是 L
    Weight int // 单位 kg
    Color  string
}

func main() {
    myCar := Car{Volume: 2574, Weight: 1200, Color: "White"}
    fmt.Printf("我的车的体积：%d L\n", myCar.Volume)
    fmt.Printf("我的车的质量：%d kg\n", myCar.Weight)
    fmt.Printf("我的车的颜色：%s\n", myCar.Color)
}
</code></pre>
<p>输出</p>
<pre><code>我的车的体积：2574 L
我的车的质量：1200 kg
我的车的颜色：White
</code></pre>
<p>⚠注意：如果不将 <code>Car</code> 实例化，直接输出 <code>Car</code>，就会编译错误。</p>
<pre><code># command-line-arguments
.\main.go:12:33: Car (type) is not an expression
</code></pre>
<p>意思是，<code>Car</code> 是一个类型而不是表达式。此时 <code>Car</code> 与 <code>int</code>、<code>string</code> 等类型是同一层级的。</p>
<h2>结构体的方法和函数</h2>
<p>定义了结构体之后，如果只是用它来存数据显然是不够用的。我们需要写几个与结构体有关的函数，这样结构体就有自己的<strong>行为</strong>了。函数的定义方式在上一篇教程就有提到，但是在这里，<strong>方法是与特定类型关联的函数，通过接收者（receiver）绑定到类型上</strong>，因此这里不再称作函数（function），而是称作<strong>方法</strong>。</p>
<p>→<a href="https://karlbaey.top/articles/program-design-sequence-for-newbies-and-buffering-io-episode-two/#%E5%A6%82%E4%BD%95%E5%86%99%E4%B8%80%E4%B8%AA%E5%87%BD%E6%95%B0">程序设计#2 - 序列入门和缓冲输入输出 - 💡如何写一个函数 | Karlblogs</a></p>
<p>例如在这里定义一个矩形。我们知道，只要确定了一个矩形的宽和高，这个矩形的面积就确定下来了。</p>
<p>那么可以实现一个计算矩形面积的<strong>方法</strong>，这个方法完全依赖于<strong>矩形</strong>这个结构体。</p>
<pre><code>package main

import "fmt"

type Rectangle struct {
    Width  int
    Height int
}

func (r Rectangle) Area() int {
    return r.Height * r.Width
}

func main() {
    var rect = Rectangle{Width: 10, Height: 20}

    fmt.Println(rect.Area())
}
</code></pre>
<p>在 <code>func (r Rectangle) Area() int</code> 这一行代码中</p>
<ul>
<li><code>func</code> 是定义函数的关键字，在这里用来定义<strong>方法</strong>。</li>
<li><code>(r Rectangle)</code> 的意思是，把这个方法作为结构体 <code>Rectangle</code> 的方法。<code>r</code> 表示<strong>参数名</strong>，它的类型就是 <code>Rectangle</code>。</li>
<li><code>Area()</code> 是<strong>方法名</strong>，可在程序的其他地方用 <code>InstanceRectangle.Area()</code> 调用这个方法。</li>
<li><code>int</code> 表示<strong>返回值</strong>的类型。</li>
</ul>
<p>⚠这里，<code>Area()</code> 是结构体 <code>Rectangle</code> 的方法。</p>
<p>方法也可以用指针传入，这样能够节省内存。</p>
<p>如果我们把 <code>Area()</code> 改写成一个输入参数是 <code>Rectangle</code> 的函数，就会像这样。</p>
<pre><code>func Area(r Rectangle) int {
    return r.Height * r.Width
}
</code></pre>
<p>结构体如果首字母大写，就说明这个结构体是<strong>可导出的</strong>，意思是你可以在这个程序外使用这个结构体。如果首字母小写，就不能在程序外使用或者修改结构体。结构体里的字段（例如上文 <code>Rectangle</code> 的 <code>Width</code> 和 <code>Height</code>）、结构体的方法（例如 <code>Rectangle.Area()</code>）、程序内写好的函数以及用 <code>const</code> 关键字定义的常量，都是这样。</p>
<h3>函数的三种传参</h3>
<p>首先我们需要知道<strong>函数传参</strong>是怎么一回事。它就是字面意思，<strong>把参数传递给函数</strong>。但是函数传参的方式很多。</p>
<p>首先，<strong>Go 语言的函数只有一种参数传递：值传递</strong>[^2]（pass by value）。</p>
<p>意思就是说，当我们把变量或指针<strong>作为参数</strong>传递给函数的时候，其实是把参数复制了一份，再让函数进行操作。</p>
<p>也就是说，像 C++ 的三种传参（值、指针和引用），在 Go 里面统统没有。因为：</p>
<ul>
<li>如果是传入基本类型（<code>int</code> 以及 <code>string</code> 等），函数会复制一份实际数据。</li>
<li>如果是传入引用类型（例如之前说过的切片），函数会复制一份这个引用类型的内存地址。</li>
<li>如果是传入指针，函数会复制一个指针，而不是这个指针指向的数据。</li>
</ul>
<p>我们也可以把这一篇教程所说的结构体作为参数传递给函数。</p>
<p>但是，如果结构体非常大，已经占用了好几个兆字节，那么不建议把结构体作为值，而应该创建一个指向<strong>结构体实例</strong>的指针，直接操作指针就行。这能够大大减少程序运行时占用的内存，因为一个指针通常只有 4 字节或 8 字节，<a href="#%E6%8C%87%E9%92%88%E5%8D%A0%E7%94%A8%E5%86%85%E5%AD%98%E4%BB%A5%E5%8F%8A-cpu-%E6%98%AF%E6%80%8E%E4%B9%88%E6%93%8D%E4%BD%9C%E5%86%85%E5%AD%98%E7%9A%84">原因在后面解释</a>，远远比一个大型结构体实例占用的内存少。不要忘了，如果向函数传入结构体，那就相当于复制了一个与原来一样大的结构体。</p>
<p>虽然从技术层面（当前我们离这个层面<em>特别特别远</em>）来说，函数只有值传参。但我们<strong>完全可以通过操作值，使它们产生与引用传参和指针传参相同的效果</strong>。</p>
<p>上一句话比较拗口，具体的例子请看下面。</p>
<h4>值传参</h4>
<p>这种传参适用于值比较小，而且不需要修改值本身的函数。</p>
<p>比如，我们希望实现一个函数，让它能够运算输出两个整数的和。</p>
<p>非常简单，代码实现像这样。</p>
<pre><code>func AddInt(x, y int) int {
    return x + y
}

func main() {
    x := 2
    y := 3
    fmt.Printf("x + y == %d\n", AddInt(x, y))
}
</code></pre>
<p>输出</p>
<pre><code>x + y == 5
</code></pre>
<p>在这个过程里，主函数中的 <code>x</code> 和 <code>y</code> 都没有被改变，<code>AddInt()</code> 只是复制了一份 <code>x</code> 和 <code>y</code> 的副本给函数进行计算。</p>
<p>值传参是最安全的，在函数内对值的任何修改都不会影响到函数外。</p>
<h4>指针传参</h4>
<p><strong>指针传参一定要优先检查空指针。</strong> 如果操作空指针就会导致 <code>panic</code>。</p>
<pre><code>package main

import "fmt"

func NilInt(a *int) { // 测试空指针
    *a = 10 // 给空指针赋值，会导致 panic
    fmt.Println(a)
}

func main() {
    var a *int // 空指针
    NilInt(a)
}
</code></pre>
<p>输出</p>
<pre><code>panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xc0000005 code=0x1 addr=0x0 pc=0x5aa90e]

goroutine 1 [running]:
main.NilInt(0xc0000021c0?)
        E:/DRAFTBOX/Go/main.go:6 +0xe
main.main()
        E:/DRAFTBOX/Go/main.go:12 +0x15
</code></pre>
<p>解决办法就是在函数开头加一个判断空指针的逻辑。</p>
<pre><code>func NilInt(a *int) {
    if a == nil { // 判断空指针
        fmt.Println("a is a nil pointer.")
        return
    }
    *a = 10
    fmt.Println(a)
}
</code></pre>
<p>输出</p>
<pre><code>a is a nil pointer.
</code></pre>
<p>因为下面涉及的指针传参都不是空指针，所以判断空指针的逻辑都会省略。</p>
<p>如果我们需要把两个数 <code>a</code> 和 <code>b</code> 的值调换一下，需要经过三步操作。</p>
<ol>
<li>将 <code>a</code> 的值赋给临时变量 <code>temp</code>（temporary variable，<strong>临时变量</strong>的头四个字母）。这是用 <code>temp</code> 暂存 <code>a</code> 的值。</li>
<li>将 <code>b</code> 的值赋给 <code>a</code>。</li>
<li>将 <code>temp</code> 的值赋给 <code>b</code>。</li>
</ol>
<p>如果我们仍然用上一节<strong>值传参</strong>的方法，会导致什么都没有发生。</p>
<pre><code>func SwapInt(a, b int) { // 我们不需要输出值，因为整个函数都不涉及 a 和 b 以外的值
    temp := a
    a = b
    b = temp
}

func main() {
    a := 2
    b := 3

    SwapInt(a, b)
    fmt.Printf("a == %d\nb == %d\n", a, b) // 预期：a == 3 b == 2
}
</code></pre>
<p>输出</p>
<pre><code>a == 2
b == 3
</code></pre>
<p>出现这种情况的原因是 <code>SwapInt()</code> 操作的是副本，而不是我们在主函数里定义好的 <code>a</code> 和 <code>b</code>。</p>
<p>解决这个问题的办法就是使用指针，把内存地址对应的数字交换即可。</p>
<pre><code>func SwapInt(a, b *int) {
    temp := *a // 把 a（内存地址）对应的值赋给 temp
    *a = *b    // 把 b 对应的值赋给 a 对应的值
    *b = temp  // 把 temp 的值赋给 b 对应的值
}

func main() {
    a := 2
    b := 3

    SwapInt(&amp;a, &amp;b)
    fmt.Printf("a == %d\nb == %d", a, b) // 正确输出
}
</code></pre>
<p>输出</p>
<pre><code>a == 3
b == 2
</code></pre>
<p>我们把 <code>SwapInt()</code> 函数改为传入指针。这时候，我们复制的就是<strong>指针的副本</strong>了。</p>
<p>这时候 <code>a</code> 的指针 <code>&amp;a</code> 记录了 <code>a</code> 的值，暂时先把这个值记录给 <code>temp</code>。</p>
<p>然后继续照着我们开始的步骤，到最后 <code>&amp;a</code> 指向的是 <code>b</code> 的值，<code>&amp;b</code> 指向的是 <code>a</code> 的值。这就是我们想要的。</p>
<p>所以即使在 <code>SwapInt()</code> 执行结束，销毁了临时的指针副本后，我们依然成功交换了 <code>a</code> 和 <code>b</code> 的值。</p>
<p>我们可以打个比方。</p>
<p>现在 Karlbaeu 穿着一件夹克衫，左右边口袋分别放着一张 10 元钞票和一张 20 元钞票。现在 Karlbaeu 想要把左右边口袋里的钞票交换一下，但是她的每个夹克口袋最多只能放一张钞票。</p>
<p>Karlbaeu 首先试试把两张钞票复印了一份[^3]，然后按照上面所说的步骤交换了两张钞票的复印件。但是这些复印件很神奇，在交换完之后会自动销毁。Karlbaeu 只能把两张钞票放回原位。</p>
<p>这就是我们运用值传递的弊端，我们操作的始终是一份副本（也就是上面说的“复印件”），而且副本操作完后还会自动销毁。真正的值从来没有变过。</p>
<p>Karlbaeu 发现这样既麻烦又没用，所以她想了一个好方法，就是把两个口袋的钞票的位置分别用一张小纸条记录下来。她写了两张纸条：“左口袋里的是 10 元”，“右口袋里的是 20 元”（也就是上面输入的两个指针 <code>&amp;a</code> 和 <code>&amp;b</code>）。</p>
<p>现在 Karlbaeu 按照第一张纸条找到了 10 元钞票，把它暂时地放在桌面上（存放在临时变量里），然后把 20 元钞票放进已经清空的左口袋，最后把桌面上的 10 元钞票放进右口袋。</p>
<p>这样就完成了交换的全过程。两条记录钞票位置的纸条在操作完后自动销毁了，但我们不关心这些，因为钞票确实完成了交换。</p>
<p>这样传参能成功的原因是，<strong>即使我们操作的是指针的副本，指针对应的值也是不会变的，而我们的目的是操作指针对应的值，所以借助指针完成这个操作即可。</strong></p>
<p>其实要交换值还有一种更简洁的写法。</p>
<pre><code>a, b = b, a
</code></pre>
<p>指针传参还有一种用法，就是用在大型结构体的时候。</p>
<pre><code>package main

import "fmt"

type Rectangle struct {
    Width  int
    Height int
}

func SwapSides(r *Rectangle) { // 交换矩形的宽和高
    temp := r.Width // 自动解引用
    r.Width = r.Height
    r.Height = temp
}

func main() {
    var rect = Rectangle{Width: 10, Height: 20} // 宽 10 高 20

    fmt.Printf("宽：%d\n高：%d\n", rect.Width, rect.Height)
    SwapSides(&amp;rect)
    fmt.Printf("宽：%d\n高：%d\n", rect.Width, rect.Height)
}
</code></pre>
<p>输出</p>
<pre><code>宽：10
高：20
宽：20
高：10
</code></pre>
<p>它确实地交换了矩形的宽和高，原理之前已经说过了。指针传参也像前文所说能够节省内存。</p>
<p>但是我们看一看 <code>SwapSides()</code> 这个函数，它输入的是一个指向矩形结构体实例的指针，但是在处理中我们没有用 <code>(*r).Width</code> 来解引用。</p>
<p>这是因为 Go 非常聪明，当我们访问结构体指针的字段时，它会<strong>自动解引用</strong>。换句话说，<code>SwapSides()</code> 也可以用这种写法。</p>
<pre><code>func SwapSides(r *Rectangle) {
    temp := (*r).Width
    (*r).Width = (*r).Height
    (*r).Height = temp
}
</code></pre>
<p>它们是完全等价的。</p>
<h4>引用类型传参</h4>
<p>我们以传递切片（切片是引用类型）为例。</p>
<p>事实上我们已经在之前的 <a href="https://karlbaey.top/articles/program-design-sequence-for-newbies-and-buffering-io-episode-two/#%E5%AD%97%E7%AC%A6%E4%B8%B2%E6%8B%BC%E6%8E%A5">程序设计#2 - 序列入门和缓冲输入输出 | Karlblogs</a> LeetCode 344 中接触了引用传参。</p>
<p>我们写一个主函数试着调用这个它，并且我们把原题中的 <code>[]byte</code> 改为 <code>[]int</code>。</p>
<pre><code>func ReverseSlice(s []int) {
    l := 0
    r := len(s) - 1

    for l &lt; r {
        s[l], s[r] = s[r], s[l] // a, b = b, a 表示交换 a 和 b 的值
        l++
        r--
    }
}
</code></pre>
<p>主函数这样写。</p>
<pre><code>func main() {
    a := []int{1, 2, 3, 4, 5, 6, 7}
    ReverseSlice(a)
    fmt.Println(a)
}
</code></pre>
<p>输出</p>
<pre><code>[7 6 5 4 3 2 1]
</code></pre>
<p>按照 Go 的值传递，传递切片进入函数时，应该复制一份新切片才对。这样的话操作不会影响到原来的切片。但它确实把原切片给修改了。</p>
<p>原因是，**即使函数复制了一份原切片，原切片和函数内切片的副本仍然是共享底层数组的。**这意味着即使函数内外的数据不同，它们的底层也是一样的。</p>
<p>切片复制时，实际上是复制一份切片头。</p>
<p>切片头包括三个要素：指针（Ptr）、长度（Len）和容量（Cap）。</p>
<ul>
<li>指针指向切片的头元素。</li>
<li>长度表示当前切片中的元素数量。</li>
<li>容量表示当前切片最多容纳元素的数量。</li>
</ul>
<p>其中容量可以使用函数 <code>cap()</code> 来获取。通常来说，一个切片的容量就等于切片长度。</p>
<pre><code>package main

import "fmt"

func main() {
    a := []int{1, 2, 3, 4, 5, 6, 7}
    fmt.Println(cap(a))
}
</code></pre>
<p>输出</p>
<pre><code>7
</code></pre>
<p>我们也可以用 <code>make()</code> 函数来自定义切片的容量。</p>
<pre><code>package main

import "fmt"

func main() {
       a := make([]int, 7, 10)
    fmt.Println(cap(a))
    fmt.Println(a)
}
</code></pre>
<p>输出</p>
<pre><code>10
[0 0 0 0 0 0 0]
</code></pre>
<p>如果是从数组里截取出的切片，那切片的容量就是从截取的头到原数组的尾。</p>
<pre><code>package main

import "fmt"

func main() {
    b := [8]int{9, 8, 7, 6, 5, 4, 3, 2}
    c := b[4:6]
    fmt.Printf("c 的值：%v\n", c)
    fmt.Printf("c 的容量：%d\n", cap(c))
}
</code></pre>
<p>输出</p>
<pre><code>c 的值：[5 4]
c 的容量：4
</code></pre>
<p>切片 <code>c</code> 的长度是 2，但是容量是 4。这是因为 <code>c</code> 实际容量是通过数组长度减去起始索引得来的，在上面的代码中就是 <code>len(b) - 4 == cap(c)</code>。</p>
<p>引用传参可能会因为 <code>append()</code> 触发超出容量。</p>
<pre><code>package main

import "fmt"

func ModSlice(t []int) {
    t[0] = 6512345
}

func main() {
    b := []int{1, 3, 5, 7}
    ModSlice(b)
    fmt.Printf("b 的值是：%v", b)
}
</code></pre>
<p>输出</p>
<pre><code>b 的值是：[6512345 3 5 7]
</code></pre>
<p>现在没问题，但是我们在 <code>ModSlice()</code> 中加入一个 <code>append()</code> 函数，结果就会不同。</p>
<p>修改函数</p>
<pre><code>func ModSlice(t []int) {
    t[0] = 6512345
    t = append(t, 123333)
    t[2] = 101010
    fmt.Printf("函数内的 t：%v\n", t)
}
</code></pre>
<p>输出</p>
<pre><code>函数内的 t：[6512345 3 101010 7 123333]
b 的值是：[6512345 3 5 7]
</code></pre>
<p>这是因为原先 <code>b</code> 的容量只有 4，无法继续往后添加元素。如果需要添加元素，就必须分配一个新的切片。这体现在 <code>ModSlice()</code> 中，就是扩容切片，然后后续的操作都发生在这个新切片中。</p>
<p>这种<strong>操作无法应用到原切片上的现象</strong>，我们称作<strong>失联</strong>。</p>
<p>解决失联的方法有两种。</p>
<p>第一种是，在创建切片时就把容量设置得足够大，然后用指针传参。</p>
<pre><code>package main

import "fmt"

func ModSlice(t *[]int) {
    *t = append(*t, 10)
}

func main() {
    a := make([]int, 0, 10)
    ModSlice(&amp;a)
    fmt.Println(a)
}
</code></pre>
<p>但是要强调，足够大的容量不是必须的，但是这样可以防止反复扩容，优化性能。</p>
<p>第二种是，给函数设置一个返回值，这种操作非常直接，不多解释。</p>
<pre><code>package main

import "fmt"

func ModSlice(t []int) []int {
    t = append(t, 10)
    return t
}

func main() {
    a := make([]int, 0, 10)
    a = ModSlice(a)
    fmt.Println(a)

</code></pre>
<p>上面两种方法的输出</p>
<pre><code>[10]
</code></pre>
<h2>数据结构 - 栈</h2>
<p>💡<strong>栈（Stack）是一种后进先出（LIFO，Last In First Out）的数据结构。</strong></p>
<p>距离栈的入口最近的元素，我们叫做<strong>栈顶元素</strong>。相应的，距离栈的入口最远的元素叫做栈底元素。</p>
<p>我们画图解释这个过程。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/pictures/stack.gif" alt="stack" /></p>
<p>我们可以通过结构体来实现这一个数据结构。</p>
<p>因为栈可能非常非常大，所以我们调用方法时都会使用指针来节约内存。</p>
<p>我们以这个模板 <a href="https://www.luogu.com.cn/problem/B3614">B3614 【模板】栈 - 洛谷</a> 作为辅助。默认栈里面所有的元素都是整数。</p>
<p>先导入必要包。</p>
<pre><code>package main

import (
    "errors" // 错误包，用来处理空栈
    "fmt"
)
</code></pre>
<p>然后因为栈中的元素都是整数，所以我们直接用一个切片就行。</p>
<pre><code>type Stack struct {
    elements []int
}
</code></pre>
<p>当我们初始化一个栈时，通常调用 <code>NewStack()</code> 函数，它返回一个新栈的内存地址。以后我们初始化一个结构体时，全部使用 <code>NewXxx()</code> 这种格式的函数</p>
<pre><code>func NewStack() *Stack {
    return &amp;Stack{
        elements: make([]int, 0),
    }
}
</code></pre>
<p>现在我们需要实现栈的四个方法：<code>Push()</code>（将元素压入栈）、<code>Pop()</code> （将栈顶元素弹出栈）、<code>Query()</code>（也叫 <code>Peek()</code>，查看栈顶的元素）以及 <code>Size()</code>（输出栈的元素个数）。</p>
<p>先从 <code>Push()</code> 开始。我们使用 <code>append()</code> 函数实现。</p>
<pre><code>func (st *Stack) Push(elm int) {
    st.elements = append(st.elements, elm)
}
</code></pre>
<p>然后是弹出元素 <code>Pop()</code>。因为栈有可能是空的，所以我们应该先检查栈不是空的，再弹出。</p>
<pre><code>func (st *Stack) Pop() error { // 不考虑弹出的元素究竟是什么
    if len(st.elements) == 0 {
        return errors.New("Empty")
    }
    st.elements = st.elements[:len(st.elements)-1] // 使用切片弹出元素
    return nil
}
</code></pre>
<p>接着，实现 <code>Query()</code> 方法时，一样要先检查空栈。</p>
<pre><code>func (st *Stack) Query() (int, error) {
    if len(st.elements) == 0 {
        return 0, errors.New("Anguei!") // 栈里没有元素，默认返回 0，错误类型返回 Anguei!
    }
    return st.elements[len(st.elements)-1], nil
}
</code></pre>
<p><code>Size()</code> 方法只要简单地输出栈长度就可以了。</p>
<pre><code>func (s *Stack) Size() int {
    return len(s.elements)
}
</code></pre>
<p>到这里的话其实我们就可以着手写主函数了。但是考虑到有时候我们需要输出栈的全部元素，这时候我们需要使用 <code>fmt</code> 包的<strong>接口</strong>（interface，使用方法往下翻）<code>Stringer()</code> 来输出一个栈。</p>
<p>源码中的 <code>Stringer()</code> 是这样定义的。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/pictures/image-20250830170612578.png" alt="Stringer" /></p>
<p>我们将从栈底到栈顶依次输出。例如 <code>1 -&gt; 2 -&gt; 3</code>，箭头的末尾就是栈顶元素。</p>
<pre><code>func (st *Stack) String() string { // 不需要显式声明，只要有 String() string 就算实现了 Stringer 接口
    if len(st.elements) == 0 {
        return "Empty"
    }

    var res string
    for i, elm := range st.elements {
        if i &gt; 0 {
            res += " -&gt; "
        }
        res += fmt.Sprintf("%d", elm)
    }

    return res
}
</code></pre>
<p>这样的话，当我们调用 <code>fmt.Println()</code> 输出这个栈时，程序就会按照我们设置的规则输出。</p>
<p>在洛谷提交时我们用不到输出栈，所以这段代码是可选的。</p>
<h3>栈的优化</h3>
<p>现在我们开始写主函数。这里涉及高速读写，可前往 <a href="https://karlbaey.top/articles/program-design-sequence-for-newbies-and-buffering-io-episode-two/#%E9%AB%98%E9%80%9F%E8%AF%BB%E5%86%99">程序设计#2 - 序列入门和缓冲输入输出 - 高速读写 | Karlblogs</a> 阅读。</p>
<p>首先我们需要知道，这一道题用的是<strong>标准输入输出</strong>而不是<strong>核心代码</strong>，所以我们需要一种方法加速读写。</p>
<p>因为 <code>push</code> 后有空格以及一个数字，我们使用 <code>bufio.NewScanner()</code> 结合上 <code>scanner.Split(bufio.ScanWords)</code> 会比单纯使用自定义解析效率更高。因为 <code>bufio</code> 写的东西大多都已经压进了底层代码，这比我们手动写一个更快。</p>
<p>注意：因为原题中的 <code>x</code> 范围在 [0, 2&lt;sup&gt;64&lt;/sup&gt;)，我们需要把原来栈中的 <code>int</code> 类型改为 <code>uint64</code>[^4]，否则就会 <code>panic</code>。</p>
<p>考虑到每次扩容切片都是一笔不小的时间空间开销，我们首先要限定栈最大的容量。最坏情况下栈里要容纳 <code>n</code> 个元素，设置为 <code>n</code> 即可。</p>
<p>标准输出 <code>os.Stdout</code> 不会自带换行，我们在每次输出后都要自己写一次换行。</p>
<p>全部代码如下。有一些以前没有见过的函数在注释里有用法。</p>
<pre><code>package main

import (
    "bufio"
    "errors"
    "os"
    "strconv"
)

type Stack struct {
    elements []uint64 // 统统用 uint64 防止 panic
}

func NewStack(cap int) *Stack { // 通常来说 n 就足够了
    return &amp;Stack{elements: make([]uint64, 0, cap)}
}

func (st *Stack) Push(elm uint64) {
    st.elements = append(st.elements, elm)
}

func (st *Stack) Pop() error {
    if len(st.elements) == 0 {
        return errors.New("Empty")
    }
    st.elements = st.elements[:len(st.elements)-1]
    return nil
}

func (st *Stack) Query() (uint64, error) {
    if len(st.elements) == 0 {
        return 0, errors.New("Anguei!")
    }

    return st.elements[len(st.elements)-1], nil
}

func (st *Stack) Size() int {
    return len(st.elements)
}

func main() {
    scanner := bufio.NewScanner(os.Stdin) // 不使用 Reader 而是 Scanner，这样可以加速
    scanner.Split(bufio.ScanWords)

    writer := bufio.NewWriter(os.Stdout)
    defer writer.Flush()

    scanner.Scan()
    t, _ := strconv.Atoi(scanner.Text()) // Atoi 把字符串转换成整数
    // 它有两个返回值，第二个是错误类型。因为我们能保证不会报错，所以销毁即可
        scanner.Scan()
        n, _ := strconv.Atoi(scanner.Text())
        st := NewStack(n)
        for range n {
            scanner.Scan()
            opr := scanner.Text()
            switch opr {
            case "push":
                scanner.Scan()
                x, _ := strconv.ParseUint(scanner.Text(), 10, 64) // 十进制，64 位整数
                st.Push(x)
            case "pop":
                if err := st.Pop(); err != nil { // 先判断是否有错误
                    writer.WriteString("Empty\n")
                }
            case "query":
                if elm, err := st.Query(); err != nil {
                    writer.WriteString("Anguei!\n")
                } else {
                    writer.WriteString(strconv.FormatUint(elm, 10) + "\n") // 把 uint64 类型数字转换成 10 进制表示
                }
            case "size":
                writer.WriteString(strconv.Itoa(st.Size()) + "\n") // Itoa 把整数转换成字符串
            }
        }
    }
}
</code></pre>
<p>这样的代码可以 AC，贴一张参考用时和占用内存。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/pictures/image-20250830184935366.png" alt="AC 截图" /></p>
<p>如果我们使用上一篇教程的高速读写，第八个测试点会超时。</p>
<h3>接口的使用方法</h3>
<p>输出栈时我们运用了 <code>fmt</code> 包中的 <code>Stringer</code> 接口，它只有一个字段。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/pictures/image-20250830170612578.png" alt="Stringer" /></p>
<p>这意味着，我们让结构体实现了 <code>String()</code> 方法，这个方法的输出值类型是 <code>string</code>，就算实现了这个接口。</p>
<p>我们在 <code>String()</code> 方法中定义的输出方式会在 <code>fmt</code> 包中的输出函数自动调用。</p>
<p>比如我们定义一个新的结构体 <code>Series</code>，它就是一个数组。我们让它在输出时每两个元素之间夹一个连字符 <code>-</code>。</p>
<pre><code>package main

import (
    "fmt"
    "strconv"
)

type Series []int

func (s Series) String() string { // 调用 Stringer 接口
    var res string
    for i, s_i := range s {
        if i &gt; 0 {
            res += " - "
        }
        res += strconv.Itoa(s_i)
    }

    return res
}

func main() {
    s := Series{1, 3, 5, 7, 9}
    fmt.Println(s)
}
</code></pre>
<p>输出</p>
<pre><code>1 - 3 - 5 - 7 - 9
</code></pre>
<p><code>Stringer</code> 接口能决定一个结构体应该怎样用 <code>fmt</code> 包中的函数输出。</p>
<p>我们也可以自己写一个接口。接口通常以 <code>er</code> 或 <code>or</code> 结尾。</p>
<pre><code>package main

import "fmt"

type Rectangle struct { // 矩形结构体
    Height int
    Width  int
}

type RectCalculator interface { // 有关于矩形的接口
    Area() int      // 计算面积
    Perimeter() int // 计算周长
}

func (r Rectangle) Area() int {
    return r.Height * r.Width
}

func (r Rectangle) Perimeter() int {
    return 2 * (r.Height + r.Width)
}

func RectInfo(r RectCalculator) { // 此时这个函数期望一个 RectCalculator 接口
    // 我们可以直接输入 Rectangle 类型的数据
    // 因为我们已经为 Rectangle 实现好了 RectCalculator 的两个方法

    fmt.Printf("矩形的面积：%d\n矩形的周长：%d\n", r.Area(), r.Perimeter())
}

func main() {
    var rect = Rectangle{Height: 60, Width: 50}
    RectInfo(rect)
}
</code></pre>
<p>输出</p>
<pre><code>矩形的面积：3000
矩形的周长：220
</code></pre>
<p>Go 的接口实现是隐式的，意思是，我不需要给 <code>Rectangle</code> 结构体声明，我使用了 <code>RectCalculator</code> 这个接口。只要实现好了直接调用就行。</p>
<p>当一个接口什么都没有的时候，这时候我们管它叫做<strong>空接口</strong>。空接口没有任何方法，所有类型都实现了空接口。它可以表示任何类型的值。</p>
<pre><code>/* 代码块 */
type EmptyPrinter interface{}

// 也可以写
// type EmptyPrinter any

func Printa(v EmptyPrinter) {
    fmt.Println(v)
}

func main() {
    var rect = Rectangle{Height: 60, Width: 50}
    Printa(rect)
}
</code></pre>
<p>输出</p>
<pre><code>{60 50}
</code></pre>
<p><strong><code>any</code> 是空接口 <code>interface{}</code> 的别名。</strong></p>
<p>我们可以使用<strong>类型断言</strong>（type assertion）来判断接口值的具体类型。</p>
<pre><code>package main

import "fmt"

type Rectangle struct {
    Height int
    Width  int
}

type Square struct {
    Side int
}

type ShapeJudger interface{}

func Shape(v ShapeJudger) { // 开始类型断言
    if rect, ok := v.(Rectangle); ok { // 如果 rect 是 Rectangle 类型的数据，ok 才是 true，接着向下执行
        fmt.Printf("This is a Rectangle.\nWidth: %d\nHeight: %d\n", rect.Width, rect.Height)
    } else if sq, ok := v.(Square); ok {
        fmt.Printf("This is a Square.\nSide: %d\n", sq.Side)
    } else {
        fmt.Println("Unknown.")
    }
}

func main() {
    var rect = Rectangle{Height: 60, Width: 50}
    var sq = Square{Side: 100}
    a := 10

    Shape(rect)
    Shape(sq)
    Shape(a)
}
</code></pre>
<p>输出</p>
<pre><code>This is a Rectangle.
Width: 50
Height: 60
This is a Square.
Side: 100
Unknown.
</code></pre>
<h2>额外部分</h2>
<h3>指针占用内存（以及 CPU 是怎么操作内存的）</h3>
<p><em>下面的东西比较复杂，看不明白的话我再改。</em></p>
<p>首先，我们要明白，<strong>指针就是地址，地址就是指针</strong>。</p>
<p>一个指针，在 32 位机器上，占用 4 字节；在 64 位机器上，占用 8 字节。指针占用大小不是瞎说的，它跟中央处理器的运算方式有关。</p>
<p>这里的 xx 位表示中央处理器（CPU，Central Processing Unit，有时候简称<strong>处理器</strong>）通用寄存器[^5]的宽度，也就是<strong>数据总线宽度</strong>。拿一台 32 位机器来说，<strong>它的中央处理器一次性能够操作 32 个二进制数，也就是 32 个比特</strong>。</p>
<p>处理器通常不直接与硬盘交互，而是借内存作为中转。所以就需要给内存地址编码，方便处理器处理数据。</p>
<p>处理器与内存交互的模式可以抽象成下面这张图。（实际数据和索引都用十六进制表示）</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/pictures/CPU%26MEMORY.svg" alt="处理器与内存交互草图" /></p>
<p>例如，我们想在内存中读一个数字 <strong>9</strong>，处理器是这样做的。</p>
<ul>
<li>通过<strong>地址总线</strong>寻找 9 在内存中的位置。</li>
<li>通过<strong>控制总线</strong>决定操作是读还是写。</li>
<li>通过<strong>数据总线</strong>，把数字 9 传递给处理器。</li>
</ul>
<p>从上面的过程中我们能总结出三点。</p>
<ol>
<li><strong>地址总线直接决定处理器寻找内存地址的能力。</strong></li>
<li><strong>控制总线直接决定处理器的操作内存的能力。</strong></li>
<li><strong>数据总线直接决定内存和处理器之间的数据传输速度。也可以说它决定处理器一次最多处理的数据量。</strong></li>
</ol>
<p>我们以 32 位机器为例，它的处理器一次能够操作 32 个比特，那我们一次读取的整数最大也是占用 32 个比特（4 个字节）。这是数据总线决定的。</p>
<p>指针本质上是一个整数，这个整数记录了一个内存地址，只不过它平时显示为十六进制（也就是 <code>0x123456</code> 这种格式的数字）。</p>
<p>因为机器一次性最多读 4 字节的数据，如果一个指针的空间占用超过了 4 字节，就会导致浪费。 32 位机器无法一次读取指针后寻找内存地址。</p>
<p>32 位机器的地址总线通常一次处理 32 个比特，<strong>每个比特的值要么是 1，要么是 0</strong>。也就是说，机器的内存索引最大值是 2&lt;sup&gt;32&lt;/sup&gt;-1（内存索引从 0 开始），也就是最大控制 4 GiB[^6]的内存空间。[^7]</p>
<p>所以只要一个指针的空间占用限制在 4 个字节，就能让处理器按照指针找到所有的内存地址，也就能读取到所有在内存中的值。</p>
<p>在 64 位机器上同理，8 个字节就能访问所有的内存地址。</p>
<p>所以这就是为什么指针占用的字节数是固定的。<strong>指针的空间占用只和机器位数有关。</strong></p>
<p>另外，<strong>操作内存的最小单元是字节（byte）而不是比特（bit）</strong>，这是计算机底层架构决定的。不过我们通常不会只操作一个字节，而是多个字节一起组成更大的块（例如<strong>缓存行</strong>）一起处理，这样可以提高效率。</p>
<hr />
<p>💡总结一下</p>
<p>我们学到了结构体用法和结构体的实例化方式。并且我们接触了函数（方法）的三种传参。然后，我们借助结构体，实现了经典数据结构<strong>栈</strong>。额外部分里面涉及了指针大小和计算机架构（尽管讲得非常粗糙）。</p>
<p>下一篇不是主线 #4，而是暂时分开一条支线：#3-1 面向对象程序设计。</p>
<p>🎉撒花🎉</p>
<p>[^2]: 严格地说，Go 所有类型都是值类型。我们管切片之类的东西叫做“引用类型”，是因为<strong>操作切片给人的印象就是在操作一个引用自其他数据的类型</strong>。这是非常专业的说法，我们一般用不到。它不影响我们下文对于三种传参的理解。
[^3]: Karlbaeu 的钞票没有运用欧姆龙环防伪技术，她的打印机也不会拒绝复印有欧姆龙环的纸张。
[^4]: <code>uint64</code> 的意思是无符号 64 位整数（unsigned int64），它最小值是 0，最大值是 2&lt;sup&gt;64&lt;/sup&gt;-1。
[^5]: <strong>通用寄存器</strong>是中央处理器内部的一组高速存储单元，用于临时存放程序执行过程中的数据和指令。它们通常按功能分为多种类型，如数据寄存器、地址寄存器等，以提高计算和访问效率。它们的用途极其广泛，而且使用非常高频，因此得名通用寄存器。寄存器的位数（如8位、16位、32位或64位）会影响处理器单次能处理的数据量，进而影响程序执行效率，但不是唯一因素——处理器架构、指令集和内存速度等也会共同影响性能。现代计算机中，通用寄存器的位数通常与处理器的字长（也就是处理器的位数）一致（例如64位处理器配备64位寄存器）。
[^6]: 4GiB == 2&lt;sup&gt;2&lt;/sup&gt; GiB == 2&lt;sup&gt;12&lt;/sup&gt; MiB == 2&lt;sup&gt;22&lt;/sup&gt; KiB == 2&lt;sup&gt;32&lt;/sup&gt; 字节 == 2&lt;sup&gt;35&lt;/sup&gt; 比特
[^7]: 有一些方法（比如 PAE 技术）可以让机器能访问的内存超过 4 GiB 的限制，但这里不讨论。</p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2025-08-30T12:14:52.594Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[程序设计#2 - 序列入门和缓冲输入输出]]></title>
        <id>https://re.karlbaey.top/articles/program-design-sequence-for-newbies-and-buffering-io-episode-two/</id>
        <link href="https://re.karlbaey.top/articles/program-design-sequence-for-newbies-and-buffering-io-episode-two/"/>
        <updated>2025-08-25T04:04:22.815Z</updated>
        <summary type="html"><![CDATA[这一篇是关于 数组、字符串 的教程。因为这些东西的内容极其宏大，所以本篇仅仅是一个入门，连初步都算不上。此外，本篇还涉及了计算机底层逻辑的输...]]></summary>
        <content type="html"><![CDATA[<p>这一篇是关于 <strong>数组</strong>、<strong>字符串</strong> 的教程。因为这些东西的内容极其宏大，所以本篇仅仅是一个入门，连初步都算不上。</p>
<p>此外，本篇还涉及了计算机底层逻辑的<strong>输入输出</strong>的教程。<strong>缓冲输入输出</strong>的意思是，<strong>将键盘从命令行输入、输出到命令行的内容优先存到缓冲区</strong>，这样程序就不需要频繁地读写命令行了。</p>
<p><strong>数组和字符串都是由一系列元素组成的一个序列</strong>。它们的性质十分相似，因此放在同一篇教程里详细讨论。</p>
<h2>序列的引入</h2>
<p>还记得在 <a href="https://karlbaey.top/articles/program-design-preparation-episode-o/">前期准备</a> 中我们谈到的 <strong>数组和字符串</strong> 吗？它们有一个共同的特点：<strong>由几个元素以特定的顺序排列而成</strong>。它们的这种性质与数学中的数列（sequence，→ <a href="https://www.qiuwenbaike.cn/wiki/%E6%95%B0%E5%88%97">数列 - 求闻百科</a>）非常相似，因此在程序设计中，我们把具有这种特点的数据类型或数据结构称作 <strong>序列</strong>。</p>
<p>💡数组和字符串都是序列的代表。</p>
<pre><code>package main

import (
    "fmt"
)

func main() {
    s := "A string is a sequence."
    a := [7]int{6, 5, 1, 2, 3, 4, 5}

    fmt.Printf("这是一个字符串：%s\n", s)
    fmt.Printf("这是一个数组：%v\n", a)
}
</code></pre>
<p>输出</p>
<pre><code>这是一个字符串：A string is a sequence.
这是一个数组：[6 5 1 2 3 4 5]
</code></pre>
<p>我们可以从序列中引出一个新概念：<strong>子序列</strong>。因为序列<strong>只关注元素的排列顺序</strong>，所以子序列也是只关注排列顺序的。我们通过一个具体的例子来看。</p>
<p>例如一个数组 <code>a</code>： <code>[1, 2, 3, 4, 5, 6, 7]</code>。</p>
<ul>
<li>
<p><code>[1, 3, 5, 7]</code> 是 <code>a</code> 的子序列。因为在 <code>a</code> 中，<code>1</code> 在 <code>3</code> 前面，<code>3</code> 在 <code>5</code> 前面，以此类推。<strong>即使它们在原数组中并不连续。</strong></p>
</li>
<li>
<p><code>[1, 2, 3, 4]</code> 是 <code>a</code> 的子序列。</p>
</li>
<li>
<p><code>[1, 2, 3, 4, 5, 6, 7]</code> 是 <code>a</code> 的子序列。</p>
</li>
</ul>
<p>这里有一个与子序列相近的概念：<strong>子数组</strong>。与子序列唯一不同的是，<strong>子数组在原数组中必定是连续的</strong>。一定要区分开。</p>
<p>因此，切片操作相当于在原数组中截取子数组。</p>
<pre><code>package main

import (
    "fmt"
)

func main() {
    a := [7]int{1, 2, 3, 4, 5, 6, 7}
    sub_a := a[1:5]
    fmt.Printf("%v 的子数组之一是：%v\n", a, sub_a)
}
</code></pre>
<p>输出</p>
<pre><code>[1 2 3 4 5 6 7] 的子数组之一是：[2 3 4 5]
</code></pre>
<p>字符串因为也是序列，所以同样有 <strong>子序列</strong> 和 <strong>子串</strong> 的概念。<strong>子串是原字符串中连续的一段。</strong></p>
<h2>序列操作</h2>
<h3>数组和切片</h3>
<p>上一期我们提到了，切片是数组的引用类型，切片比数组多了一个性质：<strong>切片的长度是可变的</strong>。如果我们需要在一系列数据中添加或删除元素，那么切片是更符合实际需求的数据类型。</p>
<p>为了行文方便，以后不一定会把数组和切片分得特别清晰。</p>
<p>利用<strong>数组可以有序地组织元素</strong>的特性。如果在题目中要求反向输出所给的一系列数据，那么数组是最合适的选择。</p>
<p>例如 <a href="https://www.luogu.com.cn/problem/P5727">洛谷 P5727 【深基 5.例 3】冰雹猜想</a> 中，我们就可以通过把当前的数增加到切片 <code>a</code> 中，这样在得到 1 后，开始反向输出 <code>a</code> 即可。</p>
<pre><code>package main

import (
    "fmt"
)

func main() {
    var n int
    fmt.Scan(&amp;n)
    a := make([]int, 0)

    a = append(a, n) // 给出的 n 也要记录
    for n != 1 {
        if n % 2 == 1 {
            n = n * 3 + 1
        } else {
            n /= 2 // 等价于 n = n / 2
        }
        a = append(a, n)
    }

    for i := len(a)-1; i &gt;= 0; i-- {
        fmt.Printf("%d ", a[i])
    }
}
</code></pre>
<p>反向遍历切片可以使用如下格式。</p>
<pre><code>for i := len(a)-1; i &gt;= 0; i-- {
     /* 代码 */
}
</code></pre>
<p>如果我们把一个数组套上另一个数组，那么这个数组就变成了 <strong>二维数组</strong>。二维数组也叫做 <strong>矩阵</strong>。</p>
<p>在 <a href="https://www.luogu.com.cn/problem/P5728">洛谷 P5728</a> 中，题目给的就是一个行数为 <code>N</code>，列数为 3 的矩阵。</p>
<p>由于操作涉及到总分，我们可以对每一行成绩求和，并且将成绩的和也合并到矩阵中，这样就更方便运算了。</p>
<p>绝对值可使用 Go 的标准库 <code>math</code> 中的函数 <code>math.Abs()</code>，也可以自己写一个新函数。</p>
<h4>💡如何写一个函数</h4>
<p>数学中的函数<strong>描述了两个集合的对应关系</strong>。但在程序设计中的函数是用来<strong>实现特定功能</strong>的[^1]。使用函数的原因之一是，我们希望反复地使用一个功能，又不希望每一次使用这功能都要敲一遍代码。</p>
<p>我们在此前已经接触到了一个<strong>函数</strong> <code>len()</code>，它能够接收输入，并返回一个数组、切片、字符串或是映射等的长度。</p>
<p>你应该能注意到，如果我们导入了包（例如 <code>fmt</code>），我们<strong>调用包中的函数时，函数的首字母必然大写</strong>（例如 <code>fmt.Println()</code>）原因在下一节<strong>结构体</strong>中解释。为了风格统一，建议自定义的函数首字母也大写。</p>
<p>与 <code>len()</code> 类似，我们希望绝对值函数 <code>Abs()</code> 能实现这样的功能。</p>
<ul>
<li>如果 <code>n &lt; 0</code>，输出 <code>n</code> 的相反数 <code>-n</code>。</li>
<li>如果 <code>n &gt;= 0</code>，输出 <code>n</code>。</li>
</ul>
<p><strong>函数的定义方式是这样。</strong></p>
<pre><code>func function(参数1 type, 参数2 type) 返回值1 type,  {
    /* 代码，这一块也叫函数体 */
}
</code></pre>
<p>⚠<strong>注意</strong>：<code>function</code> 表示<strong>函数名</strong>，<code>参数1</code> 和 <code>参数2</code> 表示<strong>输入</strong>，<code>返回值1</code> 表示<strong>输出</strong>。三个 <code>type</code> 都填<strong>数据类型</strong>，如果数据类型填的是 <code>any</code>，就表示<strong>可使用任意类型的数据</strong>。</p>
<p>以上的定义方式仅作为示例。函数的输入和输出都不需要限制数量。可以没有输入，也可以有很多输入。输出也一样。</p>
<p>因此，考虑到输入和输出都是 <code>int</code> 类型，我们自己实现的绝对值函数如下所示。</p>
<pre><code>func Abs(n int) int {
    if n &lt; 0 {
        n = -n // 这一行的意思是计算 n 的相反数，并赋值给 n
    }
    return n
}
</code></pre>
<p>关于函数，这只是一个非常粗浅的介绍，我们在此处的目的是<strong>够用就行</strong>。我们会在下一部分<strong>结构体</strong>中说明函数的更多用法以及关键字 <code>func</code> 的应用场景。</p>
<hr />
<p>为了避免在组合时出现重复组合或与自己组合，我们只让<strong>每一位同学与编号比自己小的同学组合</strong>。</p>
<p>跟主函数 <code>func main()</code> 结合起来，就得到了整道题的解法。</p>
<pre><code>package main

import (
    "fmt"
)

func Abs(n int) int { // 计算绝对值
    if n &lt; 0 {
        n = -n
    }
    return n
}

func main() {
    var n int
    fmt.Scan(&amp;n)
    a := make([][]int, n) // n 就是学生的数量
    // 我们可以把每一个学生都看作一个数组，数组中的数据就是我们需要计算的东西
    for i := 0， i &lt; n; i++ { // 循环输入以便生成矩阵
        b := make([]int, 4) // b[0] 表示语文成绩、b[1] 表示数学成绩、b[2] 表示英语成绩、b[3] 表示总分
        fmt.Scan(&amp;b[0], &amp;b[1], &amp;b[2])
        b[3] = b[0] + b[1] + b[2]
        a[i] = b
    }
    
    // a[i] 表示第 i+1 位学生（索引从 0 开始）
    // a[i][0] 表示第 i+1 位学生的语文成绩，以此类推

    var pairs int
    for i := range a { // 遍历每一位学生
        for j := 0; j &lt; i; j++ { // 防止跟自己组合或是重复组合
            diff_a := Abs(a[i][0] - a[j][0])
            diff_b := Abs(a[i][1] - a[j][1])
            diff_c := Abs(a[i][2] - a[j][2])
            diff_all := Abs(a[i][3] - a[j][3])

            if diff_a &lt;= 5 &amp;&amp; diff_b &lt;= 5 &amp;&amp; diff_c &lt;= 5 &amp;&amp; diff_all &lt;= 10 {
                pairs++
            }
        }
    }

    fmt.Println(pairs)
}
</code></pre>
<p>上面两道题是遍历一维数组与二维数组的模板题。它们的中心思想是这样的：<strong>遍历的同时记录数据</strong>。否则，遍历就失去了价值。</p>
<p>这一思想的平凡应用场景很多，其中之一是求最大连续值。以 <strong>LeetCode 485. 最大连续 1 的个数</strong> 为例。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/pictures/image-20250823014712667.png" alt="lc 485" /></p>
<p>我们可以通过遍历一次数组，并且使用一个变量 <code>now</code> 来记录当前已经有连续 <code>1</code> 的数量。</p>
<p>我们用 <code>i</code> 表示数组的索引，那么</p>
<ul>
<li>如果 <code>nums[i] == 1</code>，<code>now++</code>。</li>
<li>如果 <code>nums[i] == 0</code>，将 <code>now</code> 归零。</li>
</ul>
<p>在每一次判断完 <code>now</code> 执行的操作后，将答案 <code>ans</code> 与 <code>now</code> 相比后取最大值，就是结果了。</p>
<pre><code>func findMaxConsecutiveOnes(nums []int) int {
    var now, ans int
    for _, num := range nums {
        if num == 1 {
            now++
        } else {
            now = 0
        }
        ans = max(now, ans)
    }

    return ans
}
</code></pre>
<p>除此之外，还有挖坑 / 种树问题。因为不能在相同的地方两次挖坑或者种树，所以我们通过 <code>1</code> 和 <code>0</code> 来表示有 / 没有执行操作。</p>
<p><a href="https://www.luogu.com.cn/problem/P1047">P1047 NOIP 2005 普及组 校门外的树 - 洛谷</a> 中，因为给出的区间中的树可能已经被挖走，而已经挖掉的树不能第二次挖掉，所以我们可以通过记录一个<strong>二进制数组</strong>[^2]来表示某个点的树是否存在。</p>
<p>因为题目中在原点处也种了树，注意在生成记录挖树状态的数组时将长度设置为 <code>l+1</code>。</p>
<pre><code>package main

import "fmt"

func main() {
    var l, m int
    fmt.Scan(&amp;l, &amp;m)
    trees := make([]bool, l+1) // 布尔数组，记录树是否健在
    moved := make([][2]int, m) // 记录所有将被挖走的树的区间

    for i := 0; i &lt; m; i++ {
        fmt.Scan(&amp;moved[i][0], &amp;moved[i][1])
    }
    
    // 接下来只要遍历 moved 中每一个数组即可
    // 1 表示已被挖走，0 表示未被挖走
    // 例如，moved[0] == [150 300]，那么我们把 trees 中索引从 150 到 300 的数据全部设为 true
    for _, moved_i := range moved {
        for i := moved_i[0]; i &lt;= moved_i[1]; i++ {
            trees[i] = true
        }
    }

    var ans int
    for _, tree := range trees {
        if !tree { // false 的个数就是剩余的树的数量
            ans += 1
        }
    }

    fmt.Println(ans)
}
</code></pre>
<p>数组还可以继续套娃，此后的数组称作三维数组、四维数组……但这样的套娃在数据增长后的操作会很麻烦，而且完全可以转化成多个数组的运算，这里不再继续推广[^3]。如果感兴趣可以试试看 <a href="https://www.luogu.com.cn/problem/P5729">P5729 【深基5.例7】工艺品制作 - 洛谷</a> 来练练手。注意切割会重复。</p>
<p><a href="https://www.luogu.me/paste/m86xbxbb">🔗跳转至 P5729 解法</a></p>
<h3>字符串</h3>
<p>字符串作为一种序列，它和数组/切片的最大区别是，<strong>字符串的每一个元素都是不可变的</strong>（immutable）。这意味着一个字符串一旦生成就不能再改变内容[^4]。</p>
<p>这里有一个易错点：在 Go 中，使用双引号 <code>""</code> 表示初始化一个字符串，它的类型是 <code>string</code>；单引号 <code>''</code> 表示初始化一个 ASCII 字符，它的类型是 <code>byte</code>。它们是完全不同的。</p>
<p>我们已经知道，一个 UTF-8 字符可以使用 <code>rune</code> 来存储。一个 <code>rune</code> 占用的字节[^5]数是 4。</p>
<p>Go 的字符串都以 UTF-8 编码。但是 Go 很聪明，它把字符串里的每一个字符都用 UTF-8 编码，而不是采取空间占用更大的 <code>rune</code>，这样就能节省空间。</p>
<p>这里需要引入一个关于字符的知识。</p>
<h4>UTF-8 和 Unicode 的关系</h4>
<p><strong>Unicode</strong>（万国码）是一个<strong>字符集</strong>，这个集合中包含了世界上所有文字和符号的编码。但是字符集只能给字符分配编号，不能有序地组织起海量的字符。在这种背景下，就需要一个标准来组织所有的字符。</p>
<p>早期使用的标准叫做 <strong>UTF-32</strong>，它的每个字符都占用 4 个字节。这样的标准确实简单，但有一个严重的弊端：分别使用 UTF-32 和 ASCII[^6] 存储一篇纯现代英文文本，前者消耗的空间是后者的四倍。</p>
<p>目前最通用的规则是 <strong>UTF-8</strong>。<strong>UTF-8</strong> 通过<strong>变长地存储字符</strong>来优化空间。比如，对于英文字母“a”和汉字“字”。</p>
<pre><code>a  U+0061 二进制表示：01100001             UTF-8表示：0x61
字 U+5b57 二进制表示：01011011 01010111    UTF-8表示：0xE5 0xAD 0x97
</code></pre>
<p>如果我们将这些字符按照二进制最高位来划分空间的话，就能极大地节省空间。</p>
<hr />
<p>当我们在 Go 中计算一个字符串的长度时，实际上是在计算它占用的字节数。</p>
<pre><code>package main

import "fmt"

func main() {
    s := "abcdefg"
    t := "这是一行中文"

    fmt.Printf("%s 占用的字节数是：%d\n", s, len(s))
    fmt.Printf("%s 占用的字节数是：%d\n", t, len(t))
}
</code></pre>
<p>输出</p>
<pre><code>abcdefg 占用的字节数是：7
这是一行中文 占用的字节数是：18
</code></pre>
<p>输出内容告诉我们一个英文字母占用 1 字节，一个汉字占用 3 字节，符合我们对 UTF-8 编码的印象。</p>
<p>要返回字符串的字符数量我们通常使用迭代循环，也可以使用 Go 内置的 <code>strings</code> 包。</p>
<pre><code>package main

import (
    "fmt"
    "strings"
)

func main() {
    t := "这是一行中文"
    var lengthT int
    for range t {
        lengthT++
    }
    fmt.Printf("%s 的长度是：%d\n", t, lengthT)

    fmt.Println(strings.Count(t, "") - 1)
}
</code></pre>
<p>输出</p>
<pre><code>这是一行中文 的长度是：6
6
</code></pre>
<p>这里使用 <code>strings.Count()</code> 函数时我们统计的是空字符 <code>""</code>，也就是每两个字符之间、头部以及末尾的空字符，因此在得到结果后需要减去 1。</p>
<p>因为字符串是无法改变的，所以当我们需要反转字符串时，就需要开一个新的字符串或是使用 <code>[]byte</code>。</p>
<p>如果是洛谷等主要面向<strong>程序设计竞赛</strong>的题库，那么完全可以处理一部分字符串就输出一部分，没有必要为了构建字符串专门写一个函数。但是在力扣等平台上，只需要写核心代码，返回值通常限制在一个字符串，这就需要我们为字符串做预处理。</p>
<p>重开一个字符串可以使用 <code>strings.Builder</code> 来初始化空字符串。然后使用 <code>Builder</code> 的<strong>方法</strong> <code>String()</code> 输出即可[^7]。</p>
<h4>字符串拼接</h4>
<p><a href="https://leetcode.cn/problems/reverse-words-in-a-string/description/">151. 反转字符串中的单词 - 力扣（LeetCode）</a>中，因为我们需要频繁地写入一个字符串，且 <code>s</code> 的长度达到了 10,000，所以使用 <code>strings.Builder</code> 是相对合适的选择。下文还会提到如何使用 <code>strings.Join()</code> 函数来拼接字符串。</p>
<p>在这一题中，我们应该先遍历一次字符串，去除所有的空格后，使用 <code>strings.Builder</code> 的 <code>WriteString()</code> 方法拼接字符串。</p>
<p>遍历字符串时需要判断是否遍历到空格，并截断字符串，存到 <code>builder</code> 中。</p>
<p>为了尽量优化性能，我们反向遍历字符串，用一个索引 <code>j</code> 记录当前单词的右边界，当循环变量 <code>i</code> 遍历到空格时，就说明找到了当前单词的左边界。在 <code>i</code> 找到下一个非空格的字符时，就让 <code>j = i</code>。不断循环这个过程即可。</p>
<pre><code>func reverseWords(s string) string {
    var builder strings.Builder // 不可以直接使用 strings.Builder，必须先赋值
    s = strings.TrimSpace(s) // 清除左右空格
    i := len(s) - 1

    for i &gt;= 0 {
        j := i
        for i &gt;= 0 &amp;&amp; s[i] != ' ' {
            i--
        }
        if builder.Len() &gt; 0 {
            builder.WriteByte(' ') // 防止拼接最后一个单词时加上一个空格
        }
        builder.WriteString(s[i+1 : j+1]) // 无论是什么切片操作都是“留头去尾”的，所以这样不会超出范围
        for i &gt;= 0 &amp;&amp; s[i] == ' ' {
            i--
        }
        j = i
    }
    return builder.String()
}
</code></pre>
<p>我们还可以用 <code>strings.Join()</code> 来拼接。<code>strings.Join()</code> 可以把一个切片中的所有字符串拼起来并决定连接符号。</p>
<pre><code>func reverseWords(s string) string {
    s = strings.TrimSpace(s)
    i := len(s) - 1
    var words = make([]string, 0)

    for i &gt;= 0 {
        j := i
        for i &gt;= 0 &amp;&amp; s[i] != ' ' {
            i--
        }
        words = append(words, s[i+1 : j+1])
        for i &gt;= 0 &amp;&amp; s[i] == ' ' {
            i--
        }
        j = i
    }
    return strings.Join(words, " ") // 意思是，把切片 words 的全部元素用空格连接起来
}
</code></pre>
<p>这样操作相比 <code>string.Builder</code> 来说，可读性强了很多，因此更推荐用后者。</p>
<p>有些时候题目会限制空间，例如<strong>原地修改数组</strong>。字符串本身不能这样做，但是将字符串转为存储 <code>byte</code> 类型数据的数组后，就可以原地修改字符了。</p>
<p>例如 <a href="https://leetcode.cn/problems/reverse-string/description/">344. 反转字符串 - 力扣（LeetCode）</a>，这道题目要求我们反转给出的 <code>s</code>，我们发现 <code>s</code> 的数据类型不是 <code>string</code>，而是 <code>[]byte</code>。</p>
<p>这实际上是把<strong>不可变</strong>的字符串转换成了<strong>可变</strong>的数组，所以我们只需要设置两个坐标，<code>l</code> 在头，<code>r</code> 在尾。</p>
<p>不断交换 <code>s[l]</code> 和 <code>s[r]</code>，<code>l</code> 循环一次就增加 1，<code>r</code> 循环一次就减少 1。直到 <code>l &gt;= r</code>。</p>
<pre><code>func reverseString(s []byte)  {
    l := 0
    r := len(s) - 1

    for l &lt; r {
        s[l], s[r] = s[r], s[l] // a, b = b, a 表示交换 a 和 b 的值
        l++
        r--
    }
}
</code></pre>
<p>这里涉及序列中非常重要的<strong>双指针算法</strong>。双指针算法的中心思想是，在给定的序列中选择两个下标（或者索引），然后让两个下标遍历序列。这不一定要遍历完整的序列。</p>
<p>双指针算法是非常高效的，因为它通常只需要遍历两次完整的序列。关于双指针，在上面的两个示例中分别使用了快慢指针和左右指针。</p>
<p>关于双指针，更多内容会在之后的经典算法部分说明。</p>
<h2>带缓冲的输入输出</h2>
<h3>有空格的输入</h3>
<p>在之前我们使用的输入中，我们用的全部都是 <code>fmt</code> 包提供的 <code>fmt.Scan()</code> 这个函数，但是这有一个问题：如果输入是一行带空格的英文句子，那么 <code>fmt.Scan()</code> 最多只能获取第一个单词。这是因为 <code>fmt.Scan()</code> 默认使用空格 <code>" "</code> 作为分隔符。就在上文的 LeetCode 151 中，如果题目使用的是标准输入输出，那么以目前的方式是过不了这一题的。</p>
<p>为了避免这种情况，我们需要使用 Go 的标准库 <code>bufio</code> 和 <code>os</code>。</p>
<pre><code>package main

import (
    "bufio"
    "fmt"
    "os"
)

func main() {
    in := bufio.NewReader(os.Stdin) // 创建读取器
    out := bufio.NewWriter(os.Stdout) // 创建写入器
    defer out.Flush() // 推迟刷新，确保全部输出

    a, err := in.ReadString('\n') // 到换行符才截断
    if err != nil { // 错误处理
        return
    }

    fmt.Fprintln(out, a)
}
</code></pre>
<p>它的结果是</p>
<pre><code>IN                 | OUT
-------------------+----------------
I am Karlbaey.     | I am Karlbaey.
</code></pre>
<p>下面详细解释这段程序输入输出的原理[^8]。（以下涉及大量说明和概念，请一定要细心阅读）</p>
<p><code>os</code> 包能够直接和操作系统互动。我们如果希望执行一个自己写好的程序，最直接的方式就是在命令行输入程序名来运行程序。这个程序读取输入时，实际上是接受我们在键盘上的输入，也就是<strong>标准输入流</strong> <code>os.Stdin</code>。标准输入流默认关联键盘（也就是常说的键盘打字），但在某些场景中会关联已经写好的文件。</p>
<p><strong>标准输出流</strong> <code>os.Stdout</code> 同理，程序把处理好的文本打印在命令行上，就称作标准输出流。</p>
<p>因为我们希望直接看到输入和输出的文本，所以在这里不对这两个流做任何处理。仅把输入流导向内存就可以了。</p>
<p>现在有了输入输出流，但还不够。如果每次需要输入都要在命令行和程序之间来回跑，那就太慢了。所以我们希望能有一个空间，用来存储我们输入到命令行的内容，这样程序直接读取这片空间，速度就快得多了。这片空间的学名叫做<strong>内存</strong>，上一篇提到的指针就用于记录<strong>内存地址</strong>。内存的访问速度是极快的，比直接从命令行读取数据快 1,000 倍左右。</p>
<p>想象一下你在厨房做了一锅汤。现在你在餐桌上吃饭，想喝汤的话，最好的做法是拿一个足够大的碗，将汤装进大碗里面带回餐桌喝。如果你每次想喝汤都要跑去厨房里面喝，这种做法的效率可想而知。将“用大碗装汤”的比喻反映在程序设计里，就是将输入优先存到内存里，这样的操作效率就高多了。</p>
<p>为了实现将数据存到内存里然后让程序访问，Go 提供了 <code>bufio</code> 包。<code>buf</code> 的意思是<strong>缓冲</strong>（buffer），<code>io</code> 的意思是输入输出（input and output）。</p>
<p><code>in := bufio.NewReader(os.Stdin)</code> 实际上是在内存中创建一个<strong>带有缓冲空间的读取器</strong> <code>in</code>，并且把标准输入流都导向这个缓冲空间。</p>
<p><img src="https://gcore.jsdelivr.net/gh/Karlbaey/tutu@master/pictures/%E5%B1%8F%E5%B9%95%E6%88%AA%E5%9B%BE%202025-08-25%20030614.png" alt="默认缓冲空间为 4096 字节" /></p>
<p>我们在下面使用的 <code>in.ReadString('\n')</code> 意思就是<strong>在这个读取器的缓冲空间中一直读，直到读到换行符 <code>"\n"</code> 才停下</strong>，这样就避免了 <code>fmt.Scan()</code> 默认的读到空格就停止的问题。</p>
<p>如果使用另一个读取缓冲空间的函数 <code>fmt.Fscan()</code> ，也仍然有这个问题。所以一定要记牢这个用法。</p>
<p>同理可得，<code>out := bufio.NewWriter(os.Stdout)</code> 就是在内存中创建一个<strong>带有缓冲空间的写入器</strong>，并把标准输出流导向写入器的缓冲空间</p>
<p><code>defer out.Flush()</code> 是一道保险。这道保险<strong>确保在函数结束前（遇到 <code>return</code> 或者执行到最后一行），把所有缓冲数据都实际地写入输出设备</strong>。本文的语境中，输出设备指的是命令行。<strong>如果没有这一行，那么我们输入的内容只是被写入了内存缓冲区，而没有被实际刷新到标准输出。</strong></p>
<p><code>defer</code> 关键字的意思是<strong>延迟函数执行</strong>，它通常用在函数结束前的清扫工作，例如关闭文件、释放内存等。</p>
<p><code>if err != nil</code> 用来处理可能的错误。如果发生了什么奇怪的问题就让函数提前结束，不执行输出。</p>
<p><code>fmt.Fprintln(out, a)</code> 的意思是从写入器 <code>out</code> 中读取 <code>a</code> 的值，并且打印到命令行上。带上了一个 <code>F</code> 表示指定输出目标。</p>
<h3>高速读写</h3>
<p>当输入数量来到 10&lt;sup&gt;5&lt;/sup&gt; 乃至 10&lt;sup&gt;6&lt;/sup&gt; 时，上面的输入方法都不够用了。因为哪怕是分割输入、分割输出这种操作，在次数来到百万级别时，时间占用都会非常大。考虑到下面还有程序的主要内容需要执行，让输入输出拖慢我们的脚步显然是不值当的，所以我们引入一个新的概念：<strong>高速读写</strong>[^9]。</p>
<p>使用缓冲来存储输入输出还是第一步。我们运用的 <code>in.ReadString('\n')</code> 还要切割字符串，这在一定程度上也拖慢了输入的时间。我们希望用一种高速的方法直接读内存，如何读由我们自己决定。这个“如何读”的过程，我们称作<strong>自定义解析</strong>。自定义解析需要我们使用上面提到的如何定义函数。</p>
<p>高速读写通常不考虑浮点数（float，也就是小数），因为 <code>fmt</code> 提供的解析浮点数的工具已经足够高效，自定义解析并不能带来很大的性能提升。</p>
<p>在这里需要先导入必要包，然后定义两个全局变量 <code>reader</code>（输入）和 <code>writer</code>（输出）。<strong>全局变量</strong>的意思是<strong>在整个程序都能用的变量</strong>。</p>
<pre><code>package main

import (
    "bufio"
    "os"
) // 看到了吗？我们连最早用到的 fmt 都不需要了

var reader = bufio.NewReader(os.Stdin) // 上面提过，这里不再说这两者的含义
var writer = bufio.NewWriter(os.Stdout)
</code></pre>
<p>我们以输入一系列整数为例。</p>
<p>输入</p>
<pre><code>65 12 345
</code></pre>
<p>我们实际上是从这一行的第一位开始读，中间遇到<strong>空格</strong>时就把当前读到的内容<strong>截断</strong>，作为一个数字给程序处理。只要当每一个空格都被跳过，而且读到这一行末尾时，就算读完了这一行的全部数字。这就称作整数的解析逻辑。</p>
<p>那么我们就写一个针对 32 位整数[^10]的<strong>解析</strong>逻辑。</p>
<pre><code>func nextInt() int {
    var n int
    var sign int = 1 // 决定是否是负数
    var b byte

    // 跳过非数字字符
    for {
        b, _ = reader.ReadByte() // 往后读一位。b 就是当前读取的内容，占一个字节
        if b == '-' { // b 是 -，说明这个整数是负数
            sign = -1
            break
        } else if b &gt;= '0' &amp;&amp; b &lt;= '9' {
            n = int(b - '0') // ASCII 运算，说明 b 对应的 ASCII 码点在 0 到 9 之间，写入即可
            break
        }
    }

    // 读取数字
    for {
        b, _ = reader.ReadByte()
        if b &lt; '0' || b &gt; '9' { // 读到空格或者换行符了，也有可能是读到末尾了
            break
        }
        n = n*10 + int(b-'0') // n 就是上面解析好的数字
        // 例如 n == 1, b == 6，说明当前的数字就是 16
        // n*10 + b 即可，其余的数字同样处理
    }

    return n * sign // 处理正负
}
</code></pre>
<p>解析 64 位整数只要把上面代码中的 <code>int</code> 改成 <code>int64</code> 就行了。</p>
<p>解析字符串的逻辑跟解析整数是一致的，不过需要去掉回车 <code>\r</code>、换行 <code>\n</code> 和制表符 <code>\t</code> 。而且在上文的字符串部分我们也提到过，字符串本身不可变，我们应该用 <code>[]byte</code>。</p>
<pre><code>func nextString() string {
    var bytes []byte
    var b byte

    // 跳过空白字符
    for {
        b, _ = reader.ReadByte()
        if b != ' ' &amp;&amp; b != '\n' &amp;&amp; b != '\t' &amp;&amp; b != '\r' { // 不是空白字符
            bytes = append(bytes, b)
            break
        }
    }

    // 读取直到空白字符
    for {
        b, _ = reader.ReadByte()
        if b == ' ' || b == '\n' || b == '\t' || b == '\r' {
            break
        }
        bytes = append(bytes, b)
    }

    return string(bytes)
}
</code></pre>
<p>这个读字符串的函数并不能读包含空格的字符串，因为它读到空格就停止读取了。</p>
<p>如果需要读一整行，我们应该写一个专门读一行的函数。</p>
<pre><code>func nextLine() string {
    line, _ := reader.ReadString('\n')
    // 去除可能的换行符
    if len(line) &gt; 0 &amp;&amp; line[len(line)-1] == '\n' { // 清除换行
        line = line[:len(line)-1] // 切片，切掉最后一位的换行符
    }
    return line
}
</code></pre>
<p>这里还是用内置函数读取一整行。</p>
<p>解决了读取的问题，现在我们需要处理写入的问题。</p>
<p>先从写入整数开始。我们写入整数时，优先处理的问题应该是整数是否为负以及整数是否为 0。</p>
<p>然后，因为不知道这个整数具体有多少位，我们应该把这个整数反转。这个操作的同时我们就知道了整数的位数，然后反向输出即可。</p>
<pre><code>func writeInt(n int) {
    if n &lt; 0 { // 判定负数
        writer.WriteByte('-')
        n = -n
    }

    if n == 0 { // 判定 0
        writer.WriteByte('0')
        return // 提前结束
    }

    // 反转数字
    var digits []byte
    for n &gt; 0 {
        digits = append(digits, byte('0'+n%10))
        n /= 10 // 自动截断整数
    }

    // 逆序输出
    for i := len(digits) - 1; i &gt;= 0; i-- {
        writer.WriteByte(digits[i])
    }
}
</code></pre>
<p>在写入操作中，只有整数需要这样的特殊处理。因为 <code>bufio</code> 没有提供直接写入整数的方法，我们需要自己实现。</p>
<p>其余的写入字符串等操作，直接调用方法 <code>writer.WriteString(s)</code> 和 <code>writer.WriteByte('\n')</code>（实现换行）即可。</p>
<p>我们放在主函数里测试。</p>
<pre><code>func main() {
    defer writer.Flush() // 仍然要有一层保险
    
    c := nextLine()
    a := nextInt()
    b := nextString()

    writeInt(a)
    writer.WriteByte('\n') // 都是换行，如果嫌麻烦可以再自定义函数
    writer.WriteString(b)
    writer.WriteByte('\n')
    writer.WriteString(c)
    writer.WriteByte('\n')
}
</code></pre>
<p>输入输出</p>
<pre><code>IN                   | OUT
---------------------+-----------------------
I am Karlbaey        | 65656565
65656565 string      | string
                     | I am Karlbaey
</code></pre>
<hr />
<p>该给这篇教程结个尾了。我们在这篇教程里说了<strong>数组和切片、字符串，以及高速读写</strong>。</p>
<p>数组、切片和字符串最重要的操作是遍历，然后在遍历的同时记录数据。</p>
<p>遍历次数能够直接决定这个程序的性能如何，所以我们引入了<strong>双指针</strong>的概念。用来减少遍历次数。</p>
<p><strong>切片是可变的</strong>，修改切片某个元素可以引用下标 <code>a[i] = ...</code>，在切片后加入新元素使用 <code>a = append(a, ...)</code>。</p>
<p><strong>字符串是不可变的</strong>，改变字符串需要使用 <code>[]byte</code>，或者使用 <code>strings.Builder</code> 或 <code>strings.Join()</code> 构建新的字符串。</p>
<p><strong>高速读写</strong>包括两个部分：<strong>缓冲输入输出</strong>和<strong>自定义解析</strong>。</p>
<p>关于高速读写，这里有一个思想：<strong>倒序输出</strong>。这里先不说倒序输出有什么用。我们通过自定义缓冲区的输入内容应该怎么分割，大大提升了程序的输入输出性能。</p>
<p>其实你应该看出来了，高速读写完全可以用下面两张图概括。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/pictures/image-20250825125713102.png" alt="超级拆解" /></p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/pictures/image-20250825125454345.png" alt="超级拼装" /></p>
<p>（图片仅供展示）</p>
<p>那么，这期教程就到这里，我们将会在下一期说明<strong>结构体</strong>，并且会用到 <code>func</code> 关键字的更多使用方法。</p>
<p>🎉撒花🎉</p>
<p>[^1]: 函数的英文是 “function”，而 function 这个词又恰好有“功能”的意思。
[^2]: <strong>二进制数组</strong>的意思是只含有 <code>0</code> 和 <code>1</code> 的数组。由于可以用布尔值来表示 <code>0</code> 和 <code>1</code>，因此<strong>二进制数组</strong>往往可以转换成<strong>布尔数组</strong>，并表现成 <code>[]bool</code> 的形式。用这种数组来记录存在的状态非常方便。
[^3]: 三维及以上数组的应用场景之一是动态规划。这里用不到所以暂时略过。
[^4]: 字符串事实上是一个<strong>只读的</strong>字节序列 <code>[]byte</code>。
[^5]: 1 个字节（byte）等于 8 个比特（bit）。例如 9 的二进制形式是 <code>1001</code>，每个 <code>1</code> 和 <code>0</code> 都用一个比特的空间存储，而 9 在计算机中通常要补全成 <code>00001001</code>，这时整数 9 的空间占用就是 1 个字节。
[^6]: ASCII（American Standard Code for Information Interchange，美国信息交换标准代码）是基于拉丁字母的一套电脑编码系统。它最主要的功能是显示现代英语文本。一个 ASCII 字符占用 1 个字节。
[^7]: <strong>方法</strong>（method）和<strong>函数</strong>（function）的区别在于，函数是一个完全独立的单元，而方法需要依赖特定的类存在。例如，<code>strings.Builder</code> 是一个专门构造字符串的类型，在构造完之后需要输出，就需要使用 <code>String()</code> 方法。<code>String()</code> 方法必须依赖 <code>strings.Builder</code> 才能使用，所以它是方法而不是函数。
[^8]: 学习过 C/C++ 的人可能比较熟悉下面提到的重要概念。但对于感到生疏的人来说，别慌，我相信我的说法能让你分明白这些概念。
[^9]: 速度大概是 <code>fmt.Scan()</code> 的一百倍，<code>a, err := in.ReadString('\n')</code> 的十倍。
[^10]: 32 位整数指的是这个数在计算机中占用 32 个比特，也就是 4 个字节。一个 32 位整数的区间是 <code>[-2147483648, 2147483647]</code>。64 位整数同理。</p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2025-08-25T04:04:22.815Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[程序设计#1 - 流程控制]]></title>
        <id>https://re.karlbaey.top/articles/program-design-process-control-episode-one/</id>
        <link href="https://re.karlbaey.top/articles/program-design-process-control-episode-one/"/>
        <updated>2025-08-21T13:51:39.362Z</updated>
        <summary type="html"><![CDATA[⚠ 注意：从本篇教程开始，就会涉及真正的程序设计，但不会太难，更多的是作为讲解用的例题。书接上回。我们在第零篇教程中简要地说明了如何在 Go...]]></summary>
        <content type="html"><![CDATA[<p><strong>⚠ 注意</strong>：从本篇教程开始，就会涉及真正的程序设计，但不会太难，更多的是作为讲解用的例题。</p>
<p>书接上回。我们在<a href="/t/topic/885736/">第零篇教程</a>中简要地说明了如何在 Go 中操作内置的数据类型。如果我们只是一条一条地执行代码，那这个程序就是 <strong>顺序结构</strong>。顺序结构是最基本的流程控制。不过在这之前，我们需要先接触程序的输入输出，以及指针和指针运算。</p>
<h2>I/O For Golang</h2>
<p>Go 有内置的 <code>fmt</code> 包用于格式化输入输出，我们以 <a href="https://www.luogu.com.cn/problem/P1001">P1001 A+B Problem - 洛谷</a> 为例。</p>
<pre><code>package main

import "fmt"

func main() {
    var a, b int // 初始化原题中的 A、B
    fmt.Scan(&amp;a, &amp;b) // 输入

    fmt.Println(a+b) // 输出结果
}
</code></pre>
<p>其中，<code>fmt.Scan</code> 负责程序的输入，<code>fmt.Println</code> 负责程序的输出。输入时涉及到了指针运算 <code>&amp;</code>。<code>&amp;</code> 的学名是 <strong>取址运算符</strong>，它能够获取变量的内存地址，以便在程序读取输入时，将输入内容存在正确的地方，在下文会详细地说明。这样的代码可以在原题拿满分。</p>
<p>Go 的输入除了这里的 <code>fmt.Scan</code>，还可以使用 <code>fmt.Scanf()</code>（格式化输入）和 <code>fmt.Scanln()</code> （只会检查一行输入，直到这行结束）。</p>
<pre><code>package main

import "fmt"

func main() {
    var a, b, c, d, e int=
    
    fmt.Scan(&amp;a, &amp;b)
    fmt.Scanf("%d", &amp;c)
    fmt.Scanln(&amp;d)
    fmt.Scanln(&amp;e)

    fmt.Printf("a 的值是：%d\n", a)
    fmt.Printf("b 的值是：%d\n", b)
    fmt.Printf("c 的值是：%d\n", c)
    fmt.Printf("d 的值是：%d\n", d)
    fmt.Printf("e 的值是：%d\n", e)
}

</code></pre>
<p>这段代码，会因为输入方式不同而输出不同的结果。（同一行为对应的输入输出）</p>
<pre><code>IN                  | OUT
--------------------+-------------
6 5 1 2 3           | a 的值是：6
                    | b 的值是：5
                    | c 的值是：1
                    | d 的值是：2
                    | e 的值是：0
--------------------+-------------
6 5 1 2             | a 的值是：6
3                   | b 的值是：5
                    | c 的值是：1
                    | d 的值是：2
                    | e 的值是：3
--------------------+-------------
</code></pre>
<p>这是因为 <code>fmt.Scanln()</code> <strong>检查到换行符时就不再继续读取输入</strong>，并且当前程序输入会换行。在第一组测试中没有第二行，所以无法输入 <code>e</code> 的值。</p>
<p><strong>格式化输出</strong>在 <a href="https://linux.do/t/topic/885736#p-8059198-h-3">上一篇</a> 中已经给出了详细的方法，对照题目的输出样例写输出语句即可。</p>
<h3>练习题：K-0 超级 65！</h3>
<p>如下图，写一个程序输出这幅 ASCII 字符画。你需要在这张字符画的每一行开头加一个 <strong>特定的数字 <code>n</code> 以及一个空格</strong>。</p>
<pre><code>    .ooo     oooooooo   .o    .oooo.     .oooo.         .o     oooooooo 
  .88'      dP""""""" o888  .dP""Y88b  .dP""Y88b      .d88    dP""""""" 
 d88'      d88888b.    888        ]8P'       ]8P'   .d'888   d88888b.   
d888P"Ybo.     `Y88b   888      .d8P'      &lt;88b.  .d'  888       `Y88b  
Y88[   ]88       ]88   888    .dP'          `88b. 88ooo888oo       ]88  
`Y88   88P o.   .88P   888  .oP     .o o.   .88P       888   o.   .88P  
 `88bod8'  `8bd88P'   o888o 8888888888 `8bd88P'       o888o  `8bd88P'   
</code></pre>
<p>例</p>
<p>输入</p>
<p>一个整数 n，在区间 [1, 9] 内。</p>
<pre><code>5
</code></pre>
<p>输出</p>
<pre><code>5     .ooo     oooooooo   .o    .oooo.     .oooo.         .o     oooooooo 
5   .88'      dP""""""" o888  .dP""Y88b  .dP""Y88b      .d88    dP""""""" 
5  d88'      d88888b.    888        ]8P'       ]8P'   .d'888   d88888b.   
5 d888P"Ybo.     `Y88b   888      .d8P'      &lt;88b.  .d'  888       `Y88b  
5 Y88[   ]88       ]88   888    .dP'          `88b. 88ooo888oo       ]88  
5 `Y88   88P o.   .88P   888  .oP     .o o.   .88P       888   o.   .88P  
5  `88bod8'  `8bd88P'   o888o 8888888888 `8bd88P'       o888o  `8bd88P'   
</code></pre>
<h2>指针与指针运算</h2>
<p><strong>指针</strong>（pointer）是 <strong>记录值的内存地址的数据类型</strong>。例如，定义一个变量 <code>a</code>，如果变量 <code>b</code> 存储了 <code>a</code> 的内存地址，那么 <code>b</code> 是 <code>a</code> 的指针。用代码表示就是这样。</p>
<pre><code>b := &amp;a
</code></pre>
<p>关于 <code>&amp;</code> <strong>取址运算符</strong>，我们通过下面的代码来测试。</p>
<pre><code>package main

import "fmt"

func main() {
    var a, b int
    fmt.Scan(&amp;a, &amp;b)

    fmt.Printf("a 的值是：%d\nb 的值是：%d\n", a, b)
    fmt.Printf("a 的内存地址在 %v\nb 的内存地址在 %v", &amp;a, &amp;b)
}
</code></pre>
<p>输出</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/pictures/image-20250820221355977.png" alt="每次的地址都可能不同" /></p>
<p>注意到每次执行代码时，内存地址都可能会变。<strong>这是内存的特点之一：随机访问</strong>。</p>
<p>当使用 <code>fmt.Scan()</code> 输入时，我们实际上是希望 <strong>把输入到程序的值存到变量对应的内存地址中</strong>，这就要求我们必须使用 <code>&amp;</code> 获取内存地址。打个比方，把输入比作快递包裹，<code>fmt.Scan()</code> 能告诉快递员把包裹送到哪里，如果我们只是告诉快递员“把包裹送到我家（变量名）”显然是不对的，应该告诉快递员家的具体地址（内存地址），才能收到包裹（输入成功）。</p>
<hr />
<p>关于 <code>*</code> <strong>解引用运算符</strong>，可看下面的代码。</p>
<pre><code>package main

import "fmt"

func main() {
    var a int
    fmt.Scan(&amp;a)

    a_P := &amp;a // 定义一个指向 a 的指针 a_P

    fmt.Printf("a 的类型是：%T\na 的值是：%v\n", a, a)
    fmt.Printf("a_P 的类型是：%T\na_P 的值是：%v\n", a_P, a_P) // 内存地址会变
    fmt.Printf("*a_P 的类型：%T\na_P 解引用后的值：%v", *a_P, *a_P) // 解引用 a_P
}
</code></pre>
<p>输入</p>
<pre><code>6
</code></pre>
<p>输出</p>
<pre><code>a 的类型是：int
a 的值是：6
a_P 的类型是：*int
a_P 的值是：0xc00000a0c8
*a_P 的类型：int
a_P 解引用后的值：6
</code></pre>
<p>结合着上面的两块代码，我们就知道了指针的两个运算的特点： <strong><code>&amp;</code> 取址表示由值到指针，<code>*</code> 解引用表示从指针到值</strong>。当后面学习到结构体的时候，使用指针往往可以节省大量内存，提高性能。因为 <strong>指针只记录了值的地址，所有的操作仅仅是通过解引用指针来作用到值本身上的</strong>。</p>
<p>Go 中同样有 <strong>空指针</strong> 的概念，意思是，指针指向的地方什么都没有。如果尝试给空指针的值赋值，程序会报错。</p>
<pre><code>package main

import "fmt"

func main() {
    var a *int         // a 是一个空指针

    *a = 10            // 将空指针的值改为 10（panic）
    fmt.Println(a, &amp;a) 
}
</code></pre>
<p>输出</p>
<pre><code>&lt;nil&gt; 0xc000088058
panic: runtime error: invalid memory address or nil pointer dereference
[signal 0xc0000005 code=0x1 addr=0x0 pc=0x20a97c]

goroutine 1 [running]:
main.main()
        E:/DRAFTBOX/Go/main.go:9 +0x7c
</code></pre>
<p><code>panic</code> （恐慌）表示程序遇到了无法恢复的错误。除此之外，查询数组或切片范围以外的索引，除法运算中除数为零导致 <code>panic</code> 也很常见。</p>
<h2>流程控制语句</h2>
<h3>💡 <strong>顺序结构</strong></h3>
<p><strong>顺序结构是按照从上往下的顺序，依次执行代码的结构。</strong> 最经典的就是上文提到的 P1001，以及这一题 <a href="https://www.luogu.com.cn/problem/P5703">P5703</a>。前者是将两个输入值相加，后者是将两个输入值相乘。</p>
<p>P5703</p>
<pre><code>package main

import "fmt"

func main() {
    var x, n int
    fmt.Scan(&amp;x, &amp;n)

    fmt.Println(x*n)
}
</code></pre>
<hr />
<h3>💡 <strong>选择结构</strong></h3>
<p><strong>选择结构是程序根据不同条件，选择性地执行不同代码的结构</strong>。这种结构通过条件语句实现。Go 中有两种条件语句。</p>
<p>条件语句都是可以嵌套的。嵌套就是在条件语句中再写一个条件语句。</p>
<h4><code>if ... else ...</code></h4>
<p><code>if ... else ...</code> 的用法是，在 <code>if</code> 后写一个 <strong>布尔表达式</strong> [^1]，如果结果为 <code>true</code>，就执行 <code>if</code> 后的语句，否则就执行 <code>else</code> 后的语句。</p>
<p>⚠ <strong>注意</strong>：<code>else</code> 并不是必须的。如果没有 <code>else</code> ，那么 <code>if</code> 在布尔表达式为 <code>true</code> 时，其后紧跟的语句块执行，如果为 <code>false</code> 则不执行。</p>
<p>两段 <code>if ... else ...</code> 语句可以连用，形成的 <code>else if ...</code> 结构与 Python 中的 <code>elif</code> 类似。</p>
<p>这是最常用的条件语句。</p>
<p>我们以洛谷 <a href="https://www.luogu.com.cn/problem/P5714">P5714</a> 为例题，看看 <code>if ... else ...</code> 怎么实际应用。</p>
<p>解法</p>
<pre><code>package main

import "fmt"

func main() {
    var m, h float64 // 方便保留有效数字的运算
    fmt.Scan(&amp;m, &amp;h)

    bmi := m / (h * h)
    if bmi &lt; 18.5 {
        fmt.Println("Underweight")
    } else if bmi &gt;= 18.5 &amp;&amp; bmi &lt; 24 {
        fmt.Println("Normal")
    } else {
        fmt.Printf("%.6g\n", bmi) // %.6g 表示保留小数的 6 位有效数字，%.5g 就是保留 5 位有效数字，以此类推
        fmt.Println("Overweight")
    }
}
</code></pre>
<p>结果</p>
<pre><code>IN                  | OUT
--------------------+-----------------------------
70 1.72             | Normal
--------------------+-----------------------------
100 1.68            | 35.4308
                    | Overweight
</code></pre>
<p><code>if</code> 可以用 <strong>关系运算符</strong> 和 <strong>逻辑运算符</strong> [^2] 来写布尔表达式。例如，<code>else if bmi &gt;= 18.5 &amp;&amp; bmi &lt; 24</code> 转换成自然语言就是“<code>bmi</code> 大于等于 18.5 且 <code>bmi</code> 小于 24”。<code>&gt;=</code> 和 <code>&lt;</code> 是关系运算符，<code>&amp;&amp;</code> 是逻辑运算符</p>
<p>其余的关系运算符和逻辑运算符如下所示。</p>
<pre><code>package main

import "fmt"

func main() {
    a, b := 6, 5

    fmt.Printf("a: %d\n", a)
    fmt.Printf("b: %d\n", b)

    // 关系运算符
    fmt.Printf("a 是否等于 b？%t\n", a == b) // 与 := 和 = 区分开
    fmt.Printf("a 是否大于 b？%t\n", a &gt; b)
    fmt.Printf("a 是否小于 b？%t\n", a &lt; b)
    fmt.Printf("a 是否不等于 b？%t\n", a != b)
    fmt.Printf("a 是否大于等于 b？%t\n", a &gt;= b)
    fmt.Printf("a 是否小于等于 b？%t\n", a &lt;= b)

    // 逻辑运算符
    fmt.Printf("a 大于 b 而且 a 小于 10？%t\n", a &gt; b &amp;&amp; a &lt; 10)      // &amp;&amp; 表示和（AND）运算
    fmt.Printf("a 小于 b 或者 b 不等于 0？%t\n", a &lt; b || b != 0)      // || 表示或（OR）运算
    fmt.Printf("a 小于等于 b 或者 a 大于等于 10？%t\n", !(a &gt; b &amp;&amp; a &lt; 10)) // ! 表示非（NOT）运算，它后面的布尔值都会被反转
}

</code></pre>
<p>输出</p>
<pre><code>a: 6
b: 5
a 是否等于 b？false
a 是否大于 b？true
a 是否小于 b？false
a 是否不等于 b？true
a 是否大于等于 b？true
a 是否小于等于 b？false
a 大于 b 而且 a 小于 10？true
a 小于 b 或者 b 不等于 0？true
a 小于等于 b 或者 a 大于等于 10？false
</code></pre>
<p>这里涉及一个逻辑的小知识：设有两个布尔值 <code>A</code> 和 <code>B</code>，根据德摩根定律（De Morgan's laws，→ <a href="https://www.qiuwenbaike.cn/wiki/%E5%BE%B7%E6%91%A9%E6%A0%B9%E5%AE%9A%E5%BE%8B">德摩根定律 - 求闻百科</a>），<code>NOT(A OR B)</code> 等价于 <code>NOT A AND NOT B</code>，<code>NOT(A AND B)</code> 等价于 <code>NOT A OR NOT B</code>。表现在代码上，如下所示。</p>
<pre><code>!(A || B) == !A &amp;&amp; !B
!(A &amp;&amp; B) == !A || !B
</code></pre>
<h4><code>switch ... case ...</code></h4>
<p><code>switch ... case ...</code> 的用法是，在 <code>switch</code> 后接上一个变量，这个变量的 <a href="https://karlbaey.top/articles/program-design-preparation-episode-o/#go-%E6%95%B0%E6%8D%AE%E7%B1%BB%E5%9E%8B">数据类型</a> 可以是 <code>int</code>、<code>string</code> 或是 <code>bool</code> 等，表示待 <strong>匹配</strong>（match）的数据。<strong>它尝试匹配每一个 <code>case</code>，匹配成功即执行该 <code>case</code> 的语句。</strong></p>
<p>每一个 <code>case</code> 语句后接的表达式的数据类型 <strong>必须</strong> 和 <code>switch</code> 一致，而且 <code>case</code> 匹配的数据不能够重复。</p>
<p>每一个 <code>case</code> 会在最后默认加上一个 <code>break</code> [^3]。</p>
<pre><code>package main

import "fmt"

func main() {
    var a int
    fmt.Scan(&amp;a)

    switch a {
    case 1, 0: // case 可以匹配多个数据
        fmt.Println("Monday")
    case 2:
        fmt.Println("Tuesday")
    case 3:
        fmt.Println("Wednesday")
        if a &gt; 2 { // 此时做判断，如果 a 大于 2 立刻跳出匹配（必定为 true）
            break
        }
        fallthrough // fallthrough 是多余的，因为不可能执行到这一条语句
    case 4:
        fmt.Println("Thursday")
    case 5:
        fmt.Println("Friday")
        fallthrough // 如果 a 等于 5，就紧接着执行下一条 case 6
    case 6:
        fmt.Println("Saturday")
    case 7:
        fmt.Println("Sunday")
    default: // a 不在 0 到 7 之间
        fmt.Println("Beyond the range of a week.")
    }
}
</code></pre>
<p>不同的输入会导致不同的输出。</p>
<pre><code>IN                  | OUT
--------------------+-----------------------------
0                   | Monday
--------------------+-----------------------------
1                   | Monday
--------------------+-----------------------------
3                   | Wednesday
--------------------+-----------------------------
5                   | Friday
                    | Saturday
--------------------+-----------------------------
10                  | Beyond the range of a week.
--------------------+-----------------------------
</code></pre>
<p>在 <code>switch ... case ...</code>  中有两个关键字：<code>fallthrough</code> 和 <code>default</code>。</p>
<ul>
<li><code>fallthrough</code> 表示在当前的 <code>case</code> 执行完毕后 <strong>强制执行下一个</strong> <code>case</code>，无论下一个 <code>case</code> 的表达式是否为 <code>true</code>。<code>fallthrough</code> 必须紧紧贴着下一个 <code>case</code> 写，而且不能放在最后一个 <code>case</code> 中。</li>
<li><strong><code>default</code> 的意思是，所有的 <code>case</code> 都匹配失败时，默认执行的语句。</strong> 例如上面的输入中，输入 <code>10</code>，就执行了 <code>default</code> 中的语句，输出 <code>Beyond the range of a week.</code>。<strong>无论 <code>default</code> 放在哪里，它一定最后执行</strong>。</li>
</ul>
<p><code>switch ... case ...</code> 在处理情况较为复杂的分支时具有优越性，因为 <code>case</code> 可以一次匹配多个条件，有效地增强了代码可读性。但在 <code>case</code> 数量较小时，它们的性能差异并不明显。</p>
<p>例如 <a href="https://leetcode.cn/problems/maximum-number-of-vowels-in-a-substring-of-given-length/description/">力扣 1456. 定长子串中元音的最大数目</a>，它的解法如下。</p>
<pre><code>func maxVowels(s string, k int) int {
    ans := 0
    left := 0
    now := 0
    
    // 这里实际上是在维护一个长为 k 的队列（queue），关于队列的知识会在数据结构中说明
    for right := 0; right &lt; len(s); right++ {
        switch s[right] {
        case 'a', 'e', 'i', 'o', 'u': // 匹配元音
            now++
        }

        if right-left+1 &lt; k {
            continue
        }

        if now &gt; ans {
            ans = now
        }
        switch s[left] {
        case 'a', 'e', 'i', 'o', 'u': // 匹配元音
            now--
        }

        left++
    }
    return ans
}
</code></pre>
<p>这里运用了 <code>for</code> 循环，它是下一小节 <strong>循环结构</strong> 的主角。</p>
<hr />
<h3>💡 <strong>循环结构</strong></h3>
<p>Go 中只有一种循环：<code>for</code> 循环。<code>for</code> 循环有四种形式。</p>
<h4>三表达式循环</h4>
<p>此时 <code>for</code> 循环有三个部分，分别是 <strong>初始化语句</strong>、<strong>循环条件</strong> 和 <strong>后置语句</strong> [^4]，它们使用分号 <code>;</code> 分隔。</p>
<p>例如我们希望从 1 输出到 10，就可以使用这种形式的 <code>for</code> 循环。</p>
<pre><code>package main

import "fmt"

func main() {
    for i := 1; i &lt;= 10; i++ {
        fmt.Printf("%d ", i)
    }
}
</code></pre>
<p>输出</p>
<pre><code>1 2 3 4 5 6 7 8 9 10
</code></pre>
<p>后置语句通常使用 <strong>循环变量</strong> 搭配 <strong>自增自减运算符</strong>。在上面的循环中，<code>i</code> 是循环变量；<code>++</code> 是自增运算符，表示在这一次循环结束后将 <code>i</code> +1。自减运算符是 <code>--</code>，表示将当前变量 -1。</p>
<p>以 <a href="https://www.luogu.com.cn/problem/P5705">洛谷 P5705 【深基 2.例 7】数字反转</a> 为例。这一题的目标是将带小数点的数字反转，因此选用字符串方便我们处理。</p>
<p>解法</p>
<pre><code>package main

import "fmt"

func main() {
    var s string
    fmt.Scan(&amp;s)
    
    for i := len(s) - 1; i &gt;= 0; i-- { // 使用自减运算符反向处理输入
        fmt.Printf("%s", string(s[i]))
    }
    fmt.Print("\n") // 换行准备下一轮输出
}
</code></pre>
<p>另一种应用三表达式循环的题目是 <a href="https://www.luogu.com.cn/problem/P1424">洛谷 P1424 小鱼的航程（改进版）</a>。</p>
<p>在这一题，我们需要从 <strong>题目给出的开始天数开始遍历</strong>，因为在开始天数之前小鱼都没有游泳。</p>
<p>然后，判断这一天是不是星期六或星期天。如果是的话，不做任何操作；如果不是，游泳里程数加 250（单位：km）。</p>
<p>判断天数使用取余运算 <code>%</code> [^5]，如果当前天数除以 7 的余数是 6 或 0，说明这一天是周末。</p>
<p>P1424 解法</p>
<pre><code>package main

import "fmt"

func main() {
    var x, n, swim int
    fmt.Scan(&amp;x, &amp;n)

    for i := x; i &lt; x+n; i++ { // 从星期 x 开始，往后数 n 天
        switch i % 7 {
        case 1, 2, 3, 4, 5: // 星期一到星期五
            swim += 250
        }
    }

    fmt.Println(swim)
}
</code></pre>
<h4>条件循环</h4>
<p>此时 <code>for</code> 循环只有一个部分：<strong>循环条件</strong>。当循环条件是 <code>true</code> 时，循环继续；循环条件是 <code>false</code> 时，不再循环。</p>
<pre><code>package main

import "fmt"

func main() {
    num := 10
    for num &gt; 0 { // 这里的 num &gt; 0 就是循环条件
        fmt.Printf("%d ", num)
        num--
    }
}

</code></pre>
<p>输出</p>
<pre><code>10 9 8 7 6 5 4 3 2 1
</code></pre>
<h4>无限循环</h4>
<p>无限循环只使用一个 <code>for</code>，类似 C++ 的 <code>while(true)</code> 和 Python 的 <code>while True</code>。</p>
<pre><code>package main

import "fmt"

func main() {
    num := 10
    for {
        fmt.Printf("%d ", num)
        num--
    }
}
</code></pre>
<p>输出</p>
<pre><code>10 9 8 7 6 5 4 3 2 1 0 -1 -2 -3 -4 -5 -6 -7 -8 -9...
</code></pre>
<p>⚠ 注意！<strong>无限循环一定要包含跳出循环的条件</strong>！否则程序就失去了原来的作用。</p>
<h4>迭代循环</h4>
<p>因为这种循环依赖于关键字 <code>range</code>，因此也叫 <strong>for-range 循环</strong>。这种循环专门用于遍历 [^6] 数组、切片或字符串等。</p>
<p>关键字 <code>range</code> 可以生成一系列整数。例如，<code>for i := range n</code> 表示从 0 到 <code>n-1</code>.</p>
<pre><code>package main

import "fmt"

func main() {
    b := [3]int{65, 12, 345} // 数组
    c := make([]int, 10)     // 空切片
    d := map[string]int{"Karlbaey": 255, "65": 6512345}

    for a := range 10 { // 从 0 到 9
        fmt.Printf("%d ", a)
    }
    fmt.Print("\n")

    for idx, element := range b {
        fmt.Printf("数组的第 %d 位元素是 %d", idx+1, element)
        fmt.Print("\n")
    }

    for _, element := range c { // 用销毁变量 _ 销毁索引
        fmt.Printf("%d ", element)
        fmt.Print("\n")
    }

    for key := range d { // 如果只有一个循环变量，那么优先遍历键
        fmt.Printf("%s ", key)
    }
    fmt.Print("\n")
}
</code></pre>
<p>输出</p>
<pre><code>数组的第 1 位元素是 65
数组的第 2 位元素是 12
数组的第 3 位元素是 345
0
0
0
0
0
0
0
0
0
0
Karlbaey 65
</code></pre>
<p><code>range</code> 关键字也可以 <strong>只输出一个值</strong>。如果是数组或切片，这个值就是 <strong>索引</strong>；如果是映射，这个值就是 <strong>键</strong>。</p>
<hr />
<p>无论是哪种形式的循环，它们都能用两个语句打断循环：<code>break</code>、<code>continue</code>。</p>
<pre><code>package main

import "fmt"

func main() {
    // break 就是跳出循环。一旦执行到 break 这个循环就此结束，继续执行循环下方的代码。
    for {
        fmt.Println("loop")
        break // 事实上这个循环只循环了一次
    }
    
    // continue 就是开始下一次循环
    for n := 0; n &lt;= 5; n++ {
        if n%2 == 0 {
            continue // n 是偶数时就执行下一次循环，下方的输出不会执行
        }
        fmt.Println(n)
    }
}
</code></pre>
<p>输出</p>
<pre><code>loop
1
3
5
</code></pre>
<hr />
<p>循环可以嵌套，以 <a href="https://www.luogu.com.cn/problem/P5721">洛谷 P5721 【深基 4.例 6】数字直角三角形</a> 为例。</p>
<p>题目中要求输出的三角形从第二行开始，每一行都比上一行长度少 2，也就是一个数字的长度。</p>
<p>所以在第一行的输出长度为 <code>2*n</code>，每一行递减。我们可以通过记录一个变量 <code>num</code> 来确定当前应该输出的值，再用一个双层循环确定这一行输出数字的数量。</p>
<pre><code>package main

import "fmt"

func main() {
    var n int
    fmt.Scan(&amp;n)
    num := 1
    
    for i := range n {
        for j := 0; j &lt; n-i; j++ { // i 就是当前行减少输出的数量
            if num &lt; 10 {
                fmt.Printf("0%d", num)
            } else {
                fmt.Printf("%d", num)
            }
            num++
        }
        fmt.Print("\n")
    }
}
</code></pre>
<p>这样输出的三角形的顶角在左上角，如果要输出顶角在右上角、左下角以及右下角的三角形，使用下列代码即可。</p>
<p>无论顶角在哪个方向，一定是 <strong>外层循环控制行数，内层循环控制列数。</strong></p>
<p>右上角</p>
<pre><code>package main

import "fmt"

func main() {
    var n int
    fmt.Scan(&amp;n)
    num := 1

    for i := range n {
        for range i {
            fmt.Print("  ") // 随着 i 增加，在每一行前面补上 i*2 个空格
        }
        for j := 0; j &lt; n-i; j++ { // n-i 是当前输出的数字个数
            if num &lt; 10 {
                fmt.Printf("0%d", num)
            } else {
                fmt.Printf("%d", num)
            }
            num++
        }
        fmt.Print("\n")
    }
}
</code></pre>
<p>左下角</p>
<pre><code>package main

import "fmt"

func main() {
    var n int
    fmt.Scan(&amp;n)
    num := 1

    for i := 1; i &lt;= n; i++ { // 从 1 开始循环是为了防空行
        for range i { // 如果 i == 0，那么上面这个循环内的语句都不会执行
            if num &lt; 10 {
                fmt.Printf("0%d", num)
            } else {
                fmt.Printf("%d", num)
            }
            num++
        }
        fmt.Print("\n") 
    }
}
</code></pre>
<p>右下角</p>
<pre><code>package main

import "fmt"

func main() {
    var n int
    fmt.Scan(&amp;n)
    num := 1

    for i := 1; i &lt;= n; i++ { // 防空行
        for range n - i {
            fmt.Print("  ")
        }
        for range i {
            if num &lt; 10 {
                fmt.Printf("0%d", num)
            } else {
                fmt.Printf("%d", num)
            }
            num++
        }
        fmt.Print("\n")
    }
}
</code></pre>
<p>金字塔</p>
<pre><code>package main

import "fmt"

func main() {
    var n int
    fmt.Scan(&amp;n)
    num := 1

    for i := 1; i &lt;= n; i++ { // 防空行
        for range n - i {
            fmt.Print(" ") // 只要改为一个空格即可
        }
        for range i {
            if num &lt; 10 {
                fmt.Printf("0%d", num)
            } else {
                fmt.Printf("%d", num)
            }
            num++
        }
        fmt.Print("\n")
    }
}
</code></pre>
<p>输入</p>
<pre><code>13
</code></pre>
<p>输出</p>
<pre><code>            01
           0203
          040506
         07080910
        1112131415
       161718192021
      22232425262728
     2930313233343536
    373839404142434445
   46474849505152535455
  5657585960616263646566
 676869707172737475767778
79808182838485868788899091
</code></pre>
<hr />
<p>到了这里，你就可以开始刷程序设计题了，全部可能需要用的语句都在这里做了教程。下一期教程会开始谈数组以及字符串，随后就可以开始学习栈、队列、链表还有堆之类的数据结构了。</p>
<p>一定要自己打一次代码，理解程序为什么这样写。学习程序设计与学习数学非常像，一定要亲手写一次知识才是自己的。</p>
<p>🎉 撒花 🎉</p>
<p>[^1]: 布尔表达式（Boolean expression）是一段代码声明，它只有 <code>true</code>（真）和 <code>false</code>（假）两个取值。最简单的布尔表达式是等式（equality），这种布尔表达式用来测试一个值是否与另一个值相同。
[^2]: 关系运算符是用于比较两个值关系的运算符，关系运算的结果是布尔值；逻辑运算符是合并布尔表达式的运算符，逻辑运算的结果仍然是布尔值。
[^3]: <code>break</code> 表示强制跳出循环或匹配，<strong>此后的程序语句不再执行</strong>。在下文的 <code>for</code> 循环也会使用到这个语句。
[^4]: 后置语句也叫<strong>迭代语句</strong>。<strong>迭代</strong>的意思是，<strong>不断重复某个过程，每个过程都会有修改或优化。</strong>
[^5]: 取余也可以用 $ \textrm{mod} $ 表示，与程序设计中的 <code>%</code> 是一样的。
[^6]: 遍历（traversal）的意思是，<strong>按照一定的顺序依次访问一系列数据中的每个元素，确保每个元素都被访问一次且仅一次</strong>。</p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2025-08-21T13:51:39.362Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[程序设计#0 - 前期准备]]></title>
        <id>https://re.karlbaey.top/articles/program-design-preparation-episode-o/</id>
        <link href="https://re.karlbaey.top/articles/program-design-preparation-episode-o/"/>
        <updated>2025-08-20T07:06:11.564Z</updated>
        <summary type="html"><![CDATA[叠甲：这一篇短文与其说是教程，更像是笔记，所以可能多有缺漏或不严谨之处，尚祈见谅。我给出的方法不是最好的，但是一定是最适合新手实践的，这也是...]]></summary>
        <content type="html"><![CDATA[<p><strong>叠甲：</strong> 这一篇短文与其说是教程，更像是笔记，所以可能多有缺漏或不严谨之处，尚祈见谅。我给出的方法不是最好的，但是一定是最适合新手实践的，这也是我写这篇教程的初衷。这系列教程是程序设计的教程，包括流程处理、数据类型、数据结构、经典算法以及复杂度等内容，在保证内容准确的前提下能够让新手快速上手，不必忍受当前市面上教程中存在的专有名词过多、重理论而轻实践等问题。我保证教程的每个字都是我亲手打的，绝对不是人工智能生成内容。教程将保持使用 Go 语言，但不同语言的使用逻辑是一致的，仅仅在表达方式上有所不同，可借助官方文档或 AI 工具排查问题。一些计算机常用概念和互联网问题在 <a href="https://cn.bing.com">https://cn.bing.com</a> 都能找到解答，这里不会过多探讨。我使用的系统是 Windows 11，如果碰上 Linux 或 Mac 系统的问题，我很难给出有效的解决方法，但可以将你的问题具体地写出来，我尽量帮。欢迎批评，也欢迎提出探讨的新思路。</p>
<h2>Go 语言安装和 IDE 下载</h2>
<p>Go 语言安装包📦去这里下载 → <a href="https://go.dev/dl/">All releases - The Go Programming Language</a></p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/pictures/image-20250820104959717.png" alt="Go 语言下载" /></p>
<p>下载安装包（比如我使用 Windows 系统，就选择 <code>.windows-amd64.msi</code>），并运行安装。<strong>务必记住你的安装路径！</strong></p>
<p>⚠️<strong>配置环境变量</strong>。环境变量是系统的“指挥中心”，它需要知道 Go 的“基地”在哪。</p>
<ol>
<li><strong>打开设置窗口：</strong>
🔍 在 Windows 搜索栏直接输入“<strong>环境变量</strong>”，选择“<strong>编辑系统环境变量</strong>”。</li>
<li><strong>修改 <code>Path</code> 变量：</strong>
<ul>
<li>在“系统变量”区域找到 <code>Path</code>，点击“<strong>编辑</strong>”。</li>
<li>点击“<strong>新建</strong>”，然后填入你的 Go 安装路径加上 <code>\bin</code>。例如我的安装路径是 <code>E:\Programs\Golang</code> ，那就新增一条 <code>E:\Programs\Golang\bin</code>。</li>
<li>✅完成后一直选择“<strong>确定</strong>”关闭所有窗口。</li>
</ul>
</li>
<li><strong>确认 <code>GOROOT</code>（通常已自动设置）：</strong>
<ul>
<li>检查“系统变量”里是否有 <code>GOROOT</code>，其值就是你的安装路径。如果没有，就新建一个。</li>
</ul>
</li>
</ol>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/pictures/image-20250820105639085.png" alt="这是我的安装路径" /></p>
<p>设置好这些后，打开命令行（&lt;kbd&gt;Win&lt;/kbd&gt; + &lt;kbd&gt;R&lt;/kbd&gt; 输入 <code>cmd</code>），输入 <code>go version</code>，如果输出版本号就代表安装成功。✅</p>
<pre><code>C:\Users\Karlbaey&gt; go version
go version go1.24.5 windows/amd64
</code></pre>
<hr />
<p>IDE 推荐下载 VS Code → <a href="https://code.visualstudio.com/Download">Download Visual Studio Code - Mac, Linux, Windows</a>，汉化与美化教程很多，这里不做说明。然后打开一个 <code>.go</code> 后缀的文件，通常 VS Code 会提示是否安装 Go 语言插件，点击允许安装，这样 IDE 也配置好了。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/pictures/image-20250820111232767.png" alt="Go 语言插件外观" /></p>
<h2>Hello, World!</h2>
<p>💡<strong>开一个新文件夹</strong>，创建一个 <code>main.go</code> 文件，把下面的代码复制进去。</p>
<pre><code>package main // 当前的包名

import "fmt" // 导入 fmt 包（格式化输入输出）

func main() { // 主函数
    fmt.Println("Hello, World!") // 使用 fmt 包的 Println 函数，输出一行字符串
}
</code></pre>
<p>然后在你的文件夹中右键打开 cmd，输入 <code>go run main.go</code></p>
<pre><code>E:\Golang\algorithm\Helloworld&gt; go run main.go
Hello, World!
</code></pre>
<p>这样就算写好了一个程序。</p>
<p>如果你要把这个程序提交到洛谷之类的 OJ 平台[^1]，这样写是非常必要的，这样的话你的代码才能被<strong>编译</strong>成一个<strong>可执行文件</strong>（在 Windows 上是 <code>.exe</code> 后缀），编译的意思是<strong>将源代码转换为计算机可以执行的机器语言代码的过程</strong>，例如，我们写的 “Hello, World!” 程序就是源代码，在 cmd 执行 <code>go build main.go</code> 就是执行编译。 所以这里提供了一个模板，可以作为在 OJ 平台的输入输出模板。</p>
<pre><code>package main // 定义包名

import "fmt" // 导入必要包

func main() {
    var n int // 假设输入是一个整数，称作 n
    fmt.Scan(&amp;n) // 输入 n。前面的 &amp; 表示指向
    
    /* 程序 */
    
    fmt.Println(m) // 输出 m
}
</code></pre>
<p><code>main()</code> 是这个程序的<strong>主函数</strong>，这个程序所有会被执行的代码都在里面。Go 语言的函数使用 <code>func</code> 来定义。</p>
<h3>格式化输出</h3>
<p><code>Println</code> 很好用，但有时候我们想控制输出的格式，比如让小数只保留两位。这时就要用 <code>fmt.Printf</code> 了。它使用<strong>格式化动词</strong>来指定格式。</p>
<p>常用格式化动词：</p>
<ul>
<li><code>%v</code>：通用占位符，什么类型都能用（推荐初学者先用这个）</li>
<li><code>%T</code>：打印变量的<strong>类型</strong></li>
<li><code>%s</code>：打印字符串</li>
<li><code>%d</code>：打印整数</li>
<li><code>%f</code>：打印小数。可以用 <code>%.2f</code> 来控制保留两位小数。</li>
<li><code>%t</code>：打印布尔值</li>
<li><code>\n</code>：换行符（<code>Printf</code> 不会自动换行，经常需要手动加 <code>\n</code>）</li>
</ul>
<p>例如上文的 <code>n</code>，我们可以使用这两种方式输出。他们是完全等价的。</p>
<pre><code>fmt.Println(n)
fmt.Printf("%d\n", n)
</code></pre>
<hr />
<p>其中有一些表达式，例如 <code>var</code> 和 <code>&amp;n</code>，我们之后会说明用途和意思。</p>
<h2>Go 数据类型</h2>
<p>💡<strong>数据类型</strong>就是<strong>存储数据的方式</strong>，存放不同种类，不同大小的数据时使用合适的数据类型可以帮助程序优化性能。下面列出一些常用的数据类型以及它们的默认值。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/pictures/image-20250820114151940.png" alt="默认值列表" /></p>
<p>Go 语言的字符串使用 UTF-8 编码。这里有一个未提到的数据类型 <code>rune</code>，它能够存一个 UTF-8 字符（例如一个汉字）；而 <code>byte</code> 能存储一个 ASCII 字符。比如，我们这样写一个程序。</p>
<pre><code>package main

import "fmt"

func main() {
    var a byte = 'a'
    var b rune = '字'
    c := int(a)
    d := int(b)
    
    fmt.Println(c)
    fmt.Println(d)
}
</code></pre>
<p><code>var</code> 是定义变量的<strong>关键字</strong>，此处我们把变量 <code>a</code> 赋值为 <code>byte</code> 类型，实际值是 <code>'a'</code>。在定义 <code>b</code> 时使用了自动类型推断，也就是 <code>:=</code>（<code>:=</code> 只能在函数体内部，给局部变量使用；而 <code>var</code> 则没有限制），它能够将 <code>int(a)</code> 直接作为一个整数赋值给 <code>b</code>，而省去了输入数据类型的麻烦，所以这种定义变量的方式是最常用的。Go 不喜欢浪费，每个被定义的变量都必须被使用。</p>
<p>这段程序的输出是这样：[^2]</p>
<pre><code>97
23383
</code></pre>
<p>查 ASCII 码表，会发现 <code>a</code> 恰好对应 <code>97</code>。</p>
<p><img src="https://gcore.jsdelivr.net/gh/karlbaey/tutu@master/pictures/ascii-1-1.png" alt="ASCII 来源：维基百科" /></p>
<p>而“字”的 Unicode 码点是 <code>U+5B57</code>，转换成十进制就是 <code>23383</code>。</p>
<hr />
<p><code>bool</code>、<code>int</code>、<code>string</code> 以及上面提到的数据都是存储单个数据，Go 中还有存储多个数据的集合类型，例如<strong>数组（array）、切片（slice）和映射（map）</strong>。</p>
<p><strong>数组：</strong> 数组是固定长度的容器。就像是一排鸡蛋纸盒，只能容纳固定数量的鸡蛋，数组也是这样[^3]。<strong>数组只能容纳相同类型的数据</strong>。数组有索引，索引能告诉我们<strong>在数组中某个位置的元素</strong>。索引从 0 开始，<strong>数组的第一位元素索引为 0</strong>、第二位为 1……以此类推。</p>
<pre><code>package main

import "fmt"

func main() {
    a := [5]int{1, 2, 3, 4, 5}
    a[1] = 10 // 修改第二位元素

    fmt.Println(a)
}
</code></pre>
<p><strong>输出：</strong></p>
<pre><code>[1 10 3 4 5]
</code></pre>
<p>如果试图寻找不存在的索引，程序会在编译时抛出错误。基于上面的程序，可以这样查询。</p>
<pre><code>package main

import "fmt"

func main() {
    a := [5]int{1, 2, 3, 4, 5}

    fmt.Println(a[0]) // 查询第一个元素
    fmt.Println(a[5]) // 不存在
}
</code></pre>
<p>输出</p>
<pre><code># command-line-arguments
.\main.go:9:16: invalid argument: index 5 out of bounds [0:5]
</code></pre>
<hr />
<p><strong>切片：</strong> 切片是基于数组的可变序列，与 C++ 的 vector 类似。切片可以引用数组创建。</p>
<pre><code>package main

import "fmt"

func main() {
    a := [5]int{1, 2, 3, 4, 5}
    b := a[0:3]

    b[1] = 10
    b = append(b, 6) // 在切片 b 后新增元素 6
    b = append(b, 7)
    b = append(b, 8)

    fmt.Println(a)
    fmt.Println(b)
}
</code></pre>
<p>输出</p>
<pre><code>[1 10 3 6 7]
[1 10 3 6 7 8]
</code></pre>
<p>这时我们就发现了端倪，<strong>切片实际上是个引用类型</strong>。我们把原数组比作一幅画，切片在这里就是从画里框出了一部分，对框里内容的任何改变都会影响原来的画。反映到数组和切片操作上，那就是从数组引用而来的切片实际上会影响原数组。上面的代码中我们把切片 <code>b</code> （从 <code>a</code> 切片而来）的第二位改为 10，并往后连续接了 6、7、8，数组 <code>a</code> 同样发生了改变，但仍然保持长度是 5。</p>
<p>如果不希望切片的改变影响到原数组，可以用 Go 内置的 <code>make()</code> 和 <code>copy()</code> 函数。</p>
<pre><code>package main

import "fmt"

func main() {
    a := [5]int{1, 2, 3, 4, 5}
    b := make([]int, len(a)) // 生成一个长度为 5（a 的长度）的切片
    copy(b, a[0:3]) // 将 a 第一、二、三位的元素复制进 b，其余元素保持为 0

    b[1] = 10
    b = append(b, 6)
    b = append(b, 7)
    b = append(b, 8)

    fmt.Println(a)
    fmt.Println(b)
}
</code></pre>
<p>输出</p>
<pre><code>[1 2 3 4 5]
[1 10 3 0 0 6 7 8]
</code></pre>
<p><strong>注意：</strong><code>copy()</code> 函数是<strong>短切片优先</strong>的。具体操作：</p>
<pre><code>package main

import "fmt"

func main() {
    slice1 := []int{1, 2, 3} // 短切片
    slice2 := make([]int, 10) // 长的空切片
    copy(slice2, slice1) // 将短切片复制给长切片

    fmt.Printf("切片 2：%v\n", slice2)

    slice3 := []int{10, 11, 12, 13, 14} // 长切片
    slice4 := make([]int, 2) // 短的空切片

    copy(slice4, slice3)

    fmt.Printf("切片 4：%v", slice4)
}
</code></pre>
<p>输出</p>
<pre><code>切片 2：[1 2 3 0 0 0 0 0 0 0]
切片 4：[10 11]
</code></pre>
<hr />
<p>💡<strong>映射：</strong> 它使用键值对存储数据，类似 Python 的字典。映射的操作有很多。</p>
<pre><code>package main

import "fmt"

func main() {
    var emptymap map[byte]int                          // 创建空映射
    fmt.Printf("emptymap 是空映射吗？%t\n", emptymap == nil) // nil 表示空

    a := make(map[string]int) // 最常用的初始化方式
    // 也可以直接初始化
    name := map[string]int{
        "Jerry": 256,
        "65":    6512345,
    }

    // 赋值操作
    a["A"] = 1
    a["B"] = 2
    fmt.Println(a)

    // 查询操作
    a_A := a["A"]
    a_C := a["C"] // 查询不存在的值
    fmt.Printf("a 中 A 对应的值是：%d\n", a_A)
    fmt.Printf("a 中 C 对应的值是：%d\n", a_C) // 输出 0（int 默认值）

    // 删除操作
    delete(a, "A")
    fmt.Println(a)

    // 获取值
    sixtyFive := name["65"]
    fmt.Printf("65 的全称是：%d\n", sixtyFive)
}
</code></pre>
<p>输出</p>
<pre><code>emptymap 是空映射吗？true
map[A:1 B:2]
a 中 A 对应的值是：1
a 中 C 对应的值是：0
map[B:2]
65 的全称是：6512345
</code></pre>
<p>另外，如果要判断映射中是否存在某个键，可以用这种表达式。这叫<strong>逗号 ok 模式</strong>。</p>
<pre><code>// ...
_, ok := name["Karlbaey"]
fmt.Printf("在映射 name 中，键“Karlbaey”存在吗？%t", ok)
</code></pre>
<p>输出</p>
<pre><code>在映射 name 中，键“Karlbaey”存在吗？false
</code></pre>
<p>这在之后的流程控制语句中非常常见。</p>
<p>这样做的原因是，当向映射查询某个键时，返回值会包含这个键对应的值和这个值是否已经提前赋值。因为我们不关心值具体是什么，所以用一个销毁变量 <code>_</code> 存储值[^4]。</p>
<hr />
<p>🎉那么，到这里，Go 的基础操作都说清楚了，下一步（如果有的话）会接着学习流程控制语句，函数体与结构体。然后就可以开始学习数据结构和经典算法了。</p>
<p>[^1]: OJ 是 Online Judge 的缩写，意思是<strong>在线评测系统</strong>，用来测试你的程序是否正确。
[^2]: 事实上，代码中定义 <code>c</code> 和 <code>d</code> 的代码是多余的，<code>byte</code> 和 <code>rune</code> 本身就以 int 存储，所以直接输出 <code>a</code> 和 <code>b</code> 得到的结果跟 <code>c</code> 和 <code>d</code> 一致。这里是为了理解自动类型推断才这样做的。
[^3]: <strong>注意：数组不能装鸡蛋。</strong>
[^4]: 事实上，<code>_</code> 的正式名称是<strong>只写变量</strong>，但在这里因为给它的值都无法读取，所以我称它为“销毁变量”。</p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2025-08-20T07:06:11.564Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[晚安，卡尔白]]></title>
        <id>https://re.karlbaey.top/articles/good-night-miss-karlbaey/</id>
        <link href="https://re.karlbaey.top/articles/good-night-miss-karlbaey/"/>
        <updated>2025-07-05T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[我出生在二零零九年六月二十六日。很难想象，仅仅是过了十六年，我就从一个胚胎变成了今天坐在显示器前，给自己埋怨一切的手稿做校对的<span s...]]></summary>
        <content type="html"><![CDATA[<h2>Leading</h2>
<p>我出生在二零零九年六月二十六日。很难想象，仅仅是过了十六年，我就从一个胚胎变成了今天坐在显示器前，给自己埋怨一切的手稿做校对的&lt;span style="background-color:lightgray"&gt;人&lt;/span&gt;&lt;sup&gt;&lt;span style="color:blue"&gt;[来源请求]&lt;/span&gt;&lt;/sup&gt;。想当年我还是个个子才过一米二的小屁孩，就会每天翻着日历，期待每年生日来到的那一天。但是现在，我很讨厌过生日，不为什么，就是讨厌。我家里过的是阴历年生日，在两个星期还是三个星期前，因为星期一到五我会被禁锢在高中里，虑不得脱，于是家里人就在那之前的一个星期六，买了个不大不小，圆圆的蛋糕——毕竟我对邀请其他人没兴趣，大了也是浪费。我没有仔细看那蛋糕，因为我讨厌吃甜的。机械地配合着拍了张照，以示今年是有过生日的，而不是被埋在了垃圾堆里。我更期待后者。虽然第二天是星期五，发了条 QQ 空间宣告这事，但我的心里始终是抱着“哎呀过都过了这也不算什么记录生日吧”的心态。</p>
<p>我拒绝了很多生日礼物，但是有两个例外。其一是认识很久的好朋友给我发了个内装有八十八元人民币的微信红包，这我收下了，如果不收的话会让朋友担心我是不是出了什么事，他是最了解我的人，这程度甚至超过了过去十几年同住一个屋檐下的双亲。这也不奇怪，不能跟双亲或是关系没那么紧密的人说的事，我都能拿出来跟他说，对他的敬佩我无法用文字写出来，可惜的地方就是地球上再也找不到第二个像他一样有耐心陪我聊天打屁的人了。其二就是这篇写了接近一个月的中篇小说。从二零二五年五月二十八日动笔，一直慢慢悠悠拖到了同年六月二十八日才告结束。就在我校对的时候，我实在是忍不住吐槽自己写的都是什么反人类的玩意，可是一行一行读下来，我突然又很可怜起自己，一定是因为受到了难以想象的委屈才能这么从容地写出这些词句吧，尽管我记性差，已经忘记了究竟是什么事让我这样难过，却很能理解为什么自己能写出来这些读起来就觉得如鲠在喉的文字。明明一年前的今天，我还是个豪情万丈，未来一片光明的准高中生。</p>
<p>安排人物名字时比较费劲，因为我不喜欢在称呼这方面多下功夫，但是为里面唯一一个女角色选了一个相对常见的名字。本来是包含有很大的恶趣味的，但是现在还是把本来的意思删了，只保留下这个名字。</p>
<p>这篇中篇小说献给我的朋友 YXY，他是唯一从头到尾都支持我的人。</p>
<h2>正文部分</h2>
<p>我睁开眼睛。</p>
<p>这是我第九千一百二十三次迎接晨光。现在是夏至之前，太阳早就高高地挂起了。我并非自然醒，在我的床头柜上，手机正发出嘈杂的闹铃声，那“嘀嘀”的循环响声把我惹得格外恼火，但终于是无可奈何，起身离开了床。</p>
<p>掐灭闹钟的同时，我把手机放在了外套口袋里。随后，换衣服，洗漱，收拾零散物品，这些动作我是一气呵成的，甚至没有眼镜帮助我——我也不需要，这样的流程只要几十遍下来，人就会完全习惯的。抬头看时钟，还好来得及，我松了口气。</p>
<p>我是个普通的打工人，因为现在还算年轻，找工作还算容易，但还是要每天打起十二分的精神，几乎是拼着每一个细胞，把整个人都扔进工作的汪洋里，就像是一块不甚必要的齿轮，随时都可以被抛弃。就在参加工作之前，我还满打满算接受了 16 年的教育。说实话，学校里的生活跟社会上比起来，并没有什么差别。一边是闲人多——人闲事就多，折磨得人相当心力交瘁；一边更多的是险恶人心，恕我没办法用文字表达出有多么险恶，相信在帮卖了自己的人数过几次钱之后，就会很有感悟的。所以我觉得学校的作用相比起教书育人，更多的是控制社会不稳定分子，并且早点给压力，不至于工作后一点就炸。不过大概是幸存者偏差吧，我看到更多的人并不是忆苦思甜，倒更像是完全地忘记了少年时的岁月，也把眼前的日子当水流过一样不甚惜，愈发像具尸体了。</p>
<p>键政归键政，班还是要上。我随手抓起钥匙之类的零碎物品揣进口袋，匆匆忙忙离开了租住的房子。手机显示的时间将近 7 点，我心里暗叫不妙。虽然我在的地方确实是大城市，也有地下铁路，但人一多起来，没有一个小时必然到不了公司。于是我一路小跑着穿过一条条街道，它们中的一些我闭着眼都能走对——事实上我也的确是这样做的。困倦永远是无解的命题。</p>
<p>然后顺着人流涌进地铁站，由于我住的地方快接近地铁末站，人也不怎么多，所以不会拥挤，不过也仅限于刚进地铁的那几站路了。只要等上五站，就会挤进一大群沙丁鱼。我从此对那个站的人印象很差，很无来由，但确是实话，没人喜欢一大早，眼睛还朦胧着的时候就被一群莫名其妙的人挤来挤去，简直像一缸污浊不堪的水槽里养的一群鱼。</p>
<p>跑进车厢，我选择了个靠车门近些的地方，靠着不锈钢杆子站着。早高峰的时候优先下车的位置不比座位好找，还是把座位留给有需要的人吧。站定了，我习惯性地环顾周围的人，猜测他们今天的生活和过往的人生。这是我每天为数不多的乐趣。</p>
<p>坐在我正对面的是个高中生模样的青年。白蓝色的高中校服并没有把他的身形鼓动出更多的青春朝气，相反，他的疲劳已经从他的骨骼中勇敢地冲出来，于是他的皮肤像被水浸润了一样，写满了苦涩。大概是走读生吧？我不由自主地猜测起来。想当初我也是很向往走读的，但无奈父母皆不入流，假如我从学校逃逸出来，他们也会义无反顾——大义灭亲地把我送回到这校园。在无数个远望灰暗夜空出神的日子，我不停地想象不被困住的生活，对比起脚踩的这片更灰暗的土地，却总也生不出一丝一毫所谓对母校的感恩——从来没有恩情，何谈感恩？是这样的。尽管接受过艾青的熏陶，清楚这颗行星上的人们对土地有多么热烈的感情，我也无法忍耐这样身心的折磨，而这全部是因为我脚下的土地。于是当浑浊一片的夜空碾压上我的头顶时，我的心里总是向往乌云后的银河和繁
星，但谁知道乌云的背后是什么呢？是更黑的夜空也未可知。我不由自主地微笑一下，好像那些星星只在半臂以外，都是我触手可得的。</p>
<p>高中生的头颅一顿一顿，几次左右甩着头以图打起精神，最后掐起了自己的大腿，让疼痛刺激昏睡的头脑。看起来真难受。我不忍心继续看他，就开始闭目养神。顺便掏出来我的旧 MP3，用那副磨损得失去原先白色的3.5mm有线耳机听起歌。地铁上无事可做时，我就喜欢听听歌。MP3 里的歌都是经过我五六年的筛选，真正喜欢的那些。只有当耳边重新中回响起早就老旧的旋律时，我才觉得以前的时间没有真的离开，那些回忆的话……谈不上有多让人心驰神往，只是为了让我觉得那些时间没有跑掉罢了。这是我观察自己的切口，一个从来愈合不上的切口。</p>
<p>我睡着了，我竟然睡着了？即使从以后的事情来看，现在睡着都是难以置信的。但我在梦里格外的安稳，丝毫不顾地铁车厢里开得冰凉的冷气从四面八方冲向我，那件单薄的外套起的作用还不如伊甸园里面的一片叶子，就是插图版《圣经》里画的那种。梦的景象看得我一头雾水：夜晚，头顶是圆圆的月亮，然后一个飞行的物体，大概是流星吧，穿过——其实是掠过——月球。往下看，就是地铁的到站铃声：</p>
<p>“空方北站——到了——请……”</p>
<p>电子女声拖着噪子的鸣声吵醒了我，我匆忙地离开车厢，继续在往公司的路上奔跑。我的背后，那高中生已经睡去了，我想回头看看，但是地铁已经先我一步驶走。“好吧。”我有点庆幸地自言自语，至少没有坐过站。刚做的梦早就抛在脑后了。</p>
<p>冲进公司大门，我把手机掏出来，在手里颠倒了两下。打卡成功的提示音让我觉得好比接受了启示，从头顶到脚底，没有一个细胞不是忽然清醒过来，感到一阵快感。当然，更现实的价值是，全勤奖又多了一分保障。</p>
<p>但那声音提示后，我整个人的神经又软下去了，整个人看着萎靡得像上个世纪的僵尸电影中被抽走阳气的人。我一边暗暗咒骂着这声音的制作者绝对不得好死，一边拖移着沉重的脚步。蜗行到座位边上，一屁股把壁个人投掷进座椅，可怜那椅子，它发出几声痛苦的呻吟后就不做声了、</p>
<p>头脑里的倦意还没被消除，眼前的显示器已经在我久矣习惯的行为形成的条件反射打亮了。我揉揉眼睛，张大嘴打了个不大不小的哈欠。刚想像往常一样，右键打开微软大战代码——我给 VSCode 起的绰号——开始一天辛苦的劳动，旁边的妹子跟我搭上话了：</p>
<p>“哇——七点五十九分三十六秒，小白你又破纪录了！”</p>
<p>她看上去欢欣雀跃，完全不像个有班可上的人，倒像是公司里哪个巨鳄家里的大小姐，学了点 Java 就迫不及待，吵闹着要来亲自开发。不过程序员们整天跟男的打交道，对于来个妹子积极性还是很高的，就这么让她加入了。看她实战能力还跟的上，就让她一直留在这里。资历算是最浅的，不过也管我叫小白就是了。</p>
<p>“少挖苦我了。”我无心跟她多纠缠，草草结束了话题。</p>
<p>最近的日子不算清闲，但也不像年关那样空气里密布着紧张。再怎么说，夏天都是最活跃的时候，也是这时候难搞的甲方会一点点多起来。忙着敲键盘，上下移动光标，按 Tab 补全，一个上午的时光很快被消磨走了。</p>
<p>十二点刚过，互相问候着午饭吃什么的交谈声就像流感一样扩散开。我也不例外。</p>
<p>“小白，你中午吃什么啊？”</p>
<p>又来了，我想。她……什么名字来着？居然记不住……每到这个点就来跟我探讨吃饭与可持续发展的哲学，也只有在这时候，我和她的交谈才是最多的。</p>
<p>“懒得吃了。”</p>
<p>这是实话。可能近来运气确实不好，键盘跟我作对般总是按不灵 V 键，一来二去，折磨得脾气剩不下多少了，更没心情谈吃不吃饭。我把旋转椅往后挪了挪，以给自己腾出点位置，同时转向右边，看她吃的什么。</p>
<p>“……”听了我的回答，想必她是不知道怎么向下接了。只好从背包里摸出一个保温饭盒。就在她把饭盒放在桌面上的一瞬间，随着“咔”一声，她的头顶上浮起了一个四位数字。</p>
<p>“？”我缓缓冒出一串疑问。看清楚了，白色的，冒着若有若无的光，“1067”。摘下眼镜，把眼睛揉舒服后再看，还是 1067。我没把眼镜戴上，左顾右盼发现众多程序员弟兄头顶上都有一个数字，从几百到上万不等，最大的是“32768”。我一头雾水，转头看回邻座妹子的那一刻，我忽然想起了她的名字叫&lt;ruby&gt;雾见双叶&lt;rt&gt;きりみ ふたば&lt;/rt&gt;&lt;/ruby&gt;<a href="%E5%92%8C%E5%88%B6%E6%B1%89%E5%AD%97%EF%BC%9A%E9%9C%A7%E8%A6%8B%E9%9B%99%E8%91%89%E3%80%82">^1</a>。</p>
<p>我套近乎般凑过去看她的饭盒：“双叶，你吃的什么呢。”</p>
<p>双叶很大度地把饭盒推到我面前：“自己看吧。”我于是真的观察起来。左半边是绿叶蔬菜，右半边是白饭。是右撇子，我心想。看了半天，竟没有闻到半点油味，肉更是见不到一星半点。我下意识问：</p>
<p>“吃这么少，肚子不难受吗？”</p>
<p>双叶脸上浮起淡淡的一笑，解释说：“中午吃太多的话，下午会犯困的。”她把落到眼前的长发向耳后撩去，那动作格外的流畅自然，竟让我品出一点心旷神怡。</p>
<p>我理解地点点头，她让我想起了另一个瘦弱不堪的朋友，不过双叶的体型正常多了，而且皮肤也雪白雪白的，比那朋友的脸色好了不少。在双叶舔干净饭盒后后，众多同事的外卖就一点空窗期都没有地接连到达，空气里充满了油腥味和煮过头蔬菜的死气沉沉的气味，还有盐、味精以及各种各样不知名调味料的咸味。这些味道让我怀念起了刚才饭盒里散发出来的，真正的饭香味。</p>
<p>“双叶，你早上几点醒的？要是自己做菜的话要很多时间吧。”</p>
<p>双叶把洗干净的饭盒重新装回背包里，才回答：“早上六点吧，不用很久的。饭可以提前一天准备，第二天早起一点就行了。反正我一个人住，用不了多久。”双叶看上去心不在焉。</p>
<p>我倒是对她敢直接暴露自己独居感到挺惊讶的，不过这念头过了不久就自己烟消云散了。看着双叶玩起了手机，我就不去理她，抬头发呆了。</p>
<p>下一次回过神来，是在下午五点五十五分，还有五分钟就到了名义上的下班时间。说是“名义上的”，只是刚入职一段时间会实行，像双叶就可以五分钟后头也不回地离开；像我一样在公司中稍微稳定下来的，大多要经历一次不早不晚，就在下班前的晚会，就像是高中最喜欢玩的那套温水煮青蛙。不花多少时间，可在离开公司前的那种欢乐会被一扫而空，剩下的心情就像是按时下班就是犯罪，不过，至少不用火急火烧地去赶他妈的末班地铁了。末班地铁挺可怕的，一路上只会碰上和我一样加班到过劳的人，我看他们的脸，中间的苦涩已经溢进车厢了。以前听开公共汽车的人说，一般会专门安排一条线路，从医院到殡仪馆。开这条线路人会时常换换，绝对不能让一个人开超过三个月，不然他整天看到的都是死、别和离，时间长了，他一整个人都会像土一样失去希望了。解决方案就是派他们去开妇幼保健院的那条线，来来往往的就都是挺着大肚子的孕妇，以及抱着婴儿的各色人等，但都无一例外地脸上洋溢着幸福。那是很有感染力的。我觉得开末班地铁的的朋友们也该有这种行遇，但是开地铁的……大概是自动程序吧。</p>
<p>果然，五点五十九分，部门主管幽灵一样摸进来，叫道：“来来来，几位男同胞，开每日例会！”主管的头顶飘着“21966”。办公室降了六摄氏度。仔细一看，原来是同事们的脸骤然冰冷导致的。双叶已经把背包拉上拉链，等着六点整的铃声。</p>
<p>“那好吧。”我自言自语。双叶应该还没转正，下班得早也是应该。现在行业内几乎饱和，但其实并不拒绝女同胞，所以双叶只要不出大的乱子，大约是不会有什么意外，安然度过职业生涯的。</p>
<p>部门的例会压根不交代什么，因为最近的用不着跟甲方斡旋。这次真踩狗屎运了，甲方挺好说话，几个擅交际的同事每次交涉回来者都是满面春风。这样的环境交代不出什么。</p>
<p>可就算什么内容也没有，也还是硬占了五分钟，我的头一点一点，要睡过去，像鸡啄米。所幸是安然熬到了结束而没睡着。双叶早就没了踪影，但显示器还亮着，我顺手给她 Alt+F4 关掉了。正打算回头离开公司，一个像门一样大的身影阻住了我：</p>
<p>“我问你，你——你跟双叶什么关系？”</p>
<p>连句称呼都不带，劈头盖脸就是一句质问，我感觉心里又苍白了一分。抬头端详他，面红耳赤，五官像是健身房一组器材，可以随意摆弄的。我想了半天不知道他是谁，就敷衍说：</p>
<p>“你觉得是什么？”</p>
<p>对方的脸又红了一点，喉管上下蠕动却凑不出个完整的字。可别舞憋了，我戏谑地想。趁他思考造句时，我扔开他搭在我左肩的手，绕开他到门口，回头反问他：</p>
<p>“先想清楚我叫什么名字再来吧！”</p>
<p>说完我头也不回地从大门溜走了。但更多的是心虚。</p>
<p>一走到街上我就被震撼了，简直是片数字的汪洋。跟我在办公室看见的并无二致，同样的浮动区间，同样散发着惨白的荧光。走到地铁站口，晚高峰带来的汹涌人潮把我想挤进地铁的梦想化为泡影。我心想早上明明人少得可怜，怎么现在就像蒲公英一样把空气都淹没得喘不过气来。我叹了下气，只能找个能站的地方稳稳站着，看各种各样的数字左右穿梭。</p>
<p>一个从未见过的数字吸引了我的注意：那是个“0”。这“0”的主人是个年轻女孩，与双叶的幼态脸不同，她看上去多了一点成熟。出于好奇，我目送着她从地铁站挤出来。在一片数字森林中穿行到一只斑马边——那是斑马线。她神色闪烁着不自然，像星中迎接阔别已久的节日前，人们焦急又无可奈何的耐心等待。绿灯亮起，一辆刹车报废的小轿车飞驶过来。很快的一瞬间，像电压不稳时电灯闪烁了一下，又或者只是我眼睛眨动一下的时间里，一声巨响破开空气，直冲我的耳膜。我得庆率光比声音走得快多了，我看着一个若有若无的影子飞起，无比轻盈，像鼓起的肥皂泡无声无息地要飞走，于是重力一扯，影子掉在地上，然后好久，我才听见像呜咽一样，沉闷的撞地声。确实，世界垮塌的时候，不是“轰”的一声，而是“嘘”的一声。</p>
<p>回过神来，那女孩脸朝下沉浸在暗红色的湖水里。我被另一阵人潮裹着靠近了现场。我心急如焚地要看她头顶的数字，那“0”不负重望地闪动了一下，然后溶进空气，飞走了，有点像刚刚正飞在空中的尸体。我被挤得只有脑袋能左右旋转，像那车头瘪下去一块的汽车疯转个不停的雨刮。有人掏出电话，拨通后大声
喊叫以图破开周围的嘈杂声；有人把手机后置摄像头朝着尸体与汽车做往复运动；有些胆大的靠近尸体想确认什么，但看那面积越来越大的暗红湖泊，终于是没敢迈出脚步。无一例外，每个人头顶都闪烁着一个数字，再想起刚才消失的“0”，我顿时觉得人群只是会走路说话的尸体。一阵反胃涌上咽管，我没有等到救护车，匆匆流进了地铁站。</p>
<p>人体内的血液有四到五升，被这样撞一下，想必留下的只够个一次性塑料杯装，能不能满也不可知。但更可怕的是，那数字好像是有确定的含义的，刚才路上的一切实在是让人印象深刻。我环顾四周，地铁站里的人依然来去匆匆，中间空出一块，像化疗病人头发日渐稀疏时的斑秃。他们也都顶着数字，在我的眼里，他们不是人了。反正只是时间问题，早晚并不显得那样重要。</p>
<p>我呼吸急促，在地铁上近乎呕吐出来，我怪罪到地铁身上，但那地铁实在是再平稳不过了。煎熬了一路回到家，我把空气反复地大口吸进又吐出去，只想平复一点几乎被握得爆炸的心情。</p>
<p>“好吧。”我想。我走进浴室，拧开水龙头，把水往脸上拍打。抬起头，隔着一层水雾，我也模糊能见自己的头顶有数字。我揉了揉眼睛，就像今天看双叶一样,然后我读到了，头顶的是“7”。</p>
<p>我先是愣住了，大概算了算这“7”有什么含义。既然掉到“0”人就会死，那这个“7”是什么？我没有勇气继续想，只觉得疲倦，被子一蒙头就堕入睡眠了。</p>
<p>第二天我依然匆忙，离开家时已经完全把数字的事扔到脑后了。看到双叶的脑袋顶上变成了“1066”，我被吓得说不出话来。走进厨所看镜子，我的头顶变成了“6”，往好处想，至少是弄清这数字运行的规则了。</p>
<p>回到座位，我注意到双叶听见了我的脚步，想回头排列组合中文，但她还没开口就被吓得紧闭嘴唇，眼睛睁得圆圆的盯着我。</p>
<p>“我很可怕吗？”这一句是明知故问。不会有人知道自己六天后就要死得不明不白时，还会露出友善而欢迎世界的笑容。</p>
<p>双叶没说话，只是急促地点点头，眼里流出不容易察觉的担忧。我憋着口气，在办公桌前干坐了一整天，只靠手留下的肌肉记忆敲着键盘，好让人觉得我没有摸鱼。</p>
<p>我忘记过了多久，只是浑浑噩噩地游离到家门口也不管还穿着外出的鞋子和风尘仆仆的身体，把整个人脸朝下地扔进厚厚的床垫里。我很开心没有在夏天把厚床垫换走，不然迎接我的就是一脸一身的乌青瘀肿了。运气再差一点，连眼睛都要一起葬送。</p>
<p>我心里忽然燃起一股无名火：“去他妈的人生。”我只敢把这句没有任何现实影响的话放在头脑里反复回响，好像这样就能摆脱掉现实。但在排山倒海的现实前，再响亮的号，再有力的证词，再刻骨铭心的雄辩，还不如一块苍白的废纸。于是我的头脑分成了两块，一块在头脑里大声踏步，痛骂着现实；另一块小心翼翼跟在身后，全然不顾脚步的噪声，用吓唬蚊子的声音轻轻劝解由躁动的那一半。从始至终我都没敢发出一点声音，因为这出租屋的隔音太差，风吹草动在这里并不成什么秘密。</p>
<p>我想起有时候我整天地待在屋子里，能听见楼下有个壮汉的声音向人推销保健品，只用现金交易。我能听他沾着唾沫点着钱，脸上喜不自胜的表情从未见到却无比栩栩如生，像那抗日电视里给东洋人点头哈腰之流。楼上是个年轻女人，我只照面过一两回，她的长相乱得能在雨水里解离掉，化起妆来反而脸上的每一寸皮肤棱角分明起来，像把自己打扮成罗马方尖碑。她的活计跟楼下的壮汉很像，不过变成了贩卖自己，有时只是白天，也能听见楼上为了生活面发出的快乐叫喊声，我戴着 3.5mm 耳机被这叫喊扰得想用一把刀刺穿楼顶，也怨恨耳机厂家为什么不生产 3.5mm 的子弹，好让我一枪穿了上面两人——有时候是三人，也有犬吠——的心脏，圆了他们心心相印的梦。</p>
<p>但我不想再夹在中间这层，上不去下不来。我翻了个面，直立起上身，听见楼下的叫卖声和楼上的……也算是叫卖声吧，一股邪气陡然涌出，我把一身的力气集中到嗓门：</p>
<p>“哇袄——”</p>
<p>这声喊叫把我自己吓着了。没持续多久我便耐不住地停下，喉咙里满是咳嗽的余韵。停下之后，世界都平静了，外面的路灯一闪一闪，要应答我的嘶吼。楼上楼下的叫卖费声停顿了十秒，又嘈杂地在我耳边响起，于是我再吼了一次。如此往复以至五次，终于是彻底没了动静，如果他们敢来线下调查一番，那我……我情不自禁地想到了厨房里从未搁上橱柜的菜刀，我有点害怕，但更多的是向往。都是死了一半的人了，何苦害怕这些东西？</p>
<p>我抹一把脸，满是眼泪。预期中的惩罚终于没有降临。我没有胜利的喜悦，只有心里的荒凉卷起一股又一股狂风。</p>
<p>门响了，我以为是他们中的一个气不过，来踹门。我用袖角擦干眼泪，走到门前准备迎战，但只是个送快递的。他扫了下纸盒上的条码，又急匆匆地溜走了。我看着手里的纸盒，头脑一片空白。我没有网购的习惯，更不会有人无聊到送炸弹给我，想了一会没想明白，只把纸盒子随手摆桌上，蒙头睡去了。</p>
<p>心里的狂怒并没有因为睡了一觉而消失一些。但我只觉得头晕晕，半个胸口都涨痛得让我想一拳锤爆心脏，但又想到还有五天可活，现在就死太不值当，终于作罢。挣扎着离开家，想到又要熬过几天的百无聊赖，又后悔起来没有去死的勇气，确实无奈。</p>
<p>我也注意到了，我开始有意识地回避镜子，哪怕是有反光作用的物体：公司大楼擦得快要消失的玻璃，熄灭的 PC 和手机屏幕，以至于恐惧起自己的眼镜。我想挨个地打碎它们。每当我冒出这种念头时，我也只能长长呼出口气，“算了吧”。那数字并没有因此而消去一星半点，反而因为我日渐增长的恐惧而愈加清晰起来。</p>
<p>以前读到无产阶级革命前的世界，看到世上百分之八十的人过着沉在水下的生活：苦但死期如何并不可知。现在作为剩余价值被压榨得分毫不利的我，自比起他们来。竟然又多了一份凄惨。我为想出这个类比而沾沾自喜，但很快就把它抛诸脑后——这实在不是值得高兴的事情。</p>
<p>看着死神在不远的将来向我招手呼喊，我的心里忽然生出一股滑稽感，让人想象到一具骷髅骑着自行车的样子。</p>
<p>我的脾气也被磨没了，从一开始的怒火中烧到无可奈何，不知道用了多久，但不会超过三天。死亡从惩罚变成了一个让人久等的节日也不是什么难事。剩下的日子，我希望以前怎么过，现在还是怎么过，死亡重新变得遥远起来，那倒计时也被我有意识地忽视上了。</p>
<p>某天，双叶扭过脸来，踌躇了几下，问我：“小白，你还好吗？”</p>
<p>我发起愣，注意到双叶的头顶是“1061”，然后才重新回想起一个事实：双叶已经很久没和我说过话了。是那个壮汉的功劳吗？我希望是。但是双叶的担忧扑面而来，倒不像是鳄鱼的眼泪。</p>
<p>一秒后，我回答：“我很好。”</p>
<p>没有任何犹豫，双叶紧紧扯住话头：“可是你知道这几天你都成什么样了吗？”我说不知道，“你要是心里有事，真的不用害怕拿出来说说。你有把我当成朋友吧？啊？”双叶一气呵成了从关心到数落再到质问的流程，我两次想打断均告失败。没办法，只能等她说完她想说的，我才有开口的机会。</p>
<p>我放空大脑，看着双叶为了不惊扰其他人而克制自己不大声嚷嚷，我也让自己控制住表情，尽可能露出虚心听教训的恳切表情，而不是一片空白时想入非非的邪笑。我不明白双叶为什么这样的焦虑，我也分不开心思去想，那就任她去吧。</p>
<p>我不明白哪里来的耐心听双叶数落，但我并不反感，要是换成平时，估计双叶被我骂得狗血淋头，尽管我不舍得斥责像双叶这样可爱的女孩子，然后甩过脸去一边伤心一边暗下决心再也不理我。但这没发生过，因为双叶从来不会对我说这些，我自然不去招惹她。现在忽然生出的这种超凡耐心，大概是回为我知道自己明天就要死了吧。我想着刚知道自己只剩下几天可活的时候，我恨不得拔刀杀死每个看的见的活物，完全是出自一种“我活不了你们也别想活”的自私心理。但过了几天因为没有亲身实践的勇气，更体会到后果无补的痛苦，所以我放弃了它们。现在回想起来，我也深深觉得自己真是不可理喻，尽管那只是几十个小时前的自己。后来接受现实，就觉得这样死掉也挺好的，至少免去了后来几年的煎熬，说是“接受”，听起来格外简单，但我从发现希望消失到放弃希望，中间能经历的弯折不足为人道，就好像一个被判死刑的因徒，清楚地知道自己的死期，却无法把自己的心情传递出去的那种尴尬，最终上刑场时只留给世界一声废然的叹息。我告诉自己，认了吧，然后就无比坦然地接受了，就好像闪电割裂天空的那样漠然。</p>
<p>大概双叶永远不会经历这些吧，如果这样，那也真是安慰。这样的折磨一定能让不少人在死期来临前先找个办法弄死自己。我静静地听着她嘴里不时吐出的“冷漠”“爱搭不理”之类的形容词，清楚地知道双叶的内心是焦躁的，可我甚至能在心中偷偷感叹双叶是有文学天分的，信手拈来的词都能如此准确又贴切。</p>
<p>双叶停了嘴，我知道该我说话了。但在这之前我想回头看看时钟，终于作罢。我思考了一下：</p>
<p>“双叶，如果你明天就要死了，你会干什么？”</p>
<p>双叶的大眼睛干巴巴地眨了两下。曾经有一次我参与到同事聊天时，偶然提到了双叶。毕意是为数不多近在咫尺的女孩子，众多男人的心里想必是有很多话想说的。不假，关于双叶的议论热闹得非比寻常，大多是说她长相多么的吸人眼球。哦，我知道那个壮汉的来历了。他说双叶的眼睛像是会说话的，总是对其他人温柔以待的样子。那个比喻我一直记得，因为确实很符名双叶给人的印象，从此往后我看双叶都会下意识地先看眼睛。现在她的眼睛忽然失掉了一半光彩，让我觉得寡淡无味。</p>
<p>我低下头，刚好能看见双叶的过膝袜，即便是扎在一堆男人里，双叶也是这样穿。她竟然在不知不觉间做到了无视他人目光。说实在的，她的大腿确实让人看一看就有点脸红，明明不怎么胖，过膝袜的松紧带也能在大腿上勒出一圈凹痕。双叶忽然注意到了我的视线。就紧紧地拽住短裙，挡住不让我看。</p>
<p>“我…”双叶挠挠头，“做不了什么吧。”</p>
<p>确实，因为这样的无妄之灾降临到谁的头上，第一反应都是无助，然后才能有狂怒，一般不会来得太晚。</p>
<p>“是吗？我知道了。”</p>
<p>双叶不说话了，她好像听出来我说话的弦外音，所以就不闹了。我度过了相对清闲的一天，换用更宽泛的话说，应该是暴风雨前的宁静。</p>
<p>回到家，我只是简单用水冲洗了身体，虽然是热水，却把我的疲劳又往深处挤压了一层。离开浴室后，我把手机拿起来，就看见了双叶的即时通讯短信。只是个地点和一个汉字“来”。反正我也挺闲的，就这样走到了目的地。</p>
<p>这不是富人区吗？这是我的第一印象。直到这时我才觉得自己对双叶的了解真是少得可怜。高楼林立还是小的，道路两旁的植物被切成长方体，以及穿行其间的，叫不出品牌的车辆，才更吸引人。叫不出名字的原因是我能坐进这种车里只是个远离现实的幻想。</p>
<p>不过还好，双叶给了我一张地图，让我不至于在这种堪比迷宫的地方走不出来。我感到很开心，死前的一天还能来开开眼界，尽管我没什么能叫作&lt;ruby&gt;遗愿清单&lt;rt&gt;BUCKET LIST&lt;/rt&gt;&lt;/ruby&gt;的东西。</p>
<p>走到门口了，我纠结起要不要敲门，一个声音驱赶走我的胆怯，按着我的手拍响了门。这里看上去只是众多商品房之一，但是从旁边的落地窗看出去，就是无论何时都一样繁华的市中心。彻夜明亮的窗外，与我住的地方对比，我就住在下水道，地价便可想而知的高。</p>
<p>双叶开门的时候衣着比较不具，她也不在乎我的视线，只是一手拉我进了她的屋子。一进门我就知道我的眼睛里放射出一股会让双叶厌恶的光，像是说“啊啊没想到双叶大小姐住的地方比我优越这么多啊”。还好双叶背对着我。</p>
<p>双叶让我坐在一把带椅背和扶手的旋转椅上，她自己坐在一只比我座位高得多的高脚凳上，左手紧紧按住裙底。双叶不知道怎么开口，就向阳台外看看夜景。这里的夜景不输房费动辄一晚几千元人民币的五星级酒店，想到双叶每天都能看到这样的风景，我顿时感到羡慕不已。</p>
<p>“……”缄默充斥整个空间，双叶的侧颜对着我。她的下颌线条像用 0.35mm 圆珠笔勾出的那样，每一部分都在它们该在的地方，脸颊泛起的红色被日光灯的光芒渲染开。</p>
<p>“小白，我要做什么你才不会去……”双叶想找个委婉的说辞，终于失败，“去死……”</p>
<p>我的心里突然荡漾出一圈圈的涟漪，这涟漪甚至把我的脸带出几分笑，全然不像一个第二天就要告别世界的人该有的样子。是啊，如果能活下去，我也不愿意的。</p>
<p>“这又不是你一两句话改变的了的，该怎么过怎么过吧。”</p>
<p>双叶看上去很不满我糊涂不清的说辞。她揪住我的头发，上身俯下跟我接吻了，她的嘴唇湿湿的，尽管接触的时间不长，只有心里默数三下那么久，但还是让我双手不安地抖动无法稳定下来。</p>
<p>双叶轻轻抚摸我的左侧廉价，这抚摸确实很能安慰人，我不恐惧了。双叶舔了两下嘴唇，紧紧盯着我。我深吸进一口气，然后缓缓地吐出来。</p>
<p>“还是不够吗？”双叶眼神里充满了怜悯。如果她有戴眼镜，简直让人毫无抵抗力。</p>
<p>第二天早晨、我还是保留着紧紧抱住双叶的姿势。我想松开她，但那冷气开得温度极低，我不自觉地把怀抱中的双叶又紧了紧。双叶睁开眼睛，然后没什么顾虑地起身离开床。我也紧接着她走出了卧室。</p>
<p>双叶摸了摸我的头，把外套披在我的肩膀上，放我走了。但我其实不知道有哪里可去，所以还是决定回家，哪里来的回哪里去吧。家里的空气充满了凝重，这也仅仅是我一夜未归的结果，屋里的一切都在一夜之间生出了一股莫名其妙的疏离感尽管我的床上还有我前天晚上留下的压痕。我轻轻地摸了下曾经无比熟悉的床，然后是桌子，椅子，感觉它们都是一样的冷冰冰。一个纸盒吸引了我的注意，使劲回忆了一下才想起来这是几天前收到的东西，直到现在它还是躺倒在我的桌上。</p>
<p>我把外面包得格外粗糙的纸盒撕成两半，随手扔在一边，里面是一笺短信和一个广口玻璃瓶，玻璃瓶里混合着深红深绿色的东西，大概是某种下饭用的酱。短信的抬头写的是我的名字，署名是父母的名字，日期……我心算了一下，大概是两星期前。短信的内容我没心思细看，把它和被撕开的纸盒扔在一起。</p>
<p>刹那间，一种强烈的冲动袭击我，让我尝尝玻璃瓶的内容物。我默许了这种冲动，毕竟今天就要死了。启开瓶盖，我用勺子狠狠挖了一下放进嘴里，一股疯狂的辛辣直冲我的大脑，而且不出意料是非常油，活像被人抽了响亮的一巴掌。我强行咽了下去，留在口腔里的味道居然有种腥咸的海风味，大概是我的舌头已经被辣得失去知觉了吧。</p>
<p>直到我灌下了一大瓶一点五升装的水，才感觉好过点。我重新把玻璃瓶封口，放在原来的地方。现在时候还早，我决定出门散散步。</p>
<p>我从口袋里摸出手机。它的锁屏界面干静得如同一潭死水——没有人找我。这不是第一次，我坦然地接受了，把手机扔进被窝底下，反正我应该是再也用不到了。因为不想就这样坐着等死，我还是想出门走走。走到门口我又胆怯了，我又想起了那被撞飞又瘫倒在地上，身下流出鲜血的女孩。</p>
<p>跟不存在的自己斗争了十来分钟，我拿起钥匙走出了房门。看着这个再也不可能回来的地方，我的心里没有激起一丝波澜。斟酌了一下，我摸了摸兜里的地铁月卡。</p>
<p>说实话，地下铁路的窗外完全是漆黑一片，里面的灯光透不出去，外面的黑暗涌不进来。这座城市的地铁据说格外邪门，因为在盾构机施工时挖到过完整的人类骸骨，类似于末班地铁时人会莫名其妙消失的都市传说，实在是层出不穷。但我觉得这挺荒诞的，地下铁路的深度能挖出来哪个年代的骸骨？于是这些可止小儿夜啼的鬼故事用心立刻昭然若揭，想必是地铁线路安排不到邻近家的地方而愤愤不平了吧？</p>
<p>但曾经有次我也被吓到过，那时候我已经失去了时间观念。只从站台的提示音听出来这是末班地铁，如果乘不上我势必露宿街头。所以我大着胆子乘了上去。刚一坐下我就发视异常了。空无一人的车厢和惨白惨白的灯光，简直就是鬼片必备。我无暇管这些，只是想起来今天的这种日子，不仅拖着过劳而积满乳酸的身体，而且天天顶着被上司骂的压力，这对于当时刚走出来工作的我，实在是太残忍了。不知不觉，感受着四下无人的寂寞与电动马达的轰鸣声，我的眼泪止不住地往下流，我清楚自己积满血丝的巩膜和角膜流起泪来看着比鬼可怕，就用手捂着脸。于是我一边流着眼泪，一边抱怨着这种日子什么时候是个头。一股力拍了拍我的肩头，然后是个老者的声音：“年轻人，都会过去的。”</p>
<p>我猛拔起头，顾不得脸面就向左侧看，那是个头发黑白参半的老者，身上的衣服只是街上随处可见的 T 恤衫和宽松长裤。我哽咽得无言作答，只好挤出微笑来应。到站的铃声又响起，站名听不真切，只看到老者起身，迈着稳稳的步伐离开车厢。在那以后睡着了，具体多久也不可知。醒来时恰巧赶上家附近的站，就匆忙下了车，刚才的事情也抛诸脑后了。</p>
<p>现在的话，再也没见到那名老者，我的记忆绝不会出错。只是地铁上载着无数的人，把日子一天天地往前带着，再不回头。我又一次觉得死亡没什么大不了，只是离开这里，去过另一种清静日子罢了。</p>
<p>空方北站到了，我离开车厢。车门在我背后无声地关上，轰鸣着驶离了我的身后。我头也不回地走出了地铁站。现在不过中午，太阳已经快爬上顶了。我记得就在两三星期前还是阴雨不断，因为我这里格外容易受台风影响，一到夏天就几乎看不到什么有阳光的天空，现在天气这么好，倒是使我惊讶。但是闷热的本质依旧，浅蓝色的天空就是一口大蒸笼。于是本能驱使着我找个有树的地方避避太阳，四顾一下，偌大一个下城区，竟找不出一棵能称为完整的树，这里说的完整，当然是相对于以前的记忆而言的。我还记得二十年前，我经常在周末到户外散步，与当时身体还健朗的祖母牵着手。当时的我并不觉得牵手是多么奇怪的事，只是觉得这动作很自然，便成了一种下意识的行为。换到现在，我可能会难为情而把手默默地塞回袋里吧。</p>
<p>二十年后的今天，不说天翻地覆，至少也是物是人非。当初自己租房住时没有多么抗拒老旧的小区，也是因为曾经在几乎相同的地方生活了十八年。我以前生活的地方，大概是七十年代末兴起的社区吧。我能想象到它当时的时髦，换到现在来看，只是时代的眼泪。就连和我一起长大的人，也是这样。上次不远千里回故乡时，祖母已经认不出——更像是彻底忘记了我是谁。知道了一切的我说不出话来，只好坐在屋外的树根底下发愣。我没有吸烟或是喝酒的习惯，也并不想接触它们，但我好像明白了人有烟瘾、有酒瘾，还会去追求片刻的晕厥。</p>
<p>我还是在路上走啊走，走得靠近了河边，因为这里凉快。河岸上有很多鹅卵石，不少人就在这我卵石堆上架上板凳，坐下钓鱼。河岸往外的高处一直蔓延着草地，顶上是一座公园，几乎被树木整个地掩理，我跨过草地上坡，上面是沥青铺成的人行道。在刑事案还像吃饭喝水的年代，这里实在是抛尸的好去处。但那时估计这里只是一片荒凉的河堤，而它身后的跨河大桥也不像现在一样宏大。事实上，现在在河边能看到的景物已经完全失去了原来的样子，只有不变的地名提示人们这里是什么。像我一样的外来者只能通过黑白照片来推断它几十年前的样子了。</p>
<p>不过现在的繁华，无论从哪个角度看，都是好的。</p>
<p>河水不清澈，从岸上公园远远地看来更是如此。人行道两旁时不时出现长椅，现在……大概到下午了，人并不多，我就随意选了个长椅坐下。上面的木漆已经斑驳脱落，支撑脚处的铁零件也早就被风吹日晒雨淋得锈迹遍布。坐在上面没有什么特别的感觉，像坐回我那座摇摇晃晃又坚硬无比的床。还好屁股底下的不摇摇晃晃。</p>
<p>在阳光照不到的角落发着呆，时间真是过得格外快。视野中心是宽阔的河面，缓缓地从左往右流动，经过大桥。那大桥格外巨大，即使在阳光最毒辣的七月，在桥底也能安稳睡午觉，但现在那里无论何时挤满了跟风客，跟的就是猎奇的风。如果是圣地巡游也就罢了，可那里的人发到 SNS 的内容都是“啊好危险好害怕”之类的词句，不胜枚举，就像是暴露出弱小就有钱送上门一样。本来连个鬼影都没有的桥底成了又一个闹市。</p>
<p>我忽然想起，这个公园附近的一大片地方，总能发现有人自杀，最多的还是在大桥上投河自尽的，就这样，某某地方是自杀圣地的流言不胫而走。这让我想起太平洋彼岸，再跨过一个北美洲的康奈尔大学，同样有自杀率极高的传言。其实原因很简单：康奈尔大学地跨峡谷，求死者大多通过跃下峡谷自尽，每当打捞尸体时，就不可避免地要把峡谷上为数不多的桥封锁后停放作业车辆，在桥两侧的司机堵多了，又得知有人自杀的事实，流言就诞生了。桥上也是这样：看热闹的人多了，自杀人数就通过众人的嘴翻起倍来。这样死的也算是掷地有声，比吞安眠药自杀造成的震慑可大多了。</p>
<p>我闭上眼睛，似乎过了很久才睁开，已经是黄昏了，余晖正朝我的脸。河岸边有人在架设帐篷和营火，远远地散出迷人的暖光。在很久以前，我不知道，我也是他们中的一个。</p>
<p>在我刚过十八岁的七月，我和很多人一起出门去山间露营，中间恰逢大雨，刚才还星星点点明亮着的城市顷刻淹没进雨幕中，本来堆起的煤炭也就此泡汤。在这种时候应该神色凝重，保持严肃的，但记不请是谁，忽然“哈哈哈”地大笑起来。这笑声在大雨中极为怪异，但实实在在驱散了当时沉重的氛围，心情也从之前被雨压得喘不过气变成了全身湿透的畅快。不过还好，夏天的大雨只是一阵一阵地下，并不持久。雨停了就刮起风，哈气声连绵不绝，于是捡起些还算干燥的煤炭，重新在湿滑的土地点起火，再把吸饱水的衣服脱下，架在火旁。在座的诸位都是正在最疯狂的时候，因此即使知道明天要得重感冒，也还是无忧无虑地笑。</p>
<p>火烧个不停，几十亿年前的阳光重新点亮了那一晚的空气。被阳光照亮的脸没再发出嬉笑的动静。阳光和月亮在当时一起存在，大约是件无比浪漫的事。第二天终于没得重感冒，各自回到了生活的铁轨上。</p>
<p>河岸上也一堆一堆地围起了人，他们头上顶着各种各样的数字，这时候我才发现我有意识地忽略了倒计时，好像它并不真的宣告死亡一样。事实上，我也抱有种莫名其妙的侥幸心理，我希望倒计时从头到尾只是我的幻觉，因为这几天等待死亡，我忽然生起一股对整个世界的眷恋。我不想死，真的。我身边没有能当作镜子的东西，但我清楚我的头顶已经剩下个只有我能看见的“0”。河岸上的人们未来有一天也会不得不接受这种命运，我内心酸得皱起眉头。</p>
<p>我会怎么死呢？会走上斑马线时被 120 km/h 的小轿车撞飞再摔死吗？动量定理告诉我那不会有多好受。还是走到巷口时被飞冲出的劫匪用刀捅死呢？我希望他的刀能磨得锋利一点，那样不会有太多痛苦。不对……明明是危及性命的大事，我的脑海中却一遍遍地预演各种可能的死法。是因为这几天被倒计时折磨得心神不宁，才对生死感到麻木了吧。这也不是坏事，接受了生死置之如无物的心态才让我感到久违的摆脱烦恼，我终于杀死了痛苦。最对不起的，大概还是双叶吧，我还是没忘记她一边抱着我呜呜地哭，一边哽咽着“小白你不要死”。</p>
<p>我当时还是一个完整的人，不知道双叶看见了我的尸体会是什么表情。至于其他人，我没有精力去管，也不在乎他们。与我关系或深或浅的人大概只会惊讶几声更过分的还会笑出来。如果这样，还不如一早不让他们知道。</p>
<p>我没有什么后事要料理。我仔细回想了一下，最值钱的就是我的旧 PC 和手机了，它们快要过三岁生日，可惜我不能给它们庆生。其余就是我的铺盖卷和锅碗瓢盆，几次搬家就逼着我抛弃了它们中的很大一部分，难怪有谚语说：“三次搬家等于一场大火”[^2]。我几乎能看见我如果真的死于他杀，警情通告上会有什么内容：“死者生前关系简单……”然后才扯到谁是凶手的问题。这我并不担心，像这样的大城市一定会把监视器安排得严密无比，毕意再怎么说都是个所谓的示范城市。不过要是真的不幸是出车祸而死的，那我大概会出现在各大 QQ 群了吧，和我残缺不堪的尸体一起。</p>
<p>“啊……不知不觉快走回家了。”没有带手机的我不知道现在的具体时间。今天的月亮只剩细细的一弯。路上没有人，这是比较反常的，因为身处这个被季风支配的城市，人们大多没有在夏天早早入眠的打算，更何况现在是农历的春夏之交，正是暑热行将害人，而人又最坐不住的时候，可我只是当这没有人的街道是黄泉路，反正它总会有个头的。</p>
<p>每到巷口，我都会耐心地等待几十秒，但除了不时的狗吠，什么也听不见。我想这不应该，四周黑漆漆的，人不在黑夜中行走，反而像是在朝远离地面，远离地月系方向缓缓做着环绕运动，头顶微弱的月光就像遥远的孤星它的光芒只够用余光捕捉，其余便再无用处。反复几次，我也觉得乏味了，就当什么都没有一样往前大跨步地走，也不害怕一脚踏到松动的井盖上。</p>
<p>步行到家楼下时，我还没反应过来，险些视若无睹地路过。一阵矛盾纠缠着我，我想上到家里再看一眼，但当脚踏上一级台阶时，却又胆怯地收回到地面上了。横竖都是个死，还不如死个明明白白，所以我掏出钢匙拧开了门。房里安静得让人汗毛倒竖，我走进去，背身关上门，一切的一切都在它该在的地方，半点差错也没有。</p>
<p>我迫切地抓起手机，“6 月 27 日 00:17”。我沉默了一下，不对，照理说我已经是个魂体了，而魂体又不能和精密设备互动。QQ 里有双叶传来的消息：</p>
<p>“小白，你还好吧……刚才才发现昨天是你生日，祝你生日快乐。”</p>
<p>生日？好陌生的名词，似乎真有那么回事。这是我看了相册中的身份证图片时才证实的。我感觉一阵头晕，走进破旧的厕所。墙角有绿苔，真该死，我上个星期才清扫过的，水龙头流出的水温度不低，被外面的空气加热得接近体温。我把水往脸上泼洒，来试图叫起混沌了的大脑，然后重新抬起头，看那一方小小的，缺了右下角，还有裂痕的镜子。</p>
<p>倒计时没有了，我的表情像个天大的玩笑。</p>
<p><em><strong>（2025.6.28 全文完）</strong></em></p>
<h3>脚注</h3>
<p>[^2]: 出自<strong>本杰明·富兰克林</strong>（1706-01-17~1790-04-17）在 1757 年的《穷理查年鉴》。原文为“Three removes are as bad as a fire.”。</p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2025-07-05T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[Nothing Dawn]]></title>
        <id>https://re.karlbaey.top/articles/nothing-dawn/</id>
        <link href="https://re.karlbaey.top/articles/nothing-dawn/"/>
        <updated>2025-06-01T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[己巳孟夏，以不可言之志，尝撰此文，乃一无谓之举，不胜一览。有卿语人曰：“其一丧家犬，其有作文亦如犬狺然狂吠耳。”白怫然，终无可奈何，独觉此卿...]]></summary>
        <content type="html"><![CDATA[<h3>序</h3>
<p>己巳孟夏，以不可言之志，尝撰此文，乃一无谓之举，不胜一览。有卿语人曰：“其一丧家犬，其有作文亦如犬狺然狂吠耳。”白怫然，终无可奈何，独觉此卿“狺然”、“狂”言之过矣，余皆无误。既叹然，不可不藉尺卷寸管，以抒其不可言之志。篇中有论世，有叹惋，有嬉笑，有怒骂，为赋一文强说愁也。且聆之犬吠如。能解吾意，亦非一不幸事。</p>
<h3>Section 1</h3>
<p>和音乐扯上关系的故事都不会有多么美好的结局。布莱恩看着那个背对着她的男孩，心里终于是回想起了那个男孩曾下的判决。 布莱恩当时只当布鲁斯的话只是受刺激后的气话，但他脸上的平静神色否认了布莱恩的观点，于是她半戏谑半认真地追问：“为什么？”</p>
<p>“啊——倒没什么，只是看了很多乐队的结局，一点有感而发罢了。”布鲁斯木然地说，“远的有约翰·列侬，近的有黄家驹……实在是太多了，根本列不完。听不懂算了，你当我是胡言乱语吧。”布鲁斯让头轰然倒塌，趴在桌上，连眼镜也没摘，发出了嘎吱的响声。布莱恩刚想追问，看他一副拒之门外的模样，只好作罢。</p>
<p>如果不是布莱恩十分肯定布鲁斯对此一无所知，她一定会当场把布鲁斯的桌子掀翻，但她一直把这个秘密压在心底，从未和离开后见到的任何人说。这个秘密就是：她组了个乐队，一个完整的乐队。</p>
<p>事情发轫于何时呢？布莱恩不由自主地回忆起来。好像是三年前吧，布莱恩想，那时她刚学会一点吉他，就火急火燎地想给人看看自己努力的成果。她也记得，自己弹的第一首完整的曲子是酷玩乐队的《Yellow》[^1]，这还是她检索“新手入门吉他曲”后得来的结果。事实也如她所愿，这首鼓手入门曲同样非常适合吉他。从此以后，它成了布莱恩最爱的抒情摇滚。</p>
<p>不过，首次表演出来后，布莱恩觉得自己弹得烂透了，尽管听众给出的掌声和赞美的话绝对出自真心。那也好吧……布莱恩轻抚着六条弦，安慰自己般地想，琴弦的金属光泽熠熠生辉。</p>
<p>正在听众们分离后不久，一个头发乌黑的女孩找上了布莱恩：“啊，嗯，”女孩因为羞涩哽咽了两声，“你是布莱恩吧？你弹得太好了，以后也会有其他的曲子吗？”</p>
<p>“会，会的吧。”布莱恩同样脸红红，“我只是刚开始学罢了。”</p>
<p>像是下定了某种决心，女孩先埋低头，然后猛地一抬：“我是明日奈。 其实我会点口琴……我觉得，咱可以组个乐队。”发现布莱恩马上要开口作出回应，明日奈阻止道：“你不用马上回答的，我知道这么问太唐突了，但我还是希望你能考虑一下，我下了很大决心的。”</p>
<p>明日奈说话时手指不停搓弄着衣角，大概是局促了吧。见她这样，布莱恩说：“好，我会考虑。”突然之间，明日奈也不搓衣角了，眼角流出欣然的颜色。这句话在 布莱恩心里埋下了一颗地雷，但它不会爆炸，即便是暂时的。不同的是，这地雷会生根，会发芽，也会像它的兄弟硝酸甘油[^2]一样，会灰飞烟灭。今天是3月16日，临近春分。</p>
<p>在那之后？两个星期还是三个星期，总之四月的阳光已经不留情地向地球投掷热量时，布莱恩和明日奈的微型乐队就此组建。布鲁斯正在数学课上睡得半梦半醒，布莱恩就在他旁边，饱含着心底油然生出的温柔，回忆着她的乐队，布鲁斯发出的暴论也就在她心里烟消云散。等到布莱恩下一次想起来，已经是布鲁斯离开很久之后的事了。</p>
<p>给乐队起名并不花很多的时间，她们在第一个设想上就达成了一致。明日奈提议道：“我觉得，我们的乐队叫做“Sunset”再合适不过了，布莱恩你怎么看？”</p>
<p>日落吗……布莱恩首先想起的是《Yellow》单曲专辑的封面，一棵不知是棕榈树还是椰树的大叶片挡住太阳的大部分，但日轮余下的光芒把整个画面都渲染上一片鹅黄。这是只属于夕阳的光芒，自此之所外再也找不到一个时刻能这样均匀地为世界打上色彩。</p>
<p>“就这个了。”布莱恩一锤定音，脑中深深地刻下了太阳浮在海平面上的样子。</p>
<p>“那太好了！”明日奈兴奋得快跳起来，“我想了挺久的……不要笑我！ 就一个词也是要下心思去想的好不好！”布莱恩想到只是一个六字母单词就让明日奈想了很久，不禁笑出声来。</p>
<p>回忆的马车被下课铃砍断了车轴，布鲁斯也被唤醒过来。布莱恩看见布鲁斯几欲张嘴却又把话咽回肚里，像是在睡眠时被地震震开的碎石掩埋后又被挖出来的人，被吓得丧失了语言功能。布鲁斯叹了口气，无奈地说：“黄梁一梦啊……”</p>
<p>布莱恩当然不知道他做了什么梦，好奇心驱使她问个明白。布鲁斯抢先开了口：</p>
<p>“布莱恩，你有朋友吗……不对，我在问什么……就是说，本来关系很好却因为各种各样的原因事情绝交的？”</p>
<p>“有吗？”布莱恩照布鲁斯的话试图搜索，“有的。你问这个干嘛？”</p>
<p>“那有没有因为一句话就断交的？”</p>
<p>“这倒没有。”布莱恩的问题并没被解答，但她已经猜到了答案。于是她也不再好奇，随布鲁斯自己自言自语去。</p>
<p>看着布鲁斯突然阴沉，布莱恩猛记起好像布鲁斯从认识开始就是这种样子：除了自己，找不到另一个能说上话的人。当然，也不是一开始就这样想的，因为布鲁斯长相与普通人没有多大分别，她自然觉得布鲁斯没有多大问题，只是较其他人多了几分灰头土脸罢了。但是不对劲的地方太多了，就比如说，布鲁斯从来不找自己说话，布莱恩想。本来以为是他觉得和异性说话会尴尬，最后才发现他是几乎连话也不说，在闲时要么是发呆，要么是双目无神地自言自语。布莱恩觉得他像江山尽为外人夺去的末代皇帝，以前尽管有无限的辉煌，但也只能从他眼里不时滴出的无奈读出一些了。她只好尊重布鲁斯的选择。大概是他们两人无中生有的默契，尽管他们二人未曾说过一句话，他们好像心意相通，是布鲁斯不时流淌出的落寞在他们间筑起的高墙阻止了交流的可能吧。“怎样都好吧。”这是布鲁斯自言自语时最爱说的话。</p>
<p>不过他们最后还是聊上了天。“布莱恩，你是不是觉得，我很奇怪？</p>
<p>“会这样觉得也正常，因为我不爱说话嘛。”</p>
<p>“各人有各人的选择嘛，无所谓奇不奇怪吧。”布莱恩否认。</p>
<p>“你直接同意的话我反而好受点。算了吧，怎样都好。我只是想告诉你，你是个很好的人，我不和你说话只是我自己的选择。至于原因，是很久以前的事情了，总之，言多必失吧。”</p>
<p>布莱恩对这莫名其妙的一番说辞弄得摸不着头脑，姑且觉得这是示好了。</p>
<h3>Section 2</h3>
<p>在这以后，他们不时地会说起各种各样的废话。反正废话是说给朋友的，不说废话的那不叫朋友。也谈音乐，谈的多是现代的流行歌，摇滚其次。不时说起粤语歌，不过那些都是三十年以前的老歌了。</p>
<p>“音乐这东西，挺有意思的，我觉得是。一首好歌一定是要为现实里的事情作的。要是写的歌就为了歌颂，赞美一个人，还不如别写出来的好。”布鲁斯正说到 Beyond 乐队的《灰色轨迹》[^3]。布莱恩发现他久违地露出了一点笑容，心里就知道了布鲁斯对音乐真心的喜爱。</p>
<p>聊着，就几个月过去了。布鲁斯突然在晚自习前掏出一个黑色的物体，表面闪着光。“嘿嘿，终于有钱买个好点的 MP3 了。”布鲁斯 一边"嘿嘿"笑着一边连上 3.5 mm 有线耳机。</p>
<p>“今天我要听一晚上MP3了。”布莱恩觉得布鲁斯脸上的笑是发自心底的，于是布莱恩也回之以微笑，尽量不让布鲁斯觉得自己被嘲讽了。至少能笑出来，还是能救回来的。布莱恩想。</p>
<p>好景不长，布鲁斯塞上耳机后，分针转过了 12°，班主任从后门偷偷地摸进来了。布莱恩注意到时，已经太晚了，他站在布鲁斯斜后面， 此时的布鲁斯手忙脚乱地把耳机揉成一团塞在手心，却只能听到一句无谓的警告：
“以后别让我看到你这里有 MP4。”</p>
<p>布莱恩不敢出声，用余光瞟见布鲁斯木然地点头，他的脖子像根被扯坏的弹簧，无济于事地低下了头。布鲁斯把 MP3 小心地装回袋子，再把耳机一圈圈缠好，也装进 MP3 的袋子里，整个袋子就被放回了书包的格子里。虽然班主任已经走了，但布鲁斯继续低着头发呆，也不说话。</p>
<p>“回家再听也可以的，别太难过了。”布莱恩轻拍布鲁斯的左肩。</p>
<p>布鲁斯含糊地"嗯"了一声，算是回应。对他来说，耳机不只是播放设备，耳机更像是把他和外界暂时隔开的屏障，在被耳机保护的世界，布鲁斯大可以像个正常人一样写写画画，不论好坏。但是布鲁斯现在发现耳机还不如塑料袋可靠，事实已经证明了。他觉得，或者说是希望，布莱恩能体会到他的失望，如果连布莱恩都感觉不到，那真的不会再有人能发现了。</p>
<p>“布莱恩，”听到布鲁斯的声音，布莱恩搁下了笔，“你觉得，普通人活着的价值是什么呢？”</p>
<p>“自己去翻价值论，课本上就有。”</p>
<p>“书上的那顶个屁用，”布鲁斯不带表情地骂道，“价值论有用人就不可能组成像你我看到的这个样子了。”</p>
<p><em><strong>（此处删去 616 字）</strong></em></p>
<h3>Section 3</h3>
<p>过了两个星期的周末，Sunset 乐队终于想起来排练了。明日奈问布莱恩：“咱们一直唱的都是其他人的写的歌，要不咱自己写一首”</p>
<p>布莱恩表示同意，她们苦恼了一个上午，终于在一大片白纸上画出了几个音符。录下来用的就是手机的麦克风，音质相当难让人满意。不过也没有别的办法，专业的麦克风压根不是她们这样的微型乐队值得买一支的。大部分都是布莱恩在吉他独奏，明日奈见缝插针地补上她的 F 大调口琴。录音合成后的结果出人意料地不错，口琴竟然被明日奈吹出了卡祖笛[^4]的味道。她们两人都相当满意。布莱恩把编曲存进了闪存卡[^5]。 明日奈提出要写词，她们互相大眼瞪小眼，因为对方的文学造诣各自心里都再清楚不过。布莱恩 说：“我给我同桌听听吧，他脑子里想的事情多，写出来的文字如何也不可知。”</p>
<p>布莱恩把闪存卡交给布鲁斯时，他看上去相当惊讶：“你什么时用候有个乐队了？”</p>
<p>“很早啦，认识你之前就有了，只是因为以前一直没有自己的作品才没跟你说。你听听这个。”布莱恩把闪存卡往前推了推。 布鲁斯仔细地审视布莱恩, 看她扎好的马尾辫。现在是 6 月，正是太阳最毒的时候，但布莱恩把肱骨中间往下的皮肤都暴露出来，眨着棕色的眼睛，想让布鲁斯同意试听。 布鲁斯悄悄在心里感叹，明明穿的衣服都一样，她看上去好耀眼啊。</p>
<p>布鲁斯拾起卡片，插进 MP3。这是什么搭配？吉他和卡祖笛……是口琴。布鲁斯点点头。全长五分钟，布鲁斯沉默了七分钟。</p>
<p>“怎么回事，不是只有五分钟吗？怎么不说话啊？”布莱恩惴惴不安。</p>
<p>布鲁斯缓缓摘下眼镜，然后是耳机。“对一个高中女生来说，这个编曲太超前了。不可能是一个人录的歌吧？我能看看你们乐队的成员吗？”他的神情突然焦急起来。</p>
<p>“就两个人，另一个也是女孩子，有机会带你看看。先说正事，评价一下？”</p>
<p>“我刚刚已经说了。”</p>
<p>“那你觉得能不能写词？”布莱恩终于问上了正事。</p>
<p>“写是当然可以，”布鲁斯有点为难，“但我没写过，我吃不准怎么写。”</p>
<p>“能委托你吗？”布莱恩棕色的虹膜闪闪发光。</p>
<p>“我尽量。”</p>
<p>布莱恩把存着歌的闪存卡给了布鲁斯，布鲁斯一边听着旋律一边敲打笔杆，拿捏着原曲的情感。他心里有了点数，反正是抒情摇滚，就自己发挥了……</p>
<p><em><strong>（此处删去 215 字）</strong></em></p>
<p>布鲁斯没用多长时间，就按照自己的意思写出一篇词作，交给了布莱恩。布莱恩只觉得布鲁斯多半是夹杂了私货在里头的，不押韵的同时还有许多心思。布莱恩也不在意，只照着词唱了出来。第二天，布莱恩把带着自己声音的母带给明日奈听。“真是你同桌写的？我挺好奇他长什么样的。”明日奈说。</p>
<p>“就很普通一男的。你觉得怎么样？这是歌词本。”布莱恩掏出草稿。</p>
<p>"好，好，"明日奈突然结巴起来，"就这个。"</p>
<p>新问题又出现了，就是这歌总得有个名字。本来照歌词的意思就叫做“孤独”的问题也不大，但明日奈当即反对：</p>
<p>“不行，不能叫这个。‘孤独’已经被尼采写绝了，在他以后再怎么写都是无病呻吟。所以，换个称呼也好，叫这名字烂透了。”</p>
<p>“你说叫什么好？”</p>
<p>“提到了夜晚，有黄昏，就是没有黎明。叫《Nowhere Is Dawn》吧。”</p>
<p>思考了一阵，布莱恩说：“好。”但在这之后，她们并不会有机会把这首歌演奏出来给大众了，即使以后偶有人将这完成的作品翻找出来，或反复地听，或弹奏，但它也会注定成为历史的一粒灰，沉没后没有再恢复的可能。</p>
<h3>Section 4</h3>
<p>又是几个月。布莱恩眼睁睁看着布鲁斯的眼窝深陷下去，他的眼镜框好像越来越大，像是要把他的脸整张地盖住，镜片玻璃把他的目光和呼吸全都隐在一片朦胧后。但布莱恩什么也没有做，因为她清楚，如果干涉效果必然事与愿违。</p>
<p>也没有让布莱恩等太久。星期五，布莱恩发现布鲁斯从座位上消失了一整天，但她并没从任何人口中听说布鲁斯请了假。直到那天晚上，布莱恩接到了布鲁斯的电话。</p>
<p>“我眼镜是不是在你的包里？”</p>
<p>布莱恩先是疑惑，让布鲁斯等等，然后用右手在包里翻找起来。没用多久就挖出来了一副黑色金属框的眼镜，镜片已经因为使用已久泛起淡黄色；镜框上，镜腿上，划痕处处可见。Briam家中没人戴眼镜，这想必就是布鲁斯的了。</p>
<p>“有的。还有你眼镜为什么会在我的包里？”</p>
<p>“我太粗心了……能拜托你明天把眼镜给我吗？算我求你。”布鲁斯声音里竟有分毫的乞求，这乞求不仅毫无诚意反而让布莱恩一阵恶寒，但终究答应了下来。</p>
<p>“那好。”布莱恩已经知道布鲁斯家在哪里，便不再多言，切断了电话。</p>
<p>但那天晚上布莱恩翻来翻去，总难以平静下来。于是她回忆起刚才的那通电话，她总觉得布鲁斯的声音不像现实里听到的那样，倒像是——布莱恩不安地检索了一遍大脑——像具尸体，她知道不该那样想。可是，可是，布莱恩在心里逼问自己，为什么布鲁斯的声音里突然流出那么多的废然呢？她不敢再想，只好尽力转移自己的注意力，不久就陷进了睡眠中。</p>
<p>其实布莱恩睡得并不好，她的脑中萦绕着那张母带的影子，布鲁斯答应作词的时候格外爽快，布莱恩也很高兴，因为这省下了她很多精力说服其他人写词。事后想起来，布莱恩才发现布鲁斯当时的神态和行为都太反常了，就像是接受宿命的那种爽快。这绝对不是好兆头。</p>
<p>第二天，布莱恩决定步行去找布鲁斯。今天是个大晴天，虽然还早，但只属于夏天的炎热早就随着太阳升起辐射到大气中。晴朗的天空丝毫无法掩盖闷热，布莱恩撑在伞下，但汗水早流遍了全身。 “好吧，”布莱恩一边在太阳下喘气一边想，“谁让他的眼镜在我这里呢？”</p>
<p>布莱恩敲开门的那一刻，她吓了一跳——布鲁斯眼球深深地陷进去，就像一夜没睡。布鲁斯回答：“我没事，只是没有眼镜，看不清，只能这样看了……”</p>
<p>布莱恩理解地点点头，但心里存着更多的怀疑，于是她谨慎地观察布鲁斯，生怕他下一秒就做出什么灭绝人性的举动。还好他什么也没做，只是跟布莱恩说了几句废话，布莱恩打着哈哈，一边点头一边说着“是”“那好”之类的话，但布鲁斯像是没有察觉到一样，不停发表着漫长的演说。“奇怪”，布莱恩想，“他怎么这样了？”。布莱恩的疑惑很快会被事实解答。</p>
<p>事实上，布莱恩压根记不得布鲁斯都说了些什么，只记得他的情绪出人意料的稳定，与自己先前印象中喜怒无常的布鲁斯大相径庭。除此之外，就是离开时撒得漫山遍野的阳光了。布莱恩摇摇头，试图消去头脑中的思考残片。她成功了。</p>
<p>布鲁斯接过眼镜后一直没有戴，只是把它随手搁在自己的床上。布莱恩走之后，布鲁斯死盯着自己的眼镜，发了很久的呆，从日上三竿到太阳下山。</p>
<p>他看着眼镜，不知不觉他回忆起了他戴上眼镜的那天。在那之前，他记得自己总是受各样的训诫，其中之一就是保护好眼晴，不要近视。“怎么可能得近视……”布鲁斯还小就这样不听劝——直到日渐看不清东西后才想起来保护眼睛，但显然已经没有用了。不过，令他高兴的是，戴上眼镜之后，训诫的声音小了很多也少了很多，也可能是一天到头都戴着眼镜吧，近视的程度从未加深，这也未必不是幸运的事。这是他们两个相陪伴的第五年，但现在，布鲁斯不会再需要这对眼镜了。</p>
<p>像是刻意逃避现实和过去，布鲁斯闭上了双眼，一种久违的安全感顺着黑暗降临，蔓延上他的四肢，脊柱，然后是大脑皮层，这样的舒适几乎要让他落下泪来，因为，安全感确实不是唾手可得的东西。回忆的走马灯还在闪烁，但布鲁斯已经没有继续欣赏的耐心了，那就像是场已经被炒作到烂的电影，从万众瞩目到人见人嫌，只用了3年。看到这里，布鲁斯不禁笑出声来。布鲁斯如同驱走蚊虫一样，手不自觉地在眼前挥了挥。</p>
<p>布鲁斯起身离开房间，走到阳台上，阳台很大，他攀住栏杆，视线向外面的晚霞远远地投去。布鲁斯突然想起来小时候听的一句农谚：“朝霞不出门，晚霞行千里。”看来明天天气不错。布鲁斯想。他在的城市不知道为什么少见大鸟，自然也看不见沿海的地方才会有的黑尾鸥[^6]，他很想看一次黑尾鸥，但现在看来只能留作遗憾了。不过夕阳特有的金黄中泛起的赤红色还是很让人眼晴舒缓的。正在夏天，这颜色并不会只吝啬地停留几分钟，于是布鲁斯看完了太阳是怎样掉进地平线的，这是他第一次看落日，布鲁斯觉得这感觉挺不错的。但再好的东西也会消失，当乌云重新占领天空，天上就只剩下阴翳。</p>
<p>布鲁斯自觉无味，就走回卧室，躺到了床上。他觉得活着也不是无可取之处，但自己已经没有别的退路了。布鲁斯连眼睛都没睁开，只向右一伸手，就探到了早搁在那里的药瓶——长期的神经衰弱让他不依靠药物才能勉强入睡，而半夜是否醒来完全看运气，最好不要，因为一旦醒来就只能独卧空榻与天花板共守到天明了。这种药的说明书上写每 24 小时最多吃一颗，布鲁斯从未逾这规则半步，不过他早想破坏这规则了。市面上通行的安眠药剂量不狠，所以布鲁斯想了个办法浓缩，让它的剂量不只是催眠这样平淡。</p>
<p>布鲁斯坐起身，把浓缩后的药丸泡进冷水，然后一饮而尽。其实浓缩后药依然是乏力的，布鲁斯直等了 5 分钟才产生一点困意。躺回床上，闭上眼睛后的一刻，布鲁斯把此生最后一次落日余晖刻在了心里。</p>
<p>布莱恩收到布鲁斯的死讯，已经是一个星期后了。起初布莱恩并没有觉出任何异常。连续一个星期不来学校对于布鲁斯不是什么新鲜事，所以布莱恩觉得这次也只是布鲁斯旧疾复发罢了。消息到时，布莱恩是格外愕然的。她的心底某处总是为这一天预演，但她没想到会来的这样快。虽然感到有点惋惜，不过，对于布鲁斯来说，如果不再有什么活着的动力，他自己也觉得并没有什么益处的话，那么，放弃生命，于人于己，的确都是最好的选择。</p>
<p>很久以后，布莱恩才知道，布鲁斯本来是可以继续活下去的，那一点安眠药要达到致死量还有相当的距离，如果发现得及时，可能布鲁斯能逃过一死，但他被发现时已经是第二天半夜了。怎么被发现的布莱恩至今不能得知。所以完全可以说杀死他的绝对不仅是药品，占更多的是他求死的信念。</p>
<p>让布莱恩感到有点安慰的是，安眠药致死不会让人受苦，比睡着还快。</p>
<h3>Section 5</h3>
<p>布莱恩的邻桌一直空到离开高中，这期间布莱恩遗忘了她的乐队。她的杂事好像永远都无法结束，甚至无法抽身做自己真正喜欢的事情，连自己在忙什么也不得而知。时间久了，布莱恩甚至忘记了吉他应该怎么按和弦，这不完全是玩笑，当她架起那一柄吉他想复习大 F 和弦[^7]时，发现无论怎么调音，吉他发出的声音不仅没有音乐感，反倒像是哀鸣。布莱恩叹口气，算是同过去的自己作了告别。</p>
<p>以后的故事看去是顺理成章的。布莱恩已经忘记了怎么弹吉他，那她的乐队也再没有存在的必要了。不过，令她没想到的是，像是两人心意相通，布莱恩刚打算打电话给明日奈，就抢先收到了明日奈发来的见面消息。</p>
<p>见面地点是在某处公园，布莱恩不甚熟悉，但天底下的公园大同小异，布莱恩很快找到了约好的树下。</p>
<p>今天天气不好。风吹不透乌云，天上黑压压的，却总也下不起雨，于是单位体积里巨大的水汽量就平等地折磨每一个人。布莱恩不知道为什么选这样的一天见面，只能耐心地等。没有等多久，明日奈矮矮的身影，出现在树的右侧，气氛着实尴尬，两人都知道为什么要见面，但见面之后却连一句完整的话都组织不出来。最后还是布莱恩打破沉默：</p>
<p>“怎么突然叫我出来。”</p>
<p>没有回答。明日奈先看天上的乌云，再踩了踩几片落叶，下定了决心才开口：</p>
<p>“我以后会很忙，乐队的事情做不成了·要不咱解散吧。”</p>
<p>同样没有回答。布莱恩并不认真地分辨明日奈说的什么，因为她的注意力同样全在天上悠悠划过的乌云，沉重得好比秤砣，一瞬间布莱恩甚至期待这么多乌云全部都一起塌下来，埋住所有人。“那好吧。”布莱恩不相信那是自己的声音，但也是这句话，把布莱恩拉回眼前行将分离的乐队。明日奈会意地点头，背上背包，离开了大树。</p>
<p>一片泛黄的叶子在枝头摇摇欲坠，布莱恩自言自语：“一叶知秋啊。”</p>
<p>布莱恩心里最后一块石头落了地，从此往后就再没有什么值得注意的事了。这期间布莱恩搬了好几次家，其中遗忘遗失了很多东西，最狠的一次，布莱恩忘记了一本相册集，里面存了布莱恩许多小时候的照片，虽说现在数字化手段足够强，把布莱恩的照片存到移动硬盘的工作也是早完成了，但是，有旧照片的人都知道，如果没有旧照片上斑驳的黄色氧化痕迹，那看起来纯纯一假照片，就好比自己从来没有拥有过那段时间一样，这是布莱恩翻看硬盘里的照片时，最最直观的感受。</p>
<p>不过，有一样东西，布莱恩从来没敢忘记，那就是她那把破吉他。布莱恩不知道为什么留着这个累赘，也有心把它扔掉，但她每次下定决心，执起琴身时，心中总油然生出一股不安，终于作罢。布莱恩参加工作后辗转六七个城市，都是如此。每当布莱恩看到吉他袋子上星星点点的白色霉斑，手抚摸起她终身难忘的几道木纹，就隐隐约约有哭的冲动，像是要从心肺里冲出来，把布莱恩整个地裹进悲哀里。虽然谈不上喜欢这种感觉，但这是为数不多让布莱恩觉得自己活着的时刻了，这就是狠不下心的原因吧。布莱恩安慰自己。</p>
<h3>Section 6</h3>
<p>几年过去了，一个深夜，布莱恩支撑着一副充满了乳酸的身躯搭着末班地铁，走到了家楼下。虽说是走，却比爬还慢——布莱恩是蹭着走的，踏上几步就会打个盹，老天可怜，布莱恩一路上没有遇到一个人，也没有尸体，只能听见远远传来的猫头鹰啼。布莱恩想，如果一直是深夜的话，也很不错吧。</p>
<p>布莱恩“啪”地打开门，再“啪”地打开灯，家里空荡荡的，墙上的时钟告诉布莱恩已经是第二天了，布莱恩轻轻叹了口气。这样的生活已经一个星期了，布莱恩觉得自己已经成了一具会动的死尸，不知道明天和希望在哪里。布莱恩鬼使神差地走进卧室，从床底抽出吉他，手不自觉地弹了起来，布莱恩相当惊讶，但还是试图照着旋律唱出来了。</p>
<p>&lt;center&gt;Look at the stars&lt;/center&gt;</p>
<p>&lt;center&gt;Look how they shine for you&lt;/center&gt;</p>
<p>&lt;center&gt;............&lt;/center&gt;</p>
<p>&lt;center&gt;All the things you do...&lt;/center&gt;</p>
<p>上一次奏起这首歌是什么时候呢？有七年了吧？布莱恩不很肯定，因为她激动得浑身震悚，她觉得终于找到以前的自己了，这种快乐简直像是幻觉。不知不觉，布莱恩眼泪决堤了。</p>
<p>于是布莱恩抬起头来，在一片雾茫茫中，她突然看见布鲁斯的身影。像是早有预料，布鲁斯问道：“我说的没错吧？”</p>
<p>布莱恩说不出话来，布鲁斯走到她跟前，表示理解地拍了拍她肩头。布莱恩一开始的兴奋突然荡然无存，低下头啜泣着。她不清楚自己为什么哭，但是没关系，没有来由的哭才是最能抚慰人心的。布鲁斯看着布莱恩，眼神中充满了怜悯。</p>
<p>布莱恩呜呜了好久，终于下定决心不哭了。擦干净泪水，抬起头刚想说些什么，却发现布鲁斯已经消失不见了，布莱恩只能感受到肩头他留下的余温。</p>
<p>布莱恩顺着卧室门看出去。先是空气，再是挤破天穹的繁星。</p>
<p>2025.5.27</p>
<p><em><strong>(The end)</strong></em></p>
<h3>脚注</h3>
<p>[^1]: 〔《Yellow》〕 英国摇滚乐队酷玩乐队演唱的歌曲。由乐队四位成员克里斯·马汀、盖·贝瑞曼、强尼·邦蓝、威尔·查平共同填词，肯·尼尔森和乐队四位成员共同制作。 这首歌被收录于乐队的首张专辑《Parachutes》里，并于2000年6月26日作为专辑的第二支单曲发布。
[^2]: 〔硝酸甘油〕又称硝酸甘油酯、三硝酸甘油酯、硝化甘油，化学式为C&lt;sub&gt;3&lt;/sub&gt;H&lt;sub&gt;5&lt;/sub&gt;N&lt;sub&gt;3&lt;/sub&gt;O&lt;sub&gt;9&lt;/sub&gt;，简称NTG，是甘油的三硝酸酯。是微挥发性无臭油状液体，具有甜味、芳香味和刺激性。硝酸甘油是一种爆炸能力极强的炸药，同时，在医学上也被用作血管扩张剂。
[^3]: 〔《灰色轨迹》〕电影《天若有情》插曲。由刘卓辉作词，黄家驹作曲，Beyond 编曲并演唱，收录于 1990 年 5 月 31 日发行的专辑《天若有情 电影原声带》中。《灰色轨迹》创作于 Beyond 低潮时期，该曲以简洁而富有张力的曲调，配以明亮而有力量的吉他和打击乐，描述了一个人于生活中迷茫和彷徨的心境，同时也揭示了人们在追寻梦想与现实之间的矛盾和挣扎。
[^4]: 〔卡祖笛〕是一种极为特殊的管乐器。它通过人声哼唱发出的声音，依靠自身的膜片和共鸣管的声音放大，发出嘶哑的音色，类似萨克斯管。
[^5]: 〔闪存卡〕利用闪存技术达到存储电子信息的存储器。一般应用在数码相机，掌上电脑，MP3等小型数码产品中作为存储介质。样子小巧，有如一张卡片，所以称之为闪存卡。
[^6]: 〔黑尾鸥〕（学名：<em>Larus crassirostris</em>）是一种中型海鸥。身长约45厘米，翼展120-128厘米。分布于东亚地区，包括中国、日本和韩国，也会到阿拉斯加至北美洲东北部分一带漂泊。黑尾鸥喙末端上有红色的斑点。幼鸟褐色，嘴粉色，尾黑，需要四年才羽翼丰满成长为成鸟。会发出像猫叫的哀怨叫声，所以在日本被称作“海猫”，在韩国则为“猫鸥”。
[^7]: 〔大 F 和弦〕因其指法特点又称作“大横按”，是由 4、6、1 三音叠置构成的大三和弦，是 C 大调的 Ⅳ 级下属和弦，是小调式的 Ⅵ 级和弦。</p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2025-06-01T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[五月二十五日记]]></title>
        <id>https://re.karlbaey.top/articles/may-25th/</id>
        <link href="https://re.karlbaey.top/articles/may-25th/"/>
        <updated>2025-05-25T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[我也不知道为什么要写这个，可能是因为我再也没有力气维护一段注定破裂的关系了。最近几个月一直过得很不好，各种各样的坏事几乎是接连不断地涌过来...]]></summary>
        <content type="html"><![CDATA[<h2>……</h2>
<p>我也不知道为什么要写这个，可能是因为我再也没有力气维护一段注定破裂的关系了。</p>
<p>最近几个月一直过得很不好，各种各样的坏事几乎是接连不断地涌过来。小白很早就听过一段话，大意是说人的苦难不是劈头盖脸地朝人一股脑地来，而是一件一件麻烦事接踵而至，颇有一波未平一波又起的意思，听起来就是很煎熬的。事实也大差不差，至少我经历的是这样。其实要说有没有什么值得大发牢骚，恨不得把整件事都一起杀死的那种事情，我现在也想不到。就是因为心情很差，才打算写下这些文字。看的不舒服的话，就关闭这个标签页吧。</p>
<p>不过真要写的话，小白还真不知道从哪里写好一点，毕竟事情千头万绪，牵一发而动全身，就好比一个无解的拓扑排序<a href="%5B%E7%BB%B4%E5%9F%BA%E7%99%BE%E7%A7%91%EF%BC%9A%E6%8B%93%E6%89%91%E6%8E%92%E5%BA%8F%5D(https://w.wiki/EH3V)">^1</a>，从哪件事开始都不合适。这样想来，小白还是觉得，从事情开始来说，也算是个楔子。</p>
<p>事情开始的比较早，距离现在接近一年——其实就是我中考往前的一段时间。那时候小白的学业成绩还算不错，说起来在我读的初中里也能在前五名争夺一番，不必多言。大概也是在那样轻松的环境里，小白并没有多么认真地学习，再加上小白读的初中向来是自由软件<a href="%E8%BF%99%E9%87%8C%E5%8F%AA%E6%98%AF%E5%80%9F%E7%94%A8%5B%E8%87%AA%E7%94%B1%E8%BD%AF%E4%BB%B6%5D(https://www.gnu.org/philosophy/free-sw.zh-cn.html)%E7%9A%84%E5%86%85%E6%B6%B5%EF%BC%8C%E5%B9%B6%E4%B8%8D%E6%98%AF%E7%9C%9F%E7%9A%84%E5%85%A8%E6%B0%91%E5%BC%80%E5%8F%91%E8%87%AA%E7%94%B1%E8%BD%AF%E4%BB%B6%E7%9A%84%E5%AD%A6%E6%A0%A1%E3%80%82%E6%81%B0%E6%81%B0%E7%9B%B8%E5%8F%8D%EF%BC%8C%E5%B0%8F%E7%99%BD%E7%9A%84%E5%88%9D%E4%B8%AD%E6%98%AF%E4%B8%AA%E6%8A%80%E6%9C%AF%E6%B5%93%E5%BA%A6%E5%BE%88%E4%BD%8E%E7%9A%84%E5%9C%B0%E6%96%B9%E3%80%82">^2</a>的积极践行者和忠实捍卫者，因此小白在读初中时过的日子是非常轻松且快活的。具体说说的话，每天早晨六点十五分前后到学校，然后上课上到十二点，再就是下午的课和晚自习，晚自习之前还可以抽出三刻钟打打羽毛球，每天都是这样。老师们都很和善，相当友好。因此虽然在学校的时间很多，但也并不那么难以忍受。</p>
<p>我在的地方是个小小的县城，再具体一点的话，这个县城隶属于我这个省级行政区贪污腐败最厉害的一个市。与此相反的是，像初中老师一样的公职人员，连工资都很难到手。因此老师们常戏称，抓一个贪官就好了，一个贪官落马整个区的老师能出好几个月的工资呢。当时我也挺单纯的，不明白这些话的内涵，现在想起来，这不完全是玩笑。不过，我的初中老师都是格外尽职尽责的，可能是因为规模比较小（初中部的一个年级只有 6 个班，小学部不知道），教书用的精力也少，也就可以抽出比较多的时间来安排学生的课业。哦对了，还有一点比较引人眼球的，就是我初中的校服是非常亮的鲜黄色<a href="%5BEncycolorpedia%EF%BC%9A#ffff21%E5%8D%81%E5%85%AD%E8%BF%9B%E5%88%B6%E9%A2%9C%E8%89%B2%E4%BB%A3%E7%A0%81%5D(https://encycolorpedia.cn/ffff21)">^3</a>，导致了我这个学校的学生，好像生来就比别的初中差一等，以至于被人戏称“龙袍”，而这仅仅是因为颜色相对怪异一点罢了。</p>
<p>不过，客观地来说，一直安逸地生活，对人的无论是身体还是大脑，都是很有害的。“生于忧患，死于安乐”的道理早在两千多年前孟子就提出来了，直到现在它还在发光发热。在这么舒服的环境里，小白的斗志也一点点被消磨了。区一模和二模的成绩无时无刻不在提醒小白：你很有机会去市里的高中。人都是喜欢向上爬的，小白也不例外，而且父母也答应了，只要能考到市里的高中，就给我买一台手机。这里无所谓新不新，因为在这之前我根本没有手机，也就无从谈起新与旧的概念了。所以中考前的日子我是相当野心勃勃的，但也正是这虚假的希望蒙骗我了六个月。</p>
<p>所以啊，小白觉得，总是对遥不可及的东西抱有希望的话，是很应该反复思量是否值得的，毕竟到时候愿望落空，也不至于在倒苦水的时候连个由头都找不到，因为这都是自找的嘛。小白的中考成绩相当可惜，差了大约三分就能去另一所市区内的高中，其实也有一部分是运气因素。这里说的运气确实不是小白给自己找了个借口，我参加的是 2024 年中考，而这一年的中考有一个相当恶心人又蛋疼的政策：英语成绩缩水为原来的 90%。小白最擅长的就是英语，在满分 120 分的前提下，常年是能考到 116 往上的，虽然现在不是了。小白中考英语记得是 106.2 分，也就是本来的 118，如果多了这 11.8 分也许小白今天就不会坐在这里敲出这些文字。不过后来也看开了，一切都是最好的安排，这是小白的初三数学老师常常说的话，但其实我还是有点难以接受的，因为小白在高中的这几个月过的一直很不好，去到一个脑海中完全陌生的地方也不见得能稍微笑出两声来。</p>
<p>好了，既然把这些事情的前传都提了一下，那我觉得可以开始说说后面的事了。小白不是一个很讨人喜欢的人，这里说的“讨人喜欢”可以指任何人，当然互联网上另说。现实里的人实在是很难弄清交流的边界在何处，换句话说，我并不能仅仅通过猜测就能把握好交流以至于交友的方法，更糟心的是，猜测是我唯一做的到，而不至于引人仇恨的方法。如果不引人仇恨，我完全可以通过跟相熟的人旁敲侧击一番，获得了点基础信息，下面的事情也好办。所以问题还是回到了小白自己的身上。虽然类似“交友指南”的流媒体在网上一找一大堆，但那些的创作者已经不是学生了，更有甚者都是仅仅面向女生的哇。再说了，小白也不屑于参照那些方法，因为压根没有用。既然视频的创作者脱离学校已久，那自然不可能了解学校的生态，因此发出的意见只能是毫无根据的意淫罢了。</p>
<p>这样想来，相对好点的成绩也在一个侧面上帮助了我。小白最喜欢和人聊天，几乎什么人都聊得起来，这就需要其他人对小白的好感。但是其他人对小白的好感都是有条件的好感，这里的“有条件的好感”同样适用于任何人。这个条件多种多样，缤纷多彩，但总是能给利用这条件的人带来很多好处。我一没有钱，说没有钱还不算准确，简直是穷得叮当响，小偷来光顾都只能望洋兴叹，所幸是没有达到负资产；二没有帅气的脸，三没有权力。本来这三点在我待的这功利主义的具象中是足以致命的，但是天还是给小白留了一个下水道口：就是上面提到的好成绩。好成绩这东西小白本来是不稀罕的，甚至有时候讨厌它讨厌到恨不得就此扔掉，它给我带来的麻烦已经让小白难以承受了。但小白有什么资格谈讨不讨厌它呢？好成绩让别人愿意与小白打交道，老师也能常给小白一点好脸色看，别的不说，至少有个心安。或许从我这个例子就能推理出为什么“交友的方法”只适合女生了。</p>
<p>但是小白也不是一开始就是抱着上面说的心态与人交往的。仅仅是几个月前，小白还是很单纯，很无忧无虑一个人，可能在那样的心态里，也更容易写出来好比<a href="/articles/the-edge-of-a-decade">《The Edge of a Decade》</a>这样相对轻松的作品。不过我现在读起来，反而是觉得非常的沉重了，大概小白的潜意识从那时候就开始腐化了吧，不然其中很多的字句怎么可能会有如此特殊的味道？但是很令小白难过的是，很多人都把它读得相当片面，与小白的本意相离甚远了。不过无所谓吧，本来就是因为追求自由才写出来的小作品，也不苛求什么了。但是，如果要小白现在再写一篇类似的作品出来，是不可能的，因为小白已经不是六个月前的那个小白了。</p>
<p>嗯，在这期间也发生了几件事，也一起拿出来说一说吧。小白的班主任（虽然非常不愿意承认，但这是事实）是一个所谓的，这座高中里唯一一个博士学位的教师。但小白非常讨厌他，他总是拿它那套山河四省的教育模式来死死约束小白班里的同学们和小白自己，并且为此有一套格外离谱的说废话技能，每天都要听像他这样的人在耳边如同蚊子一样嗡嗡嗡，实在是倒人胃口。写到这里，实在是不得不叹气，拜托，少来了。并且他眼中的学生都是应该思想好（乔治·奥威尔《1984》）的，如果有一点违逆他的，下场一定好不到哪去。他绝对是有学校在背后撑腰，这是不争的事实。因为小白的学校正在制造一种不可见的恐怖，这么说是因为平日里体会不到这种恐怖的实体，只有在亲身经历这种恐怖之后，才能知道学校外自由生活的美好，相比之下，可能死亡更像是温柔。并且，小白的学校是会进行网络审查的，他们会安排人专门在微博，贴吧等各种各样的 BBS 搜查学生发泄对学校不满的证据，一旦被发现就不堪设想。为此甚至逼得小白得在 GFW 以外的地方大发牢骚。相当奇怪的是，小白以外的人似乎压根没察觉到这种恐怖，可能是他们太热爱母校了吧。我不热爱，我对这种垃圾场不可能产生一点好感。</p>
<p>2024 年 12 月，小白跟人打架了。原因就是小白看那个弱智非常不顺眼，所以动手了。后果显而易见，小白被罚写了 2,500 字的检讨，毕竟是小白先挑起的，事后也没有什么怨言，但是那个天才班主任又以一副无所不知的样子跳出来了，教育了一番不过瘾还给小白安排了个累活。就在这件事发生后的下一个星期，小白因为赖床被锁在宿舍里，又被罚了 1,000 字的检讨。班主任威胁小白，你再敢干这种事情就把你家长叫过来。小白真想顶嘴：“我没有家长。”但最后还是没有这个勇气，因为小白的力量太弱小了，家长虽然说生了小白养了小白但从来没有一次是向着小白的，他们宁可信任一个连脸都没有见过的人，都不愿意听听自己养了十几年的孩子的想法。我也没有什么想法就是了，有了想法说出来不照样是挨骂？所以我在家里呆着的时候，总是会有很失望的感觉。对于中国教育和中国家庭，我再也没有一句话能说，因为切身体验之后，只剩下了劫后余生的侥幸。再说了，互联网上很多人剖析这些都比小白这种片面的观点要完备得多也清楚得多，所以我不想浪费我的精力分析这种毫无价值的东西了。</p>
<p>现在的小白已经单方面停止结交新朋友了，处于无线电静默状态，还是挺舒服的。印度的非暴力不合作<a href="%5B%E7%BB%B4%E5%9F%BA%E7%99%BE%E7%A7%91%EF%BC%9A%E9%9D%9E%E6%9A%B4%E5%8A%9B%E4%B8%8D%E5%90%88%E4%BD%9C%5D(https://w.wiki/CFi)">^4</a>最后回到了原点，一败涂地，不知道小白的非暴力不合作能够坚持多久，小白希望久一点吧。小白的非暴力不合作是针对任何人的。小白唯一能做的就是什么都不做，突然觉得很悲哀，不是吗？</p>
<p>说点轻松的吧。小白的学校很多人谈恋爱，引得小白也有点向往，如果能找到一个能聊得来的异性，那也不能不称是件美事。但也只是想想而已，毕竟就凭小白这个相貌，真能谈上对象也应该是很久以后的事了。不过，虽不能至但心向往之吧，小白在一开始还是会和邻桌的女生有意识地搭话的。可能因为是一开始，第一印象比较空白，她也看起来比较愿意跟小白聊聊天，小白挺开心的。但是后来也无疾而终了，原因不表。直到后来小白发现自己并不是那么讨人喜欢，才渐渐地死了这条心，也不谈遗憾不遗憾，还是错在自己吧。</p>
<p>后来加了很多人的微信，翻看他们的微信朋友圈时，每天都能看到他们在自己的朋友圈发一些“我很自卑”“没人爱我”之类的……所谓文案吧。</p>
<p><img src="https://pan.moe/f/MGEUn/%E4%B8%80%E4%B8%AA%E6%9C%89%E4%B8%80%E9%9D%A2%E4%B9%8B%E7%BC%98%E7%9A%84%E5%A5%B3%E7%94%9F%E5%8F%91%E5%87%BA%E6%9D%A5%E7%9A%84%E8%87%AA%E5%8D%91%E8%AF%AD%E5%BD%95.jpg" alt="" /></p>
<p>像这个样子，仅仅作为示例。看到这些小白总有种哭笑不得的感觉：姐们你都用上 iPhone 不知道多少了还这样说话吗？是，这样的评价可能确实太偏颇，可能她的确遇到了自己迈不过去的坎呢？可能她的生活跟自己的以前相比起来差别很大呢？再或是，可能她以前很好，现在很差，以至于无法接受这种落差了呢？可是当小白看到那些平时过的不错的人抱怨自己的人生如何如何不顺自己的心意，再对比小白自己已经腐朽的生活，不能不哑然失笑。他们倒成了万青歌曲中的“Singing only love songs, they do not see the tanks.”的那种人了。这一段好像也是说自己的事情，无所谓吧。</p>
<p>我过的很不好，话又说回来了，跟以前的……朋友吧？一个个绝交，心里也不舒服，但最后还是不作为了，这应该是法学里提到的主观故意<a href="%5B%E7%BB%B4%E5%9F%BA%E7%99%BE%E7%A7%91%EF%BC%9A%E6%95%85%E6%84%8F%5D(https://w.wiki/EHEP)%EF%BC%8C%5B%E6%B3%95%E8%A1%8C%E5%AE%9D%EF%BC%9A%E5%88%91%E6%B3%95%E5%85%B3%E4%BA%8E%E4%B8%BB%E8%A7%82%E6%95%85%E6%84%8F%E4%B8%8E%E5%AE%A2%E8%A7%82%E8%A1%8C%E4%B8%BA%E7%9A%84%E8%A7%84%E5%AE%9A%5D(https://ailegal.baidu.com/?fr=seo_qadetail_bing&amp;template=business&amp;articleType=qadetail&amp;articleId=383dd4a4bb9541000605)">^5</a>吧。所以对于我来说，我已经再也没有精力去维护一段注定破裂的关系了，并不是我没有多余的精力，而是那样的话……太累人了，实在是太累了。我现在只想尽可能轻松地活着，看着富哥富姐们纸醉金迷的生活，一对比我自己，悲哀就又出来了，我能做的就是什么都不做，扭过头去不看，装成很热爱生活的样子。这又一次让小白想起了《1984》里描述的一类人：思想犯。只有具有“双重思想”的人（通常管他们叫做思想警察）才能把这种人揪出来，让思想犯在行为中流露他们的思想。我在阅读这一段的时候，心里是毛骨悚然的，如果现实里这种双重思想的人以此为乐，那对于小白来说，实在是一件很恐怖的事情。</p>
<p>最近了解到了一个观点，长期焦虑会导致记忆力降低，再对比小白的现状，小白发现这种观点是很有道理的。小白找不到对抗焦虑的方法，因为生活的腐化就是导致小白焦虑的元凶，而生活的腐化又是来自于现实。我出生，长大在小县城，可能死的时候，落叶归根，也是要回到小县城。而我现在遇到的人们，大多是无法找到我理想的善意的。所以我喜欢互联网，网络上的大家似乎都有很充分的耐心陪我聊天，说说话，教我很多事情，哪怕有时候他们的话很刺耳，但是我也是确确实实学习到了很多东西，这就是互联网的价值吧。现在我日益觉得互联网是个很棒的东西，但我在高中是经历非常严重的网络审查与封锁的，这又是一个让我叹息的地方。高中的屁事真多，如果可以的话，我想要杀死这些伤痛，不要再看着美好的东西离开自己了。</p>
<p>如果你看到了这里，那你真是相当的耐心，谢谢你。</p>
<p>突然发现了一点好玩的。我在写完之后上网冲浪，就发现了一句非常适合概括我现状的话：“学校并不比社会简单，人闲事就多。”</p>
<p><em><strong>(The end)</strong></em></p>
<h2>引用和脚注</h2>
<p><a href="https://www.xinli001.com/info/100493354">焦虑了，为什么会导致记忆力减退？-心理学文章-壹心理</a></p>
<p><a href="https://music.163.com/#/song?id=1394402692">县城 - 刘森 - 单曲 - 网易云音乐</a></p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2025-05-25T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[一道物理小题]]></title>
        <id>https://re.karlbaey.top/articles/a-simple-phys-problem/</id>
        <link href="https://re.karlbaey.top/articles/a-simple-phys-problem/"/>
        <updated>2025-05-18T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[没 blog 可写的时候，Neko 给了我一道物理题。原题：文字版：科考人员从山岭前坡向山势险峻的背坡投掷追踪器来跟踪动物活动路径。山岭可简...]]></summary>
        <content type="html"><![CDATA[<p>没 blog 可写的时候，<a href="https://nekoa.day">Neko</a> 给了我一道物理题。</p>
<blockquote>
<p>原题：</p>
<p><a href="https://pan.moe/f/pz1sQ/Neko_%E7%BB%99%E6%88%91%E7%9A%84%E7%89%A9%E7%90%86%E9%A2%98.jpg"><img src="https://pan.moe/f/pz1sQ/Neko_%E7%BB%99%E6%88%91%E7%9A%84%E7%89%A9%E7%90%86%E9%A2%98.jpg" alt="Neko 给的物理题" /></a></p>
<p>文字版：</p>
<p>科考人员从山岭前坡向山势险峻的背坡投掷追踪器来跟踪动物活动路径。山岭可简化为如图所示的 $ \triangle{ABC} $，$ \angle{BAC}=30 \degree $，$ \angle{ABC} =60 \degree $，科考人员在前坡的同一点 $ M $ 投掷追踪器。第一次以大小为 $ 5 \sqrt{7}\ ~ \text{m/s} $ 的速度投出，追踪器恰好沿背坡表面向下滑动；改变速度第二次投掷，追踪器刚好水平掠过 $ C $ 点。重力加速度 $ \text{g}=10\ ~ \text{m/s}^2 $，忽略空气阻力，两坡足够长。求：
(1) 第一次掷出时的速度方向与 AC 夹角的正切值 $ \tan\alpha$；
(2) 第二次掷出后，追踪器在背坡落点到 $ C $ 点的距离 $ \text{L} $。</p>
</blockquote>
<p>我们用 <a href="https://www.draw.io">draw.io</a> 画一画这道题的示意图：</p>
<p><img src="https://pan.moe/f/M4Efn/%E7%89%A9%E7%90%86%E7%BB%98%E5%9B%BE1.png" alt="draw.io 用着好卡手……" /></p>
<h2>（ 1 ）</h2>
<p>以 M 为原点，AC 方向为 x 轴，BC 方向为 y 轴建立直角坐标系。容易发现当追踪器抵达 C 的时候，追踪器在 y 轴上的分速度为 0。</p>
<p>于是可以得到，重力加速度在 y 轴上的分量 $ g_y = g\cos 30 \degree = 5 \sqrt{3}  ~ \mathrm{m/s^2} $，在 x 轴上的分量 $ \text{g}_x = \text{g} \sin 30 \degree = 5 ~ \mathrm{m/s^2} $</p>
<p>这样，结合刚才说到的，追踪器抵达 C 点后速度平行于 BC，就得到了两条方程：</p>
<p>$$ v \cos \alpha - g_x t = 0 \ v \sin \alpha - g_y t = - v \sin \alpha $$</p>
<p>其中 t 表示运动时间。</p>
<p>解方程，先将式子化简，</p>
<p>$$ 5 \sqrt{21} \cos \alpha = 10 \sqrt{7} \sin \alpha $$</p>
<p>所以，</p>
<p>$$ \tan \alpha = \frac{\sin \alpha}{\cos \alpha} = \frac{5 \sqrt{21}}{10 \sqrt{7}} = \frac{\sqrt{3}}{2} $$</p>
<p>且 t = 2 s。完成。</p>
<h2>（ 2 ）</h2>
<p>因为我用 draw.io 不熟练，这一节我就不打电子图了，看下面的<a href="#%E6%BC%94%E7%AE%97%E8%BF%87%E7%A8%8B">演算过程</a>吧。如果有需要我会考虑做一份电子图出来。</p>
<hr />
<p>这道题的暗示相当明显，<em><strong>追踪器刚好水平掠过 C 点</strong></em>，意思就是，当追踪器经过 C 点时，竖直方向的分速度为 0。也就是经过 C 点后，追踪器做加速度为 g 的平抛运动。记 v' 为初速度，θ 是 v' 和水平面的夹角。</p>
<p>先根据 （1）算出来 M 到 C 的距离 l&lt;sub&gt;MC&lt;/sub&gt; = 10 m。于是，</p>
<p>$$ -(v' \sin \theta)^2 = 2gl_{MC}\sin 30 \degree $$</p>
<p>解得 v'sinθ = 10 m/s。记掠过 C 点前用时 t&lt;sub&gt;1&lt;/sub&gt;，</p>
<p>$$ (v' \sin \theta) t_1 - \frac{1}{2} g t_1^2 = 5 \text{m} $$</p>
<p>解得 t&lt;sub&gt;1&lt;/sub&gt; = 1 s。所以，设掠过 C 点时速度为 v&lt;sub&gt;m&lt;/sub&gt;，</p>
<p>$$ v_m = \frac{l_{MC} \cos 30 \degree}{t_1} = 5 \sqrt{3} ~ \text{(m/s)} $$</p>
<p>后面就是简单地计算平抛运动的位移。</p>
<p>$$ l_x = v_mt_2 \ l_y = \frac{1}{2}gt_2^2 $$</p>
<p>而且我们知道  $$ \frac{l_y}{l_x} = \tan 60 \degree = \sqrt{3} $$，因此容易算出，</p>
<p>$$ L = \sqrt{l_x^2 + l_y^2} = \sqrt{(15\sqrt{3})^2+45^2} = 30 \sqrt{3} ~ \text{(m)} $$</p>
<p>完成。</p>
<h2>演算过程</h2>
<p>这一节主要是为了放我的电子版草稿，以证明我是靠自己做完这道题的。</p>
<p><img src="https://pan.moe/f/E5LtQ/%E7%AC%AC%E4%B8%80%E5%B0%8F%E9%A2%98%E6%BC%94%E7%AE%97%E8%BF%87%E7%A8%8B%E5%92%8C%E3%80%8A%E7%AD%94%E5%8F%B8%E9%A9%AC%E8%B0%8F%E8%AE%AE%E4%B9%A6%E3%80%8B.jpg" alt="为什么有《答司马谏议书》啊喂" /></p>
<p><img src="https://pan.moe/f/84GSk/%E7%AC%AC%E4%BA%8C%E5%B0%8F%E9%A2%98%E6%BC%94%E7%AE%97%E8%BF%87%E7%A8%8B.jpg" alt="写的时候快晕过去了，乱一点的话凑合看看" /></p>
<p>另外给大家看个挺离谱的东西。我做出来之后突发奇想拿这道题去问了两家 AI：豆包和深度求索。TA 们的解题过程都挺有意思的。</p>
<h3>豆包</h3>
<p><img src="https://pan.moe/f/0z8TQ/%E8%B1%86%E5%8C%85%E6%89%80%E7%BB%99%E5%87%BA%E7%9A%84%E6%AD%A3%E7%A1%AE%E8%A7%A3%E6%B3%95.png" alt="答案整整翻了两倍" /></p>
<p>但凡动脑想一下就觉得不对。前坡已经有 30° 倾斜了，如果 tanα 是根号三就代表 α = 60°，敢情科考人员做的是相对于地面的竖直上抛？</p>
<p>而且 TA 还有模有样地给出了答案和明确。</p>
<p>&lt;img alt="image" src="https://pan.moe/f/10dC7/%E8%B1%86%E5%8C%85%E7%9A%84%E7%AD%94%E6%A1%88%E5%92%8C%E6%80%BB%E7%BB%93%E6%98%8E%E7%A1%AE.png" style="zoom:150%;" /&gt;</p>
<p>豆包给的思路是没毛病的，但是计算能力差点意思。（因为豆包跟我一样都是在 M 点建立直角坐标系）</p>
<p><a href="https://www.doubao.com/chat/5629514345525250">原对话地址🔗</a></p>
<h3>深度求索</h3>
<p>&lt;img alt="image" src="https://pan.moe/f/B13Hr/deepseek%E7%9A%84%E7%AD%94%E6%A1%88%E4%BD%86%E6%98%AF%E7%94%A8%E6%97%B6%E9%9D%9E%E5%B8%B8%E9%95%BF.png" style="zoom:150%;" /&gt;</p>
<p>答案是正确的，但是用的方法非常复杂，甚至用到了解析几何的知识。在这一题里用解析几何就是杀鸡用牛刀了。</p>
<p>看用时，居然用了接近五分钟。而且其中的一些思考过程比较复杂，就不在这里一一列举。</p>
<p><a href="https://chat.deepseek.com/a/chat/s/ab7e0afc-f815-4b1c-98cd-22fb0345ba91">原对话地址🔗</a></p>
<p><em><strong>(The end)</strong></em></p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2025-05-18T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[卡尔白算法之路的开山之作：LeetCode20-有效的括号]]></title>
        <id>https://re.karlbaey.top/articles/valid-parentheses/</id>
        <link href="https://re.karlbaey.top/articles/valid-parentheses/"/>
        <updated>2025-04-20T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[欢迎前往我的仓库支持！这是我的解法：变量名解释：s：待检测括号。chars：空栈，存储单个括号用。char：s 中每一个括号。bracket...]]></summary>
        <content type="html"><![CDATA[<p>欢迎前往<a href="https://github.com/Karlbaey101/Karlgo">我的仓库</a>支持！</p>
<h2>题面描述</h2>
<pre><code>给定一个只包括 '('，')'，'{'，'}'，'['，']' 的字符串 s ，判断字符串是否有效。

有效字符串需满足：

1. 左括号必须用相同类型的右括号闭合。
2. 左括号必须以正确的顺序闭合。
3. 每个右括号都有一个对应的相同类型的左括号。

示例 1：
    I: s = "()"
    O: True

示例 2：
    I: s = "()[]{}"
    O: True

示例 3：
    I: s = "([)]"
    O: False

示例 4：
    I: s = "(]"
    O: False

示例 5：
    I: s = "([{}])"
    O: True
    
其中 1 =&lt; s.length =&lt; 10^4
</code></pre>
<h2>解法与逻辑关系的解释</h2>
<p>这是我的解法：</p>
<pre><code>class Solution:
    def isValid(self,s: str) -&gt; bool:
        brackets = {")":"(","]":"[","}":"{"}
        chars = list()
        for char in s:
            if char in brackets: # 优先处理右括号
                if not chars or chars[-1] != brackets[char]: # 栈为空 / 栈末位与待检测括号不匹配
                    return False
                chars.pop()
            else: # 处理左括号
                chars.append(char) # 直接压入栈
        return not chars # 处理完毕，栈为空则 s 有效
</code></pre>
<blockquote>
<p>变量名解释：</p>
<p>s： 待检测括号。</p>
<p>chars：空栈，存储单个括号用。</p>
<p>char：s 中每一个括号。</p>
<p>brackets：存储右-左括号的键-值对的字典。</p>
</blockquote>
<p>算法的要求很简单：每个左括号一定能在它右侧的某一位置找到与它对应的右括号**（反之亦然）<strong>，此时称它与它的右括号为一对</strong>有效的括号**，并且它的右括号<strong>不会</strong>穿插在另一对有效的括号中。</p>
<p>这意味着我需要使用栈，遵循后进先出原则（<strong>L</strong>ast <strong>I</strong>n <strong>F</strong>irst <strong>O</strong>ut），以便于在右括号与它的左括号匹配上时能够及时地将这一对有效的括号弹出，不影响后续的判断。所以用到 Python 的<strong>字典</strong>建立映射关系。</p>
<p>我的困惑是第 7 行代码</p>
<pre><code>if not chars or chars[-1] != brackets[char]:
</code></pre>
<blockquote>
<p>不过在这之前还要检查第 6 行：<code>if char in brackets:</code>。Python 的字典在使用 <code>in</code> 运算符时<strong>只检查键而不是值</strong>，也就是说，<code>brackets</code> 中的全部键均是右括号，只要第 6 行的代码能够判断待检测括号是右括号即可。</p>
</blockquote>
<p><code>or</code> 把前后分成两个条件，也就是 <code>not chars</code> 和 <code>chars[-1] != brackets[char]</code>：</p>
<ol>
<li><code>not chars</code>：当 <code>chars</code> 为空时，这个条件的结果是 <code>True</code>。栈为空还有右括号未匹配自然无效。</li>
<li><code>chars[-1] != brackets[char]</code>：<code>brackets[char]</code>是这个右括号的对应左括号，此时它的左侧（也就是栈的末端）并不是它对应的左括号，说明这对括号无效。</li>
</ol>
<p>这里的关系理顺了，下面直接把左括号压入栈的做法也就很好理解了。</p>
<h2>流程图演示</h2>
<p><img src="/images/ValidPar/ValidPar.png" alt="" /></p>
<h2>时间/空间复杂度</h2>
<p><strong>时间复杂度：</strong> O(n)</p>
<ul>
<li>代码遍历整个字符串一次，每个字符处理时间为 O(1)。字典查找、列表的末尾插入（<code>append</code>）和删除（<code>pop</code>）操作均为常数时间复杂度。</li>
</ul>
<p><strong>空间复杂度：</strong> O(n)</p>
<ul>
<li>最坏情况下（如所有字符均为左括号），<code>chars</code> 列表需要存储所有字符，占用 O(n) 空间。即使存在部分匹配，空间复杂度仍由输入字符串长度决定。</li>
</ul>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2025-04-20T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[【清明节特刊】乐评：碎月群岛BrokenMoon Islands]]></title>
        <id>https://re.karlbaey.top/articles/brokenmoon-islands/</id>
        <link href="https://re.karlbaey.top/articles/brokenmoon-islands/"/>
        <updated>2025-04-04T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[这是一个发生在很久很久以前的故事，发生在传说中的碎月群岛。版权声明：本文封面图使用了原地图文件中打包的世界地图，使用的 <iframe> 嵌...]]></summary>
        <content type="html"><![CDATA[<p>这是一个发生在很久很久以前的故事，发生在传说中的碎月群岛。</p>
<blockquote>
<p>版权声明：本文封面图使用了原地图文件中打包的世界地图，使用的 <code>&lt;iframe&gt;</code> 嵌入仅供演示和辅助本文对于<a href="https://www.bilibili.com/video/BV1vU4y1o7Ki/">《碎月群岛（Piano ver.）》</a>（原作：<a href="https://space.bilibili.com/4522524">幻影刃shadow</a>）和<a href="https://www.bilibili.com/video/BV1AC4y1t7v9/">《Minecraft生存 - 碎月群島》</a>（原作：<a href="https://www.youtube.com/@MrChesterccj">舞秋风</a>）的评论。使用到的<strong>非本人创作素材</strong>版权均属原作者所有。</p>
</blockquote>
<h2>在故事的开始</h2>
<p>&lt;iframe frameborder="no" border="0" marginwidth="0" marginheight="0" width=530 height=86 src="//music.163.com/outchain/player?type=2&amp;id=1966676185&amp;auto=0&amp;height=66"&gt;&lt;/iframe&gt;</p>
<blockquote>
<p>↑这是作者上传到网易云的版本，欢迎前往支持。</p>
</blockquote>
<p>&lt;center&gt;&lt;iframe src="//player.bilibili.com/player.html?aid=682190068&amp;bvid=BV1vU4y1o7Ki&amp;cid=546725811&amp;page=1&amp;high_quality=1&amp;danmaku=0" allowfullscreen="allowfullscreen" width="100%" height="500" scrolling="no" frameborder="0" sandbox="allow-top-navigation allow-same-origin allow-forms allow-scripts"&gt;&lt;/iframe&gt;&lt;/center&gt;</p>
<blockquote>
<p>↑这是作者在 bilibili 上传的版本，点击即可访问原网站。</p>
</blockquote>
<p><img src="/images/BrokenMoon/Brokenmoon1.png" alt="第一集：为您送上鲜花" /></p>
<p>作为一个呜帕，当我听到这段旋律又一次回响在我耳边时，我的心里其实是很又难过又感慨的。难过在无法回到当年看舞秋风一看就是一个下午的快乐，感慨在居然有人在这个流量至上的时代依然记挂着秋风。</p>
<p>然后我反反复复地听，越听反而越能找回最初的感动，于是萌生了为这首钢琴曲写一篇小乐评的想法。毕竟世界上再也找不到像这样的钢琴曲，像一部散文集一样讲完一个完整的故事。</p>
<h2>在故事的中间</h2>
<p>按照它给人最直接的听觉感受，我将整篇谱子划分成五段：</p>
<ol>
<li>00:00 ~ 00:20 楔子</li>
<li>00:21 ~ 01:34 开篇</li>
<li>01:35 ~ 02:52 中间</li>
<li>02:53 ~ 04:48 后面</li>
<li>04:48 ~ 05:08 结束</li>
</ol>
<blockquote>
<p>你的爷爷是个隐居在碎月群岛,</p>
<p>一个很偏僻的群岛的老人家。</p>
<p>有天你乘着船打算去探望很久没见的他,</p>
<p>但到了他家后，却发现他已经去世一段日子了……</p>
<p>你在他的家里发现了他的遗书。</p>
<p>上面写着他一直很想去探望在其他岛上的老朋友,</p>
<p>但碍于行动不便,所以一直没办法去。</p>
<p>你得悉这件事后,</p>
<p>你就觉得你应该帮爷爷完成他没能完成的事情...</p>
</blockquote>
<p>（为了方便描述并且适应我压根不懂钢琴的需要，弹奏部分就用左右手示意弹奏的乐句）</p>
<h3>楔子</h3>
<p>以左手伴奏入场，右手弹旋律。虽然这时候右手相对全篇来说非常的青涩，但是考虑到《碎月群岛》可以理解成主角登岛前发生在主角爷爷身上的故事，青涩感就有了源头。事实上，楔子部分很大程度上呼应了结束部分一阵爆发的感觉，这个我们后面再说。</p>
<p>可能有些可笑，但是楔子部分好像在说“接下来我要说一个‘神威难藏泪’的故事”，如果仅从字面意思上解读的话“神威难藏泪”是很合适的：即使后来经历的事情越来越多，但人童年的时光绝对难以消去。正所谓：</p>
<blockquote>
<p>幸福的人用童年治愈一生，不幸的人用一生治愈童年。</p>
</blockquote>
<p>当然，无论幸不幸福，它就在那儿。</p>
<h3>开篇</h3>
<p>这一段的编曲（我认为）相对大胆。首先为了承接楔子，依然是左手伴奏右手旋律，不同的是，旋律的存在感强了很多，这也意味着：故事开始了，请你坐下好好听我说吧。</p>
<p>虽然说是伴奏，但是左手在乐曲行进中慢慢凸显出来：</p>
<blockquote>
<p>“这是爷爷也曾有过的青春。”我想。</p>
</blockquote>
<p>右手虽然因为反复而略显单调，但是左手右手交替进场弥补了这一点，这样的单调感贯穿了整首曲子，这也是独属于碎月群岛的旋律：单调，但是在平凡中看到了破碎的闪闪月光。</p>
<p>事实上，碎月群岛便得名于此：群岛结构支离破碎，拼合起来又恰好是月亮的形状。</p>
<h3>中间</h3>
<p>这一段的编曲突然昂扬了起来，并且因为和弦大量运用，声音变得十分响亮，但是丝毫不破坏原曲给人的舒适感。</p>
<p>中间编曲有点人青年时期的味道，相当的燃，就是人青年时光芒万丈的样子，似乎整个世界都在你的手里，尽管你的世界只有群岛上的一片洼地。</p>
<p>当然，我不敢臆测在主角登岛前岛上的日子是怎么样的。不过当听到中间部分依然激烈，但仍然留存着一股挥之不去的忧郁感时，我就知道，这一段故事并不是什么英雄往事，它只是一个普通人的故事，没有什么慷慨的陈词，古往今来的英雄很多，唯独不在碎月群岛。在这片土地上只有普通人，像一个普通人一样度过这一生。</p>
<p>确实，如果世界上全是英雄，而没有普通人，世界是绝对运行不下去的，因为普通人才能在一代又一代中被砌入大厦，成为代价。</p>
<blockquote>
<p>离别后的夕阳是如此的美丽　你也应该是知道的吧</p>
</blockquote>
<h3>后面</h3>
<p>后面部分也是全曲的高潮段，我想破脑袋都想不明白作曲人是怎么作出这段旋律的。一种爆发的感觉充满了五线谱，左手在之前一直作为伴奏处理，但它在这一段发挥出了 C 区和弦的作用，感情宣泄全部都在这一段了。</p>
<p>什么感情呢？我认为有两种：一种是对群岛的不舍，一种是作为听众的我们，已经从作为观众的小朋友长成现在这样大，失去了当初的天真（其实也是对碎月群岛的不舍吧）。</p>
<p>听到这里，我的眼泪快被听下来了：在我的童年里，秋风占了很大一部分，直到今天我对 Minecraft 依然抱有这么深厚的感情，秋风功不可没。千言万语汇到指尖竟然无法成句。就留在这里权作为纪念吧。</p>
<h3>结束</h3>
<p>这一段作为收尾，自然而然编得十分平静，同时呼应了最早那一段平静的感觉。每个回到碎月群岛的人都发现这里改变不大，但这里已经没有人了。</p>
<p>引用一些语录吧。</p>
<blockquote>
<p>就像是花开会花谢一样。</p>
<p>花开的时候，花团锦簇，五彩缤纷，生机勃勃，欣欣向荣，让人心生欢喜！</p>
<p>花谢的时候，满眼残败，一地破碎，让人感伤！</p>
<hr />
<p>回不去才让人失落，可以回去是最大的谎言，那里没有人才是真实的</p>
<p>我忘不了小时候电视机上面的那张两个宝宝的图片，不是因为我想念它，而是想到了过去躺在床上，没有忧虑，总是能在看电视时注意到图片，很容易快乐的自己</p>
</blockquote>
<p><a href="https://www.zhihu.com/question/649918737">资料来源→</a></p>
<h2>……</h2>
<p>其实，我姓名下方的 The Rekalyab Islands 就是来自于当年看秋风时学到的 BrokenMoon Islands，每次看起来，不得不令人感慨万千。</p>
<p>如果可以的话，多去支持秋风吧，这是作为一个呜帕最希望看到的事情了。</p>
<p><em><strong>(The end)</strong></em></p>
<h2>参考资料</h2>
<ul>
<li>
<p><a href="https://www.bilibili.com/video/BV1AC4y1t7v9/">【全站最高清!!!】舞秋风Minecraft 碎月群岛 BrokenMoon Islands -</a></p>
</li>
<li>
<p><a href="https://www.bilibili.com/video/BV1vU4y1o7Ki/">【钢琴改编】舞秋风碎月群岛BGM 九年了，你还记得爷爷的墓吗？</a></p>
</li>
<li>
<p><a href="https://music.163.com/#/song?id=1966676185"><em>碎月群岛（Piano ver.）</em></a></p>
</li>
<li>
<p><a href="https://www.youtube.com/@MrChesterccj">舞秋风个人主页</a></p>
</li>
</ul>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2025-04-04T00:00:00.000Z</published>
    </entry>
    <entry>
        <title type="html"><![CDATA[我终于有自己的域名啦！]]></title>
        <id>https://re.karlbaey.top/articles/my-own-domain-karlbaey-top/</id>
        <link href="https://re.karlbaey.top/articles/my-own-domain-karlbaey-top/"/>
        <updated>2025-03-21T00:00:00.000Z</updated>
        <summary type="html"><![CDATA[受阮一峰先生的影响，我对个人博客这一块很早就有了非常浓厚的兴趣，但是碍于技术条件一直未能如愿。不过很幸运的是，我在 2025 年寒假时用上了...]]></summary>
        <content type="html"><![CDATA[<h2>在拥有域名以前……</h2>
<p>受阮一峰先生的影响，我对个人博客这一块很早就有了非常浓厚的兴趣，但是碍于技术条件一直未能如愿。不过很幸运的是，我在 2025 年寒假时用上了个人电脑，也自己学习了怎么用 Hexo 框架搭建博客。不选用 Wordpress 就是因为我认为 Wordpress 虽然傻瓜式操作，部署等操作非常方便，但是太过于笨重，如果我以后要在网页里实现比较复杂的功能那 Wordpress 实在不够看，因此最后选了轻量化而且静态的 Hexo。</p>
<p>2025 年 2 月 8 日，我注册了个人博客站，也就是你看到的这个站点。同天发表了<a href="/articles/my-first-blog">第一篇博客</a>，就此踏上了 blog 之路。本着吃水不忘挖井人的精神，我还发布了一篇教学 <a href="/articles/Hexo-blog-and-you">Hexo 博客与你</a>。</p>
<p>我没有 VPS 或可供当服务器的设备，因此白嫖 GitHub Pages 部署网页。但是显然有弊端：GitHub Pages 只支持如 .html 等的静态页面，对 .php 页面无能为力。不过对我来说无伤大雅，至少我在互联网上有自己的基地了。</p>
<p><img src="/images/NewDomain/FirstCommit.png" alt="第一次提交记录，甚至还失败了" /></p>
<p>看到不知不觉就一个月了心里还是很感慨的，虽然每一步走的都堪称艰难。（因为我的代码技术太差了嘛）</p>
<p>但这么一路白嫖下去也不是个事，看看：karlbaey101.github.io 这个网址，太长了。我需要一个短些的网址。于是……</p>
<h2>Karlbaey.top</h2>
<p>字面意思很好理解，就取了“最顶尖的卡尔白”这个含义。还有一个原因就是 .top 顶级域名比 .com，.cn 之流便宜得多，也省去了备案的麻烦。</p>
<p>{% message color:success size:default icon:"fal fa-heart" title:特别鸣谢 %}
非常感谢 <a href="//nekoa.day">Nekoaday</a> 为我建站和域名选择提供了非常有价值的建议和帮助！相关 issue：<a href="//github.com/Karlbaey101/Karlbaey101.github.io/issues/2">miaomiaolol · Issue #2 · Karlbaey101/Karlbaey101.github.io</a>
{% endmessage %}</p>
<p>域名是在 <a href="//spaceship.com">Spaceship</a> 上购买的，就顺便用了 Spaceship 提供的 DNS 解析服务，不得不说体验非常好。不过后来还是把一些服务托管在 <a href="//cloudflare.com">Cloudflare</a> 上了，因为我需要电子邮箱服务。以后可能会慢慢多起来的。</p>
<p>不过可能很少有人知道我原本是打算买 Karlblo.gs 这个域名的，但是我嫌域名长得不好看最终作罢。Karlbaey 也是个陪伴我很久的虚拟形象了，用这个来做域名显然更好。</p>
<h3>困难和困难</h3>
<p>买域名的时候 DNS 解析一直对不上，甚至出现了被墙的惨状，到最后发现是 Cloudflare 抽风，关掉小黄云后就万事大吉了。</p>
<h3>记一次令人疲劳的网站修复</h3>
<p>{% message color:primary size:default icon:"fa-solid fa-circle-info" title:提醒 %}
本段于 2025-03-23 03:26 编辑。
{% endmessage %}</p>
<p>星期五回到家，发现自己博客文件夹莫名其妙少配置文件了，就打算着索性换一个模板吧，因为我正用着的 Icarus 模板显示范围相当窄，看长篇文字的时候很让人不愉快。</p>
<p>于是就在 GitHub 上找了找，找到一个看着很喜欢的 amazing 模板（够霸气的名字），再细看下来原来是在 Icarus 基础上魔改的，倒也正合我意。</p>
<p>然后依葫芦画瓢把仓库克隆到本地了，结果报错一堆，实在心累。不多说，直接上图吧：</p>
<p><a href="https://github.com/user-attachments/assets/c9c305c9-09ef-4f71-9794-757b97f3e1b5"><img src="https://github.com/user-attachments/assets/c9c305c9-09ef-4f71-9794-757b97f3e1b5" alt="发给 AI 的错误日志" /></a></p>
<p>我不会改代码，就问了 AI，但是它给的方法一点用都没有。我只能自己上必应搜方法慢慢调试，最后终于好了。原因就是：我没有把模板文件夹命名成“amazing”。除了脑残我想不到任何更好的词表达我的愤怒。</p>
<p>光处理 HTML 文件无法生成这个问题就耗掉了我接近一个小时，我也没有耐心去改配置了。现在 <a href="https://karlbaey.top/">karlbaey.top</a> 暂时关闭访问了，预计等到明天我修好站点页面之后再开放访问。</p>
<p>更让我恼火的是，我用的无线鼠标在我正修得热火朝天之时，突然坏掉了……我崩溃。</p>
<p>希望不要有人重蹈我的覆辙，hexo 模板也不要再搞什么反人类的东西了。现在是3:26，非常晚，我睡了。</p>
<h2>以后的计划</h2>
<p>好多想说的，好多想做的，都慢慢来吧。</p>
<ul>
<li>每周更新一篇博客，内容不限，哪怕是碎碎念也算是努力过的证明。一年下来也会有 50 篇的。</li>
<li>一直保留着 Karlbaey.top 这个域名，因为我实在是太喜欢了。</li>
<li>加入<a href="https://foreverblog.cn/">十年之约</a>，认识更多更多的中文 Blogger。</li>
</ul>
<p>嗯，就这样吧。太阳照常升起，又是新的一天。</p>
]]></content>
        <author>
            <name>Jerry Karlbaey</name>
            <uri>https://re.karlbaey.top/</uri>
        </author>
        <published>2025-03-21T00:00:00.000Z</published>
    </entry>
</feed>