This is a beginner-friendly introduction to common data structures and algorithms in Python.
This course is top-by-acquashNS, the co-founder and CEO of Jovian.
Data structures and algorithms in Python is a practical beginner-friendly
encoding focused online course that will help you improve your programming skills,
solve coding challenges and ace technical interviews.
You can also earn a verified certificate of accomplishment by completing this course.
Learn more in register at pythondsa.com.
The course runs over six weeks with two hour video lectures every week with live interactive
coding using the Python programming language. You will get a chance to practice and improve your
coding skills with weekly programming assignments consisting of real interview questions and
you will also build a course project that you can showcase on your resume or LinkedIn profile.
This is a beginner-friendly course and some basic programming knowledge will help you follow along
with the course. Don't worry if you're new to programming, you can learn it as you work on this
course with a little extra effort. You will also get to access the course community forum where you
can ask questions, participate in discussions and share what you're working on during the course.
The course is created by Jovian, a platform for learning data science and machine learning
with a global community of tens of thousands of learners from over 150 countries.
I'm your instructor Akash, co-founder and CEO of Jovian and I'm really excited to
kick off this course with you. Register now and invite your friends to join the course at pythondsa.com.
Hello and welcome to data structures and algorithms in python.
This is an online certification course brought to you by Jovian and today we are at lesson one
binary search, link lists and complex idea analysis. My name is Akash, I'm the CEO and co-founder of
Jovian and I will be your instructor. You can find me on Twitter at at Akash and is.
This course runs over six weeks and over the six weeks if you enroll with for the course
work on four programming assignments and build a course project you can earn a certificate of
accomplishment. Along the process you will also learn about common data structures and algorithms
in python and how to use these skills to is coding interviews and technical assessments.
So let's get started then to begin we need to go to the course website pythondsa.com.
See if you open up pythondsa.com in your browser that will bring you to this page. This is the
course page and you can watch an introductory video about the course here. You can enroll for the
course for free. You will need to sign in into Jovian. You can use your Google GitHub or email
to sign in into Jovian. And once you're enrolled into the course, you can also invite your
friends to join the course. The course is still open for enrollments. So please invite your friends
and colleagues. This course is a beginner friendly introduction to common data structures and algorithms
and this course will help you prepare for coding interviews. We have coding focused hands on
video tutorials every week. So you can either follow along with this video you can pause and run
the code as we speak and you can practice coding on the cloud or you can watch the video right now
and you can practice later. In this course we will solve questions from real programming
interviews and you can earn a verified certificate of accomplishment. So let's go to lesson one binary
search link lists and complexity. On the lesson one page you can see a recording of the lesson
once it is completed and you will also be able to see a Hindi version here and all the code used in
this lesson is linked below. So the first set of code that we will look at today is called linear
and binary search. So let's open it up. So this is the first tutorial that we will work through
in this lesson and you will be able to work through it as well and this is part one and there are
a total of 12 notebooks or 12 tutorials we will go through. Now the course assumes very little
background in programming and mathematics but you still do need to know a little bit for instance
you do need to know basic programming with Python things like variables, data types,
loops and functions and don't worry if you don't know them already you can click through and follow
these links. Each of these is a separate tutorial the tutorial will take you about
half an hour or so each of these and you can learn the basic programming with Python in just a
couple of hours. You will also need to know some high school mathematics and if you want to brush
up things like polynomials, vectors, matrices and probabilities you can click through and read
these. But no prior knowledge of data structures or algorithms is required you do not need to have
an extensive coding background. We will cover any additional mathematical and theoretical concepts
as we we need as we go along. So how to run the code what you will see here is the some
explanations and then you will also see some code so you can see here that there is some code written
here and there is some function so the library is imported and a function from the library is used here.
Now to run the code you have two options you can either run this code using free online resources
which is what we recommend or you can run it on your computer locally and you can read these instructions.
I'm going to use free online resources provided by Jovian so we just scroll up here at the top of
this page and click run and then click run on binder. So this will take a second or two
and what we're doing here essentially is setting up a machine for you on the cloud using
a software called binder. It's an open source software and now what you were looking at here
this was actually not a blog post this is actually something called a Jupiter notebook. A Jupiter
notebook is something that can not only contain explanations but can also contain code and you can
look at the code and it's outputs right here in an interactive fashion. So if I scroll down here
you can see that we have all the same content that we were looking at except this time we can actually
run this code. So we can click the run button here and the run button will run the code and here we
click the second run button and that is going to run the second line of code. Now we will be using
Jupiter notebooks extensively throughout this course because Jupiter notebooks are a great way to
do interactive programming you can change the code for example instead of a mad dot square root you can
use mad dot seal and you can change the value here. So Jupiter notebooks are great for experimenting
with code. Now just a couple of tips that you want to do as soon as you run a Jupiter notebook
you can click on kernel and click restart and clear output. What this will do is this will
remove all the pre executed outputs from your code. So you can now see that the output of the
function is gone and you can see that the numbers here go away. So now you can execute the code
line by line yourself and see the output discover the output. And then one other thing you can do
if you want to hide the UI a little bit is to toggle the header and also toggle the toolbar.
Now you might need the toolbar for the run button but there's a tip here instead of pressing
the run button you can use shift plus enter. So if you press shift plus enter that will execute
a cell and that's a pretty handy shortcut. So once again you go on the lesson page
on the lesson page you will find a link to the notebook called linear and binary search.
On the linear and binary search you can read the explanations but you can't run them to run the code
you need to click run and then select run on binder and clicking run on binder will
set up a cloud machine for you and all the code that you see here will get executed on the cloud.
So you do not need to set up anything on your computer you do not need to download anything
we've done all that for you. So let's get started then. This course takes a coding
focus approach towards learning and in each notebook or each tutorial we will focus on solving
one problem and then learn the techniques algorithms and data structures to device
and efficient solution for that specific problem we will then generalize the technique and apply
to other problems. So in this specific tutorial we will focus on solving this problem and here's
the problem we're solving and this is a typical problem that you will come across in a coding
challenge or a coding interview. So here's how the problem goes Alice has some cards with numbers written
on them and then she arranges the cards in decreasing order and lays them out face down in a
sequence on a table. So this is what it looks like. These are cards each of these cards has a number
below it and the numbers are in decreasing order. She challenges Bob to pick out the card
containing a given number. For example she could say Bob I want you to pick out the number 7
by turning over as few cards as possible. So this is a puzzle that's given to us and we're not
told how many cards Alice has. So you need to write a function to help Bob locate the card.
So Alice can put down any number of cards and the target number that Bob has to pick out could be
anything. So we have to tell Bob not not the solution for a specific problem but a general
strategy that he can use to turn over as few cards as possible. So for instance look at these
7 cards and maybe put some imaginary imaginary numbers before them below them and try to figure
out a strategy try to start thinking about the problem. And this may seem like a simple problem
especially if you're familiar with the concept of binary search but the strategy and technique
that we're learning here will be widely applicable and we will soon use it to solve harder problems.
Now before I'll think about the problem and before we start solving it I just want to talk about
why you should learn data structures and algorithms and whether you're pursuing a career in
software development or data science it's almost certain that you will be asked programming
problems like reversing a leg list or balancing a binary tree in a technical interview or
coding assessment. Now it's well known that you never face these problems in your job as a software
developer so it's okay to wonder why such problems are asked in interviews and they're asked
because they demonstrate the following traits and these are very important traits for a programmer.
Number one is that you can think about a problem systematically and then solve it systematically
step by step two and the number two is that you can envision the different inputs and outputs
in edge cases for your problem because programs when you put them out in the while as part of software
can encounter any kind of inputs and as you have thousands or millions of users you will encounter
any and every possible input and often this has many security implications it can take down the
server it can take down your application or you can have a loss of data or loss of property.
You can communicate your ideas clearly to co-workers that's a very important part of
problem solving and most importantly you can convert your thoughts and ideas into working code
and the code should also be readable to other people. So it's not really the knowledge of specific
data structures or algorithms that's tested in an interview but it is your approach towards
the problem. So you may fail to solve the problem but you may still clear the interview or
vice versa you may solve the problem and still not clear the interview. So in this course we will
focus on the skills to both solve the problem and to clear interview successfully. So that's why
you need to learn data structures and algorithms. So coming back to the problem at hand now you
get the problem and you may have been thinking about it and maybe you have some ideas on how to
solve it and your first instinct might be to just start writing the code for it but that is not
the optimal strategy and you may actually end up spending a longer time to solve the problem due
to coding errors or you may not be able to solve the problem at all. So what we are going to cover
here is a systematic strategy that you should apply in interviews or in coding a problems
on encoding assessments or in general whenever you're faced with a problem like this.
So here's a strategy that we will apply. Step one, state the problem clearly,
identify the input and output formats. Step two, come up with some example inputs and outputs
and try to cover all the edge cases. Step three, come up with a correct solution for the problem.
It can be as simple as possible and state it in plain English. Step four and this is a
step that is optional sometimes. Implement the solution and test it using example inputs
and then fix any bugs in your first solution. In step five, analyze the algorithms complexity
and identify any inefficiencies and finally step six, apply the right technique to overcome
the inefficiency and then go back to step three which is come up with a new correct solution
which is also efficient then implement the solution and analyze the algorithms complexity.
So this is the technique that we will apply over and over for the course of six weeks to many
different problems and applying the right technique is where the knowledge of common data structures
and algorithms comes in handy. So this is the method we'll be using. So let's jump into the solution.
Step one, state the problem clearly. Now you will often encounter detailed word problems
and coding challenges and interviews. They will go on for paragraphs and paragraphs. For
instance, here we are talking about Alice having a deck of cards and then shuffling them,
putting them out on a table, talking to Bob etc etc etc. The first step is to state the problem
clearly and precisely in abstract terms because computers don't understand people, computers don't
understand cards, computers understand numbers. So for in this case, we can represent the sequence
of cards as a list of numbers. So a list is a basic data structure and Python.
And the turning over of a specific card is equivalent to the accessing of the value of the number
at a certain position in the list. For instance, if we think of this set of cards being represented
by this list, you can see here that this list is sorted in decreasing order. Then turning over a
certain card is equivalent to accessing that specific element from the list. So turning over card
number two or as we say in computer science, card number one because this is card number zero.
And this is one thing that you might want to get into your head as well that whenever you're counting
always start counting from zero, otherwise you may turn it to many off by one errors.
So this is position zero and this is position one. So if you turn over the carded position one,
it is as good as accessing an element from a given list, which in this case will turn out to be 11.
So these are the positions in the list starting from zero. And now what we have to figure out
is how many elements do we need to access. So we need to access the minimum number of elements
to get to a particular element. So the problem can now be stated as follows. We need to write a
program to find the position of a given number in a list arranged in decreasing order.
We also need to minimize the number of times we access the elements from a list.
So we're finding the position of the given number seven and the position in this case is three.
And we want to minimize the number of times we access elements from the list. So if we go in
this direction for example, we would need to access 13, 11, 12 and finally we discover seven.
We come from this end, we may discover seven, six, five, four and finally we may discover seven. So
definitely coming from the left is better than coming from the right. But is that the best?
That's what we're solving.
Now once we've defined the problem and what you should do is you should try to write down the
problem in your own words and primarily this is for you to make it clear to yourself.
Either speak it out loud to the interviewer or write it down in your own words as short as
make it as short as as long as possible so that you clearly understand what's in it and then
come up with the inputs in the outputs. So there are two inputs here. There's the input cards,
which is a list of numbers sorted in decreasing order. And then the second input is a query,
which is a number whose position in the array is to be determined. And there is one output,
which is position and the position is simply the position of query in the list of cards. For
example, seven is that position three counting from zero of course. And as soon as you've written
the input and output out, you can now write what is called the signature of our function, which is
a structure of our function without any actual code inside it. So now we can call it death,
locate card with cards and query and the single statement inside it called pass because a function
in Python cannot have an empty body, you need to put in at least one statement. So you always put
in the past statement first because it doesn't do anything. There you go. So now we have framed our
problem in abstract terms. And now we have a function signature to work with. Now a couple of tips
here, this is something that interviewers specifically will look for, but also encoding assessments,
because your code is also shared with the company. So you may want to name your functions properly
and think carefully about the signature. For example, here, you should not call your function f1
or funk1 or f or something like that. It's better to call it locate card because that's what it
is doing. And the similar thing is true for variable names as well. Use descriptive variable
names. One because it's good for coding practice in second because as you work on the problem,
you may lose track of what a variable represents. For example, if you call this a and you call this
b. Now 20 minutes down the line, talking about the problem writing different lines of code,
you may forget what a and b represents. So please call them what they represent, even if it can
get a little long. And finally, if you're unable to come up with a function signature,
if you're unable to come up with a simple description, then discuss the problem with the interviewer,
if you're unsure how to frame it in abstract terms. So keep that in mind, and this is really the
first and most important step, which is stating the clarifying the problem statement and stating it
clearly. Do not start coding before you have done this. Otherwise, you may get halfway into the
code and realize that you have not understood the problem at all. So step 2, now we will come up with
some examples. Example inputs and outputs and our goal will be to cover all the edge cases.
So before we start implementing a function, we want to have some examples. So that once we
implement it, the first thing we want to know is it correct? And in general, the answer is no,
because coding, especially when you get getting started is hard because you have to think about
many different scenarios. So and especially, especially interviews or coding assessments are also
stressful situations. So you may not be able to focus and think about all the different things that
you need to keep in mind. So simplest way to reduce the risk of going wrong is to use
test cases. So here's one test case that we came up with. We what we've done is we've taken the
information that we had listed above in the inputs and outputs and we've written it in as code.
So now we have a variable code cards, which is the list of cards, a list of numbers. Then we have
a query, which has the value 7 and then we have the output, which has the value 3. So the expected
output from the function is 3. And once we have a test case, you can test your function at any point
anything you want to test. You can simply pass the input, for example, cards and query into the
locate card function and get back a result. And you can see here right now because there's nothing
inside the function, the result you get back is none. But later you'll start getting back a proper
result from your function. And what you can then do is you can compare the result with the output
of the test case. So in this case, when we compare them, obviously the output is 3, the result is none,
we get back false. Now one thing we will do in this course to make testing easier because we will be
testing our algorithms again and again as we keep improving them is that we will represent our
test cases as dictionaries. So here for example, this this test case will be represented or
every test case will be represented as a dictionary containing two keys input and output. And the
input will contain one key for each argument to the function. So if your function arguments are called
cards and query in the function signature and that's why we wrote down a function signature first
so that we don't get confused here. So if your function arguments are called cards and query,
then we can take one key called cards and put the value of cards there, one key called query,
put the value of query there and then in the output we simply contain, we simply put the output
that we expect from the function. And now you can test the function like this. So how you might want
to test it first is maybe actually passing values like this. So you have test input cards and
then test input query. But there's a trick here whenever you have a dictionary. So here we have
a dictionary with two keys and we want to pass these two keys as two arguments to a function.
So we want to pass cards as the cards are given to the function locate card and query as a
query argument to locate cards. What you can do is you can simply put the dictionary itself
and just write star star. Now if you write star star what Python does is it takes the key
from this dictionary and the values are then used as arguments for parameters with these names.
So there we are now calling locate card on test input and we can compare it with test output.
And you can see that we get back false. So that's one test case for us. But is that enough?
Is that enough for you to now start writing code? Probably not because out in the while your
function should be able to handle any number of any set of valid inputs every pass into it.
And here are some possible variations that we might encounter and it really helps to list them.
In fact while I was writing these variations I realized that there are many cases that I had not thought of.
So even after coding for 12, 15 years almost I still find it really useful to list out all the
scenarios that we can find our input in. So the simplest scenario is that the query occurs
somewhere in the middle on the list of cards. This is what you imagine when you read the question.
This is what is called the general case. But then there are some special scenarios as well.
What if the query is the first element in cards? And what if the query is the last element in cards?
What if the list cards contains just one element which is the query itself? Or and this is
something that I had not thought of. What if the list cards does not even contain the number query?
What if Alice is bluffing? So what should be Bob's strategy then to figure out that the number
does not exist? What if the list of cards is empty? And what if the list contains repeating numbers?
This is again another interesting thing that may not come to mind because we said a list of numbers
and we did not specify that the numbers are unique. So the list can contain repeating numbers.
And finally what if the number the query itself occurs more in more than one position in cards?
So those are eight cases that I could think of. And this see if you can think of any more variations.
And it's likely that when you first heard the problem, you did not think of all these cases.
Because you often tend to just focus on one generic case. It's hard to hold too many cases in
mind. And that's why it helps to list them down actually right them down. In a coding interview or
in a coding assessment or an interview, you may want to put this in comments. If you have a
page coding page, you can just create a comments and list out all the test cases.
And some of these, especially things like the empty array or query not occurring in
a cards are called edge cases because they represent rare or extreme examples. And while edge
cases may not occur very frequently, your program should be able to handle edge cases. Otherwise,
they may fail in unexpected ways or somebody with the with malintensions can use the edge cases
to hack your software. So let's create some more test cases for the variations that we've
listed. And we'll store all our test cases in a list for easier testing. So here we are creating a
list called tests. And this time we will create all our test cases in the format that we
discuss, which is a dictionary format. And we will keep upending them to our list. Now if you
do not understand lists and dictionaries and upending, then you can go back and review some of the
basic material on Python, which is linked at the top of this notebook. So first we take the one
test case that we already have. We put that and we take maybe one more example of the query occurring
somewhere in the middle. So here you can see this is the card list and that the query one occurs
somewhere in the middle, although it's closer to one end. Then here's one case where the query is
the first element. Four and the output obviously the output expected is zero. Here's one case where
the query is the last element minus 127. And this is another thing the numbers could be negative
as well. Something you may want to keep in mind. Here's another one where the card contains
where cards contains just one element the query itself. Now the problem does not state what to do
if the list cards does not contain the number query. And you may often face these questions where
it may not be clear what to do in a certain situation or if a certain situation can occur.
And when you have questions like this, this is a process you should follow. Step one read
the problem statement carefully or ask the interview to repeat the question. So read the problem
statement carefully and you will often find hints and sometimes these hints are just single number
single words somewhere. Often you will also find some examples provided with the problem.
You will also find if you scroll down to the bottom, you will find some conditions,
you will find limits on what the numbers can be, whether they can be integers or can be
decimals, whether they can be negative or positive. So it's important to read the problem carefully
before you start coding and look through the examples. And then ask the interviewer or maybe
post a question on the platform for a clarification. Often it happens that interviewers because
they take so many interviews, they may forget to specify a certain detail. And or they might
expect you to ask the question because you should not be coding with an insufficient requirement.
So to clarify the specifications of the problem is very important. So if you have any doubt
ask the interviewer, even if you are somewhat sure about it but just want to verify, it's a good
idea to ask. Then finally if you are done with all of these and you still do not have a solution,
then you just make a reasonable assumption stated and move forward. So we will assume that
a function will return minus 1 in case cards does not contain query. So if cards does not contain
query, then we return, we expect the equation to return minus 1. Now here is one of the case where
the cards erase empty and obviously then it does not contain the query as well. And finally there's
one last case which is the number itself can repeat in cards. Numbers can repeat in cards
and then the query itself can repeat in cards. So here the query does not repeat,
three does not repeat but the numbers on the in the cards are a do repeat and the last case is
when the query itself repeats. So you can see here in cards the query occurs many times. Once again,
it is not specified what to do here and sometimes it may be okay sometimes the problem statement
may just say that return any one position but more likely than not what you will want to do is you
may want to make it more deterministic and that will also make it easy for you to test the function.
So what we can say we can impose this additional restriction that we will
expect our function to return the first occurrence of query and that will make it easier for us to test
so that when we when we're testing a problem we we know that if we're getting a failure it's not
because of multiple possible answers it but it's because of some issue in our code right.
So you want to get good feedback from failures and that's why you want your tests to be deterministic.
So here is the final test and now we can see the full list of test cases.
So now we can see the list of test cases here. So you have about eight or 10 test cases here.
You may not need to create this many test cases in an interview or a coding assessment
depending on how much time you have but you should create at least a few at least cover
the three or four edge cases a good number to aim for would be five and this will
not only help you in the coding interview help you solve the problem this will also be
appreciated by the interviewer because it shows that you're thinking about the problem.
So definitely take a minute or two. Now we've spent 10 15 minutes talking about this but once you
start applying this technique over and over you will see that you will start creating test cases
in seconds. So as soon as you read the problem and you state the problem find the
find the input format find the output format write a function signature and write the test and then
you will start working on test the ideas will automatically start coming to you and within maybe
two or three minutes you will be done with both all two both of these steps.
So great we now have a fairly exhaustive set of test cases and creating test cases before
hand allows you to identify different variations and edge cases and sometimes it may happen that
you may have no clue how to work on the problem you may feel completely confused but if you
simply start writing multiple test cases and start looking at them like literally just staring
at the test cases the question and the answer the solution will reveal itself to you.
So don't underestimate the power of writing things down and don't stress it don't stress out if
you can't come up with an exhaustive list of test cases because this takes time it's a skill that
you cultivate with time. So what you can do is you can list out maybe the test cases that come to
your mind right now and put them in a single place and keep coming back whenever a new test case
comes to mind while coding or while discussing or while analyzing you can just come back to the
same place and write down the test case. The important thing is that you have a single place where
you're listing all test cases. So we've written our test cases now and now we can come up with
a correct solution and how do you come up with a correct solution not by writing code but for
I first stating it in plain English. So your first goal and by correct we do not mean the best or
the most efficient solution. First we want to solve the problem. We want to figure out where
the particular number lies in the list and not to minimize because that's solving two problems
at once and sometimes that can get tricky. So first aim for correctness then aim for efficiency
and the simplest or the most obvious solution which almost always exists and is almost always
very easy to see involves checking all the possible answers and this is also called the
brute force solution. So in this problem coming up with a brute force solution is quite easy.
Bob can simply turn over the cards in order one by one till he finds the card with the given
number on it. So this is what this is how it might work. If we want to implement it in code and this
is where writing it in your own words becomes important. So we create a variable called position
inside a function with the value 0. Then we check that the number at the index position in the
card list equals query or not. Now if it does since we are starting from the beginning
if it does then position is the answer and we can return it from our function. But if it does it
then we simply increment the value of position by one and then we repeat the steps. So we go back to
step 2 and then we check whether the number at the index position on in cards equals query and once
again if it does we return position. If not we increment the position once again and repeat and we
repeat that till we reach the last position. And if the number was not found we return minus one.
So it's a simple 4 5 step description. It doesn't take very long. You can either say it out loud
to the interviewer. They will also appreciate it that they will know. You know you may know that
you know the brute force solution and you may not say it because it seems too simple or obvious.
But the interview does the interviewer doesn't know that. So it's important to state the
brute force solution. You may say that the brute force solution is fairly straightforward
and it goes like this. Steps 1, 2, 3, 4, just take 30 seconds. But at the very least it informs
the interviewer that you're able to think of some solution. And it happens very often. I've seen
it in interviews where 30, 40 minutes have passed out of 45 minutes and not a single solution
has been proposed so far even though many lines of code have been written. So it's important
to state your solution and if you state your solution the interviewer will also help you and
correct you as you go forward. So it is a collaborative experience. It is a discussion. So use that.
And if you are in a coding assessment you may just want to write out a few comments.
And what we've implemented here is congratulations. It's just our first algorithm. And then
algorithm is simply a list of statements, a list of steps that can be converted into code and
executed by a computer on different sets of inputs. So this particular algorithm is called
linear search because it involves searching through a list in a linear fashion element by element.
So now we're ready to implement the solution and just a quick tip as I've already said always
try to express the algorithm in your own words and it can be as brief or as detailed as you like
and don't underestimate the power of writing. Writing can be a great tool for thinking.
It's likely that you will find that some part of the solution is difficult for you to express.
And that simply suggests that you are probably unable to think about that part clearly.
So the most more clearly you're able to express your thoughts. The easier it will be for you
to turn it into code and you will not have to come up with a strategy while you're writing the code.
So you can focus on coding and focus on avoiding errors. And that brings us to the next step.
Implement the solution. And then test it using the example inputs. Now you can see how everything
comes together. We've already know what the function signature looks like, what the inputs look like.
We already have some test cases and through the test cases we've also identified what are the
different H cases we need to handle. And we've already written out a description or of description of
what the algorithm looks like. And in fact, what you can do is you can simply write out
comments within your function as the English description and then you simply need to fill out
code for those comments. So for instance, here are the five steps that we are just written down.
Create a variable position with the value 0 set up a loop. Check if the element is matches the
query. If yes, the answer is found. If not increment the position and then go back and then check
if we freeze the end of the array. If we have then we return minus one. So the code now is
pretty straightforward. Now we create the position variable 0. We set while true. So while true
kicks off a loop and we just want to first set up a loop and then we can break out of it when we
need to. Then we check if the element at the value position matches the query. If it does
where it done the position. If it doesn't. So if it doesn't, then this we come to this part.
If it does, then the function exists and none of the code gets executed. But if it doesn't,
then we increment the position. And then we check if we have reached the end. Now if we have
reached the end, obviously, we don't want to continue. So we can simply return minus one and exit
the loop and exit the function itself. But if it, if we have not reached the end, then we go back to
the top of the loop and now position starts out with value 1. So we check value 0 1 2 3 so on
up to the end of the array. Simple enough. Great. So now we have our first function. And let's test
our function with the first test case. So here's our test case once again. And we can simply
call locate card with the test input and the cards in the query. And this is the result we get.
And you can already see that the result matches the output. And that's why when we compare them,
we get the value true. So yeah, the results match the output. And because this is something that
you should be doing very often in this course, we have put together a small function for you
within the Joven Python library. So the Joven platform also offers a Python helper library.
That is that contains some utility functions. So we put together a small function for you
called evaluate test case. And you can write it on your own as well. But you can use this library
so let's install the library. We will install the Joven library using pip install Joven minus minus
upgrade. And then from Joven. Python DSA. So Joven is the name of the library. And then inside
the Joven library, since we have many courses, the Python DSA course, the utilities for this course
are present inside the Python DSA module. From that module, we import the function evaluate test case.
And finally, we can call evaluate test case. And then we can give it the function that we want
to test. So we want to test the locate card function. And the test case, the test case needs to be
defined in this format. So all it is going to do is it is going to pick out the input,
pass it into the function, get the output, compare that output with the expected output. And also
print some information for you to see. So here's what it does. It prints out the input.
It prints out the expected output. It prints out the actual output. It prints the execution time.
And this is something that will become important later and tells you whether the test has
passed or not. So it's nice to have this, because we don't have to look through the output
and import and compare them, especially when you're in a situation where you need to think fast.
It's helpful to create a small function that can just print pass or fail for a test case.
So now while it may seem like we have a working solution, because our test cases passed,
we can't be sure about it until we test the function with all the test cases. So for doing that,
we can use the evaluate test cases function. So just as you have evaluate test cases,
you have evaluate test cases. Also part of the joven library. And you can call evaluate test cases
with the same function locate card. And this time pass it a list of test cases. Each of the
test cases is a dictionary. Again, you don't have to use this function. You can simply put
things into a loop. So you can always just do four test intests. And then simply call evaluate
test with locate card and test. Or you can even just directly call locate card with
the test inputs and the test out and compare the output with the test output. So you can do this as well. And you can simply print that.
So here's a simple way to do this. What we are doing here.
But what we'll do is we'll use the evaluate test cases function because it prints out a lot of useful information for us.
So now you can see that it prints out case case by case. Now test case 0. We have input expected
output actual output. The case has test cases passed. That's what we saw it. In fact, it's the same test
with just did. Test case 1 passes as well. Test case 2 passes. Test case 3, 4, 5, 6. Okay,
all of them are fine. Okay. Test case 6 seems to have caused an error. So here is the error.
It says list index out of range. So that's okay. It's perfectly all right for your functions to
encounter an error. So the first thing they're most important thing is not to panic. In fact,
it's a good thing that we know exactly where the function is failing. If you look back here,
you can see what the issue is. And then we'll see how to fix the error. But one good strategy to
approach this is to keep in mind that there will always be bugs in your code. And approach
writing code not with the assumption that your code will be correct. But go with the default
assumption that your code will be wrong. That there will be issues. What that lets you do is
one, you do not feel demotivated or you do not panic when you see an error. And second,
you then tend to be a little more careful while actually writing the code. So the way you should
be writing code is every time you write a line of code, you should be asking yourself,
how can this line of code go wrong? Or in this particular case, how can cards position
equals equals query in a if statement, go wrong and throw an error. And let's look at it.
One easy way to check this is to add what is called a logging or what is called printing the
information inside a function. So we'll just rewrite a function. In a locate card function,
we will put in cards, we will put in query. The exact same function that we have, we'll set the
position. But before we create the value, we'll simply print the cards in the query. So just for our
information, just so that we can see what the function is working through, we can get some
visibility into the function. We print out cards and query. And then while true, so this is the same
loop. At the beginning of the loop, we will print out the position that we are tracking.
So let's do that. We've simply added some print statements and this print statement will
give us an insight into the inner working of the function. Now if you do not put in a print statement,
then you will have to work it out yourself by reading the code and executing it in your head.
It's always easier to just print all the information and then print it nicely, just say cards
and query. You know, we could also have done this without saying cards here. But then that would
make it a little harder to read than that would be more cognitive overload apart from already
dealing with the stress of solving an error. So just add nice, pretty print statements to make it
very obvious what we're printing. So let's see now, let's get the test case out. So let's get from
test 6, get the input, get the cards, get the query as well and pass it into locate card.
And now we see that initially the cards array is empty in a query 7 and the position is 0.
And then we encounter an error. We encounter the error list index out of range on the line cards position
equals equals query. And now at this point, it should be fairly obvious what the issue is.
The issue obviously is that we have an empty list and empty list has no elements,
but we're trying to access the position 0 which is in normal human condition, the first element
of a list. But there is no first element to access and that is why we get the error list index
out of range. So this is very important whenever you get an error, do not try to start looking at
the code first, just try to understand the error first. And if you're unable to understand the error,
just add some print statements. There are tools like debuggers that people use, but I personally
in 15 years haven't used a debugger. Maybe use it a couple of times, but I don't know how to use it.
Print statements are really simple, you just put them in, chuck them into the function, wherever
you need them as many print statements as you need with nice clear messages, make it very obvious.
And that will almost certainly solve the issue for you. So the card's array is empty, we cannot
access position 0. So what's the solution here? The solution obviously is that before we access
anything from a list, we need to make sure that we can access that list. And this is the way to do
it. So now we've written a function slightly. We once again start out with position 0, but this time
instead of putting in a while true, instead of assuming that we can access the zeroes element
of the list, we say that the position should be less than the length of cards. Now if you have
a card's list of n elements, the indices go from 0 to n minus 1. Or in the case of zero
elements, there are no indices to access. So the position has to be less than the length of
cards for you to be able to access it. And in this case, the length of the cards will be 0. So
0 is not less than 0. So the while loop will not run at all and we will directly return minus 1.
But if the card does have elements, then we can check the element at the value position
compared to the query and return the position. If it does not match the query, we can increment the
position. So that was a fairly straightforward fix, easy save. So let's test the failing case again.
Great. So it looks like the failing case is now passing because we have output minus 1 and
the expected output matches the actual output of the function. Minus 1 because the query does not
exist in the array which is empty of course. Now this is not enough. Every time you make a change
to the code, you want to go back and test all the test cases because what happened is while
fixing one error, you may introduce another error. And that is where having a good set of test
cases is very important. So let's run evaluate test cases once again. You can see here this time that
all the test cases are passing. And it's just nice to it just makes you feel good as well.
Makes you feel motivated as well to see that a bunch of test cases are passing.
Now in a real coding assessment or a real interview, you can probably skip the step of implementing
and testing the brute force solution in the interest of time because it may take about 5 to 10
minutes to implement the solution and then if you have errors in the solution, it may take
some more time to fix those errors. So it's generally quite easy to figure out the complexity which
we'll talk about in second of the brute force solution from the plain English description. And that
is why you should first state it and plain English with which only takes around 20 seconds or so.
And the computer doesn't throw errors at you for speaking. So you can just state the plain English
description and move on to talk about the complexity and start optimizing it. But while you're
practicing always, always implement the brute force solution too. And there's an important
reason why you should know how to implement the brute force solution because in case you're not
able to figure out the optimal solution to the problem, you can still go back and implement the
brute force solution and in a lot of cases that's okay. Sometimes interview us ask hard questions
just to push your boundaries a little bit. But if you're unable to figure out the optimal solution,
then they will allow you to implement the brute force solution. So that is why you should state it
and that is why you should know how to implement it. Okay. So we're done with so we're done now with
the implementation of our brute force or simplest solution and now we need to analyze it.
And this is where we'll now learn about what is called the complexity of an algorithm.
What does it mean? Now recall the statement from the original question,
Alice challenges Bob to pick out the card containing the given number by turning over as few
cards as possible. But right now what we're doing is we can simply turn over cards one by one.
And before we talk about what does it mean to minimize the number of times we turn over cards
or the number of times we access elements, we need a way to measure it. And let's think about it.
You know it's as simple as just thinking about it. Since we access the list element once in every
iteration so here's our code, our code is pretty straightforward. And this is where we are accessing
an element from the list. So since we access the element, since we access the element once
in every iteration for a list of size n, we access the elements from the list up to n times
because we may have to access this element and then this element and this element and so on.
So Bob may need to overturn up to n cards in the worst case to find the required card.
Now let's introduce an additional condition that suppose Bob is only allowed to overturn one card
per minute. So that means it may take him 30 minutes to find the required card in the worst case,
if 30 cards are laid out on the table. Now is this really the best he can do?
Or is there a way for Bob to arrive at the answer by turning over just five cards and save
25 minutes instead of turning over all 30. And this field of study and by the way Bob in this case
is represented of what a computer does and a computer takes some amount of finite time to perform
each instruction. So each array axis actually takes some time although it's so fast that we do not
see it especially for small inputs. But this is something that will become increasingly important
as we go week over week where we see that we will start to see the limits of how long it takes
computers to solve certain problems. So the field of study concerned with finding the amount of time
or the amount of space or the amount of other resources required to complete the execution of a program
is called the analysis of algorithms and the process of figuring out the best algorithm to solve
a problem is called algorithm design and that is what we are doing here. We are actually doing
the analysis of algorithms right now and algorithm design next. So there are a couple of terms
we need to understand and then we will go back to writing code. First thing is complexity and the
second thing is the big one notation and both of these are terms that you will hear very frequently
in when you're talking about data structures and algorithms when you're talking about
coding interviews assessments. So these are terms that you need to understand and they're fairly
simple terms although the term itself is complexity but all it means is that the complexity of an
algorithm is simply a measure some measure of the amount of time or space required by an algorithm
to process an input of a given size. Example if you have a list of size n,
another complexity is the amount of time required or the amount of space required on the ram
to process an input of that size. Now, unless otherwise stated the term complexity always
refers to worst case complexity. So it's possible that the Bob turns over the first card and that is
the answer but we always talk about what is the longest or the highest possible time or space
that may be taken by the program to process an input right. So we need to design our programs
keeping the worst case in mind. Now in case of a linear search which is what we've implemented just
the time complexity of the algorithm is some constant C times n assuming n is the size of the
list n is the number of cards right. So now this constant C obviously depends on the number of
operations that we perform in each iteration. So in each loop for example we have four to five
statements and then the time taken to execute a statement on your specific hardware. Now if you have
a two gigahertz computer that may be twice as fast as a one gigahertz computer. If you're running
it on a phone it may be different. So the C captures all of these things. So information about
the number of specific operations that we perform in each iteration and information about
the actual hardware that you're running on. So C n is the time complexity and n is the size of the
input. So in some sense what we understand from this is that the time complexity is proportional
to the size of the input and that's the important part here. The constant you know it doesn't
change as you change the input the constant doesn't really change. Now similarly the space complexity
now since we're already given an array the additional space that our linear search requires
is simply a single constant when we are calling it C prime or C dash and it is independent of n.
So no matter how many no matter how large a list is given to you and the list is already present in
memory we just need to allocate one new variable called position and that variable is used to iterate
through the array and it occupies a constant space in the computer memory because we keep
going updating the variable. So the space complexity is C or constant it is independent of n.
Now what we do normally is to represent the worst case complexity we often use the big
own notation and in the big own notation what we do is we drop any fixed constants
and we lower the powers of the and we drop any fixed constants and we drop any lower powers
of variables. So the idea here is to capture just the trend just the trend of the relationship
between the size of the input and the complexity of the algorithm. For example if the time
complexity of an algorithm is some constant times n cube plus some constant times n square plus some
constant time n plus some constant where n is the size of the input in the big own notation we simply
say that it is order of n cube which is that you know in the long run in the if you just study the
trend it the trend will be some something which looks a little bit like the n cube function and
it may be offset by a constant or such. So putting it this way the time complexity of linear search
is order n because we just drop the constant C and the space complexity is order 1. So we again
drop the constant C prime and we'll see why it's okay to drop the constant sometimes you
may find that okay we are not exactly doing n iterations but we're doing n minus 1 iterations so
we drop the minus 1 sometimes you will find that we are just doing n by 2 iterations and that's
simply half a fan so we drop the half and you might wonder that okay that that might take twice
or 3 times the amount of time how why are we dropping that constant because that's probably an
important thing to keep in mind but we'll see we'll see soon as we implement our efficient
solution to the problem. So before we move forward before we optimize the algorithm we are just
going to save our work because this notebook as I mentioned to you is running on an online platform
we've set up everything for you you've not had to install anything but because thousands of people are
using this using this platform this will shut down this will not keep running forever and what
you need to do is you need to save your work from time to time and here is how you can save your
work and then pick it up everything happens on the joven platform you there's no need to download
anything although you could download it if you want but you there's no need to download anything
so all you need to do is use the joven library once again we've got another helpful function for you
so you say import joven and then run joven.com it so you run joven.com it and then give it a project
name the project name by which you want to identify this specific notebook and then there are some
other arguments is not too important so you can even skip this and that should be perfectly fine
so now when you run joven.com it we will capture a snapshot of your notebook from this online platform
or wherever it is running even if you're running it on your own computer we will capture a snapshot
of your notebook from your computer wherever it's running and we will upload it and give you a link
where you can access it so let's open up this link here so now you will be able to see this page
called python binary search and it will be on your profile and you can see you can scroll down and see
that it contains all the explanations and it contains all the code so this is a read only version of
the jupiter notebook so the read only version of the jupiter notebook obviously does not require us
to keep servers running so that you can run this code and when when you need to run it you know
your work is saved to whatever extent you have executed things and now when you need to run it you simply
click run and then click run on binder once again okay so and that is how you resume your work so
what this will do is this will set up a new machine for you and on the new machine it will post
the jupiter notebook and it will start up the machine for you open up the jupiter notebook and you will
be able to start running the code and not just you now you can make a notebook's public or you can
keep them private you have multiple viewership options so your public and private not just you but
anybody else you can take this link and tweet it out if there's an interesting problem that you
worked on you want to tweet it out you can just share this link online and anybody will be able
to read through your solution and they can run it as well right in fact the notebook that I've shared
with you is hosted on my profile so jupin is not just a platform for you to learn it's also a platform
for you to build a repository of projects now if you go back to your profile you click on your profile
look click on the jupin logo and you can see here that you will find a notebook step and in the
notebook step you will find all the notebooks that you have worked on in the past okay so anything
that you have committed using jupin.com it you will be able to resume working on it so that's that's
how you save your work and keep saving your work from time to time all you need to do is run
jupin.com it you do not even need to put in this project argument this is just something if you
want to actually give you a project a name otherwise the name will be picked automatically so just keep
running jupin.com it from time to time especially if you're leaving your computer for
half an hour or so then and your computer gets goes to sleep then this server will shut down and
you may lose your work coming back to a problem we've just implemented linear search and we
understood that it has the complexity of order n which is and that's why it's called linear
it runs in a linear time is another expression that is used it is also called linear because
we are going through the array step by step now the next step is to apply the right technique to
overcome this efficiency now of course we've not learned any technique saved but we can
probably figure it out if we think about it and maybe this is something that occurred to you
right at the beginning and the idea that occurred to you is something that we will now implement
so at the moment we are simply going over the cards one by one and not even utilizing the fact
that they're sorted and that's why our approach is pretty poor we're basically checking everything
so it's not a great solution but it would be great if somehow this would be the best case
if somehow Bob realized somehow Bob could guess the card at the first attempt that would be perfect
then that would be an order one that would be a constant time solution but with all the cards
turned over it's simply impossible to guess the right card now the next best idea is to maybe pick
a random card so maybe let's say Bob picks this card and this card turns out to be a 9
now Bob can use the fact that the cards are inserted order so if this card turns out to be 9 that means
all of these cards have numbers greater than 9 and the target card is 7 so the target card cannot
lie in this region so the target card has to lie in this region and just by picking a random card
rather than picking the first card Bob has eliminated four out of seven cards to be checked right so
with one check Bob has eliminated a total of five cards one to three four five and of course if
this number turns out to be seven perfect grade guess but even if it doesn't we've still
eliminated quite a few if this number turns out to be less than seven we've still eliminated three
cards so that's the basic idea here that we pick something not from the edges but somewhere in the
middle now what is the best place to pick something in the middle now obviously when we have
picking a card we do not know whether it is going to be less than or greater than the number that we
want especially when everything is closed so we it's best to just pick the middle card so that
whichever case turns out it turns out to be we're still left with as at most three cards to process
right so if you pick this card and it doesn't turn out to be seven you either need to look at these three
or you need to look at these three so that is the strategy we'll follow and this technique is
called binary search and why do it just once just keep repeating it so each time you pick the
middle card and you can eliminate half of the array and this is what the strategy looks like so here
we have the array and in the array we want to figure out the number six of this slightly different
problem but still decreasing order we want to figure out the number six so we access the middle
okay we compare it with six now it is not six okay it was a bad guess no problem but we know that four
is less than six so that means that six lies to the left of four so we've certainly eliminated half
of the array we've done one access and eliminated half of it and now we left with three numbers we
pick the middle number we get seven seven is greater than six that means the number lies on the right
now we're left with just one card we over turn that last card or we check that last number
okay it is equal to six great if it is not well nothing more left to check all the numbers here
are greater than six are less than six and all the numbers before this are greater than six so if this
number is in six then there's no six and just like that for an array of seven elements we have done
just three checks and arrived at the answer and that was the worst case right it means it will never
take you can verify that it will never take more than three checks if six comes at this position
we guess it immediately if six comes at this position or this position we guess it in two checks
and then if six comes at any of the other positions we will guess it in three checks so that's pretty good
and now the idea if you if you read this part it says apply the right technique to overcome the
inefficiency and then repeat the steps three to six so now we're going to go back to step three
which was come up with a correct solution for the problem and stated in plain English and we have
come up with a solution already we just need to state it so here is how this technique called binary
search is applied to the problem it's called binary because well we take a left and right decision
so first we find the middle element of the list if it matches the query number then we return
the middle position as the answer and if it is less than the query number then we search the first
half of the list and if it's greater than the query number then we search the second half of the list
so the exact thing that we saw here we apply it here and finally if no more elements remain
we simply return minus one so let's just save our work now let's from this point on we'll
keep saving our work from time to time using Jovi.com it so now we've come up with the algorithm and
you can again it's important to write it in your own words whether you want to write a short
description a paragraph or a step by step guide but write it in your own words and you'll do this
in the assignment so next implement the solution now and test it using the example inputs so here's
the implementation so what we'll do is we will look at once again let's go back to this visual
representation and we will keep a track of our search space so current initially our search
space is the entire array so that means we have an array of seven numbers so our search
space goes from position zero to position six and slowly we'll keep reducing our search
space over time so to keep track of the search space we will create two variables low and high
low will have the value zero which is it will point to the first position in the array and
high will have the value pointing to the last position last valid position in the array which is
which is land cards minus one so while low and then the while loop becomes very simple
because as long as we have at least one element in our search space we can go ahead now to have
at least one element in our search space the low value which is the starting index should
be less than or equal to the end value right so while low is less than equal to high because if
the starting index is higher than the end index basically we've exhausted and we've
covered the entire list and there's nothing more that we can search for so we should
exit at this point okay so now once we have once this condition is satisfied and it is initially
let's say you have seven cards low is zero cards is lend card minus one is six then you find the
middle position and you can get the middle position by doing low plus high divided by two and now
let's start applying that strategy here where we say that every time we write a line of code we
should think about how it can go wrong now if you write it like this low plus high divided by two
and think about how it can go wrong okay low plus high may not be divisible by two
and if low plus high is not divisible by two you may end up with a decimal number now if you do end
up with a decimal number in fact the division operator and Python always remains a retainer
floating point number then you cannot use it as an array index because we want to use this
as a position within the array so that's why we need the double slash which is the which is
the integer division which simply returns the quotient so we get the middle position and then we
get the number at the middle position so we also get cards made so we access that element from
the array now this is where it makes it easy for us to count the number of times we access
because here is one axis happening inside the list and there are no other excesses
then we get the mid number and a remember last time we faced an error and we had to add print statements
you might as well just add print statements right away so here's what you can do we can just
print the value of low the value of high the value of mid and the value of mid number what this
will do is this will help you check whether the number is working as expected whether the function
is working as expected or not so now here comes the actual check the meat of the problem
if the middle number matches the query then we return the middle number great we found it well done
now if the middle number is less than the query remember the elements are inserted array
and we are looking for the number query now the middle number is less than the query
so that means the query probably lies to the left of it because the query because the elements
are decreasing order right so if the query lies to the left of it so then we need to search
we decrease the search space from the beginning to the position just before the middle number
right so what we can do is we can simply set high to mid minus one on the other hand if mid
number is greater than query so that means because of the decreasing order of the array the query
lies to the right now we need to move the starting of the search space to beyond the middle number
so we simply set low to mid plus one and that's it and you can see that we've written a
we've used if LF LF loop here so LF stands for L safe in Python and here the last condition
could might as well just have been L's because there are only three possibilities either
that they're equal or mid number is less or it's greater but sometimes it's nice to list out
all possibilities just to make it super clear and it makes it easy for you while debugging fixing
issues as well okay so that's our binary search based algorithm and finally when we exit out of the
loop if you have not returned the middle number if you've not exited the function yet then we
return minus one that the number was not formed so let's test it out using our test cases and we
have our handy evaluate test cases function here but you can also test it manually if you want
by passing individual test cases but I'll just do this from now on so great so now we have test
case 0 this is the input and this is the query and it passed here we have test case 1 this is the
input and this is the query and it passed and now because we have these print statements we can
clearly look into our test cases and actually tell if this is tested correctly or not because
now you can see here that we started out with low 0 high 7 and mid mid value of 3 so 0 1 2 3 we
found the number 7 the query is 1 so we need to check this half of the array and that's exactly
what we did we moved low to 4 and high remains 7 then mid number became 3 so that means once again
we need to check this half of the array and then we check this number and then we found the output so
now you can see exactly how the algorithm works and this is in general what you want as a programmer
you want to have a full understanding of the code that you've written you don't want your code
to work incidentally you don't want it to you don't want to be in a position where you are just
fixing things they're trying out different things and somehow at once the code works you want
to be in complete control you want to know that these this is exactly what the code is doing
and if it is failing why it is failing so we go to test case 2 3 4 5 6 looks good looks like we
may have solved everything or probably not so test case 8 seems to have failed so test case 8
is this number this list and this list contains repeating numbers and not just repeating numbers but
the query itself occurs multiple times and now if we look here and maybe let's go go down and
evaluate just a test case separately so here we are now using the singular version of the
evaluate function so if you look here you can see you have 8 8 6 6 bunch of 6 is then 3 2 2 0
the query 6 so we start out with the low of 0 higher 14 total of 15 elements that gives you a
middle position of 7 and the mid number at that position so let's count 1 0 1 2 3 4 5 6 7 okay
and the mid number at that position is 6 great 6 is also the query so that's why our function
returns 7 but remember that we had decided that our function should return the first position
of the number within the array so our function is failing that condition and why is that happening
because unlike linear search where we start from the left and so we will always bump into the
first position because of the decreasing order of elements so we'll hit will encounter this 6
before we encounter this 6 binary search does not access elements in an order it access elements
sort of randomly if the still strategy but it goes left and right and it also depends on the
values of specific elements whether this element is accessed before this element can depend on the
value of let's say this element right so as such it's kind of a pseudo random kind of order
and so we need an additional condition to keep track of it right so how do we fix it
so the way to fix it is actually quite simple when we find that the middle position in a particular
range is equal to the query we simply need to check whether it is the first occurrence of the query
in the list or not that is whether the number that comes before it is it equal to query or not
if the number that comes before the middle element is also equal to query then obviously the
middle element is not the first occurrence so that simply means that we can go back and because
it can occur multiple times before that simply means that we can now search the left half on the other hand
if the middle element if the number before the middle element is not equal to query and obviously
because it is a sorted list it will be greater than query then all the numbers here are going to
be greater than the query and so this must be the first or the only position okay so make sure
you understand that this must be the first or the only position where the query occurs so once again
to make it easier what we will do is because there is some logic involved here what we will
will define a helper function called test location and this is a very helpful thing that you can
do every time you find that okay you have to cover these special cases and your function may
start to get slightly longer and slightly more complicated what you may want to do is create a
helper function and a good rule of thumb is not to have functions that are more than 10 lines of
code or so I try to keep my functions below 7 lines of code because 7 8 lines is approximately
the amount of information that you can hold in your head at once if your function is about 7 8 lines
you can probably take a quick glance and tell what it is doing identify issues but anywhere beyond
that it is very hard and if you are writing functions that are going into hundreds of lines
please stop doing that please start breaking your code into small functions
there is a there is a code by I forget who it is by but he is a creator of I think it is Eric
Meyer he created the RX library for reactive programming and he said that great programmers
write baby code which is really small bits of code that anybody can understand with a single look
so you should be writing as many functions as many small pieces of code small pieces of logic
as possible so let's see our test location function its purpose is to take the query and then take
just a specific position so forget about binary search for now just take a specific position
and tell if that position is the answer and how do we do that we first get the mid number from the
cards so we get a mid number from cards so we then we print out mid and we print out mid number
and then we compare the mid number with the query so this is the special case that we need to
handle this is where we had the error now what we need to check if if the element before the mid number
is also equal to query so if the element before the mid number is also equal to query then we need to go
left so just to make it super clear what we do instead of setting high low etc we simply say
that we need to go left so we will return the actual string left but one thing to keep in mind here
because once again whenever you're accessing an array you need to make sure that the index is valid
so we simply check that mid minus one should be greater than or equal to zero that we made
is not this position and which can happen as your search space decreases for example if this is your
search space your mid will actually be this position so if it is equal to if the number before the
mid number is equal to query then we return left otherwise we return found once again making it
very obvious that we have found the number so we return found else the other case is if the mid
number is less than query that means that the query lies on the left because of the decreasing order
of the list so once again we need to search on the left else it returns right so a test location
simply tells us whether we found the solution or we need to look on the left or we need to look
on the right now in sometimes you will see programs specially in C++ Java return something like
minus one zero and one and then use that to represent whether you should go left and right
but Python is a high level language and strings are first class sings are first class feature of
the language so just use strings because they're really descriptive they make your code readable
somebody else reading your code will be able to understand now if you're looking at minus one plus one
etc that is going to be difficult for people to understand so now we can now simplify a locate card
once again we have our low high land cards minus one zero and land card minus one the
while loop is the same and we print low and high as well so we're planting row and high inside
the locate card function and then we are printing mid and mid number inside the test location function
wherever is the right place to print something you printed then we get the mid position
and now we simply call test location so we're testing if mid is the answer and if it is not
the answer should we go left or should we go right now that makes it really simple because now
we get this result and we check this result and if it says found then we return mid that's the answer
if it says left then we return mid minus one or then we simply move high to mid minus one
and if it returns right then we simply set low to mid plus one so we are simply changing the
start position of the search space to after the mid element and here we are changing the end
position of the search space to before the mid element right so this makes it extremely obvious
and it's really hard to go wrong when you write code like this especially so when you have
and binary search problems are specially tricky because they always have certain these special
cases that you need to handle and if you start handling them within this if loop so now you have
a while loop inside which you have an if loop inside which you have another another if statement
and it can get pretty tricky and difficult to debug so let's evaluate that test case and
looks like that test case has passed this time perfectly you can go through the logs here to verify
let's evaluate the test case all the test cases as well we should do this every time we change
the function and that is why it's helpful to have a function where you can every time you make a
change you can just run the test and on a coding platform like elite code or hacker rank you will
be given some test cases although those test cases will not be visible to you so you can
submit your solution but you may not get an actual result you may not get to know what the test
case was or where your answer was wrong and that's where you may want to create your own
test cases if you're getting a lot of errors and in fact once you've written out the algorithm
you may realize that maybe you need to add more test cases what if the number lies in the first
half of the array what if the number lies in the second half of the array so this was not an
important factor when we were not thinking about binary search but now that we're thinking in
this direction of splitting the array into half we may want to add some test cases where the number
lies exactly in the middle in the left in the right and the simplest way to do that is now go
back to the test array so you can open a you can create a new cell here by pressing the character
B so if you click outside and press a character B you can create a new cell and then you can simply
do test.append and then write your test case so here is the final code for the algorithm
without without the print statements so we have test location and then we have locate card
and try creating a few more test cases to test your algorithm more extensively and once again
at every step we are going to save our work by running joven.com it.
Now we're down to analyzing the algorithms complexity and identifying inefficiencies if there
are any. Now you may have just read online you can actually look it up just search for complexity
of binary search and you will read and you will find an answer but and you may even just say that
in interviews but it's always nice to just come up with that answer from first principles it's
always nice is especially in an interview if you can talk through it if you can talk through
why it is order why it is whatever it is and we'll see what that is.
So now let's once again try to count the number of iterations in the algorithm because we need
to minimize the number of times we access elements from the array and to do that we know that in
each iteration we are accessing the element just once and then we are comparing it so we're doing
a bunch of other operations but in each iteration we're accessing one element so we need to count
just the number of iterations the number of times the y loop was executed. Now if we start out with
an array of n elements then each time each time the size of the array reduces to half for the next
iteration. Now that's roughly true because when you check the middle element and then you decide
whether to go left or right it's actually probably n by 2 minus 1 if n is even and if n is
odd it is the floor of n by 2 but again with algorithms with complexities we are generally
want interested in studying the trend so we can ignore that small part in the calculation.
So let's say the important part is that even it's okay to over estimate a little bit but
try not to underestimate. So after the first iteration we may be left with the search
space of size n by 2 it may be slightly less than that but it's okay to over estimate.
So we have n so we after n we have after the first iteration we are left with a search space of
n by 2 then we split it into half again so next time we may be left with a search space of n by 4
which is n divided by 2 square and then then we may be left by we may be left with n by 8 and it's
possible that at any of these iterations we may just exit because we may have found the right number
but what we always try to analyse is the worst case complexity of an algorithm what is the longest
possible amount of time or the largest amount of space it can take. So right now we are talking
about time because we are counting iterations and each iteration takes some time.
So n by 8 after iteration 3 that's 2 to the power 3 and I think then you can start to see the
trend here that after the k iteration you will end up with n divided by 2 to the power k
elements. Now when does the iteration stop? So the final iteration is on an area of length 1 and
that is when we access that last element and check whether after all this checking the last
element is equal to the index or not. So we can do n divided by 2 to the power k and if we
set that to 1 we can rearrange the terms and we get back n equals 2 to the power k. So after the
case iteration if you want to be left with one element then that means n divided by 2 to the k should
be equal to 1 or n should be 2 to the k or in other words k should be equal to log n. Remember
logarithms and here obviously log refers to log to the base 2 but what I will argue is that
you can change the base of the logarithm and that will simply add a constant. So that will simply
if you are taking the natural log then that will simply add a constant here and remember when we
talk about time complexity we ignore constants. So we can just generally say that our algorithm
binary search has the time complexity of order of log n. That means as the input grows,
the amount of time taken by binary search is proportional to the logarithm of the number
of elements in the list pass to it or the amount of time taken is logarithm to the size of the
initial search space and you can verify this you can verify that the space complex you can
you can check this out by simply writing it out as well you can take some examples. Let's say you take
a card list of size 10 and then walk through it the worst case and count how many iterations you
have and compare if that is close to log n or not. And then as an exercise you can verify that
the space complexity of binary searches order 1. Can you you can try posting in the YouTube
comments or in the YouTube live chat how the space complexity of binary searches order 1.
I'll let that steam. So let's now compare linear search with binary search.
How are the two different and what we do is we will create a large test case because
you start to see the benefits of the difference between the order n algorithm and the order log
n algorithm. Only when you have larger test cases because small test cases everything runs
instantly so it's not really that much of a hassle.
Secure we have a locate card linear and this is the linear version of the algorithm where we simply
go through each of the cards 1 by 1 and then we have a really large test case here so we have the
input and then we have the cards which goes in the range okay let's see. So that's 1 to 3 so that's
1000 another 3 that's million so we have 10 million elements here. So we have 10 million elements
and we are looking and so we are actually creating a range here so we are using a function in python
so we're creating a list of numbers going down from 10 million all the way to 1 so a decreasing
list going from 10 million to 1 and this is how you created and you can check it out and in this list
we are looking for the number 2 which occurs at the very end so we are sort of creating this is
as we will see if you want to really analyze it this is going to be a worst case scenario both for
linear search and for binary search approximately worst case so the queries to and then the output
is this is the output that we expect obviously because 0 to 9999999 is are the array indices
and the last element is 1 so the element just before is 2 so this is the expected output.
Okay so now we have this large test let us call evaluate test case and let us pass check the linear
search pass in the last test and because this is a huge list we may want to turn of the display
of the output we may not want to actually see the input being displayed so we can simply turn
of the display by passing display equals false and we can just get back the result from the
evaluate test case function so the result will give the output the actual output of the function
whether the test passed and the running time of the algorithm so it takes a second so it looks like
the test did pass or algorithm is correct so that's great and it took 1 to 2 4.291 milliseconds
or about 1.2 seconds to answer it and you can probably tell why because it because this is the
result so it probably took 999998 iterations so it had to go through all the elements to get to the
variant on the other hand when we talk about binary search so now we are passing in the binary search
version once again turning display to false and we are displaying the output okay so this time
the result is the same the test did pass but the execution time is 0.019 milliseconds so that's
55000 times faster than the linear search version and in fact you can tell how many elements we
actually had to access so if we just check log of so log of this number is about 7 and maybe
now if you're checking log 2 we can maybe check something like this so not more than 20 elements
had to be accessed so where we linear search needed to access about 10 million elements binary search
was able to get to the answer with just about 20 checks so that's a lot of times saved and
you can increase the size of the array by a factor of 10 and increase this by factor of 10 as well
and then you will see far bigger difference where for a 10 times larger array linear search would
run for 10 times longer whereas binary search would only require three additional operations
so the linear search would go from 10 million operations to 100 million operations binary search
would go from 20 operations to 23 and that is the real difference between the complexities order
and order login and as the size of as the size of the array's gross bigger another way to
look at it is that if you just divide the complexities binary search runs n by login times faster
than linear search for some fixed constant because there's always some constants involved
and as the size of the input grows larger the difference only gets bigger the difference in
performance and that is what algorithm analysis of algorithms and optimization of algorithms is
all about it's about overcoming the limitations of computers by devising clever techniques to solve
problems and it's something that you can actually apply in real life as well in a lot of cases
there are a lot of things that you may see a brute force solution to but if you just apply your mind
you may find a more optimal solution a more easy way or a more lazy way to do it with less work
so think about that and here is a graph showing how the how you can compare common functions
how the how the running times of common functions vary so people look at all kinds of functions
we look at constant time functions order one for example accessing an element from an array
is order one so even if you have an element of 10 a list of 10 million elements you can access
the last element in constant time on the other hand we've looked at binary search which has
which is order login and we've also looked at linear search which is order n now in the future
we will look at other techniques which have complexities of n square n cube n to the power n
or far far higher and somewhere in between there is a very nice special type of complexity called n login
which is rather nice so we'll talk about that as well n login in fact a lot of questions in
coding assessments and coding interviews tend to be taking algorithms which would be
which would have n square complexity in in a brute force approach and optimizing them either
to order n or to order n login so we'll discuss all of this so don't worry if this doesn't
make sense just yet but I hope you see now why we ignore constants and lower order terms while
expressing the complexity of the big notation so we've covered binary search but we've seen it
in the context of a problem and now we can step away one more step and abstract it out further and
identify the general strategy behind binary search and this general strategy is actually applicable
to a wide variety of problems and this is what you want to keep doing as a programmer you need to
abstract away peel away the layers of specific problems specific details and find the general
technique find the general strategy and then encode that using your functions and programs so
here's the general strategy come up with a condition to determine whether the answer lies before
after or at a given position so we are assuming here that we have some kind of a range and we have
to identify a position within a range or maybe an element within that range but we can access
elements using the position so come up with a condition that that first tells you whether
given a position the answer lies at or before or after that position once you have that condition
first retrieve the midpoint and the middle element of the list now if the middle element of the
midpoint is the answer then return the middle position that is the answer you're done if the answer
lies before it repeat the search so repeat the process with the first half of the list
or the first half of the search space and if the answer lies after it repeat the search with the
second half of the search space so here is the generic algorithm for binary search
implemented in python and you can see a classic detailed documentation here so while
so here you have the binary search is going to take a search space low and high so low is going to be
zero and high is going to be well we will pass in maybe the final we will pass in maybe the final
index of the array but writing it this way rather than passing in array also allows you to use
binary search for problems that are not based on array sometimes these could just be numbers for
example if I ask you to find a number between 1 million and 10 million that is a perfect square
then you can use binary search to do that
then it takes a condition so what it does is it starts a loop so while low less than equals high
we get the midpoint so low plus high divided by 2 that gives us a midpoint then remember earlier we
had this condition test location so our condition simply is supposed to take the middle position
and identify if the middle position is the answer or we need to go left or right so the
condition should return either found left or right so if the condition returns found we return the
midpoint as the answer if the condition returns left we return the high we move to the left side
so which is we take the end of the search space and set it to before the midpoint so we set
high equal to mid minus 1 and if the condition returns right which is the else case here we set
low to mid plus 1 so we take the start point of the search space and move it after the element
then we return minus 1 so that's your binary generic binary search algorithm and if you start using
this what will happen is now this is a tested piece of code and in fact we can see it here
now we can rewrite locate card and locate card can be we are passing in cards and we're passing in
the query and we need to write a condition and here we're using a very interesting feature of Python
we are writing a function inside a function so this is called function closure and it's a very
handy feature so now we can simply write condition inside locate card and what that does is
binary search is going to pass the middle value the middle position but condition can also
access cards and query so which is because it lies inside locate so what we do inside condition
is okay we check them we get the mid element card's mid if card's mid is equal to query
then here we have that check we check whether it is the first occurrence of query or can
query occur before it if query occurs before it we return left else we return found and then these are
the original conditions that we already had so you can verify this by going back and checking
but the important part here is now the while loop has gone away now we can simply call binary search
with zero line cards minus one so the start index the end index and the condition and we can
evaluate the test cases and you can see that the well in test cases are correct and now you can
use this binary search function because we have not tested it with one problem you can use this
exact same function to solve other problems too in some sense it is a tested piece of logic so
here's what we will do we'll take a quick question and we will implement it now we've spent
what one and a half are talking about a particular problem but let's spend maybe two minutes talking
about a new problem and solving it so here's a slightly related question given an area of
integers sorted in increasing order find the starting and ending position of a given number so once again
you have a sorted area this time they're increasing the only difference is now apart from the fact
that they are sorted in increasing order the other difference is that we're looking for both the
start index and the end index so we're looking for both the start index and the end index
of a particular number because the number can repeat like we saw one example and
this is a very simple way to solve this as simple strategy is do binary search once to find the first
position and that's what this function does I let you read through it the only changes here are
this variable this has changed this order because now the now the elements are increasing order
and then the second change and there's no other change here so that this is just one change here and
then there is another function called last position here instead of checking the left we are
checking the right so instead of checking mid minus one we are checking mid plus one and if
mid plus one equals a target we go to the right and of course we have the same change here in this
code because instead of decreasing we have increasing order right so now we write two positions
now we write two functions first position last position and then first and last position is simply
getting the first position once so that's one binary search and getting the last position once that's
two binary searches and that's not bad you know the complexity still order login
two times login or two times some constant times login when you express it in the big
connotation is still login so that's okay and that was quick we were able to reuse most of the
code that we have written and that's the benefit of making generic functions like binary search
and in fact we can test the solution by making a submission here so let's go to leadcode.com
let us here what I've done is I have already copied over the binary search
function the first position function and the last position function so by the way leadcode is
a great platform for practicing so you can go to leadcode.com sign up with any account and you will
find a lot of problems especially on the in the problem step and here you can see that this is exactly
the problem that we have been solving just now so we've just post the code here by research first
position last position first and last position and leadcode requires you to write this class
called solution this is something that they give you beforehand and inside the solution you need
to define a function called search range where we are simply calling our first and last position
here I'll let you see and we simply we can test the code with our test case so you can pass a test
case here and test it out great or we can simply submit it and here you can see that the
problem was submitted successfully and it tells you things like how much runtime it used what was
the memory it used and your solution was accepted right so check out leadcode.com go to the problem
section and you can see all the different problems that they have you can also explore and you
have different problems that come up every day it's a great place to practice so that's binary search
for you but I just want to revisit the method once again so this is the systematic strategy that we
applied for solving the problem we state the problem clearly and we identify the input and the output
formats this this shows that you've understood the problem you know what the solution will look like
then come up with some example inputs and outputs and try to cover all the edge cases so this
shows that you are envisioning what are the different inputs that can come in before you write code
then you come up with a correct solution not necessarily the most efficient one and state it
in plain English now when you try to state it you will have to clarify it and that will help you
clarify your own thoughts and then you can analyze the algorithms complexity and you can implement
the solution and test it using example inputs so this is the basic solution now in interviews and
encoding assessments maybe you know where there's a time limit you may not want to implement
the brute force solution because then you may get stuck in fixing issues with brute force and
you can directly jump ahead to step five but while you're practicing always implement brute force
then step five analyze the algorithms complexity and most of the time it is simply a matter
of counting the number of iterations how many times a while loop or maybe a loop within a loop
is getting executed and identify inefficiencies and if it is a brute force solution it's
generally quite easy to see the inefficiency for example in this case the inefficiency was that
we know that the arrays sorted that anything we do will be better than going line by line right
we could pick a random element and that would help us eliminate a good chunk of the array
so that is the inefficiency and then apply the right technique and we are learning the techniques
so we've learned binary search today and then we're going to learn a lot more techniques
that are asked in interviews so apply the right technique to overcome the inefficiency and repeat
steps 3 to 6 which is go back and come up with a correct solution with the optimized technique
implement the solution and test it using some example inputs and then analyze that algorithms
complexity and identify any inefficiencies so what we've done for you is we have created a
template so you can see this python problem solving template and how you can use this template
is to simply run it so you run the code you run this template and then when you run the template
inside it you will see this question mark in a bunch of places so you can give it a nice project
name and you can commit it to your profile one way you can save a copy over this template to your
profile is by clicking the duplicate button if you click the duplicate button you can copy it in your
profile and you don't have to look for it you can just find it on your joven profile but anyway
once you have it copied you can click the run button and then click run on binder and run the
template then you go down once you run it and you can copy over a problem statement you can copy
over a link to the problem so that when you need to make a submission you can go back and
refer and then here the method is summarized for you and here we have created sections for you
so you can simply start filling out this method, step 1 step 2 step 3 step 4 step 5 so whenever
you are faced with a difficult problem just use this template and I guarantee it one if you
work through this course you will be able to solve a majority of the problems that you come across
and specifically even if you are able to follow maybe about 30 to 40 percent of this course
you will easily be able to solve most questions that are asked in interviews because questions
asked in interviews are fairly simple in terms of the data structures or algorithms they test
but the intention there is more to test your approach look at the quality of your code and
see how clearly you are expressing yourself and this is what is exactly what this method
teaches you to do now to encourage you to do this to encourage you to try it out and you can take
problems from places like lead code code chef code forces there are a few links listed here
you can see practice problems there are a bunch of links listed here so that was today's lesson
for the next lesson common data structures in python so this is data structures and
algorithms in python and online certification course brought to you by Jovian
thank you hello and welcome to data structures in algorithms in python this is an online certification
course pick offered by Jovian today we are looking at assignment one binary search practice
so let's get started first thing we will do is go to the course website pythondsa.com
on the course website you can enroll for the course and view all the previous lectures and assignments
for assignment one you may want to review the video and notebook for lesson one
let's open up assignment one it's called binary search practice
now in this assignment you will apply and practice the concepts that we covered in the first lesson
so you will understand and solve a system solve a problem systematically
implement linear search and analyze it and optimize the solution using binary search and ask
questions and help others on the forum let's open up the start and notebook for the assignment
which contains that problem statement and other information now this is a notebook you're
looking at hosted on Jovian you can see some description here and if you scroll down below
you can also see some code and you will need to execute this notebook modify the code with
within it and record a new version which you can then submit to see your score so let's start reading
through it as you go through the notebook you will find three question marks in certain places
to complete the assignment you have to replace the question marks with appropriate values expressions
or statements to ensure that the notebook runs properly and to end now keep in mind that you need
to run all the cells otherwise you may get errors like name error or undefined variables
you should not be changing any variable names or deleting any cells or disturb any existing code
you can add new code sales or new statements but do not redefine or do not change some of the
existing variables you will be using a temporary online service for code execution and we'll see how to
use it in a moment so keep saving your work by running Jovian.com at regular intervals and then the
question marks optional will not be considered for evaluation although we recommend doing them they
are for your learning but you can make a submission before you have solved the optional questions
now you can make a submission back on the assignment notebook page and we'll see how to do that
and if you're stuck you can ask for help on the community forum it's listed here
and we'll see how to do that as well now one final thing I want to mention is you can get
help with errors or ask for hints you can even share your code and errors that you are getting in the
code but please don't ask or share the full working answer code on the forum this is so that
everybody has the opportunity to work through the problem statement on their own make mistakes learn
from their own mistakes and arrive at the right solution now how do you run this code the recommended
way to run this code is by clicking the run button at the top of the page and selecting run on binder
but you can also run it using some other options like Google, Colab or Kaggle or you can run it on
your computer locally so we're going to use the recommended method run on binder
now we have the notebook running in front of us the first thing I like to do is go to kernel
and click restart and clear output so that we can see all the outputs of the notebook from scratch
and I'm also going to toggle the header and the toolbar so that we can zoom in a bit
so now the same Jupyter notebook is now running online on a platform called binder
and before starting the assignment let's save a snapshot of the assignment to a joven profile so
that we can access it later and continue our work I'm going to run pip install joven
this is going to install the joven library then run import joven to import the library and set a project
here I'm just calling it binary search assignment and run joven.com it now you've taken a
start a notebook which was hosted on my profile and then you've run it on binder where as soon
as you run joven.com it a copy of the start an notebook gets saved to your profile so what you will
see here is a link to a notebook hosted on your joven profile let's open it up here and see
so now this is your personal copy of the assignment notebook any changes that you make here and
run joven.com it will get added to your profile so if you want to come back and continue your
work then you do not have to go back to the original start a notebook which contains all blanks
rather you can come back to your profile and you can come to your profiles simply by opening
joven.ai and on your profile you can go to the notebook step and on the notebook step you will be
able to find as you can see here you will be able to find the binary search assignment here
there you go this is the binary search assignment that we just created and you can open it and
run it on binder to continue your work. So moving along this is the problem we are looking at here
you are given a list of numbers obtained by rotating a sorted list and unknown number of times
okay so we have two new terms here rotating a sorted list and don't worry if you don't know
that means normally if you see any new terms in a problem they will be explained somewhere within the
problem itself. For instance here you can see that there's a definition we defined rotating a list
as removing the last element of the list and adding it before the first element one is
instance rotating the number list 3241 leads to removal of the last number and then placing it
at the very beginning so you end up with the list 1 324 this is a new operation that we are defining
this is not something standard but you will find that a lot of problems will define new terms
or new operations so that it becomes easier for you to understand the problem so that's rotating
a list now rotating a list once produces 1 324 now if you rotate that list again the resulting
list one more time then you will end up with 4132 and so on and then the other term is sorted
so sorted refers to a list where the elements are arranged in increasing order. In this case we have
numbers and the numbers 1 357 are increased arranged in increasing order so this is a sorted list
but if this was 3241 well that's not the numbers are not arranged in increasing order so that's
not a sorted list so you are given a list of numbers obtained by rotating a sorted list and
unknown number of times for instance this sorted list 0 234569 is rotated a certain number of
times and you can verify that if you rotate this three times you end up with the list 5 6 9
sees your 234 right you can see that first a certain 9 comes to the beginning then 6 comes to
the beginning and then 5 comes to the beginning so you need to write a function and you're given
just this the list you're not given the original sorted list you're given the list obtained
by rotating some sorted list and unknown number of times now you need to write a function to
determine the minimum number of times the original sorted list was rotated to obtain the given
your function should have the worst case complexity of order log n where n is the length of the list
and you can assume that all the numbers in the list are unique okay so 3 parts write a function
to determine the minimum number of times you need to rotate the original sorted list in this
case it is 3 the function should have the worst case complexity of log n so this determines correctness
and this determines efficiency and then this is some additional information to help you that you can
assume all the numbers in the list are unique if this was not mentioned you would also have to
handle the case where your list is not contained unique numbers now we will apply the method that we
have been applying all throughout this course for solving the problems number one state the problem
clearly identify the input and output formats number two come up with some example inputs and outputs
and try to cover all the edge cases number three come up with a correct solution for the problem
and state it in plain English number four implement the solution and test it using some example
inputs and having test cases and then implementing a solution allows you to test them using the
example inputs and fix any bugs that's why it's very important to have some test cases number five
analyze the algorithms complexity and identify any inefficiencies and number six apply the right
technique to overcome the inefficiency and then you go back and repeat steps three to six come
up with a correct solution implement the solution and test it and analyze the algorithms complexity
and you can review lesson one for a detailed explanation of this method let's apply it step by step
the first step is to state the problem clearly and identify the input and output formats
now why while it is stated clearly enough it always helps to express it in your own words
in the way that it makes it most clear for you and this is something that you can keep
returning to rather than the original problem statement because this is something that you will
understand better and it's okay if your problem overlaps with the original problem statement
but do try to express it in your own words so in this case what I've just done is I have double clicked
here once you double click you can now edit this text cell and now we can start writing a problem
so let's say given a rotated list we need to find the number of times it was rotated
and okay I think what I've probably missed here is that it is a sorted list so given a
sorted list that was rotated some unknown number of times
we need to find the number of times it was rotated right maybe I'm just going to say given a
rotated sorted list because technically the input is not a sorted list it's a rotated sorted list
so given a rotated sorted list that was rotated and unknown number of times we need to find
the number of times it was rotated but doing this exercise helps you determine
if you understood the problem correctly and you may often find that okay there's a certain
detail in the problem that you missed okay but at this point I'm happy with my description
and you will see that it is matching the description to a large extent but it's something that
I understand better so I'll just refer to this from this point now assuming here that I know
what rotation and sorted means otherwise I could also include those then here's a question
the function will you will write will take one input called names what is it represented
and given example okay so once again we double click on this and one input is
names so this is a sorted rotated list and let's give an example here let's say we take the
sorted list three five six seven nine and then we rotated a few times let's say we rotated a
couple of times so we end up with this sorted rotated list that's our input so we answered the
question here now the first question was to express the problem in your own words this is a solution
the second question was what does the input names represent given example it represents a sorted
rotated list seven nine three five six the third question is the function you will write will return
a single output called rotations what does that represent well you have to write a function that
identifies how many times the list was rotated so this is the number of times the sorted list was
rotated okay and in this case the example that we have is that this sorted list was rotated twice
three five six seven nine was rotated two times so you mentioned to here now you can see these
backcodes that I'm using here this is next to the number one on your keyboard or below the escape key
what these backcodes let you do is they let you express text as code within markdown you can see that
they have a gray background and they have a different font this looks a lot more like code
same is here true for nouns so you can use markdown and its features
to your advantage to organize your descriptions and your text better okay so now based on the above
we can now create a signature of our functions we have a function called counts rotations it takes
the list of numbers and it returns well right now we are just putting pass in here but we know that
it's going to return on single number rotations now after each step remember to save your notebook
so we are going to just run jobin.com it and now if you leave your computer you do not have to be
worried that your work may be lost so you can go in here and you can open up this notebook from your
jobin profile and press run at any point to run this notebook now step 2 is to come up with some
example inputs and outputs and try to cover all the edge cases and our function should be able
to handle any set of valid inputs so here are some variations that you can encounter a list of size
10 rotated 3 times a list of size 8 rotated 5 times so these are two generic examples and then
a list that wasn't rotated at all a list that was rotated just once a list that was rotated
n minus 1 times where n is the size of the list a list that was rotated n times and what you mean
by rotating the list n times well let's see an empty list and a list containing just one element
and if you can think of more test cases you should definitely add more test cases here and what we
do is we will express our test cases as dictionaries so this will help us organize the test cases
and test them all at once more easily using helper functions so you can see here that we've organized
one test case here and we've expressed a test case as a dictionary so here we have the input to the
test case that is the input key and then we have the output to the test case now because a function
can take many arguments the input itself is going to be a dictionary and then for each argument
in this case there's just one so we just call it norms we have the input here and this is the
size of the output okay so let's create the test case and let us then if you want to fetch
actual input an output out of it so here we can fetch test input norms that's going to give us the
norms we can use test input the output should be test outputs this seems to be an error
and the result is counterrotations num 0 okay so this is the actual result obtained by passing
the test case into counterrotations and you can see that the result we get back is none because
right now we do not have any code we just has passed inside and the result and the output are not
equal because the output is the number 3 but the result is null so that's okay our test case is
failing right now because we have not yet implemented the function but as soon as we implemented
we expect to see the test case passing now to help you avoid all of this work we have given
you a function called evaluate test case so from jove not python dsa you can just import
evaluate test case and then call evaluate test case with a function you want to test and the actual
test case and you can see here it prints the output that was passed in the expected output
the actual output that was obtained and the test result in this case the test result was failed
and the execution time is also printed here if you just want to evaluate if a certain
implementation is faster than another so now your job is to create test cases for each of
this scenarios listed above so here is test 0 that is same as the original test case that we had
created now here is test 1 a size a list of size 8 rotated 5 times I will let you create this
but it will look something like this you will open up you replace the three question marks with
let's say a list of size 8 so 1 2 3 4 5 6 7 8 and you can imagine that this was rotated 5 times
then 1 2 3 4 5 or 5 of these numbers will then move to the first position
and you get this as the input numbers and the output well it was rotated 5 times so I think
you can guess that the output here should be 5 now here is a list that wasn't rotated at all
what should be the output here I'm sure you can guess that the output here should be 0 so I let you
fill this out here is a list that was rotated just once so let's try let's fill out this one
so this this list rotated once would give us 7 3 5 there you go a list that was rotated
n minus 1 times where n is the size of the list okay I'll let you do that a list that was rotated
n times where n is the size of the list okay what does that look like
so you take this list and then you first put 10 in the first position and then you put 9 in the first
position so 9 10 comes to the beginning and 3 5 7 8 comes after it then you move 8 to the first position
then you move 7 then you move 5 then you move 3 if you move all of these back to the first position
you end up with the same list so you've rotated it n times now what should be the output in this case
there are about 6 numbers here so is the output 6 I don't know I'm not so sure because remember
the question the original question says write a function to determine the minimum number of times
the original sorted list was rotated to obtain the given list so it has to be the minimum number
of times we may want to just go back and change this we need to find not the number of times
it was rotated but the minimum number of times it was rotated or it needs to be rotated right so
coming back here the output should be 06 but 0 so keep that in mind then here's an empty list
I let you figure out what should be the numbers in the output here and here is a list containing
just one element once again should be pretty straightforward can you rotate a list with one element
I let you decide and then we're taking all the tests and putting them into a single list now
since I have not defined all the tests I'm not going to use this definition which contains all
the tests but I'm just going to pick the number of tests that I have defined so we have defined here
S0, S1, S3, S5 I'm just going to put in S0, S1, S3 and S5
and that's the full sort of tests that we have you definitely need to fill out all the test cases
and if you can think of some other cases that you should be testing and you should include those
test cases here as well okay now to evaluate your function against all the test cases together
you can use the evaluate test cases helper function from Joven so there are two functions
evaluate test case works with the single test case and evaluate test cases works with
a list of test cases so we have a list of test cases here I have four but you should have
about eight at least and a few more if you have created them so we can import from Joven
or Python DSA evaluate test cases and then invoke evaluate test cases with the count rotations
function still it we don't have any logic in the function so we all the test cases should pass
and the list of test cases we've created so you can see test cases zero fails one fails two fails
so out of the four test cases none of them have passed no problem we have completed step two which
is to create some test cases and we'll know once we've defined a function whether the function
definition is correct now the next step is to come up with a correct solution for the problem
and stated in plain English and there's a hint here for you already coming up with a correct
solution is quite easy and it's based on this simple insight if a list of sorted numbers is
rotated k times so you keep rotating at step by step moving the last number to the first position
then the smallest number in the list ends up at position k okay and you can verify this it's very
simple to do this whenever you have a doubt discrete a new cell by the way you can create a new
cell by clicking on the left side of a cell and clicking insert cell below or if you're in a
code cell just click here near the prompt and press the B character and that adds a new cell below
so let's take the list one three five seven five six seven and let's rotate it k times
let's try with k equals two so if you set k equal to two then you're going to take two of these
from their very end and move them to the beginning so that means zero comes at position six comes
at position zero seven comes at position one and the starting element in the sorted list now comes
at position two that's interesting let's move the third element as well okay so now we've moved
three elements or rotated the list three times and the smallest element ends up at position three
so it seems to hold true and you can verify this now with a larger list smaller list empty list
all the test cases that you have if a list was sorted k times sorted list was rotated k times
then the smallest number in the list ends up at position k counting from zero further it is
the only number in the list which is smaller than the number before it and you can see this once again
the smallest number is at position three and all of these numbers are higher than the numbers
that come before them except the number one which is smaller than seven so we simply need to
check for each number in the list whether it is smaller than the number that comes before it
if there is a number before it then our answer is simply which is the number of rotations is
simply the position of this number right so if you can find the position of the number
which is smaller than the number that comes before it the position of the number is also equal
to the number of times to sorted list was rotated and if we cannot find such a number then the
list wasn't rotated at all and that's it you can see here in this list now applying this logic
three is the number the smallest number and not only that three is the only number which is
lower than the number that precedes it the predecessor which is 29 and since three occurs at
position four well actually three occurs at position three 0 1 2 3
the list was rotated exactly three times now we can use the linear searcher algorithm as a first
attempt to solve this problem in the linear search simply involves working through this list
working through this list from the left to the right so now the task for used to describe the
linear search solution in your own words and please write it in your own words but here's how I'm
going to write it let's say create a variable position with values 0 so this is the position
for tracking this for tracking the position then look at the number at the given position and not
only look at it but compare the number at the let's say the current position to the number before it
now if you're starting position with the values 0 maybe we may not there's no number before it so
we may not be able to compare it with something we may even just start with the value 1 that's all right
if the number is smaller than its predecessor then return position because position is the answer
we found the number that is smaller than its predecessor there's only one such number
otherwise increment position and repeat till we exhaust all the numbers
okay simple now you can add more steps if your description of the algorithm requires more steps
that's perfectly all right but at this point we have a very clear description of the solution
now we're starting with the position is 1 not 0 because we also want to track the previous position
now we import Jovian here and commit a project once again I keep saving your work after every step
so that you can continue your work so now we're talking about implementing the solution and testing it
so let's implement the solution we said that we want to start with position
we want to start with position 1 and while venture the loopy terminated well while position
is less than the length of numbers okay that's fair and then what is the success criteria so we have
if position greater than 0 and norms of position less than norms of position minus 1 okay
that's the success criteria here now you can see that there's a condition if position greater than 0 here
so we don't really need to start position at 0 we can start position at or we don't really need to
start position at 1 we can start position at 0 as well and all that will happen is this conditional
gets kept and position will get incrementing and this is a good practice because whenever you iterate
over a list you normally just want to start with 0 just to avoid any confusion later when you're
reading the code that did you intend to write 0 here or 1 etc etc so just put in position equal 0 here
and simply skip the check here or simply skip this comparison if position is not valid
so whenever you're accessing an element from inside a list or inside a dictionary you always want to
make sure that that index or that key is valid okay here we are making sure that the key position
minus 1 is valid by checking position greater than 0 in any case we now have the logic and finally
we are saying that if the number at position is less than the number that comes before it then we return
that and that's just going to if it's not then it's going to increment the position and it's
going to check again and again and again till we run out of numbers now if you've exhausted the
entire list then it follows that there were no rotations or there were end rotations exactly
in either case the number we return should be 0 okay so keep this in mind some you may have the
doubt should you be returning minus 1 here or should you be returning 0 here well the question does
specify clearly that you are given a sorted rotated list and you have to find the number of times
it was rotated now obviously minus 1 rotations are not possible so minus 1 would not be a valid
return value from your function and this is the reason we write test cases 2 now let's evaluate the
test case so let's call evaluate test case for a single test case on countertations linear and let's see
what the test cases this is the test case here and this is the output we call evaluate test case
with countertations linear and test and that gives us a linear search result and you can see here
this was the number the list of numbers this was the expected output then this was the actual output
so great our functions seem to have path to test case now we can evaluate all the test cases by
calling countertations linear on all the test cases together and give that gives us a whole list of
test results test case 0 and 1 and 2 and 3 all of them are passed now if you had put in minus 1 here
you would see that one of the test cases would fail which is the case where the list was
rotated at all or was rotated n times okay so that should tell you that the answer here should be
0 so that's our linear search algorithm and at this point you may face issues you may
feel stuck you may not be able to figure out how to write the code and that's perfectly all right
that's part of learning you may face errors you may face exceptions for instance if you did not have
this check here position greater than 0 or maybe what you had here was some other condition like
position less than equals position plus 1 and that's okay then you can go to the forum and post your
issue so let's open up the forum here this is the forum discussion for assignment 1 and you can
go into the original topic here which is a longer discussion so this is where everybody's posting
small issues so you can see that there's about 321 messages that have been posted you can start
looking through this forum you can start reading through some of the posts you can even search
if you press control left and you can even search for questions here now if you want to post your
own question scroll down to the very end or you can just click this button here and click reply
okay and mention your question here I have an issue should I return minus 1 or 0 in the case the
list has not been rotated okay maybe that's and if you want if you have code that's not working
or there's an error you can also include a screenshot of your code or I'll show you another trick
you can actually include let's say you commit your notebook so let me come up here I've
committed my notebook and if you have a particular line of code that you want to share you can
actually click copy cell link and paste it here so that will give a link to the entire cell
and if somebody clicks on the link then they can view that specific cell of the notebook directly let's see
you can see here that it brings us directly to this specific cell there's another option you can even
click on embed cell okay for embed first secret notebooks we know not allow embedding but copying
the cell link should work and then click reply and your question will be posted and somebody will
reply to your question just come back to the forum in a few hours or maybe the next day and you should
see an answer you will also receive an email so that's the discussion topic you can also go back
to the topic here the category here and create a new question you can see if you want to start your
own thread if you think your question deserves a deeper discussion where multiple people can reply
you can also create a new thread by clicking new topic okay so keep this in mind and do make use of
the forum what we've seen is people who are active on the forum are at least four to five times
more likely to complete the course and on the certificate of accomplishment and continue working
on these topics after the course as well okay so the next step is to analyze the algorithms
complexity and the way to do this if you've seen lesson one is to simply count the number of iterations
number of executions of the while loop now if you have a list of numbers of size n
then you can see here that this is the key loop here while position less than the length of numbers
so then there will be n loops or n iterations and then inside eat each iteration we're performing
certain comparisons and returning things so all of these are in effect constant time and based on
this you can probably tell that the complexity of linear search is order of n so you can just put in
a big o and in the big o notation this will be order n so that's the first part of the assignment
linear search now the next step is to apply the right technique to overcome the inefficiency
and that's where you can now you can now read through the rest of the assignment now the idea here is
this binary search is the technique we'll apply and the key question we need to answer and
binary searches given the middle element can you decide if it is the answer which means if it is
let's say the smallest number in the list or whether the answer lies to the left or the right
of it okay so given the middle element if the middle element is smaller than its predecessor
then it is the answer we already know that because there's only one number in the list at a
smaller than its predecessor so you can see here for example now if the middle element was one
which it's not but suppose that the middle element was one and you can see that one is smaller than
eat then we know that one is the answer so the position of the middle element is the answer
however if it isn't then we need a way to determine whether the answer lies to the left of the
middle element or to the right of it and consider these examples so here you can see that the
middle element is three and the answer is the position two so in this case the answer or the
smallest element lies to the left on the other hand in this case you can see the middle
element is four and the smallest element minus one lies to the right of it so now you need to
apply your mind and think of a check that will help you determine if the middle element
given the middle element if the answer lies to the left or the right of it right and we're
looking for the smallest element remember so the logic here if you just spend a couple of minutes
you will come up with this quite easily if the middle element of the list is smaller than the
last last element of the list okay or the last element of the range that we're currently
looking at that means that all the numbers here are in increasing order so then the answer lies to
the left of it on the other hand if the middle element of the list is larger than the
last element of the range that means that because we know that the list is rotated
list so that means that the numbers increase up to 0. and then there's a decrease and then they
continue increasing that's the only way in which the final element can be smaller so that means
the answer lies to the right of it so that's the logic here for binary search and now what you
have to do is describe the binary search solution in your own words so here once again you have
these four or five lines and it's very important that you do this because if you cannot express it
then coding it is also going to be difficult for you so always do this exercise of expressing
the solution in your own words when you're practicing when you're solving a coding challenge
or something even in an interview it's also very important because the first thing you need to
do is to communicate to the interviewer your thought process and how you're thinking about the
problem so the first thing you need to do is describe a simple solution in your simple words
and then they may or may not ask you to code that solution and then the next thing is to
identify the complexity or identify the inefficiency then the next step for you is to
describe the optimal solution or the binary search solution in your own words okay now
if you don't describe the solution in your own words and you start writing the code they may
not be able to follow your code so even if you written mostly correct code maybe with one or two
edge cases wrong they may still have a feeling that you don't know what you're writing but if you
explain the solution clearly to them they will know that now you understand the solution
and they will be able to follow the code as you write it and they will be able to pick up mistakes or
errors and help you with the errors once secret is that interviews are always open to helping you
unless you make them really confused to keep that in mind and describe the solution in your
words once you do that you can commit now the next step is to implement the solution now the
implement the binary search solution as described in the previous step let's run this again
so you run count rotations define the function count rotations binary now you may want to review
lesson one here on how to start it out you'll see that low starts out at zero and high starts out at
length numbers minus one and I will not solve the rest of this but there is a certain condition here
between low and high so in binary search we are starting with the entire list as the range then we're
looking at the mid number so we're getting the first the mid position and we look at the number
at the mid position then we check if the middle position is the answer so if the middle
position is the answer we return the middle position then we check if the answer lies in the left
half so here's a condition where you decide if the answer lies in the left half and we
once the if the condition holds true all we do is we change the high so which we change the end point
of the range, 2 made minus 1, and then we check if the answer lies in the right half,
in that case we change the starting point of the range to make plus 1 and the y loop
repeats.
Okay, so that's the general logic of binary search.
And one thing you have to keep in mind is if none of the elements satisfy the criteria
that you have, what is the answer?
And this is a very important condition.
This is where it is very easy to go wrong, this is also called the edge case or the
trivial case.
So you should handle and think about this carefully.
And then once you've done that, you can evaluate the test case and you can a single
test case, you can evaluate multiple test cases.
Now, if your test cases are failing, you may want to enable this print statement inside
by uncommenting it, but make sure to comment it out at the end once again.
And the print statement will help you see what the low high end mid points were.
Now, you may want to then take up pen and paper, look at an example that is failing.
Let's see if the printed numbers match what you expect to see.
Debugging your function is a very important scale.
So keep that in mind and use a debugging technique like this by adding print statements
and working out the same problem side by side on paper to fix your issues.
Otherwise you may feel lost if you are not able to look into the internal workings
of the function.
Next, you have to analyze the algorithm's complexity and identify inefficiencies.
This should be straight forward enough.
We've already looked at the complexity of binary search, but all you need to do is make
sure that what you're doing within the algorithm matches the analysis that we've done
earlier.
So the problem size reduces by half each time and then we're doing constant work in each
step before solving a problem of half the size.
So that should roughly give you an answer.
And keep committing your work.
Now finally, to make a submission, you have two options.
Now, one option is to take this link, so your notebook has been committed here and you
can come to the assignment page, let's open up the assignment page, binary search practice.
Come down here and paste this link here and click submit.
Now once you click submit, the assignment will be submitted and it will go into automated
evaluation.
So in about a couple of minutes, maybe up to an hour, depending on the queue of submissions
from different participants, you will receive a grade over email.
Let's just refresh the page and it seems like there was an issue here, the issue was
that count traditions binary was not defined.
So it's possible that this happened, count traditions binary did not get defined because
there are a bunch of question marks here.
So we may need to then fix the issue and then come back and make a submission once again.
Okay, so I've received a fail grade, I will go back and I will fix the issue and then come
back.
Now it's very important for you that's why to have a good set, good set of test cases,
or you to test your function, so that when you submit it or when you get an error,
you can maybe look at your functions, performance on the test cases and fix anything that
needs to be fixed and add new test cases if you need to.
Now, one other way you can submit is by simply running the code by joven.submit assignment
equals Python DSA, I think assignment one.
The code is mentioned here, you can see here that the submission was made and you can verify
your submission on the page.
Okay, so that's assignment one, so what should you do next, review the lecture video if you
need to and execute the Jupyter notebook, you may need to keep, you may want to keep
the Jupyter notebook running side by side, I'll do working on the assignment, then complete
the assignment and even attempt the optional questions if you scroll down here on the assignment
notebook, you will find that there are some optional questions for you.
Here's one bonus question, use the generic binary search algorithm, so inside the Python
DSA module in joven, there is a function called binary search, you can use the generic
binary search example, then here's an optional bonus question to handle repeating numbers,
we did say that you can assume that there are no repeating numbers in the list, but here's
one list with repeating numbers, can you modify your solution to handle the special case,
and then here's an optional bonus question three about searching in a rotated list, so
you're given a rotated list.
Now, instead of finding the number of times it was rotated, you're trying to find the
position of a certain number, well, instead of the position of six, can you apply binary
search and modify your previous solution slightly, to search within the rotated list
and find the position of a given number, okay, now here's a hint, you can simply identify
two sorted sub arrays within the given array and perform a binary search on each sub array,
using, so to identify the two sorted sub arrays, you can use the counteritations binary
search functions, so that's one potential solution, another way is to modify the counteritations
binary function to solve the problem directly, so it's a very interesting problem to solve,
and if you found the assignment easy, then you should definitely solve these bonus questions,
and if you can solve this question by yourself without taking additional help, then you
can solve pretty much any problem related to binary search that may be asked in an interview,
because most of the questions are some variations of something like this, and this is pretty much
the hardest problem you may get asked. You can also test your solution by making a submission
on lead code, and this is only for the final optional question, and there's a thread on the
forum where you can discuss the bonus questions separately as well, so do make use of the forum
thread too. Here it is, optional bonus questions discussion, so that was assignment 1 of
data structures in algorithms, so it's called binary search practice. Hello and welcome to data
structures in algorithms in Python, this is an online certification course by Jovian,
my name is Akash and I am the CEO and co-founder of Jovian, you can earn a certificate of a
accomplishment for this course by completing four weekly assignments and doing a course project,
today we are on lesson 2 of 6. Now if you open up pythonda.com, you'll end up on this course website
where you will be able to find all the information for the course, you can view the previous lessons,
which is lesson 1, and you can also work on the previous assignment, which is assignment 1,
and you can also check out the course community forum where you can get help and have discussions.
So let's open up lesson 2. This is a lesson page here, you will be able to see the video for this
lesson. You can watch live or you can watch a recording here, and you can also see a version of
this video lecture in Hindi. And in this lesson we will explore the use cases of binary search
trees and develop a step-by-step implementation from scratch, solving many common interview
questions along the way. So here is the code that we are going to use in this lesson, all the
different notebooks containing the code are listed here and let's open up the first one.
So here you can see all the explanations on the code for this lesson. This is binary search
trees, traversals, and balancing in python. And this is the second notebook in the course,
you can check out the first notebook in lesson 1. And if you're just joining us,
this is a beginner friendly course, and you do not need a lot of background and programming
with a little bit of understanding of python and a little bit of high school mathematics,
you should be able to follow along just fine. If you do not know these, then you can follow
these tutorials to learn the prerequisites in just about an hour or two. Now the best way to learn
the material that we're covering in this course is to actually run the code and experiment with it
yourself. So to run the code, and you can see here if we scroll down, you can see that there is
some code here on this page as well. Now to run the code, you have two options, you can either
run it using an online programming platform, or you can run it on your computer locally.
So to run this code, we will scroll up and click on the run button and then click run on binder.
And this is going to start executing the code that we were just looking at. So once again,
you can go on the course page by kndsa.com, open up lesson 2, and you can watch the video there,
and on lesson 2, you can open up the link to the code where you can read the code and the
explanations here. And if you want to run the code, just click the run button, and that will execute
the code for you. So once you click the run button on binder, you should be able to see an interface
like this. This is the Jupiter notebook interface, the same explanations that we were seeing on the
lesson page. You can see here, the same explanations are now available here. But the differences,
you can now edit these explanations and you can go down and you can actually run some of the code
in this tutorial. You can see here that you have a run button and when you click the run button,
that is going to run the code in this particular cell. And this is a Jupiter notebook made up of cells.
Now we'll do a couple of things here. The first thing we'll do is we click on kernel and click on restart
and clear output. But this will do is we'll clear all the outputs of the code cell so that we can
execute them ourselves. And then I'm just going to zoom in here and hide the interface so that we can
look at the explanations and the code. So finally we have some running code and in this notebook,
we will focus on solving this specific problem. And this is a common question. A question of
this sort can be asked in interviews. So this is an interview question, but along the way,
we will also learn how to build binary trees and binary search trees and how to apply them to several
other questions. So here's the question. As a senior back-end engineer at Jovind,
you are tasked with developing a fast-end memory data structure to manage profile information,
which is username, name, and email for 100 million users. It should allow the following
operations to be performed efficiently. You should be able to insert the profile information for
a new user, find the profile information for a user given their username, and then update the
profile information of a user once again given their username, and list all the users of the
platform sorted by username. And you can assume here that using names are unique. So this is a very
realistic problem that you might face if you're working at a company where you have a lot of users.
So let's see how we solve this problem. Now here's a systematic strategy that we'll apply
for solving problems, not just here, but throughout this course. This first step is to state the
problem clearly and in abstract terms, and then identify the input and output formats.
Then come up with some example inputs and outputs to test any future implementations
and try to cover all the edge cases. Then come up with a simple correct solution for the problem.
It doesn't have to be efficient, it just has to be correct and stated in plain English.
And then implement the solution and test it using some example inputs. Fix bugs if you face any.
And finally analyze the algorithms complexity and identify inefficiencies if any.
Now once you identify it, inefficiencies, then we apply the right technique and that's where
data structures and algorithms comes into picture. So we apply the right technique to overcome
the inefficiencies and then we go back to step three. So come up with a new correct solution,
which is also efficient, state it in plain English, implement it and then analyze the complexity.
Now if you follow this process, you should be able to solve any programming problem or interview
question. So step one, we state the problem clearly and we identify the input and output formats.
Now we can reduce the problem to a very simple, single line statement. We need to create a
data structure which can efficiently store 100 million records and we should be able to perform
insertion, search, update and list operations, all of them as efficient as possible.
Now the input, the key input to our data structure, the solution that we are building is going to
be user profiles which contains username, name and email for user. Now before we come up with a
solution, we need a way to represent user profiles and a Python class would be a great way to
represent the information for a user. So you may have heard of the term object oriented programming
and that is what we're going to look at now. If you're not familiar with the class, it's very
simple. A class is simply a blueprint for creating objects and what's an object well,
everything in Python is an object whether you're looking at a number, a dictionary, a list,
anything and you can create your own custom objects with custom properties and custom methods
by creating your own custom classes. So here's the simplest possible class in Python with nothing
inside it. We're creating a class user. So this is how you declare a class and then we're putting
nothing inside it. So whenever you put nothing inside a function or a class or anything you can
you need to put the past statement because Python cannot accept empty blocks of code. So here
we're creating a class which does not have anything inside it and we can create an object or
it's often called instantiation which is creating an instance of a class, instantiate an object
of the class by calling it like a function. So we say user one is user. So this creates an object
and the variable user one points to that object. Now we can verify that the object is of the
class user by simply printing it or by checking it's type. User one and type user one are both
user. Now the object user one does not contain any useful information. So let's add what's called
a constructor method. So constructor method is used to construct an object to store some
attributes and properties. So now we're defining the class user once again but inside it we're
defining this function and you can see that this function is inside the class because there is some
indentation here. So we define this function underscore underscore in it and it takes four arguments.
Now the first argument is a special argument called self and we'll talk about this and then we have
three arguments username, name and email. And inside in it what we're doing is we're
setting self dot username. So we're setting a property on self to username. We're setting a property
on self to name and we're setting a property on self to email. And finally we're printing user
created. So let's see let's create another user user to and you can see that user to is also
an object of the class user. Now here's what happened in conceptually when we do this. The first
thing that happens is when you invoke this function, when you invoke user as a function,
Python first creates an empty object of the class user and then stores it in the variable user
to and then Python invokes the init function and to the init function it passes user to the
object that was just created as self and then the other arguments that were passed while creating the
object as the rest of the arguments. So you can imagine that we are basically doing
your basically calling user dot underscore and score in it. The function with user to an empty
object and these arguments John, John, and John dot com. And then inside the init function,
we simply set these properties on user to. So now we get user to dot username is John,
user to dot name is John, and user to dot email is John, John, and do dot com. So that's basically
how classes work in Python. And that's why you always have this additional extra argument in all
class methods which will refer to the object that finally gets created. So once user to is created
with the values John, John, and John dot com, you can check that user to dot name is John,
and user to dot email is John, and user to dot username is John. Now you can also define
some custom methods inside a class. So obviously we had the init method, but here we are also
defining another method called introduce yourself. Now introduce yourself takes again two arguments,
the first argument is self which will refer to the actual object that gets created later.
And then we have a guest name, and we basically say hi, guest name, I am such and such,
contact me, it's such and such. So these blanks are filled in using the guest name,
self.name and self.email. So that's how you define a method in a class. So here we have
another user we're creating, Jane and Jane do a Jane at do dot com. And you can see here that when
we call introduce yourself with David. So user three which is Jane becomes self and then David
becomes guest name, and that's why we get hi David, I am Jane do contact me at Jane at do dot com.
So that's a quick refresher on classes and Python. Now there's a lot more to classes,
but the simplest thing you need to know is you how to define a class, how to create a constructor
which is underscore underscore in it, how to set some properties, like we said the properties,
name, email, and username, and finally how to define methods, like we defined the method
introduce yourself. And that's all we will need today. So we won't need much more than that.
And one final thing that we're doing with our class is we're defining two other special
functions underscore underscore REPR, rapper and underscore underscore STR.
So now these two functions, these two functions are used to create a string representation
of the object. And you can see here once we create an object user for, now and if you try to
print user for, you can see that user for is now printed like this. So use it three,
you was not printed, I mean user three was a printed just as a user, but with user four,
we have all this information printed here as well. So now here's an exercise for you,
which also brings us to the first quiz of the day. Now we are going to do three quizzes
in this video and you can answer these quizzes on LinkedIn. So go to our LinkedIn profile
if you see the posts, you will see a new post here, which will give you a question. And the
question is what is the purpose of defining the functions STR and rapper within a class?
And how are these two functions different? Now leave a comment with your answer and we will pick
the right answer one right answer and one lucky winner will get us to act back from us.
So that was the input. We now have a way to represent users by creating classes.
And then the output that we want the final output that we want to create for our problem is a data structure.
So a data structure is once again something that we can define using a class. So we can define,
we can expect our final output to be a class called user database, which has four methods.
Insert, find, update and list all. An insert takes a user and inserts it into the database,
find takes a username and returns the user, update takes a user,
and updates the data for that user and finally list all returns a list of the users. So this is
what the class will look like and we have not implemented it yet, but we now have an interface.
So now the next step is to come up with some example inputs and outputs.
So let's create some sample user profiles that we can use to test our functions once we implement them.
So we're going to create these seven user profiles and you can see that we're creating these seven
user profiles with a username, name, and an email and storing them in these variables.
Using the user class that we have just defined earlier and we're also going to store the list of users
in this variable called users. And as you can see, we can access different
fields within a user profile using the dot notation. So you can check barrage.username is barrage
and barrage.email is barrage.example.com and barrage.name is barrage task.
Now you can also view a string representation of the user as we have seen. So if we print the user,
you can see some information about the user and here is the full list of users that we have created.
So it's always a good idea to set up some input data set up some test inputs that you can
use to test with your implementation later on. And since we haven't implemented our data structure yet,
it's not possible to list any sample outputs, but you can try to come up with some
different scenarios to test any future implementations. So let's list some scenarios,
we're testing the methods of our user database class. So the methods are insert, find, update,
and list all. And for inserting, you may want to test that you're inserting a user into an
empty database of users. So that's what's called in hk's. And then the general case is to insert
a user into the database assuming that the user already does not exist. Then another hk's is trying
to insert a user with a username that already exists. So these are all the different ways in which
we can use the insert function and there can be some more. So here's an exercise where you try
coming up with all the different scenarios in which you would like to test the different functions
insert, find, update, and list. So that completes step two. Now we have some sample inputs and
then we have some scenarios in which you're going to finally test our function. So the next step
is to come up with a simple correct solution and then state it in plain English. Now here
is a simple and easy solution to the problem. We simply store the user objects in a list sorted
by user names. That's simple enough and suppose we do that. So inside our data structure we have
a list which simply contains a bunch of user objects. Then the various functions can be implemented
like this. So you have the insert function, the insert function simply requires looping through the
list and then adding the new user at a position that keeps the list sorted. So for instance if
you have the users, a cache, a month and so on already and then you're inserting the user
barrage then you can tell that barrage should go between a cache and a month in alphabetical order.
So that's how you insert a new user and maintain the sorted property of the list.
Then to find the user we simply loop through the list and then find the user object
with the username matching the query. So that's, if you're looking for a
payment for instance you start from the beginning you go through a cache, perage and finally
hit a payment and then you can retrieve the user object associated with a payment.
And then you have update. Now updating is very simple as well. It's similar to find. So you find
the user object matching the query and then update the details of that user object.
And then finally because our internal representation is already a list of user objects sorted by
user names so we can simply return that list when we want to list the users.
So that's our plain English description and it's always a good idea to describe your solution
in plain English so that you can clarify any doubts you have. Even during interviews it's a good
idea to have a conversation with the interviewer before you actually implement the solution.
And now one fact that we can use is that using names which are strings can be compared using
the less than greater than or equal to operators. So we can compare strings just like numbers and
Python. So that'll make it easy for us to implement these functions. And that brings us to the
implementation and the code for implementing these is also fairly straightforward. So now we have
the user database class. We are actually implementing this class and here you see that we have
a constructor and the constructor does not take any additional arguments apart from self.
And all we do is inside self we set a property dot users and that property dot users is set to an
empty list. Then we come to insertion. So now assume that we already have some users in a user database.
So we start out with a pointer set to zero and we go through all the valid positions in the
users list. So which is from zero to n minus one if there are n users. And then we find the first
user name greater than the new users use a name. So for instance if you're inserting a
himant then you go through a cache and barrage and then finally you realize that the next value is
probably Siddhan. So you want to insert himant before Siddhan. Right. So you want the first
user name that's greater than the new users use a name. And you check this property and as soon
as you find the next that the next user is greater than the user that needs to be inserted.
We break out and then we insert that user at that position. So this is the insertion you can
you know it's just four or five lines of code. So you can work through this code try to read this
code line by line and see how it works. Now similarly you have the fine function, the update
function and the list function they're all pretty straightforward. There's really not much here.
So this is an exercise for you because this is also the brute force of the simple implementation.
So this is an exercise for you to go through each of these functions and try it out.
And use the interactive nature of Jupiter to experiment and add print statements inside each of the
functions if you need inside each of the loops if you need more visibility into what's happening.
Okay. But what we will do is we will try and test this implementation out.
And the first thing we do is instantiating a new database of users using the user database class.
So here we say user database and that gives us a database of users.
And now let's insert some entries into this database. So we can now insert for instance,
we can insert the value hemant, Akash and Sadhanth. So here we have inserted three values into the
database. And now we can retrieve the data for a given user given their username using the
find method. So now we say database dot find Sadhanth that returns a user and we can check the value
of user. And you can see that now we have retrieved the data for Sadhanth, which is user names
Sadhanth names Sadhanth and emails Sadhanth at example.com. Now let's try changing the information
for a user. So to change the information we can call database dot update and then simply
pass in a new user object. So let's say we want to change the information from Sadhanth
Sadhanth you. So this is how we do it. We call database dot update.
And now if we find the information once again, if you can't find call database dot find once again,
we get back a user object and this time with the updated information. So we have created the
database, we have inserted some values into it and then we have retrieved values out of it and we
also updated them. And finally we can retrieve a list of the users in alphabetical order.
So now if we list it out, you can see here that we have the username Akash, we have the username
Himant and we have Sadhanth. These are the three values that were inserted and they are all
in alphabetical order of username. Now if we insert a new user, let's say let's say we insert
a barrage. We can make sure that barrage is inserted into the right position.
Okay, so that's how we use the data structure that we just created and you can use the empty
cells here to try out the various scenarios when you run the notebook. So just to recap,
we created a simple class inside which we are storing a list of users in sorted order of
username and then insertion is pretty easy. We simply loop through find the right position and insert
any new values. Finding values is very easy as well. We simply loop through and keep comparing
and updating values is simply a matter of finding them and then updating that specific value.
And listing is simple because we can simply return the internal list representation that we're
already storing in the sorted order of username. So that's the simplest solution or one of the
simplest solutions. There can be even simplest solutions maybe. So the next step now is to
analyze the algorithms complexity and identify any inefficiencies. So typically in an interview
setting, you may not want to implement the simplest solution. So you can actually skip step 4.
Now when you've described what the simplest solution is in English, in plain English, which was step 3,
you can directly jump to analyzing its complexity and then move on to optimization and
implementing the optimized version. But when you're practicing or when you're learning, it's always
a good idea to implement even the brute force solutions. So let's analyze the complexity. The
operations insert, find, update, involve, iterating over the list of users. And in the worst case,
they may take up to n iterations to return a result. Where n is a total number of users.
Now the list all function is slightly different because it simply returns an existing list.
So the list all function does not take linear time, it takes constant time.
Now based on this information, it's very easy to check to guess the time complexities of the various
operations. Insert, find and update have a order n post case time complexity, which means they can
take up to n iterations. However, the list function has an order 1 complexity, which means
irrespective of how many users you have in your database, it returns the list in the same amount of time.
Now if you want to display the list or if you want to iterate over the list, that may take you
additional effort. But getting the list itself is a constant time operation.
So that was the time complexity and an exercise for you is to verify that the space complexity
of each operation is order 1. And if you're wondering what we mean by complexity, then you can
go back and watch less and 1 where we talk about analysis of all the algorithms, complexities,
and the big own notation. What we're calling order of n, the big own notation, all of these
explain in a lot more detail. So you can go back to less and 1 and check it out.
Now we've created a simple solution and our first question might be to wonder if this is good enough.
And to get a sense of how long each function might take if there are 100 million
numbers, users on the platform. Let's create a while loop. Let's create a for loop.
And let's run it for let's see how many this is 1, 2, 3, 4, 5, 6, 7, 8. So let's run it for
10 million or 100 million numbers. So here we are creating a range of 100 million numbers.
And we're running a for loop which iterates over the entire range. And we're simply
performing a simple operation which we're not really using. We just multiplying the number by itself
to simulate what might happen if we have a database of 100 million users and we're trying to
access find a user. Now what is the worst case scenario here? Let's run this and you can already
see that it is taking a while. For 100 million users, the loop takes about 10 seconds to complete.
Here it took about 9.45 and a 10 second delay for fetching user profiles will definitely lead to
a suboptimal user experience and that may cause users to stop using the platform all together.
Now imagine you came to joven.ai and it took 10 or 15 seconds to load your profile and then
maybe even longer to load the other information and display it. You would not be happy with
the experience. And then a 10 second processing time for each user for each request each profile
request will also significantly limit the number of users that can access the platform at a time.
Because if you're running the back end server on one computer which has 8 cores, then each core
will be busy for 10 seconds each time a user tries to access the platform. So you can only
serve about 8 users in 10 seconds time. Now that's pretty bad. That could significantly limit
the number of users. You will have a significant outage if a lot of users come to the platform.
Or on the other hand, you may have to increase the cloud infrastructure at more servers at
bigger hardware, more cores, more RAM. And that could increase the cloud infrastructure cost for
your company by millions of dollars. So as a senior back end engineer, you must come up with a
more efficient data structure. And this is why choosing the right data structure for the
requirements at hand is a very important skill. Now we can clearly see that using a sorted list of
users may not be the best data structure to organize a profile information. So let's see what
better we can do here. And before we do that, let's save our work. So remember that
this notebook, we were running it on an online platform called binder and binder can shut
down at any moment because it is a free service. So what you want to do is run PayPal install
a Jovian and then import the Jovian library and you can then run Jovian.com it.
Now when you run Jovian.com it, what this does is this captures a snapshot of your Jupyter
notebook, whether you're running it on binder or you're running it on your own local computer and
it saves a snapshot of this Jupyter notebook on your Jovian profile. So here you can see now on my
Jovian profile. I have this notebook and I can go back on my profile and view the other
notebooks that I've created in the past. So your Jovian profile becomes a collection of all the Jupyter
notebooks that you're working on. So always just it takes just a couple of lines import Jovian
and run Jovian.com it. So always run Jovian.com it inside your notebooks. And if you want to resume
any work that you were doing, then all you need to do is click on the run button and then click
run on binder once again and then you can start executing the code within the Jupyter notebook once again.
So remember that binder is a free service so it will shut down after about 10 minutes of
inactivity which is if your computer goes to sleep or you change your tab, keep running Jovian.com
it from time to time. So now we have a simple implementation and we've analyzed it and determined
that it is not efficient, it is inefficient. So now we need to apply the right technique to overcome
the inefficiency. And we can limit the number of iterations required for common operations like
find, insert and update by ditching the linear structure that we hide earlier and organizing our
data in a more tree-like structure. So this is the structure that we use for our data and we will
call this a binary tree. Now this is called a tree because it vaguely resembles an inverted tree
trunk with branches. So you can think of this as the root. So this has the root and then you can
see each of these are like branches and then there are nodes where branches then split into multiple
branches. So these are called nodes and finally at the end you will have individual nodes which
do not have any more branches and those are called leaves. So these are some terms that I used.
The tree represents the entire structure. The top node is called the root and each element in the
tree is called a node. The top node is called a root and then the bottom most nodes which do not
have any sub trees or what are called children which do not have any children are called
leaves. So the root node has two children and then each node there with can have 0, 1 or two children.
So it's not necessary to have exactly two children but up to two children is what determines
a binary tree. So that's a binary tree. But the binary tree that we need will have some
additional properties which was what will make it efficient for our purposes. So you can see
one thing you can observe here is that the root node seems also seems to be the central value
if you sort the keys in increasing order. So what you will notice is on the left we have
keys which have which lie before jadish and on the right we have keys which lie after jadish.
So that's one thing and that is actually the second property listed here that the left
subtree of any node consists only of nodes which have keys that are lexical graphically smaller
than the nodes key. So the key for this node is barrage and that is lexical graphically smaller
than jadish and similarly himantanakash are all smaller than jadish and then this property
holds at every node. So at every node if you check sonach you can see that siddhanth is
less than sonach and vishal which comes to the right is a more than sonach and then siddhanth
vishal all three are greater than jadish. So when a binary tree satisfies this property it is
called a binary search tree. So that's what we are looking at here this is a binary search tree.
So that's the first property but we need the second property is that our nodes will have both
keys and values. Now sometimes you can create binary nodes, binary trees with just keys each
node will have a single number or a string inside it and you can call it the key or value or
element or whatever you wish. But what we want is we want the keys to be usenames so that we can
compare the keys easily. But along with each node we also want to associate a value which is the actual
user object. So if we are looking for himant let's say we started the root node. We see that jadish
is the root node and since it is a binary search tree we know that himant lies to the left
then we reach barrage. We know that himant will lie to the right of barrage so we go right
we reach himant and then we access the value stored at himant which is the user details for himant.
So we need both keys and values in a binary tree and this is what is called a tree map or a map.
In many languages. And then finally this tree that we will create this data structure that we will
create it will be balanced. So here what we are looking at is each node has two children left and
right but it is also possible to have an unbalanced tree where you only have one child on each
on maybe one of the sites. So we will require it to be balanced which means that it does not
skew too heavily in one direction and we will talk about what balancing means and we will talk
about how to check if a tree is balanced and how to keep a tree balanced. So we will go over all
of these things step by step but these are some of the properties that we want our final data
structure to have. So one important property of a tree of a binary tree is the height of a tree.
In fact if you start counting you can say this is level zero where you have one node and this
is level 2. This is level 1 where you have two nodes, the left and right,
the left and right child. Of the root node and then this is level 3 level 2 where you have 4 nodes
the left and right child of the first node on level 1 and the left and right child of the
second node on level 1. So you can see that the number of nodes in each level in a balanced binary
tree is double of the number of nodes of the previous level. So if you have a tree
of height k, which means a tree which has exactly k levels, then here is the list of the
number of nodes at each level. Now level 0 will have one node, the root node. Level 1
will have two nodes, its children. Level 2 will have four nodes, their children, so that's
four nodes is two times two or two to the power two. Level 3 will have eight nodes,
two nodes for each of these four nodes, so that's two to the power three. And similarly,
if you keep going down, level k minus one, the final level will have two to the power
of k minus one nodes. So that if the total number of nodes in the tree is n, then it follows
that n is 1 plus 2 plus 2 square plus 2 cube plus so on plus two to k minus one. So what we're
trying to determine here is what is the relationship between the height of the tree and
the total number of nodes in the tree. And this is the relationship and we can simplify
it a bit. If we add 1 to each side, you can see here that this side we get n plus 1 and
this side we get 1 plus 1, which gets simplified as 2 or 2 to the power 1. And then we
can add 2 to the power 1, we're 2 to the power 1 and that gets simplified as 2 to the
power of 2. Then we can add 2 to the power of 2 and 2 to the power of 2 and that gets
simplified 2 to the power of 3. And we can keep performing this reduction, we can keep adding
these together, till we finally end with 2 to the power k minus 1 plus 2 to the power
k minus 1, which is simply 2 to the power of k. So what that gives us is that k, the
height of the tree is log of n plus 1, which is approximately or in almost never case less
than log n plus 1. So that's a bit of an approximation we're doing here, but it is the height
of the tree is less than log n plus 1. So to store n records, we require a balanced binary
search tree of height no larger than log n plus 1. Now this is a very useful property
in combination with the fact that nodes are arranged in a way that it makes it easy to find
a specific key simply by following a path down from the root, the binary search tree property.
And we'll see soon by the end of this lesson that the insert find and update operations
in the balanced binary search tree have complexity order of log n. So in our original
implementation a brute force implementation they had order n and this time we reduce the complexity
to order log n and that is far better and we'll see how that happens. So that's a quick introduction
to binary search trees we've had enough theory now let's get into some implementation.
But before that we have the second question. Now binary trees are very commonly used as data structures
for our idea of different in a variety of different languages for instance java c plus plus python
java and c plus plus have this concept of a map which is represented using a binary tree.
And it is also used in file systems. So binary trees are also used in file systems to store
indexes of files. So when you browse your file system or when you search for a specific file it
is a binary tree that is used to look up the file and find the location of the file.
Now that's where that brings us to our second question of today.
Now you can find the second question on a LinkedIn profile. So once again go to
LinkedIn.com slash school slash java in AI and you will find the second question here.
The second question is which tree-based data structure is used to store the index
in the Windows file system and who invented the state structure. So like this question follow us
and comment with your answer and you can start chance to win a swag pack.
So if you repeat the question which tree-based data structure is used to store the index
in the Windows file system also known as anti-FS and who invented the state structure.
Okay so let's get to the implementation of binary trees. And here's a very common interview
question that you might get. Implement a binary tree using python and then show its usage
with some examples. So what we will do as we implement binary trees and binary search trees is
to also cover many common interview questions. In fact we will cover exactly 15 so that's a quite
a few. And the first one is to implement a binary tree. And to begin we will create a very simple
binary tree. So we will not have any of the special properties like key value pairs and binary
search tree and balancing rather and we will also use key numbers as keys within our nodes
because they are simpler to work with. So here is an example binary tree so we have a root node
and then we have a left child in right child. And here's a simple class representing this
representing a single node within the tree. So we are calling this class tree node and it has a
constructor function. It simply takes a key and it sets self dot key to key. It also has a
couple of other properties self dot left and self dot right which are initially set to none. So
each node when it's created exists independently of other nodes. And now let's create nodes
representing each of these nodes. So we have node 0 we're calling it we're calling tree node
with the value three then we have node one and node two. So there you go now we've created the
nodes and we can verify that it is of the type to a node you can see here. And if we check the key
of node 0 you can see that it has value three. And we can now connect the nodes by setting
the dot left and dot right properties of the root node. So if you go to node 0 and set dot
left to node one, now we've connected node 0 to node one. And similarly if we set node 0 dot right
to node two, now we've connected node 0 and node two. And that's it we're done. So now we have
three nodes and then we've connected each of those nodes and we may also just want to track
which is the root node. So we can create a new variable called tree and simply point it to node
0. So tree points to the root node of the tree and then the root node is connected to its
children and the children will be connected to their children and so on. So you can check here
that if we check tree dot key, we get three and if we check tree dot left dot key.
So three is the root node. It has a value three. 3 dot left is this node. So it should have the
value four and tree dot right dot key should have the value five. Okay so pretty straight forward
and that's pretty much the answer to the question implement a binary tree and Python.
Now going forward we will use the term three to refer the node root node to refer to the root
node and the term node can be used to refer to any node in a tree not necessarily just the root.
Okay so here's an exercise for you. Try to create this binary tree. So now you have a root
node here and then you have a left child and right child and then this left child has another
left child but does not have a right child. Similarly here you have another right child and then
it has a left child which does not have a left child but has a right child. Okay so this is slightly
more complicated tree structure and try to use these cells these empty cells that are given here
to replicate this tree structure and then try to view the different levels of that tree manually.
Okay. Now please do that because that's a great exercise in understanding how the structure
works and how to connect the nodes but it's a bit inconvenient to create a tree by manually
connecting all the nodes. In fact here you may have to make a total of 1, 2, 3, 4, 5, 6,
7, 8, 9 connections. Right. So what we can do is we can write a helper function which in
convert a tuple and the tuple will have this kind of a structure. So a tuple is simply
is kind of like a list except that it is represented with these round brackets of parenthesis.
So a tuple will have this kind of structure it will have three elements and then the middle
element will represent the value or the key within the root node. The first element will itself
also be either a tuple if the left child is an entire subtree or if it is a single number then
it will be just a number and then the right element will represent the right subtree. Okay. So here's
an example here is one tree tuple. Now if you see this tree tuple it has three elements this is the
first element. This is the second element and then this is the third element. So this first element
two represents the root node and then this so this second element two represents the root node.
This first element or element at position 0 represents this subtree. So you can see here that
in this subtree if you look at just that subtree of that tree. 3 is the root in that subtree and then
Then one is the left side and there is no right side, so that's what this represents.
And then for this subtree where 5 is the root node and then you have 2 other subtries,
that's represented here.
So 5 is the root node and then you have a subtree here and a subtree here.
So this is a very easy way, this is a this is a convenient way for us to represent a binary
tree.
And what we can do is we can define a function past tuple and this past tuple function can
take a tuple like this and then convert it into a tree like structure of length
nodes using the 3 node data structure using the 3 node class that we have defined above.
So we call the past tuple function with some data for instance this tuple and the past
tuple first checks if data is of the type tuple and it has a length 3 if these two
things hold true.
Then first we create a node, we create a node with data 1 so in this case we create a node
with 2 as the key and then we set the left and the right subtries of the node.
And then we are doing something very interesting here, we are calling the past tuple function
once again.
So we call past tuple this time, so this is called recursion when a function calls itself
inside it that's called recursion.
So we call past tuple with the first element which itself is a tuple.
So once again that calls another invocation to past tuple and for a moment let's assume
that that returns the proper subtree, the proper node so we set that node which got
created to node.left and similarly we create the right subtree using these values and
then we set that node to node.right okay.
Now you might wonder in the function we're calling itself so when will the stop can't
it go on forever and that's where you have to track the actual function calls.
So when we call past tuple with the entire tuple first it calls past tuple with this and
when you call past tuple with this then you can see that 3 is used to create a node
and then past tuple is called with 1.
So when past tuple is called with 1 this condition no longer holds true and we also
check the next condition which is if the data if 1 is none and 1 is not none so this
condition does not hold true.
So we fall into the else condition and we simply create a node right.
So we just create a node and this time we are not calling past tuple once again right.
So this is called a terminating condition of the recursive function and similarly once
we get back the result from 1 then we call past tuple with the value none.
Once again this condition is not entered and this condition matches so we set node equal
to none and then we return the node okay.
So when we reach either a leaf node which is either a single number or we reach the
value none that is when we stop in walking the function recursively and then the function
returns and that's how the entire tree gets converted.
So this is a very powerful idea in programming the idea of recursion the idea of functions
calling themselves and it can seem unintuitive and confusing at first.
So one thing you can do is you can add a print statement here inside this function
to see how it works to see how the different calls are going so when you call past tuple
with the entire tuple what are the internal calls that are made and study how the result
comes out maybe try it on pen and paper.
But it's a very important technique for you to learn you will be asked or you will find
applications of recursions in many places throughout your programming or data science career.
So do learn it.
So let's now call past tuple with this tuple as an input and let's see okay so that return
a tree and then that tree is of the type tree node that's great and now let's examine
the tree to verify that it was constructed as expected.
Now we check tree to dot key so tree to dot key should be pointing to the root node which has
the key to and then let's check the level one so that was level zero let's check level one.
So let's check tree to dot left dot key and tree to dot right dot key.
You can see we get the values three and five let's check the next level
on this level we have tree to dot left dot left and then we have tree to dot left dot right
but there's no value there so we can't really check for a key here then we have tree to
right dot left and tree to dot right dot right so you can see that tree to dot left dot left dot
key is one but tree to dot left dot right is none because there is no child here no right child
then we have left dot key and right dot key and that gives you three and a seven and similarly
you can now check level four level three as well so here are all the levels of the tree so it
looks like the tree was constructed properly and you can see the power of recursion at play here
that the recursive function can now construct trees of any levels now you can create
tuples within tuples and as long as they have the right structure as long as you have this
three element structure whether left element represents a left subtree the right element represents
the right right subtree in the middle element represents the current node you can construct a tree of
any size so here's an exercise for you we've defined a function to convert a tuple into a tree
define a function now to convert a tree back to a tuple so if you have a binary tree
can work return a tuple representing this same tree for instance for the tree created about
three two calling tree to tuple should return this tuple original tuple which is used to create the tree
and here's a hint on how to do this use recursion so do fill this out
and see if you can figure out how to do this so now we have defined a class for a binary tree
and we also have a way for creating a binary tree from a tuple so now let's create another
helper function to display all the keys of the tree in a tree like structure for easier visualization
so here we'll just use we'll call this function display keys and we'll not get into the code
for this because it's once again it's a pretty straightforward but there are a few conditions we need
to handle but here's what it will give us when we call display keys on a tree
then we'll get this kind of a representation of a tree and you can see that this is not exactly
the same representation as this you will have to take this representation and then mentally rotated
by 90 degrees in the clockwise direction to get a representation like this but you can see roughly
that the root node is two and then it has a left child three and it has a right child five
then three again it has a left child one and there is no right child now five has a left child three
and three has no left child and three has a right child four and so on so the exact same
structure has been replicated here for us to view visually this is a very useful thing we're
spending all this time here or talking about how to create trees and how to
visualize trees because the easier you make it for yourself to create trees the more likely you
are to test the easier it is for you to test different scenarios out so always spend a little bit
of time coming up with good string representations for any data structure you create something
that helps you visualize them and an easy way to create these data structures okay
so now we have a way to visualize the tree as well that's great now here's an exercise for you
try to create some more trees and visualize them using display keys and you can use this tool
xcalidraw.com and that's where how that's how these diagrams were created as a digital
whiteboard so you can create some trees you can create trees like this and then try to create
come up with tuples for those trees try to create those trees using the
powerstupile function and finally try to display them okay so experiment with it and see
explore what are all the different tree structures that you can create.
Now the next one of the frequently asked questions in interviews is to traverse a binary tree
traversals are very common so you may face one of these three questions write a function to
perform the in order traversal of a binary tree or write a function to perform the pre-order
traversal of a binary tree or write a function to perform the post-order traversal of a binary tree
so what do you mean by a traversal a traversal refers to the process of visiting each node of a tree
exactly once. Now what do you mean by visiting by visiting it could mean any operation but
generally it refers to either printing the key or the value at the node or adding the node's key
to a list and then there are three ways to traverse a binary tree and return a list of visited keys.
So the first one is called in order traversal and the in order traversal now traversal is defined
recursively because binary trees have this recursive structure so you will see that almost all the
functions that we write will have some sort of a recursive structure. So in order traversal
involves first traversing the left subtree recursively in order then traversing the current node
and then traversing the right subtree recursively in order. So what does that mean? Well we start
out with this tree and we are traversing it in doing an in order traversal. So we try we look at
the root node and then we realize that there it has a left child so it has a left subtree.
So we do not visit it yet which means we do not print it or we do not add it to our list yet.
Rather we follow the path on the left side and then we come across three and then we realize that
okay three also has a left child so we don't visit it yet. So then we go down to one
we go down to one and now it does not have a left child or a right child so we can visit one
then we go to three and now we so we've visited the left subtree of three. So now we can visit three
and then the next step is to visit the right subtree of three but of course three does not
have a right child so there is no right subtree to visit. So we can move back up to two.
So now we've visited the left subtree of two. So now we can visit two so we print one three two
and now once we've visited two we can now visit the right subtree of two.
So to visit the right subtree we go to five. Once again we realize that five has a left subtree
so we go to three. Now three doesn't have a left subtree so we can visit three. Then we visit four.
Then now since we visited the left subtree of five we can now visit five
and similarly we then visit six, seven and eight okay so that's the in order traversal of the
tree and then there is another traversal called preordered traversal which is slightly different
where you traverse the current node first. So here we start out at two and we say that
okay we're going to visit two first so we visit two or print it or add it to a list.
Then we traverse the left subtree and then we traverse the right subtree. So we go we visit three
and one and then we come to the right side we visit five and three. So you can compare these two
diagrams and see how in order and preordered traversal are different. Now these are very important
for you to understand because they're great examples of different
functions which have very similar implementations but there are just one or two things you will need to
change and these are recursive as well. So do understand the subtle difference between them and
second they are very commonly asked in interviews you will most likely face some coding
assignment or an interview where you will be asked to perform a traversal of a binary tree.
And then finally there's another order called another we traversal called the post audit
and I'll let you guess how it works you can also look it up. And here's an odd implementation
of in order traversal. Now it may seem a little complicated but it's actually pretty straightforward.
So let's look at it here what we do is given a node. We first traverse the nodes left subtree
then we create so that should return a list a list of all the keys and then we create a list
with just the nodes key. So we get the list of keys from the left subtree in with the in order
traversal then we get add to it the current node's key and then we call traverse in order
with the right subtree and that recursively keeps adding these keys each one and the end condition
so the terminating condition for the recursion is when we hit none so when we hit a node which
does not exist so that means we come there from a parent which does not have a left or right child
then we return the empty array. Okay so let's try it out with this tree so this is the
tree we have and we just saw it's traversal. Now if it traversed we tree in order we get the values
one three two three four five six seven eight and we can verify here we have one three two three four
five six seven eight. So that was the in order traversal of a tree. Now the exercise for you is to
print the pre order and post order traversal of the binary tree and you can test your implementations
by making submissions to these problems on leadcode.com okay so that was our discussion about
traversals another thing that you may get asked commonly is writing functions to calculate the
height or the depth of a binary tree and the writing of function to count the number of nodes in a
binary tree once again these can be expressed recursively as well now the height of a tree
given a node is simply one plus maximum of the height of the right subtree or the left subtree.
The height of a tree is defined as the longest path from a root node to a leaf so you can see that the
longest path from root node to the leaf is of length four so two five three and four.
And the way to do get the longest length of the longest path is by checking the max of the left
height, right height and then adding one to it and of course the terminating condition here also
is if you hit a node that does not exist you return 0. So that's how you get the height of a tree
and you can check that the height of a tree is four then here's another function to counter
number of nodes in a tree once again really simple all you do is this time instead of checking the
maximum we simply get the size of the left subtree get the size of the right subtree add them and add
one to it. So here you can see that there are nine elements in the street three six and nine
so we get three size of three as nine. Now here are a few more questions relating to the path length
in a binary tree so you can just check there's a concept of maximum depth and minimum depth and
then there's also the concept of a diameter so you can try out both of these. Now as a final step
what we can do is we can compile all the functions we've written all the methods as methods within
the tree node class itself and this technique is called encapsulation where we are encapsulating
the data as well as the functionality related with the data of the data structure within the same
class and this is really good programming practice. So as you write more code try to think about how
you can create these classes with not just the information inside them but also with the relevant
methods inside them. Okay so we've now added the methods height size, traverse in order
display keys to tuple and we've also added these methods STR and rapper and remember quiz one
or you can go on LinkedIn and post an answer to what these functions do and finally pass tuple
as well. So all of these functions are now added within the class and you can try it out here.
So for instance here we have a tree tuple and we can call tree node dot pass tuple
to convert this tree tuple into a tree. So you can see that now we are also representing the
binary tree itself using this tuple like representation but we can also display it in this
hierarchical structure using display keys. Then we can check the height using tree dot height
we can check the size using tree dot size and we can traverse the tree in order using
traverse in order and we can convert the tree to a tuple using tree dot tuple. So do create
some more trees and try out the operations that we've just defined or you can also try adding
more operations to the tree node class and before continuing we can just save our work so I'm just
going to import Joven and run Joven.com it. So that concludes our discussion on binary trees.
Next let's talk about binary search trees. Now a binary search tree or a bst is a binary tree
that satisfies these two conditions. The left subtree of any node should only contain nodes
with keys less than the current node ski and then the right subtree of any node should only
contain nodes with keys greater than the current node ski and we can see that this is let's just copy
this over. So we can see that this node this tree here is actually a binary search tree and you can
verify that these two properties hold for each of these nodes and it should follow from these
two conditions that every subtree of a binary search tree must also be a binary search tree. So I
can let you verify that that if you pick up any subtree inside so you pick up any node and you see
the tree under that node you will see that it is a binary search tree. So here are some questions
that are often asked relating to binary trees and binary search trees and we've lumped them together
because we'll answer them with a single function. So here's a function that you might be expected
to write. So write a function to check if a binary tree is a binary search tree which means
ensure that these two conditions hold and second write a function to find the maximum key in a
binary tree so this could be a generic question finding the maximum key and here's another question
that you might face write a function to find the minimum key in a binary tree. So what we will
do is we'll answer all of these questions together with a single function called is bst so is bst
takes a node and then is bst returns three things. So if you look at the return value it returns
whether the node and the tree under that node is a bst. So here so this is going to be the value
determining it's going to be either true or false telling us whether the tree under that node
with that node as root is that a bst. It also returns the minimum key from that entire tree
and it also returns the maximum key from that entire tree. Now why are these two useful we'll see
in just a moment. So the way we calculate is bst node is by actually looking at the left
subtree and the right subtree recursively. So we call is bst on the left subtree of the node
and we call is bst on the right subtree of the node. So we get back three values which is
is the left subtree of binary search tree is the right subtree binary search tree. The minimum
key in the left subtree the minimum key in the right subtree and then the maximum key in the left
subtree and the maximum key in the right subtree. So now what we can do is we can say is bst node
So, is the entire tree of binary search tree?
Well, if the left subtree is a binary search tree and the right subtree is a binary search tree
and then we verify these two properties which is the maximum key in the left subtree is either none,
which means that there is no left subtree or the current node's key is greater than the maximum key.
And the minimum key in the right subtree, the smallest key in the right subtree is either none,
which means that there is no right subtree or the minimum key in the right subtree is greater than the current node's key.
So, that this was condition 1 and condition 2 and that tells us whether this entire tree is now a binary search tree.
And then finally, we can also calculate the minimum key in maximum key simply by computing the minimum of the left minimum,
node dot key and right minimum and the maximum can be calculated by checking the maximum of the left maximum,
node dot key and right maximum.
So, what we return from the is BST function is whether a node and the tree represented rooted at that node is a binary search tree
and then the minimum and maximum key out of it.
So, if we look at this tree right here, let us verify whether this is a BST and we will before we check,
we can probably tell that it is not because you can see that 3 appears as a left subtile of 2,
but 3 the key is greater than 2 and that is a problem.
So, this is a violation of the property elsewhere this property satisfied.
You can check any other node here and you will find that the left subtree is always smaller than the node and the right subtree is larger than the node.
So, let us check is BST tree 1, it is not, so that is false.
Now, on the other hand this tree is a BST, this tree that we have been looking at all this while.
So, once again, we can create this using tree node dot parse tuple and node that keys can,
the way we implemented tree node keys can not only be numbers, but they can also be strings.
So, we do not need to change anything here and that creates tree 2 and we can even display tree 2.
So, if you do 3 to dot display keys, you can see that it has this structure where jadish is at the center.
And then on the left you have barrage on the right you have sonach, barrage in sonach.
Then you have akash, shaman, siddhant and vishal.
And this is a BST, so you get back through here and the smallest value here is akash and the highest value is vishal.
As you can verify in alphabetical order.
So, that is pretty handy, now we have a way to check if a binary tree is a binary search tree.
And this is again a very common interview question that you might face.
Next, remember that we need to store not just keys, but also user objects within each key with each key within our BST.
So, what we do is we will define a new class called BST node to represent the nodes of our binary search tree
and BST node will not only have the key, but in the constructor it can also accept a value and this is optional.
So, we will set the key and we set the value, we will also set the left and right.
Apart from this we also set another property called parent and the parent will point to the parent nodes.
So, for instance if this node is a left subtree of this root then the parent of barrage will point to jadish.
And this will be useful for upward traversal.
Now, if you are given the pointer to a node and you have to go back and find the root of the tree, the parent will be helpful there.
So, this is our BST node.
And let us try to recreate this BST right here with usenames as keys and user objects as values.
So, first we create level 0. So, level 0 we create BST node. Now, the key is jadish or username, which will be just the string jadish.
And then the value will be the jadish user object. So, we have created that and we can check its key and value.
You can see that jadish is the key and then the user object is the value.
Let us create level 1.
Now, level 1 is we set tree dot left to BST node, barrage dot username and barrage.
Now, one other thing that we should do here is once we set it, we should set tree dot left dot parent.
To tree.
And similarly, we set tree dot right.
It's not tree dot right is sonach. So, we set BST node with sonach to username as the key and sonach is the value.
And then we can set tree dot right dot parent as tree.
Now, you can view these values. So, now you can see that we have inserted barrage and the username barrage.
We have inserted sonach and the user sonach here as keys and values respectively.
Now, the exercise we used was then try to add the next level of keys and values and then verify that they were inserted properly.
Well, you can see now that we now have a way to represent the data.
Both the user names and the user objects in a binary search tree. So, we are getting pretty close to the data structure that we want to create.
Once again, we can display the keys of the tree by calling the display keys function.
Now, this is also rather nice. This is a good thing about python that because python functions are dynamic because you do not need to specify the types of the objects while defining the function.
The same display keys function can be used both with tree node and BST node classes.
So, all it requires is that the object of your class should have a property dot key for it to be able to display the keys in this visual setting.
And in the same as true with most of the other functions that we have defined. In fact, any function we have defined for tree node will also work for BST node.
Okay, so moving right along.
Now, we have a way to construct a BST but it it's a bit inconvenient to insert values manually because what we're doing so far is we're manually checking whether we should insert a value in the left of the right.
Rather, there should be a way to do it automatically. We should be able to call a function insert.
And here's this is a common question as well, write a function to insert a new node into a binary search tree.
So, we'll use the BST property to perform insertion efficiently.
Once again, let's grab a copy of this tree here so that we can think about it easily.
Okay, so now we have the tree and let's say we want to insert a new user with the username Tanya into this tree.
So, first we started the root and then we compare the key to be inserted with the current node's key.
So, the current node is the root. So, we compare Tanya with Jadish and we see that Tanya is greater than Jadish because T comes after J.
So, obviously, Tanya should not be inserted into the left subtree. Rather, Tanya should be inserted into the right subtree.
So, if the key is smaller, we recursively insert it into the right subtree and if the key is larger, we recursively insert it into the right subtree.
So, then we encounter Sonaksh. Tanya is also greater than Sonaksh, T is greater than S, T comes after S.
So, once again, we call recursively called insert on this subtree that subtree rooted at Vishal.
This time, we notice that Tanya is smaller than Vishal, so T is less than V.
So, then we need to recursively insert in the left subtree, but there is no left subtree here.
And this is the point at which we can create a new node and attach it as the left child of Vishal.
So, you can see that the node Tanya will get added here at this position in the tree.
So, here is the recursive implementation of insert exactly what we just discussed.
First, we check if the key is less than the current nodes key and if that is the case, then we insert it into the left subtree.
Then we check if the key is greater than the current nodes key and if that is the case, we insert it into the right subtree.
And the ending condition is that if the node is none, which means if we hit a position where we do not have a left subtree and we need to go left
or we do not have a right subtree and we need to go right, then we create a new node.
So, we create new node, node equal to BST node and then we return the node.
So, we return the node and this is an interesting thing that we are doing here.
We are returning the root node back from insert.
So, when we called insert with node.left, we get back the pointer to the left subtree.
So, we can set it back to node.left and we can also set the parent of the left subtree to node.
So, this is just updating the parent.
So, just study this function carefully.
See how it works?
It does exactly what we just talked about.
And it finally returns a pointer to the tree once again.
So, let us use this to recreate the tree that we had here.
Now to create the first node, we can call the insert function with none.
So, initially we do not have a tree to begin with.
We just called insert with none.
And remember that insert after performing an insert in social returns the pointer to the tree.
So, we call insert with none.
And we want to insert the value jadeesh.usename.
And we want to insert the key jadeesh.usename with the value jadeesh.
So, that gives us a tree.
And now the tree has one element.
You can see tree dot key and tree dot value.
And now the remaining nodes can just be inserted into tree.
So, now we call insert with tree.
And call it with barrage.usename and barrage.
Then we call it with sonach.usename and sonach.
Akash.usename and Akash.
And this way.
So, we are adding barrage.
Then we are adding sonach.
Then we are adding Akash.
Himant.
Siddhanth Visal.
And see that we are not specifying exactly where these nodes need to be inserted.
But you can see that once these nodes are inserted,
then they are inserted in the right places.
So, jadeesh.
You can see that the binary society properties preserved here.
And also we have exactly replicated the tree structure that we had here.
So, the left subchild of jadeesh is barrage and the right child is sonach.
For barrage, the left child is Akash and the right child is Himant.
And so on.
Now, note however that the order of insertion of nodes can change the structure of the resulting tree.
So, for instance, if we insert all the nodes in the increasing order of username.
So, for example, here we are inserting Akash.
Himant.usename.
So, this is the lexicographic increasing order.
And we try to display that tree.
This is what we end up with.
So, we end up with an unbalanced or a very skewed tree.
And you can see why it was created as a skewed or unbalanced tree.
Well, let's look at it.
So, we start out with Akash.
So, we have a single node.
And then when we try to insert barrage, we realize that we need to go right.
So, we insert barrage here.
Then we try to insert Himant.
Then we realize that we need to go right from Akash and right from barrage and go to Himant.
And then we keep going this way.
So, how you set up the root node and how you set up each subtree.
And the order and image you insert the nodes is very important.
And that can create a huge skewed within the tree.
Now, skewed or unbalanced trees are problematic because the height of such trees
is no longer logarithmic compared to the number of nodes in the tree.
So, earlier we had deduced that in a balanced tree.
If containing N nodes, the height is log N or log N plus 1.
And that makes the operations like insert, update and find, very efficient.
But here where you have a very skewed tree, the height can actually match the number of nodes.
For instance, this tree has 7 nodes and it has the height 7.
And in these skewed trees, once again you may get back the fact that insertion, finding and update can be order N.
Because you may have to traverse the entire height of the tree, which is equal to the number of nodes of the tree.
And that may once again defeat the purpose of using a binary search tree in the first place.
So, maintaining the balance of a binary search tree is very important and we will see how to do that.
So, we have seen how to insert a node.
Now, the next thing is to find the value associated with a given key in a binary search tree.
So, once again we can follow a recursive strategy here.
Similar to insertion.
So, we check, we start from the top.
Let's say we want to find the key hemant.
We start from the top and we compare it with the root node.
Now, here if it matches the root node, we can simply return this node.
If it does not, then we check whether we need to go left or right.
Since hemant comes before each other, we need to go left.
Then we encounter a barrage and here we realize that we need to go right.
And finally, we encounter hemant and we return.
Another option is that we have a value, let's say Thanya, which does not exist here.
So, if we try to search that, we may go in this kind of a direction and we end up at an empty place.
So, in that case, we simply return none.
So, you either find a node and return it or you return none.
So, you can see here that if we call fine tree with hemant, we get back the details for hemant.
And very interestingly, because there is a balance tree, we only had to take two steps and not go through the entire tree.
And in the worst case, you can check that any path from the root to any leaf in a balance tree will only be two steps long.
And that's what makes it so convenient.
Now, on the other hand, if we try to find the ketanya, you can see that it's not form.
To try creating larger BSTs and try finding some more nodes, it's important to experiment with these operations once they are defined,
because now it's simply a matter of calling the function, we've written the code for it.
So, experiment with it, try creating larger trees with multiple levels and dozens or maybe hundreds of nodes, try generating some fake data,
putting it into the trees and see how trees build up.
And that'll give you a feel for how binary search trees work.
Next, let's talk about updating a value in a BST.
Now, updating a value is fairly simple, we already have a way of finding a node.
So, if you want to update a node, let's say we want to update the node hemal, the key hemal.
And here, we want to update it, we want to update it to this value, which is the new value of the user hemal.
And we're changing the name and we're changing the email here.
So, we first find the node and if the node is not non, then we simply change the value at that node.
It's as simple as that.
And what we're also seeing is we are reusing the find function here, and this is a good practice to always incorporate into your programs into your functions.
Whenever you find yourself copy, pasting some code, and maybe changing one or two things here and there,
think about whether you can extract that piece of code into a function and then reuse that function.
So, always try to make your code more and more generic.
Let's code you write the less, there are the chances for errors, the easier it is to understand and the smaller your functions become.
So, write small reusable generic functions whenever you can.
And this is a principle called the DRY principle or the dry principle, which stands for don't repeat yourself, whenever you're writing programs.
So, in update, we are not repeating ourselves by using the find function to find the right node and simply updating it.
By setting it's value.
So, let's update him on here to the new value.
And you can see that now we have the updated data here, so we have him and she and him and she at example.com.
Now, the value of the node was successfully updated and you can easily check that the time complexity of update is same as that of find.
Now, finally, we have the last operation that was required and this was to write a function to retrieve all the key value pairs stored in a binary search tree in the sorted order of keys.
This is a question that you might face once again.
And this is simply the in order traversal, it's a different way of stating the in order traversal.
Now, what you will have to figure out or reason about is why the in order traversal of a binary search tree produces a sorted array of a sorted list of keys.
Think about it.
So, here's a list all function.
All we do here is we call list all on node.left and then we call list all on node.right and in between them and these give us two arrays.
So, we assume that list all.node.left gives us the list of key value pairs from the left sub tree in sorted order.
Similarly, here we get the list of key value pairs from the right sub tree in sorted order and between them.
We simply insert this key value pair from the current node and recursively it automatically fills out the entire array.
And this is the end condition where we end counter an empty node, we simply return the empty array.
You can see now when we pass in this tree, we get back the list of users key value pairs arranged by the sorted order of keys.
Now, here's an exercise for you.
Determine the time complexity and state complexity, space complexity of the list all function.
Now, you can do this for a balanced tree or an unbalanced tree and here's a hint it will not make a difference.
But think about it.
So, once again let's save our work and now we've talked about binary trees and operations on binary trees.
Now, the next thing is to look at balanced binary trees and this is once again a very common question that gets asked right of function to determine if a binary tree is balanced.
And here's a recursive strategy to do this.
In fact, this is really the definition of balanced binary trees.
The left sub tree should be balanced.
The right sub tree should be balanced.
And the difference between the heights of the left and right sub tree should not be more than one.
So, this is an important thing now.
When we're looking for balance, we're not always looking for perfect balance because it may not always be possible to create a tree with perfect balance.
Because to have a perfectly balanced tree where for every node the left sub tree and the right sub tree have the exact same height, you will have to fill out all the nodes at all the levels.
And that can only have that can only happen for certain numbers.
For example, you can have one node which satisfies this property or you can have a tree with three nodes which satisfies this property.
Or you can have a tree with seven nodes which satisfies this property.
You may not be able to get a tree with six nodes to satisfy that property.
For instance, if you remove Vishal here, you will see that the left sub tree and right sub tree of this node so nox will not be of equal height.
That's why for balancing, we relax the criteria slightly.
We simply need to ensure that the difference between the heights of the left and the right sub trees is not more than one.
So here's the code for is balanced.
Once again, pretty straightforward but we will return two things here.
This is balanced will not only return whether the tree a node is balanced.
It will also return the height of the tree which is rooted at that node.
So the way we implement it is first calling is balanced on node.left and then calling is balanced on node.right.
And by the way, this is exactly how we implement recursive functions as well.
Sometimes we write the recursive functions signature.
Then we immediately write the return value.
And then we assume that recursive call is going to return these values.
So recursive call is balanced.left is going to return whether the left sub tree was balanced and the height of the left sub tree.
And then we assume that is balanced for node.right is going to recall is going to return whether the right sub tree is balanced
and the height of the right sub tree because that's what we return here.
Then the entire tree is balanced if the left sub trees balanced and the right sub trees balanced
and the absolute value of the differences in the height is less than one.
Which means the height l minus height r is either minus one zero or one.
And finally we calculate the height of the tree itself which is simply one plus the maximum of the height of the left sub tree and the right sub tree and we return it.
So that's how you implement a recursive function or think recursively.
And there's one last thing which is the end condition and the end condition although it's often the last thing you think about.
It's the first thing that you have to put in.
The end condition is to check whether a node is non because as we call node.left you may not have a left sub tree.
So you may call is balanced with non.
And if the node is non we simply return true because an empty tree is balanced by default because there's no imbalance there.
And it's height is zero.
So that's our is balanced function.
It's just four or five lines of code.
But if you are not able to reason about recursion easily you may get stuck with this and you may spend an entire 45 minutes trying to write this function and debug it.
So always try to think in recursive terms and that's why always it always helps to write down what you want to do in plain English.
So that you can determine what should be the inputs and outputs to your function.
Maybe also have some test cases ready and then start implementing your function and it becomes really easy.
So this tree for instance is balanced.
Here you can check is balanced.
You get back true.
But this tree here, you're looking at this is not balanced.
So this was tree two and if you check is balanced here you get back false.
So here you also get the height of the tree which is three and here you can get the height of the tree which is seven.
Now here's another tree.
Is the tree is this tree shown here balanced? Why or why not?
Now create the tree and check if it's balanced using the is balanced function.
So there's another concept called complete minorities which is slightly similar to balance minorities but it's a slightly stricter criteria.
So you can check out this problem here and you simply need to modify the is balanced.
The code for is balanced slightly to get the code for complete binary trees.
So do check out this problem on leadcode.com.
Alright, so we've looked at binary search trees and we've looked at balanced binary trees.
Now let's bring them both together into balanced binary search trees.
And here's one question that you will face at some point.
Write a function to create a balanced binary search tree from a sorted list of key value pairs.
So you have a sorted list of key value pairs.
So the key is for example could be username.
The values could be the user objects and they are sorted by key and you have a list.
And you have to create a balanced binary search tree from it.
And here's the basic logic which is somewhat similar to binary search which is something that we've covered in lesson 1.
Do check it out.
What we can do is we look at the middle element.
For instance if you have a list of 15 elements then the element at position 7 counting from 0.
The element at position 7 is the middle element.
Now we can take a middle element and then create a new binary search tree with the middle element as the root node.
So you make the middle element the root node.
And then you take the left half of the list and use that to create a balanced BST and make it the left child of the middle element.
The root node and then you take the right half which both of the halves will have 7 elements each.
So you take the right half and you create a balanced BST out of it and then make it the right child of the middle element.
So that's the idea here.
And how do you make a balanced BST for the left or right child a recursion?
So once again here's a recursive solution.
Make balanced BST takes data which is a list of key value pairs.
It's a low and high and it also takes a parent and we look at those.
Now low is set to zero by default and high by default is set to the last index in the data.
So we use that to get the middle index.
So for instance if low is zero and highest 14 the middle index is 7 then we get back the key and the value from the middle index.
So we calculate we find data made and that gives us the key and the value for instance.
Since the username and the user object then we create the root node.
So we create the root node using BST node and then we call make balanced BST on data.
But this time from low to mid minus one.
So from the indices zero to six and make that the left child of the root and we call make balanced BST on the right node.
So on the right half so from mid plus one which is index 8 to 14 and we make this the right subterry.
And then we return the root and that's it.
That's pretty much it.
The only thing that we might need here is the terminating condition.
When low becomes less and high which means that we have no more elements to create trees out of.
We simply return none.
So the left or right subterry for those for the parents of those nodes gets set to none.
So that's your makes balanced BST function.
We also have this other thing called parent going around and this I will let you figure out what the parent does here.
But this is the basic idea.
So here is a list of key value pairs.
You have a key value pairs sorted in increasing in the increasing or lexical graphic order of keys.
And we're calling make balanced BST with data and that gives us a tree.
And let's view the tree here.
So there you go.
Now we've created the tree perfectly as we wanted it.
Jada is here at the center and we have barats on each side and then the appropriate nodes on each side as the children of those nodes.
Now recall that the same list of users when inserted one by one resulted in a skewed tree.
Here we are getting the list of users using name and user from data and inserting them.
And you can see.
Calling display keys on tree three.
Returns a skewed tree.
So whenever you have a sorted array and you want to create a balanced BST the way to do it is to start from the middle out.
Now finally one other question you may be asked is to balance an unbalanced binary search tree.
And this is pretty simple at this point and this is kind of a trick question because
if you were given this question directly you may not be able to think about what to do.
How do you balance an unbalanced binary search tree.
But now that we have a way to create a balanced binary search tree from a sorted array of key value pairs.
And we have a way to get a sorted array of key value pairs.
So now it simply becomes calling the sorted array.
So calling list all on the node which is also the in order traversal.
So doing an in order traversal of the binary search tree which gives us a sorted array of key value pairs.
And then passing that into the make balanced BST function.
Okay.
So that's the trick here.
It's a two part question and once again we see the benefit of reusing a functions here.
Now we this now balancing an unbalanced BST now becomes a single line of code.
That's very nice.
So we created tree here with the value none and now we insert into it the values one by one.
And you can see that that creates a skewed tree because we are inserting the values in increasing order.
Electrical graphic order.
So we keep adding right children and we never add a right left child.
But then we call the balance BST function which internally takes this gets in order traversal.
So the in order traversal lists all the keys and key value pairs and sorted order.
And then we call the make balance BST function which starts from the middle and then creates a balanced binary search tree out of it.
So there you see this is how you balance a binary search tree.
And what we can do now to maintain the balance as we grow our data structure is a simple thing that we can do is to insert
to balance the tree after every insertion.
And that brings us to the complexities of the various operations in a balanced BST.
So if we are doing an insertion that takes order log in because now if a tree is balanced its height is order log in.
So for insertion you may have to traverse a path from the root down to a leaf and that path can be of length.
At maximum equal to the height which is order log in.
But if we are also doing a balancing with every insertion then we also have an order end term added here.
And order end plus order log in because a log in becomes much smaller than n as n grows.
So order end plus order log in is the same as order n.
So that makes insertion order n.
Finding a node becomes order log in, updating a node becomes order log in and you can verify that listing getting a list of all the nodes is order n.
So what's the real improvement between order n and order log in?
So let's think about it.
If you're looking at 100 million records then log to the base 2 of 100 millions about 26 or 27.
So it only takes 26 operations to find or update a node within a balanced BST.
As opposed to 100 million operations.
So you can see here 26 or a loop of size of length 26 and we're doing some operation inside it.
Only takes about 19.1 microseconds.
That is 1 microsecond is 10 to the power minus 6 seconds.
On the other hand, order n involves looping through the entire list.
So looping through 100 million numbers rather than 26.
And that obviously takes far, far longer.
And we saw that it took about 10 seconds, right, about 9.98 seconds.
So to find an update, finding an updating a node in a balanced binary search tree is 300,000 times faster than our original solution.
And all we've changed here is the data structure.
And that's the importance of data structures because now each user will be able to view their profile in just 19.1 microseconds.
At least that part of the request will take only this long.
So the user experience will be better and your CPU will be busy for a shorter time.
So you will be able to serve not 8, but hundreds of thousands of users every second.
And finally, your hardware cost will also be far lower because now your CPU is busy for a lesser time.
So you do not need to use a very large machine or you do not need to use too many machines to support hundreds of millions of users.
And that is the benefit of choosing the right data structure.
Now there's one tip here how do you speed up insertions.
So what we may do is we may choose to perform the balancing periodically instead of every insertions.
For example, we can balance for every 100th insertion or every 1000th insertion or every 100th insertion.
Whatever, and that's where we have to balance how often do we need to insert things versus how often do we need to restore the balance.
Another idea is to do the balancing maybe periodically at the end of every hour.
So for a second or two, there may be a slight dip in the performance because you may be performing the balancing.
But even that, there's a way to do it. So you can take a copy of the tree and then balance it and then simply replace the pointer to the original tree.
So there are many other tricks that you can apply.
And in fact, there's also an algorithmic trick which brings insertion and balancing together into an order login operation,
which we look at right at the very end, so stay till the end.
But before we do that, let's come back and answer our original problem statement.
So remember now, as a senior back in engineer, you are tasked with developing a fast in-memory data structure to manage profile information.
Use a name name an email for 100 million users and it should allow insertion, find, update and listing the users by using it all as efficiently as possible.
And to answer this question, instead of creating a user database class, we can create a generic class called tree map because we have been making things more and more generic as we have gone along.
So let's define a function called tree map, which internally stores a binary search tree, a balance binary search tree inside it.
So when we initialize a tree map, we set self dot root to none, which means we have not created a tree so far.
And then instead of defining functions insert, update and delete, we are going to use some special functions in Python classes.
So we're going to use the function set item.
We're going to use the function set item here and set item is just like insert, except it is a combination of both insert and update.
So to set item, we will pass a key and a value.
And of course, self will refer to the tree map object itself.
So the first thing we do is we get the root, which is basically the binary search tree that we are storing internally here.
So we get the binary search tree and then we find we look for the key inside the binary search tree.
So if the key is found, so if we find the node in our tree, then we come into this else position and then we simply update its value.
And if we do not find the node, so which is what happens initially because initially our self dot root is none.
So when you call find with none and pass a key, you get back none.
So then we first set self dot root by inserting the key into the tree.
So if a key exists within our binary search tree, then we updated and if the key does not exist within our binary search tree, then we insert it into our binary search tree.
So we've combined insert and update into the single operation called set item.
And similarly we define another operation called get item. This is the find operation.
All we do here is we find the node inside self dot root using the find function we had defined earlier.
And if the node is present, if it is found, then we return the value of the node otherwise we return it on.
So given a key, we retrieve the value.
And then we have we defined one last function called iter. And this is the replacement for our list all function.
So what we do is we simply say we call list all on self dot root. So that gives us a list of key value pairs.
And then we have the special syntax we say x for x in this list.
And we put these round brackets around it. So what this round brackets around it does is that this creates a generator out of it.
So now this is no longer list, but this is a generator. And a generator is something that you can use within a for loop.
So the iter function will allow our class to be used directly within a for loop and we'll see the example in just a second.
And finally we have another function called underscore underscore lene.
So remember there are double underscore here. So there's double underscore set item double underscore get item double underscore similarly double underscore lene double underscore.
Here we simply return the size of the self dot root. So here we simply return the size of the binary tree.
And then we have this function called display this is going to simply display the keys.
Okay, so now we've defined this frame up structure and it has all of these funny looking methods like we know in it, but what about all of these.
But we see what these do in just a moment and we know what the what the functionality is, but you may be wondering why we've defined them like this.
So the reason is these are special methods that are treated specially in python. So here's how you can use them.
Let's first get a list of users that we'll later insert into a tree.
Let's get a tree map. So we instantiate the tree map function or the tree map class and that gives us a new tree map inside it. There is no binary tree you can check.
If you check tree map dot root, you will see that it is none. There's no value here.
And if we try to display it, you can see that this tree map is empty.
Then to insert instead of calling tree map dot insert or instead of calling tree map dot underscore underscore set item, we can use this indexing notation.
So we open these square brackets and we put in the key that we want to insert. So if we want to against the key and so against the key akash, which is a string.
If we want to insert the value akash, then we simply say tree of akash is akash.
And similarly tree of a certain key with the indexing notation set to this.
So this is going to first look for the key as we have defined inside item. If it finds the key, then it is going to update the value for the key.
If it does not find the key, then it is going to insert that key value pair as a new node into our tree.
So let's check it out now.
And let's see here, if we now check tree map dot root, you will see that now it is a BST node.
And if we try to display it, you can see that now it has a structure jadeesh sonaksh and akash.
Also note that this is a balanced tree. If you go back here to set item, you will notice that whenever we insert, right after this we also balanced the tree.
And now you can change the logic here so that we do the balancing not after every insertion, but maybe after every 100 insertions.
You may need to track somewhere what is the current insertion counter.
And when it gets to 100, only then do the balancing and then set the counter back to 0.
So that's an exercise for you, perform the insertion, perform the balancing at only at certain intervals.
And here's a way to retrieve an element. So retrieving an element is also now really simple.
We have called tree map with jadeesh as the index and that gives you the value. If it is found and if it is not found, it simply returns none.
Now because we've defined the function underscore underscore underscore underscore.
So you can see here that has the value 3 because now we can use it with the length function which is used for lists and dictionaries.
And let's add a few more things and let's set the values and let's see here.
So you can see all this works exactly as expected. Now we are able to set values.
We are able to update values. We are able to display the tree. It is remaining balanced.
And remember I mentioned that you can use this in a for loop. So you can now put the tree map directly into a for loop.
And what this will do is because we have defined the underscore underscore itter function and the itter function returns a generator.
So now you can use this in a for loop and you get back the key value pairs from the list all function that was used inside itter.
You can print the keys in the values. And in fact if you want to convert it to a list all you need to do is pass it into the list.
And once again because this is a generator because this is an iterable. This is now an iterable class and you have defined that the way to iterate over this class is to get elements out of the key value pair list.
So when you call list you get back this list of key value pairs.
Okay. So now we've made it a very Python friendly class. You know instantiating it is very easy. We simply create a new tree map adding values is very easy.
We simply use the indexing notation removing elements is very easy. Well not removing a finding elements is very easy.
We simply use the indexing notation updating elements is the same as inserting.
We can also check the size of the tree quite easily using the length function and then we can also use iterate over the keys iterate over all the users in a for loop quite easily.
And we can also update values as you see here values have been updated.
Now the the purpose of doing this is to make it easier for other people to use this data structure.
As a senior back in the engineer you may have designed this data structure and you may have implemented binary search trees inside it.
But it's not important for other people on the team or other people using your data structure to know what the internal implementation is.
What's important for them is to be able to use it easily. So that's why always think carefully about the interface or the API of your functions or of your modules or of your classes.
Try to make them as Python friendly as possible. This was something that will be appreciated in interviews and by co workers.
So make them Python friendly so that when people want to use something you've created it is extremely intuitive.
And they do not need to really understand the underlying details.
For instance, I could be using this class and I could have no idea that it is a binary search tree. All I know is how to insert and how to get a value out of it.
And I know that it is super efficient because you have designed it. And I don't don't need to worry about the internal details.
So encapsulation and good APIs are very important skill to have to cultivate. So do that as you work on programming problems.
Now once again let's save our work before committing. Now I did tell you that there is a way to create self balancing binary trees.
And a self balancing binary tree remains balanced after every insertion or deletion.
And in fact several decades of research has gone into creating self-balancing binary trees and not just binary trees but other trees as well, which are not binary nature.
And many approaches have been devised. For instance, red black trees, AVL trees and B trees. So here's an example.
This is an AVL tree. So here whenever a node goes out of balance, we rotate the tree. And you can see visually what we're doing here.
Whenever you see that there is an imbalance in the tree, we rotate it. And how do you do this? We do this by tracking the balanced factor, which is the difference between the height of the lead subtree and the right subtree for each node.
And then rotating unbalance subtries along the path of insertion or deletion to balance them.
So you can see the balance factor is 0 right now. The balance factor becomes 1 and the balance factor becomes 2.
Then we rotate it to set the balance factor back. And then the balance factor here becomes minus 2.
So here we do a right rotation. Here are the balance factor.
It becomes minus 2 here and minus 1 here. So here we do two rotations. So there are four cases in total.
There's the left right case, the right left case, the left left case in the right right case. All four cases were demonstrated here as well.
And then you may need to do this rotation, not just once, but you may need to do this multiple times along the path of insertion.
So when you insert a node and that node creates an imbalance, then you need to work backwards so you need to keep going from parent to parent.
And keep rotating nodes whenever you need to rebalance them based on the updated balance factor of each node.
So it seems a little complicated, but it's actually not. It's just that there are multiple cases to handle.
So you will need to write a couple of helper functions. You'll need to write a function, left rotate which rotates node left while still preserving the binary search state property.
You will need to write a function, right rotate which rotates the function which rotates it to the right while preserving the BST property.
And then in the insertion, you will also need to perform the rotation at the right places.
And you will need to track the balance factor inside each node. So there are a few things to work out here.
And don't worry, you will normally not be asked to implement an avial tree within an interview or within a coding assessment.
So you do not really need to learn the implementation, but it's nevertheless a very interesting data structure to study.
And here are a couple of resources you can check out. So you can check out this YouTube video which explains it very wonderfully.
And you can check out this implementation on geeksbookgeeks.org. And the important thing for us to take away here, and which is something that you may be asked if not the implementation, but just the complexity.
The important thing is that each rotation takes constant time and at most log and rotations may be required because if you are starting with the balance tree and you're inserting a new node.
Then you may traverse a path of height at most length at most log in. So you may need to perform at most log and rotations. Maybe twice of that.
So what that means is in order log in time, you will be able to insert and maintain the balanced property of a binary tree. So you do not need to recreate the entire tree again.
And that makes your tree very efficient because now when you're working with 100 million records, inserting will also take 20 steps and finding will also take 20 steps and updating will also take 20 steps.
And all of these will work in micro seconds.
So that makes your data structure very efficient.
And with that we conclude our discussion of binary search trees.
So here's a quick summary. We looked at this problem of creating a data structure which allows efficient storage, retrieval and updation.
Also efficient iteration in a sorted order. We first started out with a list of sorted list of values sorted by the keys.
And we realized that that was probably not the right idea because we were working with really large number of records. Then we created this binary tree structure.
So we looked at binary trees. We looked at how to create them. We looked at easy ways to visualize them easy ways to create them from tuples.
We looked at how to calculate their heights. Their sizes. How to traverse them in order pre-order post order.
We then looked at binary search trees which have this property that the left subtree has keys that are smaller than the root nodes keys.
And the right subtree keys are larger than the root nodes keys. And that property holds at every subtree.
And that makes it really easy to find to locate a specific element or find the position to insert an element.
So we created binary search trees. We created the operations insert update find and list all in a binary search tree.
We also determined ways to check if a binary tree is a binary search tree or not.
Then we talked about balancing and we saw how to create balanced binary search trees.
And binary search trees form the basis of many modern programming languages, language features. For instance,
maps in C++ in Java are binary search trees and data storage systems like file system indexes or relational databases.
Also use something called B trees which is an extension of binary search trees.
So it's very important to know about binary search trees even if you may not ever need to implement them.
You may be asked about them and in many cases you may need to pick a binary search tree as a data structure for a problem like we did in this case.
Now you may wonder if dictionaries and Python are also binary search trees well they're not.
Dictionaries and Python are not binary search trees. So we will soon release an assignment that you can find on the lesson page and you will work on hash tables in the assignment.
And here are some more problems that you can try out. So you can try to implement rotations and cell balancing insertion.
You can try to implement the deletion of a node in a binary search tree that's slightly more complicated because what you do if you have to delete a node that has both left and right subtree.
You can try deletion with balancing if you really are up for a challenge.
Here are a couple more finding the lowest common ancestor of two nodes in a tree.
So the common node which is a common parent of both nodes.
Here you can use a parent property finding the next node in lexical graphic order.
So given a node how do you find the next node? What's its complexity?
Or given a number k how do you find a kth node in a binary search tree?
So to do this you will have to employ some clever tricks and then there are a couple more resources here.
You can open up these and find more questions.
The important thing to take away is that almost all of these will involve some form of recursion.
So you will either work with the left subtree or the right subtree or both.
And some of them may also require you to store additional information within the node.
For instance, for this one the given number k find the kth node.
This may require you to store the size of each balance binary search tree in each node.
So what to do next? You should review the lecture video and execute the Jupiter notebook experiment with the code yourself.
Then complete the assignment hopefully.
The next lesson is called divide and conquer and sorting algorithms.
This is data structures and algorithms and Python and I will see you next time.
Thank you and goodbye.
Let's look at assignment two of data structures and algorithms and Python.
The topic of the assignment is hash tables and Python dictionaries.
Let's get started.
First thing we will do is go to the course website pythondsa.com
And on the course website you can find all the lessons and previous assignments.
We are looking at assignment two.
So you may want to open that up and assignment two is based on or inspired from some of the topics discussed in a
course lesson two. So you may also want to watch lesson two and complete the notebook before you work on assignment two.
Let's open it up.
Now in this assignment you will apply some of the concepts learned in the first two lessons to implement a hash table from scratch in Python.
That's very interesting.
You will and hash tables are very important data structure.
They present in pretty much every programming language and are a common topic discussed and asked in coding interviews.
So we'll see how to implement them from scratch.
And one of the central problems in hash tables is called collisions.
So we'll see how to handle hashing collisions using linear probing.
And we will also replicate the functionality of Python dictionaries.
So Python dictionaries are actually implemented using hash tables.
So we'll see how to replicate the way Python dictionaries are created and used and modified and the way we
access keys and iterate over keys and set values and change values and so on.
So we'll pretty much re implement the Python dictionary.
Now we have an assignment, start a notebook here.
So we can click on view notebook to open up the notebook.
Once again, this is a Jupyter notebook.
And as you walk through the notebook, you will find question marks in certain places to complete the assignment.
You have to replace all the question marks with appropriate values, expressions or statements to ensure that your notebook runs properly and to end.
Okay, so make sure that you run all the code cells do not change any variable names.
And in some cases, you may need to add code cells or new statements.
And since you'll be using a temporary online service for code execution, keep saving your work by running JoVin.commit at regular intervals.
There are some optional questions. They are not considered for evaluation, but they are for your learning.
Okay, so let's run the code.
The recommended way to run the code is using free online resources, binders specifically, but you can also run it on your computer locally.
So we're going to click run and click run on binder.
Once again, this may take a few minutes, sometimes depending on the current traffic on the platform.
There we have it. Now we have the Jupyter notebook running.
The first thing I like to do is click kernel and restart and clear output, so that we can execute all the code cells and see their outputs from scratch.
I'm also going to hide the header and the toolbar and zoom in here a little bit.
So we can see things a little better.
The first thing we will do is set a project name, import the JoVin library and run JoVin.commit.
This will allow you to save a snapshot of your work to your JoVin profile.
So now you have a copy of the assignment starter notebook.
Any modifications that you make every time you run JoVin.commit will get saved to your personal copy.
And it is this personal copy that you will submit at the very end.
So let's talk about the problem statement.
In this assignment, you will recreate Python dictionaries from scratch using a data structure called hash tables.
And dictionaries in Python are used to store key value pairs.
So keys are used to store and retain values.
Here's an example. Here's a dictionary for storing and retrieving phone numbers using people's names.
So we have a dictionary called phone numbers and the way you create a dictionary is using this special character, the brace or the curly bracket as it's called.
And then in a dictionary you have these key value pairs.
So this is one key value pair where you have a key.
The key in this case is a string or a cache.
And here you have a colon.
And then here you have a value. The value in this case is a phone number.
So that's how you create a key value pair.
And comma separated key value pairs is what you need to create a dictionary.
You can see once the dictionary is created, it has displayed in the exact same way.
And then you can access a person's phone number using their name.
So if you have variable phone numbers and we use the indexing notation, so this is the square bracket.
And we pass in a key here.
We get back their name.
And you may wonder what happens if the key is not present.
The great thing about Jupiter is you can insert a new cell.
Like you can just click insert cell below.
Or you can use a keyboard shortcut B as I just did.
And check maybe.
Let's check the key Vishal.
Okay. And you get back a key error.
And you may also wonder what happens if is it case sensitive?
Does that matter? You can check it very easily.
So a lot of the questions that you might get.
A lot of the questions that you may want to even ask on the forum or look up online.
Can be resolved simply by creating a new cell and typing out some code.
So what happens if questions can all be answered by writing some code?
So now let's add some new phone numbers.
So this is how you create an initial set of phone numbers.
This is how you access a phone number.
And this is how you add new values.
So adding new values is like accessing them.
But instead of accessing it, you put an equal to and then you actually set the value here.
So we can add a new value here.
The phone number for Vishal.
And we can also update an existing value in a dictionary simply by accessing that value and putting an equal to.
And putting a new value there.
You can see now that the dictionary is updated to contain the new phone number 7878 and not the original phone number 948948.
You can also view all the names and phone numbers stored in the phone number dictionary using a poop.
So you can say far for name in phone numbers.
So when you put a dictionary into a for loop, you get back a key within each loop.
You can see here that the name and the phone number here is displayed for you using the print statement.
So those are some things that you can do within a dictionary.
And dictionary isn't Python are implemented using a data structure called a hash table.
And hash table uses a list or an array to store key value pairs.
And uses a hashing function to determine the index for storing or retrieving the data associated with a given key.
So here's what it looks like here.
You have the key, John Smith.
And you have a function called hash and the function hash takes any key.
And it returns an index within the list.
So why do we use a hashing function?
Well, one approach as we've discussed in lesson two is we can store or key.
And store or key value pairs in a list.
And we can simply search through the list each time we want to look up the value for a key.
But that is inefficient because that requires looking through potentially all the keys before we get to the key that we want.
Or maybe half of the keys.
So that makes it an order and operation.
If N is a size of the list.
That's pretty inefficient.
We want something faster and a hash function actually operates in constant time.
Simply takes the key.
And it converts a key into a number.
So in that sense, it gives you the index of the specific key value pair in constant time rather than ordering.
And that is what makes hash tables so efficient.
So hash function.
There's not required looping through the list.
It simply takes a key gives you the index.
And you can simply then get the key value pair or the value from that index.
Now your objective in this assignment is to implement a hash table class which supports these operations.
And insert operation.
The way to insert a new key value pair.
A find operation.
To find the value associated with a given key.
And update operation.
To update the value associated with a given key.
And then list operation.
To list all the keys stored in the hash table.
And here's where we are going to use Python classes.
And there's a brief introduction to Python classes in lesson 2 of this course.
So do check out lesson 2 if you want to refresh on Python classes.
We have the class hash table.
And inside the class hash table, you have a bunch of methods.
Now the insert method apart from taking the self argument.
And remember that the self argument is refers to the object of the class that will be created.
So this is equivalent to this variable in Java or C++.
But these are the actual arguments of the method.
The actual arguments are key and value.
So the insert function of the insert method will take key and value.
Then the find method will take a key.
The update method will take a key and value once again.
So the find method takes a key and your job is to return the value.
The insert method takes a key and value and you insert the key value pair into the hash table.
Then you have the list all method which is used to list all the keys from the table.
So before we begin our implementation, let's just save and commit our work.
So we're running Jovind.commit here.
Let's just run that once again.
There we go.
The notebook has now been committed.
So what you can do is you can come back to this particular page.
And you can find this from your profile.
And then you can click run to continue your work based on the modifications that you've already made.
Okay.
So we build hash table class step by step.
And the first step is to create a Python list which will hold all the key value pairs.
Now remember that hash table internally uses a list to store the key value pairs.
And we will create a list of a fixed size.
So we'll set this variable max hash table size of size 4096 initially.
And we're going to create a Python list of this size.
And how do you create a Python list of the size?
And we want all the values to be set to non.
So this is the way to do it.
You can of course you can start typing non and that would take a long time.
Or you can use a simple technique.
Just put in non times 4096.
And there's one of the great things about Python.
It is such an expressive language that creating a list of 4000 elements simply requires this single.
Expression here.
You can check that here.
You can even check the length of the data list.
Now if the list was created successfully, here are some test cases.
Here is one check that the length of the list is 4096.
Here's another check.
And we simply picking a random value from the list 99.
And just checking if that is equal to non.
But if you really want to have a short short test here, what you should be doing is you should be checking for.
Item in detail list.
Item equals non.
And here's a trick you can do.
You can write a word called assert.
And what assert does is if this comparison is true, then it does nothing.
It lets your code proceed as usual.
But if at any point this comparison becomes false, then it throws an error.
Let's see here.
You can see here there was no error.
So that means it worked fine.
But if this comparison was wrong, so let's say if it.
If we had here, we wanted the items to be equal to.
Seven if you put it and and the tell is does not contain the item seven at certain position.
And you will get an assertion error here.
Okay.
This is how you can create your own test cases by putting in assert.
But the idea here is that whatever you try to do, make sure that you're adding some.
You're adding some more test cases and not just depending on the test cases that are given here.
It these are simply to guide you in the right direction.
Okay.
So next up we have a list.
Now we need a way to store or insert key value pairs into a list.
That's where the hashing function comes into picture.
The hashing function is used to convert strings and other non numeric data types into numbers,
which can then be used as list indices.
For example, if a hashing function converts the string or cosh into the number four,
then the key value pair or cosh and the phone number 7878787878787878.
We'll be stored at the position four within the data list.
And here's a simple algorithm for hashing, which can convert strings into numeric list indices.
And a hashing algorithm does not have a single definition.
You can come up with a hashing algorithm.
And in fact, coming up with a good hashing algorithm is an area of research in itself.
Now of course, python dictionaries use hashing that is in built into python.
And that's a fairly optimized hashing algorithm that's probably the result of several years of research.
But here's one very simple technique.
We iterate over the string character by character.
And then we convert each character into a number using python's built in ORD function.
And you can see here that if you call ORD on the character x,
you get back a number.
It's already gives you a way of converting characters into numbers,
but not entire strings.
That's why we need to iterate over the string character by character.
Then we simply add the numbers for each character to obtain the hash for the entire string.
So very simple technique.
We just keep if you have the number hello,
we take the odd for hello, the odd for either odd for L, the odd for L, the odd for L,
the odd for O and add them together.
And since we want that number,
the final result to be an index or a position within the list.
So we take the remainder of the result with the size of the detail list.
So it's possible that once you add the numbers together,
you may end up with a pretty big number.
But if you take the remainder with 0096 or the max hash table size variable,
you get back a number that is smaller than 4096.
So you can use just let it remain as the index.
So let's first define a function called get index.
All it does is it takes the detail list and it takes a string.
And it returns.
It applies this hashing algorithm to return an index for that string for that key.
So for a character in a string,
we need to convert the character to a number.
So we convert the character from the string into a number by calling awardee on a character.
Great.
Then we update the result by adding the number.
So we say result plus equals a number pretty straight forward.
And that repeats for all the characters in the string.
And then we get back the final result.
Now that result may be longer than the actual size of the list.
And this is where we may then want to check the size of the list.
Okay.
Now remember there's one,
I could also have probably written max hash table size here.
But that would be wrong,
isn't it?
Because we are passing in a detail list here.
We are passing in a detail list.
And although we have so far created it in detail list of size 4096,
your function should ideally be looking at the size of the detail list that you have.
And not any global variables.
So keep that in mind.
And the right thing you should check here is land detail list.
And what this will allow is now this will allow your function to work with
Data lists of different sizes.
And not just the standard size 4096 that we have.
Defined above.
Okay.
Very important thing.
Always make sure that your functions.
Use the arguments that there are passed into them.
They are generic that they can work with any input and not just a particular input that
have been that has been defined earlier.
Okay.
So there you go.
Now you have.
This is our function get index that has been defined.
And here are some tests.
Now if you pass in the detail list and you pass in the empty string,
because there are no characters.
The result is likely to be zero.
Great.
Here's another one.
The result here is 585.
Here's another one.
The result here is 941.
Great.
Now this is where you should be testing your function with some custom test cases.
So I'm going to create a new.
Data list 2.
And this is going to have the size 9 times 48.
So this is only going to have the size 48.
And I should be testing get index with this data list as well.
So let's say we're looking at the key a cache.
Now we know that let's see.
We can actually test this out here.
What happens if you add ORD of A plus ORD of A plus K A S and H.
That number is 585.
But since the size of the list is 48,
what we should be getting back as the result is 48 divided by 585.
So we should be getting, oh sorry.
585.
And it's remainder with 48.
We should be getting back the number 9.
This should be equal to 9.
Okay.
So let's check that if this is equal to 9.
And indeed this is equal to 9.
On the other hand, if we had max hash table size,
you will see here that since we are not taking into consideration the actual size of the list
that was passed into the function,
we are getting back the value 585 because we are taking the remainder with 4096.
Okay.
So remember to take the result remainder with the size of the detail list that was passed in.
So this is one of the several gotchas in this assignment and they're there for a reason
because this is something that you need to keep in mind.
A function which only uses its arguments and does not depend on any external global variables
or constraints and things like that is called a pure function.
Of course a pure function also does not modify any external global variables.
So it simply takes some arguments and it turns the result irrespective of anything else outside.
So now we can to insert a key value pair into hash table.
We can simply get a hash of the key.
So here we have a key value pair and we simply get a hash of the key by calling get index for
data list and key.
We get back the index 585.
And then inside the data list at the given index,
we can simply set the key value pair as the element stored at that index.
And the same operation can be expressed in a single line of code.
Here we're calling get index for data list and he month.
And that's going to give us an index.
And we're going to then invoke a set at that particular index within data list.
The the element.
He month comma, he month's phone number.
Now to retrieve or find the element associated with a pair,
we can simply get a hash of the element the value associated with the key.
We can simply get a hash of the key and look up that index within the data list.
But here we have the key a cache and we have the data list.
And we call get index.
So we get the index of the key a cache.
And that gives us the index here.
And we can then call data list and pass in the position IDX.
And that should give us a key value pair.
Remember that we stored a key value pair at the given index.
So we should get back that value here.
So now we know how to store a value.
You get it's hash for the key and you stored the key value pair.
Now to retrieve a value.
So you get a hash for the key and then you retrieve the key value pair.
And from there, you can get the value.
You can also list the keys to list the keys.
Here is some special code we are using.
So let's see.
This is called list comprehension.
And let's take a quick look at list comprehension.
So list comprehension works like this.
If you create a list. Why from a list X?
Let's say let's call this list one.
And list two.
Good variable names always help.
So if you have a list one.
And you write this X for X in list one.
What does that do?
That for X in list one.
Petch is elements one by one from the list.
And then here you can specify what to do with the numbers that we've fetched.
So right now I'm not doing anything.
I'm simply returning that number.
And then I'm putting the entire thing into a list.
What this does is this creates a new list.
So you can see this is a copy of the original list.
What I could do is I could write X times two for X in list one.
And now I would end up with a list which in which each element is the double of that particular element.
I could also do X times X.
If I wanted.
I could also call a function on it.
Let's see what function we can call here.
Let's maybe put in some numbers here.
1.3.
2.4.
3.2.
So we could put maybe the function maths dot round X.
Also our math dot seal.
This is going to give us a ceiling.
1.3 becomes 2.4 becomes 3.
So you can do any operation with each element of the list.
And once you put that in a bracket and you have this for here.
That's going to apply that same operation to the entire list.
And this is called list comprehension and python.
It's a very powerful way to express complex operations on lists and dictionaries.
And there's one final thing in list operations which is the if condition.
So for X in list one can be followed by an if condition.
And the if condition can once again apply on X.
So if X is greater than 3 let's say we put this condition.
Then what happens is we choose only those numbers from list one,
which satisfy this condition X greater than 3.
So that means we would skip 1.3 we would skip 2.4.
You would get 3.2 we would get 6 we would get 7.
And we would apply math dot seal to them.
And that's how we get back 4.67.
Let's list comprehension in a nutshell.
So to get a list of keys all we can do is for key value pairs in detail list.
If the key value pair is not none remember that we have a lot of non values and it's a huge list.
If the key value pair is not none then we simply return kv0.
So remember if you have a key value pair.
If you have like a key value pair that's a cache and a phone number.
And you can also put because these are tuples.
You can also put a round bracket here if you want.
But even without it it's the same thing.
That's a key value pair.
So kv0 is going to give you the key and kv1 is going to give you the value.
So we simply get the key for those key value pairs in detail list.
Where the element at that position of the key value pair is not none.
And that should not be called pairs that should probably be called keys.
You can see that the keys are a cache in payment.
So that's how we can now use the get in next function.
And the next step for you is to complete the hash table implementation here.
By following the instructions given in the comments.
So now you have this basic hash table class and in this class you have a constructor.
Now the constructor takes the object self or this.
And the self is going to point to the actual object of the actual hash table that gets created using the class.
And then it takes a maximum size.
Now what are we doing here?
We want to make our hash table configurable.
We don't always want to have 4096 elements in our internal list.
If we may need a hash table that can store more values or we may need a hash table that can only store fewer values.
So we are going to set a default value for it which is the max hash table size.
So if you do not provide this argument by default it will create a list of size 4096.
But we also want the option to specify a maximum size.
Now you need to create a list of size max size with all the values set to none.
Now you may be tempted to do this.
But that would be wrong.
Remember that always use the arguments to a function.
Try not to depend on an external value or external constant.
So this would be wrong.
You may also be tempted to do this.
Detail list.
Detail list equals details that we've already created.
This would also be wrong.
Not just because you're not using the max size but also because.
Now you're tying this class implementation to a global variable.
And that global variable is a list which can be modified.
So if you all the objects of this class any number of hash tables that you create using this class.
We'll all use the same data list.
And that's not what you want each hash table that you create.
Maybe you have a hash table for phone numbers.
You have a hash table for addresses.
You have a hash table for something else.
Each of them should have their own internal data list.
And this is not going to create a copy of that original list.
It's simply going to point to the original list.
So what you want to do is you want to do.
None.
And you want to multiply it with max size.
There you go.
This is the correct way to do this.
Now we're looking at insert here.
Now to insert.
We did see that to get the index all you need to do is you need to pass the key.
And remember here you need to pass not data list but self dot data list.
Right? Because now we want to use the.
Data list that is stored inside this specific object of the class.
We do not want to use the global data list.
And this is something that is.
And mistake that we often make initially.
I've still make this mistake where I have certain global variables defined.
And I'm using those global variables inside my class.
Why doing that?
Anything that you want to put inside a class object.
You need to put inside self like we've done here.
And then to access it, you need to use self dot to access that specific property or.
Element or even method.
So now we have self dot data list.
And we pass in the key and the data list into get index.
And that gives us the index.
Now the get index function was defined earlier.
We've seen it already.
Now we want to store the index inside the list.
So we call.
Self dot data list IDX and we want to store the key value pair there.
So we can simply put in key comma value here.
If you wish, we can also put in the brackets, but they're not necessary.
And that's going to insert the key value pair.
Now how do we.
Find the value associated with the given key.
First, we get the index for the key.
So we call get index on self dot data list and key.
Then we retrieve the data stored at the index.
So this would be simply.
Self dot data list of IDX.
And then if the key value pair is not non.
If the key value pair is non well.
There's nothing that index.
We can return none.
Another option would be to also maybe raise an index error.
And with a message.
It's a threat.
But return on is good enough for now.
Then if not from the key value pair, we get back the key and the value.
And then we return the value.
Keep that in mind.
If you simply return this, you would get an error.
You would get an exception that may go unexplained.
So whenever you are destructuring or you're trying to get to values out of it.
To make sure that the tuple is not non, especially in this case,
because we're starting with a list of nons in a place where we're supposed to be storing key value pairs.
So that's fine.
Now update is going to be pretty much identical to insert.
I don't see any difference here.
So we can simply say get index or self dot data list and key.
And then now we simply store the key value pair inside it.
So we can simply store the key comma value inside self dot data list IDX.
Then for list all, again straight forward,
self dot if kv is not known.
So get all the key value pairs that are not empty.
And then we simply get kv zero is going to give us the key from kv.
So that there it is.
Here you can see already that we are creating a basic hash table.
Of max size 1024.
So the first thing that we can verify is that the length of the basic of the
detail list is 1024.
There you go.
Then you insert some values here.
So we insert the value a cache.
We insert the key value pairs.
So we insert the value 9999 for a cache.
So this is one key value pair.
We are inserting a month and 80.
And what this will do is when you call basic table dot insert,
it will call this insert function.
And self will now point to the basic table that we have just created.
Because we're calling insert on that specific basic table.
So self will point to the basic table.
So self dot data list will become basic table dot data list.
And then the remaining arguments are cache in 9999.
We'll get passed in as the key and the value.
So this code will execute, we will get the index within self dot
data list for the key a cache.
And then within self dot data list or basic table dot data list in this case,
add the given index that we just computed.
We will store the key value pair, which is a cache and the phone number.
And that's how it will work.
So we're inserting some values and then we're finding a value.
So once we insert the two values and then we find a value,
that should give us the value 80, 80, 80.
You may want to then maybe modify the test case to also include the test for
the other values that we inserted.
Feel free to modify the test cases or add new test cases.
So that we're checking not just one value but both the values.
Next, let's see how we can update a value.
So we call basic table dot update and we set 7777.
Now suppose you're not implemented update here.
Let's for a moment return.
Suppose you're not implemented update here.
Then if you called update, you would get false here because the value
did not get updated.
And you can check that by simply checking basic table dot find a cache.
You can see that it still has a value 9999.
That's how test cases are helpful.
Let's remove the return.
Okay.
So now the value seems to have been updated just fine.
Then let's get a list of all the keys and the list of keys should match true.
Once again, if we did not have this KV is not none then we would get back not just
this key value pair but we would get back all the nons and we don't want that.
So these were some test cases but you need to dark create more test cases and
test them out to make sure that your implementation is correct.
Now once you've done that, you won't want to run Joven.com it.
Now the next step and this is something that you may have thought about while working
through the assignment is that how do you ensure that different keys do not point
to the same index because we're doing all these things where we're converting
each character into a number and then adding up the characters.
Now obviously if you have words which have the same characters but in different
orders now obviously are different keys but they do not have they have the same
hash listen and silent have exactly the same keys.
Exactly the same hash so for instance you can check get index listen and get index
silent.
Okay.
We also need a data list let's put in a data list here.
So them have the hash 655 that means if you insert a value at with the key listen
and then you insert a value with the key silent.
The data at this position will get overwritten.
So when you try basic table dot find listen you will get the value associated with silent
and that's bad and this is called collisions.
This is called a collision because here the two keys are colliding in some sense
because they're leading to the same hash.
And any hash table that you implement is ultimately going to have collisions
because the number of strings of the number of keys is possibly infinite
but you have a limited number of positions or indices in your table.
So our hash table implementation is incomplete because there can be data loss
and it does not handle collisions and there are multiple techniques to handle collisions
and we the technique we will use in this assignment is called linear probing
and here's how it works while inserting a new key value pair.
If the target index for a key is occupied by another key then we simply try the next index
and if the next index is also occupied by another key we try the next and then we try the next
and then we try the next till we find the closest empty location.
And then while finding a key value pair we apply the same strategy
but it's searching for an empty location this time we search for a location
which contains the key value pair with the matching key.
We get the hash of the key that we want to find
and then we check if that position is occupied by another key not the same key.
Then we try the next index and then we try the next index
and then we try the next index till we find a position which is occupied by a key value pair for the same key.
And if we find an empty position that means the key does not exist
because if it did exist then it should have been somewhere in that string of searches
that we just did.
Now by updating the key value pair again we apply the same strategy
but instead of searching for an empty location we look for a location which contains a key value pair
with the matching key and update its value.
So that's how you handle collisions in a hash table.
And to handle collisions we will define a function called get valid index
which first gets the hash using get index
and then start searching the data list and it turns the first index which is either empty
or contains a key value pair matching the given key.
So we are now addressing two requirements in one shot with the get valid index function.
For insertion we are looking for an empty position for a find and update
we are looking for a position which is occupied by the given key value by the given key value pair.
Or the given key specifically.
So here is the get valid index function and I will let you work through this.
So you will start with the index return by get index then while true
because we don't know how long we may need to iterate get the key value pair stored at the index.
This is where you may have to.
It's simply a question of putting the index into the data list getting the key value pair.
Now if the key value pair is none which means that there is nothing at that index it is empty.
That's great we are done we can simply return the index.
On the other hand if it does have values so then we get the key and value out of it.
If the key matches the key that we want to store.
Great then we can return the index once again.
If neither of these hold true we move the index to the next position.
But as we move to the next position it's possible that we may run out of indices.
So the index may become equal to the length of the data list.
Then we wrap around and go back to the 0th position.
So this is an important part where we go around.
So now our list is in some sense circular where we can keep looping around it so that if we have something
that needs to be stored at the last position.
But the last position is occupied then we move back to the 0th position and so on.
And then you can check if get valid index was defined correctly.
And if it was then these cells should output true.
Once again these are just some sample test cases.
So you should include some more of your own test cases here.
And finally once you're done just save your work.
Now the next step is to incorporate linear probing into your hash table.
So here's a new class called probing hash table.
Here you need to use not get index but get valid index.
It has pretty similar code so I let you work this out.
Be aware not to simply copy paste code and you will run into issues if you copy paste code.
So always make sure that you are writing the code yourself and carefully writing each word or each variable and each method and each argument of the code.
Then there are some test cases here for you to test a probing hash table.
Once again you can try it out with some examples and see if it works fine.
Specifically here we are taking the same example listen and silent.
Both of which in basic hash table would have the same key but in probing hash table would have different.
We'll have the same position but in probing hash table will have different positions.
And that's it.
We have at this point you're done with the assignment so you can make a submission.
If you have run jobin.com it you can take this link and make a submission on the assignment page.
Or the other option for you is to simply run jobin.submit.
I can do a assignment too.
And once you make a submission it will be evaluated automatically so let's click through here.
So it will be evaluated automatically and if you scroll down here you will see that you will get a great not just great but you will also get comments for each question.
So if you see here there are question numbers here.
You can see that there's question 5, question 4 and so on.
So it seems like we since we implemented the get index function since we implemented the detail is correctly question 1 was a pass.
Let's see what question 1 was very quickly.
Question 1 create a Python list of size max table hash size.
Question 2 was a pass so question 2 was the get index function.
Question 3 was a pass.
Question 3 was complete the hash table implementation.
Question 4 was a field get valid index we've not defined it yet and question 5 led to an exception.
Obviously because we have some code which will not execute because we have some blanks that need to be filled in.
So keep that.
Use this as feedback.
You will know exactly what to fix.
And if you are stuck at any point you know what to do.
You can go to the forum.
So let's see the forum here.
So this is the forum sub category for assignment 2.
You can create a new topic here if you want to have a longer discussion or you can simply go to the main topic.
Assignment 2 hash tables and Python dictionaries.
And you can ask a question here.
There are already a lot of discussions going on here.
So it's possible that your question may already have been answered.
And after this there are also some optional questions.
Now here the optional question is for you to implement a Python friendly interface for the hash table.
So instead of defining functions insert update and find you will define the functions get item set item.
And instead of list all you will define the function ater.
And also instead of using the hash function instead of using the custom hash function that we have defined.
You will define you will use the function that's inbuilt into Python called hash.
And it takes any string or any object and it returns a number for it.
Now since hash does not accept a list. So you will have to take the remainder manually.
So in this case, for example, you've taken the remainder and gotten back to number 3569.
So define a hash table here.
And once you have done that, you will be able to use it just like a Python dictionary.
You will be able to use it exactly like this. You create a hash table.
And then to insert a value, you use the indexing notation and insert a value.
To retrieve a value, you use the indexing notation to get the value back.
And here you can compare it with the number.
To update a value, you simply use the indexing notation again.
And to get a list of values, you simply call the list function or you can also use it within a for loop.
And we've also defined a function called rapper and STR.
What that will do is that we'll let Python printer representation like this.
When you simply run a cell, which just contains the name of this variable.
That's one. And then there are a bunch of improvements that you can try to hash tables.
This is a great exercise if you want to improve your Python programming skills and also understand how hash tables work.
If you can complete these four exercises, there's pretty much no question related to hash tables that you cannot answer.
You will know everything about them.
And each of these exercises may take another 30 minutes to 45 minutes.
But it's completely worth the time. Maybe spend a set aside a few hours on the weekend to work on these optional exercises.
Now here's one how to track the size of the hash table instead of having to loop through the entire table to get the number of key value pairs.
Can you store the length somewhere so that you can track it in size order one.
Here's one to implement deletion.
So to implement deletion, you have a topic called technique called tombstones that are used.
So you can use this tombstone technique and implement it just a little more code.
In your implement dynamic resizing.
So instead of starting out with a hash table of a given size or requiring the user to specify a size.
Can you or maybe start with a hash table of let's say 128 elements and then double it as soon as you reach 128 elements or maybe even before to avoid collisions.
You may want to double it as soon as you reach 64 elements like 50% of the capacity.
So dynamic resizing is the technique that allows you to automatically grow and shrink the data list internally.
And then here's another technique for collision resolution.
This is called separate training.
So instead of going to the next index, what you do is you maintain a length list at each position.
And for all the keys, you still use that position, but you look through the length list while looking for key or you add a new element to the length list for that position.
If you're adding a new key there.
So here's separate training explained in a YouTube video.
You can look through that and try to explain it on your own.
And one final thing here is also the complexity analysis.
And here's where you talk about average case time complexity because on average if you have a good hashing function and you've implemented some improvements like dynamic resizing.
Then the average time complexity for insert update find and delete are order one.
And list of course is still order and on the other hand, the worst case time complexity because there can be collisions are still order in.
So here's something for you to ponder upon what is average case complexity and how does it differ from worst case complexity.
It's also something that is discussed in lesson three of the course where we talk about quick sort.
And you see why insert find and update have an average case complexity of order one and a worst case complexity of order in.
If not, it is something that you can look up online.
Try to see if you can search it tutorial and learn why this happens.
Then how is the complexity of hash tables different from that of binary search trees.
We've discussed binary search trees in a lot of detail in lesson two.
It's now the question becomes when should you prefer using hash tables and when should you prefer using binary search trees or vice versa.
All these very interesting questions and you may get asked some of these questions and interviews as well.
It will help you, especially to ponder upon some of these questions even if you do not end up solving all of these optional questions.
Do look at the complexity analysis and think about it.
And there's a forum thread where you can discuss your thoughts.
So what do you do next review the lecture video review the assignment walkthrough video and execute the Jupiter notebook.
Complete the assignment and attempt the optional questions as well.
And do participate in forum discussions.
So this was a walkthrough of assignment two of data structures and algorithms in Python.
Hello and welcome to data structures and algorithms in Python.
This is an online certification course conducted by Jovian in today we're on lesson three.
My name is Akash and I'm the CEO of Jovian and I'm your instructor for the course.
If you follow along with this course and complete four weekly assignments and a course project.
You can earn a certificate of accomplishment for this course.
So let's get started.
The first thing we do is visit the course website.
PythonDSA.com.
So when you visit pythonDSA.com this will bring you to the course website.
Here you can find all the information and material for the course.
You can check out lessons one and two and assignments one and two.
Both of which are still open for submission.
And let's open up lesson three.
So the topic today is sorting algorithms and divide and conquer.
And you can watch a video recording of the lesson here.
You can also catch a version in Hindi.
Now the code used for the lesson is provided here.
So let's open up this link sorting and divide and conquer.
This is where all the code is present.
So here we have it.
Now we are looking at the tutorial and the code for this lesson.
If you scroll down you can see that there is some code here.
Now to execute this code you have two options.
You can either execute this code online using free online resources which is what we recommend.
Or you can download it and run it on your computer locally.
And the instructions for both of these are given here.
We are going to use the first one which is to click the run button at the top of this page and select run on binder.
So let us scroll up here and let us click the run button and then click run on binder.
Now once you do this it will open up and interface like this.
And what you're looking at here is a Jupiter notebook.
So a Jupiter notebook is an interactive programming environment where you can write code.
Look at the results and you can also write explanations.
And we've provided you with a cloud based Jupiter notebook setup.
So you don't have to install anything.
All the code that you execute here will be running on our cloud.
But you can also download it and run it on your own computer by following the instructions.
So the first thing we'll do is click on the kernel menu and click restart and clear output.
To remove any of the outputs from previous executions of the code.
So that we can execute the code and see the outputs fresh for ourselves.
Now I'm also going to zoom in a little bit here.
So we can look at the code and let's get started.
So this is a coding focus and practical course.
And we're talking about different data structures and algorithms.
The topic today is sorting algorithms and dividing conquer algorithms in Python.
So in every lecture we focus on a specific problem.
So in this notebook in this tutorial we will focus on this problem which you're looking at here.
So let's read the question.
You're working on a new feature on Joven called top notebooks of the week.
Write a function to sort a list of notebooks and decreasing order of likes.
Keep in mind that up to millions of notebooks can be created every week.
So your function needs to be as efficient as possible.
That is the key point here.
Now this is a classical problem in computing.
The problem of sorting a list of objects and it comes up over and over and computer science is software development.
And it's important to understand common approaches for sorting.
How they work, what the trade-offs are between them and how to use them.
So before we solve this problem we solve a simplified version of the problem.
It's quite simple to state.
Write a program to sort a list of numbers.
And sorting usually refers to sorting in ascending order unless specified otherwise.
So that's a question for today.
Write a program to sort a list of numbers and we'll expand upon it to answer this original question as well.
Now this is the method that we've been following throughout the course and we will continue to follow a systematic strategy for solving programming problems.
Step one, state the problem clearly.
Identify the input and output formats.
Step two, come up with some example inputs and outputs.
Try to cover all the edge cases.
And step three, come up with a correct solution for the problem.
State it in plain English.
Step four, implement the solution and test it using example inputs.
So this is very important that you implement the simple solution.
So you just need a correct solution first, not the efficient one and then you implement it and test it.
Then you analyze its complexity, identify inefficiencies and then you apply the right techniques to overcome the inefficiencies.
And that is where the knowledge of the right data structures and algorithms comes into picture.
And once you apply the new technique, then you once again state the solution, implement it and analyze its complexity and repeat if necessary.
So this is the strategy we'll follow here today as well.
So step one, state the problem clearly and identify input and output formats.
Now the problem is stated clearly enough for us.
We need to write a function to sort a list of numbers in ascending or increasing order.
Now here's the input.
The input is a single argument called norms and that is a list of numbers.
So for instance, here's a list of numbers.
You can see that they're not in any specific order.
And then the output is the sorted version of the input.
So here is the same list of numbers in sorted order.
And based on these two, we can now write a signature of our function.
So our function will be called sort or something else, but it will accept just one input.
And right now we've not written any code here, so we just put it in pass.
Now I'm running this code here using the shift plus enter shortcut,
but you can also use the run button on the toolbar.
So either run or shift plus enter.
And the great thing about Jupiter notebooks is that you can add more code cells anywhere and test anything that you want.
For instance, if you want to insert a code cell below, just click the insert cell below menu option.
Or click outside a cell on the left and press the B button.
And now you can write some code here and run it.
So please feel free to experiment with this notebook as you go along.
It's a step to come up with some example inputs and outputs.
Now this is very important.
You need to think about all the different scenarios in which you may want to test out your function before you put it into production.
So that you catch errors early on.
And thinking about scenarios will help you identify what are the special cases you need to handle and code.
And it's easier to do it right now than while writing your code because that may lead to bugs.
So here are some scenarios that I was able to come up with and there may be more.
So you can continue and increase this list.
So the first one is some list of numbers in random order.
So some numbers in any random order and you can try slightly smaller list and larger list and so on.
Second is a list that's already sorted.
We need to ensure that an already sorted list does not become unsorted.
A third is a list that's sorted in descending order.
We may want to check that.
See if we need to handle that case separately.
Somehow.
Then a list containing repeating elements.
This is something you may not have thought of.
But the question ever said that the numbers should be unique.
So there could be repeating elements here.
And empty list.
Interesting input.
The output is also an empty list.
Or a list containing just one element.
Or a list containing one element repeated many, many, many times.
Or even a really long list.
This is something that we may want to test because we want our algorithm to be efficient at the very end.
So a long list may help us just evaluate the efficiency empirically.
So these are the scenarios.
And what we now need to do is create some test cases for these scenarios.
So test cases involve creating an input and an output.
For instance, here's an input.
Number zero.
And this could be the list for three one.
And here's the expected output.
So let me call it output zero.
And this would be one three four.
Now this is a good way to put create a test case.
And you can use it later for testing.
But we will put our tests into this particular structure.
We'll create a dictionary.
And creating a dictionary like this will help us automate the testing of all our test cases with a single help.
So what we're going to do is for each test case created dictionary.
And then it will have two keys.
First key is called input in the second key is called output.
And in the inputs for each of the arguments that go into the function.
And remember there's just one argument here.
We will have one key.
So we will have the key norms.
And the key norms will have the input value for the test case.
And the output will simply contain the output returned by the function.
So that's how we'll set up our test cases.
So there's a test zero, a list of numbers in random order.
Then we have test one.
This is also another list of numbers in random order.
You can see here no specific order.
Now we have a list that's already sorted.
And the output obviously is the same.
Now for the random order list the output is the same numbers in sorted order.
Now we have a list that's sorted in descending order.
And the output is the same list in increasing order.
Then we have a list containing repeating elements.
You can see that the numbers 1, 2, 6 and 7 and even minus 12 repeat here.
Here we have the empty list.
Here we have a list containing just one element.
And here we have a list containing one element repeated many, many times.
And then the final test case which was to create a really long list.
That's where we can start with the sorted list.
Created using the range function and then shuffle it to create the input.
Otherwise you may spend a lot of time just creating a list and then writing the sorted version of it.
That's too much work.
So always use a computer, always use helper functions whenever you can.
Even to create test cases.
So we'll use the range function.
Now the range function takes either a single number or two numbers.
So you can have something like this, range 2 to 10 or just range 10.
And if you just look at it this way, it just prints range 0 to 10.
Now if you actually want to see what's in it.
There are a couple of ways you can do list, range 10.
And that gets converted into a list.
Or you can use it in a for loop.
So you can put for x in range 10, print x.
So you can see that it contains a numbers 0 to 9.
And that's important that the range does not include the end element of the range.
So just keep that in mind.
Now what's the difference between a range in a list?
A list contains all the 10 numbers together at once.
But a range internally simply maintains a counter.
So when you use a range in a for loop, it simply starts the counter from 0.
And increment it up to the starts a counter from the starting value.
So if it's 2 to 10, then it starts a counter from 2.
And increases it up to the end value minus 1.
So it does not use as much space as a list.
It simply uses a one single variable internally.
And that's why it's more efficient.
In any case, right now we need lists.
So what we will do is we will create a list of 10,000 numbers.
So 0 to 9,999.
That is our in list.
And then our out list is also going to be 0 to 9,999.
That's our out list.
Both of them are sorted.
Now what we do is we shuffle the in a list.
So we import the random module from Python.
And then we call random dot shuffle.
And we call random dot shuffle on in list.
And that shuffles the the first list, the in list.
So now we have that as the input.
And then the out list, the sorted list is the output.
Now once again, we can even check that in list is actually shuffled.
Maybe by looking in the first 10 elements.
You can see here that these are all shuffled numbers.
On the other hand, if you check the out list.
You can see that these are all in order.
So those are our test cases.
And it's very important to create good test cases.
Even in interviews before you start coding or before you even suggest a solution.
You should try and list out your test cases either verbally to an interviewer.
In a coding assessment, you may create a block of comments at the top and start listing some test cases at the top.
Or you can create proper test case dictionaries like this.
It takes a few minutes, but it's totally worth it because you can then test your algorithms very easily.
And finally, we'll take all our test cases test 0 to test 8 and put them into a single list called tests.
Great, so we made some good progress so far.
Next, let's come up with a simple correct solution and stated in plain English.
And coming up with a correct solution is pretty straightforward.
We have a list of numbers, so we iterate over the list.
Let's grab a list of numbers, so that we have something to look at.
Here you go.
So we have a list of numbers, so we iterate over the list of numbers starting from the left.
So we start from the very left.
And then we compare each number with the number that follows it.
So we compare 99 with 10.
And if 99 is greater than 10, then we can say for sure that 99 should appear after 10.
In the final sorted array and the sorted array by default, it means the increasing order of numbers.
So that's what we're solving first.
So what we can do is we can simply swap 99 and 10 because we know that 10 should appear before 99 and 99 should appear after 10.
Now as we continue the swap, we move to the next position and then we compare 99 with the next element 9.
That turns out to be higher as well.
So we swap it and then we keep going.
So we iterate over the list and for each element compare the number with the number that follows it.
And if the number is greater than the one that follows it, swap the two elements.
Now you do that once and that alone is probably not enough to compile the entire list because the entire sorted list because 99 in this way will end up at the end.
If you follow the process, but the rest of the list is still not sorted.
So we repeat these steps one to three.
So once again we start from the left and then we start comparing 10 with 9 and then 10 with 8 and so on.
And keeps stopping elements as we go forward.
Now I have a claim here that you may you will need to repeat the steps one to three at most and minus one times
to ensure that the array is sorted.
Can you guess why? And here's the hint.
After one iteration of the process, the largest number in the list will reach the very end.
So that means that each time you're putting one of the largest numbers at the very end.
So you need around end steps.
So here's an animation showing the same thing.
You'll be compare 65 and then we switch them.
Then we compare 63 and we switch them.
Then we compare 61 and we switch them.
Now we compare 68 and we don't switch them because they're in order.
Next we compare 8 and 7 and we switch them.
Next we compare 8 and 2 and we switch them.
And finally we compare 8 and 4 and we switch them.
And in this way, the largest number 8 has reached the very end.
So now we can throw free sets position and we can start again from the beginning.
And you can see that this time the next number 7 will end up here.
And then the next time the number 6 will end up here.
And then next time the number 5 will end up here and so on.
So in end repetitions of this process of comparison left to right.
We will have sorted the array.
And this approach is called bubble sort because it causes the smaller elements to bubble to the top or to the beginning.
You can see that the numbers 1, 3 slowly bubble up to the top and it causes the larger numbers like 8 and 7 to sink to the bottom.
And you can watch this entire animation to get a full sense of how bubble sort works.
What will also really help is if you can take an example on paper and work it out on your own step by step.
Especially with sorting algorithms, this really helps.
Okay, so now we've come up with a correct solution.
Let's implement it and let's test it using an example.
Now the implementation itself is also pretty straightforward.
So we have the bubble sort function here.
Deaf bubble sort, it takes a list of numbers.
Now we may not want to modify the list of numbers in place because then our test cases will not be reusable.
So just to avoid modifying our test inputs.
We're going to create a copy of the list to avoid changing it.
And the way to create a copy simply call the list function with the list as input.
So now we are set replacing norms with a copy of norms.
Now depending on your particular use case, this may not be necessary.
So this is something that you can actually check while you're in a coding assessment or in an interviewer or talking to an interviewer.
Just check with them, do they want an array to be sorted in place or do they want a new array to be created?
If they want, if they're okay with sorting it in place, then you probably don't need this.
But you may still just want to keep it in because otherwise you may end up modifying some of your test cases unintentionally.
And that may lead to problems.
So always go to create a copy of the input rather than modifying it in place.
So then let's come to steps 1, 2, and 3.
And then we'll see step 4, which is the outer most step really.
So we iterate all the array.
So we go from we take I and we check the range,
LEN norms minus 1.
So the number of elements in the array is N and N can be obtained using LEN norms.
Then we want to go from indices 0 to N minus 2.
So the total number of indices is 0 to N minus 1.
But if you go to the N minus 1 for the last element, there is no further element to compare it with.
So keep that in mind that you only want to run this iteration till your pointer comes to this point, not till the last element.
And that is why we check if we put I in the range 0 to LEN norms minus 1.
So the highest value that it can take is LEN norms minus 2.
Next we compare norms I with norms I plus 1.
So we compare the number with the element that comes after it.
And if it is greater, so that means these two are out of order.
So then we simply swap them.
So we set norms I, comma norms I plus 1, equal to norms I plus 1, comma norms I.
Now this is a very interesting way of sorting in C or C plus plus or Java, you would have to write three or four steps to swap numbers.
But in Python it is really simple.
First you say x, y is, let's say we're missing x, y are 2, 3.
So you can see they have the values 2 and 3.
And then we simply write x, y equals y, x.
So what happens is the value of y gets placed into x and the value of x gets placed into y.
So it's a single step for swapping two numbers, there you go.
So we swap the two elements, exactly what we are showing here, swapping the two elements.
Next we repeat this.
So now we're doing this from left to the penultimate element.
And in this way we've pushed the largest element to the end.
Now we need to repeat this process n minus 1 times.
So that each time we are pushing one of the largest elements to the variant.
And in n minus 1 repetitions of these three steps, we will end up with a sorted list.
And finally we return the sorted list and that's it.
So let's test it out with an example.
And by the way, if some of this doesn't make sense, so a simple way to debug it is to add print statements here.
So you can add a print statement and maybe just print this value.
So we've used underscore here because we don't actually use this value.
But let's say we wanted to use this value, then we can print that this is iteration j.
And then inside it, you can print that the value of i is i.
And you can also print the value of norms i.
And you can also print the value of norms i plus 1.
And at the very top, you can also print norms.
Now if you add all of these print statements and then execute your algorithm,
now you will be able to see exactly what is happening inside each iteration.
So that's a great way to debug your code if you're facing any issues and also understand what the code does.
But in any case, we won't need these.
So I'm just going to comment these.
So let's test it out.
So we get from test 0, we get the norms as input.
And then we get the output.
And we can print the input and the expected output.
And then finally calculate the result by passing norm 0 into bubble sort.
And then printing the actual output and finally weather the two match.
So you can see here now that the input was this unsorted list.
And then the expected output was this sorted version.
And that's what we got.
So in fact, there was a perfect match.
And that's it.
So we've implemented our first sorting algorithm.
It was pretty straightforward.
A few lines of code.
As an exercise, you can try to implement it once again from your memory.
It's just write it in plain English first and then try to implement it.
It's a good coding practice.
And we can also evaluate all the test cases that we have.
Remember, we had created about nine test cases.
And to help you evaluate the test cases, we've given you a helper function called evaluate test cases,
which is part of the joven library.
So we install the joven library here.
Pippen, install joven.
And then from joven.python dsa.
So python dsa is the name of the course.
So that's also the module where we have helper functions for this course.
Import evaluate test cases.
And evaluate test cases simply goes over the list of test cases that you have.
And it pulls out the inputs and passes them as arguments to the function provided here,
which is bubble sort.
And then gets the outputs and compares the outputs.
And also prints the information with like what was the input.
What was the expected output in the actual output and whether they match.
So let's check it out.
So you can see here this was test case zero.
And that work, which we just tested out.
Here's a larger list including some negative numbers.
This worked as well.
You can see the test result is passed.
Then you have another list here.
This seems to work fine too.
This is already sorted.
Here you have one which is sorted in decreasing order.
That works.
Here you have one with repeating numbers.
That works too.
The empty list works.
The single element works.
And this works too.
This is the same element repeated over and over.
And finally here is the final test case.
This had 10,000 elements remember.
So you can see that this was the expected output.
And this was the actual output.
So we have successfully sorted 10,000 elements.
And that's really the power of programming that.
Without having to look at any of the numbers.
We've just written four or five lines of code.
And we've sorted 10,000 elements.
So all our test cases passed.
All the do look here that it took about 15 seconds
for the sorting of 10,000 elements.
Now maybe that's not that bad.
But we're looking at probably millions of notebooks every week
at Joven.
So we want there to be a faster sorting algorithm.
Okay.
So before we improve the algorithm,
we need to understand the algorithms complexity.
And identify any inefficiencies.
Now the core operation in bubble sort,
if you look at the code here once again,
is this operation of comparison.
So we're comparing a number with the next number.
And swapping.
Now comparison almost always happens.
And swapping doesn't happen nearly as often.
So if you want to find the time complexity,
and we want an upper bound or the worst case time complexity,
we can assume that roughly every comparison also leads to a swap
in the worst case.
So if we just count the number of comparisons
as a function of the input size,
the size of the list that was given as an input,
that should give us an idea of the time complexity.
Okay.
So here we can see that there are two loops.
And the length of each loop is n minus 1.
And inside the inner loop, there is a comparison.
So the total number of comparisons is n minus 1 times n minus 1,
which is n minus 1 square or n square minus 2n plus 1.
Now expressing this in the big own notation,
which is to get a rough idea of how the number of comparisons
or the number of operations in the algorithm grows with time.
We can ignore the lower order terms like 2n plus 1.
So we can now conclude that the time complexity of bubble sort
is order of n square.
And this is also known as quadratic complexity.
So we can now verify that bubble sort requires order 1 additional space.
That this is an exercise for you,
but here's a quick hint.
You can see that we are not allocating any new lists.
We did create a copy of the list, but we didn't have to.
So let's not count that.
But apart from that, there is no additional space that was required.
We are not allocating any new variables.
We are creating this range,
but remember I mentioned that a range simply contains a single variable inside it,
which it keeps incrementing for a for loop.
So we have these two ranges,
so maybe we have two variables assigned.
So it's constant irrespective of the size of the input.
And that's how bubble sort requires order 1 additional space.
Now you may be asked about space complexity,
and this is where it's a slightly tricky thing,
because sometimes strictly speaking space complexity
also includes the size of the input,
because to store n numbers or n elements,
you need n spaces in memory.
So the space complexity of bubble sort in that sense is order n.
And this is something you can check with the interviewer,
if they're asking you what is the space complexity,
and you can ask them if they just want to know
what is the additional space required.
So the overall space complexity is order n,
because we need to store the actual input list somewhere.
But on the other hand, the amount of additional space required is order 1,
which is a constant factor independent of the size of the list.
So that's how bubble sort works.
Now analyzing this order n square complexity,
and keeping in mind that a list of 10,000 numbers takes about 12 seconds.
So if n is 10,000 and n square is multiplied by some constant
is about 12 seconds,
then if you had a list that was of 100,000 elements,
so that would be 10 n whole square or 100 times the same amount of time
that it would take to sort it.
So that means it would take about 20 minutes to sort 100,000 numbers,
which I would say is a bit inefficient now,
and a list of a million numbers would take close to two days to be sorted in Python.
Now if you do it in C++, maybe it might be four or five times faster.
But again, the moment you go from a million to 10 million,
that will actually end up taking a year or so.
And that's bad, and that is why n square or quadratic complexity
is something that we would like to do away with,
because it grows very fast,
as soon as you hit maybe a 10,000 or 100,000 elements,
then it starts taking longer than a few seconds or a few minutes or a few days,
and at that point you can no longer use that particular algorithm.
So we need to optimize bubble sort,
and the inefficiency in bubble sort comes from the fact
that we are shifting elements by at most one position at a time.
So each time we go through the list,
we capture some information about the list.
But we are simply moving one element from left to right, so to speak.
And each time we are just moving it one at a time by doing swaps.
Rather it would be nice to just place elements directly,
maybe a few positions ahead,
and that's where we will look at some optimized algorithms.
Now another common algorithm that is used is called insertion sort,
and this is here is the code for insertion sort,
so you can look through the code for insertion sort here.
And here is an example, you can see how it works,
and we will not look into insertion sort in a lot of detail,
but roughly this is how you arrange cards in your hand,
which is by starting to move cards around,
so that at the maybe on the left edge you have sorted cards,
on the right edge you have the unsorted cards,
and you keep moving the new cards into sorted positions.
That's our works.
So here's an exercise for you, go through this function,
read the source code, and then describe the algorithm in plain English.
Now reading source code is an essential skill for software development,
this is something that you'll have to do in your work,
whether you're doing software development or data science,
maybe because there are no comments in the code,
there is no documentation or the person who is written the code
is not available or has left the company,
or this is some open source library.
So in all these cases you will have to read an understand code,
so read it and then describe insertion sort the algorithm in plain English,
then look it up online and see if it matches what you've written.
And then second is to also determine the time and space complexity of insertion sort,
and see if it is any better than bubble sort,
and explain why or why not.
So these are a couple of exercises for you.
So that's bubble sort and insertion sort.
Now before we continue,
I just want to recall you that this is a Jupiter notebook,
running on an online platform,
hop.binder.jovind.ml and since this is free,
it will start down after some time,
so you want to capture snapshot of your work at regular intervals
and that's where you can use the Jovind library.
So you install the Jovind library using paper and install Jovind,
import Jovind and then run Jovind.com it.
Now when you run Jovind.com it captures a snapshot of this Jupiter notebook
and puts it on your Jovind profile.
So now this will be your profile when you run Jovind.com it
and you will be able to resume your work
by clicking the run button on this page anytime.
And this notebook will go to your profile,
so you can just click on your Jovind profile or just click home here.
And if you check either the overview or the notebook step,
you should be able to find your notebook here.
Like here you go.
Okay, coming back now,
where it steps six where we want to apply the right technique
to overcome the inefficiency in the algorithm.
Now to perform sorting more efficiently,
we will apply a strategy called divide and conquer.
And divide and conquer is a very common strategy
of used across the board for many different kinds of algorithms.
And it has this general steps that is applied
in different ways across different problems.
So step one is to divide the inputs into two roughly equal parts.
Okay, they don't have to be exactly equal,
but two roughly equal parts.
And the idea here is that those two parts
can themselves be used as inputs as sub-problems.
So then we use recursion,
so we recursively solve the problem individually
for each of the two parts.
So here you have a problem,
you have created two sub-problems out of it
and then you call recursion.
So the recursion solution itself will use divide and conquer
and then we'll keep going and so on.
But once it gives you the solution,
combine the results to solve the problem
for the original inputs.
Okay, so you have now results of the sub-problems
and you combine them and you get back the final result.
And then the only last thing you need to know
is because you're going to keep calling
this, keep doing this division recursively.
So if you have an input of size 100,
you will call the same function on inputs of size 50 and 50,
then you will call the same function for each of those 50
will call the same function on inputs of size 25 and 25.
So each half and as you keep going,
you will eventually end up with small or invisible inputs.
And that is where you can solve the problem directly
and include terminating conditions.
So that's where the recursion stops.
Okay, so you include terminating conditions
for small or invisible inputs.
So that's divide and conquer.
You take the problem divided into two sub-problems,
recursively solve the sub-problems,
get the solutions of the sub-problems and then combine them.
So you can also call it divide-conquer-combine, in some sense.
And merge sort is the algorithm
that is the classic application of divide and conquer
to the sorting problem.
So let's take a look at merge sort
by looking at an example visually.
So here we have a list that needs to be sorted
in increasing order.
So remember, step one, divide the problem into two sub-problems.
So here we have half the list, a little more than half.
Here we have another half.
So we have split it into four elements and three elements.
Then we call recursively, we call the same sorting problem,
the same algorithm on these two.
So we split 38 and 27 into one half and 43 into another.
Here 982 becomes one half and 10 becomes the other.
Again, we can split 38 and 27.
We can split 43 and 3, 982, 10.
So now we've ended up with single elements.
So with recursion, we've ended up at this
terminating condition.
We can no longer split the list.
So now we start combining the problems.
Now if you're looking to sort a list with just one element,
38, well that list is already sorted.
So you can return that.
And 27 is already sorted, the single element.
So you return that.
Now we have these two sub lists and we need to combine them.
Each has one element, so we can simply compare these two elements.
And we can tell that 27 comes first and 38 comes second.
So that's how you combine these two results to get 27, 38.
Then similarly with 43, you combine them to get 343 and you get 982 and 10.
Next you can combine these two results.
So this is where now the combination is important.
Okay, we need to look through and we can probably tell that three should come first.
And then 27 and then 38 and then 43.
So we've combined them here.
And similarly here we've combined 9, 10 and 82.
And then we take the final results.
These two final lists and then we combine them back to get the fully sorted list.
Okay, and we'll talk about this combination or what is called the merge operation
in a lot more detail.
Soon, but this is roughly the idea here.
You keep splitting it into half and then you combine the halves.
So let's now state it in plain English.
So first, the dominating condition if the input list is empty or contains just one element,
then it is already sorted or returned it.
If it is not, divide the list of numbers into two roughly equal parts.
Then sort each part recursively using the merge sort algorithm.
And by the power of recursion you will get back to sorted lists.
Then merge the two sorted lists to get a single sorted list.
And this is the key operation here and this is why it's called a merge sort.
Because we are always merging sorted list and making bigger and bigger sorted lists out of them.
And the merge operation is something that you may be asked to write in an interview or a coding challenge,
apart from the whole merge sort operation itself.
So this is something that you can try to explain yourself.
So try to think about how the merge operation might work and explain it in your own words.
Here is some space for you.
But let's jump into the implementation of merge sort then.
Now we will implement merge sort, assuming that we already have a helper function called merge.
And this is a very useful trick where your program may need some complicated piece of logic or some logic which you have not figured out yet.
So all you do is assume that you already have the function and write use it first and then implement it later.
So here's a merge sort algorithm.
So now we have the merge sort algorithm and we have numbers here given as an input to merge sort.
Now here's the terminating condition.
If the length of numbers is less than equal to one which means if the list is empty or has just one element return the numbers.
Then if not, then get the midpoint.
So return length of numbers divided by two.
And remember using the double slash share because a single slash would return a decimal.
And we cannot use a decimal as an index or a position in the list.
So that's why we using the double slash share.
So we take the length of numbers divided by two.
So if the size of the list is 10, so we get back five year.
Then we split the list into two halves and here's some interesting syntax view.
And let's look into what the syntax actually means.
So let's say you have a list.
So this is the list we have and let's admit has the value.
Well we can check it here one, two, three, four, five, six.
So six elements by two, mid has the value three.
Now let's check x of mid.
What does that give us?
Well that gives us one, three, five.
Well actually x of colon mid means x of zero to mid.
And x of zero to mid means all the elements from position zero.
Till before the position mid.
So that's very important.
Once again it's like a range.
So you get the indices at position zero, one and two, not at position three.
Okay, so that gives us these three elements.
Then let's check the other thing.
x of mid colon.
Now what this gives you is this gives you the elements starting from the position mid.
All the way to the end.
So you can also write here minus one or we can also write here.
Lenn of x minus one, but or we can just skip it.
And Python will automatically interpret that you want all the elements starting from mid to the end.
That is 12, five and one. So position three, four and five.
And hence to split the list all we need is to invoke this.
13, five and 12, five, one.
We get back to parts of the list.
So this is a nice thing about Jupiter whenever you don't understand a line of code.
Just create a cell above or below and try out a simple example.
So now we have the left half, numbers zero to mid and then the right half.
So numbers mid colon.
Now here's where the magic happens. We call the function recursively.
So we call the merge sort function itself.
So we call merge sort on left and that gives us back a list, a sorted list for the left half called left sorted.
And then we call merge sort function right and that will give us back a sorted list called right sorted.
And then we combine the results of the two halves by calling the merge operation.
So now we are now saying that we want to merge left sorted and right sorted.
To get back the final sorted numbers and then we return the sorted numbers.
So that's merge sort.
So yeah, it's almost seems like magic but it's pretty small, pretty straight forward.
Only about four, five lines of code if you combine some of these lines.
So then let's come to the merge operation because that seems to be the meat here, right?
This is the only missing piece.
So to merge two sorted arrays, what we can do is we can repeatedly compare the two least elements of each array
and copy over the smaller one into a new array.
So here's what that process might look like.
Let's say you have these two parts, one, four, seven and zero, two, three.
And we want to get this sorted list and notice that these are both already sorted because these are the results of the recursive calls to merge sort.
So we keep a pointer on the left on each one.
So here we have the pointer at one here, we have the pointer at zero.
We compare the two.
We take the smaller one and put it in the list.
How do we know we can put it?
Because if this is smaller than this, all these numbers are also greater than zero.
And then since one is greater than zero and all these numbers are greater than zero.
A greater than one.
So that follows that all the other numbers to the right of one and to the right of zero are greater than zero.
Hence zero should come in the first position.
So we put it there and advance the pointer.
Now you can see here now we can compare one and two in this time one is smaller.
And you know that all the numbers here are greater than two.
So they're also greater than one.
And then all the numbers here are also greater than one.
Hence we know that one is now the next largest number.
So we can now put in one and advance the pointer.
And keep going.
This time now you compare two and four.
So now you can put in two and advance the pointer.
Now you put in three and then advance the pointer.
And at some point you will exhaust one of the lists.
And when you exhaust one of the lists then you can stop comparing and you can simply copy over the remaining elements.
So we can now copy over four and seven and we've exhausted this list.
And we get back the sorted master is zero one two three four seven.
So it's really simple.
It involves each step involves one comparison and incrementing one pointer.
So you're either incrementing this pointer or you're incrementing this pointer.
Okay.
So let's now define the merge operation.
And you can see the benefit now of assuming that the function already existed.
Now we do not have to worry about the actual sorting and recursion etc.
We simply have to worry about merging two sorted arrays.
So first we'll create a list to store the results.
And we have numbers one and numbers two the two left and right list that we are going to combine.
Then we're going to set up two indices or two numbers for iteration.
So we have two pointers on the two lists.
And we set up each of them at position zero.
So each of them are currently at position zero here.
And we loop over the two lists.
So we say while I less than line of numbers one and while j less than line of numbers two.
So if you have four elements in the left list then I can go from zero to three all four positions.
And if you have five elements in the right list, j can go from zero to five zero to four all five positions.
Then we check and we remember we want to make sure that both of these indices are valid.
If any of those have reached the end then you want to skip and we can simply copy over the remaining list.
Right. So as you see here as soon as we reach the point there's no more comparisons to be made.
So we can exit the loop.
So now we check which one is smaller.
So if we if numbers one I so the left list current element is smaller than numbers two j.
Then we append to the merged list numbers one I as we did here and we increment I so this is exactly what we've written done here.
So we put in.
Well let's say here. So we put in one here and we increment the left pointer.
On the other hand if that's not true.
We append the element from the right so norms two j and we increment the right pointer.
So in each case in each while loop we are incrementing one of the pointers.
And then when the while loop ends one of the lists would have been exhausted.
That's when the while loop ends. So we can get the remaining parts of both the lists.
So we can get numbers one I colon will get the remaining elements on the first list.
The left list numbers two j colon will get the remaining elements on the right list.
But remember since one of them is exhausted. So one of these two is going to be empty.
Right. Now we we can check which one is empty and simply add the remaining one.
But here's a simpler solution. We just add both of them to the mercenary.
So we append both the lists at the end and this automatically takes care of the empty case.
If the left side becomes empty then this adds nothing to the mercenary and this adds the remaining numbers from the right side.
If the right side becomes empty then this adds the remaining numbers from the left side and this adds nothing.
So that's a small trick.
So that's the merge operation. Again, not very difficult.
If you have any questions, take this out into specific cells and try it out with examples and you should see it working.
So let's try out the merge operation now. So here we have two sorted lists you can see here.
And there you go. You can see that this is now arranged all these numbers are now arranged in a sorted order.
So now we have the merge operation and we have the merge sort operations. So we can now test out the merge sort function.
So we get the first set of inputs and outputs from test zero.
And you can see here that this is the input and this is the expected output and this was the actual output as well.
Now let's test all the cases using the evaluate test cases function from Joven.
So here we're simply going to call evaluate test cases on the entire list of test cases.
And you can see all the test cases seem to be passing.
Now if one of these test cases had failed what you should do is you should go back and add some print statements inside your merge function or add some print statements inside your merge sort function.
The right places to add the print statements is right after the function definition right after in the body of the function it can be the first statement and then inside each loop.
So inside each loop whatever are the changing parameters you should print them inside the loop.
And then finally you can also print the return value of the function.
And this way you can build a full picture of what your function is doing and that makes it much easier to solve issues.
So test cases and print functions make it easy to fix errors in code and don't worry if there are there are always errors in code.
What's important is you should be able to find a way to fix them easily and without test cases or without printing you may get stuck and you may just keep staring at the code and trying to figure out what exactly went wrong.
So please do that.
Now one last thing I wanted to notice is here the execution took only about 50 milliseconds.
On the other hand remember bubble sort took about 15 seconds to sort 10,000 numbers.
So that's merge sort is much much faster right a millisecond is 0.001 10 to the power minus 3 seconds.
So in a second you can probably sort 200 of 200 list of size 10,000.
And that's what makes merge sort so much more powerful and because it is so much more efficient and as we analyze the complexity you will learn that merge sort is in fact more efficient in terms of the big on rotation as well.
So let's analyze the algorithms complexity and identify if there are any inefficiencies.
Now analyzing recursive algorithms can get tricky and that's where it helps to track and follow the chain of recursive calls.
So what we will do is we will add some print statements to our merge sort function and our merge function.
So we'll simply see what the merge sort function was involved with.
Okay, so we'll add a print statement inside merge and we'll add a print statement inside merge sort both of them and we're also tracking something called a depth to track the chain or the depth of each recursive call.
And you'll see what I mean in just a second.
Okay, so this is what it looks like.
We called merge sort on this big list of elements unsorted and that merge sort internally led to two calls of merge sort.
Let's see this one here and this one here. So you have two calls to merge sort.
One with the left half of the list and one with the right half of the list and they're unequal.
And these two merge sorts finally returned merge lists and we finally called a merge operation on the two of them.
You can see that this is the merge operation, the final merge operation called here on the two merge sort lists.
And this merge operation is working with these two sorted lists, okay.
So we can see that each merge sort invokes itself in works merge sort twice, but this time with an area of half the size.
You can see merge sort was invoked with arrays of or lists of half the size.
And it also invokes the merge function once to merge the two resulting arrays, the two sorted arrays.
Now the two calls to merge sort if you observe closely, they themselves make two more calls to merge sort.
And one more call to merge.
And then those internal calls make two more calls to merge sort and one more call to merge and so on.
Till we end up with single elements, at which point merge sort simply returns that single element.
So the merge sort algorithm ultimately points it out to a series of merge operations.
You can see here that each merge sort all its doing is calling merge sort internally and then calling a merge operation.
So ultimately what we are doing is we are first merging five and minus 12.
And then we are merging two and six.
And then we are merging minus 12 five and two comma six and then we're merging 123 and we're merging
7 minus 12 and then we're merging 7 minus 12 seven and finally we're merging 123 minus 12 77 and then finally we're merging the big list.
list, right? So it's ultimately just a whole bunch of merge operations and if you look
inside the merge operation, this is where a comparison is happening and this is where this
append step is happening. So we are comparing and upending. So those are the two key operations
here and with every comparison there is a append. So if you simply count the comparisons once again
that's happening that should be enough to get the time complexity. And what is the number of
comparisons that's happening? Well, that's straightforward too. If you have two lists,
numbers one and numbers two, each and the total length of the two lists is n. So because the
size, the number of iterations is equal to in the worst case it would be equal to the lengths
of the two lists combined. So you may have to first maybe increment i by one that increment
j by one then once again increment i by one and j by one. So the total number of iterations here
is ln of nms 1 plus ln of nms 2, right? But remember the merge was called if merge sort was
called with a list of size n then merge was called with a list of size n by 2 and n by 2 roughly.
So the total list, the total length of nms 1 plus nms 2 is actually the overall length n. So that's
the real trick here that merge, the merge operation is an order n operation where n is the number
of elements, the total number of elements. So this merge operation takes 4 plus 5 9 comparisons
and this merge operation takes 5 comparisons and this merge operation takes 3 comparisons and so on.
Now this way now we visualize a problem now as a tree where we're calling merge sort with n l with
n elements and that ends up calling merge sort with n by 2 elements and that ends up calling
merge sort with n by 4 elements all the way down and then we start merging. So here when we get
to individual elements we are calling merge with literally single elements and as we come up here
we are calling merge at this point we are calling merge with elements of size n by 8 and n by 8
but we are calling merge 8 times. So now each of these sub problems makes a call to merge and each
of these sub problems has the list size n by 8. So you have 8 calls to merge of size n by 8.
So the total number of comparisons done is n and at every stage you can check this at the top
level you are calling merge with n total elements. So the total number of comparisons is n
at the second level you're calling merge here once with n by 2 elements and you're calling merge
here once with n by 2 elements. So the total number of comparisons is 2 times n by 2 that's n
and here you're calling merge with n by 4 elements 4 times. So that's n.
So if the height of the tree is h then the total number of comparisons is n times h. So
on each level you'd require n comparisons for the merge and you call merges at every level
for each of these sub problems. So the height of the tree is so the total number of comparisons
is n times h. Now how do we get the height of the tree? If the height of the tree is h and you can
see here that as we go down it this is level 0 and it has 1 element this is level 1 and it has 2
elements this is level 2 and it has 4 sub problems and this is level 3 and it has 8 sub problems.
So level k has 2 to the power k sub problems. So if you keep going down this is level h minus 1.
So level h minus 1 should have 2 to the h minus 1 sub problems. But remember at the last level
we simply have sub problems or merge merge calls with single elements. So that means we have a total
of n elements here or n leaf nodes here. So it follows that 2 to the power of h minus 1 is n.
So I'll let you think about that in reason with that. This is something that you may have to
work out on pen and paper to get correctly that if the height of the tree is h then 2 to the
power h minus 1 is equal to n because that the bottom most layer you have n leaves in the tree.
So it follows that h is log n plus 1. So since we said that there are n times h comparisons
and h is log n plus 1. So it follows that the complexity of merge sort is n log n.
And that's a big improvement from n square. It may not seem like much but it is. So n square for
10,000 is 10,000 times 10,000 but n log n for 10,000 is 10,000 times 12 or 13 log to the base 2.
So that's about a few hundred times faster. Now even for an area of a million elements it will
only take a few seconds to be sorted and you can verify this by actually creating a list of
a million elements. So the complexity of merge sort is n log n and you get it by drawing
this subproblem tree and realizing that there are you get a subproblem tree of height log n
or log n plus 1 and at each step you perform a merge operation on multiple merge operations
totaling to n comparisons. So n times log n is the complexity of merge sort.
Now here's also a discussion about space complexity and this is something that I will leave
as an exercise for you. So do read through this and see if you can reason why the space complexity
of merge sort is order n. So time complexity is order n log n and the space complexity is order n.
But here's a hint why it's order n. You can see that inside the merge operation we are
creating a new list and then we are copying over elements from each of the two lists into the
new lists. So we are allocating a new list inside merge. And now it's so now that's no longer
constant that list will have the same size as the size of the problem itself and hence roughly
that's why the space complexity is order n. Okay. So with that we conclude our discussion of
merge sort it's a divide in conquer algorithm you split the list into half recursively
sort both of them then merge the two sorted lists and the initial condition is 1 or 0 elements.
Now there are several extensions and variations of merge sort called the KV merge sort where we split
not into two parts but into K parts when we have the counting inversions problem where
we modify merge sort a little bit to also find some other information about the list and finally
we have hybrid algorithms which combine merge sort and insertion sort. So what they do is for smaller
list they use insertion sort because that's more efficient and then for bigger list they use merge
sort. So as just splitting the list when you get to a small enough problem let's say 10 or less
elements they use insertion sort and that brings us to our next question where we make one level
of optimization and then we stop but here we will go one step further what we do is we will apply
another technique to overcome the inefficiency in merge sort. Now the time complexity is pretty good
you can actually sort millions or even tens of millions of elements with merge sort quite reliably
but it's a space complexity that causes a problem. Now because merge sort requires allocating
additional space and that additional space is as large as the input itself that makes it somewhat
slow in practice because memory allocation is more expensive than computations. So doing a
comparison is very easy you just tell the CPU to compare two things in the memory or stopping them
is also easy because you're still working with memory that you already have but when you have to
allocate new memory you often have to then request the operating system to allocate the new
memory and you have to get its address and do a whole bunch of operations so it's let's say an
order of magnitude more expensive than simply doing some computations so you should try and avoid
memory allocations as far as possible. Now one or two variables is fine but if you're dealing with
a million elements so you're probably going to need maybe a few MB of additional space and that is
what may slow down your algorithm a little bit. It would still be analog and but the constant
factor now the cost of each operation will be higher because it involves an allocation.
Now to overcome the inefficiencies the space and efficiency of merge sort we will study
another device and conquer based algorithm sorting algorithm and this is called quick sort and quick
short sorts the array in place which means it does not create a copy of the array internally
for sorting inside each operation inside each combination operation. So let's see how it works
it's a pretty interesting pretty smart trick. So here's how it works if the list is empty or has
just one element return it it's already sorted straight forward then pick a random element if not
pick a random element from the list. Now this element is called a pivot now there are many
strategies for picking a pivot one is to pick a random element one is to maybe pick the first
element the last element what we will do is we will pick the last element but you can easily
augment our implementation to pick a random element and then reorder the list and this is the
key operation here reorder the list so that all the elements with values less than or equal to the
pivot come before the pivot element while all the elements with values greater than the pivot
come after the pivot element and this element is called partitioning your partitioning the array
around the pivot. So here's an example you let's say we take three as the pivot element the final
element now what we want to do is we want to reorder the elements and the way we reorder is by doing
swapping and comparison in whatever way we can and that's what we will really focus on the partitioning
algorithm and you reorder it in such a way that all the numbers to the left of the pivot are
smaller than it and all the numbers to the right of the pivot are larger than it. Now here's the key
observation here once you do that you can tell that all these all these numbers can now be sorted
independently and none of the numbers from here will move to the right of pivot and similarly all these
numbers can also be sorted independently and none of the numbers here will move to the left of the pivot.
So the pivot is in the correct position in the final sorted array so it's now in its correct
final position and you can simply call quicksawton this half or less than half this portion of the array
and this portion of the array and there's no real combination required anymore right so because we're
doing it all in place we simply call quicksawton each side of the array and once this gets sorted
and this gets sorted recursively then you will have end up with the entire sorted list right and that's
that's how we then continue doing the process recursively now on the left half you once again pick
a pivot and then you arrange the elements around the pivot on the right half you once again pick a pivot
and arrange the elements around the pivot and so on and so on okay so as I said the key observation
here is that after the partition the pivot element is at its right place on the sorted array
the two parts of the array can be sorted independently in place now maybe once again take pen and
paper and try to work it out yourself again all of this makes a lot more sense when you
actually put it down and solve a real problem as a real example so here's an implementation of quicksawt
and once again we will assume that we already have a helper function called partition
which can pick a pivot partition the array and return the position of the pivot element
for the next quicksawt step okay so this entire process going from here to here this is where we
assume that we have a function and write the quicksawt algorithm and then implement the partition
function so here's what quicksawt might look like now quicksawt takes a bunch of numbers
and apart from the numbers it also takes a start index and end index now why we doing this
remember we want to avoid creating copies of the list that's a whole that's the whole thinking here
the line of thinking so we will call quicksawt not with a sublist which is which is a copy of
a portion but we will call quicksawt simply by changing the by passing the same original list
but by changing the start and end index okay now there is some code here if end is none
then we are setting end to the length of the list minus 1 and here's one more thing that we're doing
so the final invocation to quicksawt that we'll make will be something like this we may call quicksawt
let's say there are a few numbers here so we may call quicksawt
on a list something like this and in this case automatically start will have the values
you don't end will have the value none now remember the quicksawt is going to sort the array
in place but we also said that we don't want to modify our test cases so here's one assumption
we are making that if end is none which means if quicksawt is called just with the list then we'll
create a copy of the list right so we'll just create one copy at the very beginning right when the
list is passed for the first time and then we'll not create anymore copies and you can even
skip this line entirely but the only trouble is that we'll start changing our test case input so
that's why let's keep it and let's keep a copy but this is only done at the very top level right
so only when we start we create a copy so that we are not modifying the input list but never again
so that's what we're doing here creating a copy if quicksawt was called with a list and setting end
to landmarks-1 which is the final valid index in the list anyway putting this aside this is the
real condition here so if start is less than end which means let's say here you have start and
here you have end now if start is less than end that means you have two or more elements right
if start and end are equal that means you have just one element and if start is greater than end
that means you have zero elements really so if start is less than end that means if you have
at least two elements then we call the partition function we call the partition function on Nm
and we say that we want to partition the region start to end so let's say this is the region
start to end, we want to partition it so we want to pick a pivot and then partition it in such a
way that elements to the left of the pivot are smaller than it and elements to the right
of the pivot are larger than it for example if you want four to be the partition element four
to be the pivot element, then we will partition the area as 3, 4, 5, 23, so that
3 is smaller than 4 and 5, 23 are bigger than 4 and we will return the position
of the pivot element, okay.
So now you partition the area and return the position of the pivot element, so this is
the position we get back and then we can call Quixot on this region and on this region.
So we can now call Quixot on start to pivot minus 1 and we can call Quixot on pivot
plus 1 to end, okay.
So now we are passing actually explicitly passing in values for start and end, so this
will not kick in the next time, so no more copies of the list will be created, so all
the recursive calls will keep modifying in place, so all the, even the partition call will
modify in place and we will see how partition works in just a moment, so partition gets
the slice of the original list and it returns the position of the pivot element, then
we call Quixot on the left slice, which is before the partition, the elements smaller
than the partition and then we call Quixot on the right slice, which is elements that
come after the partition, okay.
Now here is how the partition operation works, it is pretty straightforward to not
that difficult, so what we do is we will pick the final element as the pivot element,
but if you do not want to pick the final element, you want to pick a randomized element
well, just pick a random position and move that element to the final position and that
is as good as picking the final element now, so random pivot simply involves picking an element
moving it to the final position, but assuming the pivot is in the final position, we
then keep two pointers left and right, now remember we want to create, we want to push
all the numbers smaller than the pivot to the left and we want to push all the numbers
larger than the pivot to the right, okay and what we will do ultimately is we will arrange
them in such a way that some of these are smaller than the pivot and some of these are
larger than the pivot and then we will move the pivot between them, so we will see how
to do that, so you have the left pointer and the right pointer, now here is what we
do inside partition, while these two pointers are far away from each other, first we check
if the element at the left pointer is smaller than the pivot, well if the element at the
left pointer is smaller than the pivot which it is, you simply advance the left pointer
forward, so this goes to 5 and then we go back to the next loop, now this time once again
we check if the element that the left pointer points to is smaller than the pivot, 5 is
not smaller than 3, 5 is greater than 3, so if that is the case then we check if the right
pointer is greater than the pivot, now if the right pointer is greater than the pivot that
means this number is in its right position, it is greater than the pivot, so we move the
right pointer back one space, okay, so that is this operation we just did, now once again
we check is the left pointer smaller than the pivot, no it is not, is the right pointer
greater than the pivot, no it is not, so that means these two numbers are out of place,
right we ideally would want this to be smaller than the pivot and this to be larger than
the pivot, so we swap these two elements, so now 0 comes here and 5 comes here, now once
again we can check is 0, the left pointer smaller than the pivot, yes, so move the left
pointer forward, then we is the left pointer smaller than the pivot, no 6 is now greater
than 3, so we check is the right pointer larger than the pivot, yes, so we move the right
pointer forward because 5 is still in its correct position, it is you know on the right
edge and everything is greater than 3, so now once again we end up in this position that
the left element is smaller than the is larger than the pivot, so we check the right
element, the right element is smaller than the pivot, we want it to be larger, so we
swap these two because these two are once again out of order and now you can see that
102 are all smaller than the pivot and 6 5 11 are all larger than the pivot, so we
do one final check is 2 smaller than the pivot, yes, so we advance a left pointer and now
both of the pointers are at the same position, so now we can tell at this point that
here from this point position onwards all of these numbers are larger than the pivot,
so we simply swap this element with the pivot, so there you go, so you end up with 102,
3, 5, 11 and 6, okay, so that is the partition operation, so again to understand it yourself,
do it on pen and paper, right out this area, create the pivot, create the left pointer,
right pointer and keep creating copies of the array for each step of the loop, okay, and that's
how you understand these things, it's not that difficult, it's just it involves two pointers,
so it's a little tricky, now this is the code for partition and I will let you follow this code,
we'll go over this briefly but by this point, since we're halfway into the course now,
you should be able to read the code and then there are also comments here and understand
what we have just discussed in plain English, understand that in terms of code, okay, so one
exercise for you is to explain this visual approach in plain English, step by step and then the
second exercise for you is to read the code and understand it, or maybe even try to write it from
memory, so just take the English description and try to write the partition function from your
memory, not memorize the code itself, but convert the English text into code, okay, so once again
here, you know, we have the numbers that need to be partitioned, the start and the end,
and if end is not, we simply set end to the last end x, which is land numbers minus 1,
then we initialize the start and end pointers, so we initialize the left and right pointers,
remember we want to use the end element, so this is the end element, so we want to use the end
element as the pivot, so the left pointer is start and the right pointer is n minus 1,
that is what we have set here, and then why, while the right pointer is greater than the left pointer,
we increment the left pointer if the number at the left pointer is less or equal to the pivot,
we decrement otherwise we decrement the right pointer if the number on the right pointer is greater
than the pivot, otherwise the two of them are out of place and they can be swapped, so we swap
them here and finally we place the pivot in place between the two parts and that's it,
that's exactly what's happening here, so let's see here, let's see this partition,
we are taking this list and we are calling partition on it, and three is the number that was
used as the pivot, so now three ends up here in between, so you have 102 and 5116,
and the partition function returns the position of the pivot, so now you can see how it is used
in QuickSort, the partition function returns the position of the pivot and then we call QuickSort
on the left partition, before the pivot and on the right partition after the pivot,
so now we can test out QuickSort, okay, and here's another exercise for you,
add print statements inside the partition function, so there are already some print statements,
you can simply uncomment them, uncomment the print statements to display the list, the left pointer
and the right pointer at the beginning at end of every loop, to study how partitioning works,
and similarly you can also add print statements inside the QuickSort function, to study how the
recursive calls are going on, so study what we've done in margin merge sort and add the same
print statements in Quick and QuickSort and look at these recursive calls, now what you want is
to have a completely clear and perfect idea of what your code is doing, you don't want to be
lost about it and that's why adding print statements and looking at small examples and making sure
that's working perfectly really helps, so let's look at QuickSort and Action, so here's an input
and here's the expected output and here's the actual output and they match great and we can now
evaluate all the test cases using the evaluate test cases functions for function from Joven,
so we import from Joven.python.dec, evaluate test cases, and call evaluate test cases here,
and you can see that it passes all the test cases and not only that you will also notice that it
is marginally faster than merge sort for sorted lists, sometimes you may not see that, but yeah,
you can see here that it's, you will see that in most cases QuickSort is marginally faster than
merge sort for larger lists and that's because it is not allocating new space, okay, so now coming
to the time complexity for QuickSort, assuming that we are able to have a good partition each time,
so each time we are dividing the list into roughly equal halves, the equal parts, like you start
with a list of size n and you partition it into n by 2 and n by 2, so this is what the
sub problem tree looks like, so you call QuickSort with 2 lists of n by 2 and n by 2, then you call
QuickSort with 4 list of size n by 4 and n by 4 and so on, now what is the activity that we're doing
inside QuickSort? In each QuickSort the core operation is partition, right, and that's what puts
one element, the pivot element into its right place, and then the elements smaller than it to the
left of it, the element's larger than it to the right of it, so the partition is where
the actual work, the comparison and the swapping happens and how many comparisons do we perform in the
partition? I would say that the number of comparisons is equal to the size of the actual list,
and you can see that here, you can see that we are going on comparing numbers like this, we're
comparing each number to the pivot, so each number gets compared to the pivot exactly once roughly
and that means that there are a total of n comparisons if n is the size of the list, okay.
So we have n comparison in partition, so partition performs n operations or partition is an order
n function, and what is the height of the tree? Once again, the height of the tree is log n because
to go from n to 1, it takes log n steps, you keep going n by 2 and my 4 and my 8, and so on
n by 2 to the power log n becomes n by n 1, and so the time complexity of quick sort is n login,
if you're able to partition the array into roughly equal parts, and that is what happens on average
if you're picking random pivot each time, then you do end up with roughly equal parts,
maybe 75, 75, 75, 75, but that's still more or less in the same range, so the quick sort
complexity is about n login, and this is called the average case complexity. On the other hand,
if you have a really bad partition and a really bad partition is maybe you pick the smallest
element as the pivot, now if you pick the smallest element as the pivot, then all the elements will
go to the right of the pivot, and you will end up calling quick sort on a problem of size n minus 1,
and then maybe once again if you pick this smallest element as pivot, all the elements will go to
the right of the pivot once again, and you will end up calling quick sort with a problem size of n minus 2.
Now this is an unbalanced T or a skewed tree, and what happens in a skewed tree is that the height
this time is the same as n, you can see n n minus 1 n minus 2 n minus 3, so going up to 1,
the height of the tree is n, but the amount of work involved in partitioning is the same because
you have to run through the entire list to partition the list, right? So, in this case the time
complexity is roughly n times n minus 1 by 2, so the time complexity is about order n square,
and that's bad because that's as bad as bubble sort, but despite the quadratic worst case time complexity,
quick sort is still preferred in many situations, now it really depends on what kind of algorithm
you need to use and what kind of memory constraints you have, because quick sorts complexity is
closer to n log in in practice, especially with a good strategy for picking up pivot, and a good
strategy is picking the random pivot, but there's another one called picking medium of
mediums, you can check that out as well, so that's n log in is the average time complexity of
quick sort, and then n square is the worst case time complexity of quick sort. Now here's an exercise
for you, verify that quick sort requires order 1 additional space, which means that it does not
really need to copy the array, we did create a copy, because we did not want to affect our
test cases, but we could have removed that line and quick sort would work just fine, so because
you do not need to create a copy of the list of the array, it requires order 1 additional space,
but because space complexity also includes often the size of the space required to store the
input, so you can say that quick sort has the space complexity of order n, okay, so if you get the
question about space complexity, you may want to ask, are you talking about the additional space
or do you also want to include the input in the space complexity? So that's quick sort,
and those are the two sorting algorithms we've looked at, so we've looked at bubble sort,
and we've looked at insertion sort, and then we optimized it using the dividing conquer and
got to merge sort, which is order n log in, but it also has a space complexity of the additional
space requirement of order n, which can be avoided using quick sort, which uses order 1 additional
space, but can have order n square complexity in the worst case time complexity, but with the
right choice of a pivot it is closer to n log in, so that's sorting, and you can see that Python
is such an expressive language that all these sorting algorithms which are often quite confusing
to implement in C++ or Java are actually pretty straightforward to implement in Python. All you
need to do is follow the method, which is to state it first in plain English, have some test cases
ready to test your function, and then write your code carefully, checking each line for errors,
and create small functions wherever you need to, so try not to have too much logic in one function,
a good rule of thumb is about 7 to 8 lines of code per function, no bigger than that,
and that's not just for toy problems, but that's also even as a software developer,
something that you can try to follow, just have 7-8 lines of code in any function,
if you have more than that, try to split it into two functions, and this way it's very difficult
for you to go wrong. So now let's return to our original problem statement, and let's
read it once again, you're working on a new feature on Jovian called top notebook of the week,
top top notebooks of the week, and write a function to sort a list of notebooks in decreasing
order of likes. Now keep in mind that up to millions of notebooks can be created every week,
you want to build this first scale, so your function needs to be as efficient as possible.
So first we need to sort objects this time and not just numbers, and second we also want to sort
them in the decreasing order of likes for each notebook, okay? So all we need to do is to use our
merge sort or quick sort techniques that we've already discussed, is to define a custom
comparison function to compare two notebooks, okay? But before we do that we let's create a
class that can capture some basic information about notebooks, so here we have the class.
So we're still following the methods or to speak, right? The step one was to come up with
the input and the output format, so here is the input format, our input format would be using this
class, so we create creating a class notebook, which is title, username and likes.
So we create the class and that gets stored as property, titles, username and likes, and then we
also have a string representation here, then create some test cases, so now we are creating
some test cases here, so we are creating some test cases in NB0 to NB9, and let's put them all
into a list, and you can see here that we now have a list of notebooks, NB0 to NB9, and you can see
that because we have a string representation, we can see that the first notebook is this
a caution S slash pi torch basics, and it has 373 likes, and the second one is this, and it has
532 likes, and these are clearly out of order in terms of likes. Next we will define a custom
comparison function for comparing the two notebooks. What it will do is it will return the strings
lesser equal or greater to establish the order between the two objects, so it should return
lesser when NB1 should come at a position or index lesser than the position of NB2 in a sorted list.
So in in case of our problem, what that means is we want to sort things in the decreasing order
so the first notebook should have the highest number of likes, and then maybe the second notebook
should have the second highest number of likes, and the third notebook will have a lower number of
likes and so on. So if you have two notebooks, NB1 and NB2, and if NB1 dot likes is greater than
NB2 dot likes, so then NB1 should come at a lesser index, okay, so we will return lesser because
it should come at a lower position in the sorted list, so we return lesser, because we want to
decreasing order, and if NB1 dot likes is equal to NB2 dot likes, then we return equal, and if NB1
dot likes is less than NB2 dot likes, so that means this is not, NB2 is the more like notebook,
NB1 is the less like notebook, then NB1 should actually come at a greater position, so we will
return greater, okay, so this comparison function should return whether the first input to it,
should come up, should show up at a lesser position in the sorted list, compared to the second
input. Now in languages like C++ in Java, normally the convention is to return a negative number
zero or positive number, but I find that Python allows you to return strings, strings are first
class, it is in Python, and it is a lot clearer when you are debugging things, when you face
issues to look at actual strings, and it is also easier to write code, so I prefer using strings,
but you can also use, you can also use numbers like negative zero or positive, that is totally
up to you, so now here is an implementation of merge sort which accepts a custom comparison function,
so let us see the merge sort function, so the merge sort function uses, it takes a list of objects
this time, not a list of numbers, and it also takes a compare function, which by default,
we also provide a default comparison, so that we can still use it with numbers. Now with numbers
and default assumption is if you want sorting, you want sorting in increasing orders, so this is
what the default sorting looks like for numbers, so that is pretty straightforward, but you can
also pass a custom comparison function, so here we have the dominating condition, if the length
is less than 2, then we simply return the list, then we get the mid index, and then we call
merge sort on the left half with the custom comparison function, we call merge sort on the right
half with the custom comparison function, and we call merge with the custom comparison function.
Now what happens is that merge inside merge, earlier once again we have these two halves left
and right, and then we have the custom comparison function, so we create pointers for the two of
them, and then we also create the final result list, which is merged, and then we iterate over
the left list and the right list, so while we are going through these, we compare the left
element and the right element, so now we are calling compare, now we are not doing the
greater than less than comparison, we are calling compare, and if the result, if the element on the
left is lesser or equal to the element on the right, then we append it to the result array and we
increment the left counter, otherwise, so lesser or equal means that the element on the left,
the first element on the left should show up at a lower position in the sorted final sorted list,
so that is why we append it first, otherwise we append the right child, the right element,
and we increment the right pointer, and finally we attach any remaining elements here, so this is
something that you can review, something we covered in a lot of detail, so now let's see,
let's call Mozart on our notebooks, and let's check if the notebooks are sorted by
likes and indeed they are, you can see that at position 0 you have the notebook with the highest
number of likes, and then you have the next one and the next one and so on, now since we have written
a generic merge sort function, that works with any compare function, we can now very quickly use it
to sort the notebooks by title as well, or if we had maybe the number of views per notebook or the
number of versions in each notebook or the number of comments on each notebook, we
could do that sorting as well, so we could even use a hybrid of those, so here the example
we're taking is comparing by titles, so here we have NB1 and NB2, and simple comparison strings
can also be compared using the comparison operators, so if NB1 or title is less than NB2 or
title then we return lesser, otherwise we return equal or greater, and with this we should be
able to sort them in the ascending order of titles, you can see AN, CI, CI, F E, L I, L O, P Y, P Y,
P Y T, H, P Y T, H, P Y T, O, P I, Torch, okay, so this is now order sorted in the order of titles
and exercise for you is to sort in the order of username slash title, which means you first
compare the username and if the usenames are equal then compare the titles, so you can compare
you can probably write another comparison function, compare usenames and titles and use that
to do that two level comparison and use that for sorting, okay. Now another exercise for you
going forward is to implement and test the generic versions of bubble sort, insertion sort
and quick sort using these empty cells that are given here, right. So now at this point in the
course you should start writing code, you should be writing maybe solving one problem every day
to really practice the concepts and internalize them and while you're doing that you can also
any problem that you work on, any notebook that you create, you can save it to joven.com it
and I'll show you also how to create new notebooks. So one way to create new notebooks is to go to
joven.ie, click the new button and click blank notebook and you can give it a title let's say
you are doing quick sort generic and you can set up privacy and create a notebook and that creates
a notebook for you and then you can click the run button and run it so that's one way to do it
and another way you can do it is we've given you a problem solving templates so if you come back
to the lesson page you will find a problem solving template here. Now you can click on the problem
solving template and click duplicate to create a copy of this notebook in your profile. So let's do that
and now this is on your profile so you can now click run and then run it on binder or you can
even run it locally on your computer and make some changes to it and come back and run joven.com it
and you will end up with a link that you can share so now you can now go on Twitter and you can
just share this link so write out a tweet and tag us and also use the hashtag 60 Days of Python
okay and maybe say this is your quick sort algorithm for generic objects
and tweet it out and we will retweet your tweet so we want to support everybody
who's taking part in this course on the course page you will find a link to the course community forum
which is where you can go and ask questions where if you have questions about any of these
and you can even discuss some of the ideas that are discussed here some of the exercises that are
shared so you can go into lesson three for instance and create a new topic maybe you want to talk
about the generic implementation of quick sort so maybe you can create a new topic and post a
if you're not able to make it work post your notebook there and ask a question have a discussion
and if you are helping other people out if you're answering other people's questions
and you've written some really great posts there are links to some more problems that have
been shared here so you can check out these links on each of these links you can try out
problems you can make submissions you can solve these problems some of these are interview
questions as well you can check if your results are correct and you can use this solving problem
solving template as a starting point as we've just shared so there is a start and notebook with
each assignment and in the assignment all you need to do is run the notebook so you can run it
on binder for instance and then there is a question mark in a bunch of places you will find like
question marks here in the text and you'll find question marks here in the code so you simply
need to put in your code your answers into the question marks so replace that with your code you can
see here there are some question marks here so you replace that and step by step there are
instructions to guide you there is there are comments to guide you so step by step you can solve it
and then finally you can also make a submission so write it the very end when you run the code
you will also be able to submit directly and when you make a submission then the assignment will get
automated will get evaluated in an automated fashion instantly and you will get a pass or a
feel grade now if you get a pass grade that's great but if you get a feel grade then you will also
get some comments about what went wrong in your solution so you can use those comments to fix
the issues so it's a great way to get quick feedback and keep fixing your issues and especially
watch out for edge cases so that's assignment one and then assignment two is got hash tables in
python dictionaries a very interesting assignment where you are going to implement hash tables
which power python dictionaries from scratch in python and you will also replicate the
interface of python dictionaries so do check it out a very interesting assignment again very similar format
you will find question marks in certain places you need to replace them with appropriate
values expressions or statements and in this way by working through each of these step by step
you can see here by working through each of these you will implement hash functions and hash
tables which again are very commonly asked in interviews as well so this is an important assignment
for from an interview preparation or coding assessment preparation as well and it also teaches
you a lot of really good practices in python programming in particular so do check out assignment
to as well and we will send you an email as soon as assignment three is ready but you can check back
in a couple of days and you should see it on the same page pythondsa.com so what do you do next
review the lecture video and execute the Jupiter notebook use the interactive nature of Jupiter to
experiment with the code complete the assignment and attempt the optional questions as well so each
assignment has some required questions and you can make a submission as soon as you're done with
the required questions but there are some optional questions which are a slightly harder but I highly
recommend doing that because they will improve your understanding give you more practice help you
internalize the concepts better and then participate in forum discussions and join or start a
so this is a great way to learn get together with some friends maybe watch the lecture together
over a zoom call pause the video have discussions wherever you have doubts discussion is a great way
to solve the specific doubts that you may have and it will also help you to articulate your
understanding better because when you explain to others you also answer a lot of your own questions
so please do that this is data structures and algorithms in python thank you and good day
good night hello and welcome to data structures and algorithms in python this is a live online
certification course being organized by Jovian today we are on lesson 4 recursion memorization and
dynamic programming my name is akash and i'm your instructor you can find me on twitter on at akash
and as if you follow along with this course and complete the weekly assignments you can also earn
a certificate of accomplishment which you can add to your LinkedIn profile and you will find hosted
on your Jovian profile as well so let's get started now to the data structures and
algorithms course this is python dsa dot com is the course website and on the course website you
will be able to find all the information about the course so you can view the previous lessons lessons
one two and three and you can also view the previous assignments assignments one and two
today we are on lesson 4 so let's open up lesson 4 the topic is recursion and dynamic programming
you can find a recording of the lesson here and you can also watch a version in Hindi if you
would prefer that in this lecture we will cover a recursion memorization and dynamic programming
by looking at two common problems in dynamic programming the longest common subsequent problem
and then nap sack problem and we'll do this by coding these problems live using the problem
solving template that we have been using one in one way or another since lesson one so let's open
up the problem solving template this is a template that you can use to solve any coding problem
and we will illustrate this by solving two problems using this template today so the first thing
we need to do is to run this template you can see that there is some explanation and then there
is some code here as well how to run this code you have two options you can run it using
free online resources or you can run it on your computer the simplest way to run it is click the
run button here and select run on binder and we just one click this will set up a machine on the
cloud for you start a Jupiter notebook server and you will be able to then
execute the code and modify the notebook and save a version of it to your own profile so that you can
continue working on it so there we have it now we have a running Jupiter hub server
I'm just going to zoom in here a bit so that you can see things clearly
okay so this is the problem solving template and I said we're working on two problems
so I have some problem statements listed out here you can see the first problem longest
common sub sequence is listed here and this is a part of the lesson notebook lesson pages
as well so you will find link to this problem statement on the lesson page two so let's first
modify the title of this notebook problem solving template let's change this title to
dynamic programming longest common sub sequence let's get rid of this I don't think we need this
then I'm going to keep this section on how to run your code so that if I share this notebook
with somebody else they have a way to run it and then before we start the assignment or the
problem let's just save this to our own profile so I'm just going to give it a name
longest common sub sequence this is an appropriate name for it so I'm going to give it this
a project name install the jovian python library and just run jovian.com it now what this will do
is we started out with a template and now we're editing the template by running jovian.com
it we've saved a copy of the template to our own profile you can see this is the link where you will
be able to access this notebook and you can run it and continue your work if this Jupyter notebook
shuts down if you want to continue tomorrow for instance okay so now let's look at the problem statement
now I'll just copy over the problem statement here as well so that we can see it directly within the
notebook there we have it now you can paste the problem statement and if you are getting this
problem statement from some other source then it's always a good idea to include the link to
the original source as well okay now we have a problem statement in front of us so the question is
write a function to find the length of the longest common sub sequence so that's a new term
we'll unpack that between two sequences now let's first learn what we mean by a sequence now a
sequence is a group of items with a deterministic ordering for instance a list a tuple
range or even a string these are some common sequence types in Python so here I have this
string set in dipitis this is a group of items and this also contains an order you can see that
e comes after s and r comes after e and so on so this is a sequence a list would also be a
sequence so that would be a list of numbers that's a sequence then we're looking at
sub sequence what is the sub sequence now a sub sequence is a sequence that is obtained
by deleting or removing 0 or more elements from another sequence for instance if you look at
serendipitous and if we remove the characters s r e n i i o u s then you will be left with e d
so e d p t is a sub sequence of serendipitous now two things to note here e d p t does not
have to occur continuously so these elements can occur anywhere within sequence but the order should
be the same so e d p t occur in this particular order here and e d p t should occur in the same order
here so d should occur after e and p should occur after d and t should occur after p so those are the
two requirements for e d p t to be a sub sequence of serendipitous and visually speaking what we can
see is if you take a sub sequence and then you draw boxes around some of these characters or
some of these elements of the sequence and if you just take the elements in the boxes then
in the same order then you end up with a sub sequence so now we understand what a sequence is
and what a sub sequence is and once again if this is this question is asked in an interview
and you're not sure what you mean by longest common sub sequence and even what a sequence is then
you should ask the interviewer what do you mean by a sub sequence or what do you mean by sequence
and they'll be more than happy to tell you it's very important to communicate
whatever you're thinking whatever questions you have contrary to what your might think asking
questions is actually a good thing the more questions you ask the more it is appreciated
okay so now we've talked about a sequence as and a sub sequence now what's common sub sequence
so let look at these two strings serendipitous and precipitation now if you pick just these
elements that are in the boxes R E I P I T O now you can see that R E I P I T O is a sub sequence
of serendipitous and R E I P I T O is also a sub sequence of precipitation
so a sub sequence which is common which is a sub sequence of both sequences is called a common
sub sequence so R E I P I T O is a common sub sequence between serendipitous and precipitation
now you can have many common sub sequences for instance we could just look at R E and R E here
and R E would be a common sub sequence too or you could just look at
I T and I T and that would be a common sub sequence as well or we've not picked N here but
you could also pick R E N and R E N and that would also be a common sub sequence between the two
now the longest common sub sequence as the name suggests is the sub sequence which between
the common sub sequence between the two sequences which has the maximum possible length and you
can verify this you can try different sub sequences and see that R E I P I T O is the longest
common sub sequence between these two strings these two sequences and its length is 7
1 2 3 4 5 6 7 so you have to write a function to find the length of the longest common sub
sequence between two sequences so that's a question and this isn't visual example that tells you
the answer okay it's now that we have the question of we've understood the question
we can start applying the method that we have been learning throughout so this is the systematic
strategy that we will apply and nothing about this method has changed since the first lesson
even though we've covered a whole variety of topics like binary search and binary search trees
and then sorting algorithms and divide and conquer this method has remained the same the first
step is to state the problem clearly and identify the input and output formats then the second
step is to come up with some example inputs and outputs and these will be used to
test our solution so we should try and cover all the edge cases and that will help us write code
that is correct anticipating all the errors that we might face then we come up with a correct
solution to the problem and state it in plain English very important for you to state the
problem in plain English before you start coding so that you communicate your ideas and you also
make it clear once you express yourself then you implement the solution and test it using
example inputs and you fix bugs if you find any of them and you will be able to find bugs
if you have written good test cases then you analyze the algorithms complexity and identify
inefficiencies if you have any and most likely the first solution that you come up with it doesn't
have to be optimal it just has to be correct so there will be some inefficiency but it's important
to go through that process of first finding a brute force solution and then finding the inefficiency
and then apply the right technique to overcome the inefficiency and repeat steps to 3 to 6 so
you identify what's the right technique and in this case we will learn a couple of techniques
called memoization and dynamic programming and then we go back and state the correct solution again
then we implement the solution and test it and then we analyze it again and if there's further
scope for improvement we do that otherwise we say that we've arrived at the optimal or good enough
optimal enough solution okay I hope by this point this you've started to memorize this process
and that's why we keep repeating it over and over that it should become second nature every time
you see a problem so the first thing is to state the problem clearly and identify the input and
output formats now the problem is already stated clearly enough but let's just state it slightly more
clearly so let's say we are given and just write it in your own words that's more important
watch whatever is clear to you so we are given two sequences and we need to find
the length of the longest common sub sequence between them
simple enough then we have two inputs now we decide to input an output formats
so we have sequence one a sequence example
serendipitous sequence two another sequence example
press shape rotation
great and this these are the only two inputs that we require and the output would be the
length of the longest common sub sequence let's just abbreviate that as LCS
which in this case is 7 and we know what that sub sequence looks like we've just seen it above
so now based on this we can now create and you can see the problem is now created
before I talk about the next thing you if you double click on a textil you can start editing it
and here we are using a language called markdown so you can see this creates a blockcode this
creates a bold font and this creates a code like font so let's see here no and the way to
go back into the display mode is to press shift plus enter now you can see here that now we have the
problem we have the blockcode and then we have all this styling so markdown is a really useful and
easy to learn language for formatting your text especially in Jupyter notebooks to do learn it
but now based on this we can now create a signature of a function so our function lns will accept
a sequence sequence one as sequence two and it will return something so that's the basic signature
of a function and even though it's not doing much just establishing what the arguments are is the
first step towards solving a problem and let's just save our work from time to time it's very
important to keep saving your work on Joven because this is running on a free online service so
this will shut down after some minutes of inactivity so just run Joven.com it and that will save
the notebook to your profile and you can read on it okay so now the next step is to come up with
some example inputs and outputs and here we need to try and cover all the edge cases so
I have written out a few test cases here already. Now the most common cases is a general case of a
string like we had serendipitous and precipitation that's a common case there is one of them
both of them have some common elements and there's a subsequent common subsequence of length 7
but we may also want to test out another type of data and this is one of the nice things about
Python where you can write functions that operate not just on a particular class and it's subclasses
but on any kind of data as long as it satisfies certain criteria for instance strings and
list both allow indexing into them and picking out the ith element or the nth element from
the sequence so the both sequences so our function should be able to work with both strings and with lists.
Then here is another case where we have two sequences and they have no common
a function should not throw an error here it should gracefully return the number zero because
the empty sequence is a subsequence of every other sequence. Does that make sense? Think about it.
So in that case if there's no common subsequence then the empty sequence is the common
subsequence of the answer is zero and here's one another extreme case where one is a subsequence
of the other. Here's another case where one sequence is empty. Here's another case where both
sequences are empty. All of these are important otherwise you might miss out certain special
cases and you will face an error when you code your solution. Finally you can also have this case where
you have multiple subsequences with the same length for instance if you have a b c d e f and b a d
c f e and a c e a c e is one long subsequence of length three and that's the longest you can verify
and b d f is another subsequence which is common to the two and also has the same length.
Those are some test cases. Now let's copy over these test cases here in an interview or a
coding assessment what you might want to do is just write these as comments if you have just a
single coding screen and try to list at least four or five but go as far as you can because this
will also help you streamline your own solution and it's always something that is appreciated by
interviews. Let's do that. Let's get let's copy over these test cases here and you can think
of more so if you have some more ideas of things you should test come up with them there's no
right number of tests whatever it takes for you to feel confident is what you need to do.
Okay so now what we've done is we've taken these test cases and converted them into dictionaries.
So you can see here we have this first sequence sequence one and remember that's why we
written out that's why we've written out here the names of the inputs and the signature of the
function. Now we can create test cases as dictionaries so that we can test them all easily
at once. So we have the sequence one and sequence two in the input sub dictionary inside the main
test case dictionary and then we have the output which is the output of the function which should be
seven and this you can verify so this is a general case then we have another case in this case
we have two sequences these are both lists of numbers and in this case the output that we expect is
five and we have another general case longest and stone in this case you can verify that oh and
E is the common sub sequence it has the output three then here we have two sequences which do
not have any common elements all these come from the left half of the keyboard all these come from
the right half of the keyboard so that was a quick way to generate these two sequences.
Then here we have dense and condensed and you can see that dense is actually a piece inside
so this is a special case where dense is a continuous substring of this string but it
even if we had DEC that would still be a sub sequence because DEC occur in this order so that's one
example and in this case the sequence one is itself the longest common sub sequence and it has length 5
then we have this case where one of the sequences is empty and you can see in that case the output should
in both sequences are empty and here is the case where you can have multiple longest common
sub sequences and even in this case your function should be able to figure out the answer correctly
so let's take this and let us copy over these test cases here so we have T0 to T7 that's A test
cases and you can add more test cases here please feel free coming up with good test cases is a
scale that you should develop and what we'll do is we'll also put all these test cases into this
function called LCS or longest common sub sequence tests so that we have all of them easily available
for testing at once okay okay now next step is to come up with a correct solution for the problem
now we've seen the problem we have identified some scenarios now we need to come up with a simple
correct solution and stated in plain English it doesn't have to be efficient it just has to be correct
so here's one idea here you can see we have a couple of sequences let's create two counters
IDX1 and IDX2 both starting at zero so IDX1 will be a pointer which will start tracking
elements in the first sequence and IDX2 will be a pointer which will start tracking elements in the
second sequence and what we do is we will write a recursive function so we write a recursive function
which will compute the LCS of sequence one from IDX to the IDX1 to the end and sequence two
from IDX2 to the end so what does that mean let's say IDX1 has the value 3 and IDX2 has the value
one so you can see zero one two three so sequence one IDX1 onwards is L O G Y and sequence two
IDX2 onwards is L CH M E M Y so we're looking at this portion of the problem and this portion
of the problem and a recursive function when involved with IDX1 and IDX2 should return the length
of the longest common sub sequence between these two portions so L O G Y and L CH E M Y now
why we doing this we need this longest common sub sequence for the entire string don't we
now here's the logic why we're writing this recursive function which can
theoretically compute this sub sequence for from any position onwards so here's how we do this
if sequence one of IDX1 so if IDX1 was pointing to L and IDX2 was pointing to L here as well
if sequence one of IDX1 and sequence two of IDX2 are equal then this character L belongs to
the L CS of this portion and this portion okay why think about it it makes sense because
these these elements are equal so if you pick the longest common sub sequence of this
and you pick the longest common sub sequence of the remaining then you can always add L to
both that sub sequence and that will make the sub sequence longer right and that way it follows
that L will always occur in the longest common sub sequence between L O G Y and L CH E M Y okay
so we know now that this will occur L will occur in the longest common sub sequence further
the length of this longest the length of this longest common sub sequence will be the length
of the longest common sub sequence between O G Y and CH E M Y plus one okay and now you can see
why a recursion is required because what we can now do is we can say that if sequence one of IDX1
and sequence two of IDX2 are equal then we simply call the recursive function on sequence one of
IDX1 plus one so O G Y and sequence two of IDX2 plus one CH E M Y and assume that recursion
will give us the solution there and simply add one to it because this is equal okay so that's one
if sequence one of IDX1 and sequence one of IDX2 are equal great but if they are not equal
right so for in in this case for instance you can see that if IDX1 and IDX2 are both zero
so IDX1 points to A and IDX2 points to B so if they are not equal then one of the two things should
hold either A does not occur in the longest common sub sequence between the two strings or B does
not occur in the longest common sub sequence between the two strings now we don't know which one
but that's the power of recursion that we can just try both so we can simply ignore A and we can
get the longest common sub sequence between B S E N T and B E S T and check it's length and then we
or we can simply ignore B and we can get the longest common sub sequence between A B S E N T and E S T
and check the length now whichever is longer in length that becomes the solution for the two strings okay
so this is what it looks like we start out with analogy and alchemy we compare A and A
are these two are equal so we know that the longest common sub sequences one the length is one plus
LCS of analogy and alchemy okay now we compare N and L and now we see that they're not equal
so either N does not come in the longest common sub sequence or L does not come in the longest common
sub sequence so we try both we remove N here you see ALO GUI and we remove L here we see CHENY
now once again A and L are unequal so either A does not occur in the LCS of these two strings
or L does not occur in the LCS of these two strings so if A does not occur in the LCS we can
remove A and try again if L does not occur in the LCS we can remove L and try again and here once again
we get a match so in this case we know that L occurs in the longest common sub sequence of these two
elements so now we can get the LCS of OGY and CHENY okay and then you know as these
recursive calls complete you can see that this entire tree pans out you can see that each time
you either get one child or you get two children and if you go all the way down and then you go
back up and simply count the number of matches for each path you will key and you take keep
taking the maximum so here you get back an answer let's say you get back an answer of size two
here you get back an answer of size one so the answer for this is simply the maximum of
two and one which is two and then the answer for this is simply the maximum of two and
let's say this is three then three and the answer for this is simply one plus three four okay
so this is the way that we will build up the solution so we've now looked at the recursive solution
expressed in text and we've looked at the recursive solution expressed as a tree now it's possible
that it still may not make sense to you how exactly this is working and that is where you should
start trying to create this tree yourself so pick up a pen and paper and then start drawing
on pen and paper take an example and try to read each step here and try to work it out like a computer
okay and just thinking about it that way will help you understand this algorithm now one last thing
is that if either of the sequence one from idx onwards or sequence two from idx onwards is
empty which means the index has reached the end point in after doing some recursion then their
LCS is empty so the length is zero okay so that is the recursive solution here I will just copy
over this recursive solution to along with the entire tree
now obviously in an interview you do not need to write all of this in a lot of detail or you do
not need to some it's helps to show diagrams sometimes but you don't really need to do all of this
all you need to do is express yourself clearly that we will create two counters and the condition to check
is whether these two elements at those counter positions are equal what do we do if they are equal
and why are we using recursion here so we are using recursion we can because we can use
reuse some of the sub problems to compute the final problem okay and understanding recursion is
really important for solving data structures in algorithms from is because it's like a super power
pretty much pretty much every problem that you see one way or another can does boil down to recursion
in one way okay so now let's save our work once again and now we're ready to implement the solution
so we have the recursive solution in front of us and if you remember the four steps let's go let's go
ahead and implement it so we see let's just call it LCS recursive and this will accept a sequence
one and a sequence two and let's also initialize IDX one and IDX two because we will be calling
this function recursively so we'll simply use these two counters IDX one and IDX two and set them to zero
now the first thing we need is if IDX one is equal to the length of sequence one or IDX two is equal
to the length of sequence two then we return zero again this is a common thing that happens that
the base case or the end scenario is something when you're describing the algorithm you will
describe it the very end as you're drawing the tree you will notice what the end case end scenario is
but when you're coding the algorithm the end scenario or the base case comes at the very top
because otherwise we'll try and access IDX one from sequence one and that will throw an error
so that's why you need to handle the base case at the very beginning okay next moving ahead
if sequence one of IDX one equals sequence two of IDX two
great we found a match so we simply return one plus now we can call LCS recursive
on sequence one sequence two and we increment IDX one by one and we also increment IDX two by one
both of these need to be incremented because we are going to use this element this common element
as an element in this up sequence okay so there's just one recursive call here that was nice
otherwise we have to either ignore the first element of or the current element from sequence one
or the current element from sequence two so we have two options so we have option one
which is we ignore the current element of sequence one so this becomes LCS recursive sequence one
sequence two IDX one plus one and IDX two and then we have option two this is LCS recursive
once again we sequence one and sequence two and this time we increment IDX two
okay so make sure you understand this piece because this is really the key here and then the length
of the longest common subsequent is simply the maximum of option one and option two okay and that's
it what may have seemed like a fairly tricky problem once you start thinking about it recursively okay
what happens if we simply compare the first two and they're equal and they're unequal okay now
we need to solve the problem for the remaining either we add one or we take or we ignore one
of the elements right once you get that thought the recursive thought then the solution and the
code simply presents itself to you it's just about seven lines of code okay that's our LCS recursive
solution now let's test it out let's look at a test case T0 okay so here we have serendipitous
and precipitation as the inputs let's call LCS let's keep that around so that we can view it later
let's call LCS recursive on T0 but of course we need to fetch from T0 the input and get sequence one
out of the input and similarly we need to get the input and get sequence two out of the input
you can see it takes it returns the value seven which is equal to the output by the way
so if we simply put in here T0 output and I'm also going to put in this special command
called percentage percentage time this is going to tell us how long the cell takes to execute
yeah so now you can see here that if we get back true and the cell takes 495 seconds or half a
second to execute and that's it so now we have tested this test case one small thing I can tell you how
to improve this slightly is because in T0 of input is a dictionary and because the names of the
elements of the dictionary are sequence one and sequence two which also match the argument names
of LCS recursive you can see here we have sequence one and sequence two what you can do is you can simply
say star star T0 input and Python will automatically grab each key so sequence one will be passed
as the argument sequence one and sequence two will be passed as the argument sequence two
that's this is a small trick here that helps us speed up the reduce the amount of code we need to
write okay now we've tested one test case with that's not enough we should be testing all the
test cases to test all the cases we can write a for loop for P in tests etc but we can do something else
too we can use the evaluate test cases function from joven so from joven not Python DSA the
module we will import evaluate test cases it's a helper function that we've created for you but
it's really simple to write you can just use a for loop as well and we call evaluate test cases
on the function that we want to test which is LCS recursive and the test that we have which is LCS
tests and when we do this it is going to try out each test case you can see it's try test case 0 that
was a pass it tried test case one and it's also printing out the input the expected output in the actual
output the test case one was lists and lists work too because all we've used here is indexing
and length and these are both things that are available in both strings and lists and this is
something that's very nice about Python the dynamic nature of the functions once again this work
perfectly fine then here we have another one longest in stone the expected output was 3 and the
actual output was 3 as well here we have ADS FEW AD and another string they have nothing in
common so they expected an actual output of both 0 here's one where one is the is already a
subsequence of another so the smaller one becomes the longest common subsequence and then we have
an empty string and then we have two empty strings and finally we have multiple longest common
subsequences we still get back the right output now if any of these failed you would know exactly
what went wrong for instance if you had an issue in this case where the two of these were empty
and that would tell you that you've probably not handled that empty case properly and that is why
having great test cases is very important okay and we can see the timing so these as well each of
these took about well 48 milliseconds was the highest now that's still a bit high I would say
48 milliseconds because we are just looking at sequence a serendipitous in precipitation which are
of very short length if you're looking at a really long sequence for instance this technique is
used for DNA sequencing and we were looking at two DNA strands or two DNA strings and trying
to get the common subsequence out of them and these can go into thousands of sometimes millions
of elements that would make it rather slow okay so we do want to improve this algorithm further
but let's do that and before that we can just commit our work once again but the first thing
before we improve the algorithm is to analyze its complexity how long does it really take okay and
identify any inefficiencies now to analyze the complexity let's look at an example and let's consider
the worst case now when does the worst case occur here we've seen that if two elements match then we simply
have one sub problem or one recursive call but if the two elements are two elements of the
sequences don't match then we have two recursive calls so if we have two completely distinct sequences
where none of the sequence none of the elements match then each time we will end up with two
sub problems so that becomes the worst case so the worst case occurs each time we have two sub problems
where the sequences have no common elements and here's an example this is a sequence of length 6
here's an sequence of length 8 and this is what the tree will look like so now we have no longer
put the actual sequences we've simply put what is the length of the string that we start out with
so here we start out with strings of length 6 and 8 and then we say that we either ignore the first
character of the first string or the first sequence or we ignore the first element of the second
sequence and that gives us two sub problems and this time the sequences have length 5 and 8 in this
case and 6 and 7 in this case okay so we either reduce one from the left or we reduce one from
the right and once again here we either reduce one from the left or we reduce one from the right
so this way we created tree and you can also see that a lot of common trees get created and that
really is what is the inefficiency and we'll talk about that but what will happen here is 5 7
will then call 4 7 and 5 6 and 5 7 here will once again call 4 7 and 5 6 and 4 7 and 4 7
and we'll get repeated here and 5 6 and 5 6 will get repeated 3 times here so there's a lot of
repeated calls that are going to occur and you can even see this here at the top you can see that
ALOGY the problem was called repeatedly so that's really a source of inefficiency but
now the question becomes that we know that all the leaf nodes will end at 0 0 that's when
the entire re ends so can you count the number of leaf nodes okay can you count the now if you
keep expanding the street completely expand each of these don't skip any of them
can you count the number of leaf nodes now if you count the number of leaf nodes we know that
in a binary tree the number of leaf nodes if the number of leaf nodes is L then the height of the
tree is if the number of leaf nodes is N then height of trees log N and based on that we can actually
determine the actual size of the tree as well so we know that to count the number of unique
parts from root to leaf we'll give us the number of leaves right so each time we have two choices
we either reduce from the left or we reduce from the right so to get to 0 0 we would have to
reduce all the elements from the left and we would have to reduce all the elements from the right
that means if you have strings or if you have strings of length or sequences of length M and N
then you would have to make M plus N choices in total right and you so each time you have M you
have to make M plus N choices each time you have to choose whether you want to reduce from the left
or from the right we have two choices and you have to make those two choices M plus N times that's
the right way to put it really so that means each time you compare you do two choices so you have
two multiplied by two multiplied by two multiplied by two and you keep multiplying that and you will
end up with 2 to the power of M plus N leaf nodes, okay. So here is an exercise for you,
draw the street on a piece of paper, mark out how the number of leaf nodes, how the length
of each part is M plus N, figure that out. And based on that, can you conclude that it takes
2 to the power of M plus N leaves to complete this tree. And if 2 to the power of M plus N is the
number of leaves, then the total number of elements is in the tree simply double of that.
Once again, this is something that is very easy to verify. You can check it here.
For instance, if you just consider these 2 levels, the if you have 2 leaves, then the total number
of elements in the tree is 2 plus 1 3, actually it's double minus 1. So 2 into 2 4 minus 1 3.
If you have 3 levels, you can see here that if you have 4 leaves, then the total number of elements in
the tree is 4 into 2 8 minus 1 7. And you can see that here. So it follows essentially that
we have an exponential number of sub problems. We are calling the recursive function in exponential
number of times. And inside the recursive function, we are doing inside the recursive function,
we are doing a constant time work. You can see here that there's no special work that we're doing
all we're doing is some comparison. And we're doing an addition, both of them are constant time.
So we make 2 to the power of m plus n, recursive calls inside each video constant work.
So the time complexity is order of 2 to the power of m plus n. That's a rough explanation.
We've not gone into a lot of depth because we've covered this over and over in
three lessons. But the exercise for you to is to verify how exactly it is 2 to the power of
m plus n. So that's our recursive solution. And we now know that the time
complexity is 2 to the power of m plus n. Let's just copy that over here.
And the inefficiency as we said in this algorithm is that we are calling the same problem.
We're calling the exact same problem. The LCS recursive function is called with idx equal
idx1 equal to 5 when idx2 equal to 7 and idx1 equal to 5 when idx2 equal to 7 the same time
twice. So each of these sub problems will be called twice. And then each of the sub problems with
them will be called twice. And of course some of these sub problems will once again get shared.
So there's a lot of repetition. Now there's a simple solution here which is simply to
remember some of these results. And this technique is called memoization and you may also just call it
memorization because you just remembering some of these things. But memorization is a technical term for
it. And we remember these solutions in our dictionary called memo. So what we're going to do
is we're going to follow the same recursive strategy. But this time we're going to maintain a dictionary
called memo. And we're going to track intermediate results within the dictionary. And if we find an
intermediate result already exists in the dictionary, then we will not compute it again. Okay? So let's
see. So now we write LCS memoized or let's just say LCS memo for short. It takes a sequence one
and it takes a sequence two. And this time we create this dictionary called memo. And then we
write a function inside it. So we will write a helper function or recursive helper function inside
the LCS memo function. So that it has access to sequence one and sequence two and we will simply
start it out with IDX1 as 0 and IDX2 as 0 as well. IDX1 will track the position and sequence one
IDX2 will track the position and sequence two. Now the first thing we do is create using the two
indices create a key. So we are going to create the key IDX1 comma IDX2. And if the key is present in the
memo. So this is the way to check if a key exists in a dictionary. Then we simply return memo of key.
Simple. The problem is solved. We don't have to solve this problem because it's already it's
already something that we've solved. If it isn't then we need to solve the problem and save it in
the memo. Now here we know that we can now write our same three recursive cases. Now if the
base case if IDX1 is equal to the length of sequence one or IDX2 is equal to the length of
sequence two then we simply set memo of key as 0 because by this point we have reached the end of
the strings there's nothing left for us to compare. LF IDX sequence one of IDX1 equals
sequence two of IDX2. So in this case this is the case where the current characters are equal.
So this is if we go up here and look at the tree once again. This is a case like this where the
current characters that we are pointing at are equal. So in that case we simply return we simply
get the result as one plus the result for the remaining with the first character removed.
So in this case we simply set memo of key to one plus we call the recursive function again
recurs IDX1 plus one and IDX2 plus one. Great. L's so this is a case where the two elements are
not equal and this is where we have two options. I'm not going to write the two options separately.
Let's just do a max directly here. Max and we say recurs with IDX1 plus one comma IDX2
and recurs with IDX1 comma IDX2 plus one. Okay and finally from the recurs function we return
memo of key. So we have whichever case it is we have computed the result and saved it in the memo.
So this time these computations will not get repeated again and again
and let us now return recurs of 0 comma 0 because 0 comma 0 is the entire string
and that's it. And this is the common strategy that you should apply whenever you come up with
a recursive solution and you see the inefficiency coming because of the same problem being called
again and again. This is where you need to apply this technique called memoization. Right and in
this technique you will then be able to simply store intermediate result. So it's really simple.
You just created dictionary and then you add one or two lines of code here and you make sure to
save the result in that dictionary whenever you compute the result the next time you don't have to
compute it. Okay and we can test it out we can test out with all the test cases
evaluate the test cases. So LCS memo and LCS tests and you can see that all the test cases pass.
Now not only do all the test cases pass you can see that the time taken is now lower.
Okay so that's nice the time taken is now lower. Now we went from 415 milliseconds
if we just go up here you can see that it took 480 milliseconds for the for finding the longest
common sub sequence between precipitation and serendipitous but in this case it only took about
0.234 which is 0.2 milliseconds. So it is 2,000 times faster even for strings of length 7 or 8
and that's a huge boost. Let's analyze the complexity here. Let's look at the complexity. Now a
quick and easy way to find the complexity of the solution is to see where the computation how many
times the computation can occur. Now this is where the bulk of the computation is occurring in
a recursive call and this computation is avoided if we already have something in the memo.
Okay so that means that the only number of computations that we need to do is equal to the maximum
number of elements that can end up in the memo. Now what are the keys in the memo look like the
keys in the memo look like IDX1 and IDX2. Great and what values can these take? IDX1 can take
0 to M values if M is the length of sequence 1 let's say. And IDX2 can take 0 to N values if N is a
sequence length of sequence 2. So in total the possible number of keys is M times N.
The possible number of keys is M times N. The possible number of things that you need to store in
the memo is M times N and for each of them you do constant work and then the next time you try to
accesses you do not need to do the work you do not need to call in a recursion you can simply
access the memoization. So what that tells us is the complexity of this case and in
any memoization case in general is equal to the number of keys which in this case is M times N. So
the time complexity here is order of M times N. So we've gone from 2 to the power of M plus N which
if M plus N was equal to 30 would be 1 billion 2 M times order of M times N. So let's say
both strings were 15 and 15 so that would just be 225 operations. So we've gone from 1 billion
operations to 225 operations simply by storing intermediate results and so very powerful technique
there we apply all the time. So now you can see here that the first time 5 7 is computed
the next time 5 7 does not need to be computed again and that's why this tree here is actually marked
this is the tree for memoization. The first time 4 7 is computed it never needs to be
computed again. So this entire tree of computation gets eliminated and similarly this entire
tree of computation gets eliminated. We are eliminating from 1 billion computations almost
all except 225 computation. So we're left with practically nothing and that speeds up your
algorithm by a huge huge factor. So that's memoization and as he said it's really easy to compute
the time complexity of memoization just simply count the number of keys and then just track
how much work do you need to compute each key assuming that you already have the recursive solutions
for the remaining. So how much work do you need to compute each key using some other existing
solutions. Now in this case that was constant because all we needed to do was compare and add.
Okay and I'll let you write here a simple optimized plain English explanation of memoization.
It's worth a it's a good exercise to try out but what we will also look at is another technique
called dynamic programming. Now the downside with memoization is that it requires recursive calls.
And while it's not a problem for small cases when you have really large problems,
a recursion has an overhead and the overhead for a question. If you see this way is that for this
function execution to complete, you need this function execution to complete and this to complete.
And for this to complete you need this to complete and this to complete right. So the idea here is that
for each new recursive call takes more space in the memory and it also takes longer because now we
have to allocate some memory and then set up that function stack, the function stack for the execution
of that function. So if you have a large tree then you're creating hundreds, thousands of
possibly millions of open functions all of which have their own memory and that can eat up a lot
of memory and sometimes that can also take up a take longer time. So the solution to replace recursion
is iteration. And how do we do that? We do that using a technique called dynamic programming. So
we'll do almost the same thing. There are a few changes here. Instead of using a dictionary
to track intermediate results, we will create a matrix because we know that
sequence one, the idx one can go from 0 to n, or 0 to n1. Let's say where n1 is the length of
sequence one and sequence idx two can go from 0 to n2, where c, n2 is the length of sequence two.
And what we can do is we can use a for loop or a couple of for loops to fill out all these
sub problems without having to require a recursion. And this is how we'll do it.
So let's say these are the two strings that we're working with. This is string one, a, c,
g, t and this is string two. And this is what DNA sequences look like. So what we'll do is we will create
a table of size n plus one plus one and n1 plus one and n2 plus one. So you can see that there are
n1 plus one rows. So if this is of length n1, these are n1 rows and then there's an additional
row. And similarly, there are n2 plus one rows here. So if this is of length n2, there are,
there are n2 plus one columns. So you can see these are n2 columns and there is an additional column here.
And table of i and j. So let's say table of if i and j are zero. So i is a pointer for
the first sequence and j is a pointer for the second sequence. So i selects a row and j selects a
column. So table of i and j represents the longest common sub sequence of sequence one up to i
which means sequence one. So here if let's say i was one and j was one.
So this represents the longest sub sequence of sequence one up to i. So all the positions before
one, which means only the zeroed position just t and sequence two up to j, which means all the
positions up to the first position of up to up to one. So which means only the zeroed position.
So which means a. So table one and table ij represents the longest common sub sequence of
these two of just a and t which is zero. On the other hand if we skip ahead a little bit
if we skip ahead to let's say this position, you can count here i goes zero one two three four
five six. So this is six here and here we have zero one to zero one two three. So this is
table of six comma three. The table of six comma three takes the first six elements
which is t a g t c a and the first three elements a g a of sequence two and it stores the result
of the longest common sub sequence between these two. So I'll just let you look at the table
and maybe even draw the table on a piece of paper and verify that the length three is right.
You can see here a g a a g a occurs here. So a g a is a sub sequence of t a g t c a. So the longest
common sub sequence between them is three. Now what we will do is we will now compare the next
elements of we will now compare sequence one of i and sequence two of g. So let's say we are looking at
let's pick an example let's say sequence let's say i has the valued
i has the value zero one two three four i has the value zero one two i has the value two and
let's say j has the value one so zero one. So if we compare sequence one of i
so which is g and sequence two of a sequence two of j which is also g and if they're equal.
So if they're equal then table of i plus one on j plus one which is this value right. So remember i is
two and j is one. So table one of i plus one so table one of three is zero one two three
and table. And table one of i plus one j plus one is table one of
three and table a table a table of i plus one and j plus one.
I being two and j being one is table one of three and two.
And table one of three and two is the valued two. So this value is obtained by adding one
two table one of i comma j. So because these two elements are equal,
when we can then say that if we take the longest common subsequence of t a and a and add one to it,
that will give us the longest common subsequence of
t a g and a g. So the exact same logic is recursion. We have simply
now reversed it. So we now now we're looking at the last element that we can keep filling out the
last value using some previous values. So this is one case. Similarly here's one other case
where a and a are equal. So the longest common subsequence of t a, g, t, c, a and the longest
common subsequence between a, g, a is one plus the longest common subsequence between t, a, g, c, and
a, g. Okay, one plus this value. So that's one case. The other case is if they're not equal. So let's look
this value for example over here. So we have t a, g, t on this side and then we have
okay, let's look at this one. If we have t a, g, t on this side and we have a, g, a, c on this side.
Now t is the element here and c is the element here. They are not equal. So that means the longest
common subsequence between these two either does not contain t or it does not contain c. It cannot
contain both obviously because one of the strings has to end. So if it does not contain t,
then it is this result. And if it does not contain t, if it does not contain c, then it is this result.
So we simply take the maximum of these two, maximum of these two to get the result for this,
if these two elements are not equal. And that is how you fill out the table. You start from the top,
the first row is zeros because we have empty strings and the first column is also zeros because
we have empty strings. To fill out an element, you compare if the two elements are equal.
And if they're equal, we simply add one to the diagonally left top left element.
If they're unequal, then we take the maximum of the element above it and the element to the left
of it. And that way, we fill out the entire table. Okay. So that's the dynamic programming solution.
And I know this can seem a little bit complicated. Honestly, I still get
confused with dynamic programming a lot of times. And that's why I like to just draw tables and write things out carefully.
Okay. And especially, you have to be specially careful with indices. Because here we are saying that if sequence i, i and
sequence to j are equal, then table one of i plus one and j plus one is one plus table i j. So be just
watch the indices carefully here. But let's implement the solution.
Let's implement the dynamic programming solution. So let's say LCS dynamic programming. So we'll just say
dp here and we have sequence one and we have sequence two. And the first thing we need is we need
a table of results. Now this table for it, let's just grab n1 and n2. So length of sequence one
and length of sequence two. And now we need to create a table with all zeros. How do you
create a table with all zeros? The way to do it, a way to create a list of zeros is this zero for underscore
let's say n1 and let's give n1 and n2 some values. Now if you want to create a list of zeros of
length n1, use simply say zero for underscore or zero for x, you simply ignoring whatever value
you're getting from a range, range n1. And that's going to give you a list of zeros. But we don't
want a list of n1 zeros. We want these want these want to be rows. So we want each of these to
itself be a list of zeros of length n2. So zero for x, n, range, n2. And now we have, you can see that
we have five rows, one, two, three, four, five. Then we have seven columns, one, two, three, four, five,
six, seven. So this is the table that we want to create initially. Now this is a table that we've
created. This is going to be this exact same table. And we're simply going to start each string
from position one this time, not from position zero because we want to have this additional
row where we don't consider either of these. That just makes computations a little easier.
Now we say for iDx1, n, n, addx1 and range n1. So that's that's going to iterate over the
rows. And then for iDx2 in range n2. And that's going to iterate over the columns.
And first we compare if sequence one of iDx1 is equal to sequence two of iDx2.
If they're equal, then we can fill out table of i plus one and j plus one
has one plus table of ij. Okay. And we can see this here. Suppose the first elements were equal.
So suppose this was suppose iDx1 was zero and iDx2 was also zero. Suppose they were equal.
Then this value should be one. So this value should be one plus the diagonally top element.
And that holds to anywhere within the list. So wherever you have two elements equal like G and G are
equal here. So this value is one plus this value.
Else we have table i plus one and j plus one is max of
table i comma j plus one. So you stay in the same row or you go to the previous row or
you go to the previous column which is table of i plus one comma j. And this is the previous column.
Okay. So this is this case where G and A are not equal. So if G and A are not equal,
then we take the maximum of these two values. And that's it. That is going to fill up the table
for us and then we simply say return table. We simply want the bottom right element. We can simply
say return table minus one minus one. So this is going to get their last row last column.
And that's our dynamic programming solution. Let's do evaluate test cases here.
Okay. Turns on there's no i. Okay. Let's just call this i and j.
Turns out i dx 1 is not defined. Let's just make these i and j. Now that we're doing this coding
live, you can see that even after a decade of coding, I still make all of these issues.
It says the list index is out of range. It seems like i plus one and j plus one.
Ah, that's because remember we need an additional row and an additional column to track the
case where either of the strings is empty. So we need to get range n 2 plus 1 here and we need to get
range n 1 plus 1 here. Okay. That's why it helps to have test cases so that you can fix all of
these issues. Now you have test case 0, it passes and test case 1, 2, 3 all of them pass.
You can see that all test cases are fast and you can also verify that the amount of time it
took is now lower than the amount of time it took for memorization. Okay. And so that's the
dynamic programming approach. You simply create a table and you fill out the table. Sometimes just
working with indices within the table can get confusing. So it helps to work with it on
paper and make it clear to yourself and write it in English. That's why we written it in
plain English here. And our exercise for you is to verify that the complexity of this dynamic
programming approach is order of n 1 times n 2. So which is the same as memorization and it's actually
more straightforward to see here because you have two four loops in each of these four loops.
You are simply doing a comparison and an addition and there's not even any recursion to
very, there's not even any recursion for you to worry about. So you just do a comparison and you
do an addition or you take a maximum pretty straightforward. So order of n 1 times n 2 and it does
not even invoke another function. So it does not take up too much memory, it does not take up too much
time. It's very, very efficient. And this is how you solve pretty much every dynamic programming
problem. You write a recursive solution. You come up with a brute force solution and keep in
mind that recursion is almost always the way to go about creating a brute force solution. So you
come up with a recursive solution and then you identify your rather recursion tree. And if you see that
the same sub problem is being called again and again, that is a point where you can introduce memorization.
So you introduce memorization and sometimes you can just write the memorized solution and that's
enough because it's easy to reason about. You just put in a memo and you're done with it.
Even the interviewer or the coding assessment will accept that solution. But in some cases you
will be asked to then remove the recursion and write it as an iterative fashion and that is when
then you have to start drawing a table and think about what are the rows and columns in that table
need to represent. So here the ijth element of the table represented the first the first i elements
of sequence one and the first ij elements of sequence two what is the longest sub sequence between
them and we use that to build the next row and the next column and we then filled out the entire
table and we simply use the last value. Now again this is not very straightforward how to come
up with this and the way you do that is by solving problems. So if you solve five to 10 dynamic
programming problems you will get some intuition about how to build the tables and it's always
very helpful to solve it on pen and paper first especially with dynamic programming so that it's
clear to you what each element of the table represents. Otherwise you may make a lot of off by one
errors like missing the plus one here or missing the plus one here and get confused just like i did pretty
much. And that's the time the time complexity is pretty straightforward. In most cases it is simply
the size of the table but sometimes you may have to do more than constant work here. So keep
that in mind see what it is that you're doing inside your loop. Now inside of inside your loop if you
have to go back and check the entire length of the string so that will introduce another factor
into the equation. So keep that in mind but in most cases counting the iteration should be good enough
to give you an idea of the time complexity. Okay so that's the first problem and let us just
commit this and out saved to my profile. So if I just open this up here you can see that now I have
notebook called longest common sub sequences and I can share it online whenever you work on a
notebook it's always a good idea to make it public put it up on Joven all you need to do is run
Joven.com it and share it online just press the share button and then you can share it on Twitter
LinkedIn Facebook or wherever you like. So that's the first problem that we looked at.
Now let's come back to listen for and by the way the problems that we're talking about all the
problem statements the graphs the images you can see them in the second link here but we will once again
open up the problem solving template and now we'll use it for the second problem. Let me run this once again.
We're going to look at the second problem which is the Napsack problem.
So let's read the Napsack problem. It's also called a zero one Napsack problem. Here's there are
many variations of this problem but here's one way to state it that you might come across
or something similar you are in charge of selecting a football or a soccer team from a large pool of
players and each player has a cost and a rating. So there's this election going on you have to
come up with a team for this year and you have a large pool of players each player has a cost and
each player has a rating. Now you have a limited budget so you need to build a team within the
budget. So what is the highest total rating of a team that you can create which fits within
your budget. So this is the question here you have to maximize the total rating but fit it fit
the total cost within your budget. We have two variables here rating and variables.
Rating needs to be maximized cost needs to be simply optimized to the extended it fits in the
budget and this is simplifying assumption here is that you can assume that there is no minimum
or maximum team size. This is simplification and later you can introduce a criteria there as
well that you want to build a team of exactly 10 people and see if you can also solve that problem in
a way. So that's the Napsack problem let's copy it over and here's a Jupiter notebook
a fresh problem solving template. Let's simply change the title here.
And it's also called the zero one Napsack problem because each item can either be chosen
or not chosen and let's give it a project name here too.
Let's commit it and let's paste the problem statement here.
Okay. So that's a problem statement and this is a specific or a special form
of a more general problem statement and we look at the general problem statement in a second
we'll when we try to state the problem clearly but here's once again the systematic strategy we
will apply we will state the problem clearly identify the input and output formats come up with some
example inputs and outputs and try to cover all the H cases then we will come up with a correct
solution for the problem and state the solution in plain English it just has to be simple
correct solution not to complex then we apply the right technique to overcome the inefficiency
and then we analyze the algorithm and identify any inefficiencies after implementing the solution
and finally we apply the right technique to overcome the inefficiency and then repeat the process
of stating the solution implementing it and analyzing it. So to state the problem clearly what we can
do is we can abstract out the problem in more general terms and that is what is stated here
and let's just grab that and we'll take a look.
So here we have we are given an elements and each of which has a weight and a profit
so you have an elements and here's the profit of each element and here's the weight you can
know of each element so you need to determine the maximum profit that can be obtained by selecting
a subset of the elements weighing no more than a given weight w so you have a capacity a maximum
capacity let's say the maximum capacity is 15 and you have to select certain elements so that
you fill out the total weight is no more than the capacity and the total profit is maximized
that's and this is why it's called an abstract problem so assuming here you have a bag or an
abstract with a capacity of 15 kilograms and these are the weights of the items and these are the
profits. Now in this case you can see in this example the optimal selection is these four elements
which are the weights 5, 3, 2 and 5 so that you fill out the total capacity of 16 of 15 and
the solution on the maximum profit that you can obtain is 7 plus 4, 11 plus 5, 16 plus 3, 19.
Now you can try other combinations and verify that this is actually the best solution
do give it a shot. So what are the inputs here so we have pretty clear we have an input
weights so these are the weights of the this is a list of numbers containing weights
and then you have profits a list of numbers containing profits
and this should have the same length as weights and then finally you have a capacity
the maximum weight allowed and there you go and now we have outputs so now the output would simply
be the max profit so this is the maximum profit that can be obtained by selecting elements
of total weight no more than W or no more than capacity.
Okay great so that gives us a pretty good starting point now we can write a function
signature here so we write max let's say def max profit
and we can give it weights and we can give it
profits and we can give it a capacity and we pass.
So now we have defined the problem we have stated we have identified input and output formats
now we need to come up with some example inputs and test cases. Once again we have listed out
a few test cases here so we will have a few generic test cases where you have just random
set of weights and profits and you identify the anapsack the optimal solution then here's one
option where all of the elements can be included you can take everything here's another option where
none of the elements can be included you have to think about all these scenarios here's one where
only one of the elements can be included then you may also think about areas where
you do not use the complete capacity okay you do not use the complete capacity
because the optimal solution is actually taking a lower capacity so there may be a way to
fill out to capacity but that may have a lower profit then another option which takes less
than the complete capacity but has a higher profit so think about some cases here think
think about some good test cases here and I will just copy over these for now
and then what we'll do is we will express these test cases once again as
dictionaries we have test 0, test 1, test 2 all of these expressed are dictionaries and these are
covering all the test cases that I mentioned here you can see here are some weights and some
profits and the capacity is 165 and then the optimal solution is 309 now we are simply asking
here for the optimal solution the maximum profit that can be obtained but an extension of this
problem is to identify which are the elements that should be chosen and it's a simple extension
it's a good exercise for you to try out and you can discuss it in the forums we have test 0,
test 1, test 2, test 3 and 4 and 5 so we have a total of 6 test cases let's copy over these test
cases here and let's put them here into a single string and that gives us the test cases okay
now coming up with the solution so once again the first step is to try and come up with a recursive solution
and a recursive solution is again quite straightforward we'll write a recursive function max profit
that given an index so this time we have just one sequence so given an index within the
sequence so let's say our index IDX it computes the maximum profit that can be obtained
using the elements from IDX onwards so 31547 using all of these elements IDX onwards
the maximum profit that can be obtained right and using a given capacity so it will take an index
so it will take an index and a capacity so if let's say the IDX is 1 so it will then look at just
these elements and of the capacity 10 so it will try to fill the capacity of 10 and that's
a recursive function and why are we creating a recursive function like this there's a simple reason
now suppose IDX has the value 1 and the capacity 10 or let's say the capacity is 3 then the weight of
this element is greater than the capacity so that means it cannot show up it cannot be selected
because it cannot fit inside the back the nap site that we have so then the solution for this
sub problem with IDX equal to 1 and capacity equal to 3 is same as the solution for this
sub problem with this element removed because you cannot include this element within the nap sack right
so if you remove this element and simply consider these elements the remaining elements which
essentially means IDX plus 1 so max profit of IDX plus 1 profit of weight IDX plus 1 profit
IDX plus 1 and capacity is the answer or max profit of weight IDX profits IDX and capacity
because the current weight 5 is greater than the capacity 3 which is which the recursive function has been
so that's one option but the more general case is that you have enough capacity so let's say you
have a capacity of 10 recursion was called with the capacity of 10 and you are at IDX 1 so then
you have two choices either you include this element in your nap sack or you do not include
this element in your apps because you don't know whether the optimal solution will have this
element or not so you try both so there are two possibilities we either pick weights IDX this
element or we don't and what we can do is we can simply compute the result in both cases and pick
the maximum so if we don't pick weights IDX then once again if we don't pick this element so the
capacity remains the same let's say the capacity was 10 so we simply try out to fill out the
capacity of 10 using the remaining elements so we simply call max profit with weights IDX plus 1
or profits IDX plus 1 onwards and the remaining capacity which is 10 but if we do pick the
element if we pick the element and we had a capacity of 10 then the optimal then the solution the
best solution in this case will have a profit 3 more than the solution for this case and since we
also use some capacity so we need to add 3 in the profit and we need to subtract 5 from the
capacity right so if we pick weights IDX then the maximum profit for this case is profits of IDX plus
max profit of weights IDX plus 1 onwards profits IDX plus 1 onwards but because we've used up
some capacity we reduce the capacity in the recursive call okay and that is why a recursive call
takes both an index and a capacity okay I hope that makes sense secure the recursive 3 that tells
you the same thing we started the first index and we we have the capacity and if we don't pick
the first element then we simply the answer is simply the the best solution for second index
onwards with the same capacity if we do pick the first element then the answer is the second solution
onwards with the reduced capacity with the profit added okay and then we simply take the maximum
of these two cases so we call these two recursive calls and then we simply take the maximum of
these two cases to get back the final result or the final best answer and the final end case
is that if we reach the end if weights IDX onwards is empty if the index that we're tracking
has reached the very end then irrespective of what the capacity is the maximum profit is in that
case is zero so let's try and implement this now let's copy this over as the explanation
let's try and implement the solution let's say let's call it max profit recursive
and this is going to take a set of weights it is going to take a set of profits and it is going
to take a capacity and it's also going to take an index which the index will start out in zero
so now if the index is we start with the base case so if IDX equals the length of weights
in this case there's nothing left to do we simply return zero because we don't have any more
then we check if the weights IDX is so the current element is greater in weight than the
capacity then it's a pretty straightforward solution we simply return max profit recursive of
weights, profits, capacity plus one sorry capacity and IDX plus one so we simply ignore
this element because we cannot fit it in the capacity that we have else we have two options
we have option one option one is even though it can fit within the bag we don't take it
we every because the optimal solution may still not have it just because it fits does not mean
we should take it so we look at the option one which is once again the same as this where we
ignore this element and then we have we look at option two in option two we actually put this
element into the bag so since we are putting this element into the bag then we get we get profit
from it so we get profits of IDX and then we call max profits recursive and this time we call it with
weights and profits and now the capacity has reduced a little bit because we have taken this
element so now we can now we need to fill the remaining we fill the need to fill the bag with the
remaining elements from IDX plus one onwards with a limited capacity of capacity minus weights
of IDX and then finally we just put in IDX plus one so that we can start calculating the
solution from the next element onwards so that's max profit recursive again not very
difficult it is just about six seven lines of code and let's try it out here a test zero
let's try max profit recursive with test zero input
and we need to get weights capacity and profit all of these out of it the simple way to do that
is simply to put in star star and we'll get back all of these we'll get passed in capacity will
get passed as a capacity parameter in weights will get passed in the weights parameter in
profits as the profits parameter okay so we've encountered error and that's completely fine
completely fine to encounter an error I see so what we've done here is we have not really
taken the maximum of these two we've just defined the two options so we do need to take max of
option one an option two okay once again this is why helping test having test cases helps
and you can see they're now we call max profit and we can also add a timer here the max profit
it takes 210 microseconds but it results it returns the result 309 great we get back the result
309 here which is what we expected so our function is working correctly we can even evaluate it
on all the test cases so from joven dot python dsa we import evaluate test cases
and then we simply call evaluate test cases on all the inputs so we pass in max profit
recursive and then we pass in all the test cases as tests now you can see that we have these test
cases and each test case seems to be passing is fine all six test cases are passed and these are
the times they took so that's your recursive solution pretty straightforward once you reason it out
once you may be just look at an example draw tree of recursion yourself work it out on paper the
code is in fact in most cases fairly simple and this is what the recursion tree looks like each time
we make a choice to either include the element or not include the element and now you can reason
the complexity very easily because now we have n elements for each one we keep making this choice
so that means we end up with two to the power n leaves and from there it follows that the
complexity of the recursive algorithm is order of two to the power n right so it could be
two times or c times two to the power n but and in the bigger notation it's order of two to the
power of n so it is exponential and complexity and why is it exponential complexity once again
there are it's a possibility here that we may be computing a lot of things repeatedly because we
are creating so many of these sub problems so it's possible that we may be creating we may be
re-computing a lot of data here so now the task for you or the an exercise for you is to write
the memoized version of this so what is it that you need to memoize now the trick here is to
look at what is changing within the recursive calls so now in max profit recursive you can see that
weights and profits remains the same but it's a capacity and the idx that change so you can
take the capacity comma the index the idx as the key in your memoization dictionary and each time
you compute so each time let's say you compute this or you compute this or you compute this
store the result in the dictionary before returning it and then at the beginning of the recursive
function check within the dictionary if this value is already present okay so remember what we did
for longest common sub sequence we defined a recursive function internally we defined a memo
dictionary internally and the recursive function kept either checking the dictionary or filling the
dictionary if it could not find a value and that could eliminate a lot of the repeated work
in your problem okay so that's the challenge for you to try out implement the memoized solution
and what we do is we will go ahead and we will
implement the dynamic programming solution so let's just commit our work once again and we
analyze the algorithms complexity in recursion it's ordered to the power of n in memoization
now let's in exercise for you what do you think the complexity will be but let's apply dynamic
programming so let's look at a dynamic programming solution and once again for dynamic programming you
have to create a table you always almost always have to create a table for dynamic programming and in this
case we can see that there are any limits so there are n rows within the table because we have
n elements to choose from and we have a number of columns going from 0 to capacity plus 1 going from
0 to capacity and that's why there are total of capacity plus 1 columns and in fact what we can
do is we can also include another column at the top here another row at the top here which we have
not which is not shown here but what n represents an n is a number of elements so what n represents
or what the a particular element in the table represents so table of i comma c what it represents
is the maximum profit that can be obtained using the first i elements if the maximum capacity is c
so if your maximum capacity is c let's say your maximum capacity is 3 what is the maximum
profit that you can obtain using the first two elements so here let's say we are at this
position so using the first two elements of the list within this capacity okay so the first
two elements have weights 1 and 2 and the capacity is 3 so you can you can actually pick
sorry the first two elements have weights 2 and 3 and the capacity is 3 so you either pick
this element or pick this element now if you pick this element the profit is 1 and if you pick
this element the profit is 2 so the solution is to pick this element and you get you fill the
capacity 3 and you get a profit of 2 you cannot pick both because your capacity is 3 okay so that's
the logic here a very simple visual representation now remember that there will also be a 0
throw here which we have not shown but this is something that should be here another 0 throw
so the 0 throw represents that you have not picked any of the elements and if you don't pick any
of the elements it is simply going to contain all 0's and that's why it's not shown here the first
throw assumes that you have picked you can pick only the first element so you can you you can't pick
the first element telecapacity of 2 and then from a capacity of 2 onwards you pick the first
element and that has the maximum capacity of 1 the maximum profit of 1 the second row or the
row number 2 with row with index 2 represents the fact that you can pick both of these elements
and if you can pick both of these elements once again at capacity 0 none of them can be picked
at capacity 1 none of them can be picked at capacity 2 this element can be picked which has
a weight 2 and it gives you maximum profit of 1 at capacity 3 this element can also be picked
so now you have a choice to pick between the two of these so you might as well better pick this one
because this is going to give you higher profit and then finally when the capacity becomes 5
you can pick both of these elements and you can pick both of these elements and that is going to
give you a profit of 2 plus 1 3 and so on so you keep filling out the stable for each
step here or for each set of first eye elements you fill out the capacity table and then you
use the information to fill out the next row and the next column and so on and finally what we need
is using all the elements and using the maximum capacity that we have what is the maximum
profit that we can obtain so the last element of the table will give you the result okay so what
is the logic look like we will fill the table row by row and column by column now if table of i comma c
table of i comma c let's say this is a certain position here table of i comma c
can be filled using some values in the row above it okay now if you look at the table of i comma
seal you look at look at this element for example yeah let's look at this element here
so in here c has the value 3 and then i has the value 0 which is a row that is not shown
1 2 3 4 so i has the value 4 and c has the value 3 so if
yeah so if weights of i is greater than c so 0 1 2 3 4 if if this if this
weight so this weight of this element is greater than the capacity so the weight of this element is
4 it is greater than the capacity then this element cannot show up in this maximum profit
why because its weight is greater than the capacity so obviously it cannot show up in the maximum
profit now if it cannot show up in the maximum profit then this cell can be filled using the
value above it because in any case you cannot put in this element so you might as well get the
result by using the first three elements. And in that case, the value of this cell is obtained
from the value of the cell above it. That's one case. Now, on the other hand, let's come
here, you come to this case, to this cell in, to fill this cell, because you have a capacity
of four, you have the option of either choosing this element or of not choosing this element.
Now, if you do choose this element, let's say you choose this element with a capacity of four.
With the capacity of four, you get back a profit of nine. And now, you have no more capacity
left to create a to fill more elements. On the other hand, if you do not choose this element,
then that's the same as this value, because if you do not choose this element, then you have to fill
the capacity of four using the value of, using the first three elements. And that simply gives you
the same highest profit as the previous cell. So, you just consider these two cases,
whether we choose the element or we do not choose the element. Now, if, if you do not choose
the element, the value comes from above. If you choose the element, then the value comes from where,
let's see. If you choose the element, the profit of nine comes, and you fill all the capacity
four. So, you have no remaining capacity. But on the other hand, if the capacity was six,
and you choose the element, then you have chosen the element, and you've used up the capacity
four. So, you can still use the previous three elements to fill the remaining capacity, which is
six minus four, so which is your capacity of two. So, you can go back to the previous
row and check the capacity two. And see how much was a maximum profit that you can obtain with
capacity two. And it turns out that with capacity two, using the first three elements, you can
obtain a maximum profit of one. So, the maximum profit here, when you choose the element,
is nine plus one n. Similarly, here, a maximum profit that can be obtained, if you choose the
element is nine plus seven minus plus from the previous row, you pick the element with a capacity
seven minus four, which is three, so nine plus five fourteen. So, that's the logic here.
Sometimes you choose the element, sometimes you don't choose the element, and in fact,
the result of the cell is simply the maximum of either not choosing the elements, the maximum
of this cell or choosing the element, and subtracting the weight, which is six minus four, two.
So, maximum of this in that, okay. So, let's implement this same dynamic programming solution.
Once again, do work this out on paper. It really helps to work it out on paper.
Well, let's say we have max profit dp, the dynamic programming solution. We have weights,
we have profits, and we have a capacity. And then let's say n is lenn weights. So, we need to
create a table. So, this is our table. Our table contains n rows. So, we have lenn n. And then,
in for each of the rows, we contain, we have capacity plus one. Oh, we contain n plus one rows,
remember. So, we also want to consider the case where we don't consider, we don't take any of the
elements. And it is filled with zeros and the number of columns is capacity plus one.
To check the values from zero to capacity. So, that's a table right now.
You can check what this capacity looks like. Let's say n has nn capacity.
Have the values here n in this case is 5 and capacity is 10.
Oh, we don't need a lenn here.
We don't need a lenn here as well.
It's all perfectly natural to make these mistakes. This should be range, not lenn.
So, this should be a range and this should be a range too.
Yeah. Now, you can see that we have created n rows, n plus one rows. So, one for each of these and then
one more row above containing all which will contain all zeros. This is in the case where we
don't pick any of the elements. And then we've created 11 columns. So, this is for capacity zero.
So, again, the first column will also contain all zeros. And this is something that you will
often see in dynamic programming. You will have an additional row at the beginning or at the end
containing all zeros. And that is simply to make your calculations are computation easier. But
what that will lead to is off by one error. So, you need to be very careful while doing this.
And now, we'll fill out this value using either this value or by subtracting the weight of the
element that's here and getting a value from the previous row. So, now we start iterating. So,
when now we say for i in range n and for j in range c, let's just say for c in range capacity,
we should be capacity. Table of i comma c, and it's actually going to be i plus 1 and c plus 1
because we have these additional rows and columns. Table of i plus 1 comma c plus 1 is there are two
cases here. If weights of i is greater than c, the current capacity,
then we can simply look at the previous row. So, which is this case, let's say the weight 3 is greater
than the current capacity 2. So, then we simply copy over the value from the previous row,
the same column. So, we just say table of i comma c plus 1, we see. So, the capacity should
go from the value of 1 because we don't want to affect the first column. So, the capacity
goes from the value of 1 to a value of 10. So, capacity c goes from the range of 1 to capacity.
And if the weights i is greater than the capacity, then we cannot fill the table on the other hand
if it is, if it fits within the capacity, then we have two options. So, table of i plus 1 comma c
has two options. So, one is we don't use the current element, we don't use the current element
and that gives us table i c once again. The other option is we use the current element. So,
we get profit from the current element. So, profits i, but we do not get profits. But that reduces
the capacity. So, we then have to pick table of i. But now, we have to pick c minus weight
weights of i. Okay. And that should fill out the entire table pretty much.
That is a nice thing about dynamic programming. You simply just have to write this one
solution or this one recurrence and be careful about it. And everything else is taken care of
by this loop here. And now, we simply return table of minus 1 and minus 1.
And let's see if that works. It's likely that there are some issues here, but let's see.
We have test cases max profit dp with the tests that we have.
Great. So, we are seeing an issue already. I see here that
this should be range. And this should be range.
Okay. One thing that we haven't done here is
well, it seems like our solution is always zero.
Ah, this should be capacity plus 1. So, that we this takes all the values from zero to
capacity. Right. So, see the iterator should take the values from 1, 2, 3 for all the way up to
the maximum capacity. And the range does not end. So, the range does not include the end value.
So, you need to put capacity plus 1 here. Okay. Now, with that out of the way, you see once again,
these off by one errors are always going to bug you with dynamic programming.
I've probably solved 50 or 100 terms in dynamic programming, and I still make these errors.
But with that out of the way, you can see now that each of the test cases seems to pass.
Now, there may be other cases, which you have not accounted for. But overall, we've covered
all the test cases here. And we ended up with now a dynamic programming solution.
And I'll let you figure out the complexity here. But once again, it's pretty straightforward
because we are filling up this table. And filling up this table simply requires this constant amount
of work, which is a comparison and then potentially another comparison and an addition and a
subtraction. So, like four or five operations. So, you have this n times n, you have this n times
n times capacity where n is the length of weights and capacity or W is the total capacity.
So, n times W is the number of iterations and that really also is the complexity, the time
complexity of the algorithm. So, that's an abstract problem. And now, what you can do is try and figure
out not just what is the maximum value, but also figure out what are the actual elements that
you're using. Now, you can do this for the napsack problem and you can do this for the longest
subsequent problem. Figure out the actual longest subsequent and it should be possible to do that
with just a small modification. Now, use the forum. If you have any questions about the contents
of this lecture, go back to the lesson page and open up the course community forum here.
You can see here that this is the lesson for recursion and dynamic programming lesson. You can
post your question here and you can also discuss ideas on how to figure out what the longest
common sequence is and what the best selection for the napsack problem is. So, what you do next?
Well, you can review the lecture video and execute the Jupiter notebook. The next step is also to
complete the assignment. Now, we have released assignments 1 and 2 so far. If you go back on the
lesson page, you will find lessons, you will find assignments 1 and 2 and you can walk on them.
There is sufficient time and also work on optional questions and do participate in forum discussions
and if possible join or start a study group too. That's a great way to stay motivated.
This was lesson 4 of data structures and algorithms in Python. Thanks and talk to you soon.
Hello and welcome to data structures and algorithms in Python. This is an online certification course
being conducted by Joven. Today we are on lesson 5 graph algorithms like BFS, DFS and short
dispots. My name is Akash and I am your instructor. You can find me on Twitter on at Akash and
it's. If you follow along with the scores and complete all the assignments and build a course
project, you can earn a verified certificate of accomplishment for this course.
So with that, let's get started. The first thing we will do is go to the course website,
ithendasay.com. You can point your browser to pythondsay.com to open up the course page
and on the course page you can enroll for the course and you can view all the previous lessons
and assignments. So do check it out and do check out the course project as well. But for now,
we will open up lesson 5, graph algorithms. Now on this page, you can watch a video for the lesson
later, the same video that you're watching right now and you can also catch a Hindi version of
your wish and here is the code that we are going to use today. The first notebook under the
heading notebooks. So let's open it up and this is a Jupiter notebook hosted on Joven. You should
be familiar with it by now but here you can see that there are some explanations and then there are
some code cells where we can write some code. You can see that there's some code here.
Now to actually execute and edit this code, we will need to run this notebook.
You can find the instructions to run the notebook right here. But the simplest way to do it is to click
run and select run on binder. Now this will take a second or two but this will take your Jupiter
notebook and create a new machine in the cloud and send your Jupiter notebook to that machine
for execution. This is a free service that you can access via Joven.
You can also run the notebook on your own computer directly if you wish before that you can check
the run locally option here. So our Jupiter notebook server is now ready. So we can now
start editing and writing some code. Let's just go full screen here.
Okay so the topic today is graph algorithms, BFS, DFS and shortest parts using Python.
Now before we talk about graph algorithms, let's just try to understand intuitively what graphs are.
Now here's an example of a graph in the real world. So this is the real way map of India. You can
see here all the train stations that you have in India. They're represented using these black dots
points. They're also labeled. So each train station points with city or a village.
So all these are also labeled. And then you can see connections between these stations. So these are
as you might guess railway lines and you'll see that there are three or four colors involved.
These colors could represent different types of railway lines like different gauge,
meter gauge, broad gauge, et cetera. Or these could represent different zones.
So there's some information contained in the connections as well. Now another important thing
is that each railway line between two cities will also have a certain length.
So that's what a graph is roughly. And the kind of questions that you may want to ask here is
for example, is there a path from New Delhi to either a path? So given this information first
of all the questions, how do you even represent all this information? Because you have so many
railway lines connections between different cities, so many hundreds of cities. How do you even
represent this? So that you can start writing algorithms to answer these questions. So if you're
building a search, a train search website, then you would have to answer given New Delhi and
Hyderabad, is there a way to get from New Delhi to Hyderabad? Okay, that's the first question that
you might ask. Now if there is a way, then the next question might be that what is the path
with the shortest number of stops? So do you go this way for the shortest number of stops?
Do you go this way or do you go this way? Another question could be what is the path with the shortest
distance, right? So sometimes if you measure the distance and if you measure the number of stations,
the number of stops, they may be different along different paths and one may be greater than the
other in certain cases. So those are the kind of questions that we want to ask and answer today.
Another question could be what are all the stations reachable from New Delhi within one stop
or two stops or three stops or ten stops? So those are the kind of questions we'll try and answer.
And for that we need a way to represent graphs in a more abstract fashion because the same
question can be asked in a different context. For instance here we are looking at flight routes,
international flight routes. Now once again you can ask the exact same thing here.
Is there a way to get from New Delhi to Vancouver? Now if there is, then how many stops will that
require? What is the minimum number of stops we can take to get from New Delhi to Vancouver?
Or what is the minimum time it might take? Maybe if you're okay with taking multiple stops,
but you want to minimize the, the time taken of the distance traveled because you're concerned
with the miles for some reason. Another thing you could ask is what is the minimum cost if there
is a cost along each route? Okay. Now here's one more example from a very different domain.
This is hyperlinks or the internet essentially. So you can see here you have a whole bunch of websites
and you have links on websites. Now links on websites point to other websites and in this case
it is a one way connection. You can see that from this particular course website we have a link to IBM
but from IBM you may not have a link to this course website. Now that's an interesting thing
that's a slight variation here and this is called a directed graph because each
connection here is has a particular direction. Now this is again interesting to ask
is there a way to navigate from cs.umast.edu to ithaka weather? If there is what is the shortest way?
What do, what does that path look like? So those are the kind of questions that we want to answer
and to do that we will need a more abstract representation of graphs and we will start with
the simplest possible representation where you have certain points or what we will call
nodes or vertices. So these are two terms that are used for these points. So nodes or vertices
or graph has certain nodes or vertices and just to make things easy these could be cities
of these could be web pages or these could be something else but just to make things easy what we
do is we will number the nodes. So in our graph if we have 10 nodes then we will number the
nodes from 0 to 9. Okay this is and they can be numbered completely arbitrarily. There's no
reason to name name number the 0 or number this one. What's more important is that we should use up
all the all the numbers from 0 to n minus 1 if we are dealing with n nodes. Now why do we do that?
We will see in a moment when we try to represent graphs using certain data structures like
adjacent adjacency list etc but we want a number or nodes from 0 to n minus 1 and this number
is arbitrary this one doesn't represent anything in the sense that one being greater than 0 or so on.
So these nodes have labels and then you have edges between nodes. So an edge is simply a pair
an edge is simply something like 1 comma 2. So a pair 1 comma 2 tells you that there is an edge
between the node 1 and node 2. Now as we move forward we will also store some information within an edge
and we will call that weight of an edge and we will also later look at directed edges and those
will get us directed graphs but let's start with this and let's see how we can now represent
with this basic structure how we can represent a graph. So we can represent a graph using two
variables. So one is called a number of nodes and the number of nodes is in this case
five and then we can represent the edges using a list of pairs.
So in this case the pairs are 0 comma 1. In this case the pairs are 0 comma 1 that's an edge
then 0 comma 4 that's an edge 2 then we have 1 comma 2 so 1 is connected to 2 and the edge
in this case is by directional. So when we're saying 0 comma 1 we're saying it automatically says
that 1 and 0 are also connected right. So 1 comma 2 and then we have 2 comma 3 and which order we
write these in doesn't matter we could have just written 3 comma 2 here as well.
Or we also have 1 comma 3 and then we have 1 comma 4 and then finally
we have 3 comma 4. So this is how we represent this data structure which what we've drawn here
is now represented in code using these two variables and we can check here if we simply print
the number of nodes and the length of edges we can verify if this is roughly correct. So you see
we have five nodes and we have 1, 2, 3, 4, 5, 6, 7 edges. Okay, seems right to me we could there
may be a mistake here but roughly we have set things up correctly. Okay.
Now the question becomes is this representation good enough. Now this representation is good enough
if you want to convey the structure of a graph to someone I could give you these two variables
and then without showing you this image and you could use this information to draw the graph
on a piece of paper. So this representation is complete it provides all the information about
the graph but it may not be efficient. For example if you want to find out which nodes
the node 1 is connected to we would have to iterate over the entire list of edges we would have to
go through this one and then check if either of these is one and check if either of these is one
and so on. So that makes it very tricky to access any information efficiently.
Rather it will be much nicer to just look at a list of nodes that one is connected to in some
way and go from there. Now if you want to find a shortest path we would first have to find all the
nodes that one is connected to and then for each of those we would have to find their neighbors
and then for each of those we would find have to find their neighbors and so on. So it would get pretty
tedious to go through the list so many times. So that's why and by the way by a neighbor we
represent we mean two nodes that are connected by an h. So 0 and 1 are neighbors but 0 and 2 are
not neighbors. So that's a very simple nomenclature that we can use and what we can say is if we
track the path we say 0 1 2 and there if there is an h between both of them we say that 0 1 2 is a path.
So 0 1 2 in this case is a path but 3 0 1 is not a path because there is no path but
there's no edge between 3 and 0. Okay. Now we'll see what what we mean by show of paths and neighbors
and so on in some time. But to work with graphs more efficiently we will represent them using
what's called an adjacency list. Now the name it explains what it contains. So the adjacency list
contains a list or each node and it contains a list of all the nodes that are adjacent to
that node. Now again adjacency is the same as an adjacent to same as neighbor. So if for each
node so for example for the node 0 we maintain a list and that list contains the numbers 1 and 4
indicating that 0 is adjacent to or 0 is a neighbor of or 0 is connected via a direct edge to 1 and 4.
So that's why you have 1 and 4 here and then 1 is connected to 0 2 3 and 4. You can see that 1 is
connected to 0 2 3 and 4. Similarly 2 is connected to 1 and 3. Please connected to 1 2 4
and 4 is connected to 0 1 3. Now this is more convenient for sure 1 because since this is and
this is a list. If you want to find let's say which nodes 2 is connected to we can directly
access the index 2 within the list and this is why we number the vertices or the number the nodes
from 0 to n minus 1 so that we can access them directly in an adjacency list. Right so we directly
access the numbers to our next two and so we have 1 and 3 here. So that's what makes it convenient
and one important thing to notice here is that edges each edge goes twice. So the edge 0 1
shows up in the list for 0 you can see here in the list for 0 we have 1 and similarly in the
list for 1 we have 0. So each edge shows up in 2 adjacency list of each of the nodes that it connects.
So now the obvious next question might be to create a class to represent a graph as an adjacency list
in Python. Okay this is again a question that you might get asked a step or this might be part of
another question that you may get asked where you're asked to perform a breadth first search or depth
first search or find the shortest path or the first step you'll have to do is define a class for a graph
to maintain the information about the graph as the adjacency list. Okay so here we're creating a class
graph and the first thing we'll need inside the graph is a constructor function.
So we need to put something inside the constructor function and we know that the first argument to
any graph any class method and Python is self which represents the object that will get created
ultimately when we create an object of the class but apart from this what information do you need to
create a graph? Now it's pretty straightforward we can simply work with this information because
these two variables together specify the graph completely. So let's simply accept
norm nodes and a list of edges as the information. The first thing we can do is simply store
norm nodes in self dot norm nodes so that once we create a graph we can access the number of nodes
very easily then we need to create the adjacency list. So we need to create the adjacency list
we'll call it self dot data and initially we will create a list containing empty lists because
and then we will fill out the empty list step by step. So what we need is something like this in this
case because there are five because there are five nodes so this is what we need to create
the five empty lists. Now in general the way to create repeated elements is this you can say
if you want to create a repeated element like this zero times a you type zero times 10 and that
gives you this list zero zero or containing all zeroes. On the other hand if you create empty list
times 10 let's call this L1 let's see what L1 is. It looks like you've got in an empty list
or you've got in a list containing 10 empty lists but let's just go into the first element.
So the first element is this first empty list and inside the first element let us add the value one.
Okay and then let's look at the let's look at the list L1 once again and you see what happens.
This one gets inserted into all of these lists. Now what's the problem here?
Now the problem here is that when we do this when we create a list containing an empty list or
containing any object then the same object gets replicated 10 times but python does not create
copies. Now when you're working with numbers it's fine because when you're working with let's say
the number zero that's fine because there's no internal structure inside zero right so there's nothing
you can change inside the zero it's a fixed value fixed immutable value. So what so you can you
can't really say L1 of zero and change its value internally what do you induce you can set L1 of zero
to another value let's say you can set L1 of zero to one. So instead of getting all zeros you get
but you cannot take the zero and change something inside it. On the other hand when you have an
empty list here so this is the same list that is showing up in 10 different showing up 10 different
times each of the elements in the list. Outer list is simply a pointer to this same empty list.
So what we can do is since we can go inside this empty list and append something to it so since
this is the same object that we are seeing over and over. The one gets appended to the first list
and because the rest of them are the same object we get back all once inside here okay so this
is the reason we're spending time here is because there's a common bug that you may unintentionally
execute whenever you want to create an list of empty lists do not use this method. So what's
a method you should use then. So here's one method you can use let's say you want to create a list
of empty list of size 10. So you may be familiar with this object this this object called range
this function called range what this does is if you view it as a list you can see that it contains
all the elements from zero to nine okay now if you view the range itself it simply shows you zero to 10
but when you convert it into a list you can see that internally it contains the values zero to nine okay
so you can take this range and you can do something like this put this range or put anything which
is iterable inside these brackets the list brackets and then say for x in range and simply put x
so what did that do that did practically nothing we simply took x from the range of of zero to 10
and returned x itself so we created a new list like this but suppose we multiplied it by two here
x by two so for each element in the range we are multiplying it by two and so we get back a new
list which is zero to four six eight so this is each element is the double of the elements that we
have in the range now what we need is we need just empty lists right so we can simply put an
empty list here and we can ignore this value x that we get here so now we get back a whole bunch of
empty lists so let's call this L2 and what we're now doing is for each element in the range
we are creating a new empty list so this is important so now when you do L2 0 dot append 1 and then
check L2 you can see that one was only inserted inside the first list okay so keep keep out
watch out for this this is something that you will probably go wrong with at some point I've gone wrong many
times and one last change we can make here is whenever you're not using a variable in Python it's always
a good idea to just call it underscore you can still call it x but you're sometimes somebody reading
your code may not understand why you have declared a variable and not used it and assume that
maybe you've made a mistake so just to make things very clear it's always a good idea to make
something underscore it's also a variable name a valid name and mark something is underscore if
it is not being used okay so with that whole discussion about lists we now know how to create a
list of empty lists so here you have a list of empty lists or underscore in range number nodes so
now we have created a list of empty lists then for each edge in edges we need to do something so
we need to insert it into the right lists okay now what is for edge in edges look like so let's see
for edge in edges print edge okay each edge is a pair we already know that and when you have pairs
or tuples here you can get them get the values out so let's say let's call them n1 and n2
node 1 and node 2 you can get the values n1 and n2 out like this so now we can say print n1 and print n2
you can see that we are able to get values n1 and n2 out directly within the following so let's
call this n1 and n2 and now this is a much more pithonic way of writing code so one of the things that
we are also learning is how to write code which is more pithonic or which is idiomatic in python
and this is again something that will impress people when you use it in an interview or a coding
challenge so for n1 and n2 and edges what we need to do is first we get self-to-of-date of n1 so this
gives us the adjacency list for n1 the first node and here we append the value n2 and similarly we do
the same for n2 and we append n1 to it and that's it now we've set up the graph let's create a graph g1
let's call this graph 1 maybe and we simply invoke the graph function and then we give it a number of
nodes and the edges right so remember self will be passed in by python automatically as the object that
is getting created so the graph 1 object essentially so now the number of nodes is 5 and we have a
list of edges and let's see what graph 1 dot data looks like so there you go you can see that 0 is
connected to 1 and 4 and 1 is connected to 0 2 3 4 and so on now while this is okay it would be nicer
to print it like this so maybe let's see if we can print it like this and the way to do that
is to define a wrapper function so we define a function called underscore underscore rpr and it contains
it simply takes self as the input and what we are going to do is we are going to go over
we are going to call enumerate on self dot data what does that give us let's just check what enumerate
on self dot data give us gives us well maybe before we do that let's see what enumerate on a list gives us
enumerate on a list gives us this object but let's just get the value out of it in a for loop
because you can use an enumerate in a for loop and just print x so what enumerate gives us is it gives us
the values from the list but apart from those values it also gives us indices okay so you can get
an index i and a value v out of enumerate so then you can see that you can print both i and v here
and you will get back the same output okay so what we can say is we can do enumerate self dot data
now because self dot data contains these elements so what we will get back is we will get back pairs
let's see here
we will get back pairs 0 comma 1 4 1 comma 0 2 3 4 2 comma 2 1 3 now this is starting to look a lot like
what we want okay so we'll just take enumerate self data and these will take these pairs so the pairs
will be a node so node n and it's neighbors so we have the node n so the node n will first be 0
and it's neighbors will be 1 and 4 node n will be 1 and it's neighbor will be 1 and 2 and so on
and then so for n comma neighbors in enumerate what we'll do is we simply create a simple string
and here we're using string formatting we simply creating this string
where we put this here we place a placeholder where we put n and then here we put a placeholder
where we put neighbors again let's just see what that looks like and this is the best thing about
Jupiter while you're writing code you can test your code right then in there simply by creating
putting data into a new into a new cell so let's see graph 1 dot data you can see here that now
we have now we've converted that enumerated list into a list of strings so we have a string here
this is the string 0 pointing to 1 comma 4 this is the string 1 point this is 1 pointing to 0
to 3 4 and so on but this is still a list of strings what we need to return from the
rapper function is a single string so the way to join them together whenever you have a list of
strings and you want to join them together all you need to do is you say what you want to join them
with so we want to join them with a new line and then call the join function on that string
and return that right so that is our rapper function and we'll see it's uses in just a moment
as similarly we have another function called STR now rapper is used when we simply type graph 1
so when we type graph 1 this is the output of the default rapper function
now this will get replaced by the rapper function that we are defining but when we do STR of
graph 1 or when we do print of graph 1 or when we insert graph 1 into a string that is when the
STR function is used now we will simply use a rapper representation so let's just put
self dot underscore underscore rpr and that's it okay so let's see now let's put
let's type graph 1 here and you can see that now we have this representation printed using this
rapper function that we've defined so we have 0 1 4 1 0 2 3 4 2 connected to 1 3
connected to 1 2 4 and 4 connected to 0 1 and 3 okay so now we have a graph data structure
that we've implemented using a class so the adjacency list and we have a nice way to print it out
and this is just good programming practice now you don't have to do this in a coding competition
or you don't have to do this it's good if you do it in an interview if let's say you're able
to type this out quickly but when you are working or when you are working on your own problems
or on your own code or on a project always make sure that any classes you define have a good
string representation so that when you type the name of a variable you understand what it
represents and you don't have to spend time thinking about it make it clear to yourself okay so that's
the adjacency list and we'll see how that is useful in just a few moments
but here are a couple of questions for you try writing a function to add an edge to a graph
that is represented as an adjacency list okay so here we've specified all the edges right in the
beginning but can you write a function add edge which takes a couple of nodes and it inserts an edge
between those two nodes and here's a hint this code might be useful so do try that out
now here's another one can you write a function to remove an edge from a graph
which is represented as an adjacency list here you may have to use the list remove functions
to remove a particular element from a list but these are two good exercises to
complete here okay now before we continue let's just save our work and we know that this
notebook is running on binder which is a free service so we'll just save our work by running
joven.com it and what that will do is that will capture a snapshot of this notebook all the
changes that you've made and put this on your joven profile now this will go on your joven profile
from where you can continue running it continue executing it from where you have left off okay
now another common representation for graphs is called the adjacency matrix which is slightly
different from adjacency lists in this case for example the same graph here is represented using
this matrix so what we do is we create a matrix of size n by n if n is the number of if n is the
number of nodes in the graph and then for each node for instance since we have zero and since we have
a edge between one and two so if you take the first row row number one and column number two
you put a one there otherwise if there's no edge for example there's no edge between zero and two
you take the zero through and column number two the you put a zero there okay so you put a one
wherever there is an edge between the two nodes and you put a zero wherever there isn't you can see
that there is this reflexive property here because one two is one and two one is also one because
these are undirected edges now of course if this is a directed graph this would be different
so an exercise for you once again is to represent a graph as an adjacency matrix in python
shouldn't be too hard all you have to do is instead of so an adjacency list we initialized a
list of empty lists here you may want to initialize a list of zeros a list containing lists
of zeros okay and then you may simply just want to fill in the zero once in the right places
now adjacency matrices have their own benefits sometimes they are more useful for example
when you want to immediately check if there is an edge between two vertices or two nodes you can
quickly look up look it up in the adjacency matrix but in the adjacency list you will have to
get the list for one of them and then search through that list which is fine for most cases but
in some cases you may just want an adjacency matrix as well so that's one other way you can
represent a graph and that's an exercise for you okay now we know we've represented graphs
and now we can start looking at some graph algorithms and probably the most common graph
algorithm something that you will ultimately get asked in one interview or the other if you're
interviewing with a bunch of companies is breadth first search and breadth first search well this is what
suppose you have this this is a real world graph that we're looking at so these are cities in
Germany and you can see that there are roads between these cities and we have lengths of each road
now we can ignore the lengths for now what's important is that these cities are connected to each other
but not all cities are connected to all of them all of the others so starting from Frankfurt
you may want to find out which are the cities that are that you can reach from Frankfurt
without stopping so which are the cities that are one edge away from Frankfurt and if you look at
it this way it turns out that manheim castle and wasberg are the three cities that are one edge
away from Frankfurt it's so if you start drawing the street of sorts so you will find that manheim
wasberg and castle are one edge away okay then you might ask which are the cities which are two
edges away from Frankfurt so now the cities that are manheim is connected to calls through and wasberg
is connected to these two cities and then castle is connected to this city okay so here you have
these other cities then you might ask which are the cities that are three steps away from the
from Frankfurt and that would be the remaining two cities Augsburg instead start good okay
now let you think about this but what you will find in this way as you go step by step by step
like first you're finding all the cities that are one step away so all the nodes that are one step
away from a source node then you're finding all the nodes that are two steps away from a source
what this will give you is ultimately you will end up for each node you will find out
how far away it is from the source and that will be the length of the shortest path between the two
okay and you can verify that I'll let you think about it for instance if you see you can go to
castle by going this way from wasberg to no knownberg to mention to castle
but that would not be the shortest path but binary search the this is called bread first search
bread first search will always discover the shortest path because we first finding all the nodes at
distance one and then we're finding all the nodes at distance two and then we're finding all the
nodes at distance three and if a node at distance three has a shorter path then it would have been
already found when we're finding nodes of length one or two or distance one or two okay
so that's bread first search so here's one problem that you might
face in an interview implement bread first search given a source node in a graph using python
and here is some pseudocode this is so it's always a good idea to write or explain your approach
in plain English before you implement it so that you do not make mistakes while coding
so here for is the pseudocode so if you have to write a function bfs which takes a graph
and a root or a source node so first we say create a queue so we create and this is taken by
from wikipeedia so first we create a queue and what's a queue well a queue is a very simple data
structure a queue is simply a list and it follows a first in first out policy so when you have
a list and you want to add something into a queue it's also called nq the nq operation so when you
want to add something into a queue you add it at the end okay so you have a list and then you simply
keep adding things at the end you just append things at the end of a list but when you want to
access something from a queue you do not access any value directly no you always access the first
available value okay you access the first available value in this case what what is called the
value in front and when you access a value it gets removed okay so in this way you can see that
it implements the first in first out policy like if first we nq 1 and then we nq 3 and then we
own q 4 and then we want to dq and when we want to dq we simply get the first value that was inserted
which is 1 then maybe we nq a few more numbers 5 to 7 then we dq and then we get back the first
that we had inserted which is not yet dq so then we get back or whatever was the second value
inserted initially right so that's a queue and we let's see how a queue is useful so we create a queue
and then we mark the label we mark root we label the root node as discovered okay so we need
to somehow track which nodes have been discovered or visited and first what we do is we will mark
the root node so let's say we starting from the node 3 we will mark 3 as discovered so 3 is now
discovered and as soon as we mark something is discovered we will nq it okay then while the queue is not
empty which is while we have not accessed all the elements in the queue or while we have not
dqed all the elements from the queue we dq and element so we dq the first element which has not yet
been removed from the queue and if we are looking for a particular goal node then we can simply end
there like we found that node but we are not looking for a goal node so let's remove this code
yeah so we get we get the first element or the first node from the queue which is not yet
and then so for example initially we just have 3 in the queue so then we get back 3 we get 3
back from the queue then we check all the edges for 3 so we check that 3 is connected to 1
and 3 is connected to 2 and 3 is connected to 4 so we see all the edges for 3
and if the other end of the edge we check for each node let's say the other end of this
we check if 2 is not yet discovered or not yet visited then we nqed 2 into the queue
similarly we check for 1 and if 1 is not yet already discovered we nq1 into the list
similarly for 4 we nq4 into the list okay so we have dq3 so 3 is no longer in the queue
or we've moved forward we no longer going to get q3 out of the queue but now we when q2 1 and 4
and 2 1 and 4 we now understand they are at distance 1 so when we pick the next element of the queue
we dq the next element the first in first and first out we get back 2 and then
we mark 2 as visited great now we visited 2
oh no we we mark as soon as we are adding something to a queue we also mark them as visited
because we've identified that 2 1 and 4 are all at distance 1 from 3 and we've added them to a
queue so we mark them as visited now when we get 2 out of the queue in the next iteration
we check if there are any nodes which 2 is connected to those are not yet visited so 2 is connected
to 1 but 1 is already visited so there's no need to nq it again and then 2 is connected to 3
but 3 is already visited so there's no need to nq it again and so we just move forward
then we go to 1 and when we go to 1 we realize that 0 is not yet visited so we nq0
4 is visited so we don't nq4 okay and that's how we proceed so now what you should do is you should
draw this on a piece of paper and work it out just write on a piece of paper what would be the
first element that gets inserted and what will be the elements that we will insert into a queue
etc etc but this is the algorithm here exactly what we what we just discovered so we deq in
a vertex or all the edges that start from the vertex we or the node we if the other end of the edge
is not labeled as discovered then mark it as discovered and nq it into the queue okay let's implement
this let's see if we can implement this life so we are implementing bfs where we will get a graph
and a source node the first thing we need to set up is a queue so the queue is empty then we set up
discovered and discovered will be false initially and it will have the length so we want to market
false for all the elements okay and remember now we can use this notation here because false is an
immutable value so it doesn't matter so we don't really need to use the range or the list comprehension
notation here then here let's come here so we mark the label root as discovered
so discovered of source let's just call it root so that we don't get confused with the terminology
so we mark discovered of root as root great then we insert or we nq the root so we type q dot append
now nq simply means adding something to the end and you know how to do that in a list you simply
call q dot append so q dot append root great now python list by default do not support a dq operation
so what we will do is we will set up an index which will track the first available element in the
queue okay so whenever we decrease an element we will increase the index so that we move forward
so here we have the index idx equal to zero so now while there are elements in the queue which means
while the next available index is less than the length of the queue
first we will get the current we will decrease so dqing simply means getting the first in
element the element that was most recently inserted and has not been deqed so we get current
is q of idx and then we can also increase idx so as soon as we decrease something we update the index
so you can imagine that the index starts out here and when we deq this or delete this then we get
that value out and then we update the index to the next position okay so now we have the current
so there's a dq operation then what do we have next now we want to check all the edges
of current right so we are going to say for so remember we have the adjacency list representation
so we will get for node in self dot data current so self dot data current contains a list of
all the nodes that are connected with the current node so for node in self dot data current
if not discovered node so if you have not yet discovered the node then we first marketed this as
discovered and then we added to the queue so we do queue dot append node okay so what you end up with
this way is first you have the source that got added to queue and then we inserted all the nodes
which were at a distance one from source and then we insert then if you follow the trajectory will
see that we will insert all the nodes that are at a distance two from queue and so on right so
ultimately when we end up with the entire process we will have the queue and the queue will contain
the list of nodes as they would be visited in a binary in a breadth first search okay so we can
simply return the queue here so let's try it out so we have graph one and let's call bfs
and graph one is this graph so let's grab this image as well
let's simply copy the code for the image and come down here
let's come down here and put the image here okay let's call bfs on graph one starting at the node three
okay of course this should be called graph dot data
so because graph is the graph that we're working with so we need to check graph dot data here
okay so we start out with the node three and you can see that three first causes one two and four
to get inserted and then one causes two to get inserted okay now that's bfs view it's pretty much
done at this point but what would also be helpful is maybe to keep track of what is the distance
of each node right so we can also track we can also keep track of a distance so let's say we
have a distance which we initially set to none or yeah which we initially set to none
and we will track a distance for for formation for each node so we have the distance here
and initially we are going to set the distance for the root to zero of course because the
root is at zero distance from itself and the distance here means the number of edges right
then when something is discovered so when we are discovering a node and that node was not already
previously discovered that means that the distance for that node is one more than the distance for
the current node which caused it to be discovered right so the distance for so for example if you're
starting with three the distance for one is one more than three which caused one to be discovered
and the distance for zero is going to be one more than one which caused zero to be discovered
so that's the distance great we've now also track the distance one other thing that would be nice to
have is what is called the parent if you see if you go back here you can see that it would be nice to
know what led to calls through being discovered was it manheim wasberg or castle so that we can
work our way backwards and find a path from Frankfurt to calls room okay so for that what we can
do is we can keep track of a dictionary of a list called parent once again we will have no
parents by default so parent none and whenever we find a node and that node was not already discovered
then we can set the parent of that node to the current node which caused it to be discovered okay
and now we can return from the queue the distance and the parent let's see if that works okay so it
seems like now we have these are this is the this is the order in which the nodes are being visited
you can see that three is the first node to be visited and three has and if you want to check the
distance of three you can see that the distance of three is zero so this is distance is given
in the order of the nodes in the order of the original numbering of the nodes so you can see that
three is at a distance zero from itself obviously then you have one two and four now if you want to
check the distance of one just check the index number one here so one is at a distance one if you
want to check the distance of two now that is at a distance of one as well you can check here and then
you want to check the distance of four four is also at a distance of one right so all of these
one two and four are at a distance of one from the root node three and also you can see here the
the parent of one remember these are zero one two three four these are the indices of the nodes
so the parent of one is three and the parent of two is three as well and the parent of four is three
three itself does not have a parent that's why this is none and finally the last node we visit is zero
and it is at a distance two you can see it is the distance here is indeed the highest and the parent
for zero is one right so because one was the first node that caused zero to be visited that could have
been four two but in this case just how we implemented it one was the first node which caused it to
be visited so one is the parent of zero so if you now want to find the path from three to zero
you can look at the parent of zero that would be one and then you can look at the parent of one
that would be three and we're done so we can work backwards from the target we can keep checking the
parent of the parent of the target and that will give us the entire path so now we have the path
we have the distance and we have the order in which these nodes will be visited so you may get us
bread for search in all these different variations but roughly this is what the code looks like
and you can see here that the code is not too long now we have created all these additional
additional lists but you don't really need them so the code is about 15 lines of code 10 to 15
12 to 15 lines of code not more than that so that's BFS again if you're working on a BFS problem
it always helps to first state it in simple words and work it out with an example and then start
coding so that you do not make mistakes while coding now one question that you can work on is to
check if all the nodes in a graph are connected this may not always be the keys for example here
you can see that all the nodes in the graph are connected but sometimes you may have a situation where
some nodes are not connected for instance if these edges one one two and three to one present
then two would not be connected to zero and maybe two is connected to five and six etc so here is one
graph where not all the nodes are connected to each other you can see that there are nine nodes but
there are only eight edges and if you look carefully you will see that zero one two three zero one
two three are connected but there is no connection from these nodes to four so four five six are
then connected separately and then seven eight are connected to each other but not to one another right
so can you use breadth first search to determine if all the nodes in a graph are connected
I would reckon yes look at this queue now this queue gives you all the nodes
that starting from the source node are connected to the source node by zero one two three or so many steps
if something is not connected it will not show up in the queue so you can simply check the length
of the queue and see if that is less than the total number of nodes and then use that to
determine if all the nodes are connected or not now another related question that you may get
asked is to find the number of connected components in the graph now what's a connected component
if you take a set of nodes that's connected that's one component and if you remove that then you
look at the next set of nodes that's connected that's two components if you remove that then you
take the next set of nodes that connected that are connected that and that gives you the
third connected component and so on so in this case for example you have this is one connected
component you can check by drawing the graph and then this would be one connected component
and then these would form one connected component so zero one two three would be one connected
component four five six would be another and seven eight would be another can you find the number
of connected components or even can you list all the connected components of a graph using
BFS yes you can again a very simple way to do it is just pick the first node perform BFS from
the first node that gives you the connected component that contains the first node then find
the first index which is the first node which is not yet visited start BFS from that node
now that will give you the connected component for the second node and then find and then keep
doing keep repeating this till all the nodes have been visited okay that's another question that
you might get find the number of connected components or find a list all the connected components
in a graph so BFS is a very versatile algorithm that can be applied to solve pretty much
most graph problems that you may get asked in an interview so do do work on a few BFS problems
and get some practice with it okay now another way to work through a graph to look through a
graph is what is called DFS and this is the way in which you would normally explore a means
when you start out in one direction and then keep going so for example we started out here and
then we kept going till we hit and end right so you can see here that we kept going until we hit
and end and then we turn back and then we try to next path and then we turn back and try the next
path and so on so we go like this then we turn back we try 5 go like this turn back we try 8 then we
turn back try 9 10 okay that's another way to go about it and it's some cases in some cases BFS
makes more sense in some cases DFS makes more sense and you can in most cases both of them work just
fine for most problems so you can implement either one when you are faced with a graph problem
so let's implement DFS or depth first search okay now here is a depth first search it's
pretty straightforward you have you pick a node and then you pursue the the node and then you
next node then the next node and so on among the edges you pick one node and then once once you've
exhausted the path along one edge you come back and try the next edge and then you come back and
try the next edge so there are two ways to write it there is a way to write it recursively
and then there is a way to write it without recursion and I leave it as an exercise for you to
write it recursively but what we do is we will write it without recursion and you write without recursion
we will use something called a stack we will use a stack and a stack is another data structure
very simple list like data structure but it's just like a queue but it's different instead of
being first and first out which is what we do in a queue in a stack we perform last in first out
so here's how it works you start with an empty stack so you can think of it like this container
our cookie jar and you start putting in things into that jar you put in one and you put in two
and you put in three so now when you have to remove an element from the stack or you want to access
an element from the stack the only element that you can access is the element that was inserted
most recently so last in first out that's a stack how is that going to be useful it's pretty
straight forward if you think about it because this node when you start from this as the source
you will add all these three into the let's say you add these three into the stack now if you add
these three let's add them in this order so you start with this node then you add this this and
this so you add these three into the stack then the last in a value was two okay so then what you do
is you extract two out and then you insert everything that two is connected to into the stack so you insert
three into the stack and then you the last in value was three so you insert you take out three
and then you insert four into the stack then the last in value was four then you take out four
and you have nothing left to insert so now this entire path has been exhausted so then you end up with
five now when you end up with five you can insert its neighbors eight and six into the stack
and once six gets inserted into the stack then you take out six and you put seven into the stack
and so on right so you can see how depth first search is working using a stack and roughly this is
what the procedure the process looks like you start a stack it's empty push push the current
source let's say the root node what which you are starting with push the root into the stack
now while the stack is not empty pop the stack so get the last in value from the stack
and that gets removed as soon as we call pop then if that node is not already discovered
then we mark it as discovered and then for all the edges from v to w so for all of its neighbors
we simply push them into the stack right so that's it that's all we're doing all of its
neighbors which are not already visited we can simply push them into the stack okay so let's do that
let's implement dfs and once again we will keep this picture in mind so let me just grab this picture
here as well this is one of the nice things about Jupiter that you can take these images
and simply include them within your Jupiter notebook while coding so that you don't make any
mistakes so let's say we're writing defined dfs and once again let's assume that we are going to start from three
and this picture is graph one so let's say we are starting from three so defined dfs graph
and we have a root node that we want to start with and the first thing we want to do is you
want to create a stack and you can use a list as a stack adding you can simply add things to the end
and then pop them from the end so we create the stack and then we find discovered we marked
discovered as falls or every node, then we say stack dot insert so stack dot append so we simply add
the number three to the end or the root number to the end so stack dot append root and then
we don't mark it as discovered yet now this is the interesting thing in dfs because
remember when you start out with three you want you don't want to mark four one and two all of
them as discovered you want to put them into the stack but only when they come out you want to
mark it as discovered because you want to discover four and then you want to discover zero before you
discover one so that's why we put these into the stack but we don't really mark them as discovered
just yet so that's why we're not marking the root as discovered then while land stack is greater than zero
we get the current value so the current value would be stack dot pop so interestingly
I think list do support a pop operations if you have a list and then you do L1 dot pop
you can see that the value we that you get from L1 dot pop is the value two
and L1 now has the value five comma six okay so you can use a dictionary or you can use a
python list like a stack in fact we can even try append here to see the entire process let's say we
are appending three and then we are popping three we get back three and five six two remains
so we pop the current node and then we mark it as discovered we mark it as discovered here
my discovered of current is true and we may also just want to store that this is the result
that we have so we may also just want to create a result list where every time we pop something
we are also going to add it to the result list so let's say result dot append current
and then we are finally going to return the result okay but here's the main logic so for
all the nodes in graph dot data current we are simply going to push those nodes into the stack
so we are simply going to say stack dot append node okay so what we do is we start with three
and we then pop three and add it to the result and then we put one two and four into the stack
we don't mark them as discovered yet then we pop one and then we put all of the zero two three
four into the stack we don't mark them as discovered yet we mark one is discovered now
then we pop zero because the sorry then we pop four not one because we insert one two four
so four is the last inserted value so then we pop four you mark it as discovered and then we
insert zero one and three now you can see that there is some repetition here we're also inserting
three once again so just to avoid that what we can do is we can say if not discovered node
only then add it to the stack right there's no point in adding something to a stack if it is not all
if it is already discovered so now with that in mind let's see we start with three and then we insert
one two and four great four is the last value inserted so three is discovered now four is the last
value inserted we pop four and then we insert zero one but we don't insert three because it's
so now one is the last value inserted then we pop one and then we try to insert some of these other
values it seems like everything is already inserted so nothing will get inserted then the only thing
that remains is zero so we pop zero then we pop once we have pop zero we are going to pop four
so the order in which we expect to see things is three or one zero two I believe let's see
DFS graph one starting at the node three okay so it looks like we have zero one so it looks like
we made a mistake because we got some repeated values here and that's because we may want to just check
if not discovered current we may want to just add this check and put everything inside this check
so that any older values that have been inserted into the stack which are already visited later
sometime through another value in the stack that gets ignored so we end up with three one three four
one two zero right so it goes like this first we go from three to four to one
to two and then we go from three to four to zero so that's how it goes now a challenge for you
is to also implement distance now in this case the distance will not really make sense because this is
not the shortest distance anymore so when you want to get shortest distance from one node to another
then you want to use BFS not DFS because if you track distance here you may end up going by DFS
three to four to one to two and that is going to give you a distance of four a distance of three
to getting to two although the shorter distance is one so maybe distance doesn't make sense here but
what you may want to put in is the parent you may want to track the parent for each node
should be simple enough to do whenever you are popping something you may just want to track it
parent okay that's an exercise for you another exercise that you can try is to write a function
to detect a cycle in a graph now when you're performing DFS let's say you are going about
performing DFS starting at one and you do this and then you end up here
back at one right because you go from one to two to two zero and when you notice that zero
points to one which is already visited that gives you an indication that there is a cycle in the
graph a cycle is simply a path which leads from a node to itself so one two zero one is a path and
a path is something a path is a sequence of edges so one two is a edge two zero is a edge and
zero one is a edge so this is a path but one two and two four is not an edge so one two four
is not a valid path right so cycle is simply a path that leads a node leads from a node to itself
so the challenge we use to write a function to detect a cycle in a graph another challenge for you is to
detect maybe the number of cycles in a graph okay so that's another thing that you can try out
but we'll move on to another problem now we'll talk about weighted graphs and get closer to that
example of the railway map that we looked at initially so here you have nodes so you have nodes
numbered from zero to eight so you have a total of nine nodes and you have edges two now these edges
also have weights and this could be distances for example the railway line or this could represent
any other information which is of value to you right so you decide what edge weights are what they mean
in the abstract representation we simply call them weights so this is a weighted graph and here is an
example of how we can convey the information about a weighted graph I can give you the number of nodes
and then I can give you a list of edges so the first two elements of each edge tell you which
nodes are connected like the nodes zero and one are connected here and then the last element of
the list of the third element of the list tells you if it is weighted if there is a weight associated
with the edge okay so you have zero one three and then you have zero three two so zero is connected to
three and it has a weight two and so on and you can verify that there are 10 edges here and these are
10 edges with the 10 weights so that's one variation that we see in graphs here is another variation
this is called a directed graph in this case edges have a certain direction so this corresponds
to the example of hyper links where we have pages web pages on the internet and one page can
link to the other but the other page may not necessarily link back it they mean in which case you
may have a by-directional edge but in most cases there you would have a single unit directional edge
so you have zero one one two and two three now directed graphs can be represented just the same way
as undirected graphs all we need to do is we need to provide some information that this is a directed
graph right so you can simply say a directed equals true and that will simply and once you provide
all these all this information that can then specify to the person who is going through this data
that this is a directed graph right so here's how it's exactly the same as a normal undirected
graph but when we create the adjacency list we can have a graph we can have a node from zero to one
but we should not put zero into the adjacency list for one because there's no way from
to there's no direct edge from one to zero there's only a direct edge from zero to one
so keep that in mind in similarly in the adjacency list now you will not set both the value
zero one and one zero to one you will only set one of them for responding to the one direction
unless of course there's a bi-directional edge okay and what we can do is we can even combine
directed graphs and weighted graphs so here's what we'll do we will define a class which can
represent weighted and directed graphs and Python so we'll use it to represent undirected graphs
directed graphs and weighted graphs all of these and we will take some information in the
constructor to capture this detail so let's see let's create a class graph once again we will
create a constructor now this has the self which is the object that gets created always the first
argument to any method in a class in Python then we take the number nodes then we take the edges
and then we take a couple more arguments we take a argument directed which has a default value
false and we take the argument weighted which has a default value false okay and we're going to
store the information self dot directed let's store self dot number nodes as number nodes
self dot directed as directed self dot weighted is weighted okay so now we come to the edges
so for edge in edges what do we do now an edge can either have two values or three values
if it is weighted if it is unweighted then it'll have two values if it is weighted then it'll have three
values so we need an if condition here if self dot weighted then include weights else
work without weights okay now we may want to also because we need to create an adjacency list so we
will create self dot data just as we have been doing so far and in self dot data we will create a
list of empty lists as we have done or underscore in range number edges now what we'll do
along with self dot data we will also create something called self dot weight and self dot weight
will store for each corresponding value in the adjacency list it will store the weight of the edge
between the two elements so far under and you'll see how it works in just a moment
now edges okay so we have self dot data in self dot weight and this will make it easier
another way you can do it is instead of storing single values you can store tuples
inside self dot data which will correspond to the node and which will also contain the weight right
so that both these are both ways to do it I'm just doing it this way but you can do it the
other way as well where you can store tuples directly inside self dot data which suppose it is weighted
then first we get the values out of the edge so node one node two and weight from the edge
remember the edge is a tuple if and then first we set self dot data node one
and append to it node two and then we also set self dot weight node one so at the exact same location
where we have node two at the exact same index we store the weight between the of the edge
between node one and node two which is weight okay so now we've stored one direction which is
node one to node two we may also need to store the second direction so if not directed so if if the
graph is not directed only then we need to store the second direction so we just say self dot data
node two dot append node one and then self dot data node two dot append weight okay and that's the
case when it is weighted if it is not weighted well the code is actually simpler so we simply get
node one and node two from the edge and we say self dot data node one dot append node two and then
if not directed so there's no weight here so we simply check if the graph is not directed
self dot data node two dot append node one okay so there's a bit of code here but the code is
again fairly straightforward it's just a couple of things that we have to take care of whether it's
weighted or not whether it's directed or not but now that we've done this we have a fairly generic
representation for a graph right so now we can take this graph and remember graph one
graph one had this information so similarly we can take we can create this graph we can use this
graph class to represent graph one but we can also use it to represent one of these which is a
directed graph with weights or a graph with directed edges or a graph with both a graph with both
weights and directed edges which we'll see in just a moment now one thing that we'll also do here
is create a nice representation so let's just create a representation here now I'm not going to
get into the code of this but roughly what we want is we while showing the graph if there is a
weight we also want to show the weight we'll show the weight alongside the other node so let's see
we create a result the result will be this at the empty string and then we'll return that result
then we are going to say for i comma nodes comma weights in a numerate
self dot data and self dot weight so now this is an exercise for you to figure out what exactly
this is doing and you can apply the exact same technique take create a new create a new cell
and put this data into a cell put the zip into a cell and then see what that represents if
if you're not able to if it doesn't show something then try converting it into a list or using it
in a far loop and then put a numerate around it and see what that represents so that you understand
what i nodes invades represent but i am simply going to write it here so that you see the final result
okay so let's take norm nodes one once again and edges one it was called norm nodes and edges
so this was the initial data data that we were working with let's create graph one
and of course we want to do this only if it is weighted so if self dot weighted
if it is not weighted then we have a different case where for i comma nodes in
a numerate self dot data result plus equals
okay let's see so graph one we are going to use the graph and we're going to pass
norm nodes edges and by default weighted and directed are both false so we don't need to specify
them and let's see graph one this should be norm nodes so you can see with life coding we always
make mistakes and it's almost always bound to happen that's where Jupiter notebooks are very
helpful and it's always helpful to just test your function while you're writing it okay so
now we've created graph one and graph one you can see is a undirected graph you can see that zero
points to one and one points to zero then let's look at graph two so we're going to grab this
data this contains let's call this norm nodes two and edges two this is a graph with weights
so now let's create a graph two graph and here we pass in norm nodes two edges two
and weighted equals true and let's see graph two okay there's a small change here
yeah so now you can see for graph two this was the graph we were looking at here this graph let's
grab this image as well yeah this is the graph that we were looking at and you can see that zero
is connected to one and three and so it's zero is connected to one three and eight one three and
there are also weights associated so zero one has the weight three zero three has the weight two
and zero eight has the weight four and so on if there seems to be something off here because zero
one only seems to be connected to zero I think we may have made a mistake somewhere in the code
okay so we may just have to debug this code it seems like we may have made a
small mistake somewhere because zero one one seems to be connected only to zero but one should
also be connected to seven I don't see why that did not show up here
this is the curse of life coding and that's why I have created a working I have some working
code here so I'm simply going to grab the working code right now and we'll just replace that
but see if you can detect the bug in the code okay we don't the version I have does not require
you to specify weighted so we can simply skip weighted here it detects automatically if the
graph is weighted still something wrong here let's just quickly verify what's going wrong
so we are going through the list of edges here and we are pending maybe let's just print
graph 2 dot graph 2 dot data it'd be the issues in the representation and not in the
code graph 2 dot edges ah there seems to be some issue in the weight here so we may not have inserted
the weights correctly I see so this should be called weight this should be called weight
and so should this be called weight or there was this intact error here
okay I think we fixed it finally let's
see this should be called weight so we have an edge here we have too many values to unpack
ah we simply pass weighted equals true finally and we need to make this a list
it's finally done some good hardcore live debugging but we have this finally and again you get to see
that when you're coding you will fail you will make issues you just need to but if you have a
clear idea of how you've written the code it's easier to narrow down the issues by looking at the
errors but let's see this graph here so we have 0 connected to 1 3 and 8 and that's you can see that
here 1 3 and 8 are 0 connected to 1 3 with the weights 3 2 and 4 then we have 3 connected to 0
2 and 4 so we have 3 connected to 0 2 and 4 and we have 6 connected to 5 and 8 you can see 6
connected to 5 with the value 8 so great we have now represented a graph properly and this is why
a representation is really useful because now we can check if our implementation is correct before
we go on and implement any graph algorithms we can check if our representation is correct let's try
one more let us also try this directed graph so we're going to grab this code and put it here let's
call this number node 3 edges 3 and directed 3 let me grab this graph code here as well
we're working with this graph and let's create graph 3 so for graph 3 we have graph and we
pass in node 3 we pass in edges 3 you can verify that the edges are set up correctly and we just
specify directed equals true so we don't really need this at this point we can just say directed
true and weighted by default is automatically false so we have graph 3 here you can see the 0
is connected to 1 and 1 is connected to 2 but not to 0 so now we haven't inserted the opposite edge
and then 2 is connected to 3 and 4 and then 3 is connected to 0 and 4 is connected to 2 great
so we've implemented we've now set up another graph and now here similarly you can check that if
you have a weighted directed graph the code is still going to work fine okay so that's an exercise
for you and at this point let us just save our notebook using jubin.com it
so the next question that we're going to look at is called the shortest path question
and this is really what we started out with let's say you have a bunch of nodes and this is
we have taken a directed graph here but you need not have a directed graph you can do this with
an undirected graph 2 and that will be an exercise for you but you do need weights here now whenever
you're talking about shortest paths in terms of weights that is when this algorithm makes sense
now if you do not have weights in the graph then the shortest path can be found simply by
performing breadth first search okay so whenever you're asked to find the shortest path the first
question you should be asking is is there a weight involved or are there no weights now if there
are weights involved then we simply concern with the length of the path the number of nodes in each
path and in that case you can simply perform a breadth first a breadth first search but if you
have weights whether it's directed or directed then breadth first search alone may not be enough right
because it may turn out that certain paths for instance you go from 0 to 3 so you say you go
if you go via 0 to 4 and 3 the length of the path is 2 plus 3 5 plus 4 9 but if you
sorry the yeah the total size the total size of the length of the path is 2 plus 3 5 plus 4 9
but the number of nodes is 4 0 to 3 4 on the other hand if you go via 0 1 3 in this case the number
of nodes is smaller so there's just one in between so 0 1 3 there's just three nodes total
but the length of the path is 14 which is far higher right so this could represent that you go to a far
of place of via a train and then take a train to something that was actually closer even though
there were more stops in a different route okay this is what we're going to implement now we're
going to implement an algorithm to identify the shortest path from a given node to a given
target okay so now this time we're going to focus our search between a node and a target so what is
the shortest path in terms of the total weight of the path not in terms of the number of nodes
in the path keep in mind go to the shortest path in terms of the total weight that we can find from
a starting node to an end node and roughly the strategy goes like this and the strategy
is called the die straws algorithm roughly the strategy goes like this you have the source node
and the source node is at a distance 0 from itself there's nothing there really but
the first thing that we know the first and the only thing that we know is that for one of the
siblings for one of the neighbors of the source node the direct edge will be the shortest path so
for example we have one and we have two now you have directed you have direct edges from
you have direct edges from 0 to 2 and you have a direct edge from 0 to 1 0 to 2 has the
weight 2 and 0 to 1 has the weight 4 now in this case suppose we had an edge from 2 to 1 and that edge
hit had the weight 1 then you could go from 0 to 2 with the weight 2 and then go from 0 to 2 to 1
by a weight 1 and the total weight you would incur to get to 1 would simply be 3 and that would be smaller
than the shorter smaller than the direct edge right so even if you're looking at direct
connections of the root we can't say that the direct edge is the shortest path except
or one of the nodes right so if we just look at the node where the edge weight is the smallest so
you start at the root and you look at the edge with the smallest weight then we can say for sure
that the shortest path from the root to the next node to the node 2 is the direct edge why
because this direct edge is smaller than or smaller than equal to any other direct edge so any other
path that comes to to indirectly will contain another another direct edge and then some other
edges right so it will have a length greater than or equal to this direct edge right so that's the
key insight here that at every point you maintain a group of visited nodes also in this case initially
just 2 0 is visited and then you find the first node which is at the closest distance from
any node within the visited group okay so for example if we start out at 0 and then we look at
one and we look at 2 we see the smallest edges 2 so we add 2 into our visited group because we
we know that this is the shortest path from 0 to 2 and at this point now we take all of the
siblings of all of the neighbors of 2 and update their weights now because we know that 0 to 2
as a direct shortest path so we can update the distance for 4 that 4 could be at a potential distance of
2 plus 3 5 or there could be a shorter path so we do not yet added it we will just update 4
and similarly if there was a edge to 1 we can update the distance of 1 and we can say that the
distance of 1 is either 4 which was the direct edge or it can be 2 plus 1 if there was a direct edge
from 1 so now we will get to know that 1 is at a distance of 3 which is smaller right in this
case it's not but suppose there was a direct edge from 2 to 1 of weight 1 we would get to know that
1 is at a distance 3 so each time you add a new node as you mark the node as visited you
update the weights of update the distances of all its neighbors and then you simply find the next
node with the smallest distance right so you will find that the next node with the smallest distance
in this case is 4 and then you update the neighbors of 4 there is only one neighbor the next
node with the smallest distance is 3 you update the weights of 3 and so on so that was shortest path
in a directed graph but here let's see a shortest path in an undirected graph where we have
more such cases let's just watch this from the beginning let's wait for the animation to start again
so we started 0 then we checked 2 okay we marked 2 as updated then we checked 9 then we marked 3
is updated then we update the distance of 14 but now we can see here that we have another path to go
to 2 we go to 3 that's why we track that and finally we get 2 we marked 2 as visited now we are
considering 3 and using 3 we are updating the weights of all the other graph all the other nodes
and then we are marking 3 as visited then we are using 3 to mark 6 as visited and so on right so
at each point you have a group of visited nodes and you have distances for all the nodes that are
connected with the visited nodes and then you pick the first unvisited node with the smallest distance okay
now let's read the algorithm you first mark all nodes as unvisited and then you create a set of
all the unvisited nodes and you call it the universal set so a set of all the unvisited nodes is
called the unvisited set assigned to every node a tentative distance value now set it to 0 for the
initial node because the initial node is at a distance 0 and set it to infinity for all the other nodes
so we now set the distance to infinity because we have not yet visited the nodes we don't know
their distance then you set the initial node as the current node so there's a always a current
node that we're looking at in this case we'll start with the initial node now for the current node
consider all of its unvisited neighbors and then calculate their tentative distances through the
current node right so you have the current node and the current node is connected to a lot of unvisited
nodes and if we look at each unvisited node we know the distance up to the current node
because the current node is visited and using that we can calculate distances for the unvisited
nodes now if the unvisited nodes have distances set to infinity then we know that the distance
from the current node distance why for going via the current node is going to be smaller
then the distance infinity that has been set but on the other hand if the if a distance has
already been set for an unvisited node through some other node then we can simply compare whether
it is better to go through the current node or whether it is better to retain the retain the
distance that was obtained by some other node and just maintain that right so in this way we simply
update the distances of all the unvisited nodes that are neighbors of the current node okay so for
if the current node is a and it is marked with a distance of 6 and then there is an edge
connecting it with a neighbor b and then that edge has the weight or the length 2 then the distance
to go to b through a from the source will be 6 plus 2 8 right so from the source to a is 6
a to b is 2 so the distance if you want to go to b through a will be 6 plus 2 8 on the other hand
if b was already previously marked with a distance right so it was not visited but it was just marked
with a distance greater than 8 then we know that we found a shorter path via a so we updated
its distance to 8 on the other hand if we have a value let's say the value of for visiting b
via another node b was 7 so we keep the distance as 7 right so we simply updating the distance we
are not yet marking these new we are not yet marking b as visited now when we are done updating
all the distances for the current node then we mark the current node as visited and of course
we remove it from the unvisited set right so we mark the current node as visited then a visited
node will never be checked again because once you have visited a node you have found the shortest
path to it and you have used it to update the distances of all its neighbors you never need to
visit it again so then find the first unvisited node find the first unvisited node that is marked
with the smallest distance right so now we have a bunch of visited nodes and then we have a bunch
of unvisited nodes many of those unvisited nodes have been marked with a distance so you simply
get the first unvisited node with the smallest distance and make it the current node and
the repeat the process okay so you start out with 0 you see that you can mark 2 as you can mark
the distances of for 1 and 2 so 1 gets the distance 4 and 2 gets the distance 2 now then you mark
0 as visited now you see that the node with the least the unvisited node with the least distance
is 2 so you get 2 and then you mark the mark the edges from 2 so you mark the distance for 4 as
2 plus 3 5 and suppose 2 had a H to 1 then you would mark the distance for 1 as 2 plus 1 if
1 was the weight of the H let's say your mark the distance for 1 is the minimum of 4 and 2 plus 1 so
which will be 3 so you can mark the distance for 1 as 3 and that's it and then you remove
2 from the unvisited set next you find the next unvisited node the which has the lowest distance so
if this H existed that would be 1 but if it's as if this H does not exist that would be 4 so you
get 4 and the new mark distances for the neighbors of 4 and so on okay so what we'll do is we will
create this we create this graph here which contains okay that should be a graph here that we can
look at yeah so we'll create this graph here which contains 0 to 6 which contains 6 nodes 0 to 5
this is the graph you're creating let's just put it here this graph yeah so this is a graph that
we'll work with and let's start writing a shortest path algorithm so depth shortest path
and we have a graph and that's it we have a start node so let's call it source and then we have
a target node that node that you want to get so we want to go from 0 to 5 and as soon as we have
the as soon as we mark the target node as visited we are algorithm is done right so first we
mark everything is unvisited by setting visited pulse times lan graph dot data so here we have
mark visited then we have distance so we take we take the distances infinity here's a way to create
infinity in python you just say float in and once again we set all distances to infinity
then we are going to maintain a queue so because we have this first in first out kind of
structures we're going to maintain a queue the first thing we'll do is we will mark
the distance for the source node as 0 then we can insert the source node into queue
so queue dot insert or queue dot append source and then we'll set our index to keep track of
what is the next element that we need to decue so the first element is what we need to decue so
while index is less than 0 and not visited target so while index is less than the length of the queue
and the target is not visited so what do we need to do we need to get the current element from
the queue so we simply get queue of i dx and then we increment increment i dx by 1 so we increment
i dx by 1 here then we need to take all the neighbors of queue all the neighbor or we also
need to finally mark it as visited so let's just put in visited current equals true here
but in between what we need to do is we need to update the distances of all the neighbors
and then we also need to find the next node with the find the first unvisited node
with the smallest distance okay so to update the distance of all the neighbors we have written
a function called update distance so we'll call this function update distance or update distances
where we will pass in the graph and we will pass in the current node and we will pass in the
distance matrix or the distance array and we pass it in this way and what update distances does
let's look let's look at it here and again it's always a good idea to extract out specific pieces
of logic into separate functions so here we're calling update distances where we have a current
node and then we have the graph and then we have the distance so we get the neighbors of the current
node using graph dot data graph dot data current will give us the neighbors of the current nodes
then we get the weights of of the neighbors of the edges connecting the current node towards
neighbors so we get the weights as well now we go through each list of neighbors so for i
common node in enumerate neighbors and then we check we get the weight so we now we have the node
and we have the weight so we have for each edge the node that it is connected to
and the weight of the edge and then we check the distance for the node if the distance for the node
let's say it hasn't already been said then it is infinity so in that case distance to the current
node from the source plus the weight of the edge from the current node to the next node will be less
than the distance so if the distance of current plus weight is less than the distance we simply
update the distance of the node on the other hand if the distance of the node has already been said
via some other node and that is less than the distance via the current node then we do not update
the distance okay so that's all we are doing here and we can ignore this for now we'll come back to it
but this is performing exactly that update distances function that we talked about
then next we want to find the next unvisited node so here we have a function called pick next
node which has a list of distances and it has visited so we want to track the minimum distance so
we first set a variable called minimum distance to the value infinity and then we set a variable
min node so this is the node with the minimum distance to the value non then we iterate over
the all the lists all the nodes in the that we have in the graph so from 0 to n minus
and we check that if the node is not visited and the distance of the node is less than the minimum
distance we've obtained so far then we set that node to the minimum node and we set the minimum
distance to that value okay so we track the minimum distance the running minimum distance by going
over all the nodes in the graph and we keep track of which node has the minimum which unvisited
node has the minimum distance so finally what pick next node gives us is the first
next unvisited node okay so here we can get next node is pick next node and we give it the
distance and we give it visited okay so now if there was a next node it's possible that there
is no next node because we've probably already visited a way thing that we can visit so if there
is a next node then we end Q it so we say Q dot append next node and that's it that's pretty much it
so that is our shortest path algorithm we create a visited list we create a distance list we
create a Q where we will add things so this this will be all the all the nodes that we have
visited will go through it all go through this one by one and the Q in order will give us
a list of all the nodes in their order of distance from the source node now what we need to return
here is we simply need to return distance of the target then since that was what was asked here
let's also mark current has visited true here soon enough so that we don't end up visiting current
again and again all right so let's run the shortest path algorithm then here we have a graph
this is the same graph that we see here now we can create a graph graph 7
and this is weighted and directed so we will pass in graph we will pass number nodes 7
we will pass edges 7 and then we will pass weighted equals true and directed equals true
and this is graph 7 okay this seems like it was it worked out right zero is connected to one
and two whether weights four and two respectively and five is connected to nothing four is connected
three is connected to five four is connected to three okay this looks fine so now we can say
shortest path in the graph from let's say from zero to five
in graph 7 and it says that the length of the shortest path is 20 so you have two three four
11 so two plus three five five plus four nine nine plus 11 20 so that seems to be right
what would also be nice to get is just to see what that path is and for this we can
introduce something called a parent so here we can simply have another thing called a parent
which is set to none for each element so visited we'll let's call this parent and let's
set it to none by default and all we need to do is whenever we are enquying a node we need
to track why it got encued right so if an if a node is getting encued then it is probably
getting encued so so so I not whenever we are enquying whenever we are updating the distance
of a node we need to track why it's distance got updated so inside update distances whenever we
update the distance of a node we also set the node the parent of the node to that current node
from which the distance got updated right and that's all we need to do when we update the distance
of a node we need to track why did we update this distance by which node we did we come to update
this distance so this way we have not tracked the parent and let's return not just the distance
of the target but let's also return the queue and let's return let's just return the parent for now
think this should be fine okay so now you have the parent for each one so if you look at
the fifth element 0123455 you can see that the parent of five is three so it seemed like we arrived
at five from three and then if you look at the parent of three so 0123 the parent of three was
four it seemed like we arrived at three from four and you look at the parent of two it seems like
we arrived at four from two then you look at the parent of two and it looks like we arrived there
from zero and zero was our source so the path is if simply going reverse 0 to 035 okay and that's
how you get the shortest path and not just the shortest path distance notice that zero itself
does not have a parent because that was the source now you can repeat this with another graph
let's say we take this other graph that we had this was graph two so let's grab this image here
so let's get graph two let's say shortest path graph two and let's get the shortest path
maybe from zero to seven so it seems like there are two paths one goes via one and one goes via
six out two three three two and seven so let's get the shortest path from zero to seven
okay so we started out with zero and we ended up at seven so zero one two three four five six
seven it seems like the parent for seven was one and then the parent for one was zero
so it's clear that it picked the path zero one seven and the total length of the path was seven
sounds good we can try another one we can try two and eight so there are a couple of ways to go
from two to eight one is to go via three so you can go to six two other three ways actually
but six two six you can go at three zero and eight or you can go at three four and eight
let's see which one it picks okay so now zero one two three four five six seven eight
so the parent for eight is five oh sorry zero one two three four five six seven eight
so the parent for eight is four so we came to eight via four and then the parent for four zero
one two three four the parent for four is three so we came to four via three and then the
parent for three zero one two three the parent for three is two so we came to three via six
So 2, 3, 4 is the path and the length should be 8 plus 1, 9 plus 6, 15.
Great.
It seems like we figured out the shortest path once again.
And this time, this was an undirected graph.
Okay.
So as long as you have weight,
you can apply this algorithm.
And this algorithm is called the dystras algorithm.
And that's it.
So that's all we're going to cover today.
Now, one thing that we have not looked at very closely
is the running time complexities.
Or so, let's do a quick look at that.
Let's do a quick look at, let's say at BFS.
And see, we can identify and get our guess the running time complexity
and the full proof is left to you as an exercise.
But roughly, it looks like this.
This is the main.
This is the main loop here,
so where we are going through the queue.
So the number of times this may happen is n,
of which is the number of n,
which is the number of nodes.
And the number of times this might happen.
Now, inside each node inside BFS,
remember that we check a full list of nodes inside each node for BFS.
So the number of times this may happen is equal to the number of,
for each node,
we may perform an additional number of steps equal to the number of nodes.
It is connected to, right?
So if we have n nodes,
so we have n while loops.
And then if we have a total of m edges,
and let's say those m edges are split across
if I count the number of edges for each node,
the number of edges is E1, E2, E3, E4, and so on.
And then we, so the number, the size of this loop for the node n1 is E1,
the size of this loop for the node n2 is E2,
the size of this loop for node n3 is E3.
So if you add up the list of all the edges, E1 plus E2 plus E3 plus E4,
so the total number of iterations inside this four loop
turns out to be,
the total number of iterations inside the four loop
will turn out to be the total,
the sum of all the edges in C lists,
and the total sum of all the edges in C lists is equal to twice the number of edges.
You can see here the number of edges is 1, 2, 3, 4, 5, 6, 7,
and you can verify that the number of elements of all the edges in C lists
put together is 14, because each edge is represented twice, right?
So we end up, if we have n, so if we have n, n vertices,
and m edges, we end up with n plus 2m operations, right?
So each of the n operations to start the y loop,
and then each of the 2m operations,
those are to iterate over each adjacency list, right?
And now when we are talking about complexities,
we can ignore the m, if m is the number of edges,
we can ignore the factor 2 associated with it.
So what we end up with is order of n plus m.
So order of n plus m is the complexity of breadth first search,
and now by this point,
you should be able to just work it out by looking at the code,
so do try it out, and if it's not clear, do ask on the forum,
but order of n plus m is the complexity of breadth first search,
and you will find a similar complexity for depth first search as well,
order of n plus m.
For the shortest path algorithm, however,
the complexity will be different,
because in the shortest path algorithm, let's see it here.
In the shortest path algorithm, what we do is,
we go over all the vertices, so that's,
we insert each vertex or each node into the queue once,
and then we take it out once, so this contributes a factor n.
Then when we are saying update distances,
then it also contributes a factor m.
But when we are picking the next node,
we may we visit all the vertices once again, right?
So here we are performing n operations inside,
and we are picking the next node.
So that gives us order of n square plus nm.
n square plus m.
Yeah, something like that.
So order of n square plus m or n plus m into n.
So those are some complexities that you will see reported for shortest path.
And a way to improve this,
a way to improve the picking of the next node,
is to use what is called a min heap,
so that you don't have to look through the entire list of nodes each time,
to pick the next node,
but you can simply pick the next node in a very short time.
So there's a data structure called a min heap that you can look at.
The min heap allows is used to keep track of a bunch of numbers
and easily track the minimum.
So you can keep a bunch of numbers around in a binary tree like this,
and the root will always be the minimum,
and the numbers on the left and right will always be larger than the root.
And then the same will be true for each sub tree as well.
And insertion into this heap is of order log n,
and deletion into this heap is of order log n as well.
And then the min max in this case,
fetching the min or the maximum value is of order 1.
So instead of meant to instead of looping through the entire list of nodes each time,
what you can do is you can simply insert nodes into this min heap,
and delete nodes from the min heap when they've become visited,
and getting the next node is as simple as fetching the minimum value.
Okay.
So check this out.
This is not something that will generally get us.
This is a more advanced concept.
In fact, even the diastras shortest path algorithm,
it's very unlikely that you will get us.
But do review it and do try as an exercise if you want to go further,
try implementing and improving the diastras algorithm using a binary heap.
So that will take the complexity from m plus n times n to m plus n times log n.
Okay.
And that may be better.
So do check that out.
That's obviously going to be better for larger graphs.
So do try to implement it.
In fact, inside Python,
there is a built in heap called the heap queue data structure,
and that will optimize the pick next node operation.
In the diastras algorithm.
Okay.
So that concludes our discussion of graphs here.
There's a lot more in graphs.
Graph theory is an entire course in itself.
But since this course is particularly concentrated on data structures and algorithms from the perspective of coding interviews and coding assessments,
this is as far as we need to go.
So what you should do is you should practice more graph problems related to breadth first search and depth first search.
That is really something that you need to become very familiar with breadth first and depth first search.
And shortest path may be sometimes some really hard interviews.
You may get asked shortest path as well.
So do familiarize yourself with that.
But apart from that, you don't really need a lot more.
But there are other algorithms.
You can look at minimum spanning trees.
You can look at topological sorting.
You can look at connected components.
That's another path.
You can look at detection of cycles.
And there's something called disjoint sets.
So there's a huge huge number of topics that we can cover in graph.
But we'll stop our discussion here.
So what do you do next?
Review the lecture video and execute the Jupiter notebook.
Complete the assignment and attempt the optional questions.
And finally participate in forum discussions.
Very important.
If you're stuck at any point, just go on the forum ask a question.
You can also share your code as long as it's not working to get help.
And you can also join or start a study group to learn together with friends.
And you can also find us on Twitter at Chovian ML and add Akash NS.
And the next lesson is data structures and algorithms.
In data structures and algorithms is Python interview tips, tricks and practical advice.
Thank you.
Hello and welcome to data structures and algorithms in Python.
This is an online certification course being conducted by Chovian.
Today we're on lesson six by the name of your tips, tricks and practical advice.
This is the final lesson of the course.
So I hope you're excited.
My name is Akash and I'm your instructor.
You can find me on add Akash NS.
If you've been following along with the course and you have been working on the assignments.
And if you complete a course project as well, then you can earn a certificate of accomplishment for the course.
Which you can find on your Jovian profile and also add to LinkedIn or download this PDF.
So let's get started.
First thing we'll do is go to the course website, pythondsa.com.
So this is the course website by thandassa.com.
This is where you find all the information about the course.
You can watch all the previous lessons lessons 1 through 5.
And you can also check out the previous assignments assignment 1, 2, 3.
And you have the course project as well.
Let's open up lesson 6.
Now on lesson 6, you will be able to find a video recording of the video you're watching right now.
And here is the code that we will look at today.
So today we will do something different.
We will simulate the experience of being in an interview.
So while we have given you a problem solving template and we recommend that you follow this template.
For any project or any notebook that you work on, any coding problem that you work on.
And here on the problem solving template, we also have a method.
Something that we have been applying throughout this course to different kinds of problems, different kinds of data structures and algorithms.
But in an interview, obviously you will not have this template.
So we will see how to apply this method during an interview.
And before we do that, let's revise a method so that we can recall it from memory when we are working on the interview problem.
So here is the systematic strategy that we have been applying so far for solving problems.
And do check out the previous lessons if you haven't seen them.
For examples of how to apply it in detail.
So the step one is to state the problem clearly in your own words and identify the input and output format.
And then the second step is to come up with some example inputs and outputs and try to cover all the edge cases that you can think of.
So you want to think of all the possible scenarios and that will help you write your code properly.
Then step three is to come up with a correct solution for the problem and state that solution in plain English.
And then step four is to implement the solution and estate using some example inputs.
This is important while you're practicing.
But initially when you come up with a correct solution, it will be a simple solution.
What is often called a brute force solution.
And in an interview setting, you may not have the time to implement it from scratch.
So you may skip if the brute force solution is too straightforward.
Then step five is to analyze the algorithms complexity and identify any inefficiencies in the algorithm.
So what you can do in an interview is come up with a correct solution and describe it to the interviewer and then analyze its complexity directly and start identifying inefficiencies and then move on to apply the right technique to overcome the inefficiencies.
So this is where you need to identify what which one of the techniques that you've learned in this course do you need to apply.
Is this a binary search problem? Is this a divide in conquer problem?
Is this related to binary search trees?
Is this something that you can solve in a similar way? You'd solve sorting? Is it important to look at the first case or average case complexity?
Is this a graph problem or is this a recursion or is this dynamic programming or a memoization problem?
So all of these things are something that you have to think about.
And as you practice more and more and more problems, so for each of the lessons if you try and practice about five to ten problems,
then you will start to recognize these patterns and when you're on step six, when you're trying to come up with the right technique to overcome the inefficiency, the ideas will automatically come to you.
So practice is very important to succeed in step six.
And once we have a determined how to overcome the inefficiency through the right data structure algorithm, then we state that solution, implement it, analyze the complexity.
So this is how your according assessment or an interview should proceed for you.
And let's see, let's pick up a coding problem and let's go from there.
So here we have a coding problem by then sub array with the given sum and we read the problem, but before that, you can see that here this notebook is fairly empty.
And what we're trying to do is we're trying to simulate the situation where you are on a call with somebody and they're interviewing you.
And typically, they would be using some platform like a collab, a date or maybe a platform where you can also run the code or a platform where.
The question is somewhere let's say on the right, it's already printed, it's from a pre selected database on on the right on the left and on the right, you can type your code and you can experiment with it.
Now, we're not using any third platform here, what we'll do is we'll simply simulate that in our Jupiter notebook.
Okay, so now we have this notebook running, we've clicked the run button on the Jovi notebook and here we are.
Now the question is, and this is a question that was asked during a coding interview for Amazon, of course, a lot of other companies may ask similar questions too.
You are given an array of numbers and these numbers are all non-negative.
You need to find a continuous sub array of the list which adds up to a given sum.
This is how interviewer might state the problem to you.
And then they may also tell you an example, sometimes they don't and if they don't, it's always a good idea to ask for example.
Now, you might sometimes feel that maybe if you ask too many questions, the interviewer might think that you don't know this or you're dumb in some way, but that's not true.
It's actually the opposite, the more questions you ask, the better the interview, the better the interviewer is able to convey what they want.
Now they're busy, they're doing 5 interviews a day and they have their entire day's work.
Sometimes they may just fail to state the question in its entirety.
And if you don't ask for clarifications, you may assume the wrong thing and go ahead and implement something that's completely wrong.
And that completely deals your interview and trust me, it happens more often than you might think.
Okay, so we here is one example.
So let's say interviewer did not provide an example.
You can ask them, can you please give me an example for this problem?
And then they come back to you and they say, suppose we have this array, 174, 2, 1, 3, 11 and 5.
These are all numbers and they're all non-negative.
Some of these could be zero as well, but suppose we have this array.
And I give you the number 10 that I want you to find the,
I want you to find a continuous array of the list which adds up to the given sum, which is 10.
So then they might also tell you that in this case, the solution is this array starting from position 4.
Starting from the number 4 and going all the way up to 3.
And you can check that there are no other ways to create 10, like if we took 17, that would be 8 and 174 would be 12.
On the other hand, 7, 4, 2 would be 12 again, but 4, 2, 1, 3 turns out to be 10.
And once again on the right, you will not be able to create the total of 10.
So this array is what you have to return.
Now what does it mean to return a sub-array?
To return a sub-array means to return the indices, which is the index of the starting term or end the index of the ending term.
And sometimes we know end Python when we are working with ranges, typically the end index is outside of the actual data.
So you could return the index of 4 and the index of 11.
So that we, so the index of 4 is 0, 1, 2, so 2 is the index of 4, 3, 4, 5, 6, index of 11, 6.
So if you return 2 and 6 and then I try to access the 2 to 6, 2 colon 6 range of the list, then you will get this list 4, 2, 1, 3.
And in fact, that's something that we can very quickly verify here.
Let's say L1, so you have 1, 7, 4, 2, 1, 3.
Now if I say that the start index is the start index i and the end index j are 2 and 6 respectively.
And you can see L1 of 2 to 6 is 4, 2, 1, 3.
So all the j is outside, so that doesn't get included when we put it as a range.
And then we put in 4, 2, 1, 3.
And you can also verify that the sum is 10.
All right, so that's the problem.
Now I've explained it to you in a lot more detail than an interviewer build.
But this is the process that you have to apply in your own mind.
And sometimes what you can also do is you can repeat the problem back to the interviewer.
That's a great idea.
You, they've stated the problem to you.
They may be given you an example.
Now you state the problem yourself in simple words.
Remember, that was step one.
So in the same way that I just have, you can state the problem.
And then you have to figure out what are the inputs and the outputs.
So the input, you have an array or arrays also a list in Python.
So let's say ARR 0, let's create, let's make this the first and example first input.
And that would be 1, 7, 4, 2, 1, 3.
And then the target, so your target sum is 10.
So that's the input here.
And then the output that we want to want is.
So this is the output 0, that would be 2, 6 as we've just verified.
So this is the input and output format.
Always make sense to just create some variables for that before you start coding.
The next step is to think of what are all the cases that a function should be able to
handle.
But actually before we do that, we should also write a function signature because we know what the input looks like.
We know what the output is going to look like.
And we know what, so we know what the function should look like.
So we can just say death.
And let's call this sub array sum.
And it's going to take an array.
It's going to take a target and there's going to be some logic inside it.
Okay.
All right.
So that was step one.
But it always helps to just write the function signature because if you've misunderstood to the problem still.
The interviewer can immediately correct you and tell you.
Hey, but you haven't taken a certain input or you were zoomed and input, which I've not provided.
Okay.
All right.
So now we have the function signature.
Now step two.
Remember step two was come up with an exhaustive list of
S cases to test the problem.
So you can do this in comments.
You can just create some comments.
And you can say, I'm thinking about the problem.
And I'm just trying to think what are all the cases we need to handle.
And this is a great quality.
This is not something people do often, but they should because this indicates that you're doing what is called
S-driven development, which means you are thinking about all the ways in which your
Code might be used and accounting for those before writing the code.
So kind of working backwards and it's a very useful way to avoid errors.
So now the first one could be a generic array.
Where the sub array is in the center somewhere in the center.
So which is what we have already seen here.
Now the sub array could be in the center or the sub array could be at the start.
Or the sub array could be at the end or it's possible that the sub array.
There is no such sub array.
So there's no sub array which adds up to 10.
You may also have the situation where you have a few zeros.
So you have a few zeros in the list, that's on option.
Here's one thing that can happen.
Then this could be that there are multiple sub arrays with the same sum.
Now this is where you might want to just clarify with the interviewer.
What happens if we get two sub arrays which add up to the same number the target.
And the interviewer might say find the shortest one or find the first one or find anyone.
But it's always good to clarify that.
Next one option could be that.
Or you could also ask them what is what happens if there is no sub array that adds up to 10.
And then they may tell you you can return non non or you can return minus one or whatever it is.
Or assume that there is always the sub array.
So that will help you write your code.
And then you can obviously you may have to work with the empty array.
You may also have to work with the sub array is a single element.
And whenever we say array, we also mean list in Python there practically speaking the same thing for our purposes.
Okay, we've listed quite a few test cases.
And in that process, we've come across a few more questions which we've clarified.
So now we're ready to start solving the problem.
Now at this point, what you may want to do is.
Maybe just ask for a couple of minutes and keep a pen and paper close to you.
So I'm going to use this tool.
Instead.
Yes, I'm going to use this tool instead.
So keep a pen and paper close to you so that you can work on this problem.
Now let's come up with the simplest possible solution.
It's so we have about two three minutes to come up with the solution.
And often the simplest solution is pretty obvious.
So in this case, one simple solution could be if I could simply try every sub array.
Then I will find at least one if that adds up to 10 if there is one.
So all I need to do now, each sub array is defined by a start index that is where the first element of the arrays and then and end index.
The end index is just next the next index the first index which is not in the array.
Right, so that's how we define a sub array remember.
So all we need to do is try all such values.
So all such values I come a j where I goes from zero to n minus one.
And where j goes from remember you could start out with the empty sub array.
So which means j also has the value i.
Here we are saying i and j both have the value two.
So l1 of two to two becomes the empty array.
So j grows from i to all the way beyond the last element.
Which means if the last element index is n minus one.
So j can go all the way up to n.
All right, so i goes from zero to n minus one and j goes from i to n.
And each time we started an i and we check each a so we check j equals zero and j equals one equals two j equals three four five and so on.
Then we move i again and then we start over again and then we say we start with j equals zero j equal to one j equal to two three four.
Okay, and and we keep doing this to be find an array and we have exhaust this way.
We will test all the sub arrays so the problem is solved.
So that's the brute force solution and what you should do first of all is explain that brute force solution.
It may seem that this is an obvious solution what's the point of explaining it.
But to mention it because at this point the interior knows nothing about you.
So they don't know if you can even come up with a solution to the problem right.
They're trying to assess can you think about problems and they're trying to assess can you write code now.
If you don't tell them the brute force solution then they don't even know if you figured out the brute force solution.
So do tell them the brute force solution.
And generally you do not have to code it.
You can do the analysis in your memory in your mind and you can sort of write the code in your mind picture the code and based on that come up with the.
Complexity analysis and directly say that the brute force algorithm will have such in such complexity.
Okay, now.
We will just write the code right now just to be very clear about it in case you've not but you're not yet clear on how to write the code.
But in an interview this is the part which you can skip in the interest of time.
So death.
Subbury.
I think it was called.
Subbury sum.
Subbury sum.
Let's call this subbury sum one.
The first approach that we're taking here we have array one and that's it.
You have array and then we have a target.
And we're saying remember that start i from.
So i goes from zero to n minus one.
That was the first thing.
So for i in range zero to n minus one and what's in?
Well n is simply the length of the array length of the array.
Then j goes from.
i to n.
Oops, so I made a small error here.
So it's zero to n because even in a range the last value is not taken.
So j goes from zero to i to n.
So for j in the range.
i to this should be n plus one then because we won't.
j to go all the way up to n.
Okay.
And now we simply check if.
The sum of array.
I to j and then we've seen this array.
I to j is going to give us all the indices starting a tie.
But ending just before g.
So if the sum of array.
i to j.
equals target.
Then we found the answer return i comma j.
It's it.
So check if.
sub array sum.
equals target.
And if not let's just return none.
Maybe this is what we agreed but let's return none.
And that's it.
So that's your.
That's your code.
It's about one two three four five lines of code.
Maybe six.
But that's a brute force solution.
If it's really short it doesn't hurt to write it because it then it's going to sit there and at least as a reference you have it.
But it's something you can discuss with the interviewer.
Should I I mean if you if you are clear about the brute force solution and you can tell it's complexity.
Then you don't have to write.
One other tip is whenever you're coding.
It's always helpful to simply add a small comment above.
So that even if the interviewer is not able to follow your code.
They can just follow your comments and they can tell if your general strategy is correct.
Once again reading code is hard and especially when.
You are not familiar with the coding best practices in the industry.
The code that you write is sometimes difficult to read.
So while you learn how to write good code in the meantime it always helps to just mention comment.
Makes it makes their job easier makes them easier makes it easier for them to evaluate you.
Otherwise you may spend five to ten minutes talking about.
Something in your code which either they misunderstood or you made a typo etc.
Okay.
So we have here the sub array sum one we've implemented the brute force solution.
Maybe let's also check out some cases in.
And see if this brute force solution works correctly.
So in an interview if you have the ability to run the code you can just run a few.
Samples the let's say I simply take a zero and target zero.
And you get the value two six and remember output zero also has the value two six.
So great.
It seems like our.
Our technique book.
Let's test a few more cases just to be sure.
So bury it in the end somewhere at the start.
Let's see if we can fix that.
So here is array zero.
Now if I take this remember four to one three.
Oops. I think I didn't complete it.
Let me also put in eleven comma five here.
Yeah. So remember four to one three is the solution.
Now if it simply take four to one three eleven five.
And call sub array sum.
And put in.
This number here and put in once again the target zero was ten.
Oh, this should be somewhere is some one. Okay.
Yeah. So now you can see four to one three is zero one two three, which is the range zero to four.
So it seems to have worked correctly.
Let's do the same thing. Now list this time. Let's put this at the end.
So one seven four to one three eleven five.
This works fine two two six.
Let's try another one. Let's try maybe.
17 and that probably cannot be found. Oh, it can one two.
Let's see one zero one two three four five probably the sum of all of these four.
Let's do six plus four ten.
Okay. Now maybe there's a problem here because it seems like 17 is not the right sum.
So you have one plus seven eight.
And eight plus four twelve twelve plus two fourteen fourteen plus four eighteen.
Okay. So this seems like a mistake then.
And we can even check this out.
So we have L one that's that.
Let's call that L two L two.
Oh, it says one to six. I think I misread it.
So we are ignoring the zero element.
So this does add up to seventeen. Okay. So seventeen does show up.
Let's try eighteen which takes up the entire array works fine.
Let's try maybe four which should just take the single number.
So that works fine two.
Let's try nineteen that should be none none.
We've tested this extensively and overall our solution seems correct.
This is the process whenever you write any code.
You should also test it out and it also gives more confidence to the interviewer.
But if you do not have the option to.
Estet out if you do if you're not able to run the code right now.
Then simply walk them through an example yourself like look at this example and then walk them through the example.
Okay. So now we have the brute force solution.
The next step is to analyze the brute force solution.
Now let's analyze it. So you have here one for loop.
And we know that counting for loop helps us count the number of operations.
Then we have another for loop. So one for loop can go from zero to end.
So this may run n times. Then we have another for loop which goes from i to n plus one.
Let's approximate here and say that it can run at most n minus one times or n times.
So n and inside each of these up at most n.
And then inside the second for loop you have the sum. So this is very important.
Now always carefully observe the operation inside your for loop.
So you have a sum which can be on an array of i to j.
Now remember i can be zero and j can have the value n.
That means in the the largest array that you can work with will have approximately the size n as well.
Right. So you have n and inside each of those you do n other loops and inside each loop you do work.
You do n additions right at most n additions. So that roughly gives you that this is going to be n times n times n.
So this is going to be an order and cube solution.
Okay. So if you are able to arrive at the order and cube solution at the order and cube complexity without implementing the solution.
Great. You have learned it. But if you're not able to arrive at the order and cube solution at the order and cube complexity.
Order and cube complexity for the brute force solution.
Then you probably need a little more practice because this should become second nature to you.
Just looking at a problem identifying the simplest solution and then finding the complexity of the simplest solution.
Okay.
All right. So now we have implemented it tested it and we've identified the complexity. Remember the next step.
Find the inefficiency and over come that inefficiency by applying the right technique.
So let's find the inefficiency then.
Here we have.
Let's say we are at this position. So let's say you're looking at.
7 4 2 let's say.
I has the value.
So you start out with I equal to 1 and j equal to 1 in the inner loop.
Then what we do is we increment j by 1 and then we calculate the sum and this sum is 7.
Then what we do is we increment j by 1 more and we calculate this loop and this sum and this sum is 7 plus 4 11.
Then we increment this window once again and then we calculate this sum and that is 7 plus 4 plus 2.
So 7 plus 4 11 plus 2.
13.
And then we move this and then we check it again.
So we're doing this over and over and over many many times, right?
Each time we are doing 7 plus 4 plus 2 plus 1 and 7 plus 4, 4 plus 2 plus 1 plus 3,
that seems like a lot of additional work.
Maybe we can just avoid that.
What we can do is we can, when we start out with a j,
we can keep running some and each time,
simply before incrementing j, add this upcoming element,
which is the jth element into that running some, right?
And that way we don't have to do that entire sum inside each of the inner loops.
So that's one optimization.
And this is how you should explain it.
That's one optimization that I have come up with.
The second optimization that we can come up with,
is that the moment the sum, the running sum that we calculating,
the moment the sum becomes greater than the target value.
We can skip all of these, right?
So we know that 7 plus 4 is greater than 10.
And we know that the array only contains non negative number.
So what that means is 7 plus 4 plus any of these numbers is always going to be greater than 10.
That you can, obviously you can see this,
the number is not going to decrease if we keep adding positive numbers.
And so as soon as the running sum crosses this value,
we can break out of the inner loop.
We do not need to continue and look for higher values of j.
The two optimizations helps to just write them down,
maintain a running sum that you don't forget it.
And the second optimization is,
when some exceeds target, break inner loop.
So now we have applied an optimization simply by just looking at the data.
And a lot of cases, it's very straightforward.
You don't even have to apply any special technique.
And in this case, we found these couple of optimizations.
So let's apply them.
So what we'll do is we'll define,
def sub array sum 2.
And here, once again, we have the array and we have the target.
And this time, we get the length of the array.
And once again, I goes from the same value.
So I goes from 0 to n minus 1.
Nothing changes here.
So for i in range 0 to n minus 1.
Now here is where we want to start a running sum.
So s equals 0.
This is our running sum.
Then for j in range.
Remember, we start out with i.
And we'll go all the way.
I keep making these mistakes all the time.
And by the way, these are called off by one errors.
We did was I wanted to go to at the address n minus 1,
but because ranges do not include the final value.
I put in what I put in n minus 1 was wrong.
I should be putting in n.
And I make these mistakes all the time,
even after many years of coding.
So always watch out for off by one errors.
Anyway, so j can take the range of 0 of i to n.
So here, we should put in n plus 1.
And now first we want to check if the running sum is equal to the target.
So assume that we've been calculating the running sum step by step.
And we'll write an n at this current point.
The sum has become equal to target.
Now if the sum has become equal to target,
then we simply return i comma j.
Because this sum includes the sum from index i all the way up to just before j.
So initially the j also has the value i.
So the sum is 0, which makes sense.
But if that is not the case,
we check if it is greater than the target.
So is it possible that our sum has already exceeded the target?
In that case, we don't need to continue this inner loop.
We can break out of this inner loop.
And the way to do that is by simply typing break.
And then if neither of these held through,
if neither of these was true,
so which is that the sum was not equal to the target.
It was not greater.
That means it is still less than the target.
So that means we need to then add area of j into the sum.
So we can say sum plus equal to sum plus equals area of j,
which is the same as sum equal to sum plus area of j.
In any case, area of sum plus equal to area of j.
So we have added the jth element.
Now remember,
if this is the pointer j,
we are added the jth element.
And then we will set j to j plus 1.
That will happen automatically when we come into the next iteration.
And the next iteration will once again check if the sum is equal to the target.
If it is equal, we return i.
Otherwise, we check if it is greater than the target.
If it is still less, we increment we move j once again.
So we add one and then we move j once again.
And then we check again.
So that's our running sum.
Looks good.
Now once again,
if we were if it was found,
it would have been returned somewhere here.
Since it seems like it was probably not found.
So if we come to the very end,
so here we return none, none.
And once again, let's test it out.
So let's try somewhere sum 2.
It gives you 2, 6,
somewhere sum 2 of none.
Okay, seems like there is an issue here.
Yes.
So this is why you need test cases.
So it seems that
rj took up an invalid value.
So why is that?
Well, that's because j can go to the point of n.
So the maximum value j can take is n.
So which means that you have already arrived at this position.
So now you can no longer increase the sum further.
So if you arrived at this position,
but you still not reach the total of n,
then that means you may need to increase it further,
but you can't increase further.
So there's no number here to add.
So what we should do is we should here add a check if j less than n.
It's since j can go all the way up to n.
And that's it.
So we had a small bug and we fixed it.
Now again, this is something that you should work out for yourself on pen and papers.
So even while doing the optimization,
you can ask for a couple of minutes,
play around with it on pen and paper,
write a few examples,
relax.
You can even take up to four five minutes.
And if you,
if you're not getting any ideas,
you can simply talk to the interviewer.
You can speak out loud,
explain your thought process.
And in a lot of cases,
they will give you a hint,
because they want to see you succeeding.
Okay.
So now this is the second implementation.
Let's see.
Okay, this time it worked.
None, none.
4 to 1, 3.
Let's put in 10 here.
They should give you the value 2,6.
Let's put in this.
So that's 0,3.
Let's test this out.
So that's 0,4.
Yeah, 0,4.
So it seems like it's working just fine.
Yeah, so seems like this is working pretty well.
So now we have the second optimized solution.
So let's look at the optimized solution and analyze it.
So we have one loop.
And then we have a second loop.
These two are the same.
But inside the second loop,
we are simply doing a constant operation.
We are just doing some comparison and one addition,
not up to n additions.
So the complexity goes from order of n cube to order of n square
by maintaining a running sum.
Great.
Now,
this at this point,
when you've described the solution to the interviewer
and maybe also coded it,
you might ask them,
is this good enough?
And they can see that you've thought about it.
You've thought about it.
You've found the solution and you've tested it in a test.
Well,
and at this point,
they may just say,
I'm happy with the solution.
This is good enough.
Or they may say,
Can you do better?
Now, when they say,
Can you do better?
Most of the time,
it suggests that there is a better solution.
So let's see.
Let's think about it a little more.
And let's see if there is a better solution.
Now,
to can you do better?
We apply the exact same technique.
We have analyzed the complexity.
And now we need to look for inefficiency.
Okay.
Now, we have removed the inefficiency on this side,
which is as we move J,
that is when we reuse the previous sum
to compute the next sum.
So we remove the inefficiency on this side.
And we've also added this,
also added this condition,
so that J only goes up to a certain point.
Now,
of course,
in the worst case,
J may always go up all the way to the end,
but at least in a lot of cases,
J will not go beyond the point,
where the sum becomes larger than the target.
So these are good optimizations,
but what about I?
What about the left window?
Now,
look at this here.
Now,
when you have seven,
four,
or let's start out all the way at one.
So we have one,
that's,
so first we start out with the empty,
empty sub array.
That has the sum zero.
Then we increment J.
So now the sum becomes one.
Then we increment J.
Now the sum becomes eight.
Then we increment J once again,
and now the sum becomes 12.
Okay,
the sum has become 12.
Now that's a problem.
So what do we do?
What we are saying is,
we will take I and set it to the next value,
and then we'll bring J back to zero,
or back to the value I.
So that we start with the empty sub array once again.
So now when we do seven,
and when we,
so that's,
that just has the value seven,
and when we do this,
we have to add up seven plus four.
Now here's something that we could have done
instead.
Now as soon as the value became larger than the target value,
we could have simply moved this here.
Does that make sense?
Let's think about it.
So,
till this point,
this total was less than 10.
As soon as we added this number on the right,
this total became more than 10.
Now we know that this total became more than 10,
that means that
if we slide this window,
if we slide the left window forward one step,
then the total may become less than 10.
Right,
it may still became,
stay larger.
In this case,
it stays larger,
or it may become less than 10.
So if the total now becomes less than 10,
then we can once again move this.
But if the total has not become less than 10,
so we will move this instead.
So now the total again is less than 10.
So we can once again move this.
And now the total still less than 10.
So we move this.
Now the total still less than 10.
And we moved this.
And we encountered 10 here.
But suppose we had not encountered 10.
Suppose this number was over instead.
Then what we would have to do is move this.
And now the number becomes less than 10.
So we always go.
We always try to maintain a window of size less than 10.
The moment the window becomes greater than 10,
we keep trying to reduce its size further.
To less than 10.
Right?
Or exactly 10 is well.
It's possible that the size may become exactly 10.
And then the problem is solved.
But we keep trying to reduce its size.
To a value till it becomes less than 10.
So to revise the algorithm,
we start out with both i and j at zero.
Then we increment j while the running.
Now we have a single running loop and a single loop,
essentially.
Increment j while the sum is less than 10.
The moment it becomes greater than 10,
we start incrementing i.
The moment the sum becomes less than 10,
or less than target,
we start incrementing j.
And if we encounter the point where the sum equals 10,
we have performed the answer.
So that's the algorithm.
So let's write it.
So bar A sum 3.
Now this is the array target.
Now we have i, we have j.
And we have sum.
All of them.
Let's call it s because sum is a reserved word in Python.
An existing function.
So all of these have the value zero.
Then we say while i is less than 10 array.
Let's call that n.
So let's create n equal to n a r.
i is less than n.
And j is less than n plus 1.
Remember, because j can take the value n as well,
it is the exclusive n index.
Now at this point you want to check first.
So if the sum s, the current sum running sum,
is equal to the target,
then we simply return i,
j.
L if sum is less than the target,
then we simply increment j.
Okay, so now we can move the window forward.
So we are incrementing j if the sum is less than the target.
So we increment j.
But before we increment j,
we should add the jth element to maintain the running sum.
So here we say s plus equals j or array of j.
And remember j can take the value n as well.
So that's where we do this.
Only if j is less than n.
If there is a indeed an element for us to add.
This is an error we face last time.
And you will discover this when you write the test anyway.
And then we say l if s is greater than target.
And we can also just say else here,
but just for clarity let's say l if.
In this case,
what we want to do is we want to move i forward.
So suppose we end up in a situation like this.
And we want to move this forward.
For that we need to subtract s array of i first.
So we say s minus equals,
which is equal to s minus.
Which is the same as s equals s minus.
Area of i.
And then we increment i.
So we move the left window forward as well.
So we then repeat this.
So we first move j to a point.
Then we as soon as we cross the target,
we start increasing i.
And then we keep doing that to match the target.
And then finally we return non comma non,
if we have not found it.
So that's our sub array sum 3.
This is seems like the most optimized solution.
And let's test it out.
So here we have sub array sum 3.
And let's test sub array sum 3 here as well.
Seems like it worked.
Let's see.
So if you put in 10 here,
you get 2 comma 6.
Let's say this is 4 to 1 3,
0 comma 4.
Let's put in 12 here.
That doesn't show up.
Let's put in 17 here.
0 comma 5.
13 1 comma 5.
Let's try 19.
Let's 3 comma 6.
Let's see 1 plus 3 plus 7 plus 9.
Yeah, that has the value 19.
Let's throw in a zero there and see if it works with zeros.
Let's 3 comma 7.
Works fine.
And let's see if it doesn't work out.
Yeah.
Okay.
Great.
So this solution is correct too.
Again, if you don't have the option to run the code,
you can simply pick one example
and walk through the working of the example.
Now we have sub array sum 3.
And once again,
we are ready to analyze the complexity.
Would be somewhat tricky.
It's a little bit unusual because there is a while loop with two variables.
But remember that in each while loop,
either we exit, which is the best case,
so we can ignore that.
Or we either increment j or we increment i.
So we increment j or we increment i.
And if we increment.
So j can go from the values 0 to n.
And I can go from the values 0 to n minus 1.
So the total number of increments can be,
and we can do, is that some of the number of possible values
of i and number of possible values of j.
Remember, this is not a product this time,
because you do not have an estate loop.
So for each value of i, you're not doing this.
Rather, you are incrementing each one.
And I only one of them each time.
So the sum of total number of values i can take is n.
The total number of values j can take is n plus 1.
So the total becomes,
this number of iterations becomes 2 n plus 1.
Now, of course, there's the,
you can verify that a constant amount of work is being done here.
So we finally end up with the conclusion that this is an order n algorithm.
So this is finally an order n algorithm.
So this is a good example of a problem where the step by step,
solution coming up with a simple solution.
And then thinking about the,
the inefficiency in the problem and then applying.
In this case, just common sense to solve the inefficiency step by step.
leads to the perfect solution and a very good solution in fact.
So you start out with an order n cube solution.
The order n cube is going to be pretty slow when you start hitting.
Let's say even a thousand,
even a thousand elements.
If you have 10,000 elements that will take forever,
it will take maybe an hour or so.
If you have a million elements,
it will take hundreds of years on the other hand.
Order n can work fine.
All the way up to a billion element.
So there's a huge difference between the,
somewhere is some one, two and three.
So there is some three can work instantly for a billion elements.
So there is some one will take forever,
even for a hundred thousand elements.
And so baritou is in between,
and you can do the math.
And this technique,
where you can almost certainly tell what the next step is.
So this was not really related to any of the algorithms or data structures that we have talked about.
This is what is called a greedy approach where you know some optimal strategy about the problem.
In this case,
you know that we can calculate the sums by maintaining a running sum.
So we just do that.
And then you also know that as soon as it becomes greater than a target,
we need to break out.
And then you know the next thing that when becomes greater than target,
rather you can simply update I.
So this is what what is called a greedy approach.
Where you somehow know that just doing this will fix it.
It does no real technique to be applied.
And these problems are somewhat tricky,
but you get the hang of these problems as well.
If you search for greedy problems online,
you get the hang of these by solving a few practice exercises.
Okay.
So that's our first interview problem.
And we've solved it in about 45 minutes.
And this is approximately how long you will have for an interview.
In a couple of minutes of introduction,
maybe a few minutes just you talking about a project
and the interviewer asking you questions.
But then the next 30 to 40 minutes will be dedicated
towards solving a problem.
And this is what roughly the process will look at.
Let's do one more example.
Let's pick another interview question.
And let's see if we can solve this one.
So this is slightly different.
So this gives us one more variation to study.
By the way, to run these,
you simply click the run button and select run on binder.
Okay. So this is an interview question that was asked
during a coding interview at Google.
And the question is given two strings A and B.
Find the minimum number of steps required to convert A into B.
So what you can do is you can perform operations
in each operation is counted as one step.
And the operations you can perform on a word are these.
You can either insert a character into the word
or you can delete a character from the word.
So for instance here,
you can see that if you are trying to convert
Intention into execution.
So either you can insert a character, for example,
you could insert C here,
or you can delete a character.
For example, you can delete I here
or you can replace a character.
That is you can take N and replace it with E.
You can take T and replace it with X.
And E does not need to be replaced.
And here we've inserted C.
And then here we substituted N for you.
So we've taken the word intention
and by performing a few changes,
character by character by either inserting,
deleting or replacing a character.
We have converted it into the string execution.
So the number of steps required here is one, two, three, four, five.
Now here's a challenge for you.
Try and work this out on paper and prove that this is the best solution.
So because we need to find the minimum number of steps required to convert A to B.
So that's the problem.
And this is a moderately hard problem.
And variations of this show up as well.
So let's start applying the method.
Now when you hear the problem,
a solution may not strike you upfront.
That's perfectly alright, don't panic.
Sometimes when you're not able to immediately come up with a solution or identify how to solve this problem,
you enter a sort of panic and then you're unable to think.
Don't do that.
Remember have faith in the method.
And we will apply the method and come up with a solution.
Try by step.
So the first thing is to state the problem in your own words.
To give in two strings,
we need to perform operations a series of operations on the first string.
The operations could be a deletion of a character.
Substitution of a character with another character or insertion of a character.
And through these operations, we need to convert it into a second string.
Okay, we have understood the problem.
If the interviewer had not given an example,
either you can state the example or you can just ask for an example.
Whatever makes a works for you.
So we've stated the problem.
Now what are the inputs to the problem?
The inputs are two strings.
So the inputs are strings like intention and execution.
So let's see maybe let's call them STR1.
This is intention STR2.
This is execution.
Now one thing you have to be careful about here is you do not want to capitalize
because sometimes what might happen is this I may match up with an I here in the proper solution.
But Python obviously treats small and capital letters differently.
Python doesn't know what's that the I which is lower case in the I which is upper case is the same.
So you will not be able to compare them.
So just to keep things simple, either make everything up a case or make everything lower case.
But yeah, this is what the input looks like.
And the output is going to be a single number.
So the output is simply going to be the edit distance.
So let's just call it output 1 and it is going to be the number.
5 and here is something that you can verify.
So that's the input that's the output and function signature.
So of course this term edit distances how this problem is described.
But here there is no edit there's no concept of edit distance that's mentioned.
So you can give a function name that makes sense for this problem.
So find the minimum number of steps required to convert A to B.
Okay, so let's just call it min steps for now.
So the function definition would be min steps and this would take an STR1 and this would take an STR2.
And it would return an output for now we'll just put in pass here.
All right, so now we have already clarified the problem.
If you had any questions, this would have been good time to ask the interviewer and make sure that you have a clear understanding.
Now you have stated the input output and function signature.
The problem has been communicated back and forth properly.
The first step is done.
The next step is to list out some test cases.
Once again, a very good quality listing out some test cases.
So you can say that now I'm just going to list out a few cases that I want my function to cover.
So that they will help me it will help me by writing the code.
Now one is the general case, which is listed above.
So this would be intention execution and we can take a few more examples like this.
Now one example could be where no change is required.
So you are given the same strings.
One case could be that all the characters need to be changed.
So these are the two extreme cases.
One is no change is required and second is all characters need to be changed.
Maybe added removed deleted lots of such things.
Then you can check both strings of equal length.
So in this case they are in fact of equal length.
Unequal length you can check both strings of unequal length.
One of the strings is empty.
Your function should be able to handle that too.
Then you may check things like it will if something only requires deletion.
If something only requires addition or if something only requires swapping.
All right such things.
I guess this is pretty good at this point.
So now we can probably move forward.
So we have stated some test cases.
Now you don't need to create all the test cases right now in an interview.
It can take a bit of time.
So let's just move ahead and the next step is to come up with the simplest solution to the problem.
Which is also called the brute force solution.
So now we have a lot more information about the problem.
In this meantime probably it has sunk into you and you may have been able to think of a brute force.
But if not, don't worry there is a simple trick.
I'll tell you which you can apply whenever you are stuck and you can't think of a brute force solution.
So we are looking at you looking at it.
Intention and execution.
What am I going to do?
Am I going to start from the left and right?
How do I check which one is?
How do I know if this is going supposed to be inserted or executed or.
Replace or substituted or deleted.
So the simple trick is whenever you are in doubt.
Think about recursion.
See if there is a way to solve this problem recursively.
And what do you mean by solving a problem recursively?
Can you reduce the overall problem to.
A combination of one or more sub problem.
So if you take a portion of the input and can you solve the same problem on the portion of the input.
And then use that to solve the overall problem.
So let's see.
Let's see if there is a recursive solution possible here.
So here I have the same thing.
Intention and execution.
Now with recursive solutions normally either start by looking at the first character or the last character.
So let's look at the first character factor of each string.
So we've given these two strings and we need to find.
The number of operations to change this string into this string.
Let's look at the first character.
Now suppose the first characters were in fact equal suppose this was not.
Intention but it this was and tension and this was execution.
So now we compare the first characters and we know that the first characters are equal.
Okay, so if the first characters are equal then obviously neither of them needs to be deleted or.
It removed or obviously this character is not need to be deleted or removed or switched.
It's already matching.
So what we can do is we can just ignore the first characters.
And we can simply look at the remaining string.
Okay, so intention and execution because the first characters are already equal.
Let's write that down so that we don't forget it.
And this is the recursive solution.
Now this is where you can take a moment to work this out on pen and paper and that's perfectly all right.
What helps is to just talk keep talking about what you're doing.
But for recursion now first thing we know is if the first character is equal.
Then ignore from both.
So just ignore the character of both strings and simply recursively solve the problem for the sub list and the other sub string without the first characters in each of the strings.
So you exclude e and exclude e from this and solve the problem for these two perfect.
Suppose the first character isn't equal. So that's another case.
Right. So that is the case where you have intention and execution.
So if the first character is not equal, then either the first character has to be deleted or the first character has to be swap.
So you may have to swap i with e or the first character or maybe something needs to be added before the first character.
Okay. Now let's see one by one.
So if the first character is not equal.
Either it has to be deleted or swapped or a character inserted before it.
There are only three possibilities right.
Of course it's possible that we may do some other things can insert characters after it and so on.
But add that position after applying an operation either the first character will get deleted or the first character will get swapped and will be changed to e.
Or the first character will now change to something else in the first original first character will become the second character.
Now let's look at each case. The first case is it has if it is deleted.
Now the power of the duty of recursion is that we don't need to guess which solution it is.
We can try all three recursively and then simply pick the best one.
So suppose we choose to delete the first character.
So suppose we say that we are deleting the first character.
Now what that means is we've performed one operation and we've deleted the first character.
So now what we're left with is this.
So now what we end up is the second string is remain the same. Only the first string has changed where we have lost the first character.
Now what we end up with is with the sub problem where we need to find the minimum number of steps to change and tension and t e and ti on into execution.
Okay. So in this case if the it has to be deleted then recursively find.
Then recursively solve after ignoring first character of STR1.
Okay. That's one possibility.
And you get the recursive solution and you simply add one to it. That tells you the solution if you delete the first character.
The next option is that we change the first character i to e.
Now if we change the first character i to e. So one operation has been performed and then now these two have become equal.
Now that these two have become equal we can move this forward and we can move this forward.
Now we can simply recursively solve the problem for intention and execution.
Find the minimum edit distance between the two and simply add one to it to get the number of steps required to change intention to execution.
By swapping the first character right from i to e.
So in this case you recursively solve after ignoring the first character of each.
So it is one plus in both cases it is one plus the recursive solution after ignoring the first character of each.
Because the one operation is something that has been performed.
Okay. Now the final case.
The final case is you have intention and execution. Now we decide that we are going to shift this string forward and we are going to include we are going to introduce an e here.
So we are going to introduce e here.
Now what happens is the e is matching the e.
Now i has gone on to the first position.
So effectively what has happened is that we need to recursively solve the problem or the original string intention.
And the second string with the first character removed because we have inserted something before the first character in the first string.
So that is going to match with the first character of the second string.
And hence we simply need to recursively solve the problem for these two.
In this case what we are doing is the solution is one plus recursively solve after ignoring the first character of STR2.
Okay sounds good. Looks like we've done that. Now what's the end solution going to look like the end case. Remember in recursion.
This is all well and good but at some point we are going to hit some kind of an end.
So let's see. Let's see if we can define such an end scenario.
So maybe let's say we have been performing recursion and then we ended up at a situation like this where.
There is nothing left in the second string but you still have some characters left in the first string right.
So you are at this position now.
And here this is gone. There's nothing left in the second string.
So in this case to change recursive to change TION into the empty string all we need to do is delete all four.
So if you have a few characters if the second string becomes empty and you simply find the number of remaining characters in the first string and delete them.
So that is the number of operations or the other possibility is that the second string still has some characters.
But you've run out of characters on the first string.
So if you run out of characters on the first string but the second string still has some characters.
Then in that case what you need to do obviously is you have the empty string and you need to take this.
You convert this empty string into TION that is a recursive problem you're solving.
So you that you can do by adding TION.
So you add TION and that is again going to be four steps which is the number of characters remaining in the second string.
So these are the two end cases. Now of course if both of them are empty then the answer is zero but if either of them is empty the answer is the number of remaining elements in the other one.
So let's write the solution.
Now we figured out the solution it took some time but again this is not a very straightforward problem.
There are a few cases to figure out.
And while you are doing this while you're identifying each case either you can say it out loud to the instructor or you can write it as a comment.
Whatever you feel more convenient with because the interviewer cannot see the work that you're doing on paper.
So it's very important for you to be able to convey it and that is why all this while in this course we have been saying that you need to express the solution in simple words.
Because you need to L the other person that you know the solution and they should be able to understand what you're saying without looking at your work without looking at the images that you've drawn.
And a great way to do it is either by writing or by speaking.
Let's define it then death.
What's it called min steps?
And min steps is it takes STR1 and STR2.
Great.
Now we are doing recursion and in recursion what we're tracking is the which character we are currently at.
So we could be at the 0th character or the first character or the second character in string 1.
And we could be at the 0th character second character in string 2.
So the starting point of this window determines the sub string that we're solving the problem for.
So ideally, when we want to solve this problem for these two substrings, we can simply pass those substrings.
But creating sub substrings as a cost because you have to copy those characters out and then allocate some memory and put them into a new place.
So an easier way is to simply keep a pointer.
So we will keep two pointers, i1 and i2.
And these will signify that we should be skipping while computing min steps.
We should be skipping the first i1 characters or we should be starting from the i1 index.
And we should be starting from the i2 index for STR2.
So in your window, if the i1 th index, if the starting index is equal to the length of string 1.
So this is the end case and remember the end case while coding is always written first.
So if this is equal to length of STR1, then we have known we have seen here that we need to perform these many additions.
So we simply return in this case STR, length of STR2 minus i2.
And you can verify that this is the amount number of additions required.
L if on the other hand i2 is equal to length of STR2.
So which means that you have exhausted the second string but the first string still has some values left.
So in this case, you need to remove the delete the remaining values in the first string.
So you just type length of STR1 minus i1.
So these we have now solved the trivial cases.
Now let's see L if STR1 of i1 and STR2 of i2.
Which means the first characters of each substring that we are working with.
Remember we are just using arrays as a, we are just using indices as an optimization.
What we really want to work with is substring.
So the first character of each substring, STR1 of i1 and STR2 of i2 is equal.
Now if the first character is equal, e and i are equal, then we simply ignore both and solve the problem.
So all the problem for the remaining string.
So we simply say return main steps.
And we pass in STR1, we pass in STR2.
And then we simply pass in i1 plus 1 here and we pass in i2 plus 1 here.
So what this is saying is that now we want to recursively solve the problem.
Or a substring starting at i plus i1 plus 1.
So we have ignored the first string of the current substring.
Similarly we have ignored the first character of the current substring or of the second string.
So we ignore the first characters and that's it.
And there are no steps required here.
No operations required here right now because the first characters are equal.
Now finally, this is the final case else.
Here we want to return one.
So we have to perform one operation.
Either it is an insertion, deletion or swap.
And what we can do is we can recursively check the first or the number of minimum steps required for each case of insertion deletion and swapping.
And simply pick the minimum one.
And if to it we add one.
Then we get the total minimum number of steps we need to perform for the entire list right.
So again recursion is very useful because you can simply assume that you have the function which solves the problem.
And you simply need to take the result of the sub problem and combine them.
So we take the minimum of the first option is if the first character of str1 has to be deleted.
So which is let's say we choose to delete i.
If we choose to delete i, then that means we have to solve the problem for these two.
So we say one plus recursively solve the problem after ignoring the first character of str1.
So we solve main steps for str1, str2.
Now since we've deleted the first character of str1, we can skip ahead into the next.
Because we are solving the problem now for the starting from the next index.
And i2 remains the same right.
So remember here we have not affected it.
So we need to solve this problem recursively.
So this was the case of deletion.
Next we have the option where you have swapped the first character.
So we have taken e and we have converted that it in, we have taken i converted it into an e.
If we did that, so then we can say that we can now these two characters are matching.
So now we can simply recursively solve the problem for the next character onwards after ignoring the parent character.
So this becomes str1 plus str2 plus i1 plus 1 plus i2 plus 1.
So this is swap or replace.
And you might notice that this is this turns out to be the same recursive call as this.
Except that we will add one to it because we have done the swap.
And finally if you are adding, so if you're adding inserting.
So finally if you're inserting here something, so if you are inserting e here, let's say.
So in this case, what we'll do is now we'll recursively solve the problem for intention and execution without the e in front.
So we skipped the first character of the second string.
So we have main steps str1, str2, i1 and i2 plus 1.
So this is rather nice in symmetric.
And that's it. So this should be it. Let's run this.
Okay, there is a syntax error here that's perfectly fine.
There needs to be a comma here that's fine to.
I make a lot of syntax errors all the time and of course off by one errors, I'm sure there are a few.
But yeah this is the minimum number of steps and this is the recursive function not too bad.
Two four six around eight lines of code.
And let's test out some of the test cases here.
I'm just going to copy the test cases out here below.
And let's test a general case which is intention and exception.
So let's see main steps.
Intention and exception.
It says five four.
Okay, why does it say four?
Maybe let's test.
Let's test a more simpler case first which is one of the strings being empty.
Let's say we have intention and one of the strings is empty.
So we will need to delete a let's just say int and one of the strings empty.
This looks fine. We will need to delete all three of these.
And that in some way tests out this case where.
Also it tests out the second case where the second string is empty.
Now we can test this case.
In this case also the in this case also the solution is three great looks fine.
Let's test this case where STR one I one and STR two I two are equal.
So if you have integer and let's say you have India.
So I and I and would be the same.
So these would get skipped and here is where the recursion would kick in.
So if you would have to be changed to D and then you would have to add I and E.
That looks fine too.
And let's check intention and exception once again. I don't know what's wrong here.
Let's see.
So possibly is it possible to do it with four I don't know it's maybe possible to do it with just four changes.
If you change I you delete I and then you delete N and then you delete P.
Delete I substitute these two.
I don't think it is possible which is four changes.
So there's probably an issue.
I don't know what's wrong here. It's possible I may have made a mistake here.
Let me try another Saturday and Sunday.
Okay. So Saturday, STR needs to be changed to Sunday, SUN.
Now S is the same. So ATUR needs to be changed to UN.
So you remain the same. Now if we can what we can do is we can probably delete a delete P
and take replace R with N. So this seems to be fine.
All right. So we'll probably unless I'm not seeing this.
So you have in tension and you have exception.
Unless I'm not seeing something it seems like we may have made a mistake.
One thing we could do is we can simply print out the strings that we're checking.
So let's see STR one is Ivan onwards and STR two is I two onwards.
We were first checking intention and exception. Then we check.
It's also print the result here.
Okay. So at this point, I would probably look through the loop here and see if it is correct.
Coming properly. So you have intention and exception. First we delete I.
Then we delete N. Then we delete T. Then we delete okay. Then we compare ENE.
So then we come back to N and exception and so on.
I think we'll have this might take some time to fix.
We'll come back to intention and exception.
But supposing we've solved the.
Supposing we've written the recursive solution correctly.
We have the recursive solution here.
So let me just grab that and put that in here.
Let's see what's different.
Okay. Probably the answer is four because I'm still getting four.
But supposing we have the recursive solution here.
We have main edit distance. This is the recursive solution.
And now what you need to do is you need to find out the complexity of the recursive solution.
Now to find the complexity of the recursive solution.
What we can do is simply look at the recursive calls in the worst case.
So how you start out is you start out with a string of length N1.
Let's say an string of length N2.
We have one string of length N1 and one string of length N2.
Then you call either you call this main edit distance with I1 plus 1 and I2 plus 1.
So STR1 and STR2 you call them with I1 plus 1 and I2 plus 1.
So that's one possibility.
Or you call three recursive calls.
Now one recursive call is the good case where these two match up.
So we want to look at the worst case where these two things don't match up.
So in that case you make three recursive calls.
So you make three recursive calls.
And in each recursive call you are then going to reduce the problem size by one.
So you're either going to decrease I2 or you're either going to decrease the size of the first string.
Or you're going to decrease the size of the second string.
Or you're going to decrease the sizes of both strings.
So just to keep things simple.
Let's assume that in all three we are decreasing the size of either one of the strings by one.
So we are decreasing the total problem size which is N1 plus N2 by one.
So the number of levels of recursion is going to be the total number of total length of each of the two strings.
So let's maybe just draw that graph here as well.
So let's take this.
So here you have N1 comma N2.
So let's assume these are the lengths of the two strings.
Now N1 plus N2 what happens to it is that this N1 plus N2 calls three recursive functions.
So there are three recursive functions.
So let's just draw those three recursive functions.
So we have those three recursive functions here.
Let's take this two.
And then those three recursive functions.
What we have is.
You reduce either you reduce the size of the first string or you reduce the size of the second string or you reduce the size of both strings.
So either you end up with N1 minus 1 and N2.
And let's reduce the size of that.
We end up with N1 and N2 minus 1 or we end up with N1 minus 1 and N2 minus 1.
So these are the three recursive calls that we're doing.
And then each of these will once again make three more recursive calls.
And so on. Now what is the depth overall depth of this recursive call?
Now because we can see that each time the size of the problem reduces by one.
So if the size of problem is N1 by plus N2 in this case it reduces by one in this case it reduces by one.
And in this case it reduces by two but for simplification let's say it reduces by one here.
So the total size of the problem the total number of levels in this tree is going to be N1 plus N2.
So you have three problems in the first layer the second layer we'll have three square problems.
The third layer we'll have three cube problems the three times three times three.
And similarly you can go ahead and you'll find that at the last layer you'll have three to the power N1 plus N2 minus 1 layers.
And if you then altogether all the layers what you end up with is that total total number of sub problems is three to the power N1 plus N2.
So you have a total of three to the power N1 plus N2 sub problems that you end up creating.
And because of that you have the complexity three to the power of N1 plus N2 in this case.
So that's that's the complexity so here we have a recursive solution and then we have the complexity of the recursive solution which is exponential three to the power of N1 plus N2.
Now at this point it will make sense to add memoizations so whenever you see recursive solutions and you see repeated problems for example here itself you can see a repeated problem.
And then you can see that this problem will get repeated inside this problem and inside this problem too.
So there are a lot of repetitions and all we need to do is remove some of those repetitions and to remove those repetitions we can use memoizations.
So what happens in the memo solution it is exactly the same as the recursive solution but before doing any computation we check a memo we check a dictionary if we already have the solution for the changing variables which is I1 and I2.
And if we have those.
If we have those solutions what we need to do is just return them directly.
If we do not have those solutions we need to compute the solutions put them in the memo and then return the value from the memo.
So let's write the memo is version so we have min edit instance with STR1 and STR2.
And this we are calling memo.
Okay this we are calling memo.
Now we have a memo.
The memo is going to be a dictionary and the dictionary is empty and then we define a function recurs.
So in memoization normally of to write a recursive helper function now you can either write this outside or inside.
Because well it will have access to STR2 and they do not need to be passed in.
So here we have I1 and I2 and first thing we do is recreate a key so the key is I1 comma I2.
Now if key in memo which means if we have already computed the solution then we simply return memo of key.
If not then we have all the other cases so now we have LIF.
Now we can check if I1 equals LEN of STR1.
In that case don't return set the memo of key to LEN of STR2 minus I2.
LIF I2 equals LEN of STR2.
Then we return memo of key is LEN of STR1 minus I1.
Okay in this case then we check if the first elements are equal.
We have the exact same logic you can see the same cases coming up here.
So if you have STR1 of I1 equals STR2 of I2.
In this case we have memo of E equals.
We simply ignore the first characters so we increment I1 and I2.
So exactly what we have done here.
So we simply call recurs this time with I1 plus 1 and I2 plus 1.
So we always call the recursive function but inside the recursive function.
If it is already been computed it will return from the memo.
And finally if we have and this is the final case which is where they are not equal.
So here memo of key becomes 1 plus min of let's see here.
So we have recurs so the insertion cases.
We will ignore the first element.
So the deletion cases we will ignore the first element of the current range from the first string.
So we recall recursive I1 plus 1 and I2.
Otherwise we call recursive with I1 plus 1 and I1 plus I2 plus 1.
This is the case where we swap the first element of the first string.
So we can just recursively check after ignoring the first element of each.
And then we have recurs with I1 comma I2 plus 1.
I2 plus 1 and there we go and that's it.
So now we have stored it in the memo and then we simply return memo of e at the variant.
And finally we call recurs 0 0 and that is our solution.
And there is a syntax error you can fix these syntax errors easy to fix.
And I've just realized that the solution in this case might actually be for because what we can do is we can change n to p.
So that's one step.
We can replace i n t with e xc.
So we replace i n t with e xc that's three changes.
We don't change e and we replace n with p.
The solution is for so our solution was correct.
There was no issue there.
In fact, this is not the best solution.
This is a sub optimal solution.
So this output should be forward.
And that's okay.
This is something that happens all the time where you miss something.
And you just assume that you just say that you're going to come back to it at the end and then you move forward.
And you're assuming that that code was right and then you realize either you are correct or what you mistake was.
It's probably going to happen in one of five interviews anyway.
Okay, so now we've written a memo is solution.
Great.
And we can start checking the memo is solution now.
So minimum edit distance memo.
Let's call main edit distance memo.
And we get back the value for looks fine.
Let's try sat 30 and Sunday as we have.
So that's three.
So what you will do is you will leave a as it is change 80 you are to UN by removing 80 and changing R to N.
That seems fine.
Let's test out some cases like this.
Okay, this is three six eight characters.
So that seems right.
We simply delete all the characters.
Let's check out this.
Here also eight characters.
We have to add eight characters.
Let's say we have ABC and XYZ.
So this should be three.
If it is XYZK, then maybe that will be four.
What if it's XYZA?
In this case, also it's four.
So this seems to be working fine.
We have now taken the recursive solution.
Identified the inefficiency calculated the complexity which was exponential.
Identified the inefficiency and then which was repeated sub problems.
And then fix the inefficiency by calling main edit by using memoization.
And now how do you compute the.
Time complexity of memoization.
Well, the argument is if you only need to compute the solution for a key once.
And the computation apart from the recursive calls simply involve some comparison and a fixed number of comparison and an addition.
So the time required to compute assuming you have the recursive solutions is constant.
So that means if you simply count the number of memoizations that can possibly occur.
That gives you an upper bound on the total number of operations.
It will be some multiple of that some constant multiple.
So I want can take the values 0 to n1 where n1 is the length of string 1.
And I do can take the values 0 to n2 where n2 is the length of string 2.
So memo the keys and memo can be i1 comma i2.
So we have n well n1 values for i1 and 2 values for i2.
So that makes it n1 times n2.
That's a number of keys and that because there's a constant amount of time.
Extraditional time required to compute the solution for a key.
That is also the complexity. So the complexity is order n1 plus n2.
So we've gone from 3 to the power of n1 plus n2 which grows very quickly.
Even for 3 to the power of.
3 to the power of 10 is pretty high.
We can check it out here 3 to the power of 10 is something like 59,000 3 to the power of 100.
So if you have n1 plus n2 then that's e to the 47 that's going to be a lot of operations on the other hand.
If for in with memoization it is only going to take let's say the 100 is split as 2 strings of length 50 and 50.
Only going to take 2,500 operations.
So where it towards taking 10 to the 47 operations now it takes only.
2,500 operations which is pretty small.
And still work with lists of size up to 10,000 or 100,000 very easily using the memoization.
So that covers this problem.
And keep talking through your solution even as your stock even as your confused just as I was.
It's helpful to just keep spend maybe 2 or 3 minutes trying to solve the issue.
And if you're not able to solve the issue just say that this is something I'll fix later and then move on assuming that you fixed it.
And then keep talking and keep continue keep working on the solution and at some point later it's possible that the solution might try to.
Okay.
Now at this point.
You may be asked sometimes to implement a dynamic programming or an iterative solution.
Like though when you talk to the interviewer and you're telling them that this is how I'm thinking I'm doing.
To a recursive solution first and I can see that maybe there are going to be some problems there then I'm going to then apply dynamic programming.
So you can just check with them and in most cases they will accept a memoization solution because the dynamic programming solutions can take a little bit of time to solve to.
And they're always off by one errors and it's also difficult to explain the solution so you can most most cases get away with memoization but if they do ask you to do it with iteratively with dynamic programming then you'll have to go ahead and implement the dynamic programming solution.
So once again take a couple of minutes now and work it out on a piece of paper and then go back to them now for dynamic programming remember you have to create a table essentially.
So what the table will look like in this case is let's see if we can simulate a table.
So what the table will look like is.
Let's create a new sheet.
And in this sheet let's put the two words which is intention.
Okay and let's put the word exception as well.
Move this down to and let's also put in the indices ultimately this is what a dynamic programming looks like programming problem looks like.
You are ultimately going to create a table here.
And how we start filling the table is the ijth element so let's say this element.
So this element represents the edit distance or the number of operations required to convert i and p e into e x c e.
And how do you check what the solution is now you know that e and e are equal so the final elements are equal so what that means is we look at this value then this value should tell us.
What is the minimum edit distance between e x e and e x e now since we can simply add e to each string and get this solution.
That means this solution is equal this value should is equal to this value alright.
So in the case where the corresponding elements are equal we simply copy over the value diagonally left top left value onto the current cell.
The other option is if they are not equal so let's say if we are here where here you have n and here you have p now there are three possibilities you you want to find.
The minimum edit distance between i and t and e x c e p.
Now n is not equal to p and this is the original string so either we delete n now if we delete n then we need to find the solution for i and t e and e x e p.
So if we delete n then this value will become one plus this value that's one possibility.
Or another possibility is that we swap n so we swap n for p.
So now you get this becomes p and this becomes p so this value becomes will become one plus this value because now we can ignore the p and simply get this previous solution for e x e and i and t e.
So this value becomes one plus this value.
Or the final option is that you can insert something just before n so if you insert something just before n.
Which is going to be p so if you insert p just before n.
So if you insert p just after n or before if you insert p just after n then you have p after it already so you can just look at this value.
And this value is going to be one more than this value in the case that you insert something insert p after n.
Right so there are three ways to come to this value either by deleting n or by inserting p or by changing n to p and what you can do is you can take the minimum of three values or these three values and add one to obtain this value.
So that's the logic roughly speaking and you start from the left so you see okay e and i they're unequal so you need one operation to change them and there's nothing else to consider so that's done.
Then e and n they are unequal now you need what you can do is you can either delete n.
If you delete n then you simply need to check e and i.
And you know that the solution for e and i is one so this would be two.
Another other option is that you could possibly insert something but if you insert something the length of i and is going to increase so that's going to cause a problem.
So you can't insert anything another option is you change n with e but if you change n with e then you will no longer be able to.
If you change n with e then you will no longer be able to use this solution.
Right because now you will have to match i with the empty list.
So that's going to be one as well so overall you end up with two and this is how you start filling the list so you start filling up from left to right.
And left to right and keep going top to bottom as you fill out this list finally you will fill out this final value exception and intention and that will be your solution.
So that's the dynamic programming solution and you can see that it's getting tricky to convey the entire solution because there are so many cases involved here so typically you will not find dynamic programming solutions to requested in interviews and it will help you to just stick to the memoization solutions.
All right.
So with that we have covered two common interview questions and you can keep going the idea here is to just apply the method.
Remember the remember the method the problem solving template that we've covered state the problem.
Identify input and output formats write a function signature come up with some example inputs and outputs or at least the scenarios come up with a correct solution stated in plain English.
Implement the solution.
Estet using example inputs and fixed bugs if you face any then analyze the algorithms complexity and identify inefficiencies and finally apply the right technique to overcome the inefficiency and you repeat the process.
Going back and stating the solution implementing analyzing and repeating now you in some cases you do not need to implement the root force solution if you don't have the time.
But when you're working with recursive solutions it always helps to implement brute force first before you do memoization or dynamic program.
And some tips ask questions as many questions as you can as many as you need to clarify the problem show an example all of the method don't panic.
If you get stuck it's a certain point.
Give it a couple of minutes sometimes you can even ask the interviewer and they may be able to tell you that.
Maybe what your error is or maybe you're not stuck at all what your resume you're simply assuming something incorrectly.
But beyond a few minutes what you want to say is that let I fix this later assuming this is correct let's move on and then talk about complexity and optimization and such and such and such.
Very important is to state the brute force solution to the interviewer and if you are unable to figure out a more optimal solution.
Then the best thing you can do is to offer to implement the brute force solution so that you can at least demonstrate that you are able to write code and it's all right in a lot of cases you will not be able to figure out the optimal solution and in some cases there may not be an optimal way.
So there are some there are certain problems where there is just one way and that is the hard way or the brute force way and this is typically very true with a family of problems called back tracking something we've not really covered in a lot of detail.
But it is also another form of recursion.
So what you do next so the next step for you is to review this lecture video and solve these problems yourself or take more problems ideally what you want to do is you want to take all the five different techniques that we've covered and let's quickly review what those five techniques were.
The first one was binary search so we looked at linear search and binary search which is a form of dividing conquer.
And along with that we also understood the complexity and big connotation and then you had some homework on linked list and python classes.
But binary search is something that comes up often and the hint to detect binary search is simply to look for order whenever you see something being something being mentioned mentioned as sorted.
Now that is an indication for you that this may be binary search sometimes what you may have to do is you may have to get things into a sorted form maybe by taking.
Replacing elements by some of values till that element or so on and once you get things into a sorted form maybe then you can do binary search.
That's one way to go about it and once again just two five to ten problems on binary search and you will be able to identify pretty much any binary search question in an interview.
Then the next topic that we looked at was binary search trees traverses and here is something.
That is a generally asked very directly so you will be given a question like binary search tree do something with a binary search tree.
And you can answer that question directly we've covered a lot of different things here so do check out lesson two for all the different things you can do with binary search trees traverses balancing.
And most of these are recursive solutions so it's also good exercise on recursion and we also looked at balance binary trees and how can we optimize them further.
Then you had an assignment on hash tables so hashing is a again a common question that is often asked so we built hash tables from scratch in python and we also handled collisions using a technique called linear probing.
And so this is something you can check out in assignment two so you may get asked just to implement a hash table and python or implement.
Pollution resolution in a hash table in which case you can use linear probing.
Then you have the sorting algorithms where we looked at bubble sort and insertion sort merge sort using dividing conquer and quick sort where we had a quadratic worst case complexity but.
a logarithmic average complexity and that's a good thing because merge sort although it is logarithmic in the.
Worst case it still takes up a lot of space and space allocation is slow and you may also not have the memory.
So that's why we sometimes use prefer quick sort over merge sort when we are constrained for space.
Then assignment three is pretty interesting variable implement an optimal algorithm for polynomial multiplication using dividing conquer so to check out assignment three as well.
Then we looked at dynamic programming we looked at recursion memorization sub sequence and abstract problems and then we finally also didn't cover back tracking and pruning but we'll there are some questions there in the lesson notebook which you can try out which use back tracking and pruning as well.
Then we looked at graph algorithms the last time which was graphs and adjacency list and adjacency matrices.
We looked at the depth first and breadth first search and how to implement them and we also looked at shortest parts and directed and weighted graphs.
This is a very important topic breadth first and depth first search you will get many questions related to these so do solve maybe five questions on each of these topics.
And you should be good with most graph problems as in interviews.
Now this project for you the course project if you haven't seen it already is to pick a coding problem so you can pick a coding problem from an online source like lead code hacker rank geeks or geeks etc.
And then use the problem solving template that we've shared with you.
This problem solving template as a starting point so just give it a name and then write the problem statement and implement the solution step by step.
Use the problem solving template to solve the problem using the method you've learned in the course then document your solution add explanations wherever required or form the complexity analysis.
All of this you should add in the Jupyter notebook and then publish your notebook to your joven profile.
And finally you can submit the link to your joven notebook here.
And you can check out the discussion where you can change where you can post what you what you're working on to do post your notebook as well.
And finally today we have looked at a couple of real interview questions from Amazon and Google and how to go about solving them.
And we also addressed a few issues that we faced along the way.
So that was a helpful exercise.
And that's it. So now you can review the lecture video, execute the Jupyter notebooks complete the assignments and attempt the optional questions.
So that the topics that we've covered they get consolidated and you do not ever have to look at this lecture again.
The practice is what really reinforces and consolidates your learning.
Complete the assignments and attempt the optional questions to practice and participate in forum discussions also very useful when you participate in forum discussions.
Why by answering questions a lot of your own doubts get cleared to do participate in forum discussions and then join or start a study group of possible getting together with a group of four or five people is great.
It really helps you focus and improve your understanding by discussion.
So that status structures and algorithms in Python with that.
Thank you very much for joining us on this journey as we learn data structures and algorithms in Python.
A very useful topic to improve your coding skills and also something that you will almost certainly encounter in one of your interviews no matter which company you're applying to.
So I hope this is helpful to you. Do let us know on the forum how this course helped you if it did.
You can let us know in the YouTube comments as well. If you have questions if something was not clear do post that to when we make sure to come up with clearer explanations and clearer examples the next time.
And if you have any feedback for us to post it in the comments or send us an email at support at www.ai.
With that, I will take leave and I will see you in the forums. This is not the end of our journey with you.
So do stay active on Jovind. There's a lot of great activity happening. Do check out the forums. The newsletter and stay tuned for our next course. Thank you and goodbye.
Introduction to DS & Algos
Course Overview
Lesson Structure
Understanding Jupyter Notebooks
Problem Solving Strategy
Find Card Position in List
Minimizing Accesses
Defining Problem Clearly
Function Signature and Structure
Creating Test Cases
Brute Force Solution Overview
Understanding Linear Search
Implementing the Solution
Testing the Function
Utilizing Evaluate Test Case
Worst Case Analysis of Problems
Understanding Algorithm Efficiency
Algorithm Complexity Terms
Time Complexity Explained
Space Complexity Overview
Understanding Big O Notation
Dropping Constants in Complexity
Analyzing Time Complexity Trends
Understanding Linear Search Complexity
Introduction to Binary Search
Analyzing Iterations in Algorithms
Understanding Logarithmic Outcomes
Time Complexity of Binary Search
Space Complexity of Binary Search
Comparing Linear and Binary Search
Evaluating Test Case Performance
Linear vs Binary Search Timing
Understanding Algorithm Optimization
Implementing the Generic Strategy
Binary Search in Practice
Understanding Edge Cases in Binary Search
Importance of Debugging Techniques
Analyzing Algorithm Complexity
Submission Options for Binary Search Assignment
Final Steps in the Binary Search Practice
Handling Submission Errors
Testing Your Function Properly
Understanding Assignment Submission
Exploring Optional Interview Questions
Introduction to User Profiles in Python
Defining Special Functions in Classes
Creating a User Database Class
Implementing Methods in User Database
Testing User Profiles and Database
Analyzing Time Complexity of Operations
Using Jovian to Save Notebooks
Understanding Binary Trees
Properties of Binary Trees
Introduction to Binary Search Trees
Implementation of Binary Trees in Python
Creating a Tree from Tuple
Understanding Recursion Basics
Converting Tree to Tuple
Visualizing Tree Structures
Binary Tree Traversals Explained
Understanding Binary Search Trees
Properties of Binary Search Trees
Checking if a Tree is a BST
Finding Minimum and Maximum in a Binary Tree
Inserting Nodes in a BST
Finding Nodes in a BST
Updating a Node Value
Listing All Key-Value Pairs
Understanding Tree Balance
Creating Balanced BST from Sorted List
Performance of Balanced BSTs
Insertion Complexity Analysis
Improving Data Structure Efficiency
Defining a Tree Map Class
Exploring Special Methods in Classes
Understanding Binary Tree Efficiency
Key Properties of Binary Search Trees
Operations on Binary Search Trees
Understanding B Trees in Databases
Introduction to Hash Tables
Creating Custom Test Cases
Hashing Function Overview
Implementing a Hashing Algorithm
Retrieving Data Using Hashing
Using List Comprehension in Python
Creating a Hash Table
Inserting Key-Value Pairs
Updating Values in Hash Table
Handling Data Collisions
Implementing Linear Probing
Hash Tables vs. Binary Search Trees
Understanding Assignment Two
Introduction to Lesson Three
Overview of Sorting Algorithms
Executing Code in Jupyter Notebooks
Understanding Bubble Sort
Bubble Sort Steps and Explanation
Implementing Bubble Sort Functionality
Testing the Bubble Sort Implementation
Analyzing Bubble Sort's Time Complexity
Insertion Sort Complexity Analysis
Free Online Jupyter Notebooks
Capturing Jupyter Notebooks with Jovind
Understanding Divide and Conquer
Introduction to Merge Sort
Understanding Print Statements for Debugging
Analyzing Merge Sort Efficiency
Understanding Merge Sort Recursive Calls
Exploring Merge Operations in Depth
Understanding Time and Space Complexity of Merge Sort
Understanding QuickSort Basics
Partition Function Mechanics
Recursive Calls in QuickSort
QuickSort Performance Evaluation
Space Complexity of QuickSort
Sorting Objects by Likes
Creating a Notebook Class
Implementing Custom Comparison
Merging Sorted Notebooks
Using Merge Sort with Objects
Merging Sorted Lists with Custom Comparison
Sorting Notebooks by Title
Applying Comparison Operators in Sorting
Exercises on Sorting Implementations
Introducing Problem Solving Templates
Creating Test Cases for LCS
Identifying Recursive LCS Scenarios
Constructing the Recursive Function Logic
Understanding Recursive Solution Structure
Implementing the Recursive LCS Solution
Understanding Recursive Function Complexity
Analyzing Recursive Function Inefficiencies
Implementing Memoization Technique
Tracking Intermediate Results
Improving Performance with Memoization
Understanding Memoization vs Dynamic Programming
Dynamic Programming Fundamentals
Creating and Filling the Matrix
Comparing Elements for LCS
Filling Out the DP Table
Understanding Memoization vs Iterative Solutions
Dynamic Programming Problem Solving
Exploring the Knapsack Problem
Defining Input and Output Formats
Maximum Profit Calculation
Example Inputs & Test Cases
Identifying Optimal Solutions
Developing Recursive Solutions
Recursive Function - Max Profit
Dynamic Programming Approach
Filling the DP Table for Knapsack Problem
Handling Off by One Errors
Understanding Dynamic Programming Complexity
Navigating the Dynamic Programming Forum
Introduction to Graph Algorithms
Understanding Graph Representation
Paths and Neighbors in Graphs
Introduction to Adjacency Lists
Creating a Graph Class in Python
Building the Adjacency List
Implementing Graph Traversal Techniques
Implementing Graph Traversal Techniques
DFS - Depth First Search Introduction
DFS - Depth First Search Introduction
Creating Lists of Empty Lists
Understanding Adjacent Lists in Graphs
Initializing the Graph with Edges
BFS - Breadth First Search Introduction
BFS - Breadth First Search Introduction
Using Enumerate to Simplify Code
Printing the Graph Structure
Comparing DFS and BFS
Comparing DFS and BFS
Real World Applications of Graph Traversal
Real World Applications of Graph Traversal
Understanding Heaps and Priority Queues
Heap Operations Explained
Binary Heap Introduction
Building a Max Heap from Array
Heap Sort Algorithm Overview
Understanding Heap Sort Algorithm
Heap Sort Steps Explained
Performance Analysis of Heap Sort
Applications of Heap Sort
Comparing Heap Sort with Other Algorithms
Dijkstra's Algorithm Explained
Running Time Complexities Overview
Analyzing BFS Complexity
Understanding Shortest Path Algorithm
Improving Dijkstra's Algorithm with Min Heap
Understanding Sub-array Problems
Asking for Clarification in Interviews
Identifying Input and Output Formats
Developing Test Cases for Sub-arrays
Implementing Brute Force Solution
Brute Force Solution Analysis
Analyzing Complexity of Solutions
Identifying Inefficiencies in Logic
Optimizing the Brute Force Approach
Implementing Optimized Subarray Sum
Implementing Subarray Sum Optimization
Testing Subarray Sum Implementation
Understanding Complexity of the Algorithm
Introduction to Edit Distance Problem
Recursive Edit Distance Explanation
Analyzing Character Deletion and Insertion
Final Recursive Function Steps
Testing Edge Cases in Recursive Function
Implementing Edit Distance with Recursion
Analyzing Recursive Solution Complexity
Introduction to Memoization
Optimizing Edit Distance with Memoization
Understanding Memoization Complexity
Dynamic Programming Implementation Steps
Explaining the Edit Distance Table
Reviewing Problem-Solving Techniques
Participate in Forum Discussions
Collaborating in Study Groups
Completing Assignments and Optional Questions
Conclusion and Closing Remarks
Understanding Recursion Concepts
Base Case in Recursion
Recursive Function Implementation
Visualizing Recursive Process
Understanding Stack Overflow
What is the course structure and focus?
How to access the course resources easily?
What is the first programming problem we'll tackle?
Why learn data structures for interviews?
How many times should you access elements in the list?
What is the importance of stating the problem clearly?
Why write out test cases before coding?
How do you handle edge cases effectively?
Why is it crucial to share your brute force solution during the interview process?
How can you express your algorithm in your own words for better clarity?
What strategies help you pinpoint errors in your code effectively?
How to ensure your solution passes all test cases after changes?
How many minimum cards does Bob really need to flip?
What does algorithm analysis focus on beyond execution time?
Why is worst-case complexity crucial in programming?
How does input size impact the space used by algorithms?
How do we represent worst-case complexity with Big O notation?
Why drop constants when analyzing algorithm complexity?
What are the steps for applying binary search effectively?
How to fix issues with binary search for multiple occurrences?
How is k related to n in binary search iterations?
What does the logarithmic time complexity look like in binary search?
Can you explain the difference in complexities between linear and binary search?
What happens when we use large test cases for algorithm comparisons?
What are the fundamental principles behind recursion?
How do you effectively establish a base case in your function?
What steps are needed to implement a recursive solution in Python?
How can visualizing recursion help in understanding the concept better?
How much faster is binary search compared to linear search in large datasets?
What is the significance of distinguishing between the left and right in binary search?
Why is discussing your understanding of problems crucial in interviews?
How does the number of iterations play into algorithm complexity analysis?
What edge case should we consider in binary search scenarios?
How can print statements assist in debugging during tests?
Why is it crucial to analyze your algorithm's performance?
What are the steps for submitting the binary search assignment?
What is the best strategy for testing your function before submission?
How can you effectively handle errors during code submission?
What are the key operations for managing user profiles?
How do you represent user profiles using classes in Python?
What are the special functions defined in a class?
How to create a user database class in Python?
What scenarios can be used to test user database methods?
How does time complexity impact database operations?
What is the main advantage of using Jovian for Jupyter notebooks?
How does a binary tree structure work in data organization?
What essential properties define a binary search tree?
How can you implement a binary tree structure using Python?
What method can convert tuples into tree structures effectively?
How does recursion play a role in creating binary trees?
What are the steps to perform in order traversal of binary trees?
How does pre-order traversal differ from in-order traversal?
What defines a binary search tree's unique properties?
How do you verify if a binary tree is a BST?
What are efficient ways to find tree minimum and maximum keys?
How does the insertion process affect tree structure?
How do we find a node in a balanced tree efficiently?
What’s the best way to update a value in a binary search tree?
How does in-order traversal yield sorted key-value pairs?
How much faster is a balanced BST compared to a regular list search?
What is the biggest advantage of using the right data structure for users?
How can we encapsulate functionality for ease of use in Python classes?
How does maintaining balance improve binary tree efficiency?
What are the key properties that make binary search trees useful?
Why are hash tables crucial for data retrieval speed?
How can you create your own test cases effectively using asserts?
What makes a good hashing algorithm and how to implement one easily?
How does list comprehension simplify operations on lists in Python?
How do you prevent data loss in a hash table due to collisions?
What happens when two different keys hash to the same index?
How can you find an empty index in a hash table using probing?
When should you use hash tables over binary search trees?
How do we effectively test sorting algorithms with various cases?
What is the most efficient way to handle millions of notebook entries?
How does bubble sort push large numbers to the end?
What makes bubble sort simple in Python?
Why does bubble sort take significant time with large datasets?
How does insertion sort compare to bubble sort in efficiency?
What is the divide and conquer strategy and how does it work in sorting?
How does the merge operation play a critical role in merge sort?
How can print statements simplify debugging our algorithms?
What makes merge sort significantly faster than bubble sort?
How do merge operations lead to the final sorted array?
What is the role of the pivot in QuickSort's partitioning process?
How do pointers help in the partition operation of QuickSort?
Why is QuickSort efficient compared to Merge Sort in practice?
How do we sort notebooks based on likes efficiently?
What does a custom comparison function look like for objects?
How does merge sort handle object sorting in Python?
How do we sort notebooks by likes and titles effectively?
What makes a great custom comparison function for sorting?
How does this assignment template help streamline your coding tasks?
How do we create comprehensive test cases for the longest common subsequence algorithm?
What steps should we follow to build a recursive function for finding LCS?
How can visualizing the recursive tree help us understand the LCS algorithm better?
How does memoization significantly speed up recursive algorithms?
Can storing intermediate results really reduce computation from billions to hundreds?
What role do indices play in the efficiency of storing results?
What are the downsides of memoization in larger problems?
How does dynamic programming provide a solution to recursion issues?
What does the dynamic programming table represent in LCS?
How to visualize dynamic programming solutions effectively?
What strategies help avoid off-by-one errors when building tables?
How does the Knapsack problem illustrate decision-making in optimization?
How do we ensure our test cases cover all scenarios for the knapsack problem?
What are the risks of computing the maximum profit via recursion without memoization?
What is the structure and logic of the dynamic programming table for the knapsack problem?
What common mistakes occur in dynamic programming implementation?
How do we derive the time complexity in our dynamic programming solution?
Why is structuring questions important when representing graphs?
How to effectively represent graph connections with edges?
Why is adjacency list a more efficient graph representation?
What are the steps to create a graph class in Python?
What is the main difference between DFS and BFS in traversal?
How do traversal techniques affect data structure performance?
What are some practical applications of graph traversal algorithms?
How do Depth First Search and Breadth First Search differ in graph traversal?
What are some practical applications of graph traversal algorithms?
How does DFS navigate through graphs compared to BFS?
What common bug should you avoid when creating lists in Python?
How can you use the range function to generate multiple empty lists efficiently?
What is the advantage of using enumerate for cleaner code?
What is the purpose of heaps in data structures?
How do priority queues utilize heaps for efficiency?
What steps are involved in heap sort?
What makes Heap Sort stand out among sorting algorithms?
How does Heap Sort compare in performance to QuickSort and Merge Sort?
What are practical applications of Heap Sort in real-world data sorting?
What are the complexities of BFS and Dijkstra's algorithm?
How can a min-heap optimize the shortest path algorithm?
Why asking clarifying questions in interviews is beneficial?
What questions should you ask when the problem is unclear?
How can you ensure your solution handles all edge cases?
What does a brute-force solution look like in practice?
How can maintaining a running sum optimize our solution?
What are the steps in implementing the optimized algorithm using two pointers?
Why is it essential to check for off-by-one errors during coding?
How does the running sum technique help optimize the subarray sum problem?
What is the greedy approach in solving algorithmic problems?
How many steps are required to convert one string to another in the edit distance problem?
What key operations are performed in the recursive edit distance approach?
How does the solution differ depending on character match or mismatch?
How do we define the end cases for the recursive function effectively?
What corrections can improve the recursive solution for edit distance?
How do recursive calls impact the complexity of edit distance?
What are the benefits of using memoization for edit distance calculations?
How does memoization change the complexity?
What key operations apply for the edit distance algorithm?
How does the problem-solving template improve coding interviews?
How can forum discussions clear up your doubts during the learning process?
What's the value of collaborating with peers in study groups for mastering concepts?
Why is it essential to complete assignments and tackle optional questions?
Der Inhalt konzentriert sich darauf, die grundlegenden Konzepte von Datenstrukturen und Algorithmen zu lehren, wobei Python als Hauptsprache verwendet wird. Dazu gehört das Lernen über verschiedene Arten von Datenstrukturen wie Arrays, verkettete Listen, Stacks, Warteschlangen, Bäume und Graphen. Das Video geht auch auf algorithmische Techniken wie Sortieren, Suchen und das Durchlaufen von Graphen ein. Ein grosser Schwerpunkt liegt darauf, zu verstehen, wie diese Konzepte in realen Szenarien angewendet werden, um komplexe Probleme zu lösen. Am Ende dieser lehrreichen Erfahrung sollten die Lernenden ein solides Verständnis der Programmiergrundlagen erlangen, ihre Problemlösungsfähigkeiten verbessern und Vertrauen gewinnen, technische Interviews mit Leichtigkeit zu meistern.