为面试作一些算法相关的准备~~
一、时间复杂度
通常使用最差的时间复杂度来衡量一个算法的好坏。
常数时间 O(1)
代表这个操作和数据量没关系,是一个固定时间的操作,比如说四则运算。
对于一个算法来说,可能会计算出如下操作次数 aN + 1
,N
代表数据量。那么该算法的时间复杂度就是 O(N)
。因为我们在计算时间复杂度的时候,数据量通常是非常大的,这时候低阶项和常数项可以忽略不计。
当然可能会出现两个算法都是 O(N) 的时间复杂度,那么对比两个算法的好坏就要通过对比低阶项和常数项了。
二、位运算
十进制和二进制之间的转换
位运算在算法中很有用,速度可以比四则运算快很多。
十进制33
可以看成是32 + 1
, 32
为2^5
,1
为2^0
。所以就是 100001
.
那么二进制 100001
同理,首位是 2^5
,末位是2^0
,相加得出 33
左移 <<
10 << 1
// 20
复制代码
左移就是将二进制全部往左移动,10 在二进制中表示为 1010 ,左移一位后变成 10100 ,转换为十进制也就是 20,所以基本可以把左移看成以下公式a * (2 ^ b)
左移 <<
10 >> 1
// 5
复制代码
算数右移就是将二进制全部往右移动并去除多余的右边,10 在二进制中表示为 1010 ,右移一位后变成 101 ,转换为十进制也就是 5,所以基本可以把右移看成以下公式int v = a / (2 ^ b)
- 右移的一个用处就是在二分法的时候,计算中间值
13 >> 1 // -> 6
三、排序
两通用函数
function checkArray(arr) {
if(!arr | arr.length <= 2) {
return false
}
return true
}
// 交换数组中的两个值
function swap(array, left, right) {
let rightValue = array[right]
array[right] = array[left]
array[left] = rightValue
}
复制代码
1、冒泡排序
冒泡排序的原理如下,从第一个元素开始,把当前元素和下一个索引元素进行比较。如果当前元素大,那么就交换位置,重复操作直到比较到最后一个元素,那么此时最后一个元素就是该数组中最大的数。下一轮重复以上操作,但是此时最后一个元素已经是最大数了,所以不需要再比较最后一个元素,只需要比较到 length - 1 的位置。
// 冒泡排序
function sort_1 (arr) {
// 外循环决定比较几轮次
for(var i = arr.length-1; i > 0; i--) {
// 内循环决定每一轮最少比较几次
for(var j = 0; j < i; j ++) {
if(arr[j] > arr[j+1]) {
swap(arr, j, j+1);
}
}
}
}
/**
共有 5 元素,
一共比4次。
第一轮 比较 4次
第二轮 3次
第三轮 2次
第四轮 1次
最后一轮 0次
**/
复制代码
- 冒泡就是,每次循环将该循环中的较大元素往后面放,小的往前面放。O(n*n)
2、插入排序
插入排序原理: 将数组分为两部分,前一部分是已经排好的序列,之后是未排序的序列,我们每次从未排序的序列中拿第一个,根前面已经排好的序列中从头进行比较,找到合适的位置,进行放置。我们最初假设第一次的时候,第一个是排好的。
// 插入排序
function sort_2(arr) {
// 对除第一个元素之外的元素进行向前插入
for(var i = 1 ; i < arr.length; i++){
// 取到未排序的序列的第一个元素,往左侧进行插入。
for(var j = 0; j < i ; j++) {
if(arr[j] > arr[i]) {
swap(arr, j, i);
}
}
}
}
复制代码
3、选择排序
选择排序原理:每次从待排序的序列中找到最小的放在前面
// 每次从待排序的序列中找到最小的放在前面
function sort_3(arr) {
// 一共需要确定几次最小值 n-1次
for(var i = 0 ; i < arr.length-1 ; i++) {
// 从已经排好的之后开始,找到最小值,放入该序列的最开始,
for(var j = i + 1; j < arr.length; j++) {
if(arr[i] > arr[j]) {
swap(arr, i, j);
}
}
}
}
/**
3 89 72 43 1
3之后的序列中找到比3还小的,然后替换3的位置
1 89 72 43 3
89之后的序列中找比89还小的,然后替换89的位置
1 3 72 43 89
...
**/
复制代码
4、快速排序
基本思想是选取一个记录作为枢轴,经过一趟排序,将整段序列分为两个部分,其中一部分的值都小于枢轴,另一部分都大于枢轴。然后继续对这两部分继续进行排序,从而使整个序列达到有序。
// 将数组划分为两部分,最终基值会在数组的中间某一位置,左侧的都比base小,右侧的都比 base大。
function partion(arr, left, right) {
let base = arr[left]; //基准值,默认取数组第一个元素
// 当left和right指针相遇的时候,也就是重合的时候就跳出循环
while(left<right) {
// 先从右侧开始,找比base小的,然后交换位置
while(left < right && arr[right] >= base) {
right --;
}
if(left<right) {
swap(arr, left, right);
}
// 再从左侧开始,找比base大的,交换位置
while(left < right && arr[left] <= base) {
left ++;
}
if( left < right) {
swap(arr, left, right)
}
}
arr[left] = base;
return left;
}
// 快速排序
function quickSort(arr, left, right) {
let dp;
if(left < right) {
dp = partion(arr, left, right); // 第一步: 定位第一个基值,左侧的小,右侧的大
quickSort(arr, left, dp-1); // 第二步: 排序左侧的
quickSort(arr, dp+1, right); // 第三步: 排序右侧的
}
}
复制代码
算法题:
输入: [2,0,2,1,1,0]
输出: [0,0,1,1,2,2]
复制代码
使用三路快排的思想:
function swap(array, left, right) {
let rightValue = array[right]
array[right] = array[left]
array[left] = rightValue
}
var sortColors = function(nums) {
let left = -1
let right = nums.length
let i = 0
// 下标如果遇到 right,说明已经排序完成
while (i < right) {
// 遇到0,往左侧放
if (nums[i] == 0) {
swap(nums, i++, ++left)
} else if (nums[i] == 1) {
// 1自然就到中间了
i++
} else {
// 遇到 2 往右侧放
swap(nums, i, --right)
}
}
}
复制代码
找出数组中第 K 大的元素
复制代码
使用快排来确定第K小。
function partition(items, left, right) {
var pivot = items[Math.floor((right + left) / 2)],
i = left,
j = right;
while (i <= j) {
while (items[i] < pivot) {
i++;
}
while (items[j] > pivot) {
j--;
}
if (i <= j) {
swap(items, i, j);
i++;
j--;
}
}
return i;
}
function quickSort(items, left, right, k) {
k = k -1
while (left < right) {
// 分离数组后获得比基准树大的第一个元素索引
let index = partition(items, left, right)
// 判断该索引和 k 的大小
if (index < k) {
left = index + 1
} else if (index > k) {
right = index - 1
} else {
break
}
}
return items[k]
}
// first call
var result = quickSort(a, 0, a.length - 1, 7);
console.log(result);
复制代码
四、链表
与数组相似,链表也是一种线性数据结构。这里有一个例子:
正如你所看到的,链表中的每个元素实际上是一个单独的对象,而所有对象都通过每个元素中的引用字段链接在一起。
链表结构可以充分利用计算机内存空间,实现灵活的内存动态管理。但是链表失去了数组随机读取的优点,同时链表由于增加了结点的指针域,空间开销比较大。同时链表在遍历上会话费较多的时间,但是在插入和删除上却是较为方便的。
链表有两种类型:单链表和双链表。上面给出的例子是一个单链表,这里有一个双链表的例子:
1、添加操作 - 单链表
与数组不同,我们不需要将所有元素移动到插入元素之后。因此,您可以在 O(1) 时间复杂度中将新结点插入到链表中,这非常高效。
2、删除操作 - 单链表
首先从头遍历链表,直到我们找到前一个结点 prev。
- 实现一个链表:
function Node(value) {
this.value = value;
this.next = null;
}
function LinkList() {
this.head = null;
this.size = 0;
}
/**
* [链表末尾插入]
* @param {[type]} val [description]
*/
LinkList.prototype.push = function(val) {
var node = new Node(val);
if(this.head == null) {
this.head = node;
} else {
var current = this.head;
while(current.next != null) {
current = current.next;
}
current.next = node;
}
this.size++;
}
/**
* 往某一个节点后插入一个节点
* @type {Node}
*/
LinkList.prototype.insertAfter = function(value, item) {
var node = new Node(value);
var current = this.find(item); // 找到该节点
if(current == null) {
console.log('未找到该元素');
}
node.next = current.next;
current.next = node;
this.size ++;
}
/**
* 查找某节点
* @param {[type]} item [元素值]
* @return {[type]} [description]
*/
LinkList.prototype.find = function(item) {
var currentNode = this.head;
if (currentNode == null) {
console.log("这是一个空链表!!!");
return null;
}
if (currentNode.value === item) {
return currentNode;
}
while(currentNode&¤tNode.value != item) {
currentNode = currentNode.next;
}
return currentNode;
}
/**
* 展示
* @return {[type]} [description]
*/
LinkList.prototype.show = function() {
console.log('======start====')
var current = this.head;
var index = 0;
while(current) {
console.log('序号:', ++index, current.value);
current = current.next;
}
console.log('======end====')
}
function remove(value) {
var previous = this.findPrevious(value);
var current = this.find(value);
if (previous == null) {
return console.log('链表中找不到被删除的元素');
}
previous.next = current.next;
length--;
}
/**
* 删除某一个节点
* 找到前一个节点prev。 prev.next = current.next
* @param {[type]} value [description]
* @return {[type]} [description]
*/
LinkList.prototype.remove = function(value) {
var previous = this.findPrevious(value);
console.log('前一个节点为', previous.value);
var current = this.find(value);
if (previous == null) {
console.log('链表中找不到被删除的元素');
return
}
previous.next = current.next;
this.size--;
}
/**
* 找到某一个节点的前一个节点
* @param {[type]} value [description]
* @return {[type]} [description]
*/
LinkList.prototype.findPrevious = function(value) {
var current = this.head;
if (current == null) {
console.log('这是一个空链表');
return null;
}
while(current) {
if(current.next.value == value) {
return current;
}
current = current.next;
}
return null;
}
var l = new LinkList();
l.push(1);
l.push(3);
l.push(4);
l.push(5);
l.push(6);
l.show();
l.remove(3);
l.show();
复制代码
单链表反转:
LinkList.prototype.reveser = function () {
var head = this.head;
if ( head == undefined || !head.next == undefined ) return ;
var p,q,r;
p = head;
q = p.next;
head.next = undefined;
while(q){
r = q.next;
q.next = p;
p = q;
q = r;
}
this.head = p;
};
复制代码
原理:
五、树
树 是一种经常用到的数据结构,用来模拟具有树状结构性质的数据集合。
树里的每一个节点有一个根植和一个包含所有子节点的列表。从图的观点来看,树也可视为一个拥有N
个节点和N-1
条边的一个有向无环图。
1、二叉树
二叉树是一种更为典型的树树状结构。如它名字所描述的那样,二叉树是每个节点最多有两个子树的树结构,通常子树被称作 “左子树”和“右子树”。
2、二叉搜索树
【二叉树搜索树】 (BST) 是二叉树的一种,但是它只允许你在左侧节点存储(比父节点)小的值,在右侧节点存储(比父节点)大(或者等于)的值。
这种存储方式很适合于数据搜索。如下图所示,当需要查找 6 的时候,因为需要查找的值比根节点的值大,所以只需要在根节点的右子树上寻找,大大提高了搜索效率。
二叉搜索树的插入操作
- 插入一个6:
首先会检测二叉树是否为空?
第二检测根节点(key[6] < root[11]为真),然后继续检测(node.left不是null),到达node.left[7]节点。
第三检测(key[6] < key[7]为真),然后继续检测(node.left不是null),到达node.left[5]节点。
最后检测(key[6] < key[5]为真),然后继续检测(node.right不是null),为空添加在key[5]右节点添加key[6]。
复制代码
- 移除一个节点5:
首先会检测二叉树是否为空?
第二检测根节点(key[5] = root[11]为真),然后检查(key[5] < root[11])然后继续检测(node.left不是null),到达node.left[7]节点。
第三检测根节点(key[5] = key[7]为真),然后检查(key[5] < key[7]为真),然后继续检测(node.left不是null),到达node.left[5]节点。
第四检测(key[5] = key[5]为真),然后删除 key[5]节点。
最后(key[5] )子节点,key[3]的父节点改成原来key[5]的父节点key[7]。
复制代码
3、树的遍历
前序遍历
中序遍历
后序遍历
值得注意的是,当你删除树中的节点时,删除过程将按照后序遍历的顺序进行。 也就是说,当你删除一个节点时,你将首先删除它的左节点和它的右边的节点,然后再删除节点本身。
另外,后序在数学表达中被广泛使用。
4、二叉搜索树的实现
/**
* 节点类
* @param {[type]} val [description]
* @constructor
*/
function Node(val) {
this.key = val;
this.left = this.right = null;
}
/**
* 二叉搜索树 BST
* @constructor
*/
function BinaryTree() {
this.root = null;
this.size = 0;
}
BinaryTree.prototype.constructor = BinaryTree;
/**
* 向二叉树插入一个新的值
* @param {[type]} key [值]
* @return {[type]} [description]
*/
BinaryTree.prototype.insert = function(key) {
var newNode = new Node(key);
if(this.root === null) {
this.root = newNode;
} else {
this.insertNode(this.root, newNode);
}
}
/**
* 往某一个节点下插入一个新的节点
* @param {[type]} node [description]
* @param {[type]} newNode [description]
* @return {[type]} [description]
*/
BinaryTree.prototype.insertNode = function(node,newNode) {
if(node.key > newNode.key){
if(node.left==null){
node.left=newNode;
}else{
this.insertNode(node.left,newNode)
}
}else{
if(node.right==null){
node.right=newNode
} else {
this.insertNode(node.right,newNode)
}
}
}
/**
* 获取根节点
* @return {[type]} [description]
*/
BinaryTree.prototype.getRoot = function() {
return this.root;
}
/**
* 中序遍历,从根开始
* @return {[type]} [description]
*/
BinaryTree.prototype.inOrderTraverse = function(callback) {
this.inOrderTraverseNode(this.root, callback);
}
/**
* 中序遍历从某一节点开始的子节点
* @param {[type]} node [description]
* @param {Function} callback [description]
* @return {[type]} [description]
*/
BinaryTree.prototype.inOrderTraverseNode = function(node, callback) {
if(node!=null) {
this.inOrderTraverseNode(node.left, callback); // 遍历左侧
callback(node.key); // 输出节点
this.inOrderTraverseNode(node.right, callback); // 遍历右侧
}
}
/**
* 先序遍历,从根开始
* @return {[type]} [description]
*/
BinaryTree.prototype.preOrderTraverse = function(callback) {
this.preOrderTraverseNode(this.root, callback);
}
/**
* 中序遍历从某一节点开始的子节点
* @param {[type]} node [description]
* @param {Function} callback [description]
* @return {[type]} [description]
*/
BinaryTree.prototype.preOrderTraverseNode = function(node, callback) {
if(node!=null) {
callback(node.key); // 输出节点
this.preOrderTraverseNode(node.left, callback); // 遍历左侧
this.preOrderTraverseNode(node.right, callback); // 遍历右侧
}
}
/**
* 后序遍历,从根开始
* @return {[type]} [description]
*/
BinaryTree.prototype.postOrderTraverse = function(callback) {
this.postOrderTraverseNode(this.root, callback);
}
/**
* 后序遍历从某一节点开始的子节点
* @param {[type]} node [description]
* @param {Function} callback [description]
* @return {[type]} [description]
*/
BinaryTree.prototype.postOrderTraverseNode = function(node, callback) {
if(node!=null) {
this.postOrderTraverseNode(node.left, callback); // 遍历左侧
this.postOrderTraverseNode(node.right, callback); // 遍历右侧
callback(node.key); // 输出节点
}
}
// =========测试
var tree = new BinaryTree();
tree.insert(11);
tree.insert(7);
tree.insert(15);
tree.insert(5);
tree.insert(3);
tree.insert(9);
tree.insert(8);
tree.insert(10);
tree.insert(13);
tree.insert(12);
tree.insert(14);
tree.insert(20);
tree.insert(18);
tree.insert(25);
tree.insert(6);
// //11,7,15,5,3,9,8,10,13,12,14,20,18,25,6
function printNode(value){
console.log(value);
}
// 中序遍历
// tree.inOrderTraverse(printNode);
// 先序遍历
// tree.preOrderTraverse(printNode);
// 后序遍历
tree.postOrderTraverse(printNode);
复制代码
以上实现了一个二叉搜索树,并测试了三种遍历手段。
以上的这几种遍历都可以称之为深度遍历,对应的还有种遍历叫做广度遍历,也就是一层层地遍历树。对于广度遍历来说,我们需要利用之前讲过的队列结构来完成。
以下是广度遍历:
原理:父节点出队列,他的左右子节点进队列
breadthTraversal() {
if (!this.root) return null
let q = new Queue()
// 将根节点入队
q.enQueue(this.root)
// 循环判断队列是否为空,为空
// 代表树遍历完毕
while (!q.isEmpty()) {
// 将队首出队,判断是否有左右子树
// 有的话,就先左后右入队
let n = q.deQueue()
console.log(n.value)
if (n.left) q.enQueue(n.left)
if (n.right) q.enQueue(n.right)
}
}
复制代码
如何在树中寻找最小值或最大数。因为二分搜索树的特性,所以最小值一定在根节点的最左边,最大值在最右边。代码如下:
/**
* 获取整个树的最小值
* @return {[type]} [description]
*/
BinaryTree.prototype.getMin = function() {
return this.getMinNode(this.root);
}
/**
* 获取某一个节点之下的最小值
* @param {[type]} node [description]
* @return {[type]} [description]
*/
BinaryTree.prototype.getMinNode = function(node) {
if(node) {
while(node && node.left !== null) {
node = node.left;
}
return node.key;
}
return null;
}
/**
* 获取整个树的最大值
* @return {[type]} [description]
*/
BinaryTree.prototype.getMax = function() {
return this.getMaxNode(this.root);
}
/**
* 获取某一个节点之下的最大值
* @param {[type]} node [description]
* @return {[type]} [description]
*/
BinaryTree.prototype.getMaxNode = function(node) {
if(node) {
while(node && node.right !== null) {
node = node.right;
}
return node.key;
}
return null;
}
复制代码
- 如果是获取第k小元素
思路: 使用中序遍历,得到的就是一个排好序的数组,然后取其第k-1项。
- 二叉搜索树的删除节点操作:
会存在以下几种情况
需要删除的节点没有子树
需要删除的节点只有一条子树
需要删除的节点有左右两条树
复制代码
代码如下:
/**
* 移除某一个元素
* @param {[type]} element [description]
* @return {[type]} [description]
*/
BinaryTree.prototype.remove = function(element) {
return this.removeNode(this.root, element);
}
BinaryTree.prototype.removeNode = function(node, element) {
if(node === null) {
return null;
}
// 首先确定要删除的节点的位置
if(element < node.key) {
// 如果是比当前节点小,就去左侧找
// 返回一个新的左子树
node.left = this.removeNode(node.left, element);
return node;
} else if(element > node.key) {
// 如果是比当前节点大,就去右侧找
// 返回一个新的右子树
node.right = this.removeNode(node.right, element);
return node;
} else {
// 找到该节点之后
// 叶子节点,直接置为null
if(node.left === null && node.right === null) {
node = null;
return node;
}
// 左子树为空
if(node.left === null) {
node = node.right;
return node;
} else if(node.right === null) {
// 右子树为空
node = node.left;
return node;
}
// 左右都不为空
var aux = this.findMinNode(node.right); // 找出右子树的最小
node.key = aux.key;
node.right = this.removeNode(node.right, aux.key);
return node;
}
}
function findMinNode(node) {
if (node) {
while (node && node.left !== null) {
node = node.left
}
return node
}
return null
}
复制代码
4、AVL树(平衡二叉树)
AVL树本质上是一颗二叉查找树,但是它又具有以下特点:它是一棵空树或它的左右两个子树的高度差的绝对值不超过1,并且左右两个子树都是一棵平衡二叉树。在AVL树中任何节点的两个子树的高度最大差别为一,所以它也被称为平衡二叉树。下面是平衡二叉树和非平衡二叉树对比的例图:
AVL树的作用
二分搜索树实际在业务中是受到限制的,因为并不是严格的 O(logN),在极端情况下会退化成链表,比如加入一组升序的数字就会造成这种情况。由于在删除时,我们总是选择将待删除节点的后继代替它本身,这样就会造成总是右边的节点数目减少,以至于树向左偏沉。这同时也会造成树的平衡性受到破坏。
例如:我们按顺序将一组数据1,2,3,4,5,6分别插入到一颗空二叉查找树和AVL树中,插入的结果如下图:
AVL树的基本操作
AVL树的操作基本和二叉查找树一样,这里我们关注的是两个变化很大的操作:插入和删除! 我们要做一些特殊的处理,包括:单旋转和双旋转: