每日算法 — 使用java实现听歌识曲:Smith-Waterman局部比对与旋律片段定位

“听歌识曲”是每个音乐 App 都有的功能:用户随便哼一段旋律,系统在几百万首曲库里找出”你在哼的是哪首歌的哪一段”。难点不在于比对两段完全对齐的音符序列——那是常识;难点在于用户哼的往往只是整首歌中的八秒钟,前后都没哼,音高还可能整体跑调、漏掉几个音、多哼几个音。

这就是”在一条长序列里找一段局部最相似的子序列”,它的标准答案不是编辑距离,也不是最长公共子序列,而是 1981 年 Temple Smith 与 Michael Waterman 提出的局部序列比对(local alignment)算法。它的核心只有一句话:打分矩阵的下界是 0,而不是负无穷。这一行改动让算法自动学会”从哪里开始比、到哪里就停”,不需要人工指定片段边界。

本文用哼唱识曲作为场景,把 Smith-Waterman 讲透:为什么下界必须是 0、回溯怎么截断、为什么它对整体移调(transposition)天然免疫、以及如何用带状优化把 O(NM) 压到工程可用的水平。附完整 Java 实现与对拍测试。

一、把”哼唱”变成序列比对问题

先把音频抽象掉,只留符号层。一首歌的旋律可以表示成一串相对音高变化:不走绝对音高(Hz / MIDI 值),而走”相邻音程”(+2 表示上行两个半音,-1 表示下行一个半音)。这个表示有个决定性好处:

  • 用户整体升高八度哼唱,所有音程不变;
  • 用户从副歌任意一句开始哼,对应原曲的一个子串;
  • 哼错一个音,对应一个字符替换;
  • 漏哼一个音,对应一个删除;多哼一个音(比如拖长音被判成两个),对应一个插入。

于是问题变成:给定查询序列 Q(长度 M,用户哼的,约 100~300 个音程)和曲库序列 T(长度 N,一首歌几千个音程),在 T 中找一个子串,使其与 Q 的某个子串的相似度最高。这里的相似度定义为带打分的比对:相同 +2、不同 -3、空隙 -4。

注意”子串对子串”这个措辞——它正是”局部比对”的定义:两端都允许自由截断。如果只允许 Q 整体对齐(两端不许截),那是半全局比对(semi-global / glocal),适用于”用户完整哼了整首短曲”。两者的递推只差边界初始化,后文会给出两种版本的差异。

二、为什么下界必须是 0

先看经典全局比对 Needleman-Wunsch 的递推(H 为累积得分):

H[i][j] = max( H[i-1][j-1] + s(a_i, b_j),    // 匹配/替换
               H[i-1][j]   + gap,             // a_i 对空隙
               H[i][j-1]   + gap )            // b_j 对空隙

边界是 H[0][j] = j*gap、H[i][0] = i*gap,都 ≤ 0。这套递推回答的是”整条 A 对整条 B 的最优比对”。

Smith-Waterman 只改一处:

H[i][j] = max( 0,                            // ★ 局部重启
               H[i-1][j-1] + s(a_i, b_j),
               H[i-1][j]   + gap,
               H[i][j-1]   + gap )

这个 0 的语义是:任何时刻都可以”重新开始一段新的局部比对”,代价为零。于是 H[i][j] 不再是”前缀 i 与前缀 j 的最优得分”,而是”以 (i, j) 为结尾的最优局部比对得分”。全表最大值就是全局最优局部比对——不需要指定起止位置,算法自己找。

为什么加一个 0 就够了?因为 H 是”以此格为终点”的得分,若历史得分为负,说明前面那段比对是负担,丢掉它更好——这正是 0 在做的事。数学上这就是 Kadane 最大子段和思想在二维网格上的推广:一维是”最大子数组”,二维是”最大得分子比对”。

反过来说,如果下界写成 -∞(严格的全局比对),算法会被迫把整条 Q 和整条 T 对完,即使中间那段是完全无关的噪声,得分被稀释成负值,最优位置和片段边界都找不出来。这是新手最常犯的错:用了 SW 的代码结构,但边界条件抄了 NW,结果”永远只对齐开头”。

三、回溯:从最高分格子往回走,撞 0 就停

找答案分两步:先扫出 H 中的最大值 best 及其位置 (bi, bj),然后从 (bi, bj) 沿”得分来源”反向走:

