第一次听人说“DNF排序”,我以为是哪个游戏术语,后来复习算法时才知道它其实是 Dutch National Flag(荷兰国旗)的缩写,出自计算机科学家Dijkstra提出的经典问题:给定一堆只有红、白、蓝三种颜色的球,原地排成红球在前、白球居中、蓝球在后的顺序。放在Go语言里表达,就是对一个只包含0、1、2的切片,如何做到一次遍历、原地排序、只占用常数空间。这篇文章就围绕这个算法展开,从原理推导、源码实现到边界测试,再到Go工程里的实际延伸,争取看完你就能自己写出来,并且能判断该在什么场景下用它。
1. 荷兰国旗问题为什么值得用Go重写一遍
1.1 问题定义与算法来源
荷兰国旗问题看起来非常简单:有一个数组,里面只有三种值,比如数字0、1、2,或者业务里的三种状态枚举,要求把数组原地排成“所有0在前,接着是1,最后是2”的顺序。
听起来像是随手就能写个计数排序搞定的事。但Dijkstra设计这个问题的意义在于:不借助额外数组,不用hash表统计,只靠几个指针在数组内部交换,就能在O(n)时间内完成排序。这个约束让问题变得有意思,因为你不能开一个桶数组,也不能用sort包去走一趟O(n log n)的比较排序。
为什么把这个问题叫“荷兰国旗”?因为荷兰国旗恰好就是水平排列的红、白、蓝三个色带。一堆无序的三色球,排成三色带,这个画面很形象。
你可能会问,现在CPU这么快,O(n)和O(n log n)有差别吗?差别非常大。假设数据量是1000万,log2(1000万)大概是23,也就是说比较排序大约需要2.3亿次比较操作,而DNF排序只需要一千万次级别的元素扫描和交换。在排序大切片、需要高吞吐的服务里,这个差距往往就是几毫秒和几十毫秒的差距。更重要的是,这种三指针扫描的思路,是整个三路快排的基础,是计算机科学里比“某个具体排序算法”更值得沉淀的思想。
1.2 DNF排序能解决什么实际问题
算法本身停留在纸面上容易让人觉得“学了用不上”,但仔细想想,很多业务场景天然就是三分类的。
举几个身边的例子。订单系统里,状态可以粗略分为待处理、处理中、已完成;任务调度系统里,任务可以分为未开始、执行中、已结束;运营后台里,用户可能被标记为新人、活跃、沉默;日志系统里,一条日志可能只是info、warn、error三种级别。如果你手上正好有一批这样的数据,并且需要在内存里把它们“按类分开”供下游消费,DNF排序就是很自然的方案。
它和普通排序的本质区别是:普通排序关心的是“谁大谁小”,DNF排序关心的是“你属于哪一类”。很多时候我们并不需要真正的全序关系,只需要把类别分开,这时候用快速排序反而绕了远路。
另一个更贴近算法的场景是,三路快排的partition阶段。快速排序面对大量重复元素时性能下降,后来优化的三路快排会把数组分成“小于pivot、等于pivot、大于pivot”三块,等值的那一块留好了不动,然后递归处理左右两边。这个思路的骨架,正是荷兰国旗问题里三指针扫出来的那个样子。理解了DNF排序,你再看三路快排的代码会非常轻松。
1.3 为什么Go是实现这个算法的好选择
可选的语言很多,但我自己写一遍下来,觉得Go和这个算法特别搭。
一是Go的切片语义让原地交换写得非常自然。arr[low], arr[mid] = arr[mid], arr[low]这种交换写法,一行就能完成,不需要像C语言那样声明临时变量,又不像某些语言里传值传引用需要想半天。切片的底层是引用类型,函数内部改元素,外面的切片会自动同步,这对“原地排序”的语义非常友好。
二是Go的switch语法很适合多分支处理。DNF排序的三个分支逻辑非常清晰:遇到0怎么处理,遇到1怎么处理,遇到2怎么处理。用switch表达出来,代码几乎没有噪音,读起来像在读算法描述。
三是Go在工程界的地位。现在写后台服务、中间件、云原生组件,Go是主流语言之一。这意味着这类“数组分类”的需求在实际项目里出现频率很高,学会了不是纸上谈兵。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 三指针扫描法:一次遍历的直觉来源
2.1 四段式数组布局
先别急着看代码,我们把算法的手画一遍。
想象数组从左到右被划分成四段:
- 第一段:已经处理好的0区
- 第二段:已经处理好的1区
- 第三段:尚未处理的区域
- 第四段:已经处理好的2区
对应到代码层面,我们用三个指针来记录区域边界:
low:指向下一个0应该放到的位置,也就是0区和1区的分界线mid:当前正在扫描的位置,也是1区和未处理区的分界线high:指向下一个2应该放到的位置,也就是未处理区和2区的分界线
这个布局是整个算法的心脏。你只要盯着这个布局看,就会觉得三指针不是凭空冒出来的,而是为了维护这个“四段式”结构自然长出来的。
循环的每一轮,我们只看arr[mid]这个当前元素,并根据它的值决定怎么交换。循环持续的条件是mid <= high,因为只要mid还没超过high,就说明未处理区域还有元素。
2.2 每个分支都在维护哪个不变量
下面这张表把三个分支整理得很清楚,我建议你对照着代码读,逻辑一目了然:
| 当前元素 | 操作 | 指针变化 | 理由 |
|---|---|---|---|
| 0 | 与arr[low]交换 |
low++, mid++ |
把0送到0区,交换回来的是1区开头的1,所以mid也要前进 |
| 1 | 什么都不做 | mid++ |
1本来就该待在中间区域 |
| 2 | 与arr[high]交换 |
high-- |
把2送到2区,但换回来的元素还没检查过,mid不能动 |
这其中的不变量是:每一轮循环结束后,arr[0:low]全是0,arr[low:mid]全是1,arr[high+1:]全是2,区间arr[mid:high+1]是尚未检查的元素。
当mid和high相遇再交叉时,未检查区域为空,整个数组自然就排好了。
2.3 最容易理解错的一个分支:遇到2为什么不能立刻前进
很多初学者第一次写这个算法,最容易写错的分支就是case 2。
遇到0的时候,mid要前进,因为从low换过来的那个元素,一定是1区开头的元素,它只可能是1,我们已经知道它的归属。遇到1的时候,mid前进,因为它本来就该在中间。但遇到2的时候,你把arr[mid]和arr[high]交换,从high那边换过来的元素,是未处理区域的最后一个元素,它可能是0、1、2中的任何一个。你还没检查它,就让mid跳过去,结果就是漏处理了一个元素。
你可以自己拿数组[1, 0, 2, 0, 1]试一下,如果case 2里顺手写了mid++,排序结果是错的。
这个细节我称之为“DNF排序的初心测试”:能一秒钟说清为什么mid不动,说明你理解了不变量;说不清,说明只是背了代码。
3. 完整源码与测试代码
3.1 基础实现
先给一个最基础的实现,函数接收一个[]int,原地修改切片。
go复制func DNFSort(arr []int) {
n := len(arr)
if n <= 1 {
return
}
low, mid, high := 0, 0, n-1
for mid <= high {
switch arr[mid] {
case 0:
arr[low], arr[mid] = arr[mid], arr[low]
low++
mid++
case 1:
mid++
case 2:
arr[mid], arr[high] = arr[high], arr[mid]
high--
}
}
}
这段代码只有十来行,但你可以把它当成一个迷你工程来看。
函数名用了DNFSort,大写开头,意味着包外可导出。如果你在写一个算法包,这个命名是可以直接公开的。参数直接传切片,是因为Go切片是引用类型,函数内修改元素会反映到外部切片上,满足了“原地排序”的需求。
边界条件n <= 1直接返回,处理了空切片、nil切片和单元素切片三种情况。这里有个Go特有的小坑:len(nil)是0,所以nil切片不会panic,这个后面专门讲。
3.2 表驱动测试
写算法不写测试,等于没写。Go的标准做法是写表驱动测试。我建议每个研究算法的Go项目,都配套一份这样的测试文件:
go复制package dnf
import (
"reflect"
"testing"
)
func copySlice(s []int) []int {
if s == nil {
return nil
}
result := make([]int, len(s))
copy(result, s)
return result
}
func TestDNFSort(t *testing.T) {
tests := []struct {
name string
input []int
want []int
}{
{"nil切片", nil, nil},
{"空切片", []int{}, []int{}},
{"单元素0", []int{0}, []int{0}},
{"单元素1", []int{1}, []int{1}},
{"单元素2", []int{2}, []int{2}},
{"已经有序", []int{0, 1, 2}, []int{0, 1, 2}},
{"逆序", []int{2, 1, 0}, []int{0, 1, 2}},
{"只有一种值", []int{1, 1, 1}, []int{1, 1, 1}},
{"全0", []int{0, 0, 0}, []int{0, 0, 0}},
{"全2", []int{2, 2, 2}, []int{2, 2, 2}},
{"混合乱序", []int{2, 0, 1, 2, 0, 1}, []int{0, 0, 1, 1, 2, 2}},
{"很长一段", []int{1, 2, 0, 1, 0, 2, 1, 2, 1, 0}, []int{0, 0, 0, 1, 1, 1, 1, 2, 2, 2}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
input := copySlice(tt.input)
DNFSort(input)
if !reflect.DeepEqual(input, tt.want) {
t.Fatalf("DNFSort(%v) = %v, want %v", tt.input, input, tt.want)
}
})
}
}
测试里有个细节:每个用例都先用copySlice复制一份原始切片,再传给排序函数。这是为了避免不同的用例相互污染,也保留了一份原始值用于错误信息输出。如果你直接对tt.input排序,排序完以后原来的测试数据也变了,后续用例如果还想基于原始数组断言就会出错。
3.3 随机化验证
固定用例只能覆盖逻辑分支,覆盖不了所有排列组合。这时候可以用随机测试来兜底。
go复制import (
"math/rand"
"time"
)
func TestDNFSortRandom(t *testing.T) {
rng := rand.New(rand.NewSource(time.Now().UnixNano()))
for i := 0; i < 2000; i++ {
n := rng.Intn(100)
arr := make([]int, n)
counts := [3]int{}
for j := range arr {
v := rng.Intn(3)
arr[j] = v
counts[v]++
}
original := copySlice(arr)
DNFSort(arr)
for j := 1; j < len(arr); j++ {
if arr[j] < arr[j-1] {
t.Fatalf("排序结果非递减: %v, original: %v", arr, original)
}
}
gotCounts := [3]int{}
for _, v := range arr {
gotCounts[v]++
}
if gotCounts != counts {
t.Fatalf("元素数量变化: got %v, want %v, original: %v, result: %v", gotCounts, counts, original, arr)
}
}
}
这个随机测试每次生成一个随机长度的切片,元素值随机取0、1、2,排序后检查两件事:数组是非递减的,以及0、1、2三个数字的个数没有变化。第二个检查很关键,有些错误排序会让数组有序,但元素数量对不上(比如把1丢了一个变成2),这种情况只有通过计数才能兜住。
我跑过2000轮随机测试,没有发现任何一类数据能让上面的代码出错。这个算法虽然看起来简单,但三个指针的配合逻辑确实严谨。
4. 边界条件与调整细节
4.1 边界案例逐一验证
实际工程里,你不会只遇到“参数刚好是一堆乱七八糟的0、1、2”这种典型情况。边界输入反而更容易暴露问题,所以我这里把几个容易被忽略的情况单独拿出来验证。
nil切片和空切片。len(nil)返回0,len([]int{})也是0,所以这两种情况都会被n <= 1提前拦截,不会进入循环,也不会panic。但要注意的是,nil切片和空切片在Go里并不是完全等价的概念,如果用reflect.DeepEqual比较,nil和[]int{}是不同的。所以测试用例里我分别写了两个。
**单元素切片。**不管是[0]、[1]还是[2],都直接返回,结果自然是正确的。从工程角度说,单元素不需要排序,这是很自然的短路逻辑。
**全是同一种值的切片。**比如全2的数组[2, 2, 2, 2],算法会走case 2的分支,每次把arr[mid]和arr[high]交换,但交换的两个位置其实都是同一个值。这在正确性上没问题,只是会做很多“无效交换”。你可以通过一个简单判断来优化,不过我觉得对大多数场景来说,这个开销可以忽略。
逆序数组。[2, 1, 0]这种最不利的排列,也能正确排成[0, 1, 2],说明三指针方案不依赖输入数据的初始分布。
4.2 循环条件为什么是mid <= high而不是mid < high
这是很多实现里最隐蔽的bug来源。从直觉上看,mid和high相等时,未处理区间剩一个元素,很多人会想当然地认为“只剩一个元素,肯定在正确位置”,于是把循环条件写成mid < high。
但这是错的。举个例子:[1, 0]。
用mid < high执行:
- low=0, mid=0, high=1
- 第一轮:arr[0]=1,走case 1,mid++,此时mid=1
mid < high为false,循环结束- 结果还是
[1, 0],排序失败
问题出在哪里?当mid=1、high=1时,数组里还剩一个元素0没有被检查。这个0需要和前面的1交换,但因为循环已经结束,这步操作永远不会发生。
所以正确答案是mid <= high。当mid和high重合时,那个位置的元素同样属于“未处理区域”,必须被检查一次。只有在它被处理之后,未处理区域才真正为空。
这个案例我建议你亲手跑一遍,比看十遍理论都有用。
4.3 命名与代码可读性
算法题的代码往往追求极短,但工程代码追求的是可读性。有些实现里会用l、m、r这种缩写,但不熟悉算法的人读到后面容易晕。我在代码里用low、mid、high全称,变量名本身就表达了指针含义,函数上面加注释说明参数含义和排序后的效果。
还有一个工程化的点是:不要把DNF排序的逻辑散落在业务代码里。我习惯把它封装成一个独立的函数,放到类似sortutil的工具包里,并且用注释写明它的前置条件:输入切片只能包含0、1、2三种值,超出这个范围算法的行为是未定义的。这样后续同事调用时就能快速判断是否适合这个场景。
5. 复杂度与性能对比
5.1 为什么总操作数是O(n)而不是O(n log n)
快速排序之所以是O(n log n),是因为它递归地把问题分成两半,每一层要处理n个元素,一共log n层。DNF排序不需要递归,它只扫描一遍,所以是O(n)。
有人可能会疑惑:循环里不是有交换吗?交换也算操作次数啊。没错,但交换的次数和元素个数是线性关系。
你从指针移动的角度看就明白了:每一轮循环,要么mid加1,要么high减1。mid最多从0走到n,high最多从n走到0。所以循环的总轮数是O(n)量级,每轮只做常数次比较和交换。空间上,三个指针的额外空间是O(1),完全原地。
这和计数排序的区别在于:计数排序需要额外开一个长度等于“值域范围”的数组,如果值域很大就不划算;DNF排序不需要任何额外数组,只靠几个指针就能完成。
5.2 与sort.Slice的对比
在Go工程里,很多人看到排序需求第一反应是用sort.Slice。但你要意识到,sort.Slice是通用的比较排序,它接收一个less函数,底层用的是pdqsort,平均复杂度O(n log n)。当数据量大了,这个复杂度就会体现出来。
下面是一个简化的对比场景:一个长度1000万的[]int,只有0、1、2三种值。用sort.Slice排序,与用DNF排序相比,数据量越大差距越明显。我用本地机器实测过,在接近真实业务数据量的切片上,DNF排序耗时通常只有sort.Slice的几分之一,而且峰值内存占用更低。
但这里要提醒一句:如果你的数据取值种类很多,DNF排序就无从下手。它只解决“三类值分类”这个特定问题。数据种类多的时候,老老实实用sort.Slice或者计数排序。
5.3 什么时候不该用DNF排序
写代码最忌讳手里有把锤子就看什么都像钉子。DNF排序虽好,它的使用边界也很明确。
如果数组不需要原地修改,或者你需要稳定排序保持相同元素的相对顺序,那DNF排序就不合适。因为交换过程中,相同值的元素相对顺序可能被打乱,这是它的天然代价。
如果数据不满足“只有三类值”的前提,也不能用。一个包含4种甚至更多状态的数组,用DNF排序缺一个分支,结果完全不可控。这种情况可以考虑计数排序,或者普通排序。
如果数据量很小,比如几十个元素的分类,那用sort.Slice也行,O(n log n)的差距在小数据量上根本看不出来,反而代码更通用。
6. 从DNF排序延伸到工程实战
6.1 泛型版本:让算法适用于任意类型
上面的基础版本只支持[]int。业务里的分类对象很少是裸的int,更多是结构体、字符串、自定义类型。Go 1.18之后有了泛型,我们可以把DNF排序写得更通用。
go复制package dnf
import "fmt"
// DNFSortGeneric 对任意切片进行三向分类排序。
// categoryOf 返回分类值,合法返回值只允许是 0、1、2。
// 排序结束后,返回值为 0 的元素在前,1 居中,2 在后。
func DNFSortGeneric[T any](arr []T, categoryOf func(T) int) {
n := len(arr)
if n <= 1 {
return
}
low, mid, high := 0, 0, n-1
for mid <= high {
switch categoryOf(arr[mid]) {
case 0:
arr[low], arr[mid] = arr[mid], arr[low]
low++
mid++
case 1:
mid++
case 2:
arr[mid], arr[high] = arr[high], arr[mid]
high--
default:
panic(fmt.Sprintf("DNF: categoryOf 返回值非法: %d", categoryOf(arr[mid])))
}
}
}
这个版本的函数接受一个categoryOf回调函数,把“元素具体是什么”和“按什么规则分类”解耦了。调用方只需要告诉算法“这些对象分别属于第几类”,剩下的原地交换由算法完成。
default分支是我特意加的。业务数据里总有预料不到的情况,一旦categoryOf返回了0、1、2之外的值,与其让排序结果静默错误,不如直接panic,让问题尽早暴露出来。这种“fail fast”的思路在工程里很重要。
6.2 实际业务场景:订单状态三分类
假设你在写一个订单处理程序,一个批次里有大量订单,状态只有三种:待处理、处理中、已完成。你希望把同一个批次的订单按状态分开,方便并行处理。
go复制type OrderState int
const (
StatePending OrderState = iota
StateProcessing
StateDone
)
type Order struct {
ID int64
State OrderState
}
orders := []Order{
{ID: 1001, State: StateDone},
{ID: 1002, State: StatePending},
{ID: 1003, State: StateProcessing},
{ID: 1004, State: StateProcessing},
{ID: 1005, State: StatePending},
}
DNFSortGeneric(orders, func(o Order) int {
return int(o.State)
})
这一通操作之后,orders会变成:StatePending的订单在最前,StateProcessing居中,StateDone最后。整个过程没有额外分配内存,也不需要维护三个临时slice再拼回去。
对比一下常规做法:你可能先建三个空slice,然后遍历原数组,按state分到三个slice里,最后append拼接。这个做法更容易理解,但它需要额外分配三个切片,数据量大时内存占用会多出几倍。DNF排序的做法则完全原地,省内存的同时代码也更接近“算法的本质”。
6.3 和三路快排的关系
如果面试官问你和三路快排的区别,你可以这么说:三路快排的partition阶段,本质上就是一次DNF排序。
快速排序每轮选一个pivot,然后把数组分为小于pivot、等于pivot、大于pivot三个区域。等于pivot的区域可以看成DNF中的“1区”,小于pivot的是“0区”,大于pivot的是“2区”。三路快排在递归处理左右区域时,中间区域直接跳过,因此面对大量重复元素时性能极好。
理解了DNF排序,三路快排的代码几乎不需要额外的解释。这两者是同一套思想的两种包装。如果你要研究Go标准库sort包的实现,里面遇到大量重复元素时的处理路径,也能看到类似三向切分的影子。
6.4 工程中容易踩的坑
最后聊几个我实际写代码时踩过的坑。
第一,切片是引用类型,但“引用”不等于“函数外的切片变量会自动更新”。如果你在函数内部给切片重新赋值,比如arr = arr[:n-1],外面的变量不会感知这个变化,因为切片的length和cap是放在切片头里的,函数内部的切片头和外部的是两个副本。但如果你只修改元素,比如arr[i] = x,因为底层数组是共享的,外部能看到变化。DNF排序只改元素,所以可以安全地原地生效。
第二,如果排序前需要保留原始数据,一定要先复制一份。常见的复制方式:
go复制original := make([]int, len(data))
copy(original, data)
不要直接写original := data,那样两个切片共享同一个底层数组,改其中一个另一个也会变。
第三,分类函数一定不能有副作用。比如在categoryOf里做了耗时统计、日志输出,这会破坏DNF排序原本干净的性能模型。我把categoryOf设计成一个纯函数,收到一个元素,返回一个int,不做任何外部修改。
第四,再一次强调:标准DNF只处理三类值。如果你的业务是五项状态,却硬套这个算法,你会得到一个“排序错误”的结果,而且很难排查。我建议在调用泛型版本前,自己先对全量数据做一次扫描,确认分类值的范围只有0、1、2;或者直接在default里panic,用失败报警来代替静默错误。
我在实际项目里用过几次这个算法之后,最大的感受是:它的三指针写法看起来简单,但每一个指针的移动时机都是有道理的。你只有在某个数据分布下真正写错一次、跑偏一次,才会记住为什么遇到2的时候不能急着让mid往前走。这种从“背代码”到“真正想明白”的转变,是靠测试和跑数据砸出来的。
如果你最近也在研究Go语言的数据结构和算法,不妨把这个函数连同测试代码抄下来,跑一遍,然后试试把它改造成你业务里的某个三分类场景。遇到边界数据时多停一秒想想指针在做什么,比背十道面试题都管用。
