Maven: 阿里云镜像 不指定

引用

<settings>
    <!-- omitted xml -->
    <servers>
          <server>
            <id>private_repo</id>
            <username>USERNAME</username>
            <password>PASSWORD</password>
          </server>
    </servers>
    <mirrors>
        <mirror>
            <id>aliyunmaven</id>
            <mirrorOf>*,!private_repo</mirrorOf>
            <name>阿里云公共仓库</name>
            <url>https://maven.aliyun.com/repository/public</url>
        </mirror>
    </mirrors>
</settings>

Intellij IDEA: cannot resolve symbol 不指定

结论:IDEA 是 SH*T.


问题:报错,找不到符号,通常是 Lombok annotation 生成的,比如 @Builder 生成的 XXXBuilder。


【可能】的解决方案

(1) IDE 参数,玄学,可能有用

Help -> Custom VM Option
引用
-Xms2g
-Xmx16g


Settings -> Compiler:
- Shared VM Options: "-Xss1024m"
- Heap Size: 16384 (Mbytes)


(2) 将 IDE 构建/运行操作委托给 Maven
Settings -> Build Tools -> Delegate IDE Build/Run actions to Maven.


(3) 跳过 IDE 的编译

- 使用 mvn compile -T 4 、 mvn test-compile -T 4(编译测试代码)
- 在Run Configuration的 "Modify options" 里选择 "Do not build before run"

Go ASM:实现一个阶乘函数 不指定

先实现一个纯 Go 的版本:

// main.go
package main

import "fmt"

func fac(n uint) uint {
  var result uint = 1
  for n > 0 {
    result = result * n
    n -= 1
  }
  return result
}

func main() {
  fmt.Println(fac(10))
}


修改上述 fac 方法,只保留函数定义(即声明存在该函数,由汇编代码实现):
func fac(n uint) uint


新增 fac.s
TEXT ·fac(SB), $0-8
    MOVQ n+0(FP), CX
    MOVQ $1, DX
LOOP:
    IMULQ CX, DX
    DECQ CX
    JNZ LOOP
    MOVQ DX, result+8(FP)
    RET


编译运行:
引用

$ go run .
3628800


说明:

1. TEXT ·fac(SB), $0-8
- TEXT 表示这个方法在 TEXT 段中
- · 是Unicode的「中点」(中文输入法,1左边的按键),前面省略了包名,表示这是 main 包的 fac 函数
- SB 是 stack base pointer,Go ASM 中的「伪寄存器」(不是硬件寄存器),大致等同于程序的起始地址
- $0-8:0 表示这个函数没有局部变量,8 表示返回值占用8个字节

2. MOVQ n+0(FP), CX
- MOVQ 的 Q 表示 8 个字节
- n+0(FP) 表示变量 n 在 FP(Frame Pointer,伪寄存器,表示这个函数的栈帧起始位置) + 0 的位置(即第一个参数)。注意这个写法形式上是必须得,但是变量名n没有实际意义,只是用来注记。
- CX 即 x86/x86_64 的 CX(16bit),ECX(32bit),RCX(64bit) 寄存器,具体多长取决于前面的指令(MOVQ是64bit)
- 这句的意思是把第一个参数的值写入 RCX

3. MOVQ $1, DX
- $1:$开头的是立即数
- 这句的意思是给 RDX 赋值为 1

4. IMULQ CX, DX
- DX = DX * CX

5. DECQ CX
- CX = CX - 1

6. JNZ LOOP
- JNZ: Jump if Not Zero
- 当 CX 不等于 0 时跳转到 LOOP

7. MOVQ DX, result+8(FP)
- 将 RDX 的值写入到 FP+8 的位置。

8. RET
- 返回到调用方。


p.s. 这个汇编版本的实现并不等同于原来 Go 版本,只是这样写会更简单(只要一个jump)。

参考:
- Golang ASM 简明教程:https://jiajunhuang.com/articles/2020_04_22-go_asm.md.html
- A Quick Guide to Go's Assembler:https://go.dev/doc/asm

Swagger API: 上传文件 不指定

踩了个小坑,记录一下。