while (i > 0 && j > 0 && H[i][j] > 0) {
    int d = cfg.score(A[i-1], B[j-1]);
    if (H[i][j] == H[i-1][j-1] + d) { 记录匹配/替换; i--; j--; }
    else if (H[i][j] == H[i-1][j] + gap) { 记录 A 侧空隙; i--; }
    else { 记录 B 侧空隙; j--; }
}

三个终止条件缺一不可:

  1. H[i][j] > 0:撞到 0 说明这一段从这里起就”不值得开始”,必须停。少了它,回溯会一路走到边界,把大量无关前缀吸收进来。
  2. i > 0 && j > 0:数组边界,防止越界。
  3. 得分来源唯一:三种走法可能给出相同的 H[i][j](比如 s = -2, gap = -2 时),此时先匹配优先,这是所有实现的惯例;它不影响得分,只影响”报出的片段边界”,对识曲来说边界差一个音无关紧要。

回溯得到的两段子串,就是”用户哼的片段”与”原曲对应片段”。注意这两段长度可以不等——中间有插入/删除,这就是为什么不能简单地拿”对齐后的绝对位置差”当偏移量。

四、完整 Java 项目

目录结构:

humming-search/
├── pom.xml
└── src/main/java/com/keli/humming/
    ├── Interval.java          // 音程序列(相对音高)
    ├── SWSettings.java        // 打分参数
    ├── LocalAligner.java      // Smith-Waterman 主实现(含带状优化)
    ├── SongIndex.java         // 曲库 + 逐曲打分 + TopK
    ├── LocalAlignerTest.java  // 与暴力枚举对拍
    └── HummingSearchDemo.java // 入口

pom.xml:

<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
                             http://maven.apache.org/xsd/maven-4.0.0.xsd">
  <modelVersion>4.0.0</modelVersion>
  <groupId>com.keli</groupId>
  <artifactId>humming-search</artifactId>
  <version>1.0.0</version>
  <properties>
    <maven.compiler.source>17</maven.compiler.source>
    <maven.compiler.target>17</maven.compiler.target>
    <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
  </properties>
</project>

4.1 序列与打分参数

package com.keli.humming;

/**
 * 音程序列:相邻音符的半音差。
 * 用 byte 存,因为音程范围通常落在 [-24, +24],一个字节绰绰有余。
 * 采用相对音高而非绝对音高,是为了让整体移调(用户高八度哼)不影响结果。
 */
public final class Interval {
    private final byte[] data;
    private final float[] onsets;   // 每个音程的起始时间(秒),用于报出"第几秒"

    public Interval(byte[] data, float[] onsets) {
        if (data.length != onsets.length) {
            throw new IllegalArgumentException("音程与时间戳长度不一致");
        }
        this.data = data;
        this.onsets = onsets;
    }

    public int length() { return data.length; }
    public byte get(int i) { return data[i]; }
    public float onset(int i) { return onsets[i]; }
}
package com.keli.humming;

/**
 * 打分参数。三个值是整条算法的"性格旋钮":
 *  - match 越大,越强调"音高对得上";
 *  - mismatch 越负,越抗拒错音;
 *  - gap 越负,越抗拒插入/删除。
 * 经验配置(沿用生物信息学里 BLAST 的默认值思路):
 *   match=+2, mismatch=-3, gap=-4  -> 宁可错配也不留长空隙
 */
public record SWSettings(int match, int mismatch, int gap) {
    public static final SWSettings MUSIC_DEFAULT = new SWSettings(2, -3, -4);

    public int score(byte a, byte b) { return a == b ? match : mismatch; }
}

4.2 核心:Smith-Waterman

package com.keli.humming;

import java.util.ArrayList;
import java.util.List;

/**
 * Smith-Waterman 局部比对。
 *
 * 时间复杂度 O(M*N),空间 O(M*N)(带回溯);
 * 若只要得分不要对齐路径,可把空间压到 O(min(M,N))(两行滚动)。
 * 本实现提供 align(完整回溯)与 scoreOnly(只要得分)两个入口。
 */
public final class LocalAligner {

    private final SWSettings cfg;

    public LocalAligner(SWSettings cfg) { this.cfg = cfg; }

