How the front-end of front-line business can quickly learn algorithms: direct attack on essence and disassembly

How the front-end of front-line business can quickly learn algorithms: direct attack on essence and disassembly

With the advent of the legendary era of more and more volumes, algorithms are used more and more. Compared with the role of architecture and tools with more controllable time, the front-end business of the front-line business is under great pressure, and there are many and urgent trivial tasks. Either a complex animation has only one day to develop, or it is adjusted with the visual design students for a few pixels in the afternoon, or suddenly there is user feedback on the line. If there is a problem, it must be solved immediately, It is difficult to have a large block of time to learn and practice algorithms.

However, front-line students also have their own advantages. First of all, it has strong hands-on ability, that is, the ability of code code. Otherwise, it would have been sprayed by students such as product manager, operation, back-end, design and testing; Secondly, he has strong ability to locate and debug problems. If he can only write bugs and can't solve them, he still needs his teammates to wipe his ass. he is estimated to have been fired and can't eat the first-line rice.

In this way, we develop our strengths and avoid our weaknesses, avoid learning too many technical details, only learn the most essential theory of the algorithm, and then make the rest of our own wheels for practical operation.

Some students will ask, will this fall into low-level repetition?
The answer is No. Why? Because most complex problems have no simple solution.

So what if we can't build wheels for complex problems?
The method is very simple. Disassemble the Dafa.
Step 1: we decompose this complex problem into several simple subproblems and one or more difficult subproblems.
Step 2: solve the simple sub problem
Step 3: disassemble the remaining difficult subproblems into simple subproblems and more difficult subproblems, and repeat step 2. Until the difficult problems have been disassembled small enough, we only need a little time to learn the most difficult point.

Many students don't believe it's so simple, so facts speak louder than words. Let's give examples.

Principle: put the small one on the left and the large one on the right

Let's take an example of the principle of sorting method. This principle can't be simpler. It's called putting the small one on the left and the large one on the right.

Let's refine this basic principle.
If we come to a new number, we put it to the left of a larger number or to the right of a smaller number. This thing is a binary search tree.
If we specify a number, we put the smaller one on the left and the larger one on the right. This thing is called quick sorting.

Then we start coding. js is very suitable for writing code with complex data structures such as trees and graphs. We don't have to learn STL containers like C + +. We can do it directly.

Left small right large binary tree

To be divided into left and right, the simplest structure is a binary tree. Each node has references to the left and right child nodes:

class TreeNode {
  constructor(key) {
    this.key = key;
    this.leftChild = null;
    this.rightChild = null;
  }
}

Then, according to the principle of small on the left and large on the right, we can write a function to insert nodes.
The principle is very simple. If it is smaller than the key value of the current node, it depends on whether the left child node of the node is empty. If it is empty, a new node will become the left child node of the current node.
If the left child already has one, then recurse and compare it with the left child node.
If it is greater than the key of the current node, go to the right node. The process is the same as that of the left node.

function insertNode(node, key) {
  if (key < node.key) {
    if (node.leftChild === null) {
      node.leftChild = new TreeNode(key);
    } else {
      insertNode(node.leftChild, key);
    }
  } else {
    if (node.rightChild === null) {
      node.rightChild = new TreeNode(key);
    } else {
      insertNode(node.rightChild, key);
    }
  }
}

Does the tree always have a root? Then we build a data structure for the root node:

class BinarySearchTree {
  constructor() {
    this.root = null;
  }

  insert(key) {
    if (this.root === null) {
      this.root = new TreeNode(key);
    } else {
      insertNode(this.root, key);
    }
  }
}

Then we can add new nodes:

let bst1 = new BinarySearchTree();

bst1.insert(1);
bst1.insert(2);
bst1.insert(4);

How to sort? First read the small one on the left, then the middle one, and finally the right one. This is the middle order traversal. To reflect the visitor design pattern, we pass in the function object that handles the key.

function walkTree(node, func) {
  if (node === null) {
    return;
  }
  if (node.leftChild !== null) {
    walkTree(node.leftChild, func);
  }
  if (node.key !== null) {
    func(node.key);
  }
  if (node.rightChild !== null) {
    walkTree(node.rightChild, func);
  }
}

We encapsulate a walk function for the tree class:

class BinarySearchTree {
  constructor() {
    this.root = null;
  }

  insert(key) {
    if (this.root === null) {
      this.root = new TreeNode(key);
    } else {
      insertNode(this.root, key);
    }
  }

  walk(func) {
    walkTree(this.root, func);
  }
}

We can print the sorted values by passing a console.log:

bst1.walk(console.log);

After sorting, we also want to add a search function to see if there is this element in this binary sorting tree.

It doesn't take much trouble. If it's bigger than the current one, look to the left, and if it's smaller than the current one, look to the right.

function searchNode(node, key) {
  if (node === null || node.key === null) {
    return null;
  }
  if (node.key === key) {
    return key;
  }
  if (key < node.key) {
    return searchNode(node.leftChild, key);
  } else {
    return searchNode(node.rightChild, key);
  }
}

So far, we don't need to read at all. We can also write a binary sort tree that can work.

If you have studied the data structure course, you will find that the only complex binary sort tree deletion element I didn't talk about. Because it's not available yet. But the advantage of not talking about it is that we happily learned the binary sort tree while saving our brains.
If you have learned to delete binary sort tree nodes and find it easy to understand, here is a little hint. The problem may be a little more complex than you think. So when the introduction to algorithms was published in the third edition in 2009, the deletion algorithms used in the first two editions were updated, which is the same as those used in most books.

Quick sort

Next, let's look at the processing method of left small and right large by lump, which is called quick sorting.

Let's start with a disassembly method. Let's assume that there is a heap splitting function called partition. We pass the array, start position and end position, and it returns the middle position to us. The value before the middle position is smaller than the middle value, and the value after the middle position is larger than the middle value.
Therefore, the quick sort can be written as follows. First divide the heap, and then recursively call itself for the left and right heaps respectively:

function qsort(list1, start, end) {
  let middle = partition(list1, start, end);

  if (middle - start > 1) {
    qsort(list1, start, middle - 1);
  }
  if (end - middle > 1) {
    qsort(list1, middle + 1, end);
  }
}

Let's concentrate on the partition function.
How do you write it?
The principle is that the small one is on the left and the large one is on the right.
The easiest way is to use a new empty array, the small one from the left and the large one from the right.
Let's take a value. For example, the last number is the middle number. Then compare each number from the beginning. If it is larger than the middle number, write it to the right of the new array, and then move the right pointer one grid to the left. If it is smaller than the middle number, it is written to the left of the new array, and then the left pointer moves one grid to the right.
Finally, write the value of the new array back to the old array and you're done.

At this time, compared with other languages, the advantages of js are brought into play. The new array doesn't need new at all, and it doesn't need to be long. It can be written directly where it is used.

function partition(list1, start, end) {
  //Take the last value as the benchmark
  let middle_value = list1[end];

  let list2 = [];
  let left = start;
  let right = end;

  // end is an intermediate number, so there is no need to judge. You can write it at last
  for (let i = start; i < end; i += 1) {
    if (list1[i] > middle_value) {
      list2[right] = list1[i];
      right -= 1;
    } else {
      list2[left] = list1[i];
      left += 1;
    }
  }
  list2[left] = middle_value;

  //Copy the results of the new array to the old array
  for (let j = start; j <= end; j += 1) {
    list1[j] = list2[j];
  }
  return left;
}

Let's write a piece of code to test the effect:

let list3 = [];
list3.push(3);
list3.push(100);
list3.push(8, 9, 10);
list3.push(13,12,7);

console.log(list3);
qsort(list3,0,list3.length-1);
console.log(list3);

The output is as follows:

[
   3, 100,  8, 9,
  10,  13, 12, 7
]
[
   3,  7,  8,   9,
  10, 12, 13, 100
]

Easier to read version: use a stack on the left and right

Although the above algorithm is more in line with the characteristics of js, students with Java and other language backgrounds don't seem to be used to it.
We can write a more understandable version.

First, the partition function needs to be changed frequently. We change it into an incoming parameter:

function qsort2(list1, start, end, partition) {
  let middle = partition(list1, start, end);

  if (middle - start > 1) {
    qsort(list1, start, middle - 1, partition);
  }
  if (end - middle > 1) {
    qsort(list1, middle + 1, end, partition);
  }
}