swagger api 定义:
引用

/upload:
  post:
    tags:
      - "tag"
    summary: "summary"
    operationId: uploadFile
    consumes:
      - multipart/form-data
    parameters:
      - name: "data"
        in: "formData"
        type: "file"
        required: true
        description: "file content"
    responses:
      200:
        description: "success"
        schema:
          $ref: "#/definitions/UploadFileResponse"


生成的 API:
    @RequestMapping(value = {"/sf/express/upload_channel_file" }, produces = { "application/json" }, consumes = { "multipart/form-data" }, method = RequestMethod.POST)
    default ResponseEntity<UploadFileResponse> uploadFile(@ApiParam(value = "file detail") @Valid @RequestPart("file") MultipartFile data) {
        return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED);
    }


直接用这个 API 请求会报错:
swagger Required request part 'file' is not present

细看发现是 swagger 生成的是 @RequestPart("file") 而不是 @RequestPart("data"),需要手动修改过来才能正确读取到文件字段。

Java: NPE和单测覆盖率 不指定

切到新语言就是不断地踩坑呀。

手头有一个函数,输入一个 itemList,统计不同类型的个数,核心代码如下:

int xCount = 0, yCount = 0;
switch(typeParser.parse(item)) {
  case X:
    xCount++;
    break;
  case Y:
    yCount++;
    break;
  default:
    log.error("unknown type");
    break;
}


其他 typeParser 在遇到异常数据时会返回 null。

重构的时候,稳妥起见为它写了个单测,结果就在上述代码的第二行 NPE 了。

debug 时确认了 typeParser 和 item 都不是 null,猜测是 switch 不能处理 null ,但不熟悉 java 语法,搜了一下,果然如此。

由于 case 数量不多,就暂且改成了 if ... else if ... else 的结构。

由此可见,单测对于代码的覆盖率确实是很有帮助。

解决 Intellij IDEA 不识别某些 class 的问题 不指定

又遇到一个灵异的 intellij idea 问题。

背景:在项目 P 的同一个 Module M 下面, PKG P1 需要 import PKG P2 下面的 C1 和 C2 两个 class。

现象:import C1 成功,但是import C2失败;然而通过 mvn clean install 是可以正常完成编译的。

点击在新窗口中浏览此图片

删除 project 下的 .idea 目录重新 import 并没有解决这个问题。

在另一个同学的电脑上尝试,报的错竟然不一样,是在 C2 这个 class 下无法 import 另一个 class C3,但同样可以通过 mvn 命令行完成编译。

通过 Google 搜到 StackOverflow 的这个 thread:

  https://stackoverflow.com/a/66167190/802910

解决方案很简单:删掉 idea 的cache 目录,让它重建就好了。

在 windows 下,这个目录位于 %LOCALAPPDATA%\JetBrains\IdeaIC2021.2\caches (注意替换为自己版本号)

在 mac 下,目录应该是 ~/Library/Caches/JetBrains/IdeaIC2021.2

似乎 IDEA 本身也有清空 cache 的功能(File -> Invalidate Caches...),下次遇到再验证一下。

搞事:代码找茬 不指定

最近老是想起陈芝麻烂谷子的事情,说明工龄所剩无几了。 

- 1 -

又是在那遥远的 2009 年,那个“杯具”已经不是杯具的年头,度厂办个算法比赛,办出了点儿杯具的味道。 

比赛的名字叫“百度之星”,那些年在校园里影响力还蛮大的(好像现在还是),大概赛制就是通过初赛、复赛、决赛这么几轮,选出几个社会主义四有青年瓜分奖金。值得一提的是,头两年(05、06)冠军都被楼教主承包了。

为什么说有点杯具的味道呢,因为那年的初赛结束以后,度厂搞了个骚操作,把所有参赛选手提交的代码导出来,打了个包,发在贴吧里。

于是贴吧突然就炸了。 

点击在新窗口中浏览此图片


- 2 -

要知道初赛是网络赛,各个学校的 ACM(或OI) 集训队的队员们很可能都是凑在机房一起参与,毕竟毛主席教导我们人多力量大嘛。

