基础算法
这里的几种基础算法纯属个人研究收获。主要包括以下几种:
- 选择排序
- 冒泡排序
- 插入排序
- 希尔排序
- 归并排序
- 快速排序
这几种算法的思想很简单,只要看代码实现。每个人实现的代码可能形式上千差万别,但是遵循的思想一定会是一样的。
开始
选择排序
选择排序的基本思想是:每一次内循环,选择出一个最小/最大的元素放在数组的一端。其实这个内循环是一个基本实现版本,只要能找出列表中的剩余元素即可。
const selectSort = arr => {
for(let i = 0; i < arr.length; i++){
let minIndex = i // 假定这个位置为最小的元素
for(let j = i+1; j< arr.length; j++){
if(arr[minIndex] > arr[j]){
minIndex = j
}
}
// 将最小元素和 i 位置的元素换位置
const tmp = arr[i]
arr[i] = arr[minIndex]
arr[minIndex] = tmp
}
return arr
}
这个算法只要理解其思想,上面的代码就能写出来。
排序算法的特点如下:
- 每次排序,只会有最多两个元素换位置(有可能不换,如果数组本身就是有序的)
- 它的缺点也很明显,无论你输入的原始数组是怎么样的,都会进行排序。即使输入数组是有序的,也会按照规则再进行一次排序。所以,选择排序的运行时间与输入无关。
- 它的交换次数 N 只与数组本身的大小有关,因为每次只会对两个元素的位置进行交换。这是其他算法所不具备的特性。
冒泡排序
这种排序属于基本排序算法。它的思想是:从数组左侧开始,每两个数进行比较,将大数换到右侧,直到将大数换到数组的最右侧为止。当然,这个置换,也是在内循环内完成的。这样,每进行一次内循环,就会有一个数的位置被排定,直到所有所有元素被排定。
const bubble = arr => {
for(let i = 0; i < arr.length; i++){
for(let j = 0; j < arr.length - i -1; j++) {
if( arr[j] > arr[j+1] ){
// 换位置,将大数置换到右侧
const tmp = arr[j]
arr[j] = arr[j+1]
arr[j+1] = tmp
}
}
}
return arr
}
依然是理解思想就不难写出代码的算法,这里有一个细节需要稍微注意:由于每次最大数都是放在当次循环的最右边的位置,所以,内循环的终止条件是 arr.length - 1 - i
。
- 减 1 是为了保证索引不会越界。毕竟有
i + 1
的存在 - 减 i 是为了保证每次最右边的元素往前推进一位,直到推进到 0
插入排序
插入排序和前面两个基本算法有相似之处,它存在这样的假设:假设当前元素左侧的子数组是有序的,那么只要把当前元素插入到左侧数组的合适位置,就能保证左侧数组依然是有序的。当前,这里在插入到合适位置的过程中,伴随着插入位置右侧的元素往后挪一位,以腾出空位,给新元素插入。
理解了思想以后,代码就自然而然的出来了:
const insertSort = arr => {
for(let i = 1; i <arr.length; i++){
for(let j = i; j >=0; j--) {
if(arr[j] < arr[j-1]){
// 换位置
const tmp = arr[j]
arr[j] = arr[j-1]
arr[j-1] = tmp
}
}
}
return arr
}
希尔排序
这种排序是插入排序的进阶算法,它的基本思想和插入排序一样。但是实现却有差别。它的思想是,假定数组中相隔 h 的元素是有序的(数组就叫做 h 有序数组),当 h 很大时,就能将一个元素移动很远,在 h 不断变小的过程中,数组也在逐渐变得有序,直到 h 为 1 时,希尔排序就会进行一次直接插入排序,这时,数组就成为了有序数组。
它的文字描述略显晦涩,建议上 B 站
搜索 希尔排序
查看视频讲解,能更深刻的知道其含义和思想。
const xierSort = arr => {
let h = 1
const len = arr.length
// 首先需要对数组进行分割,这个分割可以根据个人需求更改
// 这里加 1 的目的是为了 Math.floor(h/3) 能取到 1
while(h < len /3) h = h*3 + 1
while(h>=1) {
// 除了 h 之外,这里的排序算法和插入排序一样
for( let i = h; i < len; i++ ) {
for(let j = i; j>=0; j-=h){
if( arr[j] < arr[j-h] ){
// 换位置
const tmp = arr[j]
arr[j] = arr[j-h]
arr[j-h] = tmp
}
}
}
h = Math.floor(h/3)
}
return arr
}
这个算法的其实就是在插入排序的基础上,加入了步长来减少最终的元素交换次数。
仔细观察这个排序,其实就是先计算出一个最大步长,然后从这个步长进行插入排序。每一次的排序,都会使数组像有序的方向靠拢,直到步长缩小为1。这时,进行一次直接插入排序,即可完整最终的排序。
归并排序
这个排序的基本思想是:将两个有序的数组,按照元素的大小关系,归并为一个更大的数组。下面先实现一个归并函数:
const merge = (arr, start, mid, end) => {
let s = start
let e = mid +1
// 复制源数组
const newArr = arr.map(Number)
for(let i = start; i < end; i++){
if(s > mid) {
// 左侧取尽
arr[i] = newArr[e++]
} else if(e > end) {
// 右侧取尽
arr[i] = newArr[s++]
} else if(newArr[e] > newArr[s]) {
// 左侧的比右侧的小,取左侧
arr[i] = newArr[s++]
} else {
// 否则取右侧的值
arr[i] = newArr[e++]
}
}
return arr
}
这个归并函数实现了从 mid
将数组分为从 [start, mid]
到 [mid+1, end]
两部分,然后进行原地归并。这里的原地归并指的是直接在原数组上进行操作,而不重新创建新的数组。
注意:归并时的大小比较,按照 newArr
的元素进行比较,因为 arr
是原地换位置,对应位置的元素会发生改变。
递归实现归并排序
按照归并的思想:将两个有序的数组进行归并,即可得到一个更大的有序数组。对于一个无序的数组,可以从中间进行拆分为两个数组,然后对左右两个数组进行排序,再对这两个数组进行归并即可。这就实现了归并排序。
使用递归的目的是为了将左右数组不停地拆分,直到每个数组中的元素小于等于1个为止,这时的所有“左右”数组都是有序的,这样子逐层计算,就能最终将一个数组拆分为最小的单元(一个数组一个元素)。然后对每个拆分单元进行归并处理,最终就能是整个数组有序。
const diguiSort = arr => {
// 对拆分的左右数组进行排序
const sort = (arr, start, end) => {
if(start >= end){
// 此时数组至多有一个元素,认为数组是有序的
return
}
const mid = Math.floor((end-start)/2) + start
sort(arr, start, mid) // 对左侧的数组进行排序
sort(arr, mid+1, end) // 对右侧的数组进行排序
merge(arr, start, mid, end) // 对左右有序的数组进行归并处理
}
sort(arr, 0, arr.length - 1)
return arr
}
在实现递归时,千万不要去想具体的递归细节。如果你想弄清楚计算细节,可以画出递归树进行分析。只需要分析出每一步需要做什么,按照思路写出公共的计算逻辑和终结条件,将计算交给递归过程即可。
递推实现归并排序
递推过程和递归过程相反,是从小往大推出最终结果的过程,就是常说的 自下而上。而递归是从大往小分解问题得出结果的过程,就是常说的自上而下。基本概念解释完毕,一般能使用递归的逻辑,也可以使用递推计算,不过大部分情况下递推的逻辑会更难想。
递推实现的基本思路:从子数组 length = 1
开始,每次扩充为前面 size
的二倍,也就是 size += size
。直到扩充到数组的长度为止。
const dituiSort = arr =>{
for(let i = 1; i < arr.length; i+=i){
for(let j = 0; j < arr.length; j+=i*2) {
merge(arr, j, i-1+j,Math.min(j+i*2-1, arr.length -1))
}
}
}
可以看到,递推的代码很少,很精简,但是其中包含的计算逻辑会更多,更复杂。下面是简答你的分析:
- 前面提到,
length
从 1 开始,每循环一次就翻倍,也就是会经历1 2 4 8 16
这样的子数组的length
变化。这里需要注意的是,每次归并的是两个子数组,所以,每个归并后的数组包含的元素个数是length * 2
。 start = j
这个没什么争议end = Math.min(j+i*2 -1, arr.length -1)
表示每次归并的最后一个元素的位置,取最小值是为了防止索引越界。而j + i*2 - 1
是因为子数组开始的索引是j
。数组的长度为i*2
, 最后一个元素的索引为length - 1
。结合前两个计算,即可得出最后一个元素的索引为j+ i *2 - 1
。
快速排序
这个算法的基本思路是:先选出一个排定的元素,将小于这个数的都放在这个数的左边,大于这个数的都放在这个数的右边,然后再对左右两边排序,再将三部分结合,即可得到一个有序数组。
实现一个 partition 函数
这个函数用于处理将大于给定元素的数放在这个数的右边,小于的放在左边。
const partition = (arr,s, e) => {
// 选定第 0 个元素为给定元素
const ele = arr[s]
let start = s
let end = e + 1
while(true) {
// 选择到一个左侧 比基准元素大的元素
while(ele > arr[++start]) if(start === e) break;
// 选择到一个右侧 比基元素小的元素
while(ele < arr[--end]) if(end === s) break;
// 两侧的扫描有交叉,结束循环
if(start >= end) break; // 结束外层循环
// 将左侧比基准元素大的元素和右侧壁记住元素的小的元素互换位置
const tmp = arr[start]
arr[start] = arr[end]
arr[end] = tmp
}
// 将基准元素换到外层循环结束的位置
arr[s] = arr[end]
arr[end] = ele
return end // 这个位置就是数组的分割位置
}
上面的这个函数目的在于计算对于数组 arr
来说,基准元素的位置在哪。同时,将数组调整为了满足 [0, end-1] <= [end] <=[end+1, arr.length -1]
的数组。
这里在 start 和 end
出现交叉后,end
一定会在左侧数组的索引最大的元素位置上。所以,基准元素和这个元素换位置,不影响左侧数据都比基准元素小的规则。
下面的例子展示了上述的过程:
const arr = [0,1]
partition(arr, 0, 1)
// 取基准元素为 arr[0]
const ele = arr[0]
// 按照 partition 运行规则,初始值
end = 1
start = 0
//开始执行循环体
end = 0
start = 1
start > end //直接结束内存循环。
// 此时 end 正好落在了左侧做大索引上,这里就是 0。
快速排序的实现
const kuaipaiSort = arr => {
const sort = (arr, start, end) =>{
if(start >=end){
return
}
const splitIndex = partition(arr,start, end)
// 排除已经排定的位置splitIndex
sort(arr, start, splitIndex - 1)
sort(arr, splitIndex + 1, end)
}
sort(arr, 0, arr.length - 1)
return arr
}
总结
这几种排序算法都不难,如果不想原地操作,可以按照思路,构建额外的数组用于“左”“右”两个数组的储存,在理解的深入以后,再尝试使用原地操作的方式书写算法。
这篇文章对于这几个算法的记录仅仅是个人学习过程的记录,内容不够详尽和系统。后续有时间会继续补充。