ELI5ing LeetCode : 최소 크기 하위 배열 합계

저는 지금 몇 년 동안 JS 개발자로 프리랜서로 일해 왔으며 재미있었지만 업계에 뛰어들 때가 되었다고 결정했습니다. 기술 인터뷰 프로세스를 위한 준비의 일환으로 저는 LeetCode 문제 솔루션을 5살짜리 자신이 화이트보드에 설 수 있도록 간단한 단계로 세분화하고 있습니다.

나는 [슬라이딩 윈도우] 기술( )을 사용하는 솔루션으로 Minimum Size Subarray Sum 문제로 이 일련의 광산을 시작하고 있습니다. quadratic to linear time 에서 알고리즘의 시간 복잡도를 줄이기 위해 자주 사용되는 패턴입니다. 추가 보너스로 이 솔루션은 일정한 공간에서도 작동합니다!

작성자 참고 사항: 이것은 이 솔루션의 각 단계를 철저하게 살펴보기 위한 것이므로 관련된 루프의 각 반복에 대해 자세히 설명할 것입니다. 간결한 내용을 찾으셨다면 아래 목차를 사용하여 요약된 버전으로 이동할 수 있습니다.

목차


  • Challenge Description
  • Solution Summary
  • Variable Set-up
  • The Loop
  • Opening the Window
  • Slide from the Left
  • Slide to the Right
  • Modeling the Window
  • Return
  • One more thing...
  • Wrap-up

  • 시작하자...

    챌린지 설명

     
    From LeetCode:

    Given an array of positive integers nums and a positive integer target, return the minimal length of a contiguous subarray [numsl, numsl+1, ..., numsr-1, numsr] of which the sum is greater than or equal to target. If there is no such subarray, return 0 instead.

     
    If you're not used to looking at these problems, this might be tough to parse. In simpler terms, the prompt is saying that we are given two inputs:

    • nums : An array of whole numbers, all of them positive.
    • target : A target number, also positive and whole.

    With these two inputs, we need to write a function that can find the length of a segment of the array whose members all add up to equal or exceed the target. But, this array segment must be the shortest possible segment that achieves our target.

    Fortunately, there's another condition which stipulates that the segment must be contiguous , so we don't have to worry about cases where the shortest possible array could be created by combining elements not directly touching each other. When our algorithm finishes execution it should return an output that is either:

    • The length of our shortest possible array segment.

    Or, if the target can't be reached by summing even the _entire _ nums array:

    • Return 0 .

    Example input/output from LeetCode:

    Input: target = 7, nums = [2,3,1,2,4,3]
    Output: 2
    Explanation: The subarray [4,3] 
    has the minimal length under the problem constraint.

     
     

    솔루션: 슬라이딩 윈도우

    The sliding window is one approach to solving algorithm problems that would otherwise require nested loops. It's function is basically just to take a segment of an array by looking at all the values between two points, hence the term "window". The algorithm then moves those two points based on some condition, hence "sliding window." Applied to our challenge, the basic flow of the algorithm can be thought of like this: first, we'll find a window that adds up to our target, then slide it closed by one index from the left and check if we still hit the target. If not, we slide the window open from the right by one and check it again. We repeat this sliding process until we've slide the window all the way through the input array, all the while keeping track of the smallest window opening that hit the target.

    In explaining the code, we'll keep things relatively simple for the sake of ultra clarity. We'll walk through each step of the application of the sliding window using the following easy to understand and visualize target and nums inputs:

    // target = 4
    // nums = [1, 1, 1, 1, 2, 2, 4]
    
    const minSubArrayLen = function(target, nums) {
    
    };
    
     
    Variable Setup

    The first thing we'll do is set up the variables we'll need in order to track all the values we'll need to come to a confident solution.

    The first of these will be curMinLength . This is where we'll track our current best answer as we slide through our input array, which means it's also the variable that will contain our final answer. This will also be the value that we return at the end of the function. So what value do we instantiate this variable with?
    The answer is the largest possible number, for which we'll use the JS keyword Infinity . The reason we use Infinity here is that this variable must start with a value that is larger, or at least the same size as any potential input array length. We could just use nums.length + 1 here, but that's a little verbose in comparison to the simple and clean Infinity to.

    const minSubArrayLen = function(target, nums) {
      let curMinLength = Infinity;
    
      // functionality goes here...
    };
    

    The next variables we need to create will be the components of our 'window'. Remember that a window is in reality just a set of two points that demarcate the edges of a subarray. So to start, we'll create a variable left , which will act as the trailing edge of the window. Since we'll be moving from left to right in this solution, we'll give left a value of 0 , so that our window starts at the 0th index of the input array.

    The second component is the other end of the window, so we'll call this variable right . However, we won't yet give this a value, since we don't have quite enough information to decide how large our window should be at this point.

    The last component of our window will be called sum and it will hold the value equal to the sum of all elements in contained within the current window. We'll give this a starting value of 0 as well, so that we can add the sum of our first window when the time comes.

    Here's our code so far:

    const minSubArrayLen = function(target, nums) {
      let curMinLen = Infinity
      let left = 0
      let right
      let sum = 0
    
      // functionality goes here...
    };
    
     
    Logic Step 1: The Loop

    Now that we have our variables set up, it's time for the first bit of logic. We know that we need to traverse the input array, so we'll do this in the typical fashion by writing a for loop that starts at 0 , and increments a counter by 1 while the counter is less than the length of the input array. Typical, right?

    Here's where things get interesting.

    Instead of the traditional i counter, our for loop will use our right variable as its incrementing value. This is the mechanism by which we will slide the right side of our window! So now we have all the components of our window set up, and our code looks like this:

    const minSubArrayLen = function(target, nums) {
      let curMinLen = Infinity
      let left = 0
      let right
      let sum = 0
    
      for (let right = 0; right < nums.length; right++) {
    
        // ???...
      }
    };
    
     
    Logic Step 2: The Opening the Window

    This is the point at which our window will become recognizable as such - we're now going to establish its initial size, or "open" it. Our goal with this step will be to get an initial segment of the input array whose elements add together to reach the target input value. We'll do this by using our already written for loop to iterate through nums , and adding each value to our sum variable. That is, we allow the for loop to increment right until sum has reached the target value. In order to detect when this has happened, we we'll need a conditional of some sort. In this solution, we're going to use a while loop to track sum / target equality. We'll see just why we need a loop rather than a simple if/else here in just a bit... But for now, let's review the code once again:
    const minSubArrayLen = function(target, nums) {
      let curMinLen = Infinity
      let left = 0
      let right
      let sum = 0
    
      for (let right = 0; right < nums.length; right++) {
        // collect num values 
        sum = sum + nums[right]
    
        while (sum >= target) {
    
          // do something....
        }
    
      }
    };
    

     
    So. Before we get inside the while loop, the outer for loop will go about its business incrementing the right variable, adding each input element's value to the sum on each pass. Eventually, we reach a point at which that sum is equal to the target.
    Let's take a moment to envision our window at this point. It should have its left edge still at the start of the array, and its right edge at the index where adding to our sum finally brought it to the target . A written representation might look like this:

      // indeces:     0, 1, 2, 3, 4, 5, 6
      // arr:        [1, 1, 1, 1, 2, 2, 4]
      // pointers:    L------->R
      // window:     [1, 1, 1, 1]
      // sum:         1+ 1+ 1+ 1 = 4 (=== target: 4)
    
     
    Logic Step 3: Slide from the Left

    This next step is where we'll write the real meat of the algorithm - the part where we write the logic that governs the size and position of our window. Earlier, we set up a variable curMinLen to track our best (shortest) result. When we get inside our while loop, the first thing we do is to compare curMinLen to the length of our the subarray that allowed us into the loop. We then reset curMinLen with whichever is the lesser between the current curMinLen and the length of our current window. And since Infinity is real big, on our first pass through, the length of our subarray will be shorter, and so curMinLen will now take on that value. This is the mechanism by which we will always have our best answer stored in our code for return at the end of execution. Let's take a look at how this could be written in JS:
    const minSubArrayLen = function(target, nums) {
      let curMinLen = Infinity
      let left = 0
      let right
      let sum = 0
    
      for (let right = 0; right < nums.length; right++) {
        // collect num values 
        sum = sum + nums[right]
    
        while (sum >= target) {      
          curMinLen = Math.min(curMinLen, right - left + 1)
    
          // next step...
        }
    
      }
    };
    

    The expression right - left + 1 might be a little confusing to look at, but it's really just the length value of our current window. Here's how it looks when we spell it out:

        // arr:        [1, 1, 1, 1, 2, 2, 4]
        // indices:     0, 1, 2, 3, 4, 5, 6
        // pointers:    L        R
                        0        3
    
                        R - L + 1
                        3 - 0 + 1 = 4
    

    We're using the pointer indices to figure out the full length of the window. The + 1 is just there to compensate for the fact that arrays in JS are zero indexed; think of this as the inverse of getting the last index in an array by doing array.length - 1 .

    So now that we know what we're looking at with our curMinLen line, what we do next? This is where we perform step 1 of the slide, which will require two steps. First, remove the first element from our current sum; second, move our left pointer one position forward. Here's the mental model:

        // start of while loop
        [1, 1, 1, 1, 2, 2, 4]
         L        R 
        [1+ 1+ 1+ 1] = 4 // sum
    
        // reduce window from left
        [1, 1, 1, 1, 2, 2, 4]
            L     R
           [1+ 1+ 1] = 3 // sum reduced by previous val. at L  
    
    

    The way we'll write these two steps in our code is as follows:

    
        // ...previous code
    
        while (sum >= target) {      
          curMinLen = Math.min(curMinLen, right - left + 1)
    
          sum  = sum - nums[left]    // 1. reduce sum
          left++           // 2. move left pointer up 
        }
    
    
    
     
    Logic Step 4: Slide to the Right

    You may not have realized it right away, but when we reduced our sum , it fell below the value of the target . If our sum is less than the target, then we no longer meet the condition for staying inside the while loop, which means we're back into the previous context, the for loop. And what do for loops do at the end of each cycle? They increment counters. In our case, that counter is the right pointer of our window. This is how we slide the window open. We perform the inverse of the steps we took to close the window from the left, by first moving the pointer, and then adding its value to our sum :
    
        for (let right = 0; right < nums.length; right++) {
                                              // ^ 1. Move pointer 
        sum = sum + nums[right]  // 2. Add new val to sum
    
    
        // ...subsequent code
      }
    };
    
    
    

    Since we've also just completed one full cycle, let's check in with the mental model to see where we go next:

        [1, 1, 1, 1, 2, 2, 4]
            L        R
            1+ 1+ 1+ 2 = 5 // current sum
                           // prev curMinLen = 4
                           // length of cur window = 4
    

    Now that our window includes the new value at right , the sum once again satisfies the condition to enter the while loop. We go through the same series of steps: first comparing the previous curMinLen to the length of our current window (remains the same at 4), next removing the value at the left pointer from our sum, and then moving that left pointer forward one array position.

        [1, 1, 1, 1, 2, 2, 4]
               L     R
               1+ 1+ 2 = 4 // current sum
    

    But what's this? Our sum still hasn't gone below the target value of 4! This means we repeat the steps contained within the while loop, without incrementing the right pointer. This is the mechanism that allows us to shrink the window - we close it from one side without necessarily having to open it more on the othe. Let's revisit our mental model again to see what's happening here:

    
        // sum = 4, equal target -> continue inside while loop
        [1, 1, 1, 1, 2, 2, 4]
               L     R  // new curMinLen of 3!
    
        (reduce sum, move left pointer)
    
        // sum = 3, less than target -> break out of while loop
        [1, 1, 1, 1, 2, 2, 4]
               L->L  R
    
        // continue back into for loop...
    
    
     
    Logic Step 5: Modeling the Window

    At this point, we don't have much more to add to the code, and we're really just understanding what's happening during each iteration of each loop, so it's really about the mental modeling from here until the final step. The remaining steps can be visualized thusly:
        // back in for loop...
    
        (move right pointer forward, add new val to sum)
    
        [1, 1, 1,[1, 2, 2], 4]
                  L  R->R
        // sum = 5 -> enter while loop
    
        (compare lengths, reduce sum, move left pointer)
    
        [1, 1, 1, 1,[2, 2], 4]
                  L->L  R
        // sum = 4 -> continue inside while loop
    
        (compare lengths, // new curMinLen of 2!
        reduce sum, move left pointer)
    
        [1, 1, 1, 1, 2, [2], 4]
                     L->L R  // same index, sum is 2
        // sum = 2 -> less than target, break out of while loop
    
        // back in for loop...
    
        (move right pointer forward, add new val to sum)
    
        [1, 1, 1, 1, 2,[2,   4]]
                        L R->R
        // sum = 6 -> enter while loop
    
        (compare lengths, reduce sum, move left pointer)
    
        [1, 1, 1, 1, 2, 2,[4]]
                        L->LR
        // sum = 4 -> continue while loop
    
        (compare lengths, // new curMinLen of 1!
        reduce sum, move left pointer)
    
        // sum = 0 -> less than target, break out of while loop
    
        // continue back into for loop for finale...
    
     
    Logic Step 6: The Return

    As we can see in our mental model of the pointer movements, the pointers have reached the end of the input array. This means we've traversed the entirety of our input, and exhausted all the subarrays that satisfied the target along the way. Additionally, we've kept track of the value of the shortest satisfactory subarray by storing it in curMinLen . So all we have left to do is to return that value.
      let curMinLen = Infinity
      let left = 0
      let right
      let sum = 0
    
      for (let right = 0; right < nums.length; right++) {
        sum = sum + nums[right]
    
        while (sum >= target) {
          curMinLen = Math.min(curMinLen, right - left + 1)
          sum -= nums[left]
          left++
        }
      }
      return curMinLen; // last line!
    

    Hang on a sec - what would happen in that case where we never found a subarray that summed to the target ? If that was the case, then our curMinLen would still be set to its initial value of Infinity . The prompt stipulates that we should return 0 if no satisfactory array is found. To accomplish this we can add in a ternary that will substitute 0 if curMinLen has not been updated. Looks like this:

    
       // rest of code...
       }
       return curMinLen == Infinity ? 0 : curMinLen;
    
     
    Logic Addendum: One More Thing...

    I ended up adding a little optional line to this solution after I had a realization. The shortest possible array length can only ever be 1 - even if we find another subarray that is also only one element in length, we still won't ever have reason to update our curMinLen value. This means that we have no reason to continue execution in the case that we've updated curMinLen with a value of 1 .
       // code...  
    
       for (let right = 0; right < nums.length; right++) {
        if (curMinLen === 1) return 1;
    
        // code...
      }
    

    Imagining a case where we have an input array that is 1,000,000 elements long, and we come across a single value at index 0 of the input array. This single line just saved the executing machine from a minimum of 999,999 unnecessary executions. It doesn't technically reduce your time complexity, but in a real world setting it could result in some considerable savings.

    All that said, here's what the algorithm looks like in its final form:

    
    const minSubArraylen = (nums, target) => {
      let curMinLen = Infinity
      let left = 0
      let right
      let sum = 0
    
      for (let right = 0; right < nums.length; right++) {
        if (curMinLen === 1) return 1
    
        sum += nums[right]
    
        while (sum >= target) {
          curMinLen = Math.min(curMinLen, right - left + 1)
          sum -= nums[left]
          left++
        }
      }
      return curMinLen == Infinity ? 0 : curMinLen
    }
    

     
     

    기능적 요약

    To finish things up let's go over the compressed version of everything we've talked about so far. Below is an annotated version of the solution code that describes the function of each piece, followed by an essentialized version of the mental model I used throughout the article:

     
    Annotated Solution:

    
    const minSubArraylen = (nums, target) => {
      let curMinLen = Infinity 
      /*  ^ Holds final answer. Will be reset whenever a new shortest 
      satisfactory subarray is found. Starts at Infinity so that 
      it is always greater than the initial input array.*/
    
      let left = 0
      let right
      let sum = 0
      /* ^ These 3 variables are the components of the window. Left 
      side, right side, and sum of values between (and inclusive 
      of) the pointers. */
    
    
      for (let right = 0; right < nums.length; right++) {
      /* ^ Loop through array, incrementing the right pointer at each 
      the end of each loop. First set of iterations before while 
      loop is triggered will form our initial window size. */
    
      if (curMinLen === 1) return 1 
      // ^ short circuit if we find subarray of length 1.
    
        sum += nums[right] 
        /* ^ accumulate current nums value into sum each time right 
        pointer moves. */
    
        while (sum >= target) {
        // ^ If/while sum reaches target, perform the following steps:
    
          curMinLen = Math.min(curMinLen, right - left + 1)
          // check cur window length against current shortest length
    
          sum -= nums[left]
          left++
          /* ^ Remove value at left pointer from sum, then move left 
          pointer forward. Effective is to reduce the window size from 
          the left. */
        }
      }
      return curMinLen == Infinity ? 0 : curMinLen
      /* ^ Final return; checks if curMinLen was never reset.
      If never reset, no viable subarray was found. Return 0. */
    }
    

     
    Mental Model:

       // target = 4
    
       // initial window
       [1, 1, 1, 1, 2, 2, 4]
        LR 
    
       ('for loop executes...')
    
       [1, 1, 1, 1, 2, 2, 4]
        L        R  // values sum to target
    
       ('while loop executes...')
    
       [1, 1, 1, 1, 2, 2, 4]
           L     R  // sum less than target
    
       ('for loop executes...')
    
       [1, 1, 1, 1, 2, 2, 4]
           L        R  // values sum to target
    
        ('while loop executes...')
    
        [1, 1, 1, 1, 2, 2, 4]
               L     R  // values STILL sum to target
                        // new curMinLen of 3!
    
        ('while loop executes...')
    
        [1, 1, 1, 1, 2, 2, 4]
                  L  R  // sum less than target
    
        ('for loop executes...')
    
        [1, 1, 1, 1, 2, 2, 4]
                  L     R  // values sum to target
    
        ('while loop executes...')
    
        [1, 1, 1, 1, 2, 2, 4]
                     L  R  // values STILL sum to target
                           // new curMinLen of 2!
    
        ('while loop executes...')
    
        [1, 1, 1, 1, 2, 2, 4]
                        LR  // sum less than target
    
        ('for loop executes...')
    
        [1, 1, 1, 1, 2, 2, 4]
                        L  R  // values sum to target
    
        ('while loop executes...')
    
        [1, 1, 1, 1, 2, 2, 4]
                           LR  // values sum to target
                               // new curMinLen of 1!
    
        ('LAST for loop executes...')
    
           return ...;
    

     

    개념적 요약



    결론을 내리기 위해 현미경을 치우고 보다 개념적인 관점에서 알고리즘의 전체 기능을 살펴보겠습니다. 즉, 알고리즘은 두 개의 포인터 위치만 추적합니다. 이러한 포인터 사이의 값을 한 번 계산한 후에는 상당히 적은 수의 계산을 수행하여 약간 바뀐 창의 값을 평가할 수 있습니다. 내 돈을 위해 이것은 알고리즘의 필수 기능입니다. 우리는 이미 창 내에서 값을 계산하는 작업을 수행했다는 사실을 활용하고 다음 창에 대해 가정하기 위해 해당 작업을 재사용합니다. 대상으로 합산되는 4개의 요소 대신 5000인 이 버전을 상상해 보십시오. 이러한 계산 5000개를 모두 다시 실행하고 다음 5000개 계산 집합과 비교하는 대신 "이 창은 이전 창과 동일합니다. - 첫 번째 요소가 빠졌을 뿐이고 마지막에 하나의 추가 요소가 있습니다."

    이 특별한 도전은 장기적으로 볼 때 가장 어려운 것은 아니지만, 이 솔루션은 (적어도 나에게는) 실제로 무슨 일이 일어나고 있는지 파악한 후에 생각하면 만족스러운 솔루션 중 하나입니다. 또한 시간을 들여 이러한 기본 기술에 대한 매우 포괄적인 이해를 발전시키는 것이 앞으로 나아가는 데 필수적이라고 생각합니다. 이러한 기술을 실제로 습득하는 것은 일단 "벨트 아래에"설치하면 영원히 사용할 수 있는 새로운 도구를 얻는 것과 같은 느낌입니다.

    끝까지 읽으셨다면 읽어주셔서 감사합니다. 도움이 되었기를 바랍니다. 다음 시간에는 모두가 좋아하는 재귀와 관련된 문제를 다루겠습니다!

    좋은 웹페이지 즐겨찾기