点击在新窗口中浏览此图片

所以这就有点意思了:如果同一个题,两份代码长得很像,说明互相抄袭概率就很大。

问题是参赛人数有点多,两两对比这 O(n^2) 的时间复杂度,靠人肉有点儿吃不消;但这么大一个瓜,光看不吃又有点可惜了。

那么这瓜到底该怎么吃呢?


- 3 -

还好,作为一个竞赛战五渣的我,通过初赛的能力没有,搞事情的能力还是有一点的。

点击在新窗口中浏览此图片

为了对比两份代码的相似度,那我首先得找到一个指标。

作为一个善于 作弊 思考的社会主义三无青年,我估计作弊者在拿到弊友代码后不会直接提交,至少会做些更换变量名之类操作;但竞赛时间紧迫,大概率来不及修改整体框架。 

那么,只要我能算出修改的程度,就能判断相似度了:假设两份代码长度分别是 m、n,修改了 x 个字符, 我们大致可以把 `100% - x / ((m + n) / 2)` 当成相似度(没被修改的字符占比)。

但如何计算这修改的程度呢?毕竟修改前后代码长度很可能不同,不适合通过逐字比较来找到差别。 


- 4 -

既然直接处理两段代码有点棘手,那不妨先考虑一下简单的 case 。 

比如说要将字符串 "a" 改成 "b",这个简单,只要改一个字符就行。

又比如说要将字符串 "a" 改成 "ab",这个也简单,只要添加一个字符就行。

再比如说要将字符串 "ab" 改成 "b",这个依然简单,只要删除一个字符就行。

如果我能算出将一段字符串通过添加、删除、修改三种操作修改成另一个字符串的最少操作次数,应该就能把这瓜给吃了。

点击在新窗口中浏览此图片

好像是找到了方向,但是要怎么实现呢? 


- 5 -

既然直接处理两段代码还是有点棘手,那不妨再考虑一下稍微复杂一点的 case ,比如把字符串 X = "baidu" 变成 Y = "bytedance"。

首先,因为 X[0] 和 Y[0] 相同,我们可以把问题简化成:将 "aidu" 变成 "ytedance",即 X[1..4] => Y[1..8](后面简记为 X[1:] => Y[1:])。 

接着,X[1] = 'a', Y[1] = 'y',如上一节所说,这时候我们有三种选择:

* 添加:给 X 添加 'y' ,当成 "byaidu",于是问题简化为将 "aidu" 变成 "tedance",即 X[1:] => Y[2:];(注意,我们在这里把该操作当成一个思维实验就好,不需要真的修改 X ,所以这里 "aidu" 对应 X[1:] 而不是 X[2:])
* 删除:从 X 中删掉 'a' ,当成 "bidu",问题简化为将 "idu" 变成 ytedance",即 X[2:] => Y[1:];
* 修改:把 X 这个 'a' 改成 'y',当成 '"byidu",问题简化为将 "idu" 变成 "tedance",即 X[2:] => Y[2:];

这三种操作中,后续需要的修改次数最少的那个,再加上 1,就是我们把 X[1:] 改成 Y[1:] 所需的最少次数。

依此类推,如果我们将 X[i:] 变成 Y[j:] 需要 f(i, j) 次操作,由上可得到这样一个公式:

if X[i] == Y[j]:
  f(i, j) = f(i + 1, j + 1)
else:
  f(i, j) = 1 + min(
    f(i, j + 1),    #添加
    f(i + 1, j),    #删除
    f(i + 1, j + 1) #修改
  )


也就是说,我们可以用子问题的最优解来推导出问题的最优解,如果用不说人话的方式,就叫做该问题存在最优子结构

这么一看,代码是不是呼之欲出了? 


- 6 -

啰嗦了这么多,不能忘了大佬的教导: 

点击在新窗口中浏览此图片

说干就干,把上面推导的公式落地成代码,so easy:

def change(x, y):
  def f(i, j):
    if x[i] == y[j]:
      return f(i + 1, j + 1)
    else:
      return 1 + min(
        f(i, j + 1),
        f(i + 1, j),
        f(i + 1, j + 1)
      )
  return f(0, 0)
   