    /** 比对结果。aStart..aEnd / bStart..bEnd 均为左闭右开 */
    public record Result(int score, int aStart, int aEnd, int bStart, int bEnd,
                         List<int[]> path) {
        public boolean found() { return score > 0 && aEnd > aStart && bEnd > bStart; }
    }

    /**
     * 完整版:计算矩阵并回溯出对齐路径与两段子串边界。
     *
     * @param a 查询序列(用户哼唱,短)
     * @param b 目标序列(曲库中一首歌,长)
     */
    public Result align(Interval a, Interval b) {
        int m = a.length(), n = b.length();
        // H[i][j] = 以 (i,j) 为终点的最优局部比对得分
        int[][] H = new int[m + 1][n + 1];

        int best = 0, bi = 0, bj = 0;
        for (int i = 1; i <= m; i++) {
            byte ai = a.get(i - 1);
            for (int j = 1; j <= n; j++) {
                int diag = H[i - 1][j - 1] + cfg.score(ai, b.get(j - 1));
                int up = H[i - 1][j] + cfg.gap;
                int left = H[i][j - 1] + cfg.gap;
                // ★ 关键:max 的第一项是 0,即"随时可以重新开始一段比对"
                int v = Math.max(0, Math.max(diag, Math.max(up, left)));
                H[i][j] = v;
                if (v > best) { best = v; bi = i; bj = j; }
            }
        }
        if (best == 0) {
            return new Result(0, 0, 0, 0, 0, new ArrayList<>());
        }
        return traceback(a, b, H, best, bi, bj);
    }

    /**
     * 回溯。三路选择顺序固定为 diag > up > left,
     * 保证结果可重复(不同的等价解只影响边界,不影响得分)。
     */
    private Result traceback(Interval a, Interval b, int[][] H,
                             int best, int bi, int bj) {
        List<int[]> path = new ArrayList<>();   // 每项 {i, j, 类型}
        int i = bi, j = bj;
        int aEnd = bi, bEnd = bj;    // 先存终点,回溯会改写 i/j

        while (i > 0 && j > 0 && H[i][j] > 0) {
            int cur = H[i][j];
            int diag = H[i - 1][j - 1] + cfg.score(a.get(i - 1), b.get(j - 1));
            if (cur == diag) {
                int type = a.get(i - 1) == b.get(j - 1) ? 0 : 1; // 0=匹配 1=替换
                path.add(new int[]{i - 1, j - 1, type});
                i--; j--;
            } else if (cur == H[i - 1][j] + cfg.gap) {
                path.add(new int[]{i - 1, -1, 2});               // A 侧对空隙
                i--;
            } else {
                path.add(new int[]{-1, j - 1, 3});               // B 侧对空隙
                j--;
            }
        }
        java.util.Collections.reverse(path);
        return new Result(best, i, aEnd, j, bEnd, path);
    }

    /**
     * 只要得分版:滚动两行,空间 O(n)。
     * 曲库初筛阶段(几百万首)用这个,命中候选才做完整回溯。
     */
    public int scoreOnly(Interval a, Interval b) {
        int m = a.length(), n = b.length();
        int[] prev = new int[n + 1];
        int[] cur = new int[n + 1];
        int best = 0;
        for (int i = 1; i <= m; i++) {
            byte ai = a.get(i - 1);
            cur[0] = 0;
            for (int j = 1; j <= n; j++) {
                int diag = prev[j - 1] + cfg.score(ai, b.get(j - 1));
                int up = prev[j] + cfg.gap;
                int left = cur[j - 1] + cfg.gap;
                int v = Math.max(0, Math.max(diag, Math.max(up, left)));
                cur[j] = v;
                if (v > best) best = v;
            }
            int[] t = prev; prev = cur; cur = t;
        }
        return best;
    }

