常考算法题解析
这一章节依托于数据结构的内容,毕竟了解了数据结构我们才能写出更好的算法。
对于大部分公司的面试来说,排序的内容已经足以应付了,由此为了更好的符合大众需求,排序的内容是最多的。当然如果你还想冲击更好的公司,那么整一个章节的内容都是需要掌握的。对于字节跳动这类十分看重算法的公司来说,这一章节是远远不够的,剑指Offer应该是你更好的选择。
有一个可视化界面会相对减少点学习的难度,具体可以阅读 algorithm-visualizer 这个仓库。
位运算
在进入正题之前,我们先来学习一下位运算的内容。因为位运算在算法中很有用,速度可以比四则运算快很多。
在学习位运算之前应该知道十进制如何转二进制,二进制如何转十进制。这里说明下简单的计算方式
- 十进制
33
可以看成是32 + 1
,并且33
应该是六位二进制的(因为33
近似32
,而32
是 2 的五次方,所以是六位),那么 十进制33
就是100001
,只要是2
的次方,那么就是1
否则都为0
2^1 = 2, 2^2 = 4, 2^3 = 8, 2^4 = 16, 2^5 = 32, 2^6 = 64, 2^7 = 128
- 那么二进制
100001
同理,首位是2^5
,末位是2^0
,相加得出 33
算数左移 <<
10 << 1 // -> 20
左移就是将二进制全部往左移动,10
在二进制中表示为 1010
,左移一位后变成 10100
,转换为十进制也就是 20,所以基本可以把左移看成以下公式 a * (2 ^ b)
算数右移 >>
10 >> 1 // -> 5
算数右移就是将二进制全部往右移动并去除多余的右边,10
在二进制中表示为 1010
,右移一位后变成 101
,转换为十进制也就是 5,所以基本可以把右移看成以下公式 a / (2 ^ b)
条件
右移很好用,比如可以用在二分算法中取中间值
13 >> 1 // -> 6
按位操作
按位与
每一位都为 1,结果才为 1,否则为0
8 & 7 // -> 0
// 1000 & 0111 -> 0000 -> 0
按位或
其中一位为 1,结果就是 1
8 | 7 // -> 15
// 1000 | 0111 -> 1111 -> 15
按位异或
每一位都不同,结果才为 1
8 ^ 7 // -> 15
8 ^ 8 // -> 0
// 1000 ^ 0111 -> 1111 -> 15
// 1000 ^ 1000 -> 0000 -> 0
从以上代码中可以发现按位异或就是不进位加法
面试题:两个数不使用四则运算得出和
这道题中可以按位异或,因为按位异或就是不进位加法,8 ^ 8 = 0
如果进位了,就是 16 了,所以我们只需要将两个数进行异或操作,然后进位。那么也就是说两个二进制都是 1 的位置,左边应该有一个进位 1,所以可以得出以下公式 a + b = (a ^ b) + ((a & b) << 1)
,然后通过迭代的方式模拟加法
function sum (a, b) {
if (a == 0) return b
if (b == 0) return a
let newA = a ^ b
let newB = (a & b) << 1
return sum(newA, newB)
}
排序
以下两个函数是排序中会用到的通用函数,就不一一写了
function checkArray(array) {
if (!array) return
}
function swap(array, left, right) {
let rightValue = array[right]
array[right] = array[left]
array[left] = rightValue
}
冒泡排序
冒泡排序的原理如下,从第一个元素开始,把当前元素和下一个索引元素进行比较。如果当前元素大,那么就交换位置,重复操作直到比较到最后一个元素,那么此时最后一个元素就是该数组中最大的数。下一轮重复以上操作,但是此时最后一个元素已经是最大数了,所以不需要再比较最后一个元素,只需要比较到 length - 2
的位置。
function bubble(array) {
checkArray(array);
for (let i = array.length - 1; i > 0; i--) {
// 从 0 到 `length - 1` 遍历
for (let j = 0; j < i; j++) {
if (array[j] > array[j + 1]) swap(array, j, j + 1)
}
}
return array;
}
该算法的操作次数是一个等差数列 n + (n - 1) + (n - 2) + 1
,去掉常数项以后得出时间复杂度是 O(n * n)
插入排序
插入排序的原理如下。第一个元素默认是已排序元素,取出下一个元素和当前元素比较,如果当前元素大就交换位置。那么此时第一个元素就是当前的最小数,所以下次取出操作从第三个元素开始,向前对比,重复之前的操作。
以下是实现该算法的代码
function insertion(array) {
checkArray(array);
for (let i = 1; i < array.length; i++) {
for (let j = i - 1; j >= 0 && array[j] > array[j + 1]; j--)
swap(array, j, j + 1);
}
return array;
}
该算法的操作次数是一个等差数列 n + (n - 1) + (n - 2) + 1
,去掉常数项以后得出时间复杂度是 O(n * n)
选择排序
选择排序的原理如下。遍历数组,设置最小值的索引为 0,如果取出的值比当前最小值小,就替换最小值索引,遍历完成后,将第一个元素和最小值索引上的值交换。如上操作后,第一个元素就是数组中的最小值,下次遍历就可以从索引 1 开始重复上述操作。
以下是实现该算法的代码
function selection(array) {
checkArray(array);
for (let i = 0; i < array.length - 1; i++) {
let minIndex = i;
for (let j = i + 1; j < array.length; j++) {
minIndex = array[j] < array[minIndex] ? j : minIndex;
}
swap(array, i, minIndex);
}
return array;
}
该算法的操作次数是一个等差数列 n + (n - 1) + (n - 2) + 1
,去掉常数项以后得出时间复杂度是 O(n * n)
归并排序
归并排序的原理如下。递归的将数组两两分开直到最多包含两个元素,然后将数组排序合并,最终合并为排序好的数组。假设我有一组数组 [3, 1, 2, 8, 9, 7, 6]
,中间数索引是 3,先排序数组 [3, 1, 2, 8]
。在这个左边数组上,继续拆分直到变成数组包含两个元素(如果数组长度是奇数的话,会有一个拆分数组只包含一个元素)。然后排序数组 [3, 1]
和 [2, 8]
,然后再排序数组 [1, 3, 2, 8]
,这样左边数组就排序完成,然后按照以上思路排序右边数组,最后将数组 [1, 2, 3, 8]
和 [6, 7, 9]
排序。
以下是实现该算法的代码
function sort(array) {
checkArray(array);
mergeSort(array, 0, array.length - 1);
return array;
}
function mergeSort(array, left, right) {
// 左右索引相同说明已经只有一个数
if (left === right) return;
// 等同于 `left + (right - left) / 2`
// 相比 `(left + right) / 2` 来说更加安全,不会溢出
// 使用位运算是因为位运算比四则运算快
let mid = parseInt(left + ((right - left) >> 1));
mergeSort(array, left, mid);
mergeSort(array, mid + 1