It seems troublesome to use an array, so let's make two:

  let left = [];
  let right = [];

  for (let i = start; i < end; i += 1) {
    if (list1[i] > middle_value) {
      right.push(list1[i]);
    } else {
      left.push(list1[i]);
    }
  }

Then assemble the two arrays and the intermediate values together:

  const list2 = left.concat(middle_value).concat(right);

The complete code is as follows:

function partition1(list1, start, end) {
  //Take the last value as the benchmark
  let middle_value = list1[end];

  let left = [];
  let right = [];

  for (let i = start; i < end; i += 1) {
    if (list1[i] > middle_value) {
      right.push(list1[i]);
    } else {
      left.push(list1[i]);
    }
  }
  
  const len = left.length;
  const list2 = left.concat(middle_value).concat(right);

  for (let j = start; j <= end; j += 1) {
    list1[j] = list2[j-start];
  }

  return start+len;
}

The first evolution of rapid sequencing: under the exchange of mismatches

After learning quick sorting, we continue to reflect on the basic principle: the small ones on the left and the large ones on the right.
If we can find a large one on the left, there must be a small one on the right. As soon as we exchanged the two, we took a step towards completion.
Of course, if you can't find it on the left and not on the right, it's finished.

So the problem is broken down as follows:

  1. First, find the one larger than the middle value from left to right. If the right boundary has not been found, the partition has succeeded.
  2. If found, write down the location. Then start from the right to the left until you find one that is not less than the middle value, and then write it down.
  3. Swap the positions of the two values just now. After the exchange, the left side of the small value and the right side of the large value are good.
  4. Now take the right side of the small value just exchanged as the left boundary and the right side of the large value as the right boundary. Repeat the judgment in step 1.

We write roughly as follows:

    while (left < right) {
        while (list1[left] <= middle_value && left < right) {
            left += 1;
        }
        while (list1[right] > middle_value && right > left) {
            right -= 1;
        }
        if (left === right) {
            break;
        } else {
            swap(list1, left, right);
        }
    }

Now we realize the benefits of disassembling the partition function from qsort. We only need to replace the partition function with a new algorithm, and qsort does not need to be changed at all.

We can now write the second edition of partition:

function partition2(list1, start, end) {
    //Take the last value as the benchmark
    let middle_value = list1[end];

    let left = start;
    let right = end;

    while (left < right) {
        while (list1[left] <= middle_value && left < right) {
            left += 1;
        }
        while (list1[right] > middle_value && right > left) {
            right -= 1;
        }
        if (left === right) {
            break;
        } else {
            swap(list1, left, right);
        }
    }
    return left;
}

swap is used to exchange two elements in the array:

function swap(list1, pos1, pos2) {
  let tmp = list1[pos1];
  list1[pos1] = list1[pos2];
  list1[pos2] = tmp;
}

Write a use case to test:

let list3 = [];
list3.push(3);
list3.push(100);
list3.push(8, 9, 10);
list3.push(13, 12, 7);
list3.push(25, 26, 27);

console.log(list3);
qsort2(list3, 0, list3.length - 1, partition2);
console.log(list3);

The output is as follows:

[
   3, 100, 8,  9, 10,
  13,  12, 7, 25, 26,
  27
]
[
    3,  7,  8,  9, 10,
   12, 13, 25, 26, 27,
  100
]

This version is a little more complicated than the previous one. After all, it was invented by Sir Tony hall, a Turing Award winner. When writing, you can write more use cases to test. But the idea is easy to understand.

We add a line of log to qsort2 and print out where the median value returned by partition is.

function qsort2(list1, start, end, partition) {
    let middle = partition(list1, start, end);
    console.log(`start=${start},middle=${middle},end=${end}`);

    if (middle - start > 1) {
        qsort2(list1, start, middle - 1, partition);
    }
    if (end - middle > 0) {
        qsort2(list1, middle, end, partition);
    }
}

Let's start with the simplest two elements:

list8 = [7,3];
qsort2(list8, 0, list8.length - 1, partition2);
console.log(list8);

The output is as follows:

start=0,middle=1,end=1
[ 3, 7 ]

The median value is 1, that is, those greater than 1 are 0 and 1 after 2 and those less than or equal to 1.

In this way, if it doesn't look clear, we can print the original value of partition 2 and the divided result for the first time, which is more clear at a glance:

function partition2(list1, start, end) {
    console.log(`before partition ${list1.slice(start,end+1)}`);
    //Take the last value as the benchmark
    let middle_value = list1[end];

    let left = start;
    let right = end;

    while (left < right) {
        while (list1[left] <= middle_value && left < right) {
            left += 1;
        }
        while (list1[right] > middle_value && right > left) {
            right -= 1;
        }
        if (left === right) {
            break;
        } else {
            swap(list1, left, right);
        }
    }
    console.log(`after partition ${list1.slice(start,end+1)}`);
    return left;
}

Let's look at the minimum examples of the above two elements:

list8 = [7,3];
qsort2(list8, 0, list8.length - 1, partition2);
console.log(list8);

Output is:

before partition 7,3
after partition 3,7
[ 3, 7 ]

The description is divided once, from [7,3] to [3,7]

Let's be a little more complicated. Let's have a three element:

list7 = [3,  7,  1];
qsort2(list7, 0, list7.length - 1, partition2);
console.log(list7);

We can see that there are two rounds of this: first from [3,7,1] to [1,7,3], and then divided into the latter two to [1,3,7]

before partition 3,7,1
after partition 1,7,3
before partition 7,3
after partition 3,7
[ 1, 3, 7 ]
[
   3, 100, 8,  9, 10,
  13,  12, 7, 25, 26,
  27
]
before partition 3,100,8,9,10,13,12,7,25,26,27
after partition 3,27,8,9,10,13,12,7,25,26,100
before partition 3,27,8,9,10,13,12,7,25,26
after partition 3,26,8,9,10,13,12,7,25,27
before partition 3,26,8,9,10,13,12,7,25
after partition 3,25,8,9,10,13,12,7,26
before partition 3,25,8,9,10,13,12,7
after partition 3,7,8,9,10,13,12,25
before partition 3,7
after partition 3,7
before partition 8,9,10,13,12,25
after partition 8,9,10,13,12,25
before partition 8,9,10,13,12
after partition 8,9,10,12,13
before partition 8,9,10,12
after partition 8,9,10,12
before partition 8,9,10
after partition 8,9,10
before partition 8,9
after partition 8,9
[
    3,  7,  8,  9, 10,
   12, 13, 25, 26, 27,
  100
]

The second evolution of rapid sequencing: capacity expansion method

The first improved method can be called sentinel method. A sentry on the left and right found the enemy, then exchanged prisoners of war, and finally both sides were their own.
Our second improvement has fully experienced the powerful power of dismantling Dafa. We call it "construction method".

The principle is that the array to be sorted is divided into three areas: the first area is less than or equal to the middle value, the second area is greater than the middle value, and the third area is to be processed.

At the beginning, the first area is empty, the second area is empty, and all are the third area.
Let's think about the following situations:

  1. If the first element of the third area is less than or equal to the middle value. Then the length of the first zone is increased by 1, and the starting position of the second zone is moved one later.
  2. If the first element of the third area is greater than the middle value. Then the length of the second zone is 1.
  3. For the following element, if its value is less than or equal to the intermediate value. Then judge whether the length of the second area is 0. If it is 0, the first area is in front of it, and the capacity of the first area will be expanded, that is, the length of the first area plus 1, and the starting position of the second area will be moved one bit later. If the length of the second area is not 0, that is, the new small element is behind the large element area, the small element is exchanged to the first place of the large element, the capacity of the first area is expanded, and the starting position of the second area is moved back.
  4. If the following element is greater than the middle value, the second area will be expanded. This does not involve switching operations.

Let's consolidate:

  1. When the first element to be processed is greater than the middle value, the capacity of the second area will be expanded (the starting position remains unchanged, and the length will be increased by 1)
  2. When the first element to be processed is less than or equal to the intermediate value, if the second area is empty, the starting position of the second area will be moved one bit later. If the second area is not empty, exchange the starting position of the small element and the second area, expand the capacity of the first area, and move the starting position of the second area one bit later.

We define the following variables:

function partition3(list1, start, end) {
    let start1 = start;
    let start2 = start;
    let start3 = start;
    let length1 = start2 - start1;
    let length2 = start3 - start2;

It is found that only start2 indicates the beginning of the second area and start3 indicates the beginning of the third area.
The starting point of the first zone is always start and will not change. length1 and length2 can be calculated.

When processing a new element, start3 must add 1, otherwise the program is written incorrectly. If it is less than the median value, start 2 plus 1 for exchange. If it is greater than the median, start2 remains unchanged.

Don't look at all the previous instigation. It's very simple to write code:

    let start2 = start;
    let start3 = start;

    let middle_value = list1[end];

    while(start3<=end){
        if(list1[start3]<=middle_value){
            let length2 = start3 - start2;
            if(length2 > 0){
                swap(list1,start2,start3);
            }
            start2 += 1;
        }
        start3 += 1;
    }

Then we add the log point, and the position of the returned intermediate value is start2-1.

function partition3(list1, start, end) {
    console.log(`before partition ${list1.slice(start,end+1)}`);
    let start2 = start;
    let start3 = start;

    let middle_value = list1[end];

    while(start3<=end){
        if(list1[start3]<=middle_value){
            let length2 = start3 - start2;
            if(length2 > 0){
                swap(list1,start2,start3);
            }
            start2 += 1;
        }
        start3 += 1;
    }
    console.log(`after partition ${list1.slice(start,end+1)}`);
    console.log(`start2=${start2}`);
    return start2-1;
}

Let's take the above data to test and change the partition algorithm from partition2 to partition3:

let list3 = [];
list3.push(3);
list3.push(100);
list3.push(8, 9, 10);
list3.push(13, 12, 7);
list3.push(25, 26, 27);

console.log(list3);
qsort2(list3, 0, list3.length - 1, partition3);
console.log(list3);

Let's look at the sorting process:

[
   3, 100, 8,  9, 10,
  13,  12, 7, 25, 26,
  27
]
before partition 3,100,8,9,10,13,12,7,25,26,27
after partition 3,8,9,10,13,12,7,25,26,27,100
start2=10

The middle value of the first round is 27, and there is only 100 in the region. So start2=10

before partition 3,8,9,10,13,12,7,25,26
after partition 3,8,9,10,13,12,7,25,26
start2=9
before partition 3,8,9,10,13,12,7,25
after partition 3,8,9,10,13,12,7,25
start2=8

The second and third rounds are all less than or equal to the middle value, and the second area is empty.

before partition 3,8,9,10,13,12,7
after partition 3,7,9,10,13,12,8
start2=2
before partition 7,9,10,13,12,8
after partition 7,8,10,13,12,9
start2=3
before partition 8,10,13,12,9
after partition 8,9,13,12,10
start2=4
before partition 9,13,12,10
after partition 9,10,12,13
start2=5
before partition 10,12,13
after partition 10,12,13
start2=7
before partition 10,12
after partition 10,12
start2=6
before partition 27,100
after partition 27,100
start2=11
[
    3,  7,  8,  9, 10,
   12, 13, 25, 26, 27,
  100
]

The third evolution of quick sorting: randomization of intermediate values

In the previous examples, we all take the value of the last element as the intermediate value. When we look at the previous logs, we will often find that the last element is the largest, resulting in a complete empty run for the previous data in this round.

Instead, we randomly select an element as the intermediate value:

function getMiddle(start, end) {
  const len = end - start;
  const pos = start + Math.floor(Math.random() * len);
  return pos;
}

Then, let's swap this element to the last position.

function partition4(list1, start, end) {
  let start2 = start;
  let start3 = start;

  let pos = getMiddle(start, end);
  swap(list1, pos, end);

  let middle_value = list1[end];

  while (start3 <= end) {
    if (list1[start3] <= middle_value) {
      let length2 = start3 - start2;
      if (length2 > 0) {
        swap(list1, start2, start3);
      }
      start2 += 1;
    }
    start3 += 1;
  }
  return start2 - 1;
}

Summary

To sum up, we can see that as long as we roughly understand the idea and have the ability of coding and debugging, we can invent many methods introduced in the algorithm and data structure book.
In this way, we can learn more from our predecessors' experience in less learning time. The time spent on coding and debugging is worth it, which makes us have a deeper impression. When you encounter variant problems, you can understand the code more thoroughly. If you need handwritten code for the interview, it's not false.

Tags: Algorithm data structure AI

Posted on Mon, 27 Sep 2021 12:56:17 -0400 by sniped22