print change("baidu", "bytedance")


然后跑起来一看:

$ python change.py
Traceback (most recent call last):
  File "change.py", line 13, in <module>
    print change("baidu", "bytedance")
    ... #省略一大段
  File "change.py", line 3, in f
    if x[i] == y[j]:
IndexError: string index out of range


点击在新窗口中浏览此图片


- 7 -

"index out of range",越界了 —— 显然,这里漏掉了递归的终止条件。

不过这个简单:如果 X 越界了,说明这时 i == len(X),那么 Y 还剩几个字符,我们都加上就行,即还需要 len(Y) - j 次操作;或者 Y 越界了,说明 j == len(Y),我们把 X 剩下的几个字符删掉,即 len(X) - i 次操作。

于是代码变成了:

def change(x, y):
  def f(i, j):
    if i == len(x):
      return len(y) - j
    if j == len(y):
      return len(x) - i

    if x[i] == y[j]:
      return f(i + 1, j + 1)
    else:
      return 1 + min(
        f(i, j + 1),
        f(i + 1, j),
        f(i + 1, j + 1)
      )
  return f(0, 0)

print change("baidu", "bytedance")


跑起来效果杠杠的,马上就得到了 7,算得没毛病。

只可惜这代码还没法用 —— 别说跑那些参赛代码,就连长度为 16 的两个字符串都跑不出结果…… 


- 8 -

稍微分析一下我们就会发现,它竟然是指数时间复杂度的:

点击在新窗口中浏览此图片

不过仔细观察上图(不仔细也没关系,我给红色高亮了),我们可以发现,为了计算 f(i, j),我们需要计算 f(i + 1, j + 1);而且它也被用于计算 f(i, j + 1) 和 f(i + 1, j) 。

这意味着这个问题存在重复子问题,类似于我们用 f(i) = f(i - 1) + f(i - 2) 计算 飞波纳妾 fibonacci 数列。

因此我们完全可以将 f(i, j) 存下来、避免重复计算,就像这样:

def change(x, y):
  cache = {}
  def f(i, j):
    if i == len(x):
      return len(y) - j
    if j == len(y):
      return len(x) - i

    if (i, j) not in cache:
      if x[i] == y[j]:
        ans = f(i + 1, j + 1)
      else:
        ans = 1 + min(
          f(i, j + 1),
          f(i + 1, j),
          f(i + 1, j + 1)
        )
      cache[(i, j)] = ans

    return cache[(i, j)]

  return f(0, 0)


注:实际上这里也可以直接用一个二维数组 f[i][j] 来保存 f(i, j),对 C/C++ 来说实现会更容易,读写效率也更高。 

优化以后,因为每个 f(i, j) 只需要计算 1 次、并且可以在常数时间里完成(因为子问题已经计算好了),我们将时间复杂度降到了 O(m * n) ,从而可以在短时间内计算出结果了。


- 9 -

在这个问题里,还有一个很重要的特性是,我们在计算 f(i, j) 的时候,并不关心从 f(0, 0) 到 f(i, j) 的推导过程,或者说从 f(0, 0) 推导到 f(i, j) 的过程对后续计算 f(i, j) 没有任何影响。用不说人话的方式,这就叫做无后效性

注意,无后效性并不是对所有问题都成立的。

比如在一个 N * N 的迷宫里寻找从坐标 (1, 1) 走到 (N, N) 的一条路径,我们在使用 BFS 或 DFS 搜索时需要将路过的坐标做好标记,避免再次走到,因此从 (1, 1) 到 (x, y) 的路径是会影响从 (x, y) 到 (N, N) 的路径,不满足无后效性。 

汇总一下前面提到的这个问题的几个特点: 

* 最优子结构
* 重复子问题
* 无后效性

—— 这不就是标准的 动态规划 问题嘛!对应的,第 5 节我们推导出的公式,就是这个动态规划问题的状态转移方程了!

点击在新窗口中浏览此图片

由于满足这几个特性,我们实际上可用迭代的方式,从 f(m, n) 倒推出 f(0, 0),这样可以使用循环而不是递归,进一步提高代码的运行效率。 

另外,对于同一个动态规划问题,状态转移方程也可以不止有一种写法,比如我们可以定义 g(i, j) 为将 X[1..i] 变为 Y[1..j] 的最小操作次数,于是: 

if X[i] == Y[j]:
  g(i, j) = g(i - 1, j - 1)
else:
  g(i, j) = 1 + min(
    g(i, j - 1),
    g(i - 1, j),
    g(i + 1, j + 1)
  )


基于这一版状态转移方程,我们就可以从 f(0, 0) 开始逐步计算出 f(m, n)。 

具体代码就不在这里贴了,感兴趣的同学可以自己写写看,注意边界值的处理就好。

实际上,这个算法叫做编辑距离,英文名 Levenshtein distance,是一个和普京同名、姓 Levenshtein 的战斗民族大佬在 1965 年提出的算法;该算法经典到 php 的标准库里竟然包含了一个 levenshtein 函数(可见php的作者真是太闲了)。


- a -

还没完,在追求性能和效果的路上我们还能走得更远,根据代码本身的特性,我们还有一些优化可以做。

比如说代码的注释是可以先删掉再比较。

比如说代码里的字符串也可以删掉再比较。 

比如说代码里的空格,貌似也不影响我们的相似度对比。

通过这样的操作,我们可以大幅缩短了需要对比的代码长度,对于 O(m*n) 的算法而言,这个改善是很可观的。 

此外,当时下场切瓜的另一位瓜友将这个思路推到了极致:既然我们认为代码的整体框架不变、改变的只是变量名这些东西,那我们直接把所有字母、数字、空格等字符全删除,只留下标点符号来代表代码的框架,不是更香吗? 

这样我们既提升了计算的效率,又进一步提升了计算的效果。 

值得一提的是,该瓜友用的不是编辑距离,而是另一个经典的动态规划算法“最长公共子序列(LCS)”。 


- b -

啰嗦了这么多,终于可以写总结了:


* 计算两个文本的相似度,可以使用编辑距离或者最长公共子序列算法;这两个都是典型的动态规划算法;
* 动态规划问题需要满足最优子结构、重复子问题、无后效性;
* 要解决动态规划问题,先定义状态,推导状态转义方程,再编码;可以用递归也可以用迭代,前者实现简单,后者运行更快;
* 搞事情要考虑后果,本不应该把参赛账号名也直接公开,结果是那次比赛有些人面子上有点不好看;不过年轻时谁没犯点错呢,除了自己谁又还记得呢? 

最后,“编辑距离” 是个 LeetCode 的原题(No. 72,传送门),;“最长公共子序列” 也是个 LeetCode 的原题(No. 1143,传送门),感兴趣的同学也别错过。

都看到这儿了,帮忙点个 “赞” 或分享给你的小伙伴吧,感谢~

---

推荐阅读:

* 程序员面试指北:面试官视角
* 又是面试题?对,合并有序序列。
* 踩坑记:go服务内存暴涨
* TCP:学得越多越不懂
* UTF-8:一些好像没什么用的冷知识
* (译) C程序员该知道的内存知识 (1)

又是面试题?对,合并有序序列。 不指定

- 鹅厂 -

在遥远的2009年,那时候“呵呵”还没有奇怪的意思,我笑呵呵地去参加了鹅厂的实习招聘。 

面试被安排在面试官下榻酒店的房间里,校门口的**王朝大酒店,可能一晚上能顶我一个月生活费那种。

点击在新窗口中浏览此图片

过程聊得应该还可以,不过大部分细节都忘了,只记得最后那道代码题,一张纸,一支笔。

题面很简单:写一个 C 函数,合并两个有序数组。

- “最好能通用一点”,面试官补充说。

- “可以用 C++ 模板吗?”

- “最好还是用 C 。”

好多年以后,当我开始面试别人了,发现这道题确实很好用。 


- 解 -

学过归并排序的同学应该都会觉得这个题目并不难,只不过是其中的一次归并环节。

其基本思路是,用两个指针,分别从数组的第一个元素开始,依次比较,每次找到最小的元素存入新数组,然后将指针移动到下一位。

需要注意的是当一个数组被取完以后,还得处理另一个数组的剩余元素。

而所谓“通用”,是指数组的元素可以是任意类型,因此需要把数组元素的大小、用于比较的函数也作为参数传进去。

大概就是要实现这样的一个函数:

typedef int cmpfunc(void *x, void *y);
void* merge(void *A, int m, void *B, int n, int size, cmpfunc f);


其中 m、n 分别表示 A、B 这两个数组的长度,size 表示数组元素的大小。 

具体实现的 C 代码比较琐碎,就不在这里贴出来了,感兴趣的同学可以自己试着写一下。 

- WHY -

我在上一家公司,通常用这道题当笔试的压轴题,但不限制语言,以及去掉了对“通用”的要求。 

为什么选它呢?

首先,它很容易理解,不会产生歧义,不需要额外解释。

其次,在纸面上编码(至少是脱离IDE),程序员在编码前得想清楚;涂改较多也说明一些问题。

最重要的是,它有很好的区分度,因为真的有很多程序猿没认真学过归并排序。

但至少每个人都能想到将两个数组合并,然后进行排序。

有些特别直接的小伙伴,就用了 PHP 自带的 sort 函数,后来我们不得不加个说明:“避免使用库函数”。

至于排序算法,有人写冒泡,也有人写快排;快排的实现,又可以考察是不是在数组里做原地划分(大部分是拆到两个数组里再合并)。

并且,我们在题面上特地对 有序 两个字加粗、加下划线,引导候选人使用最优解法。

如果候选人最终仍然实现了排序解法,在面试中还可以再提示,是否能用上“有序”这个条件,进一步提高性能。 

这样层层递进,能够较好地帮助我们判断候选人的编码能力。 

不过机关算尽,还是遇到了比较挫败的case,比如一个候选人就反问:系统自带的函数效率最高啊,为什么要自己实现? 

点击在新窗口中浏览此图片


- 字节 -

到了字节跳动后,我发现这道题有点不够用了,撑死只能算 LeetCode Easy,对于有勇气来面试字节的候选人,通常都不在话下。

为了把它升级到 Medium,我想到了两个改动: 

1. 两个不够,m个来凑
2. 数组太简单,得换成链表

然后一看,诶,这 tm 不就是 LeetCode 23 原题吗? 

点击在新窗口中浏览此图片


话说回来,这题就变成了:请把 m 个有序链表合并成一个新的有序链表;平均每个链表有 k 个节点。


- 解² -

不用说,所有候选人都能想到每次遍历所有链表、找到最小值加入新的链表。 

对于选择这个思路的候选人,接下来的问题是:

Q1:这个方案的时间复杂度是多少呢?

有不少候选人回答 O(m * k),大概是觉得两个链表合并是 O(2 * k),m个链表合并自然是 O(m * k) 了。

实际上,使用这种思路,每次找到最小值需要逐个比较 m 个链表,这个操作需要执行 m * k 次(节点总数),因此总的时间复杂度应该是 O(m^2 * k)。

Q2:还有优化空间吗?

有些候选人确实想不到更优的解法,但只要能按这思路完成 bug free 的代码,综合面试中的其他表现,也可以通过我们的考查(详见 程序员面试指北:面试官视角)。

毕竟 LeetCode 23 原题可是 Hard 级别。


- 分治 -

对分治算法比较熟悉的候选人会想到,可以先两两合并,得到的 m / 2 个链表再两两合并,循环这个过程,直到只剩下一个链表。

然后又回到 Q1:这个方案的时间复杂度是多少呢?

这回答就千奇百怪了,O(m * log(k)),O(k * log(m)), O(m * k * log(k))……

这个计算其实不难:

* 第一轮需要 m/2 次两两合并,每次两两合并是 2k 次比较,合计 m*k
* 第二轮需要 m/4 次两两合并,每次两两合并是 4k 次比较(每个链表平均长度变成2k了),合计还是 m*k
* ……
* 对 m 个元素做二分,总共需要 log(m) 轮 