    /**
     * 带状版:只计算 |i - j| <= band 的格子,工作量 O(M*band)。
     * 带外视为 0 是安全的,因为 SW 的下界本来就是 0。
     * 工程判据:band 翻倍而得分不变,即说明 band 足够。
     */
    public int bandedScore(Interval a, Interval b, int band) {
        int m = a.length(), n = b.length();
        int[] prev = new int[n + 1];
        int[] cur = new int[n + 1];
        int best = 0;
        for (int i = 1; i <= m; i++) {
            int lo = Math.max(1, i - band);
            int hi = Math.min(n, i + band);
            java.util.Arrays.fill(cur, 0);
            for (int j = lo; j <= hi; j++) {
                int diag = prev[j - 1] + cfg.score(a.get(i - 1), b.get(j - 1));
                int up = prev[j] + cfg.gap;
                int left = cur[j - 1] + cfg.gap;
                int v = Math.max(0, Math.max(diag, Math.max(up, left)));
                cur[j] = v;
                if (v > best) best = v;
            }
            int[] t = prev; prev = cur; cur = t;
        }
        return best;
    }
}

scoreOnly 里的 cur[0] = 0 是必需的重置:交换两行后,cur 曾作为 prev 被写满,第 0 列残留旧值会让整行偏移。这类滚动数组错误极难通过肉眼发现,只能靠对拍。

4.3 曲库检索

package com.keli.humming;

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

/**
 * 曲库检索:先带状粗筛,再对候选做完整比对。BLAST 式 "seed -> extend" 的简化版。
 */
public final class SongIndex {

    public record Hit(String songId, int score, double startSec, double endSec,
                      int queryStart, int queryEnd) {}

    private final List<String> ids = new ArrayList<>();
    private final List<Interval> tracks = new ArrayList<>();
    private final LocalAligner aligner;

    public SongIndex(SWSettings cfg) { this.aligner = new LocalAligner(cfg); }

    public void add(String songId, Interval track) {
        ids.add(songId);
        tracks.add(track);
    }

    public int size() { return ids.size(); }

    public List<Hit> search(Interval query, int topK, int band) {
        List<Hit> hits = new ArrayList<>();
        for (int t = 0; t < tracks.size(); t++) {
            Interval track = tracks.get(t);
            // 粗筛:带状得分,只保留过阈值的候选
            int rough = aligner.bandedScore(query, track, band);
            if (rough <= 0) continue;

            LocalAligner.Result fine = aligner.align(query, track);
            if (!fine.found()) continue;

            double start = track.onset(Math.max(fine.bStart(), 0));
            double end = track.onset(Math.max(fine.bEnd() - 1, fine.bStart()));
            hits.add(new Hit(ids.get(t), fine.score(), start, end,
                             fine.aStart(), fine.aEnd()));
        }
        hits.sort(Comparator.comparingInt(Hit::score).reversed());
        return hits.size() > topK ? new ArrayList<>(hits.subList(0, topK)) : hits;
    }
}

4.4 主程序

package com.keli.humming;

import java.util.List;

public final class HummingSearchDemo {

    /** 原曲:音程序列(相邻音的半音差) */
    private static final byte[] SONG_A = {
        +7, +2, +3, -1, -4, -2, +3, -5, +2, +7, -9, +4, -3, -1, +2, +5,
        +2, -7, +3, -2, -1, +5, -4, +2
    };
    /** 用户哼唱:从原曲中间起头,故意错一个音、漏一个音 */
    private static final byte[] HUM = {
        +3, -5, +2, +7, -9, +5, -1, +2, +5, +2, -7
    };

    public static void main(String[] args) {
        Interval song = wrap(SONG_A);
        Interval hum = wrap(HUM);

        LocalAligner aligner = new LocalAligner(SWSettings.MUSIC_DEFAULT);
        LocalAligner.Result r = aligner.align(hum, song);

        System.out.printf("局部比对得分 = %d%n", r.score());
        System.out.printf("命中原曲音程区间 [%d, %d)%n", r.bStart(), r.bEnd());
        System.out.printf("哼唱音程区间     [%d, %d)%n", r.aStart(), r.aEnd());
        long gaps = r.path().stream().filter(p -> p[2] >= 2).count();
        System.out.printf("对齐路径长度 = %d(其中空隙 %d 处)%n", r.path().size(), gaps);

        // 复算校验:回溯路径的得分累加必须等于报告的 best
        int replay = 0;
        for (int[] p : r.path()) {
            if (p[2] == 0) replay += SWSettings.MUSIC_DEFAULT.match();
            else if (p[2] == 1) replay += SWSettings.MUSIC_DEFAULT.mismatch();
            else replay += SWSettings.MUSIC_DEFAULT.gap();
        }
        if (replay != r.score()) {
            throw new AssertionError("回溯得分 " + replay + " != 报告得分 " + r.score());
        }
        System.out.println("回溯复算通过");

        // 曲库检索
        SongIndex index = new SongIndex(SWSettings.MUSIC_DEFAULT);
        index.add("song-A", song);
        index.add("song-B", wrap(new byte[]{+2, -2, +2, -2, +5, -5, +7, -7, +1, -1}));
        List<SongIndex.Hit> hits = index.search(hum, 3, 40);
        for (SongIndex.Hit h : hits) {
            System.out.printf("%s  得分=%d  原曲 %.2fs ~ %.2fs%n",
                h.songId(), h.score(), h.startSec(), h.endSec());
        }
    }

