-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathatom.xml
More file actions
539 lines (252 loc) · 430 KB
/
atom.xml
File metadata and controls
539 lines (252 loc) · 430 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<title>禅墨云</title>
<link href="https://chanmoyun.gitee.io/atom.xml" rel="self"/>
<link href="https://chanmoyun.gitee.io/"/>
<updated>2024-04-07T03:10:10.162Z</updated>
<id>https://chanmoyun.gitee.io/</id>
<author>
<name>Chanmo Jia</name>
</author>
<generator uri="https://hexo.io/">Hexo</generator>
<entry>
<title>27|堆的应用:如何快速获取到Top 10最热门的搜索关键词?</title>
<link href="https://chanmoyun.gitee.io/2024/03/20/Data-structure/27.%E5%A0%86%E7%9A%84%E5%BA%94%E7%94%A8%EF%BC%9A%E5%A6%82%E4%BD%95%E5%BF%AB%E9%80%9F%E8%8E%B7%E5%8F%96%E5%88%B0Top%2010%E6%9C%80%E7%83%AD%E9%97%A8%E7%9A%84%E6%90%9C%E7%B4%A2%E5%85%B3%E9%94%AE%E8%AF%8D%EF%BC%9F/"/>
<id>https://chanmoyun.gitee.io/2024/03/20/Data-structure/27.%E5%A0%86%E7%9A%84%E5%BA%94%E7%94%A8%EF%BC%9A%E5%A6%82%E4%BD%95%E5%BF%AB%E9%80%9F%E8%8E%B7%E5%8F%96%E5%88%B0Top%2010%E6%9C%80%E7%83%AD%E9%97%A8%E7%9A%84%E6%90%9C%E7%B4%A2%E5%85%B3%E9%94%AE%E8%AF%8D%EF%BC%9F/</id>
<published>2024-03-19T16:00:00.000Z</published>
<updated>2024-04-07T03:10:10.162Z</updated>
<content type="html"><![CDATA[<link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="\assets\js\Meting.min.js"></script><p>搜索引擎的热门搜索排行榜功能你用过吗?你知道这个功能是如何实现的吗?实际上,它的实现并不复杂。搜索引擎每天会接收大量的用户搜索请求,它会把这些用户输入的搜索关键词记录下来,然后再离线地统计分析,得到最热门的 Top 10 搜索关键词。</p><p>那请你思考下,<strong>假设现在我们有一个包含 10 亿个搜索关键词的日志文件,如何能快速获取到热门榜 Top 10 的搜索关键词呢?</strong></p><p>这个问题就可以用堆来解决,这也是堆这种数据结构一个非常典型的应用。上一节我们讲了堆和堆排序的一些理论知识,今天我们就来讲一讲,堆这种数据结构几个非常重要的应用:优先级队列、求 Top K 和求中位数。</p><h2 id="堆的应用一:优先级队列"><a href="#堆的应用一:优先级队列" class="headerlink" title="堆的应用一:优先级队列"></a>堆的应用一:优先级队列</h2><p>首先,我们来看第一个应用场景:优先级队列。</p><p>优先级队列,顾名思义,它首先应该是一个队列。我们前面讲过,队列最大的特性就是先进先出。不过,在优先级队列中,数据的出队顺序不是先进先出,而是按照优先级来,优先级最高的,最先出队。</p><p>如何实现一个优先级队列呢?方法有很多,但是用堆来实现是最直接、最高效的。这是因为,堆和优先级队列非常相似。一个堆就可以看作一个优先级队列。很多时候,它们只是概念上的区分而已。往优先级队列中插入一个元素,就相当于往堆中插入一个元素;从优先级队列中取出优先级最高的元素,就相当于取出堆顶元素。</p><p>你可别小看这个优先级队列,它的应用场景非常多。我们后面要讲的很多数据结构和算法都要依赖它。比如,赫夫曼编码、图的最短路径、最小生成树算法等等。不仅如此,很多语言中,都提供了优先级队列的实现,比如,Java 的 PriorityQueue,C++ 的 priority_queue 等。</p><p>只讲这些应用场景比较空泛,现在,我举两个具体的例子,让你感受一下优先级队列具体是怎么用的。</p><h3 id="1-合并有序小文件"><a href="#1-合并有序小文件" class="headerlink" title="1. 合并有序小文件"></a>1. 合并有序小文件</h3><p>假设我们有 100 个小文件,每个文件的大小是 100MB,每个文件中存储的都是有序的字符串。我们希望将这些 100 个小文件合并成一个有序的大文件。这里就会用到优先级队列。</p><p>整体思路有点像归并排序中的合并函数。我们从这 100 个文件中,各取第一个字符串,放入数组中,然后比较大小,把最小的那个字符串放入合并后的大文件中,并从数组中删除。</p><p>假设,这个最小的字符串来自于 13.txt 这个小文件,我们就再从这个小文件取下一个字符串,放到数组中,重新比较大小,并且选择最小的放入合并后的大文件,将它从数组中删除。依次类推,直到所有的文件中的数据都放入到大文件为止。</p><p>这里我们用数组这种数据结构,来存储从小文件中取出来的字符串。每次从数组中取最小字符串,都需要循环遍历整个数组,显然,这不是很高效。有没有更加高效方法呢?</p><p>这里就可以用到优先级队列,也可以说是堆。我们将从小文件中取出来的字符串放入到小顶堆中,那堆顶的元素,也就是优先级队列队首的元素,就是最小的字符串。我们将这个字符串放入到大文件中,并将其从堆中删除。然后再从小文件中取出下一个字符串,放入到堆中。循环这个过程,就可以将 100 个小文件中的数据依次放入到大文件中。</p><p>我们知道,删除堆顶数据和往堆中插入数据的时间复杂度都是 O(logn),n 表示堆中的数据个数,这里就是 100。是不是比原来数组存储的方式高效了很多呢?</p><h3 id="2-高性能定时器"><a href="#2-高性能定时器" class="headerlink" title="2. 高性能定时器"></a>2. 高性能定时器</h3><p>假设我们有一个定时器,定时器中维护了很多定时任务,每个任务都设定了一个要触发执行的时间点。定时器每过一个很小的单位时间(比如 1 秒),就扫描一遍任务,看是否有任务到达设定的执行时间。如果到达了,就拿出来执行。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/29%201.jpg" alt=""></p><p>但是,这样每过 1 秒就扫描一遍任务列表的做法比较低效,主要原因有两点:第一,任务的约定执行时间离当前时间可能还有很久,这样前面很多次扫描其实都是徒劳的;第二,每次都要扫描整个任务列表,如果任务列表很大的话,势必会比较耗时。</p><p>针对这些问题,我们就可以用优先级队列来解决。我们按照任务设定的执行时间,将这些任务存储在优先级队列中,队列首部(也就是小顶堆的堆顶)存储的是最先执行的任务。</p><p>这样,定时器就不需要每隔 1 秒就扫描一遍任务列表了。它拿队首任务的执行时间点,与当前时间点相减,得到一个时间间隔 T。</p><p>这个时间间隔 T 就是,从当前时间开始,需要等待多久,才会有第一个任务需要被执行。这样,定时器就可以设定在 T 秒之后,再来执行任务。从当前时间点到(T-1)秒这段时间里,定时器都不需要做任何事情。</p><p>当 T 秒时间过去之后,定时器取优先级队列中队首的任务执行。然后再计算新的队首任务的执行时间点与当前时间点的差值,把这个值作为定时器执行下一个任务需要等待的时间。</p><p>这样,定时器既不用间隔 1 秒就轮询一次,也不用遍历整个任务列表,性能也就提高了。</p><h2 id="堆的应用二:利用堆求-Top-K"><a href="#堆的应用二:利用堆求-Top-K" class="headerlink" title="堆的应用二:利用堆求 Top K"></a>堆的应用二:利用堆求 Top K</h2><p>刚刚我们学习了优先级队列,我们现在来看,堆的另外一个非常重要的应用场景,那就是“求 Top K 问题”。</p><p>我把这种求 Top K 的问题抽象成两类。一类是针对静态数据集合,也就是说数据集合事先确定,不会再变。另一类是针对动态数据集合,也就是说数据集合事先并不确定,有数据动态地加入到集合中。</p><p>针对静态数据,如何在一个包含 n 个数据的数组中,查找前 K 大数据呢?我们可以维护一个大小为 K 的小顶堆,顺序遍历数组,从数组中取出数据与堆顶元素比较。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理,继续遍历数组。这样等数组中的数据都遍历完之后,堆中的数据就是前 K 大数据了。</p><p>遍历数组需要 O(n) 的时间复杂度,一次堆化操作需要 O(logK) 的时间复杂度,所以最坏情况下,n 个元素都入堆一次,时间复杂度就是 O(nlogK)。</p><p>针对动态数据求得 Top K 就是实时 Top K。怎么理解呢?我举一个例子。一个数据集合中有两个操作,一个是添加数据,另一个询问当前的前 K 大数据。</p><p>如果每次询问前 K 大数据,我们都基于当前的数据重新计算的话,那时间复杂度就是 O(nlogK),n 表示当前的数据的大小。实际上,我们可以一直都维护一个 K 大小的小顶堆,当有数据被添加到集合中时,我们就拿它与堆顶的元素对比。如果比堆顶元素大,我们就把堆顶元素删除,并且将这个元素插入到堆中;如果比堆顶元素小,则不做处理。这样,无论任何时候需要查询当前的前 K 大数据,我们都可以立刻返回给他。</p><h2 id="堆的应用三:利用堆求中位数"><a href="#堆的应用三:利用堆求中位数" class="headerlink" title="堆的应用三:利用堆求中位数"></a>堆的应用三:利用堆求中位数</h2><p>前面我们讲了如何求 Top K 的问题,现在我们来讲下,如何求动态数据集合中的中位数。</p><p>中位数,顾名思义,就是处在中间位置的那个数。如果数据的个数是奇数,把数据从小到大排列,那第 2n+1 个数据就是中位数(注意:假设数据是从 0 开始编号的);如果数据的个数是偶数的话,那处于中间位置的数据有两个,第 2n 个和第 2n+1 个数据,这个时候,我们可以随意取一个作为中位数,比如取两个数中靠前的那个,就是第 2n 个数据。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/29%202.jpg" alt=""></p><p>对于一组静态数据,中位数是固定的,我们可以先排序,第 2n 个数据就是中位数。每次询问中位数的时候,我们直接返回这个固定的值就好了。所以,尽管排序的代价比较大,但是边际成本会很小。但是,如果我们面对的是动态数据集合,中位数在不停地变动,如果再用先排序的方法,每次询问中位数的时候,都要先进行排序,那效率就不高了。</p><p>借助堆这种数据结构,我们不用排序,就可以非常高效地实现求中位数操作。我们来看看,它是如何做到的?</p><p>我们需要维护两个堆,一个大顶堆,一个小顶堆。大顶堆中存储前半部分数据,小顶堆中存储后半部分数据,且小顶堆中的数据都大于大顶堆中的数据。</p><p>也就是说,如果有 n 个数据,n 是偶数,我们从小到大排序,那前 2n 个数据存储在大顶堆中,后 2n 个数据存储在小顶堆中。这样,大顶堆中的堆顶元素就是我们要找的中位数。如果 n 是奇数,情况是类似的,大顶堆就存储 2n+1 个数据,小顶堆中就存储 2n 个数据。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/29%203.jpg" alt=""></p><p>我们前面也提到,数据是动态变化的,当新添加一个数据的时候,我们如何调整两个堆,让大顶堆中的堆顶元素继续是中位数呢?</p><p>如果新加入的数据小于等于大顶堆的堆顶元素,我们就将这个新数据插入到大顶堆;否则,我们就将这个新数据插入到小顶堆。</p><p>这个时候就有可能出现,两个堆中的数据个数不符合前面约定的情况:如果 n 是偶数,两个堆中的数据个数都是 2n;如果 n 是奇数,大顶堆有 2n+1 个数据,小顶堆有 2n 个数据。这个时候,我们可以从一个堆中不停地将堆顶元素移动到另一个堆,通过这样的调整,来让两个堆中的数据满足上面的约定。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/29%204.jpg" alt=""></p><p>于是,我们就可以利用两个堆,一个大顶堆、一个小顶堆,实现在动态数据集合中求中位数的操作。插入数据因为需要涉及堆化,所以时间复杂度变成了 O(logn),但是求中位数我们只需要返回大顶堆的堆顶元素就可以了,所以时间复杂度就是 O(1)。</p><p>实际上,利用两个堆不仅可以快速求出中位数,还可以快速求其他百分位的数据,原理是类似的。还记得我们在“为什么要学习数据结构与算法”里的这个问题吗?“如何快速求接口的 99% 响应时间?”我们现在就来看下,利用两个堆如何来实现。</p><p>在开始这个问题的讲解之前,我先解释一下,什么是“99% 响应时间”。</p><p>中位数的概念就是将数据从小到大排列,处于中间位置,就叫中位数,这个数据会大于等于前面 50% 的数据。99 百分位数的概念可以类比中位数,如果将一组数据从小到大排列,这个 99 百分位数就是大于前面 99% 数据的那个数据。</p><p>如果你还是不太理解,我再举个例子。假设有 100 个数据,分别是 1,2,3,……,100,那 99 百分位数就是 99,因为小于等于 99 的数占总个数的 99%。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/29%205.jpg" alt=""></p><p>弄懂了这个概念,我们再来看 99% 响应时间。如果有 100 个接口访问请求,每个接口请求的响应时间都不同,比如 55 毫秒、100 毫秒、23 毫秒等,我们把这 100 个接口的响应时间按照从小到大排列,排在第 99 的那个数据就是 99% 响应时间,也叫 99 百分位响应时间。</p><p>我们总结一下,如果有 n 个数据,将数据从小到大排列之后,99 百分位数大约就是第 n<em>99% 个数据,同类,80 百分位数大约就是第 n</em>80% 个数据。</p><p>弄懂了这些,我们再来看如何求 99% 响应时间。</p><p>我们维护两个堆,一个大顶堆,一个小顶堆。假设当前总数据的个数是 n,大顶堆中保存 n<em>99% 个数据,小顶堆中保存 n</em>1% 个数据。大顶堆堆顶的数据就是我们要找的 99% 响应时间。</p><p>每次插入一个数据的时候,我们要判断这个数据跟大顶堆和小顶堆堆顶数据的大小关系,然后决定插入到哪个堆中。如果这个新插入的数据比大顶堆的堆顶数据小,那就插入大顶堆;如果这个新插入的数据比小顶堆的堆顶数据大,那就插入小顶堆。</p><p>但是,为了保持大顶堆中的数据占 99%,小顶堆中的数据占 1%,在每次新插入数据之后,我们都要重新计算,这个时候大顶堆和小顶堆中的数据个数,是否还符合 99:1 这个比例。如果不符合,我们就将一个堆中的数据移动到另一个堆,直到满足这个比例。移动的方法类似前面求中位数的方法,这里我就不啰嗦了。</p><p>通过这样的方法,每次插入数据,可能会涉及几个数据的堆化操作,所以时间复杂度是 O(logn)。每次求 99% 响应时间的时候,直接返回大顶堆中的堆顶数据即可,时间复杂度是 O(1)。</p><h2 id="解答开篇"><a href="#解答开篇" class="headerlink" title="解答开篇"></a>解答开篇</h2><p>学懂了上面的一些应用场景的处理思路,我想你应该能解决开篇的那个问题了吧。假设现在我们有一个包含 10 亿个搜索关键词的日志文件,如何快速获取到 Top 10 最热门的搜索关键词呢?</p><p>处理这个问题,有很多高级的解决方法,比如使用 MapReduce 等。但是,如果我们将处理的场景限定为单机,可以使用的内存为 1GB。那这个问题该如何解决呢?</p><p>因为用户搜索的关键词,有很多可能都是重复的,所以我们首先要统计每个搜索关键词出现的频率。我们可以通过散列表、平衡二叉查找树或者其他一些支持快速查找、插入的数据结构,来记录关键词及其出现的次数。</p><p>假设我们选用散列表。我们就顺序扫描这 10 亿个搜索关键词。当扫描到某个关键词时,我们去散列表中查询。如果存在,我们就将对应的次数加一;如果不存在,我们就将它插入到散列表,并记录次数为 1。以此类推,等遍历完这 10 亿个搜索关键词之后,散列表中就存储了不重复的搜索关键词以及出现的次数。</p><p>然后,我们再根据前面讲的用堆求 Top K 的方法,建立一个大小为 10 的小顶堆,遍历散列表,依次取出每个搜索关键词及对应出现的次数,然后与堆顶的搜索关键词对比。如果出现次数比堆顶搜索关键词的次数多,那就删除堆顶的关键词,将这个出现次数更多的关键词加入到堆中。</p><p>以此类推,当遍历完整个散列表中的搜索关键词之后,堆中的搜索关键词就是出现次数最多的 Top 10 搜索关键词了。</p><p>不知道你发现了没有,上面的解决思路其实存在漏洞。10 亿的关键词还是很多的。我们假设 10 亿条搜索关键词中不重复的有 1 亿条,如果每个搜索关键词的平均长度是 50 个字节,那存储 1 亿个关键词起码需要 5GB 的内存空间,而散列表因为要避免频繁冲突,不会选择太大的装载因子,所以消耗的内存空间就更多了。而我们的机器只有 1GB 的可用内存空间,所以我们无法一次性将所有的搜索关键词加入到内存中。这个时候该怎么办呢?</p><p>我们在哈希算法那一节讲过,相同数据经过哈希算法得到的哈希值是一样的。我们可以根据哈希算法的这个特点,将 10 亿条搜索关键词先通过哈希算法分片到 10 个文件中。</p><p>具体可以这样做:我们创建 10 个空文件 00,01,02,……,09。我们遍历这 10 亿个关键词,并且通过某个哈希算法对其求哈希值,然后哈希值同 10 取模,得到的结果就是这个搜索关键词应该被分到的文件编号。</p><p>对这 10 亿个关键词分片之后,每个文件都只有 1 亿的关键词,去除掉重复的,可能就只有 1000 万个,每个关键词平均 50 个字节,所以总的大小就是 500MB。1GB 的内存完全可以放得下。</p><p>我们针对每个包含 1 亿条搜索关键词的文件,利用散列表和堆,分别求出 Top 10,然后把这个 10 个 Top 10 放在一块,然后取这 100 个关键词中,出现次数最多的 10 个关键词,这就是这 10 亿数据中的 Top 10 最频繁的搜索关键词了。</p><h2 id="内容小结"><a href="#内容小结" class="headerlink" title="内容小结"></a>内容小结</h2><p>我们今天主要讲了堆的几个重要的应用,它们分别是:优先级队列、求 Top K 问题和求中位数问题。</p><p>优先级队列是一种特殊的队列,优先级高的数据先出队,而不再像普通的队列那样,先进先出。实际上,堆就可以看作优先级队列,只是称谓不一样罢了。求 Top K 问题又可以分为针对静态数据和针对动态数据,只需要利用一个堆,就可以做到非常高效率的查询 Top K 的数据。求中位数实际上还有很多变形,比如求 99 百分位数据、90 百分位数据等,处理的思路都是一样的,即利用两个堆,一个大顶堆,一个小顶堆,随着数据的动态添加,动态调整两个堆中的数据,最后大顶堆的堆顶元素就是要求的数据。</p><h2 id="课后思考"><a href="#课后思考" class="headerlink" title="课后思考"></a>课后思考</h2><p>有一个访问量非常大的新闻网站,我们希望将点击量排名 Top 10 的新闻摘要,滚动显示在网站首页 banner 上,并且每隔 1 小时更新一次。如果你是负责开发这个功能的工程师,你会如何来实现呢?</p>]]></content>
<summary type="html"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" cla</summary>
<category term="算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
</entry>
<entry>
<title>26|堆和堆排序:为什么说堆排序没有快速排序快?</title>
<link href="https://chanmoyun.gitee.io/2024/03/19/Data-structure/26.%E5%A0%86%E5%92%8C%E5%A0%86%E6%8E%92%E5%BA%8F%EF%BC%9A%E4%B8%BA%E4%BB%80%E4%B9%88%E8%AF%B4%E5%A0%86%E6%8E%92%E5%BA%8F%E6%B2%A1%E6%9C%89%E5%BF%AB%E9%80%9F%E6%8E%92%E5%BA%8F%E5%BF%AB%EF%BC%9F/"/>
<id>https://chanmoyun.gitee.io/2024/03/19/Data-structure/26.%E5%A0%86%E5%92%8C%E5%A0%86%E6%8E%92%E5%BA%8F%EF%BC%9A%E4%B8%BA%E4%BB%80%E4%B9%88%E8%AF%B4%E5%A0%86%E6%8E%92%E5%BA%8F%E6%B2%A1%E6%9C%89%E5%BF%AB%E9%80%9F%E6%8E%92%E5%BA%8F%E5%BF%AB%EF%BC%9F/</id>
<published>2024-03-18T16:00:00.000Z</published>
<updated>2024-04-07T03:09:54.264Z</updated>
<content type="html"><![CDATA[<link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="\assets\js\Meting.min.js"></script><h1 id="堆和堆排序:为什么说堆排序没有快速排序快?"><a href="#堆和堆排序:为什么说堆排序没有快速排序快?" class="headerlink" title="堆和堆排序:为什么说堆排序没有快速排序快?"></a>堆和堆排序:为什么说堆排序没有快速排序快?</h1><p>我们今天讲另外一种特殊的树,“堆”(Heap)。堆这种数据结构的应用场景非常多,最经典的莫过于堆排序了。堆排序是一种原地的、时间复杂度为 O(nlogn) 的排序算法。</p><p>前面我们学过快速排序,平均情况下,它的时间复杂度为 O(nlogn)。尽管这两种排序算法的时间复杂度都是 O(nlogn),甚至堆排序比快速排序的时间复杂度还要稳定,但是,<strong>在实际的软件开发中,快速排序的性能要比堆排序好,这是为什么呢?</strong></p><p>现在,你可能还无法回答,甚至对问题本身还有点疑惑。没关系,带着这个问题,我们来学习今天的内容。等你学完之后,或许就能回答出来了。</p><h2 id="如何理解“堆”?"><a href="#如何理解“堆”?" class="headerlink" title="如何理解“堆”?"></a>如何理解“堆”?</h2><p>前面我们提到,堆是一种特殊的树。我们现在就来看看,什么样的树才是堆。我罗列了两点要求,只要满足这两点,它就是一个堆。</p><ul><li>堆是一个完全二叉树;</li><li>堆中每一个节点的值都必须大于等于(或小于等于)其子树中每个节点的值。</li></ul><p>我分别解释一下这两点。</p><p>第一点,堆必须是一个完全二叉树。还记得我们之前讲的完全二叉树的定义吗?完全二叉树要求,除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列。</p><p>第二点,堆中的每个节点的值必须大于等于(或者小于等于)其子树中每个节点的值。实际上,我们还可以换一种说法,堆中每个节点的值都大于等于(或者小于等于)其左右子节点的值。这两种表述是等价的。</p><p>对于每个节点的值都大于等于子树中每个节点值的堆,我们叫作“大顶堆”。对于每个节点的值都小于等于子树中每个节点值的堆,我们叫作“小顶堆”。</p><p>定义解释清楚了,你来看看,下面这几个二叉树是不是堆?</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/28%201.jpg" alt=""></p><p>其中第 1 个和第 2 个是大顶堆,第 3 个是小顶堆,第 4 个不是堆。除此之外,从图中还可以看出来,对于同一组数据,我们可以构建多种不同形态的堆。</p><h2 id="如何实现一个堆?"><a href="#如何实现一个堆?" class="headerlink" title="如何实现一个堆?"></a>如何实现一个堆?</h2><p>要实现一个堆,我们先要知道,<strong>堆都支持哪些操作</strong>以及<strong>如何存储一个堆。</strong></p><p>我之前讲过,完全二叉树比较适合用数组来存储。用数组来存储完全二叉树是非常节省存储空间的。因为我们不需要存储左右子节点的指针,单纯地通过数组的下标,就可以找到一个节点的左右子节点和父节点。</p><p>我画了一个用数组存储堆的例子,你可以先看下。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/28%202.jpg" alt=""></p><p>从图中我们可以看到,数组中下标为 i 的节点的左子节点,就是下标为 i∗2 的节点,右子节点就是下标为 i∗2+1 的节点,父节点就是下标为 2i 的节点。</p><p>知道了如何存储一个堆,那我们再来看看,堆上的操作有哪些呢?我罗列了几个非常核心的操作,分别是往堆中插入一个元素和删除堆顶元素。(如果没有特殊说明,我下面都是拿大顶堆来讲解)。</p><h3 id="1-往堆中插入一个元素"><a href="#1-往堆中插入一个元素" class="headerlink" title="1. 往堆中插入一个元素"></a>1. 往堆中插入一个元素</h3><p>往堆中插入一个元素后,我们需要继续满足堆的两个特性。</p><p>如果我们把新插入的元素放到堆的最后,你可以看我画的这个图,是不是不符合堆的特性了?于是,我们就需要进行调整,让其重新满足堆的特性,这个过程我们起了一个名字,就叫作<strong>堆化</strong>(heapify)。</p><p>堆化实际上有两种,从下往上和从上往下。这里我先讲<strong>从下往上</strong>的堆化方法。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/28%203.jpg" alt=""></p><p>堆化非常简单,就是顺着节点所在的路径,向上或者向下,对比,然后交换。</p><p>我这里画了一张堆化的过程分解图。我们可以让新插入的节点与父节点对比大小。如果不满足子节点小于等于父节点的大小关系,我们就互换两个节点。一直重复这个过程,直到父子节点之间满足刚说的那种大小关系。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/28%204.jpg" alt=""></p><p>我将上面讲的往堆中插入数据的过程,翻译成了代码,你可以结合着一块看。</p><p><figure class="highlight c"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br></pre></td><td class="code"><pre><span class="line">public <span class="class"><span class="keyword">class</span> <span class="title">Heap</span> {</span></span><br><span class="line"></span><br><span class="line"> private <span class="type">int</span>[] a; <span class="comment">// 数组,从下标1开始存储数据</span></span><br><span class="line"></span><br><span class="line"> private <span class="type">int</span> n; <span class="comment">// 堆可以存储的最大数据个数</span></span><br><span class="line"></span><br><span class="line"> private <span class="type">int</span> count; <span class="comment">// 堆中已经存储的数据个数</span></span><br><span class="line"></span><br><span class="line"> public <span class="title function_">Heap</span><span class="params">(<span class="type">int</span> capacity)</span> {</span><br><span class="line"></span><br><span class="line"> a = new <span class="type">int</span>[capacity + <span class="number">1</span>];</span><br><span class="line"></span><br><span class="line"> n = capacity;</span><br><span class="line"></span><br><span class="line"> count = <span class="number">0</span>;</span><br><span class="line"></span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> public <span class="type">void</span> <span class="title function_">insert</span><span class="params">(<span class="type">int</span> data)</span> {</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (count >= n) <span class="keyword">return</span>; <span class="comment">// 堆满了</span></span><br><span class="line"></span><br><span class="line"> ++count;</span><br><span class="line"></span><br><span class="line"> a[count] = data;</span><br><span class="line"></span><br><span class="line"> <span class="type">int</span> i = count;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">while</span> (i/<span class="number">2</span> > <span class="number">0</span> && a[i] > a[i/<span class="number">2</span>]) { <span class="comment">// 自下往上堆化</span></span><br><span class="line"></span><br><span class="line"> swap(a, i, i/<span class="number">2</span>); <span class="comment">// swap()函数作用:交换下标为i和i/2的两个元素</span></span><br><span class="line"></span><br><span class="line"> i = i/<span class="number">2</span>;</span><br><span class="line"></span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> }</span><br></pre></td></tr></table></figure></p><h3 id="2-删除堆顶元素"><a href="#2-删除堆顶元素" class="headerlink" title="2. 删除堆顶元素"></a>2. 删除堆顶元素</h3><p>从堆的定义的第二条中,任何节点的值都大于等于(或小于等于)子树节点的值,我们可以发现,堆顶元素存储的就是堆中数据的最大值或者最小值。</p><p>假设我们构造的是大顶堆,堆顶元素就是最大的元素。当我们删除堆顶元素之后,就需要把第二大的元素放到堆顶,那第二大元素肯定会出现在左右子节点中。然后我们再迭代地删除第二大节点,以此类推,直到叶子节点被删除。</p><p>这里我也画了一个分解图。不过这种方法有点问题,就是最后堆化出来的堆并不满足完全二叉树的特性。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/28%205.jpg" alt=""></p><p>实际上,我们稍微改变一下思路,就可以解决这个问题。你看我画的下面这幅图。我们把最后一个节点放到堆顶,然后利用同样的父子节点对比方法。对于不满足父子节点大小关系的,互换两个节点,并且重复进行这个过程,直到父子节点之间满足大小关系为止。这就是<strong>从上往下的堆化方法。</strong></p><p>因为我们移除的是数组中的最后一个元素,而在堆化的过程中,都是交换操作,不会出现数组中的“空洞”,所以这种方法堆化之后的结果,肯定满足完全二叉树的特性。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/28%206.jpg" alt=""></p><p>我把上面的删除过程同样也翻译成了代码,贴在这里,你可以结合着看。</p><p><figure class="highlight c"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br></pre></td><td class="code"><pre><span class="line">public <span class="type">void</span> <span class="title function_">removeMax</span><span class="params">()</span> {</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (count == <span class="number">0</span>) <span class="keyword">return</span> <span class="number">-1</span>; <span class="comment">// 堆中没有数据</span></span><br><span class="line"></span><br><span class="line"> a[<span class="number">1</span>] = a[count];</span><br><span class="line"></span><br><span class="line"> --count;</span><br><span class="line"></span><br><span class="line"> heapify(a, count, <span class="number">1</span>);</span><br><span class="line"></span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">private <span class="type">void</span> <span class="title function_">heapify</span><span class="params">(<span class="type">int</span>[] a, <span class="type">int</span> n, <span class="type">int</span> i)</span> { <span class="comment">// 自上往下堆化</span></span><br><span class="line"></span><br><span class="line"> <span class="keyword">while</span> (<span class="literal">true</span>) {</span><br><span class="line"></span><br><span class="line"> <span class="type">int</span> maxPos = i;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (i*<span class="number">2</span> <= n && a[i] < a[i*<span class="number">2</span>]) maxPos = i*<span class="number">2</span>;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (i*<span class="number">2</span>+<span class="number">1</span> <= n && a[maxPos] < a[i*<span class="number">2</span>+<span class="number">1</span>]) maxPos = i*<span class="number">2</span>+<span class="number">1</span>;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (maxPos == i) <span class="keyword">break</span>;</span><br><span class="line"></span><br><span class="line"> swap(a, i, maxPos);</span><br><span class="line"></span><br><span class="line"> i = maxPos;</span><br><span class="line"></span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>我们知道,一个包含 n 个节点的完全二叉树,树的高度不会超过 log2n。堆化的过程是顺着节点所在路径比较交换的,所以堆化的时间复杂度跟树的高度成正比,也就是 O(logn)。插入数据和删除堆顶元素的主要逻辑就是堆化,所以,往堆中插入一个元素和删除堆顶元素的时间复杂度都是 O(logn)。</p><h2 id="如何基于堆实现排序?"><a href="#如何基于堆实现排序?" class="headerlink" title="如何基于堆实现排序?"></a>如何基于堆实现排序?</h2><p>前面我们讲过好几种排序算法,我们再来回忆一下,有时间复杂度是 O(n2) 的冒泡排序、插入排序、选择排序,有时间复杂度是 O(nlogn) 的归并排序、快速排序,还有线性排序。</p><p>这里我们借助于堆这种数据结构实现的排序算法,就叫作堆排序。这种排序方法的时间复杂度非常稳定,是 O(nlogn),并且它还是原地排序算法。如此优秀,它是怎么做到的呢?</p><p>我们可以把堆排序的过程大致分解成两个大的步骤,<strong>建堆</strong>和<strong>排序</strong>。</p><h3 id="1-建堆"><a href="#1-建堆" class="headerlink" title="1. 建堆"></a>1. 建堆</h3><p>我们首先将数组原地建成一个堆。所谓“原地”就是,不借助另一个数组,就在原数组上操作。建堆的过程,有两种思路。</p><p>第一种是借助我们前面讲的,在堆中插入一个元素的思路。尽管数组中包含 n 个数据,但是我们可以假设,起初堆中只包含一个数据,就是下标为 1 的数据。然后,我们调用前面讲的插入操作,将下标从 2 到 n 的数据依次插入到堆中。这样我们就将包含 n 个数据的数组,组织成了堆。</p><p>第二种实现思路,跟第一种截然相反,也是我这里要详细讲的。第一种建堆思路的处理过程是从前往后处理数组数据,并且每个数据插入堆中时,都是从下往上堆化。而第二种实现思路,是从后往前处理数组,并且每个数据都是从上往下堆化。</p><p>我举了一个例子,并且画了一个第二种实现思路的建堆分解步骤图,你可以看下。因为叶子节点往下堆化只能自己跟自己比较,所以我们直接从第一个非叶子节点开始,依次堆化就行了。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/28%207.jpg" alt=""></p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/28%209.jpg" alt=""></p><p>对于程序员来说,看代码可能更好理解一些,所以,我将第二种实现思路翻译成了代码,你可以看下。</p><p><figure class="highlight c"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br></pre></td><td class="code"><pre><span class="line">private <span class="type">static</span> <span class="type">void</span> <span class="title function_">buildHeap</span><span class="params">(<span class="type">int</span>[] a, <span class="type">int</span> n)</span> {</span><br><span class="line"></span><br><span class="line"> <span class="keyword">for</span> (<span class="type">int</span> i = n/<span class="number">2</span>; i >= <span class="number">1</span>; --i) {</span><br><span class="line"></span><br><span class="line"> heapify(a, n, i);</span><br><span class="line"></span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">private <span class="type">static</span> <span class="type">void</span> <span class="title function_">heapify</span><span class="params">(<span class="type">int</span>[] a, <span class="type">int</span> n, <span class="type">int</span> i)</span> {</span><br><span class="line"></span><br><span class="line"> <span class="keyword">while</span> (<span class="literal">true</span>) {</span><br><span class="line"></span><br><span class="line"> <span class="type">int</span> maxPos = i;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (i*<span class="number">2</span> <= n && a[i] < a[i*<span class="number">2</span>]) maxPos = i*<span class="number">2</span>;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (i*<span class="number">2</span>+<span class="number">1</span> <= n && a[maxPos] < a[i*<span class="number">2</span>+<span class="number">1</span>]) maxPos = i*<span class="number">2</span>+<span class="number">1</span>;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (maxPos == i) <span class="keyword">break</span>;</span><br><span class="line"></span><br><span class="line"> swap(a, i, maxPos);</span><br><span class="line"></span><br><span class="line"> i = maxPos;</span><br><span class="line"></span><br><span class="line"> }</span><br><span class="line"> }</span><br></pre></td></tr></table></figure></p><p>你可能已经发现了,在这段代码中,我们对下标从 n/2开始到 1 的数据进行堆化,下标是 n/2+1 到 n 的节点是叶子节点,我们不需要堆化。实际上,对于完全二叉树来说,下标从 n/2+1 到 n 的节点都是叶子节点。</p><p>现在,我们来看,建堆操作的时间复杂度是多少呢?</p><p>每个节点堆化的时间复杂度是 O(logn),那 n/2+1 个节点堆化的总时间复杂度是不是就是 O(nlogn) 呢?这个答案虽然也没错,但是这个值还是不够精确。实际上,堆排序的建堆过程的时间复杂度是 O(n)。我带你推导一下。</p><p>因为叶子节点不需要堆化,所以需要堆化的节点从倒数第二层开始。每个节点堆化的过程中,需要比较和交换的节点个数,跟这个节点的高度 k 成正比。</p><p>我把每一层的节点个数和对应的高度画了出来,你可以看看。我们只需要将每个节点的高度求和,得出的就是建堆的时间复杂度。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/28%2010.jpg" alt=""></p><p>我们将每个非叶子节点的高度求和,就是下面这个公式:</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/28%2011.jpg" alt=""></p><p>这个公式的求解稍微有点技巧,不过我们高中应该都学过:把公式左右都乘以 2,就得到另一个公式 S2。我们将 S2 错位对齐,并且用 S2 减去 S1,可以得到 S。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/28%2012.jpg" alt=""></p><p>S 的中间部分是一个等比数列,所以最后可以用等比数列的求和公式来计算,最终的结果就是下面图中画的这个样子。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/28%2013.jpg" alt=""></p><p>因为 h=log2n,代入公式 S,就能得到 S=O(n),所以,建堆的时间复杂度就是 O(n)。</p><h3 id="2-排序"><a href="#2-排序" class="headerlink" title="2. 排序"></a>2. 排序</h3><p>建堆结束之后,数组中的数据已经是按照大顶堆的特性来组织的。数组中的第一个元素就是堆顶,也就是最大的元素。我们把它跟最后一个元素交换,那最大元素就放到了下标为 n 的位置。</p><p>这个过程有点类似上面讲的“删除堆顶元素”的操作,当堆顶元素移除之后,我们把下标为 n 的元素放到堆顶,然后再通过堆化的方法,将剩下的 n−1 个元素重新构建成堆。堆化完成之后,我们再取堆顶的元素,放到下标是 n−1 的位置,一直重复这个过程,直到最后堆中只剩下标为 1 的一个元素,排序工作就完成了。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/28%2014.jpg" alt=""></p><p>堆排序的过程,我也翻译成了代码。结合着代码看,你理解起来应该会更加容易。</p><p>// n表示数据的个数,数组a中的数据从下标1到n的位置。</p><p><figure class="highlight c"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line"><span class="comment">// n表示数据的个数,数组a中的数据从下标1到n的位置。</span></span><br><span class="line">public <span class="type">static</span> <span class="type">void</span> <span class="title function_">sort</span><span class="params">(<span class="type">int</span>[] a, <span class="type">int</span> n)</span> {</span><br><span class="line"> buildHeap(a, n);</span><br><span class="line"> <span class="type">int</span> k = n;</span><br><span class="line"> <span class="keyword">while</span> (k > <span class="number">1</span>) {</span><br><span class="line"> swap(a, <span class="number">1</span>, k);</span><br><span class="line"> --k;</span><br><span class="line"> heapify(a, k, <span class="number">1</span>);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>现在,我们再来分析一下堆排序的时间复杂度、空间复杂度以及稳定性。</p><p>整个堆排序的过程,都只需要极个别临时存储空间,所以堆排序是原地排序算法。堆排序包括建堆和排序两个操作,建堆过程的时间复杂度是 O(n),排序过程的时间复杂度是 O(nlogn),所以,堆排序整体的时间复杂度是 O(nlogn)。</p><p>堆排序不是稳定的排序算法,因为在排序的过程,存在将堆的最后一个节点跟堆顶节点互换的操作,所以就有可能改变值相同数据的原始相对顺序。</p><p>今天的内容到此就讲完了。我这里要稍微解释一下,在前面的讲解以及代码中,我都假设,堆中的数据是从数组下标为 1 的位置开始存储。那如果从 0 开始存储,实际上处理思路是没有任何变化的,唯一变化的,可能就是,代码实现的时候,计算子节点和父节点的下标的公式改变了。</p><p>如果节点的下标是 i,那左子节点的下标就是 2∗i+1,右子节点的下标就是 2∗i+2,父节点的下标就是 2i−1。</p><h2 id="解答开篇"><a href="#解答开篇" class="headerlink" title="解答开篇"></a>解答开篇</h2><p>现在我们来看开篇的问题,在实际开发中,为什么快速排序要比堆排序性能好?</p><p>我觉得主要有两方面的原因。</p><h3 id="第一点,堆排序数据访问的方式没有快速排序友好。"><a href="#第一点,堆排序数据访问的方式没有快速排序友好。" class="headerlink" title="第一点,堆排序数据访问的方式没有快速排序友好。"></a>第一点,堆排序数据访问的方式没有快速排序友好。</h3><p>对于快速排序来说,数据是顺序访问的。而对于堆排序来说,数据是跳着访问的。 比如,堆排序中,最重要的一个操作就是数据的堆化。比如下面这个例子,对堆顶节点进行堆化,会依次访问数组下标是 1,2,4,8 的元素,而不是像快速排序那样,局部顺序访问,所以,这样对 CPU 缓存是不友好的。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/28%2015.jpg" alt=""></p><h3 id="第二点,对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。"><a href="#第二点,对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。" class="headerlink" title="第二点,对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。"></a>第二点,对于同样的数据,在排序过程中,堆排序算法的数据交换次数要多于快速排序。</h3><p>我们在讲排序的时候,提过两个概念,有序度和逆序度。对于基于比较的排序算法来说,整个排序过程就是由两个基本的操作组成的,比较和交换(或移动)。快速排序数据交换的次数不会比逆序度多。</p><p>但是堆排序的第一步是建堆,建堆的过程会打乱数据原有的相对先后顺序,导致原数据的有序度降低。比如,对于一组已经有序的数据来说,经过建堆之后,数据反而变得更无序了。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/28%2016.jpg" alt=""></p><p>对于第二点,你可以自己做个试验看下。我们用一个记录交换次数的变量,在代码中,每次交换的时候,我们就对这个变量加一,排序完成之后,这个变量的值就是总的数据交换次数。这样你就能很直观地理解我刚刚说的,堆排序比快速排序交换次数多。</p><h2 id="内容小结"><a href="#内容小结" class="headerlink" title="内容小结"></a>内容小结</h2><p>今天我们讲了堆这种数据结构。堆是一种完全二叉树。它最大的特性是:每个节点的值都大于等于(或小于等于)其子树节点的值。因此,堆被分成了两类,大顶堆和小顶堆。</p><p>堆中比较重要的两个操作是插入一个数据和删除堆顶元素。这两个操作都要用到堆化。插入一个数据的时候,我们把新插入的数据放到数组的最后,然后从下往上堆化;删除堆顶数据的时候,我们把数组中的最后一个元素放到堆顶,然后从上往下堆化。这两个操作时间复杂度都是 O(logn)。</p><p>除此之外,我们还讲了堆的一个经典应用,堆排序。堆排序包含两个过程,建堆和排序。我们将下标从 2n 到 1 的节点,依次进行从上到下的堆化操作,然后就可以将数组中的数据组织成堆这种数据结构。接下来,我们迭代地将堆顶的元素放到堆的末尾,并将堆的大小减一,然后再堆化,重复这个过程,直到堆中只剩下一个元素,整个数组中的数据就都有序排列了。</p><h2 id="课后思考"><a href="#课后思考" class="headerlink" title="课后思考"></a>课后思考</h2><p>在讲堆排序建堆的时候,我说到,对于完全二叉树来说,下标从 2n+1 到 n 的都是叶子节点,这个结论是怎么推导出来的呢?</p><p>我们今天讲了堆的一种经典应用,堆排序。关于堆,你还能想到它的其他应用吗?</p><p>欢迎留言和我分享,我会第一时间给你反馈。</p>]]></content>
<summary type="html"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" cla</summary>
<category term="算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
</entry>
<entry>
<title>25|递归树:如何借助树来求解递归算法的时间复杂度?</title>
<link href="https://chanmoyun.gitee.io/2024/03/18/Data-structure/25.%E9%80%92%E5%BD%92%E6%A0%91%EF%BC%9A%E5%A6%82%E4%BD%95%E5%80%9F%E5%8A%A9%E6%A0%91%E6%9D%A5%E6%B1%82%E8%A7%A3%E9%80%92%E5%BD%92%E7%AE%97%E6%B3%95%E7%9A%84%E6%97%B6%E9%97%B4%E5%A4%8D%E6%9D%82%E5%BA%A6%EF%BC%9F/"/>
<id>https://chanmoyun.gitee.io/2024/03/18/Data-structure/25.%E9%80%92%E5%BD%92%E6%A0%91%EF%BC%9A%E5%A6%82%E4%BD%95%E5%80%9F%E5%8A%A9%E6%A0%91%E6%9D%A5%E6%B1%82%E8%A7%A3%E9%80%92%E5%BD%92%E7%AE%97%E6%B3%95%E7%9A%84%E6%97%B6%E9%97%B4%E5%A4%8D%E6%9D%82%E5%BA%A6%EF%BC%9F/</id>
<published>2024-03-17T16:00:00.000Z</published>
<updated>2024-03-18T02:51:51.811Z</updated>
<content type="html"><![CDATA[<link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="\assets\js\Meting.min.js"></script><h1 id="递归树:如何借助树来求解递归算法的时间复杂度?"><a href="#递归树:如何借助树来求解递归算法的时间复杂度?" class="headerlink" title="递归树:如何借助树来求解递归算法的时间复杂度?"></a>递归树:如何借助树来求解递归算法的时间复杂度?</h1><p>今天,我们来讲树这种数据结构的一种特殊应用,递归树。</p><p>我们都知道,递归代码的时间复杂度分析起来很麻烦。我们在第 12 节《排序(下)》那里讲过,如何利用递推公式,求解归并排序、快速排序的时间复杂度,但是,有些情况,比如快排的平均时间复杂度的分析,用递推公式的话,会涉及非常复杂的数学推导。</p><p>除了用递推公式这种比较复杂的分析方法,有没有更简单的方法呢?今天,我们就来学习另外一种方法,<strong>借助递归树来分析递归算法的时间复杂度。</strong></p><h2 id="递归树与时间复杂度分析"><a href="#递归树与时间复杂度分析" class="headerlink" title="递归树与时间复杂度分析"></a>递归树与时间复杂度分析</h2><p>我们前面讲过,递归的思想就是,将大问题分解为小问题来求解,然后再将小问题分解为小小问题。这样一层一层地分解,直到问题的数据规模被分解得足够小,不用继续递归分解为止。</p><p>如果我们把这个一层一层的分解过程画成图,它其实就是一棵树。我们给这棵树起一个名字,叫作<strong>递归树</strong>。我这里画了一棵斐波那契数列的递归树,你可以看看。节点里的数字表示数据的规模,一个节点的求解可以分解为左右子节点两个问题的求解。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/27%201.jpg" alt=""></p><p>通过这个例子,你对递归树的样子应该有个感性的认识了,看起来并不复杂。现在,我们就来看,<strong>如何用递归树来求解时间复杂度</strong>。</p><p>归并排序算法你还记得吧?它的递归实现代码非常简洁。现在我们就借助归并排序来看看,如何用递归树,来分析递归代码的时间复杂度。</p><p>归并排序的原理我就不详细介绍了,如果你忘记了,可以回看一下第 12 节的内容。归并排序每次会将数据规模一分为二。我们把归并排序画成递归树,就是下面这个样子:</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/27%202.jpg" alt=""></p><p>因为每次分解都是一分为二,所以代价很低,我们把时间上的消耗记作常量 1。归并算法中比较耗时的是归并操作,也就是把两个子数组合并为大数组。从图中我们可以看出,每一层归并操作消耗的时间总和是一样的,跟要排序的数据规模有关。我们把每一层归并操作消耗的时间记作 n。</p><p>现在,我们只需要知道这棵树的高度 h,用高度 h 乘以每一层的时间消耗 n,就可以得到总的时间复杂度 O(n∗h)。</p><p>从归并排序的原理和递归树,可以看出来,归并排序递归树是一棵满二叉树。我们前两节中讲到,满二叉树的高度大约是 log2n,所以,归并排序递归实现的时间复杂度就是 O(nlogn)。我这里的时间复杂度都是估算的,对树的高度的计算也没有那么精确,但是这并不影响复杂度的计算结果。</p><p>利用递归树的时间复杂度分析方法并不难理解,关键还是在实战,所以,接下来我会通过三个实际的递归算法,带你实战一下递归的复杂度分析。学完这节课之后,你应该能真正掌握递归代码的复杂度分析。</p><h2 id="实战一:分析快速排序的时间复杂度"><a href="#实战一:分析快速排序的时间复杂度" class="headerlink" title="实战一:分析快速排序的时间复杂度"></a>实战一:分析快速排序的时间复杂度</h2><p>在用递归树推导之前,我们先来回忆一下用递推公式的分析方法。你可以回想一下,当时,我们为什么说用递推公式来求解平均时间复杂度非常复杂?</p><p>快速排序在最好情况下,每次分区都能一分为二,这个时候用递推公式 T(n)=2T(2n)+n,很容易就能推导出时间复杂度是 O(nlogn)。但是,我们并不可能每次分区都这么幸运,正好一分为二。</p><p>我们假设平均情况下,每次分区之后,两个分区的大小比例为 1:k。当 k=9 时,如果用递推公式的方法来求解时间复杂度的话,递推公式就写成 T(n)=T(10n)+T(109n)+n。</p><p>这个公式可以推导出时间复杂度,但是推导过程非常复杂。那我们来看看,<strong>用递归树来分析快速排序的平均情况时间复杂度,是不是比较简单呢?</strong></p><p>我们还是取 k 等于 9,也就是说,每次分区都很不平均,一个分区是另一个分区的 9 倍。如果我们把递归分解的过程画成递归树,就是下面这个样子:</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/27%203.jpg" alt=""></p><p>快速排序的过程中,每次分区都要遍历待分区区间的所有数据,所以,每一层分区操作所遍历的数据的个数之和就是 n。我们现在只要求出递归树的高度 h,这个快排过程遍历的数据个数就是 h∗n ,也就是说,时间复杂度就是 O(h∗n)。</p><p>因为每次分区并不是均匀地一分为二,所以递归树并不是满二叉树。这样一个递归树的高度是多少呢?</p><p>我们知道,快速排序结束的条件就是待排序的小区间,大小为 1,也就是说叶子节点里的数据规模是 1。从根节点 n 到叶子节点 1,递归树中最短的一个路径每次都乘以 1/10,最长的一个路径每次都乘以 9/10。通过计算,我们可以得到,从根节点到叶子节点的最短路径是 log10n,最长的路径是 log10/9n。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/27%204.jpg" alt=""></p><p>所以,遍历数据的个数总和就介于 nlog10n 和 nlog10/9n 之间。根据复杂度的大 O 表示法,对数复杂度的底数不管是多少,我们统一写成 logn,所以,当分区大小比例是 1:9 时,快速排序的时间复杂度仍然是 O(nlogn)。</p><p>刚刚我们假设 k=9,那如果 k=99,也就是说,每次分区极其不平均,两个区间大小是 1:99,这个时候的时间复杂度是多少呢?</p><p>我们可以类比上面 k=9 的分析过程。当 k=99 的时候,树的最短路径就是 log100n,最长路径是 log100/99n,所以总遍历数据个数介于 nlog100n 和 nlog100/99n 之间。尽管底数变了,但是时间复杂度也仍然是 O(nlogn)。</p><p>也就是说,对于 k 等于 9,99,甚至是 999,9999……,只要 k 的值不随 n 变化,是一个事先确定的常量,那快排的时间复杂度就是 O(nlogn)。所以,从概率论的角度来说,快排的平均时间复杂度就是 O(nlogn)。</p><h2 id="实战二:分析斐波那契数列的时间复杂度"><a href="#实战二:分析斐波那契数列的时间复杂度" class="headerlink" title="实战二:分析斐波那契数列的时间复杂度"></a>实战二:分析斐波那契数列的时间复杂度</h2><p>在递归那一节中,我们举了一个跨台阶的例子,你还记得吗?那个例子实际上就是一个斐波那契数列。为了方便你回忆,我把它的代码实现贴在这里。</p><p><figure class="highlight c"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="type">int</span> <span class="title function_">f</span><span class="params">(<span class="type">int</span> n)</span> {</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (n == <span class="number">1</span>) <span class="keyword">return</span> <span class="number">1</span>;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (n == <span class="number">2</span>) <span class="keyword">return</span> <span class="number">2</span>;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">return</span> f(n<span class="number">-1</span>) + f(n<span class="number">-2</span>);</span><br><span class="line"></span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>这样一段代码的时间复杂度是多少呢?你可以先试着分析一下,然后再来看,我是怎么利用递归树来分析的。</p><p>我们先把上面的递归代码画成递归树,就是下面这个样子:</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/27%205.jpg" alt=""></p><p>这棵递归树的高度是多少呢?</p><p>f(n) 分解为 f(n−1) 和 f(n−2),每次数据规模都是 −1 或者 −2,叶子节点的数据规模是 1 或者 2。所以,从根节点走到叶子节点,每条路径是长短不一的。如果每次都是 −1,那最长路径大约就是 n;如果每次都是 −2,那最短路径大约就是 2n。</p><p>每次分解之后的合并操作只需要一次加法运算,我们把这次加法运算的时间消耗记作 1。所以,从上往下,第一层的总时间消耗是 1,第二层的总时间消耗是 2,第三层的总时间消耗就是 22。依次类推,第 k 层的时间消耗就是 2k−1,那整个算法的总的时间消耗就是每一层时间消耗之和。</p><p>如果路径长度都为 n,那这个总和就是 2n−1。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/27%206.jpg" alt=""></p><p>如果路径长度都是 2n ,那整个算法的总的时间消耗就是 22n−1。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/27%207.jpg" alt=""></p><p>所以,这个算法的时间复杂度就介于 O(2n) 和 O(22n) 之间。虽然这样得到的结果还不够精确,只是一个范围,但是我们也基本上知道了上面算法的时间复杂度是指数级的,非常高。</p><h2 id="实战三:分析全排列的时间复杂度"><a href="#实战三:分析全排列的时间复杂度" class="headerlink" title="实战三:分析全排列的时间复杂度"></a>实战三:分析全排列的时间复杂度</h2><p>前面两个复杂度分析都比较简单,我们再来看个稍微复杂的。</p><p>我们在高中的时候都学过排列组合。“如何把 n 个数据的所有排列都找出来”,这就是全排列的问题。</p><p>我来举个例子。比如,1,2,3 这样 3 个数据,有下面这几种不同的排列:</p><p><figure class="highlight c"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"><span class="number">1</span>, <span class="number">2</span>, <span class="number">3</span></span><br><span class="line"></span><br><span class="line"><span class="number">1</span>, <span class="number">3</span>, <span class="number">2</span></span><br><span class="line"></span><br><span class="line"><span class="number">2</span>, <span class="number">1</span>, <span class="number">3</span></span><br><span class="line"></span><br><span class="line"><span class="number">2</span>, <span class="number">3</span>, <span class="number">1</span></span><br><span class="line"></span><br><span class="line"><span class="number">3</span>, <span class="number">1</span>, <span class="number">2</span></span><br><span class="line"></span><br><span class="line"><span class="number">3</span>, <span class="number">2</span>, <span class="number">1</span></span><br></pre></td></tr></table></figure></p><p>如何编程打印一组数据的所有排列呢?这里就可以用递归来实现。</p><p>如果我们确定了最后一位数据,那就变成了求解剩下 n−1 个数据的排列问题。而最后一位数据可以是 n 个数据中的任意一个,因此它的取值就有 n 种情况。所以,“n 个数据的排列”问题,就可以分解成 n 个“n−1 个数据的排列”的子问题。</p><p>如果我们把它写成递推公式,就是下面这个样子:</p><p><figure class="highlight c"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">假设数组中存储的是<span class="number">1</span>,<span class="number">2</span>, <span class="number">3.</span>..n。</span><br><span class="line"></span><br><span class="line"> </span><br><span class="line"></span><br><span class="line">f(<span class="number">1</span>,<span class="number">2</span>,...n) = {最后一位是<span class="number">1</span>, f(n<span class="number">-1</span>)} + {最后一位是<span class="number">2</span>, f(n<span class="number">-1</span>)} +...+{最后一位是n, f(n<span class="number">-1</span>)}。</span><br></pre></td></tr></table></figure></p><p>如果我们把递推公式改写成代码,就是下面这个样子:</p><p><figure class="highlight c"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// 调用方式:</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// int[]a = a={1, 2, 3, 4}; printPermutations(a, 4, 4);</span></span><br><span class="line"></span><br><span class="line"><span class="comment">// k表示要处理的子数组的数据个数</span></span><br><span class="line"></span><br><span class="line">public <span class="type">void</span> <span class="title function_">printPermutations</span><span class="params">(<span class="type">int</span>[] data, <span class="type">int</span> n, <span class="type">int</span> k)</span> {</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (k == <span class="number">1</span>) {</span><br><span class="line"></span><br><span class="line"> <span class="keyword">for</span> (<span class="type">int</span> i = <span class="number">0</span>; i < n; ++i) {</span><br><span class="line"></span><br><span class="line"> System.out.print(data[i] + <span class="string">" "</span>);</span><br><span class="line"></span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> System.out.println();</span><br><span class="line"></span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">for</span> (<span class="type">int</span> i = <span class="number">0</span>; i < k; ++i) {</span><br><span class="line"></span><br><span class="line"> <span class="type">int</span> tmp = data[i];</span><br><span class="line"></span><br><span class="line"> data[i] = data[k<span class="number">-1</span>];</span><br><span class="line"></span><br><span class="line"> data[k<span class="number">-1</span>] = tmp;</span><br><span class="line"></span><br><span class="line"> printPermutations(data, n, k - <span class="number">1</span>);</span><br><span class="line"></span><br><span class="line"> tmp = data[i];</span><br><span class="line"></span><br><span class="line"> data[i] = data[k<span class="number">-1</span>];</span><br><span class="line"></span><br><span class="line"> data[k<span class="number">-1</span>] = tmp;</span><br><span class="line"></span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>如果不用我前面讲的递归树分析方法,这个递归代码的时间复杂度会比较难分析。现在,我们来看下,如何借助递归树,轻松分析出这个代码的时间复杂度。</p><p>首先,我们还是画出递归树。不过,现在的递归树已经不是标准的二叉树了。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/27%208.jpg" alt=""></p><p>第一层分解有 n 次交换操作,第二层有 n 个节点,每个节点分解需要 n−1 次交换,所以第二层总的交换次数是 n∗(n−1)。第三层有 n∗(n−1) 个节点,每个节点分解需要 n−2 次交换,所以第三层总的交换次数是 n∗(n−1)∗(n−2)。</p><p>以此类推,第 k 层总的交换次数就是 n∗(n−1)∗(n−2)∗…∗(n−k+1)。最后一层的交换次数就是 n∗(n−1)∗(n−2)∗…∗2∗1。每一层的交换次数之和就是总的交换次数。</p><p><figure class="highlight c"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">n + n*(n<span class="number">-1</span>) + n*(n<span class="number">-1</span>)*(n<span class="number">-2</span>) +... + n*(n<span class="number">-1</span>)*(n<span class="number">-2</span>)*...*<span class="number">2</span>*<span class="number">1</span></span><br></pre></td></tr></table></figure></p><p>这个公式的求和比较复杂,我们看最后一个数,n∗(n−1)∗(n−2)∗…∗2∗1 等于 n!,而前面的 n−1 个数都小于最后一个数,所以,总和肯定小于 n∗n!,也就是说,全排列的递归算法的时间复杂度大于 O(n!),小于 O(n∗n!),虽然我们没法知道非常精确的时间复杂度,但是这样一个范围已经让我们知道,全排列的时间复杂度是非常高的。</p><p>这里我稍微说下,掌握分析的方法很重要,思路是重点,不要纠结于精确的时间复杂度到底是多少。</p><h2 id="内容小结"><a href="#内容小结" class="headerlink" title="内容小结"></a>内容小结</h2><p>今天,我们用递归树分析了递归代码的时间复杂度。加上我们在排序那一节讲到的递推公式的时间复杂度分析方法,我们现在已经学习了两种递归代码的时间复杂度分析方法了。</p><p>有些代码比较适合用递推公式来分析,比如归并排序的时间复杂度、快速排序的最好情况时间复杂度;有些比较适合采用递归树来分析,比如快速排序的平均时间复杂度。而有些可能两个都不怎么适合使用,比如二叉树的递归前中后序遍历。</p><p>时间复杂度分析的理论知识并不多,也不复杂,掌握起来也不难,但是,在我们平时的工作、学习中,面对的代码千差万别,能够灵活应用学到的复杂度分析方法,来分析现有的代码,并不是件简单的事情,所以,你平时要多实战、多分析,只有这样,面对任何代码的时间复杂度分析,你才能做到游刃有余、毫不畏惧。</p><h2 id="课后思考"><a href="#课后思考" class="headerlink" title="课后思考"></a>课后思考</h2><p>1 个细胞的生命周期是 3 小时,1 小时分裂一次。求 n 小时后,容器内有多少细胞?请你用已经学过的递归时间复杂度的分析方法,分析一下这个递归问题的时间复杂度。</p><p>欢迎留言和我分享,我会第一时间给你反馈。</p>]]></content>
<summary type="html"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" cla</summary>
<category term="算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
</entry>
<entry>
<title>24|红黑树(下):掌握这些技巧,你也可以实现一个红黑树</title>
<link href="https://chanmoyun.gitee.io/2024/03/17/Data-structure/24.%E7%BA%A2%E9%BB%91%E6%A0%91%EF%BC%88%E4%B8%8B%EF%BC%89%EF%BC%9A%E6%8E%8C%E6%8F%A1%E8%BF%99%E4%BA%9B%E6%8A%80%E5%B7%A7%EF%BC%8C%E4%BD%A0%E4%B9%9F%E5%8F%AF%E4%BB%A5%E5%AE%9E%E7%8E%B0%E4%B8%80%E4%B8%AA%E7%BA%A2%E9%BB%91%E6%A0%91/"/>
<id>https://chanmoyun.gitee.io/2024/03/17/Data-structure/24.%E7%BA%A2%E9%BB%91%E6%A0%91%EF%BC%88%E4%B8%8B%EF%BC%89%EF%BC%9A%E6%8E%8C%E6%8F%A1%E8%BF%99%E4%BA%9B%E6%8A%80%E5%B7%A7%EF%BC%8C%E4%BD%A0%E4%B9%9F%E5%8F%AF%E4%BB%A5%E5%AE%9E%E7%8E%B0%E4%B8%80%E4%B8%AA%E7%BA%A2%E9%BB%91%E6%A0%91/</id>
<published>2024-03-16T16:00:00.000Z</published>
<updated>2024-03-18T02:38:34.855Z</updated>
<content type="html"><![CDATA[<link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="\assets\js\Meting.min.js"></script><h1 id="红黑树(下):掌握这些技巧,你也可以实现一个红黑树"><a href="#红黑树(下):掌握这些技巧,你也可以实现一个红黑树" class="headerlink" title="红黑树(下):掌握这些技巧,你也可以实现一个红黑树"></a>红黑树(下):掌握这些技巧,你也可以实现一个红黑树</h1><p>红黑树是一个让我又爱又恨的数据结构,“爱”是因为它稳定、高效的性能,“恨”是因为实现起来实在太难了。我今天讲的红黑树的实现,对于基础不太好的同学,理解起来可能会有些困难。但是,我觉得没必要去死磕它。</p><p>我为什么这么说呢?因为,即便你将左右旋背得滚瓜烂熟,我保证你过不几天就忘光了。因为,学习红黑树的代码实现,对于你平时做项目开发没有太大帮助。对于绝大部分开发工程师来说,这辈子你可能都用不着亲手写一个红黑树。除此之外,它对于算法面试也几乎没什么用,一般情况下,靠谱的面试官也不会让你手写红黑树的。</p><p>如果你对数据结构和算法很感兴趣,想要开拓眼界、训练思维,我还是很推荐你看一看这节的内容。但是如果学完今天的内容你还觉得懵懵懂懂的话,也不要纠结。我们要有的放矢去学习。你先把平时要用的、基础的东西都搞会了,如果有余力了,再来深入地研究这节内容。</p><p>好,我们现在就进入正式的内容。<strong>上一节,我们讲到红黑树定义的时候,提到红黑树的叶子节点都是黑色的空节点。当时我只是粗略地解释了,这是为了代码实现方便,那更加确切的原因是什么呢?</strong> 我们这节就来说一说。</p><h2 id="实现红黑树的基本思想"><a href="#实现红黑树的基本思想" class="headerlink" title="实现红黑树的基本思想"></a>实现红黑树的基本思想</h2><p>不知道你有没有玩过魔方?其实魔方的复原解法是有固定算法的:遇到哪几面是什么样子,对应就怎么转几下。你只要跟着这个复原步骤,就肯定能将魔方复原。</p><p>实际上,红黑树的平衡过程跟魔方复原非常神似,大致过程就是:<strong>遇到什么样的节点排布,我们就对应怎么去调整</strong>。只要按照这些固定的调整规则来操作,就能将一个非平衡的红黑树调整成平衡的。</p><p>还记得我们前面讲过的红黑树的定义吗?今天的内容里,我们会频繁用到它,所以,我们现在再来回顾一下。一棵合格的红黑树需要满足这样几个要求:</p><ul><li>根节点是黑色的;</li><li>每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据;</li><li>任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;</li><li>每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点。</li></ul><p>在插入、删除节点的过程中,第三、第四点要求可能会被破坏,而我们今天要讲的“平衡调整”,实际上就是要把被破坏的第三、第四点恢复过来。</p><p>在正式开始之前,我先介绍两个非常重要的操作,<strong>左旋(rotate left)</strong>、<strong>右旋(rotate right)</strong>。左旋全称其实是叫<strong>围绕某个节点的左旋</strong>,那右旋的全称估计你已经猜到了,就叫<strong>围绕某个节点的右旋</strong>。</p><p>我们下面的平衡调整中,会一直用到这两个操作,所以我这里画了个示意图,帮助你彻底理解这两个操作。图中的 a,b,r 表示子树,可以为空。</p><p><img src="D:\Desktop\BaiduSyncdisk\MD草稿\数据结构与算法\img\26 1.jpg" alt=""></p><p>前面我说了,红黑树的插入、删除操作会破坏红黑树的定义,具体来说就是会破坏红黑树的平衡,所以,我们现在就来看下,红黑树在插入、删除数据之后,如何调整平衡,继续当一棵合格的红黑树的。</p><h2 id="插入操作的平衡调整"><a href="#插入操作的平衡调整" class="headerlink" title="插入操作的平衡调整"></a>插入操作的平衡调整</h2><p>首先,我们来看插入操作。</p><p>红黑树规定,插入的节点必须是红色的。而且,二叉查找树中新插入的节点都是放在叶子节点上。所以,关于插入操作的平衡调整,有这样两种特殊情况,但是也都非常好处理。</p><p>如果插入节点的父节点是黑色的,那我们什么都不用做,它仍然满足红黑树的定义。</p><p>如果插入的节点是根节点,那我们直接改变它的颜色,把它变成黑色就可以了。</p><p>除此之外,其他情况都会违背红黑树的定义,于是我们就需要进行调整,调整的过程包含两种基础的操作:左右旋转和改变颜色。</p><p>红黑树的平衡调整过程是一个迭代的过程。我们把正在处理的节点叫作关注节点。关注节点会随着不停地迭代处理,而不断发生变化。最开始的关注节点就是新插入的节点。</p><p>新节点插入之后,如果红黑树的平衡被打破,那一般会有下面三种情况。我们只需要根据每种情况的特点,不停地调整,就可以让红黑树继续符合定义,也就是继续保持平衡。</p><p>我们下面依次来看每种情况的调整过程。提醒你注意下,为了简化描述,我把父节点的兄弟节点叫作叔叔节点,父节点的父节点叫作祖父节点。</p><p><strong>CASE 1:如果关注节点是 a,它的叔叔节点 d 是红色,</strong>我们就依次执行下面的操作:</p><ul><li><p>将关注节点 a 的父节点 b、叔叔节点 d 的颜色都设置成黑色;</p></li><li><p>将关注节点 a 的祖父节点 c 的颜色设置成红色;</p></li><li><p>关注节点变成 a 的祖父节点 c;</p></li><li><p>跳到 CASE 2 或者 CASE 3。</p><p><img src="D:\Desktop\BaiduSyncdisk\MD草稿\数据结构与算法\img\26 2.jpg" alt=""></p></li></ul><p><strong>CASE 2:如果关注节点是 a,它的叔叔节点 d 是黑色,关注节点 a 是其父节点 b 的右子节点,</strong>我们就依次执行下面的操作:</p><p>关注节点变成节点 a 的父节点 b;</p><p>围绕新的关注节点b 左旋;</p><p>跳到 CASE 3。</p><p><img src="D:\Desktop\BaiduSyncdisk\MD草稿\数据结构与算法\img\26 3.jpg" alt=""></p><p>CASE 3:如果关注节点是 a,它的叔叔节点 d 是黑色,关注节点 a 是其父节点 b 的左子节点,我们就依次执行下面的操作:</p><ul><li><p>围绕关注节点 a 的祖父节点 c 右旋;</p></li><li><p>将关注节点 a 的父节点 b、兄弟节点 c 的颜色互换。</p></li><li><p>调整结束。</p><p><img src="D:\Desktop\BaiduSyncdisk\MD草稿\数据结构与算法\img\26 4.jpg" alt=""></p></li></ul><h2 id="删除操作的平衡调整"><a href="#删除操作的平衡调整" class="headerlink" title="删除操作的平衡调整"></a>删除操作的平衡调整</h2><p>红黑树插入操作的平衡调整还不是很难,但是它的删除操作的平衡调整相对就要难多了。不过原理都是类似的,我们依旧只需要根据关注节点与周围节点的排布特点,按照一定的规则去调整就行了。</p><p>删除操作的平衡调整分为两步,第一步是<strong>针对删除节点初步调整。</strong>初步调整只是保证整棵红黑树在一个节点删除之后,仍然满足最后一条定义的要求,也就是说,每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;第二步是<strong>针对关注节点进行二次调整</strong>,让它满足红黑树的第三条定义,即不存在相邻的两个红色节点。</p><h3 id="1-针对删除节点初步调整"><a href="#1-针对删除节点初步调整" class="headerlink" title="1.针对删除节点初步调整"></a>1.针对删除节点初步调整</h3><p>这里需要注意一下,红黑树的定义中“只包含红色节点和黑色节点”,经过初步调整之后,为了保证满足红黑树定义的最后一条要求,有些节点会被标记成两种颜色,“红 - 黑”或者“黑 - 黑”。如果一个节点被标记为了“黑 - 黑”,那在计算黑色节点个数的时候,要算成两个黑色节点。</p><p>在下面的讲解中,如果一个节点既可以是红色,也可以是黑色,在画图的时候,我会用一半红色一半黑色来表示。如果一个节点是“红 - 黑”或者“黑 - 黑”,我会用左上角的一个小黑点来表示额外的黑色。</p><p><strong>CASE 1:如果要删除的节点是 a,它只有一个子节点 b,</strong>那我们就依次进行下面的操作:</p><ul><li><p>删除节点 a,并且把节点 b 替换到节点 a 的位置,这一部分操作跟普通的二叉查找树的删除操作一样;</p></li><li><p>节点 a 只能是黑色,节点 b 也只能是红色,其他情况均不符合红黑树的定义。这种情况下,我们把节点 b 改为黑色;</p></li><li><p>调整结束,不需要进行二次调整。</p><p><img src="D:\Desktop\BaiduSyncdisk\MD草稿\数据结构与算法\img\26 5.jpg" alt=""></p></li></ul><p><strong>CASE 2:如果要删除的节点 a 有两个非空子节点,并且它的后继节点就是节点 a 的右子节点 c。</strong>我们就依次进行下面的操作:</p><p>如果节点 a 的后继节点就是右子节点 c,那右子节点 c 肯定没有左子树。我们把节点 a 删除,并且将节点 c 替换到节点 a 的位置。这一部分操作跟普通的二叉查找树的删除操作无异;</p><p>然后把节点 c 的颜色设置为跟节点 a 相同的颜色;</p><p>如果节点 c 是黑色,为了不违反红黑树的最后一条定义,我们给节点 c 的右子节点 d 多加一个黑色,这个时候节点 d 就成了“红 - 黑”或者“黑 - 黑”;</p><p>这个时候,关注节点变成了节点 d,第二步的调整操作就会针对关注节点来做。</p><p><img src="D:\Desktop\BaiduSyncdisk\MD草稿\数据结构与算法\img\26 6.jpg" alt=""></p><p><strong>CASE 3:如果要删除的是节点 a,它有两个非空子节点,并且节点 a 的后继节点不是右子节点,</strong>我们就依次进行下面的操作:</p><ul><li>找到后继节点 d,并将它删除,删除后继节点 d 的过程参照 CASE 1;</li><li>将节点 a 替换成后继节点 d;</li><li>把节点 d 的颜色设置为跟节点 a 相同的颜色;</li><li>如果节点 d 是黑色,为了不违反红黑树的最后一条定义,我们给节点 d 的右子节点 c 多加一个黑色,这个时候节点 c 就成了“红 - 黑”或者“黑 - 黑”;</li><li>这个时候,关注节点变成了节点 c,第二步的调整操作就会针对关注节点来做。</li></ul><p><img src="D:\Desktop\BaiduSyncdisk\MD草稿\数据结构与算法\img\26 7.jpg" alt=""></p><h3 id="2-针对关注节点进行二次调整"><a href="#2-针对关注节点进行二次调整" class="headerlink" title="2.针对关注节点进行二次调整"></a>2.针对关注节点进行二次调整</h3><p>经过初步调整之后,关注节点变成了“红 - 黑”或者“黑 - 黑”节点。针对这个关注节点,我们再分四种情况来进行二次调整。二次调整是为了让红黑树中不存在相邻的红色节点。</p><p><strong>CASE 1:如果关注节点是 a,它的兄弟节点 c 是红色的,</strong>我们就依次进行下面的操作:</p><ul><li><p>围绕关注节点 a 的父节点 b 左旋;</p></li><li><p>关注节点 a 的父节点 b 和祖父节点 c 交换颜色;</p></li><li><p>关注节点不变;</p></li><li><p>继续从四种情况中选择适合的规则来调整。</p><p><img src="D:\Desktop\BaiduSyncdisk\MD草稿\数据结构与算法\img\26 8.jpg" alt=""></p></li></ul><p><strong>CASE 2:如果关注节点是 a,它的兄弟节点 c 是黑色的,并且节点 c 的左右子节点 d、e 都是黑色的,</strong>我们就依次进行下面的操作:</p><ul><li>将关注节点 a 的兄弟节点 c 的颜色变成红色;</li><li>从关注节点 a 中去掉一个黑色,这个时候节点 a 就是单纯的红色或者黑色;</li><li>给关注节点 a 的父节点 b 添加一个黑色,这个时候节点 b 就变成了“红 - 黑”或者“黑 - 黑”;</li><li>关注节点从 a 变成其父节点 b;</li><li>继续从四种情况中选择符合的规则来调整。</li></ul><p><img src="D:\Desktop\BaiduSyncdisk\MD草稿\数据结构与算法\img\26 9.jpg" alt=""></p><p><strong>CASE 3:如果关注节点是 a,它的兄弟节点 c 是黑色,c 的左子节点 d 是红色,c 的右子节点 e 是黑色,</strong>我们就依次进行下面的操作:</p><ul><li><p>围绕关注节点 a 的兄弟节点 c 右旋;</p></li><li><p>节点 c 和节点 d 交换颜色;</p></li><li><p>关注节点不变;</p></li><li><p>跳转到 CASE 4,继续调整。</p><p><img src="D:\Desktop\BaiduSyncdisk\MD草稿\数据结构与算法\img\26 10.jpg" alt=""></p></li></ul><p><strong>CASE 4:如果关注节点 a 的兄弟节点 c 是黑色的,并且 c 的右子节点是红色的,</strong>我们就依次进行下面的操作:</p><ul><li><p>围绕关注节点 a 的父节点 b 左旋;</p></li><li><p>将关注节点 a 的兄弟节点 c 的颜色,跟关注节点 a 的父节点 b 设置成相同的颜色;</p></li><li><p>将关注节点 a 的父节点 b 的颜色设置为黑色;</p></li><li><p>从关注节点 a 中去掉一个黑色,节点 a 就变成了单纯的红色或者黑色;将关注节点 a 的叔叔节点 e 设置为黑色;</p></li><li><p>调整结束。</p><p><img src="D:\Desktop\BaiduSyncdisk\MD草稿\数据结构与算法\img\26 11.jpg" alt=""></p></li></ul><h3 id="解答开篇"><a href="#解答开篇" class="headerlink" title="解答开篇"></a>解答开篇</h3><p>红黑树的平衡调整就讲完了,现在,你能回答开篇的问题了吗?为什么红黑树的定义中,要求叶子节点是黑色的空节点?</p><p>要我说,之所以有这么奇怪的要求,其实就是为了实现起来方便。只要满足这一条要求,那在任何时刻,红黑树的平衡操作都可以归结为我们刚刚讲的那几种情况。</p><p>还是有点不好理解,我通过一个例子来解释一下。假设红黑树的定义中不包含刚刚提到的那一条“叶子节点必须是黑色的空节点”,我们往一棵红黑树中插入一个数据,新插入节点的父节点也是红色的,两个红色的节点相邻,这个时候,红黑树的定义就被破坏了。那我们应该如何调整呢?</p><p><img src="D:\Desktop\BaiduSyncdisk\MD草稿\数据结构与算法\img\26 12.jpg" alt=""></p><p>你会发现,这个时候,我们前面讲的插入时,三种情况下的平衡调整规则,没有一种是适用的。但是,如果我们把黑色的空节点都给它加上,变成下面这样,你会发现,它满足 CASE 2 了。</p><p><img src="D:\Desktop\BaiduSyncdisk\MD草稿\数据结构与算法\img\26 13.jpg" alt=""></p><p>你可能会说,你可以调整一下平衡调整规则啊。比如把 CASE 2 改为“如果关注节点 a 的叔叔节点 b 是黑色或者不存在,a 是父节点的右子节点,就进行某某操作”。当然可以,但是这样的话规则就没有原来简洁了。</p><p>你可能还会说,这样给红黑树添加黑色的空的叶子节点,会不会比较浪费存储空间呢?答案是不会的。虽然我们在讲解或者画图的时候,每个黑色的、空的叶子节点都是独立画出来的。实际上,在具体实现的时候,我们只需要像下面这样,共用一个黑色的、空的叶子节点就行了。</p><p><img src="D:\Desktop\BaiduSyncdisk\MD草稿\数据结构与算法\img\26 14.jpg" alt=""></p><h2 id="内容小结"><a href="#内容小结" class="headerlink" title="内容小结"></a>内容小结</h2><p>“红黑树一向都很难学”,有这种想法的人很多。但是我感觉,其实主要原因是,很多人试图去记忆它的平衡调整策略。实际上,你只需要能看懂我讲的过程,没有知识盲点,就算是掌握了这部分内容了。毕竟实际的软件开发并不是闭卷考试,当你真的需要实现一个红黑树的时候,可以对照着我讲的步骤,一点一点去实现。</p><p>现在,我就来总结一下,如何比较轻松地看懂我今天讲的操作过程。</p><p>第一点,<strong>把红黑树的平衡调整的过程比作魔方复原,不要过于深究这个算法的正确性。</strong>你只需要明白,只要按照固定的操作步骤,保持插入、删除的过程,不破坏平衡树的定义就行了。</p><p>第二点,<strong>找准关注节点,不要搞丢、搞错关注节点。</strong>因为每种操作规则,都是基于关注节点来做的,只有弄对了关注节点,才能对应到正确的操作规则中。在迭代的调整过程中,关注节点在不停地改变,所以,这个过程一定要注意,不要弄丢了关注节点。</p><p>第三点,<strong>插入操作的平衡调整比较简单,但是删除操作就比较复杂。</strong>针对删除操作,我们有两次调整,第一次是针对要删除的节点做初步调整,让调整后的红黑树继续满足第四条定义,“每个节点到可达叶子节点的路径都包含相同个数的黑色节点”。但是这个时候,第三条定义就不满足了,有可能会存在两个红色节点相邻的情况。第二次调整就是解决这个问题,让红黑树不存在相邻的红色节点。</p><h2 id="课后思考"><a href="#课后思考" class="headerlink" title="课后思考"></a>课后思考</h2><p>如果你以前了解或者学习过红黑树,关于红黑树的实现,你也可以在留言区讲讲,你是怎样来学习的?在学习的过程中,有过什么样的心得体会?有没有什么好的学习方法?</p><p>欢迎留言和我分享,我会第一时间给你反馈。</p>]]></content>
<summary type="html"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" cla</summary>
<category term="算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
</entry>
<entry>
<title>23|红黑树(上):为什么工程中都用红黑树这种二叉树?</title>
<link href="https://chanmoyun.gitee.io/2024/03/16/Data-structure/23.%E7%BA%A2%E9%BB%91%E6%A0%91%EF%BC%88%E4%B8%8A%EF%BC%89%EF%BC%9A%E4%B8%BA%E4%BB%80%E4%B9%88%E5%B7%A5%E7%A8%8B%E4%B8%AD%E9%83%BD%E7%94%A8%E7%BA%A2%E9%BB%91%E6%A0%91%E8%BF%99%E7%A7%8D%E4%BA%8C%E5%8F%89%E6%A0%91%EF%BC%9F/"/>
<id>https://chanmoyun.gitee.io/2024/03/16/Data-structure/23.%E7%BA%A2%E9%BB%91%E6%A0%91%EF%BC%88%E4%B8%8A%EF%BC%89%EF%BC%9A%E4%B8%BA%E4%BB%80%E4%B9%88%E5%B7%A5%E7%A8%8B%E4%B8%AD%E9%83%BD%E7%94%A8%E7%BA%A2%E9%BB%91%E6%A0%91%E8%BF%99%E7%A7%8D%E4%BA%8C%E5%8F%89%E6%A0%91%EF%BC%9F/</id>
<published>2024-03-15T16:00:00.000Z</published>
<updated>2024-03-18T02:33:36.985Z</updated>
<content type="html"><![CDATA[<link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="\assets\js\Meting.min.js"></script><h1 id="红黑树(上):为什么工程中都用红黑树这种二叉树?"><a href="#红黑树(上):为什么工程中都用红黑树这种二叉树?" class="headerlink" title="红黑树(上):为什么工程中都用红黑树这种二叉树?"></a>红黑树(上):为什么工程中都用红黑树这种二叉树?</h1><p>上两节,我们依次讲了树、二叉树、二叉查找树。二叉查找树是最常用的一种二叉树,它支持快速插入、删除、查找操作,各个操作的时间复杂度跟树的高度成正比,理想情况下,时间复杂度是 O(logn)。</p><p>不过,二叉查找树在频繁的动态更新过程中,可能会出现树的高度远大于 log2n 的情况,从而导致各个操作的效率下降。极端情况下,二叉树会退化为链表,时间复杂度会退化到 O(n)。我上一节说了,要解决这个复杂度退化的问题,我们需要设计一种平衡二叉查找树,也就是今天要讲的这种数据结构。</p><p>很多书籍里,但凡讲到平衡二叉查找树,就会拿红黑树作为例子。不仅如此,如果你有一定的开发经验,你会发现,在工程中,很多用到平衡二叉查找树的地方都会用红黑树。你有没有想过,<strong>为什么工程中都喜欢用红黑树,而不是其他平衡二叉查找树呢?</strong></p><p>带着这个问题,让我们一起来学习今天的内容吧!</p><h2 id="什么是“平衡二叉查找树”?"><a href="#什么是“平衡二叉查找树”?" class="headerlink" title="什么是“平衡二叉查找树”?"></a>什么是“平衡二叉查找树”?</h2><p>平衡二叉树的严格定义是这样的:二叉树中任意一个节点的左右子树的高度相差不能大于 1。从这个定义来看,上一节我们讲的完全二叉树、满二叉树其实都是平衡二叉树,但是非完全二叉树也有可能是平衡二叉树。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/25%201.jpg" alt=""></p><p>平衡二叉查找树不仅满足上面平衡二叉树的定义,还满足二叉查找树的特点。最先被发明的平衡二叉查找树是<a href="">AVL 树</a>,它严格符合我刚讲到的平衡二叉查找树的定义,即任何节点的左右子树高度相差不超过 1,是一种高度平衡的二叉查找树。</p><p>但是很多平衡二叉查找树其实并没有严格符合上面的定义(树中任意一个节点的左右子树的高度相差不能大于 1),比如我们下面要讲的红黑树,它从根节点到各个叶子节点的最长路径,有可能会比最短路径大一倍。</p><p>我们学习数据结构和算法是为了应用到实际的开发中的,所以,我觉得没必去死抠定义。对于平衡二叉查找树这个概念,我觉得我们要从这个数据结构的由来,去理解“平衡”的意思。</p><p>发明平衡二叉查找树这类数据结构的初衷是,解决普通二叉查找树在频繁的插入、删除等动态更新的情况下,出现时间复杂度退化的问题。</p><p>所以,<strong>平衡二叉查找树中“平衡”的意思,其实就是让整棵树左右看起来比较“对称”、比较“平衡”,不要出现左子树很高、右子树很矮的情况。这样就能让整棵树的高度相对来说低一些,相应的插入、删除、查找等操作的效率高一些。</strong></p><p>所以,如果我们现在设计一个新的平衡二叉查找树,只要树的高度不比 log2n 大很多(比如树的高度仍然是对数量级的),尽管它不符合我们前面讲的严格的平衡二叉查找树的定义,但我们仍然可以说,这是一个合格的平衡二叉查找树。</p><h2 id="如何定义一棵“红黑树”?"><a href="#如何定义一棵“红黑树”?" class="headerlink" title="如何定义一棵“红黑树”?"></a>如何定义一棵“红黑树”?</h2><p>平衡二叉查找树其实有很多,比如,Splay Tree(伸展树)、Treap(树堆)等,但是我们提到平衡二叉查找树,听到的基本都是红黑树。它的出镜率甚至要高于“平衡二叉查找树”这几个字,有时候,我们甚至默认平衡二叉查找树就是红黑树,那我们现在就来看看这个“明星树”。</p><p>红黑树的英文是“Red-Black Tree”,简称 R-B Tree。它是一种不严格的平衡二叉查找树,我前面说了,它的定义是不严格符合平衡二叉查找树的定义的。那红黑树究竟是怎么定义的呢?</p><p>顾名思义,红黑树中的节点,一类被标记为黑色,一类被标记为红色。除此之外,一棵红黑树还需要满足这样几个要求:</p><ul><li>根节点是黑色的;</li><li>每个叶子节点都是黑色的空节点(NIL),也就是说,叶子节点不存储数据;</li><li>任何相邻的节点都不能同时为红色,也就是说,红色节点是被黑色节点隔开的;</li><li>每个节点,从该节点到达其可达叶子节点的所有路径,都包含相同数目的黑色节点;</li></ul><p>这里的第二点要求“叶子节点都是黑色的空节点”,稍微有些奇怪,它主要是为了简化红黑树的代码实现而设置的,下一节我们讲红黑树的实现的时候会讲到。<strong>这节我们暂时不考虑这一点,所以,在画图和讲解的时候,我将黑色的、空的叶子节点都省略掉了。</strong></p><p>为了让你更好地理解上面的定义,我画了两个红黑树的图例,你可以对照着看下。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/25%202.jpg" alt=""></p><h2 id="为什么说红黑树是“近似平衡”的?"><a href="#为什么说红黑树是“近似平衡”的?" class="headerlink" title="为什么说红黑树是“近似平衡”的?"></a>为什么说红黑树是“近似平衡”的?</h2><p>我们前面也讲到,平衡二叉查找树的初衷,是为了解决二叉查找树因为动态更新导致的性能退化问题。所以,<strong>“平衡”的意思可以等价为性能不退化。“近似平衡”就等价为性能不会退化的太严重。</strong></p><p>我们在上一节讲过,二叉查找树很多操作的性能都跟树的高度成正比。一棵极其平衡的二叉树(满二叉树或完全二叉树)的高度大约是 log2n,所以如果要证明红黑树是近似平衡的,我们只需要分析,红黑树的高度是否比较稳定地趋近 log2n 就好了。</p><p>红黑树的高度不是很好分析,我带你一步一步来推导。</p><p><strong>首先,我们来看,如果我们将红色节点从红黑树中去掉,那单纯包含黑色节点的红黑树的高度是多少呢?</strong></p><p>红色节点删除之后,有些节点就没有父节点了,它们会直接拿这些节点的祖父节点(父节点的父节点)作为父节点。所以,之前的二叉树就变成了四叉树。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/25%203.jpg" alt=""></p><p>前面红黑树的定义里有这么一条:从任意节点到可达的叶子节点的每个路径包含相同数目的黑色节点。我们从四叉树中取出某些节点,放到叶节点位置,四叉树就变成了完全二叉树。所以,仅包含黑色节点的四叉树的高度,比包含相同节点个数的完全二叉树的高度还要小。</p><p>上一节我们说,完全二叉树的高度近似 log2n,这里的四叉“黑树”的高度要低于完全二叉树,所以去掉红色节点的“黑树”的高度也不会超过 log2n。</p><p><strong>我们现在知道只包含黑色节点的“黑树”的高度,那我们现在把红色节点加回去,高度会变成多少呢?</strong></p><p>从上面我画的红黑树的例子和定义看,在红黑树中,红色节点不能相邻,也就是说,有一个红色节点就要至少有一个黑色节点,将它跟其他红色节点隔开。红黑树中包含最多黑色节点的路径不会超过 log2n,所以加入红色节点之后,最长路径不会超过 2log2n,也就是说,红黑树的高度近似 2log2n。</p><p>所以,红黑树的高度只比高度平衡的 AVL 树的高度(log2n)仅仅大了一倍,在性能上,下降得并不多。这样推导出来的结果不够精确,实际上红黑树的性能更好。</p><h2 id="解答开篇"><a href="#解答开篇" class="headerlink" title="解答开篇"></a>解答开篇</h2><p>我们刚刚提到了很多平衡二叉查找树,现在我们就来看下,为什么在工程中大家都喜欢用红黑树这种平衡二叉查找树?</p><p>我们前面提到 Treap、Splay Tree,绝大部分情况下,它们操作的效率都很高,但是也无法避免极端情况下时间复杂度的退化。尽管这种情况出现的概率不大,但是对于单次操作时间非常敏感的场景来说,它们并不适用。</p><p>AVL 树是一种高度平衡的二叉树,所以查找的效率非常高,但是,有利就有弊,AVL 树为了维持这种高度的平衡,就要付出更多的代价。每次插入、删除都要做调整,就比较复杂、耗时。所以,对于有频繁的插入、删除操作的数据集合,使用 AVL 树的代价就有点高了。</p><p>红黑树只是做到了近似平衡,并不是严格的平衡,所以在维护平衡的成本上,要比 AVL 树要低。</p><p>所以,红黑树的插入、删除、查找各种操作性能都比较稳定。对于工程应用来说,要面对各种异常情况,为了支撑这种工业级的应用,我们更倾向于这种性能稳定的平衡二叉查找树。</p><h2 id="内容小结"><a href="#内容小结" class="headerlink" title="内容小结"></a>内容小结</h2><p>很多同学都觉得红黑树很难,的确,它算是最难掌握的一种数据结构。其实红黑树最难的地方是它的实现,我们今天还没有涉及,下一节我会专门来讲。</p><p>不过呢,我认为,我们其实不应该把学习的侧重点,放到它的实现上。那你可能要问了,关于红黑树,我们究竟需要掌握哪些东西呢?</p><p>还记得我多次说过的观点吗?<strong>我们学习数据结构和算法,要学习它的由来、特性、适用的场景以及它能解决的问题。对于红黑树,也不例外。你如果能搞懂这几个问题,其实就已经足够了。</strong></p><p>红黑树是一种平衡二叉查找树。它是为了解决普通二叉查找树在数据更新的过程中,复杂度退化的问题而产生的。红黑树的高度近似 log2n,所以它是近似平衡,插入、删除、查找操作的时间复杂度都是 O(logn)。</p><p>因为红黑树是一种性能非常稳定的二叉查找树,所以,在工程中,但凡是用到动态插入、删除、查找数据的场景,都可以用到它。不过,它实现起来比较复杂,如果自己写代码实现,难度会有些高,这个时候,我们其实更倾向用跳表来替代它。</p><h2 id="课后思考"><a href="#课后思考" class="headerlink" title="课后思考"></a>课后思考</h2><p>动态数据结构支持动态地数据插入、删除、查找操作,除了红黑树,我们前面还学习过哪些呢?能对比一下各自的优势、劣势,以及应用场景吗?</p><p>欢迎留言和我分享,我会第一时间给你反馈。</p>]]></content>
<summary type="html"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" cla</summary>
<category term="算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
</entry>
<entry>
<title>22|二叉树基础(下):有了如此高效的散列表,为什么还需要二叉树?</title>
<link href="https://chanmoyun.gitee.io/2024/03/15/Data-structure/22.%E4%BA%8C%E5%8F%89%E6%A0%91%E5%9F%BA%E7%A1%80%EF%BC%88%E4%B8%8B%EF%BC%89%EF%BC%9A%E6%9C%89%E4%BA%86%E5%A6%82%E6%AD%A4%E9%AB%98%E6%95%88%E7%9A%84%E6%95%A3%E5%88%97%E8%A1%A8%EF%BC%8C%E4%B8%BA%E4%BB%80%E4%B9%88%E8%BF%98%E9%9C%80%E8%A6%81%E4%BA%8C%E5%8F%89%E6%A0%91%EF%BC%9F/"/>
<id>https://chanmoyun.gitee.io/2024/03/15/Data-structure/22.%E4%BA%8C%E5%8F%89%E6%A0%91%E5%9F%BA%E7%A1%80%EF%BC%88%E4%B8%8B%EF%BC%89%EF%BC%9A%E6%9C%89%E4%BA%86%E5%A6%82%E6%AD%A4%E9%AB%98%E6%95%88%E7%9A%84%E6%95%A3%E5%88%97%E8%A1%A8%EF%BC%8C%E4%B8%BA%E4%BB%80%E4%B9%88%E8%BF%98%E9%9C%80%E8%A6%81%E4%BA%8C%E5%8F%89%E6%A0%91%EF%BC%9F/</id>
<published>2024-03-14T16:00:00.000Z</published>
<updated>2024-03-18T02:21:36.473Z</updated>
<content type="html"><![CDATA[<link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="\assets\js\Meting.min.js"></script><h1 id="二叉树基础(下):有了如此高效的散列表,为什么还需要二叉树?"><a href="#二叉树基础(下):有了如此高效的散列表,为什么还需要二叉树?" class="headerlink" title="二叉树基础(下):有了如此高效的散列表,为什么还需要二叉树?"></a>二叉树基础(下):有了如此高效的散列表,为什么还需要二叉树?</h1><p>上一节我们学习了树、二叉树以及二叉树的遍历,今天我们再来学习一种特殊的的二叉树,二叉查找树。二叉查找树最大的特点就是,支持动态数据集合的快速插入、删除、查找操作。</p><p>我们之前说过,散列表也是支持这些操作的,并且散列表的这些操作比二叉查找树更高效,时间复杂度是 O(1)。<strong>既然有了这么高效的散列表,使用二叉树的地方是不是都可以替换成散列表呢?有没有哪些地方是散列表做不了,必须要用二叉树来做的呢?</strong></p><p>带着这些问题,我们就来学习今天的内容,二叉查找树!</p><h2 id="二叉查找树(Binary-Search-Tree)"><a href="#二叉查找树(Binary-Search-Tree)" class="headerlink" title="二叉查找树(Binary Search Tree)"></a>二叉查找树(Binary Search Tree)</h2><p>二叉查找树是二叉树中最常用的一种类型,也叫二叉搜索树。顾名思义,二叉查找树是为了实现快速查找而生的。不过,它不仅仅支持快速查找一个数据,还支持快速插入、删除一个数据。</p><p>它是怎么做到这些的呢?这些都依赖于二叉查找树的特殊结构。<strong>二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值。</strong> 我画了几个二叉查找树的例子,你一看应该就清楚了。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/24%201.jpg" alt=""></p><p>前面我们讲到,二叉查找树支持快速查找、插入、删除操作,现在我们就依次来看下,这三个操作是如何实现的。</p><h3 id="1-二叉查找树的查找操作"><a href="#1-二叉查找树的查找操作" class="headerlink" title="1. 二叉查找树的查找操作"></a>1. 二叉查找树的查找操作</h3><p>首先,我们看如何在二叉查找树中查找一个节点。我们先取根节点,如果它等于我们要查找的数据,那就返回。如果要查找的数据比根节点的值小,那就在左子树中递归查找;如果要查找的数据比根节点的值大,那就在右子树中递归查找。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/24%202.jpg" alt=""></p><p>这里我把查找的代码实现了一下,贴在下面了,结合代码,理解起来会更加容易。</p><p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">public <span class="keyword">class</span> <span class="title class_">BinarySearchTree</span> {</span><br><span class="line"> private Node tree;</span><br><span class="line"></span><br><span class="line"> public Node find(<span class="built_in">int</span> data) {</span><br><span class="line"> Node p = tree;</span><br><span class="line"> <span class="keyword">while</span> (p != null) {</span><br><span class="line"> <span class="keyword">if</span> (data < p.data) p = p.left;</span><br><span class="line"> <span class="keyword">else</span> <span class="keyword">if</span> (data > p.data) p = p.right;</span><br><span class="line"> <span class="keyword">else</span> <span class="keyword">return</span> p;</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">return</span> null;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> public static <span class="keyword">class</span> <span class="title class_">Node</span> {</span><br><span class="line"> private <span class="built_in">int</span> data;</span><br><span class="line"> private Node left;</span><br><span class="line"> private Node right;</span><br><span class="line"></span><br><span class="line"> public Node(<span class="built_in">int</span> data) {</span><br><span class="line"> this.data = data;</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><h3 id="2-二叉查找树的插入操作"><a href="#2-二叉查找树的插入操作" class="headerlink" title="2. 二叉查找树的插入操作"></a>2. 二叉查找树的插入操作</h3><p>二叉查找树的插入过程有点类似查找操作。新插入的数据一般都是在叶子节点上,所以我们只需要从根节点开始,依次比较要插入的数据和节点的大小关系。</p><p>如果要插入的数据比节点的数据大,并且节点的右子树为空,就将新数据直接插到右子节点的位置;如果不为空,就再递归遍历右子树,查找插入位置。同理,如果要插入的数据比节点数值小,并且节点的左子树为空,就将新数据插入到左子节点的位置;如果不为空,就再递归遍历左子树,查找插入位置。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/24%203.jpg" alt=""></p><p>同样,插入的代码我也实现了一下,贴在下面,你可以看看。</p><p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">public void insert(<span class="built_in">int</span> data) {</span><br><span class="line"> <span class="keyword">if</span> (tree == null) {</span><br><span class="line"> tree = new Node(data);</span><br><span class="line"> <span class="keyword">return</span>;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> Node p = tree;</span><br><span class="line"> <span class="keyword">while</span> (p != null) {</span><br><span class="line"> <span class="keyword">if</span> (data > p.data) {</span><br><span class="line"> <span class="keyword">if</span> (p.right == null) {</span><br><span class="line"> p.right = new Node(data);</span><br><span class="line"> <span class="keyword">return</span>;</span><br><span class="line"> }</span><br><span class="line"> p = p.right;</span><br><span class="line"> } <span class="keyword">else</span> { // data < p.data</span><br><span class="line"> <span class="keyword">if</span> (p.left == null) {</span><br><span class="line"> p.left = new Node(data);</span><br><span class="line"> <span class="keyword">return</span>;</span><br><span class="line"> }</span><br><span class="line"> p = p.left;</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><h3 id="3-二叉查找树的删除操作"><a href="#3-二叉查找树的删除操作" class="headerlink" title="3. 二叉查找树的删除操作"></a>3. 二叉查找树的删除操作</h3><p>二叉查找树的查找、插入操作都比较简单易懂,但是它的删除操作就比较复杂了 。针对要删除节点的子节点个数的不同,我们需要分三种情况来处理。</p><p>第一种情况是,如果要删除的节点没有子节点,我们只需要直接将父节点中,指向要删除节点的指针置为 null。比如图中的删除节点 55。</p><p>第二种情况是,如果要删除的节点只有一个子节点(只有左子节点或者右子节点),我们只需要更新父节点中,指向要删除节点的指针,让它指向要删除节点的子节点就可以了。比如图中的删除节点 13。</p><p>第三种情况是,如果要删除的节点有两个子节点,这就比较复杂了。我们需要找到这个节点的右子树中的最小节点,把它替换到要删除的节点上。然后再删除掉这个最小节点,因为最小节点肯定没有左子节点(如果有左子结点,那就不是最小节点了),所以,我们可以应用上面两条规则来删除这个最小节点。比如图中的删除节点 18。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/24%204.jpg" alt=""></p><p>老规矩,我还是把删除的代码贴在这里。</p><p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">public void delete(<span class="built_in">int</span> data) {</span><br><span class="line"> Node p = tree; // p指向要删除的节点,初始化指向根节点</span><br><span class="line"> Node pp = null; // pp记录的是p的父节点</span><br><span class="line"> <span class="keyword">while</span> (p != null && p.data != data) {</span><br><span class="line"> pp = p;</span><br><span class="line"> <span class="keyword">if</span> (data > p.data) p = p.right;</span><br><span class="line"> <span class="keyword">else</span> p = p.left;</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">if</span> (p == null) <span class="keyword">return</span>; // 没有找到</span><br><span class="line"></span><br><span class="line"> // 要删除的节点有两个子节点</span><br><span class="line"> <span class="keyword">if</span> (p.left != null && p.right != null) { // 查找右子树中最小节点</span><br><span class="line"> Node minP = p.right;</span><br><span class="line"> Node minPP = p; // minPP表示minP的父节点</span><br><span class="line"> <span class="keyword">while</span> (minP.left != null) {</span><br><span class="line"> minPP = minP;</span><br><span class="line"> minP = minP.left;</span><br><span class="line"> }</span><br><span class="line"> p.data = minP.data; // 将minP的数据替换到p中</span><br><span class="line"> p = minP; // 下面就变成了删除minP了</span><br><span class="line"> pp = minPP;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> // 删除节点是叶子节点或者仅有一个子节点</span><br><span class="line"> Node child; // p的子节点</span><br><span class="line"> <span class="keyword">if</span> (p.left != null) child = p.left;</span><br><span class="line"> <span class="keyword">else</span> <span class="keyword">if</span> (p.right != null) child = p.right;</span><br><span class="line"> <span class="keyword">else</span> child = null;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (pp == null) tree = child; // 删除的是根节点</span><br><span class="line"> <span class="keyword">else</span> <span class="keyword">if</span> (pp.left == p) pp.left = child;</span><br><span class="line"> <span class="keyword">else</span> pp.right = child;</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>实际上,关于二叉查找树的删除操作,还有个非常简单、取巧的方法,就是单纯将要删除的节点标记为“已删除”,但是并不真正从树中将这个节点去掉。这样原本删除的节点还需要存储在内存中,比较浪费内存空间,但是删除操作就变得简单了很多。而且,这种处理方法也并没有增加插入、查找操作代码实现的难度。</p><h3 id="4-二叉查找树的其他操作"><a href="#4-二叉查找树的其他操作" class="headerlink" title="4. 二叉查找树的其他操作"></a>4. 二叉查找树的其他操作</h3><p>除了插入、删除、查找操作之外,二叉查找树中还可以<strong>支持快速地查找最大节点和最小节点、前驱节点和后继节点。</strong>这些操作我就不一一展示了。我会将相应的代码放到 GitHub 上,你可以自己先实现一下,然后再去上面看。</p><p>二叉查找树除了支持上面几个操作之外,还有一个重要的特性,就是<strong>中序遍历二叉查找树,可以输出有序的数据序列,时间复杂度是 O(n),非常高效。</strong>因此,二叉查找树也叫作二叉排序树。</p><h2 id="支持重复数据的二叉查找树"><a href="#支持重复数据的二叉查找树" class="headerlink" title="支持重复数据的二叉查找树"></a>支持重复数据的二叉查找树</h2><p>前面讲二叉查找树的时候,我们默认树中节点存储的都是数字。很多时候,在实际的软件开发中,我们在二叉查找树中存储的,是一个包含很多字段的对象。我们利用对象的某个字段作为键值(key)来构建二叉查找树。我们把对象中的其他字段叫作卫星数据。</p><p>前面我们讲的二叉查找树的操作,针对的都是不存在键值相同的情况。那如果存储的两个对象键值相同,这种情况该怎么处理呢?我这里有两种解决方法。</p><p>第一种方法比较容易。二叉查找树中每一个节点不仅会存储一个数据,因此我们通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上。</p><p>第二种方法比较不好理解,不过更加优雅。</p><p>每个节点仍然只存储一个数据。在查找插入位置的过程中,如果碰到一个节点的值,与要插入数据的值相同,我们就将这个要插入的数据放到这个节点的右子树,也就是说,把这个新插入的数据当作大于这个节点的值来处理。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/24%205.jpg" alt=""></p><p>当要查找数据的时候,遇到值相同的节点,我们并不停止查找操作,而是继续在右子树中查找,直到遇到叶子节点,才停止。这样就可以把键值等于要查找值的所有节点都找出来。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/24%206.jpg" alt=""></p><p>对于删除操作,我们也需要先查找到每个要删除的节点,然后再按前面讲的删除操作的方法,依次删除。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/24%207.jpg" alt=""></p><h2 id="二叉查找树的时间复杂度分析"><a href="#二叉查找树的时间复杂度分析" class="headerlink" title="二叉查找树的时间复杂度分析"></a>二叉查找树的时间复杂度分析</h2><p>好了,对于二叉查找树常用操作的实现方式,你应该掌握得差不多了。现在,我们来分析一下,二叉查找树的插入、删除、查找操作的时间复杂度。</p><p>实际上,二叉查找树的形态各式各样。比如这个图中,对于同一组数据,我们构造了三种二叉查找树。它们的查找、插入、删除操作的执行效率都是不一样的。图中第一种二叉查找树,根节点的左右子树极度不平衡,已经退化成了链表,所以查找的时间复杂度就变成了 O(n)。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/24%208.jpg" alt=""></p><p>我刚刚其实分析了一种最糟糕的情况,我们现在来分析一个最理想的情况,二叉查找树是一棵完全二叉树(或满二叉树)。这个时候,插入、删除、查找的时间复杂度是多少呢?</p><p>从我前面的例子、图,以及还有代码来看,不管操作是插入、删除还是查找,<strong>时间复杂度其实都跟树的高度成正比,也就是 O(height)。</strong>既然这样,现在问题就转变成另外一个了,也就是,如何求一棵包含 n 个节点的完全二叉树的高度?</p><p>树的高度就等于最大层数减一,为了方便计算,我们转换成层来表示。从图中可以看出,包含 n 个节点的完全二叉树中,第一层包含 1 个节点,第二层包含 2 个节点,第三层包含 4 个节点,依次类推,下面一层节点个数是上一层的 2 倍,第 K 层包含的节点个数就是 2^(K-1)。</p><p>不过,对于完全二叉树来说,最后一层的节点个数有点儿不遵守上面的规律了。它包含的节点个数在 1 个到 2^(L-1) 个之间(我们假设最大层数是 L)。如果我们把每一层的节点个数加起来就是总的节点个数 n。也就是说,如果节点的个数是 n,那么 n 满足这样一个关系:</p><p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">n >= <span class="number">1</span>+<span class="number">2</span>+<span class="number">4</span>+<span class="number">8</span>+...+<span class="number">2</span>^(L-<span class="number">2</span>)+<span class="number">1</span></span><br><span class="line">n <= <span class="number">1</span>+<span class="number">2</span>+<span class="number">4</span>+<span class="number">8</span>+...+<span class="number">2</span>^(L-<span class="number">2</span>)+<span class="number">2</span>^(L-<span class="number">1</span>)</span><br></pre></td></tr></table></figure></p><p>借助等比数列的求和公式,我们可以计算出,L 的范围是[log2(n+1), log2n +1]。完全二叉树的层数小于等于 log2n +1,也就是说,完全二叉树的高度小于等于 log2n。</p><p>显然,极度不平衡的二叉查找树,它的查找性能肯定不能满足我们的需求。我们需要构建一种不管怎么删除、插入数据,在任何时候,都能保持任意节点左右子树都比较平衡的二叉查找树,这就是我们下一节课要详细讲的,一种特殊的二叉查找树,平衡二叉查找树。平衡二叉查找树的高度接近 logn,所以插入、删除、查找操作的时间复杂度也比较稳定,是 O(logn)。</p><h2 id="解答开篇"><a href="#解答开篇" class="headerlink" title="解答开篇"></a>解答开篇</h2><p>我们在散列表那节中讲过,散列表的插入、删除、查找操作的时间复杂度可以做到常量级的 O(1),非常高效。而二叉查找树在比较平衡的情况下,插入、删除、查找操作时间复杂度才是 O(logn),相对散列表,好像并没有什么优势,那我们为什么还要用二叉查找树呢?我认为有下面几个原因:</p><p>第一,散列表中的数据是无序存储的,如果要输出有序的数据,需要先进行排序。而对于二叉查找树来说,我们只需要中序遍历,就可以在 O(n) 的时间复杂度内,输出有序的数据序列。</p><p>第二,散列表扩容耗时很多,而且当遇到散列冲突时,性能不稳定,尽管二叉查找树的性能不稳定,但是在工程中,我们最常用的平衡二叉查找树的性能非常稳定,时间复杂度稳定在 O(logn)。</p><p>第三,笼统地来说,尽管散列表的查找等操作的时间复杂度是常量级的,但因为哈希冲突的存在,这个常量不一定比 logn 小,所以实际的查找速度可能不一定比 O(logn) 快。加上哈希函数的耗时,也不一定就比平衡二叉查找树的效率高。</p><p>第四,散列表的构造比二叉查找树要复杂,需要考虑的东西很多。比如散列函数的设计、冲突解决办法、扩容、缩容等。平衡二叉查找树只需要考虑平衡性这一个问题,而且这个问题的解决方案比较成熟、固定。</p><p>最后,为了避免过多的散列冲突,散列表装载因子不能太大,特别是基于开放寻址法解决冲突的散列表,不然会浪费一定的存储空间。</p><p>综合这几点,平衡二叉查找树在某些方面还是优于散列表的,所以,这两者的存在并不冲突。我们在实际的开发过程中,需要结合具体的需求来选择使用哪一个。</p><h2 id="内容小结"><a href="#内容小结" class="headerlink" title="内容小结"></a>内容小结</h2><p>今天我们学习了一种特殊的二叉树,二叉查找树。它支持快速地查找、插入、删除操作。二叉查找树中,每个节点的值都大于左子树节点的值,小于右子树节点的值。不过,这只是针对没有重复数据的情况。对于存在重复数据的二叉查找树,我介绍了两种构建方法,一种是让每个节点存储多个值相同的数据;另一种是,每个节点中存储一个数据。针对这种情况,我们只需要稍加改造原来的插入、删除、查找操作即可。</p><p>在二叉查找树中,查找、插入、删除等很多操作的时间复杂度都跟树的高度成正比。两个极端情况的时间复杂度分别是 O(n) 和 O(logn),分别对应二叉树退化成链表的情况和完全二叉树。</p><p>为了避免时间复杂度的退化,针对二叉查找树,我们又设计了一种更加复杂的树,平衡二叉查找树,时间复杂度可以做到稳定的 O(logn),下一节我们具体来讲。</p><h2 id="课后思考"><a href="#课后思考" class="headerlink" title="课后思考"></a>课后思考</h2><p>今天我讲了二叉树高度的理论分析方法,给出了粗略的数量级。如何通过编程,求出一棵给定二叉树的确切高度呢?</p>]]></content>
<summary type="html"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" cla</summary>
<category term="算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
</entry>
<entry>
<title>21|二叉树基础(上):什么样的二叉树适合用数组来存储?</title>
<link href="https://chanmoyun.gitee.io/2024/03/14/Data-structure/21.%E4%BA%8C%E5%8F%89%E6%A0%91%E5%9F%BA%E7%A1%80%EF%BC%88%E4%B8%8A%EF%BC%89%EF%BC%9A%E4%BB%80%E4%B9%88%E6%A0%B7%E7%9A%84%E4%BA%8C%E5%8F%89%E6%A0%91%E9%80%82%E5%90%88%E7%94%A8%E6%95%B0%E7%BB%84%E6%9D%A5%E5%AD%98%E5%82%A8%EF%BC%9F/"/>
<id>https://chanmoyun.gitee.io/2024/03/14/Data-structure/21.%E4%BA%8C%E5%8F%89%E6%A0%91%E5%9F%BA%E7%A1%80%EF%BC%88%E4%B8%8A%EF%BC%89%EF%BC%9A%E4%BB%80%E4%B9%88%E6%A0%B7%E7%9A%84%E4%BA%8C%E5%8F%89%E6%A0%91%E9%80%82%E5%90%88%E7%94%A8%E6%95%B0%E7%BB%84%E6%9D%A5%E5%AD%98%E5%82%A8%EF%BC%9F/</id>
<published>2024-03-13T16:00:00.000Z</published>
<updated>2024-03-07T02:55:38.314Z</updated>
<content type="html"><![CDATA[<link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="\assets\js\Meting.min.js"></script><h1 id="二叉树基础(上):什么样的二叉树适合用数组来存储?"><a href="#二叉树基础(上):什么样的二叉树适合用数组来存储?" class="headerlink" title="二叉树基础(上):什么样的二叉树适合用数组来存储?"></a>二叉树基础(上):什么样的二叉树适合用数组来存储?</h1><p>前面我们讲的都是线性表结构,栈、队列等等。今天我们讲一种非线性表结构,树。树这种数据结构比线性表的数据结构要复杂得多,内容也比较多,所以我会分四节来讲解。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/23%201.jpg" alt=""></p><p>我反复强调过,带着问题学习,是最有效的学习方式之一,所以在正式的内容开始之前,我还是给你出一道思考题:<strong>二叉树有哪几种存储方式?什么样的二叉树适合用数组来存储?</strong></p><p>带着这些问题,我们就来学习今天的内容,树!</p><h2 id="树(Tree)"><a href="#树(Tree)" class="headerlink" title="树(Tree)"></a>树(Tree)</h2><p>我们首先来看,什么是“树”?再完备的定义,都没有图直观。所以我在图中画了几棵“树”。你来看看,这些“树”都有什么特征?</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/23%202.jpg" alt=""></p><p>你有没有发现,“树”这种数据结构真的很像我们现实生活中的“树”,这里面每个元素我们叫作“节点”;用来连线相邻节点之间的关系,我们叫作“父子关系”。</p><p>比如下面这幅图,A 节点就是 B 节点的<strong>父节点</strong>,B 节点是 A 节点的<strong>子节点</strong>。B、C、D 这三个节点的父节点是同一个节点,所以它们之间互称为<strong>兄弟节点</strong>。我们把没有父节点的节点叫作<strong>根节点</strong>,也就是图中的节点 E。我们把没有子节点的节点叫作<strong>叶子节点</strong>或者<strong>叶节点</strong>,比如图中的 G、H、I、J、K、L 都是叶子节点。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/23%203.jpg" alt=""></p><p>除此之外,关于“树”,还有三个比较相似的概念:<strong>高度</strong>(Height)、<strong>深度</strong>(Depth)、<strong>层</strong>(Level)。它们的定义是这样的:</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/23%204.jpg" alt=""></p><p>这三个概念的定义比较容易混淆,描述起来也比较空洞。我举个例子说明一下,你一看应该就能明白。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/23%205.jpg" alt=""></p><p>记这几个概念,我还有一个小窍门,就是类比“高度”“深度”“层”这几个名词在生活中的含义。</p><p>在我们的生活中,“高度”这个概念,其实就是从下往上度量,比如我们要度量第 10 层楼的高度、第 13 层楼的高度,起点都是地面。所以,树这种数据结构的高度也是一样,从最底层开始计数,并且计数的起点是 0。</p><p>“深度”这个概念在生活中是从上往下度量的,比如水中鱼的深度,是从水平面开始度量的。所以,树这种数据结构的深度也是类似的,从根结点开始度量,并且计数起点也是 0。</p><p>“层数”跟深度的计算类似,不过,计数起点是 1,也就是说根节点的位于第 1 层。</p><h2 id="二叉树(Binary-Tree)"><a href="#二叉树(Binary-Tree)" class="headerlink" title="二叉树(Binary Tree)"></a>二叉树(Binary Tree)</h2><p>树结构多种多样,不过我们最常用还是二叉树。</p><p>二叉树,顾名思义,每个节点最多有两个“叉”,也就是两个子节点,分别是<strong>左子节点</strong>和<strong>右子节点</strong>。不过,二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。我画的这几个都是二叉树。以此类推,你可以想象一下四叉树、八叉树长什么样子。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/23%206.jpg" alt=""></p><p>这个图里面,有两个比较特殊的二叉树,分别是编号 2 和编号 3 这两个。</p><p>其中,编号 2 的二叉树中,叶子节点全都在最底层,除了叶子节点之外,每个节点都有左右两个子节点,这种二叉树就叫作<strong>满二叉树</strong>。</p><p>编号 3 的二叉树中,叶子节点都在最底下两层,最后一层的叶子节点都靠左排列,并且除了最后一层,其他层的节点个数都要达到最大,这种二叉树叫作<strong>完全二叉树</strong>。</p><p>满二叉树很好理解,也很好识别,但是完全二叉树,有的人可能就分不清了。我画了几个完全二叉树和非完全二叉树的例子,你可以对比着看看。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/23%207.jpg" alt=""></p><p>你可能会说,满二叉树的特征非常明显,我们把它单独拎出来讲,这个可以理解。但是完全二叉树的特征不怎么明显啊,单从长相上来看,完全二叉树并没有特别特殊的地方啊,更像是“芸芸众树”中的一种。</p><p>那我们为什么还要特意把它拎出来讲呢?为什么偏偏把最后一层的叶子节点靠左排列的叫完全二叉树?如果靠右排列就不能叫完全二叉树了吗?这个定义的由来或者说目的在哪里?</p><p>要理解完全二叉树定义的由来,我们需要先了解,<strong>如何表示(或者存储)一棵二叉树?</strong></p><p>想要存储一棵二叉树,我们有两种方法,一种是基于指针或者引用的二叉链式存储法,一种是基于数组的顺序存储法。</p><p>我们先来看比较简单、直观的<strong>链式存储法</strong>。从图中你应该可以很清楚地看到,每个节点有三个字段,其中一个存储数据,另外两个是指向左右子节点的指针。我们只要拎住根节点,就可以通过左右子节点的指针,把整棵树都串起来。这种存储方式我们比较常用。大部分二叉树代码都是通过这种结构来实现的。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/23%208.jpg" alt=""></p><p>我们再来看,基于数组的<strong>顺序存储法</strong>。我们把根节点存储在下标 i = 1 的位置,那左子节点存储在下标 2 <em> i = 2 的位置,右子节点存储在 2 </em> i + 1 = 3 的位置。以此类推,B 节点的左子节点存储在 2 <em> i = 2 </em> 2 = 4 的位置,右子节点存储在 2 <em> i + 1 = 2 </em> 2 + 1 = 5 的位置。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/23%209.jpg" alt=""></p><p>我来总结一下,如果节点 X 存储在数组中下标为 i 的位置,下标为 2 <em> i 的位置存储的就是左子节点,下标为 2 </em> i + 1 的位置存储的就是右子节点。反过来,下标为 i/2 的位置存储就是它的父节点。通过这种方式,我们只要知道根节点存储的位置(一般情况下,为了方便计算子节点,根节点会存储在下标为 1 的位置),这样就可以通过下标计算,把整棵树都串起来。</p><p>不过,我刚刚举的例子是一棵完全二叉树,所以仅仅“浪费”了一个下标为 0 的存储位置。如果是非完全二叉树,其实会浪费比较多的数组存储空间。你可以看我举的下面这个例子。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/23%2010.jpg" alt=""></p><p>所以,如果某棵二叉树是一棵完全二叉树,那用数组存储无疑是最节省内存的一种方式。因为数组的存储方式并不需要像链式存储法那样,要存储额外的左右子节点的指针。这也是为什么完全二叉树会单独拎出来的原因,也是为什么完全二叉树要求最后一层的子节点都靠左的原因。</p><p>当我们讲到堆和堆排序的时候,你会发现,堆其实就是一种完全二叉树,最常用的存储方式就是数组。</p><h2 id="二叉树的遍历"><a href="#二叉树的遍历" class="headerlink" title="二叉树的遍历"></a>二叉树的遍历</h2><p>前面我讲了二叉树的基本定义和存储方法,现在我们来看二叉树中非常重要的操作,二叉树的遍历。这也是非常常见的面试题。</p><p>如何将所有节点都遍历打印出来呢?经典的方法有三种,<strong>前序遍历、中序遍历和后序遍历</strong>。其中,前、中、后序,表示的是节点与它的左右子树节点遍历打印的先后顺序。</p><ul><li><p>前序遍历是指,对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。</p></li><li><p>中序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它本身,最后打印它的右子树。</p></li><li><p>后序遍历是指,对于树中的任意节点来说,先打印它的左子树,然后再打印它的右子树,最后打印这个节点本身。</p></li></ul><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/23%2011.jpg" alt=""></p><p><strong>实际上,二叉树的前、中、后序遍历就是一个递归的过程。</strong>比如,前序遍历,其实就是先打印根节点,然后再递归地打印左子树,最后递归地打印右子树。</p><p>写递归代码的关键,就是看能不能写出递推公式,而写递推公式的关键就是,如果要解决问题 A,就假设子问题 B、C 已经解决,然后再来看如何利用 B、C 来解决 A。所以,我们可以把前、中、后序遍历的递推公式都写出来</p><p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">前序遍历的递推公式:</span><br><span class="line">preOrder(r) = <span class="built_in">print</span> r->preOrder(r->left)->preOrder(r->right)</span><br><span class="line"></span><br><span class="line">中序遍历的递推公式:</span><br><span class="line">inOrder(r) = inOrder(r->left)-><span class="built_in">print</span> r->inOrder(r->right)</span><br><span class="line"></span><br><span class="line">后序遍历的递推公式:</span><br><span class="line">postOrder(r) = postOrder(r->left)->postOrder(r->right)-><span class="built_in">print</span> r</span><br></pre></td></tr></table></figure></p><p>有了递推公式,代码写起来就简单多了。这三种遍历方式的代码,我都写出来了,你可以看看。</p><p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">void preOrder(Node* root) {</span><br><span class="line"> <span class="keyword">if</span> (root == null) <span class="keyword">return</span>;</span><br><span class="line"> <span class="built_in">print</span> root // 此处为伪代码,表示打印root节点</span><br><span class="line"> preOrder(root->left);</span><br><span class="line"> preOrder(root->right);</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">void inOrder(Node* root) {</span><br><span class="line"> <span class="keyword">if</span> (root == null) <span class="keyword">return</span>;</span><br><span class="line"> inOrder(root->left);</span><br><span class="line"> <span class="built_in">print</span> root // 此处为伪代码,表示打印root节点</span><br><span class="line"> inOrder(root->right);</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">void postOrder(Node* root) {</span><br><span class="line"> <span class="keyword">if</span> (root == null) <span class="keyword">return</span>;</span><br><span class="line"> postOrder(root->left);</span><br><span class="line"> postOrder(root->right);</span><br><span class="line"> <span class="built_in">print</span> root // 此处为伪代码,表示打印root节点</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>二叉树的前、中、后序遍历的递归实现是不是很简单?你知道二叉树遍历的时间复杂度是多少吗?我们一起来看看。</p><p>从我前面画的前、中、后序遍历的顺序图,可以看出来,每个节点最多会被访问两次,所以遍历操作的时间复杂度,跟节点的个数 n 成正比,也就是说二叉树遍历的时间复杂度是 O(n)。</p><h2 id="解答开篇-amp-内容小结"><a href="#解答开篇-amp-内容小结" class="headerlink" title="解答开篇 & 内容小结"></a>解答开篇 & 内容小结</h2><p>今天,我讲了一种非线性表数据结构,树。关于树,有几个比较常用的概念你需要掌握,那就是:根节点、叶子节点、父节点、子节点、兄弟节点,还有节点的高度、深度、层数,以及树的高度。</p><p>我们平时最常用的树就是二叉树。二叉树的每个节点最多有两个子节点,分别是左子节点和右子节点。二叉树中,有两种比较特殊的树,分别是满二叉树和完全二叉树。满二叉树又是完全二叉树的一种特殊情况。</p><p>二叉树既可以用链式存储,也可以用数组顺序存储。数组顺序存储的方式比较适合完全二叉树,其他类型的二叉树用数组存储会比较浪费存储空间。除此之外,二叉树里非常重要的操作就是前、中、后序遍历操作,遍历的时间复杂度是 O(n),你需要理解并能用递归代码来实现。</p><h2 id="课后思考"><a href="#课后思考" class="headerlink" title="课后思考"></a>课后思考</h2><p>1.给定一组数据,比如 1,3,5,6,9,10。你来算算,可以构建出多少种不同的二叉树?</p><p>2.我们讲了三种二叉树的遍历方式,前、中、后序。实际上,还有另外一种遍历方式,也就是按层遍历,你知道如何实现吗?</p>]]></content>
<summary type="html"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" cla</summary>
<category term="算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
</entry>
<entry>
<title>20|哈希算法(下):哈希算法在分布式系统中有哪些应用?</title>
<link href="https://chanmoyun.gitee.io/2024/03/13/Data-structure/20.%E5%93%88%E5%B8%8C%E7%AE%97%E6%B3%95%EF%BC%88%E4%B8%8B%EF%BC%89%EF%BC%9A%E5%93%88%E5%B8%8C%E7%AE%97%E6%B3%95%E5%9C%A8%E5%88%86%E5%B8%83%E5%BC%8F%E7%B3%BB%E7%BB%9F%E4%B8%AD%E6%9C%89%E5%93%AA%E4%BA%9B%E5%BA%94%E7%94%A8%EF%BC%9F/"/>
<id>https://chanmoyun.gitee.io/2024/03/13/Data-structure/20.%E5%93%88%E5%B8%8C%E7%AE%97%E6%B3%95%EF%BC%88%E4%B8%8B%EF%BC%89%EF%BC%9A%E5%93%88%E5%B8%8C%E7%AE%97%E6%B3%95%E5%9C%A8%E5%88%86%E5%B8%83%E5%BC%8F%E7%B3%BB%E7%BB%9F%E4%B8%AD%E6%9C%89%E5%93%AA%E4%BA%9B%E5%BA%94%E7%94%A8%EF%BC%9F/</id>
<published>2024-03-12T16:00:00.000Z</published>
<updated>2024-03-07T02:50:43.723Z</updated>
<content type="html"><![CDATA[<link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="\assets\js\Meting.min.js"></script><h1 id="哈希算法(下):哈希算法在分布式系统中有哪些应用?"><a href="#哈希算法(下):哈希算法在分布式系统中有哪些应用?" class="headerlink" title="哈希算法(下):哈希算法在分布式系统中有哪些应用?"></a>哈希算法(下):哈希算法在分布式系统中有哪些应用?</h1><p>上一节,我讲了哈希算法的四个应用,它们分别是:安全加密、数据校验、唯一标识、散列函数。今天,我们再来看剩余三种应用:<strong>负载均衡、数据分片、分布式存储。</strong></p><p>你可能已经发现,这三个应用都跟分布式系统有关。没错,今天我就带你看下,<strong>哈希算法是如何解决这些分布式问题的。</strong></p><h2 id="应用五:负载均衡"><a href="#应用五:负载均衡" class="headerlink" title="应用五:负载均衡"></a>应用五:负载均衡</h2><p>我们知道,负载均衡算法有很多,比如轮询、随机、加权轮询等。那如何才能实现一个会话粘滞(session sticky)的负载均衡算法呢?也就是说,我们需要在同一个客户端上,在一次会话中的所有请求都路由到同一个服务器上。</p><p>最直接的方法就是,维护一张映射关系表,这张表的内容是客户端 IP 地址或者会话 ID 与服务器编号的映射关系。客户端发出的每次请求,都要先在映射表中查找应该路由到的服务器编号,然后再请求编号对应的服务器。这种方法简单直观,但也有几个弊端:</p><ul><li><p>如果客户端很多,映射表可能会很大,比较浪费内存空间;</p></li><li><p>客户端下线、上线,服务器扩容、缩容都会导致映射失效,这样维护映射表的成本就会很大;</p></li></ul><p>如果借助哈希算法,这些问题都可以非常完美地解决。<strong>我们可以通过哈希算法,对客户端 IP 地址或者会话 ID 计算哈希值,将取得的哈希值与服务器列表的大小进行取模运算,最终得到的值就是应该被路由到的服务器编号。</strong> 这样,我们就可以把同一个 IP 过来的所有请求,都路由到同一个后端服务器上。</p><h2 id="应用六:数据分片"><a href="#应用六:数据分片" class="headerlink" title="应用六:数据分片"></a>应用六:数据分片</h2><p>哈希算法还可以用于数据的分片。我这里有两个例子。</p><h3 id="1-如何统计“搜索关键词”出现的次数?"><a href="#1-如何统计“搜索关键词”出现的次数?" class="headerlink" title="1.如何统计“搜索关键词”出现的次数?"></a>1.如何统计“搜索关键词”出现的次数?</h3><p>假如我们有 1T 的日志文件,这里面记录了用户的搜索关键词,我们想要快速统计出每个关键词被搜索的次数,该怎么做呢?</p><p>我们来分析一下。这个问题有两个难点,第一个是搜索日志很大,没办法放到一台机器的内存中。第二个难点是,如果只用一台机器来处理这么巨大的数据,处理时间会很长。</p><p>针对这两个难点,<strong>我们可以先对数据进行分片,然后采用多台机器处理的方法,来提高处理速度</strong>。具体的思路是这样的:为了提高处理的速度,我们用 n 台机器并行处理。我们从搜索记录的日志文件中,依次读出每个搜索关键词,并且通过哈希函数计算哈希值,然后再跟 n 取模,最终得到的值,就是应该被分配到的机器编号。</p><p>这样,哈希值相同的搜索关键词就被分配到了同一个机器上。也就是说,同一个搜索关键词会被分配到同一个机器上。每个机器会分别计算关键词出现的次数,最后合并起来就是最终的结果。</p><p>实际上,这里的处理过程也是 MapReduce 的基本设计思想。</p><h3 id="2-如何快速判断图片是否在图库中?"><a href="#2-如何快速判断图片是否在图库中?" class="headerlink" title="2. 如何快速判断图片是否在图库中?"></a>2. 如何快速判断图片是否在图库中?</h3><p>如何快速判断图片是否在图库中?上一节我们讲过这个例子,不知道你还记得吗?当时我介绍了一种方法,即给每个图片取唯一标识(或者信息摘要),然后构建散列表。</p><p>假设现在我们的图库中有 1 亿张图片,很显然,在单台机器上构建散列表是行不通的。因为单台机器的内存有限,而 1 亿张图片构建散列表显然远远超过了单台机器的内存上限。</p><p>我们同样可以对数据进行分片,然后采用多机处理。我们准备 n 台机器,让每台机器只维护某一部分图片对应的散列表。我们每次从图库中读取一个图片,计算唯一标识,然后与机器个数 n 求余取模,得到的值就对应要分配的机器编号,然后将这个图片的唯一标识和图片路径发往对应的机器构建散列表。</p><p>当我们要判断一个图片是否在图库中的时候,我们通过同样的哈希算法,计算这个图片的唯一标识,然后与机器个数 n 求余取模。假设得到的值是 k,那就去编号 k 的机器构建的散列表中查找。</p><p>现在,我们来估算一下,给这 1 亿张图片构建散列表大约需要多少台机器。</p><p>散列表中每个数据单元包含两个信息,哈希值和图片文件的路径。假设我们通过 MD5 来计算哈希值,那长度就是 128 比特,也就是 16 字节。文件路径长度的上限是 256 字节,我们可以假设平均长度是 128 字节。如果我们用链表法来解决冲突,那还需要存储指针,指针只占用 8 字节。所以,散列表中每个数据单元就占用 152 字节(这里只是估算,并不准确)。</p><p>假设一台机器的内存大小为 2GB,散列表的装载因子为 0.75,那一台机器可以给大约 1000 万(2GB*0.75/152)张图片构建散列表。所以,如果要对 1 亿张图片构建索引,需要大约十几台机器。在工程中,这种估算还是很重要的,能让我们事先对需要投入的资源、资金有个大概的了解,能更好地评估解决方案的可行性。</p><p>实际上,针对这种海量数据的处理问题,我们都可以采用多机分布式处理。借助这种分片的思路,可以突破单机内存、CPU 等资源的限制。</p><h2 id="应用七:分布式存储"><a href="#应用七:分布式存储" class="headerlink" title="应用七:分布式存储"></a>应用七:分布式存储</h2><p>现在互联网面对的都是海量的数据、海量的用户。我们为了提高数据的读取、写入能力,一般都采用分布式的方式来存储数据,比如分布式缓存。我们有海量的数据需要缓存,所以一个缓存机器肯定是不够的。于是,我们就需要将数据分布在多台机器上。</p><p>该如何决定将哪个数据放到哪个机器上呢?我们可以借用前面数据分片的思想,即通过哈希算法对数据取哈希值,然后对机器个数取模,这个最终值就是应该存储的缓存机器编号。</p><p>但是,如果数据增多,原来的 10 个机器已经无法承受了,我们就需要扩容了,比如扩到 11 个机器,这时候麻烦就来了。因为,这里并不是简单地加个机器就可以了。</p><p>原来的数据是通过与 10 来取模的。比如 13 这个数据,存储在编号为 3 这台机器上。但是新加了一台机器中,我们对数据按照 11 取模,原来 13 这个数据就被分配到 2 号这台机器上了。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/22%201.jpg" alt=""></p><p>因此,所有的数据都要重新计算哈希值,然后重新搬移到正确的机器上。这样就相当于,缓存中的数据一下子就都失效了。所有的数据请求都会穿透缓存,直接去请求数据库。这样就可能发生<a href="https://zh.wikipedia.org/wiki/雪崩效应">雪崩效应</a>。</p><p>所以,我们需要一种方法,使得在新加入一个机器后,并不需要做大量的数据搬移。这时候,<strong>一致性哈希算法</strong>就要登场了。</p><p>假设我们有 k 个机器,数据的哈希值的范围是[0, MAX]。我们将整个范围划分成 m 个小区间(m 远大于 k),每个机器负责 m/k 个小区间。当有新机器加入的时候,我们就将某几个小区间的数据,从原来的机器中搬移到新的机器中。这样,既不用全部重新哈希、搬移数据,也保持了各个机器上数据数量的均衡。</p><p>一致性哈希算法的基本思想就是这么简单。除此之外,它还会借助一个虚拟的环和虚拟结点,更加优美地实现出来。这里我就不展开讲了,如果感兴趣,你可以看下这个<a href="https://en.wikipedia.org/wiki/Consistent_hashing">介绍</a>。</p><p>除了我们上面讲到的分布式缓存,实际上,一致性哈希算法的应用非常广泛,在很多分布式存储系统中,都可以见到一致性哈希算法的影子。</p><h2 id="解答开篇-amp-内容小结"><a href="#解答开篇-amp-内容小结" class="headerlink" title="解答开篇 & 内容小结"></a>解答开篇 & 内容小结</h2><p>这两节的内容理论不多,比较贴近具体的开发。今天我讲了三种哈希算法在分布式系统中的应用,它们分别是:负载均衡、数据分片、分布式存储。</p><p>在负载均衡应用中,利用哈希算法替代映射表,可以实现一个会话粘滞的负载均衡策略。在数据分片应用中,通过哈希算法对处理的海量数据进行分片,多机分布式处理,可以突破单机资源的限制。在分布式存储应用中,利用一致性哈希算法,可以解决缓存等分布式系统的扩容、缩容导致数据大量搬移的难题。</p><h2 id="课后思考"><a href="#课后思考" class="headerlink" title="课后思考"></a>课后思考</h2><p>这两节我总共讲了七个哈希算法的应用。实际上,我讲的也只是冰山一角,哈希算法还有很多其他的应用,比如网络协议中的 CRC 校验、Git commit id 等等。除了这些,你还能想到其他用到哈希算法的地方吗?</p>]]></content>
<summary type="html"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" cla</summary>
<category term="算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
</entry>
<entry>
<title>19|哈希算法(上):如何防止数据库中的用户信息被脱库?</title>
<link href="https://chanmoyun.gitee.io/2024/03/12/Data-structure/19.%E5%93%88%E5%B8%8C%E7%AE%97%E6%B3%95%EF%BC%88%E4%B8%8A%EF%BC%89%EF%BC%9A%E5%A6%82%E4%BD%95%E9%98%B2%E6%AD%A2%E6%95%B0%E6%8D%AE%E5%BA%93%E4%B8%AD%E7%9A%84%E7%94%A8%E6%88%B7%E4%BF%A1%E6%81%AF%E8%A2%AB%E8%84%B1%E5%BA%93%EF%BC%9F/"/>
<id>https://chanmoyun.gitee.io/2024/03/12/Data-structure/19.%E5%93%88%E5%B8%8C%E7%AE%97%E6%B3%95%EF%BC%88%E4%B8%8A%EF%BC%89%EF%BC%9A%E5%A6%82%E4%BD%95%E9%98%B2%E6%AD%A2%E6%95%B0%E6%8D%AE%E5%BA%93%E4%B8%AD%E7%9A%84%E7%94%A8%E6%88%B7%E4%BF%A1%E6%81%AF%E8%A2%AB%E8%84%B1%E5%BA%93%EF%BC%9F/</id>
<published>2024-03-11T16:00:00.000Z</published>
<updated>2024-03-07T02:50:56.854Z</updated>
<content type="html"><![CDATA[<link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="\assets\js\Meting.min.js"></script><h1 id="哈希算法(上):如何防止数据库中的用户信息被脱库?"><a href="#哈希算法(上):如何防止数据库中的用户信息被脱库?" class="headerlink" title="哈希算法(上):如何防止数据库中的用户信息被脱库?"></a>哈希算法(上):如何防止数据库中的用户信息被脱库?</h1><p>还记得 2011 年 CSDN 的“脱库”事件吗?当时,CSDN 网站被黑客攻击,超过 600 万用户的注册邮箱和密码明文被泄露,很多网友对 CSDN 明文保存用户密码行为产生了不满。如果你是 CSDN 的一名工程师,<strong>你会如何存储用户密码这么重要的数据吗?仅仅 MD5 加密一下存储就够了吗?</strong> 要想搞清楚这个问题,就要先弄明白哈希算法。</p><p>哈希算法历史悠久,业界著名的哈希算法也有很多,比如 MD5、SHA 等。在我们平时的开发中,基本上都是拿现成的直接用。所以,我今天不会重点剖析哈希算法的原理,也不会教你如何设计一个哈希算法,而是从实战的角度告诉你,<strong>在实际的开发中,我们该如何用哈希算法解决问题。</strong></p><h2 id="什么是哈希算法?"><a href="#什么是哈希算法?" class="headerlink" title="什么是哈希算法?"></a>什么是哈希算法?</h2><p>我们前面几节讲到“散列表”“散列函数”,这里又讲到“哈希算法”,你是不是有点一头雾水?实际上,不管是“散列”还是“哈希”,这都是中文翻译的差别,英文其实就是“<strong>Hash</strong>”。所以,我们常听到有人把“散列表”叫作“哈希表”“Hash 表”,把“哈希算法”叫作“Hash 算法”或者“散列算法”。那到底什么是哈希算法呢?</p><p>哈希算法的定义和原理非常简单,基本上一句话就可以概括了。将任意长度的二进制值串映射为固定长度的二进制值串,这个映射的规则就是<strong>哈希算法</strong>,而通过原始数据映射之后得到的二进制值串就是<strong>哈希值</strong>。但是,要想设计一个优秀的哈希算法并不容易,根据我的经验,我总结了需要满足的几点要求:</p><ul><li><p>从哈希值不能反向推导出原始数据(所以哈希算法也叫单向哈希算法);</p></li><li><p>对输入数据非常敏感,哪怕原始数据只修改了一个 Bit,最后得到的哈希值也大不相同;</p></li><li><p>散列冲突的概率要很小,对于不同的原始数据,哈希值相同的概率非常小;</p></li><li><p>哈希算法的执行效率要尽量高效,针对较长的文本,也能快速地计算出哈希值。</p></li></ul><p>这些定义和要求都比较理论,可能还是不好理解,我拿 MD5 这种哈希算法来具体说明一下。</p><p>我们分别对“今天我来讲哈希算法”和“jiajia”这两个文本,计算 MD5 哈希值,得到两串看起来毫无规律的字符串(MD5 的哈希值是 128 位的 Bit 长度,为了方便表示,我把它们转化成了 16 进制编码)。可以看出来,无论要哈希的文本有多长、多短,通过 MD5 哈希之后,得到的哈希值的长度都是相同的,而且得到的哈希值看起来像一堆随机数,完全没有规律。</p><p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">MD5(<span class="string">"今天我来讲哈希算法"</span>) = bb4767201ad42c74e650c1b6c03d78fa</span><br><span class="line">MD5(<span class="string">"jiajia"</span>) = cd611a31ea969b908932d44d126d195b</span><br></pre></td></tr></table></figure></p><p>我们再来看两个非常相似的文本,“我今天讲哈希算法!”和“我今天讲哈希算法”。这两个文本只有一个感叹号的区别。如果用 MD5 哈希算法分别计算它们的哈希值,你会发现,尽管只有一字之差,得到的哈希值也是完全不同的。</p><p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">MD5(<span class="string">"我今天讲哈希算法!"</span>) = 425f0d5a917188d2c3c3dc85b5e4f2cb</span><br><span class="line">MD5(<span class="string">"我今天讲哈希算法"</span>) = a1fb91ac128e6aa37fe42c663971ac3d</span><br></pre></td></tr></table></figure></p><p>我在前面也说了,通过哈希算法得到的哈希值,很难反向推导出原始数据。比如上面的例子中,我们就很难通过哈希值“a1fb91ac128e6aa37fe42c663971ac3d”反推出对应的文本“我今天讲哈希算法”。</p><p>哈希算法要处理的文本可能是各种各样的。比如,对于非常长的文本,如果哈希算法的计算时间很长,那就只能停留在理论研究的层面,很难应用到实际的软件开发中。比如,我们把今天这篇包含 4000 多个汉字的文章,用 MD5 计算哈希值,用不了 1ms 的时间。</p><p>哈希算法的应用非常非常多,我选了最常见的七个,分别是安全加密、唯一标识、数据校验、散列函数、负载均衡、数据分片、分布式存储。这节我们先来看前四个应用。</p><h2 id="应用一:安全加密"><a href="#应用一:安全加密" class="headerlink" title="应用一:安全加密"></a>应用一:安全加密</h2><p>说到哈希算法的应用,最先想到的应该就是安全加密。最常用于加密的哈希算法是 <strong>MD5</strong>(MD5 Message-Digest Algorithm,MD5 消息摘要算法)和 <strong>SHA</strong>(Secure Hash Algorithm,安全散列算法)。</p><p>除了这两个之外,当然还有很多其他加密算法,比如 DES(Data Encryption Standard,数据加密标准)、AES(Advanced Encryption Standard,高级加密标准)。</p><p>前面我讲到的哈希算法四点要求,对用于加密的哈希算法来说,有两点格外重要。第一点是很难根据哈希值反向推导出原始数据,第二点是散列冲突的概率要很小。</p><p>第一点很好理解,加密的目的就是防止原始数据泄露,所以很难通过哈希值反向推导原始数据,这是一个最基本的要求。所以我着重讲一下第二点。实际上,不管是什么哈希算法,我们只能尽量减少碰撞冲突的概率,理论上是没办法做到完全不冲突的。为什么这么说呢?</p><p>这里就基于组合数学中一个非常基础的理论,鸽巢原理(也叫抽屉原理)。这个原理本身很简单,它是说,如果有 10 个鸽巢,有 11 只鸽子,那肯定有 1 个鸽巢中的鸽子数量多于 1 个,换句话说就是,肯定有 2 只鸽子在 1 个鸽巢内。</p><p>有了鸽巢原理的铺垫之后,我们再来看,<strong>为什么哈希算法无法做到零冲突?</strong></p><p>我们知道,哈希算法产生的哈希值的长度是固定且有限的。比如前面举的 MD5 的例子,哈希值是固定的 128 位二进制串,能表示的数据是有限的,最多能表示 2^128 个数据,而我们要哈希的数据是无穷的。基于鸽巢原理,如果我们对 2^128+1 个数据求哈希值,就必然会存在哈希值相同的情况。这里你应该能想到,一般情况下,哈希值越长的哈希算法,散列冲突的概率越低。</p><p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line"><span class="number">2</span>^<span class="number">128</span>=<span class="number">340282366920938463463374607431768211456</span></span><br></pre></td></tr></table></figure></p><p>为了让你能有个更加直观的感受,我找了两段字符串放在这里。这两段字符串经过 MD5 哈希算法加密之后,产生的哈希值是相同的。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/21%201.jpg" alt=""></p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/21%202.jpg" alt=""></p><p>不过,即便哈希算法存在散列冲突的情况,但是因为哈希值的范围很大,冲突的概率极低,所以相对来说还是很难破解的。像 MD5,有 2^128 个不同的哈希值,这个数据已经是一个天文数字了,所以散列冲突的概率要小于 1/2^128。</p><p>如果我们拿到一个 MD5 哈希值,希望通过毫无规律的穷举的方法,找到跟这个 MD5 值相同的另一个数据,那耗费的时间应该是个天文数字。所以,即便哈希算法存在冲突,但是在有限的时间和资源下,哈希算法还是被很难破解的。</p><p>除此之外,没有绝对安全的加密。越复杂、越难破解的加密算法,需要的计算时间也越长。比如 SHA-256 比 SHA-1 要更复杂、更安全,相应的计算时间就会比较长。密码学界也一直致力于找到一种快速并且很难被破解的哈希算法。我们在实际的开发过程中,也需要权衡破解难度和计算时间,来决定究竟使用哪种加密算法。</p><h2 id="应用二:唯一标识"><a href="#应用二:唯一标识" class="headerlink" title="应用二:唯一标识"></a>应用二:唯一标识</h2><p>我先来举一个例子。如果要在海量的图库中,搜索一张图是否存在,我们不能单纯地用图片的元信息(比如图片名称)来比对,因为有可能存在名称相同但图片内容不同,或者名称不同图片内容相同的情况。那我们该如何搜索呢?</p><p>我们知道,任何文件在计算中都可以表示成二进制码串,所以,比较笨的办法就是,拿要查找的图片的二进制码串与图库中所有图片的二进制码串一一比对。如果相同,则说明图片在图库中存在。但是,每个图片小则几十 KB、大则几 MB,转化成二进制是一个非常长的串,比对起来非常耗时。有没有比较快的方法呢?</p><p>我们可以给每一个图片取一个唯一标识,或者说信息摘要。比如,我们可以从图片的二进制码串开头取 100 个字节,从中间取 100 个字节,从最后再取 100 个字节,然后将这 300 个字节放到一块,通过哈希算法(比如 MD5),得到一个哈希字符串,用它作为图片的唯一标识。通过这个唯一标识来判定图片是否在图库中,这样就可以减少很多工作量。</p><p>如果还想继续提高效率,我们可以把每个图片的唯一标识,和相应的图片文件在图库中的路径信息,都存储在散列表中。当要查看某个图片是不是在图库中的时候,我们先通过哈希算法对这个图片取唯一标识,然后在散列表中查找是否存在这个唯一标识。</p><p>如果不存在,那就说明这个图片不在图库中;如果存在,我们再通过散列表中存储的文件路径,获取到这个已经存在的图片,跟现在要插入的图片做全量的比对,看是否完全一样。如果一样,就说明已经存在;如果不一样,说明两张图片尽管唯一标识相同,但是并不是相同的图片。</p><h2 id="应用三:数据校验"><a href="#应用三:数据校验" class="headerlink" title="应用三:数据校验"></a>应用三:数据校验</h2><p>电驴这样的 BT 下载软件你肯定用过吧?我们知道,BT 下载的原理是基于 P2P 协议的。我们从多个机器上并行下载一个 2GB 的电影,这个电影文件可能会被分割成很多文件块(比如可以分成 100 块,每块大约 20MB)。等所有的文件块都下载完成之后,再组装成一个完整的电影文件就行了。</p><p>我们知道,网络传输是不安全的,下载的文件块有可能是被宿主机器恶意修改过的,又或者下载过程中出现了错误,所以下载的文件块可能不是完整的。如果我们没有能力检测这种恶意修改或者文件下载出错,就会导致最终合并后的电影无法观看,甚至导致电脑中毒。现在的问题是,如何来校验文件块的安全、正确、完整呢?</p><p>具体的 BT 协议很复杂,校验方法也有很多,我来说其中的一种思路。</p><p>我们通过哈希算法,对 100 个文件块分别取哈希值,并且保存在种子文件中。我们在前面讲过,哈希算法有一个特点,对数据很敏感。只要文件块的内容有一丁点儿的改变,最后计算出的哈希值就会完全不同。所以,当文件块下载完成之后,我们可以通过相同的哈希算法,对下载好的文件块逐一求哈希值,然后跟种子文件中保存的哈希值比对。如果不同,说明这个文件块不完整或者被篡改了,需要再重新从其他宿主机器上下载这个文件块。</p><h2 id="应用四:散列函数"><a href="#应用四:散列函数" class="headerlink" title="应用四:散列函数"></a>应用四:散列函数</h2><p>前面讲了很多哈希算法的应用,实际上,散列函数也是哈希算法的一种应用。</p><p>我们前两节讲到,散列函数是设计一个散列表的关键。它直接决定了散列冲突的概率和散列表的性能。不过,相对哈希算法的其他应用,散列函数对于散列算法冲突的要求要低很多。即便出现个别散列冲突,只要不是过于严重,我们都可以通过开放寻址法或者链表法解决。</p><p>不仅如此,散列函数对于散列算法计算得到的值,是否能反向解密也并不关心。散列函数中用到的散列算法,更加关注散列后的值是否能平均分布,也就是,一组数据是否能均匀地散列在各个槽中。除此之外,散列函数执行的快慢,也会影响散列表的性能,所以,散列函数用的散列算法一般都比较简单,比较追求效率。</p><h2 id="解答开篇"><a href="#解答开篇" class="headerlink" title="解答开篇"></a>解答开篇</h2><p>好了,有了前面的基础,现在你有没有发现开篇的问题其实很好解决?</p><p>我们可以通过哈希算法,对用户密码进行加密之后再存储,不过最好选择相对安全的加密算法,比如 SHA 等(因为 MD5 已经号称被破解了)。不过仅仅这样加密之后存储就万事大吉了吗?</p><p>字典攻击你听说过吗?如果用户信息被“脱库”,黑客虽然拿到是加密之后的密文,但可以通过“猜”的方式来破解密码,这是因为,有些用户的密码太简单。比如很多人习惯用 00000、123456 这样的简单数字组合做密码,很容易就被猜中。那我们就需要维护一个常用密码的字典表,把字典中的每个密码用哈希算法计算哈希值,然后拿哈希值跟脱库后的密文比对。如果相同,基本上就可以认为,这个加密之后的密码对应的明文就是字典中的这个密码。(注意,这里说是的是“基本上可以认为”,因为根据我们前面的学习,哈希算法存在散列冲突,也有可能出现,尽管密文一样,但是明文并不一样的情况。)</p><p>针对字典攻击,我们可以引入一个盐(salt),跟用户的密码组合在一起,增加密码的复杂度。我们拿组合之后的字符串来做哈希算法加密,将它存储到数据库中,进一步增加破解的难度。不过我这里想多说一句,我认为安全和攻击是一种博弈关系,不存在绝对的安全。所有的安全措施,只是增加攻击的成本而已。</p><h2 id="内容小结"><a href="#内容小结" class="headerlink" title="内容小结"></a>内容小结</h2><p>今天的内容比较偏实战,我讲到了哈希算法的四个应用场景。我带你来回顾一下。</p><p>第一个应用是唯一标识,哈希算法可以对大数据做信息摘要,通过一个较短的二进制编码来表示很大的数据。</p><p>第二个应用是用于校验数据的完整性和正确性。</p><p>第三个应用是安全加密,我们讲到任何哈希算法都会出现散列冲突,但是这个冲突概率非常小。越是复杂哈希算法越难破解,但同样计算时间也就越长。所以,选择哈希算法的时候,要权衡安全性和计算时间来决定用哪种哈希算法。</p><p>第四个应用是散列函数,这个我们前面讲散列表的时候已经详细地讲过,它对哈希算法的要求非常特别,更加看重的是散列的平均性和哈希算法的执行效率。</p><h2 id="课后思考"><a href="#课后思考" class="headerlink" title="课后思考"></a>课后思考</h2><p>现在,区块链是一个很火的领域,它被很多人神秘化,不过其底层的实现原理并不复杂。其中,哈希算法就是它的一个非常重要的理论基础。你能讲一讲区块链使用的是哪种哈希算法吗?是为了解决什么问题而使用的呢?</p>]]></content>
<summary type="html"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" cla</summary>
<category term="算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
</entry>
<entry>
<title>18|散列表(下):为什么散列表和链表经常会一起使用?</title>
<link href="https://chanmoyun.gitee.io/2024/03/11/Data-structure/18.%E6%95%A3%E5%88%97%E8%A1%A8%EF%BC%88%E4%B8%8B%EF%BC%89%EF%BC%9A%E4%B8%BA%E4%BB%80%E4%B9%88%E6%95%A3%E5%88%97%E8%A1%A8%E5%92%8C%E9%93%BE%E8%A1%A8%E7%BB%8F%E5%B8%B8%E4%BC%9A%E4%B8%80%E8%B5%B7%E4%BD%BF%E7%94%A8%EF%BC%9F/"/>
<id>https://chanmoyun.gitee.io/2024/03/11/Data-structure/18.%E6%95%A3%E5%88%97%E8%A1%A8%EF%BC%88%E4%B8%8B%EF%BC%89%EF%BC%9A%E4%B8%BA%E4%BB%80%E4%B9%88%E6%95%A3%E5%88%97%E8%A1%A8%E5%92%8C%E9%93%BE%E8%A1%A8%E7%BB%8F%E5%B8%B8%E4%BC%9A%E4%B8%80%E8%B5%B7%E4%BD%BF%E7%94%A8%EF%BC%9F/</id>
<published>2024-03-10T16:00:00.000Z</published>
<updated>2024-03-05T07:14:04.785Z</updated>
<content type="html"><![CDATA[<link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="\assets\js\Meting.min.js"></script><h1 id="散列表(下):为什么散列表和链表经常会一起使用?"><a href="#散列表(下):为什么散列表和链表经常会一起使用?" class="headerlink" title="散列表(下):为什么散列表和链表经常会一起使用?"></a>散列表(下):为什么散列表和链表经常会一起使用?</h1><p>我们已经学习了 20 节内容,你有没有发现,有两种数据结构,散列表和链表,经常会被放在一起使用。你还记得,前面的章节中都有哪些地方讲到散列表和链表的组合使用吗?我带你一起回忆一下。</p><p>在链表那一节,我讲到如何用链表来实现 LRU 缓存淘汰算法,但是链表实现的 LRU 缓存淘汰算法的时间复杂度是 O(n),当时我也提到了,通过散列表可以将这个时间复杂度降低到 O(1)。</p><p>在跳表那一节,我提到 Redis 的有序集合是使用跳表来实现的,跳表可以看作一种改进版的链表。当时我们也提到,Redis 有序集合不仅使用了跳表,还用到了散列表。</p><p>除此之外,如果你熟悉 Java 编程语言,你会发现 LinkedHashMap 这样一个常用的容器,也用到了散列表和链表两种数据结构。</p><p>今天,我们就来看看,在这几个问题中,散列表和链表都是如何组合起来使用的,以及为什么散列表和链表会经常放到一块使用。</p><h2 id="LRU-缓存淘汰算法"><a href="#LRU-缓存淘汰算法" class="headerlink" title="LRU 缓存淘汰算法"></a>LRU 缓存淘汰算法</h2><p>在链表那一节中,我提到,借助散列表,我们可以把 LRU 缓存淘汰算法的时间复杂度降低为 O(1)。现在,我们就来看看它是如何做到的。</p><p>首先,我们来回顾一下当时我们是如何通过链表实现 LRU 缓存淘汰算法的。</p><p>我们需要维护一个按照访问时间从大到小有序排列的链表结构。因为缓存大小有限,当缓存空间不够,需要淘汰一个数据的时候,我们就直接将链表头部的结点删除。</p><p>当要缓存某个数据的时候,先在链表中查找这个数据。如果没有找到,则直接将数据放到链表的尾部;如果找到了,我们就把它移动到链表的尾部。因为查找数据需要遍历链表,所以单纯用链表实现的 LRU 缓存淘汰算法的时间复杂很高,是 O(n)。</p><p>实际上,我总结一下,一个缓存(cache)系统主要包含下面这几个操作:</p><ul><li><p>往缓存中添加一个数据;</p></li><li><p>从缓存中删除一个数据;</p></li></ul><ul><li>在缓存中查找一个数据。</li></ul><p>这三个操作都要涉及“查找”操作,如果单纯地采用链表的话,时间复杂度只能是 O(n)。如果我们将散列表和链表两种数据结构组合使用,可以将这三个操作的时间复杂度都降低到 O(1)。具体的结构就是下面这个样子:</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/20%201.jpg" alt=""></p><p>我们使用双向链表存储数据,链表中的每个结点处理存储数据(data)、前驱指针(prev)、后继指针(next)之外,还新增了一个特殊的字段 hnext。这个 hnext 有什么作用呢?</p><p>因为我们的散列表是通过链表法解决散列冲突的,所以每个结点会在两条链中。一个链是刚刚我们提到的<strong>双向链表</strong>,另一个链是散列表中的<strong>拉链。前驱和后继指针是为了将结点串在双向链表中,hnext 指针是为了将结点串在散列表的拉链中。</strong></p><p>了解了这个散列表和双向链表的组合存储结构之后,我们再来看,前面讲到的缓存的三个操作,是如何做到时间复杂度是 O(1) 的?</p><p>首先,我们来看<strong>如何查找一个数据</strong>。我们前面讲过,散列表中查找数据的时间复杂度接近 O(1),所以通过散列表,我们可以很快地在缓存中找到一个数据。当找到数据之后,我们还需要将它移动到双向链表的尾部。</p><p>其次,我们来看<strong>如何删除一个数据</strong>。我们需要找到数据所在的结点,然后将结点删除。借助散列表,我们可以在 O(1) 时间复杂度里找到要删除的结点。因为我们的链表是双向链表,双向链表可以通过前驱指针 O(1) 时间复杂度获取前驱结点,所以在双向链表中,删除结点只需要 O(1) 的时间复杂度。</p><p>最后,我们来看<strong>如何添加一个数据</strong>。添加数据到缓存稍微有点麻烦,我们需要先看这个数据是否已经在缓存中。如果已经在其中,需要将其移动到双向链表的尾部;如果不在其中,还要看缓存有没有满。如果满了,则将双向链表头部的结点删除,然后再将数据放到链表的尾部;如果没有满,就直接将数据放到链表的尾部。</p><p>这整个过程涉及的查找操作都可以通过散列表来完成。其他的操作,比如删除头结点、链表尾部插入数据等,都可以在 O(1) 的时间复杂度内完成。所以,这三个操作的时间复杂度都是 O(1)。至此,我们就通过散列表和双向链表的组合使用,实现了一个高效的、支持 LRU 缓存淘汰算法的缓存系统原型。</p><h2 id="Redis-有序集合"><a href="#Redis-有序集合" class="headerlink" title="Redis 有序集合"></a>Redis 有序集合</h2><p>在跳表那一节,讲到有序集合的操作时,我稍微做了些简化。实际上,在有序集合中,每个成员对象有两个重要的属性,<strong>key</strong>(键值)和 <strong>score</strong>(分值)。我们不仅会通过 score 来查找数据,还会通过 key 来查找数据。</p><p>举个例子,比如用户积分排行榜有这样一个功能:我们可以通过用户的 ID 来查找积分信息,也可以通过积分区间来查找用户 ID 或者姓名信息。这里包含 ID、姓名和积分的用户信息,就是成员对象,用户 ID 就是 key,积分就是 score。</p><p>所以,如果我们细化一下 Redis 有序集合的操作,那就是下面这样:</p><ul><li>添加一个成员对象;</li></ul><ul><li>按照键值来删除一个成员对象;</li></ul><ul><li><p>按照键值来查找一个成员对象;</p></li><li><p>按照分值区间查找数据,比如查找积分在[100, 356]之间的成员对象;</p></li><li><p>按照分值从小到大排序成员变量;</p></li></ul><p>如果我们仅仅按照分值将成员对象组织成跳表的结构,那按照键值来删除、查询成员对象就会很慢,解决方法与 LRU 缓存淘汰算法的解决方法类似。我们可以再按照键值构建一个散列表,这样按照 key 来删除、查找一个成员对象的时间复杂度就变成了 O(1)。同时,借助跳表结构,其他操作也非常高效。</p><p>实际上,Redis 有序集合的操作还有另外一类,也就是查找成员对象的排名(Rank)或者根据排名区间查找成员对象。这个功能单纯用刚刚讲的这种组合结构就无法高效实现了。这块内容我后面的章节再讲。</p><h2 id="Java-LinkedHashMap"><a href="#Java-LinkedHashMap" class="headerlink" title="Java LinkedHashMap"></a>Java LinkedHashMap</h2><p>前面我们讲了两个散列表和链表结合的例子,现在我们再来看另外一个,Java 中的 LinkedHashMap 这种容器。如果你熟悉 Java,那你几乎天天会用到这个容器。</p><p>我们之前讲过,HashMap 底层是通过散列表这种数据结构实现的。而 LinkedHashMap 前面比 HashMap 多了一个“Linked”,这里的“Linked”是不是说,LinkedHashMap 是一个通过链表法解决散列冲突的散列表呢?</p><p>实际上,LinkedHashMap 并没有这么简单,其中的“Linked”也并不仅仅代表它是通过链表法解决散列冲突的。关于这一点,在我是初学者的时候,也误解了很久。</p><p>我们先来看一段代码。你觉得这段代码会以什么样的顺序打印 3,1,5,2 这几个 key 呢?原因又是什么呢?</p><p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">HashMap<Integer, Integer> m = new LinkedHashMap<>();</span><br><span class="line">m.put(<span class="number">3</span>, <span class="number">11</span>);</span><br><span class="line">m.put(<span class="number">1</span>, <span class="number">12</span>);</span><br><span class="line">m.put(<span class="number">5</span>, <span class="number">23</span>);</span><br><span class="line">m.put(<span class="number">2</span>, <span class="number">22</span>);</span><br><span class="line"></span><br><span class="line"><span class="keyword">for</span> (Map.Entry e : m.entrySet()) {</span><br><span class="line"> System.out.println(e.getKey());</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>我先告诉你答案,上面的代码会按照数据插入的顺序依次来打印,也就是说,打印的顺序就是 3,1,5,2。你有没有觉得奇怪?散列表中数据是经过散列函数打乱之后无规律存储的,这里是如何实现按照数据的插入顺序来遍历打印的呢?</p><p>你可能已经猜到了,LinkedHashMap 也是通过散列表和链表组合在一起实现的。实际上,它不仅支持按照插入顺序遍历数据,还支持按照访问顺序来遍历数据。你可以看下面这段代码:</p><p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">// <span class="number">10</span>是初始大小,<span class="number">0.75</span>是装载因子,true是表示按照访问时间排序</span><br><span class="line">HashMap<Integer, Integer> m = new LinkedHashMap<>(<span class="number">10</span>, <span class="number">0.75</span>f, true);</span><br><span class="line">m.put(<span class="number">3</span>, <span class="number">11</span>);</span><br><span class="line">m.put(<span class="number">1</span>, <span class="number">12</span>);</span><br><span class="line">m.put(<span class="number">5</span>, <span class="number">23</span>);</span><br><span class="line">m.put(<span class="number">2</span>, <span class="number">22</span>);</span><br><span class="line"></span><br><span class="line">m.put(<span class="number">3</span>, <span class="number">26</span>);</span><br><span class="line">m.get(<span class="number">5</span>);</span><br><span class="line"></span><br><span class="line"><span class="keyword">for</span> (Map.Entry e : m.entrySet()) {</span><br><span class="line"> System.out.println(e.getKey());</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>这段代码打印的结果是 1,2,3,5。我来具体分析一下,为什么这段代码会按照这样顺序来打印。</p><p>每次调用 put() 函数,往 LinkedHashMap 中添加数据的时候,都会将数据添加到链表的尾部,所以,在前四个操作完成之后,链表中的数据是下面这样:</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/20%202.jpg" alt=""></p><p>在第 8 行代码中,再次将键值为 3 的数据放入到 LinkedHashMap 的时候,会先查找这个键值是否已经有了,然后,再将已经存在的 (3,11) 删除,并且将新的 (3,26) 放到链表的尾部。所以,这个时候链表中的数据就是下面这样:</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/20%203.jpg" alt=""></p><p>当第 9 行代码访问到 key 为 5 的数据的时候,我们将被访问到的数据移动到链表的尾部。所以,第 9 行代码之后,链表中的数据是下面这样:</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/20%204.jpg" alt=""></p><p>所以,最后打印出来的数据是 1,2,3,5。从上面的分析,你有没有发现,按照访问时间排序的 LinkedHashMap 本身就是一个支持 LRU 缓存淘汰策略的缓存系统?实际上,它们两个的实现原理也是一模一样的。我也就不再啰嗦了。</p><p>我现在来总结一下,实际上,<strong>LinkedHashMap 是通过双向链表和散列表这两种数据结构组合实现的。LinkedHashMap 中的“Linked”实际上是指的是双向链表,并非指用链表法解决散列冲突。</strong></p><h2 id="解答开篇-amp-内容小结"><a href="#解答开篇-amp-内容小结" class="headerlink" title="解答开篇 & 内容小结"></a>解答开篇 & 内容小结</h2><p>弄懂刚刚我讲的这三个例子,开篇的问题也就不言而喻了。我这里总结一下,为什么散列表和链表经常一块使用?</p><p>散列表这种数据结构虽然支持非常高效的数据插入、删除、查找操作,但是散列表中的数据都是通过散列函数打乱之后无规律存储的。也就说,它无法支持按照某种顺序快速地遍历数据。如果希望按照顺序遍历散列表中的数据,那我们需要将散列表中的数据拷贝到数组中,然后排序,再遍历。</p><p>因为散列表是动态数据结构,不停地有数据的插入、删除,所以每当我们希望按顺序遍历散列表中的数据的时候,都需要先排序,那效率势必会很低。为了解决这个问题,我们将散列表和链表(或者跳表)结合在一起使用。</p><h2 id="课后思考"><a href="#课后思考" class="headerlink" title="课后思考"></a>课后思考</h2><p>1.今天讲的几个散列表和链表结合使用的例子里,我们用的都是双向链表。如果把双向链表改成单链表,还能否正常工作呢?为什么呢?假设猎聘网有 10 万名猎头,每个猎头都可以通过做任务(比如发布职位)来积累积分,然后通过积分来下载简历。</p><p>2.假设你是猎聘网的一名工程师,如何在内存中存储这 10 万个猎头 ID 和积分信息,让它能够支持这样几个操作:</p><p>根据猎头的 ID 快速查找、删除、更新这个猎头的积分信息;</p><p>查找积分在某个区间的猎头 ID 列表;</p><p>查找按照积分从小到大排名在第 x 位到第 y 位之间的猎头 ID 列表。</p>]]></content>
<summary type="html"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" cla</summary>
<category term="算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
</entry>
<entry>
<title>17|散列表(中):如何打造一个工业级水平的散列表?</title>
<link href="https://chanmoyun.gitee.io/2024/03/10/Data-structure/17.%E6%95%A3%E5%88%97%E8%A1%A8%EF%BC%88%E4%B8%AD%EF%BC%89%EF%BC%9A%E5%A6%82%E4%BD%95%E6%89%93%E9%80%A0%E4%B8%80%E4%B8%AA%E5%B7%A5%E4%B8%9A%E7%BA%A7%E6%B0%B4%E5%B9%B3%E7%9A%84%E6%95%A3%E5%88%97%E8%A1%A8%EF%BC%9F/"/>
<id>https://chanmoyun.gitee.io/2024/03/10/Data-structure/17.%E6%95%A3%E5%88%97%E8%A1%A8%EF%BC%88%E4%B8%AD%EF%BC%89%EF%BC%9A%E5%A6%82%E4%BD%95%E6%89%93%E9%80%A0%E4%B8%80%E4%B8%AA%E5%B7%A5%E4%B8%9A%E7%BA%A7%E6%B0%B4%E5%B9%B3%E7%9A%84%E6%95%A3%E5%88%97%E8%A1%A8%EF%BC%9F/</id>
<published>2024-03-09T16:00:00.000Z</published>
<updated>2024-03-05T07:10:30.232Z</updated>
<content type="html"><![CDATA[<link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="\assets\js\Meting.min.js"></script><h1 id="散列表(中):如何打造一个工业级水平的散列表?"><a href="#散列表(中):如何打造一个工业级水平的散列表?" class="headerlink" title="散列表(中):如何打造一个工业级水平的散列表?"></a>散列表(中):如何打造一个工业级水平的散列表?</h1><h2 id="写在前面"><a href="#写在前面" class="headerlink" title="写在前面"></a>写在前面</h2><p>通过上一节的学习,我们知道,散列表的查询效率并不能笼统地说成是 O(1)。它跟散列函数、装载因子、散列冲突等都有关系。如果散列函数设计得不好,或者装载因子过高,都可能导致散列冲突发生的概率升高,查询效率下降。</p><p>在极端情况下,有些恶意的攻击者,还有可能通过精心构造的数据,使得所有的数据经过散列函数之后,都散列到同一个槽里。如果我们使用的是基于链表的冲突解决方法,那这个时候,散列表就会退化为链表,查询的时间复杂度就从 O(1) 急剧退化为 O(n)。</p><p>如果散列表中有 10 万个数据,退化后的散列表查询的效率就下降了 10 万倍。更直接点说,如果之前运行 100 次查询只需要 0.1 秒,那现在就需要 1 万秒。这样就有可能因为查询操作消耗大量 CPU 或者线程资源,导致系统无法响应其他请求,从而达到拒绝服务攻击(DoS)的目的。这也就是散列表碰撞攻击的基本原理。</p><p>今天,我们就来学习一下,<strong>如何设计一个可以应对各种异常情况的工业级散列表,来避免在散列冲突的情况下,散列表性能的急剧下降,并且能抵抗散列碰撞攻击?</strong></p><h2 id="如何设计散列函数?"><a href="#如何设计散列函数?" class="headerlink" title="如何设计散列函数?"></a>如何设计散列函数?</h2><p>散列函数设计的好坏,决定了散列表冲突的概率大小,也直接决定了散列表的性能。那什么才是好的散列函数呢?</p><p>首先,<strong>散列函数的设计不能太复杂</strong>。过于复杂的散列函数,势必会消耗很多计算时间,也就间接的影响到散列表的性能。其次,<strong>散列函数生成的值要尽可能随机并且均匀分布</strong>,这样才能避免或者最小化散列冲突,而且即便出现冲突,散列到每个槽里的数据也会比较平均,不会出现某个槽内数据特别多的情况。</p><p>实际工作中,我们还需要综合考虑各种因素。这些因素有关键字的长度、特点、分布、还有散列表的大小等。散列函数各式各样,我举几个常用的、简单的散列函数的设计方法,让你有个直观的感受。</p><p>第一个例子就是我们上一节的学生运动会的例子,我们通过分析参赛编号的特征,把编号中的后两位作为散列值。我们还可以用类似的散列函数处理手机号码,因为手机号码前几位重复的可能性很大,但是后面几位就比较随机,我们可以取手机号的后四位作为散列值。这种散列函数的设计方法,我们一般叫作“数据分析法”。</p><p>第二个例子就是上一节的开篇思考题,如何实现 Word 拼写检查功能。这里面的散列函数,我们就可以这样设计:将单词中每个字母的ASCll 码值“进位”相加,然后再跟散列表的大小求余、取模,作为散列值。比如,英文单词 nice,我们转化出来的散列值就是下面这样:</p><blockquote><p>hash(“nice”)=((“n” - “a”) <em> 26</em>26<em>26 + (“i” - “a”)</em>26<em>26 + (“c” - “a”)</em>26+ (“e”-“a”)) / 78978</p></blockquote><p>实际上,散列函数的设计方法还有很多,比如直接寻址法、平方取中法、折叠法、随机数法等,这些你只要了解就行了,不需要全都掌握。</p><h2 id="装载因子过大了怎么办?"><a href="#装载因子过大了怎么办?" class="headerlink" title="装载因子过大了怎么办?"></a>装载因子过大了怎么办?</h2><p>我们上一节讲到散列表的装载因子的时候说过,装载因子越大,说明散列表中的元素越多,空闲位置越少,散列冲突的概率就越大。不仅插入数据的过程要多次寻址或者拉很长的链,查找的过程也会因此变得很慢。</p><p>对于没有频繁插入和删除的静态数据集合来说,我们很容易根据数据的特点、分布等,设计出完美的、极少冲突的散列函数,因为毕竟之前数据都是已知的。</p><p>对于动态散列表来说,数据集合是频繁变动的,我们事先无法预估将要加入的数据个数,所以我们也无法事先申请一个足够大的散列表。随着数据慢慢加入,装载因子就会慢慢变大。当装载因子大到一定程度之后,散列冲突就会变得不可接受。这个时候,我们该如何处理呢?</p><p>还记得我们前面多次讲的“动态扩容”吗?你可以回想一下,我们是如何做数组、栈、队列的动态扩容的。</p><p>针对散列表,当装载因子过大时,我们也可以进行动态扩容,重新申请一个更大的散列表,将数据搬移到这个新散列表中。假设每次扩容我们都申请一个原来散列表大小两倍的空间。如果原来散列表的装载因子是 0.8,那经过扩容之后,新散列表的装载因子就下降为原来的一半,变成了 0.4。</p><p>针对数组的扩容,数据搬移操作比较简单。但是,针对散列表的扩容,数据搬移操作要复杂很多。因为散列表的大小变了,数据的存储位置也变了,所以我们需要通过散列函数重新计算每个数据的存储位置。</p><p>你可以看我图里这个例子。在原来的散列表中,21 这个元素原来存储在下标为 0 的位置,搬移到新的散列表中,存储在下标为 7 的位置。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/19%201.jpg" alt=""></p><p>对于支持动态扩容的散列表,插入操作的时间复杂度是多少呢?前面章节我已经多次分析过支持动态扩容的数组、栈等数据结构的时间复杂度了。所以,这里我就不啰嗦了,你要是还不清楚的话,可以回去复习一下。</p><p>插入一个数据,最好情况下,不需要扩容,最好时间复杂度是 O(1)。最坏情况下,散列表装载因子过高,启动扩容,我们需要重新申请内存空间,重新计算哈希位置,并且搬移数据,所以时间复杂度是 O(n)。用摊还分析法,均摊情况下,时间复杂度接近最好情况,就是 O(1)。</p><p>实际上,对于动态散列表,随着数据的删除,散列表中的数据会越来越少,空闲空间会越来越多。如果我们对空间消耗非常敏感,我们可以在装载因子小于某个值之后,启动动态缩容。当然,如果我们更加在意执行效率,能够容忍多消耗一点内存空间,那就可以不用费劲来缩容了。</p><p>我们前面讲到,当散列表的装载因子超过某个阈值时,就需要进行扩容。装载因子阈值需要选择得当。如果太大,会导致冲突过多;如果太小,会导致内存浪费严重。</p><p>装载因子阈值的设置要权衡时间、空间复杂度。如果内存空间不紧张,对执行效率要求很高,可以降低负载因子的阈值;相反,如果内存空间紧张,对执行效率要求又不高,可以增加负载因子的值,甚至可以大于 1。</p><h2 id="如何避免低效地扩容?"><a href="#如何避免低效地扩容?" class="headerlink" title="如何避免低效地扩容?"></a>如何避免低效地扩容?</h2><p>我们刚刚分析得到,大部分情况下,动态扩容的散列表插入一个数据都很快,但是在特殊情况下,当装载因子已经到达阈值,需要先进行扩容,再插入数据。这个时候,插入数据就会变得很慢,甚至会无法接受。</p><p>我举一个极端的例子,如果散列表当前大小为 1GB,要想扩容为原来的两倍大小,那就需要对 1GB 的数据重新计算哈希值,并且从原来的散列表搬移到新的散列表,听起来就很耗时,是不是?</p><p>如果我们的业务代码直接服务于用户,尽管大部分情况下,插入一个数据的操作都很快,但是,极个别非常慢的插入操作,也会让用户崩溃。这个时候,“一次性”扩容的机制就不合适了。</p><p>为了解决一次性扩容耗时过多的情况,我们可以将扩容操作穿插在插入操作的过程中,分批完成。当装载因子触达阈值之后,我们只申请新空间,但并不将老的数据搬移到新散列表中。</p><p>当有新数据要插入时,我们将新数据插入新散列表中,并且从老的散列表中拿出一个数据放入到新散列表。每次插入一个数据到散列表,我们都重复上面的过程。经过多次插入操作之后,老的散列表中的数据就一点一点全部搬移到新散列表中了。这样没有了集中的一次性数据搬移,插入操作就都变得很快了。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/19%202.jpg" alt=""></p><p>这期间的查询操作怎么来做呢?对于查询操作,为了兼容了新、老散列表中的数据,我们先从新散列表中查找,如果没有找到,再去老的散列表中查找。</p><p>通过这样均摊的方法,将一次性扩容的代价,均摊到多次插入操作中,就避免了一次性扩容耗时过多的情况。这种实现方式,任何情况下,插入一个数据的时间复杂度都是 O(1)。</p><h2 id="如何选择冲突解决方法?"><a href="#如何选择冲突解决方法?" class="headerlink" title="如何选择冲突解决方法?"></a>如何选择冲突解决方法?</h2><p>上一节我们讲了两种主要的散列冲突的解决办法,开放寻址法和链表法。这两种冲突解决办法在实际的软件开发中都非常常用。比如,Java 中 LinkedHashMap 就采用了链表法解决冲突,ThreadLocalMap 是通过线性探测的开放寻址法来解决冲突。那你知道,这两种冲突解决方法各有什么优势和劣势,又各自适用哪些场景吗?</p><h3 id="1-开放寻址法"><a href="#1-开放寻址法" class="headerlink" title="1.开放寻址法"></a>1.开放寻址法</h3><p>我们先来看看,开放寻址法的优点有哪些。</p><p>开放寻址法不像链表法,需要拉很多链表。散列表中的数据都存储在数组中,可以有效地利用 CPU 缓存加快查询速度。而且,这种方法实现的散列表,序列化起来比较简单。链表法包含指针,序列化起来就没那么容易。你可不要小看序列化,很多场合都会用到的。我们后面就有一节会讲什么是数据结构序列化、如何序列化,以及为什么要序列化。</p><p>我们再来看下,开放寻址法有哪些缺点。</p><p>上一节我们讲到,用开放寻址法解决冲突的散列表,删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。而且,在开放寻址法中,所有的数据都存储在一个数组中,比起链表法来说,冲突的代价更高。所以,使用开放寻址法解决冲突的散列表,装载因子的上限不能太大。这也导致这种方法比链表法更浪费内存空间。</p><p>所以,<strong>我总结一下,当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是 Java 中的ThreadLocalMap使用开放寻址法解决散列冲突的原因。</strong></p><h3 id="2-链表法"><a href="#2-链表法" class="headerlink" title="2.链表法"></a>2.链表法</h3><p>首先,链表法对内存的利用率比开放寻址法要高。因为链表结点可以在需要的时候再创建,并不需要像开放寻址法那样事先申请好。实际上,这一点也是我们前面讲过的链表优于数组的地方。</p><p>链表法比起开放寻址法,对大装载因子的容忍度更高。开放寻址法只能适用装载因子小于 1 的情况。接近 1 时,就可能会有大量的散列冲突,导致大量的探测、再散列等,性能会下降很多。但是对于链表法来说,只要散列函数的值随机均匀,即便装载因子变成 10,也就是链表的长度变长了而已,虽然查找效率有所下降,但是比起顺序查找还是快很多。</p><p>还记得我们之前在链表那一节讲的吗?链表因为要存储指针,所以对于比较小的对象的存储,是比较消耗内存的,还有可能会让内存的消耗翻倍。而且,因为链表中的结点是零散分布在内存中的,不是连续的,所以对 CPU 缓存是不友好的,这方面对于执行效率也有一定的影响。</p><p>当然,如果我们存储的是大对象,也就是说要存储的对象的大小远远大于一个指针的大小(4 个字节或者 8 个字节),那链表中指针的内存消耗在大对象面前就可以忽略了。</p><p>实际上,我们对链表法稍加改造,可以实现一个更加高效的散列表。那就是,我们将链表法中的链表改造为其他高效的动态数据结构,比如跳表、红黑树。这样,即便出现散列冲突,极端情况下,所有的数据都散列到同一个桶内,那最终退化成的散列表的查找时间也只不过是 O(logn)。这样也就有效避免了前面讲到的散列碰撞攻击。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/19%203.jpg" alt=""></p><p>所以,<strong>我总结一下,基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。</strong></p><h2 id="工业级散列表举例分析"><a href="#工业级散列表举例分析" class="headerlink" title="工业级散列表举例分析"></a>工业级散列表举例分析</h2><p>刚刚我讲了实现一个工业级散列表需要涉及的一些关键技术,现在,我就拿一个具体的例子,Java 中的 HashMap 这样一个工业级的散列表,来具体看下,这些技术是怎么应用的。</p><h3 id="1-初始大小"><a href="#1-初始大小" class="headerlink" title="1.初始大小"></a>1.初始大小</h3><p>HashMap 默认的初始大小是 16,当然这个默认值是可以设置的,如果事先知道大概的数据量有多大,可以通过修改默认初始大小,减少动态扩容的次数,这样会大大提高 HashMap 的性能。</p><h3 id="2-装载因子和动态扩容"><a href="#2-装载因子和动态扩容" class="headerlink" title="2.装载因子和动态扩容"></a>2.装载因子和动态扩容</h3><p>最大装载因子默认是 0.75,当 HashMap 中元素个数超过 0.75*capacity(capacity 表示散列表的容量)的时候,就会启动扩容,每次扩容都会扩容为原来的两倍大小。</p><h3 id="3-散列冲突解决方法"><a href="#3-散列冲突解决方法" class="headerlink" title="3.散列冲突解决方法"></a>3.散列冲突解决方法</h3><p>HashMap 底层采用链表法来解决冲突。即使负载因子和散列函数设计得再合理,也免不了会出现拉链过长的情况,一旦出现拉链过长,则会严重影响 HashMap 的性能。</p><p>于是,在 JDK1.8 版本中,为了对 HashMap 做进一步优化,我们引入了红黑树。而当链表长度太长(默认超过 8)时,链表就转换为红黑树。我们可以利用红黑树快速增删改查的特点,提高 HashMap 的性能。当红黑树结点个数少于 8 个的时候,又会将红黑树转化为链表。因为在数据量较小的情况下,红黑树要维护平衡,比起链表来,性能上的优势并不明显。</p><h3 id="4-散列函数"><a href="#4-散列函数" class="headerlink" title="4.散列函数"></a>4.散列函数</h3><p>散列函数的设计并不复杂,追求的是简单高效、分布均匀。我把它摘抄出来,你可以看看。</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">int hash(Object key) {</span><br><span class="line"> int h = key.hashCode();</span><br><span class="line"> return (h ^ (h >>> 16)) & (capicity -1); //capicity表示散列表的大小</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>其中,hashCode() 返回的是 Java 对象的 hash code。比如 String 类型的对象的 hashCode() 就是下面这样:</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br></pre></td><td class="code"><pre><span class="line">public int hashCode() {</span><br><span class="line"></span><br><span class="line"> int var1 = this.hash;</span><br><span class="line"> if(var1 == 0 && this.value.length > 0) {</span><br><span class="line"> char[] var2 = this.value;</span><br><span class="line"> for(int var3 = 0; var3 < this.value.length; ++var3) {</span><br><span class="line"> var1 = 31 * var1 + var2[var3];</span><br><span class="line"> }</span><br><span class="line"> this.hash = var1;</span><br><span class="line"> }</span><br><span class="line"> return var1;</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><h2 id="解答开篇"><a href="#解答开篇" class="headerlink" title="解答开篇"></a>解答开篇</h2><p>今天的内容就讲完了,我现在来分析一下开篇的问题:如何设计的一个工业级的散列函数?如果这是一道面试题或者是摆在你面前的实际开发问题,你会从哪几个方面思考呢?</p><p>首先,我会思考,<strong>何为一个工业级的散列表?工业级的散列表应该具有哪些特性?</strong></p><p>结合已经学习过的散列知识,我觉得应该有这样几点要求:</p><ul><li>支持快速的查询、插入、删除操作;</li><li>内存占用合理,不能浪费过多的内存空间;</li><li>性能稳定,极端情况下,散列表的性能也不会退化到无法接受的情况</li></ul><p><strong>如何实现这样一个散列表呢</strong>?根据前面讲到的知识,我会从这三个方面来考虑设计思路:</p><ul><li>设计一个合适的散列函数;</li><li>定义装载因子阈值,并且设计动态扩容策略;</li><li>选择合适的散列冲突解决方法。</li></ul><p>关于散列函数、装载因子、动态扩容策略,还有散列冲突的解决办法,我们前面都讲过了,具体如何选择,还要结合具体的业务场景、具体的业务数据来具体分析。不过只要我们朝这三个方向努力,就离设计出工业级的散列表不远了。</p><h2 id="内容小结"><a href="#内容小结" class="headerlink" title="内容小结"></a>内容小结</h2><p>上一节的内容比较偏理论,今天的内容侧重实战。我主要讲了如何设计一个工业级的散列表,以及如何应对各种异常情况,防止在极端情况下,散列表的性能退化过于严重。我分了三部分来讲解这些内容,分别是:如何设计散列函数,如何根据装载因子动态扩容,以及如何选择散列冲突解决方法。</p><p>关于散列函数的设计,我们要尽可能让散列后的值随机且均匀分布,这样会尽可能地减少散列冲突,即便冲突之后,分配到每个槽内的数据也比较均匀。除此之外,散列函数的设计也不能太复杂,太复杂就会太耗时间,也会影响散列表的性能。</p><p>关于散列冲突解决方法的选择,我对比了开放寻址法和链表法两种方法的优劣和适应的场景。大部分情况下,链表法更加普适。而且,我们还可以通过将链表法中的链表改造成其他动态查找数据结构,比如红黑树,来避免散列表时间复杂度退化成 O(n),抵御散列碰撞攻击。但是,对于小规模数据、装载因子不高的散列表,比较适合用开放寻址法。</p><p>对于动态散列表来说,不管我们如何设计散列函数,选择什么样的散列冲突解决方法。随着数据的不断增加,散列表总会出现装载因子过高的情况。这个时候,我们就需要启动动态扩容。</p><h2 id="课后思考"><a href="#课后思考" class="headerlink" title="课后思考"></a>课后思考</h2><p>在你熟悉的编程语言中,哪些数据类型底层是基于散列表实现的?散列函数是如何设计的?散列冲突是通过哪种方法解决的?是否支持动态扩容呢?</p>]]></content>
<summary type="html"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" cla</summary>
<category term="算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
</entry>
<entry>
<title>16|散列表(上):Word文档中的单词拼写检查功能是如何实现的?</title>
<link href="https://chanmoyun.gitee.io/2024/03/09/Data-structure/16.%E6%95%A3%E5%88%97%E8%A1%A8%EF%BC%88%E4%B8%8A%EF%BC%89%EF%BC%9AWord%E6%96%87%E6%A1%A3%E4%B8%AD%E7%9A%84%E5%8D%95%E8%AF%8D%E6%8B%BC%E5%86%99%E6%A3%80%E6%9F%A5%E5%8A%9F%E8%83%BD%E6%98%AF%E5%A6%82%E4%BD%95%E5%AE%9E%E7%8E%B0%E7%9A%84%EF%BC%9F/"/>
<id>https://chanmoyun.gitee.io/2024/03/09/Data-structure/16.%E6%95%A3%E5%88%97%E8%A1%A8%EF%BC%88%E4%B8%8A%EF%BC%89%EF%BC%9AWord%E6%96%87%E6%A1%A3%E4%B8%AD%E7%9A%84%E5%8D%95%E8%AF%8D%E6%8B%BC%E5%86%99%E6%A3%80%E6%9F%A5%E5%8A%9F%E8%83%BD%E6%98%AF%E5%A6%82%E4%BD%95%E5%AE%9E%E7%8E%B0%E7%9A%84%EF%BC%9F/</id>
<published>2024-03-08T16:00:00.000Z</published>
<updated>2024-03-05T07:06:03.609Z</updated>
<content type="html"><![CDATA[<link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="\assets\js\Meting.min.js"></script><h1 id="散列表(上):Word文档中的单词拼写检查功能是如何实现的?"><a href="#散列表(上):Word文档中的单词拼写检查功能是如何实现的?" class="headerlink" title="散列表(上):Word文档中的单词拼写检查功能是如何实现的?"></a>散列表(上):Word文档中的单词拼写检查功能是如何实现的?</h1><h2 id="写在前面"><a href="#写在前面" class="headerlink" title="写在前面"></a>写在前面</h2><p>Word 这种文本编辑器你平时应该经常用吧,那你有没有留意过它的拼写检查功能呢?一旦我们在 Word 里输入一个错误的英文单词,它就会用标红的方式提示“拼写错误”。<strong>Word 的这个单词拼写检查功能,虽然很小但却非常实用。你有没有想过,这个功能是如何实现的呢?</strong></p><p>其实啊,一点儿都不难。只要你学完今天的内容,<strong>散列表</strong>(Hash Table)。你就能像微软 Office 的工程师一样,轻松实现这个功能。散列思想</p><h2 id="散列思想"><a href="#散列思想" class="headerlink" title="散列思想"></a>散列思想</h2><p>散列表的英文叫“Hash Table”,我们平时也叫它“哈希表”或者“Hash 表”,你一定也经常听过它,我在前面的文章里,也不止一次提到过,但是你是不是真的理解这种数据结构呢?</p><p><strong>散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。可以说,如果没有数组,就没有散列表。</strong></p><p>我用一个例子来解释一下。假如我们有 89 名选手参加学校运动会。为了方便记录成绩,每个选手胸前都会贴上自己的参赛号码。这 89 名选手的编号依次是 1 到 89。现在我们希望编程实现这样一个功能,通过编号快速找到对应的选手信息。你会怎么做呢?</p><p>我们可以把这 89 名选手的信息放在数组里。编号为 1 的选手,我们放到数组中下标为 1 的位置;编号为 2 的选手,我们放到数组中下标为 2 的位置。以此类推,编号为 k 的选手放到数组中下标为 k 的位置。</p><p>因为参赛编号跟数组下标一一对应,当我们需要查询参赛编号为 x 的选手的时候,我们只需要将下标为 x 的数组元素取出来就可以了,时间复杂度就是 O(1)。这样按照编号查找选手信息,效率是不是很高?</p><p>实际上,这个例子已经用到了散列的思想。在这个例子里,参赛编号是自然数,并且与数组的下标形成一一映射,所以利用数组支持根据下标随机访问的时候,时间复杂度是 O(1) 这一特性,就可以实现快速查找编号对应的选手信息。</p><p>你可能要说了,这个例子中蕴含的散列思想还不够明显,那我来改造一下这个例子。</p><p>假设校长说,参赛编号不能设置得这么简单,要加上年级、班级这些更详细的信息,所以我们把编号的规则稍微修改了一下,用 6 位数字来表示。比如 051167,其中,前两位 05 表示年级,中间两位 11 表示班级,最后两位还是原来的编号 1 到 89。这个时候我们该如何存储选手信息,才能够支持通过编号来快速查找选手信息呢?</p><p>思路还是跟前面类似。尽管我们不能直接把编号作为数组下标,但我们可以截取参赛编号的后两位作为数组下标,来存取选手信息数据。当通过参赛编号查询选手信息的时候,我们用同样的方法,取参赛编号的后两位,作为数组下标,来读取数组中的数据。</p><p>这就是典型的散列思想。其中,参赛选手的编号我们叫作<strong>键</strong>(key)或者<strong>关键字</strong>。我们用它来标识一个选手。我们把参赛编号转化为数组下标的映射方法就叫作<strong>散列函数</strong>(或“Hash 函数”“哈希函数”),而散列函数计算得到的值就叫作<strong>散列值</strong>(或“Hash 值”“哈希值”)。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/18%201.jpg" alt=""></p><p>通过这个例子,我们可以总结出这样的规律:散列表用的就是数组支持按照下标随机访问的时候,时间复杂度是 O(1) 的特性。我们通过散列函数把元素的键值映射为下标,然后将数据存储在数组中对应下标的位置。当我们按照键值查询元素时,我们用同样的散列函数,将键值转化数组下标,从对应的数组下标的位置取数据。</p><h2 id="散列函数"><a href="#散列函数" class="headerlink" title="散列函数"></a>散列函数</h2><p>从上面的例子我们可以看到,散列函数在散列表中起着非常关键的作用。现在我们就来学习下散列函数。</p><p>散列函数,顾名思义,它是一个函数。我们可以把它定义成 <strong>hash(key)</strong>,其中 key 表示元素的键值,hash(key) 的值表示经过散列函数计算得到的散列值。</p><p>那第一个例子中,编号就是数组下标,所以 hash(key) 就等于 key。改造后的例子,写成散列函数稍微有点复杂。我用伪代码将它写成函数就是下面这样:</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">int hash(String key) {</span><br><span class="line"> // 获取后两位字符</span><br><span class="line"> string lastTwoChars = key.substr(length-2, length);</span><br><span class="line"> // 将后两位字符转换为整数</span><br><span class="line"> int hashValue = convert lastTwoChas to int-type;</span><br><span class="line"> return hashValue;</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>刚刚举的学校运动会的例子,散列函数比较简单,也比较容易想到。但是,如果参赛选手的编号是随机生成的 6 位数字,又或者用的是 a 到 z 之间的字符串,该如何构造散列函数呢?我总结了三点散列函数设计的基本要求:</p><ol><li>散列函数计算得到的散列值是一个非负整数;</li><li>如果 key1 = key2,那 hash(key1) == hash(key2);</li><li>如果 key1 ≠ key2,那 hash(key1) ≠ hash(key2)。</li></ol><p>我来解释一下这三点。其中,第一点理解起来应该没有任何问题。因为数组下标是从 0 开始的,所以散列函数生成的散列值也要是非负整数。第二点也很好理解。相同的 key,经过散列函数得到的散列值也应该是相同的。</p><p>第三点理解起来可能会有问题,我着重说一下。这个要求看起来合情合理,但是在真实的情况下,要想找到一个不同的 key 对应的散列值都不一样的散列函数,几乎是不可能的。即便像业界著名的<a href="https://zh.wikipedia.org/wiki/MD5">MD5</a>、<a href="https://zh.wikipedia.org/wiki/SHA%E5%AE%B6%E6%97%8F">SHA</a>、<a href="https://zh.wikipedia.org/wiki/%E5%BE%AA%E7%92%B0%E5%86%97%E9%A4%98%E6%A0%A1%E9%A9%97">CRC</a>等哈希算法,也无法完全避免这种散列冲突。而且,因为数组的存储空间有限,也会加大散列冲突的概率。</p><p>所以我们几乎无法找到一个完美的无冲突的散列函数,即便能找到,付出的时间成本、计算成本也是很大的,所以针对散列冲突问题,我们需要通过其他途径来解决。</p><h2 id="散列冲突"><a href="#散列冲突" class="headerlink" title="散列冲突"></a>散列冲突</h2><p>再好的散列函数也无法避免散列冲突。那究竟该如何解决散列冲突问题呢?我们常用的散列冲突解决方法有两类,开放寻址法(open addressing)和链表法(chaining)。</p><h3 id="1-开放寻址法"><a href="#1-开放寻址法" class="headerlink" title="1. 开放寻址法"></a>1. 开放寻址法</h3><p>开放寻址法的核心思想是,如果出现了散列冲突,我们就重新探测一个空闲位置,将其插入。那如何重新探测新的位置呢?我先讲一个比较简单的探测方法,线性探测(Linear Probing)。</p><p>当我们往散列表中插入数据时,如果某个数据经过散列函数散列之后,存储位置已经被占用了,我们就从当前位置开始,依次往后查找,看是否有空闲位置,直到找到为止。</p><p>我说的可能比较抽象,我举一个例子具体给你说明一下。这里面黄色的色块表示空闲位置,橙色的色块表示已经存储了数据。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/18%202.jpg" alt=""></p><p>从图中可以看出,散列表的大小为 10,在元素 x 插入散列表之前,已经 6 个元素插入到散列表中。x 经过 Hash 算法之后,被散列到位置下标为 7 的位置,但是这个位置已经有数据了,所以就产生了冲突。于是我们就顺序地往后一个一个找,看有没有空闲的位置,遍历到尾部都没有找到空闲的位置,于是我们再从表头开始找,直到找到空闲位置 2,于是将其插入到这个位置。</p><p>在散列表中查找元素的过程有点儿类似插入过程。我们通过散列函数求出要查找元素的键值对应的散列值,然后比较数组中下标为散列值的元素和要查找的元素。如果相等,则说明就是我们要找的元素;否则就顺序往后依次查找。如果遍历到数组中的空闲位置,还没有找到,就说明要查找的元素并没有在散列表中。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/18%203.jpg" alt=""></p><p>散列表跟数组一样,不仅支持插入、查找操作,还支持删除操作。对于使用线性探测法解决冲突的散列表,删除操作稍微有些特别。我们不能单纯地把要删除的元素设置为空。这是为什么呢?</p><p>还记得我们刚讲的查找操作吗?在查找的时候,一旦我们通过线性探测方法,找到一个空闲位置,我们就可以认定散列表中不存在这个数据。但是,如果这个空闲位置是我们后来删除的,就会导致原来的查找算法失效。本来存在的数据,会被认定为不存在。这个问题如何解决呢?</p><p>我们可以将删除的元素,特殊标记为 deleted。当线性探测查找的时候,遇到标记为 deleted 的空间,并不是停下来,而是继续往下探测。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/18%204.jpg" alt=""></p><p>你可能已经发现了,线性探测法其实存在很大问题。当散列表中插入的数据越来越多时,散列冲突发生的可能性就会越来越大,空闲位置会越来越少,线性探测的时间就会越来越久。极端情况下,我们可能需要探测整个散列表,所以最坏情况下的时间复杂度为 O(n)。同理,在删除和查找时,也有可能会线性探测整张散列表,才能找到要查找或者删除的数据。</p><p>对于开放寻址冲突解决方法,除了线性探测方法之外,还有另外两种比较经典的探测方法,<strong>二次探测</strong>(Quadratic probing)和<strong>双重散列</strong>(Double hashing)。</p><p>所谓二次探测,跟线性探测很像,线性探测每次探测的步长是 1,那它探测的下标序列就是 hash(key)+0,hash(key)+1,hash(key)+2……而二次探测探测的步长就变成了原来的“二次方”,也就是说,它探测的下标序列就是 hash(key)+0,hash(key)+12,hash(key)+22……</p><p>所谓双重散列,意思就是不仅要使用一个散列函数。我们使用一组散列函数 hash1(key),hash2(key),hash3(key)……我们先用第一个散列函数,如果计算得到的存储位置已经被占用,再用第二个散列函数,依次类推,直到找到空闲的存储位置。</p><p>不管采用哪种探测方法,当散列表中空闲位置不多的时候,散列冲突的概率就会大大提高。为了尽可能保证散列表的操作效率,一般情况下,我们会尽可能保证散列表中有一定比例的空闲槽位。我们用<strong>装载因子</strong>(load factor)来表示空位的多少。</p><p>装载因子的计算公式是:</p><blockquote><p>散列表的装载因子=填入表中的元素个数/散列表的长度</p></blockquote><p>装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。</p><h3 id="2-链表法"><a href="#2-链表法" class="headerlink" title="2.链表法"></a>2.链表法</h3><p>链表法是一种更加常用的散列冲突解决办法,相比开放寻址法,它要简单很多。我们来看这个图,在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/18%205.jpg" alt=""></p><p>当插入的时候,我们只需要通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,所以插入的时间复杂度是 O(1)。当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除。那查找或删除操作的时间复杂度是多少呢?</p><p>实际上,这两个操作的时间复杂度跟链表的长度 k 成正比,也就是 O(k)。对于散列比较均匀的散列函数来说,理论上讲,k=n/m,其中 n 表示散列中数据的个数,m 表示散列表中“槽”的个数。</p><h2 id="解答开篇"><a href="#解答开篇" class="headerlink" title="解答开篇"></a>解答开篇</h2><p>有了前面这些基本知识储备,我们来看一下开篇的思考题:Word 文档中单词拼写检查功能是如何实现的?</p><p>常用的英文单词有 20 万个左右,假设单词的平均长度是 10 个字母,平均一个单词占用 10 个字节的内存空间,那 20 万英文单词大约占 2MB 的存储空间,就算放大 10 倍也就是 20MB。对于现在的计算机来说,这个大小完全可以放在内存里面。所以我们可以用散列表来存储整个英文单词词典。</p><p>当用户输入某个英文单词时,我们拿用户输入的单词去散列表中查找。如果查到,则说明拼写正确;如果没有查到,则说明拼写可能有误,给予提示。借助散列表这种数据结构,我们就可以轻松实现快速判断是否存在拼写错误。</p><h2 id="内容小结"><a href="#内容小结" class="headerlink" title="内容小结"></a>内容小结</h2><p>今天我讲了一些比较基础、比较偏理论的散列表知识,包括散列表的由来、散列函数、散列冲突的解决方法。</p><p>散列表来源于数组,它借助散列函数对数组这种数据结构进行扩展,利用的是数组支持按照下标随机访问元素的特性。散列表两个核心问题是散列函数设计和散列冲突解决。散列冲突有两种常用的解决方法,开放寻址法和链表法。散列函数设计的好坏决定了散列冲突的概率,也就决定散列表的性能。</p><p>针对散列函数和散列冲突,今天我只讲了一些基础的概念、方法,下一节我会更贴近实战、更加深入探讨这两个问题。</p><h2 id="课后思考"><a href="#课后思考" class="headerlink" title="课后思考"></a>课后思考</h2><p>1.假设我们有 10 万条 URL 访问日志,如何按照访问次数给 URL 排序?</p><p>2.有两个字符串数组,每个数组大约有 10 万条字符串,如何快速找出两个数组中相同的字符串?</p>]]></content>
<summary type="html"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" cla</summary>
<category term="算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
</entry>
<entry>
<title>15|跳表:为什么Redis一定要用跳表来实现有序集合?</title>
<link href="https://chanmoyun.gitee.io/2024/03/08/Data-structure/15.%E8%B7%B3%E8%A1%A8%EF%BC%9A%E4%B8%BA%E4%BB%80%E4%B9%88Redis%E4%B8%80%E5%AE%9A%E8%A6%81%E7%94%A8%E8%B7%B3%E8%A1%A8%E6%9D%A5%E5%AE%9E%E7%8E%B0%E6%9C%89%E5%BA%8F%E9%9B%86%E5%90%88%EF%BC%9F/"/>
<id>https://chanmoyun.gitee.io/2024/03/08/Data-structure/15.%E8%B7%B3%E8%A1%A8%EF%BC%9A%E4%B8%BA%E4%BB%80%E4%B9%88Redis%E4%B8%80%E5%AE%9A%E8%A6%81%E7%94%A8%E8%B7%B3%E8%A1%A8%E6%9D%A5%E5%AE%9E%E7%8E%B0%E6%9C%89%E5%BA%8F%E9%9B%86%E5%90%88%EF%BC%9F/</id>
<published>2024-03-07T16:00:00.000Z</published>
<updated>2024-03-04T02:15:15.730Z</updated>
<content type="html"><(Red-black tree)。</p><p>Redis 中的有序集合(Sorted Set)就是用跳表来实现的。如果你有一定基础,应该知道红黑树也可以实现快速的插入、删除和查找操作。<strong>那 Redis 为什么会选择用跳表来实现有序集合呢?</strong> 为什么不用红黑树呢?学完今天的内容,你就知道答案了。</p><h2 id="如何理解“跳表”?"><a href="#如何理解“跳表”?" class="headerlink" title="如何理解“跳表”?"></a>如何理解“跳表”?</h2><p>对于一个单链表来讲,即便链表中存储的数据是有序的,如果我们要想在其中查找某个数据,也只能从头到尾遍历链表。这样查找效率就会很低,时间复杂度会很高,是 O(n)。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/17%201.jpg" alt=""></p><p>那怎么来提高查找效率呢?如果像图中那样,对链表建立一级“索引”,查找起来是不是就会更快一些呢?每两个结点提取一个结点到上一级,我们把抽出来的那一级叫作<strong>索引或索引层</strong>。你可以看我画的图。图中的 down 表示 down 指针,指向下一级结点</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/17%202.jpg" alt=""></p><p>如果我们现在要查找某个结点,比如 16。我们可以先在索引层遍历,当遍历到索引层中值为 13 的结点时,我们发现下一个结点是 17,那要查找的结点 16 肯定就在这两个结点之间。然后我们通过索引层结点的 down 指针,下降到原始链表这一层,继续遍历。这个时候,我们只需要再遍历 2 个结点,就可以找到值等于 16 的这个结点了。这样,原来如果要查找 16,需要遍历 10 个结点,现在只需要遍历 7 个结点。</p><p>从这个例子里,我们看出,<strong>加来一层索引之后,查找一个结点需要遍历的结点个数减少了,也就是说查找效率提高了</strong>。那如果我们再加一级索引呢?效率会不会提升更多呢?</p><p>跟前面建立第一级索引的方式相似,我们在第一级索引的基础之上,每两个结点就抽出一个结点到第二级索引。现在我们再来查找 16,只需要遍历 6 个结点了,需要遍历的结点数量又减少了。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/17%203.jpg" alt=""></p><p>我举的例子数据量不大,所以即便加了两级索引,查找效率的提升也并不明显。为了让你能真切地感受索引提升查询效率。我画了一个包含 64 个结点的链表,按照前面讲的这种思路,建立了五级索引。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/17%204.jpg" alt=""></p><p>从图中我们可以看出,原来没有索引的时候,查找 62 需要遍历 62 个结点,现在只需要遍历 11 个结点,速度是不是提高了很多?所以,当链表的长度 n 比较大时,比如 1000、10000 的时候,在构建索引之后,查找效率的提升就会非常明显。</p><p>前面讲的这种链表加多级索引的结构,就是跳表。我通过例子给你展示了跳表是如何减少查询次数的,现在你应该比较清晰地知道,跳表确实是可以提高查询效率的。接下来,我会定量地分析一下,用跳表查询到底有多快。</p><h2 id="用跳表查询到底有多快?"><a href="#用跳表查询到底有多快?" class="headerlink" title="用跳表查询到底有多快?"></a>用跳表查询到底有多快?</h2><p>前面我讲过,算法的执行效率可以通过时间复杂度来度量,这里依旧可以用。我们知道,在一个单链表中查询某个数据的时间复杂度是 O(n)。那在一个具有多级索引的跳表中,查询某个数据的时间复杂度是多少呢?</p><p>这个时间复杂度的分析方法比较难想到。我把问题分解一下,先来看这样一个问题,如果链表里有 n 个结点,会有多少级索引呢?</p><p>按照我们刚才讲的,每两个结点会抽出一个结点作为上一级索引的结点,那第一级索引的结点个数大约就是 n/2,第二级索引的结点个数大约就是 n/4,第三级索引的结点个数大约就是 n/8,依次类推,也就是说,<strong>第 k 级索引的结点个数是第 k-1 级索引的结点个数的 1/2,那第 k级索引结点的个数就是 n/(2k)。</strong></p><p>假设索引有 h 级,最高级的索引有 2 个结点。通过上面的公式,我们可以得到 n/(2h)=2,从而求得 h=log2n-1。如果包含原始链表这一层,整个跳表的高度就是 log2n。我们在跳表中查询某个数据的时候,如果每一层都要遍历 m 个结点,那在跳表中查询一个数据的时间复杂度就是 O(m*logn)。</p><p>那这个 m 的值是多少呢?按照前面这种索引结构,我们每一级索引都最多只需要遍历 3 个结点,也就是说 m=3,为什么是 3 呢?我来解释一下。</p><p>假设我们要查找的数据是 x,在第 k 级索引中,我们遍历到 y 结点之后,发现 x 大于 y,小于后面的结点 z,所以我们通过 y 的 down 指针,从第 k 级索引下降到第 k-1 级索引。在第 k-1 级索引中,y 和 z 之间只有 3 个结点(包含 y 和 z),所以,我们在 K-1 级索引中最多只需要遍历 3 个结点,依次类推,每一级索引都最多只需要遍历 3 个结点。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/17%205.jpg" alt=""></p><p>通过上面的分析,我们得到 m=3,所以在跳表中查询任意数据的时间复杂度就是 O(logn)。这个查找的时间复杂度跟二分查找是一样的。换句话说,我们其实是基于单链表实现了二分查找,是不是很神奇?不过,天下没有免费的午餐,这种查询效率的提升,前提是建立了很多级索引,也就是我们在第 6 节讲过的空间换时间的设计思路。</p><h2 id="跳表是不是很浪费内存?"><a href="#跳表是不是很浪费内存?" class="headerlink" title="跳表是不是很浪费内存?"></a>跳表是不是很浪费内存?</h2><p>比起单纯的单链表,跳表需要存储多级索引,肯定要消耗更多的存储空间。那到底需要消耗多少额外的存储空间呢?我们来分析一下跳表的空间复杂度。</p><p>跳表的空间复杂度分析并不难,我在前面说了,假设原始链表大小为 n,那第一级索引大约有 n/2 个结点,第二级索引大约有 n/4 个结点,以此类推,每上升一级就减少一半,直到剩下 2 个结点。如果我们把每层索引的结点数写出来,就是一个等比数列。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/17%206.jpg" alt=""></p><p>这几级索引的结点总和就是 n/2+n/4+n/8…+8+4+2=n-2。所以,跳表的空间复杂度是 O(n)。也就是说,如果将包含 n 个结点的单链表构造成跳表,我们需要额外再用接近 n 个结点的存储空间。那我们有没有办法降低索引占用的内存空间呢?</p><p>我们前面都是每两个结点抽一个结点到上级索引,如果我们每三个结点或五个结点,抽一个结点到上级索引,是不是就不用那么多索引结点了呢?我画了一个每三个结点抽一个的示意图,你可以看下。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/17%207.jpg" alt=""></p><p>从图中可以看出,第一级索引需要大约 n/3 个结点,第二级索引需要大约 n/9 个结点。每往上一级,索引结点个数都除以 3。为了方便计算,我们假设最高一级的索引结点个数是 1。我们把每级索引的结点个数都写下来,也是一个等比数列。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/17%208.jpg" alt=""></p><p>通过等比数列求和公式,总的索引结点大约就是 n/3+n/9+n/27+…+9+3+1=n/2。尽管空间复杂度还是 O(n),但比上面的每两个结点抽一个结点的索引构建方法,要减少了一半的索引结点存储空间。</p><p>实际上,在软件开发中,我们不必太在意索引占用的额外空间。在讲数据结构和算法时,我们习惯性地把要处理的数据看成整数,但是在实际的软件开发中,原始链表中存储的有可能是很大的对象,而索引结点只需要存储关键值和几个指针,并不需要存储对象,所以当对象比索引结点大很多时,那索引占用的额外空间就可以忽略了。</p><h2 id="高效的动态插入和删除"><a href="#高效的动态插入和删除" class="headerlink" title="高效的动态插入和删除"></a>高效的动态插入和删除</h2><p>跳表长什么样子我想你应该已经很清楚了,它的查找操作我们刚才也讲过了。实际上,跳表这个动态数据结构,不仅支持查找操作,还支持动态的插入、删除操作,而且插入、删除操作的时间复杂度也是 O(logn)。</p><p>我们现在来看下, 如何在跳表中插入一个数据,以及它是如何做到 O(logn) 的时间复杂度的?</p><p>我们知道,在单链表中,一旦定位好要插入的位置,插入结点的时间复杂度是很低的,就是 O(1)。但是,这里为了保证原始链表中数据的有序性,我们需要先找到要插入的位置,这个查找操作就会比较耗时。</p><p>对于纯粹的单链表,需要遍历每个结点,来找到插入的位置。但是,对于跳表来说,我们讲过查找某个结点的的时间复杂度是 O(logn),所以这里查找某个数据应该插入的位置,方法也是类似的,时间复杂度也是 O(logn)。我画了一张图,你可以很清晰地看到插入的过程。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/17%209.jpg" alt=""></p><p>好了,我们再来看删除操作。</p><p>如果这个结点在索引中也有出现,我们除了要删除原始链表中的结点,还要删除索引中的。因为单链表中的删除操作需要拿到要删除结点的前驱结点,然后通过指针操作完成删除。所以在查找要删除的结点的时候,一定要获取前驱结点。当然,如果我们用的是双向链表,就不需要考虑这个问题了。</p><h2 id="跳表索引动态更新"><a href="#跳表索引动态更新" class="headerlink" title="跳表索引动态更新"></a>跳表索引动态更新</h2><p>当我们不停地往跳表中插入数据时,如果我们不更新索引,就有可能出现某 2 个索引结点之间数据非常多的情况。极端情况下,跳表还会退化成单链表。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/17%2010.jpg" alt=""></p><p>作为一种动态数据结构,我们需要某种手段来维护索引与原始链表大小之间的平衡,也就是说,如果链表中结点多了,索引结点就相应地增加一些,避免复杂度退化,以及查找、插入、删除操作性能下降。</p><p>如果你了解红黑树、AVL 树这样平衡二叉树,你就知道它们是通过左右旋的方式保持左右子树的大小平衡(如果不了解也没关系,我们后面会讲),而跳表是通过随机函数来维护前面提到的“平衡性”。</p><p>当我们往跳表中插入数据的时候,我们可以选择同时将这个数据插入到部分索引层中。如何选择加入哪些索引层呢?</p><p>我们通过一个随机函数,来决定将这个结点插入到哪几级索引中,比如随机函数生成了值 K,那我们就将这个结点添加到第一级到第 K 级这 K 级索引中。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/17%2011.jpg" alt=""></p><p>随机函数的选择很有讲究,从概率上来讲,能够保证跳表的索引大小和数据大小平衡性,不至于性能过度退化。至于随机函数的选择,我就不展开讲解了。如果你感兴趣的话,可以看看我在 GitHub 上的代码或者 Redis 中关于有序集合的跳表实现。</p><p>跳表的实现还是稍微有点复杂的,我将 Java 实现的代码放到了 GitHub 中,你可以根据我刚刚的讲解,对照着代码仔细思考一下。你不用死记硬背代码,跳表的实现并不是我们这节的重点。</p><h2 id="解答开篇"><a href="#解答开篇" class="headerlink" title="解答开篇"></a>解答开篇</h2><p>今天的内容到此就讲完了。现在,我来讲解一下开篇的思考题:为什么 Redis 要用跳表来实现有序集合,而不是红黑树?</p><p>Redis 中的有序集合是通过跳表来实现的,严格点讲,其实还用到了散列表。不过散列表我们后面才会讲到,所以我们现在暂且忽略这部分。如果你去查看 Redis 的开发手册,就会发现,Redis 中的有序集合支持的核心操作主要有下面这几个:</p><ul><li>插入一个数据;</li><li>删除一个数据;</li><li>查找一个数据;</li><li>按照区间查找数据(比如查找值在[100, 356]之间的数据);</li><li>迭代输出有序序列。</li></ul><p>其中,插入、删除、查找以及迭代输出有序序列这几个操作,红黑树也可以完成,时间复杂度跟跳表是一样的。但是,按照区间来查找数据这个操作,红黑树的效率没有跳表高。</p><p>对于按照区间查找数据这个操作,跳表可以做到 O(logn) 的时间复杂度定位区间的起点,然后在原始链表中顺序往后遍历就可以了。这样做非常高效。</p><p>当然,Redis 之所以用跳表来实现有序集合,还有其他原因,比如,跳表更容易代码实现。虽然跳表的实现也不简单,但比起红黑树来说还是好懂、好写多了,而简单就意味着可读性好,不容易出错。还有,跳表更加灵活,它可以通过改变索引构建策略,有效平衡执行效率和内存消耗。</p><p>不过,跳表也不能完全替代红黑树。因为红黑树比跳表的出现要早一些,很多编程语言中的 Map 类型都是通过红黑树来实现的。我们做业务开发的时候,直接拿来用就可以了,不用费劲自己去实现一个红黑树,但是跳表并没有一个现成的实现,所以在开发中,如果你想使用跳表,必须要自己实现。</p><h2 id="内容小结"><a href="#内容小结" class="headerlink" title="内容小结"></a>内容小结</h2><p>今天我们讲了跳表这种数据结构。跳表使用空间换时间的设计思路,通过构建多级索引来提高查询的效率,实现了基于链表的“二分查找”。跳表是一种动态数据结构,支持快速的插入、删除、查找操作,时间复杂度都是 O(logn)。</p><p>跳表的空间复杂度是 O(n)。不过,跳表的实现非常灵活,可以通过改变索引构建策略,有效平衡执行效率和内存消耗。虽然跳表的代码实现并不简单,但是作为一种动态数据结构,比起红黑树来说,实现要简单多了。所以很多时候,我们为了代码的简单、易读,比起红黑树,我们更倾向用跳表。</p><h2 id="课后思考"><a href="#课后思考" class="headerlink" title="课后思考"></a>课后思考</h2><p>在今天的内容中,对于跳表的时间复杂度分析,我分析了每两个结点提取一个结点作为索引的时间复杂度。如果每三个或者五个结点提取一个结点作为上级索引,对应的在跳表中查询数据的时间复杂度是多少呢?</p>]]></content>
<summary type="html"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" cla</summary>
<category term="算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
</entry>
<entry>
<title>14|二分查找(下):如何快速定位IP对应的省份地址?</title>
<link href="https://chanmoyun.gitee.io/2024/03/07/Data-structure/14.%E4%BA%8C%E5%88%86%E6%9F%A5%E6%89%BE%EF%BC%88%E4%B8%8B%EF%BC%89%EF%BC%9A%E5%A6%82%E4%BD%95%E5%BF%AB%E9%80%9F%E5%AE%9A%E4%BD%8DIP%E5%AF%B9%E5%BA%94%E7%9A%84%E7%9C%81%E4%BB%BD%E5%9C%B0%E5%9D%80%EF%BC%9F/"/>
<id>https://chanmoyun.gitee.io/2024/03/07/Data-structure/14.%E4%BA%8C%E5%88%86%E6%9F%A5%E6%89%BE%EF%BC%88%E4%B8%8B%EF%BC%89%EF%BC%9A%E5%A6%82%E4%BD%95%E5%BF%AB%E9%80%9F%E5%AE%9A%E4%BD%8DIP%E5%AF%B9%E5%BA%94%E7%9A%84%E7%9C%81%E4%BB%BD%E5%9C%B0%E5%9D%80%EF%BC%9F/</id>
<published>2024-03-06T16:00:00.000Z</published>
<updated>2024-03-04T02:09:09.862Z</updated>
<content type="html"><![CDATA[<link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="\assets\js\Meting.min.js"></script><h1 id="二分查找(下):如何快速定位IP对应的省份地址?"><a href="#二分查找(下):如何快速定位IP对应的省份地址?" class="headerlink" title="二分查找(下):如何快速定位IP对应的省份地址?"></a>二分查找(下):如何快速定位IP对应的省份地址?</h1><h2 id="写在前面"><a href="#写在前面" class="headerlink" title="写在前面"></a>写在前面</h2><p>通过 IP 地址来查找 IP 归属地的功能,不知道你有没有用过?没用过也没关系,你现在可以打开百度,在搜索框里随便输一个 IP 地址,就会看到它的归属地。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/16%201.jpg" alt=""></p><p>这个功能并不复杂,它是通过维护一个很大的 IP 地址库来实现的。地址库中包括 IP 地址范围和归属地的对应关系。</p><p>当我们想要查询 202.102.133.13 这个 IP 地址的归属地时,我们就在地址库中搜索,发现这个 IP 地址落在[202.102.133.0, 202.102.133.255]这个地址范围内,那我们就可以将这个 IP 地址范围对应的归属地“山东东营市”显示给用户了。</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">[202.102.133.0, 202.102.133.255] 山东东营市 </span><br><span class="line">[202.102.135.0, 202.102.136.255] 山东烟台 </span><br><span class="line">[202.102.156.34, 202.102.157.255] 山东青岛 </span><br><span class="line">[202.102.48.0, 202.102.48.255] 江苏宿迁 </span><br><span class="line">[202.102.49.15, 202.102.51.251] 江苏泰州 </span><br><span class="line">[202.102.56.0, 202.102.56.255] 江苏连云港</span><br></pre></td></tr></table></figure></p><p>现在我的问题是,在庞大的地址库中逐一比对 IP 地址所在的区间,是非常耗时的。<strong>假设我们有 12 万条这样的 IP 区间与归属地的对应关系,如何快速定位出一个 IP 地址的归属地呢?</strong></p><p>是不是觉得比较难?不要紧,等学完今天的内容,你就会发现这个问题其实很简单。</p><p>上一节我讲了二分查找的原理,并且介绍了最简单的一种二分查找的代码实现。今天我们来讲几种二分查找的变形问题。</p><p>不知道你有没有听过这样一个说法:“十个二分九个错”。二分查找虽然原理极其简单,但是想要写出没有 Bug 的二分查找并不容易。</p><p>唐纳德·克努特(Donald E.Knuth)在《计算机程序设计艺术》的第 3 卷《排序和查找》中说到:“尽管第一个二分查找算法于 1946 年出现,然而第一个完全正确的二分查找算法实现直到 1962 年才出现。”</p><p>你可能会说,我们上一节学的二分查找的代码实现并不难写啊。那是因为上一节讲的只是二分查找中最简单的一种情况,在不存在重复元素的有序数组中,查找值等于给定值的元素。最简单的二分查找写起来确实不难,但是,二分查找的变形问题就没那么好写了。</p><p>二分查找的变形问题很多,我只选择几个典型的来讲解,其他的你可以借助我今天讲的思路自己来分析。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/16%202.jpg" alt=""></p><p>需要特别说明一点,为了简化讲解,今天的内容,我都以数据是从小到大排列为前提,如果你要处理的数据是从大到小排列的,解决思路也是一样的。同时,我希望你最好先自己动手试着写一下这 4 个变形问题,然后再看我的讲述,这样你就会对我说的“二分查找比较难写”有更加深的体会了。</p><h2 id="变体一:查找第一个值等于给定值的元素"><a href="#变体一:查找第一个值等于给定值的元素" class="headerlink" title="变体一:查找第一个值等于给定值的元素"></a>变体一:查找第一个值等于给定值的元素</h2><p>上一节中的二分查找是最简单的一种,即有序数据集合中不存在重复的数据,我们在其中查找值等于某个给定值的数据。如果我们将这个问题稍微修改下,有序数据集合中存在重复的数据,我们希望找到第一个值等于给定值的数据,这样之前的二分查找代码还能继续工作吗?</p><p>比如下面这样一个有序数组,其中,a[5],a[6],a[7]的值都等于 8,是重复的数据。我们希望查找第一个等于 8 的数据,也就是下标是 5 的元素。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/16%203.jpg" alt=""></p><p>如果我们用上一节课讲的二分查找的代码实现,首先拿 8 与区间的中间值 a[4]比较,8 比 6 大,于是在下标 5 到 9 之间继续查找。下标 5 和 9 的中间位置是下标 7,a[7]正好等于 8,所以代码就返回了。</p><p>尽管 a[7]也等于 8,但它并不是我们想要找的第一个等于 8 的元素,因为第一个值等于 8 的元素是数组下标为 5 的元素。我们上一节讲的二分查找代码就无法处理这种情况了。所以,针对这个变形问题,我们可以稍微改造一下上一节的代码。</p><p>100 个人写二分查找就会有 100 种写法。网上有很多关于变形二分查找的实现方法,有很多写得非常简洁,比如下面这个写法。但是,尽管简洁,理解起来却非常烧脑,也很容易写错。</p><p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="type">int</span> <span class="title function_">bsearch</span><span class="params">(<span class="type">int</span>[] a, <span class="type">int</span> n, <span class="type">int</span> value)</span> {</span><br><span class="line"> <span class="type">int</span> <span class="variable">low</span> <span class="operator">=</span> <span class="number">0</span>;</span><br><span class="line"> <span class="type">int</span> <span class="variable">high</span> <span class="operator">=</span> n - <span class="number">1</span>;</span><br><span class="line"> <span class="keyword">while</span> (low <= high) {</span><br><span class="line"> <span class="type">int</span> <span class="variable">mid</span> <span class="operator">=</span> low + ((high - low) >> <span class="number">1</span>);</span><br><span class="line"> <span class="keyword">if</span> (a[mid] >= value) {</span><br><span class="line"> high = mid - <span class="number">1</span>;</span><br><span class="line"> } <span class="keyword">else</span> {</span><br><span class="line"> low = mid + <span class="number">1</span>;</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">if</span> (low < n && a[low]==value) <span class="keyword">return</span> low;</span><br><span class="line"> <span class="keyword">else</span> <span class="keyword">return</span> -<span class="number">1</span>;</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>看完这个实现之后,你是不是觉得很不好理解?如果你只是死记硬背这个写法,我敢保证,过不了几天,你就会全都忘光,再让你写,90% 的可能会写错。所以,我换了一种实现方法,你看看是不是更容易理解呢?</p><p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="type">int</span> <span class="title function_">bsearch</span><span class="params">(<span class="type">int</span>[] a, <span class="type">int</span> n, <span class="type">int</span> value)</span> {</span><br><span class="line"> <span class="type">int</span> <span class="variable">low</span> <span class="operator">=</span> <span class="number">0</span>;</span><br><span class="line"> <span class="type">int</span> <span class="variable">high</span> <span class="operator">=</span> n - <span class="number">1</span>;</span><br><span class="line"> <span class="keyword">while</span> (low <= high) {</span><br><span class="line"> <span class="type">int</span> <span class="variable">mid</span> <span class="operator">=</span> low + ((high - low) >> <span class="number">1</span>);</span><br><span class="line"> <span class="keyword">if</span> (a[mid] > value) {</span><br><span class="line"> high = mid - <span class="number">1</span>;</span><br><span class="line"> } <span class="keyword">else</span> <span class="keyword">if</span> (a[mid] < value) {</span><br><span class="line"> low = mid + <span class="number">1</span>;</span><br><span class="line"> } <span class="keyword">else</span> {</span><br><span class="line"> <span class="keyword">if</span> ((mid == <span class="number">0</span>) || (a[mid - <span class="number">1</span>] != value)) <span class="keyword">return</span> mid;</span><br><span class="line"> <span class="keyword">else</span> high = mid - <span class="number">1</span>;</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">return</span> -<span class="number">1</span>;</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>我来稍微解释一下这段代码。a[mid]跟要查找的 value 的大小关系有三种情况:大于、小于、等于。对于 a[mid]>value 的情况,我们需要更新 high= mid-1;对于 a[mid]<value 的情况,我们需要更新 low=mid+1。这两点都很好理解。那当 a[mid]=value 的时候应该如何处理呢?</p><p>如果我们查找的是任意一个值等于给定值的元素,当 a[mid]等于要查找的值时,a[mid]就是我们要找的元素。但是,如果我们求解的是第一个值等于给定值的元素,当 a[mid]等于要查找的值时,我们就需要确认一下这个 a[mid]是不是第一个值等于给定值的元素。</p><p>我们重点看第 11 行代码。如果 mid 等于 0,那这个元素已经是数组的第一个元素,那它肯定是我们要找的;如果 mid 不等于 0,但 a[mid]的前一个元素 a[mid-1]不等于 value,那也说明 a[mid]就是我们要找的第一个值等于给定值的元素。</p><p>如果经过检查之后发现 a[mid]前面的一个元素 a[mid-1]也等于 value,那说明此时的 a[mid]肯定不是我们要查找的第一个值等于给定值的元素。那我们就更新 high=mid-1,因为要找的元素肯定出现在[low, mid-1]之间。</p><p>对比上面的两段代码,是不是下面那种更好理解?实际上,<strong>很多人都觉得变形的二分查找很难写,主要原因是太追求第一种那样完美、简洁的写法</strong>。而对于我们做工程开发的人来说,代码易读懂、没 Bug,其实更重要,所以我觉得第二种写法更好。</p><h2 id="变体二:查找最后一个值等于给定值的元素"><a href="#变体二:查找最后一个值等于给定值的元素" class="headerlink" title="变体二:查找最后一个值等于给定值的元素"></a>变体二:查找最后一个值等于给定值的元素</h2><p>前面的问题是查找第一个值等于给定值的元素,我现在把问题稍微改一下,查找最后一个值等于给定值的元素,又该如何做呢?</p><p>如果你掌握了前面的写法,那这个问题你应该很轻松就能解决。你可以先试着实现一下,然后跟我写的对比一下。</p><p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="type">int</span> <span class="title function_">bsearch</span><span class="params">(<span class="type">int</span>[] a, <span class="type">int</span> n, <span class="type">int</span> value)</span> {</span><br><span class="line"> <span class="type">int</span> <span class="variable">low</span> <span class="operator">=</span> <span class="number">0</span>;</span><br><span class="line"> <span class="type">int</span> <span class="variable">high</span> <span class="operator">=</span> n - <span class="number">1</span>;</span><br><span class="line"> <span class="keyword">while</span> (low <= high) {</span><br><span class="line"> <span class="type">int</span> <span class="variable">mid</span> <span class="operator">=</span> low + ((high - low) >> <span class="number">1</span>);</span><br><span class="line"> <span class="keyword">if</span> (a[mid] > value) {</span><br><span class="line"> high = mid - <span class="number">1</span>;</span><br><span class="line"> } <span class="keyword">else</span> <span class="keyword">if</span> (a[mid] < value) {</span><br><span class="line"> low = mid + <span class="number">1</span>;</span><br><span class="line"> } <span class="keyword">else</span> {</span><br><span class="line"> <span class="keyword">if</span> ((mid == n - <span class="number">1</span>) || (a[mid + <span class="number">1</span>] != value)) <span class="keyword">return</span> mid;</span><br><span class="line"> <span class="keyword">else</span> low = mid + <span class="number">1</span>;</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">return</span> -<span class="number">1</span>;</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>我们还是重点看第 11 行代码。如果 a[mid]这个元素已经是数组中的最后一个元素了,那它肯定是我们要找的;如果 a[mid]的后一个元素 a[mid+1]不等于 value,那也说明 a[mid]就是我们要找的最后一个值等于给定值的元素。</p><p>如果我们经过检查之后,发现 a[mid]后面的一个元素 a[mid+1]也等于 value,那说明当前的这个 a[mid]并不是最后一个值等于给定值的元素。我们就更新 low=mid+1,因为要找的元素肯定出现在[mid+1, high]之间。</p><h2 id="变体三:查找第一个大于等于给定值的元素"><a href="#变体三:查找第一个大于等于给定值的元素" class="headerlink" title="变体三:查找第一个大于等于给定值的元素"></a>变体三:查找第一个大于等于给定值的元素</h2><p>现在我们再来看另外一类变形问题。在有序数组中,查找第一个大于等于给定值的元素。比如,数组中存储的这样一个序列:3,4,6,7,10。如果查找第一个大于等于 5 的元素,那就是 6。</p><p>实际上,实现的思路跟前面的那两种变形问题的实现思路类似,代码写起来甚至更简洁。</p><p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="type">int</span> <span class="title function_">bsearch</span><span class="params">(<span class="type">int</span>[] a, <span class="type">int</span> n, <span class="type">int</span> value)</span> {</span><br><span class="line"> <span class="type">int</span> <span class="variable">low</span> <span class="operator">=</span> <span class="number">0</span>;</span><br><span class="line"> <span class="type">int</span> <span class="variable">high</span> <span class="operator">=</span> n - <span class="number">1</span>;</span><br><span class="line"> <span class="keyword">while</span> (low <= high) {</span><br><span class="line"> <span class="type">int</span> <span class="variable">mid</span> <span class="operator">=</span> low + ((high - low) >> <span class="number">1</span>);</span><br><span class="line"> <span class="keyword">if</span> (a[mid] >= value) {</span><br><span class="line"> <span class="keyword">if</span> ((mid == <span class="number">0</span>) || (a[mid - <span class="number">1</span>] < value)) <span class="keyword">return</span> mid;</span><br><span class="line"> <span class="keyword">else</span> high = mid - <span class="number">1</span>;</span><br><span class="line"> } <span class="keyword">else</span> {</span><br><span class="line"> low = mid + <span class="number">1</span>;</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">return</span> -<span class="number">1</span>;</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>如果 a[mid]小于要查找的值 value,那要查找的值肯定在[mid+1, high]之间,所以,我们更新 low=mid+1。</p><p>对于 a[mid]大于等于给定值 value 的情况,我们要先看下这个 a[mid]是不是我们要找的第一个值大于等于给定值的元素。如果 a[mid]前面已经没有元素,或者前面一个元素小于要查找的值 value,那 a[mid]就是我们要找的元素。这段逻辑对应的代码是第 7 行。</p><p>如果 a[mid-1]也大于等于要查找的值 value,那说明要查找的元素在[low, mid-1]之间,所以,我们将 high 更新为 mid-1。</p><h2 id="变体四:查找最后一个小于等于给定值的元素"><a href="#变体四:查找最后一个小于等于给定值的元素" class="headerlink" title="变体四:查找最后一个小于等于给定值的元素"></a>变体四:查找最后一个小于等于给定值的元素</h2><p>现在,我们来看最后一种二分查找的变形问题,查找最后一个小于等于给定值的元素。比如,数组中存储了这样一组数据:3,5,6,8,9,10。最后一个小于等于 7 的元素就是 6。是不是有点类似上面那一种?实际上,实现思路也是一样的。</p><p>有了前面的基础,你完全可以自己写出来了,所以我就不详细分析了。我把代码贴出来,你可以写完之后对比一下。</p><p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="type">int</span> <span class="title function_">bsearch7</span><span class="params">(<span class="type">int</span>[] a, <span class="type">int</span> n, <span class="type">int</span> value)</span> {</span><br><span class="line"> <span class="type">int</span> <span class="variable">low</span> <span class="operator">=</span> <span class="number">0</span>;</span><br><span class="line"> <span class="type">int</span> <span class="variable">high</span> <span class="operator">=</span> n - <span class="number">1</span>;</span><br><span class="line"> <span class="keyword">while</span> (low <= high) {</span><br><span class="line"> <span class="type">int</span> <span class="variable">mid</span> <span class="operator">=</span> low + ((high - low) >> <span class="number">1</span>);</span><br><span class="line"> <span class="keyword">if</span> (a[mid] > value) {</span><br><span class="line"> high = mid - <span class="number">1</span>;</span><br><span class="line"> } <span class="keyword">else</span> {</span><br><span class="line"> <span class="keyword">if</span> ((mid == n - <span class="number">1</span>) || (a[mid + <span class="number">1</span>] > value)) <span class="keyword">return</span> mid;</span><br><span class="line"> <span class="keyword">else</span> low = mid + <span class="number">1</span>;</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> <span class="keyword">return</span> -<span class="number">1</span>;</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><h2 id="解答开篇"><a href="#解答开篇" class="headerlink" title="解答开篇"></a>解答开篇</h2><p>好了,现在我们回头来看开篇的问题:如何快速定位出一个 IP 地址的归属地?</p><p>现在这个问题应该很简单了。如果 IP 区间与归属地的对应关系不经常更新,我们可以先预处理这 12 万条数据,让其按照起始 IP 从小到大排序。如何来排序呢?我们知道,IP 地址可以转化为 32 位的整型数。所以,我们可以将起始地址,按照对应的整型值的大小关系,从小到大进行排序。</p><p>然后,这个问题就可以转化为我刚讲的第四种变形问题“在有序数组中,查找最后一个小于等于某个给定值的元素”了。</p><p>当我们要查询某个 IP 归属地时,我们可以先通过二分查找,找到最后一个起始 IP 小于等于这个 IP 的 IP 区间,然后,检查这个 IP 是否在这个 IP 区间内,如果在,我们就取出对应的归属地显示;如果不在,就返回未查找到。</p><h2 id="内容小结"><a href="#内容小结" class="headerlink" title="内容小结"></a>内容小结</h2><p>上一节我说过,凡是用二分查找能解决的,绝大部分我们更倾向于用散列表或者二叉查找树。即便是二分查找在内存使用上更节省,但是毕竟内存如此紧缺的情况并不多。那二分查找真的没什么用处了吗?</p><p>实际上,上一节讲的求“值等于给定值”的二分查找确实不怎么会被用到,二分查找更适合用在“近似”查找问题,在这类问题上,二分查找的优势更加明显。比如今天讲的这几种变体问题,用其他数据结构,比如散列表、二叉树,就比较难实现了。</p><p>变体的二分查找算法写起来非常烧脑,很容易因为细节处理不好而产生 Bug,这些容易出错的细节有:<strong>终止条件、区间上下界更新方法、返回值选择</strong>。所以今天的内容你最好能用自己实现一遍,对锻炼编码能力、逻辑思维、写出 Bug free 代码,会很有帮助。</p><h2 id="课后思考"><a href="#课后思考" class="headerlink" title="课后思考"></a>课后思考</h2><p>我们今天讲的都是非常规的二分查找问题,今天的思考题也是一个非常规的二分查找问题。如果有序数组是一个循环有序数组,比如 4,5,6,1,2,3。针对这种情况,如何实现一个求“值等于给定值”的二分查找算法呢?</p>]]></content>
<summary type="html"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" cla</summary>
<category term="算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
</entry>
<entry>
<title>13|二分查找(上):如何用最省内存的方式实现快速查找功能?</title>
<link href="https://chanmoyun.gitee.io/2024/03/06/Data-structure/13.%E4%BA%8C%E5%88%86%E6%9F%A5%E6%89%BE%EF%BC%88%E4%B8%8A%EF%BC%89%EF%BC%9A%E5%A6%82%E4%BD%95%E7%94%A8%E6%9C%80%E7%9C%81%E5%86%85%E5%AD%98%E7%9A%84%E6%96%B9%E5%BC%8F%E5%AE%9E%E7%8E%B0%E5%BF%AB%E9%80%9F%E6%9F%A5%E6%89%BE%E5%8A%9F%E8%83%BD%EF%BC%9F/"/>
<id>https://chanmoyun.gitee.io/2024/03/06/Data-structure/13.%E4%BA%8C%E5%88%86%E6%9F%A5%E6%89%BE%EF%BC%88%E4%B8%8A%EF%BC%89%EF%BC%9A%E5%A6%82%E4%BD%95%E7%94%A8%E6%9C%80%E7%9C%81%E5%86%85%E5%AD%98%E7%9A%84%E6%96%B9%E5%BC%8F%E5%AE%9E%E7%8E%B0%E5%BF%AB%E9%80%9F%E6%9F%A5%E6%89%BE%E5%8A%9F%E8%83%BD%EF%BC%9F/</id>
<published>2024-03-05T16:00:00.000Z</published>
<updated>2024-03-04T01:40:38.806Z</updated>
<content type="html"><![CDATA[<link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="\assets\js\Meting.min.js"></script><h1 id="二分查找(上):如何用最省内存的方式实现快速查找功能?"><a href="#二分查找(上):如何用最省内存的方式实现快速查找功能?" class="headerlink" title="二分查找(上):如何用最省内存的方式实现快速查找功能?"></a>二分查找(上):如何用最省内存的方式实现快速查找功能?</h1><h2 id="写在前面"><a href="#写在前面" class="headerlink" title="写在前面"></a>写在前面</h2><p>今天我们讲一种针对有序数据集合的查找算法:二分查找(Binary Search)算法,也叫折半查找算法。二分查找的思想非常简单,很多非计算机专业的同学很容易就能理解,但是看似越简单的东西往往越难掌握好,想要灵活应用就更加困难。</p><p>老规矩,我们还是来看一道思考题。</p><p>假设我们有 1000 万个整数数据,每个数据占 8 个字节,如何设计数据结构和算法,快速判断某个整数是否出现在这 1000 万数据中? 我们希望这个功能不要占用太多的内存空间,最多不要超过 100MB,你会怎么做呢?带着这个问题,让我们进入今天的内容吧!</p><h2 id="无处不在的二分思想"><a href="#无处不在的二分思想" class="headerlink" title="无处不在的二分思想"></a>无处不在的二分思想</h2><p>二分查找是一种非常简单易懂的快速查找算法,生活中到处可见。比如说,我们现在来做一个猜字游戏。我随机写一个 0 到 99 之间的数字,然后你来猜我写的是什么。猜的过程中,你每猜一次,我就会告诉你猜的大了还是小了,直到猜中为止。你来想想,如何快速猜中我写的数字呢?</p><p>假设我写的数字是 23,你可以按照下面的步骤来试一试。(如果猜测范围的数字有偶数个,中间数有两个,就选择较小的那个。)</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/15%201.jpg" alt=""></p><p>7 次就猜出来了,是不是很快?这个例子用的就是二分思想,按照这个思想,即便我让你猜的是 0 到 999 的数字,最多也只要 10 次就能猜中。不信的话,你可以试一试。</p><p>这是一个生活中的例子,我们现在回到实际的开发场景中。假设有 1000 条订单数据,已经按照订单金额从小到大排序,每个订单金额都不同,并且最小单位是元。我们现在想知道是否存在金额等于 19 元的订单。如果存在,则返回订单数据,如果不存在则返回 null。</p><p>最简单的办法当然是从第一个订单开始,一个一个遍历这 1000 个订单,直到找到金额等于 19 元的订单为止。但这样查找会比较慢,最坏情况下,可能要遍历完这 1000 条记录才能找到。那用二分查找能不能更快速地解决呢?</p><p>为了方便讲解,我们假设只有 10 个订单,订单金额分别是:8,11,19,23,27,33,45,55,67,98。</p><p>还是利用二分思想,每次都与区间的中间数据比对大小,缩小查找区间的范围。为了更加直观,我画了一张查找过程的图。其中,low 和 high 表示待查找区间的下标,mid 表示待查找区间的中间元素下标。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/15%202.jpg" alt=""></p><p>看懂这两个例子,你现在对二分的思想应该掌握得妥妥的了。我这里稍微总结升华一下,<strong>二分查找针对的是一个有序的数据集合,查找思想有点类似分治思想。每次都通过跟区间的中间元素对比,将待查找的区间缩小为之前的一半,直到找到要查找的元素,或者区间被缩小为 0。</strong></p><h2 id="O-logn-惊人的查找速度"><a href="#O-logn-惊人的查找速度" class="headerlink" title="O(logn) 惊人的查找速度"></a>O(logn) 惊人的查找速度</h2><p>二分查找是一种非常高效的查找算法,高效到什么程度呢?我们来分析一下它的时间复杂度。</p><p>我们假设数据大小是 n,每次查找后数据都会缩小为原来的一半,也就是会除以 2。最坏情况下,直到查找区间被缩小为空,才停止。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/15%203.jpg" alt=""></p><p>可以看出来,这是一个等比数列。其中 n/2k=1 时,k 的值就是总共缩小的次数。而每一次缩小操作只涉及两个数据的大小比较,所以,经过了 k 次区间缩小操作,时间复杂度就是 O(k)。通过 n/2k=1,我们可以求得 k=log2n,所以时间复杂度就是 O(logn)。</p><p>二分查找是我们目前为止遇到的第一个时间复杂度为 O(logn) 的算法。后面章节我们还会讲堆、二叉树的操作等等,它们的时间复杂度也是 O(logn)。我这里就再深入地讲讲 O(logn) 这种<strong>对数时间复杂度</strong>。这是一种极其高效的时间复杂度,有的时候甚至比时间复杂度是常量级 O(1) 的算法还要高效。为什么这么说呢?</p><p>因为 logn 是一个非常“恐怖”的数量级,即便 n 非常非常大,对应的 logn 也很小。比如 n 等于 2 的 32 次方,这个数很大了吧?大约是 42 亿。也就是说,如果我们在 42 亿个数据中用二分查找一个数据,最多需要比较 32 次。</p><p>我们前面讲过,用大 O 标记法表示时间复杂度的时候,会省略掉常数、系数和低阶。对于常量级时间复杂度的算法来说,O(1) 有可能表示的是一个非常大的常量值,比如 O(1000)、O(10000)。所以,常量级时间复杂度的算法有时候可能还没有 O(logn) 的算法执行效率高。</p><p>反过来,对数对应的就是指数。有一个非常著名的“阿基米德与国王下棋的故事”,你可以自行搜索一下,感受一下指数的“恐怖”。这也是为什么我们说,指数时间复杂度的算法在大规模数据面前是无效的。</p><h2 id="二分查找的递归与非递归实现"><a href="#二分查找的递归与非递归实现" class="headerlink" title="二分查找的递归与非递归实现"></a>二分查找的递归与非递归实现</h2><p>实际上,简单的二分查找并不难写,注意我这里的“简单”二字。下一节,我们会讲到二分查找的变体问题,那才是真正烧脑的。今天,我们来看如何来写最简单的二分查找。</p><p>最简单的情况就是有序数组中不存在重复元素,我们在其中用二分查找值等于给定值的数据。我用 Java 代码实现了一个最简单的二分查找算法。</p><p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line"><span class="keyword">public</span> <span class="type">int</span> <span class="title function_">bsearch</span><span class="params">(<span class="type">int</span>[] a, <span class="type">int</span> n, <span class="type">int</span> value)</span> {</span><br><span class="line"> <span class="type">int</span> <span class="variable">low</span> <span class="operator">=</span> <span class="number">0</span>;</span><br><span class="line"> <span class="type">int</span> <span class="variable">high</span> <span class="operator">=</span> n - <span class="number">1</span>;</span><br><span class="line"></span><br><span class="line"> <span class="keyword">while</span> (low <= high) {</span><br><span class="line"> <span class="type">int</span> <span class="variable">mid</span> <span class="operator">=</span> (low + high) / <span class="number">2</span>;</span><br><span class="line"> <span class="keyword">if</span> (a[mid] == value) {</span><br><span class="line"> <span class="keyword">return</span> mid;</span><br><span class="line"> } <span class="keyword">else</span> <span class="keyword">if</span> (a[mid] < value) {</span><br><span class="line"> low = mid + <span class="number">1</span>;</span><br><span class="line"> } <span class="keyword">else</span> {</span><br><span class="line"> high = mid - <span class="number">1</span>;</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> <span class="keyword">return</span> -<span class="number">1</span>;</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>这个代码我稍微解释一下,low、high、mid 都是指数组下标,其中 low 和 high 表示当前查找的区间范围,初始 low=0, high=n-1。mid 表示[low, high]的中间位置。我们通过对比 a[mid]与 value 的大小,来更新接下来要查找的区间范围,直到找到或者区间缩小为 0,就退出。如果你有一些编程基础,看懂这些应该不成问题。现在,我就着重强调一下<strong>容易出错的 3 个地方。</strong></p><h3 id="1-循环退出条件"><a href="#1-循环退出条件" class="headerlink" title="1.循环退出条件"></a>1.循环退出条件</h3><p>注意是 low<=high,而不是 low>1。因为相比除法运算来说,计算机处理位运算要快得多。</p><h3 id="2-mid的取值"><a href="#2-mid的取值" class="headerlink" title="2.mid的取值"></a>2.mid的取值</h3><p>实际上,mid=(low+high)/2 这种写法是有问题的。因为如果 low 和 high 比较大的话,两者之和就有可能会溢出。改进的方法是将 mid 的计算方式写成 low+(high-low)/2。更进一步,如果要将性能优化到极致的话,我们可以将这里的除以 2 操作转化成位运算 low+((high-low)>>1)。因为相比除法运算来说,计算机处理位运算要快得多。</p><h3 id="3-low-和-high-的更新"><a href="#3-low-和-high-的更新" class="headerlink" title="3.low 和 high 的更新"></a>3.low 和 high 的更新</h3><p>low=mid+1,high=mid-1。注意这里的 +1 和 -1,如果直接写成 low=mid 或者 high=mid,就可能会发生死循环。比如,当 high=3,low=3 时,如果 a[3]不等于 value,就会导致一直循环不退出。</p><p>如果你留意我刚讲的这三点,我想一个简单的二分查找你已经可以实现了。实际上,<strong>二分查找除了用循环来实现,还可以用递归来实现</strong>,过程也非常简单。</p><p>我用 Java 语言实现了一下这个过程,正好你可以借此机会回顾一下写递归代码的技巧。</p><p><figure class="highlight java"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line"><span class="comment">// 二分查找的递归实现</span></span><br><span class="line"><span class="keyword">public</span> <span class="type">int</span> <span class="title function_">bsearch</span><span class="params">(<span class="type">int</span>[] a, <span class="type">int</span> n, <span class="type">int</span> val)</span> {</span><br><span class="line"> <span class="keyword">return</span> bsearchInternally(a, <span class="number">0</span>, n - <span class="number">1</span>, val);</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line"><span class="keyword">private</span> <span class="type">int</span> <span class="title function_">bsearchInternally</span><span class="params">(<span class="type">int</span>[] a, <span class="type">int</span> low, <span class="type">int</span> high, <span class="type">int</span> value)</span> {</span><br><span class="line"> <span class="keyword">if</span> (low > high) <span class="keyword">return</span> -<span class="number">1</span>;</span><br><span class="line"></span><br><span class="line"> <span class="type">int</span> <span class="variable">mid</span> <span class="operator">=</span> low + ((high - low) >> <span class="number">1</span>);</span><br><span class="line"> <span class="keyword">if</span> (a[mid] == value) {</span><br><span class="line"> <span class="keyword">return</span> mid;</span><br><span class="line"> } <span class="keyword">else</span> <span class="keyword">if</span> (a[mid] < value) {</span><br><span class="line"> <span class="keyword">return</span> bsearchInternally(a, mid+<span class="number">1</span>, high, value);</span><br><span class="line"> } <span class="keyword">else</span> {</span><br><span class="line"> <span class="keyword">return</span> bsearchInternally(a, low, mid-<span class="number">1</span>, value);</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><h2 id="二分查找应用场景的局限性"><a href="#二分查找应用场景的局限性" class="headerlink" title="二分查找应用场景的局限性"></a>二分查找应用场景的局限性</h2><p>前面我们分析过,二分查找的时间复杂度是 O(logn),查找数据的效率非常高。不过,并不是什么情况下都可以用二分查找,它的应用场景是有很大局限性的。那什么情况下适合用二分查找,什么情况下不适合呢?</p><h3 id="首先,二分查找依赖的是顺序表结构,简单点说就是数组。"><a href="#首先,二分查找依赖的是顺序表结构,简单点说就是数组。" class="headerlink" title="首先,二分查找依赖的是顺序表结构,简单点说就是数组。"></a>首先,二分查找依赖的是顺序表结构,简单点说就是数组。</h3><p>那二分查找能否依赖其他数据结构呢?比如链表。答案是不可以的,主要原因是二分查找算法需要按照下标随机访问元素。我们在数组和链表那两节讲过,数组按照下标随机访问数据的时间复杂度是 O(1),而链表随机访问的时间复杂度是 O(n)。所以,如果数据使用链表存储,二分查找的时间复杂就会变得很高。</p><p>二分查找只能用在数据是通过顺序表来存储的数据结构上。如果你的数据是通过其他数据结构存储的,则无法应用二分查找。</p><h3 id="其次,二分查找针对的是有序数据"><a href="#其次,二分查找针对的是有序数据" class="headerlink" title="其次,二分查找针对的是有序数据"></a>其次,二分查找针对的是有序数据</h3><p>二分查找对这一点的要求比较苛刻,数据必须是有序的。如果数据没有序,我们需要先排序。前面章节里我们讲到,排序的时间复杂度最低是 O(nlogn)。所以,如果我们针对的是一组静态的数据,没有频繁地插入、删除,我们可以进行一次排序,多次二分查找。这样排序的成本可被均摊,二分查找的边际成本就会比较低。</p><p>但是,如果我们的数据集合有频繁的插入和删除操作,要想用二分查找,要么每次插入、删除操作之后保证数据仍然有序,要么在每次二分查找之前都先进行排序。针对这种动态数据集合,无论哪种方法,维护有序的成本都是很高的。</p><p>所以,二分查找只能用在插入、删除操作不频繁,一次排序多次查找的场景中。针对动态变化的数据集合,二分查找将不再适用。那针对动态数据集合,如何在其中快速查找某个数据呢?别急,等到二叉树那一节我会详细讲。</p><h3 id="再次,数据量太小不适合二分查找。"><a href="#再次,数据量太小不适合二分查找。" class="headerlink" title="再次,数据量太小不适合二分查找。"></a>再次,数据量太小不适合二分查找。</h3><p>如果要处理的数据量很小,完全没有必要用二分查找,顺序遍历就足够了。比如我们在一个大小为 10 的数组中查找一个元素,不管用二分查找还是顺序遍历,查找速度都差不多。只有数据量比较大的时候,二分查找的优势才会比较明显。</p><p>不过,这里有一个例外。如果数据之间的比较操作非常耗时,不管数据量大小,我都推荐使用二分查找。比如,数组中存储的都是长度超过 300 的字符串,如此长的两个字符串之间比对大小,就会非常耗时。我们需要尽可能地减少比较次数,而比较次数的减少会大大提高性能,这个时候二分查找就比顺序遍历更有优势。</p><h3 id="最后,数据量太大也不适合二分查找"><a href="#最后,数据量太大也不适合二分查找" class="headerlink" title="最后,数据量太大也不适合二分查找"></a>最后,数据量太大也不适合二分查找</h3><p>二分查找的底层需要依赖数组这种数据结构,而数组为了支持随机访问的特性,要求内存空间连续,对内存的要求比较苛刻。比如,我们有 1GB 大小的数据,如果希望用数组来存储,那就需要 1GB 的连续内存空间。</p><p>注意这里的“连续”二字,也就是说,即便有 2GB 的内存空间剩余,但是如果这剩余的 2GB 内存空间都是零散的,没有连续的 1GB 大小的内存空间,那照样无法申请一个 1GB 大小的数组。而我们的二分查找是作用在数组这种数据结构之上的,所以太大的数据用数组存储就比较吃力了,也就不能用二分查找了。</p><h2 id="解答开篇"><a href="#解答开篇" class="headerlink" title="解答开篇"></a>解答开篇</h2><p>二分查找的理论知识你应该已经掌握了。我们来看下开篇的思考题:如何在 1000 万个整数中快速查找某个整数?</p><p>这个问题并不难。我们的内存限制是 100MB,每个数据大小是 8 字节,最简单的办法就是将数据存储在数组中,内存占用差不多是 80MB,符合内存的限制。借助今天讲的内容,我们可以先对这 1000 万数据从小到大排序,然后再利用二分查找算法,就可以快速地查找想要的数据了。</p><p>看起来这个问题并不难,很轻松就能解决。实际上,它暗藏了“玄机”。如果你对数据结构和算法有一定了解,知道散列表、二叉树这些支持快速查找的动态数据结构。你可能会觉得,用散列表和二叉树也可以解决这个问题。实际上是不行的。</p><p>虽然大部分情况下,用二分查找可以解决的问题,用散列表、二叉树都可以解决。但是,我们后面会讲,不管是散列表还是二叉树,都会需要比较多的额外的内存空间。如果用散列表或者二叉树来存储这 1000 万的数据,用 100MB 的内存肯定是存不下的。而二分查找底层依赖的是数组,除了数据本身之外,不需要额外存储其他信息,是最省内存空间的存储方式,所以刚好能在限定的内存大小下解决这个问题。</p><h2 id="内容小结"><a href="#内容小结" class="headerlink" title="内容小结"></a>内容小结</h2><p>今天我们学习了一种针对有序数据的高效查找算法,二分查找,它的时间复杂度是 O(logn)。</p><p>二分查找的核心思想理解起来非常简单,有点类似分治思想。即每次都通过跟区间中的中间元素对比,将待查找的区间缩小为一半,直到找到要查找的元素,或者区间被缩小为 0。但是二分查找的代码实现比较容易写错。你需要着重掌握它的三个容易出错的地方:循环退出条件、mid 的取值,low 和 high 的更新。</p><p>二分查找虽然性能比较优秀,但应用场景也比较有限。底层必须依赖数组,并且还要求数据是有序的。对于较小规模的数据查找,我们直接使用顺序遍历就可以了,二分查找的优势并不明显。二分查找更适合处理静态数据,也就是没有频繁的数据插入、删除操作。</p><h2 id="课后思考"><a href="#课后思考" class="headerlink" title="课后思考"></a>课后思考</h2><p>1.如何编程实现“求一个数的平方根”?要求精确到小数点后 6 位。</p><p>2.我刚才说了,如果数据使用链表存储,二分查找的时间复杂就会变得很高,那查找的时间复杂度究竟是多少呢?如果你自己推导一下,你就会深刻地认识到,为何我们会选择用数组而不是链表来实现二分查找了。</p>]]></content>
<summary type="html"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" cla</summary>
<category term="算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
</entry>
<entry>
<title>12|排序优化:如何实现一个通用的、高性能的排序函数?</title>
<link href="https://chanmoyun.gitee.io/2024/03/05/Data-structure/12.%E6%8E%92%E5%BA%8F%E4%BC%98%E5%8C%96%EF%BC%9A%E5%A6%82%E4%BD%95%E5%AE%9E%E7%8E%B0%E4%B8%80%E4%B8%AA%E9%80%9A%E7%94%A8%E7%9A%84%E3%80%81%E9%AB%98%E6%80%A7%E8%83%BD%E7%9A%84%E6%8E%92%E5%BA%8F%E5%87%BD%E6%95%B0%EF%BC%9F/"/>
<id>https://chanmoyun.gitee.io/2024/03/05/Data-structure/12.%E6%8E%92%E5%BA%8F%E4%BC%98%E5%8C%96%EF%BC%9A%E5%A6%82%E4%BD%95%E5%AE%9E%E7%8E%B0%E4%B8%80%E4%B8%AA%E9%80%9A%E7%94%A8%E7%9A%84%E3%80%81%E9%AB%98%E6%80%A7%E8%83%BD%E7%9A%84%E6%8E%92%E5%BA%8F%E5%87%BD%E6%95%B0%EF%BC%9F/</id>
<published>2024-03-04T16:00:00.000Z</published>
<updated>2024-03-04T01:32:03.612Z</updated>
<content type="html"><![CDATA[<link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="\assets\js\Meting.min.js"></script><h1 id="排序优化:如何实现一个通用的、高性能的排序函数?"><a href="#排序优化:如何实现一个通用的、高性能的排序函数?" class="headerlink" title="排序优化:如何实现一个通用的、高性能的排序函数?"></a>排序优化:如何实现一个通用的、高性能的排序函数?</h1><h2 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h2><p>几乎所有的编程语言都会提供排序函数,比如 C 语言中 qsort(),C++ STL 中的 sort()、stable_sort(),还有 Java 语言中的 Collections.sort()。在平时的开发中,我们也都是直接使用这些现成的函数来实现业务逻辑中的排序功能。那你知道这些排序函数是如何实现的吗?</p><p>底层都利用了哪种排序算法呢?基于这些问题,今天我们就来看排序这部分的最后一块内容:<strong>如何实现一个通用的、高性能的排序函数?</strong></p><h2 id="如何选择合适的排序算法?"><a href="#如何选择合适的排序算法?" class="headerlink" title="如何选择合适的排序算法?"></a>如何选择合适的排序算法?</h2><p>如果要实现一个通用的、高效率的排序函数,我们应该选择哪种排序算法?我们先回顾一下前面讲过的几种排序算法。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/14%201.jpg" alt=""></p><p>我们前面讲过,线性排序算法的时间复杂度比较低,适用场景比较特殊。所以如果要写一个通用的排序函数,不能选择线性排序算法。</p><p>如果对小规模数据进行排序,可以选择时间复杂度是 O(n2) 的算法;如果对大规模数据进行排序,时间复杂度是 O(nlogn) 的算法更加高效。所以,为了兼顾任意规模数据的排序,一般都会首选时间复杂度是 O(nlogn) 的排序算法来实现排序函数。</p><p>时间复杂度是 O(nlogn) 的排序算法不止一个,我们已经讲过的有归并排序、快速排序,后面讲堆的时候我们还会讲到堆排序。堆排序和快速排序都有比较多的应用,比如 Java 语言采用堆排序实现排序函数,C 语言使用快速排序实现排序函数。</p><p>不知道你有没有发现,使用归并排序的情况其实并不多。我们知道,快排在最坏情况下的时间复杂度是 O(n2),而归并排序可以做到平均情况、最坏情况下的时间复杂度都是 O(nlogn),从这点上看起来很诱人,那为什么它还是没能得到“宠信”呢?</p><p>还记得我们上一节讲的归并排序的空间复杂度吗?归并排序并不是原地排序算法,空间复杂度是 O(n)。所以,粗略点、夸张点讲,如果要排序 100MB 的数据,除了数据本身占用的内存之外,排序算法还要额外再占用 100MB 的内存空间,空间耗费就翻倍了。</p><p>前面我们讲到,快速排序比较适合来实现排序函数,但是,我们也知道,快速排序在最坏情况下的时间复杂度是 O(n2),如何来解决这个“复杂度恶化”的问题呢?</p><h2 id="如何优化快速排序?"><a href="#如何优化快速排序?" class="headerlink" title="如何优化快速排序?"></a>如何优化快速排序?</h2><p>我们先来看下,为什么最坏情况下快速排序的时间复杂度是 O(n2) 呢?我们前面讲过,如果数据原来就是有序的或者接近有序的,每次分区点都选择最后一个数据,那快速排序算法就会变得非常糟糕,时间复杂度就会退化为 O(n2)。实际上,<strong>这种 O(n2) 时间复杂度出现的主要原因还是因为我们分区点选的不够合理。</strong></p><p>那什么样的分区点是好的分区点呢?或者说如何来选择分区点呢?</p><p>最理想的分区点是:<strong>被分区点分开的两个分区中,数据的数量差不多。</strong></p><p>如果很粗暴地直接选择第一个或者最后一个数据作为分区点,不考虑数据的特点,肯定会出现之前讲的那样,在某些情况下,排序的最坏情况时间复杂度是 O(n2)。为了提高排序算法的性能,我们也要尽可能地让每次分区都比较平均。</p><p>我这里介绍两个比较常用、比较简单的分区算法,你可以直观地感受一下。</p><h3 id="1-三数取中法"><a href="#1-三数取中法" class="headerlink" title="1. 三数取中法"></a>1. 三数取中法</h3><p>我们从区间的首、尾、中间,分别取出一个数,然后对比大小,取这 3 个数的中间值作为分区点。这样每间隔某个固定的长度,取数据出来比较,将中间值作为分区点的分区算法,肯定要比单纯取某一个数据更好。但是,如果要排序的数组比较大,那“三数取中”可能就不够了,可能要“五数取中”或者“十数取中”。</p><h3 id="2-随机法"><a href="#2-随机法" class="headerlink" title="2. 随机法"></a>2. 随机法</h3><p>随机法就是每次从要排序的区间中,随机选择一个元素作为分区点。这种方法并不能保证每次分区点都选的比较好,但是从概率的角度来看,也不大可能会出现每次分区点都选的很差的情况,所以平均情况下,这样选的分区点是比较好的。时间复杂度退化为最糟糕的 O(n2) 的情况,出现的可能性不大。</p><p>好了,我这里也只是抛砖引玉,如果想了解更多寻找分区点的方法,你可以自己课下深入去学习一下。</p><p>我们知道,快速排序是用递归来实现的。我们在递归那一节讲过,递归要警惕堆栈溢出。为了避免快速排序里,递归过深而堆栈过小,导致堆栈溢出,我们有两种解决办法:第一种是限制递归深度。一旦递归过深,超过了我们事先设定的阈值,就停止递归。第二种是通过在堆上模拟实现一个函数调用栈,手动模拟递归压栈、出栈的过程,这样就没有了系统栈大小的限制。</p><h2 id="举例分析排序函数"><a href="#举例分析排序函数" class="headerlink" title="举例分析排序函数"></a>举例分析排序函数</h2><p>为了让你对如何实现一个排序函数有一个更直观的感受,我拿 Glibc 中的 qsort() 函数举例说明一下。虽说 qsort() 从名字上看,很像是基于快速排序算法实现的,实际上它并不仅仅用了快排这一种算法。</p><p>如果你去看源码,你就会发现,<strong>qsort() 会优先使用归并排序来排序输入数据</strong>,因为归并排序的空间复杂度是 O(n),所以对于小数据量的排序,比如 1KB、2KB 等,归并排序额外需要 1KB、2KB 的内存空间,这个问题不大。现在计算机的内存都挺大的,我们很多时候追求的是速度。还记得我们前面讲过的用空间换时间的技巧吗?这就是一个典型的应用。</p><p>但如果数据量太大,就跟我们前面提到的,排序 100MB 的数据,这个时候我们再用归并排序就不合适了。所以,<strong>要排序的数据量比较大的时候,qsort() 会改为用快速排序算法来排序。</strong></p><p>那 qsort() 是如何选择快速排序算法的分区点的呢?如果去看源码,你就会发现,qsort() 选择分区点的方法就是“三数取中法”。是不是也并不复杂?</p><p>还有我们前面提到的递归太深会导致堆栈溢出的问题,qsort() 是通过自己实现一个堆上的栈,手动模拟递归来解决的。我们之前在讲递归那一节也讲过,不知道你还有没有印象?</p><p>实际上,qsort() 并不仅仅用到了归并排序和快速排序,它还用到了插入排序。在快速排序的过程中,当要排序的区间中,元素的个数小于等于 4 时,qsort() 就退化为插入排序,不再继续用递归来做快速排序,因为我们前面也讲过,在小规模数据面前,<strong>O(n2) 时间复杂度的算法并不一定比 O(nlogn) 的算法执行时间长。</strong>我们现在就来分析下这个说法。</p><p>我们在讲复杂度分析的时候讲过,算法的性能可以通过时间复杂度来分析,但是,这种复杂度分析是比较偏理论的,如果我们深究的话,实际上时间复杂度并不等于代码实际的运行时间。</p><p>时间复杂度代表的是一个增长趋势,如果画成增长曲线图,你会发现 O(n2) 比 O(nlogn) 要陡峭,也就是说增长趋势要更猛一些。但是,我们前面讲过,在大 O 复杂度表示法中,我们会省略低阶、系数和常数,也就是说,O(nlogn) 在没有省略低阶、系数、常数之前可能是 O(knlogn + c),而且 k 和 c 有可能还是一个比较大的数。</p><p>假设 k=1000,c=200,当我们对小规模数据(比如 n=100)排序时,n2的值实际上比 knlogn+c 还要小。</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">knlogn+c = 1000 * 100 * log100 + 200 远大于10000</span><br><span class="line"></span><br><span class="line">n^2 = 100*100 = 10000</span><br></pre></td></tr></table></figure></p><p>所以,对于小规模数据的排序,O(n2) 的排序算法并不一定比 O(nlogn) 排序算法执行的时间长。对于小数据量的排序,我们选择比较简单、不需要递归的插入排序算法。</p><p>还记得我们之前讲到的哨兵来简化代码,提高执行效率吗?在 qsort() 插入排序的算法实现中,也利用了这种编程技巧。虽然哨兵可能只是少做一次判断,但是毕竟排序函数是非常常用、非常基础的函数,性能的优化要做到极致。</p><p>好了,C 语言的 qsort() 我已经分析完了,你有没有觉得其实也不是很难?基本上都是用了我们前面讲到的知识点,有了前面的知识积累,看一些底层的类库的时候是不是也更容易了呢?</p><h2 id="内容小结"><a href="#内容小结" class="headerlink" title="内容小结"></a>内容小结</h2><p>今天我带你分析了一下如何来实现一个工业级的通用的、高效的排序函数,内容比较偏实战,而且贯穿了一些前面几节的内容,你要多看几遍。我们大部分排序函数都是采用 O(nlogn) 排序算法来实现,但是为了尽可能地提高性能,会做很多优化。</p><p>我还着重讲了快速排序的一些优化策略,比如合理选择分区点、避免递归太深等等。最后,我还带你分析了一个 C 语言中 qsort() 的底层实现原理,希望你对此能有一个更加直观的感受。</p><h2 id="课后思考"><a href="#课后思考" class="headerlink" title="课后思考"></a>课后思考</h2><p>在今天的内容中,我分析了 C 语言的中的 qsort() 的底层排序算法,你能否分析一下你所熟悉的语言中的排序函数都是用什么排序算法实现的呢?都有哪些优化技巧?</p>]]></content>
<summary type="html"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" cla</summary>
<category term="算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
</entry>
<entry>
<title>11|线性排序:如何根据年龄给100万用户数据排序?</title>
<link href="https://chanmoyun.gitee.io/2024/03/04/Data-structure/11.%E7%BA%BF%E6%80%A7%E6%8E%92%E5%BA%8F%EF%BC%9A%E5%A6%82%E4%BD%95%E6%A0%B9%E6%8D%AE%E5%B9%B4%E9%BE%84%E7%BB%99100%E4%B8%87%E7%94%A8%E6%88%B7%E6%95%B0%E6%8D%AE%E6%8E%92%E5%BA%8F%EF%BC%9F/"/>
<id>https://chanmoyun.gitee.io/2024/03/04/Data-structure/11.%E7%BA%BF%E6%80%A7%E6%8E%92%E5%BA%8F%EF%BC%9A%E5%A6%82%E4%BD%95%E6%A0%B9%E6%8D%AE%E5%B9%B4%E9%BE%84%E7%BB%99100%E4%B8%87%E7%94%A8%E6%88%B7%E6%95%B0%E6%8D%AE%E6%8E%92%E5%BA%8F%EF%BC%9F/</id>
<published>2024-03-03T16:00:00.000Z</published>
<updated>2024-03-04T01:46:18.462Z</updated>
<content type="html"><![CDATA[<link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="\assets\js\Meting.min.js"></script><h1 id="线性排序:如何根据年龄给100万用户数据排序?"><a href="#线性排序:如何根据年龄给100万用户数据排序?" class="headerlink" title="线性排序:如何根据年龄给100万用户数据排序?"></a>线性排序:如何根据年龄给100万用户数据排序?</h1><h2 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h2><p>上两节中,我带你着重分析了几种常用排序算法的原理、时间复杂度、空间复杂度、稳定性等。今天,我会讲三种时间复杂度是 O(n) 的排序算法:桶排序、计数排序、基数排序。因为这些排序算法的时间复杂度是线性的,所以我们把这类排序算法叫作<strong>线性排序</strong>(Linear sort)。之所以能做到线性的时间复杂度,主要原因是,这三个算法是非基于比较的排序算法,都不涉及元素之间的比较操作。</p><p>这几种排序算法理解起来都不难,时间、空间复杂度分析起来也很简单,但是对要排序的数据要求很苛刻,所以我们<strong>今天学习重点的是掌握这些排序算法的适用场景。</strong></p><p>按照惯例,我先给你出一道思考题:<strong>如何根据年龄给 100 万用户排序?</strong> 你可能会说,我用上一节课讲的归并、快排就可以搞定啊!是的,它们也可以完成功能,但是时间复杂度最低也是 O(nlogn)。有没有更快的排序方法呢?让我们一起进入今天的内容!</p><h2 id="桶排序(Bucket-sort)"><a href="#桶排序(Bucket-sort)" class="headerlink" title="桶排序(Bucket sort)"></a>桶排序(Bucket sort)</h2><p>首先,我们来看桶排序。桶排序,顾名思义,会用到“桶”,核心思想是将要排序的数据分到几个有序的桶里,每个桶里的数据再单独进行排序。桶内排完序之后,再把每个桶里的数据按照顺序依次取出,组成的序列就是有序的了。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/13%201.jpg" alt=""></p><p>桶排序的时间复杂度为什么是 O(n) 呢?</p><p>我们一块儿来分析一下。如果要排序的数据有 n 个,我们把它们均匀地划分到 m 个桶内,每个桶里就有 k=n/m 个元素。每个桶内部使用快速排序,时间复杂度为 O(k <em> logk)。m 个桶排序的时间复杂度就是 O(m </em> k <em> logk),因为 k=n/m,所以整个桶排序的时间复杂度就是 O(n</em>log(n/m))。当桶的个数 m 接近数据个数 n 时,log(n/m) 就是一个非常小的常量,这个时候桶排序的时间复杂度接近 O(n)。</p><p><strong>桶排序看起来很优秀,那它是不是可以替代我们之前讲的排序算法呢?</strong></p><p>答案当然是否定的。为了让你轻松理解桶排序的核心思想,我刚才做了很多假设。实际上,桶排序对要排序数据的要求是非常苛刻的。</p><p>首先,要排序的数据需要很容易就能划分成 m 个桶,并且,桶与桶之间有着天然的大小顺序。这样每个桶内的数据都排序完之后,桶与桶之间的数据不需要再进行排序。</p><p>其次,数据在各个桶之间的分布是比较均匀的。如果数据经过桶的划分之后,有些桶里的数据非常多,有些非常少,很不平均,那桶内数据排序的时间复杂度就不是常量级了。在极端情况下,如果数据都被划分到一个桶里,那就退化为 O(nlogn) 的排序算法了。</p><p><strong>桶排序比较适合用在外部排序中。</strong>所谓的外部排序就是数据存储在外部磁盘中,数据量比较大,内存有限,无法将数据全部加载到内存中。</p><p>比如说我们有 10GB 的订单数据,我们希望按订单金额(假设金额都是正整数)进行排序,但是我们的内存有限,只有几百 MB,没办法一次性把 10GB 的数据都加载到内存中。这个时候该怎么办呢?</p><p>现在我来讲一下,如何借助桶排序的处理思想来解决这个问题。</p><p>我们可以先扫描一遍文件,看订单金额所处的数据范围。假设经过扫描之后我们得到,订单金额最小是 1 元,最大是 10 万元。我们将所有订单根据金额划分到 100 个桶里,第一个桶我们存储金额在 1 元到 1000 元之内的订单,第二桶存储金额在 1001 元到 2000 元之内的订单,以此类推。每一个桶对应一个文件,并且按照金额范围的大小顺序编号命名(00,01,02…99)。</p><p>理想的情况下,如果订单金额在 1 到 10 万之间均匀分布,那订单会被均匀划分到 100 个文件中,每个小文件中存储大约 100MB 的订单数据,我们就可以将这 100 个小文件依次放到内存中,用快排来排序。等所有文件都排好序之后,我们只需要按照文件编号,从小到大依次读取每个小文件中的订单数据,并将其写入到一个文件中,那这个文件中存储的就是按照金额从小到大排序的订单数据了。</p><p>不过,你可能也发现了,订单按照金额在 1 元到 10 万元之间并不一定是均匀分布的 ,所以 10GB 订单数据是无法均匀地被划分到 100 个文件中的。有可能某个金额区间的数据特别多,划分之后对应的文件就会很大,没法一次性读入内存。这又该怎么办呢?</p><p>针对这些划分之后还是比较大的文件,我们可以继续划分,比如,订单金额在 1 元到 1000 元之间的比较多,我们就将这个区间继续划分为 10 个小区间,1 元到 100 元,101 元到 200 元,201 元到 300 元…901 元到 1000 元。如果划分之后,101 元到 200 元之间的订单还是太多,无法一次性读入内存,那就继续再划分,直到所有的文件都能读入内存为止。</p><h2 id="计数排序(Counting-sort)"><a href="#计数排序(Counting-sort)" class="headerlink" title="计数排序(Counting sort)"></a>计数排序(Counting sort)</h2><p>我个人觉得,计数排序其实是桶排序的一种特殊情况。当要排序的 n 个数据,所处的范围并不大的时候,比如最大值是 k,我们就可以把数据划分成 k 个桶。每个桶内的数据值都是相同的,省掉了桶内排序的时间。</p><p>我们都经历过高考,高考查分数系统你还记得吗?我们查分数的时候,系统会显示我们的成绩以及所在省的排名。如果你所在的省有 50 万考生,如何通过成绩快速排序得出名次呢?</p><p>考生的满分是 900 分,最小是 0 分,这个数据的范围很小,所以我们可以分成 901 个桶,对应分数从 0 分到 900 分。根据考生的成绩,我们将这 50 万考生划分到这 901 个桶里。桶内的数据都是分数相同的考生,所以并不需要再进行排序。我们只需要依次扫描每个桶,将桶内的考生依次输出到一个数组中,就实现了 50 万考生的排序。因为只涉及扫描遍历操作,所以时间复杂度是 O(n)。</p><p>计数排序的算法思想就是这么简单,跟桶排序非常类似,只是桶的大小粒度不一样。<strong>不过,为什么这个排序算法叫“计数”排序呢?“计数”的含义来自哪里呢?</strong></p><p>想弄明白这个问题,我们就要来看计数排序算法的实现方法。我还拿考生那个例子来解释。为了方便说明,我对数据规模做了简化。假设只有 8 个考生,分数在 0 到 5 分之间。这 8 个考生的成绩我们放在一个数组 A[8]中,它们分别是:2,5,3,0,2,3,0,3。</p><p>考生的成绩从 0 到 5 分,我们使用大小为 6 的数组 C[6]表示桶,其中下标对应分数。不过,C[6]内存储的并不是考生,而是对应的考生个数。像我刚刚举的那个例子,我们只需要遍历一遍考生分数,就可以得到 C[6]的值。</p><p>从图中可以看出,分数为 3 分的考生有 3 个,小于 3 分的考生有 4 个,所以,成绩为 3 分的考生在排序之后的有序数组 R[8]中,会保存下标 4,5,6 的位置。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/13%202.jpg" alt=""></p><p>那我们如何快速计算出,每个分数的考生在有序数组中对应的存储位置呢?这个处理方法非常巧妙,很不容易想到。</p><p>思路是这样的:我们对 C[6]数组顺序求和,C[6]存储的数据就变成了下面这样子。C[k]里存储小于等于分数 k 的考生个数。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/13%203.jpg" alt=""></p><p>有了前面的数据准备之后,现在我就要讲计数排序中最复杂、最难理解的一部分了,请集中精力跟着我的思路!</p><p>我们从后到前依次扫描数组 A。比如,当扫描到 3 时,我们可以从数组 C 中取出下标为 3 的值 7,也就是说,到目前为止,包括自己在内,分数小于等于 3 的考生有 7 个,也就是说 3 是数组 R 中的第 7 个元素(也就是数组 R 中下标为 6 的位置)。当 3 放入到数组 R 中后,小于等于 3 的元素就只剩下了 6 个了,所以相应的 C[3]要减 1,变成 6。</p><p>以此类推,当我们扫描到第 2 个分数为 3 的考生的时候,就会把它放入数组 R 中的第 6 个元素的位置(也就是下标为 5 的位置)。当我们扫描完整个数组 A 后,数组 R 内的数据就是按照分数从小到大有序排列的了。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/13%204.jpg" alt=""></p><p>上面的过程有点复杂,我写成了代码,你可以对照着看下</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br><span class="line">38</span><br><span class="line">39</span><br><span class="line">40</span><br><span class="line">41</span><br><span class="line">42</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">// 计数排序,a是数组,n是数组大小。假设数组中存储的都是非负整数。</span><br><span class="line">public void countingSort(int[] a, int n) {</span><br><span class="line"> if (n <= 1) return;</span><br><span class="line"></span><br><span class="line"> // 查找数组中数据的范围</span><br><span class="line"> int max = a[0];</span><br><span class="line"> for (int i = 1; i < n; ++i) {</span><br><span class="line"> if (max < a[i]) {</span><br><span class="line"> max = a[i];</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> int[] c = new int[max + 1]; // 申请一个计数数组c,下标大小[0,max]</span><br><span class="line"> for (int i = 0; i <= max; ++i) {</span><br><span class="line"> c[i] = 0;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> // 计算每个元素的个数,放入c中</span><br><span class="line"> for (int i = 0; i < n; ++i) {</span><br><span class="line"> c[a[i]]++;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> // 依次累加</span><br><span class="line"> for (int i = 1; i <= max; ++i) {</span><br><span class="line"> c[i] = c[i-1] + c[i];</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> // 临时数组r,存储排序之后的结果</span><br><span class="line"> int[] r = new int[n];</span><br><span class="line"> // 计算排序的关键步骤,有点难理解</span><br><span class="line"> for (int i = n - 1; i >= 0; --i) {</span><br><span class="line"> int index = c[a[i]]-1;</span><br><span class="line"> r[index] = a[i];</span><br><span class="line"> c[a[i]]--;</span><br><span class="line"> }</span><br><span class="line"></span><br><span class="line"> // 将结果拷贝给a数组</span><br><span class="line"> for (int i = 0; i < n; ++i) {</span><br><span class="line"> a[i] = r[i];</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>这种利用另外一个数组来计数的实现方式是不是很巧妙呢?这也是为什么这种排序算法叫计数排序的原因。不过,你千万不要死记硬背上面的排序过程,重要的是理解和会用。</p><p>我总结一下,<strong>计数排序只能用在数据范围不大的场景中,如果数据范围 k 比要排序的数据 n 大很多,就不适合用计数排序了。而且,计数排序只能给非负整数排序,如果要排序的数据是其他类型的,要将其在不改变相对大小的情况下,转化为非负整数。</strong></p><p>比如,还是拿考生这个例子。如果考生成绩精确到小数后一位,我们就需要将所有的分数都先乘以 10,转化成整数,然后再放到 9010 个桶内。再比如,如果要排序的数据中有负数,数据的范围是[-1000, 1000],那我们就需要先对每个数据都加 1000,转化成非负整数。</p><h2 id="基数排序(Radix-sort)"><a href="#基数排序(Radix-sort)" class="headerlink" title="基数排序(Radix sort)"></a>基数排序(Radix sort)</h2><p>我们再来看这样一个排序问题。假设我们有 10 万个手机号码,希望将这 10 万个手机号码从小到大排序,你有什么比较快速的排序方法呢?</p><p>我们之前讲的快排,时间复杂度可以做到 O(nlogn),还有更高效的排序算法吗?桶排序、计数排序能派上用场吗?手机号码有 11 位,范围太大,显然不适合用这两种排序算法。针对这个排序问题,有没有时间复杂度是 O(n) 的算法呢?现在我就来介绍一种新的排序算法,基数排序。</p><p>刚刚这个问题里有这样的规律:假设要比较两个手机号码 a,b 的大小,如果在前面几位中,a 手机号码已经比 b 手机号码大了,那后面的几位就不用看了。</p><p>借助稳定排序算法,这里有一个巧妙的实现思路。还记得我们第 11 节中,在阐述排序算法的稳定性的时候举的订单的例子吗?我们这里也可以借助相同的处理思路,先按照最后一位来排序手机号码,然后,再按照倒数第二位重新排序,以此类推,最后按照第一位重新排序。经过 11 次排序之后,手机号码就都有序了。</p><p>手机号码稍微有点长,画图比较不容易看清楚,我用字符串排序的例子,画了一张基数排序的过程分解图,你可以看下。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/13%205.jpg" alt=""></p><p>注意,这里按照每位来排序的排序算法要是稳定的,否则这个实现思路就是不正确的。因为如果是非稳定排序算法,那最后一次排序只会考虑最高位的大小顺序,完全不管其他位的大小关系,那么低位的排序就完全没有意义了。</p><p>根据每一位来排序,我们可以用刚讲过的桶排序或者计数排序,它们的时间复杂度可以做到 O(n)。如果要排序的数据有 k 位,那我们就需要 k 次桶排序或者计数排序,总的时间复杂度是 O(k*n)。当 k 不大的时候,比如手机号码排序的例子,k 最大就是 11,所以基数排序的时间复杂度就近似于 O(n)。</p><p>实际上,有时候要排序的数据并不都是等长的,比如我们排序牛津字典中的 20 万个英文单词,最短的只有 1 个字母,最长的我特意去查了下,有 45 个字母,中文翻译是尘肺病。对于这种不等长的数据,基数排序还适用吗?</p><p>实际上,<strong>我们可以把所有的单词补齐到相同长度,位数不够的可以在后面补“0”,</strong>因为根据ASCII 值,所有字母都大于“0”,所以补“0”不会影响到原有的大小顺序。这样就可以继续用基数排序了。</p><p>我来总结一下,<strong>基数排序对要排序的数据是有要求的,需要可以分割出独立的“位”来比较,而且位之间有递进的关系,如果 a 数据的高位比 b 数据大,那剩下的低位就不用比较了。除此之外,每一位的数据范围不能太大,要可以用线性排序算法来排序,否则,基数排序的时间复杂度就无法做到 O(n) 了。</strong></p><h2 id="解答开篇"><a href="#解答开篇" class="headerlink" title="解答开篇"></a>解答开篇</h2><p>今天的内容学完了。我们再回过头来看看开篇的思考题:如何根据年龄给 100 万用户排序?现在思考题是不是变得非常简单了呢?我来说一下我的解决思路。</p><p>实际上,根据年龄给 100 万用户排序,就类似按照成绩给 50 万考生排序。我们假设年龄的范围最小 1 岁,最大不超过 120 岁。我们可以遍历这 100 万用户,根据年龄将其划分到这 120 个桶里,然后依次顺序遍历这 120 个桶中的元素。这样就得到了按照年龄排序的 100 万用户数据。</p><h2 id="内容小结"><a href="#内容小结" class="headerlink" title="内容小结"></a>内容小结</h2><p>今天,我们学习了 3 种线性时间复杂度的排序算法,有桶排序、计数排序、基数排序。它们对要排序的数据都有比较苛刻的要求,应用不是非常广泛。但是如果数据特征比较符合这些排序算法的要求,应用这些算法,会非常高效,线性时间复杂度可以达到 O(n)。</p><p>桶排序和计数排序的排序思想是非常相似的,都是针对范围不大的数据,将数据划分成不同的桶来实现排序。基数排序要求数据可以划分成高低位,位之间有递进关系。比较两个数,我们只需要比较高位,高位相同的再比较低位。而且每一位的数据范围不能太大,因为基数排序算法需要借助桶排序或者计数排序来完成每一个位的排序工作。</p><h2 id="课后思考"><a href="#课后思考" class="headerlink" title="课后思考"></a>课后思考</h2><p>我们今天讲的都是针对特殊数据的排序算法。实际上,还有很多看似是排序但又不需要使用排序算法就能处理的排序问题。</p><p>假设我们现在需要对 D,a,F,B,c,A,z 这个字符串进行排序,要求将其中所有小写字母都排在大写字母的前面,但小写字母内部和大写字母内部不要求有序。比如经过排序之后为 a,c,z,D,F,B,A,这个如何来实现呢?如果字符串中存储的不仅有大小写字母,还有数字。要将小写字母的放到前面,大写字母放在最后,数字放在中间,不用排序算法,又该怎么解决呢?</p>]]></content>
<summary type="html"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" cla</summary>
<category term="算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
</entry>
<entry>
<title>10|排序(下):如何用快排思想在O(n)内查找第K大元素?</title>
<link href="https://chanmoyun.gitee.io/2024/03/03/Data-structure/10.%E6%8E%92%E5%BA%8F%EF%BC%88%E4%B8%8B%EF%BC%89%EF%BC%9A%E5%A6%82%E4%BD%95%E7%94%A8%E5%BF%AB%E6%8E%92%E6%80%9D%E6%83%B3%E5%9C%A8O(n)%E5%86%85%E6%9F%A5%E6%89%BE%E7%AC%ACK%E5%A4%A7%E5%85%83%E7%B4%A0%EF%BC%9F/"/>
<id>https://chanmoyun.gitee.io/2024/03/03/Data-structure/10.%E6%8E%92%E5%BA%8F%EF%BC%88%E4%B8%8B%EF%BC%89%EF%BC%9A%E5%A6%82%E4%BD%95%E7%94%A8%E5%BF%AB%E6%8E%92%E6%80%9D%E6%83%B3%E5%9C%A8O(n)%E5%86%85%E6%9F%A5%E6%89%BE%E7%AC%ACK%E5%A4%A7%E5%85%83%E7%B4%A0%EF%BC%9F/</id>
<published>2024-03-02T16:00:00.000Z</published>
<updated>2024-03-01T12:29:15.793Z</updated>
<content type="html"><![CDATA[<link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="\assets\js\Meting.min.js"></script><h1 id="排序(下):如何用快排思想在O-n-内查找第K大元素?"><a href="#排序(下):如何用快排思想在O-n-内查找第K大元素?" class="headerlink" title="排序(下):如何用快排思想在O(n)内查找第K大元素?"></a>排序(下):如何用快排思想在O(n)内查找第K大元素?</h1><h2 id="前言"><a href="#前言" class="headerlink" title="前言"></a>前言</h2><p>上一节我讲了冒泡排序、插入排序、选择排序这三种排序算法,它们的时间复杂度都是 O(n2),比较高,适合小规模数据的排序。今天,我讲两种时间复杂度为 O(nlogn) 的排序算法,<strong>归并排序</strong>和<strong>快速排序</strong>。这两种排序算法适合大规模的数据排序,比上一节讲的那三种排序算法要更常用。</p><p>归并排序和快速排序都用到了分治思想,非常巧妙。我们可以借鉴这个思想,来解决非排序的问题,比如:<strong>如何在 O(n) 的时间复杂度内查找一个无序数组中的第 K 大元素?</strong> 这就要用到我们今天要讲的内容。</p><h2 id="归并排序的原理"><a href="#归并排序的原理" class="headerlink" title="归并排序的原理"></a>归并排序的原理</h2><p>我们先来看<strong>归并排序</strong>(Merge Sort)。</p><p>归并排序的核心思想还是蛮简单的。如果要排序一个数组,我们先把数组从中间分成前后两部分,然后对前后两部分分别排序,再将排好序的两部分合并在一起,这样整个数组就都有序了。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/12%20%201.jpg" alt=""></p><p>归并排序使用的就是<strong>分治思想</strong>。分治,顾名思义,就是分而治之,将一个大问题分解成小的子问题来解决。小的子问题解决了,大问题也就解决了。</p><p>从我刚才的描述,你有没有感觉到,分治思想跟我们前面讲的递归思想很像。是的,分治算法一般都是用递归来实现的。<strong>分治是一种解决问题的处理思想,递归是一种编程技巧</strong>,这两者并不冲突。分治算法的思想我后面会有专门的一节来讲,现在不展开讨论,我们今天的重点还是排序算法。</p><p>前面我通过举例让你对归并有了一个感性的认识,又告诉你,归并排序用的是分治思想,可以用递归来实现。我们现在就来看看<strong>如何用递归代码来实现归并排序。</strong></p><p>我在第 10 节讲的递归代码的编写技巧你还记得吗?写递归代码的技巧就是,分析得出递推公式,然后找到终止条件,最后将递推公式翻译成递归代码。所以,要想写出归并排序的代码,我们先写出归并排序的递推公式。</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">递推公式:</span><br><span class="line">merge_sort(p…r) = merge(merge_sort(p…q), merge_sort(q+1…r))</span><br><span class="line"></span><br><span class="line">终止条件:</span><br><span class="line">p >= r 不用再继续分解</span><br></pre></td></tr></table></figure></p><p>我来解释一下这个递推公式。</p><p>merge_sort(p…r) 表示,给下标从 p 到 r 之间的数组排序。我们将这个排序问题转化为了两个子问题,merge_sort(p…q) 和 merge_sort(q+1…r),其中下标 q 等于 p 和 r 的中间位置,也就是 (p+r)/2。当下标从 p 到 q 和从 q+1 到 r 这两个子数组都排好序之后,我们再将两个有序的子数组合并在一起,这样下标从 p 到 r 之间的数据就也排好序了。</p><p>有了递推公式,转化成代码就简单多了。为了阅读方便,我这里只给出伪代码,你可以翻译成你熟悉的编程语言。</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">// 归并排序算法, A是数组,n表示数组大小</span><br><span class="line">merge_sort(A, n) {</span><br><span class="line"> merge_sort_c(A, 0, n-1)</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">// 递归调用函数</span><br><span class="line">merge_sort_c(A, p, r) {</span><br><span class="line"> // 递归终止条件</span><br><span class="line"> if p >= r then return</span><br><span class="line"></span><br><span class="line"> // 取p到r之间的中间位置q</span><br><span class="line"> q = (p+r) / 2</span><br><span class="line"> // 分治递归</span><br><span class="line"> merge_sort_c(A, p, q)</span><br><span class="line"> merge_sort_c(A, q+1, r)</span><br><span class="line"> // 将A[p...q]和A[q+1...r]合并为A[p...r]</span><br><span class="line"> merge(A[p...r], A[p...q], A[q+1...r])</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>你可能已经发现了,merge(A[p…r], A[p…q], A[q+1…r]) 这个函数的作用就是,将已经有序的 A[p…q]和 A[q+1…r]合并成一个有序的数组,并且放入 A[p…r]。那这个过程具体该如何做呢?</p><p>如图所示,我们申请一个临时数组 tmp,大小与 A[p…r]相同。我们用两个游标 i 和 j,分别指向 A[p…q]和 A[q+1…r]的第一个元素。比较这两个元素 A[i]和 A[j],如果 A[i]<=A[j],我们就把 A[i]放入到临时数组 tmp,并且 i 后移一位,否则将 A[j]放入到数组 tmp,j 后移一位。</p><p>继续上述比较过程,直到其中一个子数组中的所有数据都放入临时数组中,再把另一个数组中的数据依次加入到临时数组的末尾,这个时候,临时数组中存储的就是两个子数组合并之后的结果了。最后再把临时数组 tmp 中的数据拷贝到原数组 A[p…r]中。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/12%202.jpg" alt=""></p><p>我们把 merge() 函数写成伪代码,就是下面这样:</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">merge(A[p...r], A[p...q], A[q+1...r]) {</span><br><span class="line"> var i := p,j := q+1,k := 0 // 初始化变量i, j, k</span><br><span class="line"> var tmp := new array[0...r-p] // 申请一个大小跟A[p...r]一样的临时数组</span><br><span class="line"> while i<=q AND j<=r do {</span><br><span class="line"> if A[i] <= A[j] {</span><br><span class="line"> tmp[k++] = A[i++] // i++等于i:=i+1</span><br><span class="line"> } else {</span><br><span class="line"> tmp[k++] = A[j++]</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> </span><br><span class="line"> // 判断哪个子数组中有剩余的数据</span><br><span class="line"> var start := i,end := q</span><br><span class="line"> if j<=r then start := j, end:=r</span><br><span class="line"> </span><br><span class="line"> // 将剩余的数据拷贝到临时数组tmp</span><br><span class="line"> while start <= end do {</span><br><span class="line"> tmp[k++] = A[start++]</span><br><span class="line"> }</span><br><span class="line"> </span><br><span class="line"> // 将tmp中的数组拷贝回A[p...r]</span><br><span class="line"> for i:=0 to r-p do {</span><br><span class="line"> A[p+i] = tmp[i]</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>你还记得第 7 讲讲过的利用哨兵简化编程的处理技巧吗?merge() 合并函数如果借助哨兵,代码就会简洁很多,这个问题留给你思考。</p><h2 id="归并排序的性能分析"><a href="#归并排序的性能分析" class="headerlink" title="归并排序的性能分析"></a>归并排序的性能分析</h2><p>这样跟着我一步一步分析,归并排序是不是没那么难啦?还记得上节课我们分析排序算法的三个问题吗?接下来,我们来看归并排序的三个问题。</p><h3 id="第一,归并排序是稳定的排序算法吗?"><a href="#第一,归并排序是稳定的排序算法吗?" class="headerlink" title="第一,归并排序是稳定的排序算法吗?"></a>第一,归并排序是稳定的排序算法吗?</h3><p>结合我前面画的那张图和归并排序的伪代码,你应该能发现,归并排序稳不稳定关键要看 merge() 函数,也就是两个有序子数组合并成一个有序数组的那部分代码。</p><p>在合并的过程中,如果 A[p…q]和 A[q+1…r]之间有值相同的元素,那我们可以像伪代码中那样,先把 A[p…q]中的元素放入 tmp 数组。这样就保证了值相同的元素,在合并前后的先后顺序不变。所以,归并排序是一个稳定的排序算法。</p><h3 id="第二,归并排序的时间复杂度是多少?"><a href="#第二,归并排序的时间复杂度是多少?" class="headerlink" title="第二,归并排序的时间复杂度是多少?"></a>第二,归并排序的时间复杂度是多少?</h3><p>归并排序涉及递归,时间复杂度的分析稍微有点复杂。我们正好借此机会来学习一下,如何分析递归代码的时间复杂度。</p><p>在递归那一节我们讲过,递归的适用场景是,一个问题 a 可以分解为多个子问题 b、c,那求解问题 a 就可以分解为求解问题 b、c。问题 b、c 解决之后,我们再把 b、c 的结果合并成 a 的结果。</p><p>如果我们定义求解问题 a 的时间是 T(a),求解问题 b、c 的时间分别是 T(b) 和 T( c),那我们就可以得到这样的递推关系式:</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">T(a) = T(b) + T(c) + K</span><br></pre></td></tr></table></figure></p><p>其中 K 等于将两个子问题 b、c 的结果合并成问题 a 的结果所消耗的时间。</p><p>从刚刚的分析,我们可以得到一个重要的结论:<strong>不仅递归求解的问题可以写成递推公式,递归代码的时间复杂度也可以写成递推公式。</strong></p><p>套用这个公式,我们来分析一下归并排序的时间复杂度。</p><p>我们假设对 n 个元素进行归并排序需要的时间是 T(n),那分解成两个子数组排序的时间都是 T(n/2)。我们知道,merge() 函数合并两个有序子数组的时间复杂度是 O(n)。所以,套用前面的公式,归并排序的时间复杂度的计算公式就是:</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">T(1) = C; n=1时,只需要常量级的执行时间,所以表示为C。</span><br><span class="line">T(n) = 2*T(n/2) + n; n>1</span><br></pre></td></tr></table></figure></p><p>通过这个公式,如何来求解 T(n) 呢?还不够直观?那我们再进一步分解一下计算过程。</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">T(n) = 2*T(n/2) + n</span><br><span class="line"> = 2*(2*T(n/4) + n/2) + n = 4*T(n/4) + 2*n</span><br><span class="line"> = 4*(2*T(n/8) + n/4) + 2*n = 8*T(n/8) + 3*n</span><br><span class="line"> = 8*(2*T(n/16) + n/8) + 3*n = 16*T(n/16) + 4*n</span><br><span class="line"> ......</span><br><span class="line"> = 2^k * T(n/2^k) + k * n</span><br><span class="line"> ......</span><br></pre></td></tr></table></figure></p><p>通过这样一步一步分解推导,我们可以得到 T(n) = 2^kT(n/2^k)+kn。当 T(n/2^k)=T(1) 时,也就是 n/2^k=1,我们得到 k=log2n 。我们将 k 值代入上面的公式,得到 T(n)=Cn+nlog2n 。如果我们用大 O 标记法来表示的话,T(n) 就等于 O(nlogn)。所以归并排序的时间复杂度是 O(nlogn)。</p><p>从我们的原理分析和伪代码可以看出,归并排序的执行效率与要排序的原始数组的有序程度无关,所以其时间复杂度是非常稳定的,不管是最好情况、最坏情况,还是平均情况,时间复杂度都是 O(nlogn)。</p><h3 id="第三,归并排序的空间复杂度是多少?"><a href="#第三,归并排序的空间复杂度是多少?" class="headerlink" title="第三,归并排序的空间复杂度是多少?"></a>第三,归并排序的空间复杂度是多少?</h3><p>归并排序的时间复杂度任何情况下都是 O(nlogn),看起来非常优秀。(待会儿你会发现,即便是快速排序,最坏情况下,时间复杂度也是 O(n2)。)但是,归并排序并没有像快排那样,应用广泛,这是为什么呢?因为它有一个致命的“弱点”,那就是归并排序不是原地排序算法。</p><p>这是因为归并排序的合并函数,在合并两个有序数组为一个有序数组时,需要借助额外的存储空间。这一点你应该很容易理解。那我现在问你,归并排序的空间复杂度到底是多少呢?是 O(n),还是 O(nlogn),应该如何分析呢?</p><p>如果我们继续按照分析递归时间复杂度的方法,通过递推公式来求解,那整个归并过程需要的空间复杂度就是 O(nlogn)。不过,类似分析时间复杂度那样来分析空间复杂度,这个思路对吗?</p><p>实际上,递归代码的空间复杂度并不能像时间复杂度那样累加。刚刚我们忘记了最重要的一点,那就是,尽管每次合并操作都需要申请额外的内存空间,但在合并完成之后,临时开辟的内存空间就被释放掉了。在任意时刻,CPU 只会有一个函数在执行,也就只会有一个临时的内存空间在使用。临时内存空间最大也不会超过 n 个数据的大小,所以空间复杂度是 O(n)。</p><h2 id="快速排序的原理"><a href="#快速排序的原理" class="headerlink" title="快速排序的原理"></a>快速排序的原理</h2><p>我们再来看快速排序算法(Quicksort),我们习惯性把它简称为“快排”。快排利用的也是分治思想。乍看起来,它有点像归并排序,但是思路其实完全不一样。我们待会会讲两者的区别。现在,我们先来看下快排的核心思想。</p><p>快排的思想是这样的:如果要排序数组中下标从 p 到 r 之间的一组数据,我们选择 p 到 r 之间的任意一个数据作为 pivot(分区点)。</p><p>我们遍历 p 到 r 之间的数据,将小于 pivot 的放到左边,将大于 pivot 的放到右边,将 pivot 放到中间。经过这一步骤之后,数组 p 到 r 之间的数据就被分成了三个部分,前面 p 到 q-1 之间都是小于 pivot 的,中间是 pivot,后面的 q+1 到 r 之间是大于 pivot 的。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/12%203.jpg" alt=""></p><p>根据分治、递归的处理思想,我们可以用递归排序下标从 p 到 q-1 之间的数据和下标从 q+1 到 r 之间的数据,直到区间缩小为 1,就说明所有的数据都有序了。</p><p>如果我们用递推公式来将上面的过程写出来的话,就是这样:</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">递推公式:</span><br><span class="line">quick_sort(p…r) = quick_sort(p…q-1) + quick_sort(q+1… r)</span><br><span class="line"></span><br><span class="line">终止条件:</span><br><span class="line">p >= r</span><br></pre></td></tr></table></figure></p><p>我将递推公式转化成递归代码。跟归并排序一样,我还是用伪代码来实现,你可以翻译成你熟悉的任何语言。</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">// 快速排序,A是数组,n表示数组的大小</span><br><span class="line">quick_sort(A, n) {</span><br><span class="line"> quick_sort_c(A, 0, n-1)</span><br><span class="line">}</span><br><span class="line">// 快速排序递归函数,p,r为下标</span><br><span class="line">quick_sort_c(A, p, r) {</span><br><span class="line"> if p >= r then return</span><br><span class="line"> </span><br><span class="line"> q = partition(A, p, r) // 获取分区点</span><br><span class="line"> quick_sort_c(A, p, q-1)</span><br><span class="line"> quick_sort_c(A, q+1, r)</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>归并排序中有一个 merge() 合并函数,我们这里有一个 partition() 分区函数。partition() 分区函数实际上我们前面已经讲过了,就是随机选择一个元素作为 pivot(一般情况下,可以选择 p 到 r 区间的最后一个元素),然后对 A[p…r]分区,函数返回 pivot 的下标。</p><p>如果我们不考虑空间消耗的话,partition() 分区函数可以写得非常简单。我们申请两个临时数组 X 和 Y,遍历 A[p…r],将小于 pivot 的元素都拷贝到临时数组 X,将大于 pivot 的元素都拷贝到临时数组 Y,最后再将数组 X 和数组 Y 中数据顺序拷贝到 A[p…r]。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/12%204.jpg" alt=""></p><p>但是,如果按照这种思路实现的话,partition() 函数就需要很多额外的内存空间,所以快排就不是原地排序算法了。如果我们希望快排是原地排序算法,那它的空间复杂度得是 O(1),那 partition() 分区函数就不能占用太多额外的内存空间,我们就需要在 A[p…r]的原地完成分区操作。</p><p>原地分区函数的实现思路非常巧妙,我写成了伪代码,我们一起来看一下。</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">partition(A, p, r) {</span><br><span class="line"> pivot := A[r]</span><br><span class="line"> i := p</span><br><span class="line"> for j := p to r-1 do {</span><br><span class="line"> if A[j] < pivot {</span><br><span class="line"> swap A[i] with A[j]</span><br><span class="line"> i := i+1</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> swap A[i] with A[r]</span><br><span class="line"> return i</span><br><span class="line"></span><br></pre></td></tr></table></figure></p><p>这里的处理有点类似选择排序。我们通过游标 i 把 A[p…r-1]分成两部分。A[p…i-1]的元素都是小于 pivot 的,我们暂且叫它“已处理区间”,A[i…r-1]是“未处理区间”。我们每次都从未处理的区间 A[i…r-1]中取一个元素 A[j],与 pivot 对比,如果小于 pivot,则将其加入到已处理区间的尾部,也就是 A[i]的位置。</p><p>数组的插入操作还记得吗?在数组某个位置插入元素,需要搬移数据,非常耗时。当时我们也讲了一种处理技巧,就是交换,在 O(1) 的时间复杂度内完成插入操作。这里我们也借助这个思想,只需要将 A[i]与 A[j]交换,就可以在 O(1) 时间复杂度内将 A[j]放到下标为 i 的位置。</p><p>文字不如图直观,所以我画了一张图来展示分区的整个过程。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/12%205.jpg" alt=""></p><p>因为分区的过程涉及交换操作,如果数组中有两个相同的元素,比如序列 6,8,7,6,3,5,9,4,在经过第一次分区操作之后,两个 6 的相对先后顺序就会改变。所以,快速排序并不是一个稳定的排序算法。</p><p>到此,快速排序的原理你应该也掌握了。现在,我再来看另外一个问题:快排和归并用的都是分治思想,递推公式和递归代码也非常相似,那它们的区别在哪里呢?</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/12%206.jpg" alt=""></p><p>可以发现,归并排序的处理过程是<strong>由下到上</strong>的,先处理子问题,然后再合并。而快排正好相反,它的处理过程是<strong>由上到下</strong>的,先分区,然后再处理子问题。归并排序虽然是稳定的、时间复杂度为 O(nlogn) 的排序算法,但是它是非原地排序算法。我们前面讲过,归并之所以是非原地排序算法,主要原因是合并函数无法在原地执行。快速排序通过设计巧妙的原地分区函数,可以实现原地排序,解决了归并排序占用太多内存的问题。</p><h3 id="快速排序的性能分析"><a href="#快速排序的性能分析" class="headerlink" title="快速排序的性能分析"></a>快速排序的性能分析</h3><p>现在,我们来分析一下快速排序的性能。我在讲解快排的实现原理的时候,已经分析了稳定性和空间复杂度。快排是一种原地、不稳定的排序算法。现在,我们集中精力来看快排的时间复杂度。</p><p>快排也是用递归来实现的。对于递归代码的时间复杂度,我前面总结的公式,这里也还是适用的。如果每次分区操作,都能正好把数组分成大小接近相等的两个小区间,那快排的时间复杂度递推求解公式跟归并是相同的。所以,快排的时间复杂度也是 O(nlogn)。</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">T(1) = C; n=1时,只需要常量级的执行时间,所以表示为C。</span><br><span class="line">T(n) = 2*T(n/2) + n; n>1</span><br></pre></td></tr></table></figure></p><p>但是,公式成立的前提是每次分区操作,我们选择的 pivot 都很合适,正好能将大区间对等地一分为二。但实际上这种情况是很难实现的。</p><p>我举一个比较极端的例子。如果数组中的数据原来已经是有序的了,比如 1,3,5,6,8。如果我们每次选择最后一个元素作为 pivot,那每次分区得到的两个区间都是不均等的。我们需要进行大约 n 次分区操作,才能完成快排的整个过程。每次分区我们平均要扫描大约 n/2 个元素,这种情况下,快排的时间复杂度就从 O(nlogn) 退化成了 O(n2)。</p><p>我们刚刚讲了两个极端情况下的时间复杂度,一个是分区极其均衡,一个是分区极其不均衡。它们分别对应快排的最好情况时间复杂度和最坏情况时间复杂度。那快排的平均情况时间复杂度是多少呢?</p><p>我们假设每次分区操作都将区间分成大小为 9:1 的两个小区间。我们继续套用递归时间复杂度的递推公式,就会变成这样:</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">T(1) = C; n=1时,只需要常量级的执行时间,所以表示为C。</span><br><span class="line"></span><br><span class="line">T(n) = T(n/10) + T(9*n/10) + n; n>1</span><br></pre></td></tr></table></figure></p><p>这个公式的递推求解的过程非常复杂,虽然可以求解,但我不推荐用这种方法。实际上,递归的时间复杂度的求解方法除了递推公式之外,还有递归树,在树那一节我再讲,这里暂时不说。我这里直接给你结论:T(n) 在大部分情况下的时间复杂度都可以做到 O(nlogn),只有在极端情况下,才会退化到 O(n2)。而且,我们也有很多方法将这个概率降到很低,如何来做?我们后面章节再讲。</p><h2 id="解答开篇"><a href="#解答开篇" class="headerlink" title="解答开篇"></a>解答开篇</h2><p>快排核心思想就是<strong>分治</strong>和<strong>分区</strong>,我们可以利用分区的思想,来解答开篇的问题:O(n) 时间复杂度内求无序数组中的第 K 大元素。比如,4, 2, 5, 12, 3 这样一组数据,第 3 大元素就是 4。</p><p>我们选择数组区间 A[0…n-1]的最后一个元素 A[n-1]作为 pivot,对数组 A[0…n-1]原地分区,这样数组就分成了三部分,A[0…p-1]、A[p]、A[p+1…n-1]。</p><p>如果 p+1=K,那 A[p]就是要求解的元素;如果 K>p+1, 说明第 K 大元素出现在 A[p+1…n-1]区间,我们再按照上面的思路递归地在 A[p+1…n-1]这个区间内查找。同理,如果 K<p+1,那我们就在 A[0…p-1]区间查找。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/12%207.jpg" alt=""></p><p>我们再来看,为什么上述解决思路的时间复杂度是 O(n)?</p><p>第一次分区查找,我们需要对大小为 n 的数组执行分区操作,需要遍历 n 个元素。第二次分区查找,我们只需要对大小为 n/2 的数组执行分区操作,需要遍历 n/2 个元素。依次类推,分区遍历元素的个数分别为、n/2、n/4、n/8、n/16.……直到区间缩小为 1。</p><p>如果我们把每次分区遍历的元素个数加起来,就是:n+n/2+n/4+n/8+…+1。这是一个等比数列求和,最后的和等于 2n-1。所以,上述解决思路的时间复杂度就为 O(n)。你可能会说,我有个很笨的办法,每次取数组中的最小值,将其移动到数组的最前面,然后在剩下的数组中继续找最小值,以此类推,执行 K 次,找到的数据不就是第 K 大元素了吗?</p><p>不过,时间复杂度就并不是 O(n) 了,而是 O(K <em> n)。你可能会说,时间复杂度前面的系数不是可以忽略吗?O(K </em> n) 不就等于 O(n) 吗?</p><p>这个可不能这么简单地划等号。当 K 是比较小的常量时,比如 1、2,那最好时间复杂度确实是 O(n);但当 K 等于 n/2 或者 n 时,这种最坏情况下的时间复杂度就是 O(n2) 了。</p><h2 id="内容小结"><a href="#内容小结" class="headerlink" title="内容小结"></a>内容小结</h2><p>归并排序和快速排序是两种稍微复杂的排序算法,它们用的都是分治的思想,代码都通过递归来实现,过程非常相似。理解归并排序的重点是理解递推公式和 merge() 合并函数。同理,理解快排的重点也是理解递推公式,还有 partition() 分区函数。</p><p>归并排序算法是一种在任何情况下时间复杂度都比较稳定的排序算法,这也使它存在致命的缺点,即归并排序不是原地排序算法,空间复杂度比较高,是 O(n)。正因为此,它也没有快排应用广泛。</p><p>快速排序算法虽然最坏情况下的时间复杂度是 O(n2),但是平均情况下时间复杂度都是 O(nlogn)。不仅如此,快速排序算法时间复杂度退化到 O(n2) 的概率非常小,我们可以通过合理地选择 pivot 来避免这种情况。</p><h2 id="课后思考"><a href="#课后思考" class="headerlink" title="课后思考"></a>课后思考</h2><p>现在你有 10 个接口访问日志文件,每个日志文件大小约 300MB,每个文件里的日志都是按照时间戳从小到大排序的。你希望将这 10 个较小的日志文件,合并为 1 个日志文件,合并之后的日志仍然按照时间戳从小到大排列。如果处理上述排序任务的机器内存只有 1GB,你有什么好的解决思路,能“快速”地将这 10 个日志文件合并吗?</p>]]></content>
<summary type="html"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" cla</summary>
<category term="算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
</entry>
<entry>
<title>09|排序(上):为什么插入排序比冒泡排序更受欢迎?</title>
<link href="https://chanmoyun.gitee.io/2024/03/02/Data-structure/09.%E6%8E%92%E5%BA%8F%EF%BC%88%E4%B8%8A%EF%BC%89%EF%BC%9A%E4%B8%BA%E4%BB%80%E4%B9%88%E6%8F%92%E5%85%A5%E6%8E%92%E5%BA%8F%E6%AF%94%E5%86%92%E6%B3%A1%E6%8E%92%E5%BA%8F%E6%9B%B4%E5%8F%97%E6%AC%A2%E8%BF%8E%EF%BC%9F/"/>
<id>https://chanmoyun.gitee.io/2024/03/02/Data-structure/09.%E6%8E%92%E5%BA%8F%EF%BC%88%E4%B8%8A%EF%BC%89%EF%BC%9A%E4%B8%BA%E4%BB%80%E4%B9%88%E6%8F%92%E5%85%A5%E6%8E%92%E5%BA%8F%E6%AF%94%E5%86%92%E6%B3%A1%E6%8E%92%E5%BA%8F%E6%9B%B4%E5%8F%97%E6%AC%A2%E8%BF%8E%EF%BC%9F/</id>
<published>2024-03-01T16:00:00.000Z</published>
<updated>2024-03-01T12:25:46.556Z</updated>
<content type="html"><![CDATA[<link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="\assets\js\Meting.min.js"></script><h1 id="排序(上):为什么插入排序比冒泡排序更受欢迎?"><a href="#排序(上):为什么插入排序比冒泡排序更受欢迎?" class="headerlink" title="排序(上):为什么插入排序比冒泡排序更受欢迎?"></a>排序(上):为什么插入排序比冒泡排序更受欢迎?</h1><p>排序对于任何一个程序员来说,可能都不会陌生。你学的第一个算法,可能就是排序。大部分编程语言中,也都提供了排序函数。在平常的项目中,我们也经常会用到排序。排序非常重要,所以我会花多一点时间来详细讲一讲经典的排序算法。</p><p>排序算法太多了,有很多可能你连名字都没听说过,比如猴子排序、睡眠排序、面条排序等。我只讲众多排序算法中的一小撮,也是最经典的、最常用的:冒泡排序、插入排序、选择排序、归并排序、快速排序、计数排序、基数排序、桶排序。我按照时间复杂度把它们分成了三类,分三节课来讲解。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/11%201.jpg" alt=""></p><p>带着问题去学习,是最有效的学习方法。所以按照惯例,我还是先给你出一个思考题:<strong>插入排序和冒泡排序的时间复杂度相同,都是 O(n2),在实际的软件开发里,为什么我们更倾向于使用插入排序算法而不是冒泡排序算法呢?</strong></p><p>你可以先思考一两分钟,带着这个问题,我们开始今天的内容!</p><h2 id="如何分析一个“排序算法”?"><a href="#如何分析一个“排序算法”?" class="headerlink" title="如何分析一个“排序算法”?"></a>如何分析一个“排序算法”?</h2><p>学习排序算法,我们除了学习它的算法原理、代码实现之外,更重要的是要学会如何评价、分析一个排序算法。那分析一个排序算法,要从哪几个方面入手呢?</p><p><strong>排序算法的执行效率</strong></p><p>对于排序算法执行效率的分析,我们一般会从这几个方面来衡量:</p><h3 id="1-最好情况、最坏情况、平均情况时间复杂度"><a href="#1-最好情况、最坏情况、平均情况时间复杂度" class="headerlink" title="1.最好情况、最坏情况、平均情况时间复杂度"></a>1.最好情况、最坏情况、平均情况时间复杂度</h3><p>我们在分析排序算法的时间复杂度时,要分别给出最好情况、最坏情况、平均情况下的时间复杂度。除此之外,你还要说出最好、最坏时间复杂度对应的要排序的原始数据是什么样的。</p><p>为什么要区分这三种时间复杂度呢?第一,有些排序算法会区分,为了好对比,所以我们最好都做一下区分。第二,对于要排序的数据,有的接近有序,有的完全无序。有序度不同的数据,对于排序的执行时间肯定是有影响的,我们要知道排序算法在不同数据下的性能表现。</p><h3 id="2-时间复杂度的系数、常数-、低阶"><a href="#2-时间复杂度的系数、常数-、低阶" class="headerlink" title="2.时间复杂度的系数、常数 、低阶"></a>2.时间复杂度的系数、常数 、低阶</h3><p>我们知道,时间复杂度反应的是数据规模 n 很大的时候的一个增长趋势,所以它表示的时候会忽略系数、常数、低阶。但是实际的软件开发中,我们排序的可能是 10 个、100 个、1000 个这样规模很小的数据,所以,在对同一阶时间复杂度的排序算法性能对比的时候,我们就要把系数、常数、低阶也考虑进来。</p><h3 id="3-比较次数和交换(或移动)次数"><a href="#3-比较次数和交换(或移动)次数" class="headerlink" title="3.比较次数和交换(或移动)次数"></a>3.比较次数和交换(或移动)次数</h3><p>这一节和下一节讲的都是基于比较的排序算法。基于比较的排序算法的执行过程,会涉及两种操作,一种是元素比较大小,另一种是元素交换或移动。所以,如果我们在分析排序算法的执行效率的时候,应该把比较次数和交换(或移动)次数也考虑进去。</p><p><strong>排序算法的内存消耗</strong></p><p>我们前面讲过,算法的内存消耗可以通过空间复杂度来衡量,排序算法也不例外。不过,针对排序算法的空间复杂度,我们还引入了一个新的概念,<strong>原地排序</strong>(Sorted in place)。原地排序算法,就是特指空间复杂度是 O(1) 的排序算法。我们今天讲的三种排序算法,都是原地排序算法。</p><p><strong>排序算法的稳定性</strong></p><p>仅仅用执行效率和内存消耗来衡量排序算法的好坏是不够的。针对排序算法,我们还有一个重要的度量指标,<strong>稳定性</strong>。这个概念是说,如果待排序的序列中存在值相等的元素,经过排序之后,相等元素之间原有的先后顺序不变。</p><p>我通过一个例子来解释一下。比如我们有一组数据 2,9,3,4,8,3,按照大小排序之后就是 2,3,3,4,8,9。</p><p>这组数据里有两个 3。经过某种排序算法排序之后,如果两个 3 的前后顺序没有改变,那我们就把这种排序算法叫作<strong>稳定的排序算法</strong>;如果前后顺序发生变化,那对应的排序算法就叫作<strong>不稳定的排序算法</strong>。</p><p>你可能要问了,两个 3 哪个在前,哪个在后有什么关系啊,稳不稳定又有什么关系呢?为什么要考察排序算法的稳定性呢?</p><p>很多数据结构和算法课程,在讲排序的时候,都是用整数来举例,但在真正软件开发中,我们要排序的往往不是单纯的整数,而是一组对象,我们需要按照对象的某个 key 来排序。</p><p>比如说,我们现在要给电商交易系统中的“订单”排序。订单有两个属性,一个是下单时间,另一个是订单金额。如果我们现在有 10 万条订单数据,我们希望按照金额从小到大对订单数据排序。对于金额相同的订单,我们希望按照下单时间从早到晚有序。对于这样一个排序需求,我们怎么来做呢?</p><p>最先想到的方法是:我们先按照金额对订单数据进行排序,然后,再遍历排序之后的订单数据,对于每个金额相同的小区间再按照下单时间排序。这种排序思路理解起来不难,但是实现起来会很复杂。</p><p>借助稳定排序算法,这个问题可以非常简洁地解决。解决思路是这样的:我们先按照下单时间给订单排序,注意是按照下单时间,不是金额。排序完成之后,我们用稳定排序算法,按照订单金额重新排序。两遍排序之后,我们得到的订单数据就是按照金额从小到大排序,金额相同的订单按照下单时间从早到晚排序的。为什么呢?</p><p><strong>稳定排序算法可以保持金额相同的两个对象,在排序之后的前后顺序不变</strong>。第一次排序之后,所有的订单按照下单时间从早到晚有序了。在第二次排序中,我们用的是稳定的排序算法,所以经过第二次排序之后,相同金额的订单仍然保持下单时间从早到晚有序。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/11%202.jpg" alt=""></p><h2 id="冒泡排序(Bubble-Sort)"><a href="#冒泡排序(Bubble-Sort)" class="headerlink" title="冒泡排序(Bubble Sort)"></a>冒泡排序(Bubble Sort)</h2><p>我们从冒泡排序开始,学习今天的三种排序算法。</p><p>冒泡排序只会操作相邻的两个数据。每次冒泡操作都会对相邻的两个元素进行比较,看是否满足大小关系要求。如果不满足就让它俩互换。一次冒泡会让至少一个元素移动到它应该在的位置,重复 n 次,就完成了 n 个数据的排序工作。</p><p>我用一个例子,带你看下冒泡排序的整个过程。我们要对一组数据 4,5,6,3,2,1,从小到大进行排序。第一次冒泡操作的详细过程就是这样:</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/11%203.jpg" alt=""></p><p>可以看出,经过一次冒泡操作之后,6 这个元素已经存储在正确的位置上。要想完成所有数据的排序,我们只要进行 6 次这样的冒泡操作就行了。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/11%204.jpg" alt=""></p><p>实际上,刚讲的冒泡过程还可以优化。当某次冒泡操作已经没有数据交换时,说明已经达到完全有序,不用再继续执行后续的冒泡操作。我这里还有另外一个例子,这里面给 6 个元素排序,只需要 4 次冒泡操作就可以了。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/11%205.jpg" alt=""></p><p>冒泡排序算法的原理比较容易理解,具体的代码我贴到下面,你可以结合着代码来看我前面讲的原理。</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">// 冒泡排序,a表示数组,n表示数组大小</span><br><span class="line">public void bubbleSort(int[] a, int n) {</span><br><span class="line"> if (n <= 1) return;</span><br><span class="line"> </span><br><span class="line"> for (int i = 0; i < n; ++i) {</span><br><span class="line"> // 提前退出冒泡循环的标志位</span><br><span class="line"> boolean flag = false;</span><br><span class="line"> for (int j = 0; j < n - i - 1; ++j) {</span><br><span class="line"> if (a[j] > a[j+1]) { // 交换</span><br><span class="line"> int tmp = a[j];</span><br><span class="line"> a[j] = a[j+1];</span><br><span class="line"> a[j+1] = tmp;</span><br><span class="line"> flag = true; // 表示有数据交换 </span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> if (!flag) break; // 没有数据交换,提前退出</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>现在,结合刚才我分析排序算法的三个方面,我有三个问题要问你。</p><h3 id="第一,冒泡排序是原地排序算法吗?"><a href="#第一,冒泡排序是原地排序算法吗?" class="headerlink" title="第一,冒泡排序是原地排序算法吗?"></a>第一,冒泡排序是原地排序算法吗?</h3><p>冒泡的过程只涉及相邻数据的交换操作,只需要常量级的临时空间,所以它的空间复杂度为 O(1),是一个原地排序算法。</p><h3 id="第二,冒泡排序是稳定的排序算法吗?"><a href="#第二,冒泡排序是稳定的排序算法吗?" class="headerlink" title="第二,冒泡排序是稳定的排序算法吗?"></a>第二,冒泡排序是稳定的排序算法吗?</h3><p>在冒泡排序中,只有交换才可以改变两个元素的前后顺序。为了保证冒泡排序算法的稳定性,当有相邻的两个元素大小相等的时候,我们不做交换,相同大小的数据在排序前后不会改变顺序,所以冒泡排序是稳定的排序算法。</p><h3 id="第三,冒泡排序的时间复杂度是多少?"><a href="#第三,冒泡排序的时间复杂度是多少?" class="headerlink" title="第三,冒泡排序的时间复杂度是多少?"></a>第三,冒泡排序的时间复杂度是多少?</h3><p>最好情况下,要排序的数据已经是有序的了,我们只需要进行一次冒泡操作,就可以结束了,所以最好情况时间复杂度是 O(n)。而最坏的情况是,要排序的数据刚好是倒序排列的,我们需要进行 n 次冒泡操作,所以最坏情况时间复杂度为 O(n2)。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/11%206.jpg" alt=""></p><p>最好、最坏情况下的时间复杂度很容易分析,那平均情况下的时间复杂是多少呢?我们前面讲过,平均时间复杂度就是加权平均期望时间复杂度,分析的时候要结合概率论的知识。</p><p>对于包含 n 个数据的数组,这 n 个数据就有 n! 种排列方式。不同的排列方式,冒泡排序执行的时间肯定是不同的。比如我们前面举的那两个例子,其中一个要进行 6 次冒泡,而另一个只需要 4 次。如果用概率论方法定量分析平均时间复杂度,涉及的数学推理和计算就会很复杂。我这里还有一种思路,通过“<strong>有序度</strong>”和“<strong>逆序度</strong>”这两个概念来进行分析。</p><p><strong>有序度</strong>是数组中具有有序关系的元素对的个数。有序元素对用数学表达式表示就是这样:</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">有序元素对:a[i] <= a[j], 如果i < j。</span><br></pre></td></tr></table></figure></p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/11%207.jpg" alt=""></p><p>同理,对于一个倒序排列的数组,比如 6,5,4,3,2,1,有序度是 0;对于一个完全有序的数组,比如 1,2,3,4,5,6,有序度就是 <strong>n*(n-1)/2</strong>,也就是 15。我们把这种完全有序的数组的有序度叫作<strong>满有序度</strong>。</p><p>逆序度的定义正好跟有序度相反(默认从小到大为有序),我想你应该已经想到了。关于逆序度,我就不举例子讲了。你可以对照我讲的有序度的例子自己看下。</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">逆序元素对:a[i] > a[j], 如果i < j。</span><br></pre></td></tr></table></figure></p><p>关于这三个概念,我们还可以得到一个公式:<strong>逆序度</strong> = <strong>满有序度</strong> <strong>- 有序度</strong>。我们排序的过程就是一种增加有序度,减少逆序度的过程,最后达到满有序度,就说明排序完成了。</p><p>我还是拿前面举的那个冒泡排序的例子来说明。要排序的数组的初始状态是 4,5,6,3,2,1 ,其中,有序元素对有 (4,5) (4,6)(5,6),所以有序度是 3。n=6,所以排序完成之后终态的满有序度为 n*(n-1)/2=15。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/11%208.jpg" alt=""></p><p>冒泡排序包含两个操作原子,<strong>比较</strong>和<strong>交换</strong>。每交换一次,有序度就加 1。不管算法怎么改进,交换次数总是确定的,即为<strong>逆序度,也就是n*(n-1)/2–初始有序度</strong>。此例中就是 15–3=12,要进行 12 次交换操作。</p><p>对于包含 n 个数据的数组进行冒泡排序,平均交换次数是多少呢?最坏情况下,初始状态的有序度是 0,所以要进行 n<em>(n-1)/2 次交换。最好情况下,初始状态的有序度是 n</em>(n-1)/2,就不需要进行交换。我们可以取个中间值 n*(n-1)/4,来表示初始有序度既不是很高也不是很低的平均情况。</p><p>换句话说,平均情况下,需要 n*(n-1)/4 次交换操作,比较操作肯定要比交换操作多,而复杂度的上限是 O(n2),所以平均情况下的时间复杂度就是 O(n2)。</p><p>这个平均时间复杂度推导过程其实并不严格,但是很多时候很实用,毕竟概率论的定量分析太复杂,不太好用。等我们讲到快排的时候,我还会再次用这种“不严格”的方法来分析平均时间复杂度。</p><h2 id="插入排序(Insertion-Sort)"><a href="#插入排序(Insertion-Sort)" class="headerlink" title="插入排序(Insertion Sort)"></a>插入排序(Insertion Sort)</h2><p>我们先来看一个问题。一个有序的数组,我们往里面添加一个新的数据后,如何继续保持数据有序呢?很简单,我们只要遍历数组,找到数据应该插入的位置将其插入即可。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/11%209.jpg" alt=""></p><p>这是一个动态排序的过程,即动态地往有序集合中添加数据,我们可以通过这种方法保持集合中的数据一直有序。而对于一组静态数据,我们也可以借鉴上面讲的插入方法,来进行排序,于是就有了插入排序算法。</p><p>那<strong>插入排序具体是如何借助上面的思想来实现排序的呢</strong>?</p><p>首先,我们将数组中的数据分为两个区间,<strong>已排序区间</strong>和<strong>未排序区间</strong>。初始已排序区间只有一个元素,就是数组的第一个元素。插入算法的核心思想是取未排序区间中的元素,在已排序区间中找到合适的插入位置将其插入,并保证已排序区间数据一直有序。重复这个过程,直到未排序区间中元素为空,算法结束。</p><p>如图所示,要排序的数据是 4,5,6,1,3,2,其中左侧为已排序区间,右侧是未排序区间。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/11%2011.jpg" alt=""></p><p>插入排序也包含两种操作,一种是<strong>元素的比较</strong>,一种是<strong>元素的移动</strong>。当我们需要将一个数据 a 插入到已排序区间时,需要拿 a 与已排序区间的元素依次比较大小,找到合适的插入位置。找到插入点之后,我们还需要将插入点之后的元素顺序往后移动一位,这样才能腾出位置给元素 a 插入。</p><p>对于不同的查找插入点方法(从头到尾、从尾到头),元素的比较次数是有区别的。但对于一个给定的初始序列,移动操作的次数总是固定的,就等于逆序度。</p><p>为什么说移动次数就等于逆序度呢?我拿刚才的例子画了一个图表,你一看就明白了。满有序度是 n*(n-1)/2=15,初始序列的有序度是 5,所以逆序度是 10。插入排序中,数据移动的个数总和也等于 10=3+3+4。插入排序的原理也很简单吧?我也将代码实现贴在这里,你可以结合着代码再看下。</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">// 插入排序,a表示数组,n表示数组大小</span><br><span class="line">public void insertionSort(int[] a, int n) {</span><br><span class="line"> if (n <= 1) return;</span><br><span class="line"></span><br><span class="line"> for (int i = 1; i < n; ++i) {</span><br><span class="line"> int value = a[i];</span><br><span class="line"> int j = i - 1;</span><br><span class="line"> // 查找插入的位置</span><br><span class="line"> for (; j >= 0; --j) {</span><br><span class="line"> if (a[j] > value) {</span><br><span class="line"> a[j+1] = a[j]; // 数据移动</span><br><span class="line"> } else {</span><br><span class="line"> break;</span><br><span class="line"> }</span><br><span class="line"> }</span><br><span class="line"> a[j+1] = value; // 插入数据</span><br><span class="line"> }</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>现在,我们来看点稍微复杂的东西。我这里还是有三个问题要问你。</p><h3 id="第一,插入排序是原地排序算法吗?"><a href="#第一,插入排序是原地排序算法吗?" class="headerlink" title="第一,插入排序是原地排序算法吗?"></a>第一,插入排序是原地排序算法吗?</h3><p>从实现过程可以很明显地看出,插入排序算法的运行并不需要额外的存储空间,所以空间复杂度是 O(1),也就是说,这是一个原地排序算法。</p><h3 id="第二,插入排序是稳定的排序算法吗?"><a href="#第二,插入排序是稳定的排序算法吗?" class="headerlink" title="第二,插入排序是稳定的排序算法吗?"></a>第二,插入排序是稳定的排序算法吗?</h3><p>在插入排序中,对于值相同的元素,我们可以选择将后面出现的元素,插入到前面出现元素的后面,这样就可以保持原有的前后顺序不变,所以插入排序是稳定的排序算法。</p><h3 id="第三,插入排序的时间复杂度是多少?"><a href="#第三,插入排序的时间复杂度是多少?" class="headerlink" title="第三,插入排序的时间复杂度是多少?"></a>第三,插入排序的时间复杂度是多少?</h3><p>如果要排序的数据已经是有序的,我们并不需要搬移任何数据。如果我们从尾到头在有序数据组里面查找插入位置,每次只需要比较一个数据就能确定插入的位置。所以这种情况下,最好是时间复杂度为 O(n)。注意,这里是<strong>从尾到头遍历已经有序的数据</strong>。</p><p>如果数组是倒序的,每次插入都相当于在数组的第一个位置插入新的数据,所以需要移动大量的数据,所以最坏情况时间复杂度为 O(n2)。</p><p>还记得我们在数组中插入一个数据的平均时间复杂度是多少吗?没错,是 O(n)。所以,对于插入排序来说,每次插入操作都相当于在数组中插入一个数据,循环执行 n 次插入操作,所以平均时间复杂度为 O(n2)。</p><h2 id="选择排序(Selection-Sort)"><a href="#选择排序(Selection-Sort)" class="headerlink" title="选择排序(Selection Sort)"></a>选择排序(Selection Sort)</h2><p>选择排序算法的实现思路有点类似插入排序,也分已排序区间和未排序区间。但是选择排序每次会从未排序区间中找到最小的元素,将其放到已排序区间的末尾。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/11%2012.jpg" alt=""></p><p>照例,也有三个问题需要你思考,不过前面两种排序算法我已经分析得很详细了,这里就直接公布答案了。</p><p>首先,选择排序空间复杂度为 O(1),是一种原地排序算法。选择排序的最好情况时间复杂度、最坏情况和平均情况时间复杂度都为 O(n2)。你可以自己来分析看看。</p><p>那选择排序是稳定的排序算法吗?这个问题我着重来说一下。</p><p>答案是否定的,选择排序是一种不稳定的排序算法。从我前面画的那张图中,你可以看出来,选择排序每次都要找剩余未排序元素中的最小值,并和前面的元素交换位置,这样破坏了稳定性。</p><p>比如 5,8,5,2,9 这样一组数据,使用选择排序算法来排序的话,第一次找到最小元素 2,与第一个 5 交换位置,那第一个 5 和中间的 5 顺序就变了,所以就不稳定了。正是因此,相对于冒泡排序和插入排序,选择排序就稍微逊色了。</p><h2 id="解答开篇"><a href="#解答开篇" class="headerlink" title="解答开篇"></a>解答开篇</h2><p>基本的知识都讲完了,我们来看开篇的问题:冒泡排序和插入排序的时间复杂度都是 O(n2),都是原地排序算法,为什么插入排序要比冒泡排序更受欢迎呢?</p><p>我们前面分析冒泡排序和插入排序的时候讲到,冒泡排序不管怎么优化,元素交换的次数是一个固定值,是原始数据的逆序度。插入排序是同样的,不管怎么优化,元素移动的次数也等于原始数据的逆序度。</p><p>但是,从代码实现上来看,冒泡排序的数据交换要比插入排序的数据移动要复杂,冒泡排序需要 3 个赋值操作,而插入排序只需要 1 个。我们来看这段操作:</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">冒泡排序中数据的交换操作:</span><br><span class="line">if (a[j] > a[j+1]) { // 交换</span><br><span class="line"> int tmp = a[j];</span><br><span class="line"> a[j] = a[j+1];</span><br><span class="line"> a[j+1] = tmp;</span><br><span class="line"> flag = true;</span><br><span class="line">}</span><br><span class="line"></span><br><span class="line">插入排序中数据的移动操作:</span><br><span class="line">if (a[j] > value) {</span><br><span class="line"> a[j+1] = a[j]; // 数据移动</span><br><span class="line">} else {</span><br><span class="line"> break;</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>我们把执行一个赋值语句的时间粗略地计为单位时间(unit_time),然后分别用冒泡排序和插入排序对同一个逆序度是 K 的数组进行排序。用冒泡排序,需要 K 次交换操作,每次需要 3 个赋值语句,所以交换操作总耗时就是 3*K 单位时间。而插入排序中数据移动操作只需要 K 个单位时间。</p><p>这个只是我们非常理论的分析,为了实验,针对上面的冒泡排序和插入排序的 Java 代码,我写了一个性能对比测试程序,随机生成 10000 个数组,每个数组中包含 200 个数据,然后在我的机器上分别用冒泡和插入排序算法来排序,冒泡排序算法大约 700ms 才能执行完成,而插入排序只需要 100ms 左右就能搞定!</p><p>所以,虽然冒泡排序和插入排序在时间复杂度上是一样的,都是 O(n2),但是如果我们希望把性能优化做到极致,那肯定首选插入排序。插入排序的算法思路也有很大的优化空间,我们只是讲了最基础的一种。如果你对插入排序的优化感兴趣,可以自行学习一下<a href="">希尔排序</a>。</p><h2 id="内容小结"><a href="#内容小结" class="headerlink" title="内容小结"></a>内容小结</h2><p>要想分析、评价一个排序算法,需要从执行效率、内存消耗和稳定性三个方面来看。因此,这一节,我带你分析了三种时间复杂度是 O(n2) 的排序算法,冒泡排序、插入排序、选择排序。你需要重点掌握的是它们的分析方法。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/11%2013.jpg" alt=""></p><p>这三种时间复杂度为 O(n2) 的排序算法中,冒泡排序、选择排序,可能就纯粹停留在理论的层面了,学习的目的也只是为了开拓思维,实际开发中应用并不多,但是插入排序还是挺有用的。后面讲排序优化的时候,我会讲到,有些编程语言中的排序函数的实现原理会用到插入排序算法。</p><p>今天讲的这三种排序算法,实现代码都非常简单,对于小规模数据的排序,用起来非常高效。但是在大规模数据排序的时候,这个时间复杂度还是稍微有点高,所以我们更倾向于用下一节要讲的时间复杂度为 O(nlogn) 的排序算法。</p><h2 id="课后思考"><a href="#课后思考" class="headerlink" title="课后思考"></a>课后思考</h2><p>我们讲过,特定算法是依赖特定的数据结构的。我们今天讲的几种排序算法,都是基于数组实现的。如果数据存储在链表中,这三种排序算法还能工作吗?如果能,那相应的时间、空间复杂度又是多少呢?</p><p>欢迎留言和我分享,我会第一时间给你反馈。</p>]]></content>
<summary type="html"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" cla</summary>
<category term="算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
</entry>
<entry>
<title>08| 递归:如何用三行代码找到“最终推荐人”?</title>
<link href="https://chanmoyun.gitee.io/2024/03/01/Data-structure/08.%E9%80%92%E5%BD%92%EF%BC%9A%E5%A6%82%E4%BD%95%E7%94%A8%E4%B8%89%E8%A1%8C%E4%BB%A3%E7%A0%81%E6%89%BE%E5%88%B0%E2%80%9C%E6%9C%80%E7%BB%88%E6%8E%A8%E8%8D%90%E4%BA%BA%E2%80%9D%EF%BC%9F/"/>
<id>https://chanmoyun.gitee.io/2024/03/01/Data-structure/08.%E9%80%92%E5%BD%92%EF%BC%9A%E5%A6%82%E4%BD%95%E7%94%A8%E4%B8%89%E8%A1%8C%E4%BB%A3%E7%A0%81%E6%89%BE%E5%88%B0%E2%80%9C%E6%9C%80%E7%BB%88%E6%8E%A8%E8%8D%90%E4%BA%BA%E2%80%9D%EF%BC%9F/</id>
<published>2024-02-29T16:00:00.000Z</published>
<updated>2024-03-01T12:17:00.205Z</updated>
<content type="html"><![CDATA[<link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" class="aplayer-secondary-script-marker"></script><script class="meting-secondary-script-marker" src="\assets\js\Meting.min.js"></script><h1 id="递归:如何用三行代码找到“最终推荐人”?"><a href="#递归:如何用三行代码找到“最终推荐人”?" class="headerlink" title="递归:如何用三行代码找到“最终推荐人”?"></a>递归:如何用三行代码找到“最终推荐人”?</h1><p>推荐注册返佣金的这个功能我想你应该不陌生吧?现在很多 App 都有这个功能。这个功能中,用户 A 推荐用户 B 来注册,用户 B 又推荐了用户 C 来注册。我们可以说,用户 C 的“最终推荐人”为用户 A,用户 B 的“最终推荐人”也为用户 A,而用户 A 没有“最终推荐人”。</p><p>一般来说,我们会通过数据库来记录这种推荐关系。在数据库表中,我们可以记录两行数据,其中 actor_id 表示用户 id,referrer_id 表示推荐人 id。</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/10%201.jpg" alt=""></p><p>基于这个背景,我的问题是,<strong>给定一个用户 ID,如何查找这个用户的“最终推荐人”?</strong> 带着这个问题,我们来学习今天的内容,递归(Recursion)!</p><h2 id="如何理解“递归”?"><a href="#如何理解“递归”?" class="headerlink" title="如何理解“递归”?"></a>如何理解“递归”?</h2><p>从我自己学习数据结构和算法的经历来看,我个人觉得,有两个最难理解的知识点,一个是<strong>动态规划</strong>,另一个就是<strong>递归</strong>。</p><p>递归是一种应用非常广泛的算法(或者编程技巧)。之后我们要讲的很多数据结构和算法的编码实现都要用到递归,比如 DFS 深度优先搜索、前中后序二叉树遍历等等。所以,搞懂递归非常重要,否则,后面复杂一些的数据结构和算法学起来就会比较吃力。</p><p>不过,别看我说了这么多,递归本身可是一点儿都不“高冷”,咱们生活中就有很多用到递归的例子。</p><p>周末你带着女朋友去电影院看电影,女朋友问你,咱们现在坐在第几排啊?电影院里面太黑了,看不清,没法数,现在你怎么办?</p><p>别忘了你是程序员,这个可难不倒你,递归就开始排上用场了。于是你就问前面一排的人他是第几排,你想只要在他的数字上加一,就知道自己在哪一排了。但是,前面的人也看不清啊,所以他也问他前面的人。就这样一排一排往前问,直到问到第一排的人,说我在第一排,然后再这样一排一排再把数字传回来。直到你前面的人告诉你他在哪一排,于是你就知道答案了。</p><p>这就是一个非常标准的递归求解问题的分解过程,去的过程叫“递”,回来的过程叫“归”。基本上,所有的递归问题都可以用递推公式来表示。刚刚这个生活中的例子,我们用递推公式将它表示出来就是这样的:</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">f(n)=f(n-1)+1 其中,f(1)=1</span><br></pre></td></tr></table></figure></p><p>f(n) 表示你想知道自己在哪一排,f(n-1) 表示前面一排所在的排数,f(1)=1 表示第一排的人知道自己在第一排。有了这个递推公式,我们就可以很轻松地将它改为递归代码,如下:</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">int f(int n) {</span><br><span class="line"> if (n == 1) return 1;</span><br><span class="line"> return f(n-1) + 1;</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><h2 id="递归需要满足的三个条件"><a href="#递归需要满足的三个条件" class="headerlink" title="递归需要满足的三个条件"></a>递归需要满足的三个条件</h2><p>刚刚这个例子是非常典型的递归,那究竟什么样的问题可以用递归来解决呢?我总结了三个条件,只要同时满足以下三个条件,就可以用递归来解决。</p><h3 id="1-一个问题的解可以分解为几个子问题的解何为子问题?"><a href="#1-一个问题的解可以分解为几个子问题的解何为子问题?" class="headerlink" title="1.一个问题的解可以分解为几个子问题的解何为子问题?"></a>1.一个问题的解可以分解为几个子问题的解何为子问题?</h3><p>子问题就是数据规模更小的问题。比如,前面讲的电影院的例子,你要知道,“自己在哪一排”的问题,可以分解为“前一排的人在哪一排”这样一个子问题。</p><h3 id="2-这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样"><a href="#2-这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样" class="headerlink" title="2.这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样"></a>2.这个问题与分解之后的子问题,除了数据规模不同,求解思路完全一样</h3><p>比如电影院那个例子,你求解“自己在哪一排”的思路,和前面一排人求解“自己在哪一排”的思路,是一模一样的。</p><h3 id="3-存在递归终止条件"><a href="#3-存在递归终止条件" class="headerlink" title="3.存在递归终止条件"></a>3.存在递归终止条件</h3><p>把问题分解为子问题,把子问题再分解为子子问题,一层一层分解下去,不能存在无限循环,这就需要有终止条件。</p><p>还是电影院的例子,第一排的人不需要再继续询问任何人,就知道自己在哪一排,也就是 f(1)=1,这就是递归的终止条件。</p><h2 id="如何编写递归代码?"><a href="#如何编写递归代码?" class="headerlink" title="如何编写递归代码?"></a>如何编写递归代码?</h2><p>刚刚铺垫了这么多,现在我们来看,如何来写递归代码?我个人觉得,写递归代码最关键的是<strong>写出递推公式,找到终止条件</strong>,剩下将递推公式转化为代码就很简单了。</p><p>你先记住这个理论。我举一个例子,带你一步一步实现一个递归代码,帮你理解。假如这里有 n 个台阶,每次你可以跨 1 个台阶或者 2 个台阶,请问走这 n 个台阶有多少种走法?如果有 7 个台阶,你可以 2,2,2,1 这样子上去,也可以 1,2,1,1,2 这样子上去,总之走法有很多,那如何用编程求得总共有多少种走法呢?</p><p>我们仔细想下,实际上,可以根据第一步的走法把所有走法分为两类,第一类是第一步走了 1 个台阶,另一类是第一步走了 2 个台阶。所以 n 个台阶的走法就等于先走 1 阶后,n-1 个台阶的走法 加上先走 2 阶后,n-2 个台阶的走法。用公式表示就是:</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">f(n) = f(n-1)+f(n-2)</span><br></pre></td></tr></table></figure></p><p>有了递推公式,递归代码基本上就完成了一半。我们再来看下终止条件。当有一个台阶时,我们不需要再继续递归,就只有一种走法。所以 f(1)=1。这个递归终止条件足够吗?我们可以用 n=2,n=3 这样比较小的数试验一下。</p><p>n=2 时,f(2)=f(1)+f(0)。如果递归终止条件只有一个 f(1)=1,那 f(2) 就无法求解了。所以除了 f(1)=1 这一个递归终止条件外,还要有 f(0)=1,表示走 0 个台阶有一种走法,不过这样子看起来就不符合正常的逻辑思维了。所以,我们可以把 f(2)=2 作为一种终止条件,表示走 2 个台阶,有两种走法,一步走完或者分两步来走。</p><p>所以,递归终止条件就是 f(1)=1,f(2)=2。这个时候,你可以再拿 n=3,n=4 来验证一下,这个终止条件是否足够并且正确。</p><p>我们把递归终止条件和刚刚得到的递推公式放到一起就是这样的:</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">f(1) = 1;</span><br><span class="line">f(2) = 2;</span><br><span class="line">f(n) = f(n-1)+f(n-2)</span><br></pre></td></tr></table></figure></p><p>有了这个公式,我们转化成递归代码就简单多了。最终的递归代码是这样的:</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">int f(int n) {</span><br><span class="line"> if (n == 1) return 1;</span><br><span class="line"> if (n == 2) return 2;</span><br><span class="line"> return f(n-1) + f(n-2);</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>我总结一下,<strong>写递归代码的关键就是找到如何将大问题分解为小问题的规律,并且基于此写出递推公式,然后再推敲终止条件,最后将递推公式和终止条件翻译成代码</strong>。</p><p>虽然我讲了这么多方法,但是作为初学者的你,现在是不是还是有种想不太清楚的感觉呢?实际上,我刚学递归的时候,也有这种感觉,这也是文章开头我说递归代码比较难理解的地方。</p><p>刚讲的电影院的例子,我们的递归调用只有一个分支,也就是说“一个问题只需要分解为一个子问题”,我们很容易能够想清楚“递“和”归”的每一个步骤,所以写起来、理解起来都不难。</p><p>但是,当我们面对的是一个问题要分解为多个子问题的情况,递归代码就没那么好理解了。</p><p>像我刚刚讲的第二个例子,人脑几乎没办法把整个“递”和“归”的过程一步一步都想清楚。</p><p>计算机擅长做重复的事情,所以递归正和它的胃口。而我们人脑更喜欢平铺直叙的思维方式。当我们看到递归时,我们总想把递归平铺展开,脑子里就会循环,一层一层往下调,然后再一层一层返回,试图想搞清楚计算机每一步都是怎么执行的,这样就很容易被绕进去。</p><p>对于递归代码,这种试图想清楚整个递和归过程的做法,实际上是进入了一个思维误区。很多时候,我们理解起来比较吃力,主要原因就是自己给自己制造了这种理解障碍。那正确的思维方式应该是怎样的呢?</p><p>如果一个问题 A 可以分解为若干子问题 B、C、D,你可以假设子问题 B、C、D 已经解决,在此基础上思考如何解决问题 A。而且,你只需要思考问题 A 与子问题 B、C、D 两层之间的关系即可,不需要一层一层往下思考子问题与子子问题,子子问题与子子子问题之间的关系。屏蔽掉递归细节,这样子理解起来就简单多了。</p><p>因此,<strong>编写递归代码的关键是,只要遇到递归,我们就把它抽象成一个递推公式,不用想一层层的调用关系,不要试图用人脑去分解递归的每个步骤</strong>。</p><h2 id="递归代码要警惕堆栈溢出"><a href="#递归代码要警惕堆栈溢出" class="headerlink" title="递归代码要警惕堆栈溢出"></a>递归代码要警惕堆栈溢出</h2><p>在实际的软件开发中,编写递归代码时,我们会遇到很多问题,比如堆栈溢出。而堆栈溢出会造成系统性崩溃,后果会非常严重。为什么递归代码容易造成堆栈溢出呢?</p><p>我们又该如何预防堆栈溢出呢?我在“栈”那一节讲过,函数调用会使用栈来保存临时变量。每调用一个函数,都会将临时变量封装为栈帧压入内存栈,等函数执行完成返回时,才出栈。系统栈或者虚拟机栈空间一般都不大。如果递归求解的数据规模很大,调用层次很深,一直压入栈,就会有堆栈溢出的风险。</p><p>比如前面的讲到的电影院的例子,如果我们将系统栈或者 JVM 堆栈大小设置为 1KB,在求解 f(19999) 时便会出现如下堆栈报错:</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">Exception in thread "main" java.lang.StackOverflowError</span><br></pre></td></tr></table></figure></p><p>那么,如何避免出现堆栈溢出呢?</p><p>我们可以通过在代码中限制递归调用的最大深度的方式来解决这个问题。递归调用超过一定深度(比如 1000)之后,我们就不继续往下再递归了,直接返回报错。还是电影院那个例子,我们可以改造成下面这样子,就可以避免堆栈溢出了。不过,我写的代码是伪代码,为了代码简洁,有些边界条件没有考虑,比如 x<=0。</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">// 全局变量,表示递归的深度。</span><br><span class="line">int depth = 0;</span><br><span class="line"></span><br><span class="line">int f(int n) {</span><br><span class="line"> ++depth;</span><br><span class="line"> if (depth > 1000) throw exception;</span><br><span class="line"> </span><br><span class="line"> if (n == 1) return 1;</span><br><span class="line"> return f(n-1) + 1;</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>但这种做法并不能完全解决问题,因为最大允许的递归深度跟当前线程剩余的栈空间大小有关,事先无法计算。如果实时计算,代码过于复杂,就会影响代码的可读性。所以,如果最大深度比较小,比如 10、50,就可以用这种方法,否则这种方法并不是很实用。</p><h2 id="递归代码要警惕重复计算"><a href="#递归代码要警惕重复计算" class="headerlink" title="递归代码要警惕重复计算"></a>递归代码要警惕重复计算</h2><p>除此之外,使用递归时还会出现重复计算的问题。刚才我讲的第二个递归代码的例子,如果我们把整个递归过程分解一下的话,那就是这样的:</p><p><img src="https://gitee.com/chanmoyun/pic-go/raw/master/2024-1/10%202.jpg" alt=""></p><p>从图中,我们可以直观地看到,想要计算 f(5),需要先计算 f(4) 和 f(3),而计算 f(4) 还需要计算 f(3),因此,f(3) 就被计算了很多次,这就是重复计算问题。</p><p>为了避免重复计算,我们可以通过一个数据结构(比如散列表)来保存已经求解过的 f(k)。当递归调用到 f(k) 时,先看下是否已经求解过了。如果是,则直接从散列表中取值返回,不需要重复计算,这样就能避免刚讲的问题了。</p><p>按照上面的思路,我们来改造一下刚才的代码:</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">public int f(int n) {</span><br><span class="line"> if (n == 1) return 1;</span><br><span class="line"> if (n == 2) return 2;</span><br><span class="line"> </span><br><span class="line"> // hasSolvedList可以理解成一个Map,key是n,value是f(n)</span><br><span class="line"> if (hasSolvedList.containsKey(n)) {</span><br><span class="line"> return hasSolvedList.get(n);</span><br><span class="line"> }</span><br><span class="line"> </span><br><span class="line"> int ret = f(n-1) + f(n-2);</span><br><span class="line"> hasSolvedList.put(n, ret);</span><br><span class="line"> return ret;</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>除了堆栈溢出、重复计算这两个常见的问题。递归代码还有很多别的问题。在时间效率上,递归代码里多了很多函数调用,当这些函数调用的数量较大时,就会积聚成一个可观的时间成本。在空间复杂度上,因为递归调用一次就会在内存栈中保存一次现场数据,所以在分析递归代码空间复杂度时,需要额外考虑这部分的开销,比如我们前面讲到的电影院递归代码,空间复杂度并不是 O(1),而是 O(n)。</p><h2 id="怎么将递归代码改写为非递归代码?"><a href="#怎么将递归代码改写为非递归代码?" class="headerlink" title="怎么将递归代码改写为非递归代码?"></a>怎么将递归代码改写为非递归代码?</h2><p>我们刚说了,递归有利有弊,利是递归代码的表达力很强,写起来非常简洁;而弊就是空间复杂度高、有堆栈溢出的风险、存在重复计算、过多的函数调用会耗时较多等问题。所以,在开发过程中,我们要根据实际情况来选择是否需要用递归的方式来实现。</p><p>那我们是否可以把递归代码改写为非递归代码呢?比如刚才那个电影院的例子,我们抛开场景,只看 f(x) =f(x-1)+1 这个递推公式。我们这样改写看看:</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">int f(int n) {</span><br><span class="line"> int ret = 1;</span><br><span class="line"> for (int i = 2; i <= n; ++i) {</span><br><span class="line"> ret = ret + 1;</span><br><span class="line"> }</span><br><span class="line"> return ret;</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>同样,第二个例子也可以改为非递归的实现方式。</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">int f(int n) {</span><br><span class="line"> if (n == 1) return 1;</span><br><span class="line"> if (n == 2) return 2;</span><br><span class="line"> </span><br><span class="line"> int ret = 0;</span><br><span class="line"> int pre = 2;</span><br><span class="line"> int prepre = 1;</span><br><span class="line"> for (int i = 3; i <= n; ++i) {</span><br><span class="line"> ret = pre + prepre;</span><br><span class="line"> prepre = pre;</span><br><span class="line"> pre = ret;</span><br><span class="line"> }</span><br><span class="line"> return ret;</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>那是不是所有的递归代码都可以改为这种<strong>迭代循环</strong>的非递归写法呢?</p><p>笼统地讲,是的。因为递归本身就是借助栈来实现的,只不过我们使用的栈是系统或者虚拟机本身提供的,我们没有感知罢了。如果我们自己在内存堆上实现栈,手动模拟入栈、出栈过程,这样任何递归代码都可以改写成看上去不是递归代码的样子。</p><p>但是这种思路实际上是将递归改为了“手动”递归,本质并没有变,而且也并没有解决前面讲到的某些问题,徒增了实现的复杂度。</p><h2 id="解答开篇"><a href="#解答开篇" class="headerlink" title="解答开篇"></a>解答开篇</h2><p>到此为止,递归相关的基础知识已经讲完了,咱们来看一下开篇的问题:如何找到“最终推荐人”?我的解决方案是这样的:</p><p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"></span><br><span class="line">long findRootReferrerId(long actorId) {</span><br><span class="line"> Long referrerId = select referrer_id from [table] where actor_id = actorId;</span><br><span class="line"> if (referrerId == null) return actorId;</span><br><span class="line"> return findRootReferrerId(referrerId);</span><br><span class="line">}</span><br></pre></td></tr></table></figure></p><p>是不是非常简洁?用三行代码就能搞定了,不过在实际项目中,上面的代码并不能工作,为什么呢?这里面有两个问题。</p><p>第一,如果递归很深,可能会有堆栈溢出的问题。</p><p>第二,如果数据库里存在脏数据,我们还需要处理由此产生的无限递归问题。比如 demo 环境下数据库中,测试工程师为了方便测试,会人为地插入一些数据,就会出现脏数据。如果 A 的推荐人是 B,B 的推荐人是 C,C 的推荐人是 A,这样就会发生死循环。</p><p>第一个问题,我前面已经解答过了,可以用限制递归深度来解决。第二个问题,也可以用限制递归深度来解决。不过,还有一个更高级的处理方法,就是自动检测 A-B-C-A 这种“环”的存在。如何来检测环的存在呢?这个我暂时不细说,你可以自己思考下,后面的章节我们还会讲。</p><h2 id="内容小结"><a href="#内容小结" class="headerlink" title="内容小结"></a>内容小结</h2><p>关于递归的知识,到这里就算全部讲完了。我来总结一下。</p><p>递归是一种非常高效、简洁的编码技巧。只要是满足“三个条件”的问题就可以通过递归代码来解决。</p><p>不过递归代码也比较难写、难理解。编写递归代码的关键就是不要把自己绕进去,正确姿势是写出递推公式,找出终止条件,然后再翻译成递归代码。</p><p>递归代码虽然简洁高效,但是,递归代码也有很多弊端。比如,堆栈溢出、重复计算、函数调用耗时多、空间复杂度高等,所以,在编写递归代码的时候,一定要控制好这些副作用。</p><h2 id="课后思考"><a href="#课后思考" class="headerlink" title="课后思考"></a>课后思考</h2><p>我们平时调试代码喜欢使用 IDE 的单步跟踪功能,像规模比较大、递归层次很深的递归代码,几乎无法使用这种调试方式。对于递归代码,你有什么好的调试方法呢?</p><p>欢迎留言和我分享,我会第一时间给你反馈。</p>]]></content>
<summary type="html"><link rel="stylesheet" class="aplayer-secondary-style-marker" href="\assets\css\APlayer.min.css"><script src="\assets\js\APlayer.min.js" cla</summary>
<category term="算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/categories/%E7%AE%97%E6%B3%95/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
<category term="数据结构与算法" scheme="https://chanmoyun.gitee.io/tags/%E6%95%B0%E6%8D%AE%E7%BB%93%E6%9E%84%E4%B8%8E%E7%AE%97%E6%B3%95/"/>
</entry>
</feed>