演算法複習

meils發表於2019-03-25

為面試作一些演算法相關的準備~~

一、時間複雜度

通常使用最差的時間複雜度來衡量一個演算法的好壞。

常數時間 O(1) 代表這個操作和資料量沒關係,是一個固定時間的操作,比如說四則運算。

對於一個演算法來說,可能會計算出如下操作次數 aN + 1N 代表資料量。那麼該演算法的時間複雜度就是 O(N)。因為我們在計算時間複雜度的時候,資料量通常是非常大的,這時候低階項和常數項可以忽略不計。

當然可能會出現兩個演算法都是 O(N) 的時間複雜度,那麼對比兩個演算法的好壞就要通過對比低階項和常數項了。

十分鐘搞定時間複雜度(演算法的時間複雜度)

演算法時間複雜度與空間複雜度分析

二、位運算

十進位制和二進位制之間的轉換

位運算在演算法中很有用,速度可以比四則運算快很多。

十進位制33可以看成是32 + 1322^512^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&&currentNode.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樹的操作基本和二叉查詢樹一樣,這裡我們關注的是兩個變化很大的操作:插入和刪除! 我們要做一些特殊的處理,包括:單旋轉和雙旋轉:

演算法複習

演算法複習

演算法複習

相關文章