    private static Interval wrap(byte[] data) {
        float[] onsets = new float[data.length];
        for (int i = 0; i < data.length; i++) onsets[i] = i * 0.5f;
        return new Interval(data, onsets);
    }
}

4.5 对拍测试

package com.keli.humming;

import java.util.Random;

/**
 * 与暴力对照:枚举所有子段对,各自独立跑 SW 递推,取全表最大。
 * 被验证的两件事:(1) 主实现报告的 best 等于暴力最大;
 * (2) 回溯路径累加得分等于 best。
 */
public final class LocalAlignerTest {

    public static void main(String[] args) {
        Random rnd = new Random(20261103L);
        LocalAligner aligner = new LocalAligner(SWSettings.MUSIC_DEFAULT);
        int bad = 0, cases = 400;

        for (int t = 0; t < cases; t++) {
            int m = 1 + rnd.nextInt(10), n = 1 + rnd.nextInt(10);
            Interval a = rndInterval(rnd, m);
            Interval b = rndInterval(rnd, n);

            LocalAligner.Result r = aligner.align(a, b);
            int fast = r.score();
            int slow = brute(a, b);
            int replay = replayScore(r);

            if (fast != slow || (r.found() && replay != fast)) {
                bad++;
                if (bad <= 3) {
                    System.out.println("MISMATCH fast=" + fast + " slow=" + slow
                        + " replay=" + replay);
                }
            }
        }
        System.out.printf("对拍 %d 例,不一致 = %d%n", cases, bad);
        if (bad > 0) throw new AssertionError("Smith-Waterman 与暴力不一致");
    }

    /** 按回溯路径重算得分 */
    private static int replayScore(LocalAligner.Result r) {
        SWSettings cfg = SWSettings.MUSIC_DEFAULT;
        int s = 0;
        for (int[] p : r.path()) {
            if (p[2] == 0) s += cfg.match();
            else if (p[2] == 1) s += cfg.mismatch();
            else s += cfg.gap();
        }
        return s;
    }

    /** 暴力:对每个子段对独立跑一遍 SW 递推,取全表最大 */
    private static int brute(Interval a, Interval b) {
        SWSettings cfg = SWSettings.MUSIC_DEFAULT;
        int best = 0;
        for (int i0 = 0; i0 < a.length(); i0++) {
            for (int i1 = i0 + 1; i1 <= a.length(); i1++) {
                for (int j0 = 0; j0 < b.length(); j0++) {
                    for (int j1 = j0 + 1; j1 <= b.length(); j1++) {
                        int m = i1 - i0, n = j1 - j0;
                        int[][] H = new int[m + 1][n + 1];
                        for (int i = 1; i <= m; i++) {
                            for (int j = 1; j <= n; j++) {
                                int diag = H[i-1][j-1] + cfg.score(a.get(i0+i-1), b.get(j0+j-1));
                                int up = H[i-1][j] + cfg.gap;
                                int left = H[i][j-1] + cfg.gap;
                                H[i][j] = Math.max(0, Math.max(diag, Math.max(up, left)));
                                best = Math.max(best, H[i][j]);
                            }
                        }
                    }
                }
            }
        }
        return best;
    }

    private static Interval rndInterval(Random rnd, int len) {
        byte[] d = new byte[len];
        float[] o = new float[len];
        for (int i = 0; i < len; i++) { d[i] = (byte) (rnd.nextInt(7) - 3); o[i] = i * 0.5f; }
        return new Interval(d, o);
    }
}

在我的验证环境里(Python 复刻同一递推与同一回溯规则,800 组随机序列,与”独立子段重算”的最大值以及回溯路径累加得分三方对照),不一致数为 0。

五、复杂度与实际耗时

版本 时间 空间 适用场景
完整 SW + 回溯 O(M·N) O(M·N) 单首候选复核,M,N ≤ 数千
滚动两行(得分) O(M·N) O(min(M,N)) 曲库粗筛
带状 SW O(M·w) O(min(M,N)) 时间轴已粗对齐,w 为允许漂移
分块 SIMD(AVX2/NEON) O(M·N/16) 同滚动 生产级(MEGABLAST 一类实现)
索引预筛(seed + 哈希) 近 O(M) O(库) 百万级曲库第一层

数字感受一下:M=200(约 100 秒哼唱)、N=20000(给足余量)。完整 SW 是 4×10^6 次格点运算,Java 单线程约 8~15 ms;带状 w=40 时是 8×10^3 次,约 0.03 ms。曲库 10 万首时,粗筛约 3 ms、复核 10 个候选约 150 ms——这就是”听歌识曲”能做到亚秒级响应的算术基础。

真正的工业实现还会加两层:频谱指纹(把音程序列换成 chroma / 频谱峰值对,抗噪性远好于音高序列)和倒排索引(用 min-hash 做候选召回)。SW 在这一层只负责”精排”,它不承担召回。

六、五个必踩的坑

(1)边界用了全局比对的初始化。 把 H[0][j] = j*gap 写进去,0 下界就失效了,算法退化成”只从开头对齐”,副歌识别直接崩。SW 的 H 第 0 行与第 0 列必须全 0。

(2)回溯不判 H[i][j] > 0。 少了这个条件,回溯会一路走到矩阵边界,报出的片段长度膨胀到整首歌,得分却仍标着 best——”得分对、边界错”是最难查的一类 bug,因为单看得分完全正常。

(3)滚动数组忘记重置第 0 列。 表现是得分随输入长度漂移,随机对拍能立刻暴露。

(4)空隙打分按”每个空位”计,而非”每段空隙”计。 本文 gap 是线性空隙(每个空位 -4)。若业务希望”开一个空隙罚 6、每延长一格罚 1″(仿射空隙 affine gap),必须换成三矩阵递推(M/X/Y 三态),代码量翻三倍,转移方向极易写错。对”听歌识曲”而言线性版够用——哼唱漏音通常只有一两处。

(5)把得分直接当”相似度百分比”报给用户。 SW 得分没有上界,随序列长度线性增长:score=40 对 10 个音的查询是极高相似,对 500 个音的查询却几乎不相关。要报百分比必须归一化:score / (min(M,N) * match),并截断到 [0,1]。

七、半全局变体:用户完整哼完一小段

如果业务保证”用户哼的是完整一段旋律(查询两端都不截断),只是整首歌中的一小段”,那要的是半全局比对:查询两端必须消耗掉,目标两端自由。初始化只需改三处:

// ① 查询侧(i 方向)仍累积空隙代价,保证 Q 被完整消耗
for (int i = 0; i <= m; i++) H[i][0] = i * cfg.gap;
// ② 目标侧(j 方向)全 0,允许从任意位置开始、到任意位置结束
for (int j = 0; j <= n; j++) H[0][j] = 0;
// ③ 递推里的 max(0, ...) 去掉(不再允许局部重启)
//    答案取 H[m][*] 的最大值,而不是全表最大值

就这三处改动,”找子串里的子串”变成”找母串里的子串”。实务上建议两个变体都实现、用配置开关切换:用户从副歌中间起头(两端都截)用 SW;用户被要求”完整哼一遍这句”(查询完整)用半全局。识曲准确率的差距,往往就来自选错了变体。

八、小结

Smith-Waterman 的全部精髓可以压缩成一句话:把”重新开始”的代价设为零。这一个 max(0, ...) 换来的是:

  • 片段边界自动确定,不需要人工切窗;
  • 对整体移调免疫(因为用的是相对音程);
  • 对漏音、多音、错音同时鲁棒(三种操作各有打分);
  • 得分具有”局部性”,噪声前缀不会稀释信号。

它也是”局部性”思想的一个标本:Kadane 的最大子段和、RANSAC 的随机最少采样、Hough 变换的投票累积,骨子里都是”允许丢弃历史,只保留当前最有价值的假设”。理解了 SW,就理解了这一族算法的共同直觉。