所以合理的答案应该是 O(m * k * log(m))。

具体实现又可以分成上下两部分。

下层应该实现一个合并俩链表的逻辑,比较常见的错误是没能正确处理链表的头结点(比如直接当成尾节点用,或者忘记初始化,以及 C++ 小伙伴用了 new 以后常常忘了 delete),还有前面说到的,一个链表摘空了后,需要处理另一个链表剩下的节点。

上层的实现其实和归并排序长得一毛一样,可以 bottom-up,也可以 top-down。bottom-up 的实现常见的错误是没处理好落单的那一个,而 top-down 则需要注意递归的终止条件。

另外有点意外的是,不少 Java 小伙伴被 List 这个 Interface 荼毒还挺深,在编码的时候顺手就用 List.get(i) ,完全不考虑这个 API 的开销。

点击在新窗口中浏览此图片


- 最小堆 -

对常见数据结构比较熟悉的候选人则会提出使用最小堆,这样可以将每次查找最小值的时间复杂度降为 log(m) ,于是总的时间复杂度也可以降为 O(m * k * log(m))。

既然提到了堆,那就可以顺便问一下,最小元素从堆顶被摘掉以后,如何调整堆?

于是那些只知道可以用最小堆、不知道如何实现堆的候选人就暴露出来了。

不过也不打紧,大部分语言的库里都实现了 PriorityQueue 这个数据结构,让候选人直接用语言提供的版本来编码就好。

具体的代码主要有两个坑,一是循环中要注意对摘空链表的处理,二是对链表头结点的处理(前面提到了)。

- 没完 -

面试到上面的程度就足够了,不然 45 分钟实在是不够用。

但其实还有些值得思考的问题没讲完。

比如说,这两种算法,平均时间复杂度都是 O(m * k * log(m)),到底哪一个更好呢?

分治算法的优势是,两两合并时,当一个链表为空,可以直接将另一个链表的剩余节点串起来,相比于堆算法可以节约一些时间。 

另一方面,对于这样一个经典的多路归并问题,实际使用场景可能是要合并外存上的多个排好序的文件,这时候堆算法可以节约磁盘IO(只需要一次遍历),相比于分治算法就有了压倒性的优势。

所以具体还是要看场景。

再比如,在这个场景下,堆并不是最高效的数据结构。 

实际上,堆算法只是多路归并的早期实现,由于每一层的调整都需要两次比较(先取出两个子节点的较小者,然后再和当前节点比较),其效率还有优化空间。 

点击在新窗口中浏览此图片

(堆的调整) 

如果我们将用于比较的节点作为叶子节点构建一棵完全二叉树,从叶子节点往上只保存获胜的节点:

点击在新窗口中浏览此图片

这样每一层只需要和其兄弟节点做比较即可。这就是所谓“胜者树”,说起来还是空间换时间的套路(多一倍的节点数)。 

还没完 —— 这个方案对每一层的更新仍然需要访问3个节点(自己、兄弟节点,父节点),换个思路,如果我们在路径上只保存失败的值,再用一个额外的变量保存在根节点比较的获胜者:

点击在新窗口中浏览此图片

于是我们对每一层的更新只需要访问当前节点和其父节点就好了。

由于每次保存的是失败者,这个方案又被称为“败者树”。


- 小结 -

这篇没有贴具体的代码,没试过的同学,正好可以用 LeetCode 23 来练手(传送门)。

照例小结一下:

* 笔试/面试题的区分度很重要;
* 归并排序是基础,bottom-up 和 top-down 都要熟;
* 多路归并可以用分治和堆来解决,时间复杂度最优;
* 通过败者树可以进一步优化堆的解法。

喜欢本文的小伙伴,别忘了分享给你的小伙伴,感谢~ 

---

推荐阅读:

* 程序员面试指北:面试官视角
* 踩坑记:go服务内存暴涨
* TCP:学得越多越不懂
* UTF-8:一些好像没什么用的冷知识
* (译) C程序员该知道的内存知